diff --git a/AGENTS.md b/AGENTS.md index 13ff64c..1af4032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ Mishchenko. `UserPromptSubmit` hook's stdout into the prompt, and Codex reads a `PermissionRequest` hook's stdout as a decision. 4. **Never decide a permission.** The Codex `PermissionRequest` hook only observes. -5. **Never commit secrets.** A user's config (ntfy topic, token) lives in their config folder, never - in a repository. Never log the topic or a token. +5. **Never commit secrets.** A user's config (ntfy topic, tokens, the app pairing's token) lives in + their config folder, never in a repository. Never print or log the topic or a token. 6. **Never promise guaranteed delivery** in docs or output. Push is best-effort. 7. **No attribution trailers** in commits or pull requests: no `Co-Authored-By`, no "Generated with". @@ -37,7 +37,7 @@ Mishchenko. | Path | Contents | |---|---| -| `cli/src/` | The CLI: `main.ts` (commands), `hook.ts` (hook and signal logic), `state.ts` (arming), `events.ts` (protocol), `ntfy.ts`, `deliver.ts` (detached sending), `install.ts` (Codex), `skill.ts` (the alarm skill's text) | +| `cli/src/` | The CLI: `main.ts` (commands), `hook.ts` (hook and signal logic), `state.ts` (arming), `events.ts` (protocol), `ntfy.ts`, `app.ts` (pairing and the Code Alarm app's relay), `qr.ts` (the pairing QR; the encoder in `vendor/` is Project Nayuki's, MIT, kept as is), `deliver.ts` (detached sending), `install.ts` (Codex), `skill.ts` (the alarm skill's text) | | `cli/test/` | `node:test` suites; the privacy and hook tests are the important ones | | `cli/dist/code-alarm.mjs` | The bundled CLI, **committed** and built by `cli/build.mjs`; also the npm package's entry | | `plugins/claude-code/` | The Claude Code plugin: manifest, hooks, the skill (generated) and `bin/` (the bundle plus PATH shims) | diff --git a/CHANGELOG.md b/CHANGELOG.md index c84f773..535f234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f ## [Unreleased] +## [0.2.0] — 2026-10-16 + +### Added + +- The Code Alarm app transport, for the Android app now in testing: + - `pair ` pairs this computer with the phone through the Code Alarm relay. It sends only + the code and a name (`--name`, or "Windows PC", "Mac" or "Linux computer"; never the hostname). + - `unpair` removes this computer from the phone; `use ntfy|app` switches between the two. + - `status --last` shows what the phone did with the last alert: whether it rang, how long after + it was sent, and when it was stopped. + - Events go to the relay as they are, with the token only in a header. A failed push is retried + once with the same ID, which the relay dedupes. + - `pair` uses the Code Alarm relay at `https://relay.codealarm.workers.dev`; `--relay` or + `CODE_ALARM_RELAY` points it at another one, such as a local test relay. + +- QR pairing: `code-alarm pair` with no code shows a QR in the terminal for the Code Alarm app to + scan, and pairs once you tap Pair on the phone. The computer makes its own token and sends the + relay only its hash. `pair ` still takes the code the app shows. The QR is drawn with + Project Nayuki's QR Code generator (MIT), bundled with its notice. +- `docs/protocol-examples.json`: valid and invalid events, the contract the CLI and the Code Alarm + relay both test against. `protocol.md` now states the rules exactly: the label's characters, the + ID's form, the one-hour lifetime and the clock skew the relay allows. + +### Fixed + +- Codex's hooks run their own copy of the CLI, made by `install codex`, and an old copy can't read + settings a newer version wrote: its alerts stopped silently. `status` now says when that copy is + older than, or different from, the code-alarm you're running. `init`, `pair`, `unpair` and + `use` update it, unless it's the newer one. Only the copy changes, so Codex doesn't ask to trust + the hooks again. The bundle's banner now carries the version. + ## [0.1.0] — 2026-10-14 ### Added diff --git a/README.md b/README.md index 9f1fa23..945b5d5 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ reason. If it forgets, the alarm still rings when it stops, labelled *stopped*. `code-alarm status` shows what's set up, what's armed and the last alerts. `code-alarm disarm` cancels arming. +Codex's hooks run their own copy of code-alarm. After you update code-alarm, `status` says if +that copy is out of date, and `init`, `pair`, `unpair` and `use` update it for you. + ## How it works ```mermaid @@ -122,3 +125,7 @@ To try your working copy in Claude Code: `claude --plugin-dir plugins/claude-cod ## License [MIT](LICENSE). Not affiliated with Anthropic or OpenAI. + +The CLI bundles Project Nayuki's [QR Code generator library](https://www.nayuki.io/page/qr-code-generator-library) +(MIT), to draw the pairing QR in the terminal. Its notice is kept in `cli/src/vendor/qrcodegen.ts` +and at the end of the bundle. diff --git a/cli/build.mjs b/cli/build.mjs index dda6500..2f06bcc 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -19,8 +19,10 @@ await build({ format: "esm", target: "node22", minify: false, - legalComments: "none", - banner: { js: "#!/usr/bin/env node\n// code-alarm, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit." }, + // Keeps the MIT notice of the vendored QR encoder (src/vendor/qrcodegen.ts), as its licence requires. + legalComments: "eof", + // The version in the banner lets `code-alarm status` tell how old Codex's copy of the CLI is. + banner: { js: `#!/usr/bin/env node\n// code-alarm ${version}, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit.` }, define: { __VERSION__: JSON.stringify(version) }, }); diff --git a/cli/dist/code-alarm.mjs b/cli/dist/code-alarm.mjs index cf830c9..a766cb4 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -1,14 +1,17 @@ #!/usr/bin/env node -// code-alarm, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. +// code-alarm 0.2.0, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. // src/main.ts import { homedir as homedir2 } from "node:os"; import { resolve as resolve3 } from "node:path"; import { parseArgs } from "node:util"; +// src/app.ts +import { createHash, randomBytes as randomBytes2 } from "node:crypto"; + // src/config.ts import { randomBytes } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; // src/fsutil.ts @@ -28,6 +31,24 @@ var DEFAULT_NTFY_SERVER = "https://ntfy.sh"; function configPath(home) { return join(home, "config.json"); } +function readNtfy(value) { + const ntfy = value; + if (!ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") return void 0; + return { server: ntfy.server, topic: ntfy.topic, ...ntfy.token ? { token: ntfy.token } : {} }; +} +function readApp(value) { + const app = value; + if (!app) return void 0; + for (const key of ["relay", "deviceId", "senderId", "token", "name"]) if (typeof app[key] !== "string") return void 0; + return { + relay: app.relay, + deviceId: app.deviceId, + senderId: app.senderId, + token: app.token, + name: app.name, + pairedAt: typeof app.pairedAt === "number" ? app.pairedAt : 0 + }; +} function loadConfig(home) { const path = configPath(home); if (!existsSync(path)) return null; @@ -38,15 +59,18 @@ function loadConfig(home) { throw new ConfigError(`${path} isn't valid JSON. Fix it, or run \`code-alarm init --force\` to start over.`); } const config = raw; - const ntfy = config.ntfy; - if (config.transport !== "ntfy" || !ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") { - throw new ConfigError(`${path} is missing its ntfy settings. Run \`code-alarm init --force\`.`); - } - return { version: 1, transport: "ntfy", ntfy: { server: ntfy.server, topic: ntfy.topic, ...ntfy.token ? { token: ntfy.token } : {} } }; + const ntfy = readNtfy(config.ntfy); + const app = readApp(config.app); + if (config.transport === "app" && app) return { version: 1, transport: "app", app, ...ntfy ? { ntfy } : {} }; + if (config.transport === "ntfy" && ntfy) return { version: 1, transport: "ntfy", ntfy, ...app ? { app } : {} }; + throw new ConfigError(`${path} is missing its ${config.transport === "app" ? "pairing" : "ntfy"} settings. Run \`code-alarm init --force\` or \`code-alarm pair\`.`); } function saveConfig(home, config) { writeFileAtomic(configPath(home), JSON.stringify(config, null, 2) + "\n", 384); } +function removeConfig(home) { + rmSync(configPath(home), { force: true }); +} function newTopic() { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; const bytes = randomBytes(24); @@ -58,13 +82,204 @@ function isValidTopic(topic) { return /^[A-Za-z0-9_-]{1,64}$/.test(topic); } +// src/app.ts +var DEFAULT_RELAY = "https://relay.codealarm.workers.dev"; +var RELAY_ENV = "CODE_ALARM_RELAY"; +var LOCAL_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]); +function relayUrl(given, env = process.env) { + const raw = given ?? (env[RELAY_ENV] || DEFAULT_RELAY); + let url; + try { + url = new URL(raw); + } catch { + throw new ConfigError(`${raw} isn't a URL.`); + } + if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname))) { + throw new ConfigError("The relay must be an https:// address: the token travels with every request."); + } + if (url.search || url.hash || url.username || url.password) throw new ConfigError("The relay address takes no query, fragment or credentials."); + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} +function defaultName(platform = process.platform) { + return platform === "win32" ? "Windows PC" : platform === "darwin" ? "Mac" : platform === "linux" ? "Linux computer" : "Computer"; +} +var TIMEOUT_MS = 1e4; +async function call(request, fetchImpl, timeoutMs) { + const res = await fetchImpl(request.url, { ...request.init, signal: AbortSignal.timeout(timeoutMs) }); + const text = await res.text().catch(() => ""); + let body = {}; + try { + const parsed = text ? JSON.parse(text) : {}; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed; + } catch { + } + return { status: res.status, body }; +} +var message = (answer) => typeof answer.body.message === "string" ? answer.body.message : `the relay answered HTTP ${answer.status}`; +function pairRequest(relay, code, name) { + return { + url: `${relay}/v1/pair`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, name }) } + }; +} +async function pairWithPhone(relay, code, name, now, fetchImpl = fetch) { + let answer; + try { + answer = await call(pairRequest(relay, code, name), fetchImpl, TIMEOUT_MS); + } catch (e) { + return { ok: false, message: `couldn't reach the relay at ${relay} (${e instanceof Error ? e.name : "error"})` }; + } + const { sender_id, token, device_id, name: listedAs } = answer.body; + if (answer.status !== 201 || typeof sender_id !== "string" || typeof token !== "string" || typeof device_id !== "string") { + return { ok: false, message: message(answer) }; + } + return { + ok: true, + app: { relay, deviceId: device_id, senderId: sender_id, token, name: typeof listedAs === "string" ? listedAs : name, pairedAt: now } + }; +} +function newSenderToken() { + return `cas_${randomBytes2(32).toString("base64url")}`; +} +function pairingAskRequest(relay, name, token) { + const tokenHash = createHash("sha256").update(token).digest("hex"); + return { + url: `${relay}/v1/pair-requests`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, token_hash: tokenHash }) } + }; +} +async function askToPair(relay, name, fetchImpl = fetch) { + const token = newSenderToken(); + let answer; + try { + answer = await call(pairingAskRequest(relay, name, token), fetchImpl, TIMEOUT_MS); + } catch (e) { + return { ok: false, message: `couldn't reach the relay at ${relay} (${e instanceof Error ? e.name : "error"})` }; + } + const { id, code, url, expires_at } = answer.body; + if (answer.status !== 201 || typeof id !== "string" || typeof code !== "string" || typeof url !== "string" || typeof expires_at !== "number") { + return { ok: false, message: message(answer) }; + } + return { ok: true, ask: { relay, id, code, url, expiresAt: expires_at, token, name } }; +} +async function pollPairing(ask, now, fetchImpl = fetch) { + let answer; + try { + answer = await call( + { url: `${ask.relay}/v1/pair-requests/${encodeURIComponent(ask.id)}`, init: { method: "GET", headers: { authorization: `Bearer ${ask.token}` } } }, + fetchImpl, + TIMEOUT_MS + ); + } catch (e) { + return { status: "error", message: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } + const { status, sender_id, device_id, name } = answer.body; + if (answer.status === 200 && status === "approved" && typeof sender_id === "string" && typeof device_id === "string") { + return { + status: "approved", + app: { relay: ask.relay, deviceId: device_id, senderId: sender_id, token: ask.token, name: typeof name === "string" ? name : ask.name, pairedAt: now } + }; + } + if (answer.status === 200 && status === "pending") return { status: "pending" }; + if (answer.status === 200 && status === "expired") return { status: "expired" }; + return { status: "error", message: message(answer) }; +} +function appRequest(event, app) { + return { + url: `${app.relay}/v1/events`, + init: { + method: "POST", + headers: { authorization: `Bearer ${app.token}`, "content-type": "application/json" }, + body: JSON.stringify(event) + } + }; +} +async function sendApp(event, app, fetchImpl = fetch, options = {}) { + const { timeoutMs = TIMEOUT_MS, retryDelayMs = 3e3 } = options; + let result = { ok: false, detail: "not sent" }; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) await new Promise((done) => setTimeout(done, retryDelayMs)); + let answer; + try { + answer = await call(appRequest(event, app), fetchImpl, timeoutMs); + } catch (e) { + result = { ok: false, detail: `relay unreachable (${e instanceof Error ? e.name : "error"})` }; + continue; + } + if (answer.status === 200 && answer.body.status === "duplicate") return { ok: true, detail: "app: already sent" }; + if (answer.status === 200) { + return { ok: true, detail: answer.body.limited ? "app: sent as a notification (rate limit)" : "app: sent" }; + } + if (answer.status === 401) return { ok: false, detail: "this computer isn't paired any more: run `code-alarm pair`" }; + result = { ok: false, detail: `app: ${typeof answer.body.error === "string" ? answer.body.error : `HTTP ${answer.status}`}` }; + if (answer.status !== 502) return result; + } + return result; +} +async function unpairFromPhone(app, fetchImpl = fetch) { + try { + const answer = await call( + { url: `${app.relay}/v1/senders/${encodeURIComponent(app.senderId)}`, init: { method: "DELETE", headers: { authorization: `Bearer ${app.token}` } } }, + fetchImpl, + TIMEOUT_MS + ); + if (answer.status === 204) return { ok: true, detail: "removed" }; + if (answer.status === 401 || answer.status === 404) return { ok: true, detail: "already removed" }; + return { ok: false, detail: message(answer) }; + } catch (e) { + return { ok: false, detail: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } +} +async function fetchDelivery(app, id, fetchImpl = fetch) { + try { + const answer = await call( + { url: `${app.relay}/v1/events/${encodeURIComponent(id)}`, init: { method: "GET", headers: { authorization: `Bearer ${app.token}` } } }, + fetchImpl, + TIMEOUT_MS + ); + if (answer.status !== 200 || typeof answer.body.status !== "string" || typeof answer.body.sent_at !== "number") { + return { ok: false, message: answer.status === 404 ? "the relay has no record of it (it keeps a week)" : message(answer) }; + } + return { ok: true, delivery: answer.body }; + } catch (e) { + return { ok: false, message: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } +} +var seconds = (ms) => `${(Math.max(0, ms) / 1e3).toFixed(1)} s`; +function describeDelivery(d) { + const rang = d.ringing_at ? `${seconds(d.ringing_at - d.sent_at)} after the alert was sent` : ""; + switch (d.status) { + case "sending": + case "sent": + return "sent to the phone; it hasn't answered yet (it may be off, or offline)"; + case "failed": + return `not delivered${d.detail ? `: ${d.detail}` : ""}`; + case "received": + return "received by the phone"; + case "ringing": + return `ringing, ${rang}`; + case "notified": + return `shown as a notification, ${rang}`; + case "snoozed": + return `snoozed; it rang ${rang}`; + case "stopped": + return `rang ${rang}${d.ended_at && d.ringing_at ? `, stopped after ${seconds(d.ended_at - d.ringing_at)}` : ""}`; + case "expired": + return "the phone got it after it expired, so it stayed silent"; + case "duplicate": + return "the phone had already had it, so it stayed silent"; + default: + return d.status; + } +} + // src/deliver.ts import { spawn } from "node:child_process"; import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync } from "node:fs"; import { join as join2 } from "node:path"; // src/events.ts -import { createHash, randomUUID } from "node:crypto"; +import { createHash as createHash2, randomUUID } from "node:crypto"; var EVENT_FIELDS = ["v", "id", "kind", "level", "label", "agent", "sent_at", "expires_at"]; var KINDS = ["done", "needs_input", "failed", "stopped", "test"]; var DEFAULT_TTL_MS = 30 * 6e4; @@ -75,7 +290,7 @@ function labelFor(dir) { } function eventId(parts) { if (!parts || parts.some((part) => !part)) return randomUUID(); - const hex = createHash("sha256").update(parts.join("|")).digest("hex"); + const hex = createHash2("sha256").update(parts.join("|")).digest("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } function buildEvent(args) { @@ -158,11 +373,28 @@ function recentLog(home, count) { } async function sendNow(home, event, fetchImpl) { const config = loadConfig(home); - if (!config) return { ok: false, detail: "not set up: run `code-alarm init`" }; - const result = await sendNtfy(event, config.ntfy, fetchImpl); + if (!config) return { ok: false, detail: "not set up: run `code-alarm init` or `code-alarm pair`" }; + const result = config.transport === "app" && config.app ? await sendApp(event, config.app, fetchImpl) : await sendNtfy(event, config.ntfy, fetchImpl); logLine(home, `${event.kind}/${event.level} ${event.agent} "${event.label}" ${result.ok ? "sent" : "FAILED"} (${result.detail})`); + if (result.ok && config.transport === "app") rememberLast(home, event); return result; } +var lastPath = (home) => join2(home, "last-event.json"); +function rememberLast(home, event) { + try { + const last = { id: event.id, kind: event.kind, level: event.level, label: event.label, sentAt: event.sent_at }; + writeFileAtomic(lastPath(home), JSON.stringify(last)); + } catch { + } +} +function lastEvent(home) { + try { + const last = JSON.parse(readFileSync2(lastPath(home), "utf8")); + return typeof last.id === "string" && typeof last.sentAt === "number" ? last : null; + } catch { + return null; + } +} function sendDetached(event, script = process.argv[1]) { const payload = Buffer.from(JSON.stringify(event)).toString("base64url"); runDetached(script, ["__send", payload]); @@ -213,12 +445,12 @@ function formatDuration(ms) { } // src/hook.ts -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash4 } from "node:crypto"; import { resolve as resolve2 } from "node:path"; // src/state.ts -import { createHash as createHash2 } from "node:crypto"; -import { closeSync, existsSync as existsSync2, linkSync, mkdirSync as mkdirSync3, openSync, readdirSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, statSync as statSync2 } from "node:fs"; +import { createHash as createHash3 } from "node:crypto"; +import { closeSync, existsSync as existsSync2, linkSync, mkdirSync as mkdirSync3, openSync, readdirSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync2 } from "node:fs"; import { isAbsolute, join as join3, relative, resolve } from "node:path"; var NAP_KEY = "nap"; function normalize(dir, platform = process.platform) { @@ -226,7 +458,7 @@ function normalize(dir, platform = process.platform) { return platform === "win32" ? full.toLowerCase() : full; } function keyFor(dir) { - return createHash2("sha256").update(normalize(dir)).digest("hex").slice(0, 16); + return createHash3("sha256").update(normalize(dir)).digest("hex").slice(0, 16); } var armedDir = (home) => join3(home, "armed"); var signalFile = (home, key) => join3(home, "signaled", `${key}.json`); @@ -248,7 +480,7 @@ function startFresh(home, key) { clearSignaled(home, key); const dir = join3(home, "sent"); if (!existsSync2(dir)) return; - for (const file of readdirSync(dir)) if (file.startsWith(`${key}-`)) rmSync(join3(dir, file), { force: true }); + for (const file of readdirSync(dir)) if (file.startsWith(`${key}-`)) rmSync2(join3(dir, file), { force: true }); } function listArmings(home, now) { const dir = armedDir(home); @@ -260,12 +492,12 @@ function listArmings(home, now) { try { const arming = JSON.parse(readFileSync3(path, "utf8")); if (typeof arming.until !== "number" || arming.until <= now) { - rmSync(path, { force: true }); + rmSync2(path, { force: true }); continue; } found.push({ ...arming, key: file.slice(0, -".json".length) }); } catch { - rmSync(path, { force: true }); + rmSync2(path, { force: true }); } } return found; @@ -291,7 +523,7 @@ function claim(home, arming) { renameSync2(path, claimed); } catch (e) { if (e.code === "ENOENT") return false; - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return true; } try { @@ -301,12 +533,12 @@ function claim(home, arming) { linkSync(claimed, path); } catch { } - rmSync(claimed, { force: true }); + rmSync2(claimed, { force: true }); return false; } } catch { } - rmSync(claimed, { force: true }); + rmSync2(claimed, { force: true }); return true; } function claimEvent(home, id, now) { @@ -316,7 +548,7 @@ function claimEvent(home, id, now) { for (const file of readdirSync(dir)) { try { const path = join3(dir, file); - if (now - statSync2(path).mtimeMs > 24 * 60 * 6e4) rmSync(path, { force: true }); + if (now - statSync2(path).mtimeMs > 24 * 60 * 6e4) rmSync2(path, { force: true }); } catch { } } @@ -329,18 +561,18 @@ function claimEvent(home, id, now) { function disarmProject(home, dir) { const path = join3(armedDir(home), `${keyFor(dir)}.json`); const existed = existsSync2(path); - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return existed; } function disarmAll(home) { const dir = armedDir(home); if (!existsSync2(dir)) return 0; const files = readdirSync(dir).filter((file) => file.endsWith(".json")); - for (const file of files) rmSync(join3(dir, file), { force: true }); + for (const file of files) rmSync2(join3(dir, file), { force: true }); return files.length; } function signalKey(armingKey, sessionId) { - return sessionId ? `session-${createHash2("sha256").update(sessionId).digest("hex").slice(0, 16)}` : armingKey; + return sessionId ? `session-${createHash3("sha256").update(sessionId).digest("hex").slice(0, 16)}` : armingKey; } function markSignaled(home, key, now, kind) { writeFileAtomic(signalFile(home, key), JSON.stringify({ at: now, kind })); @@ -357,7 +589,7 @@ function signaledKind(home, key) { } } function clearSignaled(home, key) { - rmSync(signalFile(home, key), { force: true }); + rmSync2(signalFile(home, key), { force: true }); } var pendingFile = (home, id) => join3(home, "pending", `${id}.json`); var sessionPrefix = (sessionId) => `${signalKey("", sessionId)}-`; @@ -372,7 +604,7 @@ function takePending(home, id, at) { try { const pending = JSON.parse(readFileSync3(path, "utf8")); if (pending.at !== at) return null; - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return pending; } catch { return null; @@ -381,14 +613,14 @@ function takePending(home, id, at) { function clearPending(home, id) { const path = pendingFile(home, id); const existed = existsSync2(path); - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return existed; } function clearSessionPending(home, sessionId) { const dir = join3(home, "pending"); if (!existsSync2(dir)) return; const prefix = sessionPrefix(sessionId); - for (const file of readdirSync(dir)) if (file.startsWith(prefix)) rmSync(join3(dir, file), { force: true }); + for (const file of readdirSync(dir)) if (file.startsWith(prefix)) rmSync2(join3(dir, file), { force: true }); } function rateLimited(home, key, kind, now, windowMs) { const path = sentFile(home, key, kind); @@ -423,7 +655,7 @@ function parseHookInput(raw) { turnId: str(data.turn_id), promptId: str(data.prompt_id), command, - requestHash: request === void 0 ? void 0 : createHash3("sha256").update(request).digest("hex").slice(0, 16) + requestHash: request === void 0 ? void 0 : createHash4("sha256").update(request).digest("hex").slice(0, 16) }; } catch { return null; @@ -659,7 +891,7 @@ function sessionFromEnv(agent, env) { } // src/install.ts -import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmdirSync, rmSync as rmSync2 } from "node:fs"; +import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, rmdirSync, rmSync as rmSync3 } from "node:fs"; import { dirname as dirname2, join as join4 } from "node:path"; // src/skill.ts @@ -756,6 +988,12 @@ function readHooksFile(path) { } throw new Error(`${path} isn't valid JSON, so code-alarm won't touch it. Fix or move it, then run this again.`); } +function copyAtomic(from, to) { + mkdirSync4(dirname2(to), { recursive: true }); + const temp = `${to}.${process.pid}.tmp`; + copyFileSync(from, temp); + renameSync3(temp, to); +} var isOurFile = (path, mark) => readFileSync4(path, "utf8").includes(mark); function setAside(path, mark, now, notes) { if (!existsSync3(path) || isOurFile(path, mark)) return; @@ -769,7 +1007,7 @@ function removeOurs(path, mark, notes) { notes.push(`Left ${path} alone: code-alarm didn't write it`); return; } - rmSync2(path, { force: true }); + rmSync3(path, { force: true }); notes.push(`Removed ${path}`); } function installCodex(args) { @@ -779,8 +1017,7 @@ function installCodex(args) { throw new Error(`${paths.cli} contains " $ or \`, which can't be quoted safely in a hook command. Set CODE_ALARM_HOME to another folder and run this again.`); } const current = readHooksFile(paths.hooksFile); - mkdirSync4(dirname2(paths.cli), { recursive: true }); - copyFileSync(args.bundle, paths.cli); + copyAtomic(args.bundle, paths.cli); notes.push(`Copied the CLI to ${paths.cli}`); if (existsSync3(paths.hooksFile)) { const backup = `${paths.hooksFile}.code-alarm-backup-${args.now}`; @@ -814,9 +1051,64 @@ function uninstallCodex(args) { rmdirSync(paths.skillDir); } catch { } - rmSync2(paths.cli, { force: true }); + rmSync3(paths.cli, { force: true }); return notes; } +var BUNDLE_MARK = "Generated by cli/build.mjs"; +function bundleVersion(text) { + return /^\/\/ code-alarm (\d+\.\d+\.\d+[^\s,]*), MIT License/m.exec(text.slice(0, 1e3))?.[1] ?? null; +} +function compareVersions(a, b) { + const parts = (v) => v.split(/[-+]/)[0].split(".").map(Number); + const [x, y] = [parts(a), parts(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1; + return 0; +} +function codexCopy(alarmHome2, userHome, running) { + const paths = codexPaths(alarmHome2, userHome); + let bundle; + try { + if (!running || !hooksRun(paths)) return { state: "none" }; + bundle = readFileSync4(running); + } catch { + return { state: "none" }; + } + const text = bundle.toString("utf8", 0, 1e3); + if (!text.includes(BUNDLE_MARK)) return { state: "none" }; + const version = bundleVersion(text); + if (!existsSync3(paths.cli)) return { state: "stale", path: paths.cli, copyVersion: null, version, missing: true }; + const copy = readFileSync4(paths.cli); + if (copy.equals(bundle)) return { state: "current" }; + const copyVersion = bundleVersion(copy.toString("utf8", 0, 1e3)); + if (copyVersion && version && compareVersions(copyVersion, version) > 0) return { state: "newer", path: paths.cli, copyVersion, version }; + return { state: "stale", path: paths.cli, copyVersion, version, missing: false }; +} +function hooksRun(paths) { + const command = codexCommand(paths.cli); + const file = readHooksFile(paths.hooksFile); + return Object.values(file.hooks ?? {}).some((groups) => groups.some((group) => (group.hooks ?? []).some((h) => h.command === command))); +} +function describeCodexCopy(copy) { + if (copy.state === "stale") { + const which = copy.missing ? "but the copy of code-alarm they run is missing" : copy.copyVersion && copy.copyVersion !== copy.version ? `but they run an older code-alarm (${copy.copyVersion}; this is ${copy.version ?? "unknown"})` : "but they run a different build of code-alarm than this one"; + return `${which}, so they may send nothing. \`code-alarm install codex\` updates it; the hooks stay the same, so Codex won't ask to trust them again`; + } + if (copy.state === "newer") return `running a newer code-alarm (${copy.copyVersion}) than this one (${copy.version ?? "unknown"})`; + return null; +} +function refreshCodexCopy(alarmHome2, userHome, running) { + const copy = codexCopy(alarmHome2, userHome, running); + if (copy.state === "newer") { + return `Codex runs a newer code-alarm (${copy.copyVersion}) than this one, so its copy was left alone. If its alerts stop, update this code-alarm.`; + } + if (copy.state !== "stale") return null; + try { + copyAtomic(running, copy.path); + } catch (e) { + return `Couldn't update the copy of code-alarm that Codex's hooks run (${e instanceof Error ? e.message : String(e)}), so they may send nothing. Run \`code-alarm install codex\` to update it.`; + } + return `Updated the copy of code-alarm that Codex's hooks run, so they can read the new settings.`; +} function codexInstalled(alarmHome2, userHome) { try { const file = readHooksFile(codexPaths(alarmHome2, userHome).hooksFile); @@ -835,8 +1127,773 @@ function alarmHome(env = process.env, platform = process.platform) { return join5(env.XDG_CONFIG_HOME || join5(homedir(), ".config"), "code-alarm"); } +// src/vendor/qrcodegen.ts +var qrcodegen; +((qrcodegen2) => { + class QrCode { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + constructor(version, errorCorrectionLevel, dataCodewords, msk) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) + throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + let row = []; + for (let i = 0; i < this.size; i++) + row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); + this.isFunction.push(row.slice()); + } + this.drawFunctionPatterns(); + const allCodewords = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + if (msk == -1) { + let minPenalty = 1e9; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); + this.drawFormatBits(msk); + this.isFunction = []; + } + version; + errorCorrectionLevel; + /*-- Static factory functions (high level) --*/ + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + static encodeText(text, ecl) { + const segs = qrcodegen2.QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + static encodeBinary(data, ecl) { + const seg = qrcodegen2.QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + /*-- Static factory functions (mid level) --*/ + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) { + if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) || mask < -1 || mask > 7) + throw new RangeError("Invalid value"); + let version; + let dataUsedBits; + for (version = minVersion; ; version++) { + const dataCapacityBits2 = QrCode.getNumDataCodewords(version, ecl) * 8; + const usedBits = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits2) { + dataUsedBits = usedBits; + break; + } + if (version >= maxVersion) + throw new RangeError("Data too long"); + } + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) + ecl = newEcl; + } + let bb = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) + bb.push(b); + } + assert(bb.length == dataUsedBits); + const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - bb.length % 8) % 8, bb); + assert(bb.length % 8 == 0); + for (let padByte = 236; bb.length < dataCapacityBits; padByte ^= 236 ^ 17) + appendBits(padByte, 8, bb); + let dataCodewords = []; + while (dataCodewords.length * 8 < bb.length) + dataCodewords.push(0); + bb.forEach((b, i) => dataCodewords[i >>> 3] |= b << 7 - (i & 7)); + return new QrCode(version, ecl, dataCodewords, mask); + } + /*-- Fields --*/ + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + size; + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + mask; + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + modules = []; + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + isFunction = []; + /*-- Accessor methods --*/ + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + getModule(x, y) { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; + } + /*-- Private helper methods for constructor: Drawing function modules --*/ + // Reads this object's version field, and draws and marks all function modules. + drawFunctionPatterns() { + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + const alignPatPos = this.getAlignmentPatternPositions(); + const numAlign = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + this.drawFormatBits(0); + this.drawVersion(); + } + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + drawFormatBits(mask) { + const data = this.errorCorrectionLevel.formatBits << 3 | mask; + let rem = data; + for (let i = 0; i < 10; i++) + rem = rem << 1 ^ (rem >>> 9) * 1335; + const bits = (data << 10 | rem) ^ 21522; + assert(bits >>> 15 == 0); + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); + } + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + drawVersion() { + if (this.version < 7) + return; + let rem = this.version; + for (let i = 0; i < 12; i++) + rem = rem << 1 ^ (rem >>> 11) * 7973; + const bits = this.version << 12 | rem; + assert(bits >>> 18 == 0); + for (let i = 0; i < 18; i++) { + const color = getBit(bits, i); + const a = this.size - 11 + i % 3; + const b = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + drawFinderPattern(x, y) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const xx = x + dx; + const yy = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + drawAlignmentPattern(x, y) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + setFunctionModule(x, y, isDark) { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + /*-- Private helper methods for constructor: Codewords and masking --*/ + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + addEccAndInterleave(data) { + const ver = this.version; + const ecl = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks = numBlocks - rawCodewords % numBlocks; + const shortBlockLen = Math.floor(rawCodewords / numBlocks); + let blocks = []; + const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); + k += dat.length; + const ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) + dat.push(0); + blocks.push(dat.concat(ecc)); + } + let result = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + drawCodewords(data) { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i = 0; + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right == 6) + right = 5; + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = (right + 1 & 2) == 0; + const y = upward ? this.size - 1 - vert : vert; + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + } + } + } + assert(i == data.length * 8); + } + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + applyMask(mask) { + if (mask < 0 || mask > 7) + throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + case 7: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + default: + throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + getPenaltyScore() { + let result = 0; + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) + result += QrCode.PENALTY_N1; + else if (runX > 5) + result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) + result += QrCode.PENALTY_N1; + else if (runY > 5) + result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color = this.modules[y][x]; + if (color == this.modules[y][x + 1] && color == this.modules[y + 1][x] && color == this.modules[y + 1][x + 1]) + result += QrCode.PENALTY_N2; + } + } + let dark = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total = this.size * this.size; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); + return result; + } + /*-- Private helper functions --*/ + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + getAlignmentPatternPositions() { + if (this.version == 1) + return []; + else { + const numAlign = Math.floor(this.version / 7) + 2; + const step = Math.floor((this.version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4)) * 2; + let result = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + static getNumRawDataModules(ver) { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) + result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + static getNumDataCodewords(ver, ecl) { + return Math.floor(QrCode.getNumRawDataModules(ver) / 8) - QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + } + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + static reedSolomonComputeDivisor(degree) { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + let result = []; + for (let i = 0; i < degree - 1; i++) + result.push(0); + result.push(1); + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) + result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 2); + } + return result; + } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + static reedSolomonComputeRemainder(data, divisor) { + let result = divisor.map((_) => 0); + for (const b of data) { + const factor = b ^ result.shift(); + result.push(0); + divisor.forEach((coef, i) => result[i] ^= QrCode.reedSolomonMultiply(coef, factor)); + } + return result; + } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + static reedSolomonMultiply(x, y) { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + let z = 0; + for (let i = 7; i >= 0; i--) { + z = z << 1 ^ (z >>> 7) * 285; + z ^= (y >>> i & 1) * x; + } + assert(z >>> 8 == 0); + return z; + } + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + finderPenaltyCountPatterns(runHistory) { + const n = runHistory[1]; + assert(n <= this.size * 3); + const core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); + } + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) { + if (currentRunColor) { + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + finderPenaltyAddHistory(currentRunLength, runHistory) { + if (runHistory[0] == 0) + currentRunLength += this.size; + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + /*-- Constants and tables --*/ + // The minimum version number supported in the QR Code Model 2 standard. + static MIN_VERSION = 1; + // The maximum version number supported in the QR Code Model 2 standard. + static MAX_VERSION = 40; + // For use in getPenaltyScore(), when evaluating which mask is best. + static PENALTY_N1 = 3; + static PENALTY_N2 = 3; + static PENALTY_N3 = 40; + static PENALTY_N4 = 10; + static ECC_CODEWORDS_PER_BLOCK = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // Low + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + // Medium + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // Quartile + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30] + // High + ]; + static NUM_ERROR_CORRECTION_BLOCKS = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + // Low + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + // Medium + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + // Quartile + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81] + // High + ]; + } + qrcodegen2.QrCode = QrCode; + function appendBits(val, len, bb) { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for (let i = len - 1; i >= 0; i--) + bb.push(val >>> i & 1); + } + function getBit(x, i) { + return (x >>> i & 1) != 0; + } + function assert(cond) { + if (!cond) + throw new Error("Assertion error"); + } + class QrSegment { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + constructor(mode, numChars, bitData) { + this.mode = mode; + this.numChars = numChars; + this.bitData = bitData; + if (numChars < 0) + throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); + } + mode; + numChars; + bitData; + /*-- Static factory functions (mid level) --*/ + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + static makeBytes(data) { + let bb = []; + for (const b of data) + appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + static makeNumeric(digits) { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb = []; + for (let i = 0; i < digits.length; ) { + const n = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static makeAlphanumeric(text) { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb = []; + let i; + for (i = 0; i + 2 <= text.length; i += 2) { + let temp = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + static makeSegments(text) { + if (text == "") + return []; + else if (QrSegment.isNumeric(text)) + return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) + return [QrSegment.makeAlphanumeric(text)]; + else + return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + static makeEci(assignVal) { + let bb = []; + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) + appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(2, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1e6) { + appendBits(6, 3, bb); + appendBits(assignVal, 21, bb); + } else + throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + static isNumeric(text) { + return QrSegment.NUMERIC_REGEX.test(text); + } + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static isAlphanumeric(text) { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + /*-- Methods --*/ + // Returns a new copy of the data bits of this segment. + getData() { + return this.bitData.slice(); + } + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + static getTotalBits(segs, version) { + let result = 0; + for (const seg of segs) { + const ccbits = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) + return Infinity; + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + // Returns a new array of bytes representing the given string encoded in UTF-8. + static toUtf8ByteArray(str2) { + str2 = encodeURI(str2); + let result = []; + for (let i = 0; i < str2.length; i++) { + if (str2.charAt(i) != "%") + result.push(str2.charCodeAt(i)); + else { + result.push(parseInt(str2.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + /*-- Constants --*/ + // Describes precisely all strings that are encodable in numeric mode. + static NUMERIC_REGEX = /^[0-9]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + static ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/; + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + static ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + } + qrcodegen2.QrSegment = QrSegment; +})(qrcodegen || (qrcodegen = {})); +((qrcodegen2) => { + let QrCode; + ((QrCode2) => { + class Ecc { + // The QR Code can tolerate about 30% erroneous codewords + /*-- Constructor and fields --*/ + constructor(ordinal, formatBits) { + this.ordinal = ordinal; + this.formatBits = formatBits; + } + ordinal; + formatBits; + /*-- Constants --*/ + static LOW = new Ecc(0, 1); + // The QR Code can tolerate about 7% erroneous codewords + static MEDIUM = new Ecc(1, 0); + // The QR Code can tolerate about 15% erroneous codewords + static QUARTILE = new Ecc(2, 3); + // The QR Code can tolerate about 25% erroneous codewords + static HIGH = new Ecc(3, 2); + } + QrCode2.Ecc = Ecc; + })(QrCode = qrcodegen2.QrCode || (qrcodegen2.QrCode = {})); +})(qrcodegen || (qrcodegen = {})); +((qrcodegen2) => { + let QrSegment; + ((QrSegment2) => { + class Mode { + /*-- Constructor and fields --*/ + constructor(modeBits, numBitsCharCount) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } + modeBits; + numBitsCharCount; + /*-- Constants --*/ + static NUMERIC = new Mode(1, [10, 12, 14]); + static ALPHANUMERIC = new Mode(2, [9, 11, 13]); + static BYTE = new Mode(4, [8, 16, 16]); + static KANJI = new Mode(8, [8, 10, 12]); + static ECI = new Mode(7, [0, 0, 0]); + /*-- Method --*/ + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + numCharCountBits(ver) { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + } + QrSegment2.Mode = Mode; + })(QrSegment = qrcodegen2.QrSegment || (qrcodegen2.QrSegment = {})); +})(qrcodegen || (qrcodegen = {})); + +// src/qr.ts +function qrModules(text) { + const qr = qrcodegen.QrCode.encodeText(text, qrcodegen.QrCode.Ecc.MEDIUM); + const rows = []; + for (let y = 0; y < qr.size; y++) { + const row = []; + for (let x = 0; x < qr.size; x++) row.push(qr.getModule(x, y)); + rows.push(row); + } + return rows; +} +var QUIET = 2; +function qrForTerminal(text) { + const modules = qrModules(text); + const size = modules.length + QUIET * 2; + const dark = (x, y) => { + const mx = x - QUIET; + const my = y - QUIET; + return mx >= 0 && my >= 0 && mx < modules.length && my < modules.length && modules[my][mx]; + }; + const lines = []; + for (let y = 0; y < size; y += 2) { + let line = ""; + for (let x = 0; x < size; x++) { + const top = dark(x, y); + const bottom = y + 1 < size && dark(x, y + 1); + line += top && bottom ? "\u2588" : top ? "\u2580" : bottom ? "\u2584" : " "; + } + lines.push(`\x1B[30;47m${line}\x1B[0m`); + } + return lines.join("\n"); +} + // src/main.ts -var VERSION = true ? "0.1.0" : "dev"; +var VERSION = true ? "0.2.0" : "dev"; var DEFAULT_ARM_MS = 12 * HOUR; var HELP = `code-alarm ${VERSION}: a real alarm on your phone when Claude Code or Codex finishes or needs you. @@ -846,13 +1903,21 @@ Setup code-alarm test [--notify] Ring the phone now (or send a plain notification) code-alarm install codex Add Code Alarm's hooks and skill to Codex (uninstall codex removes them) +The Code Alarm app (in testing) + code-alarm pair [] [--name NAME] [--relay URL] + Pair with your phone: shows a QR to scan in the app, or takes the + code the app shows. Alerts then go through the app + code-alarm unpair Remove this computer from the phone + code-alarm use ntfy|app Switch between ntfy and the app when both are set up + Arming (only armed work rings) code-alarm arm [--for 12h] [--notify] Ring when the agent in this folder next finishes, stops or needs you code-alarm nap <2h> [--notify] Ring for every session until the time is up --notify: one ordinary notification instead of the alarm code-alarm disarm [--all] Cancel arming for this folder (or everything) - code-alarm status What's set up, what's armed, the last alerts + code-alarm status [--last] What's set up, what's armed, the last alerts + --last: what the phone did with the last alert (app only) Run by the agent (its skill says when) code-alarm done | needs-you | failed @@ -891,9 +1956,36 @@ function detectAgent(env) { return "cli"; } var clock = (ms) => new Date(ms).toTimeString().slice(0, 5); +var runningScript = () => process.argv[1] ? resolve3(process.argv[1]) : void 0; +function refreshCodex(home) { + const note = refreshCodexCopy(home, homedir2(), runningScript()); + if (note) console.log(note); +} function setupHint() { return "Code Alarm isn't set up on this computer yet. Run `code-alarm init` in a terminal."; } +var POLL_MS = Number(process.env.CODE_ALARM_POLL_MS) || 2500; +async function waitForPhone(ask) { + console.log("Open Code Alarm on your phone, tap Pair a computer, then Scan QR code:\n"); + console.log(qrForTerminal(ask.url.toUpperCase())); + console.log(` +(code ${ask.code}, works once, until ${clock(ask.expiresAt)})`); + console.log('No camera? In the app, choose "Show a code instead" and run: code-alarm pair '); + console.log("Waiting for your phone\u2026 Ctrl-C stops."); + let warned = false; + while (Date.now() < ask.expiresAt) { + await new Promise((done) => setTimeout(done, POLL_MS)); + const result = await pollPairing(ask, Date.now()); + if (result.status === "approved") return result.app; + if (result.status === "expired") break; + if (result.status === "error" && !warned) { + console.log(`Still waiting (${result.message}).`); + warned = true; + } + } + console.log("The code ran out before a phone approved it. Run `code-alarm pair` again."); + return null; +} async function main(argv) { const [command, ...rest] = argv; const home = alarmHome(); @@ -933,8 +2025,13 @@ async function main(argv) { args: rest, options: { server: { type: "string" }, topic: { type: "string" }, token: { type: "string" }, force: { type: "boolean" } } }); - const existing = values.force ? null : loadConfig(home); - if (existing) { + let existing = null; + try { + existing = loadConfig(home); + } catch (e) { + if (!values.force) throw e; + } + if (existing?.ntfy && !values.force) { console.log(`Already set up: ntfy topic ${existing.ntfy.topic} on ${existing.ntfy.server}.`); console.log("Run `code-alarm init --force` to start over with a new topic."); return 0; @@ -942,7 +2039,11 @@ async function main(argv) { const topic = values.topic ?? newTopic(); if (!isValidTopic(topic)) throw new ConfigError("A topic can only use letters, digits, - and _ (at most 64)."); const server = (values.server ?? DEFAULT_NTFY_SERVER).replace(/\/+$/, ""); - saveConfig(home, { version: 1, transport: "ntfy", ntfy: { server, topic, ...values.token ? { token: values.token } : {} } }); + const ntfy = { server, topic, ...values.token ? { token: values.token } : {} }; + const app = existing?.app; + saveConfig(home, app ? { version: 1, transport: "app", app, ntfy } : { version: 1, transport: "ntfy", ntfy }); + refreshCodex(home); + if (app) console.log("This computer is paired with the Code Alarm app, which still sends: `code-alarm use ntfy` switches to ntfy.\n"); console.log(`Code Alarm is set up. Server: ${server} @@ -1016,13 +2117,14 @@ Then connect your agent: return 0; } case "status": { + const { values } = parseArgs({ args: rest, options: { last: { type: "boolean" } } }); const config = loadConfig(home); - console.log( - config ? ( - // Never any part of the topic: people paste this output into bug reports. - `Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}` - ) : "Not set up: run `code-alarm init`." - ); + if (!config) console.log("Not set up: run `code-alarm init`."); + else if (config.transport === "app" && config.app) { + console.log(`Transport: Code Alarm app, paired as "${config.app.name}" through ${config.app.relay}${config.ntfy ? " (ntfy is set up too)" : ""}`); + } else if (config.ntfy) { + console.log(`Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}${config.app ? " (paired with the app too)" : ""}`); + } const now = Date.now(); const armings = listArmings(home, now); console.log(armings.length ? "Armed:" : "Nothing is armed."); @@ -1031,10 +2133,21 @@ Then connect your agent: const level = arming.level === "notify" ? ", notification only" : ""; console.log(` - ${what} until ${clock(arming.until)} (${formatDuration(arming.until - now)} left${level})`); } - console.log(`Codex hooks: ${codexInstalled(home, homedir2()) ? "installed" : "not installed"}`); + const copy = describeCodexCopy(codexCopy(home, homedir2(), runningScript())); + console.log(`Codex hooks: ${codexInstalled(home, homedir2()) ? `installed${copy ? `, ${copy}` : ""}` : "not installed"}`); const log = recentLog(home, 5); if (log.length) console.log(`Last activity: ${log.map((line) => ` ${line}`).join("\n")}`); + if (values.last) { + const last = lastEvent(home); + if (config?.transport !== "app" || !config.app) console.log("Last alert: --last needs the Code Alarm app; ntfy doesn't report back."); + else if (!last) console.log("Last alert: none sent through the app yet."); + else { + const result = await fetchDelivery(config.app, last.id); + const what = `${last.kind}/${last.level} "${last.label}" at ${clock(last.sentAt)}`; + console.log(`Last alert: ${what}: ${result.ok ? describeDelivery(result.delivery) : `unknown, ${result.message}`}`); + } + } return 0; } case "done": @@ -1046,13 +2159,82 @@ ${log.map((line) => ` ${line}`).join("\n")}`); console.log(result.message); return 0; } + case "pair": { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { name: { type: "string" }, relay: { type: "string" } } + }); + const code = positionals[0]; + const relay = relayUrl(values.relay); + const name = values.name?.trim() || defaultName(); + let existing = null; + try { + existing = loadConfig(home); + } catch { + } + let app; + if (code) { + const result = await pairWithPhone(relay, code, name, Date.now()); + if (!result.ok) { + console.log(`Couldn't pair: ${result.message}`); + return 1; + } + app = result.app; + } else { + const asked = await askToPair(relay, name); + if (!asked.ok) { + console.log(`Couldn't start pairing: ${asked.message}`); + return 1; + } + const approved = await waitForPhone(asked.ask); + if (!approved) return 1; + app = approved; + } + if (existing?.app && existing.app.senderId !== app.senderId) await unpairFromPhone(existing.app); + saveConfig(home, { version: 1, transport: "app", app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); + console.log(`Paired. Your phone lists this computer as "${app.name}", and alerts now go through the Code Alarm app.`); + refreshCodex(home); + if (existing?.ntfy) console.log("Your ntfy settings are kept: `code-alarm use ntfy` switches back."); + console.log("Check it with: code-alarm test"); + return 0; + } + case "unpair": { + const config = loadConfig(home); + if (!config?.app) { + console.log("This computer isn't paired with the Code Alarm app."); + return 0; + } + const result = await unpairFromPhone(config.app); + if (config.ntfy) saveConfig(home, { version: 1, transport: "ntfy", ntfy: config.ntfy }); + else removeConfig(home); + refreshCodex(home); + console.log( + result.ok ? "Unpaired: the phone no longer lists this computer." : `Unpaired here, but the relay didn't hear it (${result.detail}). Remove this computer in the app too.` + ); + console.log(config.ntfy ? "Alerts go through ntfy again." : "Nothing is set up now: `code-alarm pair ` or `code-alarm init` sets it up again."); + return 0; + } + case "use": { + const target = rest[0]; + if (target !== "ntfy" && target !== "app") throw new ConfigError("Usage: code-alarm use ntfy|app"); + const config = loadConfig(home); + if (!config?.[target]) { + console.log(target === "app" ? "Not paired with the app: run `code-alarm pair `." : "ntfy isn't set up: run `code-alarm init`."); + return 1; + } + saveConfig(home, { ...config, transport: target }); + console.log(`Alerts now go through ${target === "app" ? "the Code Alarm app" : "ntfy"}.`); + refreshCodex(home); + return 0; + } case "install": case "uninstall": { if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`); - const script = process.argv[1]; + const script = runningScript(); if (command === "install") { if (!script) throw new ConfigError("Can't tell where this code-alarm script is."); - for (const note of installCodex({ alarmHome: home, userHome: homedir2(), bundle: resolve3(script), now: Date.now() })) console.log(note); + for (const note of installCodex({ alarmHome: home, userHome: homedir2(), bundle: script, now: Date.now() })) console.log(note); console.log( ` Next: quit Codex completely and start it again. It reads rules only at startup, and closing @@ -1100,3 +2282,29 @@ main(process.argv.slice(2)).then( export { main }; +/*! @license + * QR Code generator library (TypeScript), from https://github.com/nayuki/QR-Code-generator + * (typescript-javascript/qrcodegen.ts at commit 3c6d0b3cefb4e049dc337e82237c9644399716a8), + * unchanged except for this header, the ts-nocheck line and the export at the end. + * + * QR Code generator library (TypeScript) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ diff --git a/cli/package-lock.json b/cli/package-lock.json index 838be71..6f0c5a4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-alarm", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-alarm", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "bin": { "code-alarm": "dist/code-alarm.mjs" diff --git a/cli/package.json b/cli/package.json index 3dda0a6..84abc35 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "code-alarm", - "version": "0.1.0", + "version": "0.2.0", "description": "Wake up when Claude Code or Codex finishes or needs you: a real alarm on your phone, from agent hooks.", "license": "MIT", "author": "Yevhen Mishchenko", @@ -11,12 +11,22 @@ "directory": "cli" }, "bugs": "https://github.com/Draganoider/code-alarm/issues", - "keywords": ["claude-code", "codex", "alarm", "notifications", "ntfy", "hooks", "ai-agents"], + "keywords": [ + "claude-code", + "codex", + "alarm", + "notifications", + "ntfy", + "hooks", + "ai-agents" + ], "type": "module", "bin": { "code-alarm": "dist/code-alarm.mjs" }, - "files": ["dist/code-alarm.mjs"], + "files": [ + "dist/code-alarm.mjs" + ], "engines": { "node": ">=22" }, diff --git a/cli/src/app.ts b/cli/src/app.ts new file mode 100644 index 0000000..370fd50 --- /dev/null +++ b/cli/src/app.ts @@ -0,0 +1,288 @@ +import { createHash, randomBytes } from "node:crypto"; +import { ConfigError, type AppConfig } from "./config.js"; +import type { AlarmEvent } from "./events.js"; +import type { SendResult } from "./ntfy.js"; + +/** + * The Code Alarm app's transport: events go to the relay, which pushes them to the paired phone + * (docs/protocol.md). The request body is the event itself and nothing else; the token goes only in + * the Authorization header. + */ + +/** The Code Alarm relay. `pair --relay ` or CODE_ALARM_RELAY points a pairing elsewhere (a local test relay). */ +export const DEFAULT_RELAY = "https://relay.codealarm.workers.dev"; +export const RELAY_ENV = "CODE_ALARM_RELAY"; + +const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); + +/** The relay to pair with: HTTPS, except on this computer (a local test relay). */ +export function relayUrl(given: string | undefined, env: NodeJS.ProcessEnv = process.env): string { + const raw = given ?? (env[RELAY_ENV] || DEFAULT_RELAY); + let url: URL; + try { + url = new URL(raw); + } catch { + throw new ConfigError(`${raw} isn't a URL.`); + } + if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname))) { + throw new ConfigError("The relay must be an https:// address: the token travels with every request."); + } + if (url.search || url.hash || url.username || url.password) throw new ConfigError("The relay address takes no query, fragment or credentials."); + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +/** How the phone lists this computer when `pair` gets no --name. Never the hostname: it would leave the computer. */ +export function defaultName(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "Windows PC" : platform === "darwin" ? "Mac" : platform === "linux" ? "Linux computer" : "Computer"; +} + +const TIMEOUT_MS = 10_000; + +interface RelayRequest { + url: string; + init: { method: string; headers: Record; body?: string }; +} + +interface RelayAnswer { + status: number; + body: Record; +} + +async function call(request: RelayRequest, fetchImpl: typeof fetch, timeoutMs: number): Promise { + const res = await fetchImpl(request.url, { ...request.init, signal: AbortSignal.timeout(timeoutMs) }); + const text = await res.text().catch(() => ""); + let body: Record = {}; + try { + const parsed = text ? (JSON.parse(text) as unknown) : {}; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as Record; + } catch { + // Not JSON: a proxy's error page, say. The status says enough. + } + return { status: res.status, body }; +} + +const message = (answer: RelayAnswer) => + typeof answer.body.message === "string" ? answer.body.message : `the relay answered HTTP ${answer.status}`; + +/** What `pair` sends: the code typed from the phone, and the name to list this computer under. */ +export function pairRequest(relay: string, code: string, name: string): RelayRequest { + return { + url: `${relay}/v1/pair`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, name }) }, + }; +} + +export type PairResult = { ok: true; app: AppConfig } | { ok: false; message: string }; + +export async function pairWithPhone(relay: string, code: string, name: string, now: number, fetchImpl: typeof fetch = fetch): Promise { + let answer: RelayAnswer; + try { + answer = await call(pairRequest(relay, code, name), fetchImpl, TIMEOUT_MS); + } catch (e) { + return { ok: false, message: `couldn't reach the relay at ${relay} (${e instanceof Error ? e.name : "error"})` }; + } + const { sender_id, token, device_id, name: listedAs } = answer.body; + if (answer.status !== 201 || typeof sender_id !== "string" || typeof token !== "string" || typeof device_id !== "string") { + return { ok: false, message: message(answer) }; + } + return { + ok: true, + app: { relay, deviceId: device_id, senderId: sender_id, token, name: typeof listedAs === "string" ? listedAs : name, pairedAt: now }, + }; +} + +/** + * QR pairing: this computer makes its own token and sends the relay only its SHA-256, so the token + * never leaves the computer. The phone scans the QR and approves; the computer then polls with the + * token until the relay says yes. + */ +export function newSenderToken(): string { + return `cas_${randomBytes(32).toString("base64url")}`; +} + +/** What QR pairing sends: the name to list this computer under, and the hash of its new token. */ +export function pairingAskRequest(relay: string, name: string, token: string): RelayRequest { + const tokenHash = createHash("sha256").update(token).digest("hex"); + return { + url: `${relay}/v1/pair-requests`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, token_hash: tokenHash }) }, + }; +} + +export interface PairingAsk { + relay: string; + id: string; + /** "K7Q-4MZ": shown under the QR. */ + code: string; + /** What the QR holds. */ + url: string; + expiresAt: number; + token: string; + name: string; +} + +export async function askToPair( + relay: string, + name: string, + fetchImpl: typeof fetch = fetch, +): Promise<{ ok: true; ask: PairingAsk } | { ok: false; message: string }> { + const token = newSenderToken(); + let answer: RelayAnswer; + try { + answer = await call(pairingAskRequest(relay, name, token), fetchImpl, TIMEOUT_MS); + } catch (e) { + return { ok: false, message: `couldn't reach the relay at ${relay} (${e instanceof Error ? e.name : "error"})` }; + } + const { id, code, url, expires_at } = answer.body; + if (answer.status !== 201 || typeof id !== "string" || typeof code !== "string" || typeof url !== "string" || typeof expires_at !== "number") { + return { ok: false, message: message(answer) }; + } + return { ok: true, ask: { relay, id, code, url, expiresAt: expires_at, token, name } }; +} + +export type PollResult = + | { status: "approved"; app: AppConfig } + | { status: "pending" } + | { status: "expired" } + | { status: "error"; message: string }; + +/** One look at whether the phone approved. A network error is "error": keep waiting and ask again. */ +export async function pollPairing(ask: PairingAsk, now: number, fetchImpl: typeof fetch = fetch): Promise { + let answer: RelayAnswer; + try { + answer = await call( + { url: `${ask.relay}/v1/pair-requests/${encodeURIComponent(ask.id)}`, init: { method: "GET", headers: { authorization: `Bearer ${ask.token}` } } }, + fetchImpl, + TIMEOUT_MS, + ); + } catch (e) { + return { status: "error", message: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } + const { status, sender_id, device_id, name } = answer.body; + if (answer.status === 200 && status === "approved" && typeof sender_id === "string" && typeof device_id === "string") { + return { + status: "approved", + app: { relay: ask.relay, deviceId: device_id, senderId: sender_id, token: ask.token, name: typeof name === "string" ? name : ask.name, pairedAt: now }, + }; + } + if (answer.status === 200 && status === "pending") return { status: "pending" }; + if (answer.status === 200 && status === "expired") return { status: "expired" }; + return { status: "error", message: message(answer) }; +} + +/** The event request. Its body is exactly the event: nothing else reaches the relay (hard rule 1). */ +export function appRequest(event: AlarmEvent, app: AppConfig): RelayRequest { + return { + url: `${app.relay}/v1/events`, + init: { + method: "POST", + headers: { authorization: `Bearer ${app.token}`, "content-type": "application/json" }, + body: JSON.stringify(event), + }, + }; +} + +/** + * Sends one event. A network error or a failed push is retried once with the same ID: the relay + * dedupes by ID, so a retry can never ring twice. + */ +export async function sendApp( + event: AlarmEvent, + app: AppConfig, + fetchImpl: typeof fetch = fetch, + options: { timeoutMs?: number; retryDelayMs?: number } = {}, +): Promise { + const { timeoutMs = TIMEOUT_MS, retryDelayMs = 3000 } = options; + let result: SendResult = { ok: false, detail: "not sent" }; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) await new Promise((done) => setTimeout(done, retryDelayMs)); + let answer: RelayAnswer; + try { + answer = await call(appRequest(event, app), fetchImpl, timeoutMs); + } catch (e) { + result = { ok: false, detail: `relay unreachable (${e instanceof Error ? e.name : "error"})` }; + continue; + } + if (answer.status === 200 && answer.body.status === "duplicate") return { ok: true, detail: "app: already sent" }; + if (answer.status === 200) { + return { ok: true, detail: answer.body.limited ? "app: sent as a notification (rate limit)" : "app: sent" }; + } + if (answer.status === 401) return { ok: false, detail: "this computer isn't paired any more: run `code-alarm pair`" }; + result = { ok: false, detail: `app: ${typeof answer.body.error === "string" ? answer.body.error : `HTTP ${answer.status}`}` }; + if (answer.status !== 502) return result; + } + return result; +} + +/** Removes this computer from the phone's list. Already gone counts as done. */ +export async function unpairFromPhone(app: AppConfig, fetchImpl: typeof fetch = fetch): Promise { + try { + const answer = await call( + { url: `${app.relay}/v1/senders/${encodeURIComponent(app.senderId)}`, init: { method: "DELETE", headers: { authorization: `Bearer ${app.token}` } } }, + fetchImpl, + TIMEOUT_MS, + ); + if (answer.status === 204) return { ok: true, detail: "removed" }; + if (answer.status === 401 || answer.status === 404) return { ok: true, detail: "already removed" }; + return { ok: false, detail: message(answer) }; + } catch (e) { + return { ok: false, detail: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } +} + +export interface Delivery { + status: string; + detail?: string | null; + sent_at: number; + ringing_at?: number | null; + ended_at?: number | null; +} + +export type DeliveryResult = { ok: true; delivery: Delivery } | { ok: false; message: string }; + +/** What happened to one event, from the relay and the phone's acks. */ +export async function fetchDelivery(app: AppConfig, id: string, fetchImpl: typeof fetch = fetch): Promise { + try { + const answer = await call( + { url: `${app.relay}/v1/events/${encodeURIComponent(id)}`, init: { method: "GET", headers: { authorization: `Bearer ${app.token}` } } }, + fetchImpl, + TIMEOUT_MS, + ); + if (answer.status !== 200 || typeof answer.body.status !== "string" || typeof answer.body.sent_at !== "number") { + return { ok: false, message: answer.status === 404 ? "the relay has no record of it (it keeps a week)" : message(answer) }; + } + return { ok: true, delivery: answer.body as unknown as Delivery }; + } catch (e) { + return { ok: false, message: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } +} + +const seconds = (ms: number) => `${(Math.max(0, ms) / 1000).toFixed(1)} s`; + +/** One line for `status --last`. Times compare this computer's clock with the phone's, so they're approximate. */ +export function describeDelivery(d: Delivery): string { + const rang = d.ringing_at ? `${seconds(d.ringing_at - d.sent_at)} after the alert was sent` : ""; + switch (d.status) { + case "sending": + case "sent": + return "sent to the phone; it hasn't answered yet (it may be off, or offline)"; + case "failed": + return `not delivered${d.detail ? `: ${d.detail}` : ""}`; + case "received": + return "received by the phone"; + case "ringing": + return `ringing, ${rang}`; + case "notified": + return `shown as a notification, ${rang}`; + case "snoozed": + return `snoozed; it rang ${rang}`; + case "stopped": + return `rang ${rang}${d.ended_at && d.ringing_at ? `, stopped after ${seconds(d.ended_at - d.ringing_at)}` : ""}`; + case "expired": + return "the phone got it after it expired, so it stayed silent"; + case "duplicate": + return "the phone had already had it, so it stayed silent"; + default: + return d.status; + } +} diff --git a/cli/src/config.ts b/cli/src/config.ts index 996c5cc..53be862 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { writeFileAtomic } from "./fsutil.js"; @@ -11,10 +11,26 @@ export interface NtfyConfig { token?: string; } +/** This computer's pairing with the Code Alarm app, through its relay (docs/protocol.md). */ +export interface AppConfig { + relay: string; + deviceId: string; + senderId: string; + /** Rings the paired phone, and only that one. Never printed or logged. */ + token: string; + /** How the phone lists this computer. */ + name: string; + pairedAt: number; +} + +export type Transport = "ntfy" | "app"; + +/** Both transports can be set up at once; `transport` says which one sends. Never both: that would ring twice. */ export interface Config { version: 1; - transport: "ntfy"; - ntfy: NtfyConfig; + transport: Transport; + ntfy?: NtfyConfig; + app?: AppConfig; } export class ConfigError extends Error {} @@ -25,6 +41,26 @@ export function configPath(home: string): string { return join(home, "config.json"); } +function readNtfy(value: unknown): NtfyConfig | undefined { + const ntfy = value as Partial | undefined; + if (!ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") return undefined; + return { server: ntfy.server, topic: ntfy.topic, ...(ntfy.token ? { token: ntfy.token } : {}) }; +} + +function readApp(value: unknown): AppConfig | undefined { + const app = value as Partial | undefined; + if (!app) return undefined; + for (const key of ["relay", "deviceId", "senderId", "token", "name"] as const) if (typeof app[key] !== "string") return undefined; + return { + relay: app.relay!, + deviceId: app.deviceId!, + senderId: app.senderId!, + token: app.token!, + name: app.name!, + pairedAt: typeof app.pairedAt === "number" ? app.pairedAt : 0, + }; +} + export function loadConfig(home: string): Config | null { const path = configPath(home); if (!existsSync(path)) return null; @@ -35,18 +71,22 @@ export function loadConfig(home: string): Config | null { throw new ConfigError(`${path} isn't valid JSON. Fix it, or run \`code-alarm init --force\` to start over.`); } const config = raw as Partial; - const ntfy = config.ntfy; - if (config.transport !== "ntfy" || !ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") { - throw new ConfigError(`${path} is missing its ntfy settings. Run \`code-alarm init --force\`.`); - } - return { version: 1, transport: "ntfy", ntfy: { server: ntfy.server, topic: ntfy.topic, ...(ntfy.token ? { token: ntfy.token } : {}) } }; + const ntfy = readNtfy(config.ntfy); + const app = readApp(config.app); + if (config.transport === "app" && app) return { version: 1, transport: "app", app, ...(ntfy ? { ntfy } : {}) }; + if (config.transport === "ntfy" && ntfy) return { version: 1, transport: "ntfy", ntfy, ...(app ? { app } : {}) }; + throw new ConfigError(`${path} is missing its ${config.transport === "app" ? "pairing" : "ntfy"} settings. Run \`code-alarm init --force\` or \`code-alarm pair\`.`); } export function saveConfig(home: string, config: Config): void { - // Readable by the user only: the topic is effectively a password on a public server. + // Readable by the user only: the topic and the token are effectively passwords. writeFileAtomic(configPath(home), JSON.stringify(config, null, 2) + "\n", 0o600); } +export function removeConfig(home: string): void { + rmSync(configPath(home), { force: true }); +} + /** e.g. "code-alarm-k3x9v0q2m8w4t7y1zr5b": 120 random bits, so nobody can guess it. */ export function newTopic(): string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/cli/src/deliver.ts b/cli/src/deliver.ts index b76acc7..7fdee2e 100644 --- a/cli/src/deliver.ts +++ b/cli/src/deliver.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { appendFileSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; +import { sendApp } from "./app.js"; import { loadConfig } from "./config.js"; import { type AlarmEvent, EVENT_FIELDS, KINDS } from "./events.js"; import { writeFileAtomic } from "./fsutil.js"; @@ -34,12 +35,44 @@ export function recentLog(home: string, count: number): string[] { /** Sends and waits for the answer: for `code-alarm test`, and inside the detached sender. */ export async function sendNow(home: string, event: AlarmEvent, fetchImpl?: typeof fetch): Promise { const config = loadConfig(home); - if (!config) return { ok: false, detail: "not set up: run `code-alarm init`" }; - const result = await sendNtfy(event, config.ntfy, fetchImpl); + if (!config) return { ok: false, detail: "not set up: run `code-alarm init` or `code-alarm pair`" }; + // One transport, never both: two would ring twice. + const result = + config.transport === "app" && config.app ? await sendApp(event, config.app, fetchImpl) : await sendNtfy(event, config.ntfy!, fetchImpl); logLine(home, `${event.kind}/${event.level} ${event.agent} "${event.label}" ${result.ok ? "sent" : "FAILED"} (${result.detail})`); + if (result.ok && config.transport === "app") rememberLast(home, event); return result; } +export interface LastEvent { + id: string; + kind: string; + level: string; + label: string; + sentAt: number; +} + +const lastPath = (home: string) => join(home, "last-event.json"); + +/** The last event sent through the app, for `status --last`. It stays on this computer. */ +function rememberLast(home: string, event: AlarmEvent): void { + try { + const last: LastEvent = { id: event.id, kind: event.kind, level: event.level, label: event.label, sentAt: event.sent_at }; + writeFileAtomic(lastPath(home), JSON.stringify(last)); + } catch { + // Only status --last needs it. + } +} + +export function lastEvent(home: string): LastEvent | null { + try { + const last = JSON.parse(readFileSync(lastPath(home), "utf8")) as Partial; + return typeof last.id === "string" && typeof last.sentAt === "number" ? (last as LastEvent) : null; + } catch { + return null; + } +} + /** * Hands the send to a detached copy of this script and returns at once, so a slow or missing * network can never hold up the agent (hard rule 6). diff --git a/cli/src/events.ts b/cli/src/events.ts index 2245c70..8b00218 100644 --- a/cli/src/events.ts +++ b/cli/src/events.ts @@ -21,7 +21,8 @@ export const KINDS = ["done", "needs_input", "failed", "stopped", "test"] as con export type Kind = (typeof KINDS)[number]; export const LEVELS = ["alarm", "notify"] as const; export type Level = (typeof LEVELS)[number]; -export type Agent = "claude-code" | "codex" | "cli"; +export const AGENTS = ["claude-code", "codex", "cli"] as const; +export type Agent = (typeof AGENTS)[number]; export const DEFAULT_TTL_MS = 30 * 60_000; export const MAX_LABEL = 40; diff --git a/cli/src/install.ts b/cli/src/install.ts index 9d8e802..6761a45 100644 --- a/cli/src/install.ts +++ b/cli/src/install.ts @@ -1,4 +1,4 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, rmdirSync, rmSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { writeFileAtomic } from "./fsutil.js"; import { skillMarkdown } from "./skill.js"; @@ -114,6 +114,14 @@ function readHooksFile(path: string): HooksFile { throw new Error(`${path} isn't valid JSON, so code-alarm won't touch it. Fix or move it, then run this again.`); } +/** Through a temporary file and a rename, so a hook starting at that moment never runs half a file. */ +function copyAtomic(from: string, to: string): void { + mkdirSync(dirname(to), { recursive: true }); + const temp = `${to}.${process.pid}.tmp`; + copyFileSync(from, temp); + renameSync(temp, to); +} + const isOurFile = (path: string, mark: string) => readFileSync(path, "utf8").includes(mark); /** Backs up a file that isn't ours before we write over it. */ @@ -143,8 +151,7 @@ export function installCodex(args: { alarmHome: string; userHome: string; bundle } const current = readHooksFile(paths.hooksFile); - mkdirSync(dirname(paths.cli), { recursive: true }); - copyFileSync(args.bundle, paths.cli); + copyAtomic(args.bundle, paths.cli); notes.push(`Copied the CLI to ${paths.cli}`); if (existsSync(paths.hooksFile)) { @@ -185,6 +192,93 @@ export function uninstallCodex(args: { alarmHome: string; userHome: string }): s return notes; } +/** cli/build.mjs puts this in every bundle's banner, with the version. */ +const BUNDLE_MARK = "Generated by cli/build.mjs"; + +/** The version in a bundle's banner. Copies made by 0.1.0 have none. */ +export function bundleVersion(text: string): string | null { + return /^\/\/ code-alarm (\d+\.\d+\.\d+[^\s,]*), MIT License/m.exec(text.slice(0, 1000))?.[1] ?? null; +} + +/** -1, 0 or 1. Only the numbers count: a pre-release tag compares equal to its release. */ +function compareVersions(a: string, b: string): number { + const parts = (v: string) => v.split(/[-+]/)[0]!.split(".").map(Number); + const [x, y] = [parts(a), parts(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + +/** + * Whether Codex's hooks run the same code-alarm as `running` (the script running now). An old copy + * can't read settings a newer version wrote, and because hooks always exit 0 it fails silently. + * - none: Codex's hooks don't point at this alarm home's copy, or `running` isn't a bundle (a dev run) + * - stale: missing, older, or a different build of the same version + * - newer: the copy's version is newer than this one, so it's left alone + */ +export type CodexCopy = + | { state: "none" | "current" } + | { state: "stale"; path: string; copyVersion: string | null; version: string | null; missing: boolean } + | { state: "newer"; path: string; copyVersion: string; version: string | null }; + +export function codexCopy(alarmHome: string, userHome: string, running: string | undefined): CodexCopy { + const paths = codexPaths(alarmHome, userHome); + let bundle: Buffer; + try { + if (!running || !hooksRun(paths)) return { state: "none" }; + bundle = readFileSync(running); + } catch { + return { state: "none" }; + } + const text = bundle.toString("utf8", 0, 1000); + if (!text.includes(BUNDLE_MARK)) return { state: "none" }; + const version = bundleVersion(text); + if (!existsSync(paths.cli)) return { state: "stale", path: paths.cli, copyVersion: null, version, missing: true }; + const copy = readFileSync(paths.cli); + if (copy.equals(bundle)) return { state: "current" }; + const copyVersion = bundleVersion(copy.toString("utf8", 0, 1000)); + if (copyVersion && version && compareVersions(copyVersion, version) > 0) return { state: "newer", path: paths.cli, copyVersion, version }; + return { state: "stale", path: paths.cli, copyVersion, version, missing: false }; +} + +/** Our exact hook command is in Codex's hooks file, pointing at this alarm home's copy. */ +function hooksRun(paths: CodexPaths): boolean { + const command = codexCommand(paths.cli); + const file = readHooksFile(paths.hooksFile); + return Object.values(file.hooks ?? {}).some((groups) => groups.some((group) => (group.hooks ?? []).some((h) => h.command === command))); +} + +/** How `status` describes the copy, or null when there's nothing to say. */ +export function describeCodexCopy(copy: CodexCopy): string | null { + if (copy.state === "stale") { + const which = copy.missing + ? "but the copy of code-alarm they run is missing" + : copy.copyVersion && copy.copyVersion !== copy.version + ? `but they run an older code-alarm (${copy.copyVersion}; this is ${copy.version ?? "unknown"})` + : "but they run a different build of code-alarm than this one"; + return `${which}, so they may send nothing. \`code-alarm install codex\` updates it; the hooks stay the same, so Codex won't ask to trust them again`; + } + if (copy.state === "newer") return `running a newer code-alarm (${copy.copyVersion}) than this one (${copy.version ?? "unknown"})`; + return null; +} + +/** + * Brings Codex's copy up to date with `running`, after a command that wrote settings an old copy + * might not read. Only the copy changes, never the hook commands. Returns what to tell the user. + */ +export function refreshCodexCopy(alarmHome: string, userHome: string, running: string | undefined): string | null { + const copy = codexCopy(alarmHome, userHome, running); + if (copy.state === "newer") { + return `Codex runs a newer code-alarm (${copy.copyVersion}) than this one, so its copy was left alone. If its alerts stop, update this code-alarm.`; + } + if (copy.state !== "stale") return null; + try { + copyAtomic(running!, copy.path); + } catch (e) { + return `Couldn't update the copy of code-alarm that Codex's hooks run (${e instanceof Error ? e.message : String(e)}), so they may send nothing. Run \`code-alarm install codex\` to update it.`; + } + return `Updated the copy of code-alarm that Codex's hooks run, so they can read the new settings.`; +} + export function codexInstalled(alarmHome: string, userHome: string): boolean { try { const file = readHooksFile(codexPaths(alarmHome, userHome).hooksFile); diff --git a/cli/src/main.ts b/cli/src/main.ts index 2369d6f..9ed36b7 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -1,13 +1,15 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { parseArgs } from "node:util"; -import { DEFAULT_NTFY_SERVER, ConfigError, isValidTopic, loadConfig, newTopic, saveConfig } from "./config.js"; -import { decodeEvent, logLine, recentLog, sendDetached, sendNow, waitDetached } from "./deliver.js"; +import { type PairingAsk, askToPair, defaultName, describeDelivery, fetchDelivery, pairWithPhone, pollPairing, relayUrl, unpairFromPhone } from "./app.js"; +import { type AppConfig, type Config, DEFAULT_NTFY_SERVER, ConfigError, isValidTopic, loadConfig, newTopic, removeConfig, saveConfig } from "./config.js"; +import { decodeEvent, lastEvent, logLine, recentLog, sendDetached, sendNow, waitDetached } from "./deliver.js"; import { HOUR, MAX_ARM_MS, formatDuration, parseDuration } from "./duration.js"; import { type Agent, buildEvent, labelFor } from "./events.js"; import { type Deps, type HookAgent, PENDING_WAIT_MS, type SignalKind, firePending, runHook, runSignal, sessionFromEnv } from "./hook.js"; -import { codexInstalled, installCodex, uninstallCodex } from "./install.js"; +import { codexCopy, codexInstalled, describeCodexCopy, installCodex, refreshCodexCopy, uninstallCodex } from "./install.js"; import { alarmHome } from "./paths.js"; +import { qrForTerminal } from "./qr.js"; import { skillMarkdown } from "./skill.js"; import { armNap, armProject, disarmAll, disarmProject, listArmings } from "./state.js"; @@ -23,13 +25,21 @@ Setup code-alarm test [--notify] Ring the phone now (or send a plain notification) code-alarm install codex Add Code Alarm's hooks and skill to Codex (uninstall codex removes them) +The Code Alarm app (in testing) + code-alarm pair [] [--name NAME] [--relay URL] + Pair with your phone: shows a QR to scan in the app, or takes the + code the app shows. Alerts then go through the app + code-alarm unpair Remove this computer from the phone + code-alarm use ntfy|app Switch between ntfy and the app when both are set up + Arming (only armed work rings) code-alarm arm [--for 12h] [--notify] Ring when the agent in this folder next finishes, stops or needs you code-alarm nap <2h> [--notify] Ring for every session until the time is up --notify: one ordinary notification instead of the alarm code-alarm disarm [--all] Cancel arming for this folder (or everything) - code-alarm status What's set up, what's armed, the last alerts + code-alarm status [--last] What's set up, what's armed, the last alerts + --last: what the phone did with the last alert (app only) Run by the agent (its skill says when) code-alarm done | needs-you | failed @@ -73,10 +83,46 @@ function detectAgent(env: NodeJS.ProcessEnv): Agent { const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 5); +/** The script running now: what `install codex` copies, and what Codex's copy is compared with. */ +const runningScript = () => (process.argv[1] ? resolve(process.argv[1]) : undefined); + +/** After saving settings: Codex's hooks run their own copy of the CLI, which must be able to read them. */ +function refreshCodex(home: string): void { + const note = refreshCodexCopy(home, homedir(), runningScript()); + if (note) console.log(note); +} + function setupHint(): string { return "Code Alarm isn't set up on this computer yet. Run `code-alarm init` in a terminal."; } +/** How often QR pairing asks the relay whether the phone said yes. CODE_ALARM_POLL_MS is for tests. */ +const POLL_MS = Number(process.env.CODE_ALARM_POLL_MS) || 2500; + +/** Shows the QR and waits for the phone to approve it, up to the request's 10 minutes. */ +async function waitForPhone(ask: PairingAsk): Promise { + console.log("Open Code Alarm on your phone, tap Pair a computer, then Scan QR code:\n"); + // Upper case fits QR's compact alphanumeric mode: a smaller code that scans more easily. The + // relay's /pair/ page and the app both read it in any case. + console.log(qrForTerminal(ask.url.toUpperCase())); + console.log(`\n(code ${ask.code}, works once, until ${clock(ask.expiresAt)})`); + console.log('No camera? In the app, choose "Show a code instead" and run: code-alarm pair '); + console.log("Waiting for your phone… Ctrl-C stops."); + let warned = false; + while (Date.now() < ask.expiresAt) { + await new Promise((done) => setTimeout(done, POLL_MS)); + const result = await pollPairing(ask, Date.now()); + if (result.status === "approved") return result.app; + if (result.status === "expired") break; + if (result.status === "error" && !warned) { + console.log(`Still waiting (${result.message}).`); + warned = true; + } + } + console.log("The code ran out before a phone approved it. Run `code-alarm pair` again."); + return null; +} + export async function main(argv: string[]): Promise { const [command, ...rest] = argv; const home = alarmHome(); @@ -123,9 +169,15 @@ export async function main(argv: string[]): Promise { args: rest, options: { server: { type: "string" }, topic: { type: "string" }, token: { type: "string" }, force: { type: "boolean" } }, }); - // --force starts over without reading the old config, which may be the broken thing. - const existing = values.force ? null : loadConfig(home); - if (existing) { + // --force starts over even when the old config can't be read, which may be the broken thing. + // A pairing with the app is kept when it can be read. + let existing: Config | null = null; + try { + existing = loadConfig(home); + } catch (e) { + if (!values.force) throw e; + } + if (existing?.ntfy && !values.force) { console.log(`Already set up: ntfy topic ${existing.ntfy.topic} on ${existing.ntfy.server}.`); console.log("Run `code-alarm init --force` to start over with a new topic."); return 0; @@ -133,7 +185,11 @@ export async function main(argv: string[]): Promise { const topic = values.topic ?? newTopic(); if (!isValidTopic(topic)) throw new ConfigError("A topic can only use letters, digits, - and _ (at most 64)."); const server = (values.server ?? DEFAULT_NTFY_SERVER).replace(/\/+$/, ""); - saveConfig(home, { version: 1, transport: "ntfy", ntfy: { server, topic, ...(values.token ? { token: values.token } : {}) } }); + const ntfy = { server, topic, ...(values.token ? { token: values.token } : {}) }; + const app = existing?.app; + saveConfig(home, app ? { version: 1, transport: "app", app, ntfy } : { version: 1, transport: "ntfy", ntfy }); + refreshCodex(home); + if (app) console.log("This computer is paired with the Code Alarm app, which still sends: `code-alarm use ntfy` switches to ntfy.\n"); console.log(`Code Alarm is set up. Server: ${server} @@ -218,13 +274,15 @@ Then connect your agent: } case "status": { + const { values } = parseArgs({ args: rest, options: { last: { type: "boolean" } } }); const config = loadConfig(home); - console.log( - config - ? // Never any part of the topic: people paste this output into bug reports. - `Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}` - : "Not set up: run `code-alarm init`.", - ); + // Never any part of the topic or the token: people paste this output into bug reports. + if (!config) console.log("Not set up: run `code-alarm init`."); + else if (config.transport === "app" && config.app) { + console.log(`Transport: Code Alarm app, paired as "${config.app.name}" through ${config.app.relay}${config.ntfy ? " (ntfy is set up too)" : ""}`); + } else if (config.ntfy) { + console.log(`Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}${config.app ? " (paired with the app too)" : ""}`); + } const now = Date.now(); const armings = listArmings(home, now); console.log(armings.length ? "Armed:" : "Nothing is armed."); @@ -233,9 +291,20 @@ Then connect your agent: const level = arming.level === "notify" ? ", notification only" : ""; console.log(` - ${what} until ${clock(arming.until)} (${formatDuration(arming.until - now)} left${level})`); } - console.log(`Codex hooks: ${codexInstalled(home, homedir()) ? "installed" : "not installed"}`); + const copy = describeCodexCopy(codexCopy(home, homedir(), runningScript())); + console.log(`Codex hooks: ${codexInstalled(home, homedir()) ? `installed${copy ? `, ${copy}` : ""}` : "not installed"}`); const log = recentLog(home, 5); if (log.length) console.log(`Last activity:\n${log.map((line) => ` ${line}`).join("\n")}`); + if (values.last) { + const last = lastEvent(home); + if (config?.transport !== "app" || !config.app) console.log("Last alert: --last needs the Code Alarm app; ntfy doesn't report back."); + else if (!last) console.log("Last alert: none sent through the app yet."); + else { + const result = await fetchDelivery(config.app, last.id); + const what = `${last.kind}/${last.level} "${last.label}" at ${clock(last.sentAt)}`; + console.log(`Last alert: ${what}: ${result.ok ? describeDelivery(result.delivery) : `unknown, ${result.message}`}`); + } + } return 0; } @@ -249,13 +318,91 @@ Then connect your agent: return 0; } + case "pair": { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { name: { type: "string" }, relay: { type: "string" } }, + }); + const code = positionals[0]; + const relay = relayUrl(values.relay); + const name = values.name?.trim() || defaultName(); + let existing: Config | null = null; + try { + existing = loadConfig(home); + } catch { + // A config that can't be read is replaced by the new pairing. + } + let app: AppConfig; + if (code) { + // The phone shows a code, and it's typed here. + const result = await pairWithPhone(relay, code, name, Date.now()); + if (!result.ok) { + console.log(`Couldn't pair: ${result.message}`); + return 1; + } + app = result.app; + } else { + // This computer shows a QR, and the phone scans it and approves. + const asked = await askToPair(relay, name); + if (!asked.ok) { + console.log(`Couldn't start pairing: ${asked.message}`); + return 1; + } + const approved = await waitForPhone(asked.ask); + if (!approved) return 1; + app = approved; + } + // Pairing again replaces the old pairing, so the phone doesn't keep listing a dead entry. + if (existing?.app && existing.app.senderId !== app.senderId) await unpairFromPhone(existing.app); + saveConfig(home, { version: 1, transport: "app", app, ...(existing?.ntfy ? { ntfy: existing.ntfy } : {}) }); + console.log(`Paired. Your phone lists this computer as "${app.name}", and alerts now go through the Code Alarm app.`); + refreshCodex(home); + if (existing?.ntfy) console.log("Your ntfy settings are kept: `code-alarm use ntfy` switches back."); + console.log("Check it with: code-alarm test"); + return 0; + } + + case "unpair": { + const config = loadConfig(home); + if (!config?.app) { + console.log("This computer isn't paired with the Code Alarm app."); + return 0; + } + const result = await unpairFromPhone(config.app); + if (config.ntfy) saveConfig(home, { version: 1, transport: "ntfy", ntfy: config.ntfy }); + else removeConfig(home); + refreshCodex(home); + console.log( + result.ok + ? "Unpaired: the phone no longer lists this computer." + : `Unpaired here, but the relay didn't hear it (${result.detail}). Remove this computer in the app too.`, + ); + console.log(config.ntfy ? "Alerts go through ntfy again." : "Nothing is set up now: `code-alarm pair ` or `code-alarm init` sets it up again."); + return 0; + } + + case "use": { + const target = rest[0]; + if (target !== "ntfy" && target !== "app") throw new ConfigError("Usage: code-alarm use ntfy|app"); + const config = loadConfig(home); + if (!config?.[target]) { + console.log(target === "app" ? "Not paired with the app: run `code-alarm pair `." : "ntfy isn't set up: run `code-alarm init`."); + return 1; + } + saveConfig(home, { ...config, transport: target }); + console.log(`Alerts now go through ${target === "app" ? "the Code Alarm app" : "ntfy"}.`); + refreshCodex(home); + return 0; + } + case "install": case "uninstall": { if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`); - const script = process.argv[1]; + const script = runningScript(); if (command === "install") { if (!script) throw new ConfigError("Can't tell where this code-alarm script is."); - for (const note of installCodex({ alarmHome: home, userHome: homedir(), bundle: resolve(script), now: Date.now() })) console.log(note); + for (const note of installCodex({ alarmHome: home, userHome: homedir(), bundle: script, now: Date.now() })) console.log(note); console.log( "\nNext: quit Codex completely and start it again. It reads rules only at startup, and closing\n" + "its window may leave it running in the background. Then approve (trust) the new hooks when it\n" + diff --git a/cli/src/qr.ts b/cli/src/qr.ts new file mode 100644 index 0000000..120f3b1 --- /dev/null +++ b/cli/src/qr.ts @@ -0,0 +1,42 @@ +import { qrcodegen } from "./vendor/qrcodegen.js"; + +/** The modules of a QR code for `text`: true is dark. Medium error correction survives a glare. */ +export function qrModules(text: string): boolean[][] { + const qr = qrcodegen.QrCode.encodeText(text, qrcodegen.QrCode.Ecc.MEDIUM); + const rows: boolean[][] = []; + for (let y = 0; y < qr.size; y++) { + const row: boolean[] = []; + for (let x = 0; x < qr.size; x++) row.push(qr.getModule(x, y)); + rows.push(row); + } + return rows; +} + +const QUIET = 2; + +/** + * A QR code for a terminal: two module rows per text line, with half-block characters. Colours + * are forced to black on white, because scanners want dark modules on a light background and many + * terminals are dark. + */ +export function qrForTerminal(text: string): string { + const modules = qrModules(text); + const size = modules.length + QUIET * 2; + const dark = (x: number, y: number) => { + const mx = x - QUIET; + const my = y - QUIET; + return mx >= 0 && my >= 0 && mx < modules.length && my < modules.length && modules[my]![mx]!; + }; + const lines: string[] = []; + for (let y = 0; y < size; y += 2) { + let line = ""; + for (let x = 0; x < size; x++) { + const top = dark(x, y); + const bottom = y + 1 < size && dark(x, y + 1); + line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "; + } + // Black foreground on a white background, reset at the end of each line. + lines.push(`\x1b[30;47m${line}\x1b[0m`); + } + return lines.join("\n"); +} diff --git a/cli/src/vendor/qrcodegen.ts b/cli/src/vendor/qrcodegen.ts new file mode 100644 index 0000000..7a64400 --- /dev/null +++ b/cli/src/vendor/qrcodegen.ts @@ -0,0 +1,997 @@ +// @ts-nocheck: vendored as is; it predates this repo's stricter compiler options. +/*! @license + * QR Code generator library (TypeScript), from https://github.com/nayuki/QR-Code-generator + * (typescript-javascript/qrcodegen.ts at commit 3c6d0b3cefb4e049dc337e82237c9644399716a8), + * unchanged except for this header, the ts-nocheck line and the export at the end. + * + * QR Code generator library (TypeScript) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +"use strict"; + + +namespace qrcodegen { + + type bit = number; + type byte = number; + type int = number; + + + /*---- QR Code symbol class ----*/ + + /* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ + export class QrCode { + + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { + const segs: Array = qrcodegen.QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + + + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary(data: Readonly>, ecl: QrCode.Ecc): QrCode { + const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + + + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments(segs: Readonly>, ecl: QrCode.Ecc, + minVersion: int = 1, maxVersion: int = 40, + mask: int = -1, boostEcl: boolean = true): QrCode { + + if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) + || mask < -1 || mask > 7) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable + } + if (version >= maxVersion) // All versions in the range could not fit the given data + throw new RangeError("Data too long"); + } + + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) + ecl = newEcl; + } + + // Concatenate all segments to create the data bit string + let bb: Array = [] + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) + bb.push(b); + } + assert(bb.length == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - bb.length % 8) % 8, bb); + assert(bb.length % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11) + appendBits(padByte, 8, bb); + + // Pack bits into bytes in big endian + let dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) + dataCodewords.push(0); + bb.forEach((b: bit, i: int) => + dataCodewords[i >>> 3] |= b << (7 - (i & 7))); + + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } + + + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules : Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + public readonly version: int, + + // The error correction level used in this QR Code. + public readonly errorCorrectionLevel: QrCode.Ecc, + + dataCodewords: Readonly>, + + msk: int) { + + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) + throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + let row: Array = []; + for (let i = 0; i < this.size; i++) + row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules .push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); // Undoes the mask due to XOR + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits + + this.isFunction = []; + } + + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; + } + + + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) + rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = (data << 10 | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) + return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) + rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25); + const bits: int = this.version << 12 | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + i % 3; + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + + + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + + + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + + + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + + + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: QrCode.Ecc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver]; + const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks: int = numBlocks - rawCodewords % numBlocks; + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + let blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat: Array = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) + dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + let result: Array = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + + + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair + if (right == 6) + right = 5; + for (let vert = 0; vert < this.size; vert++) { // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == data.length * 8); + } + + + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) + throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: invert = (x + y) % 2 == 0; break; + case 1: invert = y % 2 == 0; break; + case 2: invert = x % 3 == 0; break; + case 3: invert = (x + y) % 3 == 0; break; + case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; break; + case 5: invert = x * y % 2 + x * y % 3 == 0; break; + case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break; + case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break; + default: throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + + + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0,0,0,0,0,0,0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) + result += QrCode.PENALTY_N1; + else if (runX > 5) + result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0,0,0,0,0,0,0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) + result += QrCode.PENALTY_N1; + else if (runY > 5) + result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y][x]; + if ( color == this.modules[y][x + 1] && + color == this.modules[y + 1][x] && + color == this.modules[y + 1][x + 1]) + result += QrCode.PENALTY_N2; + } + } + + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) + return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = Math.floor((this.version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4)) * 2; + let result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + + + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) + result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + + + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { + return Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + } + + + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result: Array = []; + for (let i = 0; i < degree - 1; i++) + result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) + result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 0x02); + } + return result; + } + + + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder(data: Readonly>, divisor: Readonly>): Array { + let result: Array = divisor.map(_ => 0); + for (const b of data) { // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach((coef, i) => + result[i] ^= QrCode.reedSolomonMultiply(coef, factor)); + } + return result; + } + + + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11D); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } + + + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]; + assert(n <= this.size * 3); + const core: boolean = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); + } + + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount(currentRunColor: boolean, currentRunLength: int, runHistory: Array): int { + if (currentRunColor) { // Terminate dark run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + + + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { + if (runHistory[0] == 0) + currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + + + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High + ]; + + } + + + // Appends the given number of low-order bits of the given value + // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. + function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for (let i = len - 1; i >= 0; i--) // Append bit by bit + bb.push((val >>> i) & 1); + } + + + // Returns true iff the i'th bit of x is set to 1. + function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; + } + + + // Throws an exception if the given condition is false. + function assert(cond: boolean): void { + if (!cond) + throw new Error("Assertion error"); + } + + + + /*---- Data segment class ----*/ + + /* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ + export class QrSegment { + + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + let bb: Array = [] + for (const b of data) + appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + + + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb: Array = [] + for (let i = 0; i < digits.length; ) { // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + + + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb: Array = [] + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2 + let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + + + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") + return []; + else if (QrSegment.isNumeric(text)) + return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) + return [QrSegment.makeAlphanumeric(text)]; + else + return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + + + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + let bb: Array = [] + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < (1 << 7)) + appendBits(assignVal, 8, bb); + else if (assignVal < (1 << 14)) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else + throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + + + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + public readonly mode: QrSegment.Mode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + public readonly numChars: int, + + // The data bits of this segment. Accessed through getData(). + private readonly bitData: Array) { + + if (numChars < 0) + throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits(segs: Readonly>, version: int): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= (1 << ccbits)) + return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + + + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + let result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") + result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + + + /*-- Constants --*/ + + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; + + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + + } + +} + + + +/*---- Public helper enumeration ----*/ + +namespace qrcodegen.QrCode { + + type int = number; + + + /* + * The error correction level in a QR Code symbol. Immutable. + */ + export class Ecc { + + /*-- Constants --*/ + + public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + + + /*-- Constructor and fields --*/ + + private constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + public readonly ordinal: int, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + public readonly formatBits: int) {} + + } +} + + + +/*---- Public helper enumeration ----*/ + +namespace qrcodegen.QrSegment { + + type int = number; + + + /* + * Describes how a segment's data bits are interpreted. Immutable. + */ + export class Mode { + + /*-- Constants --*/ + + public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new Mode(0x2, [ 9, 11, 13]); + public static readonly BYTE = new Mode(0x4, [ 8, 16, 16]); + public static readonly KANJI = new Mode(0x8, [ 8, 10, 12]); + public static readonly ECI = new Mode(0x7, [ 0, 0, 0]); + + + /*-- Constructor and fields --*/ + + private constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + public readonly modeBits: int, + // Number of character count bits for three different version ranges. + private readonly numBitsCharCount: [int,int,int]) {} + + + /*-- Method --*/ + + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + + } +} + +export { qrcodegen }; diff --git a/cli/test/app.test.ts b/cli/test/app.test.ts new file mode 100644 index 0000000..51cd0e6 --- /dev/null +++ b/cli/test/app.test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { appRequest, defaultName, describeDelivery, fetchDelivery, pairWithPhone, relayUrl, sendApp, unpairFromPhone } from "../src/app.js"; +import { type AppConfig, ConfigError, configPath, loadConfig, saveConfig } from "../src/config.js"; +import { lastEvent, sendNow } from "../src/deliver.js"; +import { buildEvent } from "../src/events.js"; +import { tempDir } from "./helpers.js"; + +const APP: AppConfig = { + relay: "https://relay.example", + deviceId: "d_phone", + senderId: "s_computer", + token: "cas_secret-token", + name: "Windows PC", + pairedAt: 1, +}; + +interface Call { + url: string; + method: string; + headers: Record; + body: string | undefined; +} + +/** A fetch that records each call and answers from a list, in order. A thrown Error stands for a network failure. */ +function fakeFetch(...answers: (Response | Error)[]) { + const calls: Call[] = []; + const impl = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(input), + method: init?.method ?? "GET", + headers: { ...(init?.headers as Record) }, + body: init?.body === undefined ? undefined : String(init.body), + }); + const answer = answers.shift() ?? new Response(null, { status: 500 }); + if (answer instanceof Error) throw answer; + return answer; + }) as typeof fetch; + return { impl, calls }; +} + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +const event = () => buildEvent({ kind: "done", label: "my-project", agent: "claude-code", now: Date.now() }); + +test("the relay must be HTTPS, except on this computer", () => { + assert.equal(relayUrl("https://relay.example/"), "https://relay.example"); + assert.equal(relayUrl("https://relay.example/base/"), "https://relay.example/base"); + assert.equal(relayUrl("http://127.0.0.1:8787"), "http://127.0.0.1:8787"); + assert.equal(relayUrl(undefined, { CODE_ALARM_RELAY: "https://from-env.example" }), "https://from-env.example"); + for (const bad of ["http://relay.example", "ftp://relay.example", "https://relay.example/?x=1", "https://user:pw@relay.example", "nonsense"]) { + assert.throws(() => relayUrl(bad), ConfigError, bad); + } + assert.equal(relayUrl(undefined, {}), "https://relay.codealarm.workers.dev"); + assert.equal(relayUrl(undefined, { CODE_ALARM_RELAY: "" }), "https://relay.codealarm.workers.dev"); +}); + +test("the default computer name is generic, never the hostname", () => { + assert.equal(defaultName("win32"), "Windows PC"); + assert.equal(defaultName("darwin"), "Mac"); + assert.equal(defaultName("linux"), "Linux computer"); + assert.equal(defaultName("aix"), "Computer"); +}); + +test("pairing returns the pairing to save, or the relay's own message", async () => { + const ok = fakeFetch(json(201, { sender_id: "s_1", token: "cas_t", device_id: "d_1", name: "Desk" })); + const paired = await pairWithPhone("https://relay.example", "K7Q-4MZ", "Desk", 5, ok.impl); + assert.deepEqual(paired, { ok: true, app: { relay: "https://relay.example", deviceId: "d_1", senderId: "s_1", token: "cas_t", name: "Desk", pairedAt: 5 } }); + assert.equal(ok.calls[0]!.url, "https://relay.example/v1/pair"); + assert.deepEqual(JSON.parse(ok.calls[0]!.body!), { code: "K7Q-4MZ", name: "Desk" }); + + const wrong = fakeFetch(json(404, { error: "invalid_code", message: "That code is wrong or has expired." })); + assert.deepEqual(await pairWithPhone("https://relay.example", "AAA-AAA", "Desk", 5, wrong.impl), { ok: false, message: "That code is wrong or has expired." }); + + const offline = fakeFetch(new TypeError("fetch failed")); + const result = await pairWithPhone("https://relay.example", "AAA-AAA", "Desk", 5, offline.impl); + assert.equal(result.ok, false); + assert.match((result as { message: string }).message, /couldn't reach the relay/); +}); + +test("the event request: the event as the body, the token only as a header", () => { + const e = event(); + const { url, init } = appRequest(e, APP); + assert.equal(url, "https://relay.example/v1/events"); + assert.equal(init.method, "POST"); + assert.deepEqual(init.headers, { authorization: "Bearer cas_secret-token", "content-type": "application/json" }); + assert.deepEqual(JSON.parse(init.body!), e); +}); + +test("sendApp: sent, already sent, and rate-limited are all fine", async () => { + const cases: [Response, string][] = [ + [json(200, { status: "sent", id: "x", level: "alarm" }), "app: sent"], + [json(200, { status: "duplicate", id: "x" }), "app: already sent"], + [json(200, { status: "sent", id: "x", level: "notify", limited: true }), "app: sent as a notification (rate limit)"], + ]; + for (const [answer, detail] of cases) { + const fake = fakeFetch(answer); + assert.deepEqual(await sendApp(event(), APP, fake.impl, { retryDelayMs: 0 }), { ok: true, detail }); + } +}); + +test("sendApp retries a failed push or a network error once, with the same event", async () => { + const e = event(); + const pushFailed = fakeFetch(json(502, { status: "failed", error: "push_failed" }), json(200, { status: "sent" })); + assert.deepEqual(await sendApp(e, APP, pushFailed.impl, { retryDelayMs: 0 }), { ok: true, detail: "app: sent" }); + assert.equal(pushFailed.calls.length, 2); + assert.equal(pushFailed.calls[0]!.body, pushFailed.calls[1]!.body); + + const offline = fakeFetch(new TypeError("fetch failed"), new TypeError("fetch failed")); + const result = await sendApp(e, APP, offline.impl, { retryDelayMs: 0 }); + assert.equal(result.ok, false); + assert.match(result.detail, /relay unreachable/); + assert.equal(offline.calls.length, 2); +}); + +test("sendApp doesn't retry what a retry can't fix", async () => { + for (const [answer, detail] of [ + [json(401, { error: "unauthorized" }), /isn't paired any more/], + [json(410, { error: "expired" }), /app: expired/], + [json(400, { error: "invalid_event" }), /app: invalid_event/], + [json(429, { error: "rate_limited" }), /app: rate_limited/], + [json(503, { error: "phone_unreachable" }), /app: phone_unreachable/], + ] as const) { + const fake = fakeFetch(answer); + const result = await sendApp(event(), APP, fake.impl, { retryDelayMs: 0 }); + assert.equal(result.ok, false); + assert.match(result.detail, detail); + assert.equal(fake.calls.length, 1); + assert.ok(!result.detail.includes(APP.token)); + } +}); + +test("unpairing: removed, already gone, or couldn't tell the relay", async () => { + const removed = fakeFetch(new Response(null, { status: 204 })); + assert.deepEqual(await unpairFromPhone(APP, removed.impl), { ok: true, detail: "removed" }); + assert.equal(removed.calls[0]!.method, "DELETE"); + assert.equal(removed.calls[0]!.url, "https://relay.example/v1/senders/s_computer"); + assert.equal((await unpairFromPhone(APP, fakeFetch(json(401, {})).impl)).ok, true); + assert.equal((await unpairFromPhone(APP, fakeFetch(json(500, {})).impl)).ok, false); +}); + +test("delivery status, in words", async () => { + const fake = fakeFetch(json(200, { status: "stopped", sent_at: 1000, ringing_at: 1400, ended_at: 13_400 })); + const result = await fetchDelivery(APP, "evt-1", fake.impl); + assert.equal(fake.calls[0]!.url, "https://relay.example/v1/events/evt-1"); + assert.ok(result.ok); + assert.equal(describeDelivery(result.delivery), "rang 0.4 s after the alert was sent, stopped after 12.0 s"); + assert.match(describeDelivery({ status: "sent", sent_at: 0 }), /hasn't answered yet/); + assert.match(describeDelivery({ status: "failed", sent_at: 0, detail: "FCM 503" }), /not delivered: FCM 503/); + assert.match(describeDelivery({ status: "expired", sent_at: 0 }), /stayed silent/); + assert.match(describeDelivery({ status: "notified", sent_at: 0, ringing_at: 2000 }), /notification, 2.0 s after/); + const missing = await fetchDelivery(APP, "evt-2", fakeFetch(json(404, { error: "not_found" })).impl); + assert.deepEqual(missing, { ok: false, message: "the relay has no record of it (it keeps a week)" }); +}); + +test("sendNow uses the app when paired, never ntfy as well, and remembers the event", async () => { + const home = tempDir(); + saveConfig(home, { version: 1, transport: "app", app: APP, ntfy: { server: "https://ntfy.example", topic: "t" } }); + const fake = fakeFetch(json(200, { status: "sent" })); + const e = event(); + assert.deepEqual(await sendNow(home, e, fake.impl), { ok: true, detail: "app: sent" }); + assert.equal(fake.calls.length, 1); + assert.equal(fake.calls[0]!.url, "https://relay.example/v1/events"); + assert.deepEqual(lastEvent(home), { id: e.id, kind: "done", level: "alarm", label: "my-project", sentAt: e.sent_at }); + const log = readFileSync(join(home, "log.txt"), "utf8"); + assert.match(log, /done\/alarm claude-code "my-project" sent \(app: sent\)/); + assert.ok(!log.includes(APP.token)); +}); + +test("config: an app pairing round-trips next to ntfy, and a half-written one is refused", () => { + const home = tempDir(); + saveConfig(home, { version: 1, transport: "app", app: APP }); + assert.deepEqual(loadConfig(home), { version: 1, transport: "app", app: APP }); + saveConfig(home, { version: 1, transport: "ntfy", ntfy: { server: "https://ntfy.sh", topic: "t" }, app: APP }); + assert.deepEqual(loadConfig(home)?.app, APP); + saveConfig(home, { version: 1, transport: "app" } as never); + assert.throws(() => loadConfig(home), /missing its pairing settings/); + assert.ok(existsSync(configPath(home))); +}); diff --git a/cli/test/cli.test.ts b/cli/test/cli.test.ts index a889864..96ebb04 100644 --- a/cli/test/cli.test.ts +++ b/cli/test/cli.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; @@ -82,3 +82,24 @@ test("bad durations are refused", () => { assert.equal(result.status, 1, args.join(" ")); } }); + +test("status reports a stale Codex copy, and use refreshes it (the committed bundle)", () => { + // Only a bundle refreshes Codex's copy, so this runs cli/dist/, as Codex and the plugin do. + const DIST = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dist", "code-alarm.mjs"); + const home = tempDir(); + const user = tempDir(); + const env = { ...process.env, CODE_ALARM_HOME: home, HOME: user, USERPROFILE: user }; + const bundle = (args: string[]) => spawnSync(process.execPath, [DIST, ...args], { encoding: "utf8", env, timeout: 20_000 }); + assert.equal(bundle(["init", "--topic", "code-alarm-test-topic"]).status, 0); + assert.equal(bundle(["install", "codex"]).status, 0); + const hooks = readFileSync(join(user, ".codex", "hooks.json"), "utf8"); + assert.match(bundle(["status"]).stdout, /Codex hooks: installed\n/); + + const copy = join(home, "bin", "code-alarm.mjs"); + writeFileSync(copy, "#!/usr/bin/env node\n// code-alarm, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit.\n"); + assert.match(bundle(["status"]).stdout, /Codex hooks: installed, but they run a different build .*code-alarm install codex/); + assert.match(bundle(["use", "ntfy"]).stdout, /Updated the copy of code-alarm that Codex's hooks run/); + assert.equal(readFileSync(copy, "utf8"), readFileSync(DIST, "utf8")); + assert.equal(readFileSync(join(user, ".codex", "hooks.json"), "utf8"), hooks, "hook commands unchanged"); + assert.doesNotMatch(bundle(["use", "ntfy"]).stdout, /Updated/, "nothing to do the second time"); +}); diff --git a/cli/test/install.test.ts b/cli/test/install.test.ts index 04e0062..2a7c8ce 100644 --- a/cli/test/install.test.ts +++ b/cli/test/install.test.ts @@ -1,8 +1,20 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { test } from "node:test"; -import { CODEX_EVENTS, codexInstalled, codexPaths, codexRules, installCodex, uninstallCodex } from "../src/install.js"; +import { fileURLToPath } from "node:url"; +import { + CODEX_EVENTS, + bundleVersion, + codexCopy, + codexInstalled, + codexPaths, + codexRules, + describeCodexCopy, + installCodex, + refreshCodexCopy, + uninstallCodex, +} from "../src/install.js"; import { tempDir } from "./helpers.js"; function fixture(existing?: unknown) { @@ -117,3 +129,85 @@ test("an invalid hooks file is never overwritten", () => { assert.throws(() => installCodex({ alarmHome: f.alarmHome, userHome: f.userHome, bundle: f.bundle, now: 1 }), /isn't valid JSON/); assert.equal(readFileSync(f.paths.hooksFile, "utf8"), "{ this is not json"); }); + +/** A file that looks like a bundle cli/build.mjs made, with or without the version (0.1.0 had none). */ +function fakeBundle(path: string, version: string | null, body: string): string { + const banner = `// code-alarm${version ? ` ${version}` : ""}, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit.`; + writeFileSync(path, `#!/usr/bin/env node\n${banner}\n${body}\n`); + return path; +} + +/** Codex installed from one bundle; `newer` is the code-alarm running later, after an update. */ +function installedFrom(version: string | null, body = "// old build") { + const f = fixture(theirs); + installCodex({ alarmHome: f.alarmHome, userHome: f.userHome, bundle: fakeBundle(join(f.root, "old.mjs"), version, body), now: 1 }); + const codexFiles = () => [f.paths.hooksFile, f.paths.rulesFile, join(f.paths.skillDir, "SKILL.md")].map((path) => readFileSync(path, "utf8")); + return { ...f, codexFiles, newer: (v: string | null, b = "// new build") => fakeBundle(join(f.root, "new.mjs"), v, b) }; +} + +test("the built bundle's banner carries the package version", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const { version } = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8")); + assert.equal(bundleVersion(readFileSync(join(here, "..", "..", "dist", "code-alarm.mjs"), "utf8")), version); +}); + +test("Codex's copy is current when it's the same file as the running CLI", () => { + const f = installedFrom("0.2.0"); + assert.deepEqual(codexCopy(f.alarmHome, f.userHome, join(f.root, "old.mjs")), { state: "current" }); + assert.equal(refreshCodexCopy(f.alarmHome, f.userHome, join(f.root, "old.mjs")), null); +}); + +test("an older copy is reported, and refreshing it changes only the copy, not the hooks", () => { + const f = installedFrom("0.1.0"); + const before = f.codexFiles(); + const running = f.newer("0.2.0"); + const copy = codexCopy(f.alarmHome, f.userHome, running); + assert.equal(copy.state, "stale"); + assert.match(describeCodexCopy(copy)!, /older code-alarm \(0\.1\.0; this is 0\.2\.0\).*code-alarm install codex/); + assert.match(refreshCodexCopy(f.alarmHome, f.userHome, running)!, /^Updated the copy/); + assert.equal(readFileSync(f.paths.cli, "utf8"), readFileSync(running, "utf8")); + assert.deepEqual(f.codexFiles(), before, "hook commands, rules and skill byte-identical: Codex needn't trust them again"); + assert.deepEqual(codexCopy(f.alarmHome, f.userHome, running), { state: "current" }); + assert.ok(!readdirSync(dirname(f.paths.cli)).some((name) => name.endsWith(".tmp")), "no temporary file left"); +}); + +test("a different build of the same version, or a copy with no version, counts as stale", () => { + for (const [old, now] of [["0.2.0", "0.2.0"], [null, "0.2.0"]] as const) { + const f = installedFrom(old); + const copy = codexCopy(f.alarmHome, f.userHome, f.newer(now)); + assert.equal(copy.state, "stale", `${old} -> ${now}`); + assert.match(describeCodexCopy(copy)!, /different build/); + } +}); + +test("a missing copy is put back", () => { + const f = installedFrom("0.2.0"); + rmSync(f.paths.cli); + const running = f.newer("0.2.0"); + assert.match(describeCodexCopy(codexCopy(f.alarmHome, f.userHome, running))!, /missing/); + refreshCodexCopy(f.alarmHome, f.userHome, running); + assert.equal(readFileSync(f.paths.cli, "utf8"), readFileSync(running, "utf8")); +}); + +test("a newer copy is left alone", () => { + const f = installedFrom("0.3.0"); + const running = f.newer("0.2.0"); + assert.equal(codexCopy(f.alarmHome, f.userHome, running).state, "newer"); + assert.match(refreshCodexCopy(f.alarmHome, f.userHome, running)!, /newer code-alarm \(0\.3\.0\).*left alone/); + assert.match(readFileSync(f.paths.cli, "utf8"), /code-alarm 0\.3\.0/); +}); + +test("nothing is copied without our hooks for this alarm home, or from a script that isn't a bundle", () => { + const f = installedFrom("0.1.0"); + // A development run (node build/src/main.js) must never become Codex's copy. + const dev = join(f.root, "main.js"); + writeFileSync(dev, 'import "./hook.js";\n'); + assert.equal(refreshCodexCopy(f.alarmHome, f.userHome, dev), null); + // Hooks that point at another alarm home's copy. + assert.equal(refreshCodexCopy(join(f.root, "other-home"), f.userHome, f.newer("0.2.0")), null); + assert.ok(!existsSync(join(f.root, "other-home"))); + uninstallCodex({ alarmHome: f.alarmHome, userHome: f.userHome }); + assert.equal(refreshCodexCopy(f.alarmHome, f.userHome, f.newer("0.2.0")), null); + assert.ok(!existsSync(f.paths.cli)); + assert.match(readFileSync(join(f.root, "old.mjs"), "utf8"), /0\.1\.0/); +}); diff --git a/cli/test/pair.test.ts b/cli/test/pair.test.ts new file mode 100644 index 0000000..a115a53 --- /dev/null +++ b/cli/test/pair.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { type IncomingMessage, createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { dirname, join } from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { defaultName } from "../src/app.js"; +import { tempDir } from "./helpers.js"; + +const MAIN = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "main.js"); +const TOKEN = "cas_fake-relay-token"; + +interface Seen { + method: string; + path: string; + auth: string | undefined; + body: unknown; +} + +/** A stand-in for the relay: just enough of its API (docs/protocol.md) to drive the commands. */ +const seen: Seen[] = []; +const qr = { tokenHash: "", polls: 0 }; +let relay = ""; +const server = createServer(async (req: IncomingMessage, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : undefined; + seen.push({ method: req.method!, path: req.url!, auth: req.headers.authorization, body }); + const reply = (status: number, data?: unknown) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(data === undefined ? "" : JSON.stringify(data)); + }; + const authed = req.headers.authorization === `Bearer ${TOKEN}`; + if (req.method === "POST" && req.url === "/v1/pair-requests") { + qr.tokenHash = body.token_hash; + qr.polls = 0; + return reply(201, { id: "r_1", code: "QRC-0DE", url: `${relay}/pair/QRC-0DE`, expires_at: Date.now() + 600_000 }); + } + if (req.method === "GET" && req.url === "/v1/pair-requests/r_1") { + const token = (req.headers.authorization ?? "").replace(/^Bearer /, ""); + if (createHash("sha256").update(token).digest("hex") !== qr.tokenHash) return reply(404, { error: "not_found" }); + // Pending the first time, as if the owner were still reaching for the phone. + if (qr.polls++ === 0) return reply(200, { status: "pending", expires_at: Date.now() + 600_000 }); + return reply(200, { status: "approved", sender_id: "s_qr", device_id: "d_1", name: "Scanned PC" }); + } + if (req.method === "POST" && req.url === "/v1/pair") { + if (body.code !== "K7Q-4MZ") return reply(404, { error: "invalid_code", message: "That code is wrong or has expired." }); + return reply(201, { sender_id: "s_1", token: TOKEN, device_id: "d_1", name: body.name }); + } + if (req.method === "POST" && req.url === "/v1/events" && authed) return reply(200, { status: "sent", id: body.id, level: body.level }); + if (req.method === "GET" && req.url?.startsWith("/v1/events/") && authed) { + return reply(200, { status: "stopped", sent_at: 1000, ringing_at: 1500, ended_at: 4500 }); + } + if (req.method === "DELETE" && req.url === "/v1/senders/s_1" && authed) return reply(204); + reply(401, { error: "unauthorized" }); +}); + +before(async () => { + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + relay = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); +after(() => { + server.close(); +}); + +/** Runs the CLI without blocking, so the fake relay in this process can answer it. */ +function run(args: string[], home: string, env: Record = {}): Promise<{ status: number | null; stdout: string }> { + return new Promise((done) => { + // CODE_ALARM_RELAY points every run at the fake relay, so no test ever reaches the real one. + const child = spawn(process.execPath, [MAIN, ...args], { env: { ...process.env, CODE_ALARM_HOME: home, CODE_ALARM_RELAY: relay, ...env } }); + let stdout = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stdout += chunk)); + child.on("close", (status) => done({ status, stdout })); + }); +} + +test("pair, test, status --last, use and unpair against a relay", async () => { + const home = tempDir(); + assert.equal((await run(["init", "--topic", "code-alarm-test-topic"], home)).status, 0); + + const paired = await run(["pair", "K7Q-4MZ", "--relay", relay], home); + assert.equal(paired.status, 0, paired.stdout); + assert.match(paired.stdout, new RegExp(`lists this computer as "${defaultName()}"`)); + assert.match(paired.stdout, /ntfy settings are kept/); + assert.deepEqual(seen.at(-1)?.body, { code: "K7Q-4MZ", name: defaultName() }); + const config = JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + assert.equal(config.transport, "app"); + assert.equal(config.ntfy.topic, "code-alarm-test-topic"); + + const status = await run(["status"], home); + assert.match(status.stdout, /Transport: Code Alarm app, paired as ".+" through http:\/\/127\.0\.0\.1:\d+ \(ntfy is set up too\)/); + assert.ok(!status.stdout.includes(TOKEN), "status never shows the token"); + + const sent = await run(["test"], home); + assert.equal(sent.status, 0, sent.stdout); + const post = seen.at(-1)!; + assert.equal(post.path, "/v1/events"); + assert.equal(post.auth, `Bearer ${TOKEN}`); + assert.deepEqual(Object.keys(post.body as object).sort(), ["agent", "expires_at", "id", "kind", "label", "level", "sent_at", "v"]); + + const last = await run(["status", "--last"], home); + assert.match(last.stdout, /Last alert: test\/alarm "test" at \d\d:\d\d: rang 0\.5 s after the alert was sent, stopped after 3\.0 s/); + assert.equal(seen.at(-1)?.path, `/v1/events/${(post.body as { id: string }).id}`); + + assert.match((await run(["use", "ntfy"], home)).stdout, /through ntfy/); + assert.match((await run(["status", "--last"], home)).stdout, /--last needs the Code Alarm app/); + assert.match((await run(["use", "app"], home)).stdout, /through the Code Alarm app/); + + const unpaired = await run(["unpair"], home); + assert.match(unpaired.stdout, /Unpaired: the phone no longer lists this computer/); + assert.equal(seen.at(-1)?.method, "DELETE"); + const after = JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + assert.equal(after.transport, "ntfy"); + assert.equal(after.app, undefined); +}); + +test("pair refuses a wrong code and a relay that isn't HTTPS, and takes the relay from CODE_ALARM_RELAY", async () => { + const home = tempDir(); + const wrong = await run(["pair", "AAA-AAA"], home); + assert.equal(wrong.status, 1); + assert.match(wrong.stdout, /Couldn't pair: That code is wrong or has expired/); + assert.equal((await run(["pair", "K7Q-4MZ", "--relay", "http://relay.example"], home)).status, 1); + assert.equal((await run(["pair", "K7Q-4MZ"], home)).status, 0); + assert.equal(JSON.parse(readFileSync(join(home, "config.json"), "utf8")).app.relay, relay); +}); + +test("pair with no code shows a QR, waits for the phone, and keeps the token it made", async () => { + const home = tempDir(); + const result = await run(["pair", "--relay", relay], home, { CODE_ALARM_POLL_MS: "50" }); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /Scan QR code/); + assert.match(result.stdout, /▀|▄|█/, "a QR made of half blocks"); + assert.match(result.stdout, /\(code QRC-0DE, works once, until \d\d:\d\d\)/); + assert.match(result.stdout, /Paired\. Your phone lists this computer as "Scanned PC"/); + const ask = seen.find((s) => s.path === "/v1/pair-requests")!; + assert.deepEqual(Object.keys(ask.body as object).sort(), ["name", "token_hash"]); + const config = JSON.parse(readFileSync(join(home, "config.json"), "utf8")); + assert.equal(config.transport, "app"); + assert.equal(config.app.senderId, "s_qr"); + // The token was made here; only its hash ever went to the relay. + assert.equal(createHash("sha256").update(config.app.token).digest("hex"), (ask.body as { token_hash: string }).token_hash); + assert.ok(seen.every((s) => !JSON.stringify(s.body ?? "").includes(config.app.token)), "no request body carries the token"); +}); diff --git a/cli/test/privacy.test.ts b/cli/test/privacy.test.ts index 7d0108b..7f179f5 100644 --- a/cli/test/privacy.test.ts +++ b/cli/test/privacy.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; +import { hostname } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { appRequest, defaultName, pairRequest, pairingAskRequest } from "../src/app.js"; import { HOUR } from "../src/duration.js"; import { EVENT_FIELDS, buildEvent, labelFor } from "../src/events.js"; import { runHook } from "../src/hook.js"; @@ -62,3 +64,29 @@ test("buildEvent keeps the label short even when given a long one", () => { assert.equal(event.label.length, 40); assert.equal(event.expires_at - event.sent_at, 30 * 60_000); }); + +test("nothing from the hook payload reaches the relay request either", async () => { + const { home, project, sent, clock, deps } = setup(); + armProject(home, project, clock.now + HOUR, clock.now); + await runHook("claude", payload("Stop", { cwd: project }), deps()); + const app = { relay: "https://relay.example", deviceId: "d_1", senderId: "s_1", token: "cas_t", name: "PC", pairedAt: 0 }; + const { url, init } = appRequest(sent[0]!, app); + assert.deepEqual(Object.keys(JSON.parse(init.body!)).sort(), [...EVENT_FIELDS].sort()); + assert.deepEqual(Object.keys(init.headers).sort(), ["authorization", "content-type"]); + const request = url + JSON.stringify(init); + for (const secret of SECRETS) assert.ok(!request.includes(secret), `relay request leaks ${secret}`); +}); + +test("QR pairing sends a name and a token's hash, never the token or the hostname", () => { + const token = "cas_" + "x".repeat(43); + const { init } = pairingAskRequest("https://relay.example", defaultName(), token); + assert.deepEqual(Object.keys(JSON.parse(init.body!)).sort(), ["name", "token_hash"]); + assert.ok(!init.body!.includes(token), "the token never leaves the computer"); + assert.ok(!init.body!.includes(hostname()), "the hostname would leave the computer"); +}); + +test("pairing sends the code and a name, never the hostname", () => { + const { init } = pairRequest("https://relay.example", "K7Q-4MZ", defaultName()); + assert.deepEqual(Object.keys(JSON.parse(init.body!)).sort(), ["code", "name"]); + assert.ok(!init.body!.includes(hostname()), "the hostname would leave the computer"); +}); diff --git a/cli/test/protocol.test.ts b/cli/test/protocol.test.ts new file mode 100644 index 0000000..7a21ca5 --- /dev/null +++ b/cli/test/protocol.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { decodeEvent } from "../src/deliver.js"; +import { AGENTS, type AlarmEvent, EVENT_FIELDS, KINDS, LEVELS, buildEvent, labelFor } from "../src/events.js"; + +/** + * The CLI's half of the contract in docs/protocol-examples.json. The relay checks the same file + * from its side, so an event the CLI builds is one the relay accepts. + */ +interface Examples { + now: number; + valid: AlarmEvent[]; + invalid: { why: string; status: number; event: Record }[]; +} +const examples = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "docs", "protocol-examples.json"), "utf8"), +) as Examples; + +// The protocol's rules, as docs/protocol.md states them. +const LABEL = /^[A-Za-z0-9._ -]{0,40}$/; +const ID = /^[A-Za-z0-9-]{8,64}$/; +const HOUR = 60 * 60_000; + +function followsTheProtocol(event: Record, now: number): boolean { + const keys = Object.keys(event).sort(); + if (JSON.stringify(keys) !== JSON.stringify([...EVENT_FIELDS].sort())) return false; + const e = event as unknown as AlarmEvent; + return ( + e.v === 1 && + typeof e.id === "string" && ID.test(e.id) && + KINDS.includes(e.kind) && LEVELS.includes(e.level) && AGENTS.includes(e.agent) && + typeof e.label === "string" && LABEL.test(e.label) && + Number.isSafeInteger(e.sent_at) && Number.isSafeInteger(e.expires_at) && + e.expires_at > e.sent_at && e.expires_at - e.sent_at <= HOUR && + e.sent_at <= now + 5 * 60_000 && e.expires_at > now + ); +} + +test("the examples agree with the rules: every valid one passes, every invalid one fails", () => { + assert.ok(examples.valid.length > 0 && examples.invalid.length > 0); + for (const event of examples.valid) assert.ok(followsTheProtocol(event as unknown as Record, examples.now), JSON.stringify(event)); + for (const { why, event } of examples.invalid) assert.ok(!followsTheProtocol(event, examples.now), why); +}); + +test("the detached sender accepts every valid example", () => { + for (const event of examples.valid) { + assert.ok(decodeEvent(Buffer.from(JSON.stringify(event)).toString("base64url")), JSON.stringify(event)); + } +}); + +test("every event the CLI builds follows the protocol, whatever the folder is called", () => { + const folders = [ + "C:\Users\someone\secret-client\billing", + "/home/someone/work/my-app/", + "/x/naïve proj$ect (v2)", + "/x/" + "a".repeat(100), + "/", + "C:\\", + "/tmp/.hidden", + "/x/日本語のプロジェクト", + "/x/emoji 🚀 launch", + ]; + const now = Date.now(); + for (const folder of folders) { + for (const kind of KINDS) { + for (const level of LEVELS) { + for (const agent of AGENTS) { + const event = buildEvent({ kind, level, label: labelFor(folder), agent, now, idParts: ["a", "s", "t", kind] }); + assert.ok(followsTheProtocol(event as unknown as Record, now), `${folder} ${kind} ${level} ${agent}: ${JSON.stringify(event)}`); + } + } + } + } + assert.ok(followsTheProtocol(buildEvent({ kind: "test", label: "test", agent: "cli", now }) as unknown as Record, now)); +}); diff --git a/cli/test/qr.test.ts b/cli/test/qr.test.ts new file mode 100644 index 0000000..223ac2f --- /dev/null +++ b/cli/test/qr.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { qrForTerminal, qrModules } from "../src/qr.js"; + +const URL_TEXT = "HTTPS://RELAY.CODEALARM.WORKERS.DEV/PAIR/K7Q-4MZ"; + +test("the pairing URL in upper case makes a version 3 code, 29 modules", () => { + assert.equal(qrModules(URL_TEXT).length, 29); +}); + +test("the code has its three finder patterns", () => { + const m = qrModules(URL_TEXT); + const n = m.length; + // A finder: a 7x7 dark ring, a light ring, and a 3x3 dark centre. + const finderAt = (x0: number, y0: number) => { + for (let y = 0; y < 7; y++) { + for (let x = 0; x < 7; x++) { + const ring = x === 0 || y === 0 || x === 6 || y === 6; + const centre = x >= 2 && x <= 4 && y >= 2 && y <= 4; + if (m[y0 + y]![x0 + x] !== (ring || centre)) return false; + } + } + return true; + }; + assert.ok(finderAt(0, 0)); + assert.ok(finderAt(n - 7, 0)); + assert.ok(finderAt(0, n - 7)); +}); + +test("the terminal version packs two rows per line, black on white, with a quiet zone", () => { + const lines = qrForTerminal(URL_TEXT).split("\n"); + assert.equal(lines.length, Math.ceil((29 + 4) / 2)); + for (const line of lines) { + assert.ok(line.startsWith("\x1b[30;47m") && line.endsWith("\x1b[0m")); + assert.equal(line.replace(/\x1b\[[0-9;]*m/g, "").length, 29 + 4); + } + // The quiet zone: the first line is blank. + assert.equal(lines[0]!.replace(/\x1b\[[0-9;]*m/g, "").trim(), ""); +}); diff --git a/docs/agents.md b/docs/agents.md index a26948f..32ed770 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -52,7 +52,9 @@ Docs: [hooks](https://learn.chatgpt.com/docs/hooks), shape as Claude Code. - **On by default, but every hook must be trusted before it runs.** Trust is tied to the hook's exact command, so a changed command is skipped until it's trusted again. That's why - `code-alarm install codex` points the hooks at a fixed copy of the CLI, which updates in place. + `code-alarm install codex` points the hooks at a fixed copy of the CLI, which updates in place: + `status` reports a copy that differs from the running CLI, and `init`, `pair`, `unpair` and + `use` refresh it, since an old copy can't read newer settings and fails silently. - `async: true` runs a hook in the background, so it can't block or decide anything. We use it for all four hooks. - A hook that exits normally may leave detached helpers running: on Windows, Codex runs each hook diff --git a/docs/protocol-examples.json b/docs/protocol-examples.json new file mode 100644 index 0000000..816939a --- /dev/null +++ b/docs/protocol-examples.json @@ -0,0 +1,296 @@ +{ + "about": "Examples of protocol version 1 events (docs/protocol.md), checked by both sides: the CLI's tests (cli/test/protocol.test.ts) and the Code Alarm relay's. 'now' is the clock to check them against. 'status' is the relay's HTTP answer to an invalid event.", + "now": 1800000000000, + "valid": [ + { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + }, + { + "v": 1, + "id": "0a1b2c3d-0000-4000-8000-000000000001", + "kind": "needs_input", + "level": "notify", + "label": "billing.api_v2", + "agent": "codex", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + }, + { + "v": 1, + "id": "0a1b2c3d-0000-4000-8000-000000000002", + "kind": "failed", + "level": "alarm", + "label": "my app 2", + "agent": "cli", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + }, + { + "v": 1, + "id": "0a1b2c3d-0000-4000-8000-000000000003", + "kind": "stopped", + "level": "alarm", + "label": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "agent": "codex", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + }, + { + "v": 1, + "id": "0a1b2c3d-0000-4000-8000-000000000004", + "kind": "test", + "level": "alarm", + "label": "test", + "agent": "cli", + "sent_at": 1800000000000, + "expires_at": 1800003600000 + } + ], + "invalid": [ + { + "why": "a field outside the protocol (cwd)", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000, + "cwd": "C:\\work\\secret" + } + }, + { + "why": "a field outside the protocol (last_assistant_message)", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000, + "last_assistant_message": "the answer is 42" + } + }, + { + "why": "a Windows path as the label", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "C:\\Users\\me\\project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "a Unix path as the label", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "/home/me/project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "a label over 40 characters", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "an unknown kind", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "custom", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "an unknown level", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "loud", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "an unknown agent", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "cursor", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "another protocol version", + "status": 400, + "event": { + "v": 2, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "no id", + "status": 400, + "event": { + "v": 1, + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "no label", + "status": 400, + "event": { + "v": 1, + "id": "0a1b2c3d-0000-4000-8000-0000000000ff", + "kind": "done", + "level": "alarm", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800001800000 + } + }, + { + "why": "a timestamp as a string", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": "1800000000000", + "expires_at": 1800001800000 + } + }, + { + "why": "timestamps in seconds, not milliseconds", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000, + "expires_at": 1800001800 + } + }, + { + "why": "a lifetime over an hour", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1800003660000 + } + }, + { + "why": "expiring before it was sent", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000000000, + "expires_at": 1799999999999 + } + }, + { + "why": "sent more than 5 minutes in the future", + "status": 400, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1800000600000, + "expires_at": 1800001200000 + } + }, + { + "why": "already expired", + "status": 410, + "event": { + "v": 1, + "id": "6f1c2a9e-4b7d-8e21-a0c3-5d9f1e2b7c44", + "kind": "done", + "level": "alarm", + "label": "my-project", + "agent": "claude-code", + "sent_at": 1799998140000, + "expires_at": 1799999940000 + } + } + ] +} diff --git a/docs/protocol.md b/docs/protocol.md index c559b99..5cfa26c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -22,12 +22,15 @@ computer: no prompts, code, file paths, transcripts or assistant messages. A tes | Field | Meaning | |---|---| | `v` | Protocol version, `1` | -| `id` | Hook events: derived from the agent, session, turn and kind, so a hook that fires twice for the same turn produces the same ID. Explicit signals: random | +| `id` | 8 to 64 letters, digits and dashes; the CLI sends a UUID. Hook events: derived from the agent, session, turn and kind, so a hook that fires twice for the same turn produces the same ID. Explicit signals: random | | `kind` | `done`, `needs_input`, `failed`, `stopped` or `test` | | `level` | `alarm`: loud, repeating, through Do Not Disturb (the default). `notify`: one normal notification that pops up with sound and vibration, following the phone's sound settings. It comes from how the work was armed (`--notify`) | -| `label` | The armed project folder's **name**: plain characters only, at most 40, never a path | +| `label` | The armed project folder's **name**, never a path: at most 40 of `A–Z a–z 0–9 . _ -` and spaces | | `agent` | `claude-code`, `codex` or `cli` | -| `sent_at`, `expires_at` | Milliseconds since the epoch. Events live 30 minutes; a receiver must ignore an expired one | +| `sent_at`, `expires_at` | Milliseconds since the epoch. Events live 30 minutes, and never more than an hour; a receiver must ignore an expired one, and the relay refuses a `sent_at` more than 5 minutes ahead of its clock | + +Every field is required, and no other field is allowed. [`protocol-examples.json`](protocol-examples.json) +has valid and invalid examples. The CLI's tests check its events against them, and so do the relay's. ## When an event is sent @@ -91,6 +94,54 @@ vibration ([format](https://docs.ntfy.sh/publish/#publish-as-json)). The ntfy topic and any access token live only in your config file. -**Code Alarm app** (planned). The same event goes to a relay, which pushes it to the Code Alarm -Android app. The app rings like an alarm clock, through Do Not Disturb, until you press Stop. -The app rejects expired events and events whose `id` it has already seen. +**Code Alarm app** (in testing; the app isn't public yet). The same event goes to the Code Alarm +relay (`https://relay.codealarm.workers.dev`), which pushes it to the paired Android phone. The app rings like an alarm clock, through Do +Not Disturb, until you press Stop. It rejects expired events and events whose `id` it has already +seen. + +*Pairing, with a QR.* `code-alarm pair` makes a new 256-bit token on this computer and sends the +relay exactly two things: the name, and the token's SHA-256. The token itself never leaves the +computer. + +```json +{ "name": "Windows PC", "token_hash": "3f1c…64 hex characters" } +``` + +The relay answers with a code. The CLI shows it as a QR of +`https://relay.codealarm.workers.dev/pair/`, which works once for 10 minutes. The app scans +it, shows the computer's name, and pairs only when you tap Pair. Meanwhile the CLI asks the relay +every few seconds, with the token, whether a phone said yes. + +*Pairing, with a typed code.* The app can show a six-character code instead, valid for 10 minutes +and usable once. `code-alarm pair ` sends the relay exactly two things: + +```json +{ "code": "K7Q-4MZ", "name": "Windows PC" } +``` + +The relay answers with a token. Either way: +- the name is how the phone lists this computer: `--name`, or a generic one for the operating + system, never the hostname; +- the token can ring that one phone and nothing else; +- it lives in your config file (readable only by you), and is never printed or logged. Pairing +switches the transport to the app and keeps any ntfy settings (`code-alarm use ntfy` switches +back). Code Alarm sends through one transport at a time, never both, which would ring twice. + +*Sending.* `POST /v1/events` with `Authorization: Bearer `; the body is the event, +byte for byte. The relay checks it against this page too: it refuses any other field, a label that +isn't plain characters, and an event that lives longer than an hour or has expired. It answers: + +| Answer | The CLI | +|---|---| +| 200 `{"status": "sent"}` | Done. `"limited": true` means it went out as a notification: over 12 events an hour from this computer, or 3 a minute to the phone | +| 200 `{"status": "duplicate"}` | Done: the phone already has that `id`, so it won't ring again | +| 502 `push_failed` | Tries once more, with the same event | +| 401 | This computer was removed on the phone: pair again | +| 400, 410, 429, 503 | Logs it. 429 means over 60 events in an hour; 503 means the phone needs to open the app once | + +*Status.* `code-alarm status --last` asks `GET /v1/events/` what the phone did with the +last alert: whether it rang, how long after it was sent, and when it was stopped. The relay keeps +that, without the label, for a week. + +*Unpairing.* `code-alarm unpair` (`DELETE /v1/senders/`), or remove the computer in the +app. After that the token can't ring anything. diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index bf6c12c..5dec252 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-alarm", - "version": "0.1.0", + "version": "0.2.0", "description": "A real alarm on your phone when Claude Code finishes or needs you. Arm a task with /code-alarm:alarm; only armed work rings.", "author": { "name": "Yevhen Mishchenko", diff --git a/plugins/claude-code/bin/code-alarm.mjs b/plugins/claude-code/bin/code-alarm.mjs index cf830c9..a766cb4 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -1,14 +1,17 @@ #!/usr/bin/env node -// code-alarm, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. +// code-alarm 0.2.0, MIT License, https://github.com/Draganoider/code-alarm. Generated by cli/build.mjs: do not edit. // src/main.ts import { homedir as homedir2 } from "node:os"; import { resolve as resolve3 } from "node:path"; import { parseArgs } from "node:util"; +// src/app.ts +import { createHash, randomBytes as randomBytes2 } from "node:crypto"; + // src/config.ts import { randomBytes } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; // src/fsutil.ts @@ -28,6 +31,24 @@ var DEFAULT_NTFY_SERVER = "https://ntfy.sh"; function configPath(home) { return join(home, "config.json"); } +function readNtfy(value) { + const ntfy = value; + if (!ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") return void 0; + return { server: ntfy.server, topic: ntfy.topic, ...ntfy.token ? { token: ntfy.token } : {} }; +} +function readApp(value) { + const app = value; + if (!app) return void 0; + for (const key of ["relay", "deviceId", "senderId", "token", "name"]) if (typeof app[key] !== "string") return void 0; + return { + relay: app.relay, + deviceId: app.deviceId, + senderId: app.senderId, + token: app.token, + name: app.name, + pairedAt: typeof app.pairedAt === "number" ? app.pairedAt : 0 + }; +} function loadConfig(home) { const path = configPath(home); if (!existsSync(path)) return null; @@ -38,15 +59,18 @@ function loadConfig(home) { throw new ConfigError(`${path} isn't valid JSON. Fix it, or run \`code-alarm init --force\` to start over.`); } const config = raw; - const ntfy = config.ntfy; - if (config.transport !== "ntfy" || !ntfy || typeof ntfy.server !== "string" || typeof ntfy.topic !== "string") { - throw new ConfigError(`${path} is missing its ntfy settings. Run \`code-alarm init --force\`.`); - } - return { version: 1, transport: "ntfy", ntfy: { server: ntfy.server, topic: ntfy.topic, ...ntfy.token ? { token: ntfy.token } : {} } }; + const ntfy = readNtfy(config.ntfy); + const app = readApp(config.app); + if (config.transport === "app" && app) return { version: 1, transport: "app", app, ...ntfy ? { ntfy } : {} }; + if (config.transport === "ntfy" && ntfy) return { version: 1, transport: "ntfy", ntfy, ...app ? { app } : {} }; + throw new ConfigError(`${path} is missing its ${config.transport === "app" ? "pairing" : "ntfy"} settings. Run \`code-alarm init --force\` or \`code-alarm pair\`.`); } function saveConfig(home, config) { writeFileAtomic(configPath(home), JSON.stringify(config, null, 2) + "\n", 384); } +function removeConfig(home) { + rmSync(configPath(home), { force: true }); +} function newTopic() { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; const bytes = randomBytes(24); @@ -58,13 +82,204 @@ function isValidTopic(topic) { return /^[A-Za-z0-9_-]{1,64}$/.test(topic); } +// src/app.ts +var DEFAULT_RELAY = "https://relay.codealarm.workers.dev"; +var RELAY_ENV = "CODE_ALARM_RELAY"; +var LOCAL_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]); +function relayUrl(given, env = process.env) { + const raw = given ?? (env[RELAY_ENV] || DEFAULT_RELAY); + let url; + try { + url = new URL(raw); + } catch { + throw new ConfigError(`${raw} isn't a URL.`); + } + if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname))) { + throw new ConfigError("The relay must be an https:// address: the token travels with every request."); + } + if (url.search || url.hash || url.username || url.password) throw new ConfigError("The relay address takes no query, fragment or credentials."); + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} +function defaultName(platform = process.platform) { + return platform === "win32" ? "Windows PC" : platform === "darwin" ? "Mac" : platform === "linux" ? "Linux computer" : "Computer"; +} +var TIMEOUT_MS = 1e4; +async function call(request, fetchImpl, timeoutMs) { + const res = await fetchImpl(request.url, { ...request.init, signal: AbortSignal.timeout(timeoutMs) }); + const text = await res.text().catch(() => ""); + let body = {}; + try { + const parsed = text ? JSON.parse(text) : {}; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed; + } catch { + } + return { status: res.status, body }; +} +var message = (answer) => typeof answer.body.message === "string" ? answer.body.message : `the relay answered HTTP ${answer.status}`; +function pairRequest(relay, code, name) { + return { + url: `${relay}/v1/pair`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, name }) } + }; +} +async function pairWithPhone(relay, code, name, now, fetchImpl = fetch) { + let answer; + try { + answer = await call(pairRequest(relay, code, name), fetchImpl, TIMEOUT_MS); + } catch (e) { + return { ok: false, message: `couldn't reach the relay at ${relay} (${e instanceof Error ? e.name : "error"})` }; + } + const { sender_id, token, device_id, name: listedAs } = answer.body; + if (answer.status !== 201 || typeof sender_id !== "string" || typeof token !== "string" || typeof device_id !== "string") { + return { ok: false, message: message(answer) }; + } + return { + ok: true, + app: { relay, deviceId: device_id, senderId: sender_id, token, name: typeof listedAs === "string" ? listedAs : name, pairedAt: now } + }; +} +function newSenderToken() { + return `cas_${randomBytes2(32).toString("base64url")}`; +} +function pairingAskRequest(relay, name, token) { + const tokenHash = createHash("sha256").update(token).digest("hex"); + return { + url: `${relay}/v1/pair-requests`, + init: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, token_hash: tokenHash }) } + }; +} +async function askToPair(relay, name, fetchImpl = fetch) { + const token = newSenderToken(); + let answer; + try { + answer = await call(pairingAskRequest(relay, name, token), fetchImpl, TIMEOUT_MS); + } catch (e) { + return { ok: false, message: `couldn't reach the relay at ${relay} (${e instanceof Error ? e.name : "error"})` }; + } + const { id, code, url, expires_at } = answer.body; + if (answer.status !== 201 || typeof id !== "string" || typeof code !== "string" || typeof url !== "string" || typeof expires_at !== "number") { + return { ok: false, message: message(answer) }; + } + return { ok: true, ask: { relay, id, code, url, expiresAt: expires_at, token, name } }; +} +async function pollPairing(ask, now, fetchImpl = fetch) { + let answer; + try { + answer = await call( + { url: `${ask.relay}/v1/pair-requests/${encodeURIComponent(ask.id)}`, init: { method: "GET", headers: { authorization: `Bearer ${ask.token}` } } }, + fetchImpl, + TIMEOUT_MS + ); + } catch (e) { + return { status: "error", message: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } + const { status, sender_id, device_id, name } = answer.body; + if (answer.status === 200 && status === "approved" && typeof sender_id === "string" && typeof device_id === "string") { + return { + status: "approved", + app: { relay: ask.relay, deviceId: device_id, senderId: sender_id, token: ask.token, name: typeof name === "string" ? name : ask.name, pairedAt: now } + }; + } + if (answer.status === 200 && status === "pending") return { status: "pending" }; + if (answer.status === 200 && status === "expired") return { status: "expired" }; + return { status: "error", message: message(answer) }; +} +function appRequest(event, app) { + return { + url: `${app.relay}/v1/events`, + init: { + method: "POST", + headers: { authorization: `Bearer ${app.token}`, "content-type": "application/json" }, + body: JSON.stringify(event) + } + }; +} +async function sendApp(event, app, fetchImpl = fetch, options = {}) { + const { timeoutMs = TIMEOUT_MS, retryDelayMs = 3e3 } = options; + let result = { ok: false, detail: "not sent" }; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) await new Promise((done) => setTimeout(done, retryDelayMs)); + let answer; + try { + answer = await call(appRequest(event, app), fetchImpl, timeoutMs); + } catch (e) { + result = { ok: false, detail: `relay unreachable (${e instanceof Error ? e.name : "error"})` }; + continue; + } + if (answer.status === 200 && answer.body.status === "duplicate") return { ok: true, detail: "app: already sent" }; + if (answer.status === 200) { + return { ok: true, detail: answer.body.limited ? "app: sent as a notification (rate limit)" : "app: sent" }; + } + if (answer.status === 401) return { ok: false, detail: "this computer isn't paired any more: run `code-alarm pair`" }; + result = { ok: false, detail: `app: ${typeof answer.body.error === "string" ? answer.body.error : `HTTP ${answer.status}`}` }; + if (answer.status !== 502) return result; + } + return result; +} +async function unpairFromPhone(app, fetchImpl = fetch) { + try { + const answer = await call( + { url: `${app.relay}/v1/senders/${encodeURIComponent(app.senderId)}`, init: { method: "DELETE", headers: { authorization: `Bearer ${app.token}` } } }, + fetchImpl, + TIMEOUT_MS + ); + if (answer.status === 204) return { ok: true, detail: "removed" }; + if (answer.status === 401 || answer.status === 404) return { ok: true, detail: "already removed" }; + return { ok: false, detail: message(answer) }; + } catch (e) { + return { ok: false, detail: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } +} +async function fetchDelivery(app, id, fetchImpl = fetch) { + try { + const answer = await call( + { url: `${app.relay}/v1/events/${encodeURIComponent(id)}`, init: { method: "GET", headers: { authorization: `Bearer ${app.token}` } } }, + fetchImpl, + TIMEOUT_MS + ); + if (answer.status !== 200 || typeof answer.body.status !== "string" || typeof answer.body.sent_at !== "number") { + return { ok: false, message: answer.status === 404 ? "the relay has no record of it (it keeps a week)" : message(answer) }; + } + return { ok: true, delivery: answer.body }; + } catch (e) { + return { ok: false, message: `couldn't reach the relay (${e instanceof Error ? e.name : "error"})` }; + } +} +var seconds = (ms) => `${(Math.max(0, ms) / 1e3).toFixed(1)} s`; +function describeDelivery(d) { + const rang = d.ringing_at ? `${seconds(d.ringing_at - d.sent_at)} after the alert was sent` : ""; + switch (d.status) { + case "sending": + case "sent": + return "sent to the phone; it hasn't answered yet (it may be off, or offline)"; + case "failed": + return `not delivered${d.detail ? `: ${d.detail}` : ""}`; + case "received": + return "received by the phone"; + case "ringing": + return `ringing, ${rang}`; + case "notified": + return `shown as a notification, ${rang}`; + case "snoozed": + return `snoozed; it rang ${rang}`; + case "stopped": + return `rang ${rang}${d.ended_at && d.ringing_at ? `, stopped after ${seconds(d.ended_at - d.ringing_at)}` : ""}`; + case "expired": + return "the phone got it after it expired, so it stayed silent"; + case "duplicate": + return "the phone had already had it, so it stayed silent"; + default: + return d.status; + } +} + // src/deliver.ts import { spawn } from "node:child_process"; import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync } from "node:fs"; import { join as join2 } from "node:path"; // src/events.ts -import { createHash, randomUUID } from "node:crypto"; +import { createHash as createHash2, randomUUID } from "node:crypto"; var EVENT_FIELDS = ["v", "id", "kind", "level", "label", "agent", "sent_at", "expires_at"]; var KINDS = ["done", "needs_input", "failed", "stopped", "test"]; var DEFAULT_TTL_MS = 30 * 6e4; @@ -75,7 +290,7 @@ function labelFor(dir) { } function eventId(parts) { if (!parts || parts.some((part) => !part)) return randomUUID(); - const hex = createHash("sha256").update(parts.join("|")).digest("hex"); + const hex = createHash2("sha256").update(parts.join("|")).digest("hex"); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } function buildEvent(args) { @@ -158,11 +373,28 @@ function recentLog(home, count) { } async function sendNow(home, event, fetchImpl) { const config = loadConfig(home); - if (!config) return { ok: false, detail: "not set up: run `code-alarm init`" }; - const result = await sendNtfy(event, config.ntfy, fetchImpl); + if (!config) return { ok: false, detail: "not set up: run `code-alarm init` or `code-alarm pair`" }; + const result = config.transport === "app" && config.app ? await sendApp(event, config.app, fetchImpl) : await sendNtfy(event, config.ntfy, fetchImpl); logLine(home, `${event.kind}/${event.level} ${event.agent} "${event.label}" ${result.ok ? "sent" : "FAILED"} (${result.detail})`); + if (result.ok && config.transport === "app") rememberLast(home, event); return result; } +var lastPath = (home) => join2(home, "last-event.json"); +function rememberLast(home, event) { + try { + const last = { id: event.id, kind: event.kind, level: event.level, label: event.label, sentAt: event.sent_at }; + writeFileAtomic(lastPath(home), JSON.stringify(last)); + } catch { + } +} +function lastEvent(home) { + try { + const last = JSON.parse(readFileSync2(lastPath(home), "utf8")); + return typeof last.id === "string" && typeof last.sentAt === "number" ? last : null; + } catch { + return null; + } +} function sendDetached(event, script = process.argv[1]) { const payload = Buffer.from(JSON.stringify(event)).toString("base64url"); runDetached(script, ["__send", payload]); @@ -213,12 +445,12 @@ function formatDuration(ms) { } // src/hook.ts -import { createHash as createHash3 } from "node:crypto"; +import { createHash as createHash4 } from "node:crypto"; import { resolve as resolve2 } from "node:path"; // src/state.ts -import { createHash as createHash2 } from "node:crypto"; -import { closeSync, existsSync as existsSync2, linkSync, mkdirSync as mkdirSync3, openSync, readdirSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, statSync as statSync2 } from "node:fs"; +import { createHash as createHash3 } from "node:crypto"; +import { closeSync, existsSync as existsSync2, linkSync, mkdirSync as mkdirSync3, openSync, readdirSync, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync2 } from "node:fs"; import { isAbsolute, join as join3, relative, resolve } from "node:path"; var NAP_KEY = "nap"; function normalize(dir, platform = process.platform) { @@ -226,7 +458,7 @@ function normalize(dir, platform = process.platform) { return platform === "win32" ? full.toLowerCase() : full; } function keyFor(dir) { - return createHash2("sha256").update(normalize(dir)).digest("hex").slice(0, 16); + return createHash3("sha256").update(normalize(dir)).digest("hex").slice(0, 16); } var armedDir = (home) => join3(home, "armed"); var signalFile = (home, key) => join3(home, "signaled", `${key}.json`); @@ -248,7 +480,7 @@ function startFresh(home, key) { clearSignaled(home, key); const dir = join3(home, "sent"); if (!existsSync2(dir)) return; - for (const file of readdirSync(dir)) if (file.startsWith(`${key}-`)) rmSync(join3(dir, file), { force: true }); + for (const file of readdirSync(dir)) if (file.startsWith(`${key}-`)) rmSync2(join3(dir, file), { force: true }); } function listArmings(home, now) { const dir = armedDir(home); @@ -260,12 +492,12 @@ function listArmings(home, now) { try { const arming = JSON.parse(readFileSync3(path, "utf8")); if (typeof arming.until !== "number" || arming.until <= now) { - rmSync(path, { force: true }); + rmSync2(path, { force: true }); continue; } found.push({ ...arming, key: file.slice(0, -".json".length) }); } catch { - rmSync(path, { force: true }); + rmSync2(path, { force: true }); } } return found; @@ -291,7 +523,7 @@ function claim(home, arming) { renameSync2(path, claimed); } catch (e) { if (e.code === "ENOENT") return false; - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return true; } try { @@ -301,12 +533,12 @@ function claim(home, arming) { linkSync(claimed, path); } catch { } - rmSync(claimed, { force: true }); + rmSync2(claimed, { force: true }); return false; } } catch { } - rmSync(claimed, { force: true }); + rmSync2(claimed, { force: true }); return true; } function claimEvent(home, id, now) { @@ -316,7 +548,7 @@ function claimEvent(home, id, now) { for (const file of readdirSync(dir)) { try { const path = join3(dir, file); - if (now - statSync2(path).mtimeMs > 24 * 60 * 6e4) rmSync(path, { force: true }); + if (now - statSync2(path).mtimeMs > 24 * 60 * 6e4) rmSync2(path, { force: true }); } catch { } } @@ -329,18 +561,18 @@ function claimEvent(home, id, now) { function disarmProject(home, dir) { const path = join3(armedDir(home), `${keyFor(dir)}.json`); const existed = existsSync2(path); - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return existed; } function disarmAll(home) { const dir = armedDir(home); if (!existsSync2(dir)) return 0; const files = readdirSync(dir).filter((file) => file.endsWith(".json")); - for (const file of files) rmSync(join3(dir, file), { force: true }); + for (const file of files) rmSync2(join3(dir, file), { force: true }); return files.length; } function signalKey(armingKey, sessionId) { - return sessionId ? `session-${createHash2("sha256").update(sessionId).digest("hex").slice(0, 16)}` : armingKey; + return sessionId ? `session-${createHash3("sha256").update(sessionId).digest("hex").slice(0, 16)}` : armingKey; } function markSignaled(home, key, now, kind) { writeFileAtomic(signalFile(home, key), JSON.stringify({ at: now, kind })); @@ -357,7 +589,7 @@ function signaledKind(home, key) { } } function clearSignaled(home, key) { - rmSync(signalFile(home, key), { force: true }); + rmSync2(signalFile(home, key), { force: true }); } var pendingFile = (home, id) => join3(home, "pending", `${id}.json`); var sessionPrefix = (sessionId) => `${signalKey("", sessionId)}-`; @@ -372,7 +604,7 @@ function takePending(home, id, at) { try { const pending = JSON.parse(readFileSync3(path, "utf8")); if (pending.at !== at) return null; - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return pending; } catch { return null; @@ -381,14 +613,14 @@ function takePending(home, id, at) { function clearPending(home, id) { const path = pendingFile(home, id); const existed = existsSync2(path); - rmSync(path, { force: true }); + rmSync2(path, { force: true }); return existed; } function clearSessionPending(home, sessionId) { const dir = join3(home, "pending"); if (!existsSync2(dir)) return; const prefix = sessionPrefix(sessionId); - for (const file of readdirSync(dir)) if (file.startsWith(prefix)) rmSync(join3(dir, file), { force: true }); + for (const file of readdirSync(dir)) if (file.startsWith(prefix)) rmSync2(join3(dir, file), { force: true }); } function rateLimited(home, key, kind, now, windowMs) { const path = sentFile(home, key, kind); @@ -423,7 +655,7 @@ function parseHookInput(raw) { turnId: str(data.turn_id), promptId: str(data.prompt_id), command, - requestHash: request === void 0 ? void 0 : createHash3("sha256").update(request).digest("hex").slice(0, 16) + requestHash: request === void 0 ? void 0 : createHash4("sha256").update(request).digest("hex").slice(0, 16) }; } catch { return null; @@ -659,7 +891,7 @@ function sessionFromEnv(agent, env) { } // src/install.ts -import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmdirSync, rmSync as rmSync2 } from "node:fs"; +import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, rmdirSync, rmSync as rmSync3 } from "node:fs"; import { dirname as dirname2, join as join4 } from "node:path"; // src/skill.ts @@ -756,6 +988,12 @@ function readHooksFile(path) { } throw new Error(`${path} isn't valid JSON, so code-alarm won't touch it. Fix or move it, then run this again.`); } +function copyAtomic(from, to) { + mkdirSync4(dirname2(to), { recursive: true }); + const temp = `${to}.${process.pid}.tmp`; + copyFileSync(from, temp); + renameSync3(temp, to); +} var isOurFile = (path, mark) => readFileSync4(path, "utf8").includes(mark); function setAside(path, mark, now, notes) { if (!existsSync3(path) || isOurFile(path, mark)) return; @@ -769,7 +1007,7 @@ function removeOurs(path, mark, notes) { notes.push(`Left ${path} alone: code-alarm didn't write it`); return; } - rmSync2(path, { force: true }); + rmSync3(path, { force: true }); notes.push(`Removed ${path}`); } function installCodex(args) { @@ -779,8 +1017,7 @@ function installCodex(args) { throw new Error(`${paths.cli} contains " $ or \`, which can't be quoted safely in a hook command. Set CODE_ALARM_HOME to another folder and run this again.`); } const current = readHooksFile(paths.hooksFile); - mkdirSync4(dirname2(paths.cli), { recursive: true }); - copyFileSync(args.bundle, paths.cli); + copyAtomic(args.bundle, paths.cli); notes.push(`Copied the CLI to ${paths.cli}`); if (existsSync3(paths.hooksFile)) { const backup = `${paths.hooksFile}.code-alarm-backup-${args.now}`; @@ -814,9 +1051,64 @@ function uninstallCodex(args) { rmdirSync(paths.skillDir); } catch { } - rmSync2(paths.cli, { force: true }); + rmSync3(paths.cli, { force: true }); return notes; } +var BUNDLE_MARK = "Generated by cli/build.mjs"; +function bundleVersion(text) { + return /^\/\/ code-alarm (\d+\.\d+\.\d+[^\s,]*), MIT License/m.exec(text.slice(0, 1e3))?.[1] ?? null; +} +function compareVersions(a, b) { + const parts = (v) => v.split(/[-+]/)[0].split(".").map(Number); + const [x, y] = [parts(a), parts(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1; + return 0; +} +function codexCopy(alarmHome2, userHome, running) { + const paths = codexPaths(alarmHome2, userHome); + let bundle; + try { + if (!running || !hooksRun(paths)) return { state: "none" }; + bundle = readFileSync4(running); + } catch { + return { state: "none" }; + } + const text = bundle.toString("utf8", 0, 1e3); + if (!text.includes(BUNDLE_MARK)) return { state: "none" }; + const version = bundleVersion(text); + if (!existsSync3(paths.cli)) return { state: "stale", path: paths.cli, copyVersion: null, version, missing: true }; + const copy = readFileSync4(paths.cli); + if (copy.equals(bundle)) return { state: "current" }; + const copyVersion = bundleVersion(copy.toString("utf8", 0, 1e3)); + if (copyVersion && version && compareVersions(copyVersion, version) > 0) return { state: "newer", path: paths.cli, copyVersion, version }; + return { state: "stale", path: paths.cli, copyVersion, version, missing: false }; +} +function hooksRun(paths) { + const command = codexCommand(paths.cli); + const file = readHooksFile(paths.hooksFile); + return Object.values(file.hooks ?? {}).some((groups) => groups.some((group) => (group.hooks ?? []).some((h) => h.command === command))); +} +function describeCodexCopy(copy) { + if (copy.state === "stale") { + const which = copy.missing ? "but the copy of code-alarm they run is missing" : copy.copyVersion && copy.copyVersion !== copy.version ? `but they run an older code-alarm (${copy.copyVersion}; this is ${copy.version ?? "unknown"})` : "but they run a different build of code-alarm than this one"; + return `${which}, so they may send nothing. \`code-alarm install codex\` updates it; the hooks stay the same, so Codex won't ask to trust them again`; + } + if (copy.state === "newer") return `running a newer code-alarm (${copy.copyVersion}) than this one (${copy.version ?? "unknown"})`; + return null; +} +function refreshCodexCopy(alarmHome2, userHome, running) { + const copy = codexCopy(alarmHome2, userHome, running); + if (copy.state === "newer") { + return `Codex runs a newer code-alarm (${copy.copyVersion}) than this one, so its copy was left alone. If its alerts stop, update this code-alarm.`; + } + if (copy.state !== "stale") return null; + try { + copyAtomic(running, copy.path); + } catch (e) { + return `Couldn't update the copy of code-alarm that Codex's hooks run (${e instanceof Error ? e.message : String(e)}), so they may send nothing. Run \`code-alarm install codex\` to update it.`; + } + return `Updated the copy of code-alarm that Codex's hooks run, so they can read the new settings.`; +} function codexInstalled(alarmHome2, userHome) { try { const file = readHooksFile(codexPaths(alarmHome2, userHome).hooksFile); @@ -835,8 +1127,773 @@ function alarmHome(env = process.env, platform = process.platform) { return join5(env.XDG_CONFIG_HOME || join5(homedir(), ".config"), "code-alarm"); } +// src/vendor/qrcodegen.ts +var qrcodegen; +((qrcodegen2) => { + class QrCode { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + constructor(version, errorCorrectionLevel, dataCodewords, msk) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) + throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + let row = []; + for (let i = 0; i < this.size; i++) + row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); + this.isFunction.push(row.slice()); + } + this.drawFunctionPatterns(); + const allCodewords = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + if (msk == -1) { + let minPenalty = 1e9; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); + this.drawFormatBits(msk); + this.isFunction = []; + } + version; + errorCorrectionLevel; + /*-- Static factory functions (high level) --*/ + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + static encodeText(text, ecl) { + const segs = qrcodegen2.QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + static encodeBinary(data, ecl) { + const seg = qrcodegen2.QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + /*-- Static factory functions (mid level) --*/ + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) { + if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) || mask < -1 || mask > 7) + throw new RangeError("Invalid value"); + let version; + let dataUsedBits; + for (version = minVersion; ; version++) { + const dataCapacityBits2 = QrCode.getNumDataCodewords(version, ecl) * 8; + const usedBits = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits2) { + dataUsedBits = usedBits; + break; + } + if (version >= maxVersion) + throw new RangeError("Data too long"); + } + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) + ecl = newEcl; + } + let bb = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) + bb.push(b); + } + assert(bb.length == dataUsedBits); + const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - bb.length % 8) % 8, bb); + assert(bb.length % 8 == 0); + for (let padByte = 236; bb.length < dataCapacityBits; padByte ^= 236 ^ 17) + appendBits(padByte, 8, bb); + let dataCodewords = []; + while (dataCodewords.length * 8 < bb.length) + dataCodewords.push(0); + bb.forEach((b, i) => dataCodewords[i >>> 3] |= b << 7 - (i & 7)); + return new QrCode(version, ecl, dataCodewords, mask); + } + /*-- Fields --*/ + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + size; + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + mask; + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + modules = []; + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + isFunction = []; + /*-- Accessor methods --*/ + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + getModule(x, y) { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; + } + /*-- Private helper methods for constructor: Drawing function modules --*/ + // Reads this object's version field, and draws and marks all function modules. + drawFunctionPatterns() { + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + const alignPatPos = this.getAlignmentPatternPositions(); + const numAlign = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + this.drawFormatBits(0); + this.drawVersion(); + } + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + drawFormatBits(mask) { + const data = this.errorCorrectionLevel.formatBits << 3 | mask; + let rem = data; + for (let i = 0; i < 10; i++) + rem = rem << 1 ^ (rem >>> 9) * 1335; + const bits = (data << 10 | rem) ^ 21522; + assert(bits >>> 15 == 0); + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); + } + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + drawVersion() { + if (this.version < 7) + return; + let rem = this.version; + for (let i = 0; i < 12; i++) + rem = rem << 1 ^ (rem >>> 11) * 7973; + const bits = this.version << 12 | rem; + assert(bits >>> 18 == 0); + for (let i = 0; i < 18; i++) { + const color = getBit(bits, i); + const a = this.size - 11 + i % 3; + const b = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + drawFinderPattern(x, y) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const xx = x + dx; + const yy = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + drawAlignmentPattern(x, y) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + setFunctionModule(x, y, isDark) { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + /*-- Private helper methods for constructor: Codewords and masking --*/ + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + addEccAndInterleave(data) { + const ver = this.version; + const ecl = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks = numBlocks - rawCodewords % numBlocks; + const shortBlockLen = Math.floor(rawCodewords / numBlocks); + let blocks = []; + const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); + k += dat.length; + const ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) + dat.push(0); + blocks.push(dat.concat(ecc)); + } + let result = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + drawCodewords(data) { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i = 0; + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right == 6) + right = 5; + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = (right + 1 & 2) == 0; + const y = upward ? this.size - 1 - vert : vert; + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + } + } + } + assert(i == data.length * 8); + } + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + applyMask(mask) { + if (mask < 0 || mask > 7) + throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + case 7: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + default: + throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + getPenaltyScore() { + let result = 0; + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) + result += QrCode.PENALTY_N1; + else if (runX > 5) + result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) + result += QrCode.PENALTY_N1; + else if (runY > 5) + result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color = this.modules[y][x]; + if (color == this.modules[y][x + 1] && color == this.modules[y + 1][x] && color == this.modules[y + 1][x + 1]) + result += QrCode.PENALTY_N2; + } + } + let dark = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total = this.size * this.size; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); + return result; + } + /*-- Private helper functions --*/ + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + getAlignmentPatternPositions() { + if (this.version == 1) + return []; + else { + const numAlign = Math.floor(this.version / 7) + 2; + const step = Math.floor((this.version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4)) * 2; + let result = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + static getNumRawDataModules(ver) { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) + result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + static getNumDataCodewords(ver, ecl) { + return Math.floor(QrCode.getNumRawDataModules(ver) / 8) - QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + } + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + static reedSolomonComputeDivisor(degree) { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + let result = []; + for (let i = 0; i < degree - 1; i++) + result.push(0); + result.push(1); + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) + result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 2); + } + return result; + } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + static reedSolomonComputeRemainder(data, divisor) { + let result = divisor.map((_) => 0); + for (const b of data) { + const factor = b ^ result.shift(); + result.push(0); + divisor.forEach((coef, i) => result[i] ^= QrCode.reedSolomonMultiply(coef, factor)); + } + return result; + } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + static reedSolomonMultiply(x, y) { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + let z = 0; + for (let i = 7; i >= 0; i--) { + z = z << 1 ^ (z >>> 7) * 285; + z ^= (y >>> i & 1) * x; + } + assert(z >>> 8 == 0); + return z; + } + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + finderPenaltyCountPatterns(runHistory) { + const n = runHistory[1]; + assert(n <= this.size * 3); + const core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); + } + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) { + if (currentRunColor) { + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + finderPenaltyAddHistory(currentRunLength, runHistory) { + if (runHistory[0] == 0) + currentRunLength += this.size; + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + /*-- Constants and tables --*/ + // The minimum version number supported in the QR Code Model 2 standard. + static MIN_VERSION = 1; + // The maximum version number supported in the QR Code Model 2 standard. + static MAX_VERSION = 40; + // For use in getPenaltyScore(), when evaluating which mask is best. + static PENALTY_N1 = 3; + static PENALTY_N2 = 3; + static PENALTY_N3 = 40; + static PENALTY_N4 = 10; + static ECC_CODEWORDS_PER_BLOCK = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // Low + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + // Medium + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // Quartile + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30] + // High + ]; + static NUM_ERROR_CORRECTION_BLOCKS = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + // Low + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + // Medium + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + // Quartile + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81] + // High + ]; + } + qrcodegen2.QrCode = QrCode; + function appendBits(val, len, bb) { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for (let i = len - 1; i >= 0; i--) + bb.push(val >>> i & 1); + } + function getBit(x, i) { + return (x >>> i & 1) != 0; + } + function assert(cond) { + if (!cond) + throw new Error("Assertion error"); + } + class QrSegment { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + constructor(mode, numChars, bitData) { + this.mode = mode; + this.numChars = numChars; + this.bitData = bitData; + if (numChars < 0) + throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); + } + mode; + numChars; + bitData; + /*-- Static factory functions (mid level) --*/ + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + static makeBytes(data) { + let bb = []; + for (const b of data) + appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + static makeNumeric(digits) { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb = []; + for (let i = 0; i < digits.length; ) { + const n = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static makeAlphanumeric(text) { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb = []; + let i; + for (i = 0; i + 2 <= text.length; i += 2) { + let temp = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + static makeSegments(text) { + if (text == "") + return []; + else if (QrSegment.isNumeric(text)) + return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) + return [QrSegment.makeAlphanumeric(text)]; + else + return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + static makeEci(assignVal) { + let bb = []; + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) + appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(2, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1e6) { + appendBits(6, 3, bb); + appendBits(assignVal, 21, bb); + } else + throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + static isNumeric(text) { + return QrSegment.NUMERIC_REGEX.test(text); + } + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static isAlphanumeric(text) { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + /*-- Methods --*/ + // Returns a new copy of the data bits of this segment. + getData() { + return this.bitData.slice(); + } + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + static getTotalBits(segs, version) { + let result = 0; + for (const seg of segs) { + const ccbits = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) + return Infinity; + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + // Returns a new array of bytes representing the given string encoded in UTF-8. + static toUtf8ByteArray(str2) { + str2 = encodeURI(str2); + let result = []; + for (let i = 0; i < str2.length; i++) { + if (str2.charAt(i) != "%") + result.push(str2.charCodeAt(i)); + else { + result.push(parseInt(str2.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + /*-- Constants --*/ + // Describes precisely all strings that are encodable in numeric mode. + static NUMERIC_REGEX = /^[0-9]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + static ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/; + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + static ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + } + qrcodegen2.QrSegment = QrSegment; +})(qrcodegen || (qrcodegen = {})); +((qrcodegen2) => { + let QrCode; + ((QrCode2) => { + class Ecc { + // The QR Code can tolerate about 30% erroneous codewords + /*-- Constructor and fields --*/ + constructor(ordinal, formatBits) { + this.ordinal = ordinal; + this.formatBits = formatBits; + } + ordinal; + formatBits; + /*-- Constants --*/ + static LOW = new Ecc(0, 1); + // The QR Code can tolerate about 7% erroneous codewords + static MEDIUM = new Ecc(1, 0); + // The QR Code can tolerate about 15% erroneous codewords + static QUARTILE = new Ecc(2, 3); + // The QR Code can tolerate about 25% erroneous codewords + static HIGH = new Ecc(3, 2); + } + QrCode2.Ecc = Ecc; + })(QrCode = qrcodegen2.QrCode || (qrcodegen2.QrCode = {})); +})(qrcodegen || (qrcodegen = {})); +((qrcodegen2) => { + let QrSegment; + ((QrSegment2) => { + class Mode { + /*-- Constructor and fields --*/ + constructor(modeBits, numBitsCharCount) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } + modeBits; + numBitsCharCount; + /*-- Constants --*/ + static NUMERIC = new Mode(1, [10, 12, 14]); + static ALPHANUMERIC = new Mode(2, [9, 11, 13]); + static BYTE = new Mode(4, [8, 16, 16]); + static KANJI = new Mode(8, [8, 10, 12]); + static ECI = new Mode(7, [0, 0, 0]); + /*-- Method --*/ + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + numCharCountBits(ver) { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + } + QrSegment2.Mode = Mode; + })(QrSegment = qrcodegen2.QrSegment || (qrcodegen2.QrSegment = {})); +})(qrcodegen || (qrcodegen = {})); + +// src/qr.ts +function qrModules(text) { + const qr = qrcodegen.QrCode.encodeText(text, qrcodegen.QrCode.Ecc.MEDIUM); + const rows = []; + for (let y = 0; y < qr.size; y++) { + const row = []; + for (let x = 0; x < qr.size; x++) row.push(qr.getModule(x, y)); + rows.push(row); + } + return rows; +} +var QUIET = 2; +function qrForTerminal(text) { + const modules = qrModules(text); + const size = modules.length + QUIET * 2; + const dark = (x, y) => { + const mx = x - QUIET; + const my = y - QUIET; + return mx >= 0 && my >= 0 && mx < modules.length && my < modules.length && modules[my][mx]; + }; + const lines = []; + for (let y = 0; y < size; y += 2) { + let line = ""; + for (let x = 0; x < size; x++) { + const top = dark(x, y); + const bottom = y + 1 < size && dark(x, y + 1); + line += top && bottom ? "\u2588" : top ? "\u2580" : bottom ? "\u2584" : " "; + } + lines.push(`\x1B[30;47m${line}\x1B[0m`); + } + return lines.join("\n"); +} + // src/main.ts -var VERSION = true ? "0.1.0" : "dev"; +var VERSION = true ? "0.2.0" : "dev"; var DEFAULT_ARM_MS = 12 * HOUR; var HELP = `code-alarm ${VERSION}: a real alarm on your phone when Claude Code or Codex finishes or needs you. @@ -846,13 +1903,21 @@ Setup code-alarm test [--notify] Ring the phone now (or send a plain notification) code-alarm install codex Add Code Alarm's hooks and skill to Codex (uninstall codex removes them) +The Code Alarm app (in testing) + code-alarm pair [] [--name NAME] [--relay URL] + Pair with your phone: shows a QR to scan in the app, or takes the + code the app shows. Alerts then go through the app + code-alarm unpair Remove this computer from the phone + code-alarm use ntfy|app Switch between ntfy and the app when both are set up + Arming (only armed work rings) code-alarm arm [--for 12h] [--notify] Ring when the agent in this folder next finishes, stops or needs you code-alarm nap <2h> [--notify] Ring for every session until the time is up --notify: one ordinary notification instead of the alarm code-alarm disarm [--all] Cancel arming for this folder (or everything) - code-alarm status What's set up, what's armed, the last alerts + code-alarm status [--last] What's set up, what's armed, the last alerts + --last: what the phone did with the last alert (app only) Run by the agent (its skill says when) code-alarm done | needs-you | failed @@ -891,9 +1956,36 @@ function detectAgent(env) { return "cli"; } var clock = (ms) => new Date(ms).toTimeString().slice(0, 5); +var runningScript = () => process.argv[1] ? resolve3(process.argv[1]) : void 0; +function refreshCodex(home) { + const note = refreshCodexCopy(home, homedir2(), runningScript()); + if (note) console.log(note); +} function setupHint() { return "Code Alarm isn't set up on this computer yet. Run `code-alarm init` in a terminal."; } +var POLL_MS = Number(process.env.CODE_ALARM_POLL_MS) || 2500; +async function waitForPhone(ask) { + console.log("Open Code Alarm on your phone, tap Pair a computer, then Scan QR code:\n"); + console.log(qrForTerminal(ask.url.toUpperCase())); + console.log(` +(code ${ask.code}, works once, until ${clock(ask.expiresAt)})`); + console.log('No camera? In the app, choose "Show a code instead" and run: code-alarm pair '); + console.log("Waiting for your phone\u2026 Ctrl-C stops."); + let warned = false; + while (Date.now() < ask.expiresAt) { + await new Promise((done) => setTimeout(done, POLL_MS)); + const result = await pollPairing(ask, Date.now()); + if (result.status === "approved") return result.app; + if (result.status === "expired") break; + if (result.status === "error" && !warned) { + console.log(`Still waiting (${result.message}).`); + warned = true; + } + } + console.log("The code ran out before a phone approved it. Run `code-alarm pair` again."); + return null; +} async function main(argv) { const [command, ...rest] = argv; const home = alarmHome(); @@ -933,8 +2025,13 @@ async function main(argv) { args: rest, options: { server: { type: "string" }, topic: { type: "string" }, token: { type: "string" }, force: { type: "boolean" } } }); - const existing = values.force ? null : loadConfig(home); - if (existing) { + let existing = null; + try { + existing = loadConfig(home); + } catch (e) { + if (!values.force) throw e; + } + if (existing?.ntfy && !values.force) { console.log(`Already set up: ntfy topic ${existing.ntfy.topic} on ${existing.ntfy.server}.`); console.log("Run `code-alarm init --force` to start over with a new topic."); return 0; @@ -942,7 +2039,11 @@ async function main(argv) { const topic = values.topic ?? newTopic(); if (!isValidTopic(topic)) throw new ConfigError("A topic can only use letters, digits, - and _ (at most 64)."); const server = (values.server ?? DEFAULT_NTFY_SERVER).replace(/\/+$/, ""); - saveConfig(home, { version: 1, transport: "ntfy", ntfy: { server, topic, ...values.token ? { token: values.token } : {} } }); + const ntfy = { server, topic, ...values.token ? { token: values.token } : {} }; + const app = existing?.app; + saveConfig(home, app ? { version: 1, transport: "app", app, ntfy } : { version: 1, transport: "ntfy", ntfy }); + refreshCodex(home); + if (app) console.log("This computer is paired with the Code Alarm app, which still sends: `code-alarm use ntfy` switches to ntfy.\n"); console.log(`Code Alarm is set up. Server: ${server} @@ -1016,13 +2117,14 @@ Then connect your agent: return 0; } case "status": { + const { values } = parseArgs({ args: rest, options: { last: { type: "boolean" } } }); const config = loadConfig(home); - console.log( - config ? ( - // Never any part of the topic: people paste this output into bug reports. - `Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}` - ) : "Not set up: run `code-alarm init`." - ); + if (!config) console.log("Not set up: run `code-alarm init`."); + else if (config.transport === "app" && config.app) { + console.log(`Transport: Code Alarm app, paired as "${config.app.name}" through ${config.app.relay}${config.ntfy ? " (ntfy is set up too)" : ""}`); + } else if (config.ntfy) { + console.log(`Transport: ntfy, ${config.ntfy.server}, topic set${config.ntfy.token ? ", with a token" : ""}${config.app ? " (paired with the app too)" : ""}`); + } const now = Date.now(); const armings = listArmings(home, now); console.log(armings.length ? "Armed:" : "Nothing is armed."); @@ -1031,10 +2133,21 @@ Then connect your agent: const level = arming.level === "notify" ? ", notification only" : ""; console.log(` - ${what} until ${clock(arming.until)} (${formatDuration(arming.until - now)} left${level})`); } - console.log(`Codex hooks: ${codexInstalled(home, homedir2()) ? "installed" : "not installed"}`); + const copy = describeCodexCopy(codexCopy(home, homedir2(), runningScript())); + console.log(`Codex hooks: ${codexInstalled(home, homedir2()) ? `installed${copy ? `, ${copy}` : ""}` : "not installed"}`); const log = recentLog(home, 5); if (log.length) console.log(`Last activity: ${log.map((line) => ` ${line}`).join("\n")}`); + if (values.last) { + const last = lastEvent(home); + if (config?.transport !== "app" || !config.app) console.log("Last alert: --last needs the Code Alarm app; ntfy doesn't report back."); + else if (!last) console.log("Last alert: none sent through the app yet."); + else { + const result = await fetchDelivery(config.app, last.id); + const what = `${last.kind}/${last.level} "${last.label}" at ${clock(last.sentAt)}`; + console.log(`Last alert: ${what}: ${result.ok ? describeDelivery(result.delivery) : `unknown, ${result.message}`}`); + } + } return 0; } case "done": @@ -1046,13 +2159,82 @@ ${log.map((line) => ` ${line}`).join("\n")}`); console.log(result.message); return 0; } + case "pair": { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { name: { type: "string" }, relay: { type: "string" } } + }); + const code = positionals[0]; + const relay = relayUrl(values.relay); + const name = values.name?.trim() || defaultName(); + let existing = null; + try { + existing = loadConfig(home); + } catch { + } + let app; + if (code) { + const result = await pairWithPhone(relay, code, name, Date.now()); + if (!result.ok) { + console.log(`Couldn't pair: ${result.message}`); + return 1; + } + app = result.app; + } else { + const asked = await askToPair(relay, name); + if (!asked.ok) { + console.log(`Couldn't start pairing: ${asked.message}`); + return 1; + } + const approved = await waitForPhone(asked.ask); + if (!approved) return 1; + app = approved; + } + if (existing?.app && existing.app.senderId !== app.senderId) await unpairFromPhone(existing.app); + saveConfig(home, { version: 1, transport: "app", app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); + console.log(`Paired. Your phone lists this computer as "${app.name}", and alerts now go through the Code Alarm app.`); + refreshCodex(home); + if (existing?.ntfy) console.log("Your ntfy settings are kept: `code-alarm use ntfy` switches back."); + console.log("Check it with: code-alarm test"); + return 0; + } + case "unpair": { + const config = loadConfig(home); + if (!config?.app) { + console.log("This computer isn't paired with the Code Alarm app."); + return 0; + } + const result = await unpairFromPhone(config.app); + if (config.ntfy) saveConfig(home, { version: 1, transport: "ntfy", ntfy: config.ntfy }); + else removeConfig(home); + refreshCodex(home); + console.log( + result.ok ? "Unpaired: the phone no longer lists this computer." : `Unpaired here, but the relay didn't hear it (${result.detail}). Remove this computer in the app too.` + ); + console.log(config.ntfy ? "Alerts go through ntfy again." : "Nothing is set up now: `code-alarm pair ` or `code-alarm init` sets it up again."); + return 0; + } + case "use": { + const target = rest[0]; + if (target !== "ntfy" && target !== "app") throw new ConfigError("Usage: code-alarm use ntfy|app"); + const config = loadConfig(home); + if (!config?.[target]) { + console.log(target === "app" ? "Not paired with the app: run `code-alarm pair `." : "ntfy isn't set up: run `code-alarm init`."); + return 1; + } + saveConfig(home, { ...config, transport: target }); + console.log(`Alerts now go through ${target === "app" ? "the Code Alarm app" : "ntfy"}.`); + refreshCodex(home); + return 0; + } case "install": case "uninstall": { if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`); - const script = process.argv[1]; + const script = runningScript(); if (command === "install") { if (!script) throw new ConfigError("Can't tell where this code-alarm script is."); - for (const note of installCodex({ alarmHome: home, userHome: homedir2(), bundle: resolve3(script), now: Date.now() })) console.log(note); + for (const note of installCodex({ alarmHome: home, userHome: homedir2(), bundle: script, now: Date.now() })) console.log(note); console.log( ` Next: quit Codex completely and start it again. It reads rules only at startup, and closing @@ -1100,3 +2282,29 @@ main(process.argv.slice(2)).then( export { main }; +/*! @license + * QR Code generator library (TypeScript), from https://github.com/nayuki/QR-Code-generator + * (typescript-javascript/qrcodegen.ts at commit 3c6d0b3cefb4e049dc337e82237c9644399716a8), + * unchanged except for this header, the ts-nocheck line and the export at the end. + * + * QR Code generator library (TypeScript) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */