@gjsify/cli 0.39.0 → 0.41.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 (78) hide show
  1. package/dist/cli.gjs.mjs +202 -181
  2. package/lib/cli-app.js +12 -25
  3. package/lib/commands/create.d.ts +3 -0
  4. package/lib/commands/create.js +49 -6
  5. package/lib/commands/flatpak/check.js +1 -1
  6. package/lib/commands/flatpak/ci.js +1 -1
  7. package/lib/commands/flatpak/diff.js +1 -1
  8. package/lib/commands/flatpak/init.js +1 -1
  9. package/lib/commands/flatpak/scaffold.d.ts +2 -4
  10. package/lib/commands/flatpak/scaffold.js +25 -298
  11. package/lib/commands/flatpak/sync-flathub.js +1 -1
  12. package/lib/commands/foreach.js +34 -20
  13. package/lib/commands/index.d.ts +2 -0
  14. package/lib/commands/index.js +2 -0
  15. package/lib/commands/install.d.ts +1 -0
  16. package/lib/commands/install.js +32 -1
  17. package/lib/commands/prune.d.ts +11 -0
  18. package/lib/commands/prune.js +84 -0
  19. package/lib/commands/self-update.d.ts +1 -0
  20. package/lib/commands/self-update.js +55 -0
  21. package/lib/commands/ship.d.ts +11 -0
  22. package/lib/commands/ship.js +217 -0
  23. package/lib/config.d.ts +12 -0
  24. package/lib/config.js +15 -0
  25. package/lib/templates/install.mjs.tmpl +8 -3
  26. package/lib/types/config-data.d.ts +136 -46
  27. package/lib/utils/app-metadata.d.ts +51 -0
  28. package/lib/utils/app-metadata.js +338 -0
  29. package/lib/utils/check-system-deps.d.ts +2 -2
  30. package/lib/utils/check-system-deps.js +43 -6
  31. package/lib/utils/cli-runtime-closure.d.ts +23 -0
  32. package/lib/utils/cli-runtime-closure.js +274 -0
  33. package/lib/utils/install-provenance.d.ts +44 -0
  34. package/lib/utils/install-provenance.js +106 -0
  35. package/lib/utils/prune-prefix.d.ts +83 -0
  36. package/lib/utils/prune-prefix.js +333 -0
  37. package/lib/utils/publish-headers.d.ts +13 -0
  38. package/lib/utils/publish-headers.js +30 -4
  39. package/lib/utils/ship/ar.d.ts +21 -0
  40. package/lib/utils/ship/ar.js +57 -0
  41. package/lib/utils/ship/bytes.d.ts +4 -0
  42. package/lib/utils/ship/bytes.js +23 -0
  43. package/lib/utils/ship/cpio.d.ts +22 -0
  44. package/lib/utils/ship/cpio.js +93 -0
  45. package/lib/utils/ship/deb.d.ts +24 -0
  46. package/lib/utils/ship/deb.js +118 -0
  47. package/lib/utils/ship/depends.d.ts +62 -0
  48. package/lib/utils/ship/depends.js +221 -0
  49. package/lib/utils/ship/discover.d.ts +21 -0
  50. package/lib/utils/ship/discover.js +129 -0
  51. package/lib/utils/ship/formats.d.ts +5 -0
  52. package/lib/utils/ship/formats.js +83 -0
  53. package/lib/utils/ship/gi-namespaces.d.ts +7 -0
  54. package/lib/utils/ship/gi-namespaces.js +49 -0
  55. package/lib/utils/ship/gzip.d.ts +2 -0
  56. package/lib/utils/ship/gzip.js +21 -0
  57. package/lib/utils/ship/launcher.d.ts +7 -0
  58. package/lib/utils/ship/launcher.js +37 -0
  59. package/lib/utils/ship/payload.d.ts +30 -0
  60. package/lib/utils/ship/payload.js +59 -0
  61. package/lib/utils/ship/plan.d.ts +34 -0
  62. package/lib/utils/ship/plan.js +167 -0
  63. package/lib/utils/ship/rpm-header.d.ts +29 -0
  64. package/lib/utils/ship/rpm-header.js +170 -0
  65. package/lib/utils/ship/rpm.d.ts +12 -0
  66. package/lib/utils/ship/rpm.js +377 -0
  67. package/lib/utils/ship/scripts.d.ts +31 -0
  68. package/lib/utils/ship/scripts.js +76 -0
  69. package/lib/utils/ship/settings.d.ts +60 -0
  70. package/lib/utils/ship/settings.js +184 -0
  71. package/lib/utils/ship/stage-writer.d.ts +17 -0
  72. package/lib/utils/ship/stage-writer.js +49 -0
  73. package/lib/utils/ship/types.d.ts +114 -0
  74. package/lib/utils/ship/types.js +10 -0
  75. package/lib/utils/ship/version.d.ts +17 -0
  76. package/lib/utils/ship/version.js +55 -0
  77. package/package.json +27 -18
  78. /package/lib/templates/{flatpak → app}/desktop.tmpl +0 -0
@@ -0,0 +1,333 @@
1
+ // Remove what an install prefix holds that THIS host cannot use.
2
+ //
3
+ // An install prefix is a write-only union today: `applyPlatformFilter` keeps a
4
+ // foreign-platform package from being installed, and nothing ever removes one that
5
+ // an EARLIER install placed. Measured on a workstation whose user-global prefix was
6
+ // created in May and whose CLI was current: 638 MB, of which `@rolldown` alone was
7
+ // 258 MB across sixteen platform bindings on a host that can load exactly one.
8
+ //
9
+ // The residue is ACCRETION, not a live filter defect, and the on-disk versions are
10
+ // what prove it: the foreign `@rolldown/binding-*` dirs were a minor version behind
11
+ // the `rolldown` that pins all of them, and every foreign `@gjsify/*-<os>-<arch>`
12
+ // was at the release that first SHIPPED the filter. They were written by the
13
+ // previous CLI, during the self-update that installed the fix. A filter cannot
14
+ // remove what predates it, which is why this exists as its own pass.
15
+ //
16
+ // ## What this is allowed to decide
17
+ //
18
+ // One rule, and it is a pure manifest read: npm's own `os`/`cpu`/`libc` fields,
19
+ // through the SAME `checkPlatform` the installer filters with — so a pruned prefix
20
+ // converges on what a fresh install would have placed, rather than on this module's
21
+ // opinion. A package that declares nothing is never touched: `@rolldown/binding-
22
+ // wasm32-wasi` is unusable here and says so nowhere, and guessing from its name is
23
+ // how a prune starts deleting things it cannot justify.
24
+ //
25
+ // Reachability ("nothing installed points at this any more") would catch that one
26
+ // and more, but it needs a record of what the prefix was ASSEMBLED FROM, which no
27
+ // prefix carries yet. Uncertain means keep — see ADR 0025 and
28
+ // `status/open-todos.md`.
29
+ import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { checkPlatform, declaresPlatform, describePlatformTarget, readPlatformOverrides, } from './platform-check.js';
32
+ /** Directory entries under `node_modules` that are never packages. */
33
+ function isReservedEntry(name) {
34
+ // `.bin` holds launchers, `.gjsify-install-lock` is the LIVE cross-process lock
35
+ // directory — removing it mid-install would hand the prefix to a second writer.
36
+ return name.startsWith('.');
37
+ }
38
+ function readManifest(dir) {
39
+ try {
40
+ const raw = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
41
+ const platform = {
42
+ os: raw.os,
43
+ cpu: raw.cpu,
44
+ libc: raw.libc,
45
+ };
46
+ return {
47
+ name: typeof raw.name === 'string' ? raw.name : undefined,
48
+ version: typeof raw.version === 'string' ? raw.version : undefined,
49
+ platform: declaresPlatform(platform) ? platform : undefined,
50
+ };
51
+ }
52
+ catch {
53
+ // An unreadable or half-written manifest declares nothing, which makes the
54
+ // package un-prunable by the only rule this module has. That is the safe
55
+ // direction: the one thing worse than 638 MB of residue is deleting a
56
+ // package because its manifest could not be parsed.
57
+ return null;
58
+ }
59
+ }
60
+ function collect(nodeModules, out) {
61
+ let entries;
62
+ try {
63
+ entries = readdirSync(nodeModules, { withFileTypes: true });
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ for (const entry of entries) {
69
+ if (isReservedEntry(entry.name))
70
+ continue;
71
+ const full = join(nodeModules, entry.name);
72
+ if (entry.name.startsWith('@')) {
73
+ let scoped;
74
+ try {
75
+ scoped = readdirSync(full, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ for (const inner of scoped) {
81
+ if (isReservedEntry(inner.name))
82
+ continue;
83
+ addPackage(join(full, inner.name), out);
84
+ }
85
+ continue;
86
+ }
87
+ addPackage(full, out);
88
+ }
89
+ }
90
+ function addPackage(dir, out) {
91
+ let linked = false;
92
+ try {
93
+ linked = lstatSync(dir).isSymbolicLink();
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ const manifest = readManifest(dir);
99
+ out.push({
100
+ name: manifest?.name ?? dir,
101
+ version: manifest?.version ?? '0.0.0',
102
+ dir,
103
+ linked,
104
+ platform: manifest?.platform,
105
+ });
106
+ // A linked package is the user's own source tree — its `node_modules` is not
107
+ // this prefix's to prune.
108
+ if (!linked)
109
+ collect(join(dir, 'node_modules'), out);
110
+ }
111
+ /** Every package directory under `<prefix>/node_modules`, at any depth. */
112
+ export function scanPrefix(prefix) {
113
+ const out = [];
114
+ collect(join(prefix, 'node_modules'), out);
115
+ return out;
116
+ }
117
+ /**
118
+ * The packages `target` cannot use. Pure — no filesystem, no `process.*` — so every
119
+ * OS/arch/libc branch is decidable from any host.
120
+ */
121
+ export function planPlatformPrune(pkgs, target) {
122
+ const entries = [];
123
+ for (const pkg of pkgs) {
124
+ if (pkg.linked || !pkg.platform)
125
+ continue;
126
+ const verdict = checkPlatform(pkg.platform, target);
127
+ if (!verdict.ok)
128
+ entries.push({ pkg, bytes: 0, verdict });
129
+ }
130
+ return entries;
131
+ }
132
+ /** Apparent size of a directory tree — what the files claim, not what the block device spent. */
133
+ export function dirBytes(dir) {
134
+ let total = 0;
135
+ const stack = [dir];
136
+ while (stack.length > 0) {
137
+ const current = stack.pop();
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(current, { withFileTypes: true });
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ for (const entry of entries) {
146
+ const full = join(current, entry.name);
147
+ if (entry.isDirectory()) {
148
+ stack.push(full);
149
+ }
150
+ else if (entry.isFile()) {
151
+ try {
152
+ total += statSync(full).size;
153
+ }
154
+ catch {
155
+ // A file that vanished mid-walk contributes nothing; a size is a
156
+ // report, and refusing to produce one over a race would be worse.
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return total;
162
+ }
163
+ export function planPrune(opts) {
164
+ const pkgs = scanPrefix(opts.prefix);
165
+ const entries = planPlatformPrune(pkgs, opts.target);
166
+ for (const entry of entries)
167
+ entry.bytes = dirBytes(entry.pkg.dir);
168
+ return {
169
+ prefix: opts.prefix,
170
+ target: opts.target,
171
+ scanned: pkgs.length,
172
+ entries,
173
+ bytes: entries.reduce((sum, e) => sum + e.bytes, 0),
174
+ };
175
+ }
176
+ /**
177
+ * Remove a plan's entries.
178
+ *
179
+ * `remove` is injected so the failure path is testable without a filesystem that
180
+ * can fail on demand. Nothing here throws: this runs as housekeeping at the end of
181
+ * an install, and an install that already succeeded must not be reported as failed
182
+ * because a directory could not be unlinked.
183
+ */
184
+ export function executePrune(plan, opts = {}) {
185
+ const removed = [];
186
+ const failed = [];
187
+ const binLinks = [];
188
+ // `maxRetries` mirrors the installer's own removal: on Windows a handle held by
189
+ // a virus scanner or an indexer makes the first unlink fail and the third work.
190
+ const remove = opts.remove ?? ((dir) => rmSync(dir, { recursive: true, force: true, maxRetries: 10 }));
191
+ if (!opts.dryRun) {
192
+ for (const entry of plan.entries) {
193
+ try {
194
+ remove(entry.pkg.dir);
195
+ removed.push(entry);
196
+ }
197
+ catch (err) {
198
+ failed.push({ entry, error: err.message });
199
+ }
200
+ }
201
+ binLinks.push(...pruneDeadBinLinks(plan.prefix));
202
+ }
203
+ return {
204
+ plan,
205
+ removed,
206
+ failed,
207
+ bytes: removed.reduce((sum, e) => sum + e.bytes, 0),
208
+ binLinks,
209
+ };
210
+ }
211
+ /**
212
+ * Drop `.bin` entries that are SYMLINKS whose target is gone.
213
+ *
214
+ * Only symlinks, and only dangling ones: that is the case where deadness is
215
+ * provable from the filesystem alone. A `.bin` entry that is a real file may be a
216
+ * launcher script this prefix still needs, and deciding that would mean reading it —
217
+ * a second, weaker rule for a few hundred bytes.
218
+ */
219
+ function pruneDeadBinLinks(prefix) {
220
+ const binDir = join(prefix, 'node_modules', '.bin');
221
+ const dropped = [];
222
+ let entries;
223
+ try {
224
+ entries = readdirSync(binDir, { withFileTypes: true });
225
+ }
226
+ catch {
227
+ return dropped;
228
+ }
229
+ for (const entry of entries) {
230
+ const full = join(binDir, entry.name);
231
+ try {
232
+ if (!lstatSync(full).isSymbolicLink())
233
+ continue;
234
+ if (existsSync(full))
235
+ continue;
236
+ unlinkSync(full);
237
+ dropped.push(entry.name);
238
+ }
239
+ catch {
240
+ // Same reasoning as `executePrune`: housekeeping never fails the caller.
241
+ }
242
+ }
243
+ return dropped;
244
+ }
245
+ function humanBytes(bytes) {
246
+ if (bytes < 1024)
247
+ return `${bytes} B`;
248
+ const units = ['KB', 'MB', 'GB'];
249
+ let value = bytes / 1024;
250
+ let unit = 0;
251
+ while (value >= 1024 && unit < units.length - 1) {
252
+ value /= 1024;
253
+ unit += 1;
254
+ }
255
+ return `${value.toFixed(1)} ${units[unit]}`;
256
+ }
257
+ /** How many entries a non-verbose report lists before summarising the rest. */
258
+ const REPORT_HEAD = 10;
259
+ export function formatPruneReport(result, opts) {
260
+ const { plan } = result;
261
+ const lines = [
262
+ `${plan.prefix} · target ${describePlatformTarget(plan.target)} · scanned ${plan.scanned} package(s)`,
263
+ ];
264
+ if (plan.entries.length === 0) {
265
+ // Not "every package declares a platform this host can use": most declare
266
+ // none at all, and saying otherwise reports a check that did not happen.
267
+ lines.push('nothing to prune — no installed package declares a platform this target cannot use.');
268
+ return lines.join('\n');
269
+ }
270
+ const shown = opts.verbose ? plan.entries : plan.entries.slice(0, REPORT_HEAD);
271
+ for (const entry of shown) {
272
+ const required = JSON.stringify(entry.verdict.required);
273
+ lines.push(` ${entry.pkg.name}@${entry.pkg.version} ${humanBytes(entry.bytes)} requires ${required}`);
274
+ }
275
+ if (shown.length < plan.entries.length) {
276
+ lines.push(` … and ${plan.entries.length - shown.length} more (--verbose to list them)`);
277
+ }
278
+ // The byte figure is APPARENT size, summed from the files themselves. `du`
279
+ // reports allocated blocks and will disagree; saying so here is cheaper than
280
+ // the bug report that difference otherwise produces.
281
+ lines.push(opts.dryRun
282
+ ? `would free ${humanBytes(plan.bytes)} across ${plan.entries.length} package(s) (apparent size)`
283
+ : `freed ${humanBytes(result.bytes)} across ${result.removed.length} package(s) (apparent size)`);
284
+ if (result.binLinks.length > 0) {
285
+ lines.push(`removed ${result.binLinks.length} dangling .bin link(s): ${result.binLinks.join(', ')}`);
286
+ }
287
+ for (const f of result.failed) {
288
+ lines.push(`FAILED ${f.entry.pkg.name}: ${f.error}`);
289
+ }
290
+ return lines.join('\n');
291
+ }
292
+ /** Why an automatic pass declined to run, or null when it may proceed. */
293
+ export function automaticPruneRefusal(env, immutable) {
294
+ // THE data-loss guard. `--os/--cpu/--libc` are legal on `gjsify install -g`, and
295
+ // they reach here as npm config keys. Pruning against a target the USER TYPED
296
+ // would make `gjsify install -g foo --os=darwin` delete every linux package in
297
+ // the real shared prefix — the engine set, the bundler bindings, the CLI's own.
298
+ // An explicit `gjsify prune --os=darwin` stays legal; it is a request, not a
299
+ // side effect.
300
+ if (Object.keys(readPlatformOverrides(env)).length > 0) {
301
+ return 'the platform target was overridden on the command line';
302
+ }
303
+ // `--immutable` promises the tree is not modified. Resolution-only would be a
304
+ // defensible reading, but it is not the one the flag's users have, and
305
+ // `ensureProjectGjsEngine` already declines under it for the same reason.
306
+ if (immutable)
307
+ return '--immutable';
308
+ return null;
309
+ }
310
+ /**
311
+ * The automatic pass: prune what this install would not have placed, report one
312
+ * line, and never fail the install that called it.
313
+ */
314
+ export function pruneAfterInstall(prefix, target, opts = {}) {
315
+ try {
316
+ const refusal = automaticPruneRefusal(opts.env ?? process.env, opts.immutable ?? false);
317
+ if (refusal)
318
+ return;
319
+ const plan = planPrune({ prefix, target });
320
+ if (plan.entries.length === 0)
321
+ return;
322
+ const result = executePrune(plan);
323
+ if (result.removed.length === 0)
324
+ return;
325
+ const hint = opts.hint ? ` (${opts.hint} to review)` : '';
326
+ console.log(`gjsify: pruned ${result.removed.length} package(s) this host cannot use, ` +
327
+ `freeing ${humanBytes(result.bytes)}${hint}`);
328
+ }
329
+ catch (err) {
330
+ if (process.env.GJSIFY_DEBUG)
331
+ console.error(`gjsify: prune skipped — ${err.message}`);
332
+ }
333
+ }
@@ -39,3 +39,16 @@ export interface PublishHeaderOptions {
39
39
  */
40
40
  export declare function buildPublishHeaders(url: string, opts: PublishHeaderOptions): Record<string, string>;
41
41
  export declare function cliVersion(): string;
42
+ /**
43
+ * Directory of the RUNNING `@gjsify/cli` package — the nearest ancestor of this
44
+ * module holding a `package.json` named `@gjsify/cli`; `null` when none.
45
+ *
46
+ * The climb, not a fixed `'..'` hop, for the reason {@link cliVersion} states: the
47
+ * package is dual-entry and the two entries sit at different depths.
48
+ * `GJSIFY_CLI_PACKAGE_JSON` overrides the starting point, same as for the version.
49
+ *
50
+ * Exported because the answer to "where am I installed" is the same question as
51
+ * "which version am I", and this file is where that climb lives — a second copy
52
+ * elsewhere is the drift `check-cli-own-version-read.mjs` exists to prevent.
53
+ */
54
+ export declare function cliPackageDir(): string | null;
@@ -98,19 +98,45 @@ export function cliVersion() {
98
98
  }
99
99
  /** Nearest ancestor `package.json` whose `name` is `@gjsify/cli`; '' when none. */
100
100
  function walkForCliVersion() {
101
+ const dir = cliPackageDir();
102
+ if (!dir)
103
+ return '';
104
+ try {
105
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
106
+ if (typeof pkg.version === 'string')
107
+ return pkg.version;
108
+ }
109
+ catch {
110
+ /* cliPackageDir() already parsed it once; a race or a permission flip lands here */
111
+ }
112
+ return '';
113
+ }
114
+ /**
115
+ * Directory of the RUNNING `@gjsify/cli` package — the nearest ancestor of this
116
+ * module holding a `package.json` named `@gjsify/cli`; `null` when none.
117
+ *
118
+ * The climb, not a fixed `'..'` hop, for the reason {@link cliVersion} states: the
119
+ * package is dual-entry and the two entries sit at different depths.
120
+ * `GJSIFY_CLI_PACKAGE_JSON` overrides the starting point, same as for the version.
121
+ *
122
+ * Exported because the answer to "where am I installed" is the same question as
123
+ * "which version am I", and this file is where that climb lives — a second copy
124
+ * elsewhere is the drift `check-cli-own-version-read.mjs` exists to prevent.
125
+ */
126
+ export function cliPackageDir() {
101
127
  const override = globalThis.process?.env?.['GJSIFY_CLI_PACKAGE_JSON'];
102
128
  let dir;
103
129
  try {
104
130
  dir = override ? dirname(override) : dirname(fileURLToPath(import.meta.url));
105
131
  }
106
132
  catch {
107
- return '';
133
+ return null;
108
134
  }
109
135
  for (let i = 0; i < 12; i++) {
110
136
  try {
111
137
  const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8'));
112
- if (pkg.name === '@gjsify/cli' && typeof pkg.version === 'string')
113
- return pkg.version;
138
+ if (pkg.name === '@gjsify/cli')
139
+ return dir;
114
140
  }
115
141
  catch {
116
142
  /* no manifest here (or unreadable) — keep climbing */
@@ -120,7 +146,7 @@ function walkForCliVersion() {
120
146
  break;
121
147
  dir = parent;
122
148
  }
123
- return '';
149
+ return null;
124
150
  }
125
151
  /**
126
152
  * npm-CLI-shaped user-agent, mirroring npm-registry-fetch's
@@ -0,0 +1,21 @@
1
+ export interface ArMember {
2
+ /** Member name, at most 16 bytes. */
3
+ name: string;
4
+ /** Member contents. */
5
+ data: Uint8Array;
6
+ /** Unix mtime in seconds. Default 0 — deterministic archives. */
7
+ mtime?: number;
8
+ /** POSIX mode. Default 0o100644. */
9
+ mode?: number;
10
+ }
11
+ /**
12
+ * Build an ar archive.
13
+ *
14
+ * Names are written space-padded with NO trailing `/`: that is what
15
+ * `dpkg-deb -b` emits and what dpkg's own reader expects for a `.deb`. The
16
+ * GNU `<name>/` convention and the BSD `#1/<len>` long-name extension are both
17
+ * for archives with names longer than 15 bytes — a `.deb`'s three members are
18
+ * `debian-binary`, `control.tar.<ext>` and `data.tar.<ext>`, all short, so
19
+ * neither extension can arise and refusing a long name is honest.
20
+ */
21
+ export declare function createArArchive(members: readonly ArMember[]): Uint8Array;
@@ -0,0 +1,57 @@
1
+ // The `ar` container a `.deb` is.
2
+ //
3
+ // Two hundred lines of dependency avoided: a Debian package is an ar archive of
4
+ // exactly three members, and the format is a text header per member with no
5
+ // index, no compression and no symbol table. Writing it here keeps `gjsify
6
+ // ship` free of `dpkg-deb` — which matters beyond convenience, because this
7
+ // project's CI runs on Fedora and `dpkg-deb` is not installable there without
8
+ // dragging in a foreign package manager. The same reasoning produced
9
+ // `gjsify flatpak sources` (a Node-free reader replacing
10
+ // `flatpak-node-generator`), and it has the same payoff: the packer runs
11
+ // wherever the CLI runs, including under GJS.
12
+ import { concatBytes, encodeUtf8 } from './bytes.js';
13
+ const GLOBAL_HEADER = '!<arch>\n';
14
+ const HEADER_SIZE = 60;
15
+ /**
16
+ * Build an ar archive.
17
+ *
18
+ * Names are written space-padded with NO trailing `/`: that is what
19
+ * `dpkg-deb -b` emits and what dpkg's own reader expects for a `.deb`. The
20
+ * GNU `<name>/` convention and the BSD `#1/<len>` long-name extension are both
21
+ * for archives with names longer than 15 bytes — a `.deb`'s three members are
22
+ * `debian-binary`, `control.tar.<ext>` and `data.tar.<ext>`, all short, so
23
+ * neither extension can arise and refusing a long name is honest.
24
+ */
25
+ export function createArArchive(members) {
26
+ const chunks = [encodeUtf8(GLOBAL_HEADER)];
27
+ for (const member of members) {
28
+ chunks.push(buildMemberHeader(member));
29
+ chunks.push(member.data);
30
+ // Members are aligned to an even offset; the pad byte is `\n`.
31
+ if (member.data.byteLength % 2 === 1)
32
+ chunks.push(encodeUtf8('\n'));
33
+ }
34
+ return concatBytes(chunks);
35
+ }
36
+ function buildMemberHeader(member) {
37
+ // `pad()` below is what refuses an over-long name, with the width in the
38
+ // message — a second length check here would differ only for a non-ASCII
39
+ // name, and all three member names are literals.
40
+ const header = pad(member.name, 16) +
41
+ pad(String(member.mtime ?? 0), 12) +
42
+ pad('0', 6) + // uid
43
+ pad('0', 6) + // gid
44
+ pad((member.mode ?? 0o100644).toString(8), 8) +
45
+ pad(String(member.data.byteLength), 10) +
46
+ '`\n';
47
+ if (header.length !== HEADER_SIZE) {
48
+ throw new Error(`gjsify ship: internal error — ar header is ${header.length} bytes, expected ${HEADER_SIZE}.`);
49
+ }
50
+ return encodeUtf8(header);
51
+ }
52
+ function pad(value, width) {
53
+ if (value.length > width) {
54
+ throw new Error(`gjsify ship: ar header field "${value}" does not fit in ${width} bytes.`);
55
+ }
56
+ return value.padEnd(width, ' ');
57
+ }
@@ -0,0 +1,4 @@
1
+ /** Join byte chunks into one buffer. */
2
+ export declare function concatBytes(chunks: readonly Uint8Array[]): Uint8Array;
3
+ /** UTF-8 encode. The archive formats are byte formats; every length is a BYTE length. */
4
+ export declare function encodeUtf8(value: string): Uint8Array;
@@ -0,0 +1,23 @@
1
+ // The byte-level helpers all three writers need.
2
+ //
3
+ // `concatBytes` existed once per writer before this file did — the same nine
4
+ // lines in `ar.ts`, `cpio.ts` and `rpm.ts`. The second copy is where a helper
5
+ // belongs (root AGENTS.md § Code anti-patterns): a drifted copy of THIS
6
+ // function would not throw, it would silently produce a truncated archive.
7
+ /** Join byte chunks into one buffer. */
8
+ export function concatBytes(chunks) {
9
+ let total = 0;
10
+ for (const chunk of chunks)
11
+ total += chunk.byteLength;
12
+ const out = new Uint8Array(total);
13
+ let offset = 0;
14
+ for (const chunk of chunks) {
15
+ out.set(chunk, offset);
16
+ offset += chunk.byteLength;
17
+ }
18
+ return out;
19
+ }
20
+ /** UTF-8 encode. The archive formats are byte formats; every length is a BYTE length. */
21
+ export function encodeUtf8(value) {
22
+ return new TextEncoder().encode(value);
23
+ }
@@ -0,0 +1,22 @@
1
+ export interface CpioEntry {
2
+ /** Archive path. RPM spells these `./usr/bin/foo`. */
3
+ name: string;
4
+ /** Full st_mode, i.e. file type bits OR permission bits. */
5
+ mode: number;
6
+ /** File contents; empty for directories and symlinks. */
7
+ data?: Uint8Array;
8
+ /** Symlink target — stored as the entry's data, per the cpio format. */
9
+ linkTarget?: string;
10
+ /** Unix mtime in seconds. Default 0. */
11
+ mtime?: number;
12
+ /** Inode number. RPM numbers entries from 1. */
13
+ ino?: number;
14
+ /** Hardlink count. Default 1. */
15
+ nlink?: number;
16
+ }
17
+ /** POSIX `st_mode` file-type bits, spelled out so call sites read as intent. */
18
+ export declare const S_IFREG = 32768;
19
+ export declare const S_IFDIR = 16384;
20
+ export declare const S_IFLNK = 40960;
21
+ /** Build a cpio archive, terminated by the mandatory `TRAILER!!!` entry. */
22
+ export declare function createCpioArchive(entries: readonly CpioEntry[]): Uint8Array;
@@ -0,0 +1,93 @@
1
+ // The "new ASCII" cpio archive (magic `070701`) an RPM carries as its payload.
2
+ //
3
+ // Every field is 8 hex digits, which makes the format trivially writable and —
4
+ // more usefully — trivially INSPECTABLE: `rpm2cpio x.rpm | cpio -it` reads what
5
+ // this writes, so the e2e suite has an oracle that shares no code with the
6
+ // writer. (rpm itself formats them with `%8.8lx`, i.e. LOWERCASE; both parse,
7
+ // and this writer emits uppercase.)
8
+ //
9
+ // Two padding rules, both easy to get subtly wrong and both silent when wrong:
10
+ // the filename (including its NUL) is padded so that the DATA starts on a
11
+ // 4-byte boundary counted from the start of the header, and the data itself is
12
+ // padded so the NEXT header does too.
13
+ import { concatBytes, encodeUtf8 } from './bytes.js';
14
+ const MAGIC = '070701';
15
+ const HEADER_FIELDS = 13;
16
+ const HEADER_SIZE = MAGIC.length + HEADER_FIELDS * 8; // 110
17
+ /** POSIX `st_mode` file-type bits, spelled out so call sites read as intent. */
18
+ export const S_IFREG = 0o100000;
19
+ export const S_IFDIR = 0o040000;
20
+ export const S_IFLNK = 0o120000;
21
+ /** Build a cpio archive, terminated by the mandatory `TRAILER!!!` entry. */
22
+ export function createCpioArchive(entries) {
23
+ const chunks = [];
24
+ let offset = 0;
25
+ entries.forEach((entry, index) => {
26
+ const body = entry.linkTarget !== undefined ? encodeUtf8(entry.linkTarget) : (entry.data ?? new Uint8Array(0));
27
+ const nameBytes = encodeUtf8(`${entry.name}\0`);
28
+ const header = buildHeader({
29
+ ino: entry.ino ?? index + 1,
30
+ mode: entry.mode,
31
+ nlink: entry.nlink ?? 1,
32
+ mtime: entry.mtime ?? 0,
33
+ filesize: body.byteLength,
34
+ namesize: nameBytes.byteLength,
35
+ });
36
+ offset = push(chunks, header, offset);
37
+ offset = push(chunks, nameBytes, offset);
38
+ offset = padTo4(chunks, offset);
39
+ offset = push(chunks, body, offset);
40
+ offset = padTo4(chunks, offset);
41
+ });
42
+ // The archive ends at the entry NAMED `TRAILER!!!` — that name is the
43
+ // signal, not any of the numeric fields. rpm writes `nlink = 1` there and
44
+ // zeroes the rest, so this does too.
45
+ const trailerName = encodeUtf8('TRAILER!!!\0');
46
+ offset = push(chunks, buildHeader({ ino: 0, mode: 0, nlink: 1, mtime: 0, filesize: 0, namesize: trailerName.byteLength }), offset);
47
+ offset = push(chunks, trailerName, offset);
48
+ padTo4(chunks, offset);
49
+ return concatBytes(chunks);
50
+ }
51
+ function buildHeader(fields) {
52
+ const header = MAGIC +
53
+ hex8(fields.ino) +
54
+ hex8(fields.mode) +
55
+ hex8(0) + // uid — packages install as root
56
+ hex8(0) + // gid
57
+ hex8(fields.nlink) +
58
+ hex8(fields.mtime) +
59
+ hex8(fields.filesize) +
60
+ hex8(0) + // devmajor
61
+ hex8(0) + // devminor
62
+ hex8(0) + // rdevmajor
63
+ hex8(0) + // rdevminor
64
+ hex8(fields.namesize) +
65
+ hex8(0); // check — unused for 070701, but the field is not optional
66
+ if (header.length !== HEADER_SIZE) {
67
+ throw new Error(`gjsify ship: internal error — cpio header is ${header.length} bytes, expected ${HEADER_SIZE}.`);
68
+ }
69
+ return encodeUtf8(header);
70
+ }
71
+ function hex8(value) {
72
+ if (value < 0 || !Number.isInteger(value)) {
73
+ throw new Error(`gjsify ship: cpio header field must be a non-negative integer, got ${value}.`);
74
+ }
75
+ const text = value.toString(16).toUpperCase();
76
+ if (text.length > 8) {
77
+ throw new Error(`gjsify ship: cpio header field ${value} does not fit in 8 hex digits.`);
78
+ }
79
+ return text.padStart(8, '0');
80
+ }
81
+ function push(chunks, bytes, offset) {
82
+ if (bytes.byteLength > 0)
83
+ chunks.push(bytes);
84
+ return offset + bytes.byteLength;
85
+ }
86
+ function padTo4(chunks, offset) {
87
+ const remainder = offset % 4;
88
+ if (remainder === 0)
89
+ return offset;
90
+ const padding = 4 - remainder;
91
+ chunks.push(new Uint8Array(padding));
92
+ return offset + padding;
93
+ }
@@ -0,0 +1,24 @@
1
+ import type { PayloadEntry } from './payload.js';
2
+ import type { ShipSettings } from './types.js';
3
+ export interface DebInputs {
4
+ settings: ShipSettings;
5
+ /** The staged payload, prefix-relative. */
6
+ payload: readonly PayloadEntry[];
7
+ /** Install prefix, e.g. `/usr`. */
8
+ prefix: string;
9
+ /** Already-derived dependency list, in `name`/`name >= version` form. */
10
+ depends: readonly string[];
11
+ /** Debian architecture (`amd64`, `all`, …). */
12
+ archLabel: string;
13
+ /** Unix seconds stamped into every header. */
14
+ mtime: number;
15
+ }
16
+ export declare function buildDeb(inputs: DebInputs): Promise<Uint8Array>;
17
+ /**
18
+ * `./`, `./usr/`, `./usr/bin/`, … for every path in the archive.
19
+ *
20
+ * dpkg opens each file with `O_CREAT | O_EXCL` and never creates a parent, so
21
+ * a missing directory entry is `cannot create '<path>'` mid-unpack — with the
22
+ * package half-installed.
23
+ */
24
+ export declare function ancestorDirectories(paths: readonly string[]): string[];