@m6d/cortex-cli 0.13.0 → 0.15.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 +94 -26
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/apply.ts +171 -44
- package/src/commands/management.ts +41 -22
- package/src/commands/new.ts +48 -25
- package/src/commands/setup.ts +611 -0
- package/src/contracts/datasets/dataset-sql.ts +22 -5
- package/src/contracts/datasets/datasets.ts +139 -16
- package/src/contracts/egress-policy/scrub.ts +11 -8
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/manifest/index.ts +223 -55
- package/src/contracts/server/index.ts +109 -9
- package/src/contracts/server/reserved-names.fixtures.json +1 -3
- package/src/contracts/wire/index.ts +42 -3
- package/src/management/client.ts +14 -0
- package/src/management/profiles.ts +6 -4
- package/src/scaffold/files.ts +183 -144
- package/src/scaffold/ports.ts +5 -6
- package/src/scaffold/templates/cspell.json.txt +41 -0
- package/src/scaffold/templates/dockerignore.txt +3 -4
- package/src/scaffold/templates/gitignore.txt +1 -2
- package/src/scaffold/templates/setup.ts.txt +18 -26
- package/src/ui/prompts.ts +32 -10
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@ bunx cortex apply # make it match ./cortex.manifest.ts
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Commands are noun-first, `cortex <resource> <procedure>`, except `new`, which acts on no existing
|
|
13
|
-
resource, `apply`, which
|
|
14
|
-
on your profiles. Run `cortex --help`, then `cortex <resource> --help`.
|
|
13
|
+
resource, `setup` and `apply`, which act on a whole deployment, and `login`, `logout` and `status`,
|
|
14
|
+
which act on your profiles. Run `cortex --help`, then `cortex <resource> --help`.
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
bun add -d @m6d/cortex-cli
|
|
@@ -28,15 +28,16 @@ Scaffolds `./<name>`: a backend for Cortex agents, and Cortex itself to run them
|
|
|
28
28
|
(`userName`) and one native tool (`rollDice`), mounted at `/cortex` by `index.ts`.
|
|
29
29
|
- `cortex.manifest.ts` declares a `helper` agent on that server, for `cortex apply`.
|
|
30
30
|
- `docker-compose.yml` runs Cortex's app, worker and engine on the one `syscodeia/cortex` image,
|
|
31
|
-
with Postgres, Redis and MinIO. Only the app and the engine publish host ports
|
|
32
|
-
time from what was free.
|
|
33
|
-
- `.env` holds
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
clone gets its own secrets and a stack that never shares the
|
|
37
|
-
keeps
|
|
38
|
-
- `
|
|
39
|
-
|
|
31
|
+
with Postgres, Redis and MinIO. Only the app and the engine publish host ports. They and the
|
|
32
|
+
backend's port are picked at scaffold time from what was free.
|
|
33
|
+
- `.env` holds every setting: Cortex's secrets, the backend's key and name, the model provider, and
|
|
34
|
+
the `CORTEX_URL` and `CORTEX_KEY` the CLI reaches this checkout's Cortex with. It is not
|
|
35
|
+
committed: `setup.ts` writes it from `.env.example` on every `bun install`, generating each
|
|
36
|
+
secret left blank, so a fresh clone gets its own secrets and a stack that never shares the
|
|
37
|
+
original's volumes. `.dockerignore` keeps both out of images.
|
|
38
|
+
- `bun run dev` starts Cortex (`docker compose up`), the backend and `bun cortex setup --wait --yes` in
|
|
39
|
+
an mprocs dashboard. `bun run dev:agent` runs the same through `concurrently`, one labelled log for
|
|
40
|
+
coding agents and CI.
|
|
40
41
|
- eslint, prettier, cspell, fallow and tsconfig are inlined, so `bun run verify` works standalone.
|
|
41
42
|
|
|
42
43
|
It then runs `bun install`, and `git init` unless the target is already inside a work tree. An
|
|
@@ -47,20 +48,48 @@ manifest takes its id from the name and Cortex keeps the id `cortex` for its own
|
|
|
47
48
|
bunx @m6d/cortex-cli new shop --datasets
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
On a TTY it asks only what the flags left open: the name,
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
On a TTY it asks only what the flags left open: the name, whether to set up the `helper` agent on
|
|
52
|
+
the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
|
|
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 TTY it never prompts and behaves as `--yes`, so a missing name is a
|
|
55
|
+
hard error rather than a hang.
|
|
53
56
|
|
|
54
|
-
| Flag
|
|
55
|
-
|
|
|
56
|
-
| `--datasets`
|
|
57
|
-
| `--
|
|
57
|
+
| Flag | Default | Writes |
|
|
58
|
+
| ------------------------ | ------- | ------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `--datasets` | off | `datasets.ts`, an in-memory `orders` table served as a dataset host, and its manifest entry |
|
|
60
|
+
| `--setup` / `--no-setup` | on | `cortex setup` among the processes `bun run dev` starts |
|
|
61
|
+
| `--yes` | | Accept the defaults instead of asking; setup then runs with no model provider |
|
|
58
62
|
|
|
59
63
|
The image runs at the Cortex release that was current when this CLI was published
|
|
60
64
|
(`CORTEX_VERSION` in the shell picks another); the backend depends on the `@m6d/cortex` release of
|
|
61
65
|
that time. A server name stops at Cortex's 100 characters, so a longer project name is
|
|
62
66
|
registered cut to that length.
|
|
63
67
|
|
|
68
|
+
## `cortex setup`
|
|
69
|
+
|
|
70
|
+
Registers a `cortex new` project's backend in its Cortex, then applies its manifest. It reads the
|
|
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 server key's secret, the provider's key and
|
|
73
|
+
the provider, the server on the provider's model, a sync of the server, then the apply. Each step
|
|
74
|
+
creates what is missing and keeps what exists, so a second run changes nothing. A key's secret is
|
|
75
|
+
named for its value (`openai-api-key-1a2b3c4d`), so a changed key gets a new secret the provider or
|
|
76
|
+
server moves to, and whatever else presents the old one keeps it. A provider or server under
|
|
77
|
+
`.env`'s name that calls another URL belongs to something else: setup leaves it alone and stops. So
|
|
78
|
+
does a `MODEL_PROVIDER_*` set only in part, or a provider URL on this machine or a private network
|
|
79
|
+
that `EGRESS_ALLOWED_HOSTS` in `.env` does not admit. A server with no model yet gets the provider's.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
bun cortex setup # asks for a model provider when .env has none
|
|
83
|
+
bun cortex setup --wait --yes # what `bun run dev` runs
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--wait` waits up to 10 minutes for Cortex to answer, long enough for a first start to pull the
|
|
87
|
+
image. Without `-p`, setup sets up the Cortex `.env` names: a different `CORTEX_URL` exported in the
|
|
88
|
+
shell, which Bun lets win over `.env`, ends the run. The retried sync, a minute while Cortex cannot
|
|
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 nothing: Cortex publishes no agent without a model. The run that
|
|
91
|
+
first creates something says how to sign in, with the password only on a terminal.
|
|
92
|
+
|
|
64
93
|
## Management commands
|
|
65
94
|
|
|
66
95
|
Manage a Cortex deployment from the terminal. The commands come from
|
|
@@ -80,9 +109,9 @@ cortex logout
|
|
|
80
109
|
|
|
81
110
|
Profiles live in `~/.cortex/profiles.json` (owner-only). `-p, --profile <name>`,
|
|
82
111
|
a global option, picks one for any command, and names the one `login` saves
|
|
83
|
-
and `logout` forgets (`default` otherwise)
|
|
84
|
-
|
|
85
|
-
the directory.
|
|
112
|
+
and `logout` forgets (`default` otherwise). `CORTEX_URL` and `CORTEX_KEY`, both
|
|
113
|
+
set, win over the current profile, for CI and for a project's `.env`; a
|
|
114
|
+
profile `-p` names wins over them. `CORTEX_HOME` relocates the directory.
|
|
86
115
|
|
|
87
116
|
The grammar is `cortex <router> <procedure> [id] [flags]`, with routers as
|
|
88
117
|
the API names them (`datasetHosts`, `modelProviders`). Each top-level input
|
|
@@ -114,8 +143,16 @@ touched, and a name one of them holds fails the apply.
|
|
|
114
143
|
cortex apply # ./cortex.manifest.ts
|
|
115
144
|
cortex apply deploy/manifest.json --dry-run
|
|
116
145
|
cortex apply -p prod
|
|
146
|
+
cortex apply --prefer live # keep every live edit the manifest also changed
|
|
117
147
|
```
|
|
118
148
|
|
|
149
|
+
An entry's `overridable` lists the fields people may change in Cortex, such
|
|
150
|
+
as an agent's `prompt`. An apply keeps those edits. When the manifest changes
|
|
151
|
+
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. Without a
|
|
153
|
+
terminal and without `--prefer`, the apply changes nothing and lists the
|
|
154
|
+
conflicts; `--dry-run` marks each with `!`.
|
|
155
|
+
|
|
119
156
|
The path defaults to exactly `./cortex.manifest.ts` in the directory you run
|
|
120
157
|
in. TypeScript and JSON load the same way: the file's default export is the
|
|
121
158
|
manifest. `defineManifest` from `@m6d/cortex` types one as you write it:
|
|
@@ -144,6 +181,36 @@ export default defineManifest({
|
|
|
144
181
|
});
|
|
145
182
|
```
|
|
146
183
|
|
|
184
|
+
An `openapi` source makes every operation of an OpenAPI document a tool the
|
|
185
|
+
manifest owns, so a backend with many endpoints needs no entry per tool:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
openapi: [
|
|
189
|
+
{
|
|
190
|
+
name: "shop",
|
|
191
|
+
url: "https://shop.example.com/openapi.json",
|
|
192
|
+
credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
|
|
193
|
+
pinned: ["findOrder"],
|
|
194
|
+
// A GET runs at once; any other method waits for the end user's approval.
|
|
195
|
+
approval: { never: ["addOrderNote"], always: ["exportOrders"] },
|
|
196
|
+
audience: { agents: ["support"] },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Every apply reads the document first, without the source's credentials. An
|
|
202
|
+
operation it adds is created, a changed one updated, and a dropped one
|
|
203
|
+
deleted. A source with credentials is read only over https, or from a host
|
|
204
|
+
the deployment allows, since the document says where they go. A source needs the `tools` write permission even when nothing
|
|
205
|
+
changes. Each tool is named after its `operationId`, made an identifier of at
|
|
206
|
+
most 64 characters; an operation Cortex cannot import is left
|
|
207
|
+
out. Two operations that come out with one name are told apart by a number
|
|
208
|
+
(`getOrders`, `getOrders2`) in the order of their paths. The apply fails
|
|
209
|
+
when `pinned` or `approval` names a tool no operation has (`unknown_tool`)
|
|
210
|
+
or any name such a collision decided (`ambiguous_tool`), since a path the
|
|
211
|
+
document adds or drops could move it to another endpoint. It also fails when an
|
|
212
|
+
operation's name is already a listed tool's or another source's.
|
|
213
|
+
|
|
147
214
|
The manifest is checked against the contract before anything is sent, then
|
|
148
215
|
sent as written, so Cortex fills its own defaults. It goes to the
|
|
149
216
|
management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
|
|
@@ -173,8 +240,9 @@ it; drop it first, then apply again.
|
|
|
173
240
|
|
|
174
241
|
## Exit codes
|
|
175
242
|
|
|
176
|
-
| Command | 0
|
|
177
|
-
| ------- |
|
|
178
|
-
| `new` | Files written — even if `bun install` failed
|
|
179
|
-
| `
|
|
180
|
-
|
|
|
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, or Cortex refused or was unreachable |
|
|
247
|
+
| `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or refused |
|
|
248
|
+
| others | The procedure ran | It failed, or couldn't run |
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import pkg from "../package.json";
|
|
|
4
4
|
import { registerApply } from "@/commands/apply";
|
|
5
5
|
import { registerManagement } from "@/commands/management";
|
|
6
6
|
import { registerNew } from "@/commands/new";
|
|
7
|
+
import { registerSetup } from "@/commands/setup";
|
|
7
8
|
import * as report from "@/ui/report";
|
|
8
9
|
|
|
9
10
|
const program = new Command()
|
|
@@ -34,6 +35,7 @@ const program = new Command()
|
|
|
34
35
|
// Each command owns its help group, so the two headings survive these files being
|
|
35
36
|
// rewritten. Registration order fixes the order the groups print in.
|
|
36
37
|
registerNew(program);
|
|
38
|
+
registerSetup(program);
|
|
37
39
|
registerApply(program);
|
|
38
40
|
// The management commands come from Cortex's API, so this one may fetch
|
|
39
41
|
// before parsing; it looks at argv to decide whether it needs to, which it
|
package/src/commands/apply.ts
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { Option } from "@commander-js/extra-typings";
|
|
4
5
|
import type { Command } from "@commander-js/extra-typings";
|
|
5
|
-
import {
|
|
6
|
+
import { isRecord } from "@cortex/contracts/json";
|
|
7
|
+
import {
|
|
8
|
+
appliedManifestSchema,
|
|
9
|
+
manifestApplyInputSchema,
|
|
10
|
+
manifestPreviewSchema,
|
|
11
|
+
} from "@cortex/contracts/manifest";
|
|
6
12
|
import { loadManifestFile } from "@cortex/contracts/manifest-file";
|
|
7
13
|
import { z } from "zod";
|
|
8
14
|
|
|
9
|
-
import { callManagement } from "@/management/client";
|
|
10
|
-
import {
|
|
15
|
+
import { callManagement, ManagementError } from "@/management/client";
|
|
16
|
+
import { failCall, requireProfile } from "@/commands/management";
|
|
17
|
+
import { askChoice } from "@/ui/prompts";
|
|
11
18
|
import * as report from "@/ui/report";
|
|
12
19
|
|
|
13
20
|
// Discovery is this exact name in the cwd, with no upward walk, so the
|
|
14
21
|
// argument's default is the discovery rule.
|
|
15
|
-
const DEFAULT_MANIFEST = "./cortex.manifest.ts";
|
|
22
|
+
export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
|
|
23
|
+
|
|
24
|
+
/** Which value a field both the manifest and a live write changed keeps. */
|
|
25
|
+
const SIDES = manifestApplyInputSchema.shape.prefer.unwrap().options;
|
|
26
|
+
type Side = (typeof SIDES)[number];
|
|
27
|
+
type Resolution = z.input<typeof manifestApplyInputSchema>["resolutions"];
|
|
28
|
+
type Conflict = z.infer<typeof manifestPreviewSchema>["conflicts"][number];
|
|
29
|
+
|
|
30
|
+
/** A sync refused for fields both sides changed that nothing answered. */
|
|
31
|
+
const unresolvedSchema = z.object({
|
|
32
|
+
error: z.literal("unresolved_conflicts"),
|
|
33
|
+
conflicts: manifestPreviewSchema.shape.conflicts,
|
|
34
|
+
});
|
|
16
35
|
|
|
17
36
|
const CHANGES = ["create", "update", "delete"] as const;
|
|
18
37
|
const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
@@ -23,7 +42,9 @@ const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
|
23
42
|
/**
|
|
24
43
|
* `cortex apply [path]`: makes Cortex match a manifest in one
|
|
25
44
|
* transaction, through the management API with the profile's key.
|
|
26
|
-
* `--dry-run` answers what would change and changes nothing.
|
|
45
|
+
* `--dry-run` answers what would change and changes nothing. A field both
|
|
46
|
+
* the manifest and a live edit in Cortex changed is asked about, unless
|
|
47
|
+
* `--prefer` answers every one.
|
|
27
48
|
*/
|
|
28
49
|
export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
29
50
|
const command = program
|
|
@@ -31,7 +52,13 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
31
52
|
.helpGroup("Cortex:")
|
|
32
53
|
.description("Make Cortex match a manifest")
|
|
33
54
|
.argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
|
|
34
|
-
.option("--dry-run", "Show what would change, and change nothing")
|
|
55
|
+
.option("--dry-run", "Show what would change, and change nothing")
|
|
56
|
+
.addOption(
|
|
57
|
+
new Option(
|
|
58
|
+
"--prefer <side>",
|
|
59
|
+
"Keep the live or the manifest value of every field both changed, without asking",
|
|
60
|
+
).choices(SIDES),
|
|
61
|
+
);
|
|
35
62
|
|
|
36
63
|
command.action(async function (path, options) {
|
|
37
64
|
const { verbose = false, profile: profileName } = program.opts();
|
|
@@ -40,50 +67,141 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
40
67
|
const profile = requireProfile(profileName);
|
|
41
68
|
|
|
42
69
|
report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
|
|
43
|
-
const manifest = await loadManifest(file, verbose);
|
|
44
70
|
report.input("Manifest", label(file));
|
|
45
71
|
report.input("Cortex", profile.url);
|
|
46
72
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!applied.success) {
|
|
63
|
-
printTriggerSecrets(triggerSecrets);
|
|
64
|
-
report.fail({
|
|
65
|
-
what: `${profile.url} answered with a summary this CLI cannot read`,
|
|
66
|
-
blocks: [
|
|
67
|
-
`The ${dryRun ? "preview" : "apply"} went through. Update the CLI to read its summary:`,
|
|
68
|
-
" bun add -d @m6d/cortex-cli@latest",
|
|
69
|
-
],
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
printChanges(applied.data);
|
|
73
|
-
const created = countNames(applied.data.create);
|
|
74
|
-
const updated = countNames(applied.data.update);
|
|
75
|
-
const deleted = countNames(applied.data.delete);
|
|
73
|
+
const { changes, triggerSecrets, conflicts } = await applyManifest({
|
|
74
|
+
profile,
|
|
75
|
+
file,
|
|
76
|
+
dryRun,
|
|
77
|
+
verbose,
|
|
78
|
+
prefer: options.prefer,
|
|
79
|
+
interactive: process.stdin.isTTY === true,
|
|
80
|
+
});
|
|
81
|
+
const created = countNames(changes.create);
|
|
82
|
+
const updated = countNames(changes.update);
|
|
83
|
+
const deleted = countNames(changes.delete);
|
|
84
|
+
const conflicted =
|
|
85
|
+
conflicts.length > 0
|
|
86
|
+
? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
|
|
87
|
+
: "";
|
|
76
88
|
report.footer(
|
|
77
|
-
created + updated + deleted === 0
|
|
89
|
+
created + updated + deleted + conflicts.length === 0
|
|
78
90
|
? "no changes"
|
|
79
91
|
: dryRun
|
|
80
|
-
? `would create ${created}, update ${updated}, delete ${deleted}`
|
|
92
|
+
? `would create ${created}, update ${updated}, delete ${deleted}${conflicted}`
|
|
81
93
|
: `created ${created}, updated ${updated}, deleted ${deleted}`,
|
|
82
94
|
);
|
|
83
95
|
printTriggerSecrets(triggerSecrets);
|
|
84
96
|
});
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Loads the manifest at `file` and sends it, a preview under `dryRun`, then
|
|
101
|
+
* prints its changes one row per entry, and a preview's conflicts. Ends the
|
|
102
|
+
* run on an invalid file, a refusal, or a summary this CLI cannot read.
|
|
103
|
+
* `cortex apply` and `cortex setup` both apply through it; each prints the
|
|
104
|
+
* trigger secrets it returns.
|
|
105
|
+
*/
|
|
106
|
+
export async function applyManifest(options: {
|
|
107
|
+
profile: ReturnType<typeof requireProfile>;
|
|
108
|
+
file: string;
|
|
109
|
+
dryRun: boolean;
|
|
110
|
+
verbose: boolean;
|
|
111
|
+
prefer?: Side;
|
|
112
|
+
interactive: boolean;
|
|
113
|
+
}) {
|
|
114
|
+
const { profile, file, dryRun, verbose } = options;
|
|
115
|
+
const answer = await sendManifest({
|
|
116
|
+
...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
|
+
path: dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
123
|
+
});
|
|
124
|
+
// The secrets are read apart from the changes: a sync has committed by
|
|
125
|
+
// now, and a summary this CLI is too old to read must not lose them.
|
|
126
|
+
const { triggerSecrets = {} } = appliedManifestSchema
|
|
127
|
+
.pick({ triggerSecrets: true })
|
|
128
|
+
.partial()
|
|
129
|
+
.parse(answer);
|
|
130
|
+
const applied = manifestPreviewSchema.partial({ conflicts: true }).safeParse(answer);
|
|
131
|
+
if (!applied.success) {
|
|
132
|
+
printTriggerSecrets(triggerSecrets);
|
|
133
|
+
report.fail({
|
|
134
|
+
what: `${profile.url} answered with a summary this CLI cannot read`,
|
|
135
|
+
blocks: [
|
|
136
|
+
`The ${dryRun ? "preview" : "apply"} went through. Update the CLI to read its summary:`,
|
|
137
|
+
" bun add -d @m6d/cortex-cli@latest",
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const { conflicts = [], ...changes } = applied.data;
|
|
142
|
+
printChanges(changes, conflicts);
|
|
143
|
+
return { changes, triggerSecrets, conflicts };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sends the manifest until nothing in it is left unanswered. A sync refused
|
|
148
|
+
* for fields both the manifest and a live edit changed asks about each one
|
|
149
|
+
* when it can, then goes again with the answers; one that cannot ask ends
|
|
150
|
+
* the run naming them. Any other refusal ends the run as well.
|
|
151
|
+
*/
|
|
152
|
+
async function sendManifest(options: {
|
|
153
|
+
profile: ReturnType<typeof requireProfile>;
|
|
154
|
+
path: string;
|
|
155
|
+
command: string;
|
|
156
|
+
manifest: Record<string, unknown>;
|
|
157
|
+
prefer?: Side;
|
|
158
|
+
interactive: boolean;
|
|
159
|
+
}) {
|
|
160
|
+
const { profile, path, command, manifest, prefer, interactive } = options;
|
|
161
|
+
const resolutions: Resolution = [];
|
|
162
|
+
for (;;) {
|
|
163
|
+
try {
|
|
164
|
+
return await callManagement(profile, "post", path, {
|
|
165
|
+
...manifest,
|
|
166
|
+
prefer,
|
|
167
|
+
resolutions,
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const refused =
|
|
171
|
+
error instanceof ManagementError
|
|
172
|
+
? unresolvedSchema.safeParse(error.detail)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (!refused?.success) return failCall(profile, error);
|
|
175
|
+
if (!interactive) failUnresolved(refused.data.conflicts, command);
|
|
176
|
+
for (const conflict of refused.data.conflicts) {
|
|
177
|
+
const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
|
|
178
|
+
{ value: "live", label: "Keep live" },
|
|
179
|
+
{ value: "manifest", label: "Use manifest" },
|
|
180
|
+
]);
|
|
181
|
+
resolutions.push({ ...conflict, keep });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** How a conflict reads: its entry and the field both sides changed. */
|
|
188
|
+
function conflictLabel({ kind, name, field }: Conflict) {
|
|
189
|
+
return `${kind} ${name}: ${field} changed in both places`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Ends a run that cannot ask, naming every conflict and how to run `command` again to answer them. */
|
|
193
|
+
function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
|
|
194
|
+
const count = conflicts.length;
|
|
195
|
+
return report.fail({
|
|
196
|
+
what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
|
|
197
|
+
blocks: [
|
|
198
|
+
conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
|
|
199
|
+
"Keep one side for all of them, or run in a terminal to choose each:",
|
|
200
|
+
` ${command} --prefer live\n ${command} --prefer manifest`,
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
87
205
|
/** How a manifest path prints: relative to the cwd, `./`-prefixed. */
|
|
88
206
|
function label(path: string) {
|
|
89
207
|
const relativePath = relative(process.cwd(), path);
|
|
@@ -91,7 +209,7 @@ function label(path: string) {
|
|
|
91
209
|
}
|
|
92
210
|
|
|
93
211
|
/** The manifest's absolute path; a missing file ends the run before anything prints. */
|
|
94
|
-
function findManifest(path: string) {
|
|
212
|
+
export function findManifest(path: string) {
|
|
95
213
|
const absolute = resolve(process.cwd(), path);
|
|
96
214
|
if (existsSync(absolute)) return absolute;
|
|
97
215
|
return report.fail({
|
|
@@ -123,17 +241,23 @@ async function loadManifest(path: string, verbose: boolean) {
|
|
|
123
241
|
verbose,
|
|
124
242
|
}),
|
|
125
243
|
);
|
|
126
|
-
if (!parsed.success) {
|
|
244
|
+
if (!parsed.success || !isRecord(written)) {
|
|
127
245
|
report.fail({
|
|
128
246
|
what: `${label(path)} is not a valid manifest`,
|
|
129
|
-
blocks: [z.prettifyError(parsed.error)],
|
|
247
|
+
blocks: parsed.success ? [] : [z.prettifyError(parsed.error)],
|
|
130
248
|
});
|
|
131
249
|
}
|
|
132
250
|
return written;
|
|
133
251
|
}
|
|
134
252
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
253
|
+
/**
|
|
254
|
+
* One row per changed entry under its kind: `+` created, `~` updated, `-`
|
|
255
|
+
* deleted, and `!` a field both the manifest and a live edit changed.
|
|
256
|
+
*/
|
|
257
|
+
function printChanges(
|
|
258
|
+
changes: Record<(typeof CHANGES)[number], Record<string, string[]>>,
|
|
259
|
+
conflicts: ReadonlyArray<Conflict>,
|
|
260
|
+
) {
|
|
137
261
|
const rows = new Map<string, string[]>();
|
|
138
262
|
for (const change of CHANGES) {
|
|
139
263
|
for (const [kind, names] of Object.entries(changes[change])) {
|
|
@@ -141,6 +265,9 @@ function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, s
|
|
|
141
265
|
rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
|
|
142
266
|
}
|
|
143
267
|
}
|
|
268
|
+
for (const { kind, name, field } of conflicts) {
|
|
269
|
+
rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
|
|
270
|
+
}
|
|
144
271
|
for (const [kind, lines] of rows) {
|
|
145
272
|
for (const [index, line] of lines.entries()) {
|
|
146
273
|
report.row(index === 0 ? kind : "", line, 14);
|
|
@@ -156,7 +283,7 @@ function countNames(byKind: Record<string, string[]>) {
|
|
|
156
283
|
* The fire secrets this apply minted, for each trigger it created or whose
|
|
157
284
|
* `fireCredential` it dropped; nothing shows them again.
|
|
158
285
|
*/
|
|
159
|
-
function printTriggerSecrets(secrets: Record<string, string>) {
|
|
286
|
+
export function printTriggerSecrets(secrets: Record<string, string>) {
|
|
160
287
|
const entries = Object.entries(secrets);
|
|
161
288
|
if (entries.length === 0) return;
|
|
162
289
|
report.note(
|
|
@@ -4,7 +4,13 @@ import { type Command, Option } from "@commander-js/extra-typings";
|
|
|
4
4
|
import { canCarrySecret } from "@cortex/contracts/egress-policy";
|
|
5
5
|
import { isRecord } from "@cortex/contracts/json";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
callManagement,
|
|
9
|
+
inputSchemaOf,
|
|
10
|
+
loadOpenApi,
|
|
11
|
+
ManagementError,
|
|
12
|
+
UnreachableError,
|
|
13
|
+
} from "@/management/client";
|
|
8
14
|
import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
|
|
9
15
|
import { flagsFor, parseAnswer, toOption } from "@/management/flags";
|
|
10
16
|
import type { Flag } from "@/management/flags";
|
|
@@ -350,6 +356,13 @@ function declareCommand(
|
|
|
350
356
|
return command;
|
|
351
357
|
}
|
|
352
358
|
|
|
359
|
+
/** What a publish went ahead despite, such as a prompt naming a tool without an @-mention. */
|
|
360
|
+
function warningsOf(published: unknown) {
|
|
361
|
+
return isRecord(published) && Array.isArray(published.warnings)
|
|
362
|
+
? published.warnings.filter((warning): warning is string => typeof warning === "string")
|
|
363
|
+
: [];
|
|
364
|
+
}
|
|
365
|
+
|
|
353
366
|
/**
|
|
354
367
|
* A write to a publishable router is a draft: publish it now, or say how to.
|
|
355
368
|
* Fields Cortex reads live are already in effect and are named as such,
|
|
@@ -372,10 +385,11 @@ async function publishAfterWrite(
|
|
|
372
385
|
);
|
|
373
386
|
}
|
|
374
387
|
if (publish) {
|
|
375
|
-
await callOrFail(profile, () =>
|
|
388
|
+
const published = await callOrFail(profile, () =>
|
|
376
389
|
callManagement(profile, publisher.method, publisher.path, { id }),
|
|
377
390
|
);
|
|
378
391
|
options.note(`Published ${id}.`);
|
|
392
|
+
for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
|
|
379
393
|
} else {
|
|
380
394
|
options.note(
|
|
381
395
|
`${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
|
|
@@ -383,30 +397,35 @@ async function publishAfterWrite(
|
|
|
383
397
|
}
|
|
384
398
|
}
|
|
385
399
|
|
|
386
|
-
/**
|
|
387
|
-
* A management refusal, or a Cortex that cannot be reached, is the
|
|
388
|
-
* run's outcome, in the error grammar, with whatever the refusal names;
|
|
389
|
-
* anything else is a bug.
|
|
390
|
-
*/
|
|
400
|
+
/** `call`'s result, or the run ended by `failCall`. */
|
|
391
401
|
export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promise<T>) {
|
|
392
402
|
try {
|
|
393
403
|
return await call();
|
|
394
404
|
} catch (error) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
405
|
+
return failCall(profile, error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A management refusal, or a Cortex that cannot be reached, is the
|
|
411
|
+
* run's outcome, in the error grammar, with whatever the refusal names;
|
|
412
|
+
* anything else is a bug, and rethrown.
|
|
413
|
+
*/
|
|
414
|
+
export function failCall(profile: { url: string }, error: unknown): never {
|
|
415
|
+
if (error instanceof ManagementError) {
|
|
416
|
+
report.fail({
|
|
417
|
+
what: `${error.status} ${error.code}: ${error.message}`,
|
|
418
|
+
blocks: refusalBlocks(error.detail ?? {}),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (error instanceof UnreachableError) {
|
|
422
|
+
report.fail({
|
|
423
|
+
what: `Could not reach ${profile.url}`,
|
|
424
|
+
error,
|
|
425
|
+
blocks: ["Check the URL and that Cortex is up."],
|
|
426
|
+
});
|
|
409
427
|
}
|
|
428
|
+
throw error;
|
|
410
429
|
}
|
|
411
430
|
|
|
412
431
|
/**
|
|
@@ -527,7 +546,7 @@ function readJsonBody(source: string) {
|
|
|
527
546
|
return parsed;
|
|
528
547
|
}
|
|
529
548
|
|
|
530
|
-
async function walkPages(
|
|
549
|
+
export async function walkPages(
|
|
531
550
|
profile: ResolvedProfile,
|
|
532
551
|
method: "get" | "post",
|
|
533
552
|
path: string,
|