loopat 0.1.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/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- package/web/dist/logo.png +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, stat, mkdir, rm, unlink } from "node:fs/promises"
|
|
2
|
+
import { join, normalize, relative, sep, dirname } from "node:path"
|
|
3
|
+
import { loopDir } from "./paths"
|
|
4
|
+
|
|
5
|
+
export type FileEntry = {
|
|
6
|
+
name: string
|
|
7
|
+
path: string // relative to workdir, posix-style
|
|
8
|
+
type: "file" | "dir"
|
|
9
|
+
size?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safeJoin(rootAbs: string, rel: string): string | null {
|
|
13
|
+
const candidate = normalize(join(rootAbs, rel))
|
|
14
|
+
const insideRel = relative(rootAbs, candidate)
|
|
15
|
+
if (insideRel.startsWith("..") || insideRel.startsWith("/" + sep)) return null
|
|
16
|
+
return candidate
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".bun", ".claude"])
|
|
20
|
+
|
|
21
|
+
export async function listDir(loopId: string, relPath: string): Promise<FileEntry[]> {
|
|
22
|
+
const root = loopDir(loopId)
|
|
23
|
+
const abs = safeJoin(root, relPath)
|
|
24
|
+
if (!abs) throw new Error("path escapes workdir")
|
|
25
|
+
let names: string[] = []
|
|
26
|
+
try {
|
|
27
|
+
names = await readdir(abs)
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
const out: FileEntry[] = []
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
if (SKIP_DIRS.has(name)) continue
|
|
34
|
+
if (name === ".git" || name === ".DS_Store") continue
|
|
35
|
+
const childRel = relPath ? `${relPath}/${name}` : name
|
|
36
|
+
let isDir = false
|
|
37
|
+
let size: number | undefined
|
|
38
|
+
try {
|
|
39
|
+
// stat follows symlinks → symlinked-dir reports as dir
|
|
40
|
+
const s = await stat(join(abs, name))
|
|
41
|
+
isDir = s.isDirectory()
|
|
42
|
+
if (!isDir) size = s.size
|
|
43
|
+
} catch {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
out.push({ name, path: childRel, type: isDir ? "dir" : "file", size })
|
|
47
|
+
}
|
|
48
|
+
out.sort((a, b) => {
|
|
49
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1
|
|
50
|
+
return a.name.localeCompare(b.name)
|
|
51
|
+
})
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MAX_BYTES = 256 * 1024
|
|
56
|
+
|
|
57
|
+
export async function readWorkdirFile(loopId: string, relPath: string): Promise<{ content: string; truncated: boolean; size: number } | null> {
|
|
58
|
+
const root = loopDir(loopId)
|
|
59
|
+
const abs = safeJoin(root, relPath)
|
|
60
|
+
if (!abs) return null
|
|
61
|
+
try {
|
|
62
|
+
const s = await stat(abs)
|
|
63
|
+
if (!s.isFile()) return null
|
|
64
|
+
const truncated = s.size > MAX_BYTES
|
|
65
|
+
const buf = await readFile(abs)
|
|
66
|
+
const slice = truncated ? buf.subarray(0, MAX_BYTES) : buf
|
|
67
|
+
return { content: slice.toString("utf8"), truncated, size: s.size }
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeWorkdirFile(loopId: string, relPath: string, content: string): Promise<boolean> {
|
|
74
|
+
const root = loopDir(loopId)
|
|
75
|
+
const abs = safeJoin(root, relPath)
|
|
76
|
+
if (!abs) return false
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
79
|
+
await writeFile(abs, content)
|
|
80
|
+
return true
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function deleteWorkdirFile(loopId: string, relPath: string): Promise<boolean> {
|
|
87
|
+
const root = loopDir(loopId)
|
|
88
|
+
const abs = safeJoin(root, relPath)
|
|
89
|
+
if (!abs) return false
|
|
90
|
+
try {
|
|
91
|
+
const s = await stat(abs)
|
|
92
|
+
if (s.isDirectory()) {
|
|
93
|
+
await rm(abs, { recursive: true, force: true })
|
|
94
|
+
} else {
|
|
95
|
+
await unlink(abs)
|
|
96
|
+
}
|
|
97
|
+
return true
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function createWorkdirFolder(loopId: string, relPath: string): Promise<boolean> {
|
|
104
|
+
const root = loopDir(loopId)
|
|
105
|
+
const abs = safeJoin(root, relPath)
|
|
106
|
+
if (!abs) return false
|
|
107
|
+
try {
|
|
108
|
+
await mkdir(abs, { recursive: true })
|
|
109
|
+
return true
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MAX_RECURSIVE_ENTRIES = 5000
|
|
116
|
+
const MAX_RECURSIVE_DEPTH = 20
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Recursively list all files and directories under a root path within a loop.
|
|
120
|
+
* Returns a flat array sorted dirs-first then alpha. One HTTP call replaces
|
|
121
|
+
* the frontend's recursive fetchAllFiles waterfall.
|
|
122
|
+
*/
|
|
123
|
+
export async function listDirRecursive(
|
|
124
|
+
loopId: string,
|
|
125
|
+
relPath: string,
|
|
126
|
+
): Promise<FileEntry[]> {
|
|
127
|
+
const root = loopDir(loopId)
|
|
128
|
+
const abs = safeJoin(root, relPath)
|
|
129
|
+
if (!abs) return []
|
|
130
|
+
|
|
131
|
+
const result: FileEntry[] = []
|
|
132
|
+
const skip = new Set(SKIP_DIRS)
|
|
133
|
+
skip.add(".git").add(".DS_Store")
|
|
134
|
+
|
|
135
|
+
async function walk(absPath: string, prefix: string, depth: number) {
|
|
136
|
+
if (result.length >= MAX_RECURSIVE_ENTRIES) return
|
|
137
|
+
if (depth > MAX_RECURSIVE_DEPTH) return
|
|
138
|
+
|
|
139
|
+
let names: string[]
|
|
140
|
+
try {
|
|
141
|
+
names = await readdir(absPath)
|
|
142
|
+
} catch {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const entries: Array<{ name: string; isDir: boolean }> = []
|
|
147
|
+
for (const name of names) {
|
|
148
|
+
if (skip.has(name)) continue
|
|
149
|
+
try {
|
|
150
|
+
const s = await stat(join(absPath, name))
|
|
151
|
+
entries.push({ name, isDir: s.isDirectory() })
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
entries.sort((a, b) => {
|
|
157
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
158
|
+
return a.name.localeCompare(b.name)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
for (const { name, isDir } of entries) {
|
|
162
|
+
if (result.length >= MAX_RECURSIVE_ENTRIES) break
|
|
163
|
+
const childRel = prefix ? `${prefix}/${name}` : name
|
|
164
|
+
result.push({ name, path: childRel, type: isDir ? "dir" : "file" })
|
|
165
|
+
if (isDir) {
|
|
166
|
+
await walk(join(absPath, name), childRel, depth + 1)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await walk(abs, relPath, 0)
|
|
172
|
+
return result
|
|
173
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user git-crypt symmetric key. Decrypts the user's personal repo
|
|
3
|
+
* worktree (specifically `.loopat/vaults/**`).
|
|
4
|
+
*
|
|
5
|
+
* Storage: host-secrets/<user>/git-crypt.key — host-only, NOT bound into the
|
|
6
|
+
* sandbox, NOT in any git repo. Mode 0600.
|
|
7
|
+
*
|
|
8
|
+
* Phase A (now): plain file on disk. Anyone with host access can read it and
|
|
9
|
+
* decrypt the repo. Trade-off documented — see design discussion notes.
|
|
10
|
+
*
|
|
11
|
+
* Phase B (future, optional upgrade): replace this module's read path with an
|
|
12
|
+
* in-memory map populated at server start via passphrase prompt. Callers stay
|
|
13
|
+
* the same — `getGitCryptKey(userId)` interface is the migration boundary.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs"
|
|
16
|
+
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises"
|
|
17
|
+
import { dirname } from "node:path"
|
|
18
|
+
import { hostSecretsDir, personalGitCryptKeyPath } from "./paths"
|
|
19
|
+
|
|
20
|
+
export async function getGitCryptKey(userId: string): Promise<Buffer> {
|
|
21
|
+
return await readFile(personalGitCryptKeyPath(userId))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function gitCryptKeyExists(userId: string): Promise<boolean> {
|
|
25
|
+
return existsSync(personalGitCryptKeyPath(userId))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function saveGitCryptKey(userId: string, keyData: Buffer): Promise<void> {
|
|
29
|
+
const dir = hostSecretsDir(userId)
|
|
30
|
+
await mkdir(dir, { recursive: true })
|
|
31
|
+
await chmod(dir, 0o700).catch(() => {})
|
|
32
|
+
const path = personalGitCryptKeyPath(userId)
|
|
33
|
+
await mkdir(dirname(path), { recursive: true })
|
|
34
|
+
await writeFile(path, keyData, { mode: 0o600 })
|
|
35
|
+
await chmod(path, 0o600).catch(() => {})
|
|
36
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git host provider abstraction — docs/identity.md's five-capability contract
|
|
3
|
+
* as a pluggable interface. A GitHostProvider adapts ONE git platform (GitHub,
|
|
4
|
+
* GitLab, an internal host, …) onto the five operations loopat needs to onboard
|
|
5
|
+
* a user. loopat core stays platform-agnostic.
|
|
6
|
+
*
|
|
7
|
+
* To add a platform: implement this interface and `registerProvider()` it —
|
|
8
|
+
* see server/src/providers.ts (the explicit registry). Nothing in core changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type HostCred = { token: string; baseUrl?: string }
|
|
12
|
+
export type RepoRef = { owner: string; name: string }
|
|
13
|
+
|
|
14
|
+
export interface GitHostProvider {
|
|
15
|
+
readonly id: string
|
|
16
|
+
readonly label: string
|
|
17
|
+
|
|
18
|
+
/** Optional: where/how the user gets a token, shown in the onboarding token
|
|
19
|
+
* step. A URL or short hint. Platform-specific, so the provider supplies it
|
|
20
|
+
* (core stays platform-agnostic). */
|
|
21
|
+
readonly tokenHelp?: string
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How git authenticates clone/push on this platform:
|
|
25
|
+
* - "ssh-deploy-key": loopat generates an ssh key, the provider registers it
|
|
26
|
+
* (registerDeployKey), and git uses ssh. (GitHub.)
|
|
27
|
+
* - "https-token": git uses `https://oauth2:<token>@…`; no key is registered.
|
|
28
|
+
* (GitLab / internal — one token does API + git.)
|
|
29
|
+
*/
|
|
30
|
+
readonly gitAuthMode: "ssh-deploy-key" | "https-token"
|
|
31
|
+
|
|
32
|
+
/** ① authenticate — turn a credential into the user's login (+ email for
|
|
33
|
+
* commit authorship, where the platform enforces a valid address). */
|
|
34
|
+
authenticate(cred: HostCred): Promise<{ login: string; email?: string }>
|
|
35
|
+
|
|
36
|
+
/** ② create a private repo in the user's namespace if missing. */
|
|
37
|
+
ensureRepo(
|
|
38
|
+
cred: HostCred,
|
|
39
|
+
name: string,
|
|
40
|
+
opts?: { private?: boolean },
|
|
41
|
+
): Promise<{ url: string; created: boolean }>
|
|
42
|
+
|
|
43
|
+
/** ③ register a deploy key on a repo (only for "ssh-deploy-key" mode). */
|
|
44
|
+
registerDeployKey?(
|
|
45
|
+
cred: HostCred,
|
|
46
|
+
repo: RepoRef,
|
|
47
|
+
title: string,
|
|
48
|
+
pubkey: string,
|
|
49
|
+
readOnly: boolean,
|
|
50
|
+
): Promise<void>
|
|
51
|
+
|
|
52
|
+
/** ④ register an account-level key (only for "ssh-deploy-key" mode). */
|
|
53
|
+
registerUserKey?(cred: HostCred, title: string, pubkey: string): Promise<void>
|
|
54
|
+
|
|
55
|
+
/** ⑤ grant a member access to a repo (usually admin-gated). */
|
|
56
|
+
grantAccess(
|
|
57
|
+
cred: HostCred,
|
|
58
|
+
repo: RepoRef,
|
|
59
|
+
login: string,
|
|
60
|
+
level: "read" | "write",
|
|
61
|
+
): Promise<void>
|
|
62
|
+
|
|
63
|
+
/** List the user's repos (names) for an onboarding picker. Optional. */
|
|
64
|
+
listRepos?(cred: HostCred): Promise<{ name: string; path: string }[]>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Optional internal-setup hook. Runs once during personal-repo init, right
|
|
68
|
+
* after `git-crypt init` (so `.gitattributes` already encrypts
|
|
69
|
+
* `.loopat/vaults/**`) and before the scaffold is committed + pushed. Use it
|
|
70
|
+
* to seed default files into the working tree — provider configs, ssh keys,
|
|
71
|
+
* jumpbox configs, … This is where a team bakes its internal defaults.
|
|
72
|
+
*
|
|
73
|
+
* Encryption boundary: files under `.loopat/vaults/**` are git-crypt
|
|
74
|
+
* encrypted; everything else (e.g. `.loopat/config.json`) is committed in
|
|
75
|
+
* PLAINTEXT — so never write real secrets outside the vault (use env-var
|
|
76
|
+
* refs like `"$FOO_API_KEY"` in config.json instead).
|
|
77
|
+
*
|
|
78
|
+
* loopat stages (`git add .loopat memory`), commits and pushes whatever you
|
|
79
|
+
* write. Throwing only logs a warning — setup still succeeds.
|
|
80
|
+
*
|
|
81
|
+
* ctx.repoDir — cloned working tree (write paths relative to this)
|
|
82
|
+
* ctx.vaultDir — `${repoDir}/.loopat/vaults/default` (encrypted)
|
|
83
|
+
* ctx.userId — the loopat user being set up
|
|
84
|
+
* ctx.login — their login on this platform
|
|
85
|
+
*/
|
|
86
|
+
seedDefaults?(ctx: {
|
|
87
|
+
repoDir: string
|
|
88
|
+
vaultDir: string
|
|
89
|
+
userId: string
|
|
90
|
+
login: string
|
|
91
|
+
}): Promise<void>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const providers = new Map<string, GitHostProvider>()
|
|
95
|
+
|
|
96
|
+
export function registerProvider(p: GitHostProvider): void {
|
|
97
|
+
providers.set(p.id, p)
|
|
98
|
+
}
|
|
99
|
+
export function getProvider(id: string): GitHostProvider | undefined {
|
|
100
|
+
return providers.get(id)
|
|
101
|
+
}
|
|
102
|
+
export function listProviders(): { id: string; label: string }[] {
|
|
103
|
+
return [...providers.values()].map((p) => ({ id: p.id, label: p.label }))
|
|
104
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub integration — the five-capability contract from docs/identity.md,
|
|
3
|
+
* implemented against the GitHub REST API. It only ever consumes a *token*;
|
|
4
|
+
* how that token was obtained (a user-pasted PAT today, an OAuth grant later)
|
|
5
|
+
* is not its concern. Swapping the token source never touches this client.
|
|
6
|
+
*
|
|
7
|
+
* `baseUrl` is configurable so the same client works against github.com
|
|
8
|
+
* (https://api.github.com) or a GitHub Enterprise / internal host
|
|
9
|
+
* (https://<host>/api/v3).
|
|
10
|
+
*/
|
|
11
|
+
import { registerProvider, type GitHostProvider } from "./git-host"
|
|
12
|
+
|
|
13
|
+
export type GithubClient = {
|
|
14
|
+
baseUrl: string
|
|
15
|
+
token: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function githubClient(token: string, baseUrl = "https://api.github.com"): GithubClient {
|
|
19
|
+
return { token, baseUrl: baseUrl.replace(/\/+$/, "") }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function gh<T = any>(
|
|
23
|
+
c: GithubClient,
|
|
24
|
+
method: string,
|
|
25
|
+
path: string,
|
|
26
|
+
body?: unknown,
|
|
27
|
+
): Promise<{ status: number; data: T }> {
|
|
28
|
+
const res = await fetch(`${c.baseUrl}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${c.token}`,
|
|
32
|
+
Accept: "application/vnd.github+json",
|
|
33
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
34
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
35
|
+
},
|
|
36
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
37
|
+
})
|
|
38
|
+
let data: any = null
|
|
39
|
+
const text = await res.text()
|
|
40
|
+
if (text) {
|
|
41
|
+
try { data = JSON.parse(text) } catch { data = text }
|
|
42
|
+
}
|
|
43
|
+
return { status: res.status, data }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(op: string, r: { status: number; data: any }): never {
|
|
47
|
+
const msg = r.data?.message ?? (typeof r.data === "string" ? r.data : "")
|
|
48
|
+
throw new Error(`github ${op} failed (${r.status})${msg ? `: ${msg}` : ""}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Capability 1 — authenticate: turn a token into the user's login. */
|
|
52
|
+
export async function getViewer(c: GithubClient): Promise<{ login: string; id: number }> {
|
|
53
|
+
const r = await gh(c, "GET", "/user")
|
|
54
|
+
if (r.status !== 200) fail("authenticate", r)
|
|
55
|
+
return { login: r.data.login, id: r.data.id }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Capability 2 — create a private repo in the viewer's namespace if missing.
|
|
60
|
+
* Returns the clone URLs; idempotent (existing repo → returned as-is).
|
|
61
|
+
*/
|
|
62
|
+
export async function ensureUserRepo(
|
|
63
|
+
c: GithubClient,
|
|
64
|
+
name: string,
|
|
65
|
+
opts: { private?: boolean; description?: string } = {},
|
|
66
|
+
): Promise<{ created: boolean; sshUrl: string; httpUrl: string; fullName: string }> {
|
|
67
|
+
const me = await getViewer(c)
|
|
68
|
+
const existing = await gh(c, "GET", `/repos/${me.login}/${name}`)
|
|
69
|
+
if (existing.status === 200) {
|
|
70
|
+
return {
|
|
71
|
+
created: false,
|
|
72
|
+
sshUrl: existing.data.ssh_url,
|
|
73
|
+
httpUrl: existing.data.clone_url,
|
|
74
|
+
fullName: existing.data.full_name,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (existing.status !== 404) fail("get repo", existing)
|
|
78
|
+
const r = await gh(c, "POST", "/user/repos", {
|
|
79
|
+
name,
|
|
80
|
+
private: opts.private ?? true,
|
|
81
|
+
description: opts.description ?? "loopat",
|
|
82
|
+
auto_init: false,
|
|
83
|
+
})
|
|
84
|
+
if (r.status !== 201) fail("create repo", r)
|
|
85
|
+
return { created: true, sshUrl: r.data.ssh_url, httpUrl: r.data.clone_url, fullName: r.data.full_name }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Capability 3 — register a deploy key on a repo (bootstrap clone of personal). */
|
|
89
|
+
export async function ensureDeployKey(
|
|
90
|
+
c: GithubClient,
|
|
91
|
+
owner: string,
|
|
92
|
+
repo: string,
|
|
93
|
+
title: string,
|
|
94
|
+
publicKey: string,
|
|
95
|
+
readOnly = true,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const list = await gh(c, "GET", `/repos/${owner}/${repo}/keys`)
|
|
98
|
+
if (list.status === 200 && Array.isArray(list.data) && list.data.some((k: any) => k.key?.trim() === publicKey.trim())) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const r = await gh(c, "POST", `/repos/${owner}/${repo}/keys`, { title, key: publicKey, read_only: readOnly })
|
|
102
|
+
if (r.status !== 201 && r.status !== 422 /* already exists */) fail("add deploy key", r)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Capability 4 — register an account-level key (the runtime key in the vault). */
|
|
106
|
+
export async function ensureUserKey(c: GithubClient, title: string, publicKey: string): Promise<void> {
|
|
107
|
+
const list = await gh(c, "GET", "/user/keys")
|
|
108
|
+
if (list.status === 200 && Array.isArray(list.data) && list.data.some((k: any) => k.key?.trim() === publicKey.trim())) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const r = await gh(c, "POST", "/user/keys", { title, key: publicKey })
|
|
112
|
+
if (r.status !== 201 && r.status !== 422) fail("add user key", r)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Capability 5 — grant a member access to a repo. Admin-gated by GitHub:
|
|
117
|
+
* `c` must be a token with admin on `owner/repo` (e.g. an org-admin token at
|
|
118
|
+
* team-setup time), not the joining user's own token.
|
|
119
|
+
*/
|
|
120
|
+
export async function ensureCollaborator(
|
|
121
|
+
c: GithubClient,
|
|
122
|
+
owner: string,
|
|
123
|
+
repo: string,
|
|
124
|
+
username: string,
|
|
125
|
+
permission: "pull" | "push" | "admin" = "push",
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const r = await gh(c, "PUT", `/repos/${owner}/${repo}/collaborators/${username}`, { permission })
|
|
128
|
+
// 201 = invitation created, 204 = already a collaborator
|
|
129
|
+
if (r.status !== 201 && r.status !== 204) fail("add collaborator", r)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The built-in GitHub provider — adapts the functions above onto GitHostProvider. */
|
|
133
|
+
export const githubProvider: GitHostProvider = {
|
|
134
|
+
id: "github",
|
|
135
|
+
label: "GitHub",
|
|
136
|
+
gitAuthMode: "ssh-deploy-key",
|
|
137
|
+
async authenticate(cred) {
|
|
138
|
+
return await getViewer(githubClient(cred.token, cred.baseUrl))
|
|
139
|
+
},
|
|
140
|
+
async ensureRepo(cred, name, opts) {
|
|
141
|
+
const r = await ensureUserRepo(githubClient(cred.token, cred.baseUrl), name, { private: opts?.private })
|
|
142
|
+
return { url: r.sshUrl, created: r.created }
|
|
143
|
+
},
|
|
144
|
+
async registerDeployKey(cred, repo, title, pubkey, readOnly) {
|
|
145
|
+
await ensureDeployKey(githubClient(cred.token, cred.baseUrl), repo.owner, repo.name, title, pubkey, readOnly)
|
|
146
|
+
},
|
|
147
|
+
async registerUserKey(cred, title, pubkey) {
|
|
148
|
+
await ensureUserKey(githubClient(cred.token, cred.baseUrl), title, pubkey)
|
|
149
|
+
},
|
|
150
|
+
async grantAccess(cred, repo, login, level) {
|
|
151
|
+
await ensureCollaborator(
|
|
152
|
+
githubClient(cred.token, cred.baseUrl),
|
|
153
|
+
repo.owner,
|
|
154
|
+
repo.name,
|
|
155
|
+
login,
|
|
156
|
+
level === "write" ? "push" : "pull",
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
registerProvider(githubProvider)
|