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,309 @@
1
+ /**
2
+ * Account system — single-workspace MVP.
3
+ *
4
+ * users.json (at LOOPAT_HOME/users.json):
5
+ * { users: [{ id, salt, hash, role, status, personalRepo?, createdAt, activatedAt? }] }
6
+ *
7
+ * Open registration: anyone can register. New accounts default to
8
+ * role:"member", status:"pending" and must be activated by an admin before
9
+ * login is allowed. The first account ever to register bootstraps as
10
+ * role:"admin", status:"active" so the system isn't unreachable.
11
+ *
12
+ * Sessions persist to sessions.json so server restarts don't log everyone out.
13
+ * Cookie is HttpOnly + SameSite=Lax + maxAge 30d.
14
+ */
15
+ import { existsSync } from "node:fs"
16
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
17
+ import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual } from "node:crypto"
18
+ import { promisify } from "node:util"
19
+ import type { Context, MiddlewareHandler } from "hono"
20
+ import { getCookie, setCookie, deleteCookie } from "hono/cookie"
21
+ import { join } from "node:path"
22
+ import { usersPath, workspaceDir } from "./paths"
23
+
24
+ const scrypt = promisify(scryptCb) as (
25
+ password: string | Buffer,
26
+ salt: string | Buffer,
27
+ keylen: number,
28
+ ) => Promise<Buffer>
29
+
30
+ export const COOKIE_NAME = "loopat_session"
31
+ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
32
+ const SCRYPT_KEYLEN = 64
33
+ const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/
34
+
35
+ export type UserRole = "admin" | "member"
36
+ export type UserStatus = "active" | "pending"
37
+
38
+ export type User = {
39
+ id: string
40
+ salt: string
41
+ hash: string
42
+ role: UserRole
43
+ status: UserStatus
44
+ personalRepo?: string
45
+ createdAt: string
46
+ activatedAt?: string
47
+ }
48
+
49
+ export type PublicUser = {
50
+ id: string
51
+ role: UserRole
52
+ status: UserStatus
53
+ personalRepo?: string
54
+ createdAt: string
55
+ activatedAt?: string
56
+ }
57
+
58
+ function toPublic(u: User): PublicUser {
59
+ return {
60
+ id: u.id,
61
+ role: u.role,
62
+ status: u.status,
63
+ personalRepo: u.personalRepo,
64
+ createdAt: u.createdAt,
65
+ activatedAt: u.activatedAt,
66
+ }
67
+ }
68
+
69
+ type UsersFile = { users: User[] }
70
+ type SessionsFile = { sessions: Record<string, string> } // token → userId
71
+
72
+ let cached: UsersFile | null = null
73
+
74
+ async function readUsersFile(): Promise<UsersFile> {
75
+ if (cached) return cached
76
+ const path = usersPath()
77
+ if (!existsSync(path)) {
78
+ cached = { users: [] }
79
+ return cached
80
+ }
81
+ const raw = await readFile(path, "utf8")
82
+ const parsed = JSON.parse(raw) as UsersFile
83
+ if (!Array.isArray(parsed.users)) throw new Error("users.json: missing users array")
84
+ cached = parsed
85
+ return cached
86
+ }
87
+
88
+ async function writeUsersFile(data: UsersFile): Promise<void> {
89
+ await mkdir(workspaceDir(), { recursive: true })
90
+ await writeFile(usersPath(), JSON.stringify(data, null, 2) + "\n")
91
+ cached = data
92
+ }
93
+
94
+ export async function listUsers(): Promise<PublicUser[]> {
95
+ const f = await readUsersFile()
96
+ return f.users.map(toPublic)
97
+ }
98
+
99
+ export async function findUser(id: string): Promise<User | null> {
100
+ const f = await readUsersFile()
101
+ return f.users.find((u) => u.id === id) ?? null
102
+ }
103
+
104
+ export async function hashPassword(password: string, salt?: string): Promise<{ salt: string; hash: string }> {
105
+ const s = salt ?? randomBytes(16).toString("hex")
106
+ const buf = await scrypt(password, s, SCRYPT_KEYLEN)
107
+ return { salt: s, hash: buf.toString("hex") }
108
+ }
109
+
110
+ export async function verifyPassword(password: string, salt: string, hash: string): Promise<boolean> {
111
+ const buf = await scrypt(password, salt, SCRYPT_KEYLEN)
112
+ const expected = Buffer.from(hash, "hex")
113
+ if (buf.length !== expected.length) return false
114
+ return timingSafeEqual(buf, expected)
115
+ }
116
+
117
+ export function isValidUsername(id: string): boolean {
118
+ return USERNAME_RE.test(id)
119
+ }
120
+
121
+ /**
122
+ * Open registration. Anyone with a valid username + password can create an
123
+ * account. New accounts default to status:"pending" — they cannot log in
124
+ * until an admin activates them. The very first account ever created
125
+ * bootstraps as role:"admin", status:"active" so the system is reachable
126
+ * without manual seeding.
127
+ */
128
+ export async function createUser(input: {
129
+ id: string
130
+ password: string
131
+ personalRepo?: string
132
+ }): Promise<User> {
133
+ if (!isValidUsername(input.id)) throw new Error("invalid username (lowercase a-z0-9_- , 1-32 chars, leading alnum)")
134
+ if (!input.password || input.password.length < 1) throw new Error("password required")
135
+ const f = await readUsersFile()
136
+ if (f.users.some((u) => u.id === input.id)) throw new Error("username taken")
137
+ const { salt, hash } = await hashPassword(input.password)
138
+ const isFirst = f.users.length === 0
139
+ const now = new Date().toISOString()
140
+ const user: User = {
141
+ id: input.id,
142
+ salt,
143
+ hash,
144
+ role: isFirst ? "admin" : "member",
145
+ status: isFirst ? "active" : "pending",
146
+ personalRepo: input.personalRepo?.trim() || undefined,
147
+ createdAt: now,
148
+ activatedAt: isFirst ? now : undefined,
149
+ }
150
+ await writeUsersFile({ users: [...f.users, user] })
151
+ return user
152
+ }
153
+
154
+ export async function activateUser(id: string): Promise<User | null> {
155
+ const f = await readUsersFile()
156
+ const idx = f.users.findIndex((u) => u.id === id)
157
+ if (idx < 0) return null
158
+ if (f.users[idx].status === "active") return f.users[idx]
159
+ const updated: User = { ...f.users[idx], status: "active", activatedAt: new Date().toISOString() }
160
+ const users = f.users.slice()
161
+ users[idx] = updated
162
+ await writeUsersFile({ users })
163
+ return updated
164
+ }
165
+
166
+ export async function setUserRole(id: string, role: UserRole): Promise<User | null> {
167
+ const f = await readUsersFile()
168
+ const idx = f.users.findIndex((u) => u.id === id)
169
+ if (idx < 0) return null
170
+ const target = f.users[idx]
171
+ if (target.role === role) return target
172
+ if (target.role === "admin" && role !== "admin") {
173
+ const adminCount = f.users.filter((u) => u.role === "admin").length
174
+ if (adminCount <= 1) throw new Error("cannot demote the last admin")
175
+ }
176
+ const updated: User = { ...target, role }
177
+ const users = f.users.slice()
178
+ users[idx] = updated
179
+ await writeUsersFile({ users })
180
+ return updated
181
+ }
182
+
183
+ /**
184
+ * Remove a user from users.json. Does NOT touch personal/<id>/ on disk —
185
+ * data is preserved for safety. Caller must guard against self-delete and
186
+ * last-admin removal at the route layer (see /api/admin/users/:id).
187
+ */
188
+ export async function deleteUser(id: string): Promise<boolean> {
189
+ const f = await readUsersFile()
190
+ const idx = f.users.findIndex((u) => u.id === id)
191
+ if (idx < 0) return false
192
+ const target = f.users[idx]
193
+ if (target.role === "admin") {
194
+ const adminCount = f.users.filter((u) => u.role === "admin").length
195
+ if (adminCount <= 1) throw new Error("cannot delete the last admin")
196
+ }
197
+ const users = f.users.filter((u) => u.id !== id)
198
+ await writeUsersFile({ users })
199
+ // Drop any sessions belonging to this user so the deletion is immediate.
200
+ for (const [token, uid] of sessions.entries()) {
201
+ if (uid === id) sessions.delete(token)
202
+ }
203
+ await saveSessions()
204
+ return true
205
+ }
206
+
207
+ /**
208
+ * Persist a user's personalRepo URL. Used when the user filled it in after
209
+ * registration (via the import dialog). Idempotent — no-op if the value is
210
+ * unchanged.
211
+ */
212
+ export async function setPersonalRepo(userId: string, repoUrl: string): Promise<User | null> {
213
+ const f = await readUsersFile()
214
+ const idx = f.users.findIndex((u) => u.id === userId)
215
+ if (idx < 0) return null
216
+ const updated = { ...f.users[idx], personalRepo: repoUrl.trim() || undefined }
217
+ const users = f.users.slice()
218
+ users[idx] = updated
219
+ await writeUsersFile({ users })
220
+ return updated
221
+ }
222
+
223
+ // ── Persistent sessions (disk-backed, survives restarts) ──
224
+
225
+ function sessionsPath(): string {
226
+ return join(workspaceDir(), "sessions.json")
227
+ }
228
+
229
+ const sessions = new Map<string, string>() // token → userId
230
+
231
+ async function loadSessions(): Promise<void> {
232
+ const path = sessionsPath()
233
+ if (!existsSync(path)) return
234
+ try {
235
+ const raw = await readFile(path, "utf8")
236
+ const data = JSON.parse(raw) as SessionsFile
237
+ for (const [token, userId] of Object.entries(data.sessions ?? {})) {
238
+ sessions.set(token, userId)
239
+ }
240
+ } catch {}
241
+ }
242
+
243
+ async function saveSessions(): Promise<void> {
244
+ await mkdir(workspaceDir(), { recursive: true })
245
+ const data: SessionsFile = { sessions: Object.fromEntries(sessions) }
246
+ await writeFile(sessionsPath(), JSON.stringify(data, null, 2) + "\n").catch(() => {})
247
+ }
248
+
249
+ // Load sessions from disk at import time
250
+ loadSessions()
251
+
252
+ export function createSession(userId: string): string {
253
+ const token = randomUUID()
254
+ sessions.set(token, userId)
255
+ saveSessions()
256
+ return token
257
+ }
258
+
259
+ export function destroySession(token: string): void {
260
+ sessions.delete(token)
261
+ saveSessions()
262
+ }
263
+
264
+ export function lookupSession(token: string): string | null {
265
+ return sessions.get(token) ?? null
266
+ }
267
+
268
+ export function setSessionCookie(c: Context, token: string): void {
269
+ setCookie(c, COOKIE_NAME, token, {
270
+ httpOnly: true,
271
+ sameSite: "Lax",
272
+ path: "/",
273
+ maxAge: COOKIE_MAX_AGE,
274
+ })
275
+ }
276
+
277
+ export function clearSessionCookie(c: Context): void {
278
+ deleteCookie(c, COOKIE_NAME, { path: "/" })
279
+ }
280
+
281
+ export function getRequestUserId(c: Context): string | null {
282
+ const token = getCookie(c, COOKIE_NAME)
283
+ if (!token) return null
284
+ return lookupSession(token)
285
+ }
286
+
287
+ /**
288
+ * Hono middleware: requires a valid session cookie. Sets `userId` on context
289
+ * for downstream handlers (`c.get("userId")`).
290
+ */
291
+ export const requireAuth: MiddlewareHandler = async (c, next) => {
292
+ const userId = getRequestUserId(c)
293
+ if (!userId) return c.json({ error: "unauthorized" }, 401)
294
+ c.set("userId", userId)
295
+ await next()
296
+ }
297
+
298
+ /**
299
+ * Hono middleware: requires the session user to be role:"admin".
300
+ * Layer this *after* requireAuth (or on its own — it re-checks the cookie).
301
+ */
302
+ export const requireAdmin: MiddlewareHandler = async (c, next) => {
303
+ const userId = getRequestUserId(c)
304
+ if (!userId) return c.json({ error: "unauthorized" }, 401)
305
+ const user = await findUser(userId)
306
+ if (!user || user.role !== "admin") return c.json({ error: "forbidden" }, 403)
307
+ c.set("userId", userId)
308
+ await next()
309
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Boot-time pre-flight: verify the host has what loopat needs (podman, claude
3
+ * binary, apiKey) and print a checklist. Doesn't exit on failure — UI still
4
+ * works, just chat won't function until the user fills in what's missing.
5
+ */
6
+ import { existsSync } from "node:fs"
7
+ import { execFileSync } from "node:child_process"
8
+ import { join } from "node:path"
9
+ import { resolveClaudeBinary } from "./claude-binary"
10
+ import { configPath, type WorkspaceConfig } from "./config"
11
+ import {
12
+ WORKSPACE,
13
+ usersPath,
14
+ workspaceDir,
15
+ workspaceKnowledgeDir,
16
+ workspaceNotesDir,
17
+ workspaceRepoDir,
18
+ workspaceTeamClaudeMdPath,
19
+ } from "./paths"
20
+ import { listUsers } from "./auth"
21
+
22
+ type Check = { ok: boolean; label: string; hint?: string }
23
+
24
+ function checkPodman(): Check {
25
+ try {
26
+ const out = execFileSync("podman", ["--version"], { stdio: "pipe" }).toString().trim()
27
+ return { ok: true, label: `podman (sandbox): ${out}` }
28
+ } catch {
29
+ return {
30
+ ok: false,
31
+ label: "podman (sandbox)",
32
+ hint: "install with: sudo apt install podman uidmap fuse-overlayfs (Linux only)",
33
+ }
34
+ }
35
+ }
36
+
37
+ function checkClaudeBinary(): Check {
38
+ try {
39
+ const p = resolveClaudeBinary()
40
+ return { ok: true, label: `claude binary (${p.split("/").slice(-3).join("/")})` }
41
+ } catch (e: any) {
42
+ return {
43
+ ok: false,
44
+ label: "claude binary",
45
+ hint: "run `bun install` in the loopat repo root — SDK ships the binary as a platform-specific package",
46
+ }
47
+ }
48
+ }
49
+
50
+
51
+ function describeRemote(dir: string, url: string | undefined): string {
52
+ if (!existsSync(dir)) return "missing"
53
+ const isRepo = existsSync(join(dir, ".git"))
54
+ if (url && isRepo) return url
55
+ if (url && !isRepo) return `${url} (clone failed → local-only)`
56
+ return "local-only (no remote)"
57
+ }
58
+
59
+ function describeRepos(cfg: WorkspaceConfig): Check {
60
+ const specs = cfg.repos ?? []
61
+ if (specs.length === 0) return { ok: true, label: `repos: (none configured)` }
62
+ const parts = specs.map((r) => {
63
+ const present = existsSync(workspaceRepoDir(r.name))
64
+ return `${present ? "" : "✗"}${r.name}`
65
+ })
66
+ const allOk = specs.every((r) => existsSync(workspaceRepoDir(r.name)))
67
+ return { ok: allOk, label: `repos: ${parts.join(", ")}` }
68
+ }
69
+
70
+ async function checkUsers(): Promise<Check> {
71
+ const path = usersPath()
72
+ if (!existsSync(path)) {
73
+ return { ok: true, label: `users: (none yet — register on first visit)` }
74
+ }
75
+ try {
76
+ const users = await listUsers()
77
+ const ids = users.map((u) => u.id).join(", ") || "(empty)"
78
+ return { ok: true, label: `users: ${users.length} (${ids})` }
79
+ } catch (e: any) {
80
+ return { ok: false, label: `users: <unreadable>`, hint: `${path}: ${e?.message ?? e}` }
81
+ }
82
+ }
83
+
84
+ export async function printBootstrapBanner(cfg: WorkspaceConfig) {
85
+ const checks: Check[] = [
86
+ { ok: true, label: `workspace: ${workspaceDir()}` },
87
+ { ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` },
88
+ { ok: existsSync(workspaceKnowledgeDir()), label: `knowledge: ${describeRemote(workspaceKnowledgeDir(), cfg.knowledge?.git || undefined)}` },
89
+ { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), cfg.notes?.git || undefined)}` },
90
+ describeRepos(cfg),
91
+ await checkUsers(),
92
+ { ok: existsSync(configPath()), label: `config: ${configPath()}` },
93
+ checkPodman(),
94
+ checkClaudeBinary(),
95
+ ]
96
+
97
+ const bar = "─".repeat(60)
98
+ console.log(`\n${bar}`)
99
+ console.log(` loopat bootstrap — ${WORKSPACE}`)
100
+ console.log(bar)
101
+ for (const c of checks) {
102
+ const mark = c.ok ? "✓" : "✗"
103
+ console.log(` ${mark} ${c.label}`)
104
+ if (!c.ok && c.hint) console.log(` → ${c.hint}`)
105
+ }
106
+ console.log(bar)
107
+ const blockers = checks.filter((c) => !c.ok)
108
+ if (blockers.length === 0) {
109
+ console.log(` ready. open http://localhost:${process.env.PORT ?? 7787}\n`)
110
+ } else {
111
+ console.log(` ${blockers.length} thing(s) to fix before chat will work — see hints above.\n`)
112
+ }
113
+ }