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/kube.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Kubernetes core: cluster profiles, safe kubectl argv, and app operations.
|
|
2
|
+
// Never reads credentials, never modifies kubeconfig, never runs `use-context`.
|
|
3
|
+
// Team-shared clusters pass their techbro-owned kubeconfig file via --kubeconfig.
|
|
4
|
+
//
|
|
5
|
+
// CONTRACT (consumed by src/kube-project.js):
|
|
6
|
+
// resolveCluster(...) -> { name, context, namespace, registry, domain, tlsResolver,
|
|
7
|
+
// platform, memory, build, secretRefs, caEnv }
|
|
8
|
+
// kubeArgs(profile, args) -> argv with explicit --context/--namespace prepended
|
|
9
|
+
// kubectl(profile, args, { run, input, timeoutMs }) -> stdout string (throws on failure)
|
|
10
|
+
// `run(file, argv, { input, timeout })` is an injected subprocess runner returning
|
|
11
|
+
// { code, stdout, stderr } (or a Promise of it). The default uses execFile (no shell).
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
const DNS_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
|
|
15
|
+
const DNS_NAME = /^[a-z0-9]([-a-z0-9.]{0,251}[a-z0-9])?$/;
|
|
16
|
+
const CONTEXT_RE = /^[A-Za-z0-9][A-Za-z0-9._:@\/-]{0,252}$/;
|
|
17
|
+
const REGISTRY_RE = /^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,252}$/;
|
|
18
|
+
const IMAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._:@\/-]{0,252}$/;
|
|
19
|
+
const MEMORY_RE = /^[1-9][0-9]{0,5}(Mi|Gi)$/;
|
|
20
|
+
const PLATFORM_RE = /^linux\/(amd64|arm64|arm\/v[67])$/;
|
|
21
|
+
const RESOLVER_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
|
|
22
|
+
const PROFILE_KEYS = ["context", "namespace", "registry", "domain", "tlsResolver", "platform", "memory", "build", "secretRefs", "caEnv"];
|
|
23
|
+
const BUILD_KEYS = ["image", "privileged", "memory", "insecure"];
|
|
24
|
+
export const DEFAULT_CLUSTER = null;
|
|
25
|
+
|
|
26
|
+
// No cluster ships built in; teams share profiles with `techbro cluster add`.
|
|
27
|
+
export const BUILTIN_PROFILES = Object.freeze({});
|
|
28
|
+
|
|
29
|
+
const need = (ok, msg) => {
|
|
30
|
+
if (!ok) throw new Error(msg);
|
|
31
|
+
};
|
|
32
|
+
const str = (v) => typeof v === "string";
|
|
33
|
+
const isObj = (v) => v && typeof v === "object" && !Array.isArray(v);
|
|
34
|
+
|
|
35
|
+
export function validateName(name, what = "name") {
|
|
36
|
+
need(str(name) && DNS_LABEL.test(name), `invalid ${what}: must be a DNS label`);
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const validateNamespace = (ns) => validateName(ns, "namespace");
|
|
41
|
+
|
|
42
|
+
// Validates a stored/custom profile block; returns a cleaned copy (unknown keys rejected).
|
|
43
|
+
export function validateProfile(p) {
|
|
44
|
+
need(isObj(p), "cluster profile must be an object");
|
|
45
|
+
for (const k of Object.keys(p)) need(PROFILE_KEYS.includes(k), `unknown profile field: ${k}`);
|
|
46
|
+
need(str(p.context) && CONTEXT_RE.test(p.context), "profile.context is required and must be a plain kube context name");
|
|
47
|
+
const out = { context: p.context };
|
|
48
|
+
if (p.namespace != null) out.namespace = validateNamespace(p.namespace);
|
|
49
|
+
if (p.registry != null) {
|
|
50
|
+
need(str(p.registry) && REGISTRY_RE.test(p.registry), "invalid profile.registry");
|
|
51
|
+
out.registry = p.registry;
|
|
52
|
+
}
|
|
53
|
+
if (p.domain != null) {
|
|
54
|
+
need(str(p.domain) && DNS_NAME.test(p.domain), "invalid profile.domain");
|
|
55
|
+
out.domain = p.domain;
|
|
56
|
+
}
|
|
57
|
+
if (p.tlsResolver != null) {
|
|
58
|
+
need(str(p.tlsResolver) && RESOLVER_RE.test(p.tlsResolver), "invalid profile.tlsResolver");
|
|
59
|
+
out.tlsResolver = p.tlsResolver;
|
|
60
|
+
}
|
|
61
|
+
if (p.platform != null) {
|
|
62
|
+
need(str(p.platform) && PLATFORM_RE.test(p.platform), "invalid profile.platform");
|
|
63
|
+
out.platform = p.platform;
|
|
64
|
+
}
|
|
65
|
+
if (p.memory != null) {
|
|
66
|
+
need(str(p.memory) && MEMORY_RE.test(p.memory), "invalid profile.memory");
|
|
67
|
+
out.memory = p.memory;
|
|
68
|
+
}
|
|
69
|
+
if (p.build != null) {
|
|
70
|
+
const b = p.build;
|
|
71
|
+
need(isObj(b), "profile.build must be an object");
|
|
72
|
+
for (const k of Object.keys(b)) need(BUILD_KEYS.includes(k), `unknown profile.build field: ${k}`);
|
|
73
|
+
const nb = {};
|
|
74
|
+
if (b.image != null) {
|
|
75
|
+
need(str(b.image) && IMAGE_RE.test(b.image), "invalid profile.build.image");
|
|
76
|
+
nb.image = b.image;
|
|
77
|
+
}
|
|
78
|
+
if (b.memory != null) {
|
|
79
|
+
need(str(b.memory) && MEMORY_RE.test(b.memory), "invalid profile.build.memory");
|
|
80
|
+
nb.memory = b.memory;
|
|
81
|
+
}
|
|
82
|
+
for (const k of ["privileged", "insecure"]) {
|
|
83
|
+
if (b[k] != null) {
|
|
84
|
+
need(typeof b[k] === "boolean", `profile.build.${k} must be boolean`);
|
|
85
|
+
nb[k] = b[k];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
out.build = nb;
|
|
89
|
+
}
|
|
90
|
+
if (p.caEnv != null) {
|
|
91
|
+
need(str(p.caEnv) && /^[A-Z][A-Z0-9_]{0,62}$/.test(p.caEnv), "invalid profile.caEnv (an env var name)");
|
|
92
|
+
out.caEnv = p.caEnv;
|
|
93
|
+
}
|
|
94
|
+
if (p.secretRefs != null) {
|
|
95
|
+
need(isObj(p.secretRefs), "profile.secretRefs must be an object of secret names");
|
|
96
|
+
out.secretRefs = {};
|
|
97
|
+
for (const [k, v] of Object.entries(p.secretRefs)) {
|
|
98
|
+
need(/^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(k), `invalid secretRefs key: ${k}`);
|
|
99
|
+
out.secretRefs[k] = validateName(v, `secretRefs.${k}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const customClusters = (config) => {
|
|
106
|
+
const c = isObj(config?.kube?.clusters) ? config.kube.clusters : {};
|
|
107
|
+
for (const n of Object.keys(BUILTIN_PROFILES)) need(!(n in c), `config.kube.clusters.${n} overrides a reserved built-in profile`);
|
|
108
|
+
return c;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const sharedClusters = (config) => (isObj(config?.kube?.shared) ? config.kube.shared : {});
|
|
112
|
+
|
|
113
|
+
// Returns { current, clusters: [{ name, context, builtin, team }] }; team-shared entries shadow built-ins.
|
|
114
|
+
export function listClusters(config) {
|
|
115
|
+
const custom = customClusters(config);
|
|
116
|
+
const shared = sharedClusters(config);
|
|
117
|
+
const clusters = Object.entries(BUILTIN_PROFILES)
|
|
118
|
+
.filter(([name]) => !(name in shared))
|
|
119
|
+
.map(([name, p]) => ({ name, context: p.context, builtin: true }));
|
|
120
|
+
for (const [name, s] of Object.entries(shared)) clusters.push({ name, context: s.profile?.context, builtin: false, team: s.team });
|
|
121
|
+
for (const [name, p] of Object.entries(custom)) {
|
|
122
|
+
if (!(name in BUILTIN_PROFILES) && !(name in shared)) clusters.push({ name, context: p.context, builtin: false });
|
|
123
|
+
}
|
|
124
|
+
return { current: config?.kube?.current ?? DEFAULT_CLUSTER, clusters };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Returns a new config with the cluster added; other config keys are preserved.
|
|
128
|
+
export function addCluster(config, name, profile) {
|
|
129
|
+
validateName(name, "cluster name");
|
|
130
|
+
need(!(name in BUILTIN_PROFILES), `cluster name is reserved by a built-in profile: ${name}`);
|
|
131
|
+
const clean = validateProfile(profile);
|
|
132
|
+
const kube = config?.kube ?? {};
|
|
133
|
+
return {
|
|
134
|
+
...config,
|
|
135
|
+
kube: { ...kube, current: kube.current ?? null, clusters: { ...customClusters(config), [name]: clean } },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Returns a new config with kube.current set. Never touches kubeconfig.
|
|
140
|
+
export function useCluster(config, name) {
|
|
141
|
+
validateName(name, "cluster name");
|
|
142
|
+
need(name in BUILTIN_PROFILES || name in customClusters(config) || name in sharedClusters(config), `unknown cluster: ${name}`);
|
|
143
|
+
const kube = config?.kube ?? {};
|
|
144
|
+
return { ...config, kube: { ...kube, current: name, clusters: customClusters(config) } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function defaultRun(file, args, { input, timeout, inputFile } = {}) {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
if (inputFile != null) return reject(new Error("defaultRun does not support inputFile; inject a streaming runner"));
|
|
150
|
+
const child = execFile(file, args, { timeout, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
151
|
+
if (err && typeof err.code !== "number") return resolve({ code: 1, stdout: stdout ?? "", stderr: stderr || err.message });
|
|
152
|
+
resolve({ code: err ? err.code : 0, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
153
|
+
});
|
|
154
|
+
if (input != null) child.stdin.end(input);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Resolution precedence: explicit flags > project config.kube > local config > builtin.
|
|
159
|
+
// Namespace: explicit > project > profile > `kubectl config view --minify` lookup (no raw config).
|
|
160
|
+
export async function resolveCluster({ config = {}, project = {}, cluster, namespace, run = defaultRun } = {}) {
|
|
161
|
+
const name = cluster ?? project?.kube?.cluster ?? config?.kube?.current ?? DEFAULT_CLUSTER;
|
|
162
|
+
need(name, "no cluster selected: run techbro cluster use <name> (team-shared) or techbro kube cluster add/use");
|
|
163
|
+
validateName(name, "cluster name");
|
|
164
|
+
const custom = customClusters(config)[name];
|
|
165
|
+
const shared = sharedClusters(config)[name];
|
|
166
|
+
need(shared || custom || name in BUILTIN_PROFILES, `unknown cluster: ${name}`);
|
|
167
|
+
let raw;
|
|
168
|
+
let kubeconfig = null;
|
|
169
|
+
if (shared) {
|
|
170
|
+
need(str(shared.kubeconfig) && shared.kubeconfig.startsWith("/"), `shared cluster ${name} has no local kubeconfig; run techbro cluster use ${name}`);
|
|
171
|
+
raw = { ...(BUILTIN_PROFILES[name] ?? {}), ...validateProfile(shared.profile) };
|
|
172
|
+
kubeconfig = shared.kubeconfig;
|
|
173
|
+
} else raw = name in BUILTIN_PROFILES && !custom ? BUILTIN_PROFILES[name] : validateProfile(custom);
|
|
174
|
+
const prof = {
|
|
175
|
+
name,
|
|
176
|
+
kubeconfig,
|
|
177
|
+
context: raw.context,
|
|
178
|
+
namespace: null,
|
|
179
|
+
registry: raw.registry ?? null,
|
|
180
|
+
domain: raw.domain ?? null,
|
|
181
|
+
tlsResolver: raw.tlsResolver ?? null,
|
|
182
|
+
platform: raw.platform ?? null,
|
|
183
|
+
memory: raw.memory ?? null,
|
|
184
|
+
build: raw.build ? { ...raw.build } : null,
|
|
185
|
+
secretRefs: { ...(raw.secretRefs ?? {}) },
|
|
186
|
+
caEnv: raw.caEnv ?? null,
|
|
187
|
+
};
|
|
188
|
+
const lookup = async () => {
|
|
189
|
+
const res = await run(
|
|
190
|
+
"kubectl",
|
|
191
|
+
["config", "view", ...(kubeconfig ? ["--kubeconfig", kubeconfig] : []), "--minify", "--context", prof.context, "-o", "jsonpath={.contexts[0].context.namespace}"],
|
|
192
|
+
{ timeout: 15000 },
|
|
193
|
+
);
|
|
194
|
+
need(res.code === 0, `could not determine namespace for context ${prof.context}; pass --namespace`);
|
|
195
|
+
return String(res.stdout ?? "").trim();
|
|
196
|
+
};
|
|
197
|
+
let ns = namespace;
|
|
198
|
+
if (ns == null) {
|
|
199
|
+
const projectNs = project?.kube?.namespace ?? null;
|
|
200
|
+
const def = raw.namespace ?? (await lookup());
|
|
201
|
+
if (projectNs != null) {
|
|
202
|
+
validateNamespace(projectNs);
|
|
203
|
+
need(def === projectNs, `project namespace ${projectNs} differs from the cluster default${def ? ` ${def}` : ""}; pass --namespace to confirm`);
|
|
204
|
+
}
|
|
205
|
+
ns = def;
|
|
206
|
+
need(ns, `context ${prof.context} has no namespace; pass --namespace`);
|
|
207
|
+
}
|
|
208
|
+
prof.namespace = validateNamespace(ns);
|
|
209
|
+
return prof;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const FORBIDDEN_FLAG = /^-v|^--v|^(--context|--namespace|-n|--kubeconfig|--cluster|--user|--server|-s|--token|--as|--as-group|--as-uid|--all-namespaces|-A|--insecure-skip-tls-verify|--certificate-authority|--client-certificate|--client-key|--username|--password|--tls-server-name|--request-timeout)(=|$)|^-n.|^-s.|^-A/;
|
|
213
|
+
const READ_KINDS = new Set(["deployment", "deployments", "deploy", "service", "services", "svc", "ingress", "ingresses", "ing", "pod", "pods", "po"]);
|
|
214
|
+
|
|
215
|
+
// Builds a safe argv. Throws on conflicting context/namespace/kubeconfig flags,
|
|
216
|
+
// all-namespace requests, secret access, or verbs outside the allow-list.
|
|
217
|
+
export function kubeArgs(profile, args) {
|
|
218
|
+
need(isObj(profile) && str(profile.context) && CONTEXT_RE.test(profile.context), "profile.context required");
|
|
219
|
+
validateNamespace(profile.namespace);
|
|
220
|
+
need(profile.kubeconfig == null || (str(profile.kubeconfig) && /^\/[^\0\n]*$/.test(profile.kubeconfig)), "profile.kubeconfig must be an absolute path");
|
|
221
|
+
need(Array.isArray(args) && args.length > 0 && args.every(str), "kubectl args must be a non-empty string array");
|
|
222
|
+
for (const a of args) {
|
|
223
|
+
need(!FORBIDDEN_FLAG.test(a), `forbidden kubectl flag: ${a}`);
|
|
224
|
+
need(!/secret/i.test(a), `secret access is not allowed: ${a}`);
|
|
225
|
+
}
|
|
226
|
+
const [verb, ...rest] = args;
|
|
227
|
+
const positional = rest.filter((a) => !a.startsWith("-"));
|
|
228
|
+
switch (verb) {
|
|
229
|
+
case "get": {
|
|
230
|
+
need(positional.length > 0, "get requires a resource");
|
|
231
|
+
const kinds = positional[0].split(",").map((k) => k.split("/")[0].toLowerCase());
|
|
232
|
+
need(kinds.every((k) => READ_KINDS.has(k)), `get of this resource is not allowed: ${positional[0]}`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "logs":
|
|
236
|
+
need(positional.length === 1, "logs takes exactly one target");
|
|
237
|
+
break;
|
|
238
|
+
case "rollout":
|
|
239
|
+
need(["status", "restart", "undo"].includes(rest[0]), "rollout supports status|restart|undo only");
|
|
240
|
+
break;
|
|
241
|
+
case "auth":
|
|
242
|
+
need(rest[0] === "can-i" && positional.length >= 3, "auth supports `can-i <verb> <resource>` only");
|
|
243
|
+
break;
|
|
244
|
+
case "apply": {
|
|
245
|
+
const ok = (rest.length === 2 || (rest.length === 3 && rest[2] === "--dry-run=server")) && rest[0] === "-f" && rest[1] === "-";
|
|
246
|
+
need(ok, "apply is limited to `apply -f -` with optional --dry-run=server");
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "delete":
|
|
250
|
+
need(positional.length === 2 && positional[0] === "pod" && !rest.includes("--all") && !rest.some((a) => /^(-l|--selector)/.test(a)),
|
|
251
|
+
"delete is limited to a single named pod");
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
throw new Error(`kubectl command not allowed: ${verb}`);
|
|
255
|
+
}
|
|
256
|
+
const kc = profile.kubeconfig ? ["--kubeconfig", profile.kubeconfig] : [];
|
|
257
|
+
return [...kc, "--context", profile.context, "--namespace", profile.namespace, ...args];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Runs kubectl with safe argv. Returns stdout; throws with trimmed stderr on failure.
|
|
261
|
+
export async function kubectl(profile, args, { run = defaultRun, input, inputFile, timeoutMs = 60000 } = {}) {
|
|
262
|
+
const argv = kubeArgs(profile, args);
|
|
263
|
+
const watch = args[0] === "logs" || (args[0] === "rollout" && args[1] === "status");
|
|
264
|
+
if (!watch) argv.push("--request-timeout=30s");
|
|
265
|
+
const res = await run("kubectl", argv, { input, inputFile, timeout: timeoutMs });
|
|
266
|
+
if (res.code !== 0) throw new Error(`kubectl ${args[0]} failed: ${String(res.stderr ?? "").trim() || `exit ${res.code}`}`);
|
|
267
|
+
return String(res.stdout ?? "");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Namespaced checks only; never throws on a failing check.
|
|
271
|
+
export async function preflight({ profile, run = defaultRun }) {
|
|
272
|
+
const checks = [];
|
|
273
|
+
const add = async (name, args) => {
|
|
274
|
+
try {
|
|
275
|
+
const out = (await kubectl(profile, args, { run })).trim();
|
|
276
|
+
checks.push({ name, ok: !args.includes("can-i") || out === "yes", detail: out.slice(0, 200) });
|
|
277
|
+
} catch (e) {
|
|
278
|
+
checks.push({ name, ok: false, detail: e.message });
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
await add("get deployments", ["auth", "can-i", "get", "deployments"]);
|
|
282
|
+
await add("create deployments", ["auth", "can-i", "create", "deployments"]);
|
|
283
|
+
await add("list deployments", ["get", "deployments", "-o", "name"]);
|
|
284
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const LOGS_WARNING =
|
|
288
|
+
"Logs may contain application secrets or personal data; they are shown as-is and NOT redacted.";
|
|
289
|
+
const MUTATIONS = new Set(["restart", "rollback"]);
|
|
290
|
+
export const OPERATIONS = ["status", "logs", "rollout-status", "restart", "rollback"];
|
|
291
|
+
|
|
292
|
+
// Operations: status, logs, rollout-status, restart, rollback. Mutations need yes (or dryRun).
|
|
293
|
+
// dryRun returns the plan without running anything.
|
|
294
|
+
export async function performKube({ profile, operation, app, tail = 100, yes = false, dryRun = false, run = defaultRun }) {
|
|
295
|
+
need(OPERATIONS.includes(operation), `unknown operation: ${operation}`);
|
|
296
|
+
validateName(app, "app name");
|
|
297
|
+
need(Number.isInteger(tail) && tail >= 1 && tail <= 5000, "tail must be an integer between 1 and 5000");
|
|
298
|
+
const target = `deployment/${app}`;
|
|
299
|
+
const argsByOp = {
|
|
300
|
+
status: ["get", "deployment", app, "-o", "wide"],
|
|
301
|
+
logs: ["logs", target, `--tail=${tail}`],
|
|
302
|
+
"rollout-status": ["rollout", "status", target, "--timeout=120s"],
|
|
303
|
+
restart: ["rollout", "restart", target],
|
|
304
|
+
rollback: ["rollout", "undo", target],
|
|
305
|
+
};
|
|
306
|
+
const args = argsByOp[operation];
|
|
307
|
+
const mutation = MUTATIONS.has(operation);
|
|
308
|
+
const argv = kubeArgs(profile, args);
|
|
309
|
+
if (dryRun) {
|
|
310
|
+
return { operation, app, mutation, dryRun: true, plan: [["kubectl", ...argv].join(" ")] };
|
|
311
|
+
}
|
|
312
|
+
need(!mutation || yes === true, `${operation} changes the cluster; confirm with --yes`);
|
|
313
|
+
const output = await kubectl(profile, args, { run, timeoutMs: operation === "rollout-status" ? 150000 : 60000 });
|
|
314
|
+
const result = { operation, app, mutation, dryRun: false, output };
|
|
315
|
+
if (operation === "logs") result.warning = LOGS_WARNING;
|
|
316
|
+
return result;
|
|
317
|
+
}
|
package/src/managed.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const MANAGED_VERSION = 5;
|
|
5
|
+
export const REVIEW_WORKFLOW = ".github/workflows/techbro-review.yml";
|
|
6
|
+
export const REPORT_ARTIFACT = "techbro-review-report";
|
|
7
|
+
|
|
8
|
+
const asset = (n) => readFileSync(new URL(`../assets/${n}`, import.meta.url), "utf8");
|
|
9
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
10
|
+
|
|
11
|
+
const FILES = [
|
|
12
|
+
{ path: REVIEW_WORKFLOW, src: "review-workflow.yml", c: "#", team: true },
|
|
13
|
+
{ path: ".github/actions/techbro/action.yml", src: "review-action.yml", c: "#", team: true },
|
|
14
|
+
{ path: ".github/techbro/review.mjs", src: "review.mjs", c: "//" },
|
|
15
|
+
{ path: ".github/techbro/rules/baseline.yml", src: "baseline-rules.yml", c: "#" },
|
|
16
|
+
{ path: ".github/techbro-review.json", src: "review-config.json", c: null, seed: true },
|
|
17
|
+
];
|
|
18
|
+
const CALLER = { path: REVIEW_WORKFLOW, src: "caller-workflow.yml", c: "#", team: true };
|
|
19
|
+
|
|
20
|
+
const HEADER_RE = /^(?:#|\/\/) techbro-managed v(\d+) sha256=([0-9a-f]{64})\n/;
|
|
21
|
+
const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
|
22
|
+
const REPO_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
23
|
+
|
|
24
|
+
function teamOf(params) {
|
|
25
|
+
const { owner, repo } = params ?? {};
|
|
26
|
+
if (typeof owner !== "string" || typeof repo !== "string" || !OWNER_RE.test(owner) || !REPO_RE.test(repo) || repo === "." || repo === "..") {
|
|
27
|
+
throw new Error("valid team { owner, repo } is required to render managed files");
|
|
28
|
+
}
|
|
29
|
+
return `${owner}/${repo}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function render(f, params) {
|
|
33
|
+
let body = asset(f.src);
|
|
34
|
+
if (f.team) body = body.replaceAll("__TEAM__", teamOf(params)).replaceAll("__VERSION__", String(MANAGED_VERSION));
|
|
35
|
+
return f.c ? `${f.c} techbro-managed v${MANAGED_VERSION} sha256=${sha(body)}\n${body}` : body;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function classify(f, current, params) {
|
|
39
|
+
if (current === null || current === undefined) return "missing";
|
|
40
|
+
if (current === render(f, params)) return "current";
|
|
41
|
+
if (!f.c) return "customized";
|
|
42
|
+
const m = HEADER_RE.exec(current);
|
|
43
|
+
if (!m) return "customized";
|
|
44
|
+
return sha(current.slice(m[0].length)) === m[2] ? "pristine-old" : "customized";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function managedFiles(params) {
|
|
48
|
+
return FILES.map((f) => ({ path: f.path, content: render(f, params), ...(f.seed ? { seed: true } : {}) }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function callerFile(params) {
|
|
52
|
+
return { path: CALLER.path, content: render(CALLER, params) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function classifyManaged(path, current, params) {
|
|
56
|
+
const f = FILES.find((x) => x.path === path);
|
|
57
|
+
if (!f) throw new Error(`not a managed file: ${path}`);
|
|
58
|
+
return classify(f, current, params);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function classifyCaller(current, params) {
|
|
62
|
+
return classify(CALLER, current, params);
|
|
63
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseRepoUrl } from "./github.js";
|
|
5
|
+
import { MANAGED_VERSION, callerFile, classifyCaller } from "./managed.js";
|
|
6
|
+
|
|
7
|
+
const BRANCH = "main";
|
|
8
|
+
const HINT = "main may be protected; allow the token or open a PR manually";
|
|
9
|
+
export const REVIEW_CONFIG = ".github/techbro-review.json";
|
|
10
|
+
const defaultGit = (args, cwd) => execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf8" });
|
|
11
|
+
|
|
12
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
13
|
+
const readJson = (f) => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(f, "utf8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function proposeConfig(cwd, meta) {
|
|
22
|
+
const pkg = readJson(join(cwd, "package.json"));
|
|
23
|
+
let test = null;
|
|
24
|
+
if (meta?.template === "_default" && pkg && existsSync(join(cwd, "Cargo.toml"))) {
|
|
25
|
+
test = { runtime: "rust-node", install: "npm --prefix apps/frontend ci", command: "npm run check", workdir: "." };
|
|
26
|
+
} else if (pkg?.scripts?.test) {
|
|
27
|
+
test = { runtime: "node", command: "npm test", workdir: "." };
|
|
28
|
+
if (existsSync(join(cwd, "package-lock.json"))) test.install = "npm ci";
|
|
29
|
+
}
|
|
30
|
+
if (!test) return null;
|
|
31
|
+
return { test, security: { failOn: "ERROR" }, sonar: { mode: "optional" }, duplication: { mode: "required", maxPercent: 10 } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function seedConfig({ client, to, tr, name, proposal, canPush }) {
|
|
35
|
+
if (!proposal) {
|
|
36
|
+
return {
|
|
37
|
+
status: "unrecognized",
|
|
38
|
+
warning: `project type not recognized; no test config seeded. Add projects.${name} with a "test" command to ${REVIEW_CONFIG} in ${to}/${tr} (see README)`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!canPush) {
|
|
42
|
+
return { status: "pending-admin", proposal, warning: `no write access to ${to}/${tr}; a team admin must add projects.${name} to ${REVIEW_CONFIG}` };
|
|
43
|
+
}
|
|
44
|
+
let file;
|
|
45
|
+
try {
|
|
46
|
+
file = await client.getFile(to, tr, REVIEW_CONFIG, BRANCH);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { status: "failed", proposal, warning: `could not read ${REVIEW_CONFIG}: ${err.message}; not modified` };
|
|
49
|
+
}
|
|
50
|
+
if (file && !isObj(file.json?.projects)) {
|
|
51
|
+
return { status: "skipped", proposal, warning: `${REVIEW_CONFIG} has no "projects" object; not modified` };
|
|
52
|
+
}
|
|
53
|
+
if (file && Object.hasOwn(file.json.projects, name)) return { status: "exists" };
|
|
54
|
+
const next = file ? structuredClone(file.json) : { schemaVersion: 1, projects: {} };
|
|
55
|
+
next.projects[name] = proposal;
|
|
56
|
+
try {
|
|
57
|
+
await client.putFile(to, tr, REVIEW_CONFIG, {
|
|
58
|
+
content: JSON.stringify(next, null, 2) + "\n",
|
|
59
|
+
message: `techbro: seed review config for ${name}`,
|
|
60
|
+
sha: file?.sha,
|
|
61
|
+
branch: BRANCH,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const hint = err.status === 409 || err.status === 422 ? "changed concurrently or protected; rerun project link" : HINT;
|
|
65
|
+
return { status: "failed", proposal, warning: `could not seed ${REVIEW_CONFIG}: ${err.message} (${hint})` };
|
|
66
|
+
}
|
|
67
|
+
return { status: "seeded", proposal };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const callerBranch = () => `techbro/review-caller-v${MANAGED_VERSION}`;
|
|
71
|
+
|
|
72
|
+
async function installCaller({ client, team, po, pr, proj }) {
|
|
73
|
+
const params = { owner: team.owner, repo: team.repo };
|
|
74
|
+
if (po.toLowerCase() !== team.owner.toLowerCase()) {
|
|
75
|
+
return { status: "unsupported", warning: `project ${po}/${pr} is outside the team organization ${team.owner}; the shared review workflow is same-organization only, no caller installed` };
|
|
76
|
+
}
|
|
77
|
+
const base = proj.defaultBranch;
|
|
78
|
+
const want = callerFile(params);
|
|
79
|
+
const cur = await client.getFileText(po, pr, want.path, base);
|
|
80
|
+
const kind = classifyCaller(cur, params);
|
|
81
|
+
if (kind === "current") return { status: "current" };
|
|
82
|
+
if (kind === "customized") return { status: "customized", warning: `${want.path} in ${po}/${pr} is customized; left unchanged (compare with the managed caller by hand)` };
|
|
83
|
+
if (!proj.permissions?.push) {
|
|
84
|
+
return { status: "pending-admin", warning: `no write access to ${po}/${pr}; a project writer must run techbro project link to open the ${want.path} PR` };
|
|
85
|
+
}
|
|
86
|
+
const branch = callerBranch();
|
|
87
|
+
try {
|
|
88
|
+
let head = await client.getBranchSha(po, pr, branch);
|
|
89
|
+
let note;
|
|
90
|
+
if (head === null) {
|
|
91
|
+
head = await client.getBranchSha(po, pr, base);
|
|
92
|
+
await client.createRef(po, pr, branch, head);
|
|
93
|
+
const commit = await client.commitFiles(po, pr, { baseSha: head, files: [want], message: `techbro: ${kind === "missing" ? "add" : "update"} review caller` });
|
|
94
|
+
await client.updateRef(po, pr, branch, commit);
|
|
95
|
+
} else if ((await client.getFileText(po, pr, want.path, branch)) !== want.content) {
|
|
96
|
+
note = `branch ${branch} already exists with different ${want.path}; not overwritten`;
|
|
97
|
+
}
|
|
98
|
+
const open = await client.listPulls(po, pr, { head: branch, base });
|
|
99
|
+
if (open.length) return { status: "pr-existing", prUrl: open[0].url, branch, ...(note && { warning: note }) };
|
|
100
|
+
const pull = await client.createPull(po, pr, {
|
|
101
|
+
title: "techbro: add review caller",
|
|
102
|
+
head: branch,
|
|
103
|
+
base,
|
|
104
|
+
body: `Adds the tiny \`${want.path}\` caller that runs the team's shared review workflow (${team.owner}/${team.repo}@main).\n\nNothing is pushed to ${base}; merge this PR to enable \`techbro project review\`.`,
|
|
105
|
+
});
|
|
106
|
+
return { status: "pr-created", prUrl: pull.url, branch, ...(note && { warning: note }) };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { status: "failed", warning: `could not open the review caller PR on ${po}/${pr}: ${err.message}` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function linkProject({ cwd, url, name, config, client, git = defaultGit, now = new Date() }) {
|
|
113
|
+
const teamName = config.currentTeam;
|
|
114
|
+
if (!teamName || !config.teams[teamName]) {
|
|
115
|
+
throw new Error("no current team; run techbro team create or team join first");
|
|
116
|
+
}
|
|
117
|
+
const { owner: to, repo: tr } = parseRepoUrl(config.teams[teamName].url);
|
|
118
|
+
|
|
119
|
+
if (!url) {
|
|
120
|
+
try {
|
|
121
|
+
url = git(["remote", "get-url", "origin"], cwd).trim();
|
|
122
|
+
} catch {
|
|
123
|
+
url = "";
|
|
124
|
+
}
|
|
125
|
+
if (!url) throw new Error("no origin remote; pass --url");
|
|
126
|
+
}
|
|
127
|
+
const { owner: po, repo: pr, url: normalized } = parseRepoUrl(url);
|
|
128
|
+
|
|
129
|
+
const { login } = await client.getUser();
|
|
130
|
+
const proj = await client.getRepo(po, pr);
|
|
131
|
+
if (!proj.permissions?.pull) throw new Error(`no read access to ${po}/${pr} as ${login}`);
|
|
132
|
+
|
|
133
|
+
const members = await client.getFile(to, tr, "members.json", BRANCH);
|
|
134
|
+
if (!members) throw new Error("team repo is not initialized; run team create");
|
|
135
|
+
if (!members.json?.members?.includes(login)) {
|
|
136
|
+
throw new Error(`${login} is not a member of team ${teamName}; run techbro team join`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const metaPath = join(cwd, ".techbro.json");
|
|
140
|
+
const meta = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, "utf8")) : null;
|
|
141
|
+
const entry = {
|
|
142
|
+
name: name ?? meta?.name ?? pr,
|
|
143
|
+
url: normalized,
|
|
144
|
+
template: meta?.template ?? null,
|
|
145
|
+
linkedBy: login,
|
|
146
|
+
linkedAt: now.toISOString(),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const file = await client.getFile(to, tr, "projects.json", BRANCH);
|
|
150
|
+
if (!file) throw new Error("team repo is not initialized; run team create");
|
|
151
|
+
const list = structuredClone(file.json);
|
|
152
|
+
if (!list || !Array.isArray(list.projects)) throw new Error(`projects.json in ${to}/${tr} is malformed`);
|
|
153
|
+
|
|
154
|
+
let status = "linked";
|
|
155
|
+
let issueUrl;
|
|
156
|
+
const teamRepo = await client.getRepo(to, tr);
|
|
157
|
+
const registered = list.projects.find((p) => p.url === normalized);
|
|
158
|
+
if (!registered) {
|
|
159
|
+
if (teamRepo.permissions?.push) {
|
|
160
|
+
list.projects.push(entry);
|
|
161
|
+
try {
|
|
162
|
+
await client.putFile(to, tr, "projects.json", {
|
|
163
|
+
content: JSON.stringify(list, null, 2) + "\n",
|
|
164
|
+
message: `techbro: link project ${entry.name}`,
|
|
165
|
+
sha: file.sha,
|
|
166
|
+
branch: BRANCH,
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err.status === 409 || err.status === 422 || /protect/i.test(err.message)) throw new Error(`${err.message}\n${HINT}`);
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
status = "added";
|
|
173
|
+
} else {
|
|
174
|
+
if (!teamRepo.hasIssues) throw new Error(`issues are disabled on ${to}/${tr}; ask the owner to enable them or add it manually`);
|
|
175
|
+
const open = await client.listIssues(to, tr, { labels: "techbro-link", state: "open", creator: login });
|
|
176
|
+
issueUrl = open[0]?.url;
|
|
177
|
+
if (!issueUrl) {
|
|
178
|
+
issueUrl = (
|
|
179
|
+
await client.createIssue(to, tr, {
|
|
180
|
+
title: "techbro: link request",
|
|
181
|
+
body: JSON.stringify({ schemaVersion: 1, action: "link", login, project: entry }),
|
|
182
|
+
labels: ["techbro-link"],
|
|
183
|
+
})
|
|
184
|
+
).url;
|
|
185
|
+
}
|
|
186
|
+
status = "pending";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (meta) {
|
|
191
|
+
writeFileSync(metaPath, JSON.stringify({ ...meta, team: teamName, repo: normalized }, null, 2) + "\n");
|
|
192
|
+
}
|
|
193
|
+
const cfgName = registered?.name ?? entry.name;
|
|
194
|
+
const reviewConfig = await seedConfig({
|
|
195
|
+
client, to, tr, name: cfgName, proposal: proposeConfig(cwd, meta), canPush: teamRepo.permissions?.push === true,
|
|
196
|
+
});
|
|
197
|
+
const caller =
|
|
198
|
+
status === "pending"
|
|
199
|
+
? { status: "deferred", warning: "review caller not installed yet; rerun techbro project link once the project is registered" }
|
|
200
|
+
: await installCaller({ client, team: { owner: to, repo: tr }, po, pr, proj });
|
|
201
|
+
return { name: entry.name, url: normalized, team: teamName, status, reviewConfig, caller, ...(issueUrl && { issueUrl }) };
|
|
202
|
+
}
|