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/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
@@ -28,7 +28,8 @@ export async function listProjects(ctx) {
28
28
  full_name: repo.full_name,
29
29
  live_url: app?.url ?? null,
30
30
  slug: app?.publication?.slug ?? null,
31
- repo_urls: [repo.repo_url, repo.clone_url, repo.html_url].filter(Boolean),
31
+ // A local clone can only be of a GitHub repo (Syndai-hosted ones are never cloned).
32
+ remote: repo.provider === "github" ? normalizeRemote(`github.com/${repo.full_name}`) : null,
32
33
  protected_paths: repo.protected_paths ?? [],
33
34
  customer_id: customerId,
34
35
  };
@@ -84,7 +85,7 @@ export async function resolveProject(ctx, { app, required = true, picker = true,
84
85
  }
85
86
  }
86
87
  const remotes = gitRemotes(io.cwd);
87
- const byRemote = projects.filter((p) => p.repo_urls.some((u) => remotes.has(normalizeRemote(u))));
88
+ const byRemote = projects.filter((p) => p.remote && remotes.has(p.remote));
88
89
  if (byRemote.length === 1) return byRemote[0];
89
90
  if (projects.length === 1) return projects[0];
90
91
  if (picker && ui.interactive && projects.length > 1) return pickInteractively(ctx, projects);
@@ -197,3 +198,20 @@ export async function link(ctx, ref) {
197
198
  );
198
199
  return EXIT.OK;
199
200
  }
201
+
202
+ /** `syndai publish [--app A]`: put the project's default branch live as a hosted app. */
203
+ export async function publish(ctx) {
204
+ const project = await resolveProject(ctx, { app: ctx.flags.app });
205
+ // A call while a deploy is in flight answers that deploy, so a retry never starts a second.
206
+ const { publication } = await ctx.client.call("publishApp", {
207
+ params: { customer_id: project.customer_id, coding_repository_id: project.id },
208
+ retry: true,
209
+ });
210
+ const url = publication?.url ?? project.live_url;
211
+ ctx.ui.result(
212
+ { project_id: project.id, url, state: publication?.state ?? null, publication },
213
+ url ? `Publishing ${projectLabel(project)}: ${url}` : `Publishing ${projectLabel(project)}.`,
214
+ );
215
+ if (!ctx.ui.json) ctx.ui.progress(ctx.ui.dim("It is live there once the deploy finishes (a minute or two)."));
216
+ return EXIT.OK;
217
+ }
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,8 +22,15 @@ export const ROUTES = Object.freeze({
20
22
  "PATCH",
21
23
  `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`,
22
24
  ],
23
- getBugScan: ["GET", `${EXT}/repositories/{repository_id}/bug-scan`],
24
- setBugScan: ["PUT", `${EXT}/repositories/{repository_id}/bug-scan`],
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`],
31
+ listScans: ["GET", `${EXT}/repositories/{repository_id}/scans`],
32
+ setScanRoutine: ["PUT", `${EXT}/repositories/{repository_id}/scans`],
33
+ runScanNow: ["POST", `${EXT}/repositories/{repository_id}/scans/{kind}/run`],
25
34
  getPolicy: ["GET", `${EXT}/policy/defaults`],
26
35
  putPolicy: ["PUT", `${EXT}/policy/defaults`],
27
36
  imageIngest: ["POST", `${EXT}/runs/image-ingest`],
@@ -36,7 +45,10 @@ export const ROUTES = Object.freeze({
36
45
  landRetry: ["POST", `${EXT}/runs/{run_id}/land/retry`],
37
46
  landClose: ["POST", `${EXT}/runs/{run_id}/land/close`],
38
47
  goLive: ["POST", `${EXT}/runs/{run_id}/land/go-live`],
48
+ landBranch: ["POST", `${EXT}/repos/{coding_repository_id}/land`],
39
49
  heldDiff: ["GET", `${EXT}/runs/{run_id}/land/held-diff`],
50
+ compareKnowledgeFolder: ["POST", `${EXT}/repos/{coding_repository_id}/knowledge/folders/{name}/manifest`],
51
+ pushKnowledgeFolderFiles: ["POST", `${EXT}/repos/{coding_repository_id}/knowledge/folders/{name}/files`],
40
52
  listSchedules: ["GET", `${EXT}/schedules`],
41
53
  createSchedule: ["POST", `${EXT}/schedules`],
42
54
  getSchedule: ["GET", `${EXT}/schedules/{schedule_id}`],
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/settings.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Settings live in four places server-side:
4
4
  // autonomy repo-scoped policy default GET/PUT policy/defaults
5
5
  // feature_requests the app's publication GET/PATCH .../publication
6
- // bug_scan the repo's Bug Finder schedule GET/PUT repositories/{id}/bug-scan
6
+ // bug_scan the repo's weekly Code bugs check GET/PUT repositories/{id}/scans
7
7
  // protected_paths the repo row PATCH repos/{id} (a key may only tighten)
8
8
 
9
9
  import { ApiError, CliError, EXIT, explain } from "./errors.js";
@@ -66,9 +66,9 @@ async function apply(ctx, project, { key, op, value }) {
66
66
  body: { feature_requests: value },
67
67
  });
68
68
  } else if (key === "bug_scan") {
69
- await client.call("setBugScan", {
69
+ await client.call("setScanRoutine", {
70
70
  params: { repository_id: project.id },
71
- body: { enabled: value === "on" },
71
+ body: { kind: "code", cadence: value === "on" ? "weekly" : "off" },
72
72
  });
73
73
  } else {
74
74
  const added = value
@@ -91,7 +91,7 @@ async function read(ctx, project) {
91
91
  const [policy, publication, bugScan] = await Promise.all([
92
92
  settle(client.call("getPolicy", { query: { coding_repository_id: project.id } })),
93
93
  settle(client.call("getPublication", { params: ids })),
94
- settle(client.call("getBugScan", { params: { repository_id: project.id } })),
94
+ settle(client.call("listScans", { params: { repository_id: project.id } })),
95
95
  ]);
96
96
  const unavailable = {};
97
97
  for (const [key, got] of [
@@ -104,7 +104,7 @@ async function read(ctx, project) {
104
104
  return {
105
105
  autonomy: policy.value?.config?.autonomy ?? null,
106
106
  feature_requests: publication.value?.feature_requests ?? null,
107
- bug_scan: bugScan.value ? (bugScan.value.enabled ? "on" : "off") : null,
107
+ bug_scan: bugScan.value ? codeScan(bugScan.value) : null,
108
108
  protected_paths: project.protected_paths,
109
109
  unavailable,
110
110
  };
@@ -128,3 +128,9 @@ async function orNull(promise) {
128
128
  throw err;
129
129
  }
130
130
  }
131
+
132
+ // The Code bugs routine is "on" while it runs on a cadence with its spend agreed.
133
+ function codeScan(scans) {
134
+ const code = (scans.kinds ?? []).find((k) => k.kind === "code");
135
+ return code && code.cadence !== "off" && !code.needs_consent ? "on" : "off";
136
+ }