#!/usr/bin/env node import { readFileSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { hostname } from "node:os"; import { parseArgs } from "node:util"; import { uploadImage } from "@taleseal/sdk"; import { configPath, DEFAULT_URL, deleteStoredKey, readConfig, resolveApiKey, resolveUrl, writeConfig } from "./config"; import { initCommand } from "./init"; import { openBrowser, pollDeviceLogin, startDeviceLogin, validateKey } from "./login"; import { setTaleClient, TALE_USAGE, taleCommand } from "./tale"; /** * Who the API sees publishing. Read from the manifest rather than hard-coded — the API * refuses clients below a version floor, so a constant that drifts from package.json * would get real users refused for a version they are not actually running. */ const CLI_VERSION: string = (createRequire(import.meta.url)("../package.json") as { version: string }).version; const CLI_CLIENT = `taleseal-cli/${CLI_VERSION}`; /** * The one usage text. The tale commands ARE the CLI now, so their usage is the CLI's; * the auth/upload commands ride along at the bottom of the same text (see tale.ts). */ const USAGE = `${TALE_USAGE} Set up (the landing page's one command): taleseal init [claude|codex|cursor] Set up the tool's plugin (marketplace install where the tool has a CLI) and publish a live welcome page, no account needed; the claim link it prints keeps the page. With a stored key the page publishes to your account instead. Bare \`init\` skips the tool step. Login options: Plain \`taleseal login\` opens the browser: approve there (signing up on the way if needed) and the key is created and stored automatically. Works from SSH too: open the printed URL on any device. --key Skip the browser and store this key directly, for CI and scripts (a key minted by hand in the dashboard) Upload options: taleseal upload [--json] Upload one image; prints the { assetId, sha256, width, height } reference an "image" block carries. The server re-encodes every upload (nothing is stored verbatim) and strips metadata. The image stays private at an unguessable URL until a page that shows it is published; unreferenced uploads are swept after a day. Configuration (resolution order): API key TALESEAL_API_KEY env var, then the stored config Base URL TALESEAL_URL env var, then the stored config, then ${DEFAULT_URL} Stored config: ~/.config/taleseal/config.json ($XDG_CONFIG_HOME respected), written by \`taleseal login\` with mode 0600. No key anywhere? \`draft\` still works, anonymously (see draft above). A key that is set but EMPTY is refused as a misconfiguration, never treated as anonymous. `; function fail(message: string): never { process.stderr.write(`taleseal: ${message}\n`); process.exit(1); } /** first-bytes signature only — the server re-sniffs and re-encodes; this just fails fast */ const looksLikeImage = (bytes: Buffer): boolean => (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) || (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) || (bytes.length >= 12 && bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP"); const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; interface ParsedCli { values: { key?: string; yes?: boolean; json?: boolean; anon?: boolean; base?: string; after?: string; before?: string; start?: boolean; end?: boolean; idem?: string; title?: string; standfirst?: string; recipient?: string; stationery?: string; "sender-name"?: string; "sender-org"?: string; "cta-label"?: string; "cta-url"?: string; "expires-at"?: string; clear?: string; help?: boolean; }; positionals: string[]; } function parseCliArgs(): ParsedCli { try { return parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: { key: { type: "string" }, yes: { type: "boolean" }, json: { type: "boolean" }, anon: { type: "boolean" }, // tale editing: the base revision, op addressing, and set-envelope fields base: { type: "string" }, after: { type: "string" }, before: { type: "string" }, start: { type: "boolean" }, end: { type: "boolean" }, idem: { type: "string" }, title: { type: "string" }, standfirst: { type: "string" }, recipient: { type: "string" }, stationery: { type: "string" }, "sender-name": { type: "string" }, "sender-org": { type: "string" }, "cta-label": { type: "string" }, "cta-url": { type: "string" }, "expires-at": { type: "string" }, clear: { type: "string" }, help: { type: "boolean", short: "h" }, }, }); } catch (error) { process.stderr.write(`taleseal: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`); process.exit(1); } } /** * The browser handshake: start a device login, open the approve page, poll until the key * lands, store it (0600). Signing up happens in the same browser trip — the approve page * bounces through sign-in/sign-up and comes straight back. Returns the stored key. */ async function deviceLogin(): Promise { const config = readConfig(); const url = resolveUrl(process.env, config); const start = await startDeviceLogin(url, `CLI · ${hostname()}`).catch((error: unknown) => fail(error instanceof Error ? error.message : String(error)), ); process.stderr.write( `Confirmation code: ${start.code}\n\n` + `Opening ${start.url}\n` + "If the browser does not open, visit that URL yourself; any device works.\n\n" + "Waiting for approval…\n", ); openBrowser(start.url); const result = await pollDeviceLogin(url, start); if (result.status === "expired") { fail("the login was not approved in time (codes last ten minutes); run taleseal login again."); } const path = writeConfig({ ...readConfig(), apiKey: result.key }); process.stderr.write( `\n🦭 arf: signed in${result.email ? ` as ${result.email}` : ""}.\nKey saved to ${path} (mode 0600).\n`, ); return result.key; } /** * `taleseal login` — the browser handshake by default; `--key tk_…` validates and stores a * pasted key instead (the CI/scripting path, and the fallback when there is no browser * anywhere: mint a key in the dashboard by hand). */ async function login(keyArg: string | undefined): Promise { const key = keyArg?.trim() ?? ""; if (key === "") { if (process.stdin.isTTY !== true) { // agents and hooks land here — the guidance must be relayable to a human as-is, // and must not tempt anyone into pasting a plaintext key into a transcript fail( "not a terminal: the browser sign-in needs one. Ask the human to run " + "`npx -y taleseal@latest login` in their own terminal (the browser handles " + "sign-up and approval). For CI and scripts: taleseal login --key tk_…, or set " + "TALESEAL_API_KEY in the environment. To publish without any account: " + "`taleseal draft tale.json` with no key creates an anonymous page, live at " + "once and deleted after 24 hours unless claimed.", ); } await deviceLogin(); process.stdout.write("Next, try: taleseal validate tale.json\n"); return; } const config = readConfig(); const url = resolveUrl(process.env, config); const validation = await validateKey(url, key); if (validation.verdict === "invalid") { fail( `${url} rejected that key (401); nothing stored. ` + "Check it was pasted whole, or mint a fresh one in the dashboard.", ); } if (validation.verdict === "unknown") { process.stderr.write( `taleseal: warning: could not confirm the key against ${url} (${validation.detail}). ` + "The server may be unreachable; storing the key anyway.\n", ); } const path = writeConfig({ ...config, apiKey: key }); process.stdout.write(`Key stored in ${path} (mode 0600).\nNext, try: taleseal validate tale.json\n`); } function logout(): void { process.stdout.write( deleteStoredKey() ? `Stored key removed from ${configPath()}.\n` : "No stored key; nothing to remove.\n", ); } /** * `taleseal upload` — the reference-minting step for a tale's image block. Deliberately * gate-free like the rest of the flow: the bytes land PRIVATE at an unguessable URL, and * the human review gate is the tale's draft page in the browser, where the image is * visible in place before anything is published. */ async function upload(path: string | undefined, asJson: boolean): Promise { if (path === undefined) fail("nothing to upload; pass an image path: taleseal upload shot.png"); let bytes: number; try { bytes = statSync(path).size; } catch { fail(`cannot read ${path}; the path must exist on this machine.`); } if (bytes > MAX_UPLOAD_BYTES) { fail(`${path} is ${Math.round(bytes / 1024 / 1024)} MiB; the cap is 8 MiB. Crop it, or use JPEG.`); } if (!looksLikeImage(readFileSync(path).subarray(0, 12))) { fail(`${path} is not a PNG, JPEG or WebP; the bytes' own signature decides, never the filename.`); } const config = readConfig(); const apiKey = resolveApiKey(process.env, config); if (apiKey === undefined) { fail( "no API key: run `taleseal login`, or set TALESEAL_API_KEY. Uploads always need " + "a key: anonymous tales cannot carry images.", ); } const baseUrl = resolveUrl(process.env, config); const up = await uploadImage(path, { baseUrl, apiKey, client: CLI_CLIENT }); if (asJson) { process.stdout.write( `${JSON.stringify({ assetId: up.id, sha256: up.sha256, width: up.width, height: up.height, url: up.url })}\n`, ); return; } process.stdout.write( `Uploaded (re-encoded to ${up.width}×${up.height}, ${Math.max(1, Math.round(up.bytes / 1024))} KiB).\n` + `Reference it in an "image" block exactly as:\n` + `${JSON.stringify({ kind: "image", assetId: up.id, sha256: up.sha256, width: up.width, height: up.height }, null, 2)}\n` + `plus alt (required: what the image shows) and an optional caption.\n` + `Private at ${up.url} until a page that shows it is published; swept in a day if never referenced.\n`, ); } /** the tale subcommands, promoted to the top level — the whole document surface */ const TALE_COMMANDS = [ "validate", "draft", "publish", "revise", "retract", "pull", "outline", "get", "ops", "insert", "replace", "remove", "move", "set-envelope", ] as const; async function main(): Promise { const { values, positionals } = parseCliArgs(); if (values.help === true) { process.stdout.write(USAGE); return; } const command = positionals[0]; if (command === "init") { if (positionals.length > 2) fail(`unknown arguments "${positionals.slice(2).join(" ")}"\n\n${USAGE}`); await initCommand(positionals[1], { json: values.json === true, client: CLI_CLIENT }); return; } if (command === "upload") { if (positionals.length > 2) fail(`unknown arguments "${positionals.slice(2).join(" ")}"\n\n${USAGE}`); await upload(positionals[1], values.json === true); return; } if (command === "login") { await login(values.key); return; } if (command === "logout") { logout(); return; } if (!TALE_COMMANDS.includes(command as (typeof TALE_COMMANDS)[number])) { process.stderr.write(command === undefined ? USAGE : `taleseal: unknown command "${command}"\n\n${USAGE}`); process.exit(1); } setTaleClient(CLI_CLIENT); let base: number | undefined; if (values.base !== undefined) { base = Number(values.base); if (!Number.isInteger(base) || base < 0) { fail(`--base must be a non-negative integer (the draftSeq you read from \`outline\`); got "${values.base}"`); } } await taleCommand(positionals, { yes: values.yes === true, json: values.json === true, anon: values.anon === true, base, after: values.after, before: values.before, start: values.start === true, end: values.end === true, idem: values.idem, title: values.title, standfirst: values.standfirst, recipient: values.recipient, stationery: values.stationery, senderName: values["sender-name"], senderOrg: values["sender-org"], ctaLabel: values["cta-label"], ctaUrl: values["cta-url"], expiresAt: values["expires-at"], clear: values.clear, }); } main().catch((error: unknown) => { fail(error instanceof Error ? error.message : String(error)); });