Skip to content

Commit 3b5f87b

Browse files
committed
Fix: Vercel and resend triggers
1 parent 5ed44e2 commit 3b5f87b

9 files changed

Lines changed: 249 additions & 80 deletions

File tree

src/catalogs/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export function validateStep(
169169
.map((a) => a.flag);
170170
for (const flag of requiredFlags) {
171171
const present = step.args.some(
172-
(a) => a === flag || a.startsWith(flag + "=") || a.startsWith(flag + " "),
172+
(a) => a === flag || a.startsWith(flag + "="),
173173
);
174174
if (!present) {
175175
return {

src/catalogs/resend/index.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,9 @@ const resendCommands: readonly CommandDef[] = [
55
name: "emails",
66
subcommands: ["send", "get", "list"],
77
args: [
8-
{ flag: "--from", required: true, valueHint: "onboarding@resend.dev" },
9-
{
10-
flag: "--to",
11-
required: true,
12-
multiple: true,
13-
valueHint: "user@example.com",
14-
},
15-
{ flag: "--subject", required: true },
8+
{ flag: "--from", valueHint: "onboarding@resend.dev" },
9+
{ flag: "--to", multiple: true, valueHint: "user@example.com" },
10+
{ flag: "--subject" },
1611
{ flag: "--html", conflictsWith: "--text" },
1712
{ flag: "--text", conflictsWith: "--html" },
1813
{ flag: "--reply-to", valueHint: "reply@example.com" },
@@ -49,9 +44,9 @@ const resendCommands: readonly CommandDef[] = [
4944
name: "broadcasts",
5045
subcommands: ["create", "get", "list", "send", "delete"],
5146
args: [
52-
{ flag: "--name", required: true },
53-
{ flag: "--from", required: true, valueHint: "onboarding@resend.dev" },
54-
{ flag: "--subject", required: true },
47+
{ flag: "--name" },
48+
{ flag: "--from", valueHint: "onboarding@resend.dev" },
49+
{ flag: "--subject" },
5550
{ flag: "--html" },
5651
{ flag: "--text" },
5752
{ flag: "--json", default: true },
@@ -115,14 +110,12 @@ export const resendCatalog: CatalogModule = {
115110
commands: resendCommands,
116111
detectors: [],
117112
triggers: [
118-
"email",
119113
"resend",
120-
"broadcast",
121-
"audience",
122-
"contact",
123-
"webhook",
124-
"template",
125-
"domain",
114+
"send email",
115+
"broadcast email",
116+
"email audience",
117+
"email webhook",
118+
"email template",
126119
],
127120
typeEnum: ["resend"],
128121
buildPrompt() {
@@ -146,6 +139,8 @@ export const resendCatalog: CatalogModule = {
146139
- Everything after the top-level command goes into args[] as separate tokens.
147140
- Never include secrets in args[] (no API keys, tokens, passwords). Auth comes from env vars or prior login.
148141
- Prefer --json flag for non-interactive/automation output.
142+
- For "emails send": --from, --to, and --subject are required. Include --html or --text (not both).
143+
- For "broadcasts create/send": --name, --from, and --subject are required for create; only broadcast id needed for send.
149144
- Do not combine tokens. Example: Good: ["--subject", "Hello world"] Bad: ["--subject Hello world"]
150145
- If a value contains spaces, keep it as a single element: "Hello world".`,
151146
};

src/catalogs/vercel/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,9 @@ export const vercelCatalog: CatalogModule = {
9999
commands: vercelCommands,
100100
detectors: ["vercel.json", ".vercel"],
101101
triggers: [
102-
"deploy",
103102
"vercel",
104-
"serverless",
105-
"preview",
106-
"production",
103+
"vercel deploy",
104+
"serverless function",
107105
"env var",
108106
"env vars",
109107
],

src/guardrails.ts

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,47 +9,40 @@ const SECRET_PATTERNS = [
99
/--token\b/i,
1010
/--password\b/i,
1111
/--secret\b/i,
12-
/--key\b/i,
12+
/^--key$/i,
1313
/\bsk-[a-zA-Z0-9]{20,}\b/, // OpenAI-style keys
1414
/\bre_[a-zA-Z0-9]{20,}\b/, // Resend-style keys
1515
/\bghp_[a-zA-Z0-9]{30,}\b/, // GitHub PATs
1616
/\bxox[bpas]-[a-zA-Z0-9-]+\b/, // Slack tokens
1717
];
1818

19-
const DANGEROUS_KEYWORDS = [
20-
/\bdeploy\b/i,
21-
/\bpublish\b/i,
22-
/\bsend\b/i,
23-
/\bdelete\b/i,
24-
/\bremove\b/i,
25-
/\bdestroy\b/i,
26-
/\bpush\b.*\b--force\b/i,
27-
/\bdrop\b/i,
28-
/\bpurge\b/i,
29-
/\boverwrite\b/i,
30-
];
31-
3219
const MAX_ARG_LENGTH = 500;
3320
const SHORT_FLAG_PATTERN = /^-[a-zA-Z0-9]{1,3}$/;
3421

22+
export interface GuardrailError {
23+
stepId: number;
24+
message: string;
25+
}
26+
3527
export interface GuardrailResult {
3628
ok: boolean;
37-
errors: string[];
29+
errors: GuardrailError[];
3830
warnings: string[];
3931
}
4032

4133
export function applyGuardrails(steps: Step[]): GuardrailResult {
42-
const errors: string[] = [];
34+
const errors: GuardrailError[] = [];
4335
const warnings: string[] = [];
4436

4537
for (const step of steps) {
4638
// 1. No secrets in args
4739
for (const arg of step.args) {
4840
for (const pattern of SECRET_PATTERNS) {
4941
if (pattern.test(arg)) {
50-
errors.push(
51-
`Step ${step.id}: potential secret detected in args ("${arg.slice(0, 30)}...") - secrets should come from env vars or prior login`,
52-
);
42+
errors.push({
43+
stepId: step.id,
44+
message: `potential secret detected in args ("${arg.slice(0, 30)}...") - secrets should come from env vars or prior login`,
45+
});
5346
}
5447
}
5548
}
@@ -62,9 +55,10 @@ export function applyGuardrails(steps: Step[]): GuardrailResult {
6255
!arg.startsWith("--") &&
6356
!SHORT_FLAG_PATTERN.test(arg)
6457
) {
65-
errors.push(
66-
`Step ${step.id}: malformed single-dash flag "${arg}" - use short flags like -m or long flags like --message`,
67-
);
58+
errors.push({
59+
stepId: step.id,
60+
message: `malformed single-dash flag "${arg}" - use short flags like -m or long flags like --message`,
61+
});
6862
}
6963
// No giant inline blobs
7064
if (arg.length > MAX_ARG_LENGTH) {
@@ -73,17 +67,6 @@ export function applyGuardrails(steps: Step[]): GuardrailResult {
7367
);
7468
}
7569
}
76-
77-
// 3. High-impact confirmation
78-
const cmdStr = `${step.command} ${step.args.join(" ")}`.toLowerCase();
79-
for (const pattern of DANGEROUS_KEYWORDS) {
80-
if (pattern.test(cmdStr)) {
81-
warnings.push(
82-
`Step ${step.id}: potentially destructive action ("${step.command}") - review carefully`,
83-
);
84-
break; // one warning per step is enough
85-
}
86-
}
8770
}
8871

8972
return {

src/planner.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -139,21 +139,10 @@ function findGuardrailFailure(plan: Plan): { failure?: StepFailure; warnings: st
139139
if (guardrailResult.ok) return { warnings: guardrailResult.warnings };
140140

141141
const firstError = guardrailResult.errors[0];
142-
const match = firstError.match(/^Step\s+(\d+):\s*(.+)$/i);
143-
if (!match) {
144-
return {
145-
failure: {
146-
stepId: plan.steps[0]?.id ?? 1,
147-
reason: firstError,
148-
},
149-
warnings: guardrailResult.warnings,
150-
};
151-
}
152-
153142
return {
154143
failure: {
155-
stepId: Number(match[1]),
156-
reason: match[2],
144+
stepId: firstError.stepId,
145+
reason: firstError.message,
157146
},
158147
warnings: guardrailResult.warnings,
159148
};
@@ -477,7 +466,3 @@ export async function generatePlan(
477466
};
478467
}
479468

480-
481-
482-
483-

tests/catalogs-detection.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
2+
import { tmpdir } from "os";
3+
import { join } from "path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { detectCatalogs } from "../src/catalogs/index.js";
6+
7+
const tempDirs: string[] = [];
8+
9+
function makeTempDir(): string {
10+
const dir = mkdtempSync(join(tmpdir(), "json-cli-catalogs-"));
11+
tempDirs.push(dir);
12+
return dir;
13+
}
14+
15+
afterEach(() => {
16+
while (tempDirs.length > 0) {
17+
const dir = tempDirs.pop();
18+
if (dir) rmSync(dir, { recursive: true, force: true });
19+
}
20+
});
21+
22+
describe("catalog auto-detection", () => {
23+
it("detects trigger-based catalogs without --catalogs", () => {
24+
const cwd = makeTempDir();
25+
const catalogs = detectCatalogs(
26+
cwd,
27+
undefined,
28+
"deploy to vercel production then send email",
29+
).map((c) => c.name);
30+
31+
expect(catalogs).toContain("vercel");
32+
expect(catalogs).toContain("resend");
33+
expect(catalogs).toContain("fs");
34+
expect(catalogs).toContain("shell");
35+
});
36+
37+
it("detects file-based catalogs from project structure", () => {
38+
const cwd = makeTempDir();
39+
writeFileSync(join(cwd, "package.json"), '{"name":"tmp"}');
40+
mkdirSync(join(cwd, ".git"));
41+
writeFileSync(join(cwd, "vercel.json"), "{}");
42+
43+
const catalogs = detectCatalogs(cwd).map((c) => c.name);
44+
expect(catalogs).toContain("package");
45+
expect(catalogs).toContain("git");
46+
expect(catalogs).toContain("vercel");
47+
});
48+
49+
it("uses forced catalogs only when --catalogs is provided", () => {
50+
const cwd = makeTempDir();
51+
const catalogs = detectCatalogs(
52+
cwd,
53+
["resend"],
54+
"deploy to vercel and send email",
55+
).map((c) => c.name);
56+
57+
expect(catalogs).toEqual(["resend"]);
58+
});
59+
60+
it("throws on unknown forced catalog", () => {
61+
const cwd = makeTempDir();
62+
expect(() => detectCatalogs(cwd, ["unknown"])).toThrow(
63+
'Unknown catalog "unknown"',
64+
);
65+
});
66+
});

tests/guardrails.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("applyGuardrails flag checks", () => {
3232

3333
const result = applyGuardrails(steps);
3434
expect(result.ok).toBe(false);
35-
expect(result.errors[0]).toContain("malformed single-dash flag");
35+
expect(result.errors[0].stepId).toBe(1);
36+
expect(result.errors[0].message).toContain("malformed single-dash flag");
3637
});
3738
});

tests/resend.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,18 @@ describe("resend catalog validation", () => {
3232
expect(result.valid).toBe(true);
3333
});
3434

35-
it("rejects resend emails send step missing required flags", () => {
35+
it("accepts resend emails list step without send-specific flags", () => {
3636
const step: Step = {
3737
id: 1,
3838
type: "resend",
3939
command: "emails",
40-
args: ["send", "--from", "no-reply@support.com", "--to", "ekaone@gmail.com"],
41-
description: "Missing subject",
40+
args: ["list", "--json"],
41+
description: "List emails",
4242
cwd: null,
4343
};
4444

4545
const result = validateStep(step, catalogMap);
46-
expect(result.valid).toBe(false);
47-
expect(result.reason).toContain("Missing required flag");
48-
expect(result.reason).toContain("--subject");
46+
expect(result.valid).toBe(true);
4947
});
5048

5149
it("rejects conflicting --html and --text flags", () => {

0 commit comments

Comments
 (0)