@gjsify/cli 0.48.0 → 0.50.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.
- package/dist/cli.gjs.mjs +165 -157
- package/lib/actions/barrels-generate.d.ts +22 -0
- package/lib/actions/barrels-generate.js +36 -1
- package/lib/actions/build.js +10 -6
- package/lib/commands/barrels.js +19 -2
- package/lib/commands/build.js +4 -0
- package/lib/commands/foreach.js +11 -19
- package/lib/commands/install.js +21 -1
- package/lib/commands/test.d.ts +16 -0
- package/lib/commands/test.js +30 -29
- package/lib/commands/upgrade.js +78 -30
- package/lib/commands/workspace.js +47 -44
- package/lib/config.js +13 -0
- package/lib/index.js +10 -0
- package/lib/types/cli-build-options.d.ts +6 -0
- package/lib/types/config-data.d.ts +14 -0
- package/lib/utils/build-cache.d.ts +15 -4
- package/lib/utils/build-cache.js +62 -67
- package/lib/utils/cli-exit.d.ts +23 -0
- package/lib/utils/cli-exit.js +78 -0
- package/lib/utils/detect-native-packages.d.ts +31 -0
- package/lib/utils/detect-native-packages.js +37 -1
- package/lib/utils/package-inputs.d.ts +81 -0
- package/lib/utils/package-inputs.js +233 -0
- package/lib/utils/ship/changelog.d.ts +15 -14
- package/lib/utils/ship/changelog.js +14 -13
- package/lib/utils/ship/formats.js +23 -2
- package/lib/utils/ship/gzip.d.ts +31 -2
- package/lib/utils/ship/gzip.js +55 -3
- package/lib/utils/ship/plan.d.ts +32 -4
- package/lib/utils/ship/plan.js +61 -10
- package/lib/utils/simple-command.d.ts +17 -2
- package/lib/utils/simple-command.js +9 -4
- package/package.json +19 -19
|
@@ -21,9 +21,31 @@ export interface BarrelsArgs {
|
|
|
21
21
|
}
|
|
22
22
|
export declare const DEFAULT_BARRELS_HEADER = "// Auto-generated by `gjsify barrels` \u2014 do not edit by hand.";
|
|
23
23
|
export declare const DEFAULT_BARRELS_EXCLUDES: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* The entries of `paths` that cannot be scanned as a directory, resolved against `baseDir`
|
|
26
|
+
* — each already phrased for printing.
|
|
27
|
+
*
|
|
28
|
+
* A SEPARATE pass rather than a behaviour change inside {@link generateBarrels}, because
|
|
29
|
+
* the two callers want opposite things. A programmatic caller may legitimately hand the
|
|
30
|
+
* generator a directory that is not there yet and still want the others written, which is
|
|
31
|
+
* why it skips; a COMMAND-LINE caller named these directories, so one that cannot be read
|
|
32
|
+
* is a typo or a rename and nothing else. So the command asserts first — the shape
|
|
33
|
+
* `assertEveryIncludeMatches` already has for `gjsify foreach --include`.
|
|
34
|
+
*
|
|
35
|
+
* THE INCIDENT: `gjsify barrels --check` counted a directory it could not read as 0 drift
|
|
36
|
+
* and exited 0, so the guard reported a clean barrel for a directory nothing had looked
|
|
37
|
+
* at, and the only trace was a line printed under `--verbose`. "Skipped" and "in sync"
|
|
38
|
+
* have to be told apart by the exit code — a guard that goes green for work it never did
|
|
39
|
+
* is worse than no guard, because a later step is measuring its output.
|
|
40
|
+
*/
|
|
41
|
+
export declare function unscannableBarrelPaths(baseDir: string, paths: readonly string[]): Promise<string[]>;
|
|
24
42
|
/**
|
|
25
43
|
* Regenerate `index.ts` in every directory in `args.paths`.
|
|
26
44
|
*
|
|
45
|
+
* A path that cannot be read is SKIPPED here, deliberately — see
|
|
46
|
+
* {@link unscannableBarrelPaths} for who refuses it instead, and why that is the command
|
|
47
|
+
* and not this.
|
|
48
|
+
*
|
|
27
49
|
* Returns the number of files that drifted from the canonical output —
|
|
28
50
|
* always 0 when `check` is false (drift is rewritten in-place); non-zero
|
|
29
51
|
* under `check: true` signals CI failure.
|
|
@@ -12,14 +12,49 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Original: Copyright (c) Beabee Community Repo contributors. AGPL-3.0.
|
|
14
14
|
// Reimplemented for gjsify under the project's MIT license.
|
|
15
|
-
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { basename, extname, join, resolve } from 'node:path';
|
|
17
17
|
export const DEFAULT_BARRELS_HEADER = '// Auto-generated by `gjsify barrels` — do not edit by hand.';
|
|
18
18
|
export const DEFAULT_BARRELS_EXCLUDES = ['\\.test\\.', '\\.spec\\.', '\\.test-data\\.'];
|
|
19
19
|
const SOURCE_FILE_RE = /\.(ts|tsx|mts|cts)$/;
|
|
20
|
+
/**
|
|
21
|
+
* The entries of `paths` that cannot be scanned as a directory, resolved against `baseDir`
|
|
22
|
+
* — each already phrased for printing.
|
|
23
|
+
*
|
|
24
|
+
* A SEPARATE pass rather than a behaviour change inside {@link generateBarrels}, because
|
|
25
|
+
* the two callers want opposite things. A programmatic caller may legitimately hand the
|
|
26
|
+
* generator a directory that is not there yet and still want the others written, which is
|
|
27
|
+
* why it skips; a COMMAND-LINE caller named these directories, so one that cannot be read
|
|
28
|
+
* is a typo or a rename and nothing else. So the command asserts first — the shape
|
|
29
|
+
* `assertEveryIncludeMatches` already has for `gjsify foreach --include`.
|
|
30
|
+
*
|
|
31
|
+
* THE INCIDENT: `gjsify barrels --check` counted a directory it could not read as 0 drift
|
|
32
|
+
* and exited 0, so the guard reported a clean barrel for a directory nothing had looked
|
|
33
|
+
* at, and the only trace was a line printed under `--verbose`. "Skipped" and "in sync"
|
|
34
|
+
* have to be told apart by the exit code — a guard that goes green for work it never did
|
|
35
|
+
* is worse than no guard, because a later step is measuring its output.
|
|
36
|
+
*/
|
|
37
|
+
export async function unscannableBarrelPaths(baseDir, paths) {
|
|
38
|
+
const problems = [];
|
|
39
|
+
for (const p of paths) {
|
|
40
|
+
const dir = resolve(baseDir, p);
|
|
41
|
+
try {
|
|
42
|
+
if (!(await stat(dir)).isDirectory())
|
|
43
|
+
problems.push(`${dir} is not a directory`);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
problems.push(`${dir}: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return problems;
|
|
50
|
+
}
|
|
20
51
|
/**
|
|
21
52
|
* Regenerate `index.ts` in every directory in `args.paths`.
|
|
22
53
|
*
|
|
54
|
+
* A path that cannot be read is SKIPPED here, deliberately — see
|
|
55
|
+
* {@link unscannableBarrelPaths} for who refuses it instead, and why that is the command
|
|
56
|
+
* and not this.
|
|
57
|
+
*
|
|
23
58
|
* Returns the number of files that drifted from the canonical output —
|
|
24
59
|
* always 0 when `check` is false (drift is rewritten in-place); non-zero
|
|
25
60
|
* under `check: true` signals CI failure.
|
package/lib/actions/build.js
CHANGED
|
@@ -15,6 +15,7 @@ import { collectEntryPaths, describeJsxConfig, findJsxEntryPoint, jsxConfigMissi
|
|
|
15
15
|
import { giSystemProbes } from '../utils/gi-runtime-paths.js';
|
|
16
16
|
import { escapeRawNulForGjs } from '../utils/gjs-source-escape.js';
|
|
17
17
|
import { assertNodeBundleGlobalsShimmed } from '../utils/node-bundle-guard.js';
|
|
18
|
+
import { DEP_CHANGE_LOCKFILES } from '../utils/package-inputs.js';
|
|
18
19
|
const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
|
|
19
20
|
/**
|
|
20
21
|
* `true` when `path` points at a location that's unsafe to use as a build
|
|
@@ -59,16 +60,18 @@ function isTruthyEnv(v) {
|
|
|
59
60
|
return v === '1' || v === 'true' || v === 'yes';
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
+
* Newest mtime (ms) among known lockfiles in `cwd`, or 0 when none exist.
|
|
64
|
+
*
|
|
63
65
|
* A dep bump (`npm/yarn/pnpm/gjsify install`) rewrites one of these but leaves
|
|
64
66
|
* the plugin's own entry-file mtime untouched — so the plugin GJS-bundle cache
|
|
65
|
-
* must invalidate on
|
|
67
|
+
* must invalidate on them too, not only on the entry. The LIST is
|
|
68
|
+
* {@link DEP_CHANGE_LOCKFILES}, shared with the package-input definition: two
|
|
69
|
+
* answers to "has the dependency tree moved" is how they come to disagree
|
|
70
|
+
* about a fifth package manager.
|
|
66
71
|
*/
|
|
67
|
-
const PLUGIN_CACHE_DEP_SIGNALS = ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
68
|
-
/** Newest mtime (ms) among known lockfiles in `cwd`, or 0 when none exist. */
|
|
69
72
|
async function newestLockfileMtime(cwd) {
|
|
70
73
|
let newest = 0;
|
|
71
|
-
for (const name of
|
|
74
|
+
for (const name of DEP_CHANGE_LOCKFILES) {
|
|
72
75
|
try {
|
|
73
76
|
const s = await stat(join(cwd, name));
|
|
74
77
|
if (s.mtimeMs > newest)
|
|
@@ -630,7 +633,7 @@ export class BuildAction {
|
|
|
630
633
|
throw new Error('gjsify build: an .html entry needs an explicit --outfile ' +
|
|
631
634
|
'(e.g. --outfile dist-app/app.js); the emitted index.html is written beside it.');
|
|
632
635
|
}
|
|
633
|
-
const { consoleShim, globals, dialect } = this.configData;
|
|
636
|
+
const { consoleShim, globals, dialect, giRenderer } = this.configData;
|
|
634
637
|
const userExternal = Array.isArray(userBundler.external) ? userBundler.external : undefined;
|
|
635
638
|
const userBanner = typeof userBundler.output?.banner === 'string' ? userBundler.output.banner : undefined;
|
|
636
639
|
const pluginOpts = {
|
|
@@ -641,6 +644,7 @@ export class BuildAction {
|
|
|
641
644
|
reflection: typescript?.reflection,
|
|
642
645
|
consoleShim,
|
|
643
646
|
...(dialect !== undefined ? { dialect } : {}),
|
|
647
|
+
...(giRenderer === true ? { giRenderer: true } : {}),
|
|
644
648
|
...(aliases ? { aliases } : {}),
|
|
645
649
|
...(opts.preserveDefaultExport ? { preserveDefaultExport: true } : {}),
|
|
646
650
|
...(opts.toolchainAnchor !== undefined ? { toolchainAnchor: opts.toolchainAnchor } : {}),
|
package/lib/commands/barrels.js
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
// file as a module.
|
|
14
14
|
// - Output is sorted by file name (locale-compare) — deterministic diffs.
|
|
15
15
|
// - `--check` exits non-zero on drift without writing (pre-commit / CI use).
|
|
16
|
-
|
|
16
|
+
// - A named path that is not a readable directory is REFUSED before anything
|
|
17
|
+
// is generated: skipping it would let `--check` report 0 drift, and exit 0,
|
|
18
|
+
// for a barrel nothing ever looked at.
|
|
19
|
+
import { DEFAULT_BARRELS_EXCLUDES, DEFAULT_BARRELS_HEADER, generateBarrels, unscannableBarrelPaths, } from '../actions/barrels-generate.js';
|
|
17
20
|
export const barrelsCommand = {
|
|
18
21
|
command: 'barrels [paths..]',
|
|
19
22
|
description: 'Regenerate `index.ts` barrel files for the given directories. Drop-in replacement for `barrelsby`.',
|
|
@@ -81,13 +84,27 @@ export const barrelsCommand = {
|
|
|
81
84
|
process.exitCode = 1;
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
87
|
+
const baseDir = args.baseDir ?? process.cwd();
|
|
88
|
+
// A named path this cannot scan is a typo or a rename, and it is refused BEFORE
|
|
89
|
+
// anything is generated. The generator skips such a path and reports no drift for
|
|
90
|
+
// it, which under `--check` is a guard going green for a directory nothing looked
|
|
91
|
+
// at — the one failure mode a guard must not have.
|
|
92
|
+
const unscannable = await unscannableBarrelPaths(baseDir, paths);
|
|
93
|
+
if (unscannable.length > 0) {
|
|
94
|
+
for (const problem of unscannable)
|
|
95
|
+
console.error(`[gjsify barrels] cannot scan ${problem}`);
|
|
96
|
+
console.error(`[gjsify barrels] ${unscannable.length} of ${paths.length} path(s) could not be scanned — ` +
|
|
97
|
+
'refusing to report on the rest as if that were the whole set.');
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
84
101
|
const excludePatterns = args.exclude?.length
|
|
85
102
|
? args.exclude
|
|
86
103
|
: [...DEFAULT_BARRELS_EXCLUDES];
|
|
87
104
|
const drift = await generateBarrels({
|
|
88
105
|
paths,
|
|
89
106
|
extension: args.ext ?? 'none',
|
|
90
|
-
baseDir
|
|
107
|
+
baseDir,
|
|
91
108
|
exclude: excludePatterns.map((src) => new RegExp(src)),
|
|
92
109
|
header: args.header ?? DEFAULT_BARRELS_HEADER,
|
|
93
110
|
noSemicolon: args.semicolon === false || args.semicolon === undefined,
|
package/lib/commands/build.js
CHANGED
|
@@ -112,6 +112,10 @@ export const buildCommand = {
|
|
|
112
112
|
description: "The dialect the SOURCE is written in. 'react-native' aliases 'react-native' onto '@gjsify/react-native' and fails the build on an import whose ADR 0032 support-table status is not supported/partial. GJS and Node app builds. This is NOT gjsify.runtimes['react-native'], which declares the runtime a package RUNS on — the two answer opposite questions.",
|
|
113
113
|
type: 'string',
|
|
114
114
|
choices: [...SOURCE_DIALECTS],
|
|
115
|
+
})
|
|
116
|
+
.option('gi-renderer', {
|
|
117
|
+
description: "Resolve `gi://Ns?version=X` to the target's widget renderer instead of an empty module (ADR 0034 stage 9): --app browser answers gi://Adw and gi://Gtk out of @gjsify/adwaita-web, --app nativescript out of @gjsify/adwaita-nativescript. A namespace with no renderer, and a ?version= the renderer's vocabulary was not generated against, both FAIL the build by name. Off by default — a gi:// import on those targets is usually a GJS-only code path pulled in transitively.",
|
|
118
|
+
type: 'boolean',
|
|
115
119
|
})
|
|
116
120
|
.option('console-shim', {
|
|
117
121
|
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.',
|
package/lib/commands/foreach.js
CHANGED
|
@@ -583,25 +583,17 @@ async function runParallel(workspaces, script, args, concurrency, verbose, exec,
|
|
|
583
583
|
await Promise.all(workers);
|
|
584
584
|
}
|
|
585
585
|
async function runTopologicalParallel(workspaces, script, args, concurrency, verbose, includeDev, exec, cache) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (!spec.startsWith('workspace:'))
|
|
598
|
-
continue;
|
|
599
|
-
if (selectedNames.has(name))
|
|
600
|
-
wsDeps.add(name);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
remaining.set(ws.name, wsDeps);
|
|
604
|
-
}
|
|
586
|
+
// The SHARED graph, walked once — never a private copy of the walk. `buildDependencyGraph`
|
|
587
|
+
// over the SELECTED set yields exactly these edges: it indexes by name from the list it is
|
|
588
|
+
// handed, so a dependency outside the selection resolves to no member and is not an edge.
|
|
589
|
+
//
|
|
590
|
+
// The copy this replaces followed `workspace:` only. Once #1587 widened the shared rule to
|
|
591
|
+
// include a plain range the local member satisfies, the two topological legs would have
|
|
592
|
+
// DISAGREED — `-t` ordering correctly off the shared graph while `-t -p` saw no edges at
|
|
593
|
+
// all and started every workspace at once, in a monorepo that declares its local deps by
|
|
594
|
+
// plain semver range. Wrong order, exit 0, and a build measured by a later step: the same
|
|
595
|
+
// shape #1587 is about, one flag over.
|
|
596
|
+
const remaining = new Map([...buildDependencyGraph(workspaces, { includeDev }).edges].map(([name, deps]) => [name, new Set(deps)]));
|
|
605
597
|
const byName = new Map(workspaces.map((w) => [w.name, w]));
|
|
606
598
|
const total = workspaces.length;
|
|
607
599
|
const done = new Set();
|
package/lib/commands/install.js
CHANGED
|
@@ -23,7 +23,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
23
23
|
import { forceExit } from '../utils/force-exit.js';
|
|
24
24
|
import { discoverWorkspaces } from '@gjsify/workspace';
|
|
25
25
|
import { buildInstallCommand, detectPackageManager, runMinimalChecks } from '../utils/check-system-deps.js';
|
|
26
|
-
import { detectNativePackages } from '../utils/detect-native-packages.js';
|
|
26
|
+
import { detectHostLibc, detectNativePackages, muslPrebuildFallbacks } from '../utils/detect-native-packages.js';
|
|
27
27
|
import { buildLauncherShims, buildNativeEnvPreamble } from '../utils/bin-shim.js';
|
|
28
28
|
import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
|
|
29
29
|
import { atomicWriteStrict } from '../utils/install-cache-fs.js';
|
|
@@ -1339,6 +1339,26 @@ async function runPostInstallChecks(args) {
|
|
|
1339
1339
|
console.log(` • ${pkg.name}`);
|
|
1340
1340
|
}
|
|
1341
1341
|
console.log('\nUse `gjsify run <bundle>` to launch with LD_LIBRARY_PATH/GI_TYPELIB_PATH set.');
|
|
1342
|
+
// The install is the ONLY moment this is knowable: the libc axis is decided
|
|
1343
|
+
// here, the failure it predicts happens in another process, days later, and
|
|
1344
|
+
// names something else entirely (see `muslPrebuildFallbacks`). The list is
|
|
1345
|
+
// the point — it is what lets a later `Error relocating` line be matched to
|
|
1346
|
+
// a package.
|
|
1347
|
+
const fellBack = muslPrebuildFallbacks(detectHostLibc(process.platform), native);
|
|
1348
|
+
if (fellBack.length > 0) {
|
|
1349
|
+
console.warn(`\nWarning: musl host — ${fellBack.length} of ${native.length} package(s) ship no \`-musl\`` +
|
|
1350
|
+
' prebuild, so the\n default (glibc) build was used:');
|
|
1351
|
+
for (const name of fellBack)
|
|
1352
|
+
console.warn(` ${name}`);
|
|
1353
|
+
console.warn(' Most of these run anyway: a bridge that links only GLib records no libc at all.\n' +
|
|
1354
|
+
' One that links glibc loads too — musl aliases `libc.so.6` to itself — and then\n' +
|
|
1355
|
+
' fails at dlopen on its first glibc-only symbol:\n' +
|
|
1356
|
+
' Error relocating <prebuild>.so: <symbol>: symbol not found\n' +
|
|
1357
|
+
' A bridge lost that way is silently absent from the next build rather than\n' +
|
|
1358
|
+
' reported, so the first symptom can be an unrelated-looking bundler error\n' +
|
|
1359
|
+
' (`Could not load <file>` from rolldown, for the CSS bridge). Nothing to\n' +
|
|
1360
|
+
' install: no `-musl` package is published for any target yet.');
|
|
1361
|
+
}
|
|
1342
1362
|
}
|
|
1343
1363
|
maybeInstallGitHooks();
|
|
1344
1364
|
}
|
package/lib/commands/test.d.ts
CHANGED
|
@@ -9,4 +9,20 @@ interface TestOptions {
|
|
|
9
9
|
verbose?: boolean;
|
|
10
10
|
}
|
|
11
11
|
export declare const testCommand: Command<unknown, TestOptions>;
|
|
12
|
+
/**
|
|
13
|
+
* True when `outfile` is at least as new as every build input of the package
|
|
14
|
+
* at `cwd`.
|
|
15
|
+
*
|
|
16
|
+
* The set is {@link packageBuildInputs}, the same one the build cache hashes.
|
|
17
|
+
* It used to be `dirname(entry)` — `tests/` for any package whose suite lives
|
|
18
|
+
* there, so `src/**` was never walked: a change to the code UNDER TEST left
|
|
19
|
+
* the bundle "fresh" and `gjsify test` reran the previous build and reported
|
|
20
|
+
* on it. That went both ways inside one dependency bump — a green run
|
|
21
|
+
* measuring a version that was no longer installed, then a red one after the
|
|
22
|
+
* source had already been repaired (#1651).
|
|
23
|
+
*
|
|
24
|
+
* `outdir` is subtracted as this command's own output; `entry` is added
|
|
25
|
+
* because `--entry ../shared/test.mts` may point outside the package.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isFresh(outfile: string, entry: string, cwd: string, outdir: string): boolean;
|
|
12
28
|
export {};
|
package/lib/commands/test.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// just needs `src/test.mts` aggregating its `@gjsify/unit` suites; this
|
|
6
6
|
// command builds it for GJS + Node and runs each output, aggregating
|
|
7
7
|
// exit codes.
|
|
8
|
-
import { existsSync, statSync
|
|
9
|
-
import { join,
|
|
8
|
+
import { existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { join, resolve, relative, sep } from 'node:path';
|
|
10
|
+
import { newestDepSignalMtimeMs, newestInputMtimeMs, packageBuildInputs } from '../utils/package-inputs.js';
|
|
10
11
|
import { isRuntimeAvailable, RUNTIMES } from '../utils/runtimes.js';
|
|
11
12
|
import { nodeBinary } from '../utils/run-node.js';
|
|
12
13
|
import { describeExit, spawnToCompletion } from '../utils/spawn.js';
|
|
@@ -99,7 +100,7 @@ export const testCommand = {
|
|
|
99
100
|
const outfile = join(outdir, `test.${runtime}.mjs`);
|
|
100
101
|
// Build stage (skip if --no-build OR (not --rebuild AND outfile fresher than src)).
|
|
101
102
|
if (args.build !== false) {
|
|
102
|
-
const needsBuild = args.rebuild || !isFresh(outfile, entry);
|
|
103
|
+
const needsBuild = args.rebuild || !isFresh(outfile, entry, cwd, outdir);
|
|
103
104
|
if (needsBuild) {
|
|
104
105
|
const buildStart = Date.now();
|
|
105
106
|
if (args.verbose) {
|
|
@@ -204,16 +205,35 @@ async function runTestBundle(outfile, runtime) {
|
|
|
204
205
|
if (result.code !== 0)
|
|
205
206
|
throw new Error(`node exited with ${describeExit(result)}`);
|
|
206
207
|
}
|
|
207
|
-
/**
|
|
208
|
-
|
|
208
|
+
/**
|
|
209
|
+
* True when `outfile` is at least as new as every build input of the package
|
|
210
|
+
* at `cwd`.
|
|
211
|
+
*
|
|
212
|
+
* The set is {@link packageBuildInputs}, the same one the build cache hashes.
|
|
213
|
+
* It used to be `dirname(entry)` — `tests/` for any package whose suite lives
|
|
214
|
+
* there, so `src/**` was never walked: a change to the code UNDER TEST left
|
|
215
|
+
* the bundle "fresh" and `gjsify test` reran the previous build and reported
|
|
216
|
+
* on it. That went both ways inside one dependency bump — a green run
|
|
217
|
+
* measuring a version that was no longer installed, then a red one after the
|
|
218
|
+
* source had already been repaired (#1651).
|
|
219
|
+
*
|
|
220
|
+
* `outdir` is subtracted as this command's own output; `entry` is added
|
|
221
|
+
* because `--entry ../shared/test.mts` may point outside the package.
|
|
222
|
+
*/
|
|
223
|
+
export function isFresh(outfile, entry, cwd, outdir) {
|
|
209
224
|
if (!existsSync(outfile))
|
|
210
225
|
return false;
|
|
211
|
-
const outMtime = statSync(outfile).mtimeMs;
|
|
212
|
-
const srcRoot = dirname(entry);
|
|
213
|
-
// Conservative: walk the src tree once. If the package has no `src/`,
|
|
214
|
-
// fall back to entry-only check.
|
|
215
226
|
try {
|
|
216
|
-
const
|
|
227
|
+
const outMtime = statSync(outfile).mtimeMs;
|
|
228
|
+
const extraOutputs = [];
|
|
229
|
+
const outdirRel = relative(cwd, outdir).split(sep).join('/');
|
|
230
|
+
if (outdirRel !== '' && !outdirRel.startsWith('../'))
|
|
231
|
+
extraOutputs.push(outdirRel);
|
|
232
|
+
const newest = Math.max(newestInputMtimeMs(packageBuildInputs(cwd, { extraOutputs })), statSync(entry).mtimeMs,
|
|
233
|
+
// A dependency bump changes no file in the package — and that is
|
|
234
|
+
// the shape #1651 was found in. `node_modules` is far too large
|
|
235
|
+
// to stat per run, so the lockfile stands in for it.
|
|
236
|
+
newestDepSignalMtimeMs(cwd));
|
|
217
237
|
return outMtime >= newest;
|
|
218
238
|
}
|
|
219
239
|
catch {
|
|
@@ -221,22 +241,3 @@ function isFresh(outfile, entry) {
|
|
|
221
241
|
return false;
|
|
222
242
|
}
|
|
223
243
|
}
|
|
224
|
-
function newestMtimeUnder(path) {
|
|
225
|
-
const st = statSync(path);
|
|
226
|
-
if (st.isFile())
|
|
227
|
-
return st.mtimeMs;
|
|
228
|
-
let max = st.mtimeMs;
|
|
229
|
-
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
230
|
-
if (entry.name === 'node_modules' ||
|
|
231
|
-
entry.name === 'dist' ||
|
|
232
|
-
entry.name === 'lib' ||
|
|
233
|
-
entry.name.startsWith('.')) {
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
const child = join(path, entry.name);
|
|
237
|
-
const m = newestMtimeUnder(child);
|
|
238
|
-
if (m > max)
|
|
239
|
-
max = m;
|
|
240
|
-
}
|
|
241
|
-
return max;
|
|
242
|
-
}
|
package/lib/commands/upgrade.js
CHANGED
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
// matching packages automatically without prompting; same selection
|
|
13
13
|
// logic as above but no UI loop.
|
|
14
14
|
//
|
|
15
|
-
// 3. `--align`: offline
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// 3. `--align`: offline repair mode. Finds deps declared at multiple ranges
|
|
16
|
+
// across the workspace and proposes a single range (the highest declared)
|
|
17
|
+
// — no registry calls. With `--exact` it repairs the second question too:
|
|
18
|
+
// a dep every manifest declares identically as `^4.1.0` is consistent and
|
|
19
|
+
// still carries an operator, so the operator goes as well.
|
|
18
20
|
//
|
|
19
|
-
// 4. `--check`: CI gate. Exits non-zero when any inconsistency exists
|
|
21
|
+
// 4. `--check`: CI gate. Exits non-zero when any inconsistency exists; with
|
|
22
|
+
// `--exact`, also when any matched declaration carries a range operator.
|
|
23
|
+
// Whatever `--check` rejects, `--align` with the same flags repairs.
|
|
20
24
|
//
|
|
21
25
|
// Filters:
|
|
22
26
|
//
|
|
@@ -36,7 +40,7 @@ import { parse } from '@gjsify/semver';
|
|
|
36
40
|
import { DEFAULT_REGISTRY, fetchPackument, parseNpmrc } from '@gjsify/npm-registry';
|
|
37
41
|
import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
|
|
38
42
|
import { findWorkspaceRoot } from '../utils/workspace-root.js';
|
|
39
|
-
import { groupByDependency, findInconsistencies, } from '../utils/dep-aggregation.js';
|
|
43
|
+
import { groupByDependency, findInconsistencies, isInconsistent, } from '../utils/dep-aggregation.js';
|
|
40
44
|
export const upgradeCommand = {
|
|
41
45
|
command: 'upgrade',
|
|
42
46
|
description: 'Workspace-aware dependency upgrades. Walks every package.json in the monorepo, groups by dep, flags inconsistencies. `--latest` / `--minor` / `--patch` for bulk-mode, `--align` for offline consistency-only mode, `--check` for CI drift detection.',
|
|
@@ -71,7 +75,7 @@ export const upgradeCommand = {
|
|
|
71
75
|
type: 'string',
|
|
72
76
|
})
|
|
73
77
|
.option('align', {
|
|
74
|
-
description: 'Offline
|
|
78
|
+
description: 'Offline repair mode: find deps declared at multiple ranges across the workspace and align them to the highest. With `--exact`, also drop the range operator from declarations that already agree. No registry calls.',
|
|
75
79
|
type: 'boolean',
|
|
76
80
|
default: false,
|
|
77
81
|
})
|
|
@@ -81,7 +85,7 @@ export const upgradeCommand = {
|
|
|
81
85
|
default: false,
|
|
82
86
|
})
|
|
83
87
|
.option('exact', {
|
|
84
|
-
description: 'Pin without a range operator. When writing, emits `1.2.3` instead of `^1.2.3`; with `--check`, fails on any matched dep that carries one. Pair with `--filter` — a repository-wide exactness
|
|
88
|
+
description: 'Pin without a range operator. When writing, emits `1.2.3` instead of `^1.2.3`; with `--check`, fails on any matched dep that carries one; with `--align`, repairs exactly that. Pair with `--filter` — a repository-wide exactness run touches every ordinary caret dep by design.',
|
|
85
89
|
type: 'boolean',
|
|
86
90
|
default: false,
|
|
87
91
|
})
|
|
@@ -348,22 +352,63 @@ function runCheckMode(groups, exact = false) {
|
|
|
348
352
|
console.error(` ${range.padEnd(16)} — ${holders.join(', ')}`);
|
|
349
353
|
}
|
|
350
354
|
}
|
|
351
|
-
console.error(
|
|
355
|
+
console.error(exact
|
|
356
|
+
? `\nFix: run \`gjsify upgrade --align --exact\` (offline; aligns each dep to its highest declared version and drops the operator).`
|
|
357
|
+
: `\nFix: run \`gjsify upgrade --align\` (offline; aligns each dep to its highest declared range).`);
|
|
352
358
|
process.exit(1);
|
|
353
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* The groups `--align` has work on.
|
|
362
|
+
*
|
|
363
|
+
* Inconsistency is the whole question for a bare `--align`: a dep every manifest declares
|
|
364
|
+
* identically is done. `--exact` asks a second one, and the two do not overlap — a tree
|
|
365
|
+
* where every manifest says `^4.1.0` is perfectly consistent and carries an operator on
|
|
366
|
+
* every one of those declarations, which is precisely what `--check --exact` rejects.
|
|
367
|
+
*
|
|
368
|
+
* Selecting on inconsistency alone therefore made the repair that `--check --exact`'s own
|
|
369
|
+
* failure message names answer `nothing to do` to the tree it was pointed at: the gate
|
|
370
|
+
* demanded a property the fix could not deliver, cheerfully. Measured on bauplaner's five
|
|
371
|
+
* `@girs/*` declarations, all five `^4.1.0` and all five consistent.
|
|
372
|
+
*/
|
|
373
|
+
function alignTargets(groups, exact) {
|
|
374
|
+
if (!exact)
|
|
375
|
+
return findInconsistencies(groups);
|
|
376
|
+
return groups.filter((g) => isInconsistent(g) || g.occurrences.some((o) => o.prefix !== ''));
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* A repair that silently skipped part of its job must not report success.
|
|
380
|
+
*
|
|
381
|
+
* The loop that produces is gate red → repair "done" → gate red, where the second red
|
|
382
|
+
* reads as if the repair had never run. `^1.x` is the shape that lands here: `--check
|
|
383
|
+
* --exact` flags the operator, and no single version can be read out of the range to pin
|
|
384
|
+
* it at, so there is nothing to write and the CLI says which deps those are instead.
|
|
385
|
+
*/
|
|
386
|
+
function reportUnrepairable(groups) {
|
|
387
|
+
console.error(`\ngjsify upgrade --align: ${groups.length} dep(s) left unrepaired — no version to align on:\n`);
|
|
388
|
+
for (const g of groups) {
|
|
389
|
+
console.error(` ${ANSI.bold}${g.name}${ANSI.reset} ${[...g.declaredRanges].join(', ')}`);
|
|
390
|
+
}
|
|
391
|
+
console.error(`\nThose ranges name no single version. Edit them by hand, or resolve one with \`--latest\`.`);
|
|
392
|
+
return process.exit(1);
|
|
393
|
+
}
|
|
354
394
|
function runAlignMode(groups, args) {
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
395
|
+
const exact = args.exact ?? false;
|
|
396
|
+
const targets = alignTargets(groups, exact);
|
|
397
|
+
if (targets.length === 0) {
|
|
398
|
+
console.log(`gjsify upgrade --align: nothing to do. ${groups.length} dep(s) already consistent` +
|
|
399
|
+
(exact ? `, every declaration pinned exactly.` : `.`));
|
|
358
400
|
return;
|
|
359
401
|
}
|
|
360
|
-
// For each
|
|
361
|
-
//
|
|
362
|
-
//
|
|
402
|
+
// For each target, the alignment version is the highest declared one — preserve the
|
|
403
|
+
// prefix of the dominant occurrence so the range shape doesn't mutate (caret stays
|
|
404
|
+
// caret, tilde stays tilde). Under `--exact` the prefix is dropped, in `targetRange`.
|
|
363
405
|
const updates = [];
|
|
364
|
-
|
|
365
|
-
|
|
406
|
+
const unrepairable = [];
|
|
407
|
+
for (const g of targets) {
|
|
408
|
+
if (!g.highestVersion) {
|
|
409
|
+
unrepairable.push(g);
|
|
366
410
|
continue;
|
|
411
|
+
}
|
|
367
412
|
// Pick a prefix: use the prefix of the dominant range; if dominant has
|
|
368
413
|
// no prefix, default to "^" (the npm-cli default).
|
|
369
414
|
const dominantOcc = g.occurrences.find((o) => o.currentRange === g.dominantRange);
|
|
@@ -375,21 +420,24 @@ function runAlignMode(groups, args) {
|
|
|
375
420
|
occurrences: g.occurrences.map((o) => ({ ...o, prefix })),
|
|
376
421
|
});
|
|
377
422
|
}
|
|
378
|
-
if (updates.length
|
|
379
|
-
console.log(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
423
|
+
if (updates.length > 0) {
|
|
424
|
+
console.log(exact
|
|
425
|
+
? `gjsify upgrade --align --exact: pinning ${updates.length} dep(s) to their highest declared version, without a range operator:\n`
|
|
426
|
+
: `gjsify upgrade --align: aligning ${updates.length} inconsistent dep(s) to their highest declared version:\n`);
|
|
427
|
+
for (const u of updates) {
|
|
428
|
+
const newPrefix = u.occurrences[0]?.prefix ?? '^';
|
|
429
|
+
console.log(` ${u.name.padEnd(28)} ranges: ${[...u.declaredRanges].join(', ')} → ${targetRange(newPrefix, u.latestVersion, exact)}`);
|
|
430
|
+
}
|
|
431
|
+
if (args.dryRun) {
|
|
432
|
+
console.log('\n[gjsify upgrade --align] --dry-run: no files changed.');
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const files = applyToFiles(updates, exact);
|
|
436
|
+
console.log(`\n✏️ updated ${updates.length} dep(s) across ${files} package.json file(s).`);
|
|
437
|
+
}
|
|
390
438
|
}
|
|
391
|
-
|
|
392
|
-
|
|
439
|
+
if (unrepairable.length > 0)
|
|
440
|
+
return reportUnrepairable(unrepairable);
|
|
393
441
|
}
|
|
394
442
|
// ─── Registry resolution (group-aware) ─────────────────────────────────
|
|
395
443
|
async function resolveCandidateGroups(groups, npmrc, verbose, mode) {
|