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 +55 -0
- package/README.md +43 -5
- package/package.json +1 -1
- package/src/auth.js +44 -0
- package/src/changes.js +53 -3
- package/src/errors.js +42 -14
- package/src/main.js +83 -18
- package/src/portal.js +156 -0
- package/src/projects.js +20 -2
- package/src/routes.js +14 -2
- package/src/schedules.js +14 -6
- package/src/settings.js +11 -5
- package/src/sync.js +389 -0
- package/src/ui.js +5 -0
- package/src/watch.js +35 -4
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
|
-
|
|
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.
|
|
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
|
-
|
|
24
|
-
|
|
25
|
+
publishApp: ["POST", `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publish`],
|
|
26
|
+
usage: ["GET", `${EXT}/usage/current-period`],
|
|
27
|
+
subscription: ["GET", `${EXT}/purchases/subscription`],
|
|
28
|
+
packs: ["GET", `${EXT}/purchases/packs`],
|
|
29
|
+
packCheckout: ["POST", `${EXT}/purchases/pack-checkout`],
|
|
30
|
+
planCheckout: ["POST", `${EXT}/purchases/subscription-checkout`],
|
|
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
|
-
/**
|
|
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
|
|
121
|
+
const value = Number(text);
|
|
114
122
|
if (!Number.isFinite(value) || value <= 0) {
|
|
115
|
-
throw new CliError(EXIT.USAGE, `--budget is a
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
69
|
+
await client.call("setScanRoutine", {
|
|
70
70
|
params: { repository_id: project.id },
|
|
71
|
-
body: {
|
|
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("
|
|
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
|
|
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
|
+
}
|