@myna-sh/cli 0.1.4 → 0.3.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 +29 -0
- package/dist/dsl.d.ts +46 -3
- package/dist/dsl.js +20 -0
- package/dist/dsl.js.map +1 -1
- package/dist/main.d.ts +3 -1
- package/dist/main.js +1446 -73
- package/dist/main.js.map +1 -1
- package/package.json +23 -15
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/main.ts
|
|
2
2
|
import { Command, CommanderError } from "commander";
|
|
3
3
|
|
|
4
|
-
// ../sdk/dist/chunk-
|
|
4
|
+
// ../sdk/dist/chunk-5WMWKWGV.js
|
|
5
5
|
var MynaApiError = class _MynaApiError extends Error {
|
|
6
6
|
/** HTTP status code. */
|
|
7
7
|
status;
|
|
@@ -52,6 +52,15 @@ function problemFromResponse(status, body, fallbackTitle) {
|
|
|
52
52
|
detail
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
+
function inBrowser() {
|
|
56
|
+
return typeof globalThis === "object" && "document" in globalThis;
|
|
57
|
+
}
|
|
58
|
+
function defaultMaxRetries() {
|
|
59
|
+
return inBrowser() ? 1 : 3;
|
|
60
|
+
}
|
|
61
|
+
function defaultMaxNetworkRetries() {
|
|
62
|
+
return inBrowser() ? 0 : 1;
|
|
63
|
+
}
|
|
55
64
|
var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
56
65
|
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
57
66
|
function resolveFetch(custom) {
|
|
@@ -59,8 +68,8 @@ function resolveFetch(custom) {
|
|
|
59
68
|
if (typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
|
|
60
69
|
throw new Error("No fetch implementation available. Pass `fetch` to the Myna client.");
|
|
61
70
|
}
|
|
62
|
-
function buildQuery(
|
|
63
|
-
if (!
|
|
71
|
+
function buildQuery(query) {
|
|
72
|
+
if (!query) return "";
|
|
64
73
|
const params = new URLSearchParams();
|
|
65
74
|
const append = (key, value) => {
|
|
66
75
|
if (value === void 0 || value === null) return;
|
|
@@ -76,7 +85,7 @@ function buildQuery(query2) {
|
|
|
76
85
|
}
|
|
77
86
|
params.set(key, String(value));
|
|
78
87
|
};
|
|
79
|
-
for (const [key, value] of Object.entries(
|
|
88
|
+
for (const [key, value] of Object.entries(query)) append(key, value);
|
|
80
89
|
const qs = params.toString();
|
|
81
90
|
return qs ? `?${qs}` : "";
|
|
82
91
|
}
|
|
@@ -106,11 +115,32 @@ var HttpClient = class {
|
|
|
106
115
|
this.fetchImpl = resolveFetch(options.fetch);
|
|
107
116
|
this.defaultHeaders = options.headers ?? {};
|
|
108
117
|
this.retry = {
|
|
109
|
-
maxRetries: options.retry?.maxRetries ??
|
|
118
|
+
maxRetries: options.retry?.maxRetries ?? defaultMaxRetries(),
|
|
110
119
|
baseDelayMs: options.retry?.baseDelayMs ?? 200,
|
|
111
|
-
maxDelayMs: options.retry?.maxDelayMs ?? 5e3
|
|
120
|
+
maxDelayMs: options.retry?.maxDelayMs ?? 5e3,
|
|
121
|
+
maxNetworkRetries: options.retry?.maxNetworkRetries ?? defaultMaxNetworkRetries()
|
|
112
122
|
};
|
|
113
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Share one in-flight request between identical concurrent safe reads.
|
|
126
|
+
*
|
|
127
|
+
* Several components asking for the same collection on first paint is the
|
|
128
|
+
* normal shape of a page, not a mistake, and issuing N identical requests
|
|
129
|
+
* multiplies both latency and any failure. Only GET/HEAD are pooled, and only
|
|
130
|
+
* while in flight — this is request coalescing, not a cache.
|
|
131
|
+
*/
|
|
132
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
133
|
+
dedupe(key, run) {
|
|
134
|
+
const existing = this.inFlight.get(key);
|
|
135
|
+
if (existing) return existing.then((response) => response.clone());
|
|
136
|
+
const started = run();
|
|
137
|
+
this.inFlight.set(key, started);
|
|
138
|
+
const cleanup = () => {
|
|
139
|
+
if (this.inFlight.get(key) === started) this.inFlight.delete(key);
|
|
140
|
+
};
|
|
141
|
+
started.then(cleanup, cleanup);
|
|
142
|
+
return started.then((response) => response.clone());
|
|
143
|
+
}
|
|
114
144
|
/** Perform a request and unwrap the `{ data }` envelope. */
|
|
115
145
|
async request(method, path, options = {}) {
|
|
116
146
|
const raw = await this.requestRaw(method, path, options);
|
|
@@ -143,19 +173,30 @@ var HttpClient = class {
|
|
|
143
173
|
}
|
|
144
174
|
}
|
|
145
175
|
if (options.idempotencyKey) headers["idempotency-key"] = options.idempotencyKey;
|
|
146
|
-
const
|
|
176
|
+
const upper = method.toUpperCase();
|
|
177
|
+
const safe = SAFE_METHODS.has(upper);
|
|
178
|
+
const idempotent = safe || Boolean(options.idempotencyKey);
|
|
147
179
|
const init = { method, headers };
|
|
148
180
|
if (payload !== void 0) init.body = payload;
|
|
149
181
|
if (options.signal) init.signal = options.signal;
|
|
182
|
+
if (safe && !options.signal && (upper === "GET" || upper === "HEAD")) {
|
|
183
|
+
const key = `${upper} ${url} ${token ?? ""}`;
|
|
184
|
+
return this.dedupe(key, () => this.attempt(url, init, idempotent, options));
|
|
185
|
+
}
|
|
186
|
+
return this.attempt(url, init, idempotent, options);
|
|
187
|
+
}
|
|
188
|
+
async attempt(url, init, idempotent, options) {
|
|
150
189
|
let attempt = 0;
|
|
190
|
+
let networkAttempt = 0;
|
|
151
191
|
for (; ; ) {
|
|
152
192
|
let response;
|
|
153
193
|
try {
|
|
154
194
|
response = await this.fetchImpl(url, init);
|
|
155
195
|
} catch (error) {
|
|
156
|
-
if (idempotent && attempt < this.retry.maxRetries && !isAbort(error)) {
|
|
196
|
+
if (idempotent && networkAttempt < this.retry.maxNetworkRetries && attempt < this.retry.maxRetries && !isAbort(error)) {
|
|
157
197
|
await sleep(this.backoff(attempt), options.signal);
|
|
158
198
|
attempt++;
|
|
199
|
+
networkAttempt++;
|
|
159
200
|
continue;
|
|
160
201
|
}
|
|
161
202
|
throw error;
|
|
@@ -196,7 +237,7 @@ function generateTypes(collections) {
|
|
|
196
237
|
for (const collection of sorted) {
|
|
197
238
|
blocks.push(renderInterface(collection));
|
|
198
239
|
}
|
|
199
|
-
const registry = sorted.map((c) =>
|
|
240
|
+
const registry = sorted.map((c) => registryEntry(c)).join("\n");
|
|
200
241
|
const header = [
|
|
201
242
|
"// Generated by `myna types generate`. Do not edit by hand.",
|
|
202
243
|
"// This file is safe to commit and regenerate.",
|
|
@@ -204,11 +245,33 @@ function generateTypes(collections) {
|
|
|
204
245
|
].join("\n");
|
|
205
246
|
return `${header}${blocks.join("\n\n")}
|
|
206
247
|
|
|
248
|
+
${REGISTRY_DOC}
|
|
207
249
|
export interface MynaCollections {
|
|
208
250
|
${registry}
|
|
209
251
|
}
|
|
210
252
|
`;
|
|
211
253
|
}
|
|
254
|
+
var REGISTRY_DOC = [
|
|
255
|
+
"/**",
|
|
256
|
+
" * Collection registry consumed by `createMyna<MynaCollections>()`.",
|
|
257
|
+
" *",
|
|
258
|
+
" * `fields` types the entry payload; `sortable` and `reference` carry the",
|
|
259
|
+
" * distinctions the field types alone cannot express \u2014 a markdown body and a",
|
|
260
|
+
" * text title are both `string`, but only one of them can be ordered on.",
|
|
261
|
+
" */"
|
|
262
|
+
].join("\n");
|
|
263
|
+
var SORTABLE_TYPES = /* @__PURE__ */ new Set(["text", "number", "boolean", "date", "datetime", "slug"]);
|
|
264
|
+
function registryEntry(collection) {
|
|
265
|
+
const sortable = collection.fields.filter((f) => SORTABLE_TYPES.has(f.type) && !f.localized).map((f) => JSON.stringify(f.key));
|
|
266
|
+
const references = collection.fields.filter((f) => f.type === "reference").map((f) => JSON.stringify(f.key));
|
|
267
|
+
return [
|
|
268
|
+
` ${JSON.stringify(collection.name)}: {`,
|
|
269
|
+
` fields: ${pascal(collection.name)};`,
|
|
270
|
+
` sortable: ${sortable.length > 0 ? sortable.join(" | ") : "never"};`,
|
|
271
|
+
` reference: ${references.length > 0 ? references.join(" | ") : "never"};`,
|
|
272
|
+
` };`
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
212
275
|
function renderInterface(collection) {
|
|
213
276
|
const name = pascal(collection.name);
|
|
214
277
|
const lines = collection.fields.map((f) => renderField(f, 1));
|
|
@@ -223,7 +286,9 @@ function renderField(field, depth) {
|
|
|
223
286
|
const optional = field.required ? "" : "?";
|
|
224
287
|
const doc = field.description ? `${indent}/** ${escapeComment(field.description)} */
|
|
225
288
|
` : "";
|
|
226
|
-
|
|
289
|
+
const base = fieldType(field, depth);
|
|
290
|
+
const type = field.localized ? `{ [locale: string]: ${base} }` : base;
|
|
291
|
+
return `${doc}${indent}${safeKey(field.key)}${optional}: ${type};`;
|
|
227
292
|
}
|
|
228
293
|
function fieldType(field, depth) {
|
|
229
294
|
switch (field.type) {
|
|
@@ -250,6 +315,21 @@ function fieldType(field, depth) {
|
|
|
250
315
|
return renderObject(field, depth);
|
|
251
316
|
case "list":
|
|
252
317
|
return `${listItemType(field.item, depth)}[]`;
|
|
318
|
+
case "richText":
|
|
319
|
+
return "RichTextDoc";
|
|
320
|
+
case "blocks": {
|
|
321
|
+
const inner = " ".repeat(depth + 1);
|
|
322
|
+
const options = field.blocks.map((b) => {
|
|
323
|
+
const lines = b.fields.map((f) => renderField(f, depth + 2));
|
|
324
|
+
return `{ type: ${JSON.stringify(b.key)}; fields: {
|
|
325
|
+
${lines.join("\n")}
|
|
326
|
+
${inner}} }`;
|
|
327
|
+
});
|
|
328
|
+
return `Array<
|
|
329
|
+
${inner}${options.join(`
|
|
330
|
+
${inner}| `)}
|
|
331
|
+
${" ".repeat(depth)}>`;
|
|
332
|
+
}
|
|
253
333
|
default:
|
|
254
334
|
return "unknown";
|
|
255
335
|
}
|
|
@@ -291,7 +371,12 @@ ${closingIndent}}`;
|
|
|
291
371
|
return "unknown";
|
|
292
372
|
}
|
|
293
373
|
}
|
|
294
|
-
var JSON_VALUE =
|
|
374
|
+
var JSON_VALUE = [
|
|
375
|
+
"export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };",
|
|
376
|
+
"",
|
|
377
|
+
"/** Portable rich-text document tree (never rendered HTML). */",
|
|
378
|
+
'export interface RichTextDoc { type: "doc"; content: Array<{ type: string; [key: string]: unknown }>; }'
|
|
379
|
+
].join("\n");
|
|
295
380
|
function generateTypesModule(collections) {
|
|
296
381
|
return `${JSON_VALUE}
|
|
297
382
|
|
|
@@ -326,19 +411,25 @@ var ManagementClient = class {
|
|
|
326
411
|
idem() {
|
|
327
412
|
return this.newIdempotencyKey();
|
|
328
413
|
}
|
|
329
|
-
get(path,
|
|
330
|
-
return this.http.request("GET", path, { query
|
|
414
|
+
get(path, query, signal) {
|
|
415
|
+
return this.http.request("GET", path, { query, signal });
|
|
331
416
|
}
|
|
332
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Paged GET. Extra filters are merged into the query rather than appended to
|
|
419
|
+
* the path: the client builds its own query string from `options.query`, so a
|
|
420
|
+
* path that already carries one produces `?a=1?limit=25` — a second `?` that
|
|
421
|
+
* silently becomes part of the previous value.
|
|
422
|
+
*/
|
|
423
|
+
async page(path, opts = {}, filters = {}) {
|
|
333
424
|
const body = await this.http.requestEnvelope(
|
|
334
425
|
"GET",
|
|
335
426
|
path,
|
|
336
|
-
{ query: { limit: opts.limit, cursor: opts.cursor }, signal: opts.signal }
|
|
427
|
+
{ query: { ...filters, limit: opts.limit, cursor: opts.cursor }, signal: opts.signal }
|
|
337
428
|
);
|
|
338
429
|
return { data: body.data, nextCursor: body.pagination?.nextCursor ?? null };
|
|
339
430
|
}
|
|
340
|
-
mutate(method, path, body,
|
|
341
|
-
return this.http.request(method, path, { body, query
|
|
431
|
+
mutate(method, path, body, query) {
|
|
432
|
+
return this.http.request(method, path, { body, query, idempotencyKey: this.idem() });
|
|
342
433
|
}
|
|
343
434
|
// --- Organizations --------------------------------------------------------
|
|
344
435
|
organizations = {
|
|
@@ -368,7 +459,18 @@ var ManagementClient = class {
|
|
|
368
459
|
create: (organization, body) => this.mutate("POST", `/organizations/${enc(organization)}/projects`, body),
|
|
369
460
|
get: (project, signal) => this.get(`/projects/${enc(project)}`, void 0, signal),
|
|
370
461
|
update: (project, body) => this.mutate("PATCH", `/projects/${enc(project)}`, body),
|
|
371
|
-
archive: (project) => this.mutate("POST", `/projects/${enc(project)}/archive`)
|
|
462
|
+
archive: (project) => this.mutate("POST", `/projects/${enc(project)}/archive`),
|
|
463
|
+
/** Full project export: schemas, entries (draft + published), asset metadata. */
|
|
464
|
+
export: (project, signal) => this.get(`/projects/${enc(project)}/export`, void 0, signal),
|
|
465
|
+
/** Why a browser at `origin` can or cannot read this project's content. */
|
|
466
|
+
corsCheck: (project, origin, collection, signal) => this.get(
|
|
467
|
+
`/projects/${enc(project)}/cors-check`,
|
|
468
|
+
collection ? { origin, collection } : { origin },
|
|
469
|
+
signal
|
|
470
|
+
),
|
|
471
|
+
views: (project, signal) => this.get(`/projects/${enc(project)}/views`, void 0, signal),
|
|
472
|
+
createView: (project, body) => this.mutate("POST", `/projects/${enc(project)}/views`, body),
|
|
473
|
+
deleteView: (project, view) => this.mutate("DELETE", `/projects/${enc(project)}/views/${enc(view)}`)
|
|
372
474
|
};
|
|
373
475
|
// --- Schema ---------------------------------------------------------------
|
|
374
476
|
schema = {
|
|
@@ -380,20 +482,43 @@ var ManagementClient = class {
|
|
|
380
482
|
collections,
|
|
381
483
|
allowDestructive: opts.allowDestructive ?? false,
|
|
382
484
|
changeSummary: opts.changeSummary
|
|
485
|
+
}),
|
|
486
|
+
// Environments as projects
|
|
487
|
+
drift: (project, against, signal) => this.get(`/projects/${enc(project)}/schema/drift`, { against }, signal),
|
|
488
|
+
promote: (project, fromProject, opts = {}) => this.mutate("POST", `/projects/${enc(project)}/schema/promote`, {
|
|
489
|
+
fromProject,
|
|
490
|
+
allowDestructive: opts.allowDestructive ?? false,
|
|
491
|
+
confirm: true
|
|
383
492
|
})
|
|
384
493
|
};
|
|
385
494
|
// --- Entries & revisions --------------------------------------------------
|
|
386
495
|
entries = {
|
|
387
|
-
list: (project, opts = {}) => this.page(
|
|
388
|
-
|
|
389
|
-
opts
|
|
390
|
-
),
|
|
496
|
+
list: (project, opts = {}) => this.page(`/projects/${enc(project)}/entries`, opts, {
|
|
497
|
+
collection: opts.collection,
|
|
498
|
+
status: opts.status
|
|
499
|
+
}),
|
|
391
500
|
create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/entries`, body),
|
|
392
501
|
get: (project, entry, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}`, void 0, signal),
|
|
393
502
|
update: (project, entry, body) => this.mutate("PATCH", `/projects/${enc(project)}/entries/${enc(entry)}`, body),
|
|
394
503
|
delete: (project, entry, changeSetId) => this.mutate("DELETE", `/projects/${enc(project)}/entries/${enc(entry)}`, void 0, { changeSetId }),
|
|
395
504
|
unpublish: (project, entry, changeSetId) => this.mutate("POST", `/projects/${enc(project)}/entries/${enc(entry)}/unpublish`, void 0, { changeSetId }),
|
|
396
505
|
restore: (project, entry) => this.mutate("POST", `/projects/${enc(project)}/entries/${enc(entry)}/restore`),
|
|
506
|
+
duplicate: (project, entry, changeSetId) => this.mutate("POST", `/projects/${enc(project)}/entries/${enc(entry)}/duplicate`, {
|
|
507
|
+
changeSetId
|
|
508
|
+
}),
|
|
509
|
+
bulk: (project, body) => this.mutate("POST", `/projects/${enc(project)}/entries/bulk`, body),
|
|
510
|
+
/**
|
|
511
|
+
* Set a collection's editorial order. Not staged on a change set — ranks
|
|
512
|
+
* are arrangement rather than content and take effect immediately, which
|
|
513
|
+
* is why `confirm` is required.
|
|
514
|
+
*/
|
|
515
|
+
reorder: (project, collection, body) => this.mutate(
|
|
516
|
+
"POST",
|
|
517
|
+
`/projects/${enc(project)}/collections/${enc(collection)}/reorder`,
|
|
518
|
+
{ ...body, confirm: true }
|
|
519
|
+
),
|
|
520
|
+
import: (project, body) => this.mutate("POST", `/projects/${enc(project)}/entries/import`, body),
|
|
521
|
+
references: (project, entry, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}/references`, void 0, signal),
|
|
397
522
|
revisions: (project, entry, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}/revisions`, void 0, signal),
|
|
398
523
|
revision: (project, entry, revision, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}/revisions/${enc(revision)}`, void 0, signal),
|
|
399
524
|
restoreRevision: (project, entry, revision) => this.mutate(
|
|
@@ -403,13 +528,54 @@ var ManagementClient = class {
|
|
|
403
528
|
};
|
|
404
529
|
// --- Change sets ----------------------------------------------------------
|
|
405
530
|
changeSets = {
|
|
406
|
-
list: (project, opts = {}) => this.page(`/projects/${enc(project)}/change-sets
|
|
531
|
+
list: (project, opts = {}) => this.page(`/projects/${enc(project)}/change-sets`, opts, { status: opts.status }),
|
|
407
532
|
create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/change-sets`, body),
|
|
408
533
|
get: (project, changeSet, signal) => this.get(`/projects/${enc(project)}/change-sets/${enc(changeSet)}`, void 0, signal),
|
|
409
534
|
update: (project, changeSet, body) => this.mutate("PATCH", `/projects/${enc(project)}/change-sets/${enc(changeSet)}`, body),
|
|
410
535
|
validate: (project, changeSet) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/validate`),
|
|
411
536
|
publish: (project, changeSet) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/publish`, { confirm: true }),
|
|
412
|
-
close: (project, changeSet) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/close`)
|
|
537
|
+
close: (project, changeSet) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/close`),
|
|
538
|
+
// Scheduled publishing
|
|
539
|
+
schedule: (project, changeSet, publishAt) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/schedule`, {
|
|
540
|
+
publishAt
|
|
541
|
+
}),
|
|
542
|
+
cancelSchedule: (project, changeSet) => this.mutate("DELETE", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/schedule`),
|
|
543
|
+
// Reviews & approvals
|
|
544
|
+
reviews: (project, changeSet, signal) => this.get(
|
|
545
|
+
`/projects/${enc(project)}/change-sets/${enc(changeSet)}/reviews`,
|
|
546
|
+
void 0,
|
|
547
|
+
signal
|
|
548
|
+
),
|
|
549
|
+
requestReview: (project, changeSet, body) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/reviews`, body),
|
|
550
|
+
removeReviewer: (project, changeSet, reviewer) => this.mutate(
|
|
551
|
+
"DELETE",
|
|
552
|
+
`/projects/${enc(project)}/change-sets/${enc(changeSet)}/reviews/${enc(reviewer)}`
|
|
553
|
+
),
|
|
554
|
+
approve: (project, changeSet, body = {}) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/approve`, body),
|
|
555
|
+
requestChanges: (project, changeSet, body = {}) => this.mutate(
|
|
556
|
+
"POST",
|
|
557
|
+
`/projects/${enc(project)}/change-sets/${enc(changeSet)}/request-changes`,
|
|
558
|
+
body
|
|
559
|
+
),
|
|
560
|
+
// Comments
|
|
561
|
+
comments: (project, changeSet, signal) => this.get(
|
|
562
|
+
`/projects/${enc(project)}/change-sets/${enc(changeSet)}/comments`,
|
|
563
|
+
void 0,
|
|
564
|
+
signal
|
|
565
|
+
),
|
|
566
|
+
comment: (project, changeSet, body) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/comments`, body),
|
|
567
|
+
resolveComment: (project, changeSet, comment, resolved = true) => this.mutate(
|
|
568
|
+
"PATCH",
|
|
569
|
+
`/projects/${enc(project)}/change-sets/${enc(changeSet)}/comments/${enc(comment)}`,
|
|
570
|
+
{ resolved }
|
|
571
|
+
),
|
|
572
|
+
// Automated checks
|
|
573
|
+
checks: (project, changeSet, signal) => this.get(
|
|
574
|
+
`/projects/${enc(project)}/change-sets/${enc(changeSet)}/checks`,
|
|
575
|
+
void 0,
|
|
576
|
+
signal
|
|
577
|
+
),
|
|
578
|
+
runChecks: (project, changeSet) => this.mutate("POST", `/projects/${enc(project)}/change-sets/${enc(changeSet)}/checks/run`)
|
|
413
579
|
};
|
|
414
580
|
// --- Previews -------------------------------------------------------------
|
|
415
581
|
previews = {
|
|
@@ -427,6 +593,7 @@ var ManagementClient = class {
|
|
|
427
593
|
get: (project, asset, signal) => this.get(`/projects/${enc(project)}/assets/${enc(asset)}`, void 0, signal),
|
|
428
594
|
usage: (project, asset, signal) => this.get(`/projects/${enc(project)}/assets/${enc(asset)}`, { usage: "true" }, signal),
|
|
429
595
|
update: (project, asset, body) => this.mutate("PATCH", `/projects/${enc(project)}/assets/${enc(asset)}`, body),
|
|
596
|
+
replace: (project, asset, body) => this.mutate("POST", `/projects/${enc(project)}/assets/${enc(asset)}/replace`, body),
|
|
430
597
|
delete: (project, asset) => this.mutate("DELETE", `/projects/${enc(project)}/assets/${enc(asset)}`),
|
|
431
598
|
/** Full presigned upload flow: create → PUT bytes → complete. */
|
|
432
599
|
upload: (project, input, meta = {}) => this.uploadAsset(project, input, meta)
|
|
@@ -451,16 +618,13 @@ var ManagementClient = class {
|
|
|
451
618
|
};
|
|
452
619
|
// --- Activity -------------------------------------------------------------
|
|
453
620
|
activity(project, opts = {}) {
|
|
454
|
-
return this.page(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}),
|
|
462
|
-
opts
|
|
463
|
-
);
|
|
621
|
+
return this.page(`/projects/${enc(project)}/activity`, opts, {
|
|
622
|
+
actorType: opts.actorType,
|
|
623
|
+
action: opts.action,
|
|
624
|
+
targetType: opts.targetType,
|
|
625
|
+
from: opts.from,
|
|
626
|
+
to: opts.to
|
|
627
|
+
});
|
|
464
628
|
}
|
|
465
629
|
// --- Billing --------------------------------------------------------------
|
|
466
630
|
billing = {
|
|
@@ -522,15 +686,80 @@ function guessContentType(filename) {
|
|
|
522
686
|
function enc(segment) {
|
|
523
687
|
return encodeURIComponent(segment);
|
|
524
688
|
}
|
|
525
|
-
function query(params) {
|
|
526
|
-
const entries = Object.entries(params).filter(([, v]) => v !== void 0 && v !== "");
|
|
527
|
-
if (entries.length === 0) return "";
|
|
528
|
-
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
|
529
|
-
}
|
|
530
689
|
function createManagementClient(options) {
|
|
531
690
|
return new ManagementClient(options);
|
|
532
691
|
}
|
|
533
692
|
|
|
693
|
+
// src/auth-check.ts
|
|
694
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
695
|
+
function timeoutSignal(ms) {
|
|
696
|
+
return AbortSignal.timeout(ms);
|
|
697
|
+
}
|
|
698
|
+
async function fetchMeta(apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
699
|
+
try {
|
|
700
|
+
const res = await fetch(`${apiUrl}/v1/meta`, {
|
|
701
|
+
headers: { accept: "application/json" },
|
|
702
|
+
signal: timeoutSignal(timeoutMs)
|
|
703
|
+
});
|
|
704
|
+
if (!res.ok) return void 0;
|
|
705
|
+
const body = await res.json();
|
|
706
|
+
return body.data;
|
|
707
|
+
} catch {
|
|
708
|
+
return void 0;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async function checkCredential(apiUrl, token, source, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
712
|
+
const base = {
|
|
713
|
+
apiUrl,
|
|
714
|
+
credentialPresent: Boolean(token),
|
|
715
|
+
credentialValid: false,
|
|
716
|
+
source,
|
|
717
|
+
identity: null,
|
|
718
|
+
error: null
|
|
719
|
+
};
|
|
720
|
+
if (!token) {
|
|
721
|
+
return { ...base, error: { code: "NO_CREDENTIAL", detail: "No credential found." } };
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
const res = await fetch(`${apiUrl}/v1/auth/whoami`, {
|
|
725
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
726
|
+
signal: timeoutSignal(timeoutMs)
|
|
727
|
+
});
|
|
728
|
+
const body = await res.json().catch(() => ({}));
|
|
729
|
+
if (!res.ok || !body.data) {
|
|
730
|
+
return {
|
|
731
|
+
...base,
|
|
732
|
+
error: {
|
|
733
|
+
code: body.code ?? `HTTP_${res.status}`,
|
|
734
|
+
detail: body.detail ?? body.title ?? `The API rejected the credential (${res.status}).`
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return { ...base, credentialValid: true, identity: body.data };
|
|
739
|
+
} catch (error) {
|
|
740
|
+
return {
|
|
741
|
+
...base,
|
|
742
|
+
error: {
|
|
743
|
+
code: "UNREACHABLE",
|
|
744
|
+
detail: `Could not reach ${apiUrl}: ${error instanceof Error ? error.message : String(error)}`
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function describeIdentity(identity) {
|
|
750
|
+
switch (identity.credential) {
|
|
751
|
+
case "user":
|
|
752
|
+
return `user ${identity.user.username ?? identity.user.email}`;
|
|
753
|
+
case "api_key":
|
|
754
|
+
return `API key${identity.label ? ` "${identity.label}"` : ""} (${identity.scopes.length} scope(s))`;
|
|
755
|
+
case "preview":
|
|
756
|
+
return "preview token";
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function scopesOf(identity) {
|
|
760
|
+
return identity?.credential === "api_key" ? identity.scopes : void 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
534
763
|
// src/config.ts
|
|
535
764
|
import { execFileSync } from "child_process";
|
|
536
765
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "fs";
|
|
@@ -666,12 +895,15 @@ function resolveContext(flags) {
|
|
|
666
895
|
const link = findLinkedProject();
|
|
667
896
|
const user = readUserConfig();
|
|
668
897
|
const apiUrl = flags.apiUrl ?? process.env.MYNA_API_URL ?? link?.linked.apiUrl ?? user.apiUrl ?? DEFAULT_API_URL2;
|
|
669
|
-
const
|
|
898
|
+
const stored = loadToken(apiUrl);
|
|
899
|
+
const token = flags.token ?? process.env.MYNA_TOKEN ?? stored;
|
|
900
|
+
const tokenSource = flags.token ? "flag" : process.env.MYNA_TOKEN ? "environment" : stored ? "stored" : "none";
|
|
670
901
|
const project = flags.project ?? process.env.MYNA_PROJECT ?? link?.linked.project ?? user.defaultProject;
|
|
671
902
|
const organization = flags.organization ?? process.env.MYNA_ORGANIZATION ?? link?.linked.organization ?? user.defaultOrganization;
|
|
672
903
|
return {
|
|
673
904
|
apiUrl: apiUrl.replace(/\/+$/, ""),
|
|
674
905
|
token,
|
|
906
|
+
tokenSource,
|
|
675
907
|
project,
|
|
676
908
|
organization,
|
|
677
909
|
json: Boolean(flags.json),
|
|
@@ -899,10 +1131,16 @@ async function apiPost(apiUrl, path, body) {
|
|
|
899
1131
|
return json.data;
|
|
900
1132
|
}
|
|
901
1133
|
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1134
|
+
function startDevice(apiUrl) {
|
|
1135
|
+
return apiPost(apiUrl, "/auth/device", {});
|
|
1136
|
+
}
|
|
1137
|
+
function pollDevice(apiUrl, deviceCode) {
|
|
1138
|
+
return apiPost(apiUrl, `/auth/device/${deviceCode}/token`, { deviceCode });
|
|
1139
|
+
}
|
|
902
1140
|
function registerAuth(program) {
|
|
903
|
-
program.command("login").description("Authenticate via the browser device-authorization flow").action(
|
|
1141
|
+
const login = program.command("login").description("Authenticate via the browser device-authorization flow").action(
|
|
904
1142
|
handle(async (ctx) => {
|
|
905
|
-
const start = await
|
|
1143
|
+
const start = await startDevice(ctx.apiUrl);
|
|
906
1144
|
diag(`
|
|
907
1145
|
To authenticate, open:
|
|
908
1146
|
${start.verificationUri}
|
|
@@ -913,9 +1151,7 @@ function registerAuth(program) {
|
|
|
913
1151
|
let token;
|
|
914
1152
|
while (Date.now() < deadline) {
|
|
915
1153
|
await sleep2(Math.max(1, start.interval) * 1e3);
|
|
916
|
-
const poll = await
|
|
917
|
-
deviceCode: start.deviceCode
|
|
918
|
-
});
|
|
1154
|
+
const poll = await pollDevice(ctx.apiUrl, start.deviceCode);
|
|
919
1155
|
if (poll.status === "approved" && poll.token) {
|
|
920
1156
|
token = poll.token;
|
|
921
1157
|
break;
|
|
@@ -934,6 +1170,72 @@ function registerAuth(program) {
|
|
|
934
1170
|
);
|
|
935
1171
|
})
|
|
936
1172
|
);
|
|
1173
|
+
login.command("start").description("Begin device authorization and print the code without waiting").action(
|
|
1174
|
+
handle(async (ctx) => {
|
|
1175
|
+
const start = await startDevice(ctx.apiUrl);
|
|
1176
|
+
emit(
|
|
1177
|
+
{
|
|
1178
|
+
deviceCode: start.deviceCode,
|
|
1179
|
+
userCode: start.userCode,
|
|
1180
|
+
verificationUri: start.verificationUri,
|
|
1181
|
+
expiresIn: start.expiresIn,
|
|
1182
|
+
interval: start.interval,
|
|
1183
|
+
apiUrl: ctx.apiUrl
|
|
1184
|
+
},
|
|
1185
|
+
() => {
|
|
1186
|
+
process.stdout.write(`${start.verificationUri}
|
|
1187
|
+
`);
|
|
1188
|
+
diag(` Enter the code: ${start.userCode}`);
|
|
1189
|
+
diag(` Then run: myna login poll ${start.deviceCode}`);
|
|
1190
|
+
}
|
|
1191
|
+
);
|
|
1192
|
+
})
|
|
1193
|
+
);
|
|
1194
|
+
login.command("poll").description("Check a pending device authorization once, or wait for it").argument("<device-code>", "device code from `myna login start`").option("--wait", "keep polling until approved, denied, or expired", false).option("--interval <seconds>", "seconds between attempts when waiting", "2").option("--timeout <seconds>", "give up after this long when waiting", "300").action(
|
|
1195
|
+
handle(async (ctx, args, opts) => {
|
|
1196
|
+
const deviceCode = args[0];
|
|
1197
|
+
const wait = Boolean(opts.wait);
|
|
1198
|
+
const interval = Math.max(1, Number(opts.interval)) * 1e3;
|
|
1199
|
+
const deadline = Date.now() + Math.max(1, Number(opts.timeout)) * 1e3;
|
|
1200
|
+
for (; ; ) {
|
|
1201
|
+
const poll = await pollDevice(ctx.apiUrl, deviceCode);
|
|
1202
|
+
if (poll.status === "approved" && poll.token) {
|
|
1203
|
+
const location = storeToken(ctx.apiUrl, poll.token);
|
|
1204
|
+
emit(
|
|
1205
|
+
{ status: "approved", stored: true, storage: location, apiUrl: ctx.apiUrl },
|
|
1206
|
+
() => process.stdout.write(`Logged in to ${ctx.apiUrl} (stored in ${location})
|
|
1207
|
+
`)
|
|
1208
|
+
);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (poll.status === "denied" || poll.status === "expired") {
|
|
1212
|
+
emit(
|
|
1213
|
+
{ status: poll.status, stored: false, apiUrl: ctx.apiUrl },
|
|
1214
|
+
() => process.stderr.write(`Authorization ${poll.status}.
|
|
1215
|
+
`)
|
|
1216
|
+
);
|
|
1217
|
+
process.exitCode = 1;
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (!wait) {
|
|
1221
|
+
emit(
|
|
1222
|
+
{ status: "pending", stored: false, apiUrl: ctx.apiUrl },
|
|
1223
|
+
() => process.stdout.write("pending\n")
|
|
1224
|
+
);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (Date.now() + interval > deadline) {
|
|
1228
|
+
emit(
|
|
1229
|
+
{ status: "timeout", stored: false, apiUrl: ctx.apiUrl },
|
|
1230
|
+
() => process.stderr.write("Timed out waiting for approval.\n")
|
|
1231
|
+
);
|
|
1232
|
+
process.exitCode = 1;
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
await sleep2(interval);
|
|
1236
|
+
}
|
|
1237
|
+
})
|
|
1238
|
+
);
|
|
937
1239
|
program.command("logout").description("Remove the stored credential for the current API URL").action(
|
|
938
1240
|
handle(async (ctx) => {
|
|
939
1241
|
clearToken(ctx.apiUrl);
|
|
@@ -941,35 +1243,53 @@ function registerAuth(program) {
|
|
|
941
1243
|
`));
|
|
942
1244
|
})
|
|
943
1245
|
);
|
|
944
|
-
program.command("
|
|
1246
|
+
const auth = program.command("auth").description("Inspect and verify credentials");
|
|
1247
|
+
auth.command("verify").description("Check whether the current credential actually works").action(
|
|
945
1248
|
handle(async (ctx) => {
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1249
|
+
const status = await checkCredential(ctx.apiUrl, ctx.token, ctx.tokenSource);
|
|
1250
|
+
emit(status, () => {
|
|
1251
|
+
keyValues([
|
|
1252
|
+
["API", status.apiUrl],
|
|
1253
|
+
["Credential present", status.credentialPresent ? "yes" : "no"],
|
|
1254
|
+
["Credential valid", status.credentialValid ? "yes" : "no"],
|
|
1255
|
+
["Source", status.source],
|
|
1256
|
+
["Identity", status.identity ? describeIdentity(status.identity) : "\u2014"]
|
|
1257
|
+
]);
|
|
1258
|
+
if (status.error) process.stderr.write(`
|
|
1259
|
+
${status.error.code}: ${status.error.detail}
|
|
1260
|
+
`);
|
|
950
1261
|
});
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const
|
|
1262
|
+
})
|
|
1263
|
+
);
|
|
1264
|
+
program.command("whoami").description("Show the authenticated identity, validating it against the API").action(
|
|
1265
|
+
handle(async (ctx) => {
|
|
1266
|
+
const status = await checkCredential(ctx.apiUrl, ctx.token ?? loadToken(ctx.apiUrl), ctx.tokenSource);
|
|
956
1267
|
let organization;
|
|
957
|
-
if (ctx.organization) {
|
|
1268
|
+
if (status.credentialValid && ctx.organization) {
|
|
958
1269
|
organization = await ctx.management().organizations.get(ctx.organization).then((o) => ({ name: o.name, role: o.role, slug: o.slug })).catch((e) => {
|
|
959
1270
|
if (isMynaApiError(e)) return void 0;
|
|
960
1271
|
throw e;
|
|
961
1272
|
});
|
|
962
1273
|
}
|
|
963
|
-
emit({
|
|
964
|
-
const pairs = [
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1274
|
+
emit({ ...status, organization: organization ?? null }, () => {
|
|
1275
|
+
const pairs = [
|
|
1276
|
+
["API", status.apiUrl],
|
|
1277
|
+
["Credential present", status.credentialPresent ? "yes" : "no"],
|
|
1278
|
+
["Credential valid", status.credentialValid ? "yes" : "no"]
|
|
1279
|
+
];
|
|
1280
|
+
if (status.identity) pairs.push(["Identity", describeIdentity(status.identity)]);
|
|
970
1281
|
if (organization) pairs.push(["Organization", `${organization.name} (${organization.role})`]);
|
|
971
1282
|
keyValues(pairs);
|
|
1283
|
+
if (status.error) {
|
|
1284
|
+
process.stderr.write(`
|
|
1285
|
+
${status.error.detail}
|
|
1286
|
+
`);
|
|
1287
|
+
if (status.error.code !== "UNREACHABLE") {
|
|
1288
|
+
process.stderr.write("Run `myna login` to authenticate again.\n");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
972
1291
|
});
|
|
1292
|
+
if (!status.credentialValid) process.exitCode = 1;
|
|
973
1293
|
})
|
|
974
1294
|
);
|
|
975
1295
|
}
|
|
@@ -1070,22 +1390,26 @@ function registerWorkspace(program) {
|
|
|
1070
1390
|
);
|
|
1071
1391
|
program.command("status").description("Show the resolved configuration and project link").action(
|
|
1072
1392
|
handle(async (ctx) => {
|
|
1073
|
-
const
|
|
1393
|
+
const credentialPresent = Boolean(ctx.token ?? loadToken(ctx.apiUrl));
|
|
1074
1394
|
emit(
|
|
1075
1395
|
{
|
|
1076
1396
|
apiUrl: ctx.apiUrl,
|
|
1077
|
-
|
|
1397
|
+
credentialPresent,
|
|
1398
|
+
credentialSource: ctx.tokenSource,
|
|
1078
1399
|
project: ctx.project ?? null,
|
|
1079
1400
|
organization: ctx.organization ?? null,
|
|
1080
1401
|
linkedRoot: ctx.linkedRoot ?? null
|
|
1081
1402
|
},
|
|
1082
|
-
() =>
|
|
1083
|
-
[
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1403
|
+
() => {
|
|
1404
|
+
keyValues([
|
|
1405
|
+
["API URL", ctx.apiUrl],
|
|
1406
|
+
["Credential present", credentialPresent ? `yes (${ctx.tokenSource})` : "no"],
|
|
1407
|
+
["Project", ctx.project ?? "(none)"],
|
|
1408
|
+
["Organization", ctx.organization ?? "(none)"],
|
|
1409
|
+
["Linked at", ctx.linkedRoot ?? "(none)"]
|
|
1410
|
+
]);
|
|
1411
|
+
if (credentialPresent) diag("\nNot verified against the API. Run `myna auth verify`.");
|
|
1412
|
+
}
|
|
1089
1413
|
);
|
|
1090
1414
|
})
|
|
1091
1415
|
);
|
|
@@ -1407,6 +1731,35 @@ function registerSchema(program) {
|
|
|
1407
1731
|
emit(result, () => renderDiff(result));
|
|
1408
1732
|
})
|
|
1409
1733
|
);
|
|
1734
|
+
schema.command("drift").description("Show schema drift between this project and another (e.g. staging)").requiredOption("--against <project>", "project id or slug to compare with").action(
|
|
1735
|
+
handle(async (ctx, _args, opts) => {
|
|
1736
|
+
const project = ctx.requireProject();
|
|
1737
|
+
const result = await ctx.management().schema.drift(project, opts.against);
|
|
1738
|
+
emit(result, () => {
|
|
1739
|
+
if (result.inSync) diag("Schemas are in sync.");
|
|
1740
|
+
else {
|
|
1741
|
+
diag(`${result.ops.length} op(s) of drift (${result.classification}):`);
|
|
1742
|
+
for (const op of result.ops) {
|
|
1743
|
+
process.stderr.write(` ${op.kind} ${op.collection}${op.field ? `.${op.field}` : ""} \u2014 ${op.detail}
|
|
1744
|
+
`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
if (!result.inSync) process.exitCode = 1;
|
|
1749
|
+
})
|
|
1750
|
+
);
|
|
1751
|
+
schema.command("promote").description("Promote another project's deployed schemas into this project").requiredOption("--from <project>", "source project id or slug").option("--allow-destructive", "permit destructive schema changes", false).action(
|
|
1752
|
+
handle(async (ctx, _args, opts) => {
|
|
1753
|
+
const project = ctx.requireProject();
|
|
1754
|
+
const result = await ctx.management().schema.promote(project, opts.from, {
|
|
1755
|
+
allowDestructive: Boolean(opts.allowDestructive)
|
|
1756
|
+
});
|
|
1757
|
+
emit(result, () => {
|
|
1758
|
+
renderDiff(result.diff);
|
|
1759
|
+
diag(result.applied ? `Promoted ${result.versions.length} version(s).` : "Nothing to promote.");
|
|
1760
|
+
});
|
|
1761
|
+
})
|
|
1762
|
+
);
|
|
1410
1763
|
schema.command("push").description("Apply local schemas, creating immutable versions").option("--schema-dir <dir>", "schema directory").option("--allow-destructive", "permit destructive schema changes", false).option("--summary <text>", "change summary").action(
|
|
1411
1764
|
handle(async (ctx, _args, opts) => {
|
|
1412
1765
|
const project = ctx.requireProject();
|
|
@@ -1479,7 +1832,114 @@ function renderDiff(diff) {
|
|
|
1479
1832
|
}
|
|
1480
1833
|
}
|
|
1481
1834
|
|
|
1835
|
+
// src/content-files.ts
|
|
1836
|
+
import { readFile as readFile2, readdir, stat } from "fs/promises";
|
|
1837
|
+
import { basename as basename2, extname, join as join5 } from "path";
|
|
1838
|
+
async function detectFormat(path) {
|
|
1839
|
+
const info = await stat(path).catch(() => void 0);
|
|
1840
|
+
if (!info) throw new UsageError(`No such file or directory: ${path}`);
|
|
1841
|
+
if (info.isDirectory()) return "markdown";
|
|
1842
|
+
return extname(path).toLowerCase() === ".json" ? "json" : "markdown";
|
|
1843
|
+
}
|
|
1844
|
+
function parseFrontmatter(raw, source) {
|
|
1845
|
+
if (!raw.startsWith("---")) return { meta: {}, body: raw };
|
|
1846
|
+
const end = raw.indexOf("\n---", 3);
|
|
1847
|
+
if (end === -1) throw new UsageError(`${source}: unterminated frontmatter block.`);
|
|
1848
|
+
const block = raw.slice(raw.indexOf("\n") + 1, end);
|
|
1849
|
+
const body = raw.slice(raw.indexOf("\n", end + 1) + 1);
|
|
1850
|
+
const meta = {};
|
|
1851
|
+
for (const [lineNumber, line] of block.split("\n").entries()) {
|
|
1852
|
+
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
|
1853
|
+
const separator = line.indexOf(":");
|
|
1854
|
+
if (separator === -1) {
|
|
1855
|
+
throw new UsageError(`${source}:${lineNumber + 2}: expected "key: value" in frontmatter.`);
|
|
1856
|
+
}
|
|
1857
|
+
const key = line.slice(0, separator).trim();
|
|
1858
|
+
meta[key] = parseScalar(line.slice(separator + 1).trim());
|
|
1859
|
+
}
|
|
1860
|
+
return { meta, body };
|
|
1861
|
+
}
|
|
1862
|
+
function parseScalar(value) {
|
|
1863
|
+
if (value === "" || value === "null" || value === "~") return null;
|
|
1864
|
+
if (value === "true") return true;
|
|
1865
|
+
if (value === "false") return false;
|
|
1866
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
1867
|
+
const inner = value.slice(1, -1).trim();
|
|
1868
|
+
return inner === "" ? [] : inner.split(",").map((item) => parseScalar(item.trim()));
|
|
1869
|
+
}
|
|
1870
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
1871
|
+
return value.slice(1, -1);
|
|
1872
|
+
}
|
|
1873
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
1874
|
+
return value;
|
|
1875
|
+
}
|
|
1876
|
+
async function readJsonRows(file) {
|
|
1877
|
+
let parsed;
|
|
1878
|
+
try {
|
|
1879
|
+
parsed = JSON.parse(await readFile2(file, "utf8"));
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
throw new UsageError(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1882
|
+
}
|
|
1883
|
+
const toRow = (value, index) => {
|
|
1884
|
+
if (typeof value !== "object" || value === null) {
|
|
1885
|
+
throw new UsageError(`${file}: entry ${index} is not an object.`);
|
|
1886
|
+
}
|
|
1887
|
+
const record = value;
|
|
1888
|
+
if (record.data && typeof record.data === "object") {
|
|
1889
|
+
return {
|
|
1890
|
+
slug: typeof record.slug === "string" ? record.slug : void 0,
|
|
1891
|
+
data: record.data,
|
|
1892
|
+
source: `${file}#${index}`
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
slug: typeof record.slug === "string" ? record.slug : void 0,
|
|
1897
|
+
data: record,
|
|
1898
|
+
source: `${file}#${index}`
|
|
1899
|
+
};
|
|
1900
|
+
};
|
|
1901
|
+
if (Array.isArray(parsed)) {
|
|
1902
|
+
return { rows: parsed.map(toRow) };
|
|
1903
|
+
}
|
|
1904
|
+
const doc = parsed;
|
|
1905
|
+
if (!Array.isArray(doc.entries)) {
|
|
1906
|
+
throw new UsageError(`${file}: expected { "collection": "...", "entries": [...] } or a JSON array.`);
|
|
1907
|
+
}
|
|
1908
|
+
return {
|
|
1909
|
+
collection: typeof doc.collection === "string" ? doc.collection : void 0,
|
|
1910
|
+
rows: doc.entries.map(toRow)
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
async function readMarkdownRows(dir, bodyField) {
|
|
1914
|
+
const info = await stat(dir).catch(() => void 0);
|
|
1915
|
+
const files = info?.isDirectory() ? (await readdir(dir)).filter((name) => [".md", ".markdown"].includes(extname(name).toLowerCase())).sort().map((name) => join5(dir, name)) : [dir];
|
|
1916
|
+
if (files.length === 0) throw new UsageError(`No .md files found in ${dir}`);
|
|
1917
|
+
const rows = [];
|
|
1918
|
+
for (const file of files) {
|
|
1919
|
+
const raw = await readFile2(file, "utf8");
|
|
1920
|
+
const { meta, body } = parseFrontmatter(raw, file);
|
|
1921
|
+
const slug = typeof meta.slug === "string" ? meta.slug : basename2(file, extname(file));
|
|
1922
|
+
rows.push({
|
|
1923
|
+
slug,
|
|
1924
|
+
data: { ...meta, slug, [bodyField]: body.trim() },
|
|
1925
|
+
source: file
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
return rows;
|
|
1929
|
+
}
|
|
1930
|
+
async function readContentRows(path, opts) {
|
|
1931
|
+
const format = opts.format ?? await detectFormat(path);
|
|
1932
|
+
if (format === "json") return readJsonRows(path);
|
|
1933
|
+
return { rows: await readMarkdownRows(path, opts.bodyField) };
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1482
1936
|
// src/commands/entries.ts
|
|
1937
|
+
function parseFormat(value) {
|
|
1938
|
+
if (!value) return void 0;
|
|
1939
|
+
if (value === "json") return "json";
|
|
1940
|
+
if (value === "markdown-frontmatter" || value === "markdown" || value === "md") return "markdown";
|
|
1941
|
+
throw new UsageError("--format must be json or markdown-frontmatter.");
|
|
1942
|
+
}
|
|
1483
1943
|
function registerEntries(program) {
|
|
1484
1944
|
const entries = program.command("entries").description("Create, read, update, and manage entries");
|
|
1485
1945
|
entries.command("list").description("List entries in a collection").argument("<collection>", "collection key").option("--status <status>", "filter by status").option("--limit <n>", "page size", "25").option("--cursor <cursor>", "pagination cursor").action(
|
|
@@ -1578,6 +2038,130 @@ function registerEntries(program) {
|
|
|
1578
2038
|
);
|
|
1579
2039
|
})
|
|
1580
2040
|
);
|
|
2041
|
+
entries.command("duplicate").description("Duplicate an entry into a new draft").argument("<ref>", "collection/slug or entry id").option("--change-set <id>", "stage on an explicit change set").action(
|
|
2042
|
+
handle(async (ctx, args, opts) => {
|
|
2043
|
+
const project = ctx.requireProject();
|
|
2044
|
+
const id = await resolveEntryId(ctx.management(), project, args[0]);
|
|
2045
|
+
const entry = await ctx.management().entries.duplicate(project, id, opts.changeSet);
|
|
2046
|
+
emit(entry, () => diag(`Duplicated into ${entry.id} (${entry.slug ?? "no slug"}).`));
|
|
2047
|
+
})
|
|
2048
|
+
);
|
|
2049
|
+
entries.command("bulk").description("Stage a delete or unpublish for many entries at once").requiredOption("--action <action>", "delete or unpublish").requiredOption("--ids <ids>", "comma-separated entry ids").option("--change-set <id>", "stage on an explicit change set").action(
|
|
2050
|
+
handle(async (ctx, _args, opts) => {
|
|
2051
|
+
const action = opts.action;
|
|
2052
|
+
if (action !== "delete" && action !== "unpublish") {
|
|
2053
|
+
throw new UsageError("--action must be delete or unpublish.");
|
|
2054
|
+
}
|
|
2055
|
+
const project = ctx.requireProject();
|
|
2056
|
+
const result = await ctx.management().entries.bulk(project, {
|
|
2057
|
+
action,
|
|
2058
|
+
entryIds: opts.ids.split(",").map((s) => s.trim()).filter(Boolean),
|
|
2059
|
+
changeSetId: opts.changeSet
|
|
2060
|
+
});
|
|
2061
|
+
emit(
|
|
2062
|
+
result,
|
|
2063
|
+
() => table(result.results, [
|
|
2064
|
+
{ header: "ENTRY", value: (r) => r.entryId },
|
|
2065
|
+
{ header: "OK", value: (r) => r.ok ? "yes" : "no" },
|
|
2066
|
+
{ header: "CHANGE SET", value: (r) => r.changeSetId ?? "\u2014" },
|
|
2067
|
+
{ header: "ERROR", value: (r) => r.error ?? "" }
|
|
2068
|
+
])
|
|
2069
|
+
);
|
|
2070
|
+
if (result.results.some((r) => !r.ok)) process.exitCode = 1;
|
|
2071
|
+
})
|
|
2072
|
+
);
|
|
2073
|
+
entries.command("import").description("Import entries from JSON or a Markdown directory, with dry-run validation").argument("<path>", "JSON file, or a directory of Markdown files with frontmatter").option("--collection <key>", "target collection (required unless the JSON file names one)").option("--format <format>", "json or markdown-frontmatter (detected from the path by default)").option("--body-field <key>", "field that receives the Markdown body", "body").option("--change-set <id>", "stage onto an existing change set instead of a new one").option("--mode <mode>", "upsert (default) updates rows whose slug exists; create rejects them", "upsert").option("--dry-run", "validate without writing anything", false).action(
|
|
2074
|
+
handle(async (ctx, args, opts) => {
|
|
2075
|
+
const project = ctx.requireProject();
|
|
2076
|
+
const format = parseFormat(opts.format);
|
|
2077
|
+
const mode2 = opts.mode;
|
|
2078
|
+
if (mode2 !== "upsert" && mode2 !== "create") {
|
|
2079
|
+
throw new UsageError("--mode must be upsert or create.");
|
|
2080
|
+
}
|
|
2081
|
+
const source = await readContentRows(args[0], {
|
|
2082
|
+
format,
|
|
2083
|
+
bodyField: opts.bodyField
|
|
2084
|
+
});
|
|
2085
|
+
const collection = opts.collection ?? source.collection;
|
|
2086
|
+
if (!collection) {
|
|
2087
|
+
throw new UsageError("No collection. Pass --collection, or use a JSON file with a `collection` key.");
|
|
2088
|
+
}
|
|
2089
|
+
if (source.rows.length === 0) throw new UsageError("Nothing to import.");
|
|
2090
|
+
const result = await ctx.management().entries.import(project, {
|
|
2091
|
+
collection,
|
|
2092
|
+
entries: source.rows.map((r) => r.slug ? { slug: r.slug, data: r.data } : { data: r.data }),
|
|
2093
|
+
dryRun: Boolean(opts.dryRun),
|
|
2094
|
+
changeSetId: opts.changeSet,
|
|
2095
|
+
mode: mode2
|
|
2096
|
+
});
|
|
2097
|
+
emit(result, () => {
|
|
2098
|
+
const s = result.summary;
|
|
2099
|
+
const counts = `${s.created} created, ${s.updated} updated, ${s.unchanged} unchanged, ${s.invalid} invalid, ${s.skipped} skipped`;
|
|
2100
|
+
diag(
|
|
2101
|
+
result.dryRun ? `Dry run (${result.valid ? "would apply" : "blocked"}): ${counts}.` : `Imported into change set ${result.changeSetId}: ${counts}.`
|
|
2102
|
+
);
|
|
2103
|
+
for (const row of result.results.filter((r) => r.outcome === "invalid")) {
|
|
2104
|
+
const where = source.rows[row.index]?.source ?? `row ${row.index}`;
|
|
2105
|
+
for (const e of row.errors ?? []) {
|
|
2106
|
+
process.stderr.write(` ${where}: ${e.path || "(entry)"} ${e.message}
|
|
2107
|
+
`);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
if (!result.valid) process.exitCode = 1;
|
|
2112
|
+
})
|
|
2113
|
+
);
|
|
2114
|
+
entries.command("reorder").description("Set a collection's editorial order (applies immediately; not staged)").argument("<collection>", "collection key").option("--slugs <list>", "comma-separated slugs or ids, in the desired order").option("--move <ref>", "reposition a single entry").option("--before <ref>", "with --move: place immediately before this entry").option("--after <ref>", "with --move: place immediately after this entry").action(
|
|
2115
|
+
handle(async (ctx, args, opts) => {
|
|
2116
|
+
const project = ctx.requireProject();
|
|
2117
|
+
const collection = args[0];
|
|
2118
|
+
const slugs = opts.slugs;
|
|
2119
|
+
const move = opts.move;
|
|
2120
|
+
if (Boolean(slugs) === Boolean(move)) {
|
|
2121
|
+
throw new UsageError("Provide either --slugs or --move.");
|
|
2122
|
+
}
|
|
2123
|
+
if (!move && (opts.before || opts.after)) {
|
|
2124
|
+
throw new UsageError("--before and --after only apply with --move.");
|
|
2125
|
+
}
|
|
2126
|
+
const body = slugs ? { order: slugs.split(",").map((s) => s.trim()).filter(Boolean) } : {
|
|
2127
|
+
move: {
|
|
2128
|
+
entry: move,
|
|
2129
|
+
...opts.before ? { before: opts.before } : {},
|
|
2130
|
+
...opts.after ? { after: opts.after } : {}
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
const result = await ctx.management().entries.reorder(project, collection, body);
|
|
2134
|
+
emit(result, () => {
|
|
2135
|
+
diag(
|
|
2136
|
+
`Reordered ${result.collection}: ${result.writes} row(s) written. Ordering is not staged \u2014 this is live for readers now.`
|
|
2137
|
+
);
|
|
2138
|
+
table(result.updated, [
|
|
2139
|
+
{ header: "ENTRY", value: (r) => r.slug ?? r.id },
|
|
2140
|
+
{ header: "RANK", value: (r) => r.rank }
|
|
2141
|
+
]);
|
|
2142
|
+
});
|
|
2143
|
+
})
|
|
2144
|
+
);
|
|
2145
|
+
entries.command("references").description("Show which entries reference this one and what it references").argument("<ref>", "collection/slug or entry id").action(
|
|
2146
|
+
handle(async (ctx, args) => {
|
|
2147
|
+
const project = ctx.requireProject();
|
|
2148
|
+
const id = await resolveEntryId(ctx.management(), project, args[0]);
|
|
2149
|
+
const refs = await ctx.management().entries.references(project, id);
|
|
2150
|
+
emit(refs, () => {
|
|
2151
|
+
diag(`Referenced by ${refs.referencedBy.length} entr(y/ies):`);
|
|
2152
|
+
table(refs.referencedBy, [
|
|
2153
|
+
{ header: "ENTRY", value: (r) => `${r.collectionKey}/${r.slug ?? r.id}` },
|
|
2154
|
+
{ header: "IN DRAFT", value: (r) => r.inDraft ? "yes" : "no" },
|
|
2155
|
+
{ header: "IN PUBLISHED", value: (r) => r.inPublished ? "yes" : "no" }
|
|
2156
|
+
]);
|
|
2157
|
+
diag(`References ${refs.references.length} entr(y/ies):`);
|
|
2158
|
+
table(refs.references, [
|
|
2159
|
+
{ header: "ENTRY", value: (r) => r.collectionKey ? `${r.collectionKey}/${r.slug ?? r.id}` : r.id },
|
|
2160
|
+
{ header: "STATE", value: (r) => !r.exists ? "missing" : r.published ? "published" : "unpublished" }
|
|
2161
|
+
]);
|
|
2162
|
+
});
|
|
2163
|
+
})
|
|
2164
|
+
);
|
|
1581
2165
|
entries.command("restore").description("Restore a historical revision into a new draft").argument("<ref>", "collection/slug or entry id").requiredOption("--revision <id>", "revision id to restore").action(
|
|
1582
2166
|
handle(async (ctx, args, opts) => {
|
|
1583
2167
|
const project = ctx.requireProject();
|
|
@@ -1682,6 +2266,107 @@ function registerChanges(program) {
|
|
|
1682
2266
|
emit(cs, () => diag(`Closed ${cs.id}.`));
|
|
1683
2267
|
})
|
|
1684
2268
|
);
|
|
2269
|
+
changes.command("schedule").description("Schedule, reschedule, or cancel a change set's publish").argument("<id>", "change set id").option("--at <datetime>", "ISO 8601 datetime to publish at").option("--cancel", "cancel the scheduled publish", false).action(
|
|
2270
|
+
handle(async (ctx, args, opts) => {
|
|
2271
|
+
const project = ctx.requireProject();
|
|
2272
|
+
if (Boolean(opts.at) === Boolean(opts.cancel)) {
|
|
2273
|
+
throw new UsageError("Provide exactly one of --at <datetime> or --cancel.");
|
|
2274
|
+
}
|
|
2275
|
+
const client = ctx.management();
|
|
2276
|
+
const cs = opts.cancel ? await client.changeSets.cancelSchedule(project, args[0]) : await client.changeSets.schedule(project, args[0], new Date(opts.at).toISOString());
|
|
2277
|
+
emit(
|
|
2278
|
+
cs,
|
|
2279
|
+
() => diag(cs.scheduledAt ? `Scheduled to publish at ${cs.scheduledAt}.` : "Schedule cancelled.")
|
|
2280
|
+
);
|
|
2281
|
+
})
|
|
2282
|
+
);
|
|
2283
|
+
changes.command("reviews").description("List reviews and approvals for a change set").argument("<id>", "change set id").action(
|
|
2284
|
+
handle(async (ctx, args) => {
|
|
2285
|
+
const project = ctx.requireProject();
|
|
2286
|
+
const reviews = await ctx.management().changeSets.reviews(project, args[0]);
|
|
2287
|
+
emit(
|
|
2288
|
+
reviews,
|
|
2289
|
+
() => table(reviews, [
|
|
2290
|
+
{ header: "REVIEWER", value: (r) => `${r.reviewerType}:${r.reviewerId}` },
|
|
2291
|
+
{ header: "STATUS", value: (r) => r.status },
|
|
2292
|
+
{ header: "STALE", value: (r) => r.stale ? "yes" : "no" },
|
|
2293
|
+
{ header: "DECIDED", value: (r) => r.decidedAt ?? "\u2014" }
|
|
2294
|
+
])
|
|
2295
|
+
);
|
|
2296
|
+
})
|
|
2297
|
+
);
|
|
2298
|
+
changes.command("request-review").description("Assign a reviewer to a change set").argument("<id>", "change set id").requiredOption("--reviewer <id>", "reviewer id (user, api key, or agent id)").option("--reviewer-type <type>", "reviewer actor type", "user").action(
|
|
2299
|
+
handle(async (ctx, args, opts) => {
|
|
2300
|
+
const project = ctx.requireProject();
|
|
2301
|
+
const reviews = await ctx.management().changeSets.requestReview(project, args[0], {
|
|
2302
|
+
reviewerType: opts.reviewerType,
|
|
2303
|
+
reviewerId: opts.reviewer
|
|
2304
|
+
});
|
|
2305
|
+
emit(reviews, () => diag(`Requested review from ${opts.reviewer}.`));
|
|
2306
|
+
})
|
|
2307
|
+
);
|
|
2308
|
+
changes.command("approve").description("Approve a change set").argument("<id>", "change set id").option("--comment <text>", "optional review comment").action(
|
|
2309
|
+
handle(async (ctx, args, opts) => {
|
|
2310
|
+
const project = ctx.requireProject();
|
|
2311
|
+
const reviews = await ctx.management().changeSets.approve(project, args[0], {
|
|
2312
|
+
comment: opts.comment
|
|
2313
|
+
});
|
|
2314
|
+
emit(reviews, () => diag("Approved."));
|
|
2315
|
+
})
|
|
2316
|
+
);
|
|
2317
|
+
changes.command("request-changes").description("Request changes on a change set").argument("<id>", "change set id").option("--comment <text>", "optional review comment").action(
|
|
2318
|
+
handle(async (ctx, args, opts) => {
|
|
2319
|
+
const project = ctx.requireProject();
|
|
2320
|
+
const reviews = await ctx.management().changeSets.requestChanges(project, args[0], {
|
|
2321
|
+
comment: opts.comment
|
|
2322
|
+
});
|
|
2323
|
+
emit(reviews, () => diag("Requested changes."));
|
|
2324
|
+
})
|
|
2325
|
+
);
|
|
2326
|
+
changes.command("comments").description("List comments on a change set").argument("<id>", "change set id").action(
|
|
2327
|
+
handle(async (ctx, args) => {
|
|
2328
|
+
const project = ctx.requireProject();
|
|
2329
|
+
const comments = await ctx.management().changeSets.comments(project, args[0]);
|
|
2330
|
+
emit(
|
|
2331
|
+
comments,
|
|
2332
|
+
() => table(comments, [
|
|
2333
|
+
{ header: "AUTHOR", value: (c) => `${c.authorType}:${c.authorId ?? "\u2014"}` },
|
|
2334
|
+
{ header: "ANCHOR", value: (c) => c.resourceId ? `${c.resourceId}${c.fieldPath ? `#${c.fieldPath}` : ""}` : "\u2014" },
|
|
2335
|
+
{ header: "RESOLVED", value: (c) => c.resolvedAt ? "yes" : "no" },
|
|
2336
|
+
{ header: "BODY", value: (c) => c.body.length > 60 ? `${c.body.slice(0, 57)}...` : c.body }
|
|
2337
|
+
])
|
|
2338
|
+
);
|
|
2339
|
+
})
|
|
2340
|
+
);
|
|
2341
|
+
changes.command("comment").description("Comment on a change set (optionally anchored to a field)").argument("<id>", "change set id").argument("<body>", "comment body").option("--entry <id>", "anchor to an entry in the change set").option("--field <path>", "anchor to a field path, e.g. fields.title").action(
|
|
2342
|
+
handle(async (ctx, args, opts) => {
|
|
2343
|
+
const project = ctx.requireProject();
|
|
2344
|
+
const comment = await ctx.management().changeSets.comment(project, args[0], {
|
|
2345
|
+
body: args[1],
|
|
2346
|
+
resourceType: opts.entry ? "entry" : void 0,
|
|
2347
|
+
resourceId: opts.entry,
|
|
2348
|
+
fieldPath: opts.field
|
|
2349
|
+
});
|
|
2350
|
+
emit(comment, () => diag(`Commented ${comment.id}.`));
|
|
2351
|
+
})
|
|
2352
|
+
);
|
|
2353
|
+
changes.command("checks").description("Show the latest check run for a change set").argument("<id>", "change set id").option("--run", "run checks before showing results", false).action(
|
|
2354
|
+
handle(async (ctx, args, opts) => {
|
|
2355
|
+
const project = ctx.requireProject();
|
|
2356
|
+
const client = ctx.management();
|
|
2357
|
+
const checks = opts.run ? (await client.changeSets.runChecks(project, args[0])).checks : await client.changeSets.checks(project, args[0]);
|
|
2358
|
+
emit(
|
|
2359
|
+
checks,
|
|
2360
|
+
() => table(checks, [
|
|
2361
|
+
{ header: "CHECK", value: (c) => c.name },
|
|
2362
|
+
{ header: "STATUS", value: (c) => c.status },
|
|
2363
|
+
{ header: "STALE", value: (c) => c.stale ? "yes" : "no" },
|
|
2364
|
+
{ header: "ISSUES", value: (c) => String(c.details?.length ?? 0) }
|
|
2365
|
+
])
|
|
2366
|
+
);
|
|
2367
|
+
if (checks.some((c) => c.status === "failed")) process.exitCode = 1;
|
|
2368
|
+
})
|
|
2369
|
+
);
|
|
1685
2370
|
}
|
|
1686
2371
|
|
|
1687
2372
|
// src/commands/previews.ts
|
|
@@ -1805,6 +2490,113 @@ function registerAssets(program) {
|
|
|
1805
2490
|
);
|
|
1806
2491
|
}
|
|
1807
2492
|
|
|
2493
|
+
// ../shared/dist/ids.js
|
|
2494
|
+
var ID_PREFIXES = {
|
|
2495
|
+
user: "usr",
|
|
2496
|
+
oauthAccount: "oau",
|
|
2497
|
+
session: "ses",
|
|
2498
|
+
organization: "org",
|
|
2499
|
+
organizationMember: "mem",
|
|
2500
|
+
organizationInvitation: "inv",
|
|
2501
|
+
project: "prj",
|
|
2502
|
+
projectOrigin: "por",
|
|
2503
|
+
apiKey: "key",
|
|
2504
|
+
collection: "col",
|
|
2505
|
+
collectionVersion: "clv",
|
|
2506
|
+
entry: "ent",
|
|
2507
|
+
entrySlugAlias: "als",
|
|
2508
|
+
entryRevision: "rev",
|
|
2509
|
+
changeSet: "chs",
|
|
2510
|
+
changeSetItem: "csi",
|
|
2511
|
+
changeSetReview: "csr",
|
|
2512
|
+
changeSetComment: "csc",
|
|
2513
|
+
changeSetCheck: "chk",
|
|
2514
|
+
asset: "ast",
|
|
2515
|
+
assetUpload: "upl",
|
|
2516
|
+
savedView: "viw",
|
|
2517
|
+
previewToken: "prv",
|
|
2518
|
+
webhookEndpoint: "whk",
|
|
2519
|
+
webhookEvent: "whe",
|
|
2520
|
+
webhookDelivery: "whd",
|
|
2521
|
+
auditEvent: "aud",
|
|
2522
|
+
job: "job",
|
|
2523
|
+
idempotencyKey: "idm",
|
|
2524
|
+
billingCustomer: "bcu",
|
|
2525
|
+
billingSubscription: "bsu",
|
|
2526
|
+
billingProviderEvent: "evt",
|
|
2527
|
+
usagePeriod: "usp"
|
|
2528
|
+
};
|
|
2529
|
+
var PREFIX_SET = new Set(Object.values(ID_PREFIXES));
|
|
2530
|
+
|
|
2531
|
+
// ../shared/dist/dates.js
|
|
2532
|
+
var HOURS = 60 * 60;
|
|
2533
|
+
var DAYS = 24 * 60 * 60;
|
|
2534
|
+
|
|
2535
|
+
// ../shared/dist/origins.js
|
|
2536
|
+
function normalizeOrigin(value) {
|
|
2537
|
+
try {
|
|
2538
|
+
const url = new URL(value.trim());
|
|
2539
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
2540
|
+
return void 0;
|
|
2541
|
+
return url.origin;
|
|
2542
|
+
} catch {
|
|
2543
|
+
return void 0;
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// ../shared/dist/plans.js
|
|
2548
|
+
var GB = 1024 ** 3;
|
|
2549
|
+
var MB = 1024 ** 2;
|
|
2550
|
+
var PLANS = {
|
|
2551
|
+
free: {
|
|
2552
|
+
key: "free",
|
|
2553
|
+
priceEurMonthly: 0,
|
|
2554
|
+
priceEurYearly: 0,
|
|
2555
|
+
maxProjects: 2,
|
|
2556
|
+
maxMembers: 2,
|
|
2557
|
+
storageBytes: 1 * GB,
|
|
2558
|
+
monthlyBandwidthBytes: 25 * GB,
|
|
2559
|
+
monthlyPublicApiRequests: 1e4,
|
|
2560
|
+
maxWebhookEndpoints: 1,
|
|
2561
|
+
revisionRetentionDays: 30,
|
|
2562
|
+
maxUploadBytes: 50 * MB
|
|
2563
|
+
},
|
|
2564
|
+
pro: {
|
|
2565
|
+
key: "pro",
|
|
2566
|
+
priceEurMonthly: 9,
|
|
2567
|
+
priceEurYearly: 90,
|
|
2568
|
+
maxProjects: 10,
|
|
2569
|
+
maxMembers: 5,
|
|
2570
|
+
storageBytes: 10 * GB,
|
|
2571
|
+
monthlyBandwidthBytes: 250 * GB,
|
|
2572
|
+
monthlyPublicApiRequests: 5e5,
|
|
2573
|
+
maxWebhookEndpoints: 10,
|
|
2574
|
+
revisionRetentionDays: null,
|
|
2575
|
+
maxUploadBytes: 250 * MB
|
|
2576
|
+
}
|
|
2577
|
+
};
|
|
2578
|
+
|
|
2579
|
+
// ../shared/dist/rank.js
|
|
2580
|
+
var DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2581
|
+
var MIN_DIGIT = DIGITS[0];
|
|
2582
|
+
var LAST_INDEX = DIGITS.length - 1;
|
|
2583
|
+
var MID_DIGIT = DIGITS[Math.floor(DIGITS.length / 2)];
|
|
2584
|
+
|
|
2585
|
+
// ../shared/dist/release.js
|
|
2586
|
+
function parseSemVer(version) {
|
|
2587
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
|
|
2588
|
+
if (!match)
|
|
2589
|
+
return void 0;
|
|
2590
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
2591
|
+
}
|
|
2592
|
+
function compareSemVer(a, b) {
|
|
2593
|
+
const left = parseSemVer(a);
|
|
2594
|
+
const right = parseSemVer(b);
|
|
2595
|
+
if (!left || !right)
|
|
2596
|
+
throw new Error(`Cannot compare versions "${a}" and "${b}".`);
|
|
2597
|
+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
1808
2600
|
// src/commands/admin.ts
|
|
1809
2601
|
function csv(value) {
|
|
1810
2602
|
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -1812,6 +2604,7 @@ function csv(value) {
|
|
|
1812
2604
|
function registerAdmin(program) {
|
|
1813
2605
|
registerOrganizations(program);
|
|
1814
2606
|
registerProjects(program);
|
|
2607
|
+
registerCors(program);
|
|
1815
2608
|
registerKeys(program);
|
|
1816
2609
|
registerMembers(program);
|
|
1817
2610
|
registerWebhooks(program);
|
|
@@ -1893,6 +2686,14 @@ function registerProjects(program) {
|
|
|
1893
2686
|
);
|
|
1894
2687
|
})
|
|
1895
2688
|
);
|
|
2689
|
+
projects.command("export").description("Export the full project (schemas, entries, asset metadata) as JSON").argument("[project]", "project id or slug").action(
|
|
2690
|
+
handle(async (ctx, args) => {
|
|
2691
|
+
const ref = args[0] ?? ctx.requireProject();
|
|
2692
|
+
const data = await ctx.management().projects.export(ref);
|
|
2693
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
2694
|
+
`);
|
|
2695
|
+
})
|
|
2696
|
+
);
|
|
1896
2697
|
projects.command("update").description("Update project settings").argument("[project]", "project id or slug").option("--name <name>", "project name").option("--timezone <timezone>", "IANA timezone").option("--public-api <state>", "public API state (on|off)").option("--default-preview-template <template>", "default preview URL template").option("--clear-default-preview-template", "remove the default preview URL template").option("--origins <urls>", "comma-separated allowed origins").option("--clear-origins", "remove all allowed origins").action(
|
|
1897
2698
|
handle(async (ctx, args, opts) => {
|
|
1898
2699
|
const ref = args[0] ?? ctx.requireProject();
|
|
@@ -1925,6 +2726,56 @@ function registerProjects(program) {
|
|
|
1925
2726
|
emit(project, () => diag(`Updated ${project.slug}.`));
|
|
1926
2727
|
})
|
|
1927
2728
|
);
|
|
2729
|
+
const origins = projects.command("origins").description("Manage allowed browser origins");
|
|
2730
|
+
origins.command("list").description("List the project's allowed browser origins").argument("[project]", "project id or slug").action(
|
|
2731
|
+
handle(async (ctx, args) => {
|
|
2732
|
+
const ref = args[0] ?? ctx.requireProject();
|
|
2733
|
+
const project = await ctx.management().projects.get(ref);
|
|
2734
|
+
emit(
|
|
2735
|
+
project.origins,
|
|
2736
|
+
() => table(
|
|
2737
|
+
project.origins.map((origin) => ({ origin })),
|
|
2738
|
+
[{ header: "ORIGIN", value: (r) => r.origin }]
|
|
2739
|
+
)
|
|
2740
|
+
);
|
|
2741
|
+
})
|
|
2742
|
+
);
|
|
2743
|
+
origins.command("add").description("Allow one or more browser origins").argument("<origins...>", "origins, e.g. https://example.com").option("--project <ref>", "project id or slug").action(
|
|
2744
|
+
handle(async (ctx, args, opts) => {
|
|
2745
|
+
const ref = opts.project ?? ctx.requireProject();
|
|
2746
|
+
const project = await ctx.management().projects.get(ref);
|
|
2747
|
+
const requested = args[0].map(normalizeOne);
|
|
2748
|
+
const next = [.../* @__PURE__ */ new Set([...project.origins, ...requested])];
|
|
2749
|
+
const added = requested.filter((o) => !project.origins.includes(o));
|
|
2750
|
+
if (added.length === 0) {
|
|
2751
|
+
emit({ origins: project.origins, added: [] }, () => diag("Already allowed; nothing to do."));
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const updated = await ctx.management().projects.update(ref, { origins: next });
|
|
2755
|
+
emit(
|
|
2756
|
+
{ origins: updated.origins, added },
|
|
2757
|
+
() => diag(`Allowed ${added.join(", ")} on ${updated.slug}.`)
|
|
2758
|
+
);
|
|
2759
|
+
})
|
|
2760
|
+
);
|
|
2761
|
+
origins.command("remove").description("Stop allowing one or more browser origins").argument("<origins...>", "origins to remove").option("--project <ref>", "project id or slug").action(
|
|
2762
|
+
handle(async (ctx, args, opts) => {
|
|
2763
|
+
const ref = opts.project ?? ctx.requireProject();
|
|
2764
|
+
const project = await ctx.management().projects.get(ref);
|
|
2765
|
+
const requested = args[0].map(normalizeOne);
|
|
2766
|
+
const next = project.origins.filter((o) => !requested.includes(o));
|
|
2767
|
+
const removed = project.origins.filter((o) => requested.includes(o));
|
|
2768
|
+
if (removed.length === 0) {
|
|
2769
|
+
emit({ origins: project.origins, removed: [] }, () => diag("Not configured; nothing to do."));
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
const updated = await ctx.management().projects.update(ref, { origins: next });
|
|
2773
|
+
emit(
|
|
2774
|
+
{ origins: updated.origins, removed },
|
|
2775
|
+
() => diag(`Removed ${removed.join(", ")} from ${updated.slug}.`)
|
|
2776
|
+
);
|
|
2777
|
+
})
|
|
2778
|
+
);
|
|
1928
2779
|
projects.command("archive").description("Archive a project").argument("[project]", "project id or slug").action(
|
|
1929
2780
|
handle(async (ctx, args) => {
|
|
1930
2781
|
const ref = args[0] ?? ctx.requireProject();
|
|
@@ -1933,6 +2784,35 @@ function registerProjects(program) {
|
|
|
1933
2784
|
})
|
|
1934
2785
|
);
|
|
1935
2786
|
}
|
|
2787
|
+
function normalizeOne(value) {
|
|
2788
|
+
const normalized = normalizeOrigin(value);
|
|
2789
|
+
if (!normalized) throw new UsageError(`"${value}" is not a valid http(s) origin.`);
|
|
2790
|
+
return normalized;
|
|
2791
|
+
}
|
|
2792
|
+
function registerCors(program) {
|
|
2793
|
+
program.command("cors").description("Diagnose browser access to published content").command("check").description("Explain whether a browser at an origin can read this project").requiredOption("--origin <url>", "the browser origin to test").option("--collection <key>", "also check a specific collection's visibility").option("--project <ref>", "project id or slug").action(
|
|
2794
|
+
handle(async (ctx, _args, opts) => {
|
|
2795
|
+
const ref = opts.project ?? ctx.requireProject();
|
|
2796
|
+
const result = await ctx.management().projects.corsCheck(ref, opts.origin, opts.collection);
|
|
2797
|
+
emit(result, () => {
|
|
2798
|
+
for (const c of result.checks) {
|
|
2799
|
+
process.stdout.write(`${c.ok ? "\u2713" : "\u2717"} ${c.detail}
|
|
2800
|
+
`);
|
|
2801
|
+
}
|
|
2802
|
+
if (!result.allowed) {
|
|
2803
|
+
const failed = result.checks.find((c) => !c.ok);
|
|
2804
|
+
if (failed?.id === "origin") {
|
|
2805
|
+
diag(`
|
|
2806
|
+
Allow it with: myna projects origins add ${result.origin}`);
|
|
2807
|
+
} else if (failed?.id === "publicApi") {
|
|
2808
|
+
diag("\nEnable it with: myna projects update --public-api on");
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
});
|
|
2812
|
+
if (!result.allowed) process.exitCode = 1;
|
|
2813
|
+
})
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
1936
2816
|
function registerKeys(program) {
|
|
1937
2817
|
const keys = program.command("keys").description("Manage API keys");
|
|
1938
2818
|
keys.command("list").description("List API keys for the project (or organization with --org-scope)").option("--org-scope", "list organization-scoped keys", false).action(
|
|
@@ -2166,8 +3046,498 @@ function registerBilling(program) {
|
|
|
2166
3046
|
);
|
|
2167
3047
|
}
|
|
2168
3048
|
|
|
3049
|
+
// src/commands/doctor.ts
|
|
3050
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
3051
|
+
import { join as join6 } from "path";
|
|
3052
|
+
|
|
3053
|
+
// src/version.ts
|
|
3054
|
+
var VERSION = true ? "0.3.0" : "0.0.0-dev";
|
|
3055
|
+
|
|
3056
|
+
// src/commands/doctor.ts
|
|
3057
|
+
var NPM_REGISTRY = "https://registry.npmjs.org";
|
|
3058
|
+
var SCAN_IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".react-router", ".turbo", "coverage"]);
|
|
3059
|
+
var GENERATED_MARKERS = [
|
|
3060
|
+
"// Generated by `myna types generate`. Do not edit by hand.",
|
|
3061
|
+
"export interface MynaCollections {"
|
|
3062
|
+
];
|
|
3063
|
+
function looksGenerated(contents) {
|
|
3064
|
+
return GENERATED_MARKERS.every((marker) => contents.includes(marker));
|
|
3065
|
+
}
|
|
3066
|
+
function check(id, title, status, detail, remedy) {
|
|
3067
|
+
return remedy ? { id, title, status, detail, remedy } : { id, title, status, detail };
|
|
3068
|
+
}
|
|
3069
|
+
async function latestPublished(pkg) {
|
|
3070
|
+
try {
|
|
3071
|
+
const res = await fetch(`${NPM_REGISTRY}/${pkg}/latest`, {
|
|
3072
|
+
headers: { accept: "application/json" },
|
|
3073
|
+
signal: AbortSignal.timeout(5e3)
|
|
3074
|
+
});
|
|
3075
|
+
if (!res.ok) return void 0;
|
|
3076
|
+
const body = await res.json();
|
|
3077
|
+
return body.version;
|
|
3078
|
+
} catch {
|
|
3079
|
+
return void 0;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
async function checkCliVersion() {
|
|
3083
|
+
if (!parseSemVer(VERSION)) {
|
|
3084
|
+
return check(
|
|
3085
|
+
"cli.version",
|
|
3086
|
+
"CLI version",
|
|
3087
|
+
"warn",
|
|
3088
|
+
`Running an unreleased build (${VERSION}).`,
|
|
3089
|
+
"Install a published release: npm install -g @myna-sh/cli"
|
|
3090
|
+
);
|
|
3091
|
+
}
|
|
3092
|
+
const latest = await latestPublished("@myna-sh/cli");
|
|
3093
|
+
if (!latest) {
|
|
3094
|
+
return check("cli.version", "CLI version", "skip", `${VERSION} (could not reach npm to compare).`);
|
|
3095
|
+
}
|
|
3096
|
+
const behind = compareSemVer(VERSION, latest) < 0;
|
|
3097
|
+
return behind ? check(
|
|
3098
|
+
"cli.version",
|
|
3099
|
+
"CLI version",
|
|
3100
|
+
"warn",
|
|
3101
|
+
`${VERSION} is behind the latest release ${latest}.`,
|
|
3102
|
+
`npm install -g @myna-sh/cli@${latest}`
|
|
3103
|
+
) : check("cli.version", "CLI version", "pass", `${VERSION} is current.`);
|
|
3104
|
+
}
|
|
3105
|
+
function checkApi(meta, apiUrl) {
|
|
3106
|
+
if (!meta) {
|
|
3107
|
+
return [
|
|
3108
|
+
check(
|
|
3109
|
+
"api.reachable",
|
|
3110
|
+
"API reachable",
|
|
3111
|
+
"fail",
|
|
3112
|
+
`No capability descriptor from ${apiUrl}/v1/meta.`,
|
|
3113
|
+
"Check --api-url / MYNA_API_URL and network access. An API older than 0.2.0 does not serve /v1/meta."
|
|
3114
|
+
)
|
|
3115
|
+
];
|
|
3116
|
+
}
|
|
3117
|
+
const checks = [
|
|
3118
|
+
check("api.reachable", "API reachable", "pass", `${apiUrl} speaks API ${meta.apiVersion}.`)
|
|
3119
|
+
];
|
|
3120
|
+
if (!parseSemVer(VERSION)) {
|
|
3121
|
+
checks.push(
|
|
3122
|
+
check("api.compatibility", "Client compatibility", "skip", "Unreleased CLI build; nothing to compare.")
|
|
3123
|
+
);
|
|
3124
|
+
return checks;
|
|
3125
|
+
}
|
|
3126
|
+
const supported = compareSemVer(VERSION, meta.minClientVersion) >= 0;
|
|
3127
|
+
checks.push(
|
|
3128
|
+
supported ? check(
|
|
3129
|
+
"api.compatibility",
|
|
3130
|
+
"Client compatibility",
|
|
3131
|
+
"pass",
|
|
3132
|
+
`CLI ${VERSION} meets the minimum supported client ${meta.minClientVersion}.`
|
|
3133
|
+
) : check(
|
|
3134
|
+
"api.compatibility",
|
|
3135
|
+
"Client compatibility",
|
|
3136
|
+
"fail",
|
|
3137
|
+
`CLI ${VERSION} is older than the minimum client ${meta.minClientVersion} this API supports.`,
|
|
3138
|
+
`npm install -g @myna-sh/cli@latest`
|
|
3139
|
+
)
|
|
3140
|
+
);
|
|
3141
|
+
return checks;
|
|
3142
|
+
}
|
|
3143
|
+
async function checkAuth(ctx) {
|
|
3144
|
+
const status = await checkCredential(ctx.apiUrl, ctx.token, ctx.tokenSource);
|
|
3145
|
+
if (!status.credentialPresent) {
|
|
3146
|
+
return [
|
|
3147
|
+
check("auth.credential", "Credential", "fail", "No credential found.", "myna login")
|
|
3148
|
+
];
|
|
3149
|
+
}
|
|
3150
|
+
if (!status.credentialValid) {
|
|
3151
|
+
return [
|
|
3152
|
+
check(
|
|
3153
|
+
"auth.credential",
|
|
3154
|
+
"Credential",
|
|
3155
|
+
"fail",
|
|
3156
|
+
// The distinction the old `whoami` collapsed.
|
|
3157
|
+
`A credential is present (from ${status.source}) but the API rejected it: ${status.error?.detail ?? "unknown reason"}.`,
|
|
3158
|
+
status.error?.code === "UNREACHABLE" ? void 0 : "myna login"
|
|
3159
|
+
)
|
|
3160
|
+
];
|
|
3161
|
+
}
|
|
3162
|
+
const checks = [
|
|
3163
|
+
check("auth.credential", "Credential", "pass", `Valid \u2014 ${describeIdentity(status.identity)}.`)
|
|
3164
|
+
];
|
|
3165
|
+
const scopes = scopesOf(status.identity);
|
|
3166
|
+
if (scopes) {
|
|
3167
|
+
checks.push(
|
|
3168
|
+
scopes.length > 0 ? check("auth.scopes", "Key scopes", "pass", scopes.join(", ")) : check(
|
|
3169
|
+
"auth.scopes",
|
|
3170
|
+
"Key scopes",
|
|
3171
|
+
"fail",
|
|
3172
|
+
"The key carries no scopes, so every authorized call will be denied.",
|
|
3173
|
+
"Mint a scoped key: myna keys create --scopes content:read,content:write"
|
|
3174
|
+
)
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
return checks;
|
|
3178
|
+
}
|
|
3179
|
+
async function checkProject(ctx) {
|
|
3180
|
+
if (!ctx.project) {
|
|
3181
|
+
return [
|
|
3182
|
+
check(
|
|
3183
|
+
"project.link",
|
|
3184
|
+
"Project",
|
|
3185
|
+
"warn",
|
|
3186
|
+
"No project selected.",
|
|
3187
|
+
"myna link --project <project>, or pass --project"
|
|
3188
|
+
)
|
|
3189
|
+
];
|
|
3190
|
+
}
|
|
3191
|
+
try {
|
|
3192
|
+
const project = await ctx.management().projects.get(ctx.project);
|
|
3193
|
+
const where = ctx.linkedRoot ? ` (linked at ${ctx.linkedRoot})` : "";
|
|
3194
|
+
return [
|
|
3195
|
+
check("project.link", "Project", "pass", `${project.slug}${where}.`),
|
|
3196
|
+
project.publicApiEnabled ? check("project.publicApi", "Public API", "pass", "Enabled.") : check(
|
|
3197
|
+
"project.publicApi",
|
|
3198
|
+
"Public API",
|
|
3199
|
+
"warn",
|
|
3200
|
+
"Disabled \u2014 browser and unauthenticated reads will 404.",
|
|
3201
|
+
"myna projects update --public-api"
|
|
3202
|
+
)
|
|
3203
|
+
];
|
|
3204
|
+
} catch (error) {
|
|
3205
|
+
const detail = isMynaApiError(error) ? `${error.code} \u2014 ${error.detail ?? error.title}` : error instanceof Error ? error.message : String(error);
|
|
3206
|
+
return [
|
|
3207
|
+
check(
|
|
3208
|
+
"project.link",
|
|
3209
|
+
"Project",
|
|
3210
|
+
"fail",
|
|
3211
|
+
`Cannot read project "${ctx.project}": ${detail}`,
|
|
3212
|
+
"Check the project ref and that your credential has access to it."
|
|
3213
|
+
)
|
|
3214
|
+
];
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
async function checkOrigin(ctx, origin) {
|
|
3218
|
+
const canonical = normalizeOrigin(origin);
|
|
3219
|
+
if (!canonical) {
|
|
3220
|
+
return check("origins", "Browser origin", "fail", `"${origin}" is not a valid http(s) origin.`);
|
|
3221
|
+
}
|
|
3222
|
+
if (!ctx.project) {
|
|
3223
|
+
return check("origins", "Browser origin", "skip", "No project selected.");
|
|
3224
|
+
}
|
|
3225
|
+
try {
|
|
3226
|
+
const project = await ctx.management().projects.get(ctx.project);
|
|
3227
|
+
const allowed = project.origins.some((o) => normalizeOrigin(o) === canonical);
|
|
3228
|
+
return allowed ? check("origins", "Browser origin", "pass", `${canonical} is allowed on ${project.slug}.`) : check(
|
|
3229
|
+
"origins",
|
|
3230
|
+
"Browser origin",
|
|
3231
|
+
"fail",
|
|
3232
|
+
`${canonical} is not allowed on ${project.slug}. A browser will discard responses from it. Configured: ${project.origins.length > 0 ? project.origins.join(", ") : "(none)"}`,
|
|
3233
|
+
`myna projects update --origins ${[...project.origins, canonical].join(",")}`
|
|
3234
|
+
);
|
|
3235
|
+
} catch {
|
|
3236
|
+
return check("origins", "Browser origin", "skip", "Could not read the project's origins.");
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
async function checkSchema(ctx, schemaDir) {
|
|
3240
|
+
const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
|
|
3241
|
+
if (!existsSync4(dir)) {
|
|
3242
|
+
return check("schema.drift", "Local schema", "skip", `No schema directory at ${dir}.`);
|
|
3243
|
+
}
|
|
3244
|
+
if (!ctx.project) {
|
|
3245
|
+
return check("schema.drift", "Local schema", "skip", "No project selected.");
|
|
3246
|
+
}
|
|
3247
|
+
let local;
|
|
3248
|
+
try {
|
|
3249
|
+
local = await loadLocalSchemas(dir);
|
|
3250
|
+
} catch (error) {
|
|
3251
|
+
return check(
|
|
3252
|
+
"schema.drift",
|
|
3253
|
+
"Local schema",
|
|
3254
|
+
"fail",
|
|
3255
|
+
`Could not load schemas from ${dir}: ${error instanceof Error ? error.message : String(error)}`
|
|
3256
|
+
);
|
|
3257
|
+
}
|
|
3258
|
+
if (local.length === 0) {
|
|
3259
|
+
return check("schema.drift", "Local schema", "skip", `No collections defined in ${dir}.`);
|
|
3260
|
+
}
|
|
3261
|
+
try {
|
|
3262
|
+
const diff = await ctx.management().schema.diff(ctx.project, local);
|
|
3263
|
+
return diff.ops.length === 0 ? check("schema.drift", "Local schema", "pass", `${local.length} collection(s) match the deployed schema.`) : check(
|
|
3264
|
+
"schema.drift",
|
|
3265
|
+
"Local schema",
|
|
3266
|
+
"warn",
|
|
3267
|
+
`${diff.ops.length} undeployed change(s) (${diff.classification}).`,
|
|
3268
|
+
"myna schema diff, then myna schema push"
|
|
3269
|
+
);
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
return check(
|
|
3272
|
+
"schema.drift",
|
|
3273
|
+
"Local schema",
|
|
3274
|
+
"skip",
|
|
3275
|
+
`Could not diff against the deployed schema: ${error instanceof Error ? error.message : String(error)}`
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
function findGeneratedTypes(root, depth = 4) {
|
|
3280
|
+
let entries;
|
|
3281
|
+
try {
|
|
3282
|
+
entries = readdirSync2(root);
|
|
3283
|
+
} catch {
|
|
3284
|
+
return void 0;
|
|
3285
|
+
}
|
|
3286
|
+
const dirs = [];
|
|
3287
|
+
for (const entry of entries) {
|
|
3288
|
+
if (SCAN_IGNORE.has(entry) || entry.startsWith(".")) continue;
|
|
3289
|
+
const full = join6(root, entry);
|
|
3290
|
+
let stats;
|
|
3291
|
+
try {
|
|
3292
|
+
stats = statSync2(full);
|
|
3293
|
+
} catch {
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
3296
|
+
if (stats.isDirectory()) {
|
|
3297
|
+
dirs.push(full);
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
if (!/\.(ts|d\.ts)$/.test(entry)) continue;
|
|
3301
|
+
try {
|
|
3302
|
+
if (looksGenerated(readFileSync3(full, "utf8"))) return full;
|
|
3303
|
+
} catch {
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
if (depth <= 0) return void 0;
|
|
3307
|
+
for (const dir of dirs) {
|
|
3308
|
+
const found = findGeneratedTypes(dir, depth - 1);
|
|
3309
|
+
if (found) return found;
|
|
3310
|
+
}
|
|
3311
|
+
return void 0;
|
|
3312
|
+
}
|
|
3313
|
+
async function checkTypes(ctx, explicit, schemaDir) {
|
|
3314
|
+
const root = ctx.linkedRoot ?? process.cwd();
|
|
3315
|
+
const file = explicit ?? findGeneratedTypes(root);
|
|
3316
|
+
if (!file) {
|
|
3317
|
+
return check("types.freshness", "Generated types", "skip", "No generated types file found.");
|
|
3318
|
+
}
|
|
3319
|
+
if (!existsSync4(file)) {
|
|
3320
|
+
return check("types.freshness", "Generated types", "fail", `${file} does not exist.`);
|
|
3321
|
+
}
|
|
3322
|
+
const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
|
|
3323
|
+
if (!existsSync4(dir)) {
|
|
3324
|
+
return check("types.freshness", "Generated types", "skip", `Found ${file} but no schema directory to compare against.`);
|
|
3325
|
+
}
|
|
3326
|
+
try {
|
|
3327
|
+
const expected = generateTypesModule(await loadLocalSchemas(dir));
|
|
3328
|
+
return readFileSync3(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
|
|
3329
|
+
"types.freshness",
|
|
3330
|
+
"Generated types",
|
|
3331
|
+
"warn",
|
|
3332
|
+
`${file} is out of date with the local schema.`,
|
|
3333
|
+
`myna types generate --out ${file}`
|
|
3334
|
+
);
|
|
3335
|
+
} catch (error) {
|
|
3336
|
+
return check(
|
|
3337
|
+
"types.freshness",
|
|
3338
|
+
"Generated types",
|
|
3339
|
+
"skip",
|
|
3340
|
+
`Could not regenerate types for comparison: ${error instanceof Error ? error.message : String(error)}`
|
|
3341
|
+
);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
var ICON = { pass: "\u2713", warn: "!", fail: "\u2717", skip: "\u2013" };
|
|
3345
|
+
function registerDoctor(program) {
|
|
3346
|
+
program.command("doctor").description("Diagnose CLI, API, credential, project, schema, and origin configuration").option("--origin <url>", "also check whether this browser origin is allowed").option("--schema-dir <dir>", "schema directory").option("--types <file>", "generated types file to check for freshness").action(
|
|
3347
|
+
handle(async (ctx, _args, opts) => {
|
|
3348
|
+
const checks = [];
|
|
3349
|
+
checks.push(await checkCliVersion());
|
|
3350
|
+
const meta = await fetchMeta(ctx.apiUrl);
|
|
3351
|
+
checks.push(...checkApi(meta, ctx.apiUrl));
|
|
3352
|
+
const auth = await checkAuth(ctx);
|
|
3353
|
+
checks.push(...auth);
|
|
3354
|
+
const authed = auth[0]?.status === "pass";
|
|
3355
|
+
if (authed) {
|
|
3356
|
+
checks.push(...await checkProject(ctx));
|
|
3357
|
+
checks.push(await checkSchema(ctx, opts.schemaDir));
|
|
3358
|
+
checks.push(await checkTypes(ctx, opts.types, opts.schemaDir));
|
|
3359
|
+
if (opts.origin) checks.push(await checkOrigin(ctx, opts.origin));
|
|
3360
|
+
} else {
|
|
3361
|
+
const deferred = [
|
|
3362
|
+
["project.link", "Project"],
|
|
3363
|
+
["schema.drift", "Local schema"],
|
|
3364
|
+
["types.freshness", "Generated types"]
|
|
3365
|
+
];
|
|
3366
|
+
for (const [id, title] of deferred) {
|
|
3367
|
+
checks.push(check(id, title, "skip", "Requires a valid credential."));
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
const failed = checks.filter((c) => c.status === "fail").length;
|
|
3371
|
+
const warned = checks.filter((c) => c.status === "warn").length;
|
|
3372
|
+
emit({ ok: failed === 0, failed, warned, checks }, () => {
|
|
3373
|
+
for (const c of checks) {
|
|
3374
|
+
process.stdout.write(`${ICON[c.status]} ${c.title}: ${c.detail}
|
|
3375
|
+
`);
|
|
3376
|
+
if (c.remedy && c.status !== "pass") process.stdout.write(` \u2192 ${c.remedy}
|
|
3377
|
+
`);
|
|
3378
|
+
}
|
|
3379
|
+
diag(
|
|
3380
|
+
failed === 0 ? warned === 0 ? "\nAll checks passed." : `
|
|
3381
|
+
${warned} warning(s), no failures.` : `
|
|
3382
|
+
${failed} failure(s), ${warned} warning(s).`
|
|
3383
|
+
);
|
|
3384
|
+
});
|
|
3385
|
+
if (failed > 0) process.exitCode = 1;
|
|
3386
|
+
})
|
|
3387
|
+
);
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
// src/commands/sync.ts
|
|
3391
|
+
import { readdir as readdir2, stat as stat2 } from "fs/promises";
|
|
3392
|
+
import { join as join7 } from "path";
|
|
3393
|
+
async function isDirectory(path) {
|
|
3394
|
+
const info = await stat2(path).catch(() => void 0);
|
|
3395
|
+
return Boolean(info?.isDirectory());
|
|
3396
|
+
}
|
|
3397
|
+
async function planDirectories(dir, collection) {
|
|
3398
|
+
if (!await isDirectory(dir)) {
|
|
3399
|
+
if (!collection) throw new UsageError("Syncing a single file needs --collection.");
|
|
3400
|
+
return [{ collection, path: dir }];
|
|
3401
|
+
}
|
|
3402
|
+
if (collection) return [{ collection, path: dir }];
|
|
3403
|
+
const children = await readdir2(dir);
|
|
3404
|
+
const plan = [];
|
|
3405
|
+
for (const name of children.sort()) {
|
|
3406
|
+
if (name.startsWith(".")) continue;
|
|
3407
|
+
const full = join7(dir, name);
|
|
3408
|
+
if (await isDirectory(full)) plan.push({ collection: name, path: full });
|
|
3409
|
+
}
|
|
3410
|
+
if (plan.length === 0) {
|
|
3411
|
+
throw new UsageError(
|
|
3412
|
+
`No collection subdirectories in ${dir}. Pass --collection to sync it as one collection.`
|
|
3413
|
+
);
|
|
3414
|
+
}
|
|
3415
|
+
return plan;
|
|
3416
|
+
}
|
|
3417
|
+
function registerSync(program) {
|
|
3418
|
+
program.command("sync").description("Reconcile a content directory into collections, staged on one change set").argument("<dir>", "content directory").option("--collection <key>", "treat the directory as one collection instead of one per subdirectory").option("--format <format>", "json or markdown-frontmatter (detected from the files by default)").option("--body-field <key>", "field that receives the Markdown body", "body").option("--change-set <id>", "stage onto an existing change set").option("--dry-run", "report what would change without writing", false).option("--delete-missing", "also stage deletion of entries with no matching file", false).option("--confirm-delete", "required consequence flag for --delete-missing", false).action(
|
|
3419
|
+
handle(async (ctx, args, opts) => {
|
|
3420
|
+
const project = ctx.requireProject();
|
|
3421
|
+
const dryRun = Boolean(opts.dryRun);
|
|
3422
|
+
const deleteMissing = Boolean(opts.deleteMissing);
|
|
3423
|
+
if (deleteMissing && !opts.confirmDelete && !dryRun) {
|
|
3424
|
+
throw new UsageError("--delete-missing removes published content. Add --confirm-delete, or use --dry-run.");
|
|
3425
|
+
}
|
|
3426
|
+
const plan = await planDirectories(args[0], opts.collection);
|
|
3427
|
+
const mgmt = ctx.management();
|
|
3428
|
+
let changeSetId = opts.changeSet;
|
|
3429
|
+
const report = [];
|
|
3430
|
+
let failed = false;
|
|
3431
|
+
for (const target of plan) {
|
|
3432
|
+
const source = await readContentRows(target.path, {
|
|
3433
|
+
format: parseFormat2(opts.format),
|
|
3434
|
+
bodyField: opts.bodyField
|
|
3435
|
+
});
|
|
3436
|
+
if (source.rows.length === 0) continue;
|
|
3437
|
+
const result = await mgmt.entries.import(project, {
|
|
3438
|
+
collection: target.collection,
|
|
3439
|
+
entries: source.rows.map((r) => r.slug ? { slug: r.slug, data: r.data } : { data: r.data }),
|
|
3440
|
+
dryRun,
|
|
3441
|
+
changeSetId,
|
|
3442
|
+
mode: "upsert"
|
|
3443
|
+
});
|
|
3444
|
+
if (!result.valid) {
|
|
3445
|
+
failed = true;
|
|
3446
|
+
for (const row of result.results.filter((r) => r.outcome === "invalid")) {
|
|
3447
|
+
const where = source.rows[row.index]?.source ?? `row ${row.index}`;
|
|
3448
|
+
for (const e of row.errors ?? []) {
|
|
3449
|
+
process.stderr.write(` ${where}: ${e.path || "(entry)"} ${e.message}
|
|
3450
|
+
`);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
changeSetId ??= result.changeSetId ?? void 0;
|
|
3455
|
+
let deleted = 0;
|
|
3456
|
+
let deletedSlugs = [];
|
|
3457
|
+
if (deleteMissing && result.valid) {
|
|
3458
|
+
const staged = await stageMissingDeletions(
|
|
3459
|
+
ctx,
|
|
3460
|
+
project,
|
|
3461
|
+
target.collection,
|
|
3462
|
+
new Set(source.rows.map((r) => r.slug).filter((s) => Boolean(s))),
|
|
3463
|
+
changeSetId,
|
|
3464
|
+
dryRun
|
|
3465
|
+
);
|
|
3466
|
+
deleted = staged.count;
|
|
3467
|
+
deletedSlugs = staged.slugs;
|
|
3468
|
+
changeSetId ??= staged.changeSetId;
|
|
3469
|
+
}
|
|
3470
|
+
report.push({
|
|
3471
|
+
collection: target.collection,
|
|
3472
|
+
created: result.summary.created,
|
|
3473
|
+
updated: result.summary.updated,
|
|
3474
|
+
unchanged: result.summary.unchanged,
|
|
3475
|
+
invalid: result.summary.invalid,
|
|
3476
|
+
deleted,
|
|
3477
|
+
deletedSlugs
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
emit({ dryRun, changeSetId: changeSetId ?? null, collections: report, ok: !failed }, () => {
|
|
3481
|
+
table(report, [
|
|
3482
|
+
{ header: "COLLECTION", value: (r) => r.collection },
|
|
3483
|
+
{ header: "CREATED", value: (r) => String(r.created) },
|
|
3484
|
+
{ header: "UPDATED", value: (r) => String(r.updated) },
|
|
3485
|
+
{ header: "UNCHANGED", value: (r) => String(r.unchanged) },
|
|
3486
|
+
{ header: "DELETED", value: (r) => String(r.deleted) },
|
|
3487
|
+
{ header: "INVALID", value: (r) => String(r.invalid) }
|
|
3488
|
+
]);
|
|
3489
|
+
const removals = report.flatMap((r) => r.deletedSlugs.map((slug) => `${r.collection}/${slug}`));
|
|
3490
|
+
if (removals.length > 0) {
|
|
3491
|
+
diag(`
|
|
3492
|
+
${dryRun ? "Would stage" : "Staged"} deletion of ${removals.length} entr(y/ies) with no file:`);
|
|
3493
|
+
for (const slug of removals) diag(` \u2212 ${slug}`);
|
|
3494
|
+
}
|
|
3495
|
+
if (dryRun) {
|
|
3496
|
+
diag("\nDry run \u2014 nothing was written.");
|
|
3497
|
+
} else if (changeSetId) {
|
|
3498
|
+
diag(`
|
|
3499
|
+
Staged on change set ${changeSetId}. Review and publish it to go live.`);
|
|
3500
|
+
} else {
|
|
3501
|
+
diag("\nNothing to do \u2014 every file already matches.");
|
|
3502
|
+
}
|
|
3503
|
+
});
|
|
3504
|
+
if (failed) process.exitCode = 1;
|
|
3505
|
+
})
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
async function stageMissingDeletions(ctx, project, collection, presentSlugs, changeSetId, dryRun) {
|
|
3509
|
+
const mgmt = ctx.management();
|
|
3510
|
+
const missing = [];
|
|
3511
|
+
let cursor;
|
|
3512
|
+
do {
|
|
3513
|
+
const page = await mgmt.entries.list(project, { collection, limit: 100, cursor });
|
|
3514
|
+
for (const entry of page.data) {
|
|
3515
|
+
if (entry.slug && !presentSlugs.has(entry.slug)) missing.push({ id: entry.id, slug: entry.slug });
|
|
3516
|
+
}
|
|
3517
|
+
cursor = page.nextCursor ?? void 0;
|
|
3518
|
+
} while (cursor);
|
|
3519
|
+
const slugs = missing.map((m) => m.slug).sort();
|
|
3520
|
+
if (missing.length === 0 || dryRun) return { count: missing.length, slugs, changeSetId };
|
|
3521
|
+
const result = await mgmt.entries.bulk(project, {
|
|
3522
|
+
action: "delete",
|
|
3523
|
+
entryIds: missing.map((m) => m.id),
|
|
3524
|
+
changeSetId
|
|
3525
|
+
});
|
|
3526
|
+
const succeeded = result.results.filter((r) => r.ok);
|
|
3527
|
+
return {
|
|
3528
|
+
count: succeeded.length,
|
|
3529
|
+
slugs,
|
|
3530
|
+
changeSetId: changeSetId ?? succeeded.find((r) => r.changeSetId)?.changeSetId
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function parseFormat2(value) {
|
|
3534
|
+
if (!value) return void 0;
|
|
3535
|
+
if (value === "json") return "json";
|
|
3536
|
+
if (value === "markdown-frontmatter" || value === "markdown" || value === "md") return "markdown";
|
|
3537
|
+
throw new UsageError("--format must be json or markdown-frontmatter.");
|
|
3538
|
+
}
|
|
3539
|
+
|
|
2169
3540
|
// src/main.ts
|
|
2170
|
-
var VERSION = "0.1.4";
|
|
2171
3541
|
var GLOBAL_VALUE_FLAGS = /* @__PURE__ */ new Set(["--project", "--organization", "--token", "--api-url"]);
|
|
2172
3542
|
var GLOBAL_BOOL_FLAGS = /* @__PURE__ */ new Set(["--json", "--no-interactive", "--interactive"]);
|
|
2173
3543
|
function normalizeGlobals(argv) {
|
|
@@ -2197,9 +3567,11 @@ function buildProgram() {
|
|
|
2197
3567
|
const program = new Command();
|
|
2198
3568
|
program.name("myna").description("Myna \u2014 content infrastructure for developers and agents").version(VERSION, "-v, --version").option("--json", "emit a single machine-readable JSON value on stdout").option("--project <ref>", "project id or slug").option("--organization <ref>", "organization id or slug").option("--token <token>", "API token (overrides stored credentials)").option("--api-url <url>", "API base URL").option("--no-interactive", "disable prompts and browser opening").showHelpAfterError();
|
|
2199
3569
|
registerAuth(program);
|
|
3570
|
+
registerDoctor(program);
|
|
2200
3571
|
registerWorkspace(program);
|
|
2201
3572
|
registerSchema(program);
|
|
2202
3573
|
registerEntries(program);
|
|
3574
|
+
registerSync(program);
|
|
2203
3575
|
registerChanges(program);
|
|
2204
3576
|
registerPreviews(program);
|
|
2205
3577
|
registerAssets(program);
|
|
@@ -2226,6 +3598,7 @@ async function main(argv = process.argv) {
|
|
|
2226
3598
|
}
|
|
2227
3599
|
}
|
|
2228
3600
|
export {
|
|
3601
|
+
VERSION,
|
|
2229
3602
|
buildProgram,
|
|
2230
3603
|
main
|
|
2231
3604
|
};
|