basis-design 0.1.0 → 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 +5 -3
- package/capture.html +12 -0
- package/package.json +72 -68
- package/server/agent.ts +33 -12
- package/server/brief.ts +32 -3
- package/server/capture.ts +128 -0
- package/server/claude.ts +72 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +134 -3
- package/server/project.ts +9 -1
- package/server/setup.ts +201 -219
- package/server/template.ts +168 -0
- 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/server/workspace.ts +19 -1
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +45 -2
- package/src/Workbench.tsx +4 -1
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/chat/ChatPanel.tsx +10 -1
- 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 +58 -21
- package/src/shell/Shell.module.css +1146 -1067
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -0
- package/template/components/Field.module.css +10 -0
- package/template/components/Field.tsx +6 -0
- package/template/components/specimen.ts +24 -1
- package/template/history.json +123 -0
- package/template/patterns/Navbar.module.css +38 -12
- package/template/patterns/Navbar.specimen.tsx +1 -1
- package/template/patterns/Navbar.tsx +27 -3
|
@@ -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
|
@@ -13,11 +13,14 @@ import {
|
|
|
13
13
|
type ProjectEntry,
|
|
14
14
|
type ServerMessage,
|
|
15
15
|
} from "../shared/protocol.ts";
|
|
16
|
-
import { createInstruction, importInstruction, viewInstruction } from "./brief.ts";
|
|
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";
|
|
23
|
+
import { templateChanges } from "./template.ts";
|
|
21
24
|
|
|
22
25
|
// History, reviews and restores all run on git; without it nothing below works, and the error would not say why.
|
|
23
26
|
if (spawnSync("git", ["--version"], { windowsHide: true }).error) {
|
|
@@ -94,18 +97,65 @@ const chromeVite = await createVite({
|
|
|
94
97
|
appType: "spa",
|
|
95
98
|
cacheDir: "node_modules/.vite/chrome",
|
|
96
99
|
optimizeDeps: { entries: ["index.html"] },
|
|
97
|
-
|
|
100
|
+
// Vite forwards browser console errors to the terminal when it thinks an agent runs it; for Basis that is noise.
|
|
101
|
+
server: { middlewareMode: true, hmr: { server: chromeServer }, forwardConsole: false },
|
|
98
102
|
});
|
|
99
|
-
const chromeGuard = guard((url) => url === "/preview.html");
|
|
103
|
+
const chromeGuard = guard((url) => url === "/preview.html" || url === "/capture.html");
|
|
100
104
|
chromeServer.on("request", (req, res) => {
|
|
101
105
|
if (!isLocalHost(req, port)) return refuseHost(res);
|
|
102
106
|
if (req.method === "GET" && req.url === "/export/starter-kit.zip") {
|
|
103
107
|
void sendStarterKitZip(res);
|
|
104
108
|
return;
|
|
105
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
|
+
}
|
|
106
118
|
chromeGuard(req, res, () => chromeVite.middlewares(req, res));
|
|
107
119
|
});
|
|
108
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
|
+
|
|
109
159
|
async function starterKit() {
|
|
110
160
|
return buildStarterKit(current.workspace.state.design, current.workspace.dir);
|
|
111
161
|
}
|
|
@@ -127,6 +177,59 @@ async function sendStarterKitZip(res: http.ServerResponse): Promise<void> {
|
|
|
127
177
|
}
|
|
128
178
|
}
|
|
129
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
|
+
|
|
130
233
|
chromeServer.on("upgrade", (req, socket, head) => {
|
|
131
234
|
if (req.url !== "/bridge") return; // Vite's HMR socket handles its own upgrades.
|
|
132
235
|
if (!chromeOrigins.includes(req.headers.origin ?? "")) {
|
|
@@ -169,6 +272,13 @@ async function switchTo(entry: ProjectEntry, socket: WebSocket): Promise<void> {
|
|
|
169
272
|
send(socket, { type: "project-error", message: "The agent is working. Switch projects when it is done." });
|
|
170
273
|
return;
|
|
171
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
|
+
}
|
|
172
282
|
switching = true;
|
|
173
283
|
const previous = current.entry;
|
|
174
284
|
broadcast({ type: "project-switching", project: entry });
|
|
@@ -312,6 +422,9 @@ async function handle(message: ClientMessage, socket: WebSocket): Promise<void>
|
|
|
312
422
|
send(socket, { type: "export-result", result });
|
|
313
423
|
return;
|
|
314
424
|
}
|
|
425
|
+
case "verify-kit":
|
|
426
|
+
await verificationKit(typeof message.project === "string" ? message.project : undefined, socket);
|
|
427
|
+
return;
|
|
315
428
|
case "project-switch": {
|
|
316
429
|
const entry = projects.find(String(message.id ?? ""));
|
|
317
430
|
if (entry) await switchTo(entry, socket);
|
|
@@ -381,9 +494,27 @@ async function handle(message: ClientMessage, socket: WebSocket): Promise<void>
|
|
|
381
494
|
}
|
|
382
495
|
case "import-design":
|
|
383
496
|
return bringInDesign(socket);
|
|
497
|
+
case "template-update":
|
|
498
|
+
return bringInTemplate();
|
|
499
|
+
case "template-skip":
|
|
500
|
+
return current.workspace.settleTemplateUpdate();
|
|
384
501
|
}
|
|
385
502
|
}
|
|
386
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Starts (or queues) the guided turn that brings a newer starter template's changes into the workspace's changed
|
|
506
|
+
* starter files. The workspace counts as caught up once the turn has run (or is queued); its review shows what came
|
|
507
|
+
* in.
|
|
508
|
+
*/
|
|
509
|
+
async function bringInTemplate(): Promise<void> {
|
|
510
|
+
const update = current.workspace.templateUpdate;
|
|
511
|
+
if (!update) return;
|
|
512
|
+
const workspace = current.workspace;
|
|
513
|
+
const instruction = templateInstruction(await templateChanges(update.from), update.files);
|
|
514
|
+
await current.agent.prompt("Bring in the improvements to Basis' starter template", { instruction });
|
|
515
|
+
await workspace.settleTemplateUpdate();
|
|
516
|
+
}
|
|
517
|
+
|
|
387
518
|
/** Starts (or queues, while the agent works) the guided turn that brings in the linked repo's design system. */
|
|
388
519
|
async function bringInDesign(socket: WebSocket): Promise<void> {
|
|
389
520
|
const repo = current.entry.repo;
|
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: {
|
|
@@ -99,6 +99,8 @@ export class OpenProject {
|
|
|
99
99
|
},
|
|
100
100
|
server: {
|
|
101
101
|
middlewareMode: true,
|
|
102
|
+
// Agent-written code logs in the preview; its console belongs in the browser, not in the terminal.
|
|
103
|
+
forwardConsole: false,
|
|
102
104
|
hmr: { server: previewServer },
|
|
103
105
|
fs: { allow: [root, entry.dir] },
|
|
104
106
|
// 'self': the preview embeds a copy of itself for the accessibility check in the other theme.
|
|
@@ -110,6 +112,12 @@ export class OpenProject {
|
|
|
110
112
|
const previewGuard = guard((url) => url === "/" || url === "/index.html");
|
|
111
113
|
previewServer.on("request", (req, res) => {
|
|
112
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
|
+
}
|
|
113
121
|
previewGuard(req, res, () => previewVite.middlewares(req, res));
|
|
114
122
|
});
|
|
115
123
|
await new Promise<void>((resolve, reject) => {
|