@gjsify/cli 0.4.9 → 0.4.10

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.
@@ -0,0 +1,10 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface GenerateInstallerOptions {
3
+ target?: string;
4
+ 'bin-name'?: string;
5
+ 'bootstrap-url'?: string;
6
+ output: string;
7
+ force: boolean;
8
+ }
9
+ export declare const generateInstallerCommand: Command<any, GenerateInstallerOptions>;
10
+ export {};
@@ -0,0 +1,113 @@
1
+ // `gjsify generate-installer [target]` — scaffold an `install.mjs` for any
2
+ // GJS-runnable npm package, modeled on gjsify's own installer.
3
+ //
4
+ // The generated `install.mjs` is a verbatim copy of gjsify's root `install.mjs`
5
+ // with three constants substituted:
6
+ //
7
+ // DEFAULT_TARGET → the consumer's npm package name
8
+ // DEFAULT_BIN_NAME → the consumer's bin name (key of `gjsify.bin` or `bin`)
9
+ // DEFAULT_BOOTSTRAP_URL → URL of a gjsify `cli.gjs.mjs` bootstrap bundle
10
+ //
11
+ // End-user workflow:
12
+ // cd my-gjs-app
13
+ // gjsify generate-installer
14
+ // git add install.mjs && git commit
15
+ // # README:
16
+ // # curl -fsSL https://github.com/me/my-gjs-app/raw/main/install.mjs \
17
+ // # -o /tmp/i.mjs && gjs -m /tmp/i.mjs && rm /tmp/i.mjs
18
+ //
19
+ // The template is read at build time (static-read-inliner inlines the file
20
+ // contents into the bundled CLI), so the runtime cost is just a string
21
+ // replace + write.
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { resolve } from 'node:path';
24
+ // Lazy load. Reading at the top level breaks `gjsify run copy-templates`
25
+ // (the bootstrap step that ships the template into `lib/templates/` after
26
+ // `tsc`): the run script must first import this module to dispatch into
27
+ // itself, which would then ENOENT on the not-yet-copied template file.
28
+ // The static-read-inliner can still detect this shape inside the handler.
29
+ function loadInstallerTemplate() {
30
+ return readFileSync(new URL('../templates/install.mjs.tmpl', import.meta.url), 'utf-8');
31
+ }
32
+ const DEFAULT_BOOTSTRAP_URL = 'https://github.com/gjsify/gjsify/releases/latest/download/cli.gjs.mjs';
33
+ export const generateInstallerCommand = {
34
+ command: 'generate-installer [target]',
35
+ description: 'Scaffold an install.mjs in the current directory for a GJS-runnable npm package.',
36
+ builder: (yargs) => yargs
37
+ .positional('target', {
38
+ description: 'Npm package name to install (default: current package.json name).',
39
+ type: 'string',
40
+ })
41
+ .option('bin-name', {
42
+ description: 'Bin name produced by the installer (default: first key of `gjsify.bin` or `bin`).',
43
+ type: 'string',
44
+ })
45
+ .option('bootstrap-url', {
46
+ description: 'Override the cli.gjs.mjs bootstrap bundle URL (default: gjsify GitHub releases/latest).',
47
+ type: 'string',
48
+ })
49
+ .option('output', {
50
+ description: 'Where to write the generated installer.',
51
+ type: 'string',
52
+ default: 'install.mjs',
53
+ })
54
+ .option('force', {
55
+ description: 'Overwrite an existing output file.',
56
+ type: 'boolean',
57
+ default: false,
58
+ }),
59
+ handler: (args) => {
60
+ const outputPath = resolve(process.cwd(), args.output);
61
+ if (existsSync(outputPath) && !args.force) {
62
+ console.error(`${args.output} already exists. Re-run with --force to overwrite.`);
63
+ process.exit(1);
64
+ return;
65
+ }
66
+ const pkgJsonPath = resolve(process.cwd(), 'package.json');
67
+ let pkgJson = null;
68
+ if (existsSync(pkgJsonPath)) {
69
+ try {
70
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
71
+ }
72
+ catch {
73
+ /* no pkg.json or unparsable — fall back to flags */
74
+ }
75
+ }
76
+ const target = args.target ?? pkgJson?.name;
77
+ if (!target) {
78
+ console.error('No target package: pass `gjsify generate-installer <pkg>` or run inside a directory with a package.json.');
79
+ process.exit(1);
80
+ return;
81
+ }
82
+ const binName = args['bin-name'] ?? pickDefaultBinName(pkgJson, target);
83
+ const bootstrapUrl = args['bootstrap-url'] ?? DEFAULT_BOOTSTRAP_URL;
84
+ const rendered = loadInstallerTemplate()
85
+ .replace(/const DEFAULT_TARGET = '[^']+';/, `const DEFAULT_TARGET = ${JSON.stringify(target)};`)
86
+ .replace(/const DEFAULT_BIN_NAME = '[^']+';/, `const DEFAULT_BIN_NAME = ${JSON.stringify(binName)};`)
87
+ .replace(/const DEFAULT_BOOTSTRAP_URL =\s*'[^']+';/, `const DEFAULT_BOOTSTRAP_URL = ${JSON.stringify(bootstrapUrl)};`);
88
+ writeFileSync(outputPath, rendered, { mode: 0o755 });
89
+ console.log(`Wrote ${args.output} (target=${target}, bin=${binName}).`);
90
+ console.log('');
91
+ console.log('Install one-liner for your README:');
92
+ console.log(` curl -fsSL https://github.com/<you>/<repo>/raw/main/${args.output} -o /tmp/i.mjs \\`);
93
+ console.log(' && gjs -m /tmp/i.mjs && rm /tmp/i.mjs');
94
+ },
95
+ };
96
+ function pickDefaultBinName(pkgJson, target) {
97
+ const gjsifyBin = pkgJson?.gjsify?.bin;
98
+ if (gjsifyBin && typeof gjsifyBin === 'object') {
99
+ const first = Object.keys(gjsifyBin)[0];
100
+ if (first)
101
+ return first;
102
+ }
103
+ const npmBin = pkgJson?.bin;
104
+ if (npmBin && typeof npmBin === 'object') {
105
+ const first = Object.keys(npmBin)[0];
106
+ if (first)
107
+ return first;
108
+ }
109
+ if (typeof npmBin === 'string') {
110
+ return target.startsWith('@') ? target.slice(target.indexOf('/') + 1) : target;
111
+ }
112
+ return target.startsWith('@') ? target.slice(target.indexOf('/') + 1) : target;
113
+ }
@@ -14,3 +14,5 @@ export * from './foreach.js';
14
14
  export * from './workspace.js';
15
15
  export * from './pack.js';
16
16
  export * from './publish.js';
17
+ export * from './self-update.js';
18
+ export * from './generate-installer.js';
@@ -14,3 +14,5 @@ export * from './foreach.js';
14
14
  export * from './workspace.js';
15
15
  export * from './pack.js';
16
16
  export * from './publish.js';
17
+ export * from './self-update.js';
18
+ export * from './generate-installer.js';
@@ -0,0 +1,8 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface SelfUpdateOptions {
3
+ check?: boolean;
4
+ force?: boolean;
5
+ tag: string;
6
+ }
7
+ export declare const selfUpdateCommand: Command<any, SelfUpdateOptions>;
8
+ export {};
@@ -0,0 +1,138 @@
1
+ // `gjsify self-update` — refresh the installed @gjsify/cli to a newer release.
2
+ //
3
+ // Walks `import.meta.url` to find this CLI's own package.json (works whether
4
+ // running from `lib/index.js` under Node or the published `dist/cli.gjs.mjs`
5
+ // bundle under GJS). Compares against the latest version on the npm registry
6
+ // (or the requested `--tag`); when an upgrade is needed, re-uses the existing
7
+ // `installPackages` + `linkGlobalBins` pipeline to lay down the new tree at
8
+ // the user-global XDG location.
9
+ //
10
+ // Limitation: only works when the current CLI is installed under
11
+ // `defaultGlobalLayout().prefix` (i.e. via `gjsify install -g` or via the
12
+ // `install.mjs` bootstrap). Installs from `npm install -g @gjsify/cli` land
13
+ // elsewhere and we don't try to chase them — we print a warning and exit.
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { fetchPackument } from '@gjsify/npm-registry';
18
+ import { installPackages } from '../utils/install-backend.js';
19
+ import { defaultGlobalLayout, linkGlobalBins } from '../utils/install-global.js';
20
+ const PACKAGE_NAME = '@gjsify/cli';
21
+ export const selfUpdateCommand = {
22
+ command: 'self-update',
23
+ description: `Update the installed ${PACKAGE_NAME} to the latest release (or pinned --tag).`,
24
+ builder: (yargs) => yargs
25
+ .option('check', {
26
+ description: 'Only check whether a newer version is available; do not install.',
27
+ type: 'boolean',
28
+ default: false,
29
+ })
30
+ .option('force', {
31
+ description: 'Reinstall even when the current version already matches the target tag.',
32
+ type: 'boolean',
33
+ default: false,
34
+ })
35
+ .option('tag', {
36
+ description: 'npm dist-tag or pinned version to install (e.g. `latest`, `next`, `0.5.0`).',
37
+ type: 'string',
38
+ default: 'latest',
39
+ }),
40
+ handler: async (args) => {
41
+ const layout = defaultGlobalLayout();
42
+ const installedPkgDir = join(layout.prefix, 'node_modules', PACKAGE_NAME);
43
+ const installedPkgJson = join(installedPkgDir, 'package.json');
44
+ const currentVersion = readCurrentVersion();
45
+ const installedAtPrefix = existsSync(installedPkgJson);
46
+ console.log(`Current ${PACKAGE_NAME}: v${currentVersion ?? '(unknown)'}`);
47
+ if (!installedAtPrefix) {
48
+ console.warn(`\nWarning: no @gjsify/cli install found under ${layout.prefix}.\n` +
49
+ `self-update only manages installs created by install.mjs or \`gjsify install -g\`.\n` +
50
+ `If you installed via \`npm install -g\`, remove that and use:\n` +
51
+ ` curl -fsSL https://github.com/gjsify/gjsify/releases/latest/download/install.mjs -o /tmp/g.mjs && gjs -m /tmp/g.mjs && rm /tmp/g.mjs`);
52
+ }
53
+ console.log(`Fetching dist-tags for ${PACKAGE_NAME}@${args.tag} ...`);
54
+ let packument;
55
+ try {
56
+ packument = await fetchPackument(PACKAGE_NAME);
57
+ }
58
+ catch (err) {
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ console.error(`Failed to fetch packument: ${msg}`);
61
+ process.exit(1);
62
+ return;
63
+ }
64
+ const target = resolveTag(packument, args.tag);
65
+ if (!target) {
66
+ console.error(`Unknown dist-tag '${args.tag}' on ${PACKAGE_NAME}. ` +
67
+ `Known tags: ${Object.keys(packument['dist-tags'] ?? {}).join(', ') || '(none)'}`);
68
+ process.exit(1);
69
+ return;
70
+ }
71
+ console.log(`Latest matching --tag ${args.tag}: v${target}`);
72
+ if (currentVersion === target && !args.force) {
73
+ console.log(`Already up to date (v${target}).`);
74
+ if (!args.check)
75
+ console.log(`Run with --force to reinstall anyway.`);
76
+ return;
77
+ }
78
+ if (args.check) {
79
+ console.log(currentVersion
80
+ ? `Update available: v${currentVersion} → v${target}`
81
+ : `Install required: → v${target}`);
82
+ process.exit(1);
83
+ return;
84
+ }
85
+ console.log(`Installing ${PACKAGE_NAME}@${target} ...`);
86
+ await installPackages({
87
+ prefix: layout.prefix,
88
+ specs: [`${PACKAGE_NAME}@${target}`],
89
+ verbose: false,
90
+ });
91
+ const linked = linkGlobalBins([PACKAGE_NAME], layout);
92
+ if (linked.length === 0) {
93
+ console.warn('self-update: install completed but no bins were linked — package.json may be missing a `bin` field.');
94
+ }
95
+ else {
96
+ for (const bin of linked) {
97
+ console.log(` • ${bin.link} → ${bin.target}`);
98
+ }
99
+ }
100
+ console.log(`\nUpdated ${PACKAGE_NAME} to v${target}.`);
101
+ },
102
+ };
103
+ /**
104
+ * Resolve the CLI's own `package.json#version`. Walks up from
105
+ * `import.meta.url` (the bundle file under GJS, or `lib/index.js` under
106
+ * Node) until it finds a package.json with `name === '@gjsify/cli'`.
107
+ */
108
+ function readCurrentVersion() {
109
+ try {
110
+ const here = fileURLToPath(import.meta.url);
111
+ let dir = dirname(resolve(here));
112
+ for (let i = 0; i < 8 && dir !== dirname(dir); i++) {
113
+ const candidate = join(dir, 'package.json');
114
+ if (existsSync(candidate)) {
115
+ const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
116
+ if (pkg.name === PACKAGE_NAME && typeof pkg.version === 'string') {
117
+ return pkg.version;
118
+ }
119
+ }
120
+ dir = dirname(dir);
121
+ }
122
+ }
123
+ catch {
124
+ /* not in a recognizable layout */
125
+ }
126
+ return null;
127
+ }
128
+ function resolveTag(packument, tag) {
129
+ const distTags = (packument['dist-tags'] ?? {});
130
+ if (distTags[tag])
131
+ return distTags[tag];
132
+ // Allow pinned versions via `--tag 0.5.0`
133
+ if (packument.versions && typeof packument.versions === 'object') {
134
+ if (packument.versions[tag])
135
+ return tag;
136
+ }
137
+ return null;
138
+ }
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from 'yargs';
3
3
  import { hideBin } from 'yargs/helpers';
4
- import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, } from './commands/index.js';
4
+ import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, } from './commands/index.js';
5
5
  import { APP_NAME } from './constants.js';
6
6
  // `parseAsync()` instead of `.argv` so the top-level await keeps the
7
7
  // process alive until command handlers complete. Under Node this is
@@ -27,6 +27,8 @@ await yargs(hideBin(process.argv))
27
27
  .command(workspace.command, workspace.description, workspace.builder, workspace.handler)
28
28
  .command(pack.command, pack.description, pack.builder, pack.handler)
29
29
  .command(publish.command, publish.description, publish.builder, publish.handler)
30
+ .command(selfUpdate.command, selfUpdate.description, selfUpdate.builder, selfUpdate.handler)
31
+ .command(generateInstaller.command, generateInstaller.description, generateInstaller.builder, generateInstaller.handler)
30
32
  .demandCommand(1)
31
33
  .help()
32
34
  .parseAsync();
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env -S gjs -m
2
+ /**
3
+ * gjsify universal installer — bootstraps `@gjsify/cli` (or any GJS app
4
+ * published to npm) on a system that has only `gjs` (and `curl`/`wget`)
5
+ * available, without requiring Node.js or `npm`.
6
+ *
7
+ * Usage:
8
+ * gjs -m install.mjs # install / update @gjsify/cli
9
+ * gjs -m install.mjs --target @scope/x # install any other GJS-runnable npm package
10
+ * gjs -m install.mjs --tag next # pick an npm dist-tag or pinned version
11
+ * gjs -m install.mjs --force # reinstall even if already present
12
+ * gjs -m install.mjs --help
13
+ *
14
+ * How it works (two-stage bootstrap):
15
+ * 1. Download a small self-contained GJS bundle of the @gjsify/cli
16
+ * (`cli.gjs.mjs`) from this repo's GitHub releases. Verify SHA-256.
17
+ * 2. Spawn that bundle: `gjs -m <bundle> install -g <target>@<tag>`. The
18
+ * bundle handles transitive dependency resolution, native prebuilds,
19
+ * lockfiles, and the `~/.local/bin` launchers — all the things this
20
+ * thin bootstrapper deliberately does NOT re-implement.
21
+ *
22
+ * Generated by `gjsify generate-installer` for end-user GJS apps: in that
23
+ * mode the constants below (BOOTSTRAP_URL, DEFAULT_TARGET, DEFAULT_BIN_NAME)
24
+ * are pre-substituted to the consumer's package + custom bootstrap URL.
25
+ *
26
+ * Test hooks (set by tests/e2e/install-script/run.mjs):
27
+ * GJSIFY_INSTALL_BOOTSTRAP_URL override the cli.gjs.mjs download origin
28
+ * (accepts file:// for offline tests)
29
+ * GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL override the .sha256 companion URL
30
+ * (set to empty string to skip SHA-256)
31
+ * GJSIFY_GLOBAL_PREFIX override install prefix (forwarded to cli)
32
+ * GJSIFY_GLOBAL_BIN_DIR override bin dir (forwarded to cli)
33
+ * GJSIFY_INSTALL_REGISTRY override npm registry (forwarded as npm_config_registry)
34
+ * GJSIFY_INSTALL_BOOTSTRAP_CACHE override the bootstrap cache dir
35
+ */
36
+
37
+ import GLib from 'gi://GLib';
38
+ import Gio from 'gi://Gio';
39
+ import Soup from 'gi://Soup?version=3.0';
40
+ import system, { exit } from 'system';
41
+
42
+ Gio._promisify(Soup.Session.prototype, 'send_and_read_async');
43
+ Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
44
+
45
+ // Substituted by `gjsify generate-installer` for end-user apps.
46
+ const DEFAULT_TARGET = '@gjsify/cli';
47
+ const DEFAULT_BIN_NAME = 'gjsify';
48
+ const DEFAULT_BOOTSTRAP_URL =
49
+ 'https://github.com/gjsify/gjsify/releases/latest/download/cli.gjs.mjs';
50
+ const DEFAULT_BOOTSTRAP_SHA256_URL = `${DEFAULT_BOOTSTRAP_URL}.sha256`;
51
+
52
+ const USER_AGENT = 'gjsify-installer/1.0';
53
+
54
+ function info(msg) { print(`[gjsify] ${msg}`); }
55
+ function error(msg) { printerr(`[gjsify] ERROR: ${msg}`); }
56
+
57
+ function parseArgs() {
58
+ const argv = system?.programArgs ?? [];
59
+ let target = DEFAULT_TARGET;
60
+ let tag = 'latest';
61
+ let force = false;
62
+ let help = false;
63
+ let bootstrapUrl = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_URL') || DEFAULT_BOOTSTRAP_URL;
64
+ let bootstrapSha256Url = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_SHA256_URL');
65
+ if (bootstrapSha256Url === null || bootstrapSha256Url === undefined) {
66
+ bootstrapSha256Url = bootstrapUrl === DEFAULT_BOOTSTRAP_URL
67
+ ? DEFAULT_BOOTSTRAP_SHA256_URL
68
+ : `${bootstrapUrl}.sha256`;
69
+ }
70
+ for (let i = 0; i < argv.length; i++) {
71
+ const a = argv[i];
72
+ if (a === '--force' || a === '-f') force = true;
73
+ else if (a === '--help' || a === '-h') help = true;
74
+ else if (a === '--target') target = argv[++i];
75
+ else if (a.startsWith('--target=')) target = a.slice('--target='.length);
76
+ else if (a === '--tag') tag = argv[++i];
77
+ else if (a.startsWith('--tag=')) tag = a.slice('--tag='.length);
78
+ else if (a === '--bootstrap-url') bootstrapUrl = argv[++i];
79
+ else if (a.startsWith('--bootstrap-url=')) bootstrapUrl = a.slice('--bootstrap-url='.length);
80
+ }
81
+ return { target, tag, force, help, bootstrapUrl, bootstrapSha256Url };
82
+ }
83
+
84
+ function printUsage() {
85
+ print(`Usage: gjs -m install.mjs [options]
86
+
87
+ Installs (or updates) ${DEFAULT_TARGET} into the user-global XDG location,
88
+ using a self-contained GJS bundle of @gjsify/cli as a one-shot bootstrap.
89
+
90
+ Options:
91
+ --target <pkg> npm package to install (default: ${DEFAULT_TARGET})
92
+ --tag <tag> npm dist-tag or version (default: latest)
93
+ --force, -f Reinstall even when present.
94
+ --bootstrap-url <url> Override the cli.gjs.mjs download URL.
95
+ --help, -h Show this message.
96
+
97
+ Env vars:
98
+ GJSIFY_INSTALL_BOOTSTRAP_URL alternate bootstrap bundle URL (file:// OK)
99
+ GJSIFY_GLOBAL_PREFIX install prefix (default: ~/.local/share/gjsify/global)
100
+ GJSIFY_GLOBAL_BIN_DIR bin dir (default: ~/.local/bin)
101
+ GJSIFY_INSTALL_REGISTRY npm registry override
102
+
103
+ Examples:
104
+ # Install / update the gjsify CLI itself:
105
+ gjs -m install.mjs
106
+
107
+ # Install some other GJS-runnable package from npm:
108
+ gjs -m install.mjs --target @ts-for-gir/cli
109
+
110
+ # Pin a specific version:
111
+ gjs -m install.mjs --tag 0.4.9
112
+ `);
113
+ }
114
+
115
+ function checkGjsVersion() {
116
+ // imports.system.version is packed: major*10000 + minor*100 + micro
117
+ const v = system?.version;
118
+ if (typeof v !== 'number') return;
119
+ const major = Math.floor(v / 10000);
120
+ const minor = Math.floor((v - major * 10000) / 100);
121
+ if (major < 1 || (major === 1 && minor < 86)) {
122
+ error(`gjs ${major}.${minor} is too old — gjsify requires gjs 1.86 or newer.`);
123
+ error('Install hints:');
124
+ error(' Fedora 43+: sudo dnf install gjs');
125
+ error(' Debian 13+: sudo apt install gjs');
126
+ error(' Arch: sudo pacman -S gjs');
127
+ exit(1);
128
+ }
129
+ }
130
+
131
+ async function fetchBytes(session, url) {
132
+ if (url.startsWith('file://')) {
133
+ const path = url.slice('file://'.length);
134
+ const file = Gio.File.new_for_path(path);
135
+ const [, bytes] = file.load_contents(null);
136
+ return bytes;
137
+ }
138
+ const message = Soup.Message.new('GET', url);
139
+ message.request_headers.append('User-Agent', USER_AGENT);
140
+ const bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null);
141
+ const status = message.get_status();
142
+ if (status !== Soup.Status.OK) {
143
+ throw new Error(`HTTP ${status} from ${url}`);
144
+ }
145
+ return bytes.get_data();
146
+ }
147
+
148
+ function sha256Hex(bytes) {
149
+ const checksum = GLib.Checksum.new(GLib.ChecksumType.SHA256);
150
+ checksum.update(bytes);
151
+ return checksum.get_string();
152
+ }
153
+
154
+ function cacheDir() {
155
+ const override = GLib.getenv('GJSIFY_INSTALL_BOOTSTRAP_CACHE');
156
+ if (override) return override;
157
+ const xdg = GLib.getenv('XDG_CACHE_HOME') ||
158
+ GLib.build_filenamev([GLib.get_home_dir(), '.cache']);
159
+ return GLib.build_filenamev([xdg, 'gjsify', 'bootstrap']);
160
+ }
161
+
162
+ function ensureDir(dir) {
163
+ Gio.File.new_for_path(dir).make_directory_with_parents(null);
164
+ }
165
+
166
+ function writeBytes(path, bytes) {
167
+ Gio.File.new_for_path(path).replace_contents(
168
+ bytes, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null,
169
+ );
170
+ }
171
+
172
+ async function downloadBootstrap(session, bootstrapUrl, sha256Url) {
173
+ info(`Downloading bootstrap from ${bootstrapUrl} ...`);
174
+ const bundleBytes = await fetchBytes(session, bootstrapUrl);
175
+ if (sha256Url && sha256Url !== '') {
176
+ info('Verifying SHA-256 ...');
177
+ let sumExpected;
178
+ try {
179
+ const sumBytes = await fetchBytes(session, sha256Url);
180
+ sumExpected = new TextDecoder().decode(sumBytes).trim().split(/\s+/)[0];
181
+ } catch (err) {
182
+ error(`Could not fetch ${sha256Url} — skipping verification: ${err.message}`);
183
+ }
184
+ if (sumExpected) {
185
+ const sumActual = sha256Hex(bundleBytes);
186
+ if (sumExpected.toLowerCase() !== sumActual.toLowerCase()) {
187
+ error(`SHA-256 mismatch: expected ${sumExpected}, got ${sumActual}`);
188
+ exit(1);
189
+ }
190
+ }
191
+ }
192
+ const dir = cacheDir();
193
+ try { ensureDir(dir); } catch { /* exists */ }
194
+ const bundlePath = GLib.build_filenamev([dir, 'cli.gjs.mjs']);
195
+ writeBytes(bundlePath, bundleBytes);
196
+ info(`Bootstrap cached at ${bundlePath} (${bundleBytes.length} bytes)`);
197
+ return bundlePath;
198
+ }
199
+
200
+ function buildSpec(target, tag) {
201
+ if (!tag || tag === 'latest') return target;
202
+ return `${target}@${tag}`;
203
+ }
204
+
205
+ async function runInstall(bundlePath, spec) {
206
+ // `gjsify install -g <spec>` is always idempotent — it rewrites the tree
207
+ // unconditionally. There is no separate "force" flag to forward; the
208
+ // installer's own `--force` is satisfied by the fact that we always
209
+ // re-download the bootstrap and re-invoke the CLI.
210
+ info(`Running: gjs -m <bootstrap> install -g ${spec}`);
211
+ const argv = ['gjs', '-m', bundlePath, 'install', '-g', spec];
212
+ // Forward env vars verbatim so override paths set by tests / power-users
213
+ // reach the spawned CLI.
214
+ const launcher = new Gio.SubprocessLauncher({
215
+ flags: Gio.SubprocessFlags.NONE,
216
+ });
217
+ const proc = launcher.spawnv(argv);
218
+ await proc.wait_check_async(null);
219
+ }
220
+
221
+ async function main() {
222
+ const opts = parseArgs();
223
+ if (opts.help) { printUsage(); exit(0); }
224
+ checkGjsVersion();
225
+
226
+ const session = new Soup.Session();
227
+ let bundlePath;
228
+ try {
229
+ bundlePath = await downloadBootstrap(session, opts.bootstrapUrl, opts.bootstrapSha256Url);
230
+ } catch (err) {
231
+ error(`Bootstrap download failed: ${err.message}`);
232
+ exit(1);
233
+ }
234
+
235
+ const spec = buildSpec(opts.target, opts.tag);
236
+ try {
237
+ await runInstall(bundlePath, spec);
238
+ } catch (err) {
239
+ error(`Install failed: ${err.message}`);
240
+ exit(1);
241
+ }
242
+
243
+ info('');
244
+ info(`Installed ${spec}`);
245
+ info(`Run: ${DEFAULT_BIN_NAME} --help`);
246
+ }
247
+
248
+ await main();
@@ -91,7 +91,19 @@ export function linkGlobalBins(packageNames, layout) {
91
91
  // Inline `${target}` directly — this file is rewritten on every
92
92
  // install, paths are user-owned, and POSIX `sh` quoting via
93
93
  // single-quotes plus `'\''` for embedded quotes is well-defined.
94
- const launcher = `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
94
+ //
95
+ // `.gjs.mjs` and `.mjs` bins are GJS-runnable bundles; we wrap them
96
+ // with `gjs -m` rather than direct-exec because not every published
97
+ // bundle ships a `#!/usr/bin/env -S gjs -m` shebang (the CLI's
98
+ // build:gjs-bundle script gained the `--shebang` flag late in
99
+ // Phase F, but published <=0.4.x tarballs predate it). Direct-
100
+ // exec'ing a shebang-less .mjs file falls back to /bin/sh which
101
+ // then tries to parse JavaScript as shell. Plain Node scripts
102
+ // with shebangs (lib/index.js) keep the direct-exec path.
103
+ const isGjsBundle = targetAbs.endsWith('.gjs.mjs') || targetAbs.endsWith('.mjs');
104
+ const launcher = isGjsBundle
105
+ ? `#!/bin/sh\nexec gjs -m ${shQuote(targetAbs)} "$@"\n`
106
+ : `#!/bin/sh\nexec ${shQuote(targetAbs)} "$@"\n`;
95
107
  fs.writeFileSync(linkPath, launcher);
96
108
  fs.chmodSync(linkPath, 0o755);
97
109
  created.push({ name: binName, target: targetAbs, link: linkPath });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,8 +23,8 @@
23
23
  "clear": "rm -rf lib dist tsconfig.tsbuildinfo || exit 0",
24
24
  "check": "tsc --noEmit",
25
25
  "start": "node lib/index.js",
26
- "build": "tsc && gjsify run chmod",
27
- "build:gjs-bundle": "node lib/index.js build src/index.ts --app gjs --outfile dist/cli.gjs.mjs",
26
+ "build": "tsc && mkdir -p lib/templates && cp -L src/templates/install.mjs.tmpl lib/templates/install.mjs.tmpl && gjsify run chmod",
27
+ "build:gjs-bundle": "node lib/index.js build src/index.ts --app gjs --outfile dist/cli.gjs.mjs --shebang",
28
28
  "chmod": "chmod +x ./lib/index.js",
29
29
  "build:test:node": "node lib/index.js build src/test.mts --app node --outfile dist/test.node.mjs",
30
30
  "test:node": "node dist/test.node.mjs",
@@ -37,18 +37,18 @@
37
37
  "cli"
38
38
  ],
39
39
  "dependencies": {
40
- "@gjsify/buffer": "^0.4.9",
41
- "@gjsify/create-app": "^0.4.9",
42
- "@gjsify/node-globals": "^0.4.9",
43
- "@gjsify/node-polyfills": "^0.4.9",
44
- "@gjsify/npm-registry": "^0.4.9",
45
- "@gjsify/resolve-npm": "^0.4.9",
46
- "@gjsify/rolldown-plugin-gjsify": "^0.4.9",
47
- "@gjsify/rolldown-plugin-pnp": "^0.4.9",
48
- "@gjsify/semver": "^0.4.9",
49
- "@gjsify/tar": "^0.4.9",
50
- "@gjsify/web-polyfills": "^0.4.9",
51
- "@gjsify/workspace": "^0.4.9",
40
+ "@gjsify/buffer": "^0.4.10",
41
+ "@gjsify/create-app": "^0.4.10",
42
+ "@gjsify/node-globals": "^0.4.10",
43
+ "@gjsify/node-polyfills": "^0.4.10",
44
+ "@gjsify/npm-registry": "^0.4.10",
45
+ "@gjsify/resolve-npm": "^0.4.10",
46
+ "@gjsify/rolldown-plugin-gjsify": "^0.4.10",
47
+ "@gjsify/rolldown-plugin-pnp": "^0.4.10",
48
+ "@gjsify/semver": "^0.4.10",
49
+ "@gjsify/tar": "^0.4.10",
50
+ "@gjsify/web-polyfills": "^0.4.10",
51
+ "@gjsify/workspace": "^0.4.10",
52
52
  "cosmiconfig": "^9.0.1",
53
53
  "get-tsconfig": "^4.14.0",
54
54
  "pkg-types": "^2.3.1",
@@ -56,12 +56,12 @@
56
56
  "yargs": "^18.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@gjsify/unit": "^0.4.9",
59
+ "@gjsify/unit": "^0.4.10",
60
60
  "@types/yargs": "^17.0.35",
61
61
  "typescript": "^6.0.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@gjsify/rolldown-native": "^0.4.9"
64
+ "@gjsify/rolldown-native": "^0.4.10"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@gjsify/rolldown-native": {