@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
@@ -64,8 +64,22 @@ function checkNpmPackage(id, name, packageName, cwd, severity, requiredBy) {
64
64
  return { id, name, found: false, severity, requiredBy };
65
65
  }
66
66
  }
67
+ /**
68
+ * The package manager whose install command we may suggest.
69
+ *
70
+ * Probe order is PLATFORM-FIRST, not a single global list. On macOS the Linux
71
+ * managers are absent, so a flat list ending in `brew` would work by accident —
72
+ * but Homebrew also runs on Linux, where it is the LAST resort rather than the
73
+ * first (a Fedora host with linuxbrew installed wants `sudo dnf install`, whose
74
+ * packages carry the distro's own GI typelibs). Keying the order on
75
+ * `process.platform` states that intent instead of leaving it to list position.
76
+ *
77
+ * Returning `unknown` is a supported answer, not a failure: `buildInstallCommand`
78
+ * then prints no command rather than a wrong one. That is the honest result on a
79
+ * MacPorts or Nix host, and it is why this must never fall back to "probably brew".
80
+ */
67
81
  export function detectPackageManager() {
68
- const managers = ['apt', 'dnf', 'pacman', 'zypper', 'apk'];
82
+ const managers = process.platform === 'darwin' ? ['brew'] : ['apt', 'dnf', 'pacman', 'zypper', 'apk', 'brew'];
69
83
  for (const pm of managers) {
70
84
  const result = tryExecFile('which', [pm]);
71
85
  if (result !== null)
@@ -212,7 +226,12 @@ function computeNeededOptionalDeps(cwd) {
212
226
  */
213
227
  export function runAllChecks(cwd) {
214
228
  const needed = computeNeededOptionalDeps(cwd);
215
- return [...runMinimalChecks(), ...runRequiredChecks(cwd), ...runOptionalChecks(needed, cwd)];
229
+ return [
230
+ ...runMinimalChecks(),
231
+ ...runRequiredChecks(cwd),
232
+ ...runNativeBuildToolchainChecks(),
233
+ ...runOptionalChecks(needed, cwd),
234
+ ];
216
235
  }
217
236
  /**
218
237
  * Minimal checks needed to run any GJS example (Node + GJS binaries only).
@@ -249,6 +268,51 @@ function runRequiredChecks(_cwd) {
249
268
  results.push(checkPkgConfig('gobject-introspection', 'GObject Introspection', 'gobject-introspection-1.0', 'required'));
250
269
  return results;
251
270
  }
271
+ /**
272
+ * The native BUILD toolchain — needed to COMPILE a prebuild from source
273
+ * (`gjsify workspace <pkg> build:prebuilds`), never to run one.
274
+ *
275
+ * Optional by design, and the distinction is the whole point: every `*-native`
276
+ * package SHIPS its `.so`/`.dylib` + `.typelib` inside the tarball, so a user who
277
+ * installs `@gjsify/tls-native` and runs it needs none of these. Marking them
278
+ * `required` would fail `gjsify check` for the large majority who correctly have
279
+ * no Vala compiler on the machine.
280
+ *
281
+ * They are reported at all because the opposite silence is worse: without them a
282
+ * `build:prebuilds` failure surfaces from inside meson as a missing-compiler
283
+ * error naming no package and no install command. That is the concrete shape of
284
+ * "gjsify is supposed to be batteries-included, so why is cargo not part of it" —
285
+ * the tools were always required, they were just never named.
286
+ *
287
+ * NB `meson` is checked one function up at `required` severity, which is
288
+ * inconsistent with its own backend being optional here. That predates this
289
+ * function and is left alone deliberately: flipping it changes the exit code of
290
+ * `gjsify check` for existing users, which is a decision of its own, not a
291
+ * side effect of documenting the toolchain.
292
+ */
293
+ function runNativeBuildToolchainChecks() {
294
+ // Every meson/Vala bridge in the tree — all of them need valac + ninja.
295
+ const valaBridges = [
296
+ '@gjsify/http-soup-bridge',
297
+ '@gjsify/http2-native',
298
+ '@gjsify/lightningcss-native',
299
+ '@gjsify/oxfmt-native',
300
+ '@gjsify/rolldown-native',
301
+ '@gjsify/sab-native',
302
+ '@gjsify/terminal-native',
303
+ '@gjsify/tls-native',
304
+ '@gjsify/webgl',
305
+ '@gjsify/webrtc-native',
306
+ ];
307
+ // Narrower on purpose: only the three bridges that link an upstream Rust
308
+ // crate by Cargo path-dependency need a Rust toolchain.
309
+ const rustBridges = ['@gjsify/lightningcss-native', '@gjsify/oxfmt-native', '@gjsify/rolldown-native'];
310
+ return [
311
+ checkBinary('ninja', 'Ninja', 'ninja', ['--version'], 'optional', undefined, valaBridges),
312
+ checkBinary('vala', 'Vala', 'valac', ['--version'], 'optional', (out) => out.replace(/^Vala\s+/i, ''), valaBridges),
313
+ checkBinary('cargo', 'Cargo (Rust)', 'cargo', ['--version'], 'optional', (out) => out.replace(/^cargo\s+/i, '').split(' ')[0] ?? out, rustBridges),
314
+ ];
315
+ }
252
316
  /**
253
317
  * Optional system dependencies — only checked if the corresponding @gjsify/*
254
318
  * package is in use. Missing optional deps generate warnings, not errors.
@@ -286,6 +350,9 @@ const PM_PACKAGES = {
286
350
  'blueprint-compiler': 'blueprint-compiler',
287
351
  'pkg-config': 'pkg-config',
288
352
  meson: 'meson',
353
+ ninja: 'ninja-build',
354
+ vala: 'valac',
355
+ cargo: 'cargo',
289
356
  gtk4: 'libgtk-4-dev',
290
357
  libadwaita: 'libadwaita-1-dev',
291
358
  libsoup3: 'libsoup-3.0-dev',
@@ -306,6 +373,9 @@ const PM_PACKAGES = {
306
373
  'blueprint-compiler': 'blueprint-compiler',
307
374
  'pkg-config': 'pkgconf-pkg-config',
308
375
  meson: 'meson',
376
+ ninja: 'ninja-build',
377
+ vala: 'vala',
378
+ cargo: 'cargo',
309
379
  gtk4: 'gtk4-devel',
310
380
  libadwaita: 'libadwaita-devel',
311
381
  libsoup3: 'libsoup3-devel',
@@ -326,6 +396,9 @@ const PM_PACKAGES = {
326
396
  'blueprint-compiler': 'blueprint-compiler',
327
397
  'pkg-config': 'pkgconf',
328
398
  meson: 'meson',
399
+ ninja: 'ninja',
400
+ vala: 'vala',
401
+ cargo: 'rust',
329
402
  gtk4: 'gtk4',
330
403
  libadwaita: 'libadwaita',
331
404
  libsoup3: 'libsoup3',
@@ -346,6 +419,9 @@ const PM_PACKAGES = {
346
419
  'blueprint-compiler': 'blueprint-compiler',
347
420
  'pkg-config': 'pkg-config',
348
421
  meson: 'meson',
422
+ ninja: 'ninja',
423
+ vala: 'vala',
424
+ cargo: 'cargo',
349
425
  gtk4: 'gtk4-devel',
350
426
  libadwaita: 'libadwaita-devel',
351
427
  libsoup3: 'libsoup-3_0-devel',
@@ -366,6 +442,9 @@ const PM_PACKAGES = {
366
442
  'blueprint-compiler': 'blueprint-compiler',
367
443
  'pkg-config': 'pkgconf',
368
444
  meson: 'meson',
445
+ ninja: 'ninja',
446
+ vala: 'vala',
447
+ cargo: 'cargo',
369
448
  gtk4: 'gtk4.0-dev',
370
449
  libadwaita: 'libadwaita-dev',
371
450
  libsoup3: 'libsoup3-dev',
@@ -381,6 +460,49 @@ const PM_PACKAGES = {
381
460
  gnutls: 'gnutls-dev',
382
461
  nghttp2: 'nghttp2-dev',
383
462
  },
463
+ // macOS. Names verified against homebrew/core rather than inferred from the
464
+ // Linux rows — the mapping is NOT mechanical: Homebrew ships no `-dev`/`-devel`
465
+ // split (one formula carries headers, pkg-config file and typelib), `pkgconf`
466
+ // is what actually provides `bin/pkg-config`, and `libsoup` is the 3.x formula
467
+ // (`libsoup@2` is the old one), so a `libsoup3` guess installs nothing.
468
+ //
469
+ // TWO IDS ARE DELIBERATELY ABSENT, and in both cases the absence IS the
470
+ // answer — printing a command that does not deliver the dependency would be
471
+ // strictly worse than printing nothing:
472
+ // • `manette` — homebrew/core has no libmanette formula, and packaging one
473
+ // would not help: libmanette is built on the Linux input subsystem and
474
+ // reports raw evdev `BTN_*` codes (see @gjsify/gamepad's button-mapping),
475
+ // which macOS does not have. A macOS @gjsify/gamepad means a
476
+ // GameController.framework backend, not a missing brew formula.
477
+ // • `webkitgtk` — a formula by that name EXISTS, which is the trap. It is
478
+ // the GTK**3** build (it depends on `gtk+3` and ships `webkit2gtk-4.x`),
479
+ // while @gjsify/iframe needs the GTK4 API `webkitgtk-6.0`. homebrew/core
480
+ // packages no GTK4 WebKit at all, and the formula carries no bottle — so
481
+ // `brew install webkitgtk` would be an hours-long source build of the
482
+ // WRONG API that still leaves `pkg-config --exists webkitgtk-6.0` false.
483
+ // Recorded here so the next reader does not "fix" the gap by guessing a name;
484
+ // both are genuine platform gaps, not table omissions.
485
+ brew: {
486
+ gjs: 'gjs',
487
+ 'blueprint-compiler': 'blueprint-compiler',
488
+ 'pkg-config': 'pkgconf',
489
+ meson: 'meson',
490
+ ninja: 'ninja',
491
+ vala: 'vala',
492
+ cargo: 'rust',
493
+ gtk4: 'gtk4',
494
+ libadwaita: 'libadwaita',
495
+ libsoup3: 'libsoup',
496
+ 'gobject-introspection': 'gobject-introspection',
497
+ gstreamer: 'gstreamer',
498
+ 'gst-app': 'gst-plugins-base',
499
+ 'gdk-pixbuf': 'gdk-pixbuf',
500
+ pango: 'pango',
501
+ pangocairo: 'pango',
502
+ cairo: 'cairo',
503
+ gnutls: 'gnutls',
504
+ nghttp2: 'nghttp2',
505
+ },
384
506
  unknown: {},
385
507
  };
386
508
  const PM_INSTALL_PREFIX = {
@@ -389,6 +511,8 @@ const PM_INSTALL_PREFIX = {
389
511
  pacman: 'sudo pacman -S',
390
512
  zypper: 'sudo zypper install',
391
513
  apk: 'sudo apk add',
514
+ // No `sudo`: Homebrew refuses to run as root and tells you so.
515
+ brew: 'brew install',
392
516
  unknown: '',
393
517
  };
394
518
  /**
@@ -0,0 +1,30 @@
1
+ /** Everything {@link resolveClearTargets} needs from the outside world. */
2
+ export interface ClearContext {
3
+ /** The package directory every target must stay inside. */
4
+ cwd: string;
5
+ /** Directory listing, for wildcard expansion. Throws when absent — treated as "nothing to clear". */
6
+ readdir: (dir: string) => string[];
7
+ }
8
+ /**
9
+ * Does this path segment carry a shell wildcard?
10
+ *
11
+ * `*` and `?` only — what `rm -rf *.log` used. Deliberately NOT a glob engine:
12
+ * a `clear` script names build output, and `**` or character classes there would
13
+ * let a line delete more than its author can see at a glance.
14
+ */
15
+ export declare function hasWildcard(segment: string): boolean;
16
+ /** Compile one wildcard segment to an anchored regex, escaping every other metachar. */
17
+ export declare function segmentToRegExp(segment: string): RegExp;
18
+ /**
19
+ * Expand and validate every target, resolving wildcards against `readdir`.
20
+ *
21
+ * ALL targets are resolved before the caller deletes any of them — a typo in the
22
+ * fourth path must not be discovered after the first three are gone.
23
+ *
24
+ * A wildcard is honoured in the LAST segment only, matched against a single
25
+ * directory read: the shell semantics the rewritten scripts need, with none of
26
+ * the recursive traversal. A pattern matching nothing yields no targets, which
27
+ * is `rm -rf`'s behaviour under `--force` and why a package that was never built
28
+ * clears cleanly.
29
+ */
30
+ export declare function resolveClearTargets(paths: readonly string[], ctx: ClearContext): string[];
@@ -0,0 +1,114 @@
1
+ // Target resolution for `gjsify clear` — the safety-critical half, kept pure.
2
+ //
3
+ // A recursive delete driven by strings from 224 package.json files is a footgun,
4
+ // so the "which paths does this actually delete" decision lives here, with the
5
+ // directory read INJECTED. That makes every rule below unit-testable without a
6
+ // real filesystem, which is the point: the tests that matter are the ones for
7
+ // paths that must be REFUSED, and creating a fixture outside the cwd to prove
8
+ // that is exactly what a test must not do.
9
+ //
10
+ // See `commands/clear.ts` for why the command exists at all.
11
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
+ /**
13
+ * Does this path segment carry a shell wildcard?
14
+ *
15
+ * `*` and `?` only — what `rm -rf *.log` used. Deliberately NOT a glob engine:
16
+ * a `clear` script names build output, and `**` or character classes there would
17
+ * let a line delete more than its author can see at a glance.
18
+ */
19
+ export function hasWildcard(segment) {
20
+ return segment.includes('*') || segment.includes('?');
21
+ }
22
+ /** Compile one wildcard segment to an anchored regex, escaping every other metachar. */
23
+ export function segmentToRegExp(segment) {
24
+ const body = segment
25
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
26
+ .replace(/\*/g, '[^/\\\\]*')
27
+ .replace(/\?/g, '[^/\\\\]');
28
+ return new RegExp(`^${body}$`);
29
+ }
30
+ /**
31
+ * Reject a target that is not strictly inside `cwd`.
32
+ *
33
+ * A hard error, never a skip: a typo that would otherwise delete a sibling
34
+ * package — or `$HOME` — must not depend on anyone reading a warning in the
35
+ * middle of a 224-package `foreach` run.
36
+ */
37
+ function assertInsideCwd(absolute, original, cwd) {
38
+ const rel = relative(cwd, absolute);
39
+ if (rel === '') {
40
+ throw new Error(`gjsify clear: refusing to delete the working directory itself ("${original}").`);
41
+ }
42
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
43
+ throw new Error(`gjsify clear: "${original}" resolves to ${absolute}, outside the working directory ${cwd}. ` +
44
+ 'Only paths inside the package may be cleared.');
45
+ }
46
+ }
47
+ /**
48
+ * Expand and validate every target, resolving wildcards against `readdir`.
49
+ *
50
+ * ALL targets are resolved before the caller deletes any of them — a typo in the
51
+ * fourth path must not be discovered after the first three are gone.
52
+ *
53
+ * A wildcard is honoured in the LAST segment only, matched against a single
54
+ * directory read: the shell semantics the rewritten scripts need, with none of
55
+ * the recursive traversal. A pattern matching nothing yields no targets, which
56
+ * is `rm -rf`'s behaviour under `--force` and why a package that was never built
57
+ * clears cleanly.
58
+ */
59
+ export function resolveClearTargets(paths, ctx) {
60
+ const out = [];
61
+ const seen = new Set();
62
+ for (const target of paths) {
63
+ for (const absolute of expandTarget(target, ctx)) {
64
+ assertInsideCwd(absolute, target, ctx.cwd);
65
+ if (seen.has(absolute))
66
+ continue;
67
+ seen.add(absolute);
68
+ out.push(absolute);
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ function expandTarget(target, ctx) {
74
+ const leaf = basename(target);
75
+ const parent = dirname(target);
76
+ // Checked BEFORE the no-wildcard shortcut: `*/dist` has a wildcard-free
77
+ // basename, and treating it as a literal path would silently delete nothing
78
+ // instead of telling the author the shape is unsupported.
79
+ if (hasWildcard(parent)) {
80
+ throw new Error(`gjsify clear: a wildcard is only supported in the last path segment — got "${target}".`);
81
+ }
82
+ if (!hasWildcard(leaf))
83
+ return [resolve(ctx.cwd, target)];
84
+ // The parent is validated too: `../*.log` must be refused, and it is only
85
+ // refused if the containing DIRECTORY is checked against cwd before the
86
+ // expansion — the matches themselves are all inside it.
87
+ const dir = resolve(ctx.cwd, parent);
88
+ assertInsideCwdOrSelf(dir, target, ctx.cwd);
89
+ const re = segmentToRegExp(leaf);
90
+ let names;
91
+ try {
92
+ names = ctx.readdir(dir);
93
+ }
94
+ catch {
95
+ // The containing directory is absent or unreadable — nothing to clear,
96
+ // like `rm -rf` on a missing path. A permission problem on an EXISTING
97
+ // target still surfaces from the delete itself.
98
+ return [];
99
+ }
100
+ return names
101
+ .filter((name) => re.test(name))
102
+ .sort()
103
+ .map((name) => join(dir, name));
104
+ }
105
+ /**
106
+ * Like {@link assertInsideCwd} but tolerates `cwd` itself — a wildcard's PARENT
107
+ * legitimately is the package root (`*.log`), while a wildcard's MATCH can never
108
+ * be (it always has a name appended).
109
+ */
110
+ function assertInsideCwdOrSelf(absolute, original, cwd) {
111
+ if (relative(cwd, absolute) === '')
112
+ return;
113
+ assertInsideCwd(absolute, original, cwd);
114
+ }
@@ -0,0 +1,41 @@
1
+ /** Everything {@link planCopy} needs from the outside world. */
2
+ export interface CopyContext {
3
+ /** The package directory every DESTINATION must stay inside. */
4
+ cwd: string;
5
+ /** Directory listing, for wildcard expansion. Throws when absent — treated as "no matches". */
6
+ readdir: (dir: string) => string[];
7
+ }
8
+ /** One resolved source → destination pair. Both absolute. */
9
+ export interface CopyOperation {
10
+ from: string;
11
+ to: string;
12
+ }
13
+ /**
14
+ * Is the destination a DIRECTORY to copy into, rather than the target path?
15
+ *
16
+ * Decided from the ARGUMENTS ALONE — never from what is on disk. `cp` decides it
17
+ * by stat'ing the destination, which is why `cp -r src/assets dist/res` copies
18
+ * the directory to `dist/res` the first time and to `dist/res/assets` the
19
+ * second: the same script means two different things depending on whether it has
20
+ * run before. Four of the scripts this command replaces carry exactly that shape,
21
+ * so they were already non-idempotent on Linux. A build step must not be.
22
+ *
23
+ * The rule, in full:
24
+ * - a trailing `/` (or `\`) on the destination — the explicit spelling;
25
+ * - more than one source;
26
+ * - any source carrying a wildcard.
27
+ *
28
+ * The last two are what keep the two platforms in agreement. A POSIX shell
29
+ * expands `src/public/*` before gjsify is started (many sources); `cmd.exe` does
30
+ * not expand anything and passes the pattern through (one source, a wildcard).
31
+ * Both land in directory mode, so the same script means the same thing on both.
32
+ */
33
+ export declare function isDirectoryDestination(sources: readonly string[], dest: string): boolean;
34
+ /**
35
+ * Resolve `gjsify copy <sources…> <destination>` into the exact list of copies.
36
+ *
37
+ * ALL operations are planned before the caller performs any of them, so a bad
38
+ * argument in position four cannot be discovered after three files have already
39
+ * been written.
40
+ */
41
+ export declare function planCopy(args: readonly string[], ctx: CopyContext): CopyOperation[];
@@ -0,0 +1,145 @@
1
+ // Target resolution for `gjsify copy` — the deciding half, kept pure.
2
+ //
3
+ // Same split as `clear-targets.ts`, for the same reason: the paths come from
4
+ // package.json files rather than from a human at a prompt, so "which file lands
5
+ // where" must be testable without a filesystem — including the cases that must
6
+ // be REFUSED, which cannot be proven by writing outside the working directory.
7
+ //
8
+ // See `commands/copy.ts` for why the command exists at all.
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ import { hasWildcard, segmentToRegExp } from './clear-targets.js';
11
+ /**
12
+ * Is the destination a DIRECTORY to copy into, rather than the target path?
13
+ *
14
+ * Decided from the ARGUMENTS ALONE — never from what is on disk. `cp` decides it
15
+ * by stat'ing the destination, which is why `cp -r src/assets dist/res` copies
16
+ * the directory to `dist/res` the first time and to `dist/res/assets` the
17
+ * second: the same script means two different things depending on whether it has
18
+ * run before. Four of the scripts this command replaces carry exactly that shape,
19
+ * so they were already non-idempotent on Linux. A build step must not be.
20
+ *
21
+ * The rule, in full:
22
+ * - a trailing `/` (or `\`) on the destination — the explicit spelling;
23
+ * - more than one source;
24
+ * - any source carrying a wildcard.
25
+ *
26
+ * The last two are what keep the two platforms in agreement. A POSIX shell
27
+ * expands `src/public/*` before gjsify is started (many sources); `cmd.exe` does
28
+ * not expand anything and passes the pattern through (one source, a wildcard).
29
+ * Both land in directory mode, so the same script means the same thing on both.
30
+ */
31
+ export function isDirectoryDestination(sources, dest) {
32
+ if (dest.endsWith('/') || dest.endsWith('\\'))
33
+ return true;
34
+ if (sources.length > 1)
35
+ return true;
36
+ return sources.some((s) => hasWildcard(s));
37
+ }
38
+ /**
39
+ * Reject a destination that is not inside `cwd`.
40
+ *
41
+ * A hard error, never a skip — the same posture as `clear`, for the mirrored
42
+ * reason: `clear` must not DELETE outside the package, `copy` must not WRITE
43
+ * outside it. `allowSelf` covers `gjsify copy ../shared/x.wasm ./`, where the
44
+ * package root legitimately IS the destination directory.
45
+ *
46
+ * SOURCES are deliberately NOT checked. Three of the scripts this replaces read
47
+ * from a sibling package (`../../../packages/infra/lightningcss-wasm/…`), which
48
+ * is the normal shape for staging a workspace artifact, and reading is not the
49
+ * dangerous direction.
50
+ */
51
+ function assertWritableDestination(absolute, original, cwd, allowSelf) {
52
+ const rel = relative(cwd, absolute);
53
+ if (rel === '') {
54
+ if (allowSelf)
55
+ return;
56
+ throw new Error(`gjsify copy: "${original}" is the working directory itself. ` +
57
+ 'Name a file, or add a trailing "/" to copy INTO the directory.');
58
+ }
59
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
60
+ throw new Error(`gjsify copy: "${original}" resolves to ${absolute}, outside the working directory ${cwd}. ` +
61
+ 'Only paths inside the package may be written.');
62
+ }
63
+ }
64
+ /**
65
+ * Expand one source argument. A wildcard is honoured in the LAST segment only,
66
+ * against a single directory read — the same restricted grammar `clear` uses,
67
+ * and for the same reason: a build script names files, and `**` there would copy
68
+ * more than its author can see at a glance.
69
+ *
70
+ * A pattern matching nothing yields NO operations. A literal path that does not
71
+ * exist yields one operation, which then fails loudly in the copy itself — the
72
+ * asymmetry is deliberate and mirrors the shell: `cp missing.html dist/` is an
73
+ * error, `cp *.html dist/` on an empty directory is not.
74
+ */
75
+ function expandSource(source, ctx) {
76
+ const leaf = basename(source);
77
+ const parent = dirname(source);
78
+ if (hasWildcard(parent)) {
79
+ throw new Error(`gjsify copy: a wildcard is only supported in the last path segment — got "${source}".`);
80
+ }
81
+ if (!hasWildcard(leaf))
82
+ return [resolve(ctx.cwd, source)];
83
+ const dir = resolve(ctx.cwd, parent);
84
+ const re = segmentToRegExp(leaf);
85
+ let names;
86
+ try {
87
+ names = ctx.readdir(dir);
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ return names
93
+ .filter((name) => re.test(name))
94
+ .sort()
95
+ .map((name) => join(dir, name));
96
+ }
97
+ /**
98
+ * Resolve `gjsify copy <sources…> <destination>` into the exact list of copies.
99
+ *
100
+ * ALL operations are planned before the caller performs any of them, so a bad
101
+ * argument in position four cannot be discovered after three files have already
102
+ * been written.
103
+ */
104
+ export function planCopy(args, ctx) {
105
+ if (args.length < 2) {
106
+ throw new Error('gjsify copy: needs at least one source and a destination. Usage: gjsify copy <sources…> <dest>');
107
+ }
108
+ const sources = args.slice(0, -1);
109
+ const dest = args[args.length - 1];
110
+ if (dest === '') {
111
+ throw new Error('gjsify copy: the destination is empty.');
112
+ }
113
+ const intoDirectory = isDirectoryDestination(sources, dest);
114
+ const absDest = resolve(ctx.cwd, dest);
115
+ assertWritableDestination(absDest, dest, ctx.cwd, intoDirectory);
116
+ const expanded = [];
117
+ for (const source of sources) {
118
+ if (source === '')
119
+ throw new Error('gjsify copy: a source path is empty.');
120
+ expanded.push(...expandSource(source, ctx));
121
+ }
122
+ if (!intoDirectory && expanded.length !== 1) {
123
+ // Only reachable when a single wildcard-free source expanded to something
124
+ // other than one path, which cannot happen — but leaving it unstated would
125
+ // make a future change to `expandSource` silently copy the wrong file.
126
+ throw new Error(`gjsify copy: "${dest}" names a single destination, but ${expanded.length} sources resolved.`);
127
+ }
128
+ const ops = [];
129
+ const seenDest = new Set();
130
+ for (const from of expanded) {
131
+ const to = intoDirectory ? join(absDest, basename(from)) : absDest;
132
+ if (from === to) {
133
+ throw new Error(`gjsify copy: "${from}" is its own destination.`);
134
+ }
135
+ if (seenDest.has(to)) {
136
+ // Two sources with the same basename copied into one directory: the
137
+ // second silently overwrites the first, and which one wins depends on
138
+ // argument order. Name it instead.
139
+ throw new Error(`gjsify copy: two sources would both be written to ${to}.`);
140
+ }
141
+ seenDest.add(to);
142
+ ops.push({ from, to });
143
+ }
144
+ return ops;
145
+ }