@msn-control/liftoff 0.3.4 → 0.4.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/README.md +82 -32
- package/assets/locks/frontend/package-lock.json +2217 -0
- package/assets/locks/frontend/package.json +19 -0
- package/assets/locks/node-backend/package-lock.json +4352 -0
- package/assets/locks/node-backend/package.json +32 -0
- package/dist/args.d.ts +42 -1
- package/dist/args.js +170 -37
- package/dist/args.js.map +1 -1
- package/dist/catalogs.d.ts +10 -1
- package/dist/catalogs.js +89 -0
- package/dist/catalogs.js.map +1 -1
- package/dist/cli.js +11 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +832 -227
- package/dist/commands.js.map +1 -1
- package/dist/file-system.js +89 -5
- package/dist/file-system.js.map +1 -1
- package/dist/framework-adapters.d.ts +18 -0
- package/dist/framework-adapters.js +115 -0
- package/dist/framework-adapters.js.map +1 -0
- package/dist/framework-validation.d.ts +8 -0
- package/dist/framework-validation.js +83 -0
- package/dist/framework-validation.js.map +1 -0
- package/dist/init-filesystem.d.ts +102 -0
- package/dist/init-filesystem.js +762 -0
- package/dist/init-filesystem.js.map +1 -0
- package/dist/interactive.d.ts +43 -2
- package/dist/interactive.js +202 -91
- package/dist/interactive.js.map +1 -1
- package/dist/npm-template-assets.d.ts +3 -0
- package/dist/npm-template-assets.js +32 -0
- package/dist/npm-template-assets.js.map +1 -0
- package/dist/planner.d.ts +5 -0
- package/dist/planner.js +87 -21
- package/dist/planner.js.map +1 -1
- package/dist/process-runner.d.ts +28 -0
- package/dist/process-runner.js +83 -0
- package/dist/process-runner.js.map +1 -0
- package/dist/project-dependencies.d.ts +30 -0
- package/dist/project-dependencies.js +164 -0
- package/dist/project-dependencies.js.map +1 -0
- package/dist/published-verifier.js +1 -1
- package/dist/published-verifier.js.map +1 -1
- package/dist/reconcile.js +4 -1
- package/dist/reconcile.js.map +1 -1
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +9 -0
- package/dist/runtime.js.map +1 -0
- package/dist/standard-templates.js +3 -30
- package/dist/standard-templates.js.map +1 -1
- package/dist/templates.d.ts +9 -1
- package/dist/templates.js +110 -46
- package/dist/templates.js.map +1 -1
- package/dist/terminal.d.ts +128 -0
- package/dist/terminal.js +598 -0
- package/dist/terminal.js.map +1 -0
- package/dist/types.d.ts +38 -1
- package/dist/workstation-catalog.d.ts +20 -0
- package/dist/workstation-catalog.js +122 -0
- package/dist/workstation-catalog.js.map +1 -0
- package/dist/workstation.d.ts +76 -0
- package/dist/workstation.js +461 -0
- package/dist/workstation.js.map +1 -0
- package/package.json +8 -2
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { validateArtifactPathParts, writeProjectFile } from './file-system.js';
|
|
6
|
+
export class InitFileSystemError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'InitFileSystemError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class MergeApplyError extends InitFileSystemError {
|
|
13
|
+
rollback;
|
|
14
|
+
constructor(message, rollback) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.rollback = rollback;
|
|
17
|
+
this.name = 'MergeApplyError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const authorizedMergePlans = new WeakSet();
|
|
21
|
+
function errorCode(error) {
|
|
22
|
+
return typeof error === 'object' && error !== null && 'code' in error &&
|
|
23
|
+
typeof error.code === 'string'
|
|
24
|
+
? error.code
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
function errorMessage(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
function portablePath(pathParts) {
|
|
31
|
+
return pathParts.join('/');
|
|
32
|
+
}
|
|
33
|
+
function comparePortable(left, right) {
|
|
34
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
35
|
+
}
|
|
36
|
+
function hash(content) {
|
|
37
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
38
|
+
}
|
|
39
|
+
function isPathWithin(root, candidate, platform) {
|
|
40
|
+
const normalize = (value) => normalizeComparisonPath(value, platform);
|
|
41
|
+
const rootValue = normalize(root).replace(/\/+$/g, '');
|
|
42
|
+
const candidateValue = normalize(candidate);
|
|
43
|
+
return candidateValue === rootValue || candidateValue.startsWith(`${rootValue}/`);
|
|
44
|
+
}
|
|
45
|
+
export function normalizeComparisonPath(value, platform = process.platform) {
|
|
46
|
+
const normalized = (platform === 'win32' ? path.win32.resolve(value) : path.resolve(value))
|
|
47
|
+
.replaceAll('\\', '/')
|
|
48
|
+
.replace(/\/+$/g, '');
|
|
49
|
+
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
50
|
+
}
|
|
51
|
+
export async function discoverGitRoot(cwd, runner, platform = process.platform) {
|
|
52
|
+
let canonicalCwd;
|
|
53
|
+
try {
|
|
54
|
+
canonicalCwd = await realpath(cwd);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new InitFileSystemError(`Unable to resolve working directory ${cwd}: ${errorMessage(error)}`);
|
|
58
|
+
}
|
|
59
|
+
const result = await runner.run({ executable: 'git', args: ['rev-parse', '--show-toplevel'] }, { cwd: canonicalCwd, timeoutMs: 15_000 });
|
|
60
|
+
if (result.status !== 0) {
|
|
61
|
+
const stderr = result.stderr.trim();
|
|
62
|
+
if (!result.timedOut &&
|
|
63
|
+
result.errorCode === undefined &&
|
|
64
|
+
/not a git repository/i.test(stderr)) {
|
|
65
|
+
return { cwd, canonicalCwd, exact: false };
|
|
66
|
+
}
|
|
67
|
+
const detail = result.timedOut
|
|
68
|
+
? 'the command timed out'
|
|
69
|
+
: (result.errorMessage ?? stderr) || `git exited with status ${result.status}`;
|
|
70
|
+
throw new InitFileSystemError(`Unable to determine the Git worktree root: ${detail}`);
|
|
71
|
+
}
|
|
72
|
+
const reportedRoot = result.stdout.trim().split(/\r?\n/)[0];
|
|
73
|
+
if (!reportedRoot) {
|
|
74
|
+
return { cwd, canonicalCwd, exact: false };
|
|
75
|
+
}
|
|
76
|
+
let root;
|
|
77
|
+
try {
|
|
78
|
+
root = await realpath(path.resolve(canonicalCwd, reportedRoot));
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw new InitFileSystemError(`Git reported an unreadable worktree root ${reportedRoot}: ${errorMessage(error)}`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
cwd,
|
|
85
|
+
canonicalCwd,
|
|
86
|
+
root,
|
|
87
|
+
exact: normalizeComparisonPath(root, platform) === normalizeComparisonPath(canonicalCwd, platform)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export async function resolveInitTarget(cwd, safeProjectName, runner, platform = process.platform) {
|
|
91
|
+
const git = await discoverGitRoot(cwd, runner, platform);
|
|
92
|
+
return resolveInitTargetFromDiscovery(git, safeProjectName);
|
|
93
|
+
}
|
|
94
|
+
export function resolveInitTargetFromDiscovery(git, safeProjectName) {
|
|
95
|
+
if (git.exact && git.root) {
|
|
96
|
+
return { root: git.root, mode: 'in-place', gitRoot: git.root };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
root: path.join(git.canonicalCwd, safeProjectName),
|
|
100
|
+
mode: 'named-child',
|
|
101
|
+
...(git.root ? { gitRoot: git.root } : {})
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function assertSafeInitTarget(target, confinementRoot) {
|
|
105
|
+
let canonicalTarget;
|
|
106
|
+
try {
|
|
107
|
+
const details = await lstat(target.root);
|
|
108
|
+
if (details.isSymbolicLink()) {
|
|
109
|
+
throw new InitFileSystemError(`Initialization target is a symlink and cannot be overwritten: ${target.root}`);
|
|
110
|
+
}
|
|
111
|
+
if (!details.isDirectory()) {
|
|
112
|
+
throw new InitFileSystemError(`Initialization target exists and is not a directory: ${target.root}`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await lstat(path.join(target.root, 'liftoff.manifest.json'));
|
|
116
|
+
throw new InitFileSystemError(`A Liftoff manifest already exists at ${target.root}. Use \`liftoff update\` instead.`);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof InitFileSystemError) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
123
|
+
throw new InitFileSystemError(`Unable to inspect the Liftoff manifest guard: ${errorMessage(error)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
canonicalTarget = await realpath(target.root);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error instanceof InitFileSystemError) {
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
133
|
+
throw new InitFileSystemError(`Unable to inspect initialization target ${target.root}: ${errorMessage(error)}`);
|
|
134
|
+
}
|
|
135
|
+
const parent = path.dirname(target.root);
|
|
136
|
+
let details;
|
|
137
|
+
try {
|
|
138
|
+
details = await lstat(parent);
|
|
139
|
+
}
|
|
140
|
+
catch (parentError) {
|
|
141
|
+
throw new InitFileSystemError(`Initialization target parent is unavailable: ${errorMessage(parentError)}`);
|
|
142
|
+
}
|
|
143
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
144
|
+
throw new InitFileSystemError(`Initialization target has an unsafe parent: ${parent}`);
|
|
145
|
+
}
|
|
146
|
+
canonicalTarget = path.join(await realpath(parent), path.basename(target.root));
|
|
147
|
+
}
|
|
148
|
+
if (confinementRoot) {
|
|
149
|
+
const canonicalConfinement = await realpath(confinementRoot);
|
|
150
|
+
if (!isPathWithin(canonicalConfinement, canonicalTarget, process.platform)) {
|
|
151
|
+
throw new InitFileSystemError(`Initialization target escapes the working directory: ${target.root}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export async function withStagingArea(operation) {
|
|
156
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'liftoff-init-'));
|
|
157
|
+
const area = {
|
|
158
|
+
root,
|
|
159
|
+
origins: new Map(),
|
|
160
|
+
frameworkAllowedRoots: new Set()
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
return await operation(area);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await rm(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export async function writeStagedArtifacts(area, artifacts, origin) {
|
|
170
|
+
for (const artifact of artifacts) {
|
|
171
|
+
const pathParts = validateArtifactPathParts(artifact.pathParts);
|
|
172
|
+
await writeProjectFile(area.root, pathParts, artifact.content);
|
|
173
|
+
area.origins.set(portablePath(pathParts), origin);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function walkTree(root) {
|
|
177
|
+
const entries = [];
|
|
178
|
+
const visit = async (pathParts) => {
|
|
179
|
+
const current = path.join(root, ...pathParts);
|
|
180
|
+
const children = await readdir(current, { withFileTypes: true });
|
|
181
|
+
children.sort((left, right) => comparePortable(left.name, right.name));
|
|
182
|
+
for (const child of children) {
|
|
183
|
+
const childParts = [...pathParts, child.name];
|
|
184
|
+
validateArtifactPathParts(childParts, 'Staged path');
|
|
185
|
+
const childPath = path.join(root, ...childParts);
|
|
186
|
+
const details = await lstat(childPath);
|
|
187
|
+
if (details.isSymbolicLink()) {
|
|
188
|
+
entries.push({ pathParts: childParts, type: 'symlink' });
|
|
189
|
+
}
|
|
190
|
+
else if (details.isDirectory()) {
|
|
191
|
+
entries.push({ pathParts: childParts, type: 'directory', mode: details.mode & 0o7777 });
|
|
192
|
+
await visit(childParts);
|
|
193
|
+
}
|
|
194
|
+
else if (details.isFile()) {
|
|
195
|
+
const content = await readFile(childPath);
|
|
196
|
+
entries.push({
|
|
197
|
+
pathParts: childParts,
|
|
198
|
+
type: 'file',
|
|
199
|
+
contentHash: hash(content),
|
|
200
|
+
mode: details.mode & 0o7777
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
entries.push({ pathParts: childParts, type: 'other' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
await visit([]);
|
|
209
|
+
return entries;
|
|
210
|
+
}
|
|
211
|
+
export async function captureTreeState(root) {
|
|
212
|
+
const state = new Map();
|
|
213
|
+
for (const entry of await walkTree(root)) {
|
|
214
|
+
state.set(portablePath(entry.pathParts), entry);
|
|
215
|
+
}
|
|
216
|
+
return state;
|
|
217
|
+
}
|
|
218
|
+
export async function claimFrameworkChanges(area, before, allowedRoots) {
|
|
219
|
+
const after = await captureTreeState(area.root);
|
|
220
|
+
const changed = new Set();
|
|
221
|
+
for (const key of new Set([...before.keys(), ...after.keys()])) {
|
|
222
|
+
const previous = before.get(key);
|
|
223
|
+
const next = after.get(key);
|
|
224
|
+
if (previous?.type !== next?.type ||
|
|
225
|
+
previous?.contentHash !== next?.contentHash ||
|
|
226
|
+
previous?.mode !== next?.mode) {
|
|
227
|
+
changed.add(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const sorted = [...changed].sort(comparePortable);
|
|
231
|
+
for (const key of sorted) {
|
|
232
|
+
const root = key.split('/')[0];
|
|
233
|
+
if (!allowedRoots.includes(root)) {
|
|
234
|
+
throw new InitFileSystemError(`Framework initializer wrote outside its approved roots: ${key}`);
|
|
235
|
+
}
|
|
236
|
+
const entry = after.get(key);
|
|
237
|
+
if (entry?.type === 'file' || entry?.type === 'symlink' || entry?.type === 'other') {
|
|
238
|
+
area.origins.set(key, 'framework');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const root of allowedRoots) {
|
|
242
|
+
area.frameworkAllowedRoots.add(root);
|
|
243
|
+
}
|
|
244
|
+
return sorted;
|
|
245
|
+
}
|
|
246
|
+
export async function validateStagedTree(area) {
|
|
247
|
+
let state;
|
|
248
|
+
try {
|
|
249
|
+
state = await captureTreeState(area.root);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
throw new InitFileSystemError(`Unable to read staged output: ${errorMessage(error)}`);
|
|
253
|
+
}
|
|
254
|
+
const files = [];
|
|
255
|
+
for (const [relativePath, entry] of state) {
|
|
256
|
+
if (entry.type === 'symlink') {
|
|
257
|
+
throw new InitFileSystemError(`Staged output contains a forbidden symlink: ${relativePath}`);
|
|
258
|
+
}
|
|
259
|
+
if (entry.type === 'other') {
|
|
260
|
+
throw new InitFileSystemError(`Staged output contains an unsupported filesystem entry: ${relativePath}`);
|
|
261
|
+
}
|
|
262
|
+
if (entry.type !== 'file') {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const origin = area.origins.get(relativePath);
|
|
266
|
+
if (!origin) {
|
|
267
|
+
throw new InitFileSystemError(`Staged file has no declared owner: ${relativePath}`);
|
|
268
|
+
}
|
|
269
|
+
if (origin === 'framework' && !area.frameworkAllowedRoots.has(entry.pathParts[0])) {
|
|
270
|
+
throw new InitFileSystemError(`Framework-owned staged file is outside approved roots: ${relativePath}`);
|
|
271
|
+
}
|
|
272
|
+
let content;
|
|
273
|
+
try {
|
|
274
|
+
content = await readFile(path.join(area.root, ...entry.pathParts));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw new InitFileSystemError(`Staged file is unreadable at ${relativePath}: ${errorMessage(error)}`);
|
|
278
|
+
}
|
|
279
|
+
files.push({
|
|
280
|
+
pathParts: entry.pathParts,
|
|
281
|
+
relativePath,
|
|
282
|
+
content,
|
|
283
|
+
contentHash: hash(content),
|
|
284
|
+
mode: entry.mode ?? 0o666,
|
|
285
|
+
origin
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return files.sort((left, right) => comparePortable(left.relativePath, right.relativePath));
|
|
289
|
+
}
|
|
290
|
+
async function inspectDestination(targetRoot, pathParts) {
|
|
291
|
+
let current = targetRoot;
|
|
292
|
+
for (const [index, part] of pathParts.entries()) {
|
|
293
|
+
current = path.join(current, part);
|
|
294
|
+
let details;
|
|
295
|
+
try {
|
|
296
|
+
details = await lstat(current);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (errorCode(error) === 'ENOENT') {
|
|
300
|
+
return { type: 'missing' };
|
|
301
|
+
}
|
|
302
|
+
throw new InitFileSystemError(`Unable to inspect destination ${portablePath(pathParts)}: ${errorMessage(error)}`);
|
|
303
|
+
}
|
|
304
|
+
if (details.isSymbolicLink()) {
|
|
305
|
+
return { type: 'symlink', detail: `symlink at ${portablePath(pathParts.slice(0, index + 1))}` };
|
|
306
|
+
}
|
|
307
|
+
if (index < pathParts.length - 1 && !details.isDirectory()) {
|
|
308
|
+
return { type: details.isFile() ? 'file' : 'other', detail: `non-directory ancestor at ${portablePath(pathParts.slice(0, index + 1))}` };
|
|
309
|
+
}
|
|
310
|
+
if (index === pathParts.length - 1) {
|
|
311
|
+
if (details.isDirectory()) {
|
|
312
|
+
return { type: 'directory' };
|
|
313
|
+
}
|
|
314
|
+
if (details.isFile()) {
|
|
315
|
+
const content = await readFile(current);
|
|
316
|
+
return { type: 'file', contentHash: hash(content), mode: details.mode & 0o7777 };
|
|
317
|
+
}
|
|
318
|
+
return { type: 'other' };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { type: 'missing' };
|
|
322
|
+
}
|
|
323
|
+
async function captureTargetRootSnapshot(targetRoot) {
|
|
324
|
+
const parent = path.dirname(targetRoot);
|
|
325
|
+
let parentDetails;
|
|
326
|
+
try {
|
|
327
|
+
parentDetails = await lstat(parent);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
throw new InitFileSystemError(`Initialization target parent is unavailable: ${errorMessage(error)}`);
|
|
331
|
+
}
|
|
332
|
+
if (parentDetails.isSymbolicLink() || !parentDetails.isDirectory()) {
|
|
333
|
+
throw new InitFileSystemError(`Initialization target has an unsafe parent: ${parent}`);
|
|
334
|
+
}
|
|
335
|
+
const parentCanonicalPath = await realpath(parent);
|
|
336
|
+
const parentIdentity = {
|
|
337
|
+
parentCanonicalPath,
|
|
338
|
+
parentDevice: parentDetails.dev,
|
|
339
|
+
parentInode: parentDetails.ino
|
|
340
|
+
};
|
|
341
|
+
let details;
|
|
342
|
+
try {
|
|
343
|
+
details = await lstat(targetRoot);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (errorCode(error) === 'ENOENT') {
|
|
347
|
+
return {
|
|
348
|
+
state: 'missing',
|
|
349
|
+
canonicalPath: path.join(parentCanonicalPath, path.basename(targetRoot)),
|
|
350
|
+
...parentIdentity
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
throw new InitFileSystemError(`Unable to inspect initialization target ${targetRoot}: ${errorMessage(error)}`);
|
|
354
|
+
}
|
|
355
|
+
if (details.isSymbolicLink()) {
|
|
356
|
+
throw new InitFileSystemError(`Initialization target is a symlink and cannot be overwritten: ${targetRoot}`);
|
|
357
|
+
}
|
|
358
|
+
if (!details.isDirectory()) {
|
|
359
|
+
throw new InitFileSystemError(`Initialization target exists and is not a directory: ${targetRoot}`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
state: 'directory',
|
|
363
|
+
canonicalPath: await realpath(targetRoot),
|
|
364
|
+
device: details.dev,
|
|
365
|
+
inode: details.ino,
|
|
366
|
+
...parentIdentity
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
async function assertTargetRootSnapshot(expected, targetRoot) {
|
|
370
|
+
const current = await captureTargetRootSnapshot(targetRoot);
|
|
371
|
+
if (current.state !== expected.state ||
|
|
372
|
+
current.canonicalPath !== expected.canonicalPath ||
|
|
373
|
+
current.parentCanonicalPath !== expected.parentCanonicalPath ||
|
|
374
|
+
current.parentDevice !== expected.parentDevice ||
|
|
375
|
+
current.parentInode !== expected.parentInode ||
|
|
376
|
+
current.device !== expected.device ||
|
|
377
|
+
current.inode !== expected.inode) {
|
|
378
|
+
throw new InitFileSystemError(`Initialization target root changed after preflight: ${targetRoot}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
export async function buildMergePreflight(area, targetRoot) {
|
|
382
|
+
const targetRootSnapshot = await captureTargetRootSnapshot(targetRoot);
|
|
383
|
+
const files = await validateStagedTree(area);
|
|
384
|
+
const fileMap = new Map(files.map((file) => [file.relativePath, file]));
|
|
385
|
+
const state = await captureTreeState(area.root);
|
|
386
|
+
const entries = [];
|
|
387
|
+
for (const [relativePath, staged] of state) {
|
|
388
|
+
if (staged.type !== 'file' && staged.type !== 'directory') {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const destination = await inspectDestination(targetRoot, staged.pathParts);
|
|
392
|
+
const stagedFile = fileMap.get(relativePath);
|
|
393
|
+
let action;
|
|
394
|
+
let detail;
|
|
395
|
+
if (relativePath === 'liftoff.manifest.json' && destination.type !== 'missing') {
|
|
396
|
+
action = 'blocked';
|
|
397
|
+
detail = 'an existing Liftoff manifest must be handled with liftoff update';
|
|
398
|
+
}
|
|
399
|
+
else if (destination.type === 'missing') {
|
|
400
|
+
action = 'create';
|
|
401
|
+
detail = staged.type === 'directory' ? 'create directory' : 'create file';
|
|
402
|
+
}
|
|
403
|
+
else if (staged.type === 'directory' && destination.type === 'directory') {
|
|
404
|
+
action = 'merge-directory';
|
|
405
|
+
detail = 'merge with existing directory';
|
|
406
|
+
}
|
|
407
|
+
else if (staged.type === 'file' && destination.type === 'file') {
|
|
408
|
+
action = stagedFile?.contentHash === destination.contentHash ? 'identical' : 'replace';
|
|
409
|
+
detail = action === 'identical' ? 'identical regular file' : 'replace different regular file';
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
action = 'blocked';
|
|
413
|
+
detail = destination.detail ?? `staged ${staged.type} conflicts with destination ${destination.type}`;
|
|
414
|
+
}
|
|
415
|
+
entries.push(Object.freeze({
|
|
416
|
+
pathParts: Object.freeze([...staged.pathParts]),
|
|
417
|
+
relativePath,
|
|
418
|
+
stagedType: staged.type,
|
|
419
|
+
...(stagedFile
|
|
420
|
+
? {
|
|
421
|
+
origin: stagedFile.origin,
|
|
422
|
+
stagedHash: stagedFile.contentHash,
|
|
423
|
+
stagedMode: stagedFile.mode
|
|
424
|
+
}
|
|
425
|
+
: {}),
|
|
426
|
+
action,
|
|
427
|
+
detail,
|
|
428
|
+
destination: Object.freeze({
|
|
429
|
+
type: destination.type,
|
|
430
|
+
...(destination.contentHash ? { contentHash: destination.contentHash } : {}),
|
|
431
|
+
...(destination.mode !== undefined ? { mode: destination.mode } : {})
|
|
432
|
+
})
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
entries.sort((left, right) => comparePortable(left.relativePath, right.relativePath));
|
|
436
|
+
await assertTargetRootSnapshot(targetRootSnapshot, targetRoot);
|
|
437
|
+
const frozenEntries = Object.freeze(entries);
|
|
438
|
+
return Object.freeze({
|
|
439
|
+
stagingRoot: area.root,
|
|
440
|
+
targetRoot,
|
|
441
|
+
targetRootSnapshot: Object.freeze(targetRootSnapshot),
|
|
442
|
+
entries: frozenEntries,
|
|
443
|
+
replacements: Object.freeze(entries.filter((entry) => entry.action === 'replace')),
|
|
444
|
+
blocked: Object.freeze(entries.filter((entry) => entry.action === 'blocked'))
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
export async function authorizeMergePreflight(preflight, force, confirm) {
|
|
448
|
+
if (preflight.blocked.length > 0) {
|
|
449
|
+
throw new InitFileSystemError(`Initialization is blocked by structural or symlink conflicts:\n${preflight.blocked.map((entry) => `- ${entry.relativePath}: ${entry.detail}`).join('\n')}`);
|
|
450
|
+
}
|
|
451
|
+
if (preflight.replacements.length === 0 || force) {
|
|
452
|
+
authorizedMergePlans.add(preflight);
|
|
453
|
+
return preflight;
|
|
454
|
+
}
|
|
455
|
+
if (!confirm) {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
if (!await confirm(preflight.replacements.map((entry) => entry.relativePath))) {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
authorizedMergePlans.add(preflight);
|
|
462
|
+
return preflight;
|
|
463
|
+
}
|
|
464
|
+
async function assertPreflightEntryCurrent(targetRoot, entry) {
|
|
465
|
+
const current = await inspectDestination(targetRoot, entry.pathParts);
|
|
466
|
+
if (current.type !== entry.destination.type ||
|
|
467
|
+
current.contentHash !== entry.destination.contentHash ||
|
|
468
|
+
current.mode !== entry.destination.mode) {
|
|
469
|
+
throw new InitFileSystemError(`Destination changed after preflight: ${entry.relativePath}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function acquireTargetLock(targetRoot) {
|
|
473
|
+
const lockPath = path.join(targetRoot, '.liftoff-init.lock');
|
|
474
|
+
let handle;
|
|
475
|
+
try {
|
|
476
|
+
handle = await open(lockPath, 'wx', 0o600);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
throw new InitFileSystemError(errorCode(error) === 'EEXIST'
|
|
480
|
+
? `Another Liftoff initialization is already modifying ${targetRoot}.`
|
|
481
|
+
: `Unable to lock initialization target ${targetRoot}: ${errorMessage(error)}`);
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
await handle.writeFile(`${process.pid}\n`, 'utf8');
|
|
485
|
+
const details = await handle.stat();
|
|
486
|
+
return { path: lockPath, handle, device: details.dev, inode: details.ino };
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
await handle.close();
|
|
490
|
+
await rm(lockPath, { force: true });
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function assertTargetLock(lock) {
|
|
495
|
+
const details = await lstat(lock.path);
|
|
496
|
+
if (!details.isFile() || details.dev !== lock.device || details.ino !== lock.inode) {
|
|
497
|
+
throw new InitFileSystemError('Initialization target lock changed while the merge was running.');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function releaseTargetLock(lock) {
|
|
501
|
+
await lock.handle.close();
|
|
502
|
+
try {
|
|
503
|
+
const details = await lstat(lock.path);
|
|
504
|
+
if (details.dev === lock.device && details.ino === lock.inode) {
|
|
505
|
+
await unlink(lock.path);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function writeBufferNoClobber(targetRoot, pathParts, content, mode) {
|
|
515
|
+
const destination = path.join(targetRoot, ...pathParts);
|
|
516
|
+
const directory = path.dirname(destination);
|
|
517
|
+
const temporary = path.join(directory, `.${path.basename(destination)}.liftoff-${randomUUID()}.tmp`);
|
|
518
|
+
try {
|
|
519
|
+
await writeFile(temporary, content, { flag: 'wx', mode });
|
|
520
|
+
await chmod(temporary, mode);
|
|
521
|
+
await link(temporary, destination);
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
await rm(temporary, { force: true });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function moveCurrentFileToBackup(targetRoot, entry) {
|
|
528
|
+
const destination = path.join(targetRoot, ...entry.pathParts);
|
|
529
|
+
const backupPath = path.join(path.dirname(destination), `.${path.basename(destination)}.liftoff-${randomUUID()}.bak`);
|
|
530
|
+
await rename(destination, backupPath);
|
|
531
|
+
try {
|
|
532
|
+
const details = await lstat(backupPath);
|
|
533
|
+
if (!details.isFile()) {
|
|
534
|
+
throw new InitFileSystemError(`Destination changed after preflight: ${entry.relativePath}`);
|
|
535
|
+
}
|
|
536
|
+
const content = await readFile(backupPath);
|
|
537
|
+
const mode = details.mode & 0o7777;
|
|
538
|
+
if (hash(content) !== entry.destination.contentHash ||
|
|
539
|
+
mode !== entry.destination.mode) {
|
|
540
|
+
throw new InitFileSystemError(`Destination changed after preflight: ${entry.relativePath}`);
|
|
541
|
+
}
|
|
542
|
+
return { backupPath, content, mode };
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
try {
|
|
546
|
+
await link(backupPath, destination);
|
|
547
|
+
await unlink(backupPath);
|
|
548
|
+
}
|
|
549
|
+
catch (restoreError) {
|
|
550
|
+
throw new InitFileSystemError(`${errorMessage(error)} Original file remains at ${backupPath}; automatic restoration failed: ${errorMessage(restoreError)}`);
|
|
551
|
+
}
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async function assertOwnedFile(filePath, expectedHash, expectedMode) {
|
|
556
|
+
const details = await lstat(filePath);
|
|
557
|
+
if (!details.isFile()) {
|
|
558
|
+
throw new InitFileSystemError('Destination is no longer a regular file.');
|
|
559
|
+
}
|
|
560
|
+
if (hash(await readFile(filePath)) !== expectedHash ||
|
|
561
|
+
(details.mode & 0o7777) !== expectedMode) {
|
|
562
|
+
throw new InitFileSystemError('Destination content or mode changed after Liftoff wrote it.');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function rollbackMerge(targetRoot, actions) {
|
|
566
|
+
const report = { restored: [], removed: [], failures: [] };
|
|
567
|
+
for (const action of [...actions].reverse()) {
|
|
568
|
+
try {
|
|
569
|
+
if (action.type === 'replaced-file') {
|
|
570
|
+
const destination = path.join(targetRoot, ...action.pathParts);
|
|
571
|
+
try {
|
|
572
|
+
await assertOwnedFile(destination, action.replacementHash, action.replacementMode);
|
|
573
|
+
await unlink(destination);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
await link(action.backupPath, destination);
|
|
582
|
+
await unlink(action.backupPath);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
await writeBufferNoClobber(targetRoot, action.pathParts, action.originalContent, action.originalMode);
|
|
589
|
+
}
|
|
590
|
+
report.restored.push(action.relativePath);
|
|
591
|
+
}
|
|
592
|
+
else if (action.type === 'created-file') {
|
|
593
|
+
const destination = path.join(targetRoot, ...action.pathParts);
|
|
594
|
+
await assertOwnedFile(destination, action.contentHash, action.mode);
|
|
595
|
+
await unlink(destination);
|
|
596
|
+
report.removed.push(action.relativePath);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
await rmdir(path.join(targetRoot, ...action.pathParts));
|
|
600
|
+
report.removed.push(action.relativePath);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
report.failures.push(`${action.relativePath}: ${errorMessage(error)}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return report;
|
|
608
|
+
}
|
|
609
|
+
async function discardReplacementBackups(actions) {
|
|
610
|
+
for (const action of actions) {
|
|
611
|
+
if (action.type === 'replaced-file') {
|
|
612
|
+
await unlink(action.backupPath);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function assertFreshTarget(targetRoot, lock) {
|
|
617
|
+
const entries = (await readdir(targetRoot)).filter((entry) => path.join(targetRoot, entry) !== lock.path);
|
|
618
|
+
if (entries.length > 0) {
|
|
619
|
+
throw new InitFileSystemError(`Migration target must remain new or empty; found ${entries.sort(comparePortable).join(', ')}.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function mutationOrder(entries) {
|
|
623
|
+
return [...entries].sort((left, right) => {
|
|
624
|
+
if (left.stagedType !== right.stagedType) {
|
|
625
|
+
return left.stagedType === 'directory' ? -1 : 1;
|
|
626
|
+
}
|
|
627
|
+
if (left.relativePath === 'liftoff.manifest.json') {
|
|
628
|
+
return 1;
|
|
629
|
+
}
|
|
630
|
+
if (right.relativePath === 'liftoff.manifest.json') {
|
|
631
|
+
return -1;
|
|
632
|
+
}
|
|
633
|
+
return comparePortable(left.relativePath, right.relativePath);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
export async function applyMergePreflight(preflight, options = {}) {
|
|
637
|
+
if (!authorizedMergePlans.has(preflight)) {
|
|
638
|
+
throw new InitFileSystemError('Merge preflight must be authorized before applying.');
|
|
639
|
+
}
|
|
640
|
+
const result = { created: [], replaced: [], identical: [], mergedDirectories: [] };
|
|
641
|
+
const actions = [];
|
|
642
|
+
const entries = mutationOrder(preflight.entries);
|
|
643
|
+
let createdTargetRoot = false;
|
|
644
|
+
let activeTargetRootSnapshot = preflight.targetRootSnapshot;
|
|
645
|
+
let targetLock;
|
|
646
|
+
try {
|
|
647
|
+
await assertTargetRootSnapshot(preflight.targetRootSnapshot, preflight.targetRoot);
|
|
648
|
+
if (preflight.targetRootSnapshot.state === 'missing') {
|
|
649
|
+
await mkdir(preflight.targetRoot);
|
|
650
|
+
createdTargetRoot = true;
|
|
651
|
+
activeTargetRootSnapshot = await captureTargetRootSnapshot(preflight.targetRoot);
|
|
652
|
+
if (activeTargetRootSnapshot.state !== 'directory' ||
|
|
653
|
+
activeTargetRootSnapshot.canonicalPath !== preflight.targetRootSnapshot.canonicalPath ||
|
|
654
|
+
activeTargetRootSnapshot.parentCanonicalPath !== preflight.targetRootSnapshot.parentCanonicalPath ||
|
|
655
|
+
activeTargetRootSnapshot.parentDevice !== preflight.targetRootSnapshot.parentDevice ||
|
|
656
|
+
activeTargetRootSnapshot.parentInode !== preflight.targetRootSnapshot.parentInode) {
|
|
657
|
+
throw new InitFileSystemError(`Initialization target root changed while it was being created: ${preflight.targetRoot}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
targetLock = await acquireTargetLock(preflight.targetRoot);
|
|
661
|
+
await assertTargetRootSnapshot(activeTargetRootSnapshot, preflight.targetRoot);
|
|
662
|
+
await assertTargetLock(targetLock);
|
|
663
|
+
if (options.requireEmptyTarget) {
|
|
664
|
+
await assertFreshTarget(preflight.targetRoot, targetLock);
|
|
665
|
+
}
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
await assertPreflightEntryCurrent(preflight.targetRoot, entry);
|
|
668
|
+
}
|
|
669
|
+
for (const [index, entry] of entries.entries()) {
|
|
670
|
+
await options.onBeforeMutation?.(entry, index);
|
|
671
|
+
await assertTargetRootSnapshot(activeTargetRootSnapshot, preflight.targetRoot);
|
|
672
|
+
await assertTargetLock(targetLock);
|
|
673
|
+
await assertPreflightEntryCurrent(preflight.targetRoot, entry);
|
|
674
|
+
if (entry.action === 'merge-directory') {
|
|
675
|
+
result.mergedDirectories.push(entry.relativePath);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (entry.action === 'identical') {
|
|
679
|
+
result.identical.push(entry.relativePath);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (entry.action === 'blocked') {
|
|
683
|
+
throw new InitFileSystemError(`Blocked preflight entry reached merge: ${entry.relativePath}`);
|
|
684
|
+
}
|
|
685
|
+
if (entry.stagedType === 'directory') {
|
|
686
|
+
await mkdir(path.join(preflight.targetRoot, ...entry.pathParts));
|
|
687
|
+
actions.push({ type: 'created-directory', pathParts: entry.pathParts, relativePath: entry.relativePath });
|
|
688
|
+
result.created.push(entry.relativePath);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const stagedContent = await readFile(path.join(preflight.stagingRoot, ...entry.pathParts));
|
|
692
|
+
const stagedMode = entry.stagedMode ?? 0o666;
|
|
693
|
+
if (entry.action === 'replace') {
|
|
694
|
+
const original = await moveCurrentFileToBackup(preflight.targetRoot, entry);
|
|
695
|
+
actions.push({
|
|
696
|
+
type: 'replaced-file',
|
|
697
|
+
pathParts: entry.pathParts,
|
|
698
|
+
relativePath: entry.relativePath,
|
|
699
|
+
backupPath: original.backupPath,
|
|
700
|
+
originalContent: original.content,
|
|
701
|
+
originalMode: original.mode,
|
|
702
|
+
replacementHash: hash(stagedContent),
|
|
703
|
+
replacementMode: stagedMode
|
|
704
|
+
});
|
|
705
|
+
await writeBufferNoClobber(preflight.targetRoot, entry.pathParts, stagedContent, stagedMode);
|
|
706
|
+
result.replaced.push(entry.relativePath);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
await writeBufferNoClobber(preflight.targetRoot, entry.pathParts, stagedContent, stagedMode);
|
|
710
|
+
actions.push({
|
|
711
|
+
type: 'created-file',
|
|
712
|
+
pathParts: entry.pathParts,
|
|
713
|
+
relativePath: entry.relativePath,
|
|
714
|
+
contentHash: hash(stagedContent),
|
|
715
|
+
mode: stagedMode
|
|
716
|
+
});
|
|
717
|
+
result.created.push(entry.relativePath);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
await discardReplacementBackups(actions);
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
let rollback;
|
|
725
|
+
try {
|
|
726
|
+
await assertTargetRootSnapshot(activeTargetRootSnapshot, preflight.targetRoot);
|
|
727
|
+
rollback = await rollbackMerge(preflight.targetRoot, actions);
|
|
728
|
+
}
|
|
729
|
+
catch (rollbackError) {
|
|
730
|
+
rollback = {
|
|
731
|
+
restored: [],
|
|
732
|
+
removed: [],
|
|
733
|
+
failures: [`.: rollback refused because the target root changed: ${errorMessage(rollbackError)}`]
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (targetLock) {
|
|
737
|
+
try {
|
|
738
|
+
await releaseTargetLock(targetLock);
|
|
739
|
+
targetLock = undefined;
|
|
740
|
+
}
|
|
741
|
+
catch (lockError) {
|
|
742
|
+
rollback.failures.push(`.liftoff-init.lock: ${errorMessage(lockError)}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (createdTargetRoot) {
|
|
746
|
+
try {
|
|
747
|
+
await rmdir(preflight.targetRoot);
|
|
748
|
+
rollback.removed.push('.');
|
|
749
|
+
}
|
|
750
|
+
catch (rollbackError) {
|
|
751
|
+
rollback.failures.push(`.: ${errorMessage(rollbackError)}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
throw new MergeApplyError(`Initialization merge failed: ${errorMessage(error)}`, rollback);
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
if (targetLock) {
|
|
758
|
+
await releaseTargetLock(targetLock);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
//# sourceMappingURL=init-filesystem.js.map
|