@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.
@@ -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.
@@ -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
- * Lockfiles whose change implies a plugin's *transitive* deps may have moved.
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 these too, not only on the entry.
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 PLUGIN_CACHE_DEP_SIGNALS) {
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)
@@ -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
- import { DEFAULT_BARRELS_EXCLUDES, DEFAULT_BARRELS_HEADER, generateBarrels, } from '../actions/barrels-generate.js';
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: args.baseDir ?? process.cwd(),
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,
@@ -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
- const selectedNames = new Set(workspaces.map((w) => w.name));
587
- const remaining = new Map();
588
- for (const ws of workspaces) {
589
- const wsDeps = new Set();
590
- const m = ws.manifest;
591
- for (const block of [m.dependencies, includeDev ? m.devDependencies : undefined, m.optionalDependencies]) {
592
- if (!block)
593
- continue;
594
- for (const [name, spec] of Object.entries(block)) {
595
- if (typeof spec !== 'string')
596
- continue;
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();
@@ -32,10 +32,11 @@ import { buildDeb } from '../utils/ship/deb.js';
32
32
  import { deriveDepends, warnAboutGjsFloor, warnAboutNodeFloor } from '../utils/ship/depends.js';
33
33
  import { isGtkRuntimeTarget, stageAppRuntime } from '../utils/ship/app-runtime.js';
34
34
  import { discoverPayload } from '../utils/ship/discover.js';
35
+ import { appDirFor, appDirPayload, appImageHostRequirements, appImageRuntimeNotice, buildAppImage, findPinnedRuntime, stampAppDirTimes, } from '../utils/ship/appimage.js';
35
36
  import { buildDmgImage, dmgVolumeDir, dmgVolumeName } from '../utils/ship/dmg.js';
36
37
  import { buildFlatpakBundle } from '../utils/ship/flatpak.js';
37
38
  import { localizeMetadata } from '../utils/ship/localize-metadata.js';
38
- import { assertHostCanFinish, assertToolsInstalled, canCarryTicket, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
39
+ import { assertHostCanFinish, assertKindCanPack, assertToolsInstalled, canCarryTicket, configuredFormats, defaultFormatIds, formatIdsFor, resolveFormats, windowsProgramDirName, FORMAT_IDS, FORMATS, } from '../utils/ship/formats.js';
39
40
  import { assertLayoutSupportsArch, hostLayout, layoutForOs, place, placeStage, resolveLayout, LAYOUT_NAMES, } from '../utils/ship/layout.js';
40
41
  import { isNodeRuntimeTarget, resolveNodeRuntime } from '../utils/ship/node-runtime.js';
41
42
  import { isUnder, SHARE } from '../utils/ship/share-dirs.js';
@@ -49,7 +50,7 @@ import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/p
49
50
  import { assertHostCanSign, notarizeArtifact, stapleArtifact, resolveNotaryPlan, resolveSignPlan, signPayload, } from '../utils/ship/signing.js';
50
51
  import { renderLauncher } from '../utils/ship/launcher.js';
51
52
  import { buildRpm } from '../utils/ship/rpm.js';
52
- import { resolveShipApp, resolveShipBundle, resolveShipSettings, } from '../utils/ship/settings.js';
53
+ import { resolveShipApp, resolveShipBundle, resolveShipKind, resolveShipSettings, } from '../utils/ship/settings.js';
53
54
  import { assertExpectedTarget, assertFormatsStaged, formatTarget, overlayFiles, readStageManifest, resolveStageMtime, warnOnToolSkew, writeStageManifest, STAGE_MANIFEST_FILE, } from '../utils/ship/stage-manifest.js';
54
55
  import { directorySize, readStage, writePayload, writeStage } from '../utils/ship/stage-writer.js';
55
56
  const LOG = '[gjsify ship]';
@@ -276,6 +277,23 @@ async function assemble(args) {
276
277
  // afterwards costs the whole build for a refusal that was knowable up
277
278
  // front. Skipped under `--stage`, which is precisely the phase that does
278
279
  // NOT need the format's tooling — that asymmetry is the point of the split.
280
+ // THE THIRD REFUSAL THAT IS KNOWABLE UP FRONT, and it was the one paid for
281
+ // with a whole build: `appimage` needs a desktop entry at its AppDir root,
282
+ // `kind: 'cli'` stages none by design, and both facts are configuration. It
283
+ // used to surface inside the packer — after `runProjectBuild` — so a CLI
284
+ // project asking for an AppImage built everything first and was then told its
285
+ // payload could never have become one. The ICON half stays in the packer,
286
+ // because an icon is discovered and discovery legitimately reads build output.
287
+ //
288
+ // BEFORE `assertCanPack` AND NOT AFTER IT, which is an ordering between two
289
+ // refusals rather than a detail. `assertCanPack` says "not on THIS machine" and
290
+ // is answered by installing something; this one says "not for THIS PROJECT",
291
+ // which installing appimagetool would not fix. Reporting the machine first
292
+ // sends a CLI author to a GitHub release for a tool that was never going to
293
+ // help them — measured, because the e2e below asserted the message it wanted
294
+ // and only ever ran on a host that had the tool.
295
+ if (!args.stage)
296
+ assertKindCanPack(formats, resolveShipKind(ship, flatpak));
279
297
  if (!args.stage)
280
298
  for (const format of formats)
281
299
  assertCanPack(format);
@@ -949,6 +967,63 @@ async function packOne(input) {
949
967
  verbose: input.verbose,
950
968
  });
951
969
  break;
970
+ case 'appimage': {
971
+ // FROM THE PAYLOAD, like every other container here and NOT from the
972
+ // stage directory, so the three properties `writePayload` guarantees
973
+ // hold: the modes are the plan's, the stage's own sidecar stays out
974
+ // because it was never payload, and `--target appimage` alone works
975
+ // without another row having run first.
976
+ //
977
+ // The host requirement list is derived HERE rather than inside the
978
+ // packer, because it is the same pair of inputs `deriveDepends` takes
979
+ // one branch up — `input.namespaces` from the stage manifest and
980
+ // `facts.bundledTypelibs` from the payload. Two derivations would be
981
+ // two answers to "what does this artifact need from the machine", from
982
+ // one build, in two files.
983
+ const hostRequirements = appImageHostRequirements({
984
+ app: settings.app,
985
+ minGjsVersion: settings.minGjsVersion,
986
+ minNodeVersion: settings.minNodeVersion,
987
+ namespaces: input.namespaces,
988
+ bundledTypelibs: facts.bundledTypelibs,
989
+ });
990
+ const appDir = appDirFor(outRoot);
991
+ const tree = appDirPayload(settings, payload, hostRequirements);
992
+ // ONE `writePayload` FOR THE WHOLE AppDir, prefix and root files
993
+ // together, because that call WIPES: two of them would leave the
994
+ // previous run's `<appId>.desktop` and icon at a root the second call
995
+ // never cleaned, and appimagetool packs an AppDir with two desktop
996
+ // files without complaining about either.
997
+ writePayload(appDir, tree, '');
998
+ // AND THEN THE TIMES, which are the single difference between two packs
999
+ // of one build (measured, sha256): mksquashfs stores per-file mtimes
1000
+ // and `writePayload`'s own `mkdir`/`writeFileSync` leave the wall clock.
1001
+ stampAppDirTimes(appDir, tree, mtime);
1002
+ // PRINTED, not logged at `--verbose`. ADR 0024 § 9's objection to this
1003
+ // format is that "runs anywhere" is a claim the file cannot keep, and
1004
+ // the answer is that the file says what it does not carry — to the
1005
+ // person building it, every time, and not only in a document.
1006
+ console.log(`${LOG} the AppImage takes these from the host: ${hostRequirements.join(', ')}`);
1007
+ // AND WHAT IT EMBEDS, on the same terms and for the same reason. The
1008
+ // requirement list above says what the file does NOT carry; this says
1009
+ // where the one thing it DOES carry that this tree did not write came
1010
+ // from. With a pin that is a checkable claim (no network, and two packs
1011
+ // of one build embed identical bytes); without one appimagetool fetches
1012
+ // from a rolling tag, which still works and must still be said —
1013
+ // ADR 0024 § A26.1. Unconditional, never behind `--verbose`: the person
1014
+ // who can pin a runtime is the one reading a pack log.
1015
+ const runtimeFile = findPinnedRuntime(archLabel);
1016
+ console.log(`${LOG} ${appImageRuntimeNotice(archLabel, runtimeFile)}`);
1017
+ await buildAppImage({
1018
+ appDir,
1019
+ target,
1020
+ archLabel,
1021
+ runtimeFile,
1022
+ workDir: dirname(appDir),
1023
+ verbose: input.verbose,
1024
+ });
1025
+ break;
1026
+ }
952
1027
  case 'macos-app':
953
1028
  case 'windows-dir':
954
1029
  // ONE STATEMENT FOR TWO ROWS, folded because the code really is
@@ -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 {};
@@ -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, readdirSync } from 'node:fs';
9
- import { join, dirname, resolve, relative } from 'node:path';
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
- /** True when `outfile` exists and is newer than every `.ts`/`.mts` file under the entry's directory tree. */
208
- function isFresh(outfile, entry) {
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 newest = newestMtimeUnder(existsSync(srcRoot) ? srcRoot : entry);
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
- }
@@ -14,13 +14,16 @@
14
14
  // dependency, in topological build order, then in the target package.
15
15
  // Skips deps that don't declare the script (`--if-present` behaviour).
16
16
  // Stops on the first failure unless `--continue-on-error` is passed.
17
+ // A local dependency counts whether it is declared with the `workspace:`
18
+ // protocol or with a plain range the local member satisfies (@gjsify/workspace
19
+ // § buildDependencyGraph) — and the expansion SAYS when it found nothing.
17
20
  //
18
21
  // This replaces the manual `gjsify workspace <A> build && gjsify workspace
19
22
  // <B> build && …` chains in root `build:infra` scripts: cascade build
20
23
  // failures on uninbuilt workspace deps are the single biggest local-dev
21
24
  // pain point in this monorepo.
22
25
  import { spawnToCompletion } from '../utils/spawn.js';
23
- import { buildDependencyGraph, discoverWorkspaces, topologicalSort } from '@gjsify/workspace';
26
+ import { affectedClosure, buildDependencyGraph, discoverWorkspaces, topologicalSort, } from '@gjsify/workspace';
24
27
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
25
28
  import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
26
29
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
@@ -135,11 +138,29 @@ export const workspaceCommand = {
135
138
  // target itself.
136
139
  let runList;
137
140
  if (withDeps) {
138
- const closure = collectTransitiveClosure(target, allWorkspaces, args['include-dev'] === true);
139
- // Sort the closure topologically using the shared @gjsify/workspace
140
- // algorithm — same code path that drives `gjsify foreach -t`.
141
- const graph = buildDependencyGraph(closure, { includeDev: args['include-dev'] === true });
141
+ const includeDev = args['include-dev'] === true;
142
+ // The SHARED graph, walked by the shared closure — the same two calls
143
+ // `gjsify foreach --with-dependencies` makes. This used to be a private
144
+ // copy of the walk here, and the copy is where the defect lived: it
145
+ // followed `workspace:` edges only, so in a monorepo declaring its local
146
+ // deps by plain semver range it matched nothing, built nothing and exited
147
+ // 0 (#1587). One walk means the rule for "what is a local dependency" has
148
+ // one place to be wrong in.
149
+ const forward = buildDependencyGraph(allWorkspaces, { includeDev });
150
+ const names = affectedClosure(forward, [target.name]);
151
+ const closure = allWorkspaces.filter((ws) => names.has(ws.name));
152
+ reportUnlinked(forward.unlinked, names);
153
+ const graph = buildDependencyGraph(closure, { includeDev });
142
154
  runList = topologicalSort(graph);
155
+ if (runList.length === 1) {
156
+ // The expansion found nothing, and it SAYS so. A leaf package
157
+ // legitimately has no local dependencies, but "no local dependencies"
158
+ // and "the flag could not see them" produced the same silent exit 0,
159
+ // and that ambiguity is what cost a Flatpak build, a container run and
160
+ // a manual `ls` to resolve.
161
+ console.error(`gjsify workspace: --with-dependencies found no workspace dependencies of ` +
162
+ `"${target.name}" — running "${script}" in it alone.`);
163
+ }
143
164
  }
144
165
  else {
145
166
  runList = [target];
@@ -197,48 +218,30 @@ export const workspaceCommand = {
197
218
  },
198
219
  };
199
220
  /**
200
- * Walk the workspace-dep graph from `target`, collecting every transitive
201
- * workspace dependency (production + optional, plus devDependencies when
202
- * `includeDev`). The returned list includes `target` itself so the caller
203
- * can pass it straight to `buildDependencyGraph` / `topologicalSort`.
221
+ * Name a dependency that points at a local workspace but does not link to it, because
222
+ * the member's version does not satisfy the declared range.
223
+ *
224
+ * Only entries declared BY a package in the closure are printed: an unlinked pair
225
+ * elsewhere in the monorepo is somebody else's business, and printing all of them would
226
+ * bury the one that explains why this closure is the size it is.
227
+ *
228
+ * This is a warning, not a failure, and it deliberately asserts NO outcome, because the
229
+ * outcome depends on which installer placed `node_modules`: npm and yarn fetch such a
230
+ * dependency from the registry — the rule the graph follows, under which leaving it out of
231
+ * the build order is simply right — while `gjsify install` symlinks the local member
232
+ * whatever the range says. Under that one the build order really is short a package, so
233
+ * the message asks for the range and the version to be brought into agreement instead of
234
+ * telling the reader where the files came from.
204
235
  */
205
- function collectTransitiveClosure(target, allWorkspaces, includeDev) {
206
- const byName = new Map();
207
- for (const ws of allWorkspaces)
208
- byName.set(ws.name, ws);
209
- const seen = new Set();
210
- const out = [];
211
- const stack = [target];
212
- while (stack.length > 0) {
213
- const ws = stack.pop();
214
- if (seen.has(ws.name))
236
+ function reportUnlinked(unlinked, closure) {
237
+ for (const entry of unlinked) {
238
+ if (!closure.has(entry.from))
215
239
  continue;
216
- seen.add(ws.name);
217
- out.push(ws);
218
- const m = ws.manifest;
219
- const blocks = [
220
- m.dependencies,
221
- includeDev ? m.devDependencies : undefined,
222
- m.optionalDependencies,
223
- // peerDependencies excluded by default (yarn does the same)
224
- ];
225
- for (const block of blocks) {
226
- if (!block)
227
- continue;
228
- for (const [depName, spec] of Object.entries(block)) {
229
- if (typeof spec !== 'string')
230
- continue;
231
- if (!spec.startsWith('workspace:'))
232
- continue;
233
- const dep = byName.get(depName);
234
- if (!dep)
235
- continue;
236
- if (!seen.has(dep.name))
237
- stack.push(dep);
238
- }
239
- }
240
+ console.error(`gjsify workspace: "${entry.from}" depends on "${entry.to}": ${entry.spec}, which the local ` +
241
+ `workspace (${entry.to}@${entry.version}) does not satisfy — NOT built as a dependency. ` +
242
+ `npm and yarn take that one from the registry; \`gjsify install\` links the local one ` +
243
+ `anyway, so if it is the local one you mean, make the range admit ${entry.version}.`);
240
244
  }
241
- return out;
242
245
  }
243
246
  async function runOne(ws, script, extraArgs, runner, verbose, cache) {
244
247
  // Build-cache fast path: on a key hit restore the stored outputs and
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // This file is just the executable wrapper around `runCli`.
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { runCli } from './cli-app.js';
9
+ import { exitOnReportedFailure } from './utils/cli-exit.js';
9
10
  import { gjsExit } from '@gjsify/rolldown-plugin-gjsify/runtime';
10
11
  try {
11
12
  await runCli(hideBin(process.argv));
@@ -43,3 +44,12 @@ catch (err) {
43
44
  process.exitCode = 1;
44
45
  gjsExit(1);
45
46
  }
47
+ // The catch above is only HALF the entry's exit funnel, and the other half was
48
+ // missing: a handler that reports a failure by assigning `process.exitCode` and
49
+ // returning normally never reaches it. That is the Node idiom and the CLI uses
50
+ // it wherever the failure is already printed in full (`gjsify gresource`,
51
+ // `gjsify gsettings`, `gjsify barrels --check`, `setOxcExitCode`) — and under
52
+ // GJS, which has no atexit hook, nothing read it back, so the process ended 0
53
+ // with the error on stderr. `cli-exit.ts` carries the measurement and why `&&`
54
+ // did not protect against it (JumpLink/Learn6502#180).
55
+ exitOnReportedFailure();
@@ -11,9 +11,19 @@ import { type Workspace } from '@gjsify/workspace';
11
11
  * still on v2's whole-dir ownership and could restore over a sibling unit; a v2
12
12
  * manifest for them records `lib`, so it must not be replayed against the
13
13
  * per-child units this version stores.
14
+ *
15
+ * v4: the input set is now `packageBuildInputs` instead of `src/**` + the
16
+ * manifests (#1651). Every v3 key was computed over a strictly narrower set,
17
+ * so replaying one would report a hit for a change it could not have seen.
18
+ */
19
+ export declare const BUILD_CACHE_LAYOUT_VERSION = "v4";
20
+ /**
21
+ * Conventional per-package build output directories, checked in this order.
22
+ *
23
+ * Deliberately the same constant the input definition subtracts: a directory
24
+ * this cache STORES as an output must never also be hashed as an input, or
25
+ * storing it would invalidate the key it was stored under.
14
26
  */
15
- export declare const BUILD_CACHE_LAYOUT_VERSION = "v3";
16
- /** Conventional per-package build output directories, checked in this order. */
17
27
  export declare const OUTPUT_DIR_CANDIDATES: readonly ["lib", "dist", "dist-templates"];
18
28
  /** Keep at most this many keys per package; evict the least recently stored/used. */
19
29
  export declare const MAX_KEYS_PER_PACKAGE = 2;
@@ -45,8 +55,9 @@ export declare function sanitizePackageDirName(name: string): string;
45
55
  /** Streamed sha256 of one file (bounded memory, real contents). */
46
56
  export declare function hashFileStream(path: string): string;
47
57
  /**
48
- * Content hash of a package's build INPUTS: every file under `src/**`, plus
49
- * `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
58
+ * Content hash of a package's build INPUTS — the set is
59
+ * {@link packageBuildInputs}, shared with `gjsify test`'s freshness check so
60
+ * the two cannot drift about what an input is. Sorted, content-streamed:
50
61
  * mtimes do not participate, so a `git checkout` with identical contents
51
62
  * hashes identically across machines.
52
63
  */