@phpsandbox/sdk 0.0.51 → 0.0.52
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/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/auth.d.ts +0 -1
- package/dist/auth.js +0 -1
- package/dist/beacon/index.d.ts +4 -5
- package/dist/beacon/index.js +11 -12
- package/dist/beacon/navigator.d.ts +1 -2
- package/dist/beacon/navigator.js +0 -1
- package/dist/beacon/types.d.ts +12 -13
- package/dist/beacon/types.js +0 -1
- package/dist/browser/phpsandbox-sdk.esm.js +282 -479
- package/dist/browser/phpsandbox-sdk.esm.min.js +2 -3
- package/dist/browser/phpsandbox-sdk.iife.js +277 -479
- package/dist/browser/phpsandbox-sdk.iife.min.js +2 -3
- package/dist/composer.d.ts +2 -3
- package/dist/composer.js +0 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/container.d.ts +0 -1
- package/dist/container.js +0 -1
- package/dist/errors/index.d.ts +0 -1
- package/dist/errors/index.js +0 -1
- package/dist/events/index.d.ts +15 -15
- package/dist/events/index.js +16 -9
- package/dist/filesystem.d.ts +6 -7
- package/dist/filesystem.js +36 -14
- package/dist/git.d.ts +0 -1
- package/dist/git.js +0 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +38 -25
- package/dist/laravel.d.ts +1 -2
- package/dist/laravel.js +1 -2
- package/dist/log.d.ts +3 -4
- package/dist/log.js +1 -2
- package/dist/lsp.d.ts +10 -11
- package/dist/lsp.js +9 -10
- package/dist/repl.d.ts +3 -4
- package/dist/repl.js +2 -3
- package/dist/services.d.ts +0 -1
- package/dist/services.js +0 -1
- package/dist/shell.d.ts +1 -2
- package/dist/shell.js +1 -2
- package/dist/socket/index.d.ts +23 -129
- package/dist/socket/index.js +60 -350
- package/dist/terminal.d.ts +2 -3
- package/dist/terminal.js +45 -26
- package/dist/types.d.ts +0 -1
- package/dist/types.js +0 -1
- package/dist/utils/disposable.d.ts +0 -1
- package/dist/utils/disposable.js +0 -1
- package/dist/utils/promise.d.ts +0 -1
- package/dist/utils/promise.js +0 -1
- package/dist/utils/retry.d.ts +0 -1
- package/dist/utils/retry.js +0 -1
- package/dist/utils/websocket.d.ts +0 -1
- package/dist/utils/websocket.js +0 -1
- package/docs/api-summary.md +136 -0
- package/docs/cdn-usage.md +80 -0
- package/docs/getting-started.md +152 -0
- package/examples/README.md +206 -0
- package/examples/basic.ts +24 -0
- package/examples/get.ts +21 -0
- package/examples/git-merge-smoke.ts +415 -0
- package/examples/laravel-load-test.ts +2829 -0
- package/examples/notebook-readonly-smoke.ts +823 -0
- package/package.json +13 -7
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/beacon/index.d.ts.map +0 -1
- package/dist/beacon/index.js.map +0 -1
- package/dist/beacon/navigator.d.ts.map +0 -1
- package/dist/beacon/navigator.js.map +0 -1
- package/dist/beacon/types.d.ts.map +0 -1
- package/dist/beacon/types.js.map +0 -1
- package/dist/browser/phpsandbox-sdk.esm.js.map +0 -7
- package/dist/browser/phpsandbox-sdk.esm.min.js.map +0 -7
- package/dist/browser/phpsandbox-sdk.iife.js.map +0 -7
- package/dist/browser/phpsandbox-sdk.iife.min.js.map +0 -7
- package/dist/composer.d.ts.map +0 -1
- package/dist/composer.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/container.d.ts.map +0 -1
- package/dist/container.js.map +0 -1
- package/dist/errors/index.d.ts.map +0 -1
- package/dist/errors/index.js.map +0 -1
- package/dist/events/index.d.ts.map +0 -1
- package/dist/events/index.js.map +0 -1
- package/dist/filesystem.d.ts.map +0 -1
- package/dist/filesystem.js.map +0 -1
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/laravel.d.ts.map +0 -1
- package/dist/laravel.js.map +0 -1
- package/dist/log.d.ts.map +0 -1
- package/dist/log.js.map +0 -1
- package/dist/lsp.d.ts.map +0 -1
- package/dist/lsp.js.map +0 -1
- package/dist/repl.d.ts.map +0 -1
- package/dist/repl.js.map +0 -1
- package/dist/services.d.ts.map +0 -1
- package/dist/services.js.map +0 -1
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js.map +0 -1
- package/dist/socket/index.d.ts.map +0 -1
- package/dist/socket/index.js.map +0 -1
- package/dist/terminal.d.ts.map +0 -1
- package/dist/terminal.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/utils/disposable.d.ts.map +0 -1
- package/dist/utils/disposable.js.map +0 -1
- package/dist/utils/promise.d.ts.map +0 -1
- package/dist/utils/promise.js.map +0 -1
- package/dist/utils/retry.d.ts.map +0 -1
- package/dist/utils/retry.js.map +0 -1
- package/dist/utils/websocket.d.ts.map +0 -1
- package/dist/utils/websocket.js.map +0 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
## Examples
|
|
2
|
+
|
|
3
|
+
### Running Basic Example
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/basic.ts
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
### Running The Laravel Load Test
|
|
10
|
+
|
|
11
|
+
When stdout is a TTY, the load test renders a live dashboard with current stage, active scenarios, and rolling results. When output is redirected or piped, it falls back to plain logs.
|
|
12
|
+
|
|
13
|
+
Baseline one sandbox:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts --levels=1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Init-only baseline one sandbox:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts --levels=1 --mode=init
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Creation/provisioning parity baseline for a fresh Laravel notebook:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts \
|
|
29
|
+
--local \
|
|
30
|
+
--levels=1 \
|
|
31
|
+
--mode=init \
|
|
32
|
+
--require-init-messages="Preparing your workspace...|Setting up your project...|Opening your workspace..." \
|
|
33
|
+
--expect-provisioned=true \
|
|
34
|
+
--expect-provisioning-action=template \
|
|
35
|
+
--json-file=/tmp/phpsandbox-create-init.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Ramp concurrency over multiple levels:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts --levels=1,2,4,8 --waves=3
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or generate levels from a max concurrency:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts --max-concurrency=10 --step=2 --waves=2
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Local API with a self-signed certificate:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts \
|
|
54
|
+
--local \
|
|
55
|
+
--levels=1
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Local API with a trusted CA file:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/laravel-load-test.ts \
|
|
62
|
+
--api-url=https://api.phpsandbox.test/v1 \
|
|
63
|
+
--ca-file=/path/to/rootCA.pem \
|
|
64
|
+
--levels=1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Useful flags:
|
|
68
|
+
|
|
69
|
+
- `--no-cleanup` keeps every notebook after the run.
|
|
70
|
+
- `--keep-failed` keeps failed notebooks for inspection. By default, failures are deleted too.
|
|
71
|
+
- `--okra-origin=ws://127.0.0.1:9511` preserves the SDK-issued websocket path/auth but routes okra traffic to another host.
|
|
72
|
+
- `--include-container-restart` adds a mutable `container.start()` restart check on the fresh notebook.
|
|
73
|
+
- `--include-download` adds `file.download()` to the baseline.
|
|
74
|
+
- `--include-preview` fetches the initialized preview URL in init mode, or the route written by the scenario in full mode.
|
|
75
|
+
- `--include-lsp` adds an LSP initialize handshake to the baseline.
|
|
76
|
+
- `--require-init-messages="Preparing...|Setting up...|Opening..."` asserts frontend-visible init messages were received in order.
|
|
77
|
+
- `--expect-provisioned=true` asserts the `notebook.ready()` initialization result reports provisioning work.
|
|
78
|
+
- `--expect-provisioning-action=template` asserts the initialization result action.
|
|
79
|
+
- `--auth-payload=...` exercises `notebook.auth.login()` / `logout()`.
|
|
80
|
+
- `--mode=init` limits the scenario to notebook create, ready, and delete so you can isolate init capacity.
|
|
81
|
+
- `--init-only` is shorthand for `--mode=init`.
|
|
82
|
+
- `--stop-on-failure-level` stops after the first level that has at least one failing scenario.
|
|
83
|
+
- `--local` switches the default API URL to `https://api.phpsandbox.test/v1` and defaults TLS to insecure unless you also pass `--ca-file` or `--no-insecure-tls`.
|
|
84
|
+
- `--json` prints a machine-readable summary after the text report.
|
|
85
|
+
- `--json-file=/path/to/run.json` writes a machine-readable artifact for later analysis.
|
|
86
|
+
- `--insecure-tls` disables certificate verification for local HTTPS/WSS testing.
|
|
87
|
+
- `--ca-file=/path/to/rootCA.pem` trusts a custom CA for local HTTPS/WSS testing.
|
|
88
|
+
|
|
89
|
+
Default baseline coverage:
|
|
90
|
+
|
|
91
|
+
- notebook lifecycle: create, ready, ping, delete
|
|
92
|
+
- secrets
|
|
93
|
+
- container state and telemetry; optional restart with `--include-container-restart`
|
|
94
|
+
- filesystem watch/write/read/search/find/tree/tail
|
|
95
|
+
- optional preview fetch with `--include-preview`
|
|
96
|
+
- shell exec and stream
|
|
97
|
+
- terminal spawn streaming
|
|
98
|
+
- services with Redis logs
|
|
99
|
+
- composer metadata and autoload dump
|
|
100
|
+
- git checkpoint/log
|
|
101
|
+
- REPL eval
|
|
102
|
+
|
|
103
|
+
Intentionally excluded by default:
|
|
104
|
+
|
|
105
|
+
- `beacon`, because it needs a browser iframe runtime
|
|
106
|
+
- `auth`, because it needs a real connection payload
|
|
107
|
+
- `laravel.maintenance.*`, because it is not part of the current okra websocket action set
|
|
108
|
+
- `lsp`, because it is useful but more brittle than the rest of the headless baseline
|
|
109
|
+
|
|
110
|
+
### Running A Read-Only Existing Notebook Smoke Test
|
|
111
|
+
|
|
112
|
+
Use this when you want to compare an existing notebook, such as `gypjc`, without writing to it:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/notebook-readonly-smoke.ts \
|
|
116
|
+
--notebook-id=gypjc \
|
|
117
|
+
--json-file=/tmp/gypjc-smoke.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Local API with a self-signed certificate:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/notebook-readonly-smoke.ts \
|
|
124
|
+
--local \
|
|
125
|
+
--notebook-id=gypjc
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
To preserve the SDK-issued notebook auth/path but route the websocket to a different okra host:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/notebook-readonly-smoke.ts \
|
|
132
|
+
--notebook-id=gypjc \
|
|
133
|
+
--okra-origin=ws://127.0.0.1:9501
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Default smoke coverage:
|
|
137
|
+
|
|
138
|
+
- notebook get, ready, and ping
|
|
139
|
+
- container state and opened ports
|
|
140
|
+
- filesystem directory/tree/find/read/search
|
|
141
|
+
- read-only git status/log, service list, and composer packages where supported
|
|
142
|
+
|
|
143
|
+
Optional flags:
|
|
144
|
+
|
|
145
|
+
- `--include-exec` adds shell, terminal spawn, terminal stdin/stdout, terminal OSC, and REPL execution checks.
|
|
146
|
+
- `--include-download` adds a read-only `file.download()` check for a known file.
|
|
147
|
+
- `--include-preview` fetches the initialized preview URL.
|
|
148
|
+
|
|
149
|
+
### Running A Git Merge Smoke Test
|
|
150
|
+
|
|
151
|
+
Use this to fork a base notebook, edit the fork, checkpoint the fork, and preview the diff from the base notebook:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/git-merge-smoke.ts \
|
|
155
|
+
--local \
|
|
156
|
+
--notebook-id=10ywr \
|
|
157
|
+
--allow-checkpoint
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Pass `--apply` only when you want the merge to mutate the base notebook disk:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
PHPSANDBOX_TOKEN=your-token npx tsx examples/git-merge-smoke.ts \
|
|
164
|
+
--local \
|
|
165
|
+
--notebook-id=10ywr \
|
|
166
|
+
--allow-checkpoint \
|
|
167
|
+
--apply
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
By default the created fork is kept for inspection. Pass `--delete-fork` when you want the script to delete it after the run.
|
|
171
|
+
|
|
172
|
+
### Okra Operator Loop
|
|
173
|
+
|
|
174
|
+
For repeatable prod investigations, use the operator CLI:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npx tsx tools/okra-loop.ts run --label=init-64-96 -- --levels=64,80,96 --waves=2 --mode=init
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
If you omit the load-test level flags entirely, the operator loop defaults to a conservative staircase:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npx tsx tools/okra-loop.ts run --label=quick-probe
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
That default probe uses `--levels=1,2,4,8,12,16,24,32,48,64,80,96` and enables `--stop-on-failure-level` so you get the first failing boundary quickly.
|
|
187
|
+
|
|
188
|
+
This will:
|
|
189
|
+
|
|
190
|
+
- run the Laravel load test and save `load-test.json`, stdout, and stderr into `artifacts/okra-loop/<timestamp>-<label>/`
|
|
191
|
+
- capture before/after snapshots from `okra-main`, `okra-helfer`, and `okra-helfer-prime`
|
|
192
|
+
- sample remote load/memory/supervisor state during the run
|
|
193
|
+
- write `analysis.json` plus a `report.md` with hotspot heuristics and next actions
|
|
194
|
+
|
|
195
|
+
Useful operator flags:
|
|
196
|
+
|
|
197
|
+
- `--hosts=none` disables SSH collection, which is useful for local API runs.
|
|
198
|
+
- `--deploy-hosts=workers` deploys the local okra checkout to `okra-helfer` and `okra-helfer-prime` before the run.
|
|
199
|
+
- `--artifact-root=...` changes where run artifacts are stored.
|
|
200
|
+
- `--sample-interval-ms=15000` adjusts the SSH sampling interval.
|
|
201
|
+
|
|
202
|
+
Deploy only:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npx tsx tools/okra-loop.ts deploy --deploy-hosts=workers
|
|
206
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PHPSandbox } from '../src/index.js';
|
|
2
|
+
|
|
3
|
+
const token = process.env.PHPSANDBOX_TOKEN;
|
|
4
|
+
if (!token) {
|
|
5
|
+
throw new Error('Missing PHPSANDBOX_TOKEN');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const psb = new PHPSandbox(token);
|
|
9
|
+
const notebook = await psb.notebook.create('laravel');
|
|
10
|
+
|
|
11
|
+
console.log('====== Ping the notebook ======');
|
|
12
|
+
console.log('Response:', await notebook.ping());
|
|
13
|
+
console.log('====== Notebook is ready ======');
|
|
14
|
+
|
|
15
|
+
console.log('\n');
|
|
16
|
+
|
|
17
|
+
console.log('====== Read routes/web.php in the notebook ======');
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
const content = await notebook.file.readFile('routes/web.php');
|
|
20
|
+
console.log(decoder.decode(content));
|
|
21
|
+
|
|
22
|
+
// Always close the notebook when done to clean up the connection.
|
|
23
|
+
notebook.dispose();
|
|
24
|
+
await notebook.delete();
|
package/examples/get.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Client } from '../src/index.js';
|
|
2
|
+
|
|
3
|
+
const token = process.env.PHPSANDBOX_TOKEN;
|
|
4
|
+
if (!token) {
|
|
5
|
+
throw new Error('Missing PHPSANDBOX_TOKEN');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const notebookId = process.env.PHPSANDBOX_NOTEBOOK_ID;
|
|
9
|
+
if (!notebookId) {
|
|
10
|
+
throw new Error('Missing PHPSANDBOX_NOTEBOOK_ID');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const apiUrl = process.env.PHPSANDBOX_API_URL ?? 'https://api.phpsandbox.io/v1';
|
|
14
|
+
const client = new Client(token, apiUrl);
|
|
15
|
+
const notebook = await client.notebook.get(notebookId);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
console.log(await notebook.mail.status());
|
|
19
|
+
} finally {
|
|
20
|
+
notebook.dispose();
|
|
21
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import NodeWebSocket from 'ws';
|
|
5
|
+
import { PHPSandbox, type GitDiffPreview, type GitMergeResult, type NotebookInstance } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = 'https://api.phpsandbox.io/v1';
|
|
8
|
+
const DEFAULT_LOCAL_API_URL = 'https://api.phpsandbox.test/v1';
|
|
9
|
+
|
|
10
|
+
interface ParsedArgs {
|
|
11
|
+
get(key: string): string | boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Options {
|
|
15
|
+
token: string;
|
|
16
|
+
notebookId: string;
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
ref: string;
|
|
19
|
+
author: string;
|
|
20
|
+
message: string;
|
|
21
|
+
editPath: string;
|
|
22
|
+
editContent?: string;
|
|
23
|
+
local: boolean;
|
|
24
|
+
apply: boolean;
|
|
25
|
+
deleteFork: boolean;
|
|
26
|
+
allowCheckpoint: boolean;
|
|
27
|
+
insecureTls: boolean;
|
|
28
|
+
caFile?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main(): Promise<void> {
|
|
32
|
+
const options = parseOptions(process.argv.slice(2));
|
|
33
|
+
const transport = await createNodeTransport(options);
|
|
34
|
+
const client = new PHPSandbox(options.token, options.apiUrl, {
|
|
35
|
+
startClosed: true,
|
|
36
|
+
...transport,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let base: NotebookInstance | null = null;
|
|
40
|
+
let fork: NotebookInstance | null = null;
|
|
41
|
+
let shouldDeleteFork = false;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
base = await client.notebook.open(options.notebookId);
|
|
45
|
+
console.log(`Base notebook: ${base.data.id}`);
|
|
46
|
+
|
|
47
|
+
await base.ready();
|
|
48
|
+
await ensureBaseCheckpoint(base, options);
|
|
49
|
+
|
|
50
|
+
fork = await base.fork();
|
|
51
|
+
shouldDeleteFork = options.deleteFork;
|
|
52
|
+
console.log(`Fork notebook: ${fork.data.id}`);
|
|
53
|
+
|
|
54
|
+
await fork.ready();
|
|
55
|
+
const editContent = buildEditContent(base.data.id, fork.data.id, options);
|
|
56
|
+
const written = await fork.file.write(options.editPath, editContent);
|
|
57
|
+
if (!written) {
|
|
58
|
+
throw new Error(`fs.write returned false for ${options.editPath}.`);
|
|
59
|
+
}
|
|
60
|
+
console.log(`Edited fork file: ${options.editPath}`);
|
|
61
|
+
|
|
62
|
+
const forkCheckpoint = await fork.git.checkpoint(options.author, `Smoke edit in fork ${fork.data.id}`, options.ref, false);
|
|
63
|
+
console.log(`Fork checkpoint: ${forkCheckpoint.ref}`);
|
|
64
|
+
|
|
65
|
+
const forkCredentials = await fork.git.credentials();
|
|
66
|
+
console.log(`Diff source: ${redactUrl(forkCredentials.url)}`);
|
|
67
|
+
|
|
68
|
+
const preview = await retry(
|
|
69
|
+
() =>
|
|
70
|
+
base!.git.diff({
|
|
71
|
+
url: forkCredentials.url,
|
|
72
|
+
token: forkCredentials.token,
|
|
73
|
+
ref: options.ref,
|
|
74
|
+
}),
|
|
75
|
+
5,
|
|
76
|
+
1_000
|
|
77
|
+
);
|
|
78
|
+
printPreview(preview);
|
|
79
|
+
|
|
80
|
+
if (!options.apply) {
|
|
81
|
+
console.log('Merge skipped. Pass --apply to mutate the base notebook.');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const merge = await base.git.merge({
|
|
86
|
+
url: forkCredentials.url,
|
|
87
|
+
token: forkCredentials.token,
|
|
88
|
+
ref: options.ref,
|
|
89
|
+
expectedBaseRef: preview.baseRef,
|
|
90
|
+
author: options.author,
|
|
91
|
+
message: options.message,
|
|
92
|
+
});
|
|
93
|
+
printMerge(merge);
|
|
94
|
+
|
|
95
|
+
if (merge.status === 'conflict') {
|
|
96
|
+
shouldDeleteFork = false;
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
if (shouldDeleteFork && fork) {
|
|
100
|
+
try {
|
|
101
|
+
await fork.delete();
|
|
102
|
+
console.log(`Deleted fork notebook: ${fork.data.id}`);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`Could not delete fork notebook ${fork.data.id}: ${formatError(error)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fork?.dispose();
|
|
109
|
+
base?.dispose();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function ensureBaseCheckpoint(base: NotebookInstance, options: Options): Promise<void> {
|
|
114
|
+
const status = await base.git.status();
|
|
115
|
+
console.log(`Base Git: initialized=${status.initialized} clean=${status.clean} branch=${status.branch || '-'} ref=${status.ref || '-'}`);
|
|
116
|
+
|
|
117
|
+
if (status.initialized && status.clean) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!options.allowCheckpoint) {
|
|
122
|
+
const reason = status.initialized
|
|
123
|
+
? 'Base notebook has uncommitted changes.'
|
|
124
|
+
: 'Base notebook Git repository is not initialized.';
|
|
125
|
+
throw new Error(`${reason} Re-run with --allow-checkpoint to create a base checkpoint before forking.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const checkpoint = await base.git.checkpoint(
|
|
129
|
+
options.author,
|
|
130
|
+
status.initialized ? 'Base checkpoint before fork merge smoke' : 'Initialize base Git history for fork merge smoke',
|
|
131
|
+
options.ref,
|
|
132
|
+
!status.initialized
|
|
133
|
+
);
|
|
134
|
+
console.log(`Base checkpoint: ${checkpoint.ref}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildEditContent(baseId: string, forkId: string, options: Options): string {
|
|
138
|
+
if (options.editContent !== undefined) {
|
|
139
|
+
return options.editContent;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return [
|
|
143
|
+
'PHPSandbox Git merge smoke',
|
|
144
|
+
`base=${baseId}`,
|
|
145
|
+
`fork=${forkId}`,
|
|
146
|
+
`createdAt=${new Date().toISOString()}`,
|
|
147
|
+
'',
|
|
148
|
+
].join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function printPreview(preview: GitDiffPreview): void {
|
|
152
|
+
console.log(`Diff: status=${preview.status} base=${preview.baseRef || '-'} target=${preview.targetRef || '-'}`);
|
|
153
|
+
if (preview.status === 'unrelated') {
|
|
154
|
+
console.log(preview.message ?? 'Base and source do not share Git history.');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(`Changed files: ${preview.files.length}`);
|
|
159
|
+
for (const file of preview.files.slice(0, 20)) {
|
|
160
|
+
const oldPath = file.oldPath ? `${file.oldPath} -> ` : '';
|
|
161
|
+
console.log(`- ${file.status} ${oldPath}${file.path}`);
|
|
162
|
+
}
|
|
163
|
+
if (preview.files.length > 20) {
|
|
164
|
+
console.log(`- ... ${preview.files.length - 20} more`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function printMerge(merge: GitMergeResult): void {
|
|
169
|
+
console.log(`Merge: status=${merge.status}`);
|
|
170
|
+
if ('message' in merge && merge.message) {
|
|
171
|
+
console.log(merge.message);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (merge.status === 'conflict') {
|
|
175
|
+
for (const conflict of merge.conflicts) {
|
|
176
|
+
console.log(`- ${conflict.status} ${conflict.file} (${conflict.type})`);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if ('ref' in merge && merge.ref) {
|
|
182
|
+
console.log(`Result ref: ${merge.ref}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseOptions(argv: string[]): Options {
|
|
187
|
+
const args = parseArgs(argv);
|
|
188
|
+
|
|
189
|
+
if (booleanArg(args, 'help') === true) {
|
|
190
|
+
printHelp();
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const token = stringArg(args, 'token') ?? process.env.PHPSANDBOX_TOKEN;
|
|
195
|
+
if (!token) {
|
|
196
|
+
throw new Error('Missing PHPSANDBOX_TOKEN. Pass it via the environment or --token.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const local = booleanArg(args, 'local') ?? false;
|
|
200
|
+
const notebookId = stringArg(args, 'notebook-id') ?? '10ywr';
|
|
201
|
+
const caFile = stringArg(args, 'ca-file');
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
token,
|
|
205
|
+
notebookId,
|
|
206
|
+
apiUrl: stringArg(args, 'api-url') ?? process.env.PHPSANDBOX_API_URL ?? (local ? DEFAULT_LOCAL_API_URL : DEFAULT_API_URL),
|
|
207
|
+
ref: stringArg(args, 'ref') ?? 'main',
|
|
208
|
+
author: stringArg(args, 'author') ?? 'PHPSandbox <hi@phpsandbox.io>',
|
|
209
|
+
message: stringArg(args, 'message') ?? `Merge fork into ${notebookId}`,
|
|
210
|
+
editPath: stringArg(args, 'edit-path') ?? 'phpsandbox-git-merge-smoke.txt',
|
|
211
|
+
editContent: stringArg(args, 'edit-content'),
|
|
212
|
+
local,
|
|
213
|
+
apply: booleanArg(args, 'apply') ?? false,
|
|
214
|
+
deleteFork: booleanArg(args, 'delete-fork') ?? false,
|
|
215
|
+
allowCheckpoint: booleanArg(args, 'allow-checkpoint') ?? false,
|
|
216
|
+
insecureTls: booleanArg(args, 'insecure-tls') ?? (local && !caFile),
|
|
217
|
+
caFile,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function createNodeTransport(options: Options): Promise<{
|
|
222
|
+
fetch?: typeof globalThis.fetch;
|
|
223
|
+
webSocket?: typeof NodeWebSocket;
|
|
224
|
+
}> {
|
|
225
|
+
if (!options.insecureTls && !options.caFile) {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!options.apiUrl.startsWith('https://')) {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ca = options.caFile ? await fs.readFile(options.caFile, 'utf8') : undefined;
|
|
234
|
+
const agentOptions = {
|
|
235
|
+
ca,
|
|
236
|
+
rejectUnauthorized: !options.insecureTls,
|
|
237
|
+
};
|
|
238
|
+
const httpsAgent = new https.Agent(agentOptions);
|
|
239
|
+
|
|
240
|
+
class ConfiguredWebSocket extends NodeWebSocket {
|
|
241
|
+
public constructor(address: string | URL, protocols?: string | string[]) {
|
|
242
|
+
super(address, protocols, agentOptions);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const fetch: typeof globalThis.fetch = async (input, init) => {
|
|
247
|
+
const request = input instanceof Request ? input : new Request(input, init);
|
|
248
|
+
const headers: Record<string, string> = {};
|
|
249
|
+
request.headers.forEach((value, key) => {
|
|
250
|
+
headers[key] = value;
|
|
251
|
+
});
|
|
252
|
+
const body =
|
|
253
|
+
request.method === 'GET' || request.method === 'HEAD'
|
|
254
|
+
? undefined
|
|
255
|
+
: Buffer.from(await request.arrayBuffer());
|
|
256
|
+
|
|
257
|
+
const response = await axios.request<ArrayBuffer>({
|
|
258
|
+
url: request.url,
|
|
259
|
+
method: request.method as any,
|
|
260
|
+
headers,
|
|
261
|
+
data: body,
|
|
262
|
+
responseType: 'arraybuffer',
|
|
263
|
+
validateStatus: () => true,
|
|
264
|
+
httpsAgent,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const responseHeaders = new Headers();
|
|
268
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
for (const item of value) {
|
|
271
|
+
responseHeaders.append(key, String(item));
|
|
272
|
+
}
|
|
273
|
+
} else if (value !== undefined) {
|
|
274
|
+
responseHeaders.set(key, String(value));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return new Response(response.data, {
|
|
279
|
+
status: response.status,
|
|
280
|
+
statusText: response.statusText,
|
|
281
|
+
headers: responseHeaders,
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return { fetch, webSocket: ConfiguredWebSocket as typeof NodeWebSocket };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
289
|
+
const values = new Map<string, string | boolean>();
|
|
290
|
+
|
|
291
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
292
|
+
const current = argv[index];
|
|
293
|
+
if (!current.startsWith('--')) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (current.startsWith('--no-')) {
|
|
298
|
+
values.set(current.slice(5), false);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const assignment = current.match(/^--([^=]+)=(.*)$/);
|
|
303
|
+
if (assignment) {
|
|
304
|
+
values.set(assignment[1], assignment[2]);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const key = current.slice(2);
|
|
309
|
+
const next = argv[index + 1];
|
|
310
|
+
if (next && !next.startsWith('--')) {
|
|
311
|
+
values.set(key, next);
|
|
312
|
+
index += 1;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
values.set(key, true);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
get(key: string) {
|
|
321
|
+
return values.get(key);
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function stringArg(args: ParsedArgs, key: string): string | undefined {
|
|
327
|
+
const value = args.get(key);
|
|
328
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function booleanArg(args: ParsedArgs, key: string): boolean | undefined {
|
|
332
|
+
const value = args.get(key);
|
|
333
|
+
if (typeof value === 'boolean') {
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
if (value === undefined) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
if (value === 'true' || value === '1') {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (value === 'false' || value === '0') {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`Invalid --${key}: ${value}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function retry<T>(callback: () => Promise<T>, attempts: number, delayMs: number): Promise<T> {
|
|
349
|
+
let lastError: unknown;
|
|
350
|
+
|
|
351
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
352
|
+
try {
|
|
353
|
+
return await callback();
|
|
354
|
+
} catch (error) {
|
|
355
|
+
lastError = error;
|
|
356
|
+
if (attempt === attempts) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
console.log(`Retrying after transient Git source lookup failure (${attempt}/${attempts}): ${formatError(error)}`);
|
|
361
|
+
await sleep(delayMs);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
throw lastError;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function sleep(delayMs: number): Promise<void> {
|
|
369
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatError(error: unknown): string {
|
|
373
|
+
return error instanceof Error ? error.message : String(error);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function redactUrl(value: string): string {
|
|
377
|
+
try {
|
|
378
|
+
const url = new URL(value);
|
|
379
|
+
if (url.username || url.password) {
|
|
380
|
+
url.username = 'redacted';
|
|
381
|
+
url.password = 'redacted';
|
|
382
|
+
}
|
|
383
|
+
if (url.search) {
|
|
384
|
+
url.search = '?redacted=1';
|
|
385
|
+
}
|
|
386
|
+
return url.toString();
|
|
387
|
+
} catch {
|
|
388
|
+
return '<invalid-url>';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function printHelp(): void {
|
|
393
|
+
console.log(`
|
|
394
|
+
Usage:
|
|
395
|
+
PHPSANDBOX_TOKEN=... npx tsx examples/git-merge-smoke.ts --local --notebook-id=10ywr
|
|
396
|
+
|
|
397
|
+
Options:
|
|
398
|
+
--notebook-id=<id> Base notebook to open. Defaults to 10ywr.
|
|
399
|
+
--ref=<ref> Source Git ref. Defaults to main.
|
|
400
|
+
--edit-path=<path> File to write in the fork. Defaults to phpsandbox-git-merge-smoke.txt.
|
|
401
|
+
--edit-content=<text> Exact content to write into edit-path.
|
|
402
|
+
--apply Apply merge into the base notebook after diffing.
|
|
403
|
+
--delete-fork Delete the created fork after the smoke run.
|
|
404
|
+
--allow-checkpoint Create a base checkpoint before forking if needed.
|
|
405
|
+
--local Use the local API host.
|
|
406
|
+
--api-url=<url> API URL. Defaults to ${DEFAULT_API_URL}, or ${DEFAULT_LOCAL_API_URL} with --local.
|
|
407
|
+
--insecure-tls Disable TLS verification for local HTTPS/WSS.
|
|
408
|
+
--ca-file=<path> Trust a custom CA for local HTTPS/WSS.
|
|
409
|
+
`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main().catch((error) => {
|
|
413
|
+
console.error(error instanceof Error ? error.message : error);
|
|
414
|
+
process.exitCode = 1;
|
|
415
|
+
});
|