@lythos/cold-pool 0.10.0 → 0.11.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 +2 -2
- package/src/cold-pool.ts +6 -2
- package/src/fetch-plan.ts +6 -3
- package/src/git-io.test.ts +84 -9
- package/src/git-io.ts +26 -6
- package/src/github-naming.ts +139 -0
- package/src/index.ts +1 -0
- package/src/mirror.test.ts +137 -0
- package/src/mirror.ts +128 -0
- package/src/parse-locator.ts +25 -3
- package/src/prune-plan.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@lythos/infra": "
|
|
45
|
+
"@lythos/infra": "workspace:*",
|
|
46
46
|
"simple-git": "^3.36.0"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
package/src/cold-pool.ts
CHANGED
|
@@ -128,7 +128,10 @@ export class ColdPool {
|
|
|
128
128
|
|
|
129
129
|
function walk(dir: string, push: (d: string) => void): void {
|
|
130
130
|
let dirents: ReturnType<typeof readdirSync>
|
|
131
|
-
try { dirents = readdirSync(dir, { withFileTypes: true }) } catch
|
|
131
|
+
try { dirents = readdirSync(dir, { withFileTypes: true }) } catch (e: any) {
|
|
132
|
+
if (e.code !== 'ENOENT') console.warn(`walk: readdir failed for ${dir}: ${e.message}`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
132
135
|
for (const d of dirents) {
|
|
133
136
|
if (!d.isDirectory() || d.name.startsWith('.')) continue
|
|
134
137
|
const sub = join(dir, d.name)
|
|
@@ -154,7 +157,8 @@ export class ColdPool {
|
|
|
154
157
|
let dirents: ReturnType<typeof readdirSync>
|
|
155
158
|
try {
|
|
156
159
|
dirents = readdirSync(dir, { withFileTypes: true })
|
|
157
|
-
} catch {
|
|
160
|
+
} catch (e: any) {
|
|
161
|
+
if (e.code !== 'ENOENT') console.warn(`collectRecursive: readdir failed for ${dir}: ${e.message}`)
|
|
158
162
|
return
|
|
159
163
|
}
|
|
160
164
|
for (const d of dirents) {
|
package/src/fetch-plan.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
14
14
|
import type { ColdPool } from './cold-pool.js'
|
|
15
15
|
import type { Locator, FetchPlan, FetchResult, FetchIO } from './types.js'
|
|
16
16
|
import { gitClone } from './git-io.js'
|
|
17
|
+
import { getMirror, rewriteUrl } from './mirror.js'
|
|
17
18
|
|
|
18
19
|
export function buildFetchPlan(
|
|
19
20
|
pool: ColdPool,
|
|
@@ -22,9 +23,11 @@ export function buildFetchPlan(
|
|
|
22
23
|
): FetchPlan {
|
|
23
24
|
const targetDir = pool.resolveDir(locator)
|
|
24
25
|
const protocol = process.env.LYTHOS_GIT_PROTOCOL || 'https'
|
|
25
|
-
const
|
|
26
|
+
const rawCloneUrl = locator.isLocalhost
|
|
26
27
|
? ''
|
|
27
28
|
: `${protocol}://${locator.host}/${locator.owner}/${locator.repo}.git`
|
|
29
|
+
const mirror = getMirror()
|
|
30
|
+
const cloneUrl = rawCloneUrl ? rewriteUrl(rawCloneUrl, mirror) : ''
|
|
28
31
|
|
|
29
32
|
return {
|
|
30
33
|
locator,
|
|
@@ -57,8 +60,8 @@ export function executeFetchPlan(plan: FetchPlan, io?: FetchIO): FetchResult {
|
|
|
57
60
|
log(`🔄 checking out ${plan.ref} in ${plan.targetDir}`)
|
|
58
61
|
execFileSync('git', ['-C', plan.targetDir, 'fetch', '--depth', '1', 'origin', plan.ref], { stdio: 'pipe' })
|
|
59
62
|
execFileSync('git', ['-C', plan.targetDir, 'checkout', plan.ref], { stdio: 'pipe' })
|
|
60
|
-
} catch {
|
|
61
|
-
log(`⚠️ Could not checkout ${plan.ref} — using current HEAD`)
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
log(`⚠️ Could not checkout ${plan.ref}: ${e.message} — using current HEAD`)
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
}
|
package/src/git-io.test.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
|
2
2
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
|
-
import { detectGitRoot } from './git-io'
|
|
5
|
+
import { detectGitRoot, gitClone, gitPull, type GitExec } from './git-io.js'
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// ── Injectable mock exec for IO-layer tests ──
|
|
8
|
+
|
|
9
|
+
function createMockExec() {
|
|
10
|
+
const calls: Array<{ file: string; args: string[]; opts: unknown }> = []
|
|
11
|
+
const mockExec: GitExec = ((file: string, args: string[], opts?: object) => {
|
|
12
|
+
calls.push({ file, args, opts: opts ?? {} })
|
|
13
|
+
return Buffer.from('')
|
|
14
|
+
}) as GitExec
|
|
15
|
+
return { mockExec, calls }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── detectGitRoot tests (pure fs, no mock needed) ──
|
|
11
19
|
|
|
12
20
|
describe('detectGitRoot', () => {
|
|
13
21
|
const root = mkdtempSync(join(tmpdir(), 'git-root-test-'))
|
|
14
22
|
const repoDir = join(root, 'pool/host/owner/repo')
|
|
15
23
|
const subDir = join(repoDir, 'src/deep')
|
|
16
24
|
mkdirSync(subDir, { recursive: true })
|
|
17
|
-
writeFileSync(join(repoDir, '.git'), '')
|
|
18
|
-
// Sibling dir without .git
|
|
25
|
+
writeFileSync(join(repoDir, '.git'), '')
|
|
19
26
|
const orphanDir = join(root, 'pool/orphan')
|
|
20
27
|
mkdirSync(orphanDir, { recursive: true })
|
|
21
28
|
|
|
@@ -36,7 +43,6 @@ describe('detectGitRoot', () => {
|
|
|
36
43
|
})
|
|
37
44
|
|
|
38
45
|
test('respects coldPool boundary — outside-cold-pool when crossing out', () => {
|
|
39
|
-
// Fresh tmpdir to ensure no .git anywhere on the path back to /
|
|
40
46
|
const isolated = mkdtempSync(join(tmpdir(), 'git-root-isolated-'))
|
|
41
47
|
const inner = join(isolated, 'inner')
|
|
42
48
|
const coldPool = join(isolated, 'pool')
|
|
@@ -52,3 +58,72 @@ describe('detectGitRoot', () => {
|
|
|
52
58
|
expect(r.gitRoot).toBe(resolve(repoDir))
|
|
53
59
|
})
|
|
54
60
|
})
|
|
61
|
+
|
|
62
|
+
// ── gitClone / gitPull tests — verify SOCKS proxy CLI contract via IO injection ──
|
|
63
|
+
|
|
64
|
+
describe('gitClone — SOCKS proxy args', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
delete process.env.LYTHOS_SOCKS_PROXY
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
delete process.env.LYTHOS_SOCKS_PROXY
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('without LYTHOS_SOCKS_PROXY: no proxy flags in git args', () => {
|
|
74
|
+
const { mockExec, calls } = createMockExec()
|
|
75
|
+
gitClone('https://github.com/example/repo.git', '/tmp/repo', undefined, mockExec)
|
|
76
|
+
const call = calls.find((c) => c.args.includes('clone'))
|
|
77
|
+
expect(call).toBeDefined()
|
|
78
|
+
expect(call!.args).not.toContain('-c')
|
|
79
|
+
expect(call!.args).not.toContain('http.proxy')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('with LYTHOS_SOCKS_PROXY: injects -c http.proxy and -c https.proxy', () => {
|
|
83
|
+
process.env.LYTHOS_SOCKS_PROXY = 'proxy.example.com:1080'
|
|
84
|
+
const { mockExec, calls } = createMockExec()
|
|
85
|
+
gitClone('https://github.com/example/repo.git', '/tmp/repo', undefined, mockExec)
|
|
86
|
+
const call = calls.find((c) => c.args.includes('clone'))
|
|
87
|
+
expect(call).toBeDefined()
|
|
88
|
+
expect(call!.args).toContain('-c')
|
|
89
|
+
expect(call!.args).toContain('http.proxy=socks5://proxy.example.com:1080')
|
|
90
|
+
expect(call!.args).toContain('https.proxy=socks5://proxy.example.com:1080')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('with LYTHOS_SOCKS_PROXY already including socks5://: does not double-prefix', () => {
|
|
94
|
+
process.env.LYTHOS_SOCKS_PROXY = 'socks5://proxy.example.com:1080'
|
|
95
|
+
const { mockExec, calls } = createMockExec()
|
|
96
|
+
gitClone('https://github.com/example/repo.git', '/tmp/repo', undefined, mockExec)
|
|
97
|
+
const call = calls.find((c) => c.args.includes('clone'))
|
|
98
|
+
expect(call!.args).toContain('http.proxy=socks5://proxy.example.com:1080')
|
|
99
|
+
expect(call!.args).not.toContain('http.proxy=socks5://socks5://proxy.example.com:1080')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('ref checkout also receives proxy flags', () => {
|
|
103
|
+
process.env.LYTHOS_SOCKS_PROXY = 'proxy.example.com:1080'
|
|
104
|
+
const { mockExec, calls } = createMockExec()
|
|
105
|
+
gitClone('https://github.com/example/repo.git', '/tmp/repo', { ref: 'v1.0.0' }, mockExec)
|
|
106
|
+
const checkoutCall = calls.find((c) => c.args.includes('checkout'))
|
|
107
|
+
expect(checkoutCall).toBeDefined()
|
|
108
|
+
expect(checkoutCall!.args).toContain('http.proxy=socks5://proxy.example.com:1080')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('gitPull — SOCKS proxy args', () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
delete process.env.LYTHOS_SOCKS_PROXY
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
delete process.env.LYTHOS_SOCKS_PROXY
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('with LYTHOS_SOCKS_PROXY: injects proxy flags', () => {
|
|
122
|
+
process.env.LYTHOS_SOCKS_PROXY = 'proxy.example.com:1080'
|
|
123
|
+
const { mockExec, calls } = createMockExec()
|
|
124
|
+
gitPull('/tmp/repo', 30000, mockExec)
|
|
125
|
+
const call = calls.find((c) => c.args.includes('pull'))
|
|
126
|
+
expect(call).toBeDefined()
|
|
127
|
+
expect(call!.args).toContain('http.proxy=socks5://proxy.example.com:1080')
|
|
128
|
+
})
|
|
129
|
+
})
|
package/src/git-io.ts
CHANGED
|
@@ -11,6 +11,17 @@ import { execFileSync } from 'node:child_process'
|
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { dirname, resolve } from 'node:path'
|
|
13
13
|
|
|
14
|
+
/** Injectable exec for testing. Matches node:child_process execFileSync signature. */
|
|
15
|
+
export type GitExec = typeof execFileSync
|
|
16
|
+
|
|
17
|
+
/** Read LYTHOS_SOCKS_PROXY env var and return git-config args if present. */
|
|
18
|
+
function socksProxyArgs(): string[] {
|
|
19
|
+
const proxy = process.env.LYTHOS_SOCKS_PROXY?.trim()
|
|
20
|
+
if (!proxy) return []
|
|
21
|
+
const url = proxy.startsWith('socks5://') ? proxy : `socks5://${proxy}`
|
|
22
|
+
return ['-c', `http.proxy=${url}`, '-c', `https.proxy=${url}`]
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
export interface GitCloneOptions {
|
|
15
26
|
/** Default 1 (shallow). Set to 0 to disable depth (full history). */
|
|
16
27
|
depth?: number
|
|
@@ -22,18 +33,23 @@ export interface GitCloneOptions {
|
|
|
22
33
|
timeout?: number
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
export function gitClone(
|
|
26
|
-
|
|
36
|
+
export function gitClone(
|
|
37
|
+
url: string,
|
|
38
|
+
dir: string,
|
|
39
|
+
opts?: GitCloneOptions,
|
|
40
|
+
exec: GitExec = execFileSync,
|
|
41
|
+
): void {
|
|
42
|
+
const args = [...socksProxyArgs(), 'clone']
|
|
27
43
|
const depth = opts?.depth ?? 1
|
|
28
44
|
if (depth > 0) {
|
|
29
45
|
args.push('--depth', String(depth))
|
|
30
46
|
}
|
|
31
47
|
args.push(url, dir)
|
|
32
48
|
const timeout = opts?.timeout ?? 120_000
|
|
33
|
-
|
|
49
|
+
exec('git', args, { stdio: opts?.stdio ?? 'pipe', timeout })
|
|
34
50
|
|
|
35
51
|
if (opts?.ref && opts.ref !== 'HEAD') {
|
|
36
|
-
|
|
52
|
+
exec('git', [...socksProxyArgs(), 'checkout', opts.ref], { cwd: dir, stdio: opts?.stdio ?? 'pipe', timeout })
|
|
37
53
|
}
|
|
38
54
|
}
|
|
39
55
|
|
|
@@ -42,9 +58,13 @@ export interface GitPullResult {
|
|
|
42
58
|
message: string
|
|
43
59
|
}
|
|
44
60
|
|
|
45
|
-
export function gitPull(
|
|
61
|
+
export function gitPull(
|
|
62
|
+
dir: string,
|
|
63
|
+
timeoutMs: number = 30000,
|
|
64
|
+
exec: GitExec = execFileSync,
|
|
65
|
+
): GitPullResult {
|
|
46
66
|
try {
|
|
47
|
-
const output =
|
|
67
|
+
const output = exec('git', [...socksProxyArgs(), 'pull'], {
|
|
48
68
|
cwd: dir,
|
|
49
69
|
encoding: 'utf-8',
|
|
50
70
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub naming convention validators — guard layer for locator parsing.
|
|
3
|
+
*
|
|
4
|
+
* Rejects inputs that violate GitHub's published naming rules before they
|
|
5
|
+
* reach git operations. Practical security over theoretical compatibility:
|
|
6
|
+
* we target GitHub-hosted skills; inputs that GitHub itself would reject
|
|
7
|
+
* should not reach git clone/checkout.
|
|
8
|
+
*
|
|
9
|
+
* Reference: playground/github-user-repo-validation.txt
|
|
10
|
+
* Design: multi-guard (卫语句) over monster regex — each rule is independently
|
|
11
|
+
* readable, maintainable, and produces specific error messages.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Username (Owner) ─────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const RESERVED_USERNAMES = new Set([
|
|
17
|
+
'admin', 'support', 'help', 'about', 'pricing',
|
|
18
|
+
'api', 'www', 'mail', 'ftp', 'smtp', 'imap',
|
|
19
|
+
'signup', 'login', 'logout', 'settings',
|
|
20
|
+
'dashboard', 'explore', 'trending', 'collections',
|
|
21
|
+
'topics', 'marketplace', 'security', 'insights',
|
|
22
|
+
'github', 'git', 'null', 'undefined', 'new',
|
|
23
|
+
'notifications', 'sessions', 'oauth', 'enterprise',
|
|
24
|
+
'features', 'team', 'teams', 'organizations',
|
|
25
|
+
'users', 'repos', 'search', 'gist', 'gists',
|
|
26
|
+
'blog', 'downloads', 'contact', 'jobs', 'issues',
|
|
27
|
+
'pulls', 'commits', 'branches', 'releases',
|
|
28
|
+
'tags', 'labels', 'milestones', 'wiki', 'graphs',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
export interface NameResult {
|
|
32
|
+
valid: boolean
|
|
33
|
+
errors: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateGitHubOwner(owner: string): NameResult {
|
|
37
|
+
const errors: string[] = []
|
|
38
|
+
|
|
39
|
+
if (!owner || owner.length === 0) {
|
|
40
|
+
errors.push('owner cannot be empty')
|
|
41
|
+
return { valid: false, errors }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (owner.length > 39) {
|
|
45
|
+
errors.push(`owner "${owner}" exceeds 39 characters (${owner.length})`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!/^[a-zA-Z0-9-]+$/.test(owner)) {
|
|
49
|
+
errors.push(`owner "${owner}" contains invalid characters (only a-z, A-Z, 0-9, - allowed)`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (owner.startsWith('-')) {
|
|
53
|
+
errors.push(`owner "${owner}" cannot start with hyphen`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (owner.endsWith('-')) {
|
|
57
|
+
errors.push(`owner "${owner}" cannot end with hyphen`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (owner.includes('--')) {
|
|
61
|
+
errors.push(`owner "${owner}" cannot contain consecutive hyphens`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (RESERVED_USERNAMES.has(owner.toLowerCase())) {
|
|
65
|
+
errors.push(`"${owner}" is a reserved GitHub username`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { valid: errors.length === 0, errors }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Repository Name ──────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export function validateGitHubRepo(repo: string): NameResult {
|
|
74
|
+
const errors: string[] = []
|
|
75
|
+
|
|
76
|
+
if (!repo || repo.length === 0) {
|
|
77
|
+
errors.push('repo cannot be empty')
|
|
78
|
+
return { valid: false, errors }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (repo.length > 100) {
|
|
82
|
+
errors.push(`repo "${repo}" exceeds 100 characters (${repo.length})`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(repo)) {
|
|
86
|
+
errors.push(`repo "${repo}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (repo === '.' || repo === '..') {
|
|
90
|
+
errors.push('repo cannot be "." or ".."')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (repo.toLowerCase().endsWith('.git')) {
|
|
94
|
+
errors.push(`repo "${repo}" cannot end with ".git"`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { valid: errors.length === 0, errors }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Directory / Skill Path Segment ───────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export function validateSkillSegment(segment: string): NameResult {
|
|
103
|
+
const errors: string[] = []
|
|
104
|
+
|
|
105
|
+
if (!segment || segment.length === 0) {
|
|
106
|
+
errors.push('directory segment cannot be empty')
|
|
107
|
+
return { valid: false, errors }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (segment.startsWith('.')) {
|
|
111
|
+
errors.push(`directory "${segment}" cannot start with "." (hidden dir)`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (segment.endsWith('.lock')) {
|
|
115
|
+
errors.push(`directory "${segment}" cannot end with ".lock" (Git reserved)`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (segment.includes('..')) {
|
|
119
|
+
errors.push(`directory "${segment}" cannot contain ".."`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (segment.includes('@{')) {
|
|
123
|
+
errors.push(`directory "${segment}" cannot contain "@{" (Git syntax conflict)`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (/[\\/:*?"<>|]/.test(segment)) {
|
|
127
|
+
errors.push(`directory "${segment}" contains OS-forbidden characters`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (/[\x00-\x1f]/.test(segment)) {
|
|
131
|
+
errors.push(`directory "${segment}" contains control characters`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (segment === '@') {
|
|
135
|
+
errors.push('directory cannot be "@"')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { valid: errors.length === 0, errors }
|
|
139
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type {
|
|
|
15
15
|
FetchIO,
|
|
16
16
|
} from './types.js'
|
|
17
17
|
|
|
18
|
+
export { getMirror, rewriteUrl, mirrorUrls } from './mirror.js'
|
|
18
19
|
export { parseLocator, formatLocator } from './parse-locator.js'
|
|
19
20
|
export type { DirEntry, ListPlan, ListPlanEntry } from './cold-pool.js'
|
|
20
21
|
export { ColdPool, DEFAULT_COLD_POOL_PATH, buildListPlan } from './cold-pool.js'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { probeConnectivity } from './mirror.js'
|
|
3
|
+
|
|
4
|
+
describe('probeConnectivity', () => {
|
|
5
|
+
let originalFetch: typeof fetch
|
|
6
|
+
let fetchCalls: Array<{ url: string; options: RequestInit }>
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
originalFetch = globalThis.fetch
|
|
10
|
+
fetchCalls = []
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = originalFetch
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function mockFetch(
|
|
18
|
+
responses: Map<string, Response>,
|
|
19
|
+
defaultDelay = 0,
|
|
20
|
+
) {
|
|
21
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
22
|
+
const url = String(input)
|
|
23
|
+
fetchCalls.push({ url, options: init ?? {} })
|
|
24
|
+
|
|
25
|
+
if (defaultDelay > 0) {
|
|
26
|
+
await new Promise((r) => setTimeout(r, defaultDelay))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const res = responses.get(url)
|
|
30
|
+
if (res) return res
|
|
31
|
+
throw new Error(`ENOTFOUND ${url}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Tracer Bullet ──
|
|
36
|
+
test('direct reachable → returns direct path', async () => {
|
|
37
|
+
mockFetch(
|
|
38
|
+
new Map([
|
|
39
|
+
['https://example.com/skill', new Response(null, { status: 200 })],
|
|
40
|
+
]),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const result = await probeConnectivity('https://example.com/skill')
|
|
44
|
+
|
|
45
|
+
expect(result).toMatchObject({
|
|
46
|
+
path: 'direct',
|
|
47
|
+
url: 'https://example.com/skill',
|
|
48
|
+
latencyMs: expect.any(Number),
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ── Vertical Slice 2 ──
|
|
53
|
+
test('direct fails, mirror ok → returns mirror path', async () => {
|
|
54
|
+
mockFetch(
|
|
55
|
+
new Map([
|
|
56
|
+
['https://example.com/skill', new Response(null, { status: 500 })],
|
|
57
|
+
['https://ghfast.top/https://example.com/skill', new Response(null, { status: 200 })],
|
|
58
|
+
]),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const result = await probeConnectivity('https://example.com/skill')
|
|
62
|
+
|
|
63
|
+
expect(result).toMatchObject({
|
|
64
|
+
path: 'mirror',
|
|
65
|
+
url: 'https://ghfast.top/https://example.com/skill',
|
|
66
|
+
latencyMs: expect.any(Number),
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ── Vertical Slice 3 ──
|
|
71
|
+
test('all paths fail → returns undefined', async () => {
|
|
72
|
+
mockFetch(new Map())
|
|
73
|
+
|
|
74
|
+
const result = await probeConnectivity('https://example.com/skill')
|
|
75
|
+
|
|
76
|
+
expect(result).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ── Vertical Slice 4 ──
|
|
80
|
+
test('404 is treated as reachable (server alive)', async () => {
|
|
81
|
+
mockFetch(
|
|
82
|
+
new Map([
|
|
83
|
+
['https://example.com/skill', new Response(null, { status: 404 })],
|
|
84
|
+
]),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const result = await probeConnectivity('https://example.com/skill')
|
|
88
|
+
|
|
89
|
+
expect(result?.path).toBe('direct')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// ── Vertical Slice 5: Racing behavior ──
|
|
93
|
+
test('probes race concurrently, not sequentially', async () => {
|
|
94
|
+
const start = performance.now()
|
|
95
|
+
|
|
96
|
+
mockFetch(
|
|
97
|
+
new Map([
|
|
98
|
+
['https://example.com/skill', new Response(null, { status: 500 })],
|
|
99
|
+
['https://ghfast.top/https://example.com/skill', new Response(null, { status: 200 })],
|
|
100
|
+
['https://ghproxy.com/https://example.com/skill', new Response(null, { status: 200 })],
|
|
101
|
+
['https://mirror.ghproxy.com/https://example.com/skill', new Response(null, { status: 200 })],
|
|
102
|
+
]),
|
|
103
|
+
50, // each mock fetch takes 50ms
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const result = await probeConnectivity('https://example.com/skill')
|
|
107
|
+
|
|
108
|
+
const elapsed = performance.now() - start
|
|
109
|
+
|
|
110
|
+
expect(result?.path).toBe('mirror')
|
|
111
|
+
// Sequential would take ~200ms (4 × 50ms). Racing should be ~50-100ms.
|
|
112
|
+
expect(elapsed).toBeLessThan(150)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ── Vertical Slice 6: Timeout honored ──
|
|
116
|
+
test('timeout aborts slow probes', async () => {
|
|
117
|
+
globalThis.fetch = async (_input, init?) => {
|
|
118
|
+
return new Promise((_, reject) => {
|
|
119
|
+
const timer = setTimeout(
|
|
120
|
+
() => reject(new Error('The operation timed out.')),
|
|
121
|
+
10_000,
|
|
122
|
+
)
|
|
123
|
+
init?.signal?.addEventListener('abort', () => {
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
reject(new Error('The operation was aborted.'))
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const start = performance.now()
|
|
131
|
+
const result = await probeConnectivity('https://example.com/skill', 100)
|
|
132
|
+
const elapsed = performance.now() - start
|
|
133
|
+
|
|
134
|
+
expect(result).toBeUndefined()
|
|
135
|
+
expect(elapsed).toBeLessThan(500) // 100ms timeout + overhead
|
|
136
|
+
})
|
|
137
|
+
})
|
package/src/mirror.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror URL rewriting for restricted networks.
|
|
3
|
+
*
|
|
4
|
+
* Three layers, checked in order:
|
|
5
|
+
* 1. LYTHOSKILL_GH_MIRROR env var (explicit user choice)
|
|
6
|
+
* 2. HTTPS_PROXY / HTTP_PROXY (standard — Bun fetch and git respect these natively)
|
|
7
|
+
* 3. Known public mirrors (auto-fallback when GitHub is unreachable)
|
|
8
|
+
*
|
|
9
|
+
* Per ADR-20260512191438745.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Known public mirrors (tried in order when GitHub is unreachable) ──
|
|
13
|
+
|
|
14
|
+
const KNOWN_MIRRORS = [
|
|
15
|
+
'https://ghfast.top', // commonly used in CN
|
|
16
|
+
'https://ghproxy.com', // commonly used in CN
|
|
17
|
+
'https://mirror.ghproxy.com', // ghproxy alternative domain
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// ── Explicit user mirror ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export function getMirror(): string | undefined {
|
|
23
|
+
const v = process.env.LYTHOSKILL_GH_MIRROR?.trim()
|
|
24
|
+
if (!v) return undefined
|
|
25
|
+
if (v.startsWith('http://') || v.startsWith('https://')) {
|
|
26
|
+
return v.replace(/\/+$/, '')
|
|
27
|
+
}
|
|
28
|
+
return `https://${v.replace(/\/+$/, '')}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── URL rewriting ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export function rewriteUrl(url: string, mirror?: string): string {
|
|
34
|
+
if (!mirror) return url
|
|
35
|
+
return `${mirror}/${url}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mirrorUrls(url: string): string[] {
|
|
39
|
+
const mirrors = [...KNOWN_MIRRORS]
|
|
40
|
+
const explicit = getMirror()
|
|
41
|
+
if (explicit) mirrors.unshift(explicit)
|
|
42
|
+
return mirrors.map(m => rewriteUrl(url, m))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isLikelyGitHubBlock(err: unknown): boolean {
|
|
46
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
47
|
+
return /fetch.*failed|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|network.*unreachable/i.test(msg)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Connectivity probe ────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface ProbeResult {
|
|
53
|
+
path: 'direct' | 'mirror'
|
|
54
|
+
url: string
|
|
55
|
+
latencyMs: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProbeFailure {
|
|
59
|
+
url: string
|
|
60
|
+
reason: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Quick connectivity probe: race direct + all mirrors concurrently.
|
|
65
|
+
* Uses short timeout (default 3s) to fail fast instead of waiting for
|
|
66
|
+
* the full git clone / fetch timeout.
|
|
67
|
+
*
|
|
68
|
+
* Returns the first success, or undefined with failures recorded.
|
|
69
|
+
*/
|
|
70
|
+
export async function probeConnectivity(
|
|
71
|
+
url: string,
|
|
72
|
+
timeoutMs = 3000,
|
|
73
|
+
): Promise<ProbeResult & { failures?: ProbeFailure[] } | undefined> {
|
|
74
|
+
const start = performance.now()
|
|
75
|
+
const failures: ProbeFailure[] = []
|
|
76
|
+
|
|
77
|
+
async function tryFetch(
|
|
78
|
+
targetUrl: string,
|
|
79
|
+
pathLabel: 'direct' | 'mirror',
|
|
80
|
+
): Promise<ProbeResult | undefined> {
|
|
81
|
+
const t0 = performance.now()
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(targetUrl, {
|
|
84
|
+
method: 'HEAD',
|
|
85
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
86
|
+
redirect: 'follow',
|
|
87
|
+
})
|
|
88
|
+
if (res.ok || res.status === 404) {
|
|
89
|
+
// 404 means server reachable, resource may not exist
|
|
90
|
+
return { path: pathLabel, url: targetUrl, latencyMs: Math.round(performance.now() - t0) }
|
|
91
|
+
}
|
|
92
|
+
failures.push({ url: targetUrl, reason: `HTTP ${res.status}` })
|
|
93
|
+
} catch (err) {
|
|
94
|
+
failures.push({
|
|
95
|
+
url: targetUrl,
|
|
96
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build all probe promises (direct + mirrors) and race for first success
|
|
103
|
+
const probes = [tryFetch(url, 'direct')]
|
|
104
|
+
for (const mirrorUrl of mirrorUrls(url)) {
|
|
105
|
+
probes.push(tryFetch(mirrorUrl, 'mirror'))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let result: ProbeResult | undefined
|
|
109
|
+
let pending = probes.length
|
|
110
|
+
|
|
111
|
+
await new Promise<void>((resolve) => {
|
|
112
|
+
for (const p of probes) {
|
|
113
|
+
p.then((res) => {
|
|
114
|
+
if (!result && res) {
|
|
115
|
+
result = res
|
|
116
|
+
resolve()
|
|
117
|
+
}
|
|
118
|
+
pending--
|
|
119
|
+
if (pending === 0) resolve()
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (result) {
|
|
125
|
+
return { ...result, failures }
|
|
126
|
+
}
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
package/src/parse-locator.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* `#ref` suffix (branch/tag/commit) is compatible with skills.sh's
|
|
19
19
|
* `parseFragmentRef`. The ref is passed to gitClone for checkout.
|
|
20
20
|
*/
|
|
21
|
+
import { validateGitHubOwner, validateGitHubRepo, validateSkillSegment } from './github-naming.js'
|
|
21
22
|
import type { Locator } from './types.js'
|
|
22
23
|
|
|
23
24
|
export function parseLocator(input: string): Locator | null {
|
|
@@ -47,12 +48,33 @@ export function parseLocator(input: string): Locator | null {
|
|
|
47
48
|
const isLocalhost = parts[0] === 'localhost'
|
|
48
49
|
if (!isLocalhost && !parts[0].includes('.')) return null
|
|
49
50
|
|
|
51
|
+
const owner = parts[1]
|
|
52
|
+
const repo = parts[2]
|
|
53
|
+
const skill = parts.length > 3 ? parts.slice(3).join('/') : null
|
|
54
|
+
|
|
55
|
+
// Validate naming conventions for remote locators (GitHub rules).
|
|
56
|
+
// localhost gets relaxed validation — local filesystem, not GitHub.
|
|
57
|
+
if (!isLocalhost) {
|
|
58
|
+
const ownerCheck = validateGitHubOwner(owner)
|
|
59
|
+
if (!ownerCheck.valid) return null
|
|
60
|
+
|
|
61
|
+
const repoCheck = validateGitHubRepo(repo)
|
|
62
|
+
if (!repoCheck.valid) return null
|
|
63
|
+
|
|
64
|
+
if (skill) {
|
|
65
|
+
for (const segment of skill.split('/')) {
|
|
66
|
+
const segCheck = validateSkillSegment(segment)
|
|
67
|
+
if (!segCheck.valid) return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
return {
|
|
51
73
|
raw: input,
|
|
52
74
|
host: parts[0],
|
|
53
|
-
owner
|
|
54
|
-
repo
|
|
55
|
-
skill
|
|
75
|
+
owner,
|
|
76
|
+
repo,
|
|
77
|
+
skill,
|
|
56
78
|
ref,
|
|
57
79
|
isLocalhost,
|
|
58
80
|
}
|
package/src/prune-plan.ts
CHANGED