scribeclock 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # scribeclock — ScribeClock from the terminal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Tasks, time clock, projects and PRDs, meetings, files, the project vault and Cloudflare, from a shell,
4
+ a script or CI. No browser or AI client needed.
5
+
6
+ The CLI is a thin client over the [ScribeClock MCP server](https://scribeclock.com/help/mcp-server):
7
+ every tool the server exposes is a command here, generated from the server's live `tools/list`, so a
8
+ new server tool appears in the CLI without a release. Friendlier aliases and workflow commands are built
9
+ on top of the same tools, so the CLI, AI agents and the web app always behave the same way.
10
+
11
+ ```bash
12
+ npm install -g scribeclock # or: npx scribeclock <command>
13
+ scribeclock login # browser sign-in (OAuth 2.1 + PKCE); tokens go to your keychain
14
+ scribeclock doctor # login, scopes, server, git hooks, version
15
+ scribeclock today # timer, in-progress, due today, next meeting
16
+ ```
17
+
18
+ Requires Node 20 or newer. No runtime dependencies.
19
+
20
+ ## Login and tokens
21
+
22
+ | Way | When |
23
+ | --- | --- |
24
+ | `scribeclock login` | Interactive. Opens scribeclock.com, you pick permissions and organizations, the CLI receives the tokens on `127.0.0.1`. Access tokens last 8 h and refresh automatically for 90 days (the rotated refresh token is saved every time). |
25
+ | `scribeclock login --scopes tasks:read,tasks:write` | Ask for less. Default is everything except Cloudflare; `--all` includes it. |
26
+ | `scribeclock login --token scmcp_…` / `--token -` | A personal access token from Settings → AI agents (stdin with `-`). |
27
+ | `SCRIBECLOCK_TOKEN=scmcp_…` | Headless and CI. Always wins over a stored login. |
28
+ | `scribeclock login --paste` | Headless machine with a browser elsewhere: paste the redirect URL back. |
29
+ | `scribeclock logout` | Revokes the tokens and forgets the login. |
30
+
31
+ Credentials live in the macOS Keychain or libsecret (`secret-tool`) on Linux; without a usable
32
+ keychain they go to `~/.config/scribeclock/credentials.json` with mode `0600` and a one-time warning.
33
+ Tokens are never printed; anything that looks like one is scrubbed from output.
34
+
35
+ `SCRIBECLOCK_URL` (or `--url`) points the CLI at another server (staging). Logins are kept per server.
36
+
37
+ ## Commands
38
+
39
+ Run `scribeclock --help` for the full list and `scribeclock <command> --help` for flags and examples.
40
+
41
+ | Area | Commands |
42
+ | --- | --- |
43
+ | Daily | `today` · `next` · `start KEY` · `progress KEY "…" [--done\|--blocked]` · `done KEY` · `inbox` · `standup` |
44
+ | Time | `clock in [--task #12\|--project P]` · `clock out [--at 17:30] [--handoff]` · `clock status` · `prompt` · `timesheet [--week\|--month\|--since …] [--csv\|--json]` |
45
+ | Tasks | `tasks list\|show\|create\|update\|claim\|release\|complete\|comment\|comments\|assign\|attach\|delete\|restore\|match\|repo\|workload\|stale` · `members` |
46
+ | Projects | `projects list\|show\|create\|update` · `prd list\|show\|create\|update\|diff\|to-tasks` |
47
+ | Meetings & docs | `meetings list\|show\|transcript [--summarize]\|actions [--create-tasks]` · `docs new --from file.md` |
48
+ | Files | `files ls\|get\|put\|rm` |
49
+ | Vault & env | `vault ls\|get\|set\|credential\|rm` · `env pull\|push` · `run -- <command>` |
50
+ | Cloudflare | `cf accounts\|zones\|dns\|dns set\|dns rm\|dns diff\|pages\|deployments\|deploy-status\|workers\|purge\|request` |
51
+ | Team | `team` · `overlap [files…]` (exit 4 on overlap) · `team report` · `handoff [record]` |
52
+ | Git | `branch KEY [--create]` · `commit -m "…"` · `pr KEY` · `changelog --since <tag>` |
53
+ | Ops & agents | `doctor` · `watch tasks [--on-change cmd]` · `serve --stdio` · `mcp-config <client> [--install]` · `ask "…"` |
54
+ | Raw tools | `tools` · `call <tool> [--arg v \| --args '{json}']` · `<tool_name> …` · `refresh` |
55
+
56
+ Any server tool works directly, with arguments typed from its schema:
57
+
58
+ ```bash
59
+ scribeclock list_tasks --assignee me --status open --json | jq '.tasks[].title'
60
+ scribeclock get_task '#731'
61
+ scribeclock create_task --args '{"title":"Fix login","priority":"high"}'
62
+ scribeclock create_document --title Spec --content @spec.md # @file reads a file, @- stdin
63
+ scribeclock cloudflare_dns_upsert --help # every tool has help
64
+ ```
65
+
66
+ ## Common workflows
67
+
68
+ ```bash
69
+ # Morning
70
+ scribeclock today
71
+ scribeclock next
72
+ scribeclock start #731 # claim + clock in against the task + context
73
+
74
+ # While working (inside the repo: the task comes from the branch or last commit)
75
+ scribeclock progress auto "DNS pointed at Pages; waiting for SSL"
76
+ scribeclock commit -m "Point apex at Pages" # appends (#731) and posts the commit as progress
77
+ scribeclock overlap src/dns.ts # exit 4 if a teammate is on the same file
78
+
79
+ # Finishing
80
+ scribeclock done #731 -m "Site live with SSL; checkout tested." # runs .scribeclock.json checks first
81
+ scribeclock clock out --handoff -m "Kisa Print deploy" --next "smoke test checkout"
82
+ scribeclock standup | pbcopy
83
+
84
+ # Billing
85
+ scribeclock timesheet --last-month --person all --csv > october.csv
86
+
87
+ # Secrets (values masked; --reveal to print; vault reads are audited)
88
+ scribeclock env pull --project 'Kisa Print' -t development
89
+ scribeclock run -p 'Kisa Print' -- npm run dev
90
+ scribeclock env push -p 'Kisa Print' -f .env.production -t production --dry-run
91
+
92
+ # Cloudflare (live changes ask first; --dry-run previews)
93
+ scribeclock cf dns diff scribeclock.com dns.json
94
+ scribeclock cf deploy-status scribeclock
95
+ ```
96
+
97
+ Put a `.scribeclock.json` at the repository root to save typing:
98
+
99
+ ```json
100
+ { "project": "Kisa Print", "checks": ["npm test", "npm run lint"], "branch_prefix": "feat" }
101
+ ```
102
+
103
+ ## Safety
104
+
105
+ - Destructive and sensitive tools (`delete_*`, vault reads and writes, Cloudflare DNS, purge, any
106
+ non-GET `cloudflare_request`) ask for confirmation. Without a terminal they refuse unless `--yes`.
107
+ - `--dry-run` prints the exact call and sends nothing (for `delete_organization_file` the server
108
+ describes the deletion instead).
109
+ - Vault values are masked unless `--reveal`; variables stored as secrets are write-only through MCP
110
+ and only their names come back.
111
+ - Branch creation is opt-in (`branch KEY --create`) and refused in repositories whose hooks enforce
112
+ "main only" unless `git config scribeclock.allowBranches true`.
113
+
114
+ ## Output, exit codes, scripting
115
+
116
+ - Human-readable by default; colors only on a TTY, `NO_COLOR` respected.
117
+ - `--json` on every command prints the server's result unchanged (stable shapes), or a documented
118
+ object for composite commands (`today`, `standup`, `timesheet`, `doctor`, `inbox`, `next`).
119
+ - `watch tasks --json` streams NDJSON. Errors in `--json` mode are
120
+ `{ ok:false, error, message, hint, exit_code }`.
121
+ - Exit codes: `0` ok · `1` error · `2` usage · `3` not authenticated · `4` conflict / overlap found ·
122
+ `5` missing scope (the message names the scope and the `login --scopes` command to add it).
123
+ - `--org <id>` (or `.scribeclock.json` → `org`) picks the organization when you belong to several.
124
+ - Shell completions: `scribeclock completions bash|zsh|fish`.
125
+ - Prompt segment: `PS1='$(scribeclock prompt) \w \$ '` shows `⏱ 2h13m #731` while clocked in.
126
+
127
+ ## Agents
128
+
129
+ `scribeclock serve --stdio` runs the CLI as a local MCP server that proxies to ScribeClock with your
130
+ login, for clients that prefer local servers. `scribeclock mcp-config claude-code|cursor|vscode|windsurf|codex|gemini [--local] [--install]`
131
+ prints or writes the matching client configuration. `scribeclock ask "what's overdue for me?"` maps a
132
+ plain request to one tool call (built-in rules, no AI service), shows it, and asks before anything that
133
+ writes.
134
+
135
+ ## CI
136
+
137
+ `examples/github-actions/` has a nightly workload report and a stale-task sweep. Both use
138
+ `SCRIBECLOCK_TOKEN` and `npx scribeclock@latest … --json | jq`.
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ cd packages/cli
144
+ npm run typecheck && npm test # unit tests, incl. parity against supabase/functions/mcp/tools.ts
145
+ npm run build && node bin/scribeclock.js --help
146
+ SCRIBECLOCK_INTEGRATION=1 npm run test:integration # live endpoint; add SCRIBECLOCK_TOKEN for authenticated checks
147
+ ```
148
+
149
+ The parity test imports the server's tool catalog, so a new MCP tool without CLI reachability fails CI.
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const entry = join(here, "..", "dist", "index.js");
8
+ if (!existsSync(entry)) {
9
+ process.stderr.write("scribeclock: dist/ is missing. Run `npm run build` in packages/cli (or install the published package).\n");
10
+ process.exit(1);
11
+ }
12
+ // `scribeclock … | head` closes the pipe early: that is not an error.
13
+ for (const stream of [process.stdout, process.stderr]) {
14
+ stream.on("error", (err) => {
15
+ if (err && err.code === "EPIPE") process.exit(process.exitCode ?? 0);
16
+ throw err;
17
+ });
18
+ }
19
+
20
+ const { main } = await import(entry);
21
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,230 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { spawn } from "node:child_process";
4
+ import { CliError } from "../cli/errors.js";
5
+ export const ALL_SCOPES = [
6
+ "tasks:read",
7
+ "tasks:write",
8
+ "projects:read",
9
+ "projects:write",
10
+ "files:read",
11
+ "files:write",
12
+ "secrets:read",
13
+ "secrets:write",
14
+ "meetings:read",
15
+ "cloudflare:read",
16
+ "cloudflare:write",
17
+ ];
18
+ /** What `login` asks for by default: everything except Cloudflare, which is opt-in (--scopes / --all). */
19
+ export const DEFAULT_SCOPES = ALL_SCOPES.filter((s) => !s.startsWith("cloudflare:"));
20
+ export function base64url(bytes) {
21
+ return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
22
+ }
23
+ export function pkcePair() {
24
+ const verifier = base64url(randomBytes(48)); // 64 chars, within RFC 7636's 43–128
25
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
26
+ return { verifier, challenge };
27
+ }
28
+ export function parseWwwAuthenticate(header) {
29
+ if (!header)
30
+ return {};
31
+ const m = /resource_metadata="([^"]+)"/.exec(header);
32
+ return m ? { resource_metadata: m[1] } : {};
33
+ }
34
+ async function getJson(fetchImpl, url) {
35
+ const res = await fetchImpl(url, { headers: { accept: "application/json" } });
36
+ if (!res.ok)
37
+ throw new CliError(`${url} answered ${res.status}.`, { id: "discovery_failed" });
38
+ return (await res.json());
39
+ }
40
+ /** RFC 9728 + RFC 8414 discovery, starting from the 401 challenge of the MCP endpoint. */
41
+ export async function discover(endpoint, fetchImpl = globalThis.fetch) {
42
+ let metadataUrl;
43
+ let resource = endpoint;
44
+ try {
45
+ const probe = await fetchImpl(endpoint, {
46
+ method: "POST",
47
+ headers: { "content-type": "application/json", accept: "application/json" },
48
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
49
+ });
50
+ metadataUrl = parseWwwAuthenticate(probe.headers.get("www-authenticate")).resource_metadata;
51
+ }
52
+ catch (err) {
53
+ throw new CliError(`Could not reach ${endpoint} (${err instanceof Error ? err.message : String(err)}).`, { id: "network", hint: "Check your network, or SCRIBECLOCK_URL / --url." });
54
+ }
55
+ if (!metadataUrl) {
56
+ const u = new URL(endpoint);
57
+ metadataUrl = `${u.origin}/.well-known/oauth-protected-resource${u.pathname === "/" ? "" : u.pathname}`;
58
+ }
59
+ const prm = await getJson(fetchImpl, metadataUrl);
60
+ if (typeof prm.resource === "string")
61
+ resource = prm.resource;
62
+ const issuer = String(prm.authorization_servers?.[0] ?? new URL(endpoint).origin).replace(/\/$/, "");
63
+ const asm = await getJson(fetchImpl, `${issuer}/.well-known/oauth-authorization-server`);
64
+ const need = (k) => {
65
+ const v = asm[k];
66
+ if (typeof v !== "string")
67
+ throw new CliError(`The authorization server metadata has no ${k}.`, { id: "discovery_failed" });
68
+ return v;
69
+ };
70
+ return {
71
+ endpoint,
72
+ resource,
73
+ issuer: String(asm.issuer ?? issuer),
74
+ authorization_endpoint: need("authorization_endpoint"),
75
+ token_endpoint: need("token_endpoint"),
76
+ registration_endpoint: typeof asm.registration_endpoint === "string" ? asm.registration_endpoint : undefined,
77
+ revocation_endpoint: typeof asm.revocation_endpoint === "string" ? asm.revocation_endpoint : undefined,
78
+ scopes_supported: Array.isArray(asm.scopes_supported) ? asm.scopes_supported.map(String) : undefined,
79
+ };
80
+ }
81
+ /** RFC 7591 dynamic registration of a public (PKCE-only) client with loopback redirects. */
82
+ export async function registerClient(meta, fetchImpl, opts) {
83
+ if (!meta.registration_endpoint)
84
+ throw new CliError("The server does not offer dynamic client registration.", { id: "registration_unsupported" });
85
+ const res = await fetchImpl(meta.registration_endpoint, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", accept: "application/json" },
88
+ body: JSON.stringify({
89
+ client_name: opts.clientName,
90
+ redirect_uris: ["http://127.0.0.1/callback", "http://localhost/callback"],
91
+ token_endpoint_auth_method: "none",
92
+ grant_types: ["authorization_code", "refresh_token"],
93
+ response_types: ["code"],
94
+ scope: opts.scopes.join(" "),
95
+ client_uri: "https://scribeclock.com",
96
+ }),
97
+ });
98
+ const json = (await res.json().catch(() => ({})));
99
+ if (res.status !== 201 && res.status !== 200) {
100
+ throw new CliError(`Client registration failed (${res.status}): ${String(json.error_description ?? json.error ?? "")}`.trim(), { id: "registration_failed" });
101
+ }
102
+ if (typeof json.client_id !== "string")
103
+ throw new CliError("Client registration returned no client_id.", { id: "registration_failed" });
104
+ return json.client_id;
105
+ }
106
+ async function tokenRequest(fetchImpl, tokenEndpoint, params) {
107
+ const body = new URLSearchParams();
108
+ for (const [k, v] of Object.entries(params))
109
+ if (v !== undefined)
110
+ body.set(k, v);
111
+ const res = await fetchImpl(tokenEndpoint, {
112
+ method: "POST",
113
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
114
+ body,
115
+ });
116
+ const json = (await res.json().catch(() => ({})));
117
+ return { status: res.status, json };
118
+ }
119
+ export async function exchangeCode(fetchImpl, meta, p) {
120
+ const { status, json } = await tokenRequest(fetchImpl, meta.token_endpoint, {
121
+ grant_type: "authorization_code",
122
+ code: p.code,
123
+ code_verifier: p.verifier,
124
+ client_id: p.client_id,
125
+ redirect_uri: p.redirect_uri,
126
+ resource: meta.resource,
127
+ });
128
+ if (status !== 200 || typeof json.access_token !== "string") {
129
+ throw new CliError(`Token exchange failed: ${String(json.error_description ?? json.error ?? status)}`, { id: "token_exchange_failed" });
130
+ }
131
+ return json;
132
+ }
133
+ /** Rotating refresh: the response carries a NEW refresh token that must replace the old one. */
134
+ export async function refreshTokens(fetchImpl, tokenEndpoint, refreshToken, clientId) {
135
+ const { status, json } = await tokenRequest(fetchImpl, tokenEndpoint, { grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId });
136
+ if (status === 200 && typeof json.access_token === "string")
137
+ return { ok: true, tokens: json };
138
+ return { ok: false, error: String(json.error ?? `http_${status}`), description: typeof json.error_description === "string" ? json.error_description : undefined };
139
+ }
140
+ export async function revokeToken(fetchImpl, revocationEndpoint, token, clientId, hint) {
141
+ const { status } = await tokenRequest(fetchImpl, revocationEndpoint, { token, token_type_hint: hint, client_id: clientId });
142
+ return status === 200;
143
+ }
144
+ export function buildAuthorizeUrl(meta, p) {
145
+ const u = new URL(meta.authorization_endpoint);
146
+ u.searchParams.set("response_type", "code");
147
+ u.searchParams.set("client_id", p.client_id);
148
+ u.searchParams.set("redirect_uri", p.redirect_uri);
149
+ u.searchParams.set("state", p.state);
150
+ u.searchParams.set("scope", p.scopes.join(" "));
151
+ u.searchParams.set("code_challenge", p.challenge);
152
+ u.searchParams.set("code_challenge_method", "S256");
153
+ u.searchParams.set("resource", meta.resource);
154
+ return u.toString();
155
+ }
156
+ export function openBrowser(url, platform = process.platform) {
157
+ try {
158
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
159
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
160
+ child.on("error", () => undefined);
161
+ child.unref();
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>ScribeClock CLI</title>
169
+ <body style="font-family:system-ui;margin:4rem auto;max-width:28rem;text-align:center;color:#1b1b1f">
170
+ <h1 style="font-size:1.4rem">You're signed in to the ScribeClock CLI</h1><p>You can close this window and go back to your terminal.</p></body>`;
171
+ const FAIL_HTML = (msg) => `<!doctype html><meta charset="utf-8"><title>ScribeClock CLI</title>
172
+ <body style="font-family:system-ui;margin:4rem auto;max-width:28rem;text-align:center;color:#1b1b1f"><h1 style="font-size:1.4rem">Sign-in did not finish</h1><p>${msg}</p></body>`;
173
+ /**
174
+ * Listens once on 127.0.0.1:<random port>/callback and resolves with the code. The state must match
175
+ * (CSRF) and the server answers a friendly page either way.
176
+ */
177
+ export function waitForCallback(opts) {
178
+ return new Promise((resolveStart, rejectStart) => {
179
+ let settle = null;
180
+ let fail = null;
181
+ const codePromise = new Promise((res, rej) => {
182
+ settle = res;
183
+ fail = rej;
184
+ });
185
+ const server = createServer((req, res) => {
186
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
187
+ if (url.pathname !== "/callback") {
188
+ res.writeHead(404).end();
189
+ return;
190
+ }
191
+ const state = url.searchParams.get("state");
192
+ const code = url.searchParams.get("code");
193
+ const error = url.searchParams.get("error");
194
+ if (state !== opts.state) {
195
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(FAIL_HTML("The state did not match. Start the login again."));
196
+ return;
197
+ }
198
+ if (error || !code) {
199
+ const desc = url.searchParams.get("error_description") ?? error ?? "No authorization code was returned.";
200
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAIL_HTML(escapeHtml(desc)));
201
+ fail?.(new CliError(`Authorization was not granted: ${desc}`, { id: "authorization_denied" }));
202
+ setTimeout(() => server.close(), 50);
203
+ return;
204
+ }
205
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(SUCCESS_HTML);
206
+ settle?.(code);
207
+ setTimeout(() => server.close(), 50);
208
+ });
209
+ const timer = setTimeout(() => {
210
+ fail?.(new CliError("Timed out waiting for the browser sign-in (5 minutes).", { id: "login_timeout", hint: "Run `scribeclock login` again, or use `scribeclock login --token` with a personal access token." }));
211
+ server.close();
212
+ }, opts.timeoutMs ?? 5 * 60 * 1000);
213
+ timer.unref();
214
+ server.on("error", (err) => rejectStart(err));
215
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
216
+ const { port } = server.address();
217
+ resolveStart({ redirectUri: `http://127.0.0.1:${port}/callback`, code: codePromise.finally(() => clearTimeout(timer)) });
218
+ });
219
+ });
220
+ }
221
+ function escapeHtml(s) {
222
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
223
+ }
224
+ export function randomState() {
225
+ return base64url(randomBytes(24));
226
+ }
227
+ export function expiresAt(expiresIn, now = new Date()) {
228
+ const seconds = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 8 * 3600;
229
+ return new Date(now.getTime() + seconds * 1000).toISOString();
230
+ }
@@ -0,0 +1,212 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { writeJsonPrivate } from "../config.js";
6
+ const execFileAsync = promisify(execFile);
7
+ const ACCOUNT = "scribeclock";
8
+ const service = (profile) => `scribeclock-cli:${profile}`;
9
+ const defaultExec = async (file, args) => {
10
+ const { stdout, stderr } = await execFileAsync(file, args, { encoding: "utf8", maxBuffer: 1024 * 1024 });
11
+ return { stdout: String(stdout), stderr: String(stderr) };
12
+ };
13
+ /** macOS Keychain via `security`. The secret travels as a process argument, as every CLI using `security` does. */
14
+ export class MacKeychainStore {
15
+ exec;
16
+ backend = "keychain";
17
+ constructor(exec = defaultExec) {
18
+ this.exec = exec;
19
+ }
20
+ async load(profile) {
21
+ try {
22
+ const { stdout } = await this.exec("security", ["find-generic-password", "-a", ACCOUNT, "-s", service(profile), "-w"]);
23
+ const raw = stdout.trim();
24
+ if (!raw)
25
+ return null;
26
+ // `security -w` prints hex for values with non-ASCII bytes; ours is plain JSON.
27
+ return JSON.parse(raw.startsWith("{") ? raw : Buffer.from(raw, "hex").toString("utf8"));
28
+ }
29
+ catch (err) {
30
+ if (isNotFound(err))
31
+ return null;
32
+ throw err;
33
+ }
34
+ }
35
+ async save(profile, creds) {
36
+ await this.exec("security", ["add-generic-password", "-U", "-a", ACCOUNT, "-s", service(profile), "-l", `ScribeClock CLI (${profile})`, "-w", JSON.stringify(creds)]);
37
+ }
38
+ async clear(profile) {
39
+ try {
40
+ await this.exec("security", ["delete-generic-password", "-a", ACCOUNT, "-s", service(profile)]);
41
+ }
42
+ catch (err) {
43
+ if (!isNotFound(err))
44
+ throw err;
45
+ }
46
+ }
47
+ }
48
+ /** Linux: libsecret's `secret-tool` (GNOME Keyring, KWallet via the Secret Service API). */
49
+ export class SecretToolStore {
50
+ exec;
51
+ backend = "keychain";
52
+ constructor(exec = defaultExecWithInput) {
53
+ this.exec = exec;
54
+ }
55
+ async load(profile) {
56
+ try {
57
+ const { stdout } = await this.exec("secret-tool", ["lookup", "service", service(profile), "account", ACCOUNT]);
58
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
59
+ }
60
+ catch (err) {
61
+ if (isNotFound(err))
62
+ return null;
63
+ throw err;
64
+ }
65
+ }
66
+ async save(profile, creds) {
67
+ await this.exec("secret-tool", ["store", "--label", `ScribeClock CLI (${profile})`, "service", service(profile), "account", ACCOUNT], JSON.stringify(creds));
68
+ }
69
+ async clear(profile) {
70
+ try {
71
+ await this.exec("secret-tool", ["clear", "service", service(profile), "account", ACCOUNT]);
72
+ }
73
+ catch (err) {
74
+ if (!isNotFound(err))
75
+ throw err;
76
+ }
77
+ }
78
+ }
79
+ const defaultExecWithInput = (file, args, input) => new Promise((resolve, reject) => {
80
+ const child = execFile(file, args, { encoding: "utf8" }, (err, stdout, stderr) => {
81
+ if (err)
82
+ reject(Object.assign(err, { stdout, stderr }));
83
+ else
84
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
85
+ });
86
+ if (input !== undefined)
87
+ child.stdin?.end(input);
88
+ });
89
+ function isNotFound(err) {
90
+ const e = err;
91
+ const text = `${e.stderr ?? ""} ${e.message ?? ""}`;
92
+ return e.code === 44 || /could not be found|not found|No such|SecKeychainSearchCopyNext/i.test(text) || (e.code === 1 && /secret-tool/.test(text) && !e.stderr);
93
+ }
94
+ /** `credentials.json` with mode 0600 — the fallback when no keychain is usable. */
95
+ export class FileStore {
96
+ backend = "file";
97
+ path;
98
+ constructor(dir) {
99
+ this.path = join(dir, "credentials.json");
100
+ }
101
+ readAll() {
102
+ if (!existsSync(this.path))
103
+ return {};
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
106
+ return parsed.profiles ?? {};
107
+ }
108
+ catch {
109
+ return {};
110
+ }
111
+ }
112
+ async load(profile) {
113
+ return this.readAll()[profile] ?? null;
114
+ }
115
+ async save(profile, creds) {
116
+ const all = this.readAll();
117
+ all[profile] = creds;
118
+ writeJsonPrivate(this.path, { version: 1, profiles: all });
119
+ }
120
+ async clear(profile) {
121
+ const all = this.readAll();
122
+ if (!(profile in all))
123
+ return;
124
+ delete all[profile];
125
+ if (Object.keys(all).length === 0) {
126
+ try {
127
+ unlinkSync(this.path);
128
+ }
129
+ catch {
130
+ /* already gone */
131
+ }
132
+ return;
133
+ }
134
+ writeJsonPrivate(this.path, { version: 1, profiles: all });
135
+ }
136
+ }
137
+ /**
138
+ * Keychain when the platform has one; otherwise (or on SCRIBECLOCK_CREDENTIAL_STORE=file) the 0600
139
+ * file. If the keychain fails at runtime the caller falls back to the file and warns once.
140
+ */
141
+ export function createCredentialStore(opts) {
142
+ const env = opts.env ?? process.env;
143
+ const platform = opts.platform ?? process.platform;
144
+ const fallback = new FileStore(opts.configDir);
145
+ const forced = env.SCRIBECLOCK_CREDENTIAL_STORE;
146
+ if (forced === "file")
147
+ return { primary: fallback, fallback };
148
+ if (platform === "darwin")
149
+ return { primary: new MacKeychainStore(), fallback };
150
+ if (platform === "linux")
151
+ return { primary: new SecretToolStore(), fallback };
152
+ return { primary: fallback, fallback };
153
+ }
154
+ /** Tries the keychain, falls back to the file (warning once) when the keychain is unusable. */
155
+ export class ResilientStore {
156
+ primary;
157
+ fallbackStore;
158
+ warn;
159
+ usingFallback = false;
160
+ constructor(primary, fallbackStore, warn) {
161
+ this.primary = primary;
162
+ this.fallbackStore = fallbackStore;
163
+ this.warn = warn;
164
+ }
165
+ get backend() {
166
+ return this.usingFallback ? "file" : this.primary.backend;
167
+ }
168
+ degrade(err) {
169
+ if (this.usingFallback)
170
+ return;
171
+ this.usingFallback = true;
172
+ this.warn(`The system keychain is not usable (${err instanceof Error ? err.message.split("\n")[0] : String(err)}); credentials are kept in ${this.fallbackStore.path} with mode 0600 instead.`);
173
+ }
174
+ async load(profile) {
175
+ if (this.primary === this.fallbackStore)
176
+ return this.fallbackStore.load(profile);
177
+ if (!this.usingFallback) {
178
+ try {
179
+ const fromKeychain = await this.primary.load(profile);
180
+ if (fromKeychain)
181
+ return fromKeychain;
182
+ }
183
+ catch (err) {
184
+ this.degrade(err);
185
+ }
186
+ }
187
+ return this.fallbackStore.load(profile);
188
+ }
189
+ async save(profile, creds) {
190
+ if (!this.usingFallback && this.primary !== this.fallbackStore) {
191
+ try {
192
+ await this.primary.save(profile, creds);
193
+ return;
194
+ }
195
+ catch (err) {
196
+ this.degrade(err);
197
+ }
198
+ }
199
+ await this.fallbackStore.save(profile, creds);
200
+ }
201
+ async clear(profile) {
202
+ if (this.primary !== this.fallbackStore) {
203
+ try {
204
+ await this.primary.clear(profile);
205
+ }
206
+ catch {
207
+ /* nothing to clear or keychain locked */
208
+ }
209
+ }
210
+ await this.fallbackStore.clear(profile);
211
+ }
212
+ }