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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. 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)