totopo 3.6.1 → 3.7.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 +3 -3
- package/dist/commands/dev.js +8 -2
- package/dist/commands/onboard.js +1 -1
- package/dist/commands/workspace.js +3 -2
- package/dist/lib/migrate-to-latest.js +1 -1
- package/dist/lib/shadows.js +48 -5
- package/dist/lib/totopo-yaml.js +21 -8
- package/package.json +1 -1
- package/templates/context/git-mode-local.md +1 -3
- package/templates/context/git-mode-strict.md +1 -3
- package/templates/context/git-mode-unrestricted.md +1 -1
- package/templates/context/git-unavailable.md +1 -1
- package/templates/context/responsibilities.md +1 -1
- package/templates/context/shadow-paths.md +3 -2
- package/templates/context/workspace.md +1 -1
- package/templates/startup-git-mode.mjs +3 -1
- package/templates/startup.mjs +62 -6
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Local sandbox for AI agents.
|
|
|
14
14
|
|
|
15
15
|
Developers who use `claude`, `codex`, or `opencode` **interactively** — one human pair-programming with one agent.
|
|
16
16
|
|
|
17
|
-
totopo
|
|
17
|
+
totopo isn't an orchestration tool (no SDK, no parallel agents, no per-run worktrees), and its security is basic — just the minimum precautions I think anyone running AI agents should take. If you need more on either front, look elsewhere.
|
|
18
18
|
|
|
19
19
|
## Motivation
|
|
20
20
|
|
|
@@ -27,8 +27,6 @@ Totopo mitigates both risks by letting you run agents in a dev container — whe
|
|
|
27
27
|
|
|
28
28
|
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 do without you ever noticing.
|
|
29
29
|
|
|
30
|
-
> 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.
|
|
31
|
-
|
|
32
30
|
## Requirements
|
|
33
31
|
|
|
34
32
|
- [Docker](https://www.docker.com/products/docker-desktop/) — builds and runs the dev container
|
|
@@ -166,6 +164,8 @@ shadow_paths:
|
|
|
166
164
|
|
|
167
165
|
Patterns follow gitignore syntax — patterns without a `/` match at any depth. Manage via **Manage Workspace > Shadow paths** or edit `totopo.yaml` directly. Changes take effect on the next session.
|
|
168
166
|
|
|
167
|
+
Git-tracked paths are skipped to avoid worktree diversions. Shadowing them has no privacy benefit anyway since agents can `git show` tracked content. To hide a file, untrack it and add it to `.gitignore` first.
|
|
168
|
+
|
|
169
169
|
Common use cases:
|
|
170
170
|
- **Separate `node_modules`** — the container installs its own dependencies, avoiding platform conflicts between host and container.
|
|
171
171
|
- **Hide sensitive files** — keep credentials and secrets invisible to agents.
|
package/dist/commands/dev.js
CHANGED
|
@@ -103,7 +103,10 @@ function stopAndRemoveContainer(containerName) {
|
|
|
103
103
|
}
|
|
104
104
|
// --- Run startup checks (AI CLI update + readiness validation) ---------------------------------------------------------------------------
|
|
105
105
|
function runStartup(containerName, quiet) {
|
|
106
|
-
|
|
106
|
+
// The SPACE-to-skip prompt in startup.mjs needs raw-mode stdin (-i) and a PTY (-t).
|
|
107
|
+
// Omitted when quiet so test output stays pipe-capturable.
|
|
108
|
+
const ttyFlags = quiet ? [] : ["-i", "-t"];
|
|
109
|
+
const result = spawnSync("docker", ["exec", "-u", "root", ...ttyFlags, containerName, "node", CONTAINER_STARTUP], {
|
|
107
110
|
stdio: quiet ? "pipe" : "inherit",
|
|
108
111
|
});
|
|
109
112
|
return result.status === 0;
|
|
@@ -263,10 +266,13 @@ export async function run(packageDir, ctx, options) {
|
|
|
263
266
|
const profileHook = profileConfig?.dockerfile_hook;
|
|
264
267
|
// --- Shadow path expansion -----------------------------------------------------------------------------------------------------------
|
|
265
268
|
const shadowPatterns = yaml.shadow_paths ?? [];
|
|
266
|
-
const expandedShadows = expandShadowPatterns(shadowPatterns, workspaceDir);
|
|
269
|
+
const { paths: expandedShadows, skippedTracked } = expandShadowPatterns(shadowPatterns, workspaceDir);
|
|
267
270
|
if (expandedShadows.length > 0) {
|
|
268
271
|
log.warn(`Shadow paths active: ${expandedShadows.join(", ")} (Settings > Shadow paths)`);
|
|
269
272
|
}
|
|
273
|
+
if (skippedTracked.length > 0) {
|
|
274
|
+
log.warn(`Skipped ${skippedTracked.length} shadow path(s) tracked by git`);
|
|
275
|
+
}
|
|
270
276
|
// --- Env file ------------------------------------------------------------------------------------------------------------------------
|
|
271
277
|
let envFilePath;
|
|
272
278
|
if (yaml.env_file) {
|
package/dist/commands/onboard.js
CHANGED
|
@@ -121,7 +121,7 @@ export async function run(cwd) {
|
|
|
121
121
|
const workspaceId = deriveUniqueWorkspaceId(inputId, workspaceRoot);
|
|
122
122
|
// Build and write totopo.yaml
|
|
123
123
|
yaml = buildDefaultTotopoYaml(workspaceId);
|
|
124
|
-
writeTotopoYaml(workspaceRoot, yaml);
|
|
124
|
+
writeTotopoYaml(workspaceRoot, yaml, { includeExtendedTemplate: true });
|
|
125
125
|
log.success(`Created ${toTildePath(join(workspaceRoot, TOTOPO_YAML))}`);
|
|
126
126
|
}
|
|
127
127
|
// --- Non-git warning -----------------------------------------------------------------------------------------------------------------
|
|
@@ -28,7 +28,8 @@ async function shadowPathsMenu(ctx) {
|
|
|
28
28
|
}
|
|
29
29
|
log.message("Shadow patterns block the agent from seeing matching host paths —\n" +
|
|
30
30
|
"the container gets an empty, isolated copy instead.\n" +
|
|
31
|
-
"Supports gitignore-style patterns (e.g. node_modules, .env*)
|
|
31
|
+
"Supports gitignore-style patterns (e.g. node_modules, .env*).\n" +
|
|
32
|
+
"Git-tracked paths are skipped to avoid worktree diversions.");
|
|
32
33
|
const options = [{ value: "add", label: "Add pattern or path" }];
|
|
33
34
|
if (patterns.length > 0) {
|
|
34
35
|
options.push({ value: "remove", label: "Remove patterns" });
|
|
@@ -196,7 +197,7 @@ async function resetTotopoYaml(ctx) {
|
|
|
196
197
|
if (isCancel(confirmed) || !confirmed)
|
|
197
198
|
return;
|
|
198
199
|
const freshYaml = buildDefaultTotopoYaml(yaml.workspace_id);
|
|
199
|
-
writeTotopoYaml(ctx.workspaceRoot, freshYaml);
|
|
200
|
+
writeTotopoYaml(ctx.workspaceRoot, freshYaml, { includeExtendedTemplate: true });
|
|
200
201
|
log.success("totopo.yaml reset to defaults.");
|
|
201
202
|
await promptStopContainer(ctx);
|
|
202
203
|
}
|
|
@@ -119,7 +119,7 @@ function migrateSingleV2Workspace(v2, existingIds) {
|
|
|
119
119
|
if (v2.shadowPaths.length > 0) {
|
|
120
120
|
yaml.shadow_paths = [...new Set([...(yaml.shadow_paths ?? []), ...v2.shadowPaths])];
|
|
121
121
|
}
|
|
122
|
-
writeTotopoYaml(v2.projectRoot, yaml);
|
|
122
|
+
writeTotopoYaml(v2.projectRoot, yaml, { includeExtendedTemplate: true });
|
|
123
123
|
log.info(`Created totopo.yaml for "${v2.displayName}" (workspace_id: ${workspaceId})`);
|
|
124
124
|
}
|
|
125
125
|
const newDir = join(getWorkspacesBaseDir(), workspaceId);
|
package/dist/lib/shadows.js
CHANGED
|
@@ -2,22 +2,26 @@
|
|
|
2
2
|
// src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
|
|
3
3
|
// Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
|
|
4
4
|
// =========================================================================================================================================
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join, relative } from "node:path";
|
|
7
8
|
import fg from "fast-glob";
|
|
8
9
|
import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
|
|
9
10
|
import { safeRmSync } from "./safe-rm.js";
|
|
10
|
-
// --- Pattern expansion -------------------------------------------------------------------------------------------------------------------
|
|
11
11
|
/**
|
|
12
12
|
* Expand gitignore-style patterns into concrete relative paths.
|
|
13
13
|
*
|
|
14
14
|
* Patterns without a directory separator are treated as recursive (prepended with **/)
|
|
15
15
|
* following gitignore convention. Patterns with a / are matched relative to the workspace root.
|
|
16
16
|
* Matched directories are not recursed into (e.g. node_modules matches once, not its children).
|
|
17
|
+
*
|
|
18
|
+
* Paths that git tracks (or whose descendants git tracks) are dropped from the result and
|
|
19
|
+
* reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
|
|
20
|
+
* and breaks `git stash`/`pop` workflows.
|
|
17
21
|
*/
|
|
18
22
|
export function expandShadowPatterns(patterns, workspaceRoot) {
|
|
19
23
|
if (patterns.length === 0)
|
|
20
|
-
return [];
|
|
24
|
+
return { paths: [], skippedTracked: [] };
|
|
21
25
|
// Convert gitignore-style patterns to fast-glob patterns
|
|
22
26
|
const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
|
|
23
27
|
// Build ignore list: skip .git and contents of any matched directory
|
|
@@ -28,12 +32,51 @@ export function expandShadowPatterns(patterns, workspaceRoot) {
|
|
|
28
32
|
dot: true,
|
|
29
33
|
ignore: ignorePatterns,
|
|
30
34
|
});
|
|
31
|
-
|
|
35
|
+
const expanded = removeNestedPaths(results.sort());
|
|
36
|
+
const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
|
|
37
|
+
return { paths: kept, skippedTracked: dropped };
|
|
32
38
|
}
|
|
33
39
|
// --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
|
|
34
|
-
/** Count how many paths a pattern would match in the workspace. */
|
|
40
|
+
/** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
|
|
35
41
|
export function countPatternHits(pattern, workspaceRoot) {
|
|
36
|
-
return expandShadowPatterns([pattern], workspaceRoot).length;
|
|
42
|
+
return expandShadowPatterns([pattern], workspaceRoot).paths.length;
|
|
43
|
+
}
|
|
44
|
+
// --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
|
|
45
|
+
/**
|
|
46
|
+
* Partition expanded shadow paths into those safe to shadow and those tracked by git.
|
|
47
|
+
* A path is dropped if it equals a tracked file, or (for directories) any tracked file
|
|
48
|
+
* lives anywhere beneath it. Returns the input unchanged when no .git is present.
|
|
49
|
+
*/
|
|
50
|
+
function filterGitTrackedPaths(paths, workspaceRoot) {
|
|
51
|
+
if (paths.length === 0)
|
|
52
|
+
return { kept: [], dropped: [] };
|
|
53
|
+
// .git may be a file (worktrees) or a directory; existsSync handles both
|
|
54
|
+
if (!existsSync(join(workspaceRoot, ".git")))
|
|
55
|
+
return { kept: paths, dropped: [] };
|
|
56
|
+
// Pass shadow paths as pathspecs so git enumerates only tracked files within them.
|
|
57
|
+
// Output then scales with shadow scope (typically tiny), not the size of the repo index.
|
|
58
|
+
const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
61
|
+
});
|
|
62
|
+
if (result.status !== 0)
|
|
63
|
+
return { kept: paths, dropped: [] };
|
|
64
|
+
const matched = result.stdout.split("\0").filter((s) => s.length > 0);
|
|
65
|
+
if (matched.length === 0)
|
|
66
|
+
return { kept: paths, dropped: [] };
|
|
67
|
+
const matchedSet = new Set(matched);
|
|
68
|
+
const kept = [];
|
|
69
|
+
const dropped = [];
|
|
70
|
+
for (const p of paths) {
|
|
71
|
+
const prefix = `${p}/`;
|
|
72
|
+
if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
|
|
73
|
+
dropped.push(p);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
kept.push(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { kept, dropped };
|
|
37
80
|
}
|
|
38
81
|
// --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
|
|
39
82
|
/**
|
package/dist/lib/totopo-yaml.js
CHANGED
|
@@ -89,7 +89,7 @@ const YAML_COMMENTS = {
|
|
|
89
89
|
"# When multiple profiles exist, totopo prompts you to pick one on session start.",
|
|
90
90
|
};
|
|
91
91
|
/** Write totopo.yaml to a directory with schema header and inline comments. */
|
|
92
|
-
export function writeTotopoYaml(dir, config) {
|
|
92
|
+
export function writeTotopoYaml(dir, config, opts = {}) {
|
|
93
93
|
const filePath = join(dir, TOTOPO_YAML);
|
|
94
94
|
const yamlContent = dumpYaml(config, {
|
|
95
95
|
lineWidth: -1,
|
|
@@ -109,11 +109,14 @@ export function writeTotopoYaml(dir, config) {
|
|
|
109
109
|
output.push(line);
|
|
110
110
|
}
|
|
111
111
|
const body = output.join("\n").trimEnd();
|
|
112
|
-
|
|
112
|
+
const extendedBlock = opts.includeExtendedTemplate ? `\n\n${EXTENDED_PROFILE_TEMPLATE_PROMPT}\n${renderExtendedAsCommented()}` : "";
|
|
113
|
+
writeFileSync(filePath, `${body}${extendedBlock}\n${PROFILES_FOOTER_COMMENT}\n`);
|
|
113
114
|
}
|
|
114
115
|
// --- Defaults ----------------------------------------------------------------------------------------------------------------------------
|
|
116
|
+
const DEFAULT_PROFILE_DESCRIPTION = "Base image: Node.js, git, and AI CLIs";
|
|
115
117
|
const DEFAULT_PROFILE_HOOK = `# No extras — uses the totopo base image as-is (Node.js + git + AI CLIs).
|
|
116
118
|
`;
|
|
119
|
+
const EXTENDED_PROFILE_DESCRIPTION = "Base image + Go, Java, Rust, and Bun";
|
|
117
120
|
const EXTENDED_PROFILE_HOOK = `# Go
|
|
118
121
|
RUN apt-get update && apt-get install -y --no-install-recommends golang-go && rm -rf /var/lib/apt/lists/*
|
|
119
122
|
|
|
@@ -133,6 +136,20 @@ RUN curl -fsSL https://bun.sh/install | bash
|
|
|
133
136
|
`;
|
|
134
137
|
// Appended after the last profile to hint at adding more
|
|
135
138
|
const PROFILES_FOOTER_COMMENT = " # Add more profiles here — or ask the agent inside the container to set one up for you.";
|
|
139
|
+
const EXTENDED_PROFILE_TEMPLATE_PROMPT = " # Uncomment to enable additional runtimes (Go, Java, Rust, Bun):";
|
|
140
|
+
/**
|
|
141
|
+
* Render the `extended` profile as commented-out YAML, indented to live under `profiles:`.
|
|
142
|
+
* Generated via the same dumpYaml call as the live config so uncommenting (strip leading `# `
|
|
143
|
+
* from each line) yields YAML the parser/schema accepts without drift.
|
|
144
|
+
*/
|
|
145
|
+
function renderExtendedAsCommented() {
|
|
146
|
+
const dumped = dumpYaml({ extended: { description: EXTENDED_PROFILE_DESCRIPTION, dockerfile_hook: EXTENDED_PROFILE_HOOK } }, { lineWidth: -1, quotingType: '"', forceQuotes: false });
|
|
147
|
+
return dumped
|
|
148
|
+
.split("\n")
|
|
149
|
+
.map((line) => (line.length === 0 ? "" : ` # ${line}`))
|
|
150
|
+
.join("\n")
|
|
151
|
+
.trimEnd();
|
|
152
|
+
}
|
|
136
153
|
/** Create a default TotopoYamlConfig with sane defaults. */
|
|
137
154
|
export function buildDefaultTotopoYaml(workspaceId) {
|
|
138
155
|
return {
|
|
@@ -141,13 +158,9 @@ export function buildDefaultTotopoYaml(workspaceId) {
|
|
|
141
158
|
shadow_paths: [...DEFAULT_SHADOW_PATHS],
|
|
142
159
|
profiles: {
|
|
143
160
|
default: {
|
|
144
|
-
description:
|
|
161
|
+
description: DEFAULT_PROFILE_DESCRIPTION,
|
|
145
162
|
dockerfile_hook: DEFAULT_PROFILE_HOOK,
|
|
146
163
|
},
|
|
147
|
-
extended: {
|
|
148
|
-
description: "Base image + Go, Java, Rust, and Bun",
|
|
149
|
-
dockerfile_hook: EXTENDED_PROFILE_HOOK,
|
|
150
|
-
},
|
|
151
164
|
},
|
|
152
165
|
};
|
|
153
166
|
}
|
|
@@ -201,7 +214,7 @@ export function repairTotopoYaml(dir) {
|
|
|
201
214
|
return { repairedYaml: null, error: `${TOTOPO_YAML} could not be repaired: ${formatValidationErrors(validate.errors)}` };
|
|
202
215
|
}
|
|
203
216
|
const yaml = obj;
|
|
204
|
-
writeTotopoYaml(dir, yaml);
|
|
217
|
+
writeTotopoYaml(dir, yaml, { includeExtendedTemplate: fixes.includes("added default profiles") });
|
|
205
218
|
return { repairedYaml: yaml, message: `Repaired ${TOTOPO_YAML}: ${fixes.join(", ")}` };
|
|
206
219
|
}
|
|
207
220
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
## Git availability
|
|
2
2
|
|
|
3
|
-
The user set git mode to **local** in totopo.
|
|
4
|
-
|
|
5
|
-
Please respect this choice — do not attempt remote git operations (push, pull, fetch, clone).
|
|
3
|
+
The user set git mode to **local** in totopo - do not attempt remote git operations (push, pull, fetch, clone).
|
|
6
4
|
|
|
7
5
|
totopo enforces this by:
|
|
8
6
|
- Blocking remote access (push, pull, fetch, clone).
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
## Git availability
|
|
2
2
|
|
|
3
|
-
The user set git mode to **strict** in totopo.
|
|
4
|
-
|
|
5
|
-
Please respect this choice — do not attempt git operations that modify state or interact with remotes.
|
|
3
|
+
The user set git mode to **strict** in totopo — do not attempt git operations that modify state or interact with remotes.
|
|
6
4
|
|
|
7
5
|
totopo enforces this by:
|
|
8
6
|
- Blocking git commands that would modify the repository (attempts return a clear error).
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
## Git availability
|
|
2
2
|
|
|
3
|
-
The user set git mode to **unrestricted** in totopo. totopo does not enforce any git-specific restrictions in this mode
|
|
3
|
+
The user set git mode to **unrestricted** in totopo. totopo does not enforce any git-specific restrictions in this mode.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
## Your responsibilities at session start
|
|
2
2
|
|
|
3
3
|
At the start of every session:
|
|
4
|
-
- Briefly tell the user they are in a totopo sandbox and mention key limitations (git
|
|
4
|
+
- Briefly tell the user they are in a totopo sandbox and mention key limitations (totopo git mode, no host filesystem access outside the workspace).
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
## Shadow paths
|
|
2
2
|
|
|
3
|
-
The following patterns are overlaid with container-local storage and do not reflect
|
|
4
|
-
the host filesystem:
|
|
3
|
+
The following patterns are overlaid with container-local storage and do not reflect the host filesystem:
|
|
5
4
|
|
|
6
5
|
{{pattern_list}}
|
|
7
6
|
|
|
@@ -9,3 +8,5 @@ Matching paths are initialized empty on first use. The container may accumulate
|
|
|
9
8
|
content in them over time (for example, a shadowed `node_modules` gets
|
|
10
9
|
populated when you run `npm install` inside the container). Do not assume they
|
|
11
10
|
are empty, and do not attempt to sync or restore their host contents.
|
|
11
|
+
|
|
12
|
+
Git-tracked paths are skipped to avoid worktree diversions.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
## Workspace
|
|
2
2
|
|
|
3
|
-
You have access to the full workspace directory at `/workspace`. Some operations
|
|
3
|
+
You have access to the full workspace directory at `/workspace`. Some operations may require running on the host.
|
|
@@ -45,8 +45,10 @@ function removeWrapperIfPresent() {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function applyAsRoot(gitMode, protocolValue, fail) {
|
|
48
|
+
// Use absolute path to bypass /usr/local/bin/git (which may already be the read-only
|
|
49
|
+
// wrapper from a prior strict-mode session and would block this legitimate setup write).
|
|
48
50
|
try {
|
|
49
|
-
execSync(
|
|
51
|
+
execSync(`/usr/bin/git config --system protocol.allow ${protocolValue}`, { stdio: "pipe" });
|
|
50
52
|
} catch {
|
|
51
53
|
fail("git mode", `failed to set protocol.allow=${protocolValue}`);
|
|
52
54
|
}
|
package/templates/startup.mjs
CHANGED
|
@@ -51,19 +51,17 @@ const TIMESTAMP_FILE = "/home/devuser/.ai-cli-updated";
|
|
|
51
51
|
const THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
52
52
|
|
|
53
53
|
let lastUpdate = 0;
|
|
54
|
+
let timestampFileExists = false;
|
|
54
55
|
try {
|
|
55
56
|
const raw = readFileSync(TIMESTAMP_FILE, "utf8").trim();
|
|
56
57
|
lastUpdate = new Date(raw).getTime();
|
|
58
|
+
timestampFileExists = true;
|
|
57
59
|
} catch {
|
|
58
60
|
// File missing or unreadable -- treat as never updated
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
} else if (!isRoot) {
|
|
64
|
-
skip("AI CLIs", "update skipped (requires root)");
|
|
65
|
-
} else {
|
|
66
|
-
console.log(`${blue("●")} ${dim("Updating AI CLIs to latest...")}`);
|
|
63
|
+
const doUpdate = (label) => {
|
|
64
|
+
console.log(`${blue("●")} ${dim(label)}`);
|
|
67
65
|
try {
|
|
68
66
|
execSync("npm install -g opencode-ai@latest @anthropic-ai/claude-code@latest @openai/codex@latest", {
|
|
69
67
|
stdio: "inherit",
|
|
@@ -73,6 +71,64 @@ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
|
|
|
73
71
|
} catch {
|
|
74
72
|
fail("AI CLIs", "update failed -- continuing with existing versions");
|
|
75
73
|
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// SPACE within `seconds` -> skip. Any other input is ignored. Ctrl+C exits 130. Non-TTY -> no skip.
|
|
77
|
+
const promptSkipUpdate = (seconds) =>
|
|
78
|
+
new Promise((resolve) => {
|
|
79
|
+
if (!process.stdin.isTTY) {
|
|
80
|
+
resolve(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let remaining = seconds;
|
|
84
|
+
let tick;
|
|
85
|
+
let timer;
|
|
86
|
+
const line = (s) => `\r\x1b[K${blue("●")} ${dim(`Updating AI CLIs in ${s}s... press SPACE to skip`)}`;
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
clearInterval(tick);
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
process.stdin.removeListener("data", onData);
|
|
91
|
+
process.stdin.setRawMode(false);
|
|
92
|
+
process.stdin.pause();
|
|
93
|
+
process.stdout.write("\r\x1b[K");
|
|
94
|
+
};
|
|
95
|
+
const onData = (chunk) => {
|
|
96
|
+
for (const byte of chunk) {
|
|
97
|
+
if (byte === 0x03) {
|
|
98
|
+
cleanup();
|
|
99
|
+
process.exit(130);
|
|
100
|
+
}
|
|
101
|
+
if (byte === 0x20) {
|
|
102
|
+
cleanup();
|
|
103
|
+
resolve(true);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
process.stdin.setRawMode(true);
|
|
109
|
+
process.stdin.resume();
|
|
110
|
+
process.stdin.on("data", onData);
|
|
111
|
+
process.stdout.write(line(remaining));
|
|
112
|
+
tick = setInterval(() => {
|
|
113
|
+
remaining -= 1;
|
|
114
|
+
if (remaining > 0) process.stdout.write(line(remaining));
|
|
115
|
+
}, 1000);
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
cleanup();
|
|
118
|
+
resolve(false);
|
|
119
|
+
}, seconds * 1000);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
|
|
123
|
+
ok("AI CLIs", "up to date");
|
|
124
|
+
} else if (!isRoot) {
|
|
125
|
+
skip("AI CLIs", "update skipped (requires root)");
|
|
126
|
+
} else if (!timestampFileExists) {
|
|
127
|
+
doUpdate("Installing AI CLIs...");
|
|
128
|
+
} else if (await promptSkipUpdate(5)) {
|
|
129
|
+
skip("AI CLIs", "update skipped by user");
|
|
130
|
+
} else {
|
|
131
|
+
doUpdate("Updating AI CLIs to latest...");
|
|
76
132
|
}
|
|
77
133
|
|
|
78
134
|
// -- Security -----------------------------------------------------------------
|