From 1447df858ac91b3b10de3f97ea4925721a11fa81 Mon Sep 17 00:00:00 2001 From: Yevhen Mishchenko <92166133+Draganoider@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:29:56 -0700 Subject: [PATCH 1/6] Add the Code Alarm app transport: pair, unpair, use, status --last `pair ` redeems the code the app shows for a token bound to that one phone, sending only the code and a name (never the hostname). Events then 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. Pairing keeps the ntfy settings, and one transport sends at a time, never both. `status --last` reads back what the phone did with the last alert. The relay isn't public yet, so `pair` needs --relay or CODE_ALARM_RELAY. --- AGENTS.md | 6 +- CHANGELOG.md | 12 + cli/dist/code-alarm.mjs | 334 ++++++++++++++++++++++--- cli/src/app.ts | 209 ++++++++++++++++ cli/src/config.ts | 58 ++++- cli/src/deliver.ts | 37 ++- cli/src/main.ts | 116 ++++++++- cli/test/app.test.ts | 179 +++++++++++++ cli/test/pair.test.ts | 115 +++++++++ cli/test/privacy.test.ts | 20 ++ docs/protocol.md | 39 ++- plugins/claude-code/bin/code-alarm.mjs | 334 ++++++++++++++++++++++--- 12 files changed, 1359 insertions(+), 100 deletions(-) create mode 100644 cli/src/app.ts create mode 100644 cli/test/app.test.ts create mode 100644 cli/test/pair.test.ts diff --git a/AGENTS.md b/AGENTS.md index 13ff64c..d915141 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), `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..b658216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f ## [Unreleased] +### 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. + - The relay isn't public yet, so `pair` needs `--relay ` (or `CODE_ALARM_RELAY`). + ## [0.1.0] — 2026-10-14 ### Added diff --git a/cli/dist/code-alarm.mjs b/cli/dist/code-alarm.mjs index cf830c9..5bb342e 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -8,7 +8,7 @@ import { parseArgs } from "node:util"; // 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 +28,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 +56,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,6 +79,152 @@ function isValidTopic(topic) { return /^[A-Za-z0-9_-]{1,64}$/.test(topic); } +// src/app.ts +var DEFAULT_RELAY = null; +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; + if (!raw) throw new ConfigError("The Code Alarm relay isn't public yet. Pass --relay if you were given one."); + 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 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"; @@ -158,11 +325,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]); @@ -218,7 +402,7 @@ 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 { 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) { @@ -248,7 +432,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 +444,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 +475,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 +485,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 +500,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,14 +513,14 @@ 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) { @@ -357,7 +541,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 +556,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 +565,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); @@ -659,7 +843,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, rmdirSync, rmSync as rmSync3 } from "node:fs"; import { dirname as dirname2, join as join4 } from "node:path"; // src/skill.ts @@ -769,7 +953,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) { @@ -814,7 +998,7 @@ function uninstallCodex(args) { rmdirSync(paths.skillDir); } catch { } - rmSync2(paths.cli, { force: true }); + rmSync3(paths.cli, { force: true }); return notes; } function codexInstalled(alarmHome2, userHome) { @@ -846,13 +1030,20 @@ 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 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 @@ -933,8 +1124,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 +1138,10 @@ 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 }); + 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 +1215,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."); @@ -1035,6 +1235,16 @@ Then connect your agent: 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,6 +1256,60 @@ ${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]; + if (!code) throw new ConfigError('Usage: code-alarm pair [--name "My laptop"]. The code is in the Code Alarm app on your phone.'); + const relay = relayUrl(values.relay); + const name = values.name?.trim() || defaultName(); + let existing = null; + try { + existing = loadConfig(home); + } catch { + } + const result = await pairWithPhone(relay, code, name, Date.now()); + if (!result.ok) { + console.log(`Couldn't pair: ${result.message}`); + return 1; + } + if (existing?.app && existing.app.senderId !== result.app.senderId) await unpairFromPhone(existing.app); + saveConfig(home, { version: 1, transport: "app", app: result.app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); + console.log(`Paired. Your phone lists this computer as "${result.app.name}", and alerts now go through the Code Alarm app.`); + 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); + 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"}.`); + return 0; + } case "install": case "uninstall": { if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`); diff --git a/cli/src/app.ts b/cli/src/app.ts new file mode 100644 index 0000000..96cc1d6 --- /dev/null +++ b/cli/src/app.ts @@ -0,0 +1,209 @@ +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 public relay's address, set once it's live. Until then, `pair --relay ` or CODE_ALARM_RELAY. */ +export const DEFAULT_RELAY: string | null = null; +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; + if (!raw) throw new ConfigError("The Code Alarm relay isn't public yet. Pass --relay if you were given one."); + 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 }, + }; +} + +/** 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/main.ts b/cli/src/main.ts index 2369d6f..63ce190 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -1,8 +1,9 @@ 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 { defaultName, describeDelivery, fetchDelivery, pairWithPhone, relayUrl, unpairFromPhone } from "./app.js"; +import { 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"; @@ -23,13 +24,20 @@ 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 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 @@ -123,9 +131,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 +147,10 @@ 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 }); + 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 +235,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."); @@ -236,6 +255,16 @@ Then connect your agent: console.log(`Codex hooks: ${codexInstalled(home, homedir()) ? "installed" : "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,6 +278,67 @@ 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]; + if (!code) throw new ConfigError('Usage: code-alarm pair [--name "My laptop"]. The code is in the Code Alarm app on your phone.'); + 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. + } + const result = await pairWithPhone(relay, code, name, Date.now()); + if (!result.ok) { + console.log(`Couldn't pair: ${result.message}`); + return 1; + } + // Pairing again replaces the old pairing, so the phone doesn't keep listing a dead entry. + if (existing?.app && existing.app.senderId !== result.app.senderId) await unpairFromPhone(existing.app); + saveConfig(home, { version: 1, transport: "app", app: result.app, ...(existing?.ntfy ? { ntfy: existing.ntfy } : {}) }); + console.log(`Paired. Your phone lists this computer as "${result.app.name}", and alerts now go through the Code Alarm app.`); + 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); + 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"}.`); + return 0; + } + case "install": case "uninstall": { if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`); diff --git a/cli/test/app.test.ts b/cli/test/app.test.ts new file mode 100644 index 0000000..8ebc178 --- /dev/null +++ b/cli/test/app.test.ts @@ -0,0 +1,179 @@ +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.throws(() => relayUrl(undefined, {}), /isn't public yet/); +}); + +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/pair.test.ts b/cli/test/pair.test.ts new file mode 100644 index 0000000..7b33279 --- /dev/null +++ b/cli/test/pair.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +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[] = []; +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") { + 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) => { + const child = spawn(process.execPath, [MAIN, ...args], { env: { ...process.env, CODE_ALARM_HOME: home, CODE_ALARM_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 needs a relay address while the relay isn't public", async () => { + const home = tempDir(); + const wrong = await run(["pair", "AAA-AAA", "--relay", relay], home); + assert.equal(wrong.status, 1); + assert.match(wrong.stdout, /Couldn't pair: That code is wrong or has expired/); + const noRelay = await run(["pair", "K7Q-4MZ"], home); + assert.equal(noRelay.status, 1); + assert.match(noRelay.stdout, /isn't public yet/); + assert.equal((await run(["pair", "K7Q-4MZ", "--relay", "http://relay.example"], home)).status, 1); +}); diff --git a/cli/test/privacy.test.ts b/cli/test/privacy.test.ts index 7d0108b..78d8bad 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 } 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,21 @@ 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("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/docs/protocol.md b/docs/protocol.md index c559b99..f18947e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -91,6 +91,39 @@ 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, 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.* The app shows a six-character code, valid for 10 minutes and usable once. +`code-alarm pair ` sends the relay exactly two things: + +```json +{ "code": "K7Q-4MZ", "name": "Windows PC" } +``` + +The name is how the phone lists this computer: `--name`, or a generic one for the operating system. +Never the hostname. The relay answers with a token that 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/bin/code-alarm.mjs b/plugins/claude-code/bin/code-alarm.mjs index cf830c9..5bb342e 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -8,7 +8,7 @@ import { parseArgs } from "node:util"; // 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 +28,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 +56,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,6 +79,152 @@ function isValidTopic(topic) { return /^[A-Za-z0-9_-]{1,64}$/.test(topic); } +// src/app.ts +var DEFAULT_RELAY = null; +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; + if (!raw) throw new ConfigError("The Code Alarm relay isn't public yet. Pass --relay if you were given one."); + 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 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"; @@ -158,11 +325,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]); @@ -218,7 +402,7 @@ 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 { 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) { @@ -248,7 +432,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 +444,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 +475,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 +485,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 +500,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,14 +513,14 @@ 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) { @@ -357,7 +541,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 +556,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 +565,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); @@ -659,7 +843,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, rmdirSync, rmSync as rmSync3 } from "node:fs"; import { dirname as dirname2, join as join4 } from "node:path"; // src/skill.ts @@ -769,7 +953,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) { @@ -814,7 +998,7 @@ function uninstallCodex(args) { rmdirSync(paths.skillDir); } catch { } - rmSync2(paths.cli, { force: true }); + rmSync3(paths.cli, { force: true }); return notes; } function codexInstalled(alarmHome2, userHome) { @@ -846,13 +1030,20 @@ 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 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 @@ -933,8 +1124,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 +1138,10 @@ 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 }); + 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 +1215,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."); @@ -1035,6 +1235,16 @@ Then connect your agent: 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,6 +1256,60 @@ ${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]; + if (!code) throw new ConfigError('Usage: code-alarm pair [--name "My laptop"]. The code is in the Code Alarm app on your phone.'); + const relay = relayUrl(values.relay); + const name = values.name?.trim() || defaultName(); + let existing = null; + try { + existing = loadConfig(home); + } catch { + } + const result = await pairWithPhone(relay, code, name, Date.now()); + if (!result.ok) { + console.log(`Couldn't pair: ${result.message}`); + return 1; + } + if (existing?.app && existing.app.senderId !== result.app.senderId) await unpairFromPhone(existing.app); + saveConfig(home, { version: 1, transport: "app", app: result.app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); + console.log(`Paired. Your phone lists this computer as "${result.app.name}", and alerts now go through the Code Alarm app.`); + 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); + 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"}.`); + return 0; + } case "install": case "uninstall": { if (rest[0] !== "codex") throw new ConfigError(`Usage: code-alarm ${command} codex`); From 8161504ce2bc5bc3cd9ca6234a69a653544ed291 Mon Sep 17 00:00:00 2001 From: Yevhen Mishchenko <92166133+Draganoider@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:48:17 -0700 Subject: [PATCH 2/6] Pair with the live Code Alarm relay by default The relay is deployed at https://relay.codealarm.workers.dev, so pair no longer needs --relay. The tests point every run at their fake relay through CODE_ALARM_RELAY, so none reaches the real one. --- CHANGELOG.md | 3 ++- cli/dist/code-alarm.mjs | 5 ++--- cli/src/app.ts | 7 +++---- cli/test/app.test.ts | 3 ++- cli/test/pair.test.ts | 12 ++++++------ docs/protocol.md | 2 +- plugins/claude-code/bin/code-alarm.mjs | 5 ++--- 7 files changed, 18 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b658216..94cd9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f 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. - - The relay isn't public yet, so `pair` needs `--relay ` (or `CODE_ALARM_RELAY`). + - `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. ## [0.1.0] — 2026-10-14 diff --git a/cli/dist/code-alarm.mjs b/cli/dist/code-alarm.mjs index 5bb342e..317f5fe 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -80,12 +80,11 @@ function isValidTopic(topic) { } // src/app.ts -var DEFAULT_RELAY = null; +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; - if (!raw) throw new ConfigError("The Code Alarm relay isn't public yet. Pass --relay if you were given one."); + const raw = given ?? (env[RELAY_ENV] || DEFAULT_RELAY); let url; try { url = new URL(raw); diff --git a/cli/src/app.ts b/cli/src/app.ts index 96cc1d6..372aca1 100644 --- a/cli/src/app.ts +++ b/cli/src/app.ts @@ -8,16 +8,15 @@ import type { SendResult } from "./ntfy.js"; * the Authorization header. */ -/** The public relay's address, set once it's live. Until then, `pair --relay ` or CODE_ALARM_RELAY. */ -export const DEFAULT_RELAY: string | null = null; +/** 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; - if (!raw) throw new ConfigError("The Code Alarm relay isn't public yet. Pass --relay if you were given one."); + const raw = given ?? (env[RELAY_ENV] || DEFAULT_RELAY); let url: URL; try { url = new URL(raw); diff --git a/cli/test/app.test.ts b/cli/test/app.test.ts index 8ebc178..51cd0e6 100644 --- a/cli/test/app.test.ts +++ b/cli/test/app.test.ts @@ -52,7 +52,8 @@ test("the relay must be HTTPS, except on this computer", () => { 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.throws(() => relayUrl(undefined, {}), /isn't public yet/); + 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", () => { diff --git a/cli/test/pair.test.ts b/cli/test/pair.test.ts index 7b33279..da9c811 100644 --- a/cli/test/pair.test.ts +++ b/cli/test/pair.test.ts @@ -55,7 +55,8 @@ after(() => { /** 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) => { - const child = spawn(process.execPath, [MAIN, ...args], { env: { ...process.env, CODE_ALARM_HOME: home, CODE_ALARM_RELAY: "", ...env } }); + // 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)); @@ -103,13 +104,12 @@ test("pair, test, status --last, use and unpair against a relay", async () => { assert.equal(after.app, undefined); }); -test("pair refuses a wrong code, and needs a relay address while the relay isn't public", async () => { +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", "--relay", relay], home); + 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/); - const noRelay = await run(["pair", "K7Q-4MZ"], home); - assert.equal(noRelay.status, 1); - assert.match(noRelay.stdout, /isn't public yet/); 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); }); diff --git a/docs/protocol.md b/docs/protocol.md index f18947e..cbe8135 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -92,7 +92,7 @@ vibration live only in your config file. **Code Alarm app** (in testing; the app isn't public yet). The same event goes to the Code Alarm -relay, which pushes it to the paired Android phone. The app rings like an alarm clock, through Do +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. diff --git a/plugins/claude-code/bin/code-alarm.mjs b/plugins/claude-code/bin/code-alarm.mjs index 5bb342e..317f5fe 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -80,12 +80,11 @@ function isValidTopic(topic) { } // src/app.ts -var DEFAULT_RELAY = null; +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; - if (!raw) throw new ConfigError("The Code Alarm relay isn't public yet. Pass --relay if you were given one."); + const raw = given ?? (env[RELAY_ENV] || DEFAULT_RELAY); let url; try { url = new URL(raw); From 9a7767800e5be2d8388454867410a2da41fcc367 Mon Sep 17 00:00:00 2001 From: Yevhen Mishchenko <92166133+Draganoider@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:26:26 -0700 Subject: [PATCH 3/6] Add protocol examples that the CLI and the relay both test against docs/protocol-examples.json holds valid and invalid version 1 events. The CLI's new test checks that every event it can build follows the same rules, and the Code Alarm relay's tests check that it accepts the valid ones and refuses the invalid ones with the stated status. protocol.md now states those rules exactly. --- CHANGELOG.md | 4 + cli/src/events.ts | 3 +- cli/test/protocol.test.ts | 78 ++++++++++ docs/protocol-examples.json | 296 ++++++++++++++++++++++++++++++++++++ docs/protocol.md | 9 +- 5 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 cli/test/protocol.test.ts create mode 100644 docs/protocol-examples.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 94cd9de..1f07b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f - `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. +- `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. + ## [0.1.0] — 2026-10-14 ### Added 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/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/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 cbe8135..6c9a389 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 From eaddb5b0f48f58eea1ac6606e0bbc72a50a47e16 Mon Sep 17 00:00:00 2001 From: Yevhen Mishchenko <92166133+Draganoider@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:20:39 -0700 Subject: [PATCH 4/6] Keep Codex's copy of the CLI in step with the running one Codex's hooks run the copy `install codex` made. An old copy can't read settings a newer version wrote (16 Oct: a 14 Oct copy refused `transport: "app"`), and since hooks always exit 0, every Codex alert would have been dropped silently. `status` now reports a copy that is missing, older or a different build. `init`, `pair`, `unpair` and `use` replace it after saving settings, unless the copy is newer. Only the copy changes, so the hook commands stay byte-identical and Codex doesn't ask to trust them again. The bundle's banner carries the version so an older copy can be named. --- CHANGELOG.md | 8 ++ README.md | 3 + cli/build.mjs | 3 +- cli/dist/code-alarm.mjs | 84 +++++++++++++++++++-- cli/src/install.ts | 100 ++++++++++++++++++++++++- cli/src/main.ts | 22 +++++- cli/test/cli.test.ts | 23 +++++- cli/test/install.test.ts | 100 ++++++++++++++++++++++++- docs/agents.md | 4 +- plugins/claude-code/bin/code-alarm.mjs | 84 +++++++++++++++++++-- 10 files changed, 404 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f07b17..be981a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f 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..ddb4a96 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 diff --git a/cli/build.mjs b/cli/build.mjs index dda6500..fa6cd3c 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -20,7 +20,8 @@ await build({ 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." }, + // 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 317f5fe..0ab48e9 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -1,5 +1,5 @@ #!/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.1.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"; @@ -842,7 +842,7 @@ function sessionFromEnv(agent, env) { } // src/install.ts -import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmdirSync, rmSync as rmSync3 } 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 @@ -939,6 +939,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; @@ -962,8 +968,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}`; @@ -1000,6 +1005,61 @@ function uninstallCodex(args) { 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); @@ -1081,6 +1141,11 @@ 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."; } @@ -1140,6 +1205,7 @@ async function main(argv) { 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. @@ -1230,7 +1296,8 @@ 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")}`); @@ -1278,6 +1345,7 @@ ${log.map((line) => ` ${line}`).join("\n")}`); if (existing?.app && existing.app.senderId !== result.app.senderId) await unpairFromPhone(existing.app); saveConfig(home, { version: 1, transport: "app", app: result.app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); console.log(`Paired. Your phone lists this computer as "${result.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; @@ -1291,6 +1359,7 @@ ${log.map((line) => ` ${line}`).join("\n")}`); 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.` ); @@ -1307,15 +1376,16 @@ ${log.map((line) => ` ${line}`).join("\n")}`); } 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 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 63ce190..683d40c 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -7,7 +7,7 @@ import { decodeEvent, lastEvent, logLine, recentLog, sendDetached, sendNow, wait 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 { skillMarkdown } from "./skill.js"; import { armNap, armProject, disarmAll, disarmProject, listArmings } from "./state.js"; @@ -81,6 +81,15 @@ 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."; } @@ -150,6 +159,7 @@ export async function main(argv: string[]): Promise { 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. @@ -252,7 +262,8 @@ 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) { @@ -303,6 +314,7 @@ Then connect your agent: if (existing?.app && existing.app.senderId !== result.app.senderId) await unpairFromPhone(existing.app); saveConfig(home, { version: 1, transport: "app", app: result.app, ...(existing?.ntfy ? { ntfy: existing.ntfy } : {}) }); console.log(`Paired. Your phone lists this computer as "${result.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; @@ -317,6 +329,7 @@ Then connect your agent: 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." @@ -336,16 +349,17 @@ Then connect your agent: } 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/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/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/plugins/claude-code/bin/code-alarm.mjs b/plugins/claude-code/bin/code-alarm.mjs index 317f5fe..0ab48e9 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -1,5 +1,5 @@ #!/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.1.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"; @@ -842,7 +842,7 @@ function sessionFromEnv(agent, env) { } // src/install.ts -import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmdirSync, rmSync as rmSync3 } 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 @@ -939,6 +939,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; @@ -962,8 +968,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}`; @@ -1000,6 +1005,61 @@ function uninstallCodex(args) { 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); @@ -1081,6 +1141,11 @@ 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."; } @@ -1140,6 +1205,7 @@ async function main(argv) { 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. @@ -1230,7 +1296,8 @@ 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")}`); @@ -1278,6 +1345,7 @@ ${log.map((line) => ` ${line}`).join("\n")}`); if (existing?.app && existing.app.senderId !== result.app.senderId) await unpairFromPhone(existing.app); saveConfig(home, { version: 1, transport: "app", app: result.app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); console.log(`Paired. Your phone lists this computer as "${result.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; @@ -1291,6 +1359,7 @@ ${log.map((line) => ` ${line}`).join("\n")}`); 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.` ); @@ -1307,15 +1376,16 @@ ${log.map((line) => ` ${line}`).join("\n")}`); } 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 From 097ce368292b0983fcf49cc34d5dbbab9e02f863 Mon Sep 17 00:00:00 2001 From: Yevhen Mishchenko <92166133+Draganoider@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:21:43 -0700 Subject: [PATCH 5/6] Pair by QR: the CLI shows a code for the app to scan code-alarm pair with no code makes a sender token here, sends the relay only its SHA-256 and the computer's name, shows the returned code as a QR, and polls with the token until the phone approves. The typed-code flow stays. The QR is drawn with Project Nayuki's MIT QR Code generator, vendored unchanged; the bundle keeps its notice as a legal comment. --- AGENTS.md | 2 +- CHANGELOG.md | 4 + README.md | 4 + cli/build.mjs | 3 +- cli/dist/code-alarm.mjs | 909 +++++++++++++++++++++- cli/src/app.ts | 80 ++ cli/src/main.ts | 67 +- cli/src/qr.ts | 42 ++ cli/src/vendor/qrcodegen.ts | 997 +++++++++++++++++++++++++ cli/test/pair.test.ts | 32 + cli/test/privacy.test.ts | 10 +- cli/test/qr.test.ts | 39 + docs/protocol.md | 25 +- plugins/claude-code/bin/code-alarm.mjs | 909 +++++++++++++++++++++- 14 files changed, 3069 insertions(+), 54 deletions(-) create mode 100644 cli/src/qr.ts create mode 100644 cli/src/vendor/qrcodegen.ts create mode 100644 cli/test/qr.test.ts diff --git a/AGENTS.md b/AGENTS.md index d915141..1af4032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, `app.ts` (pairing and the Code Alarm app's relay), `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 be981a5..e04cb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Notable changes to the `code-alarm` CLI and the Claude Code plugin. The format f - `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. diff --git a/README.md b/README.md index ddb4a96..945b5d5 100644 --- a/README.md +++ b/README.md @@ -125,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 fa6cd3c..2f06bcc 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -19,7 +19,8 @@ await build({ format: "esm", target: "node22", minify: false, - legalComments: "none", + // 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 0ab48e9..310ffcc 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -6,6 +6,9 @@ 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, rmSync } from "node:fs"; @@ -135,6 +138,52 @@ async function pairWithPhone(relay, code, name, now, fetchImpl = fetch) { 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`, @@ -230,7 +279,7 @@ import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, 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; @@ -241,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) { @@ -396,11 +445,11 @@ 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 { 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"; @@ -409,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`); @@ -523,7 +572,7 @@ function disarmAll(home) { 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 })); @@ -606,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; @@ -1078,6 +1127,771 @@ 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 DEFAULT_ARM_MS = 12 * HOUR; @@ -1090,8 +1904,9 @@ Setup 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 the code the app shows; alerts then go through the app + 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 @@ -1149,6 +1964,28 @@ function refreshCodex(home) { 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(); @@ -1329,7 +2166,6 @@ ${log.map((line) => ` ${line}`).join("\n")}`); options: { name: { type: "string" }, relay: { type: "string" } } }); const code = positionals[0]; - if (!code) throw new ConfigError('Usage: code-alarm pair [--name "My laptop"]. The code is in the Code Alarm app on your phone.'); const relay = relayUrl(values.relay); const name = values.name?.trim() || defaultName(); let existing = null; @@ -1337,14 +2173,27 @@ ${log.map((line) => ` ${line}`).join("\n")}`); existing = loadConfig(home); } catch { } - const result = await pairWithPhone(relay, code, name, Date.now()); - if (!result.ok) { - console.log(`Couldn't pair: ${result.message}`); - return 1; + 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 !== result.app.senderId) await unpairFromPhone(existing.app); - saveConfig(home, { version: 1, transport: "app", app: result.app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); - console.log(`Paired. Your phone lists this computer as "${result.app.name}", and alerts now go through the Code Alarm app.`); + 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"); @@ -1433,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/src/app.ts b/cli/src/app.ts index 372aca1..370fd50 100644 --- a/cli/src/app.ts +++ b/cli/src/app.ts @@ -1,3 +1,4 @@ +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"; @@ -90,6 +91,85 @@ export async function pairWithPhone(relay: string, code: string, name: string, n }; } +/** + * 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 { diff --git a/cli/src/main.ts b/cli/src/main.ts index 683d40c..9ed36b7 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -1,14 +1,15 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { parseArgs } from "node:util"; -import { defaultName, describeDelivery, fetchDelivery, pairWithPhone, relayUrl, unpairFromPhone } from "./app.js"; -import { type Config, DEFAULT_NTFY_SERVER, ConfigError, isValidTopic, loadConfig, newTopic, removeConfig, saveConfig } from "./config.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 { 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"; @@ -25,8 +26,9 @@ Setup 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 the code the app shows; alerts then go through the app + 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 @@ -94,6 +96,33 @@ 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(); @@ -296,7 +325,6 @@ Then connect your agent: options: { name: { type: "string" }, relay: { type: "string" } }, }); const code = positionals[0]; - if (!code) throw new ConfigError('Usage: code-alarm pair [--name "My laptop"]. The code is in the Code Alarm app on your phone.'); const relay = relayUrl(values.relay); const name = values.name?.trim() || defaultName(); let existing: Config | null = null; @@ -305,15 +333,30 @@ Then connect your agent: } catch { // A config that can't be read is replaced by the new pairing. } - const result = await pairWithPhone(relay, code, name, Date.now()); - if (!result.ok) { - console.log(`Couldn't pair: ${result.message}`); - return 1; + 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 !== result.app.senderId) await unpairFromPhone(existing.app); - saveConfig(home, { version: 1, transport: "app", app: result.app, ...(existing?.ntfy ? { ntfy: existing.ntfy } : {}) }); - console.log(`Paired. Your phone lists this computer as "${result.app.name}", and alerts now go through the Code Alarm app.`); + 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"); 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/pair.test.ts b/cli/test/pair.test.ts index da9c811..a115a53 100644 --- a/cli/test/pair.test.ts +++ b/cli/test/pair.test.ts @@ -1,5 +1,6 @@ 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"; @@ -21,6 +22,7 @@ interface Seen { /** 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 = ""; @@ -32,6 +34,18 @@ const server = createServer(async (req: IncomingMessage, res) => { 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 }); @@ -113,3 +127,21 @@ test("pair refuses a wrong code and a relay that isn't HTTPS, and takes the rela 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 78d8bad..7f179f5 100644 --- a/cli/test/privacy.test.ts +++ b/cli/test/privacy.test.ts @@ -3,7 +3,7 @@ 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 } from "../src/app.js"; +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"; @@ -77,6 +77,14 @@ test("nothing from the hook payload reaches the relay request either", async () 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"]); 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/protocol.md b/docs/protocol.md index 6c9a389..5cfa26c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -99,16 +99,31 @@ relay (`https://relay.codealarm.workers.dev`), which pushes it to the paired And Not Disturb, until you press Stop. It rejects expired events and events whose `id` it has already seen. -*Pairing.* The app shows a six-character code, valid for 10 minutes and usable once. -`code-alarm pair ` sends the relay exactly two things: +*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 name is how the phone lists this computer: `--name`, or a generic one for the operating system. -Never the hostname. The relay answers with a token that 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 +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. diff --git a/plugins/claude-code/bin/code-alarm.mjs b/plugins/claude-code/bin/code-alarm.mjs index 0ab48e9..310ffcc 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -6,6 +6,9 @@ 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, rmSync } from "node:fs"; @@ -135,6 +138,52 @@ async function pairWithPhone(relay, code, name, now, fetchImpl = fetch) { 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`, @@ -230,7 +279,7 @@ import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, 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; @@ -241,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) { @@ -396,11 +445,11 @@ 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 { 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"; @@ -409,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`); @@ -523,7 +572,7 @@ function disarmAll(home) { 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 })); @@ -606,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; @@ -1078,6 +1127,771 @@ 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 DEFAULT_ARM_MS = 12 * HOUR; @@ -1090,8 +1904,9 @@ Setup 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 the code the app shows; alerts then go through the app + 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 @@ -1149,6 +1964,28 @@ function refreshCodex(home) { 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(); @@ -1329,7 +2166,6 @@ ${log.map((line) => ` ${line}`).join("\n")}`); options: { name: { type: "string" }, relay: { type: "string" } } }); const code = positionals[0]; - if (!code) throw new ConfigError('Usage: code-alarm pair [--name "My laptop"]. The code is in the Code Alarm app on your phone.'); const relay = relayUrl(values.relay); const name = values.name?.trim() || defaultName(); let existing = null; @@ -1337,14 +2173,27 @@ ${log.map((line) => ` ${line}`).join("\n")}`); existing = loadConfig(home); } catch { } - const result = await pairWithPhone(relay, code, name, Date.now()); - if (!result.ok) { - console.log(`Couldn't pair: ${result.message}`); - return 1; + 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 !== result.app.senderId) await unpairFromPhone(existing.app); - saveConfig(home, { version: 1, transport: "app", app: result.app, ...existing?.ntfy ? { ntfy: existing.ntfy } : {} }); - console.log(`Paired. Your phone lists this computer as "${result.app.name}", and alerts now go through the Code Alarm app.`); + 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"); @@ -1433,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. + */ From 0aa9b3213ca3f303e27996fbeb0f1c01bf929b5b Mon Sep 17 00:00:00 2001 From: Yevhen Mishchenko <92166133+Draganoider@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:01:41 -0700 Subject: [PATCH 6/6] Release 0.2.0 Pairing with the Code Alarm app, by QR or by code, and alerts through its relay; the protocol examples both sides test against; Codex's copy of the CLI kept current. --- CHANGELOG.md | 2 ++ cli/dist/code-alarm.mjs | 4 ++-- cli/package-lock.json | 4 ++-- cli/package.json | 16 +++++++++++++--- plugins/claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/bin/code-alarm.mjs | 4 ++-- 6 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e04cb38..535f234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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: diff --git a/cli/dist/code-alarm.mjs b/cli/dist/code-alarm.mjs index 310ffcc..a766cb4 100755 --- a/cli/dist/code-alarm.mjs +++ b/cli/dist/code-alarm.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// code-alarm 0.1.0, 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"; @@ -1893,7 +1893,7 @@ function qrForTerminal(text) { } // 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. 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/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 310ffcc..a766cb4 100755 --- a/plugins/claude-code/bin/code-alarm.mjs +++ b/plugins/claude-code/bin/code-alarm.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// code-alarm 0.1.0, 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"; @@ -1893,7 +1893,7 @@ function qrForTerminal(text) { } // 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.