loopat 0.1.2 → 0.1.4

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 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>
@@ -131,6 +137,14 @@ Loopat splits configuration along role lines — read whichever applies:
131
137
 
132
138
  ### Docker (recommended)
133
139
 
140
+ Pull the prebuilt image from GHCR:
141
+
142
+ ```sh
143
+ docker run -d --privileged -p 20001:10001 ghcr.io/simpx/loopat:latest
144
+ ```
145
+
146
+ Or, to build from source and persist the workspace in a named volume:
147
+
134
148
  ```sh
135
149
  docker compose up -d
136
150
  ```
package/bin/loopat.mjs CHANGED
@@ -19,6 +19,17 @@ const here = dirname(fileURLToPath(import.meta.url))
19
19
  const pkgRoot = join(here, "..")
20
20
  const serverEntry = join(pkgRoot, "server", "src", "index.ts")
21
21
 
22
+ // Subcommand routing. `loopat uninstall` runs a standalone cleanup entry (never
23
+ // boots the server); everything else starts the server. Kept here in the Node
24
+ // shim so the uninstall path doesn't pull in any server-side init.
25
+ const SUBCOMMANDS = {
26
+ uninstall: join(pkgRoot, "server", "src", "uninstall.ts"),
27
+ }
28
+ const sub = process.argv[2]
29
+ const subEntry = Object.prototype.hasOwnProperty.call(SUBCOMMANDS, sub) ? SUBCOMMANDS[sub] : null
30
+ const entry = subEntry ?? serverEntry
31
+ const forwardArgs = subEntry ? process.argv.slice(3) : process.argv.slice(2)
32
+
22
33
  function resolveBun() {
23
34
  // 1. Explicit override.
24
35
  if (process.env.LOOPAT_BUN && existsSync(process.env.LOOPAT_BUN)) {
@@ -49,7 +60,7 @@ function resolveBun() {
49
60
  }
50
61
 
51
62
  const bun = resolveBun()
52
- const child = spawn(bun, [serverEntry, ...process.argv.slice(2)], {
63
+ const child = spawn(bun, [entry, ...forwardArgs], {
53
64
  stdio: "inherit",
54
65
  env: process.env,
55
66
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -22,16 +22,33 @@ import { listUsers } from "./auth"
22
22
  type Check = { ok: boolean; label: string; hint?: string }
23
23
 
24
24
  function checkPodman(): Check {
25
+ const isMac = process.platform === "darwin"
26
+ let version: string
25
27
  try {
26
- const out = execFileSync("podman", ["--version"], { stdio: "pipe" }).toString().trim()
27
- return { ok: true, label: `podman (sandbox): ${out}` }
28
+ version = execFileSync("podman", ["--version"], { stdio: "pipe" }).toString().trim()
28
29
  } catch {
29
30
  return {
30
31
  ok: false,
31
32
  label: "podman (sandbox)",
32
- hint: "install with: sudo apt install podman uidmap fuse-overlayfs (Linux only)",
33
+ hint: isMac
34
+ ? "brew install podman, then: podman machine init && podman machine start"
35
+ : "sudo apt install podman uidmap fuse-overlayfs (Linux)",
33
36
  }
34
37
  }
38
+ // On macOS podman runs inside a Linux VM ("machine"). `--version` succeeds even
39
+ // when the machine is stopped — `podman info` is what actually needs the VM up.
40
+ if (isMac) {
41
+ try {
42
+ execFileSync("podman", ["info"], { stdio: "pipe", timeout: 8000 })
43
+ } catch {
44
+ return {
45
+ ok: false,
46
+ label: `podman (sandbox): ${version}`,
47
+ hint: "podman machine isn't running — start it: podman machine start (run `podman machine init` first if you never have)",
48
+ }
49
+ }
50
+ }
51
+ return { ok: true, label: `podman (sandbox): ${version}` }
35
52
  }
36
53
 
37
54
  function checkClaudeBinary(): Check {
@@ -47,6 +64,21 @@ function checkClaudeBinary(): Check {
47
64
  }
48
65
  }
49
66
 
67
+ function checkGitCrypt(): Check {
68
+ try {
69
+ const out = execFileSync("git-crypt", ["--version"], { stdio: "pipe" }).toString().trim()
70
+ return { ok: true, label: `git-crypt (personal vault): ${out}` }
71
+ } catch {
72
+ return {
73
+ ok: false,
74
+ label: "git-crypt (personal vault)",
75
+ hint: process.platform === "darwin"
76
+ ? "brew install git-crypt (encrypts your personal vault)"
77
+ : "sudo apt install git-crypt (encrypts your personal vault)",
78
+ }
79
+ }
80
+ }
81
+
50
82
 
51
83
  function describeRemote(dir: string, url: string | undefined): string {
52
84
  if (!existsSync(dir)) return "missing"
@@ -92,6 +124,7 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) {
92
124
  { ok: existsSync(configPath()), label: `config: ${configPath()}` },
93
125
  checkPodman(),
94
126
  checkClaudeBinary(),
127
+ checkGitCrypt(),
95
128
  ]
96
129
 
97
130
  const bar = "─".repeat(60)
@@ -0,0 +1,129 @@
1
+ /**
2
+ * host-cli proxy (POC). Some CLIs can only run on the host — macOS-only tools,
3
+ * or company tools bound to a specific machine. The sandbox can't run them, so
4
+ * we run them on the host *on behalf of* a loop:
5
+ *
6
+ * sandbox: `aone foo` → shim → loopat-host (forwarder) →
7
+ * server : POST /api/host-exec → execFile("aone", ["foo"]) on the host
8
+ *
9
+ * Trust model (deliberately simple): mounting the socket into a sandbox IS the
10
+ * trust decision — a host that turns on host-cli for a loop already trusts that
11
+ * loop, so there is NO whitelist. The loop may run any host cli (it can call the
12
+ * forwarder directly with a hand-built command).
13
+ *
14
+ * The mise-generated shims aren't a whitelist either — they're the declarative
15
+ * ENTRY POINT. "Which clis did the loop declare in `[host].clis`" shows up as
16
+ * "which shim binaries exist on PATH"; that's a UX convention (what the AI can
17
+ * conveniently reach), not a security boundary. The only boundary is whether
18
+ * the socket is mounted at all.
19
+ *
20
+ * - runs with HOST user permissions
21
+ * - cwd is a per-loop host workdir (mirrors the loop's workdir); the cli
22
+ * cannot see inside the sandbox
23
+ * - execFile, never a shell — argv is an array
24
+ */
25
+ import { execFile } from "node:child_process"
26
+ import { mkdir, writeFile, chmod } from "node:fs/promises"
27
+ import { existsSync, rmSync, mkdirSync } from "node:fs"
28
+ import { join } from "node:path"
29
+ import { loopDir, LOOPAT_HOME } from "./paths"
30
+
31
+ /** A per-loop host workdir — the host-side mirror of the loop's own workdir. */
32
+ export function hostWorkdir(loopId: string): string {
33
+ return join(loopDir(loopId), "host-workdir")
34
+ }
35
+
36
+ /** The dir that holds the host-exec unix socket. We mount the DIR (not the
37
+ * socket file) into sandboxes so a server restart — which recreates the
38
+ * socket inode — stays visible to already-running containers. */
39
+ export function hostExecDir(): string {
40
+ return join(LOOPAT_HOME, "host-exec")
41
+ }
42
+ export function hostExecSocketPath(): string {
43
+ return join(hostExecDir(), "host-exec.sock")
44
+ }
45
+
46
+ export type HostExecResult =
47
+ | { ok: true; exitCode: number; stdout: string; stderr: string }
48
+ | { ok: false; error: string }
49
+
50
+ /** Run a host-cli in the loop's host workdir, with host permissions. No
51
+ * whitelist — mounting the socket into the sandbox is the trust decision. */
52
+ export async function runHostCli(opts: {
53
+ cli: string
54
+ args: string[]
55
+ cwd: string
56
+ stdin?: string
57
+ timeoutMs?: number
58
+ }): Promise<HostExecResult> {
59
+ await mkdir(opts.cwd, { recursive: true })
60
+ return new Promise((resolve) => {
61
+ const child = execFile(
62
+ opts.cli,
63
+ opts.args,
64
+ { cwd: opts.cwd, timeout: opts.timeoutMs ?? 120_000, maxBuffer: 16 * 1024 * 1024 },
65
+ (err: any, stdout, stderr) => {
66
+ if (err && err.code === "ENOENT") {
67
+ resolve({ ok: false, error: `host has no '${opts.cli}'` })
68
+ return
69
+ }
70
+ const exitCode = typeof err?.code === "number" ? err.code : err ? 1 : 0
71
+ resolve({ ok: true, exitCode, stdout: String(stdout), stderr: String(stderr) })
72
+ },
73
+ )
74
+ if (opts.stdin !== undefined && child.stdin) {
75
+ child.stdin.write(opts.stdin)
76
+ child.stdin.end()
77
+ }
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Write a shim per declared host-cli into `binDir` (which the sandbox puts on
83
+ * PATH ahead of everything). Each shim just hands off to the forwarder.
84
+ */
85
+ export async function writeHostShims(binDir: string, clis: string[]): Promise<void> {
86
+ await mkdir(binDir, { recursive: true })
87
+ // The `loopat-host` forwarder is baked into the sandbox image; here we only
88
+ // emit the per-cli shims — each just hands off to it.
89
+ for (const cli of clis) {
90
+ const p = join(binDir, cli)
91
+ await writeFile(p, `#!/bin/sh\n# loopat host-cli shim — forwards "${cli}" to the host\nexec loopat-host "${cli}" "$@"\n`)
92
+ await chmod(p, 0o755)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Unix-socket server that runs host-clis for loops. The sandbox's
98
+ * forwarder connects over the *mounted* socket — no TCP, no exposed port, and
99
+ * only a container that has the socket mounted can reach it (the mount itself
100
+ * is a layer of isolation). Reuses runHostCli. stdout/stderr come back base64
101
+ * so the sh forwarder can pull them out of the JSON without escaping pain.
102
+ */
103
+ export function serveHostExec(socketPath: string, deps: { loopExists: (id: string) => Promise<boolean> }) {
104
+ try { mkdirSync(hostExecDir(), { recursive: true }) } catch {}
105
+ try { if (existsSync(socketPath)) rmSync(socketPath) } catch {}
106
+ return Bun.serve({
107
+ unix: socketPath,
108
+ async fetch(req) {
109
+ const url = new URL(req.url)
110
+ if (url.pathname !== "/host-exec" || req.method !== "POST") return new Response("not found", { status: 404 })
111
+ const b: any = await req.json().catch(() => ({}))
112
+ const loopId = typeof b.loopId === "string" ? b.loopId : ""
113
+ const cli = typeof b.cli === "string" ? b.cli : ""
114
+ const args = Array.isArray(b.args) ? b.args.map(String) : []
115
+ if (!loopId || !cli) return Response.json({ error: "loopId + cli required" })
116
+ if (!(await deps.loopExists(loopId))) return Response.json({ error: "unknown loop" })
117
+ const r = await runHostCli({
118
+ cli, args, cwd: hostWorkdir(loopId),
119
+ stdin: typeof b.stdin === "string" ? b.stdin : undefined,
120
+ })
121
+ if (!r.ok) return Response.json({ error: r.error })
122
+ return Response.json({
123
+ exitCode: r.exitCode,
124
+ stdout_b64: Buffer.from(r.stdout).toString("base64"),
125
+ stderr_b64: Buffer.from(r.stderr).toString("base64"),
126
+ })
127
+ },
128
+ })
129
+ }
@@ -55,6 +55,7 @@ import {
55
55
  import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, type ProviderConfig, type ModelEntry } from "./config"
56
56
  import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
57
57
  import { printBootstrapBanner } from "./bootstrap"
58
+ import { serveHostExec, hostExecSocketPath } from "./host-exec"
58
59
  import {
59
60
  createUser,
60
61
  findUser,
@@ -3177,6 +3178,16 @@ await printBootstrapBanner(cfg)
3177
3178
  const backfilled = await backfillAllMounts()
3178
3179
  if (backfilled > 0) console.log(`[loopat] backfilled context mounts on ${backfilled} loop(s)`)
3179
3180
 
3181
+ // host-cli proxy: a unix socket the loop sandboxes mount + forward to, so
3182
+ // host-only clis (macOS / machine-bound) run on the host on behalf of a loop.
3183
+ try {
3184
+ const sock = hostExecSocketPath()
3185
+ serveHostExec(sock, { loopExists })
3186
+ console.log(`[loopat] host-exec socket: ${sock}`)
3187
+ } catch (e: any) {
3188
+ console.warn(`[loopat] host-exec socket failed: ${e?.message ?? e}`)
3189
+ }
3190
+
3180
3191
  // Pull every imported personal repo from its remote on boot (best-effort).
3181
3192
  // personal is a per-user repo synced directly (not via loops), so a host that
3182
3193
  // was offline catches up here; settings edits then write-through on save.
@@ -36,7 +36,7 @@
36
36
  import { execFile, spawn } from "node:child_process"
37
37
  import { createHash } from "node:crypto"
38
38
  import { existsSync } from "node:fs"
39
- import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
39
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
40
40
  import { homedir, tmpdir } from "node:os"
41
41
  import { join } from "node:path"
42
42
  import { promisify } from "node:util"
@@ -59,6 +59,8 @@ import {
59
59
  } from "./paths"
60
60
  import { loadConfig } from "./config"
61
61
  import { DEFAULT_VAULT, listVaultHomeMounts } from "./vaults"
62
+ import { hostExecDir, writeHostShims } from "./host-exec"
63
+ import { parse as tomlParse, stringify as tomlStringify } from "smol-toml"
62
64
 
63
65
  const execFileP = promisify(execFile)
64
66
 
@@ -75,6 +77,13 @@ export const V_CONTEXT_PERSONAL_MEMORY = "/loopat/context/personal/memory"
75
77
  export const V_CONTEXT_REPOS = "/loopat/context/repos"
76
78
  export const V_CONTEXT_CHAT = "/loopat/context/chat"
77
79
 
80
+ // host-cli proxy: the dir holding the host-exec unix socket, mounted in so the
81
+ // loop's `loopat-host` forwarder can reach the host. Mount the DIR (not the
82
+ // socket file) so a server restart that recreates the socket inode stays
83
+ // visible inside running containers.
84
+ export const V_HOST_EXEC_DIR = "/loopat/host-exec"
85
+ export const V_HOST_EXEC_SOCK = "/loopat/host-exec/host-exec.sock"
86
+
78
87
  // $HOME inside the container. Deliberately NOT host's homedir — if we bound
79
88
  // host's $HOME at its real path, podman would auto-create parent dirs for
80
89
  // every nested bind (LOOPAT_HOME, LOOPAT_INSTALL_DIR, etc. all live under
@@ -91,7 +100,12 @@ const LABEL_CONFIG_HASH = "loopat.config-hash"
91
100
 
92
101
  // Image used as the base for every loop container. Built locally from
93
102
  // server/templates/sandbox/Containerfile via ensureSandboxImage().
94
- export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || "loopat-sandbox:latest"
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`
95
109
 
96
110
  // Container name: prefix with workspace to avoid collisions between loopat
97
111
  // instances running on the same host with different LOOPAT_HOME. Loop UUIDs
@@ -247,6 +261,15 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
247
261
  mounts.push({ src: chatDir, dst: V_CONTEXT_CHAT, ro: true })
248
262
  }
249
263
 
264
+ // host-cli proxy: mount the host-exec socket dir so the loop's `loopat-host`
265
+ // forwarder can reach the host. Mounting the socket IS the trust decision —
266
+ // a loop with this mount may run any host cli (see host-exec.ts). The dir is
267
+ // created by serveHostExec at boot; mount if present (bind-try semantics).
268
+ const hostExec = hostExecDir()
269
+ if (existsSync(hostExec)) {
270
+ mounts.push({ src: hostExec, dst: V_HOST_EXEC_DIR })
271
+ }
272
+
250
273
  // All-loops ro view (admin-gated): expose LOOPAT_HOME/loops/ at /loopat/loops.
251
274
  if (mountAllLoops) {
252
275
  mounts.push({ src: loopsDir(), dst: V_ALL_LOOPS, ro: true })
@@ -292,6 +315,10 @@ export async function buildContainerEnv(opts: ContainerOptions): Promise<Record<
292
315
  const out: Record<string, string> = {}
293
316
  // Sandbox $HOME is /loopat/home/<user> (see V_HOME comment).
294
317
  out.HOME = V_HOME(opts.createdBy)
318
+ // host-cli proxy: tell the loop's `loopat-host` forwarder where the mounted
319
+ // socket is and which loop it speaks for (server uses it for the workdir).
320
+ out.LOOPAT_HOST_SOCK = V_HOST_EXEC_SOCK
321
+ out.LOOPAT_LOOP_ID = opts.loopId
295
322
  for (const [k, v] of Object.entries(opts.extraEnv ?? {})) {
296
323
  out[k] = v
297
324
  }
@@ -347,7 +374,7 @@ export async function buildPodmanCreateArgs(opts: ContainerOptions): Promise<str
347
374
  "--device", "/dev/fuse",
348
375
  // Shared bridge network so the serve container can reach loop
349
376
  // containers by name (aardvark-dns). Outbound API calls via NAT.
350
- "--network", "loopat",
377
+ "--network", LOOPAT_NETWORK,
351
378
  "--hostname", `loop-${opts.loopId.slice(0, 8)}`,
352
379
  // Container cwd at creation; per-exec we override with -w.
353
380
  "--workdir", V_LOOP_WORKDIR(opts.loopId),
@@ -508,12 +535,17 @@ export async function probePodman(): Promise<PodmanProbeResult> {
508
535
  * through and invalidate stale child images.
509
536
  */
510
537
  export async function baseContainerfileHash(): Promise<string> {
511
- const containerfile = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox", "Containerfile")
538
+ const dir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
539
+ const containerfile = join(dir, "Containerfile")
512
540
  if (!existsSync(containerfile)) {
513
541
  throw new Error(`Containerfile not found at ${containerfile}`)
514
542
  }
515
- const content = await readFile(containerfile, "utf8")
516
- return createHash("sha256").update(content).digest("hex").slice(0, 16)
543
+ const h = createHash("sha256").update(await readFile(containerfile, "utf8"))
544
+ // Files COPY'd by the Containerfile also affect the image — hash them too so
545
+ // editing the forwarder rebuilds the base image.
546
+ const forwarder = join(dir, "loopat-host")
547
+ if (existsSync(forwarder)) h.update(await readFile(forwarder, "utf8"))
548
+ return h.digest("hex").slice(0, 16)
517
549
  }
518
550
 
519
551
  let _imageBuildInFlight: Promise<void> | null = null
@@ -527,7 +559,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
527
559
 
528
560
  // Hash the Containerfile so the base image auto-rebuilds when it changes.
529
561
  const hash = await baseContainerfileHash()
530
- const hashTag = `loopat-sandbox-${hash}:latest`
562
+ const hashTag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
531
563
 
532
564
  const present = await runPodman(["image", "exists", hashTag], { allowFail: true })
533
565
  if (present.code === 0) {
@@ -544,7 +576,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
544
576
  const buildDir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
545
577
  let lastStep = ""
546
578
  const r = await runPodman(
547
- ["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "-f", containerfile, buildDir],
579
+ ["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", containerfile, buildDir],
548
580
  {
549
581
  onLine: (line) => {
550
582
  const m = line.match(/^STEP\s+(\d+)\/(\d+):\s+(.+)/)
@@ -633,7 +665,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
633
665
  // after the nested-podman base change shipped).
634
666
  const baseHash = await baseContainerfileHash()
635
667
  const hash = createHash("sha256").update(`base:${baseHash}\n`).update(content).digest("hex").slice(0, 16)
636
- const tag = `loopat-sandbox-${hash}:latest`
668
+ const tag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
637
669
 
638
670
  const existing = _loopImageInFlight.get(tag)
639
671
  if (existing) return existing
@@ -649,23 +681,48 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
649
681
  opts?.onProgress?.("Installing tools from mise.toml…")
650
682
  const buildDir = await mkdtemp(join(tmpdir(), "loopat-img-"))
651
683
  try {
652
- await copyFile(miseTomlPath, join(buildDir, "mise.toml"))
684
+ // A loopat-native `[host]` table declares host-only clis (macOS /
685
+ // machine-bound) the sandbox can't run natively. We bake a forwarding
686
+ // shim per cli into the image's mise shims dir (already first on PATH),
687
+ // and strip the table before mise sees it — mise would reject the
688
+ // unknown table. The shim just hands off to `loopat-host` (in the base
689
+ // image) → mounted socket → host execFile. See host-exec.ts.
690
+ let hostClis: string[] = []
691
+ let miseConfig = content
692
+ try {
693
+ const parsed: any = tomlParse(content)
694
+ if (parsed && Array.isArray(parsed.host?.clis)) {
695
+ hostClis = parsed.host.clis.filter((x: unknown): x is string => typeof x === "string" && !!x)
696
+ }
697
+ if (parsed && "host" in parsed) {
698
+ const { host: _host, ...rest } = parsed
699
+ miseConfig = tomlStringify(rest)
700
+ }
701
+ } catch {}
702
+ await writeFile(join(buildDir, "mise.toml"), miseConfig)
653
703
  // Override `mise trust` interactively by marking the config path
654
704
  // trusted via env. `mise install -y` installs everything in
655
705
  // mise.toml; `mise reshim` ensures /opt/loopat-mise/shims/ has a
656
706
  // shim for every tool.
657
- const childContainerfile = [
707
+ const lines = [
658
708
  `FROM ${SANDBOX_IMAGE}`,
659
709
  `COPY mise.toml /opt/loopat-mise/config/config.toml`,
660
710
  `RUN MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`,
661
711
  ` mise install -y \\`,
662
712
  ` && MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`,
663
713
  ` mise reshim`,
664
- ].join("\n") + "\n"
714
+ ]
715
+ if (hostClis.length) {
716
+ // Generate the shims into the build context, then COPY them in AFTER
717
+ // reshim so mise's own reshim can't clobber them.
718
+ await writeHostShims(join(buildDir, "host-bin"), hostClis)
719
+ lines.push(`COPY host-bin/ /opt/loopat-mise/shims/`)
720
+ }
721
+ const childContainerfile = lines.join("\n") + "\n"
665
722
  await writeFile(join(buildDir, "Containerfile"), childContainerfile)
666
723
 
667
724
  const r = await runPodman(
668
- ["build", "-t", tag, "-f", join(buildDir, "Containerfile"), buildDir],
725
+ ["build", "-t", tag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", join(buildDir, "Containerfile"), buildDir],
669
726
  {
670
727
  allowFail: true,
671
728
  onLine: (line) => {
@@ -771,7 +828,9 @@ export async function getEphemeralHostPort(
771
828
  return Number.isFinite(port) && port > 0 ? port : null
772
829
  }
773
830
 
774
- const LOOPAT_NETWORK = "loopat"
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}`
775
834
  const SERVE_CONTAINER = `loopat-${WORKSPACE}-serve`
776
835
 
777
836
  let _networkReady = false
@@ -783,7 +842,7 @@ export async function ensureLoopatNetwork(): Promise<void> {
783
842
  const r = await runPodman(["network", "exists", LOOPAT_NETWORK], { allowFail: true })
784
843
  if (r.code !== 0) {
785
844
  console.log(`[podman] creating network ${LOOPAT_NETWORK}`)
786
- const create = await runPodman(["network", "create", LOOPAT_NETWORK])
845
+ const create = await runPodman(["network", "create", "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, LOOPAT_NETWORK])
787
846
  if (create.code !== 0) {
788
847
  throw new Error(`Failed to create podman network ${LOOPAT_NETWORK}: ${create.stderr}`)
789
848
  }
@@ -883,17 +942,26 @@ let _portProxyReady: Promise<void> | null = null
883
942
  */
884
943
  function findOccupiedPorts(lo: number, hi: number): Set<number> {
885
944
  const ports = new Set<number>()
945
+ const { execFileSync } = require("node:child_process")
946
+ const add = (port: number) => { if (port >= lo && port <= hi) ports.add(port) }
947
+ // Linux: ss reads /proc/net/tcp (every listening socket).
886
948
  try {
887
- const { execFileSync } = require("node:child_process")
888
949
  const out = execFileSync("ss", ["-tlnH"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string
889
950
  for (const line of out.split("\n")) {
890
951
  const parts = line.trim().split(/\s+/)
891
952
  if (parts.length < 4) continue
892
- const addr = parts[3] // e.g. "0.0.0.0:8080" or "[::]:8080" or "127.0.0.1:8080"
953
+ const addr = parts[3] // "0.0.0.0:8080" | "[::]:8080" | "127.0.0.1:8080"
893
954
  const colonIdx = addr.lastIndexOf(":")
894
- if (colonIdx === -1) continue
895
- const port = Number(addr.slice(colonIdx + 1))
896
- if (port >= lo && port <= hi) ports.add(port)
955
+ if (colonIdx !== -1) add(Number(addr.slice(colonIdx + 1)))
956
+ }
957
+ return ports
958
+ } catch {}
959
+ // macOS (no ss): lsof. NAME column looks like "*:8080" or "127.0.0.1:8080 (LISTEN)".
960
+ try {
961
+ const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string
962
+ for (const line of out.split("\n")) {
963
+ const m = line.match(/:(\d+)\s*\(LISTEN\)/)
964
+ if (m) add(Number(m[1]))
897
965
  }
898
966
  } catch {}
899
967
  return ports
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `loopat uninstall` — clean removal of everything THIS workspace created.
3
+ *
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.
8
+ *
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.
13
+ *
14
+ * Run via the launcher: `npx loopat uninstall [--yes]`.
15
+ */
16
+ import { existsSync } from "node:fs"
17
+ import { rm } from "node:fs/promises"
18
+ import { execFile } from "node:child_process"
19
+ import { promisify } from "node:util"
20
+ import { homedir } from "node:os"
21
+ import { join } from "node:path"
22
+ import { LOOPAT_HOME, WORKSPACE } from "./paths"
23
+
24
+ const execFileP = promisify(execFile)
25
+
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").
29
+ const LABEL_WORKSPACE = "loopat.workspace"
30
+ const labelFilter = `label=${LABEL_WORKSPACE}=${WORKSPACE}`
31
+
32
+ type Run = { code: number; out: string; err: string }
33
+ async function podman(args: string[]): Promise<Run> {
34
+ try {
35
+ const { stdout, stderr } = await execFileP("podman", args, { maxBuffer: 16 * 1024 * 1024 })
36
+ return { code: 0, out: stdout, err: stderr }
37
+ } catch (e: any) {
38
+ return { code: typeof e?.code === "number" ? e.code : 1, out: e?.stdout ?? "", err: e?.stderr ?? String(e?.message ?? e) }
39
+ }
40
+ }
41
+
42
+ const lines = (s: string) => s.split("\n").map((x) => x.trim()).filter(Boolean)
43
+
44
+ async function podmanAvailable(): Promise<boolean> {
45
+ return (await podman(["--version"])).code === 0
46
+ }
47
+
48
+ async function workspaceContainers(): Promise<string[]> {
49
+ const r = await podman(["ps", "-aq", "--filter", labelFilter])
50
+ return r.code === 0 ? lines(r.out) : []
51
+ }
52
+ async function workspaceImageIds(): Promise<string[]> {
53
+ const r = await podman(["images", "--filter", labelFilter, "--format", "{{.ID}}"])
54
+ return r.code === 0 ? [...new Set(lines(r.out))] : []
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
+ }
60
+
61
+ /** TTY confirm. Non-interactive (piped) without --yes → treated as "no". */
62
+ function confirm(question: string): boolean {
63
+ const ans = prompt(question)
64
+ return ans !== null && /^y(es)?$/i.test(ans.trim())
65
+ }
66
+
67
+ export async function runUninstall(argv: string[]): Promise<void> {
68
+ const yes = argv.includes("--yes") || argv.includes("-y")
69
+ const hasPodman = await podmanAvailable()
70
+ const containers = hasPodman ? await workspaceContainers() : []
71
+ const images = hasPodman ? await workspaceImageIds() : []
72
+ const networks = hasPodman ? await workspaceNetworks() : []
73
+ const dataExists = existsSync(LOOPAT_HOME)
74
+
75
+ // ── Plan (the user sees the exact boundary before anything happens) ──
76
+ console.log(`loopat uninstall — workspace "${WORKSPACE}"`)
77
+ console.log("")
78
+ console.log("Will remove (this workspace only):")
79
+ console.log(` • ${containers.length} sandbox container(s)`)
80
+ console.log(` • ${images.length} sandbox image(s)`)
81
+ console.log(` • ${networks.length} network(s)${networks.length ? ` (${networks.join(", ")})` : ""}`)
82
+ console.log(` • data dir: ${LOOPAT_HOME}${dataExists ? "" : " (absent)"}`)
83
+ if (!hasPodman) console.log(" • note: podman not found — skipping container/image/network cleanup")
84
+ console.log("")
85
+
86
+ if (!yes && !confirm("Proceed? This permanently deletes this workspace's data. [y/N] ")) {
87
+ console.log("Aborted — nothing removed.")
88
+ return
89
+ }
90
+
91
+ // Every resource below is label-scoped to THIS workspace — no shared-resource
92
+ // guessing, so other workspaces on the host are never touched.
93
+ if (hasPodman && containers.length) {
94
+ process.stdout.write(`Removing ${containers.length} container(s)… `)
95
+ await podman(["rm", "-f", ...containers])
96
+ console.log("done")
97
+ }
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")
109
+ }
110
+ if (dataExists) {
111
+ process.stdout.write(`Removing data dir ${LOOPAT_HOME}… `)
112
+ await rm(LOOPAT_HOME, { recursive: true, force: true })
113
+ console.log("done")
114
+ }
115
+
116
+ // Second-layer hints — shared infra we deliberately do NOT touch.
117
+ console.log("")
118
+ console.log("Done. This workspace's resources are gone.")
119
+ console.log("")
120
+ console.log("Left untouched (shared host infra — remove yourself only if loopat was their only user):")
121
+ if (process.platform === "darwin") {
122
+ console.log(" • podman machine (Linux VM): podman machine stop && podman machine rm")
123
+ }
124
+ console.log(` • npx/bun cache: rm -rf ${join(homedir(), ".npm", "_npx")}`)
125
+ console.log("")
126
+ }
127
+
128
+ if (import.meta.main) {
129
+ runUninstall(process.argv.slice(2)).catch((e) => {
130
+ console.error(`[loopat] uninstall failed: ${e?.message ?? e}`)
131
+ process.exit(1)
132
+ })
133
+ }