@gjsify/cli 0.4.9 → 0.4.11

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,6 @@ 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';
19
+ export * from './uninstall.js';
@@ -14,3 +14,6 @@ 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';
19
+ export * from './uninstall.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
+ }
@@ -0,0 +1,9 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface UninstallOptions {
3
+ packages: string[];
4
+ global?: boolean;
5
+ 'dry-run'?: boolean;
6
+ verbose?: boolean;
7
+ }
8
+ export declare const uninstallCommand: Command<any, UninstallOptions>;
9
+ export {};
@@ -0,0 +1,145 @@
1
+ // `gjsify uninstall -g <pkg>` — symmetric inverse of `install -g`.
2
+ //
3
+ // Removes the installed package tree from the user-global XDG location
4
+ // and any bin shims under `~/.local/bin/` that point into it. Mirrors
5
+ // the layout decisions in install-global.ts:
6
+ //
7
+ // ~/.local/share/gjsify/global/node_modules/<pkg>/ ← deleted
8
+ // ~/.local/bin/<bin> ← deleted iff it
9
+ // execs a path
10
+ // inside the
11
+ // removed tree
12
+ //
13
+ // Scope: --global only. Project-local uninstall (mirror of `npm uninstall
14
+ // <pkg>` without -g) is a separate workstream — it needs to rewrite
15
+ // package.json + refresh the lockfile, which install -g doesn't touch.
16
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { defaultGlobalLayout, specToPackageName } from '../utils/install-global.js';
19
+ export const uninstallCommand = {
20
+ command: 'uninstall <packages..>',
21
+ description: 'Uninstall a previously installed package. Currently only `--global` mode is supported.',
22
+ builder: (yargs) => yargs
23
+ .positional('packages', {
24
+ description: 'Package(s) to uninstall (npm names, optionally with version).',
25
+ type: 'string',
26
+ array: true,
27
+ demandOption: true,
28
+ })
29
+ .option('global', {
30
+ description: 'Uninstall from the user-global XDG location (the install -g target).',
31
+ type: 'boolean',
32
+ alias: 'g',
33
+ default: false,
34
+ })
35
+ .option('dry-run', {
36
+ description: 'Show what would be removed without touching the filesystem.',
37
+ type: 'boolean',
38
+ default: false,
39
+ })
40
+ .option('verbose', {
41
+ description: 'Verbose logging.',
42
+ type: 'boolean',
43
+ default: false,
44
+ }),
45
+ handler: (args) => {
46
+ if (!args.global) {
47
+ console.error('gjsify uninstall currently only supports --global. ' +
48
+ 'For project-local removal, edit package.json + re-run `gjsify install`.');
49
+ process.exit(1);
50
+ return;
51
+ }
52
+ const layout = defaultGlobalLayout();
53
+ const dryRun = args['dry-run'] ?? false;
54
+ const verbose = args.verbose ?? false;
55
+ const prefix = `gjsify uninstall${dryRun ? ' (dry-run)' : ''} --global`;
56
+ console.log(`${prefix} ← ${layout.prefix}`);
57
+ console.log(`${' '.repeat(prefix.length)} bins ← ${layout.binDir}`);
58
+ let removedAny = false;
59
+ for (const spec of args.packages) {
60
+ const pkgName = specToPackageName(spec);
61
+ const pkgDir = join(layout.prefix, 'node_modules', pkgName);
62
+ if (!existsSync(pkgDir)) {
63
+ console.warn(` ✗ ${pkgName} — not installed at ${pkgDir}`);
64
+ continue;
65
+ }
66
+ // Find bin shims that exec into this package's tree. The shims
67
+ // are POSIX sh launchers written by linkGlobalBins; we identify
68
+ // candidates by reading the launcher script and matching the
69
+ // absolute path.
70
+ const binsToRemove = findBinShimsForPackage(layout.binDir, pkgDir, verbose);
71
+ if (dryRun) {
72
+ console.log(` • would remove ${pkgDir}`);
73
+ for (const bin of binsToRemove) {
74
+ console.log(` • would remove ${bin}`);
75
+ }
76
+ }
77
+ else {
78
+ rmSync(pkgDir, { recursive: true, force: true });
79
+ console.log(` • removed ${pkgDir}`);
80
+ for (const bin of binsToRemove) {
81
+ unlinkSync(bin);
82
+ console.log(` • removed ${bin}`);
83
+ }
84
+ }
85
+ removedAny = true;
86
+ }
87
+ if (!removedAny) {
88
+ console.error('\nNo packages removed.');
89
+ process.exit(1);
90
+ }
91
+ },
92
+ };
93
+ /**
94
+ * Scan `binDir` for POSIX `sh` launchers whose `exec` target points into
95
+ * `pkgDir`. The launcher shape is fixed by `linkGlobalBins` — either:
96
+ *
97
+ * #!/bin/sh
98
+ * exec '<absolute-path>' "$@"
99
+ *
100
+ * or (for `.gjs.mjs` / `.mjs` targets):
101
+ *
102
+ * #!/bin/sh
103
+ * exec gjs -m '<absolute-path>' "$@"
104
+ *
105
+ * We parse the absolute path out of the single-quoted segment and check
106
+ * whether it's under `pkgDir`. Non-shim files (e.g. unrelated binaries
107
+ * the user installed via `npm install -g`) are skipped silently.
108
+ */
109
+ function findBinShimsForPackage(binDir, pkgDir, verbose) {
110
+ if (!existsSync(binDir))
111
+ return [];
112
+ const matches = [];
113
+ let entries;
114
+ try {
115
+ entries = readdirSync(binDir);
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ for (const name of entries) {
121
+ const fullPath = join(binDir, name);
122
+ try {
123
+ const st = statSync(fullPath);
124
+ if (!st.isFile())
125
+ continue;
126
+ const content = readFileSync(fullPath, 'utf-8');
127
+ if (!content.startsWith('#!/bin/sh'))
128
+ continue;
129
+ // Match the first single-quoted absolute path.
130
+ const m = content.match(/'([^']+)'/);
131
+ if (!m)
132
+ continue;
133
+ const target = m[1];
134
+ if (target.startsWith(pkgDir + '/') || target === pkgDir) {
135
+ matches.push(fullPath);
136
+ }
137
+ }
138
+ catch (err) {
139
+ if (verbose) {
140
+ console.warn(` ? could not inspect ${fullPath}: ${err.message}`);
141
+ }
142
+ }
143
+ }
144
+ return matches;
145
+ }
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, uninstallCommand as uninstall, } 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,9 @@ 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)
32
+ .command(uninstall.command, uninstall.description, uninstall.builder, uninstall.handler)
30
33
  .demandCommand(1)
31
34
  .help()
32
35
  .parseAsync();