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 +24 -22
- 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 +132 -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 { 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/
|
|
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
|
-
// ---
|
|
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
|
|
67
|
-
const hasTotopoYaml = existsSync(totopoJsonPath);
|
|
72
|
+
const hasContext = gitRoot !== null || findTotopoYamlDir(cwd) !== null;
|
|
68
73
|
|
|
69
|
-
if (
|
|
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(
|
|
90
|
+
await advanced();
|
|
86
91
|
process.exit(0);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
const ctx = await onboard(
|
|
91
|
-
if (!ctx) process.exit(0);
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
135
|
+
await rebuild(project.containerName);
|
|
134
136
|
await dev(packageDir, project);
|
|
135
137
|
break;
|
|
136
138
|
case "stop":
|
|
137
|
-
await stop(project.
|
|
139
|
+
await stop(project.containerName);
|
|
138
140
|
break;
|
|
139
141
|
case "settings":
|
|
140
|
-
await settings(
|
|
142
|
+
await settings(project);
|
|
141
143
|
showMenu = true;
|
|
142
144
|
break;
|
|
143
145
|
case "manage-totopo": {
|
|
144
|
-
const result = await advanced(
|
|
146
|
+
const result = await advanced(project.projectId);
|
|
145
147
|
if (result === "back") showMenu = true;
|
|
146
148
|
break;
|
|
147
149
|
}
|
|
148
150
|
default:
|
|
149
|
-
break;
|
|
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 {
|
|
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") {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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) {
|