syndai 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +43 -5
- package/package.json +1 -1
- package/src/auth.js +44 -0
- package/src/changes.js +53 -3
- package/src/errors.js +42 -14
- package/src/main.js +83 -18
- package/src/portal.js +156 -0
- package/src/projects.js +20 -2
- package/src/routes.js +14 -2
- package/src/schedules.js +14 -6
- package/src/settings.js +11 -5
- package/src/sync.js +389 -0
- package/src/ui.js +5 -0
- package/src/watch.js +35 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `syndai` npm package. Versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.7.0
|
|
6
|
+
|
|
7
|
+
Land your own branch, and keep a folder in a project's Knowledge.
|
|
8
|
+
|
|
9
|
+
- `land [branch] [--app A] [--no-wait]` lands your pushed `people/<you>/<name>` branch on a project
|
|
10
|
+
Syndai hosts: the same checks, review and landing slot as Syndai's own changes, then it watches
|
|
11
|
+
the landing. It prints any live work on the same files first. A `people/<you>/t12` branch
|
|
12
|
+
delivers task t12 when it goes live.
|
|
13
|
+
- `sync <dir> [--app A] [--watch]` keeps a folder (an Obsidian vault, any folder of notes) in
|
|
14
|
+
the project's Knowledge, where its runs and chat search it. Each sync sends every file's
|
|
15
|
+
sha256 and pushes only the new or changed ones: text files (`.md`, `.txt`, `.csv`, `.json`,
|
|
16
|
+
`.yaml` and the like) as their text, any other file by name only. A file deleted here is
|
|
17
|
+
removed there. Hidden files and folders (`.obsidian/`, `.trash/`, `.git/`), `node_modules/`,
|
|
18
|
+
symlinks, files over 10 MB and the patterns in `<dir>/.syndaiignore` stay out.
|
|
19
|
+
- `--watch` syncs again a second after the last change, one sync at a time, until Ctrl-C; an
|
|
20
|
+
edit is searchable within seconds. Only changed files are read again.
|
|
21
|
+
- A paused folder, or a folder of the same name someone else syncs, is refused (exit 1) with the
|
|
22
|
+
server's sentence. It needs `coding:knowledge:sync` (owners and admins); on a terminal the
|
|
23
|
+
first sync asks for it in the browser.
|
|
24
|
+
- Picking the project from the git remote now matches `github.com/<owner>/<repo>` built from the
|
|
25
|
+
project's name. The API no longer sends repo URLs. Versions up to 0.6.0 read those URLs, so
|
|
26
|
+
they skip this rule and fall through to the only project or the picker.
|
|
27
|
+
|
|
28
|
+
## 0.6.0
|
|
29
|
+
|
|
30
|
+
The steps that stay in the browser are one command away, and an agent can sign up.
|
|
31
|
+
|
|
32
|
+
- `signup <email> [--code C]`: a new workspace and its key from a 6-digit code emailed to that
|
|
33
|
+
address, no browser. Continuing accepts the Terms and Privacy Policy (asked y/N on a terminal).
|
|
34
|
+
Off a terminal it mails the code and exits 3; rerun with `--code`, which only verifies. The
|
|
35
|
+
key is stored as `login` stores it. An address that already has a workspace uses `login`.
|
|
36
|
+
- `connect <provider>` prints, and opens on a terminal, the workspace page that connects it
|
|
37
|
+
(`github`, `sentry`, `claude`, `chatgpt`, `openrouter`, `slack` and the social accounts).
|
|
38
|
+
- `billing` shows the plan, the balance and the billing page; `billing packs` lists the credit
|
|
39
|
+
packs; `billing buy <pack>` and `billing plan <tier> [--interval month|year]` open a checkout a
|
|
40
|
+
person pays (nothing is charged until then); `card`, `cancel` and a plan change go to the
|
|
41
|
+
billing page.
|
|
42
|
+
- `publish [--app A]` publishes the project as a hosted app and prints its live URL.
|
|
43
|
+
- Out-of-credits and plan errors now name the workspace's billing page
|
|
44
|
+
(`https://app.syndai.ai/w/<id>/billing`; the old `app.syndai.ai/billing` link was dead), and
|
|
45
|
+
code-host errors its repositories card; both open on a terminal.
|
|
46
|
+
- `watch` and `answer` print a question's connect link ("Connect it here") and open it on a
|
|
47
|
+
terminal.
|
|
48
|
+
|
|
49
|
+
- `schedule add|edit` and `repeat` take `--budget` in credits (1 credit = US$0.25; the lowest
|
|
50
|
+
cap is 20 credits), and `schedule show` and `repeat` print the cap and the last run's cost in
|
|
51
|
+
credits, matching the portal. `--budget 5` used to mean US$5; it now means 5 credits.
|
|
52
|
+
- `watch` (and `change`, `review`, which watch to the outcome) keeps watching through a deploy:
|
|
53
|
+
a run of 502/503s or dropped connections no longer ends it. It says once that Syndai is
|
|
54
|
+
unreachable and gives up only after 10 minutes without an answer.
|
|
55
|
+
- `change --learn URL --sign-in` and `new "<text with a product link>" --sign-in`: before Syndai
|
|
56
|
+
reads the product, the run's page asks you to sign in to it yourself in a secure browser and
|
|
57
|
+
capture the in-app pages to learn from (or skip for public pages only). The CLI prints where to
|
|
58
|
+
do it and never asks for a password. `--sign-in` without a product is a usage error (exit 2).
|
|
59
|
+
|
|
5
60
|
## 0.5.0
|
|
6
61
|
|
|
7
62
|
Environments and secrets from a terminal, Doppler-style.
|
package/README.md
CHANGED
|
@@ -30,27 +30,33 @@ README is for whoever works on the package.
|
|
|
30
30
|
| command | does |
|
|
31
31
|
|---|---|
|
|
32
32
|
| `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 |
|
|
33
|
+
| `signup <email> [--code C]` | a new workspace and its key from a 6-digit emailed code (no browser); states that continuing accepts the Terms and Privacy Policy and asks y/N on a terminal. Off a terminal it mails the code and exits 3; `--code` only verifies (it never mails a new code). Stores the key as `login` does. An address that already has a workspace gets `code_invalid`: use `login` |
|
|
33
34
|
| `link [project] [--env E]` | remember which project (and environment) this directory uses (`$XDG_CONFIG_HOME/syndai/links.json`, never in the repo); a re-link keeps the environment |
|
|
34
35
|
| `env ls`, `env use <env>` | the workspace's environments (current marked); `use` stores one in this directory's link (an environment-only link when there is none). Chosen: `--env`, `SYNDAI_ENV`, the link, the workspace default |
|
|
35
36
|
| `secrets [ls]` | names, scope (shared/project) and updated for the environment and this project; never a value, in text or `--json` |
|
|
36
37
|
| `secrets set KEY`, `rm KEY [--yes]`, `import FILE\|-` [`--project`] | write shared values (or the project's with `--project`); `set` reads the value from a hidden prompt (one line) or stdin (any value, multi-line too), and refuses one on the command line without echoing it |
|
|
37
38
|
| `secrets get KEY [--plain]`, `download [-o FILE]` | read values back: `get` refuses a pipe without `--plain` (then the exact bytes) and `--json`; `download` writes a pipe, or a 0600 file (`-o`, required on a terminal) and warns when git does not ignore it |
|
|
38
39
|
| `run [--env E] [--app A] [--] <command>...` | spawn the command (syndai's options end at its first word; `--` only for a dash-led command) with the values added to its environment only; prints `Injected N values from <env>`; its exit code (a signal: 128+n, not found: 127) is syndai's |
|
|
39
|
-
| `change "<what>" [--app A] [--review] [--learn URL] [--image F]... [--no-wait] [--timeout D]` | start a change and follow it to the outcome; `--learn URL` reads that product's public pages and asks which of its features to add (3 pre-checked), then adds only those |
|
|
40
|
-
| `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) |
|
|
40
|
+
| `change "<what>" [--app A] [--review] [--learn URL [--sign-in]] [--image F]... [--no-wait] [--timeout D]` | start a change and follow it to the outcome; `--learn URL` reads that product's public pages and asks which of its features to add (3 pre-checked), then adds only those; `--sign-in` first has the run's page ask you to sign in to it yourself in a secure browser and capture its in-app pages (never a password here) |
|
|
41
|
+
| `new "<what>" [--no-learn \| --sign-in] [--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); `--sign-in` as for `change` |
|
|
41
42
|
| `edit-text [--app A] [--page /] --from "..." --to "..."` / `--undo <sha>` | instant, free, undoable text swap on a live site |
|
|
42
43
|
| `watch <run> [--timeout D] [--logs]` | follow any change; `--logs` tails its events |
|
|
43
44
|
| `answer <run> [--input FILE\|-]` | answer an open question (`--input`: `{"entries":[...]}` from a file or stdin) |
|
|
44
45
|
| `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
|
|
45
46
|
| `go-live`, `revert`, `cancel <run>` | land actions |
|
|
47
|
+
| `land [branch] [--app A] [--no-wait]` | on a project Syndai hosts, check, review and land your pushed `people/<you>/<name>` branch (default: the current one) |
|
|
48
|
+
| `publish [--app A]` | publish (or redeploy) the project as a hosted app and print its live URL (`coding:apps:publish`) |
|
|
49
|
+
| `connect <provider>` | print, and open on a terminal, the workspace page that connects it: `github` -> `settings#repositories`, `sentry` -> `sentry`, the plan logins, OpenRouter, Slack and social accounts -> `connections?connect=<provider>`. Finish in the browser; no key reads connection status |
|
|
50
|
+
| `billing [--open]`, `packs`, `buy <pack>`, `plan <tier> [--interval month\|year] [--country CC]`, `card`, `cancel` | plan, balance (packs + included left - holds, as the portal shows it) and the billing page; `buy`/`plan` print and open a checkout a person pays (nothing is charged until then; `--country` else the locale's region else US prices it); `card`/`cancel` and an existing plan go to the billing page |
|
|
51
|
+
| `sync <dir> [--app A] [--watch]` | keep a folder (an Obsidian vault) in the project's Knowledge (`coding:knowledge:sync`): see [Folder sync](#folder-sync) |
|
|
46
52
|
| `status [--app A]` | what is running, and everything that needs you |
|
|
47
53
|
| `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
|
|
48
54
|
| `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
|
|
49
55
|
| `schedule list [--app A \| --all]`, `show <id>` | a project's recurring runs: cadence, next fires, History |
|
|
50
|
-
| `schedule add "<what>" --every E [--at T] [--tz Z] [--budget
|
|
51
|
-
| `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget
|
|
56
|
+
| `schedule add "<what>" --every E [--at T] [--tz Z] [--budget CREDITS] [--app A]` | create one; `E` is `hourly` (`--at :MM`), `daily`/`weekdays` (`--at HH:MM`), `weekly` (`--at "mon HH:MM"`) or a 5-field cron |
|
|
57
|
+
| `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget CREDITS\|none]` | change only what differs |
|
|
52
58
|
| `schedule pause \| resume \| run-now \| delete <id> [--yes]` | manage one (`delete` asks on a terminal, needs `--yes` off one) |
|
|
53
|
-
| `repeat <run> --every E [--at T] [--tz Z] [--budget
|
|
59
|
+
| `repeat <run> --every E [--at T] [--tz Z] [--budget CREDITS]` | repeat a run you liked; the server copies its prompt and project; the first run is the next scheduled time |
|
|
54
60
|
| `api` / `api <operationId> --help` | list every published operation / show one's parameters and body |
|
|
55
61
|
| `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) |
|
|
56
62
|
|
|
@@ -65,10 +71,40 @@ portal's key page or `mcp install`. A production environment's values also need
|
|
|
65
71
|
in memory only, so another process with the same key gets nothing); off a terminal or under
|
|
66
72
|
`SYNDAI_API_KEY` it exits 3.
|
|
67
73
|
|
|
74
|
+
Errors a workspace page fixes name that page, `https://app.syndai.ai/w/<id>/<path>`
|
|
75
|
+
(`portalUrl` in `src/errors.js`): out of credits or plan limits -> `billing`, a code-host
|
|
76
|
+
connection -> `settings#repositories`. Like `review`, the CLI opens it on a terminal and
|
|
77
|
+
only prints it under `--json` or `--no-input`. A question that needs a connection prints its
|
|
78
|
+
`link` ("Connect it here") in `watch` and `answer`.
|
|
79
|
+
|
|
68
80
|
Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
|
|
69
81
|
4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
|
|
70
82
|
unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
71
83
|
|
|
84
|
+
## Folder sync
|
|
85
|
+
|
|
86
|
+
`syndai sync <dir>` (`src/sync.js`, SPEC-knowledge-sources K6) walks `<dir>`, hashes every
|
|
87
|
+
file and sends the manifest (`compareKnowledgeFolder`, at most 20,000 `{path, sha256}`). The
|
|
88
|
+
server deletes the files missing from it and answers `need`, the paths new or changed; those go
|
|
89
|
+
up through `pushKnowledgeFolderFiles` in batches of at most 500 files and 8 MB, each read again
|
|
90
|
+
so the hash sent matches the bytes. Text suffixes (the server's `TEXT_SUFFIXES`) carry their
|
|
91
|
+
UTF-8 `content`; any other file goes with `content: null` (recorded by name). The folder's name
|
|
92
|
+
is the basename of `<dir>`; paths are `/`-separated and relative to it. Both calls are
|
|
93
|
+
idempotent (a manifest is a compare, a push upserts by path), so they retry like a GET.
|
|
94
|
+
|
|
95
|
+
Skipped: names starting with `.` (so `.obsidian/`, `.trash/`, `.git/`, `.syndaiignore` itself),
|
|
96
|
+
`node_modules/`, symlinks (never followed), files over 10 MB (left out of the manifest, listed
|
|
97
|
+
as skipped) and `<dir>/.syndaiignore` patterns: one a line, `#` comments, `dir/` for folders,
|
|
98
|
+
a pattern with a `/` anchored at the root, `*`, `?` and `**`; no `!`. A 409 (paused, or another
|
|
99
|
+
person's folder of that name) prints the detail and exits 1; per-file `failed` rows are printed
|
|
100
|
+
and do not change the exit code.
|
|
101
|
+
|
|
102
|
+
`--watch` keeps `{size, mtimeMs, sha256}` per file, so a re-sync re-reads only what moved (a
|
|
103
|
+
hash taken within 2 s of the mtime is redone, for coarse clocks). `io.watchDir` (`fs.watch`,
|
|
104
|
+
recursive) is the trigger, debounced 1 s, one sync at a time; a stat-only check every 10 s
|
|
105
|
+
catches missed events, and a watcher error falls back to it. An unreachable API is retried
|
|
106
|
+
after 30 s; any other error ends the watch. Ctrl-C exits 130.
|
|
107
|
+
|
|
72
108
|
## Layout
|
|
73
109
|
|
|
74
110
|
- `bin/syndai.js`: the entrypoint; wires SIGINT to an AbortSignal.
|
|
@@ -84,6 +120,8 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
|
84
120
|
- `src/schedules.js`: `schedule` and `repeat` (the preset to cron mapping of `apps/portal-v2/src/lib/cadence.ts`).
|
|
85
121
|
- `src/secrets.js`: `env`, `secrets` and `run` (environment choice, the production step-up retry).
|
|
86
122
|
- `src/dotenv.js`: the `.env` reader (`import`) and writer (`download`); errors name lines, never values.
|
|
123
|
+
- `src/portal.js`: `connect` and `billing`, the hand-offs to a workspace page.
|
|
124
|
+
- `src/sync.js`: `sync` (the walk, `.syndaiignore`, manifest and batched push, `--watch`).
|
|
87
125
|
- `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
|
|
88
126
|
|
|
89
127
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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/auth.js
CHANGED
|
@@ -260,6 +260,50 @@ export async function login(ctx) {
|
|
|
260
260
|
return EXIT.OK;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
export const TERMS_URL = "https://syndai.ai/legal/terms";
|
|
264
|
+
export const PRIVACY_URL = "https://syndai.ai/legal/privacy";
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `syndai signup <email> [--code C]`: a new workspace and its key from one emailed code
|
|
268
|
+
* (no browser). Without --code it mails a code and reads it from the prompt; off a terminal it
|
|
269
|
+
* stops there and names the rerun. With --code it only verifies: mailing again would replace
|
|
270
|
+
* the code being typed. The key is stored exactly as `login` stores it.
|
|
271
|
+
*/
|
|
272
|
+
export async function signup(ctx, [email]) {
|
|
273
|
+
const { io, ui, flags, client } = ctx;
|
|
274
|
+
if (!email) throw new CliError(EXIT.USAGE, "Which address? syndai signup you@example.com");
|
|
275
|
+
ui.progress(`Continuing accepts Syndai's Terms (${TERMS_URL}) and Privacy Policy (${PRIVACY_URL}).`);
|
|
276
|
+
if (ui.interactive && !/^y/i.test(await ask(io, "Continue? [y/N] "))) {
|
|
277
|
+
throw new CliError(EXIT.FAILED, "Not signed up; nothing was sent.");
|
|
278
|
+
}
|
|
279
|
+
let code = flags.code;
|
|
280
|
+
if (code === undefined) {
|
|
281
|
+
await client.call("signup", {
|
|
282
|
+
body: { email, accept_terms: true, client_name: "CLI", host: hostname() },
|
|
283
|
+
auth: false,
|
|
284
|
+
});
|
|
285
|
+
const next = `syndai signup ${email} --code <code>`;
|
|
286
|
+
if (!ui.interactive) {
|
|
287
|
+
ui.result({ status: "code_sent", email, next }, `We emailed a 6-digit code to ${email}.`);
|
|
288
|
+
if (!ui.json) ui.progress(`Next: ${next}`);
|
|
289
|
+
return EXIT.NEEDS_YOU;
|
|
290
|
+
}
|
|
291
|
+
ui.progress(`We emailed a 6-digit code to ${email}.`);
|
|
292
|
+
code = await ask(io, "Code: ");
|
|
293
|
+
}
|
|
294
|
+
const token = await client.call("signupVerify", {
|
|
295
|
+
body: { email, code: String(code).replace(/\s+/g, "") },
|
|
296
|
+
auth: false,
|
|
297
|
+
});
|
|
298
|
+
await storeKey(ctx, token);
|
|
299
|
+
if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is set and wins over this login."));
|
|
300
|
+
ui.result(
|
|
301
|
+
{ customer_id: token.customer_id, key_id: token.key_id, expires_at: token.expires_at, app_url: token.app_url },
|
|
302
|
+
`Signed up. Your workspace: ${token.app_url}`,
|
|
303
|
+
);
|
|
304
|
+
return EXIT.OK;
|
|
305
|
+
}
|
|
306
|
+
|
|
263
307
|
export async function logout(ctx) {
|
|
264
308
|
const { io, ui, paths } = ctx;
|
|
265
309
|
const stored = storedCredential(io, paths);
|
package/src/changes.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// edit-text. Each command is a thin call to one route plus, where there is an outcome to
|
|
3
3
|
// wait for, `watchChange`.
|
|
4
4
|
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
@@ -36,6 +37,7 @@ export async function change(ctx, words) {
|
|
|
36
37
|
const reference = flags.learn === undefined ? null : productUrlFromText(flags.learn);
|
|
37
38
|
if (flags.learn !== undefined && !reference)
|
|
38
39
|
throw new CliError(EXIT.USAGE, `--learn takes a public product URL, like https://cal.com, not ${flags.learn}.`);
|
|
40
|
+
needReferenceForSignIn(flags, reference);
|
|
39
41
|
const project = await resolveProject(ctx, { app: flags.app });
|
|
40
42
|
const outcome = flags.review ? "opens a pull request for your review" : "goes live when checks pass";
|
|
41
43
|
ui.progress(`Changing ${projectHost(project)} · ${outcome}`);
|
|
@@ -59,12 +61,13 @@ export async function change(ctx, words) {
|
|
|
59
61
|
coding_repository_id: project.id,
|
|
60
62
|
external_request_id: pending.request_id,
|
|
61
63
|
...(imageRefs.length ? { image_refs: imageRefs } : {}),
|
|
62
|
-
...referenceField(reference),
|
|
64
|
+
...referenceField(reference, flags["sign-in"]),
|
|
63
65
|
...(flags.review ? { policy_config: { autonomy: "auto" } } : {}),
|
|
64
66
|
},
|
|
65
67
|
});
|
|
66
68
|
clearPending(paths, pending.request_id);
|
|
67
69
|
ui.progress(ui.dim(`Change ${runId}`));
|
|
70
|
+
signInHint(ctx, reference);
|
|
68
71
|
if (flags["no-wait"]) {
|
|
69
72
|
return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
|
|
70
73
|
}
|
|
@@ -89,7 +92,19 @@ export function productUrlFromText(text) {
|
|
|
89
92
|
const hostOf = (url) => new URL(url).hostname.replace(/^www\./, "");
|
|
90
93
|
// The typed top-level create-run field (learn-from-a-product L1); `metadata.reference_product` is
|
|
91
94
|
// server-owned and 400s.
|
|
92
|
-
|
|
95
|
+
// `sign_in` only when asked, so a public-only request stays byte-identical.
|
|
96
|
+
const referenceField = (url, signIn) =>
|
|
97
|
+
url ? { reference_product: { kind: "url", value: url, ...(signIn ? { sign_in: true } : {}) } } : {};
|
|
98
|
+
|
|
99
|
+
/** --sign-in needs a product to sign in to; a usage error before any POST. */
|
|
100
|
+
function needReferenceForSignIn(flags, reference, hint = "--learn <product URL>") {
|
|
101
|
+
if (flags["sign-in"] && !reference) throw new CliError(EXIT.USAGE, `--sign-in needs ${hint}.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The owner signs in on the run page themselves (never a password in the terminal). */
|
|
105
|
+
function signInHint({ flags, ui }, reference) {
|
|
106
|
+
if (flags["sign-in"] && reference) ui.progress(`Sign in to ${hostOf(reference)} on the run page once it asks.`);
|
|
107
|
+
}
|
|
93
108
|
|
|
94
109
|
/** Build-home parity: start a New project. A product URL in the text makes it "your own version
|
|
95
110
|
* of X" (SPEC-build-your-own D5) unless --no-learn; its feature-pick question is asked inline. */
|
|
@@ -98,7 +113,10 @@ export async function newProject(ctx, words) {
|
|
|
98
113
|
const text = words.join(" ").trim();
|
|
99
114
|
if (!text) throw new CliError(EXIT.USAGE, 'Say what to build: syndai new "a booking site for my salon"');
|
|
100
115
|
const timeoutMs = timeoutOf(flags);
|
|
116
|
+
// --no-learn says "don't read that product"; --sign-in says "read it signed in": they conflict.
|
|
117
|
+
if (flags["no-learn"] && flags["sign-in"]) throw new CliError(EXIT.USAGE, "--sign-in and --no-learn can't be used together: --no-learn skips reading the product, --sign-in reads it signed in. Drop one.");
|
|
101
118
|
const reference = flags["no-learn"] ? null : productUrlFromText(text);
|
|
119
|
+
needReferenceForSignIn(flags, reference, "a product URL in the text, like https://cal.com");
|
|
102
120
|
ui.progress(
|
|
103
121
|
reference
|
|
104
122
|
? `New project · your own version of ${hostOf(reference)}`
|
|
@@ -116,11 +134,12 @@ export async function newProject(ctx, words) {
|
|
|
116
134
|
prompt: text,
|
|
117
135
|
new_repository: { scaffold: "syndai-web" },
|
|
118
136
|
external_request_id: pending.request_id,
|
|
119
|
-
...referenceField(reference),
|
|
137
|
+
...referenceField(reference, flags["sign-in"]),
|
|
120
138
|
},
|
|
121
139
|
});
|
|
122
140
|
clearPending(paths, pending.request_id);
|
|
123
141
|
ui.progress(ui.dim(`Change ${runId}`));
|
|
142
|
+
signInHint(ctx, reference);
|
|
124
143
|
if (flags["no-wait"]) {
|
|
125
144
|
return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
|
|
126
145
|
}
|
|
@@ -157,6 +176,37 @@ async function uploadImage(ctx, file) {
|
|
|
157
176
|
return { object_key: stored.object_key, content_type: contentType };
|
|
158
177
|
}
|
|
159
178
|
|
|
179
|
+
/** `syndai land [branch]`: validate, review and land your pushed people/<you>/<name> branch. */
|
|
180
|
+
export async function land(ctx, [branch]) {
|
|
181
|
+
const { flags, ui } = ctx;
|
|
182
|
+
const name = branch ?? currentBranch();
|
|
183
|
+
if (!name) throw new CliError("Which branch? syndai land people/<you>/<name>", EXIT.USAGE);
|
|
184
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
185
|
+
const landing = await ctx.client.call("landBranch", {
|
|
186
|
+
params: { coding_repository_id: project.id },
|
|
187
|
+
body: { branch: name },
|
|
188
|
+
retry: true, // one landing per pushed head: a retry answers the same run
|
|
189
|
+
});
|
|
190
|
+
ui.progress(ui.dim(`Landing ${name} at ${landing.head_sha.slice(0, 12)}: change ${landing.run_id}`));
|
|
191
|
+
for (const warning of landing.overlap ?? []) ui.progress(`Heads up: ${warning}`);
|
|
192
|
+
if (flags["no-wait"]) {
|
|
193
|
+
return finish(ctx, await getChange(ctx, landing.run_id), {
|
|
194
|
+
exitCode: EXIT.OK,
|
|
195
|
+
next: `syndai watch ${landing.run_id}`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return watchChange(ctx, landing.run_id, { timeoutMs: timeoutOf(flags) });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function currentBranch() {
|
|
202
|
+
try {
|
|
203
|
+
const out = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
|
|
204
|
+
return out && out !== "HEAD" ? out : null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
export async function watch(ctx, runId) {
|
|
161
211
|
return watchChange(ctx, needRun(runId, "watch"), {
|
|
162
212
|
timeoutMs: timeoutOf(ctx.flags),
|
package/src/errors.js
CHANGED
|
@@ -40,9 +40,24 @@ export class ApiError extends Error {
|
|
|
40
40
|
/** The service did not answer (network error, 5xx or 429) after the retry budget. */
|
|
41
41
|
export class UnavailableError extends Error {}
|
|
42
42
|
|
|
43
|
-
const BILLING_URL = "https://app.syndai.ai/billing";
|
|
44
43
|
export const PORTAL_URL = "https://app.syndai.ai";
|
|
45
44
|
|
|
45
|
+
/** A workspace page: `https://app.syndai.ai/w/<id>/<path>` (the portal root without an id). */
|
|
46
|
+
export function portalUrl(customerId, path) {
|
|
47
|
+
return customerId ? `${PORTAL_URL}/w/${encodeURIComponent(customerId)}/${path}` : PORTAL_URL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The workspace page that fixes each error, so a hint is the exact page, not the portal root. */
|
|
51
|
+
export const PORTAL_PAGES = Object.freeze({
|
|
52
|
+
PLAN_UPGRADE_REQUIRED: "billing",
|
|
53
|
+
BUDGET_EXHAUSTED: "billing",
|
|
54
|
+
QUOTA_EXCEEDED: "billing",
|
|
55
|
+
INSTALLATION_INCOMPLETE: "settings#repositories",
|
|
56
|
+
REPO_NOT_BOUND: "settings#repositories",
|
|
57
|
+
ALREADY_SUBSCRIBED: "billing",
|
|
58
|
+
PLAN_UNKNOWN: "billing",
|
|
59
|
+
});
|
|
60
|
+
|
|
46
61
|
/**
|
|
47
62
|
* A scope `syndai login --scope` may add with one browser approval. Members, keys and
|
|
48
63
|
* unpublish stay portal-only: the approval page promises a CLI key never manages members or
|
|
@@ -52,7 +67,8 @@ export function steppable(scope) {
|
|
|
52
67
|
return !/:members:|keys|:unpublish$/.test(scope);
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
// code -> [exit, sentence, next]. Either may be a function of the problem body.
|
|
70
|
+
// code -> [exit, sentence, next]. Either may be a function of the problem body. A code in
|
|
71
|
+
// PORTAL_PAGES has no `next` here: it is that page of the caller's workspace.
|
|
56
72
|
const KNOWN = {
|
|
57
73
|
INVALID_TOKEN: [EXIT.AUTH, "Your Syndai key is missing, expired or revoked.", "syndai login"],
|
|
58
74
|
INVALID_SCOPE: [
|
|
@@ -67,9 +83,9 @@ const KNOWN = {
|
|
|
67
83
|
!scope ? "syndai login" : steppable(scope) ? `syndai login --scope ${scope}` : PORTAL_URL,
|
|
68
84
|
],
|
|
69
85
|
ROLE_REQUIRED: [EXIT.AUTH, "Only workspace owners and admins can do that.", null],
|
|
70
|
-
PLAN_UPGRADE_REQUIRED: [EXIT.AUTH, "Your plan does not include that.",
|
|
71
|
-
BUDGET_EXHAUSTED: [EXIT.AUTH, "This workspace is out of credits.",
|
|
72
|
-
QUOTA_EXCEEDED: [EXIT.AUTH, "This workspace is out of credits for now.",
|
|
86
|
+
PLAN_UPGRADE_REQUIRED: [EXIT.AUTH, "Your plan does not include that.", null],
|
|
87
|
+
BUDGET_EXHAUSTED: [EXIT.AUTH, "This workspace is out of credits.", null],
|
|
88
|
+
QUOTA_EXCEEDED: [EXIT.AUTH, "This workspace is out of credits for now.", null],
|
|
73
89
|
CUSTOMER_DISABLED: [EXIT.AUTH, "This workspace is disabled. Contact Syndai support.", null],
|
|
74
90
|
HUMAN_APPROVAL_REQUIRED: [
|
|
75
91
|
EXIT.NEEDS_YOU,
|
|
@@ -88,12 +104,8 @@ const KNOWN = {
|
|
|
88
104
|
"run it again in a terminal logged in with syndai login",
|
|
89
105
|
],
|
|
90
106
|
RUN_CONCURRENCY_LIMIT: [EXIT.FAILED, "Too many changes are running at once.", "syndai status"],
|
|
91
|
-
REPO_NOT_BOUND: [EXIT.USAGE, "That project is not available to this workspace.",
|
|
92
|
-
INSTALLATION_INCOMPLETE: [
|
|
93
|
-
EXIT.FAILED,
|
|
94
|
-
"A code-host connection needs finishing in the portal.",
|
|
95
|
-
"https://app.syndai.ai",
|
|
96
|
-
],
|
|
107
|
+
REPO_NOT_BOUND: [EXIT.USAGE, "That project is not available to this workspace; connect its repository.", null],
|
|
108
|
+
INSTALLATION_INCOMPLETE: [EXIT.FAILED, "A code-host connection needs finishing in the portal.", null],
|
|
97
109
|
HITL_REQUEST_SETTLED: [EXIT.FAILED, "That question is already answered or closed.", null],
|
|
98
110
|
RUN_TERMINATED_BEFORE_RESPONSE: [EXIT.FAILED, "The change ended before your answer arrived.", null],
|
|
99
111
|
TEXT_EDIT_REFUSED: [
|
|
@@ -117,12 +129,27 @@ const KNOWN = {
|
|
|
117
129
|
"Learning from a product needs a Syndai site project.",
|
|
118
130
|
null,
|
|
119
131
|
],
|
|
132
|
+
ALREADY_SUBSCRIBED: [EXIT.FAILED, "You're already on a paid plan; change it on the billing page.", null],
|
|
133
|
+
PLAN_UNKNOWN: [EXIT.USAGE, "That is not a plan you can pick; the plans are on the billing page.", null],
|
|
134
|
+
PACK_UNKNOWN: [EXIT.USAGE, "No such credit pack.", "syndai billing packs"],
|
|
135
|
+
// `syndai signup` (problem+json, lowercase codes like the sign-in routes).
|
|
136
|
+
invalid_email: [EXIT.USAGE, "That is not a valid email address.", null],
|
|
137
|
+
email_disposable: [EXIT.USAGE, "Use a permanent email address; disposable ones can't sign up.", null],
|
|
138
|
+
terms_required: [EXIT.USAGE, "Signing up needs the Terms and Privacy Policy accepted.", null],
|
|
139
|
+
sso_required: [EXIT.AUTH, "This address signs in with its organization's SSO; sign up in the browser.", PORTAL_URL],
|
|
140
|
+
code_invalid: [
|
|
141
|
+
EXIT.AUTH,
|
|
142
|
+
"That code is not right. If you already have a workspace, run `syndai login`.",
|
|
143
|
+
null,
|
|
144
|
+
],
|
|
145
|
+
code_expired: [EXIT.AUTH, "That code expired or was already used.", "syndai signup <email> (for a new code)"],
|
|
146
|
+
attempts_exhausted: [EXIT.AUTH, "Too many wrong codes for this address; try again tomorrow.", null],
|
|
120
147
|
NOT_FOUND: [EXIT.FAILED, "Not found.", "syndai status"],
|
|
121
148
|
RATE_LIMITED: [EXIT.UNAVAILABLE, "Syndai is rate-limiting this workspace.", null],
|
|
122
149
|
};
|
|
123
150
|
|
|
124
|
-
/** Map any thrown error to {exitCode, message, next}. */
|
|
125
|
-
export function explain(err) {
|
|
151
|
+
/** Map any thrown error to {exitCode, message, next}; `customerId` fills a PORTAL_PAGES link. */
|
|
152
|
+
export function explain(err, customerId = null) {
|
|
126
153
|
if (err instanceof CliError) {
|
|
127
154
|
return { exitCode: err.exitCode, message: err.message, next: err.next };
|
|
128
155
|
}
|
|
@@ -137,10 +164,11 @@ export function explain(err) {
|
|
|
137
164
|
const known = err.code ? KNOWN[err.code] : null;
|
|
138
165
|
if (known) {
|
|
139
166
|
const [exitCode, sentence, next] = known;
|
|
167
|
+
const page = PORTAL_PAGES[err.code];
|
|
140
168
|
return {
|
|
141
169
|
exitCode,
|
|
142
170
|
message: typeof sentence === "function" ? sentence(err.problem) : sentence,
|
|
143
|
-
next: typeof next === "function" ? next(err.problem) : next,
|
|
171
|
+
next: page ? portalUrl(customerId, page) : typeof next === "function" ? next(err.problem) : next,
|
|
144
172
|
};
|
|
145
173
|
}
|
|
146
174
|
const auth = err.status === 401 || err.status === 402 || err.status === 403;
|
package/src/main.js
CHANGED
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
// process I/O as `io`, so tests drive it in-process with a fake server and fake clocks.
|
|
3
3
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { watch as watchFs } from "node:fs";
|
|
5
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
7
|
-
import { createContext, login, logout, whoami } from "./auth.js";
|
|
8
|
-
import { answer, cancel, change, editText, goLive, newProject, review, revert, status, watch } from "./changes.js";
|
|
9
|
-
import { CliError, EXIT, explain } from "./errors.js";
|
|
8
|
+
import { createContext, login, logout, signup, whoami } from "./auth.js";
|
|
9
|
+
import { answer, cancel, change, editText, goLive, land, newProject, review, revert, status, watch } from "./changes.js";
|
|
10
|
+
import { ApiError, CliError, EXIT, explain, PORTAL_PAGES } from "./errors.js";
|
|
10
11
|
import { VERSION } from "./http.js";
|
|
11
12
|
import { mcp } from "./mcp.js";
|
|
12
|
-
import {
|
|
13
|
+
import { billing, connect } from "./portal.js";
|
|
14
|
+
import { link, publish } from "./projects.js";
|
|
13
15
|
import { api } from "./api.js";
|
|
14
16
|
import { repeat, schedule } from "./schedules.js";
|
|
15
17
|
import { env, run, secrets } from "./secrets.js";
|
|
16
18
|
import { settings } from "./settings.js";
|
|
17
|
-
import {
|
|
19
|
+
import { sync } from "./sync.js";
|
|
20
|
+
import { closeInput, openBrowser, openPage, page } from "./ui.js";
|
|
18
21
|
|
|
19
22
|
const str = { type: "string" };
|
|
20
23
|
const bool = { type: "boolean" };
|
|
@@ -22,12 +25,15 @@ const COMMON = { json: bool, "no-input": bool, help: { type: "boolean", short: "
|
|
|
22
25
|
|
|
23
26
|
const COMMANDS = {
|
|
24
27
|
change: {
|
|
25
|
-
usage:
|
|
28
|
+
usage:
|
|
29
|
+
'change "<what>" [--app A] [--review] [--learn URL [--sign-in]] [--image F]... [--no-wait] [--timeout D]',
|
|
26
30
|
summary:
|
|
27
|
-
"Make a change; it goes live when its checks pass (--review: open a PR instead; --learn: pick features to add from a product's public pages)
|
|
31
|
+
"Make a change; it goes live when its checks pass (--review: open a PR instead; --learn: pick features to add from a product's public pages).\n" +
|
|
32
|
+
" --sign-in: Sign in to the --learn product yourself on the run page first, so the run reads its in-app pages.",
|
|
28
33
|
options: {
|
|
29
34
|
app: str,
|
|
30
35
|
learn: str,
|
|
36
|
+
"sign-in": bool,
|
|
31
37
|
review: bool,
|
|
32
38
|
image: { type: "string", multiple: true },
|
|
33
39
|
"no-wait": bool,
|
|
@@ -37,12 +43,23 @@ const COMMANDS = {
|
|
|
37
43
|
stepUp: false,
|
|
38
44
|
},
|
|
39
45
|
new: {
|
|
40
|
-
usage: 'new "<what>" [--no-learn] [--no-wait] [--timeout D]',
|
|
41
|
-
summary:
|
|
42
|
-
|
|
46
|
+
usage: 'new "<what>" [--no-learn | --sign-in] [--no-wait] [--timeout D]',
|
|
47
|
+
summary:
|
|
48
|
+
"Start a new project; a product URL in the text builds your own version of it.\n" +
|
|
49
|
+
" --sign-in: Sign in to that product yourself on the run page first, so the run reads its in-app pages.",
|
|
50
|
+
options: { "no-learn": bool, "sign-in": bool, "no-wait": bool, timeout: str },
|
|
43
51
|
run: (ctx, args) => newProject(ctx, args),
|
|
44
52
|
stepUp: false,
|
|
45
53
|
},
|
|
54
|
+
land: {
|
|
55
|
+
usage: "land [branch] [--app A] [--no-wait] [--timeout D]",
|
|
56
|
+
summary:
|
|
57
|
+
"On a project Syndai hosts: check, review and land your pushed branch (default: the current one, people/<you>/<name>).\n" +
|
|
58
|
+
" A branch named for a task (people/<you>/t12) delivers it when the landing goes live.",
|
|
59
|
+
options: { app: str, "no-wait": bool, timeout: str },
|
|
60
|
+
run: (ctx, args) => land(ctx, args),
|
|
61
|
+
stepUp: false,
|
|
62
|
+
},
|
|
46
63
|
watch: {
|
|
47
64
|
usage: "watch <run> [--timeout D] [--logs]",
|
|
48
65
|
summary: "Follow a change to its outcome (Ctrl-C detaches; the change keeps going).",
|
|
@@ -97,10 +114,19 @@ const COMMANDS = {
|
|
|
97
114
|
login: {
|
|
98
115
|
usage: "login [--with-token] [--scope S]...",
|
|
99
116
|
summary:
|
|
100
|
-
"Log in by approving this computer in your browser (--scope adds access to the key's; --with-token reads a key from stdin)
|
|
117
|
+
"Log in by approving this computer in your browser (--scope adds access to the key's; --with-token reads a key from stdin).\n" +
|
|
118
|
+
" No workspace yet? syndai signup <email>",
|
|
101
119
|
options: { "with-token": bool, scope: { type: "string", multiple: true } },
|
|
102
120
|
run: (ctx) => login(ctx),
|
|
103
121
|
},
|
|
122
|
+
signup: {
|
|
123
|
+
usage: "signup <email> [--code C]",
|
|
124
|
+
summary:
|
|
125
|
+
"Make a workspace and log in with a code emailed to you (continuing accepts the Terms and Privacy Policy).\n" +
|
|
126
|
+
" Off a terminal it emails the code and exits 3; rerun with --code.",
|
|
127
|
+
options: { code: str },
|
|
128
|
+
run: (ctx, args) => signup(ctx, args),
|
|
129
|
+
},
|
|
104
130
|
logout: {
|
|
105
131
|
usage: "logout",
|
|
106
132
|
summary: "Revoke and forget the stored key.",
|
|
@@ -113,6 +139,26 @@ const COMMANDS = {
|
|
|
113
139
|
options: {},
|
|
114
140
|
run: (ctx) => whoami(ctx),
|
|
115
141
|
},
|
|
142
|
+
publish: {
|
|
143
|
+
usage: "publish [--app A]",
|
|
144
|
+
summary: "Publish the project as a hosted app (or redeploy it) and print its live URL.",
|
|
145
|
+
options: { app: str },
|
|
146
|
+
run: (ctx) => publish(ctx),
|
|
147
|
+
},
|
|
148
|
+
connect: {
|
|
149
|
+
usage: "connect <provider>",
|
|
150
|
+
summary:
|
|
151
|
+
"Open the workspace page that connects github, sentry, claude, chatgpt, openrouter, slack or a social account; finish in the browser.",
|
|
152
|
+
options: {},
|
|
153
|
+
run: (ctx, args) => connect(ctx, args),
|
|
154
|
+
},
|
|
155
|
+
billing: {
|
|
156
|
+
usage: "billing [--open] | packs | buy <pack> | plan <tier> [--interval month|year] [--country CC] | card | cancel",
|
|
157
|
+
summary:
|
|
158
|
+
"Plan and balance; buy and plan open a checkout a person pays in the browser (nothing is charged until then).",
|
|
159
|
+
options: { open: bool, interval: str, country: str },
|
|
160
|
+
run: (ctx, args) => billing(ctx, args),
|
|
161
|
+
},
|
|
116
162
|
link: {
|
|
117
163
|
usage: "link [project] [--env E]",
|
|
118
164
|
summary: "Remember which project (and environment) this directory uses (stored outside the repo).",
|
|
@@ -147,6 +193,14 @@ const COMMANDS = {
|
|
|
147
193
|
options: { app: str },
|
|
148
194
|
run: (ctx, args) => settings(ctx, args),
|
|
149
195
|
},
|
|
196
|
+
sync: {
|
|
197
|
+
usage: "sync <dir> [--app A] [--watch]",
|
|
198
|
+
summary:
|
|
199
|
+
"Sync a folder (an Obsidian vault) into a project's Knowledge: new and changed files go up, deleted ones are removed;\n" +
|
|
200
|
+
" hidden files, node_modules/ and <dir>/.syndaiignore patterns stay out. --watch: keep syncing as files change (Ctrl-C stops).",
|
|
201
|
+
options: { app: str, watch: bool },
|
|
202
|
+
run: (ctx, args) => sync(ctx, args),
|
|
203
|
+
},
|
|
150
204
|
mcp: {
|
|
151
205
|
usage: "mcp install claude|cursor|codex",
|
|
152
206
|
summary: "Add Syndai to an AI coding tool, with its own key.",
|
|
@@ -155,8 +209,8 @@ const COMMANDS = {
|
|
|
155
209
|
},
|
|
156
210
|
schedule: {
|
|
157
211
|
usage:
|
|
158
|
-
'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget
|
|
159
|
-
" | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget
|
|
212
|
+
'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget CREDITS] [--app A]\n' +
|
|
213
|
+
" | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget CREDITS|none]\n" +
|
|
160
214
|
" | pause | resume | run-now | delete <id> [--yes]",
|
|
161
215
|
summary:
|
|
162
216
|
'A project\'s recurring runs. E: hourly (--at :MM), daily or weekdays (--at HH:MM), weekly (--at "mon HH:MM"), or a 5-field cron.',
|
|
@@ -164,7 +218,7 @@ const COMMANDS = {
|
|
|
164
218
|
run: (ctx, args) => schedule(ctx, args),
|
|
165
219
|
},
|
|
166
220
|
repeat: {
|
|
167
|
-
usage: "repeat <run> --every E [--at T] [--tz Z] [--budget
|
|
221
|
+
usage: "repeat <run> --every E [--at T] [--tz Z] [--budget CREDITS]",
|
|
168
222
|
summary: "Run a change you liked again on a schedule (its first run is the next scheduled time).",
|
|
169
223
|
options: { every: str, at: str, tz: str, budget: str },
|
|
170
224
|
run: (ctx, args) => repeat(ctx, args),
|
|
@@ -214,6 +268,12 @@ export function processIo(signal) {
|
|
|
214
268
|
sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
|
|
215
269
|
openUrl: openBrowser,
|
|
216
270
|
page: (text) => page(process, text),
|
|
271
|
+
/** `syndai sync --watch`: `onChange(file)` for a change anywhere under `dir` (`file` relative, or null); returns `{close}`. */
|
|
272
|
+
watchDir: (dir, onChange, onError) => {
|
|
273
|
+
const watcher = watchFs(dir, { recursive: true }, (_event, file) => onChange(file ? String(file) : null));
|
|
274
|
+
watcher.on("error", onError);
|
|
275
|
+
return watcher;
|
|
276
|
+
},
|
|
217
277
|
/**
|
|
218
278
|
* `syndai run`: the child shares the terminal; resolves `{code, signal}` (ENOENT rejects).
|
|
219
279
|
* While it runs the parent never exits first (that would orphan a child holding the
|
|
@@ -276,7 +336,7 @@ export async function main(argv, io) {
|
|
|
276
336
|
try {
|
|
277
337
|
return await spec.run(ctx, child ?? parsed.positionals);
|
|
278
338
|
} catch (err) {
|
|
279
|
-
return report(ctx, err);
|
|
339
|
+
return await report(ctx, err);
|
|
280
340
|
} finally {
|
|
281
341
|
closeInput(io);
|
|
282
342
|
}
|
|
@@ -298,10 +358,14 @@ function splitCommand(args, options) {
|
|
|
298
358
|
return [args, []];
|
|
299
359
|
}
|
|
300
360
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
361
|
+
/**
|
|
362
|
+
* One error line (plus the next command) on stderr; a JSON error on stdout for machines. An
|
|
363
|
+
* error a workspace page fixes (billing, GitHub) names that page, opened on a terminal.
|
|
364
|
+
*/
|
|
365
|
+
async function report(ctx, err) {
|
|
303
366
|
const { ui, io } = ctx;
|
|
304
|
-
const
|
|
367
|
+
const page = err instanceof ApiError ? PORTAL_PAGES[err.code] : null;
|
|
368
|
+
const { exitCode, message, next } = explain(err, page ? await ctx.customerId().catch(() => null) : null);
|
|
305
369
|
if (io.env.SYNDAI_DEBUG && err?.stack) io.stderr.write(`${err.stack}\n`);
|
|
306
370
|
const extra = err instanceof CliError ? err.result : null;
|
|
307
371
|
if (exitCode === EXIT.INTERRUPTED) {
|
|
@@ -317,5 +381,6 @@ function report(ctx, err) {
|
|
|
317
381
|
}
|
|
318
382
|
}
|
|
319
383
|
if (next) ui.progress(` next: ${next}`);
|
|
384
|
+
if (page) openPage(ctx, next);
|
|
320
385
|
return exitCode;
|
|
321
386
|
}
|