toolcraft 0.0.23 → 0.0.25

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.
Files changed (152) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.compile-check.js +1 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +50 -13
  5. package/dist/error-report.js +32 -3
  6. package/dist/human-in-loop/approval-tasks.d.ts +1 -0
  7. package/dist/human-in-loop/approval-tasks.js +7 -5
  8. package/dist/human-in-loop/approvals-commands.js +51 -8
  9. package/dist/human-in-loop/runner.js +24 -19
  10. package/dist/human-in-loop/state-machine.d.ts +3 -3
  11. package/dist/human-in-loop/state-machine.js +13 -5
  12. package/dist/index.d.ts +5 -0
  13. package/dist/index.js +6 -1
  14. package/dist/mcp-proxy.js +85 -19
  15. package/dist/mcp.compile-check.js +1 -0
  16. package/dist/mcp.d.ts +1 -0
  17. package/dist/mcp.js +50 -8
  18. package/dist/renderer.js +119 -13
  19. package/dist/sdk.compile-check.js +1 -0
  20. package/dist/sdk.d.ts +1 -0
  21. package/dist/sdk.js +56 -11
  22. package/node_modules/@poe-code/agent-defs/dist/registry.d.ts +1 -1
  23. package/node_modules/@poe-code/agent-defs/dist/registry.js +22 -11
  24. package/node_modules/@poe-code/agent-defs/package.json +1 -1
  25. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript-script.js +5 -1
  26. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript.js +1 -1
  27. package/node_modules/@poe-code/agent-human-in-loop/package.json +1 -1
  28. package/node_modules/@poe-code/agent-mcp-config/dist/apply.d.ts +1 -1
  29. package/node_modules/@poe-code/agent-mcp-config/dist/apply.js +41 -92
  30. package/node_modules/@poe-code/agent-mcp-config/dist/configs.js +4 -1
  31. package/node_modules/@poe-code/agent-mcp-config/dist/shapes.d.ts +14 -2
  32. package/node_modules/@poe-code/agent-mcp-config/dist/shapes.js +11 -4
  33. package/node_modules/@poe-code/agent-mcp-config/package.json +1 -1
  34. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +200 -22
  35. package/node_modules/@poe-code/config-mutations/dist/execution/path-utils.js +7 -1
  36. package/node_modules/@poe-code/config-mutations/dist/formats/index.js +1 -1
  37. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +11 -7
  38. package/node_modules/@poe-code/config-mutations/dist/formats/object.d.ts +4 -0
  39. package/node_modules/@poe-code/config-mutations/dist/formats/object.js +27 -0
  40. package/node_modules/@poe-code/config-mutations/dist/formats/toml.js +12 -9
  41. package/node_modules/@poe-code/config-mutations/dist/formats/yaml.js +12 -9
  42. package/node_modules/@poe-code/config-mutations/dist/mutations/file-mutation.d.ts +11 -1
  43. package/node_modules/@poe-code/config-mutations/dist/mutations/file-mutation.js +10 -1
  44. package/node_modules/@poe-code/config-mutations/dist/testing/mock-fs.js +25 -1
  45. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +12 -2
  46. package/node_modules/@poe-code/config-mutations/package.json +1 -1
  47. package/node_modules/@poe-code/design-system/dist/acp/components.js +3 -1
  48. package/node_modules/@poe-code/design-system/dist/components/browser.d.ts +1 -1
  49. package/node_modules/@poe-code/design-system/dist/components/browser.js +6 -1
  50. package/node_modules/@poe-code/design-system/dist/components/color.js +9 -8
  51. package/node_modules/@poe-code/design-system/dist/components/command-errors.js +3 -2
  52. package/node_modules/@poe-code/design-system/dist/components/detail-card.d.ts +22 -0
  53. package/node_modules/@poe-code/design-system/dist/components/detail-card.js +69 -0
  54. package/node_modules/@poe-code/design-system/dist/components/help-formatter.js +88 -11
  55. package/node_modules/@poe-code/design-system/dist/components/index.d.ts +1 -1
  56. package/node_modules/@poe-code/design-system/dist/components/index.js +1 -1
  57. package/node_modules/@poe-code/design-system/dist/components/table.d.ts +2 -0
  58. package/node_modules/@poe-code/design-system/dist/components/table.js +82 -5
  59. package/node_modules/@poe-code/design-system/dist/components/template.d.ts +4 -0
  60. package/node_modules/@poe-code/design-system/dist/components/template.js +198 -32
  61. package/node_modules/@poe-code/design-system/dist/components/text.js +29 -5
  62. package/node_modules/@poe-code/design-system/dist/dashboard/ansi.d.ts +2 -2
  63. package/node_modules/@poe-code/design-system/dist/dashboard/ansi.js +77 -32
  64. package/node_modules/@poe-code/design-system/dist/dashboard/buffer.js +28 -5
  65. package/node_modules/@poe-code/design-system/dist/dashboard/components/output-pane.js +45 -28
  66. package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.d.ts +4 -0
  67. package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.js +71 -0
  68. package/node_modules/@poe-code/design-system/dist/dashboard/types.d.ts +1 -0
  69. package/node_modules/@poe-code/design-system/dist/explorer/events.d.ts +6 -0
  70. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +32 -10
  71. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +3 -0
  72. package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +57 -6
  73. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  74. package/node_modules/@poe-code/design-system/dist/explorer/state.js +12 -15
  75. package/node_modules/@poe-code/design-system/dist/index.d.ts +3 -1
  76. package/node_modules/@poe-code/design-system/dist/index.js +2 -1
  77. package/node_modules/@poe-code/design-system/dist/prompts/primitives/intro.js +2 -1
  78. package/node_modules/@poe-code/design-system/dist/prompts/primitives/log.js +8 -5
  79. package/node_modules/@poe-code/design-system/dist/prompts/primitives/note.js +1 -1
  80. package/node_modules/@poe-code/design-system/dist/static/menu.js +8 -2
  81. package/node_modules/@poe-code/design-system/dist/static/spinner.js +10 -4
  82. package/node_modules/@poe-code/design-system/dist/terminal-markdown/parser/frontmatter.js +9 -2
  83. package/node_modules/@poe-code/design-system/dist/terminal-markdown/renderer.js +19 -2
  84. package/node_modules/@poe-code/design-system/package.json +2 -1
  85. package/node_modules/@poe-code/process-runner/dist/docker/args.d.ts +1 -0
  86. package/node_modules/@poe-code/process-runner/dist/docker/args.js +11 -3
  87. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +377 -130
  88. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +78 -10
  89. package/node_modules/@poe-code/process-runner/dist/docker/env-file.d.ts +6 -0
  90. package/node_modules/@poe-code/process-runner/dist/docker/env-file.js +49 -0
  91. package/node_modules/@poe-code/process-runner/dist/host/host-execution-env.js +3 -2
  92. package/node_modules/@poe-code/process-runner/dist/host/host-runner.js +21 -5
  93. package/node_modules/@poe-code/process-runner/dist/index.d.ts +1 -0
  94. package/node_modules/@poe-code/process-runner/dist/index.js +1 -0
  95. package/node_modules/@poe-code/process-runner/dist/testing/mock-runner.js +30 -8
  96. package/node_modules/@poe-code/process-runner/dist/types.d.ts +6 -0
  97. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.d.ts +61 -0
  98. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +503 -0
  99. package/node_modules/@poe-code/process-runner/package.json +1 -1
  100. package/node_modules/@poe-code/task-list/README.md +0 -2
  101. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-client.js +3 -0
  102. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +89 -59
  103. package/node_modules/@poe-code/task-list/dist/backends/gh-issues.d.ts +9 -3
  104. package/node_modules/@poe-code/task-list/dist/backends/gh-issues.js +460 -99
  105. package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +156 -154
  106. package/node_modules/@poe-code/task-list/dist/backends/utils.d.ts +2 -0
  107. package/node_modules/@poe-code/task-list/dist/backends/utils.js +79 -0
  108. package/node_modules/@poe-code/task-list/dist/backends/yaml-file.js +120 -132
  109. package/node_modules/@poe-code/task-list/dist/index.d.ts +3 -1
  110. package/node_modules/@poe-code/task-list/dist/index.js +2 -0
  111. package/node_modules/@poe-code/task-list/dist/move.d.ts +2 -0
  112. package/node_modules/@poe-code/task-list/dist/move.js +215 -0
  113. package/node_modules/@poe-code/task-list/dist/open.js +3 -4
  114. package/node_modules/@poe-code/task-list/dist/state-machine.js +3 -1
  115. package/node_modules/@poe-code/task-list/dist/state.js +9 -0
  116. package/node_modules/@poe-code/task-list/dist/types.d.ts +48 -13
  117. package/node_modules/@poe-code/task-list/package.json +1 -2
  118. package/node_modules/auth-store/dist/create-secret-store.js +4 -1
  119. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +8 -0
  120. package/node_modules/auth-store/dist/encrypted-file-store.js +104 -8
  121. package/node_modules/auth-store/dist/index.d.ts +1 -1
  122. package/node_modules/auth-store/dist/keychain-store.d.ts +4 -1
  123. package/node_modules/auth-store/dist/keychain-store.js +18 -16
  124. package/node_modules/auth-store/dist/provider-store.d.ts +5 -1
  125. package/node_modules/auth-store/dist/provider-store.js +55 -7
  126. package/node_modules/auth-store/dist/types.d.ts +3 -1
  127. package/node_modules/auth-store/package.json +2 -1
  128. package/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +46 -15
  129. package/node_modules/mcp-oauth/dist/client/loopback-authorization.js +49 -12
  130. package/node_modules/mcp-oauth/dist/client/token-endpoint.js +6 -1
  131. package/node_modules/mcp-oauth/dist/server/jwks-token-verifier.js +1 -1
  132. package/node_modules/mcp-oauth/package.json +1 -0
  133. package/node_modules/tiny-mcp-client/.turbo/turbo-build.log +1 -1
  134. package/node_modules/tiny-mcp-client/dist/internal.d.ts +9 -4
  135. package/node_modules/tiny-mcp-client/dist/internal.js +244 -66
  136. package/node_modules/tiny-mcp-client/dist/oauth-discovery.d.ts +1 -1
  137. package/node_modules/tiny-mcp-client/dist/oauth-discovery.js +4 -7
  138. package/node_modules/tiny-mcp-client/package.json +2 -1
  139. package/node_modules/tiny-mcp-client/src/http-oauth.integration.test.ts +1 -1
  140. package/node_modules/tiny-mcp-client/src/http-oauth.test.ts +46 -0
  141. package/node_modules/tiny-mcp-client/src/internal.ts +287 -76
  142. package/node_modules/tiny-mcp-client/src/mcp-client-sdk.test.ts +32 -0
  143. package/node_modules/tiny-mcp-client/src/mcp-client-tiny-stdio-test-server-tools.test.ts +1 -1
  144. package/node_modules/tiny-mcp-client/src/oauth-discovery.ts +5 -10
  145. package/node_modules/tiny-mcp-client/src/transports.test.ts +588 -6
  146. package/package.json +10 -12
  147. package/node_modules/@poe-code/file-lock/README.md +0 -52
  148. package/node_modules/@poe-code/file-lock/dist/index.d.ts +0 -1
  149. package/node_modules/@poe-code/file-lock/dist/index.js +0 -1
  150. package/node_modules/@poe-code/file-lock/dist/lock.d.ts +0 -27
  151. package/node_modules/@poe-code/file-lock/dist/lock.js +0 -203
  152. package/node_modules/@poe-code/file-lock/package.json +0 -23
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { renderTemplate } from "@poe-code/design-system";
2
3
  import { getConfigFormat, detectFormat } from "../formats/index.js";
3
4
  import { resolvePath } from "./path-utils.js";
@@ -15,9 +16,78 @@ function createInvalidDocumentBackupPath(targetPath) {
15
16
  const ext = targetPath.includes(".") ? targetPath.split(".").pop() : "bak";
16
17
  return `${targetPath}.invalid-${createTimestamp()}.${ext}`;
17
18
  }
18
- async function backupInvalidDocument(fs, targetPath, content) {
19
- const backupPath = createInvalidDocumentBackupPath(targetPath);
20
- await fs.writeFile(backupPath, content, { encoding: "utf8" });
19
+ async function backupInvalidDocument(context, targetPath, content) {
20
+ const baseBackupPath = createInvalidDocumentBackupPath(targetPath);
21
+ let attempt = 0;
22
+ while (true) {
23
+ const backupPath = attempt === 0 ? baseBackupPath : `${baseBackupPath}-${attempt}`;
24
+ await assertRegularWriteTarget(context, backupPath);
25
+ try {
26
+ await context.fs.writeFile(backupPath, content, { encoding: "utf8", flag: "wx" });
27
+ return;
28
+ }
29
+ catch (error) {
30
+ if (!isAlreadyExists(error)) {
31
+ throw error;
32
+ }
33
+ attempt += 1;
34
+ }
35
+ }
36
+ }
37
+ function isAlreadyExists(error) {
38
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
39
+ }
40
+ async function assertRegularWriteTarget(context, targetPath) {
41
+ // Symlinks inside the managed home directory are untrusted: an attacker could
42
+ // plant one to redirect a credential/config write outside it. Symlinks at or
43
+ // above home are legitimate system links (e.g. /tmp -> /private/tmp on macOS,
44
+ // /var -> /private/var) and must not block writes, so bound the walk at home.
45
+ const boundary = path.dirname(path.resolve(context.homeDir));
46
+ let currentPath = path.resolve(targetPath);
47
+ while (currentPath !== boundary) {
48
+ try {
49
+ if ((await context.fs.lstat(currentPath)).isSymbolicLink()) {
50
+ throw new Error(`Refusing mutation write through symbolic link: ${currentPath}`);
51
+ }
52
+ }
53
+ catch (error) {
54
+ if (!isNotFound(error)) {
55
+ throw error;
56
+ }
57
+ }
58
+ const parentPath = path.dirname(currentPath);
59
+ if (parentPath === currentPath) {
60
+ return;
61
+ }
62
+ currentPath = parentPath;
63
+ }
64
+ }
65
+ async function writeAtomically(context, targetPath, content) {
66
+ await assertRegularWriteTarget(context, targetPath);
67
+ let attempt = 0;
68
+ while (true) {
69
+ const tempPath = `${targetPath}.mutation-tmp-${attempt}`;
70
+ try {
71
+ await context.fs.writeFile(tempPath, content, { encoding: "utf8", flag: "wx" });
72
+ await context.fs.rename(tempPath, targetPath);
73
+ return;
74
+ }
75
+ catch (error) {
76
+ if (isAlreadyExists(error)) {
77
+ attempt += 1;
78
+ continue;
79
+ }
80
+ try {
81
+ await context.fs.unlink(tempPath);
82
+ }
83
+ catch (cleanupError) {
84
+ if (!isNotFound(cleanupError)) {
85
+ void cleanupError;
86
+ }
87
+ }
88
+ throw error;
89
+ }
90
+ }
21
91
  }
22
92
  function describeMutation(kind, targetPath) {
23
93
  const displayPath = targetPath ?? "target";
@@ -28,6 +98,8 @@ function describeMutation(kind, targetPath) {
28
98
  return `Remove directory ${displayPath}`;
29
99
  case "backup":
30
100
  return `Backup ${displayPath}`;
101
+ case "restoreBackup":
102
+ return `Restore ${displayPath}`;
31
103
  case "templateWrite":
32
104
  return `Write ${displayPath}`;
33
105
  case "chmod":
@@ -91,6 +163,8 @@ export async function applyMutation(mutation, context, options) {
91
163
  return applyChmod(mutation, context, options);
92
164
  case "backup":
93
165
  return applyBackup(mutation, context, options);
166
+ case "restoreBackup":
167
+ return applyRestoreBackup(mutation, context, options);
94
168
  case "configMerge":
95
169
  return applyConfigMerge(mutation, context, options);
96
170
  case "configPrune":
@@ -190,6 +264,9 @@ async function applyRemoveFile(mutation, context, options) {
190
264
  const content = await context.fs.readFile(targetPath, "utf8");
191
265
  const trimmed = content.trim();
192
266
  // Check whenContentMatches guard
267
+ if (mutation.whenContentMatches) {
268
+ mutation.whenContentMatches.lastIndex = 0;
269
+ }
193
270
  if (mutation.whenContentMatches && !mutation.whenContentMatches.test(trimmed)) {
194
271
  return {
195
272
  outcome: { changed: false, effect: "none", detail: "noop" },
@@ -270,22 +347,122 @@ async function applyBackup(mutation, context, options) {
270
347
  label: mutation.label ?? describeMutation(mutation.kind, targetPath),
271
348
  targetPath
272
349
  };
350
+ if (mutation.once && (await findLatestGeneratedBackup(context.fs, targetPath)) !== null) {
351
+ return {
352
+ outcome: { changed: false, effect: "none", detail: "noop" },
353
+ details
354
+ };
355
+ }
273
356
  const content = await readFileIfExists(context.fs, targetPath);
274
- if (content === null) {
357
+ if (content === null && !mutation.once) {
275
358
  return {
276
359
  outcome: { changed: false, effect: "none", detail: "noop" },
277
360
  details
278
361
  };
279
362
  }
280
363
  if (!context.dryRun) {
281
- const backupPath = `${targetPath}.backup-${createTimestamp()}`;
282
- await context.fs.writeFile(backupPath, content, { encoding: "utf8" });
364
+ const baseBackupPath = `${targetPath}.backup-${createTimestamp()}${content === null ? ".missing" : ""}`;
365
+ let attempt = 0;
366
+ while (true) {
367
+ const backupPath = attempt === 0 ? baseBackupPath : `${baseBackupPath}-${attempt}`;
368
+ try {
369
+ await assertRegularWriteTarget(context, backupPath);
370
+ await context.fs.writeFile(backupPath, content ?? "", { encoding: "utf8", flag: "wx" });
371
+ break;
372
+ }
373
+ catch (error) {
374
+ if (!isAlreadyExists(error)) {
375
+ throw error;
376
+ }
377
+ attempt += 1;
378
+ }
379
+ }
283
380
  }
284
381
  return {
285
382
  outcome: { changed: true, effect: "copy", detail: "backup" },
286
383
  details
287
384
  };
288
385
  }
386
+ async function applyRestoreBackup(mutation, context, options) {
387
+ const rawPath = resolveValue(mutation.target, options);
388
+ const targetPath = resolvePath(rawPath, context.homeDir, context.pathMapper);
389
+ const details = {
390
+ kind: mutation.kind,
391
+ label: mutation.label ?? describeMutation(mutation.kind, targetPath),
392
+ targetPath
393
+ };
394
+ const backup = await findLatestGeneratedBackup(context.fs, targetPath);
395
+ if (backup === null) {
396
+ return { outcome: { changed: false, effect: "none", detail: "noop" }, details };
397
+ }
398
+ if (!context.dryRun) {
399
+ await assertRegularWriteTarget(context, backup.path);
400
+ if (backup.originallyMissing) {
401
+ try {
402
+ await context.fs.unlink(targetPath);
403
+ }
404
+ catch (error) {
405
+ if (!isNotFound(error)) {
406
+ throw error;
407
+ }
408
+ }
409
+ }
410
+ else {
411
+ const content = await context.fs.readFile(backup.path, "utf8");
412
+ await writeAtomically(context, targetPath, content);
413
+ }
414
+ await context.fs.unlink(backup.path);
415
+ }
416
+ return { outcome: { changed: true, effect: "copy", detail: "restore" }, details };
417
+ }
418
+ async function findLatestGeneratedBackup(fs, targetPath) {
419
+ const separatorIndex = targetPath.lastIndexOf("/");
420
+ const directoryPath = separatorIndex <= 0 ? "/" : targetPath.slice(0, separatorIndex);
421
+ const targetName = targetPath.slice(separatorIndex + 1);
422
+ let entries;
423
+ try {
424
+ entries = await fs.readdir(directoryPath);
425
+ }
426
+ catch (error) {
427
+ if (isNotFound(error)) {
428
+ return null;
429
+ }
430
+ throw error;
431
+ }
432
+ const backupName = entries
433
+ .filter((entry) => isGeneratedBackupName(entry, targetName))
434
+ .sort()
435
+ .at(-1);
436
+ return backupName === undefined
437
+ ? null
438
+ : {
439
+ path: `${directoryPath}/${backupName}`,
440
+ originallyMissing: backupName.endsWith(".missing")
441
+ };
442
+ }
443
+ function isGeneratedBackupName(entry, targetName) {
444
+ const prefix = `${targetName}.backup-`;
445
+ if (!entry.startsWith(prefix)) {
446
+ return false;
447
+ }
448
+ const suffix = entry.slice(prefix.length);
449
+ const timestamp = suffix.slice(0, 24);
450
+ if (timestamp.length !== 24 || timestamp[4] !== "-" || timestamp[7] !== "-" || timestamp[10] !== "T" || timestamp[13] !== "-" || timestamp[16] !== "-" || timestamp[19] !== "-" || timestamp[23] !== "Z") {
451
+ return false;
452
+ }
453
+ const parsedTimestamp = `${timestamp.slice(0, 13)}:${timestamp.slice(14, 16)}:${timestamp.slice(17, 19)}.${timestamp.slice(20)}`;
454
+ if (Number.isNaN(Date.parse(parsedTimestamp))) {
455
+ return false;
456
+ }
457
+ const collisionSuffix = suffix.slice(24);
458
+ if (collisionSuffix.length === 0) {
459
+ return true;
460
+ }
461
+ if (collisionSuffix === ".missing") {
462
+ return true;
463
+ }
464
+ return collisionSuffix[0] === "-" && collisionSuffix.slice(1).length > 0 && [...collisionSuffix.slice(1)].every((character) => character >= "0" && character <= "9");
465
+ }
289
466
  // ============================================================================
290
467
  // Config Mutation Handlers
291
468
  // ============================================================================
@@ -309,8 +486,8 @@ async function applyConfigMerge(mutation, context, options) {
309
486
  }
310
487
  catch {
311
488
  // Invalid file - backup and start fresh
312
- if (rawContent !== null) {
313
- await backupInvalidDocument(context.fs, targetPath, rawContent);
489
+ if (rawContent !== null && !context.dryRun) {
490
+ await backupInvalidDocument(context, targetPath, rawContent);
314
491
  }
315
492
  current = {};
316
493
  }
@@ -326,7 +503,7 @@ async function applyConfigMerge(mutation, context, options) {
326
503
  const serialized = format.serialize(merged);
327
504
  const changed = serialized !== rawContent;
328
505
  if (changed && !context.dryRun) {
329
- await context.fs.writeFile(targetPath, serialized, { encoding: "utf8" });
506
+ await writeAtomically(context, targetPath, serialized);
330
507
  }
331
508
  return {
332
509
  outcome: {
@@ -395,7 +572,7 @@ async function applyConfigPrune(mutation, context, options) {
395
572
  }
396
573
  const serialized = format.serialize(result);
397
574
  if (!context.dryRun) {
398
- await context.fs.writeFile(targetPath, serialized, { encoding: "utf8" });
575
+ await writeAtomically(context, targetPath, serialized);
399
576
  }
400
577
  return {
401
578
  outcome: { changed: true, effect: "write", detail: "update" },
@@ -421,8 +598,8 @@ async function applyConfigTransform(mutation, context, options) {
421
598
  current = rawContent === null ? {} : format.parse(rawContent);
422
599
  }
423
600
  catch {
424
- if (rawContent !== null) {
425
- await backupInvalidDocument(context.fs, targetPath, rawContent);
601
+ if (rawContent !== null && !context.dryRun) {
602
+ await backupInvalidDocument(context, targetPath, rawContent);
426
603
  }
427
604
  current = {};
428
605
  }
@@ -451,7 +628,7 @@ async function applyConfigTransform(mutation, context, options) {
451
628
  }
452
629
  const serialized = format.serialize(transformed);
453
630
  if (!context.dryRun) {
454
- await context.fs.writeFile(targetPath, serialized, { encoding: "utf8" });
631
+ await writeAtomically(context, targetPath, serialized);
455
632
  }
456
633
  return {
457
634
  outcome: {
@@ -482,15 +659,16 @@ async function applyTemplateWrite(mutation, context, options) {
482
659
  ? resolveValue(mutation.context, options)
483
660
  : {};
484
661
  const rendered = renderTemplate(template, templateContext);
485
- const existed = await pathExists(context.fs, targetPath);
486
- if (!context.dryRun) {
487
- await context.fs.writeFile(targetPath, rendered, { encoding: "utf8" });
662
+ const current = await readFileIfExists(context.fs, targetPath);
663
+ const changed = current !== rendered;
664
+ if (changed && !context.dryRun) {
665
+ await writeAtomically(context, targetPath, rendered);
488
666
  }
489
667
  return {
490
668
  outcome: {
491
- changed: true,
492
- effect: "write",
493
- detail: existed ? "update" : "create"
669
+ changed,
670
+ effect: changed ? "write" : "none",
671
+ detail: changed ? (current === null ? "create" : "update") : "noop"
494
672
  },
495
673
  details
496
674
  };
@@ -529,8 +707,8 @@ async function applyTemplateMerge(mutation, context, options, formatName) {
529
707
  current = rawContent === null ? {} : format.parse(rawContent);
530
708
  }
531
709
  catch {
532
- if (rawContent !== null) {
533
- await backupInvalidDocument(context.fs, targetPath, rawContent);
710
+ if (rawContent !== null && !context.dryRun) {
711
+ await backupInvalidDocument(context, targetPath, rawContent);
534
712
  }
535
713
  current = {};
536
714
  }
@@ -539,7 +717,7 @@ async function applyTemplateMerge(mutation, context, options, formatName) {
539
717
  const serialized = format.serialize(merged);
540
718
  const changed = serialized !== rawContent;
541
719
  if (changed && !context.dryRun) {
542
- await context.fs.writeFile(targetPath, serialized, { encoding: "utf8" });
720
+ await writeAtomically(context, targetPath, serialized);
543
721
  }
544
722
  return {
545
723
  outcome: {
@@ -45,8 +45,14 @@ export function validateHomePath(targetPath) {
45
45
  export function resolvePath(rawPath, homeDir, pathMapper) {
46
46
  validateHomePath(rawPath);
47
47
  const expanded = expandHome(rawPath, homeDir);
48
+ const canonicalHome = path.resolve(homeDir);
49
+ const canonicalExpanded = path.resolve(expanded);
50
+ const relative = path.relative(canonicalHome, canonicalExpanded);
51
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
52
+ throw new Error(`Target path resolves outside home directory: "${rawPath}"`);
53
+ }
48
54
  if (!pathMapper) {
49
- return expanded;
55
+ return canonicalExpanded;
50
56
  }
51
57
  // Map the directory portion
52
58
  const rawDirectory = path.dirname(expanded);
@@ -17,7 +17,7 @@ const extensionMap = {
17
17
  */
18
18
  export function getConfigFormat(pathOrFormat) {
19
19
  // Check if it's an explicit format name
20
- if (pathOrFormat in formatRegistry) {
20
+ if (Object.prototype.hasOwnProperty.call(formatRegistry, pathOrFormat)) {
21
21
  return formatRegistry[pathOrFormat];
22
22
  }
23
23
  // Try to detect from extension
@@ -1,4 +1,5 @@
1
1
  import * as jsonc from "jsonc-parser";
2
+ import { cloneConfigObject, hasConfigEntry, setConfigEntry } from "./object.js";
2
3
  function isConfigObject(value) {
3
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
5
  }
@@ -27,31 +28,31 @@ function parse(content) {
27
28
  if (!isConfigObject(parsed)) {
28
29
  throw new Error("Expected JSON object.");
29
30
  }
30
- return parsed;
31
+ return cloneConfigObject(parsed);
31
32
  }
32
33
  function serialize(obj) {
33
34
  return `${JSON.stringify(obj, null, 2)}\n`;
34
35
  }
35
36
  function merge(base, patch) {
36
- const result = { ...base };
37
+ const result = cloneConfigObject(base);
37
38
  for (const [key, value] of Object.entries(patch)) {
38
39
  if (value === undefined) {
39
40
  continue;
40
41
  }
41
42
  const existing = result[key];
42
43
  if (isConfigObject(existing) && isConfigObject(value)) {
43
- result[key] = merge(existing, value);
44
+ setConfigEntry(result, key, merge(existing, value));
44
45
  continue;
45
46
  }
46
- result[key] = value;
47
+ setConfigEntry(result, key, value);
47
48
  }
48
49
  return result;
49
50
  }
50
51
  function prune(obj, shape) {
51
52
  let changed = false;
52
- const result = { ...obj };
53
+ const result = cloneConfigObject(obj);
53
54
  for (const [key, pattern] of Object.entries(shape)) {
54
- if (!(key in result)) {
55
+ if (!hasConfigEntry(result, key)) {
55
56
  continue;
56
57
  }
57
58
  const current = result[key];
@@ -71,10 +72,13 @@ function prune(obj, shape) {
71
72
  delete result[key];
72
73
  }
73
74
  else {
74
- result[key] = childResult;
75
+ setConfigEntry(result, key, childResult);
75
76
  }
76
77
  continue;
77
78
  }
79
+ if (isConfigObject(pattern) && Object.keys(pattern).length > 0) {
80
+ continue;
81
+ }
78
82
  delete result[key];
79
83
  changed = true;
80
84
  }
@@ -0,0 +1,4 @@
1
+ import type { ConfigObject, ConfigValue } from "../types.js";
2
+ export declare function cloneConfigObject(value: ConfigObject): ConfigObject;
3
+ export declare function setConfigEntry(target: ConfigObject, key: string, value: ConfigValue): void;
4
+ export declare function hasConfigEntry(target: ConfigObject, key: string): boolean;
@@ -0,0 +1,27 @@
1
+ export function cloneConfigObject(value) {
2
+ const result = {};
3
+ for (const [key, entry] of Object.entries(value)) {
4
+ setConfigEntry(result, key, cloneConfigValue(entry));
5
+ }
6
+ return result;
7
+ }
8
+ export function setConfigEntry(target, key, value) {
9
+ Object.defineProperty(target, key, {
10
+ configurable: true,
11
+ enumerable: true,
12
+ writable: true,
13
+ value
14
+ });
15
+ }
16
+ export function hasConfigEntry(target, key) {
17
+ return Object.prototype.hasOwnProperty.call(target, key);
18
+ }
19
+ function cloneConfigValue(value) {
20
+ if (Array.isArray(value)) {
21
+ return value.map((entry) => cloneConfigValue(entry));
22
+ }
23
+ if (value && typeof value === "object" && !(value instanceof Date)) {
24
+ return cloneConfigObject(value);
25
+ }
26
+ return value;
27
+ }
@@ -1,4 +1,5 @@
1
1
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
2
+ import { cloneConfigObject, hasConfigEntry, setConfigEntry } from "./object.js";
2
3
  function isConfigObject(value) {
3
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
5
  }
@@ -10,32 +11,32 @@ function parse(content) {
10
11
  if (!isConfigObject(parsed)) {
11
12
  throw new Error("Expected TOML document to be a table.");
12
13
  }
13
- return parsed;
14
+ return cloneConfigObject(parsed);
14
15
  }
15
16
  function serialize(obj) {
16
17
  const serialized = stringifyToml(obj);
17
18
  return serialized.endsWith("\n") ? serialized : `${serialized}\n`;
18
19
  }
19
20
  function merge(base, patch) {
20
- const result = { ...base };
21
+ const result = cloneConfigObject(base);
21
22
  for (const [key, value] of Object.entries(patch)) {
22
23
  if (value === undefined) {
23
24
  continue;
24
25
  }
25
26
  const existing = result[key];
26
27
  if (isConfigObject(existing) && isConfigObject(value)) {
27
- result[key] = merge(existing, value);
28
+ setConfigEntry(result, key, merge(existing, value));
28
29
  continue;
29
30
  }
30
- result[key] = value;
31
+ setConfigEntry(result, key, value);
31
32
  }
32
33
  return result;
33
34
  }
34
35
  function prune(obj, shape) {
35
36
  let changed = false;
36
- const result = { ...obj };
37
+ const result = cloneConfigObject(obj);
37
38
  for (const [key, pattern] of Object.entries(shape)) {
38
- if (!(key in result)) {
39
+ if (!hasConfigEntry(result, key)) {
39
40
  continue;
40
41
  }
41
42
  const current = result[key];
@@ -55,12 +56,14 @@ function prune(obj, shape) {
55
56
  delete result[key];
56
57
  }
57
58
  else {
58
- result[key] = childResult;
59
+ setConfigEntry(result, key, childResult);
59
60
  }
60
61
  continue;
61
62
  }
62
- delete result[key];
63
- changed = true;
63
+ if (!isConfigObject(pattern) || Object.keys(pattern).length === 0) {
64
+ delete result[key];
65
+ changed = true;
66
+ }
64
67
  }
65
68
  return { changed, result };
66
69
  }
@@ -1,4 +1,5 @@
1
1
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
2
+ import { cloneConfigObject, hasConfigEntry, setConfigEntry } from "./object.js";
2
3
  function isConfigObject(value) {
3
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
5
  }
@@ -13,32 +14,32 @@ function parse(content) {
13
14
  if (!isConfigObject(parsed)) {
14
15
  throw new Error("Expected YAML object.");
15
16
  }
16
- return parsed;
17
+ return cloneConfigObject(parsed);
17
18
  }
18
19
  function serialize(obj) {
19
20
  const serialized = stringifyYaml(obj);
20
21
  return serialized.endsWith("\n") ? serialized : `${serialized}\n`;
21
22
  }
22
23
  function merge(base, patch) {
23
- const result = { ...base };
24
+ const result = cloneConfigObject(base);
24
25
  for (const [key, value] of Object.entries(patch)) {
25
26
  if (value === undefined) {
26
27
  continue;
27
28
  }
28
29
  const existing = result[key];
29
30
  if (isConfigObject(existing) && isConfigObject(value)) {
30
- result[key] = merge(existing, value);
31
+ setConfigEntry(result, key, merge(existing, value));
31
32
  continue;
32
33
  }
33
- result[key] = value;
34
+ setConfigEntry(result, key, value);
34
35
  }
35
36
  return result;
36
37
  }
37
38
  function prune(obj, shape) {
38
39
  let changed = false;
39
- const result = { ...obj };
40
+ const result = cloneConfigObject(obj);
40
41
  for (const [key, pattern] of Object.entries(shape)) {
41
- if (!(key in result)) {
42
+ if (!hasConfigEntry(result, key)) {
42
43
  continue;
43
44
  }
44
45
  const current = result[key];
@@ -56,12 +57,14 @@ function prune(obj, shape) {
56
57
  delete result[key];
57
58
  }
58
59
  else {
59
- result[key] = childResult;
60
+ setConfigEntry(result, key, childResult);
60
61
  }
61
62
  continue;
62
63
  }
63
- delete result[key];
64
- changed = true;
64
+ if (!isConfigObject(pattern) || Object.keys(pattern).length === 0) {
65
+ delete result[key];
66
+ changed = true;
67
+ }
65
68
  }
66
69
  return { changed, result };
67
70
  }
@@ -1,4 +1,4 @@
1
- import type { EnsureDirectoryMutation, RemoveDirectoryMutation, RemoveFileMutation, ChmodMutation, BackupMutation, ValueResolver } from "../types.js";
1
+ import type { EnsureDirectoryMutation, RemoveDirectoryMutation, RemoveFileMutation, ChmodMutation, BackupMutation, RestoreBackupMutation, ValueResolver } from "../types.js";
2
2
  export interface EnsureDirectoryOptions {
3
3
  /** Directory path (must start with ~) */
4
4
  path: ValueResolver<string>;
@@ -34,6 +34,14 @@ export interface ChmodOptions {
34
34
  export interface BackupOptions {
35
35
  /** Target file path to backup (must start with ~) */
36
36
  target: ValueResolver<string>;
37
+ /** Keep the first baseline only, including an originally missing target. */
38
+ once?: boolean;
39
+ /** Optional human-readable label for logging */
40
+ label?: string;
41
+ }
42
+ export interface RestoreBackupOptions {
43
+ /** Target file path whose latest generated backup should be restored. */
44
+ target: ValueResolver<string>;
37
45
  /** Optional human-readable label for logging */
38
46
  label?: string;
39
47
  }
@@ -42,11 +50,13 @@ declare function remove(options: RemoveOptions): RemoveFileMutation;
42
50
  declare function removeDirectory(options: RemoveDirectoryOptions): RemoveDirectoryMutation;
43
51
  declare function chmod(options: ChmodOptions): ChmodMutation;
44
52
  declare function backup(options: BackupOptions): BackupMutation;
53
+ declare function restoreBackup(options: RestoreBackupOptions): RestoreBackupMutation;
45
54
  export declare const fileMutation: {
46
55
  ensureDirectory: typeof ensureDirectory;
47
56
  remove: typeof remove;
48
57
  removeDirectory: typeof removeDirectory;
49
58
  chmod: typeof chmod;
50
59
  backup: typeof backup;
60
+ restoreBackup: typeof restoreBackup;
51
61
  };
52
62
  export {};
@@ -34,6 +34,14 @@ function backup(options) {
34
34
  return {
35
35
  kind: "backup",
36
36
  target: options.target,
37
+ once: options.once,
38
+ label: options.label
39
+ };
40
+ }
41
+ function restoreBackup(options) {
42
+ return {
43
+ kind: "restoreBackup",
44
+ target: options.target,
37
45
  label: options.label
38
46
  };
39
47
  }
@@ -42,5 +50,6 @@ export const fileMutation = {
42
50
  remove,
43
51
  removeDirectory,
44
52
  chmod,
45
- backup
53
+ backup,
54
+ restoreBackup
46
55
  };
@@ -46,8 +46,12 @@ export function createMockFs(initialFiles, homeDir = DEFAULT_HOME_DIR) {
46
46
  return Buffer.from(content, "utf8");
47
47
  },
48
48
  async writeFile(filePath, content, options) {
49
- void options; // TypeScript satisfaction
50
49
  const absolutePath = expandPath(filePath, homeDir);
50
+ if (options?.flag === "wx" && absolutePath in files) {
51
+ const error = new Error(`EEXIST: file already exists, open '${absolutePath}'`);
52
+ error.code = "EEXIST";
53
+ throw error;
54
+ }
51
55
  // Ensure parent directory exists
52
56
  const parentDir = path.dirname(absolutePath);
53
57
  if (!directories.has(parentDir)) {
@@ -90,6 +94,17 @@ export function createMockFs(initialFiles, homeDir = DEFAULT_HOME_DIR) {
90
94
  }
91
95
  delete files[absolutePath];
92
96
  },
97
+ async rename(oldPath, newPath) {
98
+ const absoluteOldPath = expandPath(oldPath, homeDir);
99
+ const absoluteNewPath = expandPath(newPath, homeDir);
100
+ if (!(absoluteOldPath in files)) {
101
+ const error = new Error(`ENOENT: no such file or directory, rename '${absoluteOldPath}'`);
102
+ error.code = "ENOENT";
103
+ throw error;
104
+ }
105
+ files[absoluteNewPath] = files[absoluteOldPath];
106
+ delete files[absoluteOldPath];
107
+ },
93
108
  async stat(filePath) {
94
109
  const absolutePath = expandPath(filePath, homeDir);
95
110
  if (absolutePath in files) {
@@ -102,6 +117,15 @@ export function createMockFs(initialFiles, homeDir = DEFAULT_HOME_DIR) {
102
117
  error.code = "ENOENT";
103
118
  throw error;
104
119
  },
120
+ async lstat(filePath) {
121
+ const absolutePath = expandPath(filePath, homeDir);
122
+ if (absolutePath in files || directories.has(absolutePath)) {
123
+ return { isSymbolicLink: () => false };
124
+ }
125
+ const error = new Error(`ENOENT: no such file or directory, lstat '${absolutePath}'`);
126
+ error.code = "ENOENT";
127
+ throw error;
128
+ },
105
129
  async readdir(dirPath) {
106
130
  const absolutePath = expandPath(dirPath, homeDir);
107
131
  if (absolutePath in files) {