@kisev/agentomatic 11.0.0-dev.46.gfbe1e4c6992e
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/README.md +95 -0
- package/README.ru.md +98 -0
- package/dist/agent-profiles.d.ts +125 -0
- package/dist/agent-profiles.js +820 -0
- package/dist/assets/agents/architect.md +67 -0
- package/dist/assets/agents/critic.md +55 -0
- package/dist/assets/agents/manager.md +40 -0
- package/dist/assets/agents/mapper.md +33 -0
- package/dist/assets/agents/review.md +37 -0
- package/dist/assets/agents/worker.md +78 -0
- package/dist/assets/commands/agents-md.md +10 -0
- package/dist/assets/commands/askme.md +10 -0
- package/dist/assets/commands/ast-grep.md +10 -0
- package/dist/assets/commands/briefing.md +10 -0
- package/dist/assets/commands/code-explain.md +10 -0
- package/dist/assets/commands/code-review.md +10 -0
- package/dist/assets/commands/commit-msg.md +10 -0
- package/dist/assets/commands/docs-prepare.md +10 -0
- package/dist/assets/commands/docs-review.md +10 -0
- package/dist/assets/commands/goal.md +10 -0
- package/dist/assets/commands/humanize.md +10 -0
- package/dist/assets/commands/mattermost-triage.md +10 -0
- package/dist/assets/commands/mattermost.md +10 -0
- package/dist/assets/commands/mr-prepare.md +10 -0
- package/dist/assets/commands/release-prepare.md +10 -0
- package/dist/assets/commands/release-review.md +10 -0
- package/dist/assets/commands/rtk-stats.md +13 -0
- package/dist/assets/commands/rtk.md +10 -0
- package/dist/assets/commands/skill-improve.md +10 -0
- package/dist/assets/commands/slides-prompts-prepare.md +10 -0
- package/dist/assets/commands/spec-manage.md +13 -0
- package/dist/assets/commands/stopit.md +10 -0
- package/dist/assets/commands/task-prepare.md +10 -0
- package/dist/assets/commands/task-review.md +10 -0
- package/dist/assets/commands/task-triage.md +10 -0
- package/dist/assets/commands/taskmatic.md +10 -0
- package/dist/assets/commands/team-retro.md +10 -0
- package/dist/assets/commands/team-roadmap.md +10 -0
- package/dist/assets/commands/team-sprint-close.md +10 -0
- package/dist/assets/commands/team-sprint-start.md +10 -0
- package/dist/assets/lsp-catalog.json +30 -0
- package/dist/assets/migration-inventory.json +301 -0
- package/dist/assets/plugins/memomatic.js +3 -0
- package/dist/assets/plugins/rtk.js +3 -0
- package/dist/assets/plugins/rules-injector.js +3 -0
- package/dist/assets/plugins/zed-bell.js +3 -0
- package/dist/catalog.d.ts +8 -0
- package/dist/catalog.js +39 -0
- package/dist/cli-output.d.ts +27 -0
- package/dist/cli-output.js +296 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1135 -0
- package/dist/config-setup.d.ts +71 -0
- package/dist/config-setup.js +438 -0
- package/dist/contracts.d.ts +52 -0
- package/dist/contracts.js +102 -0
- package/dist/doctor.d.ts +44 -0
- package/dist/doctor.js +638 -0
- package/dist/generate-assets.d.ts +1 -0
- package/dist/generate-assets.js +54 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +141 -0
- package/dist/installer.d.ts +56 -0
- package/dist/installer.js +710 -0
- package/dist/jsonc.d.ts +56 -0
- package/dist/jsonc.js +432 -0
- package/dist/lifecycle.d.ts +77 -0
- package/dist/lifecycle.js +736 -0
- package/dist/package-metadata.d.ts +4 -0
- package/dist/package-metadata.js +42 -0
- package/dist/plugins/memomatic.d.ts +76 -0
- package/dist/plugins/memomatic.js +101 -0
- package/dist/plugins/rtk.d.ts +41 -0
- package/dist/plugins/rtk.js +177 -0
- package/dist/plugins/rules-injector.d.ts +22 -0
- package/dist/plugins/rules-injector.js +131 -0
- package/dist/plugins/zed-bell.d.ts +13 -0
- package/dist/plugins/zed-bell.js +14 -0
- package/dist/reconcile.d.ts +47 -0
- package/dist/reconcile.js +490 -0
- package/dist/registry.d.ts +21 -0
- package/dist/registry.js +92 -0
- package/dist/routing.d.ts +133 -0
- package/dist/routing.js +578 -0
- package/dist/runtime/state.d.ts +9 -0
- package/dist/runtime/state.js +193 -0
- package/dist/runtime/worktree.d.ts +33 -0
- package/dist/runtime/worktree.js +220 -0
- package/dist/terminal-wizard.d.ts +3 -0
- package/dist/terminal-wizard.js +237 -0
- package/package.json +72 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
6
|
+
export const RECEIPT_TTL_MS = 10 * 60 * 1000;
|
|
7
|
+
export { LifecycleError, assertSafePath, readRegular, writeAtomic } from "@kisev/safe-fs";
|
|
8
|
+
import { LifecycleError, assertSafePath, ensureDirectory, lstatSafe, readRegular, writeAtomic, } from "@kisev/safe-fs";
|
|
9
|
+
export function stable(value) {
|
|
10
|
+
if (value === null || typeof value !== "object")
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return `[${value.map(stable).join(",")}]`;
|
|
14
|
+
const object = value;
|
|
15
|
+
return `{${Object.keys(object)
|
|
16
|
+
.sort()
|
|
17
|
+
.map((key) => `${JSON.stringify(key)}:${stable(object[key])}`)
|
|
18
|
+
.join(",")}}`;
|
|
19
|
+
}
|
|
20
|
+
export function sha256(value) {
|
|
21
|
+
return createHash("sha256").update(value).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
export function digest(value) {
|
|
24
|
+
return sha256(stable(value));
|
|
25
|
+
}
|
|
26
|
+
export function assertSafeRelative(value) {
|
|
27
|
+
if (!value ||
|
|
28
|
+
isAbsolute(value) ||
|
|
29
|
+
normalize(value) !== value ||
|
|
30
|
+
value.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
|
|
31
|
+
throw new LifecycleError("unsafe_path", `Unsafe relative path: ${value}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function inside(root, target) {
|
|
35
|
+
const difference = relative(root, target);
|
|
36
|
+
return (difference === "" ||
|
|
37
|
+
(!difference.startsWith(`..${sep}`) && difference !== ".." && !isAbsolute(difference)));
|
|
38
|
+
}
|
|
39
|
+
export function destination(root, relativePath) {
|
|
40
|
+
assertSafeRelative(relativePath);
|
|
41
|
+
const target = resolve(root, relativePath);
|
|
42
|
+
if (!inside(resolve(root), target))
|
|
43
|
+
throw new LifecycleError("unsafe_path", `Path escapes destination root: ${relativePath}`);
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
export function deploymentRoot(scope, cwd = process.cwd(), home = homedir()) {
|
|
47
|
+
return scope === "global" ? resolve(home, ".config", "opencode") : resolve(cwd, ".opencode");
|
|
48
|
+
}
|
|
49
|
+
export function lifecycleRoot(scope, cwd = process.cwd(), home = homedir()) {
|
|
50
|
+
const base = process.env.XDG_STATE_HOME && home === homedir()
|
|
51
|
+
? resolve(process.env.XDG_STATE_HOME)
|
|
52
|
+
: resolve(home, ".local", "state");
|
|
53
|
+
const suffix = scope === "global" ? "global" : join("project", sha256(resolve(cwd)));
|
|
54
|
+
return join(base, "opencode", "agentomatic", suffix);
|
|
55
|
+
}
|
|
56
|
+
function xdgBase(variable, home) {
|
|
57
|
+
const base = process.env[variable];
|
|
58
|
+
return base && home === homedir()
|
|
59
|
+
? resolve(base)
|
|
60
|
+
: resolve(home, ".local", variable === "XDG_STATE_HOME" ? "state" : "share");
|
|
61
|
+
}
|
|
62
|
+
async function moveLegacyDirectoryOnce(legacy, target) {
|
|
63
|
+
const legacyInfo = await lstatSafe(legacy);
|
|
64
|
+
if (!legacyInfo)
|
|
65
|
+
return false;
|
|
66
|
+
if (!legacyInfo.isDirectory() || legacyInfo.isSymbolicLink())
|
|
67
|
+
throw new LifecycleError("unsafe_path", `Legacy state path is not a directory: ${legacy}`);
|
|
68
|
+
if (await lstatSafe(target))
|
|
69
|
+
return false;
|
|
70
|
+
await ensureDirectory(dirname(target), 0o700);
|
|
71
|
+
await rename(legacy, target);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
export async function migrateLegacyDeploymentNamespace(root) {
|
|
75
|
+
return moveLegacyDirectoryOnce(join(root, ".skills-opencode"), join(root, ".agentomatic"));
|
|
76
|
+
}
|
|
77
|
+
export async function migrateLegacyNamespaces(home = homedir()) {
|
|
78
|
+
const moved = [];
|
|
79
|
+
const namespaces = [
|
|
80
|
+
[
|
|
81
|
+
join(xdgBase("XDG_STATE_HOME", home), "opencode", "skills-opencode"),
|
|
82
|
+
join(xdgBase("XDG_STATE_HOME", home), "opencode", "agentomatic"),
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
join(xdgBase("XDG_DATA_HOME", home), "opencode", "skills-opencode"),
|
|
86
|
+
join(xdgBase("XDG_DATA_HOME", home), "opencode", "agentomatic"),
|
|
87
|
+
],
|
|
88
|
+
];
|
|
89
|
+
for (const [legacy, target] of namespaces) {
|
|
90
|
+
if (await moveLegacyDirectoryOnce(legacy, target))
|
|
91
|
+
moved.push(target);
|
|
92
|
+
}
|
|
93
|
+
return moved;
|
|
94
|
+
}
|
|
95
|
+
export function archiveRoot(scope, cwd = process.cwd(), home = homedir()) {
|
|
96
|
+
const base = process.env.XDG_DATA_HOME && home === homedir()
|
|
97
|
+
? resolve(process.env.XDG_DATA_HOME)
|
|
98
|
+
: resolve(home, ".local", "share");
|
|
99
|
+
const suffix = scope === "global" ? "global" : join("project", sha256(resolve(cwd)));
|
|
100
|
+
return join(base, "opencode", "agentomatic", "archive", suffix);
|
|
101
|
+
}
|
|
102
|
+
async function processAlive(pid) {
|
|
103
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
104
|
+
return false;
|
|
105
|
+
try {
|
|
106
|
+
process.kill(pid, 0);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
return error.code === "EPERM";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function reclaimStaleLifecycleLock(stateRoot, lock) {
|
|
114
|
+
const cleanup = join(stateRoot, "lifecycle-lock-cleanup");
|
|
115
|
+
try {
|
|
116
|
+
await mkdir(cleanup, { mode: 0o700 });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error.code === "EEXIST")
|
|
120
|
+
throw new LifecycleError("lifecycle_locked", "Another process is checking a stale lock");
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const lockInfo = await lstatSafe(lock);
|
|
125
|
+
if (!lockInfo)
|
|
126
|
+
return;
|
|
127
|
+
if (!lockInfo.isDirectory() || lockInfo.isSymbolicLink() || (lockInfo.mode & 0o077) !== 0)
|
|
128
|
+
throw new LifecycleError("unsafe_path", "Lifecycle lock is not a private directory");
|
|
129
|
+
const ownerRaw = await readRegular(join(lock, "owner.json")).catch(() => undefined);
|
|
130
|
+
if (!ownerRaw) {
|
|
131
|
+
if (Date.now() - lockInfo.mtimeMs < 10_000)
|
|
132
|
+
throw new LifecycleError("lifecycle_locked", "Lifecycle lock is being initialized");
|
|
133
|
+
await rm(lock, { recursive: true, force: true });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let ownerPid = 0;
|
|
137
|
+
let released = false;
|
|
138
|
+
try {
|
|
139
|
+
const owner = JSON.parse(ownerRaw.toString("utf8"));
|
|
140
|
+
ownerPid = Number(owner.pid);
|
|
141
|
+
released = owner.released === true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
ownerPid = 0;
|
|
145
|
+
}
|
|
146
|
+
if (!released && (await processAlive(ownerPid)))
|
|
147
|
+
throw new LifecycleError("lifecycle_locked", `Lifecycle is locked by process ${ownerPid}`);
|
|
148
|
+
await rm(lock, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await rm(cleanup, { recursive: true, force: true }).catch(() => undefined);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export async function withLifecycleLock(stateRoot, callback) {
|
|
155
|
+
await ensureDirectory(stateRoot, 0o700);
|
|
156
|
+
const stateInfo = await lstat(stateRoot);
|
|
157
|
+
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink() || (stateInfo.mode & 0o077) !== 0) {
|
|
158
|
+
throw new LifecycleError("unsafe_path", "Lifecycle state root must be a private directory");
|
|
159
|
+
}
|
|
160
|
+
const lock = join(stateRoot, "lifecycle.lock");
|
|
161
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
162
|
+
try {
|
|
163
|
+
await mkdir(lock, { mode: 0o700 });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error.code !== "EEXIST")
|
|
167
|
+
throw error;
|
|
168
|
+
await reclaimStaleLifecycleLock(stateRoot, lock);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await writeAtomic(join(lock, "owner.json"), Buffer.from(`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString(), released: false })}\n`), 0o600);
|
|
173
|
+
return await callback();
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await writeAtomic(join(lock, "owner.json"), Buffer.from(`${JSON.stringify({ pid: process.pid, released_at: new Date().toISOString(), released: true })}\n`), 0o600).catch(() => undefined);
|
|
177
|
+
await rm(lock, { recursive: true, force: true }).catch(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new LifecycleError("lifecycle_locked", "Cannot acquire lifecycle lock");
|
|
181
|
+
}
|
|
182
|
+
function receiptPath(stateRoot) {
|
|
183
|
+
return join(stateRoot, "receipt.json");
|
|
184
|
+
}
|
|
185
|
+
export async function saveReceipt(stateRoot, kind, scope, root, payload, now = Date.now(), planDigest = digest(payload)) {
|
|
186
|
+
const existingRaw = await readRegular(receiptPath(stateRoot));
|
|
187
|
+
let superseded;
|
|
188
|
+
if (existingRaw) {
|
|
189
|
+
const existing = parseReceipt(existingRaw);
|
|
190
|
+
if (!existing.consumed && Date.parse(existing.expires_at) >= now) {
|
|
191
|
+
superseded = existing;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const confirmationDigest = sha256(randomBytes(32));
|
|
195
|
+
const receipt = {
|
|
196
|
+
schema_version: 1,
|
|
197
|
+
digest: confirmationDigest,
|
|
198
|
+
plan_digest: planDigest,
|
|
199
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
200
|
+
created_at: new Date(now).toISOString(),
|
|
201
|
+
expires_at: new Date(now + RECEIPT_TTL_MS).toISOString(),
|
|
202
|
+
consumed: false,
|
|
203
|
+
kind,
|
|
204
|
+
scope,
|
|
205
|
+
root,
|
|
206
|
+
payload,
|
|
207
|
+
...(superseded
|
|
208
|
+
? {
|
|
209
|
+
superseded_digests: [superseded.digest, ...(superseded.superseded_digests ?? [])],
|
|
210
|
+
}
|
|
211
|
+
: {}),
|
|
212
|
+
integrity: "",
|
|
213
|
+
};
|
|
214
|
+
receipt.integrity = receiptIntegrity(receipt);
|
|
215
|
+
await writeAtomic(receiptPath(stateRoot), Buffer.from(`${stable(receipt)}\n`), 0o600);
|
|
216
|
+
return {
|
|
217
|
+
digest: receipt.digest,
|
|
218
|
+
plan_digest: receipt.plan_digest,
|
|
219
|
+
expires_at: receipt.expires_at,
|
|
220
|
+
...(superseded
|
|
221
|
+
? {
|
|
222
|
+
superseded_plan: {
|
|
223
|
+
kind: superseded.kind,
|
|
224
|
+
confirmation_digest: superseded.digest.slice(0, 12),
|
|
225
|
+
created_at: superseded.created_at,
|
|
226
|
+
expires_at: superseded.expires_at,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
: {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function parseReceipt(raw) {
|
|
233
|
+
let value;
|
|
234
|
+
try {
|
|
235
|
+
value = JSON.parse(raw.toString("utf8"));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
throw new LifecycleError("invalid_receipt", "Receipt is not valid JSON");
|
|
239
|
+
}
|
|
240
|
+
const receipt = value;
|
|
241
|
+
if (receipt &&
|
|
242
|
+
receipt.schema_version === 1 &&
|
|
243
|
+
typeof receipt.digest === "string" &&
|
|
244
|
+
/^[a-f0-9]{64}$/.test(receipt.digest) &&
|
|
245
|
+
typeof receipt.nonce === "string" &&
|
|
246
|
+
typeof receipt.expires_at === "string" &&
|
|
247
|
+
typeof receipt.consumed === "boolean" &&
|
|
248
|
+
typeof receipt.kind === "string" &&
|
|
249
|
+
(receipt.scope === "global" || receipt.scope === "project") &&
|
|
250
|
+
typeof receipt.root === "string" &&
|
|
251
|
+
typeof receipt.integrity === "string" &&
|
|
252
|
+
receipt.plan_digest === undefined) {
|
|
253
|
+
const legacyDocument = {
|
|
254
|
+
schema_version: 1,
|
|
255
|
+
digest: receipt.digest,
|
|
256
|
+
nonce: receipt.nonce,
|
|
257
|
+
expires_at: receipt.expires_at,
|
|
258
|
+
consumed: receipt.consumed,
|
|
259
|
+
kind: receipt.kind,
|
|
260
|
+
scope: receipt.scope,
|
|
261
|
+
root: receipt.root,
|
|
262
|
+
payload: receipt.payload,
|
|
263
|
+
integrity: receipt.integrity,
|
|
264
|
+
};
|
|
265
|
+
if (receipt.digest !==
|
|
266
|
+
digest({
|
|
267
|
+
schema_version: 1,
|
|
268
|
+
kind: receipt.kind,
|
|
269
|
+
scope: receipt.scope,
|
|
270
|
+
root: receipt.root,
|
|
271
|
+
payload: receipt.payload,
|
|
272
|
+
}) ||
|
|
273
|
+
receipt.integrity !==
|
|
274
|
+
digest({
|
|
275
|
+
schema_version: 1,
|
|
276
|
+
digest: receipt.digest,
|
|
277
|
+
nonce: receipt.nonce,
|
|
278
|
+
expires_at: receipt.expires_at,
|
|
279
|
+
consumed: receipt.consumed,
|
|
280
|
+
kind: receipt.kind,
|
|
281
|
+
scope: receipt.scope,
|
|
282
|
+
root: receipt.root,
|
|
283
|
+
payload: receipt.payload,
|
|
284
|
+
})) {
|
|
285
|
+
throw new LifecycleError("invalid_receipt", "Receipt integrity check failed");
|
|
286
|
+
}
|
|
287
|
+
const payloadRecord = receipt.payload;
|
|
288
|
+
const planDigest = payloadRecord &&
|
|
289
|
+
typeof payloadRecord === "object" &&
|
|
290
|
+
typeof payloadRecord.digest === "string" &&
|
|
291
|
+
/^[a-f0-9]{64}$/.test(payloadRecord.digest)
|
|
292
|
+
? payloadRecord.digest
|
|
293
|
+
: digest(receipt.payload);
|
|
294
|
+
return {
|
|
295
|
+
...legacyDocument,
|
|
296
|
+
plan_digest: planDigest,
|
|
297
|
+
created_at: new Date(Date.parse(receipt.expires_at) - RECEIPT_TTL_MS).toISOString(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (!receipt ||
|
|
301
|
+
receipt.schema_version !== 1 ||
|
|
302
|
+
typeof receipt.digest !== "string" ||
|
|
303
|
+
!/^[a-f0-9]{64}$/.test(receipt.digest) ||
|
|
304
|
+
typeof receipt.plan_digest !== "string" ||
|
|
305
|
+
!/^[a-f0-9]{64}$/.test(receipt.plan_digest) ||
|
|
306
|
+
typeof receipt.nonce !== "string" ||
|
|
307
|
+
typeof receipt.created_at !== "string" ||
|
|
308
|
+
typeof receipt.expires_at !== "string" ||
|
|
309
|
+
typeof receipt.consumed !== "boolean" ||
|
|
310
|
+
(receipt.superseded !== undefined && typeof receipt.superseded !== "boolean") ||
|
|
311
|
+
typeof receipt.kind !== "string" ||
|
|
312
|
+
(receipt.scope !== "global" && receipt.scope !== "project") ||
|
|
313
|
+
typeof receipt.root !== "string" ||
|
|
314
|
+
(receipt.superseded_digests !== undefined &&
|
|
315
|
+
(!Array.isArray(receipt.superseded_digests) ||
|
|
316
|
+
!receipt.superseded_digests.every((value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)))) ||
|
|
317
|
+
typeof receipt.integrity !== "string") {
|
|
318
|
+
throw new LifecycleError("invalid_receipt", "Receipt has an unsupported schema");
|
|
319
|
+
}
|
|
320
|
+
const payloadRecord = receipt.payload;
|
|
321
|
+
const payloadPlanDigest = payloadRecord &&
|
|
322
|
+
typeof payloadRecord === "object" &&
|
|
323
|
+
typeof payloadRecord.plan_digest === "string"
|
|
324
|
+
? payloadRecord.plan_digest
|
|
325
|
+
: digest(receipt.payload);
|
|
326
|
+
if (receipt.plan_digest !== payloadPlanDigest) {
|
|
327
|
+
throw new LifecycleError("invalid_receipt", "Receipt digest does not match its payload");
|
|
328
|
+
}
|
|
329
|
+
if (receipt.integrity !== receiptIntegrity(receipt))
|
|
330
|
+
throw new LifecycleError("invalid_receipt", "Receipt integrity check failed");
|
|
331
|
+
return receipt;
|
|
332
|
+
}
|
|
333
|
+
function receiptIntegrity(receipt) {
|
|
334
|
+
const { integrity: _integrity, ...document } = receipt;
|
|
335
|
+
return digest(document);
|
|
336
|
+
}
|
|
337
|
+
export async function consumeReceipt(stateRoot, expected, now = Date.now()) {
|
|
338
|
+
if (!/^[a-f0-9]{64}$/.test(expected.digest))
|
|
339
|
+
throw new LifecycleError("invalid_digest", "Confirmation digest must be a SHA-256 value");
|
|
340
|
+
const raw = await readRegular(receiptPath(stateRoot));
|
|
341
|
+
if (!raw)
|
|
342
|
+
throw new LifecycleError("confirmation_unknown", "Confirmation receipt is missing");
|
|
343
|
+
const receipt = parseReceipt(raw);
|
|
344
|
+
if (receipt.digest !== expected.digest ||
|
|
345
|
+
receipt.kind !== expected.kind ||
|
|
346
|
+
receipt.scope !== expected.scope ||
|
|
347
|
+
receipt.root !== expected.root) {
|
|
348
|
+
if (receipt.superseded_digests?.includes(expected.digest))
|
|
349
|
+
throw new LifecycleError("superseded_plan", "Confirmation receipt was superseded by a newer plan");
|
|
350
|
+
throw new LifecycleError("confirmation_unknown", "Confirmation does not match the saved plan");
|
|
351
|
+
}
|
|
352
|
+
if (receipt.superseded)
|
|
353
|
+
throw new LifecycleError("superseded_plan", "Confirmation receipt was superseded by a newer plan");
|
|
354
|
+
if (receipt.consumed)
|
|
355
|
+
throw new LifecycleError("confirmation_consumed", "Confirmation receipt was already consumed");
|
|
356
|
+
if (Date.parse(receipt.expires_at) < now)
|
|
357
|
+
throw new LifecycleError("confirmation_expired", "Confirmation receipt expired; request a fresh plan");
|
|
358
|
+
const consumed = { ...receipt, consumed: true, integrity: "" };
|
|
359
|
+
consumed.integrity = receiptIntegrity(consumed);
|
|
360
|
+
await writeAtomic(receiptPath(stateRoot), Buffer.from(`${stable(consumed)}\n`), 0o600);
|
|
361
|
+
return receipt.payload;
|
|
362
|
+
}
|
|
363
|
+
/** Invalidate an active receipt when a preview is blocked before a replacement exists. */
|
|
364
|
+
export async function supersedeReceipt(stateRoot, now = Date.now()) {
|
|
365
|
+
const path = receiptPath(stateRoot);
|
|
366
|
+
const raw = await readRegular(path);
|
|
367
|
+
if (!raw)
|
|
368
|
+
return;
|
|
369
|
+
const receipt = parseReceipt(raw);
|
|
370
|
+
if (receipt.consumed || receipt.superseded || Date.parse(receipt.expires_at) < now)
|
|
371
|
+
return;
|
|
372
|
+
const superseded = { ...receipt, consumed: true, superseded: true, integrity: "" };
|
|
373
|
+
superseded.integrity = receiptIntegrity(superseded);
|
|
374
|
+
await writeAtomic(path, Buffer.from(`${stable(superseded)}\n`), 0o600);
|
|
375
|
+
}
|
|
376
|
+
function journalPath(stateRoot) {
|
|
377
|
+
return join(stateRoot, "transaction-journal.json");
|
|
378
|
+
}
|
|
379
|
+
function mutationRoot(root, mutation) {
|
|
380
|
+
return resolve(mutation.root ?? root);
|
|
381
|
+
}
|
|
382
|
+
async function snapshot(root, mutation) {
|
|
383
|
+
const targetRoot = mutationRoot(root, mutation);
|
|
384
|
+
const target = destination(targetRoot, mutation.path);
|
|
385
|
+
const content = await readRegular(target);
|
|
386
|
+
if (!content)
|
|
387
|
+
return { path: mutation.path, root: targetRoot, content: null, mode: null };
|
|
388
|
+
const metadata = await stat(target);
|
|
389
|
+
return {
|
|
390
|
+
path: mutation.path,
|
|
391
|
+
root: targetRoot,
|
|
392
|
+
content: content.toString("base64"),
|
|
393
|
+
mode: metadata.mode & 0o777,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
async function validateExpectation(root, mutation) {
|
|
397
|
+
const current = await readRegular(destination(mutationRoot(root, mutation), mutation.path));
|
|
398
|
+
if (mutation.expected.absent) {
|
|
399
|
+
if (current)
|
|
400
|
+
throw new LifecycleError("stale_plan", `Expected an absent target: ${mutation.path}`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (!mutation.expected.sha256 || !current || sha256(current) !== mutation.expected.sha256) {
|
|
404
|
+
throw new LifecycleError("stale_plan", `Destination changed after preview: ${mutation.path}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function writeJournal(stateRoot, journal) {
|
|
408
|
+
await writeAtomic(journalPath(stateRoot), Buffer.from(`${stable(journal)}\n`), 0o600);
|
|
409
|
+
}
|
|
410
|
+
async function restore(root, journal) {
|
|
411
|
+
const conflicts = [];
|
|
412
|
+
for (let index = journal.snapshots.length - 1; index >= 0; index -= 1) {
|
|
413
|
+
const item = journal.snapshots[index];
|
|
414
|
+
const operation = journal.operations[index];
|
|
415
|
+
const target = destination(item.root, item.path);
|
|
416
|
+
const current = await readRegular(target);
|
|
417
|
+
const currentHash = current ? sha256(current) : undefined;
|
|
418
|
+
const currentMode = current ? (await lstat(target)).mode & 0o777 : undefined;
|
|
419
|
+
const beforeHash = item.content === null ? undefined : sha256(Buffer.from(item.content, "base64"));
|
|
420
|
+
const beforeMode = item.mode ?? undefined;
|
|
421
|
+
const intendedHash = (operation.operation === "write" || operation.operation === "external-write") &&
|
|
422
|
+
operation.content
|
|
423
|
+
? sha256(Buffer.from(operation.content, "base64"))
|
|
424
|
+
: undefined;
|
|
425
|
+
const intendedMode = operation.operation === "write" || operation.operation === "external-write"
|
|
426
|
+
? operation.mode
|
|
427
|
+
: undefined;
|
|
428
|
+
const external = operation.operation === "external-write" || operation.operation === "external-remove";
|
|
429
|
+
const touched = external
|
|
430
|
+
? journal.external_started === true
|
|
431
|
+
: index < journal.published || index === journal.applying;
|
|
432
|
+
if (!touched) {
|
|
433
|
+
if (currentHash !== beforeHash || currentMode !== beforeMode)
|
|
434
|
+
conflicts.push(item.path);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (currentHash === beforeHash && currentMode === beforeMode)
|
|
438
|
+
continue;
|
|
439
|
+
if (currentHash !== intendedHash || currentMode !== intendedMode) {
|
|
440
|
+
conflicts.push(item.path);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (item.content === null) {
|
|
444
|
+
if (current)
|
|
445
|
+
await unlink(target);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
await writeAtomic(target, Buffer.from(item.content, "base64"), item.mode ?? 0o600);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const directory of [...journal.created_directories].reverse()) {
|
|
452
|
+
try {
|
|
453
|
+
const metadata = await lstat(directory.path);
|
|
454
|
+
if (!metadata.isDirectory() ||
|
|
455
|
+
metadata.isSymbolicLink() ||
|
|
456
|
+
metadata.dev !== directory.device ||
|
|
457
|
+
metadata.ino !== directory.inode) {
|
|
458
|
+
conflicts.push(directory.path);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
await rmdir(directory.path);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
const code = error.code;
|
|
465
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY")
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (conflicts.length)
|
|
470
|
+
throw new LifecycleError("recovery_conflict", `Transaction targets changed outside the lifecycle lock: ${conflicts.join(", ")}`);
|
|
471
|
+
}
|
|
472
|
+
function parseJournal(raw, expectedRoot, allowedRoots) {
|
|
473
|
+
let value;
|
|
474
|
+
try {
|
|
475
|
+
value = JSON.parse(raw.toString("utf8"));
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
throw new LifecycleError("invalid_journal", "Transaction journal is not valid JSON");
|
|
479
|
+
}
|
|
480
|
+
const journal = value;
|
|
481
|
+
const roots = new Set([expectedRoot, ...allowedRoots].map((value) => resolve(value)));
|
|
482
|
+
if (journal.schema_version !== 1 ||
|
|
483
|
+
journal.root !== expectedRoot ||
|
|
484
|
+
!Array.isArray(journal.operations) ||
|
|
485
|
+
!Array.isArray(journal.snapshots) ||
|
|
486
|
+
journal.operations.length !== journal.snapshots.length ||
|
|
487
|
+
!Array.isArray(journal.created_directories) ||
|
|
488
|
+
typeof journal.published !== "number" ||
|
|
489
|
+
!Number.isSafeInteger(journal.published) ||
|
|
490
|
+
journal.published < 0 ||
|
|
491
|
+
journal.published > journal.operations.length ||
|
|
492
|
+
(journal.external_started !== undefined && typeof journal.external_started !== "boolean") ||
|
|
493
|
+
(journal.applying !== null &&
|
|
494
|
+
(typeof journal.applying !== "number" ||
|
|
495
|
+
!Number.isSafeInteger(journal.applying) ||
|
|
496
|
+
journal.applying !== journal.published ||
|
|
497
|
+
journal.applying < 0 ||
|
|
498
|
+
journal.applying >= journal.operations.length))) {
|
|
499
|
+
throw new LifecycleError("invalid_journal", "Transaction journal has an unsupported schema");
|
|
500
|
+
}
|
|
501
|
+
for (const item of journal.snapshots) {
|
|
502
|
+
assertSafeRelative(item.path);
|
|
503
|
+
if (item.root !== undefined && (!isAbsolute(item.root) || !roots.has(resolve(item.root))))
|
|
504
|
+
throw new LifecycleError("invalid_journal", "Transaction snapshot root is not allowed");
|
|
505
|
+
}
|
|
506
|
+
for (const item of journal.snapshots) {
|
|
507
|
+
if ((item.content !== null &&
|
|
508
|
+
(typeof item.content !== "string" ||
|
|
509
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(item.content))) ||
|
|
510
|
+
(item.mode !== null &&
|
|
511
|
+
(!Number.isSafeInteger(item.mode) || item.mode < 0 || item.mode > 0o777))) {
|
|
512
|
+
throw new LifecycleError("invalid_journal", "Transaction journal contains an invalid snapshot");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const item of journal.operations) {
|
|
516
|
+
assertSafeRelative(item.path);
|
|
517
|
+
if (item.root !== undefined && (!isAbsolute(item.root) || !roots.has(resolve(item.root))))
|
|
518
|
+
throw new LifecycleError("invalid_journal", "Transaction operation root is not allowed");
|
|
519
|
+
if (item.operation !== "write" &&
|
|
520
|
+
item.operation !== "remove" &&
|
|
521
|
+
item.operation !== "external-write" &&
|
|
522
|
+
item.operation !== "external-remove")
|
|
523
|
+
throw new LifecycleError("invalid_journal", "Transaction journal contains an invalid operation");
|
|
524
|
+
if ((item.operation === "write" || item.operation === "external-write") &&
|
|
525
|
+
(typeof item.content !== "string" ||
|
|
526
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(item.content) ||
|
|
527
|
+
typeof item.mode !== "number" ||
|
|
528
|
+
!Number.isSafeInteger(item.mode) ||
|
|
529
|
+
item.mode < 0 ||
|
|
530
|
+
item.mode > 0o777))
|
|
531
|
+
throw new LifecycleError("invalid_journal", "Transaction journal contains invalid write content");
|
|
532
|
+
}
|
|
533
|
+
for (let index = 0; index < journal.operations.length; index += 1) {
|
|
534
|
+
const operation = journal.operations[index];
|
|
535
|
+
const snapshot = journal.snapshots[index];
|
|
536
|
+
if (operation.path !== snapshot.path ||
|
|
537
|
+
resolve(operation.root ?? expectedRoot) !== resolve(snapshot.root ?? expectedRoot))
|
|
538
|
+
throw new LifecycleError("invalid_journal", "Transaction journal operation and snapshot targets differ");
|
|
539
|
+
}
|
|
540
|
+
if (!journal.created_directories.every((item) => item &&
|
|
541
|
+
typeof item.path === "string" &&
|
|
542
|
+
(inside(resolve(expectedRoot), resolve(item.path)) ||
|
|
543
|
+
[...roots].some((root) => inside(root, resolve(item.path)))) &&
|
|
544
|
+
Number.isSafeInteger(item.device) &&
|
|
545
|
+
item.device >= 0 &&
|
|
546
|
+
Number.isSafeInteger(item.inode) &&
|
|
547
|
+
item.inode > 0)) {
|
|
548
|
+
throw new LifecycleError("invalid_journal", "Transaction journal contains an unsafe created directory");
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
...journal,
|
|
552
|
+
snapshots: journal.snapshots.map((item) => ({ ...item, root: item.root ?? expectedRoot })),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
export async function recoverTransaction(root, stateRoot, allowedRoots = []) {
|
|
556
|
+
const raw = await readRegular(journalPath(stateRoot));
|
|
557
|
+
if (!raw)
|
|
558
|
+
return false;
|
|
559
|
+
const journal = parseJournal(raw, resolve(root), allowedRoots);
|
|
560
|
+
await restore(root, journal);
|
|
561
|
+
await unlink(journalPath(stateRoot));
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
async function ensureTransactionDirectories(root, mutation, stateRoot, journal) {
|
|
565
|
+
const targetRoot = mutationRoot(root, mutation);
|
|
566
|
+
await ensureDirectory(dirname(targetRoot), 0o700);
|
|
567
|
+
const relativeParent = relative(targetRoot, dirname(destination(targetRoot, mutation.path)));
|
|
568
|
+
const parts = relativeParent.split(sep).filter(Boolean);
|
|
569
|
+
const directories = [
|
|
570
|
+
targetRoot,
|
|
571
|
+
...parts.map((_, index) => resolve(targetRoot, ...parts.slice(0, index + 1))),
|
|
572
|
+
];
|
|
573
|
+
for (const directory of directories) {
|
|
574
|
+
const existing = await lstatSafe(directory);
|
|
575
|
+
if (existing) {
|
|
576
|
+
if (!existing.isDirectory() || existing.isSymbolicLink())
|
|
577
|
+
throw new LifecycleError("unsafe_path", `Transaction parent is unsafe: ${directory}`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
await mkdir(directory, { mode: 0o700 });
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (error.code !== "EEXIST")
|
|
585
|
+
throw error;
|
|
586
|
+
const raced = await lstat(directory);
|
|
587
|
+
if (!raced.isDirectory() || raced.isSymbolicLink())
|
|
588
|
+
throw new LifecycleError("unsafe_path", `Transaction parent is unsafe: ${directory}`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const metadata = await lstat(directory);
|
|
592
|
+
journal.created_directories.push({
|
|
593
|
+
path: directory,
|
|
594
|
+
device: metadata.dev,
|
|
595
|
+
inode: metadata.ino,
|
|
596
|
+
});
|
|
597
|
+
await writeJournal(stateRoot, journal);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
export async function applyTransaction(root, stateRoot, mutations, options = {}) {
|
|
601
|
+
if (await readRegular(journalPath(stateRoot)))
|
|
602
|
+
throw new LifecycleError("recovery_required", "An interrupted transaction must be recovered before apply");
|
|
603
|
+
const ordered = [
|
|
604
|
+
...mutations.filter((mutation) => mutation.operation !== "external-write" && mutation.operation !== "external-remove"),
|
|
605
|
+
...mutations.filter((mutation) => mutation.operation === "external-write" || mutation.operation === "external-remove"),
|
|
606
|
+
];
|
|
607
|
+
const unique = new Set();
|
|
608
|
+
for (const mutation of ordered) {
|
|
609
|
+
assertSafeRelative(mutation.path);
|
|
610
|
+
const key = destination(mutationRoot(root, mutation), mutation.path);
|
|
611
|
+
if (unique.has(key))
|
|
612
|
+
throw new LifecycleError("invalid_plan", `Duplicate transaction target: ${mutation.path}`);
|
|
613
|
+
unique.add(key);
|
|
614
|
+
await validateExpectation(root, mutation);
|
|
615
|
+
}
|
|
616
|
+
const snapshots = await Promise.all(ordered.map((mutation) => snapshot(root, mutation)));
|
|
617
|
+
const journal = {
|
|
618
|
+
schema_version: 1,
|
|
619
|
+
root: resolve(root),
|
|
620
|
+
operations: ordered.map((mutation) => mutation.operation === "write" || mutation.operation === "external-write"
|
|
621
|
+
? {
|
|
622
|
+
path: mutation.path,
|
|
623
|
+
...(mutation.root ? { root: resolve(mutation.root) } : {}),
|
|
624
|
+
operation: mutation.operation,
|
|
625
|
+
content: mutation.content.toString("base64"),
|
|
626
|
+
mode: mutation.mode,
|
|
627
|
+
expected: mutation.expected,
|
|
628
|
+
}
|
|
629
|
+
: {
|
|
630
|
+
path: mutation.path,
|
|
631
|
+
...(mutation.root ? { root: resolve(mutation.root) } : {}),
|
|
632
|
+
operation: mutation.operation,
|
|
633
|
+
expected: mutation.expected,
|
|
634
|
+
}),
|
|
635
|
+
snapshots,
|
|
636
|
+
created_directories: [],
|
|
637
|
+
published: 0,
|
|
638
|
+
applying: null,
|
|
639
|
+
external_started: false,
|
|
640
|
+
};
|
|
641
|
+
await writeJournal(stateRoot, journal);
|
|
642
|
+
try {
|
|
643
|
+
const native = ordered.filter((mutation) => mutation.operation !== "external-write" && mutation.operation !== "external-remove");
|
|
644
|
+
const external = ordered.slice(native.length);
|
|
645
|
+
for (const mutation of native) {
|
|
646
|
+
journal.applying = journal.published;
|
|
647
|
+
await writeJournal(stateRoot, journal);
|
|
648
|
+
options.beforePublish?.(journal.published);
|
|
649
|
+
if (mutation.operation === "write")
|
|
650
|
+
await ensureTransactionDirectories(root, mutation, stateRoot, journal);
|
|
651
|
+
await validateExpectation(root, mutation);
|
|
652
|
+
const target = destination(mutationRoot(root, mutation), mutation.path);
|
|
653
|
+
if (mutation.operation === "write")
|
|
654
|
+
await writeAtomic(target, mutation.content, mutation.mode);
|
|
655
|
+
else
|
|
656
|
+
await unlink(target);
|
|
657
|
+
journal.published += 1;
|
|
658
|
+
journal.applying = null;
|
|
659
|
+
await writeJournal(stateRoot, journal);
|
|
660
|
+
const injected = options.afterPublish?.(journal.published);
|
|
661
|
+
if (injected === "interrupt")
|
|
662
|
+
throw new LifecycleError("test_interruption", "Injected transaction interruption");
|
|
663
|
+
if (injected === "fail")
|
|
664
|
+
throw new LifecycleError("test_failure", "Injected transaction failure");
|
|
665
|
+
}
|
|
666
|
+
if (external.length) {
|
|
667
|
+
for (const mutation of external)
|
|
668
|
+
await validateExpectation(root, mutation);
|
|
669
|
+
journal.external_started = true;
|
|
670
|
+
await writeJournal(stateRoot, journal);
|
|
671
|
+
await options.applyExternal?.();
|
|
672
|
+
}
|
|
673
|
+
for (const mutation of ordered) {
|
|
674
|
+
const target = destination(mutationRoot(root, mutation), mutation.path);
|
|
675
|
+
const current = await readRegular(target);
|
|
676
|
+
if (((mutation.operation === "remove" || mutation.operation === "external-remove") &&
|
|
677
|
+
current) ||
|
|
678
|
+
((mutation.operation === "write" || mutation.operation === "external-write") &&
|
|
679
|
+
(!current ||
|
|
680
|
+
!current.equals(mutation.content) ||
|
|
681
|
+
((await stat(target)).mode & 0o777) !== mutation.mode))) {
|
|
682
|
+
throw new LifecycleError("final_validation_failed", `Transaction target failed final validation: ${mutation.path}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
await options.validateFinal?.();
|
|
686
|
+
await unlink(journalPath(stateRoot));
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (error instanceof LifecycleError && error.code === "test_interruption")
|
|
690
|
+
throw error;
|
|
691
|
+
try {
|
|
692
|
+
await restore(root, journal);
|
|
693
|
+
await unlink(journalPath(stateRoot));
|
|
694
|
+
}
|
|
695
|
+
catch (rollbackError) {
|
|
696
|
+
throw new LifecycleError("rollback_failed", `Transaction failed and rollback failed: ${String(rollbackError)}`);
|
|
697
|
+
}
|
|
698
|
+
throw new LifecycleError("rolled_back", `Transaction failed and was rolled back: ${error instanceof Error ? error.message : String(error)}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
export async function appendPrivate(path, value) {
|
|
702
|
+
await ensureDirectory(dirname(path), 0o700);
|
|
703
|
+
await assertSafePath(path);
|
|
704
|
+
const existing = await lstatSafe(path);
|
|
705
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink() || existing.nlink !== 1))
|
|
706
|
+
throw new LifecycleError("unsafe_path", `Append target is unsafe: ${path}`);
|
|
707
|
+
const handle = await open(path, constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
708
|
+
try {
|
|
709
|
+
const metadata = await handle.stat();
|
|
710
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || (metadata.mode & 0o777) !== 0o600)
|
|
711
|
+
throw new LifecycleError("unsafe_path", `Append target is unsafe: ${path}`);
|
|
712
|
+
await handle.writeFile(`${stable(value)}\n`);
|
|
713
|
+
await handle.sync();
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
await handle.close();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
export async function listDirectRegular(directory) {
|
|
720
|
+
const metadata = await lstatSafe(directory);
|
|
721
|
+
if (!metadata)
|
|
722
|
+
return [];
|
|
723
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
724
|
+
throw new LifecycleError("unsafe_path", `Directory is unsafe: ${directory}`);
|
|
725
|
+
const values = [];
|
|
726
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
727
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
728
|
+
throw new LifecycleError("unsafe_path", `Directory entry is not a regular file: ${entry.name}`);
|
|
729
|
+
const path = join(directory, entry.name);
|
|
730
|
+
const info = await lstat(path);
|
|
731
|
+
if (info.nlink !== 1)
|
|
732
|
+
throw new LifecycleError("unsafe_path", `Directory entry has multiple hard links: ${entry.name}`);
|
|
733
|
+
values.push({ name: entry.name, content: await readFile(path), mode: info.mode & 0o777 });
|
|
734
|
+
}
|
|
735
|
+
return values;
|
|
736
|
+
}
|