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/cli.js ADDED
@@ -0,0 +1,690 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { loadConfig, saveConfig } from "./config.js";
5
+ import { createClient, resolveToken } from "./github.js";
6
+ import { createTeam, joinTeam, listTeams, useTeam } from "./team.js";
7
+ import { linkProject } from "./project.js";
8
+ import { reviewProject } from "./review.js";
9
+ import { upgradeTeam } from "./upgrade.js";
10
+ import { initProject, printSummary } from "./init.js";
11
+ import { setupProject } from "./setup.js";
12
+ import { addCluster, defaultRun, listClusters, LOGS_WARNING, OPERATIONS, performKube, preflight, resolveCluster, useCluster, validateName } from "./kube.js";
13
+ import { buildProject, deployProject, generateManifest } from "./kube-project.js";
14
+ import { describeTemplate, listTemplates, packageRoot } from "./templates.js";
15
+ import { addSharedCluster, listSharedClusters, removeSharedCluster, useSharedCluster } from "./cluster.js";
16
+ import { doctor } from "./doctor.js";
17
+ import { PASSPHRASE_ENV, resolvePassphrase } from "./secret.js";
18
+
19
+ const USAGE = `Usage:
20
+ techbro project init [dir] [--template <name>] [--name <app-name>] [--force] [--no-git] [--skip-setup] [--skip-build]
21
+ techbro project link [--url <github-repo-url>] [--name <name>]
22
+ techbro project list
23
+ techbro project review [--url <repo>] [--ref <ref>] [--team <name>] [--project <name>] [--wait] [--dry-run]
24
+ techbro team create <name> --url <github-repo-url> [--force]
25
+ techbro team join <name> --url <github-repo-url> [--force]
26
+ techbro team list
27
+ techbro team use <name>
28
+ techbro team upgrade [--team <name>] [--dry-run]
29
+ techbro cluster add|list|use|remove ... (team-shared clusters)
30
+ techbro doctor [--online] [--json]
31
+ techbro kube cluster list|add|use ...
32
+ techbro kube check|status|logs|restart|rollback|rollout-status [--cluster <name>] [--namespace <ns>]
33
+ techbro kube generate|build|deploy [--app <name>] [--image <ref>] [--cluster <name>] [--namespace <ns>]
34
+ techbro --help | --version
35
+ `;
36
+
37
+ const KUBE_USAGE = `Usage:
38
+ techbro kube cluster list
39
+ techbro kube cluster add <name> --context <kube-context> [--namespace <ns>] [--profile-file <json>]
40
+ techbro kube cluster use <name>
41
+ techbro kube check [--cluster <name>] [--namespace <ns>]
42
+ techbro kube status [--app <name>] [--cluster <name>] [--namespace <ns>]
43
+ techbro kube logs [--app <name>] [--tail <n>] [--cluster <name>] [--namespace <ns>]
44
+ techbro kube rollout-status [--app <name>] [--cluster <name>] [--namespace <ns>]
45
+ techbro kube restart [--app <name>] [--dry-run] [--yes] [--cluster <name>] [--namespace <ns>]
46
+ techbro kube rollback [--app <name>] [--dry-run] [--yes] [--cluster <name>] [--namespace <ns>]
47
+ techbro kube generate --image <ref> [--app <name>] [--port <n>] [--replicas <1-3>] [--memory <Mi|Gi>] [--health-path <path>] [--with-creds] [--force] [--cluster <name>] [--namespace <ns>]
48
+ techbro kube build [--app <name>] [--dockerfile <path>] [--dry-run] --yes [--cluster <name>] [--namespace <ns>]
49
+ techbro kube deploy --image <ref> [--app <name>] [--port <n>] [--replicas <1-3>] [--memory <Mi|Gi>] [--health-path <path>] [--with-creds] [--dry-run] [--server-validate] --yes [--cluster <name>] [--namespace <ns>]
50
+
51
+ generate writes k8s/<cluster>.json locally (no cluster changes). build uploads
52
+ the committed source (clean git tree) to an in-cluster builder and pushes an
53
+ image; it never deploys. deploy applies Deployment/Service/Ingress generated
54
+ from options (immutable image tag or digest; no :latest) and waits for rollout.
55
+ build and deploy require --yes, or --dry-run to preview without changes;
56
+ --server-validate (with --dry-run) additionally contacts the cluster.
57
+
58
+ There is no default cluster. "cluster use" only changes techbro's own
59
+ setting; your kubeconfig is never read for credentials or switched. Every
60
+ operation passes an explicit --context and --namespace. Mutations (restart,
61
+ rollback) require --yes, or --dry-run to only print the plan. App defaults to
62
+ the name in ./.techbro.json; project overrides come from its config.kube.
63
+ `;
64
+
65
+ const CLUSTER_USAGE = `Usage:
66
+ techbro cluster add <name> --kubeconfig <file> --context <kube-context> [--namespace <ns>] [--profile-file <json>] [--team <name>] [--passphrase-file <file>] [--force] [--dry-run] --yes
67
+ techbro cluster list [--team <name>]
68
+ techbro cluster use <name> [--team <name>] [--passphrase-file <file>]
69
+ techbro cluster remove <name> [--team <name>] --yes
70
+
71
+ Team-shared clusters. The team owner runs "add" once: techbro extracts only the
72
+ given context from the kubeconfig (certificates inlined), encrypts it with the
73
+ team passphrase, and commits it to the team repo under clusters/. Members run
74
+ "use" to decrypt it into a private file (mode 600) that every "techbro kube"
75
+ command then passes via --kubeconfig; ~/.kube/config is never changed.
76
+ "add --force" replaces or rotates. Everyone with the passphrase gets the same
77
+ access, so share a namespace-scoped credential, not a cluster-admin one.
78
+
79
+ Passphrase: --passphrase-file, else ${PASSPHRASE_ENV}, else a hidden prompt.
80
+ `;
81
+
82
+ const DOCTOR_USAGE = `Usage:
83
+ techbro doctor [--online] [--json]
84
+
85
+ Checks tools, GitHub token, ${PASSPHRASE_ENV}, techbro config, team, shared
86
+ cluster kubeconfigs (presence, mode 600) and secret-like files tracked in the
87
+ current git repo. Offline by default; --online also checks GitHub access and
88
+ runs "kube check" on the current cluster. Never prints secret values. Exits 1
89
+ if any check fails.
90
+ `;
91
+
92
+ const TEAM_USAGE = `Usage:
93
+ techbro team create <name> --url <github-repo-url> [--force]
94
+ techbro team join <name> --url <github-repo-url> [--force]
95
+ techbro team list
96
+ techbro team use <name>
97
+ techbro team upgrade [--team <name>] [--dry-run]
98
+
99
+ Options (create, join):
100
+ -u, --url <repo> GitHub repository holding shared team state
101
+ -f, --force overwrite an existing local team of the same name
102
+ `;
103
+
104
+ const PROJECT_USAGE = `Usage:
105
+ techbro project init [dir] [--template <name>] [--name <app-name>] [--force] [--no-git] [--skip-setup] [--skip-build]
106
+ techbro project link [--url <github-repo-url>] [--name <name>]
107
+ techbro project list
108
+ techbro project review [--url <repo>] [--ref <ref>] [--team <name>] [--project <name>] [--wait] [--dry-run]
109
+
110
+ Options (review):
111
+ -u, --url <repo> registered project (default: origin of the current directory)
112
+ -r, --ref <ref> branch/tag/sha to review (default: local HEAD, else remote default branch)
113
+ -t, --team <name> team to use (default: current)
114
+ -p, --project <name> registered project name
115
+ -w, --wait wait for the run and exit non-zero unless it succeeds
116
+ --dry-run read-only preflight: show effective checks and missing setup; dispatches nothing
117
+
118
+ Options (link):
119
+ -u, --url <repo> project repository (default: git remote "origin" of the current directory)
120
+ -n, --name <name> project name (default: .techbro.json name, else repo name)
121
+
122
+ Options (init):
123
+ -t, --template <name> template to scaffold (default: _default; see "project list")
124
+ -n, --name <app-name> app name (default: slug of the target directory)
125
+ -f, --force allow a non-empty target directory
126
+ --no-git skip "git init -b main" (default: initialize a repository)
127
+ --skip-setup scaffold only; do not install dependencies or tools
128
+ --skip-build skip the final "cargo build -p backend"
129
+ `;
130
+
131
+ const KUBE_OPTS = {
132
+ cluster: { type: "string", short: "c" },
133
+ namespace: { type: "string", short: "n" },
134
+ app: { type: "string", short: "a" },
135
+ tail: { type: "string" },
136
+ context: { type: "string" },
137
+ "profile-file": { type: "string" },
138
+ image: { type: "string" },
139
+ port: { type: "string" },
140
+ replicas: { type: "string" },
141
+ memory: { type: "string" },
142
+ "health-path": { type: "string" },
143
+ dockerfile: { type: "string" },
144
+ "with-creds": { type: "boolean", default: false },
145
+ force: { type: "boolean", short: "f", default: false },
146
+ "server-validate": { type: "boolean", default: false },
147
+ "dry-run": { type: "boolean", default: false },
148
+ yes: { type: "boolean", short: "y", default: false },
149
+ help: { type: "boolean", short: "h", default: false },
150
+ };
151
+
152
+ function readJson(path, what) {
153
+ let raw;
154
+ try {
155
+ raw = readFileSync(path, "utf8");
156
+ } catch (e) {
157
+ throw new Error(`cannot read ${what} ${path}: ${e.code ?? e.message}`);
158
+ }
159
+ try {
160
+ return JSON.parse(raw);
161
+ } catch {
162
+ throw new Error(`malformed ${what} ${path}: not valid JSON`);
163
+ }
164
+ }
165
+
166
+ function loadProjectMeta(cwd) {
167
+ const path = join(cwd, ".techbro.json");
168
+ if (!existsSync(path)) return { name: null, config: {} };
169
+ const meta = readJson(path, ".techbro.json");
170
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) throw new Error(`malformed .techbro.json: expected an object`);
171
+ const config = meta.config ?? {};
172
+ if (typeof config !== "object" || config === null || Array.isArray(config)) throw new Error("malformed .techbro.json: config must be an object");
173
+ if (config.kube != null) {
174
+ const k = config.kube;
175
+ if (typeof k !== "object" || Array.isArray(k)) throw new Error("malformed .techbro.json: config.kube must be an object");
176
+ for (const key of Object.keys(k)) {
177
+ if (!["cluster", "namespace"].includes(key)) throw new Error(`malformed .techbro.json: unknown config.kube field: ${key}`);
178
+ }
179
+ if (k.cluster != null) validateName(k.cluster, "config.kube.cluster");
180
+ if (k.namespace != null) validateName(k.namespace, "config.kube.namespace");
181
+ }
182
+ return { name: typeof meta.name === "string" ? meta.name : null, config };
183
+ }
184
+
185
+ async function kubeCommand(sub, rest, { run, cwd = process.cwd() } = {}) {
186
+ const runOpt = run ? { run } : {};
187
+ run ??= defaultRun;
188
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
189
+ out(KUBE_USAGE);
190
+ return sub ? 0 : 2;
191
+ }
192
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: KUBE_OPTS });
193
+ if (values.help) {
194
+ out(KUBE_USAGE);
195
+ return 0;
196
+ }
197
+ if (sub === "cluster") {
198
+ const [action, ...pos] = positionals;
199
+ if (action === "list") {
200
+ if (pos.length) throw new Error("too many arguments");
201
+ const { current, clusters } = listClusters(loadConfig());
202
+ for (const c of clusters) out(`${c.name === current ? "*" : " "} ${c.name.padEnd(16)}${c.context}${c.builtin ? " (built-in)" : ""}${c.team ? ` (team ${c.team})` : ""}`);
203
+ return 0;
204
+ }
205
+ if (action === "use") {
206
+ if (pos.length !== 1) throw new Error("usage: techbro kube cluster use <name>");
207
+ saveConfig(useCluster(loadConfig(), pos[0]));
208
+ out(`Current cluster: ${pos[0]} (kubeconfig unchanged)`);
209
+ return 0;
210
+ }
211
+ if (action === "add") {
212
+ if (pos.length !== 1) throw new Error("usage: techbro kube cluster add <name> --context <kube-context>");
213
+ if (!values.context) throw new Error("--context is required");
214
+ const profile = values["profile-file"] ? readJson(values["profile-file"], "profile file") : {};
215
+ if (!profile || typeof profile !== "object" || Array.isArray(profile)) throw new Error("profile file must be a JSON object");
216
+ for (const k of ["context", "namespace"]) {
217
+ if (k in profile) throw new Error(`profile file must not set ${k}; use --${k}`);
218
+ }
219
+ profile.context = values.context;
220
+ if (values.namespace) profile.namespace = values.namespace;
221
+ saveConfig(addCluster(loadConfig(), pos[0], profile));
222
+ out(`Added cluster ${pos[0]} (context ${values.context}); use "techbro kube cluster use ${pos[0]}" to select it`);
223
+ return 0;
224
+ }
225
+ throw new Error("usage: techbro kube cluster list|add|use");
226
+ }
227
+ const PROJECT_OPS = ["generate", "build", "deploy"];
228
+ const ops = ["check", ...OPERATIONS, ...PROJECT_OPS];
229
+ if (!ops.includes(sub)) return null;
230
+ if (positionals.length) throw new Error("too many arguments");
231
+ const meta = loadProjectMeta(cwd);
232
+ const profile = await resolveCluster({ config: loadConfig(), project: meta.config, cluster: values.cluster, namespace: values.namespace, run });
233
+ const target = `cluster=${profile.name} context=${profile.context} namespace=${profile.namespace}`;
234
+ if (sub === "check") {
235
+ out(`Target: ${target}`);
236
+ const r = await preflight({ profile, run });
237
+ for (const c of r.checks) out(` ${c.ok ? "ok " : "FAIL"} ${c.name}${c.ok ? "" : `: ${c.detail}`}`);
238
+ return r.ok ? 0 : 1;
239
+ }
240
+ const app = values.app ?? meta.name;
241
+ if (!app) throw new Error("app name required: pass --app or run in a project with .techbro.json name");
242
+ let tail;
243
+ if (values.tail != null) {
244
+ if (sub !== "logs") throw new Error("--tail applies to logs only");
245
+ if (!/^[0-9]+$/.test(values.tail)) throw new Error("--tail must be an integer");
246
+ tail = Number(values.tail);
247
+ }
248
+ if (PROJECT_OPS.includes(sub)) return projectOp(sub, { values, profile, app, target, runOpt, cwd });
249
+ const mutation = sub === "restart" || sub === "rollback";
250
+ if (mutation && !values.yes && !values["dry-run"]) throw new Error(`${sub} changes the cluster; confirm with --yes (or preview with --dry-run)`);
251
+ out(`Target: ${target} app=${app}`);
252
+ if (sub === "logs") process.stderr.write(`techbro: warning: ${LOGS_WARNING}\n`);
253
+ const r = await performKube({ profile, operation: sub, app, tail, yes: values.yes, dryRun: values["dry-run"], run });
254
+ if (r.dryRun) {
255
+ out("Dry run: nothing executed");
256
+ for (const l of r.plan) out(` ${l}`);
257
+ } else if (r.output) out(r.output);
258
+ return 0;
259
+ }
260
+
261
+ const intOpt = (v, name) => {
262
+ if (v == null) return undefined;
263
+ if (!/^[0-9]+$/.test(v)) throw new Error(`--${name} must be an integer`);
264
+ return Number(v);
265
+ };
266
+
267
+ async function projectOp(sub, { values, profile, app, target, runOpt, cwd }) {
268
+ const dry = values["dry-run"];
269
+ if (sub !== "generate" && !values.yes && !dry) throw new Error(`${sub} changes the cluster or registry; confirm with --yes (or preview with --dry-run)`);
270
+ if (values["server-validate"] && !(sub === "deploy" && dry)) throw new Error("--server-validate applies to deploy --dry-run only");
271
+ if (sub !== "deploy" && sub !== "generate" && values.image) throw new Error("--image does not apply to build; the image name is generated");
272
+ const manifestOpts = {
273
+ image: values.image,
274
+ port: intOpt(values.port, "port"),
275
+ replicas: intOpt(values.replicas, "replicas"),
276
+ memory: values.memory,
277
+ healthPath: values["health-path"],
278
+ withCreds: values["with-creds"],
279
+ };
280
+ for (const k of Object.keys(manifestOpts)) if (manifestOpts[k] === undefined || manifestOpts[k] === false) delete manifestOpts[k];
281
+ if (sub !== "build") {
282
+ if (!values.image) throw new Error(`--image is required for ${sub} (registry-qualified, with an immutable tag or digest)`);
283
+ } else if (Object.keys(manifestOpts).length) throw new Error("build does not take manifest options");
284
+ const warn = (m) => process.stderr.write(`techbro: warning: ${m}\n`);
285
+ out(`Target: ${target} app=${app}`);
286
+ try {
287
+ if (sub === "generate") {
288
+ const r = await generateManifest({ root: cwd, force: values.force, profile, app, ...manifestOpts });
289
+ out(`Wrote ${r.path} (${r.manifests.items.map((i) => i.kind).join(", ")}); nothing applied to the cluster`);
290
+ return 0;
291
+ }
292
+ if (sub === "build") {
293
+ const r = await buildProject({ profile, app, root: cwd, dockerfile: values.dockerfile, yes: values.yes, dryRun: dry, ...runOpt });
294
+ if (r.dryRun) {
295
+ out("Dry run: nothing uploaded or built");
296
+ out(` image: ${r.image}\n builder: ${r.buildImage}${r.privileged ? " (privileged)" : ""}\n files: ${r.files}\n policy: ${r.filePolicy}`);
297
+ } else {
298
+ out(`Built and pushed image: ${r.image}\nNext: techbro kube deploy --image ${r.image} --yes`);
299
+ for (const w of r.warnings ?? []) warn(w);
300
+ }
301
+ return 0;
302
+ }
303
+ const r = await deployProject({ profile, app, yes: values.yes, dryRun: dry, serverValidate: values["server-validate"], ...runOpt, ...manifestOpts });
304
+ if (r.dryRun) {
305
+ out(`Dry run: nothing applied (${r.kinds.join(", ")})`);
306
+ for (const l of r.plan) out(` ${l}`);
307
+ if (r.serverValidation) out(r.serverValidation);
308
+ } else {
309
+ out(`Deployed ${r.kinds.join(", ")} for ${app}`);
310
+ if (r.applied) out(r.applied);
311
+ if (r.rollout) out(r.rollout);
312
+ }
313
+ return 0;
314
+ } catch (e) {
315
+ if (e.exitCode) {
316
+ process.stderr.write(`techbro: ${e.message}\n`);
317
+ return e.exitCode;
318
+ }
319
+ throw e;
320
+ }
321
+ }
322
+
323
+ const out = (s) => process.stdout.write(s.endsWith("\n") ? s : s + "\n");
324
+
325
+ function version() {
326
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
327
+ }
328
+
329
+ function projectInit(args) {
330
+ const { values, positionals } = parseArgs({
331
+ args,
332
+ allowPositionals: true,
333
+ options: {
334
+ template: { type: "string", short: "t", default: "_default" },
335
+ name: { type: "string", short: "n" },
336
+ force: { type: "boolean", short: "f", default: false },
337
+ "no-git": { type: "boolean", default: false },
338
+ "skip-setup": { type: "boolean", default: false },
339
+ "skip-build": { type: "boolean", default: false },
340
+ help: { type: "boolean", short: "h", default: false },
341
+ },
342
+ });
343
+ if (values.help) {
344
+ out(PROJECT_USAGE);
345
+ return 0;
346
+ }
347
+ if (positionals.length > 1) throw new Error("too many arguments");
348
+ const dir = positionals[0];
349
+ const result = initProject({
350
+ template: values.template,
351
+ dir,
352
+ name: values.name,
353
+ force: values.force,
354
+ git: !values["no-git"],
355
+ });
356
+ const setup = !values["skip-setup"];
357
+ if (setup) {
358
+ out(`Scaffolded ${result.target}`);
359
+ setupProject(result.target, { log: out, skipBuild: values["skip-build"] });
360
+ }
361
+ out(printSummary({ ...result, setup }, dir));
362
+ return 0;
363
+ }
364
+
365
+ async function teamCommand(sub, rest) {
366
+ if (!sub || sub === "--help" || sub === "-h") {
367
+ out(TEAM_USAGE);
368
+ return sub ? 0 : 2;
369
+ }
370
+ if (sub === "list") {
371
+ for (const t of listTeams(loadConfig())) out(`${t.current ? "*" : " "} ${t.name.padEnd(16)}${t.url}`);
372
+ return 0;
373
+ }
374
+ if (sub === "use") {
375
+ if (rest.length !== 1) throw new Error("usage: techbro team use <name>");
376
+ const config = loadConfig();
377
+ useTeam(config, rest[0]);
378
+ saveConfig(config);
379
+ out(`Current team: ${rest[0]}`);
380
+ return 0;
381
+ }
382
+ if (sub === "upgrade") {
383
+ const { values } = parseArgs({
384
+ args: rest,
385
+ options: {
386
+ team: { type: "string", short: "t" },
387
+ "dry-run": { type: "boolean", default: false },
388
+ help: { type: "boolean", short: "h", default: false },
389
+ },
390
+ });
391
+ if (values.help) {
392
+ out(TEAM_USAGE);
393
+ return 0;
394
+ }
395
+ const client = createClient({ token: resolveToken() });
396
+ const s = await upgradeTeam({ teamName: values.team, config: loadConfig(), client, dryRun: values["dry-run"] });
397
+ out(`Repository: ${s.repo} (${s.base})`);
398
+ for (const a of s.actions) {
399
+ out(` ${a.action.padEnd(10)}${a.path}${a.candidate ? ` -> candidate ${a.candidate}; merge by hand` : ""}`);
400
+ }
401
+ if (s.status === "current") out("managed files are up to date");
402
+ else if (s.status === "dry-run") out(`dry run: would write ${s.changes.join(", ")}`);
403
+ else out(`${s.status === "pr-created" ? "opened" : "reusing"} PR: ${s.prUrl} (branch ${s.branch})`);
404
+ return 0;
405
+ }
406
+ if (sub === "join") {
407
+ const { values, positionals } = parseArgs({
408
+ args: rest,
409
+ allowPositionals: true,
410
+ options: {
411
+ url: { type: "string", short: "u" },
412
+ force: { type: "boolean", short: "f", default: false },
413
+ help: { type: "boolean", short: "h", default: false },
414
+ },
415
+ });
416
+ if (values.help) {
417
+ out(TEAM_USAGE);
418
+ return 0;
419
+ }
420
+ if (positionals.length !== 1 || !values.url) throw new Error("usage: techbro team join <name> --url <github-repo-url>");
421
+ const config = loadConfig();
422
+ const client = createClient({ token: resolveToken() });
423
+ const s = await joinTeam({ name: positionals[0], url: values.url, config, client, force: values.force });
424
+ saveConfig(config);
425
+ out(`Repository: ${s.repo}`);
426
+ if (s.status === "member") out("already a member");
427
+ else if (s.status === "added") out(`added ${s.login} to members.json`);
428
+ else out(`membership pending: ${s.issueUrl} (the team workflow will add you)`);
429
+ out(`Current team: ${s.name}`);
430
+ return 0;
431
+ }
432
+ if (sub === "create") {
433
+ const { values, positionals } = parseArgs({
434
+ args: rest,
435
+ allowPositionals: true,
436
+ options: {
437
+ url: { type: "string", short: "u" },
438
+ force: { type: "boolean", short: "f", default: false },
439
+ help: { type: "boolean", short: "h", default: false },
440
+ },
441
+ });
442
+ if (values.help) {
443
+ out(TEAM_USAGE);
444
+ return 0;
445
+ }
446
+ if (positionals.length !== 1 || !values.url) throw new Error("usage: techbro team create <name> --url <github-repo-url>");
447
+ const config = loadConfig();
448
+ const client = createClient({ token: resolveToken() });
449
+ const s = await createTeam({ name: positionals[0], url: values.url, config, client, force: values.force });
450
+ saveConfig(config);
451
+ out(`Repository: ${s.repo}`);
452
+ out(`Owner: ${s.owner}`);
453
+ out(`Members: ${s.members}`);
454
+ if (s.created.length) out(`Created: ${s.created.join(", ")}`);
455
+ if (s.updated.length) out(`Updated: ${s.updated.join(", ")}`);
456
+ out(`Current team: ${s.name}`);
457
+ return 0;
458
+ }
459
+ return null;
460
+ }
461
+
462
+ async function projectLink(args) {
463
+ const { values, positionals } = parseArgs({
464
+ args,
465
+ allowPositionals: true,
466
+ options: {
467
+ url: { type: "string", short: "u" },
468
+ name: { type: "string", short: "n" },
469
+ help: { type: "boolean", short: "h", default: false },
470
+ },
471
+ });
472
+ if (values.help) {
473
+ out(PROJECT_USAGE);
474
+ return 0;
475
+ }
476
+ if (positionals.length) throw new Error("too many arguments");
477
+ const config = loadConfig();
478
+ const client = createClient({ token: resolveToken() });
479
+ const s = await linkProject({ cwd: process.cwd(), url: values.url, name: values.name, config, client });
480
+ if (s.status === "linked") out("already linked");
481
+ else if (s.status === "added") out(`linked ${s.name} (${s.url}) to team ${s.team}`);
482
+ else out(`link pending: ${s.issueUrl} (the team workflow will add it)`);
483
+ const rc = s.reviewConfig;
484
+ if (rc.status === "seeded") out("seeded review config for this project");
485
+ if (rc.warning) process.stderr.write(`techbro: warning: ${rc.warning}\n`);
486
+ if (rc.status === "pending-admin" || rc.status === "skipped" || rc.status === "failed") {
487
+ out(`Proposed review config (add under projects.${s.name}):\n${JSON.stringify(rc.proposal, null, 2)}`);
488
+ }
489
+ const cl = s.caller;
490
+ if (cl.prUrl) out(`review caller PR (${cl.status === "pr-created" ? "opened" : "reusing"}): ${cl.prUrl}`);
491
+ else if (cl.status === "current") out("review caller is up to date");
492
+ if (cl.warning) process.stderr.write(`techbro: warning: ${cl.warning}\n`);
493
+ return 0;
494
+ }
495
+
496
+ async function projectReview(args) {
497
+ const { values, positionals } = parseArgs({
498
+ args,
499
+ allowPositionals: true,
500
+ options: {
501
+ url: { type: "string", short: "u" },
502
+ ref: { type: "string", short: "r" },
503
+ team: { type: "string", short: "t" },
504
+ project: { type: "string", short: "p" },
505
+ wait: { type: "boolean", short: "w", default: false },
506
+ "dry-run": { type: "boolean", default: false },
507
+ help: { type: "boolean", short: "h", default: false },
508
+ },
509
+ });
510
+ if (values.help) {
511
+ out(PROJECT_USAGE);
512
+ return 0;
513
+ }
514
+ if (positionals.length) throw new Error("too many arguments");
515
+ const client = createClient({ token: resolveToken() });
516
+ const s = await reviewProject({
517
+ cwd: process.cwd(), url: values.url, ref: values.ref, team: values.team, project: values.project, wait: values.wait, dryRun: values["dry-run"],
518
+ config: loadConfig(), client, warn: (m) => process.stderr.write(`techbro: warning: ${m}\n`),
519
+ });
520
+ out(`Project: ${s.project} @ ${s.sha}`);
521
+ if (s.dryRun) {
522
+ out("Dry run: nothing dispatched");
523
+ out(`Tests: ${s.effective.test ?? "none"}`);
524
+ out(`Security fail-on: ${s.effective.securityFailOn}`);
525
+ out(`Duplication: ${s.effective.duplicationMode}${s.effective.duplicationMode === "required" ? ` max ${s.effective.duplicationMaxPercent}%` : ""}`);
526
+ out(`Sonar: ${s.effective.sonarMode}${s.effective.sonarProjectKey ? ` (${s.effective.sonarProjectKey})` : ""}`);
527
+ for (const m of s.missing) out(`Missing: ${m}`);
528
+ for (const n of s.notes) out(`Note: ${n}`);
529
+ return s.ok ? 0 : 1;
530
+ }
531
+ out(`Request: ${s.requestId}`);
532
+ out(`Run: ${s.runUrl ?? s.workflowUrl}`);
533
+ if (s.note) out(s.note);
534
+ if (s.status === "timeout") process.stderr.write("techbro: timed out waiting for the review\n");
535
+ else if (s.status === "failure") process.stderr.write(`techbro: review ${s.conclusion}\n`);
536
+ if (s.artifactUrl) out(`Report artifact: ${s.artifactUrl}`);
537
+ return s.ok ? 0 : 1;
538
+ }
539
+
540
+ async function clusterCommand(sub, rest, deps) {
541
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
542
+ out(CLUSTER_USAGE);
543
+ return sub ? 0 : 2;
544
+ }
545
+ const { values, positionals } = parseArgs({
546
+ args: rest,
547
+ allowPositionals: true,
548
+ options: {
549
+ team: { type: "string", short: "t" },
550
+ kubeconfig: { type: "string" },
551
+ context: { type: "string" },
552
+ namespace: { type: "string", short: "n" },
553
+ "profile-file": { type: "string" },
554
+ "passphrase-file": { type: "string" },
555
+ force: { type: "boolean", short: "f", default: false },
556
+ "dry-run": { type: "boolean", default: false },
557
+ yes: { type: "boolean", short: "y", default: false },
558
+ help: { type: "boolean", short: "h", default: false },
559
+ },
560
+ });
561
+ if (values.help) {
562
+ out(CLUSTER_USAGE);
563
+ return 0;
564
+ }
565
+ if (!["add", "list", "use", "remove"].includes(sub)) return null;
566
+ if (sub === "list" ? positionals.length : positionals.length !== 1) throw new Error(`usage: techbro cluster ${sub}${sub === "list" ? "" : " <name>"}`);
567
+ const name = positionals[0];
568
+ const config = loadConfig();
569
+ const client = deps.client ?? createClient({ token: resolveToken() });
570
+ const passphrase = (confirm) => (deps.resolvePassphrase ?? resolvePassphrase)({ file: values["passphrase-file"], confirm });
571
+ const teamName = values.team;
572
+
573
+ if (sub === "list") {
574
+ const r = await listSharedClusters({ config, client, teamName });
575
+ out(`Team ${r.team} (${r.repo})`);
576
+ if (!r.clusters.length) out(" no shared clusters");
577
+ for (const c of r.clusters) out(` ${c.name.padEnd(16)}${c.installed ? "installed" : `run: techbro cluster use ${c.name}`}`);
578
+ return 0;
579
+ }
580
+ if (sub === "use") {
581
+ const r = await useSharedCluster({ config, client, name, teamName, passphrase: await passphrase(false) });
582
+ saveConfig(r.config);
583
+ out(`Installed cluster ${name} from ${r.repo} (context ${r.context}${r.namespace ? `, namespace ${r.namespace}` : ""})`);
584
+ out(`Kubeconfig: ${r.kubeconfig} (mode 600; ~/.kube/config unchanged)`);
585
+ out(`Current cluster: ${name}; try: techbro kube check`);
586
+ return 0;
587
+ }
588
+ if (sub === "remove") {
589
+ if (!values.yes) throw new Error("remove deletes the shared cluster for the whole team; confirm with --yes");
590
+ const r = await removeSharedCluster({ config, client, name, teamName });
591
+ saveConfig(r.config);
592
+ out(`Removed ${r.removed.join(", ")} from ${r.repo}`);
593
+ out("Members keep any copy they already decrypted; rotate the credential on the cluster.");
594
+ return 0;
595
+ }
596
+ if (!values.kubeconfig || !values.context) throw new Error("--kubeconfig and --context are required");
597
+ if (!values.yes && !values["dry-run"]) throw new Error("add commits an encrypted credential to the team repo; confirm with --yes (or preview with --dry-run)");
598
+ const profile = values["profile-file"] ? readJson(values["profile-file"], "profile file") : {};
599
+ if (!profile || typeof profile !== "object" || Array.isArray(profile)) throw new Error("profile file must be a JSON object");
600
+ for (const k of ["context", "namespace"]) if (k in profile) throw new Error(`profile file must not set ${k}; use --${k}`);
601
+ const r = await addSharedCluster({
602
+ config, client, name, teamName, profile, force: values.force, dryRun: values["dry-run"],
603
+ kubeconfig: values.kubeconfig, context: values.context, namespace: values.namespace,
604
+ passphrase: values["dry-run"] ? null : await passphrase(true),
605
+ run: deps.run ?? defaultRun,
606
+ });
607
+ for (const w of r.warnings) process.stderr.write(`techbro: warning: ${w}\n`);
608
+ if (r.dryRun) {
609
+ out(`Dry run: nothing committed to ${r.repo}`);
610
+ out(` would ${r.replaced ? "replace" : "add"}: ${r.files.join(", ")} (context ${r.context}${r.namespace ? `, namespace ${r.namespace}` : ""})`);
611
+ return 0;
612
+ }
613
+ out(`${r.replaced ? "Replaced" : "Added"} shared cluster ${name} in ${r.repo} (context ${r.context}${r.namespace ? `, namespace ${r.namespace}` : ""})`);
614
+ out(`Members: techbro team join ${r.team} --url <repo>, set ${PASSPHRASE_ENV}, then techbro cluster use ${name}`);
615
+ return 0;
616
+ }
617
+
618
+ async function doctorCommand(rest, deps) {
619
+ const { values, positionals } = parseArgs({
620
+ args: rest,
621
+ allowPositionals: true,
622
+ options: { online: { type: "boolean", default: false }, json: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false } },
623
+ });
624
+ if (values.help) {
625
+ out(DOCTOR_USAGE);
626
+ return 0;
627
+ }
628
+ if (positionals.length) throw new Error("too many arguments");
629
+ let client = deps.client;
630
+ if (!client && values.online) {
631
+ try {
632
+ client = createClient({ token: resolveToken() });
633
+ } catch {
634
+ client = null;
635
+ }
636
+ }
637
+ const r = await doctor({ online: values.online, client, cwd: deps.cwd ?? process.cwd(), ...(deps.run && { run: deps.run }), ...(deps.env && { env: deps.env }) });
638
+ if (values.json) {
639
+ out(JSON.stringify(r, null, 2));
640
+ return r.ok ? 0 : 1;
641
+ }
642
+ const tag = { ok: "ok ", warn: "warn", fail: "FAIL" };
643
+ for (const c of r.checks) {
644
+ out(`${tag[c.status]} ${c.area.padEnd(8)}${c.name}${c.detail ? `: ${c.detail}` : ""}`);
645
+ if (c.fix && c.status !== "ok") out(` ${" ".repeat(8)}fix: ${c.fix}`);
646
+ }
647
+ const n = (s) => r.checks.filter((c) => c.status === s).length;
648
+ out(`\n${n("ok")} ok, ${n("warn")} warning(s), ${n("fail")} failure(s)`);
649
+ return r.ok ? 0 : 1;
650
+ }
651
+
652
+ export async function main(argv, deps = {}) {
653
+ const [cmd, sub, ...rest] = argv;
654
+ if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
655
+ out(USAGE);
656
+ return cmd ? 0 : 2;
657
+ }
658
+ if (cmd === "--version" || cmd === "-v") {
659
+ out(version());
660
+ return 0;
661
+ }
662
+ if (cmd === "project") {
663
+ if (!sub || sub === "--help" || sub === "-h") {
664
+ out(PROJECT_USAGE);
665
+ return sub ? 0 : 2;
666
+ }
667
+ if (sub === "list") {
668
+ for (const n of listTemplates()) out(`${n.padEnd(16)}${describeTemplate(n)}`);
669
+ return 0;
670
+ }
671
+ if (sub === "init") return projectInit(rest);
672
+ if (sub === "link") return projectLink(rest);
673
+ if (sub === "review") return projectReview(rest);
674
+ }
675
+ if (cmd === "team") {
676
+ const code = await teamCommand(sub, rest);
677
+ if (code !== null) return code;
678
+ }
679
+ if (cmd === "cluster") {
680
+ const code = await clusterCommand(sub, rest, deps);
681
+ if (code !== null) return code;
682
+ }
683
+ if (cmd === "doctor") return doctorCommand(sub ? [sub, ...rest] : [], deps);
684
+ if (cmd === "kube") {
685
+ const code = await kubeCommand(sub, rest, deps);
686
+ if (code !== null) return code;
687
+ }
688
+ process.stderr.write(`techbro: unknown command "${argv.join(" ")}"\n\n${USAGE}`);
689
+ return 2;
690
+ }