loopat 0.1.3 → 0.1.5
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/README.md +6 -0
- package/package.json +1 -1
- package/server/src/config.ts +10 -0
- package/server/src/index.ts +8 -8
- package/server/src/loops.ts +88 -21
- package/server/src/podman.ts +15 -8
- package/server/src/uninstall.ts +43 -56
- package/web/dist/assets/{CodeEditor-CJIDxH-3.js → CodeEditor-gBJCD2Wh.js} +1 -1
- package/web/dist/assets/{Editor-DsE3Bcm0.js → Editor-Wj_bBB08.js} +1 -1
- package/web/dist/assets/{Markdown-sWG7Jmg1.js → Markdown-DYoMIvMF.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-B9ECu-iU.js → MilkdownEditor-DD0Cub0X.js} +1 -1
- package/web/dist/assets/{Terminal-CcsuXlvr.js → Terminal-BRh-IQYg.js} +1 -1
- package/web/dist/assets/{index-gkdQO9_w.js → index-DdenW-3i.js} +3 -3
- package/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> **Self-hosted AI workspace built around context management — works solo, scales to teams**
|
|
4
4
|
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/loopat"><img src="https://img.shields.io/npm/v/loopat?logo=npm&color=cb3837" alt="npm version"></a>
|
|
7
|
+
<a href="https://github.com/simpx/loopat/pkgs/container/loopat"><img src="https://img.shields.io/badge/ghcr.io-simpx%2Floopat-2496ED?logo=docker&logoColor=white" alt="GHCR image"></a>
|
|
8
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-green" alt="License"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
5
11
|
<p align="center">
|
|
6
12
|
<img src="docs/screenshot.png" alt="loopat — Loop view with chat, workdir, terminal, and team DM" width="100%">
|
|
7
13
|
</p>
|
package/package.json
CHANGED
package/server/src/config.ts
CHANGED
|
@@ -210,6 +210,11 @@ export type PersonalConfigDisk = {
|
|
|
210
210
|
shell?: string
|
|
211
211
|
/** Optional. Missing = "fresh" (user hasn't started or dismissed yet). */
|
|
212
212
|
onboarding?: OnboardingState
|
|
213
|
+
/** Authoritative kn/notes remotes — the personal repo is self-describing.
|
|
214
|
+
* host config.json's knowledge/notes are a display mirror; these are what a
|
|
215
|
+
* loop actually connects to (with the user's vault key). */
|
|
216
|
+
knowledge?: RemoteSpec
|
|
217
|
+
notes?: RemoteSpec
|
|
213
218
|
}
|
|
214
219
|
|
|
215
220
|
export type PersonalConfig = {
|
|
@@ -224,6 +229,9 @@ export type PersonalConfig = {
|
|
|
224
229
|
vaultEnvs: Record<string, string>
|
|
225
230
|
shell?: string
|
|
226
231
|
onboarding?: OnboardingState
|
|
232
|
+
/** Authoritative kn/notes remotes (self-describing personal repo). */
|
|
233
|
+
knowledge?: RemoteSpec
|
|
234
|
+
notes?: RemoteSpec
|
|
227
235
|
}
|
|
228
236
|
|
|
229
237
|
/**
|
|
@@ -443,6 +451,8 @@ export async function loadPersonalConfig(
|
|
|
443
451
|
vaultEnvs,
|
|
444
452
|
...(disk.shell ? { shell: disk.shell } : {}),
|
|
445
453
|
...(disk.onboarding ? { onboarding: disk.onboarding } : {}),
|
|
454
|
+
...(disk.knowledge ? { knowledge: disk.knowledge } : {}),
|
|
455
|
+
...(disk.notes ? { notes: disk.notes } : {}),
|
|
446
456
|
}
|
|
447
457
|
personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
|
|
448
458
|
return cfg
|
package/server/src/index.ts
CHANGED
|
@@ -1416,23 +1416,23 @@ function syncDirFor(resource: string, name?: string): string | null {
|
|
|
1416
1416
|
return null
|
|
1417
1417
|
}
|
|
1418
1418
|
|
|
1419
|
-
app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir())))
|
|
1419
|
+
app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir(), c.get("userId") as string)))
|
|
1420
1420
|
app.post("/api/sync/knowledge/pull", requireAuth, async (c) => {
|
|
1421
|
-
const r = await pullRepoFromRemote(workspaceKnowledgeDir())
|
|
1421
|
+
const r = await pullRepoFromRemote(workspaceKnowledgeDir(), c.get("userId") as string)
|
|
1422
1422
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1423
1423
|
})
|
|
1424
1424
|
app.post("/api/sync/knowledge/push", requireAuth, async (c) => {
|
|
1425
|
-
const r = await pushRepoToRemote(workspaceKnowledgeDir())
|
|
1425
|
+
const r = await pushRepoToRemote(workspaceKnowledgeDir(), c.get("userId") as string)
|
|
1426
1426
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1427
1427
|
})
|
|
1428
1428
|
|
|
1429
|
-
app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir())))
|
|
1429
|
+
app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir(), c.get("userId") as string)))
|
|
1430
1430
|
app.post("/api/sync/notes/pull", requireAuth, async (c) => {
|
|
1431
|
-
const r = await pullRepoFromRemote(workspaceNotesDir())
|
|
1431
|
+
const r = await pullRepoFromRemote(workspaceNotesDir(), c.get("userId") as string)
|
|
1432
1432
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1433
1433
|
})
|
|
1434
1434
|
app.post("/api/sync/notes/push", requireAuth, async (c) => {
|
|
1435
|
-
const r = await pushRepoToRemote(workspaceNotesDir())
|
|
1435
|
+
const r = await pushRepoToRemote(workspaceNotesDir(), c.get("userId") as string)
|
|
1436
1436
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1437
1437
|
})
|
|
1438
1438
|
|
|
@@ -1454,12 +1454,12 @@ app.get("/api/sync/repos", requireAuth, async (c) => {
|
|
|
1454
1454
|
app.get("/api/sync/repos/:name/status", requireAuth, async (c) => {
|
|
1455
1455
|
const dir = syncDirFor("repos", c.req.param("name"))
|
|
1456
1456
|
if (!dir) return c.json({ error: "repo not found" }, 404)
|
|
1457
|
-
return c.json(await inspectRepoSync(dir))
|
|
1457
|
+
return c.json(await inspectRepoSync(dir, c.get("userId") as string))
|
|
1458
1458
|
})
|
|
1459
1459
|
app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => {
|
|
1460
1460
|
const dir = syncDirFor("repos", c.req.param("name"))
|
|
1461
1461
|
if (!dir) return c.json({ error: "repo not found" }, 404)
|
|
1462
|
-
const r = await pullRepoFromRemote(dir)
|
|
1462
|
+
const r = await pullRepoFromRemote(dir, c.get("userId") as string)
|
|
1463
1463
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1464
1464
|
})
|
|
1465
1465
|
|
package/server/src/loops.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
workspaceOriginsDir,
|
|
25
25
|
workspaceOriginPath,
|
|
26
26
|
personalDir,
|
|
27
|
+
personalVaultDir,
|
|
27
28
|
uiNotesDir,
|
|
28
29
|
personalMemoryDir,
|
|
29
30
|
workspaceMemoryDir,
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
} from "./paths"
|
|
36
37
|
import type { RepoSpec } from "./config"
|
|
37
38
|
import { existsSync as existsSyncBase } from "node:fs"
|
|
38
|
-
import { loadConfig } from "./config"
|
|
39
|
+
import { loadConfig, loadPersonalConfig } from "./config"
|
|
39
40
|
import { ensurePersonalKeypair } from "./personal-keys"
|
|
40
41
|
import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
|
|
41
42
|
import { getProvider } from "./git-host"
|
|
@@ -316,6 +317,39 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
|
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The personal repo is self-describing: its `.loopat/config.json` declares the
|
|
322
|
+
* authoritative kn/notes remotes, and a loop connects to them with the user's
|
|
323
|
+
* OWN key from the selected vault (`vaults/<vault>/mounts/home/.ssh/id`), not
|
|
324
|
+
* the host's ssh. Called at loop creation, which has the user + vault in hand.
|
|
325
|
+
*
|
|
326
|
+
* The startup clone (driven by host config.json) stays as a display mirror;
|
|
327
|
+
* here the personal-declared url wins and becomes the context repo's origin.
|
|
328
|
+
*
|
|
329
|
+
* We only set the origin + fetch with the vault key — we deliberately do NOT
|
|
330
|
+
* persist a `core.sshCommand`: the same `.git` is mounted into the sandbox,
|
|
331
|
+
* where that host path is invalid. The sandbox's own git already authenticates
|
|
332
|
+
* with the vault key via `$HOME/.ssh` (the vault home-mount), so its promote
|
|
333
|
+
* pushes as the user without any per-repo config.
|
|
334
|
+
*/
|
|
335
|
+
export async function ensureUserContext(user: string, vault: string = "default"): Promise<void> {
|
|
336
|
+
const cfg = await loadPersonalConfig(user, vault)
|
|
337
|
+
const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) }
|
|
338
|
+
const layers: Array<[string, string | undefined]> = [
|
|
339
|
+
[workspaceKnowledgeDir(), cfg.knowledge?.git],
|
|
340
|
+
[workspaceNotesDir(), cfg.notes?.git],
|
|
341
|
+
]
|
|
342
|
+
for (const [dir, url] of layers) {
|
|
343
|
+
if (!url) continue // personal didn't declare one → keep the host/local origin
|
|
344
|
+
if (!existsSyncBase(join(dir, ".git"))) continue // not initialized yet (startup hasn't run)
|
|
345
|
+
const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
|
|
346
|
+
await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
|
|
347
|
+
// validate connectivity + populate origin/* with the user's key (transient,
|
|
348
|
+
// server-side only).
|
|
349
|
+
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 20_000 }).catch(() => {})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
319
353
|
/**
|
|
320
354
|
* Repos are clone-on-demand — they can be large, so we don't pre-clone the
|
|
321
355
|
* whole set. Instead write a manifest (REPOS.md) listing the full roster, and
|
|
@@ -340,7 +374,7 @@ async function writeReposManifest(specs: RepoSpec[]) {
|
|
|
340
374
|
* Clone a single registered repo if it isn't present yet. Returns whether the
|
|
341
375
|
* repo dir exists afterwards. Used by loop creation and any on-demand path.
|
|
342
376
|
*/
|
|
343
|
-
async function ensureRepoCloned(name: string): Promise<boolean> {
|
|
377
|
+
async function ensureRepoCloned(name: string, sshCommand?: string): Promise<boolean> {
|
|
344
378
|
const dir = workspaceRepoDir(name)
|
|
345
379
|
if (existsSyncBase(dir)) return true
|
|
346
380
|
const cfg = await loadConfig()
|
|
@@ -348,7 +382,8 @@ async function ensureRepoCloned(name: string): Promise<boolean> {
|
|
|
348
382
|
if (!spec?.git) return false
|
|
349
383
|
try {
|
|
350
384
|
await mkdir(workspaceReposDir(), { recursive: true })
|
|
351
|
-
|
|
385
|
+
const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
|
|
386
|
+
await execFileP("git", ["clone", "--", spec.git, dir], { env })
|
|
352
387
|
console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
|
|
353
388
|
return true
|
|
354
389
|
} catch (e: any) {
|
|
@@ -639,7 +674,9 @@ export async function importPersonalFromRepo(
|
|
|
639
674
|
// Clone into a tmp dir. ssh uses the deploy key (StrictHostKeyChecking=
|
|
640
675
|
// accept-new, no pre-populated known_hosts on first run); https auths via url.
|
|
641
676
|
const tmp = await mkdtemp(join(tmpdir(), `loopat-import-${userId}-`))
|
|
642
|
-
|
|
677
|
+
// Bootstrap: first clone of the personal repo uses the host deploy-key (no
|
|
678
|
+
// vault key exists yet). Every later op uses the user's vault key.
|
|
679
|
+
const cloneEnv = isHttps ? { ...process.env } : { ...process.env, GIT_SSH_COMMAND: bootstrapSshCommand(userId) }
|
|
643
680
|
try {
|
|
644
681
|
await execFileP("git", ["clone", "--", repoUrl, tmp], { env: cloneEnv })
|
|
645
682
|
} catch (e: any) {
|
|
@@ -714,9 +751,25 @@ export async function importPersonalFromRepo(
|
|
|
714
751
|
return { ok: true, autoInitialized: true, cryptKey: init.cryptKey }
|
|
715
752
|
}
|
|
716
753
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
754
|
+
/**
|
|
755
|
+
* The ssh command a server-side git op uses to act AS the user — its OWN key
|
|
756
|
+
* from the selected vault (`vaults/<vault>/mounts/home/.ssh/id`). If the key
|
|
757
|
+
* isn't there the op simply fails: we deliberately do NOT fall back to the host
|
|
758
|
+
* deploy-key, so a loop never borrows the host's access. Authorization tracks
|
|
759
|
+
* the personal repo, not the host (see behavior/02-personal-permissions.md).
|
|
760
|
+
*/
|
|
761
|
+
function sshCommandForUser(userId: string, vault: string = "default"): string {
|
|
762
|
+
const vaultKey = join(personalVaultDir(userId, vault), "mounts", "home", ".ssh", "id")
|
|
763
|
+
return `ssh -i ${vaultKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Bootstrap ONLY: the first clone/decrypt of the personal repo itself, before
|
|
768
|
+
* any vault key exists. Uses the host-managed deploy-key — the one credential
|
|
769
|
+
* the host legitimately holds to unlock a personal repo.
|
|
770
|
+
*/
|
|
771
|
+
function bootstrapSshCommand(userId: string): string {
|
|
772
|
+
return `ssh -i ${hostDeployKeyPath(userId)} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
|
|
720
773
|
}
|
|
721
774
|
|
|
722
775
|
async function swapPersonalDir(
|
|
@@ -1273,13 +1326,16 @@ export async function syncUiNotes(user: string): Promise<PersonalPushResult> {
|
|
|
1273
1326
|
const branch = await remoteDefaultBranch(dir)
|
|
1274
1327
|
const c = await commitLocalChanges(dir, "loopat: edit notes")
|
|
1275
1328
|
if (!c.ok) return { ok: false, error: c.error }
|
|
1276
|
-
const
|
|
1329
|
+
const userSsh = sshCommandForUser(user)
|
|
1330
|
+
const reb = await rebaseOntoOrigin(dir, branch, userSsh)
|
|
1277
1331
|
if (!reb.ok) {
|
|
1278
1332
|
if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
|
|
1279
1333
|
return { ok: false, error: reb.error }
|
|
1280
1334
|
}
|
|
1281
1335
|
try {
|
|
1282
|
-
await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`]
|
|
1336
|
+
await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], {
|
|
1337
|
+
env: { ...process.env, GIT_SSH_COMMAND: userSsh },
|
|
1338
|
+
})
|
|
1283
1339
|
} catch (e: any) {
|
|
1284
1340
|
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1285
1341
|
return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
|
|
@@ -1300,7 +1356,7 @@ export async function ffUpdateUiNotes(
|
|
|
1300
1356
|
const branch = await remoteDefaultBranch(dir)
|
|
1301
1357
|
try {
|
|
1302
1358
|
await execFileP("git", ["-C", dir, "fetch", "origin"], {
|
|
1303
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 30_000,
|
|
1359
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000,
|
|
1304
1360
|
})
|
|
1305
1361
|
} catch (e: any) {
|
|
1306
1362
|
return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
@@ -1332,7 +1388,7 @@ export async function notesBehind(user: string): Promise<number> {
|
|
|
1332
1388
|
const branch = await remoteDefaultBranch(dir)
|
|
1333
1389
|
try {
|
|
1334
1390
|
await execFileP("git", ["-C", dir, "fetch", "origin"], {
|
|
1335
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 30_000,
|
|
1391
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000,
|
|
1336
1392
|
})
|
|
1337
1393
|
} catch {
|
|
1338
1394
|
return 0
|
|
@@ -1371,7 +1427,7 @@ export type RepoSyncResult =
|
|
|
1371
1427
|
* failures are tolerated (offline / auth glitch) — status still reflects
|
|
1372
1428
|
* last-known remote state.
|
|
1373
1429
|
*/
|
|
1374
|
-
export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
|
|
1430
|
+
export async function inspectRepoSync(dir: string, user?: string): Promise<RepoSyncStatus> {
|
|
1375
1431
|
if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) {
|
|
1376
1432
|
return { isGitRepo: false, hasRemote: false, branch: "", ahead: 0, behind: 0, uncommitted: 0 }
|
|
1377
1433
|
}
|
|
@@ -1390,7 +1446,8 @@ export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
|
|
|
1390
1446
|
|
|
1391
1447
|
if (hasRemote) {
|
|
1392
1448
|
try {
|
|
1393
|
-
|
|
1449
|
+
const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env
|
|
1450
|
+
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 15_000 })
|
|
1394
1451
|
} catch {}
|
|
1395
1452
|
}
|
|
1396
1453
|
|
|
@@ -1420,7 +1477,7 @@ export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
|
|
|
1420
1477
|
* changes (we don't auto-stash workspace repos — caller decides) and on
|
|
1421
1478
|
* any non-ff condition.
|
|
1422
1479
|
*/
|
|
1423
|
-
export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
|
|
1480
|
+
export async function pullRepoFromRemote(dir: string, user?: string): Promise<RepoSyncResult> {
|
|
1424
1481
|
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1425
1482
|
return { ok: false, error: "not a git repo" }
|
|
1426
1483
|
}
|
|
@@ -1449,7 +1506,8 @@ export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
|
|
|
1449
1506
|
}
|
|
1450
1507
|
|
|
1451
1508
|
try {
|
|
1452
|
-
|
|
1509
|
+
const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env
|
|
1510
|
+
await execFileP("git", ["-C", dir, "fetch", "origin"], { env, timeout: 30_000 })
|
|
1453
1511
|
} catch (e: any) {
|
|
1454
1512
|
return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
|
|
1455
1513
|
}
|
|
@@ -1469,7 +1527,7 @@ export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
|
|
|
1469
1527
|
* non-ff by default, which is exactly the abort-on-conflict behavior we
|
|
1470
1528
|
* want. Caller pulls first if rejected.
|
|
1471
1529
|
*/
|
|
1472
|
-
export async function pushRepoToRemote(dir: string): Promise<RepoSyncResult> {
|
|
1530
|
+
export async function pushRepoToRemote(dir: string, user?: string): Promise<RepoSyncResult> {
|
|
1473
1531
|
if (!existsSyncBase(join(dir, ".git"))) {
|
|
1474
1532
|
return { ok: false, error: "not a git repo" }
|
|
1475
1533
|
}
|
|
@@ -1489,7 +1547,8 @@ export async function pushRepoToRemote(dir: string): Promise<RepoSyncResult> {
|
|
|
1489
1547
|
if (!branch) return { ok: false, error: "HEAD is detached" }
|
|
1490
1548
|
|
|
1491
1549
|
try {
|
|
1492
|
-
|
|
1550
|
+
const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env
|
|
1551
|
+
await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { env })
|
|
1493
1552
|
} catch (e: any) {
|
|
1494
1553
|
const stderr = (e?.stderr ?? "").toString().trim()
|
|
1495
1554
|
return { ok: false, error: `push failed: ${stderr || e?.message || e}` }
|
|
@@ -1691,14 +1750,15 @@ async function remoteDefaultBranch(dir: string): Promise<string> {
|
|
|
1691
1750
|
return "main"
|
|
1692
1751
|
}
|
|
1693
1752
|
|
|
1694
|
-
async function remoteStartPoint(repo: string): Promise<string | null> {
|
|
1753
|
+
async function remoteStartPoint(repo: string, sshCommand?: string): Promise<string | null> {
|
|
1695
1754
|
try {
|
|
1696
1755
|
await execFileP("git", ["-C", repo, "remote", "get-url", "origin"])
|
|
1697
1756
|
} catch {
|
|
1698
1757
|
return null
|
|
1699
1758
|
}
|
|
1700
1759
|
try {
|
|
1701
|
-
|
|
1760
|
+
const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
|
|
1761
|
+
await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { env, timeout: 15_000 })
|
|
1702
1762
|
} catch {}
|
|
1703
1763
|
const branch = await remoteDefaultBranch(repo)
|
|
1704
1764
|
try {
|
|
@@ -1794,8 +1854,10 @@ export async function createLoop(opts: {
|
|
|
1794
1854
|
|
|
1795
1855
|
// workdir = git worktree add (if repo selected) OR plain mkdir
|
|
1796
1856
|
if (opts.repo) {
|
|
1857
|
+
// clone + fetch as the user (their vault key), not the host's ssh.
|
|
1858
|
+
const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default")
|
|
1797
1859
|
// clone-on-demand: pull the repo down only now that a loop actually needs it
|
|
1798
|
-
if (!(await ensureRepoCloned(opts.repo))) {
|
|
1860
|
+
if (!(await ensureRepoCloned(opts.repo, userSsh))) {
|
|
1799
1861
|
throw new Error(`repo "${opts.repo}" not found / clone failed`)
|
|
1800
1862
|
}
|
|
1801
1863
|
const repoPath = workspaceRepoDir(opts.repo)
|
|
@@ -1804,7 +1866,7 @@ export async function createLoop(opts: {
|
|
|
1804
1866
|
// ① pull (docs/context-flow.md): base the workdir branch on origin/main
|
|
1805
1867
|
// (best-effort fetch) so it starts from latest consensus; fall back to
|
|
1806
1868
|
// local HEAD when there's no remote / no origin/main.
|
|
1807
|
-
const start = await remoteStartPoint(repoPath)
|
|
1869
|
+
const start = await remoteStartPoint(repoPath, userSsh)
|
|
1808
1870
|
const wtArgs = ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)]
|
|
1809
1871
|
if (start) wtArgs.push(start)
|
|
1810
1872
|
await execFileP("git", wtArgs)
|
|
@@ -1819,6 +1881,11 @@ export async function createLoop(opts: {
|
|
|
1819
1881
|
await mkdir(loopWorkdir(id), { recursive: true })
|
|
1820
1882
|
}
|
|
1821
1883
|
|
|
1884
|
+
// Point the context repos at the personal-declared kn/notes remotes and
|
|
1885
|
+
// connect with the user's vault key (personal repo is self-describing).
|
|
1886
|
+
await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
|
|
1887
|
+
(e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
|
|
1888
|
+
)
|
|
1822
1889
|
await ensureContextMounts(id, effectiveDriver(meta))
|
|
1823
1890
|
await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
|
|
1824
1891
|
return meta
|
package/server/src/podman.ts
CHANGED
|
@@ -100,7 +100,12 @@ const LABEL_CONFIG_HASH = "loopat.config-hash"
|
|
|
100
100
|
|
|
101
101
|
// Image used as the base for every loop container. Built locally from
|
|
102
102
|
// server/templates/sandbox/Containerfile via ensureSandboxImage().
|
|
103
|
-
|
|
103
|
+
// Per-workspace image name so multiple LOOPAT_HOMEs on one host don't share
|
|
104
|
+
// (and can't accidentally delete) each other's images. `uninstall` finds them
|
|
105
|
+
// by the loopat.workspace label, not this name — the name only prevents tag
|
|
106
|
+
// collisions. Same-Containerfile builds still share overlay layers, so the
|
|
107
|
+
// per-workspace tags don't multiply disk usage.
|
|
108
|
+
export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || `loopat-sandbox-${WORKSPACE}:latest`
|
|
104
109
|
|
|
105
110
|
// Container name: prefix with workspace to avoid collisions between loopat
|
|
106
111
|
// instances running on the same host with different LOOPAT_HOME. Loop UUIDs
|
|
@@ -369,7 +374,7 @@ export async function buildPodmanCreateArgs(opts: ContainerOptions): Promise<str
|
|
|
369
374
|
"--device", "/dev/fuse",
|
|
370
375
|
// Shared bridge network so the serve container can reach loop
|
|
371
376
|
// containers by name (aardvark-dns). Outbound API calls via NAT.
|
|
372
|
-
"--network",
|
|
377
|
+
"--network", LOOPAT_NETWORK,
|
|
373
378
|
"--hostname", `loop-${opts.loopId.slice(0, 8)}`,
|
|
374
379
|
// Container cwd at creation; per-exec we override with -w.
|
|
375
380
|
"--workdir", V_LOOP_WORKDIR(opts.loopId),
|
|
@@ -554,7 +559,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
|
|
|
554
559
|
|
|
555
560
|
// Hash the Containerfile so the base image auto-rebuilds when it changes.
|
|
556
561
|
const hash = await baseContainerfileHash()
|
|
557
|
-
const hashTag = `loopat-sandbox-${hash}:latest`
|
|
562
|
+
const hashTag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
|
|
558
563
|
|
|
559
564
|
const present = await runPodman(["image", "exists", hashTag], { allowFail: true })
|
|
560
565
|
if (present.code === 0) {
|
|
@@ -571,7 +576,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
|
|
|
571
576
|
const buildDir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
|
|
572
577
|
let lastStep = ""
|
|
573
578
|
const r = await runPodman(
|
|
574
|
-
["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "-f", containerfile, buildDir],
|
|
579
|
+
["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", containerfile, buildDir],
|
|
575
580
|
{
|
|
576
581
|
onLine: (line) => {
|
|
577
582
|
const m = line.match(/^STEP\s+(\d+)\/(\d+):\s+(.+)/)
|
|
@@ -660,7 +665,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
|
|
|
660
665
|
// after the nested-podman base change shipped).
|
|
661
666
|
const baseHash = await baseContainerfileHash()
|
|
662
667
|
const hash = createHash("sha256").update(`base:${baseHash}\n`).update(content).digest("hex").slice(0, 16)
|
|
663
|
-
const tag = `loopat-sandbox-${hash}:latest`
|
|
668
|
+
const tag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
|
|
664
669
|
|
|
665
670
|
const existing = _loopImageInFlight.get(tag)
|
|
666
671
|
if (existing) return existing
|
|
@@ -717,7 +722,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
|
|
|
717
722
|
await writeFile(join(buildDir, "Containerfile"), childContainerfile)
|
|
718
723
|
|
|
719
724
|
const r = await runPodman(
|
|
720
|
-
["build", "-t", tag, "-f", join(buildDir, "Containerfile"), buildDir],
|
|
725
|
+
["build", "-t", tag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", join(buildDir, "Containerfile"), buildDir],
|
|
721
726
|
{
|
|
722
727
|
allowFail: true,
|
|
723
728
|
onLine: (line) => {
|
|
@@ -823,7 +828,9 @@ export async function getEphemeralHostPort(
|
|
|
823
828
|
return Number.isFinite(port) && port > 0 ? port : null
|
|
824
829
|
}
|
|
825
830
|
|
|
826
|
-
|
|
831
|
+
// Per-workspace network (+ loopat.workspace label) so parallel LOOPAT_HOMEs
|
|
832
|
+
// stay isolated and `uninstall` removes only its own.
|
|
833
|
+
const LOOPAT_NETWORK = `loopat-${WORKSPACE}`
|
|
827
834
|
const SERVE_CONTAINER = `loopat-${WORKSPACE}-serve`
|
|
828
835
|
|
|
829
836
|
let _networkReady = false
|
|
@@ -835,7 +842,7 @@ export async function ensureLoopatNetwork(): Promise<void> {
|
|
|
835
842
|
const r = await runPodman(["network", "exists", LOOPAT_NETWORK], { allowFail: true })
|
|
836
843
|
if (r.code !== 0) {
|
|
837
844
|
console.log(`[podman] creating network ${LOOPAT_NETWORK}`)
|
|
838
|
-
const create = await runPodman(["network", "create", LOOPAT_NETWORK])
|
|
845
|
+
const create = await runPodman(["network", "create", "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, LOOPAT_NETWORK])
|
|
839
846
|
if (create.code !== 0) {
|
|
840
847
|
throw new Error(`Failed to create podman network ${LOOPAT_NETWORK}: ${create.stderr}`)
|
|
841
848
|
}
|
package/server/src/uninstall.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `loopat uninstall` — clean removal of everything
|
|
2
|
+
* `loopat uninstall` — clean removal of everything THIS workspace created.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* workspace
|
|
7
|
-
*
|
|
8
|
-
* the npx/bun cache are only PRINTED as hints. Deleting a shared VM out from
|
|
9
|
-
* under the user would be the opposite of a clean uninstall.
|
|
4
|
+
* Every loopat resource is workspace-scoped: containers, images, and the
|
|
5
|
+
* network all carry a `loopat.workspace=<ws>` label, and the data dir IS this
|
|
6
|
+
* workspace's LOOPAT_HOME. So uninstall removes only its own — even when other
|
|
7
|
+
* LOOPAT_HOMEs exist on the same host, there's no cross-workspace collateral.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Shared host infrastructure loopat merely uses — the podman machine (a Linux
|
|
10
|
+
* VM on macOS) and the npx/bun cache — is only PRINTED as a hint, never
|
|
11
|
+
* touched. Deleting a shared VM out from under the user would be the opposite
|
|
12
|
+
* of a clean uninstall.
|
|
15
13
|
*
|
|
16
14
|
* Run via the launcher: `npx loopat uninstall [--yes]`.
|
|
17
15
|
*/
|
|
@@ -25,12 +23,11 @@ import { LOOPAT_HOME, WORKSPACE } from "./paths"
|
|
|
25
23
|
|
|
26
24
|
const execFileP = promisify(execFile)
|
|
27
25
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
26
|
+
// The label every loopat container/image/network carries (set at create/build
|
|
27
|
+
// time in podman.ts). Deleting by label is exact — no name-prefix ambiguity
|
|
28
|
+
// (e.g. workspace "foo" vs "foobar").
|
|
31
29
|
const LABEL_WORKSPACE = "loopat.workspace"
|
|
32
|
-
const
|
|
33
|
-
const IMAGE_REF = "loopat-sandbox" // base `loopat-sandbox:latest` + child `loopat-sandbox-<hash>:latest`
|
|
30
|
+
const labelFilter = `label=${LABEL_WORKSPACE}=${WORKSPACE}`
|
|
34
31
|
|
|
35
32
|
type Run = { code: number; out: string; err: string }
|
|
36
33
|
async function podman(args: string[]): Promise<Run> {
|
|
@@ -48,22 +45,18 @@ async function podmanAvailable(): Promise<boolean> {
|
|
|
48
45
|
return (await podman(["--version"])).code === 0
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
/** Containers belonging to THIS workspace. */
|
|
52
48
|
async function workspaceContainers(): Promise<string[]> {
|
|
53
|
-
const r = await podman(["ps", "-aq", "--filter",
|
|
49
|
+
const r = await podman(["ps", "-aq", "--filter", labelFilter])
|
|
54
50
|
return r.code === 0 ? lines(r.out) : []
|
|
55
51
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
async function anyLoopatContainers(): Promise<string[]> {
|
|
59
|
-
const r = await podman(["ps", "-aq", "--filter", `label=${LABEL_WORKSPACE}`])
|
|
60
|
-
return r.code === 0 ? lines(r.out) : []
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function loopatImageIds(): Promise<string[]> {
|
|
64
|
-
const r = await podman(["images", "--filter", `reference=${IMAGE_REF}*`, "--format", "{{.ID}}"])
|
|
52
|
+
async function workspaceImageIds(): Promise<string[]> {
|
|
53
|
+
const r = await podman(["images", "--filter", labelFilter, "--format", "{{.ID}}"])
|
|
65
54
|
return r.code === 0 ? [...new Set(lines(r.out))] : []
|
|
66
55
|
}
|
|
56
|
+
async function workspaceNetworks(): Promise<string[]> {
|
|
57
|
+
const r = await podman(["network", "ls", "--filter", labelFilter, "--format", "{{.Name}}"])
|
|
58
|
+
return r.code === 0 ? lines(r.out) : []
|
|
59
|
+
}
|
|
67
60
|
|
|
68
61
|
/** TTY confirm. Non-interactive (piped) without --yes → treated as "no". */
|
|
69
62
|
function confirm(question: string): boolean {
|
|
@@ -75,62 +68,56 @@ export async function runUninstall(argv: string[]): Promise<void> {
|
|
|
75
68
|
const yes = argv.includes("--yes") || argv.includes("-y")
|
|
76
69
|
const hasPodman = await podmanAvailable()
|
|
77
70
|
const containers = hasPodman ? await workspaceContainers() : []
|
|
71
|
+
const images = hasPodman ? await workspaceImageIds() : []
|
|
72
|
+
const networks = hasPodman ? await workspaceNetworks() : []
|
|
78
73
|
const dataExists = existsSync(LOOPAT_HOME)
|
|
79
74
|
|
|
80
|
-
// ── Plan (
|
|
75
|
+
// ── Plan (the user sees the exact boundary before anything happens) ──
|
|
81
76
|
console.log(`loopat uninstall — workspace "${WORKSPACE}"`)
|
|
82
77
|
console.log("")
|
|
83
|
-
console.log("Will remove:")
|
|
78
|
+
console.log("Will remove (this workspace only):")
|
|
84
79
|
console.log(` • ${containers.length} sandbox container(s)`)
|
|
85
|
-
console.log(` •
|
|
80
|
+
console.log(` • ${images.length} sandbox image(s)`)
|
|
81
|
+
console.log(` • ${networks.length} network(s)${networks.length ? ` (${networks.join(", ")})` : ""}`)
|
|
86
82
|
console.log(` • data dir: ${LOOPAT_HOME}${dataExists ? "" : " (absent)"}`)
|
|
87
83
|
if (!hasPodman) console.log(" • note: podman not found — skipping container/image/network cleanup")
|
|
88
84
|
console.log("")
|
|
89
85
|
|
|
90
|
-
if (!yes && !confirm("Proceed? This permanently deletes
|
|
86
|
+
if (!yes && !confirm("Proceed? This permanently deletes this workspace's data. [y/N] ")) {
|
|
91
87
|
console.log("Aborted — nothing removed.")
|
|
92
88
|
return
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
//
|
|
91
|
+
// Every resource below is label-scoped to THIS workspace — no shared-resource
|
|
92
|
+
// guessing, so other workspaces on the host are never touched.
|
|
96
93
|
if (hasPodman && containers.length) {
|
|
97
94
|
process.stdout.write(`Removing ${containers.length} container(s)… `)
|
|
98
95
|
await podman(["rm", "-f", ...containers])
|
|
99
96
|
console.log("done")
|
|
100
97
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if ((await podman(["network", "exists", LOOPAT_NETWORK])).code === 0) {
|
|
113
|
-
process.stdout.write(`Removing network "${LOOPAT_NETWORK}"… `)
|
|
114
|
-
await podman(["network", "rm", LOOPAT_NETWORK])
|
|
115
|
-
console.log("done")
|
|
116
|
-
}
|
|
117
|
-
} else {
|
|
118
|
-
console.log(`Keeping shared images/network — ${remaining.length} loopat container(s) from other workspaces still present.`)
|
|
119
|
-
}
|
|
98
|
+
if (hasPodman && images.length) {
|
|
99
|
+
// rmi by image ID removes this workspace's tags; shared overlay layers
|
|
100
|
+
// stay alive (refcounted) for any other workspace still using them.
|
|
101
|
+
process.stdout.write(`Removing ${images.length} image(s)… `)
|
|
102
|
+
await podman(["rmi", "-f", ...images])
|
|
103
|
+
console.log("done")
|
|
104
|
+
}
|
|
105
|
+
for (const net of networks) {
|
|
106
|
+
process.stdout.write(`Removing network "${net}"… `)
|
|
107
|
+
await podman(["network", "rm", net])
|
|
108
|
+
console.log("done")
|
|
120
109
|
}
|
|
121
|
-
|
|
122
|
-
// 3. Workspace data.
|
|
123
110
|
if (dataExists) {
|
|
124
111
|
process.stdout.write(`Removing data dir ${LOOPAT_HOME}… `)
|
|
125
112
|
await rm(LOOPAT_HOME, { recursive: true, force: true })
|
|
126
113
|
console.log("done")
|
|
127
114
|
}
|
|
128
115
|
|
|
129
|
-
//
|
|
116
|
+
// Second-layer hints — shared infra we deliberately do NOT touch.
|
|
130
117
|
console.log("")
|
|
131
|
-
console.log("Done.
|
|
118
|
+
console.log("Done. This workspace's resources are gone.")
|
|
132
119
|
console.log("")
|
|
133
|
-
console.log("Left untouched (remove yourself only if loopat was their only user):")
|
|
120
|
+
console.log("Left untouched (shared host infra — remove yourself only if loopat was their only user):")
|
|
134
121
|
if (process.platform === "darwin") {
|
|
135
122
|
console.log(" • podman machine (Linux VM): podman machine stop && podman machine rm")
|
|
136
123
|
}
|