syndai 0.3.0 → 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 CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to the `syndai` npm package. Versions follow semver.
4
4
 
5
+ ## 0.4.0
6
+
7
+ Every published API operation, and a project's recurring runs.
8
+
9
+ - `api <operationId>` calls any operation in the live API description (`/openapi.json`,
10
+ cached for 24 hours). `api` lists them; `api <operationId> --help` shows the parameters
11
+ and body. Fields come gh-style: `-f key=value` (string), `-F key=value` (true, false,
12
+ null, a number, or `@file`), `key[]=value` for a list. `--input FILE|-` sends a whole
13
+ body. `--paginate` follows `next_cursor` and prints one list. The workspace and this
14
+ directory's project fill required path parameters. `api <METHOD> <path>` still works.
15
+ - `schedule list|show|add|edit|pause|resume|run-now|delete`: a project's recurring runs,
16
+ with cadence presets (`--every hourly|daily|weekdays|weekly` and `--at`, or a cron),
17
+ `--tz`, and a per-run cap (`--budget`).
18
+ - `repeat <run> --every ...` repeats a run you liked. The server copies its original
19
+ prompt and project; the first run is the next scheduled time.
20
+ - `login --scope S` asks for more access. A command that needs a scope the stored key
21
+ lacks asks once, on a terminal, to approve it in the browser and then retries.
22
+ `SYNDAI_API_KEY` never does this. Re-login now revokes the key it replaces.
23
+
24
+ Breaking:
25
+
26
+ - `--json` is machine output on every command. The request body on `api` and `answer`
27
+ is now `--input FILE|-` (the old string `--json` body is gone).
28
+
5
29
  ## 0.3.0
6
30
 
7
31
  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
- `apps/portal/content/docs/cli.mdx` (rendered as the docs site's **CLI** page);
14
- this README is for whoever works on the package.
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]`, `logout`, `whoami` | browser-approved key (device flow); `--with-token` reads a key from stdin |
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> [--json body]` | answer an open question |
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
- | `api <METHOD> <path> [--json body]` | call any route with your key |
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/changes.js`, `src/settings.js`, `src/mcp.js`: the commands.
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
- The suite runs a fake API on 127.0.0.1 and drives the CLI in-process (plus two
56
- child-process tests for Ctrl-C and a clean exit). `test/contract.test.js` checks
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.0",
3
+ "version": "0.4.0",
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,111 @@ function storedCredential(io, paths) {
27
27
  return { apiKey: entry.api_key, customerId, source: paths.credentials };
28
28
  }
29
29
 
30
- /** Per-invocation state shared by every command. */
31
- export function createContext(io, flags) {
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 client = clientFor(async () => credential().apiKey);
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 this.me()).customer_id;
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
+ });
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
+ }
61
124
  }
62
125
 
63
126
  /**
64
127
  * Ask for a key and wait until an owner approves it in the browser. Returns the token
65
128
  * response `{api_key, customer_id, key_id, expires_at}`.
66
129
  */
67
- export async function deviceApproval(ctx, clientName) {
130
+ export async function deviceApproval(ctx, clientName, scopes) {
68
131
  const { client, ui, io } = ctx;
69
132
  const start = await client.call("deviceStart", {
70
- body: { client_name: clientName, host: hostname() },
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 } : {}) },
71
135
  auth: false,
72
136
  retry: true,
73
137
  });
@@ -114,7 +178,15 @@ export async function deviceApproval(ctx, clientName) {
114
178
  }
115
179
 
116
180
  export async function login(ctx) {
117
- const { io, ui, paths, flags } = 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
+ }
118
190
  let token;
119
191
  if (flags["with-token"]) {
120
192
  if (io.stdin.isTTY) ui.progress("Paste a Syndai API key, then press Ctrl-D:");
@@ -123,9 +195,9 @@ export async function login(ctx) {
123
195
  const me = await ctx.clientFor(async () => apiKey).call("me");
124
196
  token = { api_key: apiKey, customer_id: me.customer_id, key_id: me.key_id, expires_at: me.expires_at };
125
197
  } else {
126
- token = await deviceApproval(ctx, "CLI");
198
+ token = await deviceApproval(ctx, "CLI", extra.length ? await scopesWith(ctx, extra) : undefined);
127
199
  }
128
- saveCredential(paths, token);
200
+ await storeKey(ctx, token);
129
201
  if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is set and wins over this login."));
130
202
  const expires = token.expires_at ? ` until ${token.expires_at.slice(0, 10)}` : "";
131
203
  ui.result(
@@ -171,9 +243,3 @@ export async function whoami(ctx) {
171
243
  );
172
244
  return EXIT.OK;
173
245
  }
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 (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;
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.hitl_request_id ?? (questions.length === 1 ? questions[0].hitl_request_id : null);
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
- "--json needs entries, and hitl_request_id when several questions are open.",
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.mdx "Exit codes"):
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: [EXIT.AUTH, "This key is not allowed to do that.", "syndai login"],
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],