@surph_ai/cli 0.0.33 → 0.0.35
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/board/deploy.js +239 -0
- package/board/server.js +186 -0
- package/cli.js +28 -0
- package/package.json +1 -1
- package/scaffold/board/AGENTS.md +32 -0
- package/scaffold/board/CLAUDE.md +1 -0
- package/scaffold/board/README.md +3 -3
- package/scaffold/board/package.json +4 -4
- package/scaffold/index.js +2 -0
package/board/deploy.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deploy a board to Surph (`npm run deploy` → `surph-board-deploy`), run from
|
|
3
|
+
// the board's folder. Built up step by step; so far:
|
|
4
|
+
//
|
|
5
|
+
// 1. Zip the board's site: every file in the folder except the project
|
|
6
|
+
// files (package.json, package-lock.json, README.md, AGENTS.md, CLAUDE.md, board.json,
|
|
7
|
+
// node_modules), dot-files (.env, .git, .DS_Store) and the zip itself.
|
|
8
|
+
// Written to site.zip in the board's folder.
|
|
9
|
+
// 2. Ask Surph for an upload address for it: boards/<slug>/site/site.zip on
|
|
10
|
+
// S3, the slug from board.json.
|
|
11
|
+
// 3. Upload site.zip there, then delete the local site.zip (it's kept when
|
|
12
|
+
// the upload fails, to look at).
|
|
13
|
+
// 4. Unzip it on S3 into boards/<slug>/site/ (the board's pages and assets).
|
|
14
|
+
// 5. Upload board.json to boards/<slug>/board.json — last, so the live
|
|
15
|
+
// board's definition only changes once its site is up.
|
|
16
|
+
//
|
|
17
|
+
// Node's built-ins only (zlib for compression, fetch for the upload). Needs
|
|
18
|
+
// Node.js 20 or later.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const zlib = require('zlib');
|
|
23
|
+
|
|
24
|
+
const ROOT = process.cwd();
|
|
25
|
+
const ZIP_NAME = 'site.zip';
|
|
26
|
+
const RPC_URL = 'https://rpc.surph.ai';
|
|
27
|
+
// Unzips an uploaded archive in place on S3 (the `sync` action).
|
|
28
|
+
const SYNC_URL = 'https://e3lunom2hka3u6iiotrlzrcez40uwdnk.lambda-url.us-east-1.on.aws/';
|
|
29
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
30
|
+
|
|
31
|
+
// Left out at the board's top level: the project's own files.
|
|
32
|
+
const SKIP_AT_ROOT = new Set(['package.json', 'package-lock.json', 'README.md', 'AGENTS.md', 'CLAUDE.md', 'board.json', ZIP_NAME]);
|
|
33
|
+
|
|
34
|
+
// The files to deploy, as paths relative to the board's folder ('/'-separated).
|
|
35
|
+
const siteFiles = (dir = ROOT, rel = '') => {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
38
|
+
const name = entry.name;
|
|
39
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
40
|
+
if (!rel && SKIP_AT_ROOT.has(name)) continue;
|
|
41
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
42
|
+
const full = path.join(dir, name);
|
|
43
|
+
if (entry.isDirectory()) out.push(...siteFiles(full, relPath));
|
|
44
|
+
else if (entry.isFile()) out.push(relPath);
|
|
45
|
+
}
|
|
46
|
+
return out.sort();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// CRC-32 (the zip format's checksum).
|
|
50
|
+
const CRC_TABLE = (() => {
|
|
51
|
+
const table = new Uint32Array(256);
|
|
52
|
+
for (let n = 0; n < 256; n++) {
|
|
53
|
+
let c = n;
|
|
54
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
55
|
+
table[n] = c >>> 0;
|
|
56
|
+
}
|
|
57
|
+
return table;
|
|
58
|
+
})();
|
|
59
|
+
const crc32 = (buf) => {
|
|
60
|
+
let c = 0xffffffff;
|
|
61
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
62
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// A file's time in the zip format's (MS-DOS) date and time fields.
|
|
66
|
+
const dosTime = (date) => ({
|
|
67
|
+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
|
68
|
+
date: ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// A zip archive of the given files (deflated, or stored when that's smaller).
|
|
72
|
+
const zipFiles = (files) => {
|
|
73
|
+
const parts = [];
|
|
74
|
+
const central = [];
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (const rel of files) {
|
|
77
|
+
const full = path.join(ROOT, ...rel.split('/'));
|
|
78
|
+
const data = fs.readFileSync(full);
|
|
79
|
+
const deflated = zlib.deflateRawSync(data, { level: 9 });
|
|
80
|
+
const stored = deflated.length >= data.length;
|
|
81
|
+
const body = stored ? data : deflated;
|
|
82
|
+
const method = stored ? 0 : 8;
|
|
83
|
+
const crc = crc32(data);
|
|
84
|
+
const { time, date } = dosTime(fs.statSync(full).mtime);
|
|
85
|
+
const name = Buffer.from(rel, 'utf8');
|
|
86
|
+
|
|
87
|
+
const local = Buffer.alloc(30);
|
|
88
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
89
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
90
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
|
91
|
+
local.writeUInt16LE(method, 8);
|
|
92
|
+
local.writeUInt16LE(time, 10);
|
|
93
|
+
local.writeUInt16LE(date, 12);
|
|
94
|
+
local.writeUInt32LE(crc, 14);
|
|
95
|
+
local.writeUInt32LE(body.length, 18);
|
|
96
|
+
local.writeUInt32LE(data.length, 22);
|
|
97
|
+
local.writeUInt16LE(name.length, 26);
|
|
98
|
+
local.writeUInt16LE(0, 28);
|
|
99
|
+
parts.push(local, name, body);
|
|
100
|
+
|
|
101
|
+
const entry = Buffer.alloc(46);
|
|
102
|
+
entry.writeUInt32LE(0x02014b50, 0);
|
|
103
|
+
entry.writeUInt16LE(20, 4); // version made by
|
|
104
|
+
entry.writeUInt16LE(20, 6); // version needed
|
|
105
|
+
entry.writeUInt16LE(0x0800, 8);
|
|
106
|
+
entry.writeUInt16LE(method, 10);
|
|
107
|
+
entry.writeUInt16LE(time, 12);
|
|
108
|
+
entry.writeUInt16LE(date, 14);
|
|
109
|
+
entry.writeUInt32LE(crc, 16);
|
|
110
|
+
entry.writeUInt32LE(body.length, 20);
|
|
111
|
+
entry.writeUInt32LE(data.length, 24);
|
|
112
|
+
entry.writeUInt16LE(name.length, 28);
|
|
113
|
+
entry.writeUInt32LE(offset, 42);
|
|
114
|
+
central.push(entry, name);
|
|
115
|
+
|
|
116
|
+
offset += local.length + name.length + body.length;
|
|
117
|
+
}
|
|
118
|
+
const centralBuf = Buffer.concat(central);
|
|
119
|
+
const end = Buffer.alloc(22);
|
|
120
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
121
|
+
end.writeUInt16LE(files.length, 8);
|
|
122
|
+
end.writeUInt16LE(files.length, 10);
|
|
123
|
+
end.writeUInt32LE(centralBuf.length, 12);
|
|
124
|
+
end.writeUInt32LE(offset, 16);
|
|
125
|
+
return Buffer.concat([...parts, centralBuf, end]);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const fail = (message) => {
|
|
129
|
+
console.error(message);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// The board's slug, from its board.json.
|
|
134
|
+
const boardSlug = () => {
|
|
135
|
+
let board;
|
|
136
|
+
try {
|
|
137
|
+
board = JSON.parse(fs.readFileSync(path.join(ROOT, 'board.json'), 'utf8'));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
fail(`board.json isn't valid JSON: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
const slug = typeof board?.slug === 'string' ? board.slug.trim() : '';
|
|
142
|
+
if (!SLUG_RE.test(slug)) fail('board.json needs a "slug": lowercase letters, digits and dashes (e.g. "my-board").');
|
|
143
|
+
return slug;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// A presigned S3 address to PUT a file to, under boards/<slug>/ (good for an hour).
|
|
147
|
+
const uploadUrlFor = async (slug, filename, filetype) => {
|
|
148
|
+
const res = await fetch(`${RPC_URL}/s3/upload_url`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'x-surph-client': 'surph-sdk' },
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
folder: slug,
|
|
153
|
+
filename,
|
|
154
|
+
filetype,
|
|
155
|
+
bucket: 'surph',
|
|
156
|
+
toStatic: 'false',
|
|
157
|
+
collection: 'boards',
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
const json = await res.json().catch(() => null);
|
|
161
|
+
const uploadUrl = json?.response?.uploadUrl;
|
|
162
|
+
if (!res.ok || !uploadUrl) fail(`Couldn't get an upload address for ${filename}: ${json?.response?.error || `HTTP ${res.status}`}`);
|
|
163
|
+
return uploadUrl;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// PUT a file to its presigned address. S3 answers 200; anything else is an
|
|
167
|
+
// XML error, whose code and message say why.
|
|
168
|
+
const upload = async (uploadUrl, body, filetype, filename) => {
|
|
169
|
+
const res = await fetch(uploadUrl, {
|
|
170
|
+
method: 'PUT',
|
|
171
|
+
headers: { 'Content-Type': filetype },
|
|
172
|
+
body,
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
const text = await res.text().catch(() => '');
|
|
176
|
+
const code = (text.match(/<Code>([^<]+)<\/Code>/) || [])[1] || `HTTP ${res.status}`;
|
|
177
|
+
const message = (text.match(/<Message>([^<]+)<\/Message>/) || [])[1] || '';
|
|
178
|
+
fail(`Upload of ${filename} failed: ${code}${message ? ` — ${message}` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Unzip boards/<slug>/site/site.zip on S3 into boards/<slug>/site/.
|
|
183
|
+
const unzipOnS3 = async (slug) => {
|
|
184
|
+
const res = await fetch(SYNC_URL, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Turbo-Client': 'portal' },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
action: 'sync',
|
|
189
|
+
args: { project: slug, file: ZIP_NAME, bucket: 'surph', folder: 'site' },
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
const text = await res.text().catch(() => '');
|
|
193
|
+
let json = null;
|
|
194
|
+
try { json = JSON.parse(text); } catch { /* not JSON */ }
|
|
195
|
+
const error = json?.error || json?.response?.error || json?.data?.error;
|
|
196
|
+
if (!res.ok || error) {
|
|
197
|
+
fail(`Unzipping ${ZIP_NAME} on S3 failed: ${typeof error === 'string' ? error : (error?.message || text.slice(0, 300) || `HTTP ${res.status}`)}`);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const main = async () => {
|
|
202
|
+
if (!fs.existsSync(path.join(ROOT, 'board.json'))) {
|
|
203
|
+
fail(`No board.json in ${ROOT}. Run this from the board's folder.`);
|
|
204
|
+
}
|
|
205
|
+
const slug = boardSlug();
|
|
206
|
+
const files = siteFiles();
|
|
207
|
+
if (!files.length) fail('Nothing to deploy: the board has no site files.');
|
|
208
|
+
|
|
209
|
+
const zip = zipFiles(files);
|
|
210
|
+
fs.writeFileSync(path.join(ROOT, ZIP_NAME), zip);
|
|
211
|
+
console.log(`Zipped ${files.length} file${files.length === 1 ? '' : 's'} into ${ZIP_NAME} (${(zip.length / 1024).toFixed(1)} KB):`);
|
|
212
|
+
for (const file of files) console.log(` ${file}`);
|
|
213
|
+
|
|
214
|
+
console.log(`\nDeploying the board "${slug}"…`);
|
|
215
|
+
const zipUrl = await uploadUrlFor(slug, `site/${ZIP_NAME}`, 'application/zip');
|
|
216
|
+
await upload(zipUrl, zip, 'application/zip', ZIP_NAME);
|
|
217
|
+
console.log(`Uploaded: ${zipUrl.split('?')[0]}`);
|
|
218
|
+
fs.rmSync(path.join(ROOT, ZIP_NAME), { force: true });
|
|
219
|
+
|
|
220
|
+
await unzipOnS3(slug);
|
|
221
|
+
console.log(`Unzipped into: https://surph.s3.us-east-1.amazonaws.com/boards/${slug}/site/`);
|
|
222
|
+
|
|
223
|
+
const boardUrl = await uploadUrlFor(slug, 'board.json', 'application/json');
|
|
224
|
+
await upload(boardUrl, fs.readFileSync(path.join(ROOT, 'board.json')), 'application/json', 'board.json');
|
|
225
|
+
console.log(`Uploaded: ${boardUrl.split('?')[0]}`);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const MIN_NODE = 20;
|
|
229
|
+
|
|
230
|
+
const run = () => {
|
|
231
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
232
|
+
if (major < MIN_NODE) fail(`Deploying a board needs Node.js ${MIN_NODE} or later; this is ${process.version}. Install a newer Node.js (https://nodejs.org) and try again.`);
|
|
233
|
+
return main().catch((error) => fail(`Deploy failed: ${error.message}`));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// `surph board-deploy` calls run(); running this file directly runs it too.
|
|
237
|
+
if (require.main === module) run();
|
|
238
|
+
|
|
239
|
+
module.exports = { run };
|
package/board/server.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The board's local dev server (`npm run dev` → `surph-board-dev`): serves
|
|
3
|
+
// the board folder it's run from (the current directory) at
|
|
4
|
+
// http://localhost:4180 for Surph's dev mode (Settings → Developer → Run
|
|
5
|
+
// Surph as a local board). Node's built-ins only; needs Node.js 20 or later.
|
|
6
|
+
//
|
|
7
|
+
// GET /<file> This folder's files, never cached, with CORS so
|
|
8
|
+
// Surph (another address) can read board.json.
|
|
9
|
+
// GET /__surph/assets The image files in assets/ (the logo picker).
|
|
10
|
+
// PUT /board.json Surph's Local board panel saving the board. Only
|
|
11
|
+
// from Surph's own addresses, only this one file,
|
|
12
|
+
// only a JSON object with a config.pages list.
|
|
13
|
+
//
|
|
14
|
+
// It listens on this machine only (127.0.0.1 and ::1), never the network.
|
|
15
|
+
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const PORT = Number(process.env.PORT) || 4180;
|
|
21
|
+
// The board's folder: where `npm run dev` runs it, not this package's folder
|
|
22
|
+
// (it's installed under node_modules/@surph_ai/sdk).
|
|
23
|
+
const ROOT = process.cwd();
|
|
24
|
+
const MAX_BOARD_BYTES = 256 * 1024;
|
|
25
|
+
|
|
26
|
+
// The addresses whose pages may save board.json.
|
|
27
|
+
const SAVE_ORIGINS = [
|
|
28
|
+
/^https:\/\/([a-z0-9-]+\.)?surph\.ai$/,
|
|
29
|
+
/^http:\/\/localhost:\d+$/,
|
|
30
|
+
/^http:\/\/127\.0\.0\.1:\d+$/,
|
|
31
|
+
];
|
|
32
|
+
const canSave = (origin) => typeof origin === 'string' && SAVE_ORIGINS.some((re) => re.test(origin));
|
|
33
|
+
|
|
34
|
+
const TYPES = {
|
|
35
|
+
'.html': 'text/html; charset=utf-8',
|
|
36
|
+
'.css': 'text/css; charset=utf-8',
|
|
37
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
38
|
+
'.json': 'application/json; charset=utf-8',
|
|
39
|
+
'.svg': 'image/svg+xml',
|
|
40
|
+
'.png': 'image/png',
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.jpeg': 'image/jpeg',
|
|
43
|
+
'.gif': 'image/gif',
|
|
44
|
+
'.webp': 'image/webp',
|
|
45
|
+
'.ico': 'image/x-icon',
|
|
46
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
47
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
48
|
+
};
|
|
49
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
|
50
|
+
|
|
51
|
+
const send = (res, status, body, headers = {}) => {
|
|
52
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', ...headers });
|
|
53
|
+
res.end(body);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const cors = (req) => {
|
|
57
|
+
const origin = req.headers.origin;
|
|
58
|
+
const headers = { 'Access-Control-Allow-Origin': '*' };
|
|
59
|
+
// A save is only allowed from Surph; the browser asks first (preflight).
|
|
60
|
+
// Chrome asks before a public site (surph.ai) reaches a local address.
|
|
61
|
+
if (req.headers['access-control-request-private-network'] === 'true') {
|
|
62
|
+
headers['Access-Control-Allow-Private-Network'] = 'true';
|
|
63
|
+
}
|
|
64
|
+
if (canSave(origin)) {
|
|
65
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
66
|
+
headers['Access-Control-Allow-Methods'] = 'GET, PUT, OPTIONS';
|
|
67
|
+
headers['Access-Control-Allow-Headers'] = 'Content-Type';
|
|
68
|
+
headers.Vary = 'Origin';
|
|
69
|
+
}
|
|
70
|
+
return headers;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// A path inside this folder, or null (no way out of it, no dot-files).
|
|
74
|
+
const inside = (urlPath) => {
|
|
75
|
+
let rel;
|
|
76
|
+
try { rel = decodeURIComponent(urlPath.split('?')[0]); } catch { return null; }
|
|
77
|
+
if (rel.endsWith('/')) rel += 'index.html';
|
|
78
|
+
const full = path.normalize(path.join(ROOT, rel));
|
|
79
|
+
if (full !== ROOT && !full.startsWith(ROOT + path.sep)) return null;
|
|
80
|
+
if (path.relative(ROOT, full).split(path.sep).some((part) => part.startsWith('.') || part === 'node_modules')) return null;
|
|
81
|
+
return full;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
85
|
+
let size = 0;
|
|
86
|
+
const chunks = [];
|
|
87
|
+
req.on('data', (chunk) => {
|
|
88
|
+
size += chunk.length;
|
|
89
|
+
if (size > MAX_BOARD_BYTES) {
|
|
90
|
+
reject(new Error('board.json is too large'));
|
|
91
|
+
req.destroy();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
});
|
|
96
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
97
|
+
req.on('error', reject);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const saveBoard = async (req, res, headers) => {
|
|
101
|
+
if (!canSave(req.headers.origin)) {
|
|
102
|
+
send(res, 403, JSON.stringify({ error: 'board.json can only be saved from Surph.' }), { ...headers, 'Content-Type': 'application/json' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let board;
|
|
106
|
+
try {
|
|
107
|
+
board = JSON.parse(await readBody(req));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
send(res, 400, JSON.stringify({ error: error.message || 'Not JSON.' }), { ...headers, 'Content-Type': 'application/json' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!board || typeof board !== 'object' || Array.isArray(board) || !Array.isArray(board.config && board.config.pages)) {
|
|
113
|
+
send(res, 400, JSON.stringify({ error: 'board.json must be an object with a config.pages list.' }), { ...headers, 'Content-Type': 'application/json' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Written via a temporary file, so a failed write never leaves half a file.
|
|
117
|
+
const file = path.join(ROOT, 'board.json');
|
|
118
|
+
const tmp = `${file}.saving`;
|
|
119
|
+
fs.writeFileSync(tmp, `${JSON.stringify(board, null, 2)}\n`);
|
|
120
|
+
fs.renameSync(tmp, file);
|
|
121
|
+
console.log(`Saved board.json (${new Date().toLocaleTimeString()})`);
|
|
122
|
+
send(res, 200, JSON.stringify({ ok: true }), { ...headers, 'Content-Type': 'application/json' });
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const handle = async (req, res) => {
|
|
126
|
+
const headers = cors(req);
|
|
127
|
+
const urlPath = req.url || '/';
|
|
128
|
+
try {
|
|
129
|
+
if (req.method === 'OPTIONS') {
|
|
130
|
+
send(res, 204, '', headers);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.method === 'PUT' && urlPath.split('?')[0] === '/board.json') {
|
|
134
|
+
await saveBoard(req, res, headers);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
138
|
+
send(res, 405, 'Method not allowed', headers);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (urlPath.split('?')[0] === '/__surph/assets') {
|
|
142
|
+
const dir = path.join(ROOT, 'assets');
|
|
143
|
+
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => IMAGE.test(name)).sort() : [];
|
|
144
|
+
send(res, 200, JSON.stringify({ images: files }), { ...headers, 'Content-Type': 'application/json' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const full = inside(urlPath);
|
|
148
|
+
if (!full || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
|
|
149
|
+
send(res, 404, 'Not found', headers);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const type = TYPES[path.extname(full).toLowerCase()] || 'application/octet-stream';
|
|
153
|
+
send(res, 200, req.method === 'HEAD' ? '' : fs.readFileSync(full), { ...headers, 'Content-Type': type });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
send(res, 500, String(error && error.message || error), headers);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Start serving the current folder. localhost may be reached over IPv4 or
|
|
160
|
+
// IPv6: listen on both loopbacks.
|
|
161
|
+
const MIN_NODE = 20;
|
|
162
|
+
|
|
163
|
+
const start = () => {
|
|
164
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
165
|
+
if (major < MIN_NODE) {
|
|
166
|
+
console.error(`The board dev server needs Node.js ${MIN_NODE} or later; this is ${process.version}. Install a newer Node.js (https://nodejs.org) and try again.`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
http.createServer(handle)
|
|
170
|
+
.on('error', (error) => {
|
|
171
|
+
console.error(error.code === 'EADDRINUSE'
|
|
172
|
+
? `Port ${PORT} is already in use: another board server may be running. Stop it, or set PORT to use another port.`
|
|
173
|
+
: `Board dev server failed: ${error.message}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
})
|
|
176
|
+
.listen(PORT, '127.0.0.1', () => {
|
|
177
|
+
console.log(`Board dev server: http://localhost:${PORT} (serving ${ROOT})`);
|
|
178
|
+
console.log('In Surph: Settings → Developer → Run Surph as a local board.');
|
|
179
|
+
});
|
|
180
|
+
http.createServer(handle).listen(PORT, '::1').on('error', () => { /* no IPv6 loopback */ });
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// `surph board-server` calls start(); running this file directly starts it too.
|
|
184
|
+
if (require.main === module) start();
|
|
185
|
+
|
|
186
|
+
module.exports = { start };
|
package/cli.js
CHANGED
|
@@ -20,6 +20,8 @@ const AdmZip = require('adm-zip')
|
|
|
20
20
|
// const auth = require('./auth')
|
|
21
21
|
const deploy = require('./deploy')
|
|
22
22
|
const scaffold = require('./scaffold')
|
|
23
|
+
const boardDeploy = require('./board/deploy')
|
|
24
|
+
const boardServer = require('./board/server')
|
|
23
25
|
const { ensureChat, chatNodePath } = require('./toolserver/ensure-chat')
|
|
24
26
|
const utils = require('./utils')
|
|
25
27
|
const { openBrowser, openBrowserWhenReady } = require('./utils/browser')
|
|
@@ -297,6 +299,32 @@ program.command('board <name>')
|
|
|
297
299
|
.description('create new Surph board')
|
|
298
300
|
.action(name => scaffold(name, 'board'))
|
|
299
301
|
|
|
302
|
+
program.command('board-deploy')
|
|
303
|
+
.description('Deploy board to Surph')
|
|
304
|
+
.action(async (cmd, options) => {
|
|
305
|
+
try {
|
|
306
|
+
// From the board's folder: zip its site, upload and unzip it, then
|
|
307
|
+
// upload board.json (board/deploy.js).
|
|
308
|
+
await boardDeploy.run()
|
|
309
|
+
} catch (error) {
|
|
310
|
+
utils.printError(error)
|
|
311
|
+
program.error(`Unexpected error: ${error}`, ERRORS.UNSPECIFIED)
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
program.command('board-server')
|
|
316
|
+
.description('local board dev server')
|
|
317
|
+
.action(async (cmd, options) => {
|
|
318
|
+
try {
|
|
319
|
+
// Serves the board's folder (the current directory) at
|
|
320
|
+
// http://localhost:4180 for Surph's dev mode (board/server.js).
|
|
321
|
+
boardServer.start()
|
|
322
|
+
} catch (error) {
|
|
323
|
+
utils.printError(error)
|
|
324
|
+
program.error(`Unexpected error: ${error}`, ERRORS.UNSPECIFIED)
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
300
328
|
program.command('manifest')
|
|
301
329
|
.description('update manifest for Surph tool')
|
|
302
330
|
.action(async (cmd, options) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Surph board: notes for coding agents
|
|
2
|
+
|
|
3
|
+
This folder is a **Surph board**: static web pages that Surph shows in its canvas, beside the Surph chat. The person you're working with may not be technical. Explain steps in plain words, and say what a command does before you run it.
|
|
4
|
+
|
|
5
|
+
**`README.md` is the full reference.** Read it before writing or changing a page: it lists every operation Surph answers, the item shape, the rules for pages, and how `board.json` works. This file covers setup and the working loop.
|
|
6
|
+
|
|
7
|
+
## Setup: Node.js
|
|
8
|
+
|
|
9
|
+
Previewing and publishing the board need **Node.js 20 or later**. The pages themselves don't.
|
|
10
|
+
|
|
11
|
+
1. Check with `node -v`.
|
|
12
|
+
2. If it's missing or older than 20, tell the person that Node.js is needed to preview the board inside Surph and to publish it, and offer to install it. Install only with their OK.
|
|
13
|
+
- **Mac:** prefer a user-level install that needs no admin password, such as [fnm](https://github.com/Schniz/fnm) (`curl -fsSL https://fnm.vercel.app/install | bash`, then `fnm install --lts`). Homebrew (`brew install node`) is fine if Homebrew is already installed.
|
|
14
|
+
- **Windows:** `winget install OpenJS.NodeJS.LTS` (Windows may ask the person to approve once).
|
|
15
|
+
- **Linux:** the distribution's package manager, or fnm as on Mac.
|
|
16
|
+
3. A fresh install may not be visible in the current terminal. If `node -v` still fails afterwards, open a new terminal session (or load the shell profile) and check again.
|
|
17
|
+
4. If an install isn't possible (declined, or blocked on a work computer), point them to the installer at https://nodejs.org and wait for them.
|
|
18
|
+
|
|
19
|
+
## The working loop
|
|
20
|
+
|
|
21
|
+
1. **Preview:** run `npm run dev` from this folder and leave it running. It serves the board at `http://localhost:4180` (nothing to install; it uses the Surph CLI through `npx`).
|
|
22
|
+
2. **See it in Surph:** ask the person to open Surph, go to **Settings → Developer**, turn on **Run Surph as a local board**, and keep the address `http://localhost:4180`. Surph reloads as this board.
|
|
23
|
+
3. **Configure the board** (name, contexts, tools, instructions): the person can click the **DEV** label in Surph's canvas header to open the Local board panel, which saves `board.json` for you. You can also edit `board.json` directly; the README describes every field.
|
|
24
|
+
4. **Change pages:** edit the HTML in this folder and keep `board.json`'s `config.pages` in step (every page listed, `shows` written as plain facts, `ops` matching the page's `host.rpc` calls). The person reloads Surph to see the change.
|
|
25
|
+
5. **Publish:** `npm run deploy`. **Only when the person asks you to in this conversation.** It replaces the live board for everyone who uses it. Check the board in dev mode first.
|
|
26
|
+
|
|
27
|
+
## What to keep in mind
|
|
28
|
+
|
|
29
|
+
- Pages talk to Surph only through the Surph SDK (`host.rpc(…)`) and only with the operations the README lists. Don't invent operations or fields; if a page needs data Surph doesn't offer, say so.
|
|
30
|
+
- Write data from Surph with `textContent`, never `innerHTML`.
|
|
31
|
+
- Everything in this folder except `package.json`, `package-lock.json`, `README.md`, `AGENTS.md`, `CLAUDE.md`, `board.json`, `node_modules` and dot-files gets published by `npm run deploy`. Keep drafts, exports and secrets out of it (or in dot-files).
|
|
32
|
+
- Before handing off, go through the README's "Checklist before handing off a change".
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/scaffold/board/README.md
CHANGED
|
@@ -400,7 +400,7 @@ A field marked "omitted" is missing from the object, not `null`. Every URL is `h
|
|
|
400
400
|
|
|
401
401
|
## Developing locally
|
|
402
402
|
|
|
403
|
-
1.
|
|
403
|
+
1. Run `npm run dev` (nothing to install). It starts the board dev server from the Surph CLI (`npx @surph_ai/cli board-server`), which serves this folder at `http://localhost:4180` with caching off, lets Surph read it from another address, and lets Surph's Local board panel save `board.json`. It only listens on your own machine.
|
|
404
404
|
2. Keep it running while you work; the terminal shows a line each time Surph saves `board.json`.
|
|
405
405
|
3. Open `http://localhost:4180/` to check a page loads without console errors. It shows its empty layout there (`hosted` is false); real data only appears inside the Surph canvas.
|
|
406
406
|
4. **Run Surph as your board with dev mode.** Keep `npm run dev` running. In Surph, open **Settings → Developer**, turn on **Run Surph as a local board**, and keep the address `http://localhost:4180`. Surph reloads as the board `board.json` describes: its name and logo in the header, its contexts and instructions in the chat, and its pages in the canvas (it opens on your start page, the Pages menu lists your pages, and the chat's model sees their `shows` lines). The status under the address says what loaded, or why it couldn't read `board.json`; until it loads, Surph runs as usual. A **DEV** label in the canvas header shows your board is loaded. While it's on, anything the chat saves goes to your own space in Surph, not to a board's shared space.
|
|
@@ -410,10 +410,10 @@ A field marked "omitted" is missing from the object, not `null`. Every URL is `h
|
|
|
410
410
|
|
|
411
411
|
## Deploying
|
|
412
412
|
|
|
413
|
-
`npm run deploy` (`
|
|
413
|
+
`npm run deploy` (`npx @surph_ai/cli board-deploy`, from the Surph CLI) publishes the board to Surph. Run it from the board's folder. It needs Node.js 20 or later and nothing else installed. In order, it:
|
|
414
414
|
|
|
415
415
|
1. **Reads the board's `slug`** from `board.json` (lowercase letters, digits and dashes). The slug names the board's place in Surph; without a valid one it stops before doing anything.
|
|
416
|
-
2. **Zips the site** into `site.zip`: every file in the folder except `package.json`, `package-lock.json`, `README.md`, `board.json`, `node_modules`, dot-files (`.env`, `.git`, `.DS_Store`) and `site.zip` itself. It lists the files it zipped.
|
|
416
|
+
2. **Zips the site** into `site.zip`: every file in the folder except `package.json`, `package-lock.json`, `README.md`, `AGENTS.md`, `CLAUDE.md`, `board.json`, `node_modules`, dot-files (`.env`, `.git`, `.DS_Store`) and `site.zip` itself. It lists the files it zipped.
|
|
417
417
|
3. **Uploads `site.zip`** to the board's `site/` folder in Surph's storage, then deletes the local `site.zip`. If the upload fails, the zip is kept to look at.
|
|
418
418
|
4. **Unzips it there**, so the pages and `assets/` sit in the board's `site/` folder, where each page's `file` is found.
|
|
419
419
|
5. **Uploads `board.json`**, last, so the live board's definition only changes once its pages are up. If an earlier step fails, `board.json` isn't uploaded and the live board keeps its previous version.
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "
|
|
7
|
-
"deploy": "
|
|
6
|
+
"dev": "npx @surph_ai/cli board-server",
|
|
7
|
+
"deploy": "npx @surph_ai/cli board-deploy"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [],
|
|
10
10
|
"author": "",
|
|
11
11
|
"license": "ISC",
|
|
12
12
|
"type": "commonjs",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
15
|
}
|
|
16
16
|
}
|
package/scaffold/index.js
CHANGED
|
@@ -270,6 +270,8 @@ const scaffoldBoard = async (name) => {
|
|
|
270
270
|
utils.write('package.json', JSON.stringify(pkgJson, null, 2) + '\n')
|
|
271
271
|
|
|
272
272
|
shell.cp('-R', path.join(__dirname, `board/README.md`), 'README.md')
|
|
273
|
+
shell.cp('-R', path.join(__dirname, `board/AGENTS.md`), 'AGENTS.md')
|
|
274
|
+
shell.cp('-R', path.join(__dirname, `board/CLAUDE.md`), 'CLAUDE.md')
|
|
273
275
|
shell.cp('-R', path.join(__dirname, `board/index.html`), 'index.html')
|
|
274
276
|
shell.cp('-R', path.join(__dirname, `board/board.json`), 'board.json')
|
|
275
277
|
|