@gjsify/cli 0.4.35 → 0.4.36
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 +85 -75
- package/lib/bundler-pick.js +43 -4
- package/lib/commands/affected.d.ts +10 -0
- package/lib/commands/affected.js +303 -0
- package/lib/commands/build.js +1 -1
- package/lib/commands/dlx.js +8 -1
- package/lib/commands/index.d.ts +3 -0
- package/lib/commands/index.js +3 -0
- package/lib/commands/install.d.ts +3 -0
- package/lib/commands/install.js +324 -77
- package/lib/commands/publish.js +36 -35
- package/lib/commands/tsc.d.ts +6 -0
- package/lib/commands/tsc.js +109 -0
- package/lib/commands/whoami.d.ts +7 -0
- package/lib/commands/whoami.js +118 -0
- package/lib/commands/workspace.d.ts +4 -0
- package/lib/commands/workspace.js +159 -32
- package/lib/index.js +4 -1
- package/lib/utils/install-backend-native.js +58 -15
- package/lib/utils/install-backend.d.ts +19 -0
- package/lib/utils/install-backend.js +1 -0
- package/lib/utils/install-progress.d.ts +26 -0
- package/lib/utils/install-progress.js +109 -0
- package/lib/utils/install-tarball-cache.d.ts +23 -0
- package/lib/utils/install-tarball-cache.js +140 -0
- package/lib/utils/load-npmrc.d.ts +14 -0
- package/lib/utils/load-npmrc.js +61 -0
- package/lib/utils/publish-diagnose.d.ts +38 -0
- package/lib/utils/publish-diagnose.js +99 -0
- package/lib/utils/resolve-npm-package.d.ts +21 -0
- package/lib/utils/resolve-npm-package.js +121 -0
- package/package.json +29 -18
package/lib/commands/install.js
CHANGED
|
@@ -20,13 +20,21 @@
|
|
|
20
20
|
// wins today).
|
|
21
21
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { dirname, join, relative } from 'node:path';
|
|
23
|
-
import { spawn } from 'node:child_process';
|
|
23
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
24
24
|
import { discoverWorkspaces } from '@gjsify/workspace';
|
|
25
25
|
import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
|
|
26
26
|
import { detectNativePackages } from '../utils/detect-native-packages.js';
|
|
27
|
-
import { installPackages } from '../utils/install-backend.js';
|
|
27
|
+
import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
|
|
28
28
|
import { binDirOnPath, defaultGlobalLayout, linkGlobalBins, specToPackageName } from '../utils/install-global.js';
|
|
29
29
|
import { addDependencyEntry, defaultRangeFromVersion, parseSpec, projectSpecsFromPackageJson, readPackageJson, writePackageJson, } from '../utils/pkg-json-edit.js';
|
|
30
|
+
// Default 30min wall-clock budget for the full install. Big workspaces
|
|
31
|
+
// (212+ sub-packages × 600+ external deps in the gjsify monorepo itself)
|
|
32
|
+
// can legitimately take 10-20 min on a fresh CI install when the npm CDN
|
|
33
|
+
// is slow — a 5-min default would false-positive on those legitimate
|
|
34
|
+
// flows. Per-fetch timeout (30s, retried) catches the truly stuck case
|
|
35
|
+
// inside this budget. Set --timeout 0 to disable the wall-clock guard
|
|
36
|
+
// entirely.
|
|
37
|
+
const DEFAULT_INSTALL_TIMEOUT_MS = 1_800_000;
|
|
30
38
|
export const installCommand = {
|
|
31
39
|
command: 'install [packages..]',
|
|
32
40
|
description: 'Install npm dependencies in the current project (or globally with -g), then run gjsify-aware post-checks.',
|
|
@@ -54,11 +62,26 @@ export const installCommand = {
|
|
|
54
62
|
description: 'Verbose install logging.',
|
|
55
63
|
type: 'boolean',
|
|
56
64
|
default: false,
|
|
65
|
+
})
|
|
66
|
+
.option('quiet', {
|
|
67
|
+
description: 'Silence the progress bar.',
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
default: false,
|
|
70
|
+
})
|
|
71
|
+
.option('progress', {
|
|
72
|
+
description: 'Show a TTY-aware progress bar for resolve / download / extract phases. Auto-enabled when stderr is a TTY (override with --no-progress). Implicitly off under --verbose (per-package log lines replace the bar) or --quiet.',
|
|
73
|
+
type: 'boolean',
|
|
74
|
+
default: true,
|
|
57
75
|
})
|
|
58
76
|
.option('backend', {
|
|
59
77
|
description: 'Install backend. `native` (default) routes through `@gjsify/{semver,npm-registry,tar}` — no Node/npm at runtime. `npm` shells out to `npm install` as an escape hatch for cases the native backend does not yet model (Yarn PnP repos, lifecycle scripts). Overrides `GJSIFY_INSTALL_BACKEND` if both are set.',
|
|
60
78
|
type: 'string',
|
|
61
79
|
choices: ['native', 'npm'],
|
|
80
|
+
})
|
|
81
|
+
.option('timeout', {
|
|
82
|
+
description: 'Overall install wall-clock timeout in ms (default 1800000 = 30 min). On timeout, all in-flight registry fetches are aborted and the install exits non-zero with a clear "install timed out — likely a registry slowdown" message. Per-request timeouts in @gjsify/npm-registry (default 30s) still apply within this budget. Set to 0 to disable the overall budget.',
|
|
83
|
+
type: 'number',
|
|
84
|
+
default: DEFAULT_INSTALL_TIMEOUT_MS,
|
|
62
85
|
}),
|
|
63
86
|
handler: async (args) => {
|
|
64
87
|
// --immutable is incompatible with explicit `<pkg>` adds and with
|
|
@@ -98,10 +121,58 @@ export const installCommand = {
|
|
|
98
121
|
await runPostInstallChecks();
|
|
99
122
|
return;
|
|
100
123
|
}
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
// Overall wall-clock budget for the install (default 5 min). On
|
|
125
|
+
// timeout we abort every in-flight registry fetch via this controller
|
|
126
|
+
// so the process exits cleanly with an actionable message instead of
|
|
127
|
+
// a silent hang. Per-request timeouts inside @gjsify/npm-registry
|
|
128
|
+
// (default 30s, retried) still apply within this budget.
|
|
129
|
+
const overallTimeoutMs = args.timeout > 0 ? args.timeout : 0;
|
|
130
|
+
const overallController = overallTimeoutMs > 0 ? new AbortController() : null;
|
|
131
|
+
const overallTimerId = overallController !== null
|
|
132
|
+
? setTimeout(() => overallController.abort(new Error('install-overall-timeout')), overallTimeoutMs)
|
|
133
|
+
: null;
|
|
134
|
+
try {
|
|
135
|
+
await projectInstallNative(args, overallController?.signal);
|
|
136
|
+
await runPostInstallChecks();
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
if (overallController !== null && overallController.signal.aborted && isAbortedFromOverallTimeout(err)) {
|
|
140
|
+
const secs = Math.round(overallTimeoutMs / 100) / 10;
|
|
141
|
+
console.error(`gjsify install: timed out after ${secs}s — likely a registry slowdown.\n` +
|
|
142
|
+
`Re-run, or override with --timeout <ms> (set --timeout 0 to disable the overall budget).`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (overallTimerId !== null)
|
|
149
|
+
clearTimeout(overallTimerId);
|
|
150
|
+
}
|
|
103
151
|
},
|
|
104
152
|
};
|
|
153
|
+
/**
|
|
154
|
+
* Heuristic: was this error raised because the overall-install AbortSignal
|
|
155
|
+
* fired? The signal's `reason` is the sentinel `Error('install-overall-timeout')`
|
|
156
|
+
* we installed above; the abort surfaces either as that exact reason or as
|
|
157
|
+
* any AbortError thrown by a downstream fetch / setTimeout-on-abort path.
|
|
158
|
+
* We match permissively because intermediate layers (fetch, GJS Soup, our
|
|
159
|
+
* own delay()) re-wrap the reason in their own AbortError instances.
|
|
160
|
+
*/
|
|
161
|
+
function isAbortedFromOverallTimeout(err) {
|
|
162
|
+
if (!err || typeof err !== 'object')
|
|
163
|
+
return false;
|
|
164
|
+
const name = err.name;
|
|
165
|
+
if (name === 'AbortError')
|
|
166
|
+
return true;
|
|
167
|
+
const message = err.message;
|
|
168
|
+
if (typeof message === 'string' && message.includes('install-overall-timeout'))
|
|
169
|
+
return true;
|
|
170
|
+
// RegistryTimeoutError surfaces the per-request budget — distinct from
|
|
171
|
+
// the overall budget but typically the symptom the overall timer reports.
|
|
172
|
+
if (name === 'RegistryTimeoutError')
|
|
173
|
+
return true;
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
105
176
|
function isWorkspaceRoot(cwd) {
|
|
106
177
|
const pkgPath = join(cwd, 'package.json');
|
|
107
178
|
const pkg = readPackageJson(pkgPath);
|
|
@@ -118,7 +189,7 @@ function depKindFromArgs(args) {
|
|
|
118
189
|
return 'optionalDependencies';
|
|
119
190
|
return 'dependencies';
|
|
120
191
|
}
|
|
121
|
-
async function projectInstallNative(args) {
|
|
192
|
+
async function projectInstallNative(args, signal) {
|
|
122
193
|
const cwd = process.cwd();
|
|
123
194
|
const pkgPath = join(cwd, 'package.json');
|
|
124
195
|
// gjsify install is a node_modules-linker installer (like `npm install`
|
|
@@ -155,7 +226,7 @@ async function projectInstallNative(args) {
|
|
|
155
226
|
// fires for the root no-args case, which is the `yarn install`
|
|
156
227
|
// equivalent).
|
|
157
228
|
if ((!args.packages || args.packages.length === 0) && isWorkspaceRoot(cwd)) {
|
|
158
|
-
await workspaceInstall(cwd, args);
|
|
229
|
+
await workspaceInstall(cwd, args, signal);
|
|
159
230
|
return;
|
|
160
231
|
}
|
|
161
232
|
let specs;
|
|
@@ -181,6 +252,12 @@ async function projectInstallNative(args) {
|
|
|
181
252
|
}
|
|
182
253
|
}
|
|
183
254
|
mkdirSync(cwd, { recursive: true });
|
|
255
|
+
// Progress bar is auto-enabled when stderr is a TTY (and `--verbose` /
|
|
256
|
+
// `--quiet` / `--no-progress` aren't set). When piped to a log file the
|
|
257
|
+
// reporter falls back to one line per phase begin/end.
|
|
258
|
+
const progress = makeProgressReporter({
|
|
259
|
+
enabled: !args.verbose && !args.quiet && args.progress !== false,
|
|
260
|
+
});
|
|
184
261
|
const result = await installPackages({
|
|
185
262
|
prefix: cwd,
|
|
186
263
|
specs,
|
|
@@ -189,6 +266,8 @@ async function projectInstallNative(args) {
|
|
|
189
266
|
// it (the whole point is byte-stability under CI).
|
|
190
267
|
lockfile: !args.immutable,
|
|
191
268
|
frozen: args.immutable,
|
|
269
|
+
signal,
|
|
270
|
+
progress,
|
|
192
271
|
});
|
|
193
272
|
// Update package.json only when the user passed explicit packages
|
|
194
273
|
// (the `gjsify install <pkg>...` add-a-dep flow). The no-args refresh
|
|
@@ -248,7 +327,7 @@ function syncLockfileRequested(cwd, specs) {
|
|
|
248
327
|
* nested `node_modules/` for version conflicts are tracked as a follow-up
|
|
249
328
|
* in STATUS.md "Open TODOs".
|
|
250
329
|
*/
|
|
251
|
-
async function workspaceInstall(cwd, args) {
|
|
330
|
+
async function workspaceInstall(cwd, args, signal) {
|
|
252
331
|
const workspaces = discoverWorkspaces(cwd, { includeRoot: true });
|
|
253
332
|
if (workspaces.length === 0) {
|
|
254
333
|
throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
|
|
@@ -308,12 +387,78 @@ async function workspaceInstall(cwd, args) {
|
|
|
308
387
|
console.log(`gjsify install: ${workspaces.length} workspace(s), ${externalSpecs.size} external dep spec(s), ${symlinks.length} workspace symlink(s)`);
|
|
309
388
|
// Read top-level package.json's `overrides` (npm-native) or `resolutions`
|
|
310
389
|
// (yarn-native, kept as the existing field name in pre-Phase-D.8 repos).
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
390
|
+
// Flat `name → range` entries become global overrides applied to every
|
|
391
|
+
// workspace; nested `<workspace> → {dep → range}` entries become
|
|
392
|
+
// workspace-local installs that place the overridden dep inside that
|
|
393
|
+
// workspace's own `node_modules/`. Lets a monorepo pin one workspace to
|
|
394
|
+
// an older `typescript` (e.g. a downstream integration test) without
|
|
395
|
+
// forcing the rest of the tree to the same version.
|
|
314
396
|
const rootManifest = workspaces.find((w) => w.location === cwd)?.manifest;
|
|
315
|
-
const
|
|
397
|
+
const extracted = extractOverrides(rootManifest);
|
|
398
|
+
const overrides = extracted?.global;
|
|
399
|
+
// Second pass: pluck specs that have a workspace-scoped override out of
|
|
400
|
+
// `externalSpecs` and re-collect them into a per-workspace map. Those
|
|
401
|
+
// specs will be installed into the workspace's own `node_modules/` after
|
|
402
|
+
// the root install completes, so the resolver in the root pass does NOT
|
|
403
|
+
// see the conflicting versions.
|
|
404
|
+
const wsLocalSpecs = new Map(); // wsLocation → name@range set
|
|
405
|
+
const droppedFromExternal = new Set();
|
|
406
|
+
if (extracted && extracted.scoped.size > 0) {
|
|
407
|
+
for (const ws of workspaces) {
|
|
408
|
+
const wsManifest = ws.manifest;
|
|
409
|
+
for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
410
|
+
const deps = wsManifest[kind];
|
|
411
|
+
if (!deps)
|
|
412
|
+
continue;
|
|
413
|
+
for (const [depName, spec] of Object.entries(deps)) {
|
|
414
|
+
const override = scopedOverrideFor(ws, depName, extracted);
|
|
415
|
+
if (!override)
|
|
416
|
+
continue;
|
|
417
|
+
// Re-route this dep to the workspace's own install
|
|
418
|
+
const targetRange = override;
|
|
419
|
+
const wsKey = ws.location;
|
|
420
|
+
let bucket = wsLocalSpecs.get(wsKey);
|
|
421
|
+
if (!bucket) {
|
|
422
|
+
bucket = new Set();
|
|
423
|
+
wsLocalSpecs.set(wsKey, bucket);
|
|
424
|
+
}
|
|
425
|
+
bucket.add(`${depName}@${targetRange}`);
|
|
426
|
+
// Drop the un-overridden version from the root spec set:
|
|
427
|
+
// the workspace will see its scoped version via parent-walk
|
|
428
|
+
// resolution. Note we only drop the EXACT `name@spec` the
|
|
429
|
+
// workspace declared; other workspaces' instances of the
|
|
430
|
+
// same name+spec stay in the root set.
|
|
431
|
+
droppedFromExternal.add(`${depName}@${spec}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Apply the drops only when no OTHER workspace declared the same spec
|
|
436
|
+
// — otherwise it has legitimate root requesters and must stay.
|
|
437
|
+
const stillNeeded = new Set();
|
|
438
|
+
for (const ws of workspaces) {
|
|
439
|
+
for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
440
|
+
const deps = ws.manifest[kind];
|
|
441
|
+
if (!deps)
|
|
442
|
+
continue;
|
|
443
|
+
for (const [depName, spec] of Object.entries(deps)) {
|
|
444
|
+
if (scopedOverrideFor(ws, depName, extracted))
|
|
445
|
+
continue;
|
|
446
|
+
stillNeeded.add(`${depName}@${spec}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const dropped of droppedFromExternal) {
|
|
451
|
+
if (!stillNeeded.has(dropped))
|
|
452
|
+
externalSpecs.delete(dropped);
|
|
453
|
+
}
|
|
454
|
+
if (wsLocalSpecs.size > 0) {
|
|
455
|
+
console.log(`gjsify install: ${wsLocalSpecs.size} workspace(s) have scoped overrides — they will install their overridden deps locally after the root install`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
316
458
|
if (externalSpecs.size > 0) {
|
|
459
|
+
const progress = makeProgressReporter({
|
|
460
|
+
enabled: !args.verbose && !args.quiet && args.progress !== false,
|
|
461
|
+
});
|
|
317
462
|
await installPackages({
|
|
318
463
|
prefix: cwd,
|
|
319
464
|
specs: [...externalSpecs],
|
|
@@ -321,40 +466,90 @@ async function workspaceInstall(cwd, args) {
|
|
|
321
466
|
lockfile: !args.immutable,
|
|
322
467
|
frozen: args.immutable,
|
|
323
468
|
overrides,
|
|
469
|
+
signal,
|
|
470
|
+
progress,
|
|
324
471
|
});
|
|
325
472
|
}
|
|
326
473
|
else if (args.verbose) {
|
|
327
474
|
console.log('gjsify install: no external deps to fetch');
|
|
328
475
|
}
|
|
329
|
-
for
|
|
330
|
-
|
|
331
|
-
|
|
476
|
+
// Workspace-local installs for scoped overrides. Each runs as its own
|
|
477
|
+
// `installPackages` call inside the workspace location — the resulting
|
|
478
|
+
// `node_modules/<dep>` shadows the root-hoisted version via standard
|
|
479
|
+
// Node parent-walk resolution.
|
|
480
|
+
for (const [wsLocation, specSet] of wsLocalSpecs) {
|
|
481
|
+
if (specSet.size === 0)
|
|
332
482
|
continue;
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// a normal symlink left over from a previous install. Using
|
|
337
|
-
// `{ recursive: true, force: true }` handles every shape in one
|
|
338
|
-
// call: `rmSync` no-ops on missing paths under `force: true`, and
|
|
339
|
-
// `recursive: true` covers the directory case. Avoids the EEXIST
|
|
340
|
-
// race a previous lstat-then-branch version hit when the stat's
|
|
341
|
-
// type-discrimination missed an edge case (e.g. broken symlink
|
|
342
|
-
// whose `isSymbolicLink()` returned a non-truthy value through
|
|
343
|
-
// Gio's NOFOLLOW path, leaving a leftover entry that
|
|
344
|
-
// `symlinkSync` would then refuse to overwrite).
|
|
345
|
-
try {
|
|
346
|
-
rmSync(linkPath, { recursive: true, force: true });
|
|
483
|
+
const wsName = workspaces.find((w) => w.location === wsLocation)?.name ?? wsLocation;
|
|
484
|
+
if (args.verbose) {
|
|
485
|
+
console.log(`gjsify install: ${wsName} — installing ${specSet.size} scoped-override spec(s) into ${wsLocation}/node_modules/`);
|
|
347
486
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
487
|
+
await installPackages({
|
|
488
|
+
prefix: wsLocation,
|
|
489
|
+
specs: [...specSet],
|
|
490
|
+
verbose: args.verbose,
|
|
491
|
+
// Per-workspace installs get a thin lockfile next to the workspace
|
|
492
|
+
// package.json. Same `--immutable` semantics as the root install.
|
|
493
|
+
lockfile: !args.immutable,
|
|
494
|
+
frozen: args.immutable,
|
|
495
|
+
signal,
|
|
496
|
+
});
|
|
356
497
|
}
|
|
498
|
+
// Workspace symlink wiring — pre-dedup the parent-dir mkdirs (every
|
|
499
|
+
// symlink for the same workspace shares a single `node_modules` parent),
|
|
500
|
+
// then run the per-link rm + symlink steps with bounded concurrency.
|
|
501
|
+
// Pure sync loops here used to dominate the tail of large installs
|
|
502
|
+
// (~793 symlinks × ~10ms each for mkdir+rm+symlink = ~24s of serial
|
|
503
|
+
// syscalls). With async + a 32-wide pool the same set lands in 1-2s.
|
|
357
504
|
if (symlinks.length > 0) {
|
|
505
|
+
const fsp = await import('node:fs/promises');
|
|
506
|
+
const parentDirs = new Set();
|
|
507
|
+
const plans = [];
|
|
508
|
+
for (const link of symlinks) {
|
|
509
|
+
const target = byName.get(link.fromWorkspaceName);
|
|
510
|
+
if (!target)
|
|
511
|
+
continue;
|
|
512
|
+
const linkPath = join(target.location, 'node_modules', link.depName);
|
|
513
|
+
parentDirs.add(dirname(linkPath));
|
|
514
|
+
const relTarget = relative(dirname(linkPath), link.targetLocation);
|
|
515
|
+
plans.push({ linkPath, relTarget });
|
|
516
|
+
}
|
|
517
|
+
// Phase 1: one mkdir per unique parent (max ~213 instead of ~793).
|
|
518
|
+
await Promise.all([...parentDirs].map((dir) => fsp.mkdir(dir, { recursive: true })));
|
|
519
|
+
// Phase 2: per-link rm + symlink, pooled. A semaphore-style cursor
|
|
520
|
+
// keeps the concurrent in-flight count bounded so we don't blow up
|
|
521
|
+
// the file-descriptor table on huge monorepos.
|
|
522
|
+
const SYMLINK_CONCURRENCY = 32;
|
|
523
|
+
let cursor = 0;
|
|
524
|
+
const workers = [];
|
|
525
|
+
const wireOne = async (linkPath, relTarget) => {
|
|
526
|
+
// Remove any prior entry — regular dir, broken symlink, file, or
|
|
527
|
+
// a normal symlink left over from a previous install.
|
|
528
|
+
// `{ recursive: true, force: true }` handles every shape (rm
|
|
529
|
+
// no-ops on missing paths under force; recursive covers dirs).
|
|
530
|
+
try {
|
|
531
|
+
await fsp.rm(linkPath, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
/* unexpected; symlink call below will surface the real
|
|
535
|
+
cause if it persists. */
|
|
536
|
+
}
|
|
537
|
+
await fsp.symlink(relTarget, linkPath);
|
|
538
|
+
};
|
|
539
|
+
for (let i = 0; i < Math.min(SYMLINK_CONCURRENCY, plans.length); i++) {
|
|
540
|
+
workers.push((async () => {
|
|
541
|
+
while (true) {
|
|
542
|
+
const idx = cursor++;
|
|
543
|
+
if (idx >= plans.length)
|
|
544
|
+
return;
|
|
545
|
+
const p = plans[idx];
|
|
546
|
+
if (!p)
|
|
547
|
+
return;
|
|
548
|
+
await wireOne(p.linkPath, p.relTarget);
|
|
549
|
+
}
|
|
550
|
+
})());
|
|
551
|
+
}
|
|
552
|
+
await Promise.all(workers);
|
|
358
553
|
console.log(`gjsify install: wired ${symlinks.length} workspace symlink(s)`);
|
|
359
554
|
}
|
|
360
555
|
// Hoist EVERY workspace package to the repo root's `node_modules/` so
|
|
@@ -461,62 +656,75 @@ async function workspaceInstall(cwd, args) {
|
|
|
461
656
|
console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
|
|
462
657
|
}
|
|
463
658
|
}
|
|
464
|
-
/**
|
|
465
|
-
* Build a shell shim that prefers Node when its target file exists at
|
|
466
|
-
* invocation time, falling back to GJS otherwise. The runtime check is
|
|
467
|
-
* per-invocation (not at install time) so the same shim works both
|
|
468
|
-
* before and after the workspace's `lib/` has been built — a fresh
|
|
469
|
-
* checkout only has the committed `dist/cli.gjs.mjs`, while every
|
|
470
|
-
* subsequent `npm run build` produces `lib/index.js`.
|
|
471
|
-
*
|
|
472
|
-
* Both targets are absolute paths so the shim is portable across the
|
|
473
|
-
* different cwds that consumers (`yarn run`, `npm run`, direct PATH
|
|
474
|
-
* invocation) call us from.
|
|
475
|
-
*/
|
|
476
|
-
/**
|
|
477
|
-
* Flatten npm `overrides` or yarn `resolutions` into a bare name → range map.
|
|
478
|
-
*
|
|
479
|
-
* Supports two input shapes:
|
|
480
|
-
*
|
|
481
|
-
* "overrides": { "typescript": "~5.9.2" } (npm)
|
|
482
|
-
* "resolutions": { "typescript@*": "~5.9.2" } (yarn pattern)
|
|
483
|
-
*
|
|
484
|
-
* Pattern keys with a version glob (`name@*`, `name@^x`) are normalised to the
|
|
485
|
-
* bare name — gjsify's resolver doesn't yet support per-incoming-range
|
|
486
|
-
* scoping. Object-valued nested overrides (npm's per-parent shape, e.g.
|
|
487
|
-
* `"foo": { ".": "1.0", "bar": "2.0" }`) are intentionally ignored; they would
|
|
488
|
-
* silently misbehave without per-parent support, so we surface a warning
|
|
489
|
-
* instead of half-applying them.
|
|
490
|
-
*
|
|
491
|
-
* Keys beginning with `_` are skipped (convention for documentation entries
|
|
492
|
-
* like `"_comment_typescript"` used in the wild).
|
|
493
|
-
*/
|
|
494
659
|
function extractOverrides(rootManifest) {
|
|
495
660
|
if (!rootManifest)
|
|
496
661
|
return undefined;
|
|
497
|
-
const
|
|
662
|
+
const global = {};
|
|
663
|
+
const scoped = new Map();
|
|
498
664
|
const merge = (source, fieldName) => {
|
|
499
665
|
if (!source)
|
|
500
666
|
return;
|
|
501
667
|
for (const [key, value] of Object.entries(source)) {
|
|
502
668
|
if (key.startsWith('_'))
|
|
503
669
|
continue;
|
|
504
|
-
if (typeof value
|
|
505
|
-
|
|
670
|
+
if (typeof value === 'string') {
|
|
671
|
+
// Flat `name → range` entry. Normalise pattern keys (`name@*`,
|
|
672
|
+
// `name@^range`) → bare name. For scoped packages preserve the
|
|
673
|
+
// leading `@`.
|
|
674
|
+
let name = key;
|
|
675
|
+
const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
|
|
676
|
+
if (atIdx > 0)
|
|
677
|
+
name = key.slice(0, atIdx);
|
|
678
|
+
global[name] = value;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
682
|
+
// Scoped entry — `<workspace> → {dep → range}`. This is the
|
|
683
|
+
// npm-overrides nested shape and yarn's resolutions
|
|
684
|
+
// selectors collapsed to per-workspace level.
|
|
685
|
+
const sub = {};
|
|
686
|
+
for (const [depKey, depValue] of Object.entries(value)) {
|
|
687
|
+
if (depKey.startsWith('_'))
|
|
688
|
+
continue;
|
|
689
|
+
if (typeof depValue !== 'string') {
|
|
690
|
+
console.warn(`gjsify install: ${fieldName}["${key}"]["${depKey}"] is not a string — only one level of nesting is supported, skipping`);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
let depName = depKey;
|
|
694
|
+
const atIdx = depKey.startsWith('@') ? depKey.indexOf('@', 1) : depKey.indexOf('@');
|
|
695
|
+
if (atIdx > 0)
|
|
696
|
+
depName = depKey.slice(0, atIdx);
|
|
697
|
+
sub[depName] = depValue;
|
|
698
|
+
}
|
|
699
|
+
if (Object.keys(sub).length > 0) {
|
|
700
|
+
const existing = scoped.get(key) ?? {};
|
|
701
|
+
scoped.set(key, { ...existing, ...sub });
|
|
702
|
+
}
|
|
506
703
|
continue;
|
|
507
704
|
}
|
|
508
|
-
|
|
509
|
-
// For scoped packages preserve the leading `@`.
|
|
510
|
-
let name = key;
|
|
511
|
-
const atIdx = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
|
|
512
|
-
if (atIdx > 0)
|
|
513
|
-
name = key.slice(0, atIdx);
|
|
514
|
-
out[name] = value;
|
|
705
|
+
console.warn(`gjsify install: ${fieldName}["${key}"] is not a string or object — skipping`);
|
|
515
706
|
}
|
|
516
707
|
};
|
|
517
708
|
merge(rootManifest.overrides, 'overrides');
|
|
518
709
|
merge(rootManifest.resolutions, 'resolutions');
|
|
519
|
-
|
|
710
|
+
if (Object.keys(global).length === 0 && scoped.size === 0)
|
|
711
|
+
return undefined;
|
|
712
|
+
return { global, scoped };
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Look up the scoped override for a given workspace + dep. Matches by
|
|
716
|
+
* workspace name OR relative location (both accepted for readability).
|
|
717
|
+
*/
|
|
718
|
+
function scopedOverrideFor(ws, depName, extracted) {
|
|
719
|
+
if (!extracted || extracted.scoped.size === 0)
|
|
720
|
+
return undefined;
|
|
721
|
+
const candidates = [ws.name, ws.relativeLocation];
|
|
722
|
+
for (const key of candidates) {
|
|
723
|
+
const entry = extracted.scoped.get(key);
|
|
724
|
+
if (entry?.[depName])
|
|
725
|
+
return entry[depName];
|
|
726
|
+
}
|
|
727
|
+
return undefined;
|
|
520
728
|
}
|
|
521
729
|
function buildBinShim(wsLocation, nodeTarget, gjsTarget, nativePrebuildDirs = []) {
|
|
522
730
|
const nodeAbs = nodeTarget ? join(wsLocation, nodeTarget) : null;
|
|
@@ -668,4 +876,43 @@ async function runPostInstallChecks() {
|
|
|
668
876
|
}
|
|
669
877
|
console.log('\nUse `gjsify run <bundle>` to launch with LD_LIBRARY_PATH/GI_TYPELIB_PATH set.');
|
|
670
878
|
}
|
|
879
|
+
// 3. Install workspace git hooks (only fires inside the gjsify monorepo
|
|
880
|
+
// itself, NOT in consumer projects that depend on @gjsify/cli — gated
|
|
881
|
+
// by the presence of `scripts/install-git-hooks.mjs` + a `.git`
|
|
882
|
+
// checkout). Idempotent; safe to re-run on every install.
|
|
883
|
+
maybeInstallGitHooks();
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Wire `core.hooksPath = .githooks` when running `gjsify install` inside a
|
|
887
|
+
* git checkout that ships `scripts/install-git-hooks.mjs` (i.e. the gjsify
|
|
888
|
+
* monorepo). Consumer projects that don't ship the script are skipped
|
|
889
|
+
* silently — they wouldn't have hooks to install.
|
|
890
|
+
*
|
|
891
|
+
* The script itself handles its own no-op cases (extracted tarball, already
|
|
892
|
+
* configured, SKIP_GJSIFY_HOOKS=1).
|
|
893
|
+
*/
|
|
894
|
+
function maybeInstallGitHooks() {
|
|
895
|
+
const cwd = process.cwd();
|
|
896
|
+
const scriptPath = join(cwd, 'scripts', 'install-git-hooks.mjs');
|
|
897
|
+
if (!existsSync(scriptPath))
|
|
898
|
+
return;
|
|
899
|
+
// Need a git checkout — the script also checks, but skipping here
|
|
900
|
+
// avoids spawning a process when we know the answer.
|
|
901
|
+
if (!existsSync(join(cwd, '.git')))
|
|
902
|
+
return;
|
|
903
|
+
try {
|
|
904
|
+
const result = spawnSync(process.execPath, [scriptPath, '--quiet'], {
|
|
905
|
+
cwd,
|
|
906
|
+
stdio: 'inherit',
|
|
907
|
+
env: process.env,
|
|
908
|
+
});
|
|
909
|
+
if (result.status !== 0) {
|
|
910
|
+
console.warn(`[gjsify install] scripts/install-git-hooks.mjs exited ${result.status} — git hooks may not be active.`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
// Hook installation is a quality-of-life touchup, not a hard install
|
|
915
|
+
// requirement. Never let it abort the surrounding install.
|
|
916
|
+
console.warn(`[gjsify install] git hook installation skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
917
|
+
}
|
|
671
918
|
}
|
package/lib/commands/publish.js
CHANGED
|
@@ -33,12 +33,13 @@
|
|
|
33
33
|
// Source: documented in https://docs.npmjs.com/cli/v10/commands/npm-publish
|
|
34
34
|
// and npm's @npmcli/registry-fetch internals — verified against npm's
|
|
35
35
|
// in-the-wild publish payloads.
|
|
36
|
-
import {
|
|
37
|
-
import { homedir } from 'node:os';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
38
37
|
import { join, resolve } from 'node:path';
|
|
39
|
-
import { DEFAULT_REGISTRY,
|
|
38
|
+
import { DEFAULT_REGISTRY, registryFor, buildHeaders } from '@gjsify/npm-registry';
|
|
40
39
|
import { packWorkspace } from './pack.js';
|
|
41
40
|
import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
|
|
41
|
+
import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
|
|
42
|
+
import { loadNpmrc } from '../utils/load-npmrc.js';
|
|
42
43
|
export const publishCommand = {
|
|
43
44
|
command: 'publish [path]',
|
|
44
45
|
description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
|
|
@@ -403,6 +404,38 @@ export const publishCommand = {
|
|
|
403
404
|
process.stdout.write(`= ${packed.name}@${packed.version} (already published, tolerated)\n`);
|
|
404
405
|
return;
|
|
405
406
|
}
|
|
407
|
+
// 404 diagnostic — token-auth only. npm returns 404 for both a
|
|
408
|
+
// dead `_authToken` and a genuinely-missing package; `/-/whoami`
|
|
409
|
+
// disambiguates. OIDC has its own clear error surfaces (handled in
|
|
410
|
+
// the OIDC catch block above) and `--otp` flows take a different
|
|
411
|
+
// 401 path, so the diagnostic only kicks in for the plain
|
|
412
|
+
// token-auth PUT signature.
|
|
413
|
+
if (res.status === 404 && authMode === 'token' && !otp) {
|
|
414
|
+
if (is404DiagnosticCandidate(text)) {
|
|
415
|
+
const diag = await diagnose404({
|
|
416
|
+
packageName: packed.name,
|
|
417
|
+
version: packed.version,
|
|
418
|
+
registry: registryClean,
|
|
419
|
+
npmrc,
|
|
420
|
+
});
|
|
421
|
+
if (diag.reason !== 'unknown') {
|
|
422
|
+
if (args.json) {
|
|
423
|
+
process.stdout.write(`${JSON.stringify({
|
|
424
|
+
ok: false,
|
|
425
|
+
name: packed.name,
|
|
426
|
+
version: packed.version,
|
|
427
|
+
status: 404,
|
|
428
|
+
diagnostic: diag.reason,
|
|
429
|
+
username: diag.username,
|
|
430
|
+
}, null, 2)}\n`);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
process.stderr.write(`${diag.message}\n`);
|
|
434
|
+
}
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
406
439
|
console.error(`gjsify publish: ${packed.name}@${packed.version} — ${res.status} ${res.statusText}`);
|
|
407
440
|
console.error(text);
|
|
408
441
|
process.exit(1);
|
|
@@ -460,38 +493,6 @@ async function loadRewrittenManifest(wsDir, pkg) {
|
|
|
460
493
|
}
|
|
461
494
|
return pkg;
|
|
462
495
|
}
|
|
463
|
-
async function loadNpmrc(cwd) {
|
|
464
|
-
// npm CLI's npmrc resolution order (lowest → highest precedence):
|
|
465
|
-
// 1. globalconfig: /etc/npmrc (system)
|
|
466
|
-
// 2. userconfig: $NPM_CONFIG_USERCONFIG (overrides ~/.npmrc)
|
|
467
|
-
// or ~/.npmrc (default)
|
|
468
|
-
// 3. projectconfig: ./.npmrc (closest)
|
|
469
|
-
//
|
|
470
|
-
// actions/setup-node writes the auth-token npmrc to $RUNNER_TEMP/.npmrc
|
|
471
|
-
// and exports NPM_CONFIG_USERCONFIG pointing at it — it does NOT touch
|
|
472
|
-
// ~/.npmrc. Honor the env var so CI authentication works end-to-end.
|
|
473
|
-
const sources = [];
|
|
474
|
-
const projectNpmrc = join(cwd, '.npmrc');
|
|
475
|
-
if (existsSync(projectNpmrc))
|
|
476
|
-
sources.push(readFileSync(projectNpmrc, 'utf-8'));
|
|
477
|
-
const userConfig = process.env.NPM_CONFIG_USERCONFIG;
|
|
478
|
-
if (userConfig && existsSync(userConfig)) {
|
|
479
|
-
sources.push(readFileSync(userConfig, 'utf-8'));
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
const homeNpmrc = join(homedir(), '.npmrc');
|
|
483
|
-
if (existsSync(homeNpmrc))
|
|
484
|
-
sources.push(readFileSync(homeNpmrc, 'utf-8'));
|
|
485
|
-
}
|
|
486
|
-
// Inline `${VAR}` placeholders (npm CLI's expand-on-read behavior).
|
|
487
|
-
// The auth-token npmrc from actions/setup-node ships
|
|
488
|
-
// `_authToken=${NODE_AUTH_TOKEN}` as a literal placeholder; the env var
|
|
489
|
-
// is set on the publish step.
|
|
490
|
-
const merged = sources
|
|
491
|
-
.join('\n')
|
|
492
|
-
.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/gi, (_, name) => process.env[name] ?? '');
|
|
493
|
-
return parseNpmrc(merged);
|
|
494
|
-
}
|
|
495
496
|
function buildPublishPayload(opts) {
|
|
496
497
|
const { pkg, tag, access, tarballBytes, tarballUrl, packed, provenance } = opts;
|
|
497
498
|
const versionEntry = {
|