@m6d/cortex-cli 0.15.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 +38 -22
- 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 +77 -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 +369 -124
- package/src/contracts/server/index.ts +44 -53
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +126 -25
- 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 +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/src/management/client.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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"
|
|
15
|
-
"x-publish-procedure"
|
|
16
|
-
"x-live-fields"
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
if (!
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
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 {
|
|
5
|
+
import { z } from "zod";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
56
|
+
export function saveProfile(name: string, profile: z.infer<typeof profileSchema>) {
|
|
68
57
|
const store = readStore();
|
|
69
58
|
writeStore({
|
|
70
59
|
current: name,
|
package/src/scaffold/files.ts
CHANGED
|
@@ -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.
|
|
32
|
+
const CORTEX_VERSION = "0.16.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": "
|
|
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.
|
|
67
|
-
typescript: "
|
|
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.
|
|
153
|
+
"@m6d/cortex": "~0.4.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",
|
|
@@ -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,
|
|
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
|
-
|
|
25
|
-
const value = await answer;
|
|
27
|
+
const output = process.stderr;
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
}),
|