syndai 0.4.0 → 0.5.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 +36 -0
- package/README.md +30 -3
- package/package.json +1 -1
- package/src/auth.js +67 -14
- package/src/changes.js +13 -2
- package/src/dotenv.js +80 -0
- package/src/errors.js +15 -0
- package/src/http.js +14 -3
- package/src/main.js +80 -8
- package/src/projects.js +35 -4
- package/src/routes.js +8 -0
- package/src/secrets.js +317 -0
- package/src/ui.js +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `syndai` npm package. Versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.5.0
|
|
6
|
+
|
|
7
|
+
Environments and secrets from a terminal, Doppler-style.
|
|
8
|
+
|
|
9
|
+
- `env ls` lists the workspace's environments (the current one marked); `env use <env>` picks
|
|
10
|
+
the one this directory uses, stored in its link outside the repo. `link [project] --env E`
|
|
11
|
+
sets both. Otherwise: `--env`, then `SYNDAI_ENV`, then the workspace default.
|
|
12
|
+
- `secrets` lists names, scope (shared or project) and when each changed, never a value.
|
|
13
|
+
`secrets set KEY` reads the value from a hidden prompt or stdin, never the command line;
|
|
14
|
+
`secrets rm KEY` asks first (`--yes` off a terminal); `secrets import FILE|-` stores a
|
|
15
|
+
`.env` in one all-or-nothing request. `--project` writes at the project's scope.
|
|
16
|
+
- `secrets get KEY` prints one value to a terminal (`--plain` for a pipe, exact bytes);
|
|
17
|
+
`secrets download [-o FILE]` writes a `.env` to a pipe, or to a 0600 file and warns when git
|
|
18
|
+
does not ignore it; `run [--env E] [--] <command>` hands the values to the child's environment
|
|
19
|
+
only and exits with its code. The values layer the way a run's do: the project's over the
|
|
20
|
+
shared ones, over Test.
|
|
21
|
+
- Reading values needs `coding:secrets:reveal`, which only a browser approval grants: the
|
|
22
|
+
first secrets command asks once for read, write and reveal together. Production values also
|
|
23
|
+
need you to allow each command in the browser; another process holding the same key, `SYNDAI_API_KEY`
|
|
24
|
+
(CI) and an MCP key never read them (exit 3).
|
|
25
|
+
|
|
26
|
+
Learn from a product.
|
|
27
|
+
|
|
28
|
+
- `new "<text>"` starts a project from scratch. A product link in the text (not GitHub) builds
|
|
29
|
+
your own version of it: Syndai reads its public pages, then asks which features to build and
|
|
30
|
+
what to call it. `--no-learn` treats the link as context only.
|
|
31
|
+
- `change "<text>" --learn URL` adds features from that product to an existing project: Syndai
|
|
32
|
+
reads its public pages and asks which features to add (3 pre-checked). A link inside the
|
|
33
|
+
change text is never treated as one.
|
|
34
|
+
|
|
35
|
+
## 0.4.1
|
|
36
|
+
|
|
37
|
+
- A running `watch`, `change` or MCP server no longer stops when `login` or a scope
|
|
38
|
+
approval in another terminal replaces its key. On a 401 the CLI reads the stored key
|
|
39
|
+
again once, and retries with it if it changed. `SYNDAI_API_KEY` is never re-read.
|
|
40
|
+
|
|
5
41
|
## 0.4.0
|
|
6
42
|
|
|
7
43
|
Every published API operation, and a project's recurring runs.
|
package/README.md
CHANGED
|
@@ -9,6 +9,18 @@ npx -y syndai@latest change "make the header blue"
|
|
|
9
9
|
# Changing bakery.syndai.app · goes live when checks pass
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Secrets and environments, Doppler-style:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
syndai link bakery --env development # this directory: project + environment
|
|
16
|
+
syndai secrets # names, scope, updated (never a value)
|
|
17
|
+
printf %s "$TOKEN" | syndai secrets set API_TOKEN # or type it at the hidden prompt
|
|
18
|
+
syndai secrets import .env # a whole .env, all or nothing
|
|
19
|
+
syndai run npm run dev # the values reach npm's environment only
|
|
20
|
+
syndai secrets download -o .env.local # 0600, warns if git does not ignore it
|
|
21
|
+
syndai run --env production ./deploy # asks you to allow this one command
|
|
22
|
+
```
|
|
23
|
+
|
|
12
24
|
Node 22 or newer, zero dependencies, no telemetry. The customer guide is the docs
|
|
13
25
|
site's **CLI** page, https://syndai.ai/docs/cli (`web/content/docs/cli.mdx`); this
|
|
14
26
|
README is for whoever works on the package.
|
|
@@ -18,8 +30,13 @@ README is for whoever works on the package.
|
|
|
18
30
|
| command | does |
|
|
19
31
|
|---|---|
|
|
20
32
|
| `login [--with-token] [--scope S]...`, `logout`, `whoami` | browser-approved key (device flow); `--scope` asks for access on top of the key's current scopes; `--with-token` reads a key from stdin. Re-login saves the new key, then revokes the old one |
|
|
21
|
-
| `link [project]` | remember which project this directory
|
|
22
|
-
| `
|
|
33
|
+
| `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
|
+
| `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
|
+
| `secrets [ls]` | names, scope (shared/project) and updated for the environment and this project; never a value, in text or `--json` |
|
|
36
|
+
| `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
|
+
| `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
|
+
| `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 |
|
|
23
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) |
|
|
24
41
|
| `edit-text [--app A] [--page /] --from "..." --to "..."` / `--undo <sha>` | instant, free, undoable text swap on a live site |
|
|
25
42
|
| `watch <run> [--timeout D] [--logs]` | follow any change; `--logs` tails its events |
|
|
@@ -40,6 +57,13 @@ README is for whoever works on the package.
|
|
|
40
57
|
A 403 that names a missing scope asks, on a terminal with a stored login, to approve
|
|
41
58
|
it in the browser and retries once. `SYNDAI_API_KEY` never steps up; off a terminal the
|
|
42
59
|
error names `syndai login --scope <scope>`. Members, keys and unpublish are portal-only.
|
|
60
|
+
A missing `coding:secrets:*` scope asks once for all three (read, write, reveal). Reveal
|
|
61
|
+
(reading values) is granted only by that browser approval, never by `--with-token`, the
|
|
62
|
+
portal's key page or `mcp install`. A production environment's values also need a
|
|
63
|
+
`PRODUCTION_STEP_UP_REQUIRED` approval: on a terminal with a stored login the CLI opens
|
|
64
|
+
`/cli` for a person to allow THIS command, then retries once with that approval's grant (held
|
|
65
|
+
in memory only, so another process with the same key gets nothing); off a terminal or under
|
|
66
|
+
`SYNDAI_API_KEY` it exits 3.
|
|
43
67
|
|
|
44
68
|
Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
|
|
45
69
|
4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
|
|
@@ -58,6 +82,8 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
|
58
82
|
- `src/openapi.js`: the live `/openapi.json` (cached 24h per host in `$XDG_CACHE_HOME/syndai`).
|
|
59
83
|
- `src/api.js`: `syndai api` (operations by id, fields, `--paginate`).
|
|
60
84
|
- `src/schedules.js`: `schedule` and `repeat` (the preset to cron mapping of `apps/portal-v2/src/lib/cadence.ts`).
|
|
85
|
+
- `src/secrets.js`: `env`, `secrets` and `run` (environment choice, the production step-up retry).
|
|
86
|
+
- `src/dotenv.js`: the `.env` reader (`import`) and writer (`download`); errors name lines, never values.
|
|
61
87
|
- `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
|
|
62
88
|
|
|
63
89
|
## Development
|
|
@@ -71,7 +97,8 @@ CI runs it in its own **CLI Checks** job (`pr-ci.yml`, `main-ci.yml`) whenever
|
|
|
71
97
|
part of **PR Gate** and deploys nothing.
|
|
72
98
|
|
|
73
99
|
The suite runs a fake API on 127.0.0.1 (serving `/openapi.json` from the snapshot) and
|
|
74
|
-
drives the CLI in-process (plus
|
|
100
|
+
drives the CLI in-process (plus child-process tests for Ctrl-C, a clean exit and `run`'s
|
|
101
|
+
real child). `test/secrets.test.js` searches every output and request for a sentinel value.
|
|
75
102
|
`test/schedule.test.js` imports `apps/portal-v2/src/lib/cadence.ts` to prove the
|
|
76
103
|
`--every` presets give the portal's cron; that needs a Node that strips TypeScript
|
|
77
104
|
types (23.6+, as `.nvmrc`), and is skipped on Node 22. `test/contract.test.js` checks
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The Syndai owner CLI: say what to change, and Syndai builds it, checks it and puts it live. Also installs the Syndai MCP server into Claude Code, Cursor and Codex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/auth.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
// owner or admin approves the request in a browser session at app.syndai.ai/cli. The key is
|
|
5
5
|
// stored per workspace in credentials.json (0600). `SYNDAI_API_KEY` wins over it (CI).
|
|
6
6
|
// There is no --api-key flag, so a key never lands in shell history.
|
|
7
|
+
//
|
|
8
|
+
// Reading secret values needs `coding:secrets:reveal`, which only this browser approval
|
|
9
|
+
// grants. Production values also need a person to allow THIS key in the browser, for 10
|
|
10
|
+
// minutes (`productionStepUp`); a stored key alone, or SYNDAI_API_KEY, never reads them.
|
|
7
11
|
|
|
8
12
|
import { hostname } from "node:os";
|
|
9
13
|
import { ApiError, CliError, EXIT, explain, PORTAL_URL, steppable } from "./errors.js";
|
|
@@ -69,10 +73,22 @@ export function createContext(io, flags, { stepUp = true } = {}) {
|
|
|
69
73
|
getKey: async () => credential().apiKey,
|
|
70
74
|
io,
|
|
71
75
|
onMissingScope: stepUp ? (scope) => stepUpScope(ctx, scope) : undefined,
|
|
76
|
+
onUnauthorized: async () => {
|
|
77
|
+
// A login or step-up in another process saves a new key, then revokes this one.
|
|
78
|
+
if (!cached || cached.source === "SYNDAI_API_KEY") return false;
|
|
79
|
+
const fresh = storedCredential(io, paths);
|
|
80
|
+
if (!fresh || fresh.apiKey === cached.apiKey) return false;
|
|
81
|
+
cached = fresh;
|
|
82
|
+
me = undefined;
|
|
83
|
+
return true;
|
|
84
|
+
},
|
|
72
85
|
});
|
|
73
86
|
return ctx;
|
|
74
87
|
}
|
|
75
88
|
|
|
89
|
+
/** One approval for every `syndai secrets` / `env` / `run` command, not one per scope. */
|
|
90
|
+
export const SECRETS_SCOPES = ["coding:secrets:read", "coding:secrets:write", "coding:secrets:reveal"];
|
|
91
|
+
|
|
76
92
|
/**
|
|
77
93
|
* A 403 named the scope it needs. On a terminal, with the stored login (never
|
|
78
94
|
* SYNDAI_API_KEY, which belongs to CI), ask once, approve a key with the current scopes plus
|
|
@@ -81,9 +97,11 @@ export function createContext(io, flags, { stepUp = true } = {}) {
|
|
|
81
97
|
async function stepUpScope(ctx, scope) {
|
|
82
98
|
const { io, ui } = ctx;
|
|
83
99
|
if (!ui.interactive || !steppable(scope) || ctx.credential().source === "SYNDAI_API_KEY") return false;
|
|
84
|
-
const
|
|
100
|
+
const secrets = SECRETS_SCOPES.includes(scope);
|
|
101
|
+
const what = secrets ? "access to this workspace's secrets" : `the ${scope} scope`;
|
|
102
|
+
const answer = await ask(io, `This needs ${what}. Approve it in your browser? [Y/n] `);
|
|
85
103
|
if (/^n/i.test(answer)) return false;
|
|
86
|
-
const token = await deviceApproval(ctx, "CLI", await scopesWith(ctx, [scope]));
|
|
104
|
+
const token = await deviceApproval(ctx, "CLI", await scopesWith(ctx, secrets ? SECRETS_SCOPES : [scope]));
|
|
87
105
|
await storeKey(ctx, token);
|
|
88
106
|
return true;
|
|
89
107
|
}
|
|
@@ -127,14 +145,53 @@ async function storeKey(ctx, token) {
|
|
|
127
145
|
* Ask for a key and wait until an owner approves it in the browser. Returns the token
|
|
128
146
|
* response `{api_key, customer_id, key_id, expires_at}`.
|
|
129
147
|
*/
|
|
130
|
-
export
|
|
131
|
-
|
|
132
|
-
|
|
148
|
+
export function deviceApproval(ctx, clientName, scopes) {
|
|
149
|
+
return browserApproval(ctx, {
|
|
150
|
+
start: "deviceStart",
|
|
151
|
+
token: "deviceToken",
|
|
133
152
|
// No `scopes` means the owner preset (owner_door); a list asks for those on top of it.
|
|
134
153
|
body: { client_name: clientName, host: hostname(), ...(scopes ? { scopes } : {}) },
|
|
135
154
|
auth: false,
|
|
136
|
-
|
|
155
|
+
again: "syndai login",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A production value was refused (403 PRODUCTION_STEP_UP_REQUIRED). On a terminal with the
|
|
161
|
+
* stored login (never SYNDAI_API_KEY), ask once, wait for a person to allow THIS key in the
|
|
162
|
+
* browser, and answer true so the caller retries once. Off a terminal: false (exit 3).
|
|
163
|
+
*/
|
|
164
|
+
export async function productionStepUp(ctx) {
|
|
165
|
+
const { io, ui } = ctx;
|
|
166
|
+
if (!ui.interactive || ctx.credential().source === "SYNDAI_API_KEY") return false;
|
|
167
|
+
const answer = await ask(io, "Production values need your approval in the browser for this command. Open it? [Y/n] ");
|
|
168
|
+
if (/^n/i.test(answer)) return false;
|
|
169
|
+
let grant;
|
|
170
|
+
const allowed = await browserApproval(ctx, {
|
|
171
|
+
start: "startProductionAccess",
|
|
172
|
+
token: "redeemProductionAccess",
|
|
173
|
+
body: { client_name: "CLI", host: hostname() },
|
|
174
|
+
auth: true,
|
|
175
|
+
again: "run the command again",
|
|
176
|
+
onStart: (start) => {
|
|
177
|
+
grant = start.device_code;
|
|
178
|
+
},
|
|
137
179
|
});
|
|
180
|
+
const until = new Date(allowed.allowed_until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
181
|
+
ui.progress(`Production values allowed for this command (until ${until}).`);
|
|
182
|
+
// The grant lives in this process only: another process with the same stored key has none.
|
|
183
|
+
return grant;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The RFC 8628 loop both approvals share: start, show the code and open the page, poll the
|
|
188
|
+
* token route until a person decides. `auth` sends the stored key (the production step-up
|
|
189
|
+
* is for the key that asks); `again` is the command a denied or expired code points to.
|
|
190
|
+
*/
|
|
191
|
+
async function browserApproval(ctx, { start: startRoute, token: tokenRoute, body, auth, again, onStart }) {
|
|
192
|
+
const { client, ui, io } = ctx;
|
|
193
|
+
const start = await client.call(startRoute, { body, auth, retry: true });
|
|
194
|
+
onStart?.(start);
|
|
138
195
|
const url = start.verification_uri_complete ?? start.verification_uri;
|
|
139
196
|
ui.progress(`Your one-time code: ${ui.bold(start.user_code)}`);
|
|
140
197
|
ui.progress(`Approve it in your browser: ${url}`);
|
|
@@ -150,9 +207,9 @@ export async function deviceApproval(ctx, clientName, scopes) {
|
|
|
150
207
|
if (io.signal.aborted) throw interrupted();
|
|
151
208
|
throw err;
|
|
152
209
|
}
|
|
153
|
-
const res = await client.call(
|
|
210
|
+
const res = await client.call(tokenRoute, {
|
|
154
211
|
body: { device_code: start.device_code },
|
|
155
|
-
auth
|
|
212
|
+
auth,
|
|
156
213
|
retry: true,
|
|
157
214
|
raw: true,
|
|
158
215
|
});
|
|
@@ -164,14 +221,10 @@ export async function deviceApproval(ctx, clientName, scopes) {
|
|
|
164
221
|
continue;
|
|
165
222
|
}
|
|
166
223
|
if (error === "access_denied") {
|
|
167
|
-
throw new CliError(EXIT.AUTH, "The request was denied in the browser.", {
|
|
168
|
-
next: "syndai login",
|
|
169
|
-
});
|
|
224
|
+
throw new CliError(EXIT.AUTH, "The request was denied in the browser.", { next: again });
|
|
170
225
|
}
|
|
171
226
|
if (error === "expired_token" || error === "authorization_pending") {
|
|
172
|
-
throw new CliError(EXIT.AUTH, "The code expired before it was approved.", {
|
|
173
|
-
next: "syndai login",
|
|
174
|
-
});
|
|
227
|
+
throw new CliError(EXIT.AUTH, "The code expired before it was approved.", { next: again });
|
|
175
228
|
}
|
|
176
229
|
throw new ApiError(res.status, res.body, res.headers);
|
|
177
230
|
}
|
package/src/changes.js
CHANGED
|
@@ -32,9 +32,14 @@ export async function change(ctx, words) {
|
|
|
32
32
|
const text = words.join(" ").trim();
|
|
33
33
|
if (!text) throw new CliError(EXIT.USAGE, 'Say what to change: syndai change "make the header blue"');
|
|
34
34
|
const timeoutMs = timeoutOf(flags);
|
|
35
|
+
// --learn is explicit (L10): change prompts carry docs links and the owner's own site.
|
|
36
|
+
const reference = flags.learn === undefined ? null : productUrlFromText(flags.learn);
|
|
37
|
+
if (flags.learn !== undefined && !reference)
|
|
38
|
+
throw new CliError(EXIT.USAGE, `--learn takes a public product URL, like https://cal.com, not ${flags.learn}.`);
|
|
35
39
|
const project = await resolveProject(ctx, { app: flags.app });
|
|
36
40
|
const outcome = flags.review ? "opens a pull request for your review" : "goes live when checks pass";
|
|
37
41
|
ui.progress(`Changing ${projectHost(project)} · ${outcome}`);
|
|
42
|
+
if (reference) ui.progress(`Learning from ${hostOf(reference)}`);
|
|
38
43
|
|
|
39
44
|
const imageRefs = [];
|
|
40
45
|
for (const file of flags.image ?? []) imageRefs.push(await uploadImage(ctx, file));
|
|
@@ -54,6 +59,7 @@ export async function change(ctx, words) {
|
|
|
54
59
|
coding_repository_id: project.id,
|
|
55
60
|
external_request_id: pending.request_id,
|
|
56
61
|
...(imageRefs.length ? { image_refs: imageRefs } : {}),
|
|
62
|
+
...referenceField(reference),
|
|
57
63
|
...(flags.review ? { policy_config: { autonomy: "auto" } } : {}),
|
|
58
64
|
},
|
|
59
65
|
});
|
|
@@ -80,6 +86,11 @@ export function productUrlFromText(text) {
|
|
|
80
86
|
return null;
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
const hostOf = (url) => new URL(url).hostname.replace(/^www\./, "");
|
|
90
|
+
// The typed top-level create-run field (learn-from-a-product L1); `metadata.reference_product` is
|
|
91
|
+
// server-owned and 400s.
|
|
92
|
+
const referenceField = (url) => (url ? { reference_product: { kind: "url", value: url } } : {});
|
|
93
|
+
|
|
83
94
|
/** Build-home parity: start a New project. A product URL in the text makes it "your own version
|
|
84
95
|
* of X" (SPEC-build-your-own D5) unless --no-learn; its feature-pick question is asked inline. */
|
|
85
96
|
export async function newProject(ctx, words) {
|
|
@@ -90,7 +101,7 @@ export async function newProject(ctx, words) {
|
|
|
90
101
|
const reference = flags["no-learn"] ? null : productUrlFromText(text);
|
|
91
102
|
ui.progress(
|
|
92
103
|
reference
|
|
93
|
-
? `New project · your own version of ${
|
|
104
|
+
? `New project · your own version of ${hostOf(reference)}`
|
|
94
105
|
: "New project",
|
|
95
106
|
);
|
|
96
107
|
const pending = findPending(paths, { app: "new", text }) ?? {
|
|
@@ -105,7 +116,7 @@ export async function newProject(ctx, words) {
|
|
|
105
116
|
prompt: text,
|
|
106
117
|
new_repository: { scaffold: "syndai-web" },
|
|
107
118
|
external_request_id: pending.request_id,
|
|
108
|
-
...(reference
|
|
119
|
+
...referenceField(reference),
|
|
109
120
|
},
|
|
110
121
|
});
|
|
111
122
|
clearPending(paths, pending.request_id);
|
package/src/dotenv.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `.env` text <-> values, for `syndai secrets import` and `syndai secrets download`.
|
|
2
|
+
//
|
|
3
|
+
// Read: KEY=VALUE lines, an optional `export ` prefix, blank lines and `#` comments, an
|
|
4
|
+
// unquoted value's ` # comment`, 'single quotes' (literal) and "double quotes" (\n \r \t \"
|
|
5
|
+
// \\ escapes); either quote may span lines. A later KEY wins. Errors name the line, never a
|
|
6
|
+
// value or a name: a malformed line may be a pasted secret.
|
|
7
|
+
// Write: one KEY=value per name, sorted, quoted only when the value needs it.
|
|
8
|
+
|
|
9
|
+
import { CliError, EXIT } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
/** The server's secret-name grammar (`ENV_NAME_PATTERN`). */
|
|
12
|
+
export const NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
13
|
+
|
|
14
|
+
const ESCAPES = { n: "\n", r: "\r", t: "\t", '"': '"', "\\": "\\" };
|
|
15
|
+
|
|
16
|
+
function bad(lineNo, why) {
|
|
17
|
+
return new CliError(EXIT.USAGE, `.env line ${lineNo}: ${why}.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `.env` text -> `[{key_name, value}]` in file order (a repeated name keeps its last value). */
|
|
21
|
+
export function parseDotenv(text) {
|
|
22
|
+
const lines = String(text).replace(/^/, "").split(/\r?\n/);
|
|
23
|
+
const values = new Map();
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const lineNo = i + 1;
|
|
26
|
+
// Only the start is trimmed: a quoted value keeps its trailing spaces.
|
|
27
|
+
const line = lines[i].trimStart();
|
|
28
|
+
if (!line.trim() || line.startsWith("#")) continue;
|
|
29
|
+
const match = /^(?:export\s+)?([^=\s]+)\s*=\s*(.*)$/.exec(line);
|
|
30
|
+
if (!match) throw bad(lineNo, "expected KEY=VALUE");
|
|
31
|
+
const [, name, rest] = match;
|
|
32
|
+
if (!NAME.test(name)) throw bad(lineNo, "a name is uppercase letters, digits and _ (like API_TOKEN)");
|
|
33
|
+
const quote = rest[0];
|
|
34
|
+
if (quote !== '"' && quote !== "'") {
|
|
35
|
+
values.delete(name);
|
|
36
|
+
values.set(name, rest.replace(/\s+#.*$/, "").trim());
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// A quoted value: read on (across lines) to the closing quote.
|
|
40
|
+
let value = "";
|
|
41
|
+
let chunk = rest.slice(1);
|
|
42
|
+
let closed = false;
|
|
43
|
+
for (;;) {
|
|
44
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
45
|
+
const ch = chunk[j];
|
|
46
|
+
if (quote === '"' && ch === "\\" && j + 1 < chunk.length) {
|
|
47
|
+
const next = chunk[++j];
|
|
48
|
+
value += ESCAPES[next] ?? `\\${next}`;
|
|
49
|
+
} else if (ch === quote) {
|
|
50
|
+
if (!/^\s*(#.*)?$/.test(chunk.slice(j + 1))) throw bad(i + 1, "text after the closing quote");
|
|
51
|
+
closed = true;
|
|
52
|
+
break;
|
|
53
|
+
} else {
|
|
54
|
+
value += ch;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (closed) break;
|
|
58
|
+
if (++i >= lines.length) throw bad(lineNo, "a quote is never closed");
|
|
59
|
+
value += "\n";
|
|
60
|
+
chunk = lines[i];
|
|
61
|
+
}
|
|
62
|
+
values.delete(name);
|
|
63
|
+
values.set(name, value);
|
|
64
|
+
}
|
|
65
|
+
return [...values].map(([key_name, value]) => ({ key_name, value }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `{NAME: value}` -> `.env` text that `parseDotenv` (and dotenv, docker) read back exactly. */
|
|
69
|
+
export function serializeDotenv(values) {
|
|
70
|
+
return Object.keys(values)
|
|
71
|
+
.sort()
|
|
72
|
+
.map((name) => `${name}=${quoted(values[name])}\n`)
|
|
73
|
+
.join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function quoted(value) {
|
|
77
|
+
if (/^[\w./:@%+,=-]*$/.test(value)) return value;
|
|
78
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
79
|
+
return `"${value.replace(/[\\"\n\r\t]/g, (ch) => ({ "\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t" })[ch])}"`;
|
|
80
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -82,6 +82,11 @@ const KNOWN = {
|
|
|
82
82
|
null,
|
|
83
83
|
],
|
|
84
84
|
CLI_UPGRADE_REQUIRED: [EXIT.FAILED, "This version of syndai is too old.", "npx -y syndai@latest"],
|
|
85
|
+
PRODUCTION_STEP_UP_REQUIRED: [
|
|
86
|
+
EXIT.NEEDS_YOU,
|
|
87
|
+
"Production values need you to allow this command in your browser; a key alone never reads them.",
|
|
88
|
+
"run it again in a terminal logged in with syndai login",
|
|
89
|
+
],
|
|
85
90
|
RUN_CONCURRENCY_LIMIT: [EXIT.FAILED, "Too many changes are running at once.", "syndai status"],
|
|
86
91
|
REPO_NOT_BOUND: [EXIT.USAGE, "That project is not available to this workspace.", "syndai link"],
|
|
87
92
|
INSTALLATION_INCOMPLETE: [
|
|
@@ -102,6 +107,16 @@ const KNOWN = {
|
|
|
102
107
|
"A change is in flight on this site; edit text once it lands.",
|
|
103
108
|
(p) => (p.run_id ? `syndai watch ${p.run_id}` : "syndai status"),
|
|
104
109
|
],
|
|
110
|
+
REFERENCE_PRODUCT_INVALID: [
|
|
111
|
+
EXIT.USAGE,
|
|
112
|
+
"That is not a product link to learn from; use its full address, like https://cal.com.",
|
|
113
|
+
null,
|
|
114
|
+
],
|
|
115
|
+
REFERENCE_PRODUCT_NEEDS_SCAFFOLD: [
|
|
116
|
+
EXIT.USAGE,
|
|
117
|
+
"Learning from a product needs a Syndai site project.",
|
|
118
|
+
null,
|
|
119
|
+
],
|
|
105
120
|
NOT_FOUND: [EXIT.FAILED, "Not found.", "syndai status"],
|
|
106
121
|
RATE_LIMITED: [EXIT.UNAVAILABLE, "Syndai is rate-limiting this workspace.", null],
|
|
107
122
|
};
|
package/src/http.js
CHANGED
|
@@ -39,11 +39,13 @@ export function interrupted() {
|
|
|
39
39
|
/**
|
|
40
40
|
* `onMissingScope(scope)` is asked once per request when a 403 names the scope it needs
|
|
41
41
|
* (`required_scope`); when it answers true (a new key was approved), the request is sent
|
|
42
|
-
* again with the new key.
|
|
42
|
+
* again with the new key. `onUnauthorized()` is asked once per request on a 401; when it
|
|
43
|
+
* answers true (another `syndai` process rotated the stored key), the request is sent again
|
|
44
|
+
* with the new key, so a long watch or MCP server outlives a login elsewhere.
|
|
43
45
|
*
|
|
44
|
-
* @param {{baseUrl: string, getKey: () => Promise<string>, io: object, onMissingScope?: (scope: string) => Promise<boolean>}} options
|
|
46
|
+
* @param {{baseUrl: string, getKey: () => Promise<string>, io: object, onMissingScope?: (scope: string) => Promise<boolean>, onUnauthorized?: () => Promise<boolean>}} options
|
|
45
47
|
*/
|
|
46
|
-
export function createClient({ baseUrl, getKey, io, onMissingScope }) {
|
|
48
|
+
export function createClient({ baseUrl, getKey, io, onMissingScope, onUnauthorized }) {
|
|
47
49
|
/** Call a named route from `routes.js`. */
|
|
48
50
|
function call(name, { params, ...opts } = {}) {
|
|
49
51
|
const [method, path] = route(name, params);
|
|
@@ -66,6 +68,7 @@ export function createClient({ baseUrl, getKey, io, onMissingScope }) {
|
|
|
66
68
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
67
69
|
|
|
68
70
|
let steppedUp = false;
|
|
71
|
+
let reloaded = false;
|
|
69
72
|
for (let attempt = 1; ; attempt++) {
|
|
70
73
|
let res;
|
|
71
74
|
let parsed;
|
|
@@ -95,6 +98,14 @@ export function createClient({ baseUrl, getKey, io, onMissingScope }) {
|
|
|
95
98
|
continue;
|
|
96
99
|
}
|
|
97
100
|
}
|
|
101
|
+
if (res.status === 401 && auth && onUnauthorized && !reloaded) {
|
|
102
|
+
reloaded = true;
|
|
103
|
+
if (await onUnauthorized()) {
|
|
104
|
+
headers.authorization = `Bearer ${await getKey()}`;
|
|
105
|
+
attempt = 0;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
98
109
|
const transient =
|
|
99
110
|
problem !== null &&
|
|
100
111
|
(res.status >= 500 ||
|
package/src/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
7
|
import { createContext, login, logout, whoami } from "./auth.js";
|
|
@@ -11,6 +12,7 @@ import { mcp } from "./mcp.js";
|
|
|
11
12
|
import { link } from "./projects.js";
|
|
12
13
|
import { api } from "./api.js";
|
|
13
14
|
import { repeat, schedule } from "./schedules.js";
|
|
15
|
+
import { env, run, secrets } from "./secrets.js";
|
|
14
16
|
import { settings } from "./settings.js";
|
|
15
17
|
import { closeInput, openBrowser, page } from "./ui.js";
|
|
16
18
|
|
|
@@ -20,10 +22,12 @@ const COMMON = { json: bool, "no-input": bool, help: { type: "boolean", short: "
|
|
|
20
22
|
|
|
21
23
|
const COMMANDS = {
|
|
22
24
|
change: {
|
|
23
|
-
usage: 'change "<what>" [--app A] [--review] [--image F]... [--no-wait] [--timeout D]',
|
|
24
|
-
summary:
|
|
25
|
+
usage: 'change "<what>" [--app A] [--review] [--learn URL] [--image F]... [--no-wait] [--timeout D]',
|
|
26
|
+
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).",
|
|
25
28
|
options: {
|
|
26
29
|
app: str,
|
|
30
|
+
learn: str,
|
|
27
31
|
review: bool,
|
|
28
32
|
image: { type: "string", multiple: true },
|
|
29
33
|
"no-wait": bool,
|
|
@@ -110,11 +114,33 @@ const COMMANDS = {
|
|
|
110
114
|
run: (ctx) => whoami(ctx),
|
|
111
115
|
},
|
|
112
116
|
link: {
|
|
113
|
-
usage: "link [project]",
|
|
114
|
-
summary: "Remember which project this directory
|
|
115
|
-
options: { app: str },
|
|
117
|
+
usage: "link [project] [--env E]",
|
|
118
|
+
summary: "Remember which project (and environment) this directory uses (stored outside the repo).",
|
|
119
|
+
options: { app: str, env: str },
|
|
116
120
|
run: (ctx, [ref]) => link(ctx, ref),
|
|
117
121
|
},
|
|
122
|
+
env: {
|
|
123
|
+
usage: "env ls | use <env>",
|
|
124
|
+
summary: "List the workspace's environments, or pick the one this directory uses.",
|
|
125
|
+
options: {},
|
|
126
|
+
run: (ctx, args) => env(ctx, args),
|
|
127
|
+
},
|
|
128
|
+
secrets: {
|
|
129
|
+
usage:
|
|
130
|
+
"secrets [ls] | set KEY | rm KEY [--yes] | import FILE|- | get KEY [--plain] | download [-o FILE]\n" +
|
|
131
|
+
" [--env E] [--app A] [--project]",
|
|
132
|
+
summary:
|
|
133
|
+
"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).",
|
|
134
|
+
options: { env: str, app: str, project: bool, plain: bool, output: { type: "string", short: "o" }, yes: bool },
|
|
135
|
+
run: (ctx, args) => secrets(ctx, args),
|
|
136
|
+
},
|
|
137
|
+
run: {
|
|
138
|
+
usage: "run [--env E] [--app A] [--] <command> [args...]",
|
|
139
|
+
summary: "Run a local command with the environment's values in its environment only; its exit code is syndai's.",
|
|
140
|
+
options: { env: str, app: str },
|
|
141
|
+
passthrough: true,
|
|
142
|
+
run: (ctx, command) => run(ctx, command),
|
|
143
|
+
},
|
|
118
144
|
settings: {
|
|
119
145
|
usage: "settings [--app A] [get | set key=value...]",
|
|
120
146
|
summary: "autonomy, feature_requests, bug_scan, protected_paths (a key can only tighten them).",
|
|
@@ -167,7 +193,8 @@ export function helpText() {
|
|
|
167
193
|
"",
|
|
168
194
|
"Every command: --json (machine output; also when stdout is not a terminal),",
|
|
169
195
|
"--no-input (never prompt), -h/--help.",
|
|
170
|
-
"Environment: SYNDAI_API_KEY (wins over `syndai login
|
|
196
|
+
"Environment: SYNDAI_API_KEY (wins over `syndai login`; never reads production values),",
|
|
197
|
+
"SYNDAI_APP, SYNDAI_ENV, SYNDAI_API_URL, NO_COLOR.",
|
|
171
198
|
"Exit codes: 0 done, 1 failed, 2 usage or no project, 3 needs you, 4 live but unverified,",
|
|
172
199
|
"5 auth/scope/plan, 6 --timeout reached, 7 service unavailable, 130 interrupted.",
|
|
173
200
|
"",
|
|
@@ -187,6 +214,33 @@ export function processIo(signal) {
|
|
|
187
214
|
sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
|
|
188
215
|
openUrl: openBrowser,
|
|
189
216
|
page: (text) => page(process, text),
|
|
217
|
+
/**
|
|
218
|
+
* `syndai run`: the child shares the terminal; resolves `{code, signal}` (ENOENT rejects).
|
|
219
|
+
* While it runs the parent never exits first (that would orphan a child holding the
|
|
220
|
+
* secrets): Ctrl-C reaches the child through the terminal, and SIGTERM/SIGHUP are passed on.
|
|
221
|
+
*/
|
|
222
|
+
exec: (file, args, childEnv) =>
|
|
223
|
+
new Promise((resolve, reject) => {
|
|
224
|
+
const child = spawn(file, args, { stdio: "inherit", env: childEnv });
|
|
225
|
+
const saved = process.listeners("SIGINT");
|
|
226
|
+
process.removeAllListeners("SIGINT");
|
|
227
|
+
const forward = (sig) => child.kill(sig);
|
|
228
|
+
process.on("SIGINT", () => {});
|
|
229
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) process.on(sig, forward);
|
|
230
|
+
const restore = () => {
|
|
231
|
+
process.removeAllListeners("SIGINT");
|
|
232
|
+
for (const listener of saved) process.on("SIGINT", listener);
|
|
233
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) process.off(sig, forward);
|
|
234
|
+
};
|
|
235
|
+
child.once("error", (err) => {
|
|
236
|
+
restore();
|
|
237
|
+
reject(err);
|
|
238
|
+
});
|
|
239
|
+
child.once("exit", (code, exitSignal) => {
|
|
240
|
+
restore();
|
|
241
|
+
resolve({ code, signal: exitSignal });
|
|
242
|
+
});
|
|
243
|
+
}),
|
|
190
244
|
};
|
|
191
245
|
}
|
|
192
246
|
|
|
@@ -205,9 +259,11 @@ export async function main(argv, io) {
|
|
|
205
259
|
io.stderr.write(`error: unknown command "${command}"\n\n${helpText()}`);
|
|
206
260
|
return EXIT.USAGE;
|
|
207
261
|
}
|
|
262
|
+
const options = { ...COMMON, ...spec.options };
|
|
263
|
+
const [own, child] = spec.passthrough ? splitCommand(rest, options) : [rest, null];
|
|
208
264
|
let parsed;
|
|
209
265
|
try {
|
|
210
|
-
parsed = parseArgs({ args:
|
|
266
|
+
parsed = parseArgs({ args: own, options, allowPositionals: true });
|
|
211
267
|
} catch (err) {
|
|
212
268
|
io.stderr.write(`error: ${err.message}\n usage: syndai ${spec.usage}\n`);
|
|
213
269
|
return EXIT.USAGE;
|
|
@@ -218,7 +274,7 @@ export async function main(argv, io) {
|
|
|
218
274
|
}
|
|
219
275
|
const ctx = createContext(io, parsed.values, { stepUp: spec.stepUp !== false });
|
|
220
276
|
try {
|
|
221
|
-
return await spec.run(ctx, parsed.positionals);
|
|
277
|
+
return await spec.run(ctx, child ?? parsed.positionals);
|
|
222
278
|
} catch (err) {
|
|
223
279
|
return report(ctx, err);
|
|
224
280
|
} finally {
|
|
@@ -226,6 +282,22 @@ export async function main(argv, io) {
|
|
|
226
282
|
}
|
|
227
283
|
}
|
|
228
284
|
|
|
285
|
+
/**
|
|
286
|
+
* A passthrough command's own options end at `--` or at the child command's first word
|
|
287
|
+
* (`syndai run npm test --watch`): everything after belongs to the child, unparsed.
|
|
288
|
+
*/
|
|
289
|
+
function splitCommand(args, options) {
|
|
290
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
291
|
+
const arg = args[i];
|
|
292
|
+
if (arg === "--") return [args.slice(0, i), args.slice(i + 1)];
|
|
293
|
+
if (!arg.startsWith("-")) return [args.slice(0, i), args.slice(i)];
|
|
294
|
+
const name = arg.replace(/^--?/, "").split("=")[0];
|
|
295
|
+
const option = options[name] ?? Object.values(options).find((o) => o.short === name);
|
|
296
|
+
if (option?.type === "string" && !arg.includes("=")) i += 1; // its value is the next word
|
|
297
|
+
}
|
|
298
|
+
return [args, []];
|
|
299
|
+
}
|
|
300
|
+
|
|
229
301
|
/** One error line (plus the next command) on stderr; a JSON error on stdout for machines. */
|
|
230
302
|
function report(ctx, err) {
|
|
231
303
|
const { ui, io } = ctx;
|
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,41 @@ 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
|
+
});
|
|
163
194
|
ctx.ui.result(
|
|
164
|
-
{ dir, project_id: project.id, name: project.name },
|
|
165
|
-
`Linked ${dir} to ${projectLabel(project)}.`,
|
|
195
|
+
{ dir, project_id: project.id, name: project.name, environment: environment ?? null },
|
|
196
|
+
`Linked ${dir} to ${projectLabel(project)}${environment ? ` (${environment})` : ""}.`,
|
|
166
197
|
);
|
|
167
198
|
return EXIT.OK;
|
|
168
199
|
}
|
package/src/routes.js
CHANGED
|
@@ -45,6 +45,14 @@ export const ROUTES = Object.freeze({
|
|
|
45
45
|
pauseSchedule: ["POST", `${EXT}/schedules/{schedule_id}/pause`],
|
|
46
46
|
resumeSchedule: ["POST", `${EXT}/schedules/{schedule_id}/resume`],
|
|
47
47
|
runScheduleNow: ["POST", `${EXT}/schedules/{schedule_id}/run-now`],
|
|
48
|
+
listEnvironments: ["GET", `${EXT}/environments`],
|
|
49
|
+
listSecrets: ["GET", `${EXT}/secrets`],
|
|
50
|
+
putSecret: ["PUT", `${EXT}/secrets/{key_name}`],
|
|
51
|
+
deleteSecret: ["DELETE", `${EXT}/secrets/{key_name}`],
|
|
52
|
+
importSecrets: ["POST", `${EXT}/secrets/import`],
|
|
53
|
+
resolveSecrets: ["POST", `${EXT}/secrets/resolve`],
|
|
54
|
+
startProductionAccess: ["POST", `${EXT}/cli/production-access`],
|
|
55
|
+
redeemProductionAccess: ["POST", `${EXT}/cli/production-access/token`],
|
|
48
56
|
});
|
|
49
57
|
|
|
50
58
|
/** The MCP endpoint `syndai mcp install` points clients at (JSON-RPC, not REST). */
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// `syndai env`, `syndai secrets` and `syndai run`: a workspace's environments and secrets from
|
|
2
|
+
// a terminal, Doppler-style (docs: syndai.ai/docs/cli, "Secrets and environments").
|
|
3
|
+
//
|
|
4
|
+
// The environment: --env, else SYNDAI_ENV, else the directory's link (`syndai env use`,
|
|
5
|
+
// `syndai link --env`), else the workspace default (where runs go). The project: the usual
|
|
6
|
+
// resolver (--app, SYNDAI_APP, the link, the git remote, the only project), never a picker;
|
|
7
|
+
// its values layer over the shared ones, as in a run. `set`, `rm` and `import` write shared
|
|
8
|
+
// values unless --project.
|
|
9
|
+
//
|
|
10
|
+
// A value never travels through argv, JSON output, an error or a log line: `set` reads it from
|
|
11
|
+
// a hidden prompt or stdin, `get` prints it only to a terminal (or with --plain), `download`
|
|
12
|
+
// writes a 0600 file (or a pipe), and `run` hands values to the child's environment only.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import { constants } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { productionStepUp } from "./auth.js";
|
|
19
|
+
import { NAME, parseDotenv, serializeDotenv } from "./dotenv.js";
|
|
20
|
+
import { ApiError, CliError, EXIT } from "./errors.js";
|
|
21
|
+
import { environmentBySlug, listEnvironments, projectLabel, resolveProject } from "./projects.js";
|
|
22
|
+
import { findLink, saveLink, writeFileAtomic } from "./store.js";
|
|
23
|
+
import { ask, askSecret, readAll } from "./ui.js";
|
|
24
|
+
|
|
25
|
+
/** The slug the flags, SYNDAI_ENV or the link name; null means the workspace default. */
|
|
26
|
+
function chosenSlug(ctx) {
|
|
27
|
+
return ctx.flags.env || ctx.io.env.SYNDAI_ENV || findLink(ctx.paths, ctx.io.cwd)?.environment || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The chosen environment, or the default one (both validated against the workspace). */
|
|
31
|
+
async function chosenEnvironment(ctx, environments) {
|
|
32
|
+
const envs = environments ?? (await listEnvironments(ctx));
|
|
33
|
+
const slug = chosenSlug(ctx);
|
|
34
|
+
if (slug) return environmentBySlug(ctx, slug, envs);
|
|
35
|
+
const fallback = envs.find((env) => env.default) ?? envs[0];
|
|
36
|
+
if (!fallback) throw new CliError(EXIT.FAILED, "This workspace has no environments.", { next: "https://app.syndai.ai" });
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The project whose values layer over the shared ones, or null (shared only). */
|
|
41
|
+
function layeredProject(ctx) {
|
|
42
|
+
return resolveProject(ctx, { app: ctx.flags.app, required: false, picker: false });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The project a write targets: shared (null) unless --project. */
|
|
46
|
+
async function writeTarget(ctx) {
|
|
47
|
+
if (!ctx.flags.project) return null;
|
|
48
|
+
return resolveProject(ctx, { app: ctx.flags.app, picker: false });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const scopeOf = (project) => (project ? `project ${projectLabel(project)}` : "shared");
|
|
52
|
+
|
|
53
|
+
// --- syndai env --------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export async function env(ctx, [action = "ls", slug, ...extra]) {
|
|
56
|
+
if (action === "ls" && !slug) return envList(ctx);
|
|
57
|
+
if (action === "use" && slug && !extra.length) return envUse(ctx, slug);
|
|
58
|
+
throw new CliError(EXIT.USAGE, "Usage: syndai env ls | syndai env use <env>");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function envList(ctx) {
|
|
62
|
+
const envs = await listEnvironments(ctx);
|
|
63
|
+
const current = (await chosenEnvironment(ctx, envs)).slug;
|
|
64
|
+
const rows = envs.map((e) => ({ ...e, current: e.slug === current }));
|
|
65
|
+
const width = Math.max(...rows.map((e) => e.slug.length), 4);
|
|
66
|
+
ctx.ui.result(
|
|
67
|
+
{ environments: rows, current },
|
|
68
|
+
rows
|
|
69
|
+
.map((e) => `${e.current ? "*" : " "} ${e.slug.padEnd(width)} ${e.name} (${e.role}${e.default ? ", default" : ""})`)
|
|
70
|
+
.join("\n"),
|
|
71
|
+
);
|
|
72
|
+
return EXIT.OK;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function envUse(ctx, slug) {
|
|
76
|
+
const environment = await environmentBySlug(ctx, slug);
|
|
77
|
+
const { paths, io } = ctx;
|
|
78
|
+
const here = findLink(paths, io.cwd);
|
|
79
|
+
// The nearest link keeps its project; with none, this directory gets an environment-only one.
|
|
80
|
+
const dir = here?.dir ?? path.resolve(io.cwd);
|
|
81
|
+
const { dir: _dir, ...kept } = here ?? { customer_id: await ctx.customerId() };
|
|
82
|
+
saveLink(paths, dir, { ...kept, environment: environment.slug });
|
|
83
|
+
ctx.ui.result({ dir, environment: environment.slug }, `${dir} now uses ${environment.slug}.`);
|
|
84
|
+
return EXIT.OK;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- syndai secrets ----------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export async function secrets(ctx, [action = "ls", ...args]) {
|
|
90
|
+
const handler = SECRET_ACTIONS[action];
|
|
91
|
+
if (!handler) {
|
|
92
|
+
throw new CliError(EXIT.USAGE, "Usage: syndai secrets [ls | set KEY | rm KEY | import FILE|- | get KEY | download]");
|
|
93
|
+
}
|
|
94
|
+
return handler(ctx, args);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One positional secret name; never echoes what was typed (it may be a pasted value). */
|
|
98
|
+
function secretName(args, verb) {
|
|
99
|
+
if (args.length !== 1 || args[0].includes("=")) {
|
|
100
|
+
throw new CliError(EXIT.USAGE, `syndai secrets ${verb} takes one name; a value never goes on the command line.`, {
|
|
101
|
+
next: verb === "set" ? "syndai secrets set KEY (then type the value, or pipe it on stdin)" : null,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (!NAME.test(args[0])) {
|
|
105
|
+
throw new CliError(EXIT.USAGE, "A secret name is uppercase letters, digits and _ (like API_TOKEN).");
|
|
106
|
+
}
|
|
107
|
+
return args[0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SECRET_ACTIONS = {
|
|
111
|
+
async ls(ctx, args) {
|
|
112
|
+
if (args.length) throw new CliError(EXIT.USAGE, "Usage: syndai secrets [ls]");
|
|
113
|
+
const environment = await chosenEnvironment(ctx);
|
|
114
|
+
const project = await layeredProject(ctx);
|
|
115
|
+
const { secrets: rows } = await ctx.client.call("listSecrets");
|
|
116
|
+
const shown = rows
|
|
117
|
+
.filter((s) => s.environment === environment.slug && (!s.repository_id || s.repository_id === project?.id))
|
|
118
|
+
.map((s) => ({
|
|
119
|
+
key_name: s.key_name,
|
|
120
|
+
environment: s.environment,
|
|
121
|
+
scope: s.repository_id ? "project" : "shared",
|
|
122
|
+
repository_id: s.repository_id,
|
|
123
|
+
updated_at: s.updated_at,
|
|
124
|
+
}));
|
|
125
|
+
const width = Math.max(...shown.map((s) => s.key_name.length), 4);
|
|
126
|
+
const human = shown.length
|
|
127
|
+
? [
|
|
128
|
+
`${"NAME".padEnd(width)} SCOPE UPDATED`,
|
|
129
|
+
...shown.map((s) => `${s.key_name.padEnd(width)} ${s.scope.padEnd(7)} ${s.updated_at.slice(0, 10)}`),
|
|
130
|
+
].join("\n")
|
|
131
|
+
: `No secrets in ${environment.slug} yet. Add one: syndai secrets set KEY`;
|
|
132
|
+
ctx.ui.result({ environment: environment.slug, project_id: project?.id ?? null, secrets: shown }, human);
|
|
133
|
+
return EXIT.OK;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async set(ctx, args) {
|
|
137
|
+
const { io, ui } = ctx;
|
|
138
|
+
const name = secretName(args, "set");
|
|
139
|
+
let value;
|
|
140
|
+
if (io.stdin.isTTY) {
|
|
141
|
+
if (!ui.interactive) {
|
|
142
|
+
throw new CliError(EXIT.USAGE, "Nobody can type the value here; pipe it on stdin.", {
|
|
143
|
+
next: `printf %s "$VALUE" | syndai secrets set ${name}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
value = await askSecret(io, `Value for ${name} (hidden): `);
|
|
147
|
+
} else {
|
|
148
|
+
value = (await readAll(io.stdin)).replace(/\r?\n$/, "");
|
|
149
|
+
}
|
|
150
|
+
if (!value) throw new CliError(EXIT.USAGE, `No value for ${name}: type it at the prompt or pipe it on stdin.`);
|
|
151
|
+
const environment = await chosenEnvironment(ctx);
|
|
152
|
+
const project = await writeTarget(ctx);
|
|
153
|
+
const { secret } = await ctx.client.call("putSecret", {
|
|
154
|
+
params: { key_name: name },
|
|
155
|
+
body: { value, environment: environment.slug, ...(project ? { repository_id: project.id } : {}) },
|
|
156
|
+
});
|
|
157
|
+
ui.result(
|
|
158
|
+
{ key_name: name, environment: environment.slug, scope: project ? "project" : "shared", repository_id: project?.id ?? null, updated_at: secret.updated_at },
|
|
159
|
+
`Set ${name} in ${environment.slug} (${scopeOf(project)}).`,
|
|
160
|
+
);
|
|
161
|
+
return EXIT.OK;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async rm(ctx, args) {
|
|
165
|
+
const { flags, io, ui } = ctx;
|
|
166
|
+
const name = secretName(args, "rm");
|
|
167
|
+
const environment = await chosenEnvironment(ctx);
|
|
168
|
+
const project = await writeTarget(ctx);
|
|
169
|
+
const where = `${environment.slug} (${scopeOf(project)})`;
|
|
170
|
+
if (!flags.yes) {
|
|
171
|
+
if (!ui.interactive) {
|
|
172
|
+
throw new CliError(EXIT.USAGE, "Removing a secret needs --yes when nobody can confirm it.", {
|
|
173
|
+
next: `syndai secrets rm ${name} --yes`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const answer = await ask(io, `Remove ${name} from ${where}? [y/N] `);
|
|
177
|
+
if (!/^y/i.test(answer)) {
|
|
178
|
+
ui.result({ key_name: name, removed: false }, "Kept.");
|
|
179
|
+
return EXIT.OK;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await ctx.client.call("deleteSecret", {
|
|
183
|
+
params: { key_name: name },
|
|
184
|
+
query: { environment: environment.slug, repository_id: project?.id },
|
|
185
|
+
});
|
|
186
|
+
ui.result({ key_name: name, environment: environment.slug, removed: true }, `Removed ${name} from ${where}.`);
|
|
187
|
+
return EXIT.OK;
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async import(ctx, args) {
|
|
191
|
+
const { io, ui } = ctx;
|
|
192
|
+
if (args.length !== 1) throw new CliError(EXIT.USAGE, "Usage: syndai secrets import FILE|-");
|
|
193
|
+
const [source] = args;
|
|
194
|
+
let text;
|
|
195
|
+
try {
|
|
196
|
+
text = source === "-" ? await readAll(io.stdin) : fs.readFileSync(path.resolve(io.cwd, source), "utf8");
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw new CliError(EXIT.USAGE, `Cannot read ${source} (${err.code ?? "unreadable"}).`);
|
|
199
|
+
}
|
|
200
|
+
const items = parseDotenv(text);
|
|
201
|
+
if (!items.length) throw new CliError(EXIT.USAGE, `${source} has no KEY=VALUE lines.`);
|
|
202
|
+
const environment = await chosenEnvironment(ctx);
|
|
203
|
+
const project = await writeTarget(ctx);
|
|
204
|
+
await ctx.client.call("importSecrets", {
|
|
205
|
+
body: { items, environment: environment.slug, ...(project ? { repository_id: project.id } : {}) },
|
|
206
|
+
});
|
|
207
|
+
const names = items.map((item) => item.key_name);
|
|
208
|
+
ui.result(
|
|
209
|
+
{ environment: environment.slug, scope: project ? "project" : "shared", imported: names.length, names },
|
|
210
|
+
`Imported ${names.length} value${names.length === 1 ? "" : "s"} into ${environment.slug} (${scopeOf(project)}): ${names.join(", ")}.`,
|
|
211
|
+
);
|
|
212
|
+
return EXIT.OK;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async get(ctx, args) {
|
|
216
|
+
const { io, flags } = ctx;
|
|
217
|
+
const name = secretName(args, "get");
|
|
218
|
+
if (flags.json) throw new CliError(EXIT.USAGE, "JSON never carries a secret value; use --plain.");
|
|
219
|
+
if (!io.stdout.isTTY && !flags.plain) {
|
|
220
|
+
throw new CliError(EXIT.USAGE, "Not printing a secret value into a pipe or a file without --plain.", {
|
|
221
|
+
next: `syndai secrets get ${name} --plain`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const { environment, values } = await resolveValues(ctx, [name]);
|
|
225
|
+
if (!(name in values)) throw new CliError(EXIT.FAILED, `${name} is not set in ${environment}.`, { next: "syndai secrets" });
|
|
226
|
+
// --plain: the exact bytes, for $(...) and pipes; a terminal gets a newline.
|
|
227
|
+
io.stdout.write(flags.plain ? values[name] : `${values[name]}\n`);
|
|
228
|
+
return EXIT.OK;
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async download(ctx, args) {
|
|
232
|
+
const { io, flags, ui } = ctx;
|
|
233
|
+
if (args.length) throw new CliError(EXIT.USAGE, "Usage: syndai secrets download [-o FILE]");
|
|
234
|
+
if (!flags.output && (io.stdout.isTTY || flags.json)) {
|
|
235
|
+
throw new CliError(EXIT.USAGE, "Pass -o FILE: a terminal (or --json) never gets the values.", {
|
|
236
|
+
next: "syndai secrets download -o .env",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const { environment, values } = await resolveValues(ctx);
|
|
240
|
+
const text = serializeDotenv(values);
|
|
241
|
+
const names = Object.keys(values).sort();
|
|
242
|
+
if (!flags.output) {
|
|
243
|
+
io.stdout.write(text);
|
|
244
|
+
return EXIT.OK;
|
|
245
|
+
}
|
|
246
|
+
const file = path.resolve(io.cwd, flags.output);
|
|
247
|
+
writeFileAtomic(file, text, 0o600);
|
|
248
|
+
if (notIgnored(file)) {
|
|
249
|
+
ui.progress(ui.yellow(`Warning: ${flags.output} is not ignored by git; add it to .gitignore so it is never committed.`));
|
|
250
|
+
}
|
|
251
|
+
ui.result(
|
|
252
|
+
{ file, environment, count: names.length, names },
|
|
253
|
+
`Wrote ${names.length} value${names.length === 1 ? "" : "s"} from ${environment} to ${file} (mode 600).`,
|
|
254
|
+
);
|
|
255
|
+
return EXIT.OK;
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** True only inside a git checkout that does not ignore `file` (outside one: false). */
|
|
260
|
+
function notIgnored(file) {
|
|
261
|
+
try {
|
|
262
|
+
execFileSync("git", ["check-ignore", "-q", path.basename(file)], {
|
|
263
|
+
cwd: path.dirname(file),
|
|
264
|
+
stdio: "ignore",
|
|
265
|
+
timeout: 3000,
|
|
266
|
+
});
|
|
267
|
+
return false; // exit 0: ignored
|
|
268
|
+
} catch (err) {
|
|
269
|
+
return err.status === 1; // 1: not ignored; 128 (no repo) or no git: nothing to warn about
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The chosen environment's merged values (`names` narrows them). A production environment
|
|
275
|
+
* the server refuses (no approved step-up) gets one browser step-up on a terminal, whose grant
|
|
276
|
+
* rides only this command's retry.
|
|
277
|
+
*/
|
|
278
|
+
async function resolveValues(ctx, names) {
|
|
279
|
+
const slug = chosenSlug(ctx);
|
|
280
|
+
const project = await layeredProject(ctx);
|
|
281
|
+
const body = {
|
|
282
|
+
...(slug ? { environment: slug } : {}),
|
|
283
|
+
...(project ? { repository_id: project.id } : {}),
|
|
284
|
+
...(names ? { names } : {}),
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
return await ctx.client.call("resolveSecrets", { body });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (!(err instanceof ApiError && err.code === "PRODUCTION_STEP_UP_REQUIRED")) throw err;
|
|
290
|
+
const grant = await productionStepUp(ctx);
|
|
291
|
+
if (!grant) throw err;
|
|
292
|
+
return ctx.client.call("resolveSecrets", { body: { ...body, production_grant: grant } });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- syndai run --------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/** `syndai run [--env E] [--] <command>...`: the child alone gets the values; its exit code is ours. */
|
|
299
|
+
export async function run(ctx, command) {
|
|
300
|
+
const { io, ui } = ctx;
|
|
301
|
+
if (!command.length) throw new CliError(EXIT.USAGE, "Usage: syndai run [--env E] [--] <command> [args...]");
|
|
302
|
+
const { environment, values } = await resolveValues(ctx);
|
|
303
|
+
const count = Object.keys(values).length;
|
|
304
|
+
ui.progress(ui.dim(`Injected ${count} value${count === 1 ? "" : "s"} from ${environment}`));
|
|
305
|
+
let exit;
|
|
306
|
+
try {
|
|
307
|
+
exit = await io.exec(command[0], command.slice(1), { ...io.env, ...values });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (err.code === "ENOENT") {
|
|
310
|
+
ui.progress(`${ui.red("error:")} command not found: ${command[0]}`);
|
|
311
|
+
return 127;
|
|
312
|
+
}
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
if (exit.signal) return 128 + (constants.signals[exit.signal] ?? 0);
|
|
316
|
+
return exit.code ?? EXIT.FAILED;
|
|
317
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -59,6 +59,56 @@ export async function ask(io, question) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Ask for a secret without echoing it, in raw mode: Enter ends it, Ctrl-C interrupts, Backspace
|
|
64
|
+
* edits, an escape sequence (an arrow key) is dropped. A terminal that cannot hide typing is
|
|
65
|
+
* refused rather than echoing, and so is a pasted multi-line value (it would be cut at its
|
|
66
|
+
* first line): both go through stdin instead.
|
|
67
|
+
*/
|
|
68
|
+
export async function askSecret(io, question) {
|
|
69
|
+
const { stdin } = io;
|
|
70
|
+
if (typeof stdin.setRawMode !== "function") {
|
|
71
|
+
throw new CliError(EXIT.USAGE, "This terminal can't hide what you type; pipe the value on stdin instead.");
|
|
72
|
+
}
|
|
73
|
+
if (io.signal.aborted) throw interrupted();
|
|
74
|
+
io.stderr.write(question);
|
|
75
|
+
stdin.setRawMode(true);
|
|
76
|
+
stdin.setEncoding("utf8");
|
|
77
|
+
stdin.resume();
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
let value = "";
|
|
80
|
+
const finish = (err) => {
|
|
81
|
+
stdin.removeListener("data", onData);
|
|
82
|
+
io.signal.removeEventListener("abort", onAbort);
|
|
83
|
+
stdin.setRawMode(false);
|
|
84
|
+
stdin.pause();
|
|
85
|
+
io.stderr.write("\n");
|
|
86
|
+
if (err) reject(err);
|
|
87
|
+
else resolve(value);
|
|
88
|
+
};
|
|
89
|
+
const onAbort = () => finish(interrupted());
|
|
90
|
+
const onData = (chunk) => {
|
|
91
|
+
// An escape sequence (ESC [ ... final byte) is a key like an arrow, never part of a value.
|
|
92
|
+
const keys = chunk.replace(/\u001b\[[0-9;?]*[@-~]|\u001b./g, "");
|
|
93
|
+
for (let i = 0; i < keys.length; i++) {
|
|
94
|
+
const ch = keys[i];
|
|
95
|
+
if (ch === "\r" || ch === "\n") {
|
|
96
|
+
if (keys.slice(i + 1).replace(/^[\r\n]+/, "")) {
|
|
97
|
+
return finish(new CliError(EXIT.USAGE, "That value has more than one line; pipe it on stdin instead."));
|
|
98
|
+
}
|
|
99
|
+
return finish();
|
|
100
|
+
}
|
|
101
|
+
if (ch === "\u0003") return finish(interrupted());
|
|
102
|
+
if (ch === "\u0004") return finish(value ? null : new CliError(EXIT.NEEDS_YOU, "No answer: input closed."));
|
|
103
|
+
if (ch === "\u007f" || ch === "\b") value = Array.from(value).slice(0, -1).join("");
|
|
104
|
+
else value += ch;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
stdin.on("data", onData);
|
|
108
|
+
io.signal.addEventListener("abort", onAbort, { once: true });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
/** Release stdin so the process can exit (called once a command is done). */
|
|
63
113
|
export function closeInput(io) {
|
|
64
114
|
readers.get(io.stdin)?.rl.close();
|