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
package/server/setup.ts
CHANGED
|
@@ -1,219 +1,201 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const require = createRequire(import.meta.url);
|
|
11
|
-
|
|
12
|
-
/*
|
|
13
|
-
* Getting a workspace ready on every start: starter source for new
|
|
14
|
-
* workspaces, what Basis added since for older ones, and the files Basis owns.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Seeds a new workspace from template/, or brings an older one up to date.
|
|
19
|
-
* Returns a note for the history entry ("Basis added component contracts"),
|
|
20
|
-
* or "" when nothing an existing workspace would notice was added.
|
|
21
|
-
*/
|
|
22
|
-
export async function seedWorkspace(dir: string): Promise<string> {
|
|
23
|
-
const components = path.join(dir, COMPONENTS_DIR);
|
|
24
|
-
const notes: string[] = [];
|
|
25
|
-
if (!existsSync(components)) await seed(dir, COMPONENTS_DIR);
|
|
26
|
-
else if (!existsSync(path.join(components, "specimen.ts"))) {
|
|
27
|
-
await seed(dir, COMPONENTS_DIR, (name) => name.includes("specimen"));
|
|
28
|
-
notes.push("gallery specimens");
|
|
29
|
-
}
|
|
30
|
-
if (!existsSync(path.join(components, "contracts.ts"))) {
|
|
31
|
-
await seed(dir, COMPONENTS_DIR, (name) => name === "contracts.ts");
|
|
32
|
-
notes.push("component contracts");
|
|
33
|
-
}
|
|
34
|
-
if (!existsSync(path.join(dir, PATTERNS_DIR))) {
|
|
35
|
-
const fresh = notes.length === 0 && !existsSync(path.join(dir, ".git"));
|
|
36
|
-
await seed(dir, PATTERNS_DIR);
|
|
37
|
-
if (!fresh) notes.push("starter patterns");
|
|
38
|
-
} else {
|
|
39
|
-
if (!existsSync(path.join(dir, PATTERNS_DIR, "contracts.ts"))) {
|
|
40
|
-
await seed(dir, PATTERNS_DIR, (name) => name === "contracts.ts");
|
|
41
|
-
notes.push("pattern contracts");
|
|
42
|
-
}
|
|
43
|
-
const added = await addLaterPatterns(dir);
|
|
44
|
-
if (added.length) notes.push(`the ${added.join(" and ")} pattern${added.length > 1 ? "s" : ""}`);
|
|
45
|
-
}
|
|
46
|
-
if (!existsSync(path.join(dir, VIEWS_DIR))) {
|
|
47
|
-
const fresh = notes.length === 0 && !existsSync(path.join(dir, ".git"));
|
|
48
|
-
await seed(dir, VIEWS_DIR);
|
|
49
|
-
if (!fresh) notes.push("a starter view");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*/
|
|
103
|
-
async function
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
},
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"//
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"// Every export of patterns/index.ts needs a contract; add missing ones to patterns/contracts.ts.",
|
|
203
|
-
"type UncoveredPattern = Exclude<",
|
|
204
|
-
" keyof typeof patterns,",
|
|
205
|
-
" keyof PatternContracts<IconName> | keyof AddedPatternContracts",
|
|
206
|
-
">;",
|
|
207
|
-
"export const everyPatternHasAContract: [UncoveredPattern] extends [never]",
|
|
208
|
-
" ? true",
|
|
209
|
-
" : { missingContractFor: UncoveredPattern } = true;",
|
|
210
|
-
"",
|
|
211
|
-
].join("\n"),
|
|
212
|
-
);
|
|
213
|
-
await writeFile(path.join(dir, "AGENTS.md"), AGENT_BRIEF);
|
|
214
|
-
await writeFile(path.join(dir, "CLAUDE.md"), CLAUDE_MD);
|
|
215
|
-
await writeFile(
|
|
216
|
-
path.join(dir, ".gitignore"),
|
|
217
|
-
`# Generated by Basis on every start\n.basis/\nAGENTS.md\nCLAUDE.md\ntsconfig.json\nnode_modules/\n${REVIEW_FILE}\n`,
|
|
218
|
-
);
|
|
219
|
-
}
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { REVIEW_FILE } from "../shared/review.ts";
|
|
6
|
+
import { AGENT_BRIEF, CLAUDE_MD } from "./brief.ts";
|
|
7
|
+
import { COMPONENTS_DIR, PATTERNS_DIR, repoRoot, SOURCE_DIRS, VIEWS_DIR } from "./paths.ts";
|
|
8
|
+
import { updateUntouchedStarterFiles } from "./template.ts";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Getting a workspace ready on every start: starter source for new
|
|
14
|
+
* workspaces, what Basis added since for older ones, and the files Basis owns.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Seeds a new workspace from template/, or brings an older one up to date.
|
|
19
|
+
* Returns a note for the history entry ("Basis added component contracts"),
|
|
20
|
+
* or "" when nothing an existing workspace would notice was added.
|
|
21
|
+
*/
|
|
22
|
+
export async function seedWorkspace(dir: string): Promise<string> {
|
|
23
|
+
const components = path.join(dir, COMPONENTS_DIR);
|
|
24
|
+
const notes: string[] = [];
|
|
25
|
+
if (!existsSync(components)) await seed(dir, COMPONENTS_DIR);
|
|
26
|
+
else if (!existsSync(path.join(components, "specimen.ts"))) {
|
|
27
|
+
await seed(dir, COMPONENTS_DIR, (name) => name.includes("specimen"));
|
|
28
|
+
notes.push("gallery specimens");
|
|
29
|
+
}
|
|
30
|
+
if (!existsSync(path.join(components, "contracts.ts"))) {
|
|
31
|
+
await seed(dir, COMPONENTS_DIR, (name) => name === "contracts.ts");
|
|
32
|
+
notes.push("component contracts");
|
|
33
|
+
}
|
|
34
|
+
if (!existsSync(path.join(dir, PATTERNS_DIR))) {
|
|
35
|
+
const fresh = notes.length === 0 && !existsSync(path.join(dir, ".git"));
|
|
36
|
+
await seed(dir, PATTERNS_DIR);
|
|
37
|
+
if (!fresh) notes.push("starter patterns");
|
|
38
|
+
} else {
|
|
39
|
+
if (!existsSync(path.join(dir, PATTERNS_DIR, "contracts.ts"))) {
|
|
40
|
+
await seed(dir, PATTERNS_DIR, (name) => name === "contracts.ts");
|
|
41
|
+
notes.push("pattern contracts");
|
|
42
|
+
}
|
|
43
|
+
const added = await addLaterPatterns(dir);
|
|
44
|
+
if (added.length) notes.push(`the ${added.join(" and ")} pattern${added.length > 1 ? "s" : ""}`);
|
|
45
|
+
}
|
|
46
|
+
if (!existsSync(path.join(dir, VIEWS_DIR))) {
|
|
47
|
+
const fresh = notes.length === 0 && !existsSync(path.join(dir, ".git"));
|
|
48
|
+
await seed(dir, VIEWS_DIR);
|
|
49
|
+
if (!fresh) notes.push("a starter view");
|
|
50
|
+
}
|
|
51
|
+
// Starter files nobody changed follow the template (see server/template.ts).
|
|
52
|
+
const updated = await updateUntouchedStarterFiles(dir);
|
|
53
|
+
const parts = notes.length ? [`added ${notes.join(" and ")}`] : [];
|
|
54
|
+
if (updated.length) parts.push(`updated ${updated.length === 1 ? updated[0] : `${updated.length} starter files`}`);
|
|
55
|
+
return parts.length ? `Basis ${parts.join(" and ")}` : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Starter patterns added to the baseline after workspaces already had patterns/.
|
|
60
|
+
* Older workspaces get their files and an export in patterns/index.ts, since the
|
|
61
|
+
* baseline contract requires them.
|
|
62
|
+
*/
|
|
63
|
+
const LATER_PATTERNS = ["Composer", "Chat"];
|
|
64
|
+
|
|
65
|
+
async function addLaterPatterns(dir: string): Promise<string[]> {
|
|
66
|
+
const index = path.join(dir, PATTERNS_DIR, "index.ts");
|
|
67
|
+
const added: string[] = [];
|
|
68
|
+
for (const name of LATER_PATTERNS) {
|
|
69
|
+
if (existsSync(path.join(dir, PATTERNS_DIR, `${name}.tsx`))) continue;
|
|
70
|
+
await seed(dir, PATTERNS_DIR, (file) => file.startsWith(`${name}.`));
|
|
71
|
+
const exports = await readFile(index, "utf8").catch(() => "");
|
|
72
|
+
if (!exports.includes(`"./${name}.tsx"`)) {
|
|
73
|
+
const separator = exports === "" || exports.endsWith("\n") ? "" : "\n";
|
|
74
|
+
await writeFile(index, `${exports}${separator}export * from "./${name}.tsx";\n`);
|
|
75
|
+
}
|
|
76
|
+
added.push(name);
|
|
77
|
+
}
|
|
78
|
+
return added;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Copies starter files from template/<folder> that are missing, normalising
|
|
83
|
+
* line endings (a Windows checkout of Basis may have CRLF).
|
|
84
|
+
*/
|
|
85
|
+
async function seed(dir: string, folder: string, only: (name: string) => boolean = () => true): Promise<void> {
|
|
86
|
+
const source = path.join(repoRoot, "template", folder);
|
|
87
|
+
const destination = path.join(dir, folder);
|
|
88
|
+
await mkdir(destination, { recursive: true });
|
|
89
|
+
for (const name of await readdir(source)) {
|
|
90
|
+
if (!only(name) || existsSync(path.join(destination, name))) continue;
|
|
91
|
+
const text = await readFile(path.join(source, name), "utf8");
|
|
92
|
+
await writeFile(path.join(destination, name), text.replaceAll("\r\n", "\n"));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The workspace format this Basis writes. Raise it when a change means an older Basis would misread or damage a
|
|
98
|
+
* workspace this one has opened; an older Basis then refuses to open it instead.
|
|
99
|
+
*/
|
|
100
|
+
export const WORKSPACE_FORMAT = 1;
|
|
101
|
+
|
|
102
|
+
/** Refuses a workspace a newer Basis has opened, and marks it with this Basis' format otherwise. */
|
|
103
|
+
export async function claimWorkspaceFormat(dir: string): Promise<void> {
|
|
104
|
+
const file = path.join(dir, ".basis", "format.json");
|
|
105
|
+
const saved = existsSync(file) ? Number(JSON.parse(await readFile(file, "utf8")).format) || 0 : 0;
|
|
106
|
+
if (saved > WORKSPACE_FORMAT) {
|
|
107
|
+
throw new Error("a newer version of Basis has opened this project. Update Basis to open it again.");
|
|
108
|
+
}
|
|
109
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
110
|
+
await writeFile(file, `${JSON.stringify({ format: WORKSPACE_FORMAT })}\n`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Files Basis owns in the workspace, rewritten on every start and kept out of history. */
|
|
114
|
+
export async function writeGeneratedFiles(dir: string): Promise<void> {
|
|
115
|
+
const basisDir = path.join(dir, ".basis");
|
|
116
|
+
await mkdir(basisDir, { recursive: true });
|
|
117
|
+
// Resolved, not joined onto Basis' folder: an npm install may hoist them to a node_modules above it.
|
|
118
|
+
const types = (pkg: string) =>
|
|
119
|
+
path
|
|
120
|
+
.dirname(require.resolve(`${pkg}/package.json`))
|
|
121
|
+
.split(path.sep)
|
|
122
|
+
.join("/");
|
|
123
|
+
const tsconfig = {
|
|
124
|
+
compilerOptions: {
|
|
125
|
+
target: "ES2022",
|
|
126
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
127
|
+
module: "ESNext",
|
|
128
|
+
moduleResolution: "bundler",
|
|
129
|
+
jsx: "react-jsx",
|
|
130
|
+
strict: true,
|
|
131
|
+
noEmit: true,
|
|
132
|
+
skipLibCheck: true,
|
|
133
|
+
isolatedModules: true,
|
|
134
|
+
allowImportingTsExtensions: true,
|
|
135
|
+
types: [],
|
|
136
|
+
// Resolve React's types from the Basis install, wherever this workspace lives.
|
|
137
|
+
paths: {
|
|
138
|
+
react: [`${types("@types/react")}/index.d.ts`],
|
|
139
|
+
"react/*": [`${types("@types/react")}/*`],
|
|
140
|
+
"react-dom": [`${types("@types/react-dom")}/index.d.ts`],
|
|
141
|
+
"react-dom/*": [`${types("@types/react-dom")}/*`],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
include: [...SOURCE_DIRS.map((folder) => `${folder}/**/*`), ".basis/*.ts"],
|
|
145
|
+
};
|
|
146
|
+
await writeFile(path.join(dir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
|
|
147
|
+
await writeFile(
|
|
148
|
+
path.join(basisDir, "contracts.ts"),
|
|
149
|
+
await readFile(path.join(repoRoot, "shared", "contracts.ts"), "utf8"),
|
|
150
|
+
);
|
|
151
|
+
await writeFile(
|
|
152
|
+
path.join(basisDir, "pattern-contracts.ts"),
|
|
153
|
+
await readFile(path.join(repoRoot, "shared", "pattern-contracts.ts"), "utf8"),
|
|
154
|
+
);
|
|
155
|
+
await writeFile(
|
|
156
|
+
path.join(basisDir, "guidelines.md"),
|
|
157
|
+
await readFile(path.join(repoRoot, "server", "guidelines.md"), "utf8"),
|
|
158
|
+
);
|
|
159
|
+
await writeFile(
|
|
160
|
+
path.join(basisDir, "env.d.ts"),
|
|
161
|
+
'declare module "*.module.css" {\n const classes: Record<string, string>;\n export default classes;\n}\n',
|
|
162
|
+
);
|
|
163
|
+
await writeFile(
|
|
164
|
+
path.join(basisDir, "check.ts"),
|
|
165
|
+
[
|
|
166
|
+
"// Fails to type-check when components/ or patterns/ stop satisfying their contracts: the Basis",
|
|
167
|
+
"// baselines (./contracts.ts, ./pattern-contracts.ts) and the contracts this design system added",
|
|
168
|
+
"// (components/contracts.ts, patterns/contracts.ts).",
|
|
169
|
+
'import type { Contracts } from "./contracts.ts";',
|
|
170
|
+
'import type { AddedContracts } from "../components/contracts.ts";',
|
|
171
|
+
'import * as components from "../components/index.ts";',
|
|
172
|
+
'import type { PatternContracts } from "./pattern-contracts.ts";',
|
|
173
|
+
'import type { AddedPatternContracts } from "../patterns/contracts.ts";',
|
|
174
|
+
'import type { IconName } from "../patterns/icons.tsx";',
|
|
175
|
+
'import * as patterns from "../patterns/index.ts";',
|
|
176
|
+
"",
|
|
177
|
+
"export const satisfiesContracts: Contracts & AddedContracts = components;",
|
|
178
|
+
"export const satisfiesPatternContracts: PatternContracts<IconName> & AddedPatternContracts = patterns;",
|
|
179
|
+
"",
|
|
180
|
+
"// Every export of components/index.ts needs a contract; add missing ones to components/contracts.ts.",
|
|
181
|
+
"type Uncovered = Exclude<keyof typeof components, keyof Contracts | keyof AddedContracts>;",
|
|
182
|
+
"export const everyExportHasAContract: [Uncovered] extends [never] ? true : { missingContractFor: Uncovered } = true;",
|
|
183
|
+
"",
|
|
184
|
+
"// Every export of patterns/index.ts needs a contract; add missing ones to patterns/contracts.ts.",
|
|
185
|
+
"type UncoveredPattern = Exclude<",
|
|
186
|
+
" keyof typeof patterns,",
|
|
187
|
+
" keyof PatternContracts<IconName> | keyof AddedPatternContracts",
|
|
188
|
+
">;",
|
|
189
|
+
"export const everyPatternHasAContract: [UncoveredPattern] extends [never]",
|
|
190
|
+
" ? true",
|
|
191
|
+
" : { missingContractFor: UncoveredPattern } = true;",
|
|
192
|
+
"",
|
|
193
|
+
].join("\n"),
|
|
194
|
+
);
|
|
195
|
+
await writeFile(path.join(dir, "AGENTS.md"), AGENT_BRIEF);
|
|
196
|
+
await writeFile(path.join(dir, "CLAUDE.md"), CLAUDE_MD);
|
|
197
|
+
await writeFile(
|
|
198
|
+
path.join(dir, ".gitignore"),
|
|
199
|
+
`# Generated by Basis on every start\n.basis/\nAGENTS.md\nCLAUDE.md\ntsconfig.json\nnode_modules/\n${REVIEW_FILE}\n`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { TemplateUpdate } from "../shared/protocol.ts";
|
|
7
|
+
import { repoRoot, SOURCE_DIRS } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Keeping workspaces up to date with Basis' starter template (template/). A workspace owns its copy, so Basis
|
|
11
|
+
* replaces only files nobody changed: those that still match an earlier version of the template, listed in
|
|
12
|
+
* template/history.json. For files the agent has changed, Basis offers a guided turn that brings in what
|
|
13
|
+
* template/CHANGES.md describes, from a copy of the template in .basis/template/.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Earlier versions of each starter file, by workspace-relative path (written by scripts/template-history.ts). */
|
|
17
|
+
export const TEMPLATE_HISTORY_FILE = "template/history.json";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The version of the starter template. Raise it, with a section in template/CHANGES.md, when the template changes
|
|
21
|
+
* in a way worth bringing into workspaces whose agent has changed their components.
|
|
22
|
+
*/
|
|
23
|
+
export const TEMPLATE_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/** Where a workspace records the template version it has caught up with. */
|
|
26
|
+
const VERSION_FILE = path.join(".basis", "template.json");
|
|
27
|
+
|
|
28
|
+
/** The copy of the current template the agent compares against during the guided turn. */
|
|
29
|
+
export const TEMPLATE_COPY = ".basis/template";
|
|
30
|
+
|
|
31
|
+
export function hashText(text: string): string {
|
|
32
|
+
return createHash("sha256").update(text.replaceAll("\r\n", "\n")).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The starter files, as workspace-relative forward-slash paths ("components/Button.tsx"). */
|
|
36
|
+
export async function templateFiles(root = repoRoot): Promise<string[]> {
|
|
37
|
+
const files: string[] = [];
|
|
38
|
+
for (const dir of SOURCE_DIRS) {
|
|
39
|
+
const names = await readdir(path.join(root, "template", dir)).catch(() => [] as string[]);
|
|
40
|
+
files.push(...names.sort().map((name) => `${dir}/${name}`));
|
|
41
|
+
}
|
|
42
|
+
return files;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The hashes of every earlier version of each starter file, read from git. Only the script that writes
|
|
47
|
+
* template/history.json and its test use it; an installed Basis has no git history.
|
|
48
|
+
*/
|
|
49
|
+
export function earlierTemplateHashes(root: string): Record<string, string[]> {
|
|
50
|
+
const git = (args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 << 20 });
|
|
51
|
+
const history: Record<string, string[]> = {};
|
|
52
|
+
const current = git(["ls-files", ...SOURCE_DIRS.map((dir) => `template/${dir}`)])
|
|
53
|
+
.split("\n")
|
|
54
|
+
.filter((file) => file && file.split("/").length === 3);
|
|
55
|
+
for (const file of current) {
|
|
56
|
+
// The file as it is now, committed or not: the version a workspace should end up with.
|
|
57
|
+
const now = hashText(readFileSync(path.join(root, file), "utf8"));
|
|
58
|
+
const hashes = new Set<string>();
|
|
59
|
+
for (const commit of git(["log", "--format=%H", "--", file]).split("\n").filter(Boolean)) {
|
|
60
|
+
let text: string;
|
|
61
|
+
try {
|
|
62
|
+
text = git(["show", `${commit}:${file}`]);
|
|
63
|
+
} catch {
|
|
64
|
+
continue; // The commit removed the file.
|
|
65
|
+
}
|
|
66
|
+
const hash = hashText(text);
|
|
67
|
+
if (hash !== now) hashes.add(hash);
|
|
68
|
+
}
|
|
69
|
+
if (hashes.size) history[file.slice("template/".length)] = [...hashes].sort();
|
|
70
|
+
}
|
|
71
|
+
return history;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The files that belong together: a component's source, stylesheet and specimen share the part of their name
|
|
76
|
+
* before the first dot ("patterns/Navbar"), and a new Navbar.tsx needs the stylesheet written for it.
|
|
77
|
+
*/
|
|
78
|
+
function unitOf(file: string): string {
|
|
79
|
+
const slash = file.lastIndexOf("/");
|
|
80
|
+
return file.slice(0, slash + 1) + file.slice(slash + 1).split(".")[0];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Replaces starter files that still match an earlier version of the template with the current one, a unit at a
|
|
85
|
+
* time (see unitOf): a unit is updated only when none of its files was changed, so a component is never left with a
|
|
86
|
+
* new source and an old stylesheet. Returns the paths it replaced. Changed or removed files are left alone.
|
|
87
|
+
*/
|
|
88
|
+
export async function updateUntouchedStarterFiles(dir: string): Promise<string[]> {
|
|
89
|
+
const history = JSON.parse(await readFile(path.join(repoRoot, TEMPLATE_HISTORY_FILE), "utf8")) as Record<
|
|
90
|
+
string,
|
|
91
|
+
string[]
|
|
92
|
+
>;
|
|
93
|
+
const units = new Map<string, { file: string; current: string; ours: string }[]>();
|
|
94
|
+
for (const file of await templateFiles()) {
|
|
95
|
+
const target = path.join(dir, file);
|
|
96
|
+
if (!existsSync(target)) continue;
|
|
97
|
+
const current = (await readFile(path.join(repoRoot, "template", file), "utf8")).replaceAll("\r\n", "\n");
|
|
98
|
+
const files = units.get(unitOf(file)) ?? [];
|
|
99
|
+
files.push({ file, current, ours: hashText(await readFile(target, "utf8")) });
|
|
100
|
+
units.set(unitOf(file), files);
|
|
101
|
+
}
|
|
102
|
+
const updated: string[] = [];
|
|
103
|
+
for (const files of units.values()) {
|
|
104
|
+
const untouched = files.every(
|
|
105
|
+
({ file, current, ours }) => ours === hashText(current) || history[file]?.includes(ours),
|
|
106
|
+
);
|
|
107
|
+
if (!untouched) continue;
|
|
108
|
+
for (const { file, current, ours } of files) {
|
|
109
|
+
if (ours === hashText(current)) continue;
|
|
110
|
+
await writeFile(path.join(dir, file), current);
|
|
111
|
+
updated.push(file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function recordedTemplateVersion(dir: string): Promise<number> {
|
|
118
|
+
try {
|
|
119
|
+
return Number(JSON.parse(await readFile(path.join(dir, VERSION_FILE), "utf8")).version) || 0;
|
|
120
|
+
} catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function recordTemplateVersion(dir: string, version = TEMPLATE_VERSION): Promise<void> {
|
|
126
|
+
await mkdir(path.join(dir, ".basis"), { recursive: true });
|
|
127
|
+
await writeFile(path.join(dir, VERSION_FILE), `${JSON.stringify({ version })}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the workspace is behind the template. A workspace whose starter files all match the current template
|
|
132
|
+
* has nothing to bring in, so it is recorded as caught up.
|
|
133
|
+
*/
|
|
134
|
+
export async function templateUpdate(dir: string): Promise<TemplateUpdate | null> {
|
|
135
|
+
const from = await recordedTemplateVersion(dir);
|
|
136
|
+
if (from >= TEMPLATE_VERSION) return null;
|
|
137
|
+
const files: string[] = [];
|
|
138
|
+
for (const file of await templateFiles()) {
|
|
139
|
+
const target = path.join(dir, file);
|
|
140
|
+
if (!existsSync(target)) continue;
|
|
141
|
+
const ours = hashText(await readFile(target, "utf8"));
|
|
142
|
+
if (ours !== hashText(await readFile(path.join(repoRoot, "template", file), "utf8"))) files.push(file);
|
|
143
|
+
}
|
|
144
|
+
if (files.length === 0) {
|
|
145
|
+
await recordTemplateVersion(dir);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return { from, to: TEMPLATE_VERSION, files };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The sections of template/CHANGES.md newer than `from`. */
|
|
152
|
+
export async function templateChanges(from: number): Promise<string> {
|
|
153
|
+
const text = (await readFile(path.join(repoRoot, "template", "CHANGES.md"), "utf8")).replaceAll("\r\n", "\n");
|
|
154
|
+
return text
|
|
155
|
+
.split(/^(?=## )/m)
|
|
156
|
+
.filter((section) => Number(section.match(/^## (\d+)/)?.[1] ?? 0) > from)
|
|
157
|
+
.join("")
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Copies the current template into the workspace for the guided turn to read (it is generated, not history). */
|
|
162
|
+
export async function writeTemplateCopy(dir: string): Promise<void> {
|
|
163
|
+
const target = path.join(dir, TEMPLATE_COPY);
|
|
164
|
+
await rm(target, { recursive: true, force: true });
|
|
165
|
+
for (const folder of SOURCE_DIRS) {
|
|
166
|
+
await cp(path.join(repoRoot, "template", folder), path.join(target, folder), { recursive: true });
|
|
167
|
+
}
|
|
168
|
+
}
|