@myna-sh/cli 0.2.0 → 0.4.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,10 +115,31 @@ 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()
122
+ };
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);
112
140
  };
141
+ started.then(cleanup, cleanup);
142
+ return started.then((response) => response.clone());
113
143
  }
114
144
  /** Perform a request and unwrap the `{ data }` envelope. */
115
145
  async request(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));
@@ -348,19 +411,25 @@ var ManagementClient = class {
348
411
  idem() {
349
412
  return this.newIdempotencyKey();
350
413
  }
351
- get(path, query2, signal) {
352
- return this.http.request("GET", path, { query: query2, signal });
414
+ get(path, query, signal) {
415
+ return this.http.request("GET", path, { query, signal });
353
416
  }
354
- 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 = {}) {
355
424
  const body = await this.http.requestEnvelope(
356
425
  "GET",
357
426
  path,
358
- { query: { limit: opts.limit, cursor: opts.cursor }, signal: opts.signal }
427
+ { query: { ...filters, limit: opts.limit, cursor: opts.cursor }, signal: opts.signal }
359
428
  );
360
429
  return { data: body.data, nextCursor: body.pagination?.nextCursor ?? null };
361
430
  }
362
- mutate(method, path, body, query2) {
363
- 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() });
364
433
  }
365
434
  // --- Organizations --------------------------------------------------------
366
435
  organizations = {
@@ -393,6 +462,12 @@ var ManagementClient = class {
393
462
  archive: (project) => this.mutate("POST", `/projects/${enc(project)}/archive`),
394
463
  /** Full project export: schemas, entries (draft + published), asset metadata. */
395
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
+ ),
396
471
  views: (project, signal) => this.get(`/projects/${enc(project)}/views`, void 0, signal),
397
472
  createView: (project, body) => this.mutate("POST", `/projects/${enc(project)}/views`, body),
398
473
  deleteView: (project, view) => this.mutate("DELETE", `/projects/${enc(project)}/views/${enc(view)}`)
@@ -418,10 +493,10 @@ var ManagementClient = class {
418
493
  };
419
494
  // --- Entries & revisions --------------------------------------------------
420
495
  entries = {
421
- list: (project, opts = {}) => this.page(
422
- `/projects/${enc(project)}/entries` + query({ collection: opts.collection, status: opts.status }),
423
- opts
424
- ),
496
+ list: (project, opts = {}) => this.page(`/projects/${enc(project)}/entries`, opts, {
497
+ collection: opts.collection,
498
+ status: opts.status
499
+ }),
425
500
  create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/entries`, body),
426
501
  get: (project, entry, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}`, void 0, signal),
427
502
  update: (project, entry, body) => this.mutate("PATCH", `/projects/${enc(project)}/entries/${enc(entry)}`, body),
@@ -432,6 +507,16 @@ var ManagementClient = class {
432
507
  changeSetId
433
508
  }),
434
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
+ ),
435
520
  import: (project, body) => this.mutate("POST", `/projects/${enc(project)}/entries/import`, body),
436
521
  references: (project, entry, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}/references`, void 0, signal),
437
522
  revisions: (project, entry, signal) => this.get(`/projects/${enc(project)}/entries/${enc(entry)}/revisions`, void 0, signal),
@@ -443,7 +528,7 @@ var ManagementClient = class {
443
528
  };
444
529
  // --- Change sets ----------------------------------------------------------
445
530
  changeSets = {
446
- 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 }),
447
532
  create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/change-sets`, body),
448
533
  get: (project, changeSet, signal) => this.get(`/projects/${enc(project)}/change-sets/${enc(changeSet)}`, void 0, signal),
449
534
  update: (project, changeSet, body) => this.mutate("PATCH", `/projects/${enc(project)}/change-sets/${enc(changeSet)}`, body),
@@ -533,16 +618,13 @@ var ManagementClient = class {
533
618
  };
534
619
  // --- Activity -------------------------------------------------------------
535
620
  activity(project, opts = {}) {
536
- return this.page(
537
- `/projects/${enc(project)}/activity` + query({
538
- actorType: opts.actorType,
539
- action: opts.action,
540
- targetType: opts.targetType,
541
- from: opts.from,
542
- to: opts.to
543
- }),
544
- opts
545
- );
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
+ });
546
628
  }
547
629
  // --- Billing --------------------------------------------------------------
548
630
  billing = {
@@ -604,15 +686,80 @@ function guessContentType(filename) {
604
686
  function enc(segment) {
605
687
  return encodeURIComponent(segment);
606
688
  }
607
- function query(params) {
608
- const entries = Object.entries(params).filter(([, v]) => v !== void 0 && v !== "");
609
- if (entries.length === 0) return "";
610
- return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
611
- }
612
689
  function createManagementClient(options) {
613
690
  return new ManagementClient(options);
614
691
  }
615
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
+
616
763
  // src/config.ts
617
764
  import { execFileSync } from "child_process";
618
765
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "fs";
@@ -748,12 +895,15 @@ function resolveContext(flags) {
748
895
  const link = findLinkedProject();
749
896
  const user = readUserConfig();
750
897
  const apiUrl = flags.apiUrl ?? process.env.MYNA_API_URL ?? link?.linked.apiUrl ?? user.apiUrl ?? DEFAULT_API_URL2;
751
- 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";
752
901
  const project = flags.project ?? process.env.MYNA_PROJECT ?? link?.linked.project ?? user.defaultProject;
753
902
  const organization = flags.organization ?? process.env.MYNA_ORGANIZATION ?? link?.linked.organization ?? user.defaultOrganization;
754
903
  return {
755
904
  apiUrl: apiUrl.replace(/\/+$/, ""),
756
905
  token,
906
+ tokenSource,
757
907
  project,
758
908
  organization,
759
909
  json: Boolean(flags.json),
@@ -981,10 +1131,16 @@ async function apiPost(apiUrl, path, body) {
981
1131
  return json.data;
982
1132
  }
983
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
+ }
984
1140
  function registerAuth(program) {
985
- 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(
986
1142
  handle(async (ctx) => {
987
- const start = await apiPost(ctx.apiUrl, "/auth/device", {});
1143
+ const start = await startDevice(ctx.apiUrl);
988
1144
  diag(`
989
1145
  To authenticate, open:
990
1146
  ${start.verificationUri}
@@ -995,9 +1151,7 @@ function registerAuth(program) {
995
1151
  let token;
996
1152
  while (Date.now() < deadline) {
997
1153
  await sleep2(Math.max(1, start.interval) * 1e3);
998
- const poll = await apiPost(ctx.apiUrl, `/auth/device/${start.deviceCode}/token`, {
999
- deviceCode: start.deviceCode
1000
- });
1154
+ const poll = await pollDevice(ctx.apiUrl, start.deviceCode);
1001
1155
  if (poll.status === "approved" && poll.token) {
1002
1156
  token = poll.token;
1003
1157
  break;
@@ -1016,6 +1170,72 @@ function registerAuth(program) {
1016
1170
  );
1017
1171
  })
1018
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
+ );
1019
1239
  program.command("logout").description("Remove the stored credential for the current API URL").action(
1020
1240
  handle(async (ctx) => {
1021
1241
  clearToken(ctx.apiUrl);
@@ -1023,35 +1243,53 @@ function registerAuth(program) {
1023
1243
  `));
1024
1244
  })
1025
1245
  );
1026
- 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(
1027
1248
  handle(async (ctx) => {
1028
- const token = ctx.token ?? loadToken(ctx.apiUrl);
1029
- if (!token) throw new CliError("Not authenticated. Run `myna login`.");
1030
- const identityResponse = await fetch(`${ctx.apiUrl}/v1/auth/whoami`, {
1031
- 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
+ `);
1032
1261
  });
1033
- const identityBody = await identityResponse.json().catch(() => ({}));
1034
- if (!identityResponse.ok || !identityBody.data) {
1035
- throw new CliError(identityBody.detail ?? "Stored credential is invalid. Run `myna login` again.");
1036
- }
1037
- 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);
1038
1267
  let organization;
1039
- if (ctx.organization) {
1268
+ if (status.credentialValid && ctx.organization) {
1040
1269
  organization = await ctx.management().organizations.get(ctx.organization).then((o) => ({ name: o.name, role: o.role, slug: o.slug })).catch((e) => {
1041
1270
  if (isMynaApiError(e)) return void 0;
1042
1271
  throw e;
1043
1272
  });
1044
1273
  }
1045
- emit({ apiUrl: ctx.apiUrl, authenticated: true, identity, organization: organization ?? null }, () => {
1046
- const pairs = [["API", ctx.apiUrl], ["Authenticated", "yes"]];
1047
- if (identity.credential === "user") {
1048
- pairs.push(["User", identity.user?.username ?? "(unknown)"]);
1049
- } else {
1050
- pairs.push(["Credential", identity.credential === "api_key" ? "API key" : "Preview token"]);
1051
- }
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)]);
1052
1281
  if (organization) pairs.push(["Organization", `${organization.name} (${organization.role})`]);
1053
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
+ }
1054
1291
  });
1292
+ if (!status.credentialValid) process.exitCode = 1;
1055
1293
  })
1056
1294
  );
1057
1295
  }
@@ -1152,22 +1390,26 @@ function registerWorkspace(program) {
1152
1390
  );
1153
1391
  program.command("status").description("Show the resolved configuration and project link").action(
1154
1392
  handle(async (ctx) => {
1155
- const hasToken = Boolean(ctx.token ?? loadToken(ctx.apiUrl));
1393
+ const credentialPresent = Boolean(ctx.token ?? loadToken(ctx.apiUrl));
1156
1394
  emit(
1157
1395
  {
1158
1396
  apiUrl: ctx.apiUrl,
1159
- authenticated: hasToken,
1397
+ credentialPresent,
1398
+ credentialSource: ctx.tokenSource,
1160
1399
  project: ctx.project ?? null,
1161
1400
  organization: ctx.organization ?? null,
1162
1401
  linkedRoot: ctx.linkedRoot ?? null
1163
1402
  },
1164
- () => keyValues([
1165
- ["API URL", ctx.apiUrl],
1166
- ["Authenticated", hasToken ? "yes" : "no"],
1167
- ["Project", ctx.project ?? "(none)"],
1168
- ["Organization", ctx.organization ?? "(none)"],
1169
- ["Linked at", ctx.linkedRoot ?? "(none)"]
1170
- ])
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
+ }
1171
1413
  );
1172
1414
  })
1173
1415
  );
@@ -1590,8 +1832,114 @@ function renderDiff(diff) {
1590
1832
  }
1591
1833
  }
1592
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
+
1593
1936
  // src/commands/entries.ts
1594
- import { readFile as readFile2 } from "fs/promises";
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
+ }
1595
1943
  function registerEntries(program) {
1596
1944
  const entries = program.command("entries").description("Create, read, update, and manage entries");
1597
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(
@@ -1722,22 +2070,40 @@ function registerEntries(program) {
1722
2070
  if (result.results.some((r) => !r.ok)) process.exitCode = 1;
1723
2071
  })
1724
2072
  );
1725
- entries.command("import").description("Import entries from a JSON file, with dry-run validation").argument("<file>", 'JSON file: { "collection": "posts", "entries": [{ "slug"?, "data" }] }').option("--dry-run", "validate without creating anything", false).action(
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(
1726
2074
  handle(async (ctx, args, opts) => {
1727
2075
  const project = ctx.requireProject();
1728
- const raw = JSON.parse(await readFile2(args[0], "utf8"));
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.");
1729
2090
  const result = await ctx.management().entries.import(project, {
1730
- collection: raw.collection,
1731
- entries: raw.entries,
1732
- dryRun: Boolean(opts.dryRun)
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
1733
2096
  });
1734
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`;
1735
2100
  diag(
1736
- result.dryRun ? `Dry run: ${result.valid ? "all rows valid" : "validation failed"}.` : `Imported ${result.results.filter((r) => r.ok).length}/${result.results.length} into change set ${result.changeSetId}.`
2101
+ result.dryRun ? `Dry run (${result.valid ? "would apply" : "blocked"}): ${counts}.` : `Imported into change set ${result.changeSetId}: ${counts}.`
1737
2102
  );
1738
- for (const row of result.results.filter((r) => !r.ok)) {
2103
+ for (const row of result.results.filter((r) => r.outcome === "invalid")) {
2104
+ const where = source.rows[row.index]?.source ?? `row ${row.index}`;
1739
2105
  for (const e of row.errors ?? []) {
1740
- process.stderr.write(` row ${row.index}${row.slug ? ` (${row.slug})` : ""}: ${e.path} ${e.message}
2106
+ process.stderr.write(` ${where}: ${e.path || "(entry)"} ${e.message}
1741
2107
  `);
1742
2108
  }
1743
2109
  }
@@ -1745,6 +2111,37 @@ function registerEntries(program) {
1745
2111
  if (!result.valid) process.exitCode = 1;
1746
2112
  })
1747
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
+ );
1748
2145
  entries.command("references").description("Show which entries reference this one and what it references").argument("<ref>", "collection/slug or entry id").action(
1749
2146
  handle(async (ctx, args) => {
1750
2147
  const project = ctx.requireProject();
@@ -2093,6 +2490,137 @@ function registerAssets(program) {
2093
2490
  );
2094
2491
  }
2095
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
+ mcpClient: "mcl",
2529
+ mcpGrant: "mgr",
2530
+ mcpAuthorizationCode: "mac",
2531
+ mcpToken: "mtk"
2532
+ };
2533
+ var PREFIX_SET = new Set(Object.values(ID_PREFIXES));
2534
+
2535
+ // ../shared/dist/dates.js
2536
+ var HOURS = 60 * 60;
2537
+ var DAYS = 24 * 60 * 60;
2538
+
2539
+ // ../shared/dist/origins.js
2540
+ function normalizeOrigin(value) {
2541
+ try {
2542
+ const url = new URL(value.trim());
2543
+ if (url.protocol !== "http:" && url.protocol !== "https:")
2544
+ return void 0;
2545
+ return url.origin;
2546
+ } catch {
2547
+ return void 0;
2548
+ }
2549
+ }
2550
+
2551
+ // ../shared/dist/plans.js
2552
+ var GB = 1024 ** 3;
2553
+ var MB = 1024 ** 2;
2554
+ var UNLIMITED = Number.MAX_SAFE_INTEGER;
2555
+ var PLANS = {
2556
+ free: {
2557
+ key: "free",
2558
+ priceEurMonthly: 0,
2559
+ priceEurYearly: 0,
2560
+ maxProjects: 2,
2561
+ maxMembers: 2,
2562
+ storageBytes: 1 * GB,
2563
+ monthlyBandwidthBytes: 25 * GB,
2564
+ monthlyPublicApiRequests: 1e4,
2565
+ maxWebhookEndpoints: 1,
2566
+ revisionRetentionDays: 30,
2567
+ maxUploadBytes: 50 * MB
2568
+ },
2569
+ pro: {
2570
+ key: "pro",
2571
+ priceEurMonthly: 9,
2572
+ priceEurYearly: 90,
2573
+ maxProjects: 10,
2574
+ maxMembers: 5,
2575
+ storageBytes: 10 * GB,
2576
+ monthlyBandwidthBytes: 250 * GB,
2577
+ monthlyPublicApiRequests: 5e5,
2578
+ maxWebhookEndpoints: 10,
2579
+ revisionRetentionDays: null,
2580
+ maxUploadBytes: 250 * MB
2581
+ },
2582
+ /**
2583
+ * Myna's own organizations — the changelog, and anything else we run on the
2584
+ * product we sell. It is never sold, never offered at checkout, and cannot be
2585
+ * reached through the API: `scripts/bootstrap-internal-org.mjs` writes it
2586
+ * directly, and billing refuses to move an organization on or off it.
2587
+ */
2588
+ internal: {
2589
+ key: "internal",
2590
+ priceEurMonthly: 0,
2591
+ priceEurYearly: null,
2592
+ maxProjects: UNLIMITED,
2593
+ maxMembers: UNLIMITED,
2594
+ storageBytes: UNLIMITED,
2595
+ monthlyBandwidthBytes: UNLIMITED,
2596
+ monthlyPublicApiRequests: UNLIMITED,
2597
+ maxWebhookEndpoints: UNLIMITED,
2598
+ revisionRetentionDays: null,
2599
+ maxUploadBytes: UNLIMITED
2600
+ }
2601
+ };
2602
+
2603
+ // ../shared/dist/rank.js
2604
+ var DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2605
+ var MIN_DIGIT = DIGITS[0];
2606
+ var LAST_INDEX = DIGITS.length - 1;
2607
+ var MID_DIGIT = DIGITS[Math.floor(DIGITS.length / 2)];
2608
+
2609
+ // ../shared/dist/release.js
2610
+ function parseSemVer(version) {
2611
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
2612
+ if (!match)
2613
+ return void 0;
2614
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
2615
+ }
2616
+ function compareSemVer(a, b) {
2617
+ const left = parseSemVer(a);
2618
+ const right = parseSemVer(b);
2619
+ if (!left || !right)
2620
+ throw new Error(`Cannot compare versions "${a}" and "${b}".`);
2621
+ return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
2622
+ }
2623
+
2096
2624
  // src/commands/admin.ts
2097
2625
  function csv(value) {
2098
2626
  return value.split(",").map((s) => s.trim()).filter(Boolean);
@@ -2100,6 +2628,7 @@ function csv(value) {
2100
2628
  function registerAdmin(program) {
2101
2629
  registerOrganizations(program);
2102
2630
  registerProjects(program);
2631
+ registerCors(program);
2103
2632
  registerKeys(program);
2104
2633
  registerMembers(program);
2105
2634
  registerWebhooks(program);
@@ -2221,6 +2750,56 @@ function registerProjects(program) {
2221
2750
  emit(project, () => diag(`Updated ${project.slug}.`));
2222
2751
  })
2223
2752
  );
2753
+ const origins = projects.command("origins").description("Manage allowed browser origins");
2754
+ origins.command("list").description("List the project's allowed browser origins").argument("[project]", "project id or slug").action(
2755
+ handle(async (ctx, args) => {
2756
+ const ref = args[0] ?? ctx.requireProject();
2757
+ const project = await ctx.management().projects.get(ref);
2758
+ emit(
2759
+ project.origins,
2760
+ () => table(
2761
+ project.origins.map((origin) => ({ origin })),
2762
+ [{ header: "ORIGIN", value: (r) => r.origin }]
2763
+ )
2764
+ );
2765
+ })
2766
+ );
2767
+ 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(
2768
+ handle(async (ctx, args, opts) => {
2769
+ const ref = opts.project ?? ctx.requireProject();
2770
+ const project = await ctx.management().projects.get(ref);
2771
+ const requested = args[0].map(normalizeOne);
2772
+ const next = [.../* @__PURE__ */ new Set([...project.origins, ...requested])];
2773
+ const added = requested.filter((o) => !project.origins.includes(o));
2774
+ if (added.length === 0) {
2775
+ emit({ origins: project.origins, added: [] }, () => diag("Already allowed; nothing to do."));
2776
+ return;
2777
+ }
2778
+ const updated = await ctx.management().projects.update(ref, { origins: next });
2779
+ emit(
2780
+ { origins: updated.origins, added },
2781
+ () => diag(`Allowed ${added.join(", ")} on ${updated.slug}.`)
2782
+ );
2783
+ })
2784
+ );
2785
+ origins.command("remove").description("Stop allowing one or more browser origins").argument("<origins...>", "origins to remove").option("--project <ref>", "project id or slug").action(
2786
+ handle(async (ctx, args, opts) => {
2787
+ const ref = opts.project ?? ctx.requireProject();
2788
+ const project = await ctx.management().projects.get(ref);
2789
+ const requested = args[0].map(normalizeOne);
2790
+ const next = project.origins.filter((o) => !requested.includes(o));
2791
+ const removed = project.origins.filter((o) => requested.includes(o));
2792
+ if (removed.length === 0) {
2793
+ emit({ origins: project.origins, removed: [] }, () => diag("Not configured; nothing to do."));
2794
+ return;
2795
+ }
2796
+ const updated = await ctx.management().projects.update(ref, { origins: next });
2797
+ emit(
2798
+ { origins: updated.origins, removed },
2799
+ () => diag(`Removed ${removed.join(", ")} from ${updated.slug}.`)
2800
+ );
2801
+ })
2802
+ );
2224
2803
  projects.command("archive").description("Archive a project").argument("[project]", "project id or slug").action(
2225
2804
  handle(async (ctx, args) => {
2226
2805
  const ref = args[0] ?? ctx.requireProject();
@@ -2229,6 +2808,35 @@ function registerProjects(program) {
2229
2808
  })
2230
2809
  );
2231
2810
  }
2811
+ function normalizeOne(value) {
2812
+ const normalized = normalizeOrigin(value);
2813
+ if (!normalized) throw new UsageError(`"${value}" is not a valid http(s) origin.`);
2814
+ return normalized;
2815
+ }
2816
+ function registerCors(program) {
2817
+ 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(
2818
+ handle(async (ctx, _args, opts) => {
2819
+ const ref = opts.project ?? ctx.requireProject();
2820
+ const result = await ctx.management().projects.corsCheck(ref, opts.origin, opts.collection);
2821
+ emit(result, () => {
2822
+ for (const c of result.checks) {
2823
+ process.stdout.write(`${c.ok ? "\u2713" : "\u2717"} ${c.detail}
2824
+ `);
2825
+ }
2826
+ if (!result.allowed) {
2827
+ const failed = result.checks.find((c) => !c.ok);
2828
+ if (failed?.id === "origin") {
2829
+ diag(`
2830
+ Allow it with: myna projects origins add ${result.origin}`);
2831
+ } else if (failed?.id === "publicApi") {
2832
+ diag("\nEnable it with: myna projects update --public-api on");
2833
+ }
2834
+ }
2835
+ });
2836
+ if (!result.allowed) process.exitCode = 1;
2837
+ })
2838
+ );
2839
+ }
2232
2840
  function registerKeys(program) {
2233
2841
  const keys = program.command("keys").description("Manage API keys");
2234
2842
  keys.command("list").description("List API keys for the project (or organization with --org-scope)").option("--org-scope", "list organization-scoped keys", false).action(
@@ -2307,7 +2915,12 @@ function registerMembers(program) {
2307
2915
  email: opts.email,
2308
2916
  role: opts.role
2309
2917
  });
2310
- emit(invite, () => diag(`Invited ${invite.email} as ${invite.role}.`));
2918
+ emit(
2919
+ invite,
2920
+ () => diag(
2921
+ `Invited ${invite.email} as ${invite.role}. An email with the accept link is on its way; it expires ${invite.expiresAt}.`
2922
+ )
2923
+ );
2311
2924
  })
2312
2925
  );
2313
2926
  members.command("update").description("Update a member's role").argument("<user>", "user id").requiredOption("--role <role>", "new role").action(
@@ -2462,8 +3075,498 @@ function registerBilling(program) {
2462
3075
  );
2463
3076
  }
2464
3077
 
3078
+ // src/commands/doctor.ts
3079
+ import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3080
+ import { join as join6 } from "path";
3081
+
3082
+ // src/version.ts
3083
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
3084
+
3085
+ // src/commands/doctor.ts
3086
+ var NPM_REGISTRY = "https://registry.npmjs.org";
3087
+ var SCAN_IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".react-router", ".turbo", "coverage"]);
3088
+ var GENERATED_MARKERS = [
3089
+ "// Generated by `myna types generate`. Do not edit by hand.",
3090
+ "export interface MynaCollections {"
3091
+ ];
3092
+ function looksGenerated(contents) {
3093
+ return GENERATED_MARKERS.every((marker) => contents.includes(marker));
3094
+ }
3095
+ function check(id, title, status, detail, remedy) {
3096
+ return remedy ? { id, title, status, detail, remedy } : { id, title, status, detail };
3097
+ }
3098
+ async function latestPublished(pkg) {
3099
+ try {
3100
+ const res = await fetch(`${NPM_REGISTRY}/${pkg}/latest`, {
3101
+ headers: { accept: "application/json" },
3102
+ signal: AbortSignal.timeout(5e3)
3103
+ });
3104
+ if (!res.ok) return void 0;
3105
+ const body = await res.json();
3106
+ return body.version;
3107
+ } catch {
3108
+ return void 0;
3109
+ }
3110
+ }
3111
+ async function checkCliVersion() {
3112
+ if (!parseSemVer(VERSION)) {
3113
+ return check(
3114
+ "cli.version",
3115
+ "CLI version",
3116
+ "warn",
3117
+ `Running an unreleased build (${VERSION}).`,
3118
+ "Install a published release: npm install -g @myna-sh/cli"
3119
+ );
3120
+ }
3121
+ const latest = await latestPublished("@myna-sh/cli");
3122
+ if (!latest) {
3123
+ return check("cli.version", "CLI version", "skip", `${VERSION} (could not reach npm to compare).`);
3124
+ }
3125
+ const behind = compareSemVer(VERSION, latest) < 0;
3126
+ return behind ? check(
3127
+ "cli.version",
3128
+ "CLI version",
3129
+ "warn",
3130
+ `${VERSION} is behind the latest release ${latest}.`,
3131
+ `npm install -g @myna-sh/cli@${latest}`
3132
+ ) : check("cli.version", "CLI version", "pass", `${VERSION} is current.`);
3133
+ }
3134
+ function checkApi(meta, apiUrl) {
3135
+ if (!meta) {
3136
+ return [
3137
+ check(
3138
+ "api.reachable",
3139
+ "API reachable",
3140
+ "fail",
3141
+ `No capability descriptor from ${apiUrl}/v1/meta.`,
3142
+ "Check --api-url / MYNA_API_URL and network access. An API older than 0.2.0 does not serve /v1/meta."
3143
+ )
3144
+ ];
3145
+ }
3146
+ const checks = [
3147
+ check("api.reachable", "API reachable", "pass", `${apiUrl} speaks API ${meta.apiVersion}.`)
3148
+ ];
3149
+ if (!parseSemVer(VERSION)) {
3150
+ checks.push(
3151
+ check("api.compatibility", "Client compatibility", "skip", "Unreleased CLI build; nothing to compare.")
3152
+ );
3153
+ return checks;
3154
+ }
3155
+ const supported = compareSemVer(VERSION, meta.minClientVersion) >= 0;
3156
+ checks.push(
3157
+ supported ? check(
3158
+ "api.compatibility",
3159
+ "Client compatibility",
3160
+ "pass",
3161
+ `CLI ${VERSION} meets the minimum supported client ${meta.minClientVersion}.`
3162
+ ) : check(
3163
+ "api.compatibility",
3164
+ "Client compatibility",
3165
+ "fail",
3166
+ `CLI ${VERSION} is older than the minimum client ${meta.minClientVersion} this API supports.`,
3167
+ `npm install -g @myna-sh/cli@latest`
3168
+ )
3169
+ );
3170
+ return checks;
3171
+ }
3172
+ async function checkAuth(ctx) {
3173
+ const status = await checkCredential(ctx.apiUrl, ctx.token, ctx.tokenSource);
3174
+ if (!status.credentialPresent) {
3175
+ return [
3176
+ check("auth.credential", "Credential", "fail", "No credential found.", "myna login")
3177
+ ];
3178
+ }
3179
+ if (!status.credentialValid) {
3180
+ return [
3181
+ check(
3182
+ "auth.credential",
3183
+ "Credential",
3184
+ "fail",
3185
+ // The distinction the old `whoami` collapsed.
3186
+ `A credential is present (from ${status.source}) but the API rejected it: ${status.error?.detail ?? "unknown reason"}.`,
3187
+ status.error?.code === "UNREACHABLE" ? void 0 : "myna login"
3188
+ )
3189
+ ];
3190
+ }
3191
+ const checks = [
3192
+ check("auth.credential", "Credential", "pass", `Valid \u2014 ${describeIdentity(status.identity)}.`)
3193
+ ];
3194
+ const scopes = scopesOf(status.identity);
3195
+ if (scopes) {
3196
+ checks.push(
3197
+ scopes.length > 0 ? check("auth.scopes", "Key scopes", "pass", scopes.join(", ")) : check(
3198
+ "auth.scopes",
3199
+ "Key scopes",
3200
+ "fail",
3201
+ "The key carries no scopes, so every authorized call will be denied.",
3202
+ "Mint a scoped key: myna keys create --scopes content:read,content:write"
3203
+ )
3204
+ );
3205
+ }
3206
+ return checks;
3207
+ }
3208
+ async function checkProject(ctx) {
3209
+ if (!ctx.project) {
3210
+ return [
3211
+ check(
3212
+ "project.link",
3213
+ "Project",
3214
+ "warn",
3215
+ "No project selected.",
3216
+ "myna link --project <project>, or pass --project"
3217
+ )
3218
+ ];
3219
+ }
3220
+ try {
3221
+ const project = await ctx.management().projects.get(ctx.project);
3222
+ const where = ctx.linkedRoot ? ` (linked at ${ctx.linkedRoot})` : "";
3223
+ return [
3224
+ check("project.link", "Project", "pass", `${project.slug}${where}.`),
3225
+ project.publicApiEnabled ? check("project.publicApi", "Public API", "pass", "Enabled.") : check(
3226
+ "project.publicApi",
3227
+ "Public API",
3228
+ "warn",
3229
+ "Disabled \u2014 browser and unauthenticated reads will 404.",
3230
+ "myna projects update --public-api"
3231
+ )
3232
+ ];
3233
+ } catch (error) {
3234
+ const detail = isMynaApiError(error) ? `${error.code} \u2014 ${error.detail ?? error.title}` : error instanceof Error ? error.message : String(error);
3235
+ return [
3236
+ check(
3237
+ "project.link",
3238
+ "Project",
3239
+ "fail",
3240
+ `Cannot read project "${ctx.project}": ${detail}`,
3241
+ "Check the project ref and that your credential has access to it."
3242
+ )
3243
+ ];
3244
+ }
3245
+ }
3246
+ async function checkOrigin(ctx, origin) {
3247
+ const canonical = normalizeOrigin(origin);
3248
+ if (!canonical) {
3249
+ return check("origins", "Browser origin", "fail", `"${origin}" is not a valid http(s) origin.`);
3250
+ }
3251
+ if (!ctx.project) {
3252
+ return check("origins", "Browser origin", "skip", "No project selected.");
3253
+ }
3254
+ try {
3255
+ const project = await ctx.management().projects.get(ctx.project);
3256
+ const allowed = project.origins.some((o) => normalizeOrigin(o) === canonical);
3257
+ return allowed ? check("origins", "Browser origin", "pass", `${canonical} is allowed on ${project.slug}.`) : check(
3258
+ "origins",
3259
+ "Browser origin",
3260
+ "fail",
3261
+ `${canonical} is not allowed on ${project.slug}. A browser will discard responses from it. Configured: ${project.origins.length > 0 ? project.origins.join(", ") : "(none)"}`,
3262
+ `myna projects update --origins ${[...project.origins, canonical].join(",")}`
3263
+ );
3264
+ } catch {
3265
+ return check("origins", "Browser origin", "skip", "Could not read the project's origins.");
3266
+ }
3267
+ }
3268
+ async function checkSchema(ctx, schemaDir) {
3269
+ const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3270
+ if (!existsSync4(dir)) {
3271
+ return check("schema.drift", "Local schema", "skip", `No schema directory at ${dir}.`);
3272
+ }
3273
+ if (!ctx.project) {
3274
+ return check("schema.drift", "Local schema", "skip", "No project selected.");
3275
+ }
3276
+ let local;
3277
+ try {
3278
+ local = await loadLocalSchemas(dir);
3279
+ } catch (error) {
3280
+ return check(
3281
+ "schema.drift",
3282
+ "Local schema",
3283
+ "fail",
3284
+ `Could not load schemas from ${dir}: ${error instanceof Error ? error.message : String(error)}`
3285
+ );
3286
+ }
3287
+ if (local.length === 0) {
3288
+ return check("schema.drift", "Local schema", "skip", `No collections defined in ${dir}.`);
3289
+ }
3290
+ try {
3291
+ const diff = await ctx.management().schema.diff(ctx.project, local);
3292
+ return diff.ops.length === 0 ? check("schema.drift", "Local schema", "pass", `${local.length} collection(s) match the deployed schema.`) : check(
3293
+ "schema.drift",
3294
+ "Local schema",
3295
+ "warn",
3296
+ `${diff.ops.length} undeployed change(s) (${diff.classification}).`,
3297
+ "myna schema diff, then myna schema push"
3298
+ );
3299
+ } catch (error) {
3300
+ return check(
3301
+ "schema.drift",
3302
+ "Local schema",
3303
+ "skip",
3304
+ `Could not diff against the deployed schema: ${error instanceof Error ? error.message : String(error)}`
3305
+ );
3306
+ }
3307
+ }
3308
+ function findGeneratedTypes(root, depth = 4) {
3309
+ let entries;
3310
+ try {
3311
+ entries = readdirSync2(root);
3312
+ } catch {
3313
+ return void 0;
3314
+ }
3315
+ const dirs = [];
3316
+ for (const entry of entries) {
3317
+ if (SCAN_IGNORE.has(entry) || entry.startsWith(".")) continue;
3318
+ const full = join6(root, entry);
3319
+ let stats;
3320
+ try {
3321
+ stats = statSync2(full);
3322
+ } catch {
3323
+ continue;
3324
+ }
3325
+ if (stats.isDirectory()) {
3326
+ dirs.push(full);
3327
+ continue;
3328
+ }
3329
+ if (!/\.(ts|d\.ts)$/.test(entry)) continue;
3330
+ try {
3331
+ if (looksGenerated(readFileSync3(full, "utf8"))) return full;
3332
+ } catch {
3333
+ }
3334
+ }
3335
+ if (depth <= 0) return void 0;
3336
+ for (const dir of dirs) {
3337
+ const found = findGeneratedTypes(dir, depth - 1);
3338
+ if (found) return found;
3339
+ }
3340
+ return void 0;
3341
+ }
3342
+ async function checkTypes(ctx, explicit, schemaDir) {
3343
+ const root = ctx.linkedRoot ?? process.cwd();
3344
+ const file = explicit ?? findGeneratedTypes(root);
3345
+ if (!file) {
3346
+ return check("types.freshness", "Generated types", "skip", "No generated types file found.");
3347
+ }
3348
+ if (!existsSync4(file)) {
3349
+ return check("types.freshness", "Generated types", "fail", `${file} does not exist.`);
3350
+ }
3351
+ const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3352
+ if (!existsSync4(dir)) {
3353
+ return check("types.freshness", "Generated types", "skip", `Found ${file} but no schema directory to compare against.`);
3354
+ }
3355
+ try {
3356
+ const expected = generateTypesModule(await loadLocalSchemas(dir));
3357
+ return readFileSync3(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
3358
+ "types.freshness",
3359
+ "Generated types",
3360
+ "warn",
3361
+ `${file} is out of date with the local schema.`,
3362
+ `myna types generate --out ${file}`
3363
+ );
3364
+ } catch (error) {
3365
+ return check(
3366
+ "types.freshness",
3367
+ "Generated types",
3368
+ "skip",
3369
+ `Could not regenerate types for comparison: ${error instanceof Error ? error.message : String(error)}`
3370
+ );
3371
+ }
3372
+ }
3373
+ var ICON = { pass: "\u2713", warn: "!", fail: "\u2717", skip: "\u2013" };
3374
+ function registerDoctor(program) {
3375
+ 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(
3376
+ handle(async (ctx, _args, opts) => {
3377
+ const checks = [];
3378
+ checks.push(await checkCliVersion());
3379
+ const meta = await fetchMeta(ctx.apiUrl);
3380
+ checks.push(...checkApi(meta, ctx.apiUrl));
3381
+ const auth = await checkAuth(ctx);
3382
+ checks.push(...auth);
3383
+ const authed = auth[0]?.status === "pass";
3384
+ if (authed) {
3385
+ checks.push(...await checkProject(ctx));
3386
+ checks.push(await checkSchema(ctx, opts.schemaDir));
3387
+ checks.push(await checkTypes(ctx, opts.types, opts.schemaDir));
3388
+ if (opts.origin) checks.push(await checkOrigin(ctx, opts.origin));
3389
+ } else {
3390
+ const deferred = [
3391
+ ["project.link", "Project"],
3392
+ ["schema.drift", "Local schema"],
3393
+ ["types.freshness", "Generated types"]
3394
+ ];
3395
+ for (const [id, title] of deferred) {
3396
+ checks.push(check(id, title, "skip", "Requires a valid credential."));
3397
+ }
3398
+ }
3399
+ const failed = checks.filter((c) => c.status === "fail").length;
3400
+ const warned = checks.filter((c) => c.status === "warn").length;
3401
+ emit({ ok: failed === 0, failed, warned, checks }, () => {
3402
+ for (const c of checks) {
3403
+ process.stdout.write(`${ICON[c.status]} ${c.title}: ${c.detail}
3404
+ `);
3405
+ if (c.remedy && c.status !== "pass") process.stdout.write(` \u2192 ${c.remedy}
3406
+ `);
3407
+ }
3408
+ diag(
3409
+ failed === 0 ? warned === 0 ? "\nAll checks passed." : `
3410
+ ${warned} warning(s), no failures.` : `
3411
+ ${failed} failure(s), ${warned} warning(s).`
3412
+ );
3413
+ });
3414
+ if (failed > 0) process.exitCode = 1;
3415
+ })
3416
+ );
3417
+ }
3418
+
3419
+ // src/commands/sync.ts
3420
+ import { readdir as readdir2, stat as stat2 } from "fs/promises";
3421
+ import { join as join7 } from "path";
3422
+ async function isDirectory(path) {
3423
+ const info = await stat2(path).catch(() => void 0);
3424
+ return Boolean(info?.isDirectory());
3425
+ }
3426
+ async function planDirectories(dir, collection) {
3427
+ if (!await isDirectory(dir)) {
3428
+ if (!collection) throw new UsageError("Syncing a single file needs --collection.");
3429
+ return [{ collection, path: dir }];
3430
+ }
3431
+ if (collection) return [{ collection, path: dir }];
3432
+ const children = await readdir2(dir);
3433
+ const plan = [];
3434
+ for (const name of children.sort()) {
3435
+ if (name.startsWith(".")) continue;
3436
+ const full = join7(dir, name);
3437
+ if (await isDirectory(full)) plan.push({ collection: name, path: full });
3438
+ }
3439
+ if (plan.length === 0) {
3440
+ throw new UsageError(
3441
+ `No collection subdirectories in ${dir}. Pass --collection to sync it as one collection.`
3442
+ );
3443
+ }
3444
+ return plan;
3445
+ }
3446
+ function registerSync(program) {
3447
+ 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(
3448
+ handle(async (ctx, args, opts) => {
3449
+ const project = ctx.requireProject();
3450
+ const dryRun = Boolean(opts.dryRun);
3451
+ const deleteMissing = Boolean(opts.deleteMissing);
3452
+ if (deleteMissing && !opts.confirmDelete && !dryRun) {
3453
+ throw new UsageError("--delete-missing removes published content. Add --confirm-delete, or use --dry-run.");
3454
+ }
3455
+ const plan = await planDirectories(args[0], opts.collection);
3456
+ const mgmt = ctx.management();
3457
+ let changeSetId = opts.changeSet;
3458
+ const report = [];
3459
+ let failed = false;
3460
+ for (const target of plan) {
3461
+ const source = await readContentRows(target.path, {
3462
+ format: parseFormat2(opts.format),
3463
+ bodyField: opts.bodyField
3464
+ });
3465
+ if (source.rows.length === 0) continue;
3466
+ const result = await mgmt.entries.import(project, {
3467
+ collection: target.collection,
3468
+ entries: source.rows.map((r) => r.slug ? { slug: r.slug, data: r.data } : { data: r.data }),
3469
+ dryRun,
3470
+ changeSetId,
3471
+ mode: "upsert"
3472
+ });
3473
+ if (!result.valid) {
3474
+ failed = true;
3475
+ for (const row of result.results.filter((r) => r.outcome === "invalid")) {
3476
+ const where = source.rows[row.index]?.source ?? `row ${row.index}`;
3477
+ for (const e of row.errors ?? []) {
3478
+ process.stderr.write(` ${where}: ${e.path || "(entry)"} ${e.message}
3479
+ `);
3480
+ }
3481
+ }
3482
+ }
3483
+ changeSetId ??= result.changeSetId ?? void 0;
3484
+ let deleted = 0;
3485
+ let deletedSlugs = [];
3486
+ if (deleteMissing && result.valid) {
3487
+ const staged = await stageMissingDeletions(
3488
+ ctx,
3489
+ project,
3490
+ target.collection,
3491
+ new Set(source.rows.map((r) => r.slug).filter((s) => Boolean(s))),
3492
+ changeSetId,
3493
+ dryRun
3494
+ );
3495
+ deleted = staged.count;
3496
+ deletedSlugs = staged.slugs;
3497
+ changeSetId ??= staged.changeSetId;
3498
+ }
3499
+ report.push({
3500
+ collection: target.collection,
3501
+ created: result.summary.created,
3502
+ updated: result.summary.updated,
3503
+ unchanged: result.summary.unchanged,
3504
+ invalid: result.summary.invalid,
3505
+ deleted,
3506
+ deletedSlugs
3507
+ });
3508
+ }
3509
+ emit({ dryRun, changeSetId: changeSetId ?? null, collections: report, ok: !failed }, () => {
3510
+ table(report, [
3511
+ { header: "COLLECTION", value: (r) => r.collection },
3512
+ { header: "CREATED", value: (r) => String(r.created) },
3513
+ { header: "UPDATED", value: (r) => String(r.updated) },
3514
+ { header: "UNCHANGED", value: (r) => String(r.unchanged) },
3515
+ { header: "DELETED", value: (r) => String(r.deleted) },
3516
+ { header: "INVALID", value: (r) => String(r.invalid) }
3517
+ ]);
3518
+ const removals = report.flatMap((r) => r.deletedSlugs.map((slug) => `${r.collection}/${slug}`));
3519
+ if (removals.length > 0) {
3520
+ diag(`
3521
+ ${dryRun ? "Would stage" : "Staged"} deletion of ${removals.length} entr(y/ies) with no file:`);
3522
+ for (const slug of removals) diag(` \u2212 ${slug}`);
3523
+ }
3524
+ if (dryRun) {
3525
+ diag("\nDry run \u2014 nothing was written.");
3526
+ } else if (changeSetId) {
3527
+ diag(`
3528
+ Staged on change set ${changeSetId}. Review and publish it to go live.`);
3529
+ } else {
3530
+ diag("\nNothing to do \u2014 every file already matches.");
3531
+ }
3532
+ });
3533
+ if (failed) process.exitCode = 1;
3534
+ })
3535
+ );
3536
+ }
3537
+ async function stageMissingDeletions(ctx, project, collection, presentSlugs, changeSetId, dryRun) {
3538
+ const mgmt = ctx.management();
3539
+ const missing = [];
3540
+ let cursor;
3541
+ do {
3542
+ const page = await mgmt.entries.list(project, { collection, limit: 100, cursor });
3543
+ for (const entry of page.data) {
3544
+ if (entry.slug && !presentSlugs.has(entry.slug)) missing.push({ id: entry.id, slug: entry.slug });
3545
+ }
3546
+ cursor = page.nextCursor ?? void 0;
3547
+ } while (cursor);
3548
+ const slugs = missing.map((m) => m.slug).sort();
3549
+ if (missing.length === 0 || dryRun) return { count: missing.length, slugs, changeSetId };
3550
+ const result = await mgmt.entries.bulk(project, {
3551
+ action: "delete",
3552
+ entryIds: missing.map((m) => m.id),
3553
+ changeSetId
3554
+ });
3555
+ const succeeded = result.results.filter((r) => r.ok);
3556
+ return {
3557
+ count: succeeded.length,
3558
+ slugs,
3559
+ changeSetId: changeSetId ?? succeeded.find((r) => r.changeSetId)?.changeSetId
3560
+ };
3561
+ }
3562
+ function parseFormat2(value) {
3563
+ if (!value) return void 0;
3564
+ if (value === "json") return "json";
3565
+ if (value === "markdown-frontmatter" || value === "markdown" || value === "md") return "markdown";
3566
+ throw new UsageError("--format must be json or markdown-frontmatter.");
3567
+ }
3568
+
2465
3569
  // src/main.ts
2466
- var VERSION = "0.2.0";
2467
3570
  var GLOBAL_VALUE_FLAGS = /* @__PURE__ */ new Set(["--project", "--organization", "--token", "--api-url"]);
2468
3571
  var GLOBAL_BOOL_FLAGS = /* @__PURE__ */ new Set(["--json", "--no-interactive", "--interactive"]);
2469
3572
  function normalizeGlobals(argv) {
@@ -2493,9 +3596,11 @@ function buildProgram() {
2493
3596
  const program = new Command();
2494
3597
  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();
2495
3598
  registerAuth(program);
3599
+ registerDoctor(program);
2496
3600
  registerWorkspace(program);
2497
3601
  registerSchema(program);
2498
3602
  registerEntries(program);
3603
+ registerSync(program);
2499
3604
  registerChanges(program);
2500
3605
  registerPreviews(program);
2501
3606
  registerAssets(program);
@@ -2522,6 +3627,7 @@ async function main(argv = process.argv) {
2522
3627
  }
2523
3628
  }
2524
3629
  export {
3630
+ VERSION,
2525
3631
  buildProgram,
2526
3632
  main
2527
3633
  };