@playdrop/playdrop-cli 0.12.1 → 0.12.3
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/config/client-meta.json +1 -1
- package/dist/clientInfo.js +0 -11
- package/dist/commands/login.d.ts +13 -1
- package/dist/commands/login.js +72 -5
- package/dist/commands/review.d.ts +0 -20
- package/dist/commands/review.js +1 -112
- package/dist/commands/worker/instrument-evidence.d.ts +17 -0
- package/dist/commands/worker/instrument-evidence.js +239 -0
- package/dist/commands/worker/runtime.d.ts +1 -0
- package/dist/commands/worker/runtime.js +11 -0
- package/dist/commands/worker.d.ts +26 -1
- package/dist/commands/worker.js +263 -10
- package/dist/index.js +21 -17
- package/node_modules/@playdrop/api-client/dist/domains/game-ideas.d.ts +46 -0
- package/node_modules/@playdrop/api-client/dist/domains/game-ideas.d.ts.map +1 -0
- package/node_modules/@playdrop/api-client/dist/domains/game-ideas.js +177 -0
- package/node_modules/@playdrop/api-client/dist/domains/music.d.ts +27 -0
- package/node_modules/@playdrop/api-client/dist/domains/music.d.ts.map +1 -0
- package/node_modules/@playdrop/api-client/dist/domains/music.js +96 -0
- package/node_modules/@playdrop/config/client-meta.json +1 -1
- package/node_modules/@playdrop/config/dist/tsconfig.tsbuildinfo +1 -1
- package/node_modules/@playdrop/types/dist/api.d.ts +17 -0
- package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
- package/node_modules/@playdrop/types/dist/index.d.ts +1 -0
- package/node_modules/@playdrop/types/dist/index.d.ts.map +1 -1
- package/node_modules/@playdrop/types/dist/index.js +3 -1
- package/node_modules/@playdrop/types/dist/instrument-evidence.d.ts +41 -0
- package/node_modules/@playdrop/types/dist/instrument-evidence.d.ts.map +1 -0
- package/node_modules/@playdrop/types/dist/instrument-evidence.js +215 -0
- package/package.json +1 -1
package/config/client-meta.json
CHANGED
package/dist/clientInfo.js
CHANGED
|
@@ -36,17 +36,6 @@ function readCliPackageVersion() {
|
|
|
36
36
|
if (cachedCliPackageVersion) {
|
|
37
37
|
return cachedCliPackageVersion;
|
|
38
38
|
}
|
|
39
|
-
const candidateMetaPaths = [
|
|
40
|
-
path_1.default.resolve(__dirname, '..', 'config', 'client-meta.json'),
|
|
41
|
-
path_1.default.resolve(__dirname, '..', '..', '..', 'config', 'client-meta.json'),
|
|
42
|
-
];
|
|
43
|
-
for (const candidatePath of candidateMetaPaths) {
|
|
44
|
-
const clientMetaVersion = readVersionFromJson(candidatePath, 'version');
|
|
45
|
-
if (clientMetaVersion) {
|
|
46
|
-
cachedCliPackageVersion = clientMetaVersion;
|
|
47
|
-
return clientMetaVersion;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
39
|
const packageJsonPath = path_1.default.resolve(__dirname, '..', 'package.json');
|
|
51
40
|
const version = readVersionFromJson(packageJsonPath, 'version');
|
|
52
41
|
if (!version) {
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Readable } from 'stream';
|
|
1
2
|
import { resetOpenBrowserForTests, setOpenBrowserForTests } from '../browser';
|
|
2
3
|
export { resetOpenBrowserForTests, setOpenBrowserForTests, };
|
|
3
4
|
type LoginOptions = {
|
|
@@ -5,6 +6,17 @@ type LoginOptions = {
|
|
|
5
6
|
password?: string;
|
|
6
7
|
key?: string;
|
|
7
8
|
handoffToken?: string;
|
|
9
|
+
handoffTokenStdin?: boolean;
|
|
8
10
|
json?: boolean;
|
|
9
11
|
};
|
|
10
|
-
|
|
12
|
+
type LoginRuntime = {
|
|
13
|
+
stdin?: Readable;
|
|
14
|
+
};
|
|
15
|
+
export declare const HANDOFF_TOKEN_STDIN_MAX_BYTES: number;
|
|
16
|
+
type HandoffTokenStdinErrorCode = 'empty' | 'too_large' | 'read_failed';
|
|
17
|
+
export declare class HandoffTokenStdinError extends Error {
|
|
18
|
+
readonly code: HandoffTokenStdinErrorCode;
|
|
19
|
+
constructor(code: HandoffTokenStdinErrorCode);
|
|
20
|
+
}
|
|
21
|
+
export declare function readHandoffTokenFromStdin(input?: Readable): Promise<string>;
|
|
22
|
+
export declare function login(env: string, options?: LoginOptions, runtime?: LoginRuntime): Promise<void>;
|
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.setOpenBrowserForTests = exports.resetOpenBrowserForTests = void 0;
|
|
3
|
+
exports.HandoffTokenStdinError = exports.HANDOFF_TOKEN_STDIN_MAX_BYTES = exports.setOpenBrowserForTests = exports.resetOpenBrowserForTests = void 0;
|
|
4
|
+
exports.readHandoffTokenFromStdin = readHandoffTokenFromStdin;
|
|
4
5
|
exports.login = login;
|
|
5
6
|
const types_1 = require("@playdrop/types");
|
|
6
7
|
const config_1 = require("../config");
|
|
@@ -12,6 +13,40 @@ Object.defineProperty(exports, "resetOpenBrowserForTests", { enumerable: true, g
|
|
|
12
13
|
Object.defineProperty(exports, "setOpenBrowserForTests", { enumerable: true, get: function () { return browser_1.setOpenBrowserForTests; } });
|
|
13
14
|
const output_1 = require("../output");
|
|
14
15
|
const messages_1 = require("../messages");
|
|
16
|
+
exports.HANDOFF_TOKEN_STDIN_MAX_BYTES = 8 * 1024;
|
|
17
|
+
class HandoffTokenStdinError extends Error {
|
|
18
|
+
constructor(code) {
|
|
19
|
+
super(`handoff_token_stdin_${code}`);
|
|
20
|
+
this.name = 'HandoffTokenStdinError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.HandoffTokenStdinError = HandoffTokenStdinError;
|
|
25
|
+
async function readHandoffTokenFromStdin(input = process.stdin) {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let byteCount = 0;
|
|
28
|
+
try {
|
|
29
|
+
for await (const chunk of input) {
|
|
30
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
31
|
+
byteCount += buffer.byteLength;
|
|
32
|
+
if (byteCount > exports.HANDOFF_TOKEN_STDIN_MAX_BYTES) {
|
|
33
|
+
throw new HandoffTokenStdinError('too_large');
|
|
34
|
+
}
|
|
35
|
+
chunks.push(buffer);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof HandoffTokenStdinError) {
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
throw new HandoffTokenStdinError('read_failed');
|
|
43
|
+
}
|
|
44
|
+
const token = Buffer.concat(chunks, byteCount).toString('utf8').trim();
|
|
45
|
+
if (!token) {
|
|
46
|
+
throw new HandoffTokenStdinError('empty');
|
|
47
|
+
}
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
15
50
|
function sleep(ms) {
|
|
16
51
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
52
|
}
|
|
@@ -193,7 +228,7 @@ async function loginInBrowser(env, baseUrl, options = {}) {
|
|
|
193
228
|
], { command: 'login' });
|
|
194
229
|
process.exitCode = 1;
|
|
195
230
|
}
|
|
196
|
-
async function login(env, options = {}) {
|
|
231
|
+
async function login(env, options = {}, runtime = {}) {
|
|
197
232
|
const envConfig = (0, environment_1.resolveEnvironmentConfig)(env);
|
|
198
233
|
if (!envConfig) {
|
|
199
234
|
const choices = (0, environment_1.formatEnvironmentList)();
|
|
@@ -207,12 +242,16 @@ async function login(env, options = {}) {
|
|
|
207
242
|
const username = options.username?.trim();
|
|
208
243
|
const password = options.password;
|
|
209
244
|
const apiKey = options.key?.trim();
|
|
210
|
-
const
|
|
245
|
+
const argvHandoffToken = options.handoffToken?.trim();
|
|
246
|
+
const hasApiKeyArgument = options.key !== undefined;
|
|
247
|
+
const hasHandoffTokenArgument = options.handoffToken !== undefined;
|
|
248
|
+
const readsHandoffTokenFromStdin = options.handoffTokenStdin === true;
|
|
211
249
|
const hasDirectCredentials = Boolean(username || password);
|
|
212
250
|
const explicitLoginMethods = [
|
|
213
|
-
|
|
251
|
+
hasApiKeyArgument ? 'key' : null,
|
|
214
252
|
hasDirectCredentials ? 'credentials' : null,
|
|
215
|
-
|
|
253
|
+
hasHandoffTokenArgument ? 'handoff' : null,
|
|
254
|
+
readsHandoffTokenFromStdin ? 'handoff-stdin' : null,
|
|
216
255
|
].filter(Boolean);
|
|
217
256
|
if (explicitLoginMethods.length > 1) {
|
|
218
257
|
(0, messages_1.printErrorWithHelp)('Choose exactly one login method.', [
|
|
@@ -220,6 +259,7 @@ async function login(env, options = {}) {
|
|
|
220
259
|
'Use "--username" with "--password" for direct login.',
|
|
221
260
|
'Use "--key" by itself for API key login.',
|
|
222
261
|
'Use "--handoff-token" by itself for native app handoff login.',
|
|
262
|
+
'Use "--handoff-token-stdin" by itself to read a native app handoff token from stdin.',
|
|
223
263
|
], { command: 'login' });
|
|
224
264
|
process.exitCode = 1;
|
|
225
265
|
return;
|
|
@@ -232,6 +272,33 @@ async function login(env, options = {}) {
|
|
|
232
272
|
process.exitCode = 1;
|
|
233
273
|
return;
|
|
234
274
|
}
|
|
275
|
+
if (hasApiKeyArgument && !apiKey) {
|
|
276
|
+
(0, messages_1.printErrorWithHelp)('The API key cannot be empty.', ['Provide a non-empty value to "--key".'], { command: 'login' });
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (hasHandoffTokenArgument && !argvHandoffToken) {
|
|
281
|
+
(0, messages_1.printErrorWithHelp)('The native app handoff token cannot be empty.', ['Request a fresh handoff token from the PlayDrop app and retry.'], { command: 'login' });
|
|
282
|
+
process.exitCode = 1;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
let handoffToken = argvHandoffToken;
|
|
286
|
+
if (readsHandoffTokenFromStdin) {
|
|
287
|
+
try {
|
|
288
|
+
handoffToken = await readHandoffTokenFromStdin(runtime.stdin ?? process.stdin);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
const inputError = error instanceof HandoffTokenStdinError ? error : null;
|
|
292
|
+
const message = inputError?.code === 'too_large'
|
|
293
|
+
? `Native app handoff input exceeded the ${exports.HANDOFF_TOKEN_STDIN_MAX_BYTES}-byte limit.`
|
|
294
|
+
: inputError?.code === 'empty'
|
|
295
|
+
? 'Native app handoff input was empty.'
|
|
296
|
+
: 'Could not read native app handoff input from stdin.';
|
|
297
|
+
(0, messages_1.printErrorWithHelp)(message, ['Request a fresh handoff token from the PlayDrop app and retry.'], { command: 'login' });
|
|
298
|
+
process.exitCode = 1;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
235
302
|
try {
|
|
236
303
|
if (apiKey) {
|
|
237
304
|
await loginWithKey(env, envConfig.apiBase, apiKey, { json: options.json });
|
|
@@ -33,26 +33,6 @@ export type ReviewRatingCardOptions = {
|
|
|
33
33
|
punchline?: string;
|
|
34
34
|
title?: string;
|
|
35
35
|
};
|
|
36
|
-
export type ReviewListWindowsOptions = {
|
|
37
|
-
pid: string;
|
|
38
|
-
};
|
|
39
|
-
export type ReviewScreenshotOptions = {
|
|
40
|
-
metadata?: string;
|
|
41
|
-
out: string;
|
|
42
|
-
pid: string;
|
|
43
|
-
windowId?: string;
|
|
44
|
-
};
|
|
45
|
-
export declare function buildReviewListWindowsRecorderArgs(options: ReviewListWindowsOptions): string[];
|
|
46
|
-
export declare function buildReviewScreenshotRecorderArgs(options: ReviewScreenshotOptions): {
|
|
47
|
-
args: string[];
|
|
48
|
-
metadataPath: string;
|
|
49
|
-
outputPath: string;
|
|
50
|
-
};
|
|
51
|
-
export declare function listReviewCaptureWindows(options: ReviewListWindowsOptions): Promise<void>;
|
|
52
|
-
export declare function captureReviewScreenshot(options: ReviewScreenshotOptions): Promise<{
|
|
53
|
-
metadataPath: string;
|
|
54
|
-
outputPath: string;
|
|
55
|
-
}>;
|
|
56
36
|
export declare function validateGameReviewResult(input: ValidateGameReviewResultInput): Promise<ReviewValidationResult>;
|
|
57
37
|
export declare function validateReviewResultCommand(options: ReviewValidateResultOptions): Promise<void>;
|
|
58
38
|
export declare function composeReviewEvidence(options: ReviewComposeEvidenceOptions): Promise<{
|
package/dist/commands/review.js
CHANGED
|
@@ -4,20 +4,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.REQUIRED_REVIEW_EVIDENCE_FILES = exports.REVIEW_CRITERIA = void 0;
|
|
7
|
-
exports.buildReviewListWindowsRecorderArgs = buildReviewListWindowsRecorderArgs;
|
|
8
|
-
exports.buildReviewScreenshotRecorderArgs = buildReviewScreenshotRecorderArgs;
|
|
9
|
-
exports.listReviewCaptureWindows = listReviewCaptureWindows;
|
|
10
|
-
exports.captureReviewScreenshot = captureReviewScreenshot;
|
|
11
7
|
exports.validateGameReviewResult = validateGameReviewResult;
|
|
12
8
|
exports.validateReviewResultCommand = validateReviewResultCommand;
|
|
13
9
|
exports.composeReviewEvidence = composeReviewEvidence;
|
|
14
10
|
exports.createReviewRatingCard = createReviewRatingCard;
|
|
15
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
-
const node_child_process_1 = require("node:child_process");
|
|
17
12
|
const node_fs_1 = require("node:fs");
|
|
18
13
|
const sharp_1 = __importDefault(require("sharp"));
|
|
19
14
|
const output_1 = require("../output");
|
|
20
|
-
const captureListing_1 = require("./captureListing");
|
|
21
15
|
exports.REVIEW_CRITERIA = [
|
|
22
16
|
'Gameplay / Core Loop',
|
|
23
17
|
'Depth / Replayability',
|
|
@@ -45,6 +39,7 @@ const STATE_OUTCOME = {
|
|
|
45
39
|
PASSED: 'Passed',
|
|
46
40
|
};
|
|
47
41
|
exports.REQUIRED_REVIEW_EVIDENCE_FILES = [
|
|
42
|
+
'first-frame.png',
|
|
48
43
|
'core.png',
|
|
49
44
|
'win.png',
|
|
50
45
|
'loss.png',
|
|
@@ -52,7 +47,6 @@ exports.REQUIRED_REVIEW_EVIDENCE_FILES = [
|
|
|
52
47
|
'rating-card.png',
|
|
53
48
|
];
|
|
54
49
|
const REVIEW_EVIDENCE_MAX_BYTES = 10 * 1024 * 1024;
|
|
55
|
-
const REVIEW_RECORDER_TIMEOUT_MS = 15000;
|
|
56
50
|
function resolveFilePath(filePath, code) {
|
|
57
51
|
const normalized = typeof filePath === 'string' ? filePath.trim() : '';
|
|
58
52
|
if (!normalized) {
|
|
@@ -174,111 +168,6 @@ async function assertPngFile(filePath) {
|
|
|
174
168
|
}
|
|
175
169
|
}
|
|
176
170
|
}
|
|
177
|
-
async function assertNonBlankPngFile(filePath) {
|
|
178
|
-
await assertPngFile(filePath);
|
|
179
|
-
const image = (0, sharp_1.default)(filePath);
|
|
180
|
-
const metadata = await image.metadata();
|
|
181
|
-
if (!metadata.width || !metadata.height) {
|
|
182
|
-
throw new Error(`review_screenshot_invalid_image:${filePath}`);
|
|
183
|
-
}
|
|
184
|
-
const stats = await image.stats();
|
|
185
|
-
const colorChannels = stats.channels.slice(0, 3);
|
|
186
|
-
const brightest = Math.max(...colorChannels.map((channel) => channel.max));
|
|
187
|
-
if (brightest <= 5) {
|
|
188
|
-
throw new Error(`review_screenshot_blank:${filePath}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
function parsePositiveInteger(value, code) {
|
|
192
|
-
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
193
|
-
const parsed = Number.parseInt(normalized, 10);
|
|
194
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0 || String(parsed) !== normalized) {
|
|
195
|
-
throw new Error(code);
|
|
196
|
-
}
|
|
197
|
-
return parsed;
|
|
198
|
-
}
|
|
199
|
-
function buildReviewListWindowsRecorderArgs(options) {
|
|
200
|
-
const pid = parsePositiveInteger(options.pid, 'invalid_review_window_pid');
|
|
201
|
-
return ['--list-windows', '--pid', String(pid)];
|
|
202
|
-
}
|
|
203
|
-
function buildReviewScreenshotRecorderArgs(options) {
|
|
204
|
-
const pid = parsePositiveInteger(options.pid, 'invalid_review_window_pid');
|
|
205
|
-
const outputPath = resolveFilePath(options.out, 'missing_out');
|
|
206
|
-
const metadataPath = options.metadata
|
|
207
|
-
? resolveFilePath(options.metadata, 'invalid_review_screenshot_metadata')
|
|
208
|
-
: outputPath.replace(/\.png$/i, '') + '.metadata.json';
|
|
209
|
-
const args = [
|
|
210
|
-
'--pid',
|
|
211
|
-
String(pid),
|
|
212
|
-
'--screenshot',
|
|
213
|
-
outputPath,
|
|
214
|
-
'--metadata',
|
|
215
|
-
metadataPath,
|
|
216
|
-
];
|
|
217
|
-
if (options.windowId !== undefined && options.windowId !== '') {
|
|
218
|
-
const windowId = parsePositiveInteger(options.windowId, 'invalid_review_window_id');
|
|
219
|
-
args.push('--window-id', String(windowId));
|
|
220
|
-
}
|
|
221
|
-
return { args, metadataPath, outputPath };
|
|
222
|
-
}
|
|
223
|
-
function runRecorder(args) {
|
|
224
|
-
return new Promise((resolve, reject) => {
|
|
225
|
-
const recorderPath = (0, captureListing_1.resolveListingRecorderPath)();
|
|
226
|
-
const child = (0, node_child_process_1.spawn)(recorderPath, args, {
|
|
227
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
228
|
-
});
|
|
229
|
-
let stdout = '';
|
|
230
|
-
let stderr = '';
|
|
231
|
-
let settled = false;
|
|
232
|
-
const timer = setTimeout(() => {
|
|
233
|
-
if (settled) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
settled = true;
|
|
237
|
-
child.kill('SIGKILL');
|
|
238
|
-
reject(new Error(`review_recorder_timeout:${REVIEW_RECORDER_TIMEOUT_MS}`));
|
|
239
|
-
}, REVIEW_RECORDER_TIMEOUT_MS);
|
|
240
|
-
child.stdout.on('data', (chunk) => {
|
|
241
|
-
stdout += chunk.toString();
|
|
242
|
-
});
|
|
243
|
-
child.stderr.on('data', (chunk) => {
|
|
244
|
-
stderr += chunk.toString();
|
|
245
|
-
});
|
|
246
|
-
child.on('error', (error) => {
|
|
247
|
-
if (settled) {
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
settled = true;
|
|
251
|
-
clearTimeout(timer);
|
|
252
|
-
reject(new Error(`review_recorder_unavailable:${recorderPath}:${error.message}`));
|
|
253
|
-
});
|
|
254
|
-
child.on('close', (code) => {
|
|
255
|
-
if (settled) {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
settled = true;
|
|
259
|
-
clearTimeout(timer);
|
|
260
|
-
if (code === 0) {
|
|
261
|
-
resolve(stdout);
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
const detail = (stderr || stdout || `exit ${code}`).trim();
|
|
265
|
-
reject(new Error(`review_recorder_failed:${detail}`));
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
async function listReviewCaptureWindows(options) {
|
|
270
|
-
const output = await runRecorder(buildReviewListWindowsRecorderArgs(options));
|
|
271
|
-
process.stdout.write(output);
|
|
272
|
-
}
|
|
273
|
-
async function captureReviewScreenshot(options) {
|
|
274
|
-
const { args, metadataPath, outputPath } = buildReviewScreenshotRecorderArgs(options);
|
|
275
|
-
await node_fs_1.promises.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
|
|
276
|
-
await node_fs_1.promises.mkdir(node_path_1.default.dirname(metadataPath), { recursive: true });
|
|
277
|
-
await runRecorder(args);
|
|
278
|
-
await assertNonBlankPngFile(outputPath);
|
|
279
|
-
(0, output_1.printSuccess)(`Review screenshot written to ${outputPath}`);
|
|
280
|
-
return { metadataPath, outputPath };
|
|
281
|
-
}
|
|
282
171
|
async function validateGameReviewResult(input) {
|
|
283
172
|
const normalizedState = String(input.reviewState || '').trim().toUpperCase();
|
|
284
173
|
if (!TERMINAL_REVIEW_STATES.has(normalizedState)) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type InstrumentEvidenceCapture, type InstrumentEvidenceManifest } from '@playdrop/types';
|
|
2
|
+
export declare const INSTRUMENT_EVIDENCE_MANIFEST_FILE = "instrument-evidence.json";
|
|
3
|
+
export declare function createWorkerInstrumentScreenshotCollector(workspaceDir: string): {
|
|
4
|
+
push: (chunk: string) => number;
|
|
5
|
+
finish: () => number;
|
|
6
|
+
};
|
|
7
|
+
export declare function saveInstrumentEvidenceCapture(input: {
|
|
8
|
+
workspaceDir: string;
|
|
9
|
+
evidenceDir: string;
|
|
10
|
+
groupId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
screenshotId: string;
|
|
13
|
+
}): Promise<InstrumentEvidenceCapture>;
|
|
14
|
+
export declare function readAndValidateInstrumentEvidenceManifest(input: {
|
|
15
|
+
workspaceDir: string;
|
|
16
|
+
evidenceDir: string;
|
|
17
|
+
}): InstrumentEvidenceManifest;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.INSTRUMENT_EVIDENCE_MANIFEST_FILE = void 0;
|
|
7
|
+
exports.createWorkerInstrumentScreenshotCollector = createWorkerInstrumentScreenshotCollector;
|
|
8
|
+
exports.saveInstrumentEvidenceCapture = saveInstrumentEvidenceCapture;
|
|
9
|
+
exports.readAndValidateInstrumentEvidenceManifest = readAndValidateInstrumentEvidenceManifest;
|
|
10
|
+
const types_1 = require("@playdrop/types");
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const node_fs_1 = require("node:fs");
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
15
|
+
const SCREENSHOT_STORE_DIR = '.playdrop-instrument-screenshots';
|
|
16
|
+
const SCREENSHOT_INDEX_FILE = 'index.json';
|
|
17
|
+
exports.INSTRUMENT_EVIDENCE_MANIFEST_FILE = 'instrument-evidence.json';
|
|
18
|
+
const GROUP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
19
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
20
|
+
function sha256(buffer) {
|
|
21
|
+
return (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
function isPathInside(parent, child) {
|
|
24
|
+
const relative = node_path_1.default.relative(parent, child);
|
|
25
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative));
|
|
26
|
+
}
|
|
27
|
+
function writeJsonAtomic(filePath, value) {
|
|
28
|
+
(0, node_fs_1.mkdirSync)(node_path_1.default.dirname(filePath), { recursive: true });
|
|
29
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
30
|
+
(0, node_fs_1.writeFileSync)(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
31
|
+
(0, node_fs_1.renameSync)(tempPath, filePath);
|
|
32
|
+
}
|
|
33
|
+
function readJsonRecord(filePath, code) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse((0, node_fs_1.readFileSync)(filePath, 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw new Error(`${code}:${error instanceof Error ? error.message : String(error)}`);
|
|
40
|
+
}
|
|
41
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
42
|
+
throw new Error(code);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
function screenshotStorePath(workspaceDir) {
|
|
47
|
+
return node_path_1.default.join(workspaceDir, SCREENSHOT_STORE_DIR);
|
|
48
|
+
}
|
|
49
|
+
function screenshotIndexPath(workspaceDir) {
|
|
50
|
+
return node_path_1.default.join(screenshotStorePath(workspaceDir), SCREENSHOT_INDEX_FILE);
|
|
51
|
+
}
|
|
52
|
+
function readScreenshotIndex(workspaceDir) {
|
|
53
|
+
const filePath = screenshotIndexPath(workspaceDir);
|
|
54
|
+
if (!(0, node_fs_1.existsSync)(filePath)) {
|
|
55
|
+
return { version: 1, screenshots: {} };
|
|
56
|
+
}
|
|
57
|
+
const parsed = readJsonRecord(filePath, 'invalid_instrument_screenshot_index');
|
|
58
|
+
const screenshots = parsed['screenshots'];
|
|
59
|
+
if (parsed['version'] !== 1 || !screenshots || typeof screenshots !== 'object' || Array.isArray(screenshots)) {
|
|
60
|
+
throw new Error('invalid_instrument_screenshot_index');
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
function extensionForMediaType(mediaType) {
|
|
65
|
+
return mediaType === 'image/jpeg' ? 'jpg' : 'png';
|
|
66
|
+
}
|
|
67
|
+
function createWorkerInstrumentScreenshotCollector(workspaceDir) {
|
|
68
|
+
const resolvedWorkspace = node_path_1.default.resolve(workspaceDir);
|
|
69
|
+
const parser = new types_1.ClaudeChromeScreenshotStreamParser((screenshot) => {
|
|
70
|
+
const buffer = Buffer.from(screenshot.dataBase64, 'base64');
|
|
71
|
+
if (buffer.length <= 0) {
|
|
72
|
+
throw new Error(`invalid_claude_screenshot_payload:${screenshot.screenshotId}`);
|
|
73
|
+
}
|
|
74
|
+
const storeDir = screenshotStorePath(resolvedWorkspace);
|
|
75
|
+
(0, node_fs_1.mkdirSync)(storeDir, { recursive: true });
|
|
76
|
+
const fileName = `${screenshot.screenshotId}.${extensionForMediaType(screenshot.mediaType)}`;
|
|
77
|
+
const filePath = node_path_1.default.join(storeDir, fileName);
|
|
78
|
+
(0, node_fs_1.writeFileSync)(filePath, buffer);
|
|
79
|
+
const index = readScreenshotIndex(resolvedWorkspace);
|
|
80
|
+
index.screenshots[screenshot.screenshotId] = {
|
|
81
|
+
screenshotId: screenshot.screenshotId,
|
|
82
|
+
tabId: screenshot.tabId,
|
|
83
|
+
toolUseId: screenshot.toolUseId,
|
|
84
|
+
mediaType: screenshot.mediaType,
|
|
85
|
+
fileName,
|
|
86
|
+
sha256: sha256(buffer),
|
|
87
|
+
capturedAt: new Date().toISOString(),
|
|
88
|
+
width: screenshot.width,
|
|
89
|
+
height: screenshot.height,
|
|
90
|
+
};
|
|
91
|
+
writeJsonAtomic(screenshotIndexPath(resolvedWorkspace), index);
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
push: (chunk) => parser.push(chunk),
|
|
95
|
+
finish: () => parser.finish(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function normalizeEvidenceName(value) {
|
|
99
|
+
const normalized = value.trim();
|
|
100
|
+
if (!types_1.INSTRUMENT_EVIDENCE_NAMES.includes(normalized)) {
|
|
101
|
+
throw new Error(`invalid_instrument_evidence_name:${value}`);
|
|
102
|
+
}
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
function normalizeGroupId(value) {
|
|
106
|
+
const normalized = value.trim().toLowerCase();
|
|
107
|
+
if (!GROUP_ID_PATTERN.test(normalized)) {
|
|
108
|
+
throw new Error(`invalid_instrument_evidence_group:${value}`);
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
function resolveEvidenceDir(workspaceDir, evidenceDir) {
|
|
113
|
+
const resolvedWorkspace = node_path_1.default.resolve(workspaceDir);
|
|
114
|
+
const resolvedEvidenceDir = node_path_1.default.resolve(evidenceDir);
|
|
115
|
+
if (!isPathInside(resolvedWorkspace, resolvedEvidenceDir)) {
|
|
116
|
+
throw new Error('instrument_evidence_dir_outside_workspace');
|
|
117
|
+
}
|
|
118
|
+
return resolvedEvidenceDir;
|
|
119
|
+
}
|
|
120
|
+
function readManifestIfPresent(evidenceDir) {
|
|
121
|
+
const filePath = node_path_1.default.join(evidenceDir, exports.INSTRUMENT_EVIDENCE_MANIFEST_FILE);
|
|
122
|
+
if (!(0, node_fs_1.existsSync)(filePath)) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return readJsonRecord(filePath, 'invalid_instrument_evidence_manifest');
|
|
126
|
+
}
|
|
127
|
+
async function saveInstrumentEvidenceCapture(input) {
|
|
128
|
+
const evidenceName = normalizeEvidenceName(input.name);
|
|
129
|
+
const groupId = normalizeGroupId(input.groupId);
|
|
130
|
+
const screenshotId = input.screenshotId.trim();
|
|
131
|
+
const index = readScreenshotIndex(input.workspaceDir);
|
|
132
|
+
const screenshot = index.screenshots[screenshotId];
|
|
133
|
+
if (!screenshot) {
|
|
134
|
+
throw new Error(`instrument_screenshot_not_found:${screenshotId}`);
|
|
135
|
+
}
|
|
136
|
+
const evidenceDir = resolveEvidenceDir(input.workspaceDir, input.evidenceDir);
|
|
137
|
+
(0, node_fs_1.mkdirSync)(evidenceDir, { recursive: true });
|
|
138
|
+
const existing = readManifestIfPresent(evidenceDir);
|
|
139
|
+
if (existing && existing.groupId !== groupId) {
|
|
140
|
+
throw new Error(`instrument_evidence_group_mismatch:${existing.groupId}:${groupId}`);
|
|
141
|
+
}
|
|
142
|
+
if (existing && existing.controlledTabId !== screenshot.tabId) {
|
|
143
|
+
throw new Error(`instrument_evidence_tab_mismatch:${existing.controlledTabId}:${screenshot.tabId}`);
|
|
144
|
+
}
|
|
145
|
+
const fileName = `${evidenceName}.png`;
|
|
146
|
+
const sourcePath = node_path_1.default.join(screenshotStorePath(input.workspaceDir), screenshot.fileName);
|
|
147
|
+
const outputPath = node_path_1.default.join(evidenceDir, fileName);
|
|
148
|
+
const sourceBuffer = (0, node_fs_1.readFileSync)(sourcePath);
|
|
149
|
+
const outputBuffer = await (0, sharp_1.default)(sourceBuffer).png().toBuffer();
|
|
150
|
+
(0, node_fs_1.writeFileSync)(outputPath, outputBuffer);
|
|
151
|
+
const outputHash = sha256(outputBuffer);
|
|
152
|
+
const capture = {
|
|
153
|
+
name: evidenceName,
|
|
154
|
+
screenshotId,
|
|
155
|
+
tabId: screenshot.tabId,
|
|
156
|
+
toolUseId: screenshot.toolUseId,
|
|
157
|
+
mediaType: screenshot.mediaType,
|
|
158
|
+
fileName,
|
|
159
|
+
sourceSha256: screenshot.sha256,
|
|
160
|
+
sha256: outputHash,
|
|
161
|
+
capturedAt: screenshot.capturedAt,
|
|
162
|
+
};
|
|
163
|
+
const manifest = existing ?? {
|
|
164
|
+
version: 1,
|
|
165
|
+
groupId,
|
|
166
|
+
controlledTabId: screenshot.tabId,
|
|
167
|
+
captures: {},
|
|
168
|
+
};
|
|
169
|
+
manifest.captures[evidenceName] = capture;
|
|
170
|
+
writeJsonAtomic(node_path_1.default.join(evidenceDir, exports.INSTRUMENT_EVIDENCE_MANIFEST_FILE), manifest);
|
|
171
|
+
return capture;
|
|
172
|
+
}
|
|
173
|
+
function normalizeCapture(value, name) {
|
|
174
|
+
const capture = value && typeof value === 'object' && !Array.isArray(value)
|
|
175
|
+
? value
|
|
176
|
+
: null;
|
|
177
|
+
const screenshotId = typeof capture?.['screenshotId'] === 'string' ? capture['screenshotId'].trim() : '';
|
|
178
|
+
const tabId = Number(capture?.['tabId']);
|
|
179
|
+
const toolUseId = typeof capture?.['toolUseId'] === 'string' ? capture['toolUseId'].trim() : '';
|
|
180
|
+
const mediaType = capture?.['mediaType'];
|
|
181
|
+
const fileName = typeof capture?.['fileName'] === 'string' ? capture['fileName'].trim() : '';
|
|
182
|
+
const hash = typeof capture?.['sha256'] === 'string' ? capture['sha256'].trim().toLowerCase() : '';
|
|
183
|
+
const sourceHash = typeof capture?.['sourceSha256'] === 'string' ? capture['sourceSha256'].trim().toLowerCase() : '';
|
|
184
|
+
const capturedAt = typeof capture?.['capturedAt'] === 'string' ? capture['capturedAt'].trim() : '';
|
|
185
|
+
if (capture?.['name'] !== name
|
|
186
|
+
|| !screenshotId
|
|
187
|
+
|| !Number.isInteger(tabId)
|
|
188
|
+
|| tabId <= 0
|
|
189
|
+
|| !toolUseId
|
|
190
|
+
|| (mediaType !== 'image/jpeg' && mediaType !== 'image/png')
|
|
191
|
+
|| node_path_1.default.basename(fileName) !== fileName
|
|
192
|
+
|| !fileName
|
|
193
|
+
|| !SHA256_PATTERN.test(sourceHash)
|
|
194
|
+
|| !SHA256_PATTERN.test(hash)
|
|
195
|
+
|| !capturedAt) {
|
|
196
|
+
throw new Error(`invalid_instrument_evidence_capture:${name}`);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
name,
|
|
200
|
+
screenshotId,
|
|
201
|
+
tabId,
|
|
202
|
+
toolUseId,
|
|
203
|
+
mediaType,
|
|
204
|
+
fileName,
|
|
205
|
+
sourceSha256: sourceHash,
|
|
206
|
+
sha256: hash,
|
|
207
|
+
capturedAt,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function readAndValidateInstrumentEvidenceManifest(input) {
|
|
211
|
+
const evidenceDir = resolveEvidenceDir(input.workspaceDir, input.evidenceDir);
|
|
212
|
+
const parsed = readManifestIfPresent(evidenceDir);
|
|
213
|
+
if (!parsed || parsed.version !== 1) {
|
|
214
|
+
throw new Error('missing_instrument_evidence_manifest');
|
|
215
|
+
}
|
|
216
|
+
const groupId = normalizeGroupId(parsed.groupId);
|
|
217
|
+
const controlledTabId = Number(parsed.controlledTabId);
|
|
218
|
+
if (!Number.isInteger(controlledTabId) || controlledTabId <= 0) {
|
|
219
|
+
throw new Error('invalid_instrument_evidence_tab');
|
|
220
|
+
}
|
|
221
|
+
const captures = {};
|
|
222
|
+
const screenshotIds = new Set();
|
|
223
|
+
for (const name of types_1.INSTRUMENT_EVIDENCE_NAMES) {
|
|
224
|
+
const capture = normalizeCapture(parsed.captures?.[name], name);
|
|
225
|
+
if (capture.tabId !== controlledTabId) {
|
|
226
|
+
throw new Error(`instrument_evidence_tab_mismatch:${controlledTabId}:${capture.tabId}`);
|
|
227
|
+
}
|
|
228
|
+
const filePath = node_path_1.default.join(evidenceDir, capture.fileName);
|
|
229
|
+
if (!(0, node_fs_1.existsSync)(filePath) || sha256((0, node_fs_1.readFileSync)(filePath)) !== capture.sha256) {
|
|
230
|
+
throw new Error(`instrument_evidence_file_hash_mismatch:${name}`);
|
|
231
|
+
}
|
|
232
|
+
if (screenshotIds.has(capture.screenshotId)) {
|
|
233
|
+
throw new Error(`duplicate_instrument_evidence_screenshot:${capture.screenshotId}`);
|
|
234
|
+
}
|
|
235
|
+
screenshotIds.add(capture.screenshotId);
|
|
236
|
+
captures[name] = capture;
|
|
237
|
+
}
|
|
238
|
+
return { version: 1, groupId, controlledTabId, captures };
|
|
239
|
+
}
|
|
@@ -50,6 +50,7 @@ export declare function runLoggedProcess(input: {
|
|
|
50
50
|
maxOutputChars: number;
|
|
51
51
|
transcriptFlushIntervalMs?: number;
|
|
52
52
|
onTranscriptChunks?: (chunks: LoggedProcessTranscriptChunk[]) => Promise<void> | void;
|
|
53
|
+
onStdoutText?: (text: string) => boolean | void;
|
|
53
54
|
onChild?: (controls: {
|
|
54
55
|
terminate: () => void;
|
|
55
56
|
}) => void;
|
|
@@ -377,6 +377,17 @@ async function runLoggedProcess(input) {
|
|
|
377
377
|
if (input.onTranscriptChunks) {
|
|
378
378
|
pendingTranscriptChunks.push({ stream, content: text });
|
|
379
379
|
}
|
|
380
|
+
if (stream === 'stdout' && input.onStdoutText) {
|
|
381
|
+
try {
|
|
382
|
+
const flushNow = input.onStdoutText(text) === true;
|
|
383
|
+
if (flushNow && input.onTranscriptChunks) {
|
|
384
|
+
queueTranscriptFlush().catch(handleTranscriptFlushError);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
handleTranscriptFlushError(error);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
380
391
|
combined = tailText(combined + text, input.maxOutputChars);
|
|
381
392
|
};
|
|
382
393
|
const timeout = setTimeout(() => {
|