@phpsandbox/sdk 0.0.56 → 0.0.58

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.
@@ -0,0 +1,42 @@
1
+ import type { Client, PaginatedApiResponse } from './index.js';
2
+ export interface ServerData {
3
+ id: string;
4
+ name: string;
5
+ host: string;
6
+ provider: string;
7
+ status: string;
8
+ token?: string;
9
+ sshPublicKey?: string;
10
+ installCommand?: string;
11
+ connectedAt: string | null;
12
+ createdAt: string | null;
13
+ updatedAt: string | null;
14
+ }
15
+ export interface ServerSshOptions {
16
+ user?: string;
17
+ port?: number;
18
+ }
19
+ export interface CreateServerInput {
20
+ name: string;
21
+ host: string;
22
+ ssh?: ServerSshOptions;
23
+ }
24
+ export declare class ServerApi {
25
+ private readonly client;
26
+ constructor(client: Client);
27
+ list(): Promise<PaginatedApiResponse<ServerData>>;
28
+ create(input: CreateServerInput): Promise<ServerInstance>;
29
+ get(id: string): Promise<ServerInstance>;
30
+ delete(id: string): Promise<void>;
31
+ }
32
+ export declare class ServerInstance {
33
+ readonly data: ServerData;
34
+ private readonly client;
35
+ constructor(data: ServerData, client: Client);
36
+ refresh(): Promise<ServerInstance>;
37
+ delete(): Promise<void>;
38
+ waitReady(options?: {
39
+ timeout?: number;
40
+ interval?: number;
41
+ }): Promise<ServerInstance>;
42
+ }
@@ -0,0 +1,48 @@
1
+ export class ServerApi {
2
+ constructor(client) {
3
+ this.client = client;
4
+ }
5
+ async list() {
6
+ const response = await this.client.get('/servers');
7
+ return response.data;
8
+ }
9
+ async create(input) {
10
+ const response = await this.client.post('/servers', input);
11
+ return new ServerInstance(response.data, this.client);
12
+ }
13
+ async get(id) {
14
+ const response = await this.client.get(`/servers/${id}`);
15
+ return new ServerInstance(response.data, this.client);
16
+ }
17
+ async delete(id) {
18
+ await this.client.delete(`/servers/${id}`);
19
+ }
20
+ }
21
+ export class ServerInstance {
22
+ constructor(data, client) {
23
+ this.data = data;
24
+ this.client = client;
25
+ }
26
+ refresh() {
27
+ return this.client.servers.get(this.data.id);
28
+ }
29
+ delete() {
30
+ return this.client.servers.delete(this.data.id);
31
+ }
32
+ async waitReady(options) {
33
+ const timeout = options?.timeout ?? 300000; // 5 minutes default
34
+ const interval = options?.interval ?? 3000; // poll every 3s
35
+ const deadline = Date.now() + timeout;
36
+ while (Date.now() < deadline) {
37
+ const server = await this.refresh();
38
+ if (server.data.status === 'connected') {
39
+ return server;
40
+ }
41
+ if (server.data.status === 'failed') {
42
+ throw new Error(`Server provisioning failed: ${server.data.id}`);
43
+ }
44
+ await new Promise(resolve => setTimeout(resolve, interval));
45
+ }
46
+ throw new Error(`Server did not become ready within ${timeout}ms`);
47
+ }
48
+ }
@@ -75,7 +75,7 @@ const client = new PHPSandbox(token, 'https://api.phpsandbox.io/v1', {
75
75
  ### Git (`notebook.git`)
76
76
 
77
77
  - `checkpoint(author, message, branch = 'main')`
78
- - `sync(url, author, ref?, token?, direction?, force?)`
78
+ - `sync({ provider, repository, author, branch?, credential, direction?, visibility?, setDefault? })`
79
79
  - `diff({ url?, token?, ref?, base?, target? })`
80
80
  - `merge({ url?, token?, ref?, target?, expectedBaseRef?, author?, message? })`
81
81
  - `conflicts()`
@@ -6,6 +6,34 @@
6
6
  PHPSANDBOX_TOKEN=your-token npx tsx examples/basic.ts
7
7
  ```
8
8
 
9
+ ### Running Deployment Examples
10
+
11
+ Cloudflare Containers target:
12
+
13
+ ```bash
14
+ PHPSANDBOX_TOKEN=your-token PHPSANDBOX_NOTEBOOK_ID=notebook-id \
15
+ npx tsx examples/deploy-cloudflare-container.ts
16
+ ```
17
+
18
+ SSH agent target:
19
+
20
+ ```bash
21
+ PHPSANDBOX_TOKEN=your-token PHPSANDBOX_NOTEBOOK_ID=notebook-id \
22
+ npx tsx examples/deploy-ssh-agent.ts
23
+ ```
24
+
25
+ Laravel Cloud target:
26
+
27
+ ```bash
28
+ PHPSANDBOX_TOKEN=your-token PHPSANDBOX_NOTEBOOK_ID=notebook-id \
29
+ PHPSANDBOX_LARAVEL_CLOUD_REPOSITORY=owner/repo \
30
+ PHPSANDBOX_GITHUB_TOKEN=github-token \
31
+ PHPSANDBOX_LARAVEL_CLOUD_API_KEY=lc-api-key \
32
+ npx tsx examples/deploy-laravel-cloud.ts
33
+ ```
34
+
35
+ Deployment examples accept `PHPSANDBOX_API_URL` and `PHPSANDBOX_DEPLOY_SLUG`. On the first run they create the notebook's deployment; later runs publish the existing deployment without changing slug or provider. The SSH example creates a server registration when `PHPSANDBOX_DEPLOY_SERVER_ID` is not set; on local `.test` APIs it also starts local Rook from `PHPSANDBOX_ROOK_DIR` or the sibling `rook` repo. The Laravel Cloud example syncs the notebook to `PHPSANDBOX_LARAVEL_CLOUD_REPOSITORY`, upserts `PHPSANDBOX_GITHUB_TOKEN` into `GITHUB_TOKEN`, and upserts `PHPSANDBOX_LARAVEL_CLOUD_API_KEY` into `LARAVEL_CLOUD_API_KEY` on first publish.
36
+
9
37
  ### Running The Laravel Load Test
10
38
 
11
39
  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.
@@ -0,0 +1,49 @@
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 slug = process.env.PHPSANDBOX_DEPLOY_SLUG ?? `sdk-cloudflare-${Date.now().toString(36)}`;
14
+ const apiUrl = process.env.PHPSANDBOX_API_URL;
15
+ const client = new Client(token, apiUrl);
16
+ const notebook = await client.notebook.get(notebookId);
17
+
18
+ try {
19
+ const deployment = await notebook.deployment();
20
+ if (deployment && deployment.data.targetProvider !== 'okraCloudflareContainer') {
21
+ throw new Error(`Notebook is already published to ${deployment.data.targetProvider}. Delete that deployment before changing providers.`);
22
+ }
23
+
24
+ const run = deployment
25
+ ? await deployment.publish()
26
+ : await notebook.publish({
27
+ slug,
28
+ targetProvider: 'okraCloudflareContainer',
29
+ options: {
30
+ size: 'small',
31
+ sleepAfter: '30m',
32
+ },
33
+ });
34
+
35
+ for await (const event of run.events()) {
36
+ if (event.type === 'phase') {
37
+ console.log('phase:', event.name);
38
+ } else if (event.type === 'log') {
39
+ process.stdout.write(event.content);
40
+ } else if (event.type === 'result') {
41
+ console.log('result:', event);
42
+ }
43
+ }
44
+
45
+ const current = await run.deployment();
46
+ console.log('deployment:', current.data.id, current.data.status, current.data.url);
47
+ } finally {
48
+ notebook.dispose();
49
+ }
@@ -0,0 +1,106 @@
1
+ import { Client, type LaravelCloudRegion } 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 apiKeySecret = 'LARAVEL_CLOUD_API_KEY';
14
+ const githubTokenSecret = 'GITHUB_TOKEN';
15
+ const slug = process.env.PHPSANDBOX_DEPLOY_SLUG ?? `sdk-laravel-cloud-${Date.now().toString(36)}`;
16
+ const region: LaravelCloudRegion = 'eu-central-1';
17
+ const apiUrl = process.env.PHPSANDBOX_API_URL ?? 'https://api.phpsandbox.io/v1';
18
+
19
+ if (new URL(apiUrl).hostname.endsWith('.test') && process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
20
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
21
+ }
22
+
23
+ const client = new Client(token, apiUrl);
24
+ const notebook = await client.notebook.get(notebookId);
25
+
26
+ try {
27
+ const deployment = await notebook.deployment();
28
+ if (deployment && deployment.data.targetProvider !== 'okraLaravelCloud') {
29
+ throw new Error(`Notebook is already published to ${deployment.data.targetProvider}. Delete that deployment before changing providers.`);
30
+ }
31
+
32
+ const run = deployment ? await deployment.publish() : await publishFirstLaravelCloudDeployment();
33
+
34
+ for await (const event of run.events()) {
35
+ if (event.type === 'phase') {
36
+ console.log('phase:', event.name);
37
+ } else if (event.type === 'log') {
38
+ process.stdout.write(`${event.content}\n`);
39
+ } else if (event.type === 'result') {
40
+ console.log('result:', event);
41
+ }
42
+ }
43
+
44
+ const current = await run.deployment();
45
+ console.log('deployment:', current.data.id, current.data.status, current.data.url);
46
+ } finally {
47
+ notebook.dispose();
48
+ }
49
+
50
+ async function publishFirstLaravelCloudDeployment() {
51
+ const repository = process.env.PHPSANDBOX_LARAVEL_CLOUD_REPOSITORY;
52
+ if (!repository) {
53
+ throw new Error('Missing PHPSANDBOX_LARAVEL_CLOUD_REPOSITORY');
54
+ }
55
+
56
+ const githubToken = process.env.PHPSANDBOX_GITHUB_TOKEN;
57
+ if (!githubToken) {
58
+ throw new Error('Missing PHPSANDBOX_GITHUB_TOKEN');
59
+ }
60
+
61
+ const gitAuthor = process.env.PHPSANDBOX_GIT_AUTHOR;
62
+ if (!gitAuthor) {
63
+ throw new Error('Missing PHPSANDBOX_GIT_AUTHOR');
64
+ }
65
+
66
+ const apiKey = process.env.PHPSANDBOX_LARAVEL_CLOUD_API_KEY;
67
+ if (!apiKey) {
68
+ throw new Error('Missing PHPSANDBOX_LARAVEL_CLOUD_API_KEY');
69
+ }
70
+
71
+ await notebook.secrets.set(githubTokenSecret, {
72
+ value: githubToken,
73
+ type: 'credential',
74
+ });
75
+
76
+ const source = await notebook.git.sync({
77
+ provider: 'github',
78
+ repository,
79
+ author: gitAuthor,
80
+ branch: 'main',
81
+ credential: {
82
+ secret: githubTokenSecret,
83
+ },
84
+ setDefault: true,
85
+ });
86
+
87
+ console.log('source:', source.repository, source.branch, source.lastCommitSha);
88
+
89
+ const secret = await notebook.secrets.set(apiKeySecret, {
90
+ value: apiKey,
91
+ type: 'credential',
92
+ });
93
+
94
+ console.log('secret:', secret.name);
95
+
96
+ return notebook.publish({
97
+ slug,
98
+ targetProvider: 'okraLaravelCloud',
99
+ options: {
100
+ apiKey: {
101
+ secret: apiKeySecret,
102
+ },
103
+ region,
104
+ },
105
+ });
106
+ }
@@ -0,0 +1,211 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { Client, type ServerInstance } from '../src/index.js';
7
+
8
+ const token = process.env.PHPSANDBOX_TOKEN;
9
+ if (!token) {
10
+ throw new Error('Missing PHPSANDBOX_TOKEN');
11
+ }
12
+
13
+ const notebookId = process.env.PHPSANDBOX_NOTEBOOK_ID;
14
+ if (!notebookId) {
15
+ throw new Error('Missing PHPSANDBOX_NOTEBOOK_ID');
16
+ }
17
+
18
+ const slug = process.env.PHPSANDBOX_DEPLOY_SLUG ?? `sdk-ssh-${Date.now().toString(36)}`;
19
+ const apiUrl = process.env.PHPSANDBOX_API_URL ?? 'https://api.phpsandbox.io/v1';
20
+ const isLocal = new URL(apiUrl).hostname.endsWith('.test');
21
+
22
+ if (isLocal && process.env.NODE_TLS_REJECT_UNAUTHORIZED === undefined) {
23
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
24
+ }
25
+
26
+ const client = new Client(token, apiUrl);
27
+ const notebook = await client.notebook.get(notebookId);
28
+ let agent: ChildProcessWithoutNullStreams | null = null;
29
+
30
+ try {
31
+ const deployment = await notebook.deployment();
32
+ if (deployment && deployment.data.targetProvider !== 'okraSshAgent') {
33
+ throw new Error(`Notebook is already published to ${deployment.data.targetProvider}. Delete that deployment before changing providers.`);
34
+ }
35
+
36
+ const run = deployment
37
+ ? await deployment.publish()
38
+ : await notebook.publish({
39
+ slug,
40
+ targetProvider: 'okraSshAgent',
41
+ options: { serverId: (await resolveServer()).data.id },
42
+ });
43
+
44
+ for await (const event of run.events()) {
45
+ if (event.type === 'phase') {
46
+ console.log('phase:', event.name);
47
+ } else if (event.type === 'log') {
48
+ process.stdout.write(event.content);
49
+ } else if (event.type === 'result') {
50
+ console.log('result:', event);
51
+ }
52
+ }
53
+
54
+ const current = await run.deployment();
55
+ console.log('deployment:', current.data.id, current.data.status, current.data.url);
56
+
57
+ if (current.data.url) {
58
+ const response = await fetch(current.data.url);
59
+ console.log('http:', response.status, response.statusText, await response.text());
60
+ if (!response.ok) {
61
+ throw new Error(`Deployment URL returned ${response.status} ${response.statusText}`);
62
+ }
63
+ }
64
+ } finally {
65
+ notebook.dispose();
66
+ if (agent) {
67
+ stopLocalAgent(agent);
68
+ }
69
+ }
70
+
71
+ async function resolveServer(): Promise<ServerInstance> {
72
+ const serverId = process.env.PHPSANDBOX_DEPLOY_SERVER_ID;
73
+ if (serverId) {
74
+ const server = await client.servers.get(serverId);
75
+ console.log('server:', server.data.id, server.data.status);
76
+ return server;
77
+ }
78
+
79
+ const server = await client.servers.create({
80
+ name: `SDK SSH ${slug}`,
81
+ host: process.env.PHPSANDBOX_DEPLOY_SERVER_HOST ?? 'localhost',
82
+ ssh: {
83
+ user: process.env.USER ?? 'local',
84
+ port: Number(process.env.PHPSANDBOX_DEPLOY_SERVER_PORT ?? 22),
85
+ },
86
+ });
87
+
88
+ console.log('server:', server.data.id, server.data.status);
89
+
90
+ if (!isLocal) {
91
+ throw new Error('Created server registration. Install the agent on the target server, then rerun with PHPSANDBOX_DEPLOY_SERVER_ID.');
92
+ }
93
+
94
+ if (!server.data.token) {
95
+ throw new Error('Created server registration without an agent token.');
96
+ }
97
+
98
+ agent = await startLocalAgent(server.data.id, server.data.token);
99
+ const connected = await server.waitReady({ timeout: 120_000, interval: 1_000 });
100
+ console.log('server:', connected.data.id, connected.data.status);
101
+
102
+ return connected;
103
+ }
104
+
105
+ async function startLocalAgent(serverId: string, serverToken: string): Promise<ChildProcessWithoutNullStreams> {
106
+ const dir = mkdtempSync(join(tmpdir(), 'phpsandbox-sdk-ssh-agent-'));
107
+ const configPath = join(dir, 'agent.yaml');
108
+ const stateDir = join(dir, 'state');
109
+ const controlPlane = process.env.PHPSANDBOX_AGENT_CONTROL_PLANE ?? 'wss://okra-edge.ciroue.test/_okra/agent/connect';
110
+
111
+ writeFileSync(configPath, [
112
+ `server_id: "${serverId}"`,
113
+ `token: "${serverToken}"`,
114
+ `control_plane: "${controlPlane}"`,
115
+ `state_dir: "${stateDir}"`,
116
+ '',
117
+ ].join('\n'));
118
+
119
+ const rookDir = process.env.PHPSANDBOX_ROOK_DIR
120
+ ?? fileURLToPath(new URL('../../rook/', import.meta.url));
121
+ if (!existsSync(join(rookDir, 'cmd/rook/main.go'))) {
122
+ throw new Error('Cannot find local Rook repo. Set PHPSANDBOX_ROOK_DIR to the rook directory.');
123
+ }
124
+
125
+ const child = spawn('go', ['run', './cmd/rook', '--config', configPath], {
126
+ cwd: rookDir,
127
+ detached: true,
128
+ env: {
129
+ ...process.env,
130
+ SSL_CERT_FILE: localCaCertPath(),
131
+ },
132
+ });
133
+
134
+ process.once('exit', () => stopLocalAgent(child));
135
+
136
+ await waitForAgent(child);
137
+ console.log('agent:', 'connected');
138
+
139
+ return child;
140
+ }
141
+
142
+ function stopLocalAgent(child: ChildProcessWithoutNullStreams): void {
143
+ if (child.exitCode !== null || child.signalCode !== null) {
144
+ return;
145
+ }
146
+
147
+ if (child.pid && child.pid > 0) {
148
+ try {
149
+ process.kill(-child.pid, 'SIGTERM');
150
+ return;
151
+ } catch {
152
+ // Fall back to killing the direct child when process group signaling is unavailable.
153
+ }
154
+ }
155
+
156
+ child.kill('SIGTERM');
157
+ }
158
+
159
+ function waitForAgent(child: ChildProcessWithoutNullStreams): Promise<void> {
160
+ return new Promise((resolve, reject) => {
161
+ let settled = false;
162
+ let output = '';
163
+ const timeout = setTimeout(() => {
164
+ finish(new Error(`Timed out waiting for Rook to connect. ${output.trim()}`));
165
+ }, 120_000);
166
+
167
+ const finish = (error?: Error) => {
168
+ if (settled) {
169
+ return;
170
+ }
171
+ settled = true;
172
+ clearTimeout(timeout);
173
+ child.stdout.off('data', onStdout);
174
+ child.stderr.off('data', onStderr);
175
+ child.off('exit', onExit);
176
+ if (error) {
177
+ reject(error);
178
+ } else {
179
+ resolve();
180
+ }
181
+ };
182
+
183
+ const append = (chunk: Buffer) => {
184
+ output = `${output}${chunk.toString()}`.slice(-4000);
185
+ };
186
+
187
+ const onStdout = (chunk: Buffer) => {
188
+ append(chunk);
189
+ if (chunk.toString().includes('connected, waiting for commands')) {
190
+ finish();
191
+ }
192
+ };
193
+
194
+ const onStderr = (chunk: Buffer) => append(chunk);
195
+ const onExit = (code: number | null) => {
196
+ finish(new Error(`Rook exited before connecting with code ${code}. ${output.trim()}`));
197
+ };
198
+
199
+ child.stdout.on('data', onStdout);
200
+ child.stderr.on('data', onStderr);
201
+ child.once('exit', onExit);
202
+ });
203
+ }
204
+
205
+ function localCaCertPath(): string {
206
+ const configured = process.env.PHPSANDBOX_LOCAL_CA_CERT
207
+ ?? process.env.NODE_EXTRA_CA_CERTS
208
+ ?? `${process.env.HOME}/.config/valet/CA/LaravelValetCASelfSigned.pem`;
209
+
210
+ return configured.replace(/^"(.*)"$/, '$1');
211
+ }
package/examples/get.ts CHANGED
@@ -15,6 +15,7 @@ const client = new Client(token, apiUrl);
15
15
  const notebook = await client.notebook.get(notebookId);
16
16
 
17
17
  try {
18
+ console.log('notebook:', notebook.data.id, notebook.data.title ?? '');
18
19
  console.log(await notebook.mail.status());
19
20
  } finally {
20
21
  notebook.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phpsandbox/sdk",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
4
4
  "description": "A comprehensive TypeScript SDK for interacting with cloud-based PHP development environments",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "build:browser": "node build.mjs",
29
29
  "clean": "rm -rf dist",
30
30
  "test": "vitest run",
31
- "check:examples": "tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2020 --types node --skipLibCheck --allowSyntheticDefaultImports --esModuleInterop examples/basic.ts examples/get.ts examples/git-merge-smoke.ts examples/laravel-load-test.ts examples/notebook-readonly-smoke.ts",
31
+ "check:examples": "tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2020 --types node --skipLibCheck --allowSyntheticDefaultImports --esModuleInterop examples/basic.ts examples/deploy-cloudflare-container.ts examples/deploy-laravel-cloud.ts examples/deploy-ssh-agent.ts examples/get.ts examples/git-merge-smoke.ts examples/laravel-load-test.ts examples/notebook-readonly-smoke.ts",
32
32
  "prepublishOnly": "npm test && npm run check:examples && npm run clean && npm run build",
33
33
  "bumpAndPublish": "npm version patch && npm publish --access public"
34
34
  },
@@ -61,7 +61,8 @@
61
61
  "mitt": "^3.0.1",
62
62
  "nanoid": "^5.1.2",
63
63
  "reconnecting-websocket": "^4.4.0",
64
- "ws": "^8.21.0"
64
+ "ws": "^8.21.0",
65
+ "zod": "^4.4.3"
65
66
  },
66
67
  "devDependencies": {
67
68
  "@types/node": "^24.12.2",