@cratis/pi 7.104.0 → 7.105.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.
@@ -55,7 +55,7 @@ Commands (COMMAND --help shows these instructions too):
55
55
  consumer resolves must be STAMP (none resolved: exit 1).
56
56
  verify-pack --composition FILE [--json]
57
57
  verify-assets CONSUMER-PATH --composition FILE [--json]
58
- use-nuget CONSUMER --composition FILE [--pin-transitive]
58
+ use-nuget CONSUMER --composition FILE [--task ID] [--pin-transitive] [--private-cache]
59
59
  Opt-in mixed-version NuGet closure: tab-separated producer NAME STAMP
60
60
  ABSOLUTE_WORKTREE rows, then package PRODUCER PACKAGE_ID rows. Case-insensitive
61
61
  IDs have one owner. All declared artifacts must exist; nothing is built/repacked.
@@ -66,25 +66,34 @@ Commands (COMMAND --help shows these instructions too):
66
66
  The next mutation recovers interrupted work, or refuses and preserves unknown
67
67
  ownership/drift. Active compositions retain member artifacts. Restore before
68
68
  changing a composition or switching between it and a legacy overlay.
69
- pack-nuget PRODUCER STAMP [--property NAME=VALUE]... PROJECT-OR-SLN...
69
+ list [--json] [--stamp STAMP]
70
+ Read-only inventory of feed artifacts and stamp records, including legacy stamps.
71
+ Shows producer, task state, live/total consumers and age. Requires node; npm
72
+ package names are read from tarballs with tar. Takes no session lock.
73
+ finish --task ID [--apply]
74
+ Print records naming ID; --apply marks that task finished. No match is success.
75
+ Finishing never restores consumers or removes packages. Requires node.
76
+ pack-nuget PRODUCER STAMP [--task ID] [--property NAME=VALUE]... PROJECT-OR-SLN...
70
77
  Release-pack each target into the NuGet feed; requires dotnet. Each --property
71
78
  becomes one -p:NAME=VALUE argument of that command only (closure prints the
72
79
  properties of each map pack row); no environment variable carries them.
73
- pack-npm PRODUCER STAMP
80
+ pack-npm PRODUCER STAMP [--task ID]
74
81
  Requires node, npm and Yarn 4, and a completed repository build. Temporarily
75
82
  version non-private workspaces, pack each with npm pack --ignore-scripts (the
76
83
  publish workflows' tool, so the tarball matches the published one) as
77
84
  <scope>-<name>-<version>.tgz, and restore tracked manifests even on failure.
78
85
  This helper does not build or run lifecycle scripts.
79
- use-nuget CONSUMER STAMP [--pin-transitive] [PACKAGE-ID...]
86
+ use-nuget CONSUMER STAMP [--task ID] [--pin-transitive] [--private-cache] [PACKAGE-ID...]
80
87
  Create a CPM overlay importing the repository's own Directory.Packages.props.
81
88
  Transitive pinning stays as the consumer sets it; --pin-transitive forces it on so
82
89
  stamped IDs the consumer does not pin still take effect (can raise NU1109 floors).
83
90
  Defaults to every stamped package ID in the feed. Prints properties and sourceable
84
91
  env. With source mapping, also prints the required restore --configfile.
92
+ --private-cache (or CRATIS_LOCAL_PRIVATE_CACHE=1) exports NUGET_PACKAGES in env
93
+ and registers a marked cache at <absolute-git-dir>/cratis-local-nuget.
85
94
  Non-CPM: manually use an untracked project copy with explicit PackageReference
86
95
  versions and dotnet restore --source FEED/nuget; do not edit shipped versions.
87
- use-npm CONSUMER STAMP [--dir REL]
96
+ use-npm CONSUMER STAMP [--task ID] [--dir REL]
88
97
  --dir REL names a Yarn project in a subdirectory (restore and clean follow it).
89
98
  Requires node and tar. Back up package.json, yarn.lock (including existing edits),
90
99
  and git diff; add file-tarball resolutions for every stamped feed package.
@@ -93,18 +102,37 @@ Commands (COMMAND --help shows these instructions too):
93
102
  Restore npm backups when only overlay resolutions and their local lockfile
94
103
  entries changed (including yarn install). Refuse unrelated changes and retain
95
104
  backups for review. Remove CPM/env overlays
96
- and run check. No git checkout is used on consumer files.
105
+ and run check. Keep the private NuGet cache. No git checkout is used on consumer files.
97
106
  check REPO [--staged]
98
107
  Scan added/changed tracked lines (staged-only if requested), plus all contents
99
108
  of untracked, non-ignored files. Reports path:line:pattern and file counts.
100
- clean [CONSUMER] [--stamp STAMP | --all] [--apply] [--dir REL]
109
+ clean [CONSUMER] [--stamp STAMP | --all | --expired [--online]] [--apply] [--dir REL] [--private-cache]
101
110
  Default: list all local feed/cache/image/session subjects, without deleting.
102
111
  --apply deletes listed subjects (docker rmi, never force). Refuses more than
103
112
  CRATIS_LOCAL_CLEAN_MAX subjects (default 500), or unresolved npm backups.
104
113
  Refuses selected stamps referenced by registered live consumer overlays/manifests;
105
114
  restore those consumers first. Dry-run uses the same protection.
106
115
  Known sessions are registered in FEED/sessions; an optional consumer also
107
- discovers its per-worktree session. Docker probes have a 10-second deadline.
116
+ discovers its per-worktree session. Registered private caches are also scanned;
117
+ missing/mismatched markers or unsafe cache paths refuse the entire clean (2).
118
+ clean CONSUMER --private-cache lists the whole marked cache for removal; --apply
119
+ removes it and its registration only after restore and with no live stamp.
120
+ Removes selected FEED/stamps/STAMP records with the artifacts on --apply.
121
+ --expired keeps unrecorded stamps, compositions and live references; expires
122
+ finished producing tasks, gone consumers or stamps superseded by a release.
123
+ Release evidence is offline NuGet caches unless --online opts into 10-second
124
+ NuGet/npm checks. Kept stamps succeed (0). --apply also prunes gone consumers
125
+ and missing sessions. Uses the same fuse, lock and dry-run default.
126
+ Docker probes have a 10-second deadline.
127
+
128
+ Pack/use commands (including --composition) accept --task ID, overriding
129
+ CRATIS_LOCAL_TASK, else record null. Convention: owner/repo#number. IDs match
130
+ ^[A-Za-z0-9][A-Za-z0-9._#:/-]{0,127}$ and are JSON values, never path components.
131
+ Registry mutations require node. Successful packs record producer HEAD and branch;
132
+ use records consumers after publication; restore retains their history.
133
+ Invalid records: pack/use and selected clean subjects refuse with 2. Restore reads only
134
+ session stamps after restoring overlays; finish skips invalid records with WARNINGs.
135
+ Restore/finish exit 1 for invalid records; list shows invalid rows and exits 1.
108
136
  --self-test
109
137
  Isolated scratch HOME tests; npm uses inherited PATH to discover node.
110
138
  Other tests use system PATH. Reports passed and skipped counts.
@@ -114,6 +142,8 @@ if present (trusted shell code), then use these defaults for unset variables:
114
142
  CRATIS_LOCAL_FEED=${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed
115
143
  CRATIS_LOCAL_SESSION_DIR=<absolute-git-dir>/cratis-local (per checkout)
116
144
  NUGET_PACKAGES=$HOME/.nuget/packages
145
+ CRATIS_LOCAL_PRIVATE_CACHE=0 (1 opts use-nuget into a private worktree cache)
146
+ CRATIS_LOCAL_TASK=optional task ID (overridden by --task ID)
117
147
  CRATIS_LOCAL_CLEAN_MAX=500
118
148
  CRATIS_LOCAL_RUN=optional executable wrapper, invoked for each heavy step as:
119
149
  "$CRATIS_LOCAL_RUN" build LABEL -- COMMAND ARGUMENTS...
@@ -229,7 +259,7 @@ validate_session() {
229
259
  [ -e "$entry" ] || [ -L "$entry" ] || continue
230
260
  [ ! -L "$entry" ] || die "symlink in session: $entry"
231
261
  case "${entry##*/}" in
232
- repo|stamp|env|npm-dir|Directory.Packages.props|cratis-local.targets|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
262
+ repo|stamp|env|npm-dir|nuget-cache|Directory.Packages.props|cratis-local.targets|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
233
263
  npm-backup|composition) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
234
264
  *) die "unrecognized file in session (preserved): $entry" ;;
235
265
  esac
@@ -246,6 +276,50 @@ register_session() {
246
276
  printf '%s\n' "$session" >> "$FEED/sessions"
247
277
  fi
248
278
  }
279
+ # Validate every helper-owned path component before trusting a cache registration.
280
+ validate_private_cache() {
281
+ local repo=$1 cache=$2 git_dir parent relative
282
+ git_dir=$(git -C "$repo" rev-parse --absolute-git-dir) || die "cannot find cache repository: $repo"
283
+ case "$cache" in "$git_dir/"*) relative=${cache#"$git_dir/"} ;; *) die "private cache outside repository git directory: $cache" ;; esac
284
+ case "/$relative/" in */../*|*/./*|*//*) die "unsafe private cache path: $cache" ;; esac
285
+ parent=$cache
286
+ while [ "$parent" != "$git_dir" ]; do
287
+ [ -d "$parent" ] && [ ! -L "$parent" ] || die "unsafe private cache directory: $parent"
288
+ parent=${parent%/*}
289
+ done
290
+ [ -f "$cache/.cratis-local-cache" ] && [ ! -L "$cache/.cratis-local-cache" ] || die "private cache marker missing: $cache"
291
+ [ "$(cat "$cache/.cratis-local-cache")" = "$repo" ] || die "private cache marker mismatch: $cache"
292
+ }
293
+ registered_private_cache() {
294
+ local session=$1 repo cache
295
+ validate_session "$session"
296
+ [ -f "$session/repo" ] || die "private cache session has no repository: $session"
297
+ repo=$(cat "$session/repo"); cache=$(cat "$session/nuget-cache")
298
+ [ "$(repo_root "$repo")" = "$repo" ] && [ "$(session_path "$repo")" = "$session" ] || die "private cache session no longer matches repository: $session"
299
+ validate_private_cache "$repo" "$cache"
300
+ printf '%s\n' "$cache"
301
+ }
302
+ prepare_private_cache() {
303
+ local repo=$1 cache=$2 session=$3
304
+ validate_session "$session"
305
+ if [ -e "$cache" ] || [ -L "$cache" ]; then validate_private_cache "$repo" "$cache"; fi
306
+ if [ -f "$session/nuget-cache" ]; then
307
+ [ "$(registered_private_cache "$session")" = "$cache" ] || die "different private cache already registered: $session"
308
+ fi
309
+ }
310
+ register_private_cache() {
311
+ local repo=$1 cache=$2 session=$3
312
+ prepare_private_cache "$repo" "$cache" "$session"
313
+ mkdir -p "$cache"
314
+ printf '%s\n' "$repo" > "$cache/.cratis-local-cache"
315
+ printf '%s\n' "$cache" > "$session/nuget-cache"
316
+ }
317
+ private_cache_restored() {
318
+ local session=$1 entry
319
+ for entry in stamp env Directory.Packages.props cratis-local.targets nuget.config npm-backup composition; do
320
+ [ ! -e "$session/$entry" ] && [ ! -L "$session/$entry" ] || die "private cache still referenced by $session/$entry; restore first"
321
+ done
322
+ }
249
323
  xml_escape() { printf '%s' "$1" | sed -e 's/\&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g' -e "s/'/\&apos;/g"; }
250
324
 
251
325
  # Next patch of the highest reachable vX.Y.Z tag; sets NEXT_BASE.
@@ -313,9 +387,326 @@ stamp() {
313
387
  printf '%s-local.%s.g%.7s\n' "$base" "$(date -u +%Y%m%d%H%M)" "$sha"
314
388
  }
315
389
 
390
+ # All stamp-record mutations use this path, after builds or overlay publication.
391
+ registry_update() {
392
+ [ "$DRY" -eq 0 ] || return 0
393
+ [ -n "$SESSION_LOCK" ] || acquire_session_lock
394
+ registry "$@"
395
+ }
396
+ registry_consumer() {
397
+ if ! (registry_update use "$@"); then
398
+ printf 'WARNING: stamp registry update failed; overlay kept; restore before cleaning\n' >&2
399
+ exit 1
400
+ fi
401
+ }
402
+ registry() {
403
+ need node
404
+ case "$1" in list|expiry) expiry_context "$@" ;; esac
405
+ node - "$FEED" "$TASK" "$TMP" "$@" <<'REGISTRY'
406
+ const fs = require('node:fs'), path = require('node:path'), cp = require('node:child_process');
407
+ const [feed, taskValue, scratch, operation, ...args] = process.argv.slice(2);
408
+ const task = taskValue || null, root = path.join(feed, 'stamps');
409
+ const stampPattern = /^([0-9]+\.[0-9]+\.[0-9]+)-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}$/;
410
+ const taskPattern = /^[A-Za-z0-9][A-Za-z0-9._#:/-]{0,127}$/;
411
+ const now = new Date().toISOString();
412
+ function fail(message) { throw new Error(message); }
413
+ function safe(file, directory = false) {
414
+ const relative = path.relative(feed, file);
415
+ if (relative.startsWith('..') || path.isAbsolute(relative)) fail('outside stamp registry: ' + file);
416
+ let current = feed;
417
+ for (const part of ['', ...relative.split(path.sep).filter(Boolean)]) {
418
+ current = path.join(current, part);
419
+ const stat = fs.lstatSync(current, {throwIfNoEntry:false});
420
+ if (stat && (stat.isSymbolicLink() || (current !== file || directory ? !stat.isDirectory() : !stat.isFile()))) fail('unsafe registry path: ' + current);
421
+ }
422
+ }
423
+ function recordPath(stamp) {
424
+ if (!stampPattern.test(stamp)) fail('invalid registry stamp: ' + stamp);
425
+ const file = path.join(root, stamp, 'record.json'); safe(file); return file;
426
+ }
427
+ function read(stamp) {
428
+ const file = recordPath(stamp);
429
+ if (!fs.existsSync(file)) return null;
430
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
431
+ const match = stamp.match(stampPattern);
432
+ if (value.schema !== 1 || value.stamp !== stamp || value.base !== match[1] || value.kind !== (match[2] === 'train' ? 'train' : 'local') ||
433
+ !Array.isArray(value.packs) || !Array.isArray(value.consumers) || !value.tasks || typeof value.tasks !== 'object' || Array.isArray(value.tasks) ||
434
+ !Number.isFinite(Date.parse(value.created)) || !Number.isFinite(Date.parse(value.updated))) fail('invalid stamp record: ' + file);
435
+ for (const pack of value.packs) {
436
+ if (!['nuget','npm'].includes(pack.ecosystem) || !Array.isArray(pack.ids) || pack.ids.some(id => typeof id !== 'string') ||
437
+ !pack.producer || ['repo','worktree','branch','head'].some(k => typeof pack.producer[k] !== 'string') ||
438
+ !(pack.task === null || typeof pack.task === 'string' && taskPattern.test(pack.task)) || !Number.isFinite(Date.parse(pack.time))) fail('invalid pack record: ' + file);
439
+ }
440
+ for (const consumer of value.consumers) {
441
+ if (['worktree','session','ecosystem','time'].some(k => typeof consumer[k] !== 'string') || !['nuget','npm'].includes(consumer.ecosystem) ||
442
+ !(consumer.task === null || typeof consumer.task === 'string' && taskPattern.test(consumer.task)) ||
443
+ !Number.isFinite(Date.parse(consumer.time)) || consumer.restored && !Number.isFinite(Date.parse(consumer.restored))) fail('invalid consumer record: ' + file);
444
+ }
445
+ for (const [id, state] of Object.entries(value.tasks)) {
446
+ if (!taskPattern.test(id) || !state || !(state.finished === null || typeof state.finished === 'string' && Number.isFinite(Date.parse(state.finished)))) fail('invalid task record: ' + file);
447
+ }
448
+ return value;
449
+ }
450
+ function warning(stamp, error) {
451
+ console.error('WARNING: stamp registry: ' + path.join(root, stamp, 'record.json') + ': ' + error.message);
452
+ process.exitCode = 1;
453
+ }
454
+ function sessionStamps(session) {
455
+ const files = ['stamp','Directory.Packages.props','cratis-local.targets','nuget.config','env','npm-backup/expected-package.json','composition/manifest.tsv'];
456
+ const found = new Set();
457
+ for (const name of files) {
458
+ const file = path.join(session, name);
459
+ if (!fs.existsSync(file)) continue;
460
+ for (const stamp of fs.readFileSync(file, 'utf8').match(/[0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}/g) || []) found.add(stamp);
461
+ }
462
+ for (const stamp of found) console.log(stamp);
463
+ }
464
+ function stamps() {
465
+ safe(root, true);
466
+ if (!fs.existsSync(root)) return [];
467
+ return fs.readdirSync(root).filter(s => stampPattern.test(s)).sort();
468
+ }
469
+ function write(value) {
470
+ const file = recordPath(value.stamp), next = file + '.next'; safe(next);
471
+ fs.mkdirSync(path.dirname(file), {recursive:true, mode:0o700});
472
+ value.updated = now;
473
+ fs.writeFileSync(next, JSON.stringify(value, null, 2) + '\n', {mode:0o600});
474
+ fs.chmodSync(next, 0o600); fs.renameSync(next, file);
475
+ }
476
+ function consumerState(consumer) {
477
+ if (consumer.restored) return 'restored';
478
+ if (!fs.existsSync(consumer.worktree)) return 'gone';
479
+ const git = cp.spawnSync('git', ['-C', consumer.worktree, 'rev-parse', '--show-toplevel'], {encoding:'utf8'});
480
+ if (git.status !== 0 || git.stdout.trim() !== consumer.worktree) return 'gone';
481
+ return ['env','Directory.Packages.props','cratis-local.targets','nuget.config','npm-backup','composition'].some(n => fs.existsSync(path.join(consumer.session,n))) ? 'live' : 'restored';
482
+ }
483
+ function packages() {
484
+ const found = new Map();
485
+ for (const ecosystem of ['nuget','npm']) {
486
+ const directory = path.join(feed,ecosystem); safe(directory,true);
487
+ if (!fs.existsSync(directory)) continue;
488
+ for (const name of fs.readdirSync(directory)) {
489
+ const match = name.match(/^(.*?)[.-]([0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7})\.(nupkg|tgz)$/);
490
+ if (!match || (ecosystem === 'nuget' ? match[3] !== 'nupkg' : match[3] !== 'tgz')) continue;
491
+ const file = path.join(directory,name); safe(file);
492
+ let id = match[1];
493
+ if (ecosystem === 'npm') id = JSON.parse(cp.execFileSync('tar',['-xOf',file,'package/package.json'],{encoding:'utf8'})).name;
494
+ if (typeof id !== 'string' || !id) fail('invalid package ID: ' + file);
495
+ const entries = found.get(match[2]) || [];
496
+ entries.push({id,version:match[2],ecosystem,file}); found.set(match[2],entries);
497
+ }
498
+ }
499
+ return found;
500
+ }
501
+ function worktreeGone(consumer) {
502
+ if (!fs.statSync(consumer.worktree,{throwIfNoEntry:false})?.isDirectory()) return true;
503
+ const git = cp.spawnSync('git',['-C',consumer.worktree,'rev-parse','--show-toplevel'],{encoding:'utf8'});
504
+ return git.status !== 0 || git.stdout.trim() !== consumer.worktree;
505
+ }
506
+ function gone(consumer) {
507
+ return !fs.statSync(consumer.session,{throwIfNoEntry:false})?.isDirectory() || worktreeGone(consumer);
508
+ }
509
+ function expiryInputs() {
510
+ const selectedIndex = args.indexOf('--stamp'), selected = selectedIndex < 0 ? null : args[selectedIndex + 1];
511
+ const sessions = path.join(feed,'sessions'); safe(sessions);
512
+ if (fs.existsSync(sessions)) {
513
+ for (const session of fs.readFileSync(sessions,'utf8').split('\n').filter(Boolean)) {
514
+ const repo = path.join(session,'repo');
515
+ if (fs.existsSync(repo)) console.log(fs.readFileSync(repo,'utf8').trim() + '\t' + session);
516
+ else console.log('\t' + session);
517
+ }
518
+ }
519
+ for (const stamp of selected ? [selected] : stamps()) {
520
+ let value;
521
+ try { value = read(stamp); } catch (error) { if (args[0] === 'list') continue; throw error; }
522
+ for (const c of value?.consumers || []) console.log(c.worktree + '\t' + c.session);
523
+ }
524
+ }
525
+ function contextLines(name) { return fs.readFileSync(path.join(scratch,'expiry-' + name),'utf8').split('\n').filter(Boolean); }
526
+ function atLeast(version, base) {
527
+ if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version)) return false;
528
+ const a = version.split('.').map(BigInt), b = base.split('.').map(BigInt);
529
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
530
+ return true;
531
+ }
532
+ function offlineRelease(value) {
533
+ for (const pack of value.packs.filter(p => p.ecosystem === 'nuget')) for (const id of pack.ids) {
534
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(id)) fail('invalid NuGet release ID: ' + id);
535
+ for (const cache of contextLines('caches')) {
536
+ const directory = path.join(cache,id.toLowerCase());
537
+ // A missing cache is no evidence; actual scan/read errors fail closed.
538
+ if (!fs.statSync(directory,{throwIfNoEntry:false})) continue;
539
+ for (const version of fs.readdirSync(directory).sort()) {
540
+ if (atLeast(version,value.base) && fs.statSync(path.join(directory,version)).isDirectory() &&
541
+ fs.statSync(path.join(directory,version,'.nupkg.metadata'),{throwIfNoEntry:false})?.isFile()) return 'released:' + id + '@' + version;
542
+ }
543
+ }
544
+ }
545
+ return null;
546
+ }
547
+ async function onlineRelease(value) {
548
+ for (const pack of value.packs) for (const id of pack.ids) {
549
+ // Test-only override: hermetic unreachable-host fixtures, never a feed setting.
550
+ const override = process.env.CRATIS_LOCAL_RELEASE_URL_BASE;
551
+ const url = pack.ecosystem === 'nuget' ? (override || 'https://api.nuget.org/v3-flatcontainer') + '/' + encodeURIComponent(id.toLowerCase()) + '/index.json' :
552
+ (override || 'https://registry.npmjs.org') + '/' + encodeURIComponent(id);
553
+ try {
554
+ const response = await fetch(url,{signal:AbortSignal.timeout(10000)});
555
+ if (!response.ok) continue;
556
+ const data = await response.json(), versions = pack.ecosystem === 'nuget' ? data.versions : Object.keys(data.versions || {});
557
+ if (!Array.isArray(versions)) continue;
558
+ const version = versions.find(v => typeof v === 'string' && atLeast(v,value.base));
559
+ if (version) return 'released:' + id + '@' + version;
560
+ } catch { /* Network failures are not release evidence and never fail cleanup. */ }
561
+ }
562
+ return null;
563
+ }
564
+ async function expiry(stamp, value, online = false) {
565
+ const kept = reason => ({expired:false,reasons:[],kept:[reason]});
566
+ if (!value) return kept('unrecorded; use clean --stamp ' + stamp);
567
+ if (contextLines('compositions').includes(stamp)) return kept('active composition member');
568
+ const reference = contextLines('live').find(line => line.split('\t')[0] === stamp);
569
+ if (reference) return kept('live reference in ' + reference.slice(reference.indexOf('\t') + 1));
570
+ const tasks = value.packs.map(p => p.task).filter(t => t !== null);
571
+ let reason = tasks.length && tasks.every(t => value.tasks[t]?.finished) ? 'task-finished' : null;
572
+ if (!reason && value.consumers.length && value.consumers.every(gone)) reason = 'consumers-gone';
573
+ if (!reason) reason = offlineRelease(value);
574
+ if (!reason && online) reason = await onlineRelease(value);
575
+ return reason ? {expired:true,reasons:[reason],kept:[]} : kept('active');
576
+ }
577
+ async function expired() {
578
+ const names = [...new Set([...packages().keys(),...stamps()])].sort();
579
+ // Validate every subject before reporting decisions or allowing any deletion.
580
+ const records = names.map(stamp => [stamp,read(stamp)]);
581
+ for (const [stamp,value] of records) {
582
+ const decision = await expiry(stamp,value,args.includes('--online'));
583
+ console.log([decision.expired ? 'expired' : 'kept',stamp,(decision.expired ? decision.reasons : decision.kept).join('; ')].join('\t'));
584
+ }
585
+ }
586
+ function prune() {
587
+ const values = stamps().map(read), sessions = path.join(feed,'sessions'); safe(sessions);
588
+ const before = fs.existsSync(sessions) ? fs.readFileSync(sessions,'utf8') : null;
589
+ for (const value of values.filter(Boolean)) {
590
+ // Keep a consumer while its worktree exists: its manifests are still scanned for live references.
591
+ const consumers = value.consumers.filter(c => !worktreeGone(c));
592
+ if (consumers.length !== value.consumers.length) { value.consumers = consumers; write(value); }
593
+ }
594
+ if (before !== null) {
595
+ const after = before.split('\n').filter(s => s && fs.statSync(s,{throwIfNoEntry:false})?.isDirectory()).join('\n');
596
+ const next = sessions + '.next'; safe(next);
597
+ fs.writeFileSync(next,after ? after + '\n' : '',{mode:0o600}); fs.renameSync(next,sessions);
598
+ }
599
+ }
600
+ async function list() {
601
+ const json = args.includes('--json'), index = args.indexOf('--stamp');
602
+ const selected = index < 0 ? null : args[index + 1];
603
+ const remaining = args.filter((_,i) => i !== index && i !== index + 1 || index < 0);
604
+ if (remaining.some(a => a !== '--json') || index >= 0 && !stampPattern.test(selected || '')) fail('list accepts --json and --stamp STAMP');
605
+ const artifacts = packages(), names = [...new Set([...artifacts.keys(), ...stamps()])].sort();
606
+ const rows = [];
607
+ for (const stamp of names.filter(s => !selected || s === selected)) {
608
+ let value = null, error;
609
+ try { value = read(stamp); } catch (failure) { error = failure.message; warning(stamp, failure); }
610
+ const match = stamp.match(stampPattern), created = value?.created || null;
611
+ rows.push({stamp,base:match[1],kind:match[2] === 'train' ? 'train' : 'local',recorded:error ? 'invalid' : !!value,...(error ? {error} : {}),packages:artifacts.get(stamp) || [],
612
+ producers:value?.packs.map(p => p.producer) || [],tasks:Object.entries(value?.tasks || {}).map(([id,s]) => ({id,finished:s.finished})),
613
+ consumers:value?.consumers.map(c => ({worktree:c.worktree,task:c.task,state:consumerState(c)})) || [],created,
614
+ ageSeconds:created ? Math.max(0,Math.floor((Date.now()-Date.parse(created))/1000)) : null,
615
+ expiry:error ? {expired:false,reasons:[],kept:['invalid record']} : await expiry(stamp,value)});
616
+ }
617
+ if (json) console.log(JSON.stringify({schema:1,feed,stamps:rows}));
618
+ else {
619
+ console.log('STAMP\tECOSYS\tPACKAGES(n)\tOWNER(repo@branch)\tTASK(state)\tCONSUMERS(live/total)\tAGE\tRECORDED');
620
+ for (const r of rows) console.log([r.stamp,[...new Set(r.packages.map(p => p.ecosystem))].join(',') || '-',r.packages.length,
621
+ r.producers.map(p => p.repo+'@'+p.branch).join(',') || '-',r.tasks.map(t => t.id+'('+ (t.finished ? 'finished' : 'active') +')').join(',') || '-',
622
+ r.consumers.filter(c => c.state === 'live').length+'/'+r.consumers.length,r.ageSeconds === null ? '-' : r.ageSeconds+'s',r.recorded].join('\t'));
623
+ }
624
+ }
625
+ function update() {
626
+ const [stamp, ecosystem, repo, session, cache, ...ids] = args;
627
+ let value = read(stamp);
628
+ if (!value) {
629
+ const match = stamp.match(stampPattern);
630
+ value = {schema:1,stamp,base:match[1],kind:match[2] === 'train' ? 'train' : 'local',created:now,updated:now,packs:[],consumers:[],tasks:{}};
631
+ }
632
+ if (task && !Object.hasOwn(value.tasks,task)) Object.defineProperty(value.tasks,task,{value:{finished:null},enumerable:true,writable:true,configurable:true});
633
+ if (operation === 'pack') {
634
+ const git = (...argv) => cp.execFileSync('git',['-C',repo,...argv],{encoding:'utf8'}).trim();
635
+ const producer = {repo:path.basename(repo),worktree:repo,branch:git('branch','--show-current'),head:git('rev-parse','HEAD')};
636
+ const entry = {ecosystem,ids,producer,task,time:now};
637
+ const index = value.packs.findIndex(p => p.ecosystem === ecosystem && p.producer.worktree === repo && p.task === task);
638
+ if (index < 0) value.packs.push(entry); else value.packs[index] = {...entry,ids:[...new Set([...value.packs[index].ids,...ids])]};
639
+ } else {
640
+ const entry = {worktree:repo,session,task,ecosystem,time:now,...(cache ? {cache} : {})};
641
+ const index = value.consumers.findIndex(c => c.worktree === repo && c.session === session && c.ecosystem === ecosystem && c.task === task);
642
+ if (index < 0) value.consumers.push(entry); else value.consumers[index] = entry;
643
+ }
644
+ write(value);
645
+ }
646
+ async function main() {
647
+ if (operation === 'list') await list();
648
+ else if (operation === 'expiry-inputs') expiryInputs();
649
+ else if (operation === 'expiry') await expired();
650
+ else if (operation === 'prune') prune();
651
+ else if (operation === 'pack' || operation === 'use') update();
652
+ else if (operation === 'restore-stamps') sessionStamps(args[0]);
653
+ else if (operation === 'remove') {
654
+ const file = recordPath(args[0]); read(args[0]);
655
+ if (fs.readdirSync(path.dirname(file)).some(n => n !== 'record.json')) fail('unexpected stamp directory entry');
656
+ fs.unlinkSync(file); fs.rmdirSync(path.dirname(file));
657
+ } else {
658
+ const selected = operation === 'restore' ? [...new Set(args.slice(1))] :
659
+ operation === 'clean' && args[0] && args[0] !== 'all' ? [args[0]] : stamps();
660
+ let count = 0;
661
+ for (const stamp of selected) {
662
+ let value;
663
+ try { value = read(stamp); } catch (error) {
664
+ if (operation !== 'finish' && operation !== 'restore') throw error;
665
+ warning(stamp, error); continue;
666
+ }
667
+ if (!value) continue;
668
+ if (operation === 'finish' && Object.hasOwn(value.tasks,task)) {
669
+ console.log((args.includes('--apply') ? 'finish: ' : 'would finish: ') + stamp + ' ' + task); count++;
670
+ if (args.includes('--apply')) { value.tasks[task].finished = now; write(value); }
671
+ } else if (operation === 'restore' && value.consumers.some(c => c.session === args[0] && !c.restored)) {
672
+ for (const c of value.consumers) if (c.session === args[0] && !c.restored) c.restored = now;
673
+ write(value);
674
+ } else if (operation === 'clean' && (!args[0] || args[0] === 'all' || stamp === args[0])) {
675
+ const directory = path.dirname(recordPath(stamp));
676
+ if (fs.readdirSync(directory).some(n => n !== 'record.json')) fail('unexpected stamp directory entry: ' + directory);
677
+ console.log(directory);
678
+ }
679
+ }
680
+ if (operation === 'finish' && !count) console.log('no records name task ' + task);
681
+ }
682
+ }
683
+ main().catch(error => { console.error('ERROR: stamp registry: ' + error.message); process.exitCode = 2; });
684
+ REGISTRY
685
+ }
686
+
687
+ # Compare file identities/times so a multi-producer stamp does not attribute earlier
688
+ # producers' packages to the current pack. Repacking the same ID is still recorded.
689
+ nuget_pack_files() {
690
+ node - "$FEED/nuget" "$@" <<'NODE'
691
+ const fs = require('node:fs'), path = require('node:path');
692
+ const [directory, stamp, before] = process.argv.slice(2);
693
+ const files = {};
694
+ for (const name of fs.existsSync(directory) ? fs.readdirSync(directory) : []) {
695
+ if (!name.endsWith('.' + stamp + '.nupkg')) continue;
696
+ const file = path.join(directory,name), st = fs.statSync(file, {bigint:true});
697
+ files[file] = [st.ino,st.size,st.mtimeNs,st.ctimeNs].map(String).join(':');
698
+ }
699
+ if (!before) console.log(JSON.stringify(files));
700
+ else {
701
+ const previous = JSON.parse(fs.readFileSync(before,'utf8'));
702
+ for (const [file,identity] of Object.entries(files)) if (previous[file] !== identity) process.stdout.write(file + '\0');
703
+ }
704
+ NODE
705
+ }
706
+
316
707
  pack_nuget() {
317
708
  [ $# -ge 3 ] || die 'pack-nuget requires producer, stamp and targets'
318
- local repo version target path count=0 property
709
+ local repo version target path count=0 property ids=()
319
710
  local properties=()
320
711
  repo=$(repo_root "$1"); version=$2; shift 2
321
712
  valid_stamp "$version"; need dotnet
@@ -328,16 +719,23 @@ pack_nuget() {
328
719
  properties+=("-p:$property")
329
720
  done
330
721
  [ $# -ge 1 ] || die 'pack-nuget requires at least one target after the properties'
722
+ if [ "$DRY" -eq 0 ]; then
723
+ need node; scratch
724
+ nuget_pack_files "$version" > "$TMP/pack-before"
725
+ fi
331
726
  mutate mkdir -p "$FEED/nuget"
332
727
  for target in "$@"; do
333
728
  (cd "$repo" && heavy build "pack NuGet $target" dotnet pack "$target" -c Release "-p:PackageVersion=$version" "-p:Version=$version" ${properties[@]+"${properties[@]}"} -o "$FEED/nuget")
334
729
  done
335
730
  [ "$DRY" -eq 0 ] || return 0
731
+ nuget_pack_files "$version" "$TMP/pack-before" > "$TMP/pack-produced"
336
732
  while IFS= read -r -d '' path; do
337
733
  printf '%s\n' "$path"; count=$((count + 1))
338
- done < <(find "$FEED/nuget" -type f -name "*.$version.nupkg" -print0)
734
+ target=${path##*/}; ids+=("${target%."$version".nupkg}")
735
+ done < "$TMP/pack-produced"
339
736
  [ "$count" -gt 0 ] || failed 'pack produced no matching nupkgs'
340
737
  printf 'produced %s nupkgs\n' "$count"
738
+ registry_update pack "$version" nuget "$repo" '' '' "${ids[@]}"
341
739
  }
342
740
 
343
741
  # Pack one workspace with npm pack (what publish does) into the feed as <scope>-<name>-<version>.tgz.
@@ -358,7 +756,7 @@ pack_npm_workspace() {
358
756
 
359
757
  pack_npm() {
360
758
  [ $# -eq 2 ] || die 'pack-npm requires producer and stamp'
361
- local repo version path count=0
759
+ local repo version path count=0 ids=()
362
760
  repo=$(repo_root "$1"); version=$2
363
761
  valid_stamp "$version"; need node; need npm; need yarn; scratch
364
762
  git -C "$repo" diff --name-only HEAD -- 'package.json' '*/package.json' > "$TMP/modified"
@@ -409,6 +807,10 @@ NODE
409
807
  done < <(find "$FEED/npm" -type f -name "*-$version.tgz" -print0)
410
808
  [ "$count" -gt 0 ] || failed 'pack produced no matching npm tarballs'
411
809
  printf 'produced %s npm tarballs\n' "$count"
810
+ while IFS= read -r -d '' path; do
811
+ ids+=("$(node -p 'require(process.argv[1]).name' "$repo/$path")")
812
+ done < "$TMP/manifests"
813
+ registry_update pack "$version" npm "$repo" '' '' "${ids[@]}"
412
814
  }
413
815
 
414
816
  source_mapping() {
@@ -478,10 +880,35 @@ nuget_env() {
478
880
  printf 'if [ "%s" != %q ]; then\n' "\${$targets_key:-}" "$session/cratis-local.targets"
479
881
  printf ' export _CratisLocalPreviousAfterMicrosoftCommonTargets="%s"\nfi\n' "\${$targets_key:-}"
480
882
  printf 'export %s=%q\n' "$targets_key" "$session/cratis-local.targets"
883
+ if [ -n "${PRIVATE_NUGET_CACHE:-}" ]; then printf 'export NUGET_PACKAGES=%q\n' "$PRIVATE_NUGET_CACHE"; fi
481
884
  }
482
885
 
483
886
  use_nuget() {
484
- if [ "${2:-}" = --composition ]; then composition use-nuget "$@"; return; fi
887
+ local private=${CRATIS_LOCAL_PRIVATE_CACHE:-0} PRIVATE_NUGET_CACHE='' option filtered=() private_repo private_session
888
+ for option in "$@"; do
889
+ if [ "$option" = --private-cache ]; then private=1; else filtered+=("$option"); fi
890
+ done
891
+ set -- ${filtered[@]+"${filtered[@]}"}
892
+ if [ "$private" = 1 ]; then
893
+ [ $# -ge 2 ] || die 'use-nuget requires consumer and stamp or composition'
894
+ private_repo=$(repo_root "$1"); private_session=$(session_path "$private_repo")
895
+ PRIVATE_NUGET_CACHE=$(git -C "$private_repo" rev-parse --absolute-git-dir)/cratis-local-nuget
896
+ prepare_private_cache "$private_repo" "$PRIVATE_NUGET_CACHE" "$private_session"
897
+ fi
898
+ if [ "${2:-}" = --composition ]; then
899
+ composition use-nuget "$@"
900
+ if [ "$private" = 1 ] && [ "$DRY" -eq 0 ]; then register_private_cache "$private_repo" "$PRIVATE_NUGET_CACHE" "$private_session"; fi
901
+ if [ "$DRY" -eq 0 ]; then
902
+ local composition_repo composition_session composition_stamp
903
+ composition_repo=$(repo_root "$1"); composition_session=$(session_path "$composition_repo")
904
+ scratch
905
+ cut -f3 "$composition_session/composition/manifest.tsv" | sort -u > "$TMP/registry-stamps"
906
+ while IFS= read -r composition_stamp; do
907
+ registry_consumer "$composition_stamp" nuget "$composition_repo" "$composition_session" "$PRIVATE_NUGET_CACHE"
908
+ done < "$TMP/registry-stamps"
909
+ fi
910
+ return
911
+ fi
485
912
  case " $* " in *' --composition '*) die 'cannot mix a stamp or IDs with --composition' ;; esac
486
913
  [ $# -ge 2 ] || die 'use-nuget requires consumer and stamp'
487
914
  [ ! -d "$(session_path "$(repo_root "$1")")/composition" ] || die 'active composition; restore first'
@@ -527,6 +954,7 @@ use_nuget() {
527
954
  return 0
528
955
  fi
529
956
  register_session "$repo" "$session" "$version"
957
+ if [ "$private" = 1 ]; then register_private_cache "$repo" "$PRIVATE_NUGET_CACHE" "$session"; fi
530
958
  {
531
959
  nuget_props_start "$pin"
532
960
  for id in "${ids[@]}"; do
@@ -558,8 +986,11 @@ use_nuget() {
558
986
  elif [ -f "$session/nuget.config" ]; then
559
987
  rm -- "$session/nuget.config"
560
988
  fi
989
+ registry_consumer "$version" nuget "$repo" "$session" "$PRIVATE_NUGET_CACHE"
561
990
  printf 'overrode %s NuGet package IDs\n' "${#ids[@]}"
562
- printf 'Optional per-worktree cache (set before restore and clean): export NUGET_PACKAGES=%q\n' "$(git -C "$repo" rev-parse --absolute-git-dir)/cratis-local-nuget"
991
+ if [ "$private" != 1 ]; then
992
+ printf 'Optional per-worktree cache (set before restore and clean): export NUGET_PACKAGES=%q\n' "$(git -C "$repo" rev-parse --absolute-git-dir)/cratis-local-nuget"
993
+ fi
563
994
  }
564
995
 
565
996
  # A shared lock covers legacy mutations too: registration rollback must never replace
@@ -952,7 +1383,7 @@ function activate(packages,pin) {
952
1383
  const props = path.join(repo,'Directory.Packages.props'); plain(props);
953
1384
  if (exists(session)) {
954
1385
  plain(session,true);
955
- const allowed = new Set(['repo','composition']);
1386
+ const allowed = new Set(['repo','composition','nuget-cache']);
956
1387
  if (fs.readdirSync(session).some(n => !allowed.has(n))) fail('conflicting active overlay/session; restore first');
957
1388
  if (exists(path.join(session,'repo')) && fs.readFileSync(path.join(session,'repo'),'utf8') !== repo + '\n') fail('session belongs to another checkout');
958
1389
  }
@@ -1083,14 +1514,15 @@ function cleanGuard() {
1083
1514
  if (!exists(composition)) continue;
1084
1515
  plain(composition,true); plain(path.join(composition,'manifest.tsv'));
1085
1516
  const versions = fs.readFileSync(path.join(composition,'manifest.tsv'),'utf8').trim().split('\n').map(row => row.split('\t')[2]);
1086
- if (!selected || selected === 'all' || versions.includes(selected)) fail('active composition retains member artifacts; restore first: ' + known);
1517
+ if (mode === 'clean-members') { for (const version of versions) console.log(version); }
1518
+ else if (!selected || selected === 'all' || versions.includes(selected)) fail('active composition retains member artifacts; restore first: ' + known);
1087
1519
  }
1088
1520
  }
1089
1521
  try {
1090
1522
  safeParents(feed);
1091
1523
  if (mode === 'recover') recover();
1092
1524
  else if (mode === 'restore-state') restoreState();
1093
- else if (mode === 'clean-guard') cleanGuard();
1525
+ else if (mode === 'clean-guard' || mode === 'clean-members') cleanGuard();
1094
1526
  else {
1095
1527
  const args = [...argv]; if (mode !== 'verify-pack') args.shift();
1096
1528
  if (args.shift() !== '--composition' || !args.length) fail('expected --composition FILE');
@@ -1629,17 +2061,30 @@ NODE
1629
2061
  git -C "$dir" diff HEAD -- package.json yarn.lock > "$backup/before.diff"
1630
2062
  cp "$TMP/package.json" "$backup/expected-package.json"
1631
2063
  cp "$TMP/package.json" "$dir/package.json"
2064
+ registry_consumer "$version" npm "$repo" "$session" ''
1632
2065
  printf 'Next: cd %q && yarn install\n' "$dir"
1633
2066
  printf 'Vite: clear stale prebundles with rm -rf node_modules/.vite\n'
1634
2067
  }
1635
2068
 
1636
2069
  restore() {
1637
2070
  [ $# -eq 1 ] || die 'restore requires a consumer'
1638
- local repo session backup dir
2071
+ local repo session backup dir stamp
2072
+ local restore_stamps=()
1639
2073
  repo=$(repo_root "$1"); session=$(session_path "$repo"); backup=$session/npm-backup
1640
2074
  validate_session "$session"
2075
+ scratch
2076
+ # Restoring the overlay never depends on node; only the registry bookkeeping does.
2077
+ local registry_node=0
2078
+ if [ "$DRY" -eq 0 ] && command -v node > /dev/null 2>&1; then
2079
+ registry_node=1
2080
+ registry restore-stamps "$session" > "$TMP/restore-stamps"
2081
+ while IFS= read -r stamp; do restore_stamps+=("$stamp"); done < "$TMP/restore-stamps"
2082
+ elif [ "$DRY" -eq 0 ]; then
2083
+ printf 'WARNING: node not found; restoring without updating the stamp registry\n' >&2
2084
+ fi
1641
2085
  if [ -d "$session/composition" ]; then
1642
2086
  composition restore-state "$repo"
2087
+ if [ "$registry_node" -eq 1 ]; then registry_update restore "$session" ${restore_stamps[@]+"${restore_stamps[@]}"}; fi
1643
2088
  if [ "$DRY" -eq 0 ]; then check "$repo"; fi
1644
2089
  return
1645
2090
  fi
@@ -1659,6 +2104,7 @@ restore() {
1659
2104
  mutate rm -rf -- "$backup"
1660
2105
  fi
1661
2106
  mutate rm -f -- "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
2107
+ if [ "$registry_node" -eq 1 ]; then registry_update restore "$session" ${restore_stamps[@]+"${restore_stamps[@]}"}; fi
1662
2108
  if [ "$DRY" -eq 0 ]; then check "$repo"; fi
1663
2109
  }
1664
2110
 
@@ -1738,20 +2184,11 @@ bounded_docker() {
1738
2184
  local_artifact() {
1739
2185
  [[ "$1" =~ -(local\.[0-9]{12}|train)\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
1740
2186
  }
1741
- # Legacy registrations name consumers, not an artifact ownership graph. Fail closed
1742
- # for the whole requested cleanup if any selected stamp is still live. Also keep a
1743
- # selected session when its overlays/manifests reference another stamp.
1744
- clean_live_references() {
1745
- local selected=$1 session repo file found stamp status blocked=0 session_selected
1746
- while IFS= read -r session; do
1747
- [ -d "$session" ] && [ -f "$session/repo" ] || continue
1748
- validate_session "$session"
1749
- repo=$(cat "$session/repo")
1750
- [ -d "$repo" ] || continue
1751
- [ "$(repo_root "$repo")" = "$repo" ] || die "registered consumer no longer matches repository: $repo"
1752
- session_selected=0
1753
- if [ -f "$session/stamp" ] && [ "$(cat "$session/stamp")" = "$selected" ]; then session_selected=1; fi
1754
- # Follow manifest symlinks too; traversal/read failures refuse cleanup.
2187
+ # Print stamp<TAB>file. Follow manifest symlinks; traversal/read errors refuse cleanup.
2188
+ live_stamps() {
2189
+ local repo session file found stamp status
2190
+ for repo in "$@"; do
2191
+ session=${LIVE_SESSION:-$(session_path "$repo")}
1755
2192
  find -L "$repo" \( -name .git -o -name node_modules \) -prune -o \( -iname nuget.config -o -iname Directory.Packages.props -o -name packages.lock.json -o -name package.json -o -name yarn.lock \) \( -type f -o -type l \) -print0 > "$TMP/live-files" || die "cannot inspect registered consumer: $repo"
1756
2193
  for file in "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config"; do
1757
2194
  if [ -e "$file" ]; then printf '%s\0' "$file" >> "$TMP/live-files"; fi
@@ -1764,35 +2201,96 @@ clean_live_references() {
1764
2201
  [ -n "$found" ] || continue
1765
2202
  while IFS= read -r stamp; do
1766
2203
  stamp=$(printf '%s' "$stamp" | tr '[:upper:]' '[:lower:]')
1767
- if [ -z "$selected" ] || [ "$selected" = all ] || [ "$session_selected" -eq 1 ] || [ "$stamp" = "$selected" ]; then
1768
- printf 'kept stamp %s: registered live reference in %s\n' "$stamp" "$file"
1769
- blocked=1
1770
- fi
2204
+ printf '%s\t%s\n' "$stamp" "$file"
1771
2205
  done <<< "$found"
1772
2206
  done < "$TMP/live-files"
2207
+ done
2208
+ }
2209
+ # Legacy clean still refuses the whole request, with its original messages.
2210
+ clean_live_references() {
2211
+ local selected=$1 session repo file stamp blocked=0 session_selected
2212
+ while IFS= read -r session; do
2213
+ [ -d "$session" ] && [ -f "$session/repo" ] || continue
2214
+ validate_session "$session"
2215
+ repo=$(cat "$session/repo")
2216
+ [ -d "$repo" ] || continue
2217
+ [ "$(repo_root "$repo")" = "$repo" ] || die "registered consumer no longer matches repository: $repo"
2218
+ session_selected=0
2219
+ if [ -f "$session/stamp" ] && [ "$(cat "$session/stamp")" = "$selected" ]; then session_selected=1; fi
2220
+ LIVE_SESSION=$session live_stamps "$repo" > "$TMP/legacy-live"
2221
+ while IFS=$'\t' read -r stamp file; do
2222
+ if [ -z "$selected" ] || [ "$selected" = all ] || [ "$session_selected" -eq 1 ] || [ "$stamp" = "$selected" ]; then
2223
+ printf 'kept stamp %s: registered live reference in %s\n' "$stamp" "$file"
2224
+ blocked=1
2225
+ fi
2226
+ done < "$TMP/legacy-live"
1773
2227
  done < "$TMP/unique-sessions"
1774
2228
  [ "$blocked" -eq 0 ] || die 'live references retained; restore registered consumers before cleaning (no subjects removed)'
1775
2229
  }
2230
+ # The registry supplies recorded consumers as well as registered sessions. Both list
2231
+ # and expired clean use exactly this offline context and the same decision function.
2232
+ expiry_context() {
2233
+ local repo session cache actual
2234
+ scratch
2235
+ registry expiry-inputs "$@" > "$TMP/expiry-inputs"
2236
+ if [ -n "${EXPIRY_CONSUMER:-}" ]; then printf '%s\t%s\n' "$EXPIRY_CONSUMER" "$(session_path "$EXPIRY_CONSUMER")" >> "$TMP/expiry-inputs"; fi
2237
+ : > "$TMP/expiry-live"; : > "$TMP/expiry-compositions"
2238
+ printf '%s\n' "$NUGET_CACHE" "$HOME/.nuget/packages" > "$TMP/expiry-caches"
2239
+ while IFS= read -r actual; do
2240
+ session=${actual#*$'\t'}; repo=${actual%%$'\t'*}
2241
+ validate_session "$session"
2242
+ if [ -e "$session/nuget-cache" ] || [ -L "$session/nuget-cache" ]; then
2243
+ cache=$(registered_private_cache "$session")
2244
+ printf '%s\n' "$cache" >> "$TMP/expiry-caches"
2245
+ fi
2246
+ if [ -e "$session/composition" ] || [ -L "$session/composition" ]; then
2247
+ composition clean-members all "$session" >> "$TMP/expiry-compositions"
2248
+ fi
2249
+ [ -n "$repo" ] && [ -d "$repo" ] || continue
2250
+ actual=$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null) || continue
2251
+ [ "$actual" = "$repo" ] || continue
2252
+ LIVE_SESSION=$session live_stamps "$repo" >> "$TMP/expiry-live"
2253
+ done < "$TMP/expiry-inputs"
2254
+ }
2255
+ expired_guard() {
2256
+ local stamp=$1 found file
2257
+ expiry_context expiry
2258
+ if grep -Fxq -- "$stamp" "$TMP/expiry-compositions"; then die "expired stamp changed: active composition member: $stamp"; fi
2259
+ while IFS=$'\t' read -r found file; do
2260
+ [ "$found" != "$stamp" ] || die "expired stamp changed: live reference in $file"
2261
+ done < "$TMP/expiry-live"
2262
+ }
2263
+ subject_stamp() {
2264
+ local name=${1##*/}
2265
+ if [[ "$name" =~ ([0-9]+\.[0-9]+\.[0-9]+-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}) ]]; then printf '%s\n' "${BASH_REMATCH[1]}"; fi
2266
+ }
1776
2267
  clean() {
1777
- local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel=''
1778
- local subjects=() kinds=()
2268
+ local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel='' private=0 whole_cache='' whole_session=''
2269
+ local expired=0 online=0 selected_stamp decision reason EXPIRY_CONSUMER=''
2270
+ local subjects=() kinds=() caches=("$NUGET_CACHE") cache_sessions=() filtered_subjects=() filtered_kinds=()
1779
2271
  while [ $# -gt 0 ]; do
1780
2272
  arg=$1; shift
1781
2273
  case "$arg" in
1782
2274
  --stamp) [ $# -gt 0 ] || die '--stamp requires a stamp'; [ -z "$version" ] || die 'choose --stamp or --all'; version=$1; shift; valid_stamp "$version" ;;
1783
2275
  --all) [ -z "$version" ] || die 'choose --stamp or --all'; version=all ;;
2276
+ --expired) expired=1 ;;
2277
+ --online) online=1 ;;
1784
2278
  --apply) apply=1 ;;
2279
+ --private-cache) private=1 ;;
1785
2280
  --dir) [ $# -gt 0 ] || die '--dir requires a relative directory'; rel=${1%/}; shift ;;
1786
2281
  -*) die "unknown clean option: $arg" ;;
1787
2282
  *) [ -z "$consumer" ] || die 'clean takes at most one consumer'; consumer=$(repo_root "$arg") ;;
1788
2283
  esac
1789
2284
  done
2285
+ [ "$expired" -eq 0 ] || [ -z "$version" ] || die '--expired cannot be combined with --stamp or --all'
2286
+ [ "$online" -eq 0 ] || [ "$expired" -eq 1 ] || die '--online requires --expired'
2287
+ [ "$expired" -eq 0 ] || [ "$private" -eq 0 ] || die '--expired cannot remove a whole private cache'
1790
2288
  local registered
1791
- if [ -n "$consumer" ]; then
2289
+ if [ "$expired" -eq 0 ] && [ -n "$consumer" ]; then
1792
2290
  session=$(session_path "$consumer")
1793
2291
  if [ -e "$session/composition" ] || [ -L "$session/composition" ]; then composition clean-guard "${version:-all}" "$session"; fi
1794
2292
  fi
1795
- if [ -f "$FEED/sessions" ]; then
2293
+ if [ "$expired" -eq 0 ] && [ -f "$FEED/sessions" ]; then
1796
2294
  while IFS= read -r registered; do
1797
2295
  if [ -e "$registered/composition" ] || [ -L "$registered/composition" ]; then
1798
2296
  composition clean-guard "${version:-all}"
@@ -1805,6 +2303,37 @@ clean() {
1805
2303
  [ ${#maximum} -le 6 ] || die 'CRATIS_LOCAL_CLEAN_MAX is too large'
1806
2304
  maximum=$((10#$maximum))
1807
2305
  scratch
2306
+ if [ -f "$FEED/sessions" ]; then cat "$FEED/sessions" > "$TMP/sessions"; else : > "$TMP/sessions"; fi
2307
+ if [ -n "$consumer" ]; then session_path "$consumer" >> "$TMP/sessions"; fi
2308
+ sort -u "$TMP/sessions" > "$TMP/unique-sessions"
2309
+ if [ "$expired" -eq 1 ]; then
2310
+ EXPIRY_CONSUMER=$consumer
2311
+ if [ "$online" -eq 1 ]; then registry expiry --online > "$TMP/expiry-decisions"; else registry expiry > "$TMP/expiry-decisions"; fi
2312
+ : > "$TMP/expired-stamps"
2313
+ while IFS=$'\t' read -r decision selected_stamp reason; do
2314
+ printf '%s %s: %s\n' "$decision" "$selected_stamp" "$reason"
2315
+ if [ "$decision" = expired ]; then printf '%s\n' "$selected_stamp" >> "$TMP/expired-stamps"; fi
2316
+ done < "$TMP/expiry-decisions"
2317
+ while IFS= read -r path; do printf '%s\n' "${path#*$'\t'}" >> "$TMP/sessions"; done < "$TMP/expiry-inputs"
2318
+ sort -u "$TMP/sessions" > "$TMP/unique-sessions"
2319
+ caches+=("$HOME/.nuget/packages")
2320
+ fi
2321
+ while IFS= read -r session; do
2322
+ if [ -e "$session/nuget-cache" ] || [ -L "$session/nuget-cache" ]; then
2323
+ cache=$(registered_private_cache "$session")
2324
+ cache_sessions+=("$session"); caches+=("$cache")
2325
+ fi
2326
+ done < "$TMP/unique-sessions"
2327
+ if [ "$private" -eq 1 ]; then
2328
+ [ -n "$consumer" ] || die 'clean --private-cache requires a consumer'
2329
+ whole_session=$(session_path "$consumer")
2330
+ [ -f "$whole_session/nuget-cache" ] || die "no registered private cache: $consumer"
2331
+ whole_cache=$(registered_private_cache "$whole_session")
2332
+ private_cache_restored "$whole_session"
2333
+ clean_live_references ''
2334
+ subjects+=("$whole_cache"); kinds+=(private-cache)
2335
+ fi
2336
+ printf '%s\n' "${caches[@]}" | sort -u > "$TMP/nuget-caches"
1808
2337
  for cache in "$FEED/nuget" "$FEED/npm"; do
1809
2338
  [ -d "$cache" ] || continue
1810
2339
  while IFS= read -r -d '' path; do
@@ -1813,8 +2342,10 @@ clean() {
1813
2342
  subjects+=("$path"); kinds+=(file)
1814
2343
  done < <(find "$cache" -type f \( -name '*.nupkg' -o -name '*.snupkg' -o -name '*.tgz' \) -print0)
1815
2344
  done
1816
- if [ -d "$NUGET_CACHE" ]; then
1817
- for cache in "$NUGET_CACHE"/*; do
2345
+ while IFS= read -r path; do
2346
+ [ "$path" != "$whole_cache" ] || continue
2347
+ [ -d "$path" ] || continue
2348
+ for cache in "$path"/*; do
1818
2349
  [ -d "$cache" ] && [ ! -L "$cache" ] || continue
1819
2350
  for path in "$cache"/*-local.* "$cache"/*-train.*; do
1820
2351
  [ -d "$path" ] && [ ! -L "$path" ] || continue
@@ -1823,7 +2354,7 @@ clean() {
1823
2354
  subjects+=("$path"); kinds+=(directory)
1824
2355
  done
1825
2356
  done
1826
- fi
2357
+ done < "$TMP/nuget-caches"
1827
2358
  if [ -n "$consumer" ] && [ ! -f "$consumer${rel:+/$rel}/package.json" ]; then
1828
2359
  printf 'SKIP: no package.json in %s; pass --dir REL for a Yarn project in a subdirectory\n' "$consumer${rel:+/$rel}" >&2
1829
2360
  elif command -v yarn >/dev/null 2>&1 && [ -n "$consumer" ]; then
@@ -1849,12 +2380,10 @@ clean() {
1849
2380
  printf 'SKIP: Docker unavailable or did not respond within 10 seconds\n' >&2
1850
2381
  fi
1851
2382
  else printf 'SKIP: docker is not on PATH\n' >&2; fi
1852
- if [ -f "$FEED/sessions" ]; then cat "$FEED/sessions" > "$TMP/sessions"; else : > "$TMP/sessions"; fi
1853
- if [ -n "$consumer" ]; then session_path "$consumer" >> "$TMP/sessions"; fi
1854
- sort -u "$TMP/sessions" > "$TMP/unique-sessions"
1855
2383
  while IFS= read -r session; do
1856
2384
  [ -d "$session" ] && [ -f "$session/repo" ] && [ -f "$session/stamp" ] || continue
1857
2385
  local_artifact "$(cat "$session/stamp")" || continue
2386
+ if [ "$expired" -eq 1 ] && ! grep -Fxq -- "$(cat "$session/stamp")" "$TMP/expired-stamps"; then continue; fi
1858
2387
  if [ -n "$version" ] && [ "$version" != all ] && [ "$(cat "$session/stamp")" != "$version" ]; then continue; fi
1859
2388
  [ ! -L "$session" ] && [ "$session" != / ] || die "unsafe session: $session"
1860
2389
  validate_session "$session"
@@ -1863,25 +2392,55 @@ clean() {
1863
2392
  [ ! -d "$session/npm-backup" ] || die "unresolved npm backup: $session; restore before cleaning"
1864
2393
  subjects+=("$session"); kinds+=(session)
1865
2394
  done < "$TMP/unique-sessions"
1866
- clean_live_references "$version"
2395
+ # Stamp records exist only after a registry-aware pack or use; without any, clean needs no node.
2396
+ : > "$TMP/stamp-records"
2397
+ if [ -e "$FEED/stamps" ] || [ -L "$FEED/stamps" ]; then registry clean "$version" > "$TMP/stamp-records"; fi
2398
+ while IFS= read -r path; do subjects+=("$path"); kinds+=(stamp-record); done < "$TMP/stamp-records"
2399
+ if [ "$expired" -eq 1 ]; then
2400
+ for ((arg=0; arg<${#subjects[@]}; arg++)); do
2401
+ if [ "${kinds[$arg]}" = session ]; then selected_stamp=$(cat "${subjects[$arg]}/stamp"); else selected_stamp=$(subject_stamp "${subjects[$arg]}"); fi
2402
+ if grep -Fxq -- "$selected_stamp" "$TMP/expired-stamps"; then
2403
+ filtered_subjects+=("${subjects[$arg]}"); filtered_kinds+=("${kinds[$arg]}")
2404
+ fi
2405
+ done
2406
+ subjects=(${filtered_subjects[@]+"${filtered_subjects[@]}"}); kinds=(${filtered_kinds[@]+"${filtered_kinds[@]}"})
2407
+ else clean_live_references "$version"; fi
1867
2408
  count=${#subjects[@]}
1868
2409
  [ "$count" -le "$maximum" ] || die "clean fuse: $count subjects exceeds $maximum"
1869
2410
  for ((arg=0; arg<count; arg++)); do printf '%s: %s\n' "${kinds[$arg]}" "${subjects[$arg]}"; done
1870
2411
  if [ "$apply" -eq 1 ] && [ "$DRY" -eq 0 ]; then
1871
2412
  for ((arg=0; arg<count; arg++)); do
1872
- clean_live_references "$version"
2413
+ if [ "$expired" -eq 1 ]; then
2414
+ if [ "${kinds[$arg]}" = session ]; then selected_stamp=$(cat "${subjects[$arg]}/stamp"); else selected_stamp=$(subject_stamp "${subjects[$arg]}"); fi
2415
+ expired_guard "$selected_stamp"
2416
+ else clean_live_references "$version"; fi
2417
+ for session in ${cache_sessions[@]+"${cache_sessions[@]}"}; do registered_private_cache "$session" > /dev/null; done
1873
2418
  path=${subjects[$arg]}
1874
2419
  case "${kinds[$arg]}" in
2420
+ private-cache)
2421
+ [ "$(registered_private_cache "$whole_session")" = "$path" ] || die "private cache changed: $path"
2422
+ private_cache_restored "$whole_session"
2423
+ clean_live_references ''
2424
+ rm -rf -- "$path"
2425
+ rm -- "$whole_session/nuget-cache"
2426
+ # Later subjects still validate all remaining registrations.
2427
+ cache_sessions=()
2428
+ while IFS= read -r session; do
2429
+ if [ -f "$session/nuget-cache" ]; then cache_sessions+=("$session"); fi
2430
+ done < "$TMP/unique-sessions"
2431
+ ;;
2432
+ stamp-record) registry_update remove "${path##*/}" ;;
1875
2433
  image) docker rmi "$path" ;;
1876
2434
  file) [ -f "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -- "$path" ;;
1877
2435
  directory) [ -d "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -rf -- "$path" ;;
1878
2436
  session)
1879
2437
  [ -f "$path/stamp" ] && [ ! -L "$path" ] && [ ! -e "$path/npm-backup" ] || die "session changed: $path"
1880
2438
  validate_session "$path"
1881
- rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/cratis-local.targets" "$path/nuget.config"
1882
- rmdir -- "$path" ;;
2439
+ rm -f -- "$path/stamp" "$path/env" "$path/npm-dir" "$path/Directory.Packages.props" "$path/cratis-local.targets" "$path/nuget.config"
2440
+ if [ ! -f "$path/nuget-cache" ]; then rm -f -- "$path/repo"; rmdir -- "$path"; fi ;;
1883
2441
  esac
1884
2442
  done
2443
+ if [ "$expired" -eq 1 ]; then registry_update prune; fi
1885
2444
  printf 'removed %s subjects\n' "$count"
1886
2445
  else printf 'dry-run: %s subjects (use --apply to remove)\n' "$count"; fi
1887
2446
  }
@@ -1907,15 +2466,18 @@ self_test_nuget_overlay() {
1907
2466
 
1908
2467
  self_test() {
1909
2468
  [ $# -eq 0 ] || die '--self-test takes no arguments'
1910
- local root home repo feed version output status passed=0 skipped=0 test_key arg map
2469
+ local root home repo feed version output status passed=0 skipped=0 test_key arg map system_path
1911
2470
  # Fixtures use the physical path (macOS TMPDIR is /var -> /private/var) because the helper
1912
2471
  # reports repositories by their git toplevel; symlinked feeds have their own test below.
1913
2472
  scratch; root=$(cd -P "$TMP" && pwd -P)/self-test; mkdir -p "$root/home" "$root/repo"
1914
2473
  map=$root/map.tsv; home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
1915
- # System-only tests stay hermetic; npm tests inherit PATH to discover node.
1916
- # Both isolate HOME/config and omit the host adapter.
1917
- st() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
1918
- stm() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_MAP="$map" /bin/bash "$SELF" "$@"; }
2474
+ # Isolate tool discovery, adding only node for the stamp registry. npm tests
2475
+ # inherit PATH. Both isolate HOME/config and omit the host adapter.
2476
+ need node; mkdir -p "$root/registry-bin"
2477
+ ln -s "$(command -v node)" "$root/registry-bin/node"
2478
+ system_path=$root/registry-bin:/usr/bin:/bin:/usr/sbin:/sbin
2479
+ st() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
2480
+ stm() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_MAP="$map" /bin/bash "$SELF" "$@"; }
1919
2481
  has() { grep -Fq -- "$1" "$2"; }
1920
2482
  st_npm() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
1921
2483
  git -C "$repo" init -q
@@ -1991,6 +2553,14 @@ LOCK
1991
2553
  passed=$((passed + 5))
1992
2554
  st check "$repo" > "$root/output" || failed 'self-test: clean repo'
1993
2555
  passed=$((passed + 1))
2556
+ # Legacy clean of a feed without stamp records must not need node.
2557
+ mkdir -p "$root/no-registry-feed/nuget"
2558
+ if PATH=/usr/bin:/bin:/usr/sbin:/sbin command -v node > /dev/null 2>&1; then
2559
+ printf 'SKIP: clean without node needs a system PATH without node\n' >&2; skipped=$((skipped + 1))
2560
+ else
2561
+ expect_status 0 'legacy clean without node or stamp records' env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$root/no-registry-feed" /bin/bash "$SELF" clean
2562
+ passed=$((passed + 1))
2563
+ fi
1994
2564
  for test_key in stamp npm props sources targets previous override feed; do
1995
2565
  case "$test_key" in
1996
2566
  stamp) printf '%s\n' "$version" > "$repo/probe" ;;
@@ -2197,6 +2767,8 @@ NODE
2197
2767
  passed=$((passed + 6))
2198
2768
  else printf 'SKIP: use-npm --dir needs node\n'; skipped=$((skipped + 6)); fi
2199
2769
  self_test_live_clean
2770
+ self_test_private_cache
2771
+ self_test_registry
2200
2772
  mkdir -p "$home/.nuget/packages/cratis.test/$version" "$home/.nuget/packages/cratis.test/1.2.3"
2201
2773
  mkdir -p "$home/.nuget/packages/cratis.test/1.2.3-local.keep"
2202
2774
  for arg in nuget npm; do
@@ -2219,10 +2791,351 @@ NODE
2219
2791
  [ -d "$home/.nuget/packages/cratis.test/1.2.3" ] || failed 'self-test: clean removed real version'
2220
2792
  passed=$((passed + 5))
2221
2793
  printf 'SKIP: real dotnet/Yarn/Docker packing is not a hermetic self-test\n'; skipped=$((skipped + 3))
2794
+ self_test_expired
2222
2795
  self_test_matrix
2223
2796
  printf 'self-test: %s passed, %s skipped\n' "$passed" "$skipped"
2224
2797
  }
2225
2798
 
2799
+ self_test_expired() {
2800
+ local fixture ef er es ec record result expected stamp=9.8.7-train.gabcdef0
2801
+ local fixtures=('task finished' 'consumers gone' 'released offline cache' 'live reference kept' 'composition kept' 'unrecorded kept' 'dry-run no mutation' 'with stamp' 'fuse' 'online unreachable kept' 'prunes sessions' 'list json expiry' 'invalid record' 'record consumer reference kept' 'private release cache' 'prerelease cache kept' 'older release kept' 'without task kept' 'with all')
2802
+ ext() { env -i HOME="$ef/home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$ef/feed" NUGET_PACKAGES="$ec" CRATIS_LOCAL_CLEAN_MAX="${limit:-500}" CRATIS_LOCAL_RELEASE_URL_BASE=http://127.0.0.1:1 /bin/bash "$SELF" "$@"; }
2803
+ for fixture in "${fixtures[@]}"; do
2804
+ ef=$root/expired-${fixture// /-}; er=$ef/repo; ec=$ef/cache
2805
+ mkdir -p "$er" "$ef/feed/nuget" "$ec/cratis.expiry/$stamp" "$ef/home"
2806
+ git -C "$er" init -q
2807
+ es=$(git -C "$er" rev-parse --absolute-git-dir)/cratis-local
2808
+ record=$ef/feed/stamps/$stamp/record.json
2809
+ mkdir -p "${record%/*}"
2810
+ printf 'fixture\n' > "$ef/feed/nuget/Cratis.Expiry.$stamp.nupkg"
2811
+ node - "$record" "$stamp" "$er" "$es" "$fixture" <<'NODE'
2812
+ const fs = require('node:fs'); const [file,stamp,repo,session,fixture] = process.argv.slice(2), now = new Date().toISOString();
2813
+ const finished = ['task finished','live reference kept','composition kept','dry-run no mutation','fuse','prunes sessions','list json expiry','record consumer reference kept'].includes(fixture);
2814
+ const r = {schema:1,stamp,base:'9.8.7',kind:'train',created:now,updated:now,
2815
+ packs:[{ecosystem:'nuget',ids:['Cratis.Expiry'],producer:{repo:'Fixture',worktree:repo,branch:'main',head:'a'.repeat(40)},task:'Fixture#1',time:now}],
2816
+ consumers:[],tasks:{'Fixture#1':{finished:finished ? now : null}}};
2817
+ if (fixture === 'consumers gone') r.consumers.push({worktree:repo + '/gone',session:session + '-gone',ecosystem:'nuget',task:null,time:now});
2818
+ if (fixture === 'record consumer reference kept') r.consumers.push({worktree:repo,session,ecosystem:'nuget',task:null,time:now});
2819
+ if (fixture === 'without task kept') r.packs[0].task = null;
2820
+ fs.writeFileSync(file,JSON.stringify(r) + '\n');
2821
+ NODE
2822
+ expected=0; result=removed
2823
+ case "$fixture" in
2824
+ 'released offline cache') mkdir -p "$ec/cratis.expiry/9.8.8"; : > "$ec/cratis.expiry/9.8.8/.nupkg.metadata" ;;
2825
+ 'private release cache')
2826
+ mkdir -p "$es" "$er/.git/cratis-local-nuget/cratis.expiry/9.8.7"
2827
+ printf '%s\n' "$er" > "$es/repo"
2828
+ printf '%s\n' "$es" > "$ef/feed/sessions"
2829
+ printf '%s\n' "$er/.git/cratis-local-nuget" > "$es/nuget-cache"
2830
+ printf '%s\n' "$er" > "$er/.git/cratis-local-nuget/.cratis-local-cache"
2831
+ : > "$er/.git/cratis-local-nuget/cratis.expiry/9.8.7/.nupkg.metadata" ;;
2832
+ 'live reference kept'|'record consumer reference kept')
2833
+ mkdir -p "$es"; printf '%s\n' "$er" > "$es/repo"
2834
+ if [ "$fixture" = 'live reference kept' ]; then printf '%s\n' "$es" > "$ef/feed/sessions"; fi
2835
+ printf '{"dependencies":{"fixture":"%s"}}\n' "$stamp" > "$er/package.json"
2836
+ result=kept ;;
2837
+ 'composition kept')
2838
+ mkdir -p "$es/composition"; printf '%s\n' "$er" > "$es/repo"
2839
+ printf '%s\n' "$es" > "$ef/feed/sessions"
2840
+ printf 'Fixture\tCratis.Expiry\t%s\t%s\n' "$stamp" "$er" > "$es/composition/manifest.tsv"
2841
+ result=kept ;;
2842
+ 'unrecorded kept') rm -- "$record"; rmdir "${record%/*}"; result=kept ;;
2843
+ 'dry-run no mutation') cp "$record" "$ef/before"; result=kept ;;
2844
+ 'with stamp'|'with all'|'fuse'|'invalid record')
2845
+ expected=2; result=kept
2846
+ if [ "$fixture" = 'invalid record' ]; then printf '{broken\n' > "$record"; fi ;;
2847
+ 'prerelease cache kept') mkdir -p "$ec/cratis.expiry/9.8.9-preview"; : > "$ec/cratis.expiry/9.8.9-preview/.nupkg.metadata"; result=kept ;;
2848
+ 'older release kept') mkdir -p "$ec/cratis.expiry/9.8.6"; : > "$ec/cratis.expiry/9.8.6/.nupkg.metadata"; result=kept ;;
2849
+ 'online unreachable kept'|'without task kept') result=kept ;;
2850
+ 'list json expiry') result=kept ;;
2851
+ 'prunes sessions')
2852
+ mkdir -p "$es"; printf '%s\n' "$er" > "$es/repo"; : > "$es/env"
2853
+ printf '%s\n%s\n' "$ef/missing-session" "$es" > "$ef/feed/sessions"
2854
+ node - "$record" "$es" <<'NODE'
2855
+ const fs = require('node:fs'); const [file,session] = process.argv.slice(2), r = JSON.parse(fs.readFileSync(file));
2856
+ r.stamp = '9.8.7-train.g1234567'; r.packs[0].task = null;
2857
+ r.consumers = [{worktree:r.packs[0].producer.worktree,session,ecosystem:'nuget',task:null,time:r.created},
2858
+ {worktree:'/nonexistent/cratis-expiry-fixture',session:session + '-gone',ecosystem:'nuget',task:null,time:r.created},
2859
+ {worktree:r.packs[0].producer.worktree,session:session + '-missing',ecosystem:'nuget',task:null,time:r.created}];
2860
+ const target = file.replace('gabcdef0','g1234567'); fs.mkdirSync(require('node:path').dirname(target)); fs.writeFileSync(target,JSON.stringify(r));
2861
+ NODE
2862
+ ;;
2863
+ esac
2864
+ case "$fixture" in
2865
+ 'with stamp') expect_status "$expected" 'expired with stamp' ext clean --expired --stamp "$stamp" --apply ;;
2866
+ 'with all') expect_status "$expected" 'expired with all' ext clean --all --expired --apply ;;
2867
+ 'fuse') limit=0 expect_status "$expected" 'expired fuse' ext clean --expired --apply ;;
2868
+ 'online unreachable kept') expect_status 0 'expired online unreachable kept' ext clean --expired --online --apply ;;
2869
+ 'dry-run no mutation') expect_status 0 'expired dry-run no mutation' ext clean --expired ;;
2870
+ 'list json expiry')
2871
+ expect_status 0 'list json expiry' ext list --json
2872
+ node - "$root/output" <<'NODE' || failed 'list json expiry decision'
2873
+ const r = JSON.parse(require('node:fs').readFileSync(process.argv[2])).stamps;
2874
+ if (r.length !== 1 || !r[0].expiry.expired || r[0].expiry.reasons.join() !== 'task-finished' || r[0].expiry.kept.length) process.exit(1);
2875
+ NODE
2876
+ ;;
2877
+ *) expect_status "$expected" "expired $fixture" ext clean --expired --apply ;;
2878
+ esac
2879
+ if [ "$result" = removed ]; then
2880
+ [ ! -e "$record" ] && [ ! -e "$ef/feed/nuget/Cratis.Expiry.$stamp.nupkg" ] && [ ! -e "$ec/cratis.expiry/$stamp" ] || failed "expired $fixture left subjects"
2881
+ else
2882
+ [ -f "$ef/feed/nuget/Cratis.Expiry.$stamp.nupkg" ] && [ -d "$ec/cratis.expiry/$stamp" ] || failed "expired $fixture removed subjects"
2883
+ fi
2884
+ case "$fixture" in
2885
+ 'task finished'|'consumers gone') grep -Fq "expired $stamp: ${fixture// /-}" "$root/output" || failed "expired $fixture reason" ;;
2886
+ 'released offline cache') grep -Fq 'released:Cratis.Expiry@9.8.8' "$root/output" || failed 'offline release reason' ;;
2887
+ 'live reference kept'|'record consumer reference kept') grep -Fq "kept $stamp: live reference in $er/package.json" "$root/output" || failed 'live override reason' ;;
2888
+ 'composition kept') grep -Fq "kept $stamp: active composition member" "$root/output" || failed 'composition override reason' ;;
2889
+ 'unrecorded kept') grep -Fq "unrecorded; use clean --stamp $stamp" "$root/output" || failed 'unrecorded reason' ;;
2890
+ 'online unreachable kept') grep -Fq "kept $stamp: active" "$root/output" || failed 'network failure removed active stamp' ;;
2891
+ 'dry-run no mutation') cmp -s "$record" "$ef/before" || failed 'expired dry-run rewrote record' ;;
2892
+ 'prunes sessions')
2893
+ [ "$(cat "$ef/feed/sessions")" = "$es" ] || failed 'expired prunes sessions'
2894
+ node - "$ef/feed/stamps/9.8.7-train.g1234567/record.json" <<'NODE' || failed 'expired prunes consumers'
2895
+ // The consumer with a missing session but an existing worktree stays, so its manifests remain protected.
2896
+ const r = JSON.parse(require('node:fs').readFileSync(process.argv[2])); if (r.consumers.length !== 2 || r.consumers.some(c => c.worktree.endsWith('fixture')) || !r.consumers.some(c => c.session.endsWith('-missing'))) process.exit(1);
2897
+ NODE
2898
+ ;;
2899
+ esac
2900
+ done
2901
+ }
2902
+
2903
+ self_test_registry() {
2904
+ local rr=$root/'registry repo' rf=$root/registry-feed rb=$root/registry-tools rs record
2905
+ local rt=Cratis/Fixture#1 legacy=1.2.5-train.gabcdef0
2906
+ mkdir -p "$rr" "$rf/nuget" "$rb"
2907
+ git -C "$rr" init -q
2908
+ cp "$repo/Directory.Packages.props" "$rr/Directory.Packages.props"
2909
+ printf '{"name":"@cratis/registry","version":"1.0.0"}\n' > "$rr/package.json"
2910
+ git -C "$rr" add .
2911
+ git -C "$rr" -c user.email=test@example.invalid -c user.name='Registry fixture' commit -qm initial
2912
+ rs=$(git -C "$rr" rev-parse --absolute-git-dir)/cratis-local
2913
+ record=$rf/stamps/$version/record.json
2914
+ # Pack-tool doubles exercise successful/failed pack boundaries without building.
2915
+ cat > "$rb/dotnet" <<'SH'
2916
+ #!/bin/bash
2917
+ set -eu
2918
+ [ ! -e "$CRATIS_LOCAL_FEED/fail-pack" ] || exit 1
2919
+ version=''; output=''
2920
+ for arg in "$@"; do
2921
+ case "$arg" in -p:PackageVersion=*) version=${arg#*=} ;; esac
2922
+ if [ "$output" = next ]; then output=$arg; fi
2923
+ if [ "$arg" = -o ]; then output=next; fi
2924
+ done
2925
+ printf 'packed\n' > "$output/Cratis.Registry.$version.nupkg"
2926
+ SH
2927
+ printf '#!/bin/sh\nprintf '\''{"location":"."}\\n'\''\n' > "$rb/yarn"
2928
+ cat > "$rb/npm" <<'SH'
2929
+ #!/bin/bash
2930
+ set -eu
2931
+ version=$(node -p 'require("./package.json").version')
2932
+ mkdir -p "$TMPDIR/registry-tar/package"
2933
+ cp package.json "$TMPDIR/registry-tar/package/package.json"
2934
+ tar -czf "$CRATIS_LOCAL_FEED/npm/cratis-registry-$version.tgz" -C "$TMPDIR/registry-tar" package
2935
+ SH
2936
+ chmod +x "$rb/dotnet" "$rb/npm" "$rb/yarn"
2937
+ rgt() { env -i HOME="$home" PATH="$rb:$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$rf" NUGET_PACKAGES="$root/registry-cache" CRATIS_LOCAL_TASK="$rt" /bin/bash "$SELF" "$@"; }
2938
+ rg_assert() { node - "$record" "$@" <<'NODE'
2939
+ const fs = require('node:fs'); const [file,kind,version,repo,session] = process.argv.slice(2);
2940
+ const r = JSON.parse(fs.readFileSync(file,'utf8'));
2941
+ if (r.schema !== 1 || r.stamp !== version || !r.created.endsWith('Z') || !r.updated.endsWith('Z')) process.exit(1);
2942
+ if (kind === 'pack' && (r.packs.length !== 2 || !r.packs.some(p => p.ecosystem === 'nuget' && p.ids.join() === 'Cratis.Registry') || !r.packs.some(p => p.ids.includes('@cratis/registry')) || r.packs.some(p => p.producer.worktree !== repo || !/^[0-9a-f]{40}$/.test(p.producer.head)) || (fs.statSync(file).mode & 0o777) !== 0o600)) process.exit(1);
2943
+ if (kind === 'use' && (r.consumers.length !== 2 || r.consumers.some(c => c.worktree !== repo || c.session !== session || c.task !== 'Cratis/Fixture#2'))) process.exit(1);
2944
+ if (kind === 'restored' && r.consumers.some(c => !c.restored?.endsWith('Z'))) process.exit(1);
2945
+ if (kind === 'finished' && !r.tasks['Cratis/Fixture#1'].finished?.endsWith('Z')) process.exit(1);
2946
+ NODE
2947
+ }
2948
+ : > "$rf/nuget/Cratis.Previous.$version.nupkg"
2949
+ rgt pack-nuget "$rr" "$version" Fixture.csproj > "$root/output"
2950
+ rgt pack-npm "$rr" "$version" > "$root/output"
2951
+ expect_status 0 'registry pack records' rg_assert pack "$version" "$rr" "$rs"
2952
+ rgt use-nuget "$rr" "$version" --task Cratis/Fixture#2 > "$root/output"
2953
+ rgt use-npm "$rr" "$version" --task Cratis/Fixture#2 > "$root/output"
2954
+ expect_status 0 'registry use consumer' rg_assert use "$version" "$rr" "$rs"
2955
+ expect_status 0 'registry restore consumer' rgt restore "$rr"
2956
+ rg_assert restored "$version" "$rr" "$rs" || failed 'registry restore history'
2957
+ cp "$record" "$root/registry-before"
2958
+ expect_status 0 'registry dry-run no write' rgt --dry-run pack-nuget "$rr" "$legacy" Fixture.csproj
2959
+ [ ! -e "$rf/stamps/$legacy" ] || failed 'registry dry-run created record'
2960
+ rgt --dry-run use-nuget "$rr" "$version" > "$root/output"
2961
+ cmp -s "$record" "$root/registry-before" || failed 'registry dry-run use rewrote record'
2962
+ expect_status 0 'finish task dry-run' rgt finish --task "$rt"
2963
+ cmp -s "$record" "$root/registry-before" || failed 'finish dry-run rewrote record'
2964
+ expect_status 0 'finish task apply' rgt finish --task "$rt" --apply
2965
+ rg_assert finished "$version" "$rr" "$rs" || failed 'finish did not mark task'
2966
+ expect_status 0 'finish unknown task' rgt finish --task Cratis/Unknown#2 --apply
2967
+ grep -Fq 'no records name task' "$root/output" || failed 'finish unknown task message'
2968
+ : > "$rf/nuget/Cratis.Legacy.$legacy.nupkg"
2969
+ expect_status 0 'list unrecorded legacy' rgt list --stamp "$legacy" --json
2970
+ node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1])).stamps;if(r.length!==1||r[0].recorded||r[0].packages[0].id!=="Cratis.Legacy")process.exit(1)' "$root/output" || failed 'list legacy missing'
2971
+ rgt list --json > "$root/registry-list"
2972
+ rg_json() { node - "$root/registry-list" "$rf" <<'NODE'
2973
+ const fs = require('node:fs'); const [file,feed] = process.argv.slice(2), data = JSON.parse(fs.readFileSync(file));
2974
+ if (data.schema !== 1 || data.feed !== feed || data.stamps.length !== 2) process.exit(1);
2975
+ const keys = ['stamp','base','kind','recorded','packages','producers','tasks','consumers','created','ageSeconds','expiry'].sort().join();
2976
+ for (const r of data.stamps) {
2977
+ if (Object.keys(r).sort().join() !== keys || typeof r.expiry.expired !== 'boolean' || !Array.isArray(r.expiry.reasons) || !Array.isArray(r.expiry.kept)) process.exit(1);
2978
+ for (const p of r.packages) if (Object.keys(p).sort().join() !== 'ecosystem,file,id,version') process.exit(1);
2979
+ for (const c of r.consumers) if (c.state !== 'restored') process.exit(1);
2980
+ }
2981
+ NODE
2982
+ }
2983
+ expect_status 0 'list json schema' rg_json
2984
+ cp "$record" "$root/registry-good"
2985
+ printf '{"schema":2}\n' > "$record"
2986
+ cp "$record" "$root/registry-bad"
2987
+ expect_status 2 'registry rejects bad schema' rgt clean --stamp "$version" --apply
2988
+ cmp -s "$record" "$root/registry-bad" || failed 'registry rewrote bad schema'
2989
+ printf '{broken\n' > "$record"
2990
+ expect_status 2 'registry rejects malformed JSON' rgt clean --stamp "$version" --apply
2991
+ cp "$root/registry-good" "$record"
2992
+ mv "$rf/stamps" "$rf/real-stamps"; ln -s "$rf/real-stamps" "$rf/stamps"
2993
+ expect_status 2 'registry symlink refused' rgt finish --task "$rt" --apply
2994
+ rm "$rf/stamps"; mv "$rf/real-stamps" "$rf/stamps"
2995
+ ln -s "$root/registry-good" "$record.next"
2996
+ expect_status 2 'registry next symlink refused' rgt finish --task "$rt" --apply
2997
+ rm "$record.next"
2998
+ expect_status 2 'registry bad task id' rgt pack-nuget "$rr" "$version" --task 'bad task' Fixture.csproj
2999
+ : > "$rf/fail-pack"
3000
+ expect_status 1 'registry failed pack no write' rgt pack-nuget "$rr" "$legacy" Fixture.csproj
3001
+ [ ! -e "$rf/stamps/$legacy" ] || failed 'failed pack wrote record'
3002
+ rm "$rf/fail-pack"
3003
+ # Post-publication failures preserve overlays and report a warning/findings.
3004
+ printf '{"schema":2}\n' > "$record"
3005
+ expect_status 1 'registry use failure keeps overlay' rgt use-nuget "$rr" "$version"
3006
+ { [ -f "$rs/env" ] && grep -Fq WARNING "$root/stderr"; } || failed 'registry failed use lost overlay/warning'
3007
+ cp "$root/registry-good" "$record"
3008
+ rgt restore "$rr" > "$root/output"
3009
+ expect_status 0 'clean removes stamp record' rgt clean --stamp "$version" --apply
3010
+ [ ! -e "$rf/stamps/$version" ] && [ ! -e "$rf/nuget/Cratis.Registry.$version.nupkg" ] || failed 'clean retained stamp record/artifacts'
3011
+ [ -e "$rf/nuget/Cratis.Legacy.$legacy.nupkg" ] || failed 'registry clean removed another stamp'
3012
+ self_test_registry_invalid
3013
+ }
3014
+
3015
+ self_test_registry_invalid() {
3016
+ local fixture rf bad_record other=1.2.3-train.gabcdef0
3017
+ for fixture in clean-scoped restore-other restore-no-node restore-own finish list; do
3018
+ rf=$root/registry-invalid-$fixture
3019
+ bad_record=$rf/stamps/$other/record.json
3020
+ mkdir -p "$rf/nuget" "${bad_record%/*}"
3021
+ printf '{broken\n' > "$bad_record"
3022
+ : > "$rf/nuget/Cratis.Test.$version.nupkg"
3023
+ rgt use-nuget "$rr" "$version" > "$root/output"
3024
+ if [ "$fixture" = restore-own ]; then
3025
+ bad_record=$rf/stamps/$version/record.json
3026
+ printf '{broken\n' > "$bad_record"
3027
+ fi
3028
+ cp "$bad_record" "$root/invalid-before"
3029
+ case "$fixture" in
3030
+ clean-scoped)
3031
+ rgt restore "$rr" > "$root/output"
3032
+ expect_status 0 'registry invalid other record clean scoped' rgt clean --stamp "$version" --apply
3033
+ [ ! -e "$rf/stamps/$version" ] && [ ! -e "$rf/nuget/Cratis.Test.$version.nupkg" ] || failed 'invalid other record blocked scoped clean'
3034
+ expect_status 2 'registry invalid record clean all' rgt clean --all --apply
3035
+ expect_status 2 'registry invalid record clean default' rgt clean --apply
3036
+ ;;
3037
+ restore-other)
3038
+ expect_status 0 'registry invalid other record restore' rgt restore "$rr"
3039
+ ;;
3040
+ restore-no-node)
3041
+ if PATH=/usr/bin:/bin:/usr/sbin:/sbin command -v node > /dev/null 2>&1; then
3042
+ printf 'SKIP: restore without node needs a system PATH without node\n' >&2; skipped=$((skipped + 1))
3043
+ rgt restore "$rr" > "$root/output"
3044
+ else
3045
+ expect_status 0 'registry restore without node restores the overlay' env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$rf" NUGET_PACKAGES="$root/registry-cache" /bin/bash "$SELF" restore "$rr"
3046
+ { [ ! -e "$rs/env" ] && grep -Fq 'WARNING: node not found' "$root/stderr"; } || failed 'restore without node kept overlay or missed warning'
3047
+ fi
3048
+ ;;
3049
+ restore-own)
3050
+ expect_status 1 'registry invalid own record restore' rgt restore "$rr"
3051
+ { grep -Fq 'WARNING:' "$root/stderr" && grep -Fq "$bad_record" "$root/stderr"; } || failed 'restore missing invalid record warning'
3052
+ ;;
3053
+ finish)
3054
+ expect_status 1 'registry finish skips invalid' rgt finish --task "$rt" --apply
3055
+ record=$rf/stamps/$version/record.json rg_assert finished "$version" "$rr" "$rs" || failed 'finish skipped valid record'
3056
+ { grep -Fq 'WARNING:' "$root/stderr" && grep -Fq "$bad_record" "$root/stderr"; } || failed 'finish missing invalid record warning'
3057
+ rgt restore "$rr" > "$root/output"
3058
+ ;;
3059
+ list)
3060
+ expect_status 1 'list invalid record' rgt list --json
3061
+ node - "$root/output" "$version" "$other" <<'NODE' || failed 'list missing valid/invalid rows'
3062
+ const fs = require('node:fs'); const [file, valid, invalid] = process.argv.slice(2);
3063
+ const rows = JSON.parse(fs.readFileSync(file, 'utf8')).stamps;
3064
+ if (rows.length !== 2 || rows.find(r => r.stamp === valid)?.recorded !== true ||
3065
+ rows.find(r => r.stamp === invalid)?.recorded !== 'invalid' || typeof rows.find(r => r.stamp === invalid)?.error !== 'string') process.exit(1);
3066
+ NODE
3067
+ expect_status 1 'list invalid record text' rgt list
3068
+ { grep -Fq invalid "$root/output" && grep -Fq "$version" "$root/output"; } || failed 'text list missing valid/invalid rows'
3069
+ rgt restore "$rr" > "$root/output"
3070
+ ;;
3071
+ esac
3072
+ [ ! -e "$rs/env" ] && [ ! -e "$rs/Directory.Packages.props" ] || failed 'invalid record prevented overlay restore'
3073
+ cmp -s "$bad_record" "$root/invalid-before" || failed 'invalid record rewritten'
3074
+ done
3075
+ }
3076
+
3077
+ self_test_private_cache() {
3078
+ local pc_repo=$root/'private cache repo' pc_feed=$root/private-feed pc_shared=$root/private-shared
3079
+ local pc_cache pc_session pc_opt=0 other=1.2.5-train.g1234567
3080
+ mkdir -p "$pc_repo" "$pc_feed/nuget"
3081
+ git -C "$pc_repo" init -q
3082
+ cp "$repo/Directory.Packages.props" "$pc_repo/Directory.Packages.props"
3083
+ git -C "$pc_repo" add Directory.Packages.props
3084
+ git -C "$pc_repo" -c user.email=test@example.invalid -c user.name='Private cache fixture' commit -qm initial
3085
+ pc_cache=$(git -C "$pc_repo" rev-parse --absolute-git-dir)/cratis-local-nuget
3086
+ pc_session=$(git -C "$pc_repo" rev-parse --absolute-git-dir)/cratis-local
3087
+ pct() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$pc_feed" NUGET_PACKAGES="$pc_shared" CRATIS_LOCAL_PRIVATE_CACHE="$pc_opt" /bin/bash "$SELF" "$@"; }
3088
+ : > "$pc_feed/nuget/Cratis.Test.$version.nupkg"
3089
+ expect_status 0 'private-cache default unchanged' pct use-nuget "$pc_repo" "$version"
3090
+ [ ! -e "$pc_cache" ] && [ ! -e "$pc_session/nuget-cache" ] || failed 'private-cache default created a cache'
3091
+ has 'Optional per-worktree cache' "$root/output" || failed 'private-cache default hint changed'
3092
+ pct restore "$pc_repo" > "$root/output"
3093
+ expect_status 0 'private-cache dry-run' pct --dry-run use-nuget "$pc_repo" "$version" --private-cache
3094
+ [ ! -e "$pc_cache" ] && [ ! -e "$pc_session/nuget-cache" ] || failed 'private-cache dry-run wrote a cache'
3095
+ expect_status 0 'private-cache env export' pct use-nuget "$pc_repo" "$version" --private-cache
3096
+ env -i PATH=/usr/bin:/bin /bin/bash -c "source \"\$1\"; [ \"\$NUGET_PACKAGES\" = \"\$2\" ]" _ "$pc_session/env" "$pc_cache" || failed 'private-cache env does not export gitdir cache'
3097
+ if has 'Optional per-worktree cache' "$root/output"; then failed 'private-cache still prints manual hint'; fi
3098
+ pc_marker() { [ "$(cat "$pc_cache/.cratis-local-cache")" = "$pc_repo" ] && [ "$(cat "$pc_session/nuget-cache")" = "$pc_cache" ]; }
3099
+ expect_status 0 'private-cache marker' pc_marker
3100
+ expect_status 2 'private-cache live kept' pct clean "$pc_repo" --private-cache --apply
3101
+ [ -d "$pc_cache" ] || failed 'private-cache live cache removed'
3102
+ pct restore "$pc_repo" > "$root/output"
3103
+ [ -f "$pc_session/nuget-cache" ] && [ -d "$pc_cache" ] || failed 'private-cache restore removed cache'
3104
+ mkdir -p "$pc_cache/cratis.test/$version" "$pc_cache/cratis.test/$other" "$pc_cache/cratis.test/1.2.3" "$pc_cache/cratis.test/1.2.3-local.keep"
3105
+ mkdir -p "$pc_shared/cratis.test/$version" "$pc_shared/cratis.test/$other" "$pc_shared/cratis.test/1.2.3"
3106
+ expect_status 0 'private-cache clean scoped' pct clean --stamp "$version" --apply
3107
+ [ ! -e "$pc_cache/cratis.test/$version" ] && [ -d "$pc_cache/cratis.test/$other" ] && [ -d "$pc_cache/cratis.test/1.2.3" ] && [ -d "$pc_cache/cratis.test/1.2.3-local.keep" ] || failed 'private-cache stamp scope violated'
3108
+ [ ! -e "$pc_shared/cratis.test/$version" ] || failed 'private-cache shared local version retained'
3109
+ expect_status 0 'private-cache shared cache only local' pct clean --all --apply
3110
+ [ ! -e "$pc_shared/cratis.test/$other" ] && [ -d "$pc_shared/cratis.test/1.2.3" ] || failed 'private-cache shared non-local folder removed'
3111
+ rm -- "$pc_cache/.cratis-local-cache"
3112
+ mkdir -p "$pc_shared/cratis.test/$version"
3113
+ expect_status 2 'private-cache unmarked refused' pct clean --stamp "$version" --apply
3114
+ [ -d "$pc_shared/cratis.test/$version" ] || failed 'private-cache unmarked refusal mutated shared cache'
3115
+ printf '%s\n' "$root/other-repo" > "$pc_cache/.cratis-local-cache"
3116
+ expect_status 2 'private-cache mismatched marker refused' pct clean --all --apply
3117
+ printf '%s\n' "$pc_repo" > "$pc_cache/.cratis-local-cache"
3118
+ mv "$pc_cache" "$pc_cache.saved"; ln -s "$pc_cache.saved" "$pc_cache"
3119
+ expect_status 2 'private-cache symlink refused' pct clean --apply
3120
+ rm -- "$pc_cache"; mv "$pc_cache.saved" "$pc_cache"
3121
+ printf '%s\n' "$pc_shared" > "$pc_session/nuget-cache"
3122
+ expect_status 2 'private-cache outside gitdir refused' pct clean --apply
3123
+ printf '%s\n' "$pc_cache" > "$pc_session/nuget-cache"
3124
+ printf '<Project><!-- %s --></Project>\n' "$other" > "$pc_repo/Directory.Packages.props"
3125
+ expect_status 2 'private-cache restored manifest live kept' pct clean "$pc_repo" --private-cache --stamp "$version" --apply
3126
+ cp "$repo/Directory.Packages.props" "$pc_repo/Directory.Packages.props"
3127
+ expect_status 0 'private-cache whole dry-run' pct clean "$pc_repo" --private-cache
3128
+ [ -d "$pc_cache" ] && [ -f "$pc_session/nuget-cache" ] || failed 'private-cache whole dry-run mutated cache'
3129
+ expect_status 0 'private-cache whole removal' pct clean "$pc_repo" --private-cache --apply
3130
+ [ ! -e "$pc_cache" ] && [ ! -e "$pc_session/nuget-cache" ] && [ -d "$pc_shared/cratis.test/1.2.3" ] || failed 'private-cache whole removal scope violated'
3131
+ : > "$pc_feed/nuget/Cratis.Test.$version.nupkg"
3132
+ pc_opt=1
3133
+ expect_status 0 'private-cache environment opt-in' pct use-nuget "$pc_repo" "$version"
3134
+ pc_marker || failed 'private-cache environment opt-in missing marker'
3135
+ pct restore "$pc_repo" > "$root/output"
3136
+ pct clean "$pc_repo" --private-cache --apply > "$root/output"
3137
+ }
3138
+
2226
3139
  self_test_base_version() {
2227
3140
  local base mode actual status
2228
3141
  for mode in local train; do
@@ -2264,7 +3177,7 @@ self_test_live_clean() {
2264
3177
  git -C "$lr" add Directory.Packages.props
2265
3178
  git -C "$lr" -c user.email=test@example.invalid -c user.name='Live clean fixture' commit -qm initial
2266
3179
  ls=$(git -C "$lr" rev-parse --absolute-git-dir)/cratis-local
2267
- lct() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$lf" NUGET_PACKAGES="$root/live-cache" /bin/bash "$SELF" "$@"; }
3180
+ lct() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$lf" NUGET_PACKAGES="$root/live-cache" /bin/bash "$SELF" "$@"; }
2268
3181
  : > "$lf/nuget/Cratis.Live.$lv.nupkg"
2269
3182
  : > "$lf/nuget/Cratis.Unused.$uv.nupkg"
2270
3183
  mkdir -p "$root/live-cache/cratis.live/$lv" "$root/live-cache/cratis.live/5.0.0"
@@ -2604,7 +3517,7 @@ self_test_matrix() {
2604
3517
  self_test_composition
2605
3518
  }
2606
3519
 
2607
- # Legacy mutations must work without Node and with system/configured symlink parents.
3520
+ # Registry paths allow system/configured symlink parents of the feed.
2608
3521
  self_test_symlink_feed() {
2609
3522
  local physical=$root/feed-physical-parent alias=$root/feed-symlink-parent
2610
3523
  local sr=$root/feed-symlink-repo sv=1.2.3-local.202610240544.gabcdef0
@@ -2615,7 +3528,7 @@ self_test_symlink_feed() {
2615
3528
  printf '<Project><ItemGroup><PackageVersion Include="Cratis.Symlink" Version="1.2.3"/></ItemGroup></Project>\n' > "$sr/Directory.Packages.props"
2616
3529
  git -C "$sr" add .gitignore Directory.Packages.props
2617
3530
  git -C "$sr" -c user.email=test@example.invalid -c user.name='Symlink feed fixture' commit -qm initial
2618
- slf() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$alias/missing/feed" CRATIS_LOCAL_SESSION_DIR="$sr/.ai-work/local-packages" /bin/bash "$SELF" "$@"; }
3531
+ slf() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$alias/missing/feed" CRATIS_LOCAL_SESSION_DIR="$sr/.ai-work/local-packages" /bin/bash "$SELF" "$@"; }
2619
3532
  expect_status 0 'symlink feed resolution does not create a missing feed' slf check "$sr"
2620
3533
  [ ! -e "$physical/missing" ] || failed 'symlink feed resolution created directories'
2621
3534
  mkdir -p "$physical/missing/feed/nuget"
@@ -2767,6 +3680,16 @@ JSON
2767
3680
  expect_status 1 'composition malformed assets' cst verify-assets "$cr" --composition "$cm"
2768
3681
  cp "$root/composition-assets.json" "$cr/obj/project.assets.json"
2769
3682
  expect_status 0 'composition activation with transitive pinning' cst use-nuget "$cr" --composition "$cm" --pin-transitive
3683
+ composition_registry() {
3684
+ node - "$cf" "$cr" "$a" "$b" "$c" <<'NODE'
3685
+ const fs = require('node:fs'), path = require('node:path'); const [feed,repo,...stamps] = process.argv.slice(2);
3686
+ for (const stamp of stamps) {
3687
+ const r = JSON.parse(fs.readFileSync(path.join(feed,'stamps',stamp,'record.json')));
3688
+ if (r.schema !== 1 || r.consumers.length !== 1 || r.consumers[0].worktree !== repo || r.consumers[0].ecosystem !== 'nuget') process.exit(1);
3689
+ }
3690
+ NODE
3691
+ }
3692
+ expect_status 0 'registry composition consumer' composition_registry
2770
3693
  output=$cr/.ai-work/local-packages/composition
2771
3694
  grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'composition pin property'
2772
3695
  self_test_nuget_overlay "$output" Cratis.Stage "$c"
@@ -2802,6 +3725,23 @@ JSON
2802
3725
  expect_status 0 'composition repeated restore' cst restore "$cr"
2803
3726
  [ ! -e "$output" ] || failed 'composition overlay survives restore'
2804
3727
  cmp -s "$cr/Directory.Packages.props" "$root/composition-original.props" || failed 'composition consumer props mutated'
3728
+ # Whole-cache cleanup also cleans feed artifacts. Keep this fixture's feed
3729
+ # separate so the recovery fixtures below retain their declared packages.
3730
+ local composition_feed=$cf composition_cache
3731
+ cf=$root/private-composition-feed
3732
+ mkdir -p "$cf/nuget"
3733
+ cp "$composition_feed/nuget/"*.nupkg "$cf/nuget/"
3734
+ expect_status 0 'private-cache composition' cst use-nuget "$cr" --composition "$cm" --private-cache
3735
+ composition_cache=$(git -C "$cr" rev-parse --absolute-git-dir)/cratis-local-nuget
3736
+ env -i PATH=/usr/bin:/bin /bin/bash -c "source \"\$1\"; [ \"\$NUGET_PACKAGES\" = \"\$2\" ]" _ "$output/env" "$composition_cache" || failed 'private-cache composition env export'
3737
+ [ "$(cat "$cr/.ai-work/local-packages/nuget-cache")" = "$composition_cache" ] && [ "$(cat "$composition_cache/.cratis-local-cache")" = "$cr" ] || failed 'private-cache composition marker'
3738
+ expect_status 2 'private-cache composition live kept' cst clean "$cr" --private-cache --apply
3739
+ expect_status 0 'private-cache composition restore' cst restore "$cr"
3740
+ [ -d "$composition_cache" ] || failed 'private-cache composition restore removed cache'
3741
+ expect_status 0 'private-cache composition removal' cst clean "$cr" --private-cache --apply
3742
+ [ ! -e "$composition_cache" ] && [ ! -e "$cr/.ai-work/local-packages/nuget-cache" ] || failed 'private-cache composition removal left cache registration'
3743
+ cf=$composition_feed
3744
+ [ -f "$cf/nuget/Cratis.Chronicle.InProcess.$a.nupkg" ] && [ -f "$cf/nuget/Cratis.Screenplay.$b.nupkg" ] && [ -f "$cf/nuget/Cratis.Stage.$c.nupkg" ] || failed 'private-cache composition cleanup removed recovery feed packages'
2805
3745
  # Every live mutation boundary is exercised both as an ordinary failure and
2806
3746
  # an uncatchable parent/child interruption, then recovered by the next call.
2807
3747
  for kind in fail interrupt; do
@@ -2859,7 +3799,26 @@ JSON
2859
3799
  passed=$((passed + 1))
2860
3800
  }
2861
3801
 
3802
+ TASK=''
3803
+ case "$MODE" in
3804
+ pack-nuget|pack-npm|use-nuget|use-npm|finish)
3805
+ TASK=${CRATIS_LOCAL_TASK:-}
3806
+ ARGS=()
3807
+ while [ $# -gt 0 ]; do
3808
+ if [ "$1" = --task ]; then
3809
+ [ $# -ge 2 ] || die '--task requires an ID'
3810
+ TASK=$2; [ -n "$TASK" ] || die 'invalid task ID'; shift 2
3811
+ else ARGS+=("$1"); shift; fi
3812
+ done
3813
+ set -- ${ARGS[@]+"${ARGS[@]}"}
3814
+ if [ -n "$TASK" ]; then [[ "$TASK" =~ ^[A-Za-z0-9][A-Za-z0-9._#:/-]{0,127}$ ]] || die 'invalid task ID'; fi ;;
3815
+ esac
2862
3816
  case "$MODE" in
3817
+ list) registry list "$@" ;;
3818
+ finish)
3819
+ [ -n "$TASK" ] || die 'finish requires --task ID or CRATIS_LOCAL_TASK'
3820
+ [ $# -eq 0 ] || { [ $# -eq 1 ] && [ "$1" = --apply ]; } || die 'finish accepts --task ID and --apply'
3821
+ if [ "${1:-}" = --apply ] && [ "$DRY" -eq 0 ]; then registry_update finish --apply; else registry finish; fi ;;
2863
3822
  stamp) stamp "$@" ;;
2864
3823
  plan) plan_command "$@" ;;
2865
3824
  closure) closure "$@" ;;