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,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
|
+
}
|