@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.
- package/dist/cli.gjs.mjs +202 -181
- package/lib/cli-app.js +12 -25
- package/lib/commands/create.d.ts +3 -0
- package/lib/commands/create.js +49 -6
- package/lib/commands/flatpak/check.js +1 -1
- package/lib/commands/flatpak/ci.js +1 -1
- package/lib/commands/flatpak/diff.js +1 -1
- package/lib/commands/flatpak/init.js +1 -1
- package/lib/commands/flatpak/scaffold.d.ts +2 -4
- package/lib/commands/flatpak/scaffold.js +25 -298
- package/lib/commands/flatpak/sync-flathub.js +1 -1
- package/lib/commands/foreach.js +34 -20
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.d.ts +1 -0
- package/lib/commands/install.js +32 -1
- package/lib/commands/prune.d.ts +11 -0
- package/lib/commands/prune.js +84 -0
- package/lib/commands/self-update.d.ts +1 -0
- package/lib/commands/self-update.js +55 -0
- package/lib/commands/ship.d.ts +11 -0
- package/lib/commands/ship.js +217 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +15 -0
- package/lib/templates/install.mjs.tmpl +8 -3
- package/lib/types/config-data.d.ts +136 -46
- package/lib/utils/app-metadata.d.ts +51 -0
- package/lib/utils/app-metadata.js +338 -0
- package/lib/utils/check-system-deps.d.ts +2 -2
- package/lib/utils/check-system-deps.js +43 -6
- package/lib/utils/cli-runtime-closure.d.ts +23 -0
- package/lib/utils/cli-runtime-closure.js +274 -0
- package/lib/utils/install-provenance.d.ts +44 -0
- package/lib/utils/install-provenance.js +106 -0
- package/lib/utils/prune-prefix.d.ts +83 -0
- package/lib/utils/prune-prefix.js +333 -0
- package/lib/utils/publish-headers.d.ts +13 -0
- package/lib/utils/publish-headers.js +30 -4
- package/lib/utils/ship/ar.d.ts +21 -0
- package/lib/utils/ship/ar.js +57 -0
- package/lib/utils/ship/bytes.d.ts +4 -0
- package/lib/utils/ship/bytes.js +23 -0
- package/lib/utils/ship/cpio.d.ts +22 -0
- package/lib/utils/ship/cpio.js +93 -0
- package/lib/utils/ship/deb.d.ts +24 -0
- package/lib/utils/ship/deb.js +118 -0
- package/lib/utils/ship/depends.d.ts +62 -0
- package/lib/utils/ship/depends.js +221 -0
- package/lib/utils/ship/discover.d.ts +21 -0
- package/lib/utils/ship/discover.js +129 -0
- package/lib/utils/ship/formats.d.ts +5 -0
- package/lib/utils/ship/formats.js +83 -0
- package/lib/utils/ship/gi-namespaces.d.ts +7 -0
- package/lib/utils/ship/gi-namespaces.js +49 -0
- package/lib/utils/ship/gzip.d.ts +2 -0
- package/lib/utils/ship/gzip.js +21 -0
- package/lib/utils/ship/launcher.d.ts +7 -0
- package/lib/utils/ship/launcher.js +37 -0
- package/lib/utils/ship/payload.d.ts +30 -0
- package/lib/utils/ship/payload.js +59 -0
- package/lib/utils/ship/plan.d.ts +34 -0
- package/lib/utils/ship/plan.js +167 -0
- package/lib/utils/ship/rpm-header.d.ts +29 -0
- package/lib/utils/ship/rpm-header.js +170 -0
- package/lib/utils/ship/rpm.d.ts +12 -0
- package/lib/utils/ship/rpm.js +377 -0
- package/lib/utils/ship/scripts.d.ts +31 -0
- package/lib/utils/ship/scripts.js +76 -0
- package/lib/utils/ship/settings.d.ts +60 -0
- package/lib/utils/ship/settings.js +184 -0
- package/lib/utils/ship/stage-writer.d.ts +17 -0
- package/lib/utils/ship/stage-writer.js +49 -0
- package/lib/utils/ship/types.d.ts +114 -0
- package/lib/utils/ship/types.js +10 -0
- package/lib/utils/ship/version.d.ts +17 -0
- package/lib/utils/ship/version.js +55 -0
- package/package.json +27 -18
- /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'
|
|
113
|
-
return
|
|
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[];
|