@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.
- package/README.md +4 -0
- package/dist/amo-source-detect.d.ts +18 -0
- package/dist/amo-source-detect.d.ts.map +1 -0
- package/dist/amo-source-detect.js +41 -0
- package/dist/amo-source-detect.js.map +1 -0
- package/dist/amo-source.d.ts +41 -0
- package/dist/amo-source.d.ts.map +1 -0
- package/dist/amo-source.js +240 -0
- package/dist/amo-source.js.map +1 -0
- package/dist/amowebstore-md.d.ts +38 -0
- package/dist/amowebstore-md.d.ts.map +1 -0
- package/dist/amowebstore-md.js +118 -0
- package/dist/amowebstore-md.js.map +1 -0
- package/dist/auto-evidence.d.ts +154 -0
- package/dist/auto-evidence.d.ts.map +1 -0
- package/dist/auto-evidence.js +297 -0
- package/dist/auto-evidence.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +366 -11
- package/dist/cli.js.map +1 -1
- package/dist/domain.d.ts +40 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +64 -0
- package/dist/domain.js.map +1 -0
- package/dist/edgewebstore-md.d.ts +26 -0
- package/dist/edgewebstore-md.d.ts.map +1 -0
- package/dist/edgewebstore-md.js +95 -0
- package/dist/edgewebstore-md.js.map +1 -0
- package/dist/evidence-ingest.d.ts +60 -0
- package/dist/evidence-ingest.d.ts.map +1 -0
- package/dist/evidence-ingest.js +141 -0
- package/dist/evidence-ingest.js.map +1 -0
- package/dist/extension-localization.d.ts +6 -1
- package/dist/extension-localization.d.ts.map +1 -1
- package/dist/extension-localization.js +4 -0
- package/dist/extension-localization.js.map +1 -1
- package/dist/firefox-rdp.d.ts +55 -0
- package/dist/firefox-rdp.d.ts.map +1 -0
- package/dist/firefox-rdp.js +203 -0
- package/dist/firefox-rdp.js.map +1 -0
- package/dist/gecko-id.d.ts +55 -0
- package/dist/gecko-id.d.ts.map +1 -0
- package/dist/gecko-id.js +132 -0
- package/dist/gecko-id.js.map +1 -0
- package/dist/generate-listing.d.ts +19 -8
- package/dist/generate-listing.d.ts.map +1 -1
- package/dist/generate-listing.js +66 -18
- package/dist/generate-listing.js.map +1 -1
- package/dist/icon.d.ts +18 -0
- package/dist/icon.d.ts.map +1 -1
- package/dist/icon.js +29 -20
- package/dist/icon.js.map +1 -1
- package/dist/landing-page.d.ts +22 -4
- package/dist/landing-page.d.ts.map +1 -1
- package/dist/landing-page.js +14 -11
- package/dist/landing-page.js.map +1 -1
- package/dist/landing-sections.d.ts +332 -0
- package/dist/landing-sections.d.ts.map +1 -0
- package/dist/landing-sections.js +1071 -0
- package/dist/landing-sections.js.map +1 -0
- package/dist/listing.d.ts +15 -0
- package/dist/listing.d.ts.map +1 -1
- package/dist/listing.js +1 -1
- package/dist/listing.js.map +1 -1
- package/dist/media.d.ts +42 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +96 -0
- package/dist/media.js.map +1 -0
- package/dist/package.d.ts +163 -0
- package/dist/package.d.ts.map +1 -0
- package/dist/package.js +500 -0
- package/dist/package.js.map +1 -0
- package/dist/page.d.ts +110 -21
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +240 -10
- package/dist/page.js.map +1 -1
- package/dist/pay-contract.d.ts +42 -11
- package/dist/pay-contract.d.ts.map +1 -1
- package/dist/pay-contract.js +31 -0
- package/dist/pay-contract.js.map +1 -1
- package/dist/pay-test-cycle.d.ts +40 -0
- package/dist/pay-test-cycle.d.ts.map +1 -0
- package/dist/pay-test-cycle.js +207 -0
- package/dist/pay-test-cycle.js.map +1 -0
- package/dist/pay.d.ts +1 -1
- package/dist/pay.d.ts.map +1 -1
- package/dist/pay.js +1 -0
- package/dist/pay.js.map +1 -1
- package/dist/project.d.ts +69 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +363 -54
- package/dist/project.js.map +1 -1
- package/dist/publish.d.ts +33 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +113 -2
- package/dist/publish.js.map +1 -1
- package/dist/review-checks.d.ts +13 -0
- package/dist/review-checks.d.ts.map +1 -1
- package/dist/review-checks.js.map +1 -1
- package/dist/review-risk.d.ts +10 -1
- package/dist/review-risk.d.ts.map +1 -1
- package/dist/review-risk.js +1 -0
- package/dist/review-risk.js.map +1 -1
- package/dist/scan.d.ts +3 -2
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +4 -2
- package/dist/scan.js.map +1 -1
- package/dist/screenshots-scenes.d.ts +1383 -0
- package/dist/screenshots-scenes.d.ts.map +1 -0
- package/dist/screenshots-scenes.js +276 -0
- package/dist/screenshots-scenes.js.map +1 -0
- package/dist/screenshots.d.ts +143 -0
- package/dist/screenshots.d.ts.map +1 -0
- package/dist/screenshots.js +558 -0
- package/dist/screenshots.js.map +1 -0
- package/dist/smoke.d.ts +81 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +491 -0
- package/dist/smoke.js.map +1 -0
- package/dist/store-assets.d.ts +139 -0
- package/dist/store-assets.d.ts.map +1 -0
- package/dist/store-assets.js +582 -0
- package/dist/store-assets.js.map +1 -0
- package/dist/zip.d.ts +57 -0
- package/dist/zip.d.ts.map +1 -0
- package/dist/zip.js +134 -0
- package/dist/zip.js.map +1 -0
- package/package.json +7 -6
package/dist/project.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,2FAA2F;
|
|
1
|
+
{"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAQ3F,OAAO,EAKN,wBAAwB,EAKxB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAQxC,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;IACtB,iFAAiF;IACjF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;gBAJf,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA;IACtB,iFAAiF;IACxE,MAAM,CAAC,EAAE,MAAM,YAAA;CAMzB;AAeD,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,CAkDf;AAED,eAAO,MAAM,cAAc,4BAAe,CAAA;AAwD1C,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,iBAe5D;AACD,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDhE;AAoGD,wGAAwG;AACxG,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC3B,8EAA8E;IAC9E,OAAO,CAAC,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IAC/E,qFAAqF;IACrF,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC7B;AAED,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,KAAK,CAAC,CAAA;AAElF,yEAAyE;AACzE,MAAM,WAAW,kBAAkB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;CACd;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAazF;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAC7C,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,EAC9C,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,kBAAkB,GAC5B,OAAO,CAAC,kBAAkB,CAAC,CA0B7B;AAUD,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAMzG;AACD,sDAAsD;AACtD,wBAAgB,2BAA2B,IAAI,IAAI,CAElD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACxC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,cAAc,EAAE,GACvB,OAAO,CAAC;IAAE,MAAM,EAAE,UAAU,GAAG,cAAc,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAAC,CAgC1G;AAoCD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QAgnBvD"}
|
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
|
-
|
|
18
|
-
|
|
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 === '
|
|
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:
|
|
566
|
-
// the presigned private URL
|
|
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
|
|
569
|
-
|
|
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
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
}));
|