@rtrentjones/greenlight 0.2.23 → 0.2.24
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/dist/bin.js +122 -15
- package/dist/{chunk-TFWXR7PP.js → chunk-2A7ZBBYN.js} +100 -0
- package/dist/index.js +1 -1
- package/package.json +5 -5
package/dist/bin.js
CHANGED
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
allPass,
|
|
5
5
|
loadConfig,
|
|
6
6
|
resolveUrl,
|
|
7
|
+
scanSqlFiles,
|
|
7
8
|
verifyAll
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-2A7ZBBYN.js";
|
|
9
10
|
import "./chunk-HX7VA25D.js";
|
|
10
11
|
import "./chunk-N3IKUCSF.js";
|
|
11
12
|
import "./chunk-KP3Y6WRU.js";
|
|
@@ -443,7 +444,7 @@ function tokensForTool(tool) {
|
|
|
443
444
|
}
|
|
444
445
|
|
|
445
446
|
// src/version.ts
|
|
446
|
-
var MODULE_REF = "v0.2.
|
|
447
|
+
var MODULE_REF = "v0.2.24";
|
|
447
448
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
448
449
|
function moduleSource(module, ref = MODULE_REF) {
|
|
449
450
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -777,10 +778,22 @@ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
|
|
|
777
778
|
async function agentCommand(args) {
|
|
778
779
|
if (args[0] !== "sync") {
|
|
779
780
|
console.log(
|
|
780
|
-
"usage: greenlight agent sync
|
|
781
|
+
"usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
|
|
781
782
|
);
|
|
782
783
|
process.exit(args[0] ? 1 : 0);
|
|
783
784
|
}
|
|
785
|
+
const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
|
|
786
|
+
if (name) {
|
|
787
|
+
const { config } = await loadManifest();
|
|
788
|
+
const entry = resolveEntry(config, name);
|
|
789
|
+
const dir = resolve3(process.cwd(), entry.dir ?? ".");
|
|
790
|
+
materializeAgentKit(dir, { target: entry.target, data: entry.data });
|
|
791
|
+
console.log(
|
|
792
|
+
`
|
|
793
|
+
Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (target=${entry.target}, data=${entry.data}).`
|
|
794
|
+
);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
784
797
|
materializeAgentKit(process.cwd());
|
|
785
798
|
console.log(
|
|
786
799
|
"\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
|
|
@@ -1992,6 +2005,7 @@ async function deployCommand(args) {
|
|
|
1992
2005
|
|
|
1993
2006
|
// src/commands/doctor.ts
|
|
1994
2007
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
2008
|
+
import { lookup } from "dns/promises";
|
|
1995
2009
|
import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
|
|
1996
2010
|
import { join as join4 } from "path";
|
|
1997
2011
|
function dirCheck(label, dir) {
|
|
@@ -2120,19 +2134,52 @@ function runDoctor(config, root) {
|
|
|
2120
2134
|
});
|
|
2121
2135
|
checks.push(versionDriftCheck(root));
|
|
2122
2136
|
checks.push(submoduleDriftCheck(root));
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
"
|
|
2129
|
-
|
|
2130
|
-
|
|
2137
|
+
return checks;
|
|
2138
|
+
}
|
|
2139
|
+
var errMsg = (e) => e instanceof Error ? e.message : String(e);
|
|
2140
|
+
async function livenessCheck(name, url) {
|
|
2141
|
+
try {
|
|
2142
|
+
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(1e4) });
|
|
2143
|
+
return res.status >= 500 ? { name: `${name}: live`, status: "warn", detail: `${url} \u2192 ${res.status} (degraded)` } : { name: `${name}: live`, status: "ok", detail: `${url} \u2192 ${res.status}` };
|
|
2144
|
+
} catch (e) {
|
|
2145
|
+
return { name: `${name}: live`, status: "fail", detail: `${url} unreachable: ${errMsg(e)}` };
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
async function dnsCheck(name, url) {
|
|
2149
|
+
const host = new URL(url).hostname;
|
|
2150
|
+
try {
|
|
2151
|
+
await lookup(host);
|
|
2152
|
+
return { name: `${name}: DNS`, status: "ok", detail: host };
|
|
2153
|
+
} catch {
|
|
2154
|
+
return { name: `${name}: DNS`, status: "fail", detail: `${host} does not resolve` };
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async function runDoctorLive(config) {
|
|
2158
|
+
const targets = [];
|
|
2159
|
+
if (config.blog) targets.push({ name: "blog", url: `https://${config.domain}` });
|
|
2160
|
+
for (const t of config.tools) {
|
|
2161
|
+
if (!t.envs?.includes("prod")) continue;
|
|
2162
|
+
targets.push({
|
|
2163
|
+
name: t.name,
|
|
2164
|
+
url: resolveUrl({ domain: config.domain, name: t.name, env: "prod", mcp: t.lane === "mcp" })
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
const checks = [];
|
|
2168
|
+
for (const tg of targets) {
|
|
2169
|
+
checks.push(await dnsCheck(tg.name, tg.url));
|
|
2170
|
+
checks.push(await livenessCheck(tg.name, tg.url));
|
|
2171
|
+
}
|
|
2172
|
+
for (const name of ["terraform drift", "Vercel cap headroom", "OCI PAYG status"]) {
|
|
2173
|
+
checks.push({
|
|
2174
|
+
name,
|
|
2175
|
+
status: "skip",
|
|
2176
|
+
detail: "not yet implemented \u2014 needs the provider API + creds"
|
|
2177
|
+
});
|
|
2131
2178
|
}
|
|
2132
2179
|
return checks;
|
|
2133
2180
|
}
|
|
2134
2181
|
var ICON = { ok: "\u2714", warn: "!", fail: "\u2718", skip: "\xB7" };
|
|
2135
|
-
async function doctorCommand() {
|
|
2182
|
+
async function doctorCommand(args = []) {
|
|
2136
2183
|
let config;
|
|
2137
2184
|
try {
|
|
2138
2185
|
({ config } = await loadManifest());
|
|
@@ -2141,13 +2188,19 @@ async function doctorCommand() {
|
|
|
2141
2188
|
console.error(`\u2718 manifest: ${e instanceof Error ? e.message : String(e)}`);
|
|
2142
2189
|
process.exit(1);
|
|
2143
2190
|
}
|
|
2191
|
+
const live = args.includes("--live");
|
|
2144
2192
|
const checks = runDoctor(config, process.cwd());
|
|
2193
|
+
if (live) {
|
|
2194
|
+
console.log(" (probing live prod URLs\u2026)");
|
|
2195
|
+
checks.push(...await runDoctorLive(config));
|
|
2196
|
+
}
|
|
2145
2197
|
for (const c of checks) {
|
|
2146
2198
|
console.log(` ${ICON[c.status]} ${c.name}${c.detail ? ` \u2014 ${c.detail}` : ""}`);
|
|
2147
2199
|
}
|
|
2148
2200
|
const failed = checks.filter((c) => c.status === "fail").length;
|
|
2149
2201
|
console.log(`
|
|
2150
2202
|
${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
|
|
2203
|
+
if (!live) console.log("\xB7 run `greenlight doctor --live` for DNS + reachability probes");
|
|
2151
2204
|
process.exit(failed === 0 ? 0 : 1);
|
|
2152
2205
|
}
|
|
2153
2206
|
|
|
@@ -2403,6 +2456,57 @@ Next:
|
|
|
2403
2456
|
4. greenlight verify <name> --env prod | greenlight doctor`);
|
|
2404
2457
|
}
|
|
2405
2458
|
|
|
2459
|
+
// src/commands/migrations.ts
|
|
2460
|
+
import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
|
|
2461
|
+
import { join as join5 } from "path";
|
|
2462
|
+
var DEFAULT_DIR = "supabase/migrations";
|
|
2463
|
+
async function migrationsCommand(args) {
|
|
2464
|
+
if (args[0] !== "scan") {
|
|
2465
|
+
console.log(
|
|
2466
|
+
`usage: greenlight migrations scan [<dir>] [--strict]
|
|
2467
|
+
scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
|
|
2468
|
+
default dir: ${DEFAULT_DIR}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2469
|
+
);
|
|
2470
|
+
process.exit(args[0] ? 1 : 0);
|
|
2471
|
+
}
|
|
2472
|
+
const dir = args.slice(1).find((a) => !a.startsWith("-")) ?? DEFAULT_DIR;
|
|
2473
|
+
const strict = args.includes("--strict");
|
|
2474
|
+
let names;
|
|
2475
|
+
try {
|
|
2476
|
+
names = readdirSync3(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
2477
|
+
} catch {
|
|
2478
|
+
console.log(`\xB7 no migrations dir at ${dir} \u2014 nothing to scan`);
|
|
2479
|
+
process.exit(0);
|
|
2480
|
+
}
|
|
2481
|
+
if (names.length === 0) {
|
|
2482
|
+
console.log(`\xB7 no .sql files in ${dir} \u2014 nothing to scan`);
|
|
2483
|
+
process.exit(0);
|
|
2484
|
+
}
|
|
2485
|
+
const files = names.map((f) => ({
|
|
2486
|
+
path: join5(dir, f),
|
|
2487
|
+
content: readFileSync7(join5(dir, f), "utf8")
|
|
2488
|
+
}));
|
|
2489
|
+
const findings = scanSqlFiles(files);
|
|
2490
|
+
if (findings.length === 0) {
|
|
2491
|
+
console.log(`\u2714 migrations scan: ${names.length} file(s) clean (${dir})`);
|
|
2492
|
+
process.exit(0);
|
|
2493
|
+
}
|
|
2494
|
+
for (const f of findings) {
|
|
2495
|
+
console.log(
|
|
2496
|
+
` ${f.severity === "danger" ? "\u2718" : "!"} ${f.file}:${f.line} [${f.rule}] ${f.detail}
|
|
2497
|
+
${f.snippet}`
|
|
2498
|
+
);
|
|
2499
|
+
}
|
|
2500
|
+
const dangers = findings.filter((f) => f.severity === "danger");
|
|
2501
|
+
const blocking = strict ? findings : dangers;
|
|
2502
|
+
const verdict = blocking.length === 0 ? "\u2714 no blocking findings" : `\u2718 ${blocking.length} blocking finding(s)`;
|
|
2503
|
+
console.log(
|
|
2504
|
+
`
|
|
2505
|
+
${verdict} (${dangers.length} danger, ${findings.length - dangers.length} warn). Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2506
|
+
);
|
|
2507
|
+
process.exit(blocking.length === 0 ? 0 : 1);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2406
2510
|
// src/commands/preview.ts
|
|
2407
2511
|
import { execFileSync as execFileSync5, spawn } from "child_process";
|
|
2408
2512
|
import { resolve as resolve10 } from "path";
|
|
@@ -2900,9 +3004,10 @@ var HELP = `greenlight <command>
|
|
|
2900
3004
|
status <name> last ship/deploy/verify run for a tool (via gh)
|
|
2901
3005
|
secrets gather <name> [--repo o/r] [--env e] guided, link-first token prompts -> GitHub secrets (no disk/logs)
|
|
2902
3006
|
secrets sync [--repo o/r] [--env <env>] push .greenlight/secrets.env -> GitHub Actions secrets
|
|
2903
|
-
agent sync
|
|
3007
|
+
agent sync [<name>] write the loop kit (named \u2192 tool-aware, into its dir)
|
|
2904
3008
|
adopt <name> --repo <path> --lane --target onboard an existing tool repo as a thin consumer
|
|
2905
|
-
|
|
3009
|
+
migrations scan [<dir>] [--strict] dangerous-SQL gate for migrations (pre-apply)
|
|
3010
|
+
doctor [--live] consistency checks (--live: DNS + reachability probes)
|
|
2906
3011
|
help show this message
|
|
2907
3012
|
|
|
2908
3013
|
Real cloud deploys need the target's creds (e.g. CLOUDFLARE_API_TOKEN); see docs/archive/greenlight-v1.md \xA716.`;
|
|
@@ -2937,8 +3042,10 @@ async function main() {
|
|
|
2937
3042
|
return agentCommand(args);
|
|
2938
3043
|
case "adopt":
|
|
2939
3044
|
return adoptCommand(args);
|
|
3045
|
+
case "migrations":
|
|
3046
|
+
return migrationsCommand(args);
|
|
2940
3047
|
case "doctor":
|
|
2941
|
-
return doctorCommand();
|
|
3048
|
+
return doctorCommand(args);
|
|
2942
3049
|
default:
|
|
2943
3050
|
throw new Error(`Unknown command "${cmd}".
|
|
2944
3051
|
|
|
@@ -131,6 +131,105 @@ function resolveUrl({ domain, name, env, mcp }) {
|
|
|
131
131
|
return `https://${host}${mcp ? "/mcp" : ""}`;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// ../packages/shared/src/sql-scan.ts
|
|
135
|
+
var RULES = [
|
|
136
|
+
{
|
|
137
|
+
name: "drop-table",
|
|
138
|
+
severity: "danger",
|
|
139
|
+
detail: "DROP TABLE destroys a table and its data",
|
|
140
|
+
test: /\bDROP\s+TABLE\b/i
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "drop-column",
|
|
144
|
+
severity: "danger",
|
|
145
|
+
detail: "DROP COLUMN destroys a column and its data",
|
|
146
|
+
test: /\bDROP\s+COLUMN\b/i
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "drop-schema",
|
|
150
|
+
severity: "danger",
|
|
151
|
+
detail: "DROP SCHEMA destroys a schema",
|
|
152
|
+
test: /\bDROP\s+SCHEMA\b/i
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "drop-database",
|
|
156
|
+
severity: "danger",
|
|
157
|
+
detail: "DROP DATABASE destroys a database",
|
|
158
|
+
test: /\bDROP\s+DATABASE\b/i
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "truncate",
|
|
162
|
+
severity: "danger",
|
|
163
|
+
detail: "TRUNCATE empties a table irreversibly",
|
|
164
|
+
test: /\bTRUNCATE\b/i
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "delete-without-where",
|
|
168
|
+
severity: "danger",
|
|
169
|
+
detail: "DELETE without WHERE removes every row",
|
|
170
|
+
test: /\bDELETE\s+FROM\b(?![\s\S]*\bWHERE\b)/i
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "update-without-where",
|
|
174
|
+
severity: "danger",
|
|
175
|
+
detail: "UPDATE \u2026 SET without WHERE rewrites every row",
|
|
176
|
+
test: /\bUPDATE\s+[^\s;]+\s+SET\b(?![\s\S]*\bWHERE\b)/i
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "non-concurrent-index",
|
|
180
|
+
severity: "warn",
|
|
181
|
+
detail: "CREATE INDEX without CONCURRENTLY locks writes (fine on a new/empty table)",
|
|
182
|
+
test: /\bCREATE\s+(?:UNIQUE\s+)?INDEX\b(?![\s\S]*\bCONCURRENTLY\b)/i
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "alter-column-type",
|
|
186
|
+
severity: "warn",
|
|
187
|
+
detail: "ALTER COLUMN \u2026 TYPE can rewrite + lock the table",
|
|
188
|
+
test: /\bALTER\s+COLUMN\b[\s\S]*?\bTYPE\b/i
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
var ALLOW = /greenlight:\s*allow/i;
|
|
192
|
+
function scanSql(content, file = "<sql>") {
|
|
193
|
+
const allowLines = /* @__PURE__ */ new Set();
|
|
194
|
+
content.split("\n").forEach((ln, i) => {
|
|
195
|
+
if (ALLOW.test(ln)) allowLines.add(i + 1);
|
|
196
|
+
});
|
|
197
|
+
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/--[^\n]*/g, (m) => " ".repeat(m.length));
|
|
198
|
+
const findings = [];
|
|
199
|
+
let pos = 0;
|
|
200
|
+
for (const stmt of stripped.split(";")) {
|
|
201
|
+
const lead = stmt.length - stmt.trimStart().length;
|
|
202
|
+
const startLine = content.slice(0, pos + lead).split("\n").length;
|
|
203
|
+
const endLine = content.slice(0, pos + stmt.length).split("\n").length;
|
|
204
|
+
pos += stmt.length + 1;
|
|
205
|
+
if (!stmt.trim()) continue;
|
|
206
|
+
let allowed = false;
|
|
207
|
+
for (let l = startLine; l <= endLine; l++) {
|
|
208
|
+
if (allowLines.has(l)) {
|
|
209
|
+
allowed = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (allowed) continue;
|
|
214
|
+
for (const rule of RULES) {
|
|
215
|
+
if (rule.test.test(stmt)) {
|
|
216
|
+
findings.push({
|
|
217
|
+
file,
|
|
218
|
+
line: startLine,
|
|
219
|
+
rule: rule.name,
|
|
220
|
+
severity: rule.severity,
|
|
221
|
+
detail: rule.detail,
|
|
222
|
+
snippet: stmt.replace(/\s+/g, " ").trim().slice(0, 100)
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return findings;
|
|
228
|
+
}
|
|
229
|
+
function scanSqlFiles(files) {
|
|
230
|
+
return files.flatMap((f) => scanSql(f.content, f.path));
|
|
231
|
+
}
|
|
232
|
+
|
|
134
233
|
// ../packages/verify/src/index.ts
|
|
135
234
|
import { setTimeout as sleep } from "timers/promises";
|
|
136
235
|
|
|
@@ -339,6 +438,7 @@ export {
|
|
|
339
438
|
defineConfig,
|
|
340
439
|
loadConfig,
|
|
341
440
|
resolveUrl,
|
|
441
|
+
scanSqlFiles,
|
|
342
442
|
defineVerify,
|
|
343
443
|
verifyAll,
|
|
344
444
|
allPass
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"description": "Greenlight CLI — setup and lifecycle for the harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"@anthropic-ai/sdk": "^0.69.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@rtrentjones/greenlight-
|
|
35
|
-
"@rtrentjones/greenlight-
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
34
|
+
"@rtrentjones/greenlight-adapters": "0.2.24",
|
|
35
|
+
"@rtrentjones/greenlight-loop": "0.2.24",
|
|
36
|
+
"@rtrentjones/greenlight-verify": "0.2.24",
|
|
37
|
+
"@rtrentjones/greenlight-shared": "0.2.24"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node scripts/copy-assets.mjs && tsup",
|