syndai 0.5.0 → 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 CHANGED
@@ -2,6 +2,38 @@
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
+
5
37
  ## 0.5.0
6
38
 
7
39
  Environments and secrets from a terminal, Doppler-style.
package/README.md CHANGED
@@ -30,27 +30,31 @@ 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
+ | `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 |
46
50
  | `status [--app A]` | what is running, and everything that needs you |
47
51
  | `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
48
52
  | `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
49
53
  | `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 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 |
51
- | `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget USD\|none]` | change only what differs |
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 |
52
56
  | `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 USD]` | repeat a run you liked; the server copies its prompt and project; the first run is the next scheduled time |
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 |
54
58
  | `api` / `api <operationId> --help` | list every published operation / show one's parameters and body |
55
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) |
56
60
 
@@ -65,6 +69,12 @@ portal's key page or `mcp install`. A production environment's values also need
65
69
  in memory only, so another process with the same key gets nothing); off a terminal or under
66
70
  `SYNDAI_API_KEY` it exits 3.
67
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`.
77
+
68
78
  Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
69
79
  4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
70
80
  unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
@@ -84,6 +94,7 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
84
94
  - `src/schedules.js`: `schedule` and `repeat` (the preset to cron mapping of `apps/portal-v2/src/lib/cadence.ts`).
85
95
  - `src/secrets.js`: `env`, `secrets` and `run` (environment choice, the production step-up retry).
86
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.
87
98
  - `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
88
99
 
89
100
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syndai",
3
- "version": "0.5.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
@@ -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
@@ -36,6 +36,7 @@ export async function change(ctx, words) {
36
36
  const reference = flags.learn === undefined ? null : productUrlFromText(flags.learn);
37
37
  if (flags.learn !== undefined && !reference)
38
38
  throw new CliError(EXIT.USAGE, `--learn takes a public product URL, like https://cal.com, not ${flags.learn}.`);
39
+ needReferenceForSignIn(flags, reference);
39
40
  const project = await resolveProject(ctx, { app: flags.app });
40
41
  const outcome = flags.review ? "opens a pull request for your review" : "goes live when checks pass";
41
42
  ui.progress(`Changing ${projectHost(project)} · ${outcome}`);
@@ -59,12 +60,13 @@ export async function change(ctx, words) {
59
60
  coding_repository_id: project.id,
60
61
  external_request_id: pending.request_id,
61
62
  ...(imageRefs.length ? { image_refs: imageRefs } : {}),
62
- ...referenceField(reference),
63
+ ...referenceField(reference, flags["sign-in"]),
63
64
  ...(flags.review ? { policy_config: { autonomy: "auto" } } : {}),
64
65
  },
65
66
  });
66
67
  clearPending(paths, pending.request_id);
67
68
  ui.progress(ui.dim(`Change ${runId}`));
69
+ signInHint(ctx, reference);
68
70
  if (flags["no-wait"]) {
69
71
  return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
70
72
  }
@@ -89,7 +91,19 @@ export function productUrlFromText(text) {
89
91
  const hostOf = (url) => new URL(url).hostname.replace(/^www\./, "");
90
92
  // The typed top-level create-run field (learn-from-a-product L1); `metadata.reference_product` is
91
93
  // server-owned and 400s.
92
- const referenceField = (url) => (url ? { reference_product: { kind: "url", value: url } } : {});
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
+ }
93
107
 
94
108
  /** Build-home parity: start a New project. A product URL in the text makes it "your own version
95
109
  * of X" (SPEC-build-your-own D5) unless --no-learn; its feature-pick question is asked inline. */
@@ -98,7 +112,10 @@ export async function newProject(ctx, words) {
98
112
  const text = words.join(" ").trim();
99
113
  if (!text) throw new CliError(EXIT.USAGE, 'Say what to build: syndai new "a booking site for my salon"');
100
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.");
101
117
  const reference = flags["no-learn"] ? null : productUrlFromText(text);
118
+ needReferenceForSignIn(flags, reference, "a product URL in the text, like https://cal.com");
102
119
  ui.progress(
103
120
  reference
104
121
  ? `New project · your own version of ${hostOf(reference)}`
@@ -116,11 +133,12 @@ export async function newProject(ctx, words) {
116
133
  prompt: text,
117
134
  new_repository: { scaffold: "syndai-web" },
118
135
  external_request_id: pending.request_id,
119
- ...referenceField(reference),
136
+ ...referenceField(reference, flags["sign-in"]),
120
137
  },
121
138
  });
122
139
  clearPending(paths, pending.request_id);
123
140
  ui.progress(ui.dim(`Change ${runId}`));
141
+ signInHint(ctx, reference);
124
142
  if (flags["no-wait"]) {
125
143
  return finish(ctx, await getChange(ctx, runId), { exitCode: EXIT.OK, next: `syndai watch ${runId}` });
126
144
  }
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.", BILLING_URL],
71
- BUDGET_EXHAUSTED: [EXIT.AUTH, "This workspace is out of credits.", BILLING_URL],
72
- QUOTA_EXCEEDED: [EXIT.AUTH, "This workspace is out of credits for now.", BILLING_URL],
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.", "syndai link"],
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
@@ -4,17 +4,18 @@
4
4
  import { spawn } from "node:child_process";
5
5
  import { setTimeout as sleep } from "node:timers/promises";
6
6
  import { parseArgs } from "node:util";
7
- import { createContext, login, logout, whoami } from "./auth.js";
7
+ import { createContext, login, logout, signup, whoami } from "./auth.js";
8
8
  import { answer, cancel, change, editText, goLive, newProject, review, revert, status, watch } from "./changes.js";
9
- import { CliError, EXIT, explain } from "./errors.js";
9
+ import { ApiError, CliError, EXIT, explain, PORTAL_PAGES } from "./errors.js";
10
10
  import { VERSION } from "./http.js";
11
11
  import { mcp } from "./mcp.js";
12
- import { link } from "./projects.js";
12
+ import { billing, connect } from "./portal.js";
13
+ import { link, publish } from "./projects.js";
13
14
  import { api } from "./api.js";
14
15
  import { repeat, schedule } from "./schedules.js";
15
16
  import { env, run, secrets } from "./secrets.js";
16
17
  import { settings } from "./settings.js";
17
- import { closeInput, openBrowser, page } from "./ui.js";
18
+ import { closeInput, openBrowser, openPage, page } from "./ui.js";
18
19
 
19
20
  const str = { type: "string" };
20
21
  const bool = { type: "boolean" };
@@ -22,12 +23,15 @@ const COMMON = { json: bool, "no-input": bool, help: { type: "boolean", short: "
22
23
 
23
24
  const COMMANDS = {
24
25
  change: {
25
- usage: 'change "<what>" [--app A] [--review] [--learn URL] [--image F]... [--no-wait] [--timeout D]',
26
+ usage:
27
+ 'change "<what>" [--app A] [--review] [--learn URL [--sign-in]] [--image F]... [--no-wait] [--timeout D]',
26
28
  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).",
29
+ "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" +
30
+ " --sign-in: Sign in to the --learn product yourself on the run page first, so the run reads its in-app pages.",
28
31
  options: {
29
32
  app: str,
30
33
  learn: str,
34
+ "sign-in": bool,
31
35
  review: bool,
32
36
  image: { type: "string", multiple: true },
33
37
  "no-wait": bool,
@@ -37,9 +41,11 @@ const COMMANDS = {
37
41
  stepUp: false,
38
42
  },
39
43
  new: {
40
- usage: 'new "<what>" [--no-learn] [--no-wait] [--timeout D]',
41
- summary: "Start a new project; a product URL in the text builds your own version of it.",
42
- options: { "no-learn": bool, "no-wait": bool, timeout: str },
44
+ usage: 'new "<what>" [--no-learn | --sign-in] [--no-wait] [--timeout D]',
45
+ summary:
46
+ "Start a new project; a product URL in the text builds your own version of it.\n" +
47
+ " --sign-in: Sign in to that product yourself on the run page first, so the run reads its in-app pages.",
48
+ options: { "no-learn": bool, "sign-in": bool, "no-wait": bool, timeout: str },
43
49
  run: (ctx, args) => newProject(ctx, args),
44
50
  stepUp: false,
45
51
  },
@@ -97,10 +103,19 @@ const COMMANDS = {
97
103
  login: {
98
104
  usage: "login [--with-token] [--scope S]...",
99
105
  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).",
106
+ "Log in by approving this computer in your browser (--scope adds access to the key's; --with-token reads a key from stdin).\n" +
107
+ " No workspace yet? syndai signup <email>",
101
108
  options: { "with-token": bool, scope: { type: "string", multiple: true } },
102
109
  run: (ctx) => login(ctx),
103
110
  },
111
+ signup: {
112
+ usage: "signup <email> [--code C]",
113
+ summary:
114
+ "Make a workspace and log in with a code emailed to you (continuing accepts the Terms and Privacy Policy).\n" +
115
+ " Off a terminal it emails the code and exits 3; rerun with --code.",
116
+ options: { code: str },
117
+ run: (ctx, args) => signup(ctx, args),
118
+ },
104
119
  logout: {
105
120
  usage: "logout",
106
121
  summary: "Revoke and forget the stored key.",
@@ -113,6 +128,26 @@ const COMMANDS = {
113
128
  options: {},
114
129
  run: (ctx) => whoami(ctx),
115
130
  },
131
+ publish: {
132
+ usage: "publish [--app A]",
133
+ summary: "Publish the project as a hosted app (or redeploy it) and print its live URL.",
134
+ options: { app: str },
135
+ run: (ctx) => publish(ctx),
136
+ },
137
+ connect: {
138
+ usage: "connect <provider>",
139
+ summary:
140
+ "Open the workspace page that connects github, sentry, claude, chatgpt, openrouter, slack or a social account; finish in the browser.",
141
+ options: {},
142
+ run: (ctx, args) => connect(ctx, args),
143
+ },
144
+ billing: {
145
+ usage: "billing [--open] | packs | buy <pack> | plan <tier> [--interval month|year] [--country CC] | card | cancel",
146
+ summary:
147
+ "Plan and balance; buy and plan open a checkout a person pays in the browser (nothing is charged until then).",
148
+ options: { open: bool, interval: str, country: str },
149
+ run: (ctx, args) => billing(ctx, args),
150
+ },
116
151
  link: {
117
152
  usage: "link [project] [--env E]",
118
153
  summary: "Remember which project (and environment) this directory uses (stored outside the repo).",
@@ -155,8 +190,8 @@ const COMMANDS = {
155
190
  },
156
191
  schedule: {
157
192
  usage:
158
- 'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget USD] [--app A]\n' +
159
- " | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget USD|none]\n" +
193
+ 'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget CREDITS] [--app A]\n' +
194
+ " | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget CREDITS|none]\n" +
160
195
  " | pause | resume | run-now | delete <id> [--yes]",
161
196
  summary:
162
197
  '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 +199,7 @@ const COMMANDS = {
164
199
  run: (ctx, args) => schedule(ctx, args),
165
200
  },
166
201
  repeat: {
167
- usage: "repeat <run> --every E [--at T] [--tz Z] [--budget USD]",
202
+ usage: "repeat <run> --every E [--at T] [--tz Z] [--budget CREDITS]",
168
203
  summary: "Run a change you liked again on a schedule (its first run is the next scheduled time).",
169
204
  options: { every: str, at: str, tz: str, budget: str },
170
205
  run: (ctx, args) => repeat(ctx, args),
@@ -276,7 +311,7 @@ export async function main(argv, io) {
276
311
  try {
277
312
  return await spec.run(ctx, child ?? parsed.positionals);
278
313
  } catch (err) {
279
- return report(ctx, err);
314
+ return await report(ctx, err);
280
315
  } finally {
281
316
  closeInput(io);
282
317
  }
@@ -298,10 +333,14 @@ function splitCommand(args, options) {
298
333
  return [args, []];
299
334
  }
300
335
 
301
- /** One error line (plus the next command) on stderr; a JSON error on stdout for machines. */
302
- function report(ctx, err) {
336
+ /**
337
+ * One error line (plus the next command) on stderr; a JSON error on stdout for machines. An
338
+ * error a workspace page fixes (billing, GitHub) names that page, opened on a terminal.
339
+ */
340
+ async function report(ctx, err) {
303
341
  const { ui, io } = ctx;
304
- const { exitCode, message, next } = explain(err);
342
+ const page = err instanceof ApiError ? PORTAL_PAGES[err.code] : null;
343
+ const { exitCode, message, next } = explain(err, page ? await ctx.customerId().catch(() => null) : null);
305
344
  if (io.env.SYNDAI_DEBUG && err?.stack) io.stderr.write(`${err.stack}\n`);
306
345
  const extra = err instanceof CliError ? err.result : null;
307
346
  if (exitCode === EXIT.INTERRUPTED) {
@@ -317,5 +356,6 @@ function report(ctx, err) {
317
356
  }
318
357
  }
319
358
  if (next) ui.progress(` next: ${next}`);
359
+ if (page) openPage(ctx, next);
320
360
  return exitCode;
321
361
  }
package/src/portal.js ADDED
@@ -0,0 +1,156 @@
1
+ // What stays in the browser, one step away: `connect` and `billing` hand off to the exact
2
+ // workspace page (G1: `portalUrl`). Connections and payment need a person there, so no key
3
+ // may read connection status or pay; the CLI prints the page, opens it on a terminal and
4
+ // never polls. `billing` reads the balance and starts a checkout a person then pays.
5
+
6
+ import { CliError, EXIT, UnavailableError, portalUrl } from "./errors.js";
7
+ import { openPage } from "./ui.js";
8
+
9
+ /** `syndai connect <provider>` -> its card. The `connect` list is the portal's
10
+ * `CONNECT_PROVIDERS` (apps/portal-v2/src/lib/connection-link.ts). */
11
+ const CONNECTIONS = [
12
+ "claude",
13
+ "chatgpt",
14
+ "openrouter",
15
+ "linkedin",
16
+ "instagram",
17
+ "x",
18
+ "threads",
19
+ "bluesky",
20
+ "buffer",
21
+ "tiktok",
22
+ "youtube",
23
+ "slack",
24
+ ];
25
+ export const CONNECT_PAGES = Object.freeze({
26
+ github: "settings#repositories",
27
+ sentry: "sentry",
28
+ ...Object.fromEntries(CONNECTIONS.map((provider) => [provider, `connections?connect=${provider}`])),
29
+ });
30
+
31
+ /** Print a workspace page (JSON: `{url}`), say what to do there, open it on a terminal. */
32
+ function handOff(ctx, url, line, data = {}) {
33
+ ctx.ui.result({ ...data, url }, url);
34
+ if (!ctx.ui.json) ctx.ui.progress(line);
35
+ openPage(ctx, url);
36
+ return EXIT.OK;
37
+ }
38
+
39
+ export async function connect(ctx, [provider]) {
40
+ const names = Object.keys(CONNECT_PAGES).join(", ");
41
+ const page = provider ? CONNECT_PAGES[provider.toLowerCase()] : null;
42
+ if (!page) {
43
+ throw new CliError(EXIT.USAGE, provider ? `Unknown provider "${provider}". One of: ${names}.` : `Connect what? One of: ${names}.`);
44
+ }
45
+ return handOff(ctx, portalUrl(await ctx.customerId(), page), "Finish in the browser.");
46
+ }
47
+
48
+ export async function billing(ctx, [sub = "status", arg]) {
49
+ switch (sub) {
50
+ case "status":
51
+ return billingStatus(ctx);
52
+ case "packs":
53
+ return packs(ctx);
54
+ case "buy":
55
+ return buy(ctx, arg);
56
+ case "plan":
57
+ return plan(ctx, arg);
58
+ case "card":
59
+ case "cancel":
60
+ return handOff(
61
+ ctx,
62
+ portalUrl(await ctx.customerId(), "billing"),
63
+ "Change the card, plan or cancel on this page.",
64
+ );
65
+ default:
66
+ throw new CliError(EXIT.USAGE, `Unknown billing command "${sub}". Use packs, buy, plan, card or cancel.`);
67
+ }
68
+ }
69
+
70
+ const dollars = (usd) => `${usd < 0 ? "-" : ""}$${Math.abs(usd).toFixed(2)}`;
71
+
72
+ async function billingStatus(ctx) {
73
+ const { client, ui, io, flags } = ctx;
74
+ const [usage, subscription, customerId] = await Promise.all([
75
+ client.call("usage"),
76
+ // The plan's renewal comes from the payment provider: its outage only drops that line.
77
+ client.call("subscription").then(
78
+ (body) => body?.subscription ?? null,
79
+ (err) => {
80
+ if (err instanceof UnavailableError) return null;
81
+ throw err;
82
+ },
83
+ ),
84
+ ctx.customerId(),
85
+ ]);
86
+ // The portal's `balanceUsd` (apps/portal-v2/src/sync/usage-api.ts): packs + included left - holds.
87
+ const held = usage.bids_usd ?? 0;
88
+ const balance = usage.pack_balance_usd + usage.included_remaining_usd - held;
89
+ const url = portalUrl(customerId, "billing");
90
+ const planCode = subscription?.plan_code ?? usage.plan_code ?? null;
91
+ const renews = subscription?.current_period_end
92
+ ? ` (${subscription.interval ?? ""}ly, ${subscription.cancel_at_period_end ? "ends" : "renews"} ${subscription.current_period_end.slice(0, 10)})`
93
+ : "";
94
+ const parts = [`packs ${dollars(usage.pack_balance_usd)}`, `included ${dollars(usage.included_remaining_usd)} left`];
95
+ if (held) parts.push(`${dollars(held)} held`);
96
+ ui.result(
97
+ {
98
+ plan_code: planCode,
99
+ subscription,
100
+ balance_usd: balance,
101
+ pack_balance_usd: usage.pack_balance_usd,
102
+ included_remaining_usd: usage.included_remaining_usd,
103
+ bids_usd: held,
104
+ included_resets_at: usage.included_resets_at ?? null,
105
+ url,
106
+ },
107
+ [`Plan ${planCode ?? "unknown"}${renews}`, `Balance ${dollars(balance)} (${parts.join(", ")})`, `Billing ${url}`].join("\n"),
108
+ );
109
+ if (flags.open && ui.interactive) io.openUrl(url);
110
+ return EXIT.OK;
111
+ }
112
+
113
+ async function packs(ctx) {
114
+ const body = await ctx.client.call("packs");
115
+ const lines = (body.packs ?? []).map(
116
+ (p) => ` ${p.key.padEnd(14)} ${String(p.credits).padStart(5)} credits ${p.prices?.usd === undefined ? "" : dollars(p.prices.usd / 100)}`,
117
+ );
118
+ ctx.ui.result(body, ["Credit packs (syndai billing buy <pack>):", ...lines].join("\n"));
119
+ return EXIT.OK;
120
+ }
121
+
122
+ async function buy(ctx, pack) {
123
+ if (!pack) throw new CliError(EXIT.USAGE, "Which pack? syndai billing buy <pack>", { next: "syndai billing packs" });
124
+ const packKey = pack.startsWith("pack_") ? pack : `pack_${pack}`;
125
+ const { checkout_url: url } = await ctx.client.call("packCheckout", {
126
+ body: { pack_key: packKey, country: buyerCountry(ctx) },
127
+ });
128
+ return checkout(ctx, url);
129
+ }
130
+
131
+ async function plan(ctx, tier) {
132
+ if (!tier) throw new CliError(EXIT.USAGE, "Which plan? syndai billing plan <tier> [--interval month|year]");
133
+ const interval = ctx.flags.interval ?? "month";
134
+ if (!["month", "year"].includes(interval)) throw new CliError(EXIT.USAGE, "--interval is month or year.");
135
+ const { checkout_url: url } = await ctx.client.call("planCheckout", {
136
+ body: { plan_code: tier.startsWith("tier_") ? tier : `tier_${tier}`, interval, country: buyerCountry(ctx) },
137
+ });
138
+ return checkout(ctx, url);
139
+ }
140
+
141
+ function checkout(ctx, url) {
142
+ return handOff(ctx, url, "A person pays on this page; nothing is charged until then.", { checkout_url: url });
143
+ }
144
+
145
+ /**
146
+ * The billing country a checkout is priced in (ISO alpha-2): `--country`, else the locale's
147
+ * region, else US. A claim only: the provider's billing address re-checks it.
148
+ */
149
+ function buyerCountry({ flags, io }) {
150
+ if (flags.country !== undefined) {
151
+ if (!/^[A-Za-z]{2}$/.test(flags.country)) throw new CliError(EXIT.USAGE, "--country is a two-letter code, like US or DE.");
152
+ return flags.country.toUpperCase();
153
+ }
154
+ const locale = io.env.LC_ALL || io.env.LC_MONETARY || io.env.LANG || "";
155
+ return /^[a-z]{2,3}_([A-Z]{2})\b/.exec(locale)?.[1] ?? "US";
156
+ }
package/src/projects.js CHANGED
@@ -197,3 +197,20 @@ export async function link(ctx, ref) {
197
197
  );
198
198
  return EXIT.OK;
199
199
  }
200
+
201
+ /** `syndai publish [--app A]`: put the project's default branch live as a hosted app. */
202
+ export async function publish(ctx) {
203
+ const project = await resolveProject(ctx, { app: ctx.flags.app });
204
+ // A call while a deploy is in flight answers that deploy, so a retry never starts a second.
205
+ const { publication } = await ctx.client.call("publishApp", {
206
+ params: { customer_id: project.customer_id, coding_repository_id: project.id },
207
+ retry: true,
208
+ });
209
+ const url = publication?.url ?? project.live_url;
210
+ ctx.ui.result(
211
+ { project_id: project.id, url, state: publication?.state ?? null, publication },
212
+ url ? `Publishing ${projectLabel(project)}: ${url}` : `Publishing ${projectLabel(project)}.`,
213
+ );
214
+ if (!ctx.ui.json) ctx.ui.progress(ctx.ui.dim("It is live there once the deploy finishes (a minute or two)."));
215
+ return EXIT.OK;
216
+ }
package/src/routes.js CHANGED
@@ -8,6 +8,8 @@ const EXT = "/api/v1/external/coding";
8
8
  export const ROUTES = Object.freeze({
9
9
  deviceStart: ["POST", "/api/v1/cli/device"],
10
10
  deviceToken: ["POST", "/api/v1/cli/device/token"],
11
+ signup: ["POST", "/api/v1/signup"],
12
+ signupVerify: ["POST", "/api/v1/signup/verify"],
11
13
  me: ["GET", `${EXT}/me`],
12
14
  revokeMe: ["DELETE", `${EXT}/me`],
13
15
  listRepos: ["GET", `${EXT}/repos`],
@@ -20,6 +22,12 @@ export const ROUTES = Object.freeze({
20
22
  "PATCH",
21
23
  `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`,
22
24
  ],
25
+ publishApp: ["POST", `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publish`],
26
+ usage: ["GET", `${EXT}/usage/current-period`],
27
+ subscription: ["GET", `${EXT}/purchases/subscription`],
28
+ packs: ["GET", `${EXT}/purchases/packs`],
29
+ packCheckout: ["POST", `${EXT}/purchases/pack-checkout`],
30
+ planCheckout: ["POST", `${EXT}/purchases/subscription-checkout`],
23
31
  getBugScan: ["GET", `${EXT}/repositories/{repository_id}/bug-scan`],
24
32
  setBugScan: ["PUT", `${EXT}/repositories/{repository_id}/bug-scan`],
25
33
  getPolicy: ["GET", `${EXT}/policy/defaults`],
package/src/schedules.js CHANGED
@@ -16,6 +16,9 @@ const PAUSE_REASONS = {
16
16
  consecutive_failures: "it failed 5 times in a row",
17
17
  invalid_trigger: "its schedule is no longer valid",
18
18
  };
19
+ // The fixed, published peg (backend `billing/tier_catalog.USD_PER_CREDIT`): the API stores the
20
+ // per-run cap in USD, the CLI takes and shows it in credits like the portal.
21
+ const USD_PER_CREDIT = 0.25;
19
22
  const CADENCE_HELP = 'hourly --at :MM, daily|weekdays --at HH:MM, weekly --at "mon HH:MM", or a 5-field cron';
20
23
 
21
24
  // --- cadence -------------------------------------------------------------------------------
@@ -106,15 +109,20 @@ function dayIndex(text) {
106
109
  return index >= 0 && full[index].startsWith(text) ? index : -1;
107
110
  }
108
111
 
109
- /** `--budget 5` -> 5; `--budget none` -> null (no per-run cap) where allowed. */
112
+ /** USD -> credits for display: whole numbers bare, else up to two decimals ("20", "6", "0.08"). */
113
+ function credits(usd) {
114
+ return String(Math.round((usd / USD_PER_CREDIT) * 100) / 100);
115
+ }
116
+
117
+ /** `--budget 20` (credits) -> 5 (USD); `--budget none` -> null (no per-run cap) where allowed. */
110
118
  function budgetOf(text, { allowNone }) {
111
119
  if (text === undefined) return undefined;
112
120
  if (allowNone && text.trim().toLowerCase() === "none") return null;
113
- const value = Number(text.replace(/^\$/, ""));
121
+ const value = Number(text);
114
122
  if (!Number.isFinite(value) || value <= 0) {
115
- throw new CliError(EXIT.USAGE, `--budget is a dollar amount per run${allowNone ? ", or none" : ""}, not "${text}".`);
123
+ throw new CliError(EXIT.USAGE, `--budget is a number of credits per run${allowNone ? ", or none" : ""}, not "${text}".`);
116
124
  }
117
- return value;
125
+ return value * USD_PER_CREDIT;
118
126
  }
119
127
 
120
128
  function triggerOf(ctx, { every, at, tz }) {
@@ -162,7 +170,7 @@ function card(s, occurrences = []) {
162
170
  const lines = [`${labelOf(s)} · ${stateOf(s)}`, `"${firstLine(s.prompt)}"`];
163
171
  if (s.repo_name) lines.push(`Project ${s.repo_name}`);
164
172
  if (s.occurrence_budget_usd !== null && s.occurrence_budget_usd !== undefined) {
165
- lines.push(`Cap $${s.occurrence_budget_usd} per run`);
173
+ lines.push(`Cap ${credits(s.occurrence_budget_usd)} credits per run`);
166
174
  }
167
175
  if (s.status !== "paused") lines.push(upcomingLine(s));
168
176
  lines.push("", occurrences.length ? "History:" : "History: none yet");
@@ -368,7 +376,7 @@ export async function repeat(ctx, [runId]) {
368
376
  trigger,
369
377
  ...(budget === undefined ? {} : { occurrence_budget_usd: budget }),
370
378
  });
371
- const cost = typeof run.cost_usd === "number" ? ` (last run cost $${run.cost_usd.toFixed(2)})` : "";
379
+ const cost = typeof run.cost_usd === "number" ? ` (last run cost ${credits(run.cost_usd)} credits)` : "";
372
380
  return printCreated(
373
381
  ctx,
374
382
  created,
package/src/ui.js CHANGED
@@ -133,6 +133,11 @@ export function openBrowser(url) {
133
133
  }
134
134
  }
135
135
 
136
+ /** `review`'s rule for a hand-off page: open it on a terminal; the caller always prints it. */
137
+ export function openPage({ ui, io }, url) {
138
+ if (ui.interactive && !ui.json) io.openUrl(url);
139
+ }
140
+
136
141
  /** Show long text through $PAGER on a terminal; print it plainly otherwise. */
137
142
  export function page(proc, text) {
138
143
  if (!proc.stdout.isTTY) {
package/src/watch.js CHANGED
@@ -5,14 +5,18 @@
5
5
  // cursor. Ctrl-C only detaches: it never sends a cancel. `--logs` also tails the run's SSE
6
6
  // events (resuming with Last-Event-ID) next to the loop.
7
7
 
8
- import { ApiError, CliError, EXIT, explain } from "./errors.js";
8
+ import { ApiError, CliError, EXIT, UnavailableError, explain } from "./errors.js";
9
9
  import { backoffMs } from "./http.js";
10
- import { ask, ulid } from "./ui.js";
10
+ import { ask, openPage, ulid } from "./ui.js";
11
11
 
12
12
  const POLL_WAIT_S = 45;
13
13
  const MIN_POLL_GAP_MS = 1000;
14
14
  const DEADLINE_GRACE_MS = 2000;
15
15
  const TIMED_OUT = Symbol("timed out");
16
+ // A deploy answers 502/503 for a minute or two, longer than one request's retries: the
17
+ // watch keeps polling through it and gives up only after this long without an answer.
18
+ const OUTAGE_MAX_MS = 10 * 60_000;
19
+ const OUTAGE_MAX_WAIT_MS = 30_000;
16
20
 
17
21
  // Exit code per ChangeState state (docs: syndai.ai/docs/cli, "Exit codes").
18
22
  const STATE_EXIT = {
@@ -21,6 +25,7 @@ const STATE_EXIT = {
21
25
  ready: EXIT.OK,
22
26
  live: EXIT.OK,
23
27
  live_unverified: EXIT.UNVERIFIED,
28
+ unverified: EXIT.UNVERIFIED,
24
29
  failed: EXIT.FAILED,
25
30
  reverted: EXIT.FAILED,
26
31
  cancelled: EXIT.FAILED,
@@ -35,6 +40,7 @@ export function nextFor(state) {
35
40
  const id = state.run_id;
36
41
  if (state.state === "needs_input") return `syndai answer ${id}`;
37
42
  if (state.state === "held") return `syndai review ${id}`;
43
+ if (state.state === "unverified") return state.pr_url ?? state.run_url ?? null;
38
44
  if (state.state === "ready") return `syndai go-live ${id}`;
39
45
  if (state.state === "live_unverified") return `syndai watch ${id}`;
40
46
  return state.done ? null : `syndai watch ${id}`;
@@ -55,6 +61,8 @@ export function describe(state) {
55
61
  return `Held: ${state.held?.message ?? state.held?.reason ?? "needs your review"}`;
56
62
  case "ready":
57
63
  return `Ready for review${headline}`;
64
+ case "unverified":
65
+ return `Delivered, but checks could not be verified${headline}`;
58
66
  case "live":
59
67
  return `Live: ${state.live_url ?? state.project?.live_url ?? ""}`.trim();
60
68
  case "live_unverified":
@@ -68,12 +76,19 @@ export function describe(state) {
68
76
  export function finish(ctx, state, { exitCode = exitFor(state), next = nextFor(state) } = {}) {
69
77
  const { ui } = ctx;
70
78
  const lines = [describe(state)];
79
+ for (const link of questionLinks(state)) lines.push(`Connect it here: ${link}`);
80
+ if (state.pr_url) lines.push(ui.dim(state.pr_url));
71
81
  if (state.run_url) lines.push(ui.dim(state.run_url));
72
82
  ui.result({ ...state, ...(next ? { next } : {}) }, lines.join("\n"));
73
83
  if (next && !ui.json) ui.progress(`Next: ${next}`);
74
84
  return exitCode;
75
85
  }
76
86
 
87
+ /** The pages an open question needs (a connection to make), each an absolute portal URL. */
88
+ function questionLinks(state) {
89
+ return state.state === "needs_input" ? (state.needs_input ?? []).map((q) => q.link).filter(Boolean) : [];
90
+ }
91
+
77
92
  export async function getChange(ctx, runId) {
78
93
  return ctx.client.call("changeState", { params: { run_id: runId }, query: { wait: 0 } });
79
94
  }
@@ -95,6 +110,8 @@ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = nul
95
110
  let last = null;
96
111
  let lastLine = null;
97
112
  const answered = new Set(); // a state read before our answer landed must not ask again
113
+ let outageSince = null;
114
+ let outageTries = 0;
98
115
  try {
99
116
  for (;;) {
100
117
  const remaining = deadline === null ? Number.POSITIVE_INFINITY : deadline - io.now();
@@ -111,14 +128,24 @@ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = nul
111
128
  });
112
129
  } catch (err) {
113
130
  if (err === TIMED_OUT) return timedOut(ctx, runId, last);
114
- throw err;
131
+ if (!(err instanceof UnavailableError)) throw err;
132
+ outageSince ??= io.now();
133
+ if (io.now() - outageSince >= OUTAGE_MAX_MS) throw err;
134
+ if (outageTries++ === 0) ui.progress(ui.dim(`· Syndai is unreachable (${err.message}); still watching`));
135
+ await io.sleep(Math.min(OUTAGE_MAX_WAIT_MS, backoffMs(outageTries + 4)), io.signal);
136
+ continue;
115
137
  }
138
+ outageSince = null;
139
+ outageTries = 0;
116
140
  const line = describe(state);
117
141
  const changed = line !== lastLine || state.cursor !== cursor;
118
142
  const final =
119
143
  (state.done && state.state !== untilNot) || (state.state === "needs_input" && !ui.interactive);
120
144
  if (final) return finish(ctx, state);
121
- if (line !== lastLine) ui.progress(`${ui.dim("·")} ${line}`);
145
+ if (line !== lastLine) {
146
+ ui.progress(`${ui.dim("·")} ${line}`);
147
+ if (state.pr_url) ui.progress(ui.dim(state.pr_url));
148
+ }
122
149
  lastLine = line;
123
150
  cursor = state.cursor ?? cursor;
124
151
  last = state;
@@ -162,6 +189,10 @@ function timedOut(ctx, runId, last) {
162
189
  export async function answerInteractively(ctx, runId, questions) {
163
190
  for (const question of questions) {
164
191
  ctx.ui.progress(ctx.ui.bold(question.summary ?? "A question about your change"));
192
+ if (question.link) {
193
+ ctx.ui.progress(`Connect it here: ${question.link}`);
194
+ openPage(ctx, question.link);
195
+ }
165
196
  const entries = [];
166
197
  for (const input of question.inputs ?? []) {
167
198
  const entry = await askInput(ctx, input);