abelworkflow 1.1.3 → 1.2.1

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -29
  3. package/extensions/gpt-responses-compat.ts +213 -0
  4. package/extensions/k2think-compat.ts +40 -0
  5. package/lib/cli/args.mjs +5 -19
  6. package/lib/cli/main.mjs +105 -72
  7. package/lib/cli/prompts.mjs +9 -2
  8. package/lib/config/dotenv.mjs +1 -1
  9. package/lib/config/jsonc.mjs +1 -1
  10. package/lib/config/store.mjs +197 -45
  11. package/lib/config/toml.mjs +147 -453
  12. package/lib/installer/assets.mjs +67 -45
  13. package/lib/installer/install.mjs +202 -45
  14. package/lib/installer/links.mjs +124 -101
  15. package/lib/installer/lock.mjs +82 -0
  16. package/lib/installer/state.mjs +14 -21
  17. package/lib/paths.mjs +9 -9
  18. package/lib/providers/claude.mjs +58 -114
  19. package/lib/providers/codex.mjs +101 -198
  20. package/lib/providers/pi.mjs +331 -496
  21. package/lib/providers/skills.mjs +88 -68
  22. package/lib/providers/url.mjs +35 -0
  23. package/lib/templates/workflow/commands/abel-design.md +23 -161
  24. package/lib/templates/workflow/commands/abel-diagnose.md +30 -56
  25. package/lib/templates/workflow/commands/abel-implement.md +30 -150
  26. package/lib/templates/workflow/commands/abel-init.md +1 -1
  27. package/lib/tools/cli-installer.mjs +29 -190
  28. package/package.json +7 -9
  29. package/skills/context7-auto-research/context7-api.cjs +75 -17
  30. package/skills/dev-browser/SKILL.md +15 -1
  31. package/skills/dev-browser/dist/scripts/start.d.ts +0 -1
  32. package/skills/dev-browser/dist/scripts/start.js +8 -36
  33. package/skills/dev-browser/dist/src/client.d.ts +3 -4
  34. package/skills/dev-browser/dist/src/client.js +117 -117
  35. package/skills/dev-browser/dist/src/entrypoint.d.ts +1 -1
  36. package/skills/dev-browser/dist/src/entrypoint.js +38 -31
  37. package/skills/dev-browser/dist/src/index.d.ts +0 -1
  38. package/skills/dev-browser/dist/src/index.js +0 -1
  39. package/skills/dev-browser/dist/src/page-api.d.ts +0 -1
  40. package/skills/dev-browser/dist/src/page-api.js +0 -1
  41. package/skills/dev-browser/dist/src/relay.d.ts +0 -1
  42. package/skills/dev-browser/dist/src/relay.js +53 -7
  43. package/skills/dev-browser/dist/src/runtime.d.ts +3 -14
  44. package/skills/dev-browser/dist/src/runtime.js +5 -15
  45. package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts +0 -1
  46. package/skills/dev-browser/dist/src/snapshot/browser-script.js +0 -1
  47. package/skills/dev-browser/dist/src/snapshot/index.d.ts +0 -1
  48. package/skills/dev-browser/dist/src/snapshot/index.js +0 -1
  49. package/skills/dev-browser/dist/src/snapshot/inject.d.ts +0 -1
  50. package/skills/dev-browser/dist/src/snapshot/inject.js +0 -1
  51. package/skills/dev-browser/dist/src/standalone.d.ts +0 -1
  52. package/skills/dev-browser/dist/src/standalone.js +2 -3
  53. package/skills/dev-browser/dist/src/startup.d.ts +3 -12
  54. package/skills/dev-browser/dist/src/startup.js +6 -10
  55. package/skills/dev-browser/dist/src/target-registry.d.ts +0 -1
  56. package/skills/dev-browser/dist/src/target-registry.js +0 -1
  57. package/skills/dev-browser/dist/src/types.d.ts +0 -1
  58. package/skills/dev-browser/dist/src/types.js +0 -1
  59. package/skills/dev-browser/package-lock.json +1 -1
  60. package/skills/dev-browser/package.json +2 -2
  61. package/skills/grok-search/.env.example +1 -1
  62. package/skills/grok-search/SKILL.md +13 -16
  63. package/skills/grok-search/scripts/_dotenv.py +1 -1
  64. package/skills/grok-search/scripts/groksearch_cli.py +271 -195
  65. package/skills/time/SKILL.md +5 -2
  66. package/skills/time/scripts/time_cli.py +61 -22
  67. package/extensions/pi-gpt-responses-compat/index.ts +0 -25
  68. package/lib/templates/codex/config-base.toml +0 -103
  69. package/skills/dev-browser/dist/scripts/start.d.ts.map +0 -1
  70. package/skills/dev-browser/dist/scripts/start.js.map +0 -1
  71. package/skills/dev-browser/dist/src/client.d.ts.map +0 -1
  72. package/skills/dev-browser/dist/src/client.js.map +0 -1
  73. package/skills/dev-browser/dist/src/entrypoint.d.ts.map +0 -1
  74. package/skills/dev-browser/dist/src/entrypoint.js.map +0 -1
  75. package/skills/dev-browser/dist/src/index.d.ts.map +0 -1
  76. package/skills/dev-browser/dist/src/index.js.map +0 -1
  77. package/skills/dev-browser/dist/src/page-api.d.ts.map +0 -1
  78. package/skills/dev-browser/dist/src/page-api.js.map +0 -1
  79. package/skills/dev-browser/dist/src/relay.d.ts.map +0 -1
  80. package/skills/dev-browser/dist/src/relay.js.map +0 -1
  81. package/skills/dev-browser/dist/src/runtime.d.ts.map +0 -1
  82. package/skills/dev-browser/dist/src/runtime.js.map +0 -1
  83. package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts.map +0 -1
  84. package/skills/dev-browser/dist/src/snapshot/browser-script.js.map +0 -1
  85. package/skills/dev-browser/dist/src/snapshot/index.d.ts.map +0 -1
  86. package/skills/dev-browser/dist/src/snapshot/index.js.map +0 -1
  87. package/skills/dev-browser/dist/src/snapshot/inject.d.ts.map +0 -1
  88. package/skills/dev-browser/dist/src/snapshot/inject.js.map +0 -1
  89. package/skills/dev-browser/dist/src/standalone.d.ts.map +0 -1
  90. package/skills/dev-browser/dist/src/standalone.js.map +0 -1
  91. package/skills/dev-browser/dist/src/startup.d.ts.map +0 -1
  92. package/skills/dev-browser/dist/src/startup.js.map +0 -1
  93. package/skills/dev-browser/dist/src/target-registry.d.ts.map +0 -1
  94. package/skills/dev-browser/dist/src/target-registry.js.map +0 -1
  95. package/skills/dev-browser/dist/src/types.d.ts.map +0 -1
  96. package/skills/dev-browser/dist/src/types.js.map +0 -1
  97. package/skills/grok-search/gitignore.template +0 -4
  98. package/skills/grok-search/scripts/groksearch_entry.py +0 -131
package/lib/cli/main.mjs CHANGED
@@ -5,15 +5,15 @@ import c from "picocolors";
5
5
  import { assertInteractiveMenuSupported, parseArgs } from "./args.mjs";
6
6
  import * as promptApi from "./prompts.mjs";
7
7
  import {
8
- ensurePiResourcesLinked,
8
+ ensurePiExtensionsPresent,
9
9
  ensureSkillPresent,
10
- installManagedWorkflow,
10
+ installWorkflow,
11
11
  packageVersionFor
12
12
  } from "../installer/install.mjs";
13
+ import { withDeploymentLock } from "../installer/lock.mjs";
13
14
  import {
14
15
  finalizeProviderInstallMetadata,
15
16
  getPreviousManagedCodexAgentFiles,
16
- getPreviousManagedCodexAuthKeys,
17
17
  readInstallMetadata,
18
18
  writeInstallMetadata
19
19
  } from "../installer/state.mjs";
@@ -89,22 +89,33 @@ function presentInstallReport(report, reportOutput) {
89
89
  }
90
90
  }
91
91
 
92
+ function assertNoInstallConflicts(report) {
93
+ if (uniqueReportPaths(report.conflicts).length > 0) {
94
+ throw new Error("工作流安装存在冲突");
95
+ }
96
+ }
97
+
92
98
  async function runManagedInstall(options) {
93
- const report = await installManagedWorkflow(options);
99
+ const report = await installWorkflow(options);
94
100
  presentInstallReport(report, p.log);
101
+ assertNoInstallConflicts(report);
95
102
  return report;
96
103
  }
97
104
 
98
- async function ensureSkillPresentWithReport(paths, skillName) {
99
- const report = await ensureSkillPresent(paths, skillName);
100
- presentInstallReport(report, p.log);
101
- return report;
105
+ async function ensureSkillPresentWithReport(paths, skillName, continuation) {
106
+ return ensureSkillPresent(paths, skillName, async (report, guard) => {
107
+ presentInstallReport(report, p.log);
108
+ assertNoInstallConflicts(report);
109
+ return continuation ? continuation(guard) : report;
110
+ });
102
111
  }
103
112
 
104
- async function ensurePiResourcesLinkedWithReport(paths) {
105
- const report = await ensurePiResourcesLinked(paths);
106
- presentInstallReport(report, p.log);
107
- return report;
113
+ async function ensurePiExtensionsPresentWithReport(paths, continuation) {
114
+ return ensurePiExtensionsPresent(paths, async (report, guard) => {
115
+ presentInstallReport(report, p.log);
116
+ assertNoInstallConflicts(report);
117
+ return continuation ? continuation(guard) : report;
118
+ });
108
119
  }
109
120
 
110
121
  function printHelp() {
@@ -115,83 +126,104 @@ ${c.bold("Usage:")}
115
126
  ${c.cyan("npx abelworkflow init")}
116
127
  ${c.cyan("npx abelworkflow install")}
117
128
  ${c.cyan("npx abelworkflow install --force")}
118
- ${c.cyan("npx abelworkflow install --link-only")}
119
129
  ${c.cyan("npx abelworkflow install --agents-dir /custom/path")}
120
- ${c.cyan("npx abelworkflow --non-interactive")}
121
130
 
122
131
  ${c.bold("Default behavior:")}
123
132
  - npx abelworkflow: open the interactive setup menu.
124
133
  - npx abelworkflow install: sync managed files and links explicitly.
125
- - --non-interactive: auto-execute install (skip interactive menu); auto-enabled in CI.
126
134
  `);
127
135
  }
128
136
 
129
137
  async function configureProviderWithMetadata(paths, configure) {
130
- const metadata = await readInstallMetadata(paths);
131
- const packageVersion = await packageVersionFor(paths, metadata);
132
- const overrides = await configure(metadata);
133
- await writeInstallMetadata(paths, finalizeProviderInstallMetadata({
134
- previousMetadata: metadata,
135
- packageVersion,
136
- overrides
137
- }));
138
+ return withDeploymentLock(paths, async (guard) => {
139
+ await guard();
140
+ const metadata = await readInstallMetadata(paths);
141
+ const packageVersion = await packageVersionFor(paths, metadata);
142
+ const overrides = await configure(metadata, guard);
143
+ await writeInstallMetadata(paths, finalizeProviderInstallMetadata({
144
+ previousMetadata: metadata,
145
+ packageVersion,
146
+ overrides
147
+ }), { guard });
148
+ });
138
149
  }
139
150
 
140
151
  async function configureCodexForPaths(paths) {
141
- await configureProviderWithMetadata(paths, async (metadata) => {
152
+ await configureProviderWithMetadata(paths, async (metadata, guard) => {
142
153
  const result = await configureCodexApi(paths, promptApi, {
143
- managedAuthKeys: getPreviousManagedCodexAuthKeys(metadata),
154
+ guard,
144
155
  managedCodexAgentFiles: getPreviousManagedCodexAgentFiles(metadata)
145
156
  });
146
157
  return {
147
- managedCodexAuthKeys: result.managedAuthKeys,
148
158
  managedCodexAgentFiles: result.managedCodexAgentFiles
149
159
  };
150
160
  });
151
161
  }
152
162
 
153
163
  async function configureClaudeForPaths(paths) {
154
- await configureProviderWithMetadata(paths, () => configureClaudeApi(paths, promptApi));
164
+ await configureClaudeApi(paths, promptApi);
155
165
  }
156
166
 
157
- async function runFullInit(options) {
158
- const installReport = await runManagedInstall({
159
- paths: options.paths,
160
- force: options.force,
161
- relinkOnly: false
162
- });
167
+ async function runFullInit(options, runtime = {}) {
168
+ const operations = {
169
+ installWorkflow: () => runManagedInstall({
170
+ paths: options.paths,
171
+ force: options.force
172
+ }),
173
+ confirm: confirmOrCancel,
174
+ commandExists,
175
+ installClaude: () => installCliTool("claude", promptApi),
176
+ configureClaude: () => configureClaudeForPaths(options.paths),
177
+ installCodex: () => installCliTool("codex", promptApi),
178
+ configureCodex: () => configureCodexForPaths(options.paths),
179
+ installPi: () => installCliTool("pi", promptApi),
180
+ configurePi: () => configurePiApi(
181
+ options.paths,
182
+ ensurePiExtensionsPresentWithReport,
183
+ promptApi
184
+ ),
185
+ configureGrok: () => configureGrokSearchEnv(
186
+ options.paths,
187
+ (paths, continuation) => ensureSkillPresentWithReport(paths, "grok-search", continuation),
188
+ promptApi
189
+ ),
190
+ configureContext7: () => configureContext7Env(
191
+ options.paths,
192
+ (paths, continuation) => ensureSkillPresentWithReport(paths, "context7-auto-research", continuation),
193
+ promptApi
194
+ ),
195
+ success: (message) => p.log.success(message),
196
+ ...runtime
197
+ };
163
198
 
164
- if (await confirmOrCancel({ message: "是否安装或更新 Claude Code CLI?", initialValue: false })) {
165
- await installCliTool("claude", promptApi);
199
+ await operations.installWorkflow();
200
+
201
+ if (await operations.confirm({ message: "是否安装或更新 Claude Code CLI?", initialValue: false })) {
202
+ await operations.installClaude();
166
203
  }
167
- if (await confirmOrCancel({ message: "是否配置 Claude Code 第三方 API?", initialValue: commandExists("claude") })) {
168
- await configureClaudeForPaths(options.paths);
204
+ if (await operations.confirm({ message: "是否配置 Claude Code 第三方 API?", initialValue: operations.commandExists("claude") })) {
205
+ await operations.configureClaude();
169
206
  }
170
- if (await confirmOrCancel({ message: "是否安装或更新 Codex CLI?", initialValue: false })) {
171
- await installCliTool("codex", promptApi);
207
+ if (await operations.confirm({ message: "是否安装或更新 Codex CLI?", initialValue: false })) {
208
+ await operations.installCodex();
172
209
  }
173
- if (await confirmOrCancel({ message: "是否配置 Codex 第三方 API?", initialValue: commandExists("codex") })) {
174
- await configureCodexForPaths(options.paths);
210
+ if (await operations.confirm({ message: "是否配置 Codex 第三方 API?", initialValue: operations.commandExists("codex") })) {
211
+ await operations.configureCodex();
175
212
  }
176
- if (await confirmOrCancel({ message: "是否安装或更新 Pi CLI?", initialValue: false })) {
177
- await installCliTool("pi", promptApi);
213
+ if (await operations.confirm({ message: "是否安装或更新 Pi CLI?", initialValue: false })) {
214
+ await operations.installPi();
178
215
  }
179
- if (await confirmOrCancel({ message: "是否配置 Pi 当前有效 Provider API?", initialValue: commandExists("pi") })) {
180
- await configurePiApi(options.paths, ensurePiResourcesLinkedWithReport, promptApi);
216
+ if (await operations.confirm({ message: "是否配置 Pi 当前有效 Provider API?", initialValue: operations.commandExists("pi") })) {
217
+ await operations.configurePi();
181
218
  }
182
- if (await confirmOrCancel({ message: "是否填写 grok-search 环境变量?", initialValue: false })) {
183
- await configureGrokSearchEnv(options.paths, (paths) => ensureSkillPresentWithReport(paths, "grok-search"), promptApi);
219
+ if (await operations.confirm({ message: "是否填写 grok-search 环境变量?", initialValue: false })) {
220
+ await operations.configureGrok();
184
221
  }
185
- if (await confirmOrCancel({ message: "是否填写 context7-auto-research 环境变量?", initialValue: false })) {
186
- await configureContext7Env(options.paths, (paths) => ensureSkillPresentWithReport(paths, "context7-auto-research"), promptApi);
222
+ if (await operations.confirm({ message: "是否填写 context7-auto-research 环境变量?", initialValue: false })) {
223
+ await operations.configureContext7();
187
224
  }
188
225
 
189
- const conflictCount = uniqueReportPaths(installReport.conflicts).length;
190
- if (conflictCount > 0) {
191
- p.log.warn(c.yellow(`AbelWorkflow 初始化流程结束;已保留 ${conflictCount} 个工作流冲突`));
192
- } else {
193
- p.log.success(c.green("AbelWorkflow 完整初始化完成"));
194
- }
226
+ operations.success(c.green("AbelWorkflow 完整初始化完成"));
195
227
  }
196
228
 
197
229
  async function runInteractiveMenu(options) {
@@ -209,7 +241,11 @@ async function runInteractiveMenu(options) {
209
241
  pi: {
210
242
  title: "Pi",
211
243
  install: () => installCliTool("pi", promptApi),
212
- configure: () => configurePiApi(options.paths, ensurePiResourcesLinkedWithReport, promptApi)
244
+ configure: () => configurePiApi(
245
+ options.paths,
246
+ ensurePiExtensionsPresentWithReport,
247
+ promptApi
248
+ )
213
249
  },
214
250
  codex: {
215
251
  title: "Codex",
@@ -251,11 +287,10 @@ async function runInteractiveMenu(options) {
251
287
  "full-init": async () => runFullInit(options),
252
288
  install: async () => runManagedInstall({
253
289
  paths: options.paths,
254
- force: options.force,
255
- relinkOnly: options.relinkOnly
290
+ force: options.force
256
291
  }),
257
- "grok-search": async () => configureGrokSearchEnv(options.paths, (paths) => ensureSkillPresentWithReport(paths, "grok-search"), promptApi),
258
- context7: async () => configureContext7Env(options.paths, (paths) => ensureSkillPresentWithReport(paths, "context7-auto-research"), promptApi),
292
+ "grok-search": async () => configureGrokSearchEnv(options.paths, (paths, continuation) => ensureSkillPresentWithReport(paths, "grok-search", continuation), promptApi),
293
+ context7: async () => configureContext7Env(options.paths, (paths, continuation) => ensureSkillPresentWithReport(paths, "context7-auto-research", continuation), promptApi),
259
294
  "pi-cli": async () => runCliToolMenu("pi"),
260
295
  "codex-cli": async () => runCliToolMenu("codex"),
261
296
  "claude-cli": async () => runCliToolMenu("claude")
@@ -324,11 +359,6 @@ async function main(argv, runtime = {}) {
324
359
  return 0;
325
360
  }
326
361
 
327
- if (options.command === "menu" && options.nonInteractive) {
328
- console.log("检测到非交互模式,自动执行工作流安装...");
329
- options.command = "install";
330
- }
331
-
332
362
  if (options.command === "install") {
333
363
  try {
334
364
  await runManagedInstall({
@@ -341,8 +371,7 @@ async function main(argv, runtime = {}) {
341
371
  });
342
372
  } catch (error) {
343
373
  const message = `操作失败: ${error.message || String(error)}`;
344
- if (options.nonInteractive) console.error(message);
345
- else p.outro(c.red(message));
374
+ p.outro(c.red(message));
346
375
  return 1;
347
376
  }
348
377
  return 0;
@@ -351,8 +380,7 @@ async function main(argv, runtime = {}) {
351
380
  assertInteractiveMenuSupported({
352
381
  command: options.command,
353
382
  inputIsTTY: runtime.inputIsTTY ?? input.isTTY,
354
- outputIsTTY: runtime.outputIsTTY ?? output.isTTY,
355
- nonInteractive: options.nonInteractive
383
+ outputIsTTY: runtime.outputIsTTY ?? output.isTTY
356
384
  });
357
385
 
358
386
  try {
@@ -367,10 +395,15 @@ async function main(argv, runtime = {}) {
367
395
  return 0;
368
396
  } catch (error) {
369
397
  const message = `操作失败: ${error.message || String(error)}`;
370
- if (options.nonInteractive) console.error(message);
371
- else p.outro(c.red(message));
398
+ p.outro(c.red(message));
372
399
  return 1;
373
400
  }
374
401
  }
375
402
 
376
- export { main, presentInstallReport };
403
+ export {
404
+ assertNoInstallConflicts,
405
+ configureProviderWithMetadata,
406
+ main,
407
+ presentInstallReport,
408
+ runFullInit
409
+ };
@@ -40,8 +40,12 @@ function required(message = "此项不能为空") {
40
40
  }
41
41
 
42
42
  function requiredUnlessExisting(existingValue, message = "此项不能为空") {
43
+ const configured = typeof existingValue === "string" && existingValue.trim() !== "";
43
44
  return (value) => {
44
- if ((value === undefined || value === null || (typeof value === "string" && value.trim() === "")) && !existingValue) return message;
45
+ const trimmed = typeof value === "string" ? value.trim() : value;
46
+ if (trimmed === "-" || ((trimmed === undefined || trimmed === null || trimmed === "") && !configured)) {
47
+ return message;
48
+ }
45
49
  };
46
50
  }
47
51
 
@@ -60,8 +64,11 @@ function resolvePasswordValue(userInput, existingValue) {
60
64
 
61
65
  function passwordPromptOptions(message, existingValue, validate) {
62
66
  const configured = typeof existingValue === "string" && existingValue.trim() !== "";
67
+ const hint = configured
68
+ ? `(已配置,直接回车保留${validate ? "" : ";输入 - 清除"})`
69
+ : validate ? "" : "(输入 - 清除)";
63
70
  return {
64
- message: `${message}${configured ? "(已配置,直接回车保留;输入 - 清除)" : "(输入 - 清除)"}`,
71
+ message: `${message}${hint}`,
65
72
  mask: "*",
66
73
  ...(validate ? { validate } : {})
67
74
  };
@@ -91,4 +91,4 @@ function updateDotenvContent(content, updates) {
91
91
  return next;
92
92
  }
93
93
 
94
- export { parseDotenv, quoteEnvValue, updateDotenvContent };
94
+ export { parseDotenv, updateDotenvContent };
@@ -77,4 +77,4 @@ function stripJsonTrailingCommas(content) {
77
77
  return output;
78
78
  }
79
79
 
80
- export { stripJsonComments, stripJsonTrailingCommas };
80
+ export { stripJsonComments };
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import lockfile from "proper-lockfile";
4
4
  import { parseDotenv, updateDotenvContent } from "./dotenv.mjs";
5
5
  import { stripJsonComments } from "./jsonc.mjs";
@@ -21,6 +21,51 @@ function isPosix() {
21
21
  return process.platform !== "win32";
22
22
  }
23
23
 
24
+ function assertPlainObject(value, label) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
26
+ throw new TypeError(`${label} must contain a JSON object`);
27
+ }
28
+ const prototype = Object.getPrototypeOf(value);
29
+ if (prototype !== null && prototype !== Object.prototype) {
30
+ throw new TypeError(`${label} must contain a JSON object`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ async function assertSafeFilePath(path, safeRoot) {
36
+ if (!safeRoot) return;
37
+ const root = resolve(safeRoot);
38
+ const target = resolve(path);
39
+ const relativePath = relative(root, target);
40
+ if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
41
+ throw new Error(`Unsafe config path outside root: ${path}`);
42
+ }
43
+
44
+ const segments = relativePath.split(sep);
45
+ let current = root;
46
+ for (const segment of segments.slice(0, -1)) {
47
+ current = join(current, segment);
48
+ try {
49
+ const currentStat = await fs.lstat(current);
50
+ if (currentStat.isSymbolicLink() || !currentStat.isDirectory()) {
51
+ throw new Error(`Unsafe config parent: ${current}`);
52
+ }
53
+ } catch (error) {
54
+ if (isMissing(error)) break;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ try {
60
+ const targetStat = await fs.lstat(target);
61
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
62
+ throw new Error(`Unsafe config file: ${target}`);
63
+ }
64
+ } catch (error) {
65
+ if (!isMissing(error)) throw error;
66
+ }
67
+ }
68
+
24
69
  async function pathExists(path) {
25
70
  try {
26
71
  await fs.lstat(path);
@@ -52,8 +97,14 @@ async function repairSensitiveMode(path) {
52
97
  return true;
53
98
  }
54
99
 
55
- async function readFileWithOptions(path, { fallback, sensitive = false, hasFallback = true } = {}) {
100
+ async function readFileWithOptions(path, {
101
+ fallback,
102
+ sensitive = false,
103
+ hasFallback = true,
104
+ safeRoot
105
+ } = {}) {
56
106
  try {
107
+ await assertSafeFilePath(path, safeRoot);
57
108
  if (sensitive) await assertSensitiveRegularIfPresent(path);
58
109
  const content = await fs.readFile(path, "utf8");
59
110
  if (sensitive) await repairSensitiveMode(path);
@@ -87,7 +138,8 @@ function parseJsonAtPath(path, content, jsonc = false) {
87
138
  async function readJsonFileSafe(path, fallback = {}, options = {}) {
88
139
  const content = await readFileWithOptions(path, {
89
140
  fallback: null,
90
- sensitive: options.sensitive
141
+ sensitive: options.sensitive,
142
+ safeRoot: options.safeRoot
91
143
  });
92
144
  return content === null ? fallback : parseJsonAtPath(path, content);
93
145
  }
@@ -95,17 +147,10 @@ async function readJsonFileSafe(path, fallback = {}, options = {}) {
95
147
  async function readJsoncFileSafe(path, fallback = {}, options = {}) {
96
148
  const content = await readFileWithOptions(path, {
97
149
  fallback: null,
98
- sensitive: options.sensitive
99
- });
100
- return content === null ? fallback : parseJsonAtPath(path, content, true);
101
- }
102
-
103
- async function readText(path, options = {}) {
104
- return readFileWithOptions(path, {
105
- fallback: options.fallback,
106
150
  sensitive: options.sensitive,
107
- hasFallback: Object.hasOwn(options, "fallback")
151
+ safeRoot: options.safeRoot
108
152
  });
153
+ return content === null ? fallback : parseJsonAtPath(path, content, true);
109
154
  }
110
155
 
111
156
  async function listNewBackups(targetPath) {
@@ -165,6 +210,7 @@ async function writeText(path, content, options = {}) {
165
210
  let exists = true;
166
211
  let permissionRepaired = false;
167
212
 
213
+ await assertSafeFilePath(path, options.safeRoot);
168
214
  if (sensitive) await assertSensitiveRegularIfPresent(path);
169
215
  try {
170
216
  previous = await fs.readFile(path);
@@ -203,27 +249,145 @@ async function writeText(path, content, options = {}) {
203
249
  }
204
250
  }
205
251
 
206
- async function writeJson(path, value, options = {}) {
207
- await readJsonFileSafe(path, {}, options);
208
- return writeText(path, `${JSON.stringify(value, null, 2)}\n`, options);
252
+ async function snapshotFile(path, options) {
253
+ await assertSafeFilePath(path, options.safeRoot);
254
+ try {
255
+ const targetStat = await fs.lstat(path);
256
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
257
+ throw new Error(`Unsafe config file: ${path}`);
258
+ }
259
+ return {
260
+ content: await fs.readFile(path),
261
+ exists: true,
262
+ mode: targetStat.mode & 0o777
263
+ };
264
+ } catch (error) {
265
+ if (isMissing(error)) return { content: null, exists: false, mode: undefined };
266
+ throw error;
267
+ }
209
268
  }
210
269
 
211
- async function updateLockedJson(path, updater, options = {}) {
212
- await fs.mkdir(dirname(path), { recursive: true, mode: options.sensitive ? 0o700 : 0o777 });
213
- const release = await lockfile.lock(path, {
214
- realpath: false,
215
- retries: options.retries ?? 2,
216
- lockfilePath: `${path}.lock`
217
- });
270
+ async function restoreSnapshot(path, snapshot, options) {
271
+ await assertSafeFilePath(path, options.safeRoot);
272
+ if (!snapshot.exists) {
273
+ try {
274
+ const current = await fs.lstat(path);
275
+ if (current.isSymbolicLink() || !current.isFile()) {
276
+ throw new Error(`Unsafe config file during rollback: ${path}`);
277
+ }
278
+ await fs.unlink(path);
279
+ } catch (error) {
280
+ if (!isMissing(error)) throw error;
281
+ }
282
+ return;
283
+ }
284
+
285
+ const tempPath = join(dirname(path), `.${path.slice(dirname(path).length + 1)}.abelworkflow.rollback.${nextUniqueSuffix()}`);
286
+ try {
287
+ await fs.writeFile(tempPath, snapshot.content, { flag: "wx", mode: snapshot.mode });
288
+ if (isPosix()) await fs.chmod(tempPath, snapshot.mode);
289
+ await fs.rename(tempPath, path);
290
+ if (isPosix()) await fs.chmod(path, snapshot.mode);
291
+ } catch (error) {
292
+ await fs.rm(tempPath, { force: true }).catch(() => {});
293
+ throw error;
294
+ }
295
+ }
296
+
297
+ async function withFileTransaction(files, action) {
298
+ const descriptors = [...files]
299
+ .map((file) => ({ ...file, path: resolve(file.path) }))
300
+ .sort((left, right) => left.path.localeCompare(right.path));
301
+ if (new Set(descriptors.map(({ path }) => path)).size !== descriptors.length) {
302
+ throw new Error("Transaction paths must be unique");
303
+ }
304
+
305
+ const releases = [];
306
+ const snapshots = new Map();
307
+ const touched = new Set();
218
308
  try {
219
- const current = await readJsonFileSafe(path, options.defaultValue ?? {}, options);
220
- const updated = await updater(current);
221
- return await writeJson(path, updated, options);
309
+ for (const descriptor of descriptors) {
310
+ await assertSafeFilePath(descriptor.path, descriptor.safeRoot);
311
+ await fs.mkdir(dirname(descriptor.path), {
312
+ recursive: true,
313
+ mode: descriptor.sensitive ? 0o700 : 0o777
314
+ });
315
+ await assertSafeFilePath(descriptor.path, descriptor.safeRoot);
316
+ releases.push(await lockfile.lock(descriptor.path, {
317
+ realpath: false,
318
+ retries: descriptor.retries ?? {
319
+ retries: 10,
320
+ factor: 2,
321
+ minTimeout: 25,
322
+ maxTimeout: 1000,
323
+ randomize: true
324
+ },
325
+ lockfilePath: `${descriptor.path}.lock`
326
+ }));
327
+ }
328
+ for (const descriptor of descriptors) {
329
+ snapshots.set(descriptor.path, await snapshotFile(descriptor.path, descriptor));
330
+ }
331
+
332
+ const descriptorMap = new Map(descriptors.map((descriptor) => [descriptor.path, descriptor]));
333
+ const transaction = {
334
+ readJson(path, fallback = {}, { jsonc = false } = {}) {
335
+ const absolutePath = resolve(path);
336
+ const snapshot = snapshots.get(absolutePath);
337
+ if (!snapshot) throw new Error(`Path is not part of transaction: ${path}`);
338
+ return snapshot.exists
339
+ ? parseJsonAtPath(absolutePath, snapshot.content.toString("utf8"), jsonc)
340
+ : fallback;
341
+ },
342
+ readText(path, fallback = "") {
343
+ const snapshot = snapshots.get(resolve(path));
344
+ if (!snapshot) throw new Error(`Path is not part of transaction: ${path}`);
345
+ return snapshot.exists ? snapshot.content.toString("utf8") : fallback;
346
+ },
347
+ async writeText(path, content, overrides = {}) {
348
+ const absolutePath = resolve(path);
349
+ const descriptor = descriptorMap.get(absolutePath);
350
+ if (!descriptor) throw new Error(`Path is not part of transaction: ${path}`);
351
+ const { writer = writeText, ...writeOptions } = overrides;
352
+ touched.add(absolutePath);
353
+ return writer(absolutePath, content, { ...descriptor, ...writeOptions });
354
+ },
355
+ async writeJson(path, value, overrides = {}) {
356
+ return this.writeText(path, `${JSON.stringify(value, null, 2)}\n`, overrides);
357
+ }
358
+ };
359
+
360
+ try {
361
+ return await action(transaction);
362
+ } catch (error) {
363
+ const rollbackErrors = [];
364
+ for (const descriptor of [...descriptors].reverse()) {
365
+ if (!touched.has(descriptor.path)) continue;
366
+ try {
367
+ await restoreSnapshot(descriptor.path, snapshots.get(descriptor.path), descriptor);
368
+ } catch (rollbackError) {
369
+ rollbackErrors.push(rollbackError);
370
+ }
371
+ }
372
+ if (rollbackErrors.length) {
373
+ throw new AggregateError([error, ...rollbackErrors], "Transaction failed and rollback was incomplete", {
374
+ cause: error
375
+ });
376
+ }
377
+ throw error;
378
+ }
222
379
  } finally {
223
- await release();
380
+ for (const release of releases.reverse()) {
381
+ await release().catch(() => {});
382
+ }
224
383
  }
225
384
  }
226
385
 
386
+ async function writeJson(path, value, options = {}) {
387
+ await readJsonFileSafe(path, {}, options);
388
+ return writeText(path, `${JSON.stringify(value, null, 2)}\n`, options);
389
+ }
390
+
227
391
  async function backupIfNeeded(targetPath, options = {}) {
228
392
  if (!(await pathExists(targetPath))) return null;
229
393
  const backupLimit = options.backupLimit ?? 3;
@@ -235,24 +399,11 @@ async function backupIfNeeded(targetPath, options = {}) {
235
399
  return backupPath;
236
400
  }
237
401
 
238
- async function ensurePrivateJsonFile(path) {
239
- await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
240
- try {
241
- await fs.writeFile(path, "{}\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
242
- if (isPosix()) await fs.chmod(path, 0o600);
243
- return true;
244
- } catch (error) {
245
- if (error?.code !== "EEXIST") throw error;
246
- await assertSensitiveRegularIfPresent(path);
247
- await repairSensitiveMode(path);
248
- return false;
249
- }
250
- }
251
-
252
402
  async function readDotenvFile(path, options = {}) {
253
403
  const content = await readFileWithOptions(path, {
254
404
  fallback: "",
255
- sensitive: options.sensitive
405
+ sensitive: options.sensitive,
406
+ safeRoot: options.safeRoot
256
407
  });
257
408
  return parseDotenv(content);
258
409
  }
@@ -260,22 +411,23 @@ async function readDotenvFile(path, options = {}) {
260
411
  async function updateDotenvFile(path, updates, options = {}) {
261
412
  const content = await readFileWithOptions(path, {
262
413
  fallback: "",
263
- sensitive: options.sensitive
414
+ sensitive: options.sensitive,
415
+ safeRoot: options.safeRoot
264
416
  });
265
417
  return writeText(path, updateDotenvContent(content, updates), options);
266
418
  }
267
419
 
268
420
  export {
421
+ assertPlainObject,
422
+ assertSafeFilePath,
269
423
  backupIfNeeded,
270
- ensurePrivateJsonFile,
271
424
  pathExists,
272
425
  pathTargetExists,
273
426
  readDotenvFile,
274
427
  readJsonFileSafe,
275
428
  readJsoncFileSafe,
276
- readText,
277
429
  updateDotenvFile,
278
- updateLockedJson,
430
+ withFileTransaction,
279
431
  writeJson,
280
432
  writeText
281
433
  };