@fitlab-ai/agent-infra 0.4.5 → 0.5.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 +16 -2
- package/README.zh-CN.md +16 -2
- package/bin/cli.js +19 -0
- package/lib/defaults.json +17 -0
- package/lib/init.js +1 -0
- package/lib/log.js +5 -10
- package/lib/merge.js +465 -0
- package/lib/sandbox/commands/create.js +1047 -0
- package/lib/sandbox/commands/enter.js +31 -0
- package/lib/sandbox/commands/ls.js +70 -0
- package/lib/sandbox/commands/rebuild.js +102 -0
- package/lib/sandbox/commands/rm.js +211 -0
- package/lib/sandbox/commands/vm.js +101 -0
- package/lib/sandbox/config.js +79 -0
- package/lib/sandbox/constants.js +113 -0
- package/lib/sandbox/dockerfile.js +95 -0
- package/lib/sandbox/engine.js +93 -0
- package/lib/sandbox/index.js +64 -0
- package/lib/sandbox/runtimes/ai-tools.dockerfile +26 -0
- package/lib/sandbox/runtimes/base.dockerfile +30 -0
- package/lib/sandbox/runtimes/java17.dockerfile +3 -0
- package/lib/sandbox/runtimes/java21.dockerfile +3 -0
- package/lib/sandbox/runtimes/node20.dockerfile +3 -0
- package/lib/sandbox/runtimes/node22.dockerfile +3 -0
- package/lib/sandbox/runtimes/python3.dockerfile +3 -0
- package/lib/sandbox/shell.js +48 -0
- package/lib/sandbox/task-resolver.js +35 -0
- package/lib/sandbox/tools.js +131 -0
- package/lib/update.js +16 -2
- package/package.json +5 -1
- package/templates/.agents/scripts/validate-artifact.js +40 -0
- package/templates/.agents/skills/archive-tasks/SKILL.md +6 -3
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +6 -3
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +91 -8
- package/templates/.agents/skills/create-task/SKILL.md +6 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +6 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- package/templates/.agents/skills/import-issue/SKILL.md +2 -0
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/import-issue/config/verify.json +1 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -1
- package/templates/.agents/templates/task.md +5 -4
- package/templates/.agents/templates/task.zh-CN.md +5 -4
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { assertValidBranchName, containerNameCandidates } from '../constants.js';
|
|
3
|
+
import { runInteractive, runSafe } from '../shell.js';
|
|
4
|
+
import { resolveTaskBranch } from '../task-resolver.js';
|
|
5
|
+
|
|
6
|
+
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
7
|
+
|
|
8
|
+
export function enter(args) {
|
|
9
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
10
|
+
process.stdout.write(`${USAGE}\n`);
|
|
11
|
+
if (args.length === 0) {
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
const [branchOrTaskId, ...cmd] = args;
|
|
19
|
+
const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
|
|
20
|
+
assertValidBranchName(branch);
|
|
21
|
+
const running = runSafe('docker', ['ps', '--format', '{{.Names}}']).split('\n');
|
|
22
|
+
const container = containerNameCandidates(config, branch).find((name) => running.includes(name));
|
|
23
|
+
|
|
24
|
+
if (!container) {
|
|
25
|
+
throw new Error(`No running sandbox found for branch '${branch}'`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return cmd.length === 0
|
|
29
|
+
? runInteractive('docker', ['exec', '-it', container, 'bash'])
|
|
30
|
+
: runInteractive('docker', ['exec', '-it', container, ...cmd]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { sandboxLabel } from '../constants.js';
|
|
7
|
+
import { runSafe } from '../shell.js';
|
|
8
|
+
import { resolveTools } from '../tools.js';
|
|
9
|
+
|
|
10
|
+
const USAGE = 'Usage: ai sandbox ls';
|
|
11
|
+
|
|
12
|
+
function listChildren(dir) {
|
|
13
|
+
if (!fs.existsSync(dir)) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return fs.readdirSync(dir).sort().map((entry) => path.join(dir, entry));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ls(args = []) {
|
|
21
|
+
if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
|
|
22
|
+
process.stdout.write(`${USAGE}\n`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const tools = resolveTools(config);
|
|
28
|
+
const label = sandboxLabel(config);
|
|
29
|
+
const containers = runSafe('docker', [
|
|
30
|
+
'ps',
|
|
31
|
+
'-a',
|
|
32
|
+
'--filter',
|
|
33
|
+
`label=${label}`,
|
|
34
|
+
'--format',
|
|
35
|
+
'table {{.Names}}\t{{.Status}}\t{{.Label "' + `${label}.branch` + '"}}'
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
p.intro(pc.cyan(`Sandbox status for ${config.project}`));
|
|
39
|
+
|
|
40
|
+
p.log.step('Containers');
|
|
41
|
+
if (!containers || containers.split('\n').length <= 1) {
|
|
42
|
+
p.log.warn(' No sandbox containers');
|
|
43
|
+
} else {
|
|
44
|
+
for (const line of containers.split('\n')) {
|
|
45
|
+
process.stdout.write(` ${line}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
p.log.step('Worktrees');
|
|
50
|
+
const worktrees = listChildren(config.worktreeBase);
|
|
51
|
+
if (worktrees.length === 0) {
|
|
52
|
+
p.log.warn(' No sandbox worktrees');
|
|
53
|
+
} else {
|
|
54
|
+
for (const worktree of worktrees) {
|
|
55
|
+
process.stdout.write(` ${worktree}\n`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const tool of tools) {
|
|
60
|
+
p.log.step(`${tool.name} state`);
|
|
61
|
+
const entries = listChildren(path.join(tool.sandboxBase, config.project));
|
|
62
|
+
if (entries.length === 0) {
|
|
63
|
+
p.log.warn(` No ${tool.name} sandbox state`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
process.stdout.write(` ${entry}\n`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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.js';
|
|
6
|
+
import { prepareDockerfile } from '../dockerfile.js';
|
|
7
|
+
import { sandboxImageConfigLabel, sandboxLabel } from '../constants.js';
|
|
8
|
+
import { ensureDocker } from '../engine.js';
|
|
9
|
+
import { run, runOk, runVerbose } from '../shell.js';
|
|
10
|
+
import { resolveTools, toolNpmPackagesArg } from '../tools.js';
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
|
|
13
|
+
|
|
14
|
+
function buildSignature(preparedDockerfile, tools) {
|
|
15
|
+
return createHash('sha256')
|
|
16
|
+
.update(JSON.stringify({
|
|
17
|
+
dockerfile: preparedDockerfile.signature,
|
|
18
|
+
tools: tools.map((tool) => tool.npmPackage)
|
|
19
|
+
}))
|
|
20
|
+
.digest('hex')
|
|
21
|
+
.slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildArgs(config, tools, dockerfilePath, imageSignature) {
|
|
25
|
+
const hostUid = run('id', ['-u']);
|
|
26
|
+
const hostGid = run('id', ['-g']);
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
'build',
|
|
30
|
+
'-t',
|
|
31
|
+
config.imageName,
|
|
32
|
+
'--build-arg',
|
|
33
|
+
`HOST_UID=${hostUid}`,
|
|
34
|
+
'--build-arg',
|
|
35
|
+
`HOST_GID=${hostGid}`,
|
|
36
|
+
'--build-arg',
|
|
37
|
+
`AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
|
|
38
|
+
'--label',
|
|
39
|
+
sandboxLabel(config),
|
|
40
|
+
'--label',
|
|
41
|
+
`${sandboxImageConfigLabel(config)}=${imageSignature}`,
|
|
42
|
+
'-f',
|
|
43
|
+
dockerfilePath,
|
|
44
|
+
config.repoRoot
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function removeImageIfPresent(imageName) {
|
|
49
|
+
if (runOk('docker', ['image', 'inspect', imageName])) {
|
|
50
|
+
run('docker', ['rmi', imageName]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function rebuild(args) {
|
|
55
|
+
const { values } = parseArgs({
|
|
56
|
+
args,
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
strict: true,
|
|
59
|
+
options: {
|
|
60
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
61
|
+
help: { type: 'boolean', short: 'h' }
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (values.help) {
|
|
66
|
+
process.stdout.write(`${USAGE}\n`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
const tools = resolveTools(config);
|
|
72
|
+
const preparedDockerfile = prepareDockerfile(config);
|
|
73
|
+
const imageSignature = buildSignature(preparedDockerfile, tools);
|
|
74
|
+
const quiet = values.quiet ?? false;
|
|
75
|
+
|
|
76
|
+
await ensureDocker(config);
|
|
77
|
+
p.intro(pc.cyan('Rebuilding sandbox image'));
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (quiet) {
|
|
81
|
+
const spinner = p.spinner();
|
|
82
|
+
spinner.start(`Removing old image ${config.imageName}...`);
|
|
83
|
+
removeImageIfPresent(config.imageName);
|
|
84
|
+
spinner.stop('Old image removed');
|
|
85
|
+
spinner.start('Building image...');
|
|
86
|
+
run('docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature), { cwd: config.repoRoot });
|
|
87
|
+
spinner.stop(pc.green('Sandbox image rebuilt'));
|
|
88
|
+
} else {
|
|
89
|
+
p.log.step(`Removing old image ${config.imageName}`);
|
|
90
|
+
removeImageIfPresent(config.imageName);
|
|
91
|
+
p.log.step('Building image');
|
|
92
|
+
runVerbose(
|
|
93
|
+
'docker',
|
|
94
|
+
buildArgs(config, tools, preparedDockerfile.path, imageSignature),
|
|
95
|
+
{ cwd: config.repoRoot }
|
|
96
|
+
);
|
|
97
|
+
p.log.success(pc.green('Sandbox image rebuilt'));
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
preparedDockerfile.cleanup();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { loadConfig } from '../config.js';
|
|
7
|
+
import {
|
|
8
|
+
assertValidBranchName,
|
|
9
|
+
containerNameCandidates,
|
|
10
|
+
sandboxBranchLabel,
|
|
11
|
+
sandboxLabel,
|
|
12
|
+
worktreeDirCandidates
|
|
13
|
+
} from '../constants.js';
|
|
14
|
+
import { isVmManaged } from '../engine.js';
|
|
15
|
+
import { run, runOk, runSafe } from '../shell.js';
|
|
16
|
+
import { resolveTaskBranch } from '../task-resolver.js';
|
|
17
|
+
import { resolveTools, toolConfigDirCandidates } from '../tools.js';
|
|
18
|
+
|
|
19
|
+
const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
|
|
20
|
+
|
|
21
|
+
function projectToolDirs(config, tools) {
|
|
22
|
+
return tools.map((tool) => path.join(tool.sandboxBase, config.project));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function rmOne(config, tools, branch) {
|
|
26
|
+
assertValidBranchName(branch);
|
|
27
|
+
let effectiveBranch = branch;
|
|
28
|
+
let worktreeCandidates = worktreeDirCandidates(config, branch);
|
|
29
|
+
let toolCandidates = tools.map((tool) => ({
|
|
30
|
+
tool,
|
|
31
|
+
candidates: toolConfigDirCandidates(tool, config.project, branch)
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
35
|
+
|
|
36
|
+
const existing = runSafe('docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
37
|
+
const matchedContainers = containerNameCandidates(config, branch)
|
|
38
|
+
.filter((name) => existing.includes(name));
|
|
39
|
+
|
|
40
|
+
if (matchedContainers.length > 0) {
|
|
41
|
+
const resolvedBranch = runSafe('docker', [
|
|
42
|
+
'inspect',
|
|
43
|
+
'-f',
|
|
44
|
+
`{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
|
|
45
|
+
matchedContainers[0]
|
|
46
|
+
]);
|
|
47
|
+
if (resolvedBranch) {
|
|
48
|
+
effectiveBranch = resolvedBranch;
|
|
49
|
+
worktreeCandidates = worktreeDirCandidates(config, effectiveBranch);
|
|
50
|
+
toolCandidates = tools.map((tool) => ({
|
|
51
|
+
tool,
|
|
52
|
+
candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spinner = p.spinner();
|
|
57
|
+
spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
|
|
58
|
+
for (const name of matchedContainers) {
|
|
59
|
+
runSafe('docker', ['stop', name]);
|
|
60
|
+
runSafe('docker', ['rm', name]);
|
|
61
|
+
}
|
|
62
|
+
spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
|
|
63
|
+
} else {
|
|
64
|
+
p.log.warn(`No sandbox container found for '${branch}'`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const existingWorktrees = worktreeCandidates.filter((candidate) => fs.existsSync(candidate));
|
|
68
|
+
if (existingWorktrees.length > 0) {
|
|
69
|
+
const shouldRemoveWorktree = await p.confirm({
|
|
70
|
+
message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
|
|
71
|
+
initialValue: true
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (p.isCancel(shouldRemoveWorktree)) {
|
|
75
|
+
p.outro('Cancelled');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (shouldRemoveWorktree) {
|
|
80
|
+
for (const worktree of existingWorktrees) {
|
|
81
|
+
try {
|
|
82
|
+
run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
|
|
83
|
+
} catch {
|
|
84
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const shouldDeleteBranch = await p.confirm({
|
|
89
|
+
message: `Also delete local branch '${effectiveBranch}'?`,
|
|
90
|
+
initialValue: false
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!p.isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
|
|
94
|
+
if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
|
|
95
|
+
p.log.warn(`Local branch '${effectiveBranch}' was not deleted`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const { tool, candidates } of toolCandidates) {
|
|
102
|
+
for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
|
|
103
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
p.log.success(`${tool.name} state removed: ${dir}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
p.outro(pc.green('Sandbox removed'));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function rmAll(config, tools) {
|
|
112
|
+
p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
|
|
113
|
+
|
|
114
|
+
const containers = runSafe('docker', [
|
|
115
|
+
'ps',
|
|
116
|
+
'-a',
|
|
117
|
+
'--filter',
|
|
118
|
+
`label=${sandboxLabel(config)}`,
|
|
119
|
+
'--format',
|
|
120
|
+
'{{.Names}}'
|
|
121
|
+
]);
|
|
122
|
+
if (containers) {
|
|
123
|
+
const spinner = p.spinner();
|
|
124
|
+
spinner.start('Stopping project sandbox containers...');
|
|
125
|
+
for (const name of containers.split('\n').filter(Boolean)) {
|
|
126
|
+
runSafe('docker', ['stop', name]);
|
|
127
|
+
runSafe('docker', ['rm', name]);
|
|
128
|
+
}
|
|
129
|
+
spinner.stop(pc.green('Project sandbox containers removed'));
|
|
130
|
+
} else {
|
|
131
|
+
p.log.warn('No project sandbox containers found');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (fs.existsSync(config.worktreeBase) && fs.readdirSync(config.worktreeBase).length > 0) {
|
|
135
|
+
const shouldRemoveWorktrees = await p.confirm({
|
|
136
|
+
message: `Remove all worktrees in ${config.worktreeBase}?`,
|
|
137
|
+
initialValue: true
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
|
|
141
|
+
for (const entry of fs.readdirSync(config.worktreeBase)) {
|
|
142
|
+
const dir = path.join(config.worktreeBase, entry);
|
|
143
|
+
try {
|
|
144
|
+
run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
145
|
+
} catch {
|
|
146
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const dir of projectToolDirs(config, tools)) {
|
|
154
|
+
if (fs.existsSync(dir)) {
|
|
155
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
p.log.success(`Removed tool state: ${dir}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const shouldRemoveImage = await p.confirm({
|
|
161
|
+
message: `Remove image ${config.imageName}?`,
|
|
162
|
+
initialValue: false
|
|
163
|
+
});
|
|
164
|
+
if (!p.isCancel(shouldRemoveImage) && shouldRemoveImage) {
|
|
165
|
+
runSafe('docker', ['rmi', config.imageName]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isVmManaged()) {
|
|
169
|
+
const shouldStopVm = await p.confirm({
|
|
170
|
+
message: 'Stop Colima VM?',
|
|
171
|
+
initialValue: false
|
|
172
|
+
});
|
|
173
|
+
if (!p.isCancel(shouldStopVm) && shouldStopVm) {
|
|
174
|
+
runSafe('colima', ['stop']);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
p.outro(pc.green('All project sandboxes removed'));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function rm(args) {
|
|
182
|
+
const { values, positionals } = parseArgs({
|
|
183
|
+
args,
|
|
184
|
+
allowPositionals: true,
|
|
185
|
+
strict: true,
|
|
186
|
+
options: {
|
|
187
|
+
all: { type: 'boolean' },
|
|
188
|
+
help: { type: 'boolean', short: 'h' }
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (values.help) {
|
|
193
|
+
process.stdout.write(`${USAGE}\n`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!values.all && positionals.length !== 1) {
|
|
198
|
+
throw new Error(USAGE);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const config = loadConfig();
|
|
202
|
+
const tools = resolveTools(config);
|
|
203
|
+
|
|
204
|
+
if (values.all) {
|
|
205
|
+
await rmAll(config, tools);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const branch = resolveTaskBranch(positionals[0], config.repoRoot);
|
|
210
|
+
await rmOne(config, tools, branch);
|
|
211
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
import { parsePositiveIntegerOption } from '../constants.js';
|
|
6
|
+
import { detectEngine, ensureDocker, isVmManaged } from '../engine.js';
|
|
7
|
+
import { run, runOk, runSafe } from '../shell.js';
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: ai sandbox vm <status|start|stop> [--cpu <n>] [--memory <n>]`;
|
|
10
|
+
|
|
11
|
+
function ensureManagedVm() {
|
|
12
|
+
if (!isVmManaged()) {
|
|
13
|
+
throw new Error(`VM management is unavailable on ${detectEngine()}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function status() {
|
|
18
|
+
ensureManagedVm();
|
|
19
|
+
p.intro(pc.cyan('Sandbox VM status'));
|
|
20
|
+
|
|
21
|
+
if (runOk('colima', ['status'])) {
|
|
22
|
+
process.stdout.write(`${runSafe('colima', ['status'])}\n`);
|
|
23
|
+
} else {
|
|
24
|
+
p.log.warn('Colima VM is not running');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function start(args) {
|
|
29
|
+
ensureManagedVm();
|
|
30
|
+
|
|
31
|
+
const { values } = parseArgs({
|
|
32
|
+
args,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
strict: true,
|
|
35
|
+
options: {
|
|
36
|
+
cpu: { type: 'string' },
|
|
37
|
+
memory: { type: 'string' },
|
|
38
|
+
help: { type: 'boolean', short: 'h' }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (values.help) {
|
|
43
|
+
process.stdout.write(`${USAGE}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
const effectiveConfig = {
|
|
49
|
+
...config,
|
|
50
|
+
vm: {
|
|
51
|
+
...config.vm,
|
|
52
|
+
cpu: parsePositiveIntegerOption(values.cpu, '--cpu') ?? config.vm.cpu,
|
|
53
|
+
memory: parsePositiveIntegerOption(values.memory, '--memory') ?? config.vm.memory
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
p.intro(pc.cyan('Starting sandbox VM'));
|
|
58
|
+
await ensureDocker(effectiveConfig, (detail) => {
|
|
59
|
+
p.log.info(detail);
|
|
60
|
+
});
|
|
61
|
+
p.outro(pc.green('VM ready'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stop() {
|
|
65
|
+
ensureManagedVm();
|
|
66
|
+
p.intro(pc.cyan('Stopping sandbox VM'));
|
|
67
|
+
|
|
68
|
+
if (!runOk('colima', ['status'])) {
|
|
69
|
+
p.log.warn('Colima VM is not running');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
run('colima', ['stop']);
|
|
74
|
+
p.outro(pc.green('VM stopped'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function vm(args) {
|
|
78
|
+
const [subcommand, ...rest] = args;
|
|
79
|
+
|
|
80
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
81
|
+
process.stdout.write(`${USAGE}\n`);
|
|
82
|
+
if (!subcommand) {
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (subcommand) {
|
|
89
|
+
case 'status':
|
|
90
|
+
status();
|
|
91
|
+
break;
|
|
92
|
+
case 'start':
|
|
93
|
+
await start(rest);
|
|
94
|
+
break;
|
|
95
|
+
case 'stop':
|
|
96
|
+
stop();
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(USAGE);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = Object.freeze({
|
|
6
|
+
runtimes: ['node20'],
|
|
7
|
+
tools: ['claude-code', 'codex', 'opencode', 'gemini-cli'],
|
|
8
|
+
dockerfile: null,
|
|
9
|
+
vm: {
|
|
10
|
+
cpu: null,
|
|
11
|
+
memory: null,
|
|
12
|
+
disk: null
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function detectRepoRoot() {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error('sandbox: current directory is not inside a git repository');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneDefaults() {
|
|
28
|
+
return {
|
|
29
|
+
runtimes: [...DEFAULTS.runtimes],
|
|
30
|
+
tools: [...DEFAULTS.tools],
|
|
31
|
+
dockerfile: DEFAULTS.dockerfile,
|
|
32
|
+
vm: { ...DEFAULTS.vm }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadConfig() {
|
|
37
|
+
const repoRoot = detectRepoRoot();
|
|
38
|
+
const home = process.env.HOME;
|
|
39
|
+
|
|
40
|
+
if (!home) {
|
|
41
|
+
throw new Error('sandbox: HOME environment variable is required');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const configPath = path.join(repoRoot, '.agents', '.airc.json');
|
|
45
|
+
if (!fs.existsSync(configPath)) {
|
|
46
|
+
throw new Error('No .agents/.airc.json found. Run "ai init" first.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const airc = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
50
|
+
const defaults = cloneDefaults();
|
|
51
|
+
const sandbox = airc.sandbox ?? {};
|
|
52
|
+
const project = airc.project;
|
|
53
|
+
|
|
54
|
+
if (!project || typeof project !== 'string') {
|
|
55
|
+
throw new Error('sandbox: .agents/.airc.json is missing a valid "project" field');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
repoRoot,
|
|
60
|
+
configPath,
|
|
61
|
+
project,
|
|
62
|
+
org: airc.org ?? '',
|
|
63
|
+
home,
|
|
64
|
+
containerPrefix: `${project}-dev`,
|
|
65
|
+
imageName: `${project}-sandbox:latest`,
|
|
66
|
+
worktreeBase: path.join(home, `.${project}-worktrees`),
|
|
67
|
+
runtimes: Array.isArray(sandbox.runtimes) && sandbox.runtimes.length > 0
|
|
68
|
+
? [...sandbox.runtimes]
|
|
69
|
+
: defaults.runtimes,
|
|
70
|
+
tools: Array.isArray(sandbox.tools) && sandbox.tools.length > 0
|
|
71
|
+
? [...sandbox.tools]
|
|
72
|
+
: defaults.tools,
|
|
73
|
+
dockerfile: sandbox.dockerfile ?? defaults.dockerfile,
|
|
74
|
+
vm: {
|
|
75
|
+
...defaults.vm,
|
|
76
|
+
...(sandbox.vm ?? {})
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const validatedBranches = new Set();
|
|
6
|
+
|
|
7
|
+
function dedupe(items) {
|
|
8
|
+
return [...new Set(items)];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertValidBranchName(branch) {
|
|
12
|
+
if (validatedBranches.has(branch)) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!branch || branch.trim().length === 0) {
|
|
17
|
+
throw new Error('Branch name is required');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
21
|
+
throw new Error(`Invalid branch name '${branch}': only letters, digits, ., _, -, and / are allowed`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
execFileSync('git', ['check-ref-format', '--branch', branch], {
|
|
26
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
27
|
+
});
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`Invalid branch name '${branch}': does not satisfy git branch naming rules`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
validatedBranches.add(branch);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sanitizeBranchName(branch) {
|
|
36
|
+
assertValidBranchName(branch);
|
|
37
|
+
return branch.replace(/\//g, '..');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function legacySanitizeBranchName(branch) {
|
|
41
|
+
assertValidBranchName(branch);
|
|
42
|
+
return branch.replace(/\//g, '-');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function safeNameCandidates(branch) {
|
|
46
|
+
return dedupe([sanitizeBranchName(branch), legacySanitizeBranchName(branch)]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function containerName(config, branch) {
|
|
50
|
+
return `${config.containerPrefix}-${sanitizeBranchName(branch)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function containerNameCandidates(config, branch) {
|
|
54
|
+
return safeNameCandidates(branch).map((name) => `${config.containerPrefix}-${name}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function worktreeDir(config, branch) {
|
|
58
|
+
return path.join(config.worktreeBase, sanitizeBranchName(branch));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function worktreeDirCandidates(config, branch) {
|
|
62
|
+
return safeNameCandidates(branch).map((name) => path.join(config.worktreeBase, name));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sandboxLabel(config) {
|
|
66
|
+
return `${config.project}.sandbox`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function sandboxBranchLabel(config) {
|
|
70
|
+
return `${sandboxLabel(config)}.branch`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sandboxImageConfigLabel(config) {
|
|
74
|
+
return `${sandboxLabel(config)}.image-config`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parsePositiveIntegerOption(value, optionName) {
|
|
78
|
+
if (value === undefined || value === null) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parsed = Number(value);
|
|
83
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
84
|
+
throw new Error(`${optionName} must be a positive integer, got: ${value}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function detectHostResources() {
|
|
91
|
+
if (process.platform === 'darwin') {
|
|
92
|
+
try {
|
|
93
|
+
const hostCpu = Number(execFileSync('sysctl', ['-n', 'hw.ncpu'], { encoding: 'utf8' }).trim());
|
|
94
|
+
const hostMemBytes = Number(execFileSync('sysctl', ['-n', 'hw.memsize'], { encoding: 'utf8' }).trim());
|
|
95
|
+
const hostMemGb = Math.floor(hostMemBytes / 1024 / 1024 / 1024);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
cpu: Math.max(1, hostCpu - 2),
|
|
99
|
+
memory: Math.max(2, Math.floor(hostMemGb / 2))
|
|
100
|
+
};
|
|
101
|
+
} catch {
|
|
102
|
+
// Fall through to generic detection below.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hostCpu = os.cpus()?.length ?? 4;
|
|
107
|
+
const hostMemGb = Math.floor(os.totalmem() / 1024 / 1024 / 1024);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
cpu: Math.max(1, Math.min(hostCpu, hostCpu - 1 || 1)),
|
|
111
|
+
memory: Math.max(2, Math.floor(hostMemGb / 2))
|
|
112
|
+
};
|
|
113
|
+
}
|