totopo 3.1.0 → 3.2.0-rc-1

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
@@ -168,7 +168,7 @@ codex # Codex (OpenAI)
168
168
 
169
169
  Agents are self-aware — sandbox constraints, git remote block, and any active shadow path overlays are injected into agent context at every session start.
170
170
 
171
- totopo updates all three CLIs to their latest published versions at most once per 24 hours per workspace.
171
+ totopo updates all three CLIs to their latest published versions automatically when you open a session.
172
172
 
173
173
  ### Persistent Agent Memory
174
174
 
@@ -5,14 +5,14 @@
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
7
  import { join, relative } from "node:path";
8
- import { cancel, isCancel, log, outro, select } from "@clack/prompts";
8
+ import { cancel, confirm, isCancel, log, outro, select } from "@clack/prompts";
9
9
  import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
10
- import { CONTAINER_POST_START, CONTAINER_WORKSPACE, LABEL_MANAGED, LABEL_PROFILE, LABEL_SHADOWS, PROFILE } from "../lib/constants.js";
10
+ import { CONTAINER_STARTUP, CONTAINER_WORKSPACE, LABEL_MANAGED, LABEL_PROFILE, LABEL_SHADOWS, PROFILE } from "../lib/constants.js";
11
11
  import { buildDockerfile, buildImageWithTempfile } from "../lib/dockerfile-builder.js";
12
+ import { isImageStale } from "../lib/migrate-to-latest.js";
12
13
  import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
13
14
  import { readTotopoYaml } from "../lib/totopo-yaml.js";
14
- import { readActiveProfile, readLastCliUpdate, writeActiveProfile, writeLastCliUpdate } from "../lib/workspace-identity.js";
15
- const CLI_UPDATE_THROTTLE_MS = 24 * 60 * 60 * 1000;
15
+ import { readActiveProfile, writeActiveProfile } from "../lib/workspace-identity.js";
16
16
  // --- Prompt: working directory selection -------------------------------------------------------------------------------------------------
17
17
  async function promptWorkdir(workspaceDir, cwd) {
18
18
  if (cwd === workspaceDir)
@@ -82,32 +82,12 @@ function stopAndRemoveContainer(containerName) {
82
82
  spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
83
83
  spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
84
84
  }
85
- // --- Update AI CLIs to latest (runs as root so npm global is writable) -------------------------------------------------------------------
86
- function updateAiClis(containerName) {
87
- log.step("Updating AI CLIs to latest...");
88
- spawnSync("docker", [
89
- "exec",
90
- "-u",
91
- "root",
92
- containerName,
93
- "npm",
94
- "install",
95
- "-g",
96
- "opencode-ai@latest",
97
- "@anthropic-ai/claude-code@latest",
98
- "@openai/codex@latest",
99
- ], { stdio: "inherit" });
100
- }
101
- // --- Run post-start ----------------------------------------------------------------------------------------------------------------------
102
- function runPostStart(containerName) {
103
- log.step("Running post-start checks...");
104
- const postStart = spawnSync("docker", ["exec", containerName, "node", CONTAINER_POST_START], {
85
+ // --- Run startup checks (AI CLI update + readiness validation) ---------------------------------------------------------------------------
86
+ function runStartup(containerName) {
87
+ const result = spawnSync("docker", ["exec", "-u", "root", containerName, "node", CONTAINER_STARTUP], {
105
88
  stdio: "inherit",
106
89
  });
107
- if (postStart.status !== 0) {
108
- outro("Post-start checks failed.");
109
- process.exit(postStart.status ?? 1);
110
- }
90
+ return result.status === 0;
111
91
  }
112
92
  export function startContainer(opts) {
113
93
  const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envFilePath, hasGit, shadowPatterns, workspaceName, noCache, quiet = false, } = opts;
@@ -260,7 +240,7 @@ export async function run(packageDir, ctx, options) {
260
240
  }
261
241
  const hasGit = existsSync(join(workspaceDir, ".git"));
262
242
  // --- Start container -----------------------------------------------------------------------------------------------------------------
263
- const sessionResult = startContainer({
243
+ const containerOpts = {
264
244
  containerName,
265
245
  workspaceRoot: workspaceDir,
266
246
  cacheDir,
@@ -273,18 +253,42 @@ export async function run(packageDir, ctx, options) {
273
253
  shadowPatterns,
274
254
  workspaceName: ctx.displayName,
275
255
  ...(options?.noCache !== undefined && { noCache: options.noCache }),
276
- });
277
- // --- Post-start setup (only on fresh start or resume) --------------------------------------------------------------------------------
278
- if (sessionResult === "created" || sessionResult === "resumed") {
279
- const lastUpdate = readLastCliUpdate(ctx.workspaceId);
280
- if (!lastUpdate || Date.now() - new Date(lastUpdate).getTime() >= CLI_UPDATE_THROTTLE_MS) {
281
- updateAiClis(containerName);
282
- writeLastCliUpdate(ctx.workspaceId, new Date().toISOString());
256
+ };
257
+ startContainer(containerOpts);
258
+ // --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
259
+ let stale = isImageStale(containerName);
260
+ if (stale) {
261
+ const rebuild = await confirm({
262
+ message: "Container image is outdated and needs rebuilding. Agent memory and settings are preserved. Rebuild now?",
263
+ initialValue: true,
264
+ });
265
+ if (isCancel(rebuild)) {
266
+ cancel("Session cancelled.");
267
+ process.exit(0);
268
+ }
269
+ if (rebuild) {
270
+ stopAndRemoveContainer(containerName);
271
+ spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
272
+ startContainer(containerOpts);
273
+ stale = false;
274
+ }
275
+ }
276
+ // --- Startup checks (AI CLI update + readiness validation) ----------------------------------------------------------------------------
277
+ if (!runStartup(containerName)) {
278
+ if (stale) {
279
+ const connect = await confirm({
280
+ message: "Startup checks failed (likely due to outdated image). Connect anyway?",
281
+ initialValue: true,
282
+ });
283
+ if (!connect || isCancel(connect)) {
284
+ cancel("Session cancelled.");
285
+ process.exit(0);
286
+ }
283
287
  }
284
288
  else {
285
- log.info("AI CLIs are up to date.");
289
+ outro("Startup checks failed.");
290
+ process.exit(1);
286
291
  }
287
- runPostStart(containerName);
288
292
  }
289
293
  // --- Connect -------------------------------------------------------------------------------------------------------------------------
290
294
  const exec = spawnSync("docker", ["exec", "-it", "-w", workdir, containerName, "bash", "--login"], {
@@ -21,7 +21,7 @@ export const DEFAULT_SHADOW_PATHS = ["node_modules", ".env*"];
21
21
  export const CONTAINER_USER = "devuser";
22
22
  export const CONTAINER_HOME = `/home/${CONTAINER_USER}`;
23
23
  export const CONTAINER_WORKSPACE = "/workspace";
24
- export const CONTAINER_POST_START = `${CONTAINER_HOME}/post-start.mjs`;
24
+ export const CONTAINER_STARTUP = `${CONTAINER_HOME}/startup.mjs`;
25
25
  // Docker container/image naming
26
26
  export const CONTAINER_NAME_PREFIX = "totopo-";
27
27
  // Docker label keys
@@ -7,7 +7,7 @@ import { randomBytes } from "node:crypto";
7
7
  import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
- import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_POST_START, CONTAINER_USER, LABEL_MANAGED } from "./constants.js";
10
+ import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_STARTUP, CONTAINER_USER, LABEL_MANAGED } from "./constants.js";
11
11
  // --- User shell config appended after USER instruction -----------------------------------------------------------------------------------
12
12
  const USER_SHELL_CONFIG = `
13
13
  # ---------------------------------------------------------------------------
@@ -19,7 +19,7 @@ RUN echo 'export PS1="\\[\\033[01;32m\\][devcontainer]\\[\\033[00m\\] \\[\\033[0
19
19
  echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
20
20
  echo "echo \\" Run 'opencode', 'claude', or 'codex' to start an agent.\\"" >> ${CONTAINER_HOME}/.bashrc && \\
21
21
  echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
22
- echo 'alias status="node ${CONTAINER_POST_START}"' >> ${CONTAINER_HOME}/.bashrc
22
+ echo 'alias status="node ${CONTAINER_STARTUP}"' >> ${CONTAINER_HOME}/.bashrc
23
23
 
24
24
  CMD ["/bin/bash"]
25
25
  `;
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { log } from "@clack/prompts";
26
26
  import { load as loadYaml } from "js-yaml";
27
- import { AGENTS_DIR, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
27
+ import { AGENTS_DIR, CONTAINER_STARTUP, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
28
28
  import { safeRmSync } from "./safe-rm.js";
29
29
  import { buildDefaultTotopoYaml, readTotopoYaml, slugifyForWorkspaceId, validateWorkspaceId, writeTotopoYaml, } from "./totopo-yaml.js";
30
30
  import { findTotopoYamlDir, getWorkspacesBaseDir, initWorkspaceDir, LOCK_KEYS } from "./workspace-identity.js";
@@ -296,7 +296,7 @@ function migrateLockFileFormat() {
296
296
  if (!firstLine || firstLine.includes("="))
297
297
  continue; // empty or already new format
298
298
  const activeProfile = secondLine ?? PROFILE.default;
299
- writeFileSync(lockPath, `${LOCK_KEYS.workspaceRoot}=${firstLine}\n${LOCK_KEYS.activeProfile}=${activeProfile}\n${LOCK_KEYS.lastCliUpdate}=\n`);
299
+ writeFileSync(lockPath, `${LOCK_KEYS.workspaceRoot}=${firstLine}\n${LOCK_KEYS.activeProfile}=${activeProfile}\n`);
300
300
  }
301
301
  catch {
302
302
  // unreadable -- skip, will surface as a broken workspace elsewhere
@@ -325,6 +325,32 @@ function migrateLockKeyYamlToRoot() {
325
325
  }
326
326
  }
327
327
  }
328
+ /**
329
+ * v3.1.0 and earlier: Remove the "last-cli-update" key from .lock files.
330
+ * CLI update timestamps are now managed inside the container via /home/devuser/.ai-cli-updated.
331
+ * Detects old format by presence of "last-cli-update=" in the file content. Idempotent.
332
+ */
333
+ function migrateRemoveLastCliUpdate() {
334
+ const baseDir = getWorkspacesBaseDir();
335
+ if (!existsSync(baseDir))
336
+ return;
337
+ for (const entry of readdirSync(baseDir)) {
338
+ const lockPath = join(baseDir, entry, LOCK_FILE);
339
+ try {
340
+ const content = readFileSync(lockPath, "utf8");
341
+ if (!content.includes("last-cli-update="))
342
+ continue;
343
+ const filtered = content
344
+ .split("\n")
345
+ .filter((line) => !line.startsWith("last-cli-update="))
346
+ .join("\n");
347
+ writeFileSync(lockPath, filtered);
348
+ }
349
+ catch {
350
+ // unreadable -- skip, will surface as a broken workspace elsewhere
351
+ }
352
+ }
353
+ }
328
354
  // Order matters: migrateProjectsDir must run before migrateV2Workspaces because
329
355
  // step 2 scans ~/.totopo/workspaces/ which only exists after step 1 renames projects/.
330
356
  // Steps 3 and 4 are independent of each other and of steps 1-2.
@@ -338,6 +364,7 @@ const MIGRATIONS = [
338
364
  { from: "v2.x", description: "Remove legacy ~/.totopo/.env global key file", run: migrateGlobalEnv },
339
365
  { from: "v3-rc-6", description: "Upgrade .lock files from positional to key=value format", run: migrateLockFileFormat },
340
366
  { from: "v3-rc-8", description: "Rename 'yaml' key to 'root' in .lock files", run: migrateLockKeyYamlToRoot },
367
+ { from: "v3.1.0", description: "Remove last-cli-update key from .lock files", run: migrateRemoveLastCliUpdate },
341
368
  ];
342
369
  /** Run all migrations in order. Called early in bin/totopo.js startup. */
343
370
  export function runMigration(cwd) {
@@ -345,3 +372,16 @@ export function runMigration(cwd) {
345
372
  migration.run(cwd);
346
373
  }
347
374
  }
375
+ // =========================================================================================================================================
376
+ // Image staleness detection
377
+ // Called at session start to detect outdated container images that need rebuilding.
378
+ // Add new conditions here when the base image changes in future releases.
379
+ // =========================================================================================================================================
380
+ /** Check if a running container's image is stale (missing expected files/features). */
381
+ export function isImageStale(containerName) {
382
+ // v3.2.0: startup.mjs replaced post-start.mjs + update-ai-clis.mjs
383
+ const check = spawnSync("docker", ["exec", containerName, "test", "-f", CONTAINER_STARTUP], { stdio: "pipe" });
384
+ if (check.status !== 0)
385
+ return true;
386
+ return false;
387
+ }
@@ -11,7 +11,6 @@ import { readTotopoYaml } from "./totopo-yaml.js";
11
11
  export const LOCK_KEYS = {
12
12
  workspaceRoot: "root",
13
13
  activeProfile: "profile",
14
- lastCliUpdate: "last-cli-update",
15
14
  };
16
15
  /** Reverse lookup: file key → LockFile field name, used during parsing. */
17
16
  const FILE_KEY_TO_FIELD = Object.fromEntries(Object.entries(LOCK_KEYS).map(([field, key]) => [key, field]));
@@ -54,7 +53,6 @@ function parseLockFile(workspaceId) {
54
53
  return {
55
54
  workspaceRoot: partial.workspaceRoot,
56
55
  activeProfile: partial.activeProfile ?? PROFILE.default,
57
- lastCliUpdate: partial.lastCliUpdate ?? "",
58
56
  };
59
57
  }
60
58
  catch {
@@ -72,44 +70,32 @@ function writeLockFileInternal(workspaceId, data) {
72
70
  export function readLockFile(workspaceId) {
73
71
  return parseLockFile(workspaceId)?.workspaceRoot ?? null;
74
72
  }
75
- /** Write a workspace's lock file with the owning workspace root path. Preserves active profile and lastCliUpdate. */
73
+ /** Write a workspace's lock file with the owning workspace root path. Preserves active profile. */
76
74
  export function writeLockFile(workspaceId, workspaceRoot) {
77
75
  const existing = parseLockFile(workspaceId);
78
76
  writeLockFileInternal(workspaceId, {
79
77
  workspaceRoot,
80
78
  activeProfile: existing?.activeProfile ?? PROFILE.default,
81
- lastCliUpdate: existing?.lastCliUpdate ?? "",
82
79
  });
83
80
  }
84
81
  /** Read the active profile name. Returns null if lock file is missing. */
85
82
  export function readActiveProfile(workspaceId) {
86
83
  return parseLockFile(workspaceId)?.activeProfile ?? null;
87
84
  }
88
- /** Write the active profile name. Preserves workspace root path and lastCliUpdate. */
85
+ /** Write the active profile name. Preserves workspace root path. */
89
86
  export function writeActiveProfile(workspaceId, profile) {
90
87
  const existing = parseLockFile(workspaceId);
91
88
  if (!existing)
92
89
  return;
93
90
  writeLockFileInternal(workspaceId, { ...existing, activeProfile: profile });
94
91
  }
95
- /** Read the last CLI update timestamp. Returns empty string if lock file is missing or field was never set. */
96
- export function readLastCliUpdate(workspaceId) {
97
- return parseLockFile(workspaceId)?.lastCliUpdate ?? "";
98
- }
99
- /** Write the last CLI update timestamp. Preserves all other fields. No-op if lock file is missing. */
100
- export function writeLastCliUpdate(workspaceId, timestamp) {
101
- const existing = parseLockFile(workspaceId);
102
- if (!existing)
103
- return;
104
- writeLockFileInternal(workspaceId, { ...existing, lastCliUpdate: timestamp });
105
- }
106
92
  // --- Workspace directory initialization --------------------------------------------------------------------------------------------------
107
93
  /** Initialize ~/.totopo/workspaces/<workspace_id>/ with lock file and subdirs. */
108
94
  export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = PROFILE.default) {
109
95
  const dir = getWorkspaceDir(workspaceId);
110
96
  mkdirSync(join(dir, AGENTS_DIR), { recursive: true });
111
97
  mkdirSync(join(dir, SHADOWS_DIR), { recursive: true });
112
- writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile, lastCliUpdate: "" });
98
+ writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile });
113
99
  }
114
100
  // --- Listing -----------------------------------------------------------------------------------------------------------------------------
115
101
  /** List all registered workspace IDs (directories with a .lock file) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.1.0",
3
+ "version": "3.2.0-rc-1",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,13 +86,14 @@ RUN npm install -g \
86
86
  RUN groupadd --gid 1001 devuser && \
87
87
  useradd --uid 1001 --gid devuser --shell /bin/bash --create-home devuser && \
88
88
  mkdir -p /home/devuser/.local/state /home/devuser/.local/share /home/devuser/.local/bin && \
89
+ date -u +"%Y-%m-%dT%H:%M:%S.000Z" > /home/devuser/.ai-cli-updated && \
89
90
  chown -R devuser:devuser /home/devuser
90
91
 
91
92
  # ---------------------------------------------------------------------------
92
- # Layer 9 — Bake post-start script into image
93
+ # Layer 9 — Bake startup script into image
93
94
  # ---------------------------------------------------------------------------
94
- COPY post-start.mjs /home/devuser/post-start.mjs
95
- RUN chown devuser:devuser /home/devuser/post-start.mjs
95
+ COPY startup.mjs /home/devuser/startup.mjs
96
+ RUN chown devuser:devuser /home/devuser/startup.mjs
96
97
 
97
98
  WORKDIR /workspace
98
99
 
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // =============================================================================
3
- // post-start.mjs Security validation & readiness check
4
- // Baked into the container image at /home/devuser/post-start.mjs
5
- // Must use only Node.js built-ins no external packages available in container.
3
+ // startup.mjs -- Container startup: AI CLI updates + readiness checks
4
+ // Baked into the container image at /home/devuser/startup.mjs
5
+ // Must run as root (npm global install requires root).
6
+ // Must use only Node.js built-ins -- no external packages available in container.
6
7
  // =============================================================================
7
8
 
8
9
  import { execSync } from "node:child_process";
10
+ import { readFileSync, writeFileSync } from "node:fs";
9
11
 
10
12
  const run = (cmd) => {
11
13
  try {
@@ -18,18 +20,17 @@ const run = (cmd) => {
18
20
  }
19
21
  };
20
22
 
21
- // ─── ANSI helpers ─────────────────────────────────────────────────────────────
23
+ // -- ANSI helpers -------------------------------------------------------------
22
24
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
23
25
  const _yellow = (s) => `\x1b[33m${s}\x1b[0m`;
24
26
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
25
27
  const blue = (s) => `\x1b[34m${s}\x1b[0m`;
26
28
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
27
29
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
30
+ const grey = (s) => `\x1b[90m${s}\x1b[0m`;
28
31
 
29
32
  let errors = 0;
30
33
 
31
- const grey = (s) => `\x1b[90m${s}\x1b[0m`;
32
-
33
34
  const ok = (label, detail) => console.log(`${green("✓")} ${label.padEnd(24)}${detail ? dim(detail) : ""}`);
34
35
  const skip = (label, detail) => console.log(`${grey("–")} ${grey(label.padEnd(24))}${detail ? grey(detail) : ""}`);
35
36
  const fail = (label, detail) => {
@@ -38,53 +39,80 @@ const fail = (label, detail) => {
38
39
  };
39
40
  const section = (title) => console.log(`\n${bold(title)}`);
40
41
 
41
- // ─── Header ──────────────────────────────────────────────────────────────────
42
- console.log(`\n${bold("totopo Secure AI Box")}\n`);
42
+ // -- Header -------------------------------------------------------------------
43
+ console.log(`\n${bold("totopo -- Secure AI Box")}\n`);
44
+
45
+ // -- AI CLI update (requires root - skipped when run via 'status' alias as devuser) -
46
+ section("AI CLI update");
47
+
48
+ const isRoot = process.getuid?.() === 0;
49
+ const TIMESTAMP_FILE = "/home/devuser/.ai-cli-updated";
50
+ const THROTTLE_MS = 24 * 60 * 60 * 1000;
51
+
52
+ let lastUpdate = 0;
53
+ try {
54
+ const raw = readFileSync(TIMESTAMP_FILE, "utf8").trim();
55
+ lastUpdate = new Date(raw).getTime();
56
+ } catch {
57
+ // File missing or unreadable -- treat as never updated
58
+ }
59
+
60
+ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
61
+ ok("AI CLIs", "up to date");
62
+ } else if (!isRoot) {
63
+ skip("AI CLIs", "update skipped (requires root)");
64
+ } else {
65
+ console.log(`${blue("●")} ${dim("Updating AI CLIs to latest...")}`);
66
+ try {
67
+ execSync("npm install -g opencode-ai@latest @anthropic-ai/claude-code@latest @openai/codex@latest", {
68
+ stdio: "inherit",
69
+ });
70
+ writeFileSync(TIMESTAMP_FILE, `${new Date().toISOString()}\n`);
71
+ ok("AI CLIs", "updated");
72
+ } catch {
73
+ fail("AI CLIs", "update failed -- continuing with existing versions");
74
+ }
75
+ }
43
76
 
44
- // ─── Security ────────────────────────────────────────────────────────────────
77
+ // -- Security -----------------------------------------------------------------
45
78
  section("Security");
46
79
 
47
- const whoami = run("whoami");
48
- if (whoami !== "root") {
49
- ok("non-root user", whoami ?? "unknown");
80
+ const idOutput = run("id devuser");
81
+ if (idOutput?.includes("uid=1001")) {
82
+ ok("non-root user", "devuser (uid=1001)");
50
83
  } else {
51
- fail("non-root user", "running as root container is misconfigured");
84
+ fail("non-root user", "devuser not found or wrong uid -- container is misconfigured");
52
85
  }
53
86
 
54
87
  const gitProtocol = run("git config --system protocol.allow");
55
88
  if (gitProtocol === "never") {
56
89
  ok("git remote block", "protocol.allow = never");
57
90
  } else {
58
- fail("git remote block", "not set rebuild the container");
91
+ fail("git remote block", "not set -- rebuild the container");
59
92
  }
60
93
 
61
94
  try {
62
95
  execSync("/usr/bin/git -C /workspace push", { stdio: "pipe" });
63
- fail("push blocked", "git push succeeded remote access is NOT blocked");
96
+ fail("push blocked", "git push succeeded -- remote access is NOT blocked");
64
97
  } catch {
65
98
  ok("push blocked", "remote push not possible");
66
99
  }
67
100
 
68
- // ─── AI tools ────────────────────────────────────────────────────────────────
101
+ // -- AI tools -----------------------------------------------------------------
69
102
  section("AI tools");
70
103
 
71
- const aiToolResults = [];
72
-
73
104
  const checkTool = (cmd) => {
74
105
  const out = run(`${cmd} --version`);
75
106
  if (out !== null && out.trim() !== "") {
76
107
  const version = out.split("\n")[0];
77
108
  ok(cmd, version);
78
- aiToolResults.push({ cmd, version, found: true });
79
109
  return;
80
110
  }
81
111
  const which = run(`which ${cmd}`);
82
112
  if (which !== null) {
83
113
  ok(cmd, "installed");
84
- aiToolResults.push({ cmd, version: "installed", found: true });
85
114
  } else {
86
- fail(cmd, "not found rebuild container");
87
- aiToolResults.push({ cmd, version: null, found: false });
115
+ fail(cmd, "not found -- rebuild container");
88
116
  }
89
117
  };
90
118
 
@@ -92,7 +120,7 @@ checkTool("opencode");
92
120
  checkTool("claude");
93
121
  checkTool("codex");
94
122
 
95
- // ─── Runtimes ────────────────────────────────────────────────────────────────
123
+ // -- Runtimes -----------------------------------------------------------------
96
124
  section("Runtimes");
97
125
 
98
126
  const checkRuntime = (label, version) => {
@@ -115,7 +143,7 @@ checkRuntime("go", run("go version"));
115
143
  checkRuntime("cargo", run("cargo --version"));
116
144
  checkRuntime("java", run("java --version")?.split("\n")[0] ?? null);
117
145
 
118
- // ─── Dev tools ───────────────────────────────────────────────────────────────
146
+ // -- Dev tools ----------------------------------------------------------------
119
147
  section("Dev tools");
120
148
 
121
149
  ok("gh", run("gh --version")?.split("\n")[0] ?? "not found");
@@ -125,7 +153,7 @@ ok("fzf", run("fzf --version") ?? "not found");
125
153
  ok("jq", run("jq --version") ?? "not found");
126
154
  ok("yq", run("yq --version") ?? "not found");
127
155
 
128
- // ─── Database tools ──────────────────────────────────────────────────────────
156
+ // -- Database tools -----------------------------------------------------------
129
157
  section("Database tools");
130
158
 
131
159
  ok("sqlite3", run("sqlite3 --version")?.split(" ").slice(0, 2).join(" ") ?? "not found");
@@ -133,21 +161,21 @@ ok("psql", run("psql --version") ?? "not found");
133
161
  ok("mysql", run("mysql --version") ?? "not found");
134
162
  ok("redis-cli", run("redis-cli --version") ?? "not found");
135
163
 
136
- // ─── API keys ────────────────────────────────────────────────────────────────
164
+ // -- API keys -----------------------------------------------------------------
137
165
  section("API keys");
138
166
 
139
167
  console.log(`${blue("●")} ${dim("API keys are injected via env_file in totopo.yaml. Set env_file to point to your .env file.")}`);
140
168
 
141
- // ─── Summary ─────────────────────────────────────────────────────────────────
169
+ // -- Summary ------------------------------------------------------------------
142
170
  if (errors === 0) {
143
- const workspaceSuffix = process.env.TOTOPO_WORKSPACE ? ` workspace: ${bold(process.env.TOTOPO_WORKSPACE)}` : "";
171
+ const workspaceSuffix = process.env.TOTOPO_WORKSPACE ? ` -- workspace: ${bold(process.env.TOTOPO_WORKSPACE)}` : "";
144
172
  console.log(`\n${blue("●")} ${bold("totopo dev container ready")}${workspaceSuffix}`);
145
173
  console.log(
146
- `${grey(" To adjust settings, ask any agent about")} ${bold("totopo.yaml")} ${grey(" it lives in the workspace root.")}\n`,
174
+ `${grey(" To adjust settings, ask any agent about")} ${bold("totopo.yaml")} ${grey("-- it lives in the workspace root.")}\n`,
147
175
  );
148
176
  console.log(`${green("●")} ${bold("Ready.")}`);
149
177
  console.log(`${grey("Type 'status' to re-run the readiness check.")}\n`);
150
178
  } else {
151
- console.log(`\n${red("●")} ${bold(`${errors} error(s) see above. Rebuild the container to fix.`)}\n`);
179
+ console.log(`\n${red("●")} ${bold(`${errors} error(s) -- see above. Rebuild the container to fix.`)}\n`);
152
180
  process.exit(1);
153
181
  }