techbro 0.0.0-stage → 0.5.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 +453 -2
- package/assets/baseline-rules.yml +60 -0
- package/assets/caller-workflow.yml +26 -0
- package/assets/review-action.yml +27 -0
- package/assets/review-config.json +4 -0
- package/assets/review-workflow.yml +443 -0
- package/assets/review.mjs +721 -0
- package/bin/techbro.js +9 -0
- package/package.json +23 -4
- package/src/cli.js +690 -0
- package/src/cluster.js +134 -0
- package/src/config.js +35 -0
- package/src/doctor.js +125 -0
- package/src/github.js +152 -0
- package/src/init.js +167 -0
- package/src/kube-project.js +468 -0
- package/src/kube.js +317 -0
- package/src/managed.js +63 -0
- package/src/project.js +202 -0
- package/src/review.js +165 -0
- package/src/secret.js +80 -0
- package/src/setup.js +91 -0
- package/src/team-workflow.js +106 -0
- package/src/team.js +145 -0
- package/src/templates.js +44 -0
- package/src/upgrade.js +94 -0
- package/templates/_default/.agents/skills/README.md +36 -0
- package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
- package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
- package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
- package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
- package/templates/_default/.editorconfig +15 -0
- package/templates/_default/.env.example +9 -0
- package/templates/_default/.github/workflows/ci.yml +60 -0
- package/templates/_default/.nvmrc +1 -0
- package/templates/_default/Cargo.lock +1834 -0
- package/templates/_default/Cargo.toml +3 -0
- package/templates/_default/README.md +257 -0
- package/templates/_default/TEMPLATE.md +29 -0
- package/templates/_default/_dockerignore +8 -0
- package/templates/_default/_gitignore +8 -0
- package/templates/_default/apps/backend/Cargo.toml +22 -0
- package/templates/_default/apps/backend/Dockerfile +34 -0
- package/templates/_default/apps/backend/README.md +13 -0
- package/templates/_default/apps/backend/src/config.rs +111 -0
- package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
- package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
- package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
- package/templates/_default/apps/backend/src/error.rs +115 -0
- package/templates/_default/apps/backend/src/lib.rs +7 -0
- package/templates/_default/apps/backend/src/main.rs +71 -0
- package/templates/_default/apps/backend/src/models/item.rs +94 -0
- package/templates/_default/apps/backend/src/models/mod.rs +1 -0
- package/templates/_default/apps/backend/src/routes.rs +94 -0
- package/templates/_default/apps/backend/src/state.rs +12 -0
- package/templates/_default/apps/backend/src/views/items.rs +18 -0
- package/templates/_default/apps/backend/src/views/mod.rs +1 -0
- package/templates/_default/apps/backend/tests/api.rs +234 -0
- package/templates/_default/apps/frontend/.env.example +2 -0
- package/templates/_default/apps/frontend/.prettierignore +3 -0
- package/templates/_default/apps/frontend/.prettierrc +1 -0
- package/templates/_default/apps/frontend/Dockerfile +15 -0
- package/templates/_default/apps/frontend/README.md +39 -0
- package/templates/_default/apps/frontend/_dockerignore +3 -0
- package/templates/_default/apps/frontend/_gitignore +24 -0
- package/templates/_default/apps/frontend/components.json +25 -0
- package/templates/_default/apps/frontend/eslint.config.js +37 -0
- package/templates/_default/apps/frontend/index.html +13 -0
- package/templates/_default/apps/frontend/jsconfig.json +7 -0
- package/templates/_default/apps/frontend/nginx.conf +30 -0
- package/templates/_default/apps/frontend/package-lock.json +4338 -0
- package/templates/_default/apps/frontend/package.json +49 -0
- package/templates/_default/apps/frontend/public/favicon.svg +1 -0
- package/templates/_default/apps/frontend/src/App.jsx +11 -0
- package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
- package/templates/_default/apps/frontend/src/api/client.js +50 -0
- package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
- package/templates/_default/apps/frontend/src/api/items.js +26 -0
- package/templates/_default/apps/frontend/src/api/system.js +11 -0
- package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
- package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
- package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
- package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
- package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
- package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
- package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
- package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
- package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
- package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
- package/templates/_default/apps/frontend/src/index.css +130 -0
- package/templates/_default/apps/frontend/src/lib/config.js +2 -0
- package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
- package/templates/_default/apps/frontend/src/main.jsx +11 -0
- package/templates/_default/apps/frontend/src/models/items.js +24 -0
- package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
- package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
- package/templates/_default/apps/frontend/src/routes.jsx +17 -0
- package/templates/_default/apps/frontend/src/shadcn.css +655 -0
- package/templates/_default/apps/frontend/src/test/setup.js +1 -0
- package/templates/_default/apps/frontend/vite.config.js +25 -0
- package/templates/_default/database/Dockerfile +9 -0
- package/templates/_default/database/README.md +15 -0
- package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
- package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
- package/templates/_default/docker-compose.prod.yml +53 -0
- package/templates/_default/docker-compose.yml +69 -0
- package/templates/_default/docs/api/README.md +41 -0
- package/templates/_default/docs/api/openapi.yaml +152 -0
- package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
- package/templates/_default/docs/decisions/README.md +6 -0
- package/templates/_default/docs/deployment.md +46 -0
- package/templates/_default/docs/docker.md +63 -0
- package/templates/_default/docs/getting-started.md +214 -0
- package/templates/_default/package.json +15 -0
- package/templates/_default/rust-toolchain.toml +3 -0
- package/templates/_default/scripts/check +10 -0
- package/templates/_default/scripts/db-migrate +25 -0
- package/templates/_default/scripts/install-hooks +5 -0
- package/templates/_default/scripts/pre-push +3 -0
- package/templates/_default/scripts/setup +26 -0
package/src/review.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { execFileSync, } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { parseRepoUrl } from "./github.js";
|
|
4
|
+
import { normalizeDuplication, normalizeSecurity, normalizeSonar, normalizeTest } from "../assets/review.mjs";
|
|
5
|
+
import { REVIEW_CONFIG } from "./project.js";
|
|
6
|
+
import { REPORT_ARTIFACT, REVIEW_WORKFLOW, classifyCaller } from "./managed.js";
|
|
7
|
+
|
|
8
|
+
const ACTION = ".github/actions/techbro/action.yml";
|
|
9
|
+
|
|
10
|
+
const defaultGit = (args, cwd) => execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf8" });
|
|
11
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
const WF_FILE = REVIEW_WORKFLOW.split("/").pop();
|
|
13
|
+
|
|
14
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
15
|
+
|
|
16
|
+
async function inspectConfig(client, to, tr, tb, name) {
|
|
17
|
+
const missing = [];
|
|
18
|
+
let cfg = null;
|
|
19
|
+
try {
|
|
20
|
+
cfg = await client.getFile(to, tr, REVIEW_CONFIG, tb);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
missing.push(`${REVIEW_CONFIG} unreadable: ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
const all = cfg?.json?.projects;
|
|
25
|
+
const pc = isObj(all) && Object.hasOwn(all, name) && isObj(all[name]) ? all[name] : null;
|
|
26
|
+
if (!cfg && !missing.length) missing.push(`${REVIEW_CONFIG} is missing in ${to}/${tr}; run techbro team upgrade`);
|
|
27
|
+
else if (cfg && !pc) missing.push(`no projects.${name} entry in ${REVIEW_CONFIG}; defaults apply and no tests run`);
|
|
28
|
+
const effective = { test: null, securityFailOn: "ERROR", sonarMode: "optional", sonarProjectKey: null, duplicationMode: "required", duplicationMaxPercent: 10 };
|
|
29
|
+
const check = (label, fn) => {
|
|
30
|
+
try {
|
|
31
|
+
return fn();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
missing.push(`projects.${name}.${label} invalid: ${err.message}; the remote review would reject it`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
if (cfg && pc) {
|
|
38
|
+
const t = check("test", () => normalizeTest(pc.test));
|
|
39
|
+
const sec = check("security", () => normalizeSecurity(pc.security));
|
|
40
|
+
const son = check("sonar", () => normalizeSonar(pc.sonar));
|
|
41
|
+
if (t) effective.test = `${t.runtime}: ${t.command}`;
|
|
42
|
+
else if (!pc.test?.command && !missing.some((m) => m.includes(".test invalid"))) missing.push(`projects.${name}.test.command is not configured; tests will be skipped`);
|
|
43
|
+
const dup = check("duplication", () => normalizeDuplication(pc.duplication));
|
|
44
|
+
if (dup) {
|
|
45
|
+
effective.duplicationMode = dup.mode;
|
|
46
|
+
effective.duplicationMaxPercent = dup.maxPercent;
|
|
47
|
+
}
|
|
48
|
+
if (sec) effective.securityFailOn = sec.failOn;
|
|
49
|
+
if (son) {
|
|
50
|
+
effective.sonarMode = son.mode;
|
|
51
|
+
effective.sonarProjectKey = son.projectKey || null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { missing, effective };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function reviewProject({
|
|
58
|
+
cwd, url, ref, team, project, wait = false, dryRun = false, config, client, git = defaultGit, uuid = randomUUID, sleep = defaultSleep,
|
|
59
|
+
warn = () => {}, lookupAttempts = 10, lookupIntervalMs = 2000, waitTimeoutMs = 30 * 60 * 1000, waitIntervalMs = 10000,
|
|
60
|
+
}) {
|
|
61
|
+
const teamName = team ?? config.currentTeam;
|
|
62
|
+
const t = teamName && config.teams[teamName];
|
|
63
|
+
if (!t) throw new Error(teamName ? `unknown team "${teamName}"` : "no current team; run techbro team create or team join first");
|
|
64
|
+
const { owner: to, repo: tr } = parseRepoUrl(t.url);
|
|
65
|
+
|
|
66
|
+
const { login } = await client.getUser();
|
|
67
|
+
const teamRepo = await client.getRepo(to, tr);
|
|
68
|
+
const tb = teamRepo.defaultBranch;
|
|
69
|
+
if (!teamRepo.permissions?.pull) throw new Error(`no read access to ${to}/${tr} as ${login}`);
|
|
70
|
+
const members = await client.getFile(to, tr, "members.json", tb);
|
|
71
|
+
if (!members?.json?.members?.some((m) => String(m).toLowerCase() === login.toLowerCase())) throw new Error(`${login} is not a member of team ${teamName}; run techbro team join`);
|
|
72
|
+
const reg = await client.getFile(to, tr, "projects.json", tb);
|
|
73
|
+
if (!reg || !Array.isArray(reg.json?.projects)) throw new Error(`projects.json in ${to}/${tr} is missing or malformed`);
|
|
74
|
+
|
|
75
|
+
let originUrl = null;
|
|
76
|
+
try {
|
|
77
|
+
originUrl = parseRepoUrl(git(["remote", "get-url", "origin"], cwd).trim()).url;
|
|
78
|
+
} catch {
|
|
79
|
+
// no usable origin
|
|
80
|
+
}
|
|
81
|
+
const explicitUrl = url ? parseRepoUrl(url).url : null;
|
|
82
|
+
let matches;
|
|
83
|
+
if (project) matches = reg.json.projects.filter((p) => p.name === project);
|
|
84
|
+
else if (explicitUrl ?? originUrl) matches = reg.json.projects.filter((p) => p.url === (explicitUrl ?? originUrl));
|
|
85
|
+
else throw new Error("no origin remote; pass --url or --project");
|
|
86
|
+
if (matches.length === 0) throw new Error("project is not registered with this team; run techbro project link");
|
|
87
|
+
if (matches.length > 1) throw new Error("project matches more than one registered entry; pass --project <name>");
|
|
88
|
+
const entry = matches[0];
|
|
89
|
+
const { owner: po, repo: pr, url: purl } = parseRepoUrl(entry.url);
|
|
90
|
+
if (explicitUrl && explicitUrl !== purl) throw new Error(`--url ${explicitUrl} does not match registered project ${entry.name} (${purl})`);
|
|
91
|
+
|
|
92
|
+
const projRepo = await client.getRepo(po, pr);
|
|
93
|
+
if (!projRepo.permissions?.pull) throw new Error(`no read access to ${po}/${pr} as ${login}`);
|
|
94
|
+
if (po.toLowerCase() !== to.toLowerCase()) {
|
|
95
|
+
throw new Error(`${po}/${pr} is outside the team organization ${to}; the shared review workflow is same-organization only`);
|
|
96
|
+
}
|
|
97
|
+
const pb = projRepo.defaultBranch;
|
|
98
|
+
const blockers = [];
|
|
99
|
+
if (!projRepo.permissions?.push) blockers.push(`no write access to ${po}/${pr} as ${login}; reviews are dispatched from the project repo`);
|
|
100
|
+
|
|
101
|
+
let sha;
|
|
102
|
+
const local = !ref && originUrl === purl && (!explicitUrl || explicitUrl === originUrl);
|
|
103
|
+
if (local) {
|
|
104
|
+
sha = git(["rev-parse", "HEAD"], cwd).trim();
|
|
105
|
+
if (git(["status", "--porcelain"], cwd).trim()) warn("uncommitted changes are excluded; only the committed HEAD is reviewed");
|
|
106
|
+
if ((await client.getCommitSha(po, pr, sha)) === null) throw new Error(`commit ${sha.slice(0, 12)} is not on ${po}/${pr}; push it first`);
|
|
107
|
+
} else {
|
|
108
|
+
const r = ref ?? projRepo.defaultBranch;
|
|
109
|
+
sha = await client.getCommitSha(po, pr, r);
|
|
110
|
+
if (sha === null) throw new Error(`cannot resolve ref "${r}" in ${po}/${pr}`);
|
|
111
|
+
}
|
|
112
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`could not resolve an immutable commit sha (got "${sha}")`);
|
|
113
|
+
|
|
114
|
+
const params = { owner: to, repo: tr };
|
|
115
|
+
const caller = await client.getFileText(po, pr, REVIEW_WORKFLOW, pb);
|
|
116
|
+
if (caller === null) blockers.push(`${REVIEW_WORKFLOW} is not on ${pb} of ${po}/${pr}; run techbro project link and merge the caller PR`);
|
|
117
|
+
else if (classifyCaller(caller, params) !== "current") warn(`${REVIEW_WORKFLOW} in ${po}/${pr} differs from the managed caller; using it as is`);
|
|
118
|
+
for (const f of [REVIEW_WORKFLOW, ACTION]) {
|
|
119
|
+
if ((await client.getFileText(to, tr, f, tb)) === null) blockers.push(`${f} is not installed in ${to}/${tr}; run techbro team upgrade`);
|
|
120
|
+
}
|
|
121
|
+
const notes = ["Sonar token/host are optional repository secrets/variables in the project caller; not inspected (the remote gate decides)"];
|
|
122
|
+
if (teamRepo.private && client.getActionsAccess) {
|
|
123
|
+
try {
|
|
124
|
+
const level = await client.getActionsAccess(to, tr);
|
|
125
|
+
if (level === "none") blockers.push(`${to}/${tr} Actions access is "none"; a team admin must allow organization repositories to use its workflows and actions`);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
notes.push(`could not verify Actions sharing on ${to}/${tr} (${err.message}); a team admin must enable organization access`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!dryRun && blockers.length) throw new Error(blockers[0]);
|
|
131
|
+
|
|
132
|
+
const { missing, effective } = await inspectConfig(client, to, tr, tb, entry.name);
|
|
133
|
+
const workflowUrl = `https://github.com/${po}/${pr}/actions/workflows/${WF_FILE}`;
|
|
134
|
+
if (dryRun) {
|
|
135
|
+
missing.unshift(...blockers);
|
|
136
|
+
return { dryRun: true, ok: missing.length === 0, project: entry.name, sha, team: teamName, workflowUrl, effective, missing, notes };
|
|
137
|
+
}
|
|
138
|
+
for (const m of missing) warn(m);
|
|
139
|
+
|
|
140
|
+
const requestId = uuid();
|
|
141
|
+
await client.dispatchWorkflow(po, pr, WF_FILE, pb, { sha, request_id: requestId });
|
|
142
|
+
const base = { project: entry.name, sha, requestId, workflowUrl, team: teamName };
|
|
143
|
+
|
|
144
|
+
let run = null;
|
|
145
|
+
for (let i = 0; i < lookupAttempts && !run; i++) {
|
|
146
|
+
if (i) await sleep(lookupIntervalMs);
|
|
147
|
+
const runs = await client.listWorkflowRuns(po, pr, WF_FILE, { branch: pb });
|
|
148
|
+
run = runs.find((x) => typeof x.title === "string" && x.title.trim().split(/\s+/).pop() === requestId) ?? null;
|
|
149
|
+
}
|
|
150
|
+
if (!run) return { ...base, status: "dispatched", ok: !wait, runUrl: null, note: "run not found yet; check the workflow page (not redispatched)" };
|
|
151
|
+
if (!wait) return { ...base, status: "dispatched", ok: true, runUrl: run.url };
|
|
152
|
+
|
|
153
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
154
|
+
let cur = run;
|
|
155
|
+
while (cur.status !== "completed") {
|
|
156
|
+
if (Date.now() >= deadline) return { ...base, status: "timeout", ok: false, runUrl: run.url };
|
|
157
|
+
await sleep(waitIntervalMs);
|
|
158
|
+
cur = await client.getRun(po, pr, run.id);
|
|
159
|
+
}
|
|
160
|
+
let artifactUrl = null;
|
|
161
|
+
const art = (await client.listRunArtifacts(po, pr, run.id)).find((a) => a.name === REPORT_ARTIFACT && !a.expired);
|
|
162
|
+
if (art) artifactUrl = `https://github.com/${po}/${pr}/actions/runs/${run.id}/artifacts/${art.id}`;
|
|
163
|
+
const ok = cur.conclusion === "success";
|
|
164
|
+
return { ...base, status: ok ? "success" : "failure", ok, conclusion: cur.conclusion, runUrl: run.url, artifactUrl };
|
|
165
|
+
}
|
package/src/secret.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const PASSPHRASE_ENV = "TECHBRO_PASSPHRASE";
|
|
5
|
+
const FORMAT = "techbro-secret-v1";
|
|
6
|
+
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
7
|
+
export const MIN_PASSPHRASE = 12;
|
|
8
|
+
|
|
9
|
+
const deriveKey = (passphrase, salt) => scryptSync(String(passphrase).normalize("NFC"), salt, 32, SCRYPT);
|
|
10
|
+
|
|
11
|
+
export function encryptSecret(plaintext, passphrase) {
|
|
12
|
+
if (!passphrase || String(passphrase).length < MIN_PASSPHRASE) throw new Error(`passphrase must be at least ${MIN_PASSPHRASE} characters`);
|
|
13
|
+
const salt = randomBytes(16);
|
|
14
|
+
const iv = randomBytes(12);
|
|
15
|
+
const cipher = createCipheriv("aes-256-gcm", deriveKey(passphrase, salt), iv);
|
|
16
|
+
cipher.setAAD(Buffer.from(FORMAT));
|
|
17
|
+
const data = Buffer.concat([cipher.update(String(plaintext), "utf8"), cipher.final()]);
|
|
18
|
+
const b64 = (b) => b.toString("base64");
|
|
19
|
+
return JSON.stringify({ format: FORMAT, kdf: "scrypt", cipher: "aes-256-gcm", salt: b64(salt), iv: b64(iv), tag: b64(cipher.getAuthTag()), data: b64(data) }, null, 2) + "\n";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function decryptSecret(envelope, passphrase) {
|
|
23
|
+
let e;
|
|
24
|
+
try {
|
|
25
|
+
e = typeof envelope === "string" ? JSON.parse(envelope) : envelope;
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error("encrypted secret is not valid JSON");
|
|
28
|
+
}
|
|
29
|
+
if (e?.format !== FORMAT) throw new Error("unsupported encrypted secret format");
|
|
30
|
+
if (!passphrase) throw new Error(`no passphrase: set ${PASSPHRASE_ENV} or pass --passphrase-file`);
|
|
31
|
+
try {
|
|
32
|
+
const d = createDecipheriv("aes-256-gcm", deriveKey(passphrase, Buffer.from(e.salt, "base64")), Buffer.from(e.iv, "base64"));
|
|
33
|
+
d.setAAD(Buffer.from(FORMAT));
|
|
34
|
+
d.setAuthTag(Buffer.from(e.tag, "base64"));
|
|
35
|
+
return Buffer.concat([d.update(Buffer.from(e.data, "base64")), d.final()]).toString("utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error("could not decrypt (wrong passphrase or modified file)");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Order: --passphrase-file > TECHBRO_PASSPHRASE > hidden TTY prompt.
|
|
42
|
+
export async function resolvePassphrase({ file, env = process.env, prompt = promptHidden, confirm = false } = {}) {
|
|
43
|
+
if (file) {
|
|
44
|
+
const p = readFileSync(file, "utf8").replace(/\r?\n$/, "");
|
|
45
|
+
if (!p) throw new Error(`passphrase file ${file} is empty`);
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
if (env[PASSPHRASE_ENV]) return env[PASSPHRASE_ENV];
|
|
49
|
+
if (!process.stdin.isTTY) throw new Error(`no passphrase: set ${PASSPHRASE_ENV} or pass --passphrase-file`);
|
|
50
|
+
const p = await prompt("Passphrase: ");
|
|
51
|
+
if (confirm && (await prompt("Repeat passphrase: ")) !== p) throw new Error("passphrases do not match");
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function promptHidden(label) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const { stdin, stderr } = process;
|
|
58
|
+
let buf = "";
|
|
59
|
+
stderr.write(label);
|
|
60
|
+
stdin.setRawMode(true);
|
|
61
|
+
stdin.resume();
|
|
62
|
+
stdin.setEncoding("utf8");
|
|
63
|
+
const done = (err) => {
|
|
64
|
+
stdin.setRawMode(false);
|
|
65
|
+
stdin.pause();
|
|
66
|
+
stdin.off("data", onData);
|
|
67
|
+
stderr.write("\n");
|
|
68
|
+
err ? reject(err) : resolve(buf);
|
|
69
|
+
};
|
|
70
|
+
const onData = (chunk) => {
|
|
71
|
+
for (const ch of chunk) {
|
|
72
|
+
if (ch === "\r" || ch === "\n") return done();
|
|
73
|
+
if (ch === "\u0003") return done(Object.assign(new Error("cancelled"), { exitCode: 130 }));
|
|
74
|
+
if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1);
|
|
75
|
+
else buf += ch;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
stdin.on("data", onData);
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function defaultRun(cmd, args, opts = {}) {
|
|
6
|
+
execFileSync(cmd, args, { stdio: "inherit", ...opts });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function defaultWhich(cmd) {
|
|
10
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
11
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
12
|
+
for (const ext of exts) {
|
|
13
|
+
try {
|
|
14
|
+
if (statSync(join(dir, cmd + ext)).isFile()) return true;
|
|
15
|
+
} catch {
|
|
16
|
+
// keep looking
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setupProject(
|
|
24
|
+
target,
|
|
25
|
+
{ log = console.log, run = defaultRun, which = defaultWhich, skipBuild = false } = {},
|
|
26
|
+
) {
|
|
27
|
+
const total = skipBuild ? 6 : 7;
|
|
28
|
+
let n = 0;
|
|
29
|
+
const step = (title, manual, fn) => {
|
|
30
|
+
n++;
|
|
31
|
+
log(`[${n}/${total}] ${title}`);
|
|
32
|
+
try {
|
|
33
|
+
fn();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`setup step ${n}/${total} failed (${title.replace(/\.\.\.$/, "")}): ${err.message}\n` +
|
|
37
|
+
` the scaffold is kept in ${target}; rerun manually: ${manual}`,
|
|
38
|
+
{ cause: err },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const sh = (cmd, args, cwd = target) => run(cmd, args, { cwd });
|
|
43
|
+
|
|
44
|
+
step("Checking prerequisites...", "install node, npm and cargo (https://rustup.rs)", () => {
|
|
45
|
+
const missing = ["node", "npm", "cargo"].filter((c) => !which(c));
|
|
46
|
+
if (missing.length === 0) return;
|
|
47
|
+
const hint = missing.includes("cargo") ? " Install Rust from https://rustup.rs" : "";
|
|
48
|
+
throw new Error(`missing on PATH: ${missing.join(", ")}.${hint}`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
step("Creating .env files...", "cp .env.example .env", () => {
|
|
52
|
+
for (const dir of [".", "apps/frontend"]) {
|
|
53
|
+
const example = join(target, dir, ".env.example");
|
|
54
|
+
const env = join(target, dir, ".env");
|
|
55
|
+
if (existsSync(example) && !existsSync(env)) copyFileSync(example, env);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
step("Installing frontend dependencies...", "cd apps/frontend && npm ci", () => {
|
|
60
|
+
const cwd = join(target, "apps/frontend");
|
|
61
|
+
sh("npm", [existsSync(join(cwd, "package-lock.json")) ? "ci" : "install"], cwd);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
step("Fetching backend dependencies...", "cargo fetch --locked", () => {
|
|
65
|
+
sh("cargo", ["fetch", "--locked"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
step(
|
|
69
|
+
"Installing tools (sqlx-cli, cargo-watch; slow the first time, can take minutes)...",
|
|
70
|
+
"cargo install sqlx-cli --locked --no-default-features --features sqlite,postgres && cargo install cargo-watch --locked",
|
|
71
|
+
() => {
|
|
72
|
+
if (which("sqlx")) log(" sqlx already installed");
|
|
73
|
+
else
|
|
74
|
+
sh("cargo", [
|
|
75
|
+
"install", "sqlx-cli", "--locked", "--no-default-features", "--features", "sqlite,postgres",
|
|
76
|
+
]);
|
|
77
|
+
if (which("cargo-watch")) log(" cargo-watch already installed");
|
|
78
|
+
else sh("cargo", ["install", "cargo-watch", "--locked"]);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
step("Creating the local database...", "scripts/db-migrate sqlite", () => {
|
|
83
|
+
sh("bash", ["scripts/db-migrate", "sqlite"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!skipBuild) {
|
|
87
|
+
step("Building the backend (a few minutes the first time)...", "cargo build -p backend", () => {
|
|
88
|
+
sh("cargo", ["build", "-p", "backend"]);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export function teamWorkflowYaml() {
|
|
2
|
+
return `name: techbro team
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
issues:
|
|
6
|
+
types: [opened, reopened]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
issues: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
join:
|
|
14
|
+
if: contains(github.event.issue.labels.*.name, 'techbro-join')
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
timeout-minutes: 5
|
|
17
|
+
env:
|
|
18
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
19
|
+
REPO: \${{ github.repository }}
|
|
20
|
+
NUM: \${{ github.event.issue.number }}
|
|
21
|
+
AUTHOR: \${{ github.event.issue.user.login }}
|
|
22
|
+
ISSUE_BODY: \${{ github.event.issue.body }}
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
with:
|
|
26
|
+
ref: main
|
|
27
|
+
- name: Add member
|
|
28
|
+
shell: bash
|
|
29
|
+
run: |
|
|
30
|
+
reject() {
|
|
31
|
+
gh issue comment "$NUM" --repo "$REPO" --body "$1"
|
|
32
|
+
gh issue close "$NUM" --repo "$REPO"
|
|
33
|
+
exit 0
|
|
34
|
+
}
|
|
35
|
+
perm=$(gh api "repos/$REPO/collaborators/$AUTHOR/permission" --jq .permission 2>/dev/null || echo none)
|
|
36
|
+
case "$perm" in
|
|
37
|
+
read|triage|write|maintain|admin) ;;
|
|
38
|
+
*) reject "$AUTHOR is not a collaborator on this repository." ;;
|
|
39
|
+
esac
|
|
40
|
+
LOGIN=$(node -e 'try { const b = JSON.parse(process.env.ISSUE_BODY); if (b.action === "join" && typeof b.login === "string") process.stdout.write(b.login); } catch {}')
|
|
41
|
+
if [ -z "$LOGIN" ] || [ "$LOGIN" != "$AUTHOR" ]; then
|
|
42
|
+
reject "Invalid join request: login must match the issue author."
|
|
43
|
+
fi
|
|
44
|
+
LOGIN="$LOGIN" node -e 'const fs = require("fs"); const m = JSON.parse(fs.readFileSync("members.json", "utf8")); if (!m.members.includes(process.env.LOGIN)) m.members.push(process.env.LOGIN); fs.writeFileSync("members.json", JSON.stringify(m, null, 2) + "\\n");'
|
|
45
|
+
if git diff --quiet members.json; then
|
|
46
|
+
gh issue comment "$NUM" --repo "$REPO" --body "$LOGIN is already a member."
|
|
47
|
+
else
|
|
48
|
+
git config user.name "github-actions[bot]"
|
|
49
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
50
|
+
git add members.json
|
|
51
|
+
git commit -m "techbro: add $LOGIN to team members"
|
|
52
|
+
if ! git push origin HEAD:main; then
|
|
53
|
+
gh issue comment "$NUM" --repo "$REPO" --body "main may be protected; add the member manually."
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
gh issue comment "$NUM" --repo "$REPO" --body "added $LOGIN to members.json"
|
|
57
|
+
fi
|
|
58
|
+
gh issue close "$NUM" --repo "$REPO"
|
|
59
|
+
link:
|
|
60
|
+
if: contains(github.event.issue.labels.*.name, 'techbro-link')
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
timeout-minutes: 5
|
|
63
|
+
env:
|
|
64
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
65
|
+
REPO: \${{ github.repository }}
|
|
66
|
+
NUM: \${{ github.event.issue.number }}
|
|
67
|
+
AUTHOR: \${{ github.event.issue.user.login }}
|
|
68
|
+
ISSUE_BODY: \${{ github.event.issue.body }}
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/checkout@v4
|
|
71
|
+
with:
|
|
72
|
+
ref: main
|
|
73
|
+
- name: Link project
|
|
74
|
+
shell: bash
|
|
75
|
+
run: |
|
|
76
|
+
reject() {
|
|
77
|
+
gh issue comment "$NUM" --repo "$REPO" --body "$1"
|
|
78
|
+
gh issue close "$NUM" --repo "$REPO"
|
|
79
|
+
exit 0
|
|
80
|
+
}
|
|
81
|
+
perm=$(gh api "repos/$REPO/collaborators/$AUTHOR/permission" --jq .permission 2>/dev/null || echo none)
|
|
82
|
+
case "$perm" in
|
|
83
|
+
read|triage|write|maintain|admin) ;;
|
|
84
|
+
*) reject "$AUTHOR is not a collaborator on this repository." ;;
|
|
85
|
+
esac
|
|
86
|
+
LOGIN=$(node -e 'try { const b = JSON.parse(process.env.ISSUE_BODY); if (b.action === "link" && typeof b.login === "string") process.stdout.write(b.login); } catch {}')
|
|
87
|
+
if [ -z "$LOGIN" ] || [ "$LOGIN" != "$AUTHOR" ]; then
|
|
88
|
+
reject "Invalid join request: login must match the issue author."
|
|
89
|
+
fi
|
|
90
|
+
NAME=$(LOGIN="$LOGIN" node -e 'const fs = require("fs"); const p = JSON.parse(process.env.ISSUE_BODY).project; const m = JSON.parse(fs.readFileSync("projects.json", "utf8")); if (p && typeof p.url === "string" && typeof p.name === "string" && !m.projects.some((x) => x.url === p.url)) { m.projects.push(p); fs.writeFileSync("projects.json", JSON.stringify(m, null, 2) + "\\n"); process.stdout.write(p.name); }')
|
|
91
|
+
if git diff --quiet projects.json; then
|
|
92
|
+
gh issue comment "$NUM" --repo "$REPO" --body "project is already linked."
|
|
93
|
+
else
|
|
94
|
+
git config user.name "github-actions[bot]"
|
|
95
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
96
|
+
git add projects.json
|
|
97
|
+
git commit -m "techbro: link project $NAME"
|
|
98
|
+
if ! git push origin HEAD:main; then
|
|
99
|
+
gh issue comment "$NUM" --repo "$REPO" --body "main may be protected; add the project manually."
|
|
100
|
+
exit 1
|
|
101
|
+
fi
|
|
102
|
+
gh issue comment "$NUM" --repo "$REPO" --body "linked $NAME to projects.json"
|
|
103
|
+
fi
|
|
104
|
+
gh issue close "$NUM" --repo "$REPO"
|
|
105
|
+
`;
|
|
106
|
+
}
|
package/src/team.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { parseRepoUrl } from "./github.js";
|
|
2
|
+
import { teamWorkflowYaml } from "./team-workflow.js";
|
|
3
|
+
import { REVIEW_WORKFLOW, managedFiles } from "./managed.js";
|
|
4
|
+
|
|
5
|
+
const WORKFLOW = ".github/workflows/techbro-team.yml";
|
|
6
|
+
|
|
7
|
+
const BRANCH = "main";
|
|
8
|
+
const HINT =
|
|
9
|
+
"main may be protected, or the token lacks permission to write .github/workflows (classic `workflow` scope / fine-grained Workflows: write, and Actions workflow permissions); allow the token or open a PR manually";
|
|
10
|
+
|
|
11
|
+
export async function createTeam({ name, url, config, client, now = new Date(), force = false }) {
|
|
12
|
+
if (!/^[a-z0-9-]+$/.test(name ?? "")) throw new Error(`invalid team name "${name}" (use lowercase letters, digits, and hyphens)`);
|
|
13
|
+
if (config.teams[name] && !force) throw new Error(`team "${name}" already exists (use --force to overwrite)`);
|
|
14
|
+
const { owner: o, repo: r, url: normalized } = parseRepoUrl(url);
|
|
15
|
+
const { login } = await client.getUser();
|
|
16
|
+
const repo = await client.getRepo(o, r);
|
|
17
|
+
if (!repo.permissions?.push) throw new Error(`no write access to ${o}/${r} as ${login}`);
|
|
18
|
+
|
|
19
|
+
const put = async (path, args) => {
|
|
20
|
+
try {
|
|
21
|
+
await client.putFile(o, r, path, { ...args, branch: BRANCH });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.status === 409 || err.status === 422 || /protect/i.test(err.message)) {
|
|
24
|
+
throw new Error(`${err.message}\n${HINT}`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const json = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
30
|
+
const created = [];
|
|
31
|
+
const updated = [];
|
|
32
|
+
|
|
33
|
+
const members = await client.getFile(o, r, "members.json", BRANCH);
|
|
34
|
+
let owner = login;
|
|
35
|
+
let count = 1;
|
|
36
|
+
if (!members) {
|
|
37
|
+
await put("members.json", {
|
|
38
|
+
content: json({ schemaVersion: 1, team: name, owner: login, members: [login] }),
|
|
39
|
+
message: "techbro: initialize team members",
|
|
40
|
+
});
|
|
41
|
+
created.push("members.json");
|
|
42
|
+
} else {
|
|
43
|
+
const m = members.json;
|
|
44
|
+
if (!m || typeof m !== "object" || !Array.isArray(m.members) || typeof m.owner !== "string") {
|
|
45
|
+
throw new Error(`members.json in ${o}/${r} is malformed`);
|
|
46
|
+
}
|
|
47
|
+
owner = m.owner;
|
|
48
|
+
count = m.members.length;
|
|
49
|
+
if (!m.members.includes(login)) {
|
|
50
|
+
m.members.push(login);
|
|
51
|
+
count = m.members.length;
|
|
52
|
+
await put("members.json", { content: json(m), message: `techbro: add ${login} to team members`, sha: members.sha });
|
|
53
|
+
updated.push("members.json");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!(await client.getFile(o, r, "projects.json", BRANCH))) {
|
|
58
|
+
await put("projects.json", {
|
|
59
|
+
content: json({ schemaVersion: 1, projects: [] }),
|
|
60
|
+
message: "techbro: initialize team projects",
|
|
61
|
+
});
|
|
62
|
+
created.push("projects.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if ((await client.getFileText(o, r, WORKFLOW, BRANCH)) === null) {
|
|
66
|
+
await put(WORKFLOW, { content: teamWorkflowYaml(), message: "techbro: add team workflow" });
|
|
67
|
+
created.push(WORKFLOW);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const files = managedFiles({ owner: o, repo: r });
|
|
71
|
+
const ordered = [...files.filter((f) => f.path !== REVIEW_WORKFLOW), ...files.filter((f) => f.path === REVIEW_WORKFLOW)];
|
|
72
|
+
for (const f of ordered) {
|
|
73
|
+
if ((await client.getFileText(o, r, f.path, BRANCH)) === null) {
|
|
74
|
+
await put(f.path, { content: f.content, message: `techbro: add ${f.path}` });
|
|
75
|
+
created.push(f.path);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
config.teams[name] = { url: normalized, owner, addedAt: now.toISOString() };
|
|
80
|
+
config.currentTeam = name;
|
|
81
|
+
return { name, repo: `${o}/${r}`, owner, members: count, created, updated };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function joinTeam({ name, url, config, client, now = new Date(), force = false }) {
|
|
85
|
+
if (!/^[a-z0-9-]+$/.test(name ?? "")) throw new Error(`invalid team name "${name}" (use lowercase letters, digits, and hyphens)`);
|
|
86
|
+
if (config.teams[name] && !force) throw new Error(`team "${name}" already exists (use --force to overwrite)`);
|
|
87
|
+
const { owner: o, repo: r, url: normalized } = parseRepoUrl(url);
|
|
88
|
+
const { login } = await client.getUser();
|
|
89
|
+
const repo = await client.getRepo(o, r);
|
|
90
|
+
if (!repo.permissions?.pull) throw new Error(`no read access to ${o}/${r} as ${login}`);
|
|
91
|
+
|
|
92
|
+
const file = await client.getFile(o, r, "members.json", BRANCH);
|
|
93
|
+
if (!file) throw new Error("team repo is not initialized; run team create");
|
|
94
|
+
const m = structuredClone(file.json);
|
|
95
|
+
if (!m || typeof m !== "object" || !Array.isArray(m.members) || typeof m.owner !== "string") {
|
|
96
|
+
throw new Error(`members.json in ${o}/${r} is malformed`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let status = "member";
|
|
100
|
+
let issueUrl;
|
|
101
|
+
if (!m.members.includes(login)) {
|
|
102
|
+
if (repo.permissions.push) {
|
|
103
|
+
m.members.push(login);
|
|
104
|
+
try {
|
|
105
|
+
await client.putFile(o, r, "members.json", {
|
|
106
|
+
content: JSON.stringify(m, null, 2) + "\n",
|
|
107
|
+
message: `techbro: add ${login} to team members`,
|
|
108
|
+
sha: file.sha,
|
|
109
|
+
branch: BRANCH,
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.status === 409 || err.status === 422 || /protect/i.test(err.message)) throw new Error(`${err.message}\n${HINT}`);
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
status = "added";
|
|
116
|
+
} else {
|
|
117
|
+
if (!repo.hasIssues) throw new Error(`issues are disabled on ${o}/${r}; ask the owner to enable them or add you manually`);
|
|
118
|
+
const open = await client.listIssues(o, r, { labels: "techbro-join", state: "open", creator: login });
|
|
119
|
+
issueUrl = open[0]?.url;
|
|
120
|
+
if (!issueUrl) {
|
|
121
|
+
issueUrl = (
|
|
122
|
+
await client.createIssue(o, r, {
|
|
123
|
+
title: "techbro: join request",
|
|
124
|
+
body: JSON.stringify({ schemaVersion: 1, action: "join", login }),
|
|
125
|
+
labels: ["techbro-join"],
|
|
126
|
+
})
|
|
127
|
+
).url;
|
|
128
|
+
}
|
|
129
|
+
status = "pending";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
config.teams[name] = { url: normalized, owner: m.owner, addedAt: now.toISOString() };
|
|
134
|
+
config.currentTeam = name;
|
|
135
|
+
return { name, repo: `${o}/${r}`, login, status, ...(issueUrl && { issueUrl }) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function listTeams(config) {
|
|
139
|
+
return Object.entries(config.teams).map(([name, t]) => ({ name, url: t.url, owner: t.owner, current: config.currentTeam === name }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function useTeam(config, name) {
|
|
143
|
+
if (!config.teams[name]) throw new Error(`unknown team "${name}"`);
|
|
144
|
+
config.currentTeam = name;
|
|
145
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
|
|
7
|
+
export function templatesDir() {
|
|
8
|
+
const bundled = join(packageRoot, "templates");
|
|
9
|
+
if (existsSync(bundled)) return bundled;
|
|
10
|
+
const dev = resolve(packageRoot, ".project_templates");
|
|
11
|
+
if (existsSync(dev)) return dev;
|
|
12
|
+
throw new Error("no templates found (expected ./templates or ./.project_templates)");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function listTemplates() {
|
|
16
|
+
const dir = templatesDir();
|
|
17
|
+
return readdirSync(dir)
|
|
18
|
+
.filter((n) => statSync(join(dir, n)).isDirectory())
|
|
19
|
+
.sort();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function templatePath(name) {
|
|
23
|
+
const available = listTemplates();
|
|
24
|
+
const hint = `available templates: ${available.join(", ") || "(none)"}`;
|
|
25
|
+
if (!name) throw new Error(`missing template name; ${hint}`);
|
|
26
|
+
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
27
|
+
throw new Error(`invalid template name "${name}"; ${hint}`);
|
|
28
|
+
}
|
|
29
|
+
if (!available.includes(name)) {
|
|
30
|
+
throw new Error(`unknown template "${name}"; ${hint}`);
|
|
31
|
+
}
|
|
32
|
+
return join(templatesDir(), name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function describeTemplate(name) {
|
|
36
|
+
const dir = templatePath(name);
|
|
37
|
+
for (const file of ["TEMPLATE.md", "README.md"]) {
|
|
38
|
+
const p = join(dir, file);
|
|
39
|
+
if (!existsSync(p)) continue;
|
|
40
|
+
const m = readFileSync(p, "utf8").match(/^# (.+)$/m);
|
|
41
|
+
if (m) return m[1].trim();
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|