@gjsify/cli 0.49.0 → 0.51.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 +98 -97
- package/lib/actions/barrels-generate.d.ts +22 -0
- package/lib/actions/barrels-generate.js +36 -1
- package/lib/actions/build.js +8 -5
- package/lib/commands/barrels.js +19 -2
- package/lib/commands/foreach.js +11 -19
- package/lib/commands/ship.js +77 -2
- package/lib/commands/test.d.ts +16 -0
- package/lib/commands/test.js +30 -29
- package/lib/commands/workspace.js +47 -44
- package/lib/index.js +10 -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/package-inputs.d.ts +81 -0
- package/lib/utils/package-inputs.js +233 -0
- package/lib/utils/ship/appimage.d.ts +352 -0
- package/lib/utils/ship/appimage.js +671 -0
- package/lib/utils/ship/changelog.d.ts +15 -14
- package/lib/utils/ship/changelog.js +14 -13
- package/lib/utils/ship/depends.d.ts +20 -0
- package/lib/utils/ship/depends.js +31 -16
- package/lib/utils/ship/formats.d.ts +9 -0
- package/lib/utils/ship/formats.js +173 -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/ship/settings.d.ts +14 -0
- package/lib/utils/ship/settings.js +19 -1
- package/lib/utils/ship/types.d.ts +17 -1
- package/lib/utils/simple-command.d.ts +17 -2
- package/lib/utils/simple-command.js +9 -4
- package/package.json +19 -19
package/lib/utils/build-cache.js
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// - a toolchain salt (workspace-resolved versions of @gjsify/cli,
|
|
12
12
|
// @gjsify/tsc, rolldown, typescript — a bundler/compiler bump must
|
|
13
13
|
// invalidate everything),
|
|
14
|
-
// - the package's own input hash
|
|
15
|
-
//
|
|
16
|
-
// contents, streamed sha256, sorted for determinism
|
|
14
|
+
// - the package's own input hash — `packageBuildInputs`, the ONE definition
|
|
15
|
+
// of "what are this package's build inputs" (`utils/package-inputs.ts`),
|
|
16
|
+
// hashed by real file contents, streamed sha256, sorted for determinism,
|
|
17
17
|
// - the input hashes of EVERY workspace dependency in the package's
|
|
18
18
|
// transitive dep closure (prod + dev + optional + peer `workspace:`
|
|
19
19
|
// edges). Composing the closure's OWN hashes (instead of recursing into
|
|
@@ -54,6 +54,7 @@ import { dirname, join } from 'node:path';
|
|
|
54
54
|
import { fileURLToPath } from 'node:url';
|
|
55
55
|
import { buildDependencyGraph } from '@gjsify/workspace';
|
|
56
56
|
import { replicateLinkSync } from './dir-link.js';
|
|
57
|
+
import { CONVENTIONAL_OUTPUT_DIRS, packageBuildInputs } from './package-inputs.js';
|
|
57
58
|
/**
|
|
58
59
|
* Bump to invalidate every existing cache entry (schema/semantic changes).
|
|
59
60
|
*
|
|
@@ -66,10 +67,20 @@ import { replicateLinkSync } from './dir-link.js';
|
|
|
66
67
|
* still on v2's whole-dir ownership and could restore over a sibling unit; a v2
|
|
67
68
|
* manifest for them records `lib`, so it must not be replayed against the
|
|
68
69
|
* per-child units this version stores.
|
|
70
|
+
*
|
|
71
|
+
* v4: the input set is now `packageBuildInputs` instead of `src/**` + the
|
|
72
|
+
* manifests (#1651). Every v3 key was computed over a strictly narrower set,
|
|
73
|
+
* so replaying one would report a hit for a change it could not have seen.
|
|
69
74
|
*/
|
|
70
|
-
export const BUILD_CACHE_LAYOUT_VERSION = '
|
|
71
|
-
/**
|
|
72
|
-
|
|
75
|
+
export const BUILD_CACHE_LAYOUT_VERSION = 'v4';
|
|
76
|
+
/**
|
|
77
|
+
* Conventional per-package build output directories, checked in this order.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately the same constant the input definition subtracts: a directory
|
|
80
|
+
* this cache STORES as an output must never also be hashed as an input, or
|
|
81
|
+
* storing it would invalidate the key it was stored under.
|
|
82
|
+
*/
|
|
83
|
+
export const OUTPUT_DIR_CANDIDATES = CONVENTIONAL_OUTPUT_DIRS;
|
|
73
84
|
/** Keep at most this many keys per package; evict the least recently stored/used. */
|
|
74
85
|
export const MAX_KEYS_PER_PACKAGE = 2;
|
|
75
86
|
/** Toolchain packages whose resolved versions salt every cache key. */
|
|
@@ -113,11 +124,50 @@ export function hashFileStream(path) {
|
|
|
113
124
|
}
|
|
114
125
|
return hash.digest('hex');
|
|
115
126
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Content hash of a package's build INPUTS — the set is
|
|
129
|
+
* {@link packageBuildInputs}, shared with `gjsify test`'s freshness check so
|
|
130
|
+
* the two cannot drift about what an input is. Sorted, content-streamed:
|
|
131
|
+
* mtimes do not participate, so a `git checkout` with identical contents
|
|
132
|
+
* hashes identically across machines.
|
|
133
|
+
*/
|
|
134
|
+
export function hashPackageInputs(location) {
|
|
135
|
+
const files = packageBuildInputs(location);
|
|
136
|
+
const hash = createHash('sha256');
|
|
137
|
+
hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
|
|
138
|
+
for (const f of files) {
|
|
139
|
+
if (f.link !== undefined) {
|
|
140
|
+
// Symlink: try content (file links), fall back to the target path.
|
|
141
|
+
let content;
|
|
142
|
+
try {
|
|
143
|
+
content = hashFileStream(f.abs);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
content = `link:${f.link}`;
|
|
147
|
+
}
|
|
148
|
+
hash.update(`${f.rel} ${content}\n`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Vanished mid-hash (concurrent edit) — record the absence so the
|
|
156
|
+
// key still changes vs. the file being present.
|
|
157
|
+
hash.update(`${f.rel} unreadable\n`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return hash.digest('hex');
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Recursive, sorted walk of an OUTPUT tree, yielding `/`-separated relative
|
|
164
|
+
* paths. Directory symlinks are not followed (loop safety) — the link target
|
|
165
|
+
* string participates in the hash instead.
|
|
166
|
+
*
|
|
167
|
+
* Deliberately NOT `packageBuildInputs`: this one is handed a directory that
|
|
168
|
+
* the input walk subtracts, and it must report everything inside it, dot-files
|
|
169
|
+
* included — an emitted `.gresource` is part of what was built.
|
|
170
|
+
*/
|
|
121
171
|
function walkFiles(dir, prefix, out) {
|
|
122
172
|
let entries;
|
|
123
173
|
try {
|
|
@@ -140,11 +190,8 @@ function walkFiles(dir, prefix, out) {
|
|
|
140
190
|
continue;
|
|
141
191
|
}
|
|
142
192
|
if (st.isSymbolicLink()) {
|
|
143
|
-
// File symlinks: hash the resolved content below if readable;
|
|
144
|
-
// dir symlinks: record the target only.
|
|
145
193
|
try {
|
|
146
|
-
|
|
147
|
-
out.push({ rel, abs, link: target });
|
|
194
|
+
out.push({ rel, abs, link: readlinkSync(abs) });
|
|
148
195
|
}
|
|
149
196
|
catch {
|
|
150
197
|
/* dropped mid-walk */
|
|
@@ -158,58 +205,6 @@ function walkFiles(dir, prefix, out) {
|
|
|
158
205
|
}
|
|
159
206
|
}
|
|
160
207
|
}
|
|
161
|
-
/**
|
|
162
|
-
* Content hash of a package's build INPUTS: every file under `src/**`, plus
|
|
163
|
-
* `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
|
|
164
|
-
* mtimes do not participate, so a `git checkout` with identical contents
|
|
165
|
-
* hashes identically across machines.
|
|
166
|
-
*/
|
|
167
|
-
export function hashPackageInputs(location) {
|
|
168
|
-
const files = [];
|
|
169
|
-
const pkgJson = join(location, 'package.json');
|
|
170
|
-
if (existsSync(pkgJson))
|
|
171
|
-
files.push({ rel: 'package.json', abs: pkgJson });
|
|
172
|
-
let rootEntries = [];
|
|
173
|
-
try {
|
|
174
|
-
rootEntries = readdirSync(location);
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
/* unreadable package dir — hash degenerates to the empty set */
|
|
178
|
-
}
|
|
179
|
-
for (const entry of rootEntries.sort()) {
|
|
180
|
-
if (/^tsconfig.*\.json$/.test(entry)) {
|
|
181
|
-
files.push({ rel: entry, abs: join(location, entry) });
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const srcDir = join(location, 'src');
|
|
185
|
-
if (existsSync(srcDir))
|
|
186
|
-
walkFiles(srcDir, 'src', files);
|
|
187
|
-
const hash = createHash('sha256');
|
|
188
|
-
hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
|
|
189
|
-
for (const f of files) {
|
|
190
|
-
if (f.link !== undefined) {
|
|
191
|
-
// Symlink: try content (file links), fall back to the target path.
|
|
192
|
-
let content;
|
|
193
|
-
try {
|
|
194
|
-
content = hashFileStream(f.abs);
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
content = `link:${f.link}`;
|
|
198
|
-
}
|
|
199
|
-
hash.update(`${f.rel} ${content}\n`);
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
try {
|
|
203
|
-
hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// Vanished mid-hash (concurrent edit) — record the absence so the
|
|
207
|
-
// key still changes vs. the file being present.
|
|
208
|
-
hash.update(`${f.rel} unreadable\n`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return hash.digest('hex');
|
|
212
|
-
}
|
|
213
208
|
/**
|
|
214
209
|
* Mtime-independent content hash of a set of output dirs — used to skip the
|
|
215
210
|
* restore copy when the on-disk outputs already match the cached entry, and
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status a settled CLI run still owes the shell, or `null` when it owes
|
|
3
|
+
* none.
|
|
4
|
+
*
|
|
5
|
+
* @param exitCode what `process.exitCode` holds now — `number | string | null |
|
|
6
|
+
* undefined`, the full set Node accepts for that property.
|
|
7
|
+
* @param isDaemon whether the command declared itself a daemon. A watch loop
|
|
8
|
+
* RESOLVES as soon as it is armed and keeps running, so its resolution is not
|
|
9
|
+
* an ending to exit on — the same rule `gjsify run`'s fast path applies, and
|
|
10
|
+
* the incident behind it (`gjsify run dev` killing the loop it had just
|
|
11
|
+
* started) is in `daemon-command.ts`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function pendingFailureCode(exitCode: number | string | null | undefined, isDaemon: boolean): number | null;
|
|
14
|
+
/**
|
|
15
|
+
* End a settled CLI run on the status its handler reported, on both runtimes.
|
|
16
|
+
*
|
|
17
|
+
* No-op unless a handler actually reported a failure. On Node it is a no-op
|
|
18
|
+
* even then — `gjsExit` finds no `imports.system` and the runtime honours
|
|
19
|
+
* `process.exitCode` at natural shutdown by itself, which is why this does NOT
|
|
20
|
+
* reach for `process.exit()`: that would cut short the stdout flush Node's own
|
|
21
|
+
* idiom exists to allow.
|
|
22
|
+
*/
|
|
23
|
+
export declare function exitOnReportedFailure(): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The CLI has TWO ways to fail, and until now only one of them reached the shell
|
|
2
|
+
// under GJS.
|
|
3
|
+
//
|
|
4
|
+
// A command handler either THROWS — `index.ts` catches it, prints, and calls
|
|
5
|
+
// `gjsExit(1)` — or it REPORTS the failure by assigning `process.exitCode` and
|
|
6
|
+
// returning normally. The second shape is the documented Node idiom (streams
|
|
7
|
+
// flush, the process ends naturally) and the CLI uses it wherever a failure has
|
|
8
|
+
// already been printed in full: `gjsify gresource`/`gjsify gsettings` propagate
|
|
9
|
+
// the code `glib-compile-*` exited with, `gjsify barrels --check` reports drift,
|
|
10
|
+
// `setOxcExitCode` propagates a formatter's verdict.
|
|
11
|
+
//
|
|
12
|
+
// **Under GJS that assignment is a REPORT and nothing more.** There is no atexit
|
|
13
|
+
// hook, so nothing reads `process.exitCode` back at natural shutdown and the
|
|
14
|
+
// process ends 0. Measured on the v0.49.0 bundle, same argv, same broken input:
|
|
15
|
+
//
|
|
16
|
+
// $ node lib/index.js gresource broken.gresource.xml → exit 1
|
|
17
|
+
// $ gjs -m dist/cli.gjs.mjs gresource broken.gresource.xml → exit 0
|
|
18
|
+
// ("[gjsify gresource] glib-compile-resources failed (exit 1)" printed)
|
|
19
|
+
//
|
|
20
|
+
// and the same split for `gjsify gsettings` and `gjsify barrels --check`. The
|
|
21
|
+
// consequence is not a cosmetic status: a package script chaining on it runs the
|
|
22
|
+
// next step over an artifact that was never written —
|
|
23
|
+
//
|
|
24
|
+
// "build:resources": "gjsify gresource … && echo SECOND"
|
|
25
|
+
// → SECOND printed, script exits 0 (JumpLink/Learn6502#180)
|
|
26
|
+
//
|
|
27
|
+
// — which is exactly the shape `&&` is supposed to protect. `gjsify run`'s
|
|
28
|
+
// in-process fast path was already immune (it reads `process.exitCode` back
|
|
29
|
+
// itself, #1568), so the hole survived precisely in the SPAWNED command, i.e.
|
|
30
|
+
// in every chained script and every direct CI invocation.
|
|
31
|
+
//
|
|
32
|
+
// This module is the missing half of the entry's exit funnel, and a sibling of
|
|
33
|
+
// `force-exit.ts`: that one is for a caller that knows the code it wants NOW,
|
|
34
|
+
// this one for the status a settled run left behind. The rule is pure so it can
|
|
35
|
+
// be stated as a test without driving a process to its death.
|
|
36
|
+
//
|
|
37
|
+
// Related, and deliberately distinct: `gjsify/deferred-process-exit` (oxlint)
|
|
38
|
+
// covers the OTHER GJS exit trap, a bare `process.exit()` that does not halt.
|
|
39
|
+
// No lint rule could have caught this one — every `process.exitCode = …` site
|
|
40
|
+
// is correct; the entry was not reading them.
|
|
41
|
+
import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
42
|
+
import { isDaemonCommand } from './daemon-command.js';
|
|
43
|
+
/**
|
|
44
|
+
* The status a settled CLI run still owes the shell, or `null` when it owes
|
|
45
|
+
* none.
|
|
46
|
+
*
|
|
47
|
+
* @param exitCode what `process.exitCode` holds now — `number | string | null |
|
|
48
|
+
* undefined`, the full set Node accepts for that property.
|
|
49
|
+
* @param isDaemon whether the command declared itself a daemon. A watch loop
|
|
50
|
+
* RESOLVES as soon as it is armed and keeps running, so its resolution is not
|
|
51
|
+
* an ending to exit on — the same rule `gjsify run`'s fast path applies, and
|
|
52
|
+
* the incident behind it (`gjsify run dev` killing the loop it had just
|
|
53
|
+
* started) is in `daemon-command.ts`.
|
|
54
|
+
*/
|
|
55
|
+
export function pendingFailureCode(exitCode, isDaemon) {
|
|
56
|
+
if (isDaemon)
|
|
57
|
+
return null;
|
|
58
|
+
if (exitCode === null || exitCode === undefined)
|
|
59
|
+
return null;
|
|
60
|
+
const code = Number(exitCode);
|
|
61
|
+
// A non-finite value cannot be assigned to `process.exitCode` on Node at
|
|
62
|
+
// all, so there is no failure here to invent one from.
|
|
63
|
+
return Number.isFinite(code) && code !== 0 ? code : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* End a settled CLI run on the status its handler reported, on both runtimes.
|
|
67
|
+
*
|
|
68
|
+
* No-op unless a handler actually reported a failure. On Node it is a no-op
|
|
69
|
+
* even then — `gjsExit` finds no `imports.system` and the runtime honours
|
|
70
|
+
* `process.exitCode` at natural shutdown by itself, which is why this does NOT
|
|
71
|
+
* reach for `process.exit()`: that would cut short the stdout flush Node's own
|
|
72
|
+
* idiom exists to allow.
|
|
73
|
+
*/
|
|
74
|
+
export function exitOnReportedFailure() {
|
|
75
|
+
const code = pendingFailureCode(process.exitCode, isDaemonCommand());
|
|
76
|
+
if (code !== null)
|
|
77
|
+
gjsExit(code);
|
|
78
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** One file the build may read. `link` is set for a symlink (never followed). */
|
|
2
|
+
export interface PackageInputFile {
|
|
3
|
+
/** Package-relative path, forward slashes. */
|
|
4
|
+
rel: string;
|
|
5
|
+
abs: string;
|
|
6
|
+
/** Raw symlink target, when this entry is a symlink. */
|
|
7
|
+
link?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where the toolchain writes when the package does not say otherwise.
|
|
11
|
+
*
|
|
12
|
+
* Used for a package with NO `clear` script (a consumer project we know
|
|
13
|
+
* nothing about), and unioned onto the declared set for one that has one:
|
|
14
|
+
* `gjsify test` writes `dist/test.{gjs,node}.mjs` into packages whose `clear`
|
|
15
|
+
* line predates that command and names only `lib`, and an output left in the
|
|
16
|
+
* input set makes every run see a newer file than the artifact it is judging.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CONVENTIONAL_OUTPUT_DIRS: readonly ["lib", "dist", "dist-templates"];
|
|
19
|
+
/**
|
|
20
|
+
* Files whose change means the installed dependency tree may have moved.
|
|
21
|
+
*
|
|
22
|
+
* `node_modules` is deliberately not walked — too large to stat per run, and
|
|
23
|
+
* the reason the old `gjsify test` walk skipped it too — so the lockfile is
|
|
24
|
+
* the cheap proxy for it. That substitution is not new: `actions/build.ts`
|
|
25
|
+
* has made it since the plugin bundle cache existed, and this is the list it
|
|
26
|
+
* uses, kept in one place so a fifth package manager is added once.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DEP_CHANGE_LOCKFILES: readonly ["gjsify-lock.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
|
29
|
+
export interface PackageBuildInputOptions {
|
|
30
|
+
/** Parsed `package.json`, when the caller already has it. */
|
|
31
|
+
manifest?: {
|
|
32
|
+
scripts?: Record<string, string>;
|
|
33
|
+
} | null;
|
|
34
|
+
/** Extra package-relative paths to treat as output (e.g. a `--outdir`). */
|
|
35
|
+
extraOutputs?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The paths this package PRODUCES, package-relative — DERIVED from the
|
|
39
|
+
* package's own `clear` script rather than re-declared, the same move
|
|
40
|
+
* `utils/dev-plan.ts` makes for build flags.
|
|
41
|
+
*
|
|
42
|
+
* Three cases, and the middle one is the one that matters:
|
|
43
|
+
* - `clear` is `gjsify clear a b c` → `a b c` plus the conventional dirs.
|
|
44
|
+
* 127 of 212 workspace packages, naming `tmp`, `build`, `*.tsbuildinfo`,
|
|
45
|
+
* `test.gjs.mjs`, and in two of them `src/*.generated.ts` — a generated
|
|
46
|
+
* file INSIDE `src/` that the old walk hashed as an input.
|
|
47
|
+
* - `clear` is anything else (`echo 'nothing to do'`, 74 packages) →
|
|
48
|
+
* NOTHING. The package states it has nothing to delete, so its `lib/` is
|
|
49
|
+
* source, not output. Exactly two of the 74 have a conventional output dir
|
|
50
|
+
* on disk, `resolve-npm/lib` and `manifest-conformance/lib`, and in both
|
|
51
|
+
* it is tracked source — so the script is a trustworthy oracle both ways.
|
|
52
|
+
* - no `clear` script (11 packages, and any consumer project) → the
|
|
53
|
+
* conventional dirs, the only guess available.
|
|
54
|
+
*/
|
|
55
|
+
export declare function packageOutputPaths(manifest: {
|
|
56
|
+
scripts?: Record<string, string>;
|
|
57
|
+
} | null): string[];
|
|
58
|
+
/** `package.json` at `pkgRoot`, or `null` when absent/unreadable. */
|
|
59
|
+
export declare function readPackageManifest(pkgRoot: string): {
|
|
60
|
+
scripts?: Record<string, string>;
|
|
61
|
+
} | null;
|
|
62
|
+
/**
|
|
63
|
+
* Every file the build of the package at `pkgRoot` may read, sorted by
|
|
64
|
+
* package-relative path.
|
|
65
|
+
*
|
|
66
|
+
* Enumeration only — the caller decides whether to hash contents (build cache)
|
|
67
|
+
* or take mtimes (`gjsify test` freshness). That split is deliberate: the two
|
|
68
|
+
* must agree on the SET, and they legitimately differ on what they do with it.
|
|
69
|
+
*/
|
|
70
|
+
export declare function packageBuildInputs(pkgRoot: string, options?: PackageBuildInputOptions): PackageInputFile[];
|
|
71
|
+
/** Newest mtime (ms) among `files`, or 0 for an empty set. */
|
|
72
|
+
export declare function newestInputMtimeMs(files: readonly PackageInputFile[]): number;
|
|
73
|
+
/**
|
|
74
|
+
* Newest lockfile mtime (ms) at or above `startDir`, or 0 when there is none.
|
|
75
|
+
*
|
|
76
|
+
* Walks up because in a monorepo the lockfile that governs a package's
|
|
77
|
+
* `node_modules` sits at the workspace root, several levels away — and a
|
|
78
|
+
* dependency bump that rewrites it is precisely the case #1651 was found in
|
|
79
|
+
* (postbote, gjsify 0.33.0 → 0.49.0, not one source file touched).
|
|
80
|
+
*/
|
|
81
|
+
export declare function newestDepSignalMtimeMs(startDir: string): number;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// ONE answer to "what are this package's build inputs" — read by the build
|
|
3
|
+
// cache (`build-cache.ts`) and by `gjsify test`'s freshness check. Add a
|
|
4
|
+
// fourth READER, never a fourth answer.
|
|
5
|
+
//
|
|
6
|
+
// It was answered three times, each an ALLOW-list over a guessed layout, and
|
|
7
|
+
// each is wrong for a package that already exists here: `src/**` misses
|
|
8
|
+
// `@gjsify/adwaita-fonts` (no `src/` at all) and the tracked source in
|
|
9
|
+
// `resolve-npm/lib` + `manifest-conformance/lib` (#821); `dirname(<test
|
|
10
|
+
// entry>)` missed `src/**` entirely, so `gjsify test` reran the PREVIOUS
|
|
11
|
+
// bundle and reported on it (#1651).
|
|
12
|
+
//
|
|
13
|
+
// So: a DENY-list. An input is any file in the package that is not a
|
|
14
|
+
// dependency, not tool/VCS state, and not something the package produces —
|
|
15
|
+
// which covers a layout nobody anticipated by construction. **In doubt, a
|
|
16
|
+
// file is an input**: a false input costs one rebuild, a false non-input
|
|
17
|
+
// costs a green test run about code that is not in the artifact.
|
|
18
|
+
//
|
|
19
|
+
// The incident, the measurements behind every number below, and the copy
|
|
20
|
+
// still open (CI's `actions/cache` glob): docs/build-artifacts.md.
|
|
21
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { hasWildcard, segmentToRegExp } from './clear-targets.js';
|
|
24
|
+
import { gjsifyCommandArgv } from './simple-command.js';
|
|
25
|
+
/**
|
|
26
|
+
* Where the toolchain writes when the package does not say otherwise.
|
|
27
|
+
*
|
|
28
|
+
* Used for a package with NO `clear` script (a consumer project we know
|
|
29
|
+
* nothing about), and unioned onto the declared set for one that has one:
|
|
30
|
+
* `gjsify test` writes `dist/test.{gjs,node}.mjs` into packages whose `clear`
|
|
31
|
+
* line predates that command and names only `lib`, and an output left in the
|
|
32
|
+
* input set makes every run see a newer file than the artifact it is judging.
|
|
33
|
+
*/
|
|
34
|
+
export const CONVENTIONAL_OUTPUT_DIRS = ['lib', 'dist', 'dist-templates'];
|
|
35
|
+
/**
|
|
36
|
+
* Files whose change means the installed dependency tree may have moved.
|
|
37
|
+
*
|
|
38
|
+
* `node_modules` is deliberately not walked — too large to stat per run, and
|
|
39
|
+
* the reason the old `gjsify test` walk skipped it too — so the lockfile is
|
|
40
|
+
* the cheap proxy for it. That substitution is not new: `actions/build.ts`
|
|
41
|
+
* has made it since the plugin bundle cache existed, and this is the list it
|
|
42
|
+
* uses, kept in one place so a fifth package manager is added once.
|
|
43
|
+
*/
|
|
44
|
+
export const DEP_CHANGE_LOCKFILES = ['gjsify-lock.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
45
|
+
/**
|
|
46
|
+
* The paths this package PRODUCES, package-relative — DERIVED from the
|
|
47
|
+
* package's own `clear` script rather than re-declared, the same move
|
|
48
|
+
* `utils/dev-plan.ts` makes for build flags.
|
|
49
|
+
*
|
|
50
|
+
* Three cases, and the middle one is the one that matters:
|
|
51
|
+
* - `clear` is `gjsify clear a b c` → `a b c` plus the conventional dirs.
|
|
52
|
+
* 127 of 212 workspace packages, naming `tmp`, `build`, `*.tsbuildinfo`,
|
|
53
|
+
* `test.gjs.mjs`, and in two of them `src/*.generated.ts` — a generated
|
|
54
|
+
* file INSIDE `src/` that the old walk hashed as an input.
|
|
55
|
+
* - `clear` is anything else (`echo 'nothing to do'`, 74 packages) →
|
|
56
|
+
* NOTHING. The package states it has nothing to delete, so its `lib/` is
|
|
57
|
+
* source, not output. Exactly two of the 74 have a conventional output dir
|
|
58
|
+
* on disk, `resolve-npm/lib` and `manifest-conformance/lib`, and in both
|
|
59
|
+
* it is tracked source — so the script is a trustworthy oracle both ways.
|
|
60
|
+
* - no `clear` script (11 packages, and any consumer project) → the
|
|
61
|
+
* conventional dirs, the only guess available.
|
|
62
|
+
*/
|
|
63
|
+
export function packageOutputPaths(manifest) {
|
|
64
|
+
const clear = manifest?.scripts?.clear;
|
|
65
|
+
if (clear === undefined)
|
|
66
|
+
return [...CONVENTIONAL_OUTPUT_DIRS];
|
|
67
|
+
// `allowGlobs`: five packages here clear `*.tsbuildinfo`, and the pattern
|
|
68
|
+
// is what we want — this reads the targets, it does not run them.
|
|
69
|
+
const argv = gjsifyCommandArgv(clear, { allowGlobs: true });
|
|
70
|
+
if (argv === null || argv[0] !== 'clear')
|
|
71
|
+
return [];
|
|
72
|
+
const targets = argv.slice(1).filter((token) => !token.startsWith('-'));
|
|
73
|
+
return [...targets, ...CONVENTIONAL_OUTPUT_DIRS];
|
|
74
|
+
}
|
|
75
|
+
/** `package.json` at `pkgRoot`, or `null` when absent/unreadable. */
|
|
76
|
+
export function readPackageManifest(pkgRoot) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Match a package-relative path against output targets — the target itself and
|
|
86
|
+
* everything under it.
|
|
87
|
+
*
|
|
88
|
+
* Wildcards are honoured in the LAST segment only, which is `gjsify clear`'s
|
|
89
|
+
* own rule (`utils/clear-targets.ts`), so the set matched here is the set that
|
|
90
|
+
* command would delete.
|
|
91
|
+
*/
|
|
92
|
+
function makeOutputMatcher(targets) {
|
|
93
|
+
const compiled = targets
|
|
94
|
+
.map((target) => target
|
|
95
|
+
.split(/[\\/]+/)
|
|
96
|
+
.filter((segment) => segment !== '' && segment !== '.')
|
|
97
|
+
.map((segment) => (hasWildcard(segment) ? segmentToRegExp(segment) : segment)))
|
|
98
|
+
.filter((segments) => segments.length > 0);
|
|
99
|
+
return (rel) => {
|
|
100
|
+
const parts = rel.split('/');
|
|
101
|
+
for (const target of compiled) {
|
|
102
|
+
if (target.length > parts.length)
|
|
103
|
+
continue;
|
|
104
|
+
let matched = true;
|
|
105
|
+
for (let i = 0; i < target.length; i++) {
|
|
106
|
+
const segment = target[i];
|
|
107
|
+
const got = parts[i];
|
|
108
|
+
if (typeof segment === 'string' ? segment !== got : !segment.test(got)) {
|
|
109
|
+
matched = false;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (matched)
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function walk(dir, prefix, isOutput, out) {
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = readdirSync(dir);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
entries.sort();
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
// `node_modules` is the dependency tree, not this package's source, and
|
|
130
|
+
// a dot-entry is VCS/editor/cache state — `.git` alone would make the
|
|
131
|
+
// walk unbounded. Measured across every package here: no file under any
|
|
132
|
+
// `src/` is dot-prefixed, so nothing the old hash covered is lost.
|
|
133
|
+
if (entry === 'node_modules' || entry.startsWith('.'))
|
|
134
|
+
continue;
|
|
135
|
+
const rel = prefix === '' ? entry : `${prefix}/${entry}`;
|
|
136
|
+
if (isOutput(rel))
|
|
137
|
+
continue;
|
|
138
|
+
const abs = join(dir, entry);
|
|
139
|
+
let st;
|
|
140
|
+
try {
|
|
141
|
+
st = lstatSync(abs);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (st.isSymbolicLink()) {
|
|
147
|
+
// Not followed: a directory symlink can loop, and a workspace
|
|
148
|
+
// `node_modules` link would drag the whole tree in. The target
|
|
149
|
+
// string participates instead; file links are read by content where
|
|
150
|
+
// the consumer asks for content.
|
|
151
|
+
out.push({ rel, abs, link: readLinkTarget(abs) });
|
|
152
|
+
}
|
|
153
|
+
else if (st.isDirectory()) {
|
|
154
|
+
walk(abs, rel, isOutput, out);
|
|
155
|
+
}
|
|
156
|
+
else if (st.isFile()) {
|
|
157
|
+
out.push({ rel, abs });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function readLinkTarget(abs) {
|
|
162
|
+
try {
|
|
163
|
+
return readlinkSync(abs);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Dropped between lstat and readlink. An empty target still differs
|
|
167
|
+
// from the file being absent, which is all the hash needs.
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Every file the build of the package at `pkgRoot` may read, sorted by
|
|
173
|
+
* package-relative path.
|
|
174
|
+
*
|
|
175
|
+
* Enumeration only — the caller decides whether to hash contents (build cache)
|
|
176
|
+
* or take mtimes (`gjsify test` freshness). That split is deliberate: the two
|
|
177
|
+
* must agree on the SET, and they legitimately differ on what they do with it.
|
|
178
|
+
*/
|
|
179
|
+
export function packageBuildInputs(pkgRoot, options = {}) {
|
|
180
|
+
const manifest = options.manifest !== undefined ? options.manifest : readPackageManifest(pkgRoot);
|
|
181
|
+
const outputs = [...packageOutputPaths(manifest), ...(options.extraOutputs ?? [])];
|
|
182
|
+
const files = [];
|
|
183
|
+
walk(pkgRoot, '', makeOutputMatcher(outputs), files);
|
|
184
|
+
return files;
|
|
185
|
+
}
|
|
186
|
+
/** Newest mtime (ms) among `files`, or 0 for an empty set. */
|
|
187
|
+
export function newestInputMtimeMs(files) {
|
|
188
|
+
let newest = 0;
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
try {
|
|
191
|
+
const { mtimeMs } = statSync(file.abs);
|
|
192
|
+
if (mtimeMs > newest)
|
|
193
|
+
newest = mtimeMs;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Vanished mid-walk. Treat as a change: an input we cannot read is
|
|
197
|
+
// not evidence that the artifact is current.
|
|
198
|
+
return Number.POSITIVE_INFINITY;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return newest;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Newest lockfile mtime (ms) at or above `startDir`, or 0 when there is none.
|
|
205
|
+
*
|
|
206
|
+
* Walks up because in a monorepo the lockfile that governs a package's
|
|
207
|
+
* `node_modules` sits at the workspace root, several levels away — and a
|
|
208
|
+
* dependency bump that rewrites it is precisely the case #1651 was found in
|
|
209
|
+
* (postbote, gjsify 0.33.0 → 0.49.0, not one source file touched).
|
|
210
|
+
*/
|
|
211
|
+
export function newestDepSignalMtimeMs(startDir) {
|
|
212
|
+
let newest = 0;
|
|
213
|
+
let dir = startDir;
|
|
214
|
+
for (;;) {
|
|
215
|
+
for (const name of DEP_CHANGE_LOCKFILES) {
|
|
216
|
+
const candidate = join(dir, name);
|
|
217
|
+
if (!existsSync(candidate))
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
const { mtimeMs } = statSync(candidate);
|
|
221
|
+
if (mtimeMs > newest)
|
|
222
|
+
newest = mtimeMs;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* raced away — the other lockfiles still answer */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const parent = dirname(dir);
|
|
229
|
+
if (parent === dir)
|
|
230
|
+
return newest;
|
|
231
|
+
dir = parent;
|
|
232
|
+
}
|
|
233
|
+
}
|