syndai 0.4.1 → 0.6.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 +62 -0
- package/README.md +45 -7
- package/package.json +1 -1
- package/src/auth.js +102 -14
- package/src/changes.js +31 -2
- package/src/dotenv.js +80 -0
- package/src/errors.js +57 -14
- package/src/main.js +135 -23
- package/src/portal.js +156 -0
- package/src/projects.js +52 -4
- package/src/routes.js +16 -0
- package/src/schedules.js +14 -6
- package/src/secrets.js +317 -0
- package/src/ui.js +55 -0
- package/src/watch.js +35 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `syndai` npm package. Versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.6.0
|
|
6
|
+
|
|
7
|
+
The steps that stay in the browser are one command away, and an agent can sign up.
|
|
8
|
+
|
|
9
|
+
- `signup <email> [--code C]`: a new workspace and its key from a 6-digit code emailed to that
|
|
10
|
+
address, no browser. Continuing accepts the Terms and Privacy Policy (asked y/N on a terminal).
|
|
11
|
+
Off a terminal it mails the code and exits 3; rerun with `--code`, which only verifies. The
|
|
12
|
+
key is stored as `login` stores it. An address that already has a workspace uses `login`.
|
|
13
|
+
- `connect <provider>` prints, and opens on a terminal, the workspace page that connects it
|
|
14
|
+
(`github`, `sentry`, `claude`, `chatgpt`, `openrouter`, `slack` and the social accounts).
|
|
15
|
+
- `billing` shows the plan, the balance and the billing page; `billing packs` lists the credit
|
|
16
|
+
packs; `billing buy <pack>` and `billing plan <tier> [--interval month|year]` open a checkout a
|
|
17
|
+
person pays (nothing is charged until then); `card`, `cancel` and a plan change go to the
|
|
18
|
+
billing page.
|
|
19
|
+
- `publish [--app A]` publishes the project as a hosted app and prints its live URL.
|
|
20
|
+
- Out-of-credits and plan errors now name the workspace's billing page
|
|
21
|
+
(`https://app.syndai.ai/w/<id>/billing`; the old `app.syndai.ai/billing` link was dead), and
|
|
22
|
+
code-host errors its repositories card; both open on a terminal.
|
|
23
|
+
- `watch` and `answer` print a question's connect link ("Connect it here") and open it on a
|
|
24
|
+
terminal.
|
|
25
|
+
|
|
26
|
+
- `schedule add|edit` and `repeat` take `--budget` in credits (1 credit = US$0.25; the lowest
|
|
27
|
+
cap is 20 credits), and `schedule show` and `repeat` print the cap and the last run's cost in
|
|
28
|
+
credits, matching the portal. `--budget 5` used to mean US$5; it now means 5 credits.
|
|
29
|
+
- `watch` (and `change`, `review`, which watch to the outcome) keeps watching through a deploy:
|
|
30
|
+
a run of 502/503s or dropped connections no longer ends it. It says once that Syndai is
|
|
31
|
+
unreachable and gives up only after 10 minutes without an answer.
|
|
32
|
+
- `change --learn URL --sign-in` and `new "<text with a product link>" --sign-in`: before Syndai
|
|
33
|
+
reads the product, the run's page asks you to sign in to it yourself in a secure browser and
|
|
34
|
+
capture the in-app pages to learn from (or skip for public pages only). The CLI prints where to
|
|
35
|
+
do it and never asks for a password. `--sign-in` without a product is a usage error (exit 2).
|
|
36
|
+
|
|
37
|
+
## 0.5.0
|
|
38
|
+
|
|
39
|
+
Environments and secrets from a terminal, Doppler-style.
|
|
40
|
+
|
|
41
|
+
- `env ls` lists the workspace's environments (the current one marked); `env use <env>` picks
|
|
42
|
+
the one this directory uses, stored in its link outside the repo. `link [project] --env E`
|
|
43
|
+
sets both. Otherwise: `--env`, then `SYNDAI_ENV`, then the workspace default.
|
|
44
|
+
- `secrets` lists names, scope (shared or project) and when each changed, never a value.
|
|
45
|
+
`secrets set KEY` reads the value from a hidden prompt or stdin, never the command line;
|
|
46
|
+
`secrets rm KEY` asks first (`--yes` off a terminal); `secrets import FILE|-` stores a
|
|
47
|
+
`.env` in one all-or-nothing request. `--project` writes at the project's scope.
|
|
48
|
+
- `secrets get KEY` prints one value to a terminal (`--plain` for a pipe, exact bytes);
|
|
49
|
+
`secrets download [-o FILE]` writes a `.env` to a pipe, or to a 0600 file and warns when git
|
|
50
|
+
does not ignore it; `run [--env E] [--] <command>` hands the values to the child's environment
|
|
51
|
+
only and exits with its code. The values layer the way a run's do: the project's over the
|
|
52
|
+
shared ones, over Test.
|
|
53
|
+
- Reading values needs `coding:secrets:reveal`, which only a browser approval grants: the
|
|
54
|
+
first secrets command asks once for read, write and reveal together. Production values also
|
|
55
|
+
need you to allow each command in the browser; another process holding the same key, `SYNDAI_API_KEY`
|
|
56
|
+
(CI) and an MCP key never read them (exit 3).
|
|
57
|
+
|
|
58
|
+
Learn from a product.
|
|
59
|
+
|
|
60
|
+
- `new "<text>"` starts a project from scratch. A product link in the text (not GitHub) builds
|
|
61
|
+
your own version of it: Syndai reads its public pages, then asks which features to build and
|
|
62
|
+
what to call it. `--no-learn` treats the link as context only.
|
|
63
|
+
- `change "<text>" --learn URL` adds features from that product to an existing project: Syndai
|
|
64
|
+
reads its public pages and asks which features to add (3 pre-checked). A link inside the
|
|
65
|
+
change text is never treated as one.
|
|
66
|
+
|
|
5
67
|
## 0.4.1
|
|
6
68
|
|
|
7
69
|
- A running `watch`, `change` or MCP server no longer stops when `login` or a scope
|
package/README.md
CHANGED
|
@@ -9,6 +9,18 @@ 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
|
+
Secrets and environments, Doppler-style:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
syndai link bakery --env development # this directory: project + environment
|
|
16
|
+
syndai secrets # names, scope, updated (never a value)
|
|
17
|
+
printf %s "$TOKEN" | syndai secrets set API_TOKEN # or type it at the hidden prompt
|
|
18
|
+
syndai secrets import .env # a whole .env, all or nothing
|
|
19
|
+
syndai run npm run dev # the values reach npm's environment only
|
|
20
|
+
syndai secrets download -o .env.local # 0600, warns if git does not ignore it
|
|
21
|
+
syndai run --env production ./deploy # asks you to allow this one command
|
|
22
|
+
```
|
|
23
|
+
|
|
12
24
|
Node 22 or newer, zero dependencies, no telemetry. The customer guide is the docs
|
|
13
25
|
site's **CLI** page, https://syndai.ai/docs/cli (`web/content/docs/cli.mdx`); this
|
|
14
26
|
README is for whoever works on the package.
|
|
@@ -18,28 +30,50 @@ README is for whoever works on the package.
|
|
|
18
30
|
| command | does |
|
|
19
31
|
|---|---|
|
|
20
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 |
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
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` |
|
|
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 |
|
|
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 |
|
|
36
|
+
| `secrets [ls]` | names, scope (shared/project) and updated for the environment and this project; never a value, in text or `--json` |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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` |
|
|
24
42
|
| `edit-text [--app A] [--page /] --from "..." --to "..."` / `--undo <sha>` | instant, free, undoable text swap on a live site |
|
|
25
43
|
| `watch <run> [--timeout D] [--logs]` | follow any change; `--logs` tails its events |
|
|
26
44
|
| `answer <run> [--input FILE\|-]` | answer an open question (`--input`: `{"entries":[...]}` from a file or stdin) |
|
|
27
45
|
| `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
|
|
28
46
|
| `go-live`, `revert`, `cancel <run>` | land actions |
|
|
47
|
+
| `publish [--app A]` | publish (or redeploy) the project as a hosted app and print its live URL (`coding:apps:publish`) |
|
|
48
|
+
| `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 |
|
|
49
|
+
| `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 |
|
|
29
50
|
| `status [--app A]` | what is running, and everything that needs you |
|
|
30
51
|
| `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
|
|
31
52
|
| `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
|
|
32
53
|
| `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
|
|
34
|
-
| `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget
|
|
54
|
+
| `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 |
|
|
55
|
+
| `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget CREDITS\|none]` | change only what differs |
|
|
35
56
|
| `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
|
|
57
|
+
| `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 |
|
|
37
58
|
| `api` / `api <operationId> --help` | list every published operation / show one's parameters and body |
|
|
38
59
|
| `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
60
|
|
|
40
61
|
A 403 that names a missing scope asks, on a terminal with a stored login, to approve
|
|
41
62
|
it in the browser and retries once. `SYNDAI_API_KEY` never steps up; off a terminal the
|
|
42
63
|
error names `syndai login --scope <scope>`. Members, keys and unpublish are portal-only.
|
|
64
|
+
A missing `coding:secrets:*` scope asks once for all three (read, write, reveal). Reveal
|
|
65
|
+
(reading values) is granted only by that browser approval, never by `--with-token`, the
|
|
66
|
+
portal's key page or `mcp install`. A production environment's values also need a
|
|
67
|
+
`PRODUCTION_STEP_UP_REQUIRED` approval: on a terminal with a stored login the CLI opens
|
|
68
|
+
`/cli` for a person to allow THIS command, then retries once with that approval's grant (held
|
|
69
|
+
in memory only, so another process with the same key gets nothing); off a terminal or under
|
|
70
|
+
`SYNDAI_API_KEY` it exits 3.
|
|
71
|
+
|
|
72
|
+
Errors a workspace page fixes name that page, `https://app.syndai.ai/w/<id>/<path>`
|
|
73
|
+
(`portalUrl` in `src/errors.js`): out of credits or plan limits -> `billing`, a code-host
|
|
74
|
+
connection -> `settings#repositories`. Like `review`, the CLI opens it on a terminal and
|
|
75
|
+
only prints it under `--json` or `--no-input`. A question that needs a connection prints its
|
|
76
|
+
`link` ("Connect it here") in `watch` and `answer`.
|
|
43
77
|
|
|
44
78
|
Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
|
|
45
79
|
4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
|
|
@@ -58,6 +92,9 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
|
58
92
|
- `src/openapi.js`: the live `/openapi.json` (cached 24h per host in `$XDG_CACHE_HOME/syndai`).
|
|
59
93
|
- `src/api.js`: `syndai api` (operations by id, fields, `--paginate`).
|
|
60
94
|
- `src/schedules.js`: `schedule` and `repeat` (the preset to cron mapping of `apps/portal-v2/src/lib/cadence.ts`).
|
|
95
|
+
- `src/secrets.js`: `env`, `secrets` and `run` (environment choice, the production step-up retry).
|
|
96
|
+
- `src/dotenv.js`: the `.env` reader (`import`) and writer (`download`); errors name lines, never values.
|
|
97
|
+
- `src/portal.js`: `connect` and `billing`, the hand-offs to a workspace page.
|
|
61
98
|
- `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
|
|
62
99
|
|
|
63
100
|
## Development
|
|
@@ -71,7 +108,8 @@ CI runs it in its own **CLI Checks** job (`pr-ci.yml`, `main-ci.yml`) whenever
|
|
|
71
108
|
part of **PR Gate** and deploys nothing.
|
|
72
109
|
|
|
73
110
|
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
|
|
111
|
+
drives the CLI in-process (plus child-process tests for Ctrl-C, a clean exit and `run`'s
|
|
112
|
+
real child). `test/secrets.test.js` searches every output and request for a sentinel value.
|
|
75
113
|
`test/schedule.test.js` imports `apps/portal-v2/src/lib/cadence.ts` to prove the
|
|
76
114
|
`--every` presets give the portal's cron; that needs a Node that strips TypeScript
|
|
77
115
|
types (23.6+, as `.nvmrc`), and is skipped on Node 22. `test/contract.test.js` checks
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
// owner or admin approves the request in a browser session at app.syndai.ai/cli. The key is
|
|
5
5
|
// stored per workspace in credentials.json (0600). `SYNDAI_API_KEY` wins over it (CI).
|
|
6
6
|
// There is no --api-key flag, so a key never lands in shell history.
|
|
7
|
+
//
|
|
8
|
+
// Reading secret values needs `coding:secrets:reveal`, which only this browser approval
|
|
9
|
+
// grants. Production values also need a person to allow THIS key in the browser, for 10
|
|
10
|
+
// minutes (`productionStepUp`); a stored key alone, or SYNDAI_API_KEY, never reads them.
|
|
7
11
|
|
|
8
12
|
import { hostname } from "node:os";
|
|
9
13
|
import { ApiError, CliError, EXIT, explain, PORTAL_URL, steppable } from "./errors.js";
|
|
@@ -82,6 +86,9 @@ export function createContext(io, flags, { stepUp = true } = {}) {
|
|
|
82
86
|
return ctx;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/** One approval for every `syndai secrets` / `env` / `run` command, not one per scope. */
|
|
90
|
+
export const SECRETS_SCOPES = ["coding:secrets:read", "coding:secrets:write", "coding:secrets:reveal"];
|
|
91
|
+
|
|
85
92
|
/**
|
|
86
93
|
* A 403 named the scope it needs. On a terminal, with the stored login (never
|
|
87
94
|
* SYNDAI_API_KEY, which belongs to CI), ask once, approve a key with the current scopes plus
|
|
@@ -90,9 +97,11 @@ export function createContext(io, flags, { stepUp = true } = {}) {
|
|
|
90
97
|
async function stepUpScope(ctx, scope) {
|
|
91
98
|
const { io, ui } = ctx;
|
|
92
99
|
if (!ui.interactive || !steppable(scope) || ctx.credential().source === "SYNDAI_API_KEY") return false;
|
|
93
|
-
const
|
|
100
|
+
const secrets = SECRETS_SCOPES.includes(scope);
|
|
101
|
+
const what = secrets ? "access to this workspace's secrets" : `the ${scope} scope`;
|
|
102
|
+
const answer = await ask(io, `This needs ${what}. Approve it in your browser? [Y/n] `);
|
|
94
103
|
if (/^n/i.test(answer)) return false;
|
|
95
|
-
const token = await deviceApproval(ctx, "CLI", await scopesWith(ctx, [scope]));
|
|
104
|
+
const token = await deviceApproval(ctx, "CLI", await scopesWith(ctx, secrets ? SECRETS_SCOPES : [scope]));
|
|
96
105
|
await storeKey(ctx, token);
|
|
97
106
|
return true;
|
|
98
107
|
}
|
|
@@ -136,14 +145,53 @@ async function storeKey(ctx, token) {
|
|
|
136
145
|
* Ask for a key and wait until an owner approves it in the browser. Returns the token
|
|
137
146
|
* response `{api_key, customer_id, key_id, expires_at}`.
|
|
138
147
|
*/
|
|
139
|
-
export
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
export function deviceApproval(ctx, clientName, scopes) {
|
|
149
|
+
return browserApproval(ctx, {
|
|
150
|
+
start: "deviceStart",
|
|
151
|
+
token: "deviceToken",
|
|
142
152
|
// No `scopes` means the owner preset (owner_door); a list asks for those on top of it.
|
|
143
153
|
body: { client_name: clientName, host: hostname(), ...(scopes ? { scopes } : {}) },
|
|
144
154
|
auth: false,
|
|
145
|
-
|
|
155
|
+
again: "syndai login",
|
|
146
156
|
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A production value was refused (403 PRODUCTION_STEP_UP_REQUIRED). On a terminal with the
|
|
161
|
+
* stored login (never SYNDAI_API_KEY), ask once, wait for a person to allow THIS key in the
|
|
162
|
+
* browser, and answer true so the caller retries once. Off a terminal: false (exit 3).
|
|
163
|
+
*/
|
|
164
|
+
export async function productionStepUp(ctx) {
|
|
165
|
+
const { io, ui } = ctx;
|
|
166
|
+
if (!ui.interactive || ctx.credential().source === "SYNDAI_API_KEY") return false;
|
|
167
|
+
const answer = await ask(io, "Production values need your approval in the browser for this command. Open it? [Y/n] ");
|
|
168
|
+
if (/^n/i.test(answer)) return false;
|
|
169
|
+
let grant;
|
|
170
|
+
const allowed = await browserApproval(ctx, {
|
|
171
|
+
start: "startProductionAccess",
|
|
172
|
+
token: "redeemProductionAccess",
|
|
173
|
+
body: { client_name: "CLI", host: hostname() },
|
|
174
|
+
auth: true,
|
|
175
|
+
again: "run the command again",
|
|
176
|
+
onStart: (start) => {
|
|
177
|
+
grant = start.device_code;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const until = new Date(allowed.allowed_until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
181
|
+
ui.progress(`Production values allowed for this command (until ${until}).`);
|
|
182
|
+
// The grant lives in this process only: another process with the same stored key has none.
|
|
183
|
+
return grant;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The RFC 8628 loop both approvals share: start, show the code and open the page, poll the
|
|
188
|
+
* token route until a person decides. `auth` sends the stored key (the production step-up
|
|
189
|
+
* is for the key that asks); `again` is the command a denied or expired code points to.
|
|
190
|
+
*/
|
|
191
|
+
async function browserApproval(ctx, { start: startRoute, token: tokenRoute, body, auth, again, onStart }) {
|
|
192
|
+
const { client, ui, io } = ctx;
|
|
193
|
+
const start = await client.call(startRoute, { body, auth, retry: true });
|
|
194
|
+
onStart?.(start);
|
|
147
195
|
const url = start.verification_uri_complete ?? start.verification_uri;
|
|
148
196
|
ui.progress(`Your one-time code: ${ui.bold(start.user_code)}`);
|
|
149
197
|
ui.progress(`Approve it in your browser: ${url}`);
|
|
@@ -159,9 +207,9 @@ export async function deviceApproval(ctx, clientName, scopes) {
|
|
|
159
207
|
if (io.signal.aborted) throw interrupted();
|
|
160
208
|
throw err;
|
|
161
209
|
}
|
|
162
|
-
const res = await client.call(
|
|
210
|
+
const res = await client.call(tokenRoute, {
|
|
163
211
|
body: { device_code: start.device_code },
|
|
164
|
-
auth
|
|
212
|
+
auth,
|
|
165
213
|
retry: true,
|
|
166
214
|
raw: true,
|
|
167
215
|
});
|
|
@@ -173,14 +221,10 @@ export async function deviceApproval(ctx, clientName, scopes) {
|
|
|
173
221
|
continue;
|
|
174
222
|
}
|
|
175
223
|
if (error === "access_denied") {
|
|
176
|
-
throw new CliError(EXIT.AUTH, "The request was denied in the browser.", {
|
|
177
|
-
next: "syndai login",
|
|
178
|
-
});
|
|
224
|
+
throw new CliError(EXIT.AUTH, "The request was denied in the browser.", { next: again });
|
|
179
225
|
}
|
|
180
226
|
if (error === "expired_token" || error === "authorization_pending") {
|
|
181
|
-
throw new CliError(EXIT.AUTH, "The code expired before it was approved.", {
|
|
182
|
-
next: "syndai login",
|
|
183
|
-
});
|
|
227
|
+
throw new CliError(EXIT.AUTH, "The code expired before it was approved.", { next: again });
|
|
184
228
|
}
|
|
185
229
|
throw new ApiError(res.status, res.body, res.headers);
|
|
186
230
|
}
|
|
@@ -216,6 +260,50 @@ export async function login(ctx) {
|
|
|
216
260
|
return EXIT.OK;
|
|
217
261
|
}
|
|
218
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
|
+
|
|
219
307
|
export async function logout(ctx) {
|
|
220
308
|
const { io, ui, paths } = ctx;
|
|
221
309
|
const stored = storedCredential(io, paths);
|
package/src/changes.js
CHANGED
|
@@ -32,9 +32,15 @@ export async function change(ctx, words) {
|
|
|
32
32
|
const text = words.join(" ").trim();
|
|
33
33
|
if (!text) throw new CliError(EXIT.USAGE, 'Say what to change: syndai change "make the header blue"');
|
|
34
34
|
const timeoutMs = timeoutOf(flags);
|
|
35
|
+
// --learn is explicit (L10): change prompts carry docs links and the owner's own site.
|
|
36
|
+
const reference = flags.learn === undefined ? null : productUrlFromText(flags.learn);
|
|
37
|
+
if (flags.learn !== undefined && !reference)
|
|
38
|
+
throw new CliError(EXIT.USAGE, `--learn takes a public product URL, like https://cal.com, not ${flags.learn}.`);
|
|
39
|
+
needReferenceForSignIn(flags, reference);
|
|
35
40
|
const project = await resolveProject(ctx, { app: flags.app });
|
|
36
41
|
const outcome = flags.review ? "opens a pull request for your review" : "goes live when checks pass";
|
|
37
42
|
ui.progress(`Changing ${projectHost(project)} · ${outcome}`);
|
|
43
|
+
if (reference) ui.progress(`Learning from ${hostOf(reference)}`);
|
|
38
44
|
|
|
39
45
|
const imageRefs = [];
|
|
40
46
|
for (const file of flags.image ?? []) imageRefs.push(await uploadImage(ctx, file));
|
|
@@ -54,11 +60,13 @@ export async function change(ctx, words) {
|
|
|
54
60
|
coding_repository_id: project.id,
|
|
55
61
|
external_request_id: pending.request_id,
|
|
56
62
|
...(imageRefs.length ? { image_refs: imageRefs } : {}),
|
|
63
|
+
...referenceField(reference, flags["sign-in"]),
|
|
57
64
|
...(flags.review ? { policy_config: { autonomy: "auto" } } : {}),
|
|
58
65
|
},
|
|
59
66
|
});
|
|
60
67
|
clearPending(paths, pending.request_id);
|
|
61
68
|
ui.progress(ui.dim(`Change ${runId}`));
|
|
69
|
+
signInHint(ctx, reference);
|
|
62
70
|
if (flags["no-wait"]) {
|
|
63
71
|
return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
|
|
64
72
|
}
|
|
@@ -80,6 +88,23 @@ export function productUrlFromText(text) {
|
|
|
80
88
|
return null;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
const hostOf = (url) => new URL(url).hostname.replace(/^www\./, "");
|
|
92
|
+
// The typed top-level create-run field (learn-from-a-product L1); `metadata.reference_product` is
|
|
93
|
+
// server-owned and 400s.
|
|
94
|
+
// `sign_in` only when asked, so a public-only request stays byte-identical.
|
|
95
|
+
const referenceField = (url, signIn) =>
|
|
96
|
+
url ? { reference_product: { kind: "url", value: url, ...(signIn ? { sign_in: true } : {}) } } : {};
|
|
97
|
+
|
|
98
|
+
/** --sign-in needs a product to sign in to; a usage error before any POST. */
|
|
99
|
+
function needReferenceForSignIn(flags, reference, hint = "--learn <product URL>") {
|
|
100
|
+
if (flags["sign-in"] && !reference) throw new CliError(EXIT.USAGE, `--sign-in needs ${hint}.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The owner signs in on the run page themselves (never a password in the terminal). */
|
|
104
|
+
function signInHint({ flags, ui }, reference) {
|
|
105
|
+
if (flags["sign-in"] && reference) ui.progress(`Sign in to ${hostOf(reference)} on the run page once it asks.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
83
108
|
/** Build-home parity: start a New project. A product URL in the text makes it "your own version
|
|
84
109
|
* of X" (SPEC-build-your-own D5) unless --no-learn; its feature-pick question is asked inline. */
|
|
85
110
|
export async function newProject(ctx, words) {
|
|
@@ -87,10 +112,13 @@ export async function newProject(ctx, words) {
|
|
|
87
112
|
const text = words.join(" ").trim();
|
|
88
113
|
if (!text) throw new CliError(EXIT.USAGE, 'Say what to build: syndai new "a booking site for my salon"');
|
|
89
114
|
const timeoutMs = timeoutOf(flags);
|
|
115
|
+
// --no-learn says "don't read that product"; --sign-in says "read it signed in": they conflict.
|
|
116
|
+
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.");
|
|
90
117
|
const reference = flags["no-learn"] ? null : productUrlFromText(text);
|
|
118
|
+
needReferenceForSignIn(flags, reference, "a product URL in the text, like https://cal.com");
|
|
91
119
|
ui.progress(
|
|
92
120
|
reference
|
|
93
|
-
? `New project · your own version of ${
|
|
121
|
+
? `New project · your own version of ${hostOf(reference)}`
|
|
94
122
|
: "New project",
|
|
95
123
|
);
|
|
96
124
|
const pending = findPending(paths, { app: "new", text }) ?? {
|
|
@@ -105,11 +133,12 @@ export async function newProject(ctx, words) {
|
|
|
105
133
|
prompt: text,
|
|
106
134
|
new_repository: { scaffold: "syndai-web" },
|
|
107
135
|
external_request_id: pending.request_id,
|
|
108
|
-
...(reference
|
|
136
|
+
...referenceField(reference, flags["sign-in"]),
|
|
109
137
|
},
|
|
110
138
|
});
|
|
111
139
|
clearPending(paths, pending.request_id);
|
|
112
140
|
ui.progress(ui.dim(`Change ${runId}`));
|
|
141
|
+
signInHint(ctx, reference);
|
|
113
142
|
if (flags["no-wait"]) {
|
|
114
143
|
return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
|
|
115
144
|
}
|
package/src/dotenv.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `.env` text <-> values, for `syndai secrets import` and `syndai secrets download`.
|
|
2
|
+
//
|
|
3
|
+
// Read: KEY=VALUE lines, an optional `export ` prefix, blank lines and `#` comments, an
|
|
4
|
+
// unquoted value's ` # comment`, 'single quotes' (literal) and "double quotes" (\n \r \t \"
|
|
5
|
+
// \\ escapes); either quote may span lines. A later KEY wins. Errors name the line, never a
|
|
6
|
+
// value or a name: a malformed line may be a pasted secret.
|
|
7
|
+
// Write: one KEY=value per name, sorted, quoted only when the value needs it.
|
|
8
|
+
|
|
9
|
+
import { CliError, EXIT } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
/** The server's secret-name grammar (`ENV_NAME_PATTERN`). */
|
|
12
|
+
export const NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
13
|
+
|
|
14
|
+
const ESCAPES = { n: "\n", r: "\r", t: "\t", '"': '"', "\\": "\\" };
|
|
15
|
+
|
|
16
|
+
function bad(lineNo, why) {
|
|
17
|
+
return new CliError(EXIT.USAGE, `.env line ${lineNo}: ${why}.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `.env` text -> `[{key_name, value}]` in file order (a repeated name keeps its last value). */
|
|
21
|
+
export function parseDotenv(text) {
|
|
22
|
+
const lines = String(text).replace(/^/, "").split(/\r?\n/);
|
|
23
|
+
const values = new Map();
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const lineNo = i + 1;
|
|
26
|
+
// Only the start is trimmed: a quoted value keeps its trailing spaces.
|
|
27
|
+
const line = lines[i].trimStart();
|
|
28
|
+
if (!line.trim() || line.startsWith("#")) continue;
|
|
29
|
+
const match = /^(?:export\s+)?([^=\s]+)\s*=\s*(.*)$/.exec(line);
|
|
30
|
+
if (!match) throw bad(lineNo, "expected KEY=VALUE");
|
|
31
|
+
const [, name, rest] = match;
|
|
32
|
+
if (!NAME.test(name)) throw bad(lineNo, "a name is uppercase letters, digits and _ (like API_TOKEN)");
|
|
33
|
+
const quote = rest[0];
|
|
34
|
+
if (quote !== '"' && quote !== "'") {
|
|
35
|
+
values.delete(name);
|
|
36
|
+
values.set(name, rest.replace(/\s+#.*$/, "").trim());
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// A quoted value: read on (across lines) to the closing quote.
|
|
40
|
+
let value = "";
|
|
41
|
+
let chunk = rest.slice(1);
|
|
42
|
+
let closed = false;
|
|
43
|
+
for (;;) {
|
|
44
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
45
|
+
const ch = chunk[j];
|
|
46
|
+
if (quote === '"' && ch === "\\" && j + 1 < chunk.length) {
|
|
47
|
+
const next = chunk[++j];
|
|
48
|
+
value += ESCAPES[next] ?? `\\${next}`;
|
|
49
|
+
} else if (ch === quote) {
|
|
50
|
+
if (!/^\s*(#.*)?$/.test(chunk.slice(j + 1))) throw bad(i + 1, "text after the closing quote");
|
|
51
|
+
closed = true;
|
|
52
|
+
break;
|
|
53
|
+
} else {
|
|
54
|
+
value += ch;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (closed) break;
|
|
58
|
+
if (++i >= lines.length) throw bad(lineNo, "a quote is never closed");
|
|
59
|
+
value += "\n";
|
|
60
|
+
chunk = lines[i];
|
|
61
|
+
}
|
|
62
|
+
values.delete(name);
|
|
63
|
+
values.set(name, value);
|
|
64
|
+
}
|
|
65
|
+
return [...values].map(([key_name, value]) => ({ key_name, value }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `{NAME: value}` -> `.env` text that `parseDotenv` (and dotenv, docker) read back exactly. */
|
|
69
|
+
export function serializeDotenv(values) {
|
|
70
|
+
return Object.keys(values)
|
|
71
|
+
.sort()
|
|
72
|
+
.map((name) => `${name}=${quoted(values[name])}\n`)
|
|
73
|
+
.join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function quoted(value) {
|
|
77
|
+
if (/^[\w./:@%+,=-]*$/.test(value)) return value;
|
|
78
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
79
|
+
return `"${value.replace(/[\\"\n\r\t]/g, (ch) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t" })[ch])}"`;
|
|
80
|
+
}
|
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,
|
|
@@ -82,13 +98,14 @@ const KNOWN = {
|
|
|
82
98
|
null,
|
|
83
99
|
],
|
|
84
100
|
CLI_UPGRADE_REQUIRED: [EXIT.FAILED, "This version of syndai is too old.", "npx -y syndai@latest"],
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"A code-host connection needs finishing in the portal.",
|
|
90
|
-
"https://app.syndai.ai",
|
|
101
|
+
PRODUCTION_STEP_UP_REQUIRED: [
|
|
102
|
+
EXIT.NEEDS_YOU,
|
|
103
|
+
"Production values need you to allow this command in your browser; a key alone never reads them.",
|
|
104
|
+
"run it again in a terminal logged in with syndai login",
|
|
91
105
|
],
|
|
106
|
+
RUN_CONCURRENCY_LIMIT: [EXIT.FAILED, "Too many changes are running at once.", "syndai status"],
|
|
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],
|
|
92
109
|
HITL_REQUEST_SETTLED: [EXIT.FAILED, "That question is already answered or closed.", null],
|
|
93
110
|
RUN_TERMINATED_BEFORE_RESPONSE: [EXIT.FAILED, "The change ended before your answer arrived.", null],
|
|
94
111
|
TEXT_EDIT_REFUSED: [
|
|
@@ -102,12 +119,37 @@ const KNOWN = {
|
|
|
102
119
|
"A change is in flight on this site; edit text once it lands.",
|
|
103
120
|
(p) => (p.run_id ? `syndai watch ${p.run_id}` : "syndai status"),
|
|
104
121
|
],
|
|
122
|
+
REFERENCE_PRODUCT_INVALID: [
|
|
123
|
+
EXIT.USAGE,
|
|
124
|
+
"That is not a product link to learn from; use its full address, like https://cal.com.",
|
|
125
|
+
null,
|
|
126
|
+
],
|
|
127
|
+
REFERENCE_PRODUCT_NEEDS_SCAFFOLD: [
|
|
128
|
+
EXIT.USAGE,
|
|
129
|
+
"Learning from a product needs a Syndai site project.",
|
|
130
|
+
null,
|
|
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],
|
|
105
147
|
NOT_FOUND: [EXIT.FAILED, "Not found.", "syndai status"],
|
|
106
148
|
RATE_LIMITED: [EXIT.UNAVAILABLE, "Syndai is rate-limiting this workspace.", null],
|
|
107
149
|
};
|
|
108
150
|
|
|
109
|
-
/** Map any thrown error to {exitCode, message, next}. */
|
|
110
|
-
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) {
|
|
111
153
|
if (err instanceof CliError) {
|
|
112
154
|
return { exitCode: err.exitCode, message: err.message, next: err.next };
|
|
113
155
|
}
|
|
@@ -122,10 +164,11 @@ export function explain(err) {
|
|
|
122
164
|
const known = err.code ? KNOWN[err.code] : null;
|
|
123
165
|
if (known) {
|
|
124
166
|
const [exitCode, sentence, next] = known;
|
|
167
|
+
const page = PORTAL_PAGES[err.code];
|
|
125
168
|
return {
|
|
126
169
|
exitCode,
|
|
127
170
|
message: typeof sentence === "function" ? sentence(err.problem) : sentence,
|
|
128
|
-
next: typeof next === "function" ? next(err.problem) : next,
|
|
171
|
+
next: page ? portalUrl(customerId, page) : typeof next === "function" ? next(err.problem) : next,
|
|
129
172
|
};
|
|
130
173
|
}
|
|
131
174
|
const auth = err.status === 401 || err.status === 402 || err.status === 403;
|