@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,116 @@
1
+ // `gjsify install [pkg...]` — thin npm wrapper with gjsify-aware post-checks.
2
+ //
3
+ // The actual install is delegated to `npm install` in the user's project root
4
+ // (no `--prefix` rewrite, unlike `gjsify dlx`). After install completes we run
5
+ // `runMinimalChecks()` so missing system deps (gjs, gtk4, libsoup, ...) surface
6
+ // immediately, and report any installed `@gjsify/*` packages that ship native
7
+ // prebuilds so users know they can use `gjsify run` to wire `LD_LIBRARY_PATH` /
8
+ // `GI_TYPELIB_PATH` automatically.
9
+ //
10
+ // Modes:
11
+ // gjsify install → project install (npm install)
12
+ // gjsify install <pkg> [<pkg>...] → add package(s) (npm install <pkg>...)
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import type { Command } from '../types/index.js';
16
+ import {
17
+ buildInstallCommand,
18
+ detectPackageManager,
19
+ runMinimalChecks,
20
+ } from '../utils/check-system-deps.js';
21
+ import { detectNativePackages } from '../utils/detect-native-packages.js';
22
+
23
+ interface InstallOptions {
24
+ packages?: string[];
25
+ 'save-dev'?: boolean;
26
+ 'save-peer'?: boolean;
27
+ 'save-optional'?: boolean;
28
+ verbose: boolean;
29
+ }
30
+
31
+ export const installCommand: Command<any, InstallOptions> = {
32
+ command: 'install [packages..]',
33
+ description:
34
+ 'Install npm dependencies in the current project, then run gjsify-aware post-checks.',
35
+ builder: (yargs) =>
36
+ yargs
37
+ .positional('packages', {
38
+ description: 'Optional package specs. With none, runs a full project install.',
39
+ type: 'string',
40
+ array: true,
41
+ })
42
+ .option('save-dev', { type: 'boolean', alias: 'D' })
43
+ .option('save-peer', { type: 'boolean' })
44
+ .option('save-optional', { type: 'boolean', alias: 'O' })
45
+ .option('verbose', {
46
+ description: 'Verbose npm logging.',
47
+ type: 'boolean',
48
+ default: false,
49
+ }),
50
+ handler: async (args) => {
51
+ const npmArgs = ['install'];
52
+ if (args['save-dev']) npmArgs.push('--save-dev');
53
+ if (args['save-peer']) npmArgs.push('--save-peer');
54
+ if (args['save-optional']) npmArgs.push('--save-optional');
55
+ if (args.verbose) npmArgs.push('--loglevel', 'verbose');
56
+ if (args.packages && args.packages.length > 0) {
57
+ npmArgs.push(...args.packages);
58
+ }
59
+
60
+ await spawnNpm(npmArgs);
61
+
62
+ await runPostInstallChecks();
63
+ },
64
+ };
65
+
66
+ async function spawnNpm(npmArgs: string[]): Promise<void> {
67
+ return new Promise<void>((resolve, reject) => {
68
+ const child = spawn('npm', npmArgs, { stdio: 'inherit' });
69
+ child.on('close', (code) => {
70
+ if (code === 0) resolve();
71
+ else reject(new Error(`npm install exited with code ${code}`));
72
+ });
73
+ child.on('error', (err) => {
74
+ const code = (err as NodeJS.ErrnoException).code;
75
+ const msg = code === 'ENOENT'
76
+ ? 'npm not found on PATH — install Node.js first.'
77
+ : `npm install failed: ${err.message}`;
78
+ reject(new Error(msg));
79
+ });
80
+ }).catch((err: Error) => {
81
+ console.error(err.message);
82
+ process.exit(1);
83
+ });
84
+ }
85
+
86
+ async function runPostInstallChecks(): Promise<void> {
87
+ console.log('\n--- gjsify post-install checks ---');
88
+
89
+ // 1. System deps that GJS apps typically need.
90
+ const results = runMinimalChecks();
91
+ const missing = results.filter((r) => !r.found && r.severity === 'required');
92
+ if (missing.length > 0) {
93
+ console.warn('Missing required system dependencies:\n');
94
+ for (const dep of missing) {
95
+ console.warn(` ✗ ${dep.name}`);
96
+ }
97
+ const pm = detectPackageManager();
98
+ const cmd = buildInstallCommand(pm, missing);
99
+ if (cmd) console.warn(`\nInstall with:\n ${cmd}`);
100
+ } else {
101
+ console.log('System dependencies OK.');
102
+ }
103
+
104
+ // 2. Surface @gjsify/* packages with native prebuilds — `gjsify run`
105
+ // will set LD_LIBRARY_PATH / GI_TYPELIB_PATH for these automatically.
106
+ const native = detectNativePackages(process.cwd());
107
+ if (native.length > 0) {
108
+ console.log(
109
+ `\nDetected ${native.length} @gjsify/* package(s) with native prebuilds:`,
110
+ );
111
+ for (const pkg of native) {
112
+ console.log(` • ${pkg.name}`);
113
+ }
114
+ console.log('\nUse `gjsify run <bundle>` to launch with LD_LIBRARY_PATH/GI_TYPELIB_PATH set.');
115
+ }
116
+ }
@@ -1,7 +1,13 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  import { discoverShowcases, findShowcase } from '../utils/discover-showcases.js';
3
- import { runMinimalChecks, checkGwebgl, detectPackageManager, buildInstallCommand } from '../utils/check-system-deps.js';
4
- import { runGjsBundle } from '../utils/run-gjs.js';
3
+ import {
4
+ runMinimalChecks,
5
+ checkGwebgl,
6
+ detectPackageManager,
7
+ buildInstallCommand,
8
+ } from '../utils/check-system-deps.js';
9
+ import { spawn } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
5
11
 
6
12
  interface ShowcaseOptions {
7
13
  name?: string;
@@ -11,9 +17,9 @@ interface ShowcaseOptions {
11
17
 
12
18
  export const showcaseCommand: Command<any, ShowcaseOptions> = {
13
19
  command: 'showcase [name]',
14
- description: 'List or run built-in gjsify showcase applications.',
15
- builder: (yargs) => {
16
- return yargs
20
+ description: 'List or run curated gjsify showcase applications.',
21
+ builder: (yargs) =>
22
+ yargs
17
23
  .positional('name', {
18
24
  description: 'Showcase name to run (omit to list all)',
19
25
  type: 'string',
@@ -27,8 +33,7 @@ export const showcaseCommand: Command<any, ShowcaseOptions> = {
27
33
  description: 'List available showcases',
28
34
  type: 'boolean',
29
35
  default: false,
30
- });
31
- },
36
+ }),
32
37
  handler: async (args) => {
33
38
  // List mode: no name given, or --list flag
34
39
  if (!args.name || args.list) {
@@ -40,11 +45,10 @@ export const showcaseCommand: Command<any, ShowcaseOptions> = {
40
45
  }
41
46
 
42
47
  if (showcases.length === 0) {
43
- console.log('No showcases found. Showcase packages may not be installed.');
48
+ console.log('No showcases found. The CLI ships a curated list in `showcases.json`; if it is missing the CLI install is incomplete.');
44
49
  return;
45
50
  }
46
51
 
47
- // Group by category
48
52
  const grouped = new Map<string, typeof showcases>();
49
53
  for (const sc of showcases) {
50
54
  const list = grouped.get(sc.category) ?? [];
@@ -55,7 +59,7 @@ export const showcaseCommand: Command<any, ShowcaseOptions> = {
55
59
  console.log('Available gjsify showcases:\n');
56
60
  for (const [category, list] of grouped) {
57
61
  console.log(` ${category.toUpperCase()}:`);
58
- const maxNameLen = Math.max(...list.map(e => e.name.length));
62
+ const maxNameLen = Math.max(...list.map((e) => e.name.length));
59
63
  for (const sc of list) {
60
64
  const pad = ' '.repeat(maxNameLen - sc.name.length + 2);
61
65
  const desc = sc.description ? `${pad}${sc.description}` : '';
@@ -68,7 +72,6 @@ export const showcaseCommand: Command<any, ShowcaseOptions> = {
68
72
  return;
69
73
  }
70
74
 
71
- // Run mode: find the showcase
72
75
  const showcase = findShowcase(args.name);
73
76
  if (!showcase) {
74
77
  console.error(`Unknown showcase: "${args.name}"`);
@@ -76,16 +79,14 @@ export const showcaseCommand: Command<any, ShowcaseOptions> = {
76
79
  process.exit(1);
77
80
  }
78
81
 
79
- // System dependency check before running — only check what this showcase needs.
80
- // All showcases need GJS; WebGL showcases additionally need gwebgl prebuilds.
82
+ // System dependency check before delegating — only what this showcase needs.
81
83
  const results = runMinimalChecks();
82
- const needsWebgl = showcase.packageName.includes('webgl') || showcase.packageName.includes('three');
83
- if (needsWebgl) {
84
+ if (showcase.needsWebgl) {
84
85
  results.push(checkGwebgl(process.cwd()));
85
86
  }
86
- // Hard-fail only on missing REQUIRED deps (gjs, gwebgl is required if needsWebgl).
87
- // For showcase, gwebgl is treated as required because the bundle won't run without it.
88
- const missingHard = results.filter(r => !r.found && (r.severity === 'required' || r.id === 'gwebgl'));
87
+ const missingHard = results.filter(
88
+ (r) => !r.found && (r.severity === 'required' || r.id === 'gwebgl'),
89
+ );
89
90
  if (missingHard.length > 0) {
90
91
  console.error('Missing system dependencies:\n');
91
92
  for (const dep of missingHard) {
@@ -99,8 +100,26 @@ export const showcaseCommand: Command<any, ShowcaseOptions> = {
99
100
  process.exit(1);
100
101
  }
101
102
 
102
- // Run the showcase via shared GJS runner
103
- console.log(`Running showcase: ${showcase.name}\n`);
104
- await runGjsBundle(showcase.bundlePath);
103
+ // Delegate to `gjsify dlx <package>` same npm-cache, same atomic
104
+ // symlink-swap, same `gjsify.main` resolution. Re-spawning the CLI
105
+ // keeps the dlx logic in one place.
106
+ console.log(`Running showcase: ${showcase.name} (via gjsify dlx)\n`);
107
+ const cliBin = fileURLToPath(new URL('../index.js', import.meta.url));
108
+ const child = spawn(process.execPath, [cliBin, 'dlx', showcase.packageName], {
109
+ stdio: 'inherit',
110
+ });
111
+ await new Promise<void>((resolvePromise, reject) => {
112
+ child.on('close', (code) => {
113
+ if (code !== 0) {
114
+ reject(new Error(`gjsify dlx exited with code ${code}`));
115
+ } else {
116
+ resolvePromise();
117
+ }
118
+ });
119
+ child.on('error', reject);
120
+ }).catch((err) => {
121
+ console.error(err.message);
122
+ process.exit(1);
123
+ });
105
124
  },
106
125
  };
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  createCommand as create,
12
12
  gresourceCommand as gresource,
13
13
  gettextCommand as gettext,
14
+ dlxCommand as dlx,
15
+ installCommand as install,
14
16
  } from './commands/index.js'
15
17
  import { APP_NAME } from './constants.js'
16
18
 
@@ -18,8 +20,10 @@ void yargs(hideBin(process.argv))
18
20
  .scriptName(APP_NAME)
19
21
  .strict()
20
22
  .command(create.command, create.description, create.builder, create.handler)
23
+ .command(install.command, install.description, install.builder, install.handler)
21
24
  .command(build.command, build.description, build.builder, build.handler)
22
25
  .command(run.command, run.description, run.builder, run.handler)
26
+ .command(dlx.command, dlx.description, dlx.builder, dlx.handler)
23
27
  .command(info.command, info.description, info.builder, info.handler)
24
28
  .command(check.command, check.description, check.builder, check.handler)
25
29
  .command(showcase.command, showcase.description, showcase.builder, showcase.handler)
@@ -159,8 +159,13 @@ const PACKAGE_DEPS: Record<string, string[]> = {
159
159
  '@gjsify/canvas2d': ['gdk-pixbuf', 'pango', 'pangocairo', 'cairo'],
160
160
  '@gjsify/canvas2d-core': ['gdk-pixbuf', 'pango', 'pangocairo', 'cairo'],
161
161
  '@gjsify/dom-elements': ['gdk-pixbuf'],
162
- // @gjsify/webgl, @gjsify/event-bridge only need gtk4/gdk which are
163
- // already in the required set, so they don't need optional entries.
162
+ // @gjsify/webgl needs the gwebgl npm package (Vala prebuild) — handled
163
+ // as a special-case checkNpmPackage rather than checkPkgConfig in
164
+ // runOptionalChecks. Mapping it here so its presence in the project's
165
+ // dep tree triggers the check.
166
+ '@gjsify/webgl': ['gwebgl'],
167
+ // @gjsify/event-bridge only needs gtk4/gdk which are already in the
168
+ // required set, so it doesn't need an optional entry.
164
169
  };
165
170
 
166
171
  /** Walk up from cwd looking for the nearest package.json. */
@@ -322,10 +327,16 @@ function runOptionalChecks(needed: Set<string> | null, cwd: string): DepCheck[]
322
327
  results.push(checkPkgConfig(dep.id, dep.name, dep.pkgName, 'optional', requiredBy));
323
328
  }
324
329
 
325
- // gwebgl npm package — special case (not a pkg-config lib).
326
- // Always reported (the npm package is bundled with the CLI), but marked
327
- // optional because only @gjsify/webgl users need it.
328
- results.push(checkGwebgl(cwd));
330
+ // gwebgl npm package — special case (not a pkg-config lib). Only checked
331
+ // when the project directly or transitively depends on @gjsify/webgl
332
+ // since the CLI tarball no longer ships any showcase example packages,
333
+ // gwebgl is not part of the CLI's own dep tree, so reporting it for every
334
+ // project would always be `found: false`. `needed === null` means "check
335
+ // everything" (no project context).
336
+ const hasWebglDep = needed === null || needed.has('gwebgl');
337
+ if (hasWebglDep) {
338
+ results.push(checkGwebgl(cwd));
339
+ }
329
340
 
330
341
  return results;
331
342
  }
@@ -1,9 +1,13 @@
1
- // Dynamic discovery of installed showcase packages (@gjsify/example-*).
2
- // Scans the CLI's own package.json dependencies at runtime.
1
+ // Static discovery of showcase packages from `showcases.json`.
2
+ //
3
+ // Earlier versions read showcases from the CLI's own `package.json#dependencies`
4
+ // — every showcase had to be a direct CLI dependency. That made the CLI tarball
5
+ // blow up with each new showcase and required a CLI rebuild to publish a new
6
+ // one. Static manifest decouples both: the CLI reads the manifest at runtime,
7
+ // `gjsify showcase <name>` delegates to `gjsify dlx <package>`.
3
8
 
4
- import { readFileSync } from 'node:fs';
9
+ import { existsSync, readFileSync } from 'node:fs';
5
10
  import { dirname, join } from 'node:path';
6
- import { createRequire } from 'node:module';
7
11
  import { fileURLToPath } from 'node:url';
8
12
 
9
13
  export interface ShowcaseInfo {
@@ -13,68 +17,53 @@ export interface ShowcaseInfo {
13
17
  packageName: string;
14
18
  /** Category: "dom" or "node" */
15
19
  category: string;
16
- /** Description from showcase's package.json */
20
+ /** Description for the list view */
17
21
  description: string;
18
- /** Absolute path to the GJS bundle (resolved from "main" field) */
19
- bundlePath: string;
22
+ /** Whether the showcase needs the gwebgl native prebuild. */
23
+ needsWebgl: boolean;
20
24
  }
21
25
 
22
- const EXAMPLE_PREFIX = '@gjsify/example-';
26
+ interface ManifestEntry {
27
+ name: string;
28
+ package: string;
29
+ category: string;
30
+ description?: string;
31
+ needsWebgl?: boolean;
32
+ }
23
33
 
24
- /** Extract short name and category from package name. */
25
- function parseShowcaseName(packageName: string): { name: string; category: string } | null {
26
- // @gjsify/example-dom-three-postprocessing-pixel → category=dom, name=three-postprocessing-pixel
27
- const suffix = packageName.slice(EXAMPLE_PREFIX.length);
28
- const dashIdx = suffix.indexOf('-');
29
- if (dashIdx === -1) return null;
30
- return {
31
- category: suffix.slice(0, dashIdx),
32
- name: suffix.slice(dashIdx + 1),
33
- };
34
+ interface Manifest {
35
+ showcases: ManifestEntry[];
36
+ }
37
+
38
+ function manifestPath(): string {
39
+ // `showcases.json` lives at the package root: ../../showcases.json from lib/utils/.
40
+ return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'showcases.json');
34
41
  }
35
42
 
36
43
  /**
37
- * Discover all installed showcase packages by scanning the CLI's own dependencies.
38
- * Returns showcases sorted by category then name.
44
+ * Read the curated showcase list from `showcases.json`. Returns showcases
45
+ * sorted by category then name. An empty list (or missing manifest) yields
46
+ * an empty array — `gjsify showcase` then prints the empty-state message.
39
47
  */
40
48
  export function discoverShowcases(): ShowcaseInfo[] {
41
- const require = createRequire(import.meta.url);
42
- const cliPkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
43
- let cliPkg: Record<string, unknown>;
49
+ const path = manifestPath();
50
+ if (!existsSync(path)) return [];
51
+
52
+ let manifest: Manifest;
44
53
  try {
45
- cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf-8')) as Record<string, unknown>;
54
+ manifest = JSON.parse(readFileSync(path, 'utf-8')) as Manifest;
46
55
  } catch {
47
56
  return [];
48
57
  }
58
+ if (!Array.isArray(manifest.showcases)) return [];
49
59
 
50
- const deps = cliPkg['dependencies'] as Record<string, string> | undefined;
51
- if (!deps) return [];
52
-
53
- const showcases: ShowcaseInfo[] = [];
54
-
55
- for (const packageName of Object.keys(deps)) {
56
- if (!packageName.startsWith(EXAMPLE_PREFIX)) continue;
57
-
58
- const parsed = parseShowcaseName(packageName);
59
- if (!parsed) continue;
60
-
61
- try {
62
- const pkgJsonPath = require.resolve(`${packageName}/package.json`);
63
- const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as Record<string, unknown>;
64
- const main = pkg['main'] as string | undefined;
65
- if (!main) continue;
66
-
67
- showcases.push({
68
- name: parsed.name,
69
- packageName,
70
- category: parsed.category,
71
- description: (pkg['description'] as string) ?? '',
72
- bundlePath: join(dirname(pkgJsonPath), main),
73
- });
74
- } catch {
75
- // Package listed as dep but not resolvable — skip silently
76
- }
77
- }
60
+ const showcases: ShowcaseInfo[] = manifest.showcases.map((e) => ({
61
+ name: e.name,
62
+ packageName: e.package,
63
+ category: e.category,
64
+ description: e.description ?? '',
65
+ needsWebgl: Boolean(e.needsWebgl),
66
+ }));
78
67
 
79
68
  showcases.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
80
69
  return showcases;
@@ -82,5 +71,5 @@ export function discoverShowcases(): ShowcaseInfo[] {
82
71
 
83
72
  /** Find a single showcase by short name. */
84
73
  export function findShowcase(name: string): ShowcaseInfo | undefined {
85
- return discoverShowcases().find(e => e.name === name);
74
+ return discoverShowcases().find((e) => e.name === name);
86
75
  }
@@ -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
+ }