@milesroxas/create-design-hub 0.0.0-stage → 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -2
  3. package/index.mjs +154 -0
  4. package/lib.mjs +65 -0
  5. package/package.json +37 -3
  6. package/setup/.fallowrc.jsonc +6 -0
  7. package/setup/app.html +861 -0
  8. package/setup/lib.mjs +147 -0
  9. package/setup/lib.test.mjs +96 -0
  10. package/setup/server.mjs +444 -0
  11. package/setup/server.test.mjs +99 -0
  12. package/setup/slug.mjs +15 -0
  13. package/template/.claude/launch.json +18 -0
  14. package/template/.claude/settings.json +35 -0
  15. package/template/.design-hub/checks.json +9 -0
  16. package/template/.env.example +28 -0
  17. package/template/.impeccable/config.json +16 -0
  18. package/template/AGENTS.md +133 -0
  19. package/template/CLAUDE.md +1 -0
  20. package/template/DESIGN.md +126 -0
  21. package/template/Design Hub.command +11 -0
  22. package/template/PRODUCT.md +65 -0
  23. package/template/README.md +20 -0
  24. package/template/_gitattributes +2 -0
  25. package/template/_gitignore +41 -0
  26. package/template/api/review.ts +5 -0
  27. package/template/docs/.obsidian/app.json +6 -0
  28. package/template/docs/.obsidian/appearance.json +1 -0
  29. package/template/docs/.obsidian/core-plugins.json +32 -0
  30. package/template/docs/README.md +19 -0
  31. package/template/docs/sprints/sample/guide.md +69 -0
  32. package/template/kit/AppShell.tsx +149 -0
  33. package/template/kit/SiteShell.tsx +75 -0
  34. package/template/kit/app-shell.css +70 -0
  35. package/template/kit/icons.ts +11 -0
  36. package/template/kit/shell.ts +59 -0
  37. package/template/kit/site-shell.css +27 -0
  38. package/template/kit/tokens.css +249 -0
  39. package/template/package.json +38 -0
  40. package/template/plans/README.md +11 -0
  41. package/template/prototypes/sample-list/README.md +51 -0
  42. package/template/prototypes/sample-list/fixtures.json +87 -0
  43. package/template/prototypes/sample-list/index.tsx +122 -0
  44. package/template/prototypes/sample-list/styles.css +25 -0
  45. package/template/proxy.ts +4 -0
  46. package/template/sketches/paper/README.md +3 -0
  47. package/template/tsconfig.json +10 -0
  48. package/template/vercel.json +49 -0
package/setup/lib.mjs ADDED
@@ -0,0 +1,147 @@
1
+ // The pure parts of the setup app: naming, validation, the arguments handed to
2
+ // create-design-hub and bootstrap, and the plain-language version of their errors.
3
+ // No I/O here, so every rule is tested without a server or a disk.
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { slugify } from './slug.mjs';
6
+
7
+ /** What to do when nothing more specific is known. */
8
+ export const FALLBACK_FIX = 'Try again. If it fails twice, send the details to a developer.';
9
+
10
+ /** Where new projects go unless the person picks another folder. No space in it, so every tool copes. */
11
+ export function defaultParent(home) {
12
+ return join(home, 'Design');
13
+ }
14
+
15
+ /** `~/…` for display; the server always works with the absolute path. */
16
+ export function tildify(path, home) {
17
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
18
+ }
19
+
20
+ /** The PORT line of a project's .env, or null. */
21
+ export function readPort(envText) {
22
+ const match = String(envText ?? '').match(/^\s*PORT\s*=\s*"?(\d+)"?\s*$/m);
23
+ return match ? Number(match[1]) : null;
24
+ }
25
+
26
+ /** Where a new project goes: its parent folder joined with the slug of its name. */
27
+ export function projectPath({ name, parent } = {}, home) {
28
+ const slug = slugify(name);
29
+ const dir = parent?.trim() || defaultParent(home);
30
+ const errors = {};
31
+ if (!slug) errors.name = 'Use at least one letter or number in the name.';
32
+ if (!isAbsolute(dir)) errors.parent = 'Pick a folder on this computer.';
33
+ return Object.keys(errors).length ? { errors } : { path: join(dir, slug) };
34
+ }
35
+
36
+ /**
37
+ * The create request's own fields: the default when empty, and the rule a
38
+ * filled-in value must meet. An empty optional field is always fine.
39
+ */
40
+ const FIELDS = {
41
+ visibility: {
42
+ fallback: 'local',
43
+ valid: /^(local|private|public)$/,
44
+ error: 'Choose this computer or GitHub.',
45
+ },
46
+ owner: { valid: /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/, error: 'A GitHub account or organization name.' },
47
+ typesafeKey: { valid: /^[\w.-]{16,}$/, error: 'That does not look like a TypeSafe key.' },
48
+ conductorBranch: { valid: /^[\w./-]+$/, error: 'A branch name, like main.' },
49
+ };
50
+
51
+ /** Checks a create request from the page; returns the clean value or the problems by field. */
52
+ export function validateCreate(body, { home }) {
53
+ const b = body ?? {};
54
+ const value = {};
55
+ const errors = {};
56
+ for (const [field, rule] of Object.entries(FIELDS)) {
57
+ const v = String(b[field] ?? '').trim() || rule.fallback;
58
+ if (v && !rule.valid.test(v)) errors[field] = rule.error;
59
+ value[field] = v;
60
+ }
61
+ const target = projectPath(b, home);
62
+ Object.assign(errors, target.errors);
63
+ value.path = target.path;
64
+ return Object.keys(errors).length ? { ok: false, errors } : { ok: true, value };
65
+ }
66
+
67
+ /** Flags for create-design-hub: the copy only; the install is its own step. `link` is a local design-hub checkout. */
68
+ export function createArgs({ path, visibility, owner, link }) {
69
+ const args = ['--path', path, '--visibility', visibility === 'local' ? 'none' : visibility, '--no-install'];
70
+ if (owner && visibility !== 'local') args.push('--owner', owner);
71
+ if (link) args.push('--link', link);
72
+ return args;
73
+ }
74
+
75
+ /** Flags for the bootstrap run: everything but the install, which ran as its own step. */
76
+ export function bootstrapArgs({ typesafeKey, conductorBranch }) {
77
+ const args = ['--no-install'];
78
+ if (typesafeKey) args.push('--typesafe-key', typesafeKey);
79
+ if (conductorBranch) args.push('--conductor', '--branch', conductorBranch);
80
+ return args;
81
+ }
82
+
83
+ const PLAIN = [
84
+ [
85
+ /exists and is not empty/,
86
+ 'That folder already has files in it.',
87
+ 'Pick another name or another place to save it.',
88
+ ],
89
+ [
90
+ /gh is not installed/,
91
+ 'GitHub needs the GitHub CLI on this computer.',
92
+ 'Keep the project on this computer for now, or ask a developer to install the GitHub CLI.',
93
+ ],
94
+ [
95
+ /gh auth status failed|not logged in/i,
96
+ 'You are not signed in to GitHub.',
97
+ 'Go back and sign in to GitHub, then try again.',
98
+ ],
99
+ [
100
+ /gh repo failed/,
101
+ 'GitHub would not create the repo.',
102
+ 'A repo with this name may exist already on that account. Try another name.',
103
+ ],
104
+ [
105
+ /Could not resolve host|unable to access|ERR_PNPM_FETCH|ENOTFOUND/i,
106
+ 'Could not download what the project needs.',
107
+ 'Check the internet connection, then try again.',
108
+ ],
109
+ [
110
+ /git is not installed|xcrun/i,
111
+ "Apple's developer tools are missing.",
112
+ 'Install them from the first screen, then try again.',
113
+ ],
114
+ [
115
+ /pnpm install failed|npx|ERR_PNPM/i,
116
+ 'Installing the project failed.',
117
+ 'Check the internet connection, then try again. Setup picks up where it stopped.',
118
+ ],
119
+ [
120
+ /No free port/,
121
+ 'Every port Design Hub uses is taken.',
122
+ 'Close other Design Hub projects, then try again.',
123
+ ],
124
+ ];
125
+
126
+ /** A script's `{ ok: false, error, fix? }` in words a non-developer can act on; the raw text stays in `detail`. */
127
+ export function explain(result) {
128
+ const raw = [result?.error, result?.stderr].filter(Boolean).join('\n');
129
+ for (const [pattern, message, fix] of PLAIN) if (pattern.test(raw)) return { message, fix, detail: raw };
130
+ return {
131
+ message: result?.error || 'Something went wrong.',
132
+ fix: typeof result?.fix === 'string' ? result.fix : FALLBACK_FIX,
133
+ detail: raw,
134
+ };
135
+ }
136
+
137
+ /** The one-time code and URL `gh auth login --web` prints when it has no terminal. */
138
+ export function parseDeviceCode(text) {
139
+ const code = String(text).match(/\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/)?.[1];
140
+ const url = String(text).match(/https:\/\/\S+\/login\/device\S*/)?.[0];
141
+ return code && url ? { code, url } : null;
142
+ }
143
+
144
+ /** Only this machine's own name for the server: blocks DNS rebinding from a web page. */
145
+ export function hostAllowed(host, port) {
146
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
147
+ }
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import {
4
+ bootstrapArgs,
5
+ createArgs,
6
+ explain,
7
+ hostAllowed,
8
+ parseDeviceCode,
9
+ readPort,
10
+ tildify,
11
+ validateCreate,
12
+ } from './lib.mjs';
13
+ import { slugify } from './slug.mjs';
14
+
15
+ const HOME = '/Users/sam';
16
+
17
+ test('slugify makes a folder name create-design-hub accepts', () => {
18
+ assert.equal(slugify('Acme Checkout'), 'acme-checkout');
19
+ assert.equal(slugify(' Café / Menü 2.0! '), 'cafe-menu-2.0');
20
+ assert.equal(slugify('---'), '');
21
+ assert.match(slugify('x'.repeat(200)), /^x{80}$/);
22
+ });
23
+
24
+ test('tildify shortens only paths under home', () => {
25
+ assert.equal(tildify('/Users/sam/Design/a', HOME), '~/Design/a');
26
+ assert.equal(tildify('/Users/samuel/a', HOME), '/Users/samuel/a');
27
+ });
28
+
29
+ test('readPort reads the PORT line', () => {
30
+ assert.equal(readPort('A=1\nPORT=3104\n'), 3104);
31
+ assert.equal(readPort('PORT="3105"'), 3105);
32
+ assert.equal(readPort('EXPORT=1'), null);
33
+ });
34
+
35
+ test('validateCreate builds the path and rejects bad fields', () => {
36
+ const ok = validateCreate({ name: 'Acme', visibility: 'local' }, { home: HOME });
37
+ assert.equal(ok.ok, true);
38
+ assert.equal(ok.value.path, '/Users/sam/Design/acme');
39
+ const bad = validateCreate(
40
+ {
41
+ name: '!!',
42
+ parent: 'relative',
43
+ visibility: 'x',
44
+ typesafeKey: 'short',
45
+ owner: '-bad',
46
+ },
47
+ { home: HOME },
48
+ );
49
+ assert.deepEqual(Object.keys(bad.errors).sort(), ['name', 'owner', 'parent', 'typesafeKey', 'visibility']);
50
+ });
51
+
52
+ test('createArgs maps local to none, drops the owner there, never installs, and links a local hub', () => {
53
+ const base = { path: '/p/a', owner: 'team' };
54
+ assert.deepEqual(createArgs({ ...base, visibility: 'local' }), [
55
+ '--path',
56
+ '/p/a',
57
+ '--visibility',
58
+ 'none',
59
+ '--no-install',
60
+ ]);
61
+ assert.deepEqual(createArgs({ ...base, visibility: 'private' }).slice(-2), ['--owner', 'team']);
62
+ assert.deepEqual(createArgs({ ...base, visibility: 'local', link: '/hub' }).slice(-2), ['--link', '/hub']);
63
+ });
64
+
65
+ test('bootstrapArgs never installs; the install is its own step', () => {
66
+ assert.deepEqual(bootstrapArgs({}), ['--no-install']);
67
+ assert.deepEqual(bootstrapArgs({ typesafeKey: 'k'.repeat(20), conductorBranch: 'main' }), [
68
+ '--no-install',
69
+ '--typesafe-key',
70
+ 'k'.repeat(20),
71
+ '--conductor',
72
+ '--branch',
73
+ 'main',
74
+ ]);
75
+ });
76
+
77
+ test('explain turns known failures into plain words and keeps the raw text', () => {
78
+ const e = explain({ ok: false, error: '/x exists and is not empty.' });
79
+ assert.equal(e.message, 'That folder already has files in it.');
80
+ assert.match(e.detail, /not empty/);
81
+ assert.equal(explain({ error: 'Odd.', fix: 'Do y.' }).fix, 'Do y.');
82
+ });
83
+
84
+ test('parseDeviceCode reads what gh prints without a terminal', () => {
85
+ const text =
86
+ '! One-time code (4B12-7915) copied to clipboard\nOpen this URL to continue in your web browser: https://github.com/login/device\n';
87
+ assert.deepEqual(parseDeviceCode(text), { code: '4B12-7915', url: 'https://github.com/login/device' });
88
+ assert.equal(parseDeviceCode('nothing yet'), null);
89
+ });
90
+
91
+ test('hostAllowed accepts only this machine on this port', () => {
92
+ assert.equal(hostAllowed('127.0.0.1:4000', 4000), true);
93
+ assert.equal(hostAllowed('localhost:4000', 4000), true);
94
+ assert.equal(hostAllowed('evil.test:4000', 4000), false);
95
+ assert.equal(hostAllowed('127.0.0.1:4001', 4000), false);
96
+ });
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env node
2
+ // The setup app's local server. `Design Hub.command` starts it; it opens the
3
+ // page in the browser and runs the same commands an agent would
4
+ // (create-design-hub, pnpm install, design-hub bootstrap), so the mechanics
5
+ // live in one place. Plain JavaScript with no dependencies, because it runs
6
+ // before anything is installed.
7
+ //
8
+ // It runs commands on this machine, so it listens on 127.0.0.1 only, accepts
9
+ // only its own Host name (no DNS rebinding), and every POST must carry the
10
+ // token printed into the page (no other site can send one).
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import { randomBytes } from 'node:crypto';
13
+ import {
14
+ chmodSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from 'node:fs';
22
+ import { createServer } from 'node:http';
23
+ import { homedir, platform } from 'node:os';
24
+ import { basename, delimiter, dirname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import {
27
+ bootstrapArgs,
28
+ createArgs,
29
+ defaultParent,
30
+ explain,
31
+ FALLBACK_FIX,
32
+ hostAllowed,
33
+ parseDeviceCode,
34
+ projectPath,
35
+ readPort,
36
+ tildify,
37
+ validateCreate,
38
+ } from './lib.mjs';
39
+
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const HOME = homedir();
42
+ const MAC = platform() === 'darwin';
43
+ const TOKEN = randomBytes(16).toString('hex');
44
+ const CREATE = join(HERE, '..', 'index.mjs');
45
+ // Inside the Design Hub repo itself, new projects link the local package instead of the published one.
46
+ const LOCAL_HUB = join(HERE, '..', '..', 'design-hub');
47
+ const LINK = existsSync(join(LOCAL_HUB, 'package.json')) ? LOCAL_HUB : undefined;
48
+ // The pnpm major projects are installed with.
49
+ const PNPM = 'pnpm@12';
50
+
51
+ /** Projects this server may start or reveal: the ones it made. */
52
+ const known = new Set();
53
+ /** Dev servers this launcher started, by project path. */
54
+ const servers = new Map();
55
+ let githubLogin = null;
56
+
57
+ // ---------- machine ----------
58
+
59
+ const has = (cmd, args = ['--version']) => spawnSync(cmd, args, { encoding: 'utf8' }).status === 0;
60
+
61
+ /** pnpm, or a shim that fetches it through npx, so nobody installs it by hand. */
62
+ function ensurePnpm() {
63
+ if (has('pnpm')) return;
64
+ const config = process.env.XDG_CONFIG_HOME || join(HOME, '.config');
65
+ const bin = join(config, 'design-hub', 'bin');
66
+ mkdirSync(bin, { recursive: true });
67
+ const shim = join(bin, 'pnpm');
68
+ writeFileSync(shim, `#!/bin/sh\nexec npx --yes ${PNPM} "$@"\n`);
69
+ chmodSync(shim, 0o755);
70
+ process.env.PATH = `${bin}${delimiter}${process.env.PATH}`;
71
+ }
72
+
73
+ /** The signed-in GitHub account, or null. */
74
+ function githubUser() {
75
+ if (!has('gh', ['auth', 'status'])) return null;
76
+ return (
77
+ spawnSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8' }).stdout.trim() || 'signed in'
78
+ );
79
+ }
80
+
81
+ function tools() {
82
+ // On a Mac without Apple's developer tools, `git` is a stub that opens an install dialog.
83
+ const devtools = !MAC || has('xcode-select', ['-p']);
84
+ const gh = has('gh');
85
+ return { git: devtools && has('git'), gh: { installed: gh, login: gh ? githubUser() : null } };
86
+ }
87
+
88
+ /** Opens a URL or folder in its default app. `args` go before the target, for example `-R` to select a file in Finder. */
89
+ function openExternal(target, args = []) {
90
+ spawn(MAC ? 'open' : 'xdg-open', [...(MAC ? args : []), target], {
91
+ stdio: 'ignore',
92
+ detached: true,
93
+ }).unref();
94
+ }
95
+
96
+ /** The pid listening on a port, or null. */
97
+ function listener(port) {
98
+ if (!port) return null;
99
+ const out = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
100
+ const pid = Number(out.stdout?.trim().split('\n')[0]);
101
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
102
+ }
103
+
104
+ // ---------- projects ----------
105
+
106
+ /** The port a project's .env claims, or null before setup. */
107
+ function portOf(path) {
108
+ const env = join(path, '.env');
109
+ return existsSync(env) ? readPort(readFileSync(env, 'utf8')) : null;
110
+ }
111
+
112
+ function project(path) {
113
+ const port = portOf(path);
114
+ return {
115
+ name: basename(path),
116
+ path,
117
+ display: tildify(path, HOME),
118
+ port,
119
+ running: listener(port) !== null,
120
+ url: port ? `http://localhost:${port}/` : null,
121
+ };
122
+ }
123
+
124
+ /** Whether a new project can go at `path`: a missing or empty folder only. */
125
+ function isFree(path) {
126
+ return !existsSync(path) || readdirSync(path).length === 0;
127
+ }
128
+
129
+ /** Runs a script that prints one JSON object; resolves with it, never rejects. */
130
+ function runJson(cmd, args, cwd) {
131
+ return new Promise((resolve) => {
132
+ const child = spawn(cmd, args, { cwd, env: process.env });
133
+ let out = '';
134
+ let err = '';
135
+ child.stdout.on('data', (d) => (out += d));
136
+ child.stderr.on('data', (d) => (err += d));
137
+ child.on('error', (e) => resolve({ ok: false, error: `${cmd} is not installed.`, stderr: e.message }));
138
+ child.on('close', (code) => {
139
+ try {
140
+ resolve(JSON.parse(out));
141
+ } catch {
142
+ resolve({
143
+ ok: code === 0,
144
+ error: code === 0 ? undefined : `${cmd} failed.`,
145
+ stderr: (err || out).slice(-1200),
146
+ });
147
+ }
148
+ });
149
+ });
150
+ }
151
+
152
+ /** Sends a step's running, then done or error; resolves with the result, or null when it failed. */
153
+ async function step(send, name, run) {
154
+ send({ step: name, status: 'running' });
155
+ const result = await run();
156
+ send(result.ok ? { step: name, status: 'done' } : { step: name, status: 'error', ...explain(result) });
157
+ return result.ok ? result : null;
158
+ }
159
+
160
+ /** The template copy. */
161
+ async function copyTemplate(value, send) {
162
+ mkdirSync(dirname(value.path), { recursive: true });
163
+ const made = await step(send, 'copy', () =>
164
+ runJson(process.execPath, [CREATE, ...createArgs({ ...value, link: LINK })], HOME),
165
+ );
166
+ if (!made) return null;
167
+ known.add(value.path);
168
+ return value.path;
169
+ }
170
+
171
+ /** Checks the request and copies the template; resolves with the project path, or null. */
172
+ async function prepare(body, send) {
173
+ const checked = validateCreate(body, { home: HOME });
174
+ if (!checked.ok) {
175
+ send({ step: 'invalid', status: 'error', errors: checked.errors });
176
+ return null;
177
+ }
178
+ const path = await copyTemplate(checked.value, send);
179
+ return path && { path, value: checked.value };
180
+ }
181
+
182
+ /** pnpm install, then the miles-* skills from the installed package. */
183
+ function install(path) {
184
+ return new Promise((resolve) => {
185
+ const child = spawn('pnpm', ['install'], { cwd: path, env: process.env });
186
+ let err = '';
187
+ child.stderr.on('data', (d) => (err += d));
188
+ child.on('error', (e) => resolve({ ok: false, error: 'pnpm is not installed.', stderr: e.message }));
189
+ child.on('close', (code) => {
190
+ if (code !== 0) return resolve({ ok: false, error: 'pnpm install failed.', stderr: err.slice(-1200) });
191
+ resolve(runJson(join(path, 'node_modules', '.bin', 'design-hub'), ['skills'], path));
192
+ });
193
+ });
194
+ }
195
+
196
+ async function create(body, send) {
197
+ const ready = await prepare(body, send);
198
+ if (!ready) return;
199
+ const installed = await step(send, 'install', () => install(ready.path));
200
+ if (!installed) return;
201
+ const hub = join(ready.path, 'node_modules', '.bin', 'design-hub');
202
+ const ported = await step(send, 'port', () =>
203
+ runJson(hub, ['bootstrap', ...bootstrapArgs(ready.value)], ready.path),
204
+ );
205
+ if (!ported) return;
206
+ send({ step: 'ready', status: 'done', project: project(ready.path), warnings: ported.warnings ?? [] });
207
+ }
208
+
209
+ async function reachable(url, ms) {
210
+ const until = Date.now() + ms;
211
+ while (Date.now() < until) {
212
+ try {
213
+ await fetch(url, { signal: AbortSignal.timeout(1500) });
214
+ return true;
215
+ } catch {
216
+ await new Promise((r) => setTimeout(r, 400));
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+
222
+ /** Runs `design-hub dev` in its own process group, so Stop ends it and anything under it together. */
223
+ function launch(path) {
224
+ const logDir = join(path, '.design-hub');
225
+ mkdirSync(logDir, { recursive: true });
226
+ const logFile = join(logDir, 'dev.log');
227
+ const log = openSync(logFile, 'w');
228
+ const child = spawn(join(path, 'node_modules', '.bin', 'design-hub'), ['dev'], {
229
+ cwd: path,
230
+ detached: true,
231
+ stdio: ['ignore', log, log],
232
+ });
233
+ servers.set(path, child);
234
+ return {
235
+ exited: new Promise((r) => child.on('exit', () => r(false))),
236
+ tail: () => (existsSync(logFile) ? readFileSync(logFile, 'utf8').slice(-1200) : ''),
237
+ };
238
+ }
239
+
240
+ async function start(path) {
241
+ const p = project(path);
242
+ if (!p.port) return { ok: false, message: 'This project is not set up yet.', fix: 'Run setup first.' };
243
+ if (p.running) return { ok: true, project: p };
244
+ const { exited, tail } = launch(path);
245
+ if (await Promise.race([reachable(p.url, 45_000), exited])) return { ok: true, project: project(path) };
246
+ stop(path);
247
+ return { ok: false, message: 'Design Hub did not start.', fix: FALLBACK_FIX, detail: tail() };
248
+ }
249
+
250
+ function kill(pid) {
251
+ if (!pid) return;
252
+ try {
253
+ process.kill(pid, 'SIGTERM');
254
+ } catch {
255
+ // Already gone.
256
+ }
257
+ }
258
+
259
+ /** Stops the hub: the process group this server started, or else whatever serves the project's port. */
260
+ function stop(path) {
261
+ const own = servers.get(path)?.pid;
262
+ servers.delete(path);
263
+ kill(own ? -own : listener(portOf(path)));
264
+ }
265
+
266
+ function githubSignIn() {
267
+ return new Promise((resolve) => {
268
+ if (githubLogin) return resolve(githubLogin.device ?? { pending: true });
269
+ // Without a terminal, gh prints the code and the URL, then waits for the browser.
270
+ const child = spawn(
271
+ 'gh',
272
+ ['auth', 'login', '--web', '--hostname', 'github.com', '--git-protocol', 'https', '--skip-ssh-key'],
273
+ { stdio: ['ignore', 'pipe', 'pipe'] },
274
+ );
275
+ githubLogin = { child, device: null };
276
+ let text = '';
277
+ const timer = setTimeout(() => resolve({ error: 'GitHub did not answer. Try again.' }), 15_000);
278
+ const read = (d) => {
279
+ text += d;
280
+ const device = parseDeviceCode(text);
281
+ if (device && !githubLogin.device) {
282
+ githubLogin.device = device;
283
+ clearTimeout(timer);
284
+ openExternal(device.url);
285
+ resolve(device);
286
+ }
287
+ };
288
+ child.stdout.on('data', read);
289
+ child.stderr.on('data', read);
290
+ child.on('error', () => resolve({ error: 'The GitHub CLI is not installed.' }));
291
+ child.on('close', () => {
292
+ githubLogin = null;
293
+ clearTimeout(timer);
294
+ resolve({ error: 'Sign-in stopped. Try again.' });
295
+ });
296
+ });
297
+ }
298
+
299
+ function pickFolder() {
300
+ if (!MAC) return {};
301
+ const out = spawnSync(
302
+ 'osascript',
303
+ [
304
+ '-e',
305
+ 'activate',
306
+ '-e',
307
+ 'POSIX path of (choose folder with prompt "Where should Design Hub save the project?")',
308
+ ],
309
+ { encoding: 'utf8' },
310
+ );
311
+ if (out.status !== 0) return { cancelled: true };
312
+ const path = out.stdout.trim().replace(/\/$/, '');
313
+ return { path, display: tildify(path, HOME) };
314
+ }
315
+
316
+ // ---------- http ----------
317
+
318
+ const json = (res, status, body) => {
319
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
320
+ res.end(JSON.stringify(body));
321
+ };
322
+
323
+ async function readBody(req) {
324
+ let raw = '';
325
+ for await (const chunk of req) {
326
+ raw += chunk;
327
+ if (raw.length > 64_000) throw new Error('too large');
328
+ }
329
+ return raw ? JSON.parse(raw) : {};
330
+ }
331
+
332
+ function state() {
333
+ const parent = defaultParent(HOME);
334
+ return {
335
+ mode: 'create',
336
+ defaults: { parent, parentDisplay: tildify(parent, HOME) },
337
+ here: null,
338
+ tools: tools(),
339
+ };
340
+ }
341
+
342
+ /** Copies the path for pasting into Claude Code and opens the Claude app; false when the app is missing. */
343
+ function openClaude(path) {
344
+ const copied = MAC && spawnSync('pbcopy', { input: path }).status === 0;
345
+ if (copied && has('open', ['-a', 'Claude'])) return true;
346
+ openExternal('https://claude.ai/download');
347
+ return false;
348
+ }
349
+
350
+ /** Shows the project's own launcher in Finder, so people see what to double-click next time. */
351
+ function reveal(path) {
352
+ const launcher = join(path, 'Design Hub.command');
353
+ if (existsSync(launcher)) openExternal(launcher, ['-R']);
354
+ else openExternal(path);
355
+ }
356
+
357
+ /** Actions on a project take only one this server owns: its own folder or one it created. */
358
+ const onProject = (action) => (b) =>
359
+ known.has(b.path) ? action(b.path) : { ok: false, message: 'Unknown project.' };
360
+
361
+ /** Every action is a POST with the page's token. */
362
+ const routes = {
363
+ '/api/state': () => state(),
364
+ // Step 1's check: the same rule create uses, so the page never guesses.
365
+ '/api/path': (b) => {
366
+ const target = projectPath(b, HOME);
367
+ if (target.errors) return { ok: false, error: target.errors.name ?? target.errors.parent };
368
+ if (!isFree(target.path))
369
+ return { ok: false, error: 'A folder with that name already has files in it. Try another name.' };
370
+ return { ok: true };
371
+ },
372
+ '/api/pick-folder': () => pickFolder(),
373
+ '/api/devtools': () => {
374
+ if (MAC) spawn('xcode-select', ['--install'], { stdio: 'ignore', detached: true }).unref();
375
+ return { ok: true };
376
+ },
377
+ '/api/github': () => githubSignIn(),
378
+ '/api/start': onProject(start),
379
+ '/api/stop': onProject((path) => {
380
+ stop(path);
381
+ // The port can stay bound for a moment after the signal, so don't ask lsof yet.
382
+ return { ok: true, project: { ...project(path), running: false } };
383
+ }),
384
+ '/api/reveal': onProject((path) => {
385
+ reveal(path);
386
+ return { ok: true };
387
+ }),
388
+ '/api/claude': onProject((path) => ({ ok: true, app: openClaude(path) })),
389
+ };
390
+
391
+ /** Files the page loads. slug.mjs is the naming rule, shared with the server. */
392
+ const files = {
393
+ '/': ['app.html', 'text/html; charset=utf-8'],
394
+ '/slug.mjs': ['slug.mjs', 'text/javascript; charset=utf-8'],
395
+ };
396
+
397
+ function serveFile(res, [name, type]) {
398
+ const body = readFileSync(join(HERE, name), 'utf8').replace('__TOKEN__', TOKEN);
399
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store', 'x-frame-options': 'DENY' });
400
+ res.end(body);
401
+ }
402
+
403
+ /** One JSON object per line, one per step, so the page shows progress as it happens. */
404
+ async function streamCreate(req, res) {
405
+ const body = await readBody(req);
406
+ res.writeHead(200, { 'content-type': 'application/x-ndjson', 'cache-control': 'no-store' });
407
+ await create(body, (event) => res.write(`${JSON.stringify(event)}\n`));
408
+ res.end();
409
+ }
410
+
411
+ async function handle(req, res) {
412
+ if (!hostAllowed(req.headers.host, server.address().port)) return json(res, 403, { error: 'Wrong host.' });
413
+ const { pathname } = new URL(req.url, 'http://localhost');
414
+ if (req.method === 'GET' && files[pathname]) return serveFile(res, files[pathname]);
415
+ if (req.method !== 'POST' || req.headers['x-setup-token'] !== TOKEN)
416
+ return json(res, 403, { error: 'Bad token.' });
417
+ if (pathname === '/api/create') return streamCreate(req, res);
418
+ const route = routes[pathname];
419
+ if (!route) return json(res, 404, { error: 'Not found.' });
420
+ json(res, 200, await route(await readBody(req)));
421
+ }
422
+
423
+ const server = createServer((req, res) =>
424
+ handle(req, res).catch((error) => {
425
+ if (res.headersSent) res.end();
426
+ else json(res, 500, { error: error.message });
427
+ }),
428
+ );
429
+
430
+ function shutdown() {
431
+ for (const path of servers.keys()) stop(path);
432
+ githubLogin?.child.kill();
433
+ process.exit(0);
434
+ }
435
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, shutdown);
436
+
437
+ ensurePnpm();
438
+ server.listen(0, '127.0.0.1', () => {
439
+ const url = `http://127.0.0.1:${server.address().port}/`;
440
+ console.log(
441
+ `\n Design Hub is open in your browser.\n If it did not open, go to ${url}\n\n Keep this window open while you work. Close it to stop Design Hub.\n`,
442
+ );
443
+ if (!process.argv.includes('--no-open')) openExternal(url);
444
+ });