@lythos/cold-pool 0.9.24
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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +39 -0
- package/src/cold-pool.test.ts +101 -0
- package/src/cold-pool.ts +87 -0
- package/src/fetch-plan.test.ts +117 -0
- package/src/fetch-plan.ts +72 -0
- package/src/git-io.test.ts +54 -0
- package/src/git-io.ts +90 -0
- package/src/github-tree.test.ts +100 -0
- package/src/github-tree.ts +111 -0
- package/src/index.ts +33 -0
- package/src/infer-skill-path.test.ts +72 -0
- package/src/infer-skill-path.ts +43 -0
- package/src/parse-locator.test.ts +125 -0
- package/src/parse-locator.ts +45 -0
- package/src/types.ts +83 -0
- package/src/validate-plan.test.ts +162 -0
- package/src/validate-plan.ts +259 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lythos-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @lythos/cold-pool
|
|
2
|
+
|
|
3
|
+
Cold pool service layer for the lythoskill ecosystem.
|
|
4
|
+
|
|
5
|
+
> Status: scaffold (0.9.x). Public API will stabilize at 0.10.0.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
A dedicated resource-holder package for the cold pool — the local cache of
|
|
10
|
+
skill repositories at `~/.agents/skill-repos/` (configurable). This package
|
|
11
|
+
is the **only** layer in the ecosystem that holds git side-effects.
|
|
12
|
+
deck / curator / arena consume it instead of running `git clone` themselves.
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
Three layers, sharing the project's `intent → plan → execute` pattern
|
|
17
|
+
(`cortex/wiki/01-patterns/2026-05-04-intent-plan-execute-fractal-architecture-pattern.md`):
|
|
18
|
+
|
|
19
|
+
- **Resource layer** — `ColdPool` class holds path, metadata index, reconcile
|
|
20
|
+
entry. Read-only accessors.
|
|
21
|
+
- **Plan layer** — `buildFetchPlan(coldPool, locator) → FetchPlan`,
|
|
22
|
+
`buildValidationPlan(coldPool, locator) → ValidationReport`. Pure data,
|
|
23
|
+
no side effects, dry-run printable.
|
|
24
|
+
- **Execute layer** — `executeFetchPlan(plan, io: FetchIO)`. IO is
|
|
25
|
+
injectable; defaults to real git operations. Tests swap mocks.
|
|
26
|
+
|
|
27
|
+
## Locator
|
|
28
|
+
|
|
29
|
+
Per `ADR-20260502012643244`, locators are FQ-only:
|
|
30
|
+
|
|
31
|
+
- `host.tld/owner/repo[/skill]` — remote skill (monorepo, flat, or arbitrary subdir)
|
|
32
|
+
- `localhost/<name>` — local-only skill, no remote origin
|
|
33
|
+
|
|
34
|
+
Bare names and `owner/repo` shorthand are rejected — `parseLocator` returns null.
|
|
35
|
+
|
|
36
|
+
## Granularity boundary
|
|
37
|
+
|
|
38
|
+
`skill-deck.lock` is **working-set granularity** (per-skill in `.claude/skills/`).
|
|
39
|
+
The cold pool reconciliation runs at **repo+ref granularity** (per cloned
|
|
40
|
+
repository at a specific commit). The two are not conflated. See
|
|
41
|
+
`ADR-20260507021957847`.
|
|
42
|
+
|
|
43
|
+
## Package status
|
|
44
|
+
|
|
45
|
+
This is internal monorepo infrastructure during the 0.9.x line. Public
|
|
46
|
+
npm publish + stable API targeted for 0.10.0. See
|
|
47
|
+
`EPIC-20260507020846020` for the rollout plan.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lythos/cold-pool",
|
|
3
|
+
"version": "0.9.24",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"ai-agent",
|
|
7
|
+
"skill",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"agent-skills",
|
|
10
|
+
"llm-tooling",
|
|
11
|
+
"lythoskill",
|
|
12
|
+
"cold-pool"
|
|
13
|
+
],
|
|
14
|
+
"author": "lythos-labs",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "src/index.ts",
|
|
21
|
+
"types": "src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/lythos-labs/lythoskill.git",
|
|
30
|
+
"directory": "packages/lythoskill-cold-pool"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/lythos-labs/lythoskill/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool'
|
|
6
|
+
import { parseLocator } from './parse-locator'
|
|
7
|
+
|
|
8
|
+
describe('ColdPool — constructor', () => {
|
|
9
|
+
test('default path when none given', () => {
|
|
10
|
+
const pool = new ColdPool()
|
|
11
|
+
expect(pool.path).toBe(DEFAULT_COLD_POOL_PATH)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('custom path is stored verbatim', () => {
|
|
15
|
+
const pool = new ColdPool('/tmp/custom-pool')
|
|
16
|
+
expect(pool.path).toBe('/tmp/custom-pool')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('ColdPool.resolveDir — pure path computation', () => {
|
|
21
|
+
const pool = new ColdPool('/cold')
|
|
22
|
+
|
|
23
|
+
test('host/owner/repo', () => {
|
|
24
|
+
const loc = parseLocator('github.com/anthropics/skills')!
|
|
25
|
+
expect(pool.resolveDir(loc)).toBe('/cold/github.com/anthropics/skills')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('host/owner/repo/skill — skill subpath does NOT extend the dir', () => {
|
|
29
|
+
// resolveDir returns repo dir, not skill dir. Skill subpath is for findSkillDir later.
|
|
30
|
+
const loc = parseLocator('github.com/anthropics/skills/skills/pdf')!
|
|
31
|
+
expect(pool.resolveDir(loc)).toBe('/cold/github.com/anthropics/skills')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('localhost form — uniform <pool>/<host>/<owner>/<repo>, no special-case', () => {
|
|
35
|
+
const loc = parseLocator('localhost/me/my-skill')!
|
|
36
|
+
expect(pool.resolveDir(loc)).toBe('/cold/localhost/me/my-skill')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ColdPool — fs-backed read accessors', () => {
|
|
41
|
+
// Build a small fake cold pool on disk per the uniform layout:
|
|
42
|
+
// `<pool>/<host>/<owner>/<repo>/SKILL.md` for ALL hosts including localhost.
|
|
43
|
+
// "Directory layers = FQ locator segments."
|
|
44
|
+
const root = mkdtempSync(join(tmpdir(), 'cold-pool-test-'))
|
|
45
|
+
mkdirSync(join(root, 'github.com/owner/repo-a'), { recursive: true })
|
|
46
|
+
mkdirSync(join(root, 'github.com/owner/repo-b'), { recursive: true })
|
|
47
|
+
mkdirSync(join(root, 'localhost/me/skill-x'), { recursive: true })
|
|
48
|
+
writeFileSync(join(root, 'localhost/me/skill-x/SKILL.md'), '# x')
|
|
49
|
+
// Hidden dir should be skipped
|
|
50
|
+
mkdirSync(join(root, '.git'), { recursive: true })
|
|
51
|
+
|
|
52
|
+
const pool = new ColdPool(root)
|
|
53
|
+
|
|
54
|
+
test('has() returns true for existing repo', () => {
|
|
55
|
+
const loc = parseLocator('github.com/owner/repo-a')!
|
|
56
|
+
expect(pool.has(loc)).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('has() returns false for missing repo', () => {
|
|
60
|
+
const loc = parseLocator('github.com/owner/missing')!
|
|
61
|
+
expect(pool.has(loc)).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('has() works for localhost form (uniform <host>/<owner>/<repo>)', () => {
|
|
65
|
+
const loc = parseLocator('localhost/me/skill-x')!
|
|
66
|
+
expect(pool.has(loc)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('list() enumerates uniform host/owner/repo across all hosts, skips hidden', () => {
|
|
70
|
+
const entries = pool.list().sort()
|
|
71
|
+
expect(entries).toEqual([
|
|
72
|
+
join(root, 'github.com/owner/repo-a'),
|
|
73
|
+
join(root, 'github.com/owner/repo-b'),
|
|
74
|
+
join(root, 'localhost/me/skill-x'),
|
|
75
|
+
].sort())
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('list() includes legacy drift entries (depth-2 SKILL.md, missing repo level) for cleanup awareness', () => {
|
|
79
|
+
const driftRoot = mkdtempSync(join(tmpdir(), 'cold-pool-drift-'))
|
|
80
|
+
mkdirSync(join(driftRoot, 'github.com/o/r'), { recursive: true })
|
|
81
|
+
mkdirSync(join(driftRoot, 'localhost/me/canonical'), { recursive: true })
|
|
82
|
+
// Legacy drift A: top-level dir with SKILL.md (post-compaction agent invention,
|
|
83
|
+
// bare-name "skill-a" hack)
|
|
84
|
+
mkdirSync(join(driftRoot, 'legacy-toplevel'), { recursive: true })
|
|
85
|
+
writeFileSync(join(driftRoot, 'legacy-toplevel/SKILL.md'), '# legacy A')
|
|
86
|
+
// Legacy drift B: <localhost>/<name>/SKILL.md (depth-2 missing repo level,
|
|
87
|
+
// earlier `localhost/<name>` form before owner/repo became required)
|
|
88
|
+
mkdirSync(join(driftRoot, 'localhost/legacy-name'), { recursive: true })
|
|
89
|
+
writeFileSync(join(driftRoot, 'localhost/legacy-name/SKILL.md'), '# legacy B')
|
|
90
|
+
|
|
91
|
+
const driftPool = new ColdPool(driftRoot)
|
|
92
|
+
const entries = driftPool.list().sort()
|
|
93
|
+
expect(entries).toContain(join(driftRoot, 'legacy-toplevel'))
|
|
94
|
+
expect(entries).toContain(join(driftRoot, 'localhost/legacy-name'))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('list() returns [] when path does not exist', () => {
|
|
98
|
+
const empty = new ColdPool('/no/such/path')
|
|
99
|
+
expect(empty.list()).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
})
|
package/src/cold-pool.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ColdPool — dedicated resource holder.
|
|
3
|
+
*
|
|
4
|
+
* Owns the cold-pool path, exposes read-only accessors. Operations
|
|
5
|
+
* (fetch, validate, reconcile) are external functions that take a
|
|
6
|
+
* ColdPool and return Plan-shaped data, per the intent/plan/execute
|
|
7
|
+
* pattern.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import type { Locator } from './types.js'
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_COLD_POOL_PATH = process.env.LYTHOS_COLD_POOL
|
|
15
|
+
?? join(homedir(), '.agents/skill-repos')
|
|
16
|
+
|
|
17
|
+
export class ColdPool {
|
|
18
|
+
readonly path: string
|
|
19
|
+
|
|
20
|
+
constructor(coldPoolPath?: string) {
|
|
21
|
+
this.path = coldPoolPath ?? DEFAULT_COLD_POOL_PATH
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute the cold-pool directory for a locator. No fs check.
|
|
26
|
+
*
|
|
27
|
+
* Layout invariant: `<pool>/<host>/<owner>/<repo>` for ALL locators
|
|
28
|
+
* including localhost. No special-case branching — "directory layers
|
|
29
|
+
* = FQ locator segments" (per user 2026-05-07). Skill subpath
|
|
30
|
+
* extends within the repo dir (resolveDir returns the repo dir).
|
|
31
|
+
*/
|
|
32
|
+
resolveDir(locator: Locator): string {
|
|
33
|
+
return join(this.path, locator.host, locator.owner, locator.repo)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a locator's repo directory exists in the pool. */
|
|
37
|
+
has(locator: Locator): boolean {
|
|
38
|
+
return existsSync(this.resolveDir(locator))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Enumerate cold-pool entries.
|
|
43
|
+
*
|
|
44
|
+
* Uniform layout: `<pool>/<host>/<owner>/<repo>`. localhost is just
|
|
45
|
+
* another host. No localhost special-case.
|
|
46
|
+
*
|
|
47
|
+
* Legacy drift: `<pool>/<x>/SKILL.md` (depth 2 with SKILL.md) or
|
|
48
|
+
* `<pool>/localhost/<name>/SKILL.md` (depth 3 with SKILL.md, missing
|
|
49
|
+
* owner/repo) are non-canonical state from older agents that bypassed
|
|
50
|
+
* FQ-only enforcement. Surface them so prune can offer cleanup.
|
|
51
|
+
*/
|
|
52
|
+
list(): string[] {
|
|
53
|
+
if (!existsSync(this.path)) return []
|
|
54
|
+
const repos: string[] = []
|
|
55
|
+
|
|
56
|
+
for (const host of readdirSync(this.path, { withFileTypes: true })) {
|
|
57
|
+
if (!host.isDirectory() || host.name.startsWith('.')) continue
|
|
58
|
+
const hostPath = join(this.path, host.name)
|
|
59
|
+
|
|
60
|
+
// Legacy drift: top-level dir with SKILL.md (not canonical 3-segment)
|
|
61
|
+
if (existsSync(join(hostPath, 'SKILL.md'))) {
|
|
62
|
+
repos.push(hostPath)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Canonical: <host>/<owner>/<repo>
|
|
67
|
+
for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
|
|
68
|
+
if (!owner.isDirectory() || owner.name.startsWith('.')) continue
|
|
69
|
+
const ownerPath = join(hostPath, owner.name)
|
|
70
|
+
|
|
71
|
+
// Legacy drift: <host>/<x>/SKILL.md (depth 2 missing repo level —
|
|
72
|
+
// typically `localhost/<name>/SKILL.md` from older agents)
|
|
73
|
+
if (existsSync(join(ownerPath, 'SKILL.md'))) {
|
|
74
|
+
repos.push(ownerPath)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
|
|
79
|
+
if (!repo.isDirectory() || repo.name.startsWith('.')) continue
|
|
80
|
+
repos.push(join(ownerPath, repo.name))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return repos
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, mkdirSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { ColdPool } from './cold-pool'
|
|
6
|
+
import { parseLocator } from './parse-locator'
|
|
7
|
+
import { buildFetchPlan, executeFetchPlan } from './fetch-plan'
|
|
8
|
+
import type { FetchIO } from './types'
|
|
9
|
+
|
|
10
|
+
describe('buildFetchPlan', () => {
|
|
11
|
+
test('builds targetDir + cloneUrl from locator', () => {
|
|
12
|
+
const pool = new ColdPool('/cold')
|
|
13
|
+
const loc = parseLocator('github.com/anthropics/skills/skills/pdf')!
|
|
14
|
+
const plan = buildFetchPlan(pool, loc)
|
|
15
|
+
expect(plan.targetDir).toBe('/cold/github.com/anthropics/skills')
|
|
16
|
+
expect(plan.cloneUrl).toBe('https://github.com/anthropics/skills.git')
|
|
17
|
+
expect(plan.alreadyExists).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('alreadyExists reflects fs presence', () => {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), 'fetch-plan-test-'))
|
|
22
|
+
mkdirSync(join(root, 'github.com/owner/repo'), { recursive: true })
|
|
23
|
+
const pool = new ColdPool(root)
|
|
24
|
+
const loc = parseLocator('github.com/owner/repo')!
|
|
25
|
+
const plan = buildFetchPlan(pool, loc)
|
|
26
|
+
expect(plan.alreadyExists).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('localhost locator gets empty cloneUrl + uniform <host>/<owner>/<repo> path', () => {
|
|
30
|
+
const pool = new ColdPool('/cold')
|
|
31
|
+
const loc = parseLocator('localhost/me/my-skill')!
|
|
32
|
+
const plan = buildFetchPlan(pool, loc)
|
|
33
|
+
expect(plan.cloneUrl).toBe('')
|
|
34
|
+
expect(plan.targetDir).toBe('/cold/localhost/me/my-skill')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('passes ref through', () => {
|
|
38
|
+
const pool = new ColdPool('/cold')
|
|
39
|
+
const loc = parseLocator('github.com/o/r')!
|
|
40
|
+
const plan = buildFetchPlan(pool, loc, { ref: 'v1.2.3' })
|
|
41
|
+
expect(plan.ref).toBe('v1.2.3')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('executeFetchPlan', () => {
|
|
46
|
+
test('alreadyExists path → already-present, no clone called', () => {
|
|
47
|
+
const pool = new ColdPool('/cold')
|
|
48
|
+
const loc = parseLocator('github.com/o/r')!
|
|
49
|
+
const plan = { ...buildFetchPlan(pool, loc), alreadyExists: true }
|
|
50
|
+
let cloneCalls = 0
|
|
51
|
+
const io: FetchIO = {
|
|
52
|
+
gitClone: () => { cloneCalls++ },
|
|
53
|
+
exists: () => true,
|
|
54
|
+
}
|
|
55
|
+
const result = executeFetchPlan(plan, io)
|
|
56
|
+
expect(result.status).toBe('already-present')
|
|
57
|
+
expect(cloneCalls).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('clones when target absent', () => {
|
|
61
|
+
const pool = new ColdPool('/cold')
|
|
62
|
+
const loc = parseLocator('github.com/o/r')!
|
|
63
|
+
const plan = buildFetchPlan(pool, loc)
|
|
64
|
+
const cloneArgs: Array<{ url: string; dir: string; opts: unknown }> = []
|
|
65
|
+
const io: FetchIO = {
|
|
66
|
+
gitClone: (url, dir, opts) => { cloneArgs.push({ url, dir, opts }) },
|
|
67
|
+
exists: () => false,
|
|
68
|
+
}
|
|
69
|
+
const result = executeFetchPlan(plan, io)
|
|
70
|
+
expect(result.status).toBe('cloned')
|
|
71
|
+
expect(cloneArgs.length).toBe(1)
|
|
72
|
+
expect(cloneArgs[0].url).toBe('https://github.com/o/r.git')
|
|
73
|
+
expect(cloneArgs[0].dir).toBe('/cold/github.com/o/r')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('clone error → failed result with message', () => {
|
|
77
|
+
const pool = new ColdPool('/cold')
|
|
78
|
+
const loc = parseLocator('github.com/o/r')!
|
|
79
|
+
const plan = buildFetchPlan(pool, loc)
|
|
80
|
+
const io: FetchIO = {
|
|
81
|
+
gitClone: () => { throw new Error('boom') },
|
|
82
|
+
exists: () => false,
|
|
83
|
+
}
|
|
84
|
+
const result = executeFetchPlan(plan, io)
|
|
85
|
+
expect(result.status).toBe('failed')
|
|
86
|
+
expect(result.message).toContain('boom')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('localhost locator refuses to fetch', () => {
|
|
90
|
+
const pool = new ColdPool('/cold')
|
|
91
|
+
const loc = parseLocator('localhost/me/x')!
|
|
92
|
+
const plan = buildFetchPlan(pool, loc)
|
|
93
|
+
let cloneCalls = 0
|
|
94
|
+
const io: FetchIO = {
|
|
95
|
+
gitClone: () => { cloneCalls++ },
|
|
96
|
+
exists: () => false,
|
|
97
|
+
}
|
|
98
|
+
const result = executeFetchPlan(plan, io)
|
|
99
|
+
expect(result.status).toBe('failed')
|
|
100
|
+
expect(result.message).toContain('localhost')
|
|
101
|
+
expect(cloneCalls).toBe(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('log IO is invoked', () => {
|
|
105
|
+
const pool = new ColdPool('/cold')
|
|
106
|
+
const loc = parseLocator('github.com/o/r')!
|
|
107
|
+
const plan = buildFetchPlan(pool, loc)
|
|
108
|
+
const lines: string[] = []
|
|
109
|
+
const io: FetchIO = {
|
|
110
|
+
gitClone: () => {},
|
|
111
|
+
exists: () => false,
|
|
112
|
+
log: (m) => lines.push(m),
|
|
113
|
+
}
|
|
114
|
+
executeFetchPlan(plan, io)
|
|
115
|
+
expect(lines.some((l) => l.includes('cloning'))).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FetchPlan + executor.
|
|
3
|
+
*
|
|
4
|
+
* Per-locator fetch primitive. `buildFetchPlan` is pure (computes
|
|
5
|
+
* targetDir + cloneUrl, peeks fs for `alreadyExists`). `executeFetchPlan`
|
|
6
|
+
* does the side-effecting `git clone` via injected IO.
|
|
7
|
+
*
|
|
8
|
+
* Localhost locators are not fetchable (no remote) — `buildFetchPlan`
|
|
9
|
+
* returns a plan with `alreadyExists: pool.has(locator)` but
|
|
10
|
+
* `executeFetchPlan` will refuse to clone (status: 'failed').
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import type { ColdPool } from './cold-pool.js'
|
|
14
|
+
import type { Locator, FetchPlan, FetchResult, FetchIO } from './types.js'
|
|
15
|
+
import { gitClone } from './git-io.js'
|
|
16
|
+
|
|
17
|
+
export function buildFetchPlan(
|
|
18
|
+
pool: ColdPool,
|
|
19
|
+
locator: Locator,
|
|
20
|
+
opts?: { ref?: string },
|
|
21
|
+
): FetchPlan {
|
|
22
|
+
const targetDir = pool.resolveDir(locator)
|
|
23
|
+
const cloneUrl = locator.isLocalhost
|
|
24
|
+
? ''
|
|
25
|
+
: `https://${locator.host}/${locator.owner}/${locator.repo}.git`
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
locator,
|
|
29
|
+
cloneUrl,
|
|
30
|
+
targetDir,
|
|
31
|
+
ref: opts?.ref,
|
|
32
|
+
alreadyExists: existsSync(targetDir),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function executeFetchPlan(plan: FetchPlan, io?: FetchIO): FetchResult {
|
|
37
|
+
const log = io?.log ?? (() => {})
|
|
38
|
+
const exists = io?.exists ?? existsSync
|
|
39
|
+
const cloneFn = io?.gitClone ?? gitClone
|
|
40
|
+
|
|
41
|
+
if (plan.locator.isLocalhost) {
|
|
42
|
+
return {
|
|
43
|
+
status: 'failed',
|
|
44
|
+
targetDir: plan.targetDir,
|
|
45
|
+
message: 'localhost locators have no remote; nothing to fetch',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (exists(plan.targetDir)) {
|
|
50
|
+
log(`✓ already present: ${plan.targetDir}`)
|
|
51
|
+
return {
|
|
52
|
+
status: 'already-present',
|
|
53
|
+
targetDir: plan.targetDir,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log(`📦 cloning ${plan.cloneUrl} → ${plan.targetDir}`)
|
|
58
|
+
try {
|
|
59
|
+
cloneFn(plan.cloneUrl, plan.targetDir, { depth: 1, ref: plan.ref })
|
|
60
|
+
return {
|
|
61
|
+
status: 'cloned',
|
|
62
|
+
targetDir: plan.targetDir,
|
|
63
|
+
}
|
|
64
|
+
} catch (err: unknown) {
|
|
65
|
+
const e = err as { message?: string }
|
|
66
|
+
return {
|
|
67
|
+
status: 'failed',
|
|
68
|
+
targetDir: plan.targetDir,
|
|
69
|
+
message: e.message ?? 'git clone failed',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
import { detectGitRoot } from './git-io'
|
|
6
|
+
|
|
7
|
+
// Note: gitClone() and gitPull() wrap external `git` invocations and are
|
|
8
|
+
// tested indirectly via executeFetchPlan + integration scenarios. Direct
|
|
9
|
+
// tests would require a fixture remote and slow the CI loop. detectGitRoot
|
|
10
|
+
// is pure fs traversal and is fully tested here.
|
|
11
|
+
|
|
12
|
+
describe('detectGitRoot', () => {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), 'git-root-test-'))
|
|
14
|
+
const repoDir = join(root, 'pool/host/owner/repo')
|
|
15
|
+
const subDir = join(repoDir, 'src/deep')
|
|
16
|
+
mkdirSync(subDir, { recursive: true })
|
|
17
|
+
writeFileSync(join(repoDir, '.git'), '') // .git can be a file (worktree marker) or dir; existsSync covers both
|
|
18
|
+
// Sibling dir without .git
|
|
19
|
+
const orphanDir = join(root, 'pool/orphan')
|
|
20
|
+
mkdirSync(orphanDir, { recursive: true })
|
|
21
|
+
|
|
22
|
+
test('finds .git at the same dir', () => {
|
|
23
|
+
const r = detectGitRoot(repoDir)
|
|
24
|
+
expect(r.gitRoot).toBe(resolve(repoDir))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('walks up to find .git', () => {
|
|
28
|
+
const r = detectGitRoot(subDir)
|
|
29
|
+
expect(r.gitRoot).toBe(resolve(repoDir))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('returns not-found when no .git in walk', () => {
|
|
33
|
+
const r = detectGitRoot(orphanDir)
|
|
34
|
+
expect(r.gitRoot).toBeNull()
|
|
35
|
+
expect(r.reason).toBe('not-found')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('respects coldPool boundary — outside-cold-pool when crossing out', () => {
|
|
39
|
+
// Fresh tmpdir to ensure no .git anywhere on the path back to /
|
|
40
|
+
const isolated = mkdtempSync(join(tmpdir(), 'git-root-isolated-'))
|
|
41
|
+
const inner = join(isolated, 'inner')
|
|
42
|
+
const coldPool = join(isolated, 'pool')
|
|
43
|
+
mkdirSync(inner, { recursive: true })
|
|
44
|
+
mkdirSync(coldPool, { recursive: true })
|
|
45
|
+
const r = detectGitRoot(inner, coldPool)
|
|
46
|
+
expect(r.gitRoot).toBeNull()
|
|
47
|
+
expect(r.reason).toBe('outside-cold-pool')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('coldPool given, walking up within cold pool finds .git', () => {
|
|
51
|
+
const r = detectGitRoot(subDir, join(root, 'pool'))
|
|
52
|
+
expect(r.gitRoot).toBe(resolve(repoDir))
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/git-io.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git IO primitives — the only place in the lythoskill ecosystem that
|
|
3
|
+
* runs `git clone` / `git pull` directly. Consumers (deck/curator/arena)
|
|
4
|
+
* must go through these (or through `executeFetchPlan` which uses them).
|
|
5
|
+
*
|
|
6
|
+
* Per ADR-20260507021957847: cold-pool is the dedicated holder of git
|
|
7
|
+
* side-effects. Direct `execFileSync('git', ...)` calls in other
|
|
8
|
+
* packages are an anti-pattern (controller bypassing service to DAO).
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync, execSync } from 'node:child_process'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { dirname, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
export interface GitCloneOptions {
|
|
15
|
+
/** Default 1 (shallow). Set to 0 to disable depth (full history). */
|
|
16
|
+
depth?: number
|
|
17
|
+
/** Branch/tag/commit to checkout after clone. Implies a follow-up `git checkout` if not HEAD. */
|
|
18
|
+
ref?: string
|
|
19
|
+
/** stdio mode for the spawned git process. Default 'pipe' (capture). */
|
|
20
|
+
stdio?: 'pipe' | 'inherit' | 'ignore'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function gitClone(url: string, dir: string, opts?: GitCloneOptions): void {
|
|
24
|
+
const args = ['clone']
|
|
25
|
+
const depth = opts?.depth ?? 1
|
|
26
|
+
if (depth > 0) {
|
|
27
|
+
args.push('--depth', String(depth))
|
|
28
|
+
}
|
|
29
|
+
args.push(url, dir)
|
|
30
|
+
execFileSync('git', args, { stdio: opts?.stdio ?? 'pipe' })
|
|
31
|
+
|
|
32
|
+
if (opts?.ref && opts.ref !== 'HEAD') {
|
|
33
|
+
execFileSync('git', ['checkout', opts.ref], { cwd: dir, stdio: opts?.stdio ?? 'pipe' })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GitPullResult {
|
|
38
|
+
status: 'updated' | 'up-to-date' | 'failed'
|
|
39
|
+
message: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function gitPull(dir: string, timeoutMs: number = 30000): GitPullResult {
|
|
43
|
+
try {
|
|
44
|
+
const output = execSync('git pull', {
|
|
45
|
+
cwd: dir,
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
|
+
timeout: timeoutMs,
|
|
49
|
+
}).trim()
|
|
50
|
+
|
|
51
|
+
if (output.includes('Already up to date') || output.includes('Already up-to-date')) {
|
|
52
|
+
return { status: 'up-to-date', message: output }
|
|
53
|
+
}
|
|
54
|
+
return { status: 'updated', message: output }
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
const e = err as { stderr?: { toString: () => string }; message?: string }
|
|
57
|
+
const stderr = e.stderr?.toString() || e.message || ''
|
|
58
|
+
return { status: 'failed', message: stderr.trim() }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk up from `dir` looking for a directory containing `.git/`. Stops
|
|
64
|
+
* walking when it crosses outside `coldPool` (if given) — keeps the
|
|
65
|
+
* search scoped to the cold-pool's own git roots.
|
|
66
|
+
*/
|
|
67
|
+
export interface GitRootResult {
|
|
68
|
+
gitRoot: string | null
|
|
69
|
+
reason?: 'not-found' | 'outside-cold-pool'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectGitRoot(dir: string, coldPool?: string): GitRootResult {
|
|
73
|
+
const absDir = resolve(dir)
|
|
74
|
+
const absColdPool = coldPool ? resolve(coldPool) : null
|
|
75
|
+
|
|
76
|
+
let cur = absDir
|
|
77
|
+
while (true) {
|
|
78
|
+
if (absColdPool && !cur.startsWith(absColdPool)) {
|
|
79
|
+
return { gitRoot: null, reason: 'outside-cold-pool' }
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(`${cur}/.git`)) {
|
|
82
|
+
return { gitRoot: cur }
|
|
83
|
+
}
|
|
84
|
+
const parent = dirname(cur)
|
|
85
|
+
if (parent === cur) {
|
|
86
|
+
return { gitRoot: null, reason: 'not-found' }
|
|
87
|
+
}
|
|
88
|
+
cur = parent
|
|
89
|
+
}
|
|
90
|
+
}
|