syndai 0.1.1 → 0.4.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/CHANGELOG.md +55 -0
- package/README.md +97 -52
- package/bin/syndai.js +13 -29
- package/package.json +6 -11
- package/src/api.js +188 -56
- package/src/auth.js +245 -0
- package/src/changes.js +350 -0
- package/src/errors.js +148 -0
- package/src/http.js +166 -0
- package/src/main.js +249 -0
- package/src/mcp.js +93 -0
- package/src/openapi.js +145 -0
- package/src/projects.js +168 -0
- package/src/routes.js +65 -0
- package/src/schedules.js +377 -0
- package/src/settings.js +130 -0
- package/src/store.js +137 -0
- package/src/ui.js +156 -0
- package/src/watch.js +299 -0
- package/LICENSE +0 -9
- package/src/claude.js +0 -145
- package/src/codex.js +0 -71
- package/src/connect.js +0 -122
- package/src/proc.js +0 -36
- package/src/prompt.js +0 -43
- package/src/tokens.js +0 -45
package/src/auth.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Credentials and the browser-approved login (RFC 8628 device flow, like `gh auth login`).
|
|
2
|
+
//
|
|
3
|
+
// Every door uses one credential type, an `sk_` key. `syndai login` mints one only after an
|
|
4
|
+
// owner or admin approves the request in a browser session at app.syndai.ai/cli. The key is
|
|
5
|
+
// stored per workspace in credentials.json (0600). `SYNDAI_API_KEY` wins over it (CI).
|
|
6
|
+
// There is no --api-key flag, so a key never lands in shell history.
|
|
7
|
+
|
|
8
|
+
import { hostname } from "node:os";
|
|
9
|
+
import { ApiError, CliError, EXIT, explain, PORTAL_URL, steppable } from "./errors.js";
|
|
10
|
+
import { createClient, DEFAULT_API_URL, interrupted } from "./http.js";
|
|
11
|
+
import { findLink, loadCredentials, removeCredential, saveCredential, storePaths } from "./store.js";
|
|
12
|
+
import { ask, createUi, readAll } from "./ui.js";
|
|
13
|
+
|
|
14
|
+
/** The key this invocation uses: env first, then the cwd link's workspace, then the default. */
|
|
15
|
+
export function resolveCredential(io, paths) {
|
|
16
|
+
const envKey = io.env.SYNDAI_API_KEY?.trim();
|
|
17
|
+
if (envKey) return { apiKey: envKey, customerId: null, source: "SYNDAI_API_KEY" };
|
|
18
|
+
return storedCredential(io, paths);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function storedCredential(io, paths) {
|
|
22
|
+
const creds = loadCredentials(paths);
|
|
23
|
+
const link = findLink(paths, io.cwd);
|
|
24
|
+
const customerId = link && creds.customers[link.customer_id] ? link.customer_id : creds.default;
|
|
25
|
+
const entry = customerId ? creds.customers[customerId] : null;
|
|
26
|
+
if (!entry) return null;
|
|
27
|
+
return { apiKey: entry.api_key, customerId, source: paths.credentials };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-invocation state shared by every command. `stepUp: false` (the commands that follow a
|
|
32
|
+
* change live) never asks for a missing scope mid-stream: they print it and exit 5.
|
|
33
|
+
*/
|
|
34
|
+
export function createContext(io, flags, { stepUp = true } = {}) {
|
|
35
|
+
const paths = storePaths(io.env);
|
|
36
|
+
const ui = createUi(io, { json: flags.json === true, noInput: flags["no-input"] === true });
|
|
37
|
+
const baseUrl = (io.env.SYNDAI_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
38
|
+
let cached;
|
|
39
|
+
let me;
|
|
40
|
+
const credential = () => {
|
|
41
|
+
cached ??= resolveCredential(io, paths);
|
|
42
|
+
if (!cached) throw new CliError(EXIT.AUTH, "You are not logged in.", { next: "syndai login" });
|
|
43
|
+
return cached;
|
|
44
|
+
};
|
|
45
|
+
const clientFor = (getKey) => createClient({ baseUrl, getKey, io });
|
|
46
|
+
const ctx = {
|
|
47
|
+
io,
|
|
48
|
+
ui,
|
|
49
|
+
paths,
|
|
50
|
+
flags,
|
|
51
|
+
baseUrl,
|
|
52
|
+
clientFor,
|
|
53
|
+
credential,
|
|
54
|
+
/** Forget the memoised key and `/me` (after a new key is stored). */
|
|
55
|
+
reset() {
|
|
56
|
+
cached = undefined;
|
|
57
|
+
me = undefined;
|
|
58
|
+
},
|
|
59
|
+
async me() {
|
|
60
|
+
me ??= await ctx.client.call("me");
|
|
61
|
+
return me;
|
|
62
|
+
},
|
|
63
|
+
async customerId() {
|
|
64
|
+
return credential().customerId ?? (await ctx.me()).customer_id;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
ctx.client = createClient({
|
|
68
|
+
baseUrl,
|
|
69
|
+
getKey: async () => credential().apiKey,
|
|
70
|
+
io,
|
|
71
|
+
onMissingScope: stepUp ? (scope) => stepUpScope(ctx, scope) : undefined,
|
|
72
|
+
});
|
|
73
|
+
return ctx;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A 403 named the scope it needs. On a terminal, with the stored login (never
|
|
78
|
+
* SYNDAI_API_KEY, which belongs to CI), ask once, approve a key with the current scopes plus
|
|
79
|
+
* that one in the browser, store it, and let the client retry. Declining mints nothing.
|
|
80
|
+
*/
|
|
81
|
+
async function stepUpScope(ctx, scope) {
|
|
82
|
+
const { io, ui } = ctx;
|
|
83
|
+
if (!ui.interactive || !steppable(scope) || ctx.credential().source === "SYNDAI_API_KEY") return false;
|
|
84
|
+
const answer = await ask(io, `This needs the ${scope} scope. Approve it in your browser? [Y/n] `);
|
|
85
|
+
if (/^n/i.test(answer)) return false;
|
|
86
|
+
const token = await deviceApproval(ctx, "CLI", await scopesWith(ctx, [scope]));
|
|
87
|
+
await storeKey(ctx, token);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The stored key's scopes plus `extra` (an unknown or dead stored key adds nothing). */
|
|
92
|
+
async function scopesWith(ctx, extra) {
|
|
93
|
+
const stored = storedCredential(ctx.io, ctx.paths);
|
|
94
|
+
let current = [];
|
|
95
|
+
if (stored) {
|
|
96
|
+
try {
|
|
97
|
+
current = (await ctx.clientFor(async () => stored.apiKey).call("me")).scopes ?? [];
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (!(err instanceof ApiError && err.status === 401)) throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...new Set([...current, ...extra])];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Save a new key, THEN revoke the key it replaces (best-effort: a failed revoke only warns,
|
|
107
|
+
* so the new key is never lost). The same key id again is not revoked.
|
|
108
|
+
*/
|
|
109
|
+
async function storeKey(ctx, token) {
|
|
110
|
+
const { paths, ui } = ctx;
|
|
111
|
+
const old = loadCredentials(paths).customers[token.customer_id];
|
|
112
|
+
saveCredential(paths, token);
|
|
113
|
+
ctx.reset();
|
|
114
|
+
if (!old || old.api_key === token.api_key || (old.key_id && old.key_id === token.key_id)) return;
|
|
115
|
+
try {
|
|
116
|
+
await ctx.clientFor(async () => old.api_key).call("revokeMe");
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof ApiError && err.status === 401) return; // already revoked or expired
|
|
119
|
+
if (err instanceof CliError && err.exitCode === EXIT.INTERRUPTED) throw err;
|
|
120
|
+
ui.progress(
|
|
121
|
+
ui.yellow(`Warning: the previous key was not revoked (${explain(err).message}). Revoke it at ${PORTAL_URL}.`),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ask for a key and wait until an owner approves it in the browser. Returns the token
|
|
128
|
+
* response `{api_key, customer_id, key_id, expires_at}`.
|
|
129
|
+
*/
|
|
130
|
+
export async function deviceApproval(ctx, clientName, scopes) {
|
|
131
|
+
const { client, ui, io } = ctx;
|
|
132
|
+
const start = await client.call("deviceStart", {
|
|
133
|
+
// No `scopes` means the owner preset (owner_door); a list asks for those on top of it.
|
|
134
|
+
body: { client_name: clientName, host: hostname(), ...(scopes ? { scopes } : {}) },
|
|
135
|
+
auth: false,
|
|
136
|
+
retry: true,
|
|
137
|
+
});
|
|
138
|
+
const url = start.verification_uri_complete ?? start.verification_uri;
|
|
139
|
+
ui.progress(`Your one-time code: ${ui.bold(start.user_code)}`);
|
|
140
|
+
ui.progress(`Approve it in your browser: ${url}`);
|
|
141
|
+
if (ui.interactive) io.openUrl(url);
|
|
142
|
+
ui.progress(ui.dim("Waiting for approval (Ctrl-C to stop)..."));
|
|
143
|
+
|
|
144
|
+
let intervalMs = (start.interval ?? 5) * 1000;
|
|
145
|
+
const expiresAt = io.now() + (start.expires_in ?? 600) * 1000;
|
|
146
|
+
for (;;) {
|
|
147
|
+
try {
|
|
148
|
+
await io.sleep(intervalMs, io.signal);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (io.signal.aborted) throw interrupted();
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
const res = await client.call("deviceToken", {
|
|
154
|
+
body: { device_code: start.device_code },
|
|
155
|
+
auth: false,
|
|
156
|
+
retry: true,
|
|
157
|
+
raw: true,
|
|
158
|
+
});
|
|
159
|
+
if (res.status === 200) return res.body;
|
|
160
|
+
const error = res.body?.error;
|
|
161
|
+
if (error === "authorization_pending" && io.now() < expiresAt) continue;
|
|
162
|
+
if (error === "slow_down") {
|
|
163
|
+
intervalMs += 5000; // RFC 8628 section 3.5
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (error === "access_denied") {
|
|
167
|
+
throw new CliError(EXIT.AUTH, "The request was denied in the browser.", {
|
|
168
|
+
next: "syndai login",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (error === "expired_token" || error === "authorization_pending") {
|
|
172
|
+
throw new CliError(EXIT.AUTH, "The code expired before it was approved.", {
|
|
173
|
+
next: "syndai login",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
throw new ApiError(res.status, res.body, res.headers);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function login(ctx) {
|
|
181
|
+
const { io, ui, flags } = ctx;
|
|
182
|
+
const extra = flags.scope ?? [];
|
|
183
|
+
const portalOnly = extra.find((scope) => !steppable(scope));
|
|
184
|
+
if (portalOnly) {
|
|
185
|
+
throw new CliError(EXIT.USAGE, `${portalOnly} is granted in the portal only.`, { next: PORTAL_URL });
|
|
186
|
+
}
|
|
187
|
+
if (extra.length && flags["with-token"]) {
|
|
188
|
+
throw new CliError(EXIT.USAGE, "--scope asks the browser for a key; it does not go with --with-token.");
|
|
189
|
+
}
|
|
190
|
+
let token;
|
|
191
|
+
if (flags["with-token"]) {
|
|
192
|
+
if (io.stdin.isTTY) ui.progress("Paste a Syndai API key, then press Ctrl-D:");
|
|
193
|
+
const apiKey = (await readAll(io.stdin)).trim();
|
|
194
|
+
if (!apiKey) throw new CliError(EXIT.USAGE, "No key on stdin.", { next: "syndai login" });
|
|
195
|
+
const me = await ctx.clientFor(async () => apiKey).call("me");
|
|
196
|
+
token = { api_key: apiKey, customer_id: me.customer_id, key_id: me.key_id, expires_at: me.expires_at };
|
|
197
|
+
} else {
|
|
198
|
+
token = await deviceApproval(ctx, "CLI", extra.length ? await scopesWith(ctx, extra) : undefined);
|
|
199
|
+
}
|
|
200
|
+
await storeKey(ctx, token);
|
|
201
|
+
if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is set and wins over this login."));
|
|
202
|
+
const expires = token.expires_at ? ` until ${token.expires_at.slice(0, 10)}` : "";
|
|
203
|
+
ui.result(
|
|
204
|
+
{ customer_id: token.customer_id, key_id: token.key_id, expires_at: token.expires_at },
|
|
205
|
+
`Logged in to workspace ${token.customer_id}${expires}.`,
|
|
206
|
+
);
|
|
207
|
+
return EXIT.OK;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function logout(ctx) {
|
|
211
|
+
const { io, ui, paths } = ctx;
|
|
212
|
+
const stored = storedCredential(io, paths);
|
|
213
|
+
if (!stored) {
|
|
214
|
+
ui.result({ logged_out: false }, "Not logged in.");
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
await ctx.clientFor(async () => stored.apiKey).call("revokeMe");
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// Already revoked or expired: nothing left to revoke, so just forget it.
|
|
221
|
+
if (!(err instanceof ApiError && err.status === 401)) throw err;
|
|
222
|
+
}
|
|
223
|
+
removeCredential(paths, stored.customerId);
|
|
224
|
+
if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is still set in this shell."));
|
|
225
|
+
ui.result(
|
|
226
|
+
{ logged_out: true, customer_id: stored.customerId },
|
|
227
|
+
`Logged out of workspace ${stored.customerId}; its key is revoked.`,
|
|
228
|
+
);
|
|
229
|
+
return EXIT.OK;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function whoami(ctx) {
|
|
233
|
+
const me = await ctx.me();
|
|
234
|
+
const { source } = ctx.credential();
|
|
235
|
+
ctx.ui.result(
|
|
236
|
+
{ ...me, source },
|
|
237
|
+
[
|
|
238
|
+
`Workspace ${me.customer_id}`,
|
|
239
|
+
`Key ${me.key_name ?? me.key_id}${me.expires_at ? ` (expires ${me.expires_at.slice(0, 10)})` : ""}`,
|
|
240
|
+
`Scopes ${(me.scopes ?? []).join(", ")}`,
|
|
241
|
+
`From ${source}`,
|
|
242
|
+
].join("\n"),
|
|
243
|
+
);
|
|
244
|
+
return EXIT.OK;
|
|
245
|
+
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// The change lifecycle: change, watch, answer, review, go-live, revert, cancel, status,
|
|
2
|
+
// edit-text. Each command is a thin call to one route plus, where there is an outcome to
|
|
3
|
+
// wait for, `watchChange`.
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { ApiError, CliError, EXIT } from "./errors.js";
|
|
9
|
+
import { projectHost, projectLabel, resolveProject } from "./projects.js";
|
|
10
|
+
import { clearPending, findPending, writePending } from "./store.js";
|
|
11
|
+
import { ask, parseDuration, readJsonInput, ulid } from "./ui.js";
|
|
12
|
+
import { answerInteractively, describe, finish, getChange, nextFor, respond, watchChange } from "./watch.js";
|
|
13
|
+
|
|
14
|
+
const TERMINAL_PHASES = new Set(["completed", "failed", "cancelled"]);
|
|
15
|
+
const IMAGE_TYPES = {
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".webp": "image/webp",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const timeoutOf = (flags) => (flags.timeout ? parseDuration(flags.timeout) : null);
|
|
24
|
+
|
|
25
|
+
function needRun(runId, command) {
|
|
26
|
+
if (!runId) throw new CliError(EXIT.USAGE, `Which change? syndai ${command} <run-id>`);
|
|
27
|
+
return runId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function change(ctx, words) {
|
|
31
|
+
const { flags, ui, paths, io } = ctx;
|
|
32
|
+
const text = words.join(" ").trim();
|
|
33
|
+
if (!text) throw new CliError(EXIT.USAGE, 'Say what to change: syndai change "make the header blue"');
|
|
34
|
+
const timeoutMs = timeoutOf(flags);
|
|
35
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
36
|
+
const outcome = flags.review ? "opens a pull request for your review" : "goes live when checks pass";
|
|
37
|
+
ui.progress(`Changing ${projectHost(project)} · ${outcome}`);
|
|
38
|
+
|
|
39
|
+
const imageRefs = [];
|
|
40
|
+
for (const file of flags.image ?? []) imageRefs.push(await uploadImage(ctx, file));
|
|
41
|
+
|
|
42
|
+
// The request id is on disk BEFORE the POST: a retry, or a rerun after a crash, sends the
|
|
43
|
+
// same external_request_id, and the server answers with the run it already made.
|
|
44
|
+
const pending = findPending(paths, { app: project.id, text }) ?? {
|
|
45
|
+
request_id: `cli:${ulid()}`,
|
|
46
|
+
app: project.id,
|
|
47
|
+
text,
|
|
48
|
+
created_at: new Date(io.now()).toISOString(),
|
|
49
|
+
};
|
|
50
|
+
writePending(paths, pending);
|
|
51
|
+
const runId = await createRun(ctx, {
|
|
52
|
+
body: {
|
|
53
|
+
prompt: text,
|
|
54
|
+
coding_repository_id: project.id,
|
|
55
|
+
external_request_id: pending.request_id,
|
|
56
|
+
...(imageRefs.length ? { image_refs: imageRefs } : {}),
|
|
57
|
+
...(flags.review ? { policy_config: { autonomy: "auto" } } : {}),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
clearPending(paths, pending.request_id);
|
|
61
|
+
ui.progress(ui.dim(`Change ${runId}`));
|
|
62
|
+
if (flags["no-wait"]) {
|
|
63
|
+
return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
|
|
64
|
+
}
|
|
65
|
+
return watchChange(ctx, runId, { timeoutMs });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The first non-GitHub product URL in the text, normalised to https; null when there is none.
|
|
69
|
+
* Same rule as Build home (portal-v2 `productUrlFromText`): a GitHub URL means an existing repo. */
|
|
70
|
+
export function productUrlFromText(text) {
|
|
71
|
+
for (const m of text.matchAll(/\b(?:https?:\/\/|www\.)[^\s<>"')\]]+/gi)) {
|
|
72
|
+
const raw = m[0].replace(/[.,;:!?]+$/, "");
|
|
73
|
+
try {
|
|
74
|
+
const url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
75
|
+
const host = url.hostname.toLowerCase();
|
|
76
|
+
if (!host.includes(".") || host === "github.com" || host.endsWith(".github.com")) continue;
|
|
77
|
+
return url.href;
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build-home parity: start a New project. A product URL in the text makes it "your own version
|
|
84
|
+
* of X" (SPEC-build-your-own D5) unless --no-learn; its feature-pick question is asked inline. */
|
|
85
|
+
export async function newProject(ctx, words) {
|
|
86
|
+
const { flags, ui, paths, io } = ctx;
|
|
87
|
+
const text = words.join(" ").trim();
|
|
88
|
+
if (!text) throw new CliError(EXIT.USAGE, 'Say what to build: syndai new "a booking site for my salon"');
|
|
89
|
+
const timeoutMs = timeoutOf(flags);
|
|
90
|
+
const reference = flags["no-learn"] ? null : productUrlFromText(text);
|
|
91
|
+
ui.progress(
|
|
92
|
+
reference
|
|
93
|
+
? `New project · your own version of ${new URL(reference).hostname.replace(/^www\./, "")}`
|
|
94
|
+
: "New project",
|
|
95
|
+
);
|
|
96
|
+
const pending = findPending(paths, { app: "new", text }) ?? {
|
|
97
|
+
request_id: `cli:${ulid()}`,
|
|
98
|
+
app: "new",
|
|
99
|
+
text,
|
|
100
|
+
created_at: new Date(io.now()).toISOString(),
|
|
101
|
+
};
|
|
102
|
+
writePending(paths, pending);
|
|
103
|
+
const runId = await createRun(ctx, {
|
|
104
|
+
body: {
|
|
105
|
+
prompt: text,
|
|
106
|
+
new_repository: { scaffold: "syndai-web" },
|
|
107
|
+
external_request_id: pending.request_id,
|
|
108
|
+
...(reference ? { metadata: { reference_product: { kind: "url", value: reference } } } : {}),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
clearPending(paths, pending.request_id);
|
|
112
|
+
ui.progress(ui.dim(`Change ${runId}`));
|
|
113
|
+
if (flags["no-wait"]) {
|
|
114
|
+
return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
|
|
115
|
+
}
|
|
116
|
+
return watchChange(ctx, runId, { timeoutMs });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function createRun(ctx, { body }) {
|
|
120
|
+
try {
|
|
121
|
+
const created = await ctx.client.call("createRun", { body, retry: true });
|
|
122
|
+
return created.run.id;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (!(err instanceof ApiError && err.code === "IDEMPOTENCY_REPLAY")) throw err;
|
|
125
|
+
const location = err.headers?.get("location") ?? "";
|
|
126
|
+
const fromLocation = /\/runs\/([^/?#]+)/.exec(location)?.[1];
|
|
127
|
+
const runId = fromLocation ?? err.problem.existing_run_id;
|
|
128
|
+
if (!runId) throw err;
|
|
129
|
+
return runId;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function uploadImage(ctx, file) {
|
|
134
|
+
const contentType = IMAGE_TYPES[path.extname(file).toLowerCase()];
|
|
135
|
+
if (!contentType)
|
|
136
|
+
throw new CliError(EXIT.USAGE, `--image takes a png, jpeg, gif or webp file, not ${file}.`);
|
|
137
|
+
let bytes;
|
|
138
|
+
try {
|
|
139
|
+
bytes = readFileSync(file);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new CliError(EXIT.USAGE, `Cannot read ${file}.`);
|
|
142
|
+
}
|
|
143
|
+
const stored = await ctx.client.call("imageIngest", {
|
|
144
|
+
body: { content_type: contentType, data_base64: bytes.toString("base64") },
|
|
145
|
+
});
|
|
146
|
+
return { object_key: stored.object_key, content_type: contentType };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function watch(ctx, runId) {
|
|
150
|
+
return watchChange(ctx, needRun(runId, "watch"), {
|
|
151
|
+
timeoutMs: timeoutOf(ctx.flags),
|
|
152
|
+
logs: ctx.flags.logs === true,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function answer(ctx, runId) {
|
|
157
|
+
const { flags, ui } = ctx;
|
|
158
|
+
needRun(runId, "answer");
|
|
159
|
+
const state = await getChange(ctx, runId);
|
|
160
|
+
const questions = state.state === "needs_input" ? (state.needs_input ?? []) : [];
|
|
161
|
+
if (questions.length === 0) {
|
|
162
|
+
ui.result({ ...state, next: nextFor(state) }, `Nothing to answer. ${describe(state)}`);
|
|
163
|
+
return EXIT.OK;
|
|
164
|
+
}
|
|
165
|
+
if (flags.input !== undefined) {
|
|
166
|
+
const body = await readJsonInput(ctx.io, flags.input);
|
|
167
|
+
const entries = Array.isArray(body) ? body : body?.entries;
|
|
168
|
+
const hitlRequestId =
|
|
169
|
+
body?.hitl_request_id ?? (questions.length === 1 ? questions[0].hitl_request_id : null);
|
|
170
|
+
if (!hitlRequestId || !Array.isArray(entries)) {
|
|
171
|
+
throw new CliError(
|
|
172
|
+
EXIT.USAGE,
|
|
173
|
+
'--input needs {"entries":[...]}, and hitl_request_id when several questions are open.',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
await respond(ctx, runId, hitlRequestId, entries);
|
|
177
|
+
} else if (ui.interactive) {
|
|
178
|
+
await answerInteractively(ctx, runId, questions);
|
|
179
|
+
} else {
|
|
180
|
+
return finish(ctx, state);
|
|
181
|
+
}
|
|
182
|
+
const next = `syndai watch ${runId}`;
|
|
183
|
+
ui.result({ run_id: runId, answered: true, next }, "Answered.");
|
|
184
|
+
if (!ui.json) ui.progress(`Next: ${next}`);
|
|
185
|
+
return EXIT.OK;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function review(ctx, runId) {
|
|
189
|
+
const { flags, ui, io, client } = ctx;
|
|
190
|
+
needRun(runId, "review");
|
|
191
|
+
if (flags.retry && flags.close) throw new CliError(EXIT.USAGE, "Pick one of --retry and --close.");
|
|
192
|
+
const params = { run_id: runId };
|
|
193
|
+
const state = await getChange(ctx, runId);
|
|
194
|
+
if (state.state !== "held") return finish(ctx, state);
|
|
195
|
+
if (flags.retry) {
|
|
196
|
+
await client.call("landRetry", { params });
|
|
197
|
+
ui.progress("Running the checks again.");
|
|
198
|
+
return watchChange(ctx, runId, { untilNot: "held" });
|
|
199
|
+
}
|
|
200
|
+
if (flags.close) {
|
|
201
|
+
await client.call("landClose", { params });
|
|
202
|
+
ui.result({ run_id: runId, closed: true }, "Closed. Nothing went live.");
|
|
203
|
+
return EXIT.OK;
|
|
204
|
+
}
|
|
205
|
+
const diff = await client.call("heldDiff", { params });
|
|
206
|
+
const reviewUrl = state.held?.review_url ?? null;
|
|
207
|
+
if (ui.json) {
|
|
208
|
+
ui.result({ ...state, held_diff: diff, next: reviewUrl ?? nextFor(state) }, "");
|
|
209
|
+
return EXIT.NEEDS_YOU;
|
|
210
|
+
}
|
|
211
|
+
await io.page(renderHeldDiff(state, diff));
|
|
212
|
+
if (!ui.interactive || !reviewUrl) {
|
|
213
|
+
ui.progress(`Next: ${reviewUrl ?? `syndai review ${runId}`}`);
|
|
214
|
+
return EXIT.NEEDS_YOU;
|
|
215
|
+
}
|
|
216
|
+
const yes = (await ask(io, "Approve in your browser? [Y/n] ")).toLowerCase();
|
|
217
|
+
if (yes && !yes.startsWith("y")) {
|
|
218
|
+
ui.progress(`Next: syndai review ${runId}`);
|
|
219
|
+
return EXIT.NEEDS_YOU;
|
|
220
|
+
}
|
|
221
|
+
ui.progress(`Opening ${reviewUrl}`);
|
|
222
|
+
io.openUrl(reviewUrl);
|
|
223
|
+
ui.progress(ui.dim("Waiting for your decision (Ctrl-C stops watching, not the review)..."));
|
|
224
|
+
return watchChange(ctx, runId, { untilNot: "held" });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function renderHeldDiff(state, diff) {
|
|
228
|
+
const lines = [describe(state)];
|
|
229
|
+
if (diff.head_sha) {
|
|
230
|
+
const by = diff.expires_at ? ` · decide by ${diff.expires_at.slice(0, 10)}` : "";
|
|
231
|
+
lines.push(`At ${diff.head_sha.slice(0, 12)}${by}`);
|
|
232
|
+
}
|
|
233
|
+
lines.push("", "Files:");
|
|
234
|
+
for (const file of diff.paths ?? []) {
|
|
235
|
+
const modes =
|
|
236
|
+
file.old_mode !== file.new_mode && file.new_mode
|
|
237
|
+
? ` (${file.old_mode ?? "none"} -> ${file.new_mode})`
|
|
238
|
+
: "";
|
|
239
|
+
lines.push(` ${file.change.padEnd(10)} ${file.path}${modes}`);
|
|
240
|
+
}
|
|
241
|
+
for (const file of diff.paths ?? []) {
|
|
242
|
+
lines.push("", `--- ${file.path}`);
|
|
243
|
+
lines.push(file.binary ? "(binary file)" : (file.diff ?? "(no text diff)"));
|
|
244
|
+
}
|
|
245
|
+
return `${lines.join("\n")}\n`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function goLive(ctx, runId) {
|
|
249
|
+
needRun(runId, "go-live");
|
|
250
|
+
await ctx.client.call("goLive", { params: { run_id: runId } });
|
|
251
|
+
ctx.ui.progress("Going live.");
|
|
252
|
+
return watchChange(ctx, runId, { timeoutMs: timeoutOf(ctx.flags), untilNot: "ready" });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function revert(ctx, runId) {
|
|
256
|
+
needRun(runId, "revert");
|
|
257
|
+
let reverted;
|
|
258
|
+
try {
|
|
259
|
+
// Idempotent per landing: a retry returns the same revert change.
|
|
260
|
+
reverted = await ctx.client.call("revert", {
|
|
261
|
+
params: { run_id: runId },
|
|
262
|
+
body: { force: ctx.flags.force === true },
|
|
263
|
+
retry: true,
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
if (err instanceof ApiError && String(err.problem.detail ?? "").startsWith("revert_conflict")) {
|
|
267
|
+
throw new CliError(EXIT.FAILED, "Newer changes landed on top of this one.", {
|
|
268
|
+
next: `syndai revert ${runId} --force`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
ctx.ui.progress(`Reverting with change ${reverted.run.id}.`);
|
|
274
|
+
return watchChange(ctx, reverted.run.id, { timeoutMs: timeoutOf(ctx.flags) });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function cancel(ctx, runId) {
|
|
278
|
+
needRun(runId, "cancel");
|
|
279
|
+
await ctx.client.call("cancel", { params: { run_id: runId }, body: {} });
|
|
280
|
+
ctx.ui.result({ run_id: runId, cancelled: true }, "Cancelled.");
|
|
281
|
+
return EXIT.OK;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function status(ctx) {
|
|
285
|
+
const { client, ui, flags } = ctx;
|
|
286
|
+
const project = await resolveProject(ctx, { app: flags.app, required: false, picker: false });
|
|
287
|
+
const query = { coding_repository_id: project?.id, limit: 20 };
|
|
288
|
+
const [needs, recent] = await Promise.all([
|
|
289
|
+
client.call("listRuns", { query: { ...query, needs: "owner" } }),
|
|
290
|
+
client.call("listRuns", { query }),
|
|
291
|
+
]);
|
|
292
|
+
// The ChangeState names the exact next command, so read it rather than guess from the run.
|
|
293
|
+
const needsYou = await Promise.all(
|
|
294
|
+
needs.runs.slice(0, 10).map(async (run) => ({ run, state: await getChange(ctx, run.id) })),
|
|
295
|
+
);
|
|
296
|
+
const needIds = new Set(needs.runs.map((run) => run.id));
|
|
297
|
+
const active = recent.runs.filter((run) => !TERMINAL_PHASES.has(run.current_phase) && !needIds.has(run.id));
|
|
298
|
+
const title = (run) => run.title ?? run.prompt?.slice(0, 60) ?? run.id;
|
|
299
|
+
const lines = [];
|
|
300
|
+
if (project) lines.push(ui.bold(projectLabel(project)));
|
|
301
|
+
if (needsYou.length) {
|
|
302
|
+
lines.push("Needs you:");
|
|
303
|
+
for (const { run, state } of needsYou)
|
|
304
|
+
lines.push(` ${title(run)} ${describe(state)} -> ${nextFor(state)}`);
|
|
305
|
+
}
|
|
306
|
+
if (active.length) {
|
|
307
|
+
lines.push("Running:");
|
|
308
|
+
for (const run of active) lines.push(` ${title(run)} ${run.current_phase} -> syndai watch ${run.id}`);
|
|
309
|
+
}
|
|
310
|
+
if (!needsYou.length && !active.length) lines.push("Nothing needs you, and nothing is running.");
|
|
311
|
+
ui.result(
|
|
312
|
+
{
|
|
313
|
+
project: project ? { id: project.id, name: project.name, live_url: project.live_url } : null,
|
|
314
|
+
needs_you: needsYou.map(({ state }) => ({ ...state, next: nextFor(state) })),
|
|
315
|
+
running: active.map((run) => ({ run_id: run.id, title: title(run), phase: run.current_phase })),
|
|
316
|
+
},
|
|
317
|
+
lines.join("\n"),
|
|
318
|
+
);
|
|
319
|
+
return EXIT.OK;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function editText(ctx) {
|
|
323
|
+
const { flags, ui, client } = ctx;
|
|
324
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
325
|
+
const params = { coding_repository_id: project.id };
|
|
326
|
+
if (flags.undo) {
|
|
327
|
+
const undone = await client.call("undoTextEdit", { params: { ...params, sha: flags.undo }, retry: true });
|
|
328
|
+
ui.result(undone, `Undone on ${projectHost(project)}.`);
|
|
329
|
+
return EXIT.OK;
|
|
330
|
+
}
|
|
331
|
+
if (flags.from === undefined || flags.to === undefined) {
|
|
332
|
+
throw new CliError(EXIT.USAGE, 'Give the exact text: syndai edit-text --page / --from "Old" --to "New"');
|
|
333
|
+
}
|
|
334
|
+
const pagePath = flags.page ?? "/";
|
|
335
|
+
const edited = await client.call("textEdit", {
|
|
336
|
+
params,
|
|
337
|
+
body: {
|
|
338
|
+
page: pagePath,
|
|
339
|
+
before: flags.from,
|
|
340
|
+
after: flags.to,
|
|
341
|
+
all: flags.all === true,
|
|
342
|
+
request_id: randomUUID(),
|
|
343
|
+
},
|
|
344
|
+
retry: true,
|
|
345
|
+
});
|
|
346
|
+
const next = `syndai edit-text --app ${project.id} --undo ${edited.commit_sha}`;
|
|
347
|
+
ui.result({ ...edited, next }, `Edited ${projectHost(project)}${pagePath}; live in a few seconds.`);
|
|
348
|
+
if (!ui.json) ui.progress(`Undo: ${next}`);
|
|
349
|
+
return EXIT.OK;
|
|
350
|
+
}
|