release-skill 0.1.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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,113 @@
1
+ // Stable error codes and exit codes for the release-skill system.
2
+ // Error codes are grouped by phase; each maps to a unique stable exit code.
3
+
4
+ /** @type {Readonly<Record<string, number>>} */
5
+ const EXIT_CODE_MAP = Object.freeze({
6
+ CONFIG_INVALID: 10,
7
+ BASELINE_CHANGED: 11,
8
+ DIRTY_SCOPE_CONFLICT: 12,
9
+ GATE_FAILED: 13,
10
+ AUTH_MISSING: 14,
11
+ REMOTE_CONFLICT: 15,
12
+ HOOK_TIMEOUT: 16,
13
+ PARTIAL_RELEASE: 17,
14
+ POST_PUBLISH_VERIFY_FAILED: 18,
15
+ INVALID_STATE_TRANSITION: 19,
16
+ PLAN_DIGEST_MISMATCH: 20,
17
+ SECRET_DETECTED: 21,
18
+ PUBLIC_PATH_FORBIDDEN: 22,
19
+ STALE_BUILD_ARTIFACT: 23,
20
+ MISSING_PARAMETERS: 24,
21
+ PUBLIC_FILE_MISSING: 25,
22
+ SNAPSHOT_FIDELITY_FAILED: 26,
23
+ FORBIDDEN_CONTENT_DETECTED: 27,
24
+ PATH_UNSAFE: 28,
25
+ STRUCTURE_INVALID: 29,
26
+ LOCK_MIGRATION_REQUIRED: 30,
27
+ ARTIFACT_POLICY_INVALID: 31,
28
+ BASE_UNAVAILABLE: 32,
29
+ PRODUCER_NONDETERMINISTIC: 33,
30
+ PRODUCER_SCOPE_VIOLATION: 34,
31
+ ADOPTION_AMBIGUOUS: 35,
32
+ PLAN_STALE: 36,
33
+ SENSITIVE_CONFLICT: 37,
34
+ TRANSACTION_INCOMPLETE: 38,
35
+ SAFE_WRITE_UNAVAILABLE: 39,
36
+ });
37
+
38
+ // ---- Error code constants ----
39
+
40
+ export const CONFIG_INVALID = 'CONFIG_INVALID';
41
+ export const BASELINE_CHANGED = 'BASELINE_CHANGED';
42
+ export const DIRTY_SCOPE_CONFLICT = 'DIRTY_SCOPE_CONFLICT';
43
+ export const GATE_FAILED = 'GATE_FAILED';
44
+ export const AUTH_MISSING = 'AUTH_MISSING';
45
+ export const REMOTE_CONFLICT = 'REMOTE_CONFLICT';
46
+ export const HOOK_TIMEOUT = 'HOOK_TIMEOUT';
47
+ export const PARTIAL_RELEASE = 'PARTIAL_RELEASE';
48
+ export const POST_PUBLISH_VERIFY_FAILED = 'POST_PUBLISH_VERIFY_FAILED';
49
+ export const INVALID_STATE_TRANSITION = 'INVALID_STATE_TRANSITION';
50
+ export const PLAN_DIGEST_MISMATCH = 'PLAN_DIGEST_MISMATCH';
51
+ export const SECRET_DETECTED = 'SECRET_DETECTED';
52
+ export const PUBLIC_PATH_FORBIDDEN = 'PUBLIC_PATH_FORBIDDEN';
53
+ export const STALE_BUILD_ARTIFACT = 'STALE_BUILD_ARTIFACT';
54
+ export const MISSING_PARAMETERS = 'MISSING_PARAMETERS';
55
+ export const PUBLIC_FILE_MISSING = 'PUBLIC_FILE_MISSING';
56
+ export const SNAPSHOT_FIDELITY_FAILED = 'SNAPSHOT_FIDELITY_FAILED';
57
+ export const FORBIDDEN_CONTENT_DETECTED = 'FORBIDDEN_CONTENT_DETECTED';
58
+ export const PATH_UNSAFE = 'PATH_UNSAFE';
59
+ export const STRUCTURE_INVALID = 'STRUCTURE_INVALID';
60
+ export const LOCK_MIGRATION_REQUIRED = 'LOCK_MIGRATION_REQUIRED';
61
+ export const ARTIFACT_POLICY_INVALID = 'ARTIFACT_POLICY_INVALID';
62
+ export const BASE_UNAVAILABLE = 'BASE_UNAVAILABLE';
63
+ export const PRODUCER_NONDETERMINISTIC = 'PRODUCER_NONDETERMINISTIC';
64
+ export const PRODUCER_SCOPE_VIOLATION = 'PRODUCER_SCOPE_VIOLATION';
65
+ export const ADOPTION_AMBIGUOUS = 'ADOPTION_AMBIGUOUS';
66
+ export const PLAN_STALE = 'PLAN_STALE';
67
+ export const SENSITIVE_CONFLICT = 'SENSITIVE_CONFLICT';
68
+ export const TRANSACTION_INCOMPLETE = 'TRANSACTION_INCOMPLETE';
69
+ export const SAFE_WRITE_UNAVAILABLE = 'SAFE_WRITE_UNAVAILABLE';
70
+
71
+ /**
72
+ * Typed error for release-skill operations.
73
+ *
74
+ * @param {string} code One of the exported error-code constants.
75
+ * @param {string} message Human-readable description.
76
+ * @param {Record<string, unknown>} [details] Machine-readable context (must not contain secrets).
77
+ * @param {number} [exitCode] Override exit code; defaults to the stable mapping for `code`.
78
+ */
79
+ export class ReleaseError extends Error {
80
+ constructor(code, message, details = {}, exitCode) {
81
+ super(message);
82
+ this.name = 'ReleaseError';
83
+ this.code = code;
84
+ this.details = details;
85
+ this.exitCode = exitCode ?? EXIT_CODE_MAP[code] ?? 1;
86
+
87
+ // Maintain proper stack trace in V8 environments
88
+ if (Error.captureStackTrace) {
89
+ Error.captureStackTrace(this, ReleaseError);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Serialise the error to a plain JSON-safe object.
95
+ * Intentionally omits the stack trace and any raw secret values.
96
+ *
97
+ * @returns {{ code: string, message: string, details: Record<string, unknown>, exitCode: number }}
98
+ */
99
+ toJSON() {
100
+ return {
101
+ code: this.code,
102
+ message: this.message,
103
+ details: this.details,
104
+ exitCode: this.exitCode,
105
+ };
106
+ }
107
+ }
108
+
109
+ /** All known error codes as a frozen array. */
110
+ export const ALL_ERROR_CODES = Object.freeze(Object.keys(EXIT_CODE_MAP));
111
+
112
+ /** The stable exit-code map, keyed by error code. */
113
+ export { EXIT_CODE_MAP };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Structured evidence writer for release-skill.
3
+ *
4
+ * Creates append-only JSONL evidence streams with automatic redaction
5
+ * of sensitive keys and known token/credential prefixes.
6
+ *
7
+ * @module evidence
8
+ */
9
+
10
+ import { open, mkdir, writeFile } from 'node:fs/promises';
11
+ import { basename } from 'node:path';
12
+
13
+ /** Schema version for evidence events. */
14
+ const SCHEMA_VERSION = 1;
15
+
16
+ /**
17
+ * Key-name pattern that indicates a sensitive value requiring redaction.
18
+ * Matches: token, secret, password, authorization, cookie (case-insensitive).
19
+ */
20
+ const SENSITIVE_KEY_PATTERN = /token|secret|password|authorization|cookie/i;
21
+
22
+ /**
23
+ * Known credential prefixes. Order matters for longest-prefix-first matching.
24
+ * Each entry maps a prefix string to its display label.
25
+ */
26
+ const CREDENTIAL_PREFIXES = [
27
+ { prefix: 'github_pat_', label: 'github_pat_' },
28
+ { prefix: 'ghp_', label: 'ghp_' },
29
+ { prefix: 'npm_', label: 'npm_' },
30
+ { prefix: 'AKIA', label: 'AKIA' },
31
+ ];
32
+
33
+ const REDACTED = '[REDACTED]';
34
+
35
+ /**
36
+ * Recursively redact sensitive values in an object.
37
+ *
38
+ * Redaction rules:
39
+ * 1. If a key name matches `SENSITIVE_KEY_PATTERN`, its value is replaced
40
+ * with `[REDACTED]`.
41
+ * 2. If a string value starts with a known credential prefix, it is replaced
42
+ * with `[REDACTED:<PREFIX>]`.
43
+ *
44
+ * @param {*} obj - The value to redact (object, array, string, or primitive).
45
+ * @returns {*} A new value with sensitive data redacted.
46
+ */
47
+ export function redact(obj) {
48
+ if (obj === null || obj === undefined) {
49
+ return obj;
50
+ }
51
+
52
+ if (Array.isArray(obj)) {
53
+ return obj.map((item) => redact(item));
54
+ }
55
+
56
+ if (typeof obj === 'object') {
57
+ const result = {};
58
+ for (const [key, value] of Object.entries(obj)) {
59
+ if (SENSITIVE_KEY_PATTERN.test(key)) {
60
+ result[key] = REDACTED;
61
+ } else {
62
+ result[key] = redact(value);
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ if (typeof obj === 'string') {
69
+ for (const { prefix, label } of CREDENTIAL_PREFIXES) {
70
+ if (obj.startsWith(prefix)) {
71
+ return `[REDACTED:${label}]`;
72
+ }
73
+ }
74
+ }
75
+
76
+ return obj;
77
+ }
78
+
79
+ /**
80
+ * Create an evidence writer that appends structured JSONL events and
81
+ * produces a summary JSON file.
82
+ *
83
+ * @param {Object} options
84
+ * @param {string} options.runDir - Absolute path to the run directory. The
85
+ * directory name (last segment) is used as the `runId`.
86
+ * @param {string} options.command - The top-level command being executed
87
+ * (e.g. "prepare", "publish").
88
+ * @param {() => string} [options.clock] - Optional clock function returning
89
+ * an ISO-8601 timestamp string. Defaults to `() => new Date().toISOString()`.
90
+ * @returns {{ append: (event: Object) => Promise<void>, finish: (summary: Object) => Promise<void> }}
91
+ */
92
+ export function createEvidenceWriter({ runDir, command, clock }) {
93
+ const clockFn = typeof clock === 'function' ? clock : () => new Date().toISOString();
94
+ const runId = basename(runDir);
95
+ const evidencePath = `${runDir}/evidence.jsonl`;
96
+ const summaryPath = `${runDir}/summary.json`;
97
+
98
+ let sequence = 0;
99
+ let handle = null;
100
+
101
+ /**
102
+ * Lazily open the evidence file for appending.
103
+ * Creates the run directory if it does not exist.
104
+ */
105
+ async function ensureHandle() {
106
+ if (handle === null) {
107
+ await mkdir(runDir, { recursive: true });
108
+ handle = await open(evidencePath, 'a');
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Append a single event to the evidence JSONL stream.
114
+ *
115
+ * The event is enriched with automatic metadata:
116
+ * - `schemaVersion`: always 1
117
+ * - `runId`: extracted from the run directory name
118
+ * - `sequence`: auto-incrementing integer starting at 0
119
+ * - `timestamp`: ISO-8601 string from the clock
120
+ * - `command`: the command passed at creation time
121
+ *
122
+ * The entire event object is redacted before writing.
123
+ *
124
+ * @param {Object} event - The event data. Must include `phase` and `status`;
125
+ * may include `error` and any other fields.
126
+ */
127
+ async function append(event) {
128
+ await ensureHandle();
129
+
130
+ const enriched = {
131
+ schemaVersion: SCHEMA_VERSION,
132
+ runId,
133
+ sequence,
134
+ timestamp: clockFn(),
135
+ command,
136
+ ...event,
137
+ };
138
+
139
+ sequence += 1;
140
+
141
+ const redacted = redact(enriched);
142
+ const line = JSON.stringify(redacted);
143
+
144
+ await handle.write(`${line}\n`, null, 'utf8');
145
+ }
146
+
147
+ /**
148
+ * Write the final summary file and close the evidence stream.
149
+ *
150
+ * The summary is redacted before writing.
151
+ *
152
+ * @param {Object} summary - The run summary object.
153
+ */
154
+ async function finish(summary) {
155
+ await ensureHandle();
156
+
157
+ const redacted = redact(summary);
158
+ await writeFile(summaryPath, JSON.stringify(redacted, null, 2), 'utf8');
159
+
160
+ if (handle !== null) {
161
+ await handle.close();
162
+ handle = null;
163
+ }
164
+ }
165
+
166
+ return { append, finish };
167
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Secure hook execution for the release-skill system.
3
+ *
4
+ * Design constraints:
5
+ * - Uses `execFile` exclusively; never `exec` or `shell: true`.
6
+ * - Resolves cwd through `realpath` and rejects any path that escapes root.
7
+ * - Builds a minimal environment: platform-required vars plus only those
8
+ * explicitly listed in `envAllowlist` that exist in `context.env`.
9
+ * - Kills the child process on timeout and returns `HOOK_TIMEOUT`.
10
+ * - Never leaks unallowlisted environment variables to the child process.
11
+ *
12
+ * @module hooks
13
+ */
14
+
15
+ import { execFile as execFileCb } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import { resolve, relative, isAbsolute } from 'node:path';
18
+ import { realpath } from 'node:fs/promises';
19
+ import { ReleaseError } from './errors.mjs';
20
+
21
+ const execFileAsync = promisify(execFileCb);
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Platform-required environment variables
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Minimum environment variables needed for child processes.
29
+ * PATH is always included; HOME/USER are needed for temporary directories and
30
+ * user detection; LANG/LC_* support locale on POSIX; SystemRoot/COMSPEC/PATHEXT
31
+ * are required on Windows.
32
+ */
33
+ const PLATFORM_REQUIRED_VARS = (() => {
34
+ const posix = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM'];
35
+ const win32 = ['PATH', 'HOME', 'USER', 'SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'];
36
+ return new Set(process.platform === 'win32' ? win32 : posix);
37
+ })();
38
+
39
+ /** Pattern that every envAllowlist key must satisfy. */
40
+ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Validation
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Validate the shape of a hook descriptor against the project contract.
48
+ *
49
+ * @param {unknown} hook
50
+ * @throws {ReleaseError} INVALID_HOOK on any contract violation.
51
+ */
52
+ function validateHook(hook) {
53
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
54
+ throw new ReleaseError('INVALID_HOOK', 'hook must be a non-null object');
55
+ }
56
+
57
+ const { command, cwd, timeoutMs, envAllowlist } = hook;
58
+
59
+ // command: required, non-empty array of strings
60
+ if (!Array.isArray(command) || command.length === 0) {
61
+ throw new ReleaseError('INVALID_HOOK', 'hook.command must be a non-empty array');
62
+ }
63
+ for (const c of command) {
64
+ if (typeof c !== 'string') {
65
+ throw new ReleaseError('INVALID_HOOK', 'every element of hook.command must be a string');
66
+ }
67
+ }
68
+
69
+ // cwd: optional string
70
+ if (cwd !== undefined && typeof cwd !== 'string') {
71
+ throw new ReleaseError('INVALID_HOOK', 'hook.cwd must be a string when provided');
72
+ }
73
+
74
+ // timeoutMs: optional positive integer (minimum 1 to avoid accidental zero)
75
+ if (timeoutMs !== undefined) {
76
+ if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 1) {
77
+ throw new ReleaseError('INVALID_HOOK', 'hook.timeoutMs must be a positive integer');
78
+ }
79
+ }
80
+
81
+ // envAllowlist: optional array of uppercase key names
82
+ if (envAllowlist !== undefined) {
83
+ if (!Array.isArray(envAllowlist)) {
84
+ throw new ReleaseError('INVALID_HOOK', 'hook.envAllowlist must be an array');
85
+ }
86
+ for (const key of envAllowlist) {
87
+ if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key)) {
88
+ throw new ReleaseError(
89
+ 'INVALID_HOOK',
90
+ `hook.envAllowlist key "${key}" must match /^[A-Z_][A-Z0-9_]*$/`,
91
+ );
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Environment building
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Build a filtered environment for a child process.
103
+ *
104
+ * Included keys:
105
+ * 1. Platform-required vars (from `process.env`).
106
+ * 2. Allowlisted vars (from `context.env` only).
107
+ *
108
+ * Everything else is stripped.
109
+ *
110
+ * @param {string[]} envAllowlist - Keys to forward from context.env.
111
+ * @param {Record<string, string>} [contextEnv] - Caller-provided env map.
112
+ * @returns {Record<string, string>}
113
+ */
114
+ function buildFilteredEnv(envAllowlist, contextEnv) {
115
+ const env = {};
116
+
117
+ // 1. Platform-required variables
118
+ for (const key of PLATFORM_REQUIRED_VARS) {
119
+ const val = process.env[key];
120
+ if (val !== undefined) {
121
+ env[key] = val;
122
+ }
123
+ }
124
+
125
+ // 2. Allowlisted variables from context.env
126
+ if (contextEnv && envAllowlist) {
127
+ for (const key of envAllowlist) {
128
+ if (key in contextEnv) {
129
+ env[key] = contextEnv[key];
130
+ }
131
+ }
132
+ }
133
+
134
+ return env;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Public API
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Run a hook command in a controlled subprocess.
143
+ *
144
+ * @param {Object} hook
145
+ * @param {string[]} hook.command - [executable, ...args].
146
+ * @param {string} [hook.cwd] - Relative (to root) working directory.
147
+ * @param {number} [hook.timeoutMs] - Kill child after this many ms.
148
+ * @param {string[]} [hook.envAllowlist] - Extra env keys to pass through.
149
+ *
150
+ * @param {Object} context
151
+ * @param {string} context.root - Absolute project root.
152
+ * @param {Record<string, string>} [context.env] - Extra env variables.
153
+ *
154
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
155
+ *
156
+ * @throws {ReleaseError} HOOK_TIMEOUT - when timeoutMs expires.
157
+ * @throws {ReleaseError} INVALID_HOOK - when hook shape is invalid or cwd escapes root.
158
+ */
159
+ export async function runHook(hook, context) {
160
+ // --- Validate inputs ---
161
+ validateHook(hook);
162
+
163
+ if (!context || typeof context !== 'object' || Array.isArray(context)) {
164
+ throw new ReleaseError('INVALID_HOOK', 'context must be a non-null object');
165
+ }
166
+ if (typeof context.root !== 'string' || !isAbsolute(context.root)) {
167
+ throw new ReleaseError('INVALID_HOOK', 'context.root must be an absolute path');
168
+ }
169
+
170
+ const { command, cwd, timeoutMs, envAllowlist = [] } = hook;
171
+
172
+ // --- Resolve and validate cwd ---
173
+ const rootReal = await realpath(context.root);
174
+ const resolvedCwd = cwd
175
+ ? await realpath(resolve(rootReal, cwd))
176
+ : rootReal;
177
+
178
+ const rel = relative(rootReal, resolvedCwd);
179
+ if (rel.startsWith('..') || rel === '..') {
180
+ throw new ReleaseError(
181
+ 'INVALID_HOOK',
182
+ `hook.cwd "${cwd}" resolves outside project root`,
183
+ );
184
+ }
185
+
186
+ // --- Build safe environment ---
187
+ const env = buildFilteredEnv(envAllowlist, context.env);
188
+
189
+ // --- Set up timeout ---
190
+ const executable = command[0];
191
+ const args = command.slice(1);
192
+
193
+ /** @type {AbortController | undefined} */
194
+ let timeoutController;
195
+ /** @type {NodeJS.Timeout | undefined} */
196
+ let timeoutHandle;
197
+
198
+ if (timeoutMs && timeoutMs > 0) {
199
+ timeoutController = new AbortController();
200
+ timeoutHandle = setTimeout(() => timeoutController.abort(), timeoutMs);
201
+ }
202
+
203
+ try {
204
+ const { stdout, stderr } = await execFileAsync(executable, args, {
205
+ cwd: resolvedCwd,
206
+ env,
207
+ shell: false,
208
+ maxBuffer: 10 * 1024 * 1024, // 10 MiB
209
+ signal: timeoutController?.signal,
210
+ });
211
+
212
+ return { exitCode: 0, stdout: stdout ?? '', stderr: stderr ?? '' };
213
+ } catch (err) {
214
+ // Two timeout indicators:
215
+ // 1. AbortError — direct result of AbortController.abort()
216
+ // 2. err.killed && err.signal === 'SIGTERM' — child was killed
217
+ if (err.name === 'AbortError' || (err.killed && err.signal === 'SIGTERM')) {
218
+ throw new ReleaseError(
219
+ 'HOOK_TIMEOUT',
220
+ `hook timed out after ${timeoutMs}ms: ${executable} ${args.join(' ')}`,
221
+ { command, timeoutMs },
222
+ );
223
+ }
224
+
225
+ // Non-zero exit code: return the result so callers can inspect exitCode.
226
+ if ('stdout' in err) {
227
+ return {
228
+ exitCode: typeof err.code === 'number' ? err.code : 1,
229
+ stdout: err.stdout ?? '',
230
+ stderr: err.stderr ?? '',
231
+ };
232
+ }
233
+
234
+ // Unexpected errors bubble up.
235
+ throw err;
236
+ } finally {
237
+ if (timeoutHandle) {
238
+ clearTimeout(timeoutHandle);
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Node.js 版本解析、最低版本检查和 readiness 状态纯函数。
3
+ *
4
+ * @module core/node-version
5
+ */
6
+
7
+ /**
8
+ * 解析 Node.js --version 输出(如 "v24.0.0"),返回主版本号。
9
+ * @param {string} versionString
10
+ * @returns {number|null} major version number, or null if unparseable
11
+ */
12
+ export function parseNodeMajor(versionString) {
13
+ if (!versionString || typeof versionString !== 'string') return null;
14
+ const match = versionString.trim().match(/^v(\d+)\./);
15
+ return match ? parseInt(match[1], 10) : null;
16
+ }
17
+
18
+ /**
19
+ * 判断给定的 Node.js 主版本号是否满足最低要求。
20
+ * @param {number|null} major
21
+ * @param {number} minimum
22
+ * @returns {boolean}
23
+ */
24
+ export function meetsMinimum(major, minimum = 22) {
25
+ if (major == null || typeof major !== 'number') return false;
26
+ return major >= minimum;
27
+ }
28
+
29
+ /**
30
+ * 计算环境就绪状态。
31
+ *
32
+ * Required(不满足 → NOT_READY):
33
+ * - Node.js >= 22
34
+ * - Git
35
+ *
36
+ * Optional(不影响 READY/NOT_READY):
37
+ * - pnpm / npm / gh
38
+ *
39
+ * @param {object} checks - 环境检查结果(来自 performEnvironmentChecks)
40
+ * @param {boolean} checks.nodeAvailable - Node.js 是否可用
41
+ * @param {boolean} checks.nodeMeetsMinimum - Node.js 版本 >= 22
42
+ * @param {boolean} checks.gitAvailable - Git 是否可用
43
+ * @returns {{ status: 'READY'|'NOT_READY', requiredMet: boolean, missingRequired: string[] }}
44
+ */
45
+ export function computeReadinessStatus(checks) {
46
+ const missingRequired = [];
47
+
48
+ if (!checks.nodeAvailable) {
49
+ missingRequired.push('node');
50
+ } else if (!checks.nodeMeetsMinimum) {
51
+ missingRequired.push('node>=22');
52
+ }
53
+
54
+ if (!checks.gitAvailable) {
55
+ missingRequired.push('git');
56
+ }
57
+
58
+ const requiredMet = missingRequired.length === 0;
59
+ return {
60
+ status: requiredMet ? 'READY' : 'NOT_READY',
61
+ requiredMet,
62
+ missingRequired,
63
+ };
64
+ }