agent-cloud-kit 0.0.0-stage → 1.0.0-rc.1

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.
@@ -0,0 +1,246 @@
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, packageSpec, 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
+ * @param packageVersion - Version of the running package, for the setup command.
95
+ * @returns Warning messages, without prefix.
96
+ */
97
+ function machineWarnings(repo, config, state, packageVersion) {
98
+ if (!state) {
99
+ return [
100
+ `the environment setup did not run for ${basename(repo)}. Set the setup script of the environment in claude.ai to: npx -y ${packageSpec(packageVersion)} setup`,
101
+ ];
102
+ }
103
+ const out = [];
104
+ const failed = state.steps.filter((s) => !s.ok).map((s) => s.name);
105
+ if (failed.length)
106
+ out.push(`setup failed: ${failed.join(", ")}. Run agent-cloud-kit doctor.`);
107
+ if (expectedFingerprint(repo, config, state) !== state.fingerprint) {
108
+ out.push("the machine was prepared with another configuration. Edit the setup script field in claude.ai (any change) so it is rebuilt.");
109
+ }
110
+ const total = state.steps.reduce((sum, s) => sum + s.ms, 0);
111
+ if (total > SETUP_SLOW_MS)
112
+ out.push(`setup took ${minutesSeconds(total)}, close to the ~5 minute limit.`);
113
+ return out;
114
+ }
115
+ /**
116
+ * Appends the PATH line and the CA export to `$CLAUDE_ENV_FILE`, when it is set; never throws.
117
+ *
118
+ * @param env - Process environment.
119
+ * @param lines - Lines to write; empty ones are dropped.
120
+ * @returns An error line when the file could not be written; undefined otherwise.
121
+ */
122
+ function writeEnvFile(env, lines) {
123
+ const file = env[ENV_FILE_VAR];
124
+ const body = lines.filter(Boolean);
125
+ if (!file || !body.length)
126
+ return undefined;
127
+ try {
128
+ appendFileSync(file, `${body.join("\n")}\n`);
129
+ return undefined;
130
+ }
131
+ catch (err) {
132
+ return `could not write ${ENV_FILE_VAR} (${file}): ${err instanceof Error ? err.message : String(err)}`;
133
+ }
134
+ }
135
+ /**
136
+ * Makes the clone complete and brings the default branch, so `git diff main...` works.
137
+ * Each git call is an argv array; the branch is validated before it is used in a refspec.
138
+ *
139
+ * @param repo - Repo root.
140
+ * @param run - Process runner.
141
+ * @param env - Environment for git.
142
+ */
143
+ async function syncGit(repo, run, env) {
144
+ const opts = { cwd: repo, env: { ...env, ...GIT_NON_INTERACTIVE }, stdio: "stderr" };
145
+ const fetchOpts = { ...opts, timeoutMs: GIT_FETCH_TIMEOUT_MS };
146
+ if (existsSync(join(repo, SHALLOW_MARKER))) {
147
+ await run("git", ["fetch", "-q", "--unshallow", "origin"], fetchOpts).catch(() => undefined);
148
+ }
149
+ const branch = defaultBranch(repo);
150
+ const valid = await run("git", ["check-ref-format", "--branch", branch], opts).then(() => !branch.startsWith("-"), () => false);
151
+ if (!valid)
152
+ return;
153
+ await run("git", ["fetch", "-q", "origin", `${branch}:refs/remotes/origin/${branch}`], fetchOpts).catch(() => undefined);
154
+ }
155
+ /**
156
+ * Runs the repo's session commands in order; a failure is reported and does not stop the rest.
157
+ * Their output goes to stderr, so it never lands in Claude's context, and each one is killed
158
+ * after `SESSION_STEP_TIMEOUT_MS`.
159
+ *
160
+ * @param repo - Repo root.
161
+ * @param commands - Shell commands from the config (repo-owned by design).
162
+ * @param run - Process runner.
163
+ * @param env - Environment for the commands.
164
+ * @returns One line per failed or timed out command.
165
+ */
166
+ async function runSessionSteps(repo, commands, run, env) {
167
+ const problems = [];
168
+ for (const command of commands) {
169
+ try {
170
+ await run("bash", ["-c", command], { cwd: repo, env, stdio: "stderr", timeoutMs: SESSION_STEP_TIMEOUT_MS });
171
+ }
172
+ catch (err) {
173
+ problems.push(`${err instanceof RunTimeoutError ? "session step timed out" : "session step failed"}: ${command}`);
174
+ }
175
+ }
176
+ return problems;
177
+ }
178
+ /**
179
+ * Starts Docker without ever throwing, even when the injected operation does.
180
+ *
181
+ * @param docker - Docker operations.
182
+ * @returns An error message, or undefined on success.
183
+ */
184
+ function startDocker(docker) {
185
+ try {
186
+ return docker.start();
187
+ }
188
+ catch (err) {
189
+ return err instanceof Error ? err.message : String(err);
190
+ }
191
+ }
192
+ /**
193
+ * Does the work of `session`; may throw, the caller turns that into a line.
194
+ *
195
+ * @param repo - Repo root.
196
+ * @param ctx - Dependencies.
197
+ * @param out - Output lines, filled in order.
198
+ */
199
+ async function sessionBody(repo, ctx, out) {
200
+ out.push(GENERIC_NOTE);
201
+ if (!existsSync(join(repo, CONFIG_FILE)))
202
+ return;
203
+ const { config, errors } = readConfig(repo);
204
+ out.push(...errors);
205
+ const versions = readToolVersions(repo);
206
+ const state = readState(ctx.toolsDir, repo);
207
+ const dirs = pathDirs(versions, ctx.toolsDir, state?.resolved ?? {});
208
+ const ca = findProxyCa(ctx.env, existsSync);
209
+ const envFileProblem = writeEnvFile(ctx.env, [pathLine(dirs), ca ? `export ${PROXY_CA_VAR}=${shellQuote(ca)}` : ""]);
210
+ if (envFileProblem)
211
+ out.push(envFileProblem);
212
+ if (config)
213
+ out.push(...machineWarnings(repo, config, state, ctx.packageVersion));
214
+ const missing = (config?.requiredEnv ?? []).filter((name) => !ctx.env[name]);
215
+ if (missing.length)
216
+ out.push(`missing environment variables: ${missing.join(", ")}`);
217
+ const notes = resolveNotes(repo, config?.notes)?.trim();
218
+ if (notes)
219
+ out.push(notes);
220
+ const dockerProblem = startDocker(ctx.docker ?? realDocker);
221
+ if (dockerProblem)
222
+ out.push(`could not start dockerd: ${dockerProblem}`);
223
+ const env = toolsEnv(dirs, ctx.env, ca);
224
+ await syncGit(repo, ctx.run, env);
225
+ out.push(...(await runSessionSteps(repo, config?.session ?? [], ctx.run, env)));
226
+ }
227
+ /**
228
+ * Runs the session phase for a repo.
229
+ *
230
+ * @param repo - Repo root.
231
+ * @param ctx - Dependencies (environment, tools dir, runner, package version, Docker).
232
+ * @returns The lines to print on stdout, each with the output prefix; never rejects.
233
+ * @example
234
+ * (await session("/home/user/app", { env: process.env, toolsDir, run: runProcess, packageVersion: "1.0.0" }))
235
+ * .forEach((line) => console.log(line));
236
+ */
237
+ export async function session(repo, ctx) {
238
+ const out = [];
239
+ try {
240
+ await sessionBody(repo, ctx, out);
241
+ }
242
+ catch (err) {
243
+ out.push(`session error: ${err instanceof Error ? err.message : String(err)}`);
244
+ }
245
+ return out.map((line) => OUTPUT_PREFIX + line);
246
+ }
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
+ }
package/dist/skill.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @file The `cloud-ready` skill that `init` copies into the repo, and the link Claude Code needs
3
+ * to discover it.
4
+ */
5
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ /** Repo-relative directory of the `cloud-ready` skill. */
8
+ export const SKILL_DIR = ".agents/skills/cloud-ready";
9
+ /** Directory of the skill inside this package. */
10
+ const PACKAGE_SKILL_DIR = "skills/cloud-ready";
11
+ /** Repo-relative folder of the skills that Claude Code discovers. */
12
+ const CLAUDE_SKILLS_DIR = ".claude/skills";
13
+ /** Repo-relative folder of the skills that skills-manager installs. */
14
+ const AGENTS_SKILLS_DIR = ".agents/skills";
15
+ /** Repo-relative path of the link to the skill that Claude Code discovers. */
16
+ const SKILL_LINK = `${CLAUDE_SKILLS_DIR}/cloud-ready`;
17
+ /** Target of `SKILL_LINK`, relative to the folder that holds it. */
18
+ const SKILL_LINK_TARGET = `../../${SKILL_DIR}`;
19
+ /** Matches the frontmatter at the start of a skill file; group 1 is its body. */
20
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
21
+ /** Matches the `metadata:` block of the frontmatter, up to the next top-level key or the end. */
22
+ const METADATA_BLOCK = /^metadata:[ \t]*\n((?:[ \t]+.*(?:\n|$))*)/m;
23
+ /** Matches an indented `version:` line inside the metadata block; the value may be quoted. */
24
+ const VERSION_LINE = /^\s+version:\s*"?([^"\n]+)"?$/m;
25
+ /**
26
+ * Reads `metadata.version` from the frontmatter of the installed skill.
27
+ *
28
+ * @param repo - Repo root.
29
+ * @returns The version, or undefined when the skill or its version is missing.
30
+ * @example
31
+ * skillVersion("/home/user/app"); // "1.0.0"
32
+ */
33
+ export function skillVersion(repo) {
34
+ const file = join(repo, SKILL_DIR, "SKILL.md");
35
+ if (!existsSync(file))
36
+ return undefined;
37
+ const frontmatter = FRONTMATTER.exec(readFileSync(file, "utf8"))?.[1] ?? "";
38
+ const block = METADATA_BLOCK.exec(frontmatter)?.[1] ?? "";
39
+ return VERSION_LINE.exec(block)?.[1]?.trim();
40
+ }
41
+ /**
42
+ * Sets `metadata.version` in the frontmatter of a skill file, adding the block when it is missing.
43
+ *
44
+ * @param text - Content of SKILL.md.
45
+ * @param version - Version to write.
46
+ * @returns The new content; unchanged when the file has no frontmatter.
47
+ * @example
48
+ * withVersion('---\nname: x\nmetadata:\n version: "0.0.0"\n---\n', "1.0.0"); // version "1.0.0"
49
+ */
50
+ function withVersion(text, version) {
51
+ const match = FRONTMATTER.exec(text);
52
+ if (!match)
53
+ return text;
54
+ const frontmatter = match[1] ?? "";
55
+ const line = ` version: "${version}"`;
56
+ const block = METADATA_BLOCK.exec(frontmatter);
57
+ let updated;
58
+ if (!block) {
59
+ updated = `${frontmatter}\nmetadata:\n${line}`;
60
+ }
61
+ else {
62
+ const body = block[1] ?? "";
63
+ const newBody = VERSION_LINE.test(body) ? body.replace(VERSION_LINE, line) : `${line}\n${body}`;
64
+ const head = block[0].slice(0, block[0].length - body.length);
65
+ updated = frontmatter.slice(0, block.index) + head + newBody + frontmatter.slice(block.index + block[0].length);
66
+ }
67
+ return `---\n${updated}\n---${text.slice(match[0].length)}`;
68
+ }
69
+ /**
70
+ * Copies the `cloud-ready` skill of this package into the repo, with `metadata.version` set to the
71
+ * package version. Leaves it untouched when it is already at that version; otherwise replaces the
72
+ * whole folder atomically (copy to a temp folder, then rename).
73
+ *
74
+ * @param repo - Repo root.
75
+ * @param packageRoot - Root of this package, which holds `skills/cloud-ready`.
76
+ * @param packageVersion - Version of this package.
77
+ * @returns A line describing the outcome.
78
+ * @throws When the package has no skill or the copy fails (the installed skill is kept).
79
+ * @example
80
+ * installSkill("/home/user/app", "/usr/lib/node_modules/agent-cloud-kit", "1.0.0");
81
+ * // "installed .agents/skills/cloud-ready 1.0.0"
82
+ */
83
+ export function installSkill(repo, packageRoot, packageVersion) {
84
+ const current = skillVersion(repo);
85
+ if (current === packageVersion)
86
+ return `${SKILL_DIR} already at ${packageVersion}`;
87
+ const source = join(packageRoot, PACKAGE_SKILL_DIR);
88
+ if (!existsSync(join(source, "SKILL.md")))
89
+ throw new Error(`${PACKAGE_SKILL_DIR}/SKILL.md not found in ${packageRoot}`);
90
+ const target = join(repo, SKILL_DIR);
91
+ const temp = `${target}.${process.pid}.tmp`;
92
+ const old = `${target}.${process.pid}.old`;
93
+ mkdirSync(dirname(target), { recursive: true });
94
+ try {
95
+ rmSync(temp, { recursive: true, force: true });
96
+ cpSync(source, temp, { recursive: true });
97
+ const skillFile = join(temp, "SKILL.md");
98
+ writeFileSync(skillFile, withVersion(readFileSync(skillFile, "utf8"), packageVersion));
99
+ if (existsSync(target))
100
+ renameSync(target, old);
101
+ renameSync(temp, target);
102
+ }
103
+ catch (err) {
104
+ rmSync(temp, { recursive: true, force: true });
105
+ if (existsSync(old) && !existsSync(target))
106
+ renameSync(old, target);
107
+ throw err;
108
+ }
109
+ rmSync(old, { recursive: true, force: true });
110
+ return current === undefined ? `installed ${SKILL_DIR} ${packageVersion}` : `updated ${SKILL_DIR} from ${current} to ${packageVersion}`;
111
+ }
112
+ /**
113
+ * Tells whether a path exists, counting a broken symlink as existing.
114
+ *
115
+ * @param path - Absolute path.
116
+ * @returns True when something is there.
117
+ */
118
+ function present(path) {
119
+ try {
120
+ lstatSync(path);
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ /**
128
+ * Links the skill where Claude Code discovers it: Claude Code does not read `.agents/skills/`, so
129
+ * this creates the relative symlink `.claude/skills/cloud-ready -> ../../.agents/skills/cloud-ready`.
130
+ * Does nothing when `.claude/skills` already resolves to `.agents/skills` or the entry exists.
131
+ *
132
+ * @param repo - Repo root.
133
+ * @returns A line describing what was done, or undefined when nothing was needed.
134
+ * @example
135
+ * linkSkill("/home/user/app"); // "linked .claude/skills/cloud-ready to .agents/skills/cloud-ready"
136
+ */
137
+ export function linkSkill(repo) {
138
+ const claudeSkills = join(repo, CLAUDE_SKILLS_DIR);
139
+ const agentsSkills = join(repo, AGENTS_SKILLS_DIR);
140
+ if (existsSync(claudeSkills) && existsSync(agentsSkills) && realpathSync(claudeSkills) === realpathSync(agentsSkills))
141
+ return undefined;
142
+ const link = join(repo, SKILL_LINK);
143
+ if (present(link))
144
+ return undefined;
145
+ mkdirSync(claudeSkills, { recursive: true });
146
+ symlinkSync(SKILL_LINK_TARGET, link);
147
+ return `linked ${SKILL_LINK} to ${SKILL_DIR}`;
148
+ }