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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact plan assembly, digest computation, and atomic protocol write.
|
|
3
|
+
*
|
|
4
|
+
* The plan is a content-addressed document that binds every immutable input
|
|
5
|
+
* (repository identity, policy digest, base/current/producer manifest digests)
|
|
6
|
+
* to a single `nextAction` recommendation.
|
|
7
|
+
*
|
|
8
|
+
* Plans are written only to:
|
|
9
|
+
* - `.release-skill/runs/<run-id>/artifact-plan.json` (protocol run directory)
|
|
10
|
+
* - User-specified `--output` path
|
|
11
|
+
*
|
|
12
|
+
* Writing uses a temporary file + fsync + rename to ensure atomicity.
|
|
13
|
+
* No inventory target is ever written by this module.
|
|
14
|
+
*
|
|
15
|
+
* @module artifacts/artifact-plan
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { writeFile, mkdir, readFile, rename, open } from 'node:fs/promises';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public API
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Assemble a canonical artifact plan from classified inputs.
|
|
29
|
+
*
|
|
30
|
+
* The plan covers:
|
|
31
|
+
* - `apiVersion`: fixed contract version
|
|
32
|
+
* - `operation`: 'inspect' | 'init' | 'status'
|
|
33
|
+
* - `bindings`: all immutable input digests
|
|
34
|
+
* - `artifacts`: per-artifact decision array
|
|
35
|
+
* - `safeToWrite`: true only when every artifact is safe
|
|
36
|
+
* - `targetUnchanged`: always true for read-only operations
|
|
37
|
+
* - `nextAction`: a single recommended command
|
|
38
|
+
* - `planDigest`: `sha256:<hex>` of the canonical plan
|
|
39
|
+
*
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {'inspect'|'init'|'status'} options.operation - Operation type.
|
|
42
|
+
* @param {object} options.bindings - Immutable input bindings.
|
|
43
|
+
* @param {string} options.bindings.repositoryIdentity - Repository identity hash.
|
|
44
|
+
* @param {string} options.bindings.policyDigest - Policy digest.
|
|
45
|
+
* @param {string} options.bindings.baseManifestDigest - Base manifest digest.
|
|
46
|
+
* @param {string} options.bindings.currentManifestDigest - Current manifest digest.
|
|
47
|
+
* @param {string} options.bindings.producerClosureDigest - Producer closure digest.
|
|
48
|
+
* @param {Array<object>} options.artifacts - Artifact decisions.
|
|
49
|
+
* @param {boolean} options.safeToWrite - Whether all artifacts are safe to write.
|
|
50
|
+
* @param {boolean} options.targetUnchanged - Whether targets are unchanged.
|
|
51
|
+
* @returns {object} Frozen artifact plan with planDigest.
|
|
52
|
+
*/
|
|
53
|
+
export function assemblePlan({
|
|
54
|
+
operation,
|
|
55
|
+
bindings,
|
|
56
|
+
artifacts = [],
|
|
57
|
+
safeToWrite = false,
|
|
58
|
+
targetUnchanged = true,
|
|
59
|
+
} = {}) {
|
|
60
|
+
const nextAction = chooseNextAction(operation, artifacts, safeToWrite);
|
|
61
|
+
|
|
62
|
+
const plan = {
|
|
63
|
+
apiVersion: 'release-skill.dev/artifact-plan/v1',
|
|
64
|
+
operation,
|
|
65
|
+
bindings: Object.freeze({ ...bindings }),
|
|
66
|
+
artifacts: Object.freeze(artifacts.map((a) => Object.freeze({ ...a }))),
|
|
67
|
+
safeToWrite,
|
|
68
|
+
targetUnchanged,
|
|
69
|
+
nextAction,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Compute planDigest over the canonical form (without the digest field itself)
|
|
73
|
+
const canonical = canonicalJson(plan);
|
|
74
|
+
plan.planDigest = `sha256:${sha256Hex(canonical)}`;
|
|
75
|
+
|
|
76
|
+
return Object.freeze(plan);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Write a plan atomically to the specified path.
|
|
81
|
+
*
|
|
82
|
+
* Uses temporary file + fsync + rename for atomicity.
|
|
83
|
+
* Creates parent directories if needed.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} plan - Frozen artifact plan.
|
|
86
|
+
* @param {string} outputPath - Absolute path to write the plan.
|
|
87
|
+
*/
|
|
88
|
+
export async function writePlan(plan, outputPath) {
|
|
89
|
+
const dir = dirname(outputPath);
|
|
90
|
+
await mkdir(dir, { recursive: true });
|
|
91
|
+
|
|
92
|
+
const tmpPath = `${outputPath}.tmp`;
|
|
93
|
+
const content = JSON.stringify(plan, null, 2);
|
|
94
|
+
|
|
95
|
+
// Write to temp file with fsync
|
|
96
|
+
const fh = await open(tmpPath, 'w');
|
|
97
|
+
try {
|
|
98
|
+
await writeFile(fh, content, 'utf8');
|
|
99
|
+
await fh.sync();
|
|
100
|
+
} finally {
|
|
101
|
+
await fh.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Atomic rename
|
|
105
|
+
await rename(tmpPath, outputPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Internal: nextAction selection
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Choose a single nextAction for the plan.
|
|
114
|
+
*
|
|
115
|
+
* The nextAction is always a single object with a `command` field.
|
|
116
|
+
* Priority:
|
|
117
|
+
* 1. If any artifact has status that blocks writes → `artifacts adopt`
|
|
118
|
+
* 2. If any artifact needs acceptance → `artifacts accept`
|
|
119
|
+
* 3. Otherwise → `artifacts apply` (all clean/mergeable)
|
|
120
|
+
*
|
|
121
|
+
* @param {string} operation - The plan operation type.
|
|
122
|
+
* @param {Array<object>} artifacts - Artifact decisions.
|
|
123
|
+
* @param {boolean} safeToWrite - Whether all artifacts are safe to write.
|
|
124
|
+
* @returns {{ command: string }}
|
|
125
|
+
*/
|
|
126
|
+
function chooseNextAction(operation, artifacts, safeToWrite) {
|
|
127
|
+
// Statuses that require adoption
|
|
128
|
+
const BLOCKING = new Set([
|
|
129
|
+
'ADOPTION_REQUIRED', 'CONFLICT', 'BASE_UNAVAILABLE',
|
|
130
|
+
'POLICY_INVALID', 'POLICY_CHANGE_PENDING',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// Statuses that need acceptance
|
|
134
|
+
const NEEDS_ACCEPT = new Set(['GENERATOR_CHANGED', 'MERGEABLE']);
|
|
135
|
+
|
|
136
|
+
if (operation === 'init') {
|
|
137
|
+
// Init always recommends adopt with bootstrap-plan
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
command: 'artifacts adopt --bootstrap-plan',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!safeToWrite) {
|
|
144
|
+
// Find the highest-priority blocking status
|
|
145
|
+
const blocking = artifacts.filter((a) => BLOCKING.has(a.status));
|
|
146
|
+
if (blocking.length > 0) {
|
|
147
|
+
return Object.freeze({
|
|
148
|
+
command: 'artifacts adopt --plan',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (artifacts.some((a) => NEEDS_ACCEPT.has(a.status))) {
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
command: 'artifacts accept --plan',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
command: 'artifacts apply --plan',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact entry reader and manifest digest.
|
|
3
|
+
*
|
|
4
|
+
* Reads a single artifact entry from the working tree or git object store.
|
|
5
|
+
* Entry kinds: 'absent' | 'regular' | 'tree'.
|
|
6
|
+
*
|
|
7
|
+
* Tree entries carry a manifestDigest computed from a canonical subset of
|
|
8
|
+
* entry fields (path, type, mode, size, sha256) to enable deterministic
|
|
9
|
+
* comparison without materialising full content.
|
|
10
|
+
*
|
|
11
|
+
* @module artifacts/entry
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
19
|
+
import { ReleaseError, PATH_UNSAFE } from '../core/errors.mjs';
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Paths to skip during recursive directory enumeration. */
|
|
28
|
+
const SKIP_DIRS = new Set(['.git']);
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Internal helpers
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert a Node.js `stat.mode` (decimal) to a git-style octal mode string.
|
|
36
|
+
*
|
|
37
|
+
* Git uses a restricted subset of POSIX modes:
|
|
38
|
+
* - '100644' — regular file
|
|
39
|
+
* - '100755' — executable file
|
|
40
|
+
* - '040000' — tree (directory)
|
|
41
|
+
* - '120000' — symlink
|
|
42
|
+
* - '160000' — gitlink (submodule)
|
|
43
|
+
*
|
|
44
|
+
* For files, group-write is stripped to produce '100644' unless the
|
|
45
|
+
* executable bit is set, in which case '100755' is returned.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('node:fs').Stats} stat
|
|
48
|
+
* @returns {string} Git octal mode string.
|
|
49
|
+
*/
|
|
50
|
+
function statToGitMode(stat) {
|
|
51
|
+
if (stat.isDirectory()) return '040000';
|
|
52
|
+
if (stat.isSymbolicLink()) return '120000';
|
|
53
|
+
// Regular file: check executable bit (owner + group + other)
|
|
54
|
+
const mode = stat.mode & 0o777;
|
|
55
|
+
const executable = (mode & 0o111) !== 0;
|
|
56
|
+
return executable ? '100755' : '100644';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compute the git blob object ID (SHA-1) for a file's content.
|
|
61
|
+
*
|
|
62
|
+
* Uses `git hash-object` without `-w` so no objects are written to the
|
|
63
|
+
* repository's object store.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} root - Repository root (cwd for git).
|
|
66
|
+
* @param {string} absPath - Absolute path to the file.
|
|
67
|
+
* @returns {Promise<string>} 40-character hex SHA-1.
|
|
68
|
+
*/
|
|
69
|
+
async function gitHashObject(root, absPath) {
|
|
70
|
+
const { stdout } = await execFileAsync(
|
|
71
|
+
'git',
|
|
72
|
+
['hash-object', absPath],
|
|
73
|
+
{ cwd: root, shell: false },
|
|
74
|
+
);
|
|
75
|
+
return stdout.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Recursively enumerate files in a directory, returning entry metadata.
|
|
80
|
+
*
|
|
81
|
+
* Directories and files under `.git/` are skipped. Symbolic links and
|
|
82
|
+
* hardlinks (nlink > 1) cause an immediate PATH_UNSAFE error (v1 policy).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} root - Repository root (for git hash-object).
|
|
85
|
+
* @param {string} dirPath - Absolute path to the directory being enumerated.
|
|
86
|
+
* @param {string} relBase - Relative path prefix for entries within this tree.
|
|
87
|
+
* @returns {Promise<Array<{path:string, type:string, mode:string, gitOid:string, sha256:string, size:number}>>}
|
|
88
|
+
*/
|
|
89
|
+
async function enumerateTreeEntries(root, dirPath, relBase) {
|
|
90
|
+
const entries = [];
|
|
91
|
+
const items = await readdir(dirPath, { withFileTypes: true });
|
|
92
|
+
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
if (SKIP_DIRS.has(item.name)) continue;
|
|
95
|
+
|
|
96
|
+
const absPath = join(dirPath, item.name);
|
|
97
|
+
const relPath = relBase ? `${relBase}/${item.name}` : item.name;
|
|
98
|
+
const st = await lstat(absPath);
|
|
99
|
+
|
|
100
|
+
if (st.isSymbolicLink()) {
|
|
101
|
+
throw new ReleaseError(
|
|
102
|
+
PATH_UNSAFE,
|
|
103
|
+
`symlink encountered in tree: ${relPath}`,
|
|
104
|
+
{ path: relPath },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!st.isDirectory() && st.nlink > 1) {
|
|
109
|
+
throw new ReleaseError(
|
|
110
|
+
PATH_UNSAFE,
|
|
111
|
+
`hardlink detected (nlink=${st.nlink}): ${relPath}`,
|
|
112
|
+
{ path: relPath, nlink: st.nlink },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (st.isDirectory()) {
|
|
117
|
+
const subEntries = await enumerateTreeEntries(root, absPath, relPath);
|
|
118
|
+
entries.push(...subEntries);
|
|
119
|
+
} else {
|
|
120
|
+
const content = await readFile(absPath);
|
|
121
|
+
entries.push(
|
|
122
|
+
Object.freeze({
|
|
123
|
+
path: relPath,
|
|
124
|
+
type: 'blob',
|
|
125
|
+
mode: statToGitMode(st),
|
|
126
|
+
gitOid: await gitHashObject(root, absPath),
|
|
127
|
+
sha256: sha256Hex(content),
|
|
128
|
+
size: st.size,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sort by path for deterministic ordering
|
|
135
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
136
|
+
return entries;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Manifest digest
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compute a deterministic manifest digest for a set of tree entries.
|
|
145
|
+
*
|
|
146
|
+
* The digest covers path, type, mode, size, and sha256 of each entry
|
|
147
|
+
* (gitOid is excluded because it depends on the git hash algorithm, which
|
|
148
|
+
* is SHA-1 in current git and not guaranteed stable across implementations).
|
|
149
|
+
*
|
|
150
|
+
* @param {Array<{path:string, type:string, mode:string, sha256:string, size:number}>} entries
|
|
151
|
+
* @returns {string} `sha256:<hex>` digest.
|
|
152
|
+
*/
|
|
153
|
+
export function digestEntryManifest(entries) {
|
|
154
|
+
const canonical = entries.map(({ path, type, mode, sha256, size }) => ({
|
|
155
|
+
path, type, mode, size, sha256,
|
|
156
|
+
}));
|
|
157
|
+
return `sha256:${sha256Hex(canonicalJson(canonical))}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Public API
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read a single artifact entry from the working tree.
|
|
166
|
+
*
|
|
167
|
+
* Entry kinds:
|
|
168
|
+
* - `{ kind: 'absent' }` — path does not exist on disk.
|
|
169
|
+
* - `{ kind: 'regular', path, type, mode, gitOid, sha256, size }` — a single file.
|
|
170
|
+
* - `{ kind: 'tree', entries, manifestDigest }` — a directory; `entries` is
|
|
171
|
+
* the recursive enumeration of all files within it.
|
|
172
|
+
*
|
|
173
|
+
* Symbolic links and hardlinks (nlink > 1) are rejected with PATH_UNSAFE
|
|
174
|
+
* (v1 policy: writable/publish entries only allow absent, regular file, tree).
|
|
175
|
+
*
|
|
176
|
+
* @param {object} options
|
|
177
|
+
* @param {string} options.root - Repository root (absolute).
|
|
178
|
+
* @param {string} options.path - Relative POSIX path to the entry.
|
|
179
|
+
* @param {'worktree'} [options.source='worktree'] - Read source.
|
|
180
|
+
* @returns {Promise<ArtifactEntry>}
|
|
181
|
+
* @throws {ReleaseError} PATH_UNSAFE on symlink or hardlink.
|
|
182
|
+
*/
|
|
183
|
+
export async function readEntry({ root, path, source = 'worktree' } = {}) {
|
|
184
|
+
if (source !== 'worktree') {
|
|
185
|
+
throw new ReleaseError(PATH_UNSAFE, `unsupported readEntry source: ${source}`, { source });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const absPath = join(root, path);
|
|
189
|
+
|
|
190
|
+
let st;
|
|
191
|
+
try {
|
|
192
|
+
st = await lstat(absPath);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
|
|
195
|
+
return Object.freeze({ kind: 'absent' });
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Symlinks are always unsafe (v1)
|
|
201
|
+
if (st.isSymbolicLink()) {
|
|
202
|
+
throw new ReleaseError(
|
|
203
|
+
PATH_UNSAFE,
|
|
204
|
+
`symlink encountered: ${path}`,
|
|
205
|
+
{ path },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Directory → tree entry with recursive manifest
|
|
210
|
+
if (st.isDirectory()) {
|
|
211
|
+
const entries = await enumerateTreeEntries(root, absPath, path);
|
|
212
|
+
const manifestDigest = digestEntryManifest(entries);
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
kind: 'tree',
|
|
215
|
+
entries: Object.freeze(entries),
|
|
216
|
+
manifestDigest,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Hardlink detection (v1: reject nlink > 1)
|
|
221
|
+
if (st.nlink > 1) {
|
|
222
|
+
throw new ReleaseError(
|
|
223
|
+
PATH_UNSAFE,
|
|
224
|
+
`hardlink detected (nlink=${st.nlink}): ${path}`,
|
|
225
|
+
{ path, nlink: st.nlink },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Regular file
|
|
230
|
+
const content = await readFile(absPath);
|
|
231
|
+
return Object.freeze({
|
|
232
|
+
kind: 'regular',
|
|
233
|
+
path,
|
|
234
|
+
type: 'blob',
|
|
235
|
+
mode: statToGitMode(st),
|
|
236
|
+
gitOid: await gitHashObject(root, absPath),
|
|
237
|
+
sha256: sha256Hex(content),
|
|
238
|
+
size: st.size,
|
|
239
|
+
});
|
|
240
|
+
}
|