@gjsify/cli 0.13.2 → 0.15.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.
- package/dist/cli.gjs.mjs +34 -29
- package/lib/commands/foreach.d.ts +1 -0
- package/lib/commands/foreach.js +48 -13
- package/lib/commands/install.js +132 -65
- package/lib/commands/storybook.d.ts +1 -0
- package/lib/commands/storybook.js +51 -9
- package/lib/commands/workspace.d.ts +1 -0
- package/lib/commands/workspace.js +34 -5
- package/lib/utils/build-cache.d.ts +157 -0
- package/lib/utils/build-cache.js +670 -0
- package/lib/utils/install-backend-native.js +104 -9
- package/lib/utils/install-backend.d.ts +10 -0
- package/lib/utils/install-cache-fs.d.ts +9 -0
- package/lib/utils/install-cache-fs.js +20 -6
- package/lib/utils/install-lock.d.ts +15 -0
- package/lib/utils/install-lock.js +293 -0
- package/lib/utils/run-node.d.ts +20 -0
- package/lib/utils/run-node.js +85 -0
- package/package.json +19 -18
package/lib/commands/foreach.js
CHANGED
|
@@ -14,6 +14,7 @@ import { cpus } from 'node:os';
|
|
|
14
14
|
import { buildDependencyGraph, discoverWorkspaces, filterWorkspaces, topologicalSort, } from '@gjsify/workspace';
|
|
15
15
|
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
16
16
|
import { prefixLines } from '../utils/prefixed-output.js';
|
|
17
|
+
import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
|
|
17
18
|
import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
18
19
|
// Every child spawned by spawnPrefixed registers here so fail-fast can
|
|
19
20
|
// terminate the whole in-flight set instead of waiting on it. On CI a
|
|
@@ -238,6 +239,10 @@ export const foreachCommand = {
|
|
|
238
239
|
description: 'Treat <script> [args..] as an arbitrary command (yarn `workspaces foreach exec`-equivalent) instead of a package.json script lookup. Workspace filtering by script presence is skipped. Use `-- <cmd> <args...>` to pass flags to the command without yargs intercepting them.',
|
|
239
240
|
type: 'boolean',
|
|
240
241
|
default: false,
|
|
242
|
+
})
|
|
243
|
+
.option('cached', {
|
|
244
|
+
description: 'Content-hash build cache (ADR 0006): skip a workspace whose inputs (src/**, package.json, tsconfig*.json, transitive workspace deps, toolchain versions) are unchanged and restore its stored outputs instead; on miss run the script and store the output dirs it modified. Script mode only. Default from GJSIFY_BUILD_CACHE=1.',
|
|
245
|
+
type: 'boolean',
|
|
241
246
|
})
|
|
242
247
|
.option('shard', {
|
|
243
248
|
description: 'Run only a deterministic slice "<index>/<total>" (1-based) of the matched workspaces, e.g. --shard 2/4. For fanning a long run across parallel CI jobs. Partitions by sorted name (round-robin) so the shards are disjoint and their union is the full set; order-independent, so safe for tests but NOT for ordered builds.',
|
|
@@ -352,20 +357,43 @@ export const foreachCommand = {
|
|
|
352
357
|
// `cmd` is guaranteed string at this point — both branches above
|
|
353
358
|
// exit on undefined, but TS doesn't narrow through them.
|
|
354
359
|
const finalCmd = cmd;
|
|
360
|
+
// Content-hash build cache (ADR 0006 phase 1). Explicit --cached /
|
|
361
|
+
// --no-cached wins; GJSIFY_BUILD_CACHE=1 is the env default so a
|
|
362
|
+
// whole root-script chain (`gjsify run build`) can opt in without
|
|
363
|
+
// editing every nested invocation. Keys are computed against ALL
|
|
364
|
+
// discovered workspaces (a dep outside the --include filter still
|
|
365
|
+
// participates in its dependents' keys).
|
|
366
|
+
const cached = args.cached ?? buildCacheEnabledByEnv();
|
|
367
|
+
if (args.cached === true && exec) {
|
|
368
|
+
// Only the EXPLICIT flag errors — the GJSIFY_BUILD_CACHE env
|
|
369
|
+
// default covers whole script chains (CI sets it once for the
|
|
370
|
+
// build job) where an --exec invocation is simply not cacheable.
|
|
371
|
+
console.error('gjsify foreach: --cached only applies to script mode, not --exec.');
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
const cache = cached && !exec
|
|
375
|
+
? new BuildCacheRunner({
|
|
376
|
+
root: cwd,
|
|
377
|
+
workspaces: allWorkspaces,
|
|
378
|
+
script: finalCmd,
|
|
379
|
+
args: cmdArgs,
|
|
380
|
+
targets: selected.map((w) => w.name),
|
|
381
|
+
})
|
|
382
|
+
: undefined;
|
|
355
383
|
try {
|
|
356
384
|
if (args.parallel && !args.topological && !args['topological-dev']) {
|
|
357
385
|
const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
|
|
358
|
-
await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec);
|
|
386
|
+
await runParallel(selected, finalCmd, cmdArgs, jobs, verbose, exec, cache);
|
|
359
387
|
}
|
|
360
388
|
else if (args.parallel) {
|
|
361
389
|
// Topological + parallel: each workspace starts as soon as its
|
|
362
390
|
// deps (in the selected set) have finished. Yarn calls this
|
|
363
391
|
// "topological order with concurrency"; we cap at --jobs.
|
|
364
392
|
const jobs = args.jobs && args.jobs > 0 ? args.jobs : cpus().length;
|
|
365
|
-
await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec);
|
|
393
|
+
await runTopologicalParallel(selected, finalCmd, cmdArgs, jobs, verbose, args['topological-dev'] === true, exec, cache);
|
|
366
394
|
}
|
|
367
395
|
else {
|
|
368
|
-
await runSequential(selected, finalCmd, cmdArgs, verbose, exec);
|
|
396
|
+
await runSequential(selected, finalCmd, cmdArgs, verbose, exec, cache);
|
|
369
397
|
}
|
|
370
398
|
}
|
|
371
399
|
catch (err) {
|
|
@@ -382,25 +410,25 @@ export const foreachCommand = {
|
|
|
382
410
|
process.exit(0);
|
|
383
411
|
},
|
|
384
412
|
};
|
|
385
|
-
async function runSequential(workspaces, script, args, verbose, exec) {
|
|
413
|
+
async function runSequential(workspaces, script, args, verbose, exec, cache) {
|
|
386
414
|
for (const ws of workspaces) {
|
|
387
|
-
await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec);
|
|
415
|
+
await runOne(ws, script, args, /* prefixOutput */ false, verbose, exec, cache);
|
|
388
416
|
}
|
|
389
417
|
}
|
|
390
|
-
async function runParallel(workspaces, script, args, concurrency, verbose, exec) {
|
|
418
|
+
async function runParallel(workspaces, script, args, concurrency, verbose, exec, cache) {
|
|
391
419
|
let cursor = 0;
|
|
392
420
|
const workers = [];
|
|
393
421
|
for (let w = 0; w < concurrency; w++) {
|
|
394
422
|
workers.push((async () => {
|
|
395
423
|
while (cursor < workspaces.length) {
|
|
396
424
|
const i = cursor++;
|
|
397
|
-
await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose, exec);
|
|
425
|
+
await runOne(workspaces[i], script, args, /* prefixOutput */ true, verbose, exec, cache);
|
|
398
426
|
}
|
|
399
427
|
})());
|
|
400
428
|
}
|
|
401
429
|
await Promise.all(workers);
|
|
402
430
|
}
|
|
403
|
-
async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec) {
|
|
431
|
+
async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec, cache) {
|
|
404
432
|
const selectedNames = new Set(workspaces.map((w) => w.name));
|
|
405
433
|
const remaining = new Map();
|
|
406
434
|
for (const ws of workspaces) {
|
|
@@ -439,9 +467,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
|
|
|
439
467
|
return new Promise((resolve, reject) => {
|
|
440
468
|
let error = null;
|
|
441
469
|
let lastActivity = Date.now();
|
|
442
|
-
const describeInflight = () => [...inflightStarts.entries()]
|
|
443
|
-
.map(([n, t]) => `${n} (${Math.round((Date.now() - t) / 1000)}s)`)
|
|
444
|
-
.join(', ');
|
|
470
|
+
const describeInflight = () => [...inflightStarts.entries()].map(([n, t]) => `${n} (${Math.round((Date.now() - t) / 1000)}s)`).join(', ');
|
|
445
471
|
const failFast = (e) => {
|
|
446
472
|
// First error wins. KILL the in-flight siblings instead of
|
|
447
473
|
// waiting for them (yarn waits — but a sibling's nested build
|
|
@@ -488,7 +514,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
|
|
|
488
514
|
if (liveProgress) {
|
|
489
515
|
console.error(`[gjsify foreach] start ${next} (${done.size}/${total} done, ${inflight} in flight)`);
|
|
490
516
|
}
|
|
491
|
-
runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec)
|
|
517
|
+
runOne(byName.get(next), script, args, /* prefixOutput */ true, verbose, exec, cache)
|
|
492
518
|
.then(() => {
|
|
493
519
|
done.add(next);
|
|
494
520
|
})
|
|
@@ -528,7 +554,7 @@ async function runTopologicalParallel(workspaces, script, args, concurrency, ver
|
|
|
528
554
|
pump();
|
|
529
555
|
});
|
|
530
556
|
}
|
|
531
|
-
async function runOne(ws, script, args, prefixOutput, verbose, exec) {
|
|
557
|
+
async function runOne(ws, script, args, prefixOutput, verbose, exec, cache) {
|
|
532
558
|
if (exec) {
|
|
533
559
|
// Arbitrary-command mode: spawn `<script> <args...>` directly
|
|
534
560
|
// (yarn `workspaces foreach exec`-equivalent). Used by callers
|
|
@@ -540,6 +566,13 @@ async function runOne(ws, script, args, prefixOutput, verbose, exec) {
|
|
|
540
566
|
await spawnPrefixed(script, args, ws.location, prefixOutput ? `[${ws.name}] ` : null);
|
|
541
567
|
return;
|
|
542
568
|
}
|
|
569
|
+
// Content-hash build cache: on a key hit the stored outputs are
|
|
570
|
+
// restored and the script is skipped entirely; on a miss the output
|
|
571
|
+
// dirs are snapshotted so only the dirs the script MODIFIED are stored
|
|
572
|
+
// after it succeeds (never a committed, no-build lib/).
|
|
573
|
+
if (cache && cache.tryRestore(ws))
|
|
574
|
+
return;
|
|
575
|
+
const before = cache?.snapshotOutputs(ws);
|
|
543
576
|
// Use the same package manager that invoked us — yarn under yarn,
|
|
544
577
|
// npm under npm, gjsify under gjsify. Default to `npm` for portability
|
|
545
578
|
// when nothing is detectable; the script-runner (D.5) will replace
|
|
@@ -550,6 +583,8 @@ async function runOne(ws, script, args, prefixOutput, verbose, exec) {
|
|
|
550
583
|
console.error(`[${ws.name}] $ ${runner} ${argv.join(' ')}`);
|
|
551
584
|
}
|
|
552
585
|
await spawnPrefixed(runner, argv, ws.location, prefixOutput ? `[${ws.name}] ` : null);
|
|
586
|
+
if (cache && before)
|
|
587
|
+
cache.storeAfterSuccess(ws, before);
|
|
553
588
|
}
|
|
554
589
|
function detectPackageManager() {
|
|
555
590
|
// Under GJS there is no Node — neither `npm` nor `yarn` can run scripts, so
|
package/lib/commands/install.js
CHANGED
|
@@ -15,9 +15,18 @@
|
|
|
15
15
|
// Workspace install (`gjsify install` in a monorepo root with a
|
|
16
16
|
// `"workspaces"` field) hoists every workspace's externals into the root
|
|
17
17
|
// `node_modules/` and symlinks `workspace:*` / `workspace:^` / `workspace:~`
|
|
18
|
-
// refs to their target source.
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// refs to their target source. Conflicting version ranges of the same
|
|
19
|
+
// external dep from different workspaces still share ONE hoisted root copy
|
|
20
|
+
// (a real per-workspace dedup pass is Phase D.8, see STATUS.md), but since
|
|
21
|
+
// ADR 0001 step 3 the resolver surfaces every such conflict loudly —
|
|
22
|
+
// `[gjsify] warning: version conflict for <pkg> …` names both ranges, the
|
|
23
|
+
// workspaces that requested them, and the version that actually won.
|
|
24
|
+
//
|
|
25
|
+
// Concurrency (ADR 0001 step 2): every mutation of an install prefix —
|
|
26
|
+
// project node_modules/, gjsify-lock.json, the user-global prefix — runs
|
|
27
|
+
// under a per-prefix cross-process lock (utils/install-lock.ts). Installs
|
|
28
|
+
// into different prefixes stay parallel; the shared XDG tarball/packument
|
|
29
|
+
// caches need no lock because their writes are atomic tmp+rename.
|
|
21
30
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
22
31
|
import { dirname, join, relative } from 'node:path';
|
|
23
32
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -25,6 +34,8 @@ import { discoverWorkspaces } from '@gjsify/workspace';
|
|
|
25
34
|
import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
|
|
26
35
|
import { detectNativePackages } from '../utils/detect-native-packages.js';
|
|
27
36
|
import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
|
|
37
|
+
import { atomicWriteStrict } from '../utils/install-cache-fs.js';
|
|
38
|
+
import { acquireInstallLock } from '../utils/install-lock.js';
|
|
28
39
|
import { binDirOnPath, defaultGlobalLayout, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
|
|
29
40
|
import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
|
|
30
41
|
// Default 30min wall-clock budget for the full install. Big workspaces
|
|
@@ -268,40 +279,50 @@ async function projectInstallNative(args, signal) {
|
|
|
268
279
|
const progress = makeProgressReporter({
|
|
269
280
|
enabled: !args.verbose && !args.quiet && args.progress !== false,
|
|
270
281
|
});
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
282
|
+
// Hold the per-prefix lock across the install AND the manifest/lockfile
|
|
283
|
+
// sync that follows, so a concurrent `gjsify install <pkg>` in the same
|
|
284
|
+
// project can't interleave its package.json / gjsify-lock.json writes
|
|
285
|
+
// with ours. `installPackages` re-acquires the same lock re-entrantly.
|
|
286
|
+
const lock = await acquireInstallLock(cwd, { signal });
|
|
287
|
+
try {
|
|
288
|
+
const result = await installPackages({
|
|
289
|
+
prefix: cwd,
|
|
290
|
+
specs,
|
|
291
|
+
verbose: args.verbose,
|
|
292
|
+
// --immutable consumes the lockfile verbatim and must NOT rewrite
|
|
293
|
+
// it (the whole point is byte-stability under CI).
|
|
294
|
+
lockfile: !args.immutable,
|
|
295
|
+
frozen: args.immutable,
|
|
296
|
+
refreshLockfile: args['refresh-lockfile'],
|
|
297
|
+
signal,
|
|
298
|
+
progress,
|
|
299
|
+
});
|
|
300
|
+
// Update package.json only when the user passed explicit packages
|
|
301
|
+
// (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
|
|
302
|
+
// flow doesn't mutate manifest entries.
|
|
303
|
+
if (args.packages && args.packages.length > 0 && pkg) {
|
|
304
|
+
const kind = depKindFromArgs(args);
|
|
305
|
+
for (const spec of args.packages) {
|
|
306
|
+
const { name, range } = parseSpec(spec);
|
|
307
|
+
const installed = result.installed.find((r) => r.name === name);
|
|
308
|
+
const finalRange = range ?? (installed ? defaultRangeFromVersion(installed.version) : 'latest');
|
|
309
|
+
addDependencyEntry(pkg, name, finalRange, kind);
|
|
310
|
+
}
|
|
311
|
+
writePackageJson(pkgPath, pkg);
|
|
312
|
+
// Re-sync the lockfile's `requested` field with what
|
|
313
|
+
// `projectSpecsFromPackageJson()` will return on the next
|
|
314
|
+
// invocation. Without this, a `gjsify install foo` (bare name,
|
|
315
|
+
// lockfile records `"foo"`) followed by `gjsify install
|
|
316
|
+
// --immutable` (reads package.json → spec `"foo@^1.2.3"`) would
|
|
317
|
+
// surface a spurious drift error.
|
|
318
|
+
if (!args.immutable) {
|
|
319
|
+
syncLockfileRequested(cwd, projectSpecsFromPackageJson(pkg));
|
|
320
|
+
}
|
|
303
321
|
}
|
|
304
322
|
}
|
|
323
|
+
finally {
|
|
324
|
+
lock.release();
|
|
325
|
+
}
|
|
305
326
|
}
|
|
306
327
|
function syncLockfileRequested(cwd, specs) {
|
|
307
328
|
const lockPath = join(cwd, 'gjsify-lock.json');
|
|
@@ -315,7 +336,9 @@ function syncLockfileRequested(cwd, specs) {
|
|
|
315
336
|
return; // Already in sync; preserve byte-stability.
|
|
316
337
|
}
|
|
317
338
|
lock.requested = specs;
|
|
318
|
-
|
|
339
|
+
// Atomic tmp+rename — same rationale as the backend's lockfile
|
|
340
|
+
// writer: a torn gjsify-lock.json breaks the next `--immutable`.
|
|
341
|
+
atomicWriteStrict(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
319
342
|
}
|
|
320
343
|
catch {
|
|
321
344
|
// Best-effort sync; if the lockfile is malformed, the next
|
|
@@ -328,23 +351,45 @@ function syncLockfileRequested(cwd, specs) {
|
|
|
328
351
|
* 2. Aggregate the union of their external (non-`workspace:`) deps.
|
|
329
352
|
* 3. Run the native install backend ONCE at the root prefix so all
|
|
330
353
|
* externals land in a single `node_modules/` (poor-man's hoisting —
|
|
331
|
-
*
|
|
332
|
-
*
|
|
354
|
+
* workspaces with conflicting ranges of the same dep share one hoisted
|
|
355
|
+
* copy; the resolver emits a loud `[gjsify] warning: version conflict`
|
|
356
|
+
* naming both sides and the version that won).
|
|
333
357
|
* 4. For every `workspace:` reference, symlink the target workspace's
|
|
334
358
|
* directory into the requesting workspace's `node_modules/<dep>`
|
|
335
359
|
* so `import '@gjsify/utils'` resolves to the local source.
|
|
336
360
|
*
|
|
337
361
|
* Hoisting strategy is intentionally minimal — per-workspace dedup +
|
|
338
|
-
* nested `node_modules/` for version conflicts are
|
|
339
|
-
* in STATUS.md
|
|
362
|
+
* nested `node_modules/` for cross-workspace version conflicts are the
|
|
363
|
+
* Phase D.8 follow-up tracked in STATUS.md.
|
|
340
364
|
*/
|
|
341
365
|
async function workspaceInstall(cwd, args, signal) {
|
|
366
|
+
// Hold the root-prefix lock for the WHOLE workspace flow: bin shims,
|
|
367
|
+
// workspace symlinks and the external install all mutate the root
|
|
368
|
+
// node_modules/, and a second concurrent workspace install interleaving
|
|
369
|
+
// its rm+symlink/extract steps with ours corrupts both. The inner
|
|
370
|
+
// `installPackages` calls re-acquire the same lock re-entrantly (root
|
|
371
|
+
// prefix) or take the child-workspace lock (scoped overrides) — always
|
|
372
|
+
// in root→child order, so there is no lock-ordering cycle.
|
|
373
|
+
const lock = await acquireInstallLock(cwd, { signal });
|
|
374
|
+
try {
|
|
375
|
+
await workspaceInstallLocked(cwd, args, signal);
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
lock.release();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Body of {@link workspaceInstall}, run while holding the root-prefix lock. */
|
|
382
|
+
async function workspaceInstallLocked(cwd, args, signal) {
|
|
342
383
|
const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
|
|
343
384
|
if (workspaces.length === 0) {
|
|
344
385
|
throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
|
|
345
386
|
}
|
|
346
387
|
const byName = new Map(workspaces.map((w) => [w.name, w]));
|
|
347
388
|
const externalSpecs = new Set();
|
|
389
|
+
// Which workspace(s) declared each external spec — `"<name>@<range>"` →
|
|
390
|
+
// workspace names. Feeds the resolver's version-conflict warning so it
|
|
391
|
+
// can attribute both sides of a cross-workspace range conflict.
|
|
392
|
+
const specOrigins = new Map();
|
|
348
393
|
const symlinks = [];
|
|
349
394
|
for (const ws of workspaces) {
|
|
350
395
|
const m = ws.manifest;
|
|
@@ -391,7 +436,14 @@ async function workspaceInstall(cwd, args, signal) {
|
|
|
391
436
|
}
|
|
392
437
|
if (/^(link|file|portal|git\+|https?):/.test(spec))
|
|
393
438
|
continue;
|
|
394
|
-
|
|
439
|
+
const specKey = `${depName}@${spec}`;
|
|
440
|
+
externalSpecs.add(specKey);
|
|
441
|
+
let origins = specOrigins.get(specKey);
|
|
442
|
+
if (!origins) {
|
|
443
|
+
origins = new Set();
|
|
444
|
+
specOrigins.set(specKey, origins);
|
|
445
|
+
}
|
|
446
|
+
origins.add(ws.name);
|
|
395
447
|
}
|
|
396
448
|
}
|
|
397
449
|
}
|
|
@@ -624,6 +676,7 @@ async function workspaceInstall(cwd, args, signal) {
|
|
|
624
676
|
// symlinks (e.g. a `types-dev/<lib>` workspace that also exists on
|
|
625
677
|
// npm and is pinned transitively in the lockfile).
|
|
626
678
|
workspaceNames: new Set(byName.keys()),
|
|
679
|
+
specOrigins: new Map([...specOrigins].map(([k, v]) => [k, [...v]])),
|
|
627
680
|
});
|
|
628
681
|
}
|
|
629
682
|
else if (args.verbose) {
|
|
@@ -926,28 +979,42 @@ async function installGlobalAndLink(specs, opts) {
|
|
|
926
979
|
mkdirSync(layout.prefix, { recursive: true });
|
|
927
980
|
console.log(`gjsify install --global → ${layout.prefix}`);
|
|
928
981
|
console.log(` bins → ${layout.binDir}`);
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
982
|
+
// The global prefix is SHARED user-wide state — two concurrent
|
|
983
|
+
// `gjsify install -g` runs (e.g. parallel agent sessions bootstrapping
|
|
984
|
+
// tooling) used to interleave rm+extract on the same package dirs and
|
|
985
|
+
// race the launcher writes in ~/.local/bin. Hold the prefix lock across
|
|
986
|
+
// the install, the engine-package top-up AND the bin linking (ADR 0001).
|
|
987
|
+
const lock = await acquireInstallLock(layout.prefix, {});
|
|
988
|
+
try {
|
|
989
|
+
const result = await installPackages({
|
|
990
|
+
prefix: layout.prefix,
|
|
991
|
+
specs,
|
|
992
|
+
verbose: opts.verbose,
|
|
993
|
+
});
|
|
994
|
+
const packageNames = specs.map(specToPackageName);
|
|
995
|
+
// A global GJS install of `@gjsify/cli` must also lay down the GJS-native
|
|
996
|
+
// bundler engine (`@gjsify/rolldown-native`) + the sibling format/CSS
|
|
997
|
+
// bridges. They are declared OPTIONAL peers of `@gjsify/cli` (so plain
|
|
998
|
+
// `npm install @gjsify/cli` on Node doesn't force a linux prebuild), and
|
|
999
|
+
// the native backend doesn't resolve peerDependencies at all — so without
|
|
1000
|
+
// this they'd never be installed, and `gjsify build` would hard-fail under
|
|
1001
|
+
// GJS ("no usable bundler engine"). Installed best-effort at the cli's
|
|
1002
|
+
// resolved version so they move in lockstep with the bundle; a platform
|
|
1003
|
+
// with no published prebuild degrades to a warning. Must run BEFORE
|
|
1004
|
+
// linkGlobalBins so the launcher's detectNativePackages() bakes the
|
|
1005
|
+
// engine's prebuild dirs into the wrapper's GI_TYPELIB_PATH/LD_LIBRARY_PATH.
|
|
1006
|
+
if (packageNames.includes('@gjsify/cli')) {
|
|
1007
|
+
const cliVersion = result.installed.find((r) => r.name === '@gjsify/cli')?.version ?? 'latest';
|
|
1008
|
+
await installGjsEnginePackages(layout.prefix, cliVersion, { verbose: opts.verbose });
|
|
1009
|
+
}
|
|
1010
|
+
const created = linkGlobalBins(packageNames, layout);
|
|
1011
|
+
reportLinkedBins(created, layout.binDir);
|
|
1012
|
+
}
|
|
1013
|
+
finally {
|
|
1014
|
+
lock.release();
|
|
949
1015
|
}
|
|
950
|
-
|
|
1016
|
+
}
|
|
1017
|
+
function reportLinkedBins(created, binDir) {
|
|
951
1018
|
if (created.length === 0) {
|
|
952
1019
|
console.warn('\nNo bins declared (neither `gjsify.bin` nor `bin` in package.json) — nothing was symlinked.');
|
|
953
1020
|
}
|
|
@@ -957,9 +1024,9 @@ async function installGlobalAndLink(specs, opts) {
|
|
|
957
1024
|
console.log(` • ${e.link} → ${e.target}`);
|
|
958
1025
|
}
|
|
959
1026
|
}
|
|
960
|
-
if (created.length > 0 && !binDirOnPath(
|
|
961
|
-
console.warn(`\nNote: ${
|
|
962
|
-
`Add it to your shell rc file:\n export PATH="${
|
|
1027
|
+
if (created.length > 0 && !binDirOnPath(binDir)) {
|
|
1028
|
+
console.warn(`\nNote: ${binDir} is not on your PATH.\n` +
|
|
1029
|
+
`Add it to your shell rc file:\n export PATH="${binDir}:$PATH"`);
|
|
963
1030
|
}
|
|
964
1031
|
}
|
|
965
1032
|
async function runPostInstallChecks() {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// `gjsify storybook` — discover every *.story.ts in a project, generate a tiny
|
|
2
|
-
// entry that registers them, then build
|
|
2
|
+
// entry that registers them, then build and launch the generic
|
|
3
3
|
// @gjsify/storybook component browser. No per-project storybook app needed.
|
|
4
4
|
//
|
|
5
|
+
// `--runtime gjs` (default) builds `--app gjs` and runs it with `gjs -m`.
|
|
6
|
+
// `--runtime node` builds `--app node` (gi:// → @gjsify/node-gi reverse bridge)
|
|
7
|
+
// and runs it with `node` — the SAME storybook source rendering on Node.js. The
|
|
8
|
+
// node path wires GI_TYPELIB_PATH/LD_LIBRARY_PATH like `gjsify run` (so any
|
|
9
|
+
// native @gjsify bridge a story uses resolves) and requires `@gjsify/node-gi`
|
|
10
|
+
// installed in the project (kept external by the bundler, a native addon).
|
|
11
|
+
//
|
|
5
12
|
// Dispatch is IN-PROCESS (runCli + runGjsBundle), not a re-spawn of the CLI:
|
|
6
13
|
// the showcase-style `spawn(process.execPath, [cliBin, …])` pattern assumes
|
|
7
14
|
// Node, but under the committed GJS bundle `process.execPath` is `gjs` and the
|
|
@@ -82,6 +89,13 @@ export const storybookCommand = {
|
|
|
82
89
|
.option('out', {
|
|
83
90
|
description: 'Output bundle path (default: node_modules/.cache/gjsify-storybook)',
|
|
84
91
|
type: 'string',
|
|
92
|
+
})
|
|
93
|
+
.option('runtime', {
|
|
94
|
+
description: 'Runtime to build for and launch on: gjs (default) or node (via @gjsify/node-gi). ' +
|
|
95
|
+
'node requires @gjsify/node-gi installed in the project.',
|
|
96
|
+
type: 'string',
|
|
97
|
+
choices: ['gjs', 'node'],
|
|
98
|
+
default: 'gjs',
|
|
85
99
|
})
|
|
86
100
|
.option('watch', {
|
|
87
101
|
description: 'Rebuild and relaunch when a story file changes',
|
|
@@ -97,10 +111,16 @@ export const storybookCommand = {
|
|
|
97
111
|
const applicationId = args.appId ?? config.applicationId ?? deriveAppId(pkg.name);
|
|
98
112
|
const title = args.title ?? config.title;
|
|
99
113
|
const globals = args.globals ?? config.globals ?? 'auto';
|
|
114
|
+
const runtime = (args.runtime ?? config.runtime ?? 'gjs');
|
|
115
|
+
// The runtime drives BOTH the build target (`gjsify build --app <app>`)
|
|
116
|
+
// and the launcher: `gjs` builds an `--app gjs` bundle run by `gjs -m`,
|
|
117
|
+
// `node` builds an `--app node` bundle (gi:// → @gjsify/node-gi) run by
|
|
118
|
+
// `node`. Same generated entry + `--globals auto` for both.
|
|
119
|
+
const app = runtime === 'node' ? 'node' : 'gjs';
|
|
100
120
|
const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify-storybook');
|
|
101
121
|
mkdirSync(cacheDir, { recursive: true });
|
|
102
122
|
const entryPath = join(cacheDir, 'entry.ts');
|
|
103
|
-
const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir,
|
|
123
|
+
const outPath = args.out ? resolve(cwd, args.out) : join(cacheDir, `storybook.${app}.mjs`);
|
|
104
124
|
const writeEntry = () => {
|
|
105
125
|
const files = findStoryFiles(storiesDir).sort();
|
|
106
126
|
writeFileSync(entryPath, generateEntry(files, applicationId, title));
|
|
@@ -111,24 +131,46 @@ export const storybookCommand = {
|
|
|
111
131
|
console.error(`No *.story.ts files found under ${relative(cwd, storiesDir) || storiesDir}`);
|
|
112
132
|
process.exit(1);
|
|
113
133
|
}
|
|
114
|
-
console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'}`);
|
|
134
|
+
console.log(`Found ${initialCount} story file(s) under ${relative(cwd, storiesDir) || '.'} (runtime: ${runtime})`);
|
|
115
135
|
// In-process build — runtime-agnostic (Node uses npm rolldown, GJS uses
|
|
116
136
|
// the @gjsify/rolldown-native facade), no re-spawn of the CLI binary.
|
|
117
137
|
const { runCli } = await import('../cli-app.js');
|
|
118
|
-
const buildBundle = () => runCli(['build', entryPath, '--app',
|
|
138
|
+
const buildBundle = () => runCli(['build', entryPath, '--app', app, '--globals', globals, '--outfile', outPath]);
|
|
139
|
+
const launch = async () => {
|
|
140
|
+
if (runtime === 'node') {
|
|
141
|
+
const { runNodeBundle } = await import('../utils/run-node.js');
|
|
142
|
+
await runNodeBundle(outPath, [], { exitOnSuccess: true });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const { runGjsBundle } = await import('../utils/run-gjs.js');
|
|
146
|
+
await runGjsBundle(outPath, [], { exitOnSuccess: true });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
119
149
|
if (!args.watch) {
|
|
120
150
|
await buildBundle();
|
|
121
151
|
if (args.buildOnly)
|
|
122
152
|
return;
|
|
123
|
-
|
|
124
|
-
await runGjsBundle(outPath, [], { exitOnSuccess: true });
|
|
153
|
+
await launch();
|
|
125
154
|
return;
|
|
126
155
|
}
|
|
127
156
|
// Watch mode: rebuild + relaunch on any change under the stories dir.
|
|
128
157
|
// The storybook bundle only needs system Gtk/Adw typelibs at runtime
|
|
129
|
-
// (no @gjsify native bridges), so a plain `gjs -m` child is
|
|
130
|
-
// stays killable for relaunch.
|
|
158
|
+
// (no @gjsify native bridges), so a plain `gjs -m` / `node` child is
|
|
159
|
+
// enough and stays killable for relaunch.
|
|
131
160
|
const { watch } = await import('node:fs');
|
|
161
|
+
const spawnChild = async () => {
|
|
162
|
+
if (runtime === 'node') {
|
|
163
|
+
const { computeNativeEnvForBundle } = await import('../utils/run-gjs.js');
|
|
164
|
+
const { resolveNodeGi, nodeBinary } = await import('../utils/run-node.js');
|
|
165
|
+
if (!resolveNodeGi(cwd)) {
|
|
166
|
+
throw new Error('Cannot run the storybook on Node: `@gjsify/node-gi` is not installed. ' +
|
|
167
|
+
'Add @gjsify/node-gi as a dependency to run the storybook on Node.');
|
|
168
|
+
}
|
|
169
|
+
const { env: nativeEnv } = computeNativeEnvForBundle(outPath, cwd);
|
|
170
|
+
return spawn(nodeBinary(), [outPath], { stdio: 'inherit', env: { ...process.env, ...nativeEnv } });
|
|
171
|
+
}
|
|
172
|
+
return spawn('gjs', ['-m', outPath], { stdio: 'inherit' });
|
|
173
|
+
};
|
|
132
174
|
let child = null;
|
|
133
175
|
let rebuilding = false;
|
|
134
176
|
let pending = false;
|
|
@@ -146,7 +188,7 @@ export const storybookCommand = {
|
|
|
146
188
|
}
|
|
147
189
|
await buildBundle();
|
|
148
190
|
if (!args.buildOnly) {
|
|
149
|
-
child =
|
|
191
|
+
child = await spawnChild();
|
|
150
192
|
}
|
|
151
193
|
}
|
|
152
194
|
catch (err) {
|