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 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
- | `link [project]` | remember which project this directory changes (`$XDG_CONFIG_HOME/syndai/links.json`, never in the repo) |
22
- | `change "<what>" [--app A] [--review] [--image F]... [--no-wait] [--timeout D]` | start a change and follow it to the outcome |
23
- | `new "<what>" [--no-learn] [--no-wait] [--timeout D]` | start a New project; a product URL in the text builds your own version of it (its feature pick is asked inline, Enter keeps the pre-checked core) |
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 USD] [--app A]` | create one; `E` is `hourly` (`--at :MM`), `daily`/`weekdays` (`--at HH:MM`), `weekly` (`--at "mon HH:MM"`) or a 5-field cron |
34
- | `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget USD\|none]` | change only what differs |
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 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 |
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 two child-process tests for Ctrl-C and a clean exit).
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.4.1",
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 answer = await ask(io, `This needs the ${scope} scope. Approve it in your browser? [Y/n] `);
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 async function deviceApproval(ctx, clientName, scopes) {
140
- const { client, ui, io } = ctx;
141
- const start = await client.call("deviceStart", {
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
- retry: true,
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("deviceToken", {
210
+ const res = await client.call(tokenRoute, {
163
211
  body: { device_code: start.device_code },
164
- auth: false,
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 ${new URL(reference).hostname.replace(/^www\./, "")}`
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 ? { metadata: { reference_product: { kind: "url", value: 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.", 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,
@@ -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
- RUN_CONCURRENCY_LIMIT: [EXIT.FAILED, "Too many changes are running at once.", "syndai status"],
86
- REPO_NOT_BOUND: [EXIT.USAGE, "That project is not available to this workspace.", "syndai link"],
87
- INSTALLATION_INCOMPLETE: [
88
- EXIT.FAILED,
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;