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,1832 @@
|
|
|
1
|
+
import { chmod, copyFile, mkdir, mkdtemp, readdir, readFile, rename, writeFile, stat, symlink, lstat, rm } from "node:fs/promises"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { execFile } from "node:child_process"
|
|
4
|
+
import { promisify } from "node:util"
|
|
5
|
+
import { existsSync } from "node:fs"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { tmpdir } from "node:os"
|
|
8
|
+
import {
|
|
9
|
+
loopsDir,
|
|
10
|
+
loopDir,
|
|
11
|
+
loopWorkdir,
|
|
12
|
+
loopClaudeDir,
|
|
13
|
+
loopContextDir,
|
|
14
|
+
loopContextKnowledge,
|
|
15
|
+
loopContextNotes,
|
|
16
|
+
loopContextPersonal,
|
|
17
|
+
loopContextRepos,
|
|
18
|
+
loopMetaPath,
|
|
19
|
+
workspaceDir,
|
|
20
|
+
workspaceKnowledgeDir,
|
|
21
|
+
workspaceNotesDir,
|
|
22
|
+
workspaceReposDir,
|
|
23
|
+
workspaceRepoDir,
|
|
24
|
+
workspaceOriginsDir,
|
|
25
|
+
workspaceOriginPath,
|
|
26
|
+
personalDir,
|
|
27
|
+
uiNotesDir,
|
|
28
|
+
personalMemoryDir,
|
|
29
|
+
workspaceMemoryDir,
|
|
30
|
+
hostDeployKeyPath,
|
|
31
|
+
personalGitCryptKeyPath,
|
|
32
|
+
loopHistoryPath,
|
|
33
|
+
loopChatHistoryPath,
|
|
34
|
+
loopKindClaudePath,
|
|
35
|
+
} from "./paths"
|
|
36
|
+
import type { RepoSpec } from "./config"
|
|
37
|
+
import { existsSync as existsSyncBase } from "node:fs"
|
|
38
|
+
import { loadConfig } from "./config"
|
|
39
|
+
import { ensurePersonalKeypair } from "./personal-keys"
|
|
40
|
+
import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
|
|
41
|
+
import { getProvider } from "./git-host"
|
|
42
|
+
import { loadExtensionProviders } from "./providers" // also registers built-in providers
|
|
43
|
+
|
|
44
|
+
const execFileP = promisify(execFile)
|
|
45
|
+
|
|
46
|
+
export type LoopMeta = {
|
|
47
|
+
id: string
|
|
48
|
+
title: string
|
|
49
|
+
createdAt: string
|
|
50
|
+
createdBy: string
|
|
51
|
+
/**
|
|
52
|
+
* Active driver. New loops set this to `createdBy` on creation. Legacy
|
|
53
|
+
* loops created before drivers existed may omit it; callers should use
|
|
54
|
+
* `effectiveDriver()` rather than reading this field directly.
|
|
55
|
+
*
|
|
56
|
+
* The driver is the user whose personal config (apiKey, vault, env) the
|
|
57
|
+
* sandbox runs under, and the only user permitted to write (send messages,
|
|
58
|
+
* change provider, write terminal, etc.). Non-driver users are read-only —
|
|
59
|
+
* same set of writes blocked by `archived`. See request-for-drive flow.
|
|
60
|
+
*/
|
|
61
|
+
driver?: string
|
|
62
|
+
/**
|
|
63
|
+
* Chronological log of driver assignments. First entry is creation time
|
|
64
|
+
* (driver = createdBy). Each subsequent entry is a successful handoff via
|
|
65
|
+
* POST /api/loops/:id/drive. Used by the chat UI to splice "driving by X
|
|
66
|
+
* since <ts>" markers into the message timeline. Legacy loops may omit
|
|
67
|
+
* this; on the next handoff a fresh history starts from there.
|
|
68
|
+
*/
|
|
69
|
+
driverHistory?: Array<{ driver: string; since: string }>
|
|
70
|
+
/**
|
|
71
|
+
* RFD ("Request For Drive") state. When set, the current driver has
|
|
72
|
+
* released control: the sandbox is torn down, and any authenticated user
|
|
73
|
+
* may take over via POST /api/loops/:id/drive. Cleared when someone drives.
|
|
74
|
+
*/
|
|
75
|
+
rfdRequestedAt?: string
|
|
76
|
+
rfdRequestedBy?: string
|
|
77
|
+
/**
|
|
78
|
+
* One-shot flag written by POST /api/loops/:id/drive, consumed by the next
|
|
79
|
+
* sendUserText. While set, the next user message is prefixed with a
|
|
80
|
+
* handoff preamble so the model knows the user it's talking to has just
|
|
81
|
+
* changed. Cleared atomically when consumed.
|
|
82
|
+
*/
|
|
83
|
+
pendingDriverNote?: { from: string; to: string; at: string }
|
|
84
|
+
repo?: string
|
|
85
|
+
branch?: string
|
|
86
|
+
config?: {
|
|
87
|
+
default_model?: string
|
|
88
|
+
default_model_source?: "personal" | "workspace"
|
|
89
|
+
default_model_id?: string
|
|
90
|
+
permission_mode?: string
|
|
91
|
+
/**
|
|
92
|
+
* Active profiles for this loop (post-2026-05 composition model).
|
|
93
|
+
* Profiles live in `<LOOPAT_HOME>/context/profiles/<name>/`; each has a
|
|
94
|
+
* profile.json (lists plugin specs) + sibling CLAUDE.md + optional
|
|
95
|
+
* knowledge/. On spawn, loopat orchestrates `claude plugin install` for
|
|
96
|
+
* the union of plugins, concats CLAUDE.mds, mounts knowledge.
|
|
97
|
+
*
|
|
98
|
+
* Order matters: CLAUDE.md fragments concat in declared order (later
|
|
99
|
+
* shadows earlier). "base" profile is always implicit if present, even
|
|
100
|
+
* when this list is empty. Personal CLAUDE.md appends last.
|
|
101
|
+
*
|
|
102
|
+
* Empty / undefined = no profile-driven plugins, base CLAUDE.md only
|
|
103
|
+
* (if it exists), personal CLAUDE.md only. CC still runs.
|
|
104
|
+
*
|
|
105
|
+
* See docs/composition.md.
|
|
106
|
+
*/
|
|
107
|
+
profiles?: string[]
|
|
108
|
+
/**
|
|
109
|
+
* Vault selected for this loop. The named vault under
|
|
110
|
+
* `personal/<user>/.loopat/vaults/<vault>/` provides this loop's
|
|
111
|
+
* credentials at runtime. Default: "default". The act of choosing here
|
|
112
|
+
* is the security boundary — other vaults are not exposed inside the
|
|
113
|
+
* sandbox. Set to null only by very old loops created before vaults
|
|
114
|
+
* existed; bwrap treats absent/null as "default" for backward compat.
|
|
115
|
+
*/
|
|
116
|
+
vault?: string
|
|
117
|
+
/**
|
|
118
|
+
* If true, /loopat/context/knowledge/ is bound rw instead of ro. Set
|
|
119
|
+
* for loops that exist to distill notes into knowledge.
|
|
120
|
+
*/
|
|
121
|
+
knowledge_rw?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* Admin-only flag: bind the entire LOOPAT_HOME/loops/ tree read-only
|
|
124
|
+
* at /loopat/loops/ so this loop can read every other loop's chat
|
|
125
|
+
* history, workdir, meta, etc. — for cross-loop distill. Granted only
|
|
126
|
+
* to admins at create time; cannot be toggled later.
|
|
127
|
+
*
|
|
128
|
+
* Privacy note: this exposes other users' chats and workdirs to the
|
|
129
|
+
* driver of this loop. Don't ship a UI that lets non-admins flip it.
|
|
130
|
+
*/
|
|
131
|
+
mount_all_loops?: boolean
|
|
132
|
+
/** Session-scoped goal set via /goal. Displayed in UI and injected into the system prompt. */
|
|
133
|
+
goal?: string
|
|
134
|
+
goalSetAt?: string
|
|
135
|
+
goalStatus?: "active" | "completed"
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Archive = "hide + read-only". Hidden from default list, all writes
|
|
139
|
+
* (sendUserText / clear / setProvider / writeTerm / answerQuestions /
|
|
140
|
+
* vault writes) reject. Reads stay open (attach, history, files, term
|
|
141
|
+
* view). Lossless — `unarchive` flips back. See docs/design notes.
|
|
142
|
+
*/
|
|
143
|
+
archived?: boolean
|
|
144
|
+
archivedAt?: string
|
|
145
|
+
/**
|
|
146
|
+
* Free-form key/value metadata attached by the caller of the v1 Loop API.
|
|
147
|
+
* Not interpreted by loopat; not exposed to the sandbox. Used by external
|
|
148
|
+
* integrations (e.g. a bot framework storing "slack_thread: C123:1234").
|
|
149
|
+
* Capped at 16 KB JSON-serialized.
|
|
150
|
+
*/
|
|
151
|
+
metadata?: Record<string, unknown>
|
|
152
|
+
/**
|
|
153
|
+
* If true, this loop's chat (and only the chat) is readable by anonymous
|
|
154
|
+
* visitors at `/share/:id`. Everything else (workspace, files, kanban, ...)
|
|
155
|
+
* still requires auth. Only the loop's `createdBy` may toggle it.
|
|
156
|
+
*/
|
|
157
|
+
public?: boolean
|
|
158
|
+
publicAt?: string
|
|
159
|
+
/**
|
|
160
|
+
* Workspace serve config. When shareEnabled, the loop's workdir is accessible
|
|
161
|
+
* via one of three modes:
|
|
162
|
+
*
|
|
163
|
+
* - "static" — serve container streams workdir files via subdomain
|
|
164
|
+
* - "port" — serve container HTTP-proxies to sharePort via subdomain
|
|
165
|
+
* - "direct" — port-proxy container TCP/UDP-relays a fixed external
|
|
166
|
+
* port (shareExternalPort) to sharePort
|
|
167
|
+
* - "ephemeral" — the loop container itself publishes sharePort via
|
|
168
|
+
* `-p :<sharePort>`, kernel-assigned host port that
|
|
169
|
+
* changes on every container restart. No port-proxy.
|
|
170
|
+
* Read the current host port via `podman port`.
|
|
171
|
+
*/
|
|
172
|
+
shareEnabled?: boolean
|
|
173
|
+
shareMode?: "static" | "port" | "ephemeral"
|
|
174
|
+
shareAlias?: string
|
|
175
|
+
sharePort?: number
|
|
176
|
+
/** External port for direct TCP/UDP access (see port-proxy). */
|
|
177
|
+
shareExternalPort?: number
|
|
178
|
+
/** Protocol for shareExternalPort: "tcp" (default), "udp", or "static". */
|
|
179
|
+
shareProtocol?: "tcp" | "udp" | "static"
|
|
180
|
+
/**
|
|
181
|
+
* Set when the loop was spawned from a chat conversation. The snapshot of
|
|
182
|
+
* the chat history is at loops/<id>/context/chat/<convId>.jsonl (mounted as
|
|
183
|
+
* /loopat/context/chat/<convId>.jsonl inside the sandbox).
|
|
184
|
+
*/
|
|
185
|
+
seededFrom?: {
|
|
186
|
+
kind: "chat"
|
|
187
|
+
convId: string
|
|
188
|
+
messageCount: number
|
|
189
|
+
snapshotAt: string
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Last metadata received from the external runtime gateway. Written by
|
|
193
|
+
* `recordExternalMeta` on each turn so the UI / admin can see which
|
|
194
|
+
* external platform and user this loop serves. Only present on loops
|
|
195
|
+
* created via the gateway SSE API.
|
|
196
|
+
*/
|
|
197
|
+
lastExternalMeta?: {
|
|
198
|
+
source: string | null
|
|
199
|
+
userId: string | null
|
|
200
|
+
metadata: Record<string, unknown> | null
|
|
201
|
+
traceId: string | null
|
|
202
|
+
at: string
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const PERSONAL_MEMORY_INDEX_STUB = `# Personal memory index
|
|
207
|
+
|
|
208
|
+
Each line points at a memory file in this directory. Maintained by Claude.
|
|
209
|
+
|
|
210
|
+
`
|
|
211
|
+
|
|
212
|
+
const TEAM_MEMORY_INDEX_STUB = `# Team memory index
|
|
213
|
+
|
|
214
|
+
Cross-loop, cross-user memory shared via the notes git repo. One line per entry.
|
|
215
|
+
Promote here only when the insight is workspace-wide (a convention, an
|
|
216
|
+
operational fact, a non-obvious gotcha). Routine observations belong in
|
|
217
|
+
\`/loopat/context/personal/memory/\` instead.
|
|
218
|
+
|
|
219
|
+
`
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Who is currently driving this loop — `meta.driver` if set, else the
|
|
223
|
+
* creator. Use this everywhere "whose credentials/permissions" matters.
|
|
224
|
+
* Reserve direct `meta.createdBy` reads for "who owns this loop forever"
|
|
225
|
+
* (archive, public toggle).
|
|
226
|
+
*/
|
|
227
|
+
export function effectiveDriver(meta: { createdBy: string; driver?: string }): string {
|
|
228
|
+
return meta.driver ?? meta.createdBy
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function isDriver(meta: { createdBy: string; driver?: string }, userId: string): boolean {
|
|
232
|
+
return effectiveDriver(meta) === userId
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Derive the ephemeral `-p` set to pass into the loop's container at create
|
|
237
|
+
* time. Returns an empty list unless the loop is in "ephemeral" share mode
|
|
238
|
+
* with a valid internal port. Static mode and the legacy "port"/"direct"
|
|
239
|
+
* modes don't touch the loop container's own port mappings (they go via
|
|
240
|
+
* the serve / port-proxy containers instead).
|
|
241
|
+
*/
|
|
242
|
+
export function loopEphemeralPorts(
|
|
243
|
+
meta: Pick<LoopMeta, "shareEnabled" | "shareMode" | "sharePort" | "shareProtocol">,
|
|
244
|
+
): { internalPort: number; protocol?: "tcp" | "udp" }[] {
|
|
245
|
+
if (!meta.shareEnabled || meta.shareMode !== "ephemeral" || !meta.sharePort) return []
|
|
246
|
+
const proto = meta.shareProtocol === "udp" ? "udp" : "tcp"
|
|
247
|
+
return [{ internalPort: meta.sharePort, protocol: proto }]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function gitInitIfMissing(dir: string) {
|
|
251
|
+
if (existsSyncBase(join(dir, ".git"))) return
|
|
252
|
+
try {
|
|
253
|
+
await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"])
|
|
254
|
+
} catch (e: any) {
|
|
255
|
+
console.warn(`[loopat] git init failed for ${dir}: ${e?.message ?? e}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function isEmptyOrMissing(dir: string): Promise<boolean> {
|
|
260
|
+
if (!existsSyncBase(dir)) return true
|
|
261
|
+
try {
|
|
262
|
+
const names = await readdir(dir)
|
|
263
|
+
return names.length === 0
|
|
264
|
+
} catch {
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Materialize a context repo (knowledge / notes) with an `origin` to pull/push.
|
|
271
|
+
* Remote backend: clone the configured git url. Local backend (no url): loopat
|
|
272
|
+
* hosts the remote itself — a bare repo at origins/<name>.git becomes `origin`
|
|
273
|
+
* (docs/context-flow.md "solo"). Either way the working dir ends up a git repo
|
|
274
|
+
* with `origin` set, so the symmetric pull/push model just works.
|
|
275
|
+
*/
|
|
276
|
+
async function ensureContextRepo(dir: string, name: string, url?: string): Promise<void> {
|
|
277
|
+
if (url && (await isEmptyOrMissing(dir))) {
|
|
278
|
+
try {
|
|
279
|
+
try { await rm(dir, { recursive: true, force: true }) } catch {}
|
|
280
|
+
await mkdir(join(dir, ".."), { recursive: true })
|
|
281
|
+
await execFileP("git", ["clone", "--", url, dir])
|
|
282
|
+
console.log(`[loopat] cloned ${url} → ${dir}`)
|
|
283
|
+
return
|
|
284
|
+
} catch (e: any) {
|
|
285
|
+
console.warn(`[loopat] clone failed (${url}): ${e?.stderr ?? e?.message ?? e} — falling back to local origin`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Local backend: loopat-hosted bare origin.
|
|
289
|
+
const bare = workspaceOriginPath(name)
|
|
290
|
+
if (!existsSyncBase(join(bare, "HEAD"))) {
|
|
291
|
+
await mkdir(workspaceOriginsDir(), { recursive: true })
|
|
292
|
+
try {
|
|
293
|
+
await execFileP("git", ["init", "--bare", "-b", "main", bare])
|
|
294
|
+
} catch (e: any) {
|
|
295
|
+
console.warn(`[loopat] bare init failed (${bare}): ${e?.message ?? e}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (await isEmptyOrMissing(dir)) {
|
|
299
|
+
try { await rm(dir, { recursive: true, force: true }) } catch {}
|
|
300
|
+
await mkdir(join(dir, ".."), { recursive: true })
|
|
301
|
+
try {
|
|
302
|
+
await execFileP("git", ["clone", "--", bare, dir])
|
|
303
|
+
} catch {
|
|
304
|
+
await mkdir(dir, { recursive: true })
|
|
305
|
+
await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]).catch(() => {})
|
|
306
|
+
await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {})
|
|
307
|
+
}
|
|
308
|
+
} else if (existsSyncBase(join(dir, ".git"))) {
|
|
309
|
+
const hasOrigin = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
|
|
310
|
+
if (!hasOrigin) await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {})
|
|
311
|
+
} else {
|
|
312
|
+
// non-empty dir that isn't a git repo yet (e.g. a freshly-scaffolded
|
|
313
|
+
// personal/) → init in place and point it at the local bare origin.
|
|
314
|
+
await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]).catch(() => {})
|
|
315
|
+
await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Repos are clone-on-demand — they can be large, so we don't pre-clone the
|
|
321
|
+
* whole set. Instead write a manifest (REPOS.md) listing the full roster, and
|
|
322
|
+
* clone a repo only when it's actually needed. Per docs/context-flow.md the AI
|
|
323
|
+
* can also clone any listed repo by hand into context/repos/<name>.
|
|
324
|
+
*/
|
|
325
|
+
async function writeReposManifest(specs: RepoSpec[]) {
|
|
326
|
+
await mkdir(workspaceReposDir(), { recursive: true })
|
|
327
|
+
const body = [
|
|
328
|
+
"# repos — clone on demand",
|
|
329
|
+
"",
|
|
330
|
+
"Full roster below. Only already-cloned repos exist as subdirectories;",
|
|
331
|
+
"clone any other on demand: `git clone <git> /loopat/context/repos/<name>`.",
|
|
332
|
+
"",
|
|
333
|
+
...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``),
|
|
334
|
+
"",
|
|
335
|
+
].join("\n")
|
|
336
|
+
await writeFile(join(workspaceReposDir(), "REPOS.md"), body)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Clone a single registered repo if it isn't present yet. Returns whether the
|
|
341
|
+
* repo dir exists afterwards. Used by loop creation and any on-demand path.
|
|
342
|
+
*/
|
|
343
|
+
async function ensureRepoCloned(name: string): Promise<boolean> {
|
|
344
|
+
const dir = workspaceRepoDir(name)
|
|
345
|
+
if (existsSyncBase(dir)) return true
|
|
346
|
+
const cfg = await loadConfig()
|
|
347
|
+
const spec = cfg.repos?.find((r) => r.name === name)
|
|
348
|
+
if (!spec?.git) return false
|
|
349
|
+
try {
|
|
350
|
+
await mkdir(workspaceReposDir(), { recursive: true })
|
|
351
|
+
await execFileP("git", ["clone", "--", spec.git, dir])
|
|
352
|
+
console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
|
|
353
|
+
return true
|
|
354
|
+
} catch (e: any) {
|
|
355
|
+
console.warn(`[loopat] repo clone failed (${spec.git}): ${e?.stderr ?? e?.message ?? e}`)
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function ensureWorkspaceDirs() {
|
|
361
|
+
await mkdir(workspaceDir(), { recursive: true })
|
|
362
|
+
await mkdir(loopsDir(), { recursive: true })
|
|
363
|
+
await mkdir(workspaceReposDir(), { recursive: true })
|
|
364
|
+
|
|
365
|
+
// knowledge / notes / repos: clone from config'd remote if present
|
|
366
|
+
const cfg = await loadConfig()
|
|
367
|
+
await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
|
|
368
|
+
await ensureContextRepo(workspaceNotesDir(), "notes", cfg.notes?.git || undefined)
|
|
369
|
+
await writeReposManifest(cfg.repos ?? [])
|
|
370
|
+
|
|
371
|
+
// workspace memory dir + stub
|
|
372
|
+
const tm = workspaceMemoryDir()
|
|
373
|
+
await mkdir(tm, { recursive: true })
|
|
374
|
+
const tmIdx = `${tm}/MEMORY.md`
|
|
375
|
+
if (!existsSyncBase(tmIdx)) await writeFile(tmIdx, TEAM_MEMORY_INDEX_STUB)
|
|
376
|
+
|
|
377
|
+
// knowledge / notes are already git repos with `origin` (ensureContextRepo).
|
|
378
|
+
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Provision a freshly-registered user's personal/ tree. NEVER clones the
|
|
383
|
+
* user's remote repo here — the server has no credentials for private repos
|
|
384
|
+
* at register time. We:
|
|
385
|
+
* 1. mkdir + `git init` an empty personal/<user>/
|
|
386
|
+
* 2. seed `memory/MEMORY.md` so SDK auto-recall sees something
|
|
387
|
+
* 3. generate a loopat-managed ed25519 keypair under
|
|
388
|
+
* `host-secrets/<user>/deploy-key` (deploy-key flow, host-only)
|
|
389
|
+
*
|
|
390
|
+
* If `personalRepo` was given at register, the user goes through a separate
|
|
391
|
+
* confirm step (see `importPersonalFromRepo`) AFTER they paste the public key
|
|
392
|
+
* as a deploy key on the remote.
|
|
393
|
+
*
|
|
394
|
+
* Returns the public key so the UI can show it.
|
|
395
|
+
*/
|
|
396
|
+
export async function provisionUserPersonal(userId: string): Promise<{ publicKey: string | null }> {
|
|
397
|
+
const dir = personalDir(userId)
|
|
398
|
+
await mkdir(dir, { recursive: true })
|
|
399
|
+
|
|
400
|
+
const pm = personalMemoryDir(userId)
|
|
401
|
+
await mkdir(pm, { recursive: true })
|
|
402
|
+
const pmIdx = `${pm}/MEMORY.md`
|
|
403
|
+
if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB)
|
|
404
|
+
|
|
405
|
+
// personal gets a loopat-hosted bare origin too (local backend), so its
|
|
406
|
+
// promote is `push origin` like every other context repo. A later import of
|
|
407
|
+
// the user's own remote repo (importPersonalFromRepo) replaces this origin.
|
|
408
|
+
await ensureContextRepo(dir, `personal-${userId}`, undefined)
|
|
409
|
+
|
|
410
|
+
const { publicKey } = await ensurePersonalKeypair(userId)
|
|
411
|
+
return { publicKey }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Provider-agnostic personal onboarding (docs/identity.md integration contract).
|
|
416
|
+
* Uses a host-side credential (token, never enters a sandbox) via the selected
|
|
417
|
+
* GitHostProvider to create the personal repo + register the deploy key, then
|
|
418
|
+
* reuses importPersonalFromRepo to clone + handle git-crypt (empty repo →
|
|
419
|
+
* auto-init and return the generated key; existing → needsCryptKey).
|
|
420
|
+
*
|
|
421
|
+
* The token only *sets things up*; runtime git uses the deploy key / vault.
|
|
422
|
+
* `provider` selects the git platform (default "github"); add platforms by
|
|
423
|
+
* implementing GitHostProvider (see git-host.ts / providers.ts).
|
|
424
|
+
*/
|
|
425
|
+
export async function setupPersonalViaProvider(opts: {
|
|
426
|
+
userId: string
|
|
427
|
+
provider?: string
|
|
428
|
+
token: string
|
|
429
|
+
baseUrl?: string
|
|
430
|
+
repoName: string
|
|
431
|
+
cryptKey?: string
|
|
432
|
+
}): Promise<
|
|
433
|
+
| { ok: true; repo: string; repoUrl: string; created: boolean; autoInitialized?: boolean; cryptKey?: string }
|
|
434
|
+
| { ok: false; error: string; needsCryptKey?: boolean }
|
|
435
|
+
> {
|
|
436
|
+
await loadExtensionProviders() // ensure external (internal-platform) providers are registered
|
|
437
|
+
const provider = getProvider(opts.provider ?? "github")
|
|
438
|
+
if (!provider) return { ok: false, error: `unknown git host provider: ${opts.provider}` }
|
|
439
|
+
const cred = { token: opts.token, baseUrl: opts.baseUrl }
|
|
440
|
+
|
|
441
|
+
let login: string
|
|
442
|
+
let email: string | undefined
|
|
443
|
+
try {
|
|
444
|
+
const auth = await provider.authenticate(cred)
|
|
445
|
+
login = auth.login
|
|
446
|
+
email = auth.email
|
|
447
|
+
} catch (e: any) {
|
|
448
|
+
return { ok: false, error: `${provider.id} auth failed: ${e?.message ?? e}` }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let repo: { url: string; created: boolean }
|
|
452
|
+
try {
|
|
453
|
+
repo = await provider.ensureRepo(cred, opts.repoName, { private: true })
|
|
454
|
+
} catch (e: any) {
|
|
455
|
+
return { ok: false, error: `ensure repo failed: ${e?.message ?? e}` }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Set up git auth per the provider's mode.
|
|
459
|
+
let cloneUrl = repo.url
|
|
460
|
+
if (provider.gitAuthMode === "ssh-deploy-key") {
|
|
461
|
+
// GitHub-style: register a loopat-generated deploy key; git clones via ssh.
|
|
462
|
+
const { publicKey } = await ensurePersonalKeypair(opts.userId)
|
|
463
|
+
if (publicKey && provider.registerDeployKey) {
|
|
464
|
+
try {
|
|
465
|
+
await provider.registerDeployKey(cred, { owner: login, name: opts.repoName }, `loopat:${opts.userId}`, publicKey, false)
|
|
466
|
+
} catch (e: any) {
|
|
467
|
+
return { ok: false, error: `register deploy key failed: ${e?.message ?? e}` }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
// https-token git: https://<login>:<token>@host/path — GitLab/Code use the
|
|
472
|
+
// username + private_token as basic auth (GitHub PAT works the same way).
|
|
473
|
+
// Normalize http→https. (MVP: the token lands in the worktree's .git/config —
|
|
474
|
+
// fine for a private, user-owned personal repo; a credential-helper pass can
|
|
475
|
+
// harden it later.)
|
|
476
|
+
cloneUrl = repo.url.replace(
|
|
477
|
+
/^https?:\/\//,
|
|
478
|
+
`https://${encodeURIComponent(login)}:${encodeURIComponent(opts.token)}@`,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Clone + git-crypt via the existing import path (commit author from the
|
|
483
|
+
// platform identity — some hosts reject non-corporate emails).
|
|
484
|
+
// Internal-setup hook (optional): the provider may seed default files into
|
|
485
|
+
// the fresh repo (provider configs, ssh keys, …). Only fires on auto-init.
|
|
486
|
+
const seed = provider.seedDefaults
|
|
487
|
+
? (repoDir: string) =>
|
|
488
|
+
provider.seedDefaults!({
|
|
489
|
+
repoDir,
|
|
490
|
+
vaultDir: join(repoDir, ".loopat", "vaults", "default"),
|
|
491
|
+
userId: opts.userId,
|
|
492
|
+
login,
|
|
493
|
+
})
|
|
494
|
+
: undefined
|
|
495
|
+
const imp = await importPersonalFromRepo(opts.userId, cloneUrl, opts.cryptKey, { name: login, email }, seed)
|
|
496
|
+
if (!imp.ok) return { ok: false, error: imp.error, needsCryptKey: imp.needsCryptKey }
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
repo: `${login}/${opts.repoName}`,
|
|
500
|
+
repoUrl: repo.url,
|
|
501
|
+
created: repo.created,
|
|
502
|
+
autoInitialized: imp.autoInitialized,
|
|
503
|
+
cryptKey: imp.cryptKey,
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Back-compat thin wrapper — GitHub is just the default provider. */
|
|
508
|
+
export async function setupPersonalViaGithub(opts: {
|
|
509
|
+
userId: string
|
|
510
|
+
token: string
|
|
511
|
+
repoName: string
|
|
512
|
+
baseUrl?: string
|
|
513
|
+
cryptKey?: string
|
|
514
|
+
}) {
|
|
515
|
+
return setupPersonalViaProvider({ ...opts, provider: "github" })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** List the user's repos via a provider (onboarding picker), "personal"-named
|
|
519
|
+
* first. Empty when the provider can't list or the call fails. */
|
|
520
|
+
export async function listPersonalReposViaProvider(opts: {
|
|
521
|
+
provider?: string
|
|
522
|
+
token: string
|
|
523
|
+
baseUrl?: string
|
|
524
|
+
}): Promise<{ name: string; path: string }[]> {
|
|
525
|
+
await loadExtensionProviders()
|
|
526
|
+
const provider = getProvider(opts.provider ?? "github")
|
|
527
|
+
if (!provider?.listRepos) return []
|
|
528
|
+
let repos: { name: string; path: string }[]
|
|
529
|
+
try {
|
|
530
|
+
repos = await provider.listRepos({ token: opts.token, baseUrl: opts.baseUrl })
|
|
531
|
+
} catch {
|
|
532
|
+
return []
|
|
533
|
+
}
|
|
534
|
+
return repos.sort((a, b) => (b.name.includes("personal") ? 1 : 0) - (a.name.includes("personal") ? 1 : 0))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Validate a token by authenticating it against the provider. The onboarding
|
|
538
|
+
* picker calls this to fail fast on a bad token instead of silently showing
|
|
539
|
+
* an empty repo list. */
|
|
540
|
+
export async function authenticateViaProvider(opts: {
|
|
541
|
+
provider?: string
|
|
542
|
+
token: string
|
|
543
|
+
baseUrl?: string
|
|
544
|
+
}): Promise<{ ok: true; login: string } | { ok: false; error: string }> {
|
|
545
|
+
await loadExtensionProviders()
|
|
546
|
+
const provider = getProvider(opts.provider ?? "github")
|
|
547
|
+
if (!provider) return { ok: false, error: `unknown git host provider: ${opts.provider}` }
|
|
548
|
+
try {
|
|
549
|
+
const auth = await provider.authenticate({ token: opts.token, baseUrl: opts.baseUrl })
|
|
550
|
+
return { ok: true, login: auth.login }
|
|
551
|
+
} catch (e: any) {
|
|
552
|
+
return { ok: false, error: `${provider.id} auth failed: ${e?.message ?? e}` }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The provider's optional token-help hint (URL/text), for the onboarding UI. */
|
|
557
|
+
export async function providerTokenHelp(providerId?: string): Promise<string | null> {
|
|
558
|
+
await loadExtensionProviders()
|
|
559
|
+
return getProvider(providerId ?? "github")?.tokenHelp ?? null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Detect whether `personal/<user>/` is "fresh" — i.e. only has the
|
|
564
|
+
* scaffolding we put there (`.git`, `memory/`). If yes, it's safe to wipe +
|
|
565
|
+
* clone over the top. Anything else means we refuse to overwrite.
|
|
566
|
+
*
|
|
567
|
+
* Note: host-secrets/<user>/ lives OUTSIDE personal/<user>/ so it's not
|
|
568
|
+
* part of this check and survives import without preservation logic.
|
|
569
|
+
*/
|
|
570
|
+
export async function isPersonalFresh(userId: string): Promise<boolean> {
|
|
571
|
+
const dir = personalDir(userId)
|
|
572
|
+
try {
|
|
573
|
+
const entries = await readdir(dir)
|
|
574
|
+
const SCAFFOLD = new Set([".git", "memory"])
|
|
575
|
+
return entries.every((e) => SCAFFOLD.has(e))
|
|
576
|
+
} catch {
|
|
577
|
+
return true
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* One-shot clone using the user's loopat-managed deploy key. Replaces the
|
|
583
|
+
* fresh-scaffolded `personal/<user>/` with the cloned repo.
|
|
584
|
+
*
|
|
585
|
+
* Two paths:
|
|
586
|
+
*
|
|
587
|
+
* 1. Default (auto-init). User provides a *clean* repo URL (no git-crypt
|
|
588
|
+
* config and no tracked `.loopat/vaults/**`). Server clones, runs
|
|
589
|
+
* `git-crypt init`, writes `.gitattributes` + `.gitignore`, commits the
|
|
590
|
+
* scaffold, and pushes. The newly-generated symmetric key is saved under
|
|
591
|
+
* `host-secrets/<user>/git-crypt.key` AND returned to the caller exactly
|
|
592
|
+
* once so the UI can show it for backup.
|
|
593
|
+
*
|
|
594
|
+
* 2. Recovery (BYOK). User pastes a base64-encoded git-crypt key in
|
|
595
|
+
* `cryptKey`. Repo must already be a git-crypt'd loopat repo (typical
|
|
596
|
+
* case: same user, new host). Server runs `git-crypt unlock`, stores the
|
|
597
|
+
* key under host-secrets/, swaps personal/ in.
|
|
598
|
+
*
|
|
599
|
+
* Anything in between (partially set-up repo, leftover plaintext secrets,
|
|
600
|
+
* git-crypt configured but no key supplied, etc.) is refused with a precise
|
|
601
|
+
* error so the user knows what to fix.
|
|
602
|
+
*
|
|
603
|
+
* Returns { ok: false, error } on any failure; on failure personal/<user>/
|
|
604
|
+
* is left untouched (we clone into a temp dir first).
|
|
605
|
+
*/
|
|
606
|
+
export async function importPersonalFromRepo(
|
|
607
|
+
userId: string,
|
|
608
|
+
repoUrl: string,
|
|
609
|
+
cryptKey?: string,
|
|
610
|
+
author?: { name?: string; email?: string },
|
|
611
|
+
seed?: (repoDir: string) => Promise<void>,
|
|
612
|
+
): Promise<
|
|
613
|
+
| { ok: true; autoInitialized?: boolean; cryptKey?: string }
|
|
614
|
+
| {
|
|
615
|
+
ok: false
|
|
616
|
+
error: string
|
|
617
|
+
needsCryptKey?: boolean
|
|
618
|
+
notClean?: boolean
|
|
619
|
+
secretsExposed?: boolean
|
|
620
|
+
exposedFiles?: string[]
|
|
621
|
+
}
|
|
622
|
+
> {
|
|
623
|
+
if (!repoUrl?.trim()) return { ok: false, error: "repoUrl required" }
|
|
624
|
+
|
|
625
|
+
// Refuse if the user has already populated personal/. We don't want to nuke
|
|
626
|
+
// their work. They can `rm -rf` manually and retry if that's really intended.
|
|
627
|
+
if (!(await isPersonalFresh(userId))) {
|
|
628
|
+
return { ok: false, error: "personal/ is not empty — refusing to overwrite" }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// https-token urls carry their own auth (https://user:token@…) and need no
|
|
632
|
+
// ssh deploy key; ssh urls require the loopat-managed deploy key.
|
|
633
|
+
const isHttps = /^https?:\/\//.test(repoUrl)
|
|
634
|
+
const priv = hostDeployKeyPath(userId)
|
|
635
|
+
if (!isHttps && !existsSyncBase(priv)) {
|
|
636
|
+
return { ok: false, error: "deploy keypair missing — re-register" }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Clone into a tmp dir. ssh uses the deploy key (StrictHostKeyChecking=
|
|
640
|
+
// accept-new, no pre-populated known_hosts on first run); https auths via url.
|
|
641
|
+
const tmp = await mkdtemp(join(tmpdir(), `loopat-import-${userId}-`))
|
|
642
|
+
const cloneEnv = isHttps ? { ...process.env } : { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) }
|
|
643
|
+
try {
|
|
644
|
+
await execFileP("git", ["clone", "--", repoUrl, tmp], { env: cloneEnv })
|
|
645
|
+
} catch (e: any) {
|
|
646
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
647
|
+
const msg = (e?.stderr || e?.message || String(e)).toString().trim().split("\n").slice(-3).join(" ")
|
|
648
|
+
return { ok: false, error: `clone failed: ${msg}` }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Exposure check (always, regardless of path): refuse to adopt a repo whose
|
|
652
|
+
// .loopat/vaults/** are plaintext in git. Even with BYOK this is bad —
|
|
653
|
+
// if any single secret blob is plaintext, those secrets are already burned.
|
|
654
|
+
const exposed = await detectExposedSecrets(tmp)
|
|
655
|
+
if (exposed.length > 0) {
|
|
656
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
657
|
+
return {
|
|
658
|
+
ok: false,
|
|
659
|
+
error: "secrets are exposed (plaintext) in this repo's git history",
|
|
660
|
+
secretsExposed: true,
|
|
661
|
+
exposedFiles: exposed.slice(0, 20),
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const hasGitCrypt = await detectGitCryptEnabled(tmp)
|
|
666
|
+
const trackedSecrets = await listTrackedSecretFiles(tmp)
|
|
667
|
+
|
|
668
|
+
if (cryptKey?.trim()) {
|
|
669
|
+
// ── BYOK / recovery path ──
|
|
670
|
+
if (!hasGitCrypt) {
|
|
671
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
error:
|
|
675
|
+
"you provided a crypt key but this repo has no git-crypt config — leave the key field empty to let loopat initialize the repo, or point at the right repo",
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const unlockResult = await unlockWithCryptKey(tmp, userId, cryptKey)
|
|
679
|
+
if (!unlockResult.ok) {
|
|
680
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
681
|
+
return { ok: false, error: unlockResult.error }
|
|
682
|
+
}
|
|
683
|
+
return await swapPersonalDir(userId, tmp)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Default / auto-init path ──
|
|
687
|
+
// Require a strictly clean repo: no git-crypt config, no tracked secrets.
|
|
688
|
+
if (hasGitCrypt) {
|
|
689
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
690
|
+
return {
|
|
691
|
+
ok: false,
|
|
692
|
+
notClean: true,
|
|
693
|
+
error:
|
|
694
|
+
"this repo already has git-crypt configured — either point at a fresh empty repo, or paste your existing crypt key under Recovery to import it",
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (trackedSecrets.length > 0) {
|
|
698
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
699
|
+
return {
|
|
700
|
+
ok: false,
|
|
701
|
+
notClean: true,
|
|
702
|
+
error: `\`.loopat/vaults/\` in this repo isn't empty (${trackedSecrets.length} file(s) tracked) — use a fresh repo`,
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const init = await autoInitGitCrypt(tmp, userId, author, seed)
|
|
707
|
+
if (!init.ok) {
|
|
708
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {})
|
|
709
|
+
return { ok: false, error: init.error }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const swap = await swapPersonalDir(userId, tmp)
|
|
713
|
+
if (!swap.ok) return swap
|
|
714
|
+
return { ok: true, autoInitialized: true, cryptKey: init.cryptKey }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function sshCommandForUser(userId: string): string {
|
|
718
|
+
const priv = hostDeployKeyPath(userId)
|
|
719
|
+
return `ssh -i ${priv} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function swapPersonalDir(
|
|
723
|
+
userId: string,
|
|
724
|
+
tmp: string,
|
|
725
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
726
|
+
const dir = personalDir(userId)
|
|
727
|
+
try {
|
|
728
|
+
await mkdir(join(dir, ".."), { recursive: true })
|
|
729
|
+
await rm(dir, { recursive: true, force: true })
|
|
730
|
+
await rename(tmp, dir)
|
|
731
|
+
const pm = personalMemoryDir(userId)
|
|
732
|
+
await mkdir(pm, { recursive: true })
|
|
733
|
+
const pmIdx = `${pm}/MEMORY.md`
|
|
734
|
+
if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB)
|
|
735
|
+
return { ok: true }
|
|
736
|
+
} catch (e: any) {
|
|
737
|
+
return { ok: false, error: `swap failed: ${e?.message ?? e}` }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Server-side bootstrap for a clean personal repo: git-crypt init, write the
|
|
743
|
+
* scaffold (`.gitattributes`, `.gitignore`, `.loopat/vaults/default/.gitkeep`),
|
|
744
|
+
* commit, push, and stash the freshly-generated symmetric key under
|
|
745
|
+
* host-secrets/<user>/. Returns the key base64-encoded so the UI can show it
|
|
746
|
+
* to the user exactly once for backup.
|
|
747
|
+
*
|
|
748
|
+
* On any failure (clone tampered, push permission missing, git-crypt missing)
|
|
749
|
+
* the saved host-secrets key is rolled back so a retry starts from scratch.
|
|
750
|
+
*/
|
|
751
|
+
async function autoInitGitCrypt(
|
|
752
|
+
repoDir: string,
|
|
753
|
+
userId: string,
|
|
754
|
+
author?: { name?: string; email?: string },
|
|
755
|
+
seed?: (repoDir: string) => Promise<void>,
|
|
756
|
+
): Promise<{ ok: true; cryptKey: string } | { ok: false; error: string }> {
|
|
757
|
+
// git-crypt must be on the host; check early with a useful error
|
|
758
|
+
try {
|
|
759
|
+
await execFileP("git-crypt", ["--version"])
|
|
760
|
+
} catch {
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
error: "git-crypt not installed on host (sudo apt install git-crypt / brew install git-crypt)",
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Local-only commit author so this doesn't depend on global git config
|
|
768
|
+
try {
|
|
769
|
+
await execFileP("git", ["-C", repoDir, "config", "user.email", author?.email ?? "loopat@local"])
|
|
770
|
+
await execFileP("git", ["-C", repoDir, "config", "user.name", author?.name ?? "loopat"])
|
|
771
|
+
} catch (e: any) {
|
|
772
|
+
return { ok: false, error: `git config failed: ${e?.message ?? e}` }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
await execFileP("git-crypt", ["init"], { cwd: repoDir })
|
|
777
|
+
} catch (e: any) {
|
|
778
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
779
|
+
return { ok: false, error: `git-crypt init failed: ${stderr || e?.message || e}` }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Merge .gitattributes (preserve any existing lines, e.g. LFS / line endings).
|
|
783
|
+
await appendLineIfMissing(
|
|
784
|
+
join(repoDir, ".gitattributes"),
|
|
785
|
+
".loopat/vaults/** filter=git-crypt diff=git-crypt",
|
|
786
|
+
(existing, line) => existing.includes(line),
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
// Merge .gitignore so host-only state under .loopat/host/ never gets pushed
|
|
790
|
+
await appendLineIfMissing(
|
|
791
|
+
join(repoDir, ".gitignore"),
|
|
792
|
+
"/.loopat/host/",
|
|
793
|
+
(existing, line) =>
|
|
794
|
+
existing.split("\n").some((l) => l.trim() === line || l.trim() === ".loopat/host/"),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
// Scaffold vaults/default/ so future writes land in a tracked directory.
|
|
798
|
+
// New imports start in the new layout; legacy `secrets/` is only consulted
|
|
799
|
+
// for users who imported before vaults existed.
|
|
800
|
+
await mkdir(join(repoDir, ".loopat/vaults/default"), { recursive: true })
|
|
801
|
+
await writeFile(join(repoDir, ".loopat/vaults/default/.gitkeep"), "")
|
|
802
|
+
|
|
803
|
+
// Ship the memory index in the scaffold commit so cloning onto a second
|
|
804
|
+
// host doesn't depend on swapPersonalDir's late top-up.
|
|
805
|
+
await mkdir(join(repoDir, "memory"), { recursive: true })
|
|
806
|
+
if (!existsSyncBase(join(repoDir, "memory/MEMORY.md"))) {
|
|
807
|
+
await writeFile(join(repoDir, "memory/MEMORY.md"), PERSONAL_MEMORY_INDEX_STUB)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Internal-setup hook: let the provider seed default files (provider configs,
|
|
811
|
+
// ssh keys, …) into the working tree now. git-crypt is initialized, so
|
|
812
|
+
// anything written under .loopat/vaults/** is encrypted, and the scaffold
|
|
813
|
+
// commit below picks it up via `git add .loopat`. Non-fatal.
|
|
814
|
+
if (seed) {
|
|
815
|
+
try {
|
|
816
|
+
await seed(repoDir)
|
|
817
|
+
} catch (e: any) {
|
|
818
|
+
console.warn(`[loopat] seedDefaults hook failed: ${e?.message ?? e}`)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Export the key BEFORE pushing so a push failure rolls back to a state
|
|
823
|
+
// that knows whether we had the key at all
|
|
824
|
+
let cryptKeyB64: string
|
|
825
|
+
let keyBuf: Buffer
|
|
826
|
+
try {
|
|
827
|
+
const exportPath = join(repoDir, ".git", "git-crypt-export.key")
|
|
828
|
+
await execFileP("git-crypt", ["export-key", exportPath], { cwd: repoDir })
|
|
829
|
+
keyBuf = await readFile(exportPath)
|
|
830
|
+
cryptKeyB64 = keyBuf.toString("base64")
|
|
831
|
+
await rm(exportPath, { force: true })
|
|
832
|
+
} catch (e: any) {
|
|
833
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
834
|
+
return { ok: false, error: `git-crypt export-key failed: ${stderr || e?.message || e}` }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Persist to host-secrets BEFORE push so loop start-up code can find it
|
|
838
|
+
// even if push partially succeeded. We undo on push failure below.
|
|
839
|
+
const { saveGitCryptKey } = await import("./git-crypt-key")
|
|
840
|
+
try {
|
|
841
|
+
await saveGitCryptKey(userId, keyBuf)
|
|
842
|
+
} catch (e: any) {
|
|
843
|
+
return { ok: false, error: `failed to save git-crypt key: ${e?.message ?? e}` }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Stage + commit
|
|
847
|
+
try {
|
|
848
|
+
await execFileP("git", [
|
|
849
|
+
"-C",
|
|
850
|
+
repoDir,
|
|
851
|
+
"add",
|
|
852
|
+
".gitattributes",
|
|
853
|
+
".gitignore",
|
|
854
|
+
".loopat",
|
|
855
|
+
"memory",
|
|
856
|
+
])
|
|
857
|
+
await execFileP("git", [
|
|
858
|
+
"-C",
|
|
859
|
+
repoDir,
|
|
860
|
+
"commit",
|
|
861
|
+
"-m",
|
|
862
|
+
"loopat: initialize personal vault (git-crypt enabled)",
|
|
863
|
+
])
|
|
864
|
+
} catch (e: any) {
|
|
865
|
+
await rollbackSavedKey(userId)
|
|
866
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
867
|
+
return { ok: false, error: `commit failed: ${stderr || e?.message || e}` }
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Determine target branch: prefer existing local HEAD (carries remote's
|
|
871
|
+
// default); fall back to "main" for the empty-repo case where there's no
|
|
872
|
+
// symbolic ref to follow.
|
|
873
|
+
let branch = "main"
|
|
874
|
+
try {
|
|
875
|
+
const { stdout } = await execFileP("git", ["-C", repoDir, "symbolic-ref", "--short", "HEAD"])
|
|
876
|
+
const v = stdout.trim()
|
|
877
|
+
if (v) branch = v
|
|
878
|
+
} catch {}
|
|
879
|
+
|
|
880
|
+
try {
|
|
881
|
+
await execFileP("git", ["-C", repoDir, "push", "origin", `HEAD:${branch}`], {
|
|
882
|
+
env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
|
|
883
|
+
})
|
|
884
|
+
} catch (e: any) {
|
|
885
|
+
await rollbackSavedKey(userId)
|
|
886
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
887
|
+
const hint = /denied|read.only|permission/i.test(stderr)
|
|
888
|
+
? " (does the deploy key have write access?)"
|
|
889
|
+
: ""
|
|
890
|
+
return { ok: false, error: `push failed${hint}: ${stderr || e?.message || e}` }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return { ok: true, cryptKey: cryptKeyB64 }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function rollbackSavedKey(userId: string) {
|
|
897
|
+
const { rm: rmFile } = await import("node:fs/promises")
|
|
898
|
+
await rmFile(personalGitCryptKeyPath(userId), { force: true }).catch(() => {})
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function appendLineIfMissing(
|
|
902
|
+
path: string,
|
|
903
|
+
line: string,
|
|
904
|
+
alreadyPresent: (existing: string, line: string) => boolean,
|
|
905
|
+
) {
|
|
906
|
+
let existing = ""
|
|
907
|
+
try {
|
|
908
|
+
existing = await readFile(path, "utf8")
|
|
909
|
+
} catch {}
|
|
910
|
+
if (alreadyPresent(existing, line)) return
|
|
911
|
+
const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n"
|
|
912
|
+
await writeFile(path, existing + sep + line + "\n")
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function listTrackedSecretFiles(repoDir: string): Promise<string[]> {
|
|
916
|
+
try {
|
|
917
|
+
const { stdout } = await execFileP("git", [
|
|
918
|
+
"-C",
|
|
919
|
+
repoDir,
|
|
920
|
+
"ls-files",
|
|
921
|
+
"-z",
|
|
922
|
+
".loopat/vaults",
|
|
923
|
+
])
|
|
924
|
+
return stdout
|
|
925
|
+
.split("\0")
|
|
926
|
+
.filter(Boolean)
|
|
927
|
+
// Scaffold marker files are not real content; ignore them.
|
|
928
|
+
.filter((f) => !f.endsWith("/.gitkeep"))
|
|
929
|
+
} catch {
|
|
930
|
+
return []
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export type PersonalDirtyStatus = {
|
|
935
|
+
uncommitted: number
|
|
936
|
+
unpushed: number
|
|
937
|
+
isGitRepo: boolean
|
|
938
|
+
hasRemote: boolean
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Inspect personal/<user>/: how many uncommitted worktree changes, how many
|
|
943
|
+
* commits not reachable from any remote-tracking branch. Used as the
|
|
944
|
+
* pre-flight before a destructive delete.
|
|
945
|
+
*
|
|
946
|
+
* Returns counts only; the caller decides what "dirty" means (we treat
|
|
947
|
+
* uncommitted > 0 || unpushed > 0 as dirty).
|
|
948
|
+
*/
|
|
949
|
+
export async function inspectPersonalDirty(userId: string): Promise<PersonalDirtyStatus> {
|
|
950
|
+
const dir = personalDir(userId)
|
|
951
|
+
if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) {
|
|
952
|
+
return { uncommitted: 0, unpushed: 0, isGitRepo: false, hasRemote: false }
|
|
953
|
+
}
|
|
954
|
+
let hasRemote = false
|
|
955
|
+
try {
|
|
956
|
+
const { stdout } = await execFileP("git", ["-C", dir, "remote"])
|
|
957
|
+
hasRemote = stdout.trim().length > 0
|
|
958
|
+
} catch {}
|
|
959
|
+
|
|
960
|
+
// Refresh remote-tracking refs so "unpushed" reflects current remote state.
|
|
961
|
+
// Best-effort — offline / no network is fine, we'll just over-report.
|
|
962
|
+
if (hasRemote) {
|
|
963
|
+
try {
|
|
964
|
+
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], {
|
|
965
|
+
env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
|
|
966
|
+
timeout: 15_000,
|
|
967
|
+
})
|
|
968
|
+
} catch {}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
let uncommitted = 0
|
|
972
|
+
try {
|
|
973
|
+
const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"])
|
|
974
|
+
uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length
|
|
975
|
+
} catch {}
|
|
976
|
+
|
|
977
|
+
let unpushed = 0
|
|
978
|
+
try {
|
|
979
|
+
// Commits on HEAD not reachable from any remote-tracking branch.
|
|
980
|
+
const { stdout } = await execFileP("git", [
|
|
981
|
+
"-C",
|
|
982
|
+
dir,
|
|
983
|
+
"rev-list",
|
|
984
|
+
"--count",
|
|
985
|
+
"HEAD",
|
|
986
|
+
"--not",
|
|
987
|
+
"--remotes",
|
|
988
|
+
])
|
|
989
|
+
unpushed = parseInt(stdout.trim(), 10) || 0
|
|
990
|
+
} catch {
|
|
991
|
+
// No commits at all on HEAD → rev-list errors; treat as 0
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
return { uncommitted, unpushed, isGitRepo: true, hasRemote }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Stage + commit + push everything in personal/<user>/. Best-effort. If
|
|
999
|
+
* there's nothing to commit but there are unpushed commits, just push.
|
|
1000
|
+
*/
|
|
1001
|
+
export async function syncPersonalToRemote(
|
|
1002
|
+
userId: string,
|
|
1003
|
+
): Promise<{ ok: true } | { ok: false, error: string }> {
|
|
1004
|
+
const dir = personalDir(userId)
|
|
1005
|
+
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1006
|
+
return { ok: false, error: "personal/ is not a git repo — nothing to sync to" }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Author must be set for the commit step. Set locally so we don't rely
|
|
1010
|
+
// on the host's global git config.
|
|
1011
|
+
try {
|
|
1012
|
+
await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"])
|
|
1013
|
+
await execFileP("git", ["-C", dir, "config", "user.name", "loopat"])
|
|
1014
|
+
} catch (e: any) {
|
|
1015
|
+
return { ok: false, error: `git config failed: ${e?.message ?? e}` }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Stage everything
|
|
1019
|
+
try {
|
|
1020
|
+
await execFileP("git", ["-C", dir, "add", "-A"])
|
|
1021
|
+
} catch (e: any) {
|
|
1022
|
+
return { ok: false, error: `git add failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Commit if there's anything staged. `git diff --cached --quiet` exits
|
|
1026
|
+
// non-zero when there are staged changes, so we invert the check.
|
|
1027
|
+
let hadStaged = false
|
|
1028
|
+
try {
|
|
1029
|
+
await execFileP("git", ["-C", dir, "diff", "--cached", "--quiet"])
|
|
1030
|
+
} catch {
|
|
1031
|
+
hadStaged = true
|
|
1032
|
+
}
|
|
1033
|
+
if (hadStaged) {
|
|
1034
|
+
try {
|
|
1035
|
+
await execFileP("git", [
|
|
1036
|
+
"-C",
|
|
1037
|
+
dir,
|
|
1038
|
+
"commit",
|
|
1039
|
+
"-m",
|
|
1040
|
+
"loopat: sync personal vault before delete",
|
|
1041
|
+
])
|
|
1042
|
+
} catch (e: any) {
|
|
1043
|
+
return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Determine target branch
|
|
1048
|
+
let branch = "main"
|
|
1049
|
+
try {
|
|
1050
|
+
const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
|
|
1051
|
+
const v = stdout.trim()
|
|
1052
|
+
if (v) branch = v
|
|
1053
|
+
} catch {}
|
|
1054
|
+
|
|
1055
|
+
// Need an origin to push to. If there's no remote (e.g. the user never
|
|
1056
|
+
// imported, personal/ is the local-only scaffold), refuse — sync is
|
|
1057
|
+
// impossible. Caller can still force-delete.
|
|
1058
|
+
let hasOrigin = false
|
|
1059
|
+
try {
|
|
1060
|
+
await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
|
|
1061
|
+
hasOrigin = true
|
|
1062
|
+
} catch {}
|
|
1063
|
+
if (!hasOrigin) {
|
|
1064
|
+
return { ok: false, error: "no remote configured — nothing to sync to" }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], {
|
|
1069
|
+
env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
|
|
1070
|
+
})
|
|
1071
|
+
} catch (e: any) {
|
|
1072
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1073
|
+
return { ok: false, error: `push failed: ${stderr || e?.message || e}` }
|
|
1074
|
+
}
|
|
1075
|
+
return { ok: true }
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* ff-only sync core — the loop-outside (no-AI) rule from docs/context-flow.md:
|
|
1080
|
+
* rebase a checkout's local commits onto origin/<branch>. A clean rebase means
|
|
1081
|
+
* local is now origin + local commits, linear, ready to ff-push. A real
|
|
1082
|
+
* same-spot conflict is *held back*: we abort (local commits preserved —
|
|
1083
|
+
* nothing is lost) and report the files so the caller can surface the choice
|
|
1084
|
+
* (discard local / take remote / resolve in a loop). Never a blind merge.
|
|
1085
|
+
*/
|
|
1086
|
+
async function rebaseOntoOrigin(
|
|
1087
|
+
dir: string,
|
|
1088
|
+
branch: string,
|
|
1089
|
+
sshCommand?: string,
|
|
1090
|
+
): Promise<{ ok: true } | { ok: false; error: string } | { ok: false; conflict: true; files: string[] }> {
|
|
1091
|
+
const fetchEnv: Record<string, string> = { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
1092
|
+
if (sshCommand) fetchEnv.GIT_SSH_COMMAND = sshCommand
|
|
1093
|
+
try {
|
|
1094
|
+
await execFileP("git", ["-C", dir, "fetch", "origin"], { env: fetchEnv, timeout: 30_000 })
|
|
1095
|
+
} catch (e: any) {
|
|
1096
|
+
return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1097
|
+
}
|
|
1098
|
+
// No upstream branch yet (empty remote) → nothing to rebase onto.
|
|
1099
|
+
try {
|
|
1100
|
+
await execFileP("git", ["-C", dir, "rev-parse", "--verify", "--quiet", `origin/${branch}`])
|
|
1101
|
+
} catch {
|
|
1102
|
+
return { ok: true }
|
|
1103
|
+
}
|
|
1104
|
+
try { await execFileP("git", ["-C", dir, "rebase", "--abort"]) } catch {}
|
|
1105
|
+
try {
|
|
1106
|
+
await execFileP("git", ["-C", dir, "rebase", `origin/${branch}`], {
|
|
1107
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
1108
|
+
})
|
|
1109
|
+
return { ok: true }
|
|
1110
|
+
} catch (e: any) {
|
|
1111
|
+
const stderr = (e?.stderr ?? "").toString()
|
|
1112
|
+
let files: string[] = []
|
|
1113
|
+
try {
|
|
1114
|
+
const { stdout } = await execFileP("git", ["-C", dir, "diff", "--name-only", "--diff-filter=U"])
|
|
1115
|
+
files = stdout.split("\n").filter((l) => l.trim())
|
|
1116
|
+
} catch {}
|
|
1117
|
+
try { await execFileP("git", ["-C", dir, "rebase", "--abort"]) } catch {}
|
|
1118
|
+
if (files.length > 0 || /CONFLICT/.test(stderr)) return { ok: false, conflict: true, files }
|
|
1119
|
+
return { ok: false, error: `rebase failed: ${stderr || e?.message || e}` }
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Stage + commit local changes. Preserves the repo's existing author (set at
|
|
1125
|
+
* import from the platform identity — some hosts reject non-corporate emails);
|
|
1126
|
+
* only falls back to a local identity if none is configured.
|
|
1127
|
+
*/
|
|
1128
|
+
async function commitLocalChanges(
|
|
1129
|
+
dir: string,
|
|
1130
|
+
message: string,
|
|
1131
|
+
): Promise<{ ok: true; committed: boolean } | { ok: false; error: string }> {
|
|
1132
|
+
try {
|
|
1133
|
+
try { await execFileP("git", ["-C", dir, "config", "user.email"]) }
|
|
1134
|
+
catch { await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"]) }
|
|
1135
|
+
try { await execFileP("git", ["-C", dir, "config", "user.name"]) }
|
|
1136
|
+
catch { await execFileP("git", ["-C", dir, "config", "user.name", "loopat"]) }
|
|
1137
|
+
await execFileP("git", ["-C", dir, "add", "-A"])
|
|
1138
|
+
} catch (e: any) {
|
|
1139
|
+
return { ok: false, error: `git add failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1140
|
+
}
|
|
1141
|
+
let staged = false
|
|
1142
|
+
try { await execFileP("git", ["-C", dir, "diff", "--cached", "--quiet"]) } catch { staged = true }
|
|
1143
|
+
if (!staged) return { ok: true, committed: false }
|
|
1144
|
+
try {
|
|
1145
|
+
await execFileP("git", ["-C", dir, "commit", "-m", message])
|
|
1146
|
+
} catch (e: any) {
|
|
1147
|
+
return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1148
|
+
}
|
|
1149
|
+
return { ok: true, committed: true }
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Pull = align this checkout to origin (the SoT). Commits local edits, rebases
|
|
1154
|
+
* them onto origin/<branch> (held back on real conflict). With `force`, discards
|
|
1155
|
+
* local entirely and takes the remote — the "take remote" escape hatch.
|
|
1156
|
+
*/
|
|
1157
|
+
export type PersonalPullResult =
|
|
1158
|
+
| { ok: true; message: string }
|
|
1159
|
+
| { ok: false; error: string; conflict?: boolean; files?: string[]; needsStash?: boolean }
|
|
1160
|
+
|
|
1161
|
+
export async function pullPersonalFromRemote(
|
|
1162
|
+
userId: string,
|
|
1163
|
+
opts?: { force?: boolean },
|
|
1164
|
+
): Promise<PersonalPullResult> {
|
|
1165
|
+
const force = opts?.force ?? false
|
|
1166
|
+
const dir = personalDir(userId)
|
|
1167
|
+
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1168
|
+
return { ok: false, error: "personal/ is not a git repo" }
|
|
1169
|
+
}
|
|
1170
|
+
let hasOrigin = false
|
|
1171
|
+
try { await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]); hasOrigin = true } catch {}
|
|
1172
|
+
if (!hasOrigin) return { ok: false, error: "no remote configured" }
|
|
1173
|
+
|
|
1174
|
+
let branch = "main"
|
|
1175
|
+
try {
|
|
1176
|
+
const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
|
|
1177
|
+
if (stdout.trim()) branch = stdout.trim()
|
|
1178
|
+
} catch {}
|
|
1179
|
+
|
|
1180
|
+
if (force) {
|
|
1181
|
+
// "Take the remote": discard ALL local state, re-align to origin. Doubles as
|
|
1182
|
+
// the escape hatch for a wedged repo (stuck rebase/merge, dirty index).
|
|
1183
|
+
const silent = { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" }
|
|
1184
|
+
try {
|
|
1185
|
+
try { await execFileP("git", ["-C", dir, "rebase", "--abort"], { env: silent }) } catch {}
|
|
1186
|
+
try { await execFileP("git", ["-C", dir, "merge", "--abort"], { env: silent }) } catch {}
|
|
1187
|
+
await execFileP("git", ["-C", dir, "fetch", "origin"], {
|
|
1188
|
+
env: { ...silent, GIT_SSH_COMMAND: sshCommandForUser(userId) }, timeout: 30_000,
|
|
1189
|
+
})
|
|
1190
|
+
await execFileP("git", ["-C", dir, "reset", "--hard", `origin/${branch}`], { env: silent })
|
|
1191
|
+
await execFileP("git", ["-C", dir, "clean", "-fd"], { env: silent })
|
|
1192
|
+
return { ok: true, message: `reset to origin/${branch}` }
|
|
1193
|
+
} catch (e: any) {
|
|
1194
|
+
return { ok: false, error: `force pull failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Normal pull: commit local edits so the tree is clean, then rebase onto origin.
|
|
1199
|
+
const c = await commitLocalChanges(dir, "loopat: local personal edits")
|
|
1200
|
+
if (!c.ok) return { ok: false, error: c.error }
|
|
1201
|
+
const reb = await rebaseOntoOrigin(dir, branch, sshCommandForUser(userId))
|
|
1202
|
+
if (!reb.ok) {
|
|
1203
|
+
if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
|
|
1204
|
+
return { ok: false, error: reb.error }
|
|
1205
|
+
}
|
|
1206
|
+
return { ok: true, message: `aligned to origin/${branch}` }
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Push = land this checkout on origin (the SoT). Commits local edits, rebases
|
|
1211
|
+
* onto origin/<branch> (held back on a real conflict — never a blind merge),
|
|
1212
|
+
* then ff-pushes. Outside a loop there's no AI, so a conflict is surfaced
|
|
1213
|
+
* (`conflict` + `files`), not swallowed.
|
|
1214
|
+
*/
|
|
1215
|
+
export type PersonalPushResult =
|
|
1216
|
+
| { ok: true; message: string }
|
|
1217
|
+
| { ok: false; error: string; conflict?: boolean; files?: string[]; needsPull?: boolean }
|
|
1218
|
+
|
|
1219
|
+
export async function pushPersonalToRemote(
|
|
1220
|
+
userId: string,
|
|
1221
|
+
): Promise<PersonalPushResult> {
|
|
1222
|
+
const dir = personalDir(userId)
|
|
1223
|
+
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1224
|
+
return { ok: false, error: "personal/ is not a git repo" }
|
|
1225
|
+
}
|
|
1226
|
+
let hasOrigin = false
|
|
1227
|
+
try { await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]); hasOrigin = true } catch {}
|
|
1228
|
+
if (!hasOrigin) return { ok: false, error: "no remote configured" }
|
|
1229
|
+
|
|
1230
|
+
let branch = "main"
|
|
1231
|
+
try {
|
|
1232
|
+
const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
|
|
1233
|
+
if (stdout.trim()) branch = stdout.trim()
|
|
1234
|
+
} catch {}
|
|
1235
|
+
|
|
1236
|
+
const c = await commitLocalChanges(dir, "loopat: sync personal vault")
|
|
1237
|
+
if (!c.ok) return { ok: false, error: c.error }
|
|
1238
|
+
const reb = await rebaseOntoOrigin(dir, branch, sshCommandForUser(userId))
|
|
1239
|
+
if (!reb.ok) {
|
|
1240
|
+
if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
|
|
1241
|
+
return { ok: false, error: reb.error }
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], {
|
|
1245
|
+
env: { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) },
|
|
1246
|
+
})
|
|
1247
|
+
} catch (e: any) {
|
|
1248
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1249
|
+
// We just rebased onto origin, so a rejection means the remote moved again
|
|
1250
|
+
// between rebase and push (rare) — caller can simply retry.
|
|
1251
|
+
return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
|
|
1252
|
+
}
|
|
1253
|
+
return { ok: true, message: c.committed ? "committed and pushed" : "pushed" }
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* UI-loop notes worktree: a per-user checkout of notes, opened from origin/main,
|
|
1258
|
+
* for editing team notes outside any AI loop (the no-AI "UI loop"). Disposable —
|
|
1259
|
+
* rebuilt from origin if missing.
|
|
1260
|
+
*/
|
|
1261
|
+
export async function ensureUiNotesWorktree(user: string): Promise<void> {
|
|
1262
|
+
await ensureContextWorktree(workspaceNotesDir(), uiNotesDir(user), `ui/${user}`)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Save = land this user's notes edits on origin/main (the SoT). Commits, rebases
|
|
1267
|
+
* onto origin/main (held back on a real conflict), ff-pushes HEAD:main. notes
|
|
1268
|
+
* uses the host's default git auth (team origin), not a personal deploy key.
|
|
1269
|
+
*/
|
|
1270
|
+
export async function syncUiNotes(user: string): Promise<PersonalPushResult> {
|
|
1271
|
+
const dir = uiNotesDir(user)
|
|
1272
|
+
await ensureUiNotesWorktree(user)
|
|
1273
|
+
const c = await commitLocalChanges(dir, "loopat: edit notes")
|
|
1274
|
+
if (!c.ok) return { ok: false, error: c.error }
|
|
1275
|
+
const reb = await rebaseOntoOrigin(dir, "main")
|
|
1276
|
+
if (!reb.ok) {
|
|
1277
|
+
if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
|
|
1278
|
+
return { ok: false, error: reb.error }
|
|
1279
|
+
}
|
|
1280
|
+
try {
|
|
1281
|
+
await execFileP("git", ["-C", dir, "push", "origin", "HEAD:main"])
|
|
1282
|
+
} catch (e: any) {
|
|
1283
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1284
|
+
return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
|
|
1285
|
+
}
|
|
1286
|
+
return { ok: true, message: c.committed ? "saved & pushed" : "pushed" }
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ── Generic repo sync (knowledge / notes / repos) ─────────────────────
|
|
1290
|
+
//
|
|
1291
|
+
// Distinct from personal sync above: these workspace-level repos use the
|
|
1292
|
+
// host's default SSH config (whatever the server clone used at boot), NOT
|
|
1293
|
+
// a per-user deploy key. Strict ff-only on both directions — by design no
|
|
1294
|
+
// one edits these outside of loopat, so divergence is treated as an error
|
|
1295
|
+
// to investigate, not auto-resolved.
|
|
1296
|
+
|
|
1297
|
+
export type RepoSyncStatus = {
|
|
1298
|
+
isGitRepo: boolean
|
|
1299
|
+
hasRemote: boolean
|
|
1300
|
+
branch: string
|
|
1301
|
+
ahead: number
|
|
1302
|
+
behind: number
|
|
1303
|
+
uncommitted: number
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export type RepoSyncResult =
|
|
1307
|
+
| { ok: true; message: string }
|
|
1308
|
+
| { ok: false; error: string }
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Best-effort fetch then count ahead/behind vs origin/<branch>. Fetch
|
|
1312
|
+
* failures are tolerated (offline / auth glitch) — status still reflects
|
|
1313
|
+
* last-known remote state.
|
|
1314
|
+
*/
|
|
1315
|
+
export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
|
|
1316
|
+
if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) {
|
|
1317
|
+
return { isGitRepo: false, hasRemote: false, branch: "", ahead: 0, behind: 0, uncommitted: 0 }
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
let branch = ""
|
|
1321
|
+
try {
|
|
1322
|
+
const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
|
|
1323
|
+
branch = stdout.trim()
|
|
1324
|
+
} catch {}
|
|
1325
|
+
|
|
1326
|
+
let hasRemote = false
|
|
1327
|
+
try {
|
|
1328
|
+
await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
|
|
1329
|
+
hasRemote = true
|
|
1330
|
+
} catch {}
|
|
1331
|
+
|
|
1332
|
+
if (hasRemote) {
|
|
1333
|
+
try {
|
|
1334
|
+
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { timeout: 15_000 })
|
|
1335
|
+
} catch {}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
let uncommitted = 0
|
|
1339
|
+
try {
|
|
1340
|
+
const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"])
|
|
1341
|
+
uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length
|
|
1342
|
+
} catch {}
|
|
1343
|
+
|
|
1344
|
+
let ahead = 0
|
|
1345
|
+
let behind = 0
|
|
1346
|
+
if (hasRemote && branch) {
|
|
1347
|
+
try {
|
|
1348
|
+
const { stdout } = await execFileP("git", [
|
|
1349
|
+
"-C", dir, "rev-list", "--left-right", "--count", `origin/${branch}...${branch}`,
|
|
1350
|
+
])
|
|
1351
|
+
const m = stdout.trim().match(/^(\d+)\s+(\d+)$/)
|
|
1352
|
+
if (m) { behind = parseInt(m[1], 10); ahead = parseInt(m[2], 10) }
|
|
1353
|
+
} catch {}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return { isGitRepo: true, hasRemote, branch, ahead, behind, uncommitted }
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Fetch + ff-only merge into the current HEAD. Aborts on uncommitted
|
|
1361
|
+
* changes (we don't auto-stash workspace repos — caller decides) and on
|
|
1362
|
+
* any non-ff condition.
|
|
1363
|
+
*/
|
|
1364
|
+
export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
|
|
1365
|
+
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1366
|
+
return { ok: false, error: "not a git repo" }
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
let hasRemote = false
|
|
1370
|
+
try {
|
|
1371
|
+
await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
|
|
1372
|
+
hasRemote = true
|
|
1373
|
+
} catch {}
|
|
1374
|
+
if (!hasRemote) return { ok: false, error: "no remote configured" }
|
|
1375
|
+
|
|
1376
|
+
let branch = ""
|
|
1377
|
+
try {
|
|
1378
|
+
const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
|
|
1379
|
+
branch = stdout.trim()
|
|
1380
|
+
} catch {}
|
|
1381
|
+
if (!branch) return { ok: false, error: "HEAD is detached" }
|
|
1382
|
+
|
|
1383
|
+
let uncommitted = 0
|
|
1384
|
+
try {
|
|
1385
|
+
const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"])
|
|
1386
|
+
uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length
|
|
1387
|
+
} catch {}
|
|
1388
|
+
if (uncommitted > 0) {
|
|
1389
|
+
return { ok: false, error: `aborted: ${uncommitted} uncommitted change(s) in primary` }
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
try {
|
|
1393
|
+
await execFileP("git", ["-C", dir, "fetch", "origin"], { timeout: 30_000 })
|
|
1394
|
+
} catch (e: any) {
|
|
1395
|
+
return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
try {
|
|
1399
|
+
await execFileP("git", ["-C", dir, "merge", "--ff-only", `origin/${branch}`])
|
|
1400
|
+
} catch (e: any) {
|
|
1401
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1402
|
+
return { ok: false, error: `merge --ff-only failed (diverged from origin/${branch}?): ${stderr || e?.message || e}` }
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return { ok: true, message: `pulled origin/${branch}` }
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Push current HEAD branch to origin. Plain `git push` — git refuses
|
|
1410
|
+
* non-ff by default, which is exactly the abort-on-conflict behavior we
|
|
1411
|
+
* want. Caller pulls first if rejected.
|
|
1412
|
+
*/
|
|
1413
|
+
export async function pushRepoToRemote(dir: string): Promise<RepoSyncResult> {
|
|
1414
|
+
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1415
|
+
return { ok: false, error: "not a git repo" }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
let hasRemote = false
|
|
1419
|
+
try {
|
|
1420
|
+
await execFileP("git", ["-C", dir, "remote", "get-url", "origin"])
|
|
1421
|
+
hasRemote = true
|
|
1422
|
+
} catch {}
|
|
1423
|
+
if (!hasRemote) return { ok: false, error: "no remote configured" }
|
|
1424
|
+
|
|
1425
|
+
let branch = ""
|
|
1426
|
+
try {
|
|
1427
|
+
const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
|
|
1428
|
+
branch = stdout.trim()
|
|
1429
|
+
} catch {}
|
|
1430
|
+
if (!branch) return { ok: false, error: "HEAD is detached" }
|
|
1431
|
+
|
|
1432
|
+
try {
|
|
1433
|
+
await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`])
|
|
1434
|
+
} catch (e: any) {
|
|
1435
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1436
|
+
return { ok: false, error: `push failed: ${stderr || e?.message || e}` }
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
return { ok: true, message: `pushed to origin/${branch}` }
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Wipe personal/<user>/ AND the saved git-crypt key. Deploy keypair stays
|
|
1444
|
+
* (it's the SSH identity, reusable for the next import). Re-scaffolds an
|
|
1445
|
+
* empty git-init'd personal/<user>/ so workspace bind paths still resolve.
|
|
1446
|
+
*/
|
|
1447
|
+
export async function deletePersonalVault(userId: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
1448
|
+
const dir = personalDir(userId)
|
|
1449
|
+
try {
|
|
1450
|
+
await rm(dir, { recursive: true, force: true })
|
|
1451
|
+
} catch (e: any) {
|
|
1452
|
+
return { ok: false, error: `rm personal/ failed: ${e?.message ?? e}` }
|
|
1453
|
+
}
|
|
1454
|
+
const { rm: rmFile } = await import("node:fs/promises")
|
|
1455
|
+
await rmFile(personalGitCryptKeyPath(userId), { force: true }).catch(() => {})
|
|
1456
|
+
|
|
1457
|
+
// Re-scaffold empty so the workspace doesn't have a hole. Mirrors
|
|
1458
|
+
// provisionUserPersonal but without re-running deploy-key gen.
|
|
1459
|
+
try {
|
|
1460
|
+
await mkdir(dir, { recursive: true })
|
|
1461
|
+
const pm = personalMemoryDir(userId)
|
|
1462
|
+
await mkdir(pm, { recursive: true })
|
|
1463
|
+
const pmIdx = `${pm}/MEMORY.md`
|
|
1464
|
+
if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB)
|
|
1465
|
+
await gitInitIfMissing(dir)
|
|
1466
|
+
} catch (e: any) {
|
|
1467
|
+
return { ok: false, error: `re-scaffold failed: ${e?.message ?? e}` }
|
|
1468
|
+
}
|
|
1469
|
+
return { ok: true }
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// git-crypt's per-file magic header (10 bytes): \x00 G I T C R Y P T \x00
|
|
1473
|
+
const GIT_CRYPT_MAGIC = Buffer.from([0x00, 0x47, 0x49, 0x54, 0x43, 0x52, 0x59, 0x50, 0x54, 0x00])
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Returns tracked files under `.loopat/vaults/**` that are stored as
|
|
1477
|
+
* plaintext (i.e., the worktree blob doesn't start with the git-crypt magic
|
|
1478
|
+
* header). Reads the worktree directly: in a fresh clone where git-crypt
|
|
1479
|
+
* isn't unlocked, the worktree contents ARE the raw blobs, so non-encrypted
|
|
1480
|
+
* files are visibly plaintext here.
|
|
1481
|
+
*/
|
|
1482
|
+
async function detectExposedSecrets(repoDir: string): Promise<string[]> {
|
|
1483
|
+
// Anything under `.loopat/vaults/` stored as plaintext is an exposure and
|
|
1484
|
+
// refuses import.
|
|
1485
|
+
//
|
|
1486
|
+
// Symlinks are skipped: git stores a symlink's target as the blob, and
|
|
1487
|
+
// git-crypt's filter doesn't (and can't) encrypt that. The target path
|
|
1488
|
+
// itself isn't a secret value — and walkVaultFiles refuses to bind any
|
|
1489
|
+
// symlink whose realpath escapes personal/<user>/.
|
|
1490
|
+
const exposed: string[] = []
|
|
1491
|
+
let stdout = ""
|
|
1492
|
+
try {
|
|
1493
|
+
const r = await execFileP("git", ["-C", repoDir, "ls-files", "-z", ".loopat/vaults"])
|
|
1494
|
+
stdout = r.stdout
|
|
1495
|
+
} catch {
|
|
1496
|
+
return exposed
|
|
1497
|
+
}
|
|
1498
|
+
const files = stdout.split("\0").filter(Boolean)
|
|
1499
|
+
for (const f of files) {
|
|
1500
|
+
if (f.endsWith("/.gitkeep")) continue
|
|
1501
|
+
try {
|
|
1502
|
+
const lst = await lstat(join(repoDir, f))
|
|
1503
|
+
if (lst.isSymbolicLink()) continue
|
|
1504
|
+
const buf = await readFile(join(repoDir, f))
|
|
1505
|
+
if (buf.length === 0) continue
|
|
1506
|
+
if (!buf.subarray(0, GIT_CRYPT_MAGIC.length).equals(GIT_CRYPT_MAGIC)) {
|
|
1507
|
+
exposed.push(f)
|
|
1508
|
+
}
|
|
1509
|
+
} catch {
|
|
1510
|
+
// unreadable — skip
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return exposed
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function detectGitCryptEnabled(repoDir: string): Promise<boolean> {
|
|
1517
|
+
try {
|
|
1518
|
+
const { stdout } = await execFileP("git", ["-C", repoDir, "ls-files", "-z"])
|
|
1519
|
+
const files = stdout.split("\0").filter((f) => f.endsWith(".gitattributes"))
|
|
1520
|
+
for (const f of files) {
|
|
1521
|
+
try {
|
|
1522
|
+
const content = await readFile(join(repoDir, f), "utf8")
|
|
1523
|
+
if (/filter=git-crypt/.test(content)) return true
|
|
1524
|
+
} catch {}
|
|
1525
|
+
}
|
|
1526
|
+
return false
|
|
1527
|
+
} catch {
|
|
1528
|
+
return false
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Persist cryptKey (base64) to host-secrets/<user>/git-crypt.key and run
|
|
1534
|
+
* `git-crypt unlock` against the cloned repo. On failure, removes the saved
|
|
1535
|
+
* keyfile so a retry can paste a different key.
|
|
1536
|
+
*/
|
|
1537
|
+
async function unlockWithCryptKey(
|
|
1538
|
+
repoDir: string,
|
|
1539
|
+
userId: string,
|
|
1540
|
+
cryptKeyB64: string,
|
|
1541
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
1542
|
+
const { saveGitCryptKey, gitCryptKeyExists } = await import("./git-crypt-key")
|
|
1543
|
+
try {
|
|
1544
|
+
const keyBuf = Buffer.from(cryptKeyB64.trim(), "base64")
|
|
1545
|
+
if (keyBuf.length < 32) {
|
|
1546
|
+
return { ok: false, error: "invalid git-crypt key (too short — must be base64-encoded export-key output)" }
|
|
1547
|
+
}
|
|
1548
|
+
await saveGitCryptKey(userId, keyBuf)
|
|
1549
|
+
} catch (e: any) {
|
|
1550
|
+
return { ok: false, error: `failed to save git-crypt key: ${e?.message ?? e}` }
|
|
1551
|
+
}
|
|
1552
|
+
const keyPath = personalGitCryptKeyPath(userId)
|
|
1553
|
+
try {
|
|
1554
|
+
await execFileP("git-crypt", ["unlock", keyPath], { cwd: repoDir })
|
|
1555
|
+
return { ok: true }
|
|
1556
|
+
} catch (e: any) {
|
|
1557
|
+
if (await gitCryptKeyExists(userId)) {
|
|
1558
|
+
const { rm: rmFile } = await import("node:fs/promises")
|
|
1559
|
+
await rmFile(keyPath, { force: true }).catch(() => {})
|
|
1560
|
+
}
|
|
1561
|
+
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1562
|
+
if (/not the file you generated/i.test(stderr) || /Invalid key file/i.test(stderr)) {
|
|
1563
|
+
return { ok: false, error: "git-crypt unlock failed: wrong key (HMAC mismatch)" }
|
|
1564
|
+
}
|
|
1565
|
+
if (/command not found/i.test(stderr) || e?.code === "ENOENT") {
|
|
1566
|
+
return { ok: false, error: "git-crypt not installed on host (apt install git-crypt)" }
|
|
1567
|
+
}
|
|
1568
|
+
return { ok: false, error: `git-crypt unlock failed: ${stderr || e?.message || e}` }
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
async function ensureSymlink(link: string, target: string) {
|
|
1573
|
+
try {
|
|
1574
|
+
await lstat(link)
|
|
1575
|
+
} catch {
|
|
1576
|
+
await symlink(target, link, "dir")
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Idempotently materialize a per-loop git worktree of `repo` at `path` on
|
|
1582
|
+
* branch `branchName`. If the path already holds a worktree, no-op. If the
|
|
1583
|
+
* source isn't a git repo (e.g., knowledge without a remote), fall back to
|
|
1584
|
+
* a symlink so the path still resolves — those loops can't publish, but
|
|
1585
|
+
* read access still works.
|
|
1586
|
+
*/
|
|
1587
|
+
async function ensureContextWorktree(repo: string, path: string, branchName: string) {
|
|
1588
|
+
let stats: Awaited<ReturnType<typeof lstat>> | null = null
|
|
1589
|
+
try { stats = await lstat(path) } catch {}
|
|
1590
|
+
// Real dir with .git → already a worktree, leave it alone.
|
|
1591
|
+
if (stats?.isDirectory() && existsSyncBase(join(path, ".git"))) return
|
|
1592
|
+
|
|
1593
|
+
// Source isn't a git repo — fall back to symlink (legacy shape).
|
|
1594
|
+
if (!existsSyncBase(join(repo, ".git"))) {
|
|
1595
|
+
try { await rm(path, { recursive: true, force: true }) } catch {}
|
|
1596
|
+
await ensureSymlink(path, repo)
|
|
1597
|
+
return
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Stale state (old symlink, empty dir, leftover from manual cleanup) → wipe + create.
|
|
1601
|
+
try { await rm(path, { recursive: true, force: true }) } catch {}
|
|
1602
|
+
// ① pull (docs/context-flow.md): open the worktree from origin/main so the
|
|
1603
|
+
// loop starts from latest consensus, not a possibly-stale local HEAD.
|
|
1604
|
+
const start = await remoteStartPoint(repo)
|
|
1605
|
+
const args = ["-C", repo, "worktree", "add", "-b", branchName, path]
|
|
1606
|
+
if (start) args.push(start)
|
|
1607
|
+
await execFileP("git", args)
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* ① pull, per docs/context-flow.md: a loop starts from consensus. Best-effort
|
|
1612
|
+
* fetch origin, then return `origin/main` as the worktree start-point so the
|
|
1613
|
+
* loop opens from the latest shared state. Returns null to fall back to local
|
|
1614
|
+
* HEAD (solo / offline / no remote / no origin/main yet).
|
|
1615
|
+
*/
|
|
1616
|
+
async function remoteStartPoint(repo: string): Promise<string | null> {
|
|
1617
|
+
try {
|
|
1618
|
+
await execFileP("git", ["-C", repo, "remote", "get-url", "origin"])
|
|
1619
|
+
} catch {
|
|
1620
|
+
return null
|
|
1621
|
+
}
|
|
1622
|
+
try {
|
|
1623
|
+
await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { timeout: 15_000 })
|
|
1624
|
+
} catch {}
|
|
1625
|
+
try {
|
|
1626
|
+
await execFileP("git", ["-C", repo, "rev-parse", "--verify", "--quiet", "origin/main^{commit}"])
|
|
1627
|
+
return "origin/main"
|
|
1628
|
+
} catch {
|
|
1629
|
+
return null
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
export async function ensureContextMounts(id: string, createdBy: string) {
|
|
1634
|
+
await mkdir(loopContextDir(id), { recursive: true })
|
|
1635
|
+
await ensureContextWorktree(workspaceKnowledgeDir(), loopContextKnowledge(id), `loop/${id}`)
|
|
1636
|
+
await ensureContextWorktree(workspaceNotesDir(), loopContextNotes(id), `loop/${id}`)
|
|
1637
|
+
// personal is also a per-loop worktree (docs/context-flow.md) — same shape as
|
|
1638
|
+
// notes, just wired to the user's private remote. ensureContextWorktree falls
|
|
1639
|
+
// back to a symlink when personal/ isn't a git repo yet.
|
|
1640
|
+
await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`)
|
|
1641
|
+
await ensureSymlink(loopContextRepos(id), workspaceReposDir())
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
export async function listLoops(): Promise<LoopMeta[]> {
|
|
1645
|
+
try {
|
|
1646
|
+
const ids = await readdir(loopsDir())
|
|
1647
|
+
const metas = await Promise.all(
|
|
1648
|
+
ids.map(async (id) => {
|
|
1649
|
+
try {
|
|
1650
|
+
const raw = await readFile(loopMetaPath(id), "utf8")
|
|
1651
|
+
return JSON.parse(raw) as LoopMeta
|
|
1652
|
+
} catch {
|
|
1653
|
+
return null
|
|
1654
|
+
}
|
|
1655
|
+
})
|
|
1656
|
+
)
|
|
1657
|
+
return metas.filter((m): m is LoopMeta => m !== null).sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
1658
|
+
} catch (e: any) {
|
|
1659
|
+
if (e?.code === "ENOENT") return []
|
|
1660
|
+
throw e
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// refreshLoopSandbox removed entirely — profile model re-composes every spawn.
|
|
1665
|
+
|
|
1666
|
+
async function shortBranchSlug(title: string): Promise<string> {
|
|
1667
|
+
const base = title
|
|
1668
|
+
.toLowerCase()
|
|
1669
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1670
|
+
.replace(/^-+|-+$/g, "")
|
|
1671
|
+
.slice(0, 32)
|
|
1672
|
+
return base || "loop"
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
export async function createLoop(opts: {
|
|
1676
|
+
title: string
|
|
1677
|
+
repo?: string
|
|
1678
|
+
createdBy: string
|
|
1679
|
+
profiles?: string[]
|
|
1680
|
+
vault?: string
|
|
1681
|
+
knowledgeRw?: boolean
|
|
1682
|
+
mountAllLoops?: boolean
|
|
1683
|
+
}): Promise<LoopMeta> {
|
|
1684
|
+
await ensureWorkspaceDirs()
|
|
1685
|
+
const id = randomUUID()
|
|
1686
|
+
const createdAt = new Date().toISOString()
|
|
1687
|
+
const meta: LoopMeta = {
|
|
1688
|
+
id,
|
|
1689
|
+
title: opts.title.trim() || "untitled",
|
|
1690
|
+
createdAt,
|
|
1691
|
+
createdBy: opts.createdBy,
|
|
1692
|
+
driver: opts.createdBy,
|
|
1693
|
+
driverHistory: [{ driver: opts.createdBy, since: createdAt }],
|
|
1694
|
+
}
|
|
1695
|
+
if (opts.profiles && opts.profiles.length > 0) {
|
|
1696
|
+
meta.config = { ...(meta.config ?? {}), profiles: opts.profiles }
|
|
1697
|
+
}
|
|
1698
|
+
if (opts.vault && opts.vault !== "default") {
|
|
1699
|
+
meta.config = { ...(meta.config ?? {}), vault: opts.vault }
|
|
1700
|
+
}
|
|
1701
|
+
if (opts.knowledgeRw) {
|
|
1702
|
+
meta.config = { ...(meta.config ?? {}), knowledge_rw: true }
|
|
1703
|
+
}
|
|
1704
|
+
if (opts.mountAllLoops) {
|
|
1705
|
+
meta.config = { ...(meta.config ?? {}), mount_all_loops: true }
|
|
1706
|
+
}
|
|
1707
|
+
await mkdir(loopDir(id), { recursive: true })
|
|
1708
|
+
await mkdir(loopClaudeDir(id), { recursive: true })
|
|
1709
|
+
// Compose skills/agents + profile-chain doctrine into .claude/, write
|
|
1710
|
+
// settings.json (autoMemory). Plugin resolution happens at spawn time
|
|
1711
|
+
// (see session.ts) — SDK loads plugins via its `plugins` option, no
|
|
1712
|
+
// loop-local install state needed.
|
|
1713
|
+
await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles)
|
|
1714
|
+
await writeLoopSettings(id)
|
|
1715
|
+
|
|
1716
|
+
// workdir = git worktree add (if repo selected) OR plain mkdir
|
|
1717
|
+
if (opts.repo) {
|
|
1718
|
+
// clone-on-demand: pull the repo down only now that a loop actually needs it
|
|
1719
|
+
if (!(await ensureRepoCloned(opts.repo))) {
|
|
1720
|
+
throw new Error(`repo "${opts.repo}" not found / clone failed`)
|
|
1721
|
+
}
|
|
1722
|
+
const repoPath = workspaceRepoDir(opts.repo)
|
|
1723
|
+
const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
|
|
1724
|
+
try {
|
|
1725
|
+
// ① pull (docs/context-flow.md): base the workdir branch on origin/main
|
|
1726
|
+
// (best-effort fetch) so it starts from latest consensus; fall back to
|
|
1727
|
+
// local HEAD when there's no remote / no origin/main.
|
|
1728
|
+
const start = await remoteStartPoint(repoPath)
|
|
1729
|
+
const wtArgs = ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)]
|
|
1730
|
+
if (start) wtArgs.push(start)
|
|
1731
|
+
await execFileP("git", wtArgs)
|
|
1732
|
+
meta.repo = opts.repo
|
|
1733
|
+
meta.branch = branch
|
|
1734
|
+
} catch (e: any) {
|
|
1735
|
+
// fallback: plain mkdir (let user know)
|
|
1736
|
+
console.warn(`[loopat] git worktree add failed for repo=${opts.repo}: ${e?.stderr ?? e?.message}`)
|
|
1737
|
+
await mkdir(loopWorkdir(id), { recursive: true })
|
|
1738
|
+
}
|
|
1739
|
+
} else {
|
|
1740
|
+
await mkdir(loopWorkdir(id), { recursive: true })
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
await ensureContextMounts(id, effectiveDriver(meta))
|
|
1744
|
+
await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
|
|
1745
|
+
return meta
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Spawn a child "distill loop" from a source loop. The child's workdir gets
|
|
1750
|
+
* a point-in-time snapshot of the source's conversation files plus a
|
|
1751
|
+
* project-tier CLAUDE.md telling the AI it's a distill loop. Knowledge is
|
|
1752
|
+
* rw so the child can publish sedimented insights. The source is not
|
|
1753
|
+
* touched. Any authenticated user may distill any loop — distill is a
|
|
1754
|
+
* read-only relationship.
|
|
1755
|
+
*/
|
|
1756
|
+
export async function distillLoop(sourceId: string, byUser: string): Promise<LoopMeta> {
|
|
1757
|
+
const source = await getLoop(sourceId)
|
|
1758
|
+
if (!source) throw new Error(`source loop ${sourceId} not found`)
|
|
1759
|
+
|
|
1760
|
+
const shortId = source.id.slice(0, 6)
|
|
1761
|
+
const child = await createLoop({
|
|
1762
|
+
title: `distill: ${shortId} ${source.title}`,
|
|
1763
|
+
createdBy: byUser,
|
|
1764
|
+
knowledgeRw: true,
|
|
1765
|
+
})
|
|
1766
|
+
|
|
1767
|
+
// Snapshot the source's conversation into the child's workdir.
|
|
1768
|
+
const sourceDir = join(loopWorkdir(child.id), "source")
|
|
1769
|
+
await mkdir(sourceDir, { recursive: true })
|
|
1770
|
+
for (const [from, to] of [
|
|
1771
|
+
[loopHistoryPath(sourceId), join(sourceDir, "messages.jsonl")],
|
|
1772
|
+
[loopChatHistoryPath(sourceId), join(sourceDir, "chat_history.jsonl")],
|
|
1773
|
+
]) {
|
|
1774
|
+
if (existsSyncBase(from)) {
|
|
1775
|
+
await copyFile(from, to)
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Drop the distill kind's project-tier CLAUDE.md into the workdir. Claude
|
|
1780
|
+
// Code auto-loads <workdir>/CLAUDE.md (settingSources includes "project").
|
|
1781
|
+
const tmpl = loopKindClaudePath("distill")
|
|
1782
|
+
if (existsSyncBase(tmpl)) {
|
|
1783
|
+
await copyFile(tmpl, join(loopWorkdir(child.id), "CLAUDE.md"))
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
return child
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
export async function getLoop(id: string): Promise<LoopMeta | null> {
|
|
1790
|
+
try {
|
|
1791
|
+
const raw = await readFile(loopMetaPath(id), "utf8")
|
|
1792
|
+
return JSON.parse(raw) as LoopMeta
|
|
1793
|
+
} catch {
|
|
1794
|
+
return null
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
export async function patchLoopMeta(id: string, patch: Partial<LoopMeta>): Promise<LoopMeta | null> {
|
|
1799
|
+
const meta = await getLoop(id)
|
|
1800
|
+
if (!meta) return null
|
|
1801
|
+
const updated = { ...meta, ...patch }
|
|
1802
|
+
await writeFile(loopMetaPath(id), JSON.stringify(updated, null, 2))
|
|
1803
|
+
return updated
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
export async function loopExists(id: string): Promise<boolean> {
|
|
1807
|
+
try {
|
|
1808
|
+
const s = await stat(loopDir(id))
|
|
1809
|
+
return s.isDirectory()
|
|
1810
|
+
} catch {
|
|
1811
|
+
return false
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
export async function backfillAllMounts(): Promise<number> {
|
|
1816
|
+
let count = 0
|
|
1817
|
+
try {
|
|
1818
|
+
const ids = await readdir(loopsDir())
|
|
1819
|
+
for (const id of ids) {
|
|
1820
|
+
try {
|
|
1821
|
+
const meta = await getLoop(id)
|
|
1822
|
+
if (!meta?.createdBy) {
|
|
1823
|
+
console.warn(`[loopat] loop ${id}: meta missing createdBy — skipping mount backfill`)
|
|
1824
|
+
continue
|
|
1825
|
+
}
|
|
1826
|
+
await ensureContextMounts(id, effectiveDriver(meta))
|
|
1827
|
+
count++
|
|
1828
|
+
} catch {}
|
|
1829
|
+
}
|
|
1830
|
+
} catch {}
|
|
1831
|
+
return count
|
|
1832
|
+
}
|