@extenshi/cli 0.21.0 → 0.23.0

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 (128) hide show
  1. package/README.md +4 -0
  2. package/dist/amo-source-detect.d.ts +18 -0
  3. package/dist/amo-source-detect.d.ts.map +1 -0
  4. package/dist/amo-source-detect.js +41 -0
  5. package/dist/amo-source-detect.js.map +1 -0
  6. package/dist/amo-source.d.ts +41 -0
  7. package/dist/amo-source.d.ts.map +1 -0
  8. package/dist/amo-source.js +240 -0
  9. package/dist/amo-source.js.map +1 -0
  10. package/dist/amowebstore-md.d.ts +38 -0
  11. package/dist/amowebstore-md.d.ts.map +1 -0
  12. package/dist/amowebstore-md.js +118 -0
  13. package/dist/amowebstore-md.js.map +1 -0
  14. package/dist/auto-evidence.d.ts +154 -0
  15. package/dist/auto-evidence.d.ts.map +1 -0
  16. package/dist/auto-evidence.js +297 -0
  17. package/dist/auto-evidence.js.map +1 -0
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +366 -11
  20. package/dist/cli.js.map +1 -1
  21. package/dist/domain.d.ts +40 -0
  22. package/dist/domain.d.ts.map +1 -0
  23. package/dist/domain.js +64 -0
  24. package/dist/domain.js.map +1 -0
  25. package/dist/edgewebstore-md.d.ts +26 -0
  26. package/dist/edgewebstore-md.d.ts.map +1 -0
  27. package/dist/edgewebstore-md.js +95 -0
  28. package/dist/edgewebstore-md.js.map +1 -0
  29. package/dist/evidence-ingest.d.ts +60 -0
  30. package/dist/evidence-ingest.d.ts.map +1 -0
  31. package/dist/evidence-ingest.js +141 -0
  32. package/dist/evidence-ingest.js.map +1 -0
  33. package/dist/extension-localization.d.ts +6 -1
  34. package/dist/extension-localization.d.ts.map +1 -1
  35. package/dist/extension-localization.js +4 -0
  36. package/dist/extension-localization.js.map +1 -1
  37. package/dist/firefox-rdp.d.ts +55 -0
  38. package/dist/firefox-rdp.d.ts.map +1 -0
  39. package/dist/firefox-rdp.js +203 -0
  40. package/dist/firefox-rdp.js.map +1 -0
  41. package/dist/gecko-id.d.ts +55 -0
  42. package/dist/gecko-id.d.ts.map +1 -0
  43. package/dist/gecko-id.js +132 -0
  44. package/dist/gecko-id.js.map +1 -0
  45. package/dist/generate-listing.d.ts +19 -8
  46. package/dist/generate-listing.d.ts.map +1 -1
  47. package/dist/generate-listing.js +66 -18
  48. package/dist/generate-listing.js.map +1 -1
  49. package/dist/icon.d.ts +18 -0
  50. package/dist/icon.d.ts.map +1 -1
  51. package/dist/icon.js +29 -20
  52. package/dist/icon.js.map +1 -1
  53. package/dist/landing-page.d.ts +22 -4
  54. package/dist/landing-page.d.ts.map +1 -1
  55. package/dist/landing-page.js +14 -11
  56. package/dist/landing-page.js.map +1 -1
  57. package/dist/landing-sections.d.ts +332 -0
  58. package/dist/landing-sections.d.ts.map +1 -0
  59. package/dist/landing-sections.js +1071 -0
  60. package/dist/landing-sections.js.map +1 -0
  61. package/dist/listing.d.ts +15 -0
  62. package/dist/listing.d.ts.map +1 -1
  63. package/dist/listing.js +1 -1
  64. package/dist/listing.js.map +1 -1
  65. package/dist/media.d.ts +42 -0
  66. package/dist/media.d.ts.map +1 -0
  67. package/dist/media.js +96 -0
  68. package/dist/media.js.map +1 -0
  69. package/dist/package.d.ts +163 -0
  70. package/dist/package.d.ts.map +1 -0
  71. package/dist/package.js +500 -0
  72. package/dist/package.js.map +1 -0
  73. package/dist/page.d.ts +110 -21
  74. package/dist/page.d.ts.map +1 -1
  75. package/dist/page.js +240 -10
  76. package/dist/page.js.map +1 -1
  77. package/dist/pay-contract.d.ts +42 -11
  78. package/dist/pay-contract.d.ts.map +1 -1
  79. package/dist/pay-contract.js +31 -0
  80. package/dist/pay-contract.js.map +1 -1
  81. package/dist/pay-test-cycle.d.ts +40 -0
  82. package/dist/pay-test-cycle.d.ts.map +1 -0
  83. package/dist/pay-test-cycle.js +207 -0
  84. package/dist/pay-test-cycle.js.map +1 -0
  85. package/dist/pay.d.ts +1 -1
  86. package/dist/pay.d.ts.map +1 -1
  87. package/dist/pay.js +1 -0
  88. package/dist/pay.js.map +1 -1
  89. package/dist/project.d.ts +69 -1
  90. package/dist/project.d.ts.map +1 -1
  91. package/dist/project.js +363 -54
  92. package/dist/project.js.map +1 -1
  93. package/dist/publish.d.ts +33 -0
  94. package/dist/publish.d.ts.map +1 -1
  95. package/dist/publish.js +113 -2
  96. package/dist/publish.js.map +1 -1
  97. package/dist/review-checks.d.ts +13 -0
  98. package/dist/review-checks.d.ts.map +1 -1
  99. package/dist/review-checks.js.map +1 -1
  100. package/dist/review-risk.d.ts +10 -1
  101. package/dist/review-risk.d.ts.map +1 -1
  102. package/dist/review-risk.js +1 -0
  103. package/dist/review-risk.js.map +1 -1
  104. package/dist/scan.d.ts +3 -2
  105. package/dist/scan.d.ts.map +1 -1
  106. package/dist/scan.js +4 -2
  107. package/dist/scan.js.map +1 -1
  108. package/dist/screenshots-scenes.d.ts +1383 -0
  109. package/dist/screenshots-scenes.d.ts.map +1 -0
  110. package/dist/screenshots-scenes.js +276 -0
  111. package/dist/screenshots-scenes.js.map +1 -0
  112. package/dist/screenshots.d.ts +143 -0
  113. package/dist/screenshots.d.ts.map +1 -0
  114. package/dist/screenshots.js +558 -0
  115. package/dist/screenshots.js.map +1 -0
  116. package/dist/smoke.d.ts +81 -0
  117. package/dist/smoke.d.ts.map +1 -0
  118. package/dist/smoke.js +491 -0
  119. package/dist/smoke.js.map +1 -0
  120. package/dist/store-assets.d.ts +139 -0
  121. package/dist/store-assets.d.ts.map +1 -0
  122. package/dist/store-assets.js +582 -0
  123. package/dist/store-assets.js.map +1 -0
  124. package/dist/zip.d.ts +57 -0
  125. package/dist/zip.d.ts.map +1 -0
  126. package/dist/zip.js +134 -0
  127. package/dist/zip.js.map +1 -0
  128. package/package.json +7 -6
package/dist/cli.js CHANGED
@@ -32,9 +32,13 @@ import { createRequire } from 'node:module';
32
32
  import os from 'node:os';
33
33
  import * as readline from 'node:readline/promises';
34
34
  import { program } from 'commander';
35
+ import { runAmoSource } from './amo-source.js';
36
+ import { assertEvidenceBinding, evidenceArtifact, evidenceBrowsers, generatedListingEvidence, listingEvidence, permissionsEvidence, recordAutoEvidence, recordScreenshotsEvidence, scanEvidence, screenshotsEvidence, screenshotsEvidencePlan, smokeEvidence, } from './auto-evidence.js';
35
37
  import { checkAutoUpdate } from './auto-update.js';
36
38
  import { CONFIG_PATH, loadConfig, readTelemetryPreference, saveConfig, saveTelemetryPreference, } from './config.js';
37
- import { isExpectedError } from './expected-error.js';
39
+ import { runDomainAdd, runDomainRemove, runDomainStatus, runDomainVerify } from './domain.js';
40
+ import { registerEvidenceIngest } from './evidence-ingest.js';
41
+ import { InvalidInputError, isExpectedError } from './expected-error.js';
38
42
  import { runGenerateListing } from './generate-listing.js';
39
43
  import { runIconPreview } from './icon.js';
40
44
  import { applyLocalization, checkLocalization, prepareLocalization } from './localize.js';
@@ -42,13 +46,20 @@ import { initializeLocalization } from './localize-init.js';
42
46
  import { makeLoginBff } from './login-bff.js';
43
47
  import { runBrowserLogin } from './login-flow.js';
44
48
  import { openBrowser, openLoopback, sleep } from './login-runtime.js';
45
- import { runPageGenerate, runPagePublish, runPageRegister, runPageUnpublish } from './page.js';
49
+ import { runMediaUpload } from './media.js';
50
+ import { parsePackageBrowsers, resolvePackagedArtifact, runPackage } from './package.js';
51
+ import { runPageDraft, runPageGenerate, runPagePreview, runPagePublish, runPageRegister, runPageRollback, runPageUnpublish, runPageValidate, runPageVersions, } from './page.js';
46
52
  import { registerPayCommands } from './pay.js';
53
+ import { registerPayTestCycle } from './pay-test-cycle.js';
47
54
  import { registerProjectCommands } from './project.js';
48
55
  import { runPublish } from './publish.js';
56
+ import { runListingOnlyChecks } from './review-checks.js';
49
57
  import { runReviewRisk } from './review-risk.js';
50
58
  import { parseRefs, parseStore, runRisk } from './risk.js';
51
59
  import { runScan } from './scan.js';
60
+ import { runScreenshotsCommand } from './screenshots.js';
61
+ import { ENGINE_BROWSER, parseSmokeEngine, renderSmoke, runSmoke } from './smoke.js';
62
+ import { runStoreAssets } from './store-assets.js';
52
63
  import { captureError, captureEvent, classifyError, flagsFromArgv, flushTelemetry, initTelemetry, } from './telemetry.js';
53
64
  import { defaultConsentDeps, envAnswerSource, maybeAskForTelemetryConsent } from './telemetry-consent.js';
54
65
  const MISSING_KEY_HELP = "Looks like you don't have an Extenshi API key set up yet — let's fix that.\n" +
@@ -103,7 +114,8 @@ async function runCommand(command, action) {
103
114
  }
104
115
  }
105
116
  registerProjectCommands(program);
106
- registerPayCommands(program);
117
+ registerPayTestCycle(registerPayCommands(program));
118
+ registerEvidenceIngest(program, runCommand);
107
119
  program
108
120
  .name('extenshi')
109
121
  .description('Pre-publish security scanner for browser extensions')
@@ -125,12 +137,21 @@ program
125
137
  .option('--listing <file>', 'JSON file with your store listing text (name / shortDescription / description, optionally per locale). ' +
126
138
  'Without it the listing checks still run, but only over the name and short description the package carries — ' +
127
139
  'the full description lives in the dashboard, never in the artifact')
140
+ .option('--evidence', 'Record the result as `scan` release evidence for the project bound here (extenshi project bind), bound to this exact package')
141
+ .option('--evidence-browser <list>', 'Store(s) the evidence is for: chrome,edge,firefox (default: read from the extenshi-packages.json next to the zip)')
128
142
  .action(async (artifact, opts) => {
129
143
  await runCommand('scan', async () => {
130
144
  const config = await loadConfig(opts.apiUrl);
131
145
  if (!config.apiKey) {
132
146
  throw new Error(MISSING_KEY_HELP);
133
147
  }
148
+ // Everything --evidence needs is settled before a credit is spent.
149
+ let evidence;
150
+ if (opts.evidence) {
151
+ assertEvidenceBinding();
152
+ const { digest } = await evidenceArtifact(artifact);
153
+ evidence = { digest, browsers: evidenceBrowsers(artifact, digest, opts.evidenceBrowser) };
154
+ }
134
155
  // Auto-update check (fail-soft — never blocks scan)
135
156
  await checkAutoUpdate();
136
157
  let reportFormat;
@@ -161,7 +182,7 @@ program
161
182
  // explicitly disabled (--no-open), and not in CI (no browser/display —
162
183
  // the file is still written so CI can archive it as an artifact).
163
184
  const openReport = format === 'html' && (opts.open ?? true) && !process.env.CI;
164
- await runScan({
185
+ const body = await runScan({
165
186
  artifactPath: artifact,
166
187
  apiKey: config.apiKey,
167
188
  apiUrl: config.apiUrl,
@@ -180,6 +201,8 @@ program
180
201
  bffUrl: config.bffUrl,
181
202
  listingPath: opts.listing,
182
203
  });
204
+ if (evidence)
205
+ await recordAutoEvidence(scanEvidence({ artifactDigest: evidence.digest, browsers: evidence.browsers, body }));
183
206
  });
184
207
  });
185
208
  program
@@ -191,6 +214,13 @@ program
191
214
  .option('--stores <list>', 'Comma-separated stores: chrome,firefox,edge (default: every store with complete credentials)')
192
215
  .option('--firefox-artifact <path>', 'Separate .xpi for Firefox (default: the main artifact)')
193
216
  .option('--release-notes <text>', 'Release notes passed to stores that accept them')
217
+ .option('--listing <file>', 'Firefox: AMO listing (JSON, or the AMO.md `generate-listing --store firefox` writes, placeholders filled); ' +
218
+ 'creates the add-on on first submission or edits its listing')
219
+ .option('--source <archive>', 'Firefox: reviewer source archive (.zip/.tar.gz) for the new version (needs --listing)')
220
+ .option('--screenshots <dir>', 'Firefox: AMO previews in file-name order: an image folder or `extenshi screenshots` output (its firefox/<locale>/ set); needs --listing')
221
+ .option('--allow-new-addon', 'Firefox: allow creating a NEW AMO add-on, whose id is permanent (in a bound project the owner-decided amo.addonId governs this)')
222
+ .option('--addon-id <id>', 'Firefox: the AMO add-on id ({GUID} or name@domain), stated explicitly; required with --allow-new-addon outside a bound project')
223
+ .option('--replace-screenshots', 'Firefox: replace the previews of an existing AMO add-on')
194
224
  .option('--validate', 'Only validate store credentials, publish nothing')
195
225
  .option('--no-wait', 'Edge: return after commit without polling the operation to a terminal status')
196
226
  .option('--extension-id <id>', 'Numeric catalog ID — checks publish-beta access against this specific extension')
@@ -227,6 +257,12 @@ program
227
257
  bffUrl: config.bffUrl,
228
258
  apiKey: config.apiKey,
229
259
  extensionId,
260
+ listingPath: opts.listing,
261
+ sourcePath: opts.source,
262
+ screenshotsDir: opts.screenshots,
263
+ allowNewAddon: opts.allowNewAddon ?? false,
264
+ addonId: opts.addonId,
265
+ replaceScreenshots: opts.replaceScreenshots ?? false,
230
266
  });
231
267
  });
232
268
  });
@@ -245,8 +281,20 @@ program
245
281
  'The package already supplies the localized name and short description; this adds the full description, ' +
246
282
  'which exists only in the developer dashboard.')
247
283
  .option('--json', 'Output raw JSON findings')
284
+ .option('--evidence', 'Record `permissions` evidence (bound to the packaged manifest) and, with --listing, `listing` evidence per locale ' +
285
+ '(bound to that file — pass the same file to release prepare --listing) for the project bound here')
286
+ .option('--evidence-browser <list>', 'Store(s) the evidence is for: chrome,edge,firefox (default: --store, else the extenshi-packages.json next to the zip)')
248
287
  .action(async (artifact, opts) => {
249
288
  await runCommand('review-risk', async () => {
289
+ let evidence;
290
+ if (opts.evidence) {
291
+ assertEvidenceBinding();
292
+ const { digest } = await evidenceArtifact(artifact);
293
+ evidence = {
294
+ digest,
295
+ browsers: evidenceBrowsers(artifact, digest, opts.evidenceBrowser ?? opts.store),
296
+ };
297
+ }
250
298
  // Default to CHROME — this tool is Chrome-Web-Store-centric, and pinning
251
299
  // the store avoids diffing against another store's snapshot.
252
300
  const storeMap = {
@@ -262,7 +310,7 @@ program
262
310
  // manifest diff only uses the key if the catalog read path is gated.
263
311
  const config = await loadConfig();
264
312
  await checkAutoUpdate();
265
- await runReviewRisk({
313
+ const result = await runReviewRisk({
266
314
  artifactPath: artifact,
267
315
  extensionId: opts.extensionId,
268
316
  bffUrl: (opts.catalogUrl ?? 'https://bff.extenshi.io').replace(/\/$/, ''),
@@ -271,6 +319,27 @@ program
271
319
  listingPath: opts.listing,
272
320
  outputJson: opts.json ?? false,
273
321
  });
322
+ if (evidence) {
323
+ const listingFindings = runListingOnlyChecks(result.listing);
324
+ const records = permissionsEvidence({
325
+ artifactDigest: evidence.digest,
326
+ manifest: result.manifest,
327
+ browsers: evidence.browsers,
328
+ findings: result.summary.findings,
329
+ listingCheckIds: new Set(listingFindings.map((f) => f.check)),
330
+ });
331
+ if (opts.listing)
332
+ records.push(...listingEvidence({
333
+ artifactDigest: evidence.digest,
334
+ listingBytes: await readFile(opts.listing),
335
+ listing: result.listing,
336
+ browsers: evidence.browsers,
337
+ listingFindings,
338
+ }));
339
+ else
340
+ process.stderr.write('note: no listing evidence — it binds to a listing file; pass --listing <file> (the one you give release prepare --listing)\n');
341
+ await recordAutoEvidence(records);
342
+ }
274
343
  });
275
344
  });
276
345
  const localize = program
@@ -322,21 +391,140 @@ localize
322
391
  });
323
392
  program
324
393
  .command('generate-listing <artifact>')
325
- .description('Generate CHROMEWEBSTORE.md from a packaged (.zip/.crx/.xpi) or unpacked extension: ' +
394
+ .description('Generate a store listing draft from a packaged (.zip/.crx/.xpi) or unpacked extension: ' +
326
395
  'listing copy, permission justifications, single purpose and privacy-practices questions. ' +
327
396
  'Assembles the existing justification and privacy-disclosure checklists — does not write ' +
328
- 'anything into Chrome Web Store. The file is the same listing draft `review-risk --listing` accepts.')
329
- .option('--output <path>', 'Where to write the markdown (default: CHROMEWEBSTORE.md in the current directory)')
397
+ 'anything into any store dashboard. --store picks CHROMEWEBSTORE.md / EDGE.md / AMO.md ' +
398
+ '(default chrome); all three are the same listing draft `review-risk --listing` accepts.')
399
+ .option('--store <store>', 'Which store to draft for: chrome (default), edge, or firefox', 'chrome')
400
+ .option('--output <path>', 'Where to write the markdown (default: the per-store filename in the current directory)')
330
401
  .option('--listing <file>', 'JSON file with your store listing text (name / shortDescription / description). ' +
331
402
  'Overlays the package so the generated markdown can include the full dashboard description.')
403
+ .option('--evidence', 'After writing the file, run the listing rules over it and record `listing` evidence per locale, bound to the ' +
404
+ 'generated file (pass it to release prepare --listing). Needs a packaged zip, not a folder.')
405
+ .option('--evidence-browser <list>', 'Store(s) the evidence is for: chrome,edge,firefox (default: the --store the draft is written for)')
332
406
  .action(async (artifact, opts) => {
333
407
  await runCommand('generate-listing', async () => {
408
+ const store = (opts.store ?? 'chrome').trim().toLowerCase();
409
+ if (store !== 'chrome' && store !== 'edge' && store !== 'firefox') {
410
+ throw new Error(`--store must be chrome, edge or firefox (got "${opts.store}")`);
411
+ }
412
+ // The draft is one store's listing, so its evidence is that store's by default.
413
+ let evidence;
414
+ if (opts.evidence) {
415
+ assertEvidenceBinding();
416
+ const { bytes, digest } = await evidenceArtifact(artifact);
417
+ evidence = {
418
+ bytes,
419
+ digest,
420
+ browsers: evidenceBrowsers(artifact, digest, opts.evidenceBrowser ?? store),
421
+ };
422
+ }
334
423
  await checkAutoUpdate();
335
- await runGenerateListing({
424
+ const outputPath = await runGenerateListing({
336
425
  artifactPath: artifact,
337
426
  outputPath: opts.output,
338
427
  listingPath: opts.listing,
428
+ store,
339
429
  });
430
+ if (evidence)
431
+ await recordAutoEvidence(await generatedListingEvidence({
432
+ artifactBytes: evidence.bytes,
433
+ artifactDigest: evidence.digest,
434
+ draftPath: outputPath,
435
+ browsers: evidence.browsers,
436
+ }));
437
+ });
438
+ });
439
+ program
440
+ .command('package [dir]')
441
+ .description('Build one reproducible store zip per browser from an unpacked build directory: sorted entries, fixed ' +
442
+ 'timestamps, no file modes or extra metadata — the same bytes on every machine and timezone. Prints the ' +
443
+ 'sha256 of each zip and writes extenshi-packages.json, which release prepare, test smoke and evidence ' +
444
+ 'ingest accept directly. Never modifies the source directory. Free, offline.')
445
+ .option('--browser <list>', 'Comma-separated: chrome,edge,firefox (default: chrome)')
446
+ .option('--chrome-dir <dir>', 'Build directory for Chrome (overrides [dir])')
447
+ .option('--edge-dir <dir>', 'Build directory for Edge (overrides [dir])')
448
+ .option('--firefox-dir <dir>', 'Build directory for Firefox (overrides [dir])')
449
+ .option('--out <dir>', 'Output directory (default: .extenshi/packages — ignored by the dirty-tree digest release prepare records)')
450
+ .option('--exclude <patterns...>', 'Leave files out (gitignore-style globs, comma-separated or repeated). Dot-entries and node_modules are always left out.')
451
+ .option('--gecko-id <id>', 'Firefox: write this add-on ID into the PACKAGED manifest. A missing or placeholder ID is refused otherwise.')
452
+ .option('--fix-war', 'Replace wildcard web_accessible_resources with the explicit list of matching files, in the packaged copy only')
453
+ .option('--json', 'Print the packages manifest as JSON')
454
+ .action(async (dir, opts) => {
455
+ await runCommand('package', async () => {
456
+ runPackage({
457
+ dir,
458
+ dirs: { chrome: opts.chromeDir, edge: opts.edgeDir, firefox: opts.firefoxDir },
459
+ browsers: parsePackageBrowsers(opts.browser),
460
+ outDir: opts.out ?? '.extenshi/packages',
461
+ exclude: opts.exclude,
462
+ geckoId: opts.geckoId,
463
+ fixWar: opts.fixWar ?? false,
464
+ json: opts.json ?? false,
465
+ cliVersion: pkg.version,
466
+ });
467
+ });
468
+ });
469
+ const test = program.command('test').description('Test packaged extensions in real browsers');
470
+ test
471
+ .command('smoke <package>')
472
+ .description('Install the EXACT package (a zip, or extenshi-packages.json + --browser) in a real browser and check that it ' +
473
+ 'loads, its service worker/background starts, the browser reports no manifest errors, and its popup/options/' +
474
+ 'side-panel pages open without console errors. chromium/edge use Playwright (install it in your project: ' +
475
+ 'npm i -D playwright && npx playwright install chromium); firefox needs only a Firefox binary. Exit 1 on failure.')
476
+ .requiredOption('--browser <browser>', 'chromium (alias chrome) | edge | firefox')
477
+ .option('--json', 'Print the result as JSON')
478
+ .option('--timeout <ms>', 'Budget per wait (service worker start, page load)', '15000')
479
+ .option('--headed', 'Show the browser window')
480
+ .option('--firefox-bin <path>', 'Firefox binary (default: FIREFOX_BIN, standard install locations, PATH)')
481
+ .option('--browser-path <path>', 'Chromium/Edge executable instead of the Playwright channel default')
482
+ .option('--evidence', 'Record the result as `installed` release evidence for the project bound here (extenshi project bind)')
483
+ .action(async (packagePath, opts) => {
484
+ await runCommand('test smoke', async () => {
485
+ const engine = parseSmokeEngine(opts.browser);
486
+ if (opts.evidence)
487
+ assertEvidenceBinding();
488
+ const timeoutMs = Number.parseInt(opts.timeout, 10);
489
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1000)
490
+ throw new InvalidInputError('--timeout must be ≥ 1000 ms');
491
+ const { artifactPath } = resolvePackagedArtifact(packagePath, ENGINE_BROWSER[engine]);
492
+ const result = await runSmoke({
493
+ artifactPath,
494
+ engine,
495
+ timeoutMs,
496
+ headed: opts.headed ?? false,
497
+ firefoxBinary: opts.firefoxBin,
498
+ browserPath: opts.browserPath,
499
+ });
500
+ process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}\n` : `${renderSmoke(result)}\n`);
501
+ if (opts.evidence)
502
+ await recordAutoEvidence(smokeEvidence({ browser: ENGINE_BROWSER[engine], result }));
503
+ if (result.status === 'failed')
504
+ process.exitCode = 1;
505
+ });
506
+ });
507
+ program
508
+ .command('amo-source <project-dir>')
509
+ .description('Produce the AMO source-code archive + BUILD.md reviewer instructions for a Firefox add-on ' +
510
+ 'project directory. Excludes node_modules/dist/.git and dotenv secrets (.env*); includes the ' +
511
+ 'lockfile. Refuses on an uncommitted git working tree (the archive should match a real commit). ' +
512
+ 'Flags vendored minified files with no reproducible-build provenance as blockers. Does not talk ' +
513
+ 'to the AMO API — upload the archive by hand, or via the separate AMO API submission path.')
514
+ .option('--out <path>', 'Where to write the archive (default: amo-source.zip in the current directory)')
515
+ .action(async (projectDir, opts) => {
516
+ await runCommand('amo-source', async () => {
517
+ await checkAutoUpdate();
518
+ const result = await runAmoSource({ projectDir, outputPath: opts.out });
519
+ console.log(`Wrote ${result.zipPath} (${result.fileCount} files)`);
520
+ console.log(`Wrote ${result.buildMdPath}`);
521
+ if (result.blockers.length > 0) {
522
+ console.log(`\n${result.blockers.length} file(s) need a source-code submission before this can go to AMO:`);
523
+ for (const f of result.blockers)
524
+ console.log(` - ${f.path}`);
525
+ console.log('See BUILD.md for what to do about each one.');
526
+ process.exitCode = 1;
527
+ }
340
528
  });
341
529
  });
342
530
  program
@@ -399,6 +587,66 @@ icon
399
587
  await runIconPreview(iconPath, opts, pkg.version);
400
588
  });
401
589
  });
590
+ icon
591
+ .command('store-assets <source>')
592
+ .description('Render the store LISTING artwork from one .svg / .png source: Chrome Web Store icon 128 (96 px artwork + ' +
593
+ '16 px padding), 440×280 promo tile and 1400×560 marquee; Edge Add-ons 300×300 logo and 440×280 / ' +
594
+ '1400×560 tiles; AMO 128 / 64 / 32 icons. Offline (WebAssembly renderer), no API key.')
595
+ .option('--store <stores>', 'Comma-separated: chrome, edge, firefox (default: all)')
596
+ .option('--out <dir>', 'Output directory (default: ./store-assets)')
597
+ .option('--background <color>', 'Promo tile / marquee background (default: a light tint of the icon colour)')
598
+ .option('--icon-background <color>', 'Fill behind icons and the Edge logo (default: transparent)')
599
+ .option('--promo <file>', 'Full-bleed artwork (.svg/.png) for the 440×280 tiles instead of composing the icon')
600
+ .option('--marquee <file>', 'Full-bleed artwork (.svg/.png) for the 1400×560 tiles instead of composing the icon')
601
+ .option('--font <files...>', 'Font files for text in SVG sources (system fonts are matched by name otherwise)')
602
+ .option('--json', 'Print the result as JSON')
603
+ .action(async (source, opts) => {
604
+ await runCommand('icon store-assets', async () => {
605
+ await runStoreAssets(source, opts);
606
+ });
607
+ });
608
+ program
609
+ .command('screenshots <extension>')
610
+ .description('Store screenshots without a human: load an unpacked build (or .zip) in Chromium, run the declarative ' +
611
+ 'scenes in --scenes per locale (the UI language is switched and verified through chrome.i18n), and ' +
612
+ 'write each store size — Chrome/Edge/AMO 1280×800 (or --small) — as <out>/<store>/<locale>/NN-<scene>-WxH.png ' +
613
+ 'plus screenshots.json. Needs Playwright with Chromium in the project (npm i -D playwright && npx playwright ' +
614
+ 'install chromium). Scenes format: https://docs.extenshi.io/developers/cli#extenshi-screenshots')
615
+ .requiredOption('--scenes <scenes.json>', 'Declarative scenes file')
616
+ .option('--locales <codes>', 'Comma-separated _locales codes (default: every locale the extension ships)')
617
+ .option('--store <stores>', 'Comma-separated: chrome, edge, firefox (default: all)')
618
+ .option('--out <dir>', 'Output directory (default: ./store-screenshots)')
619
+ .option('--small', "Each store's smaller size: Chrome 640×400, Edge 640×480, AMO 640×400")
620
+ .option('--headed', 'Show the browser window (debugging scenes)')
621
+ .option('--json', 'Print the result as JSON')
622
+ .option('--evidence', 'Record `screenshots` evidence per shot locale and store for the project bound here (extenshi project bind), ' +
623
+ "bound to this zip and to --listing, with that store's PNGs attached (private storage). Needs a packaged zip, not a folder.")
624
+ .option('--listing <file>', 'With --evidence: the listing revision these screenshots illustrate — the file you give release prepare --listing')
625
+ .option('--evidence-browser <list>', 'Store(s) the evidence is for: chrome,edge,firefox (default: --store, else the extenshi-packages.json next to the zip); each must be shot')
626
+ .action(async (extension, opts) => {
627
+ await runCommand('screenshots', async () => {
628
+ // Settled before Chromium starts, so a run that cannot be recorded is not shot.
629
+ const evidence = opts.evidence ? await screenshotsEvidencePlan({ extension, ...opts }) : undefined;
630
+ const result = await runScreenshotsCommand(extension, opts);
631
+ if (evidence)
632
+ await recordScreenshotsEvidence(screenshotsEvidence({ ...evidence, result }), result.files);
633
+ });
634
+ });
635
+ program
636
+ .command('media')
637
+ .description("The project's PUBLIC media store (logo, screenshots) used by hosted pages")
638
+ .command('upload <files...>')
639
+ .description("Upload PNG/JPEG/WebP images (≤2 MB each; .svg is rasterized to PNG locally) to the project's public media " +
640
+ 'store and print their stable URLs — the same store Dojo uploads use, accepted by `page publish` as ' +
641
+ 'screenshots[].url / logoUrl. The server strips metadata and enforces a per-project quota. Requires an ' +
642
+ 'API key (extenshi login) with hosted.write.')
643
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
644
+ .option('--svg-width <px>', 'Width to rasterize .svg files at (default 1024)')
645
+ .action(async (files, opts) => {
646
+ await runCommand('media upload', async () => {
647
+ await runMediaUpload(files, opts);
648
+ });
649
+ });
402
650
  const page = program
403
651
  .command('page')
404
652
  .description('Landing page (homepage) for your extension: generate static HTML offline, register its URL');
@@ -430,19 +678,84 @@ page
430
678
  });
431
679
  page
432
680
  .command('publish')
433
- .description('Host the landing page on extenshi.io (page.extenshi.io/<code>, or /landing on your active custom ' +
434
- 'domain) from the same JSON config as `page generate`. Republishing keeps the URL. Also registers it ' +
681
+ .description('Host the landing page on extenshi.io (page.extenshi.io/<code>, or the root of your active custom ' +
682
+ 'domain) from the same JSON config as `page generate` (v1, or schema v2 from `page draft`). Republishing keeps the URL. Also registers it ' +
435
683
  'as the project homepage unless --no-homepage. Screenshots must be https store/Dojo images. ' +
436
684
  'Requires an API key (extenshi login).')
437
685
  .requiredOption('--project <id>', 'Dojo project ID (UUID)')
438
686
  .requiredOption('--config <landing.json>', 'Landing config JSON')
439
687
  .option('--logo <file>', 'Logo: .svg or .png/.jpg/.webp/.gif (≤256 KiB)')
440
688
  .option('--no-homepage', 'Do not register the URL as the project homepage')
689
+ .option('--upload-local', "Upload local screenshot / logoUrl paths (relative to the config file) to the project's public media store first")
441
690
  .action(async (opts) => {
442
691
  await runCommand('page publish', async () => {
443
692
  await runPagePublish(opts);
444
693
  });
445
694
  });
695
+ page
696
+ .command('draft')
697
+ .description('Draft a full homepage (schema v2: sections, theme, SEO, locales) from the project state on extenshi.io — ' +
698
+ 'manifest and its translations, the published page, project media, Pay offers, hosted privacy/uninstall/' +
699
+ 'terms pages — and write it as a config for `page preview` / `page publish`. Prints where each field ' +
700
+ 'came from and what only you can add. Writes nothing on the server. Requires an API key.')
701
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
702
+ .option('--out <landing.json>', 'Where to write the config', 'landing.json')
703
+ .option('--chrome-url <url>', 'Chrome Web Store listing URL (when the project does not know it yet)')
704
+ .option('--firefox-url <url>', 'Firefox Add-ons listing URL')
705
+ .option('--edge-url <url>', 'Edge Add-ons listing URL')
706
+ .action(async (opts) => {
707
+ await runCommand('page draft', async () => {
708
+ await runPageDraft(opts);
709
+ });
710
+ });
711
+ page
712
+ .command('preview')
713
+ .description('Validate and render a config exactly as `page publish` would (image hosts, store locales, Pay offer ' +
714
+ 'prices, canonical URL) without publishing. Writes the page to --out; exits non-zero with every problem ' +
715
+ 'and its fix when publish would refuse it. --screenshot saves a full-page PNG (needs Playwright + ' +
716
+ 'Chromium, as for `extenshi screenshots`). Requires an API key.')
717
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
718
+ .requiredOption('--config <landing.json>', 'Landing config JSON')
719
+ .option('--locale <locale>', 'Which locale page to render (default: the default locale)')
720
+ .option('--out <path>', 'Where to write the rendered HTML', 'preview.html')
721
+ .option('--screenshot <png>', 'Also save a full-page screenshot')
722
+ .option('--logo <file>', 'Logo: .svg or .png/.jpg/.webp/.gif (≤256 KiB)')
723
+ .action(async (opts) => {
724
+ await runCommand('page preview', async () => {
725
+ await runPagePreview(opts);
726
+ });
727
+ });
728
+ page
729
+ .command('validate')
730
+ .description('Check a landing config against the shared rules offline (sections, attribution, fonts, colours, locales, ' +
731
+ 'SEO) and print every problem with its fix. Hosted-only rules need `page preview`. No API key.')
732
+ .requiredOption('--config <landing.json>', 'Landing config JSON')
733
+ .action(async (opts) => {
734
+ await runCommand('page validate', async () => {
735
+ await runPageValidate(opts);
736
+ });
737
+ });
738
+ page
739
+ .command('versions')
740
+ .description('Version history of the hosted landing page (newest first; which one is live, why each exists).')
741
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
742
+ .action(async (opts) => {
743
+ await runCommand('page versions', async () => {
744
+ await runPageVersions(opts);
745
+ });
746
+ });
747
+ page
748
+ .command('rollback')
749
+ .description('Restore an earlier version of the hosted landing page. It is published as a new version (history stays ' +
750
+ "append-only), re-rendered with today's URL and Pay prices.")
751
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
752
+ .requiredOption('--version <n>', 'Version number to restore (see `extenshi page versions`)')
753
+ .option('--no-homepage', 'Do not register the URL as the project homepage')
754
+ .action(async (opts) => {
755
+ await runCommand('page rollback', async () => {
756
+ await runPageRollback(opts);
757
+ });
758
+ });
446
759
  page
447
760
  .command('unpublish')
448
761
  .description('Take the hosted landing page offline (404). The URL returns on the next publish; a homepage ' +
@@ -453,6 +766,48 @@ page
453
766
  await runPageUnpublish(opts);
454
767
  });
455
768
  });
769
+ const domain = program
770
+ .command('domain')
771
+ .description('Custom domain for the project homepage (served at the domain root) and every hosted page — Pro, one ' +
772
+ 'hostname per project. Prints the exact DNS records; nothing is served until ownership (TXT) and the ' +
773
+ 'HTTPS certificate are verified.');
774
+ domain
775
+ .command('add')
776
+ .description('Add www.example.com (or the apex) and print the CNAME + TXT records to set')
777
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
778
+ .requiredOption('--hostname <host>', 'e.g. www.example.com')
779
+ .action(async (opts) => {
780
+ await runCommand('domain add', async () => {
781
+ await runDomainAdd(opts);
782
+ });
783
+ });
784
+ domain
785
+ .command('status')
786
+ .description('Show the domain, its verification state, the DNS records and the next step')
787
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
788
+ .action(async (opts) => {
789
+ await runCommand('domain status', async () => {
790
+ await runDomainStatus(opts);
791
+ });
792
+ });
793
+ domain
794
+ .command('verify')
795
+ .description('Check ownership and the certificate now; on ACTIVE the homepage moves to the domain root and HOMEPAGE_URL follows')
796
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
797
+ .action(async (opts) => {
798
+ await runCommand('domain verify', async () => {
799
+ await runDomainVerify(opts);
800
+ });
801
+ });
802
+ domain
803
+ .command('remove')
804
+ .description('Stop serving the custom domain; the homepage returns to page.extenshi.io')
805
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
806
+ .action(async (opts) => {
807
+ await runCommand('domain remove', async () => {
808
+ await runDomainRemove(opts);
809
+ });
810
+ });
456
811
  /**
457
812
  * Change or inspect the answer to the first-run telemetry question.
458
813
  *