@gjsify/cli 0.52.0 → 0.54.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/dist/cli.gjs.mjs +351 -305
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +72 -1
  5. package/lib/bundler-pick.js +175 -17
  6. package/lib/cli-app.js +7 -2
  7. package/lib/commands/blueprint/index.d.ts +4 -0
  8. package/lib/commands/blueprint/index.js +15 -0
  9. package/lib/commands/blueprint/types.d.ts +8 -0
  10. package/lib/commands/blueprint/types.js +124 -0
  11. package/lib/commands/devtools.d.ts +21 -0
  12. package/lib/commands/devtools.js +146 -0
  13. package/lib/commands/env.d.ts +31 -0
  14. package/lib/commands/env.js +114 -0
  15. package/lib/commands/exec.d.ts +10 -0
  16. package/lib/commands/exec.js +208 -0
  17. package/lib/commands/foreach.js +5 -97
  18. package/lib/commands/format.js +26 -6
  19. package/lib/commands/index.d.ts +5 -0
  20. package/lib/commands/index.js +5 -0
  21. package/lib/commands/install.d.ts +2 -2
  22. package/lib/commands/install.js +5 -5
  23. package/lib/commands/self-update.js +2 -1
  24. package/lib/commands/ship.js +51 -8
  25. package/lib/commands/test.d.ts +18 -9
  26. package/lib/commands/test.js +56 -11
  27. package/lib/commands/tsc.js +4 -4
  28. package/lib/commands/upgrade.d.ts +15 -0
  29. package/lib/commands/upgrade.js +76 -2
  30. package/lib/commands/webext/index.d.ts +2 -0
  31. package/lib/commands/webext/index.js +15 -0
  32. package/lib/commands/webext/subcommands.d.ts +16 -0
  33. package/lib/commands/webext/subcommands.js +204 -0
  34. package/lib/types/config-data.d.ts +1 -1
  35. package/lib/utils/bin-shim.d.ts +8 -2
  36. package/lib/utils/bin-shim.js +6 -5
  37. package/lib/utils/bundle-inputs.d.ts +84 -0
  38. package/lib/utils/bundle-inputs.js +268 -0
  39. package/lib/utils/check-system-deps.d.ts +4 -2
  40. package/lib/utils/check-system-deps.js +21 -26
  41. package/lib/utils/dev-link.d.ts +9 -6
  42. package/lib/utils/dev-link.js +71 -19
  43. package/lib/utils/exec-bin.d.ts +137 -0
  44. package/lib/utils/exec-bin.js +321 -0
  45. package/lib/utils/gi-runtime-paths.js +3 -2
  46. package/lib/utils/gitignore.d.ts +15 -0
  47. package/lib/utils/gitignore.js +178 -0
  48. package/lib/utils/gjsify-shim.d.ts +47 -0
  49. package/lib/utils/gjsify-shim.js +97 -8
  50. package/lib/utils/install-backend-native.d.ts +19 -1
  51. package/lib/utils/install-backend-native.js +93 -7
  52. package/lib/utils/install-lock.js +4 -25
  53. package/lib/utils/native-skip-scan.d.ts +20 -0
  54. package/lib/utils/native-skip-scan.js +343 -0
  55. package/lib/utils/node-script.js +1 -1
  56. package/lib/utils/oxc-resolve.js +46 -5
  57. package/lib/utils/process-table.d.ts +22 -0
  58. package/lib/utils/process-table.js +188 -0
  59. package/lib/utils/prune-prefix.js +1 -1
  60. package/lib/utils/run-gjs.d.ts +15 -1
  61. package/lib/utils/run-gjs.js +16 -7
  62. package/lib/utils/run-node.d.ts +2 -0
  63. package/lib/utils/run-node.js +2 -1
  64. package/lib/utils/runtimes.js +14 -13
  65. package/lib/utils/ship/app-runtime.d.ts +7 -5
  66. package/lib/utils/ship/app-runtime.js +11 -8
  67. package/lib/utils/ship/appimage.d.ts +9 -0
  68. package/lib/utils/ship/appimage.js +2 -1
  69. package/lib/utils/ship/deb.d.ts +10 -0
  70. package/lib/utils/ship/deb.js +7 -0
  71. package/lib/utils/ship/depends.d.ts +37 -3
  72. package/lib/utils/ship/depends.js +21 -9
  73. package/lib/utils/ship/gi-namespaces.d.ts +16 -0
  74. package/lib/utils/ship/gi-namespaces.js +64 -1
  75. package/lib/utils/ship/gzip.js +1 -1
  76. package/lib/utils/ship/icons.d.ts +6 -0
  77. package/lib/utils/ship/icons.js +6 -5
  78. package/lib/utils/ship/launcher.js +2 -2
  79. package/lib/utils/ship/layout.js +25 -2
  80. package/lib/utils/ship/rpm.d.ts +5 -0
  81. package/lib/utils/ship/rpm.js +42 -0
  82. package/lib/utils/ship/settings.js +6 -0
  83. package/lib/utils/ship/signing.d.ts +7 -2
  84. package/lib/utils/ship/signing.js +59 -32
  85. package/lib/utils/ship/stage-manifest.d.ts +14 -0
  86. package/lib/utils/ship/stage-manifest.js +7 -0
  87. package/lib/utils/spawn.d.ts +13 -0
  88. package/lib/utils/spawn.js +10 -5
  89. package/lib/utils/watch-loop.d.ts +15 -1
  90. package/lib/utils/watch-loop.js +9 -5
  91. package/lib/utils/webext/build.d.ts +42 -0
  92. package/lib/utils/webext/build.js +120 -0
  93. package/lib/utils/webext/config.d.ts +72 -0
  94. package/lib/utils/webext/config.js +234 -0
  95. package/lib/utils/webext/icons.d.ts +24 -0
  96. package/lib/utils/webext/icons.js +83 -0
  97. package/lib/utils/webext/launch.d.ts +32 -0
  98. package/lib/utils/webext/launch.js +77 -0
  99. package/lib/utils/webext/manifest.d.ts +53 -0
  100. package/lib/utils/webext/manifest.js +219 -0
  101. package/lib/utils/webext/pages.d.ts +26 -0
  102. package/lib/utils/webext/pages.js +61 -0
  103. package/lib/utils/webext/targets.d.ts +13 -0
  104. package/lib/utils/webext/targets.js +48 -0
  105. package/lib/utils/win32-command.d.ts +17 -0
  106. package/lib/utils/win32-command.js +21 -6
  107. package/package.json +20 -19
@@ -0,0 +1,120 @@
1
+ // `gjsify webext build|zip|dev` — assemble one folder per target (ADR 0077 § 4).
2
+ //
3
+ // Every bundle is built ONCE into a staging directory and copied into each
4
+ // target; only the manifest and the icon files differ between targets. That is
5
+ // beifahrer's measured arrangement, and it is also what makes a target cheap:
6
+ // adding `edge-mv3` to a project costs a copy, not a rebuild.
7
+ import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
9
+ import { buildZip } from '../ship/zip.js';
10
+ import { renderWebextIcons, webextIconPaths, writeWebextIcons } from './icons.js';
11
+ import { checkManifest, composeManifest, loadManifestTemplate } from './manifest.js';
12
+ import { planPage } from './pages.js';
13
+ /** The fixed ZIP timestamp when SOURCE_DATE_EPOCH is unset: 1980-01-01, the format's floor. */
14
+ const ZIP_EPOCH_FLOOR = 315532800;
15
+ /** One `SOURCE_DATE_EPOCH`-aware timestamp for every entry, so a rebuild zips to the same bytes. */
16
+ export function zipTimestamp(env = process.env) {
17
+ const epoch = Number(env.SOURCE_DATE_EPOCH);
18
+ return Number.isFinite(epoch) && epoch >= ZIP_EPOCH_FLOOR ? epoch : ZIP_EPOCH_FLOOR;
19
+ }
20
+ /** Every file under `dir`, as archive entries relative to it. */
21
+ export function zipEntriesOf(dir) {
22
+ const out = [];
23
+ const walk = (current) => {
24
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
25
+ const full = join(current, entry.name);
26
+ if (entry.isDirectory())
27
+ walk(full);
28
+ else
29
+ out.push({ path: relative(dir, full).split(/[\\/]/).join('/'), mode: 0o644, data: readFileSync(full) });
30
+ }
31
+ };
32
+ walk(dir);
33
+ return out;
34
+ }
35
+ export async function buildWebext(opts) {
36
+ const { config, targets, outDir, define } = opts;
37
+ const log = opts.log ?? ((line) => console.log(line));
38
+ const minify = opts.mode === 'production' && config.minify;
39
+ const stage = join(outDir, '.stage');
40
+ rmSync(stage, { recursive: true, force: true });
41
+ mkdirSync(stage, { recursive: true });
42
+ try {
43
+ for (const [name, entry] of config.scripts) {
44
+ await opts.bundle({ entry, outfile: join(stage, `${name}.js`), format: 'iife', define, minify });
45
+ }
46
+ const written = new Map();
47
+ for (const [name, htmlPath] of config.pages) {
48
+ const plan = planPage(name, htmlPath, readFileSync(htmlPath, 'utf8'));
49
+ for (const script of plan.scripts) {
50
+ await opts.bundle({
51
+ entry: script.entry,
52
+ outfile: join(stage, script.output),
53
+ format: script.format,
54
+ define,
55
+ minify,
56
+ });
57
+ }
58
+ for (const sheet of plan.stylesheets) {
59
+ const previous = written.get(sheet.output);
60
+ if (previous !== undefined && previous !== sheet.source) {
61
+ throw new Error(`gjsify webext: pages link ${previous} and ${sheet.source}, which would both be written as ` +
62
+ `${sheet.output}. Rename one.`);
63
+ }
64
+ written.set(sheet.output, sheet.source);
65
+ cpSync(sheet.source, join(stage, sheet.output));
66
+ }
67
+ writeFileSync(join(stage, `${name}.html`), plan.html);
68
+ }
69
+ const icons = config.icons;
70
+ const rendered = icons ? await renderWebextIcons(icons, targets, opts.rasterize) : null;
71
+ const template = await loadManifestTemplate(config.manifest);
72
+ const result = { folders: new Map(), zips: new Map() };
73
+ for (const target of targets) {
74
+ const dir = join(outDir, target.id);
75
+ if (!opts.inPlace)
76
+ rmSync(dir, { recursive: true, force: true });
77
+ mkdirSync(dir, { recursive: true });
78
+ // Public files first, so a built file of the same name wins.
79
+ if (config.publicDir)
80
+ cpSync(config.publicDir, dir, { recursive: true });
81
+ if (config.localesDir)
82
+ cpSync(config.localesDir, join(dir, '_locales'), { recursive: true });
83
+ cpSync(stage, dir, { recursive: true });
84
+ if (icons && rendered)
85
+ writeWebextIcons(rendered, icons, target, dir);
86
+ const ctx = {
87
+ target: target.id,
88
+ browser: target.browser,
89
+ manifestVersion: target.manifestVersion,
90
+ mode: opts.mode,
91
+ version: config.version,
92
+ define,
93
+ icons: (name) => {
94
+ if (!icons) {
95
+ throw new Error(`gjsify webext: the manifest asks for icon "${name}" and \`gjsify.webext.icons\` is not declared.`);
96
+ }
97
+ return webextIconPaths(icons, name, target);
98
+ },
99
+ };
100
+ const manifest = await composeManifest(template, ctx);
101
+ writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
102
+ const problems = checkManifest(manifest, target, dir);
103
+ if (problems.length > 0) {
104
+ throw new Error(`gjsify webext: ${target.id} would not load:\n - ${problems.join('\n - ')}`);
105
+ }
106
+ result.folders.set(target.id, dir);
107
+ log(`built ${relative(process.cwd(), dir) || dir}`);
108
+ if (opts.zip) {
109
+ const file = join(outDir, `${config.name}-${config.version}-${target.id}.zip`);
110
+ writeFileSync(file, buildZip(zipEntriesOf(dir), zipTimestamp()));
111
+ result.zips.set(target.id, file);
112
+ log(`zipped ${relative(process.cwd(), file) || file} (${Math.round(statSync(file).size / 1024)} KiB)`);
113
+ }
114
+ }
115
+ return result;
116
+ }
117
+ finally {
118
+ rmSync(stage, { recursive: true, force: true });
119
+ }
120
+ }
@@ -0,0 +1,72 @@
1
+ import { type WebextTarget } from './targets.js';
2
+ /** A source SVG per icon name: one file, or one per size ceiling (`{ "default": …, "32": … }`). */
3
+ export type WebextIconSource = string | Record<string, string>;
4
+ export interface WebextIconsConfig {
5
+ sizes?: number[];
6
+ sources: Record<string, WebextIconSource>;
7
+ /** Targets that receive the SVG files themselves instead of rendered PNGs. */
8
+ svg?: string[];
9
+ }
10
+ /** The block as a project writes it. */
11
+ export interface WebextConfig {
12
+ name?: string;
13
+ targets?: string[];
14
+ manifest?: string;
15
+ scripts?: Record<string, string>;
16
+ pages?: Record<string, string>;
17
+ icons?: WebextIconsConfig;
18
+ public?: string;
19
+ locales?: string;
20
+ outDir?: string;
21
+ define?: Record<string, string>;
22
+ watch?: string[];
23
+ minify?: boolean;
24
+ }
25
+ export interface ResolvedWebextIcons {
26
+ sizes: number[];
27
+ /** name → absolute SVG path per size. */
28
+ sources: Map<string, Map<number, string>>;
29
+ svgTargets: Set<string>;
30
+ }
31
+ /** The block after validation, with every path absolute. */
32
+ export interface ResolvedWebext {
33
+ root: string;
34
+ name: string;
35
+ version: string;
36
+ targets: WebextTarget[];
37
+ manifest: string;
38
+ scripts: Map<string, string>;
39
+ pages: Map<string, string>;
40
+ icons: ResolvedWebextIcons | null;
41
+ publicDir: string | null;
42
+ localesDir: string | null;
43
+ outDir: string;
44
+ define: Record<string, string>;
45
+ watch: string[];
46
+ minify: boolean;
47
+ }
48
+ export declare const DEFAULT_ICON_SIZES: number[];
49
+ interface PackageShape {
50
+ name?: string;
51
+ version?: string;
52
+ gjsify?: {
53
+ webext?: unknown;
54
+ };
55
+ }
56
+ /** The zip base name a package gets when it declares none: its npm name without the scope. */
57
+ export declare function defaultArtifactName(packageName: string | undefined): string;
58
+ /**
59
+ * Pick the SVG an icon uses at `size`: the smallest numeric ceiling that is
60
+ * `>= size`, else `default`. Returns undefined when neither covers the size.
61
+ */
62
+ export declare function iconSourceForSize(source: WebextIconSource, size: number): string | undefined;
63
+ /**
64
+ * Validate `gjsify.webext` and resolve it against the package root.
65
+ *
66
+ * `exists` is injectable so the specs can describe a project without writing
67
+ * one to disk.
68
+ */
69
+ export declare function resolveWebextConfig(pkg: PackageShape, root: string, exists?: (path: string, kind: 'file' | 'dir') => boolean): ResolvedWebext;
70
+ /** Narrow the configured targets to the ones a command was asked for. */
71
+ export declare function selectTargets(config: ResolvedWebext, requested: readonly string[] | undefined): WebextTarget[];
72
+ export {};
@@ -0,0 +1,234 @@
1
+ // `package.json#gjsify.webext` — read, validated and resolved to absolute paths
2
+ // in one place, so every subcommand (build, zip, dev) works from the same answer.
3
+ //
4
+ // Every problem is collected before throwing: a config with three mistakes
5
+ // should cost one run to fix, not three.
6
+ import { existsSync, statSync } from 'node:fs';
7
+ import { isAbsolute, join, resolve } from 'node:path';
8
+ import { DEFAULT_WEBEXT_TARGETS, parseWebextTarget } from './targets.js';
9
+ export const DEFAULT_ICON_SIZES = [16, 32, 48, 128];
10
+ const MANIFEST_CANDIDATES = ['manifest.ts', 'manifest.mts', 'manifest.js', 'manifest.mjs', 'manifest.json'];
11
+ /** Output names become file names in every target (`<name>.js`, `<name>.html`). */
12
+ const OUTPUT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
13
+ function isRecord(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ /** The zip base name a package gets when it declares none: its npm name without the scope. */
17
+ export function defaultArtifactName(packageName) {
18
+ const bare = (packageName ?? 'extension').replace(/^@[^/]+\//, '');
19
+ return bare === '' ? 'extension' : bare;
20
+ }
21
+ /**
22
+ * Pick the SVG an icon uses at `size`: the smallest numeric ceiling that is
23
+ * `>= size`, else `default`. Returns undefined when neither covers the size.
24
+ */
25
+ export function iconSourceForSize(source, size) {
26
+ if (typeof source === 'string')
27
+ return source;
28
+ const ceilings = Object.keys(source)
29
+ .filter((key) => /^\d+$/.test(key))
30
+ .map(Number)
31
+ .sort((a, b) => a - b);
32
+ const ceiling = ceilings.find((value) => size <= value);
33
+ return ceiling === undefined ? source.default : source[String(ceiling)];
34
+ }
35
+ /**
36
+ * Validate `gjsify.webext` and resolve it against the package root.
37
+ *
38
+ * `exists` is injectable so the specs can describe a project without writing
39
+ * one to disk.
40
+ */
41
+ export function resolveWebextConfig(pkg, root, exists = defaultExists) {
42
+ const problems = [];
43
+ const raw = pkg.gjsify?.webext;
44
+ if (raw === undefined) {
45
+ throw new Error('gjsify webext: this package declares no `gjsify.webext` block in package.json. ' +
46
+ 'Add one naming at least the manifest and one script or page — see `gjsify webext build --help`.');
47
+ }
48
+ if (!isRecord(raw))
49
+ throw new Error('gjsify webext: `gjsify.webext` must be an object.');
50
+ const block = raw;
51
+ const abs = (path) => (isAbsolute(path) ? path : resolve(root, path));
52
+ const need = (label, path, kind) => {
53
+ const full = abs(path);
54
+ if (!exists(full, kind))
55
+ problems.push(`${label} "${path}" is not a ${kind === 'dir' ? 'directory' : 'file'}.`);
56
+ return full;
57
+ };
58
+ const targets = [];
59
+ const targetIds = block.targets ?? [...DEFAULT_WEBEXT_TARGETS];
60
+ if (!Array.isArray(targetIds) || targetIds.length === 0) {
61
+ problems.push('`targets` must be a non-empty array of target names.');
62
+ }
63
+ else {
64
+ for (const id of targetIds) {
65
+ try {
66
+ if (targets.some((t) => t.id === id))
67
+ problems.push(`target "${id}" is listed twice.`);
68
+ else
69
+ targets.push(parseWebextTarget(String(id)));
70
+ }
71
+ catch (err) {
72
+ problems.push(err.message.replace(/^gjsify webext: /, ''));
73
+ }
74
+ }
75
+ }
76
+ let manifest = '';
77
+ if (block.manifest !== undefined) {
78
+ manifest = need('`manifest`', block.manifest, 'file');
79
+ }
80
+ else {
81
+ const found = MANIFEST_CANDIDATES.find((candidate) => exists(join(root, candidate), 'file'));
82
+ if (found === undefined) {
83
+ problems.push(`no \`manifest\` declared and none of ${MANIFEST_CANDIDATES.join(', ')} exists.`);
84
+ }
85
+ else {
86
+ manifest = join(root, found);
87
+ }
88
+ }
89
+ const outputs = new Map();
90
+ const claim = (output, by) => {
91
+ const previous = outputs.get(output);
92
+ if (previous !== undefined)
93
+ problems.push(`${by} and ${previous} both write ${output}.`);
94
+ else
95
+ outputs.set(output, by);
96
+ };
97
+ const readEntries = (key) => {
98
+ const out = new Map();
99
+ const value = block[key];
100
+ if (value === undefined)
101
+ return out;
102
+ if (!isRecord(value)) {
103
+ problems.push(`\`${key}\` must map an output name to a source file.`);
104
+ return out;
105
+ }
106
+ for (const [name, source] of Object.entries(value)) {
107
+ if (!OUTPUT_NAME_RE.test(name)) {
108
+ problems.push(`\`${key}.${name}\`: an output name is a plain file name (letters, digits, . _ -).`);
109
+ continue;
110
+ }
111
+ if (typeof source !== 'string') {
112
+ problems.push(`\`${key}.${name}\` must be a path.`);
113
+ continue;
114
+ }
115
+ out.set(name, need(`\`${key}.${name}\``, source, 'file'));
116
+ if (key === 'scripts')
117
+ claim(`${name}.js`, `\`scripts.${name}\``);
118
+ else
119
+ claim(`${name}.html`, `\`pages.${name}\``);
120
+ }
121
+ return out;
122
+ };
123
+ const scripts = readEntries('scripts');
124
+ const pages = readEntries('pages');
125
+ if (scripts.size === 0 && pages.size === 0) {
126
+ problems.push('declare at least one entry under `scripts` or `pages`; there is nothing to build.');
127
+ }
128
+ // A page's first script is written as `<page>.js`, so it competes with `scripts` for the name.
129
+ for (const name of pages.keys()) {
130
+ if (scripts.has(name))
131
+ problems.push(`\`scripts.${name}\` and the script of \`pages.${name}\` both write ${name}.js.`);
132
+ }
133
+ let icons = null;
134
+ if (block.icons !== undefined) {
135
+ if (!isRecord(block.icons) || !isRecord(block.icons.sources)) {
136
+ problems.push('`icons` must be an object with a `sources` map (icon name → SVG).');
137
+ }
138
+ else {
139
+ const sizes = block.icons.sizes ?? DEFAULT_ICON_SIZES;
140
+ if (!Array.isArray(sizes) || sizes.length === 0 || sizes.some((s) => !Number.isInteger(s) || s <= 0)) {
141
+ problems.push('`icons.sizes` must be a non-empty list of positive integers.');
142
+ }
143
+ const svgTargets = new Set(block.icons.svg ?? []);
144
+ for (const id of svgTargets) {
145
+ if (!targets.some((t) => t.id === id)) {
146
+ problems.push(`\`icons.svg\` names "${id}", which is not one of this package's targets.`);
147
+ }
148
+ }
149
+ const sources = new Map();
150
+ for (const [name, source] of Object.entries(block.icons.sources)) {
151
+ if (!OUTPUT_NAME_RE.test(name)) {
152
+ problems.push(`\`icons.sources.${name}\`: an icon name is a plain file name.`);
153
+ continue;
154
+ }
155
+ if (typeof source !== 'string' && !isRecord(source)) {
156
+ problems.push(`\`icons.sources.${name}\` must be an SVG path or a map of size ceilings to SVGs.`);
157
+ continue;
158
+ }
159
+ const bySize = new Map();
160
+ for (const size of sizes) {
161
+ const svg = iconSourceForSize(source, size);
162
+ if (svg === undefined) {
163
+ problems.push(`\`icons.sources.${name}\` has no SVG for ${size} px — add a "default".`);
164
+ continue;
165
+ }
166
+ if (!/\.svg$/i.test(svg))
167
+ problems.push(`\`icons.sources.${name}\`: "${svg}" is not an .svg file.`);
168
+ bySize.set(size, abs(svg));
169
+ }
170
+ for (const svg of new Set(bySize.values())) {
171
+ if (!exists(svg, 'file'))
172
+ problems.push(`\`icons.sources.${name}\`: "${svg}" does not exist.`);
173
+ }
174
+ sources.set(name, bySize);
175
+ }
176
+ icons = { sizes, sources, svgTargets };
177
+ }
178
+ }
179
+ const optionalDir = (key, fallback) => {
180
+ const declared = block[key];
181
+ if (declared !== undefined)
182
+ return need(`\`${key}\``, declared, 'dir');
183
+ const implicit = join(root, fallback);
184
+ return exists(implicit, 'dir') ? implicit : null;
185
+ };
186
+ const publicDir = optionalDir('public', 'public');
187
+ const localesDir = optionalDir('locales', '_locales');
188
+ const define = block.define ?? {};
189
+ if (!isRecord(define) || Object.values(define).some((v) => typeof v !== 'string')) {
190
+ problems.push('`define` must map identifiers to JS expressions given as strings.');
191
+ }
192
+ const watch = block.watch ?? [];
193
+ if (!Array.isArray(watch) || watch.some((w) => typeof w !== 'string')) {
194
+ problems.push('`watch` must be a list of directories.');
195
+ }
196
+ if (problems.length > 0) {
197
+ throw new Error(`gjsify webext: \`gjsify.webext\` in ${join(root, 'package.json')}:\n - ${problems.join('\n - ')}`);
198
+ }
199
+ return {
200
+ root,
201
+ name: block.name ?? defaultArtifactName(pkg.name),
202
+ version: pkg.version ?? '0.0.0',
203
+ targets,
204
+ manifest,
205
+ scripts,
206
+ pages,
207
+ icons,
208
+ publicDir,
209
+ localesDir,
210
+ outDir: abs(block.outDir ?? '.output'),
211
+ define: define,
212
+ watch: watch.map(abs),
213
+ minify: block.minify ?? true,
214
+ };
215
+ }
216
+ function defaultExists(path, kind) {
217
+ if (!existsSync(path))
218
+ return false;
219
+ const stat = statSync(path);
220
+ return kind === 'dir' ? stat.isDirectory() : stat.isFile();
221
+ }
222
+ /** Narrow the configured targets to the ones a command was asked for. */
223
+ export function selectTargets(config, requested) {
224
+ if (requested === undefined || requested.length === 0)
225
+ return config.targets;
226
+ return requested.map((id) => {
227
+ const found = config.targets.find((t) => t.id === id);
228
+ if (found)
229
+ return found;
230
+ // Parse first: an unknown spelling deserves the vocabulary message, not "not configured".
231
+ parseWebextTarget(id);
232
+ throw new Error(`gjsify webext: target "${id}" is not in \`gjsify.webext.targets\` (${config.targets.map((t) => t.id).join(', ')}).`);
233
+ });
234
+ }
@@ -0,0 +1,24 @@
1
+ import { type RasterizeInput } from '../ship/icons.js';
2
+ import type { ResolvedWebextIcons } from './config.js';
3
+ import type { WebextTarget } from './targets.js';
4
+ /** Where an icon lives inside a target folder, per size (`{ "16": "icons/idle-16.png" }`). */
5
+ export declare function webextIconPaths(icons: ResolvedWebextIcons, name: string, target: WebextTarget): Record<string, string>;
6
+ /**
7
+ * Every file an SVG target receives, keyed by its path in the target. Two
8
+ * different sources with one base name would overwrite each other there, so
9
+ * that is refused rather than decided by iteration order.
10
+ */
11
+ export declare function svgIconFiles(icons: ResolvedWebextIcons): Map<string, string>;
12
+ export interface RenderedIcons {
13
+ /** `icons/<name>-<size>.png` → bytes. Empty when no target needs rasters. */
14
+ png: Map<string, Uint8Array>;
15
+ /** `icons/<file>.svg` → source path. Empty when no target draws SVG. */
16
+ svg: Map<string, string>;
17
+ }
18
+ /**
19
+ * Render what the configured targets need, once for all of them: one child
20
+ * process per distinct SVG, each rendering every size that SVG is used for.
21
+ */
22
+ export declare function renderWebextIcons(icons: ResolvedWebextIcons, targets: readonly WebextTarget[], rasterize?: (input: RasterizeInput) => Promise<Map<number, Uint8Array>>): Promise<RenderedIcons>;
23
+ /** Write the icons one target receives into its folder. */
24
+ export declare function writeWebextIcons(rendered: RenderedIcons, icons: ResolvedWebextIcons, target: WebextTarget, dir: string): void;
@@ -0,0 +1,83 @@
1
+ // Extension icons from SVG (ADR 0077 § 2): PNGs at every size for the targets
2
+ // that need rasters (Chromium draws no SVG in `icons`/`action.default_icon`),
3
+ // the SVG files themselves for the targets that draw them (Firefox).
4
+ //
5
+ // The rendering is `ship`'s: librsvg in a `gjs -c` child, IHDR-checked, the same
6
+ // on a GJS-hosted and a Node-hosted CLI. Only the naming lives here, and it
7
+ // lives in ONE function (`webextIconPaths`) that both the file writer and the
8
+ // manifest's `ctx.icons(name)` read, so a manifest cannot name an icon path the
9
+ // build did not write.
10
+ import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { basename, join } from 'node:path';
12
+ import { rasterizeSvg } from '../ship/icons.js';
13
+ /** Where an icon lives inside a target folder, per size (`{ "16": "icons/idle-16.png" }`). */
14
+ export function webextIconPaths(icons, name, target) {
15
+ const bySize = icons.sources.get(name);
16
+ if (bySize === undefined) {
17
+ throw new Error(`gjsify webext: the manifest asks for icon "${name}", and \`gjsify.webext.icons.sources\` declares ` +
18
+ `${[...icons.sources.keys()].map((n) => `"${n}"`).join(', ') || 'none'}.`);
19
+ }
20
+ const svg = icons.svgTargets.has(target.id);
21
+ const out = {};
22
+ for (const size of icons.sizes) {
23
+ out[String(size)] = svg ? `icons/${basename(bySize.get(size))}` : `icons/${name}-${size}.png`;
24
+ }
25
+ return out;
26
+ }
27
+ /**
28
+ * Every file an SVG target receives, keyed by its path in the target. Two
29
+ * different sources with one base name would overwrite each other there, so
30
+ * that is refused rather than decided by iteration order.
31
+ */
32
+ export function svgIconFiles(icons) {
33
+ const files = new Map();
34
+ for (const [name, bySize] of icons.sources) {
35
+ for (const source of bySize.values()) {
36
+ const path = `icons/${basename(source)}`;
37
+ const previous = files.get(path);
38
+ if (previous !== undefined && previous !== source) {
39
+ throw new Error(`gjsify webext: icon "${name}" uses ${source}, and ${previous} has the same file name; ` +
40
+ 'an SVG target would receive only one of them as ' +
41
+ path +
42
+ '. Rename one.');
43
+ }
44
+ files.set(path, source);
45
+ }
46
+ }
47
+ return files;
48
+ }
49
+ /**
50
+ * Render what the configured targets need, once for all of them: one child
51
+ * process per distinct SVG, each rendering every size that SVG is used for.
52
+ */
53
+ export async function renderWebextIcons(icons, targets, rasterize = rasterizeSvg) {
54
+ const needsPng = targets.some((t) => !icons.svgTargets.has(t.id));
55
+ const needsSvg = targets.some((t) => icons.svgTargets.has(t.id));
56
+ const png = new Map();
57
+ if (needsPng) {
58
+ const jobs = new Map();
59
+ for (const [name, bySize] of icons.sources) {
60
+ for (const [size, svg] of bySize)
61
+ jobs.set(svg, [...(jobs.get(svg) ?? []), { size, name }]);
62
+ }
63
+ for (const [svg, uses] of jobs) {
64
+ const sizes = [...new Set(uses.map((u) => u.size))];
65
+ const rendered = await rasterize({ svg, sizes, command: 'gjsify webext' });
66
+ for (const { size, name } of uses)
67
+ png.set(`icons/${name}-${size}.png`, rendered.get(size));
68
+ }
69
+ }
70
+ return { png, svg: needsSvg ? svgIconFiles(icons) : new Map() };
71
+ }
72
+ /** Write the icons one target receives into its folder. */
73
+ export function writeWebextIcons(rendered, icons, target, dir) {
74
+ mkdirSync(join(dir, 'icons'), { recursive: true });
75
+ if (icons.svgTargets.has(target.id)) {
76
+ for (const [path, source] of rendered.svg)
77
+ copyFileSync(source, join(dir, path));
78
+ }
79
+ else {
80
+ for (const [path, bytes] of rendered.png)
81
+ writeFileSync(join(dir, path), bytes);
82
+ }
83
+ }
@@ -0,0 +1,32 @@
1
+ import { type WebextTarget } from './targets.js';
2
+ export interface WebExtRunOptions {
3
+ sourceDir: string;
4
+ target: WebextTarget;
5
+ profile: string;
6
+ browserBinary?: string;
7
+ headless?: boolean;
8
+ }
9
+ /** `web-ext run` arguments. Pure, so the flag spelling per browser is pinned by a spec. */
10
+ export declare function webExtRunArgs(opts: WebExtRunOptions): string[];
11
+ /**
12
+ * The persistent dev profile. Persistent because a fresh profile per run loses
13
+ * whatever the developer set up in the browser (a login, a granted permission)
14
+ * on every restart.
15
+ */
16
+ export declare function defaultDevProfile(name: string, target: WebextTarget, env?: NodeJS.ProcessEnv): string;
17
+ /** How to spawn web-ext: always the BARE name, with the environment that finds it. */
18
+ export interface WebExtCommand {
19
+ cmd: 'web-ext';
20
+ env: NodeJS.ProcessEnv;
21
+ }
22
+ /**
23
+ * The `web-ext` to run: the project's own install (walking up, because a
24
+ * workspace hoists it to the root), else one on PATH, else null.
25
+ *
26
+ * A project install is put on the child's PATH rather than spawned by its path:
27
+ * on Windows it is a `.cmd` shim, Node refuses to spawn a batch file directly
28
+ * (EINVAL, CVE-2024-27980), and `spawnToCompletion` routes only a BARE name
29
+ * through `cmd.exe`.
30
+ */
31
+ export declare function resolveWebExt(root: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): WebExtCommand | null;
32
+ export declare const WEB_EXT_MISSING: string;
@@ -0,0 +1,77 @@
1
+ // Launching a browser with the dev build (ADR 0077 § 6) — through `web-ext run`,
2
+ // which loads the folder as a temporary add-on and reloads it when the folder
3
+ // changes. Firefox cannot load an unpacked extension from a command-line flag at
4
+ // all; web-ext does it over the remote debugging protocol.
5
+ //
6
+ // web-ext is a Node program. Once `gjsify exec` (ADR 0076, #1812) ships, this is
7
+ // the one place that changes: the spawn goes through it and a GJS-only host can
8
+ // run the dev loop.
9
+ import { existsSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { delimiter, dirname, join } from 'node:path';
12
+ import { isOnPath } from '../check-system-deps.js';
13
+ import { webExtRunTarget } from './targets.js';
14
+ /** `web-ext run` arguments. Pure, so the flag spelling per browser is pinned by a spec. */
15
+ export function webExtRunArgs(opts) {
16
+ const driver = webExtRunTarget(opts.target);
17
+ if (driver === null) {
18
+ throw new Error(`gjsify webext dev: web-ext cannot launch ${opts.target.browser}. Build with \`gjsify webext build ` +
19
+ `--target ${opts.target.id}\` and load the folder by hand, or pass \`--no-launch\`.`);
20
+ }
21
+ const args = [
22
+ 'run',
23
+ '--source-dir',
24
+ opts.sourceDir,
25
+ '--target',
26
+ driver,
27
+ '--no-input',
28
+ '--profile-create-if-missing',
29
+ ];
30
+ if (driver === 'firefox-desktop') {
31
+ args.push('--firefox-profile', opts.profile, '--keep-profile-changes');
32
+ if (opts.browserBinary)
33
+ args.push('--firefox', opts.browserBinary);
34
+ if (opts.headless)
35
+ args.push('--arg=-headless');
36
+ }
37
+ else {
38
+ args.push('--chromium-profile', opts.profile);
39
+ if (opts.browserBinary)
40
+ args.push('--chromium-binary', opts.browserBinary);
41
+ if (opts.headless)
42
+ args.push('--arg=--headless=new');
43
+ }
44
+ return args;
45
+ }
46
+ /**
47
+ * The persistent dev profile. Persistent because a fresh profile per run loses
48
+ * whatever the developer set up in the browser (a login, a granted permission)
49
+ * on every restart.
50
+ */
51
+ export function defaultDevProfile(name, target, env = process.env) {
52
+ const cache = env.XDG_CACHE_HOME || join(homedir(), '.cache');
53
+ return join(cache, 'gjsify', 'webext', name, target.id);
54
+ }
55
+ /**
56
+ * The `web-ext` to run: the project's own install (walking up, because a
57
+ * workspace hoists it to the root), else one on PATH, else null.
58
+ *
59
+ * A project install is put on the child's PATH rather than spawned by its path:
60
+ * on Windows it is a `.cmd` shim, Node refuses to spawn a batch file directly
61
+ * (EINVAL, CVE-2024-27980), and `spawnToCompletion` routes only a BARE name
62
+ * through `cmd.exe`.
63
+ */
64
+ export function resolveWebExt(root, platform = process.platform, env = process.env) {
65
+ const leaf = platform === 'win32' ? 'web-ext.cmd' : 'web-ext';
66
+ for (let dir = root;; dir = dirname(dir)) {
67
+ const bin = join(dir, 'node_modules', '.bin');
68
+ if (existsSync(join(bin, leaf))) {
69
+ return { cmd: 'web-ext', env: { ...env, PATH: [bin, env.PATH].filter(Boolean).join(delimiter) } };
70
+ }
71
+ if (dirname(dir) === dir)
72
+ break;
73
+ }
74
+ return isOnPath('web-ext') ? { cmd: 'web-ext', env } : null;
75
+ }
76
+ export const WEB_EXT_MISSING = 'gjsify webext dev: `web-ext` is not installed. Add it to the project (`gjsify install --save-dev web-ext`) ' +
77
+ 'or install it globally, or pass `--no-launch` to rebuild without a browser.';