@hominis/fireforge 0.15.9 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +6 -2
  3. package/dist/src/cli.d.ts +4 -1
  4. package/dist/src/cli.js +6 -3
  5. package/dist/src/commands/config.js +16 -5
  6. package/dist/src/commands/download.js +31 -4
  7. package/dist/src/commands/export-all.js +96 -9
  8. package/dist/src/commands/export.js +10 -1
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
  11. package/dist/src/commands/furnace/create.js +21 -3
  12. package/dist/src/commands/furnace/diff.js +22 -2
  13. package/dist/src/commands/furnace/index.js +1 -0
  14. package/dist/src/commands/furnace/init.js +76 -2
  15. package/dist/src/commands/furnace/override.js +35 -12
  16. package/dist/src/commands/furnace/preview.js +46 -1
  17. package/dist/src/commands/furnace/rename.js +14 -3
  18. package/dist/src/commands/lint.js +26 -2
  19. package/dist/src/commands/package.js +16 -5
  20. package/dist/src/commands/re-export.js +25 -0
  21. package/dist/src/commands/rebase/patch-loop.js +19 -0
  22. package/dist/src/commands/register.js +2 -18
  23. package/dist/src/commands/run.js +23 -2
  24. package/dist/src/commands/status.js +42 -8
  25. package/dist/src/commands/test.js +6 -24
  26. package/dist/src/commands/token.js +14 -1
  27. package/dist/src/commands/watch.js +14 -2
  28. package/dist/src/commands/wire.js +35 -9
  29. package/dist/src/core/branding.d.ts +23 -0
  30. package/dist/src/core/branding.js +39 -0
  31. package/dist/src/core/browser-wire.js +68 -23
  32. package/dist/src/core/build-baseline.d.ts +14 -0
  33. package/dist/src/core/build-baseline.js +61 -1
  34. package/dist/src/core/config-mutate.d.ts +1 -1
  35. package/dist/src/core/config.d.ts +17 -0
  36. package/dist/src/core/config.js +35 -0
  37. package/dist/src/core/firefox.d.ts +16 -2
  38. package/dist/src/core/firefox.js +7 -2
  39. package/dist/src/core/furnace-config.d.ts +23 -0
  40. package/dist/src/core/furnace-config.js +38 -0
  41. package/dist/src/core/mach-build-artifacts.d.ts +41 -0
  42. package/dist/src/core/mach-build-artifacts.js +70 -0
  43. package/dist/src/core/mach-error-hints.js +38 -0
  44. package/dist/src/core/mach-mozconfig.d.ts +25 -0
  45. package/dist/src/core/mach-mozconfig.js +66 -0
  46. package/dist/src/core/mach.d.ts +12 -1
  47. package/dist/src/core/mach.js +14 -1
  48. package/dist/src/core/manifest-rules.js +22 -1
  49. package/dist/src/core/patch-lint.js +43 -20
  50. package/dist/src/core/test-stale-check.js +46 -1
  51. package/dist/src/core/token-manager.js +57 -4
  52. package/dist/src/core/token-scaffold.d.ts +36 -0
  53. package/dist/src/core/token-scaffold.js +74 -0
  54. package/dist/src/types/commands/options.d.ts +10 -0
  55. package/dist/src/utils/fs.d.ts +12 -0
  56. package/dist/src/utils/fs.js +12 -0
  57. package/dist/src/utils/paths.d.ts +19 -0
  58. package/dist/src/utils/paths.js +33 -0
  59. package/package.json +1 -1
@@ -23,6 +23,9 @@
23
23
  * plugin, etc.) can legitimately have a fresh `dist/` with no
24
24
  * FireForge-recorded baseline update.
25
25
  */
26
+ import { createHash } from 'node:crypto';
27
+ import { readFile } from 'node:fs/promises';
28
+ import { join } from 'node:path';
26
29
  import { toError } from '../utils/errors.js';
27
30
  import { verbose } from '../utils/logger.js';
28
31
  import { isPackageablePath } from './build-audit.js';
@@ -90,7 +93,32 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
90
93
  return { stale: false, changedPaths: [], truncated: 0, baseline: undefined };
91
94
  }
92
95
  const changed = await collectChangedEnginePaths(engineDir, baseline);
93
- const packageable = changed.filter((path) => isPackageablePath(path)).sort();
96
+ let packageable = changed.filter((path) => isPackageablePath(path)).sort();
97
+ // Content-hash comparison: when the baseline carries a fingerprint set,
98
+ // fold each candidate path through a live re-hash and drop paths whose
99
+ // current content matches the baseline. Pre-0.16.0 baselines have no
100
+ // `packageableFingerprints` field; those fall through and the
101
+ // path-only comparison behaves as before (every workdir-dirty
102
+ // packageable path is reported as stale). The concrete motivating
103
+ // case: a project with imported patches + Furnace-applied components
104
+ // always has a persistent workdir diff against HEAD. Before the
105
+ // fingerprint layer, `git diff --name-only HEAD` returned that diff
106
+ // on every build, so the stale check fired immediately after a
107
+ // successful build even though nothing had actually changed. The
108
+ // fingerprints capture "these files had this content when the build
109
+ // ran"; a path stays stale only when its live hash diverges.
110
+ const fingerprints = baseline.packageableFingerprints;
111
+ if (fingerprints) {
112
+ const staleAfterHashCheck = [];
113
+ for (const path of packageable) {
114
+ const recorded = fingerprints[path];
115
+ const live = await hashEngineFile(engineDir, path);
116
+ if (recorded === undefined || live === undefined || recorded !== live) {
117
+ staleAfterHashCheck.push(path);
118
+ }
119
+ }
120
+ packageable = staleAfterHashCheck;
121
+ }
94
122
  if (packageable.length === 0) {
95
123
  return { stale: false, changedPaths: [], truncated: 0, baseline };
96
124
  }
@@ -98,6 +126,23 @@ export async function checkStaleBuildForTest(projectRoot, engineDir) {
98
126
  const truncated = Math.max(0, packageable.length - head.length);
99
127
  return { stale: true, changedPaths: head, truncated, baseline };
100
128
  }
129
+ /**
130
+ * Reads a file under the engine directory and returns a hex-encoded
131
+ * SHA-256 of its contents, matching the hash the baseline writer
132
+ * produces. Returns `undefined` on any IO error (missing file,
133
+ * permission denied, etc.) so the caller can treat the path as still
134
+ * stale rather than crashing the preflight.
135
+ */
136
+ async function hashEngineFile(engineDir, relPath) {
137
+ try {
138
+ const buffer = await readFile(join(engineDir, relPath));
139
+ return createHash('sha256').update(buffer).digest('hex');
140
+ }
141
+ catch (error) {
142
+ verbose(`Stale-build preflight: could not hash ${relPath} for baseline comparison — ${toError(error).message}`);
143
+ return undefined;
144
+ }
145
+ }
101
146
  /**
102
147
  * Formats a human-readable warning body from a {@link StaleBuildResult}.
103
148
  * Kept separate from the probe so test code can assert on the structured
@@ -93,8 +93,56 @@ async function assertTokenCategoryExists(engineDir, tokensCssPath, category) {
93
93
  }
94
94
  }
95
95
  }
96
- throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}. ` +
97
- 'Available categories are defined by /* =... category ...= */ comment headers.');
96
+ const discoveredCategories = discoverCategoryHeaders(lines);
97
+ const available = discoveredCategories.length > 0
98
+ ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
99
+ : 'The file currently has no category headers. Add one by hand near the top of the :root { … } block — the format is "/* = My Category = */" — or run "fireforge furnace init --force" to re-scaffold the default seed set.';
100
+ throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
101
+ `${available}\n\n` +
102
+ 'Categories are declared by comment headers. Single-line shape: /* = My Category = */. ' +
103
+ 'Multi-line shape: /* =============\\n * My Category\\n * ============= */.');
104
+ }
105
+ /**
106
+ * Scans a tokens CSS file for category header comments and returns the
107
+ * category names in document order. Used to enrich the "category not
108
+ * found" error body with concrete alternatives the operator can copy.
109
+ *
110
+ * Mirrors the shapes `findCategorySection` already recognises:
111
+ * - Single-line: `/* = Foo = *\/`
112
+ * - Multi-line: `/* =====` opening, `Foo` on any of the next ~5 lines,
113
+ * closing `*\/`.
114
+ *
115
+ * This helper exists as a pure inspector; it never throws on malformed
116
+ * headers and silently skips shapes it cannot parse.
117
+ */
118
+ function discoverCategoryHeaders(lines) {
119
+ const categories = new Set();
120
+ const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i] ?? '';
123
+ const singleMatch = singleLinePattern.exec(line);
124
+ if (singleMatch?.[1]) {
125
+ const extracted = singleMatch[1].trim();
126
+ if (extracted.length > 0)
127
+ categories.add(extracted);
128
+ continue;
129
+ }
130
+ if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
131
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
132
+ const blockLine = lines[j] ?? '';
133
+ if (/\*\//.test(blockLine))
134
+ break;
135
+ const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
136
+ if (trimmed.length === 0)
137
+ continue;
138
+ if (/^=+$/.test(trimmed))
139
+ continue;
140
+ categories.add(trimmed);
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ return [...categories];
98
146
  }
99
147
  /**
100
148
  * Validates token-add inputs without mutating files.
@@ -187,8 +235,13 @@ function findCategorySection(lines, category, tokensCssPath) {
187
235
  }
188
236
  }
189
237
  if (categoryLine === -1) {
190
- throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}. ` +
191
- 'Available categories are defined by /* =... category ...= */ comment headers.');
238
+ const discoveredCategories = discoverCategoryHeaders(lines);
239
+ const available = discoveredCategories.length > 0
240
+ ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
241
+ : 'The file currently has no category headers.';
242
+ throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
243
+ `${available}\n\n` +
244
+ 'Add a header by hand inside the :root block (format: "/* = My Category = */") or re-run "fireforge furnace init --force" to re-seed the default categories.');
192
245
  }
193
246
  // Find the end of this category section (next section header or closing })
194
247
  // Handles both single-line (/* =...= */) and multi-line (/* ===...) section delimiters
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Scaffolds the default tokens CSS file consumed by `fireforge token add`.
3
+ *
4
+ * Before 0.16.0 `fireforge furnace init` wrote `furnace.json` but not the
5
+ * tokens CSS — every project's first `fireforge token add` hit
6
+ * `Token CSS file not found: browser/themes/shared/<binaryName>-tokens.css`.
7
+ * The 0.16.0 init now calls into this module to write a canonical
8
+ * `:root { … }` shell with a seed set of category headers that
9
+ * `assertTokenCategoryExists` recognises, and registers the tokens CSS
10
+ * path in `patchLint.rawColorAllowlist` so the first token that's an
11
+ * actual color value does not instantly fail `fireforge lint`.
12
+ */
13
+ import type { ProjectLicense } from '../types/config.js';
14
+ /**
15
+ * The set of categories seeded by the default scaffold. `token add
16
+ * --category <name>` accepts any of these without further setup; an
17
+ * operator who needs another category only has to add a matching
18
+ * `/* = My Category = *\/` header inside the `:root` block by hand.
19
+ *
20
+ * The names intentionally mirror the vocabulary used in Firefox's own
21
+ * token files (Colors — Canvas, Spacing, …) so operators coming from
22
+ * upstream don't have to relearn a fork-specific taxonomy.
23
+ */
24
+ export declare const DEFAULT_TOKEN_CATEGORIES: readonly string[];
25
+ /**
26
+ * Generates the default tokens CSS body. Extracted from the init
27
+ * command so tests can assert on the generated shape without running
28
+ * the interactive scaffold flow.
29
+ *
30
+ * @param binaryName - `fireforge.json` `binaryName` used in the
31
+ * rendered file banner so operators can identify the fork on-sight.
32
+ * @param license - Project license; piped through `getLicenseHeader`
33
+ * so the scaffold is SPDX-marked and survives `fireforge lint`'s
34
+ * license-header checks without operator intervention.
35
+ */
36
+ export declare function generateDefaultTokensCss(binaryName: string, license: ProjectLicense): string;
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Scaffolds the default tokens CSS file consumed by `fireforge token add`.
4
+ *
5
+ * Before 0.16.0 `fireforge furnace init` wrote `furnace.json` but not the
6
+ * tokens CSS — every project's first `fireforge token add` hit
7
+ * `Token CSS file not found: browser/themes/shared/<binaryName>-tokens.css`.
8
+ * The 0.16.0 init now calls into this module to write a canonical
9
+ * `:root { … }` shell with a seed set of category headers that
10
+ * `assertTokenCategoryExists` recognises, and registers the tokens CSS
11
+ * path in `patchLint.rawColorAllowlist` so the first token that's an
12
+ * actual color value does not instantly fail `fireforge lint`.
13
+ */
14
+ import { getLicenseHeader } from './license-headers.js';
15
+ /**
16
+ * The set of categories seeded by the default scaffold. `token add
17
+ * --category <name>` accepts any of these without further setup; an
18
+ * operator who needs another category only has to add a matching
19
+ * `/* = My Category = *\/` header inside the `:root` block by hand.
20
+ *
21
+ * The names intentionally mirror the vocabulary used in Firefox's own
22
+ * token files (Colors — Canvas, Spacing, …) so operators coming from
23
+ * upstream don't have to relearn a fork-specific taxonomy.
24
+ */
25
+ export const DEFAULT_TOKEN_CATEGORIES = [
26
+ 'Colors — General',
27
+ 'Colors — Canvas',
28
+ 'Colors — Experiment',
29
+ 'Spacing',
30
+ ];
31
+ /**
32
+ * Generates the default tokens CSS body. Extracted from the init
33
+ * command so tests can assert on the generated shape without running
34
+ * the interactive scaffold flow.
35
+ *
36
+ * @param binaryName - `fireforge.json` `binaryName` used in the
37
+ * rendered file banner so operators can identify the fork on-sight.
38
+ * @param license - Project license; piped through `getLicenseHeader`
39
+ * so the scaffold is SPDX-marked and survives `fireforge lint`'s
40
+ * license-header checks without operator intervention.
41
+ */
42
+ export function generateDefaultTokensCss(binaryName, license) {
43
+ const header = getLicenseHeader(license, 'css');
44
+ const categoryBlocks = DEFAULT_TOKEN_CATEGORIES.map((category) => ` /* = ${category} = */\n /* (add design tokens for "${category}" here; use \`fireforge token add --category "${category}" …\`) */`).join('\n\n');
45
+ return `${header}
46
+
47
+ /*
48
+ * Design tokens for ${binaryName}.
49
+ *
50
+ * Scaffolded by \`fireforge furnace init\`. Add new tokens with
51
+ * \`fireforge token add --category '<name>' -- <token-name> <value>\`
52
+ * — the command appends into the matching \`/* = <name> = *\\/\` block
53
+ * below and keeps the per-category ordering stable.
54
+ *
55
+ * Raw color literals inside this file are expected — the whole point
56
+ * of the tokens layer is that every other CSS file consumes \`var(…)\`
57
+ * instead of literal colors. \`fireforge furnace init\` adds this
58
+ * file's path to \`patchLint.rawColorAllowlist\` in fireforge.json so
59
+ * \`fireforge lint\` stops flagging the literals here.
60
+ */
61
+
62
+ :root {
63
+ ${categoryBlocks}
64
+ }
65
+
66
+ @media (prefers-color-scheme: dark) {
67
+ :root {
68
+ /* Dark-mode overrides land here. Use \`fireforge token add --mode override --dark-value <v>\`
69
+ to have a token's dark value placed inside this block. */
70
+ }
71
+ }
72
+ `;
73
+ }
74
+ //# sourceMappingURL=token-scaffold.js.map
@@ -372,6 +372,16 @@ export interface FurnaceCreateOptions {
372
372
  * dry-run faithfully previews the real command's outcome.
373
373
  */
374
374
  dryRun?: boolean;
375
+ /**
376
+ * Bypass the configured `componentPrefix` check for the supplied name.
377
+ * Without this flag, a name that does not start with the prefix is
378
+ * rejected before any files are written, so a prefix-mismatched
379
+ * component cannot leave behind a half-scaffolded state. Pass this
380
+ * flag only when you know the prefix mismatch is intentional — e.g.
381
+ * creating an experimental component whose name intentionally breaks
382
+ * the fork's convention.
383
+ */
384
+ allowPrefixMismatch?: boolean;
375
385
  }
376
386
  /**
377
387
  * Options for the wire command.
@@ -78,6 +78,18 @@ export declare function writeFileAtomic(path: string, content: string | Buffer):
78
78
  * @param dest - Destination directory path
79
79
  */
80
80
  export declare function copyDir(src: string, dest: string): Promise<void>;
81
+ /**
82
+ * Matches the atomic-temp-file shape emitted by `createAtomicTempPath`
83
+ * anywhere in a normalised (forward-slash) path. The `.fireforge-tmp-`
84
+ * marker plus a PID/UUID tail is unique to our own rename-based atomic
85
+ * writes, so callers (notably `status`) can filter these mid-flight
86
+ * artefacts out of their listings without racing the rename.
87
+ *
88
+ * Intentionally anchored so a legitimately-named backup file like
89
+ * `.notes.fireforge-tmp-backup` (no PID+UUID continuation) is NOT treated
90
+ * as one of our temps. The full shape is `.<filename>.fireforge-tmp-<pid>-<uuid>`.
91
+ */
92
+ export declare const FIREFORGE_TMP_PATH_PATTERN: RegExp;
81
93
  /**
82
94
  * Checks available disk space at a path and warns via the provided
83
95
  * callback when it falls below `minBytes`.
@@ -180,6 +180,18 @@ export async function copyDir(src, dest) {
180
180
  }
181
181
  }
182
182
  }
183
+ /**
184
+ * Matches the atomic-temp-file shape emitted by `createAtomicTempPath`
185
+ * anywhere in a normalised (forward-slash) path. The `.fireforge-tmp-`
186
+ * marker plus a PID/UUID tail is unique to our own rename-based atomic
187
+ * writes, so callers (notably `status`) can filter these mid-flight
188
+ * artefacts out of their listings without racing the rename.
189
+ *
190
+ * Intentionally anchored so a legitimately-named backup file like
191
+ * `.notes.fireforge-tmp-backup` (no PID+UUID continuation) is NOT treated
192
+ * as one of our temps. The full shape is `.<filename>.fireforge-tmp-<pid>-<uuid>`.
193
+ */
194
+ export const FIREFORGE_TMP_PATH_PATTERN = /(^|\/)\.[^/]+\.fireforge-tmp-\d+-[0-9a-f-]{36}$/i;
183
195
  /**
184
196
  * Generates a unique temp file path for atomic writes.
185
197
  *
@@ -1,5 +1,24 @@
1
1
  /** Converts Windows path separators to forward slashes for stable comparisons. */
2
2
  export declare function normalizePathSlashes(path: string): string;
3
+ /**
4
+ * Strips a leading `engine/` (or `engine\\`) segment from a user-supplied
5
+ * path so the same command invocation accepts both repo-root-relative paths
6
+ * (`engine/browser/base/content/foo.js`) and engine-relative paths
7
+ * (`browser/base/content/foo.js`). The match is case-insensitive because
8
+ * default macOS and Windows filesystems treat `Engine/` and `engine/` as
9
+ * the same directory; a literal lowercase-only check previously left `mach`
10
+ * / the manifest writers resolving against a wrongly-cased prefix. Leading
11
+ * whitespace is ignored so tab-completed inputs don't slip past the strip.
12
+ *
13
+ * The return value is trimmed of the same leading whitespace when the
14
+ * prefix matched, and otherwise passed through verbatim — callers that
15
+ * care about internal whitespace can trim on their side.
16
+ *
17
+ * @param filePath Path as provided by the user
18
+ * @returns Path relative to the engine directory (or the original when the
19
+ * prefix was absent)
20
+ */
21
+ export declare function stripEnginePrefix(filePath: string): string;
3
22
  /** Checks whether a path is explicitly absolute on either POSIX or Windows. */
4
23
  export declare function isExplicitAbsolutePath(path: string): boolean;
5
24
  /** Resolves a candidate path and returns whether it stays within the given root. */
@@ -2,10 +2,43 @@
2
2
  import { isAbsolute, relative, resolve } from 'node:path';
3
3
  const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/;
4
4
  const RELATIVE_PATH_ROOT = resolve('/__fireforge_path_root__');
5
+ /**
6
+ * Matches a leading `engine/` or `engine\\` segment (case-insensitive,
7
+ * tolerates leading whitespace). Shared between `register`, `test`, `lint`,
8
+ * and `export` so every command that takes an engine-relative path accepts
9
+ * both the repo-root form (`engine/browser/...`) and the engine-relative
10
+ * form (`browser/...`) without diverging.
11
+ */
12
+ const ENGINE_PREFIX_PATTERN = /^\s*engine[/\\]/i;
5
13
  /** Converts Windows path separators to forward slashes for stable comparisons. */
6
14
  export function normalizePathSlashes(path) {
7
15
  return path.replace(/\\/g, '/');
8
16
  }
17
+ /**
18
+ * Strips a leading `engine/` (or `engine\\`) segment from a user-supplied
19
+ * path so the same command invocation accepts both repo-root-relative paths
20
+ * (`engine/browser/base/content/foo.js`) and engine-relative paths
21
+ * (`browser/base/content/foo.js`). The match is case-insensitive because
22
+ * default macOS and Windows filesystems treat `Engine/` and `engine/` as
23
+ * the same directory; a literal lowercase-only check previously left `mach`
24
+ * / the manifest writers resolving against a wrongly-cased prefix. Leading
25
+ * whitespace is ignored so tab-completed inputs don't slip past the strip.
26
+ *
27
+ * The return value is trimmed of the same leading whitespace when the
28
+ * prefix matched, and otherwise passed through verbatim — callers that
29
+ * care about internal whitespace can trim on their side.
30
+ *
31
+ * @param filePath Path as provided by the user
32
+ * @returns Path relative to the engine directory (or the original when the
33
+ * prefix was absent)
34
+ */
35
+ export function stripEnginePrefix(filePath) {
36
+ const match = ENGINE_PREFIX_PATTERN.exec(filePath);
37
+ if (match) {
38
+ return filePath.slice(match[0].length);
39
+ }
40
+ return filePath;
41
+ }
9
42
  /** Checks whether a path is explicitly absolute on either POSIX or Windows. */
10
43
  export function isExplicitAbsolutePath(path) {
11
44
  return isAbsolute(path) || WINDOWS_ABSOLUTE_PATH.test(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.15.9",
3
+ "version": "0.16.1",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",