@hominis/fireforge 0.27.0 → 0.27.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 (71) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +6 -6
  3. package/dist/src/cli.js +5 -1
  4. package/dist/src/commands/build.js +61 -1
  5. package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
  6. package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
  7. package/dist/src/commands/doctor-working-tree.js +5 -1
  8. package/dist/src/commands/doctor.js +3 -0
  9. package/dist/src/commands/download.js +41 -42
  10. package/dist/src/commands/export-all.js +3 -2
  11. package/dist/src/commands/export-flow.d.ts +2 -0
  12. package/dist/src/commands/export-flow.js +2 -0
  13. package/dist/src/commands/export.js +5 -4
  14. package/dist/src/commands/import.js +2 -1
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/re-export.js +10 -8
  17. package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
  18. package/dist/src/commands/rebase/conflict-summary.js +38 -0
  19. package/dist/src/commands/rebase/continue.js +2 -0
  20. package/dist/src/commands/rebase/index.d.ts +2 -2
  21. package/dist/src/commands/rebase/index.js +9 -4
  22. package/dist/src/commands/rebase/patch-loop.js +29 -11
  23. package/dist/src/commands/rebase/summary.js +7 -2
  24. package/dist/src/commands/resolve.js +2 -1
  25. package/dist/src/commands/setup-support.js +6 -2
  26. package/dist/src/commands/setup.js +1 -0
  27. package/dist/src/commands/source.d.ts +9 -0
  28. package/dist/src/commands/source.js +92 -0
  29. package/dist/src/commands/status-output.d.ts +13 -0
  30. package/dist/src/commands/status-output.js +186 -0
  31. package/dist/src/commands/status.js +4 -247
  32. package/dist/src/commands/verify.js +32 -16
  33. package/dist/src/core/build-prepare.js +12 -4
  34. package/dist/src/core/config-validate.js +1 -1
  35. package/dist/src/core/firefox-cache.d.ts +1 -1
  36. package/dist/src/core/firefox-cache.js +10 -3
  37. package/dist/src/core/firefox-extract.d.ts +1 -1
  38. package/dist/src/core/firefox-extract.js +13 -1
  39. package/dist/src/core/firefox.d.ts +2 -1
  40. package/dist/src/core/firefox.js +3 -3
  41. package/dist/src/core/furnace-registration-validate.d.ts +7 -0
  42. package/dist/src/core/furnace-registration-validate.js +29 -12
  43. package/dist/src/core/furnace-validate-registration.js +5 -37
  44. package/dist/src/core/git.js +25 -5
  45. package/dist/src/core/ownership-table.d.ts +3 -1
  46. package/dist/src/core/ownership-table.js +31 -7
  47. package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
  48. package/dist/src/core/patch-artifact-normalize.js +13 -0
  49. package/dist/src/core/patch-export-update.js +2 -1
  50. package/dist/src/core/patch-export.d.ts +4 -0
  51. package/dist/src/core/patch-export.js +7 -2
  52. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  53. package/dist/src/core/patch-manifest-consistency.js +4 -2
  54. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  55. package/dist/src/core/patch-manifest-query.js +12 -4
  56. package/dist/src/core/patch-manifest-validate.js +22 -4
  57. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  58. package/dist/src/core/patch-source-metadata.js +17 -0
  59. package/dist/src/core/rebase-session.d.ts +8 -3
  60. package/dist/src/core/rebase-session.js +1 -1
  61. package/dist/src/core/status-classify.d.ts +4 -1
  62. package/dist/src/core/status-classify.js +4 -5
  63. package/dist/src/types/commands/index.d.ts +1 -1
  64. package/dist/src/types/commands/options.d.ts +16 -1
  65. package/dist/src/types/commands/patches.d.ts +9 -1
  66. package/dist/src/types/config.d.ts +1 -1
  67. package/dist/src/utils/elapsed.d.ts +4 -0
  68. package/dist/src/utils/elapsed.js +15 -0
  69. package/dist/src/utils/validation.d.ts +2 -2
  70. package/dist/src/utils/validation.js +5 -5
  71. package/package.json +1 -1
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { extractConflictingFiles } from '../../core/patch-parse.js';
3
+ function normalizeRejectFile(file) {
4
+ return file.replace(/\.rej$/, '');
5
+ }
6
+ function classifyConflict(files) {
7
+ if (files.some((file) => file.endsWith('toolkit/content/customElements.js'))) {
8
+ return 'registration context drift';
9
+ }
10
+ if (files.some((file) => file.endsWith('jar.mn') ||
11
+ file.endsWith('moz.build') ||
12
+ file.endsWith('browser.toml') ||
13
+ file.endsWith('browser/moz.configure'))) {
14
+ return 'manifest context drift';
15
+ }
16
+ return 'patch context drift';
17
+ }
18
+ /** Builds a concise operator-facing summary for a failed rebase patch. */
19
+ export function buildRebaseConflictSummary(args) {
20
+ const failedFiles = [
21
+ ...new Set([
22
+ ...extractConflictingFiles(args.error),
23
+ ...(args.rejectFiles ?? []).map(normalizeRejectFile),
24
+ ]),
25
+ ].sort();
26
+ return {
27
+ patchFilename: args.patchFilename,
28
+ failedFiles,
29
+ category: classifyConflict(failedFiles),
30
+ nextCommands: [
31
+ "find engine -name '*.rej'",
32
+ 'edit the affected engine/ files',
33
+ 'fireforge rebase --continue',
34
+ 'fireforge rebase --abort',
35
+ ],
36
+ };
37
+ }
38
+ //# sourceMappingURL=conflict-summary.js.map
@@ -73,6 +73,8 @@ export async function handleContinue(projectRoot, maxFuzz) {
73
73
  // v0.14.0 resolve.ts fix.
74
74
  await updatePatchAndMetadata(paths.patches, currentPatch.filename, diffContent, {
75
75
  sourceEsrVersion: session.toVersion,
76
+ sourceVersion: session.toVersion,
77
+ ...(session.toProduct !== undefined ? { sourceProduct: session.toProduct } : {}),
76
78
  });
77
79
  }
78
80
  finally {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `fireforge rebase` — semi-automated ESR version upgrade.
2
+ * `fireforge rebase` — semi-automated Firefox source version upgrade.
3
3
  *
4
4
  * Orchestrates the full patch-rebase workflow:
5
5
  * 1. Reset engine to baseline
@@ -13,7 +13,7 @@ import { Command } from 'commander';
13
13
  import type { CommandContext } from '../../types/cli.js';
14
14
  import type { RebaseOptions } from '../../types/commands/index.js';
15
15
  /**
16
- * Runs the rebase command to orchestrate an ESR version upgrade.
16
+ * Runs the rebase command to orchestrate a Firefox source version upgrade.
17
17
  * @param projectRoot - Root directory of the project
18
18
  * @param options - Rebase options
19
19
  */
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * `fireforge rebase` — semi-automated ESR version upgrade.
3
+ * `fireforge rebase` — semi-automated Firefox source version upgrade.
4
4
  *
5
5
  * Orchestrates the full patch-rebase workflow:
6
6
  * 1. Reset engine to baseline
@@ -15,6 +15,7 @@ import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.j
15
15
  import { getHead, isGitRepository, isMissingHeadError, resetChanges } from '../../core/git.js';
16
16
  import { discoverPatches } from '../../core/patch-files.js';
17
17
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
18
+ import { getPatchSourceProduct, getPatchSourceVersion } from '../../core/patch-source-metadata.js';
18
19
  import { hasActiveRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
19
20
  import { GeneralError } from '../../errors/base.js';
20
21
  import { RebaseSessionExistsError } from '../../errors/rebase.js';
@@ -65,9 +66,11 @@ async function handleFreshStart(projectRoot, options) {
65
66
  throw new GeneralError('No patches found in manifest. Nothing to rebase.');
66
67
  }
67
68
  // Determine the "from" version from the patches
68
- const patchVersions = new Set(manifest.patches.map((p) => p.sourceEsrVersion));
69
+ const patchVersions = new Set(manifest.patches.map((p) => getPatchSourceVersion(p)));
70
+ const patchProducts = new Set(manifest.patches.map((p) => getPatchSourceProduct(p)).filter(Boolean));
69
71
  const sortedVersions = [...patchVersions].sort();
70
72
  const fromVersion = sortedVersions[0] ?? currentVersion;
73
+ const fromProduct = [...patchProducts].sort()[0] ?? config.firefox.product;
71
74
  if (patchVersions.size === 1 && fromVersion === currentVersion) {
72
75
  info('All patches already match the current Firefox version. Nothing to rebase.');
73
76
  outro('Rebase not needed');
@@ -110,6 +113,8 @@ async function handleFreshStart(projectRoot, options) {
110
113
  const allPatches = await discoverPatches(paths.patches);
111
114
  const session = {
112
115
  startedAt: new Date().toISOString(),
116
+ fromProduct,
117
+ toProduct: config.firefox.product,
113
118
  fromVersion,
114
119
  toVersion: currentVersion,
115
120
  preRebaseCommit,
@@ -125,7 +130,7 @@ async function handleFreshStart(projectRoot, options) {
125
130
  }
126
131
  // ── Public API ──
127
132
  /**
128
- * Runs the rebase command to orchestrate an ESR version upgrade.
133
+ * Runs the rebase command to orchestrate a Firefox source version upgrade.
129
134
  * @param projectRoot - Root directory of the project
130
135
  * @param options - Rebase options
131
136
  */
@@ -142,7 +147,7 @@ export async function rebaseCommand(projectRoot, options = {}) {
142
147
  export function registerRebase(program, { getProjectRoot, withErrorHandling }) {
143
148
  program
144
149
  .command('rebase')
145
- .description('Semi-automated ESR version upgrade — apply patches with fuzz and re-export')
150
+ .description('Semi-automated Firefox source version upgrade — apply patches with fuzz and re-export')
146
151
  .option('--continue', 'Resume after manually resolving a failed patch')
147
152
  .option('--abort', 'Cancel the rebase and restore engine to pre-rebase state')
148
153
  .option('--dry-run', 'Show what would happen without modifying anything')
@@ -15,9 +15,11 @@ import { extractConflictingFiles } from '../../core/patch-parse.js';
15
15
  import { clearRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
16
16
  import { runInSignalCriticalSection } from '../../core/signal-critical.js';
17
17
  import { RebaseError } from '../../errors/rebase.js';
18
+ import { elapsedSince } from '../../utils/elapsed.js';
18
19
  import { toError } from '../../utils/errors.js';
19
20
  import { pathExists } from '../../utils/fs.js';
20
21
  import { error, info, outro, spinner, success, warn } from '../../utils/logger.js';
22
+ import { buildRebaseConflictSummary } from './conflict-summary.js';
21
23
  import { printSummary } from './summary.js';
22
24
  /**
23
25
  * Runs the patch application loop, re-exports applied patches, and stamps versions.
@@ -37,7 +39,7 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
37
39
  await saveRebaseSession(projectRoot, session);
38
40
  continue;
39
41
  }
40
- s.message(`Applying ${entry.filename}...`);
42
+ s.message(`Applying ${i + 1}/${session.patches.length}: ${entry.filename}...`);
41
43
  // Apply + session persist is wrapped in a signal-deferred critical
42
44
  // section so a SIGINT / SIGTERM between the filesystem mutation and
43
45
  // the session-file update is held until the bookkeeping write lands.
@@ -90,8 +92,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
90
92
  },
91
93
  }));
92
94
  s.error(`${entry.filename} failed to apply`);
95
+ const summary = buildRebaseConflictSummary({
96
+ patchFilename: entry.filename,
97
+ ...(result.error !== undefined ? { error: result.error } : {}),
98
+ ...(result.rejectFiles !== undefined ? { rejectFiles: result.rejectFiles } : {}),
99
+ });
100
+ warn(`Conflict summary for ${summary.patchFilename}: ${summary.category}`);
101
+ if (summary.failedFiles.length > 0) {
102
+ warn(` Failed files: ${summary.failedFiles.join(', ')}`);
103
+ }
104
+ else {
105
+ warn(' Failed files: not detected from git output');
106
+ }
107
+ info(' Suggested next commands:');
108
+ for (const command of summary.nextCommands) {
109
+ info(` ${command}`);
110
+ }
93
111
  if (result.error) {
94
- error(` Error: ${result.error}`);
112
+ error(` Raw apply detail: ${result.error}`);
95
113
  }
96
114
  if (result.rejectFiles && result.rejectFiles.length > 0) {
97
115
  info(` .rej files created for manual resolution`);
@@ -125,16 +143,16 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
125
143
  .filter((p) => p.status === 'applied-clean' || p.status === 'applied-fuzz' || p.status === 'resolved')
126
144
  .map((p) => p.filename);
127
145
  if (appliedFilenames.length > 0) {
128
- await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion);
146
+ await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion, session.toProduct);
129
147
  }
130
148
  // Stamp every Furnace override's `baseVersion` to match the rebased
131
- // Firefox version. Before this stamp, a successful ESR bump left
149
+ // Firefox source version. Before this stamp, a successful source bump left
132
150
  // overrides in a doctor-failing drift state (each override still
133
- // claimed the pre-rebase ESR as its baseline) and every subsequent
151
+ // claimed the pre-rebase source as its baseline) and every subsequent
134
152
  // `fireforge doctor` failed `Furnace component validation`. The
135
153
  // stamp is unconditional per the helper's contract: rebase already
136
154
  // succeeded on the patch side, so the operator is committing to the
137
- // new ESR baseline; per-component health checking stays with
155
+ // new source baseline; per-component health checking stays with
138
156
  // `fireforge furnace validate` / `doctor --repair-furnace`.
139
157
  try {
140
158
  const overridesStamped = await stampFurnaceOverrideBaseVersions(projectRoot, session.toVersion);
@@ -158,7 +176,7 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
158
176
  return next;
159
177
  });
160
178
  info('');
161
- success(`All patches re-exported with sourceEsrVersion=${session.toVersion}`);
179
+ success(`All patches re-exported with sourceVersion=${session.toVersion}`);
162
180
  outro('Rebase complete!');
163
181
  }
164
182
  async function reExportAppliedPatches(session, paths) {
@@ -167,19 +185,19 @@ async function reExportAppliedPatches(session, paths) {
167
185
  if (!manifest)
168
186
  return failures;
169
187
  const s = spinner('Re-exporting patches...');
170
- for (const entry of session.patches) {
171
- if (entry.status !== 'applied-clean' && entry.status !== 'applied-fuzz')
172
- continue;
188
+ const reExportable = session.patches.filter((entry) => entry.status === 'applied-clean' || entry.status === 'applied-fuzz');
189
+ const startedAt = Date.now();
190
+ for (const [index, entry] of reExportable.entries()) {
173
191
  const meta = manifest.patches.find((p) => p.filename === entry.filename);
174
192
  if (!meta)
175
193
  continue;
176
- s.message(`Re-exporting ${entry.filename}...`);
177
194
  const existingFiles = [];
178
195
  for (const f of meta.filesAffected) {
179
196
  if (await pathExists(join(paths.engine, f))) {
180
197
  existingFiles.push(f);
181
198
  }
182
199
  }
200
+ s.message(`Re-exporting ${index + 1}/${reExportable.length}: ${entry.filename} (${existingFiles.length}/${meta.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
183
201
  try {
184
202
  const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
185
203
  if (diffContent.trim()) {
@@ -27,7 +27,11 @@ export function statusLabel(status, fuzzFactor) {
27
27
  */
28
28
  export function printSummary(session) {
29
29
  info('');
30
- info(`ESR Rebase Summary: ${session.fromVersion} → ${session.toVersion}`);
30
+ const from = session.fromProduct
31
+ ? `${session.fromProduct} ${session.fromVersion}`
32
+ : session.fromVersion;
33
+ const to = session.toProduct ? `${session.toProduct} ${session.toVersion}` : session.toVersion;
34
+ info(`Source Rebase Summary: ${from} → ${to}`);
31
35
  info('='.repeat(55));
32
36
  for (const patch of session.patches) {
33
37
  const label = statusLabel(patch.status, patch.fuzzFactor);
@@ -37,7 +41,8 @@ export function printSummary(session) {
37
41
  const fuzz = session.patches.filter((p) => p.status === 'applied-fuzz').length;
38
42
  const resolved = session.patches.filter((p) => p.status === 'resolved').length;
39
43
  const failed = session.patches.filter((p) => p.status === 'failed').length;
44
+ const total = session.patches.length;
40
45
  info('');
41
- info(`Results: ${clean} clean, ${fuzz} fuzz-applied, ${resolved} manually resolved, ${failed} failed`);
46
+ info(`Results: ${total} total: ${clean} clean, ${fuzz} fuzz-applied, ${resolved} manually resolved, ${failed} failed`);
42
47
  }
43
48
  //# sourceMappingURL=summary.js.map
@@ -8,6 +8,7 @@ import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
10
  import { loadPatchesManifest } from '../core/patch-manifest.js';
11
+ import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
11
12
  import { GeneralError, ResolutionError } from '../errors/base.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists } from '../utils/fs.js';
@@ -132,7 +133,7 @@ export async function resolveCommand(projectRoot, options = {}) {
132
133
  const config = await loadConfig(projectRoot);
133
134
  await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
134
135
  filesAffected: diffFilesAffected,
135
- sourceEsrVersion: config.firefox.version,
136
+ ...buildPatchSourceMetadata(config.firefox),
136
137
  });
137
138
  // Cleanup: Clear pendingResolution from state.json transactionally so
138
139
  // we don't clobber concurrent updates to unrelated keys (e.g. another
@@ -19,10 +19,13 @@ function renderLicenseTemplate(license, template, vendor, now = new Date()) {
19
19
  return template.replace(/\[year\]/g, String(now.getFullYear())).replace(/\[fullname\]/g, vendor);
20
20
  }
21
21
  function resolveFirefoxProduct(value, field) {
22
- if (value === 'firefox' || value === 'firefox-esr' || value === 'firefox-beta') {
22
+ if (value === 'firefox' ||
23
+ value === 'firefox-esr' ||
24
+ value === 'firefox-beta' ||
25
+ value === 'firefox-devedition') {
23
26
  return value;
24
27
  }
25
- throw new InvalidArgumentError('Invalid product (use: firefox, firefox-esr, firefox-beta)', field);
28
+ throw new InvalidArgumentError('Invalid product (use: firefox, firefox-esr, firefox-beta, firefox-devedition)', field);
26
29
  }
27
30
  function resolveProjectLicense(value, field) {
28
31
  if (typeof value === 'string' && isValidProjectLicense(value)) {
@@ -153,6 +156,7 @@ async function promptSetupInputs(options) {
153
156
  { value: 'firefox', label: 'Firefox (stable releases)' },
154
157
  { value: 'firefox-esr', label: 'Firefox ESR (extended support)' },
155
158
  { value: 'firefox-beta', label: 'Firefox Beta (pre-release)' },
159
+ { value: 'firefox-devedition', label: 'Firefox Developer Edition' },
156
160
  ],
157
161
  });
158
162
  },
@@ -71,6 +71,7 @@ export function registerSetup(program, { withErrorHandling }) {
71
71
  'firefox',
72
72
  'firefox-esr',
73
73
  'firefox-beta',
74
+ 'firefox-devedition',
74
75
  ]))
75
76
  .addOption(new Option('--license <license>', 'Project license').choices([...PROJECT_LICENSES]))
76
77
  .option('-f, --force', 'Overwrite existing configuration without prompting')
@@ -0,0 +1,9 @@
1
+ import { Command } from 'commander';
2
+ import type { CommandContext } from '../types/cli.js';
3
+ import type { SourceSetOptions } from '../types/commands/index.js';
4
+ /**
5
+ * Atomically updates the Firefox source tuple in fireforge.json.
6
+ */
7
+ export declare function sourceSetCommand(projectRoot: string, options: SourceSetOptions): Promise<void>;
8
+ /** Registers the source command on the CLI program. */
9
+ export declare function registerSource(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { Option } from 'commander';
3
+ import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock, writeConfigDocument, } from '../core/config.js';
4
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
5
+ import { info, intro, outro, success } from '../utils/logger.js';
6
+ import { isValidFirefoxProduct } from '../utils/validation.js';
7
+ const SOURCE_PRODUCTS = [
8
+ 'firefox',
9
+ 'firefox-esr',
10
+ 'firefox-beta',
11
+ 'firefox-devedition',
12
+ ];
13
+ function isRecord(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ function cloneRawConfig(raw) {
17
+ const cloned = structuredClone(raw);
18
+ if (!isRecord(cloned)) {
19
+ throw new GeneralError('Cannot update fireforge.json: config clone was not an object.');
20
+ }
21
+ return cloned;
22
+ }
23
+ function parseSourceProduct(product) {
24
+ if (isValidFirefoxProduct(product)) {
25
+ return product;
26
+ }
27
+ throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
28
+ }
29
+ /**
30
+ * Atomically updates the Firefox source tuple in fireforge.json.
31
+ */
32
+ export async function sourceSetCommand(projectRoot, options) {
33
+ intro('FireForge Source');
34
+ if (!(await configExists(projectRoot))) {
35
+ throw new GeneralError('No fireforge.json found. Run "fireforge setup" to create a project.');
36
+ }
37
+ if (options.sha256 !== undefined && options.clearSha256 === true) {
38
+ throw new InvalidArgumentError('--sha256 cannot be combined with --clear-sha256', '--sha256');
39
+ }
40
+ const written = await withConfigFileLock(projectRoot, async () => {
41
+ const raw = await loadRawConfigDocument(projectRoot);
42
+ const updated = cloneRawConfig(raw);
43
+ const firefox = isRecord(updated['firefox']) ? { ...updated['firefox'] } : {};
44
+ firefox['version'] = options.version;
45
+ firefox['product'] = options.product;
46
+ if (options.clearSha256 === true) {
47
+ delete firefox['sha256'];
48
+ }
49
+ else if (options.sha256 !== undefined) {
50
+ firefox['sha256'] = options.sha256;
51
+ }
52
+ updated['firefox'] = firefox;
53
+ const validated = validateConfig(updated);
54
+ if (validated.firefox.sha256 !== undefined) {
55
+ firefox['sha256'] = validated.firefox.sha256;
56
+ }
57
+ await writeConfigDocument(projectRoot, updated);
58
+ return validated.firefox;
59
+ });
60
+ success(`Set firefox.version = ${written.version}`);
61
+ success(`Set firefox.product = ${written.product}`);
62
+ if (written.sha256 !== undefined) {
63
+ success(`Set firefox.sha256 = ${written.sha256}`);
64
+ }
65
+ else if (options.clearSha256 === true) {
66
+ info('Cleared firefox.sha256');
67
+ }
68
+ outro('');
69
+ }
70
+ /** Registers the source command on the CLI program. */
71
+ export function registerSource(program, { getProjectRoot, withErrorHandling }) {
72
+ const source = program.command('source').description('Manage Firefox source configuration');
73
+ source
74
+ .command('set')
75
+ .description('Atomically set Firefox source version, product, and optional checksum')
76
+ .requiredOption('--version <version>', 'Firefox version to base on')
77
+ .addOption(new Option('--product <product>', 'Firefox product')
78
+ .choices([...SOURCE_PRODUCTS])
79
+ .makeOptionMandatory())
80
+ .option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
81
+ .option('--clear-sha256', 'Clear any existing pinned SHA-256')
82
+ .action(withErrorHandling(async (options) => {
83
+ const { product, version, sha256, clearSha256 } = options;
84
+ await sourceSetCommand(getProjectRoot(), {
85
+ version,
86
+ product: parseSourceProduct(product),
87
+ ...(sha256 !== undefined ? { sha256 } : {}),
88
+ ...(clearSha256 !== undefined ? { clearSha256 } : {}),
89
+ });
90
+ }));
91
+ }
92
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1,13 @@
1
+ import type { ClassifiedFile } from '../core/status-classify.js';
2
+ export interface ClassifiedBuckets {
3
+ conflict: ClassifiedFile[];
4
+ unmanaged: ClassifiedFile[];
5
+ patchBacked: ClassifiedFile[];
6
+ patchOwnedDrift: ClassifiedFile[];
7
+ branding: ClassifiedFile[];
8
+ furnace: ClassifiedFile[];
9
+ }
10
+ /** Renders the unmanaged-only status view and registration hints. */
11
+ export declare function renderUnmanagedOnly(unmanagedFiles: ClassifiedFile[], totalModified: number, projectRoot: string, binaryName: string): Promise<void>;
12
+ /** Renders the default classified status buckets. */
13
+ export declare function renderDefaultStatus(totalModified: number, buckets: ClassifiedBuckets, projectRoot: string, binaryName: string): Promise<void>;
@@ -0,0 +1,186 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
3
+ import { GeneralError } from '../errors/base.js';
4
+ import { info, outro, warn } from '../utils/logger.js';
5
+ const STATUS_DESCRIPTIONS = {
6
+ M: 'modified',
7
+ A: 'added',
8
+ D: 'deleted',
9
+ R: 'renamed',
10
+ C: 'copied',
11
+ U: 'unmerged',
12
+ '?': 'untracked',
13
+ '!': 'ignored',
14
+ };
15
+ function getStatusDescription(code) {
16
+ return STATUS_DESCRIPTIONS[code] ?? 'changed';
17
+ }
18
+ function getPrimaryStatusCode(status) {
19
+ if (status.includes('?'))
20
+ return '?';
21
+ if (status.includes('!'))
22
+ return '!';
23
+ for (const code of status) {
24
+ if (code !== ' ')
25
+ return code;
26
+ }
27
+ return status;
28
+ }
29
+ function isNewFileStatus(status) {
30
+ const code = getPrimaryStatusCode(status);
31
+ return code === '?' || code === 'A';
32
+ }
33
+ function groupFilesByStatus(files) {
34
+ const grouped = new Map();
35
+ for (const { status, file } of files) {
36
+ const code = getPrimaryStatusCode(status);
37
+ const existing = grouped.get(code) ?? [];
38
+ existing.push(file);
39
+ grouped.set(code, existing);
40
+ }
41
+ return grouped;
42
+ }
43
+ function printStatusGroups(files) {
44
+ const grouped = groupFilesByStatus(files);
45
+ for (const [status, fileList] of grouped) {
46
+ warn(`${getStatusDescription(status)}:`);
47
+ for (const file of fileList)
48
+ info(` ${file}`);
49
+ }
50
+ }
51
+ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
52
+ const newFiles = files.filter((f) => isNewFileStatus(f.status));
53
+ if (newFiles.length === 0)
54
+ return;
55
+ const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
56
+ const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
57
+ try {
58
+ return {
59
+ file: f.file,
60
+ registered: await isFileRegistered(projectRoot, f.file),
61
+ manifestMissing: false,
62
+ manifestMissingMessage: undefined,
63
+ };
64
+ }
65
+ catch (err) {
66
+ if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
67
+ return {
68
+ file: f.file,
69
+ registered: false,
70
+ manifestMissing: true,
71
+ manifestMissingMessage: err.message,
72
+ };
73
+ }
74
+ throw err;
75
+ }
76
+ }));
77
+ const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
78
+ const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
79
+ if (unregistered.length > 0) {
80
+ info('');
81
+ warn('Potentially unregistered files:');
82
+ for (const f of unregistered)
83
+ info(` ${f.file} — run 'fireforge register ${f.file}'`);
84
+ }
85
+ if (manifestMissing.length > 0) {
86
+ info('');
87
+ warn('Files whose registration manifest does not exist yet:');
88
+ for (const f of manifestMissing) {
89
+ info(` ${f.file} — ${f.manifestMissingMessage}`);
90
+ info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
91
+ }
92
+ }
93
+ }
94
+ /** Renders the unmanaged-only status view and registration hints. */
95
+ export async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
96
+ info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
97
+ if (unmanagedFiles.length > 0) {
98
+ printStatusGroups(unmanagedFiles);
99
+ await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
100
+ }
101
+ else {
102
+ info('No unmanaged changes');
103
+ }
104
+ outro(unmanagedFiles.length === 0
105
+ ? 'No unmanaged changes'
106
+ : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
107
+ }
108
+ /** Renders the default classified status buckets. */
109
+ export async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
110
+ const { conflict, unmanaged, patchBacked, patchOwnedDrift, branding, furnace } = buckets;
111
+ info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
112
+ if (conflict.length > 0) {
113
+ warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
114
+ printStatusGroups(conflict);
115
+ for (const entry of conflict) {
116
+ if (entry.claimedBy && entry.claimedBy.length > 0) {
117
+ info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
118
+ }
119
+ }
120
+ info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
121
+ }
122
+ if (unmanaged.length > 0) {
123
+ if (conflict.length > 0)
124
+ info('');
125
+ warn('Unmanaged changes:');
126
+ printStatusGroups(unmanaged);
127
+ await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
128
+ }
129
+ if (patchBacked.length > 0) {
130
+ if (conflict.length > 0 || unmanaged.length > 0)
131
+ info('');
132
+ warn('Patch-backed materialized changes:');
133
+ printStatusGroups(patchBacked);
134
+ }
135
+ if (patchOwnedDrift.length > 0) {
136
+ if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0)
137
+ info('');
138
+ warn('Patch-owned drift:');
139
+ printStatusGroups(patchOwnedDrift);
140
+ info('These files are claimed by exactly one patch, but engine/ no longer matches that patch output. Re-export the owning patch after reviewing the manual resolution.');
141
+ }
142
+ if (branding.length > 0) {
143
+ if (conflict.length > 0 ||
144
+ unmanaged.length > 0 ||
145
+ patchBacked.length > 0 ||
146
+ patchOwnedDrift.length > 0) {
147
+ info('');
148
+ }
149
+ warn('Tool-managed branding changes:');
150
+ printStatusGroups(branding);
151
+ }
152
+ if (furnace.length > 0) {
153
+ if (conflict.length > 0 ||
154
+ unmanaged.length > 0 ||
155
+ patchBacked.length > 0 ||
156
+ patchOwnedDrift.length > 0 ||
157
+ branding.length > 0) {
158
+ info('');
159
+ }
160
+ warn('Furnace-managed component changes:');
161
+ printStatusGroups(furnace);
162
+ }
163
+ if (conflict.length === 0 &&
164
+ unmanaged.length === 0 &&
165
+ patchBacked.length === 0 &&
166
+ patchOwnedDrift.length === 0 &&
167
+ branding.length === 0 &&
168
+ furnace.length === 0) {
169
+ info('No changes');
170
+ }
171
+ const parts = [];
172
+ if (conflict.length > 0)
173
+ parts.push(`${conflict.length} conflict`);
174
+ if (unmanaged.length > 0)
175
+ parts.push(`${unmanaged.length} unmanaged`);
176
+ if (patchBacked.length > 0)
177
+ parts.push(`${patchBacked.length} patch-backed`);
178
+ if (patchOwnedDrift.length > 0)
179
+ parts.push(`${patchOwnedDrift.length} patch-owned drift`);
180
+ if (branding.length > 0)
181
+ parts.push(`${branding.length} branding`);
182
+ if (furnace.length > 0)
183
+ parts.push(`${furnace.length} furnace`);
184
+ outro(parts.join(', '));
185
+ }
186
+ //# sourceMappingURL=status-output.js.map