@gjsify/cli 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,15 +23,15 @@
23
23
  "cli"
24
24
  ],
25
25
  "dependencies": {
26
- "@gjsify/create-app": "^0.3.3",
27
- "@gjsify/esbuild-plugin-gjsify": "^0.3.3",
28
- "@gjsify/example-dom-canvas2d-fireworks": "^0.3.3",
29
- "@gjsify/example-dom-excalibur-jelly-jumper": "^0.3.3",
30
- "@gjsify/example-dom-three-geometry-teapot": "^0.3.3",
31
- "@gjsify/example-dom-three-postprocessing-pixel": "^0.3.3",
32
- "@gjsify/example-node-express-webserver": "^0.3.3",
33
- "@gjsify/node-polyfills": "^0.3.3",
34
- "@gjsify/web-polyfills": "^0.3.3",
26
+ "@gjsify/create-app": "^0.3.5",
27
+ "@gjsify/esbuild-plugin-gjsify": "^0.3.5",
28
+ "@gjsify/example-dom-canvas2d-fireworks": "^0.3.5",
29
+ "@gjsify/example-dom-excalibur-jelly-jumper": "^0.3.5",
30
+ "@gjsify/example-dom-three-geometry-teapot": "^0.3.5",
31
+ "@gjsify/example-dom-three-postprocessing-pixel": "^0.3.5",
32
+ "@gjsify/example-node-express-webserver": "^0.3.5",
33
+ "@gjsify/node-polyfills": "^0.3.5",
34
+ "@gjsify/web-polyfills": "^0.3.5",
35
35
  "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.15",
36
36
  "cosmiconfig": "^9.0.1",
37
37
  "esbuild": "^0.28.0",
@@ -8,8 +8,10 @@ import {
8
8
  detectAutoGlobals,
9
9
  } from "@gjsify/esbuild-plugin-gjsify/globals";
10
10
  import { dirname, extname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
11
12
  import { chmod, readFile, writeFile } from "node:fs/promises";
12
13
  import { existsSync } from "node:fs";
14
+ import { createRequire } from "node:module";
13
15
 
14
16
  const GJS_SHEBANG = "#!/usr/bin/env -S gjs -m\n";
15
17
 
@@ -29,27 +31,85 @@ function findPnpRoot(dir: string): string | null {
29
31
  * @yarnpkg/esbuild-plugin-pnp plugin so esbuild can resolve
30
32
  * modules from zip archives without manual extraction.
31
33
  *
32
- * Custom onResolve: fall through on UNDECLARED_DEPENDENCY errors so the
33
- * gjsify alias plugin can handle bare specifiers (e.g. `abort-controller`)
34
- * that PnP can't resolve from the inject file's issuer context but that
35
- * gjsify maps to `@gjsify/*` packages the project DOES have available.
34
+ * Custom onResolve: when the project's PnP context throws
35
+ * UNDECLARED_DEPENDENCY, retry via a two-hop relay:
36
+ * 1. @gjsify/cli context (direct dep of the project using gjsify build)
37
+ * 2. @gjsify/node-polyfills context (direct dep of @gjsify/cli, has all
38
+ * node polyfills as direct deps including @gjsify/node-globals)
39
+ * 3. @gjsify/web-polyfills context (direct dep of @gjsify/cli, has all
40
+ * web polyfills as direct deps including @gjsify/fetch, @gjsify/abort-controller)
41
+ * For bare specifiers that the gjsify alias plugin maps (e.g. `abort-controller`),
42
+ * fall through so that plugin can handle the transformation first.
36
43
  */
37
44
  async function getPnpPlugin(): Promise<Plugin | null> {
38
45
  if (!findPnpRoot(process.cwd())) return null;
39
46
  try {
40
47
  const { pnpPlugin } = await import("@yarnpkg/esbuild-plugin-pnp");
48
+
49
+ // gjsify's own file path — @gjsify/cli has node-polyfills + web-polyfills
50
+ // as direct deps, so we can resolve them as relay issuers from here.
51
+ const gjsifyIssuer = fileURLToPath(import.meta.url);
52
+
53
+ // Two-hop relay: node-polyfills and web-polyfills have all individual
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).
59
+ const requireFromGjsify = createRequire(gjsifyIssuer);
60
+ const relayIssuers: string[] = [];
61
+ for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
62
+ try {
63
+ relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
64
+ } catch {
65
+ // polyfills package not in dep tree — relay won't cover it
66
+ }
67
+ }
68
+
69
+ // pnpapi is a virtual module injected by Yarn PnP at runtime.
70
+ type PnpApi = {
71
+ resolveRequest: (req: string, issuer: string) => string | null;
72
+ };
73
+ let pnpApi: PnpApi | null = null;
74
+ try {
75
+ // pnpapi has no npm package — it is a virtual module injected by Yarn PnP
76
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
77
+ // @ts-expect-error
78
+ pnpApi = (await import("pnpapi")) as PnpApi;
79
+ } catch {
80
+ // Not in a PnP runtime (shouldn't happen since findPnpRoot passed)
81
+ }
82
+
41
83
  return pnpPlugin({
42
- onResolve: async (_args, { resolvedPath, error, watchFiles }) => {
84
+ onResolve: async (args, { resolvedPath, error, watchFiles }) => {
43
85
  if (resolvedPath !== null) {
44
86
  return { namespace: "pnp", path: resolvedPath, watchFiles };
45
87
  }
46
- // UNDECLARED_DEPENDENCY: package exists transitively but isn't
47
- // in the issuer's direct deps. Fall through so the gjsify alias
48
- // plugin can resolve it (e.g. bare → @gjsify/* mappings).
49
88
  if (
50
89
  (error as { pnpCode?: string } | null)?.pnpCode ===
51
90
  "UNDECLARED_DEPENDENCY"
52
91
  ) {
92
+ if (pnpApi !== null) {
93
+ // Try @gjsify/cli context first (covers @gjsify/* that are
94
+ // direct deps of cli's own deps — unlikely but fast check).
95
+ try {
96
+ const rp = pnpApi.resolveRequest(args.path, gjsifyIssuer);
97
+ if (rp !== null)
98
+ return { namespace: "pnp", path: rp, watchFiles };
99
+ } catch {}
100
+ // Two-hop relay: resolve from node-polyfills / web-polyfills context
101
+ // which have the individual @gjsify/* packages as direct deps.
102
+ for (const relayIssuer of relayIssuers) {
103
+ try {
104
+ const rp = pnpApi.resolveRequest(args.path, relayIssuer);
105
+ if (rp !== null)
106
+ return { namespace: "pnp", path: rp, watchFiles };
107
+ } catch {}
108
+ }
109
+ }
110
+ // Fall through — bare aliases (abort-controller, fetch/register/*)
111
+ // are handled by the gjsify alias plugin after this returns null,
112
+ // then the re-resolved @gjsify/* path goes through this hook again.
53
113
  return null;
54
114
  }
55
115
  return {
@@ -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,5 @@ 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';
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  createCommand as create,
12
12
  gresourceCommand as gresource,
13
13
  gettextCommand as gettext,
14
+ dlxCommand as dlx,
14
15
  } from './commands/index.js'
15
16
  import { APP_NAME } from './constants.js'
16
17
 
@@ -20,6 +21,7 @@ void yargs(hideBin(process.argv))
20
21
  .command(create.command, create.description, create.builder, create.handler)
21
22
  .command(build.command, build.description, build.builder, build.handler)
22
23
  .command(run.command, run.description, run.builder, run.handler)
24
+ .command(dlx.command, dlx.description, dlx.builder, dlx.handler)
23
25
  .command(info.command, info.description, info.builder, info.handler)
24
26
  .command(check.command, check.description, check.builder, check.handler)
25
27
  .command(showcase.command, showcase.description, showcase.builder, showcase.handler)
@@ -0,0 +1,135 @@
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
+
9
+ import { createHash } from 'node:crypto';
10
+ import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync, type Stats } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join, resolve } from 'node:path';
13
+
14
+ const ONE_MINUTE_MS = 60_000;
15
+ const DEFAULT_TTL_MIN = 60 * 24 * 7; // 7 days
16
+
17
+ function lexCompare(a: string, b: string): number {
18
+ return a < b ? -1 : a > b ? 1 : 0;
19
+ }
20
+
21
+ interface CacheKeyOpts {
22
+ packages: string[];
23
+ registries?: Record<string, string>;
24
+ }
25
+
26
+ /** Stable, sorted JSON hash of inputs. */
27
+ export function createCacheKey(opts: CacheKeyOpts): string {
28
+ const sortedPkgs = [...opts.packages].sort(lexCompare);
29
+ const sortedRegs = Object.entries(opts.registries ?? {}).sort(([a], [b]) => lexCompare(a, b));
30
+ const payload = JSON.stringify([sortedPkgs, sortedRegs]);
31
+ return createHash('sha256').update(payload).digest('hex');
32
+ }
33
+
34
+ /** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
35
+ export function dlxCacheRoot(): string {
36
+ const xdg = process.env.XDG_CACHE_HOME;
37
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
38
+ const root = join(base, 'gjsify', 'dlx');
39
+ mkdirSync(root, { recursive: true });
40
+ return root;
41
+ }
42
+
43
+ /** Per-key cache directory: <root>/<sha>. */
44
+ export function cacheDirFor(cacheKey: string): string {
45
+ const dir = join(dlxCacheRoot(), cacheKey);
46
+ mkdirSync(dir, { recursive: true });
47
+ return dir;
48
+ }
49
+
50
+ /** A fresh prepare directory under the per-key cache, named timestamp-pid. */
51
+ export function makePrepareDir(cacheDir: string): string {
52
+ const name = `${Date.now().toString(16)}-${process.pid.toString(16)}`;
53
+ const dir = join(cacheDir, name);
54
+ mkdirSync(dir, { recursive: true });
55
+ return dir;
56
+ }
57
+
58
+ /**
59
+ * If <cacheDir>/pkg points to a target whose mtime + maxAge < now, return its
60
+ * realpath. Returns undefined when the link doesn't exist, isn't a symlink,
61
+ * has been removed, or has expired.
62
+ */
63
+ export function getValidCachedPkg(
64
+ cacheDir: string,
65
+ maxAgeMinutes: number = DEFAULT_TTL_MIN,
66
+ ): string | undefined {
67
+ const linkPath = join(cacheDir, 'pkg');
68
+ let stats: Stats;
69
+ try {
70
+ stats = lstatSync(linkPath);
71
+ } catch (err) {
72
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
73
+ throw err;
74
+ }
75
+ if (!stats.isSymbolicLink()) return undefined;
76
+
77
+ let target: string;
78
+ try {
79
+ target = realpathSync(linkPath);
80
+ } catch {
81
+ return undefined;
82
+ }
83
+
84
+ const ageMs = Date.now() - stats.mtime.getTime();
85
+ return ageMs <= maxAgeMinutes * ONE_MINUTE_MS ? target : undefined;
86
+ }
87
+
88
+ /**
89
+ * Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
90
+ *
91
+ * Strategy:
92
+ * 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
93
+ * 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
94
+ *
95
+ * Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
96
+ * — a parallel process won, return its realpath.
97
+ */
98
+ export function symlinkSwap(cacheDir: string, prepareDir: string): string {
99
+ const linkPath = join(cacheDir, 'pkg');
100
+ const tmpName = `pkg.tmp-${Date.now().toString(16)}-${process.pid.toString(16)}`;
101
+ const tmpLink = join(cacheDir, tmpName);
102
+
103
+ try {
104
+ symlinkSync(prepareDir, tmpLink, 'dir');
105
+ } catch (err) {
106
+ // If we cannot even create the tmp link, give up.
107
+ throw err;
108
+ }
109
+
110
+ try {
111
+ renameSync(tmpLink, linkPath);
112
+ } catch (err) {
113
+ const code = (err as NodeJS.ErrnoException).code;
114
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
115
+ // Race lost — clean up our tmp and use whoever won.
116
+ try { rmSync(tmpLink); } catch {}
117
+ return realpathSync(linkPath);
118
+ }
119
+ throw err;
120
+ }
121
+
122
+ return realpathSync(linkPath);
123
+ }
124
+
125
+ /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
126
+ export function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes: number = DEFAULT_TTL_MIN): void {
127
+ // Out of scope for Phase 1 — pnpm has the same TODO. Leaving a stub so
128
+ // call sites already exist when we do implement it.
129
+ void cacheDir;
130
+ }
131
+
132
+ /** Resolve absolute path to the installed package's directory inside cache. */
133
+ export function resolveInstalledPkgDir(cachedRoot: string, pkgName: string): string {
134
+ return resolve(cachedRoot, 'node_modules', pkgName);
135
+ }
@@ -0,0 +1,79 @@
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
+
14
+ import { spawn } from 'node:child_process';
15
+ import { writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ export interface InstallOptions {
19
+ /** Directory to install into (npm `--prefix`). Created by caller. */
20
+ prefix: string;
21
+ /** npm-resolvable specs: `name`, `name@version`, `git+https://...`, tarball URL, ... */
22
+ specs: string[];
23
+ /** Verbose logging passes through `--loglevel verbose`. */
24
+ verbose?: boolean;
25
+ /** Optional registry override (writes a temp `.npmrc` in prefix). */
26
+ registry?: string;
27
+ }
28
+
29
+ const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'npm';
30
+
31
+ export async function installPackages(opts: InstallOptions): Promise<void> {
32
+ if (DEFAULT_BACKEND === 'native') {
33
+ throw new Error(
34
+ 'GJSIFY_INSTALL_BACKEND=native is reserved for the Phase 4 GJS-native resolver — not yet implemented.',
35
+ );
36
+ }
37
+ return installViaNpm(opts);
38
+ }
39
+
40
+ async function installViaNpm({ prefix, specs, verbose, registry }: InstallOptions): Promise<void> {
41
+ if (specs.length === 0) {
42
+ throw new Error('installPackages: empty specs list');
43
+ }
44
+
45
+ // Seed an empty package.json so npm doesn't walk up from prefix and pick
46
+ // up the user's project metadata. Cosmetic name/version only.
47
+ writeFileSync(
48
+ join(prefix, 'package.json'),
49
+ JSON.stringify({ name: 'gjsify-dlx-cache', version: '0.0.0', private: true }, null, 2),
50
+ );
51
+
52
+ if (registry) {
53
+ writeFileSync(join(prefix, '.npmrc'), `registry=${registry}\n`);
54
+ }
55
+
56
+ const args = [
57
+ 'install',
58
+ '--no-package-lock',
59
+ '--no-audit',
60
+ '--no-fund',
61
+ '--prefix', prefix,
62
+ ...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
63
+ ...specs,
64
+ ];
65
+
66
+ await new Promise<void>((resolve, reject) => {
67
+ const child = spawn('npm', args, { stdio: 'inherit' });
68
+ child.on('close', (code) => {
69
+ if (code === 0) resolve();
70
+ else reject(new Error(`npm install exited with code ${code}`));
71
+ });
72
+ child.on('error', (err) => {
73
+ const msg = (err as NodeJS.ErrnoException).code === 'ENOENT'
74
+ ? 'npm not found on PATH — install Node.js or set GJSIFY_INSTALL_BACKEND=native (not yet supported)'
75
+ : `npm install failed: ${err.message}`;
76
+ reject(new Error(msg));
77
+ });
78
+ });
79
+ }
@@ -0,0 +1,48 @@
1
+ // Parse a `gjsify dlx` package spec — distinguishes local paths from npm specs.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { isAbsolute, resolve } from 'node:path';
5
+
6
+ export type ParsedSpec =
7
+ | { kind: 'local'; path: string }
8
+ | { kind: 'registry'; name: string; version?: string; spec: string };
9
+
10
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
11
+
12
+ /**
13
+ * Parse a CLI input into either a local-path or an npm-registry spec.
14
+ *
15
+ * Local: starts with `./`, `../`, `/`, or is an existing directory path.
16
+ * Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
17
+ *
18
+ * The full spec string is preserved on the registry case so it can be passed
19
+ * verbatim to `npm install` (which already understands dist-tags, ranges,
20
+ * git URIs, tarball URLs, etc. — we don't re-parse those).
21
+ */
22
+ export function parseSpec(input: string): ParsedSpec {
23
+ if (!input) throw new Error('dlx: empty package spec');
24
+
25
+ if (input.startsWith('./') || input.startsWith('../') || isAbsolute(input)) {
26
+ return { kind: 'local', path: resolve(input) };
27
+ }
28
+
29
+ if (existsSync(input)) {
30
+ return { kind: 'local', path: resolve(input) };
31
+ }
32
+
33
+ // Registry spec: split off the version after the LAST `@` that isn't the
34
+ // leading scope separator.
35
+ let name = input;
36
+ let version: string | undefined;
37
+ const lastAt = input.lastIndexOf('@');
38
+ if (lastAt > 0) {
39
+ name = input.slice(0, lastAt);
40
+ version = input.slice(lastAt + 1);
41
+ }
42
+
43
+ if (!NPM_NAME_RE.test(name)) {
44
+ throw new Error(`dlx: invalid package name "${name}"`);
45
+ }
46
+
47
+ return { kind: 'registry', name, version, spec: input };
48
+ }