@extenshi/cli 0.17.0 → 0.19.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 +52 -0
- package/dist/cli.js +71 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +17 -1
- package/dist/config.js.map +1 -1
- package/dist/cws-policies.d.ts.map +1 -1
- package/dist/cws-policies.js +1 -0
- package/dist/cws-policies.js.map +1 -1
- package/dist/extension-localization.d.ts +20 -0
- package/dist/extension-localization.d.ts.map +1 -0
- package/dist/extension-localization.js +94 -0
- package/dist/extension-localization.js.map +1 -0
- package/dist/generate-listing.d.ts +31 -0
- package/dist/generate-listing.d.ts.map +1 -0
- package/dist/generate-listing.js +145 -0
- package/dist/generate-listing.js.map +1 -0
- package/dist/listing-checks.d.ts +9 -1
- package/dist/listing-checks.d.ts.map +1 -1
- package/dist/listing-checks.js +89 -1
- package/dist/listing-checks.js.map +1 -1
- package/dist/localize-init.d.ts +9 -0
- package/dist/localize-init.d.ts.map +1 -0
- package/dist/localize-init.js +166 -0
- package/dist/localize-init.js.map +1 -0
- package/dist/localize.d.ts +27 -0
- package/dist/localize.d.ts.map +1 -0
- package/dist/localize.js +292 -0
- package/dist/localize.js.map +1 -0
- package/dist/permission-justifications.d.ts +39 -0
- package/dist/permission-justifications.d.ts.map +1 -0
- package/dist/permission-justifications.js +145 -0
- package/dist/permission-justifications.js.map +1 -0
- package/dist/project-workspace.d.ts +1976 -0
- package/dist/project-workspace.d.ts.map +1 -0
- package/dist/project-workspace.js +350 -0
- package/dist/project-workspace.js.map +1 -0
- package/dist/project.d.ts +17 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +671 -0
- package/dist/project.js.map +1 -0
- package/dist/review-checks.d.ts +10 -0
- package/dist/review-checks.d.ts.map +1 -1
- package/dist/review-checks.js +24 -0
- package/dist/review-checks.js.map +1 -1
- package/package.json +7 -6
package/dist/project.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/** Local metadata client. Never rewrites extension source or sends repository contents. */
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { lstat, mkdir, open, readFile, readlink, rename, rm, stat } from 'node:fs/promises';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { loadConfig } from './config.js';
|
|
9
|
+
import { evidenceSchema, hashMetadata, importManifestSchema, workspacePatchSchema, } from './project-workspace.js';
|
|
10
|
+
import { loadArtifact } from './review-archive.js';
|
|
11
|
+
const cliVersion = createRequire(import.meta.url)('../package.json').version;
|
|
12
|
+
export class ProjectApiError extends Error {
|
|
13
|
+
category;
|
|
14
|
+
status;
|
|
15
|
+
code;
|
|
16
|
+
constructor(category, status, code) {
|
|
17
|
+
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.'}`);
|
|
18
|
+
this.category = category;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function projectRequest(config, method, input = {}, mutation = false) {
|
|
24
|
+
if (!config.apiKey)
|
|
25
|
+
throw new ProjectApiError('auth', 401);
|
|
26
|
+
const procedure = method === 'cli.getApiCallerBalance' ? method : `devProject.${method}`;
|
|
27
|
+
const url = new URL(`${config.bffUrl}/api/trpc/${procedure}`);
|
|
28
|
+
if (!mutation)
|
|
29
|
+
url.searchParams.set('input', JSON.stringify(input));
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(url, {
|
|
33
|
+
method: mutation ? 'POST' : 'GET',
|
|
34
|
+
headers: { authorization: `Bearer ${config.apiKey}`, 'content-type': 'application/json' },
|
|
35
|
+
body: mutation ? JSON.stringify(input) : undefined,
|
|
36
|
+
signal: AbortSignal.timeout(20_000),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new ProjectApiError('offline', 0);
|
|
41
|
+
}
|
|
42
|
+
const body = (await response.json().catch(() => null));
|
|
43
|
+
if (!response.ok || body?.error) {
|
|
44
|
+
const status = response.status;
|
|
45
|
+
throw new ProjectApiError(status === 401
|
|
46
|
+
? 'auth'
|
|
47
|
+
: status === 403
|
|
48
|
+
? 'permission'
|
|
49
|
+
: status === 429
|
|
50
|
+
? 'quota'
|
|
51
|
+
: status === 409
|
|
52
|
+
? 'conflict'
|
|
53
|
+
: status === 404
|
|
54
|
+
? body?.error?.message === 'Project not found'
|
|
55
|
+
? 'not-found'
|
|
56
|
+
: 'unsupported'
|
|
57
|
+
: status >= 500
|
|
58
|
+
? 'server'
|
|
59
|
+
: 'validation', status, body?.error?.message === 'PROJECT_PREMIUM_REQUIRED' ? 'PROJECT_PREMIUM_REQUIRED' : undefined);
|
|
60
|
+
}
|
|
61
|
+
if (!body?.result?.data)
|
|
62
|
+
throw new ProjectApiError('server', response.status);
|
|
63
|
+
return body.result.data;
|
|
64
|
+
}
|
|
65
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
66
|
+
export const metadataDigest = hashMetadata;
|
|
67
|
+
async function jsonFile(file, limit = 256 * 1024) {
|
|
68
|
+
if ((await stat(file)).size > limit)
|
|
69
|
+
throw new Error('Metadata file exceeds 256 KiB');
|
|
70
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
export async function atomicJson(file, value) {
|
|
73
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
74
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
75
|
+
try {
|
|
76
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
77
|
+
try {
|
|
78
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
79
|
+
await handle.sync();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await handle.close();
|
|
83
|
+
}
|
|
84
|
+
await rename(temporary, file);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await rm(temporary, { force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function localProvenance(cwd) {
|
|
91
|
+
const git = (...args) => execFileSync('git', args, { cwd, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
92
|
+
const result = {
|
|
93
|
+
source: 'local',
|
|
94
|
+
observedAt: new Date().toISOString(),
|
|
95
|
+
toolVersion: `extenshi-cli/${cliVersion}`,
|
|
96
|
+
};
|
|
97
|
+
let root;
|
|
98
|
+
try {
|
|
99
|
+
root = git('rev-parse', '--show-toplevel').toString().trim();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return result; // Observations outside Git require --commit to prepare a release.
|
|
103
|
+
}
|
|
104
|
+
cwd = root;
|
|
105
|
+
try {
|
|
106
|
+
result.commit = git('rev-parse', '--verify', 'HEAD').toString().trim();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// A repository without its first commit can still identify a dirty snapshot.
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
result.branch = git('symbolic-ref', '--short', '-q', 'HEAD').toString().trim();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Detached HEAD already has a source commit.
|
|
116
|
+
}
|
|
117
|
+
const paths = ['--', '.', ':(exclude).extenshi', ':(exclude)**/.extenshi/**'];
|
|
118
|
+
const diff = result.commit
|
|
119
|
+
? git('diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', ...paths)
|
|
120
|
+
: Buffer.concat([
|
|
121
|
+
git('diff', '--no-ext-diff', '--no-textconv', '--binary', '--cached', ...paths),
|
|
122
|
+
git('diff', '--no-ext-diff', '--no-textconv', '--binary', ...paths),
|
|
123
|
+
]);
|
|
124
|
+
const untracked = git('ls-files', '--others', '--exclude-standard', '-z', ...paths)
|
|
125
|
+
.toString()
|
|
126
|
+
.split('\0')
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.sort();
|
|
129
|
+
if (diff.length || untracked.length) {
|
|
130
|
+
const entries = [];
|
|
131
|
+
for (const file of untracked) {
|
|
132
|
+
const location = path.join(root, file);
|
|
133
|
+
const info = await lstat(location);
|
|
134
|
+
const digest = createHash('sha256');
|
|
135
|
+
if (info.isSymbolicLink())
|
|
136
|
+
digest.update(await readlink(location));
|
|
137
|
+
else if (info.isFile()) {
|
|
138
|
+
for await (const chunk of createReadStream(location))
|
|
139
|
+
digest.update(chunk);
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
throw new Error('Cannot identify the dirty snapshot: an untracked entry is not a file.');
|
|
143
|
+
entries.push({
|
|
144
|
+
path: file,
|
|
145
|
+
mode: info.mode & 0o777,
|
|
146
|
+
symlink: info.isSymbolicLink(),
|
|
147
|
+
hash: digest.digest('hex'),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
result.dirtyTreeDigest = hashMetadata({ version: 1, trackedDiff: sha(diff), untracked: entries });
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
const missingOriginFields = (origin, prefix) => ['branch', 'commit', 'dirtyTreeDigest']
|
|
155
|
+
.filter((key) => origin[key] === undefined)
|
|
156
|
+
.map((key) => `${prefix}/${key}`);
|
|
157
|
+
const print = (value) => console.log(JSON.stringify(value, null, 2));
|
|
158
|
+
async function localState(cwd) {
|
|
159
|
+
try {
|
|
160
|
+
return await jsonFile(path.join(cwd, '.extenshi/project.json'), 512 * 1024);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error.code === 'ENOENT')
|
|
164
|
+
throw new Error('Run extenshi project bind --project <id> --repo <owner/repo> first.');
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function withLocalLock(cwd, work) {
|
|
169
|
+
const directory = path.join(cwd, '.extenshi');
|
|
170
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
171
|
+
const lock = path.join(directory, 'workspace.lock');
|
|
172
|
+
try {
|
|
173
|
+
await mkdir(lock);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code === 'EEXIST')
|
|
177
|
+
throw new Error('Another project command holds .extenshi/workspace.lock. If it crashed, verify it has stopped before removing the lock directory.');
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return await work();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await rm(lock, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function queue(cwd, operation) {
|
|
188
|
+
const file = path.join(cwd, '.extenshi/pending.json');
|
|
189
|
+
let pending = [];
|
|
190
|
+
try {
|
|
191
|
+
pending = (await jsonFile(file, 8 * 1024 * 1024)).operations ?? [];
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.code !== 'ENOENT')
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
if (pending.length >= 20)
|
|
198
|
+
throw new Error('Pending queue is full; synchronize it first.');
|
|
199
|
+
await atomicJson(file, { operations: [...pending, operation] });
|
|
200
|
+
}
|
|
201
|
+
async function flush(cwd, config, binding) {
|
|
202
|
+
const file = path.join(cwd, '.extenshi/pending.json');
|
|
203
|
+
let operations;
|
|
204
|
+
try {
|
|
205
|
+
operations = (await jsonFile(file, 8 * 1024 * 1024)).operations ?? [];
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error.code === 'ENOENT')
|
|
209
|
+
return;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
while (operations.length) {
|
|
213
|
+
const next = operations[0];
|
|
214
|
+
if (next.input.projectId !== binding.projectId)
|
|
215
|
+
throw new Error('Pending operation belongs to another project. Resolve the binding before syncing.');
|
|
216
|
+
const result = await projectRequest(config, next.method, next.input, true);
|
|
217
|
+
if (result.conflicts?.length) {
|
|
218
|
+
print({ status: 'conflict', operation: next.input.idempotencyKey, ...result });
|
|
219
|
+
throw new ProjectApiError('conflict', 409);
|
|
220
|
+
}
|
|
221
|
+
// Persist the receipt before removing the immutable queued request. A crash replays its key.
|
|
222
|
+
if (result.newRevision !== undefined) {
|
|
223
|
+
binding.revision = result.newRevision;
|
|
224
|
+
binding.document = result.document;
|
|
225
|
+
if (result.dojoStateHash)
|
|
226
|
+
binding.dojoStateHash = result.dojoStateHash;
|
|
227
|
+
binding.fetchedAt = new Date().toISOString();
|
|
228
|
+
await atomicJson(path.join(cwd, '.extenshi/project.json'), binding);
|
|
229
|
+
}
|
|
230
|
+
operations = operations.slice(1);
|
|
231
|
+
await atomicJson(file, { operations });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function syncPatch(cwd, patch, options) {
|
|
235
|
+
return withLocalLock(cwd, async () => {
|
|
236
|
+
const binding = await localState(cwd);
|
|
237
|
+
const config = await loadConfig();
|
|
238
|
+
const observed = options.observed ?? (await localProvenance(cwd));
|
|
239
|
+
const input = workspacePatchSchema.parse({
|
|
240
|
+
schemaVersion: 1,
|
|
241
|
+
projectId: binding.projectId,
|
|
242
|
+
expectedRevision: binding.revision,
|
|
243
|
+
idempotencyKey: randomUUID(),
|
|
244
|
+
provenance: observed,
|
|
245
|
+
patch,
|
|
246
|
+
tombstones: options.tombstones ?? [],
|
|
247
|
+
});
|
|
248
|
+
if (options.dryRun) {
|
|
249
|
+
print(await projectRequest(config, 'agentDiffWorkspace', input, true));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await queue(cwd, { method: 'agentPatchWorkspace', input });
|
|
253
|
+
try {
|
|
254
|
+
await flush(cwd, config, binding);
|
|
255
|
+
print({ status: 'synced', projectId: binding.projectId, revision: binding.revision });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error instanceof ProjectApiError && ['offline', 'server'].includes(error.category)) {
|
|
259
|
+
print({
|
|
260
|
+
status: 'pending-sync',
|
|
261
|
+
savedAt: observed.observedAt,
|
|
262
|
+
message: 'Metadata is queued. Run extenshi project sync when connected.',
|
|
263
|
+
});
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
}
|
|
266
|
+
else
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
export function registerProjectCommands(program) {
|
|
272
|
+
const run = (fn) => async (...args) => {
|
|
273
|
+
try {
|
|
274
|
+
await fn(...args);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
console.error(error instanceof Error ? error.message : 'Project command failed');
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
program
|
|
282
|
+
.command('doctor')
|
|
283
|
+
.option('--json', 'Emit JSON')
|
|
284
|
+
.action(run(async () => {
|
|
285
|
+
const config = await loadConfig();
|
|
286
|
+
try {
|
|
287
|
+
const [connection, balance] = await Promise.all([
|
|
288
|
+
projectRequest(config, 'agentConnectionDiagnostics'),
|
|
289
|
+
projectRequest(config, 'cli.getApiCallerBalance'),
|
|
290
|
+
]);
|
|
291
|
+
print({
|
|
292
|
+
status: 'connected',
|
|
293
|
+
...connection,
|
|
294
|
+
balance,
|
|
295
|
+
client: { version: cliVersion, workspaceContract: 1, authMode: 'api-key', refreshTokens: false },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
print({
|
|
300
|
+
status: 'unavailable',
|
|
301
|
+
category: error instanceof ProjectApiError ? error.category : 'unknown',
|
|
302
|
+
recovery: error instanceof ProjectApiError && error.category === 'unsupported'
|
|
303
|
+
? 'This backend does not provide project-workspace v1 yet. Deploy or connect to a compatible backend. Existing CLI commands remain available.'
|
|
304
|
+
: error instanceof ProjectApiError && error.category === 'offline'
|
|
305
|
+
? 'The backend is unreachable. Keep queued metadata and run project sync when connected.'
|
|
306
|
+
: error instanceof ProjectApiError && error.category === 'permission'
|
|
307
|
+
? 'Check granted OAuth scopes and project access. Automated readiness requires Pro.'
|
|
308
|
+
: 'Run extenshi login for API-key authentication errors. For OAuth invalid_grant, reconnect the MCP client and serialize refresh in its credential store. Retry server failures after recovery.',
|
|
309
|
+
});
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
}
|
|
312
|
+
}));
|
|
313
|
+
const project = program
|
|
314
|
+
.command('project')
|
|
315
|
+
.description('Synchronize project metadata and evidence with Dojo');
|
|
316
|
+
project
|
|
317
|
+
.command('bind')
|
|
318
|
+
.requiredOption('--project <id>')
|
|
319
|
+
.requiredOption('--repo <owner/repo>')
|
|
320
|
+
.option('--branch <branch>', 'Default branch', 'main')
|
|
321
|
+
.action(run(async (options) => {
|
|
322
|
+
const cwd = process.cwd();
|
|
323
|
+
await withLocalLock(cwd, async () => {
|
|
324
|
+
let existing;
|
|
325
|
+
try {
|
|
326
|
+
existing = await jsonFile(path.join(cwd, '.extenshi/project.json'), 512 * 1024);
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
if (e.code !== 'ENOENT')
|
|
330
|
+
throw e;
|
|
331
|
+
}
|
|
332
|
+
if (existing && existing.projectId !== options.project)
|
|
333
|
+
throw new Error('This directory is already bound to another project.');
|
|
334
|
+
const state = await projectRequest(await loadConfig(), 'agentGetWorkspace', {
|
|
335
|
+
projectId: options.project,
|
|
336
|
+
});
|
|
337
|
+
await atomicJson(path.join(cwd, '.extenshi/project.json'), {
|
|
338
|
+
...state,
|
|
339
|
+
fetchedAt: new Date().toISOString(),
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
await syncPatch(cwd, { repository: { repo: options.repo, defaultBranch: options.branch } }, {});
|
|
343
|
+
}));
|
|
344
|
+
project
|
|
345
|
+
.command('import-manifest <file>')
|
|
346
|
+
.option('--built', 'Record a built observation without replacing source editor paths')
|
|
347
|
+
.option('--browser <browser>', 'Target browser', 'chrome')
|
|
348
|
+
.option('--messages <file>', 'Default-locale messages for project name and description')
|
|
349
|
+
.option('--expected-state-hash <hash>', 'Reviewed Dojo state hash; overrides the cached binding')
|
|
350
|
+
.option('--dry-run')
|
|
351
|
+
.action(run(async (file, options) => {
|
|
352
|
+
const cwd = process.cwd();
|
|
353
|
+
await withLocalLock(cwd, async () => {
|
|
354
|
+
const binding = await localState(cwd);
|
|
355
|
+
const config = await loadConfig();
|
|
356
|
+
const location = path.resolve(file);
|
|
357
|
+
const manifest = await jsonFile(location);
|
|
358
|
+
const observed = await localProvenance(cwd);
|
|
359
|
+
let messages = options.messages ? await jsonFile(path.resolve(options.messages)) : undefined;
|
|
360
|
+
if (!messages &&
|
|
361
|
+
typeof manifest.default_locale === 'string' &&
|
|
362
|
+
/^[a-z]{2,3}(?:[_-][A-Za-z0-9]+)*$/.test(manifest.default_locale)) {
|
|
363
|
+
try {
|
|
364
|
+
messages = await jsonFile(path.join(path.dirname(location), '_locales', manifest.default_locale, 'messages.json'));
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (error.code !== 'ENOENT')
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const base = importManifestSchema.parse({
|
|
372
|
+
projectId: binding.projectId,
|
|
373
|
+
schemaVersion: 1,
|
|
374
|
+
expectedRevision: binding.revision,
|
|
375
|
+
idempotencyKey: randomUUID(),
|
|
376
|
+
provenance: observed,
|
|
377
|
+
browser: options.browser,
|
|
378
|
+
kind: options.built ? 'built' : 'source',
|
|
379
|
+
manifest,
|
|
380
|
+
messages,
|
|
381
|
+
dryRun: true,
|
|
382
|
+
tombstones: missingOriginFields(observed, `/${options.built ? 'builtManifests' : 'sourceManifests'}/${options.browser}/provenance`),
|
|
383
|
+
});
|
|
384
|
+
if (options.dryRun) {
|
|
385
|
+
print(await projectRequest(config, 'agentImportManifest', base, true));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
let expectedStateHash = options.expectedStateHash ?? binding.dojoStateHash;
|
|
389
|
+
if (!expectedStateHash) {
|
|
390
|
+
const preview = await projectRequest(config, 'agentImportManifest', base, true);
|
|
391
|
+
if (preview.conflicts?.length) {
|
|
392
|
+
print(preview);
|
|
393
|
+
throw new ProjectApiError('conflict', 409);
|
|
394
|
+
}
|
|
395
|
+
expectedStateHash = preview.expectedStateHash;
|
|
396
|
+
}
|
|
397
|
+
const input = importManifestSchema.parse({ ...base, dryRun: false, expectedStateHash });
|
|
398
|
+
await queue(cwd, { method: 'agentImportManifest', input });
|
|
399
|
+
try {
|
|
400
|
+
await flush(cwd, config, binding);
|
|
401
|
+
print({ status: 'synced', revision: binding.revision, dojoUpdated: !options.built });
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (error instanceof ProjectApiError && ['offline', 'server'].includes(error.category)) {
|
|
405
|
+
print({
|
|
406
|
+
status: 'pending-sync',
|
|
407
|
+
message: 'Manifest import is queued. Run extenshi project sync when connected.',
|
|
408
|
+
});
|
|
409
|
+
process.exitCode = 1;
|
|
410
|
+
}
|
|
411
|
+
else
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}));
|
|
416
|
+
project
|
|
417
|
+
.command('diff')
|
|
418
|
+
.option('--patch <file>', 'Typed metadata patch JSON')
|
|
419
|
+
.action(run(async (options) => {
|
|
420
|
+
const binding = await localState(process.cwd());
|
|
421
|
+
if (options.patch)
|
|
422
|
+
await syncPatch(process.cwd(), await jsonFile(path.resolve(options.patch)), { dryRun: true });
|
|
423
|
+
else {
|
|
424
|
+
const remote = await projectRequest(await loadConfig(), 'agentGetWorkspace', {
|
|
425
|
+
projectId: binding.projectId,
|
|
426
|
+
});
|
|
427
|
+
print({
|
|
428
|
+
localRevision: binding.revision,
|
|
429
|
+
serverRevision: remote.revision,
|
|
430
|
+
snapshotAgeSeconds: Math.max(0, (Date.now() - Date.parse(binding.fetchedAt)) / 1000),
|
|
431
|
+
local: binding.document,
|
|
432
|
+
server: remote.document,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}));
|
|
436
|
+
project
|
|
437
|
+
.command('sync')
|
|
438
|
+
.option('--patch <file>', 'Typed repository/sourceManifests/builtManifests/spec/release patch')
|
|
439
|
+
.option('--expected-revision <revision>')
|
|
440
|
+
.option('--delete <pointer...>', 'Explicit metadata tombstones')
|
|
441
|
+
.action(run(async (options) => {
|
|
442
|
+
const cwd = process.cwd();
|
|
443
|
+
if (options.expectedRevision !== undefined &&
|
|
444
|
+
Number(options.expectedRevision) !== (await localState(cwd)).revision)
|
|
445
|
+
throw new Error('Expected revision does not match the local baseline. Review project diff first.');
|
|
446
|
+
if (options.patch || options.delete)
|
|
447
|
+
await syncPatch(cwd, options.patch ? await jsonFile(path.resolve(options.patch)) : {}, {
|
|
448
|
+
tombstones: options.delete ?? [],
|
|
449
|
+
});
|
|
450
|
+
else
|
|
451
|
+
await withLocalLock(cwd, async () => {
|
|
452
|
+
const binding = await localState(cwd);
|
|
453
|
+
const config = await loadConfig();
|
|
454
|
+
await flush(cwd, config, binding);
|
|
455
|
+
const state = await projectRequest(config, 'agentGetWorkspace', { projectId: binding.projectId });
|
|
456
|
+
await atomicJson(path.join(cwd, '.extenshi/project.json'), {
|
|
457
|
+
...state,
|
|
458
|
+
fetchedAt: new Date().toISOString(),
|
|
459
|
+
});
|
|
460
|
+
print({ status: 'synced', revision: state.revision });
|
|
461
|
+
});
|
|
462
|
+
}));
|
|
463
|
+
project
|
|
464
|
+
.command('export-manifest')
|
|
465
|
+
.option('--browser <browser>', 'Target browser', 'chrome')
|
|
466
|
+
.option('--built')
|
|
467
|
+
.action(run(async (options) => {
|
|
468
|
+
if (!['chrome', 'firefox', 'edge'].includes(options.browser))
|
|
469
|
+
throw new Error('Browser must be chrome, firefox or edge');
|
|
470
|
+
const binding = await localState(process.cwd());
|
|
471
|
+
const state = await projectRequest(await loadConfig(), 'agentGetWorkspace', {
|
|
472
|
+
projectId: binding.projectId,
|
|
473
|
+
});
|
|
474
|
+
const observation = state.document[options.built ? 'builtManifests' : 'sourceManifests']?.[options.browser];
|
|
475
|
+
if (!observation)
|
|
476
|
+
throw new Error('No manifest observation for this browser');
|
|
477
|
+
print(observation.manifest);
|
|
478
|
+
}));
|
|
479
|
+
project
|
|
480
|
+
.command('resolve')
|
|
481
|
+
.requiredOption('--operation <id>', 'Conflicting operation idempotency key')
|
|
482
|
+
.requiredOption('--patch <file>', 'Reviewed replacement metadata patch')
|
|
483
|
+
.option('--delete <pointer...>')
|
|
484
|
+
.action(run(async (options) => {
|
|
485
|
+
const cwd = process.cwd();
|
|
486
|
+
await withLocalLock(cwd, async () => {
|
|
487
|
+
const binding = await localState(cwd);
|
|
488
|
+
const config = await loadConfig();
|
|
489
|
+
const pendingFile = path.join(cwd, '.extenshi/pending.json');
|
|
490
|
+
const pending = await jsonFile(pendingFile, 8 * 1024 * 1024);
|
|
491
|
+
const index = pending.operations.findIndex((op) => op.input.idempotencyKey === options.operation);
|
|
492
|
+
if (index < 0 ||
|
|
493
|
+
!['agentPatchWorkspace', 'agentImportManifest'].includes(pending.operations[index].method))
|
|
494
|
+
throw new Error('No matching pending project patch');
|
|
495
|
+
const current = await projectRequest(config, 'agentGetWorkspace', { projectId: binding.projectId });
|
|
496
|
+
if (pending.operations[index].method === 'agentImportManifest') {
|
|
497
|
+
const replacement = importManifestSchema.parse({
|
|
498
|
+
...pending.operations[index].input,
|
|
499
|
+
manifest: await jsonFile(path.resolve(options.patch)),
|
|
500
|
+
provenance: await localProvenance(cwd),
|
|
501
|
+
tombstones: options.delete ?? pending.operations[index].input.tombstones,
|
|
502
|
+
expectedRevision: current.revision,
|
|
503
|
+
expectedStateHash: current.dojoStateHash,
|
|
504
|
+
idempotencyKey: randomUUID(),
|
|
505
|
+
dryRun: false,
|
|
506
|
+
});
|
|
507
|
+
print(await projectRequest(config, 'agentImportManifest', { ...replacement, dryRun: true }, true));
|
|
508
|
+
pending.operations[index] = { method: 'agentImportManifest', input: replacement };
|
|
509
|
+
await atomicJson(pendingFile, pending);
|
|
510
|
+
await flush(cwd, config, binding);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const replacement = workspacePatchSchema.parse({
|
|
514
|
+
schemaVersion: 1,
|
|
515
|
+
projectId: binding.projectId,
|
|
516
|
+
expectedRevision: current.revision,
|
|
517
|
+
idempotencyKey: randomUUID(),
|
|
518
|
+
provenance: await localProvenance(cwd),
|
|
519
|
+
patch: await jsonFile(path.resolve(options.patch)),
|
|
520
|
+
tombstones: options.delete ?? [],
|
|
521
|
+
});
|
|
522
|
+
const preview = await projectRequest(config, 'agentDiffWorkspace', replacement, true);
|
|
523
|
+
print(preview);
|
|
524
|
+
pending.operations[index] = { method: 'agentPatchWorkspace', input: replacement };
|
|
525
|
+
await atomicJson(pendingFile, pending);
|
|
526
|
+
await flush(cwd, config, binding);
|
|
527
|
+
});
|
|
528
|
+
}));
|
|
529
|
+
program
|
|
530
|
+
.command('evidence')
|
|
531
|
+
.command('push <report>')
|
|
532
|
+
.requiredOption('--artifact <file>', 'Exact ZIP/XPI covered by this report')
|
|
533
|
+
.action(run(async (report, options) => {
|
|
534
|
+
const cwd = process.cwd();
|
|
535
|
+
await withLocalLock(cwd, async () => {
|
|
536
|
+
const binding = await localState(cwd);
|
|
537
|
+
const metadata = await jsonFile(path.resolve(report));
|
|
538
|
+
const artifactDigest = sha(await readFile(path.resolve(options.artifact)));
|
|
539
|
+
if (metadata.artifactDigest !== artifactDigest)
|
|
540
|
+
throw new Error('Report artifactDigest does not match the supplied artifact. Rerun the check for this package.');
|
|
541
|
+
if (!['local', 'agent'].includes(metadata.provenance?.source))
|
|
542
|
+
throw new Error('Evidence source must be local or agent; this command cannot attest CI.');
|
|
543
|
+
// Explicit metadata allowlist. Full logs and arbitrary report properties stay local.
|
|
544
|
+
const { kind, browser, locale, inputDigest, status, provenance: origin, reportUrl } = metadata;
|
|
545
|
+
const input = evidenceSchema.parse({
|
|
546
|
+
projectId: binding.projectId,
|
|
547
|
+
idempotencyKey: randomUUID(),
|
|
548
|
+
kind,
|
|
549
|
+
browser,
|
|
550
|
+
locale,
|
|
551
|
+
artifactDigest,
|
|
552
|
+
inputDigest,
|
|
553
|
+
status,
|
|
554
|
+
provenance: origin,
|
|
555
|
+
reportUrl,
|
|
556
|
+
});
|
|
557
|
+
await queue(cwd, { method: 'agentRecordEvidence', input });
|
|
558
|
+
try {
|
|
559
|
+
await flush(cwd, await loadConfig(), binding);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
if (error instanceof ProjectApiError && ['offline', 'server'].includes(error.category)) {
|
|
563
|
+
print({
|
|
564
|
+
status: 'pending-sync',
|
|
565
|
+
artifactDigest,
|
|
566
|
+
message: 'Evidence metadata is queued. Run extenshi project sync when connected.',
|
|
567
|
+
});
|
|
568
|
+
process.exitCode = 1;
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
print({ status: 'recorded', artifactDigest });
|
|
574
|
+
});
|
|
575
|
+
}));
|
|
576
|
+
const release = program.command('release');
|
|
577
|
+
release
|
|
578
|
+
.command('prepare <artifact>')
|
|
579
|
+
.option('--browser <browser>', 'Target browser', 'chrome')
|
|
580
|
+
.option('--locales <locales>', 'Comma-separated release locales')
|
|
581
|
+
.option('--source-manifest <file>', 'Source manifest used for this build')
|
|
582
|
+
.option('--listing <file>', 'Listing revision to bind by hash')
|
|
583
|
+
.option('--policy <file>', 'Policy revision to bind by hash')
|
|
584
|
+
.requiredOption('--payment <mode>', 'required or not-used; an explicit release decision')
|
|
585
|
+
.option('--payment-config <file>', 'Payment configuration metadata to hash locally')
|
|
586
|
+
.option('--commit <sha>', 'Explicit source commit when outside a Git checkout')
|
|
587
|
+
.option('--dry-run')
|
|
588
|
+
.action(run(async (artifact, options) => {
|
|
589
|
+
if (!['chrome', 'firefox', 'edge'].includes(options.browser))
|
|
590
|
+
throw new Error('Browser must be chrome, firefox or edge');
|
|
591
|
+
if (!['required', 'not-used'].includes(options.payment))
|
|
592
|
+
throw new Error('--payment must be required or not-used');
|
|
593
|
+
if (options.paymentConfig && options.payment !== 'required')
|
|
594
|
+
throw new Error('Payment configuration requires --payment required');
|
|
595
|
+
const bytes = await readFile(path.resolve(artifact));
|
|
596
|
+
const loaded = await loadArtifact(bytes);
|
|
597
|
+
const origin = await localProvenance(process.cwd());
|
|
598
|
+
if (options.commit) {
|
|
599
|
+
if (origin.commit && origin.commit !== options.commit)
|
|
600
|
+
throw new Error('Explicit commit differs from the checked-out commit');
|
|
601
|
+
origin.commit = options.commit;
|
|
602
|
+
}
|
|
603
|
+
const source = options.sourceManifest
|
|
604
|
+
? await jsonFile(path.resolve(options.sourceManifest))
|
|
605
|
+
: undefined;
|
|
606
|
+
const hashFile = async (file) => file ? sha(await readFile(path.resolve(file))) : undefined;
|
|
607
|
+
const locale = typeof loaded.manifest.default_locale === 'string' ? loaded.manifest.default_locale : undefined;
|
|
608
|
+
if (!options.locales && !locale)
|
|
609
|
+
throw new Error('Specify --locales for a package without default_locale');
|
|
610
|
+
const patch = {
|
|
611
|
+
builtManifests: { [options.browser]: { manifest: loaded.manifest, provenance: origin } },
|
|
612
|
+
...(source
|
|
613
|
+
? { sourceManifests: { [options.browser]: { manifest: source, provenance: origin } } }
|
|
614
|
+
: {}),
|
|
615
|
+
release: {
|
|
616
|
+
browser: options.browser,
|
|
617
|
+
version: loaded.manifest.version,
|
|
618
|
+
artifactDigest: sha(bytes),
|
|
619
|
+
manifestDigest: metadataDigest(loaded.manifest),
|
|
620
|
+
sourceManifestDigest: source ? metadataDigest(source) : undefined,
|
|
621
|
+
commit: origin.commit,
|
|
622
|
+
dirtyTreeDigest: origin.dirtyTreeDigest,
|
|
623
|
+
locales: options.locales ? options.locales.split(',').map((s) => s.trim()) : [locale],
|
|
624
|
+
listingDigest: await hashFile(options.listing),
|
|
625
|
+
policyDigest: await hashFile(options.policy),
|
|
626
|
+
paymentDigest: await hashFile(options.paymentConfig),
|
|
627
|
+
paymentRequired: options.payment === 'required',
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
await syncPatch(process.cwd(), patch, {
|
|
631
|
+
...options,
|
|
632
|
+
observed: origin,
|
|
633
|
+
tombstones: [
|
|
634
|
+
...missingOriginFields(origin, `/builtManifests/${options.browser}/provenance`),
|
|
635
|
+
...(source ? missingOriginFields(origin, `/sourceManifests/${options.browser}/provenance`) : []),
|
|
636
|
+
...[
|
|
637
|
+
'commit',
|
|
638
|
+
'dirtyTreeDigest',
|
|
639
|
+
'sourceManifestDigest',
|
|
640
|
+
'listingDigest',
|
|
641
|
+
'policyDigest',
|
|
642
|
+
'paymentDigest',
|
|
643
|
+
]
|
|
644
|
+
.filter((key) => patch.release[key] === undefined)
|
|
645
|
+
.map((key) => `/release/${key}`),
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
}));
|
|
649
|
+
release
|
|
650
|
+
.command('status')
|
|
651
|
+
.option('--browser <browser>')
|
|
652
|
+
.option('--artifact-digest <sha256>')
|
|
653
|
+
.option('--json')
|
|
654
|
+
.action(run(async (options) => {
|
|
655
|
+
const binding = await localState(process.cwd());
|
|
656
|
+
const status = await projectRequest(await loadConfig(), 'agentReleaseReadiness', {
|
|
657
|
+
projectId: binding.projectId,
|
|
658
|
+
browser: options.browser,
|
|
659
|
+
artifactDigest: options.artifactDigest,
|
|
660
|
+
});
|
|
661
|
+
if (options.browser && status.release?.browser !== options.browser) {
|
|
662
|
+
print({ ready: false, blockers: ['NO_RELEASE_FOR_REQUESTED_BROWSER'], browser: options.browser });
|
|
663
|
+
process.exitCode = 1;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
print(status);
|
|
667
|
+
if (!status.ready)
|
|
668
|
+
process.exitCode = 1;
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
671
|
+
//# sourceMappingURL=project.js.map
|