totopo 3.2.1 → 3.3.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/README.md +5 -6
- package/bin/totopo.js +2 -2
- package/dist/commands/dev.js +1 -1
- package/dist/commands/global.js +6 -6
- package/dist/commands/menu.js +1 -1
- package/dist/commands/onboard.js +12 -17
- package/dist/commands/workspace.js +2 -2
- package/dist/lib/migrate-to-latest.js +136 -31
- package/dist/lib/safe-rm.js +7 -2
- package/dist/lib/totopo-yaml.js +7 -24
- package/dist/lib/workspace-identity.js +0 -2
- package/package.json +1 -1
- package/schema/totopo.schema.json +1 -10
- package/templates/Dockerfile +1 -1
package/README.md
CHANGED
|
@@ -12,15 +12,14 @@ Local sandbox for AI agents.
|
|
|
12
12
|
|
|
13
13
|
## Motivation
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Two fundamental risks when running AI agents locally:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
1. Agents are unpredictable — they will make mistakes that may be hard to detect or undo.
|
|
18
|
+
2. Agents are vulnerable to prompt injection and can be subtly manipulated to leak sensitive data or execute unauthorized operations.
|
|
19
19
|
|
|
20
|
-
Totopo mitigates both risks
|
|
20
|
+
Totopo mitigates both risks by letting you run agents in a dev container — when you run totopo in a given directory, that directory is mounted as a workspace where agents can work freely, without access to the rest of your filesystem or your git remote.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
If an agent gets compromised, it can't reach your host files — blast radius is limited to the workspace you chose to share.
|
|
22
|
+
In practice, this means any mistake can be reverted from your git remote, and even a compromised agent can't access sensitive files on your machine — SSH keys, credentials, browser data — things a locally-running agent could otherwise read without you ever noticing.
|
|
24
23
|
|
|
25
24
|
> totopo's security approach is basic — it is about the minimal precautions I believe anyone running AI agents should have. If you need more robust protections, look somewhere else.
|
|
26
25
|
|
package/bin/totopo.js
CHANGED
|
@@ -50,10 +50,10 @@ if (!existsSync(new URL("../dist/commands/dev.js", import.meta.url))) {
|
|
|
50
50
|
process.exit(1);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// ---
|
|
53
|
+
// --- migrations check --------------------------------------------------------------------------------------------------------------------
|
|
54
54
|
try {
|
|
55
55
|
const { runMigration } = await import("../dist/lib/migrate-to-latest.js");
|
|
56
|
-
runMigration(process.cwd());
|
|
56
|
+
await runMigration(process.cwd(), false);
|
|
57
57
|
} catch {
|
|
58
58
|
// Non-fatal - migration failure should not block startup
|
|
59
59
|
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -251,7 +251,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
251
251
|
envFilePath,
|
|
252
252
|
hasGit,
|
|
253
253
|
shadowPatterns,
|
|
254
|
-
workspaceName: ctx.
|
|
254
|
+
workspaceName: ctx.workspaceId,
|
|
255
255
|
...(options?.noCache !== undefined && { noCache: options.noCache }),
|
|
256
256
|
};
|
|
257
257
|
startContainer(containerOpts);
|
package/dist/commands/global.js
CHANGED
|
@@ -70,12 +70,12 @@ async function clearAgentMemory() {
|
|
|
70
70
|
if (w === undefined)
|
|
71
71
|
return;
|
|
72
72
|
toClear = [w.workspaceId];
|
|
73
|
-
log.info(`Clearing agent memory for ${w.
|
|
73
|
+
log.info(`Clearing agent memory for ${w.workspaceId}...`);
|
|
74
74
|
}
|
|
75
75
|
else {
|
|
76
76
|
const selected = await multiselect({
|
|
77
77
|
message: "Select workspaces to clear agent memory for: (space to toggle, enter to confirm)",
|
|
78
|
-
options: workspaces.map((w) => ({ value: w.workspaceId, label: w.
|
|
78
|
+
options: workspaces.map((w) => ({ value: w.workspaceId, label: w.workspaceId, hint: w.workspaceRoot })),
|
|
79
79
|
required: false,
|
|
80
80
|
});
|
|
81
81
|
if (isCancel(selected)) {
|
|
@@ -95,7 +95,7 @@ async function clearAgentMemory() {
|
|
|
95
95
|
const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
|
|
96
96
|
if (isRunning) {
|
|
97
97
|
const confirmed = await confirm({
|
|
98
|
-
message: `Container for ${w.
|
|
98
|
+
message: `Container for ${w.workspaceId} is running. Stop it to clear memory?`,
|
|
99
99
|
});
|
|
100
100
|
if (isCancel(confirmed) || !confirmed)
|
|
101
101
|
continue;
|
|
@@ -104,7 +104,7 @@ async function clearAgentMemory() {
|
|
|
104
104
|
}
|
|
105
105
|
const agentsDir = join(w.workspaceDir, AGENTS_DIR);
|
|
106
106
|
safeRmSync(agentsDir, { recursive: true, force: true });
|
|
107
|
-
log.success(`Cleared agent memory for ${w.
|
|
107
|
+
log.success(`Cleared agent memory for ${w.workspaceId}.`);
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
// --- Remove images (multi-select across all workspaces) ----------------------------------------------------------------------------------
|
|
@@ -163,7 +163,7 @@ async function uninstallWorkspaces(currentWorkspaceId) {
|
|
|
163
163
|
message: "Select workspaces to uninstall: (space to toggle, enter to confirm)",
|
|
164
164
|
options: sorted.map((w) => ({
|
|
165
165
|
value: w.workspaceId,
|
|
166
|
-
label: w.
|
|
166
|
+
label: w.workspaceId,
|
|
167
167
|
hint: w.workspaceRoot + (w.workspaceId === currentWorkspaceId ? " (current)" : ""),
|
|
168
168
|
})),
|
|
169
169
|
required: false,
|
|
@@ -199,7 +199,7 @@ async function uninstallWorkspaces(currentWorkspaceId) {
|
|
|
199
199
|
removeTotopoYaml = !isCancel(ans) && ans;
|
|
200
200
|
}
|
|
201
201
|
removeWorkspaceFiles(w.workspaceRoot, w.workspaceDir, removeTotopoYaml);
|
|
202
|
-
log.success(`Uninstalled workspace ${w.
|
|
202
|
+
log.success(`Uninstalled workspace ${w.workspaceId}.`);
|
|
203
203
|
}
|
|
204
204
|
return currentWorkspaceId !== undefined && selectedIds.includes(currentWorkspaceId);
|
|
205
205
|
}
|
package/dist/commands/menu.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function run(args) {
|
|
|
16
16
|
// --- Status box ----------------------------------------------------------------------------------------------------------------------
|
|
17
17
|
const containerStatus = workspaceRunning ? "running" : "stopped";
|
|
18
18
|
const gitNotice = hasGit ? "" : `\n${styleText("yellow", "●")} no git — agent changes are not tracked`;
|
|
19
|
-
box(`workspace: ${ctx.
|
|
19
|
+
box(`workspace: ${ctx.workspaceId}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}`, ` totopo v${version} `, {
|
|
20
20
|
contentAlign: "left",
|
|
21
21
|
titleAlign: "center",
|
|
22
22
|
width: "auto",
|
package/dist/commands/onboard.js
CHANGED
|
@@ -60,11 +60,6 @@ export async function run(cwd) {
|
|
|
60
60
|
}
|
|
61
61
|
workspaceRoot = yamlDir;
|
|
62
62
|
yaml = existing;
|
|
63
|
-
// Show welcome message
|
|
64
|
-
if (yaml.name) {
|
|
65
|
-
log.info(yaml.name);
|
|
66
|
-
process.stdout.write("\n");
|
|
67
|
-
}
|
|
68
63
|
const ok = await confirm({ message: `Set up totopo for: ${toTildePath(workspaceRoot)}?` });
|
|
69
64
|
if (isCancel(ok) || !ok) {
|
|
70
65
|
cancel("Setup cancelled.");
|
|
@@ -109,22 +104,23 @@ export async function run(cwd) {
|
|
|
109
104
|
else {
|
|
110
105
|
workspaceRoot = rootChoice;
|
|
111
106
|
}
|
|
112
|
-
// Ask for workspace
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
message: "Workspace
|
|
116
|
-
placeholder:
|
|
117
|
-
defaultValue:
|
|
107
|
+
// Ask for workspace ID
|
|
108
|
+
const defaultId = slugifyForWorkspaceId(basename(workspaceRoot));
|
|
109
|
+
const idInput = await text({
|
|
110
|
+
message: "Workspace ID:",
|
|
111
|
+
placeholder: defaultId,
|
|
112
|
+
defaultValue: defaultId,
|
|
113
|
+
validate: (v) => validateWorkspaceId((v ?? "").trim() || defaultId),
|
|
118
114
|
});
|
|
119
|
-
if (isCancel(
|
|
115
|
+
if (isCancel(idInput)) {
|
|
120
116
|
cancel("Setup cancelled.");
|
|
121
117
|
return null;
|
|
122
118
|
}
|
|
123
|
-
const
|
|
124
|
-
//
|
|
125
|
-
const workspaceId = deriveUniqueWorkspaceId(
|
|
119
|
+
const inputId = idInput.trim() || defaultId;
|
|
120
|
+
// Auto-resolve collisions with numeric suffix
|
|
121
|
+
const workspaceId = deriveUniqueWorkspaceId(inputId, workspaceRoot);
|
|
126
122
|
// Build and write totopo.yaml
|
|
127
|
-
yaml = buildDefaultTotopoYaml(workspaceId
|
|
123
|
+
yaml = buildDefaultTotopoYaml(workspaceId);
|
|
128
124
|
writeTotopoYaml(workspaceRoot, yaml);
|
|
129
125
|
log.success(`Created ${toTildePath(join(workspaceRoot, TOTOPO_YAML))}`);
|
|
130
126
|
}
|
|
@@ -205,6 +201,5 @@ export async function run(cwd) {
|
|
|
205
201
|
workspaceRoot,
|
|
206
202
|
containerName: deriveContainerName(finalId),
|
|
207
203
|
workspaceDir: getWorkspaceDir(finalId),
|
|
208
|
-
displayName: yaml.name || finalId,
|
|
209
204
|
};
|
|
210
205
|
}
|
|
@@ -147,12 +147,12 @@ async function resetTotopoYaml(ctx) {
|
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
149
|
note("This will reset totopo.yaml to factory defaults.\n" +
|
|
150
|
-
"Your workspace_id
|
|
150
|
+
"Your workspace_id will be preserved.\n" +
|
|
151
151
|
"Shadow paths, profiles, and env_file will be reset to defaults.", "Reset totopo.yaml");
|
|
152
152
|
const confirmed = await confirm({ message: "Reset totopo.yaml to defaults?" });
|
|
153
153
|
if (isCancel(confirmed) || !confirmed)
|
|
154
154
|
return;
|
|
155
|
-
const freshYaml = buildDefaultTotopoYaml(yaml.workspace_id
|
|
155
|
+
const freshYaml = buildDefaultTotopoYaml(yaml.workspace_id);
|
|
156
156
|
writeTotopoYaml(ctx.workspaceRoot, freshYaml);
|
|
157
157
|
log.success("totopo.yaml reset to defaults.");
|
|
158
158
|
await promptStopContainer(ctx);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// v2.x (~/.totopo/projects/<sha256-hash>/)
|
|
7
7
|
// Each workspace stored as: meta.json, settings.json, agents/, shadows/
|
|
8
8
|
// Global API keys in ~/.totopo/.env
|
|
9
|
-
// Optional totopo.yaml with name field (
|
|
9
|
+
// Optional totopo.yaml with name field (removed in v3.3)
|
|
10
10
|
//
|
|
11
11
|
// v3-rc-1/rc-2 (~/.totopo/workspaces/<workspace_id>/)
|
|
12
12
|
// Renamed projects/ to workspaces/, hash dirs to workspace_id dirs
|
|
@@ -16,13 +16,16 @@
|
|
|
16
16
|
// v3-rc-3+ (latest)
|
|
17
17
|
// project_id renamed to workspace_id in totopo.yaml
|
|
18
18
|
//
|
|
19
|
+
// v3.2.1 and earlier
|
|
20
|
+
// totopo.yaml had schema_version field and yaml-language-server header (both redundant)
|
|
21
|
+
//
|
|
19
22
|
// All migrations are idempotent - each checks if needed and skips if not.
|
|
20
23
|
// =========================================================================================================================================
|
|
21
24
|
import { spawnSync } from "node:child_process";
|
|
22
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
23
26
|
import { homedir } from "node:os";
|
|
24
27
|
import { join } from "node:path";
|
|
25
|
-
import { log } from "@clack/prompts";
|
|
28
|
+
import { confirm, isCancel, log } from "@clack/prompts";
|
|
26
29
|
import { load as loadYaml } from "js-yaml";
|
|
27
30
|
import { AGENTS_DIR, CONTAINER_STARTUP, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
|
|
28
31
|
import { safeRmSync } from "./safe-rm.js";
|
|
@@ -54,18 +57,6 @@ function readV2ShadowPaths(dirPath) {
|
|
|
54
57
|
return [];
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
|
-
function readV2YamlName(workspaceRoot) {
|
|
58
|
-
try {
|
|
59
|
-
const raw = loadYaml(readFileSync(join(workspaceRoot, TOTOPO_YAML), "utf8"));
|
|
60
|
-
if (typeof raw !== "object" || raw === null)
|
|
61
|
-
return null;
|
|
62
|
-
const obj = raw;
|
|
63
|
-
return typeof obj.name === "string" ? obj.name : null;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
60
|
function detectV2Projects() {
|
|
70
61
|
const baseDir = getWorkspacesBaseDir();
|
|
71
62
|
if (!existsSync(baseDir))
|
|
@@ -123,9 +114,8 @@ function migrateSingleV2Workspace(v2, existingIds) {
|
|
|
123
114
|
workspaceId = yaml.workspace_id;
|
|
124
115
|
}
|
|
125
116
|
else {
|
|
126
|
-
const v2Name = readV2YamlName(v2.projectRoot);
|
|
127
117
|
workspaceId = generateUniqueWorkspaceId(v2.displayName, existingIds);
|
|
128
|
-
yaml = buildDefaultTotopoYaml(workspaceId
|
|
118
|
+
yaml = buildDefaultTotopoYaml(workspaceId);
|
|
129
119
|
if (v2.shadowPaths.length > 0) {
|
|
130
120
|
yaml.shadow_paths = [...new Set([...(yaml.shadow_paths ?? []), ...v2.shadowPaths])];
|
|
131
121
|
}
|
|
@@ -161,6 +151,55 @@ function migrateSingleV2Workspace(v2, existingIds) {
|
|
|
161
151
|
// =========================================================================================================================================
|
|
162
152
|
// Migration steps - each is idempotent and checks if needed before acting
|
|
163
153
|
// =========================================================================================================================================
|
|
154
|
+
const V1_WORKSPACE_FILES = ["Dockerfile", "README.md", "post-start.mjs", "settings.json"];
|
|
155
|
+
function getCandidateWorkspaceRoots(cwd) {
|
|
156
|
+
const roots = [cwd];
|
|
157
|
+
const gitRoot = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
158
|
+
cwd,
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
stdio: "pipe",
|
|
161
|
+
});
|
|
162
|
+
const root = (gitRoot.stdout ?? "").trim();
|
|
163
|
+
if (gitRoot.status === 0 && root.length > 0 && root !== cwd)
|
|
164
|
+
roots.push(root);
|
|
165
|
+
return roots;
|
|
166
|
+
}
|
|
167
|
+
function detectLegacyV1WorkspaceDir(cwd) {
|
|
168
|
+
for (const root of getCandidateWorkspaceRoots(cwd)) {
|
|
169
|
+
const legacyDir = join(root, TOTOPO_DIR);
|
|
170
|
+
try {
|
|
171
|
+
if (!statSync(legacyDir).isDirectory())
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const hasLegacyFile = V1_WORKSPACE_FILES.some((file) => existsSync(join(legacyDir, file)));
|
|
178
|
+
if (hasLegacyFile)
|
|
179
|
+
return legacyDir;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* v1.0.3 -> latest: Remove workspace-local .totopo/ artifacts.
|
|
185
|
+
* These files are now bundled in the totopo CLI package.
|
|
186
|
+
*/
|
|
187
|
+
async function migrateLegacyV1WorkspaceArtifacts(cwd, requireConfirmation = true) {
|
|
188
|
+
const legacyDir = detectLegacyV1WorkspaceDir(cwd);
|
|
189
|
+
if (!legacyDir)
|
|
190
|
+
return;
|
|
191
|
+
log.warn(`Found legacy v1 totopo artifacts at ${legacyDir}.\n` +
|
|
192
|
+
" Latest totopo bundles these files in the binary, so this directory can be safely removed.");
|
|
193
|
+
if (requireConfirmation) {
|
|
194
|
+
const shouldRemove = await confirm({ message: "Remove legacy .totopo/ directory?", initialValue: true });
|
|
195
|
+
if (isCancel(shouldRemove) || !shouldRemove) {
|
|
196
|
+
log.info("Kept legacy .totopo/ directory.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
safeRmSync(legacyDir, { recursive: true, force: true });
|
|
201
|
+
log.success("Removed legacy .totopo/ directory.");
|
|
202
|
+
}
|
|
164
203
|
/**
|
|
165
204
|
* v3-rc-1/rc-2 → latest: Rename ~/.totopo/projects/ → ~/.totopo/workspaces/.
|
|
166
205
|
* Stops running containers first because they have bind mounts into the old path.
|
|
@@ -351,25 +390,83 @@ function migrateRemoveLastCliUpdate() {
|
|
|
351
390
|
}
|
|
352
391
|
}
|
|
353
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* v3.2.1 and earlier: Remove deprecated fields from totopo.yaml.
|
|
395
|
+
* - schema_version: redundant, totopo validates with the bundled JSON schema at runtime
|
|
396
|
+
* - yaml-language-server header: created stale versioned URLs
|
|
397
|
+
* - name: redundant, workspace_id serves as both identifier and display name
|
|
398
|
+
* Only migrates the current workspace (found by walking up from cwd).
|
|
399
|
+
*/
|
|
400
|
+
function migrateRemoveDeprecatedYamlFields(cwd) {
|
|
401
|
+
const dir = findTotopoYamlDir(cwd);
|
|
402
|
+
if (!dir)
|
|
403
|
+
return;
|
|
404
|
+
const filePath = join(dir, TOTOPO_YAML);
|
|
405
|
+
try {
|
|
406
|
+
const content = readFileSync(filePath, "utf8");
|
|
407
|
+
const hasSchemaVersion = /^schema_version:\s/m.test(content);
|
|
408
|
+
const hasYamlLsHeader = content.includes("# yaml-language-server:");
|
|
409
|
+
const hasName = /^name:\s/m.test(content);
|
|
410
|
+
if (!hasSchemaVersion && !hasYamlLsHeader && !hasName)
|
|
411
|
+
return;
|
|
412
|
+
const raw = loadYaml(content);
|
|
413
|
+
if (typeof raw !== "object" || raw === null)
|
|
414
|
+
return;
|
|
415
|
+
const obj = raw;
|
|
416
|
+
delete obj.schema_version;
|
|
417
|
+
delete obj.name;
|
|
418
|
+
try {
|
|
419
|
+
writeTotopoYaml(dir, obj);
|
|
420
|
+
readTotopoYaml(dir);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
writeFileSync(filePath, content);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const removed = [];
|
|
427
|
+
if (hasSchemaVersion)
|
|
428
|
+
removed.push("schema_version");
|
|
429
|
+
if (hasYamlLsHeader)
|
|
430
|
+
removed.push("yaml-language-server header");
|
|
431
|
+
if (hasName)
|
|
432
|
+
removed.push("name");
|
|
433
|
+
log.success(`Migrated totopo.yaml: removed ${removed.join(", ")}`);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// Unreadable or invalid yaml - skip
|
|
437
|
+
}
|
|
438
|
+
}
|
|
354
439
|
// Order matters: migrateProjectsDir must run before migrateV2Workspaces because
|
|
355
440
|
// step 2 scans ~/.totopo/workspaces/ which only exists after step 1 renames projects/.
|
|
356
441
|
// Steps 3 and 4 are independent of each other and of steps 1-2.
|
|
357
442
|
// migrateLockFileFormat and migrateLockKeyYamlToRoot must run last so all workspace
|
|
358
443
|
// dirs are in their final location first. migrateLockKeyYamlToRoot runs after
|
|
359
444
|
// migrateLockFileFormat so the latter always writes "root=" for freshly upgraded files.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
445
|
+
function buildMigrations(cwd, skipAnyConfirmations) {
|
|
446
|
+
return [
|
|
447
|
+
{
|
|
448
|
+
from: "v1.0.3",
|
|
449
|
+
description: "Remove workspace-local .totopo/ artifacts",
|
|
450
|
+
run: () => migrateLegacyV1WorkspaceArtifacts(cwd, !skipAnyConfirmations),
|
|
451
|
+
},
|
|
452
|
+
{ from: "v3-rc-1/rc-2", description: "Rename ~/.totopo/projects/ to ~/.totopo/workspaces/", run: migrateProjectsDir },
|
|
453
|
+
{ from: "v2.x", description: "Hash-based dirs to workspace_id-based dirs + totopo.yaml", run: migrateV2Workspaces },
|
|
454
|
+
{ from: "v3-rc-1/rc-2", description: "Rename project_id to workspace_id in totopo.yaml", run: () => migrateTotopoYaml(cwd) },
|
|
455
|
+
{ from: "v2.x", description: "Remove legacy ~/.totopo/.env global key file", run: migrateGlobalEnv },
|
|
456
|
+
{ from: "v3-rc-6", description: "Upgrade .lock files from positional to key=value format", run: migrateLockFileFormat },
|
|
457
|
+
{ from: "v3-rc-8", description: "Rename 'yaml' key to 'root' in .lock files", run: migrateLockKeyYamlToRoot },
|
|
458
|
+
{ from: "v3.1.0", description: "Remove last-cli-update key from .lock files", run: migrateRemoveLastCliUpdate },
|
|
459
|
+
{
|
|
460
|
+
from: "v3.2.1",
|
|
461
|
+
description: "Remove deprecated fields (schema_version, name, yaml-language-server) from totopo.yaml",
|
|
462
|
+
run: () => migrateRemoveDeprecatedYamlFields(cwd),
|
|
463
|
+
},
|
|
464
|
+
];
|
|
465
|
+
}
|
|
369
466
|
/** Run all migrations in order. Called early in bin/totopo.js startup. */
|
|
370
|
-
export function runMigration(cwd) {
|
|
371
|
-
for (const migration of
|
|
372
|
-
migration.run(
|
|
467
|
+
export async function runMigration(cwd, skipAnyConfirmations = true) {
|
|
468
|
+
for (const migration of buildMigrations(cwd, skipAnyConfirmations)) {
|
|
469
|
+
await migration.run();
|
|
373
470
|
}
|
|
374
471
|
}
|
|
375
472
|
// =========================================================================================================================================
|
|
@@ -380,8 +477,16 @@ export function runMigration(cwd) {
|
|
|
380
477
|
/** Check if a running container's image is stale (missing expected files/features). */
|
|
381
478
|
export function isImageStale(containerName) {
|
|
382
479
|
// v3.2.0: startup.mjs replaced post-start.mjs + update-ai-clis.mjs
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
480
|
+
const startupCheck = spawnSync("docker", ["exec", containerName, "test", "-f", CONTAINER_STARTUP], { stdio: "pipe" });
|
|
481
|
+
if (startupCheck.status !== 0)
|
|
482
|
+
return true;
|
|
483
|
+
// v3.3.0: file added to the base image for artifact inspection
|
|
484
|
+
const fileCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/file"], { stdio: "pipe" });
|
|
485
|
+
if (fileCheck.status !== 0)
|
|
486
|
+
return true;
|
|
487
|
+
// v3.3.0: bubblewrap added for Codex sandboxing prerequisites
|
|
488
|
+
const bubblewrapCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/bwrap"], { stdio: "pipe" });
|
|
489
|
+
if (bubblewrapCheck.status !== 0)
|
|
385
490
|
return true;
|
|
386
491
|
return false;
|
|
387
492
|
}
|
package/dist/lib/safe-rm.js
CHANGED
|
@@ -13,14 +13,19 @@ const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
|
|
|
13
13
|
/**
|
|
14
14
|
* Safe wrapper around rmSync. Throws if the path is outside a totopo-owned location:
|
|
15
15
|
* - ~/.totopo/ (workspace caches, agents, shadows, global config)
|
|
16
|
+
* - A directory named .totopo (legacy v1 workspace-local artifacts)
|
|
16
17
|
* - A file named totopo.yaml (workspace config file in any user workspace root)
|
|
17
18
|
* - <tmpdir>/totopo-test-* (test temp directories)
|
|
18
19
|
*/
|
|
19
20
|
export function safeRmSync(path, options) {
|
|
20
21
|
const r = resolve(path);
|
|
21
|
-
const ok = r === TOTOPO_HOME ||
|
|
22
|
+
const ok = r === TOTOPO_HOME ||
|
|
23
|
+
r.startsWith(TOTOPO_HOME + sep) ||
|
|
24
|
+
basename(r) === TOTOPO_DIR ||
|
|
25
|
+
basename(r) === TOTOPO_YAML ||
|
|
26
|
+
r.startsWith(TEST_TMP_PREFIX);
|
|
22
27
|
if (!ok) {
|
|
23
|
-
throw new Error(`safeRmSync: refusing to delete '${r}'
|
|
28
|
+
throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.totopo/, named .totopo, named totopo.yaml, or a test temp dir`);
|
|
24
29
|
}
|
|
25
30
|
rmSync(r, options);
|
|
26
31
|
}
|
package/dist/lib/totopo-yaml.js
CHANGED
|
@@ -78,18 +78,14 @@ export function readTotopoYaml(dir) {
|
|
|
78
78
|
// Every published version (rc or release) has a corresponding git tag created by pnpm rc / pnpm rc:promote.
|
|
79
79
|
// We rely on that tag existing so these URLs resolve correctly for every installed version.
|
|
80
80
|
const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
81
|
-
const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/asafratzon/totopo/v${version}`;
|
|
82
81
|
export const GITHUB_README_URL = `https://github.com/asafratzon/totopo/blob/v${version}/README.md`;
|
|
83
|
-
const YAML_HEADER = `# yaml-language-server: $schema=${GITHUB_RAW_BASE}/schema/totopo.schema.json
|
|
84
|
-
`;
|
|
85
82
|
// Inline comments injected before specific YAML keys (preceded by a blank line)
|
|
86
83
|
const YAML_COMMENTS = {
|
|
87
|
-
workspace_id: "# totopo workspace config
|
|
84
|
+
workspace_id: "# totopo workspace config - run 'npx totopo' from anywhere under this directory tree to start your dev container.\n" +
|
|
88
85
|
"# Ask the AI agent inside the container to help you edit this file if needed.\n" +
|
|
89
86
|
"# This file may be rewritten by totopo (repair, reset, settings changes). Custom comments will not be preserved.",
|
|
90
|
-
shadow_paths: "# .gitignore-style patterns
|
|
91
|
-
profiles: "# Dockerfile profiles
|
|
92
|
-
`# Base Dockerfile: ${GITHUB_RAW_BASE}/templates/Dockerfile\n` +
|
|
87
|
+
shadow_paths: "# .gitignore-style patterns - agents see an empty, isolated copy instead of the real host data.",
|
|
88
|
+
profiles: "# Dockerfile profiles - each adds on top of the totopo base image (Debian + Node.js + git + AI CLIs).\n" +
|
|
93
89
|
"# Switch profiles in the totopo settings menu, or ask the agent inside the container to help you add a new one.",
|
|
94
90
|
};
|
|
95
91
|
/** Write totopo.yaml to a directory with schema header and inline comments. */
|
|
@@ -113,7 +109,7 @@ export function writeTotopoYaml(dir, config) {
|
|
|
113
109
|
output.push(line);
|
|
114
110
|
}
|
|
115
111
|
const body = output.join("\n").trimEnd();
|
|
116
|
-
writeFileSync(filePath, `${
|
|
112
|
+
writeFileSync(filePath, `${body}\n${PROFILES_FOOTER_COMMENT}\n`);
|
|
117
113
|
}
|
|
118
114
|
// --- Defaults ----------------------------------------------------------------------------------------------------------------------------
|
|
119
115
|
const DEFAULT_PROFILE_HOOK = `# No extras — uses the totopo base image as-is (Node.js + git + AI CLIs).
|
|
@@ -138,9 +134,8 @@ RUN curl -fsSL https://bun.sh/install | bash
|
|
|
138
134
|
// Appended after the last profile to hint at adding more
|
|
139
135
|
const PROFILES_FOOTER_COMMENT = " # Add more profiles here — or ask the agent inside the container to set one up for you.";
|
|
140
136
|
/** Create a default TotopoYamlConfig with sane defaults. */
|
|
141
|
-
export function buildDefaultTotopoYaml(workspaceId
|
|
142
|
-
|
|
143
|
-
schema_version: 3,
|
|
137
|
+
export function buildDefaultTotopoYaml(workspaceId) {
|
|
138
|
+
return {
|
|
144
139
|
workspace_id: workspaceId,
|
|
145
140
|
shadow_paths: [...DEFAULT_SHADOW_PATHS],
|
|
146
141
|
profiles: {
|
|
@@ -154,18 +149,10 @@ export function buildDefaultTotopoYaml(workspaceId, name) {
|
|
|
154
149
|
},
|
|
155
150
|
},
|
|
156
151
|
};
|
|
157
|
-
if (name)
|
|
158
|
-
config.name = name;
|
|
159
|
-
// Reorder keys so name appears after workspace_id
|
|
160
|
-
const { schema_version, workspace_id, name: n, ...rest } = config;
|
|
161
|
-
const ordered = { schema_version, workspace_id };
|
|
162
|
-
if (n !== undefined)
|
|
163
|
-
ordered.name = n;
|
|
164
|
-
return Object.assign(ordered, rest);
|
|
165
152
|
}
|
|
166
153
|
// --- Repair -------------------------------------------------------------------------------------------------------------------------------
|
|
167
154
|
/** Set of keys that TotopoYamlConfig allows (used to strip unknown fields). */
|
|
168
|
-
const KNOWN_KEYS = new Set(["
|
|
155
|
+
const KNOWN_KEYS = new Set(["workspace_id", "env_file", "shadow_paths", "profiles"]);
|
|
169
156
|
/**
|
|
170
157
|
* Attempt to repair an invalid totopo.yaml on disk.
|
|
171
158
|
* Strips unknown fields, fills missing required/optional fields from defaults,
|
|
@@ -192,10 +179,6 @@ export function repairTotopoYaml(dir) {
|
|
|
192
179
|
const fallbackId = slugifyForWorkspaceId(basename(dir));
|
|
193
180
|
const defaults = buildDefaultTotopoYaml(obj.workspace_id || fallbackId);
|
|
194
181
|
// Fill missing required fields
|
|
195
|
-
if (!("schema_version" in obj)) {
|
|
196
|
-
obj.schema_version = defaults.schema_version;
|
|
197
|
-
fixes.push("added missing schema_version");
|
|
198
|
-
}
|
|
199
182
|
if (!("workspace_id" in obj)) {
|
|
200
183
|
obj.workspace_id = defaults.workspace_id;
|
|
201
184
|
fixes.push(`added missing workspace_id ("${defaults.workspace_id}")`);
|
|
@@ -126,7 +126,6 @@ export function listWorkspaces() {
|
|
|
126
126
|
workspaceRoot: lockPath,
|
|
127
127
|
containerName: deriveContainerName(workspaceId),
|
|
128
128
|
workspaceDir: getWorkspaceDir(workspaceId),
|
|
129
|
-
displayName: yaml.name || workspaceId,
|
|
130
129
|
};
|
|
131
130
|
}
|
|
132
131
|
catch {
|
|
@@ -154,7 +153,6 @@ export function resolveWorkspace(fromPath) {
|
|
|
154
153
|
workspaceRoot: current,
|
|
155
154
|
containerName: deriveContainerName(yaml.workspace_id),
|
|
156
155
|
workspaceDir: getWorkspaceDir(yaml.workspace_id),
|
|
157
|
-
displayName: yaml.name || yaml.workspace_id,
|
|
158
156
|
};
|
|
159
157
|
}
|
|
160
158
|
// totopo.yaml found but workspace not initialized or lock mismatch - return null
|
package/package.json
CHANGED
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
"title": "totopo.yaml",
|
|
4
4
|
"description": "Configuration file for totopo — secure AI dev containers",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"required": ["
|
|
6
|
+
"required": ["workspace_id"],
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
|
-
"schema_version": {
|
|
10
|
-
"type": "integer",
|
|
11
|
-
"const": 3,
|
|
12
|
-
"description": "Schema version — must be 3"
|
|
13
|
-
},
|
|
14
9
|
"workspace_id": {
|
|
15
10
|
"type": "string",
|
|
16
11
|
"pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
|
@@ -18,10 +13,6 @@
|
|
|
18
13
|
"maxLength": 48,
|
|
19
14
|
"description": "Unique workspace identifier. Used for container naming and cache directory. Lowercase alphanumeric and hyphens only."
|
|
20
15
|
},
|
|
21
|
-
"name": {
|
|
22
|
-
"type": "string",
|
|
23
|
-
"description": "Human-readable workspace name (shown in menus and welcome message)"
|
|
24
|
-
},
|
|
25
16
|
"env_file": {
|
|
26
17
|
"type": "string",
|
|
27
18
|
"description": "Path to env file relative to totopo.yaml (e.g. '.env'). Injected into container at runtime via --env-file."
|
package/templates/Dockerfile
CHANGED
|
@@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
23
23
|
# Build essentials
|
|
24
24
|
build-essential pkg-config libssl-dev \
|
|
25
25
|
# Utilities
|
|
26
|
-
jq unzip zip tree htop procps lsb-release gnupg ca-certificates sox \
|
|
26
|
+
jq unzip zip tree htop procps lsb-release gnupg ca-certificates sox file bubblewrap \
|
|
27
27
|
# Modern search/navigation tools
|
|
28
28
|
ripgrep fzf \
|
|
29
29
|
# Database clients
|