syndai 0.3.0 → 0.4.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/CHANGELOG.md +30 -0
- package/README.md +30 -9
- package/package.json +1 -1
- package/src/api.js +202 -0
- package/src/auth.js +96 -21
- package/src/changes.js +57 -14
- package/src/errors.js +22 -2
- package/src/http.js +30 -6
- package/src/main.js +50 -13
- package/src/openapi.js +145 -0
- package/src/projects.js +1 -1
- package/src/routes.js +10 -1
- package/src/schedules.js +377 -0
- package/src/settings.js +1 -31
- package/src/store.js +3 -0
- package/src/ui.js +23 -0
- package/src/watch.js +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `syndai` npm package. Versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.4.1
|
|
6
|
+
|
|
7
|
+
- A running `watch`, `change` or MCP server no longer stops when `login` or a scope
|
|
8
|
+
approval in another terminal replaces its key. On a 401 the CLI reads the stored key
|
|
9
|
+
again once, and retries with it if it changed. `SYNDAI_API_KEY` is never re-read.
|
|
10
|
+
|
|
11
|
+
## 0.4.0
|
|
12
|
+
|
|
13
|
+
Every published API operation, and a project's recurring runs.
|
|
14
|
+
|
|
15
|
+
- `api <operationId>` calls any operation in the live API description (`/openapi.json`,
|
|
16
|
+
cached for 24 hours). `api` lists them; `api <operationId> --help` shows the parameters
|
|
17
|
+
and body. Fields come gh-style: `-f key=value` (string), `-F key=value` (true, false,
|
|
18
|
+
null, a number, or `@file`), `key[]=value` for a list. `--input FILE|-` sends a whole
|
|
19
|
+
body. `--paginate` follows `next_cursor` and prints one list. The workspace and this
|
|
20
|
+
directory's project fill required path parameters. `api <METHOD> <path>` still works.
|
|
21
|
+
- `schedule list|show|add|edit|pause|resume|run-now|delete`: a project's recurring runs,
|
|
22
|
+
with cadence presets (`--every hourly|daily|weekdays|weekly` and `--at`, or a cron),
|
|
23
|
+
`--tz`, and a per-run cap (`--budget`).
|
|
24
|
+
- `repeat <run> --every ...` repeats a run you liked. The server copies its original
|
|
25
|
+
prompt and project; the first run is the next scheduled time.
|
|
26
|
+
- `login --scope S` asks for more access. A command that needs a scope the stored key
|
|
27
|
+
lacks asks once, on a terminal, to approve it in the browser and then retries.
|
|
28
|
+
`SYNDAI_API_KEY` never does this. Re-login now revokes the key it replaces.
|
|
29
|
+
|
|
30
|
+
Breaking:
|
|
31
|
+
|
|
32
|
+
- `--json` is machine output on every command. The request body on `api` and `answer`
|
|
33
|
+
is now `--input FILE|-` (the old string `--json` body is gone).
|
|
34
|
+
|
|
5
35
|
## 0.3.0
|
|
6
36
|
|
|
7
37
|
The package becomes the Syndai owner CLI.
|
package/README.md
CHANGED
|
@@ -9,26 +9,37 @@ npx -y syndai@latest change "make the header blue"
|
|
|
9
9
|
# Changing bakery.syndai.app · goes live when checks pass
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Node 22 or newer, zero dependencies, no telemetry. The customer guide is
|
|
13
|
-
`
|
|
14
|
-
|
|
12
|
+
Node 22 or newer, zero dependencies, no telemetry. The customer guide is the docs
|
|
13
|
+
site's **CLI** page, https://syndai.ai/docs/cli (`web/content/docs/cli.mdx`); this
|
|
14
|
+
README is for whoever works on the package.
|
|
15
15
|
|
|
16
16
|
## Commands
|
|
17
17
|
|
|
18
18
|
| command | does |
|
|
19
19
|
|---|---|
|
|
20
|
-
| `login [--with-token]
|
|
20
|
+
| `login [--with-token] [--scope S]...`, `logout`, `whoami` | browser-approved key (device flow); `--scope` asks for access on top of the key's current scopes; `--with-token` reads a key from stdin. Re-login saves the new key, then revokes the old one |
|
|
21
21
|
| `link [project]` | remember which project this directory changes (`$XDG_CONFIG_HOME/syndai/links.json`, never in the repo) |
|
|
22
22
|
| `change "<what>" [--app A] [--review] [--image F]... [--no-wait] [--timeout D]` | start a change and follow it to the outcome |
|
|
23
|
+
| `new "<what>" [--no-learn] [--no-wait] [--timeout D]` | start a New project; a product URL in the text builds your own version of it (its feature pick is asked inline, Enter keeps the pre-checked core) |
|
|
23
24
|
| `edit-text [--app A] [--page /] --from "..." --to "..."` / `--undo <sha>` | instant, free, undoable text swap on a live site |
|
|
24
25
|
| `watch <run> [--timeout D] [--logs]` | follow any change; `--logs` tails its events |
|
|
25
|
-
| `answer <run> [--
|
|
26
|
+
| `answer <run> [--input FILE\|-]` | answer an open question (`--input`: `{"entries":[...]}` from a file or stdin) |
|
|
26
27
|
| `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
|
|
27
28
|
| `go-live`, `revert`, `cancel <run>` | land actions |
|
|
28
29
|
| `status [--app A]` | what is running, and everything that needs you |
|
|
29
30
|
| `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
|
|
30
31
|
| `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
|
|
31
|
-
| `
|
|
32
|
+
| `schedule list [--app A \| --all]`, `show <id>` | a project's recurring runs: cadence, next fires, History |
|
|
33
|
+
| `schedule add "<what>" --every E [--at T] [--tz Z] [--budget USD] [--app A]` | create one; `E` is `hourly` (`--at :MM`), `daily`/`weekdays` (`--at HH:MM`), `weekly` (`--at "mon HH:MM"`) or a 5-field cron |
|
|
34
|
+
| `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget USD\|none]` | change only what differs |
|
|
35
|
+
| `schedule pause \| resume \| run-now \| delete <id> [--yes]` | manage one (`delete` asks on a terminal, needs `--yes` off one) |
|
|
36
|
+
| `repeat <run> --every E [--at T] [--tz Z] [--budget USD]` | repeat a run you liked; the server copies its prompt and project; the first run is the next scheduled time |
|
|
37
|
+
| `api` / `api <operationId> --help` | list every published operation / show one's parameters and body |
|
|
38
|
+
| `api <operationId> \| <METHOD> <path> [-f k=v]... [-F k=v]... [--input FILE\|-] [--paginate] [--app A]` | call it with your key (`-f` string, `-F` typed, `k[]=v` lists) |
|
|
39
|
+
|
|
40
|
+
A 403 that names a missing scope asks, on a terminal with a stored login, to approve
|
|
41
|
+
it in the browser and retries once. `SYNDAI_API_KEY` never steps up; off a terminal the
|
|
42
|
+
error names `syndai login --scope <scope>`. Members, keys and unpublish are portal-only.
|
|
32
43
|
|
|
33
44
|
Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
|
|
34
45
|
4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
|
|
@@ -44,7 +55,10 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
|
44
55
|
- `src/auth.js`, `src/store.js`: credentials (0600, by workspace), links, pending change ids.
|
|
45
56
|
- `src/projects.js`: which project a command acts on.
|
|
46
57
|
- `src/watch.js`: the ChangeState long-poll loop, questions, `--logs`.
|
|
47
|
-
- `src/
|
|
58
|
+
- `src/openapi.js`: the live `/openapi.json` (cached 24h per host in `$XDG_CACHE_HOME/syndai`).
|
|
59
|
+
- `src/api.js`: `syndai api` (operations by id, fields, `--paginate`).
|
|
60
|
+
- `src/schedules.js`: `schedule` and `repeat` (the preset to cron mapping of `apps/portal-v2/src/lib/cadence.ts`).
|
|
61
|
+
- `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
|
|
48
62
|
|
|
49
63
|
## Development
|
|
50
64
|
|
|
@@ -52,8 +66,15 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
|
52
66
|
cd tools/syndai-connect && node --test
|
|
53
67
|
```
|
|
54
68
|
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
CI runs it in its own **CLI Checks** job (`pr-ci.yml`, `main-ci.yml`) whenever
|
|
70
|
+
`tools/syndai-connect/**`, `docs/api/**` or the portal's `cadence.ts` changes; it is
|
|
71
|
+
part of **PR Gate** and deploys nothing.
|
|
72
|
+
|
|
73
|
+
The suite runs a fake API on 127.0.0.1 (serving `/openapi.json` from the snapshot) and
|
|
74
|
+
drives the CLI in-process (plus two child-process tests for Ctrl-C and a clean exit).
|
|
75
|
+
`test/schedule.test.js` imports `apps/portal-v2/src/lib/cadence.ts` to prove the
|
|
76
|
+
`--every` presets give the portal's cron; that needs a Node that strips TypeScript
|
|
77
|
+
types (23.6+, as `.nvmrc`), and is skipped on Node 22. `test/contract.test.js` checks
|
|
57
78
|
every route in `src/routes.js` against the published OpenAPI,
|
|
58
79
|
`docs/api/external-coding-v1.openapi.json`. It goes red when a backend route the
|
|
59
80
|
CLI uses is renamed or not yet published; regenerate the snapshot with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The Syndai owner CLI: say what to change, and Syndai builds it, checks it and puts it live. Also installs the Syndai MCP server into Claude Code, Cursor and Codex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/api.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// `syndai api`: every published operation, by operationId (from the live OpenAPI, see
|
|
2
|
+
// openapi.js) or as a raw `METHOD path`. Fields come gh-style: `-f key=value` is a string,
|
|
3
|
+
// `-F key=value` is typed (true, false, null, a number, or @file for a file's text), and
|
|
4
|
+
// `key[]=value` builds a list. A path-parameter field fills the path; any other field goes to
|
|
5
|
+
// the query on GET and DELETE and to the JSON body otherwise. `--input FILE|-` is the whole
|
|
6
|
+
// body instead. The step-up on a missing scope happens in http.js, so it covers this too.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ApiError, CliError, EXIT, explain } from "./errors.js";
|
|
11
|
+
import { findOperation, HTTP_METHODS, loadOperations, matchPath, shortPath } from "./openapi.js";
|
|
12
|
+
import { resolveProject } from "./projects.js";
|
|
13
|
+
import { readJsonInput } from "./ui.js";
|
|
14
|
+
|
|
15
|
+
const EXT = "/api/v1/external/coding";
|
|
16
|
+
const MAX_PAGES = 50;
|
|
17
|
+
const USAGE = "syndai api [<operationId> | <METHOD> <path>] [-f k=v]... [-F k=v]... [--input F] [--paginate]";
|
|
18
|
+
|
|
19
|
+
export async function api(ctx, args) {
|
|
20
|
+
const { flags } = ctx;
|
|
21
|
+
const [first, second, ...extra] = args;
|
|
22
|
+
if (!first) return listOperations(ctx);
|
|
23
|
+
const raw = HTTP_METHODS.includes(first.toUpperCase());
|
|
24
|
+
if (extra.length || (raw && !second) || (!raw && second)) {
|
|
25
|
+
throw new CliError(EXIT.USAGE, `Usage: ${USAGE}`, { next: "syndai api" });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let op = null;
|
|
29
|
+
let method;
|
|
30
|
+
let template;
|
|
31
|
+
const query = {};
|
|
32
|
+
if (raw) {
|
|
33
|
+
method = first.toUpperCase();
|
|
34
|
+
const full = second.startsWith("/api/") ? second : `${EXT}/${second.replace(/^\/+/, "")}`;
|
|
35
|
+
const [pathOnly, search = ""] = full.split("?");
|
|
36
|
+
template = pathOnly;
|
|
37
|
+
Object.assign(query, Object.fromEntries(new URLSearchParams(search)));
|
|
38
|
+
if (flags.help) {
|
|
39
|
+
ctx.io.stdout.write(`usage: ${USAGE}\n`);
|
|
40
|
+
return EXIT.OK;
|
|
41
|
+
}
|
|
42
|
+
if (flags.paginate) op = matchPath((await loadOperations(ctx)).operations, method, pathOnly);
|
|
43
|
+
} else {
|
|
44
|
+
op = await findOperation(ctx, first);
|
|
45
|
+
if (flags.help) return describeOperation(ctx, op);
|
|
46
|
+
method = op.method;
|
|
47
|
+
template = op.path;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pathParams = new Set(
|
|
51
|
+
op && !raw ? op.params.filter((p) => p.in === "path").map((p) => p.name) : [],
|
|
52
|
+
);
|
|
53
|
+
const filled = {};
|
|
54
|
+
const body = {};
|
|
55
|
+
for (const [key, value] of Object.entries(collectFields(ctx))) {
|
|
56
|
+
if (pathParams.has(key)) filled[key] = value;
|
|
57
|
+
else if (method === "GET" || method === "DELETE") query[key] = value;
|
|
58
|
+
else body[key] = value;
|
|
59
|
+
}
|
|
60
|
+
let payload = Object.keys(body).length ? body : undefined;
|
|
61
|
+
if (flags.input !== undefined) {
|
|
62
|
+
if (payload) throw new CliError(EXIT.USAGE, "--input is the whole body; drop the body fields or --input.");
|
|
63
|
+
payload = await readJsonInput(ctx.io, flags.input);
|
|
64
|
+
}
|
|
65
|
+
for (const name of pathParams) {
|
|
66
|
+
if (filled[name] === undefined) filled[name] = await autoFill(ctx, op, name);
|
|
67
|
+
}
|
|
68
|
+
const target = template.replace(/\{(\w+)\}/g, (_, key) => encodeURIComponent(String(filled[key])));
|
|
69
|
+
|
|
70
|
+
if (flags.paginate) return paginate(ctx, op, method, target, query);
|
|
71
|
+
const res = await ctx.client.send(method, target, { query, body: payload, raw: true });
|
|
72
|
+
return printResponse(ctx, res);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `-f`/`-F` pairs as one object; a repeated key without `[]` is a usage error. */
|
|
76
|
+
function collectFields(ctx) {
|
|
77
|
+
const fields = {};
|
|
78
|
+
const add = (pair, typed) => {
|
|
79
|
+
const eq = pair.indexOf("=");
|
|
80
|
+
if (eq < 1) throw new CliError(EXIT.USAGE, `Fields are key=value, not "${pair}".`);
|
|
81
|
+
let key = pair.slice(0, eq);
|
|
82
|
+
const value = typed ? typedValue(ctx, pair.slice(eq + 1)) : pair.slice(eq + 1);
|
|
83
|
+
const list = key.endsWith("[]");
|
|
84
|
+
if (list) key = key.slice(0, -2);
|
|
85
|
+
if (list && (fields[key] === undefined || Array.isArray(fields[key]))) {
|
|
86
|
+
fields[key] = [...(fields[key] ?? []), value];
|
|
87
|
+
} else if (key in fields) {
|
|
88
|
+
throw new CliError(EXIT.USAGE, `${key} is given twice; use ${key}[]=... for a list.`);
|
|
89
|
+
} else {
|
|
90
|
+
fields[key] = value;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const pair of ctx.flags["raw-field"] ?? []) add(pair, false);
|
|
94
|
+
for (const pair of ctx.flags.field ?? []) add(pair, true);
|
|
95
|
+
return fields;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function typedValue(ctx, text) {
|
|
99
|
+
if (text === "true") return true;
|
|
100
|
+
if (text === "false") return false;
|
|
101
|
+
if (text === "null") return null;
|
|
102
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
|
103
|
+
if (text.startsWith("@")) {
|
|
104
|
+
try {
|
|
105
|
+
return fs.readFileSync(path.resolve(ctx.io.cwd, text.slice(1)), "utf8");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new CliError(EXIT.USAGE, `Cannot read ${text.slice(1)} (${err.code ?? err.message}).`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Only required path parameters fill themselves: the workspace, and this directory's project. */
|
|
114
|
+
async function autoFill(ctx, op, name) {
|
|
115
|
+
if (name === "customer_id") return ctx.customerId();
|
|
116
|
+
if (name === "coding_repository_id" || name === "repository_id") {
|
|
117
|
+
return (await resolveProject(ctx, { app: ctx.flags.app, picker: false })).id;
|
|
118
|
+
}
|
|
119
|
+
throw new CliError(EXIT.USAGE, `${op.id} needs ${name}.`, { next: `syndai api ${op.id} -f ${name}=...` });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function paginate(ctx, op, method, target, query) {
|
|
123
|
+
if (method !== "GET") throw new CliError(EXIT.USAGE, "--paginate works on GET operations only.");
|
|
124
|
+
const names = new Set((op?.params ?? []).map((p) => p.name));
|
|
125
|
+
const cursorParam = ["cursor", "after"].find((name) => names.has(name));
|
|
126
|
+
if (!cursorParam) {
|
|
127
|
+
throw new CliError(EXIT.USAGE, `${op?.id ?? target} does not page (no cursor or after parameter).`);
|
|
128
|
+
}
|
|
129
|
+
const items = [];
|
|
130
|
+
for (let page = 1; ; page++) {
|
|
131
|
+
const res = await ctx.client.send(method, target, { query, raw: true });
|
|
132
|
+
if (res.status >= 400) return printResponse(ctx, res);
|
|
133
|
+
const arrays = Object.entries(res.body ?? {}).filter(([, value]) => Array.isArray(value));
|
|
134
|
+
if (arrays.length !== 1) {
|
|
135
|
+
throw new CliError(EXIT.FAILED, `${op.id} does not answer with one list, so it cannot be paged.`);
|
|
136
|
+
}
|
|
137
|
+
items.push(...arrays[0][1]);
|
|
138
|
+
const next = res.body.next_cursor;
|
|
139
|
+
if (!next) break;
|
|
140
|
+
if (page >= MAX_PAGES) {
|
|
141
|
+
ctx.ui.progress(ctx.ui.yellow(`Stopped after ${MAX_PAGES} pages; narrow the query to see the rest.`));
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
query[cursorParam] = next;
|
|
145
|
+
}
|
|
146
|
+
ctx.io.stdout.write(`${JSON.stringify(items, null, ctx.ui.json ? 0 : 2)}\n`);
|
|
147
|
+
return EXIT.OK;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The body as JSON on stdout (problem bodies too); an error line and its next step on stderr. */
|
|
151
|
+
function printResponse(ctx, res) {
|
|
152
|
+
const { io, ui } = ctx;
|
|
153
|
+
if (res.body !== null && res.body !== undefined) {
|
|
154
|
+
io.stdout.write(`${JSON.stringify(res.body, null, ui.json ? 0 : 2)}\n`);
|
|
155
|
+
}
|
|
156
|
+
if (res.status < 400) return EXIT.OK;
|
|
157
|
+
const { exitCode, message, next } = explain(new ApiError(res.status, res.body, res.headers));
|
|
158
|
+
ui.progress(`${ui.red("error:")} ${message}`);
|
|
159
|
+
if (next) ui.progress(` next: ${next}`);
|
|
160
|
+
return exitCode;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function listOperations(ctx) {
|
|
164
|
+
const { ui } = ctx;
|
|
165
|
+
const { operations } = await loadOperations(ctx);
|
|
166
|
+
const sorted = [...operations].sort((a, b) => a.group.localeCompare(b.group) || a.id.localeCompare(b.id));
|
|
167
|
+
const width = Math.max(...sorted.map((op) => op.id.length));
|
|
168
|
+
const lines = [];
|
|
169
|
+
let group = null;
|
|
170
|
+
for (const op of sorted) {
|
|
171
|
+
if (op.group !== group) {
|
|
172
|
+
group = op.group;
|
|
173
|
+
lines.push(`${lines.length ? "\n" : ""}${ui.bold(group)}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(` ${op.id.padEnd(width)} ${op.method.padEnd(6)} ${shortPath(op.path)}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push(
|
|
178
|
+
"",
|
|
179
|
+
`usage: ${USAGE}`,
|
|
180
|
+
"Details of one: syndai api <operationId> --help. Paths are relative to /api/v1/external/coding.",
|
|
181
|
+
);
|
|
182
|
+
ui.result(
|
|
183
|
+
{ operations: sorted.map(({ id, method, path: opPath, summary }) => ({ operation_id: id, method, path: opPath, summary })) },
|
|
184
|
+
lines.join("\n"),
|
|
185
|
+
);
|
|
186
|
+
return EXIT.OK;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function describeOperation(ctx, op) {
|
|
190
|
+
const lines = [`${ctx.ui.bold(op.id)} ${op.method} ${op.path}`];
|
|
191
|
+
if (op.summary) lines.push(op.summary);
|
|
192
|
+
if (op.params.length) {
|
|
193
|
+
lines.push("", "Parameters (-f name=value):");
|
|
194
|
+
for (const p of op.params) lines.push(` ${p.name} ${p.in}${p.required ? ", required" : ""}`);
|
|
195
|
+
}
|
|
196
|
+
if (op.body) {
|
|
197
|
+
lines.push("", `Body${op.body.schema ? ` (${op.body.schema})` : ""}: fields, or --input FILE`);
|
|
198
|
+
for (const f of op.body.fields) lines.push(` ${f.name} ${f.type}${f.required ? ", required" : ""}`);
|
|
199
|
+
}
|
|
200
|
+
ctx.ui.result(op, lines.join("\n"));
|
|
201
|
+
return EXIT.OK;
|
|
202
|
+
}
|
package/src/auth.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// There is no --api-key flag, so a key never lands in shell history.
|
|
7
7
|
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
|
-
import { ApiError, CliError, EXIT } from "./errors.js";
|
|
9
|
+
import { ApiError, CliError, EXIT, explain, PORTAL_URL, steppable } from "./errors.js";
|
|
10
10
|
import { createClient, DEFAULT_API_URL, interrupted } from "./http.js";
|
|
11
11
|
import { findLink, loadCredentials, removeCredential, saveCredential, storePaths } from "./store.js";
|
|
12
|
-
import { createUi } from "./ui.js";
|
|
12
|
+
import { ask, createUi, readAll } from "./ui.js";
|
|
13
13
|
|
|
14
14
|
/** The key this invocation uses: env first, then the cwd link's workspace, then the default. */
|
|
15
15
|
export function resolveCredential(io, paths) {
|
|
@@ -27,47 +27,120 @@ function storedCredential(io, paths) {
|
|
|
27
27
|
return { apiKey: entry.api_key, customerId, source: paths.credentials };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
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 } = {}) {
|
|
32
35
|
const paths = storePaths(io.env);
|
|
33
36
|
const ui = createUi(io, { json: flags.json === true, noInput: flags["no-input"] === true });
|
|
34
37
|
const baseUrl = (io.env.SYNDAI_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
35
38
|
let cached;
|
|
39
|
+
let me;
|
|
36
40
|
const credential = () => {
|
|
37
41
|
cached ??= resolveCredential(io, paths);
|
|
38
42
|
if (!cached) throw new CliError(EXIT.AUTH, "You are not logged in.", { next: "syndai login" });
|
|
39
43
|
return cached;
|
|
40
44
|
};
|
|
41
45
|
const clientFor = (getKey) => createClient({ baseUrl, getKey, io });
|
|
42
|
-
const
|
|
43
|
-
let me;
|
|
44
|
-
return {
|
|
46
|
+
const ctx = {
|
|
45
47
|
io,
|
|
46
48
|
ui,
|
|
47
49
|
paths,
|
|
48
50
|
flags,
|
|
49
51
|
baseUrl,
|
|
50
|
-
client,
|
|
51
52
|
clientFor,
|
|
52
53
|
credential,
|
|
54
|
+
/** Forget the memoised key and `/me` (after a new key is stored). */
|
|
55
|
+
reset() {
|
|
56
|
+
cached = undefined;
|
|
57
|
+
me = undefined;
|
|
58
|
+
},
|
|
53
59
|
async me() {
|
|
54
|
-
me ??= await client.call("me");
|
|
60
|
+
me ??= await ctx.client.call("me");
|
|
55
61
|
return me;
|
|
56
62
|
},
|
|
57
63
|
async customerId() {
|
|
58
|
-
return credential().customerId ?? (await
|
|
64
|
+
return credential().customerId ?? (await ctx.me()).customer_id;
|
|
59
65
|
},
|
|
60
66
|
};
|
|
67
|
+
ctx.client = createClient({
|
|
68
|
+
baseUrl,
|
|
69
|
+
getKey: async () => credential().apiKey,
|
|
70
|
+
io,
|
|
71
|
+
onMissingScope: stepUp ? (scope) => stepUpScope(ctx, scope) : undefined,
|
|
72
|
+
onUnauthorized: async () => {
|
|
73
|
+
// A login or step-up in another process saves a new key, then revokes this one.
|
|
74
|
+
if (!cached || cached.source === "SYNDAI_API_KEY") return false;
|
|
75
|
+
const fresh = storedCredential(io, paths);
|
|
76
|
+
if (!fresh || fresh.apiKey === cached.apiKey) return false;
|
|
77
|
+
cached = fresh;
|
|
78
|
+
me = undefined;
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
return ctx;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A 403 named the scope it needs. On a terminal, with the stored login (never
|
|
87
|
+
* SYNDAI_API_KEY, which belongs to CI), ask once, approve a key with the current scopes plus
|
|
88
|
+
* that one in the browser, store it, and let the client retry. Declining mints nothing.
|
|
89
|
+
*/
|
|
90
|
+
async function stepUpScope(ctx, scope) {
|
|
91
|
+
const { io, ui } = ctx;
|
|
92
|
+
if (!ui.interactive || !steppable(scope) || ctx.credential().source === "SYNDAI_API_KEY") return false;
|
|
93
|
+
const answer = await ask(io, `This needs the ${scope} scope. Approve it in your browser? [Y/n] `);
|
|
94
|
+
if (/^n/i.test(answer)) return false;
|
|
95
|
+
const token = await deviceApproval(ctx, "CLI", await scopesWith(ctx, [scope]));
|
|
96
|
+
await storeKey(ctx, token);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The stored key's scopes plus `extra` (an unknown or dead stored key adds nothing). */
|
|
101
|
+
async function scopesWith(ctx, extra) {
|
|
102
|
+
const stored = storedCredential(ctx.io, ctx.paths);
|
|
103
|
+
let current = [];
|
|
104
|
+
if (stored) {
|
|
105
|
+
try {
|
|
106
|
+
current = (await ctx.clientFor(async () => stored.apiKey).call("me")).scopes ?? [];
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (!(err instanceof ApiError && err.status === 401)) throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [...new Set([...current, ...extra])];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Save a new key, THEN revoke the key it replaces (best-effort: a failed revoke only warns,
|
|
116
|
+
* so the new key is never lost). The same key id again is not revoked.
|
|
117
|
+
*/
|
|
118
|
+
async function storeKey(ctx, token) {
|
|
119
|
+
const { paths, ui } = ctx;
|
|
120
|
+
const old = loadCredentials(paths).customers[token.customer_id];
|
|
121
|
+
saveCredential(paths, token);
|
|
122
|
+
ctx.reset();
|
|
123
|
+
if (!old || old.api_key === token.api_key || (old.key_id && old.key_id === token.key_id)) return;
|
|
124
|
+
try {
|
|
125
|
+
await ctx.clientFor(async () => old.api_key).call("revokeMe");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof ApiError && err.status === 401) return; // already revoked or expired
|
|
128
|
+
if (err instanceof CliError && err.exitCode === EXIT.INTERRUPTED) throw err;
|
|
129
|
+
ui.progress(
|
|
130
|
+
ui.yellow(`Warning: the previous key was not revoked (${explain(err).message}). Revoke it at ${PORTAL_URL}.`),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
61
133
|
}
|
|
62
134
|
|
|
63
135
|
/**
|
|
64
136
|
* Ask for a key and wait until an owner approves it in the browser. Returns the token
|
|
65
137
|
* response `{api_key, customer_id, key_id, expires_at}`.
|
|
66
138
|
*/
|
|
67
|
-
export async function deviceApproval(ctx, clientName) {
|
|
139
|
+
export async function deviceApproval(ctx, clientName, scopes) {
|
|
68
140
|
const { client, ui, io } = ctx;
|
|
69
141
|
const start = await client.call("deviceStart", {
|
|
70
|
-
|
|
142
|
+
// No `scopes` means the owner preset (owner_door); a list asks for those on top of it.
|
|
143
|
+
body: { client_name: clientName, host: hostname(), ...(scopes ? { scopes } : {}) },
|
|
71
144
|
auth: false,
|
|
72
145
|
retry: true,
|
|
73
146
|
});
|
|
@@ -114,7 +187,15 @@ export async function deviceApproval(ctx, clientName) {
|
|
|
114
187
|
}
|
|
115
188
|
|
|
116
189
|
export async function login(ctx) {
|
|
117
|
-
const { io, ui,
|
|
190
|
+
const { io, ui, flags } = ctx;
|
|
191
|
+
const extra = flags.scope ?? [];
|
|
192
|
+
const portalOnly = extra.find((scope) => !steppable(scope));
|
|
193
|
+
if (portalOnly) {
|
|
194
|
+
throw new CliError(EXIT.USAGE, `${portalOnly} is granted in the portal only.`, { next: PORTAL_URL });
|
|
195
|
+
}
|
|
196
|
+
if (extra.length && flags["with-token"]) {
|
|
197
|
+
throw new CliError(EXIT.USAGE, "--scope asks the browser for a key; it does not go with --with-token.");
|
|
198
|
+
}
|
|
118
199
|
let token;
|
|
119
200
|
if (flags["with-token"]) {
|
|
120
201
|
if (io.stdin.isTTY) ui.progress("Paste a Syndai API key, then press Ctrl-D:");
|
|
@@ -123,9 +204,9 @@ export async function login(ctx) {
|
|
|
123
204
|
const me = await ctx.clientFor(async () => apiKey).call("me");
|
|
124
205
|
token = { api_key: apiKey, customer_id: me.customer_id, key_id: me.key_id, expires_at: me.expires_at };
|
|
125
206
|
} else {
|
|
126
|
-
token = await deviceApproval(ctx, "CLI");
|
|
207
|
+
token = await deviceApproval(ctx, "CLI", extra.length ? await scopesWith(ctx, extra) : undefined);
|
|
127
208
|
}
|
|
128
|
-
|
|
209
|
+
await storeKey(ctx, token);
|
|
129
210
|
if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is set and wins over this login."));
|
|
130
211
|
const expires = token.expires_at ? ` until ${token.expires_at.slice(0, 10)}` : "";
|
|
131
212
|
ui.result(
|
|
@@ -171,9 +252,3 @@ export async function whoami(ctx) {
|
|
|
171
252
|
);
|
|
172
253
|
return EXIT.OK;
|
|
173
254
|
}
|
|
174
|
-
|
|
175
|
-
async function readAll(stream) {
|
|
176
|
-
let text = "";
|
|
177
|
-
for await (const chunk of stream) text += chunk;
|
|
178
|
-
return text;
|
|
179
|
-
}
|
package/src/changes.js
CHANGED
|
@@ -8,7 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import { ApiError, CliError, EXIT } from "./errors.js";
|
|
9
9
|
import { projectHost, projectLabel, resolveProject } from "./projects.js";
|
|
10
10
|
import { clearPending, findPending, writePending } from "./store.js";
|
|
11
|
-
import { ask, parseDuration, ulid } from "./ui.js";
|
|
11
|
+
import { ask, parseDuration, readJsonInput, ulid } from "./ui.js";
|
|
12
12
|
import { answerInteractively, describe, finish, getChange, nextFor, respond, watchChange } from "./watch.js";
|
|
13
13
|
|
|
14
14
|
const TERMINAL_PHASES = new Set(["completed", "failed", "cancelled"]);
|
|
@@ -65,6 +65,57 @@ export async function change(ctx, words) {
|
|
|
65
65
|
return watchChange(ctx, runId, { timeoutMs });
|
|
66
66
|
}
|
|
67
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
|
+
|
|
68
119
|
async function createRun(ctx, { body }) {
|
|
69
120
|
try {
|
|
70
121
|
const created = await ctx.client.call("createRun", { body, retry: true });
|
|
@@ -111,23 +162,15 @@ export async function answer(ctx, runId) {
|
|
|
111
162
|
ui.result({ ...state, next: nextFor(state) }, `Nothing to answer. ${describe(state)}`);
|
|
112
163
|
return EXIT.OK;
|
|
113
164
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
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;
|
|
165
|
+
if (flags.input !== undefined) {
|
|
166
|
+
const body = await readJsonInput(ctx.io, flags.input);
|
|
167
|
+
const entries = Array.isArray(body) ? body : body?.entries;
|
|
125
168
|
const hitlRequestId =
|
|
126
|
-
body
|
|
169
|
+
body?.hitl_request_id ?? (questions.length === 1 ? questions[0].hitl_request_id : null);
|
|
127
170
|
if (!hitlRequestId || !Array.isArray(entries)) {
|
|
128
171
|
throw new CliError(
|
|
129
172
|
EXIT.USAGE,
|
|
130
|
-
|
|
173
|
+
'--input needs {"entries":[...]}, and hitl_request_id when several questions are open.',
|
|
131
174
|
);
|
|
132
175
|
}
|
|
133
176
|
await respond(ctx, runId, hitlRequestId, entries);
|
package/src/errors.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Exit codes and the one place a server problem becomes a sentence plus the next command.
|
|
2
2
|
//
|
|
3
|
-
// The exit codes are a contract with scripts and agents (docs: cli
|
|
3
|
+
// The exit codes are a contract with scripts and agents (docs: syndai.ai/docs/cli, "Exit codes"):
|
|
4
4
|
// never renumber one.
|
|
5
5
|
|
|
6
6
|
export const EXIT = Object.freeze({
|
|
@@ -41,11 +41,31 @@ export class ApiError extends Error {
|
|
|
41
41
|
export class UnavailableError extends Error {}
|
|
42
42
|
|
|
43
43
|
const BILLING_URL = "https://app.syndai.ai/billing";
|
|
44
|
+
export const PORTAL_URL = "https://app.syndai.ai";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A scope `syndai login --scope` may add with one browser approval. Members, keys and
|
|
48
|
+
* unpublish stay portal-only: the approval page promises a CLI key never manages members or
|
|
49
|
+
* keys, and those powers stay out of a device-code grant.
|
|
50
|
+
*/
|
|
51
|
+
export function steppable(scope) {
|
|
52
|
+
return !/:members:|keys|:unpublish$/.test(scope);
|
|
53
|
+
}
|
|
44
54
|
|
|
45
55
|
// code -> [exit, sentence, next]. Either may be a function of the problem body.
|
|
46
56
|
const KNOWN = {
|
|
47
57
|
INVALID_TOKEN: [EXIT.AUTH, "Your Syndai key is missing, expired or revoked.", "syndai login"],
|
|
48
|
-
INVALID_SCOPE: [
|
|
58
|
+
INVALID_SCOPE: [
|
|
59
|
+
EXIT.AUTH,
|
|
60
|
+
({ required_scope: scope }) =>
|
|
61
|
+
!scope
|
|
62
|
+
? "This key is not allowed to do that."
|
|
63
|
+
: steppable(scope)
|
|
64
|
+
? `This key does not have the ${scope} scope.`
|
|
65
|
+
: `Only the portal can do this (it needs ${scope}).`,
|
|
66
|
+
({ required_scope: scope }) =>
|
|
67
|
+
!scope ? "syndai login" : steppable(scope) ? `syndai login --scope ${scope}` : PORTAL_URL,
|
|
68
|
+
],
|
|
49
69
|
ROLE_REQUIRED: [EXIT.AUTH, "Only workspace owners and admins can do that.", null],
|
|
50
70
|
PLAN_UPGRADE_REQUIRED: [EXIT.AUTH, "Your plan does not include that.", BILLING_URL],
|
|
51
71
|
BUDGET_EXHAUSTED: [EXIT.AUTH, "This workspace is out of credits.", BILLING_URL],
|