@hominis/fireforge 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +6 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- package/package.json +1 -1
|
@@ -3,18 +3,21 @@ import { stat } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { Option } from 'commander';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
|
+
import { appendHistory, confirmDestructive } from '../core/destructive.js';
|
|
7
|
+
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
8
|
import { getStatusWithCodes, isGitRepository } from '../core/git.js';
|
|
7
9
|
import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff.js';
|
|
8
10
|
import { isBinaryFile } from '../core/git-file-ops.js';
|
|
9
11
|
import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
|
|
10
12
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
11
13
|
import { commitExportedPatch } from '../core/patch-export.js';
|
|
12
|
-
import { GeneralError } from '../errors/base.js';
|
|
14
|
+
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
13
15
|
import { toError } from '../utils/errors.js';
|
|
14
16
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
15
17
|
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
16
18
|
import { pickDefined } from '../utils/options.js';
|
|
17
|
-
import { PATCH_CATEGORIES } from '../utils/validation.js';
|
|
19
|
+
import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
|
|
20
|
+
import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
|
|
18
21
|
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
19
22
|
async function collectExportFiles(paths, files) {
|
|
20
23
|
const collectedFiles = new Set();
|
|
@@ -90,8 +93,23 @@ async function generatePatchDiff(engineDir, allFiles) {
|
|
|
90
93
|
* @param files - File or directory paths to export (relative to engine/)
|
|
91
94
|
* @param options - Export options
|
|
92
95
|
*/
|
|
96
|
+
// The command body is intentionally linear: validation → diff → placement
|
|
97
|
+
// gate → dry-run/placement/default write. Splitting it further would
|
|
98
|
+
// spread the error-handling (spinner.error, try/catch) across multiple
|
|
99
|
+
// helpers and hurt readability more than it would help.
|
|
100
|
+
// eslint-disable-next-line max-lines-per-function
|
|
93
101
|
export async function exportCommand(projectRoot, files, options) {
|
|
94
|
-
|
|
102
|
+
const isDryRun = options.dryRun === true;
|
|
103
|
+
intro(isDryRun ? 'FireForge Export (dry run)' : 'FireForge Export');
|
|
104
|
+
// Placement flags are mutually exclusive with each other.
|
|
105
|
+
const placementFlagCount = [
|
|
106
|
+
options.order !== undefined,
|
|
107
|
+
options.before !== undefined,
|
|
108
|
+
options.after !== undefined,
|
|
109
|
+
].filter(Boolean).length;
|
|
110
|
+
if (placementFlagCount > 1) {
|
|
111
|
+
throw new InvalidArgumentError('--order, --before, and --after are mutually exclusive.', 'export placement');
|
|
112
|
+
}
|
|
95
113
|
const paths = getProjectPaths(projectRoot);
|
|
96
114
|
// Check if engine exists
|
|
97
115
|
if (!(await pathExists(paths.engine))) {
|
|
@@ -101,7 +119,19 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
101
119
|
if (!(await isGitRepository(paths.engine))) {
|
|
102
120
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
103
121
|
}
|
|
104
|
-
|
|
122
|
+
let allFiles = await collectExportFiles(paths, files);
|
|
123
|
+
// Filter out furnace-managed files when --exclude-furnace is set
|
|
124
|
+
if (options.excludeFurnace) {
|
|
125
|
+
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
126
|
+
if (furnacePrefixes.size > 0) {
|
|
127
|
+
const before = allFiles.length;
|
|
128
|
+
allFiles = allFiles.filter((file) => ![...furnacePrefixes].some((prefix) => file.startsWith(prefix)));
|
|
129
|
+
const excluded = before - allFiles.length;
|
|
130
|
+
if (excluded > 0) {
|
|
131
|
+
info(`Excluded ${excluded} furnace-managed file${excluded === 1 ? '' : 's'} from export`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
105
135
|
if (allFiles.length === 0) {
|
|
106
136
|
const pathList = files.join(', ');
|
|
107
137
|
throw new GeneralError(`Paths "${pathList}" have no changes to export.\n\n` +
|
|
@@ -124,11 +154,120 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
124
154
|
if (!metadata)
|
|
125
155
|
return;
|
|
126
156
|
const { patchName, selectedCategory, description } = metadata;
|
|
127
|
-
const s = spinner('Exporting patch...');
|
|
157
|
+
const s = spinner(isDryRun ? 'Planning export...' : 'Exporting patch...');
|
|
128
158
|
try {
|
|
129
159
|
// Extract affected files from diff
|
|
130
160
|
const filesAffected = extractAffectedFiles(diff);
|
|
131
161
|
await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
|
|
162
|
+
// Resolve placement (if any flag was given). Placement is mutually
|
|
163
|
+
// exclusive with supersede — the semantics overlap confusingly.
|
|
164
|
+
let placementPlan = null;
|
|
165
|
+
if (placementFlagCount > 0) {
|
|
166
|
+
if (options.supersede) {
|
|
167
|
+
throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
|
|
168
|
+
}
|
|
169
|
+
placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
|
|
170
|
+
const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
|
|
171
|
+
const summary = placementSummary(placementPlan);
|
|
172
|
+
const renameCount = placementPlan.renameMap.size;
|
|
173
|
+
// Route through confirmDestructive when the operation is destructive
|
|
174
|
+
// enough to warrant a prompt (more than one rename) OR when the user
|
|
175
|
+
// asked for a dry-run. The dry-run branch must always print the
|
|
176
|
+
// placement summary — previously, single-rename/no-rename dry-runs
|
|
177
|
+
// exited silently with no filename or projected layout.
|
|
178
|
+
if (renameCount > 1 || isDryRun) {
|
|
179
|
+
s.stop();
|
|
180
|
+
const decision = await confirmDestructive({
|
|
181
|
+
operation: 'export-order',
|
|
182
|
+
title: `Export with placement at order ${placementPlan.insertionOrder}`,
|
|
183
|
+
summary,
|
|
184
|
+
yes: options.yes === true,
|
|
185
|
+
dryRun: isDryRun,
|
|
186
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
187
|
+
conflicts,
|
|
188
|
+
});
|
|
189
|
+
if (decision === 'dry-run') {
|
|
190
|
+
outro('Dry run complete — no changes made');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (decision === 'cancelled') {
|
|
194
|
+
outro('Export cancelled');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (conflicts && options.forceUnsafe !== true) {
|
|
199
|
+
s.stop();
|
|
200
|
+
throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Dry-run path: compute the plan and print it, never write.
|
|
204
|
+
if (isDryRun && !placementPlan) {
|
|
205
|
+
s.stop('Plan ready');
|
|
206
|
+
await renderDryRunPreview({
|
|
207
|
+
patchesDir: paths.patches,
|
|
208
|
+
category: selectedCategory,
|
|
209
|
+
name: patchName,
|
|
210
|
+
description,
|
|
211
|
+
filesAffected,
|
|
212
|
+
sourceEsrVersion: config.firefox.version,
|
|
213
|
+
explicitSupersede: options.supersede === true,
|
|
214
|
+
});
|
|
215
|
+
outro('Dry run complete — no changes made');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Placement path (non-dry-run): run the renumber + write + manifest
|
|
219
|
+
// update under a single patch directory lock so concurrent exports
|
|
220
|
+
// cannot race into the renumber gap. Dry-runs with a placement plan
|
|
221
|
+
// are fully handled in the placement gate above and never reach here.
|
|
222
|
+
if (placementPlan) {
|
|
223
|
+
const placementMetadata = {
|
|
224
|
+
filename: placementPlan.newFilename,
|
|
225
|
+
order: placementPlan.insertionOrder,
|
|
226
|
+
category: selectedCategory,
|
|
227
|
+
name: patchName,
|
|
228
|
+
description,
|
|
229
|
+
createdAt: new Date().toISOString(),
|
|
230
|
+
sourceEsrVersion: config.firefox.version,
|
|
231
|
+
filesAffected,
|
|
232
|
+
};
|
|
233
|
+
const committedPlan = await commitPlacementExport({
|
|
234
|
+
patchesDir: paths.patches,
|
|
235
|
+
options,
|
|
236
|
+
category: selectedCategory,
|
|
237
|
+
name: patchName,
|
|
238
|
+
diff,
|
|
239
|
+
metadata: placementMetadata,
|
|
240
|
+
expectedPlan: placementPlan,
|
|
241
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
242
|
+
// History append runs inside the same lock as the mutation so
|
|
243
|
+
// concurrent placement exports cannot interleave their records
|
|
244
|
+
// and a crash between mutation and record cannot orphan the
|
|
245
|
+
// audit entry.
|
|
246
|
+
onCommitted: async (finalPlan) => {
|
|
247
|
+
await appendHistory(paths.patches, {
|
|
248
|
+
operation: 'export-order',
|
|
249
|
+
args: {
|
|
250
|
+
filename: finalPlan.newFilename,
|
|
251
|
+
order: finalPlan.insertionOrder,
|
|
252
|
+
renames: Array.from(finalPlan.renameMap.entries()).map(([from, entry]) => ({
|
|
253
|
+
from,
|
|
254
|
+
to: entry.newFilename,
|
|
255
|
+
order: entry.newOrder,
|
|
256
|
+
})),
|
|
257
|
+
},
|
|
258
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
259
|
+
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
260
|
+
result: 'ok',
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
s.stop(`Exported to ${committedPlan.newFilename}`);
|
|
265
|
+
info(`\nPatch saved to: patches/${committedPlan.newFilename}`);
|
|
266
|
+
info(`Files affected: ${filesAffected.join(', ')}`);
|
|
267
|
+
outro('Export complete');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Default (no dry-run, no placement) path: the pre-existing behavior.
|
|
132
271
|
// Check how many existing patches would be superseded
|
|
133
272
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
134
273
|
if (!shouldProceed)
|
|
@@ -167,6 +306,13 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
167
306
|
.option('-d, --description <desc>', 'Description of the patch')
|
|
168
307
|
.option('--supersede', 'Allow superseding multiple existing patches')
|
|
169
308
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
309
|
+
.option('--dry-run', 'Print the export plan (including supersede preview) without writing')
|
|
310
|
+
.addOption(new Option('--order <N>', 'Place the new patch at this ordinal, shifting subsequent patches up').argParser((v) => parsePositiveIntegerFlag('--order', v)))
|
|
311
|
+
.option('--before <anchor>', 'Place the new patch immediately before <anchor>')
|
|
312
|
+
.option('--after <anchor>', 'Place the new patch immediately after <anchor>')
|
|
313
|
+
.option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
|
|
314
|
+
.option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
|
|
315
|
+
.option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
|
|
170
316
|
.action(withErrorHandling(async (paths, options) => {
|
|
171
317
|
const { category, ...rest } = options;
|
|
172
318
|
await exportCommand(getProjectRoot(), paths, {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { FurnaceApplyOptions } from '../../types/commands/index.js';
|
|
2
2
|
/**
|
|
3
|
-
* Runs the furnace apply command to apply
|
|
3
|
+
* Runs the furnace apply command to apply components to the engine.
|
|
4
4
|
* @param projectRoot - Root directory of the project
|
|
5
|
+
* @param name - Optional component name to apply a single component
|
|
5
6
|
* @param options - Apply options
|
|
6
7
|
*/
|
|
7
|
-
export declare function furnaceApplyCommand(projectRoot: string, options?: FurnaceApplyOptions): Promise<void>;
|
|
8
|
+
export declare function furnaceApplyCommand(projectRoot: string, name?: string, options?: FurnaceApplyOptions): Promise<void>;
|
|
@@ -1,17 +1,155 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { watch as fsWatch } from 'node:fs';
|
|
3
|
+
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
4
|
+
import { applyAllComponents, computeComponentChecksums } from '../../core/furnace-apply.js';
|
|
5
|
+
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
6
|
+
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, } from '../../core/furnace-config.js';
|
|
7
|
+
import { isComponentSourceFile } from '../../core/furnace-constants.js';
|
|
8
|
+
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
9
|
+
import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
|
|
5
10
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
6
11
|
import { pathExists } from '../../utils/fs.js';
|
|
7
|
-
import {
|
|
12
|
+
import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
|
|
13
|
+
/** Interval (ms) for the periodic checksum poll that catches events missed by fs.watch. */
|
|
14
|
+
const WATCH_POLL_INTERVAL_MS = 30_000;
|
|
8
15
|
/**
|
|
9
|
-
*
|
|
16
|
+
* Collects a combined checksum snapshot across all watched directories.
|
|
17
|
+
*/
|
|
18
|
+
async function snapshotWatchedChecksums(watchDirs) {
|
|
19
|
+
const combined = new Map();
|
|
20
|
+
for (const dir of watchDirs) {
|
|
21
|
+
try {
|
|
22
|
+
const checksums = await computeComponentChecksums(dir);
|
|
23
|
+
for (const [file, hash] of Object.entries(checksums)) {
|
|
24
|
+
combined.set(`${dir}/${file}`, hash);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Directory may have been removed between iterations — ignore.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return combined;
|
|
32
|
+
}
|
|
33
|
+
function checksumMapsEqual(a, b) {
|
|
34
|
+
if (a.size !== b.size)
|
|
35
|
+
return false;
|
|
36
|
+
for (const [key, value] of a) {
|
|
37
|
+
if (b.get(key) !== value)
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
async function runWatchLoop(projectRoot) {
|
|
43
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
44
|
+
const watchDirs = [];
|
|
45
|
+
if (await pathExists(furnacePaths.overridesDir))
|
|
46
|
+
watchDirs.push(furnacePaths.overridesDir);
|
|
47
|
+
if (await pathExists(furnacePaths.customDir))
|
|
48
|
+
watchDirs.push(furnacePaths.customDir);
|
|
49
|
+
if (watchDirs.length === 0) {
|
|
50
|
+
info('No component directories to watch.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === 'linux') {
|
|
54
|
+
warn('Watch mode uses fs.watch with recursive: true, which may miss changes ' +
|
|
55
|
+
'in deeply nested directories on Linux. A periodic poll runs every 30s as a fallback.');
|
|
56
|
+
}
|
|
57
|
+
info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
|
|
58
|
+
let debounceTimer = null;
|
|
59
|
+
let applyInFlight = false;
|
|
60
|
+
let lastChecksums = await snapshotWatchedChecksums(watchDirs);
|
|
61
|
+
const triggerApply = () => {
|
|
62
|
+
if (debounceTimer)
|
|
63
|
+
clearTimeout(debounceTimer);
|
|
64
|
+
debounceTimer = setTimeout(() => {
|
|
65
|
+
if (applyInFlight)
|
|
66
|
+
return;
|
|
67
|
+
applyInFlight = true;
|
|
68
|
+
void (async () => {
|
|
69
|
+
try {
|
|
70
|
+
info('\nChange detected — re-applying...');
|
|
71
|
+
const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
|
|
72
|
+
logApplyResult(result, false);
|
|
73
|
+
const applied = result.applied.length;
|
|
74
|
+
const skipped = result.skipped.length;
|
|
75
|
+
info(`Re-applied: ${applied} applied, ${skipped} skipped`);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
warn(`Apply failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
applyInFlight = false;
|
|
82
|
+
// Update checksums after apply so the next poll does not re-trigger.
|
|
83
|
+
lastChecksums = await snapshotWatchedChecksums(watchDirs);
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
}, 300);
|
|
87
|
+
};
|
|
88
|
+
// Register signal-driven cleanup BEFORE creating watchers so there is no
|
|
89
|
+
// race window where a SIGINT could arrive after watchers exist but before
|
|
90
|
+
// cleanup handlers are registered.
|
|
91
|
+
const watchers = [];
|
|
92
|
+
let pollTimer = null;
|
|
93
|
+
const cleanup = () => {
|
|
94
|
+
for (const w of watchers)
|
|
95
|
+
w.close();
|
|
96
|
+
if (debounceTimer)
|
|
97
|
+
clearTimeout(debounceTimer);
|
|
98
|
+
if (pollTimer)
|
|
99
|
+
clearInterval(pollTimer);
|
|
100
|
+
};
|
|
101
|
+
process.once('SIGINT', cleanup);
|
|
102
|
+
process.once('SIGTERM', cleanup);
|
|
103
|
+
for (const dir of watchDirs) {
|
|
104
|
+
const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
|
|
105
|
+
if (!filename)
|
|
106
|
+
return;
|
|
107
|
+
if (isComponentSourceFile(filename)) {
|
|
108
|
+
triggerApply();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
watcher.on('error', (err) => {
|
|
112
|
+
warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
|
|
113
|
+
});
|
|
114
|
+
watchers.push(watcher);
|
|
115
|
+
}
|
|
116
|
+
// Periodic checksum-based poll to catch events missed by fs.watch (known
|
|
117
|
+
// issue on Linux with recursive: true and certain filesystems).
|
|
118
|
+
pollTimer = setInterval(() => {
|
|
119
|
+
if (applyInFlight)
|
|
120
|
+
return;
|
|
121
|
+
void (async () => {
|
|
122
|
+
try {
|
|
123
|
+
const current = await snapshotWatchedChecksums(watchDirs);
|
|
124
|
+
if (!checksumMapsEqual(current, lastChecksums)) {
|
|
125
|
+
triggerApply();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Best effort — errors here are transient filesystem issues.
|
|
130
|
+
}
|
|
131
|
+
})();
|
|
132
|
+
}, WATCH_POLL_INTERVAL_MS);
|
|
133
|
+
// Block until signal. The cleanup function registered above closes all
|
|
134
|
+
// watchers when SIGINT/SIGTERM arrives. The finally block is a safety net
|
|
135
|
+
// in case the Promise settles through some other path.
|
|
136
|
+
try {
|
|
137
|
+
await new Promise(() => { });
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
cleanup();
|
|
141
|
+
process.removeListener('SIGINT', cleanup);
|
|
142
|
+
process.removeListener('SIGTERM', cleanup);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Runs the furnace apply command to apply components to the engine.
|
|
10
147
|
* @param projectRoot - Root directory of the project
|
|
148
|
+
* @param name - Optional component name to apply a single component
|
|
11
149
|
* @param options - Apply options
|
|
12
150
|
*/
|
|
13
|
-
export async function furnaceApplyCommand(projectRoot, options = {}) {
|
|
14
|
-
intro('Furnace Apply');
|
|
151
|
+
export async function furnaceApplyCommand(projectRoot, name, options = {}) {
|
|
152
|
+
intro(name ? `Furnace Apply (${name})` : 'Furnace Apply');
|
|
15
153
|
// Verify engine exists
|
|
16
154
|
const paths = getProjectPaths(projectRoot);
|
|
17
155
|
if (!(await pathExists(paths.engine))) {
|
|
@@ -29,42 +167,33 @@ export async function furnaceApplyCommand(projectRoot, options = {}) {
|
|
|
29
167
|
outro('Done');
|
|
30
168
|
return;
|
|
31
169
|
}
|
|
170
|
+
// Warn on baseVersion drift before mutating the engine. This is advisory
|
|
171
|
+
// only: apply continues, because a silent divergence is worse than a
|
|
172
|
+
// noisy one and the operator is the only authority on whether the
|
|
173
|
+
// upstream component has actually changed shape.
|
|
174
|
+
const forgeConfig = await loadConfig(projectRoot);
|
|
175
|
+
const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
|
|
176
|
+
for (const entry of driftEntries) {
|
|
177
|
+
warn(formatOverrideBaseVersionDriftWarning(entry));
|
|
178
|
+
}
|
|
32
179
|
const dryRun = options.dryRun ?? false;
|
|
180
|
+
const force = options.force ?? false;
|
|
181
|
+
if (!force && driftEntries.length > 0) {
|
|
182
|
+
throw new FurnaceError(formatOverrideBaseVersionDriftError(driftEntries));
|
|
183
|
+
}
|
|
33
184
|
const applySpinner = dryRun ? undefined : spinner('Applying components to engine...');
|
|
34
|
-
const result = await applyAllComponents(projectRoot, dryRun
|
|
185
|
+
const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, dryRun, {
|
|
186
|
+
operationContext: ctx,
|
|
187
|
+
...(name !== undefined ? { componentName: name } : {}),
|
|
188
|
+
}), { dryRun });
|
|
35
189
|
if (applySpinner) {
|
|
36
190
|
applySpinner.stop('Components applied');
|
|
37
191
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const prefix = dryRun ? '[dry-run] Would apply' : '';
|
|
41
|
-
const label = dryRun
|
|
42
|
-
? `${prefix} ${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`
|
|
43
|
-
: `${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`;
|
|
44
|
-
if (dryRun) {
|
|
45
|
-
info(label);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
success(label);
|
|
49
|
-
}
|
|
50
|
-
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
51
|
-
for (const stepErr of applied.stepErrors) {
|
|
52
|
-
warn(`${applied.name}: [${stepErr.step}] ${stepErr.error}`);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
// Report skipped
|
|
57
|
-
for (const skipped of result.skipped) {
|
|
58
|
-
info(`${skipped.name} — ${skipped.reason}`);
|
|
59
|
-
}
|
|
60
|
-
// Report errors
|
|
61
|
-
for (const err of result.errors) {
|
|
62
|
-
error(`${err.name} — ${err.error}`);
|
|
63
|
-
}
|
|
64
|
-
const stepFailureCount = dryRun
|
|
192
|
+
logApplyResult(result, dryRun);
|
|
193
|
+
const appliedWithStepErrorsCount = dryRun
|
|
65
194
|
? 0
|
|
66
195
|
: result.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
|
|
67
|
-
const totalApplyFailures = result.errors.length +
|
|
196
|
+
const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
|
|
68
197
|
if (totalApplyFailures > 0) {
|
|
69
198
|
throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
|
|
70
199
|
}
|
|
@@ -76,5 +205,9 @@ export async function furnaceApplyCommand(projectRoot, options = {}) {
|
|
|
76
205
|
else {
|
|
77
206
|
outro(`Applied ${appliedCount}, skipped ${skippedCount}`);
|
|
78
207
|
}
|
|
208
|
+
// Watch mode: re-apply on file changes in component directories
|
|
209
|
+
if (options.watch && !dryRun) {
|
|
210
|
+
await runWatchLoop(projectRoot);
|
|
211
|
+
}
|
|
79
212
|
}
|
|
80
213
|
//# sourceMappingURL=apply.js.map
|