@hominis/fireforge 0.15.6 → 0.15.8

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 (64) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +158 -15
  3. package/dist/src/commands/build.js +60 -3
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +17 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +18 -0
  6. package/dist/src/commands/furnace/chrome-doc-tests.d.ts +23 -0
  7. package/dist/src/commands/furnace/chrome-doc-tests.js +120 -0
  8. package/dist/src/commands/furnace/chrome-doc.d.ts +11 -0
  9. package/dist/src/commands/furnace/chrome-doc.js +37 -4
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +38 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +100 -0
  12. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  13. package/dist/src/commands/furnace/create-features.js +56 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  15. package/dist/src/commands/furnace/create-templates.js +28 -6
  16. package/dist/src/commands/furnace/create.js +62 -63
  17. package/dist/src/commands/furnace/index.js +4 -1
  18. package/dist/src/commands/lint.d.ts +17 -2
  19. package/dist/src/commands/lint.js +25 -2
  20. package/dist/src/commands/register.d.ts +1 -1
  21. package/dist/src/commands/register.js +30 -7
  22. package/dist/src/commands/run.d.ts +15 -1
  23. package/dist/src/commands/run.js +202 -7
  24. package/dist/src/commands/test.js +113 -3
  25. package/dist/src/core/build-audit-registration.d.ts +80 -0
  26. package/dist/src/core/build-audit-registration.js +187 -0
  27. package/dist/src/core/build-audit-transforms.d.ts +23 -0
  28. package/dist/src/core/build-audit-transforms.js +94 -0
  29. package/dist/src/core/build-audit.js +107 -7
  30. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  31. package/dist/src/core/furnace-apply-ftl.js +6 -2
  32. package/dist/src/core/furnace-apply-helpers.js +14 -4
  33. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  34. package/dist/src/core/furnace-config-custom.js +64 -0
  35. package/dist/src/core/furnace-config.js +2 -39
  36. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  37. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  38. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  39. package/dist/src/core/furnace-validate-helpers.js +19 -0
  40. package/dist/src/core/furnace-validate-registration.d.ts +6 -4
  41. package/dist/src/core/furnace-validate-registration.js +66 -6
  42. package/dist/src/core/furnace-validate-structure.js +6 -2
  43. package/dist/src/core/furnace-validate.js +6 -3
  44. package/dist/src/core/mach-build-artifacts.d.ts +44 -0
  45. package/dist/src/core/mach-build-artifacts.js +104 -3
  46. package/dist/src/core/mach.d.ts +27 -1
  47. package/dist/src/core/mach.js +26 -2
  48. package/dist/src/core/shared-ftl.d.ts +28 -0
  49. package/dist/src/core/shared-ftl.js +42 -0
  50. package/dist/src/core/smoke-patterns.d.ts +45 -0
  51. package/dist/src/core/smoke-patterns.js +100 -0
  52. package/dist/src/core/test-stale-check.d.ts +42 -0
  53. package/dist/src/core/test-stale-check.js +114 -0
  54. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  55. package/dist/src/core/xpcshell-appdir.js +273 -0
  56. package/dist/src/errors/codes.d.ts +13 -0
  57. package/dist/src/errors/codes.js +13 -0
  58. package/dist/src/errors/run.d.ts +16 -0
  59. package/dist/src/errors/run.js +22 -0
  60. package/dist/src/types/commands/options.d.ts +64 -0
  61. package/dist/src/types/furnace.d.ts +39 -0
  62. package/dist/src/utils/process.d.ts +63 -0
  63. package/dist/src/utils/process.js +122 -0
  64. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
- import { multiselect, text } from '@clack/prompts';
3
+ import { text } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
5
  import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
6
  import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-constants.js';
@@ -10,11 +10,14 @@ import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow,
10
10
  import { isComponentInEngine } from '../../core/furnace-scanner.js';
11
11
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
12
12
  import { registerTestManifest } from '../../core/manifest-register.js';
13
+ import { validateSharedFtl } from '../../core/shared-ftl.js';
13
14
  import { InvalidArgumentError } from '../../errors/base.js';
14
15
  import { FurnaceError } from '../../errors/furnace.js';
15
16
  import { toError } from '../../utils/errors.js';
16
17
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
17
18
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
19
+ import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
20
+ import { resolveCreateFeatures } from './create-features.js';
18
21
  import { scaffoldMochikitTestFiles } from './create-mochikit.js';
19
22
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
20
23
  import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
@@ -157,40 +160,6 @@ add_task(async function test_${underscored}_defined() {
157
160
  }
158
161
  return testFiles;
159
162
  }
160
- /**
161
- * Resolves the localized and registration feature flags for a new component.
162
- * @param isInteractive - Whether interactive prompts are available
163
- * @param options - CLI-provided feature flags
164
- * @returns Final feature selections, or null when creation is cancelled
165
- */
166
- async function resolveCreateFeatures(isInteractive, options) {
167
- let localized = options.localized ?? false;
168
- let register = options.register ?? true;
169
- if (isInteractive && options.localized === undefined && options.register === undefined) {
170
- const features = await multiselect({
171
- message: 'Component features:',
172
- options: [
173
- {
174
- value: 'localized',
175
- label: 'Fluent localization (data-l10n-id)',
176
- },
177
- {
178
- value: 'register',
179
- label: 'Register in customElements.js',
180
- },
181
- ],
182
- initialValues: ['register'],
183
- });
184
- if (isCancel(features)) {
185
- cancel('Create cancelled');
186
- return null;
187
- }
188
- const selected = features;
189
- localized = selected.includes('localized');
190
- register = selected.includes('register');
191
- }
192
- return { localized, register };
193
- }
194
163
  /**
195
164
  * Writes the scaffolded component source files to disk.
196
165
  * @param componentDir - Destination component directory
@@ -202,20 +171,25 @@ async function resolveCreateFeatures(isInteractive, options) {
202
171
  * @param journal - Optional rollback journal that snapshots files before writes
203
172
  * @returns Relative filenames written for the component
204
173
  */
205
- async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, journal) {
174
+ async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, sharedFtl, journal) {
206
175
  await ensureDir(componentDir);
207
176
  const files = [`${componentName}.mjs`, `${componentName}.css`];
208
177
  const mjsPath = join(componentDir, `${componentName}.mjs`);
209
178
  if (journal)
210
179
  await snapshotFile(journal, mjsPath);
211
- const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath);
180
+ const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath, sharedFtl);
212
181
  await writeText(mjsPath, mjsContent);
213
182
  const cssPath = join(componentDir, `${componentName}.css`);
214
183
  if (journal)
215
184
  await snapshotFile(journal, cssPath);
216
185
  const cssContent = generateCssContent(getLicenseHeader(license, 'css'));
217
186
  await writeText(cssPath, cssContent);
218
- if (localized) {
187
+ // Skip the per-component .ftl stub when the component participates in a
188
+ // pre-existing feature-scoped bundle. The shared bundle is owned
189
+ // elsewhere; dropping a stub here would clutter the workspace with
190
+ // empty files that never get packaged (furnace apply also skips copying
191
+ // them in this mode).
192
+ if (localized && !sharedFtl) {
219
193
  const ftlPath = join(componentDir, `${componentName}.ftl`);
220
194
  if (journal)
221
195
  await snapshotFile(journal, ftlPath);
@@ -276,7 +250,7 @@ async function performCreateMutations(args) {
276
250
  // so signal-driven rollback can clean it up even if writeComponentFiles
277
251
  // is interrupted mid-ensureDir.
278
252
  recordCreatedDir(journal, args.componentDir);
279
- files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, journal);
253
+ files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, args.sharedFtl, journal);
280
254
  const customEntry = {
281
255
  description: args.description,
282
256
  targetPath: `toolkit/content/widgets/${args.componentName}`,
@@ -286,6 +260,9 @@ async function performCreateMutations(args) {
286
260
  if (args.composes && args.composes.length > 0) {
287
261
  customEntry.composes = args.composes;
288
262
  }
263
+ if (args.sharedFtl) {
264
+ customEntry.sharedFtl = args.sharedFtl;
265
+ }
289
266
  args.config.custom[args.componentName] = customEntry;
290
267
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
291
268
  await writeFurnaceConfig(args.projectRoot, args.config);
@@ -374,7 +351,8 @@ function validateComposesTargets(config, componentName, composes) {
374
351
  */
375
352
  export async function furnaceCreateCommand(projectRoot, name, options = {}) {
376
353
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
377
- intro('Furnace Create');
354
+ const isDryRun = options.dryRun ?? false;
355
+ intro(isDryRun ? 'Furnace Create (dry run)' : 'Furnace Create');
378
356
  // --- Resolve component name ---
379
357
  // Validation runs before we load/create any persisted furnace config so a
380
358
  // failed authoring command never auto-creates furnace.json in a fresh
@@ -453,6 +431,42 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
453
431
  // does not strand component files behind.
454
432
  const composes = options.compose;
455
433
  validateComposesTargets(config, componentName, composes);
434
+ // --- Normalize and validate --shared-ftl ahead of any writes. Shares the
435
+ // structural rules with furnace-config.ts so the command and the on-disk
436
+ // schema cannot diverge. Pass the resolved `localized` rather than a
437
+ // `true` literal so the validator's cross-field check stays anchored to
438
+ // the real feature selection — `resolveCreateFeatures` promotes localized
439
+ // upstream, but hard-coding `true` here would hide a regression if that
440
+ // promotion ever moved or dropped.
441
+ let sharedFtl;
442
+ if (options.sharedFtl !== undefined) {
443
+ const result = validateSharedFtl(options.sharedFtl, { localized });
444
+ if (!result.ok) {
445
+ throw new InvalidArgumentError(`--shared-ftl ${result.reason}.`, 'sharedFtl');
446
+ }
447
+ sharedFtl = result.value;
448
+ }
449
+ // Dry-run exits here — every validation that does not need a write has
450
+ // already run, so the plan we render reflects exactly what the real
451
+ // command would do. The mutation phase and its rollback journal are
452
+ // intentionally skipped so no furnace.json/engine state is touched.
453
+ if (isDryRun) {
454
+ const plan = formatDryRunPlan({
455
+ componentName,
456
+ localized,
457
+ register,
458
+ composes,
459
+ // Spread rather than assign so the key is absent when sharedFtl is
460
+ // undefined — the DryRunPlanInput type uses strict-optional shape.
461
+ ...(sharedFtl !== undefined ? { sharedFtl } : {}),
462
+ testStyle,
463
+ description,
464
+ binaryName: forgeConfig.binaryName,
465
+ });
466
+ note(plan, componentName);
467
+ outro('Dry run complete (no files modified)');
468
+ return;
469
+ }
456
470
  // All validation is done. Hand off to the transactional mutation helper
457
471
  // so any failure restores the workspace and engine to their pre-command
458
472
  // state via the shared rollback journal. The mutation runs under the
@@ -470,6 +484,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
470
484
  localized,
471
485
  register,
472
486
  composes,
487
+ sharedFtl,
473
488
  componentDir,
474
489
  furnacePaths,
475
490
  config,
@@ -480,29 +495,13 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
480
495
  ftlChromeSubPath,
481
496
  operationContext: ctx,
482
497
  }));
483
- // --- Success ---
484
- let noteParts = `Files created in components/custom/${componentName}/:\n` +
485
- files.map((f) => ` ${f}`).join('\n');
486
- if (testFiles.length > 0) {
487
- let testRoot;
488
- if (testStyle === 'xpcshell') {
489
- testRoot = `engine/browser/base/content/test/${forgeConfig.binaryName}-xpcshell/${componentName}/`;
490
- }
491
- else if (testStyle === 'mochikit') {
492
- testRoot = 'engine/toolkit/content/tests/widgets/';
493
- }
494
- else {
495
- testRoot = `engine/browser/base/content/test/${forgeConfig.binaryName}/`;
496
- }
497
- noteParts += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
498
- }
499
- noteParts +=
500
- '\n\n' +
501
- 'Next steps:\n' +
502
- ` 1. Edit component files in components/custom/${componentName}/\n` +
503
- ' 2. Run "fireforge furnace preview" to see it\n' +
504
- ' 3. Run "fireforge build" to apply and build';
505
- note(noteParts, componentName);
498
+ note(formatSuccessNote({
499
+ componentName,
500
+ files,
501
+ testFiles,
502
+ testStyle,
503
+ binaryName: forgeConfig.binaryName,
504
+ }), componentName);
506
505
  outro('Component created');
507
506
  }
508
507
  //# sourceMappingURL=create.js.map
@@ -73,7 +73,7 @@ function registerFurnaceInfoCommands(furnace, context) {
73
73
  .option('--localized', 'Include Fluent l10n support')
74
74
  .option('--no-register', 'Skip customElements.js registration')
75
75
  .option('--with-tests', 'Scaffold a test harness (defaults to MochiKit; see --test-style)')
76
- .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser); equivalent to --test-style=xpcshell')
76
+ .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser); equivalent to --test-style=xpcshell. Note: xpcshell resolves chrome://global/* URIs but not chrome://browser/* — use --test-style=browser-chrome for browser-chrome-dependent tests.')
77
77
  .option('--test-style <style>', "Override the harness written by --with-tests: mochikit (default, runs against non-tabbrowser chrome), browser-chrome (today's scaffold, needs tabbrowser), or xpcshell (headless)", (value) => {
78
78
  if (value !== 'mochikit' && value !== 'browser-chrome' && value !== 'xpcshell') {
79
79
  throw new Error(`--test-style must be one of: mochikit, browser-chrome, xpcshell. Got: "${value}".`);
@@ -81,6 +81,8 @@ function registerFurnaceInfoCommands(furnace, context) {
81
81
  return value;
82
82
  })
83
83
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
84
+ .option('--shared-ftl <path>', 'Participate in an existing feature-scoped .ftl at this path (e.g. "browser/hominis-dock.ftl"); skips the per-component .ftl scaffold (implies --localized)')
85
+ .option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
84
86
  .action(withErrorHandling(async (name, options) => {
85
87
  await furnaceCreateCommand(getProjectRoot(), name, options);
86
88
  }));
@@ -91,6 +93,7 @@ function registerFurnaceInfoCommands(furnace, context) {
91
93
  .command('create <name>')
92
94
  .description('Scaffold a new top-level chrome document')
93
95
  .option('--no-titlebar', 'Frameless overlay-style document (omits titlebar-buttonbox)')
96
+ .option('--with-tests', 'Scaffold an xpcshell packaging-verification test that probes XCurProcD/chrome/browser/... directly (bypasses the xpcshell chrome:// URI limitation).')
94
97
  .action(withErrorHandling(async (name, options) => {
95
98
  await furnaceChromeDocCreateCommand(getProjectRoot(), name, pickDefined(options));
96
99
  }));
@@ -6,10 +6,25 @@ export interface LintCommandOptions {
6
6
  * When set, tag each issue as `introduced` or `cumulative` based on
7
7
  * whether its file changed since this git revision (e.g. `HEAD`, a
8
8
  * branch name, or a SHA). Issues are not filtered — the full set still
9
- * prints and the exit code is unchanged — but a diff-scoped summary
10
- * makes it trivial to see which errors the current task introduced.
9
+ * prints — but a diff-scoped summary makes it trivial to see which
10
+ * errors the current task introduced.
11
11
  */
12
12
  since?: string;
13
+ /**
14
+ * When set together with {@link since}, scope the exit code to issues
15
+ * tagged `introduced`. Cumulative pre-existing errors still print (so
16
+ * the operator can still see the full queue state) but do not fail
17
+ * lint. Motivating case: a branch whose diff is clean but whose repo
18
+ * already carries unrelated `raw-color` / license-header errors from
19
+ * older patches. Without this flag, CI treats the clean branch as
20
+ * failing; with it, a branch "breaks the build" only when its own diff
21
+ * introduced a new error.
22
+ *
23
+ * Requires {@link since}: without a revision to diff against there is
24
+ * no distinction between introduced and cumulative, so the flag is
25
+ * rejected up-front rather than silently ignored.
26
+ */
27
+ onlyIntroduced?: boolean;
13
28
  }
14
29
  /**
15
30
  * Runs the lint command to check engine changes against patch quality rules.
@@ -19,6 +19,14 @@ import { info, intro, outro, success, warn } from '../utils/logger.js';
19
19
  */
20
20
  export async function lintCommand(projectRoot, files, options = {}) {
21
21
  intro('FireForge Lint');
22
+ // `--only-introduced` scopes the exit code to `--since`-tagged issues, so
23
+ // without a revision to anchor the diff there is no "introduced" subset
24
+ // to scope to — reject the combination up-front so a misconfigured CI
25
+ // invocation fails loud instead of silently treating every error as
26
+ // cumulative and passing.
27
+ if (options.onlyIntroduced && !options.since) {
28
+ throw new GeneralError('--only-introduced requires --since <git-rev> so introduced-vs-cumulative can be distinguished.');
29
+ }
22
30
  const paths = getProjectPaths(projectRoot);
23
31
  if (!(await pathExists(paths.engine))) {
24
32
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
@@ -139,9 +147,20 @@ export async function lintCommand(projectRoot, files, options = {}) {
139
147
  else {
140
148
  info(`\nLint: ${errors.length} error(s), ${warnings.length} warning(s)`);
141
149
  }
142
- if (errors.length > 0) {
150
+ // Exit-code scope: `--only-introduced` narrows the failure criterion to
151
+ // issues tagged `introduced`. Cumulative errors still print so the
152
+ // operator sees the full queue state, but do not fail lint — the
153
+ // motivating case is a branch whose own diff is clean but whose repo
154
+ // already carries pre-existing queue errors from older patches.
155
+ const failingErrors = options.onlyIntroduced
156
+ ? errors.filter((i) => i.tag === 'introduced')
157
+ : errors;
158
+ if (failingErrors.length > 0) {
143
159
  outro('Lint failed');
144
- throw new GeneralError(`Patch lint found ${errors.length} error(s). Fix these before exporting.`);
160
+ const cumulativeSuppressed = options.onlyIntroduced && errors.length > failingErrors.length
161
+ ? ` (${errors.length - failingErrors.length} cumulative error(s) suppressed by --only-introduced)`
162
+ : '';
163
+ throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
145
164
  }
146
165
  outro('Lint passed with warnings');
147
166
  }
@@ -151,11 +170,15 @@ export function registerLint(program, { getProjectRoot, withErrorHandling }) {
151
170
  .command('lint [paths...]')
152
171
  .description('Lint engine changes against patch quality rules')
153
172
  .option('--since <git-rev>', 'Tag issues as [introduced] or [cumulative] based on whether the file changed since <git-rev> (e.g. HEAD, a branch, a SHA)')
173
+ .option('--only-introduced', 'Fail only on issues tagged [introduced] (requires --since). Cumulative errors still print but do not set a non-zero exit.')
154
174
  .action(withErrorHandling(async (paths, options) => {
155
175
  const lintOptions = {};
156
176
  if (options.since !== undefined) {
157
177
  lintOptions.since = options.since;
158
178
  }
179
+ if (options.onlyIntroduced !== undefined) {
180
+ lintOptions.onlyIntroduced = options.onlyIntroduced;
181
+ }
159
182
  await lintCommand(getProjectRoot(), paths, lintOptions);
160
183
  }));
161
184
  }
@@ -5,7 +5,7 @@ import type { RegisterOptions } from '../types/commands/index.js';
5
5
  * Registers a file in the appropriate build manifest.
6
6
  *
7
7
  * @param projectRoot - Root directory of the project
8
- * @param filePath - Path relative to engine/
8
+ * @param filePath - Path relative to engine/ (a leading `engine/` segment is stripped)
9
9
  * @param options - Command options
10
10
  */
11
11
  export declare function registerCommand(projectRoot: string, filePath: string, options?: RegisterOptions): Promise<void>;
@@ -6,11 +6,28 @@ import { InvalidArgumentError } from '../errors/base.js';
6
6
  import { pathExists } from '../utils/fs.js';
7
7
  import { info, intro, outro, success, warn } from '../utils/logger.js';
8
8
  import { pickDefined } from '../utils/options.js';
9
+ /**
10
+ * Strips a leading `engine/` segment (either separator flavour) from a
11
+ * user-supplied path so operators can pass either a repo-root-relative
12
+ * path (`engine/browser/base/content/foo.xhtml`) or an engine-relative
13
+ * path (`browser/base/content/foo.xhtml`). The engine-relative form is
14
+ * what the manifest writers expect; without this normalisation, the
15
+ * former failed with a misleading "File not found in engine" pointing
16
+ * at a doubled path like `engine/engine/browser/...` that operators
17
+ * had no way to spot from the error message alone.
18
+ */
19
+ function normalizeEngineRelativePath(filePath) {
20
+ if (filePath.startsWith('engine/'))
21
+ return filePath.slice('engine/'.length);
22
+ if (filePath.startsWith('engine\\'))
23
+ return filePath.slice('engine\\'.length);
24
+ return filePath;
25
+ }
9
26
  /**
10
27
  * Registers a file in the appropriate build manifest.
11
28
  *
12
29
  * @param projectRoot - Root directory of the project
13
- * @param filePath - Path relative to engine/
30
+ * @param filePath - Path relative to engine/ (a leading `engine/` segment is stripped)
14
31
  * @param options - Command options
15
32
  */
16
33
  export async function registerCommand(projectRoot, filePath, options = {}) {
@@ -28,17 +45,23 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
28
45
  throw new InvalidArgumentError('--after must be a non-empty substring without control characters or line terminators.', 'after');
29
46
  }
30
47
  }
48
+ // Accept either repo-root-relative (`engine/browser/...`) or
49
+ // engine-relative (`browser/...`) inputs — operators frequently paste
50
+ // the former from the output of tab completion or `git status`, and
51
+ // the mismatch used to produce a "File not found" error that named
52
+ // the original path with no hint that dropping `engine/` would fix it.
53
+ const engineRelativePath = normalizeEngineRelativePath(filePath);
31
54
  // Verify the file exists in engine/ (skip for dry-run)
32
55
  if (!options.dryRun) {
33
56
  const paths = getProjectPaths(projectRoot);
34
- const fullPath = join(paths.engine, filePath);
57
+ const fullPath = join(paths.engine, engineRelativePath);
35
58
  if (!(await pathExists(fullPath))) {
36
- throw new InvalidArgumentError(`File not found in engine: ${filePath}`, 'path');
59
+ throw new InvalidArgumentError(`File not found in engine: ${engineRelativePath}`, 'path');
37
60
  }
38
61
  }
39
- const result = await registerFile(projectRoot, filePath, options.dryRun, options.after);
62
+ const result = await registerFile(projectRoot, engineRelativePath, options.dryRun, options.after);
40
63
  if (options.dryRun) {
41
- info(`[dry-run] Would register ${filePath}`);
64
+ info(`[dry-run] Would register ${engineRelativePath}`);
42
65
  info(` manifest: ${result.manifest}`);
43
66
  info(` entry: ${result.entry}`);
44
67
  if (result.previousEntry) {
@@ -54,14 +77,14 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
54
77
  return;
55
78
  }
56
79
  if (result.skipped) {
57
- info(`Already registered: ${filePath} in ${result.manifest}`);
80
+ info(`Already registered: ${engineRelativePath} in ${result.manifest}`);
58
81
  }
59
82
  else {
60
83
  if (result.afterFallback) {
61
84
  warn(`--after target "${options.after}" not found, falling back to alphabetical order`);
62
85
  }
63
86
  const position = result.previousEntry ? ` (after ${result.previousEntry})` : '';
64
- success(`Registered ${filePath} in ${result.manifest}${position}`);
87
+ success(`Registered ${engineRelativePath} in ${result.manifest}${position}`);
65
88
  info("hint: Run 'fireforge build --ui' to make the new module available at runtime");
66
89
  }
67
90
  outro('Done');
@@ -1,9 +1,23 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
+ import type { RunOptions } from '../types/commands/index.js';
4
+ /**
5
+ * Exit code returned by smoke-run mode when the captured console stream
6
+ * produced one or more error lines that did NOT match the operator's
7
+ * allowlist.
8
+ */
9
+ export declare const SMOKE_EXIT_FAILURE: 12;
10
+ /**
11
+ * Exit code returned by smoke-run mode when the browser itself exited
12
+ * with a non-clean status before the smoke window elapsed — i.e. a
13
+ * launch-side failure we could NOT observe as a console error line
14
+ * (crash before console wiring, missing profile, etc.).
15
+ */
16
+ export declare const SMOKE_LAUNCH_FAILURE: 13;
3
17
  /**
4
18
  * Runs the run command to launch the built browser.
5
19
  * @param projectRoot - Root directory of the project
6
20
  */
7
- export declare function runCommand(projectRoot: string): Promise<void>;
21
+ export declare function runCommand(projectRoot: string, options?: RunOptions): Promise<void>;
8
22
  /** Registers the run command on the CLI program. */
9
23
  export declare function registerRun(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;