Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/worktrees/agent-a03a93fd17ee7f4bd
Submodule agent-a03a93fd17ee7f4bd added at d4548e
1 change: 1 addition & 0 deletions .claude/worktrees/agent-a26866f310f7301e9
Submodule agent-a26866f310f7301e9 added at 5a2204
1 change: 1 addition & 0 deletions .claude/worktrees/agent-a877d7b21d08dcf4f
Submodule agent-a877d7b21d08dcf4f added at b60533
1 change: 1 addition & 0 deletions .claude/worktrees/agent-ab23fba714113176a
Submodule agent-ab23fba714113176a added at 3cf532
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
branches: [main, dev]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm

- run: npm ci
- run: npm run build
- run: npm test
33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Release

on:
release:
types: [published]
push:
tags:
- "v*"

permissions:
id-token: write
contents: read

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org

- run: npm ci
- run: npm run build
- run: npm test

- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# uptool

[![CI](https://github.com/pyeom/uptool/actions/workflows/ci.yml/badge.svg)](https://github.com/pyeom/uptool/actions/workflows/ci.yml)

Serve LLM-generated HTML files from your own machine via wildcard subdomains.

Your LLM runs `uptool deploy` → gets back a URL → you open it anywhere.
Expand Down Expand Up @@ -145,6 +147,29 @@ uptool deploy v2.html --update x7k2mq
# same URL, new content
```

### QR code

Print a scannable QR code for the URL, handy for pulling a deploy up on a phone:

```bash
uptool deploy dashboard.html --qr
```

With multiple files in one invocation, a QR is printed after each URL.

### Watch and redeploy

Keep the process running and redeploy in place whenever the source changes:

```bash
uptool deploy dashboard.html --watch
# ✓ http://x7k2mq.mydev.com
# Watching dashboard.html for changes... (Ctrl-C to stop)
# ↻ redeployed http://x7k2mq.mydev.com (14:32:07)
```

Works on a single file or a directory bundle, and combines with `--qr` (printed once, on the first deploy). Changes are debounced 300ms. `--watch` requires exactly one file/directory argument and can't be used with stdin. Stop with Ctrl-C.

### Protected deployments

Require a key to view (dashboards with semi-private data, drafts):
Expand Down Expand Up @@ -182,6 +207,19 @@ uptool list
uptool rm x7k2mq
```

### Admin page

```bash
uptool admin
```

Opens a 100% local, token-authenticated web UI (served by the internal API on
`127.0.0.1:<api_port>`, no CORS, no external assets or CDNs) listing every
deployment — slug, name, filename, created/expires as relative times, a lock
icon for protected deploys, a preview link to the public URL, and a Delete
button per row. Auto-refreshes every 10s. The token is passed once in the URL
and immediately scrubbed from the browser's address bar.

### Daemon control

```bash
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
},
"dependencies": {
"commander": "^12.1.0",
"qrcode-terminal": "^0.12.0",
"smol-toml": "^1.3.1",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"tsup": "^8.3.0",
"typescript": "^5.6.0",
Expand Down
12 changes: 12 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { statusCommand } from "./commands/status.js";
import { installServiceCommand } from "./commands/install-service.js";
import { touchCommand } from "./commands/touch.js";
import { openCommand } from "./commands/open.js";
import { adminCommand } from "./commands/admin.js";
import { rollbackCommand } from "./commands/rollback.js";
import { mcpCommand } from "./commands/mcp.js";
import {
Expand Down Expand Up @@ -48,6 +49,12 @@ program
"--protect [key]",
"Require Basic Auth to view (autogenerates a key when none is given)"
)
.option("--qr", "Print a QR code for the public URL", false)
.option(
"--watch",
"Watch the file/directory and redeploy on change (single target only)",
false
)
.action((files, opts) => deployCommand(files, opts));

program
Expand Down Expand Up @@ -117,6 +124,11 @@ program
.description("Reconfigure all settings interactively")
.action(() => configCommand());

program
.command("admin")
.description("Open the local admin web UI (100% local, token-authenticated)")
.action(() => adminCommand());

program
.command("mcp")
.description("Start MCP server (stdio, for Claude Code integration)")
Expand Down
26 changes: 26 additions & 0 deletions src/commands/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as fs from "node:fs";
import * as child_process from "node:child_process";
import { loadConfig, tokenPath } from "../config/index.js";

export async function adminCommand(): Promise<void> {
const config = loadConfig();

if (!fs.existsSync(tokenPath())) {
console.error("Auth token not found. Run: uptool init");
process.exitCode = 1;
return;
}
const token = fs.readFileSync(tokenPath(), "utf8").trim();

const url = `http://127.0.0.1:${config.api_port}/admin?token=${token}`;
console.log(`✓ Admin page: ${url}`);

const launcher =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";

child_process.spawn(launcher, [url], { stdio: "ignore", detached: true }).unref();
}
78 changes: 76 additions & 2 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { loadConfig, publicUrl, parseTtlMs } from "../config/index.js";
import qrcode from "qrcode-terminal";
import { loadConfig, publicUrl, parseTtlMs, type Config } from "../config/index.js";
import { callApi } from "../lib/api-client.js";
import { validateBundlePath } from "../storage/index.js";
import { debounce, formatTime } from "../lib/watch.js";

function readStdin(): Promise<string> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -88,9 +90,65 @@ async function buildBody(
return { html, filename: path.basename(filePath) };
}

/** Watch a file or directory and redeploy (in place, by slug) on change. */
function watchAndRedeploy(
target: string,
slug: string,
key: string | undefined,
config: Config
): void {
console.log(`\nWatching ${target} for changes... (Ctrl-C to stop)`);

const redeploy = debounce(async () => {
try {
const body = await buildBody(target);
body.slug = slug;
if (key) body.key = key;
const result = await callApi<{ slug?: string; error?: string }>(
config.api_port,
"POST",
"/deploy",
body
);
if (result.error) throw new Error(result.error);
const url = publicUrl(config, result.slug ?? slug);
console.log(`↻ redeployed ${url} (${formatTime()})`);
} catch (err) {
console.error(`Error redeploying: ${(err as Error).message}`);
}
}, 300);

const isDir = fs.statSync(target).isDirectory();

if (isDir) {
try {
fs.watch(target, { recursive: true }, () => redeploy());
return;
} catch {
// Recursive fs.watch unavailable on this platform/Node version — fall
// back to watching each file individually.
for (const { full } of walkDir(target, target)) {
try {
fs.watch(full, () => redeploy());
} catch {
// ignore files that can't be watched
}
}
}
} else {
fs.watch(target, () => redeploy());
}
}

export async function deployCommand(
filePaths: string[],
opts: { update?: string; name?: string; protect?: string | boolean }
opts: {
update?: string;
name?: string;
protect?: string | boolean;
qr?: boolean;
watch?: boolean;
}
): Promise<void> {
const config = loadConfig();

Expand All @@ -100,6 +158,11 @@ export async function deployCommand(
process.exit(1);
}

if (opts.watch && filePaths.length !== 1) {
console.error("--watch requires exactly one file or directory argument (no stdin).");
process.exit(1);
}

// --protect: true = autogenerate a key, string = user-supplied key
const key =
opts.protect === true
Expand All @@ -111,6 +174,8 @@ export async function deployCommand(
const expiry = ttlMs > 0 ? ` (expires in ${config.ttl})` : "";

let anyError = false;
let watchTarget: string | undefined;
let watchSlug: string | undefined;

for (const filePath of targets) {
const body = await buildBody(filePath);
Expand All @@ -131,11 +196,20 @@ export async function deployCommand(
const url = publicUrl(config, slug);
console.log(`✓ ${url}${expiry}`);
if (key) console.log(` key: ${key} (Basic Auth password — any username)`);
if (opts.qr) qrcode.generate(url, { small: true });
if (opts.watch && filePath) {
watchTarget = filePath;
watchSlug = slug;
}
} catch (err) {
console.error(`Error deploying ${filePath ?? "stdin"}: ${(err as Error).message}`);
anyError = true;
}
}

if (anyError) process.exit(1);

if (opts.watch && watchTarget && watchSlug) {
watchAndRedeploy(watchTarget, watchSlug, key, config);
}
}
Loading
Loading