@milesroxas/design-hub 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +80 -2
- package/client.d.ts +6 -0
- package/configs/biome/biome.json +63 -0
- package/configs/tsconfig.project.json +17 -0
- package/core/bytes.test.ts +14 -0
- package/core/bytes.ts +14 -0
- package/core/catalog.test.ts +44 -0
- package/core/catalog.ts +43 -0
- package/core/checks.test.ts +23 -0
- package/core/checks.ts +72 -0
- package/core/contacts.test.ts +31 -0
- package/core/contacts.ts +38 -0
- package/core/doc.ts +4 -0
- package/core/feature.ts +6 -0
- package/core/fixtures.test.ts +33 -0
- package/core/fixtures.ts +69 -0
- package/core/frontmatter.ts +15 -0
- package/core/html.ts +3 -0
- package/core/image.test.ts +22 -0
- package/core/image.ts +37 -0
- package/core/jev.test.ts +66 -0
- package/core/jev.ts +77 -0
- package/core/review/model.ts +189 -0
- package/core/review/policy.ts +26 -0
- package/core/review/service.test.ts +163 -0
- package/core/review/service.ts +309 -0
- package/core/review/store.ts +51 -0
- package/core/review/triage.test.ts +41 -0
- package/core/review/triage.ts +58 -0
- package/core/states.test.ts +36 -0
- package/core/states.ts +34 -0
- package/dist/core/bytes.d.ts +5 -0
- package/dist/core/bytes.js +9 -0
- package/dist/core/catalog.d.ts +17 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/checks.d.ts +41 -0
- package/dist/core/checks.js +53 -0
- package/dist/core/contacts.d.ts +9 -0
- package/dist/core/contacts.js +34 -0
- package/dist/core/doc.d.ts +11 -0
- package/dist/core/doc.js +1 -0
- package/dist/core/feature.d.ts +3 -0
- package/dist/core/feature.js +4 -0
- package/dist/core/fixtures.d.ts +19 -0
- package/dist/core/fixtures.js +52 -0
- package/dist/core/frontmatter.d.ts +4 -0
- package/dist/core/frontmatter.js +15 -0
- package/dist/core/html.d.ts +2 -0
- package/dist/core/html.js +2 -0
- package/dist/core/image.d.ts +6 -0
- package/dist/core/image.js +34 -0
- package/dist/core/jev.d.ts +54 -0
- package/dist/core/jev.js +52 -0
- package/dist/core/review/model.d.ts +85 -0
- package/dist/core/review/model.js +112 -0
- package/dist/core/review/policy.d.ts +3 -0
- package/dist/core/review/policy.js +19 -0
- package/dist/core/review/service.d.ts +79 -0
- package/dist/core/review/service.js +245 -0
- package/dist/core/review/store.d.ts +20 -0
- package/dist/core/review/store.js +37 -0
- package/dist/core/review/triage.d.ts +6 -0
- package/dist/core/review/triage.js +45 -0
- package/dist/core/states.d.ts +6 -0
- package/dist/core/states.js +29 -0
- package/dist/scripts/bootstrap.d.ts +51 -0
- package/dist/scripts/bootstrap.js +278 -0
- package/dist/scripts/calibrate.d.ts +127 -0
- package/dist/scripts/calibrate.js +164 -0
- package/dist/scripts/check.d.ts +25 -0
- package/dist/scripts/check.js +30 -0
- package/dist/scripts/cli.d.ts +2 -0
- package/dist/scripts/cli.js +73 -0
- package/dist/scripts/commands.d.ts +25 -0
- package/dist/scripts/commands.js +49 -0
- package/dist/scripts/comments.d.ts +63 -0
- package/dist/scripts/comments.js +152 -0
- package/dist/scripts/dev.d.ts +46 -0
- package/dist/scripts/dev.js +84 -0
- package/dist/scripts/elyx.d.ts +61 -0
- package/dist/scripts/elyx.js +195 -0
- package/dist/scripts/fixtures-pull.d.ts +60 -0
- package/dist/scripts/fixtures-pull.js +121 -0
- package/dist/scripts/guard.d.ts +58 -0
- package/dist/scripts/guard.js +201 -0
- package/dist/scripts/hook.d.ts +18 -0
- package/dist/scripts/hook.js +46 -0
- package/dist/scripts/lib/bin.d.ts +4 -0
- package/dist/scripts/lib/bin.js +20 -0
- package/dist/scripts/lib/catalog.d.ts +7 -0
- package/dist/scripts/lib/catalog.js +18 -0
- package/dist/scripts/lib/cli.d.ts +75 -0
- package/dist/scripts/lib/cli.js +88 -0
- package/dist/scripts/lib/css-tokens.d.ts +13 -0
- package/dist/scripts/lib/css-tokens.js +67 -0
- package/dist/scripts/lib/judge.d.ts +24 -0
- package/dist/scripts/lib/judge.js +60 -0
- package/dist/scripts/lib/ports.d.ts +35 -0
- package/dist/scripts/lib/ports.js +113 -0
- package/dist/scripts/lib/review.d.ts +7 -0
- package/dist/scripts/lib/review.js +22 -0
- package/dist/scripts/lib/rules.d.ts +12 -0
- package/dist/scripts/lib/rules.js +20 -0
- package/dist/scripts/lib/vercel.d.ts +17 -0
- package/dist/scripts/lib/vercel.js +53 -0
- package/dist/scripts/new.d.ts +47 -0
- package/dist/scripts/new.js +113 -0
- package/dist/scripts/people.d.ts +59 -0
- package/dist/scripts/people.js +73 -0
- package/dist/scripts/preflight.d.ts +35 -0
- package/dist/scripts/preflight.js +251 -0
- package/dist/scripts/release-check.d.ts +19 -0
- package/dist/scripts/release-check.js +39 -0
- package/dist/scripts/skill-hint.d.ts +32 -0
- package/dist/scripts/skill-hint.js +103 -0
- package/dist/scripts/skills.d.ts +13 -0
- package/dist/scripts/skills.js +34 -0
- package/dist/scripts/visual-match.d.ts +46 -0
- package/dist/scripts/visual-match.js +93 -0
- package/dist/server/api.d.ts +22 -0
- package/dist/server/api.js +143 -0
- package/dist/server/checks.d.ts +4 -0
- package/dist/server/checks.js +22 -0
- package/dist/server/dev.d.ts +2 -0
- package/dist/server/dev.js +54 -0
- package/dist/server/env.d.ts +8 -0
- package/dist/server/env.js +57 -0
- package/dist/server/fake-upstash.d.ts +1 -0
- package/dist/server/fake-upstash.js +37 -0
- package/dist/server/file-kv.d.ts +2 -0
- package/dist/server/file-kv.js +24 -0
- package/dist/server/local.d.ts +4 -0
- package/dist/server/local.js +25 -0
- package/dist/server/paths.d.ts +22 -0
- package/dist/server/paths.js +81 -0
- package/dist/server/session.d.ts +22 -0
- package/dist/server/session.js +110 -0
- package/dist/server/upstash.d.ts +7 -0
- package/dist/server/upstash.js +41 -0
- package/dist/vercel/index.d.ts +5 -0
- package/dist/vercel/index.js +12 -0
- package/dist/vercel/proxy.d.ts +11 -0
- package/dist/vercel/proxy.js +238 -0
- package/dist/vite/kit.d.ts +4 -0
- package/dist/vite/kit.js +35 -0
- package/dist/vite/markdown.d.ts +2 -0
- package/dist/vite/markdown.js +212 -0
- package/dist/vite/plugin.d.ts +15 -0
- package/dist/vite/plugin.js +84 -0
- package/dist/vite/registry.d.ts +7 -0
- package/dist/vite/registry.js +70 -0
- package/kit/Icon.tsx +39 -0
- package/kit/Space.tsx +25 -0
- package/kit/Variants.tsx +134 -0
- package/kit/ViewTools.tsx +34 -0
- package/kit/base.css +27 -0
- package/kit/dials.css +98 -0
- package/kit/dials.tsx +322 -0
- package/kit/fixtures.ts +3 -0
- package/kit/format.ts +13 -0
- package/kit/harness.css +99 -0
- package/kit/hooks.ts +35 -0
- package/kit/icons.ts +329 -0
- package/kit/motion.ts +18 -0
- package/kit/prefs.ts +111 -0
- package/kit/space.css +12 -0
- package/kit/ui/Button.tsx +135 -0
- package/kit/ui/Menu.tsx +386 -0
- package/kit/ui/Overlay.tsx +300 -0
- package/kit/ui/Toast.tsx +187 -0
- package/kit/ui/index.ts +13 -0
- package/kit/ui/layer.ts +51 -0
- package/kit/ui/ui.css +238 -0
- package/package.json +87 -3
- package/shell/Guide.tsx +160 -0
- package/shell/Index.tsx +301 -0
- package/shell/Notice.tsx +27 -0
- package/shell/Prototype.tsx +31 -0
- package/shell/a11y.ts +50 -0
- package/shell/doc.d.ts +7 -0
- package/shell/doc.ts +3 -0
- package/shell/guide.css +133 -0
- package/shell/index.html +17 -0
- package/shell/main.tsx +53 -0
- package/shell/public/favicon.svg +1 -0
- package/shell/registry.ts +21 -0
- package/shell/registry.virtual.d.ts +10 -0
- package/shell/review/Review.tsx +989 -0
- package/shell/review/anchor.ts +132 -0
- package/shell/review/client.ts +165 -0
- package/shell/review/mount.tsx +12 -0
- package/shell/review/review.css +296 -0
- package/shell/shell.css +160 -0
- package/shell/system.css +238 -0
- package/skills/miles-conductor/SKILL.md +37 -0
- package/skills/miles-deploy/SKILL.md +67 -0
- package/skills/miles-design-hub/SKILL.md +62 -0
- package/skills/miles-get-hostile/SKILL.md +61 -0
- package/skills/miles-new-feature/SKILL.md +40 -0
- package/skills/miles-paper-to-prototype/SKILL.md +46 -0
- package/skills/miles-port/SKILL.md +59 -0
- package/skills/miles-port/references/astro.md +35 -0
- package/skills/miles-port/references/next.md +36 -0
- package/skills/miles-prototype/SKILL.md +83 -0
- package/skills/miles-prototype/references/accessibility.md +48 -0
- package/skills/miles-prototype/references/motion.md +59 -0
- package/skills/miles-prototype/references/performance.md +45 -0
- package/skills/miles-review/SKILL.md +39 -0
- package/templates/app/README.md +41 -0
- package/templates/app/index.tsx +32 -0
- package/templates/app/styles.css +1 -0
- package/templates/conductor/settings.toml +29 -0
- package/templates/guide.md +38 -0
- package/templates/space/README.md +37 -0
- package/templates/space/index.tsx +43 -0
- package/templates/space/styles.css +13 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// The project's .env as key: value pairs, for the scripts and the dev server.
|
|
3
|
+
// Vite reads the same file on its own (vite.config.ts). No dotenv:
|
|
4
|
+
// the file is a flat list and a dependency for it would be the kind of
|
|
5
|
+
// gravity this repo avoids.
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
const ENV_FILE = '.env';
|
|
9
|
+
export function readEnv(dir) {
|
|
10
|
+
const file = join(dir, ENV_FILE);
|
|
11
|
+
if (!existsSync(file))
|
|
12
|
+
return {};
|
|
13
|
+
const env = {};
|
|
14
|
+
for (const raw of readFileSync(file, 'utf8').split('\n')) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line || line.startsWith('#'))
|
|
17
|
+
continue;
|
|
18
|
+
const at = line.indexOf('=');
|
|
19
|
+
if (at <= 0)
|
|
20
|
+
continue;
|
|
21
|
+
env[line.slice(0, at).trim()] = line
|
|
22
|
+
.slice(at + 1)
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/^(["'])(.*)\1$/, '$2');
|
|
25
|
+
}
|
|
26
|
+
return env;
|
|
27
|
+
}
|
|
28
|
+
// Rewrites one key in place and keeps every other line, comments included,
|
|
29
|
+
// so a second run changes nothing.
|
|
30
|
+
export function writeEnv(dir, updates) {
|
|
31
|
+
const file = join(dir, ENV_FILE);
|
|
32
|
+
const lines = existsSync(file) ? readFileSync(file, 'utf8').split('\n') : [];
|
|
33
|
+
const pending = { ...updates };
|
|
34
|
+
const next = lines.map((line) => {
|
|
35
|
+
const key = line.match(/^\s*([A-Z0-9_]+)\s*=/)?.[1];
|
|
36
|
+
if (!key || !(key in pending))
|
|
37
|
+
return line;
|
|
38
|
+
const value = pending[key];
|
|
39
|
+
delete pending[key];
|
|
40
|
+
return `${key}=${value}`;
|
|
41
|
+
});
|
|
42
|
+
while (next.length && next.at(-1)?.trim() === '')
|
|
43
|
+
next.pop();
|
|
44
|
+
for (const [key, value] of Object.entries(pending))
|
|
45
|
+
next.push(`${key}=${value}`);
|
|
46
|
+
const text = next.join('\n').replace(/\n*$/, '\n');
|
|
47
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
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.
|
|
53
|
+
*/
|
|
54
|
+
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;
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fakeUpstash(): typeof fetch;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// A fetch that answers like Upstash's REST API from memory, for tests that
|
|
3
|
+
// exercise the deployed code path (proxy.ts, api/review.ts) without an account.
|
|
4
|
+
import { memoryKV } from '../core/review/store.js';
|
|
5
|
+
export function fakeUpstash() {
|
|
6
|
+
const kv = memoryKV();
|
|
7
|
+
async function run(cmd, [key, a, b]) {
|
|
8
|
+
switch (cmd) {
|
|
9
|
+
case 'HGET':
|
|
10
|
+
return kv.hget(key, a);
|
|
11
|
+
case 'HSET':
|
|
12
|
+
await kv.hset(key, a, b);
|
|
13
|
+
return 1;
|
|
14
|
+
case 'HDEL':
|
|
15
|
+
await kv.hdel(key, a);
|
|
16
|
+
return 1;
|
|
17
|
+
case 'HGETALL':
|
|
18
|
+
return Object.entries(await kv.hgetall(key)).flat();
|
|
19
|
+
case 'SADD':
|
|
20
|
+
await kv.sadd(key, a);
|
|
21
|
+
return 1;
|
|
22
|
+
case 'SMEMBERS':
|
|
23
|
+
return kv.smembers(key);
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`unknown command ${cmd}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return (async (_url, init) => {
|
|
29
|
+
const [cmd, ...args] = JSON.parse(String(init?.body));
|
|
30
|
+
try {
|
|
31
|
+
return new Response(JSON.stringify({ result: await run(cmd, args) }));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return new Response(JSON.stringify({ error: String(error) }), { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// The review store on this machine: the in-memory store, saved to
|
|
3
|
+
// .design-hub/review.json after every write. The dev server and the CLI's
|
|
4
|
+
// --local use it, so the review UI works with no account and no network.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { memoryKV } from '../core/review/store.js';
|
|
8
|
+
export function fileKV(file) {
|
|
9
|
+
let initial = { hashes: {}, sets: {} };
|
|
10
|
+
if (existsSync(file)) {
|
|
11
|
+
try {
|
|
12
|
+
initial = JSON.parse(readFileSync(file, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`${file} is not valid JSON. Fix or delete it; it holds the local review data.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return memoryKV(initial, (snapshot) => {
|
|
19
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
20
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
21
|
+
writeFileSync(tmp, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
22
|
+
renameSync(tmp, file);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Person } from '../core/review/model.ts';
|
|
2
|
+
export declare const LOCAL_STORE: string;
|
|
3
|
+
/** Whoever runs this machine's dev server or CLI: an editor, because they hold the store itself. */
|
|
4
|
+
export declare const localEditor: (id?: string, team?: string) => Person;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// The review store and identity on this machine, shared by the dev server
|
|
3
|
+
// and the CLI: .design-hub/review.json, and a local editor named from git.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { STATE_DIR } from './paths.js';
|
|
7
|
+
export const LOCAL_STORE = join(STATE_DIR, 'review.json');
|
|
8
|
+
function gitName() {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync('git', ['config', 'user.name'], { encoding: 'utf8' }).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Whoever runs this machine's dev server or CLI: an editor, because they hold the store itself. */
|
|
17
|
+
export const localEditor = (id = 'local', team = 'Local') => ({
|
|
18
|
+
id,
|
|
19
|
+
name: gitName() || 'You',
|
|
20
|
+
team,
|
|
21
|
+
role: 'editor',
|
|
22
|
+
createdAt: new Date(0).toISOString(),
|
|
23
|
+
revokedAt: null,
|
|
24
|
+
epoch: 0,
|
|
25
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The project folder for a path inside it, or null when no ancestor installed design-hub. */
|
|
2
|
+
export declare function findProject(from: string): string | null;
|
|
3
|
+
/** The project: $DESIGN_HUB_ROOT, else the nearest project above the working directory, else the working directory. */
|
|
4
|
+
export declare function projectRoot(env?: Record<string, string | undefined>, cwd?: string): string;
|
|
5
|
+
export declare const ROOT: string;
|
|
6
|
+
/** One folder per prototype. */
|
|
7
|
+
export declare const PROTOTYPES: string;
|
|
8
|
+
/** The project's materials: tokens, the shell's chrome, its icons. */
|
|
9
|
+
export declare const KIT: string;
|
|
10
|
+
export declare const DOCS: string;
|
|
11
|
+
export declare const SKILLS: string;
|
|
12
|
+
/** Local state, gitignored but for checks.json: the Jev log and its labels, the dev review store. */
|
|
13
|
+
export declare const STATE_DIR: string;
|
|
14
|
+
export declare const CHECKS_FILE: string;
|
|
15
|
+
/** The nearest ancestor of `from` whose package.json names this package, so source and dist agree. */
|
|
16
|
+
export declare function packageRoot(from: string): string;
|
|
17
|
+
/** This package on disk, wherever it is installed: the shell, the kit, the templates, the skills. */
|
|
18
|
+
export declare const PACKAGE: string;
|
|
19
|
+
export declare const SHELL: string;
|
|
20
|
+
export declare const PACKAGE_KIT: string;
|
|
21
|
+
export declare const TEMPLATES: string;
|
|
22
|
+
export declare const PACKAGE_SKILLS: string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// Where things are, computed once. The project is the folder that installed
|
|
3
|
+
// design-hub: the nearest ancestor of the working directory whose package.json
|
|
4
|
+
// depends on it, or $DESIGN_HUB_ROOT. The scripts, the hooks and the dev
|
|
5
|
+
// server resolve every path from here, never from the current directory, so a
|
|
6
|
+
// hook run from the repo root and a script run from a subfolder behave the same.
|
|
7
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
const DEP = '@milesroxas/design-hub';
|
|
11
|
+
function dependsOnHub(dir) {
|
|
12
|
+
const file = join(dir, 'package.json');
|
|
13
|
+
if (!existsSync(file))
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
17
|
+
return Boolean(pkg.dependencies?.[DEP] ?? pkg.devDependencies?.[DEP]);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The project folder for a path inside it, or null when no ancestor installed design-hub. */
|
|
24
|
+
export function findProject(from) {
|
|
25
|
+
let dir = resolve(from);
|
|
26
|
+
for (;;) {
|
|
27
|
+
if (dependsOnHub(dir))
|
|
28
|
+
return dir;
|
|
29
|
+
const parent = dirname(dir);
|
|
30
|
+
if (parent === dir)
|
|
31
|
+
return null;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The project: $DESIGN_HUB_ROOT, else the nearest project above the working directory, else the working directory. */
|
|
36
|
+
export function projectRoot(env = process.env, cwd = process.cwd()) {
|
|
37
|
+
const root = env.DESIGN_HUB_ROOT ? resolve(env.DESIGN_HUB_ROOT) : (findProject(cwd) ?? resolve(cwd));
|
|
38
|
+
// Real, so a file path a hook hands over compares with it whatever symlink it came through.
|
|
39
|
+
try {
|
|
40
|
+
return realpathSync(root);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const ROOT = projectRoot();
|
|
47
|
+
/** One folder per prototype. */
|
|
48
|
+
export const PROTOTYPES = join(ROOT, 'prototypes');
|
|
49
|
+
/** The project's materials: tokens, the shell's chrome, its icons. */
|
|
50
|
+
export const KIT = join(ROOT, 'kit');
|
|
51
|
+
export const DOCS = join(ROOT, 'docs');
|
|
52
|
+
export const SKILLS = join(ROOT, '.claude', 'skills');
|
|
53
|
+
/** Local state, gitignored but for checks.json: the Jev log and its labels, the dev review store. */
|
|
54
|
+
export const STATE_DIR = join(ROOT, '.design-hub');
|
|
55
|
+
export const CHECKS_FILE = join(STATE_DIR, 'checks.json');
|
|
56
|
+
/** The nearest ancestor of `from` whose package.json names this package, so source and dist agree. */
|
|
57
|
+
export function packageRoot(from) {
|
|
58
|
+
let dir = dirname(from);
|
|
59
|
+
for (;;) {
|
|
60
|
+
const file = join(dir, 'package.json');
|
|
61
|
+
if (existsSync(file)) {
|
|
62
|
+
try {
|
|
63
|
+
if (JSON.parse(readFileSync(file, 'utf8')).name === DEP)
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Not ours; keep climbing.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const parent = dirname(dir);
|
|
71
|
+
if (parent === dir)
|
|
72
|
+
throw new Error(`design-hub's package.json is not above ${from}.`);
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** This package on disk, wherever it is installed: the shell, the kit, the templates, the skills. */
|
|
77
|
+
export const PACKAGE = packageRoot(fileURLToPath(import.meta.url));
|
|
78
|
+
export const SHELL = join(PACKAGE, 'shell');
|
|
79
|
+
export const PACKAGE_KIT = join(PACKAGE, 'kit');
|
|
80
|
+
export const TEMPLATES = join(PACKAGE, 'templates');
|
|
81
|
+
export const PACKAGE_SKILLS = join(PACKAGE, 'skills');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const GATE_COOKIE = "__Host-hub_gate";
|
|
2
|
+
export declare const PERSON_COOKIE = "__Host-hub_person";
|
|
3
|
+
export type Gate = {
|
|
4
|
+
password: string;
|
|
5
|
+
secret: string;
|
|
6
|
+
};
|
|
7
|
+
/** The gate's settings, or null when either is missing (the gate then fails closed). */
|
|
8
|
+
export declare function readGate(env: Record<string, string | undefined>): Gate | null;
|
|
9
|
+
export declare function passwordMatches(gate: Gate, submitted: string): Promise<boolean>;
|
|
10
|
+
export declare function issueGateToken(gate: Gate): Promise<string>;
|
|
11
|
+
export declare function gateTokenIsValid(gate: Gate, token: string | undefined): Promise<boolean>;
|
|
12
|
+
export type PersonClaim = {
|
|
13
|
+
id: string;
|
|
14
|
+
epoch: number;
|
|
15
|
+
};
|
|
16
|
+
export declare function issuePersonToken(gate: Gate, claim: PersonClaim): Promise<string>;
|
|
17
|
+
/** The person a token names when its signature and expiry hold; the caller still checks the store. */
|
|
18
|
+
export declare function readPersonToken(gate: Gate, token: string | undefined): Promise<PersonClaim | null>;
|
|
19
|
+
export declare function readCookie(request: Request, name: string): string | undefined;
|
|
20
|
+
export declare const sessionCookie: (name: string, value: string) => string;
|
|
21
|
+
export declare const clearedCookie: (name: string) => string;
|
|
22
|
+
export declare function sameOrigin(request: Request): boolean;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Signed cookies for the deployed prototypes, on Web Crypto only, so the
|
|
2
|
+
// gate (proxy.ts), the review function (api/review.ts) and their tests share
|
|
3
|
+
// one implementation. Two sessions:
|
|
4
|
+
//
|
|
5
|
+
// gate the shared password was typed: view everything, comment on nothing.
|
|
6
|
+
// The signature covers the password, so rotating it signs everyone out.
|
|
7
|
+
// person an invite link was opened: view everything, and comment as that
|
|
8
|
+
// person. The signature covers the person's epoch, which a reissue
|
|
9
|
+
// or revoke bumps, so either ends every session they hold.
|
|
10
|
+
//
|
|
11
|
+
// Both are HMAC-SHA256 with GATE_SECRET and last 30 days.
|
|
12
|
+
import { base64url, sha256 } from '../core/bytes.js';
|
|
13
|
+
export const GATE_COOKIE = '__Host-hub_gate';
|
|
14
|
+
export const PERSON_COOKIE = '__Host-hub_person';
|
|
15
|
+
const SESSION_SECONDS = 60 * 60 * 24 * 30;
|
|
16
|
+
/** The gate's settings, or null when either is missing (the gate then fails closed). */
|
|
17
|
+
export function readGate(env) {
|
|
18
|
+
const password = env.GATE_PASSWORD ?? '';
|
|
19
|
+
const secret = env.GATE_SECRET ?? '';
|
|
20
|
+
return password.length > 0 && secret.length >= 32 ? { password, secret } : null;
|
|
21
|
+
}
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
async function hmac(secret, message) {
|
|
24
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
25
|
+
return base64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(message))));
|
|
26
|
+
}
|
|
27
|
+
// Runs over every byte whatever the input, so a mismatch costs the same time
|
|
28
|
+
// wherever it falls. Callers pass equal-length digests, so length is not a leak.
|
|
29
|
+
function timingSafeEqual(a, b) {
|
|
30
|
+
let diff = a.length ^ b.length;
|
|
31
|
+
for (let i = 0; i < a.length; i++)
|
|
32
|
+
diff |= a[i] ^ (b[i % b.length] ?? 0);
|
|
33
|
+
return diff === 0;
|
|
34
|
+
}
|
|
35
|
+
const sameText = (a, b) => timingSafeEqual(encoder.encode(a), encoder.encode(b));
|
|
36
|
+
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
37
|
+
const live = (expires) => Number.isInteger(expires) && expires > nowSeconds();
|
|
38
|
+
export async function passwordMatches(gate, submitted) {
|
|
39
|
+
const [a, b] = await Promise.all([sha256(submitted), sha256(gate.password)]);
|
|
40
|
+
return timingSafeEqual(a, b);
|
|
41
|
+
}
|
|
42
|
+
// A gate session token: <expires>.<sig>
|
|
43
|
+
async function gateSignature(gate, expires) {
|
|
44
|
+
return hmac(gate.secret, `v1.${expires}.${base64url(await sha256(gate.password))}`);
|
|
45
|
+
}
|
|
46
|
+
export async function issueGateToken(gate) {
|
|
47
|
+
const expires = nowSeconds() + SESSION_SECONDS;
|
|
48
|
+
return `${expires}.${await gateSignature(gate, expires)}`;
|
|
49
|
+
}
|
|
50
|
+
export async function gateTokenIsValid(gate, token) {
|
|
51
|
+
const [expires, sig, extra] = (token ?? '').split('.');
|
|
52
|
+
if (!sig || extra !== undefined || !live(Number(expires)))
|
|
53
|
+
return false;
|
|
54
|
+
return sameText(sig, await gateSignature(gate, Number(expires)));
|
|
55
|
+
}
|
|
56
|
+
const personMessage = (id, epoch, expires) => `p1.${id}.${epoch}.${expires}`;
|
|
57
|
+
export async function issuePersonToken(gate, claim) {
|
|
58
|
+
const expires = nowSeconds() + SESSION_SECONDS;
|
|
59
|
+
return `${claim.id}.${claim.epoch}.${expires}.${await hmac(gate.secret, personMessage(claim.id, claim.epoch, expires))}`;
|
|
60
|
+
}
|
|
61
|
+
/** The person a token names when its signature and expiry hold; the caller still checks the store. */
|
|
62
|
+
export async function readPersonToken(gate, token) {
|
|
63
|
+
const [id, epochText, expiresText, sig, extra] = (token ?? '').split('.');
|
|
64
|
+
const epoch = Number(epochText);
|
|
65
|
+
const expires = Number(expiresText);
|
|
66
|
+
if (!id ||
|
|
67
|
+
!sig ||
|
|
68
|
+
extra !== undefined ||
|
|
69
|
+
!/^[a-z0-9]+$/.test(id) ||
|
|
70
|
+
!Number.isInteger(epoch) ||
|
|
71
|
+
!live(expires))
|
|
72
|
+
return null;
|
|
73
|
+
return sameText(sig, await hmac(gate.secret, personMessage(id, epoch, expires))) ? { id, epoch } : null;
|
|
74
|
+
}
|
|
75
|
+
export function readCookie(request, name) {
|
|
76
|
+
const header = request.headers.get('cookie');
|
|
77
|
+
if (!header)
|
|
78
|
+
return undefined;
|
|
79
|
+
for (const part of header.split(';')) {
|
|
80
|
+
const eq = part.indexOf('=');
|
|
81
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
82
|
+
return part.slice(eq + 1).trim();
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
export const sessionCookie = (name, value) => `${name}=${value}; Path=/; Max-Age=${SESSION_SECONDS}; HttpOnly; Secure; SameSite=Lax`;
|
|
87
|
+
export const clearedCookie = (name) => `${name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
|
88
|
+
// Same-origin check for a state-changing request. Sec-Fetch-Site is the
|
|
89
|
+
// browser's own verdict; Origin backs it up for browsers without it. Host
|
|
90
|
+
// comes from the forwarded header, since request.url carries the deployment
|
|
91
|
+
// host behind a custom domain. A browser sends "Origin: null" on a navigation
|
|
92
|
+
// from a no-referrer document, so null is accepted only with a same-origin verdict.
|
|
93
|
+
export function sameOrigin(request) {
|
|
94
|
+
const site = request.headers.get('sec-fetch-site');
|
|
95
|
+
if (site && site !== 'same-origin')
|
|
96
|
+
return false;
|
|
97
|
+
const origin = request.headers.get('origin');
|
|
98
|
+
if (!origin)
|
|
99
|
+
return true;
|
|
100
|
+
if (origin === 'null')
|
|
101
|
+
return site === 'same-origin';
|
|
102
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? new URL(request.url).host;
|
|
103
|
+
try {
|
|
104
|
+
const o = new URL(origin);
|
|
105
|
+
return (o.protocol === 'https:' || o.hostname === 'localhost') && o.host === host;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { KV } from '../core/review/store.ts';
|
|
2
|
+
export type StoreConfig = {
|
|
3
|
+
url: string;
|
|
4
|
+
token: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function storeConfig(env: Record<string, string | undefined>): StoreConfig | null;
|
|
7
|
+
export declare function upstashKV({ url, token }: StoreConfig, fetcher?: typeof fetch): KV;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function storeConfig(env) {
|
|
2
|
+
const url = env.KV_REST_API_URL ?? env.UPSTASH_REDIS_REST_URL;
|
|
3
|
+
const token = env.KV_REST_API_TOKEN ?? env.UPSTASH_REDIS_REST_TOKEN;
|
|
4
|
+
return url && token ? { url: url.replace(/\/$/, ''), token } : null;
|
|
5
|
+
}
|
|
6
|
+
const TIMEOUT_MS = 5000;
|
|
7
|
+
export function upstashKV({ url, token }, fetcher = fetch) {
|
|
8
|
+
async function command(...args) {
|
|
9
|
+
const response = await fetcher(url, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
12
|
+
body: JSON.stringify(args),
|
|
13
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
14
|
+
});
|
|
15
|
+
const json = (await response.json().catch(() => ({})));
|
|
16
|
+
// A reply without `result` is a failure too, never an empty answer the service would trust.
|
|
17
|
+
if (!response.ok || json.error !== undefined || !('result' in json))
|
|
18
|
+
throw new Error(`Upstash ${args[0]} failed (${response.status}): ${json.error ?? 'no result in the reply'}`);
|
|
19
|
+
return json.result;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
hget: (key, field) => command('HGET', key, field),
|
|
23
|
+
hset: async (key, field, value) => {
|
|
24
|
+
await command('HSET', key, field, value);
|
|
25
|
+
},
|
|
26
|
+
hdel: async (key, field) => {
|
|
27
|
+
await command('HDEL', key, field);
|
|
28
|
+
},
|
|
29
|
+
hgetall: async (key) => {
|
|
30
|
+
const flat = (await command('HGETALL', key)) ?? [];
|
|
31
|
+
const out = {};
|
|
32
|
+
for (let i = 0; i + 1 < flat.length; i += 2)
|
|
33
|
+
out[flat[i]] = flat[i + 1];
|
|
34
|
+
return out;
|
|
35
|
+
},
|
|
36
|
+
sadd: async (key, member) => {
|
|
37
|
+
await command('SADD', key, member);
|
|
38
|
+
},
|
|
39
|
+
smembers: async (key) => (await command('SMEMBERS', key)) ?? [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createProxy, GATE_FONT, gatePreview } from './proxy.ts';
|
|
2
|
+
/** The /api/review function. `checks` is the project's checks.json, so triage runs on the project's thresholds. */
|
|
3
|
+
export declare function reviewFunction(checks?: unknown): {
|
|
4
|
+
fetch: (request: Request) => Promise<Response>;
|
|
5
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What a project's two Vercel entry files import. Each is one line:
|
|
2
|
+
//
|
|
3
|
+
// api/review.ts export default reviewFunction(checks) with its .design-hub/checks.json
|
|
4
|
+
// proxy.ts export default createProxy({ siteName: 'Prototypes' })
|
|
5
|
+
import { loadChecks } from '../core/checks.js';
|
|
6
|
+
import { deployedHandler } from '../server/api.js';
|
|
7
|
+
export { createProxy, GATE_FONT, gatePreview } from './proxy.js';
|
|
8
|
+
/** The /api/review function. `checks` is the project's checks.json, so triage runs on the project's thresholds. */
|
|
9
|
+
export function reviewFunction(checks = null) {
|
|
10
|
+
const loaded = loadChecks(checks).checks;
|
|
11
|
+
return { fetch: (request) => deployedHandler(request, loaded) };
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const GATE_FONT = "/_gate/geist.woff2";
|
|
2
|
+
export declare function gatePreview(failed: boolean, siteName?: string): Promise<{
|
|
3
|
+
html: string;
|
|
4
|
+
csp: string;
|
|
5
|
+
}>;
|
|
6
|
+
export type ProxyOptions = {
|
|
7
|
+
/** The gate page's title: the project's name as reviewers know it. */
|
|
8
|
+
siteName?: string;
|
|
9
|
+
};
|
|
10
|
+
/** The middleware a project's proxy.ts default-exports. */
|
|
11
|
+
export declare function createProxy({ siteName }?: ProxyOptions): (request: Request) => Promise<Response>;
|