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.
Files changed (122) hide show
  1. package/README.md +453 -2
  2. package/assets/baseline-rules.yml +60 -0
  3. package/assets/caller-workflow.yml +26 -0
  4. package/assets/review-action.yml +27 -0
  5. package/assets/review-config.json +4 -0
  6. package/assets/review-workflow.yml +443 -0
  7. package/assets/review.mjs +721 -0
  8. package/bin/techbro.js +9 -0
  9. package/package.json +23 -4
  10. package/src/cli.js +690 -0
  11. package/src/cluster.js +134 -0
  12. package/src/config.js +35 -0
  13. package/src/doctor.js +125 -0
  14. package/src/github.js +152 -0
  15. package/src/init.js +167 -0
  16. package/src/kube-project.js +468 -0
  17. package/src/kube.js +317 -0
  18. package/src/managed.js +63 -0
  19. package/src/project.js +202 -0
  20. package/src/review.js +165 -0
  21. package/src/secret.js +80 -0
  22. package/src/setup.js +91 -0
  23. package/src/team-workflow.js +106 -0
  24. package/src/team.js +145 -0
  25. package/src/templates.js +44 -0
  26. package/src/upgrade.js +94 -0
  27. package/templates/_default/.agents/skills/README.md +36 -0
  28. package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
  29. package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
  30. package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
  31. package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
  32. package/templates/_default/.editorconfig +15 -0
  33. package/templates/_default/.env.example +9 -0
  34. package/templates/_default/.github/workflows/ci.yml +60 -0
  35. package/templates/_default/.nvmrc +1 -0
  36. package/templates/_default/Cargo.lock +1834 -0
  37. package/templates/_default/Cargo.toml +3 -0
  38. package/templates/_default/README.md +257 -0
  39. package/templates/_default/TEMPLATE.md +29 -0
  40. package/templates/_default/_dockerignore +8 -0
  41. package/templates/_default/_gitignore +8 -0
  42. package/templates/_default/apps/backend/Cargo.toml +22 -0
  43. package/templates/_default/apps/backend/Dockerfile +34 -0
  44. package/templates/_default/apps/backend/README.md +13 -0
  45. package/templates/_default/apps/backend/src/config.rs +111 -0
  46. package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
  47. package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
  48. package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
  49. package/templates/_default/apps/backend/src/error.rs +115 -0
  50. package/templates/_default/apps/backend/src/lib.rs +7 -0
  51. package/templates/_default/apps/backend/src/main.rs +71 -0
  52. package/templates/_default/apps/backend/src/models/item.rs +94 -0
  53. package/templates/_default/apps/backend/src/models/mod.rs +1 -0
  54. package/templates/_default/apps/backend/src/routes.rs +94 -0
  55. package/templates/_default/apps/backend/src/state.rs +12 -0
  56. package/templates/_default/apps/backend/src/views/items.rs +18 -0
  57. package/templates/_default/apps/backend/src/views/mod.rs +1 -0
  58. package/templates/_default/apps/backend/tests/api.rs +234 -0
  59. package/templates/_default/apps/frontend/.env.example +2 -0
  60. package/templates/_default/apps/frontend/.prettierignore +3 -0
  61. package/templates/_default/apps/frontend/.prettierrc +1 -0
  62. package/templates/_default/apps/frontend/Dockerfile +15 -0
  63. package/templates/_default/apps/frontend/README.md +39 -0
  64. package/templates/_default/apps/frontend/_dockerignore +3 -0
  65. package/templates/_default/apps/frontend/_gitignore +24 -0
  66. package/templates/_default/apps/frontend/components.json +25 -0
  67. package/templates/_default/apps/frontend/eslint.config.js +37 -0
  68. package/templates/_default/apps/frontend/index.html +13 -0
  69. package/templates/_default/apps/frontend/jsconfig.json +7 -0
  70. package/templates/_default/apps/frontend/nginx.conf +30 -0
  71. package/templates/_default/apps/frontend/package-lock.json +4338 -0
  72. package/templates/_default/apps/frontend/package.json +49 -0
  73. package/templates/_default/apps/frontend/public/favicon.svg +1 -0
  74. package/templates/_default/apps/frontend/src/App.jsx +11 -0
  75. package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
  76. package/templates/_default/apps/frontend/src/api/client.js +50 -0
  77. package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
  78. package/templates/_default/apps/frontend/src/api/items.js +26 -0
  79. package/templates/_default/apps/frontend/src/api/system.js +11 -0
  80. package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
  81. package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
  82. package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
  83. package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
  84. package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
  85. package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
  86. package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
  87. package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
  88. package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
  89. package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
  90. package/templates/_default/apps/frontend/src/index.css +130 -0
  91. package/templates/_default/apps/frontend/src/lib/config.js +2 -0
  92. package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
  93. package/templates/_default/apps/frontend/src/main.jsx +11 -0
  94. package/templates/_default/apps/frontend/src/models/items.js +24 -0
  95. package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
  96. package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
  97. package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
  98. package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
  99. package/templates/_default/apps/frontend/src/routes.jsx +17 -0
  100. package/templates/_default/apps/frontend/src/shadcn.css +655 -0
  101. package/templates/_default/apps/frontend/src/test/setup.js +1 -0
  102. package/templates/_default/apps/frontend/vite.config.js +25 -0
  103. package/templates/_default/database/Dockerfile +9 -0
  104. package/templates/_default/database/README.md +15 -0
  105. package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
  106. package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
  107. package/templates/_default/docker-compose.prod.yml +53 -0
  108. package/templates/_default/docker-compose.yml +69 -0
  109. package/templates/_default/docs/api/README.md +41 -0
  110. package/templates/_default/docs/api/openapi.yaml +152 -0
  111. package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
  112. package/templates/_default/docs/decisions/README.md +6 -0
  113. package/templates/_default/docs/deployment.md +46 -0
  114. package/templates/_default/docs/docker.md +63 -0
  115. package/templates/_default/docs/getting-started.md +214 -0
  116. package/templates/_default/package.json +15 -0
  117. package/templates/_default/rust-toolchain.toml +3 -0
  118. package/templates/_default/scripts/check +10 -0
  119. package/templates/_default/scripts/db-migrate +25 -0
  120. package/templates/_default/scripts/install-hooks +5 -0
  121. package/templates/_default/scripts/pre-push +3 -0
  122. package/templates/_default/scripts/setup +26 -0
package/src/cluster.js ADDED
@@ -0,0 +1,134 @@
1
+ // Team-shared clusters: the team owner publishes a cluster profile plus an
2
+ // encrypted, minified kubeconfig to the team repo; members decrypt it locally.
3
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { configPath } from "./config.js";
6
+ import { parseRepoUrl } from "./github.js";
7
+ import { defaultRun, validateName, validateProfile } from "./kube.js";
8
+ import { decryptSecret, encryptSecret } from "./secret.js";
9
+
10
+ const BRANCH = "main";
11
+ const DIR = "clusters";
12
+ const profilePath = (name) => `${DIR}/${name}.json`;
13
+ const credPath = (name) => `${DIR}/${name}.kubeconfig.enc`;
14
+ export const kubeconfigDir = () => join(dirname(configPath()), "kube");
15
+ export const kubeconfigFile = (name) => join(kubeconfigDir(), `${name}.kubeconfig`);
16
+
17
+ function teamRepo(config, teamName) {
18
+ const name = teamName ?? config.currentTeam;
19
+ if (!name) throw new Error("no current team: run techbro team create|join first, or pass --team");
20
+ const team = config.teams?.[name];
21
+ if (!team) throw new Error(`unknown team "${name}"`);
22
+ return { team: name, ...parseRepoUrl(team.url) };
23
+ }
24
+
25
+ async function requireOwner(client, o, r) {
26
+ const { login } = await client.getUser();
27
+ const members = await client.getFile(o, r, "members.json", BRANCH);
28
+ if (!members) throw new Error("team repo is not initialized; run team create");
29
+ if (members.json?.owner !== login) throw new Error(`only the team owner (${members.json?.owner}) can change shared clusters; you are ${login}`);
30
+ const repo = await client.getRepo(o, r);
31
+ if (!repo.permissions?.push) throw new Error(`no write access to ${o}/${r} as ${login}`);
32
+ return login;
33
+ }
34
+
35
+ // Extracts only the named context, with certificates inlined, so the owner's other contexts never leave their machine.
36
+ export async function exportKubeconfig({ kubeconfig, context, run = defaultRun }) {
37
+ const res = await run("kubectl", ["config", "view", "--kubeconfig", kubeconfig, "--context", context, "--minify", "--flatten", "-o", "json"], { timeout: 15000 });
38
+ if (res.code !== 0) throw new Error(`could not read context ${context} from ${kubeconfig} (kubectl exit ${res.code})`);
39
+ let kc;
40
+ try {
41
+ kc = JSON.parse(String(res.stdout));
42
+ } catch {
43
+ throw new Error("kubectl config view did not return JSON");
44
+ }
45
+ if (kc?.contexts?.length !== 1 || kc.contexts[0].name !== context) throw new Error(`kubeconfig does not contain exactly the context ${context}`);
46
+ const user = kc.users?.[0]?.user ?? {};
47
+ const warnings = [];
48
+ if (user.exec || user["auth-provider"]) warnings.push("this context authenticates through an external plugin; members also need that plugin and its own login");
49
+ return { text: JSON.stringify(kc), namespace: kc.contexts[0].context?.namespace || null, warnings };
50
+ }
51
+
52
+ export async function addSharedCluster({ config, client, name, kubeconfig, context, namespace, profile = {}, passphrase, teamName, force = false, dryRun = false, now = new Date(), run = defaultRun }) {
53
+ validateName(name, "cluster name");
54
+ const { team, owner: o, repo: r } = teamRepo(config, teamName);
55
+ const login = await requireOwner(client, o, r);
56
+ const exported = await exportKubeconfig({ kubeconfig, context, run });
57
+ const clean = validateProfile({ ...profile, context, ...((namespace ?? exported.namespace) && { namespace: namespace ?? exported.namespace }) });
58
+ const existing = await client.getFile(o, r, profilePath(name), BRANCH);
59
+ if (existing && !force) throw new Error(`shared cluster "${name}" already exists in ${o}/${r} (use --force to replace or rotate)`);
60
+ const files = [profilePath(name), credPath(name)];
61
+ const summary = { team, repo: `${o}/${r}`, name, context, namespace: clean.namespace ?? null, files, warnings: exported.warnings, replaced: Boolean(existing) };
62
+ if (dryRun) return { ...summary, dryRun: true };
63
+ const envelope = encryptSecret(exported.text, passphrase);
64
+ const doc = { schemaVersion: 1, name, profile: clean, credential: credPath(name), updatedBy: login, updatedAt: now.toISOString() };
65
+ const cred = await client.getFile(o, r, credPath(name), BRANCH);
66
+ await client.putFile(o, r, credPath(name), { content: envelope, message: `techbro: ${existing ? "rotate" : "add"} cluster ${name} credential`, sha: cred?.sha, branch: BRANCH });
67
+ await client.putFile(o, r, profilePath(name), { content: JSON.stringify(doc, null, 2) + "\n", message: `techbro: ${existing ? "update" : "add"} cluster ${name}`, sha: existing?.sha, branch: BRANCH });
68
+ return { ...summary, dryRun: false };
69
+ }
70
+
71
+ export async function listSharedClusters({ config, client, teamName }) {
72
+ const { team, owner: o, repo: r } = teamRepo(config, teamName);
73
+ const entries = await client.listDir(o, r, DIR, BRANCH);
74
+ const names = entries.filter((e) => e.type === "file" && /^[a-z0-9-]+\.json$/.test(e.name)).map((e) => e.name.slice(0, -5));
75
+ const local = config.kube?.shared ?? {};
76
+ return { team, repo: `${o}/${r}`, clusters: names.map((name) => ({ name, installed: local[name]?.team === team })) };
77
+ }
78
+
79
+ function writePrivate(path, content) {
80
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
81
+ const tmp = `${path}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, content, { mode: 0o600 });
83
+ chmodSync(tmp, 0o600);
84
+ renameSync(tmp, path);
85
+ }
86
+
87
+ // Returns a new config with the shared cluster installed and selected.
88
+ export async function useSharedCluster({ config, client, name, passphrase, teamName, now = new Date() }) {
89
+ validateName(name, "cluster name");
90
+ if (config.kube?.clusters?.[name]) throw new Error(`a local cluster named "${name}" already exists; remove it from your config first`);
91
+ const { team, owner: o, repo: r } = teamRepo(config, teamName);
92
+ const doc = await client.getFile(o, r, profilePath(name), BRANCH);
93
+ if (!doc) throw new Error(`no shared cluster "${name}" in ${o}/${r}`);
94
+ if (doc.json?.schemaVersion !== 1 || doc.json.credential !== credPath(name)) throw new Error(`${profilePath(name)} in ${o}/${r} is malformed`);
95
+ const profile = validateProfile(doc.json.profile);
96
+ const enc = await client.getFile(o, r, credPath(name), BRANCH);
97
+ if (!enc) throw new Error(`credential ${credPath(name)} is missing in ${o}/${r}`);
98
+ const text = decryptSecret(enc.json, passphrase);
99
+ const kubeconfig = kubeconfigFile(name);
100
+ writePrivate(kubeconfig, text);
101
+ const kube = config.kube ?? {};
102
+ const next = {
103
+ ...config,
104
+ kube: { ...kube, current: name, shared: { ...(kube.shared ?? {}), [name]: { team, profile, kubeconfig, installedAt: now.toISOString() } } },
105
+ };
106
+ return { config: next, team, repo: `${o}/${r}`, name, context: profile.context, namespace: profile.namespace ?? null, kubeconfig };
107
+ }
108
+
109
+ export async function removeSharedCluster({ config, client, name, teamName }) {
110
+ validateName(name, "cluster name");
111
+ const { team, owner: o, repo: r } = teamRepo(config, teamName);
112
+ await requireOwner(client, o, r);
113
+ const removed = [];
114
+ for (const p of [credPath(name), profilePath(name)]) {
115
+ const f = await client.getFile(o, r, p, BRANCH);
116
+ if (f) {
117
+ await client.deleteFile(o, r, p, { message: `techbro: remove cluster ${name}`, sha: f.sha, branch: BRANCH });
118
+ removed.push(p);
119
+ }
120
+ }
121
+ if (!removed.length) throw new Error(`no shared cluster "${name}" in ${o}/${r}`);
122
+ return { team, repo: `${o}/${r}`, removed, config: forgetSharedCluster(config, name) };
123
+ }
124
+
125
+ // Drops the local install (config entry + decrypted kubeconfig) only.
126
+ export function forgetSharedCluster(config, name) {
127
+ const shared = { ...(config.kube?.shared ?? {}) };
128
+ if (!shared[name]) return config;
129
+ rmSync(shared[name].kubeconfig, { force: true });
130
+ delete shared[name];
131
+ const kube = { ...config.kube, shared };
132
+ if (kube.current === name) kube.current = null;
133
+ return { ...config, kube };
134
+ }
package/src/config.js ADDED
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const configPath = () =>
6
+ join(process.env.TECHBRO_CONFIG_DIR ?? join(homedir(), ".config", "techbro"), "config.json");
7
+
8
+ export function loadConfig() {
9
+ const path = configPath();
10
+ let raw;
11
+ try {
12
+ raw = readFileSync(path, "utf8");
13
+ } catch (err) {
14
+ if (err.code === "ENOENT") return { schemaVersion: 1, currentTeam: null, teams: {} };
15
+ throw err;
16
+ }
17
+ let cfg;
18
+ try {
19
+ cfg = JSON.parse(raw);
20
+ } catch {
21
+ throw new Error(`malformed config file ${path}: not valid JSON`);
22
+ }
23
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg) || typeof cfg.teams !== "object" || cfg.teams === null) {
24
+ throw new Error(`malformed config file ${path}: unexpected shape`);
25
+ }
26
+ return cfg;
27
+ }
28
+
29
+ export function saveConfig(cfg) {
30
+ const path = configPath();
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ const tmp = `${path}.${process.pid}.tmp`;
33
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
34
+ renameSync(tmp, path);
35
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,125 @@
1
+ // Read-only health check of local setup. Never prints secret values.
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { listClusters, defaultRun, preflight, resolveCluster } from "./kube.js";
6
+ import { SECRET_NAME } from "./kube-project.js";
7
+ import { REVIEW_WORKFLOW } from "./managed.js";
8
+ import { parseRepoUrl } from "./github.js";
9
+ import { MIN_PASSPHRASE, PASSPHRASE_ENV } from "./secret.js";
10
+
11
+ const ok = (area, name, detail = "") => ({ area, name, status: "ok", detail });
12
+ const warn = (area, name, detail, fix) => ({ area, name, status: "warn", detail, fix });
13
+ const fail = (area, name, detail, fix) => ({ area, name, status: "fail", detail, fix });
14
+
15
+ async function tool(run, file, args) {
16
+ try {
17
+ const r = await run(file, args, { timeout: 10000 });
18
+ return r.code === 0 ? String(r.stdout ?? "").trim().split("\n")[0] : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export async function doctor({ env = process.env, cwd = process.cwd(), run = defaultRun, load = loadConfig, online = false, client, nodeVersion = process.versions.node } = {}) {
25
+ const checks = [];
26
+ const add = (c) => checks.push(c);
27
+
28
+ const major = Number(nodeVersion.split(".")[0]);
29
+ add(major >= 20 ? ok("env", "node", nodeVersion) : fail("env", "node", `${nodeVersion} is older than 20`, "install Node 20 or newer"));
30
+ const git = await tool(run, "git", ["--version"]);
31
+ add(git ? ok("env", "git", git) : fail("env", "git", "not found", "install git"));
32
+ const kubectl = await tool(run, "kubectl", ["version", "--client"]);
33
+ add(kubectl ? ok("env", "kubectl", kubectl) : warn("env", "kubectl", "not found; kube and cluster commands need it", "install kubectl"));
34
+ const gh = env.GITHUB_TOKEN ? "GITHUB_TOKEN is set" : (await tool(run, "gh", ["auth", "token"])) ? "gh is logged in" : null;
35
+ add(gh ? ok("secrets", "github token", gh) : fail("secrets", "github token", "missing; team, project and cluster commands need it", "gh auth login (or set GITHUB_TOKEN)"));
36
+
37
+ const pass = env[PASSPHRASE_ENV];
38
+ if (!pass) add(warn("secrets", PASSPHRASE_ENV, "not set; cluster add/use will prompt or need --passphrase-file", `export ${PASSPHRASE_ENV}=... (ask your team owner)`));
39
+ else if (pass.length < MIN_PASSPHRASE) add(fail("secrets", PASSPHRASE_ENV, `set but shorter than ${MIN_PASSPHRASE} characters`, "use the team passphrase"));
40
+ else add(ok("secrets", PASSPHRASE_ENV, "set"));
41
+
42
+ let config = null;
43
+ try {
44
+ config = load();
45
+ add(ok("config", "config file", "parsed"));
46
+ } catch (e) {
47
+ add(fail("config", "config file", e.message, "fix or remove the techbro config file"));
48
+ }
49
+
50
+ let team = null;
51
+ if (config) {
52
+ const name = config.currentTeam;
53
+ if (!name) add(warn("config", "team", "no current team", "techbro team join <name> --url <repo>"));
54
+ else if (!config.teams?.[name]) add(fail("config", "team", `current team "${name}" is not configured`, "techbro team use <name>"));
55
+ else {
56
+ team = { name, ...config.teams[name] };
57
+ add(ok("config", "team", `${name} (${team.url})`));
58
+ }
59
+
60
+ let clusters = null;
61
+ try {
62
+ clusters = listClusters(config);
63
+ } catch (e) {
64
+ add(fail("cluster", "cluster config", e.message, "fix config.kube in the techbro config file"));
65
+ }
66
+ if (clusters) {
67
+ const cur = clusters.current;
68
+ if (!cur) add(warn("cluster", "current cluster", "none selected; kube commands need one", "techbro cluster use <name> (or techbro kube cluster add/use)"));
69
+ else add(clusters.clusters.some((c) => c.name === cur) ? ok("cluster", "current cluster", cur) : fail("cluster", "current cluster", `"${cur}" is not configured`, "techbro cluster use <name>"));
70
+ }
71
+ for (const [name, s] of Object.entries(config.kube?.shared ?? {})) {
72
+ const label = `cluster ${name} kubeconfig`;
73
+ if (!s?.kubeconfig || !existsSync(s.kubeconfig)) {
74
+ add(fail("cluster", label, "missing", `techbro cluster use ${name}`));
75
+ continue;
76
+ }
77
+ const mode = statSync(s.kubeconfig).mode & 0o777;
78
+ if (mode & 0o077) add(fail("cluster", label, `permissions ${mode.toString(8)} allow other users to read credentials`, `chmod 600 ${s.kubeconfig}`));
79
+ else add(ok("cluster", label, "present, mode 600"));
80
+ }
81
+ }
82
+
83
+ const metaPath = join(cwd, ".techbro.json");
84
+ if (existsSync(metaPath)) {
85
+ try {
86
+ JSON.parse(readFileSync(metaPath, "utf8"));
87
+ add(ok("project", ".techbro.json", "parsed"));
88
+ } catch {
89
+ add(fail("project", ".techbro.json", "not valid JSON", "fix .techbro.json"));
90
+ }
91
+ add(existsSync(join(cwd, REVIEW_WORKFLOW)) ? ok("project", "review workflow", REVIEW_WORKFLOW) : warn("project", "review workflow", `${REVIEW_WORKFLOW} missing`, "techbro project link"));
92
+ }
93
+ const tracked = await run("git", ["-C", cwd, "ls-files", "-z"], { timeout: 10000 }).catch(() => null);
94
+ if (tracked?.code === 0) {
95
+ const bad = String(tracked.stdout).split("\0").filter((f) => f && SECRET_NAME.test(f) && !/(^|\/)\.env\.example$/.test(f));
96
+ add(bad.length ? fail("project", "tracked secrets", `${bad.length} secret-like file(s): ${bad.slice(0, 5).join(", ")}`, "git rm --cached <file> and rotate the secret") : ok("project", "tracked secrets", "none"));
97
+ }
98
+
99
+ if (online) {
100
+ if (client && gh) {
101
+ try {
102
+ const { login } = await client.getUser();
103
+ add(ok("online", "github login", login));
104
+ if (team) {
105
+ const { owner, repo } = parseRepoUrl(team.url);
106
+ const r = await client.getRepo(owner, repo);
107
+ add(r.permissions?.pull ? ok("online", "team repo", `${owner}/${repo}${r.permissions.push ? " (write)" : " (read)"}`) : fail("online", "team repo", "no read access", "ask the team owner for access"));
108
+ }
109
+ } catch (e) {
110
+ add(fail("online", "github", e.message, "check your token and network"));
111
+ }
112
+ }
113
+ if (config && kubectl && config.kube?.current) {
114
+ try {
115
+ const profile = await resolveCluster({ config, run });
116
+ const r = await preflight({ profile, run });
117
+ for (const c of r.checks) add(c.ok ? ok("online", `kube ${c.name}`, profile.name) : fail("online", `kube ${c.name}`, c.detail, `techbro kube check --cluster ${profile.name}`));
118
+ } catch (e) {
119
+ add(fail("online", "kube", e.message, "techbro cluster use <name> or pass --namespace via kube check"));
120
+ }
121
+ }
122
+ }
123
+
124
+ return { ok: checks.every((c) => c.status !== "fail"), checks };
125
+ }
package/src/github.js ADDED
@@ -0,0 +1,152 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export function parseRepoUrl(url) {
4
+ const m =
5
+ /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(String(url).trim()) ??
6
+ /^git@github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/.exec(String(url).trim());
7
+ if (!m) throw new Error(`not a GitHub repository URL: "${url}" (expected https://github.com/<owner>/<repo>)`);
8
+ return { owner: m[1], repo: m[2], url: `https://github.com/${m[1]}/${m[2]}` };
9
+ }
10
+
11
+ export function resolveToken() {
12
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
13
+ try {
14
+ const t = execFileSync("gh", ["auth", "token"], { stdio: "pipe", encoding: "utf8" }).trim();
15
+ if (t) return t;
16
+ } catch {
17
+ // fall through
18
+ }
19
+ throw new Error("no GitHub token: set GITHUB_TOKEN or run gh auth login");
20
+ }
21
+
22
+ export function createClient({ token, fetch: f = globalThis.fetch }) {
23
+ const headers = {
24
+ Authorization: `Bearer ${token}`,
25
+ Accept: "application/vnd.github+json",
26
+ "X-GitHub-Api-Version": "2022-11-28",
27
+ "User-Agent": "techbro",
28
+ };
29
+ async function call(method, path, body, { allow404 = false, raw = false } = {}) {
30
+ const h = { ...headers };
31
+ if (raw) h.Accept = "application/vnd.github.raw+json";
32
+ if (body) h["Content-Type"] = "application/json";
33
+ const res = await f(`https://api.github.com${path}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
34
+ if (allow404 && res.status === 404) return null;
35
+ if (raw && res.ok) return res.text();
36
+ if (res.status === 204) return {};
37
+ const data = await res.json().catch(() => ({}));
38
+ if (!res.ok) {
39
+ throw Object.assign(new Error(`GitHub ${res.status}: ${data.message ?? "request failed"}`), { status: res.status });
40
+ }
41
+ return data;
42
+ }
43
+ const repoPath = (o, r) => `/repos/${o}/${r}`;
44
+ const filePath = (o, r, p) => `${repoPath(o, r)}/contents/${p.split("/").map(encodeURIComponent).join("/")}`;
45
+ return {
46
+ async getUser() {
47
+ return { login: (await call("GET", "/user")).login };
48
+ },
49
+ async getRepo(o, r) {
50
+ const d = await call("GET", repoPath(o, r));
51
+ return {
52
+ defaultBranch: d.default_branch,
53
+ hasIssues: d.has_issues,
54
+ private: d.private === true,
55
+ permissions: d.permissions ?? {},
56
+ };
57
+ },
58
+ async listIssues(o, r, { labels, state = "open", creator } = {}) {
59
+ const q = new URLSearchParams({ state });
60
+ if (labels) q.set("labels", labels);
61
+ if (creator) q.set("creator", creator);
62
+ const d = await call("GET", `${repoPath(o, r)}/issues?${q}`);
63
+ return d.filter((i) => !i.pull_request).map((i) => ({ number: i.number, url: i.html_url }));
64
+ },
65
+ async createIssue(o, r, { title, body, labels }) {
66
+ const d = await call("POST", `${repoPath(o, r)}/issues`, { title, body, labels });
67
+ return { number: d.number, url: d.html_url };
68
+ },
69
+ async getFile(o, r, path, ref) {
70
+ const d = await call("GET", `${filePath(o, r, path)}?ref=${encodeURIComponent(ref)}`, undefined, { allow404: true });
71
+ if (!d) return null;
72
+ let json;
73
+ try {
74
+ json = JSON.parse(Buffer.from(d.content ?? "", "base64").toString("utf8"));
75
+ } catch {
76
+ throw new Error(`${path} in ${o}/${r} is not valid JSON`);
77
+ }
78
+ return { sha: d.sha, json };
79
+ },
80
+ async getFileText(o, r, path, ref) {
81
+ return call("GET", `${filePath(o, r, path)}?ref=${encodeURIComponent(ref)}`, undefined, { allow404: true, raw: true });
82
+ },
83
+ async getBranchSha(o, r, branch) {
84
+ const d = await call("GET", `${repoPath(o, r)}/git/ref/heads/${branch.split("/").map(encodeURIComponent).join("/")}`, undefined, { allow404: true });
85
+ return d ? d.object.sha : null;
86
+ },
87
+ async getCommitSha(o, r, ref) {
88
+ const d = await call("GET", `${repoPath(o, r)}/commits/${encodeURIComponent(ref)}`, undefined, { allow404: true });
89
+ return d ? d.sha : null;
90
+ },
91
+ async createRef(o, r, branch, sha) {
92
+ await call("POST", `${repoPath(o, r)}/git/refs`, { ref: `refs/heads/${branch}`, sha });
93
+ },
94
+ async updateRef(o, r, branch, sha) {
95
+ await call("PATCH", `${repoPath(o, r)}/git/refs/heads/${branch.split("/").map(encodeURIComponent).join("/")}`, { sha, force: false });
96
+ },
97
+ async commitFiles(o, r, { baseSha, files, message }) {
98
+ const base = await call("GET", `${repoPath(o, r)}/git/commits/${baseSha}`);
99
+ const tree = [];
100
+ for (const file of files) {
101
+ const blob = await call("POST", `${repoPath(o, r)}/git/blobs`, { content: file.content, encoding: "utf-8" });
102
+ tree.push({ path: file.path, mode: "100644", type: "blob", sha: blob.sha });
103
+ }
104
+ const t = await call("POST", `${repoPath(o, r)}/git/trees`, { base_tree: base.tree.sha, tree });
105
+ const c = await call("POST", `${repoPath(o, r)}/git/commits`, { message, tree: t.sha, parents: [baseSha] });
106
+ return c.sha;
107
+ },
108
+ async listPulls(o, r, { head, base }) {
109
+ const q = new URLSearchParams({ state: "open", head: `${o}:${head}`, base });
110
+ const d = await call("GET", `${repoPath(o, r)}/pulls?${q}`);
111
+ return d.map((p) => ({ number: p.number, url: p.html_url }));
112
+ },
113
+ async createPull(o, r, { title, body, head, base }) {
114
+ const d = await call("POST", `${repoPath(o, r)}/pulls`, { title, body, head, base });
115
+ return { number: d.number, url: d.html_url };
116
+ },
117
+ async dispatchWorkflow(o, r, file, ref, inputs) {
118
+ await call("POST", `${repoPath(o, r)}/actions/workflows/${encodeURIComponent(file)}/dispatches`, { ref, inputs });
119
+ },
120
+ async listWorkflowRuns(o, r, file, { branch }) {
121
+ const q = new URLSearchParams({ event: "workflow_dispatch", branch, per_page: "30" });
122
+ const d = await call("GET", `${repoPath(o, r)}/actions/workflows/${encodeURIComponent(file)}/runs?${q}`);
123
+ return (d.workflow_runs ?? []).map((x) => ({ id: x.id, url: x.html_url, title: x.display_title, status: x.status, conclusion: x.conclusion }));
124
+ },
125
+ async getRun(o, r, id) {
126
+ const x = await call("GET", `${repoPath(o, r)}/actions/runs/${id}`);
127
+ return { id: x.id, url: x.html_url, status: x.status, conclusion: x.conclusion };
128
+ },
129
+ async listRunArtifacts(o, r, id) {
130
+ const d = await call("GET", `${repoPath(o, r)}/actions/runs/${id}/artifacts`);
131
+ return (d.artifacts ?? []).map((a) => ({ id: a.id, name: a.name, expired: a.expired }));
132
+ },
133
+ async getActionsAccess(o, r) {
134
+ const d = await call("GET", `${repoPath(o, r)}/actions/permissions/access`, undefined, { allow404: true });
135
+ return d ? d.access_level : null;
136
+ },
137
+ async putFile(o, r, path, { content, message, sha, branch }) {
138
+ const body = { message, content: Buffer.from(content).toString("base64"), branch };
139
+ if (sha) body.sha = sha;
140
+ await call("PUT", filePath(o, r, path), body);
141
+ },
142
+ async listDir(o, r, path, ref) {
143
+ const d = await call("GET", `${filePath(o, r, path)}?ref=${encodeURIComponent(ref)}`, undefined, { allow404: true });
144
+ if (!d) return [];
145
+ if (!Array.isArray(d)) throw new Error(`${path} in ${o}/${r} is not a directory`);
146
+ return d.map((e) => ({ name: e.name, path: e.path, type: e.type, sha: e.sha }));
147
+ },
148
+ async deleteFile(o, r, path, { message, sha, branch }) {
149
+ await call("DELETE", filePath(o, r, path), { message, sha, branch });
150
+ },
151
+ };
152
+ }
package/src/init.js ADDED
@@ -0,0 +1,167 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { basename, join, relative, resolve } from "node:path";
13
+ import { packageRoot, templatePath } from "./templates.js";
14
+
15
+ const EXCLUDED_NAMES = new Set([
16
+ "node_modules",
17
+ "target",
18
+ "dist",
19
+ ".env",
20
+ "local.db",
21
+ ".DS_Store",
22
+ "coverage",
23
+ ]);
24
+
25
+ export function isExcluded(name) {
26
+ return EXCLUDED_NAMES.has(name) || name.endsWith(".db") || name.endsWith(".sqlite");
27
+ }
28
+
29
+ export function slugify(s) {
30
+ return s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
31
+ }
32
+
33
+ const RENAMES = { _gitignore: ".gitignore", _dockerignore: ".dockerignore" };
34
+
35
+ function walk(dir, fn) {
36
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
37
+ const p = join(dir, entry.name);
38
+ if (entry.isDirectory()) walk(p, fn);
39
+ else fn(p, entry.name);
40
+ }
41
+ }
42
+
43
+ function edit(file, fn) {
44
+ if (!existsSync(file)) return;
45
+ const before = readFileSync(file, "utf8");
46
+ const after = fn(before);
47
+ if (after !== before) writeFileSync(file, after);
48
+ }
49
+
50
+ /**
51
+ * Runs `git init -b main` in `target` unless it is already inside a repository.
52
+ * @returns {"initialized" | "existing" | "skipped"} outcome; "skipped" when git is unavailable
53
+ */
54
+ export function initGit(target) {
55
+ const run = (args, opts = {}) =>
56
+ execFileSync("git", args, { cwd: target, stdio: ["ignore", "pipe", "ignore"], ...opts });
57
+ try {
58
+ run(["--version"]);
59
+ } catch {
60
+ return "skipped";
61
+ }
62
+ try {
63
+ if (run(["rev-parse", "--is-inside-work-tree"]).toString().trim() === "true") return "existing";
64
+ } catch {
65
+ // not a repository yet
66
+ }
67
+ run(["init", "-q", "-b", "main"]);
68
+ return "initialized";
69
+ }
70
+
71
+ export function initProject({ template = "_default", dir, name, force = false, git = true }) {
72
+ const src = templatePath(template);
73
+ const target = resolve(dir ?? ".");
74
+
75
+ if (existsSync(target)) {
76
+ if (!statSync(target).isDirectory()) throw new Error(`${target} is not a directory`);
77
+ if (readdirSync(target).length > 0 && !force) {
78
+ throw new Error("directory is not empty (use --force to overwrite)");
79
+ }
80
+ }
81
+ const metadataPath = join(target, ".techbro.json");
82
+ const hasMetadata = existsSync(metadataPath);
83
+ if (hasMetadata) {
84
+ try {
85
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
86
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
87
+ throw new Error("expected a JSON object");
88
+ }
89
+ } catch (err) {
90
+ throw new Error(`invalid .techbro.json: ${err.message}`, { cause: err });
91
+ }
92
+ }
93
+ mkdirSync(target, { recursive: true });
94
+
95
+ cpSync(src, target, {
96
+ recursive: true,
97
+ force: true,
98
+ preserveTimestamps: true,
99
+ filter: (s) => s !== join(src, ".techbro.json") && !isExcluded(basename(s)),
100
+ });
101
+
102
+ let count = 0;
103
+ walk(target, (p, base) => {
104
+ count++;
105
+ if (RENAMES[base]) renameSync(p, join(p, "..", RENAMES[base]));
106
+ });
107
+
108
+ const appName = slugify(name ?? basename(target));
109
+ if (!hasMetadata) {
110
+ const techbroVersion = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
111
+ writeFileSync(metadataPath, JSON.stringify({
112
+ schemaVersion: 1,
113
+ name: appName,
114
+ template,
115
+ techbroVersion,
116
+ config: {},
117
+ }, null, 2) + "\n");
118
+ count++;
119
+ }
120
+ edit(join(target, "package.json"), (t) =>
121
+ t.replace(/("name"\s*:\s*)"project-template"/, `$1"${appName}"`),
122
+ );
123
+ edit(join(target, ".env.example"), (t) => t.replace(/^APP_NAME=my-app$/m, `APP_NAME=${appName}`));
124
+ edit(join(target, "apps/frontend/.env.example"), (t) =>
125
+ t.replace(/^VITE_APP_NAME=.*$/m, `VITE_APP_NAME=${appName}`),
126
+ );
127
+
128
+ const gitStatus = git ? initGit(target) : "disabled";
129
+ return { template, target, appName, files: count, git: gitStatus };
130
+ }
131
+
132
+ const GIT_LINES = {
133
+ initialized: "Git: initialized (branch main)",
134
+ existing: "Git: existing repository, left as is",
135
+ skipped: "Git: not initialized (git not found)",
136
+ disabled: "Git: not initialized (--no-git)",
137
+ };
138
+
139
+ export function printSummary(r, dirArg) {
140
+ const rel = relative(process.cwd(), r.target) || ".";
141
+ const lines = [
142
+ `Template: ${r.template}`,
143
+ `Target: ${r.target}`,
144
+ `App name: ${r.appName}`,
145
+ `Files: ${r.files}`,
146
+ GIT_LINES[r.git] ?? "",
147
+ "",
148
+ "Next steps:",
149
+ ];
150
+ if (rel !== ".") lines.push(` cd ${dirArg ?? rel}`);
151
+ if (r.setup) {
152
+ lines.push(
153
+ " cargo watch -x 'run -p backend' # http://localhost:8080",
154
+ " npm run dev:frontend # http://localhost:5173 (second terminal)",
155
+ );
156
+ } else {
157
+ lines.push(
158
+ " scripts/setup",
159
+ " scripts/db-migrate sqlite",
160
+ " cargo watch -x 'run -p backend'",
161
+ " npm run dev:frontend",
162
+ );
163
+ }
164
+ if (r.git === "initialized") lines.push(" git add -A && git commit -m 'Initial commit'");
165
+ lines.push("", "See docs/getting-started.md");
166
+ return lines.join("\n");
167
+ }