@phpsandbox/sdk 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +7 -18
  3. package/dist/browser/phpsandbox-sdk.esm.js +7 -3
  4. package/dist/browser/phpsandbox-sdk.esm.min.js +7 -7
  5. package/dist/browser/phpsandbox-sdk.iife.js +7 -3
  6. package/dist/browser/phpsandbox-sdk.iife.min.js +7 -7
  7. package/dist/index.d.ts +21 -5
  8. package/dist/index.js +5 -2
  9. package/dist/{integrations.d.ts → integrations/account.d.ts} +1 -1
  10. package/dist/integrations/index.d.ts +2 -0
  11. package/dist/integrations/index.js +2 -0
  12. package/dist/{sandbox-integrations.d.ts → integrations/sandbox.d.ts} +2 -2
  13. package/dist/{sandbox-integrations.js → integrations/sandbox.js} +1 -1
  14. package/dist/log.d.ts +1 -1
  15. package/dist/log.js +1 -1
  16. package/dist/metrics.d.ts +1 -1
  17. package/dist/metrics.js +1 -1
  18. package/dist/ports.d.ts +1 -1
  19. package/dist/ports.js +1 -1
  20. package/dist/runtime.d.ts +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/package.json +5 -9
  23. package/docs/api-summary.md +0 -181
  24. package/docs/cdn-usage.md +0 -68
  25. package/docs/getting-started.md +0 -169
  26. package/examples/README.md +0 -203
  27. package/examples/basic.ts +0 -15
  28. package/examples/get.ts +0 -22
  29. package/examples/git-merge-smoke.ts +0 -395
  30. package/examples/laravel-load-test.ts +0 -2800
  31. package/examples/notebook-readonly-smoke.ts +0 -806
  32. package/examples/publication-provider-label.ts +0 -11
  33. package/examples/publish-cloudflare-container.ts +0 -65
  34. package/examples/publish-laravel-cloud.ts +0 -123
  35. package/examples/publish-ssh-agent.ts +0 -211
  36. /package/dist/{integrations.js → integrations/account.js} +0 -0
  37. /package/dist/{runtime-telemetry.d.ts → telemetry.d.ts} +0 -0
  38. /package/dist/{runtime-telemetry.js → telemetry.js} +0 -0
@@ -1,806 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import { createHash } from 'node:crypto';
3
- import https from 'node:https';
4
- import axios from 'axios';
5
- import NodeWebSocket from 'ws';
6
- import {
7
- NotebookInstance,
8
- PHPSandbox,
9
- type NotebookData,
10
- type NotebookInitProgress,
11
- type NotebookInitResult,
12
- type PortInfo,
13
- } from '@phpsandbox/sdk';
14
-
15
- const DEFAULT_API_URL = 'https://api.phpsandbox.io/v1';
16
- const DEFAULT_LOCAL_API_URL = 'https://api.phpsandbox.test/v1';
17
-
18
- interface ParsedArgs {
19
- get(key: string): string | boolean | undefined;
20
- }
21
-
22
- interface SmokeOptions {
23
- token: string;
24
- local: boolean;
25
- apiUrl: string;
26
- notebookId: string;
27
- runtimeUrl?: string;
28
- runtimeOrigin?: string;
29
- includeExec: boolean;
30
- includeDownload: boolean;
31
- includePreview: boolean;
32
- debug: boolean;
33
- insecureTls: boolean;
34
- caFile?: string;
35
- stepTimeoutMs: number;
36
- streamTimeoutMs: number;
37
- maxStreamChars: number;
38
- json: boolean;
39
- jsonFile?: string;
40
- }
41
-
42
- interface StepArtifact {
43
- name: string;
44
- status: 'passed' | 'failed' | 'skipped';
45
- durationMs: number;
46
- detail?: string;
47
- error?: string;
48
- }
49
-
50
- interface InitTraceArtifact {
51
- resultType?: NotebookInitResult['type'];
52
- events: Array<{
53
- atMs: number;
54
- kind?: string;
55
- step?: string;
56
- message: string;
57
- details?: string;
58
- durationMs?: number;
59
- }>;
60
- }
61
-
62
- interface SmokeArtifact {
63
- status: 'passed' | 'failed';
64
- durationMs: number;
65
- options: {
66
- apiUrl: string;
67
- local: boolean;
68
- notebookId: string;
69
- runtimeUrl?: string;
70
- runtimeOrigin?: string;
71
- includeExec: boolean;
72
- includeDownload: boolean;
73
- includePreview: boolean;
74
- };
75
- notebook?: {
76
- id: string;
77
- title?: string;
78
- type?: string;
79
- runtimeUrl: string;
80
- };
81
- initTrace: InitTraceArtifact;
82
- steps: StepArtifact[];
83
- }
84
-
85
- async function main(): Promise<void> {
86
- const startedAt = performance.now();
87
- const options = parseOptions(process.argv.slice(2));
88
- const steps: StepArtifact[] = [];
89
- const initTrace: InitTraceArtifact = { events: [] };
90
- const transport = await createNodeTransport(options);
91
- const client = PHPSandbox.realtime(options.token, options.apiUrl, {
92
- debug: options.debug,
93
- ...transport,
94
- });
95
-
96
- let notebook: NotebookInstance | null = null;
97
- let notebookData: NotebookData | null = null;
98
-
99
- try {
100
- const response = await recordStep(steps, 'api.get-notebook', options.stepTimeoutMs, async () => {
101
- const result = await client.notebook.get(options.notebookId);
102
- result.dispose();
103
- return {
104
- data: result.data,
105
- detail: `id=${result.data.id} type=${result.data.type ?? ''} title=${result.data.title ?? ''} runtimeUrl=${redactUrl(result.data.runtimeUrl)}`,
106
- };
107
- });
108
-
109
- notebookData = {
110
- ...response.data,
111
- runtimeUrl: resolveRuntimeUrl(response.data.runtimeUrl, options),
112
- };
113
-
114
- notebook = client.notebook.open(notebookData);
115
- const initStartedAt = performance.now();
116
- notebook.listen('init.event', (event: NotebookInitProgress) => {
117
- initTrace.events.push({
118
- atMs: performance.now() - initStartedAt,
119
- kind: event.kind,
120
- step: event.step,
121
- message: event.message,
122
- details: event.details,
123
- });
124
- });
125
- notebook.onDidInitialize((result) => {
126
- initTrace.resultType = result.type;
127
- });
128
-
129
- const readyStep = await recordStep(steps, 'notebook.ready', options.stepTimeoutMs, async () => {
130
- const result = await notebook!.ready();
131
- assertValidUrl(result.data.previewUrl, 'notebook.ready previewUrl');
132
- const ports = assertPortInfoList(result.data.ports, 'notebook.ready ports');
133
- return {
134
- result,
135
- detail: `type=${result.type} env=${result.data.env.length} ${describePorts(ports)} previewUrl=${redactUrl(result.data.previewUrl)}`,
136
- };
137
- });
138
- const ready = readyStep.result;
139
-
140
- await recordStep(steps, 'container.opened-ports', options.stepTimeoutMs, async () => {
141
- const ports = await notebook!.runtime.ports.list();
142
- return describePorts(assertPortInfoList(ports, 'ports.list'));
143
- }, false);
144
-
145
- const rootSnapshot = await recordStep(steps, 'filesystem.root', options.stepTimeoutMs, async () => {
146
- const rootEntries = await notebook!.files.list('/');
147
-
148
- const preferred = ['composer.json', 'index.php', 'artisan'];
149
- const rootNames = new Set(rootEntries.map(([name]) => name));
150
- const candidate = preferred.find((name) => rootNames.has(name));
151
- return {
152
- detail: `rootEntries=${rootEntries.length}`,
153
- candidate,
154
- };
155
- });
156
-
157
- const treePath = rootSnapshot.candidate === 'artisan' ? 'routes' : undefined;
158
- if (treePath) {
159
- await recordStep(steps, 'filesystem.tree', options.stepTimeoutMs, async () => {
160
- const tree = await notebook!.files.tree(treePath);
161
- return `path=${treePath} bytes=${tree.length}`;
162
- }, false);
163
- } else {
164
- recordSkippedStep(steps, 'filesystem.tree', 'No small known directory selected for tree.');
165
- }
166
-
167
- const findSnapshot = await recordStep(steps, 'filesystem.find', options.stepTimeoutMs, async () => {
168
- const [composerFiles, indexFiles] = await Promise.all([
169
- notebook!.files.find('composer.json', { maxResults: 10 }),
170
- notebook!.files.find('index.php', { maxResults: 10 }),
171
- ]);
172
- const candidate = [...composerFiles, ...indexFiles]
173
- .find((file) => typeof file.path === 'string')
174
- ?.path;
175
- return {
176
- detail: `composerFiles=${composerFiles.length} indexFiles=${indexFiles.length}`,
177
- candidate,
178
- };
179
- }, false);
180
-
181
- const candidate = findSnapshot?.candidate ?? rootSnapshot.candidate;
182
-
183
- if (candidate) {
184
- await recordStep(steps, 'filesystem.read-candidate', options.stepTimeoutMs, async () => {
185
- const [stat, content, tail] = await Promise.all([
186
- notebook!.files.stat(candidate),
187
- notebook!.files.read(candidate),
188
- notebook!.files.tail(candidate, { lines: 10 }),
189
- ]);
190
-
191
- return `path=${candidate} size=${stat.size} sha256=${sha256(content)} tailBytes=${tail.length}`;
192
- }, false);
193
- } else {
194
- recordSkippedStep(steps, 'filesystem.read-candidate', 'No readable candidate file found.');
195
- }
196
-
197
- if (options.includeDownload) {
198
- if (candidate) {
199
- await recordStep(steps, 'filesystem.download', options.stepTimeoutMs, async () => {
200
- const archive = await notebook!.files.download({
201
- exclude: ['vendor/**', 'node_modules/**'],
202
- include: [candidate],
203
- });
204
- if (archive.size === 0) {
205
- throw new Error('file.download() returned an empty blob.');
206
- }
207
- return `${archive.size} bytes`;
208
- }, false);
209
- } else {
210
- recordSkippedStep(steps, 'filesystem.download', 'No readable candidate file found.');
211
- }
212
- } else {
213
- recordSkippedStep(steps, 'filesystem.download', 'Enable with --include-download.');
214
- }
215
-
216
- await recordStep(steps, 'filesystem.search', options.stepTimeoutMs, async () => {
217
- const [hasMore, matches] = await notebook!.files.search({
218
- pattern: 'php',
219
- isRegExp: false,
220
- isCaseSensitive: false,
221
- });
222
- return `matches=${matches.length} hasMore=${hasMore}`;
223
- }, false);
224
-
225
- await recordStep(steps, 'git.status', options.stepTimeoutMs, async () => {
226
- const status = await notebook!.git.status();
227
- return `initialized=${status.initialized} clean=${status.clean} branch=${status.branch}`;
228
- }, false);
229
-
230
- await recordStep(steps, 'git.log', options.stepTimeoutMs, async () => {
231
- const log = await notebook!.git.log('main');
232
- return `commits=${log.length}`;
233
- }, false);
234
-
235
- await recordStep(steps, 'service.list', options.stepTimeoutMs, async () => {
236
- const services = await notebook!.services.list();
237
- return `services=${services.length}`;
238
- }, false);
239
-
240
- await recordStep(steps, 'composer.packages', options.stepTimeoutMs, async () => {
241
- const packages = await notebook!.composer.packages();
242
- return `packages=${packages.length}`;
243
- }, false);
244
-
245
- if (options.includeExec) {
246
- await runExecutionChecks(notebook, steps, options);
247
- } else {
248
- recordSkippedStep(steps, 'shell.run', 'Enable with --include-exec.');
249
- recordSkippedStep(steps, 'terminals.create', 'Enable with --include-exec.');
250
- recordSkippedStep(steps, 'terminal.interactive', 'Enable with --include-exec.');
251
- recordSkippedStep(steps, 'terminal.osc', 'Enable with --include-exec.');
252
- recordSkippedStep(steps, 'repl.eval', 'Enable with --include-exec.');
253
- }
254
-
255
- if (options.includePreview) {
256
- await recordStep(steps, 'preview.fetch', options.stepTimeoutMs, async () => {
257
- const response = await fetch(ready.data.previewUrl, {
258
- redirect: 'manual',
259
- signal: AbortSignal.timeout(options.stepTimeoutMs),
260
- });
261
- return `status=${response.status} url=${redactUrl(ready.data.previewUrl)}`;
262
- }, false);
263
- } else {
264
- recordSkippedStep(steps, 'preview.fetch', 'Enable with --include-preview.');
265
- }
266
- } finally {
267
- notebook?.dispose();
268
- }
269
-
270
- const status = steps.some((step) => step.status === 'failed') ? 'failed' : 'passed';
271
- const artifact: SmokeArtifact = {
272
- status,
273
- durationMs: performance.now() - startedAt,
274
- options: {
275
- apiUrl: options.apiUrl,
276
- local: options.local,
277
- notebookId: options.notebookId,
278
- runtimeUrl: options.runtimeUrl ? redactUrl(options.runtimeUrl) : undefined,
279
- runtimeOrigin: options.runtimeOrigin,
280
- includeExec: options.includeExec,
281
- includeDownload: options.includeDownload,
282
- includePreview: options.includePreview,
283
- },
284
- notebook: notebookData
285
- ? {
286
- id: notebookData.id,
287
- title: notebookData.title,
288
- type: notebookData.type,
289
- runtimeUrl: redactUrl(notebookData.runtimeUrl),
290
- }
291
- : undefined,
292
- initTrace,
293
- steps,
294
- };
295
-
296
- if (options.jsonFile) {
297
- await fs.writeFile(options.jsonFile, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
298
- }
299
-
300
- if (options.json) {
301
- console.log(JSON.stringify(artifact, null, 2));
302
- } else {
303
- printSummary(artifact);
304
- }
305
-
306
- if (status === 'failed') {
307
- process.exitCode = 1;
308
- }
309
- }
310
-
311
- async function runExecutionChecks(notebook: NotebookInstance, steps: StepArtifact[], options: SmokeOptions): Promise<void> {
312
- await recordStep(steps, 'shell.run', options.stepTimeoutMs, async () => {
313
- const result = await notebook.run(['php', '-r', 'echo "sdk-smoke";']).wait();
314
- if (result.exitCode !== 0) {
315
- throw new Error(result.stderr || result.output);
316
- }
317
- if (!result.output.includes('sdk-smoke')) {
318
- throw new Error(`Unexpected shell output: ${result.output}`);
319
- }
320
- return `exit=${result.exitCode}`;
321
- }, false);
322
-
323
- await recordStep(steps, 'terminals.create', options.stepTimeoutMs, async () => {
324
- const process = await notebook.terminals.create({ command: 'php', args: ['-r', 'echo "sdk-smoke";'] });
325
- const [exitCode, output] = await Promise.all([
326
- withTimeout(process.wait(), options.stepTimeoutMs, 'waiting for terminal process exit'),
327
- readStreamToString(process.output, options.streamTimeoutMs, options.maxStreamChars),
328
- ]);
329
-
330
- if (exitCode !== 0 || !output.includes('sdk-smoke')) {
331
- throw new Error(`terminals.create failed: exit=${exitCode} output=${trimDetail(output)}`);
332
- }
333
-
334
- return `exit=${exitCode}`;
335
- }, false);
336
-
337
- await recordStep(steps, 'terminal.interactive', options.stepTimeoutMs, async () => {
338
- const process = await notebook.terminals.create({
339
- command: 'bash',
340
- args: ['-lc', 'IFS= read -r line; printf "sdk-input:%s\\n" "$line"'],
341
- });
342
-
343
- try {
344
- const writer = process.input.getWriter();
345
- await writer.write('hello-from-sdk\n');
346
- writer.releaseLock();
347
-
348
- const [exitCode, output] = await Promise.all([
349
- withTimeout(process.wait(), options.stepTimeoutMs, 'waiting for interactive terminal process exit'),
350
- readStreamToString(process.output, options.streamTimeoutMs, options.maxStreamChars),
351
- ]);
352
-
353
- if (exitCode !== 0 || !output.includes('sdk-input:hello-from-sdk')) {
354
- throw new Error(`terminal.interactive failed: exit=${exitCode} output=${trimDetail(output)}`);
355
- }
356
-
357
- return `exit=${exitCode}`;
358
- } finally {
359
- await process.kill().catch(() => false);
360
- }
361
- }, false);
362
-
363
- await recordStep(steps, 'terminal.osc', options.stepTimeoutMs, async () => {
364
- const process = await notebook.terminals.create({
365
- command: 'bash',
366
- args: ['-lc', 'printf "sdk-prompt$"'],
367
- osc: true,
368
- size: [80, 15],
369
- });
370
-
371
- try {
372
- const [exitCode, output] = await Promise.all([
373
- withTimeout(process.wait(), options.stepTimeoutMs, 'waiting for terminal OSC process exit'),
374
- readStreamToString(process.output, options.streamTimeoutMs, options.maxStreamChars),
375
- ]);
376
-
377
- if (exitCode !== 0 || !output.includes('\x1b]654;interactive\x07') || !output.includes('\x1b]654;prompt\x07')) {
378
- throw new Error(`terminal.osc failed: exit=${exitCode} output=${trimDetail(output)}`);
379
- }
380
-
381
- return `exit=${exitCode}`;
382
- } finally {
383
- await process.kill().catch(() => false);
384
- }
385
- }, false);
386
-
387
- await recordStep(steps, 'repl.eval', options.stepTimeoutMs, async () => {
388
- const result = await notebook.repl.eval('return "sdk-smoke";');
389
- if (result.exitCode !== 0) {
390
- throw new Error(`repl.eval exited with ${result.exitCode}`);
391
- }
392
- return `exit=${result.exitCode}`;
393
- }, false);
394
- }
395
-
396
- async function recordStep<T>(
397
- steps: StepArtifact[],
398
- name: string,
399
- timeoutMs: number,
400
- action: () => Promise<T>,
401
- required = true
402
- ): Promise<T> {
403
- const startedAt = performance.now();
404
-
405
- try {
406
- const value = await withTimeout(action(), timeoutMs, `${name} timed out after ${timeoutMs}ms`);
407
- steps.push({
408
- name,
409
- status: 'passed',
410
- durationMs: performance.now() - startedAt,
411
- detail: describeValue(value),
412
- });
413
- console.log(`PASS ${name} ${formatDuration(performance.now() - startedAt)}`);
414
- return value;
415
- } catch (error) {
416
- steps.push({
417
- name,
418
- status: 'failed',
419
- durationMs: performance.now() - startedAt,
420
- error: formatError(error),
421
- });
422
- console.log(`FAIL ${name} ${formatDuration(performance.now() - startedAt)} ${formatError(error)}`);
423
- if (required) {
424
- throw error;
425
- }
426
- return undefined as T;
427
- }
428
- }
429
-
430
- function recordSkippedStep(steps: StepArtifact[], name: string, detail: string): void {
431
- steps.push({
432
- name,
433
- status: 'skipped',
434
- durationMs: 0,
435
- detail,
436
- });
437
- console.log(`SKIP ${name} ${detail}`);
438
- }
439
-
440
- async function readStreamToString(stream: ReadableStream<string | Uint8Array>, timeoutMs: number, maxChars: number): Promise<string> {
441
- const reader = stream.getReader();
442
- let output = '';
443
-
444
- try {
445
- while (output.length < maxChars) {
446
- const chunk = await withTimeout(reader.read(), timeoutMs, 'waiting for stream output');
447
- if (chunk.done) {
448
- break;
449
- }
450
- output += chunk.value instanceof Uint8Array ? new TextDecoder().decode(chunk.value) : (chunk.value ?? '');
451
- }
452
- return output;
453
- } finally {
454
- reader.releaseLock();
455
- }
456
- }
457
-
458
- function resolveRuntimeUrl(original: string, options: SmokeOptions): string {
459
- if (options.runtimeUrl) {
460
- return options.runtimeUrl;
461
- }
462
-
463
- if (!options.runtimeOrigin) {
464
- return original;
465
- }
466
-
467
- const source = new URL(original);
468
- const target = new URL(options.runtimeOrigin);
469
- source.protocol = target.protocol;
470
- source.host = target.host;
471
- return source.toString();
472
- }
473
-
474
- function parseOptions(argv: string[]): SmokeOptions {
475
- const args = parseArgs(argv);
476
-
477
- if (booleanArg(args, 'help') === true) {
478
- printHelp();
479
- process.exit(0);
480
- }
481
-
482
- const token = stringArg(args, 'token') ?? process.env.PHPSANDBOX_TOKEN;
483
- if (!token) {
484
- throw new Error('Missing PHPSANDBOX_TOKEN. Pass it via the environment or --token.');
485
- }
486
-
487
- return {
488
- token,
489
- local: booleanArg(args, 'local') ?? false,
490
- apiUrl:
491
- stringArg(args, 'api-url') ??
492
- process.env.PHPSANDBOX_API_URL ??
493
- ((booleanArg(args, 'local') ?? false) ? DEFAULT_LOCAL_API_URL : DEFAULT_API_URL),
494
- notebookId: stringArg(args, 'notebook-id') ?? 'gypjc',
495
- runtimeUrl: stringArg(args, 'runtime-url'),
496
- runtimeOrigin: stringArg(args, 'runtime-origin'),
497
- includeExec: booleanArg(args, 'include-exec') ?? false,
498
- includeDownload: booleanArg(args, 'include-download') ?? false,
499
- includePreview: booleanArg(args, 'include-preview') ?? false,
500
- debug: booleanArg(args, 'debug') ?? false,
501
- insecureTls: booleanArg(args, 'insecure-tls') ?? ((booleanArg(args, 'local') ?? false) && !stringArg(args, 'ca-file')),
502
- caFile: stringArg(args, 'ca-file'),
503
- stepTimeoutMs: numberArg(args, 'step-timeout-ms') ?? 60_000,
504
- streamTimeoutMs: numberArg(args, 'stream-timeout-ms') ?? 20_000,
505
- maxStreamChars: numberArg(args, 'max-stream-chars') ?? 100_000,
506
- json: booleanArg(args, 'json') ?? false,
507
- jsonFile: stringArg(args, 'json-file'),
508
- };
509
- }
510
-
511
- async function createNodeTransport(options: SmokeOptions): Promise<{
512
- fetch?: typeof globalThis.fetch;
513
- webSocket?: typeof NodeWebSocket;
514
- }> {
515
- if (!options.insecureTls && !options.caFile) {
516
- return {};
517
- }
518
-
519
- if (!options.apiUrl.startsWith('https://')) {
520
- return {};
521
- }
522
-
523
- const ca = options.caFile ? await fs.readFile(options.caFile, 'utf8') : undefined;
524
- const agentOptions = {
525
- ca,
526
- rejectUnauthorized: !options.insecureTls,
527
- };
528
- const httpsAgent = new https.Agent(agentOptions);
529
-
530
- class ConfiguredWebSocket extends NodeWebSocket {
531
- public constructor(address: string | URL, protocols?: string | string[]) {
532
- super(address, protocols, agentOptions);
533
- }
534
- }
535
-
536
- const fetch: typeof globalThis.fetch = async (input, init) => {
537
- const request = input instanceof Request ? input : new Request(input, init);
538
- const headers: Record<string, string> = {};
539
- request.headers.forEach((value, key) => {
540
- headers[key] = value;
541
- });
542
- const body =
543
- request.method === 'GET' || request.method === 'HEAD'
544
- ? undefined
545
- : Buffer.from(await request.arrayBuffer());
546
-
547
- const response = await axios.request<ArrayBuffer>({
548
- url: request.url,
549
- method: request.method as any,
550
- headers,
551
- data: body,
552
- responseType: 'arraybuffer',
553
- validateStatus: () => true,
554
- httpsAgent,
555
- });
556
-
557
- const responseHeaders = new Headers();
558
- for (const [key, value] of Object.entries(response.headers)) {
559
- if (Array.isArray(value)) {
560
- for (const item of value) {
561
- responseHeaders.append(key, String(item));
562
- }
563
- } else if (value !== undefined) {
564
- responseHeaders.set(key, String(value));
565
- }
566
- }
567
-
568
- return new Response(response.data, {
569
- status: response.status,
570
- statusText: response.statusText,
571
- headers: responseHeaders,
572
- });
573
- };
574
-
575
- return { fetch, webSocket: ConfiguredWebSocket as typeof NodeWebSocket };
576
- }
577
-
578
- function parseArgs(argv: string[]): ParsedArgs {
579
- const values = new Map<string, string | boolean>();
580
-
581
- for (let index = 0; index < argv.length; index += 1) {
582
- const current = argv[index];
583
- if (!current.startsWith('--')) {
584
- continue;
585
- }
586
-
587
- if (current.startsWith('--no-')) {
588
- values.set(current.slice(5), false);
589
- continue;
590
- }
591
-
592
- const assignment = current.match(/^--([^=]+)=(.*)$/);
593
- if (assignment) {
594
- values.set(assignment[1], assignment[2]);
595
- continue;
596
- }
597
-
598
- const key = current.slice(2);
599
- const next = argv[index + 1];
600
- if (next && !next.startsWith('--')) {
601
- values.set(key, next);
602
- index += 1;
603
- continue;
604
- }
605
-
606
- values.set(key, true);
607
- }
608
-
609
- return {
610
- get(key: string) {
611
- return values.get(key);
612
- },
613
- };
614
- }
615
-
616
- function stringArg(args: ParsedArgs, key: string): string | undefined {
617
- const value = args.get(key);
618
- return typeof value === 'string' && value !== '' ? value : undefined;
619
- }
620
-
621
- function numberArg(args: ParsedArgs, key: string): number | undefined {
622
- const value = stringArg(args, key);
623
- if (value === undefined) {
624
- return undefined;
625
- }
626
-
627
- const number = Number(value);
628
- if (!Number.isFinite(number)) {
629
- throw new Error(`Invalid --${key}: ${value}`);
630
- }
631
-
632
- return number;
633
- }
634
-
635
- function booleanArg(args: ParsedArgs, key: string): boolean | undefined {
636
- const value = args.get(key);
637
- if (typeof value === 'boolean') {
638
- return value;
639
- }
640
- if (value === undefined) {
641
- return undefined;
642
- }
643
- if (value === 'true' || value === '1') {
644
- return true;
645
- }
646
- if (value === 'false' || value === '0') {
647
- return false;
648
- }
649
- throw new Error(`Invalid --${key}: ${value}`);
650
- }
651
-
652
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
653
- return new Promise<T>((resolve, reject) => {
654
- const timer = setTimeout(() => reject(new Error(label)), timeoutMs);
655
- promise.then(
656
- (value) => {
657
- clearTimeout(timer);
658
- resolve(value);
659
- },
660
- (error) => {
661
- clearTimeout(timer);
662
- reject(error);
663
- }
664
- );
665
- });
666
- }
667
-
668
- function sha256(value: Uint8Array): string {
669
- return createHash('sha256').update(value).digest('hex');
670
- }
671
-
672
- function assertValidUrl(value: unknown, label: string): string {
673
- if (typeof value !== 'string' || value.length === 0) {
674
- throw new Error(`${label} must be a non-empty string, got ${JSON.stringify(value)}`);
675
- }
676
-
677
- try {
678
- new URL(value);
679
- } catch {
680
- throw new Error(`${label} must be a valid URL, got ${JSON.stringify(value)}`);
681
- }
682
-
683
- return value;
684
- }
685
-
686
- function assertPortInfoList(value: unknown, label: string): PortInfo[] {
687
- if (!Array.isArray(value)) {
688
- throw new Error(`${label} must be an array, got ${JSON.stringify(value)}`);
689
- }
690
-
691
- value.forEach((rawPort, index) => {
692
- if (!rawPort || typeof rawPort !== 'object') {
693
- throw new Error(`${label}[${index}] must be an object, got ${JSON.stringify(rawPort)}`);
694
- }
695
-
696
- const port = rawPort as Partial<PortInfo>;
697
- if (!Number.isInteger(port.port) || (port.port ?? 0) <= 0) {
698
- throw new Error(`${label}[${index}].port must be a positive integer, got ${JSON.stringify(port.port)}`);
699
- }
700
- assertValidUrl(port.url, `${label}[${index}].url`);
701
- if (typeof port.subdomain !== 'string' || port.subdomain.length === 0) {
702
- throw new Error(`${label}[${index}].subdomain must be a non-empty string, got ${JSON.stringify(port.subdomain)}`);
703
- }
704
- if (typeof port.default !== 'boolean') {
705
- throw new Error(`${label}[${index}].default must be boolean, got ${JSON.stringify(port.default)}`);
706
- }
707
- });
708
-
709
- return value as PortInfo[];
710
- }
711
-
712
- function describePorts(ports: readonly PortInfo[]): string {
713
- const defaultPorts = ports.filter((port) => port.default).map((port) => port.port);
714
- return `ports=${ports.length} defaultPorts=${defaultPorts.length > 0 ? defaultPorts.join(',') : 'none'}`;
715
- }
716
-
717
- function redactUrl(value: string): string {
718
- try {
719
- const url = new URL(value);
720
- if (url.search) {
721
- url.search = '?redacted=1';
722
- }
723
- return url.toString();
724
- } catch {
725
- return '<invalid-url>';
726
- }
727
- }
728
-
729
- function describeValue(value: unknown): string {
730
- if (value && typeof value === 'object' && 'detail' in value && typeof value.detail === 'string') {
731
- return trimDetail(value.detail);
732
- }
733
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
734
- return trimDetail(String(value));
735
- }
736
- if (value === null || value === undefined) {
737
- return String(value);
738
- }
739
- return trimDetail(JSON.stringify(value));
740
- }
741
-
742
- function trimDetail(value: string, maxLength = 180): string {
743
- const normalized = value.replace(/\s+/g, ' ').trim();
744
- if (normalized.length <= maxLength) {
745
- return normalized;
746
- }
747
- return `${normalized.slice(0, maxLength - 3)}...`;
748
- }
749
-
750
- function formatError(error: unknown): string {
751
- if (error instanceof Error) {
752
- return error.message;
753
- }
754
- return String(error);
755
- }
756
-
757
- function formatDuration(durationMs: number): string {
758
- if (durationMs < 1000) {
759
- return `${durationMs.toFixed(0)}ms`;
760
- }
761
- return `${(durationMs / 1000).toFixed(2)}s`;
762
- }
763
-
764
- function printSummary(artifact: SmokeArtifact): void {
765
- const passed = artifact.steps.filter((step) => step.status === 'passed').length;
766
- const failed = artifact.steps.filter((step) => step.status === 'failed').length;
767
- const skipped = artifact.steps.filter((step) => step.status === 'skipped').length;
768
-
769
- console.log('\n=== Notebook readonly smoke summary ===');
770
- console.log(`Status: ${artifact.status}`);
771
- console.log(`Notebook: ${artifact.options.notebookId}`);
772
- console.log(`Runtime URL: ${artifact.notebook?.runtimeUrl ?? 'unresolved'}`);
773
- console.log(`Duration: ${formatDuration(artifact.durationMs)}`);
774
- console.log(`Steps: passed=${passed} failed=${failed} skipped=${skipped}`);
775
-
776
- for (const step of artifact.steps) {
777
- const detail = step.error ?? step.detail ?? '';
778
- console.log(` ${step.status.padEnd(7)} ${step.name.padEnd(28)} ${formatDuration(step.durationMs).padStart(8)} ${detail}`);
779
- }
780
- }
781
-
782
- function printHelp(): void {
783
- console.log(`Usage:
784
- PHPSANDBOX_TOKEN=... npx tsx examples/notebook-readonly-smoke.ts [options]
785
-
786
- Options:
787
- --notebook-id=<id> Existing notebook id to open. Defaults to gypjc.
788
- --api-url=<url> PHPSandbox API URL. Defaults to ${DEFAULT_API_URL}.
789
- --local Use ${DEFAULT_LOCAL_API_URL} and default to insecure TLS.
790
- --insecure-tls Disable TLS verification for local HTTPS/WSS.
791
- --ca-file=<path> Trust a custom CA for local HTTPS/WSS.
792
- --runtime-url=<url> Replace the notebook runtimeUrl with a full WebSocket URL.
793
- --runtime-origin=<origin> Replace only the runtimeUrl origin, preserving path/query auth.
794
- --include-exec Also run shell, terminal, and REPL read-safe execution checks.
795
- --include-download Also run a read-only file.download() check for a known file.
796
- --include-preview Fetch the initialized preview URL.
797
- --json Print the full JSON artifact.
798
- --json-file=<path> Write the JSON artifact to a file.
799
- --step-timeout-ms=<ms> Per-step timeout. Defaults to 60000.
800
- `);
801
- }
802
-
803
- void main().catch((error) => {
804
- console.error(formatError(error));
805
- process.exitCode = 1;
806
- });