syndai 0.1.1 → 0.3.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 +31 -0
- package/README.md +76 -52
- package/bin/syndai.js +13 -29
- package/package.json +6 -11
- package/src/auth.js +179 -0
- package/src/changes.js +307 -0
- package/src/errors.js +128 -0
- package/src/http.js +153 -0
- package/src/main.js +212 -0
- package/src/mcp.js +93 -0
- package/src/projects.js +168 -0
- package/src/routes.js +56 -0
- package/src/settings.js +160 -0
- package/src/store.js +134 -0
- package/src/ui.js +133 -0
- package/src/watch.js +292 -0
- package/LICENSE +0 -9
- package/src/api.js +0 -70
- 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/changes.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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, 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
|
+
async function createRun(ctx, { body }) {
|
|
69
|
+
try {
|
|
70
|
+
const created = await ctx.client.call("createRun", { body, retry: true });
|
|
71
|
+
return created.run.id;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (!(err instanceof ApiError && err.code === "IDEMPOTENCY_REPLAY")) throw err;
|
|
74
|
+
const location = err.headers?.get("location") ?? "";
|
|
75
|
+
const fromLocation = /\/runs\/([^/?#]+)/.exec(location)?.[1];
|
|
76
|
+
const runId = fromLocation ?? err.problem.existing_run_id;
|
|
77
|
+
if (!runId) throw err;
|
|
78
|
+
return runId;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function uploadImage(ctx, file) {
|
|
83
|
+
const contentType = IMAGE_TYPES[path.extname(file).toLowerCase()];
|
|
84
|
+
if (!contentType)
|
|
85
|
+
throw new CliError(EXIT.USAGE, `--image takes a png, jpeg, gif or webp file, not ${file}.`);
|
|
86
|
+
let bytes;
|
|
87
|
+
try {
|
|
88
|
+
bytes = readFileSync(file);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new CliError(EXIT.USAGE, `Cannot read ${file}.`);
|
|
91
|
+
}
|
|
92
|
+
const stored = await ctx.client.call("imageIngest", {
|
|
93
|
+
body: { content_type: contentType, data_base64: bytes.toString("base64") },
|
|
94
|
+
});
|
|
95
|
+
return { object_key: stored.object_key, content_type: contentType };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function watch(ctx, runId) {
|
|
99
|
+
return watchChange(ctx, needRun(runId, "watch"), {
|
|
100
|
+
timeoutMs: timeoutOf(ctx.flags),
|
|
101
|
+
logs: ctx.flags.logs === true,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function answer(ctx, runId) {
|
|
106
|
+
const { flags, ui } = ctx;
|
|
107
|
+
needRun(runId, "answer");
|
|
108
|
+
const state = await getChange(ctx, runId);
|
|
109
|
+
const questions = state.state === "needs_input" ? (state.needs_input ?? []) : [];
|
|
110
|
+
if (questions.length === 0) {
|
|
111
|
+
ui.result({ ...state, next: nextFor(state) }, `Nothing to answer. ${describe(state)}`);
|
|
112
|
+
return EXIT.OK;
|
|
113
|
+
}
|
|
114
|
+
if (typeof flags.json === "string") {
|
|
115
|
+
let body;
|
|
116
|
+
try {
|
|
117
|
+
body = JSON.parse(flags.json);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new CliError(
|
|
120
|
+
EXIT.USAGE,
|
|
121
|
+
'--json must be JSON, like {"entries":[{"input_id":"q1","text":"blue"}]}.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const entries = Array.isArray(body) ? body : body.entries;
|
|
125
|
+
const hitlRequestId =
|
|
126
|
+
body.hitl_request_id ?? (questions.length === 1 ? questions[0].hitl_request_id : null);
|
|
127
|
+
if (!hitlRequestId || !Array.isArray(entries)) {
|
|
128
|
+
throw new CliError(
|
|
129
|
+
EXIT.USAGE,
|
|
130
|
+
"--json needs entries, and hitl_request_id when several questions are open.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
await respond(ctx, runId, hitlRequestId, entries);
|
|
134
|
+
} else if (ui.interactive) {
|
|
135
|
+
await answerInteractively(ctx, runId, questions);
|
|
136
|
+
} else {
|
|
137
|
+
return finish(ctx, state);
|
|
138
|
+
}
|
|
139
|
+
const next = `syndai watch ${runId}`;
|
|
140
|
+
ui.result({ run_id: runId, answered: true, next }, "Answered.");
|
|
141
|
+
if (!ui.json) ui.progress(`Next: ${next}`);
|
|
142
|
+
return EXIT.OK;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function review(ctx, runId) {
|
|
146
|
+
const { flags, ui, io, client } = ctx;
|
|
147
|
+
needRun(runId, "review");
|
|
148
|
+
if (flags.retry && flags.close) throw new CliError(EXIT.USAGE, "Pick one of --retry and --close.");
|
|
149
|
+
const params = { run_id: runId };
|
|
150
|
+
const state = await getChange(ctx, runId);
|
|
151
|
+
if (state.state !== "held") return finish(ctx, state);
|
|
152
|
+
if (flags.retry) {
|
|
153
|
+
await client.call("landRetry", { params });
|
|
154
|
+
ui.progress("Running the checks again.");
|
|
155
|
+
return watchChange(ctx, runId, { untilNot: "held" });
|
|
156
|
+
}
|
|
157
|
+
if (flags.close) {
|
|
158
|
+
await client.call("landClose", { params });
|
|
159
|
+
ui.result({ run_id: runId, closed: true }, "Closed. Nothing went live.");
|
|
160
|
+
return EXIT.OK;
|
|
161
|
+
}
|
|
162
|
+
const diff = await client.call("heldDiff", { params });
|
|
163
|
+
const reviewUrl = state.held?.review_url ?? null;
|
|
164
|
+
if (ui.json) {
|
|
165
|
+
ui.result({ ...state, held_diff: diff, next: reviewUrl ?? nextFor(state) }, "");
|
|
166
|
+
return EXIT.NEEDS_YOU;
|
|
167
|
+
}
|
|
168
|
+
await io.page(renderHeldDiff(state, diff));
|
|
169
|
+
if (!ui.interactive || !reviewUrl) {
|
|
170
|
+
ui.progress(`Next: ${reviewUrl ?? `syndai review ${runId}`}`);
|
|
171
|
+
return EXIT.NEEDS_YOU;
|
|
172
|
+
}
|
|
173
|
+
const yes = (await ask(io, "Approve in your browser? [Y/n] ")).toLowerCase();
|
|
174
|
+
if (yes && !yes.startsWith("y")) {
|
|
175
|
+
ui.progress(`Next: syndai review ${runId}`);
|
|
176
|
+
return EXIT.NEEDS_YOU;
|
|
177
|
+
}
|
|
178
|
+
ui.progress(`Opening ${reviewUrl}`);
|
|
179
|
+
io.openUrl(reviewUrl);
|
|
180
|
+
ui.progress(ui.dim("Waiting for your decision (Ctrl-C stops watching, not the review)..."));
|
|
181
|
+
return watchChange(ctx, runId, { untilNot: "held" });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function renderHeldDiff(state, diff) {
|
|
185
|
+
const lines = [describe(state)];
|
|
186
|
+
if (diff.head_sha) {
|
|
187
|
+
const by = diff.expires_at ? ` · decide by ${diff.expires_at.slice(0, 10)}` : "";
|
|
188
|
+
lines.push(`At ${diff.head_sha.slice(0, 12)}${by}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push("", "Files:");
|
|
191
|
+
for (const file of diff.paths ?? []) {
|
|
192
|
+
const modes =
|
|
193
|
+
file.old_mode !== file.new_mode && file.new_mode
|
|
194
|
+
? ` (${file.old_mode ?? "none"} -> ${file.new_mode})`
|
|
195
|
+
: "";
|
|
196
|
+
lines.push(` ${file.change.padEnd(10)} ${file.path}${modes}`);
|
|
197
|
+
}
|
|
198
|
+
for (const file of diff.paths ?? []) {
|
|
199
|
+
lines.push("", `--- ${file.path}`);
|
|
200
|
+
lines.push(file.binary ? "(binary file)" : (file.diff ?? "(no text diff)"));
|
|
201
|
+
}
|
|
202
|
+
return `${lines.join("\n")}\n`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function goLive(ctx, runId) {
|
|
206
|
+
needRun(runId, "go-live");
|
|
207
|
+
await ctx.client.call("goLive", { params: { run_id: runId } });
|
|
208
|
+
ctx.ui.progress("Going live.");
|
|
209
|
+
return watchChange(ctx, runId, { timeoutMs: timeoutOf(ctx.flags), untilNot: "ready" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function revert(ctx, runId) {
|
|
213
|
+
needRun(runId, "revert");
|
|
214
|
+
let reverted;
|
|
215
|
+
try {
|
|
216
|
+
// Idempotent per landing: a retry returns the same revert change.
|
|
217
|
+
reverted = await ctx.client.call("revert", {
|
|
218
|
+
params: { run_id: runId },
|
|
219
|
+
body: { force: ctx.flags.force === true },
|
|
220
|
+
retry: true,
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof ApiError && String(err.problem.detail ?? "").startsWith("revert_conflict")) {
|
|
224
|
+
throw new CliError(EXIT.FAILED, "Newer changes landed on top of this one.", {
|
|
225
|
+
next: `syndai revert ${runId} --force`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
ctx.ui.progress(`Reverting with change ${reverted.run.id}.`);
|
|
231
|
+
return watchChange(ctx, reverted.run.id, { timeoutMs: timeoutOf(ctx.flags) });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function cancel(ctx, runId) {
|
|
235
|
+
needRun(runId, "cancel");
|
|
236
|
+
await ctx.client.call("cancel", { params: { run_id: runId }, body: {} });
|
|
237
|
+
ctx.ui.result({ run_id: runId, cancelled: true }, "Cancelled.");
|
|
238
|
+
return EXIT.OK;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function status(ctx) {
|
|
242
|
+
const { client, ui, flags } = ctx;
|
|
243
|
+
const project = await resolveProject(ctx, { app: flags.app, required: false, picker: false });
|
|
244
|
+
const query = { coding_repository_id: project?.id, limit: 20 };
|
|
245
|
+
const [needs, recent] = await Promise.all([
|
|
246
|
+
client.call("listRuns", { query: { ...query, needs: "owner" } }),
|
|
247
|
+
client.call("listRuns", { query }),
|
|
248
|
+
]);
|
|
249
|
+
// The ChangeState names the exact next command, so read it rather than guess from the run.
|
|
250
|
+
const needsYou = await Promise.all(
|
|
251
|
+
needs.runs.slice(0, 10).map(async (run) => ({ run, state: await getChange(ctx, run.id) })),
|
|
252
|
+
);
|
|
253
|
+
const needIds = new Set(needs.runs.map((run) => run.id));
|
|
254
|
+
const active = recent.runs.filter((run) => !TERMINAL_PHASES.has(run.current_phase) && !needIds.has(run.id));
|
|
255
|
+
const title = (run) => run.title ?? run.prompt?.slice(0, 60) ?? run.id;
|
|
256
|
+
const lines = [];
|
|
257
|
+
if (project) lines.push(ui.bold(projectLabel(project)));
|
|
258
|
+
if (needsYou.length) {
|
|
259
|
+
lines.push("Needs you:");
|
|
260
|
+
for (const { run, state } of needsYou)
|
|
261
|
+
lines.push(` ${title(run)} ${describe(state)} -> ${nextFor(state)}`);
|
|
262
|
+
}
|
|
263
|
+
if (active.length) {
|
|
264
|
+
lines.push("Running:");
|
|
265
|
+
for (const run of active) lines.push(` ${title(run)} ${run.current_phase} -> syndai watch ${run.id}`);
|
|
266
|
+
}
|
|
267
|
+
if (!needsYou.length && !active.length) lines.push("Nothing needs you, and nothing is running.");
|
|
268
|
+
ui.result(
|
|
269
|
+
{
|
|
270
|
+
project: project ? { id: project.id, name: project.name, live_url: project.live_url } : null,
|
|
271
|
+
needs_you: needsYou.map(({ state }) => ({ ...state, next: nextFor(state) })),
|
|
272
|
+
running: active.map((run) => ({ run_id: run.id, title: title(run), phase: run.current_phase })),
|
|
273
|
+
},
|
|
274
|
+
lines.join("\n"),
|
|
275
|
+
);
|
|
276
|
+
return EXIT.OK;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function editText(ctx) {
|
|
280
|
+
const { flags, ui, client } = ctx;
|
|
281
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
282
|
+
const params = { coding_repository_id: project.id };
|
|
283
|
+
if (flags.undo) {
|
|
284
|
+
const undone = await client.call("undoTextEdit", { params: { ...params, sha: flags.undo }, retry: true });
|
|
285
|
+
ui.result(undone, `Undone on ${projectHost(project)}.`);
|
|
286
|
+
return EXIT.OK;
|
|
287
|
+
}
|
|
288
|
+
if (flags.from === undefined || flags.to === undefined) {
|
|
289
|
+
throw new CliError(EXIT.USAGE, 'Give the exact text: syndai edit-text --page / --from "Old" --to "New"');
|
|
290
|
+
}
|
|
291
|
+
const pagePath = flags.page ?? "/";
|
|
292
|
+
const edited = await client.call("textEdit", {
|
|
293
|
+
params,
|
|
294
|
+
body: {
|
|
295
|
+
page: pagePath,
|
|
296
|
+
before: flags.from,
|
|
297
|
+
after: flags.to,
|
|
298
|
+
all: flags.all === true,
|
|
299
|
+
request_id: randomUUID(),
|
|
300
|
+
},
|
|
301
|
+
retry: true,
|
|
302
|
+
});
|
|
303
|
+
const next = `syndai edit-text --app ${project.id} --undo ${edited.commit_sha}`;
|
|
304
|
+
ui.result({ ...edited, next }, `Edited ${projectHost(project)}${pagePath}; live in a few seconds.`);
|
|
305
|
+
if (!ui.json) ui.progress(`Undo: ${next}`);
|
|
306
|
+
return EXIT.OK;
|
|
307
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Exit codes and the one place a server problem becomes a sentence plus the next command.
|
|
2
|
+
//
|
|
3
|
+
// The exit codes are a contract with scripts and agents (docs: cli.mdx "Exit codes"):
|
|
4
|
+
// never renumber one.
|
|
5
|
+
|
|
6
|
+
export const EXIT = Object.freeze({
|
|
7
|
+
OK: 0,
|
|
8
|
+
FAILED: 1, // the change failed, was reverted or cancelled; or the request was refused
|
|
9
|
+
USAGE: 2, // bad arguments, or no project could be resolved
|
|
10
|
+
NEEDS_YOU: 3, // a question, a hold, or an approval only a browser session can give
|
|
11
|
+
UNVERIFIED: 4, // live, but the live check did not confirm it
|
|
12
|
+
AUTH: 5, // 401/402/403: key, scope, role or plan
|
|
13
|
+
TIMEOUT: 6, // --timeout reached; the change keeps going
|
|
14
|
+
UNAVAILABLE: 7, // the service did not answer after retries
|
|
15
|
+
INTERRUPTED: 130, // Ctrl-C; the change keeps going
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** A user-facing failure: `message` is one sentence, `next` the command (or URL) to run next. */
|
|
19
|
+
export class CliError extends Error {
|
|
20
|
+
constructor(exitCode, message, { next = null, result = null } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.exitCode = exitCode;
|
|
23
|
+
this.next = next;
|
|
24
|
+
// `result` is printed to stdout (JSON for scripts) before the error line.
|
|
25
|
+
this.result = result;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A non-2xx answer from the API. `problem` is the parsed body (problem+json or plain JSON). */
|
|
30
|
+
export class ApiError extends Error {
|
|
31
|
+
constructor(status, problem, headers) {
|
|
32
|
+
super(problem?.detail || problem?.title || `HTTP ${status}`);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.problem = problem ?? {};
|
|
35
|
+
this.code = this.problem.code ?? null;
|
|
36
|
+
this.headers = headers;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The service did not answer (network error, 5xx or 429) after the retry budget. */
|
|
41
|
+
export class UnavailableError extends Error {}
|
|
42
|
+
|
|
43
|
+
const BILLING_URL = "https://app.syndai.ai/billing";
|
|
44
|
+
|
|
45
|
+
// code -> [exit, sentence, next]. Either may be a function of the problem body.
|
|
46
|
+
const KNOWN = {
|
|
47
|
+
INVALID_TOKEN: [EXIT.AUTH, "Your Syndai key is missing, expired or revoked.", "syndai login"],
|
|
48
|
+
INVALID_SCOPE: [EXIT.AUTH, "This key is not allowed to do that.", "syndai login"],
|
|
49
|
+
ROLE_REQUIRED: [EXIT.AUTH, "Only workspace owners and admins can do that.", null],
|
|
50
|
+
PLAN_UPGRADE_REQUIRED: [EXIT.AUTH, "Your plan does not include that.", BILLING_URL],
|
|
51
|
+
BUDGET_EXHAUSTED: [EXIT.AUTH, "This workspace is out of credits.", BILLING_URL],
|
|
52
|
+
QUOTA_EXCEEDED: [EXIT.AUTH, "This workspace is out of credits for now.", BILLING_URL],
|
|
53
|
+
CUSTOMER_DISABLED: [EXIT.AUTH, "This workspace is disabled. Contact Syndai support.", null],
|
|
54
|
+
HUMAN_APPROVAL_REQUIRED: [
|
|
55
|
+
EXIT.NEEDS_YOU,
|
|
56
|
+
"Only you can approve this, in your browser.",
|
|
57
|
+
(p) => p.review_url ?? p.extensions?.review_url ?? null,
|
|
58
|
+
],
|
|
59
|
+
PROTECTED_PATHS_MANAGED_ONLY: [
|
|
60
|
+
EXIT.FAILED,
|
|
61
|
+
"Protected files work on Syndai-hosted projects only; use the code host's branch rules.",
|
|
62
|
+
null,
|
|
63
|
+
],
|
|
64
|
+
CLI_UPGRADE_REQUIRED: [EXIT.FAILED, "This version of syndai is too old.", "npx -y syndai@latest"],
|
|
65
|
+
RUN_CONCURRENCY_LIMIT: [EXIT.FAILED, "Too many changes are running at once.", "syndai status"],
|
|
66
|
+
REPO_NOT_BOUND: [EXIT.USAGE, "That project is not available to this workspace.", "syndai link"],
|
|
67
|
+
INSTALLATION_INCOMPLETE: [
|
|
68
|
+
EXIT.FAILED,
|
|
69
|
+
"A code-host connection needs finishing in the portal.",
|
|
70
|
+
"https://app.syndai.ai",
|
|
71
|
+
],
|
|
72
|
+
HITL_REQUEST_SETTLED: [EXIT.FAILED, "That question is already answered or closed.", null],
|
|
73
|
+
RUN_TERMINATED_BEFORE_RESPONSE: [EXIT.FAILED, "The change ended before your answer arrived.", null],
|
|
74
|
+
TEXT_EDIT_REFUSED: [
|
|
75
|
+
EXIT.FAILED,
|
|
76
|
+
(p) => oneSentence(p.detail || "The text edit was refused."),
|
|
77
|
+
// Text built from code: the server hands back a ready change instruction.
|
|
78
|
+
(p) => (p.refusal === "NOT_LITERAL" && p.prompt ? `syndai change ${shellQuote(p.prompt)}` : null),
|
|
79
|
+
],
|
|
80
|
+
RUN_IN_FLIGHT: [
|
|
81
|
+
EXIT.FAILED,
|
|
82
|
+
"A change is in flight on this site; edit text once it lands.",
|
|
83
|
+
(p) => (p.run_id ? `syndai watch ${p.run_id}` : "syndai status"),
|
|
84
|
+
],
|
|
85
|
+
NOT_FOUND: [EXIT.FAILED, "Not found.", "syndai status"],
|
|
86
|
+
RATE_LIMITED: [EXIT.UNAVAILABLE, "Syndai is rate-limiting this workspace.", null],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Map any thrown error to {exitCode, message, next}. */
|
|
90
|
+
export function explain(err) {
|
|
91
|
+
if (err instanceof CliError) {
|
|
92
|
+
return { exitCode: err.exitCode, message: err.message, next: err.next };
|
|
93
|
+
}
|
|
94
|
+
if (err instanceof UnavailableError) {
|
|
95
|
+
return {
|
|
96
|
+
exitCode: EXIT.UNAVAILABLE,
|
|
97
|
+
message: `Syndai is not answering right now (${err.message}).`,
|
|
98
|
+
next: "try again in a minute",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (err instanceof ApiError) {
|
|
102
|
+
const known = err.code ? KNOWN[err.code] : null;
|
|
103
|
+
if (known) {
|
|
104
|
+
const [exitCode, sentence, next] = known;
|
|
105
|
+
return {
|
|
106
|
+
exitCode,
|
|
107
|
+
message: typeof sentence === "function" ? sentence(err.problem) : sentence,
|
|
108
|
+
next: typeof next === "function" ? next(err.problem) : next,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const auth = err.status === 401 || err.status === 402 || err.status === 403;
|
|
112
|
+
return {
|
|
113
|
+
exitCode: auth ? EXIT.AUTH : EXIT.FAILED,
|
|
114
|
+
message: oneSentence(err.problem.detail || err.problem.title || `HTTP ${err.status}`),
|
|
115
|
+
next: err.status === 401 ? "syndai login" : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { exitCode: EXIT.FAILED, message: oneSentence(String(err?.message ?? err)), next: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shellQuote(text) {
|
|
122
|
+
return `'${String(text).replace(/'/g, "'\\''")}'`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function oneSentence(text) {
|
|
126
|
+
const line = String(text).split("\n")[0].trim();
|
|
127
|
+
return /[.!?]$/.test(line) ? line : `${line}.`;
|
|
128
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The one HTTP client. Raw `fetch`, no SDK: `test/contract.test.js` checks every route
|
|
2
|
+
// against the published OpenAPI instead.
|
|
3
|
+
//
|
|
4
|
+
// Retry rule (docs: cli.mdx "Retries"): only GETs and POSTs that carry their own
|
|
5
|
+
// idempotency key are retried — callers opt in with `retry: true`. A 429 waits out
|
|
6
|
+
// `Retry-After`; a 5xx, 408, network error or `MISSION_BUSY_RETRY` backs off from 0.5 s
|
|
7
|
+
// towards 8 s with jitter. Five tries, then `UnavailableError` (exit 7).
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { ApiError, CliError, EXIT, UnavailableError } from "./errors.js";
|
|
11
|
+
import { route } from "./routes.js";
|
|
12
|
+
|
|
13
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
|
+
export const USER_AGENT = `syndai-cli/${VERSION}`;
|
|
15
|
+
export const DEFAULT_API_URL = "https://api.syndai.ai";
|
|
16
|
+
|
|
17
|
+
const TRIES = 5;
|
|
18
|
+
const MAX_WAIT_MS = 8_000;
|
|
19
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
export function backoffMs(attempt, random = Math.random) {
|
|
22
|
+
const base = 500 * 2 ** (attempt - 1);
|
|
23
|
+
return Math.min(MAX_WAIT_MS, Math.round(base * (1 + random() / 2)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `Retry-After` as milliseconds (delta-seconds or an HTTP date), or null. */
|
|
27
|
+
export function retryAfterMs(value, now = Date.now()) {
|
|
28
|
+
if (!value) return null;
|
|
29
|
+
const seconds = Number(value);
|
|
30
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
31
|
+
const at = Date.parse(value);
|
|
32
|
+
return Number.isNaN(at) ? null : Math.max(0, at - now);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function interrupted() {
|
|
36
|
+
return new CliError(EXIT.INTERRUPTED, "Interrupted.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{baseUrl: string, getKey: () => Promise<string>, io: object}} options
|
|
41
|
+
*/
|
|
42
|
+
export function createClient({ baseUrl, getKey, io }) {
|
|
43
|
+
/** Call a named route from `routes.js`. */
|
|
44
|
+
function call(name, { params, ...opts } = {}) {
|
|
45
|
+
const [method, path] = route(name, params);
|
|
46
|
+
return send(method, path, opts);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function send(
|
|
50
|
+
method,
|
|
51
|
+
path,
|
|
52
|
+
{ query, body, retry = method === "GET", auth = true, timeoutMs = 30_000, raw = false, signal } = {},
|
|
53
|
+
) {
|
|
54
|
+
const url = new URL(baseUrl + path);
|
|
55
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
56
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
57
|
+
url.searchParams.set(key, String(value));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const headers = { "user-agent": USER_AGENT, accept: "application/json" };
|
|
61
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
62
|
+
if (auth) headers.authorization = `Bearer ${await getKey()}`;
|
|
63
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
64
|
+
|
|
65
|
+
for (let attempt = 1; ; attempt++) {
|
|
66
|
+
let res;
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
res = await fetch(url, {
|
|
70
|
+
method,
|
|
71
|
+
headers,
|
|
72
|
+
body: payload,
|
|
73
|
+
signal: AbortSignal.any([io.signal, AbortSignal.timeout(timeoutMs), signal].filter(Boolean)),
|
|
74
|
+
});
|
|
75
|
+
parsed = await readBody(res);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (io.signal.aborted) throw interrupted();
|
|
78
|
+
if (signal?.aborted) throw signal.reason;
|
|
79
|
+
if (!retry || attempt >= TRIES) throw new UnavailableError(networkReason(err));
|
|
80
|
+
await pause(backoffMs(attempt), signal);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const problem = res.ok ? null : new ApiError(res.status, parsed, res.headers);
|
|
85
|
+
const transient =
|
|
86
|
+
problem !== null &&
|
|
87
|
+
(res.status >= 500 ||
|
|
88
|
+
res.status === 408 ||
|
|
89
|
+
(res.status === 429 && (!problem.code || problem.code === "RATE_LIMITED")) ||
|
|
90
|
+
problem.code === "MISSION_BUSY_RETRY");
|
|
91
|
+
if (!transient) {
|
|
92
|
+
if (raw) return { status: res.status, body: parsed, headers: res.headers };
|
|
93
|
+
if (problem) throw problem;
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
if (!retry || attempt >= TRIES) {
|
|
97
|
+
throw new UnavailableError(`HTTP ${res.status}${problem.code ? ` ${problem.code}` : ""}`);
|
|
98
|
+
}
|
|
99
|
+
const hinted = res.status === 429 ? retryAfterMs(res.headers.get("retry-after")) : null;
|
|
100
|
+
const wait = hinted ?? backoffMs(attempt);
|
|
101
|
+
if (wait > MAX_RETRY_AFTER_MS) {
|
|
102
|
+
throw new UnavailableError(`rate-limited for ${Math.ceil(wait / 1000)} s`);
|
|
103
|
+
}
|
|
104
|
+
await pause(wait, signal);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Open a streaming GET (SSE). Returns the Response; non-2xx throws ApiError. */
|
|
109
|
+
async function stream(name, { params, query, headers = {}, signal } = {}) {
|
|
110
|
+
const [, path] = route(name, params);
|
|
111
|
+
const url = new URL(baseUrl + path);
|
|
112
|
+
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, value);
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
headers: {
|
|
115
|
+
"user-agent": USER_AGENT,
|
|
116
|
+
accept: "text/event-stream",
|
|
117
|
+
authorization: `Bearer ${await getKey()}`,
|
|
118
|
+
...headers,
|
|
119
|
+
},
|
|
120
|
+
signal: AbortSignal.any([io.signal, signal].filter(Boolean)),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) throw new ApiError(res.status, await readBody(res), res.headers);
|
|
123
|
+
return res;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function pause(ms, signal) {
|
|
127
|
+
try {
|
|
128
|
+
await io.sleep(ms, AbortSignal.any([io.signal, signal].filter(Boolean)));
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (io.signal.aborted) throw interrupted();
|
|
131
|
+
if (signal?.aborted) throw signal.reason;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { call, send, stream, baseUrl };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readBody(res) {
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
if (!text) return null;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
return { detail: text.slice(0, 500) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function networkReason(err) {
|
|
150
|
+
if (err?.name === "TimeoutError") return "timed out";
|
|
151
|
+
const code = err?.cause?.code ?? err?.code;
|
|
152
|
+
return code ? String(code) : String(err?.cause?.message ?? err?.message ?? "network error");
|
|
153
|
+
}
|