@cratis/pi 7.101.0 → 7.102.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.
Files changed (30) hide show
  1. package/package/corpus/harnesses/copilot/instructions/github-actions.instructions.md +17 -0
  2. package/package/corpus/rules/github-actions.md +17 -0
  3. package/package/corpus/skills/cratis-ci-pipeline-performance/LICENSE +2 -0
  4. package/package/corpus/skills/cratis-ci-pipeline-performance/SKILL.md +201 -0
  5. package/package/corpus/skills/cratis-ci-pipeline-performance/references/build-once-and-shard.md +158 -0
  6. package/package/corpus/skills/cratis-ci-pipeline-performance/references/caching.md +86 -0
  7. package/package/corpus/skills/cratis-ci-pipeline-performance/references/flaky-and-hung-jobs.md +70 -0
  8. package/package/corpus/skills/cratis-ci-pipeline-performance/references/publish-pipelines.md +67 -0
  9. package/package/corpus/skills/cratis-ci-pipeline-performance/references/required-checks-and-path-filters.md +117 -0
  10. package/package/corpus/skills/cratis-ci-pipeline-performance/references/runner-sizing-and-images.md +65 -0
  11. package/package/corpus/skills/cratis-ci-pipeline-performance/references/security-and-pinning.md +73 -0
  12. package/package/corpus/skills/cratis-ci-pipeline-performance/references/sources.md +33 -0
  13. package/package/corpus/skills/cratis-ci-pipeline-performance/references/test-setup-cost.md +82 -0
  14. package/package/corpus/skills/cratis-ci-pipeline-performance/scripts/ci-timings.sh +356 -0
  15. package/package/corpus/skills/cratis-ci-pipeline-performance/scripts/fixtures/jobs/1003.json +79 -0
  16. package/package/corpus/skills/cratis-ci-pipeline-performance/scripts/fixtures/jobs/1007.json +79 -0
  17. package/package/corpus/skills/cratis-ci-pipeline-performance/scripts/fixtures/jobs/1010.json +79 -0
  18. package/package/corpus/skills/cratis-ci-pipeline-performance/scripts/fixtures/runs.json +102 -0
  19. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/LICENSE +2 -0
  20. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/SKILL.md +244 -0
  21. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/docker-images.md +86 -0
  22. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/dotnet-nuget.md +87 -0
  23. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/go-jvm-python-elixir.md +83 -0
  24. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/npm-yarn.md +78 -0
  25. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/other-environments.md +60 -0
  26. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/references/worked-examples.md +97 -0
  27. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/cratis-local-packages.sh +778 -0
  28. package/package/corpus/skills/cratis-engineering-local-cross-repo-verification/scripts/npm-restore-check.cjs +109 -0
  29. package/package/profile-catalog.json +2 -0
  30. package/package.json +3 -2
@@ -0,0 +1,778 @@
1
+ #!/usr/bin/env bash
2
+ # Copyright (c) Cratis. All rights reserved.
3
+ # Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+ set -euo pipefail
5
+
6
+ usage() {
7
+ cat <<'HELP'
8
+ Usage: cratis-local-packages.sh [--dry-run] COMMAND [ARGS]
9
+ Portable local package verification for macOS/Linux (Bash 3.2+). Windows: use WSL.
10
+ Exit codes: 0 success, 1 findings/failed operation, 2 cannot run/invalid input.
11
+
12
+ Commands (COMMAND --help shows these instructions too):
13
+ stamp PRODUCER
14
+ Next patch of the highest reachable vX.Y.Z tag, UTC minute and HEAD sha7.
15
+ pack-nuget PRODUCER STAMP PROJECT-OR-SLN...
16
+ Release-pack each target into the NuGet feed; requires dotnet.
17
+ pack-npm PRODUCER STAMP
18
+ Requires node and Yarn 4, and a completed repository build. Temporarily
19
+ version non-private workspaces, pack with %s-%v.tgz (Yarn slugifies scoped
20
+ names), and restore tracked manifests even on failure. Pack lifecycle hooks
21
+ are Yarn's own behavior; this helper does not run a separate build.
22
+ use-nuget CONSUMER STAMP [PACKAGE-ID...]
23
+ Create a CPM overlay importing the repository's own Directory.Packages.props.
24
+ Defaults to every stamped package ID in the feed. Prints properties and sourceable
25
+ env. With source mapping, also prints the required restore --configfile.
26
+ Non-CPM: manually use an untracked project copy with explicit PackageReference
27
+ versions and dotnet restore --source FEED/nuget; do not edit shipped versions.
28
+ use-npm CONSUMER STAMP
29
+ Requires node and tar. Back up package.json, yarn.lock (including existing edits),
30
+ and git diff; add file-tarball resolutions for every stamped feed package.
31
+ Run yarn install afterward. Only one active npm overlay per session.
32
+ restore CONSUMER
33
+ Restore npm backups when only overlay resolutions and their local lockfile
34
+ entries changed (including yarn install). Refuse unrelated changes and retain
35
+ backups for review. Remove CPM/env overlays
36
+ and run check. No git checkout is used on consumer files.
37
+ check REPO [--staged]
38
+ Scan added/changed tracked lines (staged-only if requested), plus all contents
39
+ of untracked, non-ignored files. Reports path:line:pattern and file counts.
40
+ clean [CONSUMER] [--stamp STAMP | --all] [--apply]
41
+ Default: list all local feed/cache/image/session subjects, without deleting.
42
+ --apply deletes listed subjects (docker rmi, never force). Refuses more than
43
+ CRATIS_LOCAL_CLEAN_MAX subjects (default 500), or unresolved npm backups.
44
+ Known sessions are registered in FEED/sessions; an optional consumer also
45
+ discovers its per-worktree session. Docker probes have a 10-second deadline.
46
+ --self-test
47
+ Isolated scratch HOME tests; npm uses inherited PATH to discover node.
48
+ Other tests use system PATH. Reports passed and skipped counts.
49
+
50
+ Configuration: source ${XDG_CONFIG_HOME:-$HOME/.config}/cratis/local-packages.env
51
+ if present (trusted shell code), then use these defaults for unset variables:
52
+ CRATIS_LOCAL_FEED=${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed
53
+ CRATIS_LOCAL_SESSION_DIR=<absolute-git-dir>/cratis-local (per checkout)
54
+ NUGET_PACKAGES=$HOME/.nuget/packages
55
+ CRATIS_LOCAL_CLEAN_MAX=500
56
+ CRATIS_LOCAL_RUN=optional executable wrapper, invoked for each heavy step as:
57
+ "$CRATIS_LOCAL_RUN" build LABEL -- COMMAND ARGUMENTS...
58
+ Global --dry-run prints mutations instead of executing them; read-only discovery
59
+ still runs. Use a unique stamp once per producer build. Never publish local builds.
60
+ HELP
61
+ }
62
+
63
+ die() { printf 'ERROR: %s\n' "$*" >&2; exit 2; }
64
+ failed() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
65
+ need() { command -v "$1" >/dev/null 2>&1 || die "required executable not found: $1"; }
66
+ quote_command() { printf ' +'; printf ' %q' "$@"; printf '\n'; }
67
+ DRY=0
68
+ ARGS=()
69
+ for argument in "$@"; do
70
+ case "$argument" in --dry-run) DRY=1 ;; *) ARGS+=("$argument") ;; esac
71
+ done
72
+ set -- ${ARGS[@]+"${ARGS[@]}"}
73
+ case " ${*:-} " in *' --help '*|*' -h '*) usage; exit 0 ;; esac
74
+ [ $# -gt 0 ] || { usage; exit 2; }
75
+ SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0")
76
+ # Self-test dispatch precedes configuration so the real HOME/config cannot affect it.
77
+ MODE=$1
78
+ shift
79
+ if [ "$MODE" != --self-test ]; then
80
+ CONFIG=${XDG_CONFIG_HOME:-$HOME/.config}/cratis/local-packages.env
81
+ if [ -f "$CONFIG" ]; then
82
+ # shellcheck source=/dev/null
83
+ source "$CONFIG"
84
+ fi
85
+ fi
86
+ FEED=${CRATIS_LOCAL_FEED:-${XDG_CACHE_HOME:-$HOME/.cache}/cratis/local-feed}
87
+ NUGET_CACHE=${NUGET_PACKAGES:-$HOME/.nuget/packages}
88
+ case "$FEED" in /*) ;; *) FEED=$PWD/$FEED ;; esac
89
+ case "$NUGET_CACHE" in /*) ;; *) NUGET_CACHE=$PWD/$NUGET_CACHE ;; esac
90
+ TMP=''
91
+ PACK_REPO=''
92
+ PACK_MANIFESTS=()
93
+ cleanup() {
94
+ local status=$?
95
+ trap - EXIT
96
+ if [ ${#PACK_MANIFESTS[@]} -gt 0 ]; then
97
+ if ! git -C "$PACK_REPO" checkout -- "${PACK_MANIFESTS[@]}"; then
98
+ printf 'ERROR: producer manifest restoration failed: %s\n' "$PACK_REPO" >&2
99
+ status=1
100
+ fi
101
+ fi
102
+ if [ -n "$TMP" ]; then rm -rf -- "$TMP"; fi
103
+ exit "$status"
104
+ }
105
+ trap cleanup EXIT
106
+ trap 'exit 1' HUP INT TERM
107
+ scratch() { if [ -z "$TMP" ]; then TMP=$(mktemp -d "${TMPDIR:-/tmp}/cratis-local.XXXXXXXX"); fi; }
108
+ mutate() { if [ "$DRY" -eq 1 ]; then quote_command "$@"; else "$@"; fi; }
109
+ heavy() {
110
+ local kind=$1 label=$2
111
+ shift 2
112
+ if [ -n "${CRATIS_LOCAL_RUN:-}" ]; then
113
+ mutate "$CRATIS_LOCAL_RUN" "$kind" "$label" -- "$@"
114
+ else
115
+ mutate "$@"
116
+ fi
117
+ }
118
+ repo_root() { git -C "$1" rev-parse --show-toplevel 2>/dev/null || die "not a Git repository: $1"; }
119
+ valid_stamp() {
120
+ [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+-local\.[0-9]{12}\.g[0-9a-f]{7}$ ]] || die "invalid local stamp: $1"
121
+ }
122
+ session_path() {
123
+ local path git_dir
124
+ git_dir=$(git -C "$1" rev-parse --absolute-git-dir)
125
+ path=${CRATIS_LOCAL_SESSION_DIR:-$git_dir/cratis-local}
126
+ case "$path" in /*) ;; *) path=$PWD/$path ;; esac
127
+ [ "$path" != / ] && [ "$path" != "$1" ] && [ ! -L "$path" ] || die "unsafe session directory: $path"
128
+ # Git's own directory is never tracked, but check-ignore does not classify it
129
+ # as ignored. Other in-checkout overrides must still be explicitly ignored.
130
+ case "$path/" in
131
+ "$git_dir/"*) ;;
132
+ "$1/"*) git -C "$1" check-ignore -q "$path/" || die "session override must be outside tracked files or ignored" ;;
133
+ esac
134
+ printf '%s\n' "$path"
135
+ }
136
+ validate_session() {
137
+ local session=$1 entry
138
+ [ ! -L "$session" ] || die "symlink session directory: $session"
139
+ [ -d "$session" ] || return 0
140
+ for entry in "$session"/* "$session"/.[!.]* "$session"/..?*; do
141
+ [ -e "$entry" ] || [ -L "$entry" ] || continue
142
+ [ ! -L "$entry" ] || die "symlink in session: $entry"
143
+ case "${entry##*/}" in
144
+ repo|stamp|env|Directory.Packages.props|nuget.config) [ -f "$entry" ] || die "unexpected session entry: $entry" ;;
145
+ npm-backup) [ -d "$entry" ] || die "unexpected session entry: $entry" ;;
146
+ *) die "unrecognized file in session (preserved): $entry" ;;
147
+ esac
148
+ done
149
+ }
150
+ register_session() {
151
+ local repo=$1 session=$2 stamp=$3
152
+ validate_session "$session"
153
+ if [ -f "$session/repo" ] && [ "$(cat "$session/repo")" != "$repo" ]; then die 'session belongs to another checkout'; fi
154
+ mkdir -p "$session" "$FEED"
155
+ printf '%s\n' "$repo" > "$session/repo"
156
+ printf '%s\n' "$stamp" > "$session/stamp"
157
+ if [ ! -f "$FEED/sessions" ] || ! grep -Fxq -- "$session" "$FEED/sessions"; then
158
+ printf '%s\n' "$session" >> "$FEED/sessions"
159
+ fi
160
+ }
161
+ xml_escape() { printf '%s' "$1" | sed -e 's/\&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g' -e "s/'/\&apos;/g"; }
162
+
163
+ stamp() {
164
+ [ $# -eq 1 ] || die 'stamp requires a producer repository'
165
+ local repo tag major minor patch sha
166
+ repo=$(repo_root "$1")
167
+ tag=$(git -C "$repo" tag --merged HEAD | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/' | sort -t. -k1.2,1n -k2,2n -k3,3n | tail -n 1)
168
+ if [ -n "$tag" ]; then
169
+ IFS=. read -r major minor patch <<< "${tag#v}"
170
+ tag="$major.$minor.$((10#$patch + 1))"
171
+ else
172
+ printf 'WARNING: no reachable vX.Y.Z tag; using 0.1.0\n' >&2
173
+ tag=0.1.0
174
+ fi
175
+ sha=$(git -C "$repo" rev-parse HEAD) || die 'producer has no HEAD commit'
176
+ printf '%s-local.%s.g%.7s\n' "$tag" "$(date -u +%Y%m%d%H%M)" "$sha"
177
+ }
178
+
179
+ pack_nuget() {
180
+ [ $# -ge 3 ] || die 'pack-nuget requires producer, stamp and targets'
181
+ local repo version target path count=0
182
+ repo=$(repo_root "$1"); version=$2; shift 2
183
+ valid_stamp "$version"; need dotnet
184
+ mutate mkdir -p "$FEED/nuget"
185
+ for target in "$@"; do
186
+ (cd "$repo" && heavy build "pack NuGet $target" dotnet pack "$target" -c Release "-p:PackageVersion=$version" "-p:Version=$version" -o "$FEED/nuget")
187
+ done
188
+ [ "$DRY" -eq 0 ] || return 0
189
+ while IFS= read -r -d '' path; do
190
+ printf '%s\n' "$path"; count=$((count + 1))
191
+ done < <(find "$FEED/nuget" -type f -name "*.$version.nupkg" -print0)
192
+ [ "$count" -gt 0 ] || failed 'pack produced no matching nupkgs'
193
+ printf 'produced %s nupkgs\n' "$count"
194
+ }
195
+
196
+ pack_npm() {
197
+ [ $# -eq 2 ] || die 'pack-npm requires producer and stamp'
198
+ local repo version path count=0
199
+ repo=$(repo_root "$1"); version=$2
200
+ valid_stamp "$version"; need node; need yarn; scratch
201
+ git -C "$repo" diff --name-only HEAD -- 'package.json' '*/package.json' > "$TMP/modified"
202
+ git -C "$repo" diff --cached --name-only -- 'package.json' '*/package.json' >> "$TMP/modified"
203
+ git -C "$repo" diff --name-only -- 'package.json' '*/package.json' >> "$TMP/modified"
204
+ [ ! -s "$TMP/modified" ] || die 'tracked package.json is already modified; preserve it before packing'
205
+ (cd "$repo" && yarn workspaces list --json) > "$TMP/workspaces"
206
+ node - "$repo" "$TMP/workspaces" > "$TMP/manifests" <<'NODE'
207
+ const fs = require('fs'), path = require('path');
208
+ const [repo, list] = process.argv.slice(2);
209
+ for (const line of fs.readFileSync(list, 'utf8').trim().split('\n')) {
210
+ const workspace = JSON.parse(line);
211
+ const file = path.join(workspace.location, 'package.json');
212
+ if (!JSON.parse(fs.readFileSync(path.join(repo, file), 'utf8')).private)
213
+ process.stdout.write(file + '\0');
214
+ }
215
+ NODE
216
+ PACK_REPO=$repo
217
+ while IFS= read -r -d '' path; do
218
+ git -C "$repo" ls-files --error-unmatch -- "$path" >/dev/null || die "untracked workspace manifest: $path"
219
+ PACK_MANIFESTS+=("$path")
220
+ done < "$TMP/manifests"
221
+ [ ${#PACK_MANIFESTS[@]} -gt 0 ] || die 'no non-private workspaces found'
222
+ if [ "$DRY" -eq 1 ]; then
223
+ quote_command node '<set non-private workspace versions>' "$version" "${PACK_MANIFESTS[@]}"
224
+ PACK_MANIFESTS=()
225
+ else
226
+ node - "$repo" "$version" "${PACK_MANIFESTS[@]}" <<'NODE'
227
+ const fs = require('fs'), path = require('path');
228
+ const [repo, version, ...files] = process.argv.slice(2);
229
+ for (const file of files) {
230
+ const target = path.join(repo, file), json = JSON.parse(fs.readFileSync(target, 'utf8'));
231
+ json.version = version;
232
+ fs.writeFileSync(target, JSON.stringify(json, null, 2) + '\n');
233
+ }
234
+ NODE
235
+ fi
236
+ mutate mkdir -p "$FEED/npm"
237
+ (cd "$repo" && heavy build 'pack npm workspaces' yarn workspaces foreach -A --no-private pack --out "$FEED/npm/%s-%v.tgz")
238
+ if [ "$DRY" -eq 1 ]; then quote_command git -C "$repo" checkout -- '<versioned manifests>'; return 0; fi
239
+ git -C "$repo" checkout -- "${PACK_MANIFESTS[@]}"
240
+ PACK_MANIFESTS=()
241
+ while IFS= read -r -d '' path; do
242
+ printf '%s\n' "$path"; count=$((count + 1))
243
+ done < <(find "$FEED/npm" -type f -name "*-$version.tgz" -print0)
244
+ [ "$count" -gt 0 ] || failed 'pack produced no matching npm tarballs'
245
+ printf 'produced %s npm tarballs\n' "$count"
246
+ }
247
+
248
+ # Read Include/Update attributes without assuming one element per line.
249
+ package_ids() {
250
+ awk 'BEGIN { RS="<" } /^PackageVersion[[:space:]]/ {
251
+ gsub(sprintf("%c",39),"\"")
252
+ if (match($0, /(Include|Update)[[:space:]]*=[[:space:]]*"[^"]+"/)) {
253
+ value=substr($0,RSTART,RLENGTH); sub(/^[^"]*"/,"",value); sub(/"$/,"",value); print value
254
+ }
255
+ }' "$1"
256
+ }
257
+ source_mapping() {
258
+ local config=$1 output=$2 id
259
+ shift 2
260
+ scratch
261
+ printf '%s\n' "$@" > "$TMP/ids"
262
+ # Preserve all existing configuration, but remove exact mappings for overridden
263
+ # IDs: NuGet must not select both the remote source and our local exact match.
264
+ awk -v ids="$TMP/ids" -v feed="$(xml_escape "$FEED/nuget")" -v base="$(xml_escape "$(dirname "$config")")" '
265
+ BEGIN { while ((getline id < ids)>0) { selected[tolower(id)]=1; ordered[++count]=id }; RS="<"; ORS="" }
266
+ NR==1 { print; next }
267
+ /^package[[:space:]]/ {
268
+ value=$0; gsub(sprintf("%c",39),"\"",value)
269
+ sub(/^.*pattern[[:space:]]*=[[:space:]]*"/,"",value); sub(/".*$/,"",value)
270
+ if (tolower(value) in selected) next
271
+ }
272
+ /^packageSources[[:space:]]*>/ { sources=1 }
273
+ /^\/packageSources[[:space:]]*>/ { sources=0 }
274
+ sources && /^add[[:space:]]/ {
275
+ # Relative source paths must retain their meaning after relocation.
276
+ value=$0; gsub(sprintf("%c",39),"\"",value)
277
+ if (match(value,/value[[:space:]]*=[[:space:]]*"[^"]*"/)) {
278
+ attribute=substr(value,RSTART,RLENGTH); sub(/^[^"]*"/,"",attribute); sub(/"$/,"",attribute)
279
+ if (attribute !~ /^([A-Za-z][A-Za-z0-9+.-]*:|\/|\\\\|%)/) {
280
+ sub(/value[[:space:]]*=[[:space:]]*"[^"]*"/,"value=\"" base "/" attribute "\"",value)
281
+ $0=value
282
+ }
283
+ }
284
+ }
285
+ /^\/packageSources[[:space:]]*>/ { print "<add key=\"cratis-local\" value=\"" feed "\" />\n" }
286
+ /^\/packageSourceMapping[[:space:]]*>/ {
287
+ print "<packageSource key=\"cratis-local\">\n"
288
+ for (i=1;i<=count;i++) print "<package pattern=\"" ordered[i] "\" />\n"
289
+ print "</packageSource>\n"
290
+ }
291
+ { print "<" $0 }
292
+ ' "$config" > "$output"
293
+ }
294
+
295
+ use_nuget() {
296
+ [ $# -ge 2 ] || die 'use-nuget requires consumer and stamp'
297
+ local repo version session props id file config='' props_key sources_key action
298
+ local ids=()
299
+ repo=$(repo_root "$1"); version=$2; shift 2
300
+ valid_stamp "$version"; props=$repo/Directory.Packages.props
301
+ [ -f "$props" ] || die 'no Directory.Packages.props (non-CPM). Manual alternative: untracked project copy, explicit PackageReference versions, dotnet restore --source FEED/nuget'
302
+ session=$(session_path "$repo"); scratch
303
+ package_ids "$props" > "$TMP/consumer-ids"
304
+ if [ $# -gt 0 ]; then
305
+ ids=("$@")
306
+ elif [ -d "$FEED/nuget" ]; then
307
+ while IFS= read -r -d '' file; do
308
+ id=${file##*/}; id=${id%."$version".nupkg}
309
+ ids+=("$id")
310
+ done < <(find "$FEED/nuget" -type f -name "*.$version.nupkg" -print0)
311
+ fi
312
+ [ ${#ids[@]} -gt 0 ] || die 'no matching NuGet package IDs found'
313
+ for id in "${ids[@]}"; do
314
+ [[ "$id" =~ ^[A-Za-z0-9_.-]+$ ]] || die "invalid package ID: $id"
315
+ [ -f "$FEED/nuget/$id.$version.nupkg" ] || die "package missing from feed: $id $version"
316
+ done
317
+ while IFS= read -r -d '' file; do
318
+ case "$file" in
319
+ nuget.config|NuGet.Config|NuGet.config)
320
+ if grep -q '<packageSourceMapping' "$repo/$file"; then config=$repo/$file; fi ;;
321
+ esac
322
+ done < <(git -C "$repo" ls-files -z)
323
+ if [ -n "$config" ]; then
324
+ if ! grep -q '</packageSources>' "$config" || ! grep -q '</packageSourceMapping>' "$config"; then
325
+ die 'cannot overlay incomplete source mapping configuration'
326
+ fi
327
+ if grep -q 'key="cratis-local"' "$config"; then die 'source key cratis-local already exists'; fi
328
+ fi
329
+ if [ "$DRY" -eq 1 ]; then
330
+ quote_command mkdir -p "$session"
331
+ printf 'write %s importing %s; override %s IDs to %s\n' "$session/Directory.Packages.props" "$props" "${#ids[@]}" "$version"
332
+ printf 'write %s/env%s\n' "$session" "${config:+ and nuget.config}"
333
+ return 0
334
+ fi
335
+ register_session "$repo" "$session" "$version"
336
+ {
337
+ printf '<Project>\n <Import Project="%s" />\n' "$(xml_escape "$props")"
338
+ printf ' <PropertyGroup><CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled></PropertyGroup>\n <ItemGroup>\n'
339
+ for id in "${ids[@]}"; do
340
+ action=Include
341
+ if grep -Fxiq -- "$id" "$TMP/consumer-ids"; then action=Update; fi
342
+ printf ' <PackageVersion %s="%s" Version="%s" />\n' "$action" "$id" "$version"
343
+ done
344
+ printf ' </ItemGroup>\n</Project>\n'
345
+ } > "$session/Directory.Packages.props"
346
+ props_key=DirectoryPackages"PropsPath"; sources_key=RestoreAdditional"ProjectSources"
347
+ {
348
+ printf 'export %s=%q\n' "$props_key" "$session/Directory.Packages.props"
349
+ printf 'export %s=%q\n' "$sources_key" "$FEED/nuget"
350
+ } > "$session/env"
351
+ printf ' %q' "-p:$props_key=$session/Directory.Packages.props" "-p:$sources_key=$FEED/nuget"; printf '\n'
352
+ printf 'source %q\n' "$session/env"
353
+ if [ -n "$config" ]; then
354
+ source_mapping "$config" "$session/nuget.config" "${ids[@]}"
355
+ printf 'For restore, also pass: --configfile %q\n' "$session/nuget.config"
356
+ elif [ -f "$session/nuget.config" ]; then
357
+ rm -- "$session/nuget.config"
358
+ fi
359
+ printf 'overrode %s NuGet package IDs\n' "${#ids[@]}"
360
+ }
361
+
362
+ use_npm() {
363
+ [ $# -eq 2 ] || die 'use-npm requires consumer and stamp'
364
+ local repo version session backup
365
+ repo=$(repo_root "$1"); version=$2
366
+ valid_stamp "$version"; need node; need tar; scratch
367
+ session=$(session_path "$repo"); backup=$session/npm-backup
368
+ [ -f "$repo/package.json" ] || die 'consumer has no root package.json'
369
+ [ ! -e "$backup" ] || die "active npm backup exists: $backup; restore first"
370
+ node - "$repo" "$FEED/npm" "$version" "$TMP/package.json" <<'NODE'
371
+ const fs = require('fs'), path = require('path'), {execFileSync} = require('child_process');
372
+ const [repo, feed, stamp, output] = process.argv.slice(2);
373
+ const root = JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8'));
374
+ let count = 0;
375
+ for (const file of fs.existsSync(feed) ? fs.readdirSync(feed) : []) {
376
+ if (!file.endsWith('-' + stamp + '.tgz')) continue;
377
+ const tarball = path.join(feed, file);
378
+ // Read the packed name: slugified scoped names are not reversibly encoded.
379
+ const {name} = JSON.parse(execFileSync('tar', ['-xOf', tarball, 'package/package.json'], {encoding: 'utf8'}));
380
+ if (typeof name !== 'string' || !/^(@[^/\s]+\/)?[^/\s]+$/.test(name))
381
+ throw new Error('Invalid packed package name: ' + tarball);
382
+ root.resolutions = root.resolutions || {};
383
+ root.resolutions[name] = 'file:' + tarball;
384
+ count++;
385
+ }
386
+ if (!count) { console.error('ERROR: no matching npm tarballs'); process.exit(2); }
387
+ fs.writeFileSync(output, JSON.stringify(root, null, 2) + '\n');
388
+ console.log(`overrode ${count} npm packages`);
389
+ NODE
390
+ if [ "$DRY" -eq 1 ]; then
391
+ quote_command mkdir -p "$backup"
392
+ quote_command cp "$TMP/package.json" "$repo/package.json"
393
+ return 0
394
+ fi
395
+ register_session "$repo" "$session" "$version"
396
+ mkdir "$backup"
397
+ cp -p "$repo/package.json" "$backup/package.json"
398
+ if [ -f "$repo/yarn.lock" ]; then cp -p "$repo/yarn.lock" "$backup/yarn.lock"; fi
399
+ git -C "$repo" diff HEAD -- package.json yarn.lock > "$backup/before.diff"
400
+ cp "$TMP/package.json" "$backup/expected-package.json"
401
+ cp "$TMP/package.json" "$repo/package.json"
402
+ printf 'Next: cd %q && yarn install\n' "$repo"
403
+ printf 'Vite: clear stale prebundles with rm -rf node_modules/.vite\n'
404
+ }
405
+
406
+ restore() {
407
+ [ $# -eq 1 ] || die 'restore requires a consumer'
408
+ local repo session backup
409
+ repo=$(repo_root "$1"); session=$(session_path "$repo"); backup=$session/npm-backup
410
+ validate_session "$session"
411
+ if [ -f "$session/repo" ] && [ "$(cat "$session/repo")" != "$repo" ]; then die 'session belongs to another checkout'; fi
412
+ if [ -d "$backup" ]; then
413
+ if ! cmp -s "$repo/package.json" "$backup/expected-package.json" ||
414
+ { [ -f "$backup/yarn.lock" ] && ! cmp -s "$repo/yarn.lock" "$backup/yarn.lock"; } ||
415
+ { [ ! -f "$backup/yarn.lock" ] && [ -e "$repo/yarn.lock" ]; }; then
416
+ need node
417
+ node "$(dirname "$SELF")/npm-restore-check.cjs" "$repo" "$backup" ||
418
+ die "npm files changed beyond the overlay; review $backup (nothing restored)"
419
+ fi
420
+ mutate cp -p "$backup/package.json" "$repo/package.json"
421
+ if [ -f "$backup/yarn.lock" ]; then mutate cp -p "$backup/yarn.lock" "$repo/yarn.lock"; else mutate rm -f -- "$repo/yarn.lock"; fi
422
+ mutate rm -rf -- "$backup"
423
+ fi
424
+ mutate rm -f -- "$session/Directory.Packages.props" "$session/nuget.config" "$session/env" "$session/stamp"
425
+ if [ "$DRY" -eq 0 ]; then check "$repo"; fi
426
+ }
427
+
428
+ # Check only changed lines, not unchanged context or removed local overrides.
429
+ # NUL-delimited Git paths preserve spaces; diagnostics are relative to the root.
430
+ check() {
431
+ [ $# -ge 1 ] && [ $# -le 2 ] || die 'check requires repo and optional --staged'
432
+ local repo path count=0 hits=0 staged=0
433
+ repo=$(repo_root "$1"); shift
434
+ if [ $# -gt 0 ]; then [ "$1" = --staged ] || die "unknown check option: $1"; staged=1; fi
435
+ scratch
436
+ if [ "$staged" -eq 1 ]; then
437
+ git -C "$repo" diff --cached --name-only -z --diff-filter=ACMRT > "$TMP/tracked"
438
+ else
439
+ git -C "$repo" diff HEAD --name-only -z --diff-filter=ACMRT > "$TMP/tracked"
440
+ fi
441
+ git -C "$repo" ls-files --others --exclude-standard -z > "$TMP/untracked"
442
+ while IFS= read -r -d '' path; do
443
+ count=$((count + 1))
444
+ if [ "$staged" -eq 1 ]; then
445
+ git -C "$repo" diff --cached --no-ext-diff --no-textconv --unified=0 -- "$path" > "$TMP/diff"
446
+ else
447
+ git -C "$repo" diff HEAD --no-ext-diff --no-textconv --unified=0 -- "$path" > "$TMP/diff"
448
+ fi
449
+ awk '/^@@ / { split($3,a,","); n=substr(a[1],2); next } /^\+\+\+/ { next } /^\+/ { print n ":" substr($0,2); n++; next } /^ / { n++ }' "$TMP/diff" > "$TMP/lines"
450
+ scan_lines "$path" "$TMP/lines" > "$TMP/hits"
451
+ if [ -s "$TMP/hits" ]; then cat "$TMP/hits"; hits=$((hits + 1)); fi
452
+ done < "$TMP/tracked"
453
+ while IFS= read -r -d '' path; do
454
+ [ ! -L "$repo/$path" ] || die "cannot scan untracked symlink without following outside repo: $path"
455
+ [ -f "$repo/$path" ] || continue
456
+ count=$((count + 1))
457
+ awk '{print NR ":" $0}' "$repo/$path" > "$TMP/lines"
458
+ scan_lines "$path" "$TMP/lines" > "$TMP/hits"
459
+ if [ -s "$TMP/hits" ]; then cat "$TMP/hits"; hits=$((hits + 1)); fi
460
+ done < "$TMP/untracked"
461
+ printf 'checked %s files, %s leaks\n' "$count" "$hits"
462
+ [ "$hits" -eq 0 ]
463
+ }
464
+ scan_lines() {
465
+ local path=$1 input=$2
466
+ awk -v path="$path" -v feed="$FEED" '
467
+ function hit(pattern) { print path ":" number ":" pattern }
468
+ {
469
+ number=$0; sub(/:.*/,"",number); line=$0; sub(/^[^:]*:/,"",line)
470
+ 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")
471
+ if (index(line,feed)) hit("local feed path")
472
+ if (index(line,"DirectoryPackages" "PropsPath")) hit("CPM overlay property")
473
+ if (index(line,"RestoreAdditional" "ProjectSources")) hit("additional restore sources")
474
+ if (line ~ /<PackageVersion[[:space:]]+Update[[:space:]]*=/) hit("package version override")
475
+ if (path ~ /(^|\/)(package.json|yarn.lock)$/ && line ~ /(file|portal|link):/ &&
476
+ (line ~ /\.tgz/ || line ~ /(file|portal|link):(\/|\.\.?\/|~\/|[A-Za-z]:)/)) hit("local npm reference")
477
+ }
478
+ ' "$input"
479
+ }
480
+
481
+ # Portable deadline for a read-only Docker probe; no dependency on GNU timeout.
482
+ bounded_docker() {
483
+ local pid second status=0
484
+ docker "$@" > "$TMP/docker-output" 2> "$TMP/docker-error" & pid=$!
485
+ for ((second=0; second<10; second++)); do
486
+ if ! kill -0 "$pid" 2>/dev/null; then break; fi
487
+ sleep 1
488
+ done
489
+ if kill -0 "$pid" 2>/dev/null; then
490
+ kill -TERM "$pid" 2>/dev/null || :
491
+ kill -KILL "$pid" 2>/dev/null || :
492
+ wait "$pid" 2>/dev/null || :
493
+ return 1
494
+ fi
495
+ wait "$pid" || status=$?
496
+ return "$status"
497
+ }
498
+ local_artifact() {
499
+ [[ "$1" =~ -local\.[0-9]{12}\.g[0-9a-f]{7}($|[^0-9A-Za-z]) ]]
500
+ }
501
+ clean() {
502
+ local version='' apply=0 consumer='' arg path cache image session maximum count=0
503
+ local subjects=() kinds=()
504
+ while [ $# -gt 0 ]; do
505
+ arg=$1; shift
506
+ case "$arg" in
507
+ --stamp) [ $# -gt 0 ] || die '--stamp requires a stamp'; [ -z "$version" ] || die 'choose --stamp or --all'; version=$1; shift; valid_stamp "$version" ;;
508
+ --all) [ -z "$version" ] || die 'choose --stamp or --all'; version=all ;;
509
+ --apply) apply=1 ;;
510
+ -*) die "unknown clean option: $arg" ;;
511
+ *) [ -z "$consumer" ] || die 'clean takes at most one consumer'; consumer=$(repo_root "$arg") ;;
512
+ esac
513
+ done
514
+ maximum=${CRATIS_LOCAL_CLEAN_MAX:-500}
515
+ [[ "$maximum" =~ ^[0-9]+$ ]] || die 'CRATIS_LOCAL_CLEAN_MAX must be a whole number'
516
+ [ ${#maximum} -le 6 ] || die 'CRATIS_LOCAL_CLEAN_MAX is too large'
517
+ maximum=$((10#$maximum))
518
+ scratch
519
+ for cache in "$FEED/nuget" "$FEED/npm"; do
520
+ [ -d "$cache" ] || continue
521
+ while IFS= read -r -d '' path; do
522
+ local_artifact "${path##*/}" || continue
523
+ if [ -n "$version" ] && [ "$version" != all ]; then case "$path" in *"$version"*) ;; *) continue ;; esac; fi
524
+ subjects+=("$path"); kinds+=(file)
525
+ done < <(find "$cache" -type f \( -name '*.nupkg' -o -name '*.tgz' \) -print0)
526
+ done
527
+ if [ -d "$NUGET_CACHE" ]; then
528
+ for cache in "$NUGET_CACHE"/*; do
529
+ [ -d "$cache" ] && [ ! -L "$cache" ] || continue
530
+ for path in "$cache"/*-local.*; do
531
+ [ -d "$path" ] && [ ! -L "$path" ] || continue
532
+ local_artifact "${path##*/}" || continue
533
+ if [ -n "$version" ] && [ "$version" != all ] && [ "${path##*/}" != "$version" ]; then continue; fi
534
+ subjects+=("$path"); kinds+=(directory)
535
+ done
536
+ done
537
+ fi
538
+ if command -v yarn >/dev/null 2>&1 && [ -n "$consumer" ]; then
539
+ cache=$(cd "$consumer" && yarn config get cacheFolder) || die 'cannot discover Yarn cache'
540
+ if [ -d "$cache" ]; then
541
+ while IFS= read -r -d '' path; do
542
+ local_artifact "${path##*/}" || continue
543
+ if [ -n "$version" ] && [ "$version" != all ]; then case "$path" in *"$version"*) ;; *) continue ;; esac; fi
544
+ subjects+=("$path"); kinds+=(file)
545
+ done < <(find "$cache" -type f -name '*-local.*' -print0)
546
+ fi
547
+ else
548
+ printf 'SKIP: Yarn cache discovery needs yarn and an explicit consumer\n' >&2
549
+ fi
550
+ if command -v docker >/dev/null 2>&1; then
551
+ if bounded_docker image ls --format '{{.Repository}}:{{.Tag}}'; then
552
+ while IFS= read -r image; do
553
+ local_artifact "${image##*:}" || continue
554
+ if [ -n "$version" ] && [ "$version" != all ]; then case "${image##*:}" in *"$version"*) ;; *) continue ;; esac; fi
555
+ subjects+=("$image"); kinds+=(image)
556
+ done < "$TMP/docker-output"
557
+ else
558
+ printf 'SKIP: Docker unavailable or did not respond within 10 seconds\n' >&2
559
+ fi
560
+ else printf 'SKIP: docker is not on PATH\n' >&2; fi
561
+ if [ -f "$FEED/sessions" ]; then cat "$FEED/sessions" > "$TMP/sessions"; else : > "$TMP/sessions"; fi
562
+ if [ -n "$consumer" ]; then session_path "$consumer" >> "$TMP/sessions"; fi
563
+ sort -u "$TMP/sessions" > "$TMP/unique-sessions"
564
+ while IFS= read -r session; do
565
+ [ -d "$session" ] && [ -f "$session/repo" ] && [ -f "$session/stamp" ] || continue
566
+ local_artifact "$(cat "$session/stamp")" || continue
567
+ if [ -n "$version" ] && [ "$version" != all ] && [ "$(cat "$session/stamp")" != "$version" ]; then continue; fi
568
+ [ ! -L "$session" ] && [ "$session" != / ] || die "unsafe session: $session"
569
+ validate_session "$session"
570
+ path=$(cat "$session/repo")
571
+ [ "$(session_path "$path")" = "$session" ] || die "session directory no longer matches repository: $session"
572
+ [ ! -d "$session/npm-backup" ] || die "unresolved npm backup: $session; restore before cleaning"
573
+ subjects+=("$session"); kinds+=(session)
574
+ done < "$TMP/unique-sessions"
575
+ count=${#subjects[@]}
576
+ [ "$count" -le "$maximum" ] || die "clean fuse: $count subjects exceeds $maximum"
577
+ for ((arg=0; arg<count; arg++)); do printf '%s: %s\n' "${kinds[$arg]}" "${subjects[$arg]}"; done
578
+ if [ "$apply" -eq 1 ] && [ "$DRY" -eq 0 ]; then
579
+ for ((arg=0; arg<count; arg++)); do
580
+ path=${subjects[$arg]}
581
+ case "${kinds[$arg]}" in
582
+ image) docker rmi "$path" ;;
583
+ file) [ -f "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -- "$path" ;;
584
+ directory) [ -d "$path" ] && [ ! -L "$path" ] || die "clean subject changed: $path"; rm -rf -- "$path" ;;
585
+ session)
586
+ [ -f "$path/stamp" ] && [ ! -L "$path" ] && [ ! -e "$path/npm-backup" ] || die "session changed: $path"
587
+ validate_session "$path"
588
+ rm -f -- "$path/repo" "$path/stamp" "$path/env" "$path/Directory.Packages.props" "$path/nuget.config"
589
+ rmdir -- "$path" ;;
590
+ esac
591
+ done
592
+ printf 'removed %s subjects\n' "$count"
593
+ else printf 'dry-run: %s subjects (use --apply to remove)\n' "$count"; fi
594
+ }
595
+
596
+ self_test() {
597
+ [ $# -eq 0 ] || die '--self-test takes no arguments'
598
+ local root home repo feed version output status passed=0 skipped=0 test_key arg
599
+ scratch; root=$TMP/self-test; mkdir -p "$root/home" "$root/repo"
600
+ home=$root/home; repo=$root/repo; feed=$home/.cache/cratis/local-feed
601
+ # System-only tests stay hermetic; npm tests inherit PATH to discover node.
602
+ # Both isolate HOME/config and omit the host adapter.
603
+ st() { env -i HOME="$home" PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
604
+ st_npm() { env -i HOME="$home" PATH="$PATH" TMPDIR="$TMP" /bin/bash "$SELF" "$@"; }
605
+ git -C "$repo" init -q
606
+ repo=$(repo_root "$repo")
607
+ git -C "$repo" config user.email test@example.invalid
608
+ git -C "$repo" config user.name 'Local package self-test'
609
+ printf '<Project><ItemGroup><PackageVersion Include="Cratis.Test" Version="1.2.3" /></ItemGroup></Project>\n' > "$repo/Directory.Packages.props"
610
+ printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"}}\n' > "$repo/package.json"
611
+ cat > "$repo/yarn.lock" <<'LOCK'
612
+ # This file is generated by running "yarn install" inside your project.
613
+
614
+ __metadata:
615
+ version: 8
616
+ cacheKey: 10c0
617
+
618
+ "@cratis/test@npm:1.2.3":
619
+ version: 1.2.3
620
+ resolution: "@cratis/test@npm:1.2.3"
621
+ checksum: original
622
+ languageName: node
623
+ linkType: hard
624
+
625
+ "unrelated@npm:1.0.0":
626
+ version: 1.0.0
627
+ resolution: "unrelated@npm:1.0.0"
628
+ checksum: keep
629
+ languageName: node
630
+ linkType: hard
631
+ LOCK
632
+ printf 'clean\n' > "$repo/probe"
633
+ git -C "$repo" add .; git -C "$repo" commit -qm initial; git -C "$repo" tag v1.2.3
634
+ status=0; st > "$root/output" || status=$?
635
+ if [ "$status" -ne 2 ] || ! grep -q '^Usage:' "$root/output"; then
636
+ failed 'self-test: no arguments must show usage and exit 2'
637
+ fi
638
+ passed=$((passed + 1))
639
+ version=$(st stamp "$repo")
640
+ [[ "$version" =~ ^1\.2\.4-local\.[0-9]{12}\.g[0-9a-f]{7}$ ]] || failed 'self-test: stamp format'
641
+ passed=$((passed + 1))
642
+ mkdir -p "$feed/nuget" "$feed/npm"
643
+ : > "$feed/nuget/Cratis.Test.1.2.4-local.200001010000.g1234567.nupkg"
644
+ st --dry-run use-nuget "$repo" 1.2.4-local.200001010000.g1234567 Cratis.Test > "$root/output"
645
+ status=0; st --dry-run use-nuget "$repo" 1.2.4-local.200001010000.1234567 Cratis.Test > "$root/output" 2>&1 || status=$?
646
+ [ "$status" -eq 2 ] || failed 'self-test: unprefixed numeric SHA accepted'
647
+ rm "$feed/nuget/Cratis.Test.1.2.4-local.200001010000.g1234567.nupkg"
648
+ passed=$((passed + 2))
649
+ : > "$feed/nuget/Cratis.Test.$version.nupkg"
650
+ : > "$feed/nuget/Cratis.Transitive.$version.nupkg"
651
+ mkdir -p "$root/tarball/package"
652
+ for arg in test transitive; do
653
+ printf '{"name":"@cratis/%s","version":"%s"}\n' "$arg" "$version" > "$root/tarball/package/package.json"
654
+ tar -czf "$feed/npm/@cratis-$arg-$version.tgz" -C "$root/tarball" package
655
+ done
656
+ st use-nuget "$repo" "$version" > "$root/output"
657
+ output=$(git -C "$repo" rev-parse --absolute-git-dir)/cratis-local
658
+ grep -Fq "Import Project=\"$repo/Directory.Packages.props\"" "$output/Directory.Packages.props" || failed 'self-test: overlay import'
659
+ grep -Fq "PackageVersion Update=\"Cratis.Test\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: pinned package Update'
660
+ grep -Fq "PackageVersion Include=\"Cratis.Transitive\" Version=\"$version\"" "$output/Directory.Packages.props" || failed 'self-test: transitive package Include'
661
+ grep -Fq 'CentralPackageTransitivePinningEnabled>true' "$output/Directory.Packages.props" || failed 'self-test: transitive pinning'
662
+ test_key=DirectoryPackages"PropsPath"
663
+ grep -Fq "export $test_key=" "$output/env" || failed 'self-test: env property'
664
+ passed=$((passed + 5))
665
+ st check "$repo" > "$root/output" || failed 'self-test: clean repo'
666
+ passed=$((passed + 1))
667
+ for test_key in stamp npm props sources override feed; do
668
+ case "$test_key" in
669
+ stamp) printf '%s\n' "$version" > "$repo/probe" ;;
670
+ npm) printf '{"resolutions":{"@cratis/test":"%s%s"}}\n' 'file:/tmp/' 'package.tgz' > "$repo/package.json" ;;
671
+ props) printf '%s=/tmp/overlay\n' DirectoryPackages"PropsPath" > "$repo/probe" ;;
672
+ sources) printf '%s=/tmp/feed\n' RestoreAdditional"ProjectSources" > "$repo/probe" ;;
673
+ override) printf '<PackageVersion %s="Cratis.Test" />\n' Update > "$repo/probe" ;;
674
+ feed) printf '%s\n' "$feed" > "$repo/probe" ;;
675
+ esac
676
+ status=0; st check "$repo" > "$root/output" || status=$?
677
+ [ "$status" -eq 1 ] || failed "self-test: did not find $test_key leak"
678
+ passed=$((passed + 1))
679
+ git -C "$repo" checkout -- probe package.json
680
+ done
681
+ printf '%s\n' "$version" > "$repo/untracked"
682
+ status=0; st check "$repo" > "$root/output" || status=$?
683
+ [ "$status" -eq 1 ] || failed 'self-test: untracked leak'
684
+ rm "$repo/untracked"; passed=$((passed + 1))
685
+ printf '%s\n' "$version" > "$repo/probe"; git -C "$repo" add probe
686
+ printf 'clean again\n' > "$repo/probe"
687
+ status=0; st check "$repo" --staged > "$root/output" || status=$?
688
+ [ "$status" -eq 1 ] || failed 'self-test: staged content'
689
+ git -C "$repo" restore --staged probe; git -C "$repo" checkout -- probe
690
+ passed=$((passed + 1))
691
+ if command -v node >/dev/null 2>&1; then
692
+ # Preserve pre-existing user edits as well as the committed contents.
693
+ st_npm use-npm "$repo" "$version"
694
+ grep -Fq '"@cratis/transitive": "file:' "$repo/package.json" || failed 'self-test: transitive npm resolution'
695
+ grep -Fq '"@cratis/test": "file:' "$repo/package.json" || failed 'self-test: direct npm resolution'
696
+ st_npm restore "$repo" > "$root/output"
697
+ passed=$((passed + 2))
698
+ printf '{"private":true,"dependencies":{"@cratis/test":"1.2.3"},"userSetting":true}\n' > "$repo/package.json"
699
+ cp "$repo/package.json" "$root/original-package.json"
700
+ cp "$repo/yarn.lock" "$root/original-yarn.lock"
701
+ st_npm use-npm "$repo" "$version"
702
+ # Model Yarn's replacement of a remote entry with a file-tarball entry.
703
+ node - "$repo/yarn.lock" "$feed/npm/@cratis-test-$version.tgz" "$version" <<'NODE'
704
+ const fs = require('fs');
705
+ const [file, input, stamp] = process.argv.slice(2);
706
+ const tarball = require('path').normalize(input);
707
+ const lock = fs.readFileSync(file, 'utf8');
708
+ fs.writeFileSync(file, lock.replace(/"@cratis\/test@npm:1\.2\.3":[\s\S]*?(?="unrelated)/,
709
+ `"@cratis/test@file:${tarball}::locator=consumer%40workspace%3A.":\n` +
710
+ ` version: ${stamp}\n resolution: "@cratis/test@file:${tarball}::hash=abc&locator=consumer%40workspace%3A."\n` +
711
+ ' checksum: local\n languageName: node\n linkType: hard\n\n'));
712
+ NODE
713
+ cp "$repo/yarn.lock" "$root/overlay-yarn.lock"
714
+ sed 's|^"@cratis/test@file:.*":$|"@cratis/test@npm:1.2.3":|' "$root/overlay-yarn.lock" > "$repo/yarn.lock"
715
+ node "$(dirname "$SELF")/npm-restore-check.cjs" "$repo" "$output/npm-backup" || failed 'self-test: local resolution with original descriptor'
716
+ passed=$((passed + 1))
717
+ cp "$root/overlay-yarn.lock" "$repo/yarn.lock"
718
+ printf '\n"unrelated-user-addition@npm:2":\n version: 2\n' >> "$repo/yarn.lock"
719
+ status=0; st_npm restore "$repo" > "$root/output" 2>&1 || status=$?
720
+ [ "$status" -eq 2 ] && [ -d "$output/npm-backup" ] || failed 'self-test: unrelated lockfile addition refused'
721
+ grep -Fq 'file:' "$repo/package.json" || failed 'self-test: partial restore on conflict'
722
+ passed=$((passed + 2))
723
+ sed 's/checksum: keep/checksum: user-edit/' "$root/overlay-yarn.lock" > "$repo/yarn.lock"
724
+ status=0; st_npm restore "$repo" > "$root/output" 2>&1 || status=$?
725
+ [ "$status" -eq 2 ] || failed 'self-test: unrelated lockfile edit refused'
726
+ passed=$((passed + 1))
727
+ cp "$root/overlay-yarn.lock" "$repo/yarn.lock"
728
+ node - "$repo/package.json" <<'NODE'
729
+ const fs = require('fs'), file = process.argv[2];
730
+ const json = JSON.parse(fs.readFileSync(file)); json.userSetting = false;
731
+ fs.writeFileSync(file, JSON.stringify(json));
732
+ NODE
733
+ status=0; st_npm restore "$repo" > "$root/output" 2>&1 || status=$?
734
+ [ "$status" -eq 2 ] || failed 'self-test: unrelated manifest edit refused'
735
+ passed=$((passed + 1))
736
+ cp "$output/npm-backup/expected-package.json" "$repo/package.json"
737
+ st_npm restore "$repo" > "$root/output"
738
+ cmp -s "$repo/package.json" "$root/original-package.json" || failed 'self-test: npm restore preserves prior manifest edits'
739
+ cmp -s "$repo/yarn.lock" "$root/original-yarn.lock" || failed 'self-test: npm restore after install'
740
+ [ ! -d "$output/npm-backup" ] || failed 'self-test: resolved backup removed'
741
+ passed=$((passed + 3))
742
+ git -C "$repo" checkout -- package.json
743
+ else printf 'SKIP: npm mutation/restore needs node on inherited PATH\n'; skipped=$((skipped + 10)); fi
744
+ mkdir -p "$home/.nuget/packages/cratis.test/$version" "$home/.nuget/packages/cratis.test/1.2.3"
745
+ mkdir -p "$home/.nuget/packages/cratis.test/1.2.3-local.keep"
746
+ for arg in nuget npm; do
747
+ : > "$feed/$arg/keep-local.notes.tgz"
748
+ : > "$feed/$arg/keep-local.202610221735.g12345678.tgz"
749
+ done
750
+ for arg in default --all; do
751
+ if [ "$arg" = default ]; then st clean > "$root/output"; else st clean "$arg" > "$root/output"; fi
752
+ grep -Fq "$version" "$root/output" || failed 'self-test: clean finds stamped subjects'
753
+ if grep -Eq 'keep-local|local.keep' "$root/output"; then failed 'self-test: clean selected non-stamp'; fi
754
+ passed=$((passed + 2))
755
+ done
756
+ st clean --stamp "$version" > "$root/output"
757
+ grep -Fq "$home/.nuget/packages/cratis.test/$version" "$root/output" || failed 'self-test: clean lists local cache'
758
+ [ -d "$home/.nuget/packages/cratis.test/$version" ] || failed 'self-test: dry-run mutated cache'
759
+ st clean --stamp "$version" --apply > "$root/output"
760
+ [ ! -e "$home/.nuget/packages/cratis.test/$version" ] || failed 'self-test: clean failed to remove cache'
761
+ [ -d "$home/.nuget/packages/cratis.test/1.2.3" ] || failed 'self-test: clean removed real version'
762
+ passed=$((passed + 4))
763
+ printf 'SKIP: real dotnet/Yarn/Docker packing is not a hermetic self-test\n'; skipped=$((skipped + 3))
764
+ printf 'self-test: %s passed, %s skipped\n' "$passed" "$skipped"
765
+ }
766
+
767
+ case "$MODE" in
768
+ stamp) stamp "$@" ;;
769
+ pack-nuget) pack_nuget "$@" ;;
770
+ pack-npm) pack_npm "$@" ;;
771
+ use-nuget) use_nuget "$@" ;;
772
+ use-npm) use_npm "$@" ;;
773
+ restore) restore "$@" ;;
774
+ check) check "$@" ;;
775
+ clean) clean "$@" ;;
776
+ --self-test) self_test "$@" ;;
777
+ *) die "unknown command: $MODE (use --help)" ;;
778
+ esac