@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.
- package/dist/scripts/bootstrap.d.ts +16 -0
- package/dist/scripts/bootstrap.js +96 -20
- package/dist/scripts/dev.d.ts +2 -2
- package/dist/scripts/dev.js +13 -2
- package/dist/scripts/lib/vercel.d.ts +3 -1
- package/dist/scripts/lib/vercel.js +5 -2
- package/dist/scripts/skills.js +4 -0
- package/dist/server/env.d.ts +15 -3
- package/dist/server/env.js +23 -5
- package/dist/vite/plugin.d.ts +3 -3
- package/dist/vite/plugin.js +7 -4
- package/package.json +1 -1
- package/skills/miles-deploy/SKILL.md +4 -3
- package/skills/miles-worktrees/SKILL.md +56 -0
- package/skills/miles-conductor/SKILL.md +0 -37
|
@@ -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
|
|
64
|
-
if (
|
|
65
|
-
//
|
|
66
|
-
const
|
|
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(`
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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 =
|
|
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 = {
|
package/dist/scripts/dev.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
};
|
package/dist/scripts/dev.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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'))
|
|
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]);
|
package/dist/scripts/skills.js
CHANGED
|
@@ -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))
|
package/dist/server/env.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {};
|
package/dist/server/env.js
CHANGED
|
@@ -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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
56
|
-
return Number.isInteger(port) && port > 0 ? port : undefined;
|
|
74
|
+
return harnessPort(processEnv)?.port ?? asPort(fileEnv.PORT);
|
|
57
75
|
}
|
package/dist/vite/plugin.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
*
|
|
11
|
-
* `design-hub bootstrap`
|
|
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
|
*/
|
package/dist/vite/plugin.js
CHANGED
|
@@ -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
|
-
*
|
|
65
|
-
* `design-hub bootstrap`
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|