totopo 2.1.0 → 3.0.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/bin/totopo.js CHANGED
@@ -17,8 +17,7 @@ import { run as onboard } from "../dist/commands/onboard.js";
17
17
  import { run as rebuild } from "../dist/commands/rebuild.js";
18
18
  import { run as settings } from "../dist/commands/settings.js";
19
19
  import { run as stop } from "../dist/commands/stop.js";
20
- import { run as syncDockerfile } from "../dist/commands/sync-dockerfile.js";
21
- import { listProjectIds, resolveProject, TOTOPO_YAML } from "../dist/lib/project-identity.js";
20
+ import { findTotopoYamlDir, listProjectIds, resolveProject } from "../dist/lib/project-identity.js";
22
21
 
23
22
  // --- Guard: inside container -------------------------------------------------------------------------------------------------------------
24
23
  try {
@@ -36,12 +35,11 @@ try {
36
35
  }
37
36
 
38
37
  // --- Paths -------------------------------------------------------------------------------------------------------------------------------
39
- // dirname(dirname(...)) walks up from bin/ to the package root.
40
38
  const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
41
39
  const cwd = process.cwd();
42
40
 
43
41
  // --- Guard: dist/ must exist -------------------------------------------------------------------------------------------------------------
44
- if (!existsSync(new URL("../dist/commands/sync-dockerfile.js", import.meta.url))) {
42
+ if (!existsSync(new URL("../dist/commands/dev.js", import.meta.url))) {
45
43
  console.error("");
46
44
  console.error(" totopo: compiled output not found.");
47
45
  console.error(" This should not happen with a published package.");
@@ -50,7 +48,15 @@ if (!existsSync(new URL("../dist/commands/sync-dockerfile.js", import.meta.url))
50
48
  process.exit(1);
51
49
  }
52
50
 
53
- // --- Resolve project from CWD (walk-up through ~/.totopo/projects/) ----------------------------------------------------------------------
51
+ // --- v2 migration check ------------------------------------------------------------------------------------------------------------------
52
+ try {
53
+ const { runMigration } = await import("../dist/lib/migrate-v2.js");
54
+ await runMigration();
55
+ } catch {
56
+ // migrate-v2 module may not exist yet during development - that's fine
57
+ }
58
+
59
+ // --- Resolve project from CWD (walk-up looking for totopo.yaml) --------------------------------------------------------------------------
54
60
  let project = resolveProject(cwd);
55
61
 
56
62
  // --- Onboarding (if not in a registered project) -----------------------------------------------------------------------------------------
@@ -63,10 +69,9 @@ if (!project) {
63
69
  // Not in a git repo - that's fine
64
70
  }
65
71
 
66
- const totopoJsonPath = `${gitRoot ?? cwd}/${TOTOPO_YAML}`;
67
- const hasTotopoYaml = existsSync(totopoJsonPath);
72
+ const hasContext = gitRoot !== null || findTotopoYamlDir(cwd) !== null;
68
73
 
69
- if (gitRoot !== null || hasTotopoYaml) {
74
+ if (hasContext) {
70
75
  // Has project context - if other projects already exist, let the user choose first
71
76
  if (listProjectIds().length > 0) {
72
77
  process.stdout.write("\n");
@@ -82,26 +87,23 @@ if (!project) {
82
87
  process.exit(0);
83
88
  }
84
89
  if (choice === "manage") {
85
- await advanced(packageDir);
90
+ await advanced();
86
91
  process.exit(0);
87
92
  }
88
93
  }
89
94
 
90
- const ctx = await onboard(packageDir, cwd);
91
- if (!ctx) process.exit(0); // cancelled -> exit cleanly
95
+ const ctx = await onboard(cwd);
96
+ if (!ctx) process.exit(0);
92
97
  project = ctx;
93
98
  } else {
94
99
  // No project context -> show Manage totopo menu directly
95
- await advanced(packageDir);
100
+ await advanced();
96
101
  process.exit(0);
97
102
  }
98
103
  }
99
104
 
100
- // --- Sync Dockerfile with host runtimes --------------------------------------------------------------------------------------------------
101
- await syncDockerfile(packageDir, project);
102
-
103
105
  // --- Doctor (silent pre-check) -----------------------------------------------------------------------------------------------------------
104
- const doctorResult = await doctor(project.projectDir, false);
106
+ const doctorResult = await doctor(null, false);
105
107
  if (!doctorResult.ok) {
106
108
  console.error(" Fix the issues above and re-run totopo.");
107
109
  console.error("");
@@ -109,7 +111,7 @@ if (!doctorResult.ok) {
109
111
  }
110
112
 
111
113
  // --- Gather container state for menu -----------------------------------------------------------------------------------------------------
112
- const { containerName } = project.meta;
114
+ const { containerName } = project;
113
115
 
114
116
  const dockerResult = spawnSync("docker", ["ps", "--filter", "name=totopo-", "--format", "{{.Names}}"], {
115
117
  encoding: "utf8",
@@ -130,22 +132,22 @@ while (showMenu) {
130
132
  await dev(packageDir, project);
131
133
  break;
132
134
  case "rebuild":
133
- await rebuild(project.meta.containerName);
135
+ await rebuild(project.containerName);
134
136
  await dev(packageDir, project);
135
137
  break;
136
138
  case "stop":
137
- await stop(project.meta.containerName);
139
+ await stop(project.containerName);
138
140
  break;
139
141
  case "settings":
140
- await settings(packageDir, project);
142
+ await settings(project);
141
143
  showMenu = true;
142
144
  break;
143
145
  case "manage-totopo": {
144
- const result = await advanced(packageDir, project.id);
146
+ const result = await advanced(project.projectId);
145
147
  if (result === "back") showMenu = true;
146
148
  break;
147
149
  }
148
150
  default:
149
- break; // quit or cancelled
151
+ break;
150
152
  }
151
153
  }
@@ -1,9 +1,8 @@
1
1
  // =========================================================================================================================================
2
2
  // src/commands/advanced.ts - Manage totopo menu (global, all projects)
3
- // Invoked by bin/totopo.js - shown directly when outside a project, or via "Manage totopo" from the project menu.
4
3
  // =========================================================================================================================================
5
4
  import { spawnSync } from "node:child_process";
6
- import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
5
+ import { existsSync, rmSync } from "node:fs";
7
6
  import { homedir } from "node:os";
8
7
  import { join } from "node:path";
9
8
  import { cancel, confirm, isCancel, log, multiselect, outro, select, text } from "@clack/prompts";
@@ -11,8 +10,8 @@ import { listProjects } from "../lib/project-identity.js";
11
10
  import { run as runDoctor } from "./doctor.js";
12
11
  // --- Helpers -----------------------------------------------------------------------------------------------------------------------------
13
12
  function stopAndRemoveContainer(name) {
14
- spawnSync("docker", ["stop", name], { stdio: "inherit" });
15
- spawnSync("docker", ["rm", name], { stdio: "inherit" });
13
+ spawnSync("docker", ["stop", name], { stdio: "pipe" });
14
+ spawnSync("docker", ["rm", name], { stdio: "pipe" });
16
15
  }
17
16
  // --- Stop containers (multi-select across all projects) ----------------------------------------------------------------------------------
18
17
  async function stopContainers() {
@@ -54,18 +53,18 @@ async function clearAgentMemory() {
54
53
  log.info("No agent memory found.");
55
54
  return;
56
55
  }
57
- let toClear; // project IDs
56
+ let toClear;
58
57
  if (projects.length === 1) {
59
58
  const p = projects[0];
60
59
  if (p === undefined)
61
60
  return;
62
- toClear = [p.id];
63
- log.info(`Clearing agent memory for ${p.meta.displayName}...`);
61
+ toClear = [p.projectId];
62
+ log.info(`Clearing agent memory for ${p.displayName}...`);
64
63
  }
65
64
  else {
66
65
  const selected = await multiselect({
67
66
  message: "Select projects to clear agent memory for:",
68
- options: projects.map((p) => ({ value: p.id, label: p.meta.displayName, hint: p.meta.projectRoot })),
67
+ options: projects.map((p) => ({ value: p.projectId, label: p.displayName, hint: p.projectRoot })),
69
68
  required: false,
70
69
  });
71
70
  if (isCancel(selected)) {
@@ -75,27 +74,26 @@ async function clearAgentMemory() {
75
74
  toClear = selected;
76
75
  }
77
76
  for (const id of toClear) {
78
- const p = projects.find((x) => x.id === id);
77
+ const p = projects.find((x) => x.projectId === id);
79
78
  if (!p)
80
79
  continue;
81
- // Stop the container first if running
82
- const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", p.meta.containerName], {
80
+ const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", p.containerName], {
83
81
  encoding: "utf8",
84
82
  stdio: "pipe",
85
83
  });
86
84
  const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
87
85
  if (isRunning) {
88
86
  const confirmed = await confirm({
89
- message: `Container for ${p.meta.displayName} is running. Stop it to clear memory?`,
87
+ message: `Container for ${p.displayName} is running. Stop it to clear memory?`,
90
88
  });
91
89
  if (isCancel(confirmed) || !confirmed)
92
90
  continue;
93
- log.step(`Stopping ${p.meta.containerName}...`);
94
- stopAndRemoveContainer(p.meta.containerName);
91
+ log.step(`Stopping ${p.containerName}...`);
92
+ stopAndRemoveContainer(p.containerName);
95
93
  }
96
94
  const agentsDir = join(p.projectDir, "agents");
97
95
  rmSync(agentsDir, { recursive: true, force: true });
98
- log.success(`Cleared agent memory for ${p.meta.displayName}.`);
96
+ log.success(`Cleared agent memory for ${p.displayName}.`);
99
97
  }
100
98
  }
101
99
  // --- Remove images (multi-select across all projects) ------------------------------------------------------------------------------------
@@ -123,8 +121,8 @@ async function removeImages() {
123
121
  cancel();
124
122
  return;
125
123
  }
124
+ // Stop any running container using this image first
126
125
  for (const repo of selected) {
127
- // Stop any running container using this image first
128
126
  const psResult = spawnSync("docker", ["ps", "--filter", `name=${repo}`, "--format", "{{.Names}}"], {
129
127
  encoding: "utf8",
130
128
  });
@@ -134,24 +132,10 @@ async function removeImages() {
134
132
  stopAndRemoveContainer(c);
135
133
  }
136
134
  log.step(`Removing image ${repo}...`);
137
- spawnSync("docker", ["rmi", repo], { stdio: "inherit" });
135
+ spawnSync("docker", ["rmi", repo], { stdio: "pipe" });
138
136
  }
139
137
  log.success("Done.");
140
138
  }
141
- // --- Reset API keys ----------------------------------------------------------------------------------------------------------------------
142
- async function resetApiKeys(packageDir) {
143
- const globalEnvPath = join(homedir(), ".totopo", ".env");
144
- const confirmed = await confirm({
145
- message: `Reset ${globalEnvPath}? This affects all totopo projects on this machine.`,
146
- });
147
- if (isCancel(confirmed) || !confirmed) {
148
- cancel("Cancelled.");
149
- return;
150
- }
151
- mkdirSync(join(homedir(), ".totopo"), { recursive: true });
152
- cpSync(join(packageDir, "templates", "env"), globalEnvPath);
153
- log.success(`API keys reset. Edit ${globalEnvPath} to add your keys.`);
154
- }
155
139
  // --- Uninstall projects (multi-select, remove container + image + project dir) -----------------------------------------------------------
156
140
  async function uninstallProjects(currentProjectId) {
157
141
  const projects = listProjects();
@@ -161,14 +145,14 @@ async function uninstallProjects(currentProjectId) {
161
145
  }
162
146
  // Show current project first if known
163
147
  const sorted = currentProjectId
164
- ? [...projects].sort((a, b) => (a.id === currentProjectId ? -1 : b.id === currentProjectId ? 1 : 0))
148
+ ? [...projects].sort((a, b) => (a.projectId === currentProjectId ? -1 : b.projectId === currentProjectId ? 1 : 0))
165
149
  : projects;
166
150
  const selected = await multiselect({
167
151
  message: "Select projects to uninstall:",
168
152
  options: sorted.map((p) => ({
169
- value: p.id,
170
- label: p.meta.displayName,
171
- hint: p.meta.projectRoot + (p.id === currentProjectId ? " (current)" : ""),
153
+ value: p.projectId,
154
+ label: p.displayName,
155
+ hint: p.projectRoot + (p.projectId === currentProjectId ? " (current)" : ""),
172
156
  })),
173
157
  required: false,
174
158
  });
@@ -178,11 +162,11 @@ async function uninstallProjects(currentProjectId) {
178
162
  }
179
163
  const selectedIds = selected;
180
164
  for (const id of selectedIds) {
181
- const p = projects.find((x) => x.id === id);
165
+ const p = projects.find((x) => x.projectId === id);
182
166
  if (!p)
183
167
  continue;
184
168
  // Stop and remove container if it exists (running or exited)
185
- const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${p.meta.containerName}`, "--format", "{{.Names}}"], {
169
+ const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${p.containerName}`, "--format", "{{.Names}}"], {
186
170
  encoding: "utf8",
187
171
  });
188
172
  const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
@@ -190,16 +174,15 @@ async function uninstallProjects(currentProjectId) {
190
174
  log.step(`Stopping and removing container ${c}...`);
191
175
  stopAndRemoveContainer(c);
192
176
  }
193
- // Remove Docker image if it exists (image name = container name)
194
- log.step(`Removing image ${p.meta.containerName}...`);
195
- spawnSync("docker", ["rmi", p.meta.containerName], { stdio: "inherit" });
177
+ log.step(`Removing image ${p.containerName}...`);
178
+ spawnSync("docker", ["rmi", p.containerName], { stdio: "inherit" });
196
179
  // Delete project directory
197
180
  rmSync(p.projectDir, { recursive: true, force: true });
198
- log.success(`Uninstalled project ${p.meta.displayName}.`);
181
+ log.success(`Uninstalled project ${p.displayName}.`);
199
182
  }
200
183
  return currentProjectId !== undefined && selectedIds.includes(currentProjectId);
201
184
  }
202
- // --- Uninstall totopo (global - wipes ~/.totopo/ and all containers/images) --------------------------------------------------------------
185
+ // --- Uninstall totopo (global) -----------------------------------------------------------------------------------------------------------
203
186
  async function uninstallTotopo() {
204
187
  const confirmed = await text({
205
188
  message: 'Type "uninstall-totopo" to confirm full uninstall:',
@@ -236,7 +219,7 @@ async function uninstallTotopo() {
236
219
  outro("totopo uninstalled. Re-run npx totopo to set up again.");
237
220
  }
238
221
  // --- Manage totopo menu ------------------------------------------------------------------------------------------------------------------
239
- export async function run(packageDir, currentProjectId) {
222
+ export async function run(currentProjectId) {
240
223
  while (true) {
241
224
  const action = await select({
242
225
  message: "Manage totopo:",
@@ -244,7 +227,6 @@ export async function run(packageDir, currentProjectId) {
244
227
  { value: "stop-containers", label: "Stop containers", hint: "pick running containers" },
245
228
  { value: "clear-memory", label: "Clear agent memory", hint: "pick projects to clear" },
246
229
  { value: "remove-images", label: "Remove images", hint: "pick images to remove" },
247
- { value: "reset-keys", label: "Reset API keys", hint: "overwrites ~/.totopo/.env" },
248
230
  { value: "doctor", label: "Doctor", hint: "check Docker health" },
249
231
  { value: "uninstall-project", label: "Uninstall project", hint: "pick projects to remove" },
250
232
  { value: "uninstall", label: "Uninstall totopo", hint: "wipe ~/.totopo/ and all containers/images" },
@@ -264,9 +246,6 @@ export async function run(packageDir, currentProjectId) {
264
246
  case "remove-images":
265
247
  await removeImages();
266
248
  break;
267
- case "reset-keys":
268
- await resetApiKeys(packageDir);
269
- break;
270
249
  case "uninstall-project": {
271
250
  const currentDeleted = await uninstallProjects(currentProjectId);
272
251
  if (currentDeleted)
@@ -1,17 +1,16 @@
1
1
  // =========================================================================================================================================
2
2
  // src/commands/dev.ts - Start the dev container and connect via docker exec
3
- // Invoked by bin/totopo.js - do not run directly.
3
+ // In-memory Dockerfile build, profile selection, pattern-based shadows, env_file handling.
4
4
  // =========================================================================================================================================
5
5
  import { spawnSync } from "node:child_process";
6
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
6
+ import { existsSync } from "node:fs";
8
7
  import { join, relative } from "node:path";
9
8
  import { cancel, isCancel, log, outro, select } from "@clack/prompts";
10
9
  import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
11
- import { readSettings } from "../lib/config.js";
12
- import { ensureShadowsInSync } from "../lib/shadows.js";
13
- // The project config dir is always mounted here inside the container (read-only)
14
- const TOTOPO_CONTAINER_PATH = "/home/devuser/.totopo";
10
+ import { buildDockerfile, buildImageWithTempfile } from "../lib/dockerfile-builder.js";
11
+ import { readActiveProfile, writeActiveProfile } from "../lib/project-identity.js";
12
+ import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
13
+ import { readTotopoYaml } from "../lib/totopo-yaml.js";
15
14
  // --- Prompt: working directory selection -------------------------------------------------------------------------------------------------
16
15
  async function promptWorkdir(workspaceDir, cwd) {
17
16
  if (cwd === workspaceDir)
@@ -30,64 +29,45 @@ async function promptWorkdir(workspaceDir, cwd) {
30
29
  }
31
30
  return choice === "here" ? `/workspace/${relPath}` : "/workspace";
32
31
  }
33
- // --- Build shadow mount args -------------------------------------------------------------------------------------------------------------
34
- function buildShadowMountArgs(projectDir, workspaceDir) {
35
- const settings = readSettings(projectDir);
36
- const shadowPaths = settings.shadowPaths;
37
- // Sync shadows/ dir with settings (create missing, remove stale, preserve existing)
38
- ensureShadowsInSync(projectDir, workspaceDir);
39
- const args = [];
40
- for (const relPath of shadowPaths) {
41
- args.push("-v", `${join(projectDir, "shadows", relPath)}:/workspace/${relPath}`);
32
+ // --- Profile selection -------------------------------------------------------------------------------------------------------------------
33
+ async function selectProfile(ctx, profiles) {
34
+ const profileNames = Object.keys(profiles);
35
+ if (profileNames.length <= 1) {
36
+ return profileNames[0] ?? "default";
42
37
  }
43
- return { args, shadowPaths };
44
- }
45
- // --- Build mount args --------------------------------------------------------------------------------------------------------------------
46
- // Project config dir is always explicitly mounted - it's never inside the workspace.
47
- function buildMountArgs(workspaceDir, projectDir) {
48
- const agentMounts = buildAgentMountArgs(projectDir);
49
- const configMount = ["-v", `${projectDir}:${TOTOPO_CONTAINER_PATH}:ro`];
50
- const { args: shadowArgs, shadowPaths } = buildShadowMountArgs(projectDir, workspaceDir);
51
- // Shadow mounts must come AFTER the workspace mount to overlay correctly
52
- return {
53
- mountArgs: ["-v", `${workspaceDir}:/workspace`, ...shadowArgs, ...configMount, ...agentMounts],
54
- shadowPaths,
55
- };
56
- }
57
- // --- Run post-start ----------------------------------------------------------------------------------------------------------------------
58
- function runPostStart(containerName) {
59
- log.step("Running post-start checks...");
60
- const postStart = spawnSync("docker", ["exec", containerName, "node", `${TOTOPO_CONTAINER_PATH}/post-start.mjs`], {
61
- stdio: "inherit",
38
+ const currentProfile = readActiveProfile(ctx.projectId) ?? "default";
39
+ const choice = await select({
40
+ message: "Profile:",
41
+ options: profileNames.map((name) => {
42
+ const opt = { value: name, label: name };
43
+ if (name === currentProfile)
44
+ opt.hint = "current";
45
+ return opt;
46
+ }),
47
+ initialValue: currentProfile,
62
48
  });
63
- if (postStart.status !== 0) {
64
- outro("Post-start checks failed.");
65
- process.exit(postStart.status ?? 1);
49
+ if (isCancel(choice)) {
50
+ cancel("Cancelled.");
51
+ process.exit(0);
66
52
  }
67
- }
68
- // --- Ensure global env file exists -------------------------------------------------------------------------------------------------------
69
- function ensureGlobalEnvFile() {
70
- const globalTotopoDir = join(homedir(), ".totopo");
71
- const envFile = join(globalTotopoDir, ".env");
72
- mkdirSync(globalTotopoDir, { recursive: true });
73
- if (!existsSync(envFile)) {
74
- writeFileSync(envFile, "");
53
+ const selected = choice;
54
+ if (selected !== currentProfile) {
55
+ writeActiveProfile(ctx.projectId, selected);
75
56
  }
76
- return envFile;
57
+ return selected;
77
58
  }
78
- // --- Read container shadow label ---------------------------------------------------------------------------------------------------------
79
- function readContainerShadowLabel(containerName) {
80
- const result = spawnSync("docker", ["inspect", "--format", '{{index .Config.Labels "totopo.shadows"}}', containerName], {
59
+ // --- Read container label ----------------------------------------------------------------------------------------------------------------
60
+ function readContainerLabel(containerName, label) {
61
+ const result = spawnSync("docker", ["inspect", "--format", `{{index .Config.Labels "${label}"}}`, containerName], {
81
62
  encoding: "utf8",
82
63
  stdio: "pipe",
83
64
  });
84
65
  if (result.status !== 0)
85
66
  return "";
86
67
  const val = result.stdout.trim();
87
- // Docker returns "<no value>" when label is missing
88
68
  return val === "<no value>" ? "" : val;
89
69
  }
90
- // --- Normalize shadow label --------------------------------------------------------------------------------------------------------------
70
+ // --- Shadow label ------------------------------------------------------------------------------------------------------------------------
91
71
  function shadowLabel(paths) {
92
72
  if (paths.length === 0)
93
73
  return "";
@@ -98,74 +78,129 @@ function stopAndRemoveContainer(containerName) {
98
78
  spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
99
79
  spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
100
80
  }
101
- // --- Run container -----------------------------------------------------------------------------------------------------------------------
102
- function runContainer(containerName, imageName, workspaceDir, projectDir) {
103
- const envFile = ensureGlobalEnvFile();
104
- const { mountArgs, shadowPaths } = buildMountArgs(workspaceDir, projectDir);
105
- const labelArgs = ["--label", "totopo.managed=true", "--label", `totopo.shadows=${shadowLabel(shadowPaths)}`];
106
- const run = spawnSync("docker", [
107
- "run",
108
- "-d",
109
- "--name",
110
- containerName,
111
- ...mountArgs,
112
- "--env-file",
113
- envFile,
114
- "--security-opt",
115
- "no-new-privileges:true",
116
- ...labelArgs,
117
- imageName,
118
- "sleep",
119
- "infinity",
120
- ], { stdio: "inherit" });
121
- if (run.status !== 0) {
122
- outro("Failed to start dev container.");
123
- process.exit(run.status ?? 1);
81
+ // --- Run post-start ----------------------------------------------------------------------------------------------------------------------
82
+ function runPostStart(containerName) {
83
+ log.step("Running post-start checks...");
84
+ const postStart = spawnSync("docker", ["exec", containerName, "node", "/home/devuser/post-start.mjs"], {
85
+ stdio: "inherit",
86
+ });
87
+ if (postStart.status !== 0) {
88
+ outro("Post-start checks failed.");
89
+ process.exit(postStart.status ?? 1);
124
90
  }
125
91
  }
126
- export async function run(_packageDir, ctx) {
92
+ // --- Main --------------------------------------------------------------------------------------------------------------------------------
93
+ export async function run(packageDir, ctx) {
127
94
  const cwd = process.cwd();
128
- const workspaceDir = ctx.meta.projectRoot;
129
- const containerName = ctx.meta.containerName;
130
- const imageName = ctx.meta.containerName;
95
+ const workspaceDir = ctx.projectRoot;
96
+ const containerName = ctx.containerName;
131
97
  const projectDir = ctx.projectDir;
98
+ const templatesDir = join(packageDir, "templates");
99
+ // --- Read totopo.yaml ----------------------------------------------------------------------------------------------------------------
100
+ const yaml = readTotopoYaml(workspaceDir);
101
+ if (!yaml) {
102
+ log.error("totopo.yaml not found or invalid.");
103
+ process.exit(1);
104
+ }
132
105
  // --- Prompt for working directory ----------------------------------------------------------------------------------------------------
133
106
  const workdir = await promptWorkdir(workspaceDir, cwd);
107
+ // --- Profile selection ---------------------------------------------------------------------------------------------------------------
108
+ const profiles = yaml.profiles ?? {};
109
+ const activeProfile = await selectProfile(ctx, profiles);
110
+ const profileConfig = profiles[activeProfile];
111
+ const profileHook = profileConfig?.dockerfile_hook;
112
+ // --- Shadow path expansion -----------------------------------------------------------------------------------------------------------
113
+ const shadowPatterns = yaml.shadow_paths ?? [];
114
+ const expandedShadows = expandShadowPatterns(shadowPatterns, workspaceDir);
115
+ if (expandedShadows.length > 0) {
116
+ log.warn(`Shadow paths active: ${expandedShadows.join(", ")} (Settings > Shadow paths)`);
117
+ }
118
+ // --- Sync shadows and build mount args ------------------------------------------------------------------------------------------------
119
+ ensureShadowsInSync(projectDir, expandedShadows, workspaceDir);
120
+ const shadowMountArgs = buildShadowMountArgs(projectDir, expandedShadows);
121
+ // --- Agent context -------------------------------------------------------------------------------------------------------------------
134
122
  const hasGit = existsSync(join(workspaceDir, ".git"));
135
- const settings = readSettings(projectDir);
136
- const agentDocs = buildAgentContextDocs(hasGit, settings.shadowPaths);
137
- // --- Session start warning for shadow paths ------------------------------------------------------------------------------------------
138
- if (settings.shadowPaths.length > 0) {
139
- log.warn(`Shadow paths active: ${settings.shadowPaths.join(", ")} (Settings > Shadow paths)`);
123
+ const agentDocs = buildAgentContextDocs(hasGit, shadowPatterns);
124
+ // --- Env file ------------------------------------------------------------------------------------------------------------------------
125
+ const envFileArgs = [];
126
+ if (yaml.env_file) {
127
+ const envFilePath = join(workspaceDir, yaml.env_file);
128
+ if (existsSync(envFilePath)) {
129
+ envFileArgs.push("--env-file", envFilePath);
130
+ }
131
+ else {
132
+ log.warn(`env_file "${yaml.env_file}" not found — skipping`);
133
+ }
140
134
  }
135
+ // --- Build mount args ----------------------------------------------------------------------------------------------------------------
136
+ const agentMounts = buildAgentMountArgs(projectDir);
137
+ // Shadow mounts must come AFTER the workspace mount to overlay correctly
138
+ const mountArgs = ["-v", `${workspaceDir}:/workspace`, ...shadowMountArgs, ...agentMounts];
139
+ // --- Container labels ----------------------------------------------------------------------------------------------------------------
140
+ const labelArgs = [
141
+ "--label",
142
+ "totopo.managed=true",
143
+ "--label",
144
+ `totopo.shadows=${shadowLabel(expandedShadows)}`,
145
+ "--label",
146
+ `totopo.profile=${activeProfile}`,
147
+ ];
141
148
  // --- Inspect container state ---------------------------------------------------------------------------------------------------------
142
149
  const inspect = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
143
150
  encoding: "utf8",
144
151
  stdio: "pipe",
145
152
  });
146
153
  let containerStatus = inspect.status === 0 ? inspect.stdout.trim() : null;
147
- // --- Check for shadow path mismatch --------------------------------------------------------------------------------------------------
154
+ // --- Check for shadow or profile mismatch --------------------------------------------------------------------------------------------
148
155
  if (containerStatus !== null) {
149
- const currentLabel = readContainerShadowLabel(containerName);
150
- const expectedLabel = shadowLabel(settings.shadowPaths);
151
- if (currentLabel !== expectedLabel) {
152
- log.info("Shadow paths changed recreating container...");
156
+ const currentShadowLabel = readContainerLabel(containerName, "totopo.shadows");
157
+ const expectedShadowLabel = shadowLabel(expandedShadows);
158
+ const currentProfileLabel = readContainerLabel(containerName, "totopo.profile");
159
+ const shadowChanged = currentShadowLabel !== expectedShadowLabel;
160
+ const profileChanged = currentProfileLabel !== activeProfile;
161
+ if (shadowChanged || profileChanged) {
153
162
  stopAndRemoveContainer(containerName);
154
163
  containerStatus = null;
164
+ if (profileChanged) {
165
+ // Profile change means different Dockerfile - must rebuild image
166
+ log.info(`Profile changed (${currentProfileLabel} → ${activeProfile}) — rebuilding...`);
167
+ spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
168
+ }
169
+ else {
170
+ log.info("Shadow paths changed — recreating container...");
171
+ }
155
172
  }
156
173
  }
157
174
  if (containerStatus === null) {
158
175
  // --- No container - build image and run ------------------------------------------------------------------------------------------
159
176
  log.step("Building container image...");
160
- const build = spawnSync("docker", ["build", "--label", "totopo.managed=true", "-f", join(projectDir, "Dockerfile"), "-t", imageName, projectDir], { stdio: "inherit" });
161
- if (build.status !== 0) {
177
+ const dockerfileContent = buildDockerfile(join(templatesDir, "Dockerfile"), profileHook);
178
+ const buildResult = buildImageWithTempfile(dockerfileContent, templatesDir, containerName);
179
+ if (buildResult.status !== 0) {
162
180
  outro("Failed to build container image.");
163
- process.exit(build.status ?? 1);
181
+ process.exit(buildResult.status);
164
182
  }
165
183
  log.step("Preparing agent context...");
166
184
  injectAgentContext(projectDir, agentDocs);
167
185
  log.step("Starting dev container...");
168
- runContainer(containerName, imageName, workspaceDir, projectDir);
186
+ const run = spawnSync("docker", [
187
+ "run",
188
+ "-d",
189
+ "--name",
190
+ containerName,
191
+ ...mountArgs,
192
+ ...envFileArgs,
193
+ "--security-opt",
194
+ "no-new-privileges:true",
195
+ ...labelArgs,
196
+ containerName,
197
+ "sleep",
198
+ "infinity",
199
+ ], { stdio: "inherit" });
200
+ if (run.status !== 0) {
201
+ outro("Failed to start dev container.");
202
+ process.exit(run.status ?? 1);
203
+ }
169
204
  runPostStart(containerName);
170
205
  }
171
206
  else if (containerStatus === "exited") {
@@ -2,11 +2,8 @@
2
2
  // src/commands/doctor.ts - Host readiness check for totopo
3
3
  // Runs silently on success; exits non-zero on failure.
4
4
  // Pass verbose=true for a full report.
5
- // Pass null for projectDir to skip the Dockerfile check (e.g. from Manage totopo).
6
5
  // =========================================================================================================================================
7
6
  import { spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
9
- import { join } from "node:path";
10
7
  import { log, outro } from "@clack/prompts";
11
8
  // Returns true if the given CLI tool is resolvable in the system PATH
12
9
  function commandExists(cmd) {
@@ -16,9 +13,8 @@ function commandExists(cmd) {
16
13
  });
17
14
  return r.status === 0;
18
15
  }
19
- export async function run(projectDir, verbose) {
16
+ export async function run(_projectDir, verbose) {
20
17
  const errors = [];
21
- // Logs the result of a single health check; accumulates failures into the errors array
22
18
  function check(label, ok, detail) {
23
19
  if (ok) {
24
20
  if (verbose)
@@ -33,15 +29,11 @@ export async function run(projectDir, verbose) {
33
29
  if (verbose)
34
30
  console.log("");
35
31
  // --- Docker installed ----------------------------------------------------------------------------------------------------------------
36
- check("Docker installed", commandExists("docker"), commandExists("docker") ? undefined : "'docker' not found in PATH");
32
+ const hasDocker = commandExists("docker");
33
+ check("Docker installed", hasDocker, hasDocker ? undefined : "'docker' not found in PATH");
37
34
  // --- Docker running ------------------------------------------------------------------------------------------------------------------
38
35
  const dockerInfo = spawnSync("docker", ["info"], { encoding: "utf8", stdio: "pipe" });
39
36
  check("Docker running", dockerInfo.status === 0, dockerInfo.status === 0 ? undefined : "Docker daemon not responding");
40
- // --- Project Dockerfile present (only when projectDir is provided) -------------------------------------------------------------------
41
- if (projectDir !== null) {
42
- const configOk = existsSync(join(projectDir, "Dockerfile"));
43
- check("Dockerfile present", configOk, configOk ? undefined : `missing Dockerfile in ${projectDir}`);
44
- }
45
37
  // --- Report --------------------------------------------------------------------------------------------------------------------------
46
38
  if (errors.length > 0) {
47
39
  if (verbose) {