@hominis/fireforge 0.10.1 → 0.11.0

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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -4,30 +4,86 @@ import { readdir } from 'node:fs/promises';
4
4
  import { join, relative } from 'node:path';
5
5
  import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
- import { copyFile, ensureDir, pathExists, readText } from '../utils/fs.js';
7
+ import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
+ import { verbose } from '../utils/logger.js';
8
9
  import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
9
- import { addCustomElementRegistration, addJarMnEntries } from './furnace-registration.js';
10
+ import { addCustomElementRegistration, addJarMnEntries, validateCustomElementRegistration, validateJarMnEntries, } from './furnace-registration.js';
10
11
  import { recordCreatedDir, snapshotFile } from './furnace-rollback.js';
11
- /** Path to the Fluent localization directory for toolkit global components */
12
- const FTL_DIR = 'toolkit/locales/en-US/toolkit/global';
12
+ import { checkRegistrationConsistency } from './furnace-validate-registration.js';
13
+ import { isGitRepository } from './git.js';
14
+ import { fileExistsInHead, restoreTrackedPath } from './git-file-ops.js';
15
+ function isRegularFile(entry) {
16
+ if (!entry.isFile())
17
+ return false;
18
+ if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink())
19
+ return false;
20
+ return true;
21
+ }
13
22
  function isChecksummedComponentFile(name) {
14
23
  return name.endsWith('.mjs') || name.endsWith('.css') || name.endsWith('.ftl');
15
24
  }
16
- function isOverrideCopyCandidate(entryName, type) {
25
+ /**
26
+ * Filter deciding which files in an override workspace directory are candidates
27
+ * for copying into the engine. Exported so `furnace remove` can invert apply
28
+ * using the exact same file set — the "files to restore" set is defined as the
29
+ * inverse of the "files apply would have written" set.
30
+ */
31
+ export function isOverrideCopyCandidate(entryName, type) {
17
32
  if (entryName === 'override.json') {
18
33
  return false;
19
34
  }
20
35
  if (type === 'css-only') {
21
36
  return entryName.endsWith('.css');
22
37
  }
23
- return entryName.endsWith('.mjs') || entryName.endsWith('.css');
38
+ return entryName.endsWith('.mjs') || entryName.endsWith('.css') || entryName.endsWith('.ftl');
39
+ }
40
+ /** Resolves the engine destination path for a single override-managed file. */
41
+ export function getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir) {
42
+ return fileName.endsWith('.ftl')
43
+ ? join(engineDir, ftlDir, fileName)
44
+ : join(engineDir, config.basePath, fileName);
45
+ }
46
+ /**
47
+ * Restores a single override-deployed engine file to its pristine HEAD state,
48
+ * inverting whatever apply wrote into that path.
49
+ *
50
+ * Behaviour matches the per-file branch of `restoreOverrideEngineFiles` in
51
+ * `furnace remove`: snapshot first, then either `git restore` (if the file
52
+ * exists in HEAD) or hard-delete (if the override introduced the file). The
53
+ * caller MUST guarantee `engineDir` is a git repository — this helper does
54
+ * not re-check, because both `furnace remove` and `furnace apply` already
55
+ * own the precondition check at their entry points and re-checking on every
56
+ * file would balloon git invocations.
57
+ *
58
+ * Returns the action taken so the caller can produce accurate user-facing
59
+ * counts (`restored` vs `removed`). `noop` means the file was neither in HEAD
60
+ * nor on disk, which can happen when the engine was reset out-of-band — the
61
+ * caller should treat that as a successful no-op rather than an error.
62
+ */
63
+ export async function restoreOverrideFileToBaseline(engineDir, enginePath, journal) {
64
+ const relPath = relative(engineDir, enginePath);
65
+ // Snapshot before mutation so a later rollback can undo both restoration
66
+ // (writes whatever content we removed back) and deletion (recreates the
67
+ // file). Snapshotting a missing path records `{ existed: false }`, which
68
+ // restoreFile turns into a delete — exactly the inverse of "we just
69
+ // wrote a file here", which is correct for the noop case too.
70
+ await snapshotFile(journal, enginePath);
71
+ if (await fileExistsInHead(engineDir, relPath)) {
72
+ await restoreTrackedPath(engineDir, relPath);
73
+ return 'restored';
74
+ }
75
+ if (await pathExists(enginePath)) {
76
+ await removeFile(enginePath);
77
+ return 'removed';
78
+ }
79
+ return 'noop';
24
80
  }
25
81
  /** Computes stable checksums for the source files that define a component. */
26
82
  export async function computeComponentChecksums(componentDir) {
27
83
  const checksums = {};
28
84
  const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
29
85
  for (const entry of entries) {
30
- if (!entry.isFile())
86
+ if (!isRegularFile(entry))
31
87
  continue;
32
88
  if (entry.name === 'override.json')
33
89
  continue;
@@ -40,6 +96,92 @@ export async function computeComponentChecksums(componentDir) {
40
96
  }
41
97
  return checksums;
42
98
  }
99
+ /**
100
+ * Returns the filenames present in `previous` that are absent from `current`
101
+ * — i.e. files we know we deployed last time but the workspace has since
102
+ * deleted. The order of returned names is intentionally stable
103
+ * (sorted alphabetically) so test snapshots and CLI output are deterministic.
104
+ */
105
+ export function diffDeletedFiles(previous, current) {
106
+ const deleted = [];
107
+ for (const key of Object.keys(previous)) {
108
+ if (!(key in current)) {
109
+ deleted.push(key);
110
+ }
111
+ }
112
+ return deleted.sort();
113
+ }
114
+ /**
115
+ * Removes engine copies of files that the developer has deleted from a custom
116
+ * component's workspace since the last apply. `.ftl` files live under the
117
+ * shared Fluent tree (`engine/${FTL_DIR}`); everything else lives under
118
+ * `engine/${config.targetPath}`. Snapshots each removal into the journal so a
119
+ * mid-apply failure can roll the engine back to its pre-undeploy state. Files
120
+ * that are already missing from the engine are silently no-op (the engine
121
+ * may have been reset out-of-band — refusing here would surface a confusing
122
+ * error in a recovery path).
123
+ *
124
+ * Does **not** touch jar.mn or customElements.js: registration churn is the
125
+ * caller's responsibility, since it must coordinate with the new file list
126
+ * computed by the regular apply step that follows.
127
+ */
128
+ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDir, rollbackJournal) {
129
+ const removed = [];
130
+ for (const fileName of deletedFiles) {
131
+ const enginePath = fileName.endsWith('.ftl')
132
+ ? join(engineDir, ftlDir, fileName)
133
+ : join(engineDir, config.targetPath, fileName);
134
+ if (rollbackJournal) {
135
+ await snapshotFile(rollbackJournal, enginePath);
136
+ }
137
+ if (await pathExists(enginePath)) {
138
+ await removeFile(enginePath);
139
+ removed.push(relative(engineDir, enginePath));
140
+ }
141
+ }
142
+ return removed;
143
+ }
144
+ /**
145
+ * Restores or removes engine copies of files that the developer has deleted
146
+ * from an override component's workspace since the last apply. Each file is
147
+ * routed through `restoreOverrideFileToBaseline`, which restores it from
148
+ * HEAD if it was a Firefox baseline file or hard-deletes it if the override
149
+ * had introduced it.
150
+ *
151
+ * Requires `engineDir` to be a git repository — overrides cannot be inverted
152
+ * without git HEAD as the source of truth. The caller is expected to have
153
+ * already validated this precondition for the apply path; we re-check here
154
+ * so unit tests that exercise this helper directly cannot accidentally
155
+ * silent-fail on a non-git fixture.
156
+ */
157
+ export async function undeployOverrideFiles(engineDir, config, deletedFiles, ftlDir, rollbackJournal) {
158
+ if (deletedFiles.length === 0) {
159
+ return { restored: [], removed: [] };
160
+ }
161
+ if (!rollbackJournal) {
162
+ throw new FurnaceError('Internal: undeployOverrideFiles requires a rollback journal so deletions can be undone on failure.');
163
+ }
164
+ if (!(await isGitRepository(engineDir))) {
165
+ throw new FurnaceError('Cannot undeploy override files: engine is not a git repository. Run "fireforge download" to initialise it.');
166
+ }
167
+ // Note: we deliberately do not re-filter `deletedFiles` through
168
+ // `isOverrideCopyCandidate(fileName, config.type)`. A file recorded in
169
+ // `previous` was already a valid copy candidate when it was deployed, and
170
+ // re-filtering would block cleanup if the override type later flipped
171
+ // from `full` to `css-only` — exactly the case we need cleanup for.
172
+ const restored = [];
173
+ const removed = [];
174
+ for (const fileName of deletedFiles) {
175
+ const enginePath = getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir);
176
+ const action = await restoreOverrideFileToBaseline(engineDir, enginePath, rollbackJournal);
177
+ const relPath = relative(engineDir, enginePath);
178
+ if (action === 'restored')
179
+ restored.push(relPath);
180
+ else if (action === 'removed')
181
+ removed.push(relPath);
182
+ }
183
+ return { restored, removed };
184
+ }
43
185
  /** Compares current component file checksums against the previously recorded state. */
44
186
  export async function hasComponentChanged(componentDir, previousChecksums) {
45
187
  const current = await computeComponentChecksums(componentDir);
@@ -55,10 +197,99 @@ export async function hasComponentChanged(componentDir, previousChecksums) {
55
197
  }
56
198
  return false;
57
199
  }
58
- async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries) {
200
+ function normalizeForChecksum(content) {
201
+ return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
202
+ }
203
+ /**
204
+ * Detects whether an override component's deployed files are missing from the
205
+ * engine or differ from the source. Used as a guard before skipping apply on a
206
+ * checksum match, so that reset/download/manual engine edits do not leave the
207
+ * caller with a stale "up to date" report.
208
+ *
209
+ * When `cachedEngineChecksums` is provided (populated on last successful apply),
210
+ * the function computes a SHA-256 hash of the engine file and compares it
211
+ * against the cached value. This avoids reading the full workspace source for
212
+ * the comparison when the engine hash still matches, which is the common case
213
+ * for projects with many components.
214
+ */
215
+ export async function hasOverrideEngineDrift(engineDir, componentDir, config, ftlDir, cachedEngineChecksums) {
216
+ const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
217
+ for (const entry of entries) {
218
+ if (!isRegularFile(entry))
219
+ continue;
220
+ if (!isOverrideCopyCandidate(entry.name, config.type))
221
+ continue;
222
+ const enginePath = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
223
+ if (!(await pathExists(enginePath))) {
224
+ return true;
225
+ }
226
+ const engineContent = normalizeForChecksum(await readText(enginePath));
227
+ // Fast path: compare engine content hash against cached value from last apply
228
+ if (cachedEngineChecksums) {
229
+ const engineHash = createHash('sha256').update(engineContent).digest('hex');
230
+ const cachedHash = cachedEngineChecksums[entry.name];
231
+ if (cachedHash && engineHash !== cachedHash) {
232
+ return true;
233
+ }
234
+ if (cachedHash)
235
+ continue; // Hash match — skip full source comparison
236
+ }
237
+ // Slow path: byte-compare engine content against workspace source
238
+ const srcContent = normalizeForChecksum(await readText(join(componentDir, entry.name)));
239
+ if (srcContent !== engineContent) {
240
+ return true;
241
+ }
242
+ }
243
+ return false;
244
+ }
245
+ /**
246
+ * Detects whether a custom component's deployed copies, jar.mn entries, or
247
+ * customElements.js registration are missing from the engine or out of sync.
248
+ * Delegates to `checkRegistrationConsistency` so the oracle stays aligned with
249
+ * the validate command.
250
+ */
251
+ export async function hasCustomEngineDrift(root, name, componentDir, config, ftlDir) {
252
+ const status = await checkRegistrationConsistency(root, name, config, ftlDir);
253
+ if (!status.targetExists || !status.filesInSync) {
254
+ return true;
255
+ }
256
+ if (status.missingTargetFiles.length > 0 || status.driftedFiles.length > 0) {
257
+ return true;
258
+ }
259
+ if (!config.register) {
260
+ return false;
261
+ }
262
+ // Registration drift: only check jar.mn entries for the file types that
263
+ // actually exist in source. jarMn{Mjs,Css} are substring checks, so a
264
+ // component with only .mjs (no .css) should not be flagged when jarMnCss
265
+ // is false — that is the expected post-apply state, not drift.
266
+ const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
267
+ let hasMjs = false;
268
+ let hasCss = false;
269
+ for (const entry of entries) {
270
+ if (!isRegularFile(entry))
271
+ continue;
272
+ if (entry.name.endsWith('.mjs'))
273
+ hasMjs = true;
274
+ else if (entry.name.endsWith('.css'))
275
+ hasCss = true;
276
+ }
277
+ if (!status.customElementsPresent || !status.customElementsCorrectBlock) {
278
+ return true;
279
+ }
280
+ if (hasMjs && !status.jarMnMjs) {
281
+ return true;
282
+ }
283
+ if (hasCss && !status.jarMnCss) {
284
+ return true;
285
+ }
286
+ return false;
287
+ }
288
+ async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
59
289
  const actions = [];
290
+ const stepErrors = [];
60
291
  for (const entry of entries) {
61
- if (!entry.isFile())
292
+ if (!isRegularFile(entry))
62
293
  continue;
63
294
  if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
64
295
  continue;
@@ -78,12 +309,22 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
78
309
  component: name,
79
310
  action: 'copy-ftl',
80
311
  source: ftlSrc,
81
- target: join(engineDir, FTL_DIR, ftlFile),
82
- description: `Copy ${ftlFile} to ${FTL_DIR}`,
312
+ target: join(engineDir, ftlDir, ftlFile),
313
+ description: `Copy ${ftlFile} to ${ftlDir}`,
83
314
  });
84
315
  }
85
316
  }
86
317
  if (config.register) {
318
+ try {
319
+ const modulePath = `chrome://global/content/elements/${name}.mjs`;
320
+ await validateCustomElementRegistration(engineDir, name, modulePath);
321
+ }
322
+ catch (error) {
323
+ stepErrors.push({
324
+ step: 'customElements.js registration',
325
+ error: toError(error).message,
326
+ });
327
+ }
87
328
  actions.push({
88
329
  component: name,
89
330
  action: 'register-ce',
@@ -91,27 +332,40 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
91
332
  });
92
333
  }
93
334
  const copiedFileNames = entries
94
- .filter((entry) => entry.isFile() && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
335
+ .filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
95
336
  .map((entry) => entry.name);
96
337
  if (copiedFileNames.length > 0) {
338
+ try {
339
+ await validateJarMnEntries(engineDir, name, copiedFileNames);
340
+ }
341
+ catch (error) {
342
+ stepErrors.push({
343
+ step: 'jar.mn registration',
344
+ error: toError(error).message,
345
+ });
346
+ }
97
347
  actions.push({
98
348
  component: name,
99
349
  action: 'register-jar',
100
350
  description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
101
351
  });
102
352
  }
103
- return actions;
353
+ return { actions, stepErrors };
104
354
  }
105
355
  /** Applies a custom component into the engine tree and captures registration step errors. */
106
- export async function applyCustomComponent(engineDir, name, componentDir, config, dryRun = false, rollbackJournal) {
356
+ export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal) {
107
357
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
108
358
  throw new FurnaceError(`Invalid component name "${name}": must match /^[a-z][a-z0-9-]*$/`);
109
359
  }
110
360
  const targetDir = join(engineDir, config.targetPath);
111
361
  const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
362
+ const customSymlinks = entries.filter((e) => typeof e.isSymbolicLink === 'function' && e.isSymbolicLink());
363
+ if (customSymlinks.length > 0) {
364
+ verbose(`Skipped ${customSymlinks.length} symlink(s) in "${name}": ${customSymlinks.map((e) => e.name).join(', ')}`);
365
+ }
112
366
  if (dryRun) {
113
- const actions = await buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries);
114
- return { affectedPaths: [], stepErrors: [], actions };
367
+ const { actions, stepErrors } = await buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir);
368
+ return { affectedPaths: [], stepErrors, actions };
115
369
  }
116
370
  if (rollbackJournal && !(await pathExists(targetDir))) {
117
371
  recordCreatedDir(rollbackJournal, targetDir);
@@ -120,25 +374,30 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
120
374
  const affectedPaths = [];
121
375
  const stepErrors = [];
122
376
  const copiedFileNames = [];
123
- for (const entry of entries) {
124
- if (!entry.isFile())
125
- continue;
126
- if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
127
- continue;
128
- const src = join(componentDir, entry.name);
377
+ // Collect copy candidates, then snapshot + copy in parallel. Snapshots
378
+ // must complete before any copy (they read the original content), but
379
+ // independent files can be processed concurrently.
380
+ const filesToCopy = entries.filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')));
381
+ // Snapshot phase (serial — journal is not concurrent-safe for the same path)
382
+ for (const entry of filesToCopy) {
129
383
  const dest = join(targetDir, entry.name);
130
384
  if (rollbackJournal) {
131
385
  await snapshotFile(rollbackJournal, dest);
132
386
  }
387
+ }
388
+ // Copy phase (parallel — independent file writes to different paths)
389
+ await Promise.all(filesToCopy.map(async (entry) => {
390
+ const src = join(componentDir, entry.name);
391
+ const dest = join(targetDir, entry.name);
133
392
  await copyFile(src, dest);
134
393
  affectedPaths.push(relative(engineDir, dest));
135
394
  copiedFileNames.push(entry.name);
136
- }
395
+ }));
137
396
  if (config.localized) {
138
397
  const ftlFile = `${name}.ftl`;
139
398
  const ftlSrc = join(componentDir, ftlFile);
140
399
  if (await pathExists(ftlSrc)) {
141
- const ftlDest = join(engineDir, FTL_DIR, ftlFile);
400
+ const ftlDest = join(engineDir, ftlDir, ftlFile);
142
401
  if (rollbackJournal) {
143
402
  await snapshotFile(rollbackJournal, ftlDest);
144
403
  }
@@ -180,21 +439,25 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
180
439
  return { affectedPaths, stepErrors };
181
440
  }
182
441
  /** Applies an override component by copying its matching files onto the engine tree. */
183
- export async function applyOverrideComponent(engineDir, name, componentDir, config, dryRun = false, rollbackJournal) {
442
+ export async function applyOverrideComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal) {
184
443
  const targetDir = join(engineDir, config.basePath);
185
444
  if (!(await pathExists(targetDir))) {
186
445
  throw new FurnaceError(`Override target path not found in engine: ${config.basePath}`, name);
187
446
  }
188
447
  const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
448
+ const overrideSymlinks = entries.filter((e) => typeof e.isSymbolicLink === 'function' && e.isSymbolicLink());
449
+ if (overrideSymlinks.length > 0) {
450
+ verbose(`Skipped ${overrideSymlinks.length} symlink(s) in "${name}": ${overrideSymlinks.map((e) => e.name).join(', ')}`);
451
+ }
189
452
  if (dryRun) {
190
453
  const actions = entries
191
- .filter((entry) => entry.isFile() && isOverrideCopyCandidate(entry.name, config.type))
454
+ .filter((entry) => isRegularFile(entry) && isOverrideCopyCandidate(entry.name, config.type))
192
455
  .map((entry) => ({
193
456
  component: name,
194
457
  action: 'copy',
195
458
  source: join(componentDir, entry.name),
196
- target: join(targetDir, entry.name),
197
- description: `Override ${entry.name} in ${config.basePath}`,
459
+ target: getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir),
460
+ description: `Override ${entry.name} in ${entry.name.endsWith('.ftl') ? ftlDir : config.basePath}`,
198
461
  }));
199
462
  if (actions.length === 0) {
200
463
  throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
@@ -202,43 +465,25 @@ export async function applyOverrideComponent(engineDir, name, componentDir, conf
202
465
  return { affectedPaths: [], actions };
203
466
  }
204
467
  const affectedPaths = [];
205
- for (const entry of entries) {
206
- if (!entry.isFile() || !isOverrideCopyCandidate(entry.name, config.type)) {
207
- continue;
208
- }
209
- const src = join(componentDir, entry.name);
210
- const dest = join(targetDir, entry.name);
468
+ const candidateEntries = entries.filter((entry) => isRegularFile(entry) && isOverrideCopyCandidate(entry.name, config.type));
469
+ // Snapshot phase (serial)
470
+ for (const entry of candidateEntries) {
471
+ const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
211
472
  if (rollbackJournal) {
212
473
  await snapshotFile(rollbackJournal, dest);
213
474
  }
475
+ }
476
+ // Copy phase (parallel)
477
+ await Promise.all(candidateEntries.map(async (entry) => {
478
+ const src = join(componentDir, entry.name);
479
+ const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
214
480
  await copyFile(src, dest);
215
481
  affectedPaths.push(relative(engineDir, dest));
216
- }
482
+ }));
217
483
  if (affectedPaths.length === 0) {
218
484
  throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
219
485
  }
220
486
  return { affectedPaths };
221
487
  }
222
- /** Extracts per-component checksums from the flattened state-file checksum map. */
223
- export function extractComponentChecksums(allChecksums, type, name) {
224
- if (!allChecksums)
225
- return {};
226
- const prefix = `${type}/${name}/`;
227
- const result = {};
228
- for (const [key, value] of Object.entries(allChecksums)) {
229
- if (key.startsWith(prefix)) {
230
- result[key.slice(prefix.length)] = value;
231
- }
232
- }
233
- return result;
234
- }
235
- /** Prefixes component checksums so they can be stored in the flattened state format. */
236
- export function prefixChecksums(checksums, type, name) {
237
- const prefix = `${type}/${name}/`;
238
- const result = {};
239
- for (const [key, value] of Object.entries(checksums)) {
240
- result[`${prefix}${key}`] = value;
241
- }
242
- return result;
243
- }
488
+ export { extractComponentChecksums, prefixChecksums } from './furnace-checksum-utils.js';
244
489
  //# sourceMappingURL=furnace-apply-helpers.js.map
@@ -0,0 +1,16 @@
1
+ import type { ApplyResult, DryRunAction } from '../types/furnace.js';
2
+ type ApplyResultWithActions = ApplyResult & {
3
+ actions?: DryRunAction[];
4
+ };
5
+ /**
6
+ * Prints a standard summary of an apply result for both normal and dry-run flows.
7
+ *
8
+ * Dry-run output lists the planned actions collected by the apply helpers.
9
+ * Normal output lists applied files, skipped components, and any step errors.
10
+ * Errors are always printed after the main body.
11
+ *
12
+ * @param result - Result returned by applyAllComponents
13
+ * @param isDryRun - Whether apply was invoked with dryRun=true
14
+ */
15
+ export declare function logApplyResult(result: ApplyResultWithActions, isDryRun: boolean): void;
16
+ export {};
@@ -0,0 +1,57 @@
1
+ import { error, info, success, warn } from '../utils/logger.js';
2
+ /**
3
+ * Prints a standard summary of an apply result for both normal and dry-run flows.
4
+ *
5
+ * Dry-run output lists the planned actions collected by the apply helpers.
6
+ * Normal output lists applied files, skipped components, and any step errors.
7
+ * Errors are always printed after the main body.
8
+ *
9
+ * @param result - Result returned by applyAllComponents
10
+ * @param isDryRun - Whether apply was invoked with dryRun=true
11
+ */
12
+ export function logApplyResult(result, isDryRun) {
13
+ if (isDryRun && result.actions && result.actions.length > 0) {
14
+ info('Planned actions:');
15
+ for (const action of result.actions) {
16
+ info(` [${action.action}] ${action.component}: ${action.description}`);
17
+ }
18
+ }
19
+ else if (isDryRun) {
20
+ info('No actions would be performed.');
21
+ }
22
+ else if (result.rolledBack) {
23
+ // When the rollback journal was restored, entries in `applied` reflect
24
+ // what was attempted but no longer exists in the engine. Print them as
25
+ // "attempted" rather than "success" to avoid misleading the operator.
26
+ if (result.applied.length > 0) {
27
+ warn('The following components were applied but have been rolled back due to errors:');
28
+ for (const applied of result.applied) {
29
+ warn(` ${applied.name} (${applied.type}) — rolled back`);
30
+ if (applied.stepErrors && applied.stepErrors.length > 0) {
31
+ for (const stepErr of applied.stepErrors) {
32
+ warn(` ${applied.name}: [${stepErr.step}] ${stepErr.error}`);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ else {
39
+ for (const applied of result.applied) {
40
+ success(`${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`);
41
+ }
42
+ for (const skipped of result.skipped) {
43
+ info(`${skipped.name} — ${skipped.reason}`);
44
+ }
45
+ for (const applied of result.applied) {
46
+ if (applied.stepErrors && applied.stepErrors.length > 0) {
47
+ for (const stepErr of applied.stepErrors) {
48
+ warn(`${applied.name}: [${stepErr.step}] ${stepErr.error}`);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ for (const err of result.errors) {
54
+ error(`${err.name} — ${err.error}`);
55
+ }
56
+ }
57
+ //# sourceMappingURL=furnace-apply-output.js.map
@@ -1,5 +1,7 @@
1
1
  import type { ApplyResult, DryRunAction } from '../types/furnace.js';
2
- export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, prefixChecksums, } from './furnace-apply-helpers.js';
2
+ import { type FurnaceOperationContext } from './furnace-operation.js';
3
+ import { type RollbackJournal } from './furnace-rollback.js';
4
+ export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, } from './furnace-apply-helpers.js';
3
5
  /**
4
6
  * Applies all override and custom components to the engine source tree.
5
7
  *
@@ -7,10 +9,26 @@ export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums
7
9
  * fails, FireForge restores only the engine files touched during this apply
8
10
  * attempt and leaves the state file unchanged.
9
11
  *
12
+ * When `options.persistState` is false, the furnace state file is left alone
13
+ * on success and the rollback journal is returned on the result so the caller
14
+ * can restore the engine later (used by `furnace preview` to stage workspace
15
+ * files for Storybook and then roll them back on teardown).
16
+ *
10
17
  * @param root - Root directory of the project
11
18
  * @param dryRun - If true, enumerate planned actions without writing
12
- * @returns Summary of applied, skipped, and errored components (with actions when dry-run)
19
+ * @param options - Optional behavior flags. `persistState` controls whether
20
+ * the furnace state file is updated on success (preview teardown sets this
21
+ * to false to keep ownership of the journal). `operationContext` is the
22
+ * lifecycle-wrapper hook used by `runFurnaceMutation` so a Ctrl+C mid-apply
23
+ * can find the in-flight rollback journal.
24
+ * @returns Summary of applied, skipped, and errored components (with actions
25
+ * when dry-run, and with rollbackJournal when persistState=false)
13
26
  */
14
- export declare function applyAllComponents(root: string, dryRun?: boolean): Promise<ApplyResult & {
27
+ export declare function applyAllComponents(root: string, dryRun?: boolean, options?: {
28
+ persistState?: boolean;
29
+ operationContext?: FurnaceOperationContext;
30
+ componentName?: string;
31
+ }): Promise<ApplyResult & {
15
32
  actions?: DryRunAction[];
33
+ rollbackJournal?: RollbackJournal;
16
34
  }>;