@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.
- package/dist/amo-source.d.ts +88 -17
- package/dist/amo-source.d.ts.map +1 -1
- package/dist/amo-source.js +437 -116
- package/dist/amo-source.js.map +1 -1
- package/dist/cli.js +42 -11
- package/dist/cli.js.map +1 -1
- package/dist/compliance-review.d.ts +5 -1
- package/dist/compliance-review.d.ts.map +1 -1
- package/dist/compliance-review.js +10 -5
- package/dist/compliance-review.js.map +1 -1
- package/dist/cws-policies.d.ts.map +1 -1
- package/dist/cws-policies.js +4 -0
- package/dist/cws-policies.js.map +1 -1
- package/dist/listing-checks.d.ts +15 -9
- package/dist/listing-checks.d.ts.map +1 -1
- package/dist/listing-checks.js +54 -46
- package/dist/listing-checks.js.map +1 -1
- package/dist/listing.d.ts +48 -0
- package/dist/listing.d.ts.map +1 -1
- package/dist/listing.js +201 -23
- package/dist/listing.js.map +1 -1
- package/dist/png.d.ts +40 -0
- package/dist/png.d.ts.map +1 -0
- package/dist/png.js +208 -0
- package/dist/png.js.map +1 -0
- package/dist/review-checks.d.ts +48 -13
- package/dist/review-checks.d.ts.map +1 -1
- package/dist/review-checks.js +364 -56
- package/dist/review-checks.js.map +1 -1
- package/dist/review-risk.d.ts +8 -3
- package/dist/review-risk.d.ts.map +1 -1
- package/dist/review-risk.js +14 -4
- package/dist/review-risk.js.map +1 -1
- package/dist/review-stores.d.ts +63 -0
- package/dist/review-stores.d.ts.map +1 -0
- package/dist/review-stores.js +92 -0
- package/dist/review-stores.js.map +1 -0
- package/dist/screenshots-scenes.d.ts +13 -0
- package/dist/screenshots-scenes.d.ts.map +1 -1
- package/dist/screenshots-scenes.js +20 -2
- package/dist/screenshots-scenes.js.map +1 -1
- package/dist/screenshots.d.ts +6 -4
- package/dist/screenshots.d.ts.map +1 -1
- package/dist/screenshots.js +31 -10
- package/dist/screenshots.js.map +1 -1
- package/dist/store-assets.d.ts.map +1 -1
- package/dist/store-assets.js +7 -3
- package/dist/store-assets.js.map +1 -1
- package/package.json +1 -1
package/dist/amo-source.js
CHANGED
|
@@ -1,34 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `extenshi amo-source <project-dir> [--out <path>]
|
|
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
|
|
10
|
-
*
|
|
11
|
-
* 1. archives the project's
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 —
|
|
19
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
let
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 (
|
|
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 (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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${
|
|
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
|
-
|
|
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
|
|
222
|
-
const
|
|
223
|
-
const
|
|
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
|
|
230
|
-
|
|
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 {
|
|
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
|