@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.
@@ -2,8 +2,10 @@ import { build } from "esbuild";
2
2
  import { gjsifyPlugin } from "@gjsify/esbuild-plugin-gjsify";
3
3
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, } from "@gjsify/esbuild-plugin-gjsify/globals";
4
4
  import { dirname, extname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { chmod, readFile, writeFile } from "node:fs/promises";
6
7
  import { existsSync } from "node:fs";
8
+ import { createRequire } from "node:module";
7
9
  const GJS_SHEBANG = "#!/usr/bin/env -S gjs -m\n";
8
10
  /** Walk up from dir until .pnp.cjs is found; return its directory or null. */
9
11
  function findPnpRoot(dir) {
@@ -22,26 +24,80 @@ function findPnpRoot(dir) {
22
24
  * @yarnpkg/esbuild-plugin-pnp plugin so esbuild can resolve
23
25
  * modules from zip archives without manual extraction.
24
26
  *
25
- * Custom onResolve: fall through on UNDECLARED_DEPENDENCY errors so the
26
- * gjsify alias plugin can handle bare specifiers (e.g. `abort-controller`)
27
- * that PnP can't resolve from the inject file's issuer context but that
28
- * gjsify maps to `@gjsify/*` packages the project DOES have available.
27
+ * Custom onResolve: when the project's PnP context throws
28
+ * UNDECLARED_DEPENDENCY, retry via a two-hop relay:
29
+ * 1. @gjsify/cli context (direct dep of the project using gjsify build)
30
+ * 2. @gjsify/node-polyfills context (direct dep of @gjsify/cli, has all
31
+ * node polyfills as direct deps including @gjsify/node-globals)
32
+ * 3. @gjsify/web-polyfills context (direct dep of @gjsify/cli, has all
33
+ * web polyfills as direct deps including @gjsify/fetch, @gjsify/abort-controller)
34
+ * For bare specifiers that the gjsify alias plugin maps (e.g. `abort-controller`),
35
+ * fall through so that plugin can handle the transformation first.
29
36
  */
30
37
  async function getPnpPlugin() {
31
38
  if (!findPnpRoot(process.cwd()))
32
39
  return null;
33
40
  try {
34
41
  const { pnpPlugin } = await import("@yarnpkg/esbuild-plugin-pnp");
42
+ // gjsify's own file path — @gjsify/cli has node-polyfills + web-polyfills
43
+ // as direct deps, so we can resolve them as relay issuers from here.
44
+ const gjsifyIssuer = fileURLToPath(import.meta.url);
45
+ // Two-hop relay: node-polyfills and web-polyfills have all individual
46
+ // @gjsify/* packages as direct deps. Resolving from their package.json
47
+ // paths allows PnP to use them as issuers — sub-path imports
48
+ // (`@gjsify/foo/register/bar`) then resolve through the polyfill's
49
+ // dep graph. Resolve to package.json (always present, exports-agnostic)
50
+ // rather than main/module (the polyfills meta packages have no main).
51
+ const requireFromGjsify = createRequire(gjsifyIssuer);
52
+ const relayIssuers = [];
53
+ for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
54
+ try {
55
+ relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
56
+ }
57
+ catch {
58
+ // polyfills package not in dep tree — relay won't cover it
59
+ }
60
+ }
61
+ let pnpApi = null;
62
+ try {
63
+ // pnpapi has no npm package — it is a virtual module injected by Yarn PnP
64
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
65
+ // @ts-expect-error
66
+ pnpApi = (await import("pnpapi"));
67
+ }
68
+ catch {
69
+ // Not in a PnP runtime (shouldn't happen since findPnpRoot passed)
70
+ }
35
71
  return pnpPlugin({
36
- onResolve: async (_args, { resolvedPath, error, watchFiles }) => {
72
+ onResolve: async (args, { resolvedPath, error, watchFiles }) => {
37
73
  if (resolvedPath !== null) {
38
74
  return { namespace: "pnp", path: resolvedPath, watchFiles };
39
75
  }
40
- // UNDECLARED_DEPENDENCY: package exists transitively but isn't
41
- // in the issuer's direct deps. Fall through so the gjsify alias
42
- // plugin can resolve it (e.g. bare → @gjsify/* mappings).
43
76
  if (error?.pnpCode ===
44
77
  "UNDECLARED_DEPENDENCY") {
78
+ if (pnpApi !== null) {
79
+ // Try @gjsify/cli context first (covers @gjsify/* that are
80
+ // direct deps of cli's own deps — unlikely but fast check).
81
+ try {
82
+ const rp = pnpApi.resolveRequest(args.path, gjsifyIssuer);
83
+ if (rp !== null)
84
+ return { namespace: "pnp", path: rp, watchFiles };
85
+ }
86
+ catch { }
87
+ // Two-hop relay: resolve from node-polyfills / web-polyfills context
88
+ // which have the individual @gjsify/* packages as direct deps.
89
+ for (const relayIssuer of relayIssuers) {
90
+ try {
91
+ const rp = pnpApi.resolveRequest(args.path, relayIssuer);
92
+ if (rp !== null)
93
+ return { namespace: "pnp", path: rp, watchFiles };
94
+ }
95
+ catch { }
96
+ }
97
+ }
98
+ // Fall through — bare aliases (abort-controller, fetch/register/*)
99
+ // are handled by the gjsify alias plugin after this returns null,
100
+ // then the re-resolved @gjsify/* path goes through this hook again.
45
101
  return null;
46
102
  }
47
103
  return {
@@ -93,10 +93,9 @@ export const buildCommand = {
93
93
  default: 'auto'
94
94
  })
95
95
  .option('shebang', {
96
- 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.",
96
+ 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`).",
97
97
  type: 'boolean',
98
- normalize: true,
99
- default: false
98
+ normalize: true
100
99
  })
101
100
  .option('external', {
102
101
  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,11 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface DlxOptions {
3
+ spec: string;
4
+ binOrArg?: string;
5
+ extraArgs?: string[];
6
+ 'cache-max-age': number;
7
+ verbose: boolean;
8
+ registry?: string;
9
+ }
10
+ export declare const dlxCommand: Command<any, DlxOptions>;
11
+ export {};
@@ -0,0 +1,117 @@
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
+ import { runGjsBundle } from '../utils/run-gjs.js';
14
+ import { parseSpec } from '../utils/parse-spec.js';
15
+ import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
16
+ import { cacheDirFor, createCacheKey, getValidCachedPkg, makePrepareDir, resolveInstalledPkgDir, symlinkSwap, } from '../utils/dlx-cache.js';
17
+ import { installPackages } from '../utils/install-backend.js';
18
+ export const dlxCommand = {
19
+ command: 'dlx <spec> [binOrArg] [extraArgs..]',
20
+ description: 'Run the GJS bundle of an npm-published package without installing it locally.',
21
+ builder: (yargs) => yargs
22
+ .positional('spec', {
23
+ description: 'Package spec (`name`, `name@version`, `@scope/name@spec`, or local path).',
24
+ type: 'string',
25
+ demandOption: true,
26
+ })
27
+ .positional('binOrArg', {
28
+ description: 'Optional bin name when the package defines `gjsify.bin` with multiple entries; otherwise treated as the first argument forwarded to the bundle.',
29
+ type: 'string',
30
+ })
31
+ .positional('extraArgs', {
32
+ description: 'Extra args forwarded to `gjs -m <bundle>`.',
33
+ type: 'string',
34
+ array: true,
35
+ })
36
+ .option('cache-max-age', {
37
+ description: 'Cache TTL in minutes. Defaults to 7 days. Use 0 to bypass cache.',
38
+ type: 'number',
39
+ default: 60 * 24 * 7,
40
+ })
41
+ .option('verbose', {
42
+ description: 'Verbose logging (passes --loglevel verbose to npm).',
43
+ type: 'boolean',
44
+ default: false,
45
+ })
46
+ .option('registry', {
47
+ description: 'Registry URL override.',
48
+ type: 'string',
49
+ }),
50
+ handler: async (args) => {
51
+ const parsed = parseSpec(args.spec);
52
+ const { pkgDir, cachedPkgName } = await ensurePkgDir(parsed, {
53
+ verbose: args.verbose,
54
+ registry: args.registry,
55
+ cacheMaxAge: args['cache-max-age'],
56
+ });
57
+ // Bin / args disambiguation:
58
+ // gjsify dlx <pkg> → no bin, no args
59
+ // gjsify dlx <pkg> mybin → bin if package has gjsify.bin[mybin], else arg
60
+ // gjsify dlx <pkg> mybin -- arg1 arg2 → bin + extra args
61
+ // gjsify dlx <pkg> -- arg1 arg2 → no bin, extra args
62
+ const { binName, extraArgs } = splitBinAndArgs(pkgDir, args.binOrArg, args.extraArgs ?? []);
63
+ const entry = resolveGjsEntry(pkgDir, binName);
64
+ if (entry.fromFallback) {
65
+ console.warn(`[gjsify dlx] package "${cachedPkgName ?? parsed.kind}" has no \`gjsify\` field — falling back to package.json#main. Add \`gjsify.main\` to silence.`);
66
+ }
67
+ await runGjsBundle(entry.bundlePath, extraArgs);
68
+ },
69
+ };
70
+ async function ensurePkgDir(parsed, opts) {
71
+ if (parsed.kind === 'local') {
72
+ return { pkgDir: parsed.path, cachedPkgName: null };
73
+ }
74
+ const cacheKey = createCacheKey({ packages: [parsed.spec] });
75
+ const cacheDir = cacheDirFor(cacheKey);
76
+ const cached = opts.cacheMaxAge > 0 ? getValidCachedPkg(cacheDir, opts.cacheMaxAge) : undefined;
77
+ if (cached) {
78
+ return {
79
+ pkgDir: resolveInstalledPkgDir(cached, parsed.name),
80
+ cachedPkgName: parsed.name,
81
+ };
82
+ }
83
+ const prepareDir = makePrepareDir(cacheDir);
84
+ await installPackages({
85
+ prefix: prepareDir,
86
+ specs: [parsed.spec],
87
+ verbose: opts.verbose,
88
+ registry: opts.registry,
89
+ });
90
+ const liveTarget = symlinkSwap(cacheDir, prepareDir);
91
+ return {
92
+ pkgDir: resolveInstalledPkgDir(liveTarget, parsed.name),
93
+ cachedPkgName: parsed.name,
94
+ };
95
+ }
96
+ import { existsSync, readFileSync } from 'node:fs';
97
+ import { join } from 'node:path';
98
+ function splitBinAndArgs(pkgDir, binOrArg, extraArgs) {
99
+ if (!binOrArg) {
100
+ return { binName: null, extraArgs };
101
+ }
102
+ const pkgJsonPath = join(pkgDir, 'package.json');
103
+ if (existsSync(pkgJsonPath)) {
104
+ try {
105
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
106
+ const bins = pkg.gjsify?.bin;
107
+ if (bins && Object.prototype.hasOwnProperty.call(bins, binOrArg)) {
108
+ return { binName: binOrArg, extraArgs };
109
+ }
110
+ }
111
+ catch {
112
+ // Fall through to treating as an arg.
113
+ }
114
+ }
115
+ // Not a known bin — treat the positional as the first argv to the bundle.
116
+ return { binName: null, extraArgs: [binOrArg, ...extraArgs] };
117
+ }
@@ -6,3 +6,4 @@ export * from './showcase.js';
6
6
  export * from './create.js';
7
7
  export * from './gresource.js';
8
8
  export * from './gettext.js';
9
+ export * from './dlx.js';
@@ -6,3 +6,4 @@ export * from './showcase.js';
6
6
  export * from './create.js';
7
7
  export * from './gresource.js';
8
8
  export * from './gettext.js';
9
+ export * from './dlx.js';
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, } 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, dlxCommand as dlx, } from './commands/index.js';
5
5
  import { APP_NAME } from './constants.js';
6
6
  void yargs(hideBin(process.argv))
7
7
  .scriptName(APP_NAME)
@@ -9,6 +9,7 @@ void yargs(hideBin(process.argv))
9
9
  .command(create.command, create.description, create.builder, create.handler)
10
10
  .command(build.command, build.description, build.builder, build.handler)
11
11
  .command(run.command, run.description, run.builder, run.handler)
12
+ .command(dlx.command, dlx.description, dlx.builder, dlx.handler)
12
13
  .command(info.command, info.description, info.builder, info.handler)
13
14
  .command(check.command, check.description, check.builder, check.handler)
14
15
  .command(showcase.command, showcase.description, showcase.builder, showcase.handler)
@@ -0,0 +1,34 @@
1
+ interface CacheKeyOpts {
2
+ packages: string[];
3
+ registries?: Record<string, string>;
4
+ }
5
+ /** Stable, sorted JSON hash of inputs. */
6
+ export declare function createCacheKey(opts: CacheKeyOpts): string;
7
+ /** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
8
+ export declare function dlxCacheRoot(): string;
9
+ /** Per-key cache directory: <root>/<sha>. */
10
+ export declare function cacheDirFor(cacheKey: string): string;
11
+ /** A fresh prepare directory under the per-key cache, named timestamp-pid. */
12
+ export declare function makePrepareDir(cacheDir: string): string;
13
+ /**
14
+ * If <cacheDir>/pkg points to a target whose mtime + maxAge < now, return its
15
+ * realpath. Returns undefined when the link doesn't exist, isn't a symlink,
16
+ * has been removed, or has expired.
17
+ */
18
+ export declare function getValidCachedPkg(cacheDir: string, maxAgeMinutes?: number): string | undefined;
19
+ /**
20
+ * Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
21
+ *
22
+ * Strategy:
23
+ * 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
24
+ * 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
25
+ *
26
+ * Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
27
+ * — a parallel process won, return its realpath.
28
+ */
29
+ export declare function symlinkSwap(cacheDir: string, prepareDir: string): string;
30
+ /** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
31
+ export declare function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes?: number): void;
32
+ /** Resolve absolute path to the installed package's directory inside cache. */
33
+ export declare function resolveInstalledPkgDir(cachedRoot: string, pkgName: string): string;
34
+ export {};
@@ -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
+ }