@gjsify/cli 0.25.1 → 0.26.1

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.
Files changed (41) hide show
  1. package/dist/cli.gjs.mjs +171 -171
  2. package/lib/cli-app.js +7 -10
  3. package/lib/commands/affected.js +160 -17
  4. package/lib/commands/check.js +25 -8
  5. package/lib/commands/create.js +3 -1
  6. package/lib/commands/debug.js +3 -1
  7. package/lib/commands/flatpak/check.js +4 -3
  8. package/lib/commands/flatpak/diff.js +3 -1
  9. package/lib/commands/flatpak/sources.js +4 -7
  10. package/lib/commands/foreach.js +25 -7
  11. package/lib/commands/generate-installer.js +2 -4
  12. package/lib/commands/install.js +15 -7
  13. package/lib/commands/login.js +3 -1
  14. package/lib/commands/onboard.js +3 -6
  15. package/lib/commands/publish.js +8 -11
  16. package/lib/commands/self-update.js +4 -8
  17. package/lib/commands/showcase.d.ts +16 -0
  18. package/lib/commands/showcase.js +44 -24
  19. package/lib/commands/storybook.js +16 -2
  20. package/lib/commands/system-check.js +1 -2
  21. package/lib/commands/test.js +6 -2
  22. package/lib/commands/trust.js +12 -7
  23. package/lib/commands/tsc.js +3 -1
  24. package/lib/commands/uninstall.js +1 -2
  25. package/lib/commands/whoami.js +6 -3
  26. package/lib/utils/build-cache.js +7 -2
  27. package/lib/utils/dir-link.d.ts +58 -0
  28. package/lib/utils/dir-link.js +126 -0
  29. package/lib/utils/dlx-cache.d.ts +30 -3
  30. package/lib/utils/dlx-cache.js +63 -10
  31. package/lib/utils/install-backend-native.js +1 -1
  32. package/lib/utils/install-lock.js +5 -7
  33. package/lib/utils/install-packument-cache.d.ts +23 -0
  34. package/lib/utils/install-packument-cache.js +45 -10
  35. package/lib/utils/install-tarball-cache.js +5 -8
  36. package/lib/utils/oxc-resolve.js +1 -1
  37. package/lib/utils/prompt.js +6 -1
  38. package/lib/utils/runtimes.d.ts +18 -0
  39. package/lib/utils/runtimes.js +20 -0
  40. package/lib/utils/stdin.js +6 -7
  41. package/package.json +17 -17
@@ -116,8 +116,7 @@ export const onboardCommand = {
116
116
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'auth', message: msg })}\n`);
117
117
  else
118
118
  console.error(msg);
119
- process.exit(1);
120
- return;
119
+ return process.exit(1);
121
120
  }
122
121
  // Login may have written a fresh token — reload so the probes/ops use it.
123
122
  npmrc = await loadNpmrc(cwd);
@@ -136,8 +135,7 @@ export const onboardCommand = {
136
135
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'repository', message: msg })}\n`);
137
136
  else
138
137
  console.error(msg);
139
- process.exit(1);
140
- return;
138
+ return process.exit(1);
141
139
  }
142
140
  const workflow = normalizeWorkflowFile(args.workflow);
143
141
  const environment = args.environment;
@@ -155,8 +153,7 @@ export const onboardCommand = {
155
153
  process.stdout.write(`${JSON.stringify({ ok: false, error: 'no-packages', message: msg })}\n`);
156
154
  else
157
155
  console.error(msg);
158
- process.exit(1);
159
- return;
156
+ return process.exit(1);
160
157
  }
161
158
  log(`gjsify onboard: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
162
159
  if (dryRun)
@@ -125,7 +125,9 @@ export const publishCommand = {
125
125
  const rawPkg = JSON.parse(readFileSync(rawPkgPath, 'utf-8'));
126
126
  if (typeof rawPkg.name !== 'string') {
127
127
  process.stderr.write(`gjsify publish --check-trusted: ${rawPkgPath} has no \`name\` field\n`);
128
- process.exit(2);
128
+ // `return` — a bare `process.exit()` is deferred under GJS and
129
+ // the check would continue on a nameless manifest.
130
+ return process.exit(2);
129
131
  }
130
132
  if (rawPkg.private === true) {
131
133
  const out = { ok: true, action: 'check-trusted', name: rawPkg.name, skipped: 'private' };
@@ -432,13 +434,11 @@ function reportPublishOutcome(outcome, asJson) {
432
434
  }
433
435
  case 'otp-required': {
434
436
  console.error(`gjsify publish: npm requires a 2FA one-time code — re-run with \`--otp <code>\``);
435
- process.exit(1);
436
- return;
437
+ return process.exit(1);
437
438
  }
438
439
  case 'oidc-failed': {
439
440
  handleOidcFailure(outcome.error, outcome.name, asJson);
440
- process.exit(1);
441
- return;
441
+ return process.exit(1);
442
442
  }
443
443
  case 'oidc-no-token': {
444
444
  const msg = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
@@ -446,8 +446,7 @@ function reportPublishOutcome(outcome, asJson) {
446
446
  if (asJson) {
447
447
  process.stdout.write(`${JSON.stringify({ ok: false, name: outcome.name, version: outcome.version, error: 'oidc-failed-no-token' }, null, 2)}\n`);
448
448
  }
449
- process.exit(1);
450
- return;
449
+ return process.exit(1);
451
450
  }
452
451
  case 'diagnostic': {
453
452
  if (asJson) {
@@ -463,14 +462,12 @@ function reportPublishOutcome(outcome, asJson) {
463
462
  else {
464
463
  process.stderr.write(`${outcome.diag.message}\n`);
465
464
  }
466
- process.exit(1);
467
- return;
465
+ return process.exit(1);
468
466
  }
469
467
  case 'error': {
470
468
  console.error(`gjsify publish: ${outcome.name}@${outcome.version} — ${outcome.status} ${outcome.statusText}`);
471
469
  console.error(outcome.text);
472
- process.exit(1);
473
- return;
470
+ return process.exit(1);
474
471
  }
475
472
  }
476
473
  }
@@ -65,15 +65,13 @@ export const selfUpdateCommand = {
65
65
  catch (err) {
66
66
  const msg = err instanceof Error ? err.message : String(err);
67
67
  console.error(`Failed to fetch packument: ${msg}`);
68
- process.exit(1);
69
- return;
68
+ return process.exit(1);
70
69
  }
71
70
  const target = resolveTag(packument, args.tag);
72
71
  if (!target) {
73
72
  console.error(`Unknown dist-tag '${args.tag}' on ${PACKAGE_NAME}. ` +
74
73
  `Known tags: ${Object.keys(packument['dist-tags'] ?? {}).join(', ') || '(none)'}`);
75
- process.exit(1);
76
- return;
74
+ return process.exit(1);
77
75
  }
78
76
  console.log(`Latest matching --tag ${args.tag}: v${target}`);
79
77
  // A version match is NOT sufficient to declare "up to date": a global
@@ -103,8 +101,7 @@ export const selfUpdateCommand = {
103
101
  }
104
102
  if (args.check) {
105
103
  console.log(currentVersion ? `Update available: v${currentVersion} → v${target}` : `Install required: → v${target}`);
106
- process.exit(1);
107
- return;
104
+ return process.exit(1);
108
105
  }
109
106
  // `@gjsify/cli` ships two runtimes from one package: the Node `bin`
110
107
  // (`lib/index.js`, which imports its deps from `node_modules`) and the
@@ -141,8 +138,7 @@ export const selfUpdateCommand = {
141
138
  const msg = err instanceof Error ? err.message : String(err);
142
139
  console.error(`self-update: install failed — ${msg}`);
143
140
  console.error(`Re-run \`gjsify self-update\` to retry.`);
144
- process.exit(1);
145
- return;
141
+ return process.exit(1);
146
142
  }
147
143
  // Lay down (or repair) the GJS-native engine bridges in lockstep with
148
144
  // the bundle — same set + best-effort handling as the global installer.
@@ -1,4 +1,20 @@
1
1
  import type { Command } from '../types/index.js';
2
+ /**
3
+ * Pinned invocations for the three runners that cache an unpinned bin.
4
+ *
5
+ * The deno line carries TWO flags because deno stacks two independent refusals,
6
+ * and dropping either one leaves the hint broken in exactly the window where it
7
+ * is reached most: `--reload` busts the `DENO_DIR` copy, and `--min-dep-age 0`
8
+ * waives deno's supply-chain policy of refusing any version published within
9
+ * the last 24 h (`minimumDependencyAge`, default `24h`). Without the second
10
+ * flag `@latest` silently resolves to the newest release OLDER THAN A DAY — so
11
+ * a user who hits a bug, is told to run `@latest`, and does, gets the SAME
12
+ * pre-fix binary back and a stale error message with it. Measured twice on one
13
+ * day against 0.25.1 (minutes old) falling back to 0.24.1. `@latest` is kept
14
+ * rather than a baked version so the hint cannot go stale itself; the age
15
+ * waiver is what makes `@latest` mean latest.
16
+ */
17
+ export declare const PIN_HINT: string;
2
18
  interface ShowcaseOptions {
3
19
  name?: string;
4
20
  json: boolean;
@@ -7,7 +7,7 @@ import { ensurePkgDir } from './dlx.js';
7
7
  import { parseSpec } from '../utils/parse-spec.js';
8
8
  import { resolveNodeEntry } from '../utils/resolve-gjs-entry.js';
9
9
  import { runRuntimeBundle } from '../utils/run-node.js';
10
- import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, defaultExampleRuntime, } from '../utils/runtimes.js';
10
+ import { EXAMPLE_RUNTIMES, isExampleRuntime, readDeclaredRuntimes, checkRuntimeSupported, defaultExampleRuntime, requiresGjsSystemDeps, } from '../utils/runtimes.js';
11
11
  function readCliVersion() {
12
12
  try {
13
13
  const pkgUrl = new URL('../../package.json', import.meta.url);
@@ -34,11 +34,25 @@ function readCliVersion() {
34
34
  function runningAs(cliVersion) {
35
35
  return cliVersion ? `[gjsify ${cliVersion}] ` : '';
36
36
  }
37
- /** Pinned invocations for the three runners that cache an unpinned bin. */
38
- const PIN_HINT = 'A package runner reuses a CACHED copy of an unpinned bin. Force the current one:\n' +
37
+ /**
38
+ * Pinned invocations for the three runners that cache an unpinned bin.
39
+ *
40
+ * The deno line carries TWO flags because deno stacks two independent refusals,
41
+ * and dropping either one leaves the hint broken in exactly the window where it
42
+ * is reached most: `--reload` busts the `DENO_DIR` copy, and `--min-dep-age 0`
43
+ * waives deno's supply-chain policy of refusing any version published within
44
+ * the last 24 h (`minimumDependencyAge`, default `24h`). Without the second
45
+ * flag `@latest` silently resolves to the newest release OLDER THAN A DAY — so
46
+ * a user who hits a bug, is told to run `@latest`, and does, gets the SAME
47
+ * pre-fix binary back and a stale error message with it. Measured twice on one
48
+ * day against 0.25.1 (minutes old) falling back to 0.24.1. `@latest` is kept
49
+ * rather than a baked version so the hint cannot go stale itself; the age
50
+ * waiver is what makes `@latest` mean latest.
51
+ */
52
+ export const PIN_HINT = 'A package runner reuses a CACHED copy of an unpinned bin. Force the current one:\n' +
39
53
  ' npx @gjsify/cli@latest showcase <name>\n' +
40
54
  ' bunx @gjsify/cli@latest showcase <name>\n' +
41
- ' deno run -A --reload npm:@gjsify/cli@latest showcase <name>';
55
+ ' deno run -A --reload --min-dep-age 0 npm:@gjsify/cli@latest showcase <name>';
42
56
  export const showcaseCommand = {
43
57
  command: 'showcase [name]',
44
58
  description: 'List or run curated gjsify showcase applications.',
@@ -119,33 +133,39 @@ export const showcaseCommand = {
119
133
  // `m_should_exit` core dump. Returning halts the handler here.
120
134
  return process.exit(1);
121
135
  }
136
+ // Resolve the runtime BEFORE the system-dependency gate: that gate is a
137
+ // question ABOUT the runtime, and asking it first gated every showcase
138
+ // on a gjs binary (see `requiresGjsSystemDeps`).
139
+ const runtime = (args.runtime ?? defaultExampleRuntime());
140
+ if (!isExampleRuntime(runtime)) {
141
+ console.error(`Unknown --runtime "${runtime}" (expected: ${EXAMPLE_RUNTIMES.join(', ')}).`);
142
+ // `return` — bare `process.exit` falls through under GJS (see above).
143
+ return process.exit(1);
144
+ }
122
145
  // System dependency check before delegating — only system libs (gjs,
123
- // gtk4, …). The showcase's npm deps (incl. `@gjsify/webgl` with the
146
+ // gtk4, …), and only for a runtime that consumes the `--app gjs`
147
+ // bundle. The showcase's npm deps (incl. `@gjsify/webgl` with the
124
148
  // gwebgl Vala prebuild) are fetched by `gjsify dlx` into the npm
125
149
  // cache, and `runGjsBundle()` picks the prebuild up from the bundle
126
150
  // dir via `detectNativePackages()`. Pre-flight-checking npm deps
127
151
  // here would fail for `npx @gjsify/cli showcase` (no project
128
152
  // node_modules, CLI doesn't dep on the showcase libs).
129
- const results = runMinimalChecks();
130
- const missingHard = results.filter((r) => !r.found && r.severity === 'required');
131
- if (missingHard.length > 0) {
132
- console.error('Missing system dependencies:\n');
133
- for (const dep of missingHard) {
134
- console.error(` ✗ ${dep.name}`);
135
- }
136
- const pm = detectPackageManager();
137
- const cmd = buildInstallCommand(pm, missingHard);
138
- if (cmd) {
139
- console.error(`\nInstall with:\n ${cmd}`);
153
+ if (requiresGjsSystemDeps(runtime)) {
154
+ const results = runMinimalChecks();
155
+ const missingHard = results.filter((r) => !r.found && r.severity === 'required');
156
+ if (missingHard.length > 0) {
157
+ console.error('Missing system dependencies:\n');
158
+ for (const dep of missingHard) {
159
+ console.error(` ✗ ${dep.name}`);
160
+ }
161
+ const pm = detectPackageManager();
162
+ const cmd = buildInstallCommand(pm, missingHard);
163
+ if (cmd) {
164
+ console.error(`\nInstall with:\n ${cmd}`);
165
+ }
166
+ // `return` — bare `process.exit` falls through under GJS (see above).
167
+ return process.exit(1);
140
168
  }
141
- // `return` — bare `process.exit` falls through under GJS (see above).
142
- return process.exit(1);
143
- }
144
- const runtime = (args.runtime ?? defaultExampleRuntime());
145
- if (!isExampleRuntime(runtime)) {
146
- console.error(`Unknown --runtime "${runtime}" (expected: ${EXAMPLE_RUNTIMES.join(', ')}).`);
147
- // `return` — bare `process.exit` falls through under GJS (see above).
148
- return process.exit(1);
149
169
  }
150
170
  const cliVersion = readCliVersion();
151
171
  // Non-gjs runtime: resolve the showcase's `--app node` bundle and run it
@@ -136,8 +136,22 @@ export const storybookCommand = {
136
136
  };
137
137
  const initialCount = writeEntry();
138
138
  if (initialCount === 0) {
139
- console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}`);
140
- process.exit(1);
139
+ // `return process.exit()`: a bare `process.exit()` does NOT halt
140
+ // synchronously under GJS (no atexit, the GLib loop is still
141
+ // armed), so execution fell through to the "Found 0 story file(s)"
142
+ // log and on into the build — and the deferred exit never carried
143
+ // its code, so `gjsify storybook` in a directory with no stories
144
+ // printed BOTH messages and exited 0. Measured: exit 1 under bun,
145
+ // exit 0 under gjs, same command. Same trap as `showcase.ts`.
146
+ console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}\n\n` +
147
+ ` \`gjsify storybook\` renders the stories of the CURRENT project: it discovers\n` +
148
+ ` \`*.story.ts\` files and launches a component browser over them. Point it at a\n` +
149
+ ` different directory with --stories <dir> (or \`gjsify.storybook.stories\` in\n` +
150
+ ` package.json) if yours live elsewhere.\n\n` +
151
+ ` To SEE a storybook without authoring one first, run the curated Libadwaita\n` +
152
+ ` component browser — it is a showcase, so it needs no project at all:\n\n` +
153
+ ` gjsify showcase adwaita-storybook`);
154
+ return process.exit(1);
141
155
  }
142
156
  console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'} (runtime: ${runtime})`);
143
157
  // In-process build — runtime-agnostic (Node uses npm rolldown, GJS uses
@@ -18,8 +18,7 @@ export const systemCheckCommand = {
18
18
  if (args.json) {
19
19
  console.log(JSON.stringify({ packageManager: pm, deps: results }, null, 2));
20
20
  // Only required deps influence the exit code.
21
- process.exit(missingRequired.length > 0 ? 1 : 0);
22
- return;
21
+ return process.exit(missingRequired.length > 0 ? 1 : 0);
23
22
  }
24
23
  console.log('System dependency check\n');
25
24
  const required = results.filter((r) => r.severity === 'required');
@@ -60,7 +60,9 @@ export const testCommand = {
60
60
  console.error(`[gjsify test] no test entry at ${relative(cwd, entry)} — ` +
61
61
  `add an \`src/test.mts\` that aggregates your \`@gjsify/unit\` suites, ` +
62
62
  `or set \`gjsify.test.entry\` in package.json.`);
63
- process.exit(1);
63
+ // `return` — a bare `process.exit()` is deferred under GJS and the
64
+ // handler would try to build the missing entry anyway.
65
+ return process.exit(1);
64
66
  }
65
67
  const requested = args.runtime === 'gjs'
66
68
  ? ['gjs']
@@ -124,7 +126,9 @@ export const testCommand = {
124
126
  console.log(`[gjsify test] ${summary}`);
125
127
  const anyFailed = results.some((r) => !r.ok);
126
128
  if (anyFailed) {
127
- process.exit(1);
129
+ // `return` — the deferred GJS exit otherwise fell through to the
130
+ // success `process.exit(0)` below, clobbering the failure code.
131
+ return process.exit(1);
128
132
  }
129
133
  // Explicit success exit: under GJS the spawn-armed main loop would
130
134
  // otherwise park this process after the summary. (runGjsBundle
@@ -79,7 +79,9 @@ export const trustCommand = {
79
79
  const npmrc = await loadNpmrc(cwd);
80
80
  if (!hasAnyCredential(npmrc)) {
81
81
  console.error('gjsify trust: no npm token configured. Run `gjsify login` first.');
82
- process.exit(1);
82
+ // `return` — a bare `process.exit()` is deferred under GJS and the
83
+ // handler would keep going without credentials.
84
+ return process.exit(1);
83
85
  }
84
86
  // Pre-flight: a present-but-DEAD token would otherwise surface as a
85
87
  // confusing per-package `401 "You must be logged in to publish
@@ -97,13 +99,13 @@ export const trustCommand = {
97
99
  console.error(`gjsify trust: npm auth check failed (${msg}).\n` +
98
100
  'Your ~/.npmrc token is dead or revoked — refresh it with `npm login`, then re-run.\n' +
99
101
  '(`gjsify trust` uses the ~/.npmrc bearer token directly — unlike `npm trust`, which logs in via the browser.)');
100
- process.exit(1);
101
- return;
102
+ return process.exit(1);
102
103
  }
103
104
  if (!who.username) {
104
105
  console.error('gjsify trust: your npm token appears dead or revoked (registry returned an empty whoami).\n' +
105
106
  'Refresh it with `npm login`, then re-run.');
106
- process.exit(1);
107
+ // `return` — see the deferred-exit note on the guard above.
108
+ return process.exit(1);
107
109
  }
108
110
  console.log(`gjsify trust: authenticated as ${who.username}`);
109
111
  }
@@ -117,11 +119,13 @@ export const trustCommand = {
117
119
  if (!repository) {
118
120
  console.error('gjsify trust: could not determine the GitHub repository.\n' +
119
121
  'Pass --repository owner/repo (no `origin` remote to infer from).');
120
- process.exit(1);
122
+ // `return` — see the deferred-exit note on the guard above.
123
+ return process.exit(1);
121
124
  }
122
125
  if (!validateRepository(repository)) {
123
126
  console.error(`gjsify trust: --repository must be "owner/repo", got "${repository}".`);
124
- process.exit(1);
127
+ // `return` — see the deferred-exit note on the guard above.
128
+ return process.exit(1);
125
129
  }
126
130
  const workflow = normalizeWorkflowFile(args.workflow);
127
131
  const environment = args.environment;
@@ -148,7 +152,8 @@ export const trustCommand = {
148
152
  }).filter((ws) => ws.name);
149
153
  if (selected.length === 0) {
150
154
  console.error('gjsify trust: no publishable packages matched.');
151
- process.exit(1);
155
+ // `return` — see the deferred-exit note on the guard above.
156
+ return process.exit(1);
152
157
  }
153
158
  console.log(`gjsify trust: ${selected.length} package(s) | repo=${repository} workflow=${workflow}`);
154
159
  if (dryRun)
@@ -104,7 +104,9 @@ export const tscCommand = {
104
104
  if (!bundlePath && !nodeTscPath) {
105
105
  console.error('gjsify tsc: neither @gjsify/tsc (GJS bundle) nor npm `typescript` (Node) is installed.');
106
106
  console.error(' Install with: gjsify install --save-dev @gjsify/tsc (or add `typescript`).');
107
- process.exit(1);
107
+ // `return` — a bare `process.exit()` is deferred under GJS and the
108
+ // handler would try to spawn a compiler that is not there.
109
+ return process.exit(1);
108
110
  }
109
111
  // Mirror `gjsify run`'s native-env composition so any future
110
112
  // typescript-the-bundle code path that reaches a native bridge
@@ -46,8 +46,7 @@ export const uninstallCommand = {
46
46
  if (!args.global) {
47
47
  console.error('gjsify uninstall currently only supports --global. ' +
48
48
  'For project-local removal, edit package.json + re-run `gjsify install`.');
49
- process.exit(1);
50
- return;
49
+ return process.exit(1);
51
50
  }
52
51
  const layout = defaultGlobalLayout();
53
52
  const dryRun = args['dry-run'] ?? false;
@@ -69,7 +69,9 @@ export const whoamiCommand = {
69
69
  'Then verify: gjsify whoami',
70
70
  ].join('\n') + '\n');
71
71
  }
72
- process.exit(1);
72
+ // `return` — a bare `process.exit()` is deferred under GJS and the
73
+ // handler would probe the registry without a token.
74
+ return process.exit(1);
73
75
  }
74
76
  let result;
75
77
  try {
@@ -85,8 +87,9 @@ export const whoamiCommand = {
85
87
  process.stderr.write(`gjsify whoami: ${message}\n`);
86
88
  process.stderr.write(`Registry: ${registryClean}\n`);
87
89
  }
88
- process.exit(1);
89
- return; // unreachable but pleases the type narrower
90
+ // `return process.exit` also keeps the type narrower happy: the
91
+ // never-typed call marks this branch as terminated.
92
+ return process.exit(1);
90
93
  }
91
94
  if (result.username && result.username.length > 0) {
92
95
  // Branch 1 — live token.
@@ -49,10 +49,11 @@
49
49
  // or a permission error degrade to "run the script uncached" — a cache
50
50
  // hiccup must never break a build.
51
51
  import { createHash } from 'node:crypto';
52
- import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, symlinkSync, copyFileSync, writeFileSync, } from 'node:fs';
52
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, copyFileSync, writeFileSync, } from 'node:fs';
53
53
  import { dirname, join } from 'node:path';
54
54
  import { fileURLToPath } from 'node:url';
55
55
  import { buildDependencyGraph } from '@gjsify/workspace';
56
+ import { replicateLinkSync } from './dir-link.js';
56
57
  /**
57
58
  * Bump to invalidate every existing cache entry (schema/semantic changes).
58
59
  *
@@ -417,7 +418,11 @@ function copyDirRecursive(from, to) {
417
418
  const dst = join(to, entry);
418
419
  const st = lstatSync(src);
419
420
  if (st.isSymbolicLink()) {
420
- symlinkSync(readlinkSync(src), dst);
421
+ // Not `symlinkSync(readlinkSync(src), dst)`: replicating a link to a
422
+ // DIRECTORY verbatim needs a junction on Windows, and the target has
423
+ // to be resolved for it. See `dir-link.ts` — the one definition of
424
+ // that rule, added after the same mistake shipped in `dlx-cache.ts`.
425
+ replicateLinkSync(src, dst);
421
426
  }
422
427
  else if (st.isDirectory()) {
423
428
  copyDirRecursive(src, dst);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The link type a directory link must use on this platform.
3
+ *
4
+ * `undefined` on POSIX is deliberate: Node's default is correct there, and
5
+ * passing a type would be noise.
6
+ */
7
+ export declare const DIR_LINK_TYPE: 'junction' | undefined;
8
+ /** True when directory links on this platform are NTFS junctions. */
9
+ export declare const dirLinksAreJunctions: () => boolean;
10
+ /**
11
+ * Spell the `target` argument for a link at `linkPath` pointing at `absTarget`.
12
+ *
13
+ * Absolute on Windows because a junction demands it (gotcha 1 above); relative
14
+ * on POSIX so the tree stays movable (gotcha 2).
15
+ *
16
+ * `linkType` is a PARAMETER, defaulting to the host's, for the same reason
17
+ * `detect-native-packages.ts` takes `platform`/`arch`: the Windows branch is
18
+ * then unit-testable from a Linux host, which is the only place anyone runs the
19
+ * tests. A branch nobody can exercise is how the `'dir'` bug survived to a
20
+ * release.
21
+ *
22
+ * @param linkPath where the link itself will live
23
+ * @param absTarget the directory it should point at, as an ABSOLUTE path
24
+ * @param linkType override the host's link type (tests only)
25
+ */
26
+ export declare function dirLinkTarget(linkPath: string, absTarget: string, linkType?: 'junction' | undefined): string;
27
+ /**
28
+ * Link `linkPath` → `absTarget`, choosing symlink-vs-junction and the target
29
+ * spelling for the host.
30
+ *
31
+ * `absTarget` must be absolute; passing a relative path is the mistake gotcha 1
32
+ * describes, and it would only misbehave on Windows, so it is rejected here on
33
+ * every platform rather than left to surface on the one host nobody tests on.
34
+ */
35
+ export declare function linkDirSync(linkPath: string, absTarget: string): void;
36
+ /** Promise-returning twin of {@link linkDirSync}. */
37
+ export declare function linkDir(linkPath: string, absTarget: string): Promise<void>;
38
+ /**
39
+ * Recreate an EXISTING link at a new path, preserving what it points at.
40
+ *
41
+ * A different job from {@link linkDirSync}, and it cannot reuse it: the source
42
+ * link's target is whatever it already is — frequently relative, deliberately so
43
+ * — while a junction demands an absolute one. Copying the target verbatim is
44
+ * therefore right on POSIX and wrong on Windows the moment the target is a
45
+ * directory.
46
+ *
47
+ * So the target's KIND decides: a link to a directory becomes a junction with
48
+ * the target resolved against the source link's own directory (the resolution
49
+ * Node will not do for you — see gotcha 1 above); anything else stays a plain
50
+ * file symlink, which Windows allows unprivileged.
51
+ *
52
+ * A dangling source link keeps the verbatim target: it points nowhere either
53
+ * way, and guessing a kind for it would turn a faithful copy into a fabrication.
54
+ *
55
+ * Primitive fs ops only — this runs under the GJS facade as well as Node, which
56
+ * is why there is no `fs.cp`/`opendir` here.
57
+ */
58
+ export declare function replicateLinkSync(srcLink: string, dstLink: string): void;
@@ -0,0 +1,126 @@
1
+ // Creating a link to a DIRECTORY, portably.
2
+ //
3
+ // WHY THIS FILE EXISTS. Windows cannot create a directory *symlink* without
4
+ // elevation or Developer Mode — the call fails with `EPERM` — but any user can
5
+ // create an NTFS **junction**, which is why npm, yarn and pnpm all use junctions
6
+ // for directory links. `commands/install.ts` learned that and encoded it, twice,
7
+ // as a local constant for its workspace links. Nothing shared it, so the next
8
+ // site to link a directory got it wrong: `dlx-cache.ts` linked with `'dir'` and
9
+ // every non-elevated Windows user hit
10
+ //
11
+ // Could not resolve showcase "canvas2d-fireworks": EPERM: operation not
12
+ // permitted, symlink '…\<prepare-dir>' -> '…\pkg.tmp-…'
13
+ //
14
+ // on a plain `npx @gjsify/cli@latest showcase …`. The knowledge existed in the
15
+ // repo and the bug shipped anyway, which is the argument for a single callable
16
+ // definition rather than a convention.
17
+ //
18
+ // THE TWO GOTCHAS, both load-bearing:
19
+ //
20
+ // 1. **A junction target must be ABSOLUTE.** Node normalises a `'junction'`
21
+ // target with `path.resolve()` — against the process CWD, NOT against the
22
+ // link's own directory. A relative target that looks right produces a
23
+ // junction pointing somewhere else entirely, and only on Windows.
24
+ // 2. **POSIX targets should stay RELATIVE** where the caller wants a movable
25
+ // tree: a relative symlink survives the whole tree being moved or mounted
26
+ // elsewhere. So the target spelling is per-platform, not one string.
27
+ //
28
+ // Autodetection is NOT a substitute. Node picks `'file'` or `'dir'` when the
29
+ // type argument is omitted — never `'junction'` — so an omitted type is the
30
+ // failing case, not the safe default.
31
+ import { readlinkSync, statSync, symlinkSync } from 'node:fs';
32
+ import { symlink } from 'node:fs/promises';
33
+ import { dirname, relative, resolve } from 'node:path';
34
+ /**
35
+ * The link type a directory link must use on this platform.
36
+ *
37
+ * `undefined` on POSIX is deliberate: Node's default is correct there, and
38
+ * passing a type would be noise.
39
+ */
40
+ export const DIR_LINK_TYPE = process.platform === 'win32' ? 'junction' : undefined;
41
+ /** True when directory links on this platform are NTFS junctions. */
42
+ export const dirLinksAreJunctions = () => DIR_LINK_TYPE === 'junction';
43
+ /**
44
+ * Spell the `target` argument for a link at `linkPath` pointing at `absTarget`.
45
+ *
46
+ * Absolute on Windows because a junction demands it (gotcha 1 above); relative
47
+ * on POSIX so the tree stays movable (gotcha 2).
48
+ *
49
+ * `linkType` is a PARAMETER, defaulting to the host's, for the same reason
50
+ * `detect-native-packages.ts` takes `platform`/`arch`: the Windows branch is
51
+ * then unit-testable from a Linux host, which is the only place anyone runs the
52
+ * tests. A branch nobody can exercise is how the `'dir'` bug survived to a
53
+ * release.
54
+ *
55
+ * @param linkPath where the link itself will live
56
+ * @param absTarget the directory it should point at, as an ABSOLUTE path
57
+ * @param linkType override the host's link type (tests only)
58
+ */
59
+ export function dirLinkTarget(linkPath, absTarget, linkType = DIR_LINK_TYPE) {
60
+ return linkType === 'junction' ? resolve(absTarget) : relative(dirname(linkPath), absTarget);
61
+ }
62
+ /**
63
+ * Link `linkPath` → `absTarget`, choosing symlink-vs-junction and the target
64
+ * spelling for the host.
65
+ *
66
+ * `absTarget` must be absolute; passing a relative path is the mistake gotcha 1
67
+ * describes, and it would only misbehave on Windows, so it is rejected here on
68
+ * every platform rather than left to surface on the one host nobody tests on.
69
+ */
70
+ export function linkDirSync(linkPath, absTarget) {
71
+ assertAbsolute(absTarget);
72
+ symlinkSync(dirLinkTarget(linkPath, absTarget), linkPath, DIR_LINK_TYPE);
73
+ }
74
+ /** Promise-returning twin of {@link linkDirSync}. */
75
+ export async function linkDir(linkPath, absTarget) {
76
+ assertAbsolute(absTarget);
77
+ await symlink(dirLinkTarget(linkPath, absTarget), linkPath, DIR_LINK_TYPE);
78
+ }
79
+ /**
80
+ * Recreate an EXISTING link at a new path, preserving what it points at.
81
+ *
82
+ * A different job from {@link linkDirSync}, and it cannot reuse it: the source
83
+ * link's target is whatever it already is — frequently relative, deliberately so
84
+ * — while a junction demands an absolute one. Copying the target verbatim is
85
+ * therefore right on POSIX and wrong on Windows the moment the target is a
86
+ * directory.
87
+ *
88
+ * So the target's KIND decides: a link to a directory becomes a junction with
89
+ * the target resolved against the source link's own directory (the resolution
90
+ * Node will not do for you — see gotcha 1 above); anything else stays a plain
91
+ * file symlink, which Windows allows unprivileged.
92
+ *
93
+ * A dangling source link keeps the verbatim target: it points nowhere either
94
+ * way, and guessing a kind for it would turn a faithful copy into a fabrication.
95
+ *
96
+ * Primitive fs ops only — this runs under the GJS facade as well as Node, which
97
+ * is why there is no `fs.cp`/`opendir` here.
98
+ */
99
+ export function replicateLinkSync(srcLink, dstLink) {
100
+ const rawTarget = readlinkSync(srcLink);
101
+ if (!dirLinksAreJunctions()) {
102
+ symlinkSync(rawTarget, dstLink);
103
+ return;
104
+ }
105
+ const resolvedTarget = resolve(dirname(srcLink), rawTarget);
106
+ let targetIsDir;
107
+ try {
108
+ targetIsDir = statSync(resolvedTarget).isDirectory();
109
+ }
110
+ catch {
111
+ // Dangling — copy it as-is rather than inventing a kind for it.
112
+ symlinkSync(rawTarget, dstLink);
113
+ return;
114
+ }
115
+ if (targetIsDir)
116
+ symlinkSync(resolvedTarget, dstLink, 'junction');
117
+ else
118
+ symlinkSync(rawTarget, dstLink, 'file');
119
+ }
120
+ function assertAbsolute(absTarget) {
121
+ if (resolve(absTarget) !== absTarget) {
122
+ throw new TypeError(`linkDir: target must be an absolute path, got ${JSON.stringify(absTarget)}. ` +
123
+ 'Node resolves a Windows junction target against the process CWD rather than the ' +
124
+ "link's own directory, so a relative target silently points somewhere else there.");
125
+ }
126
+ }