skillshelf 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/outdated.test.ts +147 -1
- package/src/commands/outdated.ts +86 -21
- package/src/core/fetch.test.ts +78 -0
- package/src/core/fetch.ts +38 -2
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { run } from "./outdated.ts";
|
|
5
|
+
import { run, mapLimit } from "./outdated.ts";
|
|
6
6
|
import type { Ctx } from "../types.ts";
|
|
7
7
|
|
|
8
8
|
function makeCtx(libraryPath: string) {
|
|
@@ -53,3 +53,149 @@ describe("skl outdated — LINKED entries are reported, not probed (ADR-0004)",
|
|
|
53
53
|
expect(report.rows.find((r) => r.name === "devskill")!.status).toBe("linked");
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
async function git(cwd: string, args: string[]): Promise<void> {
|
|
58
|
+
const p = Bun.spawn(["git", ...args], {
|
|
59
|
+
cwd,
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
GIT_AUTHOR_NAME: "t",
|
|
63
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
64
|
+
GIT_COMMITTER_NAME: "t",
|
|
65
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
66
|
+
},
|
|
67
|
+
stdout: "pipe",
|
|
68
|
+
stderr: "pipe",
|
|
69
|
+
});
|
|
70
|
+
await p.exited;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function gitOut(cwd: string, args: string[]): Promise<string> {
|
|
74
|
+
const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
75
|
+
const out = await new Response(p.stdout).text();
|
|
76
|
+
await p.exited;
|
|
77
|
+
return out.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Regression: an OWNED github/git entry whose upstream HEAD has advanced past the
|
|
81
|
+
// installed ref MUST surface as "stale". This is the online ref-compare path
|
|
82
|
+
// (classify step 7) that was dead code while checkEntry fed localHash:null — every
|
|
83
|
+
// online row collapsed to "current" and `outdated` could never flag an update
|
|
84
|
+
// (and the UI ↑ badge, keyed off status==="stale", never lit). Uses the git: channel
|
|
85
|
+
// over a real LOCAL repo so the "latest ref" probe is offline (`git ls-remote <path>`).
|
|
86
|
+
describe("skl outdated — online ref-compare flags a moved upstream as stale", () => {
|
|
87
|
+
let tmp: string;
|
|
88
|
+
let library: string;
|
|
89
|
+
let upstream: string;
|
|
90
|
+
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-outdated-stale-")));
|
|
93
|
+
library = join(tmp, "library");
|
|
94
|
+
upstream = join(tmp, "upstream");
|
|
95
|
+
await mkdir(join(upstream, "skills", "foo"), { recursive: true });
|
|
96
|
+
await writeFile(
|
|
97
|
+
join(upstream, "skills", "foo", "SKILL.md"),
|
|
98
|
+
"---\nname: foo\ndescription: foo skill\n---\n\nFOO BODY\n",
|
|
99
|
+
);
|
|
100
|
+
await git(upstream, ["init", "-q"]);
|
|
101
|
+
await git(upstream, ["add", "-A"]);
|
|
102
|
+
await git(upstream, ["commit", "-q", "-m", "v1"]);
|
|
103
|
+
const ref1 = await gitOut(upstream, ["rev-parse", "HEAD"]);
|
|
104
|
+
|
|
105
|
+
// Library copy of foo (readable body → a real localHash) pinned to ref1.
|
|
106
|
+
await mkdir(join(library, "foo"), { recursive: true });
|
|
107
|
+
await writeFile(
|
|
108
|
+
join(library, "foo", "SKILL.md"),
|
|
109
|
+
"---\nname: foo\ndescription: foo skill\n---\n\nFOO BODY\n",
|
|
110
|
+
);
|
|
111
|
+
await writeFile(
|
|
112
|
+
join(library, "shelf.lock.json"),
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
version: 1,
|
|
115
|
+
entries: {
|
|
116
|
+
foo: {
|
|
117
|
+
name: "foo",
|
|
118
|
+
source: `git:${upstream}#skills/foo`,
|
|
119
|
+
ref: ref1,
|
|
120
|
+
channel: "git",
|
|
121
|
+
installedAt: "2020-01-01T00:00:00.000Z",
|
|
122
|
+
localEdits: false,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Advance upstream HEAD past ref1 — the repo moved on since install.
|
|
129
|
+
await writeFile(join(upstream, "skills", "foo", "note.txt"), "changed\n");
|
|
130
|
+
await git(upstream, ["add", "-A"]);
|
|
131
|
+
await git(upstream, ["commit", "-q", "-m", "v2"]);
|
|
132
|
+
});
|
|
133
|
+
afterEach(async () => {
|
|
134
|
+
await rm(tmp, { recursive: true, force: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("an owned entry whose upstream HEAD advanced is status 'stale' (exit 2)", async () => {
|
|
138
|
+
const { ctx, json } = makeCtx(library);
|
|
139
|
+
const code = await run(["--json"], ctx);
|
|
140
|
+
|
|
141
|
+
expect(code).toBe(2); // stale exists → non-zero so CI/agents can branch on it
|
|
142
|
+
const report = json[0] as {
|
|
143
|
+
stale: number;
|
|
144
|
+
rows: Array<{ name: string; status: string }>;
|
|
145
|
+
};
|
|
146
|
+
expect(report.stale).toBe(1);
|
|
147
|
+
expect(report.rows.find((r) => r.name === "foo")!.status).toBe("stale");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// The concurrency bound is the fix for the "unknown storm" — firing one network
|
|
152
|
+
// probe per skill all at once dropped 59/87 probes to transient failures. These
|
|
153
|
+
// pin the three invariants the storm fix relies on: peak in-flight <= limit, every
|
|
154
|
+
// item runs once, and output order matches input (rows must not scramble).
|
|
155
|
+
describe("mapLimit — bounded-concurrency probe pool", () => {
|
|
156
|
+
test("never runs more than `limit` tasks concurrently", async () => {
|
|
157
|
+
let inflight = 0;
|
|
158
|
+
let peak = 0;
|
|
159
|
+
const items = Array.from({ length: 20 }, (_, i) => i);
|
|
160
|
+
await mapLimit(items, 4, async () => {
|
|
161
|
+
inflight++;
|
|
162
|
+
peak = Math.max(peak, inflight);
|
|
163
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
164
|
+
inflight--;
|
|
165
|
+
return null;
|
|
166
|
+
});
|
|
167
|
+
expect(peak).toBeLessThanOrEqual(4);
|
|
168
|
+
expect(peak).toBeGreaterThan(1); // genuinely parallel, not accidentally serialized
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("preserves input order regardless of completion order", async () => {
|
|
172
|
+
// Later items finish FIRST (descending delay); output must still be by index.
|
|
173
|
+
const out = await mapLimit([40, 30, 20, 10], 4, async (ms, i) => {
|
|
174
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
175
|
+
return `${i}:${ms}`;
|
|
176
|
+
});
|
|
177
|
+
expect(out).toEqual(["0:40", "1:30", "2:20", "3:10"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("runs every item exactly once", async () => {
|
|
181
|
+
const seen: number[] = [];
|
|
182
|
+
const items = Array.from({ length: 15 }, (_, i) => i);
|
|
183
|
+
const out = await mapLimit(items, 6, async (n) => {
|
|
184
|
+
seen.push(n);
|
|
185
|
+
return n * 2;
|
|
186
|
+
});
|
|
187
|
+
expect(out).toEqual(items.map((n) => n * 2));
|
|
188
|
+
expect([...seen].sort((a, b) => a - b)).toEqual(items);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("limit exceeding the list length still resolves all; empty list is empty", async () => {
|
|
192
|
+
expect(await mapLimit([1, 2, 3], 100, async (n) => n + 1)).toEqual([2, 3, 4]);
|
|
193
|
+
expect(await mapLimit([], 6, async (n: number) => n)).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("limit <= 0 is clamped to 1 (no undefined holes), still processes all", async () => {
|
|
197
|
+
const out = await mapLimit([1, 2, 3], 0, async (n) => n * 10);
|
|
198
|
+
expect(out).toEqual([10, 20, 30]);
|
|
199
|
+
expect(out.every((v) => v !== undefined)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
package/src/commands/outdated.ts
CHANGED
|
@@ -36,7 +36,27 @@ export interface OutdatedRow {
|
|
|
36
36
|
note: string;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Hash the on-disk SKILL.md body (frontmatter-stripped) of a tracked skill, or null when
|
|
41
|
+
* the file is missing/unreadable. The online check MUST feed this into classify: with
|
|
42
|
+
* localHash==null the classifier short-circuits to "unknown" (reconcile.ts step 4) and
|
|
43
|
+
* NEVER reaches the ref-compare (step 7), so `outdated` could never surface a stale skill
|
|
44
|
+
* — the ref-compare was dead code and the UI ↑ badge (keys off status==="stale") never lit.
|
|
45
|
+
*/
|
|
46
|
+
function localBodyHash(name: string, library: Skill[], libraryPath: string): string | null {
|
|
47
|
+
const skill = findByName(library, name);
|
|
48
|
+
const bodyPath = skill?.bodyPath ?? join(libraryPath, name, "SKILL.md");
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(bodyPath)) {
|
|
51
|
+
return hashContent(parseFrontmatter(readFileSync(bodyPath, "utf8")).body);
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
/* leave null */
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function checkEntry(entry: LockEntry, library: Skill[], libraryPath: string): Promise<OutdatedRow> {
|
|
40
60
|
const parsed = parseStoredSource(entry.source);
|
|
41
61
|
const res = await latestRef(parsed);
|
|
42
62
|
if (!res.ok) {
|
|
@@ -51,14 +71,16 @@ async function checkEntry(entry: LockEntry): Promise<OutdatedRow> {
|
|
|
51
71
|
};
|
|
52
72
|
}
|
|
53
73
|
const latest = res.ref;
|
|
54
|
-
// Ref-only online view (no upstream body fetched): classify maps to stale/current
|
|
55
|
-
//
|
|
74
|
+
// Ref-only online view (no upstream body fetched): classify maps to stale/current off
|
|
75
|
+
// the ref compare (step 7) — but only if a real localHash is supplied (else the step-4
|
|
76
|
+
// localHash==null guard returns "unknown" before the ref compare is ever reached).
|
|
77
|
+
const localHash = localBodyHash(entry.name, library, libraryPath);
|
|
56
78
|
const verdict = classify({
|
|
57
79
|
adopted: false,
|
|
58
80
|
mode: "owned",
|
|
59
81
|
installedHash: entry.installedHash ?? null,
|
|
60
82
|
localEdits: entry.localEdits,
|
|
61
|
-
localHash
|
|
83
|
+
localHash,
|
|
62
84
|
upstreamHash: null,
|
|
63
85
|
installedRef: entry.ref,
|
|
64
86
|
latestRef: latest,
|
|
@@ -70,8 +92,16 @@ async function checkEntry(entry: LockEntry): Promise<OutdatedRow> {
|
|
|
70
92
|
source: entry.source,
|
|
71
93
|
installedRef: entry.ref,
|
|
72
94
|
latestRef: latest,
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
// Map the verdict explicitly: an unreadable local body yields "unknown" (localHash
|
|
96
|
+
// null → classify step 4), NOT a falsely-reassuring "current". Only a real ref-compare
|
|
97
|
+
// (readable body) produces stale/current.
|
|
98
|
+
status: verdict === "stale" ? "stale" : verdict === "unknown" ? "unknown" : "current",
|
|
99
|
+
note:
|
|
100
|
+
verdict === "unknown"
|
|
101
|
+
? "local SKILL.md missing/unreadable — cannot compare against upstream"
|
|
102
|
+
: entry.localEdits
|
|
103
|
+
? "has local edits"
|
|
104
|
+
: "",
|
|
75
105
|
};
|
|
76
106
|
}
|
|
77
107
|
|
|
@@ -180,6 +210,38 @@ function checkEntryLocal(entry: LockEntry, library: Skill[], libraryPath: string
|
|
|
180
210
|
: { ...base, status: "current", note: "matches installed baseline (offline)" };
|
|
181
211
|
}
|
|
182
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Resolve an array through an async fn with BOUNDED concurrency, preserving input order.
|
|
215
|
+
* `outdated` probes an upstream ref per tracked skill; firing one `git ls-remote` for the
|
|
216
|
+
* WHOLE lockfile at once (unbounded Promise.all) opens dozens of simultaneous TLS handshakes
|
|
217
|
+
* that a flaky network / connection cap turns into a storm of transient "unable to access"
|
|
218
|
+
* failures — every one collapsing to status "unknown". A small pool caps in-flight probes so
|
|
219
|
+
* they succeed instead of overwhelming the transport (a failed probe degrades to "unknown",
|
|
220
|
+
* not a retry — a status check must not hang on a flaky host).
|
|
221
|
+
*
|
|
222
|
+
* `fn` MUST resolve (never reject): a rejection would tear down the pool via Promise.all
|
|
223
|
+
* while sibling workers keep running (risking an unhandled rejection). Every current caller
|
|
224
|
+
* branch is throw-free by contract; a future throwing `fn` must catch internally.
|
|
225
|
+
*/
|
|
226
|
+
export async function mapLimit<T, R>(
|
|
227
|
+
items: T[],
|
|
228
|
+
limit: number,
|
|
229
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
230
|
+
): Promise<R[]> {
|
|
231
|
+
const out: R[] = new Array(items.length);
|
|
232
|
+
let next = 0;
|
|
233
|
+
async function worker(): Promise<void> {
|
|
234
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
235
|
+
out[i] = await fn(items[i]!, i);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Clamp to >=1 worker: a limit of 0 (or negative) would spawn none, leaving `out` full
|
|
239
|
+
// of undefined holes. min() with items.length avoids spawning idle workers.
|
|
240
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
241
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
183
245
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
184
246
|
const json = argv.includes("--json");
|
|
185
247
|
const checkLocal = argv.includes("--check-local");
|
|
@@ -197,21 +259,24 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
197
259
|
// --from` case — which would otherwise be invisible here.
|
|
198
260
|
const library = await loadLibrary(libraryPath);
|
|
199
261
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
262
|
+
// Bounded concurrency (not an unbounded Promise.all): the online checkEntry path
|
|
263
|
+
// makes a network probe per skill, and firing all of them at once storms the transport
|
|
264
|
+
// into transient failures (→ status "unknown"). Offline branches (linked/adopted/
|
|
265
|
+
// --check-local) resolve instantly and cost nothing extra under the pool.
|
|
266
|
+
const PROBE_CONCURRENCY = 6;
|
|
267
|
+
const rows = await mapLimit(entries, PROBE_CONCURRENCY, (e) =>
|
|
268
|
+
entryMode(libraryPath, e.name) === "linked"
|
|
269
|
+
? Promise.resolve(linkedRow(e))
|
|
270
|
+
: e.adopted === true
|
|
271
|
+
? // An adopted entry has an unverified (often empty) baseline — never probe
|
|
272
|
+
// upstream off it; report it as `adopted` so `update` reconciles (ADR-0011).
|
|
273
|
+
// --check-local still does the offline body-vs-baseline compare below.
|
|
274
|
+
checkLocal
|
|
275
|
+
? Promise.resolve(checkEntryLocal(e, library, libraryPath))
|
|
276
|
+
: Promise.resolve(adoptedRow(e))
|
|
277
|
+
: checkLocal
|
|
278
|
+
? Promise.resolve(checkEntryLocal(e, library, libraryPath))
|
|
279
|
+
: checkEntry(e, library, libraryPath),
|
|
215
280
|
);
|
|
216
281
|
|
|
217
282
|
// Augment: LINKED library skills not already represented by a lock entry get a
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isTransientGitError, fetchRepo, parseSource, cleanupStaging } from "./fetch.ts";
|
|
3
|
+
|
|
4
|
+
// The retry gate for cloneWithRetry/lsRemoteWithRetry. Getting this wrong is
|
|
5
|
+
// costly in BOTH directions: a definitive failure classed transient burns 3
|
|
6
|
+
// attempts on a 404/auth that can never succeed; a real transient classed
|
|
7
|
+
// definitive fails a whole `skl update --repo` run on one network blip.
|
|
8
|
+
describe("isTransientGitError — retry gate", () => {
|
|
9
|
+
test("the observed LibreSSL handshake blip is transient (retry)", () => {
|
|
10
|
+
// The exact fault that failed all 35 skills in one dry-run this session.
|
|
11
|
+
expect(
|
|
12
|
+
isTransientGitError(
|
|
13
|
+
"fatal: unable to access 'https://github.com/owner/repo.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443",
|
|
14
|
+
),
|
|
15
|
+
).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test.each([
|
|
19
|
+
"fatal: unable to access '...': Failed to connect to github.com port 443: Connection reset by peer",
|
|
20
|
+
"fatal: unable to access '...': Could not resolve host: github.com",
|
|
21
|
+
"error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly",
|
|
22
|
+
"fatal: the remote end hung up unexpectedly\nfatal: early EOF",
|
|
23
|
+
"fatal: unable to access '...': Operation timed out after 30001 milliseconds",
|
|
24
|
+
"fatal: the remote end hung up unexpectedly",
|
|
25
|
+
"fatal: protocol error: bad pack header",
|
|
26
|
+
"error: unexpected disconnect while reading sideband packet",
|
|
27
|
+
"kex_exchange_identification: Connection closed by remote host",
|
|
28
|
+
])("transient network fault → retry: %s", (stderr) => {
|
|
29
|
+
expect(isTransientGitError(stderr)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test.each([
|
|
33
|
+
"fatal: repository 'https://github.com/owner/gone.git/' not found",
|
|
34
|
+
"remote: Repository not found.",
|
|
35
|
+
"fatal: Authentication failed for 'https://github.com/owner/private.git/'",
|
|
36
|
+
"fatal: unable to access '...': The requested URL returned error: 404",
|
|
37
|
+
"fatal: unable to access '...': The requested URL returned error: 403",
|
|
38
|
+
"remote: Permission denied",
|
|
39
|
+
])("definitive failure → fail fast (no retry): %s", (stderr) => {
|
|
40
|
+
expect(isTransientGitError(stderr)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a 404 that also says 'unable to access' still fails fast (guard wins)", () => {
|
|
44
|
+
// "unable to access" is a transient signal, but the 404 must veto it — else
|
|
45
|
+
// a missing repo would be retried 3× for nothing.
|
|
46
|
+
expect(
|
|
47
|
+
isTransientGitError(
|
|
48
|
+
"fatal: unable to access '...': The requested URL returned error: 404",
|
|
49
|
+
),
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("empty / unrecognized stderr is not treated as transient", () => {
|
|
54
|
+
expect(isTransientGitError("")).toBe(false);
|
|
55
|
+
expect(isTransientGitError("fatal: something totally novel")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Integration guard for the retry loop's fail-fast path: a clone of a nonexistent
|
|
60
|
+
// local repo fails with a DEFINITIVE error ("does not appear to be a git repository"),
|
|
61
|
+
// so cloneWithRetry must NOT burn 3 attempts on it — it returns quickly with ok:false.
|
|
62
|
+
// (The happy path of cloneWithRetry/lsRemoteWithRetry is exercised by the update
|
|
63
|
+
// rename/orphan test and the outdated online-stale test over real local repos.)
|
|
64
|
+
describe("fetchRepo — non-transient clone failure fails fast", () => {
|
|
65
|
+
test("a missing local git repo returns ok:false without retry churn", async () => {
|
|
66
|
+
const started = performance.now();
|
|
67
|
+
const res = await fetchRepo(parseSource("git:/no/such/skillshelf/repo/xyz"));
|
|
68
|
+
const elapsedMs = performance.now() - started;
|
|
69
|
+
|
|
70
|
+
expect(res.ok).toBe(false);
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
expect(res.error).toContain("git clone failed");
|
|
73
|
+
await cleanupStaging(res.staging);
|
|
74
|
+
}
|
|
75
|
+
// 3 transient retries would add ~0.9s of backoff; fail-fast stays well under that.
|
|
76
|
+
expect(elapsedMs).toBeLessThan(800);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/core/fetch.ts
CHANGED
|
@@ -579,6 +579,39 @@ function localCloneUrl(localPath: string): string {
|
|
|
579
579
|
return pathToFileURL(isAbsolute(localPath) ? localPath : resolve(localPath)).href;
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
/**
|
|
583
|
+
* A `git clone` failure worth retrying: a TRANSIENT network/TLS fault, not a definitive
|
|
584
|
+
* one (repo not found / auth / 404) which will never succeed and must fail fast. The
|
|
585
|
+
* definitive-failure guard is checked FIRST so a "unable to access … 404" is never mistaken
|
|
586
|
+
* for the transient "unable to access … SSL_ERROR_SYSCALL" handshake blip.
|
|
587
|
+
*/
|
|
588
|
+
export function isTransientGitError(stderr: string): boolean {
|
|
589
|
+
if (/not found|does not (exist|appear)|repository not found|authentication failed|\b40[134]\b|permission denied|invalid username or password/i.test(stderr)) {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
return /ssl_error|ssl_connect|\btls\b|gnutls|connection reset|connection timed out|could not resolve host|recv failure|send failure|unable to access|early eof|rpc failed|temporary failure|operation timed out|connection refused|failed to connect|timed out|remote end hung up|protocol error|unexpected disconnect|kex_exchange_identification|connection closed by remote/i.test(stderr);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `git clone --depth 1` with a bounded retry on TRANSIENT network/TLS faults. A single
|
|
597
|
+
* flaky handshake (observed: LibreSSL `SSL_ERROR_SYSCALL`) otherwise fails an ENTIRE `skl
|
|
598
|
+
* update --repo` run — the repo is cloned once for the whole group, so one blip reports
|
|
599
|
+
* "error" for every skill in it. Non-transient failures fail fast (one attempt). The partial
|
|
600
|
+
* checkout dir is removed between attempts so the re-clone starts from a clean slate.
|
|
601
|
+
*/
|
|
602
|
+
async function cloneWithRetry(cloneTarget: string, checkout: string): Promise<RunResult> {
|
|
603
|
+
const MAX_ATTEMPTS = 3;
|
|
604
|
+
let last: RunResult = { ok: false, code: -1, stdout: "", stderr: "" };
|
|
605
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
606
|
+
last = await run(["git", "clone", "--depth", "1", cloneTarget, checkout]);
|
|
607
|
+
if (last.ok) return last;
|
|
608
|
+
if (attempt === MAX_ATTEMPTS || !isTransientGitError(last.stderr)) return last;
|
|
609
|
+
await rm(checkout, { recursive: true, force: true }).catch(() => {});
|
|
610
|
+
await new Promise((resolve) => setTimeout(resolve, 300 * attempt));
|
|
611
|
+
}
|
|
612
|
+
return last;
|
|
613
|
+
}
|
|
614
|
+
|
|
582
615
|
/**
|
|
583
616
|
* Clone a github/git source ONCE into a fresh staging dir and capture HEAD. Shared
|
|
584
617
|
* by the single-skill fetch (fetchGithub/fetchGit) and the repo-wide fetchRepo so a
|
|
@@ -609,7 +642,7 @@ async function cloneToStaging(
|
|
|
609
642
|
}
|
|
610
643
|
|
|
611
644
|
const checkout = join(staging, "repo");
|
|
612
|
-
const clone = await
|
|
645
|
+
const clone = await cloneWithRetry(cloneTarget, checkout);
|
|
613
646
|
if (!clone.ok) {
|
|
614
647
|
return {
|
|
615
648
|
ok: false,
|
|
@@ -786,7 +819,10 @@ export async function latestGithubRef(parsed: ParsedSource): Promise<RefResult>
|
|
|
786
819
|
}
|
|
787
820
|
}
|
|
788
821
|
|
|
789
|
-
// Fallback: ls-remote default HEAD.
|
|
822
|
+
// Fallback: ls-remote default HEAD. NOT retried — this is a status probe (one per
|
|
823
|
+
// skill under `outdated`'s bounded pool); a transient blip degrades to "unknown" (cheap,
|
|
824
|
+
// re-runnable) rather than retrying with backoff, which would make a single hung probe
|
|
825
|
+
// blow a caller's time budget. Retry is reserved for the expensive clone path.
|
|
790
826
|
if (await hasBinary("git")) {
|
|
791
827
|
const url = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
|
|
792
828
|
const r = await run(["git", "ls-remote", url, "HEAD"]);
|