@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/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-VAS24QPJ.js
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(query2) {
63
- if (!query2) return "";
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(query2)) append(key, value);
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 ?? 3,
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 idempotent = SAFE_METHODS.has(method.toUpperCase()) || Boolean(options.idempotencyKey);
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) => ` ${JSON.stringify(c.name)}: ${pascal(c.name)};`).join("\n");
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
- return `${doc}${indent}${safeKey(field.key)}${optional}: ${fieldType(field, depth)};`;
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 = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
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, query2, signal) {
330
- return this.http.request("GET", path, { query: query2, signal });
414
+ get(path, query, signal) {
415
+ return this.http.request("GET", path, { query, signal });
331
416
  }
332
- async page(path, opts = {}) {
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, query2) {
341
- return this.http.request(method, path, { body, query: query2, idempotencyKey: this.idem() });
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
- `/projects/${enc(project)}/entries` + query({ collection: opts.collection, status: opts.status }),
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` + query({ status: opts.status }), opts),
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
- `/projects/${enc(project)}/activity` + query({
456
- actorType: opts.actorType,
457
- action: opts.action,
458
- targetType: opts.targetType,
459
- from: opts.from,
460
- to: opts.to
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 token = flags.token ?? process.env.MYNA_TOKEN ?? loadToken(apiUrl);
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 apiPost(ctx.apiUrl, "/auth/device", {});
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 apiPost(ctx.apiUrl, `/auth/device/${start.deviceCode}/token`, {
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("whoami").description("Show the authenticated identity").action(
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 token = ctx.token ?? loadToken(ctx.apiUrl);
947
- if (!token) throw new CliError("Not authenticated. Run `myna login`.");
948
- const identityResponse = await fetch(`${ctx.apiUrl}/v1/auth/whoami`, {
949
- headers: { authorization: `Bearer ${token}`, accept: "application/json" }
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
- const identityBody = await identityResponse.json().catch(() => ({}));
952
- if (!identityResponse.ok || !identityBody.data) {
953
- throw new CliError(identityBody.detail ?? "Stored credential is invalid. Run `myna login` again.");
954
- }
955
- const identity = identityBody.data;
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({ apiUrl: ctx.apiUrl, authenticated: true, identity, organization: organization ?? null }, () => {
964
- const pairs = [["API", ctx.apiUrl], ["Authenticated", "yes"]];
965
- if (identity.credential === "user") {
966
- pairs.push(["User", identity.user?.username ?? "(unknown)"]);
967
- } else {
968
- pairs.push(["Credential", identity.credential === "api_key" ? "API key" : "Preview token"]);
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 hasToken = Boolean(ctx.token ?? loadToken(ctx.apiUrl));
1393
+ const credentialPresent = Boolean(ctx.token ?? loadToken(ctx.apiUrl));
1074
1394
  emit(
1075
1395
  {
1076
1396
  apiUrl: ctx.apiUrl,
1077
- authenticated: hasToken,
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
- () => keyValues([
1083
- ["API URL", ctx.apiUrl],
1084
- ["Authenticated", hasToken ? "yes" : "no"],
1085
- ["Project", ctx.project ?? "(none)"],
1086
- ["Organization", ctx.organization ?? "(none)"],
1087
- ["Linked at", ctx.linkedRoot ?? "(none)"]
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
  };