@fitlab-ai/agent-infra 0.5.9 → 0.6.0
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 +200 -8
- package/README.zh-CN.md +176 -8
- package/bin/{cli.js → cli.ts} +23 -19
- package/dist/bin/cli.js +116 -0
- package/dist/lib/defaults.json +61 -0
- package/dist/lib/init.js +238 -0
- package/dist/lib/log.js +18 -0
- package/dist/lib/merge.js +747 -0
- package/dist/lib/paths.js +18 -0
- package/dist/lib/prompt.js +85 -0
- package/dist/lib/render.js +139 -0
- package/dist/lib/sandbox/commands/create.js +1173 -0
- package/dist/lib/sandbox/commands/enter.js +98 -0
- package/dist/lib/sandbox/commands/ls.js +93 -0
- package/dist/lib/sandbox/commands/rebuild.js +101 -0
- package/dist/lib/sandbox/commands/refresh.js +85 -0
- package/dist/lib/sandbox/commands/rm.js +226 -0
- package/dist/lib/sandbox/commands/vm.js +144 -0
- package/dist/lib/sandbox/config.js +85 -0
- package/dist/lib/sandbox/constants.js +104 -0
- package/dist/lib/sandbox/credentials.js +437 -0
- package/dist/lib/sandbox/dockerfile.js +76 -0
- package/dist/lib/sandbox/dotfiles.js +170 -0
- package/dist/lib/sandbox/engine.js +155 -0
- package/dist/lib/sandbox/engines/colima.js +64 -0
- package/dist/lib/sandbox/engines/docker-desktop.js +27 -0
- package/dist/lib/sandbox/engines/index.js +25 -0
- package/dist/lib/sandbox/engines/native.js +96 -0
- package/dist/lib/sandbox/engines/orbstack.js +63 -0
- package/dist/lib/sandbox/engines/selinux.js +48 -0
- package/dist/lib/sandbox/engines/wsl2-paths.js +47 -0
- package/dist/lib/sandbox/engines/wsl2.js +57 -0
- package/dist/lib/sandbox/index.js +70 -0
- package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +39 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +178 -0
- package/dist/lib/sandbox/runtimes/java17.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/java21.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/node20.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/node22.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/python3.dockerfile +3 -0
- package/dist/lib/sandbox/shell.js +148 -0
- package/dist/lib/sandbox/task-resolver.js +35 -0
- package/dist/lib/sandbox/tools.js +115 -0
- package/dist/lib/update.js +186 -0
- package/dist/lib/version.js +5 -0
- package/dist/package.json +5 -0
- package/lib/{init.js → init.ts} +64 -20
- package/lib/{log.js → log.ts} +4 -4
- package/lib/{merge.js → merge.ts} +129 -63
- package/lib/paths.ts +18 -0
- package/lib/{prompt.js → prompt.ts} +12 -12
- package/lib/{render.js → render.ts} +30 -17
- package/lib/sandbox/commands/create.ts +1507 -0
- package/lib/sandbox/commands/enter.ts +115 -0
- package/lib/sandbox/commands/{ls.js → ls.ts} +41 -10
- package/lib/sandbox/commands/rebuild.ts +135 -0
- package/lib/sandbox/commands/refresh.ts +128 -0
- package/lib/sandbox/commands/{rm.js → rm.ts} +71 -21
- package/lib/sandbox/commands/{vm.js → vm.ts} +62 -15
- package/lib/sandbox/config.ts +133 -0
- package/lib/sandbox/{constants.js → constants.ts} +41 -17
- package/lib/sandbox/credentials.ts +634 -0
- package/lib/sandbox/{dockerfile.js → dockerfile.ts} +13 -6
- package/lib/sandbox/dotfiles.ts +236 -0
- package/lib/sandbox/engine.ts +231 -0
- package/lib/sandbox/engines/colima.ts +81 -0
- package/lib/sandbox/engines/docker-desktop.ts +36 -0
- package/lib/sandbox/engines/index.ts +74 -0
- package/lib/sandbox/engines/native.ts +131 -0
- package/lib/sandbox/engines/orbstack.ts +78 -0
- package/lib/sandbox/engines/selinux.ts +66 -0
- package/lib/sandbox/engines/wsl2-paths.ts +65 -0
- package/lib/sandbox/engines/wsl2.ts +74 -0
- package/lib/sandbox/{index.js → index.ts} +17 -8
- package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
- package/lib/sandbox/runtimes/base.dockerfile +116 -1
- package/lib/sandbox/shell.ts +186 -0
- package/lib/sandbox/{task-resolver.js → task-resolver.ts} +6 -6
- package/lib/sandbox/{tools.js → tools.ts} +33 -29
- package/lib/{update.js → update.ts} +33 -10
- package/package.json +22 -12
- package/templates/.agents/rules/create-issue.github.en.md +2 -4
- package/templates/.agents/rules/create-issue.github.zh-CN.md +2 -4
- package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
- package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +26 -41
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +6 -8
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +6 -8
- package/lib/paths.js +0 -9
- package/lib/sandbox/commands/create.js +0 -1174
- package/lib/sandbox/commands/enter.js +0 -79
- package/lib/sandbox/commands/rebuild.js +0 -102
- package/lib/sandbox/config.js +0 -84
- package/lib/sandbox/engine.js +0 -256
- package/lib/sandbox/shell.js +0 -122
- package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
- /package/lib/{version.js → version.ts} +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { loadConfig } from '../config.ts';
|
|
2
|
+
import { assertValidBranchName, containerNameCandidates } from '../constants.ts';
|
|
3
|
+
import { detectEngine } from '../engine.ts';
|
|
4
|
+
import {
|
|
5
|
+
formatCredentialWarnings,
|
|
6
|
+
formatRemaining,
|
|
7
|
+
reconcileClaudeCredentials,
|
|
8
|
+
redactCommandError,
|
|
9
|
+
validateClaudeCredentialsEnvOverride
|
|
10
|
+
} from '../credentials.ts';
|
|
11
|
+
import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
|
|
12
|
+
import { resolveTaskBranch } from '../task-resolver.ts';
|
|
13
|
+
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
14
|
+
|
|
15
|
+
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
16
|
+
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
17
|
+
|
|
18
|
+
// Terminal-detection variables that interactive TUIs (e.g. claude-code)
|
|
19
|
+
// inspect to enable progressive enhancements such as the kitty keyboard
|
|
20
|
+
// protocol, which is what makes Shift+Enter distinguishable from Enter.
|
|
21
|
+
// `docker exec` does not forward these by default, so we must pass them
|
|
22
|
+
// through explicitly.
|
|
23
|
+
const FORWARDED_TERMINAL_ENV = [
|
|
24
|
+
'TERM_PROGRAM',
|
|
25
|
+
'TERM_PROGRAM_VERSION',
|
|
26
|
+
'LC_TERMINAL',
|
|
27
|
+
'LC_TERMINAL_VERSION'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function terminalEnvFlags(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
31
|
+
const flags: string[] = [];
|
|
32
|
+
for (const name of FORWARDED_TERMINAL_ENV) {
|
|
33
|
+
const value = env[name];
|
|
34
|
+
if (value) {
|
|
35
|
+
flags.push('-e', `${name}=${value}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return flags;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatCredentialSyncStatus(
|
|
42
|
+
result: ReturnType<typeof reconcileClaudeCredentials>,
|
|
43
|
+
isTTY = process.stderr.isTTY
|
|
44
|
+
): string | null {
|
|
45
|
+
if (result.status === 'STALE_ACCESS') {
|
|
46
|
+
return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
|
|
47
|
+
}
|
|
48
|
+
if (result.status === 'MISSING') {
|
|
49
|
+
return 'Warning: Claude Code credentials missing on host. Run "claude /login" to authenticate.\n';
|
|
50
|
+
}
|
|
51
|
+
if (result.status === 'KEYCHAIN_WRITE_FAILED') {
|
|
52
|
+
return `Warning: A sandbox refresh produced newer credentials but host Keychain write failed (${formatCredentialWarnings(result.warnings)}). Run "ai sandbox refresh" again or "claude /status" on the host to retry.\n`;
|
|
53
|
+
}
|
|
54
|
+
if (result.status === 'KEYCHAIN_LOCKED' || result.status === 'KEYCHAIN_ERROR') {
|
|
55
|
+
return 'Warning: Host keychain is unavailable; Claude credential sync skipped. Run "ai sandbox refresh" for details.\n';
|
|
56
|
+
}
|
|
57
|
+
if (result.status === 'OK' && result.authoritative !== 'host') {
|
|
58
|
+
const message = `Synced Claude Code credentials from sandbox refresh back to host (expires in ${formatRemaining(result.expiresAt)})`;
|
|
59
|
+
return isTTY ? `\x1b[2m${message}\x1b[0m\n` : `${message}\n`;
|
|
60
|
+
}
|
|
61
|
+
if (result.status === 'OK' && result.filesWritten.length > 0) {
|
|
62
|
+
const message = `Synced Claude Code credentials from host Keychain (expires in ${formatRemaining(result.expiresAt)})`;
|
|
63
|
+
return isTTY ? `\x1b[2m${message}\x1b[0m\n` : `${message}\n`;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function enter(args: string[]): number {
|
|
69
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
70
|
+
process.stdout.write(`${USAGE}\n`);
|
|
71
|
+
if (args.length === 0) {
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
validateClaudeCredentialsEnvOverride();
|
|
79
|
+
const engine = detectEngine(config);
|
|
80
|
+
const [branchOrTaskId = '', ...cmd] = args;
|
|
81
|
+
const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
|
|
82
|
+
assertValidBranchName(branch);
|
|
83
|
+
const running = runSafeEngine(engine, 'docker', ['ps', '--format', '{{.Names}}']).split('\n');
|
|
84
|
+
const container = containerNameCandidates(config, branch).find((name) => running.includes(name));
|
|
85
|
+
|
|
86
|
+
if (!container) {
|
|
87
|
+
throw new Error(`No running sandbox found for branch '${branch}'`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.tools.includes('claude-code')) {
|
|
91
|
+
try {
|
|
92
|
+
// Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
|
|
93
|
+
const result = reconcileClaudeCredentials(config.home);
|
|
94
|
+
const message = formatCredentialSyncStatus(result);
|
|
95
|
+
if (message) {
|
|
96
|
+
process.stderr.write(message);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
process.stderr.write(`Warning: Failed to sync Claude Code credentials: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const envFlags = terminalEnvFlags();
|
|
104
|
+
if (cmd.length === 0) {
|
|
105
|
+
try {
|
|
106
|
+
materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
|
|
115
|
+
}
|
|
@@ -2,14 +2,40 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import * as p from '@clack/prompts';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
|
-
import { loadConfig } from '../config.
|
|
6
|
-
import { sandboxLabel } from '../constants.
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { loadConfig } from '../config.ts';
|
|
6
|
+
import { sandboxBranchLabel, sandboxLabel } from '../constants.ts';
|
|
7
|
+
import { detectEngine } from '../engine.ts';
|
|
8
|
+
import { runSafeEngine } from '../shell.ts';
|
|
9
|
+
import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
|
|
9
10
|
|
|
10
11
|
const USAGE = 'Usage: ai sandbox ls';
|
|
12
|
+
const CONTAINER_LIST_HEADER = 'NAMES\tSTATUS\tBRANCH';
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
// Exported to lock the docker/podman-compatible format in unit tests.
|
|
15
|
+
export function containerListFormat(): string {
|
|
16
|
+
return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseLabels(csv: string): Record<string, string> {
|
|
20
|
+
if (!csv) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const labels: Record<string, string> = {};
|
|
25
|
+
for (const pair of csv.split(',')) {
|
|
26
|
+
if (!pair) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const eq = pair.indexOf('=');
|
|
30
|
+
if (eq < 0) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
labels[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
34
|
+
}
|
|
35
|
+
return labels;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listChildren(dir: string): string[] {
|
|
13
39
|
if (!fs.existsSync(dir)) {
|
|
14
40
|
return [];
|
|
15
41
|
}
|
|
@@ -17,32 +43,37 @@ function listChildren(dir) {
|
|
|
17
43
|
return fs.readdirSync(dir).sort().map((entry) => path.join(dir, entry));
|
|
18
44
|
}
|
|
19
45
|
|
|
20
|
-
export function ls(args = []) {
|
|
46
|
+
export function ls(args: string[] = []): void {
|
|
21
47
|
if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
|
|
22
48
|
process.stdout.write(`${USAGE}\n`);
|
|
23
49
|
return;
|
|
24
50
|
}
|
|
25
51
|
|
|
26
52
|
const config = loadConfig();
|
|
53
|
+
const engine = detectEngine(config);
|
|
27
54
|
const tools = resolveTools(config);
|
|
28
55
|
const label = sandboxLabel(config);
|
|
29
|
-
const containers =
|
|
56
|
+
const containers = runSafeEngine(engine, 'docker', [
|
|
30
57
|
'ps',
|
|
31
58
|
'-a',
|
|
32
59
|
'--filter',
|
|
33
60
|
`label=${label}`,
|
|
34
61
|
'--format',
|
|
35
|
-
|
|
62
|
+
containerListFormat()
|
|
36
63
|
]);
|
|
37
64
|
|
|
38
65
|
p.intro(pc.cyan(`Sandbox status for ${config.project}`));
|
|
39
66
|
|
|
40
67
|
p.log.step('Containers');
|
|
41
|
-
if (!containers
|
|
68
|
+
if (!containers) {
|
|
42
69
|
p.log.warn(' No sandbox containers');
|
|
43
70
|
} else {
|
|
71
|
+
const branchKey = sandboxBranchLabel(config);
|
|
72
|
+
process.stdout.write(` ${CONTAINER_LIST_HEADER}\n`);
|
|
44
73
|
for (const line of containers.split('\n')) {
|
|
45
|
-
|
|
74
|
+
const [name = '', status = '', labelsCsv = ''] = line.split('\t');
|
|
75
|
+
const branch = parseLabels(labelsCsv)[branchKey] ?? '';
|
|
76
|
+
process.stdout.write(` ${name}\t${status}\t${branch}\n`);
|
|
46
77
|
}
|
|
47
78
|
}
|
|
48
79
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { loadConfig } from '../config.ts';
|
|
6
|
+
import type { SandboxConfig } from '../config.ts';
|
|
7
|
+
import { prepareDockerfile } from '../dockerfile.ts';
|
|
8
|
+
import { sandboxImageConfigLabel, sandboxLabel } from '../constants.ts';
|
|
9
|
+
import { detectEngine, ensureDocker } from '../engine.ts';
|
|
10
|
+
import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
|
|
11
|
+
import { resolveTools, toolNpmPackagesArg } from '../tools.ts';
|
|
12
|
+
import type { SandboxTool } from '../tools.ts';
|
|
13
|
+
import { toEnginePath } from '../engines/wsl2-paths.ts';
|
|
14
|
+
import { resolveBuildUid } from '../engines/native.ts';
|
|
15
|
+
|
|
16
|
+
const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
|
|
17
|
+
|
|
18
|
+
type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
|
|
19
|
+
type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
|
|
20
|
+
type EngineRunSafeFn = EngineRunFn;
|
|
21
|
+
|
|
22
|
+
function buildSignature(preparedDockerfile: PreparedDockerfile, tools: SandboxTool[]): string {
|
|
23
|
+
return createHash('sha256')
|
|
24
|
+
.update(JSON.stringify({
|
|
25
|
+
dockerfile: preparedDockerfile.signature,
|
|
26
|
+
tools: tools.map((tool) => tool.npmPackage)
|
|
27
|
+
}))
|
|
28
|
+
.digest('hex')
|
|
29
|
+
.slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildArgs(
|
|
33
|
+
config: SandboxConfig,
|
|
34
|
+
tools: SandboxTool[],
|
|
35
|
+
dockerfilePath: string,
|
|
36
|
+
imageSignature: string,
|
|
37
|
+
{
|
|
38
|
+
engine,
|
|
39
|
+
runFn = runEngine,
|
|
40
|
+
runSafeFn = runSafeEngine,
|
|
41
|
+
env = process.env
|
|
42
|
+
}: {
|
|
43
|
+
engine?: string;
|
|
44
|
+
runFn?: EngineRunFn;
|
|
45
|
+
runSafeFn?: EngineRunSafeFn;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
} = {}
|
|
48
|
+
): string[] {
|
|
49
|
+
const selectedEngine = engine ?? detectEngine(config);
|
|
50
|
+
const { uid: hostUid, gid: hostGid } = resolveBuildUid({
|
|
51
|
+
engine: selectedEngine,
|
|
52
|
+
runFn,
|
|
53
|
+
runSafeFn,
|
|
54
|
+
env
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return [
|
|
58
|
+
'build',
|
|
59
|
+
'-t',
|
|
60
|
+
config.imageName,
|
|
61
|
+
'--build-arg',
|
|
62
|
+
`HOST_UID=${hostUid}`,
|
|
63
|
+
'--build-arg',
|
|
64
|
+
`HOST_GID=${hostGid}`,
|
|
65
|
+
'--build-arg',
|
|
66
|
+
`AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
|
|
67
|
+
'--label',
|
|
68
|
+
sandboxLabel(config),
|
|
69
|
+
'--label',
|
|
70
|
+
`${sandboxImageConfigLabel(config)}=${imageSignature}`,
|
|
71
|
+
'-f',
|
|
72
|
+
toEnginePath(selectedEngine, dockerfilePath),
|
|
73
|
+
toEnginePath(selectedEngine, config.repoRoot)
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function removeImageIfPresent(imageName: string, engine: string): void {
|
|
78
|
+
if (runOkEngine(engine, 'docker', ['image', 'inspect', imageName])) {
|
|
79
|
+
runEngine(engine, 'docker', ['rmi', imageName]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function rebuild(args: string[]): Promise<void> {
|
|
84
|
+
const { values } = parseArgs({
|
|
85
|
+
args,
|
|
86
|
+
allowPositionals: true,
|
|
87
|
+
strict: true,
|
|
88
|
+
options: {
|
|
89
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
90
|
+
help: { type: 'boolean', short: 'h' }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (values.help) {
|
|
95
|
+
process.stdout.write(`${USAGE}\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const config = loadConfig();
|
|
100
|
+
const tools = resolveTools(config);
|
|
101
|
+
const preparedDockerfile = prepareDockerfile(config);
|
|
102
|
+
const imageSignature = buildSignature(preparedDockerfile, tools);
|
|
103
|
+
const quiet = values.quiet ?? false;
|
|
104
|
+
const engine = detectEngine(config);
|
|
105
|
+
|
|
106
|
+
await ensureDocker(config, undefined);
|
|
107
|
+
p.intro(pc.cyan('Rebuilding sandbox image'));
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (quiet) {
|
|
111
|
+
const spinner = p.spinner();
|
|
112
|
+
spinner.start(`Removing old image ${config.imageName}...`);
|
|
113
|
+
removeImageIfPresent(config.imageName, engine);
|
|
114
|
+
spinner.stop('Old image removed');
|
|
115
|
+
spinner.start('Building image...');
|
|
116
|
+
runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), {
|
|
117
|
+
cwd: config.repoRoot
|
|
118
|
+
});
|
|
119
|
+
spinner.stop(pc.green('Sandbox image rebuilt'));
|
|
120
|
+
} else {
|
|
121
|
+
p.log.step(`Removing old image ${config.imageName}`);
|
|
122
|
+
removeImageIfPresent(config.imageName, engine);
|
|
123
|
+
p.log.step('Building image');
|
|
124
|
+
runVerboseEngine(
|
|
125
|
+
engine,
|
|
126
|
+
'docker',
|
|
127
|
+
buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }),
|
|
128
|
+
{ cwd: config.repoRoot }
|
|
129
|
+
);
|
|
130
|
+
p.log.success(pc.green('Sandbox image rebuilt'));
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
preparedDockerfile.cleanup();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import {
|
|
4
|
+
buildLockedGuidance,
|
|
5
|
+
discoverProjects,
|
|
6
|
+
formatCredentialWarnings,
|
|
7
|
+
formatRemaining,
|
|
8
|
+
reconcileClaudeCredentials,
|
|
9
|
+
redactCommandError,
|
|
10
|
+
validateClaudeCredentialsEnvOverride
|
|
11
|
+
} from '../credentials.ts';
|
|
12
|
+
import { runProbe } from '../shell.ts';
|
|
13
|
+
|
|
14
|
+
const USAGE = 'Usage: ai sandbox refresh';
|
|
15
|
+
|
|
16
|
+
type ReconcileOptions = NonNullable<Parameters<typeof reconcileClaudeCredentials>[1]>;
|
|
17
|
+
|
|
18
|
+
type RefreshDeps = Partial<ReconcileOptions> & {
|
|
19
|
+
spawnFn?: typeof runProbe;
|
|
20
|
+
discoverFn?: typeof discoverProjects;
|
|
21
|
+
writeStdout?: (chunk: string) => unknown;
|
|
22
|
+
writeStderr?: (chunk: string) => unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function probeClaudeStatus(spawnFn: typeof runProbe = runProbe): { ok: boolean; stderr: string; error: string | null } {
|
|
26
|
+
const result = spawnFn('claude', ['/status'], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
timeout: 30_000
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
ok: result.status === 0,
|
|
33
|
+
stderr: result.stderr?.toString() ?? '',
|
|
34
|
+
error: result.error?.message ?? null
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function refresh(args: string[], deps: RefreshDeps = {}): Promise<number> {
|
|
39
|
+
const {
|
|
40
|
+
spawnFn = runProbe,
|
|
41
|
+
execFn,
|
|
42
|
+
readFn,
|
|
43
|
+
existsFn,
|
|
44
|
+
writeFn,
|
|
45
|
+
writeHostFn,
|
|
46
|
+
discoverFn = discoverProjects,
|
|
47
|
+
writeStdout = (chunk: string) => process.stdout.write(chunk),
|
|
48
|
+
writeStderr = (chunk: string) => process.stderr.write(chunk)
|
|
49
|
+
} = deps;
|
|
50
|
+
|
|
51
|
+
if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
|
52
|
+
writeStdout(`${USAGE}\n`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { positionals } = parseArgs({ args, allowPositionals: true, strict: true });
|
|
57
|
+
if (positionals.length > 0) {
|
|
58
|
+
throw new Error(USAGE);
|
|
59
|
+
}
|
|
60
|
+
validateClaudeCredentialsEnvOverride();
|
|
61
|
+
|
|
62
|
+
const home = homedir();
|
|
63
|
+
if (!home) {
|
|
64
|
+
throw new Error('sandbox: home directory is required');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const projects = discoverFn(home);
|
|
68
|
+
if (projects.length === 0) {
|
|
69
|
+
writeStdout('No project credentials to refresh.\n');
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const reconcileOptions = { execFn, readFn, existsFn, writeFn, writeHostFn, projects };
|
|
74
|
+
let result = reconcileClaudeCredentials(home, reconcileOptions);
|
|
75
|
+
if (result.status === 'STALE_ACCESS' && result.authoritative === null) {
|
|
76
|
+
writeStdout('Host credentials appear stale; probing claude /status to trigger refresh...\n');
|
|
77
|
+
const probe = probeClaudeStatus(spawnFn);
|
|
78
|
+
if (!probe.ok) {
|
|
79
|
+
writeStderr(`Probe failed: ${redactCommandError(probe.stderr || probe.error || 'unknown error')}\n`);
|
|
80
|
+
writeStderr('Run "claude /login" on the host to renew credentials.\n');
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
writeStdout('Probe succeeded; re-inspecting host credentials.\n');
|
|
84
|
+
result = reconcileClaudeCredentials(home, reconcileOptions);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (result.status === 'MISSING') {
|
|
88
|
+
writeStderr('No Claude Code credentials found on host.\n');
|
|
89
|
+
writeStderr('Run "claude /login" on the host to authenticate.\n');
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result.status === 'KEYCHAIN_LOCKED') {
|
|
94
|
+
writeStderr(`${buildLockedGuidance()}\n`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (result.status === 'KEYCHAIN_ERROR') {
|
|
99
|
+
writeStderr(`Host keychain error: ${redactCommandError(result.detail || 'unknown error')}\n`);
|
|
100
|
+
writeStderr(`${buildLockedGuidance()}\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (result.status === 'KEYCHAIN_WRITE_FAILED') {
|
|
105
|
+
writeStderr(`[host] keychain write failed: ${formatCredentialWarnings(result.warnings) || 'unknown error'}\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.status !== 'OK') {
|
|
110
|
+
writeStderr('Host credentials still invalid after probe; run "claude /login".\n');
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (result.authoritative && result.authoritative !== 'host' && result.hostWritten) {
|
|
115
|
+
writeStdout(`[host] reconciled from ${result.authoritative}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const project of projects) {
|
|
119
|
+
const action = result.filesWritten.includes(project) ? 'updated' : 'unchanged';
|
|
120
|
+
writeStdout(`[${project}] ${action}; expires in ${formatRemaining(result.expiresAt)}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const failure of result.fileErrors) {
|
|
124
|
+
writeStderr(`[${failure.project}] sync failed: ${failure.error}\n`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result.fileErrors.length > 0 ? 1 : 0;
|
|
128
|
+
}
|
|
@@ -3,27 +3,42 @@ import path from 'node:path';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
5
|
import pc from 'picocolors';
|
|
6
|
-
import { loadConfig } from '../config.
|
|
6
|
+
import { loadConfig } from '../config.ts';
|
|
7
|
+
import type { SandboxConfig } from '../config.ts';
|
|
7
8
|
import {
|
|
8
9
|
assertValidBranchName,
|
|
9
10
|
containerNameCandidates,
|
|
10
11
|
sandboxBranchLabel,
|
|
11
12
|
sandboxLabel,
|
|
13
|
+
shareBranchDir,
|
|
12
14
|
worktreeDirCandidates
|
|
13
|
-
} from '../constants.
|
|
14
|
-
import { detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.
|
|
15
|
-
import { run, runOk, runSafe } from '../shell.
|
|
16
|
-
import { resolveTaskBranch } from '../task-resolver.
|
|
17
|
-
import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.
|
|
15
|
+
} from '../constants.ts';
|
|
16
|
+
import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
|
|
17
|
+
import { run, runOk, runSafe, runSafeEngine } from '../shell.ts';
|
|
18
|
+
import { resolveTaskBranch } from '../task-resolver.ts';
|
|
19
|
+
import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
|
|
20
|
+
import type { SandboxTool } from '../tools.ts';
|
|
18
21
|
|
|
19
22
|
const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
|
|
20
23
|
|
|
21
|
-
function projectToolDirs(config, tools) {
|
|
24
|
+
function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[] {
|
|
22
25
|
return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
export function assertManagedPath(root: string, target: string): void {
|
|
29
|
+
const resolvedRoot = path.resolve(root);
|
|
30
|
+
const resolvedTarget = path.resolve(target);
|
|
31
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
32
|
+
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string): Promise<void> {
|
|
26
40
|
assertValidBranchName(branch);
|
|
41
|
+
const engine = detectEngine(config);
|
|
27
42
|
let effectiveBranch = branch;
|
|
28
43
|
let worktreeCandidates = worktreeDirCandidates(config, branch);
|
|
29
44
|
let toolCandidates = tools.map((tool) => ({
|
|
@@ -33,16 +48,16 @@ async function rmOne(config, tools, branch) {
|
|
|
33
48
|
|
|
34
49
|
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
35
50
|
|
|
36
|
-
const existing =
|
|
51
|
+
const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
37
52
|
const matchedContainers = containerNameCandidates(config, branch)
|
|
38
53
|
.filter((name) => existing.includes(name));
|
|
39
54
|
|
|
40
55
|
if (matchedContainers.length > 0) {
|
|
41
|
-
const resolvedBranch =
|
|
56
|
+
const resolvedBranch = runSafeEngine(engine, 'docker', [
|
|
42
57
|
'inspect',
|
|
43
58
|
'-f',
|
|
44
59
|
`{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
|
|
45
|
-
matchedContainers[0]
|
|
60
|
+
matchedContainers[0] ?? ''
|
|
46
61
|
]);
|
|
47
62
|
if (resolvedBranch) {
|
|
48
63
|
effectiveBranch = resolvedBranch;
|
|
@@ -56,8 +71,8 @@ async function rmOne(config, tools, branch) {
|
|
|
56
71
|
const spinner = p.spinner();
|
|
57
72
|
spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
|
|
58
73
|
for (const name of matchedContainers) {
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
runSafeEngine(engine, 'docker', ['stop', name]);
|
|
75
|
+
runSafeEngine(engine, 'docker', ['rm', name]);
|
|
61
76
|
}
|
|
62
77
|
spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
|
|
63
78
|
} else {
|
|
@@ -81,6 +96,7 @@ async function rmOne(config, tools, branch) {
|
|
|
81
96
|
try {
|
|
82
97
|
run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
|
|
83
98
|
} catch {
|
|
99
|
+
assertManagedPath(config.worktreeBase, worktree);
|
|
84
100
|
fs.rmSync(worktree, { recursive: true, force: true });
|
|
85
101
|
}
|
|
86
102
|
}
|
|
@@ -100,18 +116,33 @@ async function rmOne(config, tools, branch) {
|
|
|
100
116
|
|
|
101
117
|
for (const { tool, candidates } of toolCandidates) {
|
|
102
118
|
for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
|
|
119
|
+
assertManagedPath(tool.sandboxBase, dir);
|
|
103
120
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
121
|
p.log.success(`${tool.name} state removed: ${dir}`);
|
|
105
122
|
}
|
|
106
123
|
}
|
|
107
124
|
|
|
125
|
+
const shareBranch = shareBranchDir(config, effectiveBranch);
|
|
126
|
+
if (fs.existsSync(shareBranch)) {
|
|
127
|
+
const shouldRemoveShare = await p.confirm({
|
|
128
|
+
message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
|
|
129
|
+
initialValue: true
|
|
130
|
+
});
|
|
131
|
+
if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
|
|
132
|
+
assertManagedPath(config.shareBase, shareBranch);
|
|
133
|
+
fs.rmSync(shareBranch, { recursive: true, force: true });
|
|
134
|
+
p.log.success(`Share dir removed: ${shareBranch}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
p.outro(pc.green('Sandbox removed'));
|
|
109
139
|
}
|
|
110
140
|
|
|
111
|
-
async function rmAll(config, tools) {
|
|
141
|
+
async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void> {
|
|
142
|
+
const engine = detectEngine(config);
|
|
112
143
|
p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
|
|
113
144
|
|
|
114
|
-
const containers =
|
|
145
|
+
const containers = runSafeEngine(engine, 'docker', [
|
|
115
146
|
'ps',
|
|
116
147
|
'-a',
|
|
117
148
|
'--filter',
|
|
@@ -123,8 +154,8 @@ async function rmAll(config, tools) {
|
|
|
123
154
|
const spinner = p.spinner();
|
|
124
155
|
spinner.start('Stopping project sandbox containers...');
|
|
125
156
|
for (const name of containers.split('\n').filter(Boolean)) {
|
|
126
|
-
|
|
127
|
-
|
|
157
|
+
runSafeEngine(engine, 'docker', ['stop', name]);
|
|
158
|
+
runSafeEngine(engine, 'docker', ['rm', name]);
|
|
128
159
|
}
|
|
129
160
|
spinner.stop(pc.green('Project sandbox containers removed'));
|
|
130
161
|
} else {
|
|
@@ -143,6 +174,7 @@ async function rmAll(config, tools) {
|
|
|
143
174
|
try {
|
|
144
175
|
run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
145
176
|
} catch {
|
|
177
|
+
assertManagedPath(config.worktreeBase, dir);
|
|
146
178
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
147
179
|
}
|
|
148
180
|
}
|
|
@@ -152,21 +184,39 @@ async function rmAll(config, tools) {
|
|
|
152
184
|
|
|
153
185
|
for (const dir of projectToolDirs(config, tools)) {
|
|
154
186
|
if (fs.existsSync(dir)) {
|
|
187
|
+
assertManagedPath(path.dirname(dir), dir);
|
|
155
188
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
189
|
p.log.success(`Removed tool state: ${dir}`);
|
|
157
190
|
}
|
|
158
191
|
}
|
|
159
192
|
|
|
193
|
+
if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
|
|
194
|
+
const shouldRemoveAllShares = await p.confirm({
|
|
195
|
+
message: `Remove all share dirs for project (${config.shareBase})?`,
|
|
196
|
+
initialValue: true
|
|
197
|
+
});
|
|
198
|
+
if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
|
|
199
|
+
assertManagedPath(path.dirname(config.shareBase), config.shareBase);
|
|
200
|
+
fs.rmSync(config.shareBase, { recursive: true, force: true });
|
|
201
|
+
p.log.success(`Project share dirs removed: ${config.shareBase}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
160
205
|
const shouldRemoveImage = await p.confirm({
|
|
161
206
|
message: `Remove image ${config.imageName}?`,
|
|
162
207
|
initialValue: false
|
|
163
208
|
});
|
|
164
209
|
if (!p.isCancel(shouldRemoveImage) && shouldRemoveImage) {
|
|
165
|
-
|
|
210
|
+
runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
|
|
166
211
|
}
|
|
167
212
|
|
|
168
|
-
const engine = detectEngine(config);
|
|
169
213
|
if (isManagedEngine(engine)) {
|
|
214
|
+
if (engine === ENGINES.WSL2) {
|
|
215
|
+
p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
|
|
216
|
+
p.outro(pc.green('All project sandboxes removed'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
170
220
|
const name = engineDisplayName(engine);
|
|
171
221
|
const shouldStopVm = await p.confirm({
|
|
172
222
|
message: `Stop ${name} VM?`,
|
|
@@ -180,7 +230,7 @@ async function rmAll(config, tools) {
|
|
|
180
230
|
p.outro(pc.green('All project sandboxes removed'));
|
|
181
231
|
}
|
|
182
232
|
|
|
183
|
-
export async function rm(args) {
|
|
233
|
+
export async function rm(args: string[]): Promise<void> {
|
|
184
234
|
const { values, positionals } = parseArgs({
|
|
185
235
|
args,
|
|
186
236
|
allowPositionals: true,
|
|
@@ -208,6 +258,6 @@ export async function rm(args) {
|
|
|
208
258
|
return;
|
|
209
259
|
}
|
|
210
260
|
|
|
211
|
-
const branch = resolveTaskBranch(positionals[0], config.repoRoot);
|
|
261
|
+
const branch = resolveTaskBranch(positionals[0] ?? '', config.repoRoot);
|
|
212
262
|
await rmOne(config, tools, branch);
|
|
213
263
|
}
|