syndai 0.5.0 → 0.7.0

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