@milesroxas/design-hub 0.1.2 → 0.2.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.
@@ -29,6 +29,10 @@ export declare const spec: {
29
29
  readonly type: 'string';
30
30
  readonly description: 'Link this Vercel project and set the password gate env on it.';
31
31
  };
32
+ readonly team: {
33
+ readonly type: 'string';
34
+ readonly description: 'The Vercel team (its slug) the project lives in or is created in. Absent, the CLI’s current team.';
35
+ };
32
36
  readonly 'gate-password': {
33
37
  readonly type: 'string';
34
38
  readonly description: 'The share password (8+ characters). Given again, it rotates.';
@@ -40,6 +44,18 @@ export declare const spec: {
40
44
  };
41
45
  readonly output: '{ ok, node, port, install, typesafe, conductor?, vercel?, share?, review?, warnings }';
42
46
  };
47
+ /**
48
+ * What `design-hub dev`, `open` and `preview` run first, so a fresh clone or
49
+ * worktree starts in any harness without a bootstrap: the harness's port when
50
+ * it gave one, else this folder's claim, else a free port claimed now. A .env
51
+ * copied from another checkout carries that checkout's claim, so this folder
52
+ * gets its own. `claimed` is true when the port is new.
53
+ */
54
+ export declare function ensurePort(): {
55
+ port: number;
56
+ claimed: boolean;
57
+ warnings: string[];
58
+ };
43
59
  /** The gate's values: the password given, kept, or made, and the secret kept or made. */
44
60
  export declare function gateValues(given: string | undefined, env: Record<string, string>): {
45
61
  password: string;
@@ -8,11 +8,11 @@ import { randomBytes } from 'node:crypto';
8
8
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
- import { readEnv, writeEnv } from '../server/env.js';
11
+ import { harnessPort, readEnv, writeEnv } from '../server/env.js';
12
12
  import { ROOT, TEMPLATES } from '../server/paths.js';
13
13
  import { CliError } from './lib/cli.js';
14
14
  import { holderOf, pickPort, readClaims, writeClaim } from './lib/ports.js';
15
- import { ENVIRONMENTS, envNames, linkedProject, STORE_ENV, setEnv, vercel, } from './lib/vercel.js';
15
+ import { ENVIRONMENTS, envNames, linkedOrg, linkedProject, STORE_ENV, setEnv, vercel, } from './lib/vercel.js';
16
16
  export const spec = {
17
17
  name: 'bootstrap',
18
18
  summary: 'Set the project up on this machine, and optionally on Vercel. Safe to run again.',
@@ -26,6 +26,10 @@ export const spec = {
26
26
  conductor: { type: 'boolean', description: 'Write .conductor/settings.toml for Conductor workspaces.' },
27
27
  branch: { type: 'string', default: 'main', description: 'The branch Conductor workspaces land on.' },
28
28
  vercel: { type: 'string', description: 'Link this Vercel project and set the password gate env on it.' },
29
+ team: {
30
+ type: 'string',
31
+ description: 'The Vercel team (its slug) the project lives in or is created in. Absent, the CLI’s current team.',
32
+ },
29
33
  'gate-password': {
30
34
  type: 'string',
31
35
  description: 'The share password (8+ characters). Given again, it rotates.',
@@ -60,17 +64,16 @@ function checkNode() {
60
64
  return process.versions.node;
61
65
  }
62
66
  function choosePort(wanted, current, warnings) {
63
- const conductor = process.env.CONDUCTOR_PORT;
64
- if (conductor) {
65
- // Conductor owns its workspace ports; they never go in the machine registry.
66
- const port = Number(conductor);
67
- const held = holderOf(port);
67
+ const harness = harnessPort();
68
+ if (harness) {
69
+ // The harness owns its worktree ports; they never go in the machine registry.
70
+ const held = holderOf(harness.port);
68
71
  if (held && held.cwd !== ROOT)
69
- throw new CliError(`Conductor's port ${port} is taken.`, {
72
+ throw new CliError(`Port ${harness.port} from $${harness.from} is taken.`, {
70
73
  holder: held,
71
74
  fix: 'Stop that server; never use another port.',
72
75
  });
73
- return port;
76
+ return harness.port;
74
77
  }
75
78
  const choice = pickPort({ dir: ROOT, wanted, current, claims: readClaims(), holder: holderOf });
76
79
  if (!choice.ok)
@@ -79,11 +82,57 @@ function choosePort(wanted, current, warnings) {
79
82
  writeClaim(ROOT, choice.port);
80
83
  return choice.port;
81
84
  }
82
- /** Keeps .claude/launch.json on the same port, when the project has one. */
85
+ /** Settles the port and records it where the dev server and the preview tools read it. */
86
+ function settlePort(wanted, current, warnings) {
87
+ const port = choosePort(wanted, current, warnings);
88
+ // A harness port belongs to that run; .env keeps the folder's own claim.
89
+ if (!harnessPort())
90
+ writeEnv(ROOT, { PORT: String(port) });
91
+ writeLaunch(port);
92
+ return port;
93
+ }
94
+ function envPort(env) {
95
+ return env.PORT ? Number(env.PORT) : undefined;
96
+ }
97
+ /**
98
+ * What `design-hub dev`, `open` and `preview` run first, so a fresh clone or
99
+ * worktree starts in any harness without a bootstrap: the harness's port when
100
+ * it gave one, else this folder's claim, else a free port claimed now. A .env
101
+ * copied from another checkout carries that checkout's claim, so this folder
102
+ * gets its own. `claimed` is true when the port is new.
103
+ */
104
+ export function ensurePort() {
105
+ const warnings = [];
106
+ const harness = harnessPort();
107
+ if (harness)
108
+ return { port: harness.port, claimed: false, warnings };
109
+ const current = envPort(readEnv(ROOT));
110
+ if (current !== undefined && readClaims()[ROOT] === current)
111
+ return { port: current, claimed: false, warnings };
112
+ const port = settlePort(undefined, current, warnings);
113
+ return { port, claimed: port !== current, warnings };
114
+ }
115
+ /** Claude Code's preview servers for this folder: start the hub, or attach to one already running. */
116
+ function defaultLaunch(port) {
117
+ return {
118
+ version: '0.0.1',
119
+ configurations: [
120
+ { name: 'prototypes', runtimeExecutable: 'pnpm', runtimeArgs: ['dev'], port },
121
+ { name: 'prototypes-attach', url: `http://localhost:${port}`, port },
122
+ ],
123
+ };
124
+ }
125
+ /**
126
+ * Keeps .claude/launch.json on this folder's port, writing it when missing.
127
+ * It is per folder like .env, and gitignored, so worktrees never conflict on it.
128
+ */
83
129
  function writeLaunch(port) {
84
130
  const file = join(ROOT, '.claude', 'launch.json');
85
- if (!existsSync(file))
131
+ if (!existsSync(file)) {
132
+ mkdirSync(join(ROOT, '.claude'), { recursive: true });
133
+ writeFileSync(file, `${JSON.stringify(defaultLaunch(port), null, 2)}\n`);
86
134
  return;
135
+ }
87
136
  const launch = JSON.parse(readFileSync(file, 'utf8'));
88
137
  for (const c of launch.configurations) {
89
138
  if ('port' in c)
@@ -120,13 +169,15 @@ function cli(args, input) {
120
169
  });
121
170
  return out.stdout;
122
171
  }
123
- function link(project, warnings) {
172
+ function link(project, team, warnings) {
124
173
  const current = linkedProject(ROOT);
125
174
  if (current === project)
126
175
  return 'kept';
127
176
  if (current)
128
177
  warnings.push(`.vercel/project.json pointed at ${current}; relinked to ${project}.`);
129
- cli(['link', '--yes', '--project', project]);
178
+ // A new project is created in the scope given; without one the CLI's current team wins, which
179
+ // is whichever team the user last switched to, not necessarily where this project belongs.
180
+ cli(['link', '--yes', '--project', project, ...(team ? ['--scope', team] : [])]);
130
181
  return 'ran';
131
182
  }
132
183
  /** Sets each value where it is missing; `rotate` names the ones given again, which replace what is there. */
@@ -169,7 +220,21 @@ function pullValues(target, names) {
169
220
  rmSync(dir, { recursive: true, force: true });
170
221
  }
171
222
  }
172
- function provisionReview(project, warnings) {
223
+ /** `--scope` for the commands that name the project instead of reading the link. */
224
+ const scopeArgs = (team) => {
225
+ const scope = team ?? linkedOrg(ROOT);
226
+ return scope ? ['--scope', scope] : [];
227
+ };
228
+ /**
229
+ * Vercel Authentication is on for every new project on a team, and it sends each
230
+ * visitor to a Vercel login before the gate. The gate is the protection, so it goes
231
+ * off. Idempotent: disabling twice is fine.
232
+ */
233
+ function protection(project, team) {
234
+ cli(['project', 'protection', 'disable', project, '--sso', ...scopeArgs(team)]);
235
+ return 'off';
236
+ }
237
+ function provisionReview(project, team, warnings) {
173
238
  const names = envNames(ROOT, 'production');
174
239
  let store = 'kept';
175
240
  if (!names || !STORE_ENV.every((n) => names.has(n))) {
@@ -186,6 +251,8 @@ function provisionReview(project, warnings) {
186
251
  '-e',
187
252
  'development',
188
253
  '--no-env-pull',
254
+ // Unlike env and deploy, this command ignores the project link and takes the CLI's current team.
255
+ ...scopeArgs(team),
189
256
  ]);
190
257
  if (out?.status !== 0)
191
258
  throw new CliError('Could not add the Upstash for Redis store.', {
@@ -226,7 +293,7 @@ export function gateValues(given, env) {
226
293
  return { password, secret, rotated: given !== undefined && given !== env.GATE_PASSWORD };
227
294
  }
228
295
  function setUpVercel(project, flags, env, typesafeKey, warnings) {
229
- const linked = link(project, warnings);
296
+ const linked = link(project, flags.team, warnings);
230
297
  const { password, secret, rotated } = gateValues(flags['gate-password'], env);
231
298
  writeEnv(ROOT, { GATE_PASSWORD: password, GATE_SECRET: secret });
232
299
  const values = { GATE_PASSWORD: password, GATE_SECRET: secret };
@@ -236,13 +303,18 @@ function setUpVercel(project, flags, env, typesafeKey, warnings) {
236
303
  if (rotated)
237
304
  warnings.push('The password changed: every visitor signs in again.');
238
305
  const result = {
239
- vercel: { project, linked, env: ensureEnv(values, new Set(rotated ? ['GATE_PASSWORD'] : [])) },
306
+ vercel: {
307
+ project,
308
+ linked,
309
+ protection: protection(project, flags.team),
310
+ env: ensureEnv(values, new Set(rotated ? ['GATE_PASSWORD'] : [])),
311
+ },
240
312
  share: { password },
241
313
  };
242
314
  if (!flags.review)
243
315
  return result;
244
316
  warnings.push('Redeploy so the new env reaches the site; then design-hub people invite --name "<you>" --role editor.');
245
- return { ...result, review: provisionReview(project, warnings) };
317
+ return { ...result, review: provisionReview(project, flags.team, warnings) };
246
318
  }
247
319
  function envIgnored(warnings) {
248
320
  try {
@@ -251,6 +323,12 @@ function envIgnored(warnings) {
251
323
  catch {
252
324
  warnings.push('.env is not gitignored; it holds secrets. Add `.env` to .gitignore before committing.');
253
325
  }
326
+ // Projects made before 0.2.0 committed it; each worktree then rewrites a tracked file.
327
+ const tracked = spawnSync('git', ['-C', ROOT, 'ls-files', '--error-unmatch', '.claude/launch.json'], {
328
+ stdio: 'ignore',
329
+ });
330
+ if (tracked.status === 0)
331
+ warnings.push('.claude/launch.json is committed but holds this folder’s port. Run `git rm --cached .claude/launch.json` and add it to .gitignore.');
254
332
  }
255
333
  export function main({ flags }) {
256
334
  if (flags.review && !flags.vercel)
@@ -258,9 +336,7 @@ export function main({ flags }) {
258
336
  const warnings = [];
259
337
  const node = checkNode();
260
338
  const env = readEnv(ROOT);
261
- const port = choosePort(flags.port, env.PORT ? Number(env.PORT) : undefined, warnings);
262
- writeEnv(ROOT, { PORT: String(port) });
263
- writeLaunch(port);
339
+ const port = settlePort(flags.port, envPort(env), warnings);
264
340
  const installed = install(flags['no-install']);
265
341
  const typesafeKey = typesafe(flags['typesafe-key'], env.TYPESAFE_API_KEY);
266
342
  const result = {
@@ -2,7 +2,7 @@
2
2
  import type { Args } from './lib/cli.ts';
3
3
  export declare const spec: {
4
4
  readonly name: 'dev';
5
- readonly summary: 'Serve the hub, every prototype and the review panel on the port in .env.';
5
+ readonly summary: "Serve the hub, every prototype and the review panel: on $DESIGN_HUB_PORT or $CONDUCTOR_PORT when a harness sets one, else this folder's port in .env, claimed on first run.";
6
6
  readonly flags: {
7
7
  readonly open: {
8
8
  readonly type: 'boolean';
@@ -19,7 +19,7 @@ export declare const buildSpec: {
19
19
  };
20
20
  export declare const previewSpec: {
21
21
  readonly name: 'preview';
22
- readonly summary: 'Serve dist/ as built, on the port in .env.';
22
+ readonly summary: 'Serve dist/ as built, on the same port as `design-hub dev`.';
23
23
  readonly flags: {};
24
24
  readonly output: "Vite's own log; the command runs until stopped.";
25
25
  };
@@ -8,9 +8,10 @@ import { createServer, build as viteBuild, preview as vitePreview } from 'vite';
8
8
  import { devPort, readEnv } from '../server/env.js';
9
9
  import { ROOT } from '../server/paths.js';
10
10
  import { hubConfig } from '../vite/plugin.js';
11
+ import { ensurePort } from './bootstrap.js';
11
12
  export const spec = {
12
13
  name: 'dev',
13
- summary: 'Serve the hub, every prototype and the review panel on the port in .env.',
14
+ summary: "Serve the hub, every prototype and the review panel: on $DESIGN_HUB_PORT or $CONDUCTOR_PORT when a harness sets one, else this folder's port in .env, claimed on first run.",
14
15
  flags: { open: { type: 'boolean', description: 'Open the browser once the server is up.' } },
15
16
  output: "Vite's own log; the command runs until stopped.",
16
17
  };
@@ -22,7 +23,7 @@ export const buildSpec = {
22
23
  };
23
24
  export const previewSpec = {
24
25
  name: 'preview',
25
- summary: 'Serve dist/ as built, on the port in .env.',
26
+ summary: 'Serve dist/ as built, on the same port as `design-hub dev`.',
26
27
  flags: {},
27
28
  output: "Vite's own log; the command runs until stopped.",
28
29
  };
@@ -42,7 +43,16 @@ function openBrowser(url) {
42
43
  .on('error', () => { })
43
44
  .unref();
44
45
  }
46
+ /** A port before Vite starts; a new claim is said on stderr, beside Vite's own log. */
47
+ function settlePort() {
48
+ const { port, claimed, warnings } = ensurePort();
49
+ for (const warning of warnings)
50
+ console.error(`design-hub: ${warning}`);
51
+ if (claimed)
52
+ console.error(`design-hub: claimed port ${port} for this folder and wrote it to .env.`);
53
+ }
45
54
  async function serve(open) {
55
+ settlePort();
46
56
  const server = await createServer(hubConfig({ root: ROOT }, 'serve'));
47
57
  await server.listen();
48
58
  server.printUrls();
@@ -69,6 +79,7 @@ export async function build() {
69
79
  }
70
80
  }
71
81
  export async function previewBuilt() {
82
+ settlePort();
72
83
  const server = await vitePreview(hubConfig({ root: ROOT }, 'build'));
73
84
  server.printUrls();
74
85
  await new Promise(() => { });
@@ -8,7 +8,9 @@ export type Run = {
8
8
  stderr: string;
9
9
  };
10
10
  export declare function vercel(cwd: string, args: string[], input?: string): Run | null;
11
- export declare function linkedProject(cwd: string): string | null;
11
+ export declare const linkedProject: (cwd: string) => string | null;
12
+ /** The team the linked project belongs to. The commands that take a project name instead of the link need it as `--scope`. */
13
+ export declare const linkedOrg: (cwd: string) => string | null;
12
14
  /** The variable names set for a target, read from `vercel env ls`'s table. */
13
15
  export declare function envNames(cwd: string, target: Target): Set<string> | null;
14
16
  /** Sets one variable for one target; `force` replaces a value that is already there. */
@@ -15,17 +15,20 @@ export function vercel(cwd, args, input) {
15
15
  return null;
16
16
  return { status: out.status ?? 1, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
17
17
  }
18
- export function linkedProject(cwd) {
18
+ function readLink(cwd) {
19
19
  const file = join(cwd, '.vercel', 'project.json');
20
20
  if (!existsSync(file))
21
21
  return null;
22
22
  try {
23
- return JSON.parse(readFileSync(file, 'utf8')).projectName ?? null;
23
+ return JSON.parse(readFileSync(file, 'utf8'));
24
24
  }
25
25
  catch {
26
26
  return null;
27
27
  }
28
28
  }
29
+ export const linkedProject = (cwd) => readLink(cwd)?.projectName ?? null;
30
+ /** The team the linked project belongs to. The commands that take a project name instead of the link need it as `--scope`. */
31
+ export const linkedOrg = (cwd) => readLink(cwd)?.orgId ?? null;
29
32
  /** The variable names set for a target, read from `vercel env ls`'s table. */
30
33
  export function envNames(cwd, target) {
31
34
  const out = vercel(cwd, ['env', 'ls', target]);
@@ -15,12 +15,16 @@ export const spec = {
15
15
  output: '{ ok, installed: [name], into }',
16
16
  };
17
17
  const GLOBAL_ONLY = new Set(['miles-design-hub']);
18
+ /** Skills the package no longer ships, removed from a project on update. miles-conductor became miles-worktrees in 0.2.0. */
19
+ const RETIRED = ['miles-conductor'];
18
20
  export function installSkills(from = PACKAGE_SKILLS, into = SKILLS) {
19
21
  const names = readdirSync(from, { withFileTypes: true })
20
22
  .filter((d) => d.isDirectory() && d.name.startsWith('miles-') && !GLOBAL_ONLY.has(d.name))
21
23
  .map((d) => d.name)
22
24
  .sort();
23
25
  mkdirSync(into, { recursive: true });
26
+ for (const name of RETIRED)
27
+ rmSync(join(into, name), { recursive: true, force: true });
24
28
  for (const name of names) {
25
29
  const dest = join(into, name);
26
30
  if (existsSync(dest))
@@ -1,8 +1,20 @@
1
1
  export declare function readEnv(dir: string): Record<string, string>;
2
2
  export declare function writeEnv(dir: string, updates: Record<string, string>): void;
3
+ /** The variables a harness (Conductor, T3 Code, a script) may hand a worktree its port in, first wins. */
4
+ declare const HARNESS_PORT_VARS: readonly ['DESIGN_HUB_PORT', 'CONDUCTOR_PORT'];
3
5
  /**
4
- * The dev server's port. Inside a Conductor workspace, Conductor's own port
5
- * wins, because its Open button always points there; everywhere else it is
6
- * the PORT that `pnpm bootstrap` wrote to .env.
6
+ * The port a harness gave this process, and the variable it came in. A
7
+ * harness owns these ports: they never enter the machine registry. A bare
8
+ * `PORT` is not one: shells and other tools export it for their own servers.
9
+ */
10
+ export declare function harnessPort(processEnv?: Record<string, string | undefined>): {
11
+ port: number;
12
+ from: (typeof HARNESS_PORT_VARS)[number];
13
+ } | null;
14
+ /**
15
+ * The dev server's port: the harness's when one gave it, because its Open
16
+ * button points there; else the PORT in .env, which `design-hub dev` and
17
+ * `design-hub bootstrap` claim for this folder.
7
18
  */
8
19
  export declare function devPort(fileEnv: Record<string, string>, processEnv?: Record<string, string | undefined>): number | undefined;
20
+ export {};
@@ -46,12 +46,30 @@ export function writeEnv(dir, updates) {
46
46
  const text = next.join('\n').replace(/\n*$/, '\n');
47
47
  writeFileSync(file, text, { mode: 0o600 });
48
48
  }
49
+ /** The variables a harness (Conductor, T3 Code, a script) may hand a worktree its port in, first wins. */
50
+ const HARNESS_PORT_VARS = ['DESIGN_HUB_PORT', 'CONDUCTOR_PORT'];
51
+ function asPort(value) {
52
+ const port = Number(value);
53
+ return Number.isInteger(port) && port > 0 ? port : undefined;
54
+ }
49
55
  /**
50
- * The dev server's port. Inside a Conductor workspace, Conductor's own port
51
- * wins, because its Open button always points there; everywhere else it is
52
- * the PORT that `pnpm bootstrap` wrote to .env.
56
+ * The port a harness gave this process, and the variable it came in. A
57
+ * harness owns these ports: they never enter the machine registry. A bare
58
+ * `PORT` is not one: shells and other tools export it for their own servers.
59
+ */
60
+ export function harnessPort(processEnv = process.env) {
61
+ for (const from of HARNESS_PORT_VARS) {
62
+ const port = asPort(processEnv[from]);
63
+ if (port !== undefined)
64
+ return { port, from };
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * The dev server's port: the harness's when one gave it, because its Open
70
+ * button points there; else the PORT in .env, which `design-hub dev` and
71
+ * `design-hub bootstrap` claim for this folder.
53
72
  */
54
73
  export function devPort(fileEnv, processEnv = process.env) {
55
- const port = Number(processEnv.CONDUCTOR_PORT || fileEnv.PORT);
56
- return Number.isInteger(port) && port > 0 ? port : undefined;
74
+ return harnessPort(processEnv)?.port ?? asPort(fileEnv.PORT);
57
75
  }
@@ -1,4 +1,4 @@
1
- import type { InlineConfig, PluginOption } from 'vite';
1
+ import { type InlineConfig, type PluginOption } from 'vite';
2
2
  export type HubOptions = {
3
3
  /** The project: the folder with prototypes/, kit/ and docs/. */
4
4
  root: string;
@@ -7,8 +7,8 @@ export type HubOptions = {
7
7
  export declare function designHub({ root }: HubOptions): PluginOption[];
8
8
  /**
9
9
  * The whole config `design-hub dev` and `design-hub build` run on. One port per
10
- * project: Conductor's per-workspace port when it runs us, else the PORT
11
- * `design-hub bootstrap` wrote to .env. strictPort refuses a taken port rather
10
+ * folder: the harness's ($DESIGN_HUB_PORT, $CONDUCTOR_PORT) when one runs us,
11
+ * else the PORT `design-hub dev` or `bootstrap` claimed in .env. strictPort refuses a taken port rather
12
12
  * than drifting to the next one, because a fallback silently serves the wrong
13
13
  * project. A build needs no port, so Vercel builds without a .env.
14
14
  */
@@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs';
9
9
  import { createRequire } from 'node:module';
10
10
  import { dirname, join } from 'node:path';
11
11
  import react from '@vitejs/plugin-react';
12
+ import { searchForWorkspaceRoot } from 'vite';
12
13
  import { devReview } from '../server/dev.js';
13
14
  import { devPort, readEnv } from '../server/env.js';
14
15
  import { PACKAGE, PACKAGE_KIT, SHELL } from '../server/paths.js';
@@ -61,15 +62,15 @@ export function designHub({ root }) {
61
62
  }
62
63
  /**
63
64
  * The whole config `design-hub dev` and `design-hub build` run on. One port per
64
- * project: Conductor's per-workspace port when it runs us, else the PORT
65
- * `design-hub bootstrap` wrote to .env. strictPort refuses a taken port rather
65
+ * folder: the harness's ($DESIGN_HUB_PORT, $CONDUCTOR_PORT) when one runs us,
66
+ * else the PORT `design-hub dev` or `bootstrap` claimed in .env. strictPort refuses a taken port rather
66
67
  * than drifting to the next one, because a fallback silently serves the wrong
67
68
  * project. A build needs no port, so Vercel builds without a .env.
68
69
  */
69
70
  export function hubConfig({ root }, command) {
70
71
  const port = devPort(readEnv(root));
71
72
  if (command === 'serve' && port === undefined)
72
- throw new Error('PORT is not set. Run `design-hub bootstrap` once to pick a free port and write it to .env.');
73
+ throw new Error('PORT is not set. `design-hub dev` claims one; a config of your own runs `design-hub bootstrap` first.');
73
74
  return {
74
75
  configFile: false,
75
76
  root: SHELL,
@@ -79,7 +80,9 @@ export function hubConfig({ root }, command) {
79
80
  plugins: designHub({ root }),
80
81
  resolve: { dedupe: ['react', 'react-dom'] },
81
82
  optimizeDeps: { entries: [join(SHELL, 'index.html'), join(root, 'prototypes', '*', 'index.tsx')] },
82
- server: { port, strictPort: true, fs: { allow: [root, PACKAGE] } },
83
+ // A linked package (a checkout of this repo) keeps its dependencies in
84
+ // the checkout's node_modules, outside both; its workspace root covers them.
85
+ server: { port, strictPort: true, fs: { allow: [root, PACKAGE, searchForWorkspaceRoot(PACKAGE)] } },
83
86
  preview: port === undefined ? undefined : { port, strictPort: true },
84
87
  build: { outDir: join(root, 'dist'), emptyOutDir: true },
85
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milesroxas/design-hub",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "The Design Hub product: the hub that lists and serves prototypes, the review panel and its API, the password gate, the kit, and the design-hub CLI. A project installs it and keeps only its prototypes, kit materials and docs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,11 +34,12 @@ Never send the link with a failed step, and never skip one with `--skip` without
34
34
  1. The user signs in once, in their own terminal: `vercel login`. It is interactive; never run it for them.
35
35
  2. From the project root:
36
36
  ```bash
37
- pnpm bootstrap --vercel <project-name>
37
+ pnpm bootstrap --vercel <project-name> --team <team-slug>
38
38
  ```
39
- It links the project, writes `GATE_PASSWORD` (12 readable characters, or `--gate-password <word>`) and a fresh `GATE_SECRET` to `.env` and to Production and Preview, and prints the password in `share.password`. A second run keeps what is set.
39
+ `--team` is the Vercel team the project is created in (`vercel teams ls` lists them); without it the CLI's current team wins, which is often not the one the user means. It links the project, writes `GATE_PASSWORD` (12 readable characters, or `--gate-password <word>`) and a fresh `GATE_SECRET` to `.env` and to Production and Preview, and prints the password in `share.password`. A second run keeps what is set.
40
40
  3. `pnpm preflight`, then push `main` (or `vercel --prod` from the project root).
41
- 4. The handover message, in one message: the URL, the password, which prototype to open (the sprint's slug), the walkthrough at `/guide/<feature>/`, and that Controls is the round button at the top of the page. A reviewer who is not told about Controls reviews one state. Never send `GATE_SECRET`.
41
+ 4. On a Pro team Vercel turns Vercel Authentication on for every new project, which sends each visitor to a Vercel login before the gate. Turn it off for Production once: Project Settings, Deployment Protection, Vercel Authentication, set to Only Preview Deployments or Disabled. The hub's gate is the protection. Test by opening the production URL in a private window: the password page must appear, not vercel.com.
42
+ 5. The handover message, in one message: the URL, the password, which prototype to open (the sprint's slug), the walkthrough at `/guide/<feature>/`, and that Controls is the round button at the top of the page. A reviewer who is not told about Controls reviews one state. Never send `GATE_SECRET`.
42
43
 
43
44
  ## Comments
44
45
 
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: miles-worktrees
3
+ description: "Run this project in more than one checkout at once: git worktrees, T3 Code, Conductor, Claude Code, Cursor, or any harness that gives each agent its own folder. A port per folder, the harness port variables, the machine port registry, Conductor's settings file, and what checkouts never share. Load when the user mentions worktrees, workspaces, parallel agents, a harness, or a dev server that answers with the wrong project. Triggers on: worktree, worktrees, workspace, workspaces, parallel agents, Conductor, T3 Code, CONDUCTOR_PORT, DESIGN_HUB_PORT, port taken, port collision, wrong site, ports.json, port registry."
4
+ ---
5
+
6
+ # Worktrees and parallel agents
7
+
8
+ Every checkout of this project is its own folder with its own dev server and its own port: the main clone, each `git worktree`, each harness workspace. Two folders on one port is the failure everything here prevents. The second server refuses to start, or the browser shows the other folder's prototypes with no error.
9
+
10
+ ## Start a new checkout
11
+
12
+ ```bash
13
+ pnpm install
14
+ pnpm dev
15
+ ```
16
+
17
+ Nothing else is needed, in any harness. On its first start `pnpm dev` claims a free port for the folder and writes it to `.env` and `.claude/launch.json`; later starts reuse it. Both files are gitignored because they belong to the folder, so worktrees never conflict on them. `pnpm bootstrap` remains the full setup (TypeSafe key, Vercel) but is not needed to run.
18
+
19
+ Some harnesses copy `.env` from the main checkout. That is fine: the copied `PORT` is claimed by the other folder, so this folder gets its own and the dev server says so on stderr. The copied `GATE_*` and `TYPESAFE_API_KEY` keep working.
20
+
21
+ ## Ports
22
+
23
+ ```
24
+ Which port does the dev server use?
25
+ ├── $DESIGN_HUB_PORT set → that port. Any harness or script that hands out ports sets this.
26
+ ├── $CONDUCTOR_PORT set → that port. Conductor sets it per workspace.
27
+ └── neither → this folder's PORT in .env, claimed in the machine registry
28
+ (~/.config/design-hub/ports.json; $DESIGN_HUB_PORTS moves it).
29
+ A port another folder claims is skipped, running or not.
30
+ ```
31
+
32
+ A harness port never enters `.env` or the registry: the harness owns that range. A bare `$PORT` is ignored on purpose. Why: shells and other tools export it for their own servers, and the hub would start on it.
33
+
34
+ A claim lasts as long as its folder. A deleted worktree's claim is dropped the next time any folder claims a port.
35
+
36
+ On a taken port, `pnpm dev` refuses to start (`strictPort`) and `pnpm bootstrap` names the process and its folder. Then:
37
+
38
+ 1. Never pass another port. Why: the harness's Open button, `.env` and `.claude/launch.json` keep pointing at the configured one, so a second port is a server nobody opens.
39
+ 2. Find the holder: `lsof -nP -iTCP:<port> -sTCP:LISTEN`, then `lsof -a -p <pid> -d cwd` for its folder.
40
+ 3. An orphaned server of this folder: stop it. A server of another folder or project: ask the user before stopping it.
41
+
42
+ ## Harnesses
43
+
44
+ - **T3 Code, Claude Code worktrees, Cursor, plain `git worktree add`.** Nothing to configure. A harness setup step needs only `pnpm install`. A harness that starts the server itself and picks ports sets `DESIGN_HUB_PORT`.
45
+ - **Conductor.**
46
+ 1. From the project root: `pnpm bootstrap --conductor --branch <integration branch>`. It writes `.conductor/settings.toml`: setup installs and bootstraps each new workspace, run starts `pnpm dev`, and the preview URL is the workspace's `$CONDUCTOR_PORT`. A file the user has edited is kept, and the JSON warns that it differs.
47
+ 2. Commit `.conductor/settings.toml`. `.conductor/settings.local.toml` is gitignored, for one person's overrides.
48
+ 3. Tell the user to add the repository in Conductor and set its base branch to the same branch. Conductor keeps that setting in its app only, so nothing in the repo can set or check it.
49
+
50
+ Conductor never checks that its port is free. Why it matters: a server orphaned in another project can hold it, and the Open button then shows that project. The bootstrap in the workspace's setup fails loudly when that happens.
51
+
52
+ ## Nothing shared
53
+
54
+ Checkouts share no database and no container. The review store's local copy is a file in each checkout's `.design-hub/`, and the deployed store is remote, so two checkouts never see each other's local comments.
55
+
56
+ When the user keeps their own record of which project owns which port, add this folder's port to it too.
@@ -1,37 +0,0 @@
1
- ---
2
- name: miles-conductor
3
- description: "Run this project in Conductor (conductor.build) workspaces: the settings file, a port per workspace, and the machine port registry that keeps projects off each other's ports. Load when the user mentions Conductor, workspaces, parallel agents, or a dev server that answers with the wrong project. Triggers on: Conductor, workspace, workspaces, CONDUCTOR_PORT, parallel agents, port taken, port collision, wrong site, ports.json, port registry."
4
- ---
5
-
6
- # Conductor
7
-
8
- Conductor gives each workspace its own `$CONDUCTOR_PORT`, but never checks that the port is free. A dev server orphaned in another project can still hold it, and the workspace's Open button then shows the other project with no error. Everything here exists to make that failure loud.
9
-
10
- ## Set up
11
-
12
- 1. From the project root: `pnpm bootstrap --conductor --branch <integration branch>`. It writes `.conductor/settings.toml`: setup runs `pnpm bootstrap` in each new workspace, run starts `pnpm dev`, and the preview URL is the workspace's port. A file the user has edited is kept, and the JSON warns that it differs.
13
- 2. Commit `.conductor/settings.toml`. `.conductor/settings.local.toml` is gitignored, for one person's overrides.
14
- 3. Tell the user to add the repository in Conductor and set its base branch to the same branch. Conductor keeps that setting in its app only, so nothing in the repo can set or check it.
15
-
16
- ## Ports
17
-
18
- ```
19
- Where is the dev server running?
20
- ├── A Conductor workspace → $CONDUCTOR_PORT, always. The dev server reads it before .env.
21
- │ It never enters the machine registry: Conductor owns that range.
22
- └── A main checkout → the port pnpm bootstrap picked, written to .env and claimed in
23
- ~/.config/design-hub/ports.json ($DESIGN_HUB_PORTS overrides the path).
24
- Bootstrap skips every port another project claims, running or not.
25
- ```
26
-
27
- On a taken port, `pnpm dev` refuses to start (`strictPort`) and `pnpm bootstrap` names the process and its folder. Then:
28
-
29
- 1. Never pass another port. Why: the Open button and `.claude/launch.json` keep pointing at the configured one, so a second port is a server nobody opens.
30
- 2. Find the holder: `lsof -nP -iTCP:<port> -sTCP:LISTEN`, then `lsof -a -p <pid> -d cwd` for its folder.
31
- 3. An orphaned server of this project: stop it. A server of another project: ask the user before stopping it.
32
-
33
- When the user keeps their own record of which project owns which port, add this project's port to it too.
34
-
35
- ## Nothing shared
36
-
37
- Workspaces share no database and no container. The review store's local copy is a file in each checkout's `.design-hub/`, and the deployed store is remote, so two workspaces never see each other's local comments.