@myna-sh/cli 0.12.2 → 0.14.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 +25 -0
- package/dist/main.js +1366 -15
- package/dist/main.js.map +1 -1
- package/package.json +4 -4
package/dist/main.js
CHANGED
|
@@ -431,6 +431,45 @@ var ManagementClient = class {
|
|
|
431
431
|
mutate(method, path, body, query) {
|
|
432
432
|
return this.http.request(method, path, { body, query, idempotencyKey: this.idem() });
|
|
433
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Escape hatch: call any management endpoint, wrapped or not.
|
|
436
|
+
*
|
|
437
|
+
* The typed methods above cover the API, but never all of it at once — a
|
|
438
|
+
* route ships before its wrapper, or a caller needs a header the wrapper does
|
|
439
|
+
* not take. Without a documented way around the client, that caller hand-rolls
|
|
440
|
+
* `fetch` and loses everything this class provides: credential handling,
|
|
441
|
+
* retry policy, and RFC 9457 errors thrown as `MynaApiError` rather than left
|
|
442
|
+
* as an opaque body to parse. The escape hatch keeps all of it.
|
|
443
|
+
*
|
|
444
|
+
* `path` is version-relative (`/projects/x`), and a leading `/v1` is accepted
|
|
445
|
+
* and stripped — this is the one method whose paths are written by hand, and
|
|
446
|
+
* every example a caller copies from the docs carries the prefix.
|
|
447
|
+
*
|
|
448
|
+
* Unlike the typed mutations, no idempotency key is generated: sending one
|
|
449
|
+
* makes a request retry-eligible, and only the caller knows whether replaying
|
|
450
|
+
* this particular one is safe. Pass `idempotencyKey` to opt in.
|
|
451
|
+
*
|
|
452
|
+
* Returns the response envelope untouched — `{ data, pagination }` and all —
|
|
453
|
+
* because an escape hatch that reshapes the response is not one.
|
|
454
|
+
*/
|
|
455
|
+
async raw(method, path, options = {}) {
|
|
456
|
+
const relative = path.startsWith("/v1/") ? path.slice(3) : path.startsWith("/") ? path : `/${path}`;
|
|
457
|
+
const response = await this.http.requestRaw(method, relative, options);
|
|
458
|
+
const headers = {};
|
|
459
|
+
response.headers.forEach((value, key) => {
|
|
460
|
+
headers[key] = value;
|
|
461
|
+
});
|
|
462
|
+
if (response.status === 204 || response.status === 304) {
|
|
463
|
+
return { status: response.status, headers, body: void 0 };
|
|
464
|
+
}
|
|
465
|
+
const text = await response.text();
|
|
466
|
+
if (!text) return { status: response.status, headers, body: void 0 };
|
|
467
|
+
try {
|
|
468
|
+
return { status: response.status, headers, body: JSON.parse(text) };
|
|
469
|
+
} catch {
|
|
470
|
+
return { status: response.status, headers, body: text };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
434
473
|
// --- Organizations --------------------------------------------------------
|
|
435
474
|
organizations = {
|
|
436
475
|
list: (signal) => this.get("/organizations", void 0, signal),
|
|
@@ -634,6 +673,147 @@ var ManagementClient = class {
|
|
|
634
673
|
body
|
|
635
674
|
)
|
|
636
675
|
};
|
|
676
|
+
// --- Feedback: boards -----------------------------------------------------
|
|
677
|
+
/**
|
|
678
|
+
* Boards collect reports. A board's `formFields` is its intake form, declared
|
|
679
|
+
* with the same field DSL as a collection schema — so a QA board that needs a
|
|
680
|
+
* build number says so there, and gets the same validation as anything else
|
|
681
|
+
* in Myna.
|
|
682
|
+
*/
|
|
683
|
+
boards = {
|
|
684
|
+
list: (project, signal) => this.get(`/projects/${enc(project)}/boards`, void 0, signal),
|
|
685
|
+
get: (project, board, signal) => this.get(`/projects/${enc(project)}/boards/${enc(board)}`, void 0, signal),
|
|
686
|
+
create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/boards`, body),
|
|
687
|
+
update: (project, board, body) => this.mutate("PATCH", `/projects/${enc(project)}/boards/${enc(board)}`, body),
|
|
688
|
+
delete: (project, board) => this.mutate("DELETE", `/projects/${enc(project)}/boards/${enc(board)}`)
|
|
689
|
+
};
|
|
690
|
+
// --- Feedback: reports ----------------------------------------------------
|
|
691
|
+
/**
|
|
692
|
+
* Bug reports, QA findings, feedback, and feature requests.
|
|
693
|
+
*
|
|
694
|
+
* `get` deliberately returns everything at once — body, board guidance,
|
|
695
|
+
* custom fields, developer-supplied context, the full timeline with resolved
|
|
696
|
+
* actors, the attachment manifest with short-lived URLs, and the links.
|
|
697
|
+
* Assembling a bug report from six calls is the problem this product exists
|
|
698
|
+
* to remove, so its own client does not make you do it.
|
|
699
|
+
*
|
|
700
|
+
* A report reference may be an id (`rep_…`), a number, or `#number`.
|
|
701
|
+
*/
|
|
702
|
+
reports = {
|
|
703
|
+
list: (project, opts = {}) => {
|
|
704
|
+
const { board, status, type, priority, assignee, labels, q, since, ...rest } = opts;
|
|
705
|
+
return this.page(`/projects/${enc(project)}/reports`, rest, {
|
|
706
|
+
board,
|
|
707
|
+
status: Array.isArray(status) ? status.join(",") : status,
|
|
708
|
+
type,
|
|
709
|
+
priority,
|
|
710
|
+
assignee,
|
|
711
|
+
labels: labels?.join(","),
|
|
712
|
+
q,
|
|
713
|
+
since
|
|
714
|
+
});
|
|
715
|
+
},
|
|
716
|
+
get: (project, report, signal) => this.get(
|
|
717
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}`,
|
|
718
|
+
void 0,
|
|
719
|
+
signal
|
|
720
|
+
),
|
|
721
|
+
create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/reports`, body),
|
|
722
|
+
update: (project, report, body) => this.mutate(
|
|
723
|
+
"PATCH",
|
|
724
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}`,
|
|
725
|
+
body
|
|
726
|
+
),
|
|
727
|
+
comment: (project, report, body) => this.mutate(
|
|
728
|
+
"POST",
|
|
729
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/comments`,
|
|
730
|
+
body
|
|
731
|
+
),
|
|
732
|
+
resolve: (project, report, body = {}) => this.mutate(
|
|
733
|
+
"POST",
|
|
734
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/resolve`,
|
|
735
|
+
body
|
|
736
|
+
),
|
|
737
|
+
reopen: (project, report, body = {}) => this.mutate(
|
|
738
|
+
"POST",
|
|
739
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/reopen`,
|
|
740
|
+
body
|
|
741
|
+
),
|
|
742
|
+
/** Ask whoever reported it to confirm a fix. The human half of the loop. */
|
|
743
|
+
requestRetest: (project, report, body = {}) => this.mutate(
|
|
744
|
+
"POST",
|
|
745
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/retest`,
|
|
746
|
+
body
|
|
747
|
+
),
|
|
748
|
+
merge: (project, report, body) => this.mutate(
|
|
749
|
+
"POST",
|
|
750
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/merge`,
|
|
751
|
+
body
|
|
752
|
+
),
|
|
753
|
+
/**
|
|
754
|
+
* What has come in since a time or a release.
|
|
755
|
+
*
|
|
756
|
+
* The call to make before deciding what to work on: counts, groupings, and
|
|
757
|
+
* the reports themselves already ordered by priority then recency.
|
|
758
|
+
*/
|
|
759
|
+
digest: (project, opts = {}, signal) => this.get(
|
|
760
|
+
`/projects/${enc(project)}/reports/digest`,
|
|
761
|
+
{ since: opts.since, release: opts.release, limit: opts.limit },
|
|
762
|
+
signal
|
|
763
|
+
),
|
|
764
|
+
similar: (project, report, signal) => this.get(
|
|
765
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/similar`,
|
|
766
|
+
void 0,
|
|
767
|
+
signal
|
|
768
|
+
),
|
|
769
|
+
/** What publishing this report to a board would expose. Read before moving. */
|
|
770
|
+
exposure: (project, report, board, signal) => this.get(
|
|
771
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/exposure`,
|
|
772
|
+
{ board },
|
|
773
|
+
signal
|
|
774
|
+
),
|
|
775
|
+
/** Move between boards. A public destination needs `confirm: true`. */
|
|
776
|
+
move: (project, report, body) => this.mutate(
|
|
777
|
+
"POST",
|
|
778
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/move`,
|
|
779
|
+
body
|
|
780
|
+
),
|
|
781
|
+
link: (project, report, body) => this.mutate(
|
|
782
|
+
"POST",
|
|
783
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/links`,
|
|
784
|
+
body
|
|
785
|
+
),
|
|
786
|
+
/** One attachment, with a fresh short-lived URL. */
|
|
787
|
+
attachment: (project, report, attachment, signal) => this.get(
|
|
788
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/attachments/${enc(attachment)}`,
|
|
789
|
+
void 0,
|
|
790
|
+
signal
|
|
791
|
+
),
|
|
792
|
+
createUpload: (project, body) => this.mutate(
|
|
793
|
+
"POST",
|
|
794
|
+
`/projects/${enc(project)}/reports/attachments/uploads`,
|
|
795
|
+
body
|
|
796
|
+
),
|
|
797
|
+
attach: (project, report, uploadIds) => this.mutate(
|
|
798
|
+
"POST",
|
|
799
|
+
`/projects/${enc(project)}/reports/${enc(String(report))}/attachments`,
|
|
800
|
+
{ uploadIds }
|
|
801
|
+
)
|
|
802
|
+
};
|
|
803
|
+
// --- Feedback: ingest keys ------------------------------------------------
|
|
804
|
+
/**
|
|
805
|
+
* Publishable keys that let a website submit reports.
|
|
806
|
+
*
|
|
807
|
+
* Unlike every other credential here, one of these is *meant* to be readable
|
|
808
|
+
* by every visitor to a customer's page. What makes that safe lives on the
|
|
809
|
+
* server: the key may only file on one board, only from an origin the project
|
|
810
|
+
* registered, and only within its own rate and quota budget.
|
|
811
|
+
*/
|
|
812
|
+
ingestKeys = {
|
|
813
|
+
list: (project, signal) => this.get(`/projects/${enc(project)}/ingest-keys`, void 0, signal),
|
|
814
|
+
create: (project, body) => this.mutate("POST", `/projects/${enc(project)}/ingest-keys`, body),
|
|
815
|
+
revoke: (project, key) => this.mutate("DELETE", `/projects/${enc(project)}/ingest-keys/${enc(key)}`)
|
|
816
|
+
};
|
|
637
817
|
// --- Declared external checks ---------------------------------------------
|
|
638
818
|
checks = {
|
|
639
819
|
list: (project, signal) => this.get(`/projects/${enc(project)}/checks`, void 0, signal),
|
|
@@ -971,6 +1151,24 @@ function readCredentialFile() {
|
|
|
971
1151
|
return {};
|
|
972
1152
|
}
|
|
973
1153
|
}
|
|
1154
|
+
function mcpConfigFile() {
|
|
1155
|
+
return process.env.MYNA_MCP_CONFIG ?? join(configDir(), "mcp.json");
|
|
1156
|
+
}
|
|
1157
|
+
function readMcpConfig() {
|
|
1158
|
+
try {
|
|
1159
|
+
return JSON.parse(readFileSync(mcpConfigFile(), "utf8"));
|
|
1160
|
+
} catch {
|
|
1161
|
+
return {};
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
function writeMcpConfig(patch) {
|
|
1165
|
+
const next = { ...readMcpConfig(), ...patch };
|
|
1166
|
+
const file = mcpConfigFile();
|
|
1167
|
+
ensureDir(dirname(file));
|
|
1168
|
+
writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
|
|
1169
|
+
chmodSync(file, 384);
|
|
1170
|
+
return next;
|
|
1171
|
+
}
|
|
974
1172
|
function resolveContext(flags) {
|
|
975
1173
|
const link = findLinkedProject();
|
|
976
1174
|
const user = readUserConfig();
|
|
@@ -2921,6 +3119,452 @@ function registerReleases(program) {
|
|
|
2921
3119
|
);
|
|
2922
3120
|
}
|
|
2923
3121
|
|
|
3122
|
+
// src/commands/feedback.ts
|
|
3123
|
+
import { writeFile } from "fs/promises";
|
|
3124
|
+
import { basename as basename3 } from "path";
|
|
3125
|
+
function registerFeedback(program) {
|
|
3126
|
+
const feedback = program.command("feedback").description("Bug reports, QA findings, and feature requests");
|
|
3127
|
+
const boards = feedback.command("boards").description("Boards that collect reports");
|
|
3128
|
+
boards.command("list").description("List boards").action(
|
|
3129
|
+
handle(async (ctx) => {
|
|
3130
|
+
const project = ctx.requireProject();
|
|
3131
|
+
const rows = await ctx.management().boards.list(project);
|
|
3132
|
+
emit(
|
|
3133
|
+
rows,
|
|
3134
|
+
() => table(rows, [
|
|
3135
|
+
{ header: "KEY", value: (b) => b.key },
|
|
3136
|
+
{ header: "NAME", value: (b) => b.name },
|
|
3137
|
+
{ header: "TYPE", value: (b) => b.type },
|
|
3138
|
+
{ header: "REPORTS", value: (b) => String(b.reportCount ?? 0) },
|
|
3139
|
+
{ header: "FORM", value: (b) => b.formFields?.length ? `${b.formFields.length} field(s)` : "\u2014" }
|
|
3140
|
+
])
|
|
3141
|
+
);
|
|
3142
|
+
})
|
|
3143
|
+
);
|
|
3144
|
+
boards.command("create").description("Create a board").requiredOption("--key <key>", "lowercase key, e.g. qa").requiredOption("--name <name>", "display name").option("--type <type>", "bug | qa | feedback | feature_request", "bug").option("--description <text>", "what this board is for").option("--guidance <text>", "how reports here should be triaged and answered").action(
|
|
3145
|
+
handle(async (ctx, _args, opts) => {
|
|
3146
|
+
const project = ctx.requireProject();
|
|
3147
|
+
const board = await ctx.management().boards.create(project, {
|
|
3148
|
+
key: opts.key,
|
|
3149
|
+
name: opts.name,
|
|
3150
|
+
type: opts.type,
|
|
3151
|
+
description: opts.description,
|
|
3152
|
+
guidance: opts.guidance
|
|
3153
|
+
});
|
|
3154
|
+
emit(board, () => diag(`Created board ${board.key} (${board.id}).`));
|
|
3155
|
+
})
|
|
3156
|
+
);
|
|
3157
|
+
boards.command("update").description("Change a board's name, visibility, or voting").argument("<board>", "board key or id").option("--name <name>", "display name").option("--description <text>", "what this board is for").option("--guidance <text>", "how reports here should be triaged and answered").option("--visibility <visibility>", "private | public").option("--voting", "let readers of a public board vote").option("--no-voting", "turn voting off").action(
|
|
3158
|
+
handle(async (ctx, args, opts) => {
|
|
3159
|
+
const project = ctx.requireProject();
|
|
3160
|
+
const visibility = opts.visibility;
|
|
3161
|
+
if (visibility && visibility !== "private" && visibility !== "public") {
|
|
3162
|
+
throw new Error(`--visibility must be private or public (got "${visibility}").`);
|
|
3163
|
+
}
|
|
3164
|
+
const board = await ctx.management().boards.update(project, args[0], {
|
|
3165
|
+
name: opts.name,
|
|
3166
|
+
description: opts.description,
|
|
3167
|
+
guidance: opts.guidance,
|
|
3168
|
+
visibility,
|
|
3169
|
+
votingEnabled: opts.voting
|
|
3170
|
+
});
|
|
3171
|
+
emit(board, () => {
|
|
3172
|
+
diag(`Updated board ${board.key}.`);
|
|
3173
|
+
if (visibility === "public") {
|
|
3174
|
+
diag(
|
|
3175
|
+
`It is now public: every report on it is readable by anyone, and that cannot be undone for reports people have already seen.`
|
|
3176
|
+
);
|
|
3177
|
+
}
|
|
3178
|
+
});
|
|
3179
|
+
})
|
|
3180
|
+
);
|
|
3181
|
+
boards.command("delete").description("Retire an empty board").argument("<board>", "board key or id").action(
|
|
3182
|
+
handle(async (ctx, args) => {
|
|
3183
|
+
const project = ctx.requireProject();
|
|
3184
|
+
const result = await ctx.management().boards.delete(project, args[0]);
|
|
3185
|
+
emit(result, () => diag(`Deleted board ${args[0]}.`));
|
|
3186
|
+
})
|
|
3187
|
+
);
|
|
3188
|
+
const reports = feedback.command("reports").description("Read and act on reports");
|
|
3189
|
+
reports.command("list").description("List reports, most recently active first").option("--board <key>", "only this board").option("--status <list>", "comma-separated statuses").option("--type <type>", "bug | qa | feedback | feature_request").option("--priority <priority>", "low | normal | high | urgent").option("--assignee <userId>", "only reports assigned to this user").option("--labels <list>", "comma-separated labels; matches any").option("-q, --query <text>", "full-text search over title and body").option("--since <iso>", "only reports active at or after this time").option("--limit <n>", "page size", "25").action(
|
|
3190
|
+
handle(async (ctx, _args, opts) => {
|
|
3191
|
+
const project = ctx.requireProject();
|
|
3192
|
+
const page = await ctx.management().reports.list(project, {
|
|
3193
|
+
board: opts.board,
|
|
3194
|
+
status: opts.status,
|
|
3195
|
+
type: opts.type,
|
|
3196
|
+
priority: opts.priority,
|
|
3197
|
+
assignee: opts.assignee,
|
|
3198
|
+
labels: opts.labels?.split(","),
|
|
3199
|
+
q: opts.query,
|
|
3200
|
+
since: opts.since,
|
|
3201
|
+
limit: Number(opts.limit)
|
|
3202
|
+
});
|
|
3203
|
+
emit(
|
|
3204
|
+
{ data: page.data, nextCursor: page.nextCursor },
|
|
3205
|
+
() => table(page.data, [
|
|
3206
|
+
{ header: "#", value: (r) => String(r.number) },
|
|
3207
|
+
{ header: "TITLE", value: (r) => r.title },
|
|
3208
|
+
{ header: "BOARD", value: (r) => r.boardKey },
|
|
3209
|
+
{ header: "STATUS", value: (r) => r.status },
|
|
3210
|
+
{ header: "PRIORITY", value: (r) => r.priority },
|
|
3211
|
+
{ header: "ATTACH", value: (r) => String(r.attachmentCount) },
|
|
3212
|
+
{ header: "ACTIVITY", value: (r) => r.lastActivityAt }
|
|
3213
|
+
])
|
|
3214
|
+
);
|
|
3215
|
+
})
|
|
3216
|
+
);
|
|
3217
|
+
reports.command("open").description("Print everything known about a report").argument("<report>", "report id, number, or #number").action(
|
|
3218
|
+
handle(async (ctx, args) => {
|
|
3219
|
+
const project = ctx.requireProject();
|
|
3220
|
+
const r = await ctx.management().reports.get(project, args[0]);
|
|
3221
|
+
emit(r, () => {
|
|
3222
|
+
diag(`#${r.number} ${r.title}`);
|
|
3223
|
+
diag(`${r.status} \xB7 ${r.priority} \xB7 ${r.type} \xB7 board ${r.boardKey}`);
|
|
3224
|
+
if (r.reporter) diag(`reported by ${r.reporter.name ?? r.reporter.id ?? "someone"}`);
|
|
3225
|
+
if (r.assignee) diag(`assigned to ${r.assignee.name ?? r.assignee.id}`);
|
|
3226
|
+
if (r.duplicateOfId) diag(`duplicate of ${r.duplicateOfId}`);
|
|
3227
|
+
if (r.body) diag(`
|
|
3228
|
+
${r.body}`);
|
|
3229
|
+
if (r.fields && Object.keys(r.fields).length > 0) {
|
|
3230
|
+
diag(`
|
|
3231
|
+
Fields:`);
|
|
3232
|
+
for (const [k, v] of Object.entries(r.fields)) diag(` ${k}: ${JSON.stringify(v)}`);
|
|
3233
|
+
}
|
|
3234
|
+
if (r.context && Object.keys(r.context).length > 0) {
|
|
3235
|
+
diag(`
|
|
3236
|
+
Context (supplied by the application):`);
|
|
3237
|
+
for (const [k, v] of Object.entries(r.context)) diag(` ${k}: ${JSON.stringify(v)}`);
|
|
3238
|
+
}
|
|
3239
|
+
if (r.board.guidance) diag(`
|
|
3240
|
+
Board guidance:
|
|
3241
|
+
${r.board.guidance}`);
|
|
3242
|
+
if (r.attachments.length > 0) {
|
|
3243
|
+
diag(`
|
|
3244
|
+
Attachments:`);
|
|
3245
|
+
for (const a of r.attachments) {
|
|
3246
|
+
diag(` ${a.id} ${a.kind.padEnd(6)} ${a.filename} (${a.byteSize} bytes)`);
|
|
3247
|
+
}
|
|
3248
|
+
diag(` Fetch one with: myna feedback attachments get ${r.number} <id> --out <path>`);
|
|
3249
|
+
}
|
|
3250
|
+
if (r.links.length > 0) {
|
|
3251
|
+
diag(`
|
|
3252
|
+
Links:`);
|
|
3253
|
+
for (const l of r.links) diag(` ${l.kind}: ${l.value}`);
|
|
3254
|
+
}
|
|
3255
|
+
if (r.timeline.length > 0) {
|
|
3256
|
+
diag(`
|
|
3257
|
+
Timeline:`);
|
|
3258
|
+
for (const e of r.timeline) {
|
|
3259
|
+
const who = e.actor.name ?? e.actor.type;
|
|
3260
|
+
const mark = e.visibility === "internal" ? " (internal)" : "";
|
|
3261
|
+
diag(` ${e.createdAt} ${who}${mark} ${e.kind}${e.body ? `: ${e.body}` : ""}`);
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
});
|
|
3265
|
+
})
|
|
3266
|
+
);
|
|
3267
|
+
reports.command("create").description("File a report").requiredOption("--board <key>", "board to file it on").requiredOption("--title <title>", "one-line summary").option("--body <text>", "what happened, and how to reproduce it").option("--priority <priority>", "low | normal | high | urgent").option("--labels <list>", "comma-separated labels").option("--context <json>", "environment supplied by the application, as JSON").action(
|
|
3268
|
+
handle(async (ctx, _args, opts) => {
|
|
3269
|
+
const project = ctx.requireProject();
|
|
3270
|
+
const report = await ctx.management().reports.create(project, {
|
|
3271
|
+
board: opts.board,
|
|
3272
|
+
title: opts.title,
|
|
3273
|
+
body: opts.body,
|
|
3274
|
+
priority: opts.priority,
|
|
3275
|
+
labels: opts.labels?.split(","),
|
|
3276
|
+
context: opts.context ? JSON.parse(opts.context) : void 0
|
|
3277
|
+
});
|
|
3278
|
+
emit(report, () => diag(`Filed #${report.number} (${report.id}).`));
|
|
3279
|
+
})
|
|
3280
|
+
);
|
|
3281
|
+
reports.command("triage").description("Set status, priority, assignee, or labels").argument("<report>", "report id, number, or #number").option("--status <status>", "triage | open | in_progress | needs_retest | resolved | closed | duplicate | wont_fix | spam").option("--priority <priority>", "low | normal | high | urgent").option("--assignee <userId>", "user id, or 'none' to unassign").option("--labels <list>", "comma-separated labels; replaces the set").action(
|
|
3282
|
+
handle(async (ctx, args, opts) => {
|
|
3283
|
+
const project = ctx.requireProject();
|
|
3284
|
+
const report = await ctx.management().reports.update(project, args[0], {
|
|
3285
|
+
status: opts.status,
|
|
3286
|
+
priority: opts.priority,
|
|
3287
|
+
assigneeId: opts.assignee === "none" ? null : opts.assignee,
|
|
3288
|
+
labels: opts.labels?.split(",")
|
|
3289
|
+
});
|
|
3290
|
+
emit(report, () => diag(`#${report.number} is now ${report.status} (${report.priority}).`));
|
|
3291
|
+
})
|
|
3292
|
+
);
|
|
3293
|
+
reports.command("reply").description("Post a reply or an internal note").argument("<report>", "report id, number, or #number").argument("<message>", "what to say").option("--public", "the reporter sees this; without it the note stays internal").action(
|
|
3294
|
+
handle(async (ctx, args, opts) => {
|
|
3295
|
+
const project = ctx.requireProject();
|
|
3296
|
+
const report = await ctx.management().reports.comment(project, args[0], {
|
|
3297
|
+
body: args[1],
|
|
3298
|
+
visibility: opts.public ? "public" : "internal"
|
|
3299
|
+
});
|
|
3300
|
+
emit(
|
|
3301
|
+
report,
|
|
3302
|
+
() => diag(`Posted ${opts.public ? "a public reply" : "an internal note"} on #${report.number}.`)
|
|
3303
|
+
);
|
|
3304
|
+
})
|
|
3305
|
+
);
|
|
3306
|
+
reports.command("resolve").description("Mark a report resolved, optionally linking the fix").argument("<report>", "report id, number, or #number").option("--reply <text>", "public reply explaining what changed").option("--commit <sha>", "commit that fixed it").option("--pr <url>", "pull request that fixed it").option("--retest", "ask the reporter to confirm instead of closing it").action(
|
|
3307
|
+
handle(async (ctx, args, opts) => {
|
|
3308
|
+
const project = ctx.requireProject();
|
|
3309
|
+
const report = await ctx.management().reports.resolve(project, args[0], {
|
|
3310
|
+
reply: opts.reply,
|
|
3311
|
+
commit: opts.commit,
|
|
3312
|
+
pullRequest: opts.pr,
|
|
3313
|
+
requestRetest: Boolean(opts.retest)
|
|
3314
|
+
});
|
|
3315
|
+
emit(
|
|
3316
|
+
report,
|
|
3317
|
+
() => diag(
|
|
3318
|
+
opts.retest ? `#${report.number} is awaiting the reporter's confirmation.` : `#${report.number} resolved.`
|
|
3319
|
+
)
|
|
3320
|
+
);
|
|
3321
|
+
})
|
|
3322
|
+
);
|
|
3323
|
+
reports.command("reopen").description("Put a resolved report back").argument("<report>", "report id, number, or #number").option("--reason <text>", "why it is not fixed").action(
|
|
3324
|
+
handle(async (ctx, args, opts) => {
|
|
3325
|
+
const project = ctx.requireProject();
|
|
3326
|
+
const report = await ctx.management().reports.reopen(project, args[0], { reason: opts.reason });
|
|
3327
|
+
emit(report, () => diag(`#${report.number} reopened.`));
|
|
3328
|
+
})
|
|
3329
|
+
);
|
|
3330
|
+
reports.command("retest").description("Ask whoever reported it to confirm a fix").argument("<report>", "report id, number, or #number").option("--note <text>", "what to check").action(
|
|
3331
|
+
handle(async (ctx, args, opts) => {
|
|
3332
|
+
const project = ctx.requireProject();
|
|
3333
|
+
const report = await ctx.management().reports.requestRetest(project, args[0], { note: opts.note });
|
|
3334
|
+
emit(report, () => diag(`Asked the reporter to retest #${report.number}.`));
|
|
3335
|
+
})
|
|
3336
|
+
);
|
|
3337
|
+
reports.command("merge").description("Mark a report as a duplicate of another").argument("<report>", "the duplicate").requiredOption("--into <report>", "the canonical report").option("--note <text>", "explain the merge on both timelines").action(
|
|
3338
|
+
handle(async (ctx, args, opts) => {
|
|
3339
|
+
const project = ctx.requireProject();
|
|
3340
|
+
const report = await ctx.management().reports.merge(project, args[0], {
|
|
3341
|
+
into: opts.into,
|
|
3342
|
+
note: opts.note
|
|
3343
|
+
});
|
|
3344
|
+
emit(report, () => diag(`#${report.number} marked as a duplicate.`));
|
|
3345
|
+
})
|
|
3346
|
+
);
|
|
3347
|
+
reports.command("similar").description("Find reports that might be the same problem").argument("<report>", "report id, number, or #number").action(
|
|
3348
|
+
handle(async (ctx, args) => {
|
|
3349
|
+
const project = ctx.requireProject();
|
|
3350
|
+
const result = await ctx.management().reports.similar(project, args[0]);
|
|
3351
|
+
emit(
|
|
3352
|
+
result,
|
|
3353
|
+
() => table(result.reports, [
|
|
3354
|
+
{ header: "#", value: (r) => String(r.number) },
|
|
3355
|
+
{ header: "TITLE", value: (r) => r.title },
|
|
3356
|
+
{ header: "STATUS", value: (r) => r.status },
|
|
3357
|
+
{ header: "WHY", value: (r) => r.reason }
|
|
3358
|
+
])
|
|
3359
|
+
);
|
|
3360
|
+
})
|
|
3361
|
+
);
|
|
3362
|
+
reports.command("exposure").description("Show exactly what publishing a report to a board would expose").argument("<report>", "report id, number, or #number").requiredOption("--board <key>", "destination board").action(
|
|
3363
|
+
handle(async (ctx, args, opts) => {
|
|
3364
|
+
const project = ctx.requireProject();
|
|
3365
|
+
const e = await ctx.management().reports.exposure(project, args[0], opts.board);
|
|
3366
|
+
emit(e, () => {
|
|
3367
|
+
diag(`Moving to "${e.board.key}" (${e.board.visibility}) would make this readable by anyone:`);
|
|
3368
|
+
diag(`
|
|
3369
|
+
${e.willBePublic.title}`);
|
|
3370
|
+
if (e.willBePublic.body) diag(`
|
|
3371
|
+
${e.willBePublic.body}`);
|
|
3372
|
+
if (e.willBePublic.comments.length > 0) {
|
|
3373
|
+
diag(`
|
|
3374
|
+
Public replies (${e.willBePublic.comments.length}):`);
|
|
3375
|
+
for (const c of e.willBePublic.comments) diag(` ${c.author}: ${c.body}`);
|
|
3376
|
+
}
|
|
3377
|
+
if (e.willBePublic.attachments.length > 0) {
|
|
3378
|
+
diag(`
|
|
3379
|
+
Attachments:`);
|
|
3380
|
+
for (const a of e.willBePublic.attachments) diag(` ${a.filename} (${a.kind}, ${a.byteSize} bytes)`);
|
|
3381
|
+
}
|
|
3382
|
+
if (e.willBePublic.context) diag(`
|
|
3383
|
+
Environment: ${JSON.stringify(e.willBePublic.context)}`);
|
|
3384
|
+
diag(`
|
|
3385
|
+
Stays private: ${e.willStayPrivate.internalComments} internal note(s)` + (e.willStayPrivate.reporterEmail ? `, the reporter's email` : ""));
|
|
3386
|
+
for (const w of e.warnings) diag(`
|
|
3387
|
+
! ${w}`);
|
|
3388
|
+
diag(`
|
|
3389
|
+
Publish with: myna feedback reports move ${args[0]} --board ${opts.board} --confirm`);
|
|
3390
|
+
});
|
|
3391
|
+
})
|
|
3392
|
+
);
|
|
3393
|
+
reports.command("move").description("Move a report to another board").argument("<report>", "report id, number, or #number").requiredOption("--board <key>", "destination board").option("--confirm", "required when the destination is public; run `exposure` first").action(
|
|
3394
|
+
handle(async (ctx, args, opts) => {
|
|
3395
|
+
const project = ctx.requireProject();
|
|
3396
|
+
const result = await ctx.management().reports.move(project, args[0], {
|
|
3397
|
+
board: opts.board,
|
|
3398
|
+
confirm: Boolean(opts.confirm)
|
|
3399
|
+
});
|
|
3400
|
+
emit(
|
|
3401
|
+
result,
|
|
3402
|
+
() => diag(
|
|
3403
|
+
result.isPublic ? `#${result.number} is now public on ${result.board}.` : `#${result.number} moved to ${result.board}.`
|
|
3404
|
+
)
|
|
3405
|
+
);
|
|
3406
|
+
})
|
|
3407
|
+
);
|
|
3408
|
+
reports.command("link").description("Point a report at a commit, pull request, entry, or release").argument("<report>", "report id, number, or #number").requiredOption("--kind <kind>", "commit | pull_request | entry | release | url").requiredOption("--value <value>", "the sha, URL, id, or release number").option("--title <text>", "label for the link").action(
|
|
3409
|
+
handle(async (ctx, args, opts) => {
|
|
3410
|
+
const project = ctx.requireProject();
|
|
3411
|
+
const report = await ctx.management().reports.link(project, args[0], {
|
|
3412
|
+
kind: opts.kind,
|
|
3413
|
+
value: opts.value,
|
|
3414
|
+
title: opts.title
|
|
3415
|
+
});
|
|
3416
|
+
emit(report, () => diag(`Linked ${opts.kind} to #${report.number}.`));
|
|
3417
|
+
})
|
|
3418
|
+
);
|
|
3419
|
+
feedback.command("digest").description("What has been reported since a time or a release").option("--since <iso>", "ISO timestamp; defaults to the last 24 hours").option("--release <n>", "since a release shipped").option("--limit <n>", "how many reports to include", "25").action(
|
|
3420
|
+
handle(async (ctx, _args, opts) => {
|
|
3421
|
+
const project = ctx.requireProject();
|
|
3422
|
+
const d = await ctx.management().reports.digest(project, {
|
|
3423
|
+
since: opts.since,
|
|
3424
|
+
release: opts.release ? Number(opts.release) : void 0,
|
|
3425
|
+
limit: Number(opts.limit)
|
|
3426
|
+
});
|
|
3427
|
+
emit(d, () => {
|
|
3428
|
+
diag(`${d.total} report(s) since ${d.sinceLabel}; ${d.stillOpen} still open.`);
|
|
3429
|
+
const counts = (label, tally) => {
|
|
3430
|
+
const parts = Object.entries(tally).map(([k, n]) => `${k} ${n}`);
|
|
3431
|
+
if (parts.length > 0) diag(`${label}: ${parts.join(", ")}`);
|
|
3432
|
+
};
|
|
3433
|
+
counts("By status", d.byStatus);
|
|
3434
|
+
counts("By board", d.byBoard);
|
|
3435
|
+
counts("By type", d.byType);
|
|
3436
|
+
if (d.reports.length > 0) {
|
|
3437
|
+
diag("");
|
|
3438
|
+
table(d.reports, [
|
|
3439
|
+
{ header: "#", value: (r) => String(r.number) },
|
|
3440
|
+
{ header: "PRIORITY", value: (r) => r.priority },
|
|
3441
|
+
{ header: "STATUS", value: (r) => r.status },
|
|
3442
|
+
{ header: "TITLE", value: (r) => r.title }
|
|
3443
|
+
]);
|
|
3444
|
+
}
|
|
3445
|
+
if (d.truncated) diag(`
|
|
3446
|
+
(showing ${d.reports.length} of ${d.total} \u2014 raise --limit for more)`);
|
|
3447
|
+
});
|
|
3448
|
+
})
|
|
3449
|
+
);
|
|
3450
|
+
const keys = feedback.command("keys").description("Publishable keys that let a website submit reports");
|
|
3451
|
+
keys.command("list").description("List ingest keys").action(
|
|
3452
|
+
handle(async (ctx) => {
|
|
3453
|
+
const project = ctx.requireProject();
|
|
3454
|
+
const rows = await ctx.management().ingestKeys.list(project);
|
|
3455
|
+
emit(
|
|
3456
|
+
rows,
|
|
3457
|
+
() => table(rows, [
|
|
3458
|
+
{ header: "ID", value: (k) => k.id },
|
|
3459
|
+
{ header: "NAME", value: (k) => k.name },
|
|
3460
|
+
{ header: "BOARD", value: (k) => k.boardKey },
|
|
3461
|
+
{ header: "SIGNED ID", value: (k) => k.hasIdentitySecret ? "yes" : "no" },
|
|
3462
|
+
{ header: "LAST USED", value: (k) => k.lastUsedAt ?? "never" },
|
|
3463
|
+
{ header: "STATE", value: (k) => k.revokedAt ? "revoked" : "live" }
|
|
3464
|
+
])
|
|
3465
|
+
);
|
|
3466
|
+
})
|
|
3467
|
+
);
|
|
3468
|
+
keys.command("create").description("Create a publishable ingest key").requiredOption("--name <name>", "what this key is for").requiredOption("--board <key>", "board it may file on").option("--signed-identity", "also mint a server-side secret for signed identify").action(
|
|
3469
|
+
handle(async (ctx, _args, opts) => {
|
|
3470
|
+
const project = ctx.requireProject();
|
|
3471
|
+
const created = await ctx.management().ingestKeys.create(project, {
|
|
3472
|
+
name: opts.name,
|
|
3473
|
+
board: opts.board,
|
|
3474
|
+
withIdentitySecret: Boolean(opts.signedIdentity)
|
|
3475
|
+
});
|
|
3476
|
+
emit(created, () => {
|
|
3477
|
+
diag(`Created ingest key ${created.id} for board ${created.boardKey}.`);
|
|
3478
|
+
diag(``);
|
|
3479
|
+
diag(` ${created.key}`);
|
|
3480
|
+
diag(``);
|
|
3481
|
+
diag(`This key is publishable \u2014 it is designed to sit in your browser bundle.`);
|
|
3482
|
+
diag(`It can only file a report on ${created.boardKey}, and only from an origin`);
|
|
3483
|
+
diag(`registered on this project. Add yours with: myna projects update --origins`);
|
|
3484
|
+
if (created.identitySecret) {
|
|
3485
|
+
diag(``);
|
|
3486
|
+
diag(`Identity signing secret (shown once \u2014 keep it on your server):`);
|
|
3487
|
+
diag(``);
|
|
3488
|
+
diag(` ${created.identitySecret}`);
|
|
3489
|
+
diag(``);
|
|
3490
|
+
diag(`Sign a user id with HMAC-SHA256 and pass it as identify.signature.`);
|
|
3491
|
+
}
|
|
3492
|
+
});
|
|
3493
|
+
})
|
|
3494
|
+
);
|
|
3495
|
+
keys.command("revoke").description("Revoke an ingest key").argument("<key>", "ingest key id").action(
|
|
3496
|
+
handle(async (ctx, args) => {
|
|
3497
|
+
const project = ctx.requireProject();
|
|
3498
|
+
const result = await ctx.management().ingestKeys.revoke(project, args[0]);
|
|
3499
|
+
emit(result, () => diag(`Revoked ${args[0]}. Sites using it can no longer submit.`));
|
|
3500
|
+
})
|
|
3501
|
+
);
|
|
3502
|
+
const attachments = feedback.command("attachments").description("Evidence attached to a report");
|
|
3503
|
+
attachments.command("get").description("Download one attachment").argument("<report>", "report id, number, or #number").argument("<attachment>", "attachment id").option("--out <path>", "where to write it; defaults to the original filename").action(
|
|
3504
|
+
handle(async (ctx, args, opts) => {
|
|
3505
|
+
const project = ctx.requireProject();
|
|
3506
|
+
const attachment = await ctx.management().reports.attachment(project, args[0], args[1]);
|
|
3507
|
+
if (!attachment.url) {
|
|
3508
|
+
throw new Error(`Attachment ${attachment.id} has no readable URL; it may have expired.`);
|
|
3509
|
+
}
|
|
3510
|
+
const path = opts.out ?? basename3(attachment.filename);
|
|
3511
|
+
const response = await fetch(attachment.url);
|
|
3512
|
+
if (!response.ok) throw new Error(`Could not download attachment: ${response.status}.`);
|
|
3513
|
+
await writeFile(path, Buffer.from(await response.arrayBuffer()));
|
|
3514
|
+
emit(
|
|
3515
|
+
{ ...attachment, path },
|
|
3516
|
+
() => diag(`Wrote ${attachment.filename} (${attachment.byteSize} bytes) to ${path}.`)
|
|
3517
|
+
);
|
|
3518
|
+
})
|
|
3519
|
+
);
|
|
3520
|
+
attachments.command("add").description("Attach a file to a report").argument("<report>", "report id, number, or #number").argument("<file>", "path to the file").action(
|
|
3521
|
+
handle(async (ctx, args) => {
|
|
3522
|
+
const project = ctx.requireProject();
|
|
3523
|
+
const { readFile: readFile3 } = await import("fs/promises");
|
|
3524
|
+
const path = args[1];
|
|
3525
|
+
const bytes = await readFile3(path);
|
|
3526
|
+
const contentType = contentTypeFor(path);
|
|
3527
|
+
const upload = await ctx.management().reports.createUpload(project, {
|
|
3528
|
+
filename: basename3(path),
|
|
3529
|
+
contentType,
|
|
3530
|
+
byteSize: bytes.byteLength
|
|
3531
|
+
});
|
|
3532
|
+
const put = await fetch(upload.url, {
|
|
3533
|
+
method: "PUT",
|
|
3534
|
+
headers: upload.headers,
|
|
3535
|
+
body: new Uint8Array(bytes)
|
|
3536
|
+
});
|
|
3537
|
+
if (!put.ok) throw new Error(`Upload failed: ${put.status}.`);
|
|
3538
|
+
const report = await ctx.management().reports.attach(project, args[0], [upload.uploadId]);
|
|
3539
|
+
emit(report, () => diag(`Attached ${basename3(path)} to #${report.number}.`));
|
|
3540
|
+
})
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
function contentTypeFor(path) {
|
|
3544
|
+
const ext = path.toLowerCase().split(".").pop() ?? "";
|
|
3545
|
+
const types = {
|
|
3546
|
+
png: "image/png",
|
|
3547
|
+
jpg: "image/jpeg",
|
|
3548
|
+
jpeg: "image/jpeg",
|
|
3549
|
+
webp: "image/webp",
|
|
3550
|
+
gif: "image/gif",
|
|
3551
|
+
webm: "video/webm",
|
|
3552
|
+
mp4: "video/mp4",
|
|
3553
|
+
mov: "video/quicktime",
|
|
3554
|
+
txt: "text/plain",
|
|
3555
|
+
log: "text/plain",
|
|
3556
|
+
json: "application/json",
|
|
3557
|
+
pdf: "application/pdf"
|
|
3558
|
+
};
|
|
3559
|
+
const type = types[ext];
|
|
3560
|
+
if (!type) {
|
|
3561
|
+
throw new Error(
|
|
3562
|
+
`Myna does not accept .${ext} attachments. Allowed: ${[...new Set(Object.values(types))].join(", ")}.`
|
|
3563
|
+
);
|
|
3564
|
+
}
|
|
3565
|
+
return type;
|
|
3566
|
+
}
|
|
3567
|
+
|
|
2924
3568
|
// src/commands/pull.ts
|
|
2925
3569
|
import { execSync } from "child_process";
|
|
2926
3570
|
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
@@ -3027,8 +3671,21 @@ var ID_PREFIXES = {
|
|
|
3027
3671
|
mcpAuthorizationCode: "mac",
|
|
3028
3672
|
mcpToken: "mtk",
|
|
3029
3673
|
deviceAuthorization: "dev",
|
|
3674
|
+
webauthnCredential: "pky",
|
|
3675
|
+
webauthnChallenge: "wac",
|
|
3030
3676
|
githubInstallation: "ghi",
|
|
3031
|
-
projectRepository: "prp"
|
|
3677
|
+
projectRepository: "prp",
|
|
3678
|
+
/** A person who files reports. Not a user; see `ActorType` "reporter". */
|
|
3679
|
+
reporter: "rpr",
|
|
3680
|
+
board: "brd",
|
|
3681
|
+
report: "rep",
|
|
3682
|
+
reportEvent: "rpe",
|
|
3683
|
+
reportAttachment: "rat",
|
|
3684
|
+
reportAttachmentUpload: "rau",
|
|
3685
|
+
reportLink: "rlk",
|
|
3686
|
+
ingestKey: "ing",
|
|
3687
|
+
reporterToken: "rtk",
|
|
3688
|
+
reportVote: "rvt"
|
|
3032
3689
|
};
|
|
3033
3690
|
var PREFIX_SET = new Set(Object.values(ID_PREFIXES));
|
|
3034
3691
|
|
|
@@ -3064,7 +3721,12 @@ var PLANS = {
|
|
|
3064
3721
|
monthlyPublicApiRequests: 1e4,
|
|
3065
3722
|
maxWebhookEndpoints: 1,
|
|
3066
3723
|
revisionRetentionDays: 30,
|
|
3067
|
-
maxUploadBytes: 50 * MB
|
|
3724
|
+
maxUploadBytes: 50 * MB,
|
|
3725
|
+
monthlyFeedbackReports: 500,
|
|
3726
|
+
feedbackAttachmentBytes: 1 * GB,
|
|
3727
|
+
feedbackRetentionDays: 90,
|
|
3728
|
+
maxBoards: 3,
|
|
3729
|
+
maxAttachmentBytes: 100 * MB
|
|
3068
3730
|
},
|
|
3069
3731
|
pro: {
|
|
3070
3732
|
key: "pro",
|
|
@@ -3077,7 +3739,12 @@ var PLANS = {
|
|
|
3077
3739
|
monthlyPublicApiRequests: 5e5,
|
|
3078
3740
|
maxWebhookEndpoints: 10,
|
|
3079
3741
|
revisionRetentionDays: null,
|
|
3080
|
-
maxUploadBytes: 250 * MB
|
|
3742
|
+
maxUploadBytes: 250 * MB,
|
|
3743
|
+
monthlyFeedbackReports: 25e3,
|
|
3744
|
+
feedbackAttachmentBytes: 25 * GB,
|
|
3745
|
+
feedbackRetentionDays: null,
|
|
3746
|
+
maxBoards: 25,
|
|
3747
|
+
maxAttachmentBytes: 500 * MB
|
|
3081
3748
|
},
|
|
3082
3749
|
/**
|
|
3083
3750
|
* Myna's own organizations — the changelog, and anything else we run on the
|
|
@@ -3096,10 +3763,28 @@ var PLANS = {
|
|
|
3096
3763
|
monthlyPublicApiRequests: UNLIMITED,
|
|
3097
3764
|
maxWebhookEndpoints: UNLIMITED,
|
|
3098
3765
|
revisionRetentionDays: null,
|
|
3099
|
-
maxUploadBytes: UNLIMITED
|
|
3766
|
+
maxUploadBytes: UNLIMITED,
|
|
3767
|
+
monthlyFeedbackReports: UNLIMITED,
|
|
3768
|
+
feedbackAttachmentBytes: UNLIMITED,
|
|
3769
|
+
feedbackRetentionDays: null,
|
|
3770
|
+
maxBoards: UNLIMITED,
|
|
3771
|
+
maxAttachmentBytes: UNLIMITED
|
|
3100
3772
|
}
|
|
3101
3773
|
};
|
|
3102
3774
|
|
|
3775
|
+
// ../shared/dist/reports.js
|
|
3776
|
+
var REPORT_LIMITS = {
|
|
3777
|
+
titleChars: 200,
|
|
3778
|
+
bodyChars: 2e4,
|
|
3779
|
+
commentChars: 2e4,
|
|
3780
|
+
attachmentsPerReport: 20,
|
|
3781
|
+
linksPerReport: 50,
|
|
3782
|
+
labelsPerReport: 20,
|
|
3783
|
+
labelChars: 40,
|
|
3784
|
+
/** Serialized `context`, which a developer supplies and we do not police. */
|
|
3785
|
+
contextBytes: 16 * 1024
|
|
3786
|
+
};
|
|
3787
|
+
|
|
3103
3788
|
// ../shared/dist/rank.js
|
|
3104
3789
|
var DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
3105
3790
|
var MIN_DIGIT = DIGITS[0];
|
|
@@ -4064,9 +4749,671 @@ function registerBilling(program) {
|
|
|
4064
4749
|
);
|
|
4065
4750
|
}
|
|
4066
4751
|
|
|
4752
|
+
// src/commands/api.ts
|
|
4753
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
4754
|
+
var METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
|
|
4755
|
+
var SAFE = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
4756
|
+
function parseTarget(args) {
|
|
4757
|
+
const [first, second] = args;
|
|
4758
|
+
if (!first) throw new UsageError("Provide a path, e.g. `myna api /projects`.");
|
|
4759
|
+
const asMethod = first.toUpperCase();
|
|
4760
|
+
if (METHODS.has(asMethod)) {
|
|
4761
|
+
if (!second) throw new UsageError(`Provide a path after ${asMethod}, e.g. \`myna api ${asMethod} /projects\`.`);
|
|
4762
|
+
return { method: asMethod, path: second };
|
|
4763
|
+
}
|
|
4764
|
+
if (second) {
|
|
4765
|
+
throw new UsageError(
|
|
4766
|
+
`Unknown method "${first}". Expected one of ${[...METHODS].join(", ")}, or a single path argument.`
|
|
4767
|
+
);
|
|
4768
|
+
}
|
|
4769
|
+
return { method: "GET", path: first };
|
|
4770
|
+
}
|
|
4771
|
+
function parseBody(input) {
|
|
4772
|
+
if (input === void 0) return void 0;
|
|
4773
|
+
const raw = input.startsWith("@") ? readFileSync6(input.slice(1), "utf8") : input;
|
|
4774
|
+
try {
|
|
4775
|
+
return JSON.parse(raw);
|
|
4776
|
+
} catch (error) {
|
|
4777
|
+
throw new UsageError(`Invalid JSON for --data: ${error instanceof Error ? error.message : String(error)}`);
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
function parseHeaders(values) {
|
|
4781
|
+
const headers = {};
|
|
4782
|
+
for (const value of values ?? []) {
|
|
4783
|
+
const colon = value.indexOf(":");
|
|
4784
|
+
if (colon === -1) throw new UsageError(`Invalid --header "${value}". Expected name:value.`);
|
|
4785
|
+
headers[value.slice(0, colon).trim().toLowerCase()] = value.slice(colon + 1).trim();
|
|
4786
|
+
}
|
|
4787
|
+
return headers;
|
|
4788
|
+
}
|
|
4789
|
+
function registerApi(program) {
|
|
4790
|
+
program.command("api").description("Call a management API endpoint directly, using the resolved credential").argument("<method-or-path>", "HTTP method, or the path when the method is GET").argument("[path]", "request path, e.g. /projects/my-site/entries?collection=posts").option("--data <json>", "request body as inline JSON or @file").option("--header <name:value>", "extra request header (repeatable)", (value, previous = []) => [...previous, value], []).option(
|
|
4791
|
+
"--idempotency-key <key>",
|
|
4792
|
+
"send Idempotency-Key, which also makes the request eligible for automatic retry"
|
|
4793
|
+
).option("--include", "also report the response status and headers on stderr").action(
|
|
4794
|
+
handle(async (ctx, args, opts) => {
|
|
4795
|
+
const { method, path } = parseTarget(args);
|
|
4796
|
+
const body = parseBody(opts.data);
|
|
4797
|
+
if (body !== void 0 && SAFE.has(method)) {
|
|
4798
|
+
throw new UsageError(`--data cannot be sent with ${method}.`);
|
|
4799
|
+
}
|
|
4800
|
+
const response = await ctx.management().raw(method, path, {
|
|
4801
|
+
headers: parseHeaders(opts.header),
|
|
4802
|
+
...body !== void 0 ? { body } : {},
|
|
4803
|
+
...opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}
|
|
4804
|
+
});
|
|
4805
|
+
if (opts.include) {
|
|
4806
|
+
diag(`${response.status}`);
|
|
4807
|
+
for (const [name, value] of Object.entries(response.headers)) diag(`${name}: ${value}`);
|
|
4808
|
+
diag("");
|
|
4809
|
+
}
|
|
4810
|
+
emit(response.body ?? { status: response.status }, () => {
|
|
4811
|
+
process.stdout.write(
|
|
4812
|
+
(response.body === void 0 ? `${response.status} (no content)` : JSON.stringify(response.body, null, 2)) + "\n"
|
|
4813
|
+
);
|
|
4814
|
+
});
|
|
4815
|
+
})
|
|
4816
|
+
);
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
// src/agent-clients.ts
|
|
4820
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
4821
|
+
import { homedir as homedir2 } from "os";
|
|
4822
|
+
import { dirname as dirname3, join as join9 } from "path";
|
|
4823
|
+
var AGENT_CLIENTS = [
|
|
4824
|
+
{
|
|
4825
|
+
id: "claude-code",
|
|
4826
|
+
label: "Claude Code",
|
|
4827
|
+
dialect: "standard",
|
|
4828
|
+
projectPath: ".mcp.json",
|
|
4829
|
+
userPath: ".claude.json",
|
|
4830
|
+
marker: ".claude"
|
|
4831
|
+
},
|
|
4832
|
+
{
|
|
4833
|
+
id: "cursor",
|
|
4834
|
+
label: "Cursor",
|
|
4835
|
+
dialect: "standard",
|
|
4836
|
+
projectPath: ".cursor/mcp.json",
|
|
4837
|
+
userPath: ".cursor/mcp.json",
|
|
4838
|
+
marker: ".cursor"
|
|
4839
|
+
},
|
|
4840
|
+
{
|
|
4841
|
+
id: "vscode",
|
|
4842
|
+
label: "VS Code",
|
|
4843
|
+
dialect: "vscode",
|
|
4844
|
+
projectPath: ".vscode/mcp.json",
|
|
4845
|
+
marker: ".vscode"
|
|
4846
|
+
},
|
|
4847
|
+
{
|
|
4848
|
+
id: "windsurf",
|
|
4849
|
+
label: "Windsurf",
|
|
4850
|
+
dialect: "standard",
|
|
4851
|
+
userPath: ".codeium/windsurf/mcp_config.json",
|
|
4852
|
+
marker: ".codeium"
|
|
4853
|
+
},
|
|
4854
|
+
{
|
|
4855
|
+
id: "codex",
|
|
4856
|
+
label: "Codex",
|
|
4857
|
+
dialect: "toml",
|
|
4858
|
+
userPath: ".codex/config.toml",
|
|
4859
|
+
marker: ".codex",
|
|
4860
|
+
note: "Codex configuration is TOML; the CLI prints the block to paste rather than editing it."
|
|
4861
|
+
}
|
|
4862
|
+
];
|
|
4863
|
+
function findClient(id) {
|
|
4864
|
+
const client = AGENT_CLIENTS.find((c) => c.id === id);
|
|
4865
|
+
if (!client) {
|
|
4866
|
+
throw new UsageError(
|
|
4867
|
+
`Unknown client "${id}". Known clients: ${AGENT_CLIENTS.map((c) => c.id).join(", ")}.`
|
|
4868
|
+
);
|
|
4869
|
+
}
|
|
4870
|
+
return client;
|
|
4871
|
+
}
|
|
4872
|
+
function isDetected(client) {
|
|
4873
|
+
return existsSync7(join9(homedir2(), client.marker));
|
|
4874
|
+
}
|
|
4875
|
+
function scopesFor(client) {
|
|
4876
|
+
const scopes = [];
|
|
4877
|
+
if (client.projectPath) scopes.push("project");
|
|
4878
|
+
if (client.userPath) scopes.push("user");
|
|
4879
|
+
return scopes;
|
|
4880
|
+
}
|
|
4881
|
+
function configPath(client, scope, projectRoot) {
|
|
4882
|
+
if (scope === "project") {
|
|
4883
|
+
if (!client.projectPath) {
|
|
4884
|
+
throw new UsageError(
|
|
4885
|
+
`${client.label} has no project-scoped configuration file. Use --scope user.`
|
|
4886
|
+
);
|
|
4887
|
+
}
|
|
4888
|
+
return join9(projectRoot, client.projectPath);
|
|
4889
|
+
}
|
|
4890
|
+
if (!client.userPath) {
|
|
4891
|
+
throw new UsageError(
|
|
4892
|
+
`${client.label} has no user-scoped configuration file. Use --scope project.`
|
|
4893
|
+
);
|
|
4894
|
+
}
|
|
4895
|
+
return join9(homedir2(), client.userPath);
|
|
4896
|
+
}
|
|
4897
|
+
function serverEntry(client, spec) {
|
|
4898
|
+
if (spec.transport === "http") {
|
|
4899
|
+
return { type: "http", url: spec.url };
|
|
4900
|
+
}
|
|
4901
|
+
const stdio = {
|
|
4902
|
+
command: "npx",
|
|
4903
|
+
args: ["-y", "@myna-sh/mcp"]
|
|
4904
|
+
};
|
|
4905
|
+
if (client.dialect === "vscode") stdio.type = "stdio";
|
|
4906
|
+
if (Object.keys(spec.env).length > 0) stdio.env = spec.env;
|
|
4907
|
+
return stdio;
|
|
4908
|
+
}
|
|
4909
|
+
function tomlBlock(name, spec) {
|
|
4910
|
+
const lines = [`[mcp_servers.${name}]`];
|
|
4911
|
+
if (spec.transport === "http") {
|
|
4912
|
+
lines.push(`url = ${JSON.stringify(spec.url)}`);
|
|
4913
|
+
} else {
|
|
4914
|
+
lines.push(`command = "npx"`, `args = ["-y", "@myna-sh/mcp"]`);
|
|
4915
|
+
for (const [key, value] of Object.entries(spec.env)) {
|
|
4916
|
+
lines.push(`env.${key} = ${JSON.stringify(value)}`);
|
|
4917
|
+
}
|
|
4918
|
+
}
|
|
4919
|
+
return lines.join("\n");
|
|
4920
|
+
}
|
|
4921
|
+
function serversKey(client) {
|
|
4922
|
+
return client.dialect === "vscode" ? "servers" : "mcpServers";
|
|
4923
|
+
}
|
|
4924
|
+
function readDocument(path) {
|
|
4925
|
+
if (!existsSync7(path)) return {};
|
|
4926
|
+
const raw = readFileSync7(path, "utf8");
|
|
4927
|
+
if (raw.trim() === "") return {};
|
|
4928
|
+
try {
|
|
4929
|
+
const parsed = JSON.parse(raw);
|
|
4930
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
4931
|
+
throw new Error("expected a JSON object");
|
|
4932
|
+
}
|
|
4933
|
+
return parsed;
|
|
4934
|
+
} catch (error) {
|
|
4935
|
+
throw new CliError(
|
|
4936
|
+
`${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}). Fix or move it; refusing to overwrite a file that may hold configuration.`
|
|
4937
|
+
);
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
function existingEntry(client, path, name) {
|
|
4941
|
+
const servers = readDocument(path)[serversKey(client)];
|
|
4942
|
+
if (typeof servers !== "object" || servers === null) return void 0;
|
|
4943
|
+
const entry = servers[name];
|
|
4944
|
+
return typeof entry === "object" && entry !== null ? entry : void 0;
|
|
4945
|
+
}
|
|
4946
|
+
function writeEntry(client, path, name, entry) {
|
|
4947
|
+
const doc = readDocument(path);
|
|
4948
|
+
const key = serversKey(client);
|
|
4949
|
+
const servers = typeof doc[key] === "object" && doc[key] !== null ? doc[key] : {};
|
|
4950
|
+
servers[name] = entry;
|
|
4951
|
+
doc[key] = servers;
|
|
4952
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
4953
|
+
writeFileSync5(path, JSON.stringify(doc, null, 2) + "\n");
|
|
4954
|
+
}
|
|
4955
|
+
function removeEntry(client, path, name) {
|
|
4956
|
+
if (!existsSync7(path)) return false;
|
|
4957
|
+
const doc = readDocument(path);
|
|
4958
|
+
const key = serversKey(client);
|
|
4959
|
+
const servers = doc[key];
|
|
4960
|
+
if (typeof servers !== "object" || servers === null) return false;
|
|
4961
|
+
const map = servers;
|
|
4962
|
+
if (!(name in map)) return false;
|
|
4963
|
+
delete map[name];
|
|
4964
|
+
writeFileSync5(path, JSON.stringify(doc, null, 2) + "\n");
|
|
4965
|
+
return true;
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
// src/commands/mcp.ts
|
|
4969
|
+
var HOSTED_PATH = "/mcp";
|
|
4970
|
+
function resolveTransport(value) {
|
|
4971
|
+
if (value === void 0) return "stdio";
|
|
4972
|
+
if (value === "stdio" || value === "http") return value;
|
|
4973
|
+
throw new UsageError(`--transport must be "stdio" or "http" (got "${String(value)}").`);
|
|
4974
|
+
}
|
|
4975
|
+
function resolveScope(client, requested) {
|
|
4976
|
+
const supported = scopesFor(client);
|
|
4977
|
+
if (requested === void 0) return supported[0];
|
|
4978
|
+
if (requested !== "project" && requested !== "user") {
|
|
4979
|
+
throw new UsageError(`--scope must be "project" or "user" (got "${String(requested)}").`);
|
|
4980
|
+
}
|
|
4981
|
+
if (!supported.includes(requested)) {
|
|
4982
|
+
throw new UsageError(
|
|
4983
|
+
`${client.label} has no ${requested}-scoped configuration file. Supported: ${supported.join(", ")}.`
|
|
4984
|
+
);
|
|
4985
|
+
}
|
|
4986
|
+
return requested;
|
|
4987
|
+
}
|
|
4988
|
+
function resolveClients(names) {
|
|
4989
|
+
if (names.length > 0) return names.map(findClient);
|
|
4990
|
+
const detected = AGENT_CLIENTS.filter(isDetected);
|
|
4991
|
+
if (detected.length === 0) {
|
|
4992
|
+
throw new CliError(
|
|
4993
|
+
`No MCP client detected. Name one explicitly: ${AGENT_CLIENTS.map((c) => c.id).join(", ")}.`
|
|
4994
|
+
);
|
|
4995
|
+
}
|
|
4996
|
+
return detected;
|
|
4997
|
+
}
|
|
4998
|
+
function buildSpec(ctx, transport) {
|
|
4999
|
+
const env = {};
|
|
5000
|
+
if (ctx.apiUrl !== DEFAULT_API_URL2) env.MYNA_API_URL = ctx.apiUrl;
|
|
5001
|
+
if (ctx.organization) env.MYNA_ORGANIZATION = ctx.organization;
|
|
5002
|
+
if (ctx.project) env.MYNA_PROJECT = ctx.project;
|
|
5003
|
+
return { transport, url: `${ctx.apiUrl}${HOSTED_PATH}`, env };
|
|
5004
|
+
}
|
|
5005
|
+
function registerMcp(program) {
|
|
5006
|
+
const mcp = program.command("mcp").description("Connect coding agents to Myna's MCP server");
|
|
5007
|
+
mcp.command("install").description("Write Myna's MCP server into the configuration of one or more coding agents").argument("[clients...]", "client ids (default: every client detected on this machine)").option("--transport <mode>", "stdio (local server, default) or http (hosted server over OAuth)").option("--scope <scope>", "project or user (default: the most specific the client supports)").option("--name <name>", "server name in the client's configuration", "myna").option("--key <token>", "credential to store for the local server (default: the resolved credential)").option("--no-credential", "do not write ~/.config/myna/mcp.json").option("--dir <dir>", "project root for project-scoped files (default: the linked project or cwd)").option("--print", "show what would be written, and write nothing").option("--force", "replace an existing server entry of the same name").action(
|
|
5008
|
+
handle(async (ctx, args, opts) => {
|
|
5009
|
+
const transport = resolveTransport(opts.transport);
|
|
5010
|
+
const name = opts.name;
|
|
5011
|
+
const root = opts.dir ?? ctx.linkedRoot ?? process.cwd();
|
|
5012
|
+
const clients = resolveClients(args[0] ?? []);
|
|
5013
|
+
const spec = buildSpec(ctx, transport);
|
|
5014
|
+
const dryRun = Boolean(opts.print);
|
|
5015
|
+
const token = transport === "stdio" ? opts.key ?? ctx.token : void 0;
|
|
5016
|
+
if (transport === "stdio" && !token) {
|
|
5017
|
+
throw new CliError(
|
|
5018
|
+
"No credential to give the local MCP server. Run `myna login`, pass --key, or use --transport http."
|
|
5019
|
+
);
|
|
5020
|
+
}
|
|
5021
|
+
const results = [];
|
|
5022
|
+
for (const client of clients) {
|
|
5023
|
+
if (client.dialect === "toml") {
|
|
5024
|
+
results.push({ client: client.id, scope: null, path: client.userPath ?? "", action: "printed", reason: client.note });
|
|
5025
|
+
continue;
|
|
5026
|
+
}
|
|
5027
|
+
const scope = resolveScope(client, opts.scope);
|
|
5028
|
+
const path = configPath(client, scope, root);
|
|
5029
|
+
const entry = serverEntry(client, spec);
|
|
5030
|
+
if (!opts.force && existingEntry(client, path, name)) {
|
|
5031
|
+
results.push({
|
|
5032
|
+
client: client.id,
|
|
5033
|
+
scope,
|
|
5034
|
+
path,
|
|
5035
|
+
action: "skipped",
|
|
5036
|
+
reason: `"${name}" is already configured; pass --force to replace it.`
|
|
5037
|
+
});
|
|
5038
|
+
continue;
|
|
5039
|
+
}
|
|
5040
|
+
if (!dryRun) writeEntry(client, path, name, entry);
|
|
5041
|
+
results.push({ client: client.id, scope, path, action: dryRun ? "printed" : "written" });
|
|
5042
|
+
}
|
|
5043
|
+
let credentialPath;
|
|
5044
|
+
if (token && opts.credential !== false && !dryRun) {
|
|
5045
|
+
const patch = { token };
|
|
5046
|
+
if (ctx.apiUrl !== DEFAULT_API_URL2) patch.apiUrl = ctx.apiUrl;
|
|
5047
|
+
writeMcpConfig(patch);
|
|
5048
|
+
credentialPath = mcpConfigFile();
|
|
5049
|
+
}
|
|
5050
|
+
emit({ transport, name, results, credentialPath: credentialPath ?? null }, () => {
|
|
5051
|
+
for (const r of results) {
|
|
5052
|
+
const where = r.scope ? ` (${r.scope})` : "";
|
|
5053
|
+
process.stdout.write(`${r.action === "written" ? "\u2713" : r.action === "skipped" ? "\u2013" : "\xB7"} ${r.client}${where}: ${r.path}
|
|
5054
|
+
`);
|
|
5055
|
+
if (r.reason) process.stdout.write(` ${r.reason}
|
|
5056
|
+
`);
|
|
5057
|
+
}
|
|
5058
|
+
for (const client of clients.filter((c) => c.dialect === "toml")) {
|
|
5059
|
+
process.stdout.write(`
|
|
5060
|
+
Add to ~/${client.userPath}:
|
|
5061
|
+
|
|
5062
|
+
${tomlBlock(name, spec)}
|
|
5063
|
+
`);
|
|
5064
|
+
}
|
|
5065
|
+
if (credentialPath) diag(`
|
|
5066
|
+
Credential written to ${credentialPath} (0600).`);
|
|
5067
|
+
if (transport === "stdio" && token && !token.startsWith("myna_sk_")) {
|
|
5068
|
+
diag(
|
|
5069
|
+
"\nThis is your personal credential, so the agent inherits everything you can do.\nPrefer a scoped key: myna keys create --scopes content:read,content:write,assets:read,assets:write,preview:write,schema:read"
|
|
5070
|
+
);
|
|
5071
|
+
}
|
|
5072
|
+
if (dryRun) {
|
|
5073
|
+
diag("\nNothing written (--print).");
|
|
5074
|
+
} else if (results.some((r) => r.action === "written")) {
|
|
5075
|
+
diag("\nRestart the client to pick up the new server.");
|
|
5076
|
+
}
|
|
5077
|
+
});
|
|
5078
|
+
})
|
|
5079
|
+
);
|
|
5080
|
+
mcp.command("list").description("Show known MCP clients, whether they are installed here, and whether Myna is configured").option("--name <name>", "server name to look for", "myna").option("--dir <dir>", "project root for project-scoped files (default: the linked project or cwd)").action(
|
|
5081
|
+
handle(async (ctx, _args, opts) => {
|
|
5082
|
+
const name = opts.name;
|
|
5083
|
+
const root = opts.dir ?? ctx.linkedRoot ?? process.cwd();
|
|
5084
|
+
const rows = AGENT_CLIENTS.map((client) => {
|
|
5085
|
+
const scopes = scopesFor(client);
|
|
5086
|
+
const configured = client.dialect === "toml" ? null : scopes.filter((scope) => existingEntry(client, configPath(client, scope, root), name) !== void 0);
|
|
5087
|
+
return {
|
|
5088
|
+
id: client.id,
|
|
5089
|
+
label: client.label,
|
|
5090
|
+
detected: isDetected(client),
|
|
5091
|
+
scopes,
|
|
5092
|
+
configured
|
|
5093
|
+
};
|
|
5094
|
+
});
|
|
5095
|
+
emit(
|
|
5096
|
+
rows,
|
|
5097
|
+
() => table(rows, [
|
|
5098
|
+
{ header: "CLIENT", value: (r) => r.id },
|
|
5099
|
+
{ header: "DETECTED", value: (r) => r.detected ? "yes" : "" },
|
|
5100
|
+
{ header: "SCOPES", value: (r) => r.scopes.join(", ") },
|
|
5101
|
+
{
|
|
5102
|
+
header: "MYNA",
|
|
5103
|
+
value: (r) => r.configured === null ? "(manual)" : r.configured.length > 0 ? r.configured.join(", ") : ""
|
|
5104
|
+
}
|
|
5105
|
+
])
|
|
5106
|
+
);
|
|
5107
|
+
})
|
|
5108
|
+
);
|
|
5109
|
+
mcp.command("uninstall").description("Remove Myna's MCP server entry from one or more coding agents").argument("[clients...]", "client ids (default: every client detected on this machine)").option("--scope <scope>", "project or user (default: every scope the client supports)").option("--name <name>", "server name in the client's configuration", "myna").option("--dir <dir>", "project root for project-scoped files (default: the linked project or cwd)").action(
|
|
5110
|
+
handle(async (ctx, args, opts) => {
|
|
5111
|
+
const name = opts.name;
|
|
5112
|
+
const root = opts.dir ?? ctx.linkedRoot ?? process.cwd();
|
|
5113
|
+
const clients = resolveClients(args[0] ?? []);
|
|
5114
|
+
const removed = [];
|
|
5115
|
+
for (const client of clients) {
|
|
5116
|
+
if (client.dialect === "toml") continue;
|
|
5117
|
+
const scopes = opts.scope ? [resolveScope(client, opts.scope)] : scopesFor(client);
|
|
5118
|
+
for (const scope of scopes) {
|
|
5119
|
+
const path = configPath(client, scope, root);
|
|
5120
|
+
if (removeEntry(client, path, name)) removed.push({ client: client.id, scope, path });
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
emit({ name, removed }, () => {
|
|
5124
|
+
if (removed.length === 0) {
|
|
5125
|
+
diag(`No "${name}" server entry found.`);
|
|
5126
|
+
return;
|
|
5127
|
+
}
|
|
5128
|
+
for (const r of removed) process.stdout.write(`\u2713 ${r.client} (${r.scope}): ${r.path}
|
|
5129
|
+
`);
|
|
5130
|
+
diag(`
|
|
5131
|
+
The credential in ${mcpConfigFile()} was left in place. Remove it by hand if nothing else uses it.`);
|
|
5132
|
+
});
|
|
5133
|
+
})
|
|
5134
|
+
);
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
// src/commands/skills.ts
|
|
5138
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
5139
|
+
import { homedir as homedir3 } from "os";
|
|
5140
|
+
import { join as join10 } from "path";
|
|
5141
|
+
|
|
5142
|
+
// src/skills/index.ts
|
|
5143
|
+
function skill(name, description, lines) {
|
|
5144
|
+
return {
|
|
5145
|
+
name,
|
|
5146
|
+
description,
|
|
5147
|
+
// The description is quoted: these sentences contain colons, and a plain
|
|
5148
|
+
// YAML scalar containing ": " is a mapping, not a string. An unquoted one
|
|
5149
|
+
// makes the whole frontmatter block fail to parse.
|
|
5150
|
+
body: ["---", `name: ${name}`, `description: ${JSON.stringify(description)}`, "---", "", ...lines, ""].join("\n")
|
|
5151
|
+
};
|
|
5152
|
+
}
|
|
5153
|
+
var CHANGE_SETS = skill(
|
|
5154
|
+
"myna-change-sets",
|
|
5155
|
+
"How to write content in Myna: every write is a draft on a change set, validated and previewed before a human publishes it. Use when creating, updating, or deleting entries or assets in Myna.",
|
|
5156
|
+
[
|
|
5157
|
+
"# Writing content in Myna",
|
|
5158
|
+
"",
|
|
5159
|
+
"Myna has one product invariant, and it governs everything below:",
|
|
5160
|
+
"",
|
|
5161
|
+
"> Writes create reviewable drafts on a change set. They never publish implicitly.",
|
|
5162
|
+
"> Validation and preview precede a separately authorized, atomic publish.",
|
|
5163
|
+
"",
|
|
5164
|
+
"There is no way to write directly to published content, and you should not look",
|
|
5165
|
+
"for one. A tool that appears to offer it is staging a draft.",
|
|
5166
|
+
"",
|
|
5167
|
+
"## The workflow",
|
|
5168
|
+
"",
|
|
5169
|
+
"1. **Create a change set.** It is the unit of work \u2014 one coherent edit, however",
|
|
5170
|
+
" many entries it touches. Give it a title a reviewer can act on.",
|
|
5171
|
+
"2. **Stage the writes.** Create, update, or delete entries and assets against",
|
|
5172
|
+
" that change set. Each one becomes a draft item on it.",
|
|
5173
|
+
"3. **Validate the whole set.** Not each entry \u2014 the set. Cross-entry problems",
|
|
5174
|
+
" (a broken reference, a required translation missing) only appear here.",
|
|
5175
|
+
"4. **Create one preview.** One URL covers the entire change set. Creating a",
|
|
5176
|
+
" preview per entry is a misuse of the API and floods the project's tokens.",
|
|
5177
|
+
"5. **Stop.** Report the change set id and the preview URL, and let a human",
|
|
5178
|
+
" review it \u2014 unless publishing was explicitly requested *and* your credential",
|
|
5179
|
+
" holds `content:publish`.",
|
|
5180
|
+
"",
|
|
5181
|
+
"```bash",
|
|
5182
|
+
"myna changes create --set title='Autumn refresh'",
|
|
5183
|
+
"myna entries update posts/welcome --change-set chs_... --set title='New title'",
|
|
5184
|
+
"myna changes validate chs_...",
|
|
5185
|
+
"myna preview create chs_...",
|
|
5186
|
+
"```",
|
|
5187
|
+
"",
|
|
5188
|
+
"The MCP equivalents are `myna_create_change_set`, `myna_update_entry`,",
|
|
5189
|
+
"`myna_validate_change_set`, and `myna_create_preview`.",
|
|
5190
|
+
"",
|
|
5191
|
+
"## Read the collection before you write to it",
|
|
5192
|
+
"",
|
|
5193
|
+
"Collections carry `guidance`: the house style anyone writing into them is",
|
|
5194
|
+
"expected to follow, plus `policy` rules that will fail validation if broken",
|
|
5195
|
+
"(length budgets, required fields, allowed values). Fetch the schema first \u2014",
|
|
5196
|
+
"`myna_get_collection_schema`, or `myna schema get <collection>` \u2014 and follow it.",
|
|
5197
|
+
"A title that busts a length budget fails at validation, not at publish, so",
|
|
5198
|
+
"there is no reason to discover it late.",
|
|
5199
|
+
"",
|
|
5200
|
+
"## Concurrency",
|
|
5201
|
+
"",
|
|
5202
|
+
"- Pass `expectedRevisionId` when updating an entry you have read. Without it,",
|
|
5203
|
+
" two writers silently overwrite each other; with it, the loser gets a clear",
|
|
5204
|
+
" conflict.",
|
|
5205
|
+
"- An entry may be staged on several open change sets at once. Yours builds on",
|
|
5206
|
+
" your change set's own base, not on anyone else's draft.",
|
|
5207
|
+
"- If publish reports `STALE_REVISION`, the entry moved underneath you. Run a",
|
|
5208
|
+
" rebase (`myna changes rebase`, `myna_rebase_change_set`) \u2014 it performs the",
|
|
5209
|
+
" three-way merge and re-opens review, because the merged result is not what",
|
|
5210
|
+
" the reviewer approved. Never work around it by re-staging over the top.",
|
|
5211
|
+
"",
|
|
5212
|
+
"## Deleting",
|
|
5213
|
+
"",
|
|
5214
|
+
"Deletes are staged like any other write, and they need explicit confirmation:",
|
|
5215
|
+
"`--confirm-delete` on the CLI, `confirm: true` on the MCP tool. An unconfirmed",
|
|
5216
|
+
"destructive call is refused, not queued.",
|
|
5217
|
+
"",
|
|
5218
|
+
"## What to report back",
|
|
5219
|
+
"",
|
|
5220
|
+
"The change set id, what it contains, the validation result, and the preview",
|
|
5221
|
+
'URL. Not "published" \u2014 you did not publish.'
|
|
5222
|
+
]
|
|
5223
|
+
);
|
|
5224
|
+
var SCHEMA = skill(
|
|
5225
|
+
"myna-schema",
|
|
5226
|
+
"How Myna collection schemas work: code-defined, immutably versioned, deployed with a reviewed diff. Use when adding or changing a collection, field, or generated content types.",
|
|
5227
|
+
[
|
|
5228
|
+
"# Schemas in Myna",
|
|
5229
|
+
"",
|
|
5230
|
+
"Schemas are code. They live in the project's schema directory (`myna/` by",
|
|
5231
|
+
"default), are written with the DSL from `@myna-sh/sdk/schema`, and are deployed",
|
|
5232
|
+
"by pushing a diff \u2014 never edited through a UI, and never edited in the database.",
|
|
5233
|
+
"Every deployed version is immutable; a change creates a new version.",
|
|
5234
|
+
"",
|
|
5235
|
+
"## Changing a schema",
|
|
5236
|
+
"",
|
|
5237
|
+
"```bash",
|
|
5238
|
+
"myna schema diff # what would change, and how dangerous it is",
|
|
5239
|
+
"myna schema push # deploy it",
|
|
5240
|
+
"myna types generate # regenerate the typed client surface",
|
|
5241
|
+
"```",
|
|
5242
|
+
"",
|
|
5243
|
+
"`schema diff` classifies the change. An **additive** diff is safe. A diff that",
|
|
5244
|
+
"removes a field, narrows a type, or changes a key is **destructive** and",
|
|
5245
|
+
"`push` refuses it without `--allow-destructive`. That flag is a statement that",
|
|
5246
|
+
"the data loss is intended \u2014 check what is stored in the affected fields first.",
|
|
5247
|
+
"",
|
|
5248
|
+
"## Drift goes both ways",
|
|
5249
|
+
"",
|
|
5250
|
+
"`myna schema drift` compares the repository against what is deployed. When they",
|
|
5251
|
+
"disagree, decide which one is right before acting:",
|
|
5252
|
+
"",
|
|
5253
|
+
"- the repository is right \u2192 `myna schema push`",
|
|
5254
|
+
"- the deployed schema is right \u2192 `myna schema pull --open-pr`, which brings it",
|
|
5255
|
+
" back as a reviewable pull request rather than a silent local edit",
|
|
5256
|
+
"",
|
|
5257
|
+
"## Generated types",
|
|
5258
|
+
"",
|
|
5259
|
+
'`myna types generate` writes a file carrying a "do not edit by hand" header.',
|
|
5260
|
+
"Believe the header. Codegen is deterministic, so any hand edit is reverted by",
|
|
5261
|
+
"the next run and `myna doctor` reports the file as stale in the meantime.",
|
|
5262
|
+
"",
|
|
5263
|
+
"## Guidance and policy belong in the schema",
|
|
5264
|
+
"",
|
|
5265
|
+
"If a collection has a rule \u2014 a title budget, a required summary, an allowed set",
|
|
5266
|
+
"of values \u2014 express it as schema `policy` so validation enforces it for every",
|
|
5267
|
+
"writer, human or agent. Do not enforce it in a build script: a build-time gate",
|
|
5268
|
+
"fails after the content is already staged, and only for the one pipeline that",
|
|
5269
|
+
"runs it. A rule that cannot be expressed as policy is a gap in Myna worth",
|
|
5270
|
+
"reporting, not a script worth writing."
|
|
5271
|
+
]
|
|
5272
|
+
);
|
|
5273
|
+
var PUBLISHING = skill(
|
|
5274
|
+
"myna-publishing",
|
|
5275
|
+
"How publishing, releases, and reverts work in Myna, and when an agent may publish. Use before publishing a change set, reverting a release, or reading content as of a past release.",
|
|
5276
|
+
[
|
|
5277
|
+
"# Publishing, releases, and reverts",
|
|
5278
|
+
"",
|
|
5279
|
+
"Publishing is a separate, explicitly authorized action. It is atomic over the",
|
|
5280
|
+
"whole change set, and it assigns the set a sequential release number \u2014 that",
|
|
5281
|
+
"number is what makes it a release.",
|
|
5282
|
+
"",
|
|
5283
|
+
"## Before you publish",
|
|
5284
|
+
"",
|
|
5285
|
+
"Do not publish unless **both** are true:",
|
|
5286
|
+
"",
|
|
5287
|
+
'1. The user asked for it, in this task, in so many words. "Update the pricing',
|
|
5288
|
+
' page" is not a request to publish it.',
|
|
5289
|
+
"2. Your credential holds `content:publish`. Many agent keys deliberately do not,",
|
|
5290
|
+
" and that is the design working, not an obstacle to route around.",
|
|
5291
|
+
"",
|
|
5292
|
+
"Publishing needs explicit confirmation \u2014 `--confirm-publish` on the CLI,",
|
|
5293
|
+
"`confirm: true` on `myna_publish_change_set` \u2014 and it can still be refused by",
|
|
5294
|
+
"the project's gates:",
|
|
5295
|
+
"",
|
|
5296
|
+
"- a number of required approvals",
|
|
5297
|
+
"- the built-in check suite passing",
|
|
5298
|
+
"- any check the project declared `required`, reported by an outside system",
|
|
5299
|
+
"",
|
|
5300
|
+
"A refused publish is information. Report which gate is unmet; do not try to",
|
|
5301
|
+
"disable the gate.",
|
|
5302
|
+
"",
|
|
5303
|
+
"## Reverting",
|
|
5304
|
+
"",
|
|
5305
|
+
"A revert does not undo anything directly. It generates a **new open change set**",
|
|
5306
|
+
"staging the inverse of every item in the release, which then goes through",
|
|
5307
|
+
"validation, review, and the same publish gates as any other change. It needs",
|
|
5308
|
+
"only `content:write`, because it publishes nothing.",
|
|
5309
|
+
"",
|
|
5310
|
+
"```bash",
|
|
5311
|
+
"myna releases list",
|
|
5312
|
+
"myna releases revert <release> # creates a change set; review it, then publish",
|
|
5313
|
+
"```",
|
|
5314
|
+
"",
|
|
5315
|
+
"## Reading a past release",
|
|
5316
|
+
"",
|
|
5317
|
+
"The public content API takes `?at=<release>`, which serves a collection exactly",
|
|
5318
|
+
"as that release served it \u2014 including the slug each entry carried at the time.",
|
|
5319
|
+
"Two properties matter when reasoning about it:",
|
|
5320
|
+
"",
|
|
5321
|
+
"- Visibility is read live, never at the pin. Pinning reproduces content, not",
|
|
5322
|
+
" access decisions: a collection made private since is private now.",
|
|
5323
|
+
"- `?at=` and `?preview=` are mutually exclusive. One reads what shipped, the",
|
|
5324
|
+
" other what has not. Asking for both is an error rather than a guess.",
|
|
5325
|
+
"",
|
|
5326
|
+
"A site that pins its content sets the release in `myna.lock`, and publishing",
|
|
5327
|
+
"opens a pull request moving that pin. Nothing deploys behind the repository's",
|
|
5328
|
+
"back."
|
|
5329
|
+
]
|
|
5330
|
+
);
|
|
5331
|
+
var SKILLS = [CHANGE_SETS, SCHEMA, PUBLISHING];
|
|
5332
|
+
|
|
5333
|
+
// src/commands/skills.ts
|
|
5334
|
+
function destinations(scope, root, extra) {
|
|
5335
|
+
const home = homedir3();
|
|
5336
|
+
if (scope === "project") {
|
|
5337
|
+
return [
|
|
5338
|
+
{ id: "project", dir: join10(root, ".claude", "skills") },
|
|
5339
|
+
...extra.map((dir, i) => ({ id: `target-${i + 1}`, dir }))
|
|
5340
|
+
];
|
|
5341
|
+
}
|
|
5342
|
+
return [
|
|
5343
|
+
// The convention Claude Code, Codex, and others now read from.
|
|
5344
|
+
{ id: "agents", dir: join10(home, ".agents", "skills") },
|
|
5345
|
+
{ id: "claude", dir: join10(home, ".claude", "skills"), requiresExisting: join10(home, ".claude") },
|
|
5346
|
+
...extra.map((dir, i) => ({ id: `target-${i + 1}`, dir }))
|
|
5347
|
+
];
|
|
5348
|
+
}
|
|
5349
|
+
function registerSkills(program) {
|
|
5350
|
+
const skills = program.command("skills").description("Install Myna's agent skills for coding agents");
|
|
5351
|
+
skills.command("install").description("Write Myna's agent skills into the skill directories on this machine").option("--scope <scope>", "user (default) or project", "user").option("--target <dir>", "additional skill directory to write to (repeatable)", (value, previous = []) => [...previous, value], []).option("--dir <dir>", "project root for --scope project (default: the linked project or cwd)").option("--print", "show what would be written, and write nothing").option("--force", "overwrite skill files that have been edited").action(
|
|
5352
|
+
handle(async (ctx, _args, opts) => {
|
|
5353
|
+
const scope = opts.scope;
|
|
5354
|
+
if (scope !== "user" && scope !== "project") {
|
|
5355
|
+
throw new UsageError(`--scope must be "user" or "project" (got "${scope}").`);
|
|
5356
|
+
}
|
|
5357
|
+
const root = opts.dir ?? ctx.linkedRoot ?? process.cwd();
|
|
5358
|
+
const dryRun = Boolean(opts.print);
|
|
5359
|
+
const targets = destinations(scope, root, opts.target ?? []).filter(
|
|
5360
|
+
(d) => !d.requiresExisting || existsSync8(d.requiresExisting)
|
|
5361
|
+
);
|
|
5362
|
+
const results = [];
|
|
5363
|
+
for (const target of targets) {
|
|
5364
|
+
for (const skill2 of SKILLS) {
|
|
5365
|
+
const path = join10(target.dir, skill2.name, "SKILL.md");
|
|
5366
|
+
const current = existsSync8(path) ? readFileSync8(path, "utf8") : void 0;
|
|
5367
|
+
if (current === skill2.body) {
|
|
5368
|
+
results.push({ skill: skill2.name, path, action: "unchanged" });
|
|
5369
|
+
continue;
|
|
5370
|
+
}
|
|
5371
|
+
if (current !== void 0 && !opts.force) {
|
|
5372
|
+
results.push({ skill: skill2.name, path, action: "skipped" });
|
|
5373
|
+
continue;
|
|
5374
|
+
}
|
|
5375
|
+
if (!dryRun) {
|
|
5376
|
+
mkdirSync5(join10(target.dir, skill2.name), { recursive: true });
|
|
5377
|
+
writeFileSync6(path, skill2.body);
|
|
5378
|
+
}
|
|
5379
|
+
results.push({ skill: skill2.name, path, action: "written" });
|
|
5380
|
+
}
|
|
5381
|
+
}
|
|
5382
|
+
const written = results.filter((r) => r.action === "written").length;
|
|
5383
|
+
const skipped = results.filter((r) => r.action === "skipped").length;
|
|
5384
|
+
emit({ scope, written, skipped, results }, () => {
|
|
5385
|
+
if (targets.length === 0) {
|
|
5386
|
+
diag("No skill directory to write to.");
|
|
5387
|
+
return;
|
|
5388
|
+
}
|
|
5389
|
+
for (const r of results) {
|
|
5390
|
+
const icon = r.action === "written" ? "\u2713" : r.action === "unchanged" ? "=" : "\u2013";
|
|
5391
|
+
process.stdout.write(`${icon} ${r.path}
|
|
5392
|
+
`);
|
|
5393
|
+
}
|
|
5394
|
+
if (skipped > 0) diag(`
|
|
5395
|
+
${skipped} file(s) differ from the bundled version and were left alone. Pass --force to update them.`);
|
|
5396
|
+
diag(dryRun ? "\nNothing written (--print)." : `
|
|
5397
|
+
${written} skill file(s) written.`);
|
|
5398
|
+
});
|
|
5399
|
+
})
|
|
5400
|
+
);
|
|
5401
|
+
skills.command("list").description("List the agent skills bundled with this CLI").action(
|
|
5402
|
+
handle(async () => {
|
|
5403
|
+
emit(
|
|
5404
|
+
SKILLS.map((s) => ({ name: s.name, description: s.description })),
|
|
5405
|
+
() => table(SKILLS, [
|
|
5406
|
+
{ header: "SKILL", value: (s) => s.name },
|
|
5407
|
+
{ header: "DESCRIPTION", value: (s) => s.description.split(". ")[0] + "." }
|
|
5408
|
+
])
|
|
5409
|
+
);
|
|
5410
|
+
})
|
|
5411
|
+
);
|
|
5412
|
+
}
|
|
5413
|
+
|
|
4067
5414
|
// src/commands/doctor.ts
|
|
4068
|
-
import { existsSync as
|
|
4069
|
-
import { join as
|
|
5415
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
5416
|
+
import { join as join11 } from "path";
|
|
4070
5417
|
|
|
4071
5418
|
// src/registry.ts
|
|
4072
5419
|
var NPM_REGISTRY = "https://registry.npmjs.org";
|
|
@@ -4105,7 +5452,7 @@ function installCommand(manager, version) {
|
|
|
4105
5452
|
}
|
|
4106
5453
|
|
|
4107
5454
|
// src/version.ts
|
|
4108
|
-
var VERSION = true ? "0.
|
|
5455
|
+
var VERSION = true ? "0.14.0" : "0.0.0-dev";
|
|
4109
5456
|
var IS_RELEASE_BUILD = true;
|
|
4110
5457
|
|
|
4111
5458
|
// src/commands/doctor.ts
|
|
@@ -4279,7 +5626,7 @@ async function checkOrigin(ctx, origin) {
|
|
|
4279
5626
|
}
|
|
4280
5627
|
async function checkSchema(ctx, schemaDir) {
|
|
4281
5628
|
const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
|
|
4282
|
-
if (!
|
|
5629
|
+
if (!existsSync9(dir)) {
|
|
4283
5630
|
return check("schema.drift", "Local schema", "skip", `No schema directory at ${dir}.`);
|
|
4284
5631
|
}
|
|
4285
5632
|
if (!ctx.project) {
|
|
@@ -4329,7 +5676,7 @@ function findGeneratedTypes(root, depth = 4) {
|
|
|
4329
5676
|
const dirs = [];
|
|
4330
5677
|
for (const entry of entries) {
|
|
4331
5678
|
if (SCAN_IGNORE.has(entry) || entry.startsWith(".")) continue;
|
|
4332
|
-
const full =
|
|
5679
|
+
const full = join11(root, entry);
|
|
4333
5680
|
let stats;
|
|
4334
5681
|
try {
|
|
4335
5682
|
stats = statSync2(full);
|
|
@@ -4342,7 +5689,7 @@ function findGeneratedTypes(root, depth = 4) {
|
|
|
4342
5689
|
}
|
|
4343
5690
|
if (!/\.(ts|d\.ts)$/.test(entry)) continue;
|
|
4344
5691
|
try {
|
|
4345
|
-
if (looksGenerated(
|
|
5692
|
+
if (looksGenerated(readFileSync9(full, "utf8"))) return full;
|
|
4346
5693
|
} catch {
|
|
4347
5694
|
}
|
|
4348
5695
|
}
|
|
@@ -4359,16 +5706,16 @@ async function checkTypes(ctx, explicit, schemaDir) {
|
|
|
4359
5706
|
if (!file) {
|
|
4360
5707
|
return check("types.freshness", "Generated types", "skip", "No generated types file found.");
|
|
4361
5708
|
}
|
|
4362
|
-
if (!
|
|
5709
|
+
if (!existsSync9(file)) {
|
|
4363
5710
|
return check("types.freshness", "Generated types", "fail", `${file} does not exist.`);
|
|
4364
5711
|
}
|
|
4365
5712
|
const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
|
|
4366
|
-
if (!
|
|
5713
|
+
if (!existsSync9(dir)) {
|
|
4367
5714
|
return check("types.freshness", "Generated types", "skip", `Found ${file} but no schema directory to compare against.`);
|
|
4368
5715
|
}
|
|
4369
5716
|
try {
|
|
4370
5717
|
const expected = generateTypesModule(await loadLocalSchemas(dir));
|
|
4371
|
-
return
|
|
5718
|
+
return readFileSync9(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
|
|
4372
5719
|
"types.freshness",
|
|
4373
5720
|
"Generated types",
|
|
4374
5721
|
"warn",
|
|
@@ -4484,7 +5831,7 @@ function registerUpdate(program) {
|
|
|
4484
5831
|
|
|
4485
5832
|
// src/commands/sync.ts
|
|
4486
5833
|
import { readdir as readdir2, stat as stat2 } from "fs/promises";
|
|
4487
|
-
import { join as
|
|
5834
|
+
import { join as join12 } from "path";
|
|
4488
5835
|
async function isDirectory(path) {
|
|
4489
5836
|
const info = await stat2(path).catch(() => void 0);
|
|
4490
5837
|
return Boolean(info?.isDirectory());
|
|
@@ -4499,7 +5846,7 @@ async function planDirectories(dir, collection) {
|
|
|
4499
5846
|
const plan = [];
|
|
4500
5847
|
for (const name of children.sort()) {
|
|
4501
5848
|
if (name.startsWith(".")) continue;
|
|
4502
|
-
const full =
|
|
5849
|
+
const full = join12(dir, name);
|
|
4503
5850
|
if (await isDirectory(full)) plan.push({ collection: name, path: full });
|
|
4504
5851
|
}
|
|
4505
5852
|
if (plan.length === 0) {
|
|
@@ -4670,9 +6017,13 @@ function buildProgram() {
|
|
|
4670
6017
|
registerSync(program);
|
|
4671
6018
|
registerChanges(program);
|
|
4672
6019
|
registerReleases(program);
|
|
6020
|
+
registerFeedback(program);
|
|
4673
6021
|
registerPull(program);
|
|
4674
6022
|
registerPreviews(program);
|
|
4675
6023
|
registerAssets(program);
|
|
6024
|
+
registerMcp(program);
|
|
6025
|
+
registerSkills(program);
|
|
6026
|
+
registerApi(program);
|
|
4676
6027
|
registerAdmin(program);
|
|
4677
6028
|
return program;
|
|
4678
6029
|
}
|