@moewolf/moe-icons-cli 0.0.1 → 0.0.2
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 +243 -9
- package/bin/check-node.cjs +36 -0
- package/bin/moeicons.js +12 -1
- package/dist/api/client.d.ts +13 -2
- package/dist/api/client.js +145 -21
- package/dist/auth/device-login.d.ts +10 -7
- package/dist/auth/device-login.js +60 -37
- package/dist/auth/login-schemas.d.ts +24 -0
- package/dist/auth/login-schemas.js +67 -0
- package/dist/catalog/catalog.d.ts +11 -0
- package/dist/catalog/catalog.js +43 -0
- package/dist/catalog/catalog.json +538 -3395
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +773 -147
- package/dist/commands/parser.d.ts +13 -2
- package/dist/commands/parser.js +138 -50
- package/dist/config-package/generated/SOURCE.json +25 -0
- package/dist/config-package/generated/moeicons-config.schema.json +191 -0
- package/dist/config-package/generated/moeicons.config.jsonc +42 -0
- package/dist/config-package/generated/render-config.cjs +145 -0
- package/dist/config-package/generated/validate-config.cjs +293 -0
- package/dist/config-package/generated-config.d.ts +32 -0
- package/dist/config-package/generated-config.js +19 -0
- package/dist/core/auth.d.ts +24 -0
- package/dist/core/auth.js +127 -8
- package/dist/core/bitmap-assets.d.ts +19 -5
- package/dist/core/bitmap-assets.js +40 -31
- package/dist/core/bitmap-shard-budget.d.ts +43 -0
- package/dist/core/bitmap-shard-budget.js +16 -0
- package/dist/core/bitmap-shard-cache.d.ts +68 -0
- package/dist/core/bitmap-shard-cache.js +115 -0
- package/dist/core/bitmap-shard-download.d.ts +130 -0
- package/dist/core/bitmap-shard-download.js +359 -0
- package/dist/core/bitmap-shard-resolver.d.ts +129 -0
- package/dist/core/bitmap-shard-resolver.js +262 -0
- package/dist/core/bitmap-shards.d.ts +70 -0
- package/dist/core/bitmap-shards.js +249 -0
- package/dist/core/bootstrap.d.ts +37 -0
- package/dist/core/bootstrap.js +54 -0
- package/dist/core/cache.d.ts +34 -0
- package/dist/core/cache.js +96 -0
- package/dist/core/context.d.ts +26 -0
- package/dist/core/doctor.d.ts +48 -0
- package/dist/core/doctor.js +190 -0
- package/dist/core/free-download.d.ts +64 -6
- package/dist/core/free-download.js +402 -30
- package/dist/core/generate.d.ts +29 -0
- package/dist/core/generate.js +341 -65
- package/dist/core/icon-selection.d.ts +8 -0
- package/dist/core/icon-selection.js +19 -0
- package/dist/core/install.d.ts +14 -0
- package/dist/core/install.js +132 -13
- package/dist/core/library-update.d.ts +5 -1
- package/dist/core/library-update.js +176 -17
- package/dist/core/local-test-env.d.ts +24 -0
- package/dist/core/local-test-env.js +43 -0
- package/dist/core/metadata-sync.d.ts +30 -0
- package/dist/core/metadata-sync.js +97 -0
- package/dist/core/pro-download.d.ts +63 -0
- package/dist/core/pro-download.js +247 -27
- package/dist/core/pro-install.d.ts +11 -3
- package/dist/core/pro-install.js +181 -19
- package/dist/core/pro-resources.d.ts +79 -0
- package/dist/core/pro-resources.js +204 -0
- package/dist/core/pro-selected-download.d.ts +18 -0
- package/dist/core/pro-selected-download.js +269 -0
- package/dist/core/release-descriptor.d.ts +42 -1
- package/dist/core/release-descriptor.js +117 -9
- package/dist/core/resource-variant.d.ts +27 -10
- package/dist/core/resource-variant.js +75 -34
- package/dist/core/selected-resources.d.ts +59 -0
- package/dist/core/selected-resources.js +350 -0
- package/dist/core/signed-artifact.d.ts +8 -1
- package/dist/core/signed-artifact.js +35 -11
- package/dist/core/target-subtree.d.ts +8 -0
- package/dist/core/target-subtree.js +54 -3
- package/dist/core/version-service.d.ts +11 -0
- package/dist/core/version-service.js +129 -24
- package/dist/core/wizard.d.ts +20 -17
- package/dist/core/wizard.js +66 -28
- package/dist/errors/errors.d.ts +2 -1
- package/dist/errors/errors.js +1 -0
- package/dist/generator/generate.d.ts +1 -1
- package/dist/generator/generate.js +420 -213
- package/dist/generator/shared/SOURCE.json +7 -0
- package/dist/generator/shared/svg-model.cjs +156 -0
- package/dist/generator/svg-model.d.ts +16 -0
- package/dist/generator/svg-model.js +4 -0
- package/dist/generator/theme-resolve.d.ts +2 -0
- package/dist/generator/theme-resolve.js +57 -26
- package/dist/mcp/server.d.ts +23 -36
- package/dist/mcp/server.js +74 -152
- package/dist/mcp/services.d.ts +4 -0
- package/dist/mcp/services.js +230 -0
- package/dist/metadata/archive.d.ts +38 -0
- package/dist/metadata/archive.js +94 -0
- package/dist/metadata/manifest.d.ts +33 -0
- package/dist/metadata/manifest.js +136 -0
- package/dist/metadata/version.d.ts +22 -0
- package/dist/metadata/version.js +53 -0
- package/dist/project/anchors/application.d.ts +13 -0
- package/dist/project/anchors/application.js +377 -0
- package/dist/project/anchors/apply.d.ts +28 -0
- package/dist/project/anchors/apply.js +65 -0
- package/dist/project/anchors/diagnose.d.ts +26 -0
- package/dist/project/anchors/diagnose.js +90 -0
- package/dist/project/anchors/helpers.d.ts +20 -0
- package/dist/project/anchors/helpers.js +55 -0
- package/dist/project/anchors/moeicons-config.d.ts +21 -0
- package/dist/project/anchors/moeicons-config.js +83 -0
- package/dist/project/anchors/parse.d.ts +14 -0
- package/dist/project/anchors/parse.js +36 -0
- package/dist/project/anchors/project-manifest.d.ts +19 -0
- package/dist/project/anchors/project-manifest.js +109 -0
- package/dist/project/anchors/style.d.ts +12 -0
- package/dist/project/anchors/style.js +87 -0
- package/dist/project/anchors/types.d.ts +32 -0
- package/dist/project/anchors/types.js +9 -0
- package/dist/project/config.d.ts +59 -5
- package/dist/project/config.js +234 -102
- package/dist/project/install-metadata.d.ts +23 -2
- package/dist/project/install-metadata.js +58 -7
- package/dist/project/install.d.ts +24 -4
- package/dist/project/install.js +402 -190
- package/dist/project/project-lock.d.ts +2 -2
- package/dist/project/project-lock.js +4 -4
- package/dist/project/tailwind.d.ts +2 -2
- package/dist/project/tailwind.js +3 -4
- package/dist/project/tar-gz.d.ts +18 -1
- package/dist/project/tar-gz.js +40 -3
- package/dist/tui/components.d.ts +62 -0
- package/dist/tui/components.js +100 -0
- package/dist/tui/primitives.d.ts +1 -0
- package/dist/tui/primitives.js +8 -1
- package/dist/ui/banner.d.ts +31 -3
- package/dist/ui/banner.js +120 -25
- package/dist/ui/branded-prompts.d.ts +41 -0
- package/dist/ui/branded-prompts.js +80 -0
- package/dist/ui/clack.d.ts +6 -3
- package/dist/ui/clack.js +56 -5
- package/dist/ui/create-ui.d.ts +1 -0
- package/dist/ui/create-ui.js +5 -1
- package/dist/ui/generated/wordmark-large.d.ts +17 -0
- package/dist/ui/generated/wordmark-large.js +28 -0
- package/dist/ui/generated/wordmark.d.ts +2 -0
- package/dist/ui/generated/wordmark.js +2 -0
- package/dist/ui/non-interactive.js +8 -0
- package/dist/ui/stream.js +30 -0
- package/dist/ui/theme.d.ts +42 -0
- package/dist/ui/theme.js +59 -0
- package/package.json +14 -6
package/dist/project/install.js
CHANGED
|
@@ -1,103 +1,326 @@
|
|
|
1
|
-
import { join, relative, resolve } from "node:path";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { join, relative, resolve, dirname, isAbsolute, sep } from "node:path";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { lstatSync, realpathSync, readFileSync as realReadFileSync, readdirSync as realReaddirSync, copyFileSync as realCopyFileSync } from "node:fs";
|
|
4
|
+
import { parseInstallMetadata, sha256Bytes } from "./install-metadata.js";
|
|
3
5
|
import { strToU8, zipSync, unzipSync } from "fflate";
|
|
6
|
+
/** Shared path boundary for install/update and doctor/init writes. */
|
|
7
|
+
export function safeManagedPath(projectRoot, relative) {
|
|
8
|
+
const normalized = relative.replace(/\\/g, "/");
|
|
9
|
+
if (!normalized || relative.includes("\\") || normalized.startsWith("/") || normalized.split("/").some((part) => !part || part === "." || part === "..") || /^[A-Za-z]:/.test(normalized))
|
|
10
|
+
throw new Error(`unsafe managed path: ${relative}`);
|
|
11
|
+
if ([".git", "node_modules"].includes(normalized.split("/")[0].toLowerCase()))
|
|
12
|
+
throw new Error(`managed path uses a protected directory: ${relative}`);
|
|
13
|
+
if (lstatSync(projectRoot).isSymbolicLink())
|
|
14
|
+
throw new Error(`project root contains symbolic link: ${projectRoot}`);
|
|
15
|
+
const project = realpathSync(projectRoot);
|
|
16
|
+
const target = resolve(project, normalized);
|
|
17
|
+
if (!target.startsWith(`${project}${sep}`))
|
|
18
|
+
throw new Error(`managed path escapes project: ${relative}`);
|
|
19
|
+
let ancestor = target;
|
|
20
|
+
while (ancestor !== project && !existsOnDisk(ancestor))
|
|
21
|
+
ancestor = dirname(ancestor);
|
|
22
|
+
for (let current = ancestor; current !== project; current = dirname(current)) {
|
|
23
|
+
if (lstatSync(current).isSymbolicLink())
|
|
24
|
+
throw new Error(`managed path contains symbolic link: ${relative}`);
|
|
25
|
+
}
|
|
26
|
+
return { normalized, target };
|
|
27
|
+
}
|
|
4
28
|
/**
|
|
5
29
|
* Atomically reconcile an explicit cross-directory managed set. `removePaths`
|
|
6
30
|
* must come from trusted metadata; this function never scans or uses globs.
|
|
7
31
|
*/
|
|
8
|
-
export function executeManagedReconcile(projectRoot, writes, removePaths, fs_) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
export function executeManagedReconcile(projectRoot, writes, removePaths, fs_, options = {}) {
|
|
33
|
+
// A previous process may have terminated between two renames. Restore only
|
|
34
|
+
// journal-owned bytes before accepting another plan; callers must replan.
|
|
35
|
+
if (recoverManagedReconcile(projectRoot, fs_) > 0) {
|
|
36
|
+
throw new Error("interrupted transaction recovered; rerun the command to create a fresh plan");
|
|
37
|
+
}
|
|
38
|
+
const operationId = randomUUID();
|
|
39
|
+
const safe = (relative) => safeManagedPath(projectRoot, relative);
|
|
40
|
+
// Validate transaction paths too: .moeicons may be a symlink even when
|
|
41
|
+
// every application entry is safe.
|
|
42
|
+
const stagingRelative = `.moeicons/.reconcile-staging-${operationId}`;
|
|
43
|
+
const backupRelative = `.moeicons/.reconcile-backup-${operationId}`;
|
|
44
|
+
const stagingRoot = safe(stagingRelative).target;
|
|
45
|
+
const backupRoot = safe(backupRelative).target;
|
|
46
|
+
const checkExpected = (relative, target) => {
|
|
47
|
+
if (Object.hasOwn(options.expectedSha256 ?? {}, relative)) {
|
|
48
|
+
const expected = options.expectedSha256?.[relative];
|
|
49
|
+
const current = fs_.existsSync(target) ? sha256Bytes(fs_.readFileSync(target)) : undefined;
|
|
50
|
+
if (current !== expected)
|
|
51
|
+
throw new Error(`file changed since planning: ${relative}`);
|
|
52
|
+
}
|
|
53
|
+
if (!Object.hasOwn(options.expectedText ?? {}, relative))
|
|
54
|
+
return;
|
|
55
|
+
const expected = options.expectedText?.[relative];
|
|
56
|
+
const current = fs_.existsSync(target) ? fs_.readFileSync(target, "utf8") : undefined;
|
|
57
|
+
if (current !== expected)
|
|
58
|
+
throw new Error(`file changed since planning: ${relative}`);
|
|
19
59
|
};
|
|
20
60
|
const entries = Object.entries(writes).map(([relative, content]) => ({ ...safe(relative), content }));
|
|
21
61
|
const removals = [...new Set(removePaths)].map(safe).filter((item) => !Object.hasOwn(writes, item.normalized));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
62
|
+
for (const item of [...entries, ...removals]) {
|
|
63
|
+
if (item.normalized.toLowerCase().startsWith(".moeicons/.reconcile-"))
|
|
64
|
+
throw new Error(`managed path overlaps transaction control: ${item.normalized}`);
|
|
65
|
+
}
|
|
66
|
+
const allPaths = [...entries, ...removals].map((item) => item.normalized.toLowerCase()).sort();
|
|
67
|
+
for (const entry of entries)
|
|
68
|
+
checkExpected(entry.normalized, entry.target);
|
|
69
|
+
for (const removal of removals)
|
|
70
|
+
checkExpected(removal.normalized, removal.target);
|
|
71
|
+
const pathSet = new Set(allPaths);
|
|
72
|
+
for (const path of allPaths) {
|
|
73
|
+
const parts = path.split("/");
|
|
74
|
+
for (let i = 1; i < parts.length; i++) {
|
|
75
|
+
const parent = parts.slice(0, i).join("/");
|
|
76
|
+
if (pathSet.has(parent))
|
|
77
|
+
throw new Error(`managed file and directory paths collide: ${parent}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (let i = 1; i < allPaths.length; i++) {
|
|
81
|
+
const previous = allPaths[i - 1];
|
|
82
|
+
const current = allPaths[i];
|
|
83
|
+
if (previous === current)
|
|
84
|
+
throw new Error(`duplicate managed path (case-insensitive): ${current}`);
|
|
85
|
+
if (current.startsWith(`${previous}/`)) {
|
|
86
|
+
throw new Error(`managed file and directory paths collide: ${previous}`);
|
|
87
|
+
}
|
|
44
88
|
}
|
|
45
89
|
const backedUp = [];
|
|
46
90
|
const installed = [];
|
|
47
91
|
let preserveBackup = false;
|
|
92
|
+
const journal = {
|
|
93
|
+
version: 1, operationId, committed: false,
|
|
94
|
+
entries: [...removals, ...entries].map((item) => ({
|
|
95
|
+
path: item.normalized,
|
|
96
|
+
before: fs_.existsSync(item.target) ? sha256Bytes(fs_.readFileSync(item.target)) : null,
|
|
97
|
+
after: Object.hasOwn(writes, item.normalized) ? sha256Bytes(writes[item.normalized]) : null,
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
const journalPath = join(backupRoot, "recovery.json");
|
|
101
|
+
const plannedHashes = new Map(journal.entries.map((entry) => [entry.path, entry.before]));
|
|
48
102
|
try {
|
|
103
|
+
safe(".moeicons");
|
|
104
|
+
fs_.mkdirSync(join(projectRoot, ".moeicons"), { recursive: true });
|
|
105
|
+
safe(stagingRelative);
|
|
106
|
+
fs_.mkdirSync(stagingRoot, { recursive: false });
|
|
107
|
+
safe(backupRelative);
|
|
108
|
+
fs_.mkdirSync(backupRoot, { recursive: false });
|
|
49
109
|
for (const entry of entries) {
|
|
110
|
+
safe(stagingRelative);
|
|
50
111
|
const staged = join(stagingRoot, entry.normalized);
|
|
51
112
|
fs_.mkdirSync(join(staged, ".."), { recursive: true });
|
|
113
|
+
safe(`${stagingRelative}/${entry.normalized}`);
|
|
52
114
|
fs_.writeFileSync(staged, entry.content);
|
|
53
115
|
}
|
|
116
|
+
// Persist the entire intent before the first destructive rename. Recovery
|
|
117
|
+
// infers completed renames from hashes, so no per-rename journal gap exists.
|
|
118
|
+
fs_.writeFileSync(journalPath, JSON.stringify(journal));
|
|
54
119
|
for (const item of [...removals, ...entries]) {
|
|
120
|
+
safe(item.normalized);
|
|
121
|
+
checkExpected(item.normalized, item.target);
|
|
122
|
+
const currentHash = fs_.existsSync(item.target) ? sha256Bytes(fs_.readFileSync(item.target)) : null;
|
|
123
|
+
if (currentHash !== plannedHashes.get(item.normalized))
|
|
124
|
+
throw new Error(`file changed since planning: ${item.normalized}`);
|
|
55
125
|
if (!fs_.existsSync(item.target))
|
|
56
126
|
continue;
|
|
57
|
-
const backup = join(backupRoot, item.normalized);
|
|
127
|
+
const backup = join(backupRoot, "files", item.normalized);
|
|
128
|
+
safe(backupRelative);
|
|
58
129
|
fs_.mkdirSync(join(backup, ".."), { recursive: true });
|
|
59
130
|
fs_.renameSync(item.target, backup);
|
|
60
|
-
backedUp.push({ target: item.target, backup });
|
|
131
|
+
backedUp.push({ target: item.target, backup, relative: item.normalized });
|
|
61
132
|
}
|
|
62
133
|
for (const entry of entries) {
|
|
134
|
+
safe(entry.normalized);
|
|
63
135
|
const staged = join(stagingRoot, entry.normalized);
|
|
136
|
+
// Each target was moved to backup above. A newly appearing target is a
|
|
137
|
+
// concurrent user write; never replace it with staged bytes.
|
|
138
|
+
if (fs_.existsSync(entry.target))
|
|
139
|
+
throw new Error(`file changed since planning: ${entry.normalized}`);
|
|
64
140
|
fs_.mkdirSync(join(entry.target, ".."), { recursive: true });
|
|
141
|
+
safe(entry.normalized);
|
|
65
142
|
fs_.renameSync(staged, entry.target);
|
|
66
|
-
installed.push(entry.target);
|
|
143
|
+
installed.push({ target: entry.target, hash: sha256Bytes(entry.content), relative: entry.normalized });
|
|
67
144
|
}
|
|
68
|
-
|
|
69
|
-
|
|
145
|
+
journal.committed = true;
|
|
146
|
+
safe(backupRelative);
|
|
147
|
+
fs_.writeFileSync(join(backupRoot, "committed"), operationId);
|
|
70
148
|
}
|
|
71
149
|
catch (error) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
150
|
+
const recoveryErrors = [];
|
|
151
|
+
for (const { target, hash, relative } of installed.reverse()) {
|
|
152
|
+
if (existsOnDisk(target)) {
|
|
153
|
+
try {
|
|
154
|
+
safe(relative);
|
|
155
|
+
if (lstatSync(target).isSymbolicLink() || sha256Bytes(realReadFileSync(target)) !== hash) {
|
|
156
|
+
preserveBackup = true;
|
|
157
|
+
recoveryErrors.push(new Error(`concurrent file retained at ${target}`));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
fs_.rmSync(target, { force: true });
|
|
161
|
+
}
|
|
162
|
+
catch (recoveryError) {
|
|
163
|
+
recoveryErrors.push(recoveryError);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
75
167
|
for (const item of backedUp.reverse()) {
|
|
76
168
|
if (fs_.existsSync(item.backup)) {
|
|
77
|
-
fs_.mkdirSync(join(item.target, ".."), { recursive: true });
|
|
78
169
|
try {
|
|
170
|
+
safe(item.relative);
|
|
171
|
+
safe(`${backupRelative}/files/${item.relative}`);
|
|
172
|
+
if (fs_.existsSync(item.target)) {
|
|
173
|
+
preserveBackup = true;
|
|
174
|
+
recoveryErrors.push(new Error(`concurrent file retained at ${item.target}`));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
fs_.mkdirSync(join(item.target, ".."), { recursive: true });
|
|
178
|
+
safe(item.relative);
|
|
79
179
|
fs_.renameSync(item.backup, item.target);
|
|
80
180
|
}
|
|
81
|
-
catch {
|
|
181
|
+
catch (recoveryError) {
|
|
82
182
|
preserveBackup = true;
|
|
183
|
+
recoveryErrors.push(recoveryError);
|
|
83
184
|
}
|
|
84
185
|
}
|
|
85
186
|
}
|
|
187
|
+
if (recoveryErrors.length > 0) {
|
|
188
|
+
throw new AggregateError([error, ...recoveryErrors], preserveBackup
|
|
189
|
+
? `reconcile failed; original files retained at ${backupRoot}`
|
|
190
|
+
: "reconcile failed; original files were restored with recovery errors");
|
|
191
|
+
}
|
|
86
192
|
throw error;
|
|
87
193
|
}
|
|
88
194
|
finally {
|
|
89
|
-
if (fs_.existsSync(stagingRoot))
|
|
195
|
+
if (fs_.existsSync(stagingRoot)) {
|
|
196
|
+
safe(stagingRelative);
|
|
90
197
|
fs_.rmSync(stagingRoot, { recursive: true, force: true });
|
|
91
|
-
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// The new state is committed. A cleanup failure must not trigger rollback
|
|
201
|
+
// after any backup bytes have already been removed.
|
|
202
|
+
if (fs_.existsSync(backupRoot)) {
|
|
203
|
+
try {
|
|
204
|
+
safe(backupRelative);
|
|
92
205
|
fs_.rmSync(backupRoot, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
throw new Error(`changes committed, backup cleanup failed at ${backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Recover an interrupted process while preserving any independently edited file.
|
|
213
|
+
* Must run under the same project lock as reconcile. Invalid journals fail closed.
|
|
214
|
+
* This covers process termination, not power loss or a hostile filesystem.
|
|
215
|
+
*/
|
|
216
|
+
export function recoverManagedReconcile(projectRoot, fs_) {
|
|
217
|
+
const control = safeManagedPath(projectRoot, ".moeicons").target;
|
|
218
|
+
if (!fs_.existsSync(control))
|
|
219
|
+
return 0;
|
|
220
|
+
let recovered = 0;
|
|
221
|
+
for (const name of fs_.readdirSync(control)) {
|
|
222
|
+
if (!/^\.reconcile-backup-[0-9a-f-]{36}$/.test(name))
|
|
223
|
+
continue;
|
|
224
|
+
const backupRoot = safeManagedPath(projectRoot, `.moeicons/${name}`).target;
|
|
225
|
+
const journalPath = safeManagedPath(projectRoot, `.moeicons/${name}/recovery.json`).target;
|
|
226
|
+
if (!fs_.existsSync(journalPath))
|
|
227
|
+
continue; // staging never reached mutation
|
|
228
|
+
const journal = JSON.parse(fs_.readFileSync(journalPath, "utf8"));
|
|
229
|
+
const hashOrNull = (value) => value === null || (typeof value === "string" && /^[0-9a-f]{64}$/.test(value));
|
|
230
|
+
if (journal.version !== 1 || name !== `.reconcile-backup-${journal.operationId}` || !Array.isArray(journal.entries) || journal.entries.some((entry) => !entry || typeof entry.path !== "string" || !hashOrNull(entry.before) || !hashOrNull(entry.after))) {
|
|
231
|
+
throw new Error(`invalid recovery journal; originals retained at ${backupRoot}`);
|
|
232
|
+
}
|
|
233
|
+
if (journal.entries.some((entry) => entry.path.toLowerCase().startsWith(".moeicons/.reconcile-")) || new Set(journal.entries.map((entry) => entry.path.toLowerCase())).size !== journal.entries.length)
|
|
234
|
+
throw new Error(`unsafe recovery journal at ${backupRoot}`);
|
|
235
|
+
const entries = journal.entries.map((entry) => ({ ...entry, target: safeManagedPath(projectRoot, entry.path).target, backup: safeManagedPath(projectRoot, `.moeicons/${name}/files/${entry.path}`).target }));
|
|
236
|
+
const committed = safeManagedPath(projectRoot, `.moeicons/${name}/committed`).target;
|
|
237
|
+
if (fs_.existsSync(committed)) {
|
|
238
|
+
if (fs_.readFileSync(committed, "utf8") !== journal.operationId)
|
|
239
|
+
throw new Error(`invalid commit marker at ${backupRoot}`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// Validate the complete recovery before deleting or restoring any file.
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const current = fs_.existsSync(entry.target) ? sha256Bytes(fs_.readFileSync(entry.target)) : null;
|
|
245
|
+
const backup = fs_.existsSync(entry.backup) ? sha256Bytes(fs_.readFileSync(entry.backup)) : null;
|
|
246
|
+
if ((backup !== null && backup !== entry.before) || (current !== null && current !== entry.after && current !== entry.before) || (entry.before !== null && backup === null && current !== entry.before)) {
|
|
247
|
+
throw new Error(`recovery conflict at ${entry.path}; originals retained at ${backupRoot}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const entry of entries.reverse()) {
|
|
251
|
+
safeManagedPath(projectRoot, entry.path);
|
|
252
|
+
if (fs_.existsSync(entry.backup)) {
|
|
253
|
+
if (fs_.existsSync(entry.target))
|
|
254
|
+
fs_.rmSync(entry.target, { force: true });
|
|
255
|
+
fs_.mkdirSync(dirname(entry.target), { recursive: true });
|
|
256
|
+
safeManagedPath(projectRoot, entry.path);
|
|
257
|
+
fs_.renameSync(entry.backup, entry.target);
|
|
258
|
+
}
|
|
259
|
+
else if (entry.before === null && fs_.existsSync(entry.target)) {
|
|
260
|
+
fs_.rmSync(entry.target, { force: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const staging = safeManagedPath(projectRoot, `.moeicons/.reconcile-staging-${journal.operationId}`).target;
|
|
265
|
+
if (fs_.existsSync(staging))
|
|
266
|
+
fs_.rmSync(staging, { recursive: true, force: true });
|
|
267
|
+
fs_.rmSync(backupRoot, { recursive: true, force: true });
|
|
268
|
+
recovered++;
|
|
269
|
+
}
|
|
270
|
+
return recovered;
|
|
271
|
+
}
|
|
272
|
+
/** Check the path before any staging directory or backup is touched. */
|
|
273
|
+
function safeOutputRoot(projectRoot, outputDir) {
|
|
274
|
+
const segments = outputDir.replace(/\\/g, "/").split("/");
|
|
275
|
+
if (!outputDir || isAbsolute(outputDir) || /^[A-Za-z]:/.test(outputDir) ||
|
|
276
|
+
segments.some((part) => !part || part === "." || part === "..")) {
|
|
277
|
+
throw new Error(`unsafe outputDir: ${outputDir}`);
|
|
278
|
+
}
|
|
279
|
+
if ([".git", ".moeicons", "node_modules"].includes(segments[0] ?? "")) {
|
|
280
|
+
throw new Error(`outputDir cannot use a project control directory: ${outputDir}`);
|
|
281
|
+
}
|
|
282
|
+
const project = realpathSync(projectRoot);
|
|
283
|
+
const output = resolve(project, outputDir);
|
|
284
|
+
if (!output.startsWith(`${project}${sep}`))
|
|
285
|
+
throw new Error(`outputDir escapes project: ${outputDir}`);
|
|
286
|
+
let ancestor = output;
|
|
287
|
+
while (ancestor !== project && !existsOnDisk(ancestor))
|
|
288
|
+
ancestor = dirname(ancestor);
|
|
289
|
+
for (let current = ancestor; current !== project; current = dirname(current)) {
|
|
290
|
+
if (lstatSync(current).isSymbolicLink())
|
|
291
|
+
throw new Error(`outputDir contains a symbolic link: ${outputDir}`);
|
|
292
|
+
}
|
|
293
|
+
if (ancestor !== project && !realpathSync(ancestor).startsWith(`${project}${sep}`)) {
|
|
294
|
+
throw new Error(`outputDir resolves outside project: ${outputDir}`);
|
|
295
|
+
}
|
|
296
|
+
return output;
|
|
297
|
+
}
|
|
298
|
+
function existsOnDisk(path) {
|
|
299
|
+
try {
|
|
300
|
+
lstatSync(path);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === "ENOENT")
|
|
305
|
+
return false;
|
|
306
|
+
throw error;
|
|
93
307
|
}
|
|
94
308
|
}
|
|
95
|
-
const GENERATED_FILE_HEADER = "// Generated by moeicons CLI.";
|
|
96
309
|
/** Pure list of install operations with expected checksums. */
|
|
97
310
|
export function createInstallPlan(targetRoot, files) {
|
|
98
311
|
const items = [];
|
|
99
312
|
const sorted = Object.keys(files).sort((a, b) => a.localeCompare(b));
|
|
313
|
+
const seen = new Set();
|
|
100
314
|
for (const rel of sorted) {
|
|
315
|
+
const normalized = rel.replace(/\\/g, "/");
|
|
316
|
+
if (!normalized || rel.includes("\\") || isAbsolute(rel) || /^[A-Za-z]:/.test(rel) ||
|
|
317
|
+
normalized.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
318
|
+
throw new Error(`unsafe install path: ${rel}`);
|
|
319
|
+
}
|
|
320
|
+
const folded = normalized.toLowerCase();
|
|
321
|
+
if (seen.has(folded))
|
|
322
|
+
throw new Error(`duplicate install path: ${rel}`);
|
|
323
|
+
seen.add(folded);
|
|
101
324
|
const content = files[rel];
|
|
102
325
|
if (content !== undefined) {
|
|
103
326
|
items.push(typeof content === "string"
|
|
@@ -105,7 +328,7 @@ export function createInstallPlan(targetRoot, files) {
|
|
|
105
328
|
: { kind: "write", path: join(targetRoot, rel), rel, bytes: content });
|
|
106
329
|
}
|
|
107
330
|
}
|
|
108
|
-
return { items };
|
|
331
|
+
return { root: targetRoot, items };
|
|
109
332
|
}
|
|
110
333
|
/** Create a deterministic ZIP for delivery (fixed mtime). */
|
|
111
334
|
export function createArtifactZip(files) {
|
|
@@ -153,63 +376,78 @@ export function sha256Hex(content) {
|
|
|
153
376
|
*/
|
|
154
377
|
export function executeInstallPlan(plan, fs_) {
|
|
155
378
|
const writes = plan.items.filter((i) => i.kind === "write");
|
|
156
|
-
// stage all writes into a sibling staging dir
|
|
157
379
|
const firstWrite = writes[0];
|
|
158
380
|
if (!firstWrite)
|
|
159
381
|
return;
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
fs_.mkdirSync(join(item.path, ".."), { recursive: true });
|
|
183
|
-
fs_.renameSync(staged, item.path);
|
|
184
|
-
installed.push(item.path);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
catch (error) {
|
|
188
|
-
for (const path of installed) {
|
|
189
|
-
if (!backups.some((backup) => backup.original === path) && fs_.existsSync(path)) {
|
|
190
|
-
fs_.rmSync(path, { force: true });
|
|
382
|
+
const requestedRoot = resolve(plan.root ?? dirname(resolve(firstWrite.path)));
|
|
383
|
+
let existingAncestor = requestedRoot;
|
|
384
|
+
while (!existsOnDisk(existingAncestor))
|
|
385
|
+
existingAncestor = dirname(existingAncestor);
|
|
386
|
+
if (lstatSync(existingAncestor).isSymbolicLink())
|
|
387
|
+
throw new Error(`install root contains symbolic link: ${requestedRoot}`);
|
|
388
|
+
const project = resolve(realpathSync(existingAncestor), relative(existingAncestor, requestedRoot));
|
|
389
|
+
const validated = writes.map((item) => {
|
|
390
|
+
const rel = item.rel;
|
|
391
|
+
if (!rel || rel.includes("\\") || isAbsolute(rel) || /^[A-Za-z]:/.test(rel) ||
|
|
392
|
+
rel.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
393
|
+
throw new Error(`unsafe install path: ${rel ?? item.path}`);
|
|
394
|
+
}
|
|
395
|
+
const target = resolve(project, rel);
|
|
396
|
+
if (relative(resolve(plan.root ?? project), resolve(item.path)).replace(/\\/g, "/") !== rel ||
|
|
397
|
+
!target.startsWith(`${project}${sep}`)) {
|
|
398
|
+
throw new Error(`install path escapes project: ${item.path}`);
|
|
399
|
+
}
|
|
400
|
+
let current = target;
|
|
401
|
+
while (current !== project) {
|
|
402
|
+
if (existsOnDisk(current) && lstatSync(current).isSymbolicLink()) {
|
|
403
|
+
throw new Error(`install path contains symbolic link: ${item.path}`);
|
|
191
404
|
}
|
|
405
|
+
current = dirname(current);
|
|
192
406
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
407
|
+
return { item, rel, target };
|
|
408
|
+
});
|
|
409
|
+
const folded = validated.map((item) => item.rel.toLowerCase());
|
|
410
|
+
if (new Set(folded).size !== folded.length)
|
|
411
|
+
throw new Error("duplicate install paths");
|
|
412
|
+
const reconcileFs = { ...fs_, readFileSync: realReadFileSync, readdirSync: realReaddirSync, copyFileSync: realCopyFileSync };
|
|
413
|
+
fs_.mkdirSync(project, { recursive: true });
|
|
414
|
+
if (recoverManagedReconcile(project, reconcileFs) > 0)
|
|
415
|
+
throw new Error("interrupted transaction recovered; rerun the command to create a fresh plan");
|
|
416
|
+
const metadataPath = join(project, ".moeicons", "install-metadata.json");
|
|
417
|
+
const priorMetadataText = existsOnDisk(metadataPath) ? realReadFileSync(metadataPath, "utf8") : undefined;
|
|
418
|
+
const prior = priorMetadataText !== undefined
|
|
419
|
+
? parseInstallMetadata(priorMetadataText, { allowLocalTest: true })
|
|
420
|
+
: undefined;
|
|
421
|
+
if (existsOnDisk(metadataPath) && !prior)
|
|
422
|
+
throw new Error("existing install metadata is invalid; refusing to overwrite project files");
|
|
423
|
+
if (prior) {
|
|
424
|
+
for (const [rel, hash] of Object.entries(prior.managedFiles)) {
|
|
425
|
+
const owned = resolve(project, rel);
|
|
426
|
+
if (!owned.startsWith(`${project}${sep}`) || !existsOnDisk(owned) ||
|
|
427
|
+
lstatSync(owned).isSymbolicLink() || sha256Bytes(realReadFileSync(owned)) !== hash) {
|
|
428
|
+
throw new Error(`managed file was modified or removed: ${rel}`);
|
|
201
429
|
}
|
|
202
430
|
}
|
|
203
|
-
throw error;
|
|
204
|
-
}
|
|
205
|
-
finally {
|
|
206
|
-
if (fs_.existsSync(stagingRoot))
|
|
207
|
-
fs_.rmSync(stagingRoot, { recursive: true, force: true });
|
|
208
431
|
}
|
|
209
|
-
for (const
|
|
210
|
-
if (
|
|
211
|
-
|
|
432
|
+
for (const { rel, target } of validated) {
|
|
433
|
+
if (!existsOnDisk(target))
|
|
434
|
+
continue;
|
|
435
|
+
if (rel === ".moeicons/install-metadata.json" && prior)
|
|
436
|
+
continue;
|
|
437
|
+
if (!prior || prior.managedFiles[rel] === undefined) {
|
|
438
|
+
throw new Error(`install path collides with an unowned user file: ${rel}`);
|
|
439
|
+
}
|
|
212
440
|
}
|
|
441
|
+
const nextPaths = new Set(validated.map((entry) => entry.rel));
|
|
442
|
+
const staleOwned = prior
|
|
443
|
+
? Object.keys(prior.managedFiles).filter((rel) => !nextPaths.has(rel)).map((rel) => ({ rel, target: resolve(project, rel) }))
|
|
444
|
+
: [];
|
|
445
|
+
const expectedSha256 = Object.fromEntries([...validated, ...staleOwned].map(({ rel, target }) => [rel,
|
|
446
|
+
rel === ".moeicons/install-metadata.json"
|
|
447
|
+
? (priorMetadataText === undefined ? undefined : sha256Bytes(priorMetadataText))
|
|
448
|
+
: prior?.managedFiles[rel] ?? (existsOnDisk(target) ? sha256Bytes(realReadFileSync(target)) : undefined),
|
|
449
|
+
]));
|
|
450
|
+
executeManagedReconcile(project, Object.fromEntries(validated.map(({ item, rel }) => [rel, item.bytes ?? item.content ?? ""])), staleOwned.map(({ rel }) => rel), reconcileFs, { expectedSha256 });
|
|
213
451
|
}
|
|
214
452
|
/**
|
|
215
453
|
* Transactionally replace only generated files. Existing files outside this
|
|
@@ -217,12 +455,11 @@ export function executeInstallPlan(plan, fs_) {
|
|
|
217
455
|
* share with user-owned files.
|
|
218
456
|
*/
|
|
219
457
|
export function executeGeneratedFiles(files, projectRoot, outputDir, fs_) {
|
|
220
|
-
const outputRoot =
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
fs_.rmSync(stagingRoot, { recursive: true, force: true });
|
|
458
|
+
const outputRoot = safeOutputRoot(projectRoot, outputDir);
|
|
459
|
+
const operationId = randomUUID();
|
|
460
|
+
const stagingRoot = `${outputRoot}.staging-${operationId}`;
|
|
224
461
|
const entries = files.map((file) => {
|
|
225
|
-
const target = resolve(projectRoot, file.path);
|
|
462
|
+
const target = resolve(realpathSync(projectRoot), file.path);
|
|
226
463
|
const relative = target.slice(outputRoot.length).replace(/^[/\\]/, "");
|
|
227
464
|
if (target !== outputRoot && !target.startsWith(`${outputRoot}/`) && !target.startsWith(`${outputRoot}\\`)) {
|
|
228
465
|
throw new Error(`generated path escapes output directory: ${file.path}`);
|
|
@@ -239,7 +476,7 @@ export function executeGeneratedFiles(files, projectRoot, outputDir, fs_) {
|
|
|
239
476
|
}
|
|
240
477
|
for (const entry of entries) {
|
|
241
478
|
if (fs_.existsSync(entry.target)) {
|
|
242
|
-
const backup = `${entry.target}.bak`;
|
|
479
|
+
const backup = `${entry.target}.bak-${operationId}`;
|
|
243
480
|
fs_.renameSync(entry.target, backup);
|
|
244
481
|
backups.push({ original: entry.target, backup });
|
|
245
482
|
}
|
|
@@ -249,12 +486,10 @@ export function executeGeneratedFiles(files, projectRoot, outputDir, fs_) {
|
|
|
249
486
|
}
|
|
250
487
|
}
|
|
251
488
|
catch (error) {
|
|
252
|
-
for (const path of installed)
|
|
253
|
-
if (
|
|
489
|
+
for (const path of installed.reverse())
|
|
490
|
+
if (fs_.existsSync(path))
|
|
254
491
|
fs_.rmSync(path, { force: true });
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
for (const backup of backups) {
|
|
492
|
+
for (const backup of backups.reverse()) {
|
|
258
493
|
try {
|
|
259
494
|
if (fs_.existsSync(backup.backup))
|
|
260
495
|
fs_.renameSync(backup.backup, backup.original);
|
|
@@ -282,137 +517,108 @@ export function executeGeneratedFiles(files, projectRoot, outputDir, fs_) {
|
|
|
282
517
|
* the output directory is never left in a partial state.
|
|
283
518
|
*
|
|
284
519
|
* The set of CLI-managed paths is determined by the `files` argument. Any file
|
|
285
|
-
* that already exists in outputDir
|
|
286
|
-
*
|
|
287
|
-
*
|
|
520
|
+
* that already exists in outputDir is considered user-owned and is preserved.
|
|
521
|
+
* This compatibility writer has no ownership metadata, so it refuses to
|
|
522
|
+
* replace an existing path. Production generation uses managed reconciliation.
|
|
288
523
|
*/
|
|
289
524
|
export function executeGeneratedFilesDir(files, projectRoot, outputDir, fs_) {
|
|
290
|
-
const outputRoot =
|
|
291
|
-
const
|
|
292
|
-
const
|
|
525
|
+
const outputRoot = safeOutputRoot(projectRoot, outputDir);
|
|
526
|
+
const operationId = randomUUID();
|
|
527
|
+
const stagingRoot = `${outputRoot}.staging-${operationId}`;
|
|
528
|
+
const backupRoot = `${outputRoot}.bak-${operationId}`;
|
|
293
529
|
// Validate all paths up front.
|
|
294
530
|
const managedRelPaths = new Set();
|
|
295
531
|
const entries = files.map((file) => {
|
|
296
|
-
const target = resolve(projectRoot, file.path);
|
|
532
|
+
const target = resolve(realpathSync(projectRoot), file.path);
|
|
297
533
|
if (target !== outputRoot && !target.startsWith(`${outputRoot}/`) && !target.startsWith(`${outputRoot}\\`)) {
|
|
298
534
|
throw new Error(`generated path escapes output directory: ${file.path}`);
|
|
299
535
|
}
|
|
300
536
|
const rel = target.slice(outputRoot.length).replace(/^[/\\]/, "");
|
|
301
|
-
managedRelPaths.
|
|
537
|
+
if (!rel || managedRelPaths.has(rel.toLowerCase()))
|
|
538
|
+
throw new Error(`duplicate generated path: ${file.path}`);
|
|
539
|
+
managedRelPaths.add(rel.toLowerCase());
|
|
302
540
|
return { staged: join(stagingRoot, rel), content: file.content };
|
|
303
541
|
});
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
542
|
+
// Copy user files while the old tree is still live. A failed copy must never
|
|
543
|
+
// make the generated tree visible or delete the only copy of a user file.
|
|
544
|
+
const userFiles = fs_.existsSync(outputRoot)
|
|
545
|
+
? collectUserFiles(outputRoot, outputRoot, managedRelPaths, fs_)
|
|
546
|
+
: [];
|
|
547
|
+
let backedUp = false;
|
|
548
|
+
let installed = false;
|
|
310
549
|
try {
|
|
550
|
+
fs_.mkdirSync(stagingRoot, { recursive: true });
|
|
311
551
|
// 1. Write all CLI-managed files into staging.
|
|
312
552
|
for (const entry of entries) {
|
|
313
553
|
fs_.mkdirSync(join(entry.staged, ".."), { recursive: true });
|
|
314
554
|
fs_.writeFileSync(entry.staged, entry.content);
|
|
315
555
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
fs_.
|
|
556
|
+
for (const { rel, src } of userFiles) {
|
|
557
|
+
const dest = join(stagingRoot, rel);
|
|
558
|
+
fs_.mkdirSync(dirname(dest), { recursive: true });
|
|
559
|
+
fs_.copyFileSync(src, dest);
|
|
320
560
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
catch (renameError) {
|
|
326
|
-
// staging rename failed; try to restore backup.
|
|
327
|
-
if (hadOutput && fs_.existsSync(backupRoot)) {
|
|
328
|
-
try {
|
|
329
|
-
fs_.renameSync(backupRoot, outputRoot);
|
|
330
|
-
}
|
|
331
|
-
catch { /* best-effort */ }
|
|
332
|
-
}
|
|
333
|
-
throw renameError;
|
|
334
|
-
}
|
|
335
|
-
// 4. Merge user-owned files from backup back into the new output directory.
|
|
336
|
-
if (hadOutput) {
|
|
337
|
-
const userFiles = collectUserFiles(backupRoot, backupRoot, managedRelPaths, fs_);
|
|
338
|
-
for (const { rel, src } of userFiles) {
|
|
339
|
-
const dest = join(outputRoot, rel);
|
|
340
|
-
fs_.mkdirSync(join(dest, ".."), { recursive: true });
|
|
341
|
-
try {
|
|
342
|
-
fs_.copyFileSync(src, dest);
|
|
343
|
-
}
|
|
344
|
-
catch {
|
|
345
|
-
// Non-fatal: a user file could not be restored. Proceed with generation.
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
fs_.rmSync(backupRoot, { recursive: true, force: true });
|
|
561
|
+
if (fs_.existsSync(outputRoot)) {
|
|
562
|
+
fs_.renameSync(outputRoot, backupRoot);
|
|
563
|
+
backedUp = true;
|
|
349
564
|
}
|
|
565
|
+
fs_.renameSync(stagingRoot, outputRoot);
|
|
566
|
+
installed = true;
|
|
350
567
|
}
|
|
351
568
|
catch (error) {
|
|
352
|
-
|
|
353
|
-
if (fs_.existsSync(backupRoot) && !fs_.existsSync(outputRoot)) {
|
|
569
|
+
if (backedUp && fs_.existsSync(backupRoot)) {
|
|
354
570
|
try {
|
|
571
|
+
if (installed && fs_.existsSync(outputRoot))
|
|
572
|
+
fs_.rmSync(outputRoot, { recursive: true, force: true });
|
|
355
573
|
fs_.renameSync(backupRoot, outputRoot);
|
|
356
574
|
}
|
|
357
|
-
catch {
|
|
575
|
+
catch (restoreError) {
|
|
576
|
+
throw new AggregateError([error, restoreError], `generation failed; original files retained at ${backupRoot}`);
|
|
577
|
+
}
|
|
358
578
|
}
|
|
359
579
|
if (fs_.existsSync(stagingRoot)) {
|
|
360
580
|
fs_.rmSync(stagingRoot, { recursive: true, force: true });
|
|
361
581
|
}
|
|
362
582
|
throw error;
|
|
363
583
|
}
|
|
584
|
+
// The replacement is live. A partially removed backup cannot be restored.
|
|
585
|
+
if (backedUp) {
|
|
586
|
+
try {
|
|
587
|
+
fs_.rmSync(backupRoot, { recursive: true, force: true });
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
throw new Error(`changes committed, backup cleanup failed at ${backupRoot}: ${String(error)}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
364
593
|
}
|
|
365
594
|
/** Recursively collect files in `dir` that are NOT in `managedRelPaths`. */
|
|
366
595
|
function collectUserFiles(dir, rootDir, managedRelPaths, fs_) {
|
|
367
596
|
const result = [];
|
|
368
|
-
|
|
369
|
-
try {
|
|
370
|
-
entries = fs_.readdirSync(dir);
|
|
371
|
-
}
|
|
372
|
-
catch {
|
|
373
|
-
return result;
|
|
374
|
-
}
|
|
597
|
+
const entries = fs_.readdirSync(dir);
|
|
375
598
|
for (const name of entries) {
|
|
376
599
|
const full = join(dir, name);
|
|
377
600
|
const rel = full.slice(rootDir.length).replace(/^[/\\]/, "");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
fs_.readdirSync(full);
|
|
383
|
-
isDir = true;
|
|
384
|
-
}
|
|
385
|
-
catch {
|
|
386
|
-
isDir = false;
|
|
387
|
-
}
|
|
601
|
+
const stat = lstatSync(full);
|
|
602
|
+
if (stat.isSymbolicLink())
|
|
603
|
+
throw new Error(`symbolic link in generated directory: ${full}`);
|
|
604
|
+
const isDir = stat.isDirectory();
|
|
388
605
|
if (isDir) {
|
|
389
606
|
result.push(...collectUserFiles(full, rootDir, managedRelPaths, fs_));
|
|
390
607
|
}
|
|
391
|
-
else if (
|
|
608
|
+
else if (managedRelPaths.has(rel.toLowerCase())) {
|
|
609
|
+
throw new Error(`generated path collides with an unowned user file: ${rel}`);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
392
612
|
result.push({ rel, src: full });
|
|
393
613
|
}
|
|
394
614
|
}
|
|
395
615
|
return result;
|
|
396
616
|
}
|
|
397
|
-
function isManagedAssetPath(rel) {
|
|
398
|
-
const posix = rel.replace(/\\/g, "/");
|
|
399
|
-
return posix === "assets" || posix.startsWith("assets/");
|
|
400
|
-
}
|
|
401
|
-
function isCliManagedFile(path, fs_) {
|
|
402
|
-
try {
|
|
403
|
-
return fs_.readFileSync(path, "utf8").startsWith(GENERATED_FILE_HEADER);
|
|
404
|
-
}
|
|
405
|
-
catch {
|
|
406
|
-
return false;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
617
|
/** Atomically create a new file without replacing an existing user file. */
|
|
410
618
|
export function createFileIfAbsent(target, content, fs_) {
|
|
411
619
|
if (fs_.existsSync(target))
|
|
412
620
|
return false;
|
|
413
|
-
const staging = `${target}.staging`;
|
|
414
|
-
if (fs_.existsSync(staging))
|
|
415
|
-
fs_.rmSync(staging, { force: true });
|
|
621
|
+
const staging = `${target}.staging-${randomUUID()}`;
|
|
416
622
|
fs_.mkdirSync(join(target, ".."), { recursive: true });
|
|
417
623
|
try {
|
|
418
624
|
fs_.writeFileSync(staging, content, { flag: "wx" });
|
|
@@ -428,6 +634,12 @@ export function createFileIfAbsent(target, content, fs_) {
|
|
|
428
634
|
* Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
|
|
429
635
|
* timeout, byte limit. Abort and temporary-file cleanup are handled by the
|
|
430
636
|
* caller via the injected fetch/signal.
|
|
637
|
+
*
|
|
638
|
+
* Redirect policy: unlike the API descriptor handshake (which uses
|
|
639
|
+
* `redirect: "error"`), payload downloads intentionally follow a bounded number
|
|
640
|
+
* of redirects because code/metadata archives are served from CDNs that 3xx to
|
|
641
|
+
* a signed object host; the destination host is re-checked against the
|
|
642
|
+
* allowlist on every hop.
|
|
431
643
|
*/
|
|
432
644
|
export async function downloadArtifact(url, limits, deps = {}) {
|
|
433
645
|
const parsed = new URL(url);
|