@m6d/cortex-cli 0.14.0 → 0.16.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 +43 -19
- package/package.json +3 -13
- package/src/commands/apply.ts +171 -38
- package/src/commands/management.ts +73 -20
- package/src/commands/new.ts +8 -10
- package/src/commands/setup.ts +77 -37
- package/src/contracts/datasets/dataset-sql.ts +58 -7
- package/src/contracts/datasets/datasets.ts +220 -195
- package/src/contracts/egress-policy/scrub.ts +22 -10
- package/src/contracts/errors/index.ts +7 -0
- package/src/contracts/json/index.ts +45 -1
- package/src/contracts/manifest/index.ts +506 -134
- package/src/contracts/server/index.ts +45 -56
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +153 -18
- package/src/management/client.ts +79 -117
- package/src/management/profiles.ts +11 -22
- package/src/scaffold/files.ts +11 -9
- package/src/ui/prompts.ts +38 -8
- 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 -18
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,8 +127,8 @@ 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
|
|
@@ -132,6 +138,11 @@ next turn, and the command names them: a trigger's
|
|
|
132
138
|
`url`, `credentials` 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,9 +153,17 @@ 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
|
|
157
|
+
cortex apply --prefer live # keep every live edit the manifest also changed
|
|
146
158
|
```
|
|
147
159
|
|
|
160
|
+
An entry's `overridable` lists the fields people may change in Cortex, such
|
|
161
|
+
as an agent's `prompt`. An apply keeps those edits. When the manifest changes
|
|
162
|
+
a field that was also edited live, `cortex apply` asks which to keep for
|
|
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 `!`.
|
|
166
|
+
|
|
148
167
|
The path defaults to exactly `./cortex.manifest.ts` in the directory you run
|
|
149
168
|
in. TypeScript and JSON load the same way: the file's default export is the
|
|
150
169
|
manifest. `defineManifest` from `@m6d/cortex` types one as you write it:
|
|
@@ -182,6 +201,8 @@ openapi: [
|
|
|
182
201
|
name: "shop",
|
|
183
202
|
url: "https://shop.example.com/openapi.json",
|
|
184
203
|
credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
|
|
204
|
+
// For an API that wraps every answer: where each one keeps its result.
|
|
205
|
+
// resultPath: "/data",
|
|
185
206
|
pinned: ["findOrder"],
|
|
186
207
|
// A GET runs at once; any other method waits for the end user's approval.
|
|
187
208
|
approval: { never: ["addOrderNote"], always: ["exportOrders"] },
|
|
@@ -207,12 +228,15 @@ The manifest is checked against the contract before anything is sent, then
|
|
|
207
228
|
sent as written, so Cortex fills its own defaults. It goes to the
|
|
208
229
|
management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
|
|
209
230
|
which changes nothing) under the same profile the management commands use, so
|
|
210
|
-
the key needs `
|
|
231
|
+
the key needs `manifests:apply` and the write permission of every kind the
|
|
211
232
|
apply changes. The output lists each changed entry under its kind: `+`
|
|
212
233
|
created, `~` updated, `-` deleted. A trigger the apply creates, or whose
|
|
213
234
|
`fireCredential` it drops, gets a fire secret, printed once below the summary
|
|
214
235
|
and never again.
|
|
215
236
|
|
|
237
|
+
Since a sync deletes, `cortex apply` asks before sending it. `--yes` skips the
|
|
238
|
+
question, and is required with no terminal to ask on. `--dry-run` never asks.
|
|
239
|
+
|
|
216
240
|
References between entries (mentions, service scopes, which role a
|
|
217
241
|
credential or secret plays) are judged on the state the apply leaves, so
|
|
218
242
|
the order the manifest lists them in does not matter. A dataset host's
|
|
@@ -232,9 +256,9 @@ it; drop it first, then apply again.
|
|
|
232
256
|
|
|
233
257
|
## Exit codes
|
|
234
258
|
|
|
235
|
-
| Command | 0 | 1
|
|
236
|
-
| ------- | ---------------------------------------------------------- |
|
|
237
|
-
| `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled
|
|
238
|
-
| `setup` | Set up, or the server registered with no model to apply on | Not a project,
|
|
239
|
-
| `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or
|
|
240
|
-
| others | The procedure ran | It failed, or couldn't run
|
|
259
|
+
| Command | 0 | 1 |
|
|
260
|
+
| ------- | ---------------------------------------------------------- | --------------------------------------------------------- |
|
|
261
|
+
| `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
|
|
262
|
+
| `setup` | Set up, or the server registered with no model to apply on | Not a project, Cortex refused or unreachable, or declined |
|
|
263
|
+
| `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, refused, or declined |
|
|
264
|
+
| 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.16.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
|
@@ -1,19 +1,38 @@
|
|
|
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 { callOrFail, requireProfile } from "@/commands/management";
|
|
15
|
+
import { callManagement, ManagementError } from "@/management/client";
|
|
16
|
+
import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
|
|
17
|
+
import { askChoice, canAsk } 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
22
|
export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
|
|
16
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
|
+
});
|
|
35
|
+
|
|
17
36
|
const CHANGES = ["create", "update", "delete"] as const;
|
|
18
37
|
const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
19
38
|
(typeof CHANGES)[number],
|
|
@@ -23,7 +42,11 @@ 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; 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.
|
|
27
50
|
*/
|
|
28
51
|
export function registerApply(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
29
52
|
const command = program
|
|
@@ -31,27 +54,62 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
31
54
|
.helpGroup("Cortex:")
|
|
32
55
|
.description("Make Cortex match a manifest")
|
|
33
56
|
.argument("[path]", "The manifest, TypeScript or JSON", DEFAULT_MANIFEST)
|
|
34
|
-
.option("--dry-run", "Show what would change, and change nothing")
|
|
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
|
+
)
|
|
62
|
+
.addOption(
|
|
63
|
+
new Option(
|
|
64
|
+
"--prefer <side>",
|
|
65
|
+
"Keep the live or the manifest value of every field both changed, without asking",
|
|
66
|
+
).choices(SIDES),
|
|
67
|
+
);
|
|
35
68
|
|
|
36
69
|
command.action(async function (path, options) {
|
|
37
70
|
const { verbose = false, profile: profileName } = program.opts();
|
|
38
71
|
const dryRun = options.dryRun === true;
|
|
39
|
-
const file = findManifest(path);
|
|
72
|
+
const file = findManifest(path, "cortex apply");
|
|
40
73
|
const profile = requireProfile(profileName);
|
|
41
74
|
|
|
42
75
|
report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
|
|
76
|
+
const manifest = await loadManifest(file, verbose);
|
|
43
77
|
report.input("Manifest", label(file));
|
|
44
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
|
+
}
|
|
45
85
|
|
|
46
|
-
const { changes, triggerSecrets } = await
|
|
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
|
+
);
|
|
47
101
|
const created = countNames(changes.create);
|
|
48
102
|
const updated = countNames(changes.update);
|
|
49
103
|
const deleted = countNames(changes.delete);
|
|
104
|
+
const conflicted =
|
|
105
|
+
conflicts.length > 0
|
|
106
|
+
? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
|
|
107
|
+
: "";
|
|
50
108
|
report.footer(
|
|
51
|
-
created + updated + deleted === 0
|
|
109
|
+
created + updated + deleted + conflicts.length === 0
|
|
52
110
|
? "no changes"
|
|
53
111
|
: dryRun
|
|
54
|
-
? `would create ${created}, update ${updated}, delete ${deleted}`
|
|
112
|
+
? `would create ${created}, update ${updated}, delete ${deleted}${conflicted}`
|
|
55
113
|
: `created ${created}, updated ${updated}, deleted ${deleted}`,
|
|
56
114
|
);
|
|
57
115
|
printTriggerSecrets(triggerSecrets);
|
|
@@ -59,34 +117,35 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
59
117
|
}
|
|
60
118
|
|
|
61
119
|
/**
|
|
62
|
-
*
|
|
63
|
-
* prints its changes one row per entry
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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.
|
|
66
127
|
*/
|
|
67
128
|
export async function applyManifest(options: {
|
|
68
129
|
profile: ReturnType<typeof requireProfile>;
|
|
69
|
-
|
|
130
|
+
manifest: Record<string, unknown>;
|
|
70
131
|
dryRun: boolean;
|
|
71
|
-
|
|
132
|
+
prefer?: Side;
|
|
133
|
+
interactive: boolean;
|
|
134
|
+
/** What to run again to answer the conflicts it could not ask about. */
|
|
135
|
+
command: string;
|
|
72
136
|
}) {
|
|
73
|
-
const { profile,
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"post",
|
|
79
|
-
dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
80
|
-
manifest,
|
|
81
|
-
),
|
|
82
|
-
);
|
|
137
|
+
const { profile, dryRun } = options;
|
|
138
|
+
const answer = await sendManifest({
|
|
139
|
+
...options,
|
|
140
|
+
path: dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
141
|
+
});
|
|
83
142
|
// The secrets are read apart from the changes: a sync has committed by
|
|
84
143
|
// now, and a summary this CLI is too old to read must not lose them.
|
|
85
144
|
const { triggerSecrets = {} } = appliedManifestSchema
|
|
86
145
|
.pick({ triggerSecrets: true })
|
|
87
146
|
.partial()
|
|
88
147
|
.parse(answer);
|
|
89
|
-
const applied =
|
|
148
|
+
const applied = manifestPreviewSchema.partial({ conflicts: true }).safeParse(answer);
|
|
90
149
|
if (!applied.success) {
|
|
91
150
|
printTriggerSecrets(triggerSecrets);
|
|
92
151
|
report.fail({
|
|
@@ -97,8 +156,68 @@ export async function applyManifest(options: {
|
|
|
97
156
|
],
|
|
98
157
|
});
|
|
99
158
|
}
|
|
100
|
-
|
|
101
|
-
|
|
159
|
+
const { conflicts = [], ...changes } = applied.data;
|
|
160
|
+
printChanges(changes, conflicts);
|
|
161
|
+
return { changes, triggerSecrets, conflicts };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Sends the manifest until nothing in it is left unanswered. A sync refused
|
|
166
|
+
* for fields both the manifest and a live edit changed asks about each one
|
|
167
|
+
* when `interactive`, then goes again with the answers; otherwise it ends
|
|
168
|
+
* the run naming them. Any other refusal throws.
|
|
169
|
+
*/
|
|
170
|
+
async function sendManifest(options: {
|
|
171
|
+
profile: ReturnType<typeof requireProfile>;
|
|
172
|
+
path: string;
|
|
173
|
+
command: string;
|
|
174
|
+
manifest: Record<string, unknown>;
|
|
175
|
+
prefer?: Side;
|
|
176
|
+
interactive: boolean;
|
|
177
|
+
}) {
|
|
178
|
+
const { profile, path, command, manifest, prefer, interactive } = options;
|
|
179
|
+
const resolutions: Resolution = [];
|
|
180
|
+
for (;;) {
|
|
181
|
+
try {
|
|
182
|
+
return await callManagement(profile, "post", path, {
|
|
183
|
+
...manifest,
|
|
184
|
+
prefer,
|
|
185
|
+
resolutions,
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const refused =
|
|
189
|
+
error instanceof ManagementError
|
|
190
|
+
? unresolvedSchema.safeParse(error.detail)
|
|
191
|
+
: undefined;
|
|
192
|
+
if (!refused?.success) throw error;
|
|
193
|
+
if (!interactive) failUnresolved(refused.data.conflicts, command);
|
|
194
|
+
for (const conflict of refused.data.conflicts) {
|
|
195
|
+
const keep = await askChoice<Side>(`${conflictLabel(conflict)}. Keep which?`, [
|
|
196
|
+
{ value: "live", label: "Keep live" },
|
|
197
|
+
{ value: "manifest", label: "Use manifest" },
|
|
198
|
+
]);
|
|
199
|
+
resolutions.push({ ...conflict, keep });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** How a conflict reads: its entry and the field both sides changed. */
|
|
206
|
+
function conflictLabel({ kind, name, field }: Conflict) {
|
|
207
|
+
return `${kind} ${name}: ${field} changed in both places`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
|
|
211
|
+
function failUnresolved(conflicts: ReadonlyArray<Conflict>, command: string): never {
|
|
212
|
+
const count = conflicts.length;
|
|
213
|
+
return report.fail({
|
|
214
|
+
what: `${count} field${count === 1 ? "" : "s"} changed both in Cortex and in the manifest`,
|
|
215
|
+
blocks: [
|
|
216
|
+
conflicts.map((conflict) => ` ${conflictLabel(conflict)}`).join("\n"),
|
|
217
|
+
"Keep one side for all of them, or run in a terminal without --yes to choose each:",
|
|
218
|
+
` ${command} --prefer live\n ${command} --prefer manifest`,
|
|
219
|
+
],
|
|
220
|
+
});
|
|
102
221
|
}
|
|
103
222
|
|
|
104
223
|
/** How a manifest path prints: relative to the cwd, `./`-prefixed. */
|
|
@@ -107,8 +226,12 @@ function label(path: string) {
|
|
|
107
226
|
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
108
227
|
}
|
|
109
228
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
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) {
|
|
112
235
|
const absolute = resolve(process.cwd(), path);
|
|
113
236
|
if (existsSync(absolute)) return absolute;
|
|
114
237
|
return report.fail({
|
|
@@ -116,9 +239,10 @@ export function findManifest(path: string) {
|
|
|
116
239
|
blocks:
|
|
117
240
|
path === DEFAULT_MANIFEST
|
|
118
241
|
? [
|
|
119
|
-
"
|
|
120
|
-
|
|
121
|
-
|
|
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.",
|
|
122
246
|
]
|
|
123
247
|
: ["The path is resolved from the directory you run in."],
|
|
124
248
|
});
|
|
@@ -129,7 +253,7 @@ export function findManifest(path: string) {
|
|
|
129
253
|
* a mistake is named before anything is sent, then sent as written: Cortex
|
|
130
254
|
* fills its own defaults, which may be newer than this CLI's.
|
|
131
255
|
*/
|
|
132
|
-
async function loadManifest(path: string, verbose: boolean) {
|
|
256
|
+
export async function loadManifest(path: string, verbose: boolean) {
|
|
133
257
|
const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
|
|
134
258
|
report.fail({
|
|
135
259
|
what: `Could not load ${label(path)}`,
|
|
@@ -140,17 +264,23 @@ async function loadManifest(path: string, verbose: boolean) {
|
|
|
140
264
|
verbose,
|
|
141
265
|
}),
|
|
142
266
|
);
|
|
143
|
-
if (!parsed.success) {
|
|
267
|
+
if (!parsed.success || !isRecord(written)) {
|
|
144
268
|
report.fail({
|
|
145
269
|
what: `${label(path)} is not a valid manifest`,
|
|
146
|
-
blocks: [z.prettifyError(parsed.error)],
|
|
270
|
+
blocks: parsed.success ? [] : [z.prettifyError(parsed.error)],
|
|
147
271
|
});
|
|
148
272
|
}
|
|
149
273
|
return written;
|
|
150
274
|
}
|
|
151
275
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
276
|
+
/**
|
|
277
|
+
* One row per changed entry under its kind: `+` created, `~` updated, `-`
|
|
278
|
+
* deleted, and `!` a field both the manifest and a live edit changed.
|
|
279
|
+
*/
|
|
280
|
+
function printChanges(
|
|
281
|
+
changes: Record<(typeof CHANGES)[number], Record<string, string[]>>,
|
|
282
|
+
conflicts: ReadonlyArray<Conflict>,
|
|
283
|
+
) {
|
|
154
284
|
const rows = new Map<string, string[]>();
|
|
155
285
|
for (const change of CHANGES) {
|
|
156
286
|
for (const [kind, names] of Object.entries(changes[change])) {
|
|
@@ -158,6 +288,9 @@ function printChanges(changes: Record<(typeof CHANGES)[number], Record<string, s
|
|
|
158
288
|
rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
|
|
159
289
|
}
|
|
160
290
|
}
|
|
291
|
+
for (const { kind, name, field } of conflicts) {
|
|
292
|
+
rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
|
|
293
|
+
}
|
|
161
294
|
for (const [kind, lines] of rows) {
|
|
162
295
|
for (const [index, line] of lines.entries()) {
|
|
163
296
|
report.row(index === 0 ? kind : "", line, 14);
|