@gjsify/cli 0.26.0 → 0.27.0

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 (57) hide show
  1. package/dist/cli.gjs.mjs +184 -182
  2. package/lib/cli-app.js +3 -1
  3. package/lib/commands/affected.js +146 -15
  4. package/lib/commands/build.js +21 -14
  5. package/lib/commands/check.js +40 -4
  6. package/lib/commands/clear.d.ts +8 -0
  7. package/lib/commands/clear.js +90 -0
  8. package/lib/commands/copy.d.ts +8 -0
  9. package/lib/commands/copy.js +87 -0
  10. package/lib/commands/dlx.js +8 -1
  11. package/lib/commands/index.d.ts +2 -0
  12. package/lib/commands/index.js +2 -0
  13. package/lib/commands/install.d.ts +4 -0
  14. package/lib/commands/install.js +164 -15
  15. package/lib/commands/onboard.js +17 -14
  16. package/lib/commands/publish.js +18 -4
  17. package/lib/commands/storybook.js +5 -1
  18. package/lib/commands/test.js +29 -2
  19. package/lib/commands/uninstall.js +44 -2
  20. package/lib/commands/upgrade.js +1 -4
  21. package/lib/templates/install.mjs.tmpl +110 -19
  22. package/lib/utils/bin-shim.d.ts +73 -0
  23. package/lib/utils/bin-shim.js +97 -2
  24. package/lib/utils/build-cache.d.ts +7 -27
  25. package/lib/utils/build-cache.js +34 -4
  26. package/lib/utils/check-system-deps.d.ts +15 -1
  27. package/lib/utils/check-system-deps.js +126 -2
  28. package/lib/utils/clear-targets.d.ts +30 -0
  29. package/lib/utils/clear-targets.js +114 -0
  30. package/lib/utils/copy-targets.d.ts +41 -0
  31. package/lib/utils/copy-targets.js +145 -0
  32. package/lib/utils/detect-native-packages.d.ts +160 -5
  33. package/lib/utils/detect-native-packages.js +326 -50
  34. package/lib/utils/dir-link.d.ts +86 -0
  35. package/lib/utils/dir-link.js +140 -0
  36. package/lib/utils/dlx-cache.d.ts +30 -3
  37. package/lib/utils/dlx-cache.js +61 -7
  38. package/lib/utils/install-backend-native.d.ts +109 -1
  39. package/lib/utils/install-backend-native.js +622 -59
  40. package/lib/utils/install-backend.js +17 -15
  41. package/lib/utils/install-global.js +6 -34
  42. package/lib/utils/install-lock.js +1 -1
  43. package/lib/utils/install-packument-cache.d.ts +51 -9
  44. package/lib/utils/install-packument-cache.js +88 -25
  45. package/lib/utils/oxc-resolve.d.ts +27 -1
  46. package/lib/utils/oxc-resolve.js +53 -26
  47. package/lib/utils/pkg-json.d.ts +24 -0
  48. package/lib/utils/pkg-json.js +27 -1
  49. package/lib/utils/platform-check.d.ts +215 -0
  50. package/lib/utils/platform-check.js +377 -0
  51. package/lib/utils/resolve-npm-package.js +9 -2
  52. package/lib/utils/run-gjs.d.ts +1 -1
  53. package/lib/utils/run-gjs.js +19 -3
  54. package/lib/utils/spawn.js +50 -2
  55. package/lib/utils/win32-command.d.ts +54 -0
  56. package/lib/utils/win32-command.js +140 -0
  57. package/package.json +19 -20
package/lib/cli-app.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import yargs from 'yargs';
22
- import { buildCommand as build, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
22
+ import { buildCommand as build, clearCommand as clear, copyCommand as copy, testCommand as test, runCommand as run, infoCommand as info, systemCheckCommand as systemCheck, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, gsettingsCommand as gsettings, flatpakCommand as flatpak, dlxCommand as dlx, installCommand as install, foreachCommand as foreach, workspaceCommand as workspace, packCommand as pack, publishCommand as publish, whoamiCommand as whoami, loginCommand as login, logoutCommand as logout, trustCommand as trust, onboardCommand as onboard, selfUpdateCommand as selfUpdate, generateInstallerCommand as generateInstaller, uninstallCommand as uninstall, formatCommand as format, lintCommand as lint, fixCommand as fix, upgradeCommand as upgrade, barrelsCommand as barrels, tscCommand as tsc, affectedCommand as affected, storybookCommand as storybook, debugCommand as debug, browseCommand as browse, } from './commands/index.js';
23
23
  import { APP_NAME } from './constants.js';
24
24
  import { isBun, isDeno, isNode, gjsSystemVersion } from '@gjsify/rolldown-plugin-gjsify/runtime';
25
25
  import { ensureGjsifyShimOnPath } from './utils/gjsify-shim.js';
@@ -89,6 +89,8 @@ export async function runCli(argv) {
89
89
  .command(create.command, create.description, create.builder, create.handler)
90
90
  .command(install.command, install.description, install.builder, install.handler)
91
91
  .command(build.command, build.description, build.builder, build.handler)
92
+ .command(clear.command, clear.description, clear.builder, clear.handler)
93
+ .command(copy.command, copy.description, copy.builder, copy.handler)
92
94
  .command(test.command, test.description, test.builder, test.handler)
93
95
  .command(run.command, run.description, run.builder, run.handler)
94
96
  .command(dlx.command, dlx.description, dlx.builder, dlx.handler)
@@ -7,15 +7,30 @@
7
7
  // up running the touched workspace + its downstream consumers instead of
8
8
  // the whole monorepo.
9
9
  //
10
+ // EVERY VERDICT THIS FILE EMITS IS SCOPED TO ONE WORKFLOW: `main.yml`. That
11
+ // scoping is the single most misread thing here, so it is stated before the
12
+ // tables rather than after. The classifier is booted only by `main.yml`'s
13
+ // `changes` job, and `main.yml` is the only workflow whose jobs consume its
14
+ // outputs. So "ignored" NEVER means "irrelevant to the repository" — it means
15
+ // "cannot change what `main.yml` builds or tests". Sibling workflows
16
+ // (`prebuilds.yml`, `audit-runtimes.yml`, `node-gi.yml`, `napi.yml`,
17
+ // `deploy-docs.yml`, …) gate themselves with their OWN `paths:` filters and are
18
+ // unaffected by anything decided here. `IGNORE` has always worked this way —
19
+ // `packages/node-gi/**` and `packages/napi/**` are load-bearing build inputs to
20
+ // their own workflows and ignored here — but the table read as a global claim,
21
+ // which is how a directory that is genuinely irrelevant to `main.yml` still
22
+ // looks like something that must not be ignored.
23
+ //
10
24
  // Classifier table (first-match wins) handles the cases that aren't a
11
25
  // straightforward "file lives in workspace X":
12
26
  //
13
- // 1. IGNORE — pure-docs / *.md / website / refs/ submodule / unrelated
14
- // workflow files / LICENSE / .gitignore. Discarded FIRST, before every
15
- // other rule: an ignored file is never build-relevant, not even inside
16
- // a GLOBAL_TRIGGERS directory (e.g. `packages/infra/cli/README.md` must
17
- // not force a full run just because `packages/infra/cli/` is a global
18
- // trigger). Ignore wins over global.
27
+ // 1. IGNORE — cannot change what `main.yml` builds or tests: pure-docs /
28
+ // *.md / website / refs/ submodule / other workflows' files and their
29
+ // inputs / LICENSE / .gitignore. Discarded FIRST, before every other
30
+ // rule: an ignored file is never build-relevant to this workflow, not
31
+ // even inside a GLOBAL_TRIGGERS directory (e.g.
32
+ // `packages/infra/cli/README.md` must not force a full run just because
33
+ // `packages/infra/cli/` is a global trigger). Ignore wins over global.
19
34
  //
20
35
  // 2. GLOBAL_TRIGGERS — a non-ignored change touching infra the classifier
21
36
  // itself depends on (`@gjsify/workspace`, `@gjsify/cli`, the bundler
@@ -24,12 +39,17 @@
24
39
  // can't trust the closure when the algorithm that computes it just
25
40
  // changed.
26
41
  //
27
- // 3. TEST_ONLY — every changed file under ONE workspace is a spec
42
+ // 3. SCRIPT_COUPLINGS — a build input that reaches its consumer through a
43
+ // BUILD SCRIPT instead of a dependency, so the closure walk has no edge
44
+ // to follow. Contributes EXTRA seeds (and may force a test tier). See
45
+ // the table for why this cannot be inferred and must be declared.
46
+ //
47
+ // 4. TEST_ONLY — every changed file under ONE workspace is a spec
28
48
  // file, e2e fixture, or integration test. Seed = that workspace
29
49
  // but SKIP the closure expansion (downstream consumers don't care
30
50
  // about test code changes).
31
51
  //
32
- // 4. CODE (default) — `workspacesForChangedFiles` maps file → ws,
52
+ // 5. CODE (default) — `workspacesForChangedFiles` maps file → ws,
33
53
  // then `affectedClosure` walks reverse-dep edges.
34
54
  //
35
55
  // Integration tests are gated separately: they run when any workspace
@@ -162,13 +182,55 @@ const GLOBAL_TRIGGERS = [
162
182
  /^\.github\/workflows\/main\.yml$/,
163
183
  /^scripts\/audit-runtimes\.mjs$/,
164
184
  ];
185
+ /**
186
+ * Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
187
+ *
188
+ * This is the sub-table where the workflow scoping stated at the top of the
189
+ * file is load-bearing, so each entry NAMES the workflow that does gate it.
190
+ * The test for membership is not "is this file unimportant" (none of these
191
+ * are) but "can `main.yml` build or test differently because of it" — and for
192
+ * every entry the answer is no, while the owning workflow has the path in its
193
+ * own `paths:` filter and would go red on a break.
194
+ *
195
+ * Adding a regex here is therefore a claim with two halves, and BOTH must
196
+ * hold: `main.yml` must not read the path, and some other workflow must.
197
+ */
198
+ const OTHER_WORKFLOW_INPUTS = [
199
+ // Each sibling workflow's own definition. `main.yml` is deliberately
200
+ // absent — it is a GLOBAL_TRIGGER, since a job-shape change there is
201
+ // invisible until the workflow re-runs.
202
+ /^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image|cancel-pr-runs)\.yml$/,
203
+ // `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and
204
+ // the `changed-packages` classifier that decides which native packages a
205
+ // prebuild run rebuilds. `prebuilds.yml` lists `.github/prebuild-toolchain/**`
206
+ // in BOTH its `push` and `pull_request` `paths:` filters, so a break here
207
+ // reds that workflow on the same PR. `main.yml` never executes any of it —
208
+ // it builds no prebuild and runs no emulated leg.
209
+ /^\.github\/prebuild-toolchain\//,
210
+ // The REPO-SCOPED manifest-conformance rules (#847) — `tier`, `refs-pin`,
211
+ // `platforms-ci` plus the `unchecked-fields` ledger. Their gate is
212
+ // `audit-runtimes.yml`, which runs `node scripts/audit-runtimes.mjs
213
+ // --check --strict` on EVERY pull_request with no `paths:` filter at all,
214
+ // so this content is verified on every PR regardless of what is ignored
215
+ // here; `prebuilds.yml` additionally declares it a shared input (#843) and
216
+ // rebuilds every native package when it changes.
217
+ //
218
+ // The distinction that makes this safe is a real one, not a technicality:
219
+ // `main.yml` DOES run manifest-conformance code, but it reaches it through
220
+ // `scripts/verify-package-outputs.mjs` importing
221
+ // `packages/infra/manifest-conformance/lib/**` — the PACKAGE, a normal
222
+ // workspace under the `packages/infra/*` glob that maps and seeds like any
223
+ // other. Only the `scripts/`-side repo-scoped half is ignored, and that
224
+ // half is precisely the half `main.yml` does not load.
225
+ /^scripts\/manifest-conformance\//,
226
+ ];
165
227
  /** Patterns that contribute no seed and don't force a full run. */
166
228
  const IGNORE = [
167
229
  /\.md$/i,
168
230
  /^refs\//,
169
231
  /^website\//,
170
232
  /^docs\//,
171
- /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image)\.yml$/,
233
+ ...OTHER_WORKFLOW_INPUTS,
172
234
  /^\.githooks\//,
173
235
  // @gjsify/node-gi (the Node-native GObject-Introspection engine, Axis 5) is
174
236
  // NOT a gjsify workspace member — the GJS-first install/foreach tooling can't
@@ -200,6 +262,31 @@ const IGNORE = [
200
262
  ];
201
263
  /** Patterns that suggest a test-only change. */
202
264
  const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
265
+ const SCRIPT_COUPLINGS = [
266
+ {
267
+ // `templates/*` ARE workspaces (root `package.json#workspaces` carries
268
+ // a `templates/*` glob), so a template change already seeds its own
269
+ // `@gjsify/template-<name>`. That is not the consumer that matters.
270
+ //
271
+ // The consumer is `@gjsify/create-app`, and it is unreachable twice
272
+ // over: it lives at `packages/infra/create-gjsify/`, so no
273
+ // directory-name search finds it, and it takes the templates in
274
+ // through `node scripts/process-template.mjs` in its `build` script
275
+ // (which resolves each template's `workspace:^` specifiers and copies
276
+ // the result into `dist-templates/`), so there is no dependency edge
277
+ // either. `dist-templates/` is a build-output cache candidate, so once
278
+ // it is stale it stays stale.
279
+ match: /^templates\//,
280
+ seeds: ['@gjsify/create-app'],
281
+ // …and `tests/e2e/create-app` is the ONLY thing that would notice: it
282
+ // scaffolds a project out of `dist-templates/` and builds it. Without
283
+ // this a templates-only PR got neither the regenerated templates nor
284
+ // the suite that exercises them. PR #853 passed only by accident of
285
+ // also touching root `package.json` and tripping a global trigger.
286
+ runE2E: true,
287
+ why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
288
+ },
289
+ ];
203
290
  function classifyAndExpand(workspaces, changedFiles) {
204
291
  const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
205
292
  if (files.length === 0) {
@@ -250,23 +337,64 @@ function classifyAndExpand(workspaces, changedFiles) {
250
337
  }
251
338
  // Map files → workspaces. Files outside any workspace stay in `unmatched`.
252
339
  const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
340
+ // SCRIPT_COUPLINGS: add the seeds no graph edge can reach. Applied BEFORE
341
+ // the `unmatched` bail-out so the table works for a coupled directory that
342
+ // is NOT itself a workspace — such a file would otherwise force a full run
343
+ // and the declared seeds would never be consulted.
344
+ const knownNames = new Set(workspaces.map((w) => w.name));
345
+ const couplingSeeds = new Set();
346
+ const couplingAccounted = new Set();
347
+ let couplingRunE2E = false;
348
+ for (const c of SCRIPT_COUPLINGS) {
349
+ const hits = remaining.filter((f) => c.match.test(f));
350
+ if (hits.length === 0)
351
+ continue;
352
+ for (const h of hits)
353
+ couplingAccounted.add(h);
354
+ for (const s of c.seeds) {
355
+ // A declared seed that is not a workspace means the table has
356
+ // drifted from the tree (package renamed, moved, removed). Fail
357
+ // towards the full run and SAY SO — the whole point of this table
358
+ // is that a missing rebuild here is otherwise invisible, so it must
359
+ // never degrade quietly back into the bug it exists to prevent.
360
+ if (!knownNames.has(s)) {
361
+ return {
362
+ global: true,
363
+ reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
364
+ `SCRIPT_COUPLINGS in commands/affected.ts is stale`,
365
+ workspaces: workspaces.map((w) => w.name),
366
+ runE2E: true,
367
+ runIntegration: true,
368
+ skipAll: false,
369
+ };
370
+ }
371
+ couplingSeeds.add(s);
372
+ }
373
+ if (c.runE2E)
374
+ couplingRunE2E = true;
375
+ }
253
376
  // Unmatched-but-not-ignored files are suspicious enough to fall back to
254
377
  // the conservative "full run" path. Examples: a new top-level dotfile,
255
378
  // a script in `scripts/` we haven't carved out, a refs/-adjacent file.
256
- if (unmatched.length > 0) {
379
+ // A file a coupling claimed is accounted for and does not count here.
380
+ const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
381
+ if (stillUnmatched.length > 0) {
257
382
  return {
258
383
  global: true,
259
- reason: `unmatched files (${unmatched.length}): ${unmatched.slice(0, 3).join(', ')}${unmatched.length > 3 ? '…' : ''}`,
384
+ reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
260
385
  workspaces: workspaces.map((w) => w.name),
261
386
  runE2E: true,
262
387
  runIntegration: true,
263
388
  skipAll: false,
264
389
  };
265
390
  }
391
+ for (const s of couplingSeeds)
392
+ matched.add(s);
266
393
  // TEST_ONLY shortcut: every remaining file is a spec / e2e / integration
267
394
  // path, all under ONE workspace. Skip the closure expansion — test code
268
- // has no downstream consumers.
269
- const testOnly = remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
395
+ // has no downstream consumers. A coupling deliberately disables it: the
396
+ // extra seeds exist precisely because a downstream consumer DOES care.
397
+ const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
270
398
  if (testOnly && matched.size === 1) {
271
399
  const only = [...matched][0];
272
400
  // E2E or integration test-only changes still need their own job.
@@ -317,10 +445,13 @@ function classifyAndExpand(workspaces, changedFiles) {
317
445
  }
318
446
  }
319
447
  }
320
- const runE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
448
+ // A `tests/e2e/**` touch turns the tier on, and so does any coupling that
449
+ // declares e2e as its only real coverage (see SCRIPT_COUPLINGS).
450
+ const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
321
451
  return {
322
452
  global: false,
323
- reason: `closure (${closure.size} ws from ${matched.size} seed(s))`,
453
+ reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
454
+ (couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
324
455
  workspaces: [...closure].sort(),
325
456
  runE2E,
326
457
  runIntegration,
@@ -1,5 +1,26 @@
1
1
  import { Config } from '../config.js';
2
2
  import { BuildAction } from '../actions/build.js';
3
+ // NO `normalize: true` ON THE GLOB-BEARING OPTIONS — `entry-points` and
4
+ // `exclude` are PATTERNS, not paths.
5
+ //
6
+ // yargs' `normalize` runs `path.normalize()` over every value, which on win32
7
+ // rewrites separators: `src/**/*.{ts,js}` becomes `src\**\*.{ts,js}`. Those
8
+ // patterns go to fast-glob, whose own source says why that must not happen
9
+ // ("we cannot use the standard `path.normalize` method, because on Windows
10
+ // platform it will use of backslashes") — and the failure is not a clean
11
+ // no-match. With no `/` left, fast-glob finds no static base, walks from `.`,
12
+ // follows the workspace's relative `node_modules/@gjsify/*` symlinks with
13
+ // unbounded depth and no cycle detection, and dies of heap exhaustion after
14
+ // tens of minutes without writing one output file. That is issue #914's
15
+ // "`--library` is pathologically slow" report, in full: every `build:gjsify`
16
+ // script in the tree passes its entry points as globs. See
17
+ // `rolldown-plugin-gjsify/src/utils/entry-points.ts` for the second layer.
18
+ //
19
+ // The flag had also been copied onto twelve options that are not paths at all
20
+ // (booleans, `choices` enums, comma-separated identifier lists). Normalizing
21
+ // those is meaningless, and it is what made the one place it is harmful look
22
+ // like house style. `outfile`/`outdir` keep it: they ARE single paths, and
23
+ // nothing globs them.
3
24
  export const buildCommand = {
4
25
  command: 'build [entryPoints..]',
5
26
  description: 'Build and bundle your Gjs project',
@@ -9,7 +30,6 @@ export const buildCommand = {
9
30
  description: 'The entry points you want to bundle. Defaults to bundler.input from package.json#gjsify or .gjsifyrc.js, falling back to src/index.ts when neither is set.',
10
31
  array: true,
11
32
  type: 'string',
12
- normalize: true,
13
33
  // No yargs `default` here on purpose. A yargs default value
14
34
  // is indistinguishable from "user passed the flag" in the
15
35
  // parsed args (cliArgs.entryPoints?.length is truthy either
@@ -29,20 +49,17 @@ export const buildCommand = {
29
49
  description: 'An array of glob patterns to exclude entry-points and aliases',
30
50
  array: true,
31
51
  type: 'string',
32
- normalize: true,
33
52
  default: [],
34
53
  })
35
54
  .option('verbose', {
36
55
  description: 'Switch on the verbose mode',
37
56
  type: 'boolean',
38
- normalize: true,
39
57
  default: false,
40
58
  })
41
59
  .option('app', {
42
60
  description: 'Build target for an application. Defaults to the target of the HOST runtime running the CLI: gjs when run under gjs, node when run under node/bun/deno (both consume the --app node bundle). Set `gjsify.app` in package.json#gjsify to override the host default.',
43
61
  type: 'string',
44
62
  choices: ['gjs', 'node', 'browser', 'nativescript'],
45
- normalize: true,
46
63
  // No yargs `default: 'gjs'` — a yargs default is
47
64
  // indistinguishable from a user-set value and would clobber
48
65
  // `package.json#gjsify.app` in config.ts (same footgun as
@@ -55,18 +72,15 @@ export const buildCommand = {
55
72
  description: 'Override the default output format',
56
73
  type: 'string',
57
74
  choices: ['iife', 'esm', 'cjs'],
58
- normalize: true,
59
75
  })
60
76
  .option('minify', {
61
77
  description: 'Minify the bundled output. Defaults to true; use --no-minify to emit pretty-printed code (e.g. for debugging or readable bundle review).',
62
78
  type: 'boolean',
63
- normalize: true,
64
79
  defaultDescription: 'true',
65
80
  })
66
81
  .option('library', {
67
82
  description: 'Use this if you want to build a library for Gjsify',
68
83
  type: 'boolean',
69
- normalize: true,
70
84
  default: false,
71
85
  })
72
86
  .option('outfile', {
@@ -85,26 +99,22 @@ export const buildCommand = {
85
99
  alias: 'r',
86
100
  description: "Enables TypeScript types on runtime using Deepkit's type compiler",
87
101
  type: 'boolean',
88
- normalize: true,
89
102
  default: false,
90
103
  })
91
104
  .option('log-level', {
92
105
  description: 'The log level can be changed to prevent esbuild from printing warning and/or error messages to the terminal',
93
106
  type: 'string',
94
107
  choices: ['silent', 'error', 'warning', 'info', 'debug', 'verbose'],
95
- normalize: true,
96
108
  default: 'warning',
97
109
  })
98
110
  .option('console-shim', {
99
111
  description: 'Inject a console shim into GJS builds for clean output without the GLib prefix and with working ANSI colors. Use --no-console-shim to disable. Only applies to GJS app builds.',
100
112
  type: 'boolean',
101
- normalize: true,
102
113
  default: true,
103
114
  })
104
115
  .option('globals', {
105
116
  description: "Comma-separated list of global identifiers, 'auto' (default) to detect automatically from the bundled output, or 'none' to disable. The 'auto' token may be combined with explicit identifiers/groups (e.g. 'auto,dom') for cases where the detector cannot statically see a global because it's accessed via indirection. Each identifier is mapped to the corresponding `@gjsify/<pkg>/register` module and injected into the bundle. See the CLI Reference docs for the full list of known identifiers. Only applies to GJS app builds.",
106
117
  type: 'string',
107
- normalize: true,
108
118
  // No yargs `default: 'auto'` — a yargs default is
109
119
  // indistinguishable from a user-set value and would always
110
120
  // clobber `package.json#gjsify.globals` / `.gjsifyrc.*` in
@@ -115,7 +125,6 @@ export const buildCommand = {
115
125
  .option('shebang', {
116
126
  description: 'Prepend a target-appropriate shebang to the output and mark it executable (chmod 755): `#!/usr/bin/env -S gjs -m` for --app gjs, `#!/usr/bin/env node` for --app node. Applies to GJS and Node app builds with a single --outfile. Default: false (use --shebang to enable, or set `shebang: true` in `.gjsifyrc.js`).',
117
127
  type: 'boolean',
118
- normalize: true,
119
128
  })
120
129
  .option('external', {
121
130
  description: 'Module names that should NOT be bundled — EXACT package names only, matched literally on both bundler engines (no globs). Repeat the flag or pass a comma-separated list (e.g. --external typedoc,prettier). Appended to the per-app built-in externals; register subpaths (`<pkg>/register[/…]`) are always force-inlined for --app gjs regardless of this flag.',
@@ -146,13 +155,11 @@ export const buildCommand = {
146
155
  .option('exclude-globals', {
147
156
  description: 'Comma-separated global identifiers to remove from auto-detection results. Use for false positives from dead browser-compat code whose polyfills require unavailable native libraries (e.g. --exclude-globals fetch,XMLHttpRequest).',
148
157
  type: 'string',
149
- normalize: true,
150
158
  })
151
159
  .option('watch', {
152
160
  alias: 'w',
153
161
  description: 'Watch source files and rebuild on change. Logs each rebuild with duration; clean SIGINT shutdown. Only valid with --app gjs|node|browser (rejected with --library). Requires the npm `rolldown` engine — run under Node, not the GJS-bundled CLI.',
154
162
  type: 'boolean',
155
- normalize: true,
156
163
  default: false,
157
164
  });
158
165
  },
@@ -14,7 +14,6 @@
14
14
  // — if both `system-check`-style flags (`--json`) and `check`-style scripts
15
15
  // are reachable here, the `--json` flag routes through this command first
16
16
  // (since the typescript-check binding is positional `[paths..]`).
17
- import { spawnSync } from 'node:child_process';
18
17
  import { prefixLines } from '../utils/prefixed-output.js';
19
18
  import { spawnToCompletion } from '../utils/spawn.js';
20
19
  import { readPackageJson } from '../utils/pkg-json.js';
@@ -22,6 +21,7 @@ import { join } from 'node:path';
22
21
  import { cpus } from 'node:os';
23
22
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
24
23
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
24
+ import { detectPackageManager } from './workspace.js';
25
25
  /**
26
26
  * Run a single workspace's `check` script. Returns the exit code; non-zero
27
27
  * indicates failure. Output is forwarded to the parent's stdout/stderr,
@@ -33,8 +33,25 @@ async function runCheck(ws, prefix) {
33
33
  // GJS, so the STREAMING async path is safe here (and required: the
34
34
  // blocking one would serialize the parallel workers). See utils/spawn.ts.
35
35
  const flushers = [];
36
+ // The RUNNER is detected, never hardcoded — `foreach` and `workspace` have
37
+ // always done this and this site did not, which made `gjsify check` the one
38
+ // orchestrator that still required Node. Under GJS `detectPackageManager()`
39
+ // returns `gjsify` and the script goes through the shim on PATH.
40
+ //
41
+ // It failed in the worst possible shape: `spawnToCompletion` maps the
42
+ // `spawn npm ENOENT` into the rejection the `catch` below turns into a bare
43
+ // `exit 1`, so a Node-less host reported `✗ <pkg> (exit 1)` for a check
44
+ // script that never ran — measured on postmarketOS/aarch64 against a
45
+ // package whose `check` was literally `echo`.
46
+ const runner = detectPackageManager();
47
+ // `--if-present` is an npm/yarn flag with no `gjsify run` equivalent, so for
48
+ // the GJS runner the absence test moves here rather than being dropped.
49
+ if (runner === 'gjsify' && readPackageJson(join(ws.location, 'package.json'))?.scripts?.check === undefined) {
50
+ return 0;
51
+ }
52
+ const argv = runner === 'gjsify' ? ['run', 'check'] : ['run', 'check', '--if-present'];
36
53
  try {
37
- const { code } = await spawnToCompletion('npm', ['run', 'check', '--if-present'], {
54
+ const { code } = await spawnToCompletion(runner, argv, {
38
55
  completion: 'exit',
39
56
  cwd: ws.location,
40
57
  stdio: prefix === null ? 'inherit' : 'pipe',
@@ -111,11 +128,30 @@ export const checkCommand = {
111
128
  if (args.verbose) {
112
129
  console.log(`[check] cwd=${cwd} → npm run check`);
113
130
  }
114
- const r = spawnSync('npm', ['run', 'check'], { cwd, stdio: 'inherit' });
131
+ // Through `spawnToCompletion`, like workspace mode above and
132
+ // like every other spawn in the CLI. This site kept a raw
133
+ // `spawnSync('npm', …)` when the six wrappers were consolidated
134
+ // (see utils/spawn.ts), and that made it the last place where
135
+ // `npm` was handed to `spawn` as a bare name — ENOENT on
136
+ // Windows, where npm is a `.cmd` shim `CreateProcess` never
137
+ // finds. Reading only `r.status` then turned that into
138
+ // `process.exit(1)` with NO diagnostic at all: `gjsify check`
139
+ // inside any package failed silently on win32. Measured there
140
+ // before the change; `--verbose` printed the command line and
141
+ // nothing else.
142
+ // Runner detected, not hardcoded — same reason as workspace mode
143
+ // above. This branch already established that the package HAS a
144
+ // check script, so there is no `--if-present` to translate.
145
+ const { code } = await spawnToCompletion(detectPackageManager(), ['run', 'check'], {
146
+ cwd,
147
+ // The handler exits the process on every path below, so the
148
+ // streaming path is safe under GJS too.
149
+ completion: 'exit',
150
+ });
115
151
  // `return` — a bare `process.exit()` is deferred under GJS (no
116
152
  // atexit), so execution fell through into workspace mode and
117
153
  // re-checked the whole monorepo after the single-package run.
118
- return process.exit(r.status ?? 1);
154
+ return process.exit(code ?? 1);
119
155
  }
120
156
  // Fall through to workspace mode when the local package has no
121
157
  // check script (cd'd into a non-package dir under the root).
@@ -0,0 +1,8 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface ClearOptions {
3
+ paths?: string[];
4
+ dryRun?: boolean;
5
+ verbose?: boolean;
6
+ }
7
+ export declare const clearCommand: Command<unknown, ClearOptions>;
8
+ export {};
@@ -0,0 +1,90 @@
1
+ // `gjsify clear <paths…>` — portable recursive delete for package `clear` scripts.
2
+ //
3
+ // WHY THIS EXISTS
4
+ //
5
+ // Every `clear` script in this workspace was `rm -rf <dirs…> || exit 0`. npm runs
6
+ // package scripts through `cmd.exe` on Windows, which has neither `rm` nor that
7
+ // syntax, so `gjsify foreach clear` — and with it the documented full gate
8
+ // (`gjsify install --immutable && gjsify run clear && gjsify run build …`) —
9
+ // could not run there at all. 224 package.json files carried that line, so the
10
+ // fix is one portable implementation, not 224 hand-written platform branches
11
+ // (issue #914, finding 3).
12
+ //
13
+ // `force: true` already ignores a missing path, which is all the `|| exit 0` tail
14
+ // ever did — so the rewritten scripts drop it and stop swallowing REAL failures.
15
+ // A permission error used to exit 0 and read as a successful clean.
16
+ //
17
+ // THE BOOTSTRAP QUESTION, ANSWERED DELIBERATELY
18
+ //
19
+ // A `clear` script that calls `gjsify` makes cleaning depend on a built CLI —
20
+ // the same circularity `gjsify run chmod` had in the CLI's own build (removed in
21
+ // ce47b6a). It is acceptable HERE, and for a stronger reason than "you cannot
22
+ // clear a tree you never built": for the single-command case there is no second
23
+ // process at all. `gjsify run clear` recognises a script that is one
24
+ // `gjsify <subcommand>` and dispatches it IN-PROCESS (see `cli-app.ts`), so the
25
+ // nested-gjs cost the chmod case paid does not arise. `gjsify foreach clear`
26
+ // still spawns one child per package, exactly as it did for `rm -rf`, and the
27
+ // root script already passes `-p`.
28
+ //
29
+ // What it does mean: `clear` is a NEW command, so it does not exist in the
30
+ // committed `dist/cli.gjs.mjs` until that bundle is rebuilt. On a checkout
31
+ // carrying a stale bundle, `gjsify run clear` fails with "Unknown command" rather
32
+ // than silently clearing nothing — which is the honest failure of the two.
33
+ import { rm, stat } from 'node:fs/promises';
34
+ import { readdirSync } from 'node:fs';
35
+ import { relative } from 'node:path';
36
+ import { resolveClearTargets } from '../utils/clear-targets.js';
37
+ export const clearCommand = {
38
+ command: 'clear [paths..]',
39
+ description: 'Delete build output recursively. Portable replacement for `rm -rf` in package `clear` scripts.',
40
+ // No `normalize: true` anywhere below — see the comment block in
41
+ // `commands/build.ts`. It is meaningless for a path `fs.rm` resolves itself,
42
+ // and on win32 it is what would backslash a wildcard segment.
43
+ builder: (yargs) => {
44
+ return yargs
45
+ .positional('paths', {
46
+ description: 'Files and directories to delete, relative to the current package. A missing path is not an error. `*` and `?` are honoured in the last segment.',
47
+ type: 'string',
48
+ array: true,
49
+ })
50
+ .option('dry-run', {
51
+ description: 'Print what would be deleted and exit without touching anything.',
52
+ type: 'boolean',
53
+ default: false,
54
+ })
55
+ .option('verbose', {
56
+ alias: 'v',
57
+ description: 'Print each path as it is deleted.',
58
+ type: 'boolean',
59
+ default: false,
60
+ });
61
+ },
62
+ handler: async (args) => {
63
+ const cwd = process.cwd();
64
+ const paths = args.paths ?? [];
65
+ if (paths.length === 0) {
66
+ throw new Error('gjsify clear: no paths given. Usage: gjsify clear <paths…>');
67
+ }
68
+ // Resolution validates every target and throws on anything outside the
69
+ // package — before a single delete runs.
70
+ const targets = resolveClearTargets(paths, { cwd, readdir: readdirSync });
71
+ for (const target of targets) {
72
+ const shown = relative(cwd, target);
73
+ if (args.dryRun) {
74
+ const present = await stat(target).then(() => true, () => false);
75
+ console.log(`gjsify clear: would remove ${shown}${present ? '' : ' (absent)'}`);
76
+ continue;
77
+ }
78
+ // `maxRetries` is for Windows: a file another process still holds
79
+ // open fails there with EBUSY/EPERM instead of being unlinked while
80
+ // open as on POSIX — and clearing `@gjsify/cli` itself deletes the
81
+ // `lib/`+`dist/` the running process was loaded from. Both runtimes
82
+ // read a module fully and close it, so this covers a transient lock
83
+ // (a scanner, an editor), not a genuinely held handle; if it does
84
+ // fail, the error names the path instead of exiting 0.
85
+ await rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
86
+ if (args.verbose)
87
+ console.log(`gjsify clear: removed ${shown}`);
88
+ }
89
+ },
90
+ };
@@ -0,0 +1,8 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface CopyOptions {
3
+ paths?: string[];
4
+ dryRun?: boolean;
5
+ verbose?: boolean;
6
+ }
7
+ export declare const copyCommand: Command<unknown, CopyOptions>;
8
+ export {};