@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/README.md +29 -0
- package/dist/main.d.ts +3 -1
- package/dist/main.js +1185 -79
- package/dist/main.js.map +1 -1
- package/package.json +4 -3
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/main.ts
|
|
2
2
|
import { Command, CommanderError } from "commander";
|
|
3
3
|
|
|
4
|
-
// ../sdk/dist/chunk-
|
|
4
|
+
// ../sdk/dist/chunk-5WMWKWGV.js
|
|
5
5
|
var MynaApiError = class _MynaApiError extends Error {
|
|
6
6
|
/** HTTP status code. */
|
|
7
7
|
status;
|
|
@@ -52,6 +52,15 @@ function problemFromResponse(status, body, fallbackTitle) {
|
|
|
52
52
|
detail
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
+
function inBrowser() {
|
|
56
|
+
return typeof globalThis === "object" && "document" in globalThis;
|
|
57
|
+
}
|
|
58
|
+
function defaultMaxRetries() {
|
|
59
|
+
return inBrowser() ? 1 : 3;
|
|
60
|
+
}
|
|
61
|
+
function defaultMaxNetworkRetries() {
|
|
62
|
+
return inBrowser() ? 0 : 1;
|
|
63
|
+
}
|
|
55
64
|
var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
56
65
|
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
57
66
|
function resolveFetch(custom) {
|
|
@@ -59,8 +68,8 @@ function resolveFetch(custom) {
|
|
|
59
68
|
if (typeof globalThis.fetch === "function") return globalThis.fetch.bind(globalThis);
|
|
60
69
|
throw new Error("No fetch implementation available. Pass `fetch` to the Myna client.");
|
|
61
70
|
}
|
|
62
|
-
function buildQuery(
|
|
63
|
-
if (!
|
|
71
|
+
function buildQuery(query) {
|
|
72
|
+
if (!query) return "";
|
|
64
73
|
const params = new URLSearchParams();
|
|
65
74
|
const append = (key, value) => {
|
|
66
75
|
if (value === void 0 || value === null) return;
|
|
@@ -76,7 +85,7 @@ function buildQuery(query2) {
|
|
|
76
85
|
}
|
|
77
86
|
params.set(key, String(value));
|
|
78
87
|
};
|
|
79
|
-
for (const [key, value] of Object.entries(
|
|
88
|
+
for (const [key, value] of Object.entries(query)) append(key, value);
|
|
80
89
|
const qs = params.toString();
|
|
81
90
|
return qs ? `?${qs}` : "";
|
|
82
91
|
}
|
|
@@ -106,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 ??
|
|
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
|
|
176
|
+
const upper = method.toUpperCase();
|
|
177
|
+
const safe = SAFE_METHODS.has(upper);
|
|
178
|
+
const idempotent = safe || Boolean(options.idempotencyKey);
|
|
147
179
|
const init = { method, headers };
|
|
148
180
|
if (payload !== void 0) init.body = payload;
|
|
149
181
|
if (options.signal) init.signal = options.signal;
|
|
182
|
+
if (safe && !options.signal && (upper === "GET" || upper === "HEAD")) {
|
|
183
|
+
const key = `${upper} ${url} ${token ?? ""}`;
|
|
184
|
+
return this.dedupe(key, () => this.attempt(url, init, idempotent, options));
|
|
185
|
+
}
|
|
186
|
+
return this.attempt(url, init, idempotent, options);
|
|
187
|
+
}
|
|
188
|
+
async attempt(url, init, idempotent, options) {
|
|
150
189
|
let attempt = 0;
|
|
190
|
+
let networkAttempt = 0;
|
|
151
191
|
for (; ; ) {
|
|
152
192
|
let response;
|
|
153
193
|
try {
|
|
154
194
|
response = await this.fetchImpl(url, init);
|
|
155
195
|
} catch (error) {
|
|
156
|
-
if (idempotent && attempt < this.retry.maxRetries && !isAbort(error)) {
|
|
196
|
+
if (idempotent && networkAttempt < this.retry.maxNetworkRetries && attempt < this.retry.maxRetries && !isAbort(error)) {
|
|
157
197
|
await sleep(this.backoff(attempt), options.signal);
|
|
158
198
|
attempt++;
|
|
199
|
+
networkAttempt++;
|
|
159
200
|
continue;
|
|
160
201
|
}
|
|
161
202
|
throw error;
|
|
@@ -196,7 +237,7 @@ function generateTypes(collections) {
|
|
|
196
237
|
for (const collection of sorted) {
|
|
197
238
|
blocks.push(renderInterface(collection));
|
|
198
239
|
}
|
|
199
|
-
const registry = sorted.map((c) =>
|
|
240
|
+
const registry = sorted.map((c) => registryEntry(c)).join("\n");
|
|
200
241
|
const header = [
|
|
201
242
|
"// Generated by `myna types generate`. Do not edit by hand.",
|
|
202
243
|
"// This file is safe to commit and regenerate.",
|
|
@@ -204,11 +245,33 @@ function generateTypes(collections) {
|
|
|
204
245
|
].join("\n");
|
|
205
246
|
return `${header}${blocks.join("\n\n")}
|
|
206
247
|
|
|
248
|
+
${REGISTRY_DOC}
|
|
207
249
|
export interface MynaCollections {
|
|
208
250
|
${registry}
|
|
209
251
|
}
|
|
210
252
|
`;
|
|
211
253
|
}
|
|
254
|
+
var REGISTRY_DOC = [
|
|
255
|
+
"/**",
|
|
256
|
+
" * Collection registry consumed by `createMyna<MynaCollections>()`.",
|
|
257
|
+
" *",
|
|
258
|
+
" * `fields` types the entry payload; `sortable` and `reference` carry the",
|
|
259
|
+
" * distinctions the field types alone cannot express \u2014 a markdown body and a",
|
|
260
|
+
" * text title are both `string`, but only one of them can be ordered on.",
|
|
261
|
+
" */"
|
|
262
|
+
].join("\n");
|
|
263
|
+
var SORTABLE_TYPES = /* @__PURE__ */ new Set(["text", "number", "boolean", "date", "datetime", "slug"]);
|
|
264
|
+
function registryEntry(collection) {
|
|
265
|
+
const sortable = collection.fields.filter((f) => SORTABLE_TYPES.has(f.type) && !f.localized).map((f) => JSON.stringify(f.key));
|
|
266
|
+
const references = collection.fields.filter((f) => f.type === "reference").map((f) => JSON.stringify(f.key));
|
|
267
|
+
return [
|
|
268
|
+
` ${JSON.stringify(collection.name)}: {`,
|
|
269
|
+
` fields: ${pascal(collection.name)};`,
|
|
270
|
+
` sortable: ${sortable.length > 0 ? sortable.join(" | ") : "never"};`,
|
|
271
|
+
` reference: ${references.length > 0 ? references.join(" | ") : "never"};`,
|
|
272
|
+
` };`
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
212
275
|
function renderInterface(collection) {
|
|
213
276
|
const name = pascal(collection.name);
|
|
214
277
|
const lines = collection.fields.map((f) => renderField(f, 1));
|
|
@@ -348,19 +411,25 @@ var ManagementClient = class {
|
|
|
348
411
|
idem() {
|
|
349
412
|
return this.newIdempotencyKey();
|
|
350
413
|
}
|
|
351
|
-
get(path,
|
|
352
|
-
return this.http.request("GET", path, { query
|
|
414
|
+
get(path, query, signal) {
|
|
415
|
+
return this.http.request("GET", path, { query, signal });
|
|
353
416
|
}
|
|
354
|
-
|
|
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,
|
|
363
|
-
return this.http.request(method, path, { body, query
|
|
431
|
+
mutate(method, path, body, query) {
|
|
432
|
+
return this.http.request(method, path, { body, query, idempotencyKey: this.idem() });
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1262
|
+
})
|
|
1263
|
+
);
|
|
1264
|
+
program.command("whoami").description("Show the authenticated identity, validating it against the API").action(
|
|
1265
|
+
handle(async (ctx) => {
|
|
1266
|
+
const status = await checkCredential(ctx.apiUrl, ctx.token ?? loadToken(ctx.apiUrl), ctx.tokenSource);
|
|
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({
|
|
1046
|
-
const pairs = [
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
|
1393
|
+
const credentialPresent = Boolean(ctx.token ?? loadToken(ctx.apiUrl));
|
|
1156
1394
|
emit(
|
|
1157
1395
|
{
|
|
1158
1396
|
apiUrl: ctx.apiUrl,
|
|
1159
|
-
|
|
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
|
-
() =>
|
|
1165
|
-
[
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1731
|
-
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
|
|
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) =>
|
|
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(`
|
|
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(
|
|
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
|
};
|