totopo 0.1.4 → 0.1.5
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/ai.sh +2 -2
- package/package.json +1 -1
- package/src/core/dev.ts +14 -26
- package/src/core/doctor.ts +56 -81
- package/src/core/menu.ts +21 -24
- package/src/core/onboard.ts +30 -60
- package/src/core/reset.ts +26 -42
- package/src/core/stop.ts +14 -12
- package/templates/Dockerfile +1 -0
- package/templates/devcontainer.json +27 -32
- package/templates/post-start.mjs +34 -38
- package/tsconfig.json +22 -22
package/ai.sh
CHANGED
|
@@ -79,9 +79,9 @@ fi
|
|
|
79
79
|
|
|
80
80
|
# ─── Gather state for menu ──────────────────────────────────────────────────
|
|
81
81
|
PROJECT_NAME="$(basename "$REPO_ROOT")"
|
|
82
|
-
WORKSPACE_NAME="totopo-$PROJECT_NAME"
|
|
82
|
+
WORKSPACE_NAME="totopo-managed-$PROJECT_NAME"
|
|
83
83
|
|
|
84
|
-
ACTIVE_COUNT=$(docker ps --filter "name=totopo-" --format "{{.Names}}" 2>/dev/null | wc -l | tr -d '[:space:]')
|
|
84
|
+
ACTIVE_COUNT=$(docker ps --filter "name=totopo-managed-" --format "{{.Names}}" 2>/dev/null | wc -l | tr -d '[:space:]')
|
|
85
85
|
|
|
86
86
|
HAS_KEY=false
|
|
87
87
|
if [ -f "$REPO_ROOT/.totopo/.env" ]; then
|
package/package.json
CHANGED
package/src/core/dev.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// =========================================================================================================================================
|
|
3
3
|
// scripts/dev.ts — Start the dev container and SSH in
|
|
4
4
|
// Called by ai.sh — do not run directly.
|
|
5
|
-
//
|
|
5
|
+
// =========================================================================================================================================
|
|
6
6
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { basename } from "node:path";
|
|
@@ -10,40 +10,28 @@ import { log, outro } from "@clack/prompts";
|
|
|
10
10
|
|
|
11
11
|
const workspaceDir = process.env.TOTOPO_REPO_ROOT;
|
|
12
12
|
if (!workspaceDir) {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
log.error("TOTOPO_REPO_ROOT not set — run via ai.sh");
|
|
14
|
+
process.exit(1);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Derive a stable workspace ID — "totopo-managed-" prefix lets reset/stop scripts identify managed workspaces
|
|
18
|
+
const workspaceName = `totopo-managed-${basename(workspaceDir)}`;
|
|
18
19
|
|
|
19
20
|
// Always run devpod up — it's idempotent (starts if stopped, no-op if running)
|
|
20
21
|
log.step("Starting dev container...");
|
|
21
22
|
const up = spawnSync(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
workspaceDir,
|
|
26
|
-
"--devcontainer-path",
|
|
27
|
-
".totopo/devcontainer.json",
|
|
28
|
-
"--ide",
|
|
29
|
-
"none",
|
|
30
|
-
"--id",
|
|
31
|
-
workspaceName,
|
|
32
|
-
],
|
|
33
|
-
{ stdio: "inherit" },
|
|
23
|
+
"devpod",
|
|
24
|
+
["up", workspaceDir, "--devcontainer-path", ".totopo/devcontainer.json", "--ide", "none", "--id", workspaceName],
|
|
25
|
+
{ stdio: "inherit" },
|
|
34
26
|
);
|
|
35
27
|
if (up.status !== 0) {
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
outro("Failed to start dev container.");
|
|
29
|
+
process.exit(up.status ?? 1);
|
|
38
30
|
}
|
|
39
31
|
log.step("Connecting via SSH...");
|
|
40
32
|
|
|
41
|
-
const ssh = spawnSync(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
{
|
|
45
|
-
stdio: "inherit",
|
|
46
|
-
},
|
|
47
|
-
);
|
|
33
|
+
const ssh = spawnSync("devpod", ["ssh", workspaceName, "--workdir", "/workspace"], {
|
|
34
|
+
stdio: "inherit",
|
|
35
|
+
});
|
|
48
36
|
|
|
49
37
|
process.exit(ssh.status ?? 0);
|
package/src/core/doctor.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// =========================================================================================================================================
|
|
3
3
|
// scripts/doctor.ts — Host readiness check for totopo
|
|
4
4
|
// Runs silently on success; exits non-zero on failure.
|
|
5
5
|
// Pass --verbose for a full report.
|
|
6
|
-
//
|
|
6
|
+
// =========================================================================================================================================
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
@@ -12,109 +12,84 @@ import { log, outro } from "@clack/prompts";
|
|
|
12
12
|
const verbose = process.argv.includes("--verbose");
|
|
13
13
|
const repoRoot = process.env.TOTOPO_REPO_ROOT;
|
|
14
14
|
if (!repoRoot) {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
log.error("TOTOPO_REPO_ROOT not set — run via ai.sh");
|
|
16
|
+
process.exit(1);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const errors: string[] = [];
|
|
20
20
|
|
|
21
|
+
// Logs the result of a single health check; accumulates failures into the errors array for the final report
|
|
21
22
|
function check(label: string, ok: boolean, detail?: string): void {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
23
|
+
if (ok) {
|
|
24
|
+
if (verbose) log.success(`${label}${detail ? ` \x1b[2m${detail}\x1b[0m` : ""}`);
|
|
25
|
+
} else {
|
|
26
|
+
errors.push(`${label}${detail ? `: ${detail}` : ""}`);
|
|
27
|
+
if (verbose) log.error(`${label}${detail ? ` ${detail}` : ""}`);
|
|
28
|
+
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Returns true if the given CLI tool is resolvable in the system PATH
|
|
31
32
|
function commandExists(cmd: string): boolean {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const r = spawnSync("command", ["-v", cmd], {
|
|
34
|
+
shell: true,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
});
|
|
37
|
+
return r.status === 0;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
if (verbose) console.log("");
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
check(
|
|
43
|
-
"Docker installed",
|
|
44
|
-
commandExists("docker"),
|
|
45
|
-
commandExists("docker") ? undefined : "'docker' not found in PATH",
|
|
46
|
-
);
|
|
42
|
+
// ─── Docker installed ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
check("Docker installed", commandExists("docker"), commandExists("docker") ? undefined : "'docker' not found in PATH");
|
|
47
44
|
|
|
48
|
-
//
|
|
45
|
+
// ─── Docker running ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
49
46
|
const dockerInfo = spawnSync("docker", ["info"], {
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
stdio: "pipe",
|
|
52
49
|
});
|
|
53
|
-
check(
|
|
54
|
-
"Docker running",
|
|
55
|
-
dockerInfo.status === 0,
|
|
56
|
-
dockerInfo.status === 0 ? undefined : "Docker daemon not responding",
|
|
57
|
-
);
|
|
50
|
+
check("Docker running", dockerInfo.status === 0, dockerInfo.status === 0 ? undefined : "Docker daemon not responding");
|
|
58
51
|
|
|
59
|
-
//
|
|
52
|
+
// ─── DevPod installed ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
60
53
|
const devpodInstalled = commandExists("devpod");
|
|
61
|
-
check(
|
|
62
|
-
"DevPod installed",
|
|
63
|
-
devpodInstalled,
|
|
64
|
-
devpodInstalled ? undefined : "'devpod' not found in PATH",
|
|
65
|
-
);
|
|
54
|
+
check("DevPod installed", devpodInstalled, devpodInstalled ? undefined : "'devpod' not found in PATH");
|
|
66
55
|
|
|
67
|
-
//
|
|
56
|
+
// ─── DevPod provider configured ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
68
57
|
if (devpodInstalled) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"DevPod provider configured",
|
|
85
|
-
providerOk,
|
|
86
|
-
providerOk
|
|
87
|
-
? undefined
|
|
88
|
-
: "no provider found — run: devpod provider add docker",
|
|
89
|
-
);
|
|
58
|
+
let providerOk = false;
|
|
59
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
60
|
+
const r = spawnSync("devpod", ["provider", "list"], {
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
stdio: "pipe",
|
|
63
|
+
});
|
|
64
|
+
if (r.stdout?.toLowerCase().includes("docker")) {
|
|
65
|
+
providerOk = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (attempt < 5) {
|
|
69
|
+
spawnSync("sleep", ["1"]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
check("DevPod provider configured", providerOk, providerOk ? undefined : "no provider found — run: devpod provider add docker");
|
|
90
73
|
}
|
|
91
74
|
|
|
92
|
-
//
|
|
93
|
-
const configOk =
|
|
94
|
-
|
|
95
|
-
existsSync(`${repoRoot}/.totopo/Dockerfile`);
|
|
96
|
-
check(
|
|
97
|
-
".totopo/ config present",
|
|
98
|
-
configOk,
|
|
99
|
-
configOk
|
|
100
|
-
? undefined
|
|
101
|
-
: "missing .totopo/devcontainer.json or .totopo/Dockerfile",
|
|
102
|
-
);
|
|
75
|
+
// ─── .totopo/ config present ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
const configOk = existsSync(`${repoRoot}/.totopo/devcontainer.json`) && existsSync(`${repoRoot}/.totopo/Dockerfile`);
|
|
77
|
+
check(".totopo/ config present", configOk, configOk ? undefined : "missing .totopo/devcontainer.json or .totopo/Dockerfile");
|
|
103
78
|
|
|
104
|
-
//
|
|
79
|
+
// ─── Report ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
105
80
|
if (errors.length > 0) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
if (verbose) {
|
|
82
|
+
console.log("");
|
|
83
|
+
log.error("totopo doctor found problems:");
|
|
84
|
+
for (const err of errors) {
|
|
85
|
+
console.log(` \x1b[2m•\x1b[0m ${err}`);
|
|
86
|
+
}
|
|
87
|
+
console.log("");
|
|
88
|
+
}
|
|
89
|
+
process.exit(1);
|
|
115
90
|
}
|
|
116
91
|
|
|
117
92
|
if (verbose) {
|
|
118
|
-
|
|
119
|
-
|
|
93
|
+
console.log("");
|
|
94
|
+
outro("All checks passed.");
|
|
120
95
|
}
|
package/src/core/menu.ts
CHANGED
|
@@ -1,46 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// =========================================================================================================================================
|
|
3
3
|
// scripts/menu.ts — totopo interactive menu (powered by @clack/prompts)
|
|
4
4
|
// Called by ai.sh — outputs selected action to stderr.
|
|
5
|
-
//
|
|
5
|
+
// =========================================================================================================================================
|
|
6
6
|
|
|
7
7
|
import { box, cancel, isCancel, select } from "@clack/prompts";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// Parse CLI args passed by ai.sh: project name, active container count, and API key presence flag
|
|
10
|
+
const [projectName = "unknown", activeCountStr, hasKeyStr] = process.argv.slice(2);
|
|
11
11
|
const activeCount = Number.parseInt(activeCountStr ?? "0", 10);
|
|
12
12
|
const hasKey = hasKeyStr === "true";
|
|
13
13
|
|
|
14
|
-
// ─── Status box
|
|
15
|
-
const sessionLabel =
|
|
16
|
-
activeCount === 1
|
|
17
|
-
? "1 container running"
|
|
18
|
-
: `${activeCount} containers running`;
|
|
14
|
+
// ─── Status box ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
const sessionLabel = activeCount === 1 ? "1 container running" : `${activeCount} containers running`;
|
|
19
16
|
const lines = [];
|
|
20
17
|
lines.push(`status: ${sessionLabel}`);
|
|
21
18
|
lines.push(`api keys: ${hasKey ? "configured" : "none"} (.totopo/.env)`);
|
|
22
19
|
box(lines.join("\n"), ` totopo · ${projectName} `, {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
contentAlign: "center",
|
|
21
|
+
titleAlign: "center",
|
|
22
|
+
width: "auto",
|
|
23
|
+
rounded: true,
|
|
27
24
|
});
|
|
28
25
|
|
|
29
|
-
// ─── Menu
|
|
26
|
+
// ─── Menu ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
30
27
|
const action = await select({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
message: "Menu:",
|
|
29
|
+
options: [
|
|
30
|
+
{ value: "dev", label: "Start session" },
|
|
31
|
+
{ value: "stop", label: "Stop all" },
|
|
32
|
+
{ value: "reset", label: "Reset (wipe workspaces + images)" },
|
|
33
|
+
{ value: "doctor", label: "Doctor" },
|
|
34
|
+
{ value: "quit", label: "Quit" },
|
|
35
|
+
],
|
|
39
36
|
});
|
|
40
37
|
|
|
41
38
|
if (isCancel(action)) {
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
cancel();
|
|
40
|
+
process.exit(0);
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
// Output action to stderr — ai.sh captures it via redirection
|
package/src/core/onboard.ts
CHANGED
|
@@ -1,104 +1,74 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// =========================================================================================================================================
|
|
3
3
|
// scripts/onboard.ts — First-time setup for a project using totopo
|
|
4
4
|
// Called by ai.sh when no .totopo/ config is found in the project.
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
cpSync,
|
|
9
|
-
existsSync,
|
|
10
|
-
mkdirSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
8
|
import { basename, join } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
box,
|
|
17
|
-
cancel,
|
|
18
|
-
confirm,
|
|
19
|
-
intro,
|
|
20
|
-
isCancel,
|
|
21
|
-
log,
|
|
22
|
-
outro,
|
|
23
|
-
} from "@clack/prompts";
|
|
9
|
+
import { box, cancel, confirm, intro, isCancel, log, outro } from "@clack/prompts";
|
|
24
10
|
|
|
25
11
|
const packageDir = process.env.TOTOPO_PACKAGE_DIR;
|
|
26
12
|
const repoRoot = process.env.TOTOPO_REPO_ROOT;
|
|
27
13
|
|
|
28
14
|
if (!packageDir || !repoRoot) {
|
|
29
|
-
|
|
30
|
-
|
|
15
|
+
log.error("TOTOPO_PACKAGE_DIR / TOTOPO_REPO_ROOT not set — run via ai.sh");
|
|
16
|
+
process.exit(1);
|
|
31
17
|
}
|
|
32
18
|
|
|
33
19
|
const templatesDir = join(packageDir, "templates");
|
|
34
20
|
const totopoDir = join(repoRoot, ".totopo");
|
|
35
21
|
const projectName = basename(repoRoot);
|
|
36
22
|
|
|
37
|
-
// ─── Intro
|
|
23
|
+
// ─── Intro ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
38
24
|
intro("totopo — First-time setup");
|
|
39
25
|
|
|
40
|
-
box(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
width: "auto",
|
|
47
|
-
rounded: true,
|
|
48
|
-
},
|
|
49
|
-
);
|
|
26
|
+
box(`project : ${projectName}\nlocation : ${totopoDir}`, "No .totopo/ config found — totopo will create it now.", {
|
|
27
|
+
contentAlign: "center",
|
|
28
|
+
titleAlign: "center",
|
|
29
|
+
width: "auto",
|
|
30
|
+
rounded: true,
|
|
31
|
+
});
|
|
50
32
|
|
|
51
33
|
const ok = await confirm({ message: "Continue?" });
|
|
52
34
|
|
|
53
35
|
if (isCancel(ok) || !ok) {
|
|
54
|
-
|
|
55
|
-
|
|
36
|
+
cancel("Setup cancelled.");
|
|
37
|
+
process.exit(0);
|
|
56
38
|
}
|
|
57
39
|
|
|
58
|
-
// ─── Copy templates
|
|
40
|
+
// ─── Copy templates ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
59
41
|
mkdirSync(totopoDir, { recursive: true });
|
|
60
42
|
|
|
61
43
|
cpSync(join(templatesDir, "Dockerfile"), join(totopoDir, "Dockerfile"));
|
|
62
44
|
cpSync(join(templatesDir, "post-start.mjs"), join(totopoDir, "post-start.mjs"));
|
|
63
45
|
|
|
64
46
|
// Substitute project name in devcontainer.json (plain string replace — file has // comments)
|
|
65
|
-
const dcTemplate = readFileSync(
|
|
66
|
-
|
|
67
|
-
"utf8",
|
|
68
|
-
);
|
|
69
|
-
writeFileSync(
|
|
70
|
-
join(totopoDir, "devcontainer.json"),
|
|
71
|
-
dcTemplate.replace(/TOTOPO_PROJECT_NAME/g, projectName),
|
|
72
|
-
);
|
|
47
|
+
const dcTemplate = readFileSync(join(templatesDir, "devcontainer.json"), "utf8");
|
|
48
|
+
writeFileSync(join(totopoDir, "devcontainer.json"), dcTemplate.replace(/TOTOPO_PROJECT_NAME/g, projectName));
|
|
73
49
|
|
|
74
50
|
log.success("Copied config templates to .totopo/");
|
|
75
51
|
|
|
76
|
-
// ─── Create .env
|
|
52
|
+
// ─── Create .env ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
77
53
|
const envPath = join(totopoDir, ".env");
|
|
78
54
|
if (existsSync(envPath)) {
|
|
79
|
-
|
|
55
|
+
log.info(".totopo/.env already exists — leaving it untouched");
|
|
80
56
|
} else {
|
|
81
|
-
|
|
82
|
-
|
|
57
|
+
cpSync(join(templatesDir, "env"), envPath);
|
|
58
|
+
log.success("Created .totopo/.env");
|
|
83
59
|
}
|
|
84
60
|
|
|
85
|
-
// ─── Ensure .totopo/.env is gitignored
|
|
61
|
+
// ─── Ensure .totopo/.env is gitignored ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
86
62
|
const gitignorePath = join(repoRoot, ".gitignore");
|
|
87
63
|
const gitignoreEntry = ".totopo/.env";
|
|
88
64
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
readFileSync(gitignorePath, "utf8").includes(gitignoreEntry)
|
|
92
|
-
) {
|
|
93
|
-
log.info(".totopo/.env already in .gitignore");
|
|
65
|
+
if (existsSync(gitignorePath) && readFileSync(gitignorePath, "utf8").includes(gitignoreEntry)) {
|
|
66
|
+
log.info(".totopo/.env already in .gitignore");
|
|
94
67
|
} else {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
: "";
|
|
100
|
-
writeFileSync(gitignorePath, existing + addition);
|
|
101
|
-
log.success("Added .totopo/.env to .gitignore");
|
|
68
|
+
const addition = "\n# totopo — API keys must never be committed\n.totopo/.env\n";
|
|
69
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
|
|
70
|
+
writeFileSync(gitignorePath, existing + addition);
|
|
71
|
+
log.success("Added .totopo/.env to .gitignore");
|
|
102
72
|
}
|
|
103
73
|
|
|
104
74
|
log.warn("Add your API keys to .totopo/.env before starting the container.");
|
package/src/core/reset.ts
CHANGED
|
@@ -1,70 +1,54 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// =========================================================================================================================================
|
|
3
3
|
// scripts/reset.ts — Full reset: delete all totopo workspaces and Docker images
|
|
4
4
|
// Called by ai.sh — do not run directly.
|
|
5
5
|
// Run 'ai.sh' → Start session after this to get a fresh build.
|
|
6
|
-
//
|
|
6
|
+
// =========================================================================================================================================
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { log, outro } from "@clack/prompts";
|
|
10
10
|
|
|
11
|
-
// ─── Step 1: Find all totopo-* DevPod workspaces
|
|
11
|
+
// ─── Step 1: Find all totopo-managed-* DevPod workspaces ─────────────────────────────────────────────────────────────────────────────────
|
|
12
12
|
const listResult = spawnSync("devpod", ["list", "--output", "json"], {
|
|
13
|
-
|
|
13
|
+
encoding: "utf8",
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
const workspaces: string[] = [];
|
|
17
17
|
if (listResult.stdout) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const matches = listResult.stdout.matchAll(/"id":"(totopo-managed-[^"]+)"/g);
|
|
19
|
+
for (const match of matches) {
|
|
20
|
+
if (match[1]) workspaces.push(match[1]);
|
|
21
|
+
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// ─── Step 2: Stop and delete all totopo workspaces
|
|
24
|
+
// ─── Step 2: Stop and delete all totopo workspaces ───────────────────────────────────────────────────────────────────────────────────────
|
|
25
25
|
if (workspaces.length === 0) {
|
|
26
|
-
|
|
26
|
+
log.info("No totopo workspaces found.");
|
|
27
27
|
} else {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
log.step(`Stopping and deleting ${workspaces.length} workspace(s)...`);
|
|
29
|
+
for (const ws of workspaces) {
|
|
30
|
+
log.step(` Removing ${ws}...`);
|
|
31
|
+
spawnSync("devpod", ["stop", ws], { stdio: "inherit" });
|
|
32
|
+
spawnSync("devpod", ["delete", ws, "--force"], { stdio: "inherit" });
|
|
33
|
+
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// ─── Step 3: Remove cached Docker images
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// prefix to get the image reference filter.
|
|
36
|
+
// ─── Step 3: Remove cached Docker images ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Images are identified via the LABEL totopo.managed=true baked into the
|
|
38
|
+
// Dockerfile template — works regardless of whether workspaces still exist.
|
|
40
39
|
log.step("Removing cached Docker images...");
|
|
41
40
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const projectName = ws.replace(/^totopo-/, "");
|
|
45
|
-
const findImages = spawnSync(
|
|
46
|
-
"docker",
|
|
47
|
-
[
|
|
48
|
-
"images",
|
|
49
|
-
"--filter",
|
|
50
|
-
`reference=vsc-${projectName}-*`,
|
|
51
|
-
"--format",
|
|
52
|
-
"{{.ID}}",
|
|
53
|
-
],
|
|
54
|
-
{ encoding: "utf8" },
|
|
55
|
-
);
|
|
56
|
-
const ids = (findImages.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
57
|
-
for (const id of ids) allImageIds.add(id);
|
|
58
|
-
}
|
|
41
|
+
const findImages = spawnSync("docker", ["images", "--filter", "label=totopo.managed=true", "--format", "{{.ID}}"], { encoding: "utf8" });
|
|
42
|
+
const imageIds = (findImages.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
59
43
|
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
44
|
+
if (imageIds.length > 0) {
|
|
45
|
+
log.info(` Found ${imageIds.length} image(s) — removing...`);
|
|
46
|
+
spawnSync("docker", ["rmi", "--force", ...imageIds], { stdio: "inherit" });
|
|
63
47
|
} else {
|
|
64
|
-
|
|
48
|
+
log.info(" No cached images found.");
|
|
65
49
|
}
|
|
66
50
|
|
|
67
51
|
spawnSync("docker", ["image", "prune", "--force"], { stdio: "inherit" });
|
|
68
52
|
|
|
69
|
-
// ─── Done
|
|
53
|
+
// ─── Done ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
70
54
|
outro("Reset complete. Run 'ai.sh' and select 'Start session' to start fresh.");
|
package/src/core/stop.ts
CHANGED
|
@@ -1,35 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// =========================================================================================================================================
|
|
3
3
|
// scripts/stop.ts — Stop and remove all totopo dev container workspaces
|
|
4
4
|
// Called by ai.sh — do not run directly.
|
|
5
|
-
//
|
|
5
|
+
// =========================================================================================================================================
|
|
6
6
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { log, outro } from "@clack/prompts";
|
|
9
9
|
|
|
10
|
+
// ─── Find all running totopo workspaces ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
10
11
|
const listResult = spawnSync("devpod", ["list", "--output", "json"], {
|
|
11
|
-
|
|
12
|
+
encoding: "utf8",
|
|
12
13
|
});
|
|
13
14
|
|
|
14
15
|
const workspaces: string[] = [];
|
|
15
16
|
if (listResult.stdout) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const matches = listResult.stdout.matchAll(/"id":"(totopo-managed-[^"]+)"/g);
|
|
18
|
+
for (const match of matches) {
|
|
19
|
+
if (match[1]) workspaces.push(match[1]);
|
|
20
|
+
}
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
if (workspaces.length === 0) {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
log.info("No totopo workspaces found.");
|
|
25
|
+
process.exit(0);
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// ─── Stop and remove each workspace ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
27
29
|
log.step("Stopping all totopo workspaces...");
|
|
28
30
|
|
|
29
31
|
for (const ws of workspaces) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
log.step(`Stopping ${ws}...`);
|
|
33
|
+
spawnSync("devpod", ["stop", ws], { stdio: "inherit" });
|
|
34
|
+
spawnSync("devpod", ["delete", ws, "--force"], { stdio: "inherit" });
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
outro("All totopo workspaces stopped and removed.");
|
package/templates/Dockerfile
CHANGED
|
@@ -1,40 +1,35 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
2
|
+
"name": "TOTOPO_PROJECT_NAME",
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
// Build from local Dockerfile
|
|
5
|
+
"build": {
|
|
6
|
+
"dockerfile": "Dockerfile",
|
|
7
|
+
"context": ".."
|
|
8
|
+
},
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// Mount the repo as the workspace — nothing else is visible
|
|
11
|
+
"workspaceFolder": "/workspace",
|
|
12
|
+
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"--name",
|
|
18
|
-
"totopo-${localWorkspaceFolderBasename}",
|
|
19
|
-
"--env-file",
|
|
20
|
-
"${localWorkspaceFolder}/.totopo/.env"
|
|
21
|
-
],
|
|
14
|
+
// Pass API keys from a local .env file — never baked into the image
|
|
15
|
+
// Create .totopo/.env on your host (see README.md)
|
|
16
|
+
"runArgs": ["--name", "totopo-${localWorkspaceFolderBasename}", "--env-file", "${localWorkspaceFolder}/.totopo/.env"],
|
|
22
17
|
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
// Run security + readiness checks every time container starts
|
|
19
|
+
"postStartCommand": "node /workspace/.totopo/post-start.mjs",
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
// Terminal-only — no extensions installed
|
|
22
|
+
"customizations": {
|
|
23
|
+
"vscode": {
|
|
24
|
+
"extensions": [],
|
|
25
|
+
"settings": {
|
|
26
|
+
// Do not forward host git credentials into the container
|
|
27
|
+
"remote.containers.gitCredentialHelperConfigLocation": "none"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
// Drop capabilities — container cannot gain new privileges
|
|
33
|
+
"capAdd": [],
|
|
34
|
+
"securityOpt": ["no-new-privileges:true"]
|
|
40
35
|
}
|
package/templates/post-start.mjs
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
9
|
|
|
10
10
|
const run = (cmd) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
try {
|
|
12
|
+
return execSync(cmd, {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
15
|
+
}).trim();
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// ─── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
@@ -27,13 +27,11 @@ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
|
27
27
|
|
|
28
28
|
let errors = 0;
|
|
29
29
|
|
|
30
|
-
const ok = (label, detail) =>
|
|
31
|
-
|
|
32
|
-
const warn = (label, detail) =>
|
|
33
|
-
console.log(`${yellow("▲")} ${label.padEnd(24)}${detail ? dim(detail) : ""}`);
|
|
30
|
+
const ok = (label, detail) => console.log(`${green("✓")} ${label.padEnd(24)}${detail ? dim(detail) : ""}`);
|
|
31
|
+
const warn = (label, detail) => console.log(`${yellow("▲")} ${label.padEnd(24)}${detail ? dim(detail) : ""}`);
|
|
34
32
|
const fail = (label, detail) => {
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
console.log(`${red("✗")} ${label.padEnd(24)}${detail || ""}`);
|
|
34
|
+
errors++;
|
|
37
35
|
};
|
|
38
36
|
const section = (title) => console.log(`\n${bold(title)}`);
|
|
39
37
|
|
|
@@ -45,35 +43,35 @@ section("Security");
|
|
|
45
43
|
|
|
46
44
|
const whoami = run("whoami");
|
|
47
45
|
if (whoami !== "root") {
|
|
48
|
-
|
|
46
|
+
ok("non-root user", whoami ?? "unknown");
|
|
49
47
|
} else {
|
|
50
|
-
|
|
48
|
+
fail("non-root user", "running as root — container is misconfigured");
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
const gitProtocol = run("git config --system protocol.allow");
|
|
54
52
|
if (gitProtocol === "never") {
|
|
55
|
-
|
|
53
|
+
ok("git remote block", "protocol.allow = never");
|
|
56
54
|
} else {
|
|
57
|
-
|
|
55
|
+
fail("git remote block", "not set — rebuild the container");
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
execSync("/usr/bin/git -C /workspace push", { stdio: "pipe" });
|
|
60
|
+
fail("push blocked", "git push succeeded — remote access is NOT blocked");
|
|
63
61
|
} catch {
|
|
64
|
-
|
|
62
|
+
ok("push blocked", "remote push not possible");
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
// ─── AI tools ────────────────────────────────────────────────────────────────
|
|
68
66
|
section("AI tools");
|
|
69
67
|
|
|
70
68
|
const checkTool = (cmd) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
const out = run(`${cmd} --version`);
|
|
70
|
+
if (out !== null) {
|
|
71
|
+
ok(cmd, out.split("\n")[0]);
|
|
72
|
+
} else {
|
|
73
|
+
fail(cmd, "not found — rebuild container");
|
|
74
|
+
}
|
|
77
75
|
};
|
|
78
76
|
|
|
79
77
|
checkTool("claude");
|
|
@@ -91,12 +89,12 @@ ok("pnpm", run("pnpm --version") ? `v${run("pnpm --version")}` : "not found");
|
|
|
91
89
|
section("API keys");
|
|
92
90
|
|
|
93
91
|
const checkKey = (varName) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
const val = process.env[varName];
|
|
93
|
+
if (val) {
|
|
94
|
+
ok(varName, `${val.substring(0, 12)}...`);
|
|
95
|
+
} else {
|
|
96
|
+
warn(varName, "not set — add to .totopo/.env");
|
|
97
|
+
}
|
|
100
98
|
};
|
|
101
99
|
|
|
102
100
|
checkKey("ANTHROPIC_API_KEY");
|
|
@@ -104,10 +102,8 @@ checkKey("KILO_API_KEY");
|
|
|
104
102
|
|
|
105
103
|
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
106
104
|
if (errors === 0) {
|
|
107
|
-
|
|
105
|
+
console.log(`\n${green("●")} ${bold("Ready.")}\n`);
|
|
108
106
|
} else {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
);
|
|
112
|
-
process.exit(1);
|
|
107
|
+
console.log(`\n${red("●")} ${bold(`${errors} error(s) — see above. Rebuild the container to fix.`)}\n`);
|
|
108
|
+
process.exit(1);
|
|
113
109
|
}
|
package/tsconfig.json
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
// Visit https://aka.ms/tsconfig to read more about this file
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
// Environment Settings
|
|
5
|
+
// See also https://aka.ms/tsconfig/module
|
|
6
|
+
"module": "nodenext",
|
|
7
|
+
"target": "esnext",
|
|
8
|
+
"lib": ["esnext"],
|
|
9
|
+
"types": ["node"],
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Other Outputs
|
|
12
|
+
"noEmit": true,
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// Stricter Typechecking Options
|
|
15
|
+
"noUncheckedIndexedAccess": true,
|
|
16
|
+
"exactOptionalPropertyTypes": true,
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
// Recommended Options
|
|
19
|
+
"strict": true,
|
|
20
|
+
"verbatimModuleSyntax": true,
|
|
21
|
+
"isolatedModules": true,
|
|
22
|
+
"noUncheckedSideEffectImports": true,
|
|
23
|
+
"moduleDetection": "force",
|
|
24
|
+
"skipLibCheck": true
|
|
25
|
+
},
|
|
26
|
+
"include": ["src/**/*.ts"]
|
|
27
27
|
}
|