@gjsify/cli 0.40.0 → 0.42.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 (72) hide show
  1. package/README.md +79 -33
  2. package/dist/cli.gjs.mjs +1043 -404
  3. package/lib/actions/build.d.ts +14 -0
  4. package/lib/actions/build.js +54 -7
  5. package/lib/bundler-pick.js +10 -2
  6. package/lib/cli-app.js +3 -1
  7. package/lib/commands/browse.js +1 -1
  8. package/lib/commands/create.d.ts +3 -0
  9. package/lib/commands/create.js +49 -6
  10. package/lib/commands/debug.js +1 -1
  11. package/lib/commands/dev.d.ts +13 -0
  12. package/lib/commands/dev.js +132 -0
  13. package/lib/commands/dlx.js +1 -1
  14. package/lib/commands/index.d.ts +2 -0
  15. package/lib/commands/index.js +2 -0
  16. package/lib/commands/install.d.ts +1 -0
  17. package/lib/commands/install.js +32 -1
  18. package/lib/commands/prune.d.ts +11 -0
  19. package/lib/commands/prune.js +84 -0
  20. package/lib/commands/run.js +38 -85
  21. package/lib/commands/self-update.d.ts +1 -0
  22. package/lib/commands/self-update.js +55 -0
  23. package/lib/commands/ship.js +6 -1
  24. package/lib/commands/showcase.js +1 -1
  25. package/lib/commands/storybook.js +17 -74
  26. package/lib/commands/test.js +12 -16
  27. package/lib/commands/tsc.js +96 -45
  28. package/lib/config.js +4 -0
  29. package/lib/templates/install.mjs.tmpl +8 -3
  30. package/lib/types/config-data.d.ts +27 -0
  31. package/lib/utils/app-metadata.js +27 -6
  32. package/lib/utils/check-system-deps.d.ts +2 -2
  33. package/lib/utils/check-system-deps.js +92 -13
  34. package/lib/utils/daemon-command.d.ts +7 -0
  35. package/lib/utils/daemon-command.js +30 -0
  36. package/lib/utils/dev-plan.d.ts +52 -0
  37. package/lib/utils/dev-plan.js +117 -0
  38. package/lib/utils/gi-runtime-paths.d.ts +27 -0
  39. package/lib/utils/gi-runtime-paths.js +84 -0
  40. package/lib/utils/gi-typelib.d.ts +49 -0
  41. package/lib/utils/gi-typelib.js +160 -0
  42. package/lib/utils/install-provenance.d.ts +44 -0
  43. package/lib/utils/install-provenance.js +106 -0
  44. package/lib/utils/node-script.d.ts +4 -1
  45. package/lib/utils/node-script.js +27 -3
  46. package/lib/utils/npm-oidc.js +8 -5
  47. package/lib/utils/prune-prefix.d.ts +83 -0
  48. package/lib/utils/prune-prefix.js +333 -0
  49. package/lib/utils/run-gjs.d.ts +12 -1
  50. package/lib/utils/run-gjs.js +24 -31
  51. package/lib/utils/run-node.d.ts +9 -4
  52. package/lib/utils/run-node.js +34 -41
  53. package/lib/utils/ship/depends.d.ts +5 -0
  54. package/lib/utils/ship/depends.js +38 -0
  55. package/lib/utils/ship/discover.js +106 -1
  56. package/lib/utils/ship/launcher.js +27 -4
  57. package/lib/utils/ship/payload.d.ts +35 -0
  58. package/lib/utils/ship/payload.js +84 -0
  59. package/lib/utils/ship/plan.js +20 -0
  60. package/lib/utils/ship/settings.d.ts +8 -1
  61. package/lib/utils/ship/settings.js +13 -1
  62. package/lib/utils/ship/types.d.ts +19 -0
  63. package/lib/utils/simple-command.d.ts +16 -0
  64. package/lib/utils/simple-command.js +77 -0
  65. package/lib/utils/spawn.d.ts +7 -1
  66. package/lib/utils/spawn.js +34 -7
  67. package/lib/utils/system-gi.d.ts +24 -0
  68. package/lib/utils/system-gi.js +14 -3
  69. package/lib/utils/watch-loop.d.ts +91 -0
  70. package/lib/utils/watch-loop.js +207 -0
  71. package/package.json +27 -18
  72. package/lib/templates/app/desktop.tmpl +0 -10
@@ -15,12 +15,12 @@
15
15
  //
16
16
  // The node binary is `process.execPath` when the CLI runs under Node, else PATH `node` — under
17
17
  // the GJS bundle `process.execPath` is `gjs`, which cannot run a `--app node` bundle.
18
- import { spawn } from 'node:child_process';
19
18
  import { existsSync, readFileSync } from 'node:fs';
20
19
  import { dirname, join, resolve } from 'node:path';
21
20
  import { isNode } from '@gjsify/rolldown-plugin-gjsify/runtime';
22
21
  import { computeNativeEnvForBundle } from './run-gjs.js';
23
22
  import { RUNTIMES } from './runtimes.js';
23
+ import { describeExit, spawnToCompletion } from './spawn.js';
24
24
  /**
25
25
  * Walk up from `startDir` for `<node_modules>/@gjsify/node-gi`, returning its package directory
26
26
  * or null. A plain fs walk because Node's own resolver is not available under the GJS-bundled CLI.
@@ -60,7 +60,7 @@ export function nodeBinary() {
60
60
  * Run a `--app node` bundle on Node.js with the native typelib env set (same detection as
61
61
  * {@link runGjsBundle}). Throws when `@gjsify/node-gi` is not resolvable.
62
62
  */
63
- export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
63
+ export async function runNodeBundle(bundlePath, extraArgs = [], options) {
64
64
  const cwd = process.cwd();
65
65
  if (!resolveNodeGiForBundle(bundlePath, cwd)) {
66
66
  throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed.\n' +
@@ -74,27 +74,28 @@ export async function runNodeBundle(bundlePath, extraArgs = [], options = {}) {
74
74
  // Echo on stderr, so a child speaking a protocol on stdout stays uncontaminated.
75
75
  const cmd = [nodeBin, ...nodeArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
76
76
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${cmd}`);
77
- const child = spawn(nodeBin, nodeArgs, { env, stdio: 'inherit' });
78
- await new Promise((resolvePromise, reject) => {
79
- child.on('close', (code) => {
80
- if (code !== 0)
81
- reject(new Error(`node exited with code ${code}`));
82
- else
83
- resolvePromise();
84
- });
85
- child.on('error', reject);
86
- }).catch((err) => {
77
+ let result;
78
+ try {
79
+ result = await spawnToCompletion(nodeBin, nodeArgs, { completion: options.completion, env });
80
+ }
81
+ catch (err) {
87
82
  console.error(err.message);
88
- process.exit(1);
89
- });
83
+ return process.exit(1);
84
+ }
85
+ if (result.code !== 0) {
86
+ console.error(`node exited with ${describeExit(result)}`);
87
+ return process.exit(result.code ?? 1);
88
+ }
90
89
  if (options.exitOnSuccess)
91
- process.exit(0);
90
+ return process.exit(0);
92
91
  }
93
92
  /**
94
93
  * Whether a `--app node` bundle actually consumes `@gjsify/node-gi` — kept EXTERNAL, so a bundle
95
94
  * using gi:// imports the shim by name. A plain Node app does NOT, and must run on node/bun/deno
96
- * WITHOUT node-gi installed, which is why the check is conditional here and not unconditional
97
- * like the storybook path.
95
+ * WITHOUT node-gi installed, which is why the check is conditional rather than a flat "this is a
96
+ * node bundle, demand node-gi". The supervised launcher (`utils/watch-loop.ts`) asks the same
97
+ * question for the same reason: `gjsify dev` on a web-server template builds a node bundle that
98
+ * never reaches a typelib.
98
99
  */
99
100
  export function bundleRequiresNodeGi(bundlePath) {
100
101
  try {
@@ -112,7 +113,7 @@ export function bundleRequiresNodeGi(bundlePath) {
112
113
  *
113
114
  * `gjs` is intentionally NOT handled here — the caller dispatches it to {@link runGjsBundle}.
114
115
  */
115
- export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], options = {}) {
116
+ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], options) {
116
117
  const cwd = process.cwd();
117
118
  const resolvedBundle = resolve(bundlePath);
118
119
  if (bundleRequiresNodeGi(resolvedBundle) && !resolveNodeGiForBundle(resolvedBundle, cwd)) {
@@ -131,32 +132,24 @@ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], opti
131
132
  const shown = [cmd, ...launchArgs.map((a) => (a.includes(' ') ? `"${a}"` : a))].join(' ');
132
133
  if (!options.quiet)
133
134
  console.error(`$ ${envPrefix ? `${envPrefix} ` : ''}${shown}`);
134
- const child = spawn(cmd, launchArgs, { env, stdio: 'inherit' });
135
135
  // Re-raise the CHILD's own code rather than a blanket 1 — a script run through
136
136
  // `--node-script` reports its status through this path, and a caller reading `$?` needs
137
- // the real one.
138
- let failed = false;
139
- let childCode = 1;
140
- await new Promise((resolvePromise, reject) => {
141
- child.on('close', (code) => {
142
- if (code !== 0) {
143
- childCode = code ?? 1;
144
- reject(new Error(`${runtime} exited with code ${code}`));
145
- }
146
- else
147
- resolvePromise();
148
- });
149
- child.on('error', reject);
150
- }).catch((err) => {
137
+ // the real one. A spawn failure has no code of its own and falls back to 1.
138
+ const failed = (code) => {
139
+ process.exitCode = code;
140
+ return process.exit(code);
141
+ };
142
+ let result;
143
+ try {
144
+ result = await spawnToCompletion(cmd, launchArgs, { completion: options.completion, env });
145
+ }
146
+ catch (err) {
151
147
  console.error(err.message);
152
- failed = true;
153
- });
154
- // `return process.exit(…)`, and a flag rather than an exit inside the `catch`: under GJS
155
- // `process.exit()` is deferred and RETURNS, so a bare call in the catch fell through to the
156
- // `exitOnSuccess` line below and scheduled a second exit.
157
- if (failed) {
158
- process.exitCode = childCode;
159
- return process.exit(childCode);
148
+ return failed(1);
149
+ }
150
+ if (result.code !== 0) {
151
+ console.error(`${runtime} exited with ${describeExit(result)}`);
152
+ return failed(result.code ?? 1);
160
153
  }
161
154
  if (options.exitOnSuccess)
162
155
  return process.exit(0);
@@ -33,6 +33,11 @@ export interface DependsInputs {
33
33
  deb: string;
34
34
  rpm: string;
35
35
  }>;
36
+ /**
37
+ * Paths of typelib files the payload carries itself. The namespaces they cover need no distro
38
+ * dependency — and are derived from these filenames, never declared separately.
39
+ */
40
+ bundledTypelibs?: readonly string[];
36
41
  /** Minimum GJS. Default {@link DEFAULT_GJS_FLOOR}. */
37
42
  minGjsVersion?: string;
38
43
  }
@@ -105,7 +105,30 @@ const TYPELIB_PACKAGES = {
105
105
  'Notify-0.7': { deb: 'gir1.2-notify-0.7', rpm: 'libnotify' },
106
106
  'Vte-3.91': { deb: 'gir1.2-vte-3.91', rpm: 'vte291-gtk4' },
107
107
  'Manette-0.2': { deb: 'gir1.2-manette-0.2', rpm: 'libmanette' },
108
+ // libgda — what `@gjsify/sqlite` binds, so every project with a local database lands here.
109
+ // The rpm name is measured on this machine (`rpm -qf …/Gda-6.0.typelib` → libgda); the deb name
110
+ // follows the `gir1.2-<name>-<version>` convention every row above uses and is NOT verified
111
+ // against a Debian system. Said plainly because a wrong name fails at `apt install` with a
112
+ // clear error, while no row at all fails the BUILD of every such project.
113
+ 'Gda-6.0': { deb: 'gir1.2-gda-6.0', rpm: 'libgda' },
108
114
  };
115
+ /**
116
+ * A typelib the payload CARRIES, rather than one the system provides.
117
+ *
118
+ * `@gjsify/*`'s native bridges ship their own `.typelib` inside the package —
119
+ * `GjsifyHttpSoupBridge`, which `@gjsify/http`'s server pulls in unconditionally,
120
+ * plus `GjsifyHttp2`, `GjsifySabNative` and the rest of the `*-native` set. No
121
+ * distribution has ever packaged a `gir1.2-gjsifyhttpsoupbridge` and none can:
122
+ * the file is in the tarball being built. So these are not a gap in the table.
123
+ * Mapping one would emit a dependency apt and dnf must fail to resolve, and the
124
+ * module header's rule — an unmapped namespace FAILS the build — is about a
125
+ * SYSTEM library nobody declared, which is the opposite situation.
126
+ *
127
+ * Excluding them is a precondition for widening the namespace scanner rather
128
+ * than a refinement of it: without this, `gjsify ship` throws on every project
129
+ * whose bundle reaches one, which includes every `@gjsify/http` server.
130
+ */
131
+ const BUNDLED_TYPELIB = /^Gjsify[A-Z]/;
109
132
  /**
110
133
  * The `Depends:` / `Requires:` list for one format.
111
134
  *
@@ -114,7 +137,22 @@ const TYPELIB_PACKAGES = {
114
137
  export function deriveDepends(format, inputs) {
115
138
  const out = [`gjs >= ${inputs.minGjsVersion ?? DEFAULT_GJS_FLOOR}`];
116
139
  const unmapped = [];
140
+ // Namespaces the package SHIPS ITSELF, read off the staged filenames rather than from a
141
+ // separate declaration: `Gwebgl-0.1.typelib` → `Gwebgl-0.1` and `Gwebgl`. Deriving it is the
142
+ // point — a project that declared a namespace bundled without the file being there would get
143
+ // a package that installs and dies at the first import, which is exactly what this check
144
+ // exists to prevent.
145
+ const shipped = new Set();
146
+ for (const file of inputs.bundledTypelibs ?? []) {
147
+ const match = /([A-Za-z0-9]+)-([\d.]+)\.typelib$/.exec(file);
148
+ if (!match)
149
+ continue;
150
+ shipped.add(`${match[1]}-${match[2]}`);
151
+ shipped.add(match[1]);
152
+ }
117
153
  for (const namespace of [...new Set(inputs.namespaces)].sort()) {
154
+ if (BUNDLED_TYPELIB.test(namespace) || shipped.has(namespace))
155
+ continue;
118
156
  const entry = lookupTypelib(namespace, inputs.typelibPackages);
119
157
  if (entry === undefined) {
120
158
  unmapped.push(namespace);
@@ -12,15 +12,120 @@ export function discoverPayload(input) {
12
12
  const { projectDir, ship } = input;
13
13
  const bundlePath = resolveBundle(input);
14
14
  const bundleDir = resolve(bundlePath, '..');
15
+ const localeFiles = discoverLocales(projectDir, ship.localeDir);
15
16
  return {
16
17
  bundlePath,
17
18
  bundleDir,
18
- bundleFiles: listFilesRecursive(bundleDir),
19
+ // Everything beside the bundle, MINUS the locale tree when it lives there.
20
+ //
21
+ // `dist/locale/` sits next to `dist/app.gjs.mjs` in the layout
22
+ // `@gjsify/vite-plugin-gettext` writes by default, and the wholesale bundle staging would
23
+ // then put every catalogue in `lib/<binary>/locale/` as well as in `share/locale/`.
24
+ // Measured on a probe package: the same `.mo` appeared at both paths. The `lib/` copy is
25
+ // dead weight — nothing looks there — and it is the same failure that once shipped the test
26
+ // suite: whatever is beside the bundle gets carried whether or not it belongs in a package.
27
+ bundleFiles: withoutLocaleTree(listFilesRecursive(bundleDir), bundleDir, projectDir, ship.localeDir),
19
28
  iconFiles: discoverIcons(projectDir, ship.icon ?? input.flatpakIcon),
20
29
  schemaFiles: discoverSchemas(projectDir, ship.schemas),
30
+ typelibFiles: discoverTypelibs(projectDir, ship.bundledTypelibs),
31
+ localeFiles,
21
32
  licenseFile: discoverLicense(projectDir, ship.licenseFile),
22
33
  };
23
34
  }
35
+ /**
36
+ * Typelibs (and their shared libraries) the PROJECT ships itself.
37
+ *
38
+ * Necessary because gjsify's own GI libraries — `Gwebgl` for the WebGL bridge, the GTK runtime
39
+ * bundles, the napi host — arrive as npm prebuilds, not as distro packages. An app using one has
40
+ * no `gir1.2-…` to depend on: it must carry the files, and something must point GI at them.
41
+ *
42
+ * A directory rather than a list of files, because a typelib is never alone: `Gwebgl-0.1.typelib`
43
+ * is useless without `libgwebgl.so`, and staging one without the other produces a package that
44
+ * installs and then fails at the first import.
45
+ */
46
+ function discoverTypelibs(projectDir, dirs) {
47
+ const out = [];
48
+ for (const dir of dirs ?? []) {
49
+ const root = resolve(projectDir, dir);
50
+ if (!existsSync(root)) {
51
+ throw new Error(`gjsify ship: \`gjsify.ship.bundledTypelibs\` names ${dir}, which does not exist. ` +
52
+ 'A missing directory here means the package would install without the typelib it promises.');
53
+ }
54
+ for (const file of listFilesRecursive(root)) {
55
+ if (/\.typelib$|\.so(\.\d+)*$/.test(file))
56
+ out.push(join(root, file));
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Drop the declared locale tree from the wholesale bundle staging.
63
+ *
64
+ * Only when it actually lies inside the bundle directory — a `localeDir` elsewhere in the project
65
+ * was never part of `bundleFiles` and there is nothing to subtract.
66
+ */
67
+ function withoutLocaleTree(bundleFiles, bundleDir, projectDir, localeDir) {
68
+ if (localeDir === undefined)
69
+ return bundleFiles;
70
+ const localeRoot = resolve(projectDir, localeDir);
71
+ const rel = relative(bundleDir, localeRoot);
72
+ // Outside the bundle dir: `relative` climbs out (`..`) or returns an absolute path.
73
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
74
+ return bundleFiles;
75
+ // Only `rel` needs normalising: it comes from `relative`, so it is host-shaped, while
76
+ // `bundleFiles` is POSIX by `listFilesRecursive`'s contract. Comparing a host-shaped prefix
77
+ // against POSIX entries matches nothing on Windows — and matching nothing here means the
78
+ // catalogues quietly ship twice again, on that platform only.
79
+ const prefix = `${rel.split(sep).join(posix.sep)}${posix.sep}`;
80
+ return bundleFiles.filter((file) => !`${file}${posix.sep}`.startsWith(prefix));
81
+ }
82
+ /**
83
+ * Compiled gettext catalogues the project ships (`gjsify.ship.localeDir`).
84
+ *
85
+ * Only `.mo` files, and only in the layout `bindtextdomain` actually reads —
86
+ * `<lang>/LC_MESSAGES/<domain>.mo`. Both refusals cover the same failure, which is the quiet kind:
87
+ * a catalogue in the wrong place, or left as `.po`, installs perfectly and the app then shows the
88
+ * untranslated msgid forever. That is indistinguishable from "no translation exists", so it is not
89
+ * something a user or a maintainer would ever report as a packaging bug.
90
+ */
91
+ function discoverLocales(projectDir, dir) {
92
+ if (dir === undefined)
93
+ return [];
94
+ const root = resolve(projectDir, dir);
95
+ if (!existsSync(root)) {
96
+ throw new Error(`gjsify ship: \`gjsify.ship.localeDir\` names ${dir}, which does not exist. ` +
97
+ 'Compile the catalogues first (msgfmt, or @gjsify/vite-plugin-gettext) — a missing ' +
98
+ 'directory here means the package would install without the translations it promises.');
99
+ }
100
+ const out = [];
101
+ const strays = [];
102
+ for (const rel of listFilesRecursive(root)) {
103
+ if (rel.endsWith('.po') || rel.endsWith('.pot')) {
104
+ strays.push(`${rel} (a .po source; bindtextdomain reads .mo only)`);
105
+ continue;
106
+ }
107
+ if (!rel.endsWith('.mo'))
108
+ continue;
109
+ // `listFilesRecursive` normalises to POSIX, so one separator is all there is to split on.
110
+ const parts = rel.split(posix.sep);
111
+ if (parts.length !== 3 || parts[1] !== 'LC_MESSAGES') {
112
+ strays.push(`${rel} (expected <lang>/LC_MESSAGES/<domain>.mo)`);
113
+ continue;
114
+ }
115
+ out.push({ rel: parts.join('/'), abs: join(root, rel) });
116
+ }
117
+ if (strays.length > 0) {
118
+ throw new Error(`gjsify ship: \`gjsify.ship.localeDir\` (${dir}) holds files no catalogue lookup will find:\n` +
119
+ strays.map((s) => ` ${s}`).join('\n') +
120
+ '\nA misplaced catalogue installs and then shows nothing, which reads exactly like having ' +
121
+ 'no translation at all. Lay them out as <lang>/LC_MESSAGES/<domain>.mo.');
122
+ }
123
+ if (out.length === 0) {
124
+ throw new Error(`gjsify ship: \`gjsify.ship.localeDir\` (${dir}) contains no \`.mo\` catalogue. ` +
125
+ 'Declaring a locale directory that ships nothing is a promise the package does not keep.');
126
+ }
127
+ return out;
128
+ }
24
129
  function resolveBundle(input) {
25
130
  const declared = input.declaredBundle;
26
131
  if (declared === undefined) {
@@ -23,7 +23,7 @@ function shellQuote(value) {
23
23
  export function renderLauncher(settings, bundleRelPath) {
24
24
  const lib = `"$prefix"/lib/${settings.binaryName}`;
25
25
  const args = settings.execArgs.map(shellQuote).join(' ');
26
- return [
26
+ const lines = [
27
27
  '#!/bin/sh',
28
28
  '# Generated by `gjsify ship` — do not edit.',
29
29
  'set -e',
@@ -31,7 +31,30 @@ export function renderLauncher(settings, bundleRelPath) {
31
31
  'prefix=$(dirname "$(dirname "$self")")',
32
32
  'XDG_DATA_DIRS="$prefix/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"',
33
33
  'export XDG_DATA_DIRS',
34
- `exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`,
35
- '',
36
- ].join('\n');
34
+ ];
35
+ // Typelibs the package carries itself (see `gjsify.ship.bundledTypelibs`). PREPENDED, not
36
+ // replacing: an app that ships `Gwebgl` still needs the system `Gtk`, and clobbering the path
37
+ // would trade one missing namespace for all the others. Only emitted when something was
38
+ // actually staged — an empty `GI_TYPELIB_PATH` entry makes GI search a directory that is not
39
+ // there, on every import, forever.
40
+ if (settings.typelibFiles.length > 0) {
41
+ const giDir = '"$prefix"/lib/' + settings.binaryName + '/gi';
42
+ for (const variable of ['GI_TYPELIB_PATH', 'LD_LIBRARY_PATH']) {
43
+ // `${VAR:+:$VAR}` appends the existing value with a separator ONLY when it is set —
44
+ // a plain `:$VAR` leaves a trailing colon, which both loaders read as "and the current
45
+ // directory", and a package that silently adds `.` to a library path is a bad package.
46
+ lines.push(`${variable}=${giDir}\${${variable}:+:$${variable}}`, `export ${variable}`);
47
+ }
48
+ }
49
+ // Where the app's gettext catalogues landed. `bindtextdomain` takes a DIRECTORY and there is no
50
+ // standard environment variable it reads on its own (`TEXTDOMAINDIR` is honoured by the gettext
51
+ // command-line tools, not by the library), so the prefix has to reach the app somehow. Passing
52
+ // it from the launcher keeps the app free of install-layout knowledge, exactly as XDG_DATA_DIRS
53
+ // above does for icons and schemas — a baked `/usr/share/locale` would be wrong in a Flatpak
54
+ // and in any `--prefix` tree.
55
+ if (settings.localeFiles.length > 0) {
56
+ lines.push('GJSIFY_LOCALE_DIR="$prefix"/share/locale', 'export GJSIFY_LOCALE_DIR');
57
+ }
58
+ lines.push(`exec gjs -m ${lib}/${bundleRelPath}${args ? ` ${args}` : ''} "$@"`, '');
59
+ return lines.join('\n');
37
60
  }
@@ -28,3 +28,38 @@ export declare function buildTimestamp(bundlePath: string, env?: Record<string,
28
28
  * does not run.
29
29
  */
30
30
  export declare function isArchIndependent(payload: readonly PayloadEntry[]): boolean;
31
+ /**
32
+ * The `process.arch` token an image says it is built for, or `null` when the
33
+ * question cannot be answered from the bytes.
34
+ *
35
+ * `null` covers three different things on purpose, and all three must stay
36
+ * silent: a file that is not a native binary at all (most of a payload), a PE
37
+ * (whose COFF machine field this tree has never parsed — `isNativeBinary` reads
38
+ * `MZ` and stops), and a Mach-O fat archive, which carries several
39
+ * architectures and therefore matches any label a caller could pass.
40
+ */
41
+ export declare function readBinaryArch(data: Uint8Array): string | null;
42
+ /**
43
+ * Refuse a payload whose binaries disagree with the label the package will carry.
44
+ *
45
+ * THE INCIDENT, measured on 0.41.0 before this existed. A project whose payload
46
+ * carries one x86-64 `.so`, packed on this x86-64 host:
47
+ *
48
+ * gjsify ship --skip-build --arch arm64
49
+ * → xarch-demo_1.2.3-1_arm64.deb, xarch-demo-1.2.3-1.aarch64.rpm
50
+ * rpm -qp --qf '%{ARCH}' → aarch64
51
+ * the .so inside it → ELF e_machine 0x3e (x86-64)
52
+ *
53
+ * `--arch` LABELS the payload; it does not cross-compile it, and nothing
54
+ * compared the two. The result installs on an arm64 machine — apt and dnf both
55
+ * believe the header — and then fails to load, which is this tree's most
56
+ * expensive failure class with an independent oracle actively confirming the
57
+ * lie: `rpm` reads the header, and the header was written from the caller's
58
+ * claim.
59
+ *
60
+ * Payload against LABEL, never payload against HOST. Assembling an arm64
61
+ * artifact on an x64 machine is a supported path — the packers are pure
62
+ * JavaScript and ADR 0024 § A1 turns it into a design commitment — so a host
63
+ * comparison would refuse the very case the split exists to allow.
64
+ */
65
+ export declare function assertPayloadMatchesArch(payload: readonly PayloadEntry[], arch: string): void;
@@ -40,6 +40,90 @@ export function buildTimestamp(bundlePath, env = process.env) {
40
40
  export function isArchIndependent(payload) {
41
41
  return !payload.some((entry) => isNativeBinary(entry.data));
42
42
  }
43
+ // `process.arch` tokens, keyed by what the image records about itself. Only the
44
+ // values this repository can actually produce are listed; an unknown one reads
45
+ // as "cannot tell" rather than as a mismatch, because refusing an artifact over
46
+ // a machine constant nobody here emits would be a guess wearing a gate's clothes.
47
+ const ELF_MACHINE_TO_ARCH = {
48
+ 0x03: 'ia32',
49
+ 0x15: 'ppc64',
50
+ 0x16: 's390x',
51
+ 0x28: 'arm',
52
+ 0x3e: 'x64',
53
+ 0xb7: 'arm64',
54
+ 0xf3: 'riscv64',
55
+ };
56
+ const MACHO_CPUTYPE_TO_ARCH = {
57
+ 0x00000007: 'ia32',
58
+ 0x0000000c: 'arm',
59
+ 0x01000007: 'x64',
60
+ 0x0100000c: 'arm64',
61
+ };
62
+ /**
63
+ * The `process.arch` token an image says it is built for, or `null` when the
64
+ * question cannot be answered from the bytes.
65
+ *
66
+ * `null` covers three different things on purpose, and all three must stay
67
+ * silent: a file that is not a native binary at all (most of a payload), a PE
68
+ * (whose COFF machine field this tree has never parsed — `isNativeBinary` reads
69
+ * `MZ` and stops), and a Mach-O fat archive, which carries several
70
+ * architectures and therefore matches any label a caller could pass.
71
+ */
72
+ export function readBinaryArch(data) {
73
+ if (data.byteLength < 20)
74
+ return null;
75
+ const magic = ((data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]) >>> 0;
76
+ if (magic === 0x7f454c46) {
77
+ // ELF: EI_DATA at offset 5 says which end e_machine (offset 18) is written from.
78
+ const littleEndian = data[5] === 1;
79
+ const machine = littleEndian ? data[18] | (data[19] << 8) : (data[18] << 8) | data[19];
80
+ return ELF_MACHINE_TO_ARCH[machine] ?? null;
81
+ }
82
+ if (magic === 0xfeedface || magic === 0xfeedfacf) {
83
+ return MACHO_CPUTYPE_TO_ARCH[((data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7]) >>> 0] ?? null;
84
+ }
85
+ if (magic === 0xcefaedfe || magic === 0xcffaedfe) {
86
+ return MACHO_CPUTYPE_TO_ARCH[((data[7] << 24) | (data[6] << 16) | (data[5] << 8) | data[4]) >>> 0] ?? null;
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Refuse a payload whose binaries disagree with the label the package will carry.
92
+ *
93
+ * THE INCIDENT, measured on 0.41.0 before this existed. A project whose payload
94
+ * carries one x86-64 `.so`, packed on this x86-64 host:
95
+ *
96
+ * gjsify ship --skip-build --arch arm64
97
+ * → xarch-demo_1.2.3-1_arm64.deb, xarch-demo-1.2.3-1.aarch64.rpm
98
+ * rpm -qp --qf '%{ARCH}' → aarch64
99
+ * the .so inside it → ELF e_machine 0x3e (x86-64)
100
+ *
101
+ * `--arch` LABELS the payload; it does not cross-compile it, and nothing
102
+ * compared the two. The result installs on an arm64 machine — apt and dnf both
103
+ * believe the header — and then fails to load, which is this tree's most
104
+ * expensive failure class with an independent oracle actively confirming the
105
+ * lie: `rpm` reads the header, and the header was written from the caller's
106
+ * claim.
107
+ *
108
+ * Payload against LABEL, never payload against HOST. Assembling an arm64
109
+ * artifact on an x64 machine is a supported path — the packers are pure
110
+ * JavaScript and ADR 0024 § A1 turns it into a design commitment — so a host
111
+ * comparison would refuse the very case the split exists to allow.
112
+ */
113
+ export function assertPayloadMatchesArch(payload, arch) {
114
+ for (const entry of payload) {
115
+ const found = readBinaryArch(entry.data);
116
+ if (found === null || found === arch)
117
+ continue;
118
+ throw new Error(`gjsify ship: the payload is ${found}, but the package would be labelled ${arch} — ` +
119
+ `${entry.path} is built for ${found}.\n` +
120
+ ' `--arch` names the architecture the PAYLOAD was built for; it does not cross-compile ' +
121
+ 'anything.\n' +
122
+ ` A package labelled ${arch} installs on ${arch} and then fails to load. Build the payload ` +
123
+ `for ${arch}\n` +
124
+ ' (its own prebuild), or drop `--arch` and label the payload you actually have.');
125
+ }
126
+ }
43
127
  /** ELF, Mach-O (both endiannesses, both widths, and a fat archive) or PE. */
44
128
  function isNativeBinary(data) {
45
129
  if (data.byteLength < 4)
@@ -54,6 +54,26 @@ export function planStage(settings, inputs) {
54
54
  source: { kind: 'file', path: schema },
55
55
  });
56
56
  }
57
+ // The typelibs the package carries itself, beside the bundle in `gi/`. The launcher points
58
+ // GI_TYPELIB_PATH and LD_LIBRARY_PATH here — see renderLauncher.
59
+ for (const file of settings.typelibFiles) {
60
+ files.push({
61
+ path: posix.join(libDir, 'gi', basename(file)),
62
+ // A shared library must be executable; a typelib need not be, but one mode for both
63
+ // keeps the staged tree free of a distinction nothing downstream reads.
64
+ mode: 0o755,
65
+ source: { kind: 'file', path: file },
66
+ });
67
+ }
68
+ // Compiled gettext catalogues, layout preserved: `share/locale/<lang>/LC_MESSAGES/<domain>.mo`
69
+ // is where `bindtextdomain` looks, and nowhere else.
70
+ for (const locale of settings.localeFiles) {
71
+ files.push({
72
+ path: posix.join('share/locale', locale.rel),
73
+ mode: 0o644,
74
+ source: { kind: 'file', path: locale.abs },
75
+ });
76
+ }
57
77
  for (const [dest, source] of Object.entries(settings.extraFiles)) {
58
78
  files.push({ path: assertInsidePrefix(dest), mode: 0o644, source: { kind: 'file', path: source } });
59
79
  }
@@ -27,6 +27,13 @@ export interface DiscoveredPayload {
27
27
  bundleFiles: string[];
28
28
  iconFiles: string[];
29
29
  schemaFiles: string[];
30
+ /** Absolute paths of the typelibs + shared libraries the project ships itself. */
31
+ typelibFiles: string[];
32
+ /** Compiled gettext catalogues, each with its `<lang>/LC_MESSAGES/<domain>.mo` path. */
33
+ localeFiles: {
34
+ rel: string;
35
+ abs: string;
36
+ }[];
30
37
  licenseFile?: string;
31
38
  }
32
39
  export interface SettingsInput {
@@ -53,7 +60,7 @@ export declare function declaredBundlePath(pkg: ShipPackageManifest, ship: Confi
53
60
  /** npm package name → a package/binary name both dpkg and rpm accept. */
54
61
  export declare function deriveBinaryName(pkgName: string | undefined): string;
55
62
  /** AppStream description (string or blocks) → plain paragraphs for deb/rpm. */
56
- export declare function descriptionParagraphs(description: string | DescriptionBlock[] | undefined): string[] | undefined;
63
+ export declare function descriptionParagraphs(description: string | Array<DescriptionBlock | string> | undefined): string[] | undefined;
57
64
  /** `LICENSE`, `LICENSE.md`, … — the names a licence file actually has. */
58
65
  export declare const LICENSE_FILE_NAMES: readonly ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "COPYING.md"];
59
66
  /** The bundle's own basename, used as the launcher's exec target. */
@@ -81,6 +81,8 @@ export function resolveShipSettings(input) {
81
81
  bundleDir: discovered.bundleDir,
82
82
  iconFiles: discovered.iconFiles,
83
83
  schemaFiles: discovered.schemaFiles,
84
+ typelibFiles: discovered.typelibFiles,
85
+ localeFiles: discovered.localeFiles,
84
86
  extraFiles: ship.extraFiles ?? {},
85
87
  execArgs: ship.execArgs ?? [],
86
88
  outDir: input.cli.outDir ?? ship.outDir ?? 'ship',
@@ -165,7 +167,17 @@ export function descriptionParagraphs(description) {
165
167
  }
166
168
  const out = [];
167
169
  for (const block of description) {
168
- if ('p' in block) {
170
+ // A plain string in the array is what a person writes when they mean one paragraph, and it
171
+ // used to reach `'p' in block` and throw a bare TypeError — "cannot use 'in' operator to
172
+ // search for \"p\" in \"Bauplaner rechnet…\"", which names neither the field nor the fix.
173
+ // Accepting it is unambiguous: the AppStream blocks are objects, so a string can only mean
174
+ // a paragraph.
175
+ if (typeof block === 'string') {
176
+ const text = block.trim().replace(/\s+/g, ' ');
177
+ if (text)
178
+ out.push(text);
179
+ }
180
+ else if ('p' in block) {
169
181
  out.push(block.p.trim().replace(/\s+/g, ' '));
170
182
  }
171
183
  else {
@@ -101,6 +101,25 @@ export interface ShipSettings {
101
101
  iconFiles: string[];
102
102
  /** Absolute paths of `*.gschema.xml` files to install. */
103
103
  schemaFiles: string[];
104
+ /**
105
+ * Typelibs + shared libraries the package carries itself (`gjsify.ship.bundledTypelibs`).
106
+ *
107
+ * gjsify's own GI libraries arrive as npm prebuilds, not distro packages, so an app using one
108
+ * has nothing to depend on and must ship the files — with the launcher pointing GI at them.
109
+ */
110
+ typelibFiles: string[];
111
+ /**
112
+ * Compiled gettext catalogues (`gjsify.ship.localeDir`), each keeping its
113
+ * `<lang>/LC_MESSAGES/<domain>.mo` path so `share/locale/` comes out in the layout
114
+ * `bindtextdomain` reads.
115
+ *
116
+ * `rel` rather than a bare list of absolute paths, because the LAYOUT is the whole point here:
117
+ * a `.mo` staged by basename alone lands where no catalogue lookup will ever look at it.
118
+ */
119
+ localeFiles: {
120
+ rel: string;
121
+ abs: string;
122
+ }[];
104
123
  /** `<dest relative to prefix>` → absolute source path. */
105
124
  extraFiles: Record<string, string>;
106
125
  /** Arguments appended to the launcher's `exec` line. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tokenize `cmd`, or return `null` if the string contains anything the shell
3
+ * would treat specially — operators (`&& | ; < > &`), substitutions (`$(...)` /
4
+ * backticks / `$VAR`), unquoted globs/expansions (`* ? { } [ ] ~`), comments
5
+ * (`#`), or an unterminated quote — in which case the caller MUST use the real
6
+ * shell instead. Quotes are honoured so a quoted glob (`'src/**'`) survives as a
7
+ * single literal token, exactly as the shell would hand it to `gjsify` (which
8
+ * does its own glob expansion).
9
+ */
10
+ export declare function tokenizeSimpleCommand(cmd: string): string[] | null;
11
+ /**
12
+ * The argv of a script that is a single `gjsify <subcommand> …` command — i.e.
13
+ * the tokens after `gjsify` — or `null` when it is anything else. `null` is the
14
+ * signal to hand the literal to a shell.
15
+ */
16
+ export declare function gjsifyCommandArgv(literal: string): string[] | null;