@m6d/cortex-cli 0.15.0 → 0.17.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/README.md +45 -28
- package/package.json +3 -13
- package/src/commands/apply.ts +61 -38
- package/src/commands/management.ts +64 -19
- package/src/commands/new.ts +8 -10
- package/src/commands/setup.ts +83 -38
- package/src/contracts/datasets/dataset-sql.ts +36 -2
- package/src/contracts/datasets/datasets.ts +84 -182
- package/src/contracts/egress-policy/scrub.ts +11 -2
- package/src/contracts/errors/index.ts +7 -0
- package/src/contracts/json/index.ts +40 -17
- package/src/contracts/manifest/index.ts +601 -168
- package/src/contracts/server/index.ts +44 -53
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +149 -27
- package/src/management/client.ts +79 -117
- package/src/management/profiles.ts +11 -22
- package/src/scaffold/files.ts +13 -11
- package/src/ui/prompts.ts +30 -9
- package/src/contracts/charts/chart-spec.ts +0 -217
- package/src/contracts/charts/index.ts +0 -8
- package/src/contracts/datasets/datasets.fixtures.json +0 -2849
- package/src/contracts/markdown/index.ts +0 -47
- package/src/contracts/pattern/index.ts +0 -276
- package/src/contracts/server/reserved-names.fixtures.json +0 -16
package/README.md
CHANGED
|
@@ -48,11 +48,11 @@ manifest takes its id from the name and Cortex keeps the id `cortex` for its own
|
|
|
48
48
|
bunx @m6d/cortex-cli new shop --datasets
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
On a
|
|
52
|
-
the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
|
|
51
|
+
On a terminal it asks only what the flags left open: the name, whether to set up the `helper` agent
|
|
52
|
+
on the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
|
|
53
53
|
the provider with one chat message to the model, and reuses it for embeddings when it serves the
|
|
54
|
-
stack's embedding model. With no
|
|
55
|
-
hard error rather than a hang.
|
|
54
|
+
stack's embedding model. With no terminal (stdin and stderr) to ask on, it never prompts and behaves
|
|
55
|
+
as `--yes`, so a missing name is a hard error rather than a hang.
|
|
56
56
|
|
|
57
57
|
| Flag | Default | Writes |
|
|
58
58
|
| ------------------------ | ------- | ------------------------------------------------------------------------------------------- |
|
|
@@ -62,15 +62,15 @@ hard error rather than a hang.
|
|
|
62
62
|
|
|
63
63
|
The image runs at the Cortex release that was current when this CLI was published
|
|
64
64
|
(`CORTEX_VERSION` in the shell picks another); the backend depends on the `@m6d/cortex` release of
|
|
65
|
-
that time. A server name stops at Cortex's
|
|
65
|
+
that time. A server name stops at Cortex's 120 characters, so a longer project name is
|
|
66
66
|
registered cut to that length.
|
|
67
67
|
|
|
68
68
|
## `cortex setup`
|
|
69
69
|
|
|
70
70
|
Registers a `cortex new` project's backend in its Cortex, then applies its manifest. It reads the
|
|
71
71
|
backend's name, URL and key (`CORTEX_SERVER_*`) and the model provider (`MODEL_PROVIDER_*`) from
|
|
72
|
-
`.env`, and goes in the one order Cortex accepts: the
|
|
73
|
-
|
|
72
|
+
`.env`, and goes in the one order Cortex accepts: the provider's key and the provider, the server
|
|
73
|
+
key's secret and the server on the provider's model, a sync of the server, then the apply. Each step
|
|
74
74
|
creates what is missing and keeps what exists, so a second run changes nothing. A key's secret is
|
|
75
75
|
named for its value (`openai-api-key-1a2b3c4d`), so a changed key gets a new secret the provider or
|
|
76
76
|
server moves to, and whatever else presents the old one keeps it. A provider or server under
|
|
@@ -87,8 +87,14 @@ bun cortex setup --wait --yes # what `bun run dev` runs
|
|
|
87
87
|
image. Without `-p`, setup sets up the Cortex `.env` names: a different `CORTEX_URL` exported in the
|
|
88
88
|
shell, which Bun lets win over `.env`, ends the run. The retried sync, a minute while Cortex cannot
|
|
89
89
|
reach the backend, gives up at once on any other refusal. With no model provider and `--yes`, it
|
|
90
|
-
registers the server and applies
|
|
91
|
-
|
|
90
|
+
registers the server without a model and still applies, since an agent may name its own; when
|
|
91
|
+
Cortex refuses an agent for want of one (`chat_model_required`), nothing is applied and setup says
|
|
92
|
+
how to add a provider. The apply
|
|
93
|
+
deletes what the manifest created and no longer lists, so like `cortex apply` it asks on a terminal
|
|
94
|
+
before it writes anything, and needs `--yes` without one. A field the manifest changes that was
|
|
95
|
+
also edited live is asked about on a terminal. Under `--yes`, as `bun run dev` runs it, such a
|
|
96
|
+
conflict stops every run until `bun cortex apply --yes --prefer live` (or `manifest`) settles it.
|
|
97
|
+
The run that first creates something says how to sign in, with the password only on a terminal.
|
|
92
98
|
|
|
93
99
|
## Management commands
|
|
94
100
|
|
|
@@ -121,17 +127,22 @@ field left out, `id` included, is asked for on a terminal and named as the
|
|
|
121
127
|
missing flag or positional otherwise. Listings take `--pageIndex`, `--pageSize`, `--q`, and `--all` to
|
|
122
128
|
walk every page. `-o json` prints the raw result for pipes.
|
|
123
129
|
|
|
124
|
-
Writes to agents, tools, services, skills, triggers, and dataset
|
|
125
|
-
drafts.
|
|
130
|
+
Writes to agents, tools, services, skills, triggers, MCP servers, and dataset
|
|
131
|
+
hosts save drafts.
|
|
126
132
|
`--publish` on a write the API marks publishable (`update`, and a `create`
|
|
127
133
|
whose result is complete enough) runs that entity's publish procedure right
|
|
128
134
|
after; without it the command says so and names the command. Fields the API
|
|
129
135
|
marks live (`x-live-fields`) take effect without a publish, from agents'
|
|
130
136
|
next turn, and the command names them: a trigger's
|
|
131
137
|
`credentialName`, `fireCredentialName` and model, say, or a dataset host's
|
|
132
|
-
`url`, `
|
|
138
|
+
`url`, `headers` and `timeoutMs`. `cortex datasetHosts sync <id>` reads a host's manifest, and a
|
|
133
139
|
draft host publishes only after `datasetHosts test` reached it once.
|
|
134
140
|
|
|
141
|
+
A procedure the API marks destructive (`x-destructive`: delete, publish, rotate, toggle and the
|
|
142
|
+
like) asks before anything is sent, and so does a `--publish` that runs one. `--yes` skips the
|
|
143
|
+
question. With no terminal to ask on, the command fails unless `--yes` is given, so scripts and CI
|
|
144
|
+
pass it. Questions render on stderr, so `-o json` output stays parseable.
|
|
145
|
+
|
|
135
146
|
## `cortex apply`
|
|
136
147
|
|
|
137
148
|
Makes Cortex match a manifest, in one transaction: what is new is
|
|
@@ -142,16 +153,16 @@ touched, and a name one of them holds fails the apply.
|
|
|
142
153
|
```sh
|
|
143
154
|
cortex apply # ./cortex.manifest.ts
|
|
144
155
|
cortex apply deploy/manifest.json --dry-run
|
|
145
|
-
cortex apply -p prod
|
|
156
|
+
cortex apply -p prod --yes # no question, as CI needs
|
|
146
157
|
cortex apply --prefer live # keep every live edit the manifest also changed
|
|
147
158
|
```
|
|
148
159
|
|
|
149
160
|
An entry's `overridable` lists the fields people may change in Cortex, such
|
|
150
161
|
as an agent's `prompt`. An apply keeps those edits. When the manifest changes
|
|
151
162
|
a field that was also edited live, `cortex apply` asks which to keep for
|
|
152
|
-
each one. `--prefer live` or `--prefer manifest` answers them all.
|
|
153
|
-
|
|
154
|
-
|
|
163
|
+
each one. `--prefer live` or `--prefer manifest` answers them all. `--yes`
|
|
164
|
+
never asks: under it, as without a terminal, an apply with conflicts and no
|
|
165
|
+
`--prefer` changes nothing and lists them. `--dry-run` marks each with `!`.
|
|
155
166
|
|
|
156
167
|
The path defaults to exactly `./cortex.manifest.ts` in the directory you run
|
|
157
168
|
in. TypeScript and JSON load the same way: the file's default export is the
|
|
@@ -189,7 +200,9 @@ openapi: [
|
|
|
189
200
|
{
|
|
190
201
|
name: "shop",
|
|
191
202
|
url: "https://shop.example.com/openapi.json",
|
|
192
|
-
|
|
203
|
+
headers: [{ header: "Authorization", prefix: "Bearer ", from: { kind: "runToken" } }],
|
|
204
|
+
// For an API that wraps every answer: where each one keeps its result.
|
|
205
|
+
// resultPath: "/data",
|
|
193
206
|
pinned: ["findOrder"],
|
|
194
207
|
// A GET runs at once; any other method waits for the end user's approval.
|
|
195
208
|
approval: { never: ["addOrderNote"], always: ["exportOrders"] },
|
|
@@ -198,10 +211,11 @@ openapi: [
|
|
|
198
211
|
],
|
|
199
212
|
```
|
|
200
213
|
|
|
201
|
-
Every apply reads the document first, without the source's
|
|
214
|
+
Every apply reads the document first, without the source's headers. An
|
|
202
215
|
operation it adds is created, a changed one updated, and a dropped one
|
|
203
|
-
deleted. A source
|
|
204
|
-
|
|
216
|
+
deleted. A source whose headers carry a credential or the run token is read
|
|
217
|
+
only over https, or from a host the deployment allows, since the document
|
|
218
|
+
says where they go. A source needs the `tools` write permission even when nothing
|
|
205
219
|
changes. Each tool is named after its `operationId`, made an identifier of at
|
|
206
220
|
most 64 characters; an operation Cortex cannot import is left
|
|
207
221
|
out. Two operations that come out with one name are told apart by a number
|
|
@@ -215,16 +229,19 @@ The manifest is checked against the contract before anything is sent, then
|
|
|
215
229
|
sent as written, so Cortex fills its own defaults. It goes to the
|
|
216
230
|
management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
|
|
217
231
|
which changes nothing) under the same profile the management commands use, so
|
|
218
|
-
the key needs `
|
|
232
|
+
the key needs `manifests:apply` and the write permission of every kind the
|
|
219
233
|
apply changes. The output lists each changed entry under its kind: `+`
|
|
220
234
|
created, `~` updated, `-` deleted. A trigger the apply creates, or whose
|
|
221
235
|
`fireCredential` it drops, gets a fire secret, printed once below the summary
|
|
222
236
|
and never again.
|
|
223
237
|
|
|
238
|
+
Since a sync deletes, `cortex apply` asks before sending it. `--yes` skips the
|
|
239
|
+
question, and is required with no terminal to ask on. `--dry-run` never asks.
|
|
240
|
+
|
|
224
241
|
References between entries (mentions, service scopes, which role a
|
|
225
242
|
credential or secret plays) are judged on the state the apply leaves, so
|
|
226
243
|
the order the manifest lists them in does not matter. A dataset host's
|
|
227
|
-
|
|
244
|
+
headers are the exception: its catalog is read with them before the
|
|
228
245
|
apply, so they are judged on the deployment as it stands. A credential a
|
|
229
246
|
trigger fires with cannot go to a host in the same apply that drops
|
|
230
247
|
it; drop it first, then apply again.
|
|
@@ -240,9 +257,9 @@ it; drop it first, then apply again.
|
|
|
240
257
|
|
|
241
258
|
## Exit codes
|
|
242
259
|
|
|
243
|
-
| Command | 0 | 1
|
|
244
|
-
| ------- | ---------------------------------------------------------- |
|
|
245
|
-
| `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled
|
|
246
|
-
| `setup` | Set up, or the server registered with no model to apply on | Not a project,
|
|
247
|
-
| `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or
|
|
248
|
-
| others | The procedure ran | It failed, or couldn't run
|
|
260
|
+
| Command | 0 | 1 |
|
|
261
|
+
| ------- | ---------------------------------------------------------- | --------------------------------------------------------- |
|
|
262
|
+
| `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
|
|
263
|
+
| `setup` | Set up, or the server registered with no model to apply on | Not a project, Cortex refused or unreachable, or declined |
|
|
264
|
+
| `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, refused, or declined |
|
|
265
|
+
| others | The procedure ran | It failed, or couldn't run |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m6d/cortex-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Scaffold Cortex projects and manage a Cortex deployment",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"check": "tsc --noEmit",
|
|
19
|
-
"prepack": "rm -rf src/contracts &&
|
|
19
|
+
"prepack": "rm -rf src/contracts && bun ../../tooling/semantic-release/release-dirs.js src/contracts",
|
|
20
20
|
"postpack": "rm -rf src/contracts",
|
|
21
21
|
"test": "bun test"
|
|
22
22
|
},
|
|
@@ -37,16 +37,6 @@
|
|
|
37
37
|
},
|
|
38
38
|
"releaseWatchPaths": [
|
|
39
39
|
"apps/cortex",
|
|
40
|
-
"packages/cortex-typescript"
|
|
41
|
-
"internal/contracts/src/client-tools",
|
|
42
|
-
"internal/contracts/src/datasets",
|
|
43
|
-
"internal/contracts/src/egress-policy",
|
|
44
|
-
"internal/contracts/src/errors",
|
|
45
|
-
"internal/contracts/src/json",
|
|
46
|
-
"internal/contracts/src/manifest",
|
|
47
|
-
"internal/contracts/src/manifest-file",
|
|
48
|
-
"internal/contracts/src/server",
|
|
49
|
-
"internal/contracts/src/triggers",
|
|
50
|
-
"internal/contracts/src/wire"
|
|
40
|
+
"packages/cortex-typescript"
|
|
51
41
|
]
|
|
52
42
|
}
|
package/src/commands/apply.ts
CHANGED
|
@@ -13,8 +13,8 @@ import { loadManifestFile } from "@cortex/contracts/manifest-file";
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
|
|
15
15
|
import { callManagement, ManagementError } from "@/management/client";
|
|
16
|
-
import {
|
|
17
|
-
import { askChoice } from "@/ui/prompts";
|
|
16
|
+
import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
|
|
17
|
+
import { askChoice, canAsk } from "@/ui/prompts";
|
|
18
18
|
import * as report from "@/ui/report";
|
|
19
19
|
|
|
20
20
|
// Discovery is this exact name in the cwd, with no upward walk, so the
|
|
@@ -42,9 +42,11 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
|
42
42
|
/**
|
|
43
43
|
* `cortex apply [path]`: makes Cortex match a manifest in one
|
|
44
44
|
* transaction, through the management API with the profile's key.
|
|
45
|
-
* `--dry-run` answers what would change and changes nothing
|
|
46
|
-
*
|
|
47
|
-
* `--prefer` answers
|
|
45
|
+
* `--dry-run` answers what would change and changes nothing; anything
|
|
46
|
+
* else asks first, since a sync deletes. A field both the manifest and a
|
|
47
|
+
* live edit in Cortex changed is asked about too, unless `--prefer` answers
|
|
48
|
+
* every one. `--yes` never asks: the sync goes ahead, and a conflict
|
|
49
|
+
* `--prefer` leaves open ends the run.
|
|
48
50
|
*/
|
|
49
51
|
export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
50
52
|
const command = program
|
|
@@ -53,6 +55,10 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
53
55
|
.description("Make Cortex match a manifest")
|
|
54
56
|
.argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
|
|
55
57
|
.option("--dry-run", "Show what would change, and change nothing")
|
|
58
|
+
.option(
|
|
59
|
+
"--yes",
|
|
60
|
+
"Never ask: apply without confirming, and stop on conflicts --prefer leaves open",
|
|
61
|
+
)
|
|
56
62
|
.addOption(
|
|
57
63
|
new Option(
|
|
58
64
|
"--prefer <side>",
|
|
@@ -63,21 +69,35 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
63
69
|
command.action(async function (path, options) {
|
|
64
70
|
const { verbose = false, profile: profileName } = program.opts();
|
|
65
71
|
const dryRun = options.dryRun === true;
|
|
66
|
-
const file = findManifest(path);
|
|
72
|
+
const file = findManifest(path, "cortex apply");
|
|
67
73
|
const profile = requireProfile(profileName);
|
|
68
74
|
|
|
69
75
|
report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
|
|
76
|
+
const manifest = await loadManifest(file, verbose);
|
|
70
77
|
report.input("Manifest", label(file));
|
|
71
78
|
report.input("Cortex", profile.url);
|
|
79
|
+
if (!dryRun) {
|
|
80
|
+
await confirmDestructive(
|
|
81
|
+
`Applying deletes what the manifest created on ${profile.url} and no longer lists.`,
|
|
82
|
+
options.yes,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
72
85
|
|
|
73
|
-
const { changes, triggerSecrets, conflicts } = await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
const { changes, triggerSecrets, conflicts } = await callOrFail(profile, () =>
|
|
87
|
+
applyManifest({
|
|
88
|
+
profile,
|
|
89
|
+
manifest,
|
|
90
|
+
dryRun,
|
|
91
|
+
prefer: options.prefer,
|
|
92
|
+
interactive: canAsk() && options.yes !== true,
|
|
93
|
+
// A run that does not ask confirmed its sync with --yes.
|
|
94
|
+
command: [
|
|
95
|
+
"cortex apply",
|
|
96
|
+
...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)]),
|
|
97
|
+
"--yes",
|
|
98
|
+
].join(" "),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
81
101
|
const created = countNames(changes.create);
|
|
82
102
|
const updated = countNames(changes.update);
|
|
83
103
|
const deleted = countNames(changes.delete);
|
|
@@ -97,28 +117,26 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
/**
|
|
100
|
-
*
|
|
101
|
-
* prints its changes one row per entry, and a preview's conflicts.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
120
|
+
* Sends a manifest `loadManifest` read, a preview under `dryRun`, then
|
|
121
|
+
* prints its changes one row per entry, and a preview's conflicts. A sync
|
|
122
|
+
* deletes what the manifest created and no longer lists, so its caller
|
|
123
|
+
* confirms first (`confirmDestructive`). Ends the run on a summary this CLI
|
|
124
|
+
* cannot read; a refusal throws, for the caller to end the run on
|
|
125
|
+
* (`callOrFail`) or to answer itself. `cortex apply` and `cortex setup` both
|
|
126
|
+
* apply through it; each prints the trigger secrets it returns.
|
|
105
127
|
*/
|
|
106
128
|
export async function applyManifest(options: {
|
|
107
129
|
profile: ReturnType<typeof requireProfile>;
|
|
108
|
-
|
|
130
|
+
manifest: Record<string, unknown>;
|
|
109
131
|
dryRun: boolean;
|
|
110
|
-
verbose: boolean;
|
|
111
132
|
prefer?: Side;
|
|
112
133
|
interactive: boolean;
|
|
134
|
+
/** What to run again to answer the conflicts it could not ask about. */
|
|
135
|
+
command: string;
|
|
113
136
|
}) {
|
|
114
|
-
const { profile,
|
|
137
|
+
const { profile, dryRun } = options;
|
|
115
138
|
const answer = await sendManifest({
|
|
116
139
|
...options,
|
|
117
|
-
// What to run again: `cortex apply`, with the path unless it is the default.
|
|
118
|
-
command: ["cortex apply", ...(label(file) === DEFAULT_MANIFEST ? [] : [label(file)])].join(
|
|
119
|
-
" ",
|
|
120
|
-
),
|
|
121
|
-
manifest: await loadManifest(file, verbose),
|
|
122
140
|
path: dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
123
141
|
});
|
|
124
142
|
// The secrets are read apart from the changes: a sync has committed by
|
|
@@ -146,8 +164,8 @@ export async function applyManifest(options: {
|
|
|
146
164
|
/**
|
|
147
165
|
* Sends the manifest until nothing in it is left unanswered. A sync refused
|
|
148
166
|
* for fields both the manifest and a live edit changed asks about each one
|
|
149
|
-
* when
|
|
150
|
-
* the run naming them. Any other refusal
|
|
167
|
+
* when `interactive`, then goes again with the answers; otherwise it ends
|
|
168
|
+
* the run naming them. Any other refusal throws.
|
|
151
169
|
*/
|
|
152
170
|
async function sendManifest(options: {
|
|
153
171
|
profile: ReturnType<typeof requireProfile>;
|
|
@@ -171,7 +189,7 @@ async function sendManifest(options: {
|
|
|
171
189
|
error instanceof ManagementError
|
|
172
190
|
? unresolvedSchema.safeParse(error.detail)
|
|
173
191
|
: undefined;
|
|
174
|
-
if (!refused?.success)
|
|
192
|
+
if (!refused?.success) throw error;
|
|
175
193
|
if (!interactive) failUnresolved(refused.data.conflicts, command);
|
|
176
194
|
for (const conflict of refused.data.conflicts) {
|
|
177
195
|
const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
|
|
@@ -189,14 +207,14 @@ function conflictLabel({ kind, name, field }: Conflict) {
|
|
|
189
207
|
return `${kind} ${name}: ${field} changed in both places`;
|
|
190
208
|
}
|
|
191
209
|
|
|
192
|
-
/** Ends a run that
|
|
210
|
+
/** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
|
|
193
211
|
function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
|
|
194
212
|
const count = conflicts.length;
|
|
195
213
|
return report.fail({
|
|
196
214
|
what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
|
|
197
215
|
blocks: [
|
|
198
216
|
conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
|
|
199
|
-
"Keep one side for all of them, or run in a terminal to choose each:",
|
|
217
|
+
"Keep one side for all of them, or run in a terminal without --yes to choose each:",
|
|
200
218
|
` ${command} --prefer live\n ${command} --prefer manifest`,
|
|
201
219
|
],
|
|
202
220
|
});
|
|
@@ -208,8 +226,12 @@ function label(path: string) {
|
|
|
208
226
|
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
209
227
|
}
|
|
210
228
|
|
|
211
|
-
/**
|
|
212
|
-
|
|
229
|
+
/**
|
|
230
|
+
* The manifest's absolute path; a missing file ends the run before anything
|
|
231
|
+
* prints. `command`, when the command also takes a path, is offered as the
|
|
232
|
+
* way to point at a manifest elsewhere.
|
|
233
|
+
*/
|
|
234
|
+
export function findManifest(path: string, command?: string) {
|
|
213
235
|
const absolute = resolve(process.cwd(), path);
|
|
214
236
|
if (existsSync(absolute)) return absolute;
|
|
215
237
|
return report.fail({
|
|
@@ -217,9 +239,10 @@ export function findManifest(path: string) {
|
|
|
217
239
|
blocks:
|
|
218
240
|
path === DEFAULT_MANIFEST
|
|
219
241
|
? [
|
|
220
|
-
"
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
"The CLI reads exactly ./cortex.manifest.ts in the directory you run in.\nIt does not search parent directories.",
|
|
243
|
+
command
|
|
244
|
+
? `Run it from the project root, or point at the file:\n ${command} ./deploy/cortex.manifest.ts`
|
|
245
|
+
: "Run it from the project root.",
|
|
223
246
|
]
|
|
224
247
|
: ["The path is resolved from the directory you run in."],
|
|
225
248
|
});
|
|
@@ -230,7 +253,7 @@ export function findManifest(path: string) {
|
|
|
230
253
|
* a mistake is named before anything is sent, then sent as written: Cortex
|
|
231
254
|
* fills its own defaults, which may be newer than this CLI's.
|
|
232
255
|
*/
|
|
233
|
-
async function loadManifest(path: string, verbose: boolean) {
|
|
256
|
+
export async function loadManifest(path: string, verbose: boolean) {
|
|
234
257
|
const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
|
|
235
258
|
report.fail({
|
|
236
259
|
what: `Could not load ${label(path)}`,
|
|
@@ -88,18 +88,22 @@ export function registerManagement(program: Program, argv: string[]) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* The
|
|
92
|
-
*
|
|
93
|
-
* when there is no terminal
|
|
91
|
+
* The URL is `--url`, or asked for on a terminal. The key is never a flag
|
|
92
|
+
* (argv is visible to other processes and lands in shell history): it is
|
|
93
|
+
* prompted for masked, or read from `CORTEX_KEY` when there is no terminal
|
|
94
|
+
* to ask.
|
|
94
95
|
*/
|
|
95
96
|
async function login(given: string | undefined, name: string) {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
"",
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
const typed =
|
|
98
|
+
given ??
|
|
99
|
+
(prompts.canAsk()
|
|
100
|
+
? await prompts.askValue("Cortex URL", "https://cortex.example.com")
|
|
101
|
+
: report.fail({
|
|
102
|
+
what: "No terminal to ask for the Cortex URL",
|
|
103
|
+
blocks: ["Pass it with --url."],
|
|
104
|
+
}));
|
|
105
|
+
const url = requireTls(typed.replace(/\/+$/, ""));
|
|
106
|
+
const key = prompts.canAsk()
|
|
103
107
|
? await prompts.askSecret("Management key")
|
|
104
108
|
: (process.env["CORTEX_KEY"] ??
|
|
105
109
|
report.fail({
|
|
@@ -248,7 +252,8 @@ function groupByRouter(document: OpenApiDocument) {
|
|
|
248
252
|
/**
|
|
249
253
|
* One procedure as one command: a positional for `id`, a typed flag per
|
|
250
254
|
* field, `--json` for a whole body, `--all` on paginated reads, `--publish`
|
|
251
|
-
* on writes to a publishable router
|
|
255
|
+
* on writes to a publishable router, `--yes` wherever a destructive
|
|
256
|
+
* procedure (`x-destructive`) would run.
|
|
252
257
|
*/
|
|
253
258
|
function registerOperation(scope: {
|
|
254
259
|
group: Command;
|
|
@@ -273,10 +278,13 @@ function registerOperation(scope: {
|
|
|
273
278
|
const publisher = publishWith === undefined ? undefined : byProcedure.get(publishWith);
|
|
274
279
|
const canPublish = publisher !== undefined;
|
|
275
280
|
const liveFields = operation["x-live-fields"] ?? [];
|
|
281
|
+
const destructive = operation["x-destructive"] === true;
|
|
282
|
+
const publishDestructive = publisher?.operation["x-destructive"] === true;
|
|
276
283
|
|
|
277
284
|
const command = declareCommand(group, name, operation.summary ?? "", flags, {
|
|
278
285
|
paginated,
|
|
279
286
|
canPublish,
|
|
287
|
+
confirms: destructive || publishDestructive,
|
|
280
288
|
});
|
|
281
289
|
|
|
282
290
|
command.action(async function (...args) {
|
|
@@ -285,11 +293,12 @@ function registerOperation(scope: {
|
|
|
285
293
|
json?: string;
|
|
286
294
|
all?: boolean;
|
|
287
295
|
publish?: boolean;
|
|
296
|
+
yes?: boolean;
|
|
288
297
|
};
|
|
289
298
|
// Commander passes the options object first when the command declares
|
|
290
299
|
// no argument, so only a string is the `[id]` positional.
|
|
291
300
|
const positional = typeof args[0] === "string" ? args[0] : undefined;
|
|
292
|
-
const { output: format, json, all, publish, ...given } = options;
|
|
301
|
+
const { output: format, json, all, publish, yes, ...given } = options;
|
|
293
302
|
const body = await collectInput(flags, given, json, positional, name);
|
|
294
303
|
// `-o json` promises stdout holds only the result; prose moves to stderr.
|
|
295
304
|
const note = format === "json" ? report.aside : report.note;
|
|
@@ -297,6 +306,20 @@ function registerOperation(scope: {
|
|
|
297
306
|
report.header(`cortex ${router} ${name}`);
|
|
298
307
|
report.input("Cortex", profile.url);
|
|
299
308
|
}
|
|
309
|
+
// One question before anything is sent, covering the publish too, so
|
|
310
|
+
// a no leaves no draft behind.
|
|
311
|
+
const confirming = [
|
|
312
|
+
...(destructive ? [`${router} ${name}`] : []),
|
|
313
|
+
...(publish === true && publishDestructive
|
|
314
|
+
? [publisher.operation.operationId.replace(".", " ")]
|
|
315
|
+
: []),
|
|
316
|
+
];
|
|
317
|
+
if (confirming.length > 0) {
|
|
318
|
+
await confirmDestructive(
|
|
319
|
+
`Running ${confirming.join(" and ")} on ${profile.url} replaces or removes state.`,
|
|
320
|
+
yes,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
300
323
|
const result = await callOrFail(profile, () =>
|
|
301
324
|
all
|
|
302
325
|
? walkPages(profile, method, path, body)
|
|
@@ -320,7 +343,7 @@ function declareCommand(
|
|
|
320
343
|
name: string,
|
|
321
344
|
summary: string,
|
|
322
345
|
flags: Flag[],
|
|
323
|
-
shape: { paginated: boolean; canPublish: boolean },
|
|
346
|
+
shape: { paginated: boolean; canPublish: boolean; confirms: boolean },
|
|
324
347
|
) {
|
|
325
348
|
const command = group
|
|
326
349
|
.command(name)
|
|
@@ -353,6 +376,8 @@ function declareCommand(
|
|
|
353
376
|
}
|
|
354
377
|
if (shape.paginated) command.option("--all", "Walk every page");
|
|
355
378
|
if (shape.canPublish) command.option("--publish", "Publish the entity right after saving");
|
|
379
|
+
if (shape.confirms)
|
|
380
|
+
command.option("--yes", "Skip the confirmation; required without a terminal");
|
|
356
381
|
return command;
|
|
357
382
|
}
|
|
358
383
|
|
|
@@ -363,6 +388,22 @@ function warningsOf(published: unknown) {
|
|
|
363
388
|
: [];
|
|
364
389
|
}
|
|
365
390
|
|
|
391
|
+
/**
|
|
392
|
+
* The go-ahead for a call that replaces or removes state, which `effect`
|
|
393
|
+
* names: `--yes`, or a yes on a terminal. With no terminal to ask, the run
|
|
394
|
+
* fails before anything is sent, so a script never hangs on the question.
|
|
395
|
+
* The hint does not echo argv, which may hold values that must not reach a
|
|
396
|
+
* CI log.
|
|
397
|
+
*/
|
|
398
|
+
export async function confirmDestructive(effect: string, yes: boolean | undefined) {
|
|
399
|
+
if (yes === true) return;
|
|
400
|
+
if (prompts.canAsk()) return prompts.askToProceed(`${effect} Continue?`);
|
|
401
|
+
report.fail({
|
|
402
|
+
what: "No terminal to confirm this call",
|
|
403
|
+
blocks: [effect, "Rerun the command with --yes to run it without asking."],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
366
407
|
/**
|
|
367
408
|
* A write to a publishable router is a draft: publish it now, or say how to.
|
|
368
409
|
* Fields Cortex reads live are already in effect and are named as such,
|
|
@@ -408,14 +449,18 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
|
|
|
408
449
|
|
|
409
450
|
/**
|
|
410
451
|
* A management refusal, or a Cortex that cannot be reached, is the
|
|
411
|
-
* run's outcome, in the error grammar
|
|
412
|
-
* anything else is a bug, and
|
|
452
|
+
* run's outcome, in the error grammar: the refusal's literal, the sentence
|
|
453
|
+
* Cortex words it in, then whatever it names; anything else is a bug, and
|
|
454
|
+
* rethrown.
|
|
413
455
|
*/
|
|
414
456
|
export function failCall(profile: { url: string }, error: unknown): never {
|
|
415
457
|
if (error instanceof ManagementError) {
|
|
416
458
|
report.fail({
|
|
417
459
|
what: `${error.status} ${error.code}: ${error.message}`,
|
|
418
|
-
blocks:
|
|
460
|
+
blocks: [
|
|
461
|
+
...(error.refusal === undefined ? [] : [error.refusal]),
|
|
462
|
+
...refusalBlocks(error.detail ?? {}),
|
|
463
|
+
],
|
|
419
464
|
});
|
|
420
465
|
}
|
|
421
466
|
if (error instanceof UnreachableError) {
|
|
@@ -459,8 +504,8 @@ function issueLine(issue: unknown) {
|
|
|
459
504
|
|
|
460
505
|
/**
|
|
461
506
|
* The body: `--json` first, flags layered over it, the positional as `id`.
|
|
462
|
-
* A required field left missing is asked for on a
|
|
463
|
-
* flag, or the positional, to add otherwise.
|
|
507
|
+
* A required field left missing is asked for on a terminal and named as
|
|
508
|
+
* the flag, or the positional, to add otherwise.
|
|
464
509
|
*/
|
|
465
510
|
async function collectInput(
|
|
466
511
|
flags: Flag[],
|
|
@@ -476,7 +521,7 @@ async function collectInput(
|
|
|
476
521
|
if (positional !== undefined) body.set("id", positional);
|
|
477
522
|
|
|
478
523
|
const missing = flags.filter((flag) => flag.required && body.get(flag.name) === undefined);
|
|
479
|
-
if (missing.length > 0 && !
|
|
524
|
+
if (missing.length > 0 && !prompts.canAsk()) {
|
|
480
525
|
const named = (flag: Flag) =>
|
|
481
526
|
flag.name === "id"
|
|
482
527
|
? "<id>"
|
package/src/commands/new.ts
CHANGED
|
@@ -9,7 +9,7 @@ import pkg from "../../package.json";
|
|
|
9
9
|
import { askProvider } from "@/commands/setup";
|
|
10
10
|
import { AGENT_SLUG, manifestIdOf, scaffoldFiles } from "@/scaffold/files";
|
|
11
11
|
import { allocatePorts, type Ports } from "@/scaffold/ports";
|
|
12
|
-
import { askConfirm, askName, intro, log, outro, runSteps } from "@/ui/prompts";
|
|
12
|
+
import { askConfirm, askName, canAsk, intro, log, outro, runSteps } from "@/ui/prompts";
|
|
13
13
|
import * as report from "@/ui/report";
|
|
14
14
|
|
|
15
15
|
/** npm's rule for an unscoped package name. The name is the directory too. */
|
|
@@ -46,9 +46,8 @@ type Options = { datasets?: true; setup?: boolean; yes?: true; verbose: boolean
|
|
|
46
46
|
async function scaffold(name: string | undefined, options: Options) {
|
|
47
47
|
const { verbose } = options;
|
|
48
48
|
|
|
49
|
-
// No
|
|
50
|
-
|
|
51
|
-
const interactive = process.stdin.isTTY === true && options.yes !== true;
|
|
49
|
+
// No terminal ⇒ never prompt and behave as `--yes`.
|
|
50
|
+
const interactive = canAsk() && options.yes !== true;
|
|
52
51
|
|
|
53
52
|
if (name === undefined && !interactive) missingName();
|
|
54
53
|
|
|
@@ -280,14 +279,13 @@ function isInsideWorkTree(target: string) {
|
|
|
280
279
|
}
|
|
281
280
|
|
|
282
281
|
function missingName(): never {
|
|
283
|
-
// No
|
|
284
|
-
// With
|
|
282
|
+
// No terminal ⇒ never prompt, so a missing required argument is a hard error.
|
|
283
|
+
// With one, this branch is only reached under `--yes`, which answers every
|
|
285
284
|
// question including the one that has no default.
|
|
286
285
|
report.fail({
|
|
287
|
-
what:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
: "cortex new needs a project name when stdin is not a TTY.",
|
|
286
|
+
what: canAsk()
|
|
287
|
+
? "cortex new needs a project name."
|
|
288
|
+
: "cortex new needs a project name when there is no terminal to ask on.",
|
|
291
289
|
blocks: [
|
|
292
290
|
"Pass it as an argument, and --yes to accept defaults for the rest:",
|
|
293
291
|
" cortex new my-app --yes",
|