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