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.
@@ -0,0 +1,324 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { CliError, usageError } from "../cli/errors.js";
5
+ import { confirm } from "../cli/confirm.js";
6
+ import { describeServer } from "../mcp/client.js";
7
+ import { hooksPath, remoteUrl, repoRoot } from "../git.js";
8
+ import { EXIT } from "../types.js";
9
+ import { fmtDate, relTime } from "../util/format.js";
10
+ import { mask } from "../cli/output.js";
11
+ import { PROJECT_FLAG, cmd, list, projectArg, str } from "./_shared.js";
12
+ import { executeGuarded } from "./tools.js";
13
+ /** KEY=value lines → map (comments, blanks, `export`, quotes handled; later keys win). */
14
+ export function parseDotenv(text) {
15
+ const out = {};
16
+ for (const raw of text.split(/\r?\n/)) {
17
+ const line = raw.trim();
18
+ if (!line || line.startsWith("#"))
19
+ continue;
20
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
21
+ if (!m)
22
+ continue;
23
+ let value = m[2].trim();
24
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
25
+ value = value.slice(1, -1).replace(/\\n/g, "\n");
26
+ else
27
+ value = value.replace(/\s+#.*$/, "");
28
+ out[m[1]] = value;
29
+ }
30
+ return out;
31
+ }
32
+ export function formatDotenv(values, secretOnly, header) {
33
+ const lines = [header];
34
+ for (const [k, v] of Object.entries(values))
35
+ lines.push(/[\s#"'\\]/.test(v) || v === "" ? `${k}=${JSON.stringify(v)}` : `${k}=${v}`);
36
+ if (secretOnly.length) {
37
+ lines.push("", "# Secrets are write-only through MCP; set these from your own copy:");
38
+ for (const k of secretOnly)
39
+ lines.push(`# ${k}=`);
40
+ }
41
+ return lines.join("\n") + "\n";
42
+ }
43
+ export function diffEnv(local, remote, secretKeys) {
44
+ const added = Object.keys(remote).filter((k) => !(k in local));
45
+ const changed = Object.keys(remote).filter((k) => k in local && local[k] !== remote[k]);
46
+ const same = Object.keys(remote).filter((k) => k in local && local[k] === remote[k]);
47
+ const removed = Object.keys(local).filter((k) => !(k in remote) && !secretKeys.includes(k));
48
+ const secretOnly = secretKeys.filter((k) => !(k in remote));
49
+ return { added, changed, removed, same, secretOnly };
50
+ }
51
+ function printDiff(ctx, diff, remote, local) {
52
+ for (const k of diff.added)
53
+ ctx.out.log(ctx.out.green(`+ ${k}=${ctx.flags.reveal ? remote[k] : mask(remote[k])}`));
54
+ for (const k of diff.changed)
55
+ ctx.out.log(ctx.out.yellow(`~ ${k}=${ctx.flags.reveal ? `${local[k]} → ${remote[k]}` : `${mask(local[k])} → ${mask(remote[k])}`}`));
56
+ for (const k of diff.removed)
57
+ ctx.out.log(ctx.out.red(`- ${k} (local only; kept unless --prune)`));
58
+ for (const k of diff.secretOnly)
59
+ ctx.out.log(ctx.out.dim(`# ${k} (secret in the vault, write-only; ${k in local ? "your local value is kept" : "not available"})`));
60
+ for (const k of diff.same)
61
+ ctx.out.log(ctx.out.dim(`= ${k}`));
62
+ }
63
+ export const envPull = cmd({
64
+ path: "env pull",
65
+ group: "Vault & env",
66
+ summary: "Write the project's plain-text variables to a .env file, with a diff preview first (secrets stay write-only).",
67
+ description: "Reads vault_get_env (audited; asks first) and merges the plain-text config values into the .env file: a diff is shown with values masked (--reveal to see them), then the file is written with mode 0600. Variables stored as secrets never leave the vault through MCP; their names are listed as comments and any local value you already have for them is kept.",
68
+ tool: "vault_get_env",
69
+ flags: [
70
+ PROJECT_FLAG,
71
+ { name: "target", alias: "t", type: "string", choices: ["production", "preview", "development"], description: "Environment (default: all)." },
72
+ { name: "out", alias: "o", type: "string", placeholder: "<path>", description: "File to write (default .env)." },
73
+ { name: "keys", alias: "k", type: "array", description: "Only these variables." },
74
+ { name: "prune", type: "boolean", description: "Remove local keys the vault does not have." },
75
+ ],
76
+ examples: ["scribeclock env pull --project 'Kisa Print' -t development", "scribeclock env pull -p X --out .env.local --yes"],
77
+ async run(ctx, inv) {
78
+ const project_id = projectArg(ctx, inv, { required: true });
79
+ const path = resolve(ctx.cwd, str(inv.flags.out) ?? ".env");
80
+ const local = existsSync(path) ? parseDotenv(readFileSync(path, "utf8")) : {};
81
+ // The read itself is the sensitive step; --dry-run still needs it to show the diff, so run it guarded but un-dry.
82
+ const wasDry = ctx.flags.dryRun;
83
+ ctx.flags.dryRun = false;
84
+ const result = await executeGuarded(ctx, "vault_get_env", { project_id, target: str(inv.flags.target), keys: list(inv.flags.keys) });
85
+ ctx.flags.dryRun = wasDry;
86
+ if (result.status !== "done")
87
+ return result.status === "cancelled" ? EXIT.ERROR : EXIT.OK;
88
+ // executeGuarded masks vault output; re-read the raw values from the unmasked call result it wrapped.
89
+ const raw = await ctx.call("vault_get_env", { project_id, target: str(inv.flags.target), keys: list(inv.flags.keys) });
90
+ const remote = (raw.map ?? raw.values ?? parseDotenv(String(raw.env ?? raw.dotenv ?? "")));
91
+ const secretKeys = (raw.secret_keys ?? []).map(String);
92
+ const diff = diffEnv(local, remote, secretKeys);
93
+ // A first pull with only write-only secrets still writes the stub file (names as comments).
94
+ const changes = diff.added.length + diff.changed.length + (inv.flags.prune ? diff.removed.length : 0) + (!existsSync(path) && diff.secretOnly.length ? 1 : 0);
95
+ if (ctx.flags.json) {
96
+ ctx.out.jsonOut({ ok: true, path, project: raw.project_name ?? project_id, dry_run: Boolean(ctx.flags.dryRun), ...diff, written: false });
97
+ if (ctx.flags.dryRun || !changes)
98
+ return EXIT.OK;
99
+ }
100
+ else {
101
+ ctx.out.log(ctx.out.bold(`${String(raw.project_name ?? project_id)}${inv.flags.target ? ` · ${String(inv.flags.target)}` : ""} → ${path}`));
102
+ printDiff(ctx, diff, remote, local);
103
+ if (!changes) {
104
+ ctx.out.info(`${path} is up to date.`);
105
+ return EXIT.OK;
106
+ }
107
+ if (ctx.flags.dryRun) {
108
+ ctx.out.info(ctx.out.dim(`Dry run: ${changes} change${changes === 1 ? "" : "s"} not written.`));
109
+ return EXIT.OK;
110
+ }
111
+ const ok = await confirm(ctx, `Write ${changes} change${changes === 1 ? "" : "s"} to ${path}?`);
112
+ if (!ok)
113
+ return EXIT.ERROR;
114
+ }
115
+ const merged = { ...local };
116
+ if (inv.flags.prune)
117
+ for (const k of diff.removed)
118
+ delete merged[k];
119
+ for (const [k, v] of Object.entries(remote))
120
+ merged[k] = v;
121
+ writeFileSync(path, formatDotenv(merged, secretKeys.filter((k) => !(k in merged)), `# ScribeClock vault · ${String(raw.project_name ?? project_id)}${inv.flags.target ? ` · ${String(inv.flags.target)}` : ""} · pulled ${fmtDate(ctx.now().toISOString(), { time: true })}`), { mode: 0o600 });
122
+ try {
123
+ chmodSync(path, 0o600);
124
+ }
125
+ catch {
126
+ /* non-POSIX */
127
+ }
128
+ if (ctx.flags.json)
129
+ ctx.out.jsonOut({ ok: true, path, written: true, ...diff });
130
+ else
131
+ ctx.out.log(`${ctx.out.green("✓")} Wrote ${path} (${Object.keys(merged).length} variables, mode 0600).`);
132
+ return EXIT.OK;
133
+ },
134
+ });
135
+ export const envPush = cmd({
136
+ path: "env push",
137
+ group: "Vault & env",
138
+ summary: "Upload a .env file into the project vault (write-only), with a preview of adds/updates first.",
139
+ tool: ["vault_list_secrets", "vault_put_env"],
140
+ flags: [
141
+ PROJECT_FLAG,
142
+ { name: "file", alias: "f", type: "string", placeholder: "<path>", description: "dotenv file (default .env)." },
143
+ { name: "target", alias: "t", type: "array", placeholder: "<production,preview,development>", description: "Environments (default all three)." },
144
+ { name: "text", type: "boolean", description: "Store as readable plain-text config instead of secret." },
145
+ { name: "skip-existing", type: "boolean", description: "Do not overwrite keys already in the vault." },
146
+ { name: "keys", alias: "k", type: "array", description: "Only these variables from the file." },
147
+ ],
148
+ examples: ["scribeclock env push --project 'Kisa Print' -f .env.production -t production", "scribeclock env push -p X --dry-run"],
149
+ async run(ctx, inv) {
150
+ const project_id = projectArg(ctx, inv, { required: true });
151
+ const path = resolve(ctx.cwd, str(inv.flags.file) ?? ".env");
152
+ if (!existsSync(path))
153
+ throw usageError(`No such file: ${path}`);
154
+ let values = parseDotenv(readFileSync(path, "utf8"));
155
+ const only = list(inv.flags.keys);
156
+ if (only)
157
+ values = Object.fromEntries(Object.entries(values).filter(([k]) => only.includes(k)));
158
+ const keys = Object.keys(values);
159
+ if (!keys.length)
160
+ throw usageError(`${path} has no KEY=value lines${only ? " matching --keys" : ""}.`);
161
+ const listing = await ctx.call("vault_list_secrets", { project_id, kind: "env_var" });
162
+ const existing = new Set((listing.secrets ?? []).map((s) => String(s.env_key ?? s.title)));
163
+ const adds = keys.filter((k) => !existing.has(k));
164
+ const updates = keys.filter((k) => existing.has(k));
165
+ const willUpdate = inv.flags["skip-existing"] ? [] : updates;
166
+ if (!ctx.flags.json) {
167
+ ctx.out.log(ctx.out.bold(`${String(listing.project_name ?? project_id)} ← ${path}`));
168
+ for (const k of adds)
169
+ ctx.out.log(ctx.out.green(`+ ${k}=${ctx.flags.reveal ? values[k] : mask(values[k])}`));
170
+ for (const k of willUpdate)
171
+ ctx.out.log(ctx.out.yellow(`~ ${k}=${ctx.flags.reveal ? values[k] : mask(values[k])} (overwrites the vault value)`));
172
+ for (const k of updates.filter((k) => !willUpdate.includes(k)))
173
+ ctx.out.log(ctx.out.dim(`= ${k} (exists; skipped)`));
174
+ }
175
+ const entries = [...adds, ...willUpdate].map((k) => ({ key: k, value: values[k] }));
176
+ if (!entries.length) {
177
+ if (ctx.flags.json)
178
+ ctx.out.jsonOut({ ok: true, stored: 0, adds, updates: willUpdate, skipped: updates });
179
+ else
180
+ ctx.out.info("Nothing to push.");
181
+ return EXIT.OK;
182
+ }
183
+ const result = await executeGuarded(ctx, "vault_put_env", { project_id, entries, targets: list(inv.flags.target), sensitivity: inv.flags.text ? "text" : "secret", duplicate_mode: inv.flags["skip-existing"] ? "skip" : "update" });
184
+ if (result.status === "cancelled")
185
+ return EXIT.ERROR;
186
+ if (result.status === "dry_run")
187
+ return EXIT.OK;
188
+ if (ctx.flags.json)
189
+ ctx.out.jsonOut({ ok: true, stored: entries.length, adds, updates: willUpdate, skipped: updates.filter((k) => !willUpdate.includes(k)), result: result.data });
190
+ else
191
+ ctx.out.log(`${ctx.out.green("✓")} Pushed ${entries.length} variable${entries.length === 1 ? "" : "s"} (${adds.length} new, ${willUpdate.length} updated) as ${inv.flags.text ? "plain text" : "secrets"}.`);
192
+ return EXIT.OK;
193
+ },
194
+ });
195
+ export const runCommand = cmd({
196
+ path: "run",
197
+ group: "Vault & env",
198
+ summary: "Run a command with the project's vault variables in its environment, without writing them to disk: run -p X -- npm start.",
199
+ description: "Reads vault_get_env (audited; asks first, so pass --yes in scripts) and spawns the command with those variables added to the environment. Only variables stored as plain-text config have readable values; secret-sensitivity variables are write-only through MCP and are reported as missing. Exit code is the command's.",
200
+ tool: "vault_get_env",
201
+ flags: [
202
+ PROJECT_FLAG,
203
+ { name: "target", alias: "t", type: "string", choices: ["production", "preview", "development"], description: "Environment (default: development if present, else all)." },
204
+ { name: "keys", alias: "k", type: "array", description: "Only these variables." },
205
+ { name: "no-inherit", type: "boolean", description: "Do not pass your current environment through (PATH is always kept)." },
206
+ ],
207
+ examples: ["scribeclock run -p 'Kisa Print' -t development -- npm run dev", "scribeclock run -p X --yes -- ./deploy.sh"],
208
+ async run(ctx, inv) {
209
+ const project_id = projectArg(ctx, inv, { required: true });
210
+ if (!inv.passthrough.length)
211
+ throw usageError("Put the command after --.", "Example: scribeclock run -p X -- npm test");
212
+ const result = await executeGuarded(ctx, "vault_get_env", { project_id, target: str(inv.flags.target), keys: list(inv.flags.keys) });
213
+ if (result.status === "cancelled")
214
+ return EXIT.ERROR;
215
+ if (result.status === "dry_run") {
216
+ ctx.out.log(`${ctx.out.dim("dry run →")} ${inv.passthrough.join(" ")}`);
217
+ return EXIT.OK;
218
+ }
219
+ const raw = await ctx.call("vault_get_env", { project_id, target: str(inv.flags.target), keys: list(inv.flags.keys) });
220
+ const values = (raw.map ?? raw.values ?? parseDotenv(String(raw.env ?? raw.dotenv ?? "")));
221
+ const secretKeys = (raw.secret_keys ?? []).map(String);
222
+ if (secretKeys.length)
223
+ ctx.out.info(ctx.out.dim(`${secretKeys.length} secret-sensitivity variable${secretKeys.length === 1 ? "" : "s"} cannot be injected (write-only via MCP): ${secretKeys.join(", ")}`));
224
+ const env = inv.flags["no-inherit"] ? { PATH: ctx.env.PATH, HOME: ctx.env.HOME } : { ...ctx.env };
225
+ Object.assign(env, values);
226
+ ctx.out.info(ctx.out.dim(`Running with ${Object.keys(values).length} variable${Object.keys(values).length === 1 ? "" : "s"} from the vault…`));
227
+ const [cmdName, ...args] = inv.passthrough;
228
+ return await new Promise((resolveExit) => {
229
+ const child = spawn(cmdName, args, { cwd: ctx.cwd, env, stdio: "inherit" });
230
+ child.on("error", (err) => {
231
+ ctx.out.error(`Could not start ${cmdName}: ${err.message}`);
232
+ resolveExit(EXIT.ERROR);
233
+ });
234
+ child.on("exit", (code, signal) => resolveExit(code ?? (signal ? 1 : 0)));
235
+ });
236
+ },
237
+ });
238
+ export const doctorCommand = cmd({
239
+ path: "doctor",
240
+ group: "Ops",
241
+ summary: "Check login, token scopes and expiry, server reachability, tool catalog, git hooks and the CLI version, with fix hints.",
242
+ tool: ["get_viewer"],
243
+ async run(ctx) {
244
+ const checks = [];
245
+ const major = Number(process.versions.node.split(".")[0]);
246
+ checks.push({ name: "node", ok: major >= 20, detail: process.version, hint: major >= 20 ? undefined : "Install Node 20 or newer.", critical: true });
247
+ checks.push({ name: "cli", ok: true, detail: `scribeclock ${ctx.version}${ctx.config.read().update_check?.latest ? ` (latest seen: ${String(ctx.config.read().update_check?.latest)})` : ""}` });
248
+ try {
249
+ accessSync(ctx.config.path.replace(/config\.json$/, ""), constants.W_OK);
250
+ checks.push({ name: "config dir", ok: true, detail: ctx.config.path.replace(/config\.json$/, "") });
251
+ }
252
+ catch {
253
+ checks.push({ name: "config dir", ok: existsSync(ctx.config.path) ? true : null, detail: ctx.config.path.replace(/config\.json$/, ""), hint: "The directory is created on first login." });
254
+ }
255
+ checks.push({ name: "endpoint", ok: null, detail: ctx.endpoint });
256
+ try {
257
+ const described = await describeServer(ctx.endpoint, ctx.fetch);
258
+ checks.push({ name: "server", ok: true, detail: `${String(described.name)} ${String(described.version)} · ${described.tools.length} tools in the public catalog` });
259
+ }
260
+ catch (err) {
261
+ checks.push({ name: "server", ok: false, detail: err instanceof Error ? err.message : String(err), hint: "Check your network, SCRIBECLOCK_URL or --url.", critical: true });
262
+ }
263
+ const envToken = Boolean(ctx.env.SCRIBECLOCK_TOKEN);
264
+ const creds = envToken ? null : await ctx.store.load(ctx.profile).catch(() => null);
265
+ if (envToken)
266
+ checks.push({ name: "token", ok: true, detail: "SCRIBECLOCK_TOKEN (environment)" });
267
+ else if (!creds)
268
+ checks.push({ name: "token", ok: false, detail: "not logged in", hint: "Run `scribeclock login` (or set SCRIBECLOCK_TOKEN).", critical: true });
269
+ else {
270
+ const expiring = creds.expires_at ? new Date(creds.expires_at).getTime() - ctx.now().getTime() < 0 : false;
271
+ checks.push({ name: "token", ok: true, detail: `${creds.kind} in ${ctx.store.backend}${creds.expires_at ? ` · access token ${expiring ? "expired (will refresh)" : relTime(creds.expires_at, ctx.now())}` : ""}${creds.refresh_token ? " · refresh token present" : ""}` });
272
+ if (creds.kind === "oauth" && !creds.refresh_token)
273
+ checks.push({ name: "refresh", ok: false, detail: "no refresh token stored", hint: "Log in again so sessions can renew for 90 days." });
274
+ }
275
+ if (envToken || creds) {
276
+ try {
277
+ const viewer = await ctx.call("get_viewer", {}, { skipOrg: true });
278
+ const v = (viewer.viewer ?? {});
279
+ const scopes = viewer.token?.scopes ?? [];
280
+ checks.push({ name: "identity", ok: true, detail: `${String(v.name ?? "?")}${v.email ? ` <${String(v.email)}>` : ""} · ${viewer.organizations?.length ?? 0} organization(s)` });
281
+ const missing = ["tasks:read", "tasks:write", "projects:read"].filter((s) => !scopes.includes(s));
282
+ checks.push({ name: "scopes", ok: missing.length === 0, detail: scopes.join(" ") || "legacy token", hint: missing.length ? `Daily-workflow commands need ${missing.join(", ")}: scribeclock login --scopes ${[...scopes, ...missing].join(",")}` : undefined });
283
+ if (viewer.requires_organization_id && !ctx.org())
284
+ checks.push({ name: "organization", ok: false, detail: "you belong to several organizations and no default is set", hint: "Pass --org <id> or put {\"org\": \"<id>\"} in .scribeclock.json." });
285
+ const tools = await ctx.tools();
286
+ checks.push({ name: "tools", ok: tools.length > 0, detail: `${tools.length} tools available to this token${ctx.catalog.cachePath ? " (cached)" : ""}` });
287
+ }
288
+ catch (err) {
289
+ checks.push({ name: "identity", ok: false, detail: err instanceof Error ? err.message : String(err), hint: err instanceof CliError ? err.hint : "Run `scribeclock login` again.", critical: true });
290
+ }
291
+ }
292
+ const root = repoRoot(ctx.cwd);
293
+ if (root) {
294
+ const hooks = hooksPath(ctx.cwd);
295
+ checks.push({ name: "git", ok: true, detail: `${remoteUrl(ctx.cwd) ?? root}${hooks ? ` · hooks: ${hooks}` : ""}` });
296
+ if (existsSync(resolve(root, ".githooks")) && hooks !== ".githooks")
297
+ checks.push({ name: "git hooks", ok: false, detail: ".githooks exists but core.hooksPath is not set", hint: "git config core.hooksPath .githooks" });
298
+ checks.push({ name: "repo config", ok: ctx.repo.path ? true : null, detail: ctx.repo.path ?? "no .scribeclock.json", hint: ctx.repo.path ? undefined : 'Optional: {"project":"<id|name>","checks":["npm test"]} at the repo root powers `done`, `env` and `files`.' });
299
+ }
300
+ else
301
+ checks.push({ name: "git", ok: null, detail: "not in a repository" });
302
+ const gh = spawnSync("gh", ["--version"], { encoding: "utf8" });
303
+ checks.push({ name: "gh", ok: gh.status === 0 ? true : null, detail: gh.status === 0 ? gh.stdout.split("\n")[0] : "GitHub CLI not found (needed for `pr` and PR evidence in `done`)" });
304
+ if (ctx.platform === "darwin")
305
+ checks.push({ name: "keychain", ok: spawnSync("security", ["help"], { encoding: "utf8" }).status === 0 ? true : false, detail: "macOS Keychain via `security`" });
306
+ const failed = checks.filter((c) => c.ok === false);
307
+ const critical = failed.some((c) => c.critical);
308
+ if (ctx.flags.json) {
309
+ ctx.out.jsonOut({ ok: !critical, checks, failed: failed.map((c) => c.name) });
310
+ return critical ? EXIT.ERROR : EXIT.OK;
311
+ }
312
+ const w = Math.max(...checks.map((c) => c.name.length));
313
+ for (const c of checks) {
314
+ const mark = c.ok === true ? ctx.out.green("✓") : c.ok === false ? ctx.out.red("✗") : ctx.out.dim("·");
315
+ ctx.out.log(`${mark} ${c.name.padEnd(w)} ${c.detail}`);
316
+ if (c.hint && c.ok !== true)
317
+ ctx.out.log(` ${" ".repeat(w)} ${ctx.out.dim(c.hint)}`);
318
+ }
319
+ ctx.out.log("");
320
+ ctx.out.log(critical ? ctx.out.red(`${failed.length} problem${failed.length === 1 ? "" : "s"} found.`) : failed.length ? ctx.out.yellow(`${failed.length} warning${failed.length === 1 ? "" : "s"}; everything essential works.`) : ctx.out.green("All good."));
321
+ return critical ? EXIT.ERROR : EXIT.OK;
322
+ },
323
+ });
324
+ export const OPS_COMMANDS = [envPull, envPush, runCommand, doctorCommand];