@gjsify/cli 0.13.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,9 @@
15
15
  // failures on uninbuilt workspace deps are the single biggest local-dev
16
16
  // pain point in this monorepo.
17
17
  import { spawn } from 'node:child_process';
18
- import { buildDependencyGraph, discoverWorkspaces, topologicalSort, } from '@gjsify/workspace';
18
+ import { buildDependencyGraph, discoverWorkspaces, topologicalSort } from '@gjsify/workspace';
19
19
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
20
+ import { BuildCacheRunner, buildCacheEnabledByEnv } from '../utils/build-cache.js';
20
21
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
21
22
  export const workspaceCommand = {
22
23
  command: 'workspace <name> <script> [args..]',
@@ -41,7 +42,7 @@ export const workspaceCommand = {
41
42
  // `-t`/`--topological` is the same flag spelled the way
42
43
  // `gjsify foreach` already spells it — kept as an alias so
43
44
  // muscle memory transfers.
44
- description: 'Pre-build the target workspace\'s transitive workspace dependencies in topological order before running the script in the target. Deps that don\'t declare the script are skipped (--if-present behaviour). Replaces manual `gjsify workspace A build && gjsify workspace B build && …` chains.',
45
+ description: "Pre-build the target workspace's transitive workspace dependencies in topological order before running the script in the target. Deps that don't declare the script are skipped (--if-present behaviour). Replaces manual `gjsify workspace A build && gjsify workspace B build && …` chains.",
45
46
  type: 'boolean',
46
47
  alias: ['d', 't', 'topological'],
47
48
  default: false,
@@ -61,6 +62,10 @@ export const workspaceCommand = {
61
62
  type: 'boolean',
62
63
  alias: 'v',
63
64
  default: false,
65
+ })
66
+ .option('cached', {
67
+ description: 'Content-hash build cache (ADR 0006): skip the script for a workspace whose inputs (src/**, package.json, tsconfig*.json, transitive workspace deps, toolchain versions) are unchanged and restore its stored outputs instead; on miss run it and store the output dirs it modified. Also applies to the deps run via --with-dependencies. Default from GJSIFY_BUILD_CACHE=1.',
68
+ type: 'boolean',
64
69
  }),
65
70
  handler: async (args) => {
66
71
  // Walk up to the monorepo root — `gjsify workspace` is often
@@ -97,6 +102,20 @@ export const workspaceCommand = {
97
102
  else {
98
103
  runList = [target];
99
104
  }
105
+ // Content-hash build cache (ADR 0006 phase 1). Explicit --cached /
106
+ // --no-cached wins; GJSIFY_BUILD_CACHE=1 is the env default so root
107
+ // script chains opt in without editing every nested invocation. Keys
108
+ // are computed against ALL discovered workspaces.
109
+ const cached = args.cached ?? buildCacheEnabledByEnv();
110
+ const cache = cached
111
+ ? new BuildCacheRunner({
112
+ root,
113
+ workspaces: allWorkspaces,
114
+ script: args.script,
115
+ args: args.args ?? [],
116
+ targets: runList.map((w) => w.name),
117
+ })
118
+ : undefined;
100
119
  const failures = [];
101
120
  for (const ws of runList) {
102
121
  const scripts = ws.manifest.scripts ?? {};
@@ -104,13 +123,14 @@ export const workspaceCommand = {
104
123
  // Skip deps that don't declare the script — mirrors
105
124
  // `gjsify foreach`'s --if-present behaviour. The target
106
125
  // itself was validated above; this branch only filters
107
- // intermediate deps.
126
+ // intermediate deps. A skipped package is also never touched
127
+ // by the build cache (the no-build-package safety rule).
108
128
  if (verbose)
109
129
  console.error(`[${ws.name}] (no "${args.script}" script — skipping)`);
110
130
  continue;
111
131
  }
112
132
  try {
113
- await runOne(ws, args.script, args.args ?? [], runner, verbose);
133
+ await runOne(ws, args.script, args.args ?? [], runner, verbose, cache);
114
134
  }
115
135
  catch (err) {
116
136
  const e = err instanceof Error ? err : new Error(String(err));
@@ -175,7 +195,14 @@ function collectTransitiveClosure(target, allWorkspaces, includeDev) {
175
195
  }
176
196
  return out;
177
197
  }
178
- async function runOne(ws, script, extraArgs, runner, verbose) {
198
+ async function runOne(ws, script, extraArgs, runner, verbose, cache) {
199
+ // Build-cache fast path: on a key hit restore the stored outputs and
200
+ // skip the script; on a miss snapshot the output dirs so only what the
201
+ // script MODIFIED is stored after success (never a committed no-build
202
+ // lib/ — see utils/build-cache.ts).
203
+ if (cache && cache.tryRestore(ws))
204
+ return;
205
+ const before = cache?.snapshotOutputs(ws);
179
206
  const argv = runner === 'gjsify'
180
207
  ? ['run', script, ...extraArgs]
181
208
  : ['run', script, ...(extraArgs.length > 0 ? ['--', ...extraArgs] : [])];
@@ -202,6 +229,8 @@ async function runOne(ws, script, extraArgs, runner, verbose) {
202
229
  });
203
230
  child.on('error', reject);
204
231
  });
232
+ if (cache && before)
233
+ cache.storeAfterSuccess(ws, before);
205
234
  }
206
235
  function detectPackageManager() {
207
236
  // Under GJS there is no Node, so neither `npm` nor `yarn` can run scripts —
@@ -0,0 +1,157 @@
1
+ import { type Workspace } from '@gjsify/workspace';
2
+ /** Bump to invalidate every existing cache entry (schema/semantic changes). */
3
+ export declare const BUILD_CACHE_LAYOUT_VERSION = "v1";
4
+ /** Conventional per-package build output directories, checked in this order. */
5
+ export declare const OUTPUT_DIR_CANDIDATES: readonly ["lib", "dist", "dist-templates"];
6
+ /** Keep at most this many keys per package; evict the least recently stored/used. */
7
+ export declare const MAX_KEYS_PER_PACKAGE = 2;
8
+ /** Toolchain packages whose resolved versions salt every cache key. */
9
+ export declare const TOOLCHAIN_PACKAGES: readonly ["@gjsify/cli", "@gjsify/tsc", "rolldown", "typescript"];
10
+ export interface BuildCacheManifest {
11
+ version: 1;
12
+ layout: string;
13
+ package: string;
14
+ script: string;
15
+ key: string;
16
+ /** Output dirs (relative to the package dir) the build modified. */
17
+ dirs: string[];
18
+ /** Content hash of the stored output dirs (mtime-independent). */
19
+ outputsHash: string;
20
+ createdAt: string;
21
+ usedAt: string;
22
+ }
23
+ /** `true` when GJSIFY_BUILD_CACHE is set to a non-empty, non-"0"/"false" value. */
24
+ export declare function buildCacheEnabledByEnv(env?: Record<string, string | undefined>): boolean;
25
+ /** Cache root for a workspace: `<root>/node_modules/.cache/gjsify/build`. */
26
+ export declare function buildCacheRoot(workspaceRoot: string): string;
27
+ /**
28
+ * Directory-safe package name. Scoped names collapse (`@gjsify/cli` →
29
+ * `gjsify-cli`); an 8-hex sha256 suffix of the exact name rules out
30
+ * collisions between e.g. `@a/b-c` and `@a-b/c`.
31
+ */
32
+ export declare function sanitizePackageDirName(name: string): string;
33
+ /** Streamed sha256 of one file (bounded memory, real contents). */
34
+ export declare function hashFileStream(path: string): string;
35
+ /**
36
+ * Content hash of a package's build INPUTS: every file under `src/**`, plus
37
+ * `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
38
+ * mtimes do not participate, so a `git checkout` with identical contents
39
+ * hashes identically across machines.
40
+ */
41
+ export declare function hashPackageInputs(location: string): string;
42
+ /**
43
+ * Mtime-independent content hash of a set of output dirs — used to skip the
44
+ * restore copy when the on-disk outputs already match the cached entry, and
45
+ * as an integrity stamp of what was stored.
46
+ */
47
+ export declare function hashOutputDirs(location: string, dirs: readonly string[]): string;
48
+ /**
49
+ * Cheap per-dir change signature (existence + file list + sizes + mtimes).
50
+ * Taken BEFORE running the script and compared AFTER, to detect which output
51
+ * dirs the build actually modified. Content hashing is deliberately avoided
52
+ * here — a build that rewrites identical bytes still bumps mtimes, and that
53
+ * is exactly the "this dir belongs to the build" signal we want.
54
+ */
55
+ export declare function snapshotOutputDirs(location: string): Record<string, string | null>;
56
+ /** Output dirs whose signature changed (or that newly appeared) since `before`. */
57
+ export declare function modifiedOutputDirs(location: string, before: Record<string, string | null>): string[];
58
+ export interface ComposeCacheKeyInput {
59
+ script: string;
60
+ /** Extra args forwarded to the script invocation (affect the outputs). */
61
+ args?: readonly string[];
62
+ /** Toolchain package → resolved version (or 'none'). */
63
+ toolchain: Record<string, string>;
64
+ /** The package's own input hash. */
65
+ ownHash: string;
66
+ /** dep name → input hash for the FULL transitive workspace-dep closure. */
67
+ depHashes: ReadonlyMap<string, string> | Record<string, string>;
68
+ }
69
+ /** Deterministic cache key (32 hex chars) from all invalidation ingredients. */
70
+ export declare function composeCacheKey(input: ComposeCacheKeyInput): string;
71
+ /**
72
+ * Resolve the toolchain versions that salt every key from the workspace's
73
+ * OWN resolution (`<root>/node_modules/<pkg>/package.json`). In the gjsify
74
+ * monorepo these are symlinks into the workspace itself, so a CLI/tsc bump
75
+ * invalidates immediately. A package that isn't resolvable maps to 'none'
76
+ * (deterministic). `@gjsify/cli` additionally falls back to the RUNNING
77
+ * CLI's own version so globally-installed CLIs still salt correctly.
78
+ */
79
+ export declare function resolveToolchainVersions(workspaceRoot: string): Record<string, string>;
80
+ /** Directory of one cache entry: `<cacheRoot>/<pkg-sanitized>/<key>`. */
81
+ export declare function buildCacheEntryDir(cacheRoot: string, pkgName: string, key: string): string;
82
+ /** Read + validate a cache entry's manifest; any mismatch is a MISS (null). */
83
+ export declare function readCacheEntry(cacheRoot: string, pkgName: string, key: string): BuildCacheManifest | null;
84
+ /**
85
+ * Store the given output dirs of a package under `key`. Atomic: builds a
86
+ * `.tmp-<pid>` sibling then renames into place, so concurrent readers never
87
+ * observe a partial entry. Best-effort — failures are swallowed (the build
88
+ * itself already succeeded).
89
+ */
90
+ export declare function storeCacheEntry(cacheRoot: string, pkg: {
91
+ name: string;
92
+ location: string;
93
+ }, key: string, script: string, dirs: readonly string[]): BuildCacheManifest | null;
94
+ /**
95
+ * Restore a validated entry into the package. Replaces EXACTLY the dirs the
96
+ * manifest records (delete-then-copy within the package); skips the copy
97
+ * when the on-disk outputs already content-match the entry. Returns how the
98
+ * hit was satisfied, or null when restoration failed (treat as MISS).
99
+ */
100
+ export declare function restoreCacheEntry(cacheRoot: string, pkg: {
101
+ name: string;
102
+ location: string;
103
+ }, manifest: BuildCacheManifest): 'restored' | 'up-to-date' | null;
104
+ /** Evict the oldest entries (by last store/use) beyond `keep` per package. */
105
+ export declare function pruneCacheEntries(cacheRoot: string, pkgName: string, keep?: number): void;
106
+ export interface BuildCacheRunnerOptions {
107
+ /** Workspace (monorepo) root — owns node_modules/.cache. */
108
+ root: string;
109
+ /** ALL discovered workspaces (not just the selected set) for dep closure. */
110
+ workspaces: readonly Workspace[];
111
+ /** Script name being run (part of every key). */
112
+ script: string;
113
+ /** Extra args forwarded to every invocation (part of every key). */
114
+ args?: readonly string[];
115
+ /**
116
+ * Workspace names that will be run. Their input hashes (plus their
117
+ * closures') are computed EAGERLY at construction so every key reflects
118
+ * the tree state at RUN START — a build that mutates its own `src/`
119
+ * (adwaita-web's generated stylesheet module) must not make a sibling's
120
+ * key depend on scheduling order within the same run.
121
+ */
122
+ targets?: readonly string[];
123
+ /** Log sink (default: stderr, keeping stdout clean for script output). */
124
+ log?: (line: string) => void;
125
+ }
126
+ export declare function buildCacheOptedOut(ws: Workspace): boolean;
127
+ /**
128
+ * Per-run orchestrator used by `gjsify workspace` / `gjsify foreach`:
129
+ * memoizes input hashes + keys across packages, restores on hit, snapshots +
130
+ * stores on miss. All entry points are fail-soft — any internal error logs a
131
+ * warning and reports a MISS so the script runs normally.
132
+ */
133
+ export declare class BuildCacheRunner {
134
+ private readonly options;
135
+ private readonly cacheRoot;
136
+ private readonly graph;
137
+ private readonly toolchain;
138
+ private readonly ownHashes;
139
+ private readonly closures;
140
+ private readonly keys;
141
+ private readonly log;
142
+ constructor(options: BuildCacheRunnerOptions);
143
+ private ownHashOf;
144
+ /** BFS transitive workspace-dep closure (cycle-safe), excluding `name` itself. */
145
+ private closureOf;
146
+ /** Content-hash cache key for one workspace (memoized per run). */
147
+ keyFor(ws: Workspace): string;
148
+ /**
149
+ * Try to satisfy the script from the cache. Returns `true` on a hit
150
+ * (outputs restored / already current — the caller SKIPS the script).
151
+ */
152
+ tryRestore(ws: Workspace): boolean;
153
+ /** Snapshot the candidate output dirs BEFORE running the script. */
154
+ snapshotOutputs(ws: Workspace): Record<string, string | null>;
155
+ /** After a SUCCESSFUL script run: store the dirs the build modified. */
156
+ storeAfterSuccess(ws: Workspace, before: Record<string, string | null>): void;
157
+ }