@cratis/pi 7.103.7 → 7.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/SKILL.md +84 -10
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dotnet-nuget.md +204 -11
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/npm-yarn.md +14 -0
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/train-mode.md +16 -0
- package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/cratis-local-packages.sh +2127 -74
- package/package.json +1 -1
|
@@ -10,12 +10,13 @@ Portable local package verification for macOS/Linux (Bash 3.2+). Windows: use WS
|
|
|
10
10
|
Exit codes: 0 success, 1 findings/failed operation, 2 cannot run/invalid input.
|
|
11
11
|
|
|
12
12
|
Commands (COMMAND --help shows these instructions too):
|
|
13
|
-
stamp PRODUCER [PRODUCER...]
|
|
13
|
+
stamp [--base-version X.Y.Z] PRODUCER [PRODUCER...]
|
|
14
14
|
Next patch of the highest reachable vX.Y.Z tag, UTC minute and HEAD sha7.
|
|
15
15
|
With several producers the highest next patch wins (one stamp for a multi-hop
|
|
16
16
|
chain, so no stamped package sorts below a released pin) and sha7 is the last
|
|
17
17
|
producer's; every HEAD is printed to stderr.
|
|
18
|
-
stamp --train PRODUCER [PRODUCER...]
|
|
18
|
+
stamp --train [--base-version X.Y.Z] PRODUCER [PRODUCER...]
|
|
19
|
+
--base-version overrides the tag-derived base (strict SemVer core, no suffix).
|
|
19
20
|
Train stamp X.Y.Z-train.g<sha7>: the same next patch, no minute, so one code
|
|
20
21
|
state always yields one stamp. Accepted everywhere a local stamp is.
|
|
21
22
|
closure [--map FILE] [--train] CONSUMER=PATH PRODUCER=PATH...
|
|
@@ -52,25 +53,47 @@ Commands (COMMAND --help shows these instructions too):
|
|
|
52
53
|
verify-assets CONSUMER-PATH STAMP [--npm] [--dir REL] [--json]
|
|
53
54
|
Manual, weaker form without a plan: every package at STAMP in the feed that the
|
|
54
55
|
consumer resolves must be STAMP (none resolved: exit 1).
|
|
55
|
-
pack
|
|
56
|
+
verify-pack --composition FILE [--json]
|
|
57
|
+
verify-assets CONSUMER-PATH --composition FILE [--json]
|
|
58
|
+
use-nuget CONSUMER --composition FILE [--task ID] [--pin-transitive] [--private-cache]
|
|
59
|
+
Opt-in mixed-version NuGet closure: tab-separated producer NAME STAMP
|
|
60
|
+
ABSOLUTE_WORKTREE rows, then package PRODUCER PACKAGE_ID rows. Case-insensitive
|
|
61
|
+
IDs have one owner. All declared artifacts must exist; nothing is built/repacked.
|
|
62
|
+
Uses Node, unzip and the selected SDK's native NuGet.Versioning APIs; unavailable
|
|
63
|
+
validation fails closed (2). Bad artifacts/ranges/assets are findings (1).
|
|
64
|
+
Composition JSON: {"packages":[{"id":..,"version":..,"producer":..}]} (no stamp).
|
|
65
|
+
Activation publishes a journaled overlay with exclusive exact source mappings.
|
|
66
|
+
The next mutation recovers interrupted work, or refuses and preserves unknown
|
|
67
|
+
ownership/drift. Active compositions retain member artifacts. Restore before
|
|
68
|
+
changing a composition or switching between it and a legacy overlay.
|
|
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...
|
|
56
77
|
Release-pack each target into the NuGet feed; requires dotnet. Each --property
|
|
57
78
|
becomes one -p:NAME=VALUE argument of that command only (closure prints the
|
|
58
79
|
properties of each map pack row); no environment variable carries them.
|
|
59
|
-
pack-npm PRODUCER STAMP
|
|
80
|
+
pack-npm PRODUCER STAMP [--task ID]
|
|
60
81
|
Requires node, npm and Yarn 4, and a completed repository build. Temporarily
|
|
61
82
|
version non-private workspaces, pack each with npm pack --ignore-scripts (the
|
|
62
83
|
publish workflows' tool, so the tarball matches the published one) as
|
|
63
84
|
<scope>-<name>-<version>.tgz, and restore tracked manifests even on failure.
|
|
64
85
|
This helper does not build or run lifecycle scripts.
|
|
65
|
-
use-nuget CONSUMER STAMP [--pin-transitive] [PACKAGE-ID...]
|
|
86
|
+
use-nuget CONSUMER STAMP [--task ID] [--pin-transitive] [--private-cache] [PACKAGE-ID...]
|
|
66
87
|
Create a CPM overlay importing the repository's own Directory.Packages.props.
|
|
67
88
|
Transitive pinning stays as the consumer sets it; --pin-transitive forces it on so
|
|
68
89
|
stamped IDs the consumer does not pin still take effect (can raise NU1109 floors).
|
|
69
90
|
Defaults to every stamped package ID in the feed. Prints properties and sourceable
|
|
70
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.
|
|
71
94
|
Non-CPM: manually use an untracked project copy with explicit PackageReference
|
|
72
95
|
versions and dotnet restore --source FEED/nuget; do not edit shipped versions.
|
|
73
|
-
use-npm CONSUMER STAMP [--dir REL]
|
|
96
|
+
use-npm CONSUMER STAMP [--task ID] [--dir REL]
|
|
74
97
|
--dir REL names a Yarn project in a subdirectory (restore and clean follow it).
|
|
75
98
|
Requires node and tar. Back up package.json, yarn.lock (including existing edits),
|
|
76
99
|
and git diff; add file-tarball resolutions for every stamped feed package.
|
|
@@ -79,16 +102,37 @@ Commands (COMMAND --help shows these instructions too):
|
|
|
79
102
|
Restore npm backups when only overlay resolutions and their local lockfile
|
|
80
103
|
entries changed (including yarn install). Refuse unrelated changes and retain
|
|
81
104
|
backups for review. Remove CPM/env overlays
|
|
82
|
-
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.
|
|
83
106
|
check REPO [--staged]
|
|
84
107
|
Scan added/changed tracked lines (staged-only if requested), plus all contents
|
|
85
108
|
of untracked, non-ignored files. Reports path:line:pattern and file counts.
|
|
86
|
-
clean [CONSUMER] [--stamp STAMP | --all] [--apply] [--dir REL]
|
|
109
|
+
clean [CONSUMER] [--stamp STAMP | --all | --expired [--online]] [--apply] [--dir REL] [--private-cache]
|
|
87
110
|
Default: list all local feed/cache/image/session subjects, without deleting.
|
|
88
111
|
--apply deletes listed subjects (docker rmi, never force). Refuses more than
|
|
89
112
|
CRATIS_LOCAL_CLEAN_MAX subjects (default 500), or unresolved npm backups.
|
|
113
|
+
Refuses selected stamps referenced by registered live consumer overlays/manifests;
|
|
114
|
+
restore those consumers first. Dry-run uses the same protection.
|
|
90
115
|
Known sessions are registered in FEED/sessions; an optional consumer also
|
|
91
|
-
discovers its per-worktree session.
|
|
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.
|
|
92
136
|
--self-test
|
|
93
137
|
Isolated scratch HOME tests; npm uses inherited PATH to discover node.
|
|
94
138
|
Other tests use system PATH. Reports passed and skipped counts.
|
|
@@ -98,6 +142,8 @@ if present (trusted shell code), then use these defaults for unset variables:
|
|
|
98
142
|
CRATIS_LOCAL_FEED=${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed
|
|
99
143
|
CRATIS_LOCAL_SESSION_DIR=<absolute-git-dir>/cratis-local (per checkout)
|
|
100
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)
|
|
101
147
|
CRATIS_LOCAL_CLEAN_MAX=500
|
|
102
148
|
CRATIS_LOCAL_RUN=optional executable wrapper, invoked for each heavy step as:
|
|
103
149
|
"$CRATIS_LOCAL_RUN" build LABEL -- COMMAND ARGUMENTS...
|
|
@@ -132,10 +178,28 @@ fi
|
|
|
132
178
|
FEED=${CRATIS_LOCAL_FEED:-${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed}
|
|
133
179
|
NUGET_CACHE=${NUGET_PACKAGES:-$HOME/.nuget/packages}
|
|
134
180
|
case "$FEED" in /*) ;; *) FEED=$PWD/$FEED ;; esac
|
|
181
|
+
# The configured spelling (for example /var/... on macOS, or a symlinked cache) is kept
|
|
182
|
+
# so leak scans catch consumer references written through either path.
|
|
183
|
+
FEED_ALIAS=$FEED
|
|
184
|
+
# Resolve configured/system symlink parents without creating a missing feed.
|
|
185
|
+
# Subsequent checks concern helper-owned components, not /tmp, /var or HOME.
|
|
186
|
+
resolve_feed() {
|
|
187
|
+
local directory=$FEED suffix='' physical
|
|
188
|
+
while [ ! -d "$directory" ]; do
|
|
189
|
+
[ ! -e "$directory" ] && [ ! -L "$directory" ] || die "not a feed directory: $directory"
|
|
190
|
+
suffix=/${directory##*/}$suffix
|
|
191
|
+
directory=${directory%/*}; [ -n "$directory" ] || directory=/
|
|
192
|
+
done
|
|
193
|
+
physical=$(cd -P "$directory" && pwd -P) || die "cannot resolve feed: $FEED"
|
|
194
|
+
if [ -n "$suffix" ]; then FEED=${physical%/}$suffix; else FEED=$physical; fi
|
|
195
|
+
}
|
|
196
|
+
[ "$MODE" = --self-test ] || resolve_feed
|
|
135
197
|
case "$NUGET_CACHE" in /*) ;; *) NUGET_CACHE=$PWD/$NUGET_CACHE ;; esac
|
|
136
198
|
TMP=''
|
|
137
199
|
PACK_REPO=''
|
|
138
200
|
PACK_MANIFESTS=()
|
|
201
|
+
SESSION_LOCK=''
|
|
202
|
+
SESSION_RECOVERY_GUARD=''
|
|
139
203
|
cleanup() {
|
|
140
204
|
local status=$?
|
|
141
205
|
trap - EXIT
|
|
@@ -145,6 +209,14 @@ cleanup() {
|
|
|
145
209
|
status=1
|
|
146
210
|
fi
|
|
147
211
|
fi
|
|
212
|
+
if [ -n "$SESSION_LOCK" ] && [ ! -e "$SESSION_LOCK/journal.json" ] &&
|
|
213
|
+
[ -f "$SESSION_LOCK/owner" ] && [ "$(cat "$SESSION_LOCK/owner")" = "$$" ]; then
|
|
214
|
+
rm -- "$SESSION_LOCK/owner"
|
|
215
|
+
rmdir -- "$SESSION_LOCK" || status=1
|
|
216
|
+
fi
|
|
217
|
+
if [ -n "$SESSION_RECOVERY_GUARD" ]; then
|
|
218
|
+
rmdir -- "$SESSION_RECOVERY_GUARD" || status=1
|
|
219
|
+
fi
|
|
148
220
|
if [ -n "$TMP" ]; then rm -rf -- "$TMP"; fi
|
|
149
221
|
exit "$status"
|
|
150
222
|
}
|
|
@@ -187,8 +259,8 @@ validate_session() {
|
|
|
187
259
|
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
|
188
260
|
[ ! -L "$entry" ] || die "symlink in session: $entry"
|
|
189
261
|
case "${entry##*/}" in
|
|
190
|
-
repo|stamp|env|npm-dir|Directory.Packages.props|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
|
|
191
|
-
npm-backup) [ -d "$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" ;;
|
|
263
|
+
npm-backup|composition) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
|
|
192
264
|
*) die "unrecognized file in session (preserved): $entry" ;;
|
|
193
265
|
esac
|
|
194
266
|
done
|
|
@@ -204,6 +276,50 @@ register_session() {
|
|
|
204
276
|
printf '%s\n' "$session" >> "$FEED/sessions"
|
|
205
277
|
fi
|
|
206
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
|
+
}
|
|
207
323
|
xml_escape() { printf '%s' "$1" | sed -e 's/\&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g' -e "s/'/\'/g"; }
|
|
208
324
|
|
|
209
325
|
# Next patch of the highest reachable vX.Y.Z tag; sets NEXT_BASE.
|
|
@@ -227,12 +343,31 @@ base_above() {
|
|
|
227
343
|
|
|
228
344
|
stamp() {
|
|
229
345
|
[ $# -ge 1 ] || die 'stamp requires a producer repository'
|
|
230
|
-
local repo base='' sha='' arg train=0 lines='' rname origin
|
|
346
|
+
local repo base='' override='' sha='' arg train=0 lines='' rname origin
|
|
347
|
+
local producers=()
|
|
231
348
|
if [ "$1" = --train ]; then train=1; shift; [ $# -ge 1 ] || die 'stamp --train requires a producer repository'; fi
|
|
349
|
+
while [ $# -gt 0 ]; do
|
|
350
|
+
arg=$1; shift
|
|
351
|
+
case "$arg" in
|
|
352
|
+
--base-version)
|
|
353
|
+
[ $# -gt 0 ] || die '--base-version requires X.Y.Z'
|
|
354
|
+
[ -z "$override" ] || die '--base-version may only be supplied once'
|
|
355
|
+
[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || die "invalid base version: $1 (expected SemVer core X.Y.Z)"
|
|
356
|
+
override=$1; shift ;;
|
|
357
|
+
--train) train=1 ;;
|
|
358
|
+
-*) die "unknown stamp option: $arg" ;;
|
|
359
|
+
*) producers+=("$arg") ;;
|
|
360
|
+
esac
|
|
361
|
+
done
|
|
362
|
+
[ ${#producers[@]} -gt 0 ] || die 'stamp requires a producer repository'
|
|
363
|
+
set -- "${producers[@]}"
|
|
364
|
+
base=$override
|
|
232
365
|
for arg in "$@"; do
|
|
233
366
|
repo=$(repo_root "$arg")
|
|
234
|
-
|
|
235
|
-
|
|
367
|
+
if [ -z "$override" ]; then
|
|
368
|
+
next_base "$repo"
|
|
369
|
+
if [ -z "$base" ] || base_above "$NEXT_BASE" "$base"; then base=$NEXT_BASE; fi
|
|
370
|
+
fi
|
|
236
371
|
sha=$(git -C "$repo" rev-parse HEAD) || die 'producer has no HEAD commit'
|
|
237
372
|
if [ $# -gt 1 ]; then printf 'HEAD %s %s\n' "$repo" "$sha" >&2; fi
|
|
238
373
|
# The repository name is its origin's last path part (stable across checkouts), else its folder name.
|
|
@@ -252,9 +387,326 @@ stamp() {
|
|
|
252
387
|
printf '%s-local.%s.g%.7s\n' "$base" "$(date -u +%Y%m%d%H%M)" "$sha"
|
|
253
388
|
}
|
|
254
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
|
+
|
|
255
707
|
pack_nuget() {
|
|
256
708
|
[ $# -ge 3 ] || die 'pack-nuget requires producer, stamp and targets'
|
|
257
|
-
local repo version target path count=0 property
|
|
709
|
+
local repo version target path count=0 property ids=()
|
|
258
710
|
local properties=()
|
|
259
711
|
repo=$(repo_root "$1"); version=$2; shift 2
|
|
260
712
|
valid_stamp "$version"; need dotnet
|
|
@@ -267,16 +719,23 @@ pack_nuget() {
|
|
|
267
719
|
properties+=("-p:$property")
|
|
268
720
|
done
|
|
269
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
|
|
270
726
|
mutate mkdir -p "$FEED/nuget"
|
|
271
727
|
for target in "$@"; do
|
|
272
728
|
(cd "$repo" && heavy build "pack NuGet $target" dotnet pack "$target" -c Release "-p:PackageVersion=$version" "-p:Version=$version" ${properties[@]+"${properties[@]}"} -o "$FEED/nuget")
|
|
273
729
|
done
|
|
274
730
|
[ "$DRY" -eq 0 ] || return 0
|
|
731
|
+
nuget_pack_files "$version" "$TMP/pack-before" > "$TMP/pack-produced"
|
|
275
732
|
while IFS= read -r -d '' path; do
|
|
276
733
|
printf '%s\n' "$path"; count=$((count + 1))
|
|
277
|
-
|
|
734
|
+
target=${path##*/}; ids+=("${target%."$version".nupkg}")
|
|
735
|
+
done < "$TMP/pack-produced"
|
|
278
736
|
[ "$count" -gt 0 ] || failed 'pack produced no matching nupkgs'
|
|
279
737
|
printf 'produced %s nupkgs\n' "$count"
|
|
738
|
+
registry_update pack "$version" nuget "$repo" '' '' "${ids[@]}"
|
|
280
739
|
}
|
|
281
740
|
|
|
282
741
|
# Pack one workspace with npm pack (what publish does) into the feed as <scope>-<name>-<version>.tgz.
|
|
@@ -297,7 +756,7 @@ pack_npm_workspace() {
|
|
|
297
756
|
|
|
298
757
|
pack_npm() {
|
|
299
758
|
[ $# -eq 2 ] || die 'pack-npm requires producer and stamp'
|
|
300
|
-
local repo version path count=0
|
|
759
|
+
local repo version path count=0 ids=()
|
|
301
760
|
repo=$(repo_root "$1"); version=$2
|
|
302
761
|
valid_stamp "$version"; need node; need npm; need yarn; scratch
|
|
303
762
|
git -C "$repo" diff --name-only HEAD -- 'package.json' '*/package.json' > "$TMP/modified"
|
|
@@ -348,17 +807,12 @@ NODE
|
|
|
348
807
|
done < <(find "$FEED/npm" -type f -name "*-$version.tgz" -print0)
|
|
349
808
|
[ "$count" -gt 0 ] || failed 'pack produced no matching npm tarballs'
|
|
350
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[@]}"
|
|
351
814
|
}
|
|
352
815
|
|
|
353
|
-
# Read Include/Update attributes without assuming one element per line.
|
|
354
|
-
package_ids() {
|
|
355
|
-
awk 'BEGIN { RS="<" } /^PackageVersion[[:space:]]/ {
|
|
356
|
-
gsub(sprintf("%c",39),"\"")
|
|
357
|
-
if (match($0, /(Include|Update)[[:space:]]*=[[:space:]]*"[^"]+"/)) {
|
|
358
|
-
value=substr($0,RSTART,RLENGTH); sub(/^[^"]*"/,"",value); sub(/"$/,"",value); print value
|
|
359
|
-
}
|
|
360
|
-
}' "$1"
|
|
361
|
-
}
|
|
362
816
|
source_mapping() {
|
|
363
817
|
local config=$1 output=$2 id
|
|
364
818
|
shift 2
|
|
@@ -397,9 +851,68 @@ source_mapping() {
|
|
|
397
851
|
' "$config" > "$output"
|
|
398
852
|
}
|
|
399
853
|
|
|
854
|
+
# These headers are shared by the legacy and composition writers. The session
|
|
855
|
+
# lives below the Git directory, never above a consumer project in the lookup.
|
|
856
|
+
nuget_props_start() {
|
|
857
|
+
cat <<'XML'
|
|
858
|
+
<Project>
|
|
859
|
+
<PropertyGroup><_CratisLocalPackagesProps>$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildProjectDirectory)'))</_CratisLocalPackagesProps></PropertyGroup>
|
|
860
|
+
<Import Project="$(_CratisLocalPackagesProps)" Condition="'$(_CratisLocalPackagesProps)' != '' and Exists('$(_CratisLocalPackagesProps)')" />
|
|
861
|
+
XML
|
|
862
|
+
if [ "$1" -eq 1 ]; then
|
|
863
|
+
printf ' <PropertyGroup Condition="%s"><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n' "'\$(ManagePackageVersionsCentrally)' == 'true'"
|
|
864
|
+
fi
|
|
865
|
+
printf ' <ItemGroup Condition="%s">\n' "'\$(ManagePackageVersionsCentrally)' == 'true'"
|
|
866
|
+
}
|
|
867
|
+
nuget_targets_start() {
|
|
868
|
+
cat <<'XML'
|
|
869
|
+
<Project>
|
|
870
|
+
<Import Project="$(_CratisLocalPreviousAfterMicrosoftCommonTargets)" Condition="'$(_CratisLocalPreviousAfterMicrosoftCommonTargets)' != '' and '$(_CratisLocalPreviousAfterMicrosoftCommonTargets)' != '$(MSBuildThisFileFullPath)' and Exists('$(_CratisLocalPreviousAfterMicrosoftCommonTargets)')" />
|
|
871
|
+
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' != 'true'">
|
|
872
|
+
XML
|
|
873
|
+
}
|
|
874
|
+
nuget_overlay_end() { printf ' </ItemGroup>\n</Project>\n'; }
|
|
875
|
+
nuget_env() {
|
|
876
|
+
local session=$1 targets_key=CustomAfterMicrosoftCommon"Targets"
|
|
877
|
+
printf 'export %s=%q\n' DirectoryPackages"PropsPath" "$session/Directory.Packages.props"
|
|
878
|
+
printf 'export %s=%q\n' RestoreAdditional"ProjectSources" "$FEED/nuget"
|
|
879
|
+
# Capture at source time, not activation time; sourcing twice must not recurse.
|
|
880
|
+
printf 'if [ "%s" != %q ]; then\n' "\${$targets_key:-}" "$session/cratis-local.targets"
|
|
881
|
+
printf ' export _CratisLocalPreviousAfterMicrosoftCommonTargets="%s"\nfi\n' "\${$targets_key:-}"
|
|
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
|
|
884
|
+
}
|
|
885
|
+
|
|
400
886
|
use_nuget() {
|
|
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
|
|
912
|
+
case " $* " in *' --composition '*) die 'cannot mix a stamp or IDs with --composition' ;; esac
|
|
401
913
|
[ $# -ge 2 ] || die 'use-nuget requires consumer and stamp'
|
|
402
|
-
|
|
914
|
+
[ ! -d "$(session_path "$(repo_root "$1")")/composition" ] || die 'active composition; restore first'
|
|
915
|
+
local repo version session props id file config='' props_key sources_key targets_key pin=0 args=()
|
|
403
916
|
local ids=()
|
|
404
917
|
repo=$(repo_root "$1"); version=$2; shift 2
|
|
405
918
|
for id in "$@"; do
|
|
@@ -409,7 +922,6 @@ use_nuget() {
|
|
|
409
922
|
valid_stamp "$version"; props=$repo/Directory.Packages.props
|
|
410
923
|
[ -f "$props" ] || die 'no Directory.Packages.props (non-CPM). Manual alternative: untracked project copy, explicit PackageReference versions, dotnet restore --source FEED/nuget'
|
|
411
924
|
session=$(session_path "$repo"); scratch
|
|
412
|
-
package_ids "$props" > "$TMP/consumer-ids"
|
|
413
925
|
if [ $# -gt 0 ]; then
|
|
414
926
|
ids=("$@")
|
|
415
927
|
elif [ -d "$FEED/nuget" ]; then
|
|
@@ -442,25 +954,31 @@ use_nuget() {
|
|
|
442
954
|
return 0
|
|
443
955
|
fi
|
|
444
956
|
register_session "$repo" "$session" "$version"
|
|
957
|
+
if [ "$private" = 1 ]; then register_private_cache "$repo" "$PRIVATE_NUGET_CACHE" "$session"; fi
|
|
445
958
|
{
|
|
446
|
-
|
|
447
|
-
if [ "$pin" -eq 1 ]; then
|
|
448
|
-
printf ' <PropertyGroup><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n'
|
|
449
|
-
fi
|
|
450
|
-
printf ' <ItemGroup>\n'
|
|
959
|
+
nuget_props_start "$pin"
|
|
451
960
|
for id in "${ids[@]}"; do
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
printf ' <PackageVersion %s="%s" Version="%s" />\n' "$action" "$id" "$version"
|
|
961
|
+
# Remove/include works with any nearest props, not only root IDs.
|
|
962
|
+
printf ' <PackageVersion Remove="%s" /><PackageVersion Include="%s" Version="%s" />\n' "$id" "$id" "$version"
|
|
455
963
|
done
|
|
456
|
-
|
|
964
|
+
nuget_overlay_end
|
|
457
965
|
} > "$session/Directory.Packages.props"
|
|
458
|
-
props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"
|
|
459
966
|
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
967
|
+
nuget_targets_start
|
|
968
|
+
for id in "${ids[@]}"; do
|
|
969
|
+
printf ' <PackageReference Update="%s" Version="%s" />\n' "$id" "$version"
|
|
970
|
+
done
|
|
971
|
+
nuget_overlay_end
|
|
972
|
+
} > "$session/cratis-local.targets"
|
|
973
|
+
nuget_env "$session" > "$session/env"
|
|
974
|
+
props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"; targets_key=CustomAfterMicrosoftCommon"Targets"
|
|
975
|
+
printf ' %q' "-p:$props_key=$session/Directory.Packages.props" "-p:$sources_key=$FEED/nuget" "-p:$targets_key=$session/cratis-local.targets"
|
|
976
|
+
if [ "${CustomAfterMicrosoftCommonTargets:-}" != "$session/cratis-local.targets" ]; then
|
|
977
|
+
printf ' %q' "-p:_CratisLocalPreviousAfterMicrosoftCommonTargets=${CustomAfterMicrosoftCommonTargets:-}"
|
|
978
|
+
else
|
|
979
|
+
printf ' %q' "-p:_CratisLocalPreviousAfterMicrosoftCommonTargets=${_CratisLocalPreviousAfterMicrosoftCommonTargets:-}"
|
|
980
|
+
fi
|
|
981
|
+
printf '\n'
|
|
464
982
|
printf 'source %q\n' "$session/env"
|
|
465
983
|
if [ -n "$config" ]; then
|
|
466
984
|
source_mapping "$config" "$session/nuget.config" "${ids[@]}"
|
|
@@ -468,7 +986,562 @@ use_nuget() {
|
|
|
468
986
|
elif [ -f "$session/nuget.config" ]; then
|
|
469
987
|
rm -- "$session/nuget.config"
|
|
470
988
|
fi
|
|
989
|
+
registry_consumer "$version" nuget "$repo" "$session" "$PRIVATE_NUGET_CACHE"
|
|
471
990
|
printf 'overrode %s NuGet package IDs\n' "${#ids[@]}"
|
|
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
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
# A shared lock covers legacy mutations too: registration rollback must never replace
|
|
997
|
+
# another invocation's registry writes. An incomplete/unknown lock is preserved.
|
|
998
|
+
acquire_session_lock() {
|
|
999
|
+
local lock=$FEED/.composition-lock
|
|
1000
|
+
if [ "$DRY" -eq 1 ]; then
|
|
1001
|
+
[ ! -e "$lock" ] && [ ! -L "$lock" ] || die 'session locked; recovery requires a non-dry-run mutation'
|
|
1002
|
+
return 0
|
|
1003
|
+
fi
|
|
1004
|
+
mkdir -p "$FEED"
|
|
1005
|
+
# Serialize inspection, journal recovery and replacement so a contender cannot
|
|
1006
|
+
# recover a stale owner and then delete the next invocation's live lock.
|
|
1007
|
+
# An abandoned guard fails closed, just like an incomplete/unknown lock.
|
|
1008
|
+
mkdir "$FEED/.composition-recovery" || die 'local package session is locked'
|
|
1009
|
+
SESSION_RECOVERY_GUARD=$FEED/.composition-recovery
|
|
1010
|
+
if [ -e "$lock" ] || [ -L "$lock" ]; then composition recover; fi
|
|
1011
|
+
mkdir "$lock" || die 'local package session is locked'
|
|
1012
|
+
SESSION_LOCK=$lock
|
|
1013
|
+
printf '%s\n' "$$" > "$lock/owner"
|
|
1014
|
+
rmdir -- "$SESSION_RECOVERY_GUARD"
|
|
1015
|
+
SESSION_RECOVERY_GUARD=''
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
# Opt-in composition uses Node orchestration and the selected SDK's native NuGet
|
|
1019
|
+
# APIs. No npm dependency, persistent project, package restore or producer build.
|
|
1020
|
+
composition() {
|
|
1021
|
+
need node; scratch
|
|
1022
|
+
local operation=$1 repo='' session=''
|
|
1023
|
+
shift
|
|
1024
|
+
case "$operation" in
|
|
1025
|
+
use-nuget|verify-assets|restore-state)
|
|
1026
|
+
[ $# -ge 1 ] || die "$operation requires a consumer"
|
|
1027
|
+
repo=$(repo_root "$1"); session=$(session_path "$repo") ;;
|
|
1028
|
+
esac
|
|
1029
|
+
nuget_props_start 0 > "$TMP/overlay-props"
|
|
1030
|
+
nuget_props_start 1 > "$TMP/overlay-props-pin"
|
|
1031
|
+
nuget_targets_start > "$TMP/overlay-targets"
|
|
1032
|
+
nuget_overlay_end > "$TMP/overlay-end"
|
|
1033
|
+
nuget_env "$session/composition" > "$TMP/overlay-env"
|
|
1034
|
+
node - "$operation" "$FEED" "$TMP" "$DRY" "$repo" "$session" "$@" <<'COMPOSITION'
|
|
1035
|
+
const fs = require('node:fs');
|
|
1036
|
+
const path = require('node:path');
|
|
1037
|
+
const cp = require('node:child_process');
|
|
1038
|
+
const crypto = require('node:crypto');
|
|
1039
|
+
const [mode, feed, scratch, dry, repo, session, ...argv] = process.argv.slice(2);
|
|
1040
|
+
const lock = path.join(feed, '.composition-lock');
|
|
1041
|
+
const registry = path.join(feed, 'sessions');
|
|
1042
|
+
const local = /-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}(?:$|[^0-9A-Za-z])/;
|
|
1043
|
+
const stamp = /^[0-9]+\.[0-9]+\.[0-9]+-(?:local\.[0-9]{12}|train)\.g[0-9a-f]{7}$/;
|
|
1044
|
+
const idPattern = /^[A-Za-z0-9_.-]+$/;
|
|
1045
|
+
const lower = s => s.toLowerCase();
|
|
1046
|
+
const exists = p => fs.existsSync(p) || !!fs.lstatSync(path.dirname(p), {throwIfNoEntry:false}) && fs.readdirSync(path.dirname(p)).includes(path.basename(p));
|
|
1047
|
+
function fail(message, code = 2) { const error = new Error(message); error.code = code; throw error; }
|
|
1048
|
+
function plain(p, directory = false) {
|
|
1049
|
+
const st = fs.lstatSync(p, {throwIfNoEntry:false});
|
|
1050
|
+
if (!st || st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile())) fail('unsafe or missing path: ' + p);
|
|
1051
|
+
}
|
|
1052
|
+
function safeParents(p, ownedRoot = feed) {
|
|
1053
|
+
if (/[\x00-\x1f]/.test(p)) fail('control character in path');
|
|
1054
|
+
const root = path.resolve(ownedRoot), start = path.resolve(p);
|
|
1055
|
+
if (start !== root && !start.startsWith(root + path.sep)) fail('outside helper-owned directory: ' + p);
|
|
1056
|
+
for (let cur = start; ; cur = path.dirname(cur)) {
|
|
1057
|
+
const st = fs.lstatSync(cur, {throwIfNoEntry:false});
|
|
1058
|
+
if (st && (st.isSymbolicLink() || !st.isDirectory())) fail('unsafe directory: ' + cur);
|
|
1059
|
+
if (cur === root) break;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
function bytes(p) { if (!exists(p)) return null; plain(p); return fs.readFileSync(p).toString('base64'); }
|
|
1063
|
+
function write(p, value) { fs.writeFileSync(p, value, {flag:'wx', mode:0o600}); }
|
|
1064
|
+
function run(command, args) {
|
|
1065
|
+
const result = cp.spawnSync(command, args, {encoding:'utf8', maxBuffer:32 * 1024 * 1024});
|
|
1066
|
+
if (result.error || result.status !== 0) fail(command + ' failed: ' + (result.error?.message || result.stderr || result.stdout), 2);
|
|
1067
|
+
return result.stdout;
|
|
1068
|
+
}
|
|
1069
|
+
function manifest(file) {
|
|
1070
|
+
plain(file);
|
|
1071
|
+
const producers = new Map(), packages = new Map(), rows = [];
|
|
1072
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
1073
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
1074
|
+
const row = line.split('\t');
|
|
1075
|
+
if (row.some(s => s === '' || /[\r\x00-\x08\x0b-\x1f]/.test(s))) fail('malformed composition row');
|
|
1076
|
+
if (row[0] === 'producer' && row.length === 4) {
|
|
1077
|
+
const [, name, version, worktree] = row;
|
|
1078
|
+
if (!idPattern.test(name) || producers.has(lower(name)) || !stamp.test(version) || !path.isAbsolute(worktree)) fail('invalid or duplicate producer: ' + name);
|
|
1079
|
+
plain(worktree, true);
|
|
1080
|
+
if (run('git', ['-C', worktree, 'rev-parse', '--show-toplevel']).trim() !== worktree) fail('producer must name an absolute worktree root: ' + worktree);
|
|
1081
|
+
producers.set(lower(name), {name, version, worktree});
|
|
1082
|
+
} else if (row[0] === 'package' && row.length === 3) rows.push(row);
|
|
1083
|
+
else fail('malformed composition row: ' + line);
|
|
1084
|
+
}
|
|
1085
|
+
for (const [, owner, id] of rows) {
|
|
1086
|
+
const producer = producers.get(lower(owner));
|
|
1087
|
+
if (!producer || !idPattern.test(id) || packages.has(lower(id))) fail('unknown producer or duplicate package ownership: ' + id);
|
|
1088
|
+
packages.set(lower(id), {id, producer:producer.name, version:producer.version, worktree:producer.worktree});
|
|
1089
|
+
}
|
|
1090
|
+
if (!producers.size || !packages.size) fail('empty composition');
|
|
1091
|
+
for (const p of producers.values()) if (![...packages.values()].some(pkg => pkg.producer === p.name)) fail('empty producer closure: ' + p.name);
|
|
1092
|
+
return [...packages.values()].sort((a,b) => lower(a.id).localeCompare(lower(b.id), 'en'));
|
|
1093
|
+
}
|
|
1094
|
+
const normalize = packages => packages.map(p => [p.producer,p.id,p.version,p.worktree].join('\t')).join('\n') + '\n';
|
|
1095
|
+
const xml = s => s.replaceAll('&','&').replaceAll('<','<').replaceAll('"','"').replaceAll("'",''');
|
|
1096
|
+
const msxml = s => xml(s.replace(/[%$@;?*()]/g, c => '%' + c.charCodeAt(0).toString(16)));
|
|
1097
|
+
// All version/range comparisons and nuspec XML parsing run inside the SDK.
|
|
1098
|
+
function native(packages, graphs = []) {
|
|
1099
|
+
const inputs = path.join(scratch, 'native-inputs.tsv');
|
|
1100
|
+
const checks = path.join(scratch, 'native-graphs.tsv');
|
|
1101
|
+
const result = path.join(scratch, 'native-result');
|
|
1102
|
+
let input = '';
|
|
1103
|
+
packages.forEach((pkg, i) => {
|
|
1104
|
+
const artifact = path.join(feed, 'nuget', `${pkg.id}.${pkg.version}.nupkg`);
|
|
1105
|
+
safeParents(path.dirname(artifact));
|
|
1106
|
+
if (!exists(artifact)) fail('missing: ' + artifact, 1);
|
|
1107
|
+
plain(artifact);
|
|
1108
|
+
let names;
|
|
1109
|
+
try { names = run('unzip', ['-Z1', artifact]).split('\n').filter(n => /\.nuspec$/i.test(n)); }
|
|
1110
|
+
catch (e) { fail('corrupt package: ' + artifact, 1); }
|
|
1111
|
+
if (names.length !== 1 || !/^[A-Za-z0-9_.-]+\.nuspec$/i.test(names[0])) fail('ambiguous nuspec: ' + artifact, 1);
|
|
1112
|
+
const extracted = cp.spawnSync('unzip', ['-p', artifact, names[0]], {maxBuffer:8 * 1024 * 1024});
|
|
1113
|
+
if (extracted.error || extracted.status !== 0) fail('unreadable nuspec: ' + artifact, 1);
|
|
1114
|
+
const nuspec = path.join(scratch, `package-${i}.xml`);
|
|
1115
|
+
fs.writeFileSync(nuspec, extracted.stdout);
|
|
1116
|
+
input += [pkg.id, pkg.version, nuspec].join('\t') + '\n';
|
|
1117
|
+
});
|
|
1118
|
+
fs.writeFileSync(inputs, input); fs.writeFileSync(checks, graphs.join('\n'));
|
|
1119
|
+
const project = path.join(scratch, 'native.proj');
|
|
1120
|
+
fs.writeFileSync(project, `<Project>
|
|
1121
|
+
<UsingTask TaskName="CompositionValidation" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll">
|
|
1122
|
+
<ParameterGroup><Sdk ParameterType="System.String" Required="true"/><Inputs ParameterType="System.String" Required="true"/><Graphs ParameterType="System.String" Required="true"/><Result ParameterType="System.String" Required="true"/></ParameterGroup>
|
|
1123
|
+
<Task><Code Type="Fragment" Language="cs"><![CDATA[
|
|
1124
|
+
var verdict = "0";
|
|
1125
|
+
var advisories = new System.Text.StringBuilder();
|
|
1126
|
+
try {
|
|
1127
|
+
var assembly = System.Reflection.Assembly.LoadFrom(System.IO.Path.Combine(Sdk, "NuGet.Versioning.dll"));
|
|
1128
|
+
var vt = assembly.GetType("NuGet.Versioning.NuGetVersion", true);
|
|
1129
|
+
var rt = assembly.GetType("NuGet.Versioning.VersionRange", true);
|
|
1130
|
+
var pv = vt.GetMethod("TryParse", new[] { typeof(string), vt.MakeByRefType() });
|
|
1131
|
+
var pr = rt.GetMethod("TryParse", new[] { typeof(string), rt.MakeByRefType() });
|
|
1132
|
+
var sat = rt.GetMethod("Satisfies", new[] { vt });
|
|
1133
|
+
if (pv == null || pr == null || sat == null) throw new MissingMethodException("native NuGet validation API unavailable");
|
|
1134
|
+
Func<string, object> version = text => {
|
|
1135
|
+
var args = new object[] { text, null };
|
|
1136
|
+
if (!(bool)pv.Invoke(null, args)) throw new FormatException("invalid version: " + text);
|
|
1137
|
+
return args[1];
|
|
1138
|
+
};
|
|
1139
|
+
Func<string, object> range = text => {
|
|
1140
|
+
var args = new object[] { text, null };
|
|
1141
|
+
if (String.IsNullOrWhiteSpace(text) || !(bool)pr.Invoke(null, args)) throw new FormatException("invalid range: " + text);
|
|
1142
|
+
return args[1];
|
|
1143
|
+
};
|
|
1144
|
+
Action<string, string, string> satisfies = (v, r, context) => {
|
|
1145
|
+
if (!(bool)sat.Invoke(range(r), new[] { version(v) })) throw new FormatException("dependency range: " + context + " " + r + " excludes " + v);
|
|
1146
|
+
};
|
|
1147
|
+
var fa = System.Reflection.Assembly.LoadFrom(System.IO.Path.Combine(Sdk, "NuGet.Frameworks.dll"));
|
|
1148
|
+
var ft = fa.GetType("NuGet.Frameworks.NuGetFramework", true);
|
|
1149
|
+
var reducerType = fa.GetType("NuGet.Frameworks.FrameworkReducer", true);
|
|
1150
|
+
var fp = ft.GetMethod("Parse", new[] { typeof(string) });
|
|
1151
|
+
var nearest = reducerType.GetMethod("GetNearest", new[] { ft, typeof(System.Collections.Generic.IEnumerable<>).MakeGenericType(ft) });
|
|
1152
|
+
if (fp == null || nearest == null) throw new MissingMethodException("native NuGet framework API unavailable");
|
|
1153
|
+
var reducer = Activator.CreateInstance(reducerType);
|
|
1154
|
+
Func<string, object> framework = text => fp.Invoke(null, new object[] { String.IsNullOrEmpty(text) ? "any" : text });
|
|
1155
|
+
var dependencies = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.Dictionary<object, System.Collections.Generic.HashSet<string>>>(StringComparer.OrdinalIgnoreCase);
|
|
1156
|
+
var owned = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
1157
|
+
var rows = System.IO.File.ReadAllLines(Inputs);
|
|
1158
|
+
foreach (var row in rows) { var p = row.Split('\t'); version(p[1]); owned.Add(p[0], p[1]); }
|
|
1159
|
+
foreach (var row in rows) {
|
|
1160
|
+
var p = row.Split('\t');
|
|
1161
|
+
var document = new System.Xml.XmlDocument(); document.XmlResolver = null;
|
|
1162
|
+
var settings = new System.Xml.XmlReaderSettings(); settings.DtdProcessing = System.Xml.DtdProcessing.Prohibit; settings.XmlResolver = null;
|
|
1163
|
+
using (var reader = System.Xml.XmlReader.Create(p[2], settings)) document.Load(reader);
|
|
1164
|
+
var metadata = document.SelectNodes("/*[local-name()='package']/*[local-name()='metadata']");
|
|
1165
|
+
if (metadata.Count != 1) throw new FormatException("ambiguous metadata: " + p[0]);
|
|
1166
|
+
var ids = metadata[0].SelectNodes("*[local-name()='id']");
|
|
1167
|
+
var versions = metadata[0].SelectNodes("*[local-name()='version']");
|
|
1168
|
+
if (ids.Count != 1 || versions.Count != 1 || !String.Equals(ids[0].InnerText, p[0], StringComparison.OrdinalIgnoreCase) || versions[0].InnerText != p[1]) throw new FormatException("nuspec identity/version mismatch: " + p[0]);
|
|
1169
|
+
var groups = new System.Collections.Generic.Dictionary<object, System.Collections.Generic.HashSet<string>>();
|
|
1170
|
+
dependencies.Add(p[0], groups);
|
|
1171
|
+
var sections = metadata[0].SelectNodes("*[local-name()='dependencies']");
|
|
1172
|
+
if (sections.Count > 1) throw new FormatException("ambiguous dependency sections: " + p[0]);
|
|
1173
|
+
if (sections.Count == 1) {
|
|
1174
|
+
var grouped = sections[0].SelectNodes("*[local-name()='group']");
|
|
1175
|
+
if (grouped.Count == 0) groups.Add(framework("any"), new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
|
1176
|
+
else {
|
|
1177
|
+
if (sections[0].SelectNodes("*[local-name()='dependency']").Count != 0) throw new FormatException("mixed dependency groups: " + p[0]);
|
|
1178
|
+
foreach (System.Xml.XmlElement group in grouped) {
|
|
1179
|
+
var target = framework(group.GetAttribute("targetFramework"));
|
|
1180
|
+
if (groups.ContainsKey(target)) throw new FormatException("duplicate dependency group: " + p[0]);
|
|
1181
|
+
groups.Add(target, new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
foreach (System.Xml.XmlNode dep in document.SelectNodes("//*[local-name()='dependency']")) {
|
|
1186
|
+
var id = dep.Attributes["id"]; var r = dep.Attributes["version"];
|
|
1187
|
+
if (id == null || String.IsNullOrWhiteSpace(id.Value) || r == null || (dep.ParentNode.LocalName != "group" && dep.ParentNode.LocalName != "dependencies")) throw new FormatException("unreadable dependency: " + p[0]);
|
|
1188
|
+
var section = dep.ParentNode.LocalName == "group" ? dep.ParentNode.ParentNode : dep.ParentNode;
|
|
1189
|
+
if (sections.Count != 1 || section != sections[0]) throw new FormatException("misplaced dependency: " + p[0]);
|
|
1190
|
+
range(r.Value);
|
|
1191
|
+
if (owned.ContainsKey(id.Value)) {
|
|
1192
|
+
satisfies(owned[id.Value], r.Value, p[0] + " -> " + id.Value);
|
|
1193
|
+
var target = dep.ParentNode.LocalName == "group" ? framework(((System.Xml.XmlElement)dep.ParentNode).GetAttribute("targetFramework")) : framework("any");
|
|
1194
|
+
groups[target].Add(id.Value);
|
|
1195
|
+
}
|
|
1196
|
+
else if (System.Text.RegularExpressions.Regex.IsMatch(r.Value, @"-(local\\.[0-9]{12}|train)\\.g[0-9a-f]{7}")) throw new FormatException("undeclared local dependency: " + id.Value);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
foreach (var row in System.IO.File.ReadAllLines(Graphs)) {
|
|
1200
|
+
if (row.Length == 0) continue;
|
|
1201
|
+
var p = row.Split('\t');
|
|
1202
|
+
if (p[0] == "~") { if (!(bool)sat.Invoke(range(p[2]), new[] { version(p[1]) })) advisories.Append("\\ndependency range outside the composition: " + p[3] + " " + p[2] + " excludes " + p[1]); continue; }
|
|
1203
|
+
if (p[0] != "@") { satisfies(p[0], p[1], p[2]); continue; }
|
|
1204
|
+
var groups = dependencies[p[2]];
|
|
1205
|
+
if (groups.Count == 0) continue;
|
|
1206
|
+
var candidates = Array.CreateInstance(ft, groups.Count); var index = 0;
|
|
1207
|
+
foreach (var candidate in groups.Keys) candidates.SetValue(candidate, index++);
|
|
1208
|
+
var selected = nearest.Invoke(reducer, new object[] { framework(p[1].Split('/')[0]), candidates });
|
|
1209
|
+
if (selected == null) continue;
|
|
1210
|
+
var consumed = new System.Collections.Generic.HashSet<string>(p[3].Split(','), StringComparer.OrdinalIgnoreCase);
|
|
1211
|
+
var edges = new System.Collections.Generic.HashSet<string>(p[4].Split(','), StringComparer.OrdinalIgnoreCase);
|
|
1212
|
+
foreach (var required in groups[selected]) if (!consumed.Contains(required) || !edges.Contains(required)) throw new FormatException("required local dependency not consumed: " + p[2] + " -> " + required + " " + p[1]);
|
|
1213
|
+
}
|
|
1214
|
+
verdict = "0" + advisories.ToString();
|
|
1215
|
+
} catch (Exception e) {
|
|
1216
|
+
verdict = (e is MissingMethodException || e is System.IO.FileNotFoundException || e is TypeLoadException ? "2" : "1") + "\\n" + e.Message;
|
|
1217
|
+
}
|
|
1218
|
+
System.IO.File.WriteAllText(Result, verdict);
|
|
1219
|
+
]]></Code></Task></UsingTask>
|
|
1220
|
+
<Target Name="Validate"><CompositionValidation Sdk="$(MSBuildToolsPath)" Inputs="${msxml(inputs)}" Graphs="${msxml(checks)}" Result="${msxml(result)}"/></Target></Project>`);
|
|
1221
|
+
const execution = cp.spawnSync('dotnet', ['msbuild', project, '-t:Validate', '-nologo', '-v:quiet', '-m:1', '-nodeReuse:false'], {encoding:'utf8', maxBuffer:8 * 1024 * 1024});
|
|
1222
|
+
if (execution.error || execution.status !== 0 || !exists(result)) fail('native SDK validator unavailable: ' + (execution.error?.message || execution.stdout || execution.stderr));
|
|
1223
|
+
const [code, ...message] = fs.readFileSync(result,'utf8').split('\n');
|
|
1224
|
+
if (code !== '0') fail(message.join('\n'), Number(code));
|
|
1225
|
+
return message.filter(Boolean);
|
|
1226
|
+
}
|
|
1227
|
+
function assets(packages) {
|
|
1228
|
+
const owned = new Map(packages.map(p => [lower(p.id), p]));
|
|
1229
|
+
const files = [], resolved = new Map(), checks = [];
|
|
1230
|
+
function walk(dir) {
|
|
1231
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes:true})) {
|
|
1232
|
+
if (entry.isSymbolicLink() || ['.git','node_modules'].includes(entry.name)) continue;
|
|
1233
|
+
const file = path.join(dir,entry.name);
|
|
1234
|
+
if (entry.isDirectory()) walk(file);
|
|
1235
|
+
else if (entry.name === 'project.assets.json' && path.basename(dir) === 'obj') files.push(file);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
walk(repo);
|
|
1239
|
+
if (!files.length) fail('no obj/project.assets.json (restore the consumer first)');
|
|
1240
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
1241
|
+
for (const file of files) {
|
|
1242
|
+
let data;
|
|
1243
|
+
try { data = JSON.parse(fs.readFileSync(file,'utf8')); } catch { fail('malformed assets: ' + file,1); }
|
|
1244
|
+
if (!object(data.libraries) || !object(data.targets) || !Object.keys(data.targets).length || data.logs?.some(log => log.level === 'Error')) fail('invalid or failed restore assets: ' + file,1);
|
|
1245
|
+
const libraries = new Map();
|
|
1246
|
+
function keyParts(key, value) {
|
|
1247
|
+
const parts = key.split('/');
|
|
1248
|
+
if (parts.length !== 2 || !idPattern.test(parts[0]) || !parts[1] || !object(value) || !['package','project'].includes(value.type)) fail('malformed asset entry: ' + key + ' ' + file,1);
|
|
1249
|
+
return parts;
|
|
1250
|
+
}
|
|
1251
|
+
for (const [key, lib] of Object.entries(data.libraries)) {
|
|
1252
|
+
const [id, version] = keyParts(key,lib);
|
|
1253
|
+
if (lib.type !== 'package') continue;
|
|
1254
|
+
// Multitarget restores can hold several versions of one ID, one per framework.
|
|
1255
|
+
if (libraries.has(lower(id) + '/' + version)) fail('duplicate assets library: ' + key,1);
|
|
1256
|
+
libraries.set(lower(id) + '/' + version, {id,version});
|
|
1257
|
+
const pkg = owned.get(lower(id));
|
|
1258
|
+
if (pkg && version !== pkg.version || !pkg && local.test(version)) fail('wrong or undeclared local library: ' + key + ' ' + file,1);
|
|
1259
|
+
}
|
|
1260
|
+
const used = new Set();
|
|
1261
|
+
for (const [framework, target] of Object.entries(data.targets)) {
|
|
1262
|
+
if (!object(target) || /[\t\r\n]/.test(framework)) fail('malformed assets target: ' + file,1);
|
|
1263
|
+
const nodes = new Map();
|
|
1264
|
+
for (const [key,node] of Object.entries(target)) {
|
|
1265
|
+
const [id,version] = keyParts(key,node);
|
|
1266
|
+
if (nodes.has(lower(id))) fail('duplicate target ID: ' + id,1);
|
|
1267
|
+
nodes.set(lower(id), {id,version,node});
|
|
1268
|
+
if (node.type !== 'package') continue;
|
|
1269
|
+
if (!libraries.has(lower(id) + '/' + version)) fail('target/library mismatch: ' + key + ' ' + file,1);
|
|
1270
|
+
used.add(lower(id) + '/' + version);
|
|
1271
|
+
checks.push([version,`[${version}]`,`${file} ${framework} ${id}`].join('\t'));
|
|
1272
|
+
if (owned.has(lower(id))) resolved.set(lower(id),owned.get(lower(id)));
|
|
1273
|
+
}
|
|
1274
|
+
const packageIds = [...nodes.values()].filter(n => n.node.type === 'package').map(n => n.id).join(',');
|
|
1275
|
+
for (const {id,node} of nodes.values()) {
|
|
1276
|
+
if (owned.has(lower(id)) && node.type === 'package') checks.push(['@',framework,id,packageIds,Object.keys(node.dependencies || {}).join(',')].join('\t'));
|
|
1277
|
+
if (node.dependencies !== undefined && !object(node.dependencies)) fail('malformed target dependencies: ' + id,1);
|
|
1278
|
+
for (const [dep,range] of Object.entries(node.dependencies || {})) {
|
|
1279
|
+
if (typeof range !== 'string' || /[\t\r\n]/.test(range)) fail('malformed asset range: ' + dep,1);
|
|
1280
|
+
const selected = nodes.get(lower(dep));
|
|
1281
|
+
if (!selected) fail('unresolved target dependency: ' + id + ' -> ' + dep + ' ' + file,1);
|
|
1282
|
+
// An edge between two IDs outside the composition is NuGet's own NU1608 case and exists
|
|
1283
|
+
// without the overlay too; report it as a warning instead of refusing the composition.
|
|
1284
|
+
if (selected.node.type === 'package' && (owned.has(lower(id)) || owned.has(lower(dep)))) checks.push([selected.version,range,`${file} ${framework} ${id} -> ${dep}`].join('\t'));
|
|
1285
|
+
else if (selected.node.type === 'package') checks.push(['~',selected.version,range,`${file} ${framework} ${id} -> ${dep}`].join('\t'));
|
|
1286
|
+
if (owned.has(lower(dep)) && selected.node.type !== 'package') fail('owned dependency resolved as project: ' + dep,1);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
for (const [key, {id}] of libraries) if (owned.has(lower(id)) && !used.has(key)) fail('owned library absent from targets: ' + id + ' ' + file,1);
|
|
1291
|
+
}
|
|
1292
|
+
if (!resolved.size) fail('verify-assets: no composition package consumed',1);
|
|
1293
|
+
return {checks, packages:[...resolved.values()], files:files.length};
|
|
1294
|
+
}
|
|
1295
|
+
// Journal contains only the transaction's exact expected bytes. Recovery refuses
|
|
1296
|
+
// drift, unknown files and symlinks rather than deleting another task's work.
|
|
1297
|
+
function directoryBytes(dir) {
|
|
1298
|
+
plain(dir,true); const result = {};
|
|
1299
|
+
for (const name of fs.readdirSync(dir)) { plain(path.join(dir,name)); result[name] = bytes(path.join(dir,name)); }
|
|
1300
|
+
return result;
|
|
1301
|
+
}
|
|
1302
|
+
function same(a,b) { return JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); }
|
|
1303
|
+
function removeOwned(dir, expected, partial = false) {
|
|
1304
|
+
if (!exists(dir)) return;
|
|
1305
|
+
const actual = directoryBytes(dir);
|
|
1306
|
+
if (partial ? Object.keys(actual).some(n => actual[n] !== expected[n]) : !same(actual,expected)) fail('transaction files changed; preserved: ' + dir);
|
|
1307
|
+
for (const name of Object.keys(actual)) fs.unlinkSync(path.join(dir,name));
|
|
1308
|
+
fs.rmdirSync(dir);
|
|
1309
|
+
}
|
|
1310
|
+
function journal(value) {
|
|
1311
|
+
const file = path.join(lock,'journal.json');
|
|
1312
|
+
fs.writeFileSync(file + '.next', JSON.stringify(value), {mode:0o600});
|
|
1313
|
+
fs.renameSync(file + '.next', file);
|
|
1314
|
+
}
|
|
1315
|
+
function recoverState(j) {
|
|
1316
|
+
if (j.schema !== 1 || !path.isAbsolute(j.repo) || !path.isAbsolute(j.session) || j.session === '/' || j.session === j.repo || !/^[a-f0-9]{24}$/.test(j.token) || !j.files || Object.keys(j.files).some(n => !['manifest.tsv','Directory.Packages.props','cratis-local.targets','nuget.config','env','transaction','state.json'].includes(n))) fail('unknown transaction journal');
|
|
1317
|
+
safeParents(j.session,j.session);
|
|
1318
|
+
const pub = path.join(j.session,'composition'), stage = path.join(j.session, '.composition-' + j.token);
|
|
1319
|
+
if (j.operation === 'activate') {
|
|
1320
|
+
if (!j.committed) {
|
|
1321
|
+
const current = bytes(registry);
|
|
1322
|
+
if (current !== j.registryBefore && current !== j.registryAfter) fail('registry changed; transaction preserved');
|
|
1323
|
+
if (exists(pub)) removeOwned(pub,j.files);
|
|
1324
|
+
if (current === j.registryAfter && current !== j.registryBefore) {
|
|
1325
|
+
if (j.registryBefore === null) fs.unlinkSync(registry);
|
|
1326
|
+
else { fs.writeFileSync(path.join(lock,'registry.rollback'),Buffer.from(j.registryBefore,'base64')); fs.renameSync(path.join(lock,'registry.rollback'),registry); }
|
|
1327
|
+
}
|
|
1328
|
+
const marker = path.join(j.session,'repo');
|
|
1329
|
+
if (!j.hadRepo && exists(marker)) {
|
|
1330
|
+
if (bytes(marker) !== Buffer.from(j.repo + '\n').toString('base64')) fail('session marker changed; preserved');
|
|
1331
|
+
fs.unlinkSync(marker);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
removeOwned(stage,j.files,true);
|
|
1335
|
+
const pending = path.join(lock,'registry.next');
|
|
1336
|
+
if (exists(pending)) {
|
|
1337
|
+
if (bytes(pending) !== j.registryAfter) fail('staged registry changed; preserved');
|
|
1338
|
+
fs.unlinkSync(pending);
|
|
1339
|
+
}
|
|
1340
|
+
if (!j.committed && !j.hadSession && exists(j.session) && fs.readdirSync(j.session).length === 0) fs.rmdirSync(j.session);
|
|
1341
|
+
} else if (j.operation === 'restore') {
|
|
1342
|
+
if (!j.committed && exists(stage)) {
|
|
1343
|
+
if (exists(pub) || !same(directoryBytes(stage),j.files)) fail('restoration state changed; preserved');
|
|
1344
|
+
fs.renameSync(stage,pub);
|
|
1345
|
+
} else if (j.committed) removeOwned(stage,j.files);
|
|
1346
|
+
} else fail('unknown transaction operation');
|
|
1347
|
+
fs.unlinkSync(path.join(lock,'journal.json'));
|
|
1348
|
+
}
|
|
1349
|
+
function recover() {
|
|
1350
|
+
safeParents(lock); plain(lock,true); plain(path.join(lock,'owner'));
|
|
1351
|
+
const owner = fs.readFileSync(path.join(lock,'owner'),'utf8').trim();
|
|
1352
|
+
if (!/^[1-9][0-9]*$/.test(owner)) fail('unknown lock owner; preserved');
|
|
1353
|
+
try { process.kill(Number(owner),0); fail('local package session is locked by ' + owner); }
|
|
1354
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
1355
|
+
const allowed = new Set(['owner','journal.json','journal.json.next','registry.next','registry.rollback']);
|
|
1356
|
+
if (fs.readdirSync(lock).some(n => !allowed.has(n))) fail('unknown lock state; preserved');
|
|
1357
|
+
for (const n of fs.readdirSync(lock)) plain(path.join(lock,n));
|
|
1358
|
+
if (exists(path.join(lock,'journal.json'))) recoverState(JSON.parse(fs.readFileSync(path.join(lock,'journal.json'),'utf8')));
|
|
1359
|
+
else if (fs.readdirSync(lock).some(n => n !== 'owner')) fail('incomplete transaction journal; preserved');
|
|
1360
|
+
for (const n of fs.readdirSync(lock)) fs.unlinkSync(path.join(lock,n));
|
|
1361
|
+
fs.rmdirSync(lock);
|
|
1362
|
+
}
|
|
1363
|
+
// Fixture-only fault injection requires the isolated self-test capability file.
|
|
1364
|
+
function boundary(name) {
|
|
1365
|
+
const capability = process.env.CRATIS_LOCAL_COMPOSITION_TEST_ROOT;
|
|
1366
|
+
if (!capability || !feed.startsWith(capability + path.sep) || !fs.existsSync(path.join(capability,'.composition-fixture'))) return;
|
|
1367
|
+
if (process.env.CRATIS_LOCAL_COMPOSITION_FAIL === name) fail('injected failure: ' + name,1);
|
|
1368
|
+
if (process.env.CRATIS_LOCAL_COMPOSITION_INTERRUPT === name) { process.kill(process.ppid,'SIGKILL'); process.kill(process.pid,'SIGKILL'); }
|
|
1369
|
+
}
|
|
1370
|
+
function activeFiles() {
|
|
1371
|
+
const pub = path.join(session,'composition');
|
|
1372
|
+
if (!exists(pub)) return null;
|
|
1373
|
+
const files = directoryBytes(pub);
|
|
1374
|
+
const allowed = new Set(['manifest.tsv','Directory.Packages.props','cratis-local.targets','nuget.config','env','transaction','state.json']);
|
|
1375
|
+
if (Object.keys(files).some(n => !allowed.has(n)) || !files['state.json'] || !files.transaction || !/^[a-f0-9]{24}\n$/.test(Buffer.from(files.transaction,'base64').toString())) fail('unsafe composition session');
|
|
1376
|
+
const expected = JSON.parse(Buffer.from(files['state.json'],'base64').toString());
|
|
1377
|
+
const actual = {...files}; delete actual.transaction; delete actual['state.json'];
|
|
1378
|
+
if (!same(actual,expected)) fail('composition files changed; preserved');
|
|
1379
|
+
return files;
|
|
1380
|
+
}
|
|
1381
|
+
function activate(packages,pin) {
|
|
1382
|
+
safeParents(session,session); safeParents(feed);
|
|
1383
|
+
const props = path.join(repo,'Directory.Packages.props'); plain(props);
|
|
1384
|
+
if (exists(session)) {
|
|
1385
|
+
plain(session,true);
|
|
1386
|
+
const allowed = new Set(['repo','composition','nuget-cache']);
|
|
1387
|
+
if (fs.readdirSync(session).some(n => !allowed.has(n))) fail('conflicting active overlay/session; restore first');
|
|
1388
|
+
if (exists(path.join(session,'repo')) && fs.readFileSync(path.join(session,'repo'),'utf8') !== repo + '\n') fail('session belongs to another checkout');
|
|
1389
|
+
}
|
|
1390
|
+
const pub = path.join(session,'composition');
|
|
1391
|
+
const template = name => fs.readFileSync(path.join(scratch,'overlay-' + name),'utf8');
|
|
1392
|
+
const escaped = packages.map(p => `<PackageVersion Remove="${xml(p.id)}"/><PackageVersion Include="${xml(p.id)}" Version="${p.version}"/>`).join('\n');
|
|
1393
|
+
const references = packages.map(p => `<PackageReference Update="${xml(p.id)}" Version="${p.version}"/>`).join('\n');
|
|
1394
|
+
const files = {
|
|
1395
|
+
'manifest.tsv':normalize(packages),
|
|
1396
|
+
'Directory.Packages.props':template(pin ? 'props-pin' : 'props') + escaped + '\n' + template('end'),
|
|
1397
|
+
'cratis-local.targets':template('targets') + references + '\n' + template('end'),
|
|
1398
|
+
env:template('env')
|
|
1399
|
+
};
|
|
1400
|
+
// Native XML manipulation preserves namespaces/configuration and makes exact
|
|
1401
|
+
// local mappings exclusive; remote wildcards remain less specific.
|
|
1402
|
+
const configs = run('git',['-C',repo,'ls-files','-z']).split('\0').filter(n => ['nuget.config','NuGet.Config','NuGet.config'].includes(n));
|
|
1403
|
+
if (configs.length > 1) fail('ambiguous root NuGet config');
|
|
1404
|
+
if (configs.length) files['nuget.config'] = configOverlay(path.join(repo,configs[0]),packages);
|
|
1405
|
+
else files['nuget.config'] = configOverlay(null,packages);
|
|
1406
|
+
files['state.json'] = JSON.stringify(Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]))) + '\n';
|
|
1407
|
+
const previous = activeFiles();
|
|
1408
|
+
if (previous) {
|
|
1409
|
+
const expected = Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]));
|
|
1410
|
+
const withoutToken = {...previous}; delete withoutToken.transaction;
|
|
1411
|
+
if (!same(expected,withoutToken) || !fs.readFileSync(registry,'utf8').split('\n').includes(session)) fail('different or unregistered active composition; restore first');
|
|
1412
|
+
instructions(packages); return;
|
|
1413
|
+
}
|
|
1414
|
+
if (dry === '1') { console.log('dry-run: validated ' + packages.length + ' composition packages; no overlay written'); return; }
|
|
1415
|
+
const token = crypto.randomBytes(12).toString('hex'); files.transaction = token + '\n';
|
|
1416
|
+
const expected = Object.fromEntries(Object.entries(files).map(([k,v]) => [k,Buffer.from(v).toString('base64')]));
|
|
1417
|
+
const before = bytes(registry);
|
|
1418
|
+
const oldRegistry = before === null ? '' : Buffer.from(before,'base64').toString();
|
|
1419
|
+
if (oldRegistry && !oldRegistry.endsWith('\n')) fail('malformed session registry');
|
|
1420
|
+
const after = oldRegistry.split('\n').includes(session) ? oldRegistry : oldRegistry + session + '\n';
|
|
1421
|
+
const j = {schema:1,operation:'activate',session,repo,token,files:expected,hadSession:exists(session),hadRepo:exists(path.join(session,'repo')),registryBefore:before,registryAfter:Buffer.from(after).toString('base64'),committed:false};
|
|
1422
|
+
journal(j);
|
|
1423
|
+
try {
|
|
1424
|
+
boundary('journal');
|
|
1425
|
+
if (!j.hadSession) { fs.mkdirSync(path.dirname(session), {recursive:true}); fs.mkdirSync(session); }
|
|
1426
|
+
boundary('session');
|
|
1427
|
+
const stage = path.join(session,'.composition-' + token); fs.mkdirSync(stage);
|
|
1428
|
+
// A partial stage is recorded before each file write for exact recovery.
|
|
1429
|
+
for (const [name,value] of Object.entries(files)) { write(path.join(stage,name),value); boundary('stage-' + name); }
|
|
1430
|
+
boundary('staged');
|
|
1431
|
+
if (bytes(registry) !== before) fail('registry changed before publication');
|
|
1432
|
+
fs.renameSync(stage,pub); boundary('published');
|
|
1433
|
+
if (!j.hadRepo) write(path.join(session,'repo'),repo + '\n');
|
|
1434
|
+
boundary('repo');
|
|
1435
|
+
write(path.join(lock,'registry.next'),after); boundary('registration-staged');
|
|
1436
|
+
if (bytes(registry) !== before) fail('registry changed before registration');
|
|
1437
|
+
fs.renameSync(path.join(lock,'registry.next'),registry); boundary('registered');
|
|
1438
|
+
j.committed = true; journal(j); boundary('committed');
|
|
1439
|
+
recoverState(j);
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
if (!j.committed) recoverState(j);
|
|
1442
|
+
throw error;
|
|
1443
|
+
}
|
|
1444
|
+
instructions(packages);
|
|
1445
|
+
}
|
|
1446
|
+
function shell(s) { return "'" + s.replaceAll("'", "'\\''") + "'"; }
|
|
1447
|
+
function instructions(packages) {
|
|
1448
|
+
const pub = path.join(session,'composition');
|
|
1449
|
+
console.log('source ' + shell(path.join(pub,'env')));
|
|
1450
|
+
console.log('For restore, also pass: --configfile ' + shell(path.join(pub,'nuget.config')));
|
|
1451
|
+
console.log('overrode ' + packages.length + ' NuGet package IDs in composition');
|
|
1452
|
+
}
|
|
1453
|
+
// XML config overlay is implemented by a second SDK task in the same temporary
|
|
1454
|
+
// project, using XmlDocument rather than a regex/token-based XML rewrite.
|
|
1455
|
+
function configOverlay(config,packages) {
|
|
1456
|
+
if (config) plain(config);
|
|
1457
|
+
const output = path.join(scratch,'config.xml'), project = path.join(scratch,'config.proj');
|
|
1458
|
+
const ids = path.join(scratch,'config-ids'); fs.writeFileSync(ids,packages.map(p => p.id).join('\n'));
|
|
1459
|
+
fs.writeFileSync(project, `<Project><UsingTask TaskName="CompositionConfig" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll"><ParameterGroup><Input ParameterType="System.String"/><Output ParameterType="System.String"/><Feed ParameterType="System.String"/><Ids ParameterType="System.String"/></ParameterGroup><Task><Code Type="Fragment" Language="cs"><![CDATA[
|
|
1460
|
+
var doc = new System.Xml.XmlDocument(); doc.XmlResolver = null;
|
|
1461
|
+
if (Input.Length == 0) doc.LoadXml("<configuration><packageSources><clear/><add key='nuget.org' value='https://api.nuget.org/v3/index.json'/></packageSources></configuration>");
|
|
1462
|
+
else { var settings = new System.Xml.XmlReaderSettings(); settings.DtdProcessing = System.Xml.DtdProcessing.Prohibit; settings.XmlResolver = null; using (var reader = System.Xml.XmlReader.Create(Input, settings)) doc.Load(reader); }
|
|
1463
|
+
var root = doc.DocumentElement;
|
|
1464
|
+
if (root.Name != "configuration") throw new FormatException("invalid NuGet config");
|
|
1465
|
+
var sources = root.SelectNodes("packageSources");
|
|
1466
|
+
if (sources.Count != 1 || root.SelectNodes("packageSourceMapping").Count > 1) throw new FormatException("ambiguous NuGet sources/mapping");
|
|
1467
|
+
foreach (System.Xml.XmlElement source in sources[0].SelectNodes("add")) {
|
|
1468
|
+
if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("source key cratis-local already exists");
|
|
1469
|
+
var value = source.GetAttribute("value"); Uri uri;
|
|
1470
|
+
if (Input.Length > 0 && !System.IO.Path.IsPathRooted(value) && !Uri.TryCreate(value, UriKind.Absolute, out uri) && !value.StartsWith("%")) source.SetAttribute("value",System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Input),value)));
|
|
1471
|
+
}
|
|
1472
|
+
var localSource = doc.CreateElement("add"); localSource.SetAttribute("key","cratis-local"); localSource.SetAttribute("value",Feed); sources[0].AppendChild(localSource);
|
|
1473
|
+
var mapping = root.SelectSingleNode("packageSourceMapping");
|
|
1474
|
+
if (mapping != null) foreach (System.Xml.XmlElement source in mapping.SelectNodes("packageSource")) if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("mapping key cratis-local already exists");
|
|
1475
|
+
foreach (System.Xml.XmlElement source in root.SelectNodes("disabledPackageSources/add")) if (String.Equals(source.GetAttribute("key"),"cratis-local",StringComparison.OrdinalIgnoreCase)) throw new FormatException("source key cratis-local is disabled");
|
|
1476
|
+
if (mapping == null) {
|
|
1477
|
+
mapping = doc.CreateElement("packageSourceMapping"); root.AppendChild(mapping);
|
|
1478
|
+
foreach (System.Xml.XmlElement source in sources[0].SelectNodes("add")) {
|
|
1479
|
+
if (source.GetAttribute("key") == "cratis-local") continue;
|
|
1480
|
+
var remote = doc.CreateElement("packageSource"); remote.SetAttribute("key",source.GetAttribute("key"));
|
|
1481
|
+
var wildcard = doc.CreateElement("package"); wildcard.SetAttribute("pattern","*"); remote.AppendChild(wildcard); mapping.AppendChild(remote);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
var localMapping = doc.CreateElement("packageSource"); localMapping.SetAttribute("key","cratis-local");
|
|
1485
|
+
foreach (var id in System.IO.File.ReadAllLines(Ids)) {
|
|
1486
|
+
foreach (System.Xml.XmlElement entry in mapping.SelectNodes("packageSource/package")) if (String.Equals(entry.GetAttribute("pattern"),id,StringComparison.OrdinalIgnoreCase)) entry.ParentNode.RemoveChild(entry);
|
|
1487
|
+
var entryLocal = doc.CreateElement("package"); entryLocal.SetAttribute("pattern",id); localMapping.AppendChild(entryLocal);
|
|
1488
|
+
}
|
|
1489
|
+
mapping.AppendChild(localMapping); doc.Save(Output);
|
|
1490
|
+
]]></Code></Task></UsingTask><Target Name="Overlay"><CompositionConfig Input="${msxml(config || '')}" Output="${msxml(output)}" Feed="${msxml(path.join(feed,'nuget'))}" Ids="${msxml(ids)}"/></Target></Project>`);
|
|
1491
|
+
run('dotnet',['msbuild',project,'-t:Overlay','-nologo','-v:quiet','-m:1','-nodeReuse:false']);
|
|
1492
|
+
return fs.readFileSync(output,'utf8');
|
|
1493
|
+
}
|
|
1494
|
+
function restoreState() {
|
|
1495
|
+
const files = activeFiles(); if (!files) return;
|
|
1496
|
+
if (dry === '1') { console.log('dry-run: remove composition overlay'); return; }
|
|
1497
|
+
const token = crypto.randomBytes(12).toString('hex');
|
|
1498
|
+
const j = {schema:1,operation:'restore',session,repo,token,files,committed:false};
|
|
1499
|
+
journal(j);
|
|
1500
|
+
try {
|
|
1501
|
+
boundary('restore-journal');
|
|
1502
|
+
fs.renameSync(path.join(session,'composition'),path.join(session,'.composition-' + token)); boundary('restore-renamed');
|
|
1503
|
+
j.committed = true; journal(j); boundary('restore-committed'); recoverState(j);
|
|
1504
|
+
} catch (error) { if (!j.committed) recoverState(j); throw error; }
|
|
1505
|
+
}
|
|
1506
|
+
function cleanGuard() {
|
|
1507
|
+
const knownSessions = new Set(argv[1] ? [argv[1]] : []);
|
|
1508
|
+
if (exists(registry)) { plain(registry); for (const known of fs.readFileSync(registry,'utf8').split('\n').filter(Boolean)) knownSessions.add(known); }
|
|
1509
|
+
const selected = argv[0] || '';
|
|
1510
|
+
for (const known of knownSessions) {
|
|
1511
|
+
if (!path.isAbsolute(known)) fail('invalid registered session');
|
|
1512
|
+
safeParents(known,known);
|
|
1513
|
+
const composition = path.join(known,'composition');
|
|
1514
|
+
if (!exists(composition)) continue;
|
|
1515
|
+
plain(composition,true); plain(path.join(composition,'manifest.tsv'));
|
|
1516
|
+
const versions = fs.readFileSync(path.join(composition,'manifest.tsv'),'utf8').trim().split('\n').map(row => row.split('\t')[2]);
|
|
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);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
safeParents(feed);
|
|
1523
|
+
if (mode === 'recover') recover();
|
|
1524
|
+
else if (mode === 'restore-state') restoreState();
|
|
1525
|
+
else if (mode === 'clean-guard' || mode === 'clean-members') cleanGuard();
|
|
1526
|
+
else {
|
|
1527
|
+
const args = [...argv]; if (mode !== 'verify-pack') args.shift();
|
|
1528
|
+
if (args.shift() !== '--composition' || !args.length) fail('expected --composition FILE');
|
|
1529
|
+
const file = args.shift(); let json = false, pin = false;
|
|
1530
|
+
for (const arg of args) {
|
|
1531
|
+
if (arg === '--json' && mode !== 'use-nuget' && !json) json = true;
|
|
1532
|
+
else if (arg === '--pin-transitive' && mode === 'use-nuget' && !pin) pin = true;
|
|
1533
|
+
else fail('cannot mix composition with stamp, IDs or other options: ' + arg);
|
|
1534
|
+
}
|
|
1535
|
+
const packages = manifest(file);
|
|
1536
|
+
const consumed = mode === 'verify-assets' ? assets(packages) : null;
|
|
1537
|
+
const warnings = native(packages,consumed?.checks);
|
|
1538
|
+
for (const warning of warnings) console.error('WARNING: ' + warning);
|
|
1539
|
+
if (mode === 'use-nuget') activate(packages,pin);
|
|
1540
|
+
else if (json) console.log(JSON.stringify({packages:(consumed?.packages || packages).map(({id,version,producer}) => ({id,version,producer})), warnings}));
|
|
1541
|
+
else console.log(mode + ': ' + (consumed?.packages.length || packages.length) + ' composition packages' + (consumed ? ', ' + consumed.files + ' assets files' : '') + ', native NuGet validation passed');
|
|
1542
|
+
}
|
|
1543
|
+
} catch (error) { console.error('ERROR: ' + error.message); process.exitCode = Number.isInteger(error.code) ? error.code : 2; }
|
|
1544
|
+
COMPOSITION
|
|
472
1545
|
}
|
|
473
1546
|
|
|
474
1547
|
# Dependency map: tab-separated rows, '#' comments (see references/dependency-map.md).
|
|
@@ -817,6 +1890,7 @@ report_findings() {
|
|
|
817
1890
|
# Plan mode checks exactly the plan the closure printed. Name mode (manual, weaker) builds the plan rows from
|
|
818
1891
|
# the named repositories' ids rows instead of from edges.
|
|
819
1892
|
verify_pack() {
|
|
1893
|
+
case " $* " in *' --composition '*) composition verify-pack "$@"; return ;; esac
|
|
820
1894
|
[ $# -ge 2 ] || die 'verify-pack requires STAMP and either --consumer C --producer P... or repository NAMEs'
|
|
821
1895
|
local stamp=$1 json=0 out=1 ecosystem='' consumer='' name eco d found
|
|
822
1896
|
local producers=() names=()
|
|
@@ -871,6 +1945,7 @@ verify_pack() {
|
|
|
871
1945
|
# Plan mode scans for exactly the planned IDs of one ecosystem; feed-wide mode (no --consumer) scans every
|
|
872
1946
|
# package at the stamp in the feed, which is manual and weaker.
|
|
873
1947
|
verify_assets() {
|
|
1948
|
+
case " $* " in *' --composition '*) composition verify-assets "$@"; return ;; esac
|
|
874
1949
|
[ $# -ge 2 ] || die 'verify-assets requires CONSUMER-PATH, STAMP, [--npm] [--dir REL]'
|
|
875
1950
|
local path=$1 stamp=$2 npm=0 dir='' json=0 out=1 consumer='' eco=nuget d name file matched nm unit
|
|
876
1951
|
local producers=()
|
|
@@ -950,6 +2025,7 @@ use_npm() {
|
|
|
950
2025
|
dir=$repo${rel:+/$rel}
|
|
951
2026
|
valid_stamp "$version"; need node; need tar; scratch
|
|
952
2027
|
session=$(session_path "$repo"); backup=$session/npm-backup
|
|
2028
|
+
[ ! -d "$session/composition" ] || die 'active composition; restore first'
|
|
953
2029
|
[ -f "$dir/package.json" ] || die "consumer has no package.json in ${rel:-the root}; use --dir REL for a Yarn project in a subdirectory"
|
|
954
2030
|
[ ! -e "$backup" ] || die "active npm backup exists: $backup; restore first"
|
|
955
2031
|
node - "$dir" "$FEED/npm" "$version" "$TMP/package.json" <<'NODE'
|
|
@@ -985,15 +2061,33 @@ NODE
|
|
|
985
2061
|
git -C "$dir" diff HEAD -- package.json yarn.lock > "$backup/before.diff"
|
|
986
2062
|
cp "$TMP/package.json" "$backup/expected-package.json"
|
|
987
2063
|
cp "$TMP/package.json" "$dir/package.json"
|
|
2064
|
+
registry_consumer "$version" npm "$repo" "$session" ''
|
|
988
2065
|
printf 'Next: cd %q && yarn install\n' "$dir"
|
|
989
2066
|
printf 'Vite: clear stale prebundles with rm -rf node_modules/.vite\n'
|
|
990
2067
|
}
|
|
991
2068
|
|
|
992
2069
|
restore() {
|
|
993
2070
|
[ $# -eq 1 ] || die 'restore requires a consumer'
|
|
994
|
-
local repo session backup dir
|
|
2071
|
+
local repo session backup dir stamp
|
|
2072
|
+
local restore_stamps=()
|
|
995
2073
|
repo=$(repo_root "$1"); session=$(session_path "$repo"); backup=$session/npm-backup
|
|
996
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
|
|
2085
|
+
if [ -d "$session/composition" ]; then
|
|
2086
|
+
composition restore-state "$repo"
|
|
2087
|
+
if [ "$registry_node" -eq 1 ]; then registry_update restore "$session" ${restore_stamps[@]+"${restore_stamps[@]}"}; fi
|
|
2088
|
+
if [ "$DRY" -eq 0 ]; then check "$repo"; fi
|
|
2089
|
+
return
|
|
2090
|
+
fi
|
|
997
2091
|
dir=$repo
|
|
998
2092
|
if [ -f "$session/npm-dir" ]; then dir=$repo/$(cat "$session/npm-dir"); fi
|
|
999
2093
|
if [ -f "$session/repo" ] && [ "$(cat "$session/repo")" != "$repo" ]; then die 'session belongs to another checkout'; fi
|
|
@@ -1009,7 +2103,8 @@ restore() {
|
|
|
1009
2103
|
if [ -f "$backup/yarn.lock" ]; then mutate cp -p "$backup/yarn.lock" "$dir/yarn.lock"; else mutate rm -f -- "$dir/yarn.lock"; fi
|
|
1010
2104
|
mutate rm -rf -- "$backup"
|
|
1011
2105
|
fi
|
|
1012
|
-
mutate rm -f -- "$session/Directory.Packages.props" "$session/nuget.config" "$session/env" "$session/stamp" "$session/npm-dir"
|
|
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
|
|
1013
2108
|
if [ "$DRY" -eq 0 ]; then check "$repo"; fi
|
|
1014
2109
|
}
|
|
1015
2110
|
|
|
@@ -1051,15 +2146,17 @@ check() {
|
|
|
1051
2146
|
}
|
|
1052
2147
|
scan_lines() {
|
|
1053
2148
|
local path=$1 input=$2
|
|
1054
|
-
awk -v path="$path" -v feed="$FEED" '
|
|
2149
|
+
awk -v path="$path" -v feed="$FEED" -v alias="$FEED_ALIAS" '
|
|
1055
2150
|
function hit(pattern) { print path ":" number ":" pattern }
|
|
1056
2151
|
{
|
|
1057
2152
|
number=$0; sub(/:.*/,"",number); line=$0; sub(/^[^:]*:/,"",line)
|
|
1058
2153
|
if (line ~ /-local\.[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]\.g[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/) hit("local stamp")
|
|
1059
2154
|
if (line ~ /-train\.g[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]/) hit("train stamp")
|
|
1060
|
-
if (index(line,feed)) hit("local feed path")
|
|
2155
|
+
if (index(line,feed) || (alias != feed && index(line,alias))) hit("local feed path")
|
|
1061
2156
|
if (index(line,"DirectoryPackages" "PropsPath")) hit("CPM overlay property")
|
|
1062
2157
|
if (index(line,"RestoreAdditional" "ProjectSources")) hit("additional restore sources")
|
|
2158
|
+
if (index(line,"CustomAfterMicrosoftCommon" "Targets")) hit("local targets property")
|
|
2159
|
+
if (index(line,"_CratisLocalPreviousAfterMicrosoftCommon" "Targets")) hit("previous targets property")
|
|
1063
2160
|
if (line ~ /<PackageVersion[[:space:]]+Update[[:space:]]*=/) hit("package version override")
|
|
1064
2161
|
if (path ~ /(^|\/)(package.json|yarn.lock)$/ && line ~ /(file|portal|link):/ &&
|
|
1065
2162
|
(line ~ /\.tgz/ || line ~ /(file|portal|link):(\/|\.\.?\/|~\/|[A-Za-z]:)/)) hit("local npm reference")
|
|
@@ -1087,25 +2184,156 @@ bounded_docker() {
|
|
|
1087
2184
|
local_artifact() {
|
|
1088
2185
|
[[ "$1" =~ -(local\.[0-9]{12}|train)\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
|
|
1089
2186
|
}
|
|
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")}
|
|
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"
|
|
2193
|
+
for file in "$session/Directory.Packages.props" "$session/cratis-local.targets" "$session/nuget.config"; do
|
|
2194
|
+
if [ -e "$file" ]; then printf '%s\0' "$file" >> "$TMP/live-files"; fi
|
|
2195
|
+
done
|
|
2196
|
+
while IFS= read -r -d '' file; do
|
|
2197
|
+
[ -f "$file" ] && [ -r "$file" ] || die "cannot read live manifest: $file"
|
|
2198
|
+
status=0
|
|
2199
|
+
found=$(grep -Eio '[0-9]+\.[0-9]+\.[0-9]+-(local\.[0-9]{12}|train)\.g[0-9a-f]{7}' "$file") || status=$?
|
|
2200
|
+
[ "$status" -le 1 ] || die "cannot inspect live manifest: $file"
|
|
2201
|
+
[ -n "$found" ] || continue
|
|
2202
|
+
while IFS= read -r stamp; do
|
|
2203
|
+
stamp=$(printf '%s' "$stamp" | tr '[:upper:]' '[:lower:]')
|
|
2204
|
+
printf '%s\t%s\n' "$stamp" "$file"
|
|
2205
|
+
done <<< "$found"
|
|
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"
|
|
2227
|
+
done < "$TMP/unique-sessions"
|
|
2228
|
+
[ "$blocked" -eq 0 ] || die 'live references retained; restore registered consumers before cleaning (no subjects removed)'
|
|
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
|
+
}
|
|
1090
2267
|
clean() {
|
|
1091
|
-
local version='' apply=0 consumer='' arg path cache image session maximum count=0 rel=''
|
|
1092
|
-
local
|
|
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=()
|
|
1093
2271
|
while [ $# -gt 0 ]; do
|
|
1094
2272
|
arg=$1; shift
|
|
1095
2273
|
case "$arg" in
|
|
1096
2274
|
--stamp) [ $# -gt 0 ] || die '--stamp requires a stamp'; [ -z "$version" ] || die 'choose --stamp or --all'; version=$1; shift; valid_stamp "$version" ;;
|
|
1097
2275
|
--all) [ -z "$version" ] || die 'choose --stamp or --all'; version=all ;;
|
|
2276
|
+
--expired) expired=1 ;;
|
|
2277
|
+
--online) online=1 ;;
|
|
1098
2278
|
--apply) apply=1 ;;
|
|
2279
|
+
--private-cache) private=1 ;;
|
|
1099
2280
|
--dir) [ $# -gt 0 ] || die '--dir requires a relative directory'; rel=${1%/}; shift ;;
|
|
1100
2281
|
-*) die "unknown clean option: $arg" ;;
|
|
1101
2282
|
*) [ -z "$consumer" ] || die 'clean takes at most one consumer'; consumer=$(repo_root "$arg") ;;
|
|
1102
2283
|
esac
|
|
1103
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'
|
|
2288
|
+
local registered
|
|
2289
|
+
if [ "$expired" -eq 0 ] && [ -n "$consumer" ]; then
|
|
2290
|
+
session=$(session_path "$consumer")
|
|
2291
|
+
if [ -e "$session/composition" ] || [ -L "$session/composition" ]; then composition clean-guard "${version:-all}" "$session"; fi
|
|
2292
|
+
fi
|
|
2293
|
+
if [ "$expired" -eq 0 ] && [ -f "$FEED/sessions" ]; then
|
|
2294
|
+
while IFS= read -r registered; do
|
|
2295
|
+
if [ -e "$registered/composition" ] || [ -L "$registered/composition" ]; then
|
|
2296
|
+
composition clean-guard "${version:-all}"
|
|
2297
|
+
break
|
|
2298
|
+
fi
|
|
2299
|
+
done < "$FEED/sessions"
|
|
2300
|
+
fi
|
|
1104
2301
|
maximum=${CRATIS_LOCAL_CLEAN_MAX:-500}
|
|
1105
2302
|
[[ "$maximum" =~ ^[0-9]+$ ]] || die 'CRATIS_LOCAL_CLEAN_MAX must be a whole number'
|
|
1106
2303
|
[ ${#maximum} -le 6 ] || die 'CRATIS_LOCAL_CLEAN_MAX is too large'
|
|
1107
2304
|
maximum=$((10#$maximum))
|
|
1108
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"
|
|
1109
2337
|
for cache in "$FEED/nuget" "$FEED/npm"; do
|
|
1110
2338
|
[ -d "$cache" ] || continue
|
|
1111
2339
|
while IFS= read -r -d '' path; do
|
|
@@ -1114,8 +2342,10 @@ clean() {
|
|
|
1114
2342
|
subjects+=("$path"); kinds+=(file)
|
|
1115
2343
|
done < <(find "$cache" -type f \( -name '*.nupkg' -o -name '*.snupkg' -o -name '*.tgz' \) -print0)
|
|
1116
2344
|
done
|
|
1117
|
-
|
|
1118
|
-
|
|
2345
|
+
while IFS= read -r path; do
|
|
2346
|
+
[ "$path" != "$whole_cache" ] || continue
|
|
2347
|
+
[ -d "$path" ] || continue
|
|
2348
|
+
for cache in "$path"/*; do
|
|
1119
2349
|
[ -d "$cache" ] && [ ! -L "$cache" ] || continue
|
|
1120
2350
|
for path in "$cache"/*-local.* "$cache"/*-train.*; do
|
|
1121
2351
|
[ -d "$path" ] && [ ! -L "$path" ] || continue
|
|
@@ -1124,7 +2354,7 @@ clean() {
|
|
|
1124
2354
|
subjects+=("$path"); kinds+=(directory)
|
|
1125
2355
|
done
|
|
1126
2356
|
done
|
|
1127
|
-
|
|
2357
|
+
done < "$TMP/nuget-caches"
|
|
1128
2358
|
if [ -n "$consumer" ] && [ ! -f "$consumer${rel:+/$rel}/package.json" ]; then
|
|
1129
2359
|
printf 'SKIP: no package.json in %s; pass --dir REL for a Yarn project in a subdirectory\n' "$consumer${rel:+/$rel}" >&2
|
|
1130
2360
|
elif command -v yarn >/dev/null 2>&1 && [ -n "$consumer" ]; then
|
|
@@ -1150,12 +2380,10 @@ clean() {
|
|
|
1150
2380
|
printf 'SKIP: Docker unavailable or did not respond within 10 seconds\n' >&2
|
|
1151
2381
|
fi
|
|
1152
2382
|
else printf 'SKIP: docker is not on PATH\n' >&2; fi
|
|
1153
|
-
if [ -f "$FEED/sessions" ]; then cat "$FEED/sessions" > "$TMP/sessions"; else : > "$TMP/sessions"; fi
|
|
1154
|
-
if [ -n "$consumer" ]; then session_path "$consumer" >> "$TMP/sessions"; fi
|
|
1155
|
-
sort -u "$TMP/sessions" > "$TMP/unique-sessions"
|
|
1156
2383
|
while IFS= read -r session; do
|
|
1157
2384
|
[ -d "$session" ] && [ -f "$session/repo" ] && [ -f "$session/stamp" ] || continue
|
|
1158
2385
|
local_artifact "$(cat "$session/stamp")" || continue
|
|
2386
|
+
if [ "$expired" -eq 1 ] && ! grep -Fxq -- "$(cat "$session/stamp")" "$TMP/expired-stamps"; then continue; fi
|
|
1159
2387
|
if [ -n "$version" ] && [ "$version" != all ] && [ "$(cat "$session/stamp")" != "$version" ]; then continue; fi
|
|
1160
2388
|
[ ! -L "$session" ] && [ "$session" != / ] || die "unsafe session: $session"
|
|
1161
2389
|
validate_session "$session"
|
|
@@ -1164,44 +2392,102 @@ clean() {
|
|
|
1164
2392
|
[ ! -d "$session/npm-backup" ] || die "unresolved npm backup: $session; restore before cleaning"
|
|
1165
2393
|
subjects+=("$session"); kinds+=(session)
|
|
1166
2394
|
done < "$TMP/unique-sessions"
|
|
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
|
|
1167
2408
|
count=${#subjects[@]}
|
|
1168
2409
|
[ "$count" -le "$maximum" ] || die "clean fuse: $count subjects exceeds $maximum"
|
|
1169
2410
|
for ((arg=0; arg<count; arg++)); do printf '%s: %s\n' "${kinds[$arg]}" "${subjects[$arg]}"; done
|
|
1170
2411
|
if [ "$apply" -eq 1 ] && [ "$DRY" -eq 0 ]; then
|
|
1171
2412
|
for ((arg=0; arg<count; arg++)); do
|
|
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
|
|
1172
2418
|
path=${subjects[$arg]}
|
|
1173
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##*/}" ;;
|
|
1174
2433
|
image) docker rmi "$path" ;;
|
|
1175
2434
|
file) [ -f "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -- "$path" ;;
|
|
1176
2435
|
directory) [ -d "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -rf -- "$path" ;;
|
|
1177
2436
|
session)
|
|
1178
2437
|
[ -f "$path/stamp" ] && [ ! -L "$path" ] && [ ! -e "$path/npm-backup" ] || die "session changed: $path"
|
|
1179
2438
|
validate_session "$path"
|
|
1180
|
-
rm -f -- "$path/
|
|
1181
|
-
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 ;;
|
|
1182
2441
|
esac
|
|
1183
2442
|
done
|
|
2443
|
+
if [ "$expired" -eq 1 ]; then registry_update prune; fi
|
|
1184
2444
|
printf 'removed %s subjects\n' "$count"
|
|
1185
2445
|
else printf 'dry-run: %s subjects (use --apply to remove)\n' "$count"; fi
|
|
1186
2446
|
}
|
|
1187
2447
|
|
|
1188
2448
|
# shellcheck disable=SC2016 # fixtures assert literal $STAMP in printed commands
|
|
2449
|
+
self_test_nuget_overlay() {
|
|
2450
|
+
local output=$1 id=$2 version=$3
|
|
2451
|
+
local cpm_on="'\$(ManagePackageVersionsCentrally)' == 'true'" cpm_off="'\$(ManagePackageVersionsCentrally)' != 'true'"
|
|
2452
|
+
grep -Fq "GetPathOfFileAbove('Directory.Packages.props', '\$(MSBuildProjectDirectory)')" "$output/Directory.Packages.props" || failed 'self-test: nearest CPM import'
|
|
2453
|
+
grep -Fq 'Import Project="$(_CratisLocalPackagesProps)" Condition="' "$output/Directory.Packages.props" || failed 'self-test: conditional nearest import'
|
|
2454
|
+
grep -Fq "ItemGroup Condition=\"$cpm_on\"" "$output/Directory.Packages.props" || failed 'self-test: CPM-only versions'
|
|
2455
|
+
grep -Fq "PropertyGroup Condition=\"$cpm_on\"" "$output/Directory.Packages.props" || failed 'self-test: CPM-only transitive pinning'
|
|
2456
|
+
grep -Fq "ItemGroup Condition=\"$cpm_off\"" "$output/cratis-local.targets" || failed 'self-test: opt-out-only references'
|
|
2457
|
+
grep -Fq "PackageReference Update=\"$id\" Version=\"$version\"" "$output/cratis-local.targets" || failed 'self-test: opt-out version override'
|
|
2458
|
+
grep -Fq 'Import Project="$(_CratisLocalPreviousAfterMicrosoftCommonTargets)"' "$output/cratis-local.targets" || failed 'self-test: chained targets import'
|
|
2459
|
+
grep -Fq "!= '\$(MSBuildThisFileFullPath)' and Exists('\$(_CratisLocalPreviousAfterMicrosoftCommonTargets)')" "$output/cratis-local.targets" || failed 'self-test: chained targets import is guarded'
|
|
2460
|
+
env -i PATH=/usr/bin:/bin CustomAfterMicrosoftCommonTargets='/previous targets/file.targets' /bin/bash -c '
|
|
2461
|
+
source "$1"; source "$1"
|
|
2462
|
+
[ "$_CratisLocalPreviousAfterMicrosoftCommonTargets" = "/previous targets/file.targets" ] &&
|
|
2463
|
+
[ "$CustomAfterMicrosoftCommonTargets" = "$2/cratis-local.targets" ]
|
|
2464
|
+
' _ "$output/env" "$output" || failed 'self-test: source twice preserves previous targets'
|
|
2465
|
+
}
|
|
2466
|
+
|
|
1189
2467
|
self_test() {
|
|
1190
2468
|
[ $# -eq 0 ] || die '--self-test takes no arguments'
|
|
1191
|
-
local root home repo feed version output status passed=0 skipped=0 test_key arg map
|
|
1192
|
-
|
|
2469
|
+
local root home repo feed version output status passed=0 skipped=0 test_key arg map system_path
|
|
2470
|
+
# Fixtures use the physical path (macOS TMPDIR is /var -> /private/var) because the helper
|
|
2471
|
+
# reports repositories by their git toplevel; symlinked feeds have their own test below.
|
|
2472
|
+
scratch; root=$(cd -P "$TMP" && pwd -P)/self-test; mkdir -p "$root/home" "$root/repo"
|
|
1193
2473
|
map=$root/map.tsv; home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
|
|
1194
|
-
#
|
|
1195
|
-
# Both isolate HOME/config and omit the host adapter.
|
|
1196
|
-
|
|
1197
|
-
|
|
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" "$@"; }
|
|
1198
2481
|
has() { grep -Fq -- "$1" "$2"; }
|
|
1199
2482
|
st_npm() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
|
|
1200
2483
|
git -C "$repo" init -q
|
|
1201
2484
|
repo=$(repo_root "$repo")
|
|
1202
2485
|
git -C "$repo" config user.email test@example.invalid
|
|
1203
2486
|
git -C "$repo" config user.name 'Local package self-test'
|
|
1204
|
-
printf '<Project><ItemGroup><PackageVersion Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/Directory.Packages.props"
|
|
2487
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup><ItemGroup><PackageVersion Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/Directory.Packages.props"
|
|
2488
|
+
mkdir -p "$repo/nested"
|
|
2489
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>\n' > "$repo/nested/Directory.Packages.props"
|
|
2490
|
+
printf '<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/nested/OptOut.csproj"
|
|
1205
2491
|
printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"}}\n' > "$repo/package.json"
|
|
1206
2492
|
cat > "$repo/yarn.lock" <<'LOCK'
|
|
1207
2493
|
# This file is generated by running "yarn install" inside your project.
|
|
@@ -1234,6 +2520,7 @@ LOCK
|
|
|
1234
2520
|
version=$(st stamp "$repo")
|
|
1235
2521
|
[[ "$version" =~ ^1\.2\.4-local\.[0-9]{12}\.g[0-9a-f]{7}$ ]] || failed 'self-test: stamp format'
|
|
1236
2522
|
passed=$((passed + 1))
|
|
2523
|
+
self_test_base_version
|
|
1237
2524
|
mkdir -p "$feed/nuget" "$feed/npm"
|
|
1238
2525
|
: > "$feed/nuget/Cratis.Test.1.2.4-local.200001010000.g1234567.nupkg"
|
|
1239
2526
|
st --dry-run use-nuget "$repo" 1.2.4-local.200001010000.g1234567 Cratis.Test > "$root/output"
|
|
@@ -1250,12 +2537,13 @@ LOCK
|
|
|
1250
2537
|
done
|
|
1251
2538
|
st use-nuget "$repo" "$version" > "$root/output"
|
|
1252
2539
|
output=$(git -C "$repo" rev-parse --absolute-git-dir)/cratis-local
|
|
1253
|
-
grep -Fq "
|
|
1254
|
-
grep -Fq "PackageVersion Update=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Update'
|
|
2540
|
+
grep -Fq "PackageVersion Include=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Include'
|
|
1255
2541
|
grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: transitive package Include'
|
|
1256
2542
|
if grep -Fq 'CentralPackageTransitivePinningEnabled' "$output/Directory.Packages.props"; then failed 'self-test: transitive pinning must be opt-in'; fi
|
|
1257
2543
|
st use-nuget "$repo" "$version" --pin-transitive > "$root/output"
|
|
1258
2544
|
grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive enables pinning'
|
|
2545
|
+
self_test_nuget_overlay "$output" Cratis.Test "$version"
|
|
2546
|
+
passed=$((passed + 8))
|
|
1259
2547
|
grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: --pin-transitive keeps the IDs'
|
|
1260
2548
|
st use-nuget "$repo" "$version" Cratis.Test --pin-transitive > "$root/output" || failed 'self-test: --pin-transitive with explicit IDs'
|
|
1261
2549
|
st use-nuget "$repo" "$version" > "$root/output"
|
|
@@ -1265,12 +2553,22 @@ LOCK
|
|
|
1265
2553
|
passed=$((passed + 5))
|
|
1266
2554
|
st check "$repo" > "$root/output" || failed 'self-test: clean repo'
|
|
1267
2555
|
passed=$((passed + 1))
|
|
1268
|
-
|
|
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
|
|
2564
|
+
for test_key in stamp npm props sources targets previous override feed; do
|
|
1269
2565
|
case "$test_key" in
|
|
1270
2566
|
stamp) printf '%s\n' "$version" > "$repo/probe" ;;
|
|
1271
2567
|
npm) printf '{"resolutions":{"@cratis/test":"%s%s"}}\n' 'file:/tmp/' 'package.tgz' > "$repo/package.json" ;;
|
|
1272
2568
|
props) printf '%s=/tmp/overlay\n' DirectoryPackages"PropsPath" > "$repo/probe" ;;
|
|
1273
2569
|
sources) printf '%s=/tmp/feed\n' RestoreAdditional"ProjectSources" > "$repo/probe" ;;
|
|
2570
|
+
targets) printf '%s=/tmp/overlay.targets\n' CustomAfterMicrosoftCommon"Targets" > "$repo/probe" ;;
|
|
2571
|
+
previous) printf '%s=/tmp/previous.targets\n' _CratisLocalPreviousAfterMicrosoftCommon"Targets" > "$repo/probe" ;;
|
|
1274
2572
|
override) printf '<PackageVersion %s="Cratis.Test" />\n' Update > "$repo/probe" ;;
|
|
1275
2573
|
feed) printf '%s\n' "$feed" > "$repo/probe" ;;
|
|
1276
2574
|
esac
|
|
@@ -1279,6 +2577,20 @@ LOCK
|
|
|
1279
2577
|
passed=$((passed + 1))
|
|
1280
2578
|
git -C "$repo" checkout -- probe package.json
|
|
1281
2579
|
done
|
|
2580
|
+
# A feed configured through a symlink is found under both its configured and its physical spelling.
|
|
2581
|
+
mkdir -p "$feed" && ln -s "$(dirname "$feed")" "$root/feed-alias"
|
|
2582
|
+
for test_key in "$root/feed-alias/$(basename "$feed")" "$(cd -P "$feed" && pwd -P)"; do
|
|
2583
|
+
printf '%s/nuget\n' "$test_key" > "$repo/probe"
|
|
2584
|
+
status=0; env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" CRATIS_LOCAL_FEED="$root/feed-alias/$(basename "$feed")" /bin/bash "$SELF" check "$repo" > "$root/output" || status=$?
|
|
2585
|
+
[ "$status" -eq 1 ] || failed "self-test: did not find aliased feed leak: $test_key"
|
|
2586
|
+
passed=$((passed + 1))
|
|
2587
|
+
git -C "$repo" checkout -- probe
|
|
2588
|
+
done
|
|
2589
|
+
rm "$root/feed-alias"
|
|
2590
|
+
# A consumer's own published-version reference update is legitimate, not an overlay leak.
|
|
2591
|
+
printf '<PackageReference %s="Cratis.Test" Version="1.2.3" />\n' Update > "$repo/probe"
|
|
2592
|
+
st check "$repo" > "$root/output" || failed 'self-test: published reference update reported as leak'
|
|
2593
|
+
git -C "$repo" checkout -- probe; passed=$((passed + 1))
|
|
1282
2594
|
printf '%s\n' "$version" > "$repo/untracked"
|
|
1283
2595
|
status=0; st check "$repo" > "$root/output" || status=$?
|
|
1284
2596
|
[ "$status" -eq 1 ] || failed 'self-test: untracked leak'
|
|
@@ -1289,6 +2601,9 @@ LOCK
|
|
|
1289
2601
|
[ "$status" -eq 1 ] || failed 'self-test: staged content'
|
|
1290
2602
|
git -C "$repo" restore --staged probe; git -C "$repo" checkout -- probe
|
|
1291
2603
|
passed=$((passed + 1))
|
|
2604
|
+
st restore "$repo" > "$root/output" || failed 'self-test: restore legacy overlay'
|
|
2605
|
+
[ ! -e "$output/cratis-local.targets" ] && [ ! -e "$output/env" ] || failed 'self-test: legacy targets and env survive restore'
|
|
2606
|
+
passed=$((passed + 1))
|
|
1292
2607
|
if command -v node >/dev/null 2>&1; then
|
|
1293
2608
|
# Preserve pre-existing user edits as well as the committed contents.
|
|
1294
2609
|
st_npm use-npm "$repo" "$version"
|
|
@@ -1319,8 +2634,9 @@ LOCK
|
|
|
1319
2634
|
cp "$repo/package.json" "$root/original-package.json"
|
|
1320
2635
|
cp "$repo/yarn.lock" "$root/original-yarn.lock"
|
|
1321
2636
|
st_npm use-npm "$repo" "$version"
|
|
1322
|
-
# Model Yarn's replacement of a remote entry with a file-tarball entry.
|
|
1323
|
-
|
|
2637
|
+
# Model Yarn's replacement of a remote entry with a file-tarball entry. Yarn writes the
|
|
2638
|
+
# path the overlay put in package.json, which uses the physical feed path.
|
|
2639
|
+
node - "$repo/yarn.lock" "$(cd -P "$feed/npm" && pwd -P)/@cratis-test-$version.tgz" "$version" <<'NODE'
|
|
1324
2640
|
const fs = require('fs');
|
|
1325
2641
|
const [file, input, stamp] = process.argv.slice(2);
|
|
1326
2642
|
const tarball = require('path').normalize(input);
|
|
@@ -1450,6 +2766,9 @@ NODE
|
|
|
1450
2766
|
[ ! -e "$(git -C "$sub" rev-parse --absolute-git-dir)/cratis-local/npm-dir" ] || failed 'self-test: restore removes npm-dir'
|
|
1451
2767
|
passed=$((passed + 6))
|
|
1452
2768
|
else printf 'SKIP: use-npm --dir needs node\n'; skipped=$((skipped + 6)); fi
|
|
2769
|
+
self_test_live_clean
|
|
2770
|
+
self_test_private_cache
|
|
2771
|
+
self_test_registry
|
|
1453
2772
|
mkdir -p "$home/.nuget/packages/cratis.test/$version" "$home/.nuget/packages/cratis.test/1.2.3"
|
|
1454
2773
|
mkdir -p "$home/.nuget/packages/cratis.test/1.2.3-local.keep"
|
|
1455
2774
|
for arg in nuget npm; do
|
|
@@ -1472,10 +2791,437 @@ NODE
|
|
|
1472
2791
|
[ -d "$home/.nuget/packages/cratis.test/1.2.3" ] || failed 'self-test: clean removed real version'
|
|
1473
2792
|
passed=$((passed + 5))
|
|
1474
2793
|
printf 'SKIP: real dotnet/Yarn/Docker packing is not a hermetic self-test\n'; skipped=$((skipped + 3))
|
|
2794
|
+
self_test_expired
|
|
1475
2795
|
self_test_matrix
|
|
1476
2796
|
printf 'self-test: %s passed, %s skipped\n' "$passed" "$skipped"
|
|
1477
2797
|
}
|
|
1478
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
|
+
|
|
3139
|
+
self_test_base_version() {
|
|
3140
|
+
local base mode actual status
|
|
3141
|
+
for mode in local train; do
|
|
3142
|
+
local options=()
|
|
3143
|
+
if [ "$mode" = train ]; then options=(--train); fi
|
|
3144
|
+
for base in 0.0.0 5.0.0 10.20.300; do
|
|
3145
|
+
actual=$(st stamp ${options[@]+"${options[@]}"} --base-version "$base" "$repo") || failed 'self-test: base override'
|
|
3146
|
+
case "$actual" in "$base-$mode."*) ;; *) failed "self-test: base override grammar $actual" ;; esac
|
|
3147
|
+
valid_stamp "$actual"
|
|
3148
|
+
passed=$((passed + 1))
|
|
3149
|
+
done
|
|
3150
|
+
for base in '' 01.0.0 1.01.0 1.0.01 v5.0.0 5.0 5.0.0.1 5.0.0-preview 5.0.0+build -1.0.0 '5.0.0 '; do
|
|
3151
|
+
status=0; st stamp ${options[@]+"${options[@]}"} --base-version "$base" "$repo" > "$root/output" 2>&1 || status=$?
|
|
3152
|
+
[ "$status" -eq 2 ] || failed "self-test: invalid base accepted: $base"
|
|
3153
|
+
passed=$((passed + 1))
|
|
3154
|
+
done
|
|
3155
|
+
done
|
|
3156
|
+
for mode in missing repeated producer; do
|
|
3157
|
+
status=0
|
|
3158
|
+
case "$mode" in
|
|
3159
|
+
missing) st stamp "$repo" --base-version > "$root/output" 2>&1 || status=$? ;;
|
|
3160
|
+
repeated) st stamp --base-version 5.0.0 --base-version 6.0.0 "$repo" > "$root/output" 2>&1 || status=$? ;;
|
|
3161
|
+
producer) st stamp --base-version 5.0.0 > "$root/output" 2>&1 || status=$? ;;
|
|
3162
|
+
esac
|
|
3163
|
+
[ "$status" -eq 2 ] || failed "self-test: base option $mode"
|
|
3164
|
+
passed=$((passed + 1))
|
|
3165
|
+
done
|
|
3166
|
+
actual=$(st stamp "$repo" --base-version 0.0.0 "$repo" 2> "$root/stderr")
|
|
3167
|
+
case "$actual" in 0.0.0-local.*) ;; *) failed 'self-test: multi-producer explicit base overrides all tags' ;; esac
|
|
3168
|
+
passed=$((passed + 1))
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
self_test_live_clean() {
|
|
3172
|
+
local lr=$root/live-repo lf=$root/live-feed ls file mode status
|
|
3173
|
+
local lv=5.0.0-local.202601010000.gabcdef0 uv=5.0.0-train.gabcdef1
|
|
3174
|
+
mkdir -p "$lr/nested" "$lf/nuget"
|
|
3175
|
+
git -C "$lr" init -q
|
|
3176
|
+
printf '<Project/>\n' > "$lr/Directory.Packages.props"
|
|
3177
|
+
git -C "$lr" add Directory.Packages.props
|
|
3178
|
+
git -C "$lr" -c user.email=test@example.invalid -c user.name='Live clean fixture' commit -qm initial
|
|
3179
|
+
ls=$(git -C "$lr" rev-parse --absolute-git-dir)/cratis-local
|
|
3180
|
+
lct() { env -i HOME="$home" PATH="$system_path" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$lf" NUGET_PACKAGES="$root/live-cache" /bin/bash "$SELF" "$@"; }
|
|
3181
|
+
: > "$lf/nuget/Cratis.Live.$lv.nupkg"
|
|
3182
|
+
: > "$lf/nuget/Cratis.Unused.$uv.nupkg"
|
|
3183
|
+
mkdir -p "$root/live-cache/cratis.live/$lv" "$root/live-cache/cratis.live/5.0.0"
|
|
3184
|
+
lct use-nuget "$lr" "$lv" > "$root/output"
|
|
3185
|
+
for mode in dry apply; do
|
|
3186
|
+
local options=()
|
|
3187
|
+
if [ "$mode" = apply ]; then options=(--apply); fi
|
|
3188
|
+
status=0; lct clean --stamp "$lv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
|
|
3189
|
+
{ [ "$status" -eq 2 ] && has "kept stamp $lv" "$root/output" && has "$ls/Directory.Packages.props" "$root/output"; } || failed "self-test: $mode clean must report live overlay"
|
|
3190
|
+
[ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] && [ -f "$ls/env" ] && [ -d "$root/live-cache/cratis.live/$lv" ] || failed 'self-test: live clean mutated subjects'
|
|
3191
|
+
passed=$((passed + 2))
|
|
3192
|
+
done
|
|
3193
|
+
lct restore "$lr" > "$root/output"
|
|
3194
|
+
for file in nuget.config Directory.Packages.props nested/packages.lock.json nested/package.json nested/yarn.lock; do
|
|
3195
|
+
printf '%s\n' "$lv" > "$lr/$file"
|
|
3196
|
+
for mode in dry apply; do
|
|
3197
|
+
local options=()
|
|
3198
|
+
if [ "$mode" = apply ]; then options=(--apply); fi
|
|
3199
|
+
status=0; lct clean --stamp "$lv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
|
|
3200
|
+
{ [ "$status" -eq 2 ] && has "kept stamp $lv" "$root/output" && has "$lr/$file" "$root/output"; } || failed "self-test: $mode clean missed live $file"
|
|
3201
|
+
[ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] || failed 'self-test: live manifest artifact removed'
|
|
3202
|
+
passed=$((passed + 2))
|
|
3203
|
+
done
|
|
3204
|
+
if [ "$file" = Directory.Packages.props ]; then printf '<Project/>\n' > "$lr/$file"; else rm "$lr/$file"; fi
|
|
3205
|
+
done
|
|
3206
|
+
printf '%s\n' "$uv" | tr '[:lower:]' '[:upper:]' > "$lr/nested/packages.lock.json"
|
|
3207
|
+
for mode in dry apply; do
|
|
3208
|
+
local options=()
|
|
3209
|
+
if [ "$mode" = apply ]; then options=(--apply); fi
|
|
3210
|
+
status=0; lct clean --stamp "$uv" ${options[@]+"${options[@]}"} > "$root/output" 2> "$root/stderr" || status=$?
|
|
3211
|
+
{ [ "$status" -eq 2 ] && has "kept stamp $uv" "$root/output" && [ -f "$lf/nuget/Cratis.Unused.$uv.nupkg" ]; } || failed 'self-test: case-insensitive train reference protection'
|
|
3212
|
+
passed=$((passed + 1))
|
|
3213
|
+
done
|
|
3214
|
+
printf '%s\n' "$lv" > "$lr/nested/packages.lock.json"
|
|
3215
|
+
lct clean --stamp "$uv" --apply > "$root/output" || failed 'self-test: unrelated live stamp blocks unreferenced cleanup'
|
|
3216
|
+
[ ! -e "$lf/nuget/Cratis.Unused.$uv.nupkg" ] && [ -f "$lf/nuget/Cratis.Live.$lv.nupkg" ] || failed 'self-test: unreferenced stamp selection'
|
|
3217
|
+
rm "$lr/nested/packages.lock.json"
|
|
3218
|
+
lct clean --stamp "$lv" > "$root/output"
|
|
3219
|
+
has "Cratis.Live.$lv.nupkg" "$root/output" && [ -d "$root/live-cache/cratis.live/$lv" ] || failed 'self-test: unreferenced dry-run'
|
|
3220
|
+
lct clean --stamp "$lv" --apply > "$root/output"
|
|
3221
|
+
[ ! -e "$lf/nuget/Cratis.Live.$lv.nupkg" ] && [ ! -d "$root/live-cache/cratis.live/$lv" ] && [ -d "$root/live-cache/cratis.live/5.0.0" ] || failed 'self-test: unreferenced per-worktree cleanup scope'
|
|
3222
|
+
passed=$((passed + 4))
|
|
3223
|
+
}
|
|
3224
|
+
|
|
1479
3225
|
# C fixtures: the printed closure is one plan, in order, and every line is valid shell.
|
|
1480
3226
|
self_test_closure() {
|
|
1481
3227
|
local name line a b c p last first fa fl fm
|
|
@@ -1692,8 +3438,12 @@ self_test_verify() {
|
|
|
1692
3438
|
expect_status() {
|
|
1693
3439
|
local want=$1 desc=$2 got=0
|
|
1694
3440
|
shift 2
|
|
3441
|
+
printf 'self-test [%ss]: %s\n' "$SECONDS" "$desc" >&2
|
|
1695
3442
|
"$@" > "$root/output" 2> "$root/stderr" || got=$?
|
|
1696
|
-
[ "$got" -
|
|
3443
|
+
if [ "$got" -ne "$want" ]; then
|
|
3444
|
+
cat "$root/stderr" >&2
|
|
3445
|
+
failed "self-test: $desc (exit $got, wanted $want)"
|
|
3446
|
+
fi
|
|
1697
3447
|
passed=$((passed + 1))
|
|
1698
3448
|
}
|
|
1699
3449
|
tsv() { local IFS=$'\t'; printf '%s\n' "$*"; }
|
|
@@ -1728,7 +3478,7 @@ self_test_matrix() {
|
|
|
1728
3478
|
} > "$root/p1.expected"
|
|
1729
3479
|
fxm plan App Lib Mid > "$root/output" 2> "$root/stderr" || failed 'self-test: P1 plan'
|
|
1730
3480
|
cmp -s "$root/output" "$root/p1.expected" || failed 'self-test: P1 plan rows differ from the expected plan'
|
|
1731
|
-
has 'Lib -> Orl -> App' "$root/stderr" || failed 'self-test: P1 unnamed-hop warning'
|
|
3481
|
+
has 'Lib -> Orl -> App' "$root/stderr" || { cat "$root/stderr" >&2; failed 'self-test: P1 unnamed-hop warning'; }
|
|
1732
3482
|
if has 'WARNING' "$root/output"; then failed 'self-test: P1 warning must stay on stderr'; fi
|
|
1733
3483
|
# P2/P3: the real map. An NuGet-only edge plans only the NuGet ecosystem of a dual-ecosystem producer;
|
|
1734
3484
|
# npm-only ids on a NuGet edge cannot be planned. P3 must move to the fixture map if Screenplay ships NuGet ids.
|
|
@@ -1763,9 +3513,312 @@ self_test_matrix() {
|
|
|
1763
3513
|
passed=$((passed + 3))
|
|
1764
3514
|
self_test_closure
|
|
1765
3515
|
self_test_verify
|
|
3516
|
+
self_test_symlink_feed
|
|
3517
|
+
self_test_composition
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
# Registry paths allow system/configured symlink parents of the feed.
|
|
3521
|
+
self_test_symlink_feed() {
|
|
3522
|
+
local physical=$root/feed-physical-parent alias=$root/feed-symlink-parent
|
|
3523
|
+
local sr=$root/feed-symlink-repo sv=1.2.3-local.202610240544.gabcdef0
|
|
3524
|
+
mkdir -p "$physical" "$sr"
|
|
3525
|
+
ln -s "$physical" "$alias"
|
|
3526
|
+
git -C "$sr" init -q
|
|
3527
|
+
printf '.ai-work/\n' > "$sr/.gitignore"
|
|
3528
|
+
printf '<Project><ItemGroup><PackageVersion Include="Cratis.Symlink" Version="1.2.3"/></ItemGroup></Project>\n' > "$sr/Directory.Packages.props"
|
|
3529
|
+
git -C "$sr" add .gitignore Directory.Packages.props
|
|
3530
|
+
git -C "$sr" -c user.email=test@example.invalid -c user.name='Symlink feed fixture' commit -qm initial
|
|
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" "$@"; }
|
|
3532
|
+
expect_status 0 'symlink feed resolution does not create a missing feed' slf check "$sr"
|
|
3533
|
+
[ ! -e "$physical/missing" ] || failed 'symlink feed resolution created directories'
|
|
3534
|
+
mkdir -p "$physical/missing/feed/nuget"
|
|
3535
|
+
: > "$physical/missing/feed/nuget/Cratis.Symlink.$sv.nupkg"
|
|
3536
|
+
expect_status 0 'legacy use-nuget under symlinked feed parent' slf use-nuget "$sr" "$sv" Cratis.Symlink
|
|
3537
|
+
has "$physical/missing/feed/nuget" "$sr/.ai-work/local-packages/env" || failed 'legacy feed path was not resolved physically'
|
|
3538
|
+
expect_status 0 'legacy restore under symlinked feed parent' slf restore "$sr"
|
|
3539
|
+
has '0 leaks' "$root/output" || failed 'symlink feed restore leaked a local pin'
|
|
3540
|
+
expect_status 0 'legacy clean under symlinked feed parent' slf clean --stamp "$sv" --apply
|
|
3541
|
+
[ ! -e "$physical/missing/feed/nuget/Cratis.Symlink.$sv.nupkg" ] || failed 'symlink feed clean left the artifact'
|
|
3542
|
+
passed=$((passed + 4))
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
# Composition fixtures are isolated, SDK-native and never restore/build a producer.
|
|
3546
|
+
self_test_composition() {
|
|
3547
|
+
local cf=$root/composition-parent/feed cr=$root/composition-repo cm=$root/composition.tsv
|
|
3548
|
+
local a=22.138.1-local.202610240544.gd1ad215 b=0.2.2-local.202610240544.g91916a3 c=0.1.18-local.202610240544.g524bdce
|
|
3549
|
+
local boundary kind base=$root/composition-prior cpkg=$root/composition-package
|
|
3550
|
+
if ! command -v node >/dev/null 2>&1 || ! command -v dotnet >/dev/null 2>&1 || ! command -v zip >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then
|
|
3551
|
+
printf 'SKIP: composition fixtures require node, dotnet SDK, zip and unzip\n'; skipped=$((skipped + 1)); return 0
|
|
3552
|
+
fi
|
|
3553
|
+
mkdir -p "$root/composition-physical-parent"
|
|
3554
|
+
ln -s "$root/composition-physical-parent" "$root/composition-parent"
|
|
3555
|
+
mkdir -p "$cf/nuget" "$cr" "$cpkg"
|
|
3556
|
+
: > "$root/.composition-fixture"
|
|
3557
|
+
git -C "$cr" init -q
|
|
3558
|
+
printf '.ai-work/\nobj/\n' > "$cr/.gitignore"
|
|
3559
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup><ItemGroup><PackageVersion Include="Cratis.Keep" Version="1.2.3"/></ItemGroup></Project>\n' > "$cr/Directory.Packages.props"
|
|
3560
|
+
mkdir -p "$cr/nested"
|
|
3561
|
+
printf '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>\n' > "$cr/nested/Directory.Packages.props"
|
|
3562
|
+
printf '<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Cratis.Stage" Version="0.1.0" /></ItemGroup></Project>\n' > "$cr/nested/OptOut.csproj"
|
|
3563
|
+
printf '<configuration><packageSources><clear/><add key="remote" value="https://example.invalid/v3/index.json"/></packageSources><packageSourceMapping><packageSource key="remote"><package pattern="*"/><package pattern="Cratis.*"/><package pattern="Cratis.Screenplay*"/><package pattern="Cratis.Screenplay"/></packageSource></packageSourceMapping></configuration>\n' > "$cr/nuget.config"
|
|
3564
|
+
git -C "$cr" add .gitignore Directory.Packages.props nuget.config nested
|
|
3565
|
+
git -C "$cr" -c user.email=test@example.invalid -c user.name='Composition fixture' commit -qm initial
|
|
3566
|
+
cp "$cr/Directory.Packages.props" "$root/composition-original.props"
|
|
3567
|
+
printf 'producer\tChronicle\t%s\t%s\nproducer\tScreenplay\t%s\t%s\nproducer\tStage\t%s\t%s\npackage\tChronicle\tCratis.Chronicle.InProcess\npackage\tScreenplay\tCratis.Screenplay\npackage\tStage\tCratis.Stage\n' "$a" "$cr" "$b" "$cr" "$c" "$cr" > "$cm"
|
|
3568
|
+
cst() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" DOTNET_CLI_HOME="$home" DOTNET_NOLOGO=1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 PI_PHASE_ORIGINAL_PATH="${PI_PHASE_ORIGINAL_PATH:-}" PI_PHASE_SHIM_DIR="${PI_PHASE_SHIM_DIR:-}" PI_PHASE_MSBUILD_MAX_CPU="${PI_PHASE_MSBUILD_MAX_CPU:-1}" CRATIS_LOCAL_FEED="$cf" CRATIS_LOCAL_SESSION_DIR="$cr/.ai-work/local-packages" CRATIS_LOCAL_COMPOSITION_TEST_ROOT="$root" CRATIS_LOCAL_COMPOSITION_FAIL="${composition_fail:-}" CRATIS_LOCAL_COMPOSITION_INTERRUPT="${composition_interrupt:-}" /bin/bash "$SELF" "$@"; }
|
|
3569
|
+
cmpkg() {
|
|
3570
|
+
local id=$1 ver=$2 dependencies=${3:-} xmlfile=$cpkg/$1.nuspec
|
|
3571
|
+
printf '<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"><metadata><id>%s</id><version>%s</version><dependencies>%s</dependencies></metadata></package>' "$id" "$ver" "$dependencies" > "$xmlfile"
|
|
3572
|
+
rm -f -- "$cf/nuget/$id.$ver.nupkg"
|
|
3573
|
+
(cd "$cpkg" && zip -q "$cf/nuget/$id.$ver.nupkg" "$id.nuspec")
|
|
3574
|
+
}
|
|
3575
|
+
cmpkg Cratis.Chronicle.InProcess "$a"
|
|
3576
|
+
cmpkg Cratis.Screenplay "$b" '<group targetFramework="net10.0"><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
|
|
3577
|
+
cmpkg Cratis.Stage "$c" "<dependency id=\"Cratis.Screenplay\" version=\"[$b]\"/>"
|
|
3578
|
+
expect_status 0 'composition native mixed-version pack' cst verify-pack --composition "$cm" --json
|
|
3579
|
+
node - "$root/output" "$a" "$b" "$c" <<'JSON'
|
|
3580
|
+
const fs = require('fs'); const [file,...versions] = process.argv.slice(2), data = JSON.parse(fs.readFileSync(file));
|
|
3581
|
+
if (data.packages.length !== 3 || !versions.every(v => data.packages.some(p => p.version === v && p.producer))) process.exit(1);
|
|
3582
|
+
JSON
|
|
3583
|
+
passed=$((passed + 1))
|
|
3584
|
+
mkdir -p "$root/no-sdk"
|
|
3585
|
+
printf '#!/bin/sh\nexit 127\n' > "$root/no-sdk/dotnet"; chmod +x "$root/no-sdk/dotnet"
|
|
3586
|
+
expect_status 2 'composition unavailable native SDK fails closed' env -i HOME="$home" PATH="$root/no-sdk:$PATH" TMPDIR="$TMP" CRATIS_LOCAL_FEED="$cf" /bin/bash "$SELF" verify-pack --composition "$cm"
|
|
3587
|
+
mkdir -p "$root/composition-foreign-nuget" "$root/composition-foreign-lock"
|
|
3588
|
+
printf 'keep foreign lock state\n' > "$root/composition-foreign-lock/sentinel"
|
|
3589
|
+
mv "$cf/nuget" "$root/composition-original-nuget"
|
|
3590
|
+
ln -s "$root/composition-foreign-nuget" "$cf/nuget"
|
|
3591
|
+
expect_status 2 'composition rejects helper-owned nuget symlink' cst verify-pack --composition "$cm"
|
|
3592
|
+
[ -d "$root/composition-foreign-nuget" ] || failed 'composition removed foreign nuget directory'
|
|
3593
|
+
rm "$cf/nuget"; mv "$root/composition-original-nuget" "$cf/nuget"
|
|
3594
|
+
ln -s "$root/composition-foreign-lock" "$cf/.composition-lock"
|
|
3595
|
+
expect_status 2 'composition rejects helper-owned lock symlink' cst use-nuget "$cr" --composition "$cm"
|
|
3596
|
+
has 'keep foreign lock state' "$root/composition-foreign-lock/sentinel" || failed 'composition removed foreign lock state'
|
|
3597
|
+
rm "$cf/.composition-lock"
|
|
3598
|
+
passed=$((passed + 2))
|
|
3599
|
+
cp "$cm" "$root/composition-good.tsv"
|
|
3600
|
+
printf 'package\tStage\tcratis.screenplay\n' >> "$cm"
|
|
3601
|
+
expect_status 2 'composition duplicate case-insensitive owner' cst verify-pack --composition "$cm"
|
|
3602
|
+
cp "$root/composition-good.tsv" "$cm"
|
|
3603
|
+
expect_status 2 'composition flags cannot mix legacy stamp' cst verify-pack "$a" --composition "$cm"
|
|
3604
|
+
expect_status 2 'composition activation cannot mix legacy IDs' cst use-nuget "$cr" --composition "$cm" Cratis.Stage
|
|
3605
|
+
printf 'producer\tBad\t1.2.3\t%s\npackage\tBad\tBad\n' "$cr" > "$root/composition-bad.tsv"
|
|
3606
|
+
expect_status 2 'composition rejects nonlocal producer stamp' cst verify-pack --composition "$root/composition-bad.tsv"
|
|
3607
|
+
cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.0.0,22.1.0)"/></group>'
|
|
3608
|
+
expect_status 1 'composition incompatible native group range' cst verify-pack --composition "$cm"
|
|
3609
|
+
cmpkg Cratis.Screenplay "$b" '<dependency id="Cratis.Chronicle.InProcess" version="[broken"/>'
|
|
3610
|
+
expect_status 1 'composition malformed native range' cst verify-pack --composition "$cm"
|
|
3611
|
+
cmpkg Cratis.Screenplay "$b" "<dependency id=\"Cratis.Undeclared\" version=\"[$a]\"/>"
|
|
3612
|
+
expect_status 1 'composition undeclared local nuspec edge' cst verify-pack --composition "$cm"
|
|
3613
|
+
cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
|
|
3614
|
+
cp "$cf/nuget/Cratis.Stage.$c.nupkg" "$root/stage.nupkg"
|
|
3615
|
+
cmpkg Cratis.Stage "$c"; printf '<package><metadata>' > "$cpkg/Cratis.Stage.nuspec"
|
|
3616
|
+
(cd "$cpkg" && zip -q "$cf/nuget/Cratis.Stage.$c.nupkg" Cratis.Stage.nuspec)
|
|
3617
|
+
expect_status 1 'composition malformed XML' cst verify-pack --composition "$cm"
|
|
3618
|
+
cp "$root/stage.nupkg" "$cf/nuget/Cratis.Stage.$c.nupkg"
|
|
3619
|
+
mv "$cf/nuget/Cratis.Stage.$c.nupkg" "$root/hidden-stage.nupkg"
|
|
3620
|
+
expect_status 1 'composition missing package' cst verify-pack --composition "$cm"
|
|
3621
|
+
mv "$root/hidden-stage.nupkg" "$cf/nuget/Cratis.Stage.$c.nupkg"
|
|
3622
|
+
mkdir -p "$cr/obj"
|
|
3623
|
+
cat > "$cr/obj/project.assets.json" <<JSON
|
|
3624
|
+
{"libraries":{"Cratis.Chronicle.InProcess/$a":{"type":"package"},"Cratis.Screenplay/$b":{"type":"package"},"Cratis.Stage/$c":{"type":"package"}},"targets":{"net10.0":{"Cratis.Chronicle.InProcess/$a":{"type":"package"},"Cratis.Screenplay/$b":{"type":"package","dependencies":{"Cratis.Chronicle.InProcess":"[22.136.4,23.0.0)"}},"Cratis.Stage/$c":{"type":"package","dependencies":{"Cratis.Screenplay":"[$b]"}}}}}
|
|
3625
|
+
JSON
|
|
3626
|
+
cp "$cr/obj/project.assets.json" "$root/composition-assets.json"
|
|
3627
|
+
expect_status 0 'composition exact assets graph' cst verify-assets "$cr" --composition "$cm" --json
|
|
3628
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
3629
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
3630
|
+
const target=data.targets['net10.0']; delete target[Object.keys(target).find(k=>k.startsWith('Cratis.Chronicle'))];
|
|
3631
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
3632
|
+
JSON
|
|
3633
|
+
expect_status 1 'composition missing consumed local dependency' cst verify-assets "$cr" --composition "$cm"
|
|
3634
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
3635
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
3636
|
+
for (const graph of [data.libraries,data.targets['net10.0']]) delete graph[Object.keys(graph).find(k=>k.startsWith('Cratis.Chronicle'))];
|
|
3637
|
+
const target=data.targets['net10.0']; target[Object.keys(target).find(k=>k.startsWith('Cratis.Screenplay'))].dependencies={};
|
|
3638
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
3639
|
+
JSON
|
|
3640
|
+
expect_status 1 'composition nuspec local dependency absent from graph' cst verify-assets "$cr" --composition "$cm"
|
|
3641
|
+
cmpkg Cratis.Screenplay "$b" '<group targetFramework="net8.0"><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group><group targetFramework="net10.0"/>'
|
|
3642
|
+
expect_status 0 'composition native nearest group allows unused owned ID' cst verify-assets "$cr" --composition "$cm"
|
|
3643
|
+
cmpkg Cratis.Screenplay "$b" '<group><dependency id="Cratis.Chronicle.InProcess" version="[22.136.4,23.0.0)"/></group>'
|
|
3644
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" "$a" <<'JSON'
|
|
3645
|
+
const fs=require('fs'), [input,output,a]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
3646
|
+
data.targets.net9=data.targets['net10.0']; data.targets.net9['Cratis.Chronicle.InProcess/'+a].dependencies={'Cratis.Stage':'[1.0.0,2.0.0)'};
|
|
3647
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
3648
|
+
JSON
|
|
3649
|
+
expect_status 1 'composition bad edge in another target' cst verify-assets "$cr" --composition "$cm"
|
|
3650
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
3651
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input)), target=data.targets['net10.0'];
|
|
3652
|
+
Object.assign(data.libraries,{'Other.A/1.0.0':{type:'package'},'Other.B/2.0.0':{type:'package'}});
|
|
3653
|
+
Object.assign(target,{'Other.A/1.0.0':{type:'package',dependencies:{'Other.B':'[1.0.0]'}},'Other.B/2.0.0':{type:'package'}});
|
|
3654
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
3655
|
+
JSON
|
|
3656
|
+
expect_status 0 'composition range conflict outside the composition is a warning' cst verify-assets "$cr" --composition "$cm" --json
|
|
3657
|
+
has 'WARNING: dependency range outside the composition' "$root/stderr" || failed 'composition outside-range warning on stderr'
|
|
3658
|
+
node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1]));if(d.warnings.length!==1||!/Other\.A -> Other\.B/.test(d.warnings[0]))process.exit(1)' "$root/output" || failed 'composition outside-range warning in JSON'
|
|
3659
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" "$c" <<'JSON'
|
|
3660
|
+
const fs=require('fs'), [input,output,c]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input)), target=data.targets['net10.0'];
|
|
3661
|
+
data.libraries['Other.B/2.0.0']={type:'package'}; target['Other.B/2.0.0']={type:'package'};
|
|
3662
|
+
target['Cratis.Stage/'+c].dependencies['Other.B']='[1.0.0]';
|
|
3663
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
3664
|
+
JSON
|
|
3665
|
+
expect_status 1 'composition range conflict on a composed edge is refused' cst verify-assets "$cr" --composition "$cm"
|
|
3666
|
+
node - "$root/composition-assets.json" "$cr/obj/project.assets.json" <<'JSON'
|
|
3667
|
+
const fs=require('fs'), [input,output]=process.argv.slice(2), data=JSON.parse(fs.readFileSync(input));
|
|
3668
|
+
data.targets.net9=JSON.parse(JSON.stringify(data.targets['net10.0']));
|
|
3669
|
+
Object.assign(data.libraries,{'Other.C/1.0.0':{type:'package'},'Other.C/2.0.0':{type:'package'}});
|
|
3670
|
+
data.targets.net9['Other.C/1.0.0']={type:'package'}; data.targets['net10.0']['Other.C/2.0.0']={type:'package'};
|
|
3671
|
+
fs.writeFileSync(output,JSON.stringify(data));
|
|
3672
|
+
JSON
|
|
3673
|
+
expect_status 0 'composition multitarget assets with one released ID at two versions' cst verify-assets "$cr" --composition "$cm"
|
|
3674
|
+
passed=$((passed + 3))
|
|
3675
|
+
sed "s/$a/22.136.4/g" "$root/composition-assets.json" > "$cr/obj/project.assets.json"
|
|
3676
|
+
expect_status 1 'composition published fallback version' cst verify-assets "$cr" --composition "$cm"
|
|
3677
|
+
printf '{"libraries":{},"targets":{"net10.0":{}}}\n' > "$cr/obj/project.assets.json"
|
|
3678
|
+
expect_status 1 'composition zero consumption' cst verify-assets "$cr" --composition "$cm"
|
|
3679
|
+
printf '{broken\n' > "$cr/obj/project.assets.json"
|
|
3680
|
+
expect_status 1 'composition malformed assets' cst verify-assets "$cr" --composition "$cm"
|
|
3681
|
+
cp "$root/composition-assets.json" "$cr/obj/project.assets.json"
|
|
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
|
|
3693
|
+
output=$cr/.ai-work/local-packages/composition
|
|
3694
|
+
grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'composition pin property'
|
|
3695
|
+
self_test_nuget_overlay "$output" Cratis.Stage "$c"
|
|
3696
|
+
passed=$((passed + 8))
|
|
3697
|
+
# Only exact owned IDs are removed remotely; broader patterns remain remote.
|
|
3698
|
+
[ "$(grep -c 'pattern="Cratis.Screenplay"' "$output/nuget.config")" -eq 1 ] || failed 'composition exclusive exact mapping'
|
|
3699
|
+
has 'pattern="*"' "$output/nuget.config" || failed 'composition removed remote wildcard'
|
|
3700
|
+
has 'pattern="Cratis.*"' "$output/nuget.config" || failed 'composition removed broader remote prefix'
|
|
3701
|
+
has 'pattern="Cratis.Screenplay*"' "$output/nuget.config" || failed 'composition removed overlapping remote prefix'
|
|
3702
|
+
has "$root/composition-physical-parent/feed/nuget" "$output/env" || failed 'composition feed path was not resolved physically'
|
|
3703
|
+
passed=$((passed + 4))
|
|
3704
|
+
cp "$output/Directory.Packages.props" "$root/composition-overlay.props"
|
|
3705
|
+
expect_status 0 'composition identical activation is a no-op' cst use-nuget "$cr" --composition "$cm" --pin-transitive
|
|
3706
|
+
cmp -s "$output/Directory.Packages.props" "$root/composition-overlay.props" || failed 'composition repeated activation drift'
|
|
3707
|
+
expect_status 2 'composition changed pinning refuses reactivation' cst use-nuget "$cr" --composition "$cm"
|
|
3708
|
+
expect_status 2 'composition rejects legacy activation while active' cst use-nuget "$cr" "$a" Cratis.Chronicle.InProcess
|
|
3709
|
+
expect_status 2 'composition active artifacts retained by clean' cst clean --stamp "$a" --apply
|
|
3710
|
+
expect_status 2 'composition refuses legacy npm activation' cst use-npm "$cr" "$b"
|
|
3711
|
+
cp "$output/env" "$root/composition-env"
|
|
3712
|
+
printf '# unrelated session edit\n' >> "$output/env"
|
|
3713
|
+
expect_status 2 'composition edited overlay retained on restore' cst restore "$cr"
|
|
3714
|
+
grep -Fq 'unrelated session edit' "$output/env" || failed 'composition deleted edited overlay'
|
|
3715
|
+
cp "$root/composition-env" "$output/env"
|
|
3716
|
+
cp "$output/cratis-local.targets" "$root/composition-targets"
|
|
3717
|
+
printf '<!-- unrelated targets edit -->\n' >> "$output/cratis-local.targets"
|
|
3718
|
+
expect_status 2 'composition edited targets retained on restore' cst restore "$cr"
|
|
3719
|
+
has 'unrelated targets edit' "$output/cratis-local.targets" || failed 'composition deleted edited targets'
|
|
3720
|
+
cp "$root/composition-targets" "$output/cratis-local.targets"
|
|
3721
|
+
printf '<!-- consumer edit -->\n' >> "$cr/Directory.Packages.props"
|
|
3722
|
+
cp "$cr/Directory.Packages.props" "$root/composition-original.props"
|
|
3723
|
+
expect_status 0 'composition restores preserving consumer edits' cst restore "$cr"
|
|
3724
|
+
has '0 leaks' "$root/output" || failed 'composition restore did not run check'
|
|
3725
|
+
expect_status 0 'composition repeated restore' cst restore "$cr"
|
|
3726
|
+
[ ! -e "$output" ] || failed 'composition overlay survives restore'
|
|
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'
|
|
3745
|
+
# Every live mutation boundary is exercised both as an ordinary failure and
|
|
3746
|
+
# an uncatchable parent/child interruption, then recovered by the next call.
|
|
3747
|
+
for kind in fail interrupt; do
|
|
3748
|
+
for boundary in journal session stage-manifest.tsv stage-Directory.Packages.props stage-cratis-local.targets stage-nuget.config stage-env stage-state.json stage-transaction staged published repo registration-staged registered committed; do
|
|
3749
|
+
printf '%s\n' "$root/another-session" > "$cf/sessions"; cp "$cf/sessions" "$base"
|
|
3750
|
+
composition_fail=''; composition_interrupt=''
|
|
3751
|
+
if [ "$kind" = fail ]; then composition_fail=$boundary; else composition_interrupt=$boundary; fi
|
|
3752
|
+
status=0; cst use-nuget "$cr" --composition "$cm" > "$root/output" 2> "$root/error" || status=$?
|
|
3753
|
+
[ "$status" -ne 0 ] || failed "composition $kind $boundary returned success"
|
|
3754
|
+
[ ! -s "$root/output" ] || failed "composition $kind $boundary printed activation instructions"
|
|
3755
|
+
composition_fail=''; composition_interrupt=''
|
|
3756
|
+
# A recovery-triggering restore must roll back incomplete activation,
|
|
3757
|
+
# or finish a committed activation and then restore it.
|
|
3758
|
+
expect_status 0 "composition recovery $kind $boundary" cst restore "$cr"
|
|
3759
|
+
[ ! -e "$output" ] || failed "composition recovery left publication $boundary"
|
|
3760
|
+
if [ "$boundary" != committed ]; then cmp -s "$cf/sessions" "$base" || failed "composition registry rollback $boundary"; fi
|
|
3761
|
+
cmp -s "$cr/Directory.Packages.props" "$root/composition-original.props" || failed "composition consumer drift $boundary"
|
|
3762
|
+
passed=$((passed + 3))
|
|
3763
|
+
done
|
|
3764
|
+
done
|
|
3765
|
+
expect_status 0 'composition activate before interrupted restoration' cst use-nuget "$cr" --composition "$cm"
|
|
3766
|
+
for boundary in restore-journal restore-renamed restore-committed; do
|
|
3767
|
+
composition_interrupt=$boundary
|
|
3768
|
+
status=0; cst restore "$cr" > "$root/output" 2> "$root/error" || status=$?
|
|
3769
|
+
[ "$status" -ne 0 ] || failed "composition interrupted restoration $boundary"
|
|
3770
|
+
composition_interrupt=''
|
|
3771
|
+
expect_status 0 "composition recover interrupted restoration $boundary" cst restore "$cr"
|
|
3772
|
+
[ ! -e "$output" ] || failed 'composition incomplete restoration recovery'
|
|
3773
|
+
expect_status 0 'composition reactivation after restoration recovery' cst use-nuget "$cr" --composition "$cm"
|
|
3774
|
+
done
|
|
3775
|
+
expect_status 0 'composition final restore' cst restore "$cr"
|
|
3776
|
+
# Another recoverer holds the transition guard after observing this dead owner.
|
|
3777
|
+
# A contender must not touch the stale lock (or its journal) until that transition ends.
|
|
3778
|
+
local dead_owner
|
|
3779
|
+
dead_owner=$(node -p process.pid)
|
|
3780
|
+
kill -0 "$dead_owner" 2>/dev/null && failed 'composition fixture owner is still alive'
|
|
3781
|
+
mkdir "$cf/.composition-lock" "$cf/.composition-recovery"
|
|
3782
|
+
printf '%s\n' "$dead_owner" > "$cf/.composition-lock/owner"
|
|
3783
|
+
expect_status 2 'composition competing stale-lock recoverer is excluded' cst restore "$cr"
|
|
3784
|
+
[ "$(cat "$cf/.composition-lock/owner")" = "$dead_owner" ] || failed 'composition contender removed stale lock'
|
|
3785
|
+
[ -d "$cf/.composition-recovery" ] || failed 'composition contender removed recovery guard'
|
|
3786
|
+
rmdir "$cf/.composition-recovery"
|
|
3787
|
+
expect_status 0 'composition stale-lock takeover after recovery guard release' cst restore "$cr"
|
|
3788
|
+
[ ! -e "$cf/.composition-lock" ] || failed 'composition stale-lock takeover left lock'
|
|
3789
|
+
mkdir -p "$cf/.composition-lock"; printf 'unknown owner\n' > "$cf/.composition-lock/owner"
|
|
3790
|
+
expect_status 2 'composition unknown ownership fails closed' cst use-nuget "$cr" --composition "$cm"
|
|
3791
|
+
grep -Fq 'unknown owner' "$cf/.composition-lock/owner" || failed 'composition deleted unknown owner'
|
|
3792
|
+
rm "$cf/.composition-lock/owner"; rmdir "$cf/.composition-lock"
|
|
3793
|
+
mkdir -p "$cf/.composition-lock"; printf '%s\n' "$$" > "$cf/.composition-lock/owner"
|
|
3794
|
+
expect_status 2 'composition concurrent activation lock' cst use-nuget "$cr" --composition "$cm"
|
|
3795
|
+
rm "$cf/.composition-lock/owner"; rmdir "$cf/.composition-lock"
|
|
3796
|
+
mkdir -p "$cr/.ai-work/local-packages/npm-backup"; printf 'unrelated npm backup\n' > "$cr/.ai-work/local-packages/npm-backup/package.json"
|
|
3797
|
+
expect_status 2 'composition preserves incompatible npm backup' cst use-nuget "$cr" --composition "$cm"
|
|
3798
|
+
grep -Fq 'unrelated npm backup' "$cr/.ai-work/local-packages/npm-backup/package.json" || failed 'composition npm backup drift'
|
|
3799
|
+
passed=$((passed + 1))
|
|
1766
3800
|
}
|
|
1767
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
|
|
1768
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 ;;
|
|
1769
3822
|
stamp) stamp "$@" ;;
|
|
1770
3823
|
plan) plan_command "$@" ;;
|
|
1771
3824
|
closure) closure "$@" ;;
|
|
@@ -1773,11 +3826,11 @@ case "$MODE" in
|
|
|
1773
3826
|
verify-assets) verify_assets "$@" ;;
|
|
1774
3827
|
pack-nuget) pack_nuget "$@" ;;
|
|
1775
3828
|
pack-npm) pack_npm "$@" ;;
|
|
1776
|
-
use-nuget) use_nuget "$@" ;;
|
|
1777
|
-
use-npm) use_npm "$@" ;;
|
|
1778
|
-
restore) restore "$@" ;;
|
|
3829
|
+
use-nuget) acquire_session_lock; use_nuget "$@" ;;
|
|
3830
|
+
use-npm) acquire_session_lock; use_npm "$@" ;;
|
|
3831
|
+
restore) acquire_session_lock; restore "$@" ;;
|
|
1779
3832
|
check) check "$@" ;;
|
|
1780
|
-
clean) clean "$@" ;;
|
|
3833
|
+
clean) acquire_session_lock; clean "$@" ;;
|
|
1781
3834
|
--self-test) self_test "$@" ;;
|
|
1782
3835
|
*) die "unknown command: $MODE (use --help)" ;;
|
|
1783
3836
|
esac
|