@actiondock/core 2.20.0 → 2.21.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/dist/errors.d.ts +11 -1
- package/dist/errors.js +23 -0
- package/dist/execution/service.js +36 -5
- package/dist/host/host.d.ts +11 -0
- package/dist/host/host.js +258 -39
- package/dist/host/types.d.ts +10 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- package/dist/invocation/types.d.ts +31 -0
- package/dist/invocation/types.js +10 -1
- package/dist/package/index.d.ts +1 -1
- package/dist/package/runtime.js +18 -4
- package/dist/platform/node.d.ts +6 -0
- package/dist/platform/node.js +59 -16
- package/dist/platform/types.d.ts +11 -0
- package/dist/process/command-executor.d.ts +35 -0
- package/dist/process/command-executor.js +326 -0
- package/dist/process/index.d.ts +1 -0
- package/dist/process/index.js +1 -0
- package/dist/process/process-driver.d.ts +19 -6
- package/dist/process/process-driver.js +223 -54
- package/dist/process/run-executor.js +101 -47
- package/dist/project/dependencies.d.ts +92 -0
- package/dist/project/dependencies.js +543 -0
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/init.js +2 -2
- package/dist/project/loader.js +15 -30
- package/dist/project/package-manager.d.ts +40 -0
- package/dist/project/package-manager.js +224 -0
- package/dist/project/preparation.d.ts +8 -0
- package/dist/project/preparation.js +130 -0
- package/dist/project/transactions.d.ts +5 -1
- package/dist/project/transactions.js +35 -75
- package/dist/runtime/context.d.ts +21 -2
- package/dist/runtime/context.js +42 -17
- package/dist/runtime/run-persistence.d.ts +1 -1
- package/dist/runtime/run-persistence.js +4 -3
- package/dist/security/config-cipher.d.ts +54 -0
- package/dist/security/config-cipher.js +190 -0
- package/dist/security/config-key.d.ts +35 -0
- package/dist/security/config-key.js +469 -0
- package/dist/server/routes/config.js +20 -1
- package/dist/service/factory.js +3 -0
- package/dist/service/types.d.ts +8 -0
- package/dist/storage/config-value.d.ts +33 -0
- package/dist/storage/config-value.js +102 -0
- package/dist/storage/file-lock.js +35 -21
- package/dist/storage/index.d.ts +24 -2
- package/dist/storage/index.js +65 -2
- package/dist/storage/migrations/index.d.ts +40 -0
- package/dist/storage/migrations/index.js +96 -0
- package/dist/storage/migrations/v2-to-v3.d.ts +16 -0
- package/dist/storage/migrations/v2-to-v3.js +116 -0
- package/dist/storage/sqlite-config.d.ts +62 -3
- package/dist/storage/sqlite-config.js +163 -9
- package/dist/storage/sqlite-driver.d.ts +5 -2
- package/dist/storage/sqlite-driver.js +24 -3
- package/dist/storage/sqlite-schema.d.ts +20 -2
- package/dist/storage/sqlite-schema.js +129 -132
- package/dist/storage/sqlite.d.ts +7 -2
- package/dist/storage/sqlite.js +64 -13
- package/dist/storage/types.d.ts +72 -4
- package/dist/storage/types.js +7 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/project/loader.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
3
|
import { NodeModuleLoader, unwrapDefaultExport } from "../platform/module-loader.js";
|
|
5
4
|
import { ActionDockError, ACTION_LOAD_FAILED, describeActionLoadFailure, INVALID_ACTION_ID, INVALID_JSON, INVALID_PACKAGE_ID, INVALID_PLAYBOOK_ID, MANIFEST_LOAD_FAILED, PLAYBOOK_NOT_FOUND, PROJECT_CONFIG_NOT_FOUND, } from "../errors.js";
|
|
6
5
|
import { loadManifest, ACTION_ID_REGEX, PLAYBOOK_ID_REGEX } from "./manifest.js";
|
|
7
6
|
import { assertValidConfigDeclarations } from "./config-declarations.js";
|
|
7
|
+
import { runPackageManager } from "./package-manager.js";
|
|
8
8
|
/**
|
|
9
9
|
* 从指定目录开始向上逐级递归查找包含 `actiondock.json` 的项目根目录。
|
|
10
10
|
*
|
|
@@ -85,36 +85,21 @@ export const ALLOWED_INSTALLERS = new Set(["npm", "bun"]);
|
|
|
85
85
|
/**
|
|
86
86
|
* 异步探测包管理器可用性(通过执行 --version 并检查退出码)。
|
|
87
87
|
*
|
|
88
|
-
*
|
|
89
|
-
* -
|
|
90
|
-
* -
|
|
91
|
-
* -
|
|
88
|
+
* 运维规范:
|
|
89
|
+
* - 复用统一包管理器启动解析与驱动底座,以 shell: false 执行;
|
|
90
|
+
* - 短超时限制,避免探测阻滞事件循环;
|
|
91
|
+
* - 仅在未明确指定包管理器时进行回退探测,选择后的执行失败不盲目换工具。
|
|
92
92
|
*/
|
|
93
|
-
function probePackageManager(command) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
settled = true;
|
|
104
|
-
resolve(false);
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
proc.on("close", (code) => {
|
|
108
|
-
if (!settled) {
|
|
109
|
-
settled = true;
|
|
110
|
-
resolve(code === 0);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
resolve(false);
|
|
116
|
-
}
|
|
117
|
-
});
|
|
93
|
+
async function probePackageManager(command) {
|
|
94
|
+
try {
|
|
95
|
+
const result = await runPackageManager(command, ["--version"], {
|
|
96
|
+
timeoutMs: 3000,
|
|
97
|
+
});
|
|
98
|
+
return result.status === 0;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
118
103
|
}
|
|
119
104
|
/**
|
|
120
105
|
* 探测宿主系统中可用的包管理工具。
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type CommandExecutionResult, type ExecuteCommandOptions } from "../process/command-executor.js";
|
|
2
|
+
export type { CommandExecutionResult };
|
|
3
|
+
export interface ResolvePackageManagerOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: Record<string, string | undefined>;
|
|
6
|
+
platform?: NodeJS.Platform;
|
|
7
|
+
execPath?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PackageManagerInvocation {
|
|
10
|
+
executable: string;
|
|
11
|
+
args: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface RunPackageManagerOptions extends ExecuteCommandOptions {
|
|
14
|
+
platform?: NodeJS.Platform;
|
|
15
|
+
execPath?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 解析包管理器在目标平台上的确定性启动规范。
|
|
19
|
+
*
|
|
20
|
+
* 核心设计契约:
|
|
21
|
+
* - 保持参数边界与数组结构(保留 argv 边界,路径中的空格、引号、&、% 与 ! 原样保留);
|
|
22
|
+
* - shell: false 语义,杜绝命令注入与 Node.js DEP0190 警告;
|
|
23
|
+
* - POSIX 平台保留正常 PATH 命令执行,兼容已有 fake npm 脚本;
|
|
24
|
+
* - Windows 平台查找遵循 PATH 目录顺序及 PATHEXT 后缀顺序(默认 .COM;.EXE;.BAT;.CMD);
|
|
25
|
+
* - PATH 所选工具为唯一事实源,显式传入 env 必须被尊重,绝不从 process.env 漏回其它 PATH;
|
|
26
|
+
* - Windows 平台原生程序(.exe, .com)直接保留该可执行文件与原数组 args,无需绕过原生版本管理器;
|
|
27
|
+
* - Windows 平台 Bun 仅支持原生程序,遇到 cmd/bat 包装脚本明确报错,无工具时严禁退回名称;
|
|
28
|
+
* - Windows 平台 npm.cmd/bat:优先其真实目录布局定位标准 npm-cli.js,由 execPath (Node) 无 shell 启动;
|
|
29
|
+
* - 仅支持标准可定位 CLI 布局,非标准 wrapper 显式报错,无有效 PATH 工具显式报错。
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolvePackageManagerInvocation(manager: "npm" | "bun", args: readonly string[], options?: ResolvePackageManagerOptions): PackageManagerInvocation;
|
|
32
|
+
/**
|
|
33
|
+
* 统一执行包管理器命令。
|
|
34
|
+
*
|
|
35
|
+
* 运维规范:
|
|
36
|
+
* - 结合 resolvePackageManagerInvocation 完成无 shell 真实命令定位;
|
|
37
|
+
* - 通过统一 ProcessDriver 底座执行命令,保留取消、超时、输出流监听与进程树收敛;
|
|
38
|
+
* - 内部可信工具执行保持标准非阻塞异步。
|
|
39
|
+
*/
|
|
40
|
+
export declare function runPackageManager(manager: "npm" | "bun", args: readonly string[], options?: RunPackageManagerOptions): Promise<CommandExecutionResult>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { ActionDockError, DEPENDENCY_INSTALL_FAILED, } from "../errors.js";
|
|
4
|
+
import { executeCommand, } from "../process/command-executor.js";
|
|
5
|
+
function isFile(filePath) {
|
|
6
|
+
try {
|
|
7
|
+
return statSync(filePath).isFile();
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
throw new ActionDockError(DEPENDENCY_INSTALL_FAILED, `Failed to inspect file at '${filePath}': ${error?.message ?? String(error)}`, { stage: "resolve", path: filePath, cause: error });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 规范化 Windows 环境变量字典。
|
|
18
|
+
*
|
|
19
|
+
* 核心契约:
|
|
20
|
+
* - 保持与 Node.js Windows child_process 相同的字典序排序与大小写去重语义;
|
|
21
|
+
* - 先由字典序首位键决定该大小写分组的最终获胜键;
|
|
22
|
+
* - 若获胜键的值为 undefined,则该变量视为未设置(同组后续键不可顶替);
|
|
23
|
+
* - 仅保留值非 undefined 的有效键值对,杜绝后续执行器剔除 undefined 时残留失效同名键。
|
|
24
|
+
*/
|
|
25
|
+
function normalizeWindowsEnvironment(env) {
|
|
26
|
+
const keys = [];
|
|
27
|
+
for (const key in env) {
|
|
28
|
+
keys.push(key);
|
|
29
|
+
}
|
|
30
|
+
keys.sort();
|
|
31
|
+
const seenUpper = new Set();
|
|
32
|
+
const normalized = {};
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const upper = key.toUpperCase();
|
|
35
|
+
if (seenUpper.has(upper)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
seenUpper.add(upper);
|
|
39
|
+
const value = env[key];
|
|
40
|
+
if (value !== undefined) {
|
|
41
|
+
normalized[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
function getNormalizedEnvValue(env, targetKey) {
|
|
47
|
+
const targetUpper = targetKey.toUpperCase();
|
|
48
|
+
for (const [key, value] of Object.entries(env)) {
|
|
49
|
+
if (key.toUpperCase() === targetUpper) {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function resolvePathExt(rawPathExt) {
|
|
56
|
+
if (!rawPathExt || rawPathExt.trim() === "") {
|
|
57
|
+
return [".COM", ".EXE", ".BAT", ".CMD"];
|
|
58
|
+
}
|
|
59
|
+
const list = rawPathExt
|
|
60
|
+
.split(";")
|
|
61
|
+
.map((e) => e.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((e) => (e.startsWith(".") ? e : `.${e}`));
|
|
64
|
+
return list.length > 0 ? list : [".COM", ".EXE", ".BAT", ".CMD"];
|
|
65
|
+
}
|
|
66
|
+
function findMatchingExecutable(dir, manager, ext) {
|
|
67
|
+
// Windows 文件系统大小写不敏感:优先尝试小写拼写,使返回路径与磁盘上常见的小写文件名一致;
|
|
68
|
+
// 再回退 PATHEXT 原样与全大写拼写,仅影响返回路径的外现大小写,不改变命中顺序。
|
|
69
|
+
const lowerCandidate = join(dir, `${manager}${ext.toLowerCase()}`);
|
|
70
|
+
if (isFile(lowerCandidate)) {
|
|
71
|
+
return lowerCandidate;
|
|
72
|
+
}
|
|
73
|
+
const directCandidate = join(dir, `${manager}${ext}`);
|
|
74
|
+
if (directCandidate !== lowerCandidate && isFile(directCandidate)) {
|
|
75
|
+
return directCandidate;
|
|
76
|
+
}
|
|
77
|
+
const upperCandidate = join(dir, `${manager}${ext.toUpperCase()}`);
|
|
78
|
+
if (upperCandidate !== directCandidate &&
|
|
79
|
+
upperCandidate !== lowerCandidate &&
|
|
80
|
+
isFile(upperCandidate)) {
|
|
81
|
+
return upperCandidate;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function findNpmCliInDirectory(targetDir) {
|
|
86
|
+
const candidates = [
|
|
87
|
+
join(targetDir, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
88
|
+
join(targetDir, "..", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
89
|
+
join(targetDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
90
|
+
join(targetDir, "..", "npm", "bin", "npm-cli.js"),
|
|
91
|
+
join(targetDir, "npm", "bin", "npm-cli.js"),
|
|
92
|
+
];
|
|
93
|
+
for (const candidate of candidates) {
|
|
94
|
+
if (isFile(candidate)) {
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 解析包管理器在目标平台上的确定性启动规范。
|
|
102
|
+
*
|
|
103
|
+
* 核心设计契约:
|
|
104
|
+
* - 保持参数边界与数组结构(保留 argv 边界,路径中的空格、引号、&、% 与 ! 原样保留);
|
|
105
|
+
* - shell: false 语义,杜绝命令注入与 Node.js DEP0190 警告;
|
|
106
|
+
* - POSIX 平台保留正常 PATH 命令执行,兼容已有 fake npm 脚本;
|
|
107
|
+
* - Windows 平台查找遵循 PATH 目录顺序及 PATHEXT 后缀顺序(默认 .COM;.EXE;.BAT;.CMD);
|
|
108
|
+
* - PATH 所选工具为唯一事实源,显式传入 env 必须被尊重,绝不从 process.env 漏回其它 PATH;
|
|
109
|
+
* - Windows 平台原生程序(.exe, .com)直接保留该可执行文件与原数组 args,无需绕过原生版本管理器;
|
|
110
|
+
* - Windows 平台 Bun 仅支持原生程序,遇到 cmd/bat 包装脚本明确报错,无工具时严禁退回名称;
|
|
111
|
+
* - Windows 平台 npm.cmd/bat:优先其真实目录布局定位标准 npm-cli.js,由 execPath (Node) 无 shell 启动;
|
|
112
|
+
* - 仅支持标准可定位 CLI 布局,非标准 wrapper 显式报错,无有效 PATH 工具显式报错。
|
|
113
|
+
*/
|
|
114
|
+
export function resolvePackageManagerInvocation(manager, args, options) {
|
|
115
|
+
const platform = options?.platform ?? process.platform;
|
|
116
|
+
const execPath = options?.execPath ?? process.execPath;
|
|
117
|
+
if (platform !== "win32") {
|
|
118
|
+
return {
|
|
119
|
+
executable: manager,
|
|
120
|
+
args: [...args],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const envSource = options?.env !== undefined ? options.env : process.env;
|
|
124
|
+
const effectiveEnv = normalizeWindowsEnvironment(envSource);
|
|
125
|
+
const rawPath = getNormalizedEnvValue(effectiveEnv, "PATH") ?? "";
|
|
126
|
+
const rawPathExt = getNormalizedEnvValue(effectiveEnv, "PATHEXT");
|
|
127
|
+
const pathExtList = resolvePathExt(rawPathExt);
|
|
128
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
129
|
+
const pathDirs = [];
|
|
130
|
+
for (const rawEntry of rawPath.split(";")) {
|
|
131
|
+
let entry = rawEntry.trim();
|
|
132
|
+
if (!entry)
|
|
133
|
+
continue;
|
|
134
|
+
if (entry.startsWith('"') && entry.endsWith('"') && entry.length >= 2) {
|
|
135
|
+
entry = entry.slice(1, -1).trim();
|
|
136
|
+
}
|
|
137
|
+
if (!entry)
|
|
138
|
+
continue;
|
|
139
|
+
pathDirs.push(isAbsolute(entry) ? entry : resolve(cwd, entry));
|
|
140
|
+
}
|
|
141
|
+
let selectedPath = null;
|
|
142
|
+
for (const dir of pathDirs) {
|
|
143
|
+
for (const ext of pathExtList) {
|
|
144
|
+
const match = findMatchingExecutable(dir, manager, ext);
|
|
145
|
+
if (match) {
|
|
146
|
+
selectedPath = match;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (selectedPath) {
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!selectedPath) {
|
|
155
|
+
throw new ActionDockError(DEPENDENCY_INSTALL_FAILED, `Cannot locate ${manager} on Windows: '${manager}' was not found in PATH (${rawPath || "empty"}).`, { stage: "resolve", manager, path: rawPath });
|
|
156
|
+
}
|
|
157
|
+
const selectedExt = extname(selectedPath).toLowerCase();
|
|
158
|
+
const isNative = selectedExt === ".exe" || selectedExt === ".com";
|
|
159
|
+
if (isNative) {
|
|
160
|
+
return {
|
|
161
|
+
executable: selectedPath,
|
|
162
|
+
args: [...args],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (manager === "bun") {
|
|
166
|
+
throw new ActionDockError(DEPENDENCY_INSTALL_FAILED, `Unsupported bun wrapper at '${selectedPath}': only native executable (.exe, .com) is supported on Windows. Non-standard wrappers cannot be executed without shell.`, { stage: "resolve", manager: "bun", path: selectedPath });
|
|
167
|
+
}
|
|
168
|
+
// Windows npm 非原生分支仅支持 .cmd 与 .bat
|
|
169
|
+
if (selectedExt !== ".cmd" && selectedExt !== ".bat") {
|
|
170
|
+
throw new ActionDockError(DEPENDENCY_INSTALL_FAILED, `Unsupported npm wrapper extension '${selectedExt}' at '${selectedPath}': only native binaries (.exe, .com) or standard Windows batch scripts (.cmd, .bat) are supported.`, { stage: "resolve", manager: "npm", path: selectedPath });
|
|
171
|
+
}
|
|
172
|
+
// Windows npm .cmd / .bat wrapper 解析
|
|
173
|
+
let realWrapperPath;
|
|
174
|
+
try {
|
|
175
|
+
realWrapperPath = realpathSync(selectedPath);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw new ActionDockError(DEPENDENCY_INSTALL_FAILED, `Failed to resolve realpath for npm wrapper at '${selectedPath}': ${error?.message ?? String(error)}`, { stage: "resolve", manager: "npm", path: selectedPath, cause: error });
|
|
179
|
+
}
|
|
180
|
+
let foundCli = null;
|
|
181
|
+
if (basename(realWrapperPath).toLowerCase() === "npm-cli.js" && isFile(realWrapperPath)) {
|
|
182
|
+
foundCli = realWrapperPath;
|
|
183
|
+
}
|
|
184
|
+
if (!foundCli) {
|
|
185
|
+
const realDir = dirname(realWrapperPath);
|
|
186
|
+
foundCli = findNpmCliInDirectory(realDir);
|
|
187
|
+
}
|
|
188
|
+
if (!foundCli) {
|
|
189
|
+
throw new ActionDockError(DEPENDENCY_INSTALL_FAILED, `Unsupported npm wrapper at '${selectedPath}': cannot locate npm-cli.js in standard installation layout. Non-standard npm wrappers cannot be executed without shell.`, { stage: "resolve", manager: "npm", path: selectedPath });
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
executable: execPath,
|
|
193
|
+
args: [foundCli, ...args],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 统一执行包管理器命令。
|
|
198
|
+
*
|
|
199
|
+
* 运维规范:
|
|
200
|
+
* - 结合 resolvePackageManagerInvocation 完成无 shell 真实命令定位;
|
|
201
|
+
* - 通过统一 ProcessDriver 底座执行命令,保留取消、超时、输出流监听与进程树收敛;
|
|
202
|
+
* - 内部可信工具执行保持标准非阻塞异步。
|
|
203
|
+
*/
|
|
204
|
+
export async function runPackageManager(manager, args, options) {
|
|
205
|
+
const platform = options?.platform ?? process.platform;
|
|
206
|
+
const normalizedEnv = platform === "win32" && options?.env !== undefined
|
|
207
|
+
? normalizeWindowsEnvironment(options.env)
|
|
208
|
+
: options?.env;
|
|
209
|
+
const invocation = resolvePackageManagerInvocation(manager, args, {
|
|
210
|
+
cwd: options?.cwd,
|
|
211
|
+
env: normalizedEnv,
|
|
212
|
+
platform: options?.platform,
|
|
213
|
+
execPath: options?.execPath,
|
|
214
|
+
});
|
|
215
|
+
return executeCommand(invocation.executable, invocation.args, {
|
|
216
|
+
cwd: options?.cwd,
|
|
217
|
+
env: normalizedEnv,
|
|
218
|
+
signal: options?.signal,
|
|
219
|
+
timeoutMs: options?.timeoutMs,
|
|
220
|
+
maxOutputBytes: options?.maxOutputBytes,
|
|
221
|
+
onOutput: options?.onOutput,
|
|
222
|
+
driver: options?.driver,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LocalPackagePreparer } from "../invocation/types.js";
|
|
2
|
+
import { type EnsureProjectDependenciesOptions } from "./dependencies.js";
|
|
3
|
+
/**
|
|
4
|
+
* 创建单个宿主使用的本地包准备器。
|
|
5
|
+
* 包实例独立缓存就绪状态;同一根调用的同一安装目录共享在途任务。
|
|
6
|
+
* 共享成功后,各包仍须通过自己的依赖检查;失败不重试、不缓存。
|
|
7
|
+
*/
|
|
8
|
+
export declare function createLocalPackagePreparer(options?: Omit<EnsureProjectDependenciesOptions, "signal">): LocalPackagePreparer;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { ActionDockError, ACTION_CANCELLED, DEPENDENCIES_NOT_READY } from "../errors.js";
|
|
4
|
+
import { ensureProjectDependencies, resolveInstallRoot, } from "./dependencies.js";
|
|
5
|
+
function cancellationError(reason) {
|
|
6
|
+
return new ActionDockError(ACTION_CANCELLED, reason instanceof Error ? reason.message : String(reason ?? "Package preparation was cancelled"), { cause: reason });
|
|
7
|
+
}
|
|
8
|
+
function isCancellation(error) {
|
|
9
|
+
return (error instanceof ActionDockError && error.code === ACTION_CANCELLED) ||
|
|
10
|
+
(error instanceof Error && error.name === "AbortError");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 创建单个宿主使用的本地包准备器。
|
|
14
|
+
* 包实例独立缓存就绪状态;同一根调用的同一安装目录共享在途任务。
|
|
15
|
+
* 共享成功后,各包仍须通过自己的依赖检查;失败不重试、不缓存。
|
|
16
|
+
*/
|
|
17
|
+
export function createLocalPackagePreparer(options = {}) {
|
|
18
|
+
const readyCache = new Set();
|
|
19
|
+
const canonicalRootCache = new Map();
|
|
20
|
+
const inFlightTasks = new Map();
|
|
21
|
+
function getPackageInstanceKey(target) {
|
|
22
|
+
let canonicalRoot = canonicalRootCache.get(target.packageRoot);
|
|
23
|
+
if (!canonicalRoot) {
|
|
24
|
+
const path = resolve(target.packageRoot);
|
|
25
|
+
try {
|
|
26
|
+
canonicalRoot = realpathSync(path);
|
|
27
|
+
canonicalRootCache.set(target.packageRoot, canonicalRoot);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new ActionDockError(DEPENDENCIES_NOT_READY, `Cannot resolve project directory '${path}': ${error?.message || String(error)}`, { path, cause: error });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify([
|
|
34
|
+
target.identity.id,
|
|
35
|
+
target.identity.instanceId,
|
|
36
|
+
target.identity.generation,
|
|
37
|
+
canonicalRoot,
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
async function waitForTask(task, signal) {
|
|
41
|
+
let released = false;
|
|
42
|
+
const releaseWaiter = () => {
|
|
43
|
+
if (!released) {
|
|
44
|
+
released = true;
|
|
45
|
+
task.waiters--;
|
|
46
|
+
}
|
|
47
|
+
return task.waiters;
|
|
48
|
+
};
|
|
49
|
+
const stopIfUnused = async () => {
|
|
50
|
+
if (releaseWaiter() !== 0 || task.settled)
|
|
51
|
+
return;
|
|
52
|
+
task.controller.abort(signal.reason);
|
|
53
|
+
try {
|
|
54
|
+
await task.promise;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!isCancellation(error))
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (signal.aborted) {
|
|
62
|
+
await stopIfUnused();
|
|
63
|
+
throw cancellationError(signal.reason);
|
|
64
|
+
}
|
|
65
|
+
let onAbort;
|
|
66
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
67
|
+
onAbort = () => {
|
|
68
|
+
void stopIfUnused().then(() => reject(cancellationError(signal.reason)), reject);
|
|
69
|
+
};
|
|
70
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
71
|
+
if (signal.aborted)
|
|
72
|
+
onAbort();
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
await Promise.race([task.promise, aborted]);
|
|
76
|
+
if (signal.aborted)
|
|
77
|
+
throw cancellationError(signal.reason);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
releaseWaiter();
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return async function prepareLocalPackage(target, context) {
|
|
85
|
+
if (context.signal.aborted)
|
|
86
|
+
throw cancellationError(context.signal.reason);
|
|
87
|
+
if (!target.packageRoot)
|
|
88
|
+
return;
|
|
89
|
+
const instanceKey = getPackageInstanceKey(target);
|
|
90
|
+
if (readyCache.has(instanceKey))
|
|
91
|
+
return;
|
|
92
|
+
const root = resolveInstallRoot(target.packageRoot);
|
|
93
|
+
if (root.skip || !root.installRoot) {
|
|
94
|
+
if (context.signal.aborted)
|
|
95
|
+
throw cancellationError(context.signal.reason);
|
|
96
|
+
readyCache.add(instanceKey);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const flightKey = JSON.stringify([context.rootRunId, root.installRoot]);
|
|
100
|
+
while (!readyCache.has(instanceKey)) {
|
|
101
|
+
if (context.signal.aborted)
|
|
102
|
+
throw cancellationError(context.signal.reason);
|
|
103
|
+
const existing = inFlightTasks.get(flightKey);
|
|
104
|
+
if (existing) {
|
|
105
|
+
existing.waiters++;
|
|
106
|
+
await waitForTask(existing, context.signal);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let task;
|
|
111
|
+
// 先登记任务与等待者,再开始底层检查,兼容同步完成的测试驱动。
|
|
112
|
+
const promise = Promise.resolve().then(async () => {
|
|
113
|
+
try {
|
|
114
|
+
if (controller.signal.aborted)
|
|
115
|
+
throw cancellationError(controller.signal.reason);
|
|
116
|
+
await ensureProjectDependencies(target.packageRoot, { ...options, signal: controller.signal });
|
|
117
|
+
readyCache.add(instanceKey);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
task.settled = true;
|
|
121
|
+
if (inFlightTasks.get(flightKey) === task)
|
|
122
|
+
inFlightTasks.delete(flightKey);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
task = { controller, promise, waiters: 1, settled: false };
|
|
126
|
+
inFlightTasks.set(flightKey, task);
|
|
127
|
+
await waitForTask(task, context.signal);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -45,7 +45,9 @@ export declare function acquireProjectLock(projectRoot: string, options?: {
|
|
|
45
45
|
/**
|
|
46
46
|
* 依据恢复后的锁文件异步执行禁用安装脚本的冻结安装,使 node_modules 与声明重新一致。
|
|
47
47
|
*/
|
|
48
|
-
export declare function runFrozenInstall(projectRoot: string
|
|
48
|
+
export declare function runFrozenInstall(projectRoot: string, options?: {
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
} | AbortSignal): Promise<void>;
|
|
49
51
|
/**
|
|
50
52
|
* 检查项目是否存在未完成提交的悬空事务。
|
|
51
53
|
*/
|
|
@@ -60,4 +62,6 @@ export declare function beginTransaction(projectRoot: string, description?: stri
|
|
|
60
62
|
*/
|
|
61
63
|
export declare function recoverPendingTransactions(projectRoot: string, options?: {
|
|
62
64
|
frozenInstall?: boolean;
|
|
65
|
+
allowInstall?: boolean;
|
|
66
|
+
signal?: AbortSignal;
|
|
63
67
|
}): Promise<string[]>;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
3
|
import { join } from "node:path";
|
|
@@ -7,6 +6,7 @@ import { isProcessAlive } from "../utils/index.js";
|
|
|
7
6
|
import { ActionDockError, PROJECT_BUSY, PROJECT_RECOVERY_REQUIRED } from "../errors.js";
|
|
8
7
|
import { LOCKFILE_NAME } from "./lockfile.js";
|
|
9
8
|
import { MANIFEST_FILE_NAME } from "./manifest.js";
|
|
9
|
+
import { runPackageManager } from "./package-manager.js";
|
|
10
10
|
export const SNAPSHOT_TRACKED_FILES = [
|
|
11
11
|
"package.json",
|
|
12
12
|
MANIFEST_FILE_NAME,
|
|
@@ -76,58 +76,12 @@ export function acquireProjectLock(projectRoot, options = {}) {
|
|
|
76
76
|
handle.release();
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
* 异步执行子进程并收集退出状态与输出流。
|
|
81
|
-
*
|
|
82
|
-
* 异步化取舍说明:
|
|
83
|
-
* - 避免在事务回滚与恢复链路中执行同步阻塞调用导致事件循环停顿;
|
|
84
|
-
* - Windows 兼容性保留:npm 在 Windows 上是 .cmd 批处理脚本,启用 shell 以防直接调用抛错;
|
|
85
|
-
* - 命令与参数由系统内部锁文件逻辑决定,不接受外部未受控入参。
|
|
86
|
-
*/
|
|
87
|
-
function execSubprocessAsync(command, args, options) {
|
|
88
|
-
return new Promise((resolve, reject) => {
|
|
89
|
-
try {
|
|
90
|
-
const proc = spawn(command, args, {
|
|
91
|
-
cwd: options?.cwd,
|
|
92
|
-
stdio: "pipe",
|
|
93
|
-
shell: process.platform === "win32",
|
|
94
|
-
env: options?.env,
|
|
95
|
-
});
|
|
96
|
-
let stdout = "";
|
|
97
|
-
let stderr = "";
|
|
98
|
-
if (proc.stdout) {
|
|
99
|
-
proc.stdout.on("data", (chunk) => {
|
|
100
|
-
stdout += chunk.toString();
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
if (proc.stderr) {
|
|
104
|
-
proc.stderr.on("data", (chunk) => {
|
|
105
|
-
stderr += chunk.toString();
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
let settled = false;
|
|
109
|
-
proc.on("error", (err) => {
|
|
110
|
-
if (!settled) {
|
|
111
|
-
settled = true;
|
|
112
|
-
reject(err);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
proc.on("close", (status) => {
|
|
116
|
-
if (!settled) {
|
|
117
|
-
settled = true;
|
|
118
|
-
resolve({ status, stdout, stderr });
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
catch (err) {
|
|
123
|
-
reject(err);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
79
|
/**
|
|
128
80
|
* 依据恢复后的锁文件异步执行禁用安装脚本的冻结安装,使 node_modules 与声明重新一致。
|
|
129
81
|
*/
|
|
130
|
-
export async function runFrozenInstall(projectRoot) {
|
|
82
|
+
export async function runFrozenInstall(projectRoot, options) {
|
|
83
|
+
const signal = options instanceof AbortSignal ? options : options?.signal;
|
|
84
|
+
signal?.throwIfAborted();
|
|
131
85
|
if (process.env.ACTIONDOCK_AUTO_INSTALL === "false") {
|
|
132
86
|
return;
|
|
133
87
|
}
|
|
@@ -137,45 +91,41 @@ export async function runFrozenInstall(projectRoot) {
|
|
|
137
91
|
}
|
|
138
92
|
const hasBunLock = existsSync(join(projectRoot, "bun.lock")) || existsSync(join(projectRoot, "bun.lockb"));
|
|
139
93
|
const hasNpmLock = existsSync(join(projectRoot, "package-lock.json")) || existsSync(join(projectRoot, "npm-shrinkwrap.json"));
|
|
140
|
-
let
|
|
94
|
+
let pm = "npm";
|
|
141
95
|
let args = ["install", "--ignore-scripts"];
|
|
142
96
|
if (hasBunLock) {
|
|
143
|
-
|
|
97
|
+
pm = "bun";
|
|
144
98
|
args = ["install", "--frozen-lockfile", "--ignore-scripts"];
|
|
145
99
|
}
|
|
146
100
|
else if (hasNpmLock) {
|
|
147
|
-
|
|
101
|
+
pm = "npm";
|
|
148
102
|
args = ["ci", "--ignore-scripts"];
|
|
149
103
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
cmd = "npm";
|
|
159
|
-
args = ["install", "--ignore-scripts"];
|
|
160
|
-
}
|
|
104
|
+
const env = {
|
|
105
|
+
...process.env,
|
|
106
|
+
npm_config_allow_scripts: "",
|
|
107
|
+
NPM_CONFIG_ALLOW_SCRIPTS: "",
|
|
108
|
+
npm_config_ignore_scripts: "true",
|
|
109
|
+
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
|
110
|
+
};
|
|
161
111
|
let proc;
|
|
162
112
|
try {
|
|
163
|
-
proc = await
|
|
113
|
+
proc = await runPackageManager(pm, args, {
|
|
164
114
|
cwd: projectRoot,
|
|
165
|
-
env
|
|
166
|
-
|
|
167
|
-
npm_config_allow_scripts: "",
|
|
168
|
-
NPM_CONFIG_ALLOW_SCRIPTS: "",
|
|
169
|
-
},
|
|
115
|
+
env,
|
|
116
|
+
signal,
|
|
170
117
|
});
|
|
171
118
|
}
|
|
172
119
|
catch (err) {
|
|
120
|
+
if (signal?.aborted || err?.name === "AbortError") {
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
173
123
|
const errorMsg = err?.message || "Unknown error";
|
|
174
|
-
throw new ActionDockError(PROJECT_RECOVERY_REQUIRED, `PROJECT_RECOVERY_REQUIRED: Frozen install failed during recovery in ${projectRoot}: ${errorMsg}
|
|
124
|
+
throw new ActionDockError(PROJECT_RECOVERY_REQUIRED, `PROJECT_RECOVERY_REQUIRED: Frozen install failed during recovery in ${projectRoot}: ${errorMsg}`, { stage: "recovery-install", projectRoot, manager: pm, cause: err });
|
|
175
125
|
}
|
|
176
126
|
if (proc.status !== 0) {
|
|
177
|
-
const errorMsg = proc.stderr || proc.stdout || "Unknown error";
|
|
178
|
-
throw new ActionDockError(PROJECT_RECOVERY_REQUIRED, `PROJECT_RECOVERY_REQUIRED: Frozen install failed during recovery in ${projectRoot}: ${errorMsg}
|
|
127
|
+
const errorMsg = (proc.stderr || proc.stdout || "Unknown error").trim();
|
|
128
|
+
throw new ActionDockError(PROJECT_RECOVERY_REQUIRED, `PROJECT_RECOVERY_REQUIRED: Frozen install failed during recovery in ${projectRoot}: ${errorMsg}`, { stage: "recovery-install", projectRoot, manager: pm, status: proc.status, stderr: proc.stderr, stdout: proc.stdout });
|
|
179
129
|
}
|
|
180
130
|
}
|
|
181
131
|
/**
|
|
@@ -214,6 +164,9 @@ export function hasPendingTransactions(projectRoot) {
|
|
|
214
164
|
export async function beginTransaction(projectRoot, description) {
|
|
215
165
|
const releaseLock = acquireProjectLock(projectRoot);
|
|
216
166
|
try {
|
|
167
|
+
if (hasPendingTransactions(projectRoot)) {
|
|
168
|
+
throw new ActionDockError(PROJECT_RECOVERY_REQUIRED, `PROJECT_RECOVERY_REQUIRED: Cannot begin transaction in '${projectRoot}': pending transaction exists. Recovery is required.`);
|
|
169
|
+
}
|
|
217
170
|
const txId = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
218
171
|
const txDir = join(projectRoot, ".actiondock", "transactions", txId);
|
|
219
172
|
const snapshotDir = join(txDir, "snapshot");
|
|
@@ -300,6 +253,7 @@ export async function beginTransaction(projectRoot, description) {
|
|
|
300
253
|
* 依据事务日志恢复所有待恢复的悬空事务快照,并在成功后执行冻结安装。
|
|
301
254
|
*/
|
|
302
255
|
export async function recoverPendingTransactions(projectRoot, options) {
|
|
256
|
+
options?.signal?.throwIfAborted();
|
|
303
257
|
const txBaseDir = join(projectRoot, ".actiondock", "transactions");
|
|
304
258
|
if (!existsSync(txBaseDir)) {
|
|
305
259
|
return [];
|
|
@@ -315,6 +269,7 @@ export async function recoverPendingTransactions(projectRoot, options) {
|
|
|
315
269
|
throw err;
|
|
316
270
|
}
|
|
317
271
|
try {
|
|
272
|
+
options?.signal?.throwIfAborted();
|
|
318
273
|
const recoveredIds = [];
|
|
319
274
|
const entries = readdirSync(txBaseDir);
|
|
320
275
|
for (const entry of entries) {
|
|
@@ -330,6 +285,11 @@ export async function recoverPendingTransactions(projectRoot, options) {
|
|
|
330
285
|
continue;
|
|
331
286
|
}
|
|
332
287
|
if (meta.status === "pending") {
|
|
288
|
+
options?.signal?.throwIfAborted();
|
|
289
|
+
if (options?.frozenInstall !== false &&
|
|
290
|
+
(options?.allowInstall === false || process.env.ACTIONDOCK_AUTO_INSTALL === "false")) {
|
|
291
|
+
throw new ActionDockError(PROJECT_RECOVERY_REQUIRED, `PROJECT_RECOVERY_REQUIRED: Project at '${projectRoot}' has pending transactions requiring recovery install, but installation is disabled (--no-install or ACTIONDOCK_AUTO_INSTALL=false).`);
|
|
292
|
+
}
|
|
333
293
|
const snapshotDir = join(txDir, "snapshot");
|
|
334
294
|
for (const rec of meta.files) {
|
|
335
295
|
const targetPath = join(projectRoot, rec.name);
|
|
@@ -346,7 +306,7 @@ export async function recoverPendingTransactions(projectRoot, options) {
|
|
|
346
306
|
}
|
|
347
307
|
}
|
|
348
308
|
if (options?.frozenInstall !== false) {
|
|
349
|
-
await runFrozenInstall(projectRoot);
|
|
309
|
+
await runFrozenInstall(projectRoot, { signal: options?.signal });
|
|
350
310
|
}
|
|
351
311
|
meta.status = "rolled_back";
|
|
352
312
|
try {
|
|
@@ -362,6 +322,6 @@ export async function recoverPendingTransactions(projectRoot, options) {
|
|
|
362
322
|
return recoveredIds;
|
|
363
323
|
}
|
|
364
324
|
finally {
|
|
365
|
-
releaseLock();
|
|
325
|
+
releaseLock?.();
|
|
366
326
|
}
|
|
367
327
|
}
|