@extenshi/cli 0.21.0 → 0.22.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 (107) 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 +100 -0
  15. package/dist/auto-evidence.d.ts.map +1 -0
  16. package/dist/auto-evidence.js +197 -0
  17. package/dist/auto-evidence.js.map +1 -0
  18. package/dist/cli.js +265 -8
  19. package/dist/cli.js.map +1 -1
  20. package/dist/edgewebstore-md.d.ts +26 -0
  21. package/dist/edgewebstore-md.d.ts.map +1 -0
  22. package/dist/edgewebstore-md.js +95 -0
  23. package/dist/edgewebstore-md.js.map +1 -0
  24. package/dist/evidence-ingest.d.ts +60 -0
  25. package/dist/evidence-ingest.d.ts.map +1 -0
  26. package/dist/evidence-ingest.js +141 -0
  27. package/dist/evidence-ingest.js.map +1 -0
  28. package/dist/firefox-rdp.d.ts +55 -0
  29. package/dist/firefox-rdp.d.ts.map +1 -0
  30. package/dist/firefox-rdp.js +203 -0
  31. package/dist/firefox-rdp.js.map +1 -0
  32. package/dist/gecko-id.d.ts +55 -0
  33. package/dist/gecko-id.d.ts.map +1 -0
  34. package/dist/gecko-id.js +132 -0
  35. package/dist/gecko-id.js.map +1 -0
  36. package/dist/generate-listing.d.ts +19 -8
  37. package/dist/generate-listing.d.ts.map +1 -1
  38. package/dist/generate-listing.js +66 -18
  39. package/dist/generate-listing.js.map +1 -1
  40. package/dist/icon.d.ts +18 -0
  41. package/dist/icon.d.ts.map +1 -1
  42. package/dist/icon.js +29 -20
  43. package/dist/icon.js.map +1 -1
  44. package/dist/media.d.ts +42 -0
  45. package/dist/media.d.ts.map +1 -0
  46. package/dist/media.js +96 -0
  47. package/dist/media.js.map +1 -0
  48. package/dist/package.d.ts +163 -0
  49. package/dist/package.d.ts.map +1 -0
  50. package/dist/package.js +500 -0
  51. package/dist/package.js.map +1 -0
  52. package/dist/page.d.ts +10 -0
  53. package/dist/page.d.ts.map +1 -1
  54. package/dist/page.js +32 -2
  55. package/dist/page.js.map +1 -1
  56. package/dist/pay-contract.d.ts +10 -3
  57. package/dist/pay-contract.d.ts.map +1 -1
  58. package/dist/pay-contract.js +9 -0
  59. package/dist/pay-contract.js.map +1 -1
  60. package/dist/pay-test-cycle.d.ts +40 -0
  61. package/dist/pay-test-cycle.d.ts.map +1 -0
  62. package/dist/pay-test-cycle.js +207 -0
  63. package/dist/pay-test-cycle.js.map +1 -0
  64. package/dist/pay.d.ts +1 -1
  65. package/dist/pay.d.ts.map +1 -1
  66. package/dist/pay.js +1 -0
  67. package/dist/pay.js.map +1 -1
  68. package/dist/project.d.ts +39 -1
  69. package/dist/project.d.ts.map +1 -1
  70. package/dist/project.js +231 -20
  71. package/dist/project.js.map +1 -1
  72. package/dist/publish.d.ts +33 -0
  73. package/dist/publish.d.ts.map +1 -1
  74. package/dist/publish.js +113 -2
  75. package/dist/publish.js.map +1 -1
  76. package/dist/review-checks.d.ts +13 -0
  77. package/dist/review-checks.d.ts.map +1 -1
  78. package/dist/review-checks.js.map +1 -1
  79. package/dist/review-risk.d.ts +10 -1
  80. package/dist/review-risk.d.ts.map +1 -1
  81. package/dist/review-risk.js +1 -0
  82. package/dist/review-risk.js.map +1 -1
  83. package/dist/scan.d.ts +3 -2
  84. package/dist/scan.d.ts.map +1 -1
  85. package/dist/scan.js +4 -2
  86. package/dist/scan.js.map +1 -1
  87. package/dist/screenshots-scenes.d.ts +1383 -0
  88. package/dist/screenshots-scenes.d.ts.map +1 -0
  89. package/dist/screenshots-scenes.js +276 -0
  90. package/dist/screenshots-scenes.js.map +1 -0
  91. package/dist/screenshots.d.ts +145 -0
  92. package/dist/screenshots.d.ts.map +1 -0
  93. package/dist/screenshots.js +557 -0
  94. package/dist/screenshots.js.map +1 -0
  95. package/dist/smoke.d.ts +81 -0
  96. package/dist/smoke.d.ts.map +1 -0
  97. package/dist/smoke.js +491 -0
  98. package/dist/smoke.js.map +1 -0
  99. package/dist/store-assets.d.ts +139 -0
  100. package/dist/store-assets.d.ts.map +1 -0
  101. package/dist/store-assets.js +582 -0
  102. package/dist/store-assets.js.map +1 -0
  103. package/dist/zip.d.ts +57 -0
  104. package/dist/zip.d.ts.map +1 -0
  105. package/dist/zip.js +134 -0
  106. package/dist/zip.js.map +1 -0
  107. package/package.json +4 -3
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-evidence.d.ts","sourceRoot":"","sources":["../src/auto-evidence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAMH,OAAO,EAAmB,KAAK,OAAO,EAAE,MAAM,cAAc,CAAA;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAIlD,OAAO,EAAE,KAAK,aAAa,EAAwB,MAAM,oBAAoB,CAAA;AAC7E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,CAAA;AAG3D,eAAO,MAAM,SAAS,GAAI,OAAO,UAAU,GAAG,MAAM,WAAqD,CAAA;AAEzG;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC/B,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,MAAM,EACtB,QAAQ,CAAC,EAAE,MAAM,GACf,eAAe,EAAE,CAmBnB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE;IACnC,cAAc,EAAE,MAAM,CAAA;IACtB,QAAQ,EAAE,eAAe,EAAE,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC7B,GAAG,cAAc,EAAE,CAWnB;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE;IAAE,OAAO,EAAE,eAAe,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG,cAAc,EAAE,CAUxG;AAQD,KAAK,eAAe,GAAG,WAAW,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAAA;AAE7D,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,eAAe,CAMlF;AAKD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IAC1C,cAAc,EAAE,MAAM,CAAA;IACtB,QAAQ,EAAE,OAAO,CAAA;IACjB,QAAQ,EAAE,eAAe,EAAE,CAAA;IAC3B,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAA;IAClC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CACpC,GAAG,cAAc,EAAE,CAYnB;AAED,uGAAuG;AACvG,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CAEnE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACtC,cAAc,EAAE,MAAM,CAAA;IACtB,YAAY,EAAE,UAAU,GAAG,MAAM,CAAA;IACjC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,eAAe,EAAE,CAAA;IAC3B,2DAA2D;IAC3D,eAAe,EAAE,SAAS,aAAa,EAAE,CAAA;CACzC,GAAG,cAAc,EAAE,CAwBnB;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACrD,aAAa,EAAE,MAAM,CAAA;IACrB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,eAAe,EAAE,CAAA;CAC3B,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAU5B;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,cAAc,EAAE,EAAE,GAAG,SAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBtG"}
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `--evidence` on `scan`, `review-risk`, `generate-listing` and `test smoke`:
3
+ * turn the result the command just computed into the readiness evidence kind it
4
+ * attests, with the digests `releaseReadiness` matches against, and record it for
5
+ * the project bound in this directory — through the same client path as
6
+ * `extenshi evidence push` (project.ts → recordAgentEvidence).
7
+ *
8
+ * The digests are the contract (shared-types/project-workspace.ts
9
+ * `releaseReadiness`):
10
+ *
11
+ * kind artifactDigest inputDigest locale
12
+ * scan sha256(zip) sha256(zip) —
13
+ * installed sha256(zip) sha256(zip) —
14
+ * permissions sha256(zip) hashMetadata(packaged manifest) —
15
+ * listing sha256(zip) sha256(listing file bytes) each locale
16
+ *
17
+ * `hashMetadata(manifest)` is what `release prepare` stores as manifestDigest,
18
+ * and `sha256(listing file)` is what `release prepare --listing <file>` stores as
19
+ * listingDigest — so evidence recorded here is current exactly when the release
20
+ * binds the same package and the same listing file.
21
+ *
22
+ * Status rules (documented in the CLI docs):
23
+ * scan failed on a REJECTION_LIKELY store-compliance finding or any
24
+ * CRITICAL security finding — the report's own "fail" verdict.
25
+ * permissions failed on a REJECTED review-risk finding outside the listing rules.
26
+ * listing failed only on a REJECTED listing-rule finding for that locale —
27
+ * exactly what would make `review-risk` exit non-zero. Listing rules
28
+ * are ADVISORY by roadmap policy (iteration 2 T9: the v3 dictionary
29
+ * measured a 78.7% false-positive rate) and never fail review-risk,
30
+ * so failing readiness on them would block releases on false
31
+ * positives. An advisory finding leaves the check `passed`.
32
+ * installed the smoke result.
33
+ *
34
+ * `permissions` and `listing` carry a `summary` (finding count, blocking count,
35
+ * distinct check ids) so the advisories behind a `passed` stay visible to
36
+ * whoever reads the record. It is sent only to a server that advertises it
37
+ * (project.ts → recordAgentEvidence).
38
+ */
39
+ import { createHash } from 'node:crypto';
40
+ import fs from 'node:fs';
41
+ import { hashMetadata } from '@extenshi/contracts';
42
+ import { InvalidInputError } from './expected-error.js';
43
+ import { assembleListing } from './listing.js';
44
+ import { packagedBrowsersFor } from './package.js';
45
+ import { summarizeCompliance, summarizeReport } from './render.js';
46
+ import { computeVerdict } from './report.js';
47
+ import { loadArtifact } from './review-archive.js';
48
+ import { runListingOnlyChecks } from './review-checks.js';
49
+ const BROWSERS = ['chrome', 'edge', 'firefox'];
50
+ export const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
51
+ /**
52
+ * Which store(s) the evidence is for: the explicit list, else the browsers
53
+ * `extenshi package` recorded for these exact bytes (Chrome and Edge built from
54
+ * one directory are the same zip), else refuse — mislabelled evidence would
55
+ * satisfy the wrong release.
56
+ */
57
+ export function evidenceBrowsers(artifactPath, artifactDigest, explicit) {
58
+ if (explicit) {
59
+ const list = explicit
60
+ .split(',')
61
+ .map((s) => s.trim().toLowerCase())
62
+ .filter(Boolean);
63
+ const bad = list.filter((s) => !BROWSERS.includes(s));
64
+ if (bad.length || !list.length)
65
+ throw new InvalidInputError(`--evidence-browser takes a comma-separated list of: ${BROWSERS.join(', ')}`);
66
+ return [...new Set(list)];
67
+ }
68
+ const packaged = packagedBrowsersFor(artifactPath, artifactDigest);
69
+ if (packaged.length)
70
+ return packaged;
71
+ throw new InvalidInputError('--evidence needs to know which store this package is for. Pass --evidence-browser chrome|edge|firefox, ' +
72
+ 'or run it on a zip from `extenshi package`, whose extenshi-packages.json records the browser.');
73
+ }
74
+ export function scanEvidence(input) {
75
+ const verdict = computeVerdict(summarizeCompliance(input.body), summarizeReport(input.body));
76
+ const critical = summarizeReport(input.body).findings.some((f) => f.severity === 'CRITICAL');
77
+ const status = verdict.level === 'fail' || critical ? 'failed' : 'passed';
78
+ return input.browsers.map((browser) => ({
79
+ kind: 'scan',
80
+ browser,
81
+ artifactDigest: input.artifactDigest,
82
+ inputDigest: input.artifactDigest,
83
+ status,
84
+ }));
85
+ }
86
+ export function smokeEvidence(input) {
87
+ return [
88
+ {
89
+ kind: 'installed',
90
+ browser: input.browser,
91
+ artifactDigest: input.result.sha256,
92
+ inputDigest: input.result.sha256,
93
+ status: input.result.status,
94
+ },
95
+ ];
96
+ }
97
+ /**
98
+ * Mirrors EVIDENCE_SUMMARY_MAX_IDS in the contract. Not imported: an installed
99
+ * @extenshi/contracts that predates the summary has no such export, and a
100
+ * missing named ESM import would stop the whole CLI from loading.
101
+ */
102
+ const SUMMARY_MAX_IDS = 50;
103
+ /** Counts and distinct check ids — never finding text, which can quote listing copy or code. */
104
+ export function findingSummary(findings) {
105
+ return {
106
+ findingCount: findings.length,
107
+ blockingCount: findings.filter(isBlocking).length,
108
+ findingIds: [...new Set(findings.map((f) => f.check))].sort().slice(0, SUMMARY_MAX_IDS),
109
+ };
110
+ }
111
+ /** What makes `review-risk` exit non-zero (summarize → `blocking`). */
112
+ const isBlocking = (f) => f.verdict === 'REJECTED';
113
+ export function permissionsEvidence(input) {
114
+ const own = input.findings.filter((f) => !input.listingCheckIds.has(f.check));
115
+ const summary = findingSummary(own);
116
+ const inputDigest = hashMetadata(input.manifest);
117
+ return input.browsers.map((browser) => ({
118
+ kind: 'permissions',
119
+ browser,
120
+ artifactDigest: input.artifactDigest,
121
+ inputDigest,
122
+ status: summary.blockingCount ? 'failed' : 'passed',
123
+ summary,
124
+ }));
125
+ }
126
+ /** Listing findings carry `[locale] field…` evidence; one without a locale applies to every locale. */
127
+ export function findingLocale(finding) {
128
+ return /^\[([^\]\s]+)\]/.exec(finding.evidence ?? '')?.[1] ?? null;
129
+ }
130
+ export function listingEvidence(input) {
131
+ const locales = input.listing?.locales.map((l) => l.locale) ?? [];
132
+ if (!locales.length)
133
+ throw new InvalidInputError('The listing has no locale with text, so there is nothing to attest.');
134
+ const inputDigest = sha256Hex(input.listingBytes);
135
+ return input.browsers.flatMap((browser) => locales.map((locale) => {
136
+ const hits = input.listingFindings.filter((f) => {
137
+ const at = findingLocale(f);
138
+ return at === null || at === locale;
139
+ });
140
+ const summary = findingSummary(hits);
141
+ return {
142
+ kind: 'listing',
143
+ browser,
144
+ locale,
145
+ artifactDigest: input.artifactDigest,
146
+ inputDigest,
147
+ // Advisory findings are carried in the summary, never in the status.
148
+ status: summary.blockingCount ? 'failed' : 'passed',
149
+ summary,
150
+ };
151
+ }));
152
+ }
153
+ /**
154
+ * `generate-listing --evidence`: read the draft the command just wrote
155
+ * (CHROMEWEBSTORE.md, EDGE.md or AMO.md — one parser, keyed on headings) back
156
+ * over the package, run the listing rules on it and bind the evidence to that
157
+ * file's bytes. `browsers` is the store the draft is for.
158
+ */
159
+ export async function generatedListingEvidence(input) {
160
+ const { manifest, files } = await loadArtifact(input.artifactBytes);
161
+ const listing = assembleListing(manifest, files, input.draftPath);
162
+ return listingEvidence({
163
+ artifactDigest: input.artifactDigest,
164
+ listingBytes: fs.readFileSync(input.draftPath),
165
+ listing,
166
+ browsers: input.browsers,
167
+ listingFindings: runListingOnlyChecks(listing),
168
+ });
169
+ }
170
+ /**
171
+ * Record the batch for the bound project and say so on stderr (stdout stays the
172
+ * command's own output, e.g. --json). A queued (offline) record sets exit code 1
173
+ * like `evidence push`; any other failure throws — the caller asked for evidence.
174
+ */
175
+ export async function recordAutoEvidence(records, cwd = process.cwd()) {
176
+ if (!records.length)
177
+ return;
178
+ const { recordAgentEvidence } = await import('./project.js');
179
+ const result = await recordAgentEvidence(cwd, records);
180
+ const summary = records
181
+ .map((r) => {
182
+ const advisories = r.summary ? r.summary.findingCount - r.summary.blockingCount : 0;
183
+ const note = advisories ? ` (${advisories} advisory: ${r.summary?.findingIds.join(' ')})` : '';
184
+ return `${r.kind}${r.locale ? `:${r.locale}` : ''}/${r.browser}=${r.status}${note}`;
185
+ })
186
+ .join(', ');
187
+ if (!result.summaries && records.some((r) => r.summary))
188
+ process.stderr.write('note: this server does not store evidence summaries yet — the advisory findings above are shown here only\n');
189
+ if (result.status === 'pending-sync') {
190
+ process.stderr.write(`evidence queued (offline) for project ${result.projectId}: ${summary}. Run extenshi project sync.\n`);
191
+ process.exitCode = 1;
192
+ }
193
+ else {
194
+ process.stderr.write(`evidence recorded for project ${result.projectId}: ${summary}\n`);
195
+ }
196
+ }
197
+ //# sourceMappingURL=auto-evidence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-evidence.js","sourceRoot":"","sources":["../src/auto-evidence.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -27,14 +27,19 @@
27
27
  * re-reads the config on the first event of the run, so an answer given here is
28
28
  * already in force for the very run that asked it.
29
29
  */
30
+ import { existsSync, statSync } from 'node:fs';
30
31
  import { readFile } from 'node:fs/promises';
31
32
  import { createRequire } from 'node:module';
32
33
  import os from 'node:os';
34
+ import path from 'node:path';
33
35
  import * as readline from 'node:readline/promises';
34
36
  import { program } from 'commander';
37
+ import { runAmoSource } from './amo-source.js';
38
+ import { evidenceBrowsers, generatedListingEvidence, listingEvidence, permissionsEvidence, recordAutoEvidence, scanEvidence, sha256Hex, smokeEvidence, } from './auto-evidence.js';
35
39
  import { checkAutoUpdate } from './auto-update.js';
36
40
  import { CONFIG_PATH, loadConfig, readTelemetryPreference, saveConfig, saveTelemetryPreference, } from './config.js';
37
- import { isExpectedError } from './expected-error.js';
41
+ import { registerEvidenceIngest } from './evidence-ingest.js';
42
+ import { InvalidInputError, isExpectedError } from './expected-error.js';
38
43
  import { runGenerateListing } from './generate-listing.js';
39
44
  import { runIconPreview } from './icon.js';
40
45
  import { applyLocalization, checkLocalization, prepareLocalization } from './localize.js';
@@ -42,13 +47,20 @@ import { initializeLocalization } from './localize-init.js';
42
47
  import { makeLoginBff } from './login-bff.js';
43
48
  import { runBrowserLogin } from './login-flow.js';
44
49
  import { openBrowser, openLoopback, sleep } from './login-runtime.js';
50
+ import { runMediaUpload } from './media.js';
51
+ import { parsePackageBrowsers, resolvePackagedArtifact, runPackage } from './package.js';
45
52
  import { runPageGenerate, runPagePublish, runPageRegister, runPageUnpublish } from './page.js';
46
53
  import { registerPayCommands } from './pay.js';
54
+ import { registerPayTestCycle } from './pay-test-cycle.js';
47
55
  import { registerProjectCommands } from './project.js';
48
56
  import { runPublish } from './publish.js';
57
+ import { runListingOnlyChecks } from './review-checks.js';
49
58
  import { runReviewRisk } from './review-risk.js';
50
59
  import { parseRefs, parseStore, runRisk } from './risk.js';
51
60
  import { runScan } from './scan.js';
61
+ import { runScreenshotsCommand } from './screenshots.js';
62
+ import { ENGINE_BROWSER, parseSmokeEngine, renderSmoke, runSmoke } from './smoke.js';
63
+ import { runStoreAssets } from './store-assets.js';
52
64
  import { captureError, captureEvent, classifyError, flagsFromArgv, flushTelemetry, initTelemetry, } from './telemetry.js';
53
65
  import { defaultConsentDeps, envAnswerSource, maybeAskForTelemetryConsent } from './telemetry-consent.js';
54
66
  const MISSING_KEY_HELP = "Looks like you don't have an Extenshi API key set up yet — let's fix that.\n" +
@@ -102,8 +114,24 @@ async function runCommand(command, action) {
102
114
  process.exit(1);
103
115
  }
104
116
  }
117
+ /**
118
+ * `--evidence` is checked BEFORE the work it attests: a paid scan that cannot
119
+ * record its evidence afterwards would spend a credit for nothing.
120
+ */
121
+ function assertEvidenceBinding() {
122
+ if (!existsSync(path.join(process.cwd(), '.extenshi/project.json')))
123
+ throw new InvalidInputError('--evidence records against the project bound in this directory. Run extenshi project bind --project <id> --repo <owner/repo> first.');
124
+ }
125
+ /** Evidence binds to the exact package bytes, so it needs a package file, not an unpacked folder. */
126
+ async function evidenceArtifact(artifact) {
127
+ if (!existsSync(artifact) || !statSync(artifact).isFile())
128
+ throw new InvalidInputError('--evidence binds to the exact package: pass the zip (e.g. from extenshi package), not an unpacked folder.');
129
+ const bytes = await readFile(artifact);
130
+ return { bytes, digest: sha256Hex(bytes) };
131
+ }
105
132
  registerProjectCommands(program);
106
- registerPayCommands(program);
133
+ registerPayTestCycle(registerPayCommands(program));
134
+ registerEvidenceIngest(program, runCommand);
107
135
  program
108
136
  .name('extenshi')
109
137
  .description('Pre-publish security scanner for browser extensions')
@@ -125,12 +153,21 @@ program
125
153
  .option('--listing <file>', 'JSON file with your store listing text (name / shortDescription / description, optionally per locale). ' +
126
154
  'Without it the listing checks still run, but only over the name and short description the package carries — ' +
127
155
  'the full description lives in the dashboard, never in the artifact')
156
+ .option('--evidence', 'Record the result as `scan` release evidence for the project bound here (extenshi project bind), bound to this exact package')
157
+ .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
158
  .action(async (artifact, opts) => {
129
159
  await runCommand('scan', async () => {
130
160
  const config = await loadConfig(opts.apiUrl);
131
161
  if (!config.apiKey) {
132
162
  throw new Error(MISSING_KEY_HELP);
133
163
  }
164
+ // Everything --evidence needs is settled before a credit is spent.
165
+ let evidence;
166
+ if (opts.evidence) {
167
+ assertEvidenceBinding();
168
+ const { digest } = await evidenceArtifact(artifact);
169
+ evidence = { digest, browsers: evidenceBrowsers(artifact, digest, opts.evidenceBrowser) };
170
+ }
134
171
  // Auto-update check (fail-soft — never blocks scan)
135
172
  await checkAutoUpdate();
136
173
  let reportFormat;
@@ -161,7 +198,7 @@ program
161
198
  // explicitly disabled (--no-open), and not in CI (no browser/display —
162
199
  // the file is still written so CI can archive it as an artifact).
163
200
  const openReport = format === 'html' && (opts.open ?? true) && !process.env.CI;
164
- await runScan({
201
+ const body = await runScan({
165
202
  artifactPath: artifact,
166
203
  apiKey: config.apiKey,
167
204
  apiUrl: config.apiUrl,
@@ -180,6 +217,8 @@ program
180
217
  bffUrl: config.bffUrl,
181
218
  listingPath: opts.listing,
182
219
  });
220
+ if (evidence)
221
+ await recordAutoEvidence(scanEvidence({ artifactDigest: evidence.digest, browsers: evidence.browsers, body }));
183
222
  });
184
223
  });
185
224
  program
@@ -191,6 +230,13 @@ program
191
230
  .option('--stores <list>', 'Comma-separated stores: chrome,firefox,edge (default: every store with complete credentials)')
192
231
  .option('--firefox-artifact <path>', 'Separate .xpi for Firefox (default: the main artifact)')
193
232
  .option('--release-notes <text>', 'Release notes passed to stores that accept them')
233
+ .option('--listing <file>', 'Firefox: AMO listing (JSON, or the AMO.md `generate-listing --store firefox` writes, placeholders filled); ' +
234
+ 'creates the add-on on first submission or edits its listing')
235
+ .option('--source <archive>', 'Firefox: reviewer source archive (.zip/.tar.gz) for the new version (needs --listing)')
236
+ .option('--screenshots <dir>', 'Firefox: AMO previews in file-name order: an image folder or `extenshi screenshots` output (its firefox/<locale>/ set); needs --listing')
237
+ .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)')
238
+ .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')
239
+ .option('--replace-screenshots', 'Firefox: replace the previews of an existing AMO add-on')
194
240
  .option('--validate', 'Only validate store credentials, publish nothing')
195
241
  .option('--no-wait', 'Edge: return after commit without polling the operation to a terminal status')
196
242
  .option('--extension-id <id>', 'Numeric catalog ID — checks publish-beta access against this specific extension')
@@ -227,6 +273,12 @@ program
227
273
  bffUrl: config.bffUrl,
228
274
  apiKey: config.apiKey,
229
275
  extensionId,
276
+ listingPath: opts.listing,
277
+ sourcePath: opts.source,
278
+ screenshotsDir: opts.screenshots,
279
+ allowNewAddon: opts.allowNewAddon ?? false,
280
+ addonId: opts.addonId,
281
+ replaceScreenshots: opts.replaceScreenshots ?? false,
230
282
  });
231
283
  });
232
284
  });
@@ -245,8 +297,20 @@ program
245
297
  'The package already supplies the localized name and short description; this adds the full description, ' +
246
298
  'which exists only in the developer dashboard.')
247
299
  .option('--json', 'Output raw JSON findings')
300
+ .option('--evidence', 'Record `permissions` evidence (bound to the packaged manifest) and, with --listing, `listing` evidence per locale ' +
301
+ '(bound to that file — pass the same file to release prepare --listing) for the project bound here')
302
+ .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
303
  .action(async (artifact, opts) => {
249
304
  await runCommand('review-risk', async () => {
305
+ let evidence;
306
+ if (opts.evidence) {
307
+ assertEvidenceBinding();
308
+ const { digest } = await evidenceArtifact(artifact);
309
+ evidence = {
310
+ digest,
311
+ browsers: evidenceBrowsers(artifact, digest, opts.evidenceBrowser ?? opts.store),
312
+ };
313
+ }
250
314
  // Default to CHROME — this tool is Chrome-Web-Store-centric, and pinning
251
315
  // the store avoids diffing against another store's snapshot.
252
316
  const storeMap = {
@@ -262,7 +326,7 @@ program
262
326
  // manifest diff only uses the key if the catalog read path is gated.
263
327
  const config = await loadConfig();
264
328
  await checkAutoUpdate();
265
- await runReviewRisk({
329
+ const result = await runReviewRisk({
266
330
  artifactPath: artifact,
267
331
  extensionId: opts.extensionId,
268
332
  bffUrl: (opts.catalogUrl ?? 'https://bff.extenshi.io').replace(/\/$/, ''),
@@ -271,6 +335,27 @@ program
271
335
  listingPath: opts.listing,
272
336
  outputJson: opts.json ?? false,
273
337
  });
338
+ if (evidence) {
339
+ const listingFindings = runListingOnlyChecks(result.listing);
340
+ const records = permissionsEvidence({
341
+ artifactDigest: evidence.digest,
342
+ manifest: result.manifest,
343
+ browsers: evidence.browsers,
344
+ findings: result.summary.findings,
345
+ listingCheckIds: new Set(listingFindings.map((f) => f.check)),
346
+ });
347
+ if (opts.listing)
348
+ records.push(...listingEvidence({
349
+ artifactDigest: evidence.digest,
350
+ listingBytes: await readFile(opts.listing),
351
+ listing: result.listing,
352
+ browsers: evidence.browsers,
353
+ listingFindings,
354
+ }));
355
+ else
356
+ process.stderr.write('note: no listing evidence — it binds to a listing file; pass --listing <file> (the one you give release prepare --listing)\n');
357
+ await recordAutoEvidence(records);
358
+ }
274
359
  });
275
360
  });
276
361
  const localize = program
@@ -322,21 +407,140 @@ localize
322
407
  });
323
408
  program
324
409
  .command('generate-listing <artifact>')
325
- .description('Generate CHROMEWEBSTORE.md from a packaged (.zip/.crx/.xpi) or unpacked extension: ' +
410
+ .description('Generate a store listing draft from a packaged (.zip/.crx/.xpi) or unpacked extension: ' +
326
411
  'listing copy, permission justifications, single purpose and privacy-practices questions. ' +
327
412
  '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)')
413
+ 'anything into any store dashboard. --store picks CHROMEWEBSTORE.md / EDGE.md / AMO.md ' +
414
+ '(default chrome); all three are the same listing draft `review-risk --listing` accepts.')
415
+ .option('--store <store>', 'Which store to draft for: chrome (default), edge, or firefox', 'chrome')
416
+ .option('--output <path>', 'Where to write the markdown (default: the per-store filename in the current directory)')
330
417
  .option('--listing <file>', 'JSON file with your store listing text (name / shortDescription / description). ' +
331
418
  'Overlays the package so the generated markdown can include the full dashboard description.')
419
+ .option('--evidence', 'After writing the file, run the listing rules over it and record `listing` evidence per locale, bound to the ' +
420
+ 'generated file (pass it to release prepare --listing). Needs a packaged zip, not a folder.')
421
+ .option('--evidence-browser <list>', 'Store(s) the evidence is for: chrome,edge,firefox (default: the --store the draft is written for)')
332
422
  .action(async (artifact, opts) => {
333
423
  await runCommand('generate-listing', async () => {
424
+ const store = (opts.store ?? 'chrome').trim().toLowerCase();
425
+ if (store !== 'chrome' && store !== 'edge' && store !== 'firefox') {
426
+ throw new Error(`--store must be chrome, edge or firefox (got "${opts.store}")`);
427
+ }
428
+ // The draft is one store's listing, so its evidence is that store's by default.
429
+ let evidence;
430
+ if (opts.evidence) {
431
+ assertEvidenceBinding();
432
+ const { bytes, digest } = await evidenceArtifact(artifact);
433
+ evidence = {
434
+ bytes,
435
+ digest,
436
+ browsers: evidenceBrowsers(artifact, digest, opts.evidenceBrowser ?? store),
437
+ };
438
+ }
334
439
  await checkAutoUpdate();
335
- await runGenerateListing({
440
+ const outputPath = await runGenerateListing({
336
441
  artifactPath: artifact,
337
442
  outputPath: opts.output,
338
443
  listingPath: opts.listing,
444
+ store,
445
+ });
446
+ if (evidence)
447
+ await recordAutoEvidence(await generatedListingEvidence({
448
+ artifactBytes: evidence.bytes,
449
+ artifactDigest: evidence.digest,
450
+ draftPath: outputPath,
451
+ browsers: evidence.browsers,
452
+ }));
453
+ });
454
+ });
455
+ program
456
+ .command('package [dir]')
457
+ .description('Build one reproducible store zip per browser from an unpacked build directory: sorted entries, fixed ' +
458
+ 'timestamps, no file modes or extra metadata — the same bytes on every machine and timezone. Prints the ' +
459
+ 'sha256 of each zip and writes extenshi-packages.json, which release prepare, test smoke and evidence ' +
460
+ 'ingest accept directly. Never modifies the source directory. Free, offline.')
461
+ .option('--browser <list>', 'Comma-separated: chrome,edge,firefox (default: chrome)')
462
+ .option('--chrome-dir <dir>', 'Build directory for Chrome (overrides [dir])')
463
+ .option('--edge-dir <dir>', 'Build directory for Edge (overrides [dir])')
464
+ .option('--firefox-dir <dir>', 'Build directory for Firefox (overrides [dir])')
465
+ .option('--out <dir>', 'Output directory (default: .extenshi/packages — ignored by the dirty-tree digest release prepare records)')
466
+ .option('--exclude <patterns...>', 'Leave files out (gitignore-style globs, comma-separated or repeated). Dot-entries and node_modules are always left out.')
467
+ .option('--gecko-id <id>', 'Firefox: write this add-on ID into the PACKAGED manifest. A missing or placeholder ID is refused otherwise.')
468
+ .option('--fix-war', 'Replace wildcard web_accessible_resources with the explicit list of matching files, in the packaged copy only')
469
+ .option('--json', 'Print the packages manifest as JSON')
470
+ .action(async (dir, opts) => {
471
+ await runCommand('package', async () => {
472
+ runPackage({
473
+ dir,
474
+ dirs: { chrome: opts.chromeDir, edge: opts.edgeDir, firefox: opts.firefoxDir },
475
+ browsers: parsePackageBrowsers(opts.browser),
476
+ outDir: opts.out ?? '.extenshi/packages',
477
+ exclude: opts.exclude,
478
+ geckoId: opts.geckoId,
479
+ fixWar: opts.fixWar ?? false,
480
+ json: opts.json ?? false,
481
+ cliVersion: pkg.version,
482
+ });
483
+ });
484
+ });
485
+ const test = program.command('test').description('Test packaged extensions in real browsers');
486
+ test
487
+ .command('smoke <package>')
488
+ .description('Install the EXACT package (a zip, or extenshi-packages.json + --browser) in a real browser and check that it ' +
489
+ 'loads, its service worker/background starts, the browser reports no manifest errors, and its popup/options/' +
490
+ 'side-panel pages open without console errors. chromium/edge use Playwright (install it in your project: ' +
491
+ 'npm i -D playwright && npx playwright install chromium); firefox needs only a Firefox binary. Exit 1 on failure.')
492
+ .requiredOption('--browser <browser>', 'chromium (alias chrome) | edge | firefox')
493
+ .option('--json', 'Print the result as JSON')
494
+ .option('--timeout <ms>', 'Budget per wait (service worker start, page load)', '15000')
495
+ .option('--headed', 'Show the browser window')
496
+ .option('--firefox-bin <path>', 'Firefox binary (default: FIREFOX_BIN, standard install locations, PATH)')
497
+ .option('--browser-path <path>', 'Chromium/Edge executable instead of the Playwright channel default')
498
+ .option('--evidence', 'Record the result as `installed` release evidence for the project bound here (extenshi project bind)')
499
+ .action(async (packagePath, opts) => {
500
+ await runCommand('test smoke', async () => {
501
+ const engine = parseSmokeEngine(opts.browser);
502
+ if (opts.evidence)
503
+ assertEvidenceBinding();
504
+ const timeoutMs = Number.parseInt(opts.timeout, 10);
505
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1000)
506
+ throw new InvalidInputError('--timeout must be ≥ 1000 ms');
507
+ const { artifactPath } = resolvePackagedArtifact(packagePath, ENGINE_BROWSER[engine]);
508
+ const result = await runSmoke({
509
+ artifactPath,
510
+ engine,
511
+ timeoutMs,
512
+ headed: opts.headed ?? false,
513
+ firefoxBinary: opts.firefoxBin,
514
+ browserPath: opts.browserPath,
339
515
  });
516
+ process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}\n` : `${renderSmoke(result)}\n`);
517
+ if (opts.evidence)
518
+ await recordAutoEvidence(smokeEvidence({ browser: ENGINE_BROWSER[engine], result }));
519
+ if (result.status === 'failed')
520
+ process.exitCode = 1;
521
+ });
522
+ });
523
+ program
524
+ .command('amo-source <project-dir>')
525
+ .description('Produce the AMO source-code archive + BUILD.md reviewer instructions for a Firefox add-on ' +
526
+ 'project directory. Excludes node_modules/dist/.git and dotenv secrets (.env*); includes the ' +
527
+ 'lockfile. Refuses on an uncommitted git working tree (the archive should match a real commit). ' +
528
+ 'Flags vendored minified files with no reproducible-build provenance as blockers. Does not talk ' +
529
+ 'to the AMO API — upload the archive by hand, or via the separate AMO API submission path.')
530
+ .option('--out <path>', 'Where to write the archive (default: amo-source.zip in the current directory)')
531
+ .action(async (projectDir, opts) => {
532
+ await runCommand('amo-source', async () => {
533
+ await checkAutoUpdate();
534
+ const result = await runAmoSource({ projectDir, outputPath: opts.out });
535
+ console.log(`Wrote ${result.zipPath} (${result.fileCount} files)`);
536
+ console.log(`Wrote ${result.buildMdPath}`);
537
+ if (result.blockers.length > 0) {
538
+ console.log(`\n${result.blockers.length} file(s) need a source-code submission before this can go to AMO:`);
539
+ for (const f of result.blockers)
540
+ console.log(` - ${f.path}`);
541
+ console.log('See BUILD.md for what to do about each one.');
542
+ process.exitCode = 1;
543
+ }
340
544
  });
341
545
  });
342
546
  program
@@ -399,6 +603,58 @@ icon
399
603
  await runIconPreview(iconPath, opts, pkg.version);
400
604
  });
401
605
  });
606
+ icon
607
+ .command('store-assets <source>')
608
+ .description('Render the store LISTING artwork from one .svg / .png source: Chrome Web Store icon 128 (96 px artwork + ' +
609
+ '16 px padding), 440×280 promo tile and 1400×560 marquee; Edge Add-ons 300×300 logo and 440×280 / ' +
610
+ '1400×560 tiles; AMO 128 / 64 / 32 icons. Offline (WebAssembly renderer), no API key.')
611
+ .option('--store <stores>', 'Comma-separated: chrome, edge, firefox (default: all)')
612
+ .option('--out <dir>', 'Output directory (default: ./store-assets)')
613
+ .option('--background <color>', 'Promo tile / marquee background (default: a light tint of the icon colour)')
614
+ .option('--icon-background <color>', 'Fill behind icons and the Edge logo (default: transparent)')
615
+ .option('--promo <file>', 'Full-bleed artwork (.svg/.png) for the 440×280 tiles instead of composing the icon')
616
+ .option('--marquee <file>', 'Full-bleed artwork (.svg/.png) for the 1400×560 tiles instead of composing the icon')
617
+ .option('--font <files...>', 'Font files for text in SVG sources (system fonts are matched by name otherwise)')
618
+ .option('--json', 'Print the result as JSON')
619
+ .action(async (source, opts) => {
620
+ await runCommand('icon store-assets', async () => {
621
+ await runStoreAssets(source, opts);
622
+ });
623
+ });
624
+ program
625
+ .command('screenshots <extension>')
626
+ .description('Store screenshots without a human: load an unpacked build (or .zip) in Chromium, run the declarative ' +
627
+ 'scenes in --scenes per locale (the UI language is switched and verified through chrome.i18n), and ' +
628
+ 'write each store size — Chrome/Edge/AMO 1280×800 (or --small) — as <out>/<store>/<locale>/NN-<scene>-WxH.png ' +
629
+ 'plus screenshots.json. Needs Playwright with Chromium in the project (npm i -D playwright && npx playwright ' +
630
+ 'install chromium). Scenes format: https://docs.extenshi.io/developers/cli#extenshi-screenshots')
631
+ .requiredOption('--scenes <scenes.json>', 'Declarative scenes file')
632
+ .option('--locales <codes>', 'Comma-separated _locales codes (default: every locale the extension ships)')
633
+ .option('--store <stores>', 'Comma-separated: chrome, edge, firefox (default: all)')
634
+ .option('--out <dir>', 'Output directory (default: ./store-screenshots)')
635
+ .option('--small', "Each store's smaller size: Chrome 640×400, Edge 640×480, AMO 640×400")
636
+ .option('--headed', 'Show the browser window (debugging scenes)')
637
+ .option('--json', 'Print the result as JSON')
638
+ .action(async (extension, opts) => {
639
+ await runCommand('screenshots', async () => {
640
+ await runScreenshotsCommand(extension, opts);
641
+ });
642
+ });
643
+ program
644
+ .command('media')
645
+ .description("The project's PUBLIC media store (logo, screenshots) used by hosted pages")
646
+ .command('upload <files...>')
647
+ .description("Upload PNG/JPEG/WebP images (≤2 MB each; .svg is rasterized to PNG locally) to the project's public media " +
648
+ 'store and print their stable URLs — the same store Dojo uploads use, accepted by `page publish` as ' +
649
+ 'screenshots[].url / logoUrl. The server strips metadata and enforces a per-project quota. Requires an ' +
650
+ 'API key (extenshi login) with hosted.write.')
651
+ .requiredOption('--project <id>', 'Dojo project ID (UUID)')
652
+ .option('--svg-width <px>', 'Width to rasterize .svg files at (default 1024)')
653
+ .action(async (files, opts) => {
654
+ await runCommand('media upload', async () => {
655
+ await runMediaUpload(files, opts);
656
+ });
657
+ });
402
658
  const page = program
403
659
  .command('page')
404
660
  .description('Landing page (homepage) for your extension: generate static HTML offline, register its URL');
@@ -438,6 +694,7 @@ page
438
694
  .requiredOption('--config <landing.json>', 'Landing config JSON')
439
695
  .option('--logo <file>', 'Logo: .svg or .png/.jpg/.webp/.gif (≤256 KiB)')
440
696
  .option('--no-homepage', 'Do not register the URL as the project homepage')
697
+ .option('--upload-local', "Upload local screenshot / logoUrl paths (relative to the config file) to the project's public media store first")
441
698
  .action(async (opts) => {
442
699
  await runCommand('page publish', async () => {
443
700
  await runPagePublish(opts);