@stdd/plugin 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- package/skills/stdd-worktrees/SKILL.md +46 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { openNativeFsSession } from "../sdk/native-fs.mjs";
|
|
4
|
+
import { resolveRepoPath } from "../sdk/path.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_NATIVE_CHUNK = 64 * 1024;
|
|
7
|
+
|
|
8
|
+
function relativeSegments(relative, label) {
|
|
9
|
+
const normalized = relative === "." ? "" : relative;
|
|
10
|
+
if (
|
|
11
|
+
typeof normalized !== "string" ||
|
|
12
|
+
path.isAbsolute(normalized) ||
|
|
13
|
+
normalized.includes("\\") ||
|
|
14
|
+
normalized.split("/").some((segment) => segment === "." || segment === "..")
|
|
15
|
+
) {
|
|
16
|
+
throw new Error(`${label} must be a safe repository-relative path`);
|
|
17
|
+
}
|
|
18
|
+
return normalized === "" ? [] : normalized.split("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sameNativeIdentity(left, right) {
|
|
22
|
+
return (
|
|
23
|
+
left?.version === right?.version &&
|
|
24
|
+
left?.platform === right?.platform &&
|
|
25
|
+
left?.volume === right?.volume &&
|
|
26
|
+
left?.fileId === right?.fileId &&
|
|
27
|
+
left?.kind === right?.kind
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nativeFailure(label, error) {
|
|
32
|
+
const unsafeSymlink = error?.code === "symlink-rejected" ? "unsafe symlink: " : "";
|
|
33
|
+
const detail =
|
|
34
|
+
error && typeof error === "object" && "code" in error
|
|
35
|
+
? `${unsafeSymlink}${error.message} (${error.code}${error.osCode ? `, os=${error.osCode}` : ""}, mutation=${error.mutation ?? "unknown"})`
|
|
36
|
+
: (error?.message ?? String(error));
|
|
37
|
+
const wrapped = new Error(`${label}: ${detail}`, { cause: error });
|
|
38
|
+
for (const field of ["code", "class", "operation", "osCode", "mutation", "retryable"]) {
|
|
39
|
+
if (error && typeof error === "object" && field in error) wrapped[field] = error[field];
|
|
40
|
+
}
|
|
41
|
+
return wrapped;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One verified helper session for one mutating init/configure command.
|
|
46
|
+
* Artifact verification, handshake, root binding, and filesystem probing all
|
|
47
|
+
* complete before the returned context permits its first mutation.
|
|
48
|
+
*/
|
|
49
|
+
export async function openNativeRepoMutation(targetDir, label = "native filesystem helper") {
|
|
50
|
+
let session = null;
|
|
51
|
+
try {
|
|
52
|
+
const packageRoot = process.env.STDD_NATIVE_FS_PACKAGE_ROOT;
|
|
53
|
+
session = await openNativeFsSession(packageRoot ? { packageRoot } : {});
|
|
54
|
+
const rootPath = path.resolve(targetDir);
|
|
55
|
+
const root = await session.openRoot(rootPath);
|
|
56
|
+
await session.probe(root.cap);
|
|
57
|
+
return {
|
|
58
|
+
session,
|
|
59
|
+
root,
|
|
60
|
+
rootPath,
|
|
61
|
+
async close() {
|
|
62
|
+
await session.close();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (session) await session.close().catch(() => {});
|
|
67
|
+
throw nativeFailure(`${label} preflight failed`, error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function openNativeRepoPath(context, relative, label = "repository path") {
|
|
72
|
+
const segments = relativeSegments(relative, label);
|
|
73
|
+
let current = context.root;
|
|
74
|
+
for (const segment of segments) {
|
|
75
|
+
try {
|
|
76
|
+
current = await context.session.openChild(current.cap, segment);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw nativeFailure(`${label} could not be opened`, error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function preflightNativeRepoDestination(
|
|
85
|
+
context,
|
|
86
|
+
relative,
|
|
87
|
+
label = "generated destination",
|
|
88
|
+
) {
|
|
89
|
+
const segments = relativeSegments(relative, label);
|
|
90
|
+
let current = context.root;
|
|
91
|
+
for (const [index, segment] of segments.entries()) {
|
|
92
|
+
let child;
|
|
93
|
+
try {
|
|
94
|
+
child = await context.session.openChild(current.cap, segment);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error?.code === "not-found") return;
|
|
97
|
+
throw nativeFailure(`${label} could not be inspected`, error);
|
|
98
|
+
}
|
|
99
|
+
const final = index === segments.length - 1;
|
|
100
|
+
if (!final && child.observation.identity.kind !== "directory") {
|
|
101
|
+
throw new Error(`${label} contains an unsafe non-directory parent ${JSON.stringify(segment)}`);
|
|
102
|
+
}
|
|
103
|
+
if (final && child.observation.identity.kind !== "file") {
|
|
104
|
+
throw new Error(`${label} is occupied by an unsafe non-file object`);
|
|
105
|
+
}
|
|
106
|
+
current = child;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function openOrCreateNativeRepoDirectory(
|
|
111
|
+
context,
|
|
112
|
+
relative,
|
|
113
|
+
{ mode = 0o755, label = "generated directory", beforeCommit = null } = {},
|
|
114
|
+
) {
|
|
115
|
+
const segments = relativeSegments(relative, label);
|
|
116
|
+
let current = context.root;
|
|
117
|
+
for (const segment of segments) {
|
|
118
|
+
try {
|
|
119
|
+
current = await context.session.openChild(current.cap, segment);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code !== "not-found") throw nativeFailure(`${label} could not be opened`, error);
|
|
122
|
+
try {
|
|
123
|
+
if (beforeCommit !== null) await beforeCommit();
|
|
124
|
+
current = await context.session.createDirectory(current.cap, segment, mode);
|
|
125
|
+
} catch (createError) {
|
|
126
|
+
throw nativeFailure(`${label} could not be created`, createError);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (current.observation.identity.kind !== "directory") {
|
|
130
|
+
throw new Error(`${label} contains an unsafe non-directory segment ${JSON.stringify(segment)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function verifyNativeRepoDirectory(context, relative, expected, label) {
|
|
137
|
+
let rebound;
|
|
138
|
+
try {
|
|
139
|
+
rebound = await context.session.openRoot(context.rootPath);
|
|
140
|
+
if (!sameNativeIdentity(rebound.observation.identity, context.root.observation.identity)) {
|
|
141
|
+
throw new Error("repository root changed during native publication");
|
|
142
|
+
}
|
|
143
|
+
for (const segment of relativeSegments(relative, label)) {
|
|
144
|
+
rebound = await context.session.openChild(rebound.cap, segment);
|
|
145
|
+
}
|
|
146
|
+
if (!sameNativeIdentity(rebound.observation.identity, expected)) {
|
|
147
|
+
throw new Error(`${label} changed during native publication`);
|
|
148
|
+
}
|
|
149
|
+
return rebound;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (!("mutation" in error)) error.mutation = "committed";
|
|
152
|
+
throw nativeFailure(`${label} postflight failed`, error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function readNativeFile(context, file, maximum = 16 * 1024 * 1024) {
|
|
157
|
+
if (file.observation.identity.kind !== "file") {
|
|
158
|
+
throw new Error("native read capability is not a regular file");
|
|
159
|
+
}
|
|
160
|
+
const size = Number(file.observation.size);
|
|
161
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > maximum) {
|
|
162
|
+
throw new Error(`native file is oversized (maximum ${maximum} bytes)`);
|
|
163
|
+
}
|
|
164
|
+
const readPass = async () => {
|
|
165
|
+
const chunks = [];
|
|
166
|
+
let offset = 0;
|
|
167
|
+
while (offset < size) {
|
|
168
|
+
const response = await context.session.read(
|
|
169
|
+
file.cap,
|
|
170
|
+
offset,
|
|
171
|
+
Math.min(MAX_NATIVE_CHUNK, size - offset),
|
|
172
|
+
);
|
|
173
|
+
const bytes = Buffer.from(response.data, "base64");
|
|
174
|
+
if (bytes.length === 0 && !response.eof) throw new Error("native file read made no progress");
|
|
175
|
+
chunks.push(bytes);
|
|
176
|
+
offset += bytes.length;
|
|
177
|
+
if (response.eof) break;
|
|
178
|
+
}
|
|
179
|
+
if (offset !== size) throw new Error("native file changed size while it was read");
|
|
180
|
+
return Buffer.concat(chunks);
|
|
181
|
+
};
|
|
182
|
+
const first = await readPass();
|
|
183
|
+
const second = await readPass();
|
|
184
|
+
const after = await context.session.stat(file.cap);
|
|
185
|
+
if (
|
|
186
|
+
!first.equals(second) ||
|
|
187
|
+
!sameNativeIdentity(file.observation.identity, after.observation.identity) ||
|
|
188
|
+
after.observation.size !== file.observation.size
|
|
189
|
+
) {
|
|
190
|
+
throw new Error("native file changed while it was read");
|
|
191
|
+
}
|
|
192
|
+
return second;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function readOptionalNativeRepoFile(
|
|
196
|
+
context,
|
|
197
|
+
relative,
|
|
198
|
+
{ label = "repository file", maximum = 16 * 1024 * 1024 } = {},
|
|
199
|
+
) {
|
|
200
|
+
resolveRepoPath(context.rootPath, relative, label);
|
|
201
|
+
const parentRelative = path.posix.dirname(relative);
|
|
202
|
+
let parent;
|
|
203
|
+
try {
|
|
204
|
+
parent = await openNativeRepoPath(context, parentRelative, `${label} parent`);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error?.code === "not-found") return null;
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
let file;
|
|
210
|
+
try {
|
|
211
|
+
file = await context.session.openChild(parent.cap, path.posix.basename(relative));
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error?.code === "not-found") return null;
|
|
214
|
+
throw nativeFailure(`${label} could not be opened`, error);
|
|
215
|
+
}
|
|
216
|
+
return { parent, file, bytes: await readNativeFile(context, file, maximum) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function writeNativeFileContent(context, file, content) {
|
|
220
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
221
|
+
const identity = file.observation.identity;
|
|
222
|
+
let offset = 0;
|
|
223
|
+
while (offset < bytes.length) {
|
|
224
|
+
const chunk = bytes.subarray(offset, Math.min(bytes.length, offset + MAX_NATIVE_CHUNK));
|
|
225
|
+
const result = await context.session.write(file.cap, offset, chunk, identity);
|
|
226
|
+
if (result.written < 1) throw new Error("native file write made no progress");
|
|
227
|
+
offset += result.written;
|
|
228
|
+
}
|
|
229
|
+
await context.session.truncate(file.cap, bytes.length, identity);
|
|
230
|
+
await context.session.flush(file.cap, "all", identity);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Publish one file through a held directory capability. The destination is
|
|
235
|
+
* identity-bound when present and no-replace when absent; the parent is
|
|
236
|
+
* durably flushed before the helper returns.
|
|
237
|
+
*/
|
|
238
|
+
export async function publishNativeRepoFile(context, relative, content, options = {}) {
|
|
239
|
+
const {
|
|
240
|
+
mode = 0o644,
|
|
241
|
+
tempPrefix = ".stdd-generated-",
|
|
242
|
+
directoryMode = 0o755,
|
|
243
|
+
expectedTarget,
|
|
244
|
+
expectedBytes,
|
|
245
|
+
} = options;
|
|
246
|
+
const targetWasInspected = Object.hasOwn(options, "expectedTarget");
|
|
247
|
+
const targetBytesWereInspected = Object.hasOwn(options, "expectedBytes");
|
|
248
|
+
resolveRepoPath(context.rootPath, relative, `generated path ${JSON.stringify(relative)}`);
|
|
249
|
+
const parentRelative = path.posix.dirname(relative);
|
|
250
|
+
const targetName = path.posix.basename(relative);
|
|
251
|
+
const parent = await openOrCreateNativeRepoDirectory(context, parentRelative, {
|
|
252
|
+
mode: directoryMode,
|
|
253
|
+
label: `generated parent for ${JSON.stringify(relative)}`,
|
|
254
|
+
});
|
|
255
|
+
let target = null;
|
|
256
|
+
try {
|
|
257
|
+
target = await context.session.openChild(parent.cap, targetName);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error?.code !== "not-found") throw nativeFailure(`generated target ${relative}`, error);
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
targetWasInspected &&
|
|
263
|
+
((expectedTarget === null && target !== null) ||
|
|
264
|
+
(expectedTarget !== null &&
|
|
265
|
+
(target === null || !sameNativeIdentity(target.observation.identity, expectedTarget))))
|
|
266
|
+
) {
|
|
267
|
+
const error = new Error(`generated target ${relative} changed after it was inspected`);
|
|
268
|
+
error.code = "identity-conflict";
|
|
269
|
+
error.mutation = "none";
|
|
270
|
+
throw nativeFailure(`generated target ${relative}`, error);
|
|
271
|
+
}
|
|
272
|
+
let temporary = null;
|
|
273
|
+
for (let attempt = 0; attempt < 16 && temporary === null; attempt += 1) {
|
|
274
|
+
const name = `${tempPrefix}${randomNativeBasename()}.tmp`;
|
|
275
|
+
try {
|
|
276
|
+
temporary = {
|
|
277
|
+
name,
|
|
278
|
+
file: await context.session.createFile(parent.cap, name, mode),
|
|
279
|
+
};
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error?.code !== "identity-conflict") {
|
|
282
|
+
const retained =
|
|
283
|
+
error?.mutation === "possible" || error?.mutation === "committed"
|
|
284
|
+
? `; inactive temporary ${parentRelative}/${name} may have been retained for manual inspection`
|
|
285
|
+
: "";
|
|
286
|
+
throw nativeFailure(`generated temporary for ${relative}${retained}`, error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!temporary) throw new Error(`generated temporary name exhausted for ${relative}`);
|
|
291
|
+
try {
|
|
292
|
+
await writeNativeFileContent(context, temporary.file, content);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
throw nativeFailure(
|
|
295
|
+
`generated temporary ${parentRelative}/${temporary.name} was retained for manual inspection`,
|
|
296
|
+
error,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
if (targetBytesWereInspected && target) {
|
|
301
|
+
const currentBytes = await readNativeFile(context, target);
|
|
302
|
+
if (!currentBytes.equals(Buffer.from(expectedBytes))) {
|
|
303
|
+
const conflict = new Error(
|
|
304
|
+
`generated target ${relative} content changed after it was inspected`,
|
|
305
|
+
);
|
|
306
|
+
conflict.code = "identity-conflict";
|
|
307
|
+
conflict.mutation = "none";
|
|
308
|
+
throw conflict;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await context.session.rename({
|
|
312
|
+
fromParent: parent.cap,
|
|
313
|
+
from: temporary.name,
|
|
314
|
+
expected: temporary.file.observation.identity,
|
|
315
|
+
toParent: parent.cap,
|
|
316
|
+
to: targetName,
|
|
317
|
+
replace: target ? "expected" : "never",
|
|
318
|
+
...(target ? { expectedTarget: target.observation.identity } : {}),
|
|
319
|
+
});
|
|
320
|
+
await context.session.flush(parent.cap, "namespace", parent.observation.identity);
|
|
321
|
+
await verifyNativeRepoDirectory(
|
|
322
|
+
context,
|
|
323
|
+
parentRelative,
|
|
324
|
+
parent.observation.identity,
|
|
325
|
+
`generated parent for ${JSON.stringify(relative)}`,
|
|
326
|
+
);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
throw nativeFailure(`generated publication for ${relative}`, error);
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const published = await context.session.openChild(parent.cap, targetName);
|
|
332
|
+
if (!sameNativeIdentity(published.observation.identity, temporary.file.observation.identity)) {
|
|
333
|
+
const error = new Error(
|
|
334
|
+
`generated path ${JSON.stringify(relative)} changed during native publication`,
|
|
335
|
+
);
|
|
336
|
+
error.mutation = "committed";
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
return published;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error?.mutation !== "committed") error.mutation = "committed";
|
|
342
|
+
throw nativeFailure(`generated publication for ${relative} postflight failed`, error);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function retireNativeRepoFile(context, relative, content) {
|
|
347
|
+
resolveRepoPath(context.rootPath, relative, `retired path ${JSON.stringify(relative)}`);
|
|
348
|
+
const sourceParentRelative = path.posix.dirname(relative);
|
|
349
|
+
const sourceParent = await openNativeRepoPath(
|
|
350
|
+
context,
|
|
351
|
+
sourceParentRelative,
|
|
352
|
+
`retired parent for ${relative}`,
|
|
353
|
+
);
|
|
354
|
+
const source = await context.session.openChild(sourceParent.cap, path.posix.basename(relative));
|
|
355
|
+
const observed = await readNativeFile(context, source);
|
|
356
|
+
const expected = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
357
|
+
if (!observed.equals(expected)) throw new Error(`${relative} changed before retained retirement`);
|
|
358
|
+
if (
|
|
359
|
+
(source.observation.identity.platform !== "win32" &&
|
|
360
|
+
source.observation.owner !== context.root.observation.owner) ||
|
|
361
|
+
source.observation.linkCount !== "1"
|
|
362
|
+
) {
|
|
363
|
+
throw new Error(`${relative} must be repository-owned and single-linked before retirement`);
|
|
364
|
+
}
|
|
365
|
+
const container = `.stdd-cleanup-${randomNativeBasename()}.tmp`;
|
|
366
|
+
const quarantineParentRelative = `.stdd/generated-quarantines/${container}`;
|
|
367
|
+
const quarantineParent = await openOrCreateNativeRepoDirectory(context, quarantineParentRelative, {
|
|
368
|
+
mode: 0o700,
|
|
369
|
+
label: `retained quarantine for ${relative}`,
|
|
370
|
+
});
|
|
371
|
+
if (
|
|
372
|
+
quarantineParent.observation.identity.platform !== "win32" &&
|
|
373
|
+
(Number(quarantineParent.observation.permissions) & 0o777) !== 0o700
|
|
374
|
+
) {
|
|
375
|
+
throw new Error(`retained quarantine for ${relative} must be owner-private mode 0700`);
|
|
376
|
+
}
|
|
377
|
+
if (
|
|
378
|
+
quarantineParent.observation.identity.platform !== "win32" &&
|
|
379
|
+
quarantineParent.observation.owner !== context.root.observation.owner
|
|
380
|
+
) {
|
|
381
|
+
throw new Error(`retained quarantine for ${relative} must be owned by the repository owner`);
|
|
382
|
+
}
|
|
383
|
+
await context.session.rename({
|
|
384
|
+
fromParent: sourceParent.cap,
|
|
385
|
+
from: path.posix.basename(relative),
|
|
386
|
+
expected: source.observation.identity,
|
|
387
|
+
toParent: quarantineParent.cap,
|
|
388
|
+
to: path.posix.basename(relative),
|
|
389
|
+
replace: "never",
|
|
390
|
+
});
|
|
391
|
+
for (const directory of [sourceParent, quarantineParent]) {
|
|
392
|
+
await context.session.flush(directory.cap, "namespace", directory.observation.identity);
|
|
393
|
+
}
|
|
394
|
+
await verifyNativeRepoDirectory(
|
|
395
|
+
context,
|
|
396
|
+
sourceParentRelative,
|
|
397
|
+
sourceParent.observation.identity,
|
|
398
|
+
`retired source parent for ${relative}`,
|
|
399
|
+
);
|
|
400
|
+
await verifyNativeRepoDirectory(
|
|
401
|
+
context,
|
|
402
|
+
quarantineParentRelative,
|
|
403
|
+
quarantineParent.observation.identity,
|
|
404
|
+
`retained quarantine for ${relative}`,
|
|
405
|
+
);
|
|
406
|
+
return {
|
|
407
|
+
relative: `${quarantineParentRelative}/${path.posix.basename(relative)}`,
|
|
408
|
+
observation: source.observation,
|
|
409
|
+
bytes: observed,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function randomNativeBasename() {
|
|
414
|
+
return randomBytes(16).toString("hex");
|
|
415
|
+
}
|