@extenshi/cli 0.21.0 → 0.23.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.
Files changed (128) hide show
  1. package/README.md +4 -0
  2. package/dist/amo-source-detect.d.ts +18 -0
  3. package/dist/amo-source-detect.d.ts.map +1 -0
  4. package/dist/amo-source-detect.js +41 -0
  5. package/dist/amo-source-detect.js.map +1 -0
  6. package/dist/amo-source.d.ts +41 -0
  7. package/dist/amo-source.d.ts.map +1 -0
  8. package/dist/amo-source.js +240 -0
  9. package/dist/amo-source.js.map +1 -0
  10. package/dist/amowebstore-md.d.ts +38 -0
  11. package/dist/amowebstore-md.d.ts.map +1 -0
  12. package/dist/amowebstore-md.js +118 -0
  13. package/dist/amowebstore-md.js.map +1 -0
  14. package/dist/auto-evidence.d.ts +154 -0
  15. package/dist/auto-evidence.d.ts.map +1 -0
  16. package/dist/auto-evidence.js +297 -0
  17. package/dist/auto-evidence.js.map +1 -0
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +366 -11
  20. package/dist/cli.js.map +1 -1
  21. package/dist/domain.d.ts +40 -0
  22. package/dist/domain.d.ts.map +1 -0
  23. package/dist/domain.js +64 -0
  24. package/dist/domain.js.map +1 -0
  25. package/dist/edgewebstore-md.d.ts +26 -0
  26. package/dist/edgewebstore-md.d.ts.map +1 -0
  27. package/dist/edgewebstore-md.js +95 -0
  28. package/dist/edgewebstore-md.js.map +1 -0
  29. package/dist/evidence-ingest.d.ts +60 -0
  30. package/dist/evidence-ingest.d.ts.map +1 -0
  31. package/dist/evidence-ingest.js +141 -0
  32. package/dist/evidence-ingest.js.map +1 -0
  33. package/dist/extension-localization.d.ts +6 -1
  34. package/dist/extension-localization.d.ts.map +1 -1
  35. package/dist/extension-localization.js +4 -0
  36. package/dist/extension-localization.js.map +1 -1
  37. package/dist/firefox-rdp.d.ts +55 -0
  38. package/dist/firefox-rdp.d.ts.map +1 -0
  39. package/dist/firefox-rdp.js +203 -0
  40. package/dist/firefox-rdp.js.map +1 -0
  41. package/dist/gecko-id.d.ts +55 -0
  42. package/dist/gecko-id.d.ts.map +1 -0
  43. package/dist/gecko-id.js +132 -0
  44. package/dist/gecko-id.js.map +1 -0
  45. package/dist/generate-listing.d.ts +19 -8
  46. package/dist/generate-listing.d.ts.map +1 -1
  47. package/dist/generate-listing.js +66 -18
  48. package/dist/generate-listing.js.map +1 -1
  49. package/dist/icon.d.ts +18 -0
  50. package/dist/icon.d.ts.map +1 -1
  51. package/dist/icon.js +29 -20
  52. package/dist/icon.js.map +1 -1
  53. package/dist/landing-page.d.ts +22 -4
  54. package/dist/landing-page.d.ts.map +1 -1
  55. package/dist/landing-page.js +14 -11
  56. package/dist/landing-page.js.map +1 -1
  57. package/dist/landing-sections.d.ts +332 -0
  58. package/dist/landing-sections.d.ts.map +1 -0
  59. package/dist/landing-sections.js +1071 -0
  60. package/dist/landing-sections.js.map +1 -0
  61. package/dist/listing.d.ts +15 -0
  62. package/dist/listing.d.ts.map +1 -1
  63. package/dist/listing.js +1 -1
  64. package/dist/listing.js.map +1 -1
  65. package/dist/media.d.ts +42 -0
  66. package/dist/media.d.ts.map +1 -0
  67. package/dist/media.js +96 -0
  68. package/dist/media.js.map +1 -0
  69. package/dist/package.d.ts +163 -0
  70. package/dist/package.d.ts.map +1 -0
  71. package/dist/package.js +500 -0
  72. package/dist/package.js.map +1 -0
  73. package/dist/page.d.ts +110 -21
  74. package/dist/page.d.ts.map +1 -1
  75. package/dist/page.js +240 -10
  76. package/dist/page.js.map +1 -1
  77. package/dist/pay-contract.d.ts +42 -11
  78. package/dist/pay-contract.d.ts.map +1 -1
  79. package/dist/pay-contract.js +31 -0
  80. package/dist/pay-contract.js.map +1 -1
  81. package/dist/pay-test-cycle.d.ts +40 -0
  82. package/dist/pay-test-cycle.d.ts.map +1 -0
  83. package/dist/pay-test-cycle.js +207 -0
  84. package/dist/pay-test-cycle.js.map +1 -0
  85. package/dist/pay.d.ts +1 -1
  86. package/dist/pay.d.ts.map +1 -1
  87. package/dist/pay.js +1 -0
  88. package/dist/pay.js.map +1 -1
  89. package/dist/project.d.ts +69 -1
  90. package/dist/project.d.ts.map +1 -1
  91. package/dist/project.js +363 -54
  92. package/dist/project.js.map +1 -1
  93. package/dist/publish.d.ts +33 -0
  94. package/dist/publish.d.ts.map +1 -1
  95. package/dist/publish.js +113 -2
  96. package/dist/publish.js.map +1 -1
  97. package/dist/review-checks.d.ts +13 -0
  98. package/dist/review-checks.d.ts.map +1 -1
  99. package/dist/review-checks.js.map +1 -1
  100. package/dist/review-risk.d.ts +10 -1
  101. package/dist/review-risk.d.ts.map +1 -1
  102. package/dist/review-risk.js +1 -0
  103. package/dist/review-risk.js.map +1 -1
  104. package/dist/scan.d.ts +3 -2
  105. package/dist/scan.d.ts.map +1 -1
  106. package/dist/scan.js +4 -2
  107. package/dist/scan.js.map +1 -1
  108. package/dist/screenshots-scenes.d.ts +1383 -0
  109. package/dist/screenshots-scenes.d.ts.map +1 -0
  110. package/dist/screenshots-scenes.js +276 -0
  111. package/dist/screenshots-scenes.js.map +1 -0
  112. package/dist/screenshots.d.ts +143 -0
  113. package/dist/screenshots.d.ts.map +1 -0
  114. package/dist/screenshots.js +558 -0
  115. package/dist/screenshots.js.map +1 -0
  116. package/dist/smoke.d.ts +81 -0
  117. package/dist/smoke.d.ts.map +1 -0
  118. package/dist/smoke.js +491 -0
  119. package/dist/smoke.js.map +1 -0
  120. package/dist/store-assets.d.ts +139 -0
  121. package/dist/store-assets.d.ts.map +1 -0
  122. package/dist/store-assets.js +582 -0
  123. package/dist/store-assets.js.map +1 -0
  124. package/dist/zip.d.ts +57 -0
  125. package/dist/zip.d.ts.map +1 -0
  126. package/dist/zip.js +134 -0
  127. package/dist/zip.js.map +1 -0
  128. package/package.json +7 -6
@@ -1 +1 @@
1
- {"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAgB3F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAIxC,KAAK,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAE/B,qBAAa,eAAgB,SAAQ,KAAK;IAExC,QAAQ,CAAC,QAAQ,EAAE,MAAM;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM;gBAFb,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA;CAMvB;AACD,wBAAsB,cAAc,CACnC,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,EAC9C,MAAM,EAAE,MAAM,EACd,KAAK,GAAE,OAAY,EACnB,QAAQ,UAAQ,GACd,OAAO,CAAC,IAAI,CAAC,CAyCf;AAED,eAAO,MAAM,cAAc,4BAAe,CAAA;AAK1C,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,iBAe5D;AACD,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDhE;AA6GD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QAggBvD"}
1
+ {"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"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"}
package/dist/project.js CHANGED
@@ -2,25 +2,47 @@
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
- import { lstat, mkdir, open, readFile, readlink, rename, rm, stat } from 'node:fs/promises';
5
+ import { lstat, mkdir, open, readdir, readFile, readlink, rename, rm, stat } from 'node:fs/promises';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
- import { agentEvidenceSchema, EVIDENCE_ATTACHMENT_MAX_BYTES, evidenceAttachmentSchema, hashMetadata, importManifestSchema, workspacePatchSchema, } from '@extenshi/contracts';
8
+ import { agentCreateProjectSchema, agentEvidenceSchema, EVIDENCE_ATTACHMENT_MAX_BYTES, EVIDENCE_ATTACHMENTS_MAX, evidenceAttachmentSchema, hashMetadata, importManifestSchema, proposeDecisionSchema, workspacePatchSchema, } from '@extenshi/contracts';
9
9
  import { loadConfig } from './config.js';
10
10
  import { assertNotSecretLike, attachmentContentType } from './evidence-attachments.js';
11
+ import { InvalidInputError } from './expected-error.js';
12
+ import { isExtensionLocale } from './extension-localization.js';
13
+ import { readLocaleMessages } from './listing.js';
14
+ import { resolvePackagedArtifact } from './package.js';
11
15
  import { loadArtifact } from './review-archive.js';
12
16
  const cliVersion = createRequire(import.meta.url)('../package.json').version;
13
17
  export class ProjectApiError extends Error {
14
18
  category;
15
19
  status;
16
20
  code;
17
- constructor(category, status, code) {
18
- super(`Project API: ${category} (${status}). ${code === 'PROJECT_PREMIUM_REQUIRED' ? 'Automated release readiness requires a Pro project. ' : ''}${category === 'auth' ? 'Run extenshi login, or reconnect your OAuth client.' : category === 'unsupported' ? 'The server does not support this command yet.' : 'Run extenshi doctor --json for diagnostics.'}`);
21
+ detail;
22
+ constructor(category, status, code,
23
+ /** The server's own reason for a 4xx refusal (e.g. why an image was refused). */
24
+ detail) {
25
+ super(`Project API: ${category} (${status}). ${detail ? `${detail} ` : ''}${code === 'PROJECT_PREMIUM_REQUIRED' ? 'Automated release readiness requires a Pro project. ' : ''}${codeHint(code)}${category === 'auth' ? 'Run extenshi login, or reconnect your OAuth client.' : category === 'unsupported' ? 'The server does not support this command yet.' : 'Run extenshi doctor --json for diagnostics.'}`);
19
26
  this.category = category;
20
27
  this.status = status;
21
28
  this.code = code;
29
+ this.detail = detail;
22
30
  }
23
31
  }
32
+ /**
33
+ * Stable codes the backend authors (never provider or user text) — the only
34
+ * server messages echoed, so a secret in an error body still cannot leak.
35
+ */
36
+ const PASSTHROUGH_CODE = /^(PROJECT_PREMIUM_REQUIRED|OWNER_SESSION_REQUIRED|IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_INPUT|DEV_PROJECT_LIMIT_[A-Z_]+|DECISION_[A-Z_]+(?::[A-Za-z.,]+)?)$/;
37
+ function codeHint(code) {
38
+ if (!code || code === 'PROJECT_PREMIUM_REQUIRED')
39
+ return '';
40
+ if (code.startsWith('DECISION_') || code === 'OWNER_SESSION_REQUIRED')
41
+ return `${code}: owner decisions are decided on the Dojo project page; agents and API keys can only propose them. `;
42
+ if (code.startsWith('DEV_PROJECT_LIMIT_'))
43
+ return `${code}: this plan's limit is reached (see Dojo billing). `;
44
+ return `${code}: a retried request reuses its key; different input needs a new idempotency key. `;
45
+ }
24
46
  export async function projectRequest(config, method, input = {}, mutation = false) {
25
47
  if (!config.apiKey)
26
48
  throw new ProjectApiError('auth', 401);
@@ -57,7 +79,16 @@ export async function projectRequest(config, method, input = {}, mutation = fals
57
79
  : 'unsupported'
58
80
  : status >= 500
59
81
  ? 'server'
60
- : 'validation', status, body?.error?.message === 'PROJECT_PREMIUM_REQUIRED' ? 'PROJECT_PREMIUM_REQUIRED' : undefined);
82
+ : 'validation', status, typeof body?.error?.message === 'string' && PASSTHROUGH_CODE.test(body.error.message)
83
+ ? body.error.message
84
+ : undefined,
85
+ // 4xx reasons are written for the caller (BAD_REQUEST / FORBIDDEN / PRECONDITION_FAILED);
86
+ // 5xx messages are not surfaced. Stable codes travel as `code` above instead.
87
+ [400, 403, 412, 413].includes(status) &&
88
+ typeof body?.error?.message === 'string' &&
89
+ !PASSTHROUGH_CODE.test(body.error.message)
90
+ ? body.error.message.slice(0, 500)
91
+ : undefined);
61
92
  }
62
93
  if (!body?.result?.data)
63
94
  throw new ProjectApiError('server', response.status);
@@ -70,6 +101,55 @@ async function jsonFile(file, limit = 256 * 1024) {
70
101
  throw new Error('Metadata file exceeds 256 KiB');
71
102
  return JSON.parse(await readFile(file, 'utf8'));
72
103
  }
104
+ const missing = (error) => ['ENOENT', 'ENOTDIR'].includes(error.code ?? '');
105
+ /**
106
+ * Every `_locales/<lang>/messages.json` next to the manifest, cut down to the
107
+ * keys its store-visible fields reference (`__MSG_*__` in name, short_name,
108
+ * description, action title): the server reads only those, and full catalogs
109
+ * for a dozen locales could crowd the 256 KiB import limit. Folders outside the
110
+ * Chrome Web Store locale registry and unparseable files are skipped.
111
+ */
112
+ async function localeCatalogs(manifestPath, manifest) {
113
+ const action = manifest.action ?? manifest.browser_action ?? manifest.page_action;
114
+ const keys = new Set([manifest.name, manifest.short_name, manifest.description, action?.default_title].flatMap((value) => {
115
+ const key = typeof value === 'string' ? /^__MSG_(.+?)__$/.exec(value.trim())?.[1] : undefined;
116
+ return key ? [key.toLowerCase()] : [];
117
+ }));
118
+ if (!keys.size)
119
+ return undefined;
120
+ const root = path.join(path.dirname(manifestPath), '_locales');
121
+ let entries;
122
+ try {
123
+ entries = await readdir(root, { withFileTypes: true });
124
+ }
125
+ catch (error) {
126
+ if (missing(error))
127
+ return undefined;
128
+ throw error;
129
+ }
130
+ const files = new Map();
131
+ for (const entry of entries) {
132
+ if (!entry.isDirectory() || !isExtensionLocale(entry.name))
133
+ continue;
134
+ const file = path.join(root, entry.name, 'messages.json');
135
+ try {
136
+ if ((await stat(file)).size > 256 * 1024)
137
+ continue;
138
+ files.set(`_locales/${entry.name}/messages.json`, await readFile(file, 'utf8'));
139
+ }
140
+ catch (error) {
141
+ if (!missing(error))
142
+ throw error;
143
+ }
144
+ }
145
+ const catalogs = {};
146
+ for (const [locale, messages] of readLocaleMessages(files)) {
147
+ const catalog = Object.fromEntries(Object.entries(messages).filter(([key, entry]) => keys.has(key.toLowerCase()) && typeof entry?.message === 'string'));
148
+ if (Object.keys(catalog).length)
149
+ catalogs[locale] = catalog;
150
+ }
151
+ return Object.keys(catalogs).length ? catalogs : undefined;
152
+ }
73
153
  export async function atomicJson(file, value) {
74
154
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
75
155
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -232,6 +312,139 @@ async function flush(cwd, config, binding) {
232
312
  await atomicJson(file, { operations });
233
313
  }
234
314
  }
315
+ /**
316
+ * Queue and synchronize evidence records — THE client path for evidence, shared
317
+ * by `evidence push` and every command's `--evidence`. Each record is queued
318
+ * and flushed on its own so a long per-locale batch never trips the 20-entry
319
+ * pending cap while online; offline, the rest keep queueing for `project sync`.
320
+ */
321
+ async function submitEvidence(cwd, config, binding, inputs) {
322
+ let pending = false;
323
+ for (const input of inputs) {
324
+ await queue(cwd, { method: 'agentRecordEvidence', input });
325
+ try {
326
+ await flush(cwd, config, binding);
327
+ }
328
+ catch (error) {
329
+ if (error instanceof ProjectApiError && ['offline', 'server'].includes(error.category))
330
+ pending = true;
331
+ else
332
+ throw error;
333
+ }
334
+ }
335
+ return pending ? 'pending-sync' : 'recorded';
336
+ }
337
+ /**
338
+ * The local gate for an evidence attachment, run before any byte leaves the
339
+ * machine: a secret-looking name, a type outside the contract's allowlist or a
340
+ * file over the per-file cap is refused — never silently dropped, which would
341
+ * show passed evidence without its proof.
342
+ */
343
+ export async function prepareEvidenceAttachment(file) {
344
+ assertNotSecretLike(file);
345
+ const contentType = attachmentContentType(file);
346
+ if (!contentType)
347
+ throw new InvalidInputError(`"${file}": unsupported attachment type. Allowed: png, jpg, webp, json, txt, md, zip.`);
348
+ const bytes = await readFile(path.resolve(file));
349
+ if (bytes.byteLength > EVIDENCE_ATTACHMENT_MAX_BYTES)
350
+ throw new InvalidInputError(`"${file}" is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB; the per-file limit is 10 MB.`);
351
+ return { file, bytes, contentType, sha256: sha(bytes) };
352
+ }
353
+ /**
354
+ * Upload one prepared attachment to the project's PRIVATE evidence storage:
355
+ * register it (agentCreateEvidenceUpload), PUT the bytes to the short-lived
356
+ * presigned URL, and return the metadata an evidence record references. Shared
357
+ * by `evidence push --attach` and `screenshots --evidence`.
358
+ */
359
+ export async function uploadEvidenceAttachment(config, projectId, attachment) {
360
+ const upload = (await projectRequest(config, 'agentCreateEvidenceUpload', {
361
+ projectId,
362
+ fileName: path.basename(attachment.file),
363
+ contentType: attachment.contentType,
364
+ bytes: attachment.bytes.byteLength,
365
+ sha256: attachment.sha256,
366
+ }, true));
367
+ const put = await fetch(upload.uploadUrl, {
368
+ method: 'PUT',
369
+ body: new Uint8Array(attachment.bytes),
370
+ headers: { 'content-type': attachment.contentType },
371
+ });
372
+ if (!put.ok)
373
+ throw new Error(`Attachment upload failed (${put.status}). Request a fresh upload and retry.`);
374
+ return evidenceAttachmentSchema.parse({
375
+ kind: 'screenshot',
376
+ sha256: attachment.sha256,
377
+ bytes: attachment.bytes.byteLength,
378
+ contentType: attachment.contentType,
379
+ evidenceRef: upload.evidenceRef,
380
+ });
381
+ }
382
+ /**
383
+ * Whether evidence may carry `summary`. Both ends must know the key, because
384
+ * both validate with a STRICT schema: the installed @extenshi/contracts (an
385
+ * older one rejects the key locally), and the server — which says so in
386
+ * connection diagnostics (`contracts.evidenceSummary`). Unknown → no: the record
387
+ * still lands, only the summary is left out. Asked once per run.
388
+ */
389
+ let summarySupport;
390
+ export function evidenceSummarySupported(config) {
391
+ if (!('summary' in agentEvidenceSchema.shape))
392
+ return Promise.resolve(false);
393
+ summarySupport ??= projectRequest(config, 'agentConnectionDiagnostics')
394
+ .then((diagnostics) => Number(diagnostics.contracts?.evidenceSummary) >= 1)
395
+ .catch(() => false);
396
+ return summarySupport;
397
+ }
398
+ /** Test hook: forget the cached capability answer. */
399
+ export function resetEvidenceSummarySupport() {
400
+ summarySupport = undefined;
401
+ }
402
+ /**
403
+ * Record evidence for the project bound in `cwd` (`extenshi project bind`),
404
+ * exactly as `evidence push` does — same schema, queue and flush — but from the
405
+ * command that produced the result, so no report file is hand-authored.
406
+ *
407
+ * Readiness counts evidence whose artifact and input digests match the prepared
408
+ * release; provenance (observed here, after the command wrote its outputs)
409
+ * records which commit produced it. Source is always `local` — CI evidence
410
+ * arrives only through the verified ingest.
411
+ *
412
+ * `attach` files are uploaded first, as `evidence push --attach` does; the same
413
+ * bytes attached to several records upload once, because the server accepts a
414
+ * ref again on a record with matching metadata. Uploading needs a connection:
415
+ * offline, it throws rather than queue a record without its proof.
416
+ */
417
+ export async function recordAgentEvidence(cwd, records) {
418
+ return withLocalLock(cwd, async () => {
419
+ const binding = await localState(cwd);
420
+ const config = await loadConfig();
421
+ const provenance = await localProvenance(cwd);
422
+ const summaries = records.some((r) => r.summary) && (await evidenceSummarySupported(config));
423
+ const uploads = new Map();
424
+ const upload = (attachment) => {
425
+ let uploaded = uploads.get(attachment.sha256);
426
+ if (!uploaded) {
427
+ uploaded = uploadEvidenceAttachment(config, binding.projectId, attachment);
428
+ uploads.set(attachment.sha256, uploaded);
429
+ }
430
+ return uploaded;
431
+ };
432
+ const inputs = [];
433
+ for (const { summary, attach, ...record } of records) {
434
+ const attachments = attach?.length ? await Promise.all(attach.map(upload)) : [];
435
+ inputs.push(agentEvidenceSchema.parse({
436
+ projectId: binding.projectId,
437
+ idempotencyKey: randomUUID(),
438
+ ...record,
439
+ ...(summaries && summary ? { summary } : {}),
440
+ ...(attachments.length ? { attachments } : {}),
441
+ provenance,
442
+ }));
443
+ }
444
+ const status = await submitEvidence(cwd, config, binding, inputs);
445
+ return { status, projectId: binding.projectId, records: inputs.length, summaries };
446
+ });
447
+ }
235
448
  async function syncPatch(cwd, patch, options) {
236
449
  return withLocalLock(cwd, async () => {
237
450
  const binding = await localState(cwd);
@@ -362,11 +575,126 @@ export function registerProjectCommands(program) {
362
575
  });
363
576
  await syncPatch(cwd, { repository: { repo: options.repo, defaultBranch: options.branch } }, {});
364
577
  }));
578
+ project
579
+ .command('create')
580
+ .description('Create a Dojo project owned by this API key, through the same service as the Dojo wizard. ' +
581
+ 'Idempotent per --idempotency-key; prints the project id and next steps (bind, scaffold, decisions).')
582
+ .requiredOption('--name <name>', 'Project name (1–120 characters)')
583
+ .option('--browsers <list>', 'Comma-separated target browsers: chrome,firefox,edge')
584
+ .option('--description <text>')
585
+ .option('--category <category>', 'Catalog category')
586
+ .option('--idempotency-key <uuid>', 'Reuse on a retry so it returns the same project instead of a new one')
587
+ .option('--json', 'Emit the machine-readable result')
588
+ .action(run(async (options) => {
589
+ const config = await loadConfig();
590
+ const input = agentCreateProjectSchema.parse({
591
+ idempotencyKey: options.idempotencyKey ?? randomUUID(),
592
+ name: options.name,
593
+ description: options.description,
594
+ category: options.category,
595
+ browsers: options.browsers
596
+ ? String(options.browsers)
597
+ .split(',')
598
+ .map((s) => s.trim().toLowerCase())
599
+ .filter(Boolean)
600
+ : [],
601
+ });
602
+ let result;
603
+ try {
604
+ result = await projectRequest(config, 'agentCreateProject', input, true);
605
+ }
606
+ catch (error) {
607
+ // Same key, same input: a retry after an ambiguous failure cannot duplicate.
608
+ if (!(error instanceof ProjectApiError) || !['offline', 'server'].includes(error.category))
609
+ throw error;
610
+ try {
611
+ result = await projectRequest(config, 'agentCreateProject', input, true);
612
+ }
613
+ catch (retryError) {
614
+ throw new Error(`${retryError instanceof Error ? retryError.message : 'Project create failed.'} Retry with --idempotency-key ${input.idempotencyKey} so a created project is returned instead of duplicated.`);
615
+ }
616
+ }
617
+ if (options.json) {
618
+ print({ ...result, idempotencyKey: input.idempotencyKey });
619
+ return;
620
+ }
621
+ console.log(`${result.replayed ? 'Existing' : 'Created'} project ${result.project?.name ?? ''} — ${result.projectId}`);
622
+ if (result.dojoUrl)
623
+ console.log(` ${result.dojoUrl}`);
624
+ for (const step of result.nextSteps ?? [])
625
+ console.log(` • ${step}`);
626
+ console.log(` idempotency key: ${input.idempotencyKey}`);
627
+ }));
628
+ project
629
+ .command('decisions')
630
+ .description('Owner decisions (AMO add-on id, license, seller identity, pricing, targets): status, value, suggested default and what each blocks')
631
+ .option('--project <id>', 'Project id (default: the project bound to this directory)')
632
+ .option('--json', 'Emit the machine-readable view')
633
+ .action(run(async (options) => {
634
+ const projectId = options.project ?? (await localState(process.cwd())).projectId;
635
+ const view = await projectRequest(await loadConfig(), 'agentGetDecisions', { projectId });
636
+ if (options.json) {
637
+ print(view);
638
+ return;
639
+ }
640
+ const shown = (v) => (typeof v === 'string' ? v : JSON.stringify(v));
641
+ console.log(`Owner decisions — project ${projectId} (${view.pending.length} pending)`);
642
+ for (const d of view.decisions) {
643
+ const mark = d.status === 'decided' ? '✓' : d.status === 'proposed' ? '?' : '·';
644
+ const value = d.value !== null
645
+ ? shown(d.value)
646
+ : d.suggestedValue !== null
647
+ ? `(suggested: ${shown(d.suggestedValue)})`
648
+ : '';
649
+ const blocks = [
650
+ ...d.blocksBrowsers,
651
+ ...(d.blocksPayment ? ['paid releases'] : []),
652
+ ].join(', ');
653
+ console.log(` ${mark} ${String(d.key).padEnd(22)} ${String(d.status).padEnd(9)} ${value}${blocks ? ` [blocks ${blocks}]` : ''}`);
654
+ }
655
+ if (view.pending.length)
656
+ console.log(' Agents propose with `extenshi project propose-decision <key> <value>`; the owner decides on the Dojo project page.');
657
+ }));
658
+ project
659
+ .command('propose-decision <key> <value>')
660
+ .description('Propose a value for an owner decision (JSON for pricing, comma-separated for targetBrowsers/targetLocales). ' +
661
+ 'The owner accepts or edits it in Dojo; a decided key is refused.')
662
+ .option('--project <id>', 'Project id (default: the project bound to this directory)')
663
+ .option('--rationale <text>', 'Why this value; shown to the owner')
664
+ .action(run(async (key, value, options) => {
665
+ const projectId = options.project ?? (await localState(process.cwd())).projectId;
666
+ // A braced GUID also starts with "{", so JSON is only taken when it parses.
667
+ let json;
668
+ try {
669
+ json = /^[[{]/.test(value.trim()) ? JSON.parse(value) : undefined;
670
+ }
671
+ catch {
672
+ json = undefined;
673
+ }
674
+ const parsedValue = json !== undefined
675
+ ? json
676
+ : ['targetBrowsers', 'targetLocales'].includes(key)
677
+ ? value
678
+ .split(',')
679
+ .map((s) => s.trim())
680
+ .filter(Boolean)
681
+ : value;
682
+ const input = proposeDecisionSchema.parse({
683
+ projectId,
684
+ idempotencyKey: randomUUID(),
685
+ key,
686
+ value: parsedValue,
687
+ rationale: options.rationale,
688
+ proposedBy: `extenshi-cli/${cliVersion}`,
689
+ });
690
+ print(await projectRequest(await loadConfig(), 'agentProposeDecision', input, true));
691
+ }));
365
692
  project
366
693
  .command('import-manifest <file>')
367
694
  .option('--built', 'Record a built observation without replacing source editor paths')
368
695
  .option('--browser <browser>', 'Target browser', 'chrome')
369
696
  .option('--messages <file>', 'Default-locale messages for project name and description')
697
+ .option('--no-locales', 'Do not send the _locales/<lang>/messages.json translations next to the manifest')
370
698
  .option('--expected-state-hash <hash>', 'Reviewed Dojo state hash; overrides the cached binding')
371
699
  .option('--dry-run')
372
700
  .action(run(async (file, options) => {
@@ -389,6 +717,9 @@ export function registerProjectCommands(program) {
389
717
  throw error;
390
718
  }
391
719
  }
720
+ // Translations of name/description from every _locales folder fill
721
+ // Dojo's Languages tab, so it matches what the repository ships.
722
+ const messagesByLocale = options.locales ? await localeCatalogs(location, manifest) : undefined;
392
723
  const base = importManifestSchema.parse({
393
724
  projectId: binding.projectId,
394
725
  schemaVersion: 1,
@@ -399,6 +730,7 @@ export function registerProjectCommands(program) {
399
730
  kind: options.built ? 'built' : 'source',
400
731
  manifest,
401
732
  messages,
733
+ messagesByLocale,
402
734
  dryRun: true,
403
735
  tombstones: missingOriginFields(observed, `/${options.built ? 'builtManifests' : 'sourceManifests'}/${options.browser}/provenance`),
404
736
  });
@@ -562,40 +894,17 @@ export function registerProjectCommands(program) {
562
894
  throw new Error('Report artifactDigest does not match the supplied artifact. Rerun the check for this package.');
563
895
  if (!['local', 'agent'].includes(metadata.provenance?.source))
564
896
  throw new Error('Evidence source must be local or agent; this command cannot attest CI.');
565
- // Attachments: refuse secret-looking names locally, upload bytes to
566
- // the presigned private URL, record only metadata on the evidence.
897
+ // Attachments: every file passes the local checks before the first
898
+ // upload; bytes go to the presigned private URL and the evidence
899
+ // records only their metadata.
900
+ const prepared = [];
901
+ for (const file of options.attach ?? [])
902
+ prepared.push(await prepareEvidenceAttachment(file));
903
+ if (prepared.length > EVIDENCE_ATTACHMENTS_MAX)
904
+ throw new InvalidInputError(`At most ${EVIDENCE_ATTACHMENTS_MAX} attachments per evidence record.`);
567
905
  const attachments = [];
568
- for (const file of options.attach ?? []) {
569
- assertNotSecretLike(file);
570
- const contentType = attachmentContentType(file);
571
- if (!contentType)
572
- throw new Error(`"${file}": unsupported attachment type. Allowed: png, jpg, webp, json, txt, md, zip.`);
573
- const bytes = await readFile(path.resolve(file));
574
- if (bytes.byteLength > EVIDENCE_ATTACHMENT_MAX_BYTES)
575
- throw new Error(`"${file}" is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB; the per-file limit is 10 MB.`);
576
- const config = await loadConfig();
577
- const upload = (await projectRequest(config, 'agentCreateEvidenceUpload', {
578
- projectId: binding.projectId,
579
- fileName: path.basename(file),
580
- contentType,
581
- bytes: bytes.byteLength,
582
- sha256: sha(bytes),
583
- }, true));
584
- const put = await fetch(upload.uploadUrl, {
585
- method: 'PUT',
586
- body: new Uint8Array(bytes),
587
- headers: { 'content-type': contentType },
588
- });
589
- if (!put.ok)
590
- throw new Error(`Attachment upload failed (${put.status}). Request a fresh upload and retry.`);
591
- attachments.push(evidenceAttachmentSchema.parse({
592
- kind: 'screenshot',
593
- sha256: sha(bytes),
594
- bytes: bytes.byteLength,
595
- contentType,
596
- evidenceRef: upload.evidenceRef,
597
- }));
598
- }
906
+ for (const attachment of prepared)
907
+ attachments.push(await uploadEvidenceAttachment(await loadConfig(), binding.projectId, attachment));
599
908
  // Explicit metadata allowlist. Full logs and arbitrary report properties stay local.
600
909
  const { kind, browser, locale, inputDigest, status, provenance: origin, reportUrl } = metadata;
601
910
  const input = agentEvidenceSchema.parse({
@@ -611,21 +920,14 @@ export function registerProjectCommands(program) {
611
920
  reportUrl,
612
921
  ...(attachments.length ? { attachments } : {}),
613
922
  });
614
- await queue(cwd, { method: 'agentRecordEvidence', input });
615
- try {
616
- await flush(cwd, await loadConfig(), binding);
617
- }
618
- catch (error) {
619
- if (error instanceof ProjectApiError && ['offline', 'server'].includes(error.category)) {
620
- print({
621
- status: 'pending-sync',
622
- artifactDigest,
623
- message: 'Evidence metadata is queued. Run extenshi project sync when connected.',
624
- });
625
- process.exitCode = 1;
626
- return;
627
- }
628
- throw error;
923
+ if ((await submitEvidence(cwd, await loadConfig(), binding, [input])) === 'pending-sync') {
924
+ print({
925
+ status: 'pending-sync',
926
+ artifactDigest,
927
+ message: 'Evidence metadata is queued. Run extenshi project sync when connected.',
928
+ });
929
+ process.exitCode = 1;
930
+ return;
629
931
  }
630
932
  print({
631
933
  status: 'recorded',
@@ -637,6 +939,7 @@ export function registerProjectCommands(program) {
637
939
  const release = program.command('release');
638
940
  release
639
941
  .command('prepare <artifact>')
942
+ .description('Record the exact release: <artifact> is the store zip, or the extenshi-packages.json written by `extenshi package` (the --browser entry is used and its sha256 re-checked)')
640
943
  .option('--browser <browser>', 'Target browser', 'chrome')
641
944
  .option('--locales <locales>', 'Comma-separated release locales')
642
945
  .option('--source-manifest <file>', 'Source manifest used for this build')
@@ -653,7 +956,9 @@ export function registerProjectCommands(program) {
653
956
  throw new Error('--payment must be required or not-used');
654
957
  if (options.paymentConfig && options.payment !== 'required')
655
958
  throw new Error('Payment configuration requires --payment required');
656
- const bytes = await readFile(path.resolve(artifact));
959
+ // A zip, or extenshi package's manifest (entry for --browser, sha re-checked).
960
+ const { artifactPath } = resolvePackagedArtifact(artifact, options.browser);
961
+ const bytes = await readFile(path.resolve(artifactPath));
657
962
  const loaded = await loadArtifact(bytes);
658
963
  const origin = await localProvenance(process.cwd());
659
964
  if (options.commit) {
@@ -725,6 +1030,10 @@ export function registerProjectCommands(program) {
725
1030
  return;
726
1031
  }
727
1032
  print(status);
1033
+ // stdout stays the JSON document; say where the payment fact came from on stderr.
1034
+ const payment = status.checks?.find((c) => c.kind === 'payment' && c.platform);
1035
+ if (typeof payment?.platform?.summary === 'string')
1036
+ console.error(`payment (${payment.source ?? 'no passing evidence'}): ${payment.platform.summary}`);
728
1037
  if (!status.ready)
729
1038
  process.exitCode = 1;
730
1039
  }));