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.
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/dist/ca.js +30 -0
- package/dist/cli.js +418 -0
- package/dist/config.js +197 -0
- package/dist/constants.js +77 -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 +223 -0
- package/dist/prepare/detect.js +29 -0
- package/dist/prepare/index.js +489 -0
- package/dist/prepare/prompts.js +28 -0
- package/dist/prepare/questions.js +5 -0
- package/dist/run.js +113 -0
- package/dist/session.js +246 -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 +202 -0
- package/skills/cloud-ready/reference/compat.md +110 -0
- package/skills/cloud-ready/reference/knowledge.md +56 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `prepare` command: walks the user, in a local terminal, from an unprepared repo to a
|
|
3
|
+
* pushed branch with the cloud setup and a cloud session that verifies it. It never deletes the
|
|
4
|
+
* worktree or the branch it creates.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, lstatSync, statSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
8
|
+
import { readConfig } from "../config.js";
|
|
9
|
+
import { CONFIG_FILE, OUTPUT_PREFIX, PACKAGE } from "../constants.js";
|
|
10
|
+
import { defaultBranch } from "../detect.js";
|
|
11
|
+
import { init } from "../init.js";
|
|
12
|
+
import { renderEnvironmentSection, writeManual } from "../manual.js";
|
|
13
|
+
import { preflight } from "./detect.js";
|
|
14
|
+
import { configurePrompt, verifyPrompt } from "./prompts.js";
|
|
15
|
+
/** Prefix of the branch `prepare` creates; the day follows it. */
|
|
16
|
+
const BRANCH_PREFIX = "chore/cloud-ready-";
|
|
17
|
+
/** Suffix added to the repo folder name to name the worktree folder, next to the repo. */
|
|
18
|
+
const WORKTREE_SUFFIX = "-cloud-ready";
|
|
19
|
+
/** Notes file the skill writes when `notes` is not set in the config. */
|
|
20
|
+
const DEFAULT_NOTES_FILE = ".claude/cloud-notes.md";
|
|
21
|
+
/** Files `prepare` always commits when they exist, relative to the worktree. */
|
|
22
|
+
const KNOWN_PATHS = [
|
|
23
|
+
CONFIG_FILE,
|
|
24
|
+
".claude/settings.json",
|
|
25
|
+
".agents/skills/cloud-ready",
|
|
26
|
+
".claude/skills/cloud-ready",
|
|
27
|
+
"docs/agent-cloud.md",
|
|
28
|
+
];
|
|
29
|
+
/** Project file of the test-audit skill, committed too when it exists. */
|
|
30
|
+
const TEST_AUDIT_PROJECT_FILE = ".agents/skills/test-audit/project.md";
|
|
31
|
+
/** Message of the commit `prepare` makes. */
|
|
32
|
+
const COMMIT_MESSAGE = "chore: prepare the repo for the Claude Code cloud";
|
|
33
|
+
/** Question asked before committing changes outside the known files. */
|
|
34
|
+
const INCLUDE_QUESTION = "Include these changes in the commit? [y/N]";
|
|
35
|
+
/** Answers to `INCLUDE_QUESTION` that mean yes. */
|
|
36
|
+
const YES_ANSWER = /^y(es)?$/i;
|
|
37
|
+
/** Text shown while the user configures the environment in claude.ai. */
|
|
38
|
+
const WAIT_TEXT = "Configure the environment in claude.ai, then press Enter";
|
|
39
|
+
/** Matches a GitHub remote URL (https or ssh) and captures `owner/repo`. */
|
|
40
|
+
const GITHUB_REMOTE = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/;
|
|
41
|
+
/** Matches a pull request URL and captures its number. */
|
|
42
|
+
const PR_URL = /\/pull\/(\d+)/;
|
|
43
|
+
/** Command that continues a prepare run; `npx` because a first-time user does not have the binary on PATH. */
|
|
44
|
+
const RESUME_COMMAND = `npx ${PACKAGE} prepare --resume`;
|
|
45
|
+
/** Printed just before the local agent starts: `prepare` continues only when `claude` exits. */
|
|
46
|
+
const EXIT_CLAUDE_HINT = `${OUTPUT_PREFIX}when the skill says configure is done, exit claude (/exit) and prepare will commit, push and open the PR`;
|
|
47
|
+
/** Exit code for success. */
|
|
48
|
+
const EXIT_OK = 0;
|
|
49
|
+
/** Exit code for failure. */
|
|
50
|
+
const EXIT_FAIL = 1;
|
|
51
|
+
/** Exit code after Ctrl+C (128 + SIGINT). */
|
|
52
|
+
export const EXIT_INTERRUPTED = 130;
|
|
53
|
+
/** Matches a PR number printed by `gh pr view --jq .number`. */
|
|
54
|
+
const PR_NUMBER = /^\d+$/;
|
|
55
|
+
/** Thrown by `ask` or `waitEnter` when the user presses Ctrl+C. */
|
|
56
|
+
export class InterruptedError extends Error {
|
|
57
|
+
/** Builds the error. */
|
|
58
|
+
constructor() {
|
|
59
|
+
super("interrupted");
|
|
60
|
+
this.name = "InterruptedError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Formats a local date as the day of the branch name.
|
|
65
|
+
*
|
|
66
|
+
* @param date - Date, read in the local time zone.
|
|
67
|
+
* @returns `YYYYMMDD`.
|
|
68
|
+
* @example
|
|
69
|
+
* branchDay(new Date(2026, 10, 2)); // "20261102"
|
|
70
|
+
*/
|
|
71
|
+
export function branchDay(date) {
|
|
72
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
73
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Says where the worktree is and how to continue there.
|
|
77
|
+
*
|
|
78
|
+
* @param reason - What happened, e.g. "the local agent stopped".
|
|
79
|
+
* @param worktree - Worktree folder.
|
|
80
|
+
* @returns The line to print.
|
|
81
|
+
* @example
|
|
82
|
+
* resumeLine("interrupted", "/home/u/app-cloud-ready"); // "interrupted; the worktree is at ...; run ... --resume there"
|
|
83
|
+
*/
|
|
84
|
+
function resumeLine(reason, worktree) {
|
|
85
|
+
return `${reason}; the worktree is at ${worktree}; run ${RESUME_COMMAND} there`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Runs a capture and trims its output; never throws.
|
|
89
|
+
*
|
|
90
|
+
* @param deps - Dependencies.
|
|
91
|
+
* @param cmd - Executable.
|
|
92
|
+
* @param args - Arguments.
|
|
93
|
+
* @param cwd - Working directory.
|
|
94
|
+
* @returns Trimmed stdout, or undefined when the command failed.
|
|
95
|
+
* @example
|
|
96
|
+
* await tryCapture(deps, "git", ["rev-parse", "--show-toplevel"], "/repo"); // "/repo"
|
|
97
|
+
*/
|
|
98
|
+
async function tryCapture(deps, cmd, args, cwd) {
|
|
99
|
+
try {
|
|
100
|
+
return (await deps.capture(cmd, args, { cwd })).trim();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Turns an unknown error into its message.
|
|
108
|
+
*
|
|
109
|
+
* @param err - Anything thrown.
|
|
110
|
+
* @returns The message.
|
|
111
|
+
* @example
|
|
112
|
+
* message(new Error("x")); // "x"
|
|
113
|
+
*/
|
|
114
|
+
function message(err) {
|
|
115
|
+
return err instanceof Error ? err.message : String(err);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Tells whether a path exists, without following a symlink (so a link counts even if broken).
|
|
119
|
+
*
|
|
120
|
+
* @param path - Absolute path.
|
|
121
|
+
* @returns True when something is there.
|
|
122
|
+
* @example
|
|
123
|
+
* entryExists("/repo/.claude/skills/cloud-ready"); // true
|
|
124
|
+
*/
|
|
125
|
+
function entryExists(path) {
|
|
126
|
+
try {
|
|
127
|
+
lstatSync(path);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Names the worktree folder: next to the repo, with `WORKTREE_SUFFIX`.
|
|
136
|
+
*
|
|
137
|
+
* @param repo - Repo root.
|
|
138
|
+
* @returns Absolute path of the worktree.
|
|
139
|
+
* @example
|
|
140
|
+
* worktreePath("/home/u/app"); // "/home/u/app-cloud-ready"
|
|
141
|
+
*/
|
|
142
|
+
function worktreePath(repo) {
|
|
143
|
+
return join(dirname(repo), `${basename(repo)}${WORKTREE_SUFFIX}`);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Finds the notes file the config names, when `configure` finished.
|
|
147
|
+
*
|
|
148
|
+
* @param worktree - Worktree root.
|
|
149
|
+
* @returns Undefined when the config reads without errors and its notes file exists; else why not.
|
|
150
|
+
* @example
|
|
151
|
+
* configureProblem("/home/u/app-cloud-ready"); // undefined
|
|
152
|
+
*/
|
|
153
|
+
function configureProblem(worktree) {
|
|
154
|
+
const { config, errors } = readConfig(worktree);
|
|
155
|
+
if (!config || errors.length)
|
|
156
|
+
return errors[0] ?? `${CONFIG_FILE} not found`;
|
|
157
|
+
const notes = config.notes ?? DEFAULT_NOTES_FILE;
|
|
158
|
+
const rel = relative(worktree, join(worktree, notes));
|
|
159
|
+
const inside = !isAbsolute(notes) && rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
160
|
+
try {
|
|
161
|
+
if (inside && statSync(join(worktree, notes)).isFile())
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// NOTE: a missing file is reported below.
|
|
166
|
+
}
|
|
167
|
+
return `the notes file ${notes} does not exist`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Lists the paths of `git status --porcelain=v1 -z` output.
|
|
171
|
+
*
|
|
172
|
+
* @param output - Raw output (NUL-separated entries).
|
|
173
|
+
* @returns Paths, renames giving both the new and the old path.
|
|
174
|
+
* @example
|
|
175
|
+
* statusPaths("?? a.txt\0"); // ["a.txt"]
|
|
176
|
+
*/
|
|
177
|
+
function statusPaths(output) {
|
|
178
|
+
const tokens = output.split("\0").filter((t) => t !== "");
|
|
179
|
+
const paths = [];
|
|
180
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
181
|
+
const token = tokens[i] ?? "";
|
|
182
|
+
const code = token.slice(0, 2);
|
|
183
|
+
paths.push(token.slice(3));
|
|
184
|
+
if (code.includes("R") || code.includes("C")) {
|
|
185
|
+
const old = tokens[++i];
|
|
186
|
+
if (old !== undefined)
|
|
187
|
+
paths.push(old);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return paths;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Tells whether a path is one of the given paths or inside one of them.
|
|
194
|
+
*
|
|
195
|
+
* @param path - Repo-relative path.
|
|
196
|
+
* @param roots - Repo-relative paths.
|
|
197
|
+
* @returns True when covered.
|
|
198
|
+
* @example
|
|
199
|
+
* covered(".agents/skills/cloud-ready/SKILL.md", [".agents/skills/cloud-ready"]); // true
|
|
200
|
+
*/
|
|
201
|
+
function covered(path, roots) {
|
|
202
|
+
return roots.some((root) => path === root || path.startsWith(`${root}/`));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Builds the GitHub compare link for a branch from the origin URL.
|
|
206
|
+
*
|
|
207
|
+
* @param originUrl - URL of `origin`, if known.
|
|
208
|
+
* @param branch - Branch to compare.
|
|
209
|
+
* @returns The link, or undefined when origin is not on GitHub.
|
|
210
|
+
* @example
|
|
211
|
+
* compareLink("git@github.com:acme/app.git", "b"); // "https://github.com/acme/app/compare/b?expand=1"
|
|
212
|
+
*/
|
|
213
|
+
function compareLink(originUrl, branch) {
|
|
214
|
+
const slug = GITHUB_REMOTE.exec(originUrl ?? "")?.[1];
|
|
215
|
+
return slug ? `https://github.com/${slug}/compare/${branch}?expand=1` : undefined;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Finds the worktree and branch to resume.
|
|
219
|
+
*
|
|
220
|
+
* @param repo - Git root where `prepare --resume` runs: the worktree, or the repo next to it.
|
|
221
|
+
* @param deps - Dependencies.
|
|
222
|
+
* @returns The worktree and its branch, or undefined when there is none.
|
|
223
|
+
* @example
|
|
224
|
+
* await findResume("/home/u/app-cloud-ready", deps); // { worktree: "...", branch: "chore/cloud-ready-20261102" }
|
|
225
|
+
*/
|
|
226
|
+
async function findResume(repo, deps) {
|
|
227
|
+
for (const worktree of [repo, worktreePath(repo)]) {
|
|
228
|
+
if (!existsSync(worktree))
|
|
229
|
+
continue;
|
|
230
|
+
const branch = await tryCapture(deps, "git", ["rev-parse", "--abbrev-ref", "HEAD"], worktree);
|
|
231
|
+
if (branch?.startsWith(BRANCH_PREFIX))
|
|
232
|
+
return { worktree, branch };
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Steps 2–5: creates the worktree, installs the kit and runs the local agent.
|
|
238
|
+
*
|
|
239
|
+
* @param repo - Git root of the repo.
|
|
240
|
+
* @param branch - Branch to create.
|
|
241
|
+
* @param worktree - Worktree folder to create.
|
|
242
|
+
* @param deps - Dependencies.
|
|
243
|
+
* @param progress - Gets the worktree once it exists.
|
|
244
|
+
* @returns True when the worktree is ready to commit.
|
|
245
|
+
* @example
|
|
246
|
+
* await createAndConfigure("/home/u/app", "chore/cloud-ready-20261102", "/home/u/app-cloud-ready", deps, {});
|
|
247
|
+
*/
|
|
248
|
+
async function createAndConfigure(repo, branch, worktree, deps, progress) {
|
|
249
|
+
if (existsSync(worktree)) {
|
|
250
|
+
deps.print(`${OUTPUT_PREFIX}${worktree} already exists; remove it (git worktree remove ${worktree}) or continue there with ${RESUME_COMMAND}`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if ((await tryCapture(deps, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], repo)) !== undefined) {
|
|
254
|
+
deps.print(`${OUTPUT_PREFIX}branch ${branch} already exists; delete it (git branch -D ${branch}) or run prepare another day`);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const base = `origin/${defaultBranch(repo)}`;
|
|
258
|
+
try {
|
|
259
|
+
await deps.run("git", ["fetch", "-q", "origin"], { cwd: repo });
|
|
260
|
+
await deps.run("git", ["worktree", "add", "-b", branch, worktree, base], { cwd: repo });
|
|
261
|
+
progress.worktree = worktree;
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
deps.print(`${OUTPUT_PREFIX}could not create the worktree: ${message(err)}`);
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
const stopped = resumeLine("the local agent stopped", worktree);
|
|
268
|
+
try {
|
|
269
|
+
const result = init(worktree, deps.packageVersion, deps.packageRoot);
|
|
270
|
+
for (const line of result.lines)
|
|
271
|
+
deps.print(line);
|
|
272
|
+
if (!result.ok) {
|
|
273
|
+
deps.print(stopped);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
deps.print(`${OUTPUT_PREFIX}${message(err)}`);
|
|
279
|
+
deps.print(stopped);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
deps.print(EXIT_CLAUDE_HINT);
|
|
283
|
+
try {
|
|
284
|
+
await runInteractive(deps, progress, [configurePrompt()], worktree);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
deps.print(stopped);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const problem = configureProblem(worktree);
|
|
291
|
+
if (problem !== undefined) {
|
|
292
|
+
deps.print(`${OUTPUT_PREFIX}configure did not finish: ${problem}`);
|
|
293
|
+
deps.print(resumeLine("prepare stopped", worktree));
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Step 6: writes the manual, commits the setup (and, if the user says so, the other changes),
|
|
300
|
+
* pushes the branch and opens a draft PR.
|
|
301
|
+
*
|
|
302
|
+
* @param worktree - Worktree root.
|
|
303
|
+
* @param branch - Its branch.
|
|
304
|
+
* @param deps - Dependencies.
|
|
305
|
+
* @returns The PR number when known (an open PR for the branch is reused), or false when the
|
|
306
|
+
* branch could not be committed or pushed.
|
|
307
|
+
* @throws InterruptedError when the user presses Ctrl+C at the question.
|
|
308
|
+
* @example
|
|
309
|
+
* await commitAndPush("/home/u/app-cloud-ready", "chore/cloud-ready-20261102", deps); // { pr: "7" }
|
|
310
|
+
*/
|
|
311
|
+
async function commitAndPush(worktree, branch, deps) {
|
|
312
|
+
try {
|
|
313
|
+
writeManual(worktree, deps.packageVersion);
|
|
314
|
+
const notes = readConfig(worktree).config?.notes ?? DEFAULT_NOTES_FILE;
|
|
315
|
+
const known = [...KNOWN_PATHS, notes, TEST_AUDIT_PROJECT_FILE].filter((path) => entryExists(join(worktree, path)));
|
|
316
|
+
await deps.run("git", ["add", "--", ...known], { cwd: worktree });
|
|
317
|
+
const status = await deps.capture("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: worktree });
|
|
318
|
+
const others = statusPaths(status).filter((path) => !covered(path, known));
|
|
319
|
+
if (others.length) {
|
|
320
|
+
deps.print(`${OUTPUT_PREFIX}the local agent also changed:`);
|
|
321
|
+
for (const path of others)
|
|
322
|
+
deps.print(` ${path}`);
|
|
323
|
+
if (YES_ANSWER.test((await deps.ask(INCLUDE_QUESTION, "")).trim())) {
|
|
324
|
+
await deps.run("git", ["add", "--", ...others], { cwd: worktree });
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
await deps.run("git", ["reset", "-q", "--", ...others], { cwd: worktree });
|
|
328
|
+
deps.print(`${OUTPUT_PREFIX}left uncommitted in ${worktree}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const staged = await deps.capture("git", ["diff", "--cached", "--name-only"], { cwd: worktree });
|
|
332
|
+
if (staged.trim())
|
|
333
|
+
await deps.run("git", ["commit", "-m", COMMIT_MESSAGE], { cwd: worktree });
|
|
334
|
+
else
|
|
335
|
+
deps.print(`${OUTPUT_PREFIX}nothing new to commit`);
|
|
336
|
+
await deps.run("git", ["push", "-u", "origin", branch], { cwd: worktree });
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (err instanceof InterruptedError)
|
|
340
|
+
throw err;
|
|
341
|
+
deps.print(`${OUTPUT_PREFIX}could not commit and push: ${message(err)}; fix it in ${worktree} and run ${RESUME_COMMAND} there`);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
const existing = await tryCapture(deps, "gh", ["pr", "view", branch, "--json", "number", "--jq", ".number"], worktree);
|
|
345
|
+
if (existing !== undefined && PR_NUMBER.test(existing)) {
|
|
346
|
+
deps.print(`${OUTPUT_PREFIX}reusing the open PR #${existing}`);
|
|
347
|
+
return { pr: existing };
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const url = await deps.capture("gh", ["pr", "create", "--draft", "--fill"], { cwd: worktree });
|
|
351
|
+
deps.print(`${OUTPUT_PREFIX}draft PR: ${url.trim()}`);
|
|
352
|
+
return { pr: PR_URL.exec(url)?.[1] };
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
const origin = await tryCapture(deps, "git", ["remote", "get-url", "origin"], worktree);
|
|
356
|
+
const link = compareLink(origin, branch);
|
|
357
|
+
deps.print(`${OUTPUT_PREFIX}could not open the PR with gh; open it by hand${link ? `: ${link}` : ` from branch ${branch}`}`);
|
|
358
|
+
return {};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Prepares a repo for Claude Code cloud sessions: checks, asks, creates a worktree on a new
|
|
363
|
+
* branch, runs the local agent with the `cloud-ready` skill, commits, pushes, opens a draft PR,
|
|
364
|
+
* waits while the user configures the environment and starts a cloud session that verifies it.
|
|
365
|
+
*
|
|
366
|
+
* @param repo - Folder where the user runs `prepare` (inside the repo, or the worktree with resume).
|
|
367
|
+
* @param deps - Dependencies.
|
|
368
|
+
* @param options - `resume` continues in an existing prepare worktree.
|
|
369
|
+
* @returns 0 when the cloud session was started, 130 after Ctrl+C at a question, 1 otherwise.
|
|
370
|
+
* @example
|
|
371
|
+
* process.exitCode = await prepare(process.cwd(), deps);
|
|
372
|
+
*/
|
|
373
|
+
export async function prepare(repo, deps, options = {}) {
|
|
374
|
+
const gitRoot = await tryCapture(deps, "git", ["rev-parse", "--show-toplevel"], repo);
|
|
375
|
+
const cwd = gitRoot ?? repo;
|
|
376
|
+
const hasOrigin = (await tryCapture(deps, "git", ["remote", "get-url", "origin"], cwd)) !== undefined;
|
|
377
|
+
const clean = options.resume ? true : (await tryCapture(deps, "git", ["status", "--porcelain"], cwd)) === "";
|
|
378
|
+
const claudeHelp = await tryCapture(deps, "claude", ["--help"], cwd);
|
|
379
|
+
const problems = preflight({ isTty: deps.isTty, gitRoot, hasOrigin, clean, claudeHelp });
|
|
380
|
+
if (problems.length || gitRoot === undefined) {
|
|
381
|
+
for (const problem of problems)
|
|
382
|
+
deps.print(`${OUTPUT_PREFIX}${problem}`);
|
|
383
|
+
return EXIT_FAIL;
|
|
384
|
+
}
|
|
385
|
+
const progress = {};
|
|
386
|
+
const stop = deps.onInterrupt?.(() => {
|
|
387
|
+
if (progress.childOwnsTerminal)
|
|
388
|
+
return false;
|
|
389
|
+
deps.print(interruptedLine(progress));
|
|
390
|
+
return true;
|
|
391
|
+
});
|
|
392
|
+
try {
|
|
393
|
+
return await prepareSteps(gitRoot, deps, options, progress);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
if (!(err instanceof InterruptedError))
|
|
397
|
+
throw err;
|
|
398
|
+
deps.print(interruptedLine(progress));
|
|
399
|
+
return EXIT_INTERRUPTED;
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
stop?.();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Runs an interactive `claude` that owns the terminal. Ctrl+C meanwhile belongs to Claude (it
|
|
407
|
+
* interrupts the agent), so the interrupt handler ignores it until the child returns.
|
|
408
|
+
*
|
|
409
|
+
* @param deps - Dependencies.
|
|
410
|
+
* @param progress - Marks the child as owning the terminal.
|
|
411
|
+
* @param args - Arguments of `claude`.
|
|
412
|
+
* @param cwd - Worktree.
|
|
413
|
+
* @returns Resolves when `claude` exits 0.
|
|
414
|
+
* @throws When `claude` exits non-zero.
|
|
415
|
+
* @example
|
|
416
|
+
* await runInteractive(deps, progress, ["--cloud", prompt], "/home/u/app-cloud-ready");
|
|
417
|
+
*/
|
|
418
|
+
async function runInteractive(deps, progress, args, cwd) {
|
|
419
|
+
progress.childOwnsTerminal = true;
|
|
420
|
+
try {
|
|
421
|
+
await deps.run("claude", args, { cwd });
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
progress.childOwnsTerminal = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The line printed on Ctrl+C: where the worktree is, when it exists.
|
|
429
|
+
*
|
|
430
|
+
* @param progress - What exists so far.
|
|
431
|
+
* @returns The line.
|
|
432
|
+
* @example
|
|
433
|
+
* interruptedLine({}); // "agent-cloud-kit: interrupted; nothing was created"
|
|
434
|
+
*/
|
|
435
|
+
function interruptedLine(progress) {
|
|
436
|
+
return progress.worktree === undefined ? `${OUTPUT_PREFIX}interrupted; nothing was created` : resumeLine("interrupted", progress.worktree);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Everything after the preflight: resume or create the worktree, commit, wait, launch.
|
|
440
|
+
*
|
|
441
|
+
* @param gitRoot - Git root where `prepare` runs.
|
|
442
|
+
* @param deps - Dependencies.
|
|
443
|
+
* @param options - Options of `prepare`.
|
|
444
|
+
* @param progress - Gets the worktree once it exists.
|
|
445
|
+
* @returns The exit code.
|
|
446
|
+
* @throws InterruptedError when the user presses Ctrl+C at a question.
|
|
447
|
+
* @example
|
|
448
|
+
* await prepareSteps("/home/u/app", deps, {}, {}); // 0
|
|
449
|
+
*/
|
|
450
|
+
async function prepareSteps(gitRoot, deps, options, progress) {
|
|
451
|
+
let worktree;
|
|
452
|
+
let branch;
|
|
453
|
+
if (options.resume) {
|
|
454
|
+
const found = await findResume(gitRoot, deps);
|
|
455
|
+
if (!found) {
|
|
456
|
+
deps.print(`${OUTPUT_PREFIX}nothing to resume: run it in the ${WORKTREE_SUFFIX} worktree on a ${BRANCH_PREFIX}* branch`);
|
|
457
|
+
return EXIT_FAIL;
|
|
458
|
+
}
|
|
459
|
+
({ worktree, branch } = found);
|
|
460
|
+
progress.worktree = worktree;
|
|
461
|
+
const problem = configureProblem(worktree);
|
|
462
|
+
if (problem) {
|
|
463
|
+
deps.print(`${OUTPUT_PREFIX}configure is not finished in ${worktree}: ${problem}; run claude "/cloud-ready configure" there first, then ${RESUME_COMMAND}`);
|
|
464
|
+
return EXIT_FAIL;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
branch = `${BRANCH_PREFIX}${deps.today}`;
|
|
469
|
+
worktree = worktreePath(gitRoot);
|
|
470
|
+
if (!(await createAndConfigure(gitRoot, branch, worktree, deps, progress)))
|
|
471
|
+
return EXIT_FAIL;
|
|
472
|
+
}
|
|
473
|
+
const pushed = await commitAndPush(worktree, branch, deps);
|
|
474
|
+
if (!pushed)
|
|
475
|
+
return EXIT_FAIL;
|
|
476
|
+
const config = readConfig(worktree).config;
|
|
477
|
+
if (config)
|
|
478
|
+
deps.print(renderEnvironmentSection(config, deps.packageVersion));
|
|
479
|
+
await deps.waitEnter(WAIT_TEXT);
|
|
480
|
+
try {
|
|
481
|
+
await runInteractive(deps, progress, ["--cloud", verifyPrompt({ branch, pr: pushed.pr })], worktree);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
deps.print(`${OUTPUT_PREFIX}claude --cloud failed: ${message(err)}; run ${RESUME_COMMAND} in ${worktree} to try again`);
|
|
485
|
+
return EXIT_FAIL;
|
|
486
|
+
}
|
|
487
|
+
deps.print(`${OUTPUT_PREFIX}the cloud session will report back as a push to ${branch} and a comment on the PR`);
|
|
488
|
+
return EXIT_OK;
|
|
489
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Prompts `prepare` hands to Claude, in Spanish: they invoke the `cloud-ready` skill.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Prompt for the local session: `configure` mode. `prepare` asks nothing first; the skill detects
|
|
6
|
+
* secrets and domains itself.
|
|
7
|
+
*
|
|
8
|
+
* @returns The prompt.
|
|
9
|
+
* @example
|
|
10
|
+
* configurePrompt(); // "/cloud-ready configure"
|
|
11
|
+
*/
|
|
12
|
+
export function configurePrompt() {
|
|
13
|
+
return "/cloud-ready configure";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Prompt for the cloud session: `verify` mode with the prepare branch and, when known, the PR.
|
|
17
|
+
*
|
|
18
|
+
* @param input Branch of `prepare` and optional PR number.
|
|
19
|
+
* @returns The prompt.
|
|
20
|
+
* @example
|
|
21
|
+
* verifyPrompt({ branch: "chore/cloud-ready-1", pr: "42" }); // "/cloud-ready verify\n\n- Rama de prepare: ..."
|
|
22
|
+
*/
|
|
23
|
+
export function verifyPrompt(input) {
|
|
24
|
+
const lines = ["/cloud-ready verify", "", `- Rama de prepare: ${input.branch}`];
|
|
25
|
+
if (input.pr !== undefined)
|
|
26
|
+
lines.push(`- PR: #${input.pr}`);
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
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
|
+
};
|