@moewolf/moe-icons-cli 0.0.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.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +32 -0
  3. package/bin/moeicons.js +20 -0
  4. package/dist/api/client.d.ts +42 -0
  5. package/dist/api/client.js +105 -0
  6. package/dist/auth/callback.d.ts +37 -0
  7. package/dist/auth/callback.js +72 -0
  8. package/dist/auth/device-login.d.ts +29 -0
  9. package/dist/auth/device-login.js +74 -0
  10. package/dist/auth/login.d.ts +41 -0
  11. package/dist/auth/login.js +85 -0
  12. package/dist/auth/open-browser.d.ts +2 -0
  13. package/dist/auth/open-browser.js +15 -0
  14. package/dist/auth/pkce.d.ts +17 -0
  15. package/dist/auth/pkce.js +20 -0
  16. package/dist/auth/token-store.d.ts +38 -0
  17. package/dist/auth/token-store.js +126 -0
  18. package/dist/catalog/catalog.d.ts +32 -0
  19. package/dist/catalog/catalog.js +80 -0
  20. package/dist/catalog/catalog.json +9371 -0
  21. package/dist/cli.d.ts +23 -0
  22. package/dist/cli.js +507 -0
  23. package/dist/commands/parser.d.ts +41 -0
  24. package/dist/commands/parser.js +146 -0
  25. package/dist/core/auth.d.ts +32 -0
  26. package/dist/core/auth.js +131 -0
  27. package/dist/core/bitmap-assets.d.ts +29 -0
  28. package/dist/core/bitmap-assets.js +77 -0
  29. package/dist/core/cli-update.d.ts +43 -0
  30. package/dist/core/cli-update.js +71 -0
  31. package/dist/core/context.d.ts +22 -0
  32. package/dist/core/context.js +1 -0
  33. package/dist/core/free-download.d.ts +66 -0
  34. package/dist/core/free-download.js +193 -0
  35. package/dist/core/generate.d.ts +33 -0
  36. package/dist/core/generate.js +285 -0
  37. package/dist/core/icon-names.d.ts +14 -0
  38. package/dist/core/icon-names.js +54 -0
  39. package/dist/core/init.d.ts +12 -0
  40. package/dist/core/init.js +23 -0
  41. package/dist/core/install.d.ts +67 -0
  42. package/dist/core/install.js +117 -0
  43. package/dist/core/library-update.d.ts +24 -0
  44. package/dist/core/library-update.js +161 -0
  45. package/dist/core/manage.d.ts +24 -0
  46. package/dist/core/manage.js +38 -0
  47. package/dist/core/pro-download.d.ts +29 -0
  48. package/dist/core/pro-download.js +112 -0
  49. package/dist/core/pro-install.d.ts +24 -0
  50. package/dist/core/pro-install.js +72 -0
  51. package/dist/core/release-descriptor.d.ts +40 -0
  52. package/dist/core/release-descriptor.js +114 -0
  53. package/dist/core/resource-variant.d.ts +35 -0
  54. package/dist/core/resource-variant.js +70 -0
  55. package/dist/core/signed-artifact.d.ts +29 -0
  56. package/dist/core/signed-artifact.js +81 -0
  57. package/dist/core/target-subtree.d.ts +49 -0
  58. package/dist/core/target-subtree.js +132 -0
  59. package/dist/core/update-policy.d.ts +31 -0
  60. package/dist/core/update-policy.js +27 -0
  61. package/dist/core/version-service.d.ts +26 -0
  62. package/dist/core/version-service.js +71 -0
  63. package/dist/core/wizard.d.ts +49 -0
  64. package/dist/core/wizard.js +71 -0
  65. package/dist/errors/errors.d.ts +34 -0
  66. package/dist/errors/errors.js +39 -0
  67. package/dist/errors/index.d.ts +1 -0
  68. package/dist/errors/index.js +1 -0
  69. package/dist/generator/cn.d.ts +5 -0
  70. package/dist/generator/cn.js +14 -0
  71. package/dist/generator/generate.d.ts +34 -0
  72. package/dist/generator/generate.js +562 -0
  73. package/dist/generator/theme-resolve.d.ts +26 -0
  74. package/dist/generator/theme-resolve.js +100 -0
  75. package/dist/mcp/server.d.ts +58 -0
  76. package/dist/mcp/server.js +176 -0
  77. package/dist/project/config.d.ts +74 -0
  78. package/dist/project/config.js +353 -0
  79. package/dist/project/detect.d.ts +26 -0
  80. package/dist/project/detect.js +104 -0
  81. package/dist/project/install-metadata.d.ts +29 -0
  82. package/dist/project/install-metadata.js +108 -0
  83. package/dist/project/install.d.ts +115 -0
  84. package/dist/project/install.js +537 -0
  85. package/dist/project/project-lock.d.ts +11 -0
  86. package/dist/project/project-lock.js +81 -0
  87. package/dist/project/tailwind.d.ts +51 -0
  88. package/dist/project/tailwind.js +188 -0
  89. package/dist/project/tar-gz.d.ts +12 -0
  90. package/dist/project/tar-gz.js +100 -0
  91. package/dist/tui/primitives.d.ts +36 -0
  92. package/dist/tui/primitives.js +88 -0
  93. package/dist/ui/banner.d.ts +11 -0
  94. package/dist/ui/banner.js +105 -0
  95. package/dist/ui/clack.d.ts +8 -0
  96. package/dist/ui/clack.js +53 -0
  97. package/dist/ui/create-ui.d.ts +14 -0
  98. package/dist/ui/create-ui.js +16 -0
  99. package/dist/ui/non-interactive.d.ts +5 -0
  100. package/dist/ui/non-interactive.js +30 -0
  101. package/dist/ui/stream.d.ts +11 -0
  102. package/dist/ui/stream.js +50 -0
  103. package/package.json +70 -0
@@ -0,0 +1,115 @@
1
+ import type { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, rmSync, readdirSync, copyFileSync } from "node:fs";
2
+ /**
3
+ * Transactional install: stage sibling, verify all files, backup existing
4
+ * managed output, rename atomically, update config last, restore on failure.
5
+ * Never touches the project tree until every file is verified in staging.
6
+ */
7
+ export interface InstallPlanItem {
8
+ readonly kind: "mkdir" | "write" | "remove" | "config";
9
+ readonly path: string;
10
+ /** Relative path inside the install root, used to stage nested files safely. */
11
+ readonly rel?: string;
12
+ readonly content?: string;
13
+ readonly bytes?: Uint8Array;
14
+ }
15
+ export interface InstallPlan {
16
+ readonly items: readonly InstallPlanItem[];
17
+ }
18
+ export interface TransactionalFs {
19
+ readonly mkdirSync: typeof mkdirSync;
20
+ readonly writeFileSync: typeof writeFileSync;
21
+ readonly existsSync: typeof existsSync;
22
+ readonly renameSync: typeof renameSync;
23
+ readonly rmSync: typeof rmSync;
24
+ }
25
+ export interface TransactionalFsWithCopy extends TransactionalFs {
26
+ readonly readFileSync: typeof readFileSync;
27
+ readonly readdirSync: typeof readdirSync;
28
+ readonly copyFileSync: typeof copyFileSync;
29
+ }
30
+ /**
31
+ * Atomically reconcile an explicit cross-directory managed set. `removePaths`
32
+ * must come from trusted metadata; this function never scans or uses globs.
33
+ */
34
+ export declare function executeManagedReconcile(projectRoot: string, writes: Readonly<Record<string, string | Uint8Array>>, removePaths: readonly string[], fs_: TransactionalFsWithCopy): void;
35
+ /** Pure list of install operations with expected checksums. */
36
+ export declare function createInstallPlan(targetRoot: string, files: Readonly<Record<string, string | Uint8Array>>): InstallPlan;
37
+ /** Create a deterministic ZIP for delivery (fixed mtime). */
38
+ export declare function createArtifactZip(files: Readonly<Record<string, string>>): Uint8Array;
39
+ /** Unzip an artifact, rejecting unsafe paths. */
40
+ export declare function extractArtifact(zipBytes: Uint8Array, limits: {
41
+ maxEntries: number;
42
+ maxExpandedBytes: number;
43
+ }): {
44
+ files: Record<string, string>;
45
+ errors: string[];
46
+ };
47
+ /** SHA-256 hex of a string. */
48
+ export declare function sha256Hex(content: string): string;
49
+ /**
50
+ * Execute an install plan against a filesystem with a staging dir, verifying
51
+ * checksums before any rename. Restores the original tree on failure.
52
+ */
53
+ export declare function executeInstallPlan(plan: InstallPlan, fs_: TransactionalFs): void;
54
+ /**
55
+ * Transactionally replace only generated files. Existing files outside this
56
+ * explicit list are left untouched, which makes the output directory safe to
57
+ * share with user-owned files.
58
+ */
59
+ export declare function executeGeneratedFiles(files: readonly {
60
+ path: string;
61
+ content: string;
62
+ }[], projectRoot: string, outputDir: string, fs_: TransactionalFs): void;
63
+ /**
64
+ * Directory-level transactional generate: writes all CLI-managed files into a
65
+ * sibling staging directory, renames the current output directory to a backup,
66
+ * renames staging to output, then merges user-owned files (files not in the
67
+ * CLI-managed set) back from the backup. Restores the backup on any failure so
68
+ * the output directory is never left in a partial state.
69
+ *
70
+ * The set of CLI-managed paths is determined by the `files` argument. Any file
71
+ * that already exists in outputDir but is NOT in that set is considered
72
+ * user-owned and is preserved. Older CLI-managed files are identified by the
73
+ * generated-file header and are intentionally not restored.
74
+ */
75
+ export declare function executeGeneratedFilesDir(files: readonly {
76
+ path: string;
77
+ content: string | Uint8Array;
78
+ }[], projectRoot: string, outputDir: string, fs_: TransactionalFsWithCopy): void;
79
+ /** Atomically create a new file without replacing an existing user file. */
80
+ export declare function createFileIfAbsent(target: string, content: string, fs_: TransactionalFs): boolean;
81
+ export interface DownloadLimits {
82
+ readonly maxBytes: number;
83
+ readonly timeoutMs: number;
84
+ readonly maxRedirects: number;
85
+ readonly allowedHosts?: readonly string[];
86
+ readonly userAgent?: string;
87
+ readonly onProgress?: (event: {
88
+ readonly downloadedBytes: number;
89
+ readonly totalBytes?: number;
90
+ }) => void;
91
+ readonly allowHttpLoopback?: boolean;
92
+ }
93
+ export type DownloadResult = {
94
+ readonly ok: true;
95
+ readonly bytes: Uint8Array;
96
+ readonly finalUrl: string;
97
+ } | {
98
+ readonly ok: false;
99
+ readonly code: string;
100
+ readonly message: string;
101
+ };
102
+ /**
103
+ * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
104
+ * timeout, byte limit. Abort and temporary-file cleanup are handled by the
105
+ * caller via the injected fetch/signal.
106
+ */
107
+ export declare function downloadArtifact(url: string, limits: DownloadLimits, deps?: {
108
+ fetchFn?: typeof fetch;
109
+ signal?: AbortSignal;
110
+ }): Promise<DownloadResult>;
111
+ /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
112
+ export declare function verifyArtifact(bytes: Uint8Array, expectedSha256: string): {
113
+ ok: boolean;
114
+ actual: string;
115
+ };
@@ -0,0 +1,537 @@
1
+ import { join, relative, resolve } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { strToU8, zipSync, unzipSync } from "fflate";
4
+ /**
5
+ * Atomically reconcile an explicit cross-directory managed set. `removePaths`
6
+ * must come from trusted metadata; this function never scans or uses globs.
7
+ */
8
+ export function executeManagedReconcile(projectRoot, writes, removePaths, fs_) {
9
+ const stagingRoot = join(projectRoot, ".moeicons", ".reconcile-staging");
10
+ const backupRoot = join(projectRoot, ".moeicons", ".reconcile-backup");
11
+ const safe = (relative) => {
12
+ const normalized = relative.replace(/\\/g, "/");
13
+ if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..") || /^[A-Za-z]:/.test(normalized))
14
+ throw new Error(`unsafe managed path: ${relative}`);
15
+ const target = resolve(projectRoot, normalized);
16
+ if (!target.startsWith(`${resolve(projectRoot)}/`) && !target.startsWith(`${resolve(projectRoot)}\\`))
17
+ throw new Error(`managed path escapes project: ${relative}`);
18
+ return { normalized, target };
19
+ };
20
+ const entries = Object.entries(writes).map(([relative, content]) => ({ ...safe(relative), content }));
21
+ const removals = [...new Set(removePaths)].map(safe).filter((item) => !Object.hasOwn(writes, item.normalized));
22
+ if (fs_.existsSync(stagingRoot))
23
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
24
+ if (fs_.existsSync(backupRoot)) {
25
+ const recover = (dir) => {
26
+ let names;
27
+ try {
28
+ names = fs_.readdirSync(dir);
29
+ }
30
+ catch {
31
+ const rel = relative(backupRoot, dir);
32
+ const target = join(projectRoot, rel);
33
+ if (fs_.existsSync(target))
34
+ fs_.rmSync(target, { recursive: true, force: true });
35
+ fs_.mkdirSync(join(target, ".."), { recursive: true });
36
+ fs_.renameSync(dir, target);
37
+ return;
38
+ }
39
+ for (const name of names)
40
+ recover(join(dir, name));
41
+ };
42
+ recover(backupRoot);
43
+ fs_.rmSync(backupRoot, { recursive: true, force: true });
44
+ }
45
+ const backedUp = [];
46
+ const installed = [];
47
+ let preserveBackup = false;
48
+ try {
49
+ for (const entry of entries) {
50
+ const staged = join(stagingRoot, entry.normalized);
51
+ fs_.mkdirSync(join(staged, ".."), { recursive: true });
52
+ fs_.writeFileSync(staged, entry.content);
53
+ }
54
+ for (const item of [...removals, ...entries]) {
55
+ if (!fs_.existsSync(item.target))
56
+ continue;
57
+ const backup = join(backupRoot, item.normalized);
58
+ fs_.mkdirSync(join(backup, ".."), { recursive: true });
59
+ fs_.renameSync(item.target, backup);
60
+ backedUp.push({ target: item.target, backup });
61
+ }
62
+ for (const entry of entries) {
63
+ const staged = join(stagingRoot, entry.normalized);
64
+ fs_.mkdirSync(join(entry.target, ".."), { recursive: true });
65
+ fs_.renameSync(staged, entry.target);
66
+ installed.push(entry.target);
67
+ }
68
+ if (fs_.existsSync(backupRoot))
69
+ fs_.rmSync(backupRoot, { recursive: true, force: true });
70
+ }
71
+ catch (error) {
72
+ for (const target of installed.reverse())
73
+ if (fs_.existsSync(target))
74
+ fs_.rmSync(target, { force: true });
75
+ for (const item of backedUp.reverse()) {
76
+ if (fs_.existsSync(item.backup)) {
77
+ fs_.mkdirSync(join(item.target, ".."), { recursive: true });
78
+ try {
79
+ fs_.renameSync(item.backup, item.target);
80
+ }
81
+ catch {
82
+ preserveBackup = true;
83
+ }
84
+ }
85
+ }
86
+ throw error;
87
+ }
88
+ finally {
89
+ if (fs_.existsSync(stagingRoot))
90
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
91
+ if (!preserveBackup && fs_.existsSync(backupRoot))
92
+ fs_.rmSync(backupRoot, { recursive: true, force: true });
93
+ }
94
+ }
95
+ const GENERATED_FILE_HEADER = "// Generated by moeicons CLI.";
96
+ /** Pure list of install operations with expected checksums. */
97
+ export function createInstallPlan(targetRoot, files) {
98
+ const items = [];
99
+ const sorted = Object.keys(files).sort((a, b) => a.localeCompare(b));
100
+ for (const rel of sorted) {
101
+ const content = files[rel];
102
+ if (content !== undefined) {
103
+ items.push(typeof content === "string"
104
+ ? { kind: "write", path: join(targetRoot, rel), rel, content }
105
+ : { kind: "write", path: join(targetRoot, rel), rel, bytes: content });
106
+ }
107
+ }
108
+ return { items };
109
+ }
110
+ /** Create a deterministic ZIP for delivery (fixed mtime). */
111
+ export function createArtifactZip(files) {
112
+ const sorted = Object.keys(files).sort((a, b) => a.localeCompare(b));
113
+ const dict = {};
114
+ for (const rel of sorted) {
115
+ dict[rel] = strToU8(files[rel] ?? "");
116
+ }
117
+ return zipSync(dict, { level: 9, mtime: new Date("2020-01-01T00:00:00.000Z") });
118
+ }
119
+ /** Unzip an artifact, rejecting unsafe paths. */
120
+ export function extractArtifact(zipBytes, limits) {
121
+ const files = {};
122
+ const errors = [];
123
+ let entries = 0;
124
+ let expanded = 0;
125
+ const dict = unzipSync(zipBytes);
126
+ for (const [rel, bytes] of Object.entries(dict)) {
127
+ entries += 1;
128
+ if (entries > limits.maxEntries) {
129
+ errors.push(`too many entries (> ${limits.maxEntries})`);
130
+ break;
131
+ }
132
+ const cleaned = rel.replace(/\\/g, "/");
133
+ if (cleaned.startsWith("/") || cleaned.split("/").includes("..")) {
134
+ errors.push(`unsafe path "${rel}"`);
135
+ continue;
136
+ }
137
+ expanded += bytes.byteLength;
138
+ if (expanded > limits.maxExpandedBytes) {
139
+ errors.push(`expanded size exceeds limit`);
140
+ break;
141
+ }
142
+ files[cleaned] = new TextDecoder().decode(bytes);
143
+ }
144
+ return { files, errors };
145
+ }
146
+ /** SHA-256 hex of a string. */
147
+ export function sha256Hex(content) {
148
+ return createHash("sha256").update(content).digest("hex");
149
+ }
150
+ /**
151
+ * Execute an install plan against a filesystem with a staging dir, verifying
152
+ * checksums before any rename. Restores the original tree on failure.
153
+ */
154
+ export function executeInstallPlan(plan, fs_) {
155
+ const writes = plan.items.filter((i) => i.kind === "write");
156
+ // stage all writes into a sibling staging dir
157
+ const firstWrite = writes[0];
158
+ if (!firstWrite)
159
+ return;
160
+ const stagingRoot = `${resolve(firstWrite.path)}.staging`;
161
+ if (fs_.existsSync(stagingRoot))
162
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
163
+ fs_.mkdirSync(stagingRoot, { recursive: true });
164
+ const backups = [];
165
+ const installed = [];
166
+ try {
167
+ for (const item of writes) {
168
+ const rel = item.rel ?? item.path.replace(/\\/g, "/").split("/").pop() ?? "file";
169
+ const staged = join(stagingRoot, rel);
170
+ fs_.mkdirSync(join(staged, ".."), { recursive: true });
171
+ fs_.writeFileSync(staged, item.bytes ?? item.content ?? "");
172
+ }
173
+ // move staged files into place, backing up existing managed output
174
+ for (const item of writes) {
175
+ const rel = item.rel ?? item.path.replace(/\\/g, "/").split("/").pop() ?? "file";
176
+ const staged = join(stagingRoot, rel);
177
+ if (fs_.existsSync(item.path)) {
178
+ const backup = `${item.path}.bak`;
179
+ fs_.renameSync(item.path, backup);
180
+ backups.push({ original: item.path, backup });
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 });
191
+ }
192
+ }
193
+ // restore any backups made before the failure
194
+ for (const b of backups) {
195
+ try {
196
+ if (fs_.existsSync(b.backup))
197
+ fs_.renameSync(b.backup, b.original);
198
+ }
199
+ catch {
200
+ // ignore restore errors; the original remains at .bak
201
+ }
202
+ }
203
+ throw error;
204
+ }
205
+ finally {
206
+ if (fs_.existsSync(stagingRoot))
207
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
208
+ }
209
+ for (const backup of backups) {
210
+ if (fs_.existsSync(backup.backup))
211
+ fs_.rmSync(backup.backup, { force: true });
212
+ }
213
+ }
214
+ /**
215
+ * Transactionally replace only generated files. Existing files outside this
216
+ * explicit list are left untouched, which makes the output directory safe to
217
+ * share with user-owned files.
218
+ */
219
+ export function executeGeneratedFiles(files, projectRoot, outputDir, fs_) {
220
+ const outputRoot = resolve(projectRoot, outputDir);
221
+ const stagingRoot = `${outputRoot}.staging`;
222
+ if (fs_.existsSync(stagingRoot))
223
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
224
+ const entries = files.map((file) => {
225
+ const target = resolve(projectRoot, file.path);
226
+ const relative = target.slice(outputRoot.length).replace(/^[/\\]/, "");
227
+ if (target !== outputRoot && !target.startsWith(`${outputRoot}/`) && !target.startsWith(`${outputRoot}\\`)) {
228
+ throw new Error(`generated path escapes output directory: ${file.path}`);
229
+ }
230
+ return { target, staged: join(stagingRoot, relative), content: file.content };
231
+ });
232
+ fs_.mkdirSync(stagingRoot, { recursive: true });
233
+ const backups = [];
234
+ const installed = [];
235
+ try {
236
+ for (const entry of entries) {
237
+ fs_.mkdirSync(join(entry.staged, ".."), { recursive: true });
238
+ fs_.writeFileSync(entry.staged, entry.content);
239
+ }
240
+ for (const entry of entries) {
241
+ if (fs_.existsSync(entry.target)) {
242
+ const backup = `${entry.target}.bak`;
243
+ fs_.renameSync(entry.target, backup);
244
+ backups.push({ original: entry.target, backup });
245
+ }
246
+ fs_.mkdirSync(join(entry.target, ".."), { recursive: true });
247
+ fs_.renameSync(entry.staged, entry.target);
248
+ installed.push(entry.target);
249
+ }
250
+ }
251
+ catch (error) {
252
+ for (const path of installed) {
253
+ if (!backups.some((backup) => backup.original === path) && fs_.existsSync(path)) {
254
+ fs_.rmSync(path, { force: true });
255
+ }
256
+ }
257
+ for (const backup of backups) {
258
+ try {
259
+ if (fs_.existsSync(backup.backup))
260
+ fs_.renameSync(backup.backup, backup.original);
261
+ }
262
+ catch {
263
+ // Preserve the backup when the filesystem cannot restore it.
264
+ }
265
+ }
266
+ throw error;
267
+ }
268
+ finally {
269
+ if (fs_.existsSync(stagingRoot))
270
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
271
+ }
272
+ for (const backup of backups) {
273
+ if (fs_.existsSync(backup.backup))
274
+ fs_.rmSync(backup.backup, { force: true });
275
+ }
276
+ }
277
+ /**
278
+ * Directory-level transactional generate: writes all CLI-managed files into a
279
+ * sibling staging directory, renames the current output directory to a backup,
280
+ * renames staging to output, then merges user-owned files (files not in the
281
+ * CLI-managed set) back from the backup. Restores the backup on any failure so
282
+ * the output directory is never left in a partial state.
283
+ *
284
+ * The set of CLI-managed paths is determined by the `files` argument. Any file
285
+ * that already exists in outputDir but is NOT in that set is considered
286
+ * user-owned and is preserved. Older CLI-managed files are identified by the
287
+ * generated-file header and are intentionally not restored.
288
+ */
289
+ export function executeGeneratedFilesDir(files, projectRoot, outputDir, fs_) {
290
+ const outputRoot = resolve(projectRoot, outputDir);
291
+ const stagingRoot = `${outputRoot}.staging`;
292
+ const backupRoot = `${outputRoot}.bak`;
293
+ // Validate all paths up front.
294
+ const managedRelPaths = new Set();
295
+ const entries = files.map((file) => {
296
+ const target = resolve(projectRoot, file.path);
297
+ if (target !== outputRoot && !target.startsWith(`${outputRoot}/`) && !target.startsWith(`${outputRoot}\\`)) {
298
+ throw new Error(`generated path escapes output directory: ${file.path}`);
299
+ }
300
+ const rel = target.slice(outputRoot.length).replace(/^[/\\]/, "");
301
+ managedRelPaths.add(rel);
302
+ return { staged: join(stagingRoot, rel), content: file.content };
303
+ });
304
+ // Clean up any leftover staging/backup from a previous failed run.
305
+ if (fs_.existsSync(stagingRoot))
306
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
307
+ if (fs_.existsSync(backupRoot))
308
+ fs_.rmSync(backupRoot, { recursive: true, force: true });
309
+ fs_.mkdirSync(stagingRoot, { recursive: true });
310
+ try {
311
+ // 1. Write all CLI-managed files into staging.
312
+ for (const entry of entries) {
313
+ fs_.mkdirSync(join(entry.staged, ".."), { recursive: true });
314
+ fs_.writeFileSync(entry.staged, entry.content);
315
+ }
316
+ // 2. Rename existing output → backup (if it exists).
317
+ const hadOutput = fs_.existsSync(outputRoot);
318
+ if (hadOutput) {
319
+ fs_.renameSync(outputRoot, backupRoot);
320
+ }
321
+ // 3. Rename staging → output (atomic on same-filesystem).
322
+ try {
323
+ fs_.renameSync(stagingRoot, outputRoot);
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 });
349
+ }
350
+ }
351
+ catch (error) {
352
+ // On failure, restore backup → output if backup exists.
353
+ if (fs_.existsSync(backupRoot) && !fs_.existsSync(outputRoot)) {
354
+ try {
355
+ fs_.renameSync(backupRoot, outputRoot);
356
+ }
357
+ catch { /* best-effort */ }
358
+ }
359
+ if (fs_.existsSync(stagingRoot)) {
360
+ fs_.rmSync(stagingRoot, { recursive: true, force: true });
361
+ }
362
+ throw error;
363
+ }
364
+ }
365
+ /** Recursively collect files in `dir` that are NOT in `managedRelPaths`. */
366
+ function collectUserFiles(dir, rootDir, managedRelPaths, fs_) {
367
+ const result = [];
368
+ let entries;
369
+ try {
370
+ entries = fs_.readdirSync(dir);
371
+ }
372
+ catch {
373
+ return result;
374
+ }
375
+ for (const name of entries) {
376
+ const full = join(dir, name);
377
+ const rel = full.slice(rootDir.length).replace(/^[/\\]/, "");
378
+ // Check if it is a directory without using withFileTypes (avoids Dirent overload issues).
379
+ let isDir = false;
380
+ try {
381
+ // readdirSync on the path will succeed for directories.
382
+ fs_.readdirSync(full);
383
+ isDir = true;
384
+ }
385
+ catch {
386
+ isDir = false;
387
+ }
388
+ if (isDir) {
389
+ result.push(...collectUserFiles(full, rootDir, managedRelPaths, fs_));
390
+ }
391
+ else if (!managedRelPaths.has(rel) && !isManagedAssetPath(rel) && !isCliManagedFile(full, fs_)) {
392
+ result.push({ rel, src: full });
393
+ }
394
+ }
395
+ return result;
396
+ }
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
+ /** Atomically create a new file without replacing an existing user file. */
410
+ export function createFileIfAbsent(target, content, fs_) {
411
+ if (fs_.existsSync(target))
412
+ return false;
413
+ const staging = `${target}.staging`;
414
+ if (fs_.existsSync(staging))
415
+ fs_.rmSync(staging, { force: true });
416
+ fs_.mkdirSync(join(target, ".."), { recursive: true });
417
+ try {
418
+ fs_.writeFileSync(staging, content, { flag: "wx" });
419
+ fs_.renameSync(staging, target);
420
+ return true;
421
+ }
422
+ finally {
423
+ if (fs_.existsSync(staging))
424
+ fs_.rmSync(staging, { force: true });
425
+ }
426
+ }
427
+ /**
428
+ * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
429
+ * timeout, byte limit. Abort and temporary-file cleanup are handled by the
430
+ * caller via the injected fetch/signal.
431
+ */
432
+ export async function downloadArtifact(url, limits, deps = {}) {
433
+ const parsed = new URL(url);
434
+ const loopbackHttp = limits.allowHttpLoopback === true && parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1");
435
+ if (parsed.protocol !== "https:" && !loopbackHttp) {
436
+ return { ok: false, code: "NON_HTTPS", message: "artifact URLs must use https" };
437
+ }
438
+ if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.host)) {
439
+ return { ok: false, code: "HOST_NOT_ALLOWED", message: `host ${parsed.host} not in allowlist` };
440
+ }
441
+ const fetchFn = deps.fetchFn ?? globalThis.fetch;
442
+ const controller = new AbortController();
443
+ const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
444
+ const abortHandler = () => controller.abort();
445
+ deps.signal?.addEventListener("abort", abortHandler, { once: true });
446
+ try {
447
+ let currentUrl = url;
448
+ let redirects = 0;
449
+ let response;
450
+ for (;;) {
451
+ response = await fetchFn(currentUrl, {
452
+ method: "GET",
453
+ redirect: "manual",
454
+ signal: controller.signal,
455
+ ...(limits.userAgent ? { headers: { "user-agent": limits.userAgent } } : {}),
456
+ });
457
+ if (response.status >= 300 && response.status < 400) {
458
+ redirects += 1;
459
+ if (redirects > limits.maxRedirects) {
460
+ return { ok: false, code: "TOO_MANY_REDIRECTS", message: `exceeded ${limits.maxRedirects} redirects` };
461
+ }
462
+ const location = response.headers.get("location");
463
+ if (!location) {
464
+ return { ok: false, code: "REDIRECT_NO_LOCATION", message: "redirect without location header" };
465
+ }
466
+ currentUrl = new URL(location, currentUrl).toString();
467
+ const next = new URL(currentUrl);
468
+ const nextLoopback = limits.allowHttpLoopback === true && next.protocol === "http:" && (next.hostname === "127.0.0.1" || next.hostname === "localhost" || next.hostname === "::1");
469
+ if (next.protocol !== "https:" && !nextLoopback) {
470
+ return { ok: false, code: "NON_HTTPS", message: "redirect to non-https url" };
471
+ }
472
+ if (limits.allowedHosts && !limits.allowedHosts.includes(next.host)) {
473
+ return { ok: false, code: "HOST_NOT_ALLOWED", message: `redirect host ${next.host} not in allowlist` };
474
+ }
475
+ continue;
476
+ }
477
+ break;
478
+ }
479
+ if (!response || response.status >= 400) {
480
+ return { ok: false, code: "HTTP_ERROR", message: `request failed with ${response.status ?? "unknown"}` };
481
+ }
482
+ const rawLength = response.headers.get("content-length");
483
+ const contentLength = rawLength !== null && /^\d+$/.test(rawLength) ? Number(rawLength) : undefined;
484
+ if (contentLength !== undefined && contentLength > limits.maxBytes) {
485
+ return { ok: false, code: "TOO_LARGE", message: `content-length ${contentLength} exceeds limit` };
486
+ }
487
+ const chunks = [];
488
+ let downloadedBytes = 0;
489
+ if (response.body) {
490
+ const reader = response.body.getReader();
491
+ for (;;) {
492
+ const chunk = await reader.read();
493
+ if (chunk.done)
494
+ break;
495
+ const value = chunk.value;
496
+ downloadedBytes += value.byteLength;
497
+ if (downloadedBytes > limits.maxBytes) {
498
+ await reader.cancel();
499
+ return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
500
+ }
501
+ chunks.push(value);
502
+ limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
503
+ }
504
+ }
505
+ else {
506
+ const buffer = await response.arrayBuffer();
507
+ downloadedBytes = buffer.byteLength;
508
+ if (downloadedBytes > limits.maxBytes)
509
+ return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
510
+ chunks.push(new Uint8Array(buffer));
511
+ limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
512
+ }
513
+ const bytes = new Uint8Array(downloadedBytes);
514
+ let offset = 0;
515
+ for (const chunk of chunks) {
516
+ bytes.set(chunk, offset);
517
+ offset += chunk.byteLength;
518
+ }
519
+ return { ok: true, bytes, finalUrl: currentUrl };
520
+ }
521
+ catch (error) {
522
+ return {
523
+ ok: false,
524
+ code: "NETWORK_ERROR",
525
+ message: error instanceof Error ? error.message : String(error),
526
+ };
527
+ }
528
+ finally {
529
+ clearTimeout(timer);
530
+ deps.signal?.removeEventListener("abort", abortHandler);
531
+ }
532
+ }
533
+ /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
534
+ export function verifyArtifact(bytes, expectedSha256) {
535
+ const actual = createHash("sha256").update(bytes).digest("hex");
536
+ return { ok: actual.toLowerCase() === expectedSha256.toLowerCase(), actual };
537
+ }
@@ -0,0 +1,11 @@
1
+ export interface ProjectLockIo {
2
+ readonly exists: (path: string) => boolean;
3
+ readonly read: (path: string) => string;
4
+ readonly createExclusive: (path: string, content: string) => void;
5
+ readonly remove: (path: string) => void;
6
+ readonly isProcessAlive: (pid: number) => boolean;
7
+ readonly now: () => number;
8
+ readonly pid: number;
9
+ }
10
+ export declare function withProjectLock<T>(projectRoot: string, operation: "install" | "update" | "reload", work: () => Promise<T> | T, io?: ProjectLockIo): Promise<T>;
11
+ export declare function withProjectLockSync<T>(projectRoot: string, operation: "install" | "update" | "reload", work: () => T, io?: ProjectLockIo): T;