totopo 2.1.0 → 3.0.0-rc-2

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 { 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,58 +48,54 @@ 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/) ----------------------------------------------------------------------
54
- let project = resolveProject(cwd);
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) --------------------------------------------------------------------------
60
+ let project;
61
+ try {
62
+ project = resolveProject(cwd);
63
+ } catch (err) {
64
+ console.error("");
65
+ console.error(` ${err instanceof Error ? err.message : err}`);
66
+ console.error("");
67
+ process.exit(1);
68
+ }
55
69
 
56
70
  // --- Onboarding (if not in a registered project) -----------------------------------------------------------------------------------------
57
71
  if (!project) {
58
- // Detect project context: git root or totopo.yaml present?
59
- let gitRoot = null;
60
- try {
61
- gitRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8", stdio: "pipe" }).trim();
62
- } catch {
63
- // Not in a git repo - that's fine
64
- }
65
-
66
- const totopoJsonPath = `${gitRoot ?? cwd}/${TOTOPO_YAML}`;
67
- const hasTotopoYaml = existsSync(totopoJsonPath);
68
-
69
- if (gitRoot !== null || hasTotopoYaml) {
70
- // Has project context - if other projects already exist, let the user choose first
71
- if (listProjectIds().length > 0) {
72
- process.stdout.write("\n");
73
- const choice = await select({
74
- message: "What would you like to do?",
75
- options: [
76
- { value: "setup", label: "Set up totopo for this directory" },
77
- { value: "manage", label: "Manage totopo →" },
78
- ],
79
- });
80
- if (isCancel(choice)) {
81
- cancel();
82
- process.exit(0);
83
- }
84
- if (choice === "manage") {
85
- await advanced(packageDir);
86
- process.exit(0);
87
- }
72
+ // If other projects already exist, let the user choose setup vs manage
73
+ if (listProjectIds().length > 0) {
74
+ process.stdout.write("\n");
75
+ const choice = await select({
76
+ message: "What would you like to do?",
77
+ options: [
78
+ { value: "setup", label: "Set up totopo for this directory" },
79
+ { value: "manage", label: "Manage totopo →" },
80
+ ],
81
+ });
82
+ if (isCancel(choice)) {
83
+ cancel();
84
+ process.exit(0);
85
+ }
86
+ if (choice === "manage") {
87
+ await advanced();
88
+ process.exit(0);
88
89
  }
89
-
90
- const ctx = await onboard(packageDir, cwd);
91
- if (!ctx) process.exit(0); // cancelled -> exit cleanly
92
- project = ctx;
93
- } else {
94
- // No project context -> show Manage totopo menu directly
95
- await advanced(packageDir);
96
- process.exit(0);
97
90
  }
98
- }
99
91
 
100
- // --- Sync Dockerfile with host runtimes --------------------------------------------------------------------------------------------------
101
- await syncDockerfile(packageDir, project);
92
+ const ctx = await onboard(cwd);
93
+ if (!ctx) process.exit(0);
94
+ project = ctx;
95
+ }
102
96
 
103
97
  // --- Doctor (silent pre-check) -----------------------------------------------------------------------------------------------------------
104
- const doctorResult = await doctor(project.projectDir, false);
98
+ const doctorResult = await doctor(null, false);
105
99
  if (!doctorResult.ok) {
106
100
  console.error(" Fix the issues above and re-run totopo.");
107
101
  console.error("");
@@ -109,7 +103,7 @@ if (!doctorResult.ok) {
109
103
  }
110
104
 
111
105
  // --- Gather container state for menu -----------------------------------------------------------------------------------------------------
112
- const { containerName } = project.meta;
106
+ const { containerName } = project;
113
107
 
114
108
  const dockerResult = spawnSync("docker", ["ps", "--filter", "name=totopo-", "--format", "{{.Names}}"], {
115
109
  encoding: "utf8",
@@ -130,22 +124,22 @@ while (showMenu) {
130
124
  await dev(packageDir, project);
131
125
  break;
132
126
  case "rebuild":
133
- await rebuild(project.meta.containerName);
127
+ await rebuild(project.containerName);
134
128
  await dev(packageDir, project);
135
129
  break;
136
130
  case "stop":
137
- await stop(project.meta.containerName);
131
+ await stop(project.containerName);
138
132
  break;
139
133
  case "settings":
140
- await settings(packageDir, project);
134
+ await settings(project);
141
135
  showMenu = true;
142
136
  break;
143
137
  case "manage-totopo": {
144
- const result = await advanced(packageDir, project.id);
138
+ const result = await advanced(project.projectId);
145
139
  if (result === "back") showMenu = true;
146
140
  break;
147
141
  }
148
142
  default:
149
- break; // quit or cancelled
143
+ break;
150
144
  }
151
145
  }
@@ -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") {