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 +48 -54
- package/dist/commands/advanced.js +26 -47
- package/dist/commands/dev.js +128 -93
- package/dist/commands/doctor.js +3 -11
- package/dist/commands/menu.js +18 -9
- package/dist/commands/onboard.js +126 -197
- package/dist/commands/rebuild.js +8 -5
- package/dist/commands/settings.js +130 -103
- package/dist/commands/stop.js +2 -2
- package/dist/lib/agent-context.js +6 -7
- package/dist/lib/dockerfile-builder.js +66 -0
- package/dist/lib/migrate-v2.js +202 -0
- package/dist/lib/project-identity.js +133 -87
- package/dist/lib/shadows.js +48 -11
- package/dist/lib/totopo-yaml.js +145 -0
- package/package.json +6 -3
- package/schema/totopo.schema.json +53 -0
- package/templates/Dockerfile +19 -60
- package/templates/post-start.mjs +11 -21
- package/dist/commands/sync-dockerfile.js +0 -29
- package/dist/lib/config.js +0 -31
- package/dist/lib/detect-host.js +0 -65
- package/dist/lib/generate-dockerfile.js +0 -225
- package/dist/lib/select-tools.js +0 -37
- package/templates/env +0 -30
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 {
|
|
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/
|
|
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
|
-
// ---
|
|
54
|
-
|
|
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
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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(
|
|
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
|
|
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.
|
|
127
|
+
await rebuild(project.containerName);
|
|
134
128
|
await dev(packageDir, project);
|
|
135
129
|
break;
|
|
136
130
|
case "stop":
|
|
137
|
-
await stop(project.
|
|
131
|
+
await stop(project.containerName);
|
|
138
132
|
break;
|
|
139
133
|
case "settings":
|
|
140
|
-
await settings(
|
|
134
|
+
await settings(project);
|
|
141
135
|
showMenu = true;
|
|
142
136
|
break;
|
|
143
137
|
case "manage-totopo": {
|
|
144
|
-
const result = await advanced(
|
|
138
|
+
const result = await advanced(project.projectId);
|
|
145
139
|
if (result === "back") showMenu = true;
|
|
146
140
|
break;
|
|
147
141
|
}
|
|
148
142
|
default:
|
|
149
|
-
break;
|
|
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 {
|
|
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: "
|
|
15
|
-
spawnSync("docker", ["rm", name], { stdio: "
|
|
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;
|
|
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.
|
|
63
|
-
log.info(`Clearing agent memory for ${p.
|
|
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.
|
|
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.
|
|
77
|
+
const p = projects.find((x) => x.projectId === id);
|
|
79
78
|
if (!p)
|
|
80
79
|
continue;
|
|
81
|
-
|
|
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.
|
|
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.
|
|
94
|
-
stopAndRemoveContainer(p.
|
|
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.
|
|
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: "
|
|
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.
|
|
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.
|
|
170
|
-
label: p.
|
|
171
|
-
hint: p.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
194
|
-
|
|
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.
|
|
181
|
+
log.success(`Uninstalled project ${p.displayName}.`);
|
|
199
182
|
}
|
|
200
183
|
return currentProjectId !== undefined && selectedIds.includes(currentProjectId);
|
|
201
184
|
}
|
|
202
|
-
// --- Uninstall totopo (global
|
|
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(
|
|
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)
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// =========================================================================================================================================
|
|
2
2
|
// src/commands/dev.ts - Start the dev container and connect via docker exec
|
|
3
|
-
//
|
|
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
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
// ---
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 (
|
|
64
|
-
|
|
65
|
-
process.exit(
|
|
49
|
+
if (isCancel(choice)) {
|
|
50
|
+
cancel("Cancelled.");
|
|
51
|
+
process.exit(0);
|
|
66
52
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
57
|
+
return selected;
|
|
77
58
|
}
|
|
78
|
-
// --- Read container
|
|
79
|
-
function
|
|
80
|
-
const result = spawnSync("docker", ["inspect", "--format",
|
|
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
|
-
// ---
|
|
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
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"-
|
|
109
|
-
|
|
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
|
-
|
|
92
|
+
// --- Main --------------------------------------------------------------------------------------------------------------------------------
|
|
93
|
+
export async function run(packageDir, ctx) {
|
|
127
94
|
const cwd = process.cwd();
|
|
128
|
-
const workspaceDir = ctx.
|
|
129
|
-
const containerName = ctx.
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
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
|
|
154
|
+
// --- Check for shadow or profile mismatch --------------------------------------------------------------------------------------------
|
|
148
155
|
if (containerStatus !== null) {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
161
|
-
|
|
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(
|
|
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
|
-
|
|
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") {
|