@kontextmind/kxm 0.7.86 → 0.7.87

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.
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
- import { DatabaseSync } from "../sqlite.ts";
6
+ import { DatabaseSync, openReadOnlyDatabase } from "../sqlite.ts";
7
7
  import {
8
8
  DEFAULT_ROLES,
9
9
  DEFAULT_ROLE_SEATS,
@@ -20,7 +20,7 @@ import {
20
20
  import { discoverKxmProjectRoot } from "../project-config.ts";
21
21
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
22
22
  import { resumeWorkflowFromRuling, type WorkflowRun } from "../workflow.ts";
23
- import { print, type CliIo, type Runtime } from "./types.ts";
23
+ import { print, printPlan, type CliIo, type Runtime } from "./types.ts";
24
24
 
25
25
  export interface PickCandidate {
26
26
  id: string;
@@ -192,7 +192,12 @@ export async function cmdRoleAdd(
192
192
  repoRoot: runtime.cwd,
193
193
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
194
194
  overwrite: options.overwrite,
195
+ dryRun: runtime.dryRun,
195
196
  });
197
+ if (runtime.dryRun) {
198
+ printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
199
+ return 0;
200
+ }
196
201
  print(
197
202
  runtime.io,
198
203
  runtime.json,
@@ -232,7 +237,12 @@ export async function cmdRoleAdd(
232
237
  repoRoot: runtime.cwd,
233
238
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
234
239
  overwrite: options.overwrite,
240
+ dryRun: runtime.dryRun,
235
241
  });
242
+ if (runtime.dryRun) {
243
+ printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
244
+ return 0;
245
+ }
236
246
  print(
237
247
  runtime.io,
238
248
  runtime.json,
@@ -283,7 +293,12 @@ export async function cmdRoleRemove(
283
293
  scope,
284
294
  repoRoot: runtime.cwd,
285
295
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
296
+ dryRun: runtime.dryRun,
286
297
  });
298
+ if (runtime.dryRun) {
299
+ printPlan(runtime, { command: "role remove", roleId, ...res }, [{ action: "delete", target: res.filePath }], `remove role '${roleId}' from ${res.scope}`);
300
+ return 0;
301
+ }
287
302
  print(
288
303
  runtime.io,
289
304
  runtime.json,
@@ -377,7 +392,12 @@ export async function cmdRoleModify(
377
392
  scope: options.scope,
378
393
  repoRoot: runtime.cwd,
379
394
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
395
+ dryRun: runtime.dryRun,
380
396
  });
397
+ if (runtime.dryRun) {
398
+ printPlan(runtime, { command: "role modify", roleId, ...res }, [{ action: "write", target: res.filePath }], `modify role '${roleId}' in ${res.scope}`);
399
+ return 0;
400
+ }
381
401
  print(
382
402
  runtime.io,
383
403
  runtime.json,
@@ -482,7 +502,17 @@ export async function cmdRoleSetHost(
482
502
  scope: options.scope ?? "local",
483
503
  repoRoot: runtime.cwd,
484
504
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
505
+ dryRun: runtime.dryRun,
485
506
  });
507
+ if (runtime.dryRun) {
508
+ printPlan(
509
+ runtime,
510
+ { command: "role set-host", seatId, host, binding: result.binding, filePath: result.filePath, scope: result.scope },
511
+ [{ action: "write", target: result.filePath }],
512
+ `bind seat '${seatId}' to host '${host}'`,
513
+ );
514
+ return 0;
515
+ }
486
516
 
487
517
  print(
488
518
  runtime.io,
@@ -521,7 +551,12 @@ export async function cmdRoleResume(
521
551
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
522
552
  if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
523
553
  if (runtime.dryRun) {
524
- print(runtime.io, runtime.json, { ok: true, command: "role resume", runId, ruling: effectiveRuling }, `would resume KXM run ${runId}`);
554
+ printPlan(
555
+ runtime,
556
+ { command: "role resume", runId, ruling: effectiveRuling },
557
+ [{ action: "request", target: `POST kxm-runtime /v1/runs/${runId}/signal (audit_escalation unblock)` }],
558
+ `resume KXM run ${runId}`,
559
+ );
525
560
  return 0;
526
561
  }
527
562
  try {
@@ -555,7 +590,7 @@ export async function cmdRoleResume(
555
590
  const dbPath = join(runtime.cwd, ".kxm", "state", "kxm.db");
556
591
  if (existsSync(dbPath)) {
557
592
  try {
558
- const database = new DatabaseSync(dbPath);
593
+ const database = runtime.dryRun ? openReadOnlyDatabase(dbPath) : new DatabaseSync(dbPath);
559
594
  try {
560
595
  const row = database.prepare("SELECT record FROM workflow_runs WHERE id = ?").get(runId) as { record: string } | undefined;
561
596
  if (!row) {
@@ -565,6 +600,15 @@ export async function cmdRoleResume(
565
600
  const run = JSON.parse(row.record) as WorkflowRun;
566
601
  const now = new Date().toISOString();
567
602
  const resumeResult = resumeWorkflowFromRuling(run, effectiveRuling, now);
603
+ if (runtime.dryRun) {
604
+ printPlan(
605
+ runtime,
606
+ { command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status },
607
+ [{ action: "write", target: `${dbPath} (workflow_runs ${runId}, one workflow_journal decision)` }],
608
+ `resume workflow run ${runId} (stage: ${resumeResult.stageId})`,
609
+ );
610
+ return 0;
611
+ }
568
612
 
569
613
  database.prepare("UPDATE workflow_runs SET record = ? WHERE id = ?").run(
570
614
  JSON.stringify(resumeResult.run),
@@ -88,6 +88,7 @@ import {
88
88
  import { kxmUserStateRoot } from "../bindings.ts";
89
89
  import {
90
90
  print,
91
+ printPlan,
91
92
  printWorker,
92
93
  gateOf,
93
94
  type CliIo,
@@ -224,6 +225,10 @@ export async function cmdSshRun(
224
225
  options: { sudo?: boolean | undefined },
225
226
  ): Promise<number> {
226
227
  const command = commandParts.join(" ");
228
+ if (runtime.dryRun) {
229
+ printPlan(runtime, { command: "ssh run", host, remoteCommand: command, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${options.sudo ? "sudo " : ""}${command}` }], `run a command on ${host}`);
230
+ return 0;
231
+ }
227
232
  const receipt = executeSshRun({
228
233
  action: "command",
229
234
  host,
@@ -247,6 +252,10 @@ export async function cmdSshFile(
247
252
  options: { content?: string | undefined; read?: boolean | undefined; append?: boolean | undefined; sudo?: boolean | undefined },
248
253
  ): Promise<number> {
249
254
  const op = options.read ? "read" : (options.append ? "append" : "write");
255
+ if (runtime.dryRun) {
256
+ printPlan(runtime, { command: "ssh file", host, path: filePath, op, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${op} ${filePath}` }], `${op} ${filePath} on ${host}`);
257
+ return 0;
258
+ }
250
259
  const receipt = executeSshRun({
251
260
  action: "file",
252
261
  host,
@@ -269,6 +278,10 @@ export async function cmdSshFile(
269
278
  }
270
279
 
271
280
  export async function cmdSshClose(runtime: Runtime, host: string): Promise<number> {
281
+ if (runtime.dryRun) {
282
+ printPlan(runtime, { command: "ssh close", host }, [{ action: "ssh", target: `${host}: close the ControlMaster socket` }], `close the ControlMaster socket for ${host}`);
283
+ return 0;
284
+ }
272
285
  const closed = closeControlSocket(host);
273
286
  if (runtime.json) {
274
287
  print(runtime.io, runtime.json, { ok: true, closed, command: "ssh close", host }, "");
@@ -568,7 +581,11 @@ export async function cmdConfigSet(
568
581
  } catch {
569
582
  // keep string
570
583
  }
571
- setKxmConfigValue(runtime.cwd, key, parsedVal, { scope });
584
+ const { file } = setKxmConfigValue(runtime.cwd, key, parsedVal, { scope, dryRun: runtime.dryRun });
585
+ if (runtime.dryRun) {
586
+ printPlan(runtime, { command: "config set", key, value: parsedVal, scope }, [{ action: "write", target: file }], `set ${key} = ${value} in ${scope} config`);
587
+ return 0;
588
+ }
572
589
  print(
573
590
  runtime.io,
574
591
  runtime.json,
@@ -11,6 +11,8 @@ import {
11
11
  getTask,
12
12
  updateTaskStatus,
13
13
  syncTaskWithTracker,
14
+ goalFilePath,
15
+ taskFilePath,
14
16
  type TaskStatus,
15
17
  type TrackerType,
16
18
  } from "../task-manager.ts";
@@ -22,8 +24,8 @@ import {
22
24
  generateStudioLayout,
23
25
  } from "../studio-layout.ts";
24
26
  import { readSessionTokenFromDisk } from "../commands.ts";
25
- import { print, type Runtime } from "./types.ts";
26
- import { cmdKxmRun } from "./project.ts";
27
+ import { print, printPlan, type Runtime } from "./types.ts";
28
+ import { cmdKxmRun, resolveKxmRunTarget } from "./project.ts";
27
29
 
28
30
  export async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
29
31
  try {
@@ -74,7 +76,11 @@ export async function cmdGoalCreate(
74
76
  area: options.area,
75
77
  successMetrics: options.metric,
76
78
  targetDate: options.targetDate,
77
- });
79
+ }, { dryRun: runtime.dryRun });
80
+ if (runtime.dryRun) {
81
+ printPlan(runtime, { command: "goal create", goal }, [{ action: "write", target: goalFilePath(runtime.cwd, goal.id) }], `create goal ${goal.title} (the id is assigned when it is created)`);
82
+ return 0;
83
+ }
78
84
  print(
79
85
  runtime.io,
80
86
  runtime.json,
@@ -118,7 +124,11 @@ export async function cmdTaskCreate(
118
124
  trackerSync: options.tracker && options.issue
119
125
  ? { tracker: options.tracker as TrackerType, issueKey: options.issue }
120
126
  : undefined,
121
- });
127
+ }, { dryRun: runtime.dryRun });
128
+ if (runtime.dryRun) {
129
+ printPlan(runtime, { command: "task create", task }, [{ action: "write", target: taskFilePath(runtime.cwd, task.id) }], `create task ${task.title} [${task.status}] (the id is assigned when it is created)`);
130
+ return 0;
131
+ }
122
132
  print(
123
133
  runtime.io,
124
134
  runtime.json,
@@ -187,6 +197,21 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
187
197
  return 1;
188
198
  }
189
199
  const workflow = task.assignedWorkflow ?? "default";
200
+ if (runtime.dryRun) {
201
+ const target = resolveKxmRunTarget(runtime, workflow);
202
+ if (typeof target === "number") return target;
203
+ const started = updateTaskStatus(runtime.cwd, taskId, "in_progress", { dryRun: true });
204
+ printPlan(
205
+ runtime,
206
+ { command: "task run", taskId, ...target, status: started.status },
207
+ [
208
+ { action: "request", target: "POST kxm-runtime /v1/runs (starts the Runtime supervisor if it is not running)" },
209
+ { action: "write", target: taskFilePath(runtime.cwd, taskId) },
210
+ ],
211
+ `run workflow ${workflow} for task ${taskId}, then mark it ${started.status}`,
212
+ );
213
+ return 0;
214
+ }
190
215
  const exitCode = await cmdKxmRun(runtime, workflow, [task.objective]);
191
216
  if (exitCode === 0) {
192
217
  updateTaskStatus(runtime.cwd, taskId, "in_progress");
@@ -201,7 +226,11 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
201
226
 
202
227
  export async function cmdTaskSync(runtime: Runtime, taskId: string): Promise<number> {
203
228
  try {
204
- const synced = syncTaskWithTracker(runtime.cwd, taskId);
229
+ const synced = syncTaskWithTracker(runtime.cwd, taskId, { dryRun: runtime.dryRun });
230
+ if (runtime.dryRun) {
231
+ printPlan(runtime, { command: "task sync", task: synced }, [{ action: "write", target: taskFilePath(runtime.cwd, taskId) }], `mark task ${taskId} synced with ${synced.trackerSync?.tracker} #${synced.trackerSync?.issueKey}`);
232
+ return 0;
233
+ }
205
234
  print(
206
235
  runtime.io,
207
236
  runtime.json,
@@ -101,6 +101,36 @@ export function print(io: CliIo, jsonMode: boolean, payload: object, text: strin
101
101
  else io.stdout(line);
102
102
  }
103
103
 
104
+ /** One change a command would have made had `--dry-run` not been given. */
105
+ export interface PlannedChange {
106
+ action: "write" | "delete" | "move" | "request" | "ssh";
107
+ target: string;
108
+ }
109
+
110
+ /** The `--dry-run` answer of a mutating command: the envelope the real run
111
+ * prints, marked `dryRun: true`, plus every write, request, or remote step it
112
+ * would have taken. Nothing is written by the caller before or after this. */
113
+ export function printPlan(
114
+ runtime: Runtime,
115
+ payload: Record<string, unknown> & { command: string },
116
+ planned: readonly PlannedChange[],
117
+ text: string,
118
+ ): void {
119
+ print(runtime.io, runtime.json, { ok: true, ...payload, dryRun: true, planned }, [
120
+ `dry run: ${text}`,
121
+ ...(planned.length === 0 ? [" no changes"] : planned.map((change) => ` would ${change.action} ${change.target}`)),
122
+ ].join("\n"));
123
+ }
124
+
125
+ export const DRY_RUN_UNSUPPORTED = "dry_run_unsupported";
126
+
127
+ /** For a command that cannot say what it would do without doing some of it:
128
+ * refuse under `--dry-run` instead of executing. */
129
+ export function refuseDryRun(io: CliIo, jsonMode: boolean, command: string, reason: string): number {
130
+ print(io, jsonMode, { ok: false, command, dryRun: true, error: DRY_RUN_UNSUPPORTED, detail: reason }, `kxm ${command} --dry-run refused: ${reason}`);
131
+ return 2;
132
+ }
133
+
104
134
  export function printWorker(
105
135
  runtime: Runtime,
106
136
  worker: Worker,
@@ -1,7 +1,7 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { basename, join, resolve } from "node:path";
4
- import { DatabaseSync } from "../sqlite.ts";
4
+ import { openReadOnlyDatabase } from "../sqlite.ts";
5
5
  import { buildRetrospective, writeRetrospective } from "../retrospective.ts";
6
6
  import { redactSecrets } from "../redact.ts";
7
7
  import { postWorkflowSignal, watchGithubChecks } from "../github-watch.ts";
@@ -24,6 +24,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts
24
24
  import type { WorkerOutcome } from "../envelope.ts";
25
25
  import {
26
26
  print,
27
+ printPlan,
27
28
  printWorker,
28
29
  gateOf,
29
30
  parseEvidencePairs,
@@ -37,7 +38,7 @@ const CLI_NAME = "kxm";
37
38
 
38
39
  export function localWorkflowSnapshot(dataPath: string, runId?: string): { runs: WorkflowRun[]; journal: WorkflowJournalEntry[] } {
39
40
  if (!existsSync(dataPath)) throw new Error("state_database_not_found");
40
- const database = new DatabaseSync(dataPath, { readOnly: true });
41
+ const database = openReadOnlyDatabase(dataPath);
41
42
  try {
42
43
  const rows = runId
43
44
  ? database.prepare("SELECT record FROM workflow_runs WHERE id = ?").all(runId)
@@ -265,7 +266,12 @@ export async function cmdWorkflowAdd(
265
266
  repoRoot: runtime.cwd,
266
267
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
267
268
  overwrite: options.overwrite,
269
+ dryRun: runtime.dryRun,
268
270
  });
271
+ if (runtime.dryRun) {
272
+ printPlan(runtime, { command: "workflow add", workflowId, ...res }, [{ action: "write", target: res.filePath }], `add workflow '${workflowId}' to ${res.scope}`);
273
+ return 0;
274
+ }
269
275
  print(
270
276
  runtime.io,
271
277
  runtime.json,
@@ -316,7 +322,12 @@ export async function cmdWorkflowRemove(
316
322
  scope,
317
323
  repoRoot: runtime.cwd,
318
324
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
325
+ dryRun: runtime.dryRun,
319
326
  });
327
+ if (runtime.dryRun) {
328
+ printPlan(runtime, { command: "workflow remove", workflowId, ...res }, [{ action: "delete", target: res.filePath }], `remove workflow '${workflowId}' from ${res.scope}`);
329
+ return 0;
330
+ }
320
331
  print(
321
332
  runtime.io,
322
333
  runtime.json,
@@ -373,8 +384,13 @@ export async function cmdWorkflowModify(
373
384
  scope: options.scope,
374
385
  repoRoot: runtime.cwd,
375
386
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
387
+ dryRun: runtime.dryRun,
376
388
  },
377
389
  );
390
+ if (runtime.dryRun) {
391
+ printPlan(runtime, { command: "workflow modify", workflowId, ...res }, [{ action: "write", target: res.filePath }], `modify workflow '${workflowId}' in ${res.scope}`);
392
+ return 0;
393
+ }
378
394
  print(
379
395
  runtime.io,
380
396
  runtime.json,
@@ -520,7 +536,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
520
536
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
521
537
  if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
522
538
  if (runtime.dryRun) {
523
- printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
539
+ printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
524
540
  return 0;
525
541
  }
526
542
  const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
@@ -557,7 +573,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
557
573
  return 2;
558
574
  }
559
575
  if (runtime.dryRun) {
560
- printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signed signal");
576
+ printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signed signal");
561
577
  return 0;
562
578
  }
563
579
  const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
@@ -30,6 +30,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts"
30
30
  // Submodule imports
31
31
  import {
32
32
  print,
33
+ refuseDryRun,
33
34
  runtimeFrom,
34
35
  redactConfiguredValues,
35
36
  type CliContext,
@@ -172,6 +173,54 @@ const USAGE_ERROR_CODES = new Set([
172
173
  "commander.optionMissingArgument",
173
174
  ]);
174
175
 
176
+ /**
177
+ * Every command that answers `--dry-run` without changing anything: it only
178
+ * reads, or it prints the plan (`dryRun: true` plus `planned`) and stops.
179
+ * A command missing from this set is refused under `--dry-run` with
180
+ * `dry_run_unsupported` before its action runs, so a new command that forgets
181
+ * to check the flag fails closed instead of mutating. Keyed by command path.
182
+ */
183
+ const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
184
+ "init", "backup", "restore", "run", "explain", "suggest", "update", "dash", "completion", "completion install",
185
+ "runs status", "runs drive", "runs receipt", "runs cancel", "runs list",
186
+ "tenant status", "models inventory-refresh", "harness list", "auth token",
187
+ "routes list", "routes count", "routes admit", "routes disable",
188
+ "runtime start", "runtime status", "runtime sync-retry", "runtime stop",
189
+ "trust diff", "trust check", "agent worker",
190
+ "session status", "session brief", "session token", "session start", "session stop",
191
+ "peer list", "peer send", "peer get", "peer await", "peer cancel", "peer fanout", "peer inbox", "peer reply",
192
+ "workflow list", "workflow get", "workflow checkpoint", "workflow record", "workflow wait", "workflow signal",
193
+ "workflow start", "workflow export", "workflow definitions", "workflow add", "workflow remove", "workflow modify",
194
+ "role list", "role get", "role add", "role remove", "role modify", "role hosts", "role set-host", "role resume",
195
+ "gate validate", "gate artifacts-exist", "gate degrade", "gate signal", "gate github watch",
196
+ "improve report",
197
+ "context get", "context recall", "context state", "context episode", "context promote", "context explain",
198
+ "context wiki-compile", "context wiki-lint",
199
+ "skills create", "skills evaluate", "skills promote", "skills reject", "skills list", "skills verify",
200
+ "memory brief", "memory note", "memory sync",
201
+ "routing report", "routing benchmark",
202
+ "ssh info", "ssh run", "ssh file", "ssh close",
203
+ "hub view", "hub start", "hub stop", "hub bind", "hub unbind",
204
+ "config get", "config set", "config list",
205
+ "goal create", "goal list",
206
+ "task create", "task list", "task get", "task run", "task sync",
207
+ "studio layout", "studio serve",
208
+ ]);
209
+
210
+ class DryRunRefused extends Error {
211
+ readonly code: number;
212
+ constructor(code: number) {
213
+ super("dry_run_unsupported");
214
+ this.code = code;
215
+ }
216
+ }
217
+
218
+ function commandPath(command: Command): string {
219
+ const names: string[] = [];
220
+ for (let current: Command | null = command; current?.parent; current = current.parent) names.unshift(current.name());
221
+ return names.join(" ");
222
+ }
223
+
175
224
  function addGlobalOptions(command: Command): Command {
176
225
  return command
177
226
  .option("--json", "Print machine-readable JSON")
@@ -331,6 +380,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
331
380
  })
332
381
  .helpCommand("help", "Show help");
333
382
  addGlobalOptions(program);
383
+ program.hook("preAction", (_program, actionCommand) => {
384
+ const opts = actionCommand.optsWithGlobals() as { dryRun?: boolean; json?: boolean };
385
+ if (!opts.dryRun) return;
386
+ const path = commandPath(actionCommand);
387
+ if (DRY_RUN_COMMANDS.has(path)) return;
388
+ throw new DryRunRefused(refuseDryRun(ctx.io, Boolean(opts.json), path, "this command cannot plan without making changes; rerun without --dry-run"));
389
+ });
334
390
 
335
391
  program.command("init").description("Create, validate, repair, or join a KXM project")
336
392
  .option("--json", "Print machine-readable JSON")
@@ -1222,6 +1278,7 @@ export async function runCli(
1222
1278
  return result.code;
1223
1279
  } catch (error) {
1224
1280
  if (error instanceof CommanderError) return mapCommanderError(error);
1281
+ if (error instanceof DryRunRefused) return error.code;
1225
1282
  throw error;
1226
1283
  }
1227
1284
  }
@@ -303,8 +303,8 @@ export function setKxmConfigValue(
303
303
  repoRoot: string,
304
304
  keyPath: string,
305
305
  value: unknown,
306
- options: { scope?: "user" | "project"; userConfigDir?: string } = {},
307
- ): void {
306
+ options: { scope?: "user" | "project"; userConfigDir?: string; dryRun?: boolean } = {},
307
+ ): { file: string } {
308
308
  const targetFile = kxmConfigFileForScope(repoRoot, options.scope ?? "project", options.userConfigDir);
309
309
  const existing = readConfigFile(targetFile);
310
310
  const parts = configKeyParts(keyPath);
@@ -317,7 +317,8 @@ export function setKxmConfigValue(
317
317
  cursor = cursor[p] as Record<string, unknown>;
318
318
  }
319
319
  cursor[parts[parts.length - 1]!] = value;
320
- writeConfigFile(targetFile, existing);
320
+ if (!options.dryRun) writeConfigFile(targetFile, existing);
321
+ return { file: targetFile };
321
322
  }
322
323
 
323
324
  /**
@@ -649,11 +649,20 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
649
649
  return stores;
650
650
  }
651
651
 
652
- export function createBackup(options: {
652
+ export interface BackupPlan {
653
+ projectRoot: string;
654
+ outDir: string;
655
+ createdAt: string;
656
+ stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
657
+ }
658
+
659
+ /** Which stores a backup would copy and where, without opening any of them.
660
+ * Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
661
+ export function planBackup(options: {
653
662
  projectRoot?: string;
654
663
  outDir?: string;
655
664
  hubDataPath?: string;
656
- } = {}): { manifest: BackupManifest; outDir: string } {
665
+ } = {}): BackupPlan {
657
666
  const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
658
667
  const stores = discoverProjectStores(projectRoot, {
659
668
  ...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
@@ -663,35 +672,43 @@ export function createBackup(options: {
663
672
  throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
664
673
  }
665
674
 
666
- const now = new Date();
667
- const timestamp = now.toISOString().replace(/[:.]/g, "-");
668
- const backupId = `bk_${randomBytes(8).toString("hex")}`;
675
+ const createdAt = new Date().toISOString();
676
+ const timestamp = createdAt.replace(/[:.]/g, "-");
669
677
  const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
670
-
671
- if (!existsSync(outDir)) {
672
- mkdirSync(outDir, { recursive: true, mode: 0o700 });
673
- }
674
-
675
- const backedUpStores: BackupStoreRecord[] = [];
676
678
  const usedFilenames = new Set<string>();
677
-
678
- for (const store of stores) {
679
+ const planned = stores.map((store) => {
679
680
  let filename = basename(store.sourcePath);
680
681
  if (usedFilenames.has(filename)) {
681
682
  const sanitizedId = store.storeId.replace(/[^a-zA-Z0-9_.-]/g, "_");
682
683
  filename = `${sanitizedId}-${filename}`;
683
684
  }
684
685
  usedFilenames.add(filename);
686
+ return { storeId: store.storeId, sourcePath: store.sourcePath, backupFile: filename };
687
+ });
688
+ return { projectRoot, outDir, createdAt, stores: planned };
689
+ }
690
+
691
+ export function createBackup(options: {
692
+ projectRoot?: string;
693
+ outDir?: string;
694
+ hubDataPath?: string;
695
+ } = {}): { manifest: BackupManifest; outDir: string } {
696
+ const { projectRoot, outDir, createdAt, stores } = planBackup(options);
697
+ const backupId = `bk_${randomBytes(8).toString("hex")}`;
685
698
 
686
- const targetFile = join(outDir, filename);
687
- const record = backupDatabaseFile(store.sourcePath, targetFile, store.storeId);
688
- backedUpStores.push(record);
699
+ if (!existsSync(outDir)) {
700
+ mkdirSync(outDir, { recursive: true, mode: 0o700 });
701
+ }
702
+
703
+ const backedUpStores: BackupStoreRecord[] = [];
704
+ for (const store of stores) {
705
+ backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
689
706
  }
690
707
 
691
708
  const manifest: BackupManifest = {
692
709
  schema: "kxm.backup-manifest.v1",
693
710
  backupId,
694
- createdAt: now.toISOString(),
711
+ createdAt,
695
712
  projectRoot,
696
713
  stores: backedUpStores,
697
714
  };
@@ -707,10 +724,19 @@ export function createBackup(options: {
707
724
  return { manifest, outDir };
708
725
  }
709
726
 
710
- export function restoreBackup(
727
+ export interface RestorePlan {
728
+ manifestPath: string;
729
+ backupId: string;
730
+ stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
731
+ }
732
+
733
+ /** Everything restore checks before it overwrites anything: the manifest, each
734
+ * backup file's digest, and each store's schema against this build's ceiling
735
+ * (from the manifest). Reads files; opens no database. */
736
+ export function planRestore(
711
737
  manifestPathOrDir: string,
712
738
  options: { projectRoot?: string } = {},
713
- ): RestoreResult {
739
+ ): RestorePlan {
714
740
  let manifestPath = resolve(manifestPathOrDir);
715
741
  const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
716
742
  if (!stat) {
@@ -737,8 +763,7 @@ export function restoreBackup(
737
763
  throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
738
764
  }
739
765
 
740
- const restoredStores: RestoreStoreRecord[] = [];
741
-
766
+ const stores: RestorePlan["stores"] = [];
742
767
  for (const store of manifest.stores) {
743
768
  const backupFilePath = join(manifestDir, store.backupFile);
744
769
  if (!existsSync(backupFilePath)) {
@@ -754,27 +779,41 @@ export function restoreBackup(
754
779
  );
755
780
  }
756
781
 
757
- const maxSupported = kxmBackupCeiling(store.storeId);
782
+ const maxSupportedVersion = kxmBackupCeiling(store.storeId);
783
+ if (store.schemaVersion > maxSupportedVersion) {
784
+ throw databaseError(
785
+ "runtime_schema_newer",
786
+ backupFilePath,
787
+ `backup store ${store.storeId} schema version ${store.schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
788
+ );
789
+ }
758
790
 
759
791
  let targetPath = store.sourcePath;
760
792
  if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
761
793
  const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
762
794
  targetPath = join(resolve(options.projectRoot), rel);
763
795
  }
764
-
765
- const result = restoreDatabaseFile(
766
- backupFilePath,
767
- targetPath,
768
- store.storeId,
769
- store.schemaVersion,
770
- maxSupported,
771
- );
772
- restoredStores.push(result);
796
+ stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
773
797
  }
774
798
 
799
+ return { manifestPath, backupId: manifest.backupId, stores };
800
+ }
801
+
802
+ export function restoreBackup(
803
+ manifestPathOrDir: string,
804
+ options: { projectRoot?: string } = {},
805
+ ): RestoreResult {
806
+ const plan = planRestore(manifestPathOrDir, options);
807
+ const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
808
+ store.backupFilePath,
809
+ store.targetPath,
810
+ store.storeId,
811
+ store.schemaVersion,
812
+ store.maxSupportedVersion,
813
+ ));
775
814
  return {
776
- manifestPath,
777
- backupId: manifest.backupId,
815
+ manifestPath: plan.manifestPath,
816
+ backupId: plan.backupId,
778
817
  restoredStores,
779
818
  };
780
819
  }