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,732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared project lock for artifact commands.
|
|
3
|
+
*
|
|
4
|
+
* All mutating artifact commands (apply, accept, recover, resolve submit,
|
|
5
|
+
* prepare) share a single project lock domain. The lock is acquired via
|
|
6
|
+
* exclusive `mkdir(.release-skill/lock)` — atomic on all POSIX filesystems.
|
|
7
|
+
*
|
|
8
|
+
* Owner record contains: pid, host, bootId (or session id), nonce, command,
|
|
9
|
+
* startedAt. The owner JSON and parent directory are fsynced before the
|
|
10
|
+
* acquire call returns success.
|
|
11
|
+
*
|
|
12
|
+
* TTL is informational only — aging a lock directory never permits automatic
|
|
13
|
+
* deletion. Only the exact owner can release, or an operator can break the
|
|
14
|
+
* lock with `breakProjectLock` which requires matching the exact owner and
|
|
15
|
+
* writes audit evidence.
|
|
16
|
+
*
|
|
17
|
+
* @module artifacts/project-lock
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdir, rm, writeFile, readFile, readdir, stat, lstat, open } from 'node:fs/promises';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { hostname } from 'node:os';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
ReleaseError,
|
|
28
|
+
TRANSACTION_INCOMPLETE,
|
|
29
|
+
PATH_UNSAFE,
|
|
30
|
+
} from '../core/errors.mjs';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Constants
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const LOCK_DIR_NAME = 'lock';
|
|
37
|
+
const OWNER_FILE_NAME = '.owner';
|
|
38
|
+
const AUDIT_DIR_NAME = 'lock-audit';
|
|
39
|
+
|
|
40
|
+
/** All owner fields that must match exactly. */
|
|
41
|
+
const OWNER_FIELDS = ['pid', 'host', 'bootId', 'nonce', 'command', 'startedAt'];
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Owner construction
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build an owner record for the current process.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} command - The command acquiring the lock.
|
|
51
|
+
* @param {() => string} [clock] - Clock function for timestamps.
|
|
52
|
+
* @returns {object} Frozen owner record.
|
|
53
|
+
*/
|
|
54
|
+
function buildOwner(command, clock) {
|
|
55
|
+
const startedAt = clock ? clock() : new Date().toISOString();
|
|
56
|
+
assertIsoTimestamp(startedAt, 'clock result');
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
pid: process.pid,
|
|
59
|
+
host: hostname(),
|
|
60
|
+
bootId: getBootId(),
|
|
61
|
+
nonce: randomBytes(16).toString('hex'),
|
|
62
|
+
command,
|
|
63
|
+
startedAt,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get a boot or session identifier.
|
|
69
|
+
*
|
|
70
|
+
* On Linux, reads /proc/sys/kernel/random/boot_id. On other platforms,
|
|
71
|
+
* falls back to a process-lifetime constant derived from process.pid +
|
|
72
|
+
* start time.
|
|
73
|
+
*
|
|
74
|
+
* @returns {string} Boot/session identifier.
|
|
75
|
+
*/
|
|
76
|
+
function getBootId() {
|
|
77
|
+
try {
|
|
78
|
+
// Linux: stable across reboots
|
|
79
|
+
return readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
80
|
+
} catch {
|
|
81
|
+
// Fallback: pid + uptime at module load time (stable within process)
|
|
82
|
+
return `pid-${process.pid}-uptime-${Math.floor(process.uptime())}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Internal: path helpers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
function lockDir(root) {
|
|
91
|
+
return join(root, '.release-skill', LOCK_DIR_NAME);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ownerPath(root) {
|
|
95
|
+
return join(lockDir(root), OWNER_FILE_NAME);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function auditDir(root) {
|
|
99
|
+
return join(root, '.release-skill', AUDIT_DIR_NAME);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Internal: owner validation
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate that an expectedOwner object is structurally sound:
|
|
108
|
+
* - Must be a plain object (not array, not null)
|
|
109
|
+
* - Must have exactly the 6 required fields (no extra, no missing)
|
|
110
|
+
* - pid must be a positive integer
|
|
111
|
+
* - All other fields must be non-empty strings
|
|
112
|
+
* - String fields must not contain control characters
|
|
113
|
+
*
|
|
114
|
+
* @param {object} owner - The owner object to validate.
|
|
115
|
+
* @throws {ReleaseError} PATH_UNSAFE on any violation.
|
|
116
|
+
*/
|
|
117
|
+
function validateOwnerObject(owner) {
|
|
118
|
+
if (!owner || typeof owner !== 'object' || Array.isArray(owner)) {
|
|
119
|
+
throw new ReleaseError(PATH_UNSAFE, 'expectedOwner must be a plain object', {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const keys = Object.keys(owner);
|
|
123
|
+
const expectedSet = new Set(OWNER_FIELDS);
|
|
124
|
+
const actualSet = new Set(keys);
|
|
125
|
+
|
|
126
|
+
if (keys.length !== OWNER_FIELDS.length) {
|
|
127
|
+
throw new ReleaseError(
|
|
128
|
+
PATH_UNSAFE,
|
|
129
|
+
`expectedOwner must have exactly ${OWNER_FIELDS.length} fields; got ${keys.length}`,
|
|
130
|
+
{},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const field of OWNER_FIELDS) {
|
|
135
|
+
if (!actualSet.has(field)) {
|
|
136
|
+
throw new ReleaseError(PATH_UNSAFE, `expectedOwner missing required field: ${field}`, {});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
if (!expectedSet.has(key)) {
|
|
142
|
+
throw new ReleaseError(PATH_UNSAFE, `expectedOwner has unexpected field: ${key}`, {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Type validation
|
|
147
|
+
if (typeof owner.pid !== 'number' || !Number.isInteger(owner.pid) || owner.pid <= 0) {
|
|
148
|
+
throw new ReleaseError(PATH_UNSAFE, 'expectedOwner.pid must be a positive integer', {});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const stringFields = OWNER_FIELDS.filter((f) => f !== 'pid');
|
|
152
|
+
for (const field of stringFields) {
|
|
153
|
+
if (typeof owner[field] !== 'string' || owner[field].trim().length === 0) {
|
|
154
|
+
throw new ReleaseError(PATH_UNSAFE, `expectedOwner.${field} must be a non-empty string`, {});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Control character check on all string fields
|
|
159
|
+
for (const field of stringFields) {
|
|
160
|
+
if (/[\x00-\x1f\x7f]/.test(owner[field])) {
|
|
161
|
+
throw new ReleaseError(PATH_UNSAFE, `expectedOwner.${field} contains control characters`, {});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// nonce becomes part of an audit filename, so accept only the format
|
|
166
|
+
// produced by buildOwner(). This excludes separators, dot segments and
|
|
167
|
+
// platform-specific path syntax by construction.
|
|
168
|
+
if (!/^[a-f0-9]{32}$/.test(owner.nonce)) {
|
|
169
|
+
throw new ReleaseError(
|
|
170
|
+
PATH_UNSAFE,
|
|
171
|
+
'expectedOwner.nonce must be exactly 32 lowercase hexadecimal characters',
|
|
172
|
+
{},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (!/^[A-Za-z0-9._:-]+$/.test(owner.host) || !/^[A-Za-z0-9._:-]+$/.test(owner.bootId)) {
|
|
176
|
+
throw new ReleaseError(PATH_UNSAFE, 'expectedOwner host/bootId contains unsafe characters', {});
|
|
177
|
+
}
|
|
178
|
+
if (sanitizeReason(owner.command) !== owner.command) {
|
|
179
|
+
throw new ReleaseError(PATH_UNSAFE, 'expectedOwner.command must not contain absolute paths', {});
|
|
180
|
+
}
|
|
181
|
+
assertIsoTimestamp(owner.startedAt, 'expectedOwner.startedAt');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function assertIsoTimestamp(value, label) {
|
|
185
|
+
if (
|
|
186
|
+
typeof value !== 'string'
|
|
187
|
+
|| !Number.isFinite(Date.parse(value))
|
|
188
|
+
|| new Date(value).toISOString() !== value
|
|
189
|
+
) {
|
|
190
|
+
throw new ReleaseError(PATH_UNSAFE, `${label} must be a canonical ISO-8601 timestamp`, {});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Internal: reason sanitization
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sanitize a reason string for audit records.
|
|
200
|
+
*
|
|
201
|
+
* If the reason contains absolute paths (e.g. /Users/..., /home/...),
|
|
202
|
+
* they are deterministically replaced with path-agnostic placeholders.
|
|
203
|
+
* This prevents leaking local filesystem layout into audit files.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} reason - Raw reason text.
|
|
206
|
+
* @returns {string} Sanitized reason text.
|
|
207
|
+
*/
|
|
208
|
+
function sanitizeReason(reason) {
|
|
209
|
+
return reason
|
|
210
|
+
.replace(/\/Users\/[^\s,;:'")\]]+/g, '<user-path>')
|
|
211
|
+
.replace(/\/home\/[^\s,;:'")\]]+/g, '<user-path>')
|
|
212
|
+
.replace(/\/tmp\/[^\s,;:'")\]]+/g, '<temp-path>')
|
|
213
|
+
.replace(/(^|[\s("'=])\/(?!\/)[^\s,;:'")\]]+/g, '$1<absolute-path>')
|
|
214
|
+
.replace(/(^|[\s("'=])(?:[A-Za-z]:[\\/]|\\\\)[^\s,;:'")\]]+/g, '$1<absolute-path>');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function emitDurability(observer, event) {
|
|
218
|
+
if (!observer) return;
|
|
219
|
+
try {
|
|
220
|
+
await observer(Object.freeze(event));
|
|
221
|
+
} catch {
|
|
222
|
+
// Observation must never weaken, skip or fail a durability operation.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function fsyncFileObserved(filePath, observer) {
|
|
227
|
+
await fsyncFile(filePath);
|
|
228
|
+
await emitDurability(observer, { operation: 'fsync-file', path: filePath });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function fsyncDirObserved(dirPath, observer) {
|
|
232
|
+
await fsyncDir(dirPath);
|
|
233
|
+
await emitDurability(observer, { operation: 'fsync-dir', path: dirPath });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Internal: assert owner matches on disk
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the persisted owner from disk and compare with the expected owner.
|
|
242
|
+
* All six fields (pid, host, bootId, nonce, command, startedAt) must match
|
|
243
|
+
* exactly.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} expected - The expected owner record.
|
|
246
|
+
* @param {string} root - Repository root.
|
|
247
|
+
* @returns {Promise<void>}
|
|
248
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch or missing.
|
|
249
|
+
*/
|
|
250
|
+
async function assertOwnerOnDisk(expected, root) {
|
|
251
|
+
let raw;
|
|
252
|
+
try {
|
|
253
|
+
raw = await readFile(ownerPath(root), 'utf8');
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (err.code === 'ENOENT') {
|
|
256
|
+
throw new ReleaseError(
|
|
257
|
+
TRANSACTION_INCOMPLETE,
|
|
258
|
+
'project lock directory does not exist — ownership lost',
|
|
259
|
+
{ root },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let actual;
|
|
266
|
+
try {
|
|
267
|
+
actual = JSON.parse(raw);
|
|
268
|
+
} catch {
|
|
269
|
+
throw new ReleaseError(
|
|
270
|
+
TRANSACTION_INCOMPLETE,
|
|
271
|
+
'project lock owner file is corrupt',
|
|
272
|
+
{ root },
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const field of OWNER_FIELDS) {
|
|
277
|
+
if (actual[field] !== expected[field]) {
|
|
278
|
+
throw new ReleaseError(
|
|
279
|
+
TRANSACTION_INCOMPLETE,
|
|
280
|
+
`project lock owner does not match — field "${field}" differs`,
|
|
281
|
+
{ root, field, expected: field === 'nonce' ? expected[field]?.slice(0, 8) : undefined },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Remove the lock directory only if the persisted owner matches exactly.
|
|
289
|
+
* After removal, fsync the parent .release-skill directory for durability.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} expected - The expected owner record.
|
|
292
|
+
* @param {string} root - Repository root.
|
|
293
|
+
* @param {(event: object) => Promise<void>} [durabilityObserver] - Observe completed durability operations.
|
|
294
|
+
* @returns {Promise<void>}
|
|
295
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch.
|
|
296
|
+
*/
|
|
297
|
+
async function removeLockIfExactOwner(expected, root, durabilityObserver) {
|
|
298
|
+
await assertOwnerOnDisk(expected, root);
|
|
299
|
+
await rm(lockDir(root), { recursive: true, force: true });
|
|
300
|
+
await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockDir(root) });
|
|
301
|
+
// Fsync parent directory to persist the lock removal
|
|
302
|
+
await fsyncDirObserved(join(root, '.release-skill'), durabilityObserver);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Internal: fsync helpers
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Fsync a file by path — opens, syncs, closes.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} filePath
|
|
313
|
+
*/
|
|
314
|
+
async function fsyncFile(filePath) {
|
|
315
|
+
const fh = await open(filePath, 'r');
|
|
316
|
+
try {
|
|
317
|
+
await fh.sync();
|
|
318
|
+
} finally {
|
|
319
|
+
await fh.close();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Fsync a directory by path.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} dirPath
|
|
327
|
+
*/
|
|
328
|
+
async function fsyncDir(dirPath) {
|
|
329
|
+
const fh = await open(dirPath, 'r');
|
|
330
|
+
try {
|
|
331
|
+
await fh.sync();
|
|
332
|
+
} finally {
|
|
333
|
+
await fh.close();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Internal: symlink/non-directory fail-closed checks
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Assert that a path is not a symlink and, if it exists, is a directory.
|
|
343
|
+
* Fails closed with PATH_UNSAFE on any violation.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} dirPath - Path to check.
|
|
346
|
+
* @param {string} label - Human label for error messages.
|
|
347
|
+
* @returns {Promise<void>}
|
|
348
|
+
* @throws {ReleaseError} PATH_UNSAFE if symlink or non-directory.
|
|
349
|
+
*/
|
|
350
|
+
async function assertNotSymlinkOrFile(dirPath, label) {
|
|
351
|
+
let st;
|
|
352
|
+
try {
|
|
353
|
+
st = await lstat(dirPath);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (err.code === 'ENOENT') return; // doesn't exist yet — OK
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
if (st.isSymbolicLink()) {
|
|
359
|
+
throw new ReleaseError(
|
|
360
|
+
PATH_UNSAFE,
|
|
361
|
+
`${label} is a symlink — refusing to operate on symlinked path`,
|
|
362
|
+
{ path: dirPath },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (!st.isDirectory()) {
|
|
366
|
+
throw new ReleaseError(
|
|
367
|
+
PATH_UNSAFE,
|
|
368
|
+
`${label} exists but is not a directory`,
|
|
369
|
+
{ path: dirPath },
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Assert that the .release-skill and lock directories are not symlinks.
|
|
376
|
+
* Checks every level: .release-skill, .release-skill/lock, .release-skill/lock-audit.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} root - Repository root.
|
|
379
|
+
* @returns {Promise<void>}
|
|
380
|
+
*/
|
|
381
|
+
async function assertLockPathsNotSymlinks(root) {
|
|
382
|
+
const releaseSkillDir = join(root, '.release-skill');
|
|
383
|
+
await assertNotSymlinkOrFile(releaseSkillDir, '.release-skill');
|
|
384
|
+
await assertNotSymlinkOrFile(lockDir(root), '.release-skill/lock');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Assert that the audit directory is not a symlink.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} root - Repository root.
|
|
391
|
+
* @returns {Promise<void>}
|
|
392
|
+
*/
|
|
393
|
+
async function assertAuditPathNotSymlink(root) {
|
|
394
|
+
const releaseSkillDir = join(root, '.release-skill');
|
|
395
|
+
await assertNotSymlinkOrFile(releaseSkillDir, '.release-skill');
|
|
396
|
+
await assertNotSymlinkOrFile(auditDir(root), '.release-skill/lock-audit');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Public API
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Acquire the project lock.
|
|
405
|
+
*
|
|
406
|
+
* Uses exclusive `mkdir` to atomically claim the lock. The owner record
|
|
407
|
+
* is written to `.owner` and fsynced before returning.
|
|
408
|
+
*
|
|
409
|
+
* If the lock is already held, throws `TRANSACTION_INCOMPLETE` — there is
|
|
410
|
+
* no automatic stale lock breakage based on TTL.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} options
|
|
413
|
+
* @param {string} options.root - Repository root (absolute).
|
|
414
|
+
* @param {string} options.command - The command acquiring the lock (e.g. 'apply', 'accept').
|
|
415
|
+
* @param {'exclusive'} [options.mode='exclusive'] - Lock mode (currently only exclusive).
|
|
416
|
+
* @param {() => string} [options.clock] - Clock function for timestamps.
|
|
417
|
+
* @param {(event: object) => Promise<void>} [options.durabilityObserver] - Best-effort observer; cannot replace or interrupt fsync.
|
|
418
|
+
* @param {(point: string) => Promise<void>} [options.faultInjector] - Test-only safe failure injection.
|
|
419
|
+
* @returns {Promise<ProjectLock>}
|
|
420
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if lock is already held.
|
|
421
|
+
*/
|
|
422
|
+
export async function acquireProjectLock({
|
|
423
|
+
root,
|
|
424
|
+
command,
|
|
425
|
+
mode = 'exclusive',
|
|
426
|
+
clock,
|
|
427
|
+
durabilityObserver,
|
|
428
|
+
faultInjector,
|
|
429
|
+
} = {}) {
|
|
430
|
+
if (!root || typeof root !== 'string') {
|
|
431
|
+
throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string', { root });
|
|
432
|
+
}
|
|
433
|
+
if (!command || typeof command !== 'string' || command.trim().length === 0) {
|
|
434
|
+
throw new ReleaseError(PATH_UNSAFE, 'command must be a non-empty string', { command });
|
|
435
|
+
}
|
|
436
|
+
if (/[\x00-\x1f\x7f]/.test(command)) {
|
|
437
|
+
throw new ReleaseError(PATH_UNSAFE, 'command contains control characters', {});
|
|
438
|
+
}
|
|
439
|
+
if (sanitizeReason(command) !== command) {
|
|
440
|
+
throw new ReleaseError(PATH_UNSAFE, 'command must not contain absolute paths', {});
|
|
441
|
+
}
|
|
442
|
+
if (mode !== 'exclusive') {
|
|
443
|
+
throw new ReleaseError(
|
|
444
|
+
PATH_UNSAFE,
|
|
445
|
+
`lock mode must be "exclusive"; "${mode}" is not supported`,
|
|
446
|
+
{ mode },
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Construct and validate the owner before touching the filesystem so a bad
|
|
451
|
+
// injected clock cannot leave a directory without an owner.
|
|
452
|
+
const owner = buildOwner(command, clock);
|
|
453
|
+
|
|
454
|
+
// Symlink/non-directory fail-closed: check every path level before touching fs
|
|
455
|
+
const releaseSkillDir = join(root, '.release-skill');
|
|
456
|
+
await assertLockPathsNotSymlinks(root);
|
|
457
|
+
|
|
458
|
+
// Ensure parent directory exists (only if not already checked as non-symlink)
|
|
459
|
+
let parentExisted = false;
|
|
460
|
+
try {
|
|
461
|
+
await lstat(releaseSkillDir);
|
|
462
|
+
parentExisted = true;
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (err.code !== 'ENOENT') throw err;
|
|
465
|
+
}
|
|
466
|
+
if (!parentExisted) {
|
|
467
|
+
await mkdir(releaseSkillDir, { recursive: true, mode: 0o700 });
|
|
468
|
+
await emitDurability(durabilityObserver, { operation: 'create-dir', path: releaseSkillDir });
|
|
469
|
+
// Persist the new .release-skill directory entry in root.
|
|
470
|
+
await fsyncDirObserved(root, durabilityObserver);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const dir = lockDir(root);
|
|
474
|
+
|
|
475
|
+
// Atomic lock acquisition via mkdir. Everything after successful mkdir and
|
|
476
|
+
// before returning is inside one cleanup boundary, so any write/fsync/fault
|
|
477
|
+
// failure cannot leave an ownerless lock directory.
|
|
478
|
+
let lockCreated = false;
|
|
479
|
+
try {
|
|
480
|
+
await mkdir(dir, { recursive: false, mode: 0o700 });
|
|
481
|
+
lockCreated = true;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
if (err.code === 'EEXIST') {
|
|
484
|
+
// Lock is held — TTL never permits automatic breakage
|
|
485
|
+
throw new ReleaseError(
|
|
486
|
+
TRANSACTION_INCOMPLETE,
|
|
487
|
+
'project lock is already held; another command is in progress',
|
|
488
|
+
{ root, lockDir: dir },
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const ownerFilePath = join(dir, OWNER_FILE_NAME);
|
|
495
|
+
try {
|
|
496
|
+
await emitDurability(durabilityObserver, { operation: 'create-dir', path: dir });
|
|
497
|
+
if (faultInjector) await faultInjector('after-lock-create');
|
|
498
|
+
await fsyncDirObserved(releaseSkillDir, durabilityObserver);
|
|
499
|
+
|
|
500
|
+
await writeFile(ownerFilePath, JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
|
|
501
|
+
await emitDurability(durabilityObserver, { operation: 'write-file', path: ownerFilePath });
|
|
502
|
+
if (faultInjector) await faultInjector('after-owner-write');
|
|
503
|
+
|
|
504
|
+
// Fsync owner file then lock directory for durability
|
|
505
|
+
await fsyncFileObserved(ownerFilePath, durabilityObserver);
|
|
506
|
+
await fsyncDirObserved(dir, durabilityObserver);
|
|
507
|
+
} catch (writeErr) {
|
|
508
|
+
// Cleanup: remove the lock dir if owner write failed — prevents zombie lock
|
|
509
|
+
// without an owner file (which cannot be broken since break requires owner).
|
|
510
|
+
if (!lockCreated) throw writeErr;
|
|
511
|
+
try {
|
|
512
|
+
await rm(dir, { recursive: true, force: true });
|
|
513
|
+
await emitDurability(durabilityObserver, { operation: 'remove-dir', path: dir });
|
|
514
|
+
await fsyncDirObserved(releaseSkillDir, durabilityObserver);
|
|
515
|
+
} catch (cleanupErr) {
|
|
516
|
+
const incomplete = new ReleaseError(
|
|
517
|
+
TRANSACTION_INCOMPLETE,
|
|
518
|
+
'project lock acquisition failed and cleanup could not be made durable',
|
|
519
|
+
{
|
|
520
|
+
acquireErrorCode: typeof writeErr?.code === 'string' ? writeErr.code : null,
|
|
521
|
+
cleanupErrorCode: typeof cleanupErr?.code === 'string' ? cleanupErr.code : null,
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
incomplete.cause = writeErr;
|
|
525
|
+
incomplete.cleanupCause = cleanupErr;
|
|
526
|
+
throw incomplete;
|
|
527
|
+
}
|
|
528
|
+
throw writeErr;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return Object.freeze({
|
|
532
|
+
owner,
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Run a function while asserting lock ownership before and after.
|
|
536
|
+
*
|
|
537
|
+
* Post-owner check runs regardless of whether fn succeeds or throws.
|
|
538
|
+
* If fn throws AND post-owner check fails, the error is TRANSACTION_INCOMPLETE
|
|
539
|
+
* with the original business error as `cause` (fail-closed, never loses the error).
|
|
540
|
+
*
|
|
541
|
+
* @param {() => Promise<T>} fn - Function to execute under lock.
|
|
542
|
+
* @returns {Promise<T>} Result of fn.
|
|
543
|
+
* @throws {ReleaseError} if ownership verification fails.
|
|
544
|
+
*/
|
|
545
|
+
async capture(fn) {
|
|
546
|
+
await assertOwnerOnDisk(owner, root);
|
|
547
|
+
let fnResult;
|
|
548
|
+
try {
|
|
549
|
+
fnResult = await fn();
|
|
550
|
+
} catch (fnErr) {
|
|
551
|
+
// fn threw — still perform post-owner check (fail-closed)
|
|
552
|
+
try {
|
|
553
|
+
await assertOwnerOnDisk(owner, root);
|
|
554
|
+
} catch {
|
|
555
|
+
// Both fn error AND owner lost — fail closed with TRANSACTION_INCOMPLETE,
|
|
556
|
+
// preserve the original business error as cause
|
|
557
|
+
const lockError = new ReleaseError(
|
|
558
|
+
TRANSACTION_INCOMPLETE,
|
|
559
|
+
'business error and lock ownership lost during capture',
|
|
560
|
+
{ businessErrorCode: typeof fnErr?.code === 'string' ? fnErr.code : null },
|
|
561
|
+
);
|
|
562
|
+
lockError.cause = fnErr;
|
|
563
|
+
throw lockError;
|
|
564
|
+
}
|
|
565
|
+
// Owner still held — re-throw the original business error
|
|
566
|
+
throw fnErr;
|
|
567
|
+
}
|
|
568
|
+
// fn succeeded — post-owner check
|
|
569
|
+
await assertOwnerOnDisk(owner, root);
|
|
570
|
+
return fnResult;
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Assert that the current process still owns the lock.
|
|
575
|
+
*
|
|
576
|
+
* @returns {Promise<void>}
|
|
577
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if ownership lost.
|
|
578
|
+
*/
|
|
579
|
+
async assertOwner() {
|
|
580
|
+
return assertOwnerOnDisk(owner, root);
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Release the lock. Only succeeds if the persisted owner matches exactly.
|
|
585
|
+
* After removal, fsyncs the parent .release-skill directory for durability.
|
|
586
|
+
*
|
|
587
|
+
* @returns {Promise<void>}
|
|
588
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch.
|
|
589
|
+
*/
|
|
590
|
+
async release() {
|
|
591
|
+
return removeLockIfExactOwner(owner, root, durabilityObserver);
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Break a project lock by force.
|
|
598
|
+
*
|
|
599
|
+
* Requires the exact owner record to match what is persisted on disk.
|
|
600
|
+
* Writes an audit record to `.release-skill/lock-audit/` before removing
|
|
601
|
+
* the lock directory.
|
|
602
|
+
*
|
|
603
|
+
* @param {object} options
|
|
604
|
+
* @param {string} options.root - Repository root (absolute).
|
|
605
|
+
* @param {object} options.expectedOwner - The exact owner record to match.
|
|
606
|
+
* @param {string} options.reason - Human-readable reason for breaking the lock.
|
|
607
|
+
* @param {() => string} [options.clock] - Clock function for timestamps.
|
|
608
|
+
* @param {(event: object) => Promise<void>} [options.durabilityObserver] - Observe completed durability operations; cannot replace fsync.
|
|
609
|
+
* @returns {Promise<AuditRecord>}
|
|
610
|
+
* @throws {ReleaseError} if owner does not match or lock does not exist.
|
|
611
|
+
*/
|
|
612
|
+
export async function breakProjectLock({ root, expectedOwner, reason, clock, durabilityObserver } = {}) {
|
|
613
|
+
if (!root || typeof root !== 'string') {
|
|
614
|
+
throw new ReleaseError(PATH_UNSAFE, 'root must be a non-empty string', { root });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Strict owner validation before any filesystem operations
|
|
618
|
+
validateOwnerObject(expectedOwner);
|
|
619
|
+
|
|
620
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
621
|
+
throw new ReleaseError(PATH_UNSAFE, 'reason must be a non-empty string (trimmed)', {});
|
|
622
|
+
}
|
|
623
|
+
if (/[\x00-\x1f\x7f]/.test(reason)) {
|
|
624
|
+
throw new ReleaseError(PATH_UNSAFE, 'reason contains control characters', {});
|
|
625
|
+
}
|
|
626
|
+
const trimmedReason = sanitizeReason(reason.trim());
|
|
627
|
+
const brokenAt = clock ? clock() : new Date().toISOString();
|
|
628
|
+
assertIsoTimestamp(brokenAt, 'clock result');
|
|
629
|
+
|
|
630
|
+
// Symlink/non-directory fail-closed check before reading owner
|
|
631
|
+
await assertLockPathsNotSymlinks(root);
|
|
632
|
+
|
|
633
|
+
// Read persisted owner
|
|
634
|
+
let raw;
|
|
635
|
+
try {
|
|
636
|
+
raw = await readFile(ownerPath(root), 'utf8');
|
|
637
|
+
} catch (err) {
|
|
638
|
+
if (err.code === 'ENOENT') {
|
|
639
|
+
throw new ReleaseError(
|
|
640
|
+
TRANSACTION_INCOMPLETE,
|
|
641
|
+
'no project lock to break — lock directory does not exist',
|
|
642
|
+
{ root },
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
throw err;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
let actualOwner;
|
|
649
|
+
try {
|
|
650
|
+
actualOwner = JSON.parse(raw);
|
|
651
|
+
} catch {
|
|
652
|
+
throw new ReleaseError(
|
|
653
|
+
TRANSACTION_INCOMPLETE,
|
|
654
|
+
'project lock owner file is corrupt — cannot break safely',
|
|
655
|
+
{ root },
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Exact owner match: all six fields must match
|
|
660
|
+
for (const field of OWNER_FIELDS) {
|
|
661
|
+
if (actualOwner[field] !== expectedOwner[field]) {
|
|
662
|
+
throw new ReleaseError(
|
|
663
|
+
TRANSACTION_INCOMPLETE,
|
|
664
|
+
`break-lock rejected: expectedOwner does not match persisted owner (field: ${field})`,
|
|
665
|
+
{ root, field },
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Check audit path is not a symlink before writing
|
|
671
|
+
await assertAuditPathNotSymlink(root);
|
|
672
|
+
|
|
673
|
+
// Build audit record — sanitize: no absolute paths in the JSON
|
|
674
|
+
const safeOriginalOwner = Object.freeze({
|
|
675
|
+
pid: actualOwner.pid,
|
|
676
|
+
host: actualOwner.host,
|
|
677
|
+
bootId: actualOwner.bootId,
|
|
678
|
+
nonce: actualOwner.nonce,
|
|
679
|
+
command: actualOwner.command,
|
|
680
|
+
startedAt: actualOwner.startedAt,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const auditRecord = Object.freeze({
|
|
684
|
+
brokenAt,
|
|
685
|
+
reason: trimmedReason,
|
|
686
|
+
originalOwner: safeOriginalOwner,
|
|
687
|
+
breakerPid: process.pid,
|
|
688
|
+
breakerHost: hostname(),
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Write audit evidence before removing lock
|
|
692
|
+
const auditDirectory = auditDir(root);
|
|
693
|
+
await mkdir(auditDirectory, { recursive: true, mode: 0o700 });
|
|
694
|
+
await emitDurability(durabilityObserver, { operation: 'create-dir', path: auditDirectory });
|
|
695
|
+
|
|
696
|
+
// Fsync .release-skill after creating audit directory
|
|
697
|
+
await fsyncDirObserved(join(root, '.release-skill'), durabilityObserver);
|
|
698
|
+
|
|
699
|
+
const safeTimestamp = auditRecord.brokenAt.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
700
|
+
const auditFileName = `${safeTimestamp}-${actualOwner.nonce}.json`;
|
|
701
|
+
const auditFilePath = join(auditDirectory, auditFileName);
|
|
702
|
+
await writeFile(auditFilePath, JSON.stringify(auditRecord, null, 2), { mode: 0o600, flag: 'wx' });
|
|
703
|
+
await emitDurability(durabilityObserver, { operation: 'write-file', path: auditFilePath });
|
|
704
|
+
await fsyncFileObserved(auditFilePath, durabilityObserver);
|
|
705
|
+
await fsyncDirObserved(auditDirectory, durabilityObserver);
|
|
706
|
+
|
|
707
|
+
// Remove the lock directory
|
|
708
|
+
await rm(lockDir(root), { recursive: true, force: true });
|
|
709
|
+
await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockDir(root) });
|
|
710
|
+
|
|
711
|
+
// Fsync parent after lock removal
|
|
712
|
+
await fsyncDirObserved(join(root, '.release-skill'), durabilityObserver);
|
|
713
|
+
|
|
714
|
+
return auditRecord;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* @typedef {object} ProjectLock
|
|
719
|
+
* @property {object} owner - The owner record.
|
|
720
|
+
* @property {(fn: () => Promise<T>) => Promise<T>} capture - Run fn under lock ownership assertion.
|
|
721
|
+
* @property {() => Promise<void>} assertOwner - Verify current process owns the lock.
|
|
722
|
+
* @property {() => Promise<void>} release - Release the lock.
|
|
723
|
+
*/
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* @typedef {object} AuditRecord
|
|
727
|
+
* @property {string} brokenAt - ISO timestamp of when the lock was broken.
|
|
728
|
+
* @property {string} reason - Human-readable reason.
|
|
729
|
+
* @property {object} originalOwner - The owner that was broken.
|
|
730
|
+
* @property {number} breakerPid - PID of the process that broke the lock.
|
|
731
|
+
* @property {string} breakerHost - Hostname of the breaker.
|
|
732
|
+
*/
|