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.
@@ -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
- server: { middlewareMode: true, hmr: { server: chromeServer } },
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) => {