@gjsify/cli 0.3.4 → 0.3.6

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,120 @@
1
+ // Cache for `gjsify dlx` — content-addressable, atomic, parallel-safe.
2
+ //
3
+ // Pattern adapted from refs/pnpm/exec/commands/src/dlx.ts:
4
+ // - cache key = sha256 over sorted [packages, registries]
5
+ // - cache layout: <root>/<sha>/{pkg,timestamp-pid}/
6
+ // - prepare into a fresh temp dir, then atomically swap a `pkg` symlink
7
+ // - TTL via lstat mtime + maxAgeMinutes (default 7 days)
8
+ import { createHash } from 'node:crypto';
9
+ import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join, resolve } from 'node:path';
12
+ const ONE_MINUTE_MS = 60_000;
13
+ const DEFAULT_TTL_MIN = 60 * 24 * 7; // 7 days
14
+ function lexCompare(a, b) {
15
+ return a < b ? -1 : a > b ? 1 : 0;
16
+ }
17
+ /** Stable, sorted JSON hash of inputs. */
18
+ export function createCacheKey(opts) {
19
+ const sortedPkgs = [...opts.packages].sort(lexCompare);
20
+ const sortedRegs = Object.entries(opts.registries ?? {}).sort(([a], [b]) => lexCompare(a, b));
21
+ const payload = JSON.stringify([sortedPkgs, sortedRegs]);
22
+ return createHash('sha256').update(payload).digest('hex');
23
+ }
24
+ /** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
25
+ export function dlxCacheRoot() {
26
+ const xdg = process.env.XDG_CACHE_HOME;
27
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
28
+ const root = join(base, 'gjsify', 'dlx');
29
+ mkdirSync(root, { recursive: true });
30
+ return root;
31
+ }
32
+ /** Per-key cache directory: <root>/<sha>. */
33
+ export function cacheDirFor(cacheKey) {
34
+ const dir = join(dlxCacheRoot(), cacheKey);
35
+ mkdirSync(dir, { recursive: true });
36
+ return dir;
37
+ }
38
+ /** A fresh prepare directory under the per-key cache, named timestamp-pid. */
39
+ export function makePrepareDir(cacheDir) {
40
+ const name = `${Date.now().toString(16)}-${process.pid.toString(16)}`;
41
+ const dir = join(cacheDir, name);
42
+ mkdirSync(dir, { recursive: true });
43
+ return dir;
44
+ }
45
+ /**
46
+ * If <cacheDir>/pkg points to a target whose mtime + maxAge < now, return its
47
+ * realpath. Returns undefined when the link doesn't exist, isn't a symlink,
48
+ * has been removed, or has expired.
49
+ */
50
+ export function getValidCachedPkg(cacheDir, maxAgeMinutes = DEFAULT_TTL_MIN) {
51
+ const linkPath = join(cacheDir, 'pkg');
52
+ let stats;
53
+ try {
54
+ stats = lstatSync(linkPath);
55
+ }
56
+ catch (err) {
57
+ if (err.code === 'ENOENT')
58
+ return undefined;
59
+ throw err;
60
+ }
61
+ if (!stats.isSymbolicLink())
62
+ return undefined;
63
+ let target;
64
+ try {
65
+ target = realpathSync(linkPath);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ const ageMs = Date.now() - stats.mtime.getTime();
71
+ return ageMs <= maxAgeMinutes * ONE_MINUTE_MS ? target : undefined;
72
+ }
73
+ /**
74
+ * Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
75
+ *
76
+ * Strategy:
77
+ * 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
78
+ * 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
79
+ *
80
+ * Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
81
+ * — a parallel process won, return its realpath.
82
+ */
83
+ export function symlinkSwap(cacheDir, prepareDir) {
84
+ const linkPath = join(cacheDir, 'pkg');
85
+ const tmpName = `pkg.tmp-${Date.now().toString(16)}-${process.pid.toString(16)}`;
86
+ const tmpLink = join(cacheDir, tmpName);
87
+ try {
88
+ symlinkSync(prepareDir, tmpLink, 'dir');
89
+ }
90
+ catch (err) {
91
+ // If we cannot even create the tmp link, give up.
92
+ throw err;
93
+ }
94
+ try {
95
+ renameSync(tmpLink, linkPath);
96
+ }
97
+ catch (err) {
98
+ const code = err.code;
99
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
100
+ // Race lost — clean up our tmp and use whoever won.
101
+ try {
102
+ rmSync(tmpLink);
103
+ }
104
+ catch { }
105
+ return realpathSync(linkPath);
106
+ }
107
+ throw err;
108
+ }
109
+ return realpathSync(linkPath);
110
+ }
111
+ /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
112
+ export function cleanupStalePrepareDirs(cacheDir, _maxAgeMinutes = DEFAULT_TTL_MIN) {
113
+ // Out of scope for Phase 1 — pnpm has the same TODO. Leaving a stub so
114
+ // call sites already exist when we do implement it.
115
+ void cacheDir;
116
+ }
117
+ /** Resolve absolute path to the installed package's directory inside cache. */
118
+ export function resolveInstalledPkgDir(cachedRoot, pkgName) {
119
+ return resolve(cachedRoot, 'node_modules', pkgName);
120
+ }
@@ -0,0 +1,11 @@
1
+ export interface InstallOptions {
2
+ /** Directory to install into (npm `--prefix`). Created by caller. */
3
+ prefix: string;
4
+ /** npm-resolvable specs: `name`, `name@version`, `git+https://...`, tarball URL, ... */
5
+ specs: string[];
6
+ /** Verbose logging passes through `--loglevel verbose`. */
7
+ verbose?: boolean;
8
+ /** Optional registry override (writes a temp `.npmrc` in prefix). */
9
+ registry?: string;
10
+ }
11
+ export declare function installPackages(opts: InstallOptions): Promise<void>;
@@ -0,0 +1,57 @@
1
+ // Install backend abstraction — Phase-4 seam.
2
+ //
3
+ // Today: spawns `npm install --no-package-lock --no-audit --no-fund --prefix <dir> <specs...>`.
4
+ // Future (Phase 4): a GJS-native resolver replaces this without changing
5
+ // the public signature, switched via `GJSIFY_INSTALL_BACKEND=native|npm`.
6
+ //
7
+ // Why npm and not pnpm/yarn? npm ships with Node so users already have it.
8
+ // Adding a yarn/pnpm dep would defeat the purpose of `gjsify dlx` (which
9
+ // itself is meant to ship binary-free GJS apps).
10
+ //
11
+ // `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
12
+ // already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
13
+ import { spawn } from 'node:child_process';
14
+ import { writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'npm';
17
+ export async function installPackages(opts) {
18
+ if (DEFAULT_BACKEND === 'native') {
19
+ throw new Error('GJSIFY_INSTALL_BACKEND=native is reserved for the Phase 4 GJS-native resolver — not yet implemented.');
20
+ }
21
+ return installViaNpm(opts);
22
+ }
23
+ async function installViaNpm({ prefix, specs, verbose, registry }) {
24
+ if (specs.length === 0) {
25
+ throw new Error('installPackages: empty specs list');
26
+ }
27
+ // Seed an empty package.json so npm doesn't walk up from prefix and pick
28
+ // up the user's project metadata. Cosmetic name/version only.
29
+ writeFileSync(join(prefix, 'package.json'), JSON.stringify({ name: 'gjsify-dlx-cache', version: '0.0.0', private: true }, null, 2));
30
+ if (registry) {
31
+ writeFileSync(join(prefix, '.npmrc'), `registry=${registry}\n`);
32
+ }
33
+ const args = [
34
+ 'install',
35
+ '--no-package-lock',
36
+ '--no-audit',
37
+ '--no-fund',
38
+ '--prefix', prefix,
39
+ ...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
40
+ ...specs,
41
+ ];
42
+ await new Promise((resolve, reject) => {
43
+ const child = spawn('npm', args, { stdio: 'inherit' });
44
+ child.on('close', (code) => {
45
+ if (code === 0)
46
+ resolve();
47
+ else
48
+ reject(new Error(`npm install exited with code ${code}`));
49
+ });
50
+ child.on('error', (err) => {
51
+ const msg = err.code === 'ENOENT'
52
+ ? 'npm not found on PATH — install Node.js or set GJSIFY_INSTALL_BACKEND=native (not yet supported)'
53
+ : `npm install failed: ${err.message}`;
54
+ reject(new Error(msg));
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,20 @@
1
+ export type ParsedSpec = {
2
+ kind: 'local';
3
+ path: string;
4
+ } | {
5
+ kind: 'registry';
6
+ name: string;
7
+ version?: string;
8
+ spec: string;
9
+ };
10
+ /**
11
+ * Parse a CLI input into either a local-path or an npm-registry spec.
12
+ *
13
+ * Local: starts with `./`, `../`, `/`, or is an existing directory path.
14
+ * Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
15
+ *
16
+ * The full spec string is preserved on the registry case so it can be passed
17
+ * verbatim to `npm install` (which already understands dist-tags, ranges,
18
+ * git URIs, tarball URLs, etc. — we don't re-parse those).
19
+ */
20
+ export declare function parseSpec(input: string): ParsedSpec;
@@ -0,0 +1,37 @@
1
+ // Parse a `gjsify dlx` package spec — distinguishes local paths from npm specs.
2
+ import { existsSync } from 'node:fs';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
5
+ /**
6
+ * Parse a CLI input into either a local-path or an npm-registry spec.
7
+ *
8
+ * Local: starts with `./`, `../`, `/`, or is an existing directory path.
9
+ * Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
10
+ *
11
+ * The full spec string is preserved on the registry case so it can be passed
12
+ * verbatim to `npm install` (which already understands dist-tags, ranges,
13
+ * git URIs, tarball URLs, etc. — we don't re-parse those).
14
+ */
15
+ export function parseSpec(input) {
16
+ if (!input)
17
+ throw new Error('dlx: empty package spec');
18
+ if (input.startsWith('./') || input.startsWith('../') || isAbsolute(input)) {
19
+ return { kind: 'local', path: resolve(input) };
20
+ }
21
+ if (existsSync(input)) {
22
+ return { kind: 'local', path: resolve(input) };
23
+ }
24
+ // Registry spec: split off the version after the LAST `@` that isn't the
25
+ // leading scope separator.
26
+ let name = input;
27
+ let version;
28
+ const lastAt = input.lastIndexOf('@');
29
+ if (lastAt > 0) {
30
+ name = input.slice(0, lastAt);
31
+ version = input.slice(lastAt + 1);
32
+ }
33
+ if (!NPM_NAME_RE.test(name)) {
34
+ throw new Error(`dlx: invalid package name "${name}"`);
35
+ }
36
+ return { kind: 'registry', name, version, spec: input };
37
+ }
@@ -0,0 +1,7 @@
1
+ interface ResolvedEntry {
2
+ bundlePath: string;
3
+ binName: string | null;
4
+ fromFallback: boolean;
5
+ }
6
+ export declare function resolveGjsEntry(pkgDir: string, binName: string | null): ResolvedEntry;
7
+ export {};
@@ -0,0 +1,65 @@
1
+ // Resolve the GJS entry point of an installed package.
2
+ //
3
+ // Per the `gjsify` field convention (see CLI reference):
4
+ //
5
+ // {
6
+ // "gjsify": {
7
+ // "main": "dist/gjs.js",
8
+ // "bin": { "name-a": "dist/a.js", "name-b": "dist/b.js" }
9
+ // }
10
+ // }
11
+ //
12
+ // Resolution order:
13
+ // 1. user-supplied bin name + `gjsify.bin[name]` → that path
14
+ // 2. single-entry `gjsify.bin` → the only path
15
+ // 3. `gjsify.main` → that path
16
+ // 4. fallback: `package.json#main` → that path (advisory warning)
17
+ // 5. otherwise: hard-fail with a fix hint
18
+ import { readFileSync } from 'node:fs';
19
+ import { existsSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ export function resolveGjsEntry(pkgDir, binName) {
22
+ const pkgJsonPath = join(pkgDir, 'package.json');
23
+ if (!existsSync(pkgJsonPath)) {
24
+ throw new Error(`dlx: no package.json found at ${pkgDir}`);
25
+ }
26
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
27
+ const gjsifyBin = pkg.gjsify?.bin;
28
+ const gjsifyMain = pkg.gjsify?.main;
29
+ const fallbackMain = pkg.main;
30
+ let entry;
31
+ let resolvedBin = null;
32
+ let fromFallback = false;
33
+ if (binName !== null) {
34
+ if (!gjsifyBin || !gjsifyBin[binName]) {
35
+ const known = gjsifyBin ? Object.keys(gjsifyBin).join(', ') : '(none)';
36
+ throw new Error(`dlx: package "${pkg.name ?? pkgDir}" has no GJS bin named "${binName}" — known: ${known}`);
37
+ }
38
+ entry = gjsifyBin[binName];
39
+ resolvedBin = binName;
40
+ }
41
+ else if (gjsifyBin && Object.keys(gjsifyBin).length === 1) {
42
+ const onlyBin = Object.keys(gjsifyBin)[0];
43
+ entry = gjsifyBin[onlyBin];
44
+ resolvedBin = onlyBin;
45
+ }
46
+ else if (gjsifyMain) {
47
+ entry = gjsifyMain;
48
+ }
49
+ else if (fallbackMain) {
50
+ entry = fallbackMain;
51
+ fromFallback = true;
52
+ }
53
+ if (gjsifyBin && Object.keys(gjsifyBin).length > 1 && binName === null) {
54
+ const names = Object.keys(gjsifyBin).join(', ');
55
+ throw new Error(`dlx: package "${pkg.name ?? pkgDir}" defines multiple GJS bins — pass one of: ${names}`);
56
+ }
57
+ if (!entry) {
58
+ throw new Error(`dlx: package "${pkg.name ?? pkgDir}" has no GJS entry — set \`gjsify.main\` (or \`gjsify.bin\`) in its package.json`);
59
+ }
60
+ const bundlePath = resolve(pkgDir, entry);
61
+ if (!existsSync(bundlePath)) {
62
+ throw new Error(`dlx: GJS entry not found: ${bundlePath}`);
63
+ }
64
+ return { bundlePath, binName: resolvedBin, fromFallback };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,15 +23,10 @@
23
23
  "cli"
24
24
  ],
25
25
  "dependencies": {
26
- "@gjsify/create-app": "^0.3.4",
27
- "@gjsify/esbuild-plugin-gjsify": "^0.3.4",
28
- "@gjsify/example-dom-canvas2d-fireworks": "^0.3.4",
29
- "@gjsify/example-dom-excalibur-jelly-jumper": "^0.3.4",
30
- "@gjsify/example-dom-three-geometry-teapot": "^0.3.4",
31
- "@gjsify/example-dom-three-postprocessing-pixel": "^0.3.4",
32
- "@gjsify/example-node-express-webserver": "^0.3.4",
33
- "@gjsify/node-polyfills": "^0.3.4",
34
- "@gjsify/web-polyfills": "^0.3.4",
26
+ "@gjsify/create-app": "^0.3.6",
27
+ "@gjsify/esbuild-plugin-gjsify": "^0.3.6",
28
+ "@gjsify/node-polyfills": "^0.3.6",
29
+ "@gjsify/web-polyfills": "^0.3.6",
35
30
  "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.15",
36
31
  "cosmiconfig": "^9.0.1",
37
32
  "esbuild": "^0.28.0",
package/showcases.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "showcases": [
4
+ {
5
+ "name": "canvas2d-fireworks",
6
+ "category": "dom",
7
+ "package": "@gjsify/example-dom-canvas2d-fireworks",
8
+ "description": "Colorful fireworks Canvas 2D example with Adwaita controls",
9
+ "needsWebgl": false
10
+ },
11
+ {
12
+ "name": "excalibur-jelly-jumper",
13
+ "category": "dom",
14
+ "package": "@gjsify/example-dom-excalibur-jelly-jumper",
15
+ "description": "Excalibur.js jelly-jumper game",
16
+ "needsWebgl": false
17
+ },
18
+ {
19
+ "name": "three-geometry-teapot",
20
+ "category": "dom",
21
+ "package": "@gjsify/example-dom-three-geometry-teapot",
22
+ "description": "Three.js Utah teapot",
23
+ "needsWebgl": true
24
+ },
25
+ {
26
+ "name": "three-postprocessing-pixel",
27
+ "category": "dom",
28
+ "package": "@gjsify/example-dom-three-postprocessing-pixel",
29
+ "description": "Three.js postprocessing pixel demo",
30
+ "needsWebgl": true
31
+ },
32
+ {
33
+ "name": "express-webserver",
34
+ "category": "node",
35
+ "package": "@gjsify/example-node-express-webserver",
36
+ "description": "Express web server on GJS via @gjsify/http",
37
+ "needsWebgl": false
38
+ }
39
+ ]
40
+ }
@@ -51,13 +51,16 @@ async function getPnpPlugin(): Promise<Plugin | null> {
51
51
  const gjsifyIssuer = fileURLToPath(import.meta.url);
52
52
 
53
53
  // Two-hop relay: node-polyfills and web-polyfills have all individual
54
- // @gjsify/* packages as direct deps. Resolving from their paths allows
55
- // PnP to reach e.g. @gjsify/node-globals (dep of node-polyfills).
54
+ // @gjsify/* packages as direct deps. Resolving from their package.json
55
+ // paths allows PnP to use them as issuers sub-path imports
56
+ // (`@gjsify/foo/register/bar`) then resolve through the polyfill's
57
+ // dep graph. Resolve to package.json (always present, exports-agnostic)
58
+ // rather than main/module (the polyfills meta packages have no main).
56
59
  const requireFromGjsify = createRequire(gjsifyIssuer);
57
60
  const relayIssuers: string[] = [];
58
61
  for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
59
62
  try {
60
- relayIssuers.push(requireFromGjsify.resolve(pkg));
63
+ relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
61
64
  } catch {
62
65
  // polyfills package not in dep tree — relay won't cover it
63
66
  }
@@ -69,10 +72,16 @@ async function getPnpPlugin(): Promise<Plugin | null> {
69
72
  };
70
73
  let pnpApi: PnpApi | null = null;
71
74
  try {
72
- // pnpapi has no npm package — it is a virtual module injected by Yarn PnP
75
+ // pnpapi has no npm package — it is a virtual CJS module injected by
76
+ // Yarn PnP. `await import()` of a CJS module yields the ESM namespace
77
+ // `{ default, "module.exports" }`, NOT the exports object — so
78
+ // `mod.resolveRequest` is `undefined`. Unwrap `.default` (the CJS
79
+ // exports) before use, falling back to the namespace itself for ESM
80
+ // builds of pnpapi (none today, but defensive).
73
81
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
74
82
  // @ts-expect-error
75
- pnpApi = (await import("pnpapi")) as PnpApi;
83
+ const mod = await import("pnpapi");
84
+ pnpApi = ((mod as { default?: PnpApi }).default ?? mod) as PnpApi;
76
85
  } catch {
77
86
  // Not in a PnP runtime (shouldn't happen since findPnpRoot passed)
78
87
  }
@@ -95,10 +95,9 @@ export const buildCommand: Command<any, CliBuildOptions> = {
95
95
  default: 'auto'
96
96
  })
97
97
  .option('shebang', {
98
- description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile.",
98
+ description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile. Default: false (use --shebang to enable, or set `shebang: true` in `.gjsifyrc.js`).",
99
99
  type: 'boolean',
100
- normalize: true,
101
- default: false
100
+ normalize: true
102
101
  })
103
102
  .option('external', {
104
103
  description: "Module names that should NOT be bundled. Repeat the flag or pass a comma-separated list (e.g. --external typedoc,prettier). Globs are forwarded to esbuild as-is. See https://esbuild.github.io/api/#external",
@@ -0,0 +1,173 @@
1
+ // `gjsify dlx <package> [bin] [-- args...]` — runs the GJS bundle of an
2
+ // npm-published package without persisting it in the user's project.
3
+ //
4
+ // Cardinal rule: dlx is a **GJS-bundle runner**, not a generic bin runner.
5
+ // It always invokes `gjs -m <bundle>` via the existing `runGjsBundle()` util.
6
+ // Packages without a GJS entry (no `gjsify.main`/`gjsify.bin`, no fallback
7
+ // `main`) fail loudly.
8
+ //
9
+ // Cache: $XDG_CACHE_HOME/gjsify/dlx/<sha256>/ with TTL (default 7d, override
10
+ // via --cache-max-age=<minutes>). Cache hit on second run skips `npm install`
11
+ // entirely. Layout + atomic-swap pattern adapted from pnpm's dlx implementation
12
+ // (refs/pnpm/exec/commands/src/dlx.ts).
13
+
14
+ import type { Command } from '../types/index.js';
15
+ import { runGjsBundle } from '../utils/run-gjs.js';
16
+ import { parseSpec, type ParsedSpec } from '../utils/parse-spec.js';
17
+ import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
18
+ import {
19
+ cacheDirFor,
20
+ createCacheKey,
21
+ getValidCachedPkg,
22
+ makePrepareDir,
23
+ resolveInstalledPkgDir,
24
+ symlinkSwap,
25
+ } from '../utils/dlx-cache.js';
26
+ import { installPackages } from '../utils/install-backend.js';
27
+
28
+ interface DlxOptions {
29
+ spec: string;
30
+ binOrArg?: string;
31
+ extraArgs?: string[];
32
+ 'cache-max-age': number;
33
+ verbose: boolean;
34
+ registry?: string;
35
+ }
36
+
37
+ export const dlxCommand: Command<any, DlxOptions> = {
38
+ command: 'dlx <spec> [binOrArg] [extraArgs..]',
39
+ description:
40
+ 'Run the GJS bundle of an npm-published package without installing it locally.',
41
+ builder: (yargs) =>
42
+ yargs
43
+ .positional('spec', {
44
+ description:
45
+ 'Package spec (`name`, `name@version`, `@scope/name@spec`, or local path).',
46
+ type: 'string',
47
+ demandOption: true,
48
+ })
49
+ .positional('binOrArg', {
50
+ description:
51
+ 'Optional bin name when the package defines `gjsify.bin` with multiple entries; otherwise treated as the first argument forwarded to the bundle.',
52
+ type: 'string',
53
+ })
54
+ .positional('extraArgs', {
55
+ description: 'Extra args forwarded to `gjs -m <bundle>`.',
56
+ type: 'string',
57
+ array: true,
58
+ })
59
+ .option('cache-max-age', {
60
+ description:
61
+ 'Cache TTL in minutes. Defaults to 7 days. Use 0 to bypass cache.',
62
+ type: 'number',
63
+ default: 60 * 24 * 7,
64
+ })
65
+ .option('verbose', {
66
+ description: 'Verbose logging (passes --loglevel verbose to npm).',
67
+ type: 'boolean',
68
+ default: false,
69
+ })
70
+ .option('registry', {
71
+ description: 'Registry URL override.',
72
+ type: 'string',
73
+ }),
74
+ handler: async (args) => {
75
+ const parsed = parseSpec(args.spec);
76
+
77
+ const { pkgDir, cachedPkgName } = await ensurePkgDir(parsed, {
78
+ verbose: args.verbose,
79
+ registry: args.registry,
80
+ cacheMaxAge: args['cache-max-age'],
81
+ });
82
+
83
+ // Bin / args disambiguation:
84
+ // gjsify dlx <pkg> → no bin, no args
85
+ // gjsify dlx <pkg> mybin → bin if package has gjsify.bin[mybin], else arg
86
+ // gjsify dlx <pkg> mybin -- arg1 arg2 → bin + extra args
87
+ // gjsify dlx <pkg> -- arg1 arg2 → no bin, extra args
88
+ const { binName, extraArgs } = splitBinAndArgs(
89
+ pkgDir,
90
+ args.binOrArg,
91
+ args.extraArgs ?? [],
92
+ );
93
+
94
+ const entry = resolveGjsEntry(pkgDir, binName);
95
+ if (entry.fromFallback) {
96
+ console.warn(
97
+ `[gjsify dlx] package "${cachedPkgName ?? parsed.kind}" has no \`gjsify\` field — falling back to package.json#main. Add \`gjsify.main\` to silence.`,
98
+ );
99
+ }
100
+
101
+ await runGjsBundle(entry.bundlePath, extraArgs);
102
+ },
103
+ };
104
+
105
+ interface EnsureOpts {
106
+ verbose: boolean;
107
+ registry?: string;
108
+ cacheMaxAge: number;
109
+ }
110
+
111
+ async function ensurePkgDir(
112
+ parsed: ParsedSpec,
113
+ opts: EnsureOpts,
114
+ ): Promise<{ pkgDir: string; cachedPkgName: string | null }> {
115
+ if (parsed.kind === 'local') {
116
+ return { pkgDir: parsed.path, cachedPkgName: null };
117
+ }
118
+
119
+ const cacheKey = createCacheKey({ packages: [parsed.spec] });
120
+ const cacheDir = cacheDirFor(cacheKey);
121
+
122
+ const cached = opts.cacheMaxAge > 0 ? getValidCachedPkg(cacheDir, opts.cacheMaxAge) : undefined;
123
+ if (cached) {
124
+ return {
125
+ pkgDir: resolveInstalledPkgDir(cached, parsed.name),
126
+ cachedPkgName: parsed.name,
127
+ };
128
+ }
129
+
130
+ const prepareDir = makePrepareDir(cacheDir);
131
+ await installPackages({
132
+ prefix: prepareDir,
133
+ specs: [parsed.spec],
134
+ verbose: opts.verbose,
135
+ registry: opts.registry,
136
+ });
137
+
138
+ const liveTarget = symlinkSwap(cacheDir, prepareDir);
139
+ return {
140
+ pkgDir: resolveInstalledPkgDir(liveTarget, parsed.name),
141
+ cachedPkgName: parsed.name,
142
+ };
143
+ }
144
+
145
+ import { existsSync, readFileSync } from 'node:fs';
146
+ import { join } from 'node:path';
147
+
148
+ function splitBinAndArgs(
149
+ pkgDir: string,
150
+ binOrArg: string | undefined,
151
+ extraArgs: string[],
152
+ ): { binName: string | null; extraArgs: string[] } {
153
+ if (!binOrArg) {
154
+ return { binName: null, extraArgs };
155
+ }
156
+
157
+ const pkgJsonPath = join(pkgDir, 'package.json');
158
+ if (existsSync(pkgJsonPath)) {
159
+ try {
160
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as {
161
+ gjsify?: { bin?: Record<string, string> };
162
+ };
163
+ const bins = pkg.gjsify?.bin;
164
+ if (bins && Object.prototype.hasOwnProperty.call(bins, binOrArg)) {
165
+ return { binName: binOrArg, extraArgs };
166
+ }
167
+ } catch {
168
+ // Fall through to treating as an arg.
169
+ }
170
+ }
171
+ // Not a known bin — treat the positional as the first argv to the bundle.
172
+ return { binName: null, extraArgs: [binOrArg, ...extraArgs] };
173
+ }
@@ -5,4 +5,6 @@ export * from './check.js';
5
5
  export * from './showcase.js';
6
6
  export * from './create.js';
7
7
  export * from './gresource.js';
8
- export * from './gettext.js';
8
+ export * from './gettext.js';
9
+ export * from './dlx.js';
10
+ export * from './install.js';