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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|