@indigoai-us/hq-cloud 5.45.0 → 5.47.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 (64) hide show
  1. package/dist/bin/sync-runner.d.ts +12 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +78 -12
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +27 -1
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +17 -2
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +2 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync-scope.test.js +1 -0
  13. package/dist/cli/sync-scope.test.js.map +1 -1
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +11 -1
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/sync.test.js +1 -0
  18. package/dist/cli/sync.test.js.map +1 -1
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/object-io.d.ts +218 -0
  24. package/dist/object-io.d.ts.map +1 -0
  25. package/dist/object-io.js +588 -0
  26. package/dist/object-io.js.map +1 -0
  27. package/dist/object-io.test.d.ts +11 -0
  28. package/dist/object-io.test.d.ts.map +1 -0
  29. package/dist/object-io.test.js +568 -0
  30. package/dist/object-io.test.js.map +1 -0
  31. package/dist/s3.d.ts +37 -0
  32. package/dist/s3.d.ts.map +1 -1
  33. package/dist/s3.js +207 -198
  34. package/dist/s3.js.map +1 -1
  35. package/dist/skill-telemetry.d.ts +107 -0
  36. package/dist/skill-telemetry.d.ts.map +1 -0
  37. package/dist/skill-telemetry.js +395 -0
  38. package/dist/skill-telemetry.js.map +1 -0
  39. package/dist/skill-telemetry.test.d.ts +2 -0
  40. package/dist/skill-telemetry.test.d.ts.map +1 -0
  41. package/dist/skill-telemetry.test.js +219 -0
  42. package/dist/skill-telemetry.test.js.map +1 -0
  43. package/dist/vault-client.d.ts +91 -0
  44. package/dist/vault-client.d.ts.map +1 -1
  45. package/dist/vault-client.js +45 -0
  46. package/dist/vault-client.js.map +1 -1
  47. package/package.json +1 -1
  48. package/scripts/presign-transport-e2e.mjs +203 -0
  49. package/scripts/vault-rebaseline.sh +275 -0
  50. package/scripts/vault-rescue.sh +291 -0
  51. package/src/bin/sync-runner.test.ts +41 -0
  52. package/src/bin/sync-runner.ts +91 -13
  53. package/src/cli/share.test.ts +2 -0
  54. package/src/cli/share.ts +29 -2
  55. package/src/cli/sync-scope.test.ts +1 -0
  56. package/src/cli/sync.test.ts +1 -0
  57. package/src/cli/sync.ts +22 -1
  58. package/src/index.ts +16 -0
  59. package/src/object-io.test.ts +663 -0
  60. package/src/object-io.ts +782 -0
  61. package/src/s3.ts +259 -233
  62. package/src/skill-telemetry.test.ts +279 -0
  63. package/src/skill-telemetry.ts +499 -0
  64. package/src/vault-client.ts +135 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Live E2E for the presigned-URL transport (hq-cloud feat/presign-transport).
3
+ *
4
+ * Drives the real s3.ts primitives through the PRESIGN factory against
5
+ * production (hqapi.getindigo.ai), exercising the exact code path that
6
+ * @getindigo.ai sync sessions will use: VaultClient.presign/listFiles +
7
+ * PresignObjectIO + fetch. Creates a scratch object under a clearly-marked
8
+ * prefix, round-trips it (put → list → head → get → symlink → delete), and
9
+ * deletes everything it created. Read-only on all real vault data.
10
+ *
11
+ * Run from the worktree root after `pnpm build`:
12
+ * node scripts/presign-transport-e2e.mjs
13
+ */
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+ import * as path from "path";
17
+ import { VaultClient } from "../dist/vault-client.js";
18
+ import {
19
+ setObjectIOFactory,
20
+ presignObjectIOFactory,
21
+ } from "../dist/object-io.js";
22
+ import {
23
+ uploadFile,
24
+ uploadSymlink,
25
+ downloadFile,
26
+ listRemoteFiles,
27
+ headRemoteFile,
28
+ deleteRemoteFile,
29
+ } from "../dist/s3.js";
30
+
31
+ const API = process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
32
+
33
+ const results = [];
34
+ function check(name, cond, detail = "") {
35
+ results.push({ name, ok: !!cond, detail });
36
+ console.log(`${cond ? "✓" : "✗"} ${name}${detail ? ` — ${detail}` : ""}`);
37
+ }
38
+
39
+ function loadAccessToken() {
40
+ const p = path.join(os.homedir(), ".hq", "cognito-tokens.json");
41
+ const t = JSON.parse(fs.readFileSync(p, "utf-8"));
42
+ const at = t.accessToken;
43
+ if (!at) throw new Error("no accessToken in cognito-tokens.json");
44
+ return at;
45
+ }
46
+
47
+ async function main() {
48
+ const token = loadAccessToken();
49
+ const vault = new VaultClient({ apiUrl: API, authToken: token });
50
+
51
+ // Resolve a company the caller can write to (owner/admin role).
52
+ const memberships = await vault.listMyMemberships();
53
+ const writable = memberships.find(
54
+ (m) => m.role === "owner" || m.role === "admin",
55
+ );
56
+ if (!writable) {
57
+ throw new Error(
58
+ `no owner/admin membership found (roles: ${memberships.map((m) => m.role).join(",") || "none"})`,
59
+ );
60
+ }
61
+ const companyUid = writable.companyUid;
62
+ let slug = companyUid;
63
+ try {
64
+ slug = (await vault.entity.get(companyUid)).slug ?? companyUid;
65
+ } catch {
66
+ /* display only */
67
+ }
68
+ console.log(`\nTarget: ${slug} (${companyUid}), role=${writable.role}\n`);
69
+
70
+ // Install the presign transport — the same selection runRunner makes for a
71
+ // @getindigo.ai session. ctx.credentials/bucketName/region are UNUSED on the
72
+ // presign path (only ctx.uid → companyUid matters), so dummy values are fine.
73
+ setObjectIOFactory(presignObjectIOFactory(vault));
74
+ const ctx = {
75
+ uid: companyUid,
76
+ slug,
77
+ bucketName: "(presign-unused)",
78
+ region: "us-east-1",
79
+ credentials: { accessKeyId: "x", secretAccessKey: "x", sessionToken: "x" },
80
+ expiresAt: "2099-01-01T00:00:00.000Z",
81
+ };
82
+
83
+ const stamp = Date.now().toString(36);
84
+ const prefix = `shared/_presign_e2e_${stamp}/`;
85
+ const fileKey = `${prefix}probe.txt`;
86
+ const linkKey = `${prefix}link`;
87
+ const author = { userSub: "e2e-probe-sub", email: "hassaan@getindigo.ai" };
88
+
89
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "presign-e2e-"));
90
+ const localUp = path.join(tmp, "up.txt");
91
+ const localDown = path.join(tmp, "down.txt");
92
+ const localLink = path.join(tmp, "link");
93
+ const content = `presign-transport probe ${stamp}\n${"x".repeat(2000)}`;
94
+ fs.writeFileSync(localUp, content);
95
+ fs.chmodSync(localUp, 0o640);
96
+
97
+ let allCreated = [];
98
+ try {
99
+ // ---- PUT (presigned, with metadata signed in) -----------------------
100
+ const up = await uploadFile(ctx, localUp, fileKey, author);
101
+ allCreated.push(fileKey);
102
+ check("uploadFile returns an etag", !!up.etag, up.etag);
103
+
104
+ // ---- LIST (presigned list, etag is load-bearing) --------------------
105
+ const listed = await listRemoteFiles(ctx, prefix);
106
+ const row = listed.find((o) => o.key === fileKey);
107
+ check("listRemoteFiles finds the uploaded key", !!row);
108
+ check(
109
+ "listed object carries a non-empty etag (#269)",
110
+ !!row && typeof row.etag === "string" && row.etag.length > 0,
111
+ row?.etag,
112
+ );
113
+ check(
114
+ "listed size matches uploaded bytes",
115
+ !!row && row.size === Buffer.byteLength(content),
116
+ `${row?.size} vs ${Buffer.byteLength(content)}`,
117
+ );
118
+
119
+ // ---- HEAD (presigned GET + header read) -----------------------------
120
+ const head = await headRemoteFile(ctx, fileKey);
121
+ check("headRemoteFile returns metadata", !!head && !!head.metadata);
122
+ check(
123
+ "metadata.created-by round-trips (signed PUT meta)",
124
+ head?.metadata?.["created-by"] === author.email,
125
+ head?.metadata?.["created-by"],
126
+ );
127
+ check(
128
+ "metadata.hq-mode round-trips as 640",
129
+ head?.metadata?.["hq-mode"] === "640",
130
+ head?.metadata?.["hq-mode"],
131
+ );
132
+ check(
133
+ "head etag matches list etag",
134
+ !!head && !!row && head.etag === row.etag,
135
+ `${head?.etag} vs ${row?.etag}`,
136
+ );
137
+
138
+ // ---- GET (presigned download, bytes + mode applied) -----------------
139
+ const dl = await downloadFile(ctx, fileKey, localDown);
140
+ const got = fs.readFileSync(localDown, "utf-8");
141
+ check("downloadFile bytes match upload", got === content);
142
+ check(
143
+ "downloaded file mode is 0640 (hq-mode applied)",
144
+ (fs.lstatSync(localDown).mode & 0o777) === 0o640,
145
+ "0" + (fs.lstatSync(localDown).mode & 0o777).toString(8),
146
+ );
147
+ check(
148
+ "downloadFile surfaces created-by metadata",
149
+ dl?.metadata?.["created-by"] === author.email,
150
+ );
151
+
152
+ // ---- SYMLINK round-trip --------------------------------------------
153
+ const ln = await uploadSymlink(ctx, "../target/path.txt", linkKey, author);
154
+ allCreated.push(linkKey);
155
+ check("uploadSymlink returns an etag", !!ln.etag);
156
+ await downloadFile(ctx, linkKey, localLink);
157
+ const st = fs.lstatSync(localLink);
158
+ check("downloaded symlink is a symlink", st.isSymbolicLink());
159
+ check(
160
+ "symlink target round-trips",
161
+ st.isSymbolicLink() && fs.readlinkSync(localLink) === "../target/path.txt",
162
+ st.isSymbolicLink() ? fs.readlinkSync(localLink) : "(not a link)",
163
+ );
164
+
165
+ // ---- DELETE (presigned) --------------------------------------------
166
+ await deleteRemoteFile(ctx, fileKey);
167
+ await deleteRemoteFile(ctx, linkKey);
168
+ allCreated = [];
169
+ const afterFile = await headRemoteFile(ctx, fileKey);
170
+ check("headRemoteFile is null after delete", afterFile === null);
171
+ const afterList = await listRemoteFiles(ctx, prefix);
172
+ check(
173
+ "listRemoteFiles empty after delete",
174
+ afterList.filter((o) => o.key.startsWith(prefix)).length === 0,
175
+ `${afterList.length} remaining`,
176
+ );
177
+ } finally {
178
+ // Best-effort cleanup of anything left if an assertion threw mid-run.
179
+ for (const k of allCreated) {
180
+ try {
181
+ await deleteRemoteFile(ctx, k);
182
+ } catch {
183
+ /* ignore */
184
+ }
185
+ }
186
+ fs.rmSync(tmp, { recursive: true, force: true });
187
+ }
188
+
189
+ const failed = results.filter((r) => !r.ok);
190
+ console.log(
191
+ `\n${results.length - failed.length}/${results.length} checks passed`,
192
+ );
193
+ if (failed.length > 0) {
194
+ console.log("FAILED:", failed.map((f) => f.name).join("; "));
195
+ process.exit(1);
196
+ }
197
+ console.log("ALL GREEN");
198
+ }
199
+
200
+ main().catch((err) => {
201
+ console.error("E2E error:", err);
202
+ process.exit(2);
203
+ });
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bash
2
+ # vault-rebaseline.sh — overlay hq-core@vX.Y.Z baseline content onto a vault.
3
+ #
4
+ # For vaults that pre-date core/core.yaml (pre-v15 partial cloud snapshots
5
+ # missing the companies/ + personal/ structure). `replace-rescue.sh` refuses
6
+ # to operate on those vaults because its safety gate requires HQ-root layout;
7
+ # this script provides a narrower, additive alternative: push the v15
8
+ # baseline tree (.claude/, .agents/, .codex/, core/, companies/_template/,
9
+ # personal/, etc.) into the vault, preserving everything the user already
10
+ # has there. Symlinks are serialized to `hq-symlink:<target>` markers so
11
+ # they round-trip cleanly through hq-sync's protocol.
12
+ #
13
+ # Purely additive by default — no `--delete`. Their v14-era root files
14
+ # (CHANGELOG.md, RELEASE-NOTES-v14.0.0.md, etc.) remain in S3 until they
15
+ # either upgrade locally (menubar Update HQ) or we run a follow-up cleanup.
16
+ # Pass `--delete-v14-cruft` to remove a hardcoded set of known v14-only
17
+ # root files in the same run.
18
+ #
19
+ # Usage:
20
+ #
21
+ # vault-rebaseline.sh --prs <prs_*> [--version X.Y.Z] [opts...]
22
+ #
23
+ # Required:
24
+ # --prs <prs_*> Entity UID. Bucket = hq-vault-<lower-uid-w/-dashes>.
25
+ #
26
+ # Optional:
27
+ # --version <X.Y.Z> Default 15.0.4 (current latest release).
28
+ # --source <owner/repo> Default indigoai-us/hq-core. Override e.g. with
29
+ # indigoai-us/hq-core-staging (would use --ref main
30
+ # when version is not a tag).
31
+ # --dry-run Show plan, don't push.
32
+ # --keep-tmp Preserve the baseline clone + staging dirs.
33
+ # --delete-v14-cruft Also delete a known set of v14 root files
34
+ # (CHANGELOG.md, README.md, LICENSE, setup.sh,
35
+ # RELEASE-NOTES-v14.0.0.md, MIGRATION-v11.md,
36
+ # USER-GUIDE.md, CONTRIBUTING.md) so their
37
+ # local install drops them on next sync.
38
+ # --profile/--region AWS config. Default $HQ_VAULT_AWS_PROFILE /
39
+ # us-east-1.
40
+ #
41
+ # Auth: uses the operator's standing AWS S3 perms directly. Operator/admin
42
+ # only — vault-service STS vending is bypassed.
43
+
44
+ set -euo pipefail
45
+
46
+ PRS=""
47
+ VERSION="15.0.4"
48
+ SOURCE_REPO="indigoai-us/hq-core"
49
+ DRY_RUN=0
50
+ KEEP_TMP=0
51
+ DELETE_CRUFT=0
52
+ AWS_PROFILE_OVERRIDE="${HQ_VAULT_AWS_PROFILE:-}"
53
+ AWS_REGION_OVERRIDE="${HQ_VAULT_AWS_REGION:-us-east-1}"
54
+
55
+ usage() {
56
+ sed -n '2,40p' "$0"
57
+ exit 2
58
+ }
59
+
60
+ while [ $# -gt 0 ]; do
61
+ case "$1" in
62
+ --prs) PRS="$2"; shift 2 ;;
63
+ --version) VERSION="$2"; shift 2 ;;
64
+ --source) SOURCE_REPO="$2"; shift 2 ;;
65
+ --dry-run) DRY_RUN=1; shift ;;
66
+ --keep-tmp) KEEP_TMP=1; shift ;;
67
+ --delete-v14-cruft) DELETE_CRUFT=1; shift ;;
68
+ --profile) AWS_PROFILE_OVERRIDE="$2"; shift 2 ;;
69
+ --region) AWS_REGION_OVERRIDE="$2"; shift 2 ;;
70
+ -h|--help) usage ;;
71
+ *) echo "unknown arg: $1" >&2; usage ;;
72
+ esac
73
+ done
74
+
75
+ [ -n "$PRS" ] || { echo "error: --prs is required" >&2; usage; }
76
+ case "$PRS" in
77
+ prs_*) ;;
78
+ *) echo "error: --prs must look like prs_*; got '$PRS'" >&2; exit 2 ;;
79
+ esac
80
+
81
+ BUCKET="hq-vault-$(printf '%s' "$PRS" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
82
+
83
+ # Construct git ref. Tagged releases live in hq-core as v<version>; staging
84
+ # uses main and treats --version as informational.
85
+ case "$SOURCE_REPO" in
86
+ *hq-core-staging) REF="main" ;;
87
+ *) REF="v$VERSION" ;;
88
+ esac
89
+
90
+ AWS_ARGS=()
91
+ [ -n "$AWS_PROFILE_OVERRIDE" ] && AWS_ARGS+=(--profile "$AWS_PROFILE_OVERRIDE")
92
+ AWS_ARGS+=(--region "$AWS_REGION_OVERRIDE")
93
+
94
+ # Tmp dirs.
95
+ BASELINE="$(mktemp -d -t hq-rebaseline-baseline-XXXXXX)"
96
+ STAGING="$(mktemp -d -t hq-rebaseline-stage-XXXXXX)"
97
+
98
+ cleanup() {
99
+ local rc=$?
100
+ if [ "$KEEP_TMP" = "1" ]; then
101
+ echo "==> --keep-tmp set; preserving $BASELINE and $STAGING" >&2
102
+ else
103
+ rm -rf "${BASELINE:-}" "${STAGING:-}"
104
+ fi
105
+ return $rc
106
+ }
107
+ trap cleanup EXIT
108
+
109
+ cat <<EOF
110
+ ==> vault-rebaseline
111
+ prs: $PRS
112
+ bucket: s3://$BUCKET
113
+ baseline: $SOURCE_REPO @ $REF (version $VERSION)
114
+ baseline tmp: $BASELINE
115
+ staging tmp: $STAGING
116
+ dry-run: $([ "$DRY_RUN" = "1" ] && echo "ON" || echo "off")
117
+ delete v14: $([ "$DELETE_CRUFT" = "1" ] && echo "ON" || echo "off")
118
+ keep-tmp: $([ "$KEEP_TMP" = "1" ] && echo "yes" || echo "no")
119
+ EOF
120
+
121
+ # ---------- phase 1: clone baseline ----------
122
+ echo "" >&2
123
+ echo "==> [1/3] clone $SOURCE_REPO@$REF (depth=1)" >&2
124
+ git clone --quiet --depth 1 --branch "$REF" "https://github.com/$SOURCE_REPO.git" "$BASELINE" 2>&1 | tail -3 || {
125
+ echo "error: clone failed. Is $SOURCE_REPO@$REF a valid ref?" >&2
126
+ exit 1
127
+ }
128
+ echo "==> clone done. baseline files: $(find "$BASELINE" -type f | wc -l | tr -d ' ') (excl .git)" >&2
129
+
130
+ # ---------- phase 2: stage with symlink serialization ----------
131
+ echo "" >&2
132
+ echo "==> [2/3] stage baseline tree (serialize symlinks → hq-symlink: markers)" >&2
133
+
134
+ n_files=0; n_syms=0
135
+ SYMLIST="$STAGING.symlinks.txt"
136
+ : > "$SYMLIST"
137
+
138
+ while IFS= read -r -d '' src; do
139
+ rel="${src#"$BASELINE/"}"
140
+ [ "$rel" = "$src" ] && continue
141
+ [ -z "$rel" ] && continue
142
+
143
+ # Skip the clone's .git directory (we're shipping just the tree, not the repo).
144
+ case "$rel" in
145
+ .git|.git/*) continue ;;
146
+ esac
147
+
148
+ dest="$STAGING/$rel"
149
+ mkdir -p "$(dirname "$dest")"
150
+
151
+ if [ -L "$src" ]; then
152
+ # Read the symlink's literal target string (apps/hq-cloud/src/s3.ts:417).
153
+ target="$(readlink "$src")"
154
+ printf 'hq-symlink:%s' "$target" > "$dest"
155
+ printf '%s\n' "$rel" >> "$SYMLIST"
156
+ n_syms=$((n_syms + 1))
157
+ elif [ -f "$src" ]; then
158
+ cp -p "$src" "$dest"
159
+ n_files=$((n_files + 1))
160
+ fi
161
+ done < <(find "$BASELINE" \( -type d -name .git -prune \) -o \( \( -type f -o -type l \) -print0 \))
162
+
163
+ echo " staged: $n_files files, $n_syms symlinks" >&2
164
+
165
+ # Strip files from staging that are KNOWN user-state — baseline ships
166
+ # default values for these but the vault almost certainly has the user's
167
+ # own customizations. Overwriting them would clobber config that lives
168
+ # nowhere else (the menubar's hq-cloud/ignore.ts excludes some of these
169
+ # from cloud sync, but earlier vault versions uploaded them).
170
+ #
171
+ # Each entry is a relative path inside the staging tree. Removed BEFORE
172
+ # `aws s3 sync` runs so the file is not even a candidate for upload.
173
+ PRESERVE_USER_STATE=(
174
+ # Company registry — user-owned, registers their connected companies'
175
+ # cloud_uid + bucket_name. v15 baseline ships an empty stub.
176
+ "companies/manifest.yaml"
177
+ # Claude Code prefs — user-customized model, env vars, hooks. v15 ships
178
+ # opinionated defaults that would overwrite e.g. MAX_THINKING_TOKENS,
179
+ # CLAUDE_CODE_SUBAGENT_MODEL, hook routing.
180
+ ".claude/settings.json"
181
+ # Obsidian UI state — pane layout, bookmarks, graph view. v15 ships
182
+ # defaults; users with active Obsidian have their own.
183
+ ".obsidian/workspace.json"
184
+ ".obsidian/graph.json"
185
+ ".obsidian/bookmarks.json"
186
+ ".obsidian/app.json"
187
+ ".obsidian/appearance.json"
188
+ ".obsidian/core-plugins.json"
189
+ )
190
+ n_preserved=0
191
+ for rel in "${PRESERVE_USER_STATE[@]}"; do
192
+ if [ -f "$STAGING/$rel" ]; then
193
+ rm -f "$STAGING/$rel"
194
+ n_preserved=$((n_preserved + 1))
195
+ echo " preserving user state (skipped from upload): $rel" >&2
196
+ fi
197
+ done
198
+
199
+ # ---------- phase 3: push ----------
200
+ echo "" >&2
201
+ echo "==> [3/3] aws s3 sync $STAGING/ → s3://$BUCKET/" >&2
202
+
203
+ # `--size-only` because staging mtime is fresh and S3 LastModified reflects
204
+ # original upload time. Default mtime-based comparison would re-upload every
205
+ # content-stable file (same problem as vault-rescue.sh's push phase).
206
+ #
207
+ # No `--delete` by default. v14 baseline files coexist with v15 in S3 until
208
+ # either the user upgrades locally (menubar Update HQ) or --delete-v14-cruft
209
+ # is passed (handled below as a separate rm pass for a narrow allowlist).
210
+ SYNC_ARGS=("s3" "sync" "$STAGING/" "s3://$BUCKET/" "--size-only")
211
+ [ "$DRY_RUN" = "1" ] && SYNC_ARGS+=("--dryrun")
212
+
213
+ aws "${AWS_ARGS[@]}" "${SYNC_ARGS[@]}"
214
+
215
+ # Stamp the hq-symlink-target metadata header on every marker so the menubar
216
+ # pull materializes them as real symlinks (apps/hq-cloud/src/s3.ts:466-468
217
+ # prefers header detection; body sniff is fallback).
218
+ n_stamped=0
219
+ if [ "$DRY_RUN" = "0" ] && [ -s "$SYMLIST" ]; then
220
+ echo "" >&2
221
+ echo " stamping hq-symlink-target metadata on $(wc -l < "$SYMLIST" | tr -d ' ') markers" >&2
222
+ while IFS= read -r rel; do
223
+ [ -z "$rel" ] && continue
224
+ aws "${AWS_ARGS[@]}" s3api put-object \
225
+ --bucket "$BUCKET" \
226
+ --key "$rel" \
227
+ --body "$STAGING/$rel" \
228
+ --metadata "hq-symlink-target=1" \
229
+ --content-type "text/plain" \
230
+ >/dev/null
231
+ n_stamped=$((n_stamped + 1))
232
+ done < "$SYMLIST"
233
+ echo " stamped: $n_stamped" >&2
234
+ fi
235
+
236
+ # Optional: delete v14-era root files that don't exist in v15. Narrow
237
+ # allowlist — files we can confidently say were always release-shipped and
238
+ # would never be user-customized. Everything else stays.
239
+ n_deleted=0
240
+ if [ "$DELETE_CRUFT" = "1" ]; then
241
+ echo "" >&2
242
+ echo " deleting v14 root-file cruft" >&2
243
+ V14_CRUFT=(
244
+ "CHANGELOG.md"
245
+ "README.md"
246
+ "LICENSE"
247
+ "setup.sh"
248
+ "RELEASE-NOTES-v14.0.0.md"
249
+ "MIGRATION-v11.md"
250
+ "USER-GUIDE.md"
251
+ "CONTRIBUTING.md"
252
+ )
253
+ for key in "${V14_CRUFT[@]}"; do
254
+ # Probe first — only delete if it actually exists.
255
+ if aws "${AWS_ARGS[@]}" s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then
256
+ if [ "$DRY_RUN" = "1" ]; then
257
+ echo " would delete: $key" >&2
258
+ else
259
+ aws "${AWS_ARGS[@]}" s3api delete-object --bucket "$BUCKET" --key "$key" >/dev/null
260
+ echo " deleted: $key" >&2
261
+ fi
262
+ n_deleted=$((n_deleted + 1))
263
+ fi
264
+ done
265
+ fi
266
+
267
+ echo "" >&2
268
+ echo "==> vault-rebaseline complete." >&2
269
+ echo " bucket: s3://$BUCKET" >&2
270
+ echo " version: $VERSION ($SOURCE_REPO@$REF)" >&2
271
+ echo " pushed: $n_files files, $n_syms symlinks (gross — preserved excluded)" >&2
272
+ echo " user-state preserved: $n_preserved files" >&2
273
+ echo " stamped: $n_stamped" >&2
274
+ echo " deleted: $n_deleted (v14 cruft)" >&2
275
+ exit 0