@hominis/fireforge 0.9.0 → 0.10.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 CHANGED
@@ -1,7 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Patch workflow validation
6
+
7
+ - Re-export now runs the same patch lint gate as export and export-all before writing patch files or manifest metadata.
8
+ - `re-export --skip-lint` now downgrades lint errors to warnings consistently, while default re-export blocks on lint errors and keeps artifacts unchanged.
9
+ - Raw CSS colors introduced by a patch are now patch lint errors, matching Furnace validation, without blocking on unrelated pre-existing upstream raw colors.
10
+ - Furnace accessibility validation now warns about missing ARIA roles only for generic interactive markup, so native semantic elements are not pushed toward redundant ARIA.
11
+
12
+ ### General improvements
13
+
14
+ - getPackageRoot up to this point expected hardcoded `@hominis/fireforge`, was changed to just the package name for potential forks and more flexibility when changing project name.
15
+ - Some test generators were derived from early Hominis Browser fork additions, the references to Hominis have been replaced with generic naming.
16
+
17
+ ### Build and Git reliability
18
+
19
+ - Build preflight now fails clearly when multiple build artifact directories make the target ambiguous.
20
+ - Git diff and status helpers now surface command failures instead of silently treating failed commands as empty output.
21
+ - Stale lock cleanup now distinguishes disappearance races from real cleanup failures.
22
+
23
+ ### Packaging
24
+
25
+ - Package metadata and smoke tests now use version `0.10.0`.
26
+ - npm install instructions use the scoped `@hominis/fireforge` package name.
27
+ - Packaging and full Firefox integration helpers now handle platform-specific npm and mozconfig names more consistently.
28
+
3
29
  ## 0.9.0
4
30
 
5
31
  ### npm release
6
32
 
7
- - Package is now installable via `npm install fireforge` or `npm install -g fireforge`.
33
+ - Package is now installable via `npm install @hominis/fireforge` or `npm install -g @hominis/fireforge`.
package/README.md CHANGED
@@ -1,10 +1,16 @@
1
1
  # FireForge
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@hominis/fireforge)](https://www.npmjs.com/package/@hominis/fireforge)
4
+ [![license](https://img.shields.io/npm/l/@hominis/fireforge)](LICENSE.md)
5
+ [![node](https://img.shields.io/node/v/@hominis/fireforge)](package.json)
6
+ [![types](https://img.shields.io/npm/types/@hominis/fireforge)](https://www.npmjs.com/package/@hominis/fireforge)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@hominis/fireforge)](https://www.npmjs.com/package/@hominis/fireforge)
8
+
3
9
  **Build and maintain your own Firefox-based browser with a patch-first workflow.**
4
10
 
5
11
  FireForge gives you a toolkit for forking Firefox: download a specific ESR release, manage your customisations as an ordered stack of contextual patches, survive version upgrades with semi-automated rebase, wire custom code into Mozilla's startup paths, and build the result. It also ships **Furnace**, a component system for creating and overriding Firefox custom elements.
6
12
 
7
- Inspired by [fern.js](https://github.com/nicktrosper/user-agent-desktop?tab=readme-ov-file#user-agent-desktop) and [Melon](https://github.com/nicktrosper/nicktrosper-melon).
13
+ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon](https://github.com/dothq/melon).
8
14
 
9
15
  ---
10
16
 
@@ -13,7 +19,7 @@ Inspired by [fern.js](https://github.com/nicktrosper/user-agent-desktop?tab=read
13
19
  ```bash
14
20
  mkdir mybrowser && cd mybrowser
15
21
  npm init -y
16
- npm install --save-dev fireforge
22
+ npm install --save-dev @hominis/fireforge
17
23
 
18
24
  npx fireforge setup # interactive project init
19
25
  npx fireforge download # fetch Firefox source (~1 GB)
@@ -165,14 +171,14 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
165
171
  <details>
166
172
  <summary>Patch lint checks</summary>
167
173
 
168
- `fireforge lint` runs automatically during export. Use `--skip-lint` to downgrade errors to warnings, though I would recommend against making that a habit.
174
+ `fireforge lint` runs automatically during export, export-all, and re-export. Use `--skip-lint` to downgrade errors to warnings, though I would recommend against making that a habit.
169
175
 
170
176
  | Check | Scope | Severity |
171
177
  | ------------------------------ | ------------------------------- | -------- |
172
178
  | `missing-license-header` | New files (JS/CSS/FTL) | error |
173
179
  | `relative-import` | JS/MJS files | error |
174
180
  | `token-prefix-violation` | CSS files (with furnace) | error |
175
- | `raw-color-value` | CSS files | warning |
181
+ | `raw-color-value` | Introduced CSS color values | error |
176
182
  | `missing-modification-comment` | Modified upstream JS/MJS | warning |
177
183
  | `file-too-large` | New files >650 lines | warning |
178
184
  | `missing-jsdoc` | Exports in new `.sys.mjs` | warning |
@@ -259,7 +265,7 @@ Furnace validates components on deploy. Errors block apply; warnings are advisor
259
265
  | `missing-css` | warning | No `.css` file |
260
266
  | `filename-mismatch` | error | File name does not match tag name |
261
267
  | `missing-override-json` | error | Override missing `override.json` |
262
- | `no-aria-role` | warning | No ARIA role found |
268
+ | `no-aria-role` | warning | Generic interactive markup lacks semantics |
263
269
  | `no-keyboard-handler` | warning | Has `@click` but no keyboard handler |
264
270
  | `relative-import` | error | Imports must use `chrome://` URIs |
265
271
  | `raw-color-value` | error | Raw hex/rgb/hsl (use CSS custom properties) |
@@ -5,7 +5,7 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
6
  import { build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts } from '../core/mach.js';
7
7
  import { GeneralError } from '../errors/base.js';
8
- import { BuildError } from '../errors/build.js';
8
+ import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
9
9
  import { pathExists } from '../utils/fs.js';
10
10
  import { error, info, intro, outro, verbose } from '../utils/logger.js';
11
11
  import { pickDefined } from '../utils/options.js';
@@ -45,6 +45,9 @@ export async function buildCommand(projectRoot, options) {
45
45
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
46
46
  }
47
47
  const buildCheck = await hasBuildArtifacts(paths.engine);
48
+ if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
49
+ throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
50
+ }
48
51
  const mismatchMessage = buildArtifactMismatchMessage(paths.engine, buildCheck, 'Build');
49
52
  if (mismatchMessage) {
50
53
  throw new GeneralError(mismatchMessage);
@@ -6,13 +6,13 @@ import { isGitRepository } from '../core/git.js';
6
6
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
7
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
8
8
  import { updatePatch, updatePatchMetadata } from '../core/patch-export.js';
9
- import { lintExportedPatch } from '../core/patch-lint.js';
10
9
  import { getClaimedFiles, loadPatchesManifest } from '../core/patch-manifest.js';
11
10
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
12
11
  import { toError } from '../utils/errors.js';
13
12
  import { pathExists } from '../utils/fs.js';
14
13
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
15
14
  import { pickDefined } from '../utils/options.js';
15
+ import { runPatchLint } from './export-shared.js';
16
16
  /**
17
17
  * Resolves patch identifiers (numbers or filenames) to manifest entries.
18
18
  * @param identifier - Patch number (e.g. "005") or filename (e.g. "005-ui-storage-modules.patch")
@@ -96,6 +96,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
96
96
  warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
97
97
  return false;
98
98
  }
99
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint);
99
100
  if (isDryRun) {
100
101
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
101
102
  }
@@ -115,15 +116,6 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
115
116
  };
116
117
  }
117
118
  }
118
- const lintIssues = await lintExportedPatch(paths.engine, existingFiles, diffContent, config);
119
- for (const issue of lintIssues) {
120
- const prefix = issue.severity === 'error' && !options.skipLint ? 'ERROR ' : '';
121
- warn(`${prefix}[${issue.check}] ${issue.file}: ${issue.message}`);
122
- }
123
- const lintErrors = lintIssues.filter((i) => i.severity === 'error');
124
- if (lintErrors.length > 0 && !options.skipLint) {
125
- warn(`${patch.filename}: ${lintErrors.length} lint error(s). Use --skip-lint to bypass.`);
126
- }
127
119
  success(`Re-exported ${patch.filename}`);
128
120
  }
129
121
  return true;
@@ -12,6 +12,15 @@ function sleep(ms) {
12
12
  setTimeout(resolve, ms);
13
13
  });
14
14
  }
15
+ function getNodeErrorCode(error) {
16
+ if (typeof error === 'object' &&
17
+ error !== null &&
18
+ 'code' in error &&
19
+ typeof error.code === 'string') {
20
+ return error.code;
21
+ }
22
+ return undefined;
23
+ }
15
24
  /** Derives the sibling lock-directory path used to guard a file-based resource. */
16
25
  export function createSiblingLockPath(filePath, suffix = '.fireforge.lock') {
17
26
  return `${filePath}${suffix}`;
@@ -31,8 +40,13 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
31
40
  return true;
32
41
  }
33
42
  catch (error) {
43
+ const code = getNodeErrorCode(error);
44
+ if (code === 'ENOENT') {
45
+ verbose(`Stale lock disappeared before cleanup completed: ${lockPath}`);
46
+ return true;
47
+ }
34
48
  verbose(`Stale lock check failed for ${lockPath}: ${toError(error).message}`);
35
- return true;
49
+ throw toError(error);
36
50
  }
37
51
  }
38
52
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
- import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasTemplateClickHandler, hasTemplateKeyboardHandler, } from './furnace-validate-helpers.js';
4
+ import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasTemplateClickHandler, hasTemplateKeyboardHandler, } from './furnace-validate-helpers.js';
5
5
  /**
6
6
  * Validates accessibility patterns in a component's .mjs file.
7
7
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
@@ -12,8 +12,8 @@ export async function validateAccessibility(componentDir, tagName) {
12
12
  return [];
13
13
  const content = await readText(mjsPath);
14
14
  const issues = [];
15
- if (!hasAriaRole(content)) {
16
- issues.push(createIssue(tagName, 'warning', 'no-aria-role', 'No ARIA role attribute found. Consider adding role= for screen reader support.'));
15
+ if (!hasAriaRole(content) && hasGenericInteractiveElement(content)) {
16
+ issues.push(createIssue(tagName, 'warning', 'no-aria-role', 'Generic interactive markup has no native semantics. Prefer native elements, or add role= when native markup cannot provide the semantics.'));
17
17
  }
18
18
  const hasClick = hasTemplateClickHandler(content);
19
19
  const hasKeyboardHandler = hasTemplateKeyboardHandler(content);
@@ -3,6 +3,8 @@ import type { ComponentType, FurnaceConfig, ValidationIssue } from '../types/fur
3
3
  export declare function createIssue(component: string, severity: ValidationIssue['severity'], check: ValidationIssue['check'], message: string): ValidationIssue;
4
4
  /** Detects whether template or script content assigns an ARIA role. */
5
5
  export declare function hasAriaRole(content: string): boolean;
6
+ /** Detects generic elements being used as custom interactive controls. */
7
+ export declare function hasGenericInteractiveElement(content: string): boolean;
6
8
  /** Detects Lit-style template click handlers. */
7
9
  export declare function hasTemplateClickHandler(content: string): boolean;
8
10
  /** Detects Lit-style template keyboard handlers. */
@@ -12,6 +12,10 @@ export function hasAriaRole(content) {
12
12
  /\.role\s*=/.test(content) ||
13
13
  /setAttribute\(\s*["']role["']/.test(content));
14
14
  }
15
+ /** Detects generic elements being used as custom interactive controls. */
16
+ export function hasGenericInteractiveElement(content) {
17
+ return /<(div|span)\b(?=[^>]*(?:@click|@key(?:down|press|up)|\btabindex\s*=|\.onclick\s*=))/i.test(content);
18
+ }
15
19
  /** Detects Lit-style template click handlers. */
16
20
  export function hasTemplateClickHandler(content) {
17
21
  return /@click\s*=\s*\$\{/.test(content);
@@ -2,13 +2,21 @@
2
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, join } from 'node:path';
5
+ import { GitError } from '../errors/git.js';
5
6
  import { toError } from '../utils/errors.js';
6
7
  import { pathExists, readText } from '../utils/fs.js';
7
8
  import { verbose } from '../utils/logger.js';
8
9
  import { exec } from '../utils/process.js';
9
- import { ensureGit } from './git-base.js';
10
+ import { ensureGit, git } from './git-base.js';
10
11
  import { fileExistsInHead } from './git-file-ops.js';
11
12
  import { getUntrackedFiles } from './git-status.js';
13
+ async function execGitWithAllowedExitCodes(repoDir, args, allowedExitCodes = [0]) {
14
+ const result = await exec('git', args, { cwd: repoDir });
15
+ if (allowedExitCodes.includes(result.exitCode)) {
16
+ return result;
17
+ }
18
+ throw new GitError(result.stderr.trim() || 'Git command failed', args.join(' '));
19
+ }
12
20
  /**
13
21
  * Gets the diff for a specific file.
14
22
  * @param repoDir - Repository directory
@@ -17,8 +25,7 @@ import { getUntrackedFiles } from './git-status.js';
17
25
  */
18
26
  export async function getFileDiff(repoDir, filePath) {
19
27
  await ensureGit();
20
- const result = await exec('git', ['diff', 'HEAD', '--', filePath], { cwd: repoDir });
21
- return result.stdout;
28
+ return git(['diff', 'HEAD', '--', filePath], repoDir);
22
29
  }
23
30
  /**
24
31
  * Generates a unified diff for a new (untracked) file.
@@ -32,8 +39,7 @@ export async function generateNewFileDiff(repoDir, filePath) {
32
39
  // Compute the abbreviated git blob hash for the index line
33
40
  let blobHash = '0000000000';
34
41
  try {
35
- const hashResult = await exec('git', ['hash-object', fullPath], { cwd: repoDir });
36
- const fullHash = hashResult.stdout.trim();
42
+ const fullHash = (await git(['hash-object', fullPath], repoDir)).trim();
37
43
  if (fullHash.length >= 10) {
38
44
  blobHash = fullHash.slice(0, 10);
39
45
  }
@@ -113,9 +119,7 @@ export async function generateModificationDiff(repoDir, filePath, baseContent) {
113
119
  try {
114
120
  await writeFile(tempFile, baseContent);
115
121
  // git diff --no-index exits code 1 when files differ — that's normal
116
- const result = await exec('git', ['diff', '--no-index', '--', tempFile, fullPath], {
117
- cwd: repoDir,
118
- });
122
+ const result = await execGitWithAllowedExitCodes(repoDir, ['diff', '--no-index', '--', tempFile, fullPath], [0, 1]);
119
123
  const output = result.stdout;
120
124
  if (!output) {
121
125
  return '';
@@ -154,8 +158,7 @@ export async function generateModificationDiff(repoDir, filePath, baseContent) {
154
158
  export async function getAllDiff(repoDir) {
155
159
  await ensureGit();
156
160
  // Get diff for tracked files
157
- const result = await exec('git', ['diff', 'HEAD'], { cwd: repoDir });
158
- const trackedDiff = result.stdout;
161
+ const trackedDiff = await git(['diff', 'HEAD'], repoDir);
159
162
  // Get untracked files (properly expanded, not directories)
160
163
  const untrackedFiles = await getUntrackedFiles(repoDir);
161
164
  // Generate diffs for untracked files
@@ -215,8 +218,7 @@ export async function getDiffForFilesAgainstHead(repoDir, files) {
215
218
  */
216
219
  export async function getStagedDiffForFiles(repoDir, files) {
217
220
  await ensureGit();
218
- const result = await exec('git', ['diff', '--cached', 'HEAD', '--', ...files], { cwd: repoDir });
219
- return result.stdout;
221
+ return git(['diff', '--cached', 'HEAD', '--', ...files], repoDir);
220
222
  }
221
223
  /**
222
224
  * Generates a GIT binary patch for a binary file.
@@ -229,18 +231,29 @@ export async function getStagedDiffForFiles(repoDir, files) {
229
231
  export async function generateBinaryFilePatch(repoDir, filePath) {
230
232
  await ensureGit();
231
233
  // Try tracked file diff first
232
- const result = await exec('git', ['diff', '--binary', 'HEAD', '--', filePath], { cwd: repoDir });
234
+ const result = await execGitWithAllowedExitCodes(repoDir, [
235
+ 'diff',
236
+ '--binary',
237
+ 'HEAD',
238
+ '--',
239
+ filePath,
240
+ ]);
233
241
  if (result.stdout.trim())
234
242
  return result.stdout;
235
243
  // For untracked files, stage temporarily to produce a binary diff
236
244
  try {
237
- await exec('git', ['add', '--intent-to-add', '--', filePath], { cwd: repoDir });
238
- const diffResult = await exec('git', ['diff', '--binary', '--', filePath], { cwd: repoDir });
245
+ await execGitWithAllowedExitCodes(repoDir, ['add', '--intent-to-add', '--', filePath]);
246
+ const diffResult = await execGitWithAllowedExitCodes(repoDir, [
247
+ 'diff',
248
+ '--binary',
249
+ '--',
250
+ filePath,
251
+ ]);
239
252
  return diffResult.stdout;
240
253
  }
241
254
  finally {
242
255
  // Always unstage, even if diff fails
243
- await exec('git', ['reset', 'HEAD', '--', filePath], { cwd: repoDir });
256
+ await execGitWithAllowedExitCodes(repoDir, ['reset', 'HEAD', '--', filePath]);
244
257
  }
245
258
  }
246
259
  //# sourceMappingURL=git-diff.js.map
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { open } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { GitError } from '../errors/git.js';
4
5
  import { removeFile } from '../utils/fs.js';
5
6
  import { exec } from '../utils/process.js';
6
7
  import { ensureGit, git } from './git-base.js';
@@ -37,7 +38,7 @@ export async function removeUntrackedPath(repoDir, filePath) {
37
38
  */
38
39
  export async function removeAddedPath(repoDir, filePath) {
39
40
  await ensureGit();
40
- await exec('git', ['reset', 'HEAD', '--', filePath], { cwd: repoDir });
41
+ await git(['reset', 'HEAD', '--', filePath], repoDir);
41
42
  await removeUntrackedPath(repoDir, filePath);
42
43
  }
43
44
  /**
@@ -92,8 +93,7 @@ export async function unstageFiles(repoDir, files) {
92
93
  */
93
94
  export async function fileExistsInHead(repoDir, filePath) {
94
95
  await ensureGit();
95
- const result = await exec('git', ['ls-tree', 'HEAD', '--', filePath], { cwd: repoDir });
96
- return result.stdout.trim().length > 0;
96
+ return (await git(['ls-tree', 'HEAD', '--', filePath], repoDir)).trim().length > 0;
97
97
  }
98
98
  /**
99
99
  * Gets the content of a file from HEAD commit.
@@ -105,7 +105,11 @@ export async function getFileContentFromHead(repoDir, filePath) {
105
105
  await ensureGit();
106
106
  const result = await exec('git', ['show', `HEAD:${filePath}`], { cwd: repoDir });
107
107
  if (result.exitCode !== 0) {
108
- return null;
108
+ const stderr = result.stderr.trim();
109
+ if (/exists on disk, but not in 'HEAD'|path '.*' exists, but not '.*'|path '.*' does not exist in 'HEAD'/i.test(stderr)) {
110
+ return null;
111
+ }
112
+ throw new GitError(stderr || 'Git command failed', `show HEAD:${filePath}`);
109
113
  }
110
114
  return result.stdout;
111
115
  }
@@ -1,6 +1,4 @@
1
- // SPDX-License-Identifier: EUPL-1.2
2
- import { exec } from '../utils/process.js';
3
- import { ensureGit } from './git-base.js';
1
+ import { ensureGit, git } from './git-base.js';
4
2
  /**
5
3
  * Parses NUL-delimited porcelain status output.
6
4
  * @param output - Raw git status output
@@ -43,8 +41,7 @@ export function parsePorcelainStatus(output) {
43
41
  */
44
42
  export async function getWorkingTreeStatus(repoDir) {
45
43
  await ensureGit();
46
- const result = await exec('git', ['status', '--porcelain=v1', '-z'], { cwd: repoDir });
47
- return parsePorcelainStatus(result.stdout);
44
+ return parsePorcelainStatus(await git(['status', '--porcelain=v1', '-z'], repoDir));
48
45
  }
49
46
  /**
50
47
  * Expands collapsed untracked directory entries into individual file entries.
@@ -92,10 +89,8 @@ export async function getModifiedFiles(repoDir) {
92
89
  export async function getUntrackedFiles(repoDir) {
93
90
  await ensureGit();
94
91
  // Use git ls-files to get all untracked files, which properly expands directories
95
- const result = await exec('git', ['ls-files', '--others', '--exclude-standard'], {
96
- cwd: repoDir,
97
- });
98
- return result.stdout.split('\n').filter((line) => line.trim().length > 0);
92
+ const output = await git(['ls-files', '--others', '--exclude-standard'], repoDir);
93
+ return output.split('\n').filter((line) => line.trim().length > 0);
99
94
  }
100
95
  /**
101
96
  * Gets untracked files within a specific directory.
@@ -106,10 +101,8 @@ export async function getUntrackedFiles(repoDir) {
106
101
  */
107
102
  export async function getUntrackedFilesInDir(repoDir, dir) {
108
103
  await ensureGit();
109
- const result = await exec('git', ['ls-files', '--others', '--exclude-standard', '--', dir], {
110
- cwd: repoDir,
111
- });
112
- return result.stdout.split('\n').filter((line) => line.trim().length > 0);
104
+ const output = await git(['ls-files', '--others', '--exclude-standard', '--', dir], repoDir);
105
+ return output.split('\n').filter((line) => line.trim().length > 0);
113
106
  }
114
107
  /**
115
108
  * Gets modified (tracked) files within a specific directory.
@@ -120,8 +113,8 @@ export async function getUntrackedFilesInDir(repoDir, dir) {
120
113
  */
121
114
  export async function getModifiedFilesInDir(repoDir, dir) {
122
115
  await ensureGit();
123
- const result = await exec('git', ['diff', '--name-only', 'HEAD', '--', dir], { cwd: repoDir });
124
- return result.stdout.split('\n').filter((line) => line.trim().length > 0);
116
+ const output = await git(['diff', '--name-only', 'HEAD', '--', dir], repoDir);
117
+ return output.split('\n').filter((line) => line.trim().length > 0);
125
118
  }
126
119
  /**
127
120
  * Checks if any of the specified files have uncommitted changes.
@@ -134,13 +127,11 @@ export async function getDirtyFiles(repoDir, files) {
134
127
  return [];
135
128
  await ensureGit();
136
129
  // Check both staged and unstaged changes for the given files
137
- const result = await exec('git', ['diff', '--name-only', 'HEAD', '--', ...files], {
138
- cwd: repoDir,
139
- });
140
- const tracked = result.stdout.split('\n').filter((line) => line.trim().length > 0);
130
+ const trackedOutput = await git(['diff', '--name-only', 'HEAD', '--', ...files], repoDir);
131
+ const tracked = trackedOutput.split('\n').filter((line) => line.trim().length > 0);
141
132
  // Also check for untracked files
142
- const untrackedResult = await exec('git', ['ls-files', '--others', '--exclude-standard', '--', ...files], { cwd: repoDir });
143
- const untracked = untrackedResult.stdout.split('\n').filter((line) => line.trim().length > 0);
133
+ const untrackedOutput = await git(['ls-files', '--others', '--exclude-standard', '--', ...files], repoDir);
134
+ const untracked = untrackedOutput.split('\n').filter((line) => line.trim().length > 0);
144
135
  return [...new Set([...tracked, ...untracked])].sort();
145
136
  }
146
137
  /**
@@ -152,12 +143,10 @@ export async function getDirtyFiles(repoDir, files) {
152
143
  */
153
144
  export async function listAllFilesInDir(repoDir, dir) {
154
145
  await ensureGit();
155
- const tracked = await exec('git', ['ls-files', '--', dir], { cwd: repoDir });
156
- const trackedFiles = tracked.stdout.split('\n').filter((line) => line.trim().length > 0);
157
- const untracked = await exec('git', ['ls-files', '--others', '--exclude-standard', '--', dir], {
158
- cwd: repoDir,
159
- });
160
- const untrackedFiles = untracked.stdout.split('\n').filter((line) => line.trim().length > 0);
146
+ const trackedOutput = await git(['ls-files', '--', dir], repoDir);
147
+ const trackedFiles = trackedOutput.split('\n').filter((line) => line.trim().length > 0);
148
+ const untrackedOutput = await git(['ls-files', '--others', '--exclude-standard', '--', dir], repoDir);
149
+ const untrackedFiles = untrackedOutput.split('\n').filter((line) => line.trim().length > 0);
161
150
  return [...new Set([...trackedFiles, ...untrackedFiles])].sort();
162
151
  }
163
152
  //# sourceMappingURL=git-status.js.map
@@ -10,13 +10,15 @@ export declare function commentStyleForFile(file: string): CommentStyle | null;
10
10
  */
11
11
  export declare function detectNewFilesInDiff(diffContent: string): Set<string>;
12
12
  /**
13
- * Lints patched CSS files for raw color values and non-tokenized custom properties.
13
+ * Lints patched CSS files for introduced raw color values and non-tokenized
14
+ * custom properties.
14
15
  *
15
16
  * @param repoDir - Absolute path to the engine (repository) directory
16
17
  * @param affectedFiles - File paths (relative to repoDir) affected by the patch
18
+ * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
17
19
  * @returns Array of lint issues found
18
20
  */
19
- export declare function lintPatchedCss(repoDir: string, affectedFiles: string[]): Promise<PatchLintIssue[]>;
21
+ export declare function lintPatchedCss(repoDir: string, affectedFiles: string[], diffContent?: string): Promise<PatchLintIssue[]>;
20
22
  /**
21
23
  * Checks new files for required license headers.
22
24
  *
@@ -80,16 +80,18 @@ function extractAddedLinesPerFile(diffContent) {
80
80
  return result;
81
81
  }
82
82
  // ---------------------------------------------------------------------------
83
- // CSS lint (existing — now with severity)
83
+ // CSS lint
84
84
  // ---------------------------------------------------------------------------
85
85
  /**
86
- * Lints patched CSS files for raw color values and non-tokenized custom properties.
86
+ * Lints patched CSS files for introduced raw color values and non-tokenized
87
+ * custom properties.
87
88
  *
88
89
  * @param repoDir - Absolute path to the engine (repository) directory
89
90
  * @param affectedFiles - File paths (relative to repoDir) affected by the patch
91
+ * @param diffContent - Optional unified diff used to scope raw color checks to introduced lines
90
92
  * @returns Array of lint issues found
91
93
  */
92
- export async function lintPatchedCss(repoDir, affectedFiles) {
94
+ export async function lintPatchedCss(repoDir, affectedFiles, diffContent) {
93
95
  const cssFiles = affectedFiles.filter((f) => f.endsWith('.css'));
94
96
  if (cssFiles.length === 0)
95
97
  return [];
@@ -108,6 +110,7 @@ export async function lintPatchedCss(repoDir, affectedFiles) {
108
110
  verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
109
111
  }
110
112
  const issues = [];
113
+ const addedLinesByFile = diffContent ? extractAddedLinesPerFile(diffContent) : undefined;
111
114
  for (const file of cssFiles) {
112
115
  const filePath = join(repoDir, file);
113
116
  if (!(await pathExists(filePath)))
@@ -115,13 +118,16 @@ export async function lintPatchedCss(repoDir, affectedFiles) {
115
118
  const rawCss = await readText(filePath);
116
119
  // Strip block comments before scanning
117
120
  const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
118
- // Check for raw color values
119
- if (hasRawCssColors(cssContent)) {
121
+ const rawColorContent = addedLinesByFile
122
+ ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
123
+ : cssContent;
124
+ // Check only introduced raw color values when diff context is available.
125
+ if (hasRawCssColors(rawColorContent)) {
120
126
  issues.push({
121
127
  file,
122
128
  check: 'raw-color-value',
123
129
  message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
124
- severity: 'warning',
130
+ severity: 'error',
125
131
  });
126
132
  }
127
133
  // Check for non-tokenized custom properties
@@ -384,7 +390,7 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
384
390
  const newFiles = detectNewFilesInDiff(diffContent);
385
391
  const lineCount = diffContent.split('\n').length;
386
392
  const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
387
- lintPatchedCss(repoDir, affectedFiles),
393
+ lintPatchedCss(repoDir, affectedFiles, diffContent),
388
394
  lintNewFileHeaders(repoDir, [...newFiles], config),
389
395
  lintPatchedJs(repoDir, affectedFiles, newFiles, config),
390
396
  lintModifiedFileHeaders(repoDir, affectedFiles, newFiles),
@@ -1,10 +1,18 @@
1
+ interface PackageMetadata {
2
+ name: string;
3
+ version: string;
4
+ bin?: unknown;
5
+ }
1
6
  /**
2
7
  * Finds the fireforge package root by walking up from the current module.
3
8
  *
4
9
  * Works from both the source tree (`src/utils/`) and the compiled
5
- * tree (`dist/src/utils/`) by looking for a `package.json` whose
6
- * `name` field is `"@hominis/fireforge"`.
10
+ * tree (`dist/src/utils/`) by looking for a `package.json` that exposes
11
+ * the `fireforge` CLI entrypoint, regardless of the npm package scope.
7
12
  */
8
13
  export declare function getPackageRoot(): string;
14
+ /** @internal */
15
+ export declare function isFireForgePackageMetadata(pkg: PackageMetadata): boolean;
9
16
  /** Reads the current package version from the repository root package manifest. */
10
17
  export declare function getPackageVersion(): string;
18
+ export {};
@@ -11,7 +11,7 @@ function validatePackageMetadata(data, filePath) {
11
11
  if (typeof name !== 'string' || typeof version !== 'string') {
12
12
  throw new Error(`Invalid package metadata in ${filePath}: expected string "name" and "version" fields`);
13
13
  }
14
- return { name, version };
14
+ return { name, version, bin: 'bin' in data ? data.bin : undefined };
15
15
  }
16
16
  function readPackageMetadata(filePath) {
17
17
  const raw = readFileSync(filePath, 'utf-8');
@@ -21,8 +21,8 @@ function readPackageMetadata(filePath) {
21
21
  * Finds the fireforge package root by walking up from the current module.
22
22
  *
23
23
  * Works from both the source tree (`src/utils/`) and the compiled
24
- * tree (`dist/src/utils/`) by looking for a `package.json` whose
25
- * `name` field is `"@hominis/fireforge"`.
24
+ * tree (`dist/src/utils/`) by looking for a `package.json` that exposes
25
+ * the `fireforge` CLI entrypoint, regardless of the npm package scope.
26
26
  */
27
27
  export function getPackageRoot() {
28
28
  let current = dirname(fileURLToPath(import.meta.url));
@@ -30,7 +30,7 @@ export function getPackageRoot() {
30
30
  try {
31
31
  const packagePath = join(current, 'package.json');
32
32
  const pkg = readPackageMetadata(packagePath);
33
- if (pkg.name === '@hominis/fireforge') {
33
+ if (isFireForgePackageMetadata(pkg)) {
34
34
  return current;
35
35
  }
36
36
  }
@@ -45,6 +45,14 @@ export function getPackageRoot() {
45
45
  current = parent;
46
46
  }
47
47
  }
48
+ /** @internal */
49
+ export function isFireForgePackageMetadata(pkg) {
50
+ if (typeof pkg.bin !== 'object' || pkg.bin === null || Array.isArray(pkg.bin)) {
51
+ return false;
52
+ }
53
+ const bin = pkg.bin;
54
+ return typeof bin['fireforge'] === 'string';
55
+ }
48
56
  /** Reads the current package version from the repository root package manifest. */
49
57
  export function getPackageVersion() {
50
58
  const packageRoot = getPackageRoot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -85,11 +85,11 @@
85
85
  "license": "EUPL-1.2",
86
86
  "repository": {
87
87
  "type": "git",
88
- "url": "https://github.com/topfi/fireforge.git"
88
+ "url": "https://github.com/HominisBrowser/FireForge.git"
89
89
  },
90
- "homepage": "https://github.com/topfi/fireforge",
90
+ "homepage": "https://github.com/HominisBrowser/FireForge",
91
91
  "bugs": {
92
- "url": "https://github.com/topfi/fireforge/issues"
92
+ "url": "https://github.com/HominisBrowser/FireForge/issues"
93
93
  },
94
94
  "keywords": [
95
95
  "fireforge",