basis-design 0.1.1 → 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/README.md +2 -1
- package/capture.html +12 -0
- package/package.json +72 -70
- package/server/brief.ts +14 -3
- package/server/capture.ts +128 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +112 -1
- package/server/project.ts +7 -1
- package/server/template.ts +168 -168
- package/server/verify-kit/capture.mjs +206 -0
- package/server/verify-kit/compare.d.mts +39 -0
- package/server/verify-kit/compare.mjs +307 -0
- package/server/verify.ts +383 -0
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +29 -1
- package/src/Workbench.tsx +1 -0
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/preview/specimens.tsx +21 -0
- package/src/shell/ExportDialog.tsx +41 -4
- package/src/shell/FolderField.tsx +131 -0
- package/src/shell/ProjectMenu.tsx +20 -18
- package/src/shell/Shell.module.css +1146 -1076
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -25
- package/template/components/specimen.ts +24 -1
- package/template/history.json +1 -0
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ To work on Basis itself, clone the repository and run `npm install` and `npm run
|
|
|
37
37
|
|
|
38
38
|
Each project is a workspace of its own: its own design system, history, chat and review. Switch, create and open projects from the menu with the project's name at the top left.
|
|
39
39
|
|
|
40
|
-
A project can link the **product repo** it designs for (project menu, **Link product repo…**). The agent reads and searches it without asking, to match the product's look, components and terms, and never writes there: writes outside the workspace are refused. **New project…** can also start from a product repo: the project takes the repo folder's name unless you give one, links the repo and starts by bringing in its design. **Bring in its design** (offered when you link, and in the project menu) starts a guided turn: the agent maps the repo's tokens onto `design-system.json`, restyles the components and patterns to resemble the product's, writes what it learned to `product.md` (where the tokens live, an inventory of the product's components and their Basis counterparts, terms to reuse) and opens a review.
|
|
40
|
+
A project can link the **product repo** it designs for (project menu, **Link product repo…**): pick it from the repos Basis finds in your usual code folders, choose it with **Browse…** in your system's folder dialog, or paste its path. The agent reads and searches it without asking, to match the product's look, components and terms, and never writes there: writes outside the workspace are refused. **New project…** can also start from a product repo: the project takes the repo folder's name unless you give one, links the repo and starts by bringing in its design. **Bring in its design** (offered when you link, and in the project menu) starts a guided turn: the agent maps the repo's tokens onto `design-system.json`, restyles the components and patterns to resemble the product's, writes what it learned to `product.md` (where the tokens live, an inventory of the product's components and their Basis counterparts, terms to reuse) and opens a review.
|
|
41
41
|
|
|
42
42
|
- **New project** starts from Basis' defaults, or as a copy of the open project's tokens and components (not its history). It lives in `~/.basis/workspaces/`.
|
|
43
43
|
- **Open folder** adds a workspace from anywhere, or an empty or new folder for Basis to set up, inside a product repo for instance. Basis refuses a folder that already has other files, so it never starts a history in someone's project.
|
|
@@ -107,6 +107,7 @@ Every `components/*.specimen.tsx` file becomes a gallery section and a sidebar e
|
|
|
107
107
|
- a README on wiring it up.
|
|
108
108
|
|
|
109
109
|
The kit needs React 18+ and CSS Modules, nothing else. The other tabs export the token formats one at a time.
|
|
110
|
+
- **Verification kit:** proof that a feature was built as its view shows. The **Verification kit** tab photographs every screen of every view at fixed widths (375, 768 and 1440 pixels, full page, unless the view's flow block sets `capture`) and packages the screenshots with a static, clickable copy of the design and two scripts, as a zip or into `basis-verify/` in the product's folder (the linked repo by default). The agent that implements the feature runs `capture.mjs` to photograph the design and its implementation on the same machine with the same settings, and `compare.mjs` to compare them pixel for pixel, aiming at 0% difference. Parts allowed to differ are named exceptions in `exceptions.json`, each with a reason. Basis takes the screenshots with Playwright's Chromium when it is installed (`npx playwright install chromium`), otherwise with Chrome or Edge; each screen is captured twice, and screens that render differently each time are reported, since nothing can match them.
|
|
110
111
|
|
|
111
112
|
## Privacy
|
|
112
113
|
|
package/capture.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Basis capture</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/capture/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,70 +1,72 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "basis-design",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Prompt a complete design system into existence with your own coding agent over ACP.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"design-system",
|
|
7
|
-
"design-tokens",
|
|
8
|
-
"react",
|
|
9
|
-
"claude-code",
|
|
10
|
-
"codex",
|
|
11
|
-
"agent-client-protocol"
|
|
12
|
-
],
|
|
13
|
-
"license": "Apache-2.0",
|
|
14
|
-
"author": "Lucas Skog",
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/sockulags/basis-design.git"
|
|
18
|
-
},
|
|
19
|
-
"type": "module",
|
|
20
|
-
"bin": {
|
|
21
|
-
"basis": "bin/basis.js"
|
|
22
|
-
},
|
|
23
|
-
"files": [
|
|
24
|
-
"bin",
|
|
25
|
-
"server",
|
|
26
|
-
"shared",
|
|
27
|
-
"src",
|
|
28
|
-
"template",
|
|
29
|
-
"index.html",
|
|
30
|
-
"preview.html",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"test
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"@
|
|
51
|
-
"@types/react
|
|
52
|
-
"@
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"@
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "basis-design",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Prompt a complete design system into existence with your own coding agent over ACP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"design-system",
|
|
7
|
+
"design-tokens",
|
|
8
|
+
"react",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"agent-client-protocol"
|
|
12
|
+
],
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"author": "Lucas Skog",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/sockulags/basis-design.git"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"basis": "bin/basis.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"server",
|
|
26
|
+
"shared",
|
|
27
|
+
"src",
|
|
28
|
+
"template",
|
|
29
|
+
"index.html",
|
|
30
|
+
"preview.html",
|
|
31
|
+
"capture.html",
|
|
32
|
+
"vite.config.ts",
|
|
33
|
+
"NOTICE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.12.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx server/index.ts",
|
|
40
|
+
"start": "tsx server/index.ts",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "node --import tsx --test \"test/*.test.ts\"",
|
|
43
|
+
"test:e2e": "playwright test",
|
|
44
|
+
"template:history": "tsx scripts/template-history.ts",
|
|
45
|
+
"lint": "oxlint",
|
|
46
|
+
"format": "prettier --write .",
|
|
47
|
+
"check": "tsc --noEmit && oxlint && prettier --check . && npm test"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@agentclientprotocol/sdk": "^1.9.0",
|
|
51
|
+
"@types/react": "^19.3.0",
|
|
52
|
+
"@types/react-dom": "^19.3.0",
|
|
53
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
54
|
+
"axe-core": "^4.14.0",
|
|
55
|
+
"fflate": "^0.8.3",
|
|
56
|
+
"playwright-core": "1.64.1",
|
|
57
|
+
"react": "^19.3.1",
|
|
58
|
+
"react-dom": "^19.3.1",
|
|
59
|
+
"tsx": "^4.23.15",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vite": "^8.3.4",
|
|
62
|
+
"ws": "^8.22.0",
|
|
63
|
+
"zod": "^4.6.7"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@playwright/test": "^1.64.1",
|
|
67
|
+
"@types/node": "^26.6.5",
|
|
68
|
+
"@types/ws": "^8.18.2",
|
|
69
|
+
"oxlint": "1.88.0",
|
|
70
|
+
"prettier": "3.9.10"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/server/brief.ts
CHANGED
|
@@ -160,9 +160,20 @@ The rules that always apply:
|
|
|
160
160
|
restyle or copy them inside a view: if a screen needs something they lack, extend the component or pattern,
|
|
161
161
|
or add a pattern. A view's own code is layout and wiring; a small layout style inline or in a
|
|
162
162
|
\`.module.css\` next to it is fine.
|
|
163
|
-
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
- A screen is the whole page as the product shows it at that size: its shell, navigation and page padding
|
|
164
|
+
included, filling the window. The verification kit photographs each screen alone at fixed widths, and the
|
|
165
|
+
implementation of the feature must match those images pixel for pixel.
|
|
166
|
+
- Fill views with realistic data, consistent across the screens (the row you open is the one the detail
|
|
167
|
+
shows). When a product repo is linked, use the product's own data where you can find it (fixtures, seeds,
|
|
168
|
+
example responses, its wording), so the implementation can show exactly the same records. Otherwise make it
|
|
169
|
+
up in the product's language. Keep data in the view file or a \`views/*-data.ts\` file.
|
|
170
|
+
- Screens must render the same every time: no \`Date.now()\`, \`new Date()\` without a fixed date, random
|
|
171
|
+
values, or animations and loading states that never settle. Basis captures each screen twice and reports
|
|
172
|
+
the ones that differ.
|
|
173
|
+
- A flow block may say how its screens are captured: \`capture: { widths: [375, 768, 1440], height: "full",
|
|
174
|
+
themes: ["light"] }\` (those are the defaults). \`height\` is \`"full"\` for the whole page or a number for
|
|
175
|
+
exactly one viewport; add \`"dark"\` to \`themes\` when the product has a dark theme. Set widths to the
|
|
176
|
+
product's breakpoints when you know them.
|
|
166
177
|
- Views are not exported with the design system and need no contract or index.
|
|
167
178
|
- To review a view, show its specimen; \`rows\` picks screens by label or id (\`"rows": ["Shipment"]\`). The panes
|
|
168
179
|
of a review move through the flow together, so before and after always show the same screen.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { chromium, type Browser, type LaunchOptions } from "playwright-core";
|
|
2
|
+
import { READY_ATTRIBUTE, shotFile, type CaptureShot, type CaptureView } from "../shared/capture.ts";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Taking the verification kit's screenshots: a headless browser opens each screen of each view on the capture
|
|
6
|
+
* page (capture.html) at the size its shots ask for. Everything that could make two runs differ is fixed: the
|
|
7
|
+
* device scale, the colour profile, text antialiasing, reduced motion. Each shot is taken twice, and a screen
|
|
8
|
+
* whose two images differ is reported as unstable, since nothing could ever match it pixel for pixel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Chromium flags that keep rendering the same between runs and machines of one platform. */
|
|
12
|
+
export const CAPTURE_ARGS = [
|
|
13
|
+
"--force-color-profile=srgb",
|
|
14
|
+
"--font-render-hinting=none",
|
|
15
|
+
"--disable-lcd-text",
|
|
16
|
+
"--hide-scrollbars",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export interface CaptureBrowser {
|
|
20
|
+
browser: Browser;
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Playwright's own Chromium when it is installed (`npx playwright install chromium`): the same build on every
|
|
27
|
+
* machine, which is what comparing screenshots wants. Otherwise the Chrome or Edge already on this computer.
|
|
28
|
+
*/
|
|
29
|
+
export async function launchCaptureBrowser(): Promise<CaptureBrowser> {
|
|
30
|
+
const candidates: { name: string; options: LaunchOptions }[] = [
|
|
31
|
+
{ name: "Playwright Chromium", options: {} },
|
|
32
|
+
{ name: "Google Chrome", options: { channel: "chrome" } },
|
|
33
|
+
{ name: "Microsoft Edge", options: { channel: "msedge" } },
|
|
34
|
+
];
|
|
35
|
+
for (const { name, options } of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
const browser = await chromium.launch({ ...options, headless: true, args: CAPTURE_ARGS });
|
|
38
|
+
return { browser, name, version: browser.version() };
|
|
39
|
+
} catch {
|
|
40
|
+
// Not installed; try the next one.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(
|
|
44
|
+
"No browser to take the screenshots with. Install Google Chrome or Microsoft Edge, or run `npx playwright install chromium`.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Shot extends CaptureShot {
|
|
49
|
+
view: string;
|
|
50
|
+
screen: string;
|
|
51
|
+
file: string;
|
|
52
|
+
png: Buffer;
|
|
53
|
+
/** The image's height in pixels: the page's height for full-page shots. */
|
|
54
|
+
imageHeight: number;
|
|
55
|
+
/** False when two captures of the shot differed, so the screen does not render the same every time. */
|
|
56
|
+
stable: boolean;
|
|
57
|
+
/** Set when the screen threw while rendering. */
|
|
58
|
+
failed: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CaptureResult {
|
|
62
|
+
views: CaptureView[];
|
|
63
|
+
problems: string[];
|
|
64
|
+
shots: Shot[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The height stored in a PNG's IHDR chunk. */
|
|
68
|
+
function pngHeight(png: Buffer): number {
|
|
69
|
+
return png.length >= 24 ? png.readUInt32BE(20) : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Captures every shot of every view the capture page at `pageUrl` lists. `pageUrl` is the page without a query
|
|
74
|
+
* (".../capture.html" on the preview server, ".../index.html" in the kit's copy of the design).
|
|
75
|
+
*/
|
|
76
|
+
export async function captureViews(
|
|
77
|
+
pageUrl: string,
|
|
78
|
+
browser: Browser,
|
|
79
|
+
onProgress?: (done: number, total: number) => void,
|
|
80
|
+
): Promise<CaptureResult> {
|
|
81
|
+
const context = await browser.newContext({
|
|
82
|
+
deviceScaleFactor: 1,
|
|
83
|
+
reducedMotion: "reduce",
|
|
84
|
+
colorScheme: "light",
|
|
85
|
+
viewport: { width: 1440, height: 900 },
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
await page.goto(`${pageUrl}?list`);
|
|
90
|
+
await page.waitForSelector(`html[${READY_ATTRIBUTE}]`, { state: "attached", timeout: 60_000 });
|
|
91
|
+
const listed = await page.evaluate(() => window.__basisViews);
|
|
92
|
+
const views = listed?.views ?? [];
|
|
93
|
+
const problems = listed?.problems ?? [];
|
|
94
|
+
const total = views.reduce((sum, view) => sum + view.screens.reduce((count, s) => count + s.shots.length, 0), 0);
|
|
95
|
+
const shots: Shot[] = [];
|
|
96
|
+
for (const view of views) {
|
|
97
|
+
for (const screen of view.screens) {
|
|
98
|
+
for (const shot of screen.shots) {
|
|
99
|
+
const url = `${pageUrl}?view=${encodeURIComponent(view.id)}&screen=${encodeURIComponent(screen.id)}&theme=${shot.theme}`;
|
|
100
|
+
await page.setViewportSize({ width: shot.width, height: shot.viewportHeight });
|
|
101
|
+
const take = async () => {
|
|
102
|
+
await page.goto(url);
|
|
103
|
+
await page.waitForSelector(`html[${READY_ATTRIBUTE}]`, { state: "attached", timeout: 60_000 });
|
|
104
|
+
const failed = await page.evaluate(() => document.documentElement.hasAttribute("data-basis-error"));
|
|
105
|
+
const png = await page.screenshot({ fullPage: shot.fullPage, animations: "disabled", caret: "hide" });
|
|
106
|
+
return { png, failed };
|
|
107
|
+
};
|
|
108
|
+
const first = await take();
|
|
109
|
+
const second = await take();
|
|
110
|
+
shots.push({
|
|
111
|
+
...shot,
|
|
112
|
+
view: view.id,
|
|
113
|
+
screen: screen.id,
|
|
114
|
+
file: shotFile(view.id, screen.id, shot),
|
|
115
|
+
png: first.png,
|
|
116
|
+
imageHeight: pngHeight(first.png),
|
|
117
|
+
stable: first.png.equals(second.png),
|
|
118
|
+
failed: first.failed,
|
|
119
|
+
});
|
|
120
|
+
onProgress?.(shots.length, total);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { views, problems, shots };
|
|
125
|
+
} finally {
|
|
126
|
+
await context.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readdir, stat } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Choosing folders without typing their path: the operating system's own folder dialog (Basis runs on the same
|
|
9
|
+
* computer as the browser), and the git repos found in the usual places for code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The folder the user chose, or null when they cancelled. Throws when this system has no folder dialog. */
|
|
13
|
+
export async function pickFolder(title: string, start?: string): Promise<string | null> {
|
|
14
|
+
const from = start && existsSync(start) ? start : os.homedir();
|
|
15
|
+
// Title and start folder travel as variables, so no quoting can break the scripts.
|
|
16
|
+
const env = { ...process.env, BASIS_TITLE: title, BASIS_START: from };
|
|
17
|
+
if (process.platform === "win32") {
|
|
18
|
+
const shell = (await has("pwsh")) ? "pwsh" : "powershell";
|
|
19
|
+
return run(shell, ["-NoProfile", "-NonInteractive", "-STA", "-Command", WINDOWS_DIALOG], env);
|
|
20
|
+
}
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
const chosen = await run("osascript", ["-e", MAC_DIALOG], env);
|
|
23
|
+
return chosen && chosen.length > 1 ? chosen.replace(/\/$/, "") : chosen;
|
|
24
|
+
}
|
|
25
|
+
if (await has("zenity")) {
|
|
26
|
+
return run("zenity", ["--file-selection", "--directory", `--title=${title}`, `--filename=${from}/`], env);
|
|
27
|
+
}
|
|
28
|
+
if (await has("kdialog")) return run("kdialog", ["--getexistingdirectory", from, "--title", title], env);
|
|
29
|
+
throw new Error("This system has no folder dialog Basis can open (install zenity or kdialog), so type the path.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A hidden, topmost owner window keeps the dialog in front of the browser.
|
|
33
|
+
const WINDOWS_DIALOG = `
|
|
34
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
35
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
36
|
+
$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; Opacity = 0 }
|
|
37
|
+
$owner.Show()
|
|
38
|
+
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
39
|
+
$dialog.Description = $env:BASIS_TITLE
|
|
40
|
+
$dialog.SelectedPath = $env:BASIS_START
|
|
41
|
+
try { $dialog.UseDescriptionForTitle = $true } catch {}
|
|
42
|
+
if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($dialog.SelectedPath) }
|
|
43
|
+
$owner.Close()
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const MAC_DIALOG = `POSIX path of (choose folder with prompt (system attribute "BASIS_TITLE") default location (POSIX file (system attribute "BASIS_START")))`;
|
|
47
|
+
|
|
48
|
+
/** Runs a dialog: its output is the chosen path; cancelling exits non-zero with nothing printed. */
|
|
49
|
+
function run(command: string, args: string[], env: NodeJS.ProcessEnv): Promise<string | null> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
execFile(command, args, { env, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
|
|
52
|
+
const chosen = stdout.trim();
|
|
53
|
+
if (chosen) return resolve(chosen);
|
|
54
|
+
if (error && "code" in error && typeof error.code === "string") return reject(error); // Not found, not run.
|
|
55
|
+
resolve(null);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function has(command: string): Promise<boolean> {
|
|
61
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
62
|
+
return new Promise((resolve) => execFile(probe, [command], { windowsHide: true }, (error) => resolve(!error)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RepoSuggestion {
|
|
66
|
+
name: string;
|
|
67
|
+
dir: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Folders under the home folder where people usually keep their code. */
|
|
71
|
+
const CODE_FOLDERS = [
|
|
72
|
+
"Code",
|
|
73
|
+
"code",
|
|
74
|
+
"Projects",
|
|
75
|
+
"projects",
|
|
76
|
+
"dev",
|
|
77
|
+
"Dev",
|
|
78
|
+
"Developer",
|
|
79
|
+
"src",
|
|
80
|
+
"repos",
|
|
81
|
+
"Repos",
|
|
82
|
+
"git",
|
|
83
|
+
"GitHub",
|
|
84
|
+
"workspace",
|
|
85
|
+
path.join("Documents", "GitHub"),
|
|
86
|
+
path.join("source", "repos"),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Git repos in the usual places for code, up to two levels down, the most recently used first (by the git index,
|
|
91
|
+
* which changes with every checkout and commit). `skip` leaves out folders such as Basis' own.
|
|
92
|
+
*/
|
|
93
|
+
export async function suggestRepos(home = os.homedir(), skip: string[] = [], limit = 12): Promise<RepoSuggestion[]> {
|
|
94
|
+
const found = new Map<string, number>();
|
|
95
|
+
const visit = async (dir: string, depth: number) => {
|
|
96
|
+
if (found.size >= 200) return;
|
|
97
|
+
const git = path.join(dir, ".git");
|
|
98
|
+
// The code folder itself is never the suggestion, even when someone made it a repo.
|
|
99
|
+
if (depth < 2 && existsSync(git)) {
|
|
100
|
+
const index = await stat(path.join(git, "index")).catch(() => stat(git));
|
|
101
|
+
found.set(dir, index.mtimeMs);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (depth === 0) return;
|
|
105
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
108
|
+
await visit(path.join(dir, entry.name), depth - 1);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
// One folder once, also when a case-insensitive disk finds it as both "Code" and "code".
|
|
112
|
+
const roots = new Map<string, string>();
|
|
113
|
+
for (const folder of CODE_FOLDERS) {
|
|
114
|
+
const dir = path.join(home, folder);
|
|
115
|
+
const info = await stat(dir).catch(() => null);
|
|
116
|
+
if (info?.isDirectory() && !roots.has(`${info.dev}:${info.ino}`)) roots.set(`${info.dev}:${info.ino}`, dir);
|
|
117
|
+
}
|
|
118
|
+
for (const root of roots.values()) await visit(root, 2);
|
|
119
|
+
const skipped = skip.map((dir) => path.resolve(dir).toLowerCase());
|
|
120
|
+
return [...found]
|
|
121
|
+
.filter(([dir]) => !skipped.some((other) => path.resolve(dir).toLowerCase() === other))
|
|
122
|
+
.sort((a, b) => b[1] - a[1])
|
|
123
|
+
.slice(0, limit)
|
|
124
|
+
.map(([dir]) => ({ name: path.basename(dir), dir }));
|
|
125
|
+
}
|
package/server/index.ts
CHANGED
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
} from "../shared/protocol.ts";
|
|
16
16
|
import { createInstruction, importInstruction, templateInstruction, viewInstruction } from "./brief.ts";
|
|
17
17
|
import { buildStarterKit, slug, writeStarterKit, zipStarterKit } from "./export.ts";
|
|
18
|
+
import { buildVerificationKit, writeVerificationKit, zipVerificationKit } from "./verify.ts";
|
|
18
19
|
import { PreviewReports } from "./previews.ts";
|
|
19
20
|
import { guard, isLocalHost, OpenProject, refuseHost, type ProjectOptions } from "./project.ts";
|
|
21
|
+
import { pickFolder, suggestRepos } from "./folders.ts";
|
|
20
22
|
import { checkRepo, Projects } from "./projects.ts";
|
|
21
23
|
import { templateChanges } from "./template.ts";
|
|
22
24
|
|
|
@@ -98,16 +100,62 @@ const chromeVite = await createVite({
|
|
|
98
100
|
// Vite forwards browser console errors to the terminal when it thinks an agent runs it; for Basis that is noise.
|
|
99
101
|
server: { middlewareMode: true, hmr: { server: chromeServer }, forwardConsole: false },
|
|
100
102
|
});
|
|
101
|
-
const chromeGuard = guard((url) => url === "/preview.html");
|
|
103
|
+
const chromeGuard = guard((url) => url === "/preview.html" || url === "/capture.html");
|
|
102
104
|
chromeServer.on("request", (req, res) => {
|
|
103
105
|
if (!isLocalHost(req, port)) return refuseHost(res);
|
|
104
106
|
if (req.method === "GET" && req.url === "/export/starter-kit.zip") {
|
|
105
107
|
void sendStarterKitZip(res);
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
110
|
+
if (req.method === "GET" && req.url === "/export/verification-kit.zip") {
|
|
111
|
+
sendVerificationZip(res);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (req.url?.startsWith("/api/")) {
|
|
115
|
+
void handleApi(req, res);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
108
118
|
chromeGuard(req, res, () => chromeVite.middlewares(req, res));
|
|
109
119
|
});
|
|
110
120
|
|
|
121
|
+
/** True while a folder dialog is open: one at a time. */
|
|
122
|
+
let picking = false;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Small requests the chrome makes and waits for: a folder from the system's dialog, repos to suggest. Only POSTs
|
|
126
|
+
* from the chrome's own origin, so another web page can neither open dialogs on this computer nor read the answer.
|
|
127
|
+
*/
|
|
128
|
+
async function handleApi(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
129
|
+
const reply = (status: number, body: unknown) => {
|
|
130
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
131
|
+
res.end(JSON.stringify(body));
|
|
132
|
+
};
|
|
133
|
+
if (req.method !== "POST" || !chromeOrigins.includes(req.headers.origin ?? "")) return reply(403, {});
|
|
134
|
+
let body: { title?: string; start?: string };
|
|
135
|
+
try {
|
|
136
|
+
let raw = "";
|
|
137
|
+
for await (const chunk of req) raw += chunk;
|
|
138
|
+
body = raw ? JSON.parse(raw) : {};
|
|
139
|
+
} catch {
|
|
140
|
+
return reply(400, {});
|
|
141
|
+
}
|
|
142
|
+
if (req.url === "/api/pick-folder") {
|
|
143
|
+
if (picking) return reply(409, { error: "A folder dialog is already open." });
|
|
144
|
+
picking = true;
|
|
145
|
+
try {
|
|
146
|
+
return reply(200, { path: await pickFolder(body.title ?? "Choose a folder", body.start) });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return reply(200, { path: null, error: (error as Error).message });
|
|
149
|
+
} finally {
|
|
150
|
+
picking = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (req.url === "/api/repos") {
|
|
154
|
+
return reply(200, { repos: await suggestRepos(undefined, [root, current.entry.dir]) });
|
|
155
|
+
}
|
|
156
|
+
reply(404, {});
|
|
157
|
+
}
|
|
158
|
+
|
|
111
159
|
async function starterKit() {
|
|
112
160
|
return buildStarterKit(current.workspace.state.design, current.workspace.dir);
|
|
113
161
|
}
|
|
@@ -129,6 +177,59 @@ async function sendStarterKitZip(res: http.ServerResponse): Promise<void> {
|
|
|
129
177
|
}
|
|
130
178
|
}
|
|
131
179
|
|
|
180
|
+
/** The last verification kit built as a zip, until it is downloaded or another is built. */
|
|
181
|
+
let verificationZip: { name: string; data: Uint8Array } | null = null;
|
|
182
|
+
/** True while a verification kit is being built: one at a time, and no project switch meanwhile. */
|
|
183
|
+
let verifying = false;
|
|
184
|
+
|
|
185
|
+
function sendVerificationZip(res: http.ServerResponse): void {
|
|
186
|
+
if (!verificationZip) {
|
|
187
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
188
|
+
res.end("No verification kit has been built. Build one from the Export dialog.");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(200, {
|
|
192
|
+
"Content-Type": "application/zip",
|
|
193
|
+
"Content-Disposition": `attachment; filename="${verificationZip.name}"`,
|
|
194
|
+
"Content-Length": verificationZip.data.byteLength,
|
|
195
|
+
"Cache-Control": "no-store",
|
|
196
|
+
});
|
|
197
|
+
res.end(verificationZip.data);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Builds the verification kit for the open project, reporting progress to every tab. */
|
|
201
|
+
async function verificationKit(project: string | undefined, socket: WebSocket): Promise<void> {
|
|
202
|
+
if (verifying) {
|
|
203
|
+
send(socket, { type: "verify-result", result: { ok: false, error: "A verification kit is already being built." } });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
verifying = true;
|
|
207
|
+
try {
|
|
208
|
+
const { files, summary } = await buildVerificationKit({
|
|
209
|
+
design: current.workspace.state.design,
|
|
210
|
+
workspaceDir: current.workspace.dir,
|
|
211
|
+
onProgress: (step, done, total) => broadcast({ type: "verify-progress", step, done, total }),
|
|
212
|
+
});
|
|
213
|
+
if (project === undefined) {
|
|
214
|
+
verificationZip = {
|
|
215
|
+
name: `${slug(current.workspace.state.design.name)}-verification-kit.zip`,
|
|
216
|
+
data: zipVerificationKit(files),
|
|
217
|
+
};
|
|
218
|
+
broadcast({ type: "verify-result", result: { ok: true, summary } });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const written = await writeVerificationKit(files, project);
|
|
222
|
+
broadcast({
|
|
223
|
+
type: "verify-result",
|
|
224
|
+
result: written.ok ? { ok: true, summary, dir: written.dir } : { ok: false, error: written.error },
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
broadcast({ type: "verify-result", result: { ok: false, error: (error as Error).message } });
|
|
228
|
+
} finally {
|
|
229
|
+
verifying = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
132
233
|
chromeServer.on("upgrade", (req, socket, head) => {
|
|
133
234
|
if (req.url !== "/bridge") return; // Vite's HMR socket handles its own upgrades.
|
|
134
235
|
if (!chromeOrigins.includes(req.headers.origin ?? "")) {
|
|
@@ -171,6 +272,13 @@ async function switchTo(entry: ProjectEntry, socket: WebSocket): Promise<void> {
|
|
|
171
272
|
send(socket, { type: "project-error", message: "The agent is working. Switch projects when it is done." });
|
|
172
273
|
return;
|
|
173
274
|
}
|
|
275
|
+
if (verifying) {
|
|
276
|
+
send(socket, {
|
|
277
|
+
type: "project-error",
|
|
278
|
+
message: "A verification kit is being built. Switch projects when it is done.",
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
174
282
|
switching = true;
|
|
175
283
|
const previous = current.entry;
|
|
176
284
|
broadcast({ type: "project-switching", project: entry });
|
|
@@ -314,6 +422,9 @@ async function handle(message: ClientMessage, socket: WebSocket): Promise<void>
|
|
|
314
422
|
send(socket, { type: "export-result", result });
|
|
315
423
|
return;
|
|
316
424
|
}
|
|
425
|
+
case "verify-kit":
|
|
426
|
+
await verificationKit(typeof message.project === "string" ? message.project : undefined, socket);
|
|
427
|
+
return;
|
|
317
428
|
case "project-switch": {
|
|
318
429
|
const entry = projects.find(String(message.id ?? ""));
|
|
319
430
|
if (entry) await switchTo(entry, socket);
|
package/server/project.ts
CHANGED
|
@@ -86,7 +86,7 @@ export class OpenProject {
|
|
|
86
86
|
root,
|
|
87
87
|
appType: "mpa",
|
|
88
88
|
cacheDir: "node_modules/.vite/preview",
|
|
89
|
-
optimizeDeps: { entries: ["preview.html"] },
|
|
89
|
+
optimizeDeps: { entries: ["preview.html", "capture.html"] },
|
|
90
90
|
plugins: [rebasePlugin(root, entry.dir)],
|
|
91
91
|
resolve: {
|
|
92
92
|
alias: {
|
|
@@ -112,6 +112,12 @@ export class OpenProject {
|
|
|
112
112
|
const previewGuard = guard((url) => url === "/" || url === "/index.html");
|
|
113
113
|
previewServer.on("request", (req, res) => {
|
|
114
114
|
if (!isLocalHost(req, options.previewPort)) return refuseHost(res);
|
|
115
|
+
// The capture page (capture.html) reads the current tokens from here, as the kit's copy reads its own file.
|
|
116
|
+
if (req.method === "GET" && req.url?.split("?")[0] === "/design.json") {
|
|
117
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
118
|
+
res.end(JSON.stringify(workspace.state.design));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
115
121
|
previewGuard(req, res, () => previewVite.middlewares(req, res));
|
|
116
122
|
});
|
|
117
123
|
await new Promise<void>((resolve, reject) => {
|