skillrepo 4.5.1 → 4.6.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.json +1 -1
- package/src/commands/add.mjs +2 -2
- package/src/commands/get.mjs +1 -1
- package/src/commands/list.mjs +36 -10
- package/src/lib/http.mjs +72 -4
- package/src/lib/sync.mjs +199 -4
- package/src/test/commands/list.test.mjs +14 -0
- package/src/test/e2e/mock-server.mjs +185 -2
- package/src/test/integration/update-list-contract.integration.test.mjs +575 -28
- package/src/test/lib/http.test.mjs +103 -0
- package/src/test/lib/mcp-merge.test.mjs +2 -2
- package/src/test/lib/sync.test.mjs +343 -0
- package/src/test/mergers/claude-mcp.test.mjs +4 -4
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +1 -1
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +1 -1
package/package.json
CHANGED
package/src/commands/add.mjs
CHANGED
|
@@ -196,11 +196,11 @@ export async function runAdd(argv, io = {}) {
|
|
|
196
196
|
.join(", ")})`;
|
|
197
197
|
if (wasNewlyAdded) {
|
|
198
198
|
stdout.write(
|
|
199
|
-
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} files) → ${where}\n\n`,
|
|
199
|
+
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
200
200
|
);
|
|
201
201
|
} else {
|
|
202
202
|
stdout.write(
|
|
203
|
-
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} files) → ${where}\n\n`,
|
|
203
|
+
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
}
|
package/src/commands/get.mjs
CHANGED
|
@@ -143,6 +143,6 @@ export async function runGet(argv, io = {}) {
|
|
|
143
143
|
.map(describePlacementTarget)
|
|
144
144
|
.join(", ")})`;
|
|
145
145
|
stdout.write(
|
|
146
|
-
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} files) → ${where}\n\n`,
|
|
146
|
+
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
147
147
|
);
|
|
148
148
|
}
|
package/src/commands/list.mjs
CHANGED
|
@@ -56,7 +56,14 @@ export async function runList(argv, io = {}) {
|
|
|
56
56
|
const stdout = io.stdout ?? process.stdout;
|
|
57
57
|
const flags = resolveFlags(argv);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// `list` is a read-only drift check — it must NEVER set sync state.
|
|
60
|
+
// Use the manifest read (#1832): metadata only, no file bodies, and the
|
|
61
|
+
// server records no delivery for it. Per-skill drift is computed from
|
|
62
|
+
// on-disk SHAs + `.last-sync` below, never from the response body, so
|
|
63
|
+
// the empty `files` array a manifest returns is irrelevant here.
|
|
64
|
+
const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey, {
|
|
65
|
+
manifest: true,
|
|
66
|
+
});
|
|
60
67
|
|
|
61
68
|
// Defensive guard: `list` calls getLibrary without an If-None-Match
|
|
62
69
|
// header so it can't legitimately receive a 304 today. But getLibrary's
|
|
@@ -243,9 +250,13 @@ function printTable(augmented, detected, out) {
|
|
|
243
250
|
.join(", ");
|
|
244
251
|
out.write(`\n Detected: ${detectedLabel}\n`);
|
|
245
252
|
} else {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
// Earlier copy said "drift cannot be reported" but the table
|
|
254
|
+
// below DOES report drift — every skill rolls up as MISSING
|
|
255
|
+
// because no placement exists to compare against. The simpler
|
|
256
|
+
// statement of fact is what users need; the footer ("No sync
|
|
257
|
+
// history…" / "library has changed…") tells them what to do
|
|
258
|
+
// next.
|
|
259
|
+
out.write("\n No agents detected in this project.\n");
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
const sorted = augmented.slice().sort(sortByOwnerAndName);
|
|
@@ -337,12 +348,27 @@ function printFooter(augmented, libraryEtag, lastSync, out, useGlyphs) {
|
|
|
337
348
|
}
|
|
338
349
|
|
|
339
350
|
if (etagMatches && driftCount > 0) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
351
|
+
// Distinguish EDITED (running update loses user changes) from
|
|
352
|
+
// MISS/STALE (running update is non-destructive — fills gaps or
|
|
353
|
+
// pulls a newer version). The earlier copy said "Run `skillrepo
|
|
354
|
+
// update` to refresh" for every drift case, which silently
|
|
355
|
+
// destroyed user edits when EDITED rows were present. Now we
|
|
356
|
+
// warn explicitly when edits are at risk.
|
|
357
|
+
const editedCount = augmented.filter(
|
|
358
|
+
(s) => s.state === SKILL_STATE.EDITED,
|
|
359
|
+
).length;
|
|
360
|
+
const driftPhrase = `${driftCount} skill${driftCount === 1 ? "" : "s"} show${driftCount === 1 ? "s" : ""}`;
|
|
361
|
+
if (editedCount > 0) {
|
|
362
|
+
out.write(
|
|
363
|
+
`\n ${warn} library in sync — but ${driftPhrase} local drift, including ${editedCount} with local edit${editedCount === 1 ? "" : "s"}.\n` +
|
|
364
|
+
" Run `skillrepo update` to refresh — this will OVERWRITE local edits.\n\n",
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
out.write(
|
|
368
|
+
`\n ${warn} library in sync — but ${driftPhrase} local drift.\n` +
|
|
369
|
+
" Run `skillrepo update` to refresh.\n\n",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
346
372
|
return;
|
|
347
373
|
}
|
|
348
374
|
|
package/src/lib/http.mjs
CHANGED
|
@@ -305,10 +305,20 @@ async function mapErrorResponse(res, url) {
|
|
|
305
305
|
if (res.status === 401) {
|
|
306
306
|
// Derive the auth URL from the request URL's origin so users
|
|
307
307
|
// running against staging see a staging hint, not a prod hint.
|
|
308
|
-
// `url` here is the full request URL
|
|
309
|
-
// https://staging.skillrepo.dev/api/v1/library
|
|
310
|
-
//
|
|
311
|
-
// `
|
|
308
|
+
// `url` here is the full request URL — typically
|
|
309
|
+
// `https://staging.skillrepo.dev/api/v1/library` (canonical) or
|
|
310
|
+
// `https://api.staging.skillrepo.dev/v1/library` (subdomain alias
|
|
311
|
+
// per #1588). `new URL(...).origin` strips back to the host —
|
|
312
|
+
// `https://staging.skillrepo.dev` or `https://api.staging.skillrepo.dev`
|
|
313
|
+
// — and `cliAuthUrl` builds the `/cli/auth` URL from there.
|
|
314
|
+
//
|
|
315
|
+
// Known limitation: if the request URL uses the api.* / mcp.*
|
|
316
|
+
// subdomain, the resulting auth hint URL points at that subdomain
|
|
317
|
+
// (`https://api.staging.skillrepo.dev/cli/auth`), which 404s
|
|
318
|
+
// because /cli/auth only lives on the canonical host. Today the
|
|
319
|
+
// CLI defaults to the canonical URL so this is latent; surfaces
|
|
320
|
+
// only when a user passes `--url https://api.<env>.skillrepo.dev`
|
|
321
|
+
// explicitly. Tracked separately — not blocking this PR.
|
|
312
322
|
let authUrl = DEFAULT_CLI_AUTH_URL;
|
|
313
323
|
try {
|
|
314
324
|
authUrl = cliAuthUrl(new URL(url).origin);
|
|
@@ -526,12 +536,20 @@ export async function validateAccessKey(serverUrl, apiKey, source = "validate")
|
|
|
526
536
|
* @param {object} [opts]
|
|
527
537
|
* @param {string} [opts.ifNoneMatch] - ETag to send as If-None-Match
|
|
528
538
|
* @param {string} [opts.since] - ISO timestamp for delta filter
|
|
539
|
+
* @param {boolean} [opts.manifest] - Request a metadata-only manifest:
|
|
540
|
+
* the server returns empty `files` for every skill and records NO
|
|
541
|
+
* delivery. Used by `skillrepo list` for a read-only drift check that
|
|
542
|
+
* must not set sync state (#1832).
|
|
529
543
|
* @returns {Promise<LibrarySyncResult>}
|
|
530
544
|
*/
|
|
531
545
|
export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
532
546
|
const base = normalizeUrl(serverUrl);
|
|
533
547
|
const params = new URLSearchParams();
|
|
534
548
|
if (opts.since) params.set("since", opts.since);
|
|
549
|
+
// Manifest mode (#1832): metadata-only, non-recording read used by
|
|
550
|
+
// `skillrepo list`. A peek must not set sync state, so the server
|
|
551
|
+
// returns empty `files` and records no delivery for this request.
|
|
552
|
+
if (opts.manifest) params.set("manifest", "1");
|
|
535
553
|
const url = params.toString()
|
|
536
554
|
? `${base}/api/v1/library?${params.toString()}`
|
|
537
555
|
: `${base}/api/v1/library`;
|
|
@@ -573,6 +591,56 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
|
573
591
|
};
|
|
574
592
|
}
|
|
575
593
|
|
|
594
|
+
/**
|
|
595
|
+
* POST /api/v1/library/sync-receipts — report confirmed local writes (#1832).
|
|
596
|
+
*
|
|
597
|
+
* Called by `runSync` AFTER it successfully writes skill files to disk.
|
|
598
|
+
* Carries the (owner, name, version) pairs actually written so the server
|
|
599
|
+
* records sync state from a confirmed write, never from a fetch response.
|
|
600
|
+
*
|
|
601
|
+
* Best-effort + retryable: the receipt is telemetry and the files are
|
|
602
|
+
* already on disk by the time we call this, so callers treat a failure as
|
|
603
|
+
* a non-fatal warning. Transient 5xx / network failures retry — the server
|
|
604
|
+
* dedups by `(user, skill, version, UTC day)` and stamps idempotently, so
|
|
605
|
+
* a replay is safe. A non-2xx after retries throws a typed error.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} serverUrl
|
|
608
|
+
* @param {string} apiKey
|
|
609
|
+
* @param {object} receipt
|
|
610
|
+
* @param {{owner: string, name: string, version: string}[]} receipt.skills
|
|
611
|
+
* The skills written to disk this sync. Empty array is a valid no-op.
|
|
612
|
+
* @param {string} receipt.syncedAt - ISO timestamp of the sync.
|
|
613
|
+
* @returns {Promise<{recorded: number}>}
|
|
614
|
+
*/
|
|
615
|
+
export async function postSyncReceipt(serverUrl, apiKey, receipt) {
|
|
616
|
+
const url = `${normalizeUrl(serverUrl)}/api/v1/library/sync-receipts`;
|
|
617
|
+
const res = await safeFetch(url, {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers: {
|
|
620
|
+
...(await authHeaders(apiKey)),
|
|
621
|
+
"Content-Type": "application/json",
|
|
622
|
+
},
|
|
623
|
+
body: JSON.stringify({
|
|
624
|
+
syncedAt: receipt.syncedAt,
|
|
625
|
+
skills: receipt.skills,
|
|
626
|
+
}),
|
|
627
|
+
// Idempotent server-side (dedup bucket + idempotent UPDATE), so a
|
|
628
|
+
// transient 5xx / network blip is safe to retry.
|
|
629
|
+
retry: true,
|
|
630
|
+
});
|
|
631
|
+
if (!res.ok) {
|
|
632
|
+
const err = await mapErrorResponse(res, url);
|
|
633
|
+
if (err === null) {
|
|
634
|
+
throw validationError(
|
|
635
|
+
`The server at ${normalizeUrl(serverUrl)} does not expose ` +
|
|
636
|
+
`/api/v1/library/sync-receipts. Is --url correct?`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
throw err;
|
|
640
|
+
}
|
|
641
|
+
return parseJsonOrThrow(res, url);
|
|
642
|
+
}
|
|
643
|
+
|
|
576
644
|
// ── /api/v1/skills/[owner]/[name] ───────────────────────────────────────
|
|
577
645
|
|
|
578
646
|
/**
|
package/src/lib/sync.mjs
CHANGED
|
@@ -85,8 +85,9 @@
|
|
|
85
85
|
*/
|
|
86
86
|
|
|
87
87
|
import { existsSync, readFileSync } from "node:fs";
|
|
88
|
+
import { join } from "node:path";
|
|
88
89
|
|
|
89
|
-
import { getLibrary } from "./http.mjs";
|
|
90
|
+
import { getLibrary, postSyncReceipt } from "./http.mjs";
|
|
90
91
|
import {
|
|
91
92
|
writeSkillDir,
|
|
92
93
|
removeSkillDir,
|
|
@@ -110,6 +111,103 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
110
111
|
*/
|
|
111
112
|
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Check whether every skill in the `.last-sync` baseline already exists
|
|
116
|
+
* under every placement target the current sync would write to. Returns
|
|
117
|
+
* true when the 304 short-circuit is safe (skipping writes won't leave
|
|
118
|
+
* any placement empty), false when at least one (skill, target) pair
|
|
119
|
+
* is missing on disk.
|
|
120
|
+
*
|
|
121
|
+
* This is the load-bearing guard for the ETag fast path. The contract:
|
|
122
|
+
* "library content unchanged AND every expected placement already has
|
|
123
|
+
* its content" — the second clause is what this function enforces.
|
|
124
|
+
*
|
|
125
|
+
* See the call site in runSync for the full motivation (upgrade-path,
|
|
126
|
+
* --global ↔ project swap, and cwd-switch scenarios all funnel through
|
|
127
|
+
* this gate).
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, {skillMdSha256: string, filesSha256: string, version: string}> | null | undefined} skillsMap
|
|
130
|
+
* The `.skills` map from `.last-sync` — keyed by `"<owner>/<name>"`.
|
|
131
|
+
* @param {string[] | undefined} vendors
|
|
132
|
+
* The current sync's vendor list (output of `effectiveVendors`).
|
|
133
|
+
* @param {boolean | undefined} global
|
|
134
|
+
* Whether the current sync is `--global` mode.
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function placementsAreComplete(skillsMap, vendors, global) {
|
|
138
|
+
// Empty baseline: we cannot trust the ETag short-circuit. Two
|
|
139
|
+
// legitimate code paths land here:
|
|
140
|
+
// 1. Genuine fresh install — no `.last-sync` file at all; this
|
|
141
|
+
// function isn't called because runSync's caller checks
|
|
142
|
+
// `lastSync?.etag` before invoking. So we never see this
|
|
143
|
+
// branch in the fresh-install case.
|
|
144
|
+
// 2. v1 → v2 migration — `readLastSync` synthesizes an empty
|
|
145
|
+
// `skills` map for a v1 file (which had no per-skill SHA
|
|
146
|
+
// cache). The etag IS present but the baseline is empty;
|
|
147
|
+
// a 304 would tell us "library unchanged" but we'd still
|
|
148
|
+
// have no per-skill cache, so `list` would render every
|
|
149
|
+
// skill as MISS. Forcing a re-fetch here populates the
|
|
150
|
+
// cache and surfaces the actual library state.
|
|
151
|
+
// The conservative direction is "force re-fetch" — wire cost
|
|
152
|
+
// happens once per upgrade, not every time.
|
|
153
|
+
if (
|
|
154
|
+
!skillsMap ||
|
|
155
|
+
typeof skillsMap !== "object" ||
|
|
156
|
+
Object.keys(skillsMap).length === 0
|
|
157
|
+
) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No vendors: this is the `--agent none` case or a degenerate config.
|
|
162
|
+
// requireVendorTargets will catch this downstream; from here, treat
|
|
163
|
+
// as "no placements to verify" → trivially complete.
|
|
164
|
+
if (!Array.isArray(vendors) || vendors.length === 0) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let targets;
|
|
169
|
+
try {
|
|
170
|
+
targets = placementTargetsFor({ vendors, global: global === true });
|
|
171
|
+
} catch {
|
|
172
|
+
// placementTargetsFor throws on an invalid vendor + scope combo
|
|
173
|
+
// (e.g., `--global` with a vendor that has no `globalTarget`).
|
|
174
|
+
// The downstream runSync write loop will surface the same error
|
|
175
|
+
// with the correct typed exception; conservatively treat as
|
|
176
|
+
// "placement incomplete" so we drop the ETag and force the
|
|
177
|
+
// full-fetch path (which is what we'd want under any error).
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const key of Object.keys(skillsMap)) {
|
|
182
|
+
// Skill keys are `"<owner>/<name>"`. We only need the name half
|
|
183
|
+
// because placement directories are keyed by skill name alone.
|
|
184
|
+
const slashAt = key.indexOf("/");
|
|
185
|
+
if (slashAt < 0) continue; // malformed entry — skip rather than crash
|
|
186
|
+
const skillName = key.slice(slashAt + 1);
|
|
187
|
+
if (!skillName) continue;
|
|
188
|
+
|
|
189
|
+
for (const target of targets) {
|
|
190
|
+
const dir = resolvePlacementDir(target, skillName);
|
|
191
|
+
// Probe `SKILL.md` rather than the bare directory. An empty or
|
|
192
|
+
// partial directory left behind by a hostile filesystem
|
|
193
|
+
// condition (manual mkdir, third-party tool, interrupted write
|
|
194
|
+
// before the atomic rename — rare but possible) would otherwise
|
|
195
|
+
// satisfy `existsSync(dir)` and let the 304 fire, leaving the
|
|
196
|
+
// user with a placement that contains no usable skill content.
|
|
197
|
+
// SKILL.md is the spec-required entry point; its presence is
|
|
198
|
+
// the load-bearing invariant the placement-presence check is
|
|
199
|
+
// really asserting.
|
|
200
|
+
if (!existsSync(join(dir, "SKILL.md"))) {
|
|
201
|
+
// ONE missing placement is enough — full re-fetch will re-write
|
|
202
|
+
// EVERY skill to EVERY target, restoring the invariant in one
|
|
203
|
+
// round.
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
113
211
|
/**
|
|
114
212
|
* Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
|
|
115
213
|
* Returns null if the file doesn't exist, is malformed, or has a
|
|
@@ -370,8 +468,61 @@ export async function runSync(options) {
|
|
|
370
468
|
|
|
371
469
|
// Step 3: fetch with conditional headers
|
|
372
470
|
const opts = {};
|
|
373
|
-
|
|
374
|
-
|
|
471
|
+
// The ETag short-circuit is only safe when EVERY skill in
|
|
472
|
+
// `.last-sync`'s baseline is present in EVERY placement directory
|
|
473
|
+
// the current sync would write to. The 304 means "library content
|
|
474
|
+
// unchanged" — but the local placement set can change between
|
|
475
|
+
// syncs even when content does not:
|
|
476
|
+
//
|
|
477
|
+
// - User upgrades from 4.5.0 (single-vendor default) to 4.5.1+
|
|
478
|
+
// (all-detected default) — new vendors detected, their
|
|
479
|
+
// placement dirs are empty.
|
|
480
|
+
// - User runs `update --global` once, then `update` (no flag)
|
|
481
|
+
// later — same vendor, but the resolved placement dir is
|
|
482
|
+
// project-scoped now instead of global.
|
|
483
|
+
// - User runs `update` in project A, then in project B — same
|
|
484
|
+
// vendor and scope, but `<cwd>/.claude/skills/` resolves to a
|
|
485
|
+
// different directory in B than in A.
|
|
486
|
+
//
|
|
487
|
+
// Without this check, any of those cases produces 304 → no writes
|
|
488
|
+
// → all-MISS output in `list`. The fix is a placement-presence
|
|
489
|
+
// check: for every (skill, target) pair the current sync would
|
|
490
|
+
// touch, the skill's directory MUST already exist under the
|
|
491
|
+
// target's root. If ANY is missing, drop `If-None-Match` so the
|
|
492
|
+
// server returns the full library and we populate the empty
|
|
493
|
+
// placement.
|
|
494
|
+
//
|
|
495
|
+
// Performance: N targets × M skills `existsSync` calls. For a 14-
|
|
496
|
+
// skill library with 2 targets, that's 28 stat calls — well under
|
|
497
|
+
// a millisecond. The conditional-fetch wire savings on a clean
|
|
498
|
+
// repeat sync still applies; this is the safety guard above it.
|
|
499
|
+
// BOTH the ETag short-circuit AND the `since` delta-query optimization
|
|
500
|
+
// depend on the same trust invariant: "the local placement set has
|
|
501
|
+
// every skill we expect." If a placement is missing, dropping ONLY
|
|
502
|
+
// `If-None-Match` is insufficient — the server filters `result.skills`
|
|
503
|
+
// by `updatedAt > since`, so a server response that we trigger by
|
|
504
|
+
// dropping the ETag will STILL omit any skill whose content hasn't
|
|
505
|
+
// changed on the server since the last sync. The missing placement
|
|
506
|
+
// would never be repopulated, producing a permanent ETag-miss loop
|
|
507
|
+
// that the user can only escape by `rm ~/.claude/skillrepo/.last-sync`.
|
|
508
|
+
//
|
|
509
|
+
// Verified against the production server at
|
|
510
|
+
// src/lib/queries/library.ts (`gt(skills.updatedAt, since)`): `since`
|
|
511
|
+
// alone is enough to filter unchanged skills out of the response.
|
|
512
|
+
//
|
|
513
|
+
// Fix: gate BOTH headers on the same `placementsAreComplete` check.
|
|
514
|
+
// When placements are incomplete, drop both — server returns the
|
|
515
|
+
// full library, runSync writes every skill to every detected
|
|
516
|
+
// vendor's placement, recovery is complete in one round.
|
|
517
|
+
const placementsComplete =
|
|
518
|
+
lastSync?.etag &&
|
|
519
|
+
placementsAreComplete(lastSync.skills, vendors, global);
|
|
520
|
+
if (placementsComplete) {
|
|
521
|
+
opts.ifNoneMatch = lastSync.etag;
|
|
522
|
+
}
|
|
523
|
+
if (lastSync?.syncedAt && placementsComplete) {
|
|
524
|
+
opts.since = lastSync.syncedAt;
|
|
525
|
+
}
|
|
375
526
|
|
|
376
527
|
// Track whether this is a full or delta sync BEFORE the network
|
|
377
528
|
// call, for the returned summary's `fullSync` field. A "full" sync
|
|
@@ -455,6 +606,11 @@ export async function runSync(options) {
|
|
|
455
606
|
let anyIncomplete = false;
|
|
456
607
|
/** @type {Record<string, SyncedSkillEntry>} */
|
|
457
608
|
const skillsMap = {};
|
|
609
|
+
// Skills actually written to disk this sync, for the confirmed-write
|
|
610
|
+
// receipt (#1832). Only what we WRITE this round — carry-forward skills
|
|
611
|
+
// were reported on the sync that wrote them.
|
|
612
|
+
/** @type {{owner: string, name: string, version: string}[]} */
|
|
613
|
+
const writtenThisSync = [];
|
|
458
614
|
// Carry forward entries from the previous sync for skills we did
|
|
459
615
|
// NOT re-fetch this round. A delta sync that touched 2 of 50
|
|
460
616
|
// skills must keep the other 48's metadata — otherwise the
|
|
@@ -518,9 +674,26 @@ export async function runSync(options) {
|
|
|
518
674
|
syncedAt: result.syncedAt,
|
|
519
675
|
};
|
|
520
676
|
}
|
|
677
|
+
|
|
678
|
+
// Record this write for the sync receipt (#1832). Only entries with a
|
|
679
|
+
// real version label can resolve to a delivery server-side, so skip
|
|
680
|
+
// null/empty versions (a skill with no current published version).
|
|
681
|
+
if (typeof skill.version === "string" && skill.version !== "") {
|
|
682
|
+
writtenThisSync.push({
|
|
683
|
+
owner: skill.owner,
|
|
684
|
+
name: skill.name,
|
|
685
|
+
version: skill.version,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
521
688
|
}
|
|
522
689
|
|
|
523
|
-
// Step 7: persist new ETag + skills map (only if the response was
|
|
690
|
+
// Step 7: persist new ETag + skills map (only if the response was
|
|
691
|
+
// complete). The placement-presence check in `placementsAreComplete`
|
|
692
|
+
// (called above before the conditional request) handles the safety
|
|
693
|
+
// gate for the ETag short-circuit — no need to persist a separate
|
|
694
|
+
// vendor-set field; the on-disk placements themselves are the
|
|
695
|
+
// source of truth for "did the local placement set change in a way
|
|
696
|
+
// that would invalidate 304?"
|
|
524
697
|
if (!anyIncomplete && result.etag) {
|
|
525
698
|
try {
|
|
526
699
|
writeLastSync({
|
|
@@ -539,6 +712,28 @@ export async function runSync(options) {
|
|
|
539
712
|
}
|
|
540
713
|
}
|
|
541
714
|
|
|
715
|
+
// Post a sync receipt for the skills actually written this sync
|
|
716
|
+
// (#1832). This confirmed-write signal — not the fetch itself — is what
|
|
717
|
+
// sets team sync state. Independent of the ETag persist above: skills
|
|
718
|
+
// skipped for `filesIncomplete` are excluded from `writtenThisSync`, so
|
|
719
|
+
// a partial sync still reports exactly what landed on disk. Best-effort:
|
|
720
|
+
// the files are already written and the server dedups replays, so a
|
|
721
|
+
// failed receipt is a non-fatal warning, never a sync failure.
|
|
722
|
+
if (writtenThisSync.length > 0) {
|
|
723
|
+
try {
|
|
724
|
+
await postSyncReceipt(serverUrl, apiKey, {
|
|
725
|
+
skills: writtenThisSync,
|
|
726
|
+
syncedAt: result.syncedAt,
|
|
727
|
+
});
|
|
728
|
+
} catch (err) {
|
|
729
|
+
stderr.write(
|
|
730
|
+
` warning: failed to report sync receipt (${err.message}). ` +
|
|
731
|
+
`Your skills are synced locally; team sync status may lag until ` +
|
|
732
|
+
`your next sync.\n`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
542
737
|
return summary;
|
|
543
738
|
}
|
|
544
739
|
|
|
@@ -92,6 +92,20 @@ describe("runList — happy path", () => {
|
|
|
92
92
|
assert.match(out, /2 skills in your library/);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it("makes a non-recording manifest read (#1832)", async () => {
|
|
96
|
+
server.setLibraryResponse({
|
|
97
|
+
skills: [makeSkill("alice", "pdf-helper")],
|
|
98
|
+
removals: [],
|
|
99
|
+
syncedAt: "x",
|
|
100
|
+
});
|
|
101
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
102
|
+
// `list` is a peek — it must hit the metadata-only manifest path so
|
|
103
|
+
// the server records no delivery for it...
|
|
104
|
+
assert.equal(server.getLastLibraryManifest(), "1");
|
|
105
|
+
// ...and it never posts a sync receipt (that's a write-confirm signal).
|
|
106
|
+
assert.equal(server.getReceiptRequestCount(), 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
95
109
|
it("sorts alphabetically by owner then name", async () => {
|
|
96
110
|
server.setLibraryResponse({
|
|
97
111
|
skills: [
|