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 +62 -0
- package/README.md +45 -7
- package/package.json +1 -1
- package/src/auth.js +102 -14
- package/src/changes.js +31 -2
- package/src/dotenv.js +80 -0
- package/src/errors.js +57 -14
- package/src/main.js +135 -23
- package/src/portal.js +156 -0
- package/src/projects.js +52 -4
- package/src/routes.js +16 -0
- package/src/schedules.js +14 -6
- package/src/secrets.js +317 -0
- package/src/ui.js +55 -0
- package/src/watch.js +35 -4
package/src/main.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// Command table, argument parsing and the one error report. `main(argv, io)` takes all
|
|
2
2
|
// process I/O as `io`, so tests drive it in-process with a fake server and fake clocks.
|
|
3
3
|
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { createContext, login, logout, whoami } from "./auth.js";
|
|
7
|
+
import { createContext, login, logout, signup, whoami } from "./auth.js";
|
|
7
8
|
import { answer, cancel, change, editText, goLive, newProject, review, revert, status, watch } from "./changes.js";
|
|
8
|
-
import { CliError, EXIT, explain } from "./errors.js";
|
|
9
|
+
import { ApiError, CliError, EXIT, explain, PORTAL_PAGES } from "./errors.js";
|
|
9
10
|
import { VERSION } from "./http.js";
|
|
10
11
|
import { mcp } from "./mcp.js";
|
|
11
|
-
import {
|
|
12
|
+
import { billing, connect } from "./portal.js";
|
|
13
|
+
import { link, publish } from "./projects.js";
|
|
12
14
|
import { api } from "./api.js";
|
|
13
15
|
import { repeat, schedule } from "./schedules.js";
|
|
16
|
+
import { env, run, secrets } from "./secrets.js";
|
|
14
17
|
import { settings } from "./settings.js";
|
|
15
|
-
import { closeInput, openBrowser, page } from "./ui.js";
|
|
18
|
+
import { closeInput, openBrowser, openPage, page } from "./ui.js";
|
|
16
19
|
|
|
17
20
|
const str = { type: "string" };
|
|
18
21
|
const bool = { type: "boolean" };
|
|
@@ -20,10 +23,15 @@ const COMMON = { json: bool, "no-input": bool, help: { type: "boolean", short: "
|
|
|
20
23
|
|
|
21
24
|
const COMMANDS = {
|
|
22
25
|
change: {
|
|
23
|
-
usage:
|
|
24
|
-
|
|
26
|
+
usage:
|
|
27
|
+
'change "<what>" [--app A] [--review] [--learn URL [--sign-in]] [--image F]... [--no-wait] [--timeout D]',
|
|
28
|
+
summary:
|
|
29
|
+
"Make a change; it goes live when its checks pass (--review: open a PR instead; --learn: pick features to add from a product's public pages).\n" +
|
|
30
|
+
" --sign-in: Sign in to the --learn product yourself on the run page first, so the run reads its in-app pages.",
|
|
25
31
|
options: {
|
|
26
32
|
app: str,
|
|
33
|
+
learn: str,
|
|
34
|
+
"sign-in": bool,
|
|
27
35
|
review: bool,
|
|
28
36
|
image: { type: "string", multiple: true },
|
|
29
37
|
"no-wait": bool,
|
|
@@ -33,9 +41,11 @@ const COMMANDS = {
|
|
|
33
41
|
stepUp: false,
|
|
34
42
|
},
|
|
35
43
|
new: {
|
|
36
|
-
usage: 'new "<what>" [--no-learn] [--no-wait] [--timeout D]',
|
|
37
|
-
summary:
|
|
38
|
-
|
|
44
|
+
usage: 'new "<what>" [--no-learn | --sign-in] [--no-wait] [--timeout D]',
|
|
45
|
+
summary:
|
|
46
|
+
"Start a new project; a product URL in the text builds your own version of it.\n" +
|
|
47
|
+
" --sign-in: Sign in to that product yourself on the run page first, so the run reads its in-app pages.",
|
|
48
|
+
options: { "no-learn": bool, "sign-in": bool, "no-wait": bool, timeout: str },
|
|
39
49
|
run: (ctx, args) => newProject(ctx, args),
|
|
40
50
|
stepUp: false,
|
|
41
51
|
},
|
|
@@ -93,10 +103,19 @@ const COMMANDS = {
|
|
|
93
103
|
login: {
|
|
94
104
|
usage: "login [--with-token] [--scope S]...",
|
|
95
105
|
summary:
|
|
96
|
-
"Log in by approving this computer in your browser (--scope adds access to the key's; --with-token reads a key from stdin)
|
|
106
|
+
"Log in by approving this computer in your browser (--scope adds access to the key's; --with-token reads a key from stdin).\n" +
|
|
107
|
+
" No workspace yet? syndai signup <email>",
|
|
97
108
|
options: { "with-token": bool, scope: { type: "string", multiple: true } },
|
|
98
109
|
run: (ctx) => login(ctx),
|
|
99
110
|
},
|
|
111
|
+
signup: {
|
|
112
|
+
usage: "signup <email> [--code C]",
|
|
113
|
+
summary:
|
|
114
|
+
"Make a workspace and log in with a code emailed to you (continuing accepts the Terms and Privacy Policy).\n" +
|
|
115
|
+
" Off a terminal it emails the code and exits 3; rerun with --code.",
|
|
116
|
+
options: { code: str },
|
|
117
|
+
run: (ctx, args) => signup(ctx, args),
|
|
118
|
+
},
|
|
100
119
|
logout: {
|
|
101
120
|
usage: "logout",
|
|
102
121
|
summary: "Revoke and forget the stored key.",
|
|
@@ -109,12 +128,54 @@ const COMMANDS = {
|
|
|
109
128
|
options: {},
|
|
110
129
|
run: (ctx) => whoami(ctx),
|
|
111
130
|
},
|
|
112
|
-
|
|
113
|
-
usage: "
|
|
114
|
-
summary: "
|
|
131
|
+
publish: {
|
|
132
|
+
usage: "publish [--app A]",
|
|
133
|
+
summary: "Publish the project as a hosted app (or redeploy it) and print its live URL.",
|
|
115
134
|
options: { app: str },
|
|
135
|
+
run: (ctx) => publish(ctx),
|
|
136
|
+
},
|
|
137
|
+
connect: {
|
|
138
|
+
usage: "connect <provider>",
|
|
139
|
+
summary:
|
|
140
|
+
"Open the workspace page that connects github, sentry, claude, chatgpt, openrouter, slack or a social account; finish in the browser.",
|
|
141
|
+
options: {},
|
|
142
|
+
run: (ctx, args) => connect(ctx, args),
|
|
143
|
+
},
|
|
144
|
+
billing: {
|
|
145
|
+
usage: "billing [--open] | packs | buy <pack> | plan <tier> [--interval month|year] [--country CC] | card | cancel",
|
|
146
|
+
summary:
|
|
147
|
+
"Plan and balance; buy and plan open a checkout a person pays in the browser (nothing is charged until then).",
|
|
148
|
+
options: { open: bool, interval: str, country: str },
|
|
149
|
+
run: (ctx, args) => billing(ctx, args),
|
|
150
|
+
},
|
|
151
|
+
link: {
|
|
152
|
+
usage: "link [project] [--env E]",
|
|
153
|
+
summary: "Remember which project (and environment) this directory uses (stored outside the repo).",
|
|
154
|
+
options: { app: str, env: str },
|
|
116
155
|
run: (ctx, [ref]) => link(ctx, ref),
|
|
117
156
|
},
|
|
157
|
+
env: {
|
|
158
|
+
usage: "env ls | use <env>",
|
|
159
|
+
summary: "List the workspace's environments, or pick the one this directory uses.",
|
|
160
|
+
options: {},
|
|
161
|
+
run: (ctx, args) => env(ctx, args),
|
|
162
|
+
},
|
|
163
|
+
secrets: {
|
|
164
|
+
usage:
|
|
165
|
+
"secrets [ls] | set KEY | rm KEY [--yes] | import FILE|- | get KEY [--plain] | download [-o FILE]\n" +
|
|
166
|
+
" [--env E] [--app A] [--project]",
|
|
167
|
+
summary:
|
|
168
|
+
"An environment's secrets: names only in lists and JSON; set reads the value from a hidden prompt or stdin; get and download need a browser-approved login (production: a fresh approval).",
|
|
169
|
+
options: { env: str, app: str, project: bool, plain: bool, output: { type: "string", short: "o" }, yes: bool },
|
|
170
|
+
run: (ctx, args) => secrets(ctx, args),
|
|
171
|
+
},
|
|
172
|
+
run: {
|
|
173
|
+
usage: "run [--env E] [--app A] [--] <command> [args...]",
|
|
174
|
+
summary: "Run a local command with the environment's values in its environment only; its exit code is syndai's.",
|
|
175
|
+
options: { env: str, app: str },
|
|
176
|
+
passthrough: true,
|
|
177
|
+
run: (ctx, command) => run(ctx, command),
|
|
178
|
+
},
|
|
118
179
|
settings: {
|
|
119
180
|
usage: "settings [--app A] [get | set key=value...]",
|
|
120
181
|
summary: "autonomy, feature_requests, bug_scan, protected_paths (a key can only tighten them).",
|
|
@@ -129,8 +190,8 @@ const COMMANDS = {
|
|
|
129
190
|
},
|
|
130
191
|
schedule: {
|
|
131
192
|
usage:
|
|
132
|
-
'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget
|
|
133
|
-
" | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget
|
|
193
|
+
'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget CREDITS] [--app A]\n' +
|
|
194
|
+
" | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget CREDITS|none]\n" +
|
|
134
195
|
" | pause | resume | run-now | delete <id> [--yes]",
|
|
135
196
|
summary:
|
|
136
197
|
'A project\'s recurring runs. E: hourly (--at :MM), daily or weekdays (--at HH:MM), weekly (--at "mon HH:MM"), or a 5-field cron.',
|
|
@@ -138,7 +199,7 @@ const COMMANDS = {
|
|
|
138
199
|
run: (ctx, args) => schedule(ctx, args),
|
|
139
200
|
},
|
|
140
201
|
repeat: {
|
|
141
|
-
usage: "repeat <run> --every E [--at T] [--tz Z] [--budget
|
|
202
|
+
usage: "repeat <run> --every E [--at T] [--tz Z] [--budget CREDITS]",
|
|
142
203
|
summary: "Run a change you liked again on a schedule (its first run is the next scheduled time).",
|
|
143
204
|
options: { every: str, at: str, tz: str, budget: str },
|
|
144
205
|
run: (ctx, args) => repeat(ctx, args),
|
|
@@ -167,7 +228,8 @@ export function helpText() {
|
|
|
167
228
|
"",
|
|
168
229
|
"Every command: --json (machine output; also when stdout is not a terminal),",
|
|
169
230
|
"--no-input (never prompt), -h/--help.",
|
|
170
|
-
"Environment: SYNDAI_API_KEY (wins over `syndai login
|
|
231
|
+
"Environment: SYNDAI_API_KEY (wins over `syndai login`; never reads production values),",
|
|
232
|
+
"SYNDAI_APP, SYNDAI_ENV, SYNDAI_API_URL, NO_COLOR.",
|
|
171
233
|
"Exit codes: 0 done, 1 failed, 2 usage or no project, 3 needs you, 4 live but unverified,",
|
|
172
234
|
"5 auth/scope/plan, 6 --timeout reached, 7 service unavailable, 130 interrupted.",
|
|
173
235
|
"",
|
|
@@ -187,6 +249,33 @@ export function processIo(signal) {
|
|
|
187
249
|
sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
|
|
188
250
|
openUrl: openBrowser,
|
|
189
251
|
page: (text) => page(process, text),
|
|
252
|
+
/**
|
|
253
|
+
* `syndai run`: the child shares the terminal; resolves `{code, signal}` (ENOENT rejects).
|
|
254
|
+
* While it runs the parent never exits first (that would orphan a child holding the
|
|
255
|
+
* secrets): Ctrl-C reaches the child through the terminal, and SIGTERM/SIGHUP are passed on.
|
|
256
|
+
*/
|
|
257
|
+
exec: (file, args, childEnv) =>
|
|
258
|
+
new Promise((resolve, reject) => {
|
|
259
|
+
const child = spawn(file, args, { stdio: "inherit", env: childEnv });
|
|
260
|
+
const saved = process.listeners("SIGINT");
|
|
261
|
+
process.removeAllListeners("SIGINT");
|
|
262
|
+
const forward = (sig) => child.kill(sig);
|
|
263
|
+
process.on("SIGINT", () => {});
|
|
264
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) process.on(sig, forward);
|
|
265
|
+
const restore = () => {
|
|
266
|
+
process.removeAllListeners("SIGINT");
|
|
267
|
+
for (const listener of saved) process.on("SIGINT", listener);
|
|
268
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) process.off(sig, forward);
|
|
269
|
+
};
|
|
270
|
+
child.once("error", (err) => {
|
|
271
|
+
restore();
|
|
272
|
+
reject(err);
|
|
273
|
+
});
|
|
274
|
+
child.once("exit", (code, exitSignal) => {
|
|
275
|
+
restore();
|
|
276
|
+
resolve({ code, signal: exitSignal });
|
|
277
|
+
});
|
|
278
|
+
}),
|
|
190
279
|
};
|
|
191
280
|
}
|
|
192
281
|
|
|
@@ -205,9 +294,11 @@ export async function main(argv, io) {
|
|
|
205
294
|
io.stderr.write(`error: unknown command "${command}"\n\n${helpText()}`);
|
|
206
295
|
return EXIT.USAGE;
|
|
207
296
|
}
|
|
297
|
+
const options = { ...COMMON, ...spec.options };
|
|
298
|
+
const [own, child] = spec.passthrough ? splitCommand(rest, options) : [rest, null];
|
|
208
299
|
let parsed;
|
|
209
300
|
try {
|
|
210
|
-
parsed = parseArgs({ args:
|
|
301
|
+
parsed = parseArgs({ args: own, options, allowPositionals: true });
|
|
211
302
|
} catch (err) {
|
|
212
303
|
io.stderr.write(`error: ${err.message}\n usage: syndai ${spec.usage}\n`);
|
|
213
304
|
return EXIT.USAGE;
|
|
@@ -218,18 +309,38 @@ export async function main(argv, io) {
|
|
|
218
309
|
}
|
|
219
310
|
const ctx = createContext(io, parsed.values, { stepUp: spec.stepUp !== false });
|
|
220
311
|
try {
|
|
221
|
-
return await spec.run(ctx, parsed.positionals);
|
|
312
|
+
return await spec.run(ctx, child ?? parsed.positionals);
|
|
222
313
|
} catch (err) {
|
|
223
|
-
return report(ctx, err);
|
|
314
|
+
return await report(ctx, err);
|
|
224
315
|
} finally {
|
|
225
316
|
closeInput(io);
|
|
226
317
|
}
|
|
227
318
|
}
|
|
228
319
|
|
|
229
|
-
/**
|
|
230
|
-
|
|
320
|
+
/**
|
|
321
|
+
* A passthrough command's own options end at `--` or at the child command's first word
|
|
322
|
+
* (`syndai run npm test --watch`): everything after belongs to the child, unparsed.
|
|
323
|
+
*/
|
|
324
|
+
function splitCommand(args, options) {
|
|
325
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
326
|
+
const arg = args[i];
|
|
327
|
+
if (arg === "--") return [args.slice(0, i), args.slice(i + 1)];
|
|
328
|
+
if (!arg.startsWith("-")) return [args.slice(0, i), args.slice(i)];
|
|
329
|
+
const name = arg.replace(/^--?/, "").split("=")[0];
|
|
330
|
+
const option = options[name] ?? Object.values(options).find((o) => o.short === name);
|
|
331
|
+
if (option?.type === "string" && !arg.includes("=")) i += 1; // its value is the next word
|
|
332
|
+
}
|
|
333
|
+
return [args, []];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One error line (plus the next command) on stderr; a JSON error on stdout for machines. An
|
|
338
|
+
* error a workspace page fixes (billing, GitHub) names that page, opened on a terminal.
|
|
339
|
+
*/
|
|
340
|
+
async function report(ctx, err) {
|
|
231
341
|
const { ui, io } = ctx;
|
|
232
|
-
const
|
|
342
|
+
const page = err instanceof ApiError ? PORTAL_PAGES[err.code] : null;
|
|
343
|
+
const { exitCode, message, next } = explain(err, page ? await ctx.customerId().catch(() => null) : null);
|
|
233
344
|
if (io.env.SYNDAI_DEBUG && err?.stack) io.stderr.write(`${err.stack}\n`);
|
|
234
345
|
const extra = err instanceof CliError ? err.result : null;
|
|
235
346
|
if (exitCode === EXIT.INTERRUPTED) {
|
|
@@ -245,5 +356,6 @@ function report(ctx, err) {
|
|
|
245
356
|
}
|
|
246
357
|
}
|
|
247
358
|
if (next) ui.progress(` next: ${next}`);
|
|
359
|
+
if (page) openPage(ctx, next);
|
|
248
360
|
return exitCode;
|
|
249
361
|
}
|
package/src/portal.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// What stays in the browser, one step away: `connect` and `billing` hand off to the exact
|
|
2
|
+
// workspace page (G1: `portalUrl`). Connections and payment need a person there, so no key
|
|
3
|
+
// may read connection status or pay; the CLI prints the page, opens it on a terminal and
|
|
4
|
+
// never polls. `billing` reads the balance and starts a checkout a person then pays.
|
|
5
|
+
|
|
6
|
+
import { CliError, EXIT, UnavailableError, portalUrl } from "./errors.js";
|
|
7
|
+
import { openPage } from "./ui.js";
|
|
8
|
+
|
|
9
|
+
/** `syndai connect <provider>` -> its card. The `connect` list is the portal's
|
|
10
|
+
* `CONNECT_PROVIDERS` (apps/portal-v2/src/lib/connection-link.ts). */
|
|
11
|
+
const CONNECTIONS = [
|
|
12
|
+
"claude",
|
|
13
|
+
"chatgpt",
|
|
14
|
+
"openrouter",
|
|
15
|
+
"linkedin",
|
|
16
|
+
"instagram",
|
|
17
|
+
"x",
|
|
18
|
+
"threads",
|
|
19
|
+
"bluesky",
|
|
20
|
+
"buffer",
|
|
21
|
+
"tiktok",
|
|
22
|
+
"youtube",
|
|
23
|
+
"slack",
|
|
24
|
+
];
|
|
25
|
+
export const CONNECT_PAGES = Object.freeze({
|
|
26
|
+
github: "settings#repositories",
|
|
27
|
+
sentry: "sentry",
|
|
28
|
+
...Object.fromEntries(CONNECTIONS.map((provider) => [provider, `connections?connect=${provider}`])),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Print a workspace page (JSON: `{url}`), say what to do there, open it on a terminal. */
|
|
32
|
+
function handOff(ctx, url, line, data = {}) {
|
|
33
|
+
ctx.ui.result({ ...data, url }, url);
|
|
34
|
+
if (!ctx.ui.json) ctx.ui.progress(line);
|
|
35
|
+
openPage(ctx, url);
|
|
36
|
+
return EXIT.OK;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function connect(ctx, [provider]) {
|
|
40
|
+
const names = Object.keys(CONNECT_PAGES).join(", ");
|
|
41
|
+
const page = provider ? CONNECT_PAGES[provider.toLowerCase()] : null;
|
|
42
|
+
if (!page) {
|
|
43
|
+
throw new CliError(EXIT.USAGE, provider ? `Unknown provider "${provider}". One of: ${names}.` : `Connect what? One of: ${names}.`);
|
|
44
|
+
}
|
|
45
|
+
return handOff(ctx, portalUrl(await ctx.customerId(), page), "Finish in the browser.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function billing(ctx, [sub = "status", arg]) {
|
|
49
|
+
switch (sub) {
|
|
50
|
+
case "status":
|
|
51
|
+
return billingStatus(ctx);
|
|
52
|
+
case "packs":
|
|
53
|
+
return packs(ctx);
|
|
54
|
+
case "buy":
|
|
55
|
+
return buy(ctx, arg);
|
|
56
|
+
case "plan":
|
|
57
|
+
return plan(ctx, arg);
|
|
58
|
+
case "card":
|
|
59
|
+
case "cancel":
|
|
60
|
+
return handOff(
|
|
61
|
+
ctx,
|
|
62
|
+
portalUrl(await ctx.customerId(), "billing"),
|
|
63
|
+
"Change the card, plan or cancel on this page.",
|
|
64
|
+
);
|
|
65
|
+
default:
|
|
66
|
+
throw new CliError(EXIT.USAGE, `Unknown billing command "${sub}". Use packs, buy, plan, card or cancel.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dollars = (usd) => `${usd < 0 ? "-" : ""}$${Math.abs(usd).toFixed(2)}`;
|
|
71
|
+
|
|
72
|
+
async function billingStatus(ctx) {
|
|
73
|
+
const { client, ui, io, flags } = ctx;
|
|
74
|
+
const [usage, subscription, customerId] = await Promise.all([
|
|
75
|
+
client.call("usage"),
|
|
76
|
+
// The plan's renewal comes from the payment provider: its outage only drops that line.
|
|
77
|
+
client.call("subscription").then(
|
|
78
|
+
(body) => body?.subscription ?? null,
|
|
79
|
+
(err) => {
|
|
80
|
+
if (err instanceof UnavailableError) return null;
|
|
81
|
+
throw err;
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
ctx.customerId(),
|
|
85
|
+
]);
|
|
86
|
+
// The portal's `balanceUsd` (apps/portal-v2/src/sync/usage-api.ts): packs + included left - holds.
|
|
87
|
+
const held = usage.bids_usd ?? 0;
|
|
88
|
+
const balance = usage.pack_balance_usd + usage.included_remaining_usd - held;
|
|
89
|
+
const url = portalUrl(customerId, "billing");
|
|
90
|
+
const planCode = subscription?.plan_code ?? usage.plan_code ?? null;
|
|
91
|
+
const renews = subscription?.current_period_end
|
|
92
|
+
? ` (${subscription.interval ?? ""}ly, ${subscription.cancel_at_period_end ? "ends" : "renews"} ${subscription.current_period_end.slice(0, 10)})`
|
|
93
|
+
: "";
|
|
94
|
+
const parts = [`packs ${dollars(usage.pack_balance_usd)}`, `included ${dollars(usage.included_remaining_usd)} left`];
|
|
95
|
+
if (held) parts.push(`${dollars(held)} held`);
|
|
96
|
+
ui.result(
|
|
97
|
+
{
|
|
98
|
+
plan_code: planCode,
|
|
99
|
+
subscription,
|
|
100
|
+
balance_usd: balance,
|
|
101
|
+
pack_balance_usd: usage.pack_balance_usd,
|
|
102
|
+
included_remaining_usd: usage.included_remaining_usd,
|
|
103
|
+
bids_usd: held,
|
|
104
|
+
included_resets_at: usage.included_resets_at ?? null,
|
|
105
|
+
url,
|
|
106
|
+
},
|
|
107
|
+
[`Plan ${planCode ?? "unknown"}${renews}`, `Balance ${dollars(balance)} (${parts.join(", ")})`, `Billing ${url}`].join("\n"),
|
|
108
|
+
);
|
|
109
|
+
if (flags.open && ui.interactive) io.openUrl(url);
|
|
110
|
+
return EXIT.OK;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function packs(ctx) {
|
|
114
|
+
const body = await ctx.client.call("packs");
|
|
115
|
+
const lines = (body.packs ?? []).map(
|
|
116
|
+
(p) => ` ${p.key.padEnd(14)} ${String(p.credits).padStart(5)} credits ${p.prices?.usd === undefined ? "" : dollars(p.prices.usd / 100)}`,
|
|
117
|
+
);
|
|
118
|
+
ctx.ui.result(body, ["Credit packs (syndai billing buy <pack>):", ...lines].join("\n"));
|
|
119
|
+
return EXIT.OK;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function buy(ctx, pack) {
|
|
123
|
+
if (!pack) throw new CliError(EXIT.USAGE, "Which pack? syndai billing buy <pack>", { next: "syndai billing packs" });
|
|
124
|
+
const packKey = pack.startsWith("pack_") ? pack : `pack_${pack}`;
|
|
125
|
+
const { checkout_url: url } = await ctx.client.call("packCheckout", {
|
|
126
|
+
body: { pack_key: packKey, country: buyerCountry(ctx) },
|
|
127
|
+
});
|
|
128
|
+
return checkout(ctx, url);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function plan(ctx, tier) {
|
|
132
|
+
if (!tier) throw new CliError(EXIT.USAGE, "Which plan? syndai billing plan <tier> [--interval month|year]");
|
|
133
|
+
const interval = ctx.flags.interval ?? "month";
|
|
134
|
+
if (!["month", "year"].includes(interval)) throw new CliError(EXIT.USAGE, "--interval is month or year.");
|
|
135
|
+
const { checkout_url: url } = await ctx.client.call("planCheckout", {
|
|
136
|
+
body: { plan_code: tier.startsWith("tier_") ? tier : `tier_${tier}`, interval, country: buyerCountry(ctx) },
|
|
137
|
+
});
|
|
138
|
+
return checkout(ctx, url);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function checkout(ctx, url) {
|
|
142
|
+
return handOff(ctx, url, "A person pays on this page; nothing is charged until then.", { checkout_url: url });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The billing country a checkout is priced in (ISO alpha-2): `--country`, else the locale's
|
|
147
|
+
* region, else US. A claim only: the provider's billing address re-checks it.
|
|
148
|
+
*/
|
|
149
|
+
function buyerCountry({ flags, io }) {
|
|
150
|
+
if (flags.country !== undefined) {
|
|
151
|
+
if (!/^[A-Za-z]{2}$/.test(flags.country)) throw new CliError(EXIT.USAGE, "--country is a two-letter code, like US or DE.");
|
|
152
|
+
return flags.country.toUpperCase();
|
|
153
|
+
}
|
|
154
|
+
const locale = io.env.LC_ALL || io.env.LC_MONETARY || io.env.LANG || "";
|
|
155
|
+
return /^[a-z]{2,3}_([A-Z]{2})\b/.exec(locale)?.[1] ?? "US";
|
|
156
|
+
}
|
package/src/projects.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// connected repo, the only project, a picker on a terminal. Otherwise exit 2 with the list.
|
|
4
4
|
//
|
|
5
5
|
// A project is a connected repository, joined with its app (live URL, slug) when it has one.
|
|
6
|
+
// A link may also name an environment (`syndai env use`, `link --env`): one without a project
|
|
7
|
+
// only picks the environment, so the project falls through to the next rule. The workspace's
|
|
8
|
+
// environments are looked up here too, so `link --env` and `syndai env` share one check.
|
|
6
9
|
|
|
7
10
|
import { execFileSync } from "node:child_process";
|
|
8
11
|
import path from "node:path";
|
|
@@ -74,7 +77,7 @@ export async function resolveProject(ctx, { app, required = true, picker = true,
|
|
|
74
77
|
if (io.env.SYNDAI_APP) return pick(io.env.SYNDAI_APP, "SYNDAI_APP");
|
|
75
78
|
if (useLink) {
|
|
76
79
|
const link = findLink(paths, io.cwd);
|
|
77
|
-
if (link) {
|
|
80
|
+
if (link?.project_id) {
|
|
78
81
|
const linked = projects.find((p) => p.id === link.project_id);
|
|
79
82
|
if (linked) return linked;
|
|
80
83
|
throw noProject(projects, `The project linked to ${link.dir} is no longer in this workspace.`);
|
|
@@ -156,13 +159,58 @@ export function normalizeRemote(url) {
|
|
|
156
159
|
return normal.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
export async function listEnvironments(ctx) {
|
|
163
|
+
return (await ctx.client.call("listEnvironments")).environments;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The workspace environment named `slug` (exit 2 with the list when there is none). */
|
|
167
|
+
export async function environmentBySlug(ctx, slug, environments) {
|
|
168
|
+
const envs = environments ?? (await listEnvironments(ctx));
|
|
169
|
+
const hit = envs.find((env) => env.slug === slug);
|
|
170
|
+
if (hit) return hit;
|
|
171
|
+
throw new CliError(EXIT.USAGE, `This workspace has no "${slug}" environment.`, {
|
|
172
|
+
next: "syndai env ls",
|
|
173
|
+
result: { data: { environments: envs.map((env) => env.slug) }, human: `Environments: ${envs.map((env) => env.slug).join(", ")}` },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `syndai link [project] [--env E]`: the project (and environment) this directory uses. */
|
|
159
178
|
export async function link(ctx, ref) {
|
|
160
179
|
const project = await resolveProject(ctx, { app: ref ?? ctx.flags.app, useLink: false });
|
|
161
180
|
const dir = path.resolve(ctx.io.cwd);
|
|
162
|
-
|
|
181
|
+
const here = findLink(ctx.paths, dir);
|
|
182
|
+
// A re-link keeps the environment this directory already chose, unless --env names one.
|
|
183
|
+
const environment = ctx.flags.env
|
|
184
|
+
? (await environmentBySlug(ctx, ctx.flags.env)).slug
|
|
185
|
+
: here?.dir === dir
|
|
186
|
+
? here.environment
|
|
187
|
+
: undefined;
|
|
188
|
+
saveLink(ctx.paths, dir, {
|
|
189
|
+
project_id: project.id,
|
|
190
|
+
name: project.name,
|
|
191
|
+
customer_id: project.customer_id,
|
|
192
|
+
...(environment ? { environment } : {}),
|
|
193
|
+
});
|
|
194
|
+
ctx.ui.result(
|
|
195
|
+
{ dir, project_id: project.id, name: project.name, environment: environment ?? null },
|
|
196
|
+
`Linked ${dir} to ${projectLabel(project)}${environment ? ` (${environment})` : ""}.`,
|
|
197
|
+
);
|
|
198
|
+
return EXIT.OK;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `syndai publish [--app A]`: put the project's default branch live as a hosted app. */
|
|
202
|
+
export async function publish(ctx) {
|
|
203
|
+
const project = await resolveProject(ctx, { app: ctx.flags.app });
|
|
204
|
+
// A call while a deploy is in flight answers that deploy, so a retry never starts a second.
|
|
205
|
+
const { publication } = await ctx.client.call("publishApp", {
|
|
206
|
+
params: { customer_id: project.customer_id, coding_repository_id: project.id },
|
|
207
|
+
retry: true,
|
|
208
|
+
});
|
|
209
|
+
const url = publication?.url ?? project.live_url;
|
|
163
210
|
ctx.ui.result(
|
|
164
|
-
{
|
|
165
|
-
`
|
|
211
|
+
{ project_id: project.id, url, state: publication?.state ?? null, publication },
|
|
212
|
+
url ? `Publishing ${projectLabel(project)}: ${url}` : `Publishing ${projectLabel(project)}.`,
|
|
166
213
|
);
|
|
214
|
+
if (!ctx.ui.json) ctx.ui.progress(ctx.ui.dim("It is live there once the deploy finishes (a minute or two)."));
|
|
167
215
|
return EXIT.OK;
|
|
168
216
|
}
|
package/src/routes.js
CHANGED
|
@@ -8,6 +8,8 @@ const EXT = "/api/v1/external/coding";
|
|
|
8
8
|
export const ROUTES = Object.freeze({
|
|
9
9
|
deviceStart: ["POST", "/api/v1/cli/device"],
|
|
10
10
|
deviceToken: ["POST", "/api/v1/cli/device/token"],
|
|
11
|
+
signup: ["POST", "/api/v1/signup"],
|
|
12
|
+
signupVerify: ["POST", "/api/v1/signup/verify"],
|
|
11
13
|
me: ["GET", `${EXT}/me`],
|
|
12
14
|
revokeMe: ["DELETE", `${EXT}/me`],
|
|
13
15
|
listRepos: ["GET", `${EXT}/repos`],
|
|
@@ -20,6 +22,12 @@ export const ROUTES = Object.freeze({
|
|
|
20
22
|
"PATCH",
|
|
21
23
|
`${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`,
|
|
22
24
|
],
|
|
25
|
+
publishApp: ["POST", `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publish`],
|
|
26
|
+
usage: ["GET", `${EXT}/usage/current-period`],
|
|
27
|
+
subscription: ["GET", `${EXT}/purchases/subscription`],
|
|
28
|
+
packs: ["GET", `${EXT}/purchases/packs`],
|
|
29
|
+
packCheckout: ["POST", `${EXT}/purchases/pack-checkout`],
|
|
30
|
+
planCheckout: ["POST", `${EXT}/purchases/subscription-checkout`],
|
|
23
31
|
getBugScan: ["GET", `${EXT}/repositories/{repository_id}/bug-scan`],
|
|
24
32
|
setBugScan: ["PUT", `${EXT}/repositories/{repository_id}/bug-scan`],
|
|
25
33
|
getPolicy: ["GET", `${EXT}/policy/defaults`],
|
|
@@ -45,6 +53,14 @@ export const ROUTES = Object.freeze({
|
|
|
45
53
|
pauseSchedule: ["POST", `${EXT}/schedules/{schedule_id}/pause`],
|
|
46
54
|
resumeSchedule: ["POST", `${EXT}/schedules/{schedule_id}/resume`],
|
|
47
55
|
runScheduleNow: ["POST", `${EXT}/schedules/{schedule_id}/run-now`],
|
|
56
|
+
listEnvironments: ["GET", `${EXT}/environments`],
|
|
57
|
+
listSecrets: ["GET", `${EXT}/secrets`],
|
|
58
|
+
putSecret: ["PUT", `${EXT}/secrets/{key_name}`],
|
|
59
|
+
deleteSecret: ["DELETE", `${EXT}/secrets/{key_name}`],
|
|
60
|
+
importSecrets: ["POST", `${EXT}/secrets/import`],
|
|
61
|
+
resolveSecrets: ["POST", `${EXT}/secrets/resolve`],
|
|
62
|
+
startProductionAccess: ["POST", `${EXT}/cli/production-access`],
|
|
63
|
+
redeemProductionAccess: ["POST", `${EXT}/cli/production-access/token`],
|
|
48
64
|
});
|
|
49
65
|
|
|
50
66
|
/** The MCP endpoint `syndai mcp install` points clients at (JSON-RPC, not REST). */
|
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,
|