@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.
@@ -3,47 +3,76 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
5
  import { isRecord } from "@cortex/contracts/json";
6
+ import { z } from "zod";
6
7
 
7
8
  import { profilesPath } from "./profiles";
8
9
 
9
- export type OpenApiOperation = {
10
- operationId: string;
11
- summary?: string;
12
- tags?: string[];
10
+ /**
11
+ * The schema subset the flag builder reads: `type`, `properties`, `required`,
12
+ * `enum`, `items`, `anyOf`, `description`, `format`, `$ref`, each of its
13
+ * declared shape when present. Parsed recursively so a nested `null` never
14
+ * reaches it.
15
+ */
16
+ const jsonSchemaSchema = z.object({
17
+ type: z.union([z.string(), z.array(z.string())]).optional(),
18
+ get properties() {
19
+ return z.record(z.string(), jsonSchemaSchema).optional();
20
+ },
21
+ required: z.array(z.string()).optional(),
22
+ enum: z.array(z.unknown()).optional(),
23
+ /** `false` on a tuple, whose positions `prefixItems` lists instead. */
24
+ get items() {
25
+ return z.union([z.boolean(), jsonSchemaSchema]).optional();
26
+ },
27
+ get anyOf() {
28
+ return z.array(jsonSchemaSchema).optional();
29
+ },
30
+ description: z.string().optional(),
31
+ format: z.string().optional(),
32
+ $ref: z.string().optional(),
33
+ });
34
+ export type JsonSchema = z.infer<typeof jsonSchemaSchema>;
35
+
36
+ const contentSchema = z.record(z.string(), z.object({ schema: jsonSchemaSchema }));
37
+
38
+ const operationSchema = z.object({
39
+ operationId: z.string(),
40
+ summary: z.string().optional(),
41
+ tags: z.array(z.string()).optional(),
13
42
  /** Cortex's own semantics, so nothing here is inferred from names or prose. */
14
- "x-destructive"?: true;
15
- "x-publish-procedure"?: string;
16
- "x-live-fields"?: string[];
43
+ "x-destructive": z.literal(true).optional(),
44
+ "x-publish-procedure": z.string().optional(),
45
+ "x-live-fields": z.array(z.string()).optional(),
17
46
  /** A query's input, one parameter per field; a field the URL cannot carry as a scalar has JSON `content` instead of a `schema`. */
18
- parameters?: Array<{
19
- name: string;
20
- in: string;
21
- required?: boolean;
22
- schema?: JsonSchema;
23
- content?: Record<string, { schema: JsonSchema }>;
24
- }>;
25
- requestBody?: { content: Record<string, { schema: JsonSchema }> };
26
- };
27
- export type JsonSchema = {
28
- type?: string | string[];
29
- properties?: Record<string, JsonSchema>;
30
- required?: string[];
31
- enum?: unknown[];
32
- /** `false` on a tuple, whose positions `prefixItems` lists instead. */
33
- items?: JsonSchema | boolean;
34
- anyOf?: JsonSchema[];
35
- default?: unknown;
36
- description?: string;
37
- format?: string;
38
- $ref?: string;
39
- };
40
- export type OpenApiDocument = {
41
- info: { version: string };
42
- paths: Record<string, Record<string, OpenApiOperation>>;
43
- components?: { schemas?: Record<string, JsonSchema> };
44
- };
47
+ parameters: z
48
+ .array(
49
+ z.object({
50
+ name: z.string(),
51
+ in: z.string(),
52
+ required: z.boolean().optional(),
53
+ schema: jsonSchemaSchema.optional(),
54
+ content: contentSchema.optional(),
55
+ }),
56
+ )
57
+ .optional(),
58
+ requestBody: z.object({ content: contentSchema }).optional(),
59
+ });
60
+ export type OpenApiOperation = z.infer<typeof operationSchema>;
61
+
62
+ /**
63
+ * Enough of an OpenAPI document to build commands from: `info.version`, and a
64
+ * `paths` object whose every operation has an `operationId`. Every field the
65
+ * command and flag builders read, down through `components` schemas, is
66
+ * parsed into the shape they expect.
67
+ */
68
+ const openApiDocumentSchema = z.object({
69
+ info: z.object({ version: z.string() }),
70
+ paths: z.record(z.string(), z.record(z.string(), operationSchema)),
71
+ components: z.object({ schemas: z.record(z.string(), jsonSchemaSchema).optional() }).optional(),
72
+ });
73
+ export type OpenApiDocument = z.infer<typeof openApiDocumentSchema>;
45
74
 
46
- type Cached = { etag: string; document: OpenApiDocument };
75
+ const cachedSchema = z.object({ etag: z.string(), document: openApiDocumentSchema });
47
76
 
48
77
  /**
49
78
  * One cache entry per Cortex base URL (scheme, host, port, path),
@@ -61,85 +90,10 @@ function cachePath(url: string) {
61
90
  );
62
91
  }
63
92
 
64
- type Check = (value: unknown) => boolean;
65
-
66
- const isString = (value: unknown) => typeof value === "string";
67
- const optional = (check: Check) => (value: unknown) => value === undefined || check(value);
68
- const arrayOf = (check: Check) => (value: unknown) => Array.isArray(value) && value.every(check);
69
- const recordOf = (check: Check) => (value: unknown) =>
70
- isRecord(value) && Object.values(value).every(check);
71
- const isStrings = arrayOf(isString);
72
-
73
- /** A record whose every listed field passes its check; fields not listed are ignored. */
74
- function hasFields(value: unknown, fields: Record<string, Check>) {
75
- return isRecord(value) && Object.entries(fields).every(([key, check]) => check(value[key]));
76
- }
77
-
78
- /**
79
- * The schema subset the flag builder reads: `type`, `properties`, `required`,
80
- * `enum`, `items`, `anyOf`, `description`, `format`, `$ref`, each of its
81
- * declared shape when present. Checked recursively so a nested `null` never
82
- * reaches it.
83
- */
84
- const SCHEMA_FIELDS = {
85
- type: optional((v) => isString(v) || isStrings(v)),
86
- properties: optional(recordOf(isJsonSchema)),
87
- required: optional(isStrings),
88
- enum: optional(Array.isArray),
89
- items: optional((v) => typeof v === "boolean" || isJsonSchema(v)),
90
- anyOf: optional(arrayOf(isJsonSchema)),
91
- description: optional(isString),
92
- format: optional(isString),
93
- $ref: optional(isString),
94
- } satisfies Record<string, Check>;
95
-
96
- function isJsonSchema(value: unknown): value is JsonSchema {
97
- return hasFields(value, SCHEMA_FIELDS);
98
- }
99
-
100
- const isContent = recordOf((media) => hasFields(media, { schema: isJsonSchema }));
101
-
102
- const PARAMETER_FIELDS = {
103
- name: isString,
104
- in: isString,
105
- required: optional((v) => typeof v === "boolean"),
106
- schema: optional(isJsonSchema),
107
- content: optional(isContent),
108
- } satisfies Record<string, Check>;
109
-
110
- const OPERATION_FIELDS = {
111
- operationId: isString,
112
- summary: optional(isString),
113
- tags: optional(isStrings),
114
- "x-publish-procedure": optional(isString),
115
- "x-live-fields": optional(isStrings),
116
- parameters: optional(arrayOf((parameter) => hasFields(parameter, PARAMETER_FIELDS))),
117
- requestBody: optional((v) => hasFields(v, { content: isContent })),
118
- } satisfies Record<string, Check>;
119
-
120
- /**
121
- * Enough of an OpenAPI document to build commands from: `info.version`, and a
122
- * `paths` object whose every operation has an `operationId`. Every field the
123
- * command and flag builders read, down through `components` schemas, is
124
- * checked for the shape they expect.
125
- */
126
- function isOpenApiDocument(value: unknown): value is OpenApiDocument {
127
- return hasFields(value, {
128
- info: (v) => hasFields(v, { version: isString }),
129
- paths: recordOf(recordOf((operation) => hasFields(operation, OPERATION_FIELDS))),
130
- components: optional((v) => hasFields(v, { schemas: optional(recordOf(isJsonSchema)) })),
131
- });
132
- }
133
-
134
- function isCached(value: unknown): value is Cached {
135
- return hasFields(value, { etag: isString, document: isOpenApiDocument });
136
- }
137
-
138
93
  /** A cache entry that is missing, fails to parse, or has the wrong shape is treated as absent. */
139
94
  function readCache(path: string) {
140
95
  try {
141
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
142
- return isCached(parsed) ? parsed : undefined;
96
+ return cachedSchema.parse(JSON.parse(readFileSync(path, "utf8")));
143
97
  } catch {
144
98
  return undefined;
145
99
  }
@@ -161,8 +115,9 @@ export async function loadOpenApi(url: string) {
161
115
  if (response.status === 304 && cached)
162
116
  return { document: cached.document, from: "cache" as const };
163
117
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
164
- const document: unknown = await response.json();
165
- if (!isOpenApiDocument(document)) throw new Error("not an OpenAPI document");
118
+ const parsed = openApiDocumentSchema.safeParse(await response.json());
119
+ if (!parsed.success) throw new Error("not an OpenAPI document");
120
+ const document = parsed.data;
166
121
  writeCache(path, { etag: response.headers.get("etag") ?? "", document });
167
122
  return { document, from: "network" as const };
168
123
  } catch (error) {
@@ -172,7 +127,7 @@ export async function loadOpenApi(url: string) {
172
127
  }
173
128
 
174
129
  /** Best-effort: a cache that cannot be written costs the next run a fetch, nothing more. */
175
- function writeCache(path: string, entry: Cached) {
130
+ function writeCache(path: string, entry: z.infer<typeof cachedSchema>) {
176
131
  try {
177
132
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
178
133
  writeFileSync(path, JSON.stringify(entry));
@@ -208,7 +163,7 @@ export function inputSchemaOf(operation: OpenApiOperation) {
208
163
  * string, one parameter per field, with a scalar written plainly and
209
164
  * anything else JSON-encoded, which is how the document declares it. A
210
165
  * mutation posts its input as the JSON body. A refusal throws
211
- * `ManagementError` with the error body's code, message and detail.
166
+ * `ManagementError` with the error body's code, message, detail and sentence.
212
167
  */
213
168
  export async function callManagement(
214
169
  profile: { url: string; key: string },
@@ -230,9 +185,10 @@ export async function callManagement(
230
185
  });
231
186
  const body: unknown = await response.json().catch(() => null);
232
187
  if (!response.ok) {
233
- const { code, message, cause } = (body ?? {}) as {
188
+ const { code, message, refusal, cause } = (body ?? {}) as {
234
189
  code?: string;
235
190
  message?: string;
191
+ refusal?: string;
236
192
  cause?: unknown;
237
193
  };
238
194
  throw new ManagementError(
@@ -240,6 +196,7 @@ export async function callManagement(
240
196
  code ?? "error",
241
197
  message ?? response.statusText,
242
198
  isRecord(cause) ? cause : undefined,
199
+ refusal,
243
200
  );
244
201
  }
245
202
  return body;
@@ -275,13 +232,18 @@ export class UnreachableError extends Error {
275
232
  }
276
233
  }
277
234
 
278
- /** A refusal; `detail` is what the refusal names, when it names something (an entry, the row holding a name). */
235
+ /**
236
+ * A refusal; `detail` is what the refusal names, when it names something (an
237
+ * entry, the row holding a name), and `refusal` the sentence Cortex words it
238
+ * in, when it has one.
239
+ */
279
240
  export class ManagementError extends Error {
280
241
  public constructor(
281
242
  public readonly status: number,
282
243
  public readonly code: string,
283
244
  message: string,
284
245
  public readonly detail?: Record<string, unknown>,
246
+ public readonly refusal?: string,
285
247
  ) {
286
248
  super(message);
287
249
  this.name = "ManagementError";
@@ -2,10 +2,14 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- import { isRecord } from "@cortex/contracts/json";
5
+ import { z } from "zod";
6
6
 
7
- type Profile = { url: string; key: string };
8
- type Store = { current: string; profiles: Record<string, Profile> };
7
+ const profileSchema = z.object({ url: z.string(), key: z.string() });
8
+ const storeSchema = z.object({
9
+ current: z.string(),
10
+ profiles: z.record(z.string(), profileSchema),
11
+ });
12
+ type Store = z.infer<typeof storeSchema>;
9
13
 
10
14
  /** `~/.cortex/profiles.json`; `CORTEX_HOME` relocates it (tests, CI). */
11
15
  export function profilesPath() {
@@ -20,22 +24,6 @@ export class ProfileFileError extends Error {
20
24
  }
21
25
  }
22
26
 
23
- function isProfile(value: unknown): value is Profile {
24
- if (!isRecord(value)) return false;
25
- const { url, key } = value;
26
- return typeof url === "string" && typeof key === "string";
27
- }
28
-
29
- function isStore(value: unknown): value is Store {
30
- if (!isRecord(value)) return false;
31
- const { current, profiles } = value;
32
- return (
33
- typeof current === "string" &&
34
- isRecord(profiles) &&
35
- Object.values(profiles).every(isProfile)
36
- );
37
- }
38
-
39
27
  /** A profile by name, own properties only: a name like `constructor` is not a profile. */
40
28
  function lookup(store: Store, name: string) {
41
29
  return Object.hasOwn(store.profiles, name) ? store.profiles[name] : undefined;
@@ -50,8 +38,9 @@ function readStore() {
50
38
  } catch (error) {
51
39
  throw new ProfileFileError(path, error instanceof Error ? error.message : String(error));
52
40
  }
53
- if (!isStore(parsed)) throw new ProfileFileError(path, "unexpected shape");
54
- return parsed;
41
+ const store = storeSchema.safeParse(parsed);
42
+ if (!store.success) throw new ProfileFileError(path, z.prettifyError(store.error));
43
+ return store.data;
55
44
  }
56
45
 
57
46
  /** The key is a credential, so the file and its directory are owner-only. */
@@ -64,7 +53,7 @@ function writeStore(store: Store) {
64
53
 
65
54
  // Names are user input, so the store is rebuilt from entries rather than
66
55
  // assigned into: `__proto__` and friends stay ordinary keys.
67
- export function saveProfile(name: string, profile: Profile) {
56
+ export function saveProfile(name: string, profile: z.infer<typeof profileSchema>) {
68
57
  const store = readStore();
69
58
  writeStore({
70
59
  current: name,
@@ -7,6 +7,8 @@
7
7
  * workspace, and a standalone scaffold has no workspace.
8
8
  */
9
9
 
10
+ import { NAME_MAX_LENGTH } from "@cortex/contracts/manifest";
11
+
10
12
  import pkg from "../../package.json";
11
13
  import serverTs from "@/scaffold/templates/server.ts.txt";
12
14
  import setupTs from "@/scaffold/templates/setup.ts.txt";
@@ -27,7 +29,7 @@ import { applyPorts, type Ports } from "@/scaffold/ports";
27
29
  * `@m6d/cortex` range below are stamped by scripts/sync-package-versions.ts
28
30
  * before the CLI publishes, so a scaffold pins what was current then.
29
31
  */
30
- const CORTEX_VERSION = "0.15.0";
32
+ const CORTEX_VERSION = "0.17.0";
31
33
 
32
34
  /** This machine as Cortex's containers name it: the one private host the stack's egress policy admits. */
33
35
  export const DOCKER_HOST = "host.docker.internal";
@@ -35,9 +37,6 @@ export const DOCKER_HOST = "host.docker.internal";
35
37
  /** How Cortex's containers reach the backend on this machine, at the port drawn for it. */
36
38
  const SERVER_URL = `http://${DOCKER_HOST}:{{port.server}}`;
37
39
 
38
- /** The longest server name Cortex accepts. */
39
- const SERVER_NAME_MAX = 100;
40
-
41
40
  /**
42
41
  * The agent the manifest declares and the README sends you to chat with.
43
42
  * Not `assistant`: Cortex holds that slug for its console's own assistant.
@@ -51,10 +50,11 @@ export const AGENT_SLUG = "helper";
51
50
  */
52
51
  export const EMBEDDING = { model: "text-embedding-3-small", dimension: 768 } as const;
53
52
 
53
+ /** The ranges this repository builds with; files.test.ts holds them to its catalog. */
54
54
  const DEV_DEPENDENCIES = {
55
55
  // `cortex apply`, `cortex setup` and the management commands, at the release that wrote the project.
56
56
  "@m6d/cortex-cli": `~${pkg.version}`,
57
- "@types/bun": "latest",
57
+ "@types/bun": "1.3.14",
58
58
  // The two runners of `bun run dev` and `bun run dev:agent`.
59
59
  concurrently: "^10.0.6",
60
60
  cspell: "^9.6.4",
@@ -63,8 +63,8 @@ const DEV_DEPENDENCIES = {
63
63
  "eslint-plugin-prettier": "^5.5.5",
64
64
  fallow: "^3.9.1",
65
65
  mprocs: "^0.9.6",
66
- prettier: "^3.6.2",
67
- typescript: "^5",
66
+ prettier: "^3.8.1",
67
+ typescript: "~6.0.3",
68
68
  "typescript-eslint": "^8.65.0",
69
69
  };
70
70
 
@@ -150,7 +150,7 @@ function packageJson({ name, datasets, setup }: Project) {
150
150
  verify: "bun run format:check && bun run lint && bun run spellcheck && bun run check && bun run audit",
151
151
  },
152
152
  dependencies: {
153
- "@m6d/cortex": "~0.3.0",
153
+ "@m6d/cortex": "~0.5.0",
154
154
  // drizzle 1.0 is in beta; this is the build `@m6d/cortex/drizzle` is tested against.
155
155
  ...(datasets ? { "drizzle-orm": "1.0.0-beta.15-859cf75" } : {}),
156
156
  hono: "^4.12.3",
@@ -198,7 +198,7 @@ if (!server) throw new Error("Apply from this project: its .env names the server
198
198
  {
199
199
  name: "store",
200
200
  url: \`\${serverUrl}/datasets\`,
201
- credentials: [],
201
+ headers: [],
202
202
  expose: ["orders"],
203
203
  pinned: ["orders"],
204
204
  audience: { server },
@@ -223,7 +223,7 @@ export default defineManifest({
223
223
  name: "Helper",
224
224
  server,
225
225
  prompt: "You are a helpful assistant. Address the user as {{userName}}.",
226
- // No identity provider yet: every caller is one shared identity.
226
+ // No identity provider yet: each browser is a visitor of its own.
227
227
  auth: { kind: "anonymous" },
228
228
  },
229
229
  ],${datasets ? datasetHosts : ""}
@@ -422,6 +422,7 @@ services:
422
422
  condition: service_completed_successfully
423
423
  redis:
424
424
  condition: service_healthy
425
+ stop_grace_period: 35s
425
426
 
426
427
  # Runs the agents: widgets, trigger callers and the console's playground
427
428
  # and assistant talk to it.
@@ -441,6 +442,7 @@ services:
441
442
  condition: service_completed_successfully
442
443
  redis:
443
444
  condition: service_healthy
445
+ stop_grace_period: 35s
444
446
 
445
447
  volumes:
446
448
  postgres-data:
@@ -530,7 +532,7 @@ pre-commit hook or CI job should run. Each is also available on its own: \`forma
530
532
  * are drawn by the caller.
531
533
  */
532
534
  export function scaffoldFiles(input: Input) {
533
- const project = { ...input, server: input.name.slice(0, SERVER_NAME_MAX) };
535
+ const project = { ...input, server: input.name.slice(0, NAME_MAX_LENGTH) };
534
536
  const files = {
535
537
  "server.ts": serverTs,
536
538
  "setup.ts": setupTs,
package/src/ui/prompts.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  * an abandoned prompt as an answer and scaffolds the project the user just
5
5
  * tried to escape. Every prompt goes through `orExit`, and the rest of the CLI
6
6
  * reaches the library through this module or not at all.
7
+ *
8
+ * Questions and the cancel line render on stderr: they are the conversation,
9
+ * not the result, so `-o json | jq` and `> file` get only what a command prints.
7
10
  */
8
11
 
9
12
  import {
@@ -21,15 +24,25 @@ import {
21
24
 
22
25
  export { intro, log, outro };
23
26
 
24
- async function orExit<T>(answer: Promise<T | symbol>) {
25
- const value = await answer;
27
+ const output = process.stderr;
26
28
 
27
- if (isCancel(value)) {
28
- cancel("Cancelled — nothing was written.");
29
- process.exit(1);
30
- }
29
+ /**
30
+ * Whether there is a terminal to ask on: stdin to answer from and stderr to
31
+ * show the question. Without one, a command never asks. On Bun a piped stream
32
+ * reports `isTTY` as `undefined` rather than `false`, hence `=== true`.
33
+ */
34
+ export function canAsk() {
35
+ return process.stdin.isTTY === true && process.stderr.isTTY === true;
36
+ }
31
37
 
32
- return value;
38
+ function exitCancelled() {
39
+ cancel("Cancelled — nothing was written.", { output });
40
+ return process.exit(1);
41
+ }
42
+
43
+ async function orExit<T>(answer: Promise<T | symbol>) {
44
+ const value = await answer;
45
+ return isCancel(value) ? exitCancelled() : value;
33
46
  }
34
47
 
35
48
  /**
@@ -42,6 +55,7 @@ export function askName(check: (name: string) => string | undefined) {
42
55
  text({
43
56
  message: "Project name",
44
57
  placeholder: "my-app",
58
+ output,
45
59
  validate: function (value) {
46
60
  const candidate = (value ?? "").trim();
47
61
  if (candidate.length === 0) return "A project name is required.";
@@ -56,12 +70,17 @@ export function askChoice<const TValue extends string>(
56
70
  message: string,
57
71
  options: Parameters<typeof select<TValue>>[0]["options"],
58
72
  ) {
59
- return orExit(select({ message, options }));
73
+ return orExit(select({ message, options, output }));
60
74
  }
61
75
 
62
76
  /** A yes-or-no question a flag left open, answered no unless the caller says otherwise. */
63
77
  export function askConfirm(message: string, initialValue = false) {
64
- return orExit(confirm({ message, initialValue }));
78
+ return orExit(confirm({ message, initialValue, output }));
79
+ }
80
+
81
+ /** A go-ahead `--yes` left open: answering no stops the run as Ctrl-C does. */
82
+ export async function askToProceed(message: string) {
83
+ if (!(await askConfirm(message))) exitCancelled();
65
84
  }
66
85
 
67
86
  /**
@@ -77,6 +96,7 @@ export function askValue(
77
96
  text({
78
97
  message: name,
79
98
  placeholder: hint,
99
+ output,
80
100
  validate: function (value) {
81
101
  const candidate = (value ?? "").trim();
82
102
  return candidate.length === 0 ? `${name} is required.` : check(candidate);
@@ -93,6 +113,7 @@ export function askSecret(
93
113
  return orExit(
94
114
  password({
95
115
  message: name,
116
+ output,
96
117
  validate: (value) =>
97
118
  (value ?? "").length === 0 ? `${name} is required.` : check(value ?? ""),
98
119
  }),