@juzser/blacksmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// New-project scaffolder (architecture §14 "Unified stack standard").
|
|
2
|
+
// `factory/scaffold/base/` is the always-copied template tree;
|
|
3
|
+
// `factory/scaffold/ui/` layers Vue+Vite on top for `--ui` projects, and
|
|
4
|
+
// `factory/scaffold/ui-tailwind/` layers the utility CSS over that when the
|
|
5
|
+
// operator answered `styling: tailwind`. What gets layered is not this
|
|
6
|
+
// module's opinion: factory/policies/stack.yml holds the answers, stack.ts
|
|
7
|
+
// parses them, and an answer these templates cannot build stops the scaffold
|
|
8
|
+
// (requireScaffoldable) instead of quietly producing a different project —
|
|
9
|
+
// which is what the old hardcoded "Vue + Tailwind + a private design system"
|
|
10
|
+
// path did to anyone who wanted something else. Deterministic file copy +
|
|
11
|
+
// string substitution only — no LLM calls, no network, no git push (guardrails.md:
|
|
12
|
+
// only the operator pushes/creates the remote; this module prints the
|
|
13
|
+
// commands for them to run, never executes them).
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { SmithError } from './errors.js';
|
|
18
|
+
import { runGit as git } from './git.js';
|
|
19
|
+
import { PROJECTS_DIR, REPO_ROOT, ROADMAP_PATH, SCAFFOLD_DIR } from './paths.js';
|
|
20
|
+
import { readRoadmapText, roadmapDeclaresId } from './roadmap.js';
|
|
21
|
+
import { loadStackAnswers, requireScaffoldable } from './stack.js';
|
|
22
|
+
export class ScaffoldError extends SmithError {
|
|
23
|
+
}
|
|
24
|
+
const PROJECT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
25
|
+
const PLACEHOLDER = '__PROJECT_NAME__';
|
|
26
|
+
/** Extensions worth scanning for the placeholder token — binary/lockfile-shaped files are skipped. */
|
|
27
|
+
const TEXT_EXTENSIONS = new Set([
|
|
28
|
+
'.json',
|
|
29
|
+
'.ts',
|
|
30
|
+
'.vue',
|
|
31
|
+
'.md',
|
|
32
|
+
'.yml',
|
|
33
|
+
'.yaml',
|
|
34
|
+
'.html',
|
|
35
|
+
'.css',
|
|
36
|
+
'.gitignore',
|
|
37
|
+
]);
|
|
38
|
+
/** Template-only files that get merged, not copied verbatim (see mergePackageJson). */
|
|
39
|
+
export const PACKAGE_FRAGMENT_NAME = 'package.fragment.json';
|
|
40
|
+
/**
|
|
41
|
+
* Suffix stripped at copy time. `biome.json` specifically CANNOT live under
|
|
42
|
+
* that literal name inside factory/scaffold/base/: Biome's own config
|
|
43
|
+
* discovery treats every `biome.json` it finds as a project root, and a
|
|
44
|
+
* nested one under black-smith's OWN root config trips "Found a nested root
|
|
45
|
+
* configuration" and fails `pnpm exec biome check .` on THIS repo — a
|
|
46
|
+
* problem that only exists while the template lives inside black-smith's
|
|
47
|
+
* tree, not in the scaffolded project itself (which rightly wants
|
|
48
|
+
* `biome.json` as ITS OWN root config). Renaming the template file sidesteps
|
|
49
|
+
* discovery here without weakening the shipped config.
|
|
50
|
+
*/
|
|
51
|
+
const TEMPLATE_SUFFIX = '.tmpl';
|
|
52
|
+
export function validateProjectName(name) {
|
|
53
|
+
if (!PROJECT_NAME_PATTERN.test(name)) {
|
|
54
|
+
throw new ScaffoldError('scaffold.invalid-project-name', `Project name "${name}" must be lowercase kebab-case (^[a-z][a-z0-9-]*$).`, { name });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function destFileName(entry) {
|
|
58
|
+
return entry.endsWith(TEMPLATE_SUFFIX) ? entry.slice(0, -TEMPLATE_SUFFIX.length) : entry;
|
|
59
|
+
}
|
|
60
|
+
function isTextFile(destFilePath) {
|
|
61
|
+
const ext = path.extname(destFilePath) || path.basename(destFilePath); // .gitignore has no extname
|
|
62
|
+
return TEXT_EXTENSIONS.has(ext);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Recursively copy `srcDir` into `destDir`, substituting PLACEHOLDER in text
|
|
66
|
+
* file contents. Exported for mcp.ts, which layers a template onto an ALREADY
|
|
67
|
+
* scaffolded project rather than creating one — same copy semantics, different
|
|
68
|
+
* precondition, so it reuses this instead of growing a second copier that
|
|
69
|
+
* could drift on placeholder or `.tmpl` handling.
|
|
70
|
+
*/
|
|
71
|
+
export function copyTemplateDir(srcDir, destDir, projectName, filesWritten, skip = new Set()) {
|
|
72
|
+
// Stripping TEMPLATE_SUFFIX means `x.yaml` and `x.yaml.tmpl` in ONE template
|
|
73
|
+
// directory are two sources for one destination, and readdirSync does not
|
|
74
|
+
// sort: which one survived was the filesystem's answer, not the repo's, and
|
|
75
|
+
// the loser's edits were discarded in silence (D-179). Refuse instead —
|
|
76
|
+
// there is no legitimate reason for two entries in the same directory to
|
|
77
|
+
// target the same file. The map is per-directory on purpose: `ui/` copying
|
|
78
|
+
// over a file `base/` already wrote is layering, which is by design.
|
|
79
|
+
const claimedBy = new Map();
|
|
80
|
+
for (const entry of readdirSync(srcDir)) {
|
|
81
|
+
if (skip.has(entry))
|
|
82
|
+
continue;
|
|
83
|
+
const destName = destFileName(entry);
|
|
84
|
+
const priorEntry = claimedBy.get(destName);
|
|
85
|
+
if (priorEntry !== undefined) {
|
|
86
|
+
throw new ScaffoldError('scaffold.duplicate-destination', `Template entries "${priorEntry}" and "${entry}" both copy to "${destName}" in ${srcDir}. Delete one — the surviving copy is whichever the filesystem lists last.`, { srcDir, destName, entries: [priorEntry, entry] });
|
|
87
|
+
}
|
|
88
|
+
claimedBy.set(destName, entry);
|
|
89
|
+
const srcPath = path.join(srcDir, entry);
|
|
90
|
+
const destPath = path.join(destDir, destName);
|
|
91
|
+
const stat = statSync(srcPath);
|
|
92
|
+
if (stat.isDirectory()) {
|
|
93
|
+
mkdirSync(destPath, { recursive: true });
|
|
94
|
+
copyTemplateDir(srcPath, destPath, projectName, filesWritten, skip);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
mkdirSync(path.dirname(destPath), { recursive: true });
|
|
98
|
+
if (isTextFile(destPath)) {
|
|
99
|
+
const text = readFileSync(srcPath, 'utf8').split(PLACEHOLDER).join(projectName);
|
|
100
|
+
writeFileSync(destPath, text, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
copyFileSync(srcPath, destPath);
|
|
104
|
+
}
|
|
105
|
+
filesWritten.push(destPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Deep-merges a `package.fragment.json` (ui variant's extra scripts/deps) onto the base package.json. */
|
|
109
|
+
export function mergePackageJson(targetDir, fragmentPath) {
|
|
110
|
+
const packageJsonPath = path.join(targetDir, 'package.json');
|
|
111
|
+
const base = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
112
|
+
const fragment = JSON.parse(readFileSync(fragmentPath, 'utf8'));
|
|
113
|
+
for (const section of ['scripts', 'dependencies', 'devDependencies']) {
|
|
114
|
+
base[section] = { ...(base[section] ?? {}), ...(fragment[section] ?? {}) };
|
|
115
|
+
}
|
|
116
|
+
writeFileSync(packageJsonPath, `${JSON.stringify(base, null, 2)}\n`, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
/** Verbatim recursive copy — no placeholder substitution, no `.tmpl` stripping. */
|
|
119
|
+
function copyTree(srcDir, destDir, written) {
|
|
120
|
+
mkdirSync(destDir, { recursive: true });
|
|
121
|
+
for (const entry of readdirSync(srcDir)) {
|
|
122
|
+
const src = path.join(srcDir, entry);
|
|
123
|
+
const dest = path.join(destDir, entry);
|
|
124
|
+
if (statSync(src).isDirectory()) {
|
|
125
|
+
copyTree(src, dest, written);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
copyFileSync(src, dest);
|
|
129
|
+
written.push(dest);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Vendor the operator's design system into `<project>/design/`.
|
|
134
|
+
*
|
|
135
|
+
* Vendored, never referenced from an external repo: a design system a project
|
|
136
|
+
* cannot build offline is a dependency on somebody else's uptime. What is
|
|
137
|
+
* copied comes from stack.yml, not from here — this repo used to hardcode its
|
|
138
|
+
* own dashboard's private kit as `hds/` in every scaffolded project, which
|
|
139
|
+
* made every project downstream of a design system its operator could not
|
|
140
|
+
* obtain. `design_system: none` vendors nothing, which is the right answer
|
|
141
|
+
* for a project that owns its own components.
|
|
142
|
+
*
|
|
143
|
+
* A named source that does not exist is a refusal, not a skip. The previous
|
|
144
|
+
* version's `if (!existsSync(src)) continue` meant a moved kit produced a
|
|
145
|
+
* project whose stylesheet imported a directory that was never written, and
|
|
146
|
+
* the scaffold reported success. Resolution is split out from the copy so the
|
|
147
|
+
* refusal lands before the target directory exists, rather than leaving half
|
|
148
|
+
* a project for the operator to clean up.
|
|
149
|
+
*/
|
|
150
|
+
function resolveDesignSystemSource(stack, repoRoot) {
|
|
151
|
+
if (stack.designSystem === 'none' || stack.designSystemSource === '')
|
|
152
|
+
return null;
|
|
153
|
+
const source = stack.designSystemSource;
|
|
154
|
+
const sourceDir = path.isAbsolute(source) ? source : path.join(repoRoot, source);
|
|
155
|
+
if (!existsSync(sourceDir)) {
|
|
156
|
+
throw new ScaffoldError('scaffold.design-system-missing', `factory/policies/stack.yml names design_system_source ${source}, which does not exist (looked in ${sourceDir}). Fix the path or set design_system: none.`, { designSystem: stack.designSystem, source, sourceDir });
|
|
157
|
+
}
|
|
158
|
+
return sourceDir;
|
|
159
|
+
}
|
|
160
|
+
/** Copy the resolved kit in. Resolution already happened, and already refused. */
|
|
161
|
+
function vendorDesignSystem(targetDir, sourceDir) {
|
|
162
|
+
if (sourceDir === null)
|
|
163
|
+
return [];
|
|
164
|
+
const written = [];
|
|
165
|
+
copyTree(sourceDir, path.join(targetDir, DESIGN_DIR), written);
|
|
166
|
+
return written;
|
|
167
|
+
}
|
|
168
|
+
/** Where a vendored kit lands, and the token file the style entry imports if it finds one. */
|
|
169
|
+
const DESIGN_DIR = 'design';
|
|
170
|
+
const DESIGN_TOKENS_FILE = 'tokens.css';
|
|
171
|
+
/**
|
|
172
|
+
* Write `src/styles/main.css`, which is generated rather than copied.
|
|
173
|
+
*
|
|
174
|
+
* Its whole content is a consequence of two answers — whether Tailwind is the
|
|
175
|
+
* utility layer, and whether a design system was vendored — so a static
|
|
176
|
+
* template could only be right for one combination. It was: `@import
|
|
177
|
+
* 'tailwindcss'` over `@import '../../hds/hds-tokens.css'`, both unconditional.
|
|
178
|
+
*/
|
|
179
|
+
function writeStyleEntry(targetDir, stack) {
|
|
180
|
+
const lines = [
|
|
181
|
+
'/* Style entry, generated once from the stack answers',
|
|
182
|
+
` * (styling: ${stack.styling}, design_system: ${stack.designSystem}).`,
|
|
183
|
+
' * Yours from here — nothing regenerates this file.',
|
|
184
|
+
' */',
|
|
185
|
+
];
|
|
186
|
+
if (stack.styling === 'tailwind')
|
|
187
|
+
lines.push("@import 'tailwindcss';");
|
|
188
|
+
const tokens = path.join(targetDir, DESIGN_DIR, DESIGN_TOKENS_FILE);
|
|
189
|
+
if (existsSync(tokens))
|
|
190
|
+
lines.push(`@import '../../${DESIGN_DIR}/${DESIGN_TOKENS_FILE}';`);
|
|
191
|
+
else if (stack.designSystem !== 'none') {
|
|
192
|
+
lines.push(`/* ${stack.designSystem} is vendored under ${DESIGN_DIR}/ but ships no`, ` * ${DESIGN_TOKENS_FILE} — import its entry point here. */`);
|
|
193
|
+
}
|
|
194
|
+
lines.push('', '.app {', ' margin: 0 auto;', ' max-width: 60rem;', ' padding: 2rem 1.5rem;', '}', '');
|
|
195
|
+
const dest = path.join(targetDir, 'src', 'styles', 'main.css');
|
|
196
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
197
|
+
writeFileSync(dest, lines.join('\n'), 'utf8');
|
|
198
|
+
return dest;
|
|
199
|
+
}
|
|
200
|
+
const SETUP_BRANCH = 'setup';
|
|
201
|
+
/**
|
|
202
|
+
* Who the first commit is attributed to when the machine can name nobody.
|
|
203
|
+
* Deliberately not a person and not a deliverable address: nobody authored
|
|
204
|
+
* this commit, so a later `git log` should name the step it belongs to rather
|
|
205
|
+
* than implicate whoever's laptop happened to run it.
|
|
206
|
+
*/
|
|
207
|
+
const FALLBACK_IDENTITY = { name: 'setup', email: 'setup@localhost' };
|
|
208
|
+
/**
|
|
209
|
+
* git needs an identity to commit, and on a machine with none it guesses one
|
|
210
|
+
* from the username and hostname — unless the username is empty (`fatal: empty
|
|
211
|
+
* ident name`) or `user.useConfigOnly` turned guessing off. A GitHub runner is
|
|
212
|
+
* the first case, which is how black-smith's own CI found this: every
|
|
213
|
+
* `smith new` inside a fresh container died on the scaffold's first commit.
|
|
214
|
+
*
|
|
215
|
+
* The scaffolder creates this repo, so no one outside it gets a chance to
|
|
216
|
+
* configure an identity in between — the fallback has to live here. It is
|
|
217
|
+
* written repo-locally, so it can never leak into the operator's global
|
|
218
|
+
* config, and only when git itself says it has nothing.
|
|
219
|
+
*/
|
|
220
|
+
function ensureCommitIdentity(targetDir) {
|
|
221
|
+
try {
|
|
222
|
+
git(targetDir, ['var', 'GIT_COMMITTER_IDENT']);
|
|
223
|
+
return 'configured';
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
git(targetDir, ['config', 'user.name', FALLBACK_IDENTITY.name]);
|
|
227
|
+
git(targetDir, ['config', 'user.email', FALLBACK_IDENTITY.email]);
|
|
228
|
+
return 'fallback';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** git init + first commit on a `setup` branch — never touches/pushes main (guardrails.md). */
|
|
232
|
+
function initGitRepo(targetDir) {
|
|
233
|
+
git(targetDir, ['init']);
|
|
234
|
+
git(targetDir, ['checkout', '-b', SETUP_BRANCH]);
|
|
235
|
+
const identity = ensureCommitIdentity(targetDir);
|
|
236
|
+
git(targetDir, ['add', '-A']);
|
|
237
|
+
git(targetDir, ['commit', '-m', 'Initial project scaffold']);
|
|
238
|
+
return identity;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Install, then the project's own gates in the order ci.yml runs them.
|
|
242
|
+
*
|
|
243
|
+
* The order is the point (P9-19): a scaffold that proves a different sequence
|
|
244
|
+
* than CI proves nothing about CI. `pnpm install` carries no
|
|
245
|
+
* `--frozen-lockfile` because this run is what CREATES the lockfile that CI's
|
|
246
|
+
* frozen install then checks against — the reason the first PR out of any epic
|
|
247
|
+
* used to red-CI on step one.
|
|
248
|
+
*/
|
|
249
|
+
const TOOLCHAIN_STEPS = [
|
|
250
|
+
{ name: 'install', command: 'pnpm', args: ['install'] },
|
|
251
|
+
{ name: 'lint', command: 'pnpm', args: ['run', 'lint'] },
|
|
252
|
+
{ name: 'typecheck', command: 'pnpm', args: ['run', 'typecheck'] },
|
|
253
|
+
{ name: 'typecheck:test', command: 'pnpm', args: ['run', 'typecheck:test'] },
|
|
254
|
+
{ name: 'test:coverage', command: 'pnpm', args: ['run', 'test:coverage'] },
|
|
255
|
+
{ name: 'build', command: 'pnpm', args: ['run', 'build'] },
|
|
256
|
+
];
|
|
257
|
+
/** Enough of a failing step's tail to diagnose from, bounded so a runaway log can't become the CLI's entire output. */
|
|
258
|
+
const OUTPUT_TAIL_BYTES = 4096;
|
|
259
|
+
const defaultRunCommand = (command, args, cwd) => {
|
|
260
|
+
const proc = spawnSync(command, [...args], { cwd, encoding: 'utf8' });
|
|
261
|
+
if (proc.error)
|
|
262
|
+
return { exitCode: null, output: proc.error.message };
|
|
263
|
+
return { exitCode: proc.status, output: `${proc.stdout ?? ''}${proc.stderr ?? ''}` };
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* Install the toolchain and run the project's own gates against it, so the
|
|
267
|
+
* first epic planned in this project inherits a repo whose checks are known to
|
|
268
|
+
* pass rather than a serial `task-0-toolchain` that claims the hottest files in
|
|
269
|
+
* the tree (D-3, D-4). The cost belongs to scaffolding, not to that epic's
|
|
270
|
+
* budget.
|
|
271
|
+
*
|
|
272
|
+
* Stops at the first red step, exactly as ci.yml's step list does: the answer
|
|
273
|
+
* the operator needs is which gate went red first, and running four more
|
|
274
|
+
* commands against a tree that failed `install` produces noise, not evidence.
|
|
275
|
+
* Never throws — a scaffold whose gates fail is still a tree worth keeping and
|
|
276
|
+
* fixing; the caller decides what a failed report is worth (`smith new` exits
|
|
277
|
+
* non-zero on one).
|
|
278
|
+
*/
|
|
279
|
+
function runToolchain(targetDir, run) {
|
|
280
|
+
const steps = [];
|
|
281
|
+
for (const step of TOOLCHAIN_STEPS) {
|
|
282
|
+
const line = [step.command, ...step.args].join(' ');
|
|
283
|
+
const { exitCode, output } = run(step.command, step.args, targetDir);
|
|
284
|
+
const ok = exitCode === 0;
|
|
285
|
+
steps.push({
|
|
286
|
+
name: step.name,
|
|
287
|
+
command: line,
|
|
288
|
+
ok,
|
|
289
|
+
exitCode,
|
|
290
|
+
...(ok ? {} : { output: output.slice(-OUTPUT_TAIL_BYTES) }),
|
|
291
|
+
});
|
|
292
|
+
if (!ok)
|
|
293
|
+
return { status: 'failed', steps, failedStep: step.name };
|
|
294
|
+
}
|
|
295
|
+
return { status: 'verified', steps };
|
|
296
|
+
}
|
|
297
|
+
export function scaffoldProject(opts) {
|
|
298
|
+
validateProjectName(opts.projectName);
|
|
299
|
+
const stack = opts.stack ?? loadStackAnswers();
|
|
300
|
+
// Before the directory is created, not after: a refusal that leaves half a
|
|
301
|
+
// project behind is a refusal the operator has to clean up.
|
|
302
|
+
requireScaffoldable(stack, { ui: opts.ui });
|
|
303
|
+
const templateDir = opts.templateDir ?? SCAFFOLD_DIR;
|
|
304
|
+
const repoRoot = opts.repoRoot ?? REPO_ROOT;
|
|
305
|
+
const designSystemDir = opts.ui ? resolveDesignSystemSource(stack, repoRoot) : null;
|
|
306
|
+
const targetDir = opts.targetDir ?? path.join(opts.projectsDir ?? PROJECTS_DIR, opts.projectName);
|
|
307
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
308
|
+
throw new ScaffoldError('scaffold.target-exists', `Refusing to scaffold into a non-empty directory: ${targetDir}.`, { targetDir });
|
|
309
|
+
}
|
|
310
|
+
mkdirSync(targetDir, { recursive: true });
|
|
311
|
+
const filesWritten = [];
|
|
312
|
+
const baseDir = path.join(templateDir, 'base');
|
|
313
|
+
copyTemplateDir(baseDir, targetDir, opts.projectName, filesWritten);
|
|
314
|
+
if (opts.ui) {
|
|
315
|
+
const uiDir = path.join(templateDir, 'ui');
|
|
316
|
+
copyTemplateDir(uiDir, targetDir, opts.projectName, filesWritten, new Set([PACKAGE_FRAGMENT_NAME]));
|
|
317
|
+
mergePackageJson(targetDir, path.join(uiDir, PACKAGE_FRAGMENT_NAME));
|
|
318
|
+
if (stack.styling === 'tailwind') {
|
|
319
|
+
// A layer, not a variant: it overwrites vite.config.ts with the same
|
|
320
|
+
// file plus the plugin, and merges the dependency in. `plain-css`
|
|
321
|
+
// projects never see Tailwind in their lockfile, which is the point.
|
|
322
|
+
const tailwindDir = path.join(templateDir, 'ui-tailwind');
|
|
323
|
+
copyTemplateDir(tailwindDir, targetDir, opts.projectName, filesWritten, new Set([PACKAGE_FRAGMENT_NAME]));
|
|
324
|
+
mergePackageJson(targetDir, path.join(tailwindDir, PACKAGE_FRAGMENT_NAME));
|
|
325
|
+
}
|
|
326
|
+
filesWritten.push(...vendorDesignSystem(targetDir, designSystemDir));
|
|
327
|
+
filesWritten.push(writeStyleEntry(targetDir, stack));
|
|
328
|
+
}
|
|
329
|
+
// Before the commit, not after: `pnpm install` is what writes
|
|
330
|
+
// pnpm-lock.yaml, and a lockfile outside the first commit is a lockfile
|
|
331
|
+
// ci.yml's `--frozen-lockfile` cannot see.
|
|
332
|
+
const toolchain = opts.skipToolchain
|
|
333
|
+
? {
|
|
334
|
+
status: 'skipped',
|
|
335
|
+
steps: [],
|
|
336
|
+
reason: 'skipToolchain was set — no install ran, and no gate was proven.',
|
|
337
|
+
}
|
|
338
|
+
: runToolchain(targetDir, opts.runCommand ?? defaultRunCommand);
|
|
339
|
+
const commitIdentity = opts.skipGit ? 'skipped' : initGitRepo(targetDir);
|
|
340
|
+
return {
|
|
341
|
+
targetDir,
|
|
342
|
+
filesWritten,
|
|
343
|
+
branch: SETUP_BRANCH,
|
|
344
|
+
toolchain,
|
|
345
|
+
commitIdentity,
|
|
346
|
+
commands: {
|
|
347
|
+
ghRepoCreate: `gh repo create ${opts.projectName} --private --source=${targetDir} --remote=origin --push=false`,
|
|
348
|
+
push: `git -C ${targetDir} push -u origin ${SETUP_BRANCH}`,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Appends a bootstrap milestone for the new project to factory/specs/
|
|
354
|
+
* roadmap.md (architecture §12: "the planner maintains it"; a fresh project
|
|
355
|
+
* gets a placeholder milestone so the Projects/Roadmap pages have something
|
|
356
|
+
* to show the moment it exists, edited further as real epics get scoped).
|
|
357
|
+
*/
|
|
358
|
+
export function registerProjectInRoadmap(projectName, roadmapPath = ROADMAP_PATH) {
|
|
359
|
+
const text = readRoadmapText(roadmapPath);
|
|
360
|
+
const id = `${projectName}-bootstrap`;
|
|
361
|
+
// See registerMcpMilestone: the substring `- id: <id>` is not what
|
|
362
|
+
// parseRoadmap reads, and a duplicate id takes the whole roadmap away from
|
|
363
|
+
// every reader of it rather than merely adding a stray section.
|
|
364
|
+
if (roadmapDeclaresId(text, id))
|
|
365
|
+
return; // idempotent — already registered
|
|
366
|
+
const block = `\n## ${projectName} — bootstrap\n` +
|
|
367
|
+
`- id: ${id}\n` +
|
|
368
|
+
'- status: planned\n' +
|
|
369
|
+
`- project: ${projectName}\n` +
|
|
370
|
+
'- epics: []\n' +
|
|
371
|
+
'- goal: Scaffold complete; awaiting the first planned epic.\n';
|
|
372
|
+
writeFileSync(roadmapPath, `${text.trimEnd()}\n${block}`, 'utf8');
|
|
373
|
+
}
|