@quadient/skillshare 1.5.0 → 1.6.1

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
@@ -217,3 +217,72 @@ Wrangler JSON/JSONC projects. The CLI bundles Worker dependencies locally; insta
217
217
  them and build frontend assets before uploading. Inspect process status and logs
218
218
  with `skillshare pages runtime <slug>`. Pages are served below `/p/<slug>/`, so use
219
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.
279
+
280
+ ### Worker user directory (1.6.1)
281
+
282
+ Authenticated requests in `pages dev` include `X-Skillshare-Users-Url` and
283
+ `X-Skillshare-Users-Token`. A Worker can fetch the URL with the token in the
284
+ `X-Skillshare-Users-Token` request header to read the four local users. Each entry
285
+ contains `id`, `displayName`, `email`, `isAdmin` and `isOwner`. The hosted backend
286
+ uses the same contract for active SkillShare users. These capabilities stay in
287
+ the Worker and are not injected on `api/bypass-sso/*` requests. See the
288
+ [Worker example](../docs/pages-backends.md#skillshare-user-directory-from-a-worker).
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { preparePageFiles } from './pages.js';
2
+ import { runPageDev } from './pages-dev.js';
2
3
  import { createServer } from 'node:http';
3
4
  import { createHash, randomBytes } from 'node:crypto';
4
5
  import { spawn } from 'node:child_process';
@@ -82,6 +83,9 @@ Commands:
82
83
  pages list
83
84
  pages show <slug>
84
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>]
85
89
  git configure [local]
86
90
  git unconfigure [local]
87
91
 
@@ -100,6 +104,10 @@ async function main() {
100
104
  process.exit(0);
101
105
  }
102
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
+ }
103
111
  switch (command) {
104
112
  case 'login': {
105
113
  await login(options.positionals[0] === 'local' ? LOCAL_URL : PROD_URL);
@@ -0,0 +1,330 @@
1
+ import { createServer, request as httpRequest, } from 'node:http';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ const sessionCookie = 'skillshare.dev-user';
4
+ const internal = '/__skillshare_dev';
5
+ const hostCookie = (name) => name.startsWith('skillshare.auth') ||
6
+ name.startsWith('.AspNetCore.') ||
7
+ name === sessionCookie;
8
+ const hopHeaders = [
9
+ 'connection',
10
+ 'keep-alive',
11
+ 'proxy-authenticate',
12
+ 'proxy-authorization',
13
+ 'te',
14
+ 'trailer',
15
+ 'transfer-encoding',
16
+ 'upgrade',
17
+ ];
18
+ function cleanHeaders(headers) {
19
+ const result = { ...headers };
20
+ for (const name of [
21
+ ...hopHeaders,
22
+ ...(headers.connection ?? '')
23
+ .split(',')
24
+ .map((name) => name.trim().toLowerCase()),
25
+ ])
26
+ delete result[name];
27
+ return result;
28
+ }
29
+ export function requestHeaders(request, user, baseUrl, slug, usersToken) {
30
+ const headers = cleanHeaders(request.headers);
31
+ for (const name of Object.keys(headers))
32
+ if (name.startsWith('x-skillshare-') ||
33
+ name.startsWith('x-forwarded-') ||
34
+ name === 'forwarded')
35
+ delete headers[name];
36
+ delete headers.host;
37
+ delete headers.cookie;
38
+ headers['accept-encoding'] = 'identity';
39
+ if (user) {
40
+ delete headers.authorization;
41
+ if (usersToken) {
42
+ headers['x-skillshare-users-url'] = new URL(`/api/pages/${encodeURIComponent(slug)}/users-directory`, baseUrl).href;
43
+ headers['x-skillshare-users-token'] = usersToken;
44
+ }
45
+ headers['x-skillshare-user'] = Buffer.from(JSON.stringify(user)).toString('base64');
46
+ const cookies = (request.headers.cookie ?? '')
47
+ .split(';')
48
+ .map((value) => value.trim())
49
+ .filter((value) => value && !hostCookie(value.split('=')[0]));
50
+ if (cookies.length)
51
+ headers.cookie = cookies.join('; ');
52
+ }
53
+ headers['x-skillshare-page'] = slug;
54
+ headers['x-skillshare-revision'] = '0';
55
+ headers['x-skillshare-base-url'] = baseUrl;
56
+ return headers;
57
+ }
58
+ export function responseCookies(cookies, prefix) {
59
+ return cookies
60
+ .filter((cookie) => !hostCookie(cookie.split('=')[0].trim()))
61
+ .map((cookie) => {
62
+ let path = '/';
63
+ const fields = cookie.split(';').filter((field, index) => {
64
+ if (!index)
65
+ return true;
66
+ if (/^\s*domain\s*=/i.test(field))
67
+ return false;
68
+ if (/^\s*path\s*=/i.test(field)) {
69
+ path = field.slice(field.indexOf('=') + 1).trim();
70
+ return false;
71
+ }
72
+ return true;
73
+ });
74
+ return (fields.join(';') +
75
+ '; Path=' +
76
+ prefix +
77
+ (path.startsWith('/') ? path : '/'));
78
+ });
79
+ }
80
+ export function createDevProxy(options) {
81
+ const prefix = `/p/${options.slug}`;
82
+ const usersToken = randomBytes(32).toString('hex');
83
+ const streams = new Set();
84
+ const upgrades = new Set();
85
+ const userFor = (request) => {
86
+ const id = (request.headers.cookie ?? '')
87
+ .split(';')
88
+ .map((value) => value.trim())
89
+ .find((value) => value.startsWith(sessionCookie + '='))
90
+ ?.slice(sessionCookie.length + 1);
91
+ return (options.users.find((user) => encodeURIComponent(user.id) === id) ??
92
+ options.users[0]);
93
+ };
94
+ function pagePath(path) {
95
+ return path.startsWith(prefix + '/') ? path.slice(prefix.length) : undefined;
96
+ }
97
+ function allowed(request) {
98
+ const host = request.headers.host ?? '';
99
+ return /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
100
+ }
101
+ const server = createServer((request, response) => {
102
+ if (!allowed(request)) {
103
+ response.writeHead(403).end('Local requests only');
104
+ return;
105
+ }
106
+ const url = new URL(request.url, `http://${request.headers.host}`);
107
+ if (url.pathname === `/api/pages/${encodeURIComponent(options.slug)}/users-directory`) {
108
+ response.setHeader('Cache-Control', 'no-store');
109
+ const token = request.headers['x-skillshare-users-token'];
110
+ if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token) ||
111
+ !timingSafeEqual(Buffer.from(token), Buffer.from(usersToken))) {
112
+ response.writeHead(401).end();
113
+ return;
114
+ }
115
+ if (request.method !== 'GET') {
116
+ response.writeHead(405).end();
117
+ return;
118
+ }
119
+ response.writeHead(200, { 'Content-Type': 'application/json' });
120
+ response.end(JSON.stringify(options.users.map(({ id, displayName, email, isAdmin, isOwner }) => ({ id, displayName, email, isAdmin, isOwner }))));
121
+ return;
122
+ }
123
+ if (url.pathname === '/' || url.pathname === prefix) {
124
+ response.writeHead(307, { Location: prefix + '/' + url.search }).end();
125
+ return;
126
+ }
127
+ if (url.pathname.startsWith(internal + '/')) {
128
+ if (request.method !== 'GET') {
129
+ response.writeHead(405).end();
130
+ return;
131
+ }
132
+ if (url.pathname === internal + '/events') {
133
+ response.writeHead(200, {
134
+ 'Content-Type': 'text/event-stream',
135
+ 'Cache-Control': 'no-store',
136
+ Connection: 'keep-alive',
137
+ });
138
+ streams.add(response);
139
+ response.write(`data: ${options.generation()}\n\n`);
140
+ response.on('close', () => streams.delete(response));
141
+ return;
142
+ }
143
+ if (url.pathname === internal + '/user') {
144
+ // Only same-origin browser navigation may switch the local identity.
145
+ if (request.headers['sec-fetch-site'] === 'cross-site') {
146
+ response.writeHead(403).end();
147
+ return;
148
+ }
149
+ const user = options.users.find((user) => user.id === url.searchParams.get('id'));
150
+ if (!user) {
151
+ response.writeHead(400).end('Unknown development user');
152
+ return;
153
+ }
154
+ response
155
+ .writeHead(303, {
156
+ Location: prefix + '/',
157
+ 'Set-Cookie': `${sessionCookie}=${encodeURIComponent(user.id)}; Path=${prefix}/; HttpOnly; SameSite=Strict`,
158
+ 'Cache-Control': 'no-store',
159
+ })
160
+ .end();
161
+ return;
162
+ }
163
+ if (url.pathname === internal + '/client.js') {
164
+ response.writeHead(200, {
165
+ 'Content-Type': 'text/javascript; charset=utf-8',
166
+ 'Cache-Control': 'no-store',
167
+ });
168
+ response.end(`(() => {
169
+ const version = new URL(document.currentScript.src).searchParams.get('version');
170
+ const events = new EventSource(${JSON.stringify(internal + '/events')});
171
+ events.onmessage = event => { if (event.data !== version) location.reload(); };
172
+ const bar = document.createElement('div');
173
+ 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';
174
+ const label = document.createElement('label'); label.textContent = 'SkillShare dev · ';
175
+ const select = document.createElement('select'); select.setAttribute('aria-label', 'Development user');
176
+ for (const user of ${JSON.stringify(options.users.map(({ id, displayName, isOwner, isAdmin }) => ({ id, displayName, isOwner, isAdmin })))}) {
177
+ const option = document.createElement('option'); option.value = user.id; option.textContent = user.displayName + ' (isOwner=' + user.isOwner + ', isAdmin=' + user.isAdmin + ')'; select.append(option);
178
+ }
179
+ select.value = new URL(document.currentScript.src).searchParams.get('user');
180
+ select.onchange = () => { location.href = ${JSON.stringify(internal + '/user?id=')} + encodeURIComponent(select.value); };
181
+ label.append(select); bar.append(label); document.body.append(bar);
182
+ })();`);
183
+ return;
184
+ }
185
+ response.writeHead(404).end();
186
+ return;
187
+ }
188
+ const path = pagePath(url.pathname);
189
+ if (!path) {
190
+ response.writeHead(404).end('Use ' + prefix + '/');
191
+ return;
192
+ }
193
+ const target = options.target();
194
+ if (!target) {
195
+ response.writeHead(500, {
196
+ 'Content-Type': 'text/html; charset=utf-8',
197
+ 'Cache-Control': 'no-store',
198
+ });
199
+ 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>`);
200
+ return;
201
+ }
202
+ const bypass = path === '/api/bypass-sso' || path.startsWith('/api/bypass-sso/');
203
+ if (!bypass &&
204
+ !['GET', 'HEAD'].includes(request.method ?? '') &&
205
+ (request.headers['sec-fetch-site'] === 'cross-site' ||
206
+ (request.headers.origin && request.headers.origin !== url.origin))) {
207
+ response.writeHead(403).end('Cross-origin mutation rejected');
208
+ return;
209
+ }
210
+ const user = bypass ? undefined : userFor(request);
211
+ const upstream = httpRequest(new URL(path + url.search, target), {
212
+ method: request.method,
213
+ headers: requestHeaders(request, user, url.origin + prefix + '/', options.slug, usersToken),
214
+ }, (incoming) => {
215
+ const headers = cleanHeaders(incoming.headers);
216
+ headers['cache-control'] = 'private, no-cache';
217
+ if (headers['set-cookie'])
218
+ headers['set-cookie'] = responseCookies(headers['set-cookie'], prefix);
219
+ if (headers.location) {
220
+ if (headers.location.startsWith('/') &&
221
+ !headers.location.startsWith('//'))
222
+ headers.location = prefix + headers.location;
223
+ else if (headers.location.startsWith(target + '/'))
224
+ headers.location =
225
+ url.origin + prefix + headers.location.slice(target.length);
226
+ }
227
+ const inject = request.method !== 'HEAD' &&
228
+ incoming.statusCode === 200 &&
229
+ !headers['content-encoding'] &&
230
+ headers['content-type']?.includes('text/html');
231
+ const failed = () => {
232
+ if (!response.headersSent)
233
+ response.writeHead(502);
234
+ response.end();
235
+ };
236
+ incoming.on('error', failed);
237
+ if (inject) {
238
+ const chunks = [];
239
+ incoming.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
240
+ incoming.on('end', () => {
241
+ delete headers['content-length'];
242
+ delete headers.etag;
243
+ const html = Buffer.concat(chunks).toString('utf8');
244
+ const script = `<script src="${internal}/client.js?version=${options.generation()}&user=${encodeURIComponent(userFor(request).id)}"></script>`;
245
+ response.writeHead(incoming.statusCode, headers);
246
+ response.end(html.includes('</body>')
247
+ ? html.replace('</body>', script + '</body>')
248
+ : html + script);
249
+ });
250
+ }
251
+ else {
252
+ response.writeHead(incoming.statusCode ?? 502, headers);
253
+ incoming.pipe(response);
254
+ }
255
+ });
256
+ upstream.on('error', () => {
257
+ if (!response.headersSent)
258
+ response.writeHead(502);
259
+ response.end('Page connection failed');
260
+ });
261
+ response.on('close', () => upstream.destroy());
262
+ request.pipe(upstream);
263
+ });
264
+ server.on('upgrade', (request, socket, head) => {
265
+ const target = options.target();
266
+ const url = new URL(request.url, `http://${request.headers.host}`);
267
+ const path = pagePath(url.pathname);
268
+ if (!allowed(request) ||
269
+ !path ||
270
+ !target ||
271
+ (request.headers.origin && request.headers.origin !== url.origin)) {
272
+ socket.destroy();
273
+ return;
274
+ }
275
+ const bypass = path === '/api/bypass-sso' || path.startsWith('/api/bypass-sso/');
276
+ const headers = requestHeaders(request, bypass ? undefined : userFor(request), url.origin + prefix + '/', options.slug, usersToken);
277
+ headers.connection = 'Upgrade';
278
+ headers.upgrade = request.headers.upgrade;
279
+ const upstream = httpRequest(new URL(path + url.search, target), {
280
+ headers,
281
+ });
282
+ upstream.on('upgrade', (incoming, peer, peerHead) => {
283
+ upgrades.add(socket);
284
+ upgrades.add(peer);
285
+ socket.on('close', () => {
286
+ upgrades.delete(socket);
287
+ peer.destroy();
288
+ });
289
+ peer.on('close', () => {
290
+ upgrades.delete(peer);
291
+ socket.destroy();
292
+ });
293
+ socket.on('error', () => peer.destroy());
294
+ peer.on('error', () => socket.destroy());
295
+ const responseHeaders = { ...incoming.headers };
296
+ if (responseHeaders['set-cookie'])
297
+ responseHeaders['set-cookie'] = responseCookies(responseHeaders['set-cookie'], prefix);
298
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(responseHeaders)
299
+ .flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map((v) => `${key}: ${v}`))
300
+ .join('\r\n')}\r\n\r\n`);
301
+ if (head.length)
302
+ peer.write(head);
303
+ if (peerHead.length)
304
+ socket.write(peerHead);
305
+ socket.pipe(peer).pipe(socket);
306
+ });
307
+ upstream.on('response', (incoming) => {
308
+ incoming.resume();
309
+ socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
310
+ });
311
+ upstream.on('error', () => socket.destroy());
312
+ socket.on('error', () => upstream.destroy());
313
+ upstream.end();
314
+ });
315
+ return {
316
+ server,
317
+ reload() {
318
+ for (const response of streams)
319
+ response.write(`data: ${options.generation()}\n\n`);
320
+ },
321
+ async close() {
322
+ for (const response of streams)
323
+ response.end();
324
+ for (const socket of upgrades)
325
+ socket.destroy();
326
+ server.closeAllConnections();
327
+ await new Promise((resolve) => server.close(() => resolve()));
328
+ },
329
+ };
330
+ }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
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"
@@ -30,6 +31,7 @@
30
31
  },
31
32
  "dependencies": {
32
33
  "@clack/prompts": "^1.7.0",
34
+ "chokidar": "4.0.3",
33
35
  "esbuild": "0.28.2",
34
36
  "jsonc-parser": "3.3.1"
35
37
  }