@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.
Files changed (151) hide show
  1. package/README.md +243 -9
  2. package/bin/check-node.cjs +36 -0
  3. package/bin/moeicons.js +12 -1
  4. package/dist/api/client.d.ts +13 -2
  5. package/dist/api/client.js +145 -21
  6. package/dist/auth/device-login.d.ts +10 -7
  7. package/dist/auth/device-login.js +60 -37
  8. package/dist/auth/login-schemas.d.ts +24 -0
  9. package/dist/auth/login-schemas.js +67 -0
  10. package/dist/catalog/catalog.d.ts +11 -0
  11. package/dist/catalog/catalog.js +43 -0
  12. package/dist/catalog/catalog.json +538 -3395
  13. package/dist/cli.d.ts +11 -2
  14. package/dist/cli.js +773 -147
  15. package/dist/commands/parser.d.ts +13 -2
  16. package/dist/commands/parser.js +138 -50
  17. package/dist/config-package/generated/SOURCE.json +25 -0
  18. package/dist/config-package/generated/moeicons-config.schema.json +191 -0
  19. package/dist/config-package/generated/moeicons.config.jsonc +42 -0
  20. package/dist/config-package/generated/render-config.cjs +145 -0
  21. package/dist/config-package/generated/validate-config.cjs +293 -0
  22. package/dist/config-package/generated-config.d.ts +32 -0
  23. package/dist/config-package/generated-config.js +19 -0
  24. package/dist/core/auth.d.ts +24 -0
  25. package/dist/core/auth.js +127 -8
  26. package/dist/core/bitmap-assets.d.ts +19 -5
  27. package/dist/core/bitmap-assets.js +40 -31
  28. package/dist/core/bitmap-shard-budget.d.ts +43 -0
  29. package/dist/core/bitmap-shard-budget.js +16 -0
  30. package/dist/core/bitmap-shard-cache.d.ts +68 -0
  31. package/dist/core/bitmap-shard-cache.js +115 -0
  32. package/dist/core/bitmap-shard-download.d.ts +130 -0
  33. package/dist/core/bitmap-shard-download.js +359 -0
  34. package/dist/core/bitmap-shard-resolver.d.ts +129 -0
  35. package/dist/core/bitmap-shard-resolver.js +262 -0
  36. package/dist/core/bitmap-shards.d.ts +70 -0
  37. package/dist/core/bitmap-shards.js +249 -0
  38. package/dist/core/bootstrap.d.ts +37 -0
  39. package/dist/core/bootstrap.js +54 -0
  40. package/dist/core/cache.d.ts +34 -0
  41. package/dist/core/cache.js +96 -0
  42. package/dist/core/context.d.ts +26 -0
  43. package/dist/core/doctor.d.ts +48 -0
  44. package/dist/core/doctor.js +190 -0
  45. package/dist/core/free-download.d.ts +64 -6
  46. package/dist/core/free-download.js +402 -30
  47. package/dist/core/generate.d.ts +29 -0
  48. package/dist/core/generate.js +341 -65
  49. package/dist/core/icon-selection.d.ts +8 -0
  50. package/dist/core/icon-selection.js +19 -0
  51. package/dist/core/install.d.ts +14 -0
  52. package/dist/core/install.js +132 -13
  53. package/dist/core/library-update.d.ts +5 -1
  54. package/dist/core/library-update.js +176 -17
  55. package/dist/core/local-test-env.d.ts +24 -0
  56. package/dist/core/local-test-env.js +43 -0
  57. package/dist/core/metadata-sync.d.ts +30 -0
  58. package/dist/core/metadata-sync.js +97 -0
  59. package/dist/core/pro-download.d.ts +63 -0
  60. package/dist/core/pro-download.js +247 -27
  61. package/dist/core/pro-install.d.ts +11 -3
  62. package/dist/core/pro-install.js +181 -19
  63. package/dist/core/pro-resources.d.ts +79 -0
  64. package/dist/core/pro-resources.js +204 -0
  65. package/dist/core/pro-selected-download.d.ts +18 -0
  66. package/dist/core/pro-selected-download.js +269 -0
  67. package/dist/core/release-descriptor.d.ts +42 -1
  68. package/dist/core/release-descriptor.js +117 -9
  69. package/dist/core/resource-variant.d.ts +27 -10
  70. package/dist/core/resource-variant.js +75 -34
  71. package/dist/core/selected-resources.d.ts +59 -0
  72. package/dist/core/selected-resources.js +350 -0
  73. package/dist/core/signed-artifact.d.ts +8 -1
  74. package/dist/core/signed-artifact.js +35 -11
  75. package/dist/core/target-subtree.d.ts +8 -0
  76. package/dist/core/target-subtree.js +54 -3
  77. package/dist/core/version-service.d.ts +11 -0
  78. package/dist/core/version-service.js +129 -24
  79. package/dist/core/wizard.d.ts +20 -17
  80. package/dist/core/wizard.js +66 -28
  81. package/dist/errors/errors.d.ts +2 -1
  82. package/dist/errors/errors.js +1 -0
  83. package/dist/generator/generate.d.ts +1 -1
  84. package/dist/generator/generate.js +420 -213
  85. package/dist/generator/shared/SOURCE.json +7 -0
  86. package/dist/generator/shared/svg-model.cjs +156 -0
  87. package/dist/generator/svg-model.d.ts +16 -0
  88. package/dist/generator/svg-model.js +4 -0
  89. package/dist/generator/theme-resolve.d.ts +2 -0
  90. package/dist/generator/theme-resolve.js +57 -26
  91. package/dist/mcp/server.d.ts +23 -36
  92. package/dist/mcp/server.js +74 -152
  93. package/dist/mcp/services.d.ts +4 -0
  94. package/dist/mcp/services.js +230 -0
  95. package/dist/metadata/archive.d.ts +38 -0
  96. package/dist/metadata/archive.js +94 -0
  97. package/dist/metadata/manifest.d.ts +33 -0
  98. package/dist/metadata/manifest.js +136 -0
  99. package/dist/metadata/version.d.ts +22 -0
  100. package/dist/metadata/version.js +53 -0
  101. package/dist/project/anchors/application.d.ts +13 -0
  102. package/dist/project/anchors/application.js +377 -0
  103. package/dist/project/anchors/apply.d.ts +28 -0
  104. package/dist/project/anchors/apply.js +65 -0
  105. package/dist/project/anchors/diagnose.d.ts +26 -0
  106. package/dist/project/anchors/diagnose.js +90 -0
  107. package/dist/project/anchors/helpers.d.ts +20 -0
  108. package/dist/project/anchors/helpers.js +55 -0
  109. package/dist/project/anchors/moeicons-config.d.ts +21 -0
  110. package/dist/project/anchors/moeicons-config.js +83 -0
  111. package/dist/project/anchors/parse.d.ts +14 -0
  112. package/dist/project/anchors/parse.js +36 -0
  113. package/dist/project/anchors/project-manifest.d.ts +19 -0
  114. package/dist/project/anchors/project-manifest.js +109 -0
  115. package/dist/project/anchors/style.d.ts +12 -0
  116. package/dist/project/anchors/style.js +87 -0
  117. package/dist/project/anchors/types.d.ts +32 -0
  118. package/dist/project/anchors/types.js +9 -0
  119. package/dist/project/config.d.ts +59 -5
  120. package/dist/project/config.js +234 -102
  121. package/dist/project/install-metadata.d.ts +23 -2
  122. package/dist/project/install-metadata.js +58 -7
  123. package/dist/project/install.d.ts +24 -4
  124. package/dist/project/install.js +402 -190
  125. package/dist/project/project-lock.d.ts +2 -2
  126. package/dist/project/project-lock.js +4 -4
  127. package/dist/project/tailwind.d.ts +2 -2
  128. package/dist/project/tailwind.js +3 -4
  129. package/dist/project/tar-gz.d.ts +18 -1
  130. package/dist/project/tar-gz.js +40 -3
  131. package/dist/tui/components.d.ts +62 -0
  132. package/dist/tui/components.js +100 -0
  133. package/dist/tui/primitives.d.ts +1 -0
  134. package/dist/tui/primitives.js +8 -1
  135. package/dist/ui/banner.d.ts +31 -3
  136. package/dist/ui/banner.js +120 -25
  137. package/dist/ui/branded-prompts.d.ts +41 -0
  138. package/dist/ui/branded-prompts.js +80 -0
  139. package/dist/ui/clack.d.ts +6 -3
  140. package/dist/ui/clack.js +56 -5
  141. package/dist/ui/create-ui.d.ts +1 -0
  142. package/dist/ui/create-ui.js +5 -1
  143. package/dist/ui/generated/wordmark-large.d.ts +17 -0
  144. package/dist/ui/generated/wordmark-large.js +28 -0
  145. package/dist/ui/generated/wordmark.d.ts +2 -0
  146. package/dist/ui/generated/wordmark.js +2 -0
  147. package/dist/ui/non-interactive.js +8 -0
  148. package/dist/ui/stream.js +30 -0
  149. package/dist/ui/theme.d.ts +42 -0
  150. package/dist/ui/theme.js +59 -0
  151. package/package.json +14 -6
@@ -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
- 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 };
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
- 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 });
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
- if (fs_.existsSync(backupRoot))
69
- fs_.rmSync(backupRoot, { recursive: true, force: true });
145
+ journal.committed = true;
146
+ safe(backupRelative);
147
+ fs_.writeFileSync(join(backupRoot, "committed"), operationId);
70
148
  }
71
149
  catch (error) {
72
- for (const target of installed.reverse())
73
- if (fs_.existsSync(target))
74
- fs_.rmSync(target, { force: true });
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
- if (!preserveBackup && fs_.existsSync(backupRoot))
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 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 });
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
- // 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
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 backup of backups) {
210
- if (fs_.existsSync(backup.backup))
211
- fs_.rmSync(backup.backup, { force: true });
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 = resolve(projectRoot, outputDir);
221
- const stagingRoot = `${outputRoot}.staging`;
222
- if (fs_.existsSync(stagingRoot))
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 (!backups.some((backup) => backup.original === path) && fs_.existsSync(path)) {
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 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.
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 = resolve(projectRoot, outputDir);
291
- const stagingRoot = `${outputRoot}.staging`;
292
- const backupRoot = `${outputRoot}.bak`;
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.add(rel);
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
- // 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 });
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
- // 2. Rename existing output → backup (if it exists).
317
- const hadOutput = fs_.existsSync(outputRoot);
318
- if (hadOutput) {
319
- fs_.renameSync(outputRoot, backupRoot);
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
- // 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 });
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
- // On failure, restore backup → output if backup exists.
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 { /* best-effort */ }
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
- let entries;
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
- // 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
- }
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 (!managedRelPaths.has(rel) && !isManagedAssetPath(rel) && !isCliManagedFile(full, fs_)) {
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);