@extenshi/cli 0.23.1 → 0.24.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 (49) hide show
  1. package/dist/amo-source.d.ts +88 -17
  2. package/dist/amo-source.d.ts.map +1 -1
  3. package/dist/amo-source.js +437 -116
  4. package/dist/amo-source.js.map +1 -1
  5. package/dist/cli.js +42 -11
  6. package/dist/cli.js.map +1 -1
  7. package/dist/compliance-review.d.ts +5 -1
  8. package/dist/compliance-review.d.ts.map +1 -1
  9. package/dist/compliance-review.js +10 -5
  10. package/dist/compliance-review.js.map +1 -1
  11. package/dist/cws-policies.d.ts.map +1 -1
  12. package/dist/cws-policies.js +4 -0
  13. package/dist/cws-policies.js.map +1 -1
  14. package/dist/listing-checks.d.ts +15 -9
  15. package/dist/listing-checks.d.ts.map +1 -1
  16. package/dist/listing-checks.js +54 -46
  17. package/dist/listing-checks.js.map +1 -1
  18. package/dist/listing.d.ts +48 -0
  19. package/dist/listing.d.ts.map +1 -1
  20. package/dist/listing.js +201 -23
  21. package/dist/listing.js.map +1 -1
  22. package/dist/png.d.ts +40 -0
  23. package/dist/png.d.ts.map +1 -0
  24. package/dist/png.js +208 -0
  25. package/dist/png.js.map +1 -0
  26. package/dist/review-checks.d.ts +48 -13
  27. package/dist/review-checks.d.ts.map +1 -1
  28. package/dist/review-checks.js +364 -56
  29. package/dist/review-checks.js.map +1 -1
  30. package/dist/review-risk.d.ts +8 -3
  31. package/dist/review-risk.d.ts.map +1 -1
  32. package/dist/review-risk.js +14 -4
  33. package/dist/review-risk.js.map +1 -1
  34. package/dist/review-stores.d.ts +63 -0
  35. package/dist/review-stores.d.ts.map +1 -0
  36. package/dist/review-stores.js +92 -0
  37. package/dist/review-stores.js.map +1 -0
  38. package/dist/screenshots-scenes.d.ts +13 -0
  39. package/dist/screenshots-scenes.d.ts.map +1 -1
  40. package/dist/screenshots-scenes.js +20 -2
  41. package/dist/screenshots-scenes.js.map +1 -1
  42. package/dist/screenshots.d.ts +6 -4
  43. package/dist/screenshots.d.ts.map +1 -1
  44. package/dist/screenshots.js +31 -10
  45. package/dist/screenshots.js.map +1 -1
  46. package/dist/store-assets.d.ts.map +1 -1
  47. package/dist/store-assets.js +7 -3
  48. package/dist/store-assets.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,34 +1,43 @@
1
1
  /**
2
- * `extenshi amo-source <project-dir> [--out <path>]` — produce the AMO
3
- * source-code archive + BUILD.md reviewer instructions.
2
+ * `extenshi amo-source <project-dir> [--out <path>] [--build <cmd>] [--verify <package>]`
3
+ * — produce the AMO source-code archive + BUILD.md reviewer instructions, and
4
+ * optionally prove a reviewer can rebuild the package from it.
4
5
  *
5
6
  * AMO requires reviewable source for any minified/bundled/transpiled code so a
6
7
  * reviewer can rebuild the extension and diff it against the submitted
7
8
  * package: https://extensionworkshop.com/documentation/publish/source-code-submission/
8
9
  *
9
- * This command does NOT run the build itself and does NOT talk to AMO's API
10
- * (that is `publisher/src/services/firefox.ts`, owned separately). It only:
11
- * 1. archives the project's tracked-looking source (excluding node_modules,
12
- * dist, .git, dotenv secrets, and any top-level directory the project's
13
- * own .gitignore names plainly — e.g. `dist-firefox/`, `dist-edge/`),
14
- * 2. writes BUILD.md with the exact commands a reviewer needs (Node/npm
15
- * versions, package manager + lockfile, the build script from
16
- * package.json), and
10
+ * This command does NOT talk to AMO's API (that is
11
+ * `publisher/src/services/firefox.ts`, owned separately). It:
12
+ * 1. archives the project's source — in a git repository `git archive HEAD`
13
+ * (.gitignore, nested ignore files, export-ignore and untracked build
14
+ * output all stay out); outside git, every file minus
15
+ * node_modules/dist-style directories and what the top-level .gitignore
16
+ * names. Dotenv files, symlinks and files over 25 MB never go in, and the
17
+ * result lists each one it left out;
18
+ * 2. writes BUILD.md with the commands a reviewer needs (Node/npm versions,
19
+ * package manager + lockfile, the build command);
17
20
  * 3. flags vendored minified files that carry no reproducible-build
18
- * provenance as BLOCKERS — the developer must vendor their source (e.g.
19
- * under vendor-src/) before submitting.
21
+ * provenance as BLOCKERS — unless their source is vendored under
22
+ * `vendor-src/<name>/` (optionally pinned by a `SOURCE_COMMIT` file), the
23
+ * layout these docs tell developers to use;
24
+ * 4. with `--verify <package>`, unpacks the archive into an empty directory,
25
+ * runs the install + build there, and compares the rebuilt package's
26
+ * SHA-256 with the one you are about to upload.
20
27
  *
21
- * If the project directory is a clean git working tree, the archive is
22
- * refused when there are uncommitted changes (matching the pattern this repo
23
- * already uses for its own extension projects) — the archive should match a
24
- * commit a reviewer can also check out. A directory that is not a git repo at
25
- * all is archived as-is; there is nothing to compare it against.
28
+ * If the project directory is a git working tree, the archive is refused when
29
+ * there are uncommitted changes — the archive should match a commit a reviewer
30
+ * can also check out. A directory that is not a git repo at all is archived
31
+ * as-is; there is nothing to compare it against.
26
32
  */
27
- import { execFileSync } from 'node:child_process';
33
+ import { execFileSync, execSync } from 'node:child_process';
34
+ import { createHash } from 'node:crypto';
28
35
  import fs from 'node:fs';
36
+ import os from 'node:os';
29
37
  import path from 'node:path';
30
38
  import JSZip from 'jszip';
31
39
  import { detectMinifiedFiles } from './amo-source-detect.js';
40
+ /** Outside git only: directories that are never source. Inside git the index decides. */
32
41
  const BASE_EXCLUDE_DIRS = new Set([
33
42
  'node_modules',
34
43
  '.git',
@@ -41,39 +50,6 @@ const BASE_EXCLUDE_DIRS = new Set([
41
50
  '.next',
42
51
  ]);
43
52
  const ENV_FILE_RE = /^\.env(\..+)?$/i;
44
- // A plain top-level directory ignore line: letters/digits/-/_/. only, no
45
- // globs, no nested path, optional trailing slash. Anything more complex
46
- // (negations, globs, nested paths) is skipped rather than mis-handled — this
47
- // is a heuristic, not a gitignore engine, and under-excluding is the safer
48
- // failure mode (a developer notices an oversized archive; over-excluding
49
- // silently drops source AMO needed).
50
- const SIMPLE_GITIGNORE_DIR_RE = /^([\w.-]+)\/?$/;
51
- /**
52
- * Fold a project's own top-level `.gitignore` build-output directories into
53
- * the exclude set — `dist-firefox/`, `dist-edge/` etc. are exactly the kind
54
- * of committed-for-CI build output that should never end up in a "source"
55
- * archive, and there is no way to enumerate every project's naming scheme in
56
- * a fixed list.
57
- */
58
- function additionalExcludeDirs(projectDir) {
59
- const extra = new Set();
60
- let raw;
61
- try {
62
- raw = fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf8');
63
- }
64
- catch {
65
- return extra;
66
- }
67
- for (const line of raw.split('\n')) {
68
- const trimmed = line.trim();
69
- if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!'))
70
- continue;
71
- const m = SIMPLE_GITIGNORE_DIR_RE.exec(trimmed);
72
- if (m)
73
- extra.add(m[1]);
74
- }
75
- return extra;
76
- }
77
53
  const TEXT_CODE_RE = /\.(?:m?js|cjs)$/i;
78
54
  const MAX_FILE_BYTES = 25 * 1024 * 1024;
79
55
  const LOCKFILES = [
@@ -81,6 +57,8 @@ const LOCKFILES = [
81
57
  { file: 'yarn.lock', manager: 'yarn', installCmd: 'yarn install --immutable' },
82
58
  { file: 'pnpm-lock.yaml', manager: 'pnpm', installCmd: 'pnpm install --frozen-lockfile' },
83
59
  ];
60
+ /** The vendored-source convention the AMO docs page and BUILD.md point developers at. */
61
+ const VENDOR_SRC_DIR = 'vendor-src';
84
62
  function isGitClean(cwd) {
85
63
  const git = (...args) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
86
64
  .toString()
@@ -93,25 +71,15 @@ function isGitClean(cwd) {
93
71
  }
94
72
  const status = git('status', '--porcelain', '--', '.');
95
73
  let commit;
74
+ let commitTime;
96
75
  try {
97
76
  commit = git('rev-parse', 'HEAD');
77
+ commitTime = new Date(Number(git('show', '-s', '--format=%ct', 'HEAD')) * 1000);
98
78
  }
99
79
  catch {
100
80
  // No commits yet — still archivable, just nothing to diff against.
101
81
  }
102
- return { isRepo: true, clean: status.length === 0, commit };
103
- }
104
- function readPackageJson(projectDir) {
105
- try {
106
- const raw = fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8');
107
- return JSON.parse(raw);
108
- }
109
- catch {
110
- return {};
111
- }
112
- }
113
- function detectLockfile(projectDir) {
114
- return LOCKFILES.find((l) => fs.existsSync(path.join(projectDir, l.file)));
82
+ return { isRepo: true, clean: status.length === 0, commit, commitTime };
115
83
  }
116
84
  function npmVersion() {
117
85
  try {
@@ -123,60 +91,354 @@ function npmVersion() {
123
91
  return undefined;
124
92
  }
125
93
  }
126
- function walkProject(projectDir, outputAbsPath) {
127
- const zip = new JSZip();
128
- const textFiles = new Map();
129
- let fileCount = 0;
130
- const excludeDirs = new Set([...BASE_EXCLUDE_DIRS, ...additionalExcludeDirs(projectDir)]);
131
- const walk = (dir) => {
132
- for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
94
+ /** One gitignore glob as a regex body: `**` crosses directories, `*` and `?` do not. */
95
+ function globToRegex(glob) {
96
+ let out = '';
97
+ for (let i = 0; i < glob.length; i++) {
98
+ const c = glob[i];
99
+ if (c === '*' && glob[i + 1] === '*') {
100
+ // `**/` → any number of leading directories; a trailing `/**` → everything inside.
101
+ if (glob[i + 2] === '/') {
102
+ out += '(?:.*/)?';
103
+ i += 2;
104
+ }
105
+ else {
106
+ out += '.*';
107
+ i += 1;
108
+ }
109
+ }
110
+ else if (c === '*')
111
+ out += '[^/]*';
112
+ else if (c === '?')
113
+ out += '[^/]';
114
+ else
115
+ out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * The top-level `.gitignore` of a project that is NOT a git repository, read
121
+ * the way git reads it: comments, `!` negation, trailing `/` for directories,
122
+ * a leading or inner `/` anchoring the pattern to the root, `*`, `?`, `**`,
123
+ * last match wins. Character classes (`[abc]`) and backslash escapes are not
124
+ * supported; such a pattern matches literally, so the file stays IN the archive
125
+ * (the safe direction). Inside a repository none of this runs — `git ls-files`
126
+ * already applies every ignore file git knows about.
127
+ */
128
+ export function compileGitignore(raw) {
129
+ const rules = [];
130
+ for (const line of raw.split(/\r?\n/)) {
131
+ let pattern = line.replace(/\s+$/, '');
132
+ if (!pattern || pattern.startsWith('#'))
133
+ continue;
134
+ const negate = pattern.startsWith('!');
135
+ if (negate)
136
+ pattern = pattern.slice(1);
137
+ const dirOnly = pattern.endsWith('/');
138
+ if (dirOnly)
139
+ pattern = pattern.replace(/\/+$/, '');
140
+ if (!pattern)
141
+ continue;
142
+ const anchored = pattern.includes('/');
143
+ pattern = pattern.replace(/^\//, '');
144
+ const body = globToRegex(pattern);
145
+ rules.push({ re: new RegExp(anchored ? `^${body}$` : `(?:^|/)${body}$`), negate, dirOnly });
146
+ }
147
+ return rules;
148
+ }
149
+ /** `rel` is a project-relative posix path. */
150
+ export function isGitignored(rel, isDir, rules) {
151
+ let ignored = false;
152
+ for (const rule of rules) {
153
+ if (rule.dirOnly && !isDir)
154
+ continue;
155
+ if (rule.re.test(rel))
156
+ ignored = !rule.negate;
157
+ }
158
+ return ignored;
159
+ }
160
+ /** Entry timestamp outside git, where there is no commit time: the DOS epoch. */
161
+ const FIXED_ENTRY_DATE = new Date('1980-01-01T00:00:00Z');
162
+ function emptyArchive(date) {
163
+ return { zip: new JSZip(), textFiles: new Map(), files: [], skipped: [], date };
164
+ }
165
+ /** Add one file unless it must stay out; `mode` is the full unix mode when known. */
166
+ function addFile(out, rel, buf, mode) {
167
+ if (ENV_FILE_RE.test(path.posix.basename(rel))) {
168
+ out.skipped.push({ path: rel, reason: 'dotenv file' });
169
+ return;
170
+ }
171
+ if (buf.length > MAX_FILE_BYTES) {
172
+ out.skipped.push({ path: rel, reason: 'larger than 25 MB' });
173
+ return;
174
+ }
175
+ // Keep the mode so a clean-room build can run a committed executable script.
176
+ out.zip.file(rel, buf, { unixPermissions: mode & 0o777, date: out.date });
177
+ out.files.push(rel);
178
+ if (TEXT_CODE_RE.test(rel))
179
+ out.textFiles.set(rel, buf.toString('utf8'));
180
+ }
181
+ /**
182
+ * In a git repository the archive is `git archive HEAD -- .`, run from the
183
+ * project directory: the committed blobs, with `export-ignore` and line-ending
184
+ * attributes applied — exactly what a reviewer gets from the same commit, and
185
+ * what keeps a previous `amo-source.zip`, `deploy*.zip` packages and gitignored
186
+ * font binaries out. Reading the working tree instead would also archive
187
+ * sparse-checkout gaps as missing files and smudge-filtered bytes.
188
+ */
189
+ async function archiveFromGit(projectDir, outputAbsPath, date) {
190
+ const raw = execFileSync('git', ['archive', '--format=zip', 'HEAD', '--', '.'], {
191
+ cwd: projectDir,
192
+ stdio: ['ignore', 'pipe', 'pipe'],
193
+ maxBuffer: 1024 * 1024 * 1024,
194
+ });
195
+ const committed = await JSZip.loadAsync(raw);
196
+ const out = emptyArchive(date);
197
+ const entries = Object.values(committed.files).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
198
+ for (const entry of entries) {
199
+ if (entry.dir)
200
+ continue;
201
+ if (path.resolve(projectDir, entry.name) === path.resolve(outputAbsPath))
202
+ continue; // never zip ourselves
203
+ const mode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0o100644;
204
+ if ((mode & 0o170000) === 0o120000) {
205
+ out.skipped.push({ path: entry.name, reason: 'symbolic link' });
206
+ continue;
207
+ }
208
+ addFile(out, entry.name, await entry.async('nodebuffer'), mode);
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Outside git: every regular file except node_modules/dist-style directories
214
+ * and what the top-level .gitignore names.
215
+ */
216
+ function archiveFromDisk(projectDir, outputAbsPath) {
217
+ let rules = [];
218
+ try {
219
+ rules = compileGitignore(fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf8'));
220
+ }
221
+ catch {
222
+ // No .gitignore — the base exclude list alone.
223
+ }
224
+ const out = emptyArchive(FIXED_ENTRY_DATE);
225
+ const walk = (dir, relDir) => {
226
+ // Sorted, so the archive and every later "first match" are the same on every machine.
227
+ const entries = fs
228
+ .readdirSync(dir, { withFileTypes: true })
229
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
230
+ for (const ent of entries) {
231
+ const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
133
232
  const full = path.join(dir, ent.name);
134
- if (path.resolve(full) === path.resolve(outputAbsPath))
135
- continue; // never zip ourselves
136
233
  if (ent.isDirectory()) {
137
- if (excludeDirs.has(ent.name))
234
+ if (BASE_EXCLUDE_DIRS.has(ent.name) || isGitignored(rel, true, rules))
138
235
  continue;
139
- walk(full);
140
- continue;
236
+ walk(full, rel);
141
237
  }
142
- if (!ent.isFile())
143
- continue;
144
- if (ENV_FILE_RE.test(ent.name))
145
- continue;
146
- const stat = fs.statSync(full);
147
- if (stat.size > MAX_FILE_BYTES)
148
- continue;
149
- const rel = path.relative(projectDir, full).split(path.sep).join('/');
150
- const buf = fs.readFileSync(full);
151
- zip.file(rel, buf);
152
- fileCount++;
153
- if (TEXT_CODE_RE.test(ent.name)) {
154
- try {
155
- textFiles.set(rel, buf.toString('utf8'));
156
- }
157
- catch {
158
- // Binary content that happened to match the extension — skip minification scan for it.
159
- }
238
+ else if (ent.isFile() && !isGitignored(rel, false, rules)) {
239
+ if (path.resolve(full) === path.resolve(outputAbsPath))
240
+ continue;
241
+ // Size first: a stray multi-GB file is reported, never read into memory.
242
+ const stat = fs.statSync(full);
243
+ if (stat.size > MAX_FILE_BYTES)
244
+ out.skipped.push({ path: rel, reason: 'larger than 25 MB' });
245
+ else
246
+ addFile(out, rel, fs.readFileSync(full), stat.mode);
160
247
  }
161
248
  }
162
249
  };
163
- walk(projectDir);
164
- return { zip, textFiles, fileCount };
250
+ walk(projectDir, '');
251
+ return out;
165
252
  }
253
+ const BUILD_CONFIG_RE = /^(?:package\.json|(?:rollup|vite|webpack|esbuild|tsup|rspack)\.config\.[cm]?[jt]s)$/i;
254
+ /** `vendor-src/<name>/` directories in the archive, read from the archive itself, in path order. */
255
+ async function vendorSourceDirs(zip, archived) {
256
+ const byDir = new Map();
257
+ for (const rel of archived) {
258
+ const parts = rel.split('/');
259
+ if (parts[0] !== VENDOR_SRC_DIR || parts.length < 3)
260
+ continue;
261
+ const dir = `${parts[0]}/${parts[1]}`;
262
+ byDir.set(dir, [...(byDir.get(dir) ?? []), parts.slice(2).join('/')]);
263
+ }
264
+ const read = async (rel) => zip.file(rel)?.async('string');
265
+ const out = [];
266
+ for (const dir of [...byDir.keys()].sort()) {
267
+ const inside = byDir.get(dir) ?? [];
268
+ let packageName;
269
+ try {
270
+ packageName = JSON.parse((await read(`${dir}/package.json`)) ?? '{}').name;
271
+ }
272
+ catch {
273
+ // Unparseable package.json — matched by directory name and config only.
274
+ }
275
+ const pinned = (await read(`${dir}/SOURCE_COMMIT`))?.trim();
276
+ const configs = inside.filter((f) => !f.includes('/') && BUILD_CONFIG_RE.test(f));
277
+ const configText = (await Promise.all(configs.map((f) => read(`${dir}/${f}`)))).join('\n');
278
+ out.push({
279
+ dir,
280
+ packageName,
281
+ // 64 for SHA-256 repositories.
282
+ sourceCommit: pinned && /^[0-9a-f]{7,64}$/i.test(pinned) ? pinned : undefined,
283
+ configText,
284
+ });
285
+ }
286
+ return out;
287
+ }
288
+ /** `extenshi-storage.esm.min.js` → `extenshi-storage`: the name a source directory would carry. */
289
+ function bundleStem(file) {
290
+ let stem = path.posix
291
+ .basename(file)
292
+ .toLowerCase()
293
+ .replace(/\.[cm]?js$/, '');
294
+ for (;;) {
295
+ const next = stem.replace(/[.-](?:min|esm|umd|cjs|iife|bundle|prod|production)$/, '');
296
+ if (next === stem)
297
+ return stem;
298
+ stem = next;
299
+ }
300
+ }
301
+ /** Suffixes that name the same library's source, not another library (`react-dom` is not `react`). */
302
+ const SOURCE_SUFFIX_RE = /^[-_.](?:sdk|src|source|lib|library|js|core)$/;
303
+ function namesMatch(stem, name) {
304
+ const n = name.toLowerCase().replace(/^@[^/]+\//, '');
305
+ return n === stem || (n.startsWith(stem) && SOURCE_SUFFIX_RE.test(n.slice(stem.length)));
306
+ }
307
+ /**
308
+ * Which `vendor-src/<name>/` holds the source of a minified file: its build
309
+ * configuration names the file, or the directory (or its package name) is the
310
+ * file's own name, optionally with a source-ish suffix —
311
+ * `extenshi-storage.esm.min.js` ↔ `vendor-src/extenshi-storage-sdk/`. BUILD.md
312
+ * says which rule matched, so a wrong attribution is visible.
313
+ */
314
+ function matchVendorSource(file, sources) {
315
+ const base = path.posix.basename(file);
316
+ // The whole file name, not a substring: `dist/myapp.min.js` in a config is not `app.min.js`.
317
+ const named = new RegExp(`(?:^|[^\\w.-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[^\\w.-]|$)`);
318
+ const byConfig = sources.find((s) => named.test(s.configText));
319
+ if (byConfig)
320
+ return { source: byConfig, by: 'build config' };
321
+ const stem = bundleStem(file);
322
+ const byName = sources.find((s) => namesMatch(stem, path.posix.basename(s.dir)) || (s.packageName && namesMatch(stem, s.packageName)));
323
+ return byName ? { source: byName, by: 'name' } : undefined;
324
+ }
325
+ // ── Clean-room rebuild ───────────────────────────────────────────────────────
326
+ function sha256(buf) {
327
+ return createHash('sha256').update(buf).digest('hex');
328
+ }
329
+ async function extractTo(zip, dir) {
330
+ for (const entry of Object.values(zip.files)) {
331
+ if (entry.dir)
332
+ continue;
333
+ const dest = path.join(dir, ...entry.name.split('/'));
334
+ // The archive is self-made, but keep the helper safe for any zip.
335
+ if (!dest.startsWith(dir + path.sep))
336
+ throw new Error(`refusing to extract ${entry.name} outside the clean room`);
337
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
338
+ fs.writeFileSync(dest, await entry.async('nodebuffer'));
339
+ const mode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions & 0o777 : 0;
340
+ if (mode)
341
+ fs.chmodSync(dest, mode);
342
+ }
343
+ }
344
+ /**
345
+ * What the clean room inherits: where tools live, who the user is, temp and
346
+ * locale settings, and how to reach the network. Nothing else — a `VITE_*`,
347
+ * `NODE_ENV` or release-channel variable in the developer's shell would make
348
+ * the rebuild match while the documented commands alone, on a reviewer's
349
+ * machine, would not. A build that needs such a value must say so in --build.
350
+ * SOURCE_DATE_EPOCH is let through: a fixed timestamp leaks no project state,
351
+ * and reproducible-build tooling expects to see it.
352
+ */
353
+ const CLEAN_ROOM_ENV_RE = /^(?:SOURCE_DATE_EPOCH|PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|LANG|LC_[A-Z]+|TERM|HTTPS?_PROXY|NO_PROXY|https?_proxy|no_proxy|NODE_EXTRA_CA_CERTS|SSL_CERT_(?:FILE|DIR)|SYSTEMROOT|COMSPEC|PATHEXT|WINDIR|APPDATA|LOCALAPPDATA|USERPROFILE|PROGRAMDATA)$/i;
354
+ /** Per command: generous for a cold `npm ci`, finite for CI. */
355
+ const CLEAN_ROOM_TIMEOUT_MS = 30 * 60 * 1000;
356
+ function cleanEnv() {
357
+ const env = {};
358
+ for (const [k, v] of Object.entries(process.env))
359
+ if (CLEAN_ROOM_ENV_RE.test(k))
360
+ env[k] = v;
361
+ return env;
362
+ }
363
+ /**
364
+ * Validate `--verify` before anything is written, so a typo does not leave a
365
+ * fresh archive without its BUILD.md.
366
+ */
367
+ function verifyTarget(projectDir, packagePath, commands) {
368
+ const rel = path.relative(projectDir, path.resolve(projectDir, packagePath));
369
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
370
+ throw new Error(`--verify: ${packagePath} is outside ${projectDir}; pass the package the build writes inside the project`);
371
+ }
372
+ const submitted = path.join(projectDir, rel);
373
+ if (!fs.existsSync(submitted) || !fs.statSync(submitted).isFile()) {
374
+ throw new Error(`--verify: ${submitted} does not exist — build the package you will upload first, then verify it`);
375
+ }
376
+ if (commands.length === 0) {
377
+ throw new Error('--verify: no build command — pass --build "<command>" (package.json has no build script)');
378
+ }
379
+ return rel;
380
+ }
381
+ async function verifyRebuild(input) {
382
+ const expected = sha256(fs.readFileSync(path.join(input.projectDir, input.rel)));
383
+ const cleanRoom = fs.mkdtempSync(path.join(os.tmpdir(), 'extenshi-amo-cleanroom-'));
384
+ const result = {
385
+ packagePath: input.rel.split(path.sep).join('/'),
386
+ expected,
387
+ actual: null,
388
+ match: false,
389
+ commands: input.commands,
390
+ };
391
+ try {
392
+ await extractTo(input.zip, cleanRoom);
393
+ const env = cleanEnv();
394
+ for (const command of input.commands) {
395
+ execSync(command, {
396
+ cwd: cleanRoom,
397
+ env,
398
+ stdio: input.quiet ? 'ignore' : ['ignore', process.stderr, process.stderr],
399
+ // A hung install or a watch-mode build fails the check instead of blocking forever.
400
+ timeout: CLEAN_ROOM_TIMEOUT_MS,
401
+ });
402
+ }
403
+ const rebuilt = path.join(cleanRoom, input.rel);
404
+ if (fs.existsSync(rebuilt))
405
+ result.actual = sha256(fs.readFileSync(rebuilt));
406
+ else
407
+ result.error = `the build did not write ${result.packagePath}`;
408
+ }
409
+ catch (err) {
410
+ result.error = err.message.split('\n')[0];
411
+ }
412
+ result.match = result.actual === expected;
413
+ if (result.match)
414
+ fs.rmSync(cleanRoom, { recursive: true, force: true });
415
+ else
416
+ result.cleanRoom = cleanRoom;
417
+ return result;
418
+ }
419
+ // ── BUILD.md ─────────────────────────────────────────────────────────────────
166
420
  function renderBuildMd(input) {
167
- const installCmd = input.lockfile?.installCmd ?? 'npm install';
168
- const buildCmd = input.buildScript ? `npm run ${input.buildScript}` : undefined;
169
- const commands = [installCmd, buildCmd].filter((c) => Boolean(c));
170
421
  const blockersBlock = input.blockers.length === 0
171
422
  ? '_None detected._'
172
423
  : input.blockers
173
- .map((f) => `- \`${f.path}\` — no reproducible-build banner found. Vendor its pre-minification source (e.g. under \`vendor-src/\`), add a build script that reproduces it byte-for-byte, and document the exact command here.`)
424
+ .map((f) => `- \`${f.path}\` — no reproducible-build banner found. Vendor its pre-minification source under \`vendor-src/<name>/\` (pin the upstream commit in \`vendor-src/<name>/SOURCE_COMMIT\`), add a build script that reproduces it byte-for-byte, and document the exact command here.`)
174
425
  .join('\n');
175
- const reproducibleBlock = input.reproducible.length === 0
426
+ const vendoredPaths = new Set(input.vendored.map((v) => v.path));
427
+ const vendoredBlock = input.vendored.length === 0
428
+ ? ''
429
+ : `\n## Vendored bundles rebuilt from vendor-src/\n\n${input.vendored
430
+ .map((v) => `- \`${v.path}\` — source in \`${v.sourceDir}/\` (matched by ${v.matchedBy})${v.sourceCommit ? `, upstream commit \`${v.sourceCommit}\`` : ' — no `SOURCE_COMMIT` file, name the upstream commit here'}. Document the command that rebuilds it byte-for-byte.`)
431
+ .join('\n')}\n`;
432
+ const bannerOnly = input.reproducible.filter((f) => !vendoredPaths.has(f.path));
433
+ const reproducibleBlock = bannerOnly.length === 0
176
434
  ? ''
177
- : `\n## Vendored bundles with a provenance banner\n\n${input.reproducible
435
+ : `\n## Vendored bundles with a provenance banner\n\n${bannerOnly
178
436
  .map((f) => `- \`${f.path}\` — declares its own version/commit/build command in a header comment.`)
179
437
  .join('\n')}\n`;
438
+ const v = input.verification;
439
+ const verifiedLine = v?.match === true
440
+ ? `\nThe uploaded package is \`${v.packagePath}\`, SHA-256 \`${v.expected}\`. The steps above rebuilt it byte for byte from this archive in an empty directory (\`extenshi amo-source --verify\`).\n`
441
+ : '';
180
442
  return `# Source code for AMO review
181
443
 
182
444
  Upload the sibling \`.zip\` in AMO's "Source code" step, and paste the section
@@ -189,15 +451,15 @@ Requirements:
189
451
  - Node.js ${input.nodeVersion}${input.npmVersion ? `, npm ${input.npmVersion}` : ''}${input.lockfile ? ` (${input.lockfile.manager}, lockfile included)` : ' (no lockfile found — pin your own versions here)'}
190
452
 
191
453
  \`\`\`sh
192
- ${commands.length > 0 ? commands.join('\n') : '# No install/build script detected in package.json — fill in the exact commands here.'}
454
+ ${input.commands.length > 0 ? input.commands.join('\n') : '# No install/build script detected in package.json — fill in the exact commands here.'}
193
455
  \`\`\`
194
-
456
+ ${verifiedLine}
195
457
  ${input.commit ? `Archived from commit \`${input.commit}\`.` : 'Archived from an uncommitted directory (not a git repository, or no commits yet) — there is no commit to diff against.'}
196
458
 
197
459
  ## Files that need a source-code submission
198
460
 
199
461
  ${blockersBlock}
200
- ${reproducibleBlock}
462
+ ${vendoredBlock}${reproducibleBlock}
201
463
  ## web-ext lint
202
464
 
203
465
  Run \`npx web-ext@10 lint --source-dir <built-output>\` and confirm 0 errors
@@ -216,25 +478,84 @@ export async function runAmoSource(opts) {
216
478
  }
217
479
  const zipPath = path.resolve(opts.outputPath ?? 'amo-source.zip');
218
480
  const buildMdPath = path.join(path.dirname(zipPath), 'BUILD.md');
219
- const { zip, textFiles, fileCount } = walkProject(projectDir, zipPath);
481
+ // A repository with no commit yet has no HEAD to archive; treat it as a directory.
482
+ const archive = git.isRepo && git.commit
483
+ ? await archiveFromGit(projectDir, zipPath, git.commitTime ?? FIXED_ENTRY_DATE)
484
+ : archiveFromDisk(projectDir, zipPath);
485
+ const { zip, textFiles, files, skipped } = archive;
220
486
  const minified = detectMinifiedFiles(textFiles);
221
- const blockers = minified.filter((f) => !f.hasProvenanceBanner);
222
- const reproducible = minified.filter((f) => f.hasProvenanceBanner);
223
- const pkg = readPackageJson(projectDir);
487
+ const sources = await vendorSourceDirs(zip, files);
488
+ const vendored = [];
489
+ for (const f of minified) {
490
+ const match = matchVendorSource(f.path, sources);
491
+ if (match)
492
+ vendored.push({
493
+ ...f,
494
+ sourceDir: match.source.dir,
495
+ sourceCommit: match.source.sourceCommit,
496
+ matchedBy: match.by,
497
+ });
498
+ }
499
+ const vendoredPaths = new Set(vendored.map((v) => v.path));
500
+ const blockers = minified.filter((f) => !f.hasProvenanceBanner && !vendoredPaths.has(f.path));
501
+ const reproducible = minified.filter((f) => f.hasProvenanceBanner || vendoredPaths.has(f.path));
502
+ // From the ARCHIVE, like the lockfile below: what is not in it never reaches
503
+ // the reviewer, so BUILD.md must not be derived from it.
504
+ let pkg = {};
505
+ try {
506
+ pkg = JSON.parse((await zip.file('package.json')?.async('string')) ?? '{}');
507
+ }
508
+ catch {
509
+ // An unparseable package.json — no engines, no default build script.
510
+ }
511
+ // A lockfile on disk that is gitignored does not reach the reviewer, so
512
+ // BUILD.md must not promise it and `npm ci` would fail.
513
+ const lockfile = LOCKFILES.find((l) => files.includes(l.file));
514
+ const installCmd = lockfile?.installCmd ?? 'npm install';
515
+ const buildCmd = opts.buildCommand ?? (pkg.scripts?.build ? 'npm run build' : undefined);
516
+ const commands = [installCmd, buildCmd].filter((c) => Boolean(c));
517
+ // The clean room runs only a real rebuild: an install with no build after it
518
+ // proves nothing, and `npm install` without a package.json invents one.
519
+ const rebuildCommands = buildCmd
520
+ ? [files.includes('package.json') ? installCmd : undefined, buildCmd].filter((c) => Boolean(c))
521
+ : [];
522
+ const verifyRel = opts.verifyPackage
523
+ ? verifyTarget(projectDir, opts.verifyPackage, rebuildCommands)
524
+ : undefined;
525
+ fs.mkdirSync(path.dirname(zipPath), { recursive: true });
526
+ const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', platform: 'UNIX' });
527
+ fs.writeFileSync(zipPath, buf);
528
+ const verification = verifyRel
529
+ ? await verifyRebuild({
530
+ projectDir,
531
+ // Rebuild from the bytes that were written, not from the working tree.
532
+ zip: await JSZip.loadAsync(buf),
533
+ rel: verifyRel,
534
+ commands: rebuildCommands,
535
+ quiet: opts.quiet ?? false,
536
+ })
537
+ : undefined;
224
538
  const buildMd = renderBuildMd({
225
- projectDir,
226
539
  commit: git.commit,
227
540
  nodeVersion: pkg.engines?.node ?? process.version,
228
541
  npmVersion: pkg.engines?.npm ?? npmVersion(),
229
- lockfile: detectLockfile(projectDir),
230
- buildScript: pkg.scripts?.build ? 'build' : undefined,
542
+ lockfile,
543
+ commands,
231
544
  blockers,
232
545
  reproducible,
546
+ vendored,
547
+ verification,
233
548
  });
234
- fs.mkdirSync(path.dirname(zipPath), { recursive: true });
235
- const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
236
- fs.writeFileSync(zipPath, buf);
237
549
  fs.writeFileSync(buildMdPath, buildMd, 'utf8');
238
- return { zipPath, buildMdPath, fileCount, blockers, reproducible };
550
+ return {
551
+ zipPath,
552
+ buildMdPath,
553
+ fileCount: files.length,
554
+ blockers,
555
+ reproducible,
556
+ vendored,
557
+ skipped,
558
+ verification,
559
+ };
239
560
  }
240
561
  //# sourceMappingURL=amo-source.js.map