@quadient/skillshare 1.4.0 → 1.6.0

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/README.md CHANGED
@@ -209,3 +209,70 @@ bun run publish:tui -- --otp 123456
209
209
  ```
210
210
 
211
211
  The publish script builds `tui/`, validates the packed files with `npm pack --dry-run`, and only then runs `npm publish`.
212
+
213
+ ## Pages applications
214
+
215
+ `skillshare pages upload <directory> --slug <slug>` accepts static sites or
216
+ Wrangler JSON/JSONC projects. The CLI bundles Worker dependencies locally; install
217
+ them and build frontend assets before uploading. Inspect process status and logs
218
+ with `skillshare pages runtime <slug>`. Pages are served below `/p/<slug>/`, so use
219
+ that base path when building assets. Runtime data survives content replacement.
220
+
221
+ ## Local Pages development (1.6.0+)
222
+
223
+ Run a celld application without a SkillShare server, repository checkout, Docker,
224
+ cloud storage account or CLI login:
225
+
226
+ ```sh
227
+ bunx @quadient/skillshare@^1.6.0 pages dev . --slug my-app
228
+ ```
229
+
230
+ The directory must contain one `wrangler.json` or `wrangler.jsonc`. The CLI downloads
231
+ and verifies celld 0.4.1 on first use, caches it under `~/.cache/skillshare/celld`,
232
+ and uses its packaged esbuild. Supported hosts are macOS Apple Silicon and Linux
233
+ x64/ARM64; Windows users can use WSL2. `--celld <executable>` or `CELLD_EXECUTABLE`
234
+ selects an existing installation.
235
+
236
+ The default URL is `http://127.0.0.1:8787/p/my-app/`. The proxy removes the Page prefix
237
+ and adds the same `X-Skillshare-*` request headers as SkillShare. The development
238
+ revision is `0`. The toolbar offers four local users covering every role combination: Alice
239
+ (owner), Bob (neither role), Charlie (admin), and Dana (owner and admin).
240
+ Each option shows explicit `isOwner` and `isAdmin` values, and each browser can
241
+ select its own user. Customize the first
242
+ user with `--user-id`, `--user-name` and `--user-email`. This is simulated local
243
+ identity, not a real SSO login. Listeners bind only to loopback.
244
+
245
+ The proxy filters SkillShare authentication cookies and Authorization on ordinary
246
+ routes, scopes application cookies and redirects to the Page, and supports HTTP
247
+ streaming and WebSockets. `/api/bypass-sso/*` preserves Authorization but forwards
248
+ no cookies or user identity, matching hosted Pages. Application `vars` and resource
249
+ bindings still come from Wrangler. Host cloud credentials and CLI tokens are not
250
+ passed to the celld process.
251
+
252
+ For a project with a build step (React/Vite, for example):
253
+
254
+ ```sh
255
+ bunx @quadient/skillshare@^1.6.0 pages dev dist \
256
+ --watch-dir . --build "bun run build" --slug my-app --port 8787
257
+ ```
258
+
259
+ `--build` runs in `--watch-dir` before the first start and after source changes.
260
+ The command is executed through your shell. The build must produce the complete
261
+ application directory, including Wrangler configuration, Worker and assets.
262
+ Source edits are debounced and builds are serialized. The output directory,
263
+ `node_modules`, `.git`, `.celld`, `.wrangler`, `.skillshare`, `dist` subdirectories
264
+ and `coverage` are ignored to avoid rebuild loops. Use a dedicated output directory;
265
+ do not write generated files alongside watched source files.
266
+
267
+ After a successful build the CLI gracefully restarts celld and reloads open pages
268
+ once the new node is ready. Local resources persist in the application's `.celld/`
269
+ directory. Do not delete that directory in your build script. A build failure leaves
270
+ the previous node serving; a celld startup failure leaves the proxy available with
271
+ an error page. Fixing a watched source file retries the build/start. The terminal
272
+ shows errors. Ctrl+C stops the watcher, proxy and child processes.
273
+
274
+ Browser reload uses a script injected into HTML and a same-origin SSE connection.
275
+ This is full-page reload, not React HMR. A CSP that disallows same-origin scripts
276
+ or connections must be adjusted for local development or the page reloaded manually.
277
+ The `/__skillshare_dev/` path is reserved for local development controls. This command
278
+ does not implement the SkillShare catalog, actual SSO or cloud deployment.
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { preparePageFiles } from './pages.js';
2
+ import { runPageDev } from './pages-dev.js';
1
3
  import { createServer } from 'node:http';
2
4
  import { createHash, randomBytes } from 'node:crypto';
3
5
  import { spawn } from 'node:child_process';
@@ -81,6 +83,9 @@ Commands:
81
83
  pages list
82
84
  pages show <slug>
83
85
  pages upload <directory> [--slug <slug>]
86
+ pages dev [directory] [--slug <slug>] [--port <port>]
87
+ [--build <command> --watch-dir <sources>] [--celld <executable>]
88
+ [--user-id <id>] [--user-name <name>] [--user-email <email>]
84
89
  git configure [local]
85
90
  git unconfigure [local]
86
91
 
@@ -99,6 +104,10 @@ async function main() {
99
104
  process.exit(0);
100
105
  }
101
106
  const options = await parseOptions(args.slice(1));
107
+ if ((command === 'pages' || command === 'page') && options.positionals[0] === 'dev') {
108
+ await runPageDev(options.positionals[1] ?? '.', options.flags);
109
+ return;
110
+ }
102
111
  switch (command) {
103
112
  case 'login': {
104
113
  await login(options.positionals[0] === 'local' ? LOCAL_URL : PROD_URL);
@@ -280,15 +289,15 @@ async function main() {
280
289
  if (action === 'list') {
281
290
  const pages = await client.get('/api/pages');
282
291
  for (const page of pages) {
283
- console.log(`${page.slug}\t${page.fileCount}\t${page.ownerDisplayName}`);
292
+ console.log(`${page.slug}\t${page.kind}\t${page.fileCount}\t${page.ownerDisplayName}`);
284
293
  }
285
294
  return;
286
295
  }
287
- if (action === 'show') {
296
+ if (action === 'show' || action === 'runtime') {
288
297
  const slug = options.positionals[1];
289
298
  if (!slug)
290
- throw new Error('pages show requires <slug>');
291
- console.log(JSON.stringify(await client.get(`/api/pages/${encodeURIComponent(slug)}`), null, 2));
299
+ throw new Error(`pages ${action} requires <slug>`);
300
+ console.log(JSON.stringify(await client.get(`/api/pages/${encodeURIComponent(slug)}${action === 'runtime' ? '/runtime' : ''}`), null, 2));
292
301
  return;
293
302
  }
294
303
  if (action === 'upload') {
@@ -311,7 +320,7 @@ async function main() {
311
320
  }
312
321
  return;
313
322
  }
314
- throw new Error(`Unknown pages command '${action}'. Use list, show, or upload.`);
323
+ throw new Error(`Unknown pages command '${action}'. Use list, show, runtime, or upload.`);
315
324
  }
316
325
  }
317
326
  return;
@@ -796,17 +805,14 @@ async function loadLocalSkillContext(directory, baseUrl) {
796
805
  };
797
806
  }
798
807
  async function loadLocalPageFiles(directory) {
799
- const filePlans = await collectFilePlans(directory, directory, { includeSkillMetadata: true });
808
+ const filePlans = await collectFilePlans(directory, directory, { includeSkillMetadata: true, pageProject: true });
800
809
  if (filePlans.length === 0) {
801
810
  throw new Error('The page directory is empty.');
802
811
  }
803
- if (!filePlans.some((file) => file.relativePath === 'index.html')) {
804
- throw new Error('The page directory must contain index.html at its root.');
805
- }
806
- const totalBytes = filePlans.reduce((sum, file) => sum + file.size, 0);
812
+ const files = await preparePageFiles(directory, await collectFiles(filePlans));
807
813
  return {
808
- files: await collectFiles(filePlans),
809
- totalBytes,
814
+ files,
815
+ totalBytes: files.reduce((sum, file) => sum + file.content.length, 0),
810
816
  };
811
817
  }
812
818
  async function createSkillFromDirectory(client, localSkill, input) {
@@ -1881,6 +1887,8 @@ async function collectFilePlans(root, current = root, options = {}) {
1881
1887
  const files = [];
1882
1888
  for (const entry of entries) {
1883
1889
  const entryPath = join(current, entry.name);
1890
+ if (options.pageProject && (entry.isSymbolicLink() || ['.git', '.celld', '.wrangler', 'node_modules'].includes(entry.name)))
1891
+ continue;
1884
1892
  if (entry.isDirectory()) {
1885
1893
  files.push(...(await collectFilePlans(root, entryPath, options)));
1886
1894
  continue;
@@ -0,0 +1,308 @@
1
+ import { createServer, request as httpRequest, } from 'node:http';
2
+ const sessionCookie = 'skillshare.dev-user';
3
+ const internal = '/__skillshare_dev';
4
+ const hostCookie = (name) => name.startsWith('skillshare.auth') ||
5
+ name.startsWith('.AspNetCore.') ||
6
+ name === sessionCookie;
7
+ const hopHeaders = [
8
+ 'connection',
9
+ 'keep-alive',
10
+ 'proxy-authenticate',
11
+ 'proxy-authorization',
12
+ 'te',
13
+ 'trailer',
14
+ 'transfer-encoding',
15
+ 'upgrade',
16
+ ];
17
+ function cleanHeaders(headers) {
18
+ const result = { ...headers };
19
+ for (const name of [
20
+ ...hopHeaders,
21
+ ...(headers.connection ?? '')
22
+ .split(',')
23
+ .map((name) => name.trim().toLowerCase()),
24
+ ])
25
+ delete result[name];
26
+ return result;
27
+ }
28
+ export function requestHeaders(request, user, baseUrl, slug) {
29
+ const headers = cleanHeaders(request.headers);
30
+ for (const name of Object.keys(headers))
31
+ if (name.startsWith('x-skillshare-') ||
32
+ name.startsWith('x-forwarded-') ||
33
+ name === 'forwarded')
34
+ delete headers[name];
35
+ delete headers.host;
36
+ delete headers.cookie;
37
+ headers['accept-encoding'] = 'identity';
38
+ if (user) {
39
+ delete headers.authorization;
40
+ headers['x-skillshare-user'] = Buffer.from(JSON.stringify(user)).toString('base64');
41
+ const cookies = (request.headers.cookie ?? '')
42
+ .split(';')
43
+ .map((value) => value.trim())
44
+ .filter((value) => value && !hostCookie(value.split('=')[0]));
45
+ if (cookies.length)
46
+ headers.cookie = cookies.join('; ');
47
+ }
48
+ headers['x-skillshare-page'] = slug;
49
+ headers['x-skillshare-revision'] = '0';
50
+ headers['x-skillshare-base-url'] = baseUrl;
51
+ return headers;
52
+ }
53
+ export function responseCookies(cookies, prefix) {
54
+ return cookies
55
+ .filter((cookie) => !hostCookie(cookie.split('=')[0].trim()))
56
+ .map((cookie) => {
57
+ let path = '/';
58
+ const fields = cookie.split(';').filter((field, index) => {
59
+ if (!index)
60
+ return true;
61
+ if (/^\s*domain\s*=/i.test(field))
62
+ return false;
63
+ if (/^\s*path\s*=/i.test(field)) {
64
+ path = field.slice(field.indexOf('=') + 1).trim();
65
+ return false;
66
+ }
67
+ return true;
68
+ });
69
+ return (fields.join(';') +
70
+ '; Path=' +
71
+ prefix +
72
+ (path.startsWith('/') ? path : '/'));
73
+ });
74
+ }
75
+ export function createDevProxy(options) {
76
+ const prefix = `/p/${options.slug}`;
77
+ const streams = new Set();
78
+ const upgrades = new Set();
79
+ const userFor = (request) => {
80
+ const id = (request.headers.cookie ?? '')
81
+ .split(';')
82
+ .map((value) => value.trim())
83
+ .find((value) => value.startsWith(sessionCookie + '='))
84
+ ?.slice(sessionCookie.length + 1);
85
+ return (options.users.find((user) => encodeURIComponent(user.id) === id) ??
86
+ options.users[0]);
87
+ };
88
+ function pagePath(path) {
89
+ return path.startsWith(prefix + '/') ? path.slice(prefix.length) : undefined;
90
+ }
91
+ function allowed(request) {
92
+ const host = request.headers.host ?? '';
93
+ return /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
94
+ }
95
+ const server = createServer((request, response) => {
96
+ if (!allowed(request)) {
97
+ response.writeHead(403).end('Local requests only');
98
+ return;
99
+ }
100
+ const url = new URL(request.url, `http://${request.headers.host}`);
101
+ if (url.pathname === '/' || url.pathname === prefix) {
102
+ response.writeHead(307, { Location: prefix + '/' + url.search }).end();
103
+ return;
104
+ }
105
+ if (url.pathname.startsWith(internal + '/')) {
106
+ if (request.method !== 'GET') {
107
+ response.writeHead(405).end();
108
+ return;
109
+ }
110
+ if (url.pathname === internal + '/events') {
111
+ response.writeHead(200, {
112
+ 'Content-Type': 'text/event-stream',
113
+ 'Cache-Control': 'no-store',
114
+ Connection: 'keep-alive',
115
+ });
116
+ streams.add(response);
117
+ response.write(`data: ${options.generation()}\n\n`);
118
+ response.on('close', () => streams.delete(response));
119
+ return;
120
+ }
121
+ if (url.pathname === internal + '/user') {
122
+ // Only same-origin browser navigation may switch the local identity.
123
+ if (request.headers['sec-fetch-site'] === 'cross-site') {
124
+ response.writeHead(403).end();
125
+ return;
126
+ }
127
+ const user = options.users.find((user) => user.id === url.searchParams.get('id'));
128
+ if (!user) {
129
+ response.writeHead(400).end('Unknown development user');
130
+ return;
131
+ }
132
+ response
133
+ .writeHead(303, {
134
+ Location: prefix + '/',
135
+ 'Set-Cookie': `${sessionCookie}=${encodeURIComponent(user.id)}; Path=${prefix}/; HttpOnly; SameSite=Strict`,
136
+ 'Cache-Control': 'no-store',
137
+ })
138
+ .end();
139
+ return;
140
+ }
141
+ if (url.pathname === internal + '/client.js') {
142
+ response.writeHead(200, {
143
+ 'Content-Type': 'text/javascript; charset=utf-8',
144
+ 'Cache-Control': 'no-store',
145
+ });
146
+ response.end(`(() => {
147
+ const version = new URL(document.currentScript.src).searchParams.get('version');
148
+ const events = new EventSource(${JSON.stringify(internal + '/events')});
149
+ events.onmessage = event => { if (event.data !== version) location.reload(); };
150
+ const bar = document.createElement('div');
151
+ bar.style.cssText = 'position:fixed;bottom:12px;right:12px;z-index:2147483647;background:#182924;color:white;padding:10px 14px;border-radius:10px;font:13px system-ui;box-shadow:0 2px 12px #0003';
152
+ const label = document.createElement('label'); label.textContent = 'SkillShare dev · ';
153
+ const select = document.createElement('select'); select.setAttribute('aria-label', 'Development user');
154
+ for (const user of ${JSON.stringify(options.users.map(({ id, displayName, isOwner, isAdmin }) => ({ id, displayName, isOwner, isAdmin })))}) {
155
+ const option = document.createElement('option'); option.value = user.id; option.textContent = user.displayName + ' (isOwner=' + user.isOwner + ', isAdmin=' + user.isAdmin + ')'; select.append(option);
156
+ }
157
+ select.value = new URL(document.currentScript.src).searchParams.get('user');
158
+ select.onchange = () => { location.href = ${JSON.stringify(internal + '/user?id=')} + encodeURIComponent(select.value); };
159
+ label.append(select); bar.append(label); document.body.append(bar);
160
+ })();`);
161
+ return;
162
+ }
163
+ response.writeHead(404).end();
164
+ return;
165
+ }
166
+ const path = pagePath(url.pathname);
167
+ if (!path) {
168
+ response.writeHead(404).end('Use ' + prefix + '/');
169
+ return;
170
+ }
171
+ const target = options.target();
172
+ if (!target) {
173
+ response.writeHead(500, {
174
+ 'Content-Type': 'text/html; charset=utf-8',
175
+ 'Cache-Control': 'no-store',
176
+ });
177
+ response.end(`<p>Page backend is rebuilding. Check your terminal for errors.</p><script src="${internal}/client.js?version=${options.generation()}&user=${encodeURIComponent(userFor(request).id)}"></script>`);
178
+ return;
179
+ }
180
+ const bypass = path === '/api/bypass-sso' || path.startsWith('/api/bypass-sso/');
181
+ if (!bypass &&
182
+ !['GET', 'HEAD'].includes(request.method ?? '') &&
183
+ (request.headers['sec-fetch-site'] === 'cross-site' ||
184
+ (request.headers.origin && request.headers.origin !== url.origin))) {
185
+ response.writeHead(403).end('Cross-origin mutation rejected');
186
+ return;
187
+ }
188
+ const user = bypass ? undefined : userFor(request);
189
+ const upstream = httpRequest(new URL(path + url.search, target), {
190
+ method: request.method,
191
+ headers: requestHeaders(request, user, url.origin + prefix + '/', options.slug),
192
+ }, (incoming) => {
193
+ const headers = cleanHeaders(incoming.headers);
194
+ headers['cache-control'] = 'private, no-cache';
195
+ if (headers['set-cookie'])
196
+ headers['set-cookie'] = responseCookies(headers['set-cookie'], prefix);
197
+ if (headers.location) {
198
+ if (headers.location.startsWith('/') &&
199
+ !headers.location.startsWith('//'))
200
+ headers.location = prefix + headers.location;
201
+ else if (headers.location.startsWith(target + '/'))
202
+ headers.location =
203
+ url.origin + prefix + headers.location.slice(target.length);
204
+ }
205
+ const inject = request.method !== 'HEAD' &&
206
+ incoming.statusCode === 200 &&
207
+ !headers['content-encoding'] &&
208
+ headers['content-type']?.includes('text/html');
209
+ const failed = () => {
210
+ if (!response.headersSent)
211
+ response.writeHead(502);
212
+ response.end();
213
+ };
214
+ incoming.on('error', failed);
215
+ if (inject) {
216
+ const chunks = [];
217
+ incoming.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
218
+ incoming.on('end', () => {
219
+ delete headers['content-length'];
220
+ delete headers.etag;
221
+ const html = Buffer.concat(chunks).toString('utf8');
222
+ const script = `<script src="${internal}/client.js?version=${options.generation()}&user=${encodeURIComponent(userFor(request).id)}"></script>`;
223
+ response.writeHead(incoming.statusCode, headers);
224
+ response.end(html.includes('</body>')
225
+ ? html.replace('</body>', script + '</body>')
226
+ : html + script);
227
+ });
228
+ }
229
+ else {
230
+ response.writeHead(incoming.statusCode ?? 502, headers);
231
+ incoming.pipe(response);
232
+ }
233
+ });
234
+ upstream.on('error', () => {
235
+ if (!response.headersSent)
236
+ response.writeHead(502);
237
+ response.end('Page connection failed');
238
+ });
239
+ response.on('close', () => upstream.destroy());
240
+ request.pipe(upstream);
241
+ });
242
+ server.on('upgrade', (request, socket, head) => {
243
+ const target = options.target();
244
+ const url = new URL(request.url, `http://${request.headers.host}`);
245
+ const path = pagePath(url.pathname);
246
+ if (!allowed(request) ||
247
+ !path ||
248
+ !target ||
249
+ (request.headers.origin && request.headers.origin !== url.origin)) {
250
+ socket.destroy();
251
+ return;
252
+ }
253
+ const bypass = path === '/api/bypass-sso' || path.startsWith('/api/bypass-sso/');
254
+ const headers = requestHeaders(request, bypass ? undefined : userFor(request), url.origin + prefix + '/', options.slug);
255
+ headers.connection = 'Upgrade';
256
+ headers.upgrade = request.headers.upgrade;
257
+ const upstream = httpRequest(new URL(path + url.search, target), {
258
+ headers,
259
+ });
260
+ upstream.on('upgrade', (incoming, peer, peerHead) => {
261
+ upgrades.add(socket);
262
+ upgrades.add(peer);
263
+ socket.on('close', () => {
264
+ upgrades.delete(socket);
265
+ peer.destroy();
266
+ });
267
+ peer.on('close', () => {
268
+ upgrades.delete(peer);
269
+ socket.destroy();
270
+ });
271
+ socket.on('error', () => peer.destroy());
272
+ peer.on('error', () => socket.destroy());
273
+ const responseHeaders = { ...incoming.headers };
274
+ if (responseHeaders['set-cookie'])
275
+ responseHeaders['set-cookie'] = responseCookies(responseHeaders['set-cookie'], prefix);
276
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(responseHeaders)
277
+ .flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map((v) => `${key}: ${v}`))
278
+ .join('\r\n')}\r\n\r\n`);
279
+ if (head.length)
280
+ peer.write(head);
281
+ if (peerHead.length)
282
+ socket.write(peerHead);
283
+ socket.pipe(peer).pipe(socket);
284
+ });
285
+ upstream.on('response', (incoming) => {
286
+ incoming.resume();
287
+ socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
288
+ });
289
+ upstream.on('error', () => socket.destroy());
290
+ socket.on('error', () => upstream.destroy());
291
+ upstream.end();
292
+ });
293
+ return {
294
+ server,
295
+ reload() {
296
+ for (const response of streams)
297
+ response.write(`data: ${options.generation()}\n\n`);
298
+ },
299
+ async close() {
300
+ for (const response of streams)
301
+ response.end();
302
+ for (const socket of upgrades)
303
+ socket.destroy();
304
+ server.closeAllConnections();
305
+ await new Promise((resolve) => server.close(() => resolve()));
306
+ },
307
+ };
308
+ }
@@ -0,0 +1,67 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { gunzipSync } from 'node:zlib';
6
+ const release = '0.4.1';
7
+ const platforms = {
8
+ 'darwin-arm64': [
9
+ 'aarch64-apple-darwin',
10
+ '95c689769f66c08fd0d191fbfc731adb4c8340b49678b11979698ecf2e73393d',
11
+ ],
12
+ 'linux-x64': [
13
+ 'x86_64-unknown-linux-gnu',
14
+ '7b42a410e340bca4dbadd08ecb7f8983854aa855aa7467c29ab0e08b8b7f2007',
15
+ ],
16
+ 'linux-arm64': [
17
+ 'aarch64-unknown-linux-gnu',
18
+ '5cc2281493a896b2cc7c7b6c46b2188753832d6b028457338c8eea774a1dd9a1',
19
+ ],
20
+ };
21
+ export async function ensureCelld(override, signal) {
22
+ if (override)
23
+ return override.includes('/') ? resolve(override) : override;
24
+ const platform = platforms[`${process.platform}-${process.arch}`];
25
+ if (!platform)
26
+ throw new Error('celld supports macOS Apple Silicon and Linux x64/ARM64. On Windows, use WSL2.');
27
+ const [target, checksum] = platform;
28
+ const directory = join(homedir(), '.cache', 'skillshare', 'celld', release, target);
29
+ const binary = join(directory, 'celld');
30
+ const archivePath = join(directory, 'celld.gz');
31
+ let archive;
32
+ try {
33
+ archive = await readFile(archivePath);
34
+ }
35
+ catch {
36
+ archive = Buffer.alloc(0);
37
+ }
38
+ if (createHash('sha256').update(archive).digest('hex') !== checksum) {
39
+ console.log(`Downloading celld ${release} (${target})…`);
40
+ const response = await fetch(`https://github.com/denoland/celld/releases/download/v${release}/celld-${target}.gz`, {
41
+ signal: signal ?? AbortSignal.timeout(120_000),
42
+ });
43
+ if (!response.ok)
44
+ throw new Error(`celld download failed: HTTP ${response.status}`);
45
+ archive = Buffer.from(await response.arrayBuffer());
46
+ if (createHash('sha256').update(archive).digest('hex') !== checksum)
47
+ throw new Error('celld checksum mismatch');
48
+ }
49
+ await mkdir(directory, { recursive: true });
50
+ const unpacked = gunzipSync(archive);
51
+ try {
52
+ if ((await readFile(binary)).equals(unpacked))
53
+ return binary;
54
+ }
55
+ catch { }
56
+ const temporary = `${binary}.${process.pid}.tmp`;
57
+ try {
58
+ await writeFile(temporary, unpacked);
59
+ await chmod(temporary, 0o755);
60
+ await rename(temporary, binary);
61
+ await writeFile(archivePath, archive);
62
+ }
63
+ finally {
64
+ await rm(temporary, { force: true });
65
+ }
66
+ return binary;
67
+ }
@@ -0,0 +1,363 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { once } from 'node:events';
4
+ import { access, realpath, readFile } from 'node:fs/promises';
5
+ import { createRequire } from 'node:module';
6
+ import { createServer } from 'node:net';
7
+ import { basename, delimiter, dirname, relative, resolve } from 'node:path';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { watch } from 'chokidar';
10
+ import { ensureCelld } from './pages-dev-tools.js';
11
+ import { createDevProxy } from './pages-dev-proxy.js';
12
+ export function createDevUsers(options = {}) {
13
+ const users = [{
14
+ id: options.userId ?? '1',
15
+ displayName: options.userName ?? 'Alice Morgan',
16
+ email: options.userEmail ?? 'alice@example.test',
17
+ isOwner: true, isAdmin: false,
18
+ }];
19
+ let nextId = 2;
20
+ for (const user of [
21
+ { displayName: 'Bob Taylor', email: 'bob@example.test', isOwner: false, isAdmin: false },
22
+ { displayName: 'Charlie Parker', email: 'charlie@example.test', isOwner: false, isAdmin: true },
23
+ { displayName: 'Dana Wilson', email: 'dana@example.test', isOwner: true, isAdmin: true },
24
+ ]) {
25
+ while (users.some(existing => existing.id === String(nextId)))
26
+ nextId++;
27
+ users.push({ id: String(nextId++), ...user });
28
+ }
29
+ return users;
30
+ }
31
+ async function freePort() {
32
+ const server = createServer();
33
+ server.listen(0, '127.0.0.1');
34
+ await once(server, 'listening');
35
+ const port = server.address().port;
36
+ await new Promise((resolve) => server.close(() => resolve()));
37
+ return port;
38
+ }
39
+ async function stop(child) {
40
+ if (!child || child.exitCode !== null || child.signalCode !== null)
41
+ return;
42
+ const exited = once(child, 'exit').catch(() => { });
43
+ const signal = (value) => {
44
+ try {
45
+ if (child.pid)
46
+ process.kill(-child.pid, value);
47
+ }
48
+ catch {
49
+ child.kill(value);
50
+ }
51
+ };
52
+ signal('SIGTERM');
53
+ const timer = setTimeout(() => signal('SIGKILL'), 5000);
54
+ try {
55
+ await exited;
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ export async function startPageDev(options) {
62
+ const requestedDirectory = resolve(options.directory);
63
+ const directory = await realpath(requestedDirectory).catch(async () => resolve(await realpath(dirname(requestedDirectory)), basename(requestedDirectory)));
64
+ const watchDirectory = await realpath(resolve(options.watchDirectory ?? directory));
65
+ const slug = options.slug ??
66
+ (basename(directory) === 'dist'
67
+ ? basename(dirname(directory))
68
+ : basename(directory));
69
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(slug))
70
+ throw new Error('Use --slug with 1–64 lowercase letters, numbers or hyphens.');
71
+ const port = options.port ?? 8787;
72
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
73
+ throw new Error('Invalid --port (expected 1–65535).');
74
+ const executable = await ensureCelld(options.celld ?? process.env.CELLD_EXECUTABLE, options.signal);
75
+ options.signal?.throwIfAborted();
76
+ const esbuildDirectory = dirname(createRequire(import.meta.url).resolve('esbuild/bin/esbuild'));
77
+ // Do not inherit host/CLI tokens or cloud storage credentials into application code.
78
+ const env = {
79
+ PATH: `${esbuildDirectory}${delimiter}${process.env.PATH ?? ''}`,
80
+ };
81
+ for (const key of [
82
+ 'HOME',
83
+ 'TMPDIR',
84
+ 'TMP',
85
+ 'TEMP',
86
+ 'LANG',
87
+ 'SSL_CERT_FILE',
88
+ 'SSL_CERT_DIR',
89
+ ])
90
+ if (process.env[key])
91
+ env[key] = process.env[key];
92
+ const users = createDevUsers(options);
93
+ let generation = 0;
94
+ let lastError;
95
+ let address;
96
+ let node;
97
+ let build;
98
+ let closing = false;
99
+ let pending = false;
100
+ let work;
101
+ let debounce;
102
+ const proxy = createDevProxy({
103
+ slug,
104
+ users,
105
+ target: () => address,
106
+ generation: () => generation,
107
+ });
108
+ proxy.server.listen(port, '127.0.0.1');
109
+ await once(proxy.server, 'listening');
110
+ const publicPort = proxy.server.address().port;
111
+ const url = `http://127.0.0.1:${publicPort}/p/${slug}/`;
112
+ async function rebuild() {
113
+ lastError = undefined;
114
+ try {
115
+ if (options.build) {
116
+ console.log(`Building: ${options.build}`);
117
+ build = spawn(options.build, {
118
+ cwd: watchDirectory,
119
+ shell: true,
120
+ stdio: 'inherit',
121
+ detached: true,
122
+ env: process.env,
123
+ });
124
+ const [code] = await once(build, 'exit');
125
+ build = undefined;
126
+ if (closing)
127
+ return;
128
+ if (code !== 0)
129
+ throw new Error('Build failed. Fix the source; watching for changes.');
130
+ }
131
+ const configurations = await Promise.all(['wrangler.json', 'wrangler.jsonc'].map(async (name) => {
132
+ try {
133
+ await access(resolve(directory, name));
134
+ return name;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }));
140
+ if (configurations.filter(Boolean).length !== 1)
141
+ throw new Error('The application directory must contain exactly one wrangler.json or wrangler.jsonc.');
142
+ if (closing)
143
+ return;
144
+ address = undefined;
145
+ const previous = node;
146
+ node = undefined;
147
+ await stop(previous);
148
+ if (closing)
149
+ return;
150
+ const workerPort = await freePort();
151
+ if (closing)
152
+ return;
153
+ const child = (node = spawn(executable, [
154
+ 'dev',
155
+ directory,
156
+ '--host',
157
+ '127.0.0.1',
158
+ '--port',
159
+ String(workerPort),
160
+ '--no-watch',
161
+ ], {
162
+ cwd: directory,
163
+ env,
164
+ stdio: ['ignore', 'inherit', 'inherit'],
165
+ detached: true,
166
+ }));
167
+ let spawnError;
168
+ child.on('error', (error) => {
169
+ spawnError = error;
170
+ });
171
+ const target = `http://127.0.0.1:${workerPort}`;
172
+ for (let attempt = 0; attempt < 150; attempt++) {
173
+ if (closing)
174
+ return;
175
+ if (spawnError)
176
+ throw spawnError;
177
+ if (child.exitCode !== null || child.signalCode !== null)
178
+ throw new Error('celld exited. Fix the project; watching for changes.');
179
+ try {
180
+ const result = await fetch(target + '/.well-known/celld/health', {
181
+ signal: AbortSignal.timeout(1000),
182
+ });
183
+ await result.body?.cancel();
184
+ if (result.ok) {
185
+ address = target;
186
+ generation++;
187
+ proxy.reload();
188
+ child.on('exit', () => {
189
+ if (node === child && !closing) {
190
+ address = undefined;
191
+ console.error('celld stopped. Waiting for a source change to restart.');
192
+ }
193
+ });
194
+ console.log(`Ready: ${url}`);
195
+ return;
196
+ }
197
+ }
198
+ catch { }
199
+ await delay(200);
200
+ }
201
+ throw new Error('celld did not become ready within the startup timeout.');
202
+ }
203
+ catch (error) {
204
+ lastError = error instanceof Error ? error.message : String(error);
205
+ if (!closing)
206
+ console.error(lastError);
207
+ // A failed build leaves the previous node serving. A failed node is disposed.
208
+ if (!address) {
209
+ await stop(node);
210
+ node = undefined;
211
+ }
212
+ }
213
+ }
214
+ function schedule() {
215
+ pending = true;
216
+ if (work || closing)
217
+ return;
218
+ work = (async () => {
219
+ while (pending && !closing) {
220
+ pending = false;
221
+ await rebuild();
222
+ }
223
+ })().finally(() => {
224
+ work = undefined;
225
+ });
226
+ }
227
+ const output = directory !== watchDirectory && options.build ? directory : undefined;
228
+ const watcher = watch(watchDirectory, {
229
+ ignoreInitial: true,
230
+ ignored(path) {
231
+ const absolute = resolve(path);
232
+ if (output && (absolute === output || absolute.startsWith(output + '/')))
233
+ return true;
234
+ return relative(watchDirectory, absolute)
235
+ .split(/[\\/]/)
236
+ .some((part) => [
237
+ 'node_modules',
238
+ '.git',
239
+ '.celld',
240
+ '.wrangler',
241
+ '.skillshare',
242
+ 'dist',
243
+ 'coverage',
244
+ ].includes(part));
245
+ },
246
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
247
+ });
248
+ const ready = once(watcher, 'ready', { signal: options.signal });
249
+ watcher.on('error', (error) => console.error('File watcher:', error));
250
+ const fingerprints = new Map();
251
+ async function fingerprint(path) {
252
+ try {
253
+ return createHash('sha256')
254
+ .update(await readFile(path))
255
+ .digest('hex');
256
+ }
257
+ catch {
258
+ return '';
259
+ }
260
+ }
261
+ watcher.on('all', async (event, path) => {
262
+ if (event === 'addDir' || event === 'unlinkDir' || closing)
263
+ return;
264
+ const digest = await fingerprint(path);
265
+ if (closing || fingerprints.get(path) === digest)
266
+ return;
267
+ fingerprints.set(path, digest);
268
+ console.log(`Changed: ${relative(watchDirectory, path)}`);
269
+ clearTimeout(debounce);
270
+ debounce = setTimeout(schedule, 150);
271
+ });
272
+ let closePromise;
273
+ function close() {
274
+ return (closePromise ??= (async () => {
275
+ closing = true;
276
+ options.signal?.removeEventListener('abort', onAbort);
277
+ clearTimeout(debounce);
278
+ await watcher.close();
279
+ await stop(build);
280
+ await stop(node);
281
+ await work;
282
+ await proxy.close();
283
+ })());
284
+ }
285
+ const onAbort = () => {
286
+ void close();
287
+ };
288
+ options.signal?.addEventListener('abort', onAbort, { once: true });
289
+ if (options.signal?.aborted) {
290
+ await close();
291
+ options.signal.throwIfAborted();
292
+ }
293
+ try {
294
+ await ready;
295
+ await Promise.all(Object.entries(watcher.getWatched()).flatMap(([directory, names]) => names.map(async (name) => {
296
+ const path = resolve(directory, name);
297
+ fingerprints.set(path, await fingerprint(path));
298
+ })));
299
+ console.log(`SkillShare Pages development: ${url}\nWatching ${watchDirectory}\nSwitch users using the development toolbar. Ctrl+C to stop.`);
300
+ schedule();
301
+ await work;
302
+ return {
303
+ url,
304
+ close,
305
+ get generation() {
306
+ return generation;
307
+ },
308
+ get error() {
309
+ return lastError;
310
+ },
311
+ };
312
+ }
313
+ catch (error) {
314
+ await close();
315
+ throw error;
316
+ }
317
+ }
318
+ export async function runPageDev(directory, flags) {
319
+ const allowed = new Set([
320
+ 'watch-dir',
321
+ 'build',
322
+ 'slug',
323
+ 'port',
324
+ 'celld',
325
+ 'user-id',
326
+ 'user-name',
327
+ 'user-email',
328
+ ]);
329
+ for (const name of Object.keys(flags))
330
+ if (!allowed.has(name))
331
+ throw new Error(`Unknown pages dev option --${name}`);
332
+ const controller = new AbortController();
333
+ const shutdown = () => controller.abort();
334
+ process.once('SIGINT', shutdown);
335
+ process.once('SIGTERM', shutdown);
336
+ try {
337
+ const development = await startPageDev({
338
+ directory,
339
+ watchDirectory: flags['watch-dir'],
340
+ build: flags.build,
341
+ slug: flags.slug,
342
+ port: flags.port ? Number(flags.port) : undefined,
343
+ celld: flags.celld,
344
+ userId: flags['user-id'],
345
+ userName: flags['user-name'],
346
+ userEmail: flags['user-email'],
347
+ signal: controller.signal,
348
+ });
349
+ if (!controller.signal.aborted)
350
+ await new Promise((resolve) => controller.signal.addEventListener('abort', () => resolve(), {
351
+ once: true,
352
+ }));
353
+ await development.close();
354
+ }
355
+ catch (error) {
356
+ if (!controller.signal.aborted)
357
+ throw error;
358
+ }
359
+ finally {
360
+ process.removeListener('SIGINT', shutdown);
361
+ process.removeListener('SIGTERM', shutdown);
362
+ }
363
+ }
package/dist/pages.js ADDED
@@ -0,0 +1,51 @@
1
+ import { builtinModules } from 'node:module';
2
+ import { build } from 'esbuild';
3
+ import { parse, printParseErrorCode } from 'jsonc-parser';
4
+ import { resolve, relative } from 'node:path';
5
+ export async function preparePageFiles(directory, files) {
6
+ const configurations = files.filter(file => file.relativePath === 'wrangler.json' || file.relativePath === 'wrangler.jsonc');
7
+ if (configurations.length > 1)
8
+ throw new Error('Publish only one of wrangler.json and wrangler.jsonc.');
9
+ const configFile = configurations[0];
10
+ if (!configFile) {
11
+ if (!files.some(file => file.relativePath === 'index.html'))
12
+ throw new Error('The page directory must contain index.html or a Wrangler configuration at its root.');
13
+ return files;
14
+ }
15
+ const errors = [];
16
+ const config = parse(configFile.content.toString('utf8'), errors, { allowTrailingComma: true });
17
+ if (errors.length || !config || typeof config !== 'object' || Array.isArray(config))
18
+ throw new Error(`Invalid ${configFile.relativePath}: ${errors.map(error => printParseErrorCode(error.error)).join(', ')}`);
19
+ if (!config.main || config.no_bundle === true)
20
+ return files;
21
+ if (typeof config.main !== 'string' || !files.some(file => file.relativePath === config.main.replace(/^\.\//, '')))
22
+ throw new Error('Wrangler main must name a file inside the uploaded project.');
23
+ if (files.some(file => file.relativePath.startsWith('__skillshare/')))
24
+ throw new Error('The __skillshare directory is reserved for the compiled Worker.');
25
+ const outputDirectory = resolve(directory, '__skillshare');
26
+ const output = await build({
27
+ absWorkingDir: directory,
28
+ entryPoints: [config.main],
29
+ outdir: outputDirectory,
30
+ entryNames: 'worker',
31
+ bundle: true,
32
+ write: false,
33
+ format: 'esm',
34
+ platform: 'browser',
35
+ target: 'es2024',
36
+ conditions: ['workerd', 'worker', 'browser'],
37
+ external: ['cloudflare:*', 'node:*', ...builtinModules],
38
+ loader: { '.wasm': 'copy' },
39
+ });
40
+ config.main = '__skillshare/worker.js';
41
+ // Native celld still performs its own validation/bundling, including sibling WASM modules.
42
+ const prepared = files.map(file => file === configFile
43
+ ? { ...file, content: Buffer.from(JSON.stringify(config, null, 2)) } : file);
44
+ for (const file of output.outputFiles)
45
+ prepared.push({
46
+ relativePath: relative(directory, file.path).replaceAll('\\', '/'),
47
+ content: Buffer.from(file.contents),
48
+ contentType: file.path.endsWith('.wasm') ? 'application/wasm' : 'text/javascript; charset=utf-8',
49
+ });
50
+ return prepared;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "dev": "tsx index.ts",
16
16
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
17
17
  "start": "node ./dist/index.js",
18
- "prepublishOnly": "npm run build"
18
+ "prepublishOnly": "npm run build",
19
+ "test": "tsx --test pages.test.ts pages-dev.test.ts"
19
20
  },
20
21
  "publishConfig": {
21
22
  "access": "public"
@@ -29,6 +30,9 @@
29
30
  "node": ">=20"
30
31
  },
31
32
  "dependencies": {
32
- "@clack/prompts": "^1.7.0"
33
+ "@clack/prompts": "^1.7.0",
34
+ "chokidar": "4.0.3",
35
+ "esbuild": "0.28.2",
36
+ "jsonc-parser": "3.3.1"
33
37
  }
34
38
  }