@gjsify/cli 0.14.0 → 0.16.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.
@@ -1,4 +1,6 @@
1
+ import { type NpmrcConfig } from '@gjsify/npm-registry';
1
2
  import type { Command } from '../types/index.js';
3
+ import { OtpProvider } from '../utils/npm-otp.js';
2
4
  import { type TrustEntry } from '../utils/trust-registry.js';
3
5
  interface TrustOptions {
4
6
  packages?: string[];
@@ -13,4 +15,23 @@ interface TrustOptions {
13
15
  'no-private'?: boolean;
14
16
  }
15
17
  export declare const trustCommand: Command<unknown, TrustOptions>;
18
+ /** Parsed result of a trust-registry request. */
19
+ export interface TrustRequestResult {
20
+ status: number;
21
+ json: unknown;
22
+ text: string;
23
+ }
24
+ /** A `(method, url, body?) => TrustRequestResult` function bound to a registry auth + OTP context. */
25
+ export type TrustRequester = (method: string, url: string, body?: unknown) => Promise<TrustRequestResult>;
26
+ /**
27
+ * Build a trust-registry requester bound to an npmrc + a shared
28
+ * {@link OtpProvider}. Each request runs through `withOtpRetry` so a 2FA
29
+ * challenge reuses the cached code before prompting — the same instance passed
30
+ * to `createTrustRequester` and to the publish flow shares ONE OTP across a
31
+ * whole `gjsify onboard` sweep.
32
+ */
33
+ export declare function createTrustRequester(ctx: {
34
+ npmrc: NpmrcConfig;
35
+ otpProvider: OtpProvider;
36
+ }): TrustRequester;
16
37
  export type { TrustEntry };
@@ -21,7 +21,7 @@ import { join } from 'node:path';
21
21
  import { DEFAULT_REGISTRY, buildHeaders, registryFor, whoami } from '@gjsify/npm-registry';
22
22
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
23
23
  import { hasAnyCredential, loadNpmrc } from '../utils/load-npmrc.js';
24
- import { promptLine } from '../utils/prompt.js';
24
+ import { OtpProvider, withOtpRetry } from '../utils/npm-otp.js';
25
25
  import { classifyTrustList, githubTrustBody, normalizeWorkflowFile, parseRepoFromGitRemote, trustUrl, validateRepository, } from '../utils/trust-registry.js';
26
26
  export const trustCommand = {
27
27
  command: 'trust [packages..]',
@@ -148,63 +148,13 @@ export const trustCommand = {
148
148
  if (dryRun)
149
149
  console.log('(dry-run — nothing will be written)');
150
150
  console.log();
151
- // A one-shot OTP supplied via --otp, used on the FIRST challenge then
152
- // cleared (a TOTP code is single-use within its ~30s window). After the
153
- // first successful OTP the registry opens a ~5-min skip window, so we
154
- // do NOT cache + re-send the code — that would re-challenge once it
155
- // expires. Instead each request tries WITHOUT an OTP first (the window
156
- // covers it) and only prompts when actually challenged. Mirrors npm's
157
- // `otplease`.
158
- let pendingOtp = args.otp;
159
- const request = async (method, url, body) => {
160
- const doFetch = async (code) => {
161
- const headers = buildHeaders(url, { npmrc });
162
- headers['accept'] = 'application/json';
163
- if (body !== undefined)
164
- headers['content-type'] = 'application/json';
165
- if (code)
166
- headers['npm-otp'] = code;
167
- return fetch(url, {
168
- method,
169
- headers,
170
- body: body === undefined ? undefined : JSON.stringify(body),
171
- });
172
- };
173
- // 1) Try without an OTP — succeeds outright (read) or via an active
174
- // 2FA-skip window.
175
- let res = await doFetch();
176
- // 2) On a challenge, use the --otp (once) or prompt, and retry.
177
- if (await isOtpChallenge(res)) {
178
- let code = pendingOtp;
179
- pendingOtp = undefined; // one-shot — never reuse a stale code
180
- if (!code) {
181
- code = await promptLine('This operation requires a one-time password.\nEnter OTP: ');
182
- }
183
- if (!code) {
184
- console.error('gjsify trust: no OTP entered.');
185
- process.exit(1);
186
- }
187
- res = await doFetch(code);
188
- // 3) Still challenged → the code was stale/expired. Prompt fresh once.
189
- if (await isOtpChallenge(res)) {
190
- const fresh = await promptLine('OTP rejected (expired?) — enter a fresh code: ');
191
- if (!fresh) {
192
- console.error('gjsify trust: no OTP entered.');
193
- process.exit(1);
194
- }
195
- res = await doFetch(fresh);
196
- }
197
- }
198
- const text = await res.text().catch(() => '');
199
- let json = undefined;
200
- try {
201
- json = text ? JSON.parse(text) : undefined;
202
- }
203
- catch {
204
- /* non-JSON body — leave json undefined */
205
- }
206
- return { status: res.status, json, text };
207
- };
151
+ // Cache-first OTP across the whole sweep (shared helper). Each request
152
+ // tries WITHOUT an OTP first (npm's ~5-min 2FA-skip window covers the
153
+ // packages after the first success) and only sends a code when actually
154
+ // challenged: the cached `--otp` / last-typed code first, then a fresh
155
+ // prompt when npm rejects it. See utils/npm-otp.ts.
156
+ const otpProvider = new OtpProvider(args.otp);
157
+ const request = createTrustRequester({ npmrc, otpProvider });
208
158
  let ok = 0;
209
159
  let skipped = 0;
210
160
  let unpublished = 0;
@@ -288,18 +238,38 @@ export const trustCommand = {
288
238
  }
289
239
  },
290
240
  };
291
- /** True when a response is an npm OTP / 2FA challenge. */
292
- async function isOtpChallenge(res) {
293
- if (res.status !== 401)
294
- return false;
295
- const wwwAuth = (res.headers.get('www-authenticate') ?? '').toLowerCase();
296
- if (wwwAuth.includes('otp'))
297
- return true;
298
- const text = await res
299
- .clone()
300
- .text()
301
- .catch(() => '');
302
- return /one-time pass/i.test(text) || /\bEOTP\b/.test(text);
241
+ /**
242
+ * Build a trust-registry requester bound to an npmrc + a shared
243
+ * {@link OtpProvider}. Each request runs through `withOtpRetry` so a 2FA
244
+ * challenge reuses the cached code before prompting — the same instance passed
245
+ * to `createTrustRequester` and to the publish flow shares ONE OTP across a
246
+ * whole `gjsify onboard` sweep.
247
+ */
248
+ export function createTrustRequester(ctx) {
249
+ return async (method, url, body) => {
250
+ const doFetch = (code) => {
251
+ const headers = buildHeaders(url, { npmrc: ctx.npmrc });
252
+ headers['accept'] = 'application/json';
253
+ if (body !== undefined)
254
+ headers['content-type'] = 'application/json';
255
+ if (code)
256
+ headers['npm-otp'] = code;
257
+ const init = { method, headers };
258
+ if (body !== undefined)
259
+ init.body = JSON.stringify(body);
260
+ return fetch(url, init);
261
+ };
262
+ const res = await withOtpRetry(doFetch, ctx.otpProvider);
263
+ const text = await res.text().catch(() => '');
264
+ let json = undefined;
265
+ try {
266
+ json = text ? JSON.parse(text) : undefined;
267
+ }
268
+ catch {
269
+ /* non-JSON body — leave json undefined */
270
+ }
271
+ return { status: res.status, json, text };
272
+ };
303
273
  }
304
274
  function firstLine(s) {
305
275
  return s.split('\n')[0].slice(0, 200);
@@ -7,6 +7,8 @@ interface WorkspaceCmdOptions {
7
7
  'continue-on-error'?: boolean;
8
8
  'include-dev'?: boolean;
9
9
  verbose?: boolean;
10
+ cached?: boolean;
10
11
  }
11
12
  export declare const workspaceCommand: Command<unknown, WorkspaceCmdOptions>;
13
+ export declare function detectPackageManager(): 'yarn' | 'npm' | 'gjsify';
12
14
  export {};
@@ -15,9 +15,11 @@
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';
22
+ import { suggestClosest } from '../utils/suggest.js';
21
23
  export const workspaceCommand = {
22
24
  command: 'workspace <name> <script> [args..]',
23
25
  description: 'Run a workspace script (`yarn workspace <name> run <script>` equivalent).',
@@ -41,7 +43,7 @@ export const workspaceCommand = {
41
43
  // `-t`/`--topological` is the same flag spelled the way
42
44
  // `gjsify foreach` already spells it — kept as an alias so
43
45
  // 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.',
46
+ 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
47
  type: 'boolean',
46
48
  alias: ['d', 't', 'topological'],
47
49
  default: false,
@@ -61,6 +63,10 @@ export const workspaceCommand = {
61
63
  type: 'boolean',
62
64
  alias: 'v',
63
65
  default: false,
66
+ })
67
+ .option('cached', {
68
+ 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.',
69
+ type: 'boolean',
64
70
  }),
65
71
  handler: async (args) => {
66
72
  // Walk up to the monorepo root — `gjsify workspace` is often
@@ -71,8 +77,21 @@ export const workspaceCommand = {
71
77
  const allWorkspaces = discoverWorkspaces(root);
72
78
  const target = allWorkspaces.find((w) => w.name === args.name);
73
79
  if (!target) {
74
- console.error(`gjsify workspace: no workspace named "${args.name}" — discovered ${allWorkspaces.length} workspace(s)`);
75
- process.exit(1);
80
+ // IMPORTANT: `return process.exit(N)`, not a bare `process.exit(N)`.
81
+ // Under GJS `process.exit` cannot terminate synchronously (a parked
82
+ // GLib.MainLoop owns the thread) — it SCHEDULES the exit on a
83
+ // GLib.idle source and returns a forever-pending Promise. A bare
84
+ // call therefore falls through to the next statements, dereferences
85
+ // `target.manifest` on `undefined`, and the resulting throw races
86
+ // the already-scheduled exit into a SECOND `system.exit` →
87
+ // `m_should_exit assertion failed` → core dump. `return`-ing halts
88
+ // the handler so exactly one exit is scheduled. (No-op under Node,
89
+ // where `process.exit` never returns.)
90
+ const suggestion = suggestClosest(args.name, allWorkspaces.map((w) => w.name));
91
+ const hint = suggestion ? ` — did you mean "${suggestion}"?` : '';
92
+ console.error(`gjsify workspace: no workspace named "${args.name}"${hint} ` +
93
+ `(discovered ${allWorkspaces.length} workspace(s) under ${root})`);
94
+ return process.exit(1);
76
95
  }
77
96
  const runner = detectPackageManager();
78
97
  const verbose = args.verbose === true;
@@ -80,8 +99,12 @@ export const workspaceCommand = {
80
99
  const continueOnError = args['continue-on-error'] === true;
81
100
  const targetScripts = target.manifest.scripts ?? {};
82
101
  if (typeof targetScripts[args.script] !== 'string') {
83
- console.error(`gjsify workspace: workspace "${args.name}" has no script "${args.script}"`);
84
- process.exit(1);
102
+ const scriptHint = suggestClosest(args.script, Object.keys(targetScripts));
103
+ console.error(`gjsify workspace: workspace "${args.name}" has no script "${args.script}"` +
104
+ (scriptHint ? ` — did you mean "${scriptHint}"?` : ''));
105
+ // `return process.exit` — see the not-found branch above for why a
106
+ // bare `process.exit` is unsafe under GJS.
107
+ return process.exit(1);
85
108
  }
86
109
  // Build the run-list: either just the target, or its transitive
87
110
  // workspace-dep closure in topological order followed by the
@@ -97,6 +120,20 @@ export const workspaceCommand = {
97
120
  else {
98
121
  runList = [target];
99
122
  }
123
+ // Content-hash build cache (ADR 0006 phase 1). Explicit --cached /
124
+ // --no-cached wins; GJSIFY_BUILD_CACHE=1 is the env default so root
125
+ // script chains opt in without editing every nested invocation. Keys
126
+ // are computed against ALL discovered workspaces.
127
+ const cached = args.cached ?? buildCacheEnabledByEnv();
128
+ const cache = cached
129
+ ? new BuildCacheRunner({
130
+ root,
131
+ workspaces: allWorkspaces,
132
+ script: args.script,
133
+ args: args.args ?? [],
134
+ targets: runList.map((w) => w.name),
135
+ })
136
+ : undefined;
100
137
  const failures = [];
101
138
  for (const ws of runList) {
102
139
  const scripts = ws.manifest.scripts ?? {};
@@ -104,31 +141,35 @@ export const workspaceCommand = {
104
141
  // Skip deps that don't declare the script — mirrors
105
142
  // `gjsify foreach`'s --if-present behaviour. The target
106
143
  // itself was validated above; this branch only filters
107
- // intermediate deps.
144
+ // intermediate deps. A skipped package is also never touched
145
+ // by the build cache (the no-build-package safety rule).
108
146
  if (verbose)
109
147
  console.error(`[${ws.name}] (no "${args.script}" script — skipping)`);
110
148
  continue;
111
149
  }
112
150
  try {
113
- await runOne(ws, args.script, args.args ?? [], runner, verbose);
151
+ await runOne(ws, args.script, args.args ?? [], runner, verbose, cache);
114
152
  }
115
153
  catch (err) {
116
154
  const e = err instanceof Error ? err : new Error(String(err));
117
155
  console.error(`[${ws.name}] ${e.message}`);
118
156
  if (!continueOnError) {
119
- process.exit(1);
157
+ // `return` — a bare `process.exit` would not halt under GJS
158
+ // and the loop would keep running the remaining deps.
159
+ return process.exit(1);
120
160
  }
121
161
  failures.push({ workspace: ws.name, error: e });
122
162
  }
123
163
  }
124
164
  if (failures.length > 0) {
125
165
  console.error(`gjsify workspace: ${failures.length} workspace(s) failed: ${failures.map((f) => f.workspace).join(', ')}`);
126
- process.exit(1);
166
+ return process.exit(1);
127
167
  }
128
168
  // ensureMainLoop() (called inside spawn) keeps GJS alive after the
129
169
  // child exits — without an explicit process.exit() the success path
130
- // would park the loop forever.
131
- process.exit(0);
170
+ // would park the loop forever. `return` for symmetry with the error
171
+ // paths above (see the not-found branch for the GJS rationale).
172
+ return process.exit(0);
132
173
  },
133
174
  };
134
175
  /**
@@ -175,7 +216,14 @@ function collectTransitiveClosure(target, allWorkspaces, includeDev) {
175
216
  }
176
217
  return out;
177
218
  }
178
- async function runOne(ws, script, extraArgs, runner, verbose) {
219
+ async function runOne(ws, script, extraArgs, runner, verbose, cache) {
220
+ // Build-cache fast path: on a key hit restore the stored outputs and
221
+ // skip the script; on a miss snapshot the output dirs so only what the
222
+ // script MODIFIED is stored after success (never a committed no-build
223
+ // lib/ — see utils/build-cache.ts).
224
+ if (cache && cache.tryRestore(ws))
225
+ return;
226
+ const before = cache?.snapshotOutputs(ws);
179
227
  const argv = runner === 'gjsify'
180
228
  ? ['run', script, ...extraArgs]
181
229
  : ['run', script, ...(extraArgs.length > 0 ? ['--', ...extraArgs] : [])];
@@ -202,8 +250,10 @@ async function runOne(ws, script, extraArgs, runner, verbose) {
202
250
  });
203
251
  child.on('error', reject);
204
252
  });
253
+ if (cache && before)
254
+ cache.storeAfterSuccess(ws, before);
205
255
  }
206
- function detectPackageManager() {
256
+ export function detectPackageManager() {
207
257
  // Under GJS there is no Node, so neither `npm` nor `yarn` can run scripts —
208
258
  // the only option is `gjsify` itself, resolved via the GJS shim on PATH
209
259
  // (see `ensureGjsifyShimOnPath`). This is what makes node-free multi-package
@@ -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
+ }