abelworkflow 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +30 -29
- package/extensions/gpt-responses-compat.ts +213 -0
- package/extensions/k2think-compat.ts +40 -0
- package/lib/cli/args.mjs +5 -19
- package/lib/cli/main.mjs +105 -72
- package/lib/cli/prompts.mjs +9 -2
- package/lib/config/dotenv.mjs +1 -1
- package/lib/config/jsonc.mjs +1 -1
- package/lib/config/store.mjs +197 -45
- package/lib/config/toml.mjs +147 -453
- package/lib/installer/assets.mjs +67 -45
- package/lib/installer/install.mjs +202 -45
- package/lib/installer/links.mjs +124 -101
- package/lib/installer/lock.mjs +82 -0
- package/lib/installer/state.mjs +14 -21
- package/lib/paths.mjs +9 -9
- package/lib/providers/claude.mjs +58 -114
- package/lib/providers/codex.mjs +101 -198
- package/lib/providers/pi.mjs +331 -497
- package/lib/providers/skills.mjs +88 -68
- package/lib/providers/url.mjs +35 -0
- package/lib/templates/workflow/commands/abel-design.md +23 -161
- package/lib/templates/workflow/commands/abel-diagnose.md +30 -56
- package/lib/templates/workflow/commands/abel-implement.md +30 -150
- package/lib/templates/workflow/commands/abel-init.md +1 -1
- package/lib/tools/cli-installer.mjs +29 -190
- package/package.json +7 -9
- package/skills/context7-auto-research/context7-api.cjs +75 -17
- package/skills/dev-browser/SKILL.md +15 -1
- package/skills/dev-browser/dist/scripts/start.d.ts +0 -1
- package/skills/dev-browser/dist/scripts/start.js +8 -36
- package/skills/dev-browser/dist/src/client.d.ts +3 -4
- package/skills/dev-browser/dist/src/client.js +117 -117
- package/skills/dev-browser/dist/src/entrypoint.d.ts +1 -1
- package/skills/dev-browser/dist/src/entrypoint.js +38 -31
- package/skills/dev-browser/dist/src/index.d.ts +0 -1
- package/skills/dev-browser/dist/src/index.js +0 -1
- package/skills/dev-browser/dist/src/page-api.d.ts +0 -1
- package/skills/dev-browser/dist/src/page-api.js +0 -1
- package/skills/dev-browser/dist/src/relay.d.ts +0 -1
- package/skills/dev-browser/dist/src/relay.js +53 -7
- package/skills/dev-browser/dist/src/runtime.d.ts +3 -14
- package/skills/dev-browser/dist/src/runtime.js +5 -15
- package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts +0 -1
- package/skills/dev-browser/dist/src/snapshot/browser-script.js +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.d.ts +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.js +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.d.ts +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.js +0 -1
- package/skills/dev-browser/dist/src/standalone.d.ts +0 -1
- package/skills/dev-browser/dist/src/standalone.js +2 -3
- package/skills/dev-browser/dist/src/startup.d.ts +3 -12
- package/skills/dev-browser/dist/src/startup.js +6 -10
- package/skills/dev-browser/dist/src/target-registry.d.ts +0 -1
- package/skills/dev-browser/dist/src/target-registry.js +0 -1
- package/skills/dev-browser/dist/src/types.d.ts +0 -1
- package/skills/dev-browser/dist/src/types.js +0 -1
- package/skills/dev-browser/package-lock.json +1 -1
- package/skills/dev-browser/package.json +2 -2
- package/skills/grok-search/.env.example +1 -1
- package/skills/grok-search/SKILL.md +13 -16
- package/skills/grok-search/scripts/_dotenv.py +1 -1
- package/skills/grok-search/scripts/groksearch_cli.py +271 -195
- package/skills/time/SKILL.md +5 -2
- package/skills/time/scripts/time_cli.py +61 -22
- package/extensions/pi-gpt-responses-compat/index.ts +0 -25
- package/lib/templates/codex/config-base.toml +0 -103
- package/skills/dev-browser/dist/scripts/start.d.ts.map +0 -1
- package/skills/dev-browser/dist/scripts/start.js.map +0 -1
- package/skills/dev-browser/dist/src/client.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/client.js.map +0 -1
- package/skills/dev-browser/dist/src/entrypoint.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/entrypoint.js.map +0 -1
- package/skills/dev-browser/dist/src/index.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/index.js.map +0 -1
- package/skills/dev-browser/dist/src/page-api.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/page-api.js.map +0 -1
- package/skills/dev-browser/dist/src/relay.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/relay.js.map +0 -1
- package/skills/dev-browser/dist/src/runtime.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/runtime.js.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/browser-script.js.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.js.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.js.map +0 -1
- package/skills/dev-browser/dist/src/standalone.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/standalone.js.map +0 -1
- package/skills/dev-browser/dist/src/startup.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/startup.js.map +0 -1
- package/skills/dev-browser/dist/src/target-registry.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/target-registry.js.map +0 -1
- package/skills/dev-browser/dist/src/types.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/types.js.map +0 -1
- package/skills/grok-search/gitignore.template +0 -4
- 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
|
-
|
|
8
|
+
ensurePiExtensionsPresent,
|
|
9
9
|
ensureSkillPresent,
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
164
|
+
await configureClaudeApi(paths, promptApi);
|
|
155
165
|
}
|
|
156
166
|
|
|
157
|
-
async function runFullInit(options) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
168
|
-
await
|
|
204
|
+
if (await operations.confirm({ message: "是否配置 Claude Code 第三方 API?", initialValue: operations.commandExists("claude") })) {
|
|
205
|
+
await operations.configureClaude();
|
|
169
206
|
}
|
|
170
|
-
if (await
|
|
171
|
-
await
|
|
207
|
+
if (await operations.confirm({ message: "是否安装或更新 Codex CLI?", initialValue: false })) {
|
|
208
|
+
await operations.installCodex();
|
|
172
209
|
}
|
|
173
|
-
if (await
|
|
174
|
-
await
|
|
210
|
+
if (await operations.confirm({ message: "是否配置 Codex 第三方 API?", initialValue: operations.commandExists("codex") })) {
|
|
211
|
+
await operations.configureCodex();
|
|
175
212
|
}
|
|
176
|
-
if (await
|
|
177
|
-
await
|
|
213
|
+
if (await operations.confirm({ message: "是否安装或更新 Pi CLI?", initialValue: false })) {
|
|
214
|
+
await operations.installPi();
|
|
178
215
|
}
|
|
179
|
-
if (await
|
|
180
|
-
await
|
|
216
|
+
if (await operations.confirm({ message: "是否配置 Pi 当前有效 Provider API?", initialValue: operations.commandExists("pi") })) {
|
|
217
|
+
await operations.configurePi();
|
|
181
218
|
}
|
|
182
|
-
if (await
|
|
183
|
-
await
|
|
219
|
+
if (await operations.confirm({ message: "是否填写 grok-search 环境变量?", initialValue: false })) {
|
|
220
|
+
await operations.configureGrok();
|
|
184
221
|
}
|
|
185
|
-
if (await
|
|
186
|
-
await
|
|
222
|
+
if (await operations.confirm({ message: "是否填写 context7-auto-research 环境变量?", initialValue: false })) {
|
|
223
|
+
await operations.configureContext7();
|
|
187
224
|
}
|
|
188
225
|
|
|
189
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
403
|
+
export {
|
|
404
|
+
assertNoInstallConflicts,
|
|
405
|
+
configureProviderWithMetadata,
|
|
406
|
+
main,
|
|
407
|
+
presentInstallReport,
|
|
408
|
+
runFullInit
|
|
409
|
+
};
|
package/lib/cli/prompts.mjs
CHANGED
|
@@ -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
|
-
|
|
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}${
|
|
71
|
+
message: `${message}${hint}`,
|
|
65
72
|
mask: "*",
|
|
66
73
|
...(validate ? { validate } : {})
|
|
67
74
|
};
|
package/lib/config/dotenv.mjs
CHANGED
package/lib/config/jsonc.mjs
CHANGED
package/lib/config/store.mjs
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
|
207
|
-
await
|
|
208
|
-
|
|
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
|
|
212
|
-
await
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
+
withFileTransaction,
|
|
279
431
|
writeJson,
|
|
280
432
|
writeText
|
|
281
433
|
};
|