@myna-sh/cli 0.13.0 → 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/dist/main.js CHANGED
@@ -673,6 +673,147 @@ var ManagementClient = class {
673
673
  body
674
674
  )
675
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
+ };
676
817
  // --- Declared external checks ---------------------------------------------
677
818
  checks = {
678
819
  list: (project, signal) => this.get(`/projects/${enc(project)}/checks`, void 0, signal),
@@ -2978,6 +3119,452 @@ function registerReleases(program) {
2978
3119
  );
2979
3120
  }
2980
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
+
2981
3568
  // src/commands/pull.ts
2982
3569
  import { execSync } from "child_process";
2983
3570
  import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
@@ -3087,7 +3674,18 @@ var ID_PREFIXES = {
3087
3674
  webauthnCredential: "pky",
3088
3675
  webauthnChallenge: "wac",
3089
3676
  githubInstallation: "ghi",
3090
- 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"
3091
3689
  };
3092
3690
  var PREFIX_SET = new Set(Object.values(ID_PREFIXES));
3093
3691
 
@@ -3123,7 +3721,12 @@ var PLANS = {
3123
3721
  monthlyPublicApiRequests: 1e4,
3124
3722
  maxWebhookEndpoints: 1,
3125
3723
  revisionRetentionDays: 30,
3126
- maxUploadBytes: 50 * MB
3724
+ maxUploadBytes: 50 * MB,
3725
+ monthlyFeedbackReports: 500,
3726
+ feedbackAttachmentBytes: 1 * GB,
3727
+ feedbackRetentionDays: 90,
3728
+ maxBoards: 3,
3729
+ maxAttachmentBytes: 100 * MB
3127
3730
  },
3128
3731
  pro: {
3129
3732
  key: "pro",
@@ -3136,7 +3739,12 @@ var PLANS = {
3136
3739
  monthlyPublicApiRequests: 5e5,
3137
3740
  maxWebhookEndpoints: 10,
3138
3741
  revisionRetentionDays: null,
3139
- maxUploadBytes: 250 * MB
3742
+ maxUploadBytes: 250 * MB,
3743
+ monthlyFeedbackReports: 25e3,
3744
+ feedbackAttachmentBytes: 25 * GB,
3745
+ feedbackRetentionDays: null,
3746
+ maxBoards: 25,
3747
+ maxAttachmentBytes: 500 * MB
3140
3748
  },
3141
3749
  /**
3142
3750
  * Myna's own organizations — the changelog, and anything else we run on the
@@ -3155,10 +3763,28 @@ var PLANS = {
3155
3763
  monthlyPublicApiRequests: UNLIMITED,
3156
3764
  maxWebhookEndpoints: UNLIMITED,
3157
3765
  revisionRetentionDays: null,
3158
- maxUploadBytes: UNLIMITED
3766
+ maxUploadBytes: UNLIMITED,
3767
+ monthlyFeedbackReports: UNLIMITED,
3768
+ feedbackAttachmentBytes: UNLIMITED,
3769
+ feedbackRetentionDays: null,
3770
+ maxBoards: UNLIMITED,
3771
+ maxAttachmentBytes: UNLIMITED
3159
3772
  }
3160
3773
  };
3161
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
+
3162
3788
  // ../shared/dist/rank.js
3163
3789
  var DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3164
3790
  var MIN_DIGIT = DIGITS[0];
@@ -4826,7 +5452,7 @@ function installCommand(manager, version) {
4826
5452
  }
4827
5453
 
4828
5454
  // src/version.ts
4829
- var VERSION = true ? "0.13.0" : "0.0.0-dev";
5455
+ var VERSION = true ? "0.14.0" : "0.0.0-dev";
4830
5456
  var IS_RELEASE_BUILD = true;
4831
5457
 
4832
5458
  // src/commands/doctor.ts
@@ -5391,6 +6017,7 @@ function buildProgram() {
5391
6017
  registerSync(program);
5392
6018
  registerChanges(program);
5393
6019
  registerReleases(program);
6020
+ registerFeedback(program);
5394
6021
  registerPull(program);
5395
6022
  registerPreviews(program);
5396
6023
  registerAssets(program);