@gjsify/cli 0.4.43 → 0.5.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.
@@ -6,6 +6,7 @@
6
6
  import { flatpakInitCommand } from './init.js';
7
7
  import { flatpakBuildCommand } from './build.js';
8
8
  import { flatpakDepsCommand } from './deps.js';
9
+ import { flatpakSourcesCommand } from './sources.js';
9
10
  import { flatpakCiCommand } from './ci.js';
10
11
  import { flatpakCheckCommand } from './check.js';
11
12
  import { flatpakSyncFlathubCommand } from './sync-flathub.js';
@@ -13,12 +14,13 @@ import { flatpakDiffCommand } from './diff.js';
13
14
  import { flatpakReleaseCommand } from './release.js';
14
15
  export const flatpakCommand = {
15
16
  command: 'flatpak <subcommand>',
16
- description: 'Flatpak toolchain: init/build/deps/ci/check/sync-flathub/diff/release subcommands for shipping GJS apps and CLIs as Flatpaks.',
17
+ description: 'Flatpak toolchain: init/build/deps/sources/ci/check/sync-flathub/diff/release subcommands for shipping GJS apps and CLIs as Flatpaks.',
17
18
  builder: (yargs) => {
18
19
  return yargs
19
20
  .command(flatpakInitCommand.command, flatpakInitCommand.description, flatpakInitCommand.builder, flatpakInitCommand.handler)
20
21
  .command(flatpakBuildCommand.command, flatpakBuildCommand.description, flatpakBuildCommand.builder, flatpakBuildCommand.handler)
21
22
  .command(flatpakDepsCommand.command, flatpakDepsCommand.description, flatpakDepsCommand.builder, flatpakDepsCommand.handler)
23
+ .command(flatpakSourcesCommand.command, flatpakSourcesCommand.description, flatpakSourcesCommand.builder, flatpakSourcesCommand.handler)
22
24
  .command(flatpakCiCommand.command, flatpakCiCommand.description, flatpakCiCommand.builder, flatpakCiCommand.handler)
23
25
  .command(flatpakCheckCommand.command, flatpakCheckCommand.description, flatpakCheckCommand.builder, flatpakCheckCommand.handler)
24
26
  .command(flatpakSyncFlathubCommand.command, flatpakSyncFlathubCommand.description, flatpakSyncFlathubCommand.builder, flatpakSyncFlathubCommand.handler)
@@ -28,4 +30,4 @@ export const flatpakCommand = {
28
30
  .strict();
29
31
  },
30
32
  };
31
- export { flatpakInitCommand, flatpakBuildCommand, flatpakDepsCommand, flatpakCiCommand, flatpakCheckCommand, flatpakSyncFlathubCommand, flatpakDiffCommand, flatpakReleaseCommand, };
33
+ export { flatpakInitCommand, flatpakBuildCommand, flatpakDepsCommand, flatpakSourcesCommand, flatpakCiCommand, flatpakCheckCommand, flatpakSyncFlathubCommand, flatpakDiffCommand, flatpakReleaseCommand, };
@@ -0,0 +1,11 @@
1
+ import type { Command } from '../../types/index.js';
2
+ type LockfileType = 'gjsify' | 'npm' | 'yarn' | 'pnpm';
3
+ interface FlatpakSourcesOptions {
4
+ lockfile?: string;
5
+ type?: LockfileType;
6
+ out?: string;
7
+ cacheRoot?: string;
8
+ printModule?: boolean;
9
+ }
10
+ export declare const flatpakSourcesCommand: Command<unknown, FlatpakSourcesOptions>;
11
+ export {};
@@ -0,0 +1,318 @@
1
+ // `gjsify flatpak sources` — turn a lockfile into a flatpak-builder `sources`
2
+ // array so a Flathub build (which forbids network access) can vendor every npm
3
+ // tarball offline.
4
+ //
5
+ // Auto-detects the lockfile format — gjsify's own `gjsify-lock.json`, npm's
6
+ // `package-lock.json`, classic `yarn.lock`, or pnpm's `pnpm-lock.yaml` — so it
7
+ // is a Node-free, dependency-free replacement for the Python
8
+ // `flatpak-node-generator` that `gjsify flatpak deps` wraps (one tool, no pipx).
9
+ //
10
+ // Each locked tarball becomes a flatpak `file` source that downloads straight
11
+ // into gjsify's content-addressed tarball cache layout:
12
+ //
13
+ // $XDG_CACHE_HOME/gjsify/tarballs/v1/<algo>/<hex[0:2]>/<full-hex>.tgz
14
+ //
15
+ // (the exact path `install-tarball-cache.ts` computes from the SRI integrity).
16
+ // flatpak-builder fetches them in its network-allowed download phase; the build
17
+ // itself then runs `gjsify install --immutable` fully offline, finding every
18
+ // tarball in the pre-populated cache.
19
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { Buffer } from 'node:buffer';
22
+ /** Checksum algorithms flatpak-builder accepts on a `file` source. */
23
+ const FLATPAK_ALGOS = new Set(['sha256', 'sha512']);
24
+ /** Parse an SRI integrity (`sha512-<base64>`) into `{ algorithm, hex }`. Mirrors
25
+ * `install-tarball-cache.ts` so the emitted cache paths match byte-for-byte. */
26
+ function parseSri(integrity) {
27
+ if (!integrity)
28
+ return null;
29
+ const dash = integrity.indexOf('-');
30
+ if (dash <= 0 || dash === integrity.length - 1)
31
+ return null;
32
+ const algorithm = integrity.slice(0, dash);
33
+ const b64 = integrity.slice(dash + 1).replace(/=+$/, '');
34
+ let hex;
35
+ try {
36
+ hex = Buffer.from(b64, 'base64').toString('hex');
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ if (hex.length < 4)
42
+ return null;
43
+ return { algorithm, hex };
44
+ }
45
+ /** gjsify-lock.json + package-lock.json share npm's v2/v3 `packages` shape:
46
+ * a map keyed by install path, each with `resolved` + `integrity`. Falls back
47
+ * to npm's v1 nested `dependencies` tree. */
48
+ function parseNpmLike(json) {
49
+ const out = [];
50
+ const root = json;
51
+ const packages = root.packages;
52
+ if (packages && typeof packages === 'object') {
53
+ for (const entry of Object.values(packages)) {
54
+ if (entry?.resolved && entry.integrity)
55
+ out.push({ url: entry.resolved, integrity: entry.integrity });
56
+ }
57
+ if (out.length > 0)
58
+ return out;
59
+ }
60
+ // npm lockfileVersion 1: nested `dependencies` tree.
61
+ if (root.dependencies && typeof root.dependencies === 'object')
62
+ collectV1Deps(root.dependencies, out);
63
+ return out;
64
+ }
65
+ function collectV1Deps(deps, out) {
66
+ for (const node of Object.values(deps)) {
67
+ if (node?.resolved && node.integrity)
68
+ out.push({ url: node.resolved, integrity: node.integrity });
69
+ if (node?.dependencies)
70
+ collectV1Deps(node.dependencies, out);
71
+ }
72
+ }
73
+ /** Classic (v1) `yarn.lock`: blank-line-separated blocks, each with a
74
+ * `resolved "<url>#<sha1>"` and (yarn ≥1.x) an `integrity sha512-…` line.
75
+ * Yarn Berry (v2+) is rejected — its `checksum` is a zip-cache hash, not a
76
+ * tarball SRI, so the tarball can't be verified from the lockfile. */
77
+ function parseYarnClassic(text) {
78
+ if (/^__metadata:/m.test(text)) {
79
+ throw new Error('gjsify flatpak sources: Yarn Berry (v2+) yarn.lock is not supported — its `checksum` is ' +
80
+ 'a cache hash, not a tarball SRI. Run `gjsify install` to produce a gjsify-lock.json, ' +
81
+ 'or use a classic (v1) yarn.lock.');
82
+ }
83
+ const out = [];
84
+ let noIntegrity = 0;
85
+ for (const block of text.split(/\n\s*\n/)) {
86
+ const rm = block.match(/^\s+resolved\s+"([^"]+)"/m);
87
+ if (!rm)
88
+ continue;
89
+ const url = rm[1].replace(/#.*$/, ''); // strip the trailing #<sha1> fragment
90
+ const im = block.match(/^\s+integrity\s+(\S+)/m);
91
+ if (!im) {
92
+ noIntegrity++;
93
+ continue;
94
+ }
95
+ out.push({ url, integrity: im[1] });
96
+ }
97
+ if (noIntegrity > 0) {
98
+ console.warn(`[gjsify flatpak sources] ${noIntegrity} yarn.lock entr(y/ies) had no integrity (pre-1.x format) ` +
99
+ 'and were skipped — re-lock with a modern yarn or use `gjsify install`.');
100
+ }
101
+ return out;
102
+ }
103
+ /** pnpm-lock.yaml: each `packages:` entry's `resolution.integrity` is the
104
+ * tarball SRI; the URL is `resolution.tarball` or reconstructed from the key.
105
+ * Handles both v9 (`name@version`) and v5/6 (`/name/version`) key shapes.
106
+ *
107
+ * A small targeted scanner instead of a full YAML parser: pnpm always writes
108
+ * the resolution inline (`resolution: {integrity: …}`), so this needs no extra
109
+ * dependency and stays bundle-light. It reads ONLY the top-level `packages:`
110
+ * block (not `snapshots:`/`importers:`), keys at exactly 2-space indent. */
111
+ function parsePnpm(text) {
112
+ const out = [];
113
+ let inPackages = false;
114
+ let currentKey = null;
115
+ for (const raw of text.split('\n')) {
116
+ const line = raw.replace(/\r$/, '');
117
+ if (/^packages:\s*$/.test(line)) {
118
+ inPackages = true;
119
+ continue;
120
+ }
121
+ // A non-indented, non-comment line ends the `packages:` section.
122
+ if (inPackages && /^[^\s#]/.test(line))
123
+ inPackages = false;
124
+ if (!inPackages)
125
+ continue;
126
+ // Package key: exactly 2-space indent, quoted or bare, ending in ':'.
127
+ const keyM = line.match(/^ {2}(?:'([^']+)'|"([^"]+)"|([^\s#][^:]*)):\s*$/);
128
+ if (keyM) {
129
+ currentKey = keyM[1] ?? keyM[2] ?? keyM[3] ?? null;
130
+ continue;
131
+ }
132
+ // Inline resolution: `resolution: {integrity: sha…, tarball: …}`.
133
+ const resM = line.match(/resolution:\s*\{([^}]*)\}/);
134
+ if (resM && currentKey) {
135
+ const intM = resM[1].match(/integrity:\s*(sha\d+-[A-Za-z0-9+/=]+)/);
136
+ if (!intM)
137
+ continue;
138
+ const tarM = resM[1].match(/tarball:\s*([^\s,}]+)/);
139
+ const url = tarM ? tarM[1] : reconstructNpmTarballUrl(currentKey);
140
+ if (url)
141
+ out.push({ url, integrity: intM[1] });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /** Reconstruct the canonical npm tarball URL from a pnpm package key. Returns
147
+ * null for non-registry refs (aliases, git, …). */
148
+ function reconstructNpmTarballUrl(key) {
149
+ let name;
150
+ let version;
151
+ if (key.startsWith('/')) {
152
+ // v5/6: /name/version or /@scope/name/version, optional `_peer` suffix.
153
+ const k = key.slice(1).replace(/_.*$/, '');
154
+ const idx = k.lastIndexOf('/');
155
+ if (idx <= 0)
156
+ return null;
157
+ name = k.slice(0, idx);
158
+ version = k.slice(idx + 1);
159
+ }
160
+ else {
161
+ // v9: name@version, optional `(peer@x)` suffixes.
162
+ const k = key.replace(/\(.*\)$/, '');
163
+ const at = k.lastIndexOf('@');
164
+ if (at <= 0)
165
+ return null;
166
+ name = k.slice(0, at);
167
+ version = k.slice(at + 1);
168
+ }
169
+ // Only plain registry semver versions — skip aliases / protocols.
170
+ if (!name || !version || /[@:/]/.test(version))
171
+ return null;
172
+ const basename = name.slice(name.lastIndexOf('/') + 1);
173
+ return `https://registry.npmjs.org/${name}/-/${basename}-${version}.tgz`;
174
+ }
175
+ function detectType(file) {
176
+ const base = file.toLowerCase();
177
+ if (base.endsWith('pnpm-lock.yaml'))
178
+ return 'pnpm';
179
+ if (base.endsWith('yarn.lock'))
180
+ return 'yarn';
181
+ if (base.endsWith('package-lock.json'))
182
+ return 'npm';
183
+ return 'gjsify';
184
+ }
185
+ function defaultLockfile(cwd) {
186
+ for (const f of ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']) {
187
+ if (existsSync(join(cwd, f)))
188
+ return f;
189
+ }
190
+ return 'gjsify-lock.json'; // surfaces a clear "not found" later
191
+ }
192
+ function parseLockfile(path, type) {
193
+ const text = readFileSync(path, 'utf-8');
194
+ if (type === 'yarn')
195
+ return parseYarnClassic(text);
196
+ if (type === 'pnpm')
197
+ return parsePnpm(text);
198
+ let json;
199
+ try {
200
+ json = JSON.parse(text);
201
+ }
202
+ catch (err) {
203
+ throw new Error(`gjsify flatpak sources: ${path} is not valid JSON: ${err.message}`);
204
+ }
205
+ return parseNpmLike(json);
206
+ }
207
+ // --- command ---------------------------------------------------------------
208
+ export const flatpakSourcesCommand = {
209
+ command: 'sources',
210
+ description: 'Generate an offline flatpak-builder `sources` array from any lockfile (gjsify-lock.json / package-lock.json / yarn.lock / pnpm-lock.yaml) for a no-network Flathub build.',
211
+ builder: (yargs) => {
212
+ return yargs
213
+ .option('lockfile', {
214
+ description: 'Path to the lockfile. Default: the first of gjsify-lock.json / package-lock.json / yarn.lock / pnpm-lock.yaml found in cwd.',
215
+ type: 'string',
216
+ normalize: true,
217
+ })
218
+ .option('type', {
219
+ description: 'Lockfile format. Default: detected from the filename.',
220
+ choices: ['gjsify', 'npm', 'yarn', 'pnpm'],
221
+ })
222
+ .option('out', {
223
+ description: 'Output JSON sources file',
224
+ type: 'string',
225
+ default: 'gjsify-sources.json',
226
+ normalize: true,
227
+ })
228
+ .option('cache-root', {
229
+ description: 'Relative dir the tarballs download into; point XDG_CACHE_HOME here in the build. ' +
230
+ 'Tarballs land at <cache-root>/gjsify/tarballs/v1/<algo>/<shard>/<hex>.tgz.',
231
+ type: 'string',
232
+ default: 'flatpak-gjsify-cache',
233
+ })
234
+ .option('print-module', {
235
+ description: 'Also print a ready-to-paste flatpak manifest module snippet to stderr',
236
+ type: 'boolean',
237
+ default: false,
238
+ });
239
+ },
240
+ handler: async (args) => {
241
+ const cwd = process.cwd();
242
+ const lockfile = resolve(cwd, args.lockfile ?? defaultLockfile(cwd));
243
+ if (!existsSync(lockfile)) {
244
+ throw new Error(`gjsify flatpak sources: lockfile ${lockfile} not found. ` +
245
+ 'Run `gjsify install` (or npm/yarn/pnpm) first, or pass --lockfile.');
246
+ }
247
+ const type = args.type ?? detectType(lockfile);
248
+ const tarballs = parseLockfile(lockfile, type);
249
+ const cacheRoot = args.cacheRoot ?? 'flatpak-gjsify-cache';
250
+ // Dedupe by content hash — the same tarball is locked at many install
251
+ // paths; flatpak rejects duplicate dest/filename pairs.
252
+ const byHex = new Map();
253
+ let skippedSri = 0;
254
+ let skippedAlgo = 0;
255
+ for (const { url, integrity } of tarballs) {
256
+ const sri = parseSri(integrity);
257
+ if (!sri) {
258
+ skippedSri++;
259
+ continue;
260
+ }
261
+ if (!FLATPAK_ALGOS.has(sri.algorithm)) {
262
+ skippedAlgo++;
263
+ continue;
264
+ }
265
+ const key = `${sri.algorithm}:${sri.hex}`;
266
+ if (byHex.has(key))
267
+ continue;
268
+ const source = {
269
+ type: 'file',
270
+ url,
271
+ dest: `${cacheRoot}/gjsify/tarballs/v1/${sri.algorithm}/${sri.hex.slice(0, 2)}`,
272
+ 'dest-filename': `${sri.hex}.tgz`,
273
+ };
274
+ // FLATPAK_ALGOS gates this to exactly sha256 / sha512.
275
+ if (sri.algorithm === 'sha512')
276
+ source.sha512 = sri.hex;
277
+ else
278
+ source.sha256 = sri.hex;
279
+ byHex.set(key, source);
280
+ }
281
+ // Stable order (by url) so the generated file diffs cleanly across runs.
282
+ const sources = [...byHex.values()].sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
283
+ const out = resolve(cwd, args.out ?? 'gjsify-sources.json');
284
+ mkdirSync(dirname(out), { recursive: true });
285
+ writeFileSync(out, JSON.stringify(sources, null, 2) + '\n');
286
+ console.log(`[gjsify flatpak sources] wrote ${out} — ${sources.length} tarball source(s) from ${type} lockfile` +
287
+ ` (${tarballs.length} locked entr${tarballs.length === 1 ? 'y' : 'ies'})` +
288
+ (skippedSri ? `, ${skippedSri} without a usable integrity skipped` : '') +
289
+ (skippedAlgo ? `, ${skippedAlgo} non-sha256/512 skipped` : ''));
290
+ if (args.printModule) {
291
+ console.error(buildModuleSnippet(args.out ?? 'gjsify-sources.json', cacheRoot));
292
+ }
293
+ },
294
+ };
295
+ /** A copy-pasteable flatpak manifest module that consumes the generated
296
+ * sources file and runs a fully offline `gjsify install`. */
297
+ function buildModuleSnippet(sourcesFile, cacheRoot) {
298
+ return [
299
+ '',
300
+ '# Add the generated sources to your app module and point XDG_CACHE_HOME',
301
+ '# at the vendored cache before running gjsify offline. Example module:',
302
+ '#',
303
+ '# {',
304
+ '# "name": "my-app",',
305
+ '# "buildsystem": "simple",',
306
+ '# "build-commands": [',
307
+ `# "export XDG_CACHE_HOME=\\"$(pwd)/${cacheRoot}\\"",`,
308
+ '# "gjsify install --immutable",',
309
+ '# "gjsify build src/main.ts --app gjs --outfile /app/bin/my-app"',
310
+ '# ],',
311
+ '# "sources": [',
312
+ '# { "type": "dir", "path": "." },',
313
+ `# "${sourcesFile}"`,
314
+ '# ]',
315
+ '# }',
316
+ '',
317
+ ].join('\n');
318
+ }