agent-cloud-kit 0.0.0-stage → 1.0.0-rc.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/LICENSE +21 -0
- package/README.md +229 -2
- package/dist/ca.js +30 -0
- package/dist/cli.js +418 -0
- package/dist/config.js +197 -0
- package/dist/constants.js +62 -0
- package/dist/detect.js +265 -0
- package/dist/docker.js +95 -0
- package/dist/doctor.js +200 -0
- package/dist/init.js +136 -0
- package/dist/installers.js +463 -0
- package/dist/launcher.js +140 -0
- package/dist/manual.js +211 -0
- package/dist/prepare/detect.js +57 -0
- package/dist/prepare/index.js +492 -0
- package/dist/prepare/prompts.js +43 -0
- package/dist/prepare/questions.js +70 -0
- package/dist/run.js +113 -0
- package/dist/session.js +245 -0
- package/dist/setup.js +277 -0
- package/dist/skill.js +148 -0
- package/dist/state.js +147 -0
- package/dist/steps.js +46 -0
- package/package.json +46 -4
- package/schema.json +72 -0
- package/skills/cloud-ready/SKILL.md +179 -0
- package/skills/cloud-ready/reference/compat.md +110 -0
- package/skills/cloud-ready/reference/knowledge.md +56 -0
package/dist/run.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Process runner: the single place where child processes are spawned.
|
|
3
|
+
* Modules that execute processes receive a `Run` (or a `Capture`) so tests can inject a fake.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
/** Signal sent to a process group that ran past its time limit. */
|
|
7
|
+
const KILL_SIGNAL = "SIGKILL";
|
|
8
|
+
/** File descriptor of the parent's stderr, reused as the child's stdout and stderr. */
|
|
9
|
+
const PARENT_STDERR = 2;
|
|
10
|
+
/** Error thrown when a child is killed because it ran past its time limit. */
|
|
11
|
+
export class RunTimeoutError extends Error {
|
|
12
|
+
/**
|
|
13
|
+
* Builds the error.
|
|
14
|
+
*
|
|
15
|
+
* @param cmd - Executable that timed out.
|
|
16
|
+
* @param ms - Time limit, in milliseconds.
|
|
17
|
+
*/
|
|
18
|
+
constructor(cmd, ms) {
|
|
19
|
+
super(`${cmd} timed out after ${ms}ms`);
|
|
20
|
+
this.name = "RunTimeoutError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Maps an output mode to the spawn stdio setting.
|
|
25
|
+
*
|
|
26
|
+
* @param mode - Output mode.
|
|
27
|
+
* @returns `"inherit"`, or stdin ignored and stdout plus stderr on the parent's stderr.
|
|
28
|
+
*/
|
|
29
|
+
function stdioFor(mode) {
|
|
30
|
+
return mode === "stderr" ? ["ignore", PARENT_STDERR, PARENT_STDERR] : "inherit";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Kills a detached child together with its process group; never throws.
|
|
34
|
+
*
|
|
35
|
+
* @param pid - Process id of the group leader.
|
|
36
|
+
*/
|
|
37
|
+
function killGroup(pid) {
|
|
38
|
+
if (pid === undefined)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(-pid, KILL_SIGNAL);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, KILL_SIGNAL);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// NOTE: the process already exited.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Spawns a process and settles when it exits, or when it runs past its time limit.
|
|
54
|
+
*
|
|
55
|
+
* @param cmd - Executable to run.
|
|
56
|
+
* @param args - Arguments passed to the executable.
|
|
57
|
+
* @param opts - Options.
|
|
58
|
+
* @param stdio - Spawn stdio setting.
|
|
59
|
+
* @param onStdout - Receives stdout chunks when stdout is piped.
|
|
60
|
+
* @returns Resolves on exit code 0.
|
|
61
|
+
* @throws When the process cannot be spawned, exits non-zero, is killed by a signal or times out.
|
|
62
|
+
*/
|
|
63
|
+
function spawnAndWait(cmd, args, opts, stdio, onStdout) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const limited = opts.timeoutMs !== undefined;
|
|
66
|
+
const child = spawn(cmd, args, { stdio, cwd: opts.cwd, env: opts.env, detached: limited });
|
|
67
|
+
if (onStdout)
|
|
68
|
+
child.stdout?.on("data", onStdout);
|
|
69
|
+
let timer;
|
|
70
|
+
if (limited) {
|
|
71
|
+
const ms = opts.timeoutMs ?? 0;
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
killGroup(child.pid);
|
|
74
|
+
reject(new RunTimeoutError(cmd, ms));
|
|
75
|
+
}, ms);
|
|
76
|
+
}
|
|
77
|
+
child.on("error", (err) => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
reject(err);
|
|
80
|
+
});
|
|
81
|
+
child.on("close", (code, signal) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
if (code === 0)
|
|
84
|
+
return resolve();
|
|
85
|
+
reject(new Error(`${cmd} exited with ${code === null ? `signal ${signal}` : `code ${code}`}`));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Spawns a process with inherited stdio, or with its output sent to stderr.
|
|
91
|
+
*
|
|
92
|
+
* @param cmd - Executable to run.
|
|
93
|
+
* @param args - Arguments passed to the executable.
|
|
94
|
+
* @param opts - Optional working directory, environment, output mode and timeout.
|
|
95
|
+
* @returns Resolves on exit code 0; rejects with the exit code in the message otherwise.
|
|
96
|
+
* @throws When the process cannot be spawned, exits with a non-zero code or signal, or times out
|
|
97
|
+
* (`RunTimeoutError`).
|
|
98
|
+
*/
|
|
99
|
+
export const runProcess = (cmd, args, opts = {}) => spawnAndWait(cmd, args, opts, stdioFor(opts.stdio ?? "inherit"));
|
|
100
|
+
/**
|
|
101
|
+
* Spawns a process and collects its stdout; stdin and stderr are ignored.
|
|
102
|
+
*
|
|
103
|
+
* @param cmd - Executable to run.
|
|
104
|
+
* @param args - Arguments passed to the executable.
|
|
105
|
+
* @param opts - Optional working directory, environment and timeout.
|
|
106
|
+
* @returns The stdout text.
|
|
107
|
+
* @throws When the process cannot be spawned, exits with a non-zero code or signal, or times out.
|
|
108
|
+
*/
|
|
109
|
+
export const captureProcess = async (cmd, args, opts = {}) => {
|
|
110
|
+
const chunks = [];
|
|
111
|
+
await spawnAndWait(cmd, args, opts, ["ignore", "pipe", "ignore"], (chunk) => chunks.push(chunk));
|
|
112
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
113
|
+
};
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `session` phase: runs from the SessionStart hook of a cloud session, reports the
|
|
3
|
+
* state of the machine, wires the pinned tools into the session and runs the repo's session steps.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { findProxyCa, shellQuote } from "./ca.js";
|
|
8
|
+
import { readConfig } from "./config.js";
|
|
9
|
+
import { CONFIG_FILE, GIT_FETCH_TIMEOUT_MS, OUTPUT_PREFIX, PACKAGE, SETUP_SLOW_MS } from "./constants.js";
|
|
10
|
+
import { configImages, defaultBranch, readToolVersions } from "./detect.js";
|
|
11
|
+
import { realDocker } from "./docker.js";
|
|
12
|
+
import { pathLine, toolBinDirs } from "./installers.js";
|
|
13
|
+
import { linkDir } from "./launcher.js";
|
|
14
|
+
import { resolveNotes } from "./manual.js";
|
|
15
|
+
import { RunTimeoutError } from "./run.js";
|
|
16
|
+
import { fingerprint, readState } from "./state.js";
|
|
17
|
+
/** Generic note shown in every cloud session. */
|
|
18
|
+
const GENERIC_NOTE = "you are in a Claude Code cloud session. There are no open ports or previews: test against localhost. gh pr and gh issue fail (the GitHub proxy blocks GraphQL): use gh api (REST).";
|
|
19
|
+
/** Milliseconds in a second. */
|
|
20
|
+
const MS_PER_SECOND = 1000;
|
|
21
|
+
/** Seconds in a minute. */
|
|
22
|
+
const SECONDS_PER_MINUTE = 60;
|
|
23
|
+
/** Environment variable holding the file where the session reads extra exports from. */
|
|
24
|
+
const ENV_FILE_VAR = "CLAUDE_ENV_FILE";
|
|
25
|
+
/** Environment variable that exposes the proxy CA path to the session. */
|
|
26
|
+
const PROXY_CA_VAR = "CLOUD_PROXY_CA";
|
|
27
|
+
/** Path of the shallow marker inside a git directory. */
|
|
28
|
+
const SHALLOW_MARKER = join(".git", "shallow");
|
|
29
|
+
/** Environment that keeps git from ever prompting. */
|
|
30
|
+
const GIT_NON_INTERACTIVE = { GIT_TERMINAL_PROMPT: "0" };
|
|
31
|
+
/** Time limit of each repo session step, in milliseconds. */
|
|
32
|
+
export const SESSION_STEP_TIMEOUT_MS = 120_000;
|
|
33
|
+
/**
|
|
34
|
+
* Lists the directories to put first on PATH: the pinned tools' bin dirs, then the folder of the
|
|
35
|
+
* `agent-cloud-kit` link, so `agent-cloud-kit doctor` resolves in the session.
|
|
36
|
+
*
|
|
37
|
+
* @param versions - Requested versions by tool.
|
|
38
|
+
* @param toolsDir - Tools directory.
|
|
39
|
+
* @param resolved - Versions setup installed (`MachineState.resolved`).
|
|
40
|
+
* @returns The directories, tools first.
|
|
41
|
+
* @example
|
|
42
|
+
* pathDirs({ node: "22" }, "/opt/agent-cloud-kit", { node: "22.12.0" }); // ["/opt/agent-cloud-kit/node-22.12.0/bin", "/opt/agent-cloud-kit/bin"]
|
|
43
|
+
*/
|
|
44
|
+
export function pathDirs(versions, toolsDir, resolved) {
|
|
45
|
+
return [...toolBinDirs(versions, toolsDir, resolved), linkDir(toolsDir)];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Builds the environment for repo commands: some directories first on PATH, plus the CA.
|
|
49
|
+
*
|
|
50
|
+
* @param dirs - Directories to put first on PATH (`pathDirs`).
|
|
51
|
+
* @param baseEnv - Environment to extend; it is not modified.
|
|
52
|
+
* @param ca - Proxy CA path, when found.
|
|
53
|
+
* @returns A new environment.
|
|
54
|
+
* @example
|
|
55
|
+
* toolsEnv(["/opt/agent-cloud-kit/node-24.14.1/bin"], process.env, "/root/.ccr/ca-bundle.crt");
|
|
56
|
+
*/
|
|
57
|
+
export function toolsEnv(dirs, baseEnv, ca) {
|
|
58
|
+
const path = [...dirs, ...(baseEnv.PATH ? [baseEnv.PATH] : [])].join(":");
|
|
59
|
+
const env = { ...baseEnv, PATH: path };
|
|
60
|
+
if (ca)
|
|
61
|
+
env[PROXY_CA_VAR] = ca;
|
|
62
|
+
return env;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Formats a duration in milliseconds as `<m>m<s>s`.
|
|
66
|
+
*
|
|
67
|
+
* @param ms - Milliseconds.
|
|
68
|
+
* @returns For example `4m10s`.
|
|
69
|
+
*/
|
|
70
|
+
function minutesSeconds(ms) {
|
|
71
|
+
const total = Math.round(ms / MS_PER_SECOND);
|
|
72
|
+
return `${Math.floor(total / SECONDS_PER_MINUTE)}m${total % SECONDS_PER_MINUTE}s`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Computes the fingerprint the machine should have, using the package version stored in the state
|
|
76
|
+
* so a package upgrade alone is not reported here (setup's relaunch handles versions).
|
|
77
|
+
*
|
|
78
|
+
* @param repo - Repo root.
|
|
79
|
+
* @param config - Repo config.
|
|
80
|
+
* @param state - Recorded state.
|
|
81
|
+
* @returns The expected fingerprint.
|
|
82
|
+
* @example
|
|
83
|
+
* expectedFingerprint("/home/user/app", config, state) === state.fingerprint;
|
|
84
|
+
*/
|
|
85
|
+
export function expectedFingerprint(repo, config, state) {
|
|
86
|
+
return fingerprint(config, readToolVersions(repo), configImages(config, repo), state.packageVersion);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Describes what is wrong with the machine, from the recorded state.
|
|
90
|
+
*
|
|
91
|
+
* @param repo - Repo root.
|
|
92
|
+
* @param config - Repo config.
|
|
93
|
+
* @param state - Recorded state, if any.
|
|
94
|
+
* @returns Warning messages, without prefix.
|
|
95
|
+
*/
|
|
96
|
+
function machineWarnings(repo, config, state) {
|
|
97
|
+
if (!state) {
|
|
98
|
+
return [
|
|
99
|
+
`the environment setup did not run for ${basename(repo)}. Set the setup script of the environment in claude.ai to: npx -y ${PACKAGE}@1 setup`,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
const out = [];
|
|
103
|
+
const failed = state.steps.filter((s) => !s.ok).map((s) => s.name);
|
|
104
|
+
if (failed.length)
|
|
105
|
+
out.push(`setup failed: ${failed.join(", ")}. Run agent-cloud-kit doctor.`);
|
|
106
|
+
if (expectedFingerprint(repo, config, state) !== state.fingerprint) {
|
|
107
|
+
out.push("the machine was prepared with another configuration. Edit the setup script field in claude.ai (any change) so it is rebuilt.");
|
|
108
|
+
}
|
|
109
|
+
const total = state.steps.reduce((sum, s) => sum + s.ms, 0);
|
|
110
|
+
if (total > SETUP_SLOW_MS)
|
|
111
|
+
out.push(`setup took ${minutesSeconds(total)}, close to the ~5 minute limit.`);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Appends the PATH line and the CA export to `$CLAUDE_ENV_FILE`, when it is set; never throws.
|
|
116
|
+
*
|
|
117
|
+
* @param env - Process environment.
|
|
118
|
+
* @param lines - Lines to write; empty ones are dropped.
|
|
119
|
+
* @returns An error line when the file could not be written; undefined otherwise.
|
|
120
|
+
*/
|
|
121
|
+
function writeEnvFile(env, lines) {
|
|
122
|
+
const file = env[ENV_FILE_VAR];
|
|
123
|
+
const body = lines.filter(Boolean);
|
|
124
|
+
if (!file || !body.length)
|
|
125
|
+
return undefined;
|
|
126
|
+
try {
|
|
127
|
+
appendFileSync(file, `${body.join("\n")}\n`);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return `could not write ${ENV_FILE_VAR} (${file}): ${err instanceof Error ? err.message : String(err)}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Makes the clone complete and brings the default branch, so `git diff main...` works.
|
|
136
|
+
* Each git call is an argv array; the branch is validated before it is used in a refspec.
|
|
137
|
+
*
|
|
138
|
+
* @param repo - Repo root.
|
|
139
|
+
* @param run - Process runner.
|
|
140
|
+
* @param env - Environment for git.
|
|
141
|
+
*/
|
|
142
|
+
async function syncGit(repo, run, env) {
|
|
143
|
+
const opts = { cwd: repo, env: { ...env, ...GIT_NON_INTERACTIVE }, stdio: "stderr" };
|
|
144
|
+
const fetchOpts = { ...opts, timeoutMs: GIT_FETCH_TIMEOUT_MS };
|
|
145
|
+
if (existsSync(join(repo, SHALLOW_MARKER))) {
|
|
146
|
+
await run("git", ["fetch", "-q", "--unshallow", "origin"], fetchOpts).catch(() => undefined);
|
|
147
|
+
}
|
|
148
|
+
const branch = defaultBranch(repo);
|
|
149
|
+
const valid = await run("git", ["check-ref-format", "--branch", branch], opts).then(() => !branch.startsWith("-"), () => false);
|
|
150
|
+
if (!valid)
|
|
151
|
+
return;
|
|
152
|
+
await run("git", ["fetch", "-q", "origin", `${branch}:refs/remotes/origin/${branch}`], fetchOpts).catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Runs the repo's session commands in order; a failure is reported and does not stop the rest.
|
|
156
|
+
* Their output goes to stderr, so it never lands in Claude's context, and each one is killed
|
|
157
|
+
* after `SESSION_STEP_TIMEOUT_MS`.
|
|
158
|
+
*
|
|
159
|
+
* @param repo - Repo root.
|
|
160
|
+
* @param commands - Shell commands from the config (repo-owned by design).
|
|
161
|
+
* @param run - Process runner.
|
|
162
|
+
* @param env - Environment for the commands.
|
|
163
|
+
* @returns One line per failed or timed out command.
|
|
164
|
+
*/
|
|
165
|
+
async function runSessionSteps(repo, commands, run, env) {
|
|
166
|
+
const problems = [];
|
|
167
|
+
for (const command of commands) {
|
|
168
|
+
try {
|
|
169
|
+
await run("bash", ["-c", command], { cwd: repo, env, stdio: "stderr", timeoutMs: SESSION_STEP_TIMEOUT_MS });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
problems.push(`${err instanceof RunTimeoutError ? "session step timed out" : "session step failed"}: ${command}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return problems;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Starts Docker without ever throwing, even when the injected operation does.
|
|
179
|
+
*
|
|
180
|
+
* @param docker - Docker operations.
|
|
181
|
+
* @returns An error message, or undefined on success.
|
|
182
|
+
*/
|
|
183
|
+
function startDocker(docker) {
|
|
184
|
+
try {
|
|
185
|
+
return docker.start();
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return err instanceof Error ? err.message : String(err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Does the work of `session`; may throw, the caller turns that into a line.
|
|
193
|
+
*
|
|
194
|
+
* @param repo - Repo root.
|
|
195
|
+
* @param ctx - Dependencies.
|
|
196
|
+
* @param out - Output lines, filled in order.
|
|
197
|
+
*/
|
|
198
|
+
async function sessionBody(repo, ctx, out) {
|
|
199
|
+
out.push(GENERIC_NOTE);
|
|
200
|
+
if (!existsSync(join(repo, CONFIG_FILE)))
|
|
201
|
+
return;
|
|
202
|
+
const { config, errors } = readConfig(repo);
|
|
203
|
+
out.push(...errors);
|
|
204
|
+
const versions = readToolVersions(repo);
|
|
205
|
+
const state = readState(ctx.toolsDir, repo);
|
|
206
|
+
const dirs = pathDirs(versions, ctx.toolsDir, state?.resolved ?? {});
|
|
207
|
+
const ca = findProxyCa(ctx.env, existsSync);
|
|
208
|
+
const envFileProblem = writeEnvFile(ctx.env, [pathLine(dirs), ca ? `export ${PROXY_CA_VAR}=${shellQuote(ca)}` : ""]);
|
|
209
|
+
if (envFileProblem)
|
|
210
|
+
out.push(envFileProblem);
|
|
211
|
+
if (config)
|
|
212
|
+
out.push(...machineWarnings(repo, config, state));
|
|
213
|
+
const missing = (config?.requiredEnv ?? []).filter((name) => !ctx.env[name]);
|
|
214
|
+
if (missing.length)
|
|
215
|
+
out.push(`missing environment variables: ${missing.join(", ")}`);
|
|
216
|
+
const notes = resolveNotes(repo, config?.notes)?.trim();
|
|
217
|
+
if (notes)
|
|
218
|
+
out.push(notes);
|
|
219
|
+
const dockerProblem = startDocker(ctx.docker ?? realDocker);
|
|
220
|
+
if (dockerProblem)
|
|
221
|
+
out.push(`could not start dockerd: ${dockerProblem}`);
|
|
222
|
+
const env = toolsEnv(dirs, ctx.env, ca);
|
|
223
|
+
await syncGit(repo, ctx.run, env);
|
|
224
|
+
out.push(...(await runSessionSteps(repo, config?.session ?? [], ctx.run, env)));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Runs the session phase for a repo.
|
|
228
|
+
*
|
|
229
|
+
* @param repo - Repo root.
|
|
230
|
+
* @param ctx - Dependencies (environment, tools dir, runner, package version, Docker).
|
|
231
|
+
* @returns The lines to print on stdout, each with the output prefix; never rejects.
|
|
232
|
+
* @example
|
|
233
|
+
* (await session("/home/user/app", { env: process.env, toolsDir, run: runProcess, packageVersion: "1.0.0" }))
|
|
234
|
+
* .forEach((line) => console.log(line));
|
|
235
|
+
*/
|
|
236
|
+
export async function session(repo, ctx) {
|
|
237
|
+
const out = [];
|
|
238
|
+
try {
|
|
239
|
+
await sessionBody(repo, ctx, out);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
out.push(`session error: ${err instanceof Error ? err.message : String(err)}`);
|
|
243
|
+
}
|
|
244
|
+
return out.map((line) => OUTPUT_PREFIX + line);
|
|
245
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `setup` phase for one repo: runs as root before Claude starts, installs the pinned
|
|
3
|
+
* tools, apt packages and Docker images and copies this package into the tools dir, then runs the
|
|
4
|
+
* repo's setup steps and records the result. Values from the repo config are untrusted: they are
|
|
5
|
+
* validated and always passed as argv, never interpolated into a shell string.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
8
|
+
import { dirname, join, relative } from "node:path";
|
|
9
|
+
import { findProxyCa } from "./ca.js";
|
|
10
|
+
import { readConfig } from "./config.js";
|
|
11
|
+
import { APT_STEP_TIMEOUT_MS, DOCKERD_LOG, DOCKER_PULL_TIMEOUT_MS, PULL_JOBS, RUNNING_PACKAGE_ROOT, SETUP_STEP_TIMEOUT_MS } from "./constants.js";
|
|
12
|
+
import { configImages, readToolVersions } from "./detect.js";
|
|
13
|
+
import { realDocker } from "./docker.js";
|
|
14
|
+
import { curlNodeIndex, installTools, resolveVersions } from "./installers.js";
|
|
15
|
+
import { linkPath, packageBin, packageInstallDir } from "./launcher.js";
|
|
16
|
+
import { pathDirs, toolsEnv } from "./session.js";
|
|
17
|
+
import { fingerprint, writeState } from "./state.js";
|
|
18
|
+
import { lanes, step } from "./steps.js";
|
|
19
|
+
/** Strict Debian package name; anything else is never handed to apt-get. */
|
|
20
|
+
const DEBIAN_PACKAGE_NAME = /^[a-z0-9][a-z0-9+.-]+$/;
|
|
21
|
+
/** Only scheme accepted for apt repo setup scripts. */
|
|
22
|
+
const HTTPS_PREFIX = "https://";
|
|
23
|
+
/** Script that downloads an apt repo setup script (`$1`) and runs it; fails if curl fails. */
|
|
24
|
+
const APT_REPO_SCRIPT = 'set -o pipefail; curl -1sLf -- "$1" | bash';
|
|
25
|
+
/** Placeholder for `$0` in inline bash scripts. */
|
|
26
|
+
const SCRIPT_NAME = "_";
|
|
27
|
+
/** Environment that keeps apt from asking questions. */
|
|
28
|
+
const APT_ENV = { DEBIAN_FRONTEND: "noninteractive" };
|
|
29
|
+
/** Characters that make an image reference unsafe to pass on: any whitespace. */
|
|
30
|
+
const WHITESPACE = /\s/;
|
|
31
|
+
/** Prefix of the name of each repo setup step. */
|
|
32
|
+
const SETUP_STEP_PREFIX = "setup: ";
|
|
33
|
+
/** Manifest file of this package. */
|
|
34
|
+
const MANIFEST = "package.json";
|
|
35
|
+
/** Compiled code of this package, copied together with the manifest. */
|
|
36
|
+
const DIST_DIR = "dist";
|
|
37
|
+
/** Entry point of the CLI, relative to the package root. */
|
|
38
|
+
const CLI_ENTRY = join(DIST_DIR, "cli.js");
|
|
39
|
+
/** Mode of the CLI entry point: executable by everyone. */
|
|
40
|
+
const EXECUTABLE_MODE = 0o755;
|
|
41
|
+
/**
|
|
42
|
+
* Builds a step that always fails with a message, for input that is refused without running it.
|
|
43
|
+
*
|
|
44
|
+
* @param name - Step name.
|
|
45
|
+
* @param message - Why it was refused.
|
|
46
|
+
* @returns The failed result.
|
|
47
|
+
*/
|
|
48
|
+
function refused(name, message) {
|
|
49
|
+
return step(name, async () => {
|
|
50
|
+
throw new Error(message);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Runs the apt work in series: repo scripts, then `apt-get update`, then `apt-get install`.
|
|
55
|
+
*
|
|
56
|
+
* @param config - Repo config.
|
|
57
|
+
* @param deps - Shared dependencies.
|
|
58
|
+
* @returns One result per apt repo and refused package, plus update (when there is a repo or a
|
|
59
|
+
* valid package) and install (when there is a valid package).
|
|
60
|
+
*/
|
|
61
|
+
async function aptLane(config, deps) {
|
|
62
|
+
if (!config.aptRepos.length && !config.apt.length)
|
|
63
|
+
return [];
|
|
64
|
+
const env = { ...deps.env, ...APT_ENV };
|
|
65
|
+
const results = [];
|
|
66
|
+
for (const url of config.aptRepos) {
|
|
67
|
+
const name = `apt repo ${url}`;
|
|
68
|
+
if (!url.startsWith(HTTPS_PREFIX)) {
|
|
69
|
+
results.push(await refused(name, `apt repo must be an ${HTTPS_PREFIX} URL`));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
results.push(await step(name, () => deps.run("bash", ["-c", APT_REPO_SCRIPT, SCRIPT_NAME, url], { env, timeoutMs: APT_STEP_TIMEOUT_MS })));
|
|
73
|
+
}
|
|
74
|
+
const valid = config.apt.filter((pkg) => DEBIAN_PACKAGE_NAME.test(pkg));
|
|
75
|
+
for (const pkg of config.apt.filter((p) => !valid.includes(p))) {
|
|
76
|
+
results.push(await refused(`apt package ${pkg}`, "invalid Debian package name"));
|
|
77
|
+
}
|
|
78
|
+
if (!valid.length && !config.aptRepos.length)
|
|
79
|
+
return results;
|
|
80
|
+
results.push(await step("apt-get update", () => deps.run("apt-get", ["update", "-q"], { env, timeoutMs: APT_STEP_TIMEOUT_MS })));
|
|
81
|
+
if (valid.length) {
|
|
82
|
+
results.push(await step("apt-get install", () => deps.run("apt-get", ["install", "-y", "-q", "--", ...valid], { env, timeoutMs: APT_STEP_TIMEOUT_MS })));
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Pulls images with at most `PULL_JOBS` pulls at a time, keeping the input order in the results.
|
|
88
|
+
*
|
|
89
|
+
* @param images - Validated image references.
|
|
90
|
+
* @param deps - Shared dependencies.
|
|
91
|
+
* @returns One result per image.
|
|
92
|
+
*/
|
|
93
|
+
async function pullAll(images, deps) {
|
|
94
|
+
const results = new Array(images.length);
|
|
95
|
+
let next = 0;
|
|
96
|
+
const worker = async () => {
|
|
97
|
+
while (next < images.length) {
|
|
98
|
+
const index = next++;
|
|
99
|
+
const image = images[index];
|
|
100
|
+
results[index] = await step(`docker pull ${image}`, () => deps.run("docker", ["pull", "-q", image], { env: deps.env, timeoutMs: DOCKER_PULL_TIMEOUT_MS }));
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
await Promise.all(Array.from({ length: Math.min(PULL_JOBS, images.length) }, worker));
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Starts Docker and pulls the images. Docker is started even with no images, because setup
|
|
108
|
+
* steps may use it (`docker build`); only `docker.pull: false` leaves it alone.
|
|
109
|
+
*
|
|
110
|
+
* @param config - Repo config.
|
|
111
|
+
* @param images - Image references from the config or detection (untrusted).
|
|
112
|
+
* @param docker - Docker operations.
|
|
113
|
+
* @param deps - Shared dependencies.
|
|
114
|
+
* @returns A failed `docker` step when the daemon does not answer, otherwise one result per image.
|
|
115
|
+
*/
|
|
116
|
+
async function dockerLane(config, images, docker, deps) {
|
|
117
|
+
if (config.docker.pull === false)
|
|
118
|
+
return [];
|
|
119
|
+
const results = [];
|
|
120
|
+
const valid = images.filter((image) => !WHITESPACE.test(image) && !image.startsWith("-") && image !== "");
|
|
121
|
+
for (const image of images.filter((i) => !valid.includes(i))) {
|
|
122
|
+
results.push(await refused(`docker pull ${image}`, "invalid image reference"));
|
|
123
|
+
}
|
|
124
|
+
const up = await docker.ensure().catch(() => false);
|
|
125
|
+
if (!up)
|
|
126
|
+
return [...results, await refused("docker", `dockerd did not start; see ${DOCKERD_LOG}`)];
|
|
127
|
+
return [...results, ...(await pullAll(valid, deps))];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Runs the repo's setup commands in series, in the repo, with the pinned tools on PATH.
|
|
131
|
+
*
|
|
132
|
+
* @param commands - Shell commands from the config (repo-owned by design).
|
|
133
|
+
* @param deps - Shared dependencies; `env` already carries the tools PATH.
|
|
134
|
+
* @returns One result per command.
|
|
135
|
+
*/
|
|
136
|
+
async function setupSteps(commands, deps) {
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const command of commands) {
|
|
139
|
+
results.push(await step(SETUP_STEP_PREFIX + command, () => deps.run("bash", ["-c", command], { cwd: deps.repo, env: deps.env, timeoutMs: SETUP_STEP_TIMEOUT_MS })));
|
|
140
|
+
}
|
|
141
|
+
return results;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Points a link at a target, replacing any old link atomically.
|
|
145
|
+
*
|
|
146
|
+
* @param link - Link path.
|
|
147
|
+
* @param target - Link target.
|
|
148
|
+
* @throws When the link cannot be created.
|
|
149
|
+
*/
|
|
150
|
+
function replaceLink(link, target) {
|
|
151
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
152
|
+
const temp = `${link}.${process.pid}.tmp`;
|
|
153
|
+
rmSync(temp, { force: true });
|
|
154
|
+
symlinkSync(target, temp);
|
|
155
|
+
renameSync(temp, link);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Reads the version from a package manifest; never throws.
|
|
159
|
+
*
|
|
160
|
+
* @param root - Package root.
|
|
161
|
+
* @returns The version, or undefined when the manifest is missing or invalid.
|
|
162
|
+
*/
|
|
163
|
+
function manifestVersion(root) {
|
|
164
|
+
try {
|
|
165
|
+
const version = JSON.parse(readFileSync(join(root, MANIFEST), "utf8")).version;
|
|
166
|
+
return typeof version === "string" ? version : undefined;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Copies the package manifest and `dist/` into a target folder, through a temp folder renamed
|
|
174
|
+
* into place, and leaves the CLI entry point executable.
|
|
175
|
+
*
|
|
176
|
+
* @param source - Root of the running package.
|
|
177
|
+
* @param target - Destination package root.
|
|
178
|
+
* @param version - Version expected in the source manifest.
|
|
179
|
+
* @throws When the source is another version or the copy fails.
|
|
180
|
+
*/
|
|
181
|
+
function copyPackage(source, target, version) {
|
|
182
|
+
const found = manifestVersion(source);
|
|
183
|
+
if (found !== version)
|
|
184
|
+
throw new Error(`the package at ${source} is ${found ?? "unknown"}, not ${version}`);
|
|
185
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
186
|
+
rmSync(temp, { recursive: true, force: true });
|
|
187
|
+
mkdirSync(temp, { recursive: true });
|
|
188
|
+
cpSync(join(source, MANIFEST), join(temp, MANIFEST));
|
|
189
|
+
cpSync(join(source, DIST_DIR), join(temp, DIST_DIR), { recursive: true });
|
|
190
|
+
chmodSync(join(temp, CLI_ENTRY), EXECUTABLE_MODE);
|
|
191
|
+
rmSync(target, { recursive: true, force: true });
|
|
192
|
+
renameSync(temp, target);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Copies the running package into `<toolsDir>/pkg/<version>` (unless that version is already
|
|
196
|
+
* there) and links its binary, without npm or network, so it works before anything else runs.
|
|
197
|
+
*
|
|
198
|
+
* @param version - Version of this package.
|
|
199
|
+
* @param toolsDir - Tools directory.
|
|
200
|
+
* @param source - Root of the running package.
|
|
201
|
+
* @returns The self install result.
|
|
202
|
+
*/
|
|
203
|
+
function selfInstall(version, toolsDir, source) {
|
|
204
|
+
return step(`self install ${version}`, async () => {
|
|
205
|
+
const target = packageInstallDir(toolsDir, version);
|
|
206
|
+
if (manifestVersion(target) !== version)
|
|
207
|
+
copyPackage(source, target, version);
|
|
208
|
+
const bin = packageBin(toolsDir, version);
|
|
209
|
+
replaceLink(bin, relative(dirname(bin), join(target, CLI_ENTRY)));
|
|
210
|
+
replaceLink(linkPath(toolsDir), bin);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Runs the phases and writes the state; may throw, the caller turns that into a step. Phase 1
|
|
215
|
+
* runs the tools, apt, Docker and the self install in parallel lanes; phase 2 runs the repo's
|
|
216
|
+
* setup steps. The fingerprint reads versions and images again after the setup steps, because
|
|
217
|
+
* those steps may generate the files they come from and `session` reads them later.
|
|
218
|
+
*
|
|
219
|
+
* @param repo - Repo root.
|
|
220
|
+
* @param config - Valid repo config.
|
|
221
|
+
* @param ctx - Dependencies.
|
|
222
|
+
* @returns The recorded state.
|
|
223
|
+
*/
|
|
224
|
+
async function runPhases(repo, config, ctx) {
|
|
225
|
+
const env = ctx.env ?? process.env;
|
|
226
|
+
const versions = readToolVersions(repo);
|
|
227
|
+
const images = configImages(config, repo);
|
|
228
|
+
const base = { repo, toolsDir: ctx.toolsDir, run: ctx.run, env };
|
|
229
|
+
let resolved = {};
|
|
230
|
+
const toolsLane = async () => {
|
|
231
|
+
const resolution = await resolveVersions(versions, curlNodeIndex(ctx.run, env));
|
|
232
|
+
resolved = resolution.resolved;
|
|
233
|
+
return [...resolution.results, ...(await installTools(resolved, ctx.toolsDir, ctx.run, env))];
|
|
234
|
+
};
|
|
235
|
+
const steps = await lanes(toolsLane, () => aptLane(config, base), () => dockerLane(config, images, ctx.docker ?? realDocker, base), async () => [await selfInstall(ctx.packageVersion, ctx.toolsDir, ctx.packageRoot ?? RUNNING_PACKAGE_ROOT)]);
|
|
236
|
+
const dirs = pathDirs(versions, ctx.toolsDir, resolved);
|
|
237
|
+
const toolsDeps = { ...base, env: toolsEnv(dirs, env, findProxyCa(env, existsSync)) };
|
|
238
|
+
steps.push(...(await setupSteps(config.setup, toolsDeps)));
|
|
239
|
+
const state = {
|
|
240
|
+
fingerprint: fingerprint(config, readToolVersions(repo), configImages(config, repo), ctx.packageVersion),
|
|
241
|
+
packageVersion: ctx.packageVersion,
|
|
242
|
+
finishedAt: new Date().toISOString(),
|
|
243
|
+
steps,
|
|
244
|
+
resolved,
|
|
245
|
+
};
|
|
246
|
+
const saved = await step("write state", async () => writeState(ctx.toolsDir, repo, state));
|
|
247
|
+
return saved.ok ? state : { ...state, steps: [...steps, saved] };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Prepares the machine for one repo: tools, apt, Docker and the self install in parallel lanes
|
|
251
|
+
* (apt work in series inside its lane), then the repo's setup steps; writes the state.
|
|
252
|
+
*
|
|
253
|
+
* @param repo - Repo root.
|
|
254
|
+
* @param ctx - Dependencies (tools dir, runner, package version, optional env and Docker).
|
|
255
|
+
* @returns The state; when the config is missing or invalid, a state with one failed `config`
|
|
256
|
+
* step that is not written. Never rejects.
|
|
257
|
+
* @example
|
|
258
|
+
* const state = await setupRepo("/home/user/app", { toolsDir: "/opt/agent-cloud-kit", run: runProcess, packageVersion: "1.0.0" });
|
|
259
|
+
*/
|
|
260
|
+
export async function setupRepo(repo, ctx) {
|
|
261
|
+
const failed = (name, error) => ({
|
|
262
|
+
fingerprint: "",
|
|
263
|
+
packageVersion: ctx.packageVersion,
|
|
264
|
+
finishedAt: new Date().toISOString(),
|
|
265
|
+
steps: [{ name, ok: false, ms: 0, error }],
|
|
266
|
+
resolved: {},
|
|
267
|
+
});
|
|
268
|
+
const { config, errors } = readConfig(repo);
|
|
269
|
+
if (!config)
|
|
270
|
+
return failed("config", errors.join("; ") || "no config");
|
|
271
|
+
try {
|
|
272
|
+
return await runPhases(repo, config, ctx);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
return failed("setup", err instanceof Error ? err.message : String(err));
|
|
276
|
+
}
|
|
277
|
+
}
|