Skip to content

Commit b695aac

Browse files
committed
Merge branch 'codex/bugs-gh-266-apply-args'
2 parents f87be73 + 54933f8 commit b695aac

2 files changed

Lines changed: 189 additions & 13 deletions

File tree

cli-e2e.test.mjs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,6 +2215,98 @@ describe("CLI e2e happy path", () => {
22152215
return { threw, stderr: capturedStderr(), stdout: capturedStdout(), deployCalled, planBodies };
22162216
}
22172217

2218+
function nonEmptyDeploySpec() {
2219+
return JSON.stringify({ site: { replace: { "index.html": { data: "ok" } } } });
2220+
}
2221+
2222+
async function writeDeployManifest(name, spec = JSON.parse(nonEmptyDeploySpec())) {
2223+
const { writeFileSync: wf } = await import("node:fs");
2224+
const manifestPath = join(tempDir, name);
2225+
wf(manifestPath, JSON.stringify(spec));
2226+
return manifestPath;
2227+
}
2228+
2229+
async function assertDeployApplyBadUsage(args, messagePattern) {
2230+
const { threw, stderr, deployCalled } = await deployApplyAndCapture(args);
2231+
assert.ok(threw && /process\.exit\(1\)/.test(threw.message),
2232+
`should exit non-zero, got: ${threw && threw.message}`);
2233+
assert.equal(deployCalled, false, "must not POST to /deploy/v2/plans on bad deploy apply usage");
2234+
const parsed = parseStderrEnvelope(stderr);
2235+
assert.equal(parsed.code, "BAD_USAGE");
2236+
assert.match(parsed.message, messagePattern);
2237+
}
2238+
2239+
// ── GH-266/GH-268: deploy apply argument/source validation ──
2240+
it("deploy apply rejects unknown flags (GH-266)", async () => {
2241+
await assertDeployApplyBadUsage(
2242+
["--spec", nonEmptyDeploySpec(), "--project", "prj_test123", "--allow-warning"],
2243+
/Unknown flag.*--allow-warning/,
2244+
);
2245+
});
2246+
2247+
it("deploy apply rejects extra positional arguments (GH-266)", async () => {
2248+
const manifestPath = await writeDeployManifest("gh266-positional.json");
2249+
await assertDeployApplyBadUsage(
2250+
["--manifest", manifestPath, "unexpected.json", "--project", "prj_test123"],
2251+
/Unexpected argument.*unexpected\.json/,
2252+
);
2253+
});
2254+
2255+
it("deploy apply rejects --manifest combined with --spec (GH-268)", async () => {
2256+
const manifestPath = await writeDeployManifest("gh268-manifest-and-spec.json");
2257+
await assertDeployApplyBadUsage(
2258+
["--manifest", manifestPath, "--spec", nonEmptyDeploySpec(), "--project", "prj_test123"],
2259+
/Only one deploy manifest source/,
2260+
);
2261+
});
2262+
2263+
it("deploy apply rejects repeated --manifest flags (GH-266)", async () => {
2264+
const firstManifestPath = await writeDeployManifest("gh266-repeated-manifest-1.json");
2265+
const secondManifestPath = await writeDeployManifest("gh266-repeated-manifest-2.json");
2266+
await assertDeployApplyBadUsage(
2267+
["--manifest", firstManifestPath, "--manifest", secondManifestPath, "--project", "prj_test123"],
2268+
/--manifest.*only be provided once/,
2269+
);
2270+
});
2271+
2272+
it("deploy apply rejects repeated --spec flags (GH-266)", async () => {
2273+
await assertDeployApplyBadUsage(
2274+
["--spec", nonEmptyDeploySpec(), "--spec", nonEmptyDeploySpec(), "--project", "prj_test123"],
2275+
/--spec.*only be provided once/,
2276+
);
2277+
});
2278+
2279+
it("deploy apply rejects redirected stdin combined with an explicit source (GH-268)", async () => {
2280+
const { closeSync, openSync, writeFileSync: wf } = await import("node:fs");
2281+
const { spawnSync } = await import("node:child_process");
2282+
const stdinPath = join(tempDir, "gh268-stdin.json");
2283+
wf(stdinPath, nonEmptyDeploySpec());
2284+
const stdinFd = openSync(stdinPath, "r");
2285+
let result;
2286+
try {
2287+
result = spawnSync(process.execPath, [
2288+
"cli/cli.mjs",
2289+
"deploy",
2290+
"apply",
2291+
"--spec",
2292+
"{}",
2293+
"--project",
2294+
"prj_test123",
2295+
], {
2296+
cwd: process.cwd(),
2297+
env: { ...process.env, RUN402_CONFIG_DIR: tempDir, RUN402_API_BASE: API },
2298+
stdio: [stdinFd, "pipe", "pipe"],
2299+
encoding: "utf-8",
2300+
});
2301+
} finally {
2302+
closeSync(stdinFd);
2303+
}
2304+
assert.notEqual(result.status, 0, `should exit non-zero, stdout: ${result.stdout}, stderr: ${result.stderr}`);
2305+
const parsed = parseStderrEnvelope(result.stderr);
2306+
assert.equal(parsed.code, "BAD_USAGE");
2307+
assert.match(parsed.message, /Only one deploy manifest source/);
2308+
});
2309+
22182310
it("deploy apply rejects empty manifest file (GH-232)", async () => {
22192311
const { writeFileSync: wf } = await import("node:fs");
22202312
const manifestPath = join(tempDir, "gh232-empty-manifest.json");

cli/lib/deploy-v2.mjs

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* UTF-8 is the default; binary files pass `"encoding": "base64"`.
2222
*/
2323

24-
import { readFileSync } from "node:fs";
24+
import { fstatSync, readFileSync } from "node:fs";
2525
import { resolve, dirname, isAbsolute } from "node:path";
2626
import {
2727
buildDeployResolveSummary,
@@ -266,29 +266,113 @@ async function readStdin() {
266266
return Buffer.concat(chunks).toString("utf-8");
267267
}
268268

269+
function hasStdinSource() {
270+
try {
271+
const stats = fstatSync(0);
272+
return stats.isFIFO() || stats.isFile();
273+
} catch {
274+
return false;
275+
}
276+
}
277+
269278
function makeStderrEventWriter(quiet) {
270279
if (quiet) return undefined;
271280
return (event) => {
272281
console.error(JSON.stringify(event));
273282
};
274283
}
275284

276-
async function applyCmd(args) {
285+
function parseApplyArgs(args) {
277286
const opts = { manifest: null, spec: null, project: null, quiet: false, allowWarnings: false };
287+
const allowedFlags = ["--manifest", "--spec", "--project", "--quiet", "--allow-warnings", "--help", "-h"];
288+
278289
for (let i = 0; i < args.length; i++) {
279-
if (args[i] === "--help" || args[i] === "-h") { console.log(APPLY_HELP); process.exit(0); }
280-
if (args[i] === "--manifest" && args[i + 1]) { opts.manifest = args[++i]; continue; }
281-
if (args[i] === "--spec" && args[i + 1]) { opts.spec = args[++i]; continue; }
282-
if (args[i] === "--project" && args[i + 1]) { opts.project = args[++i]; continue; }
283-
if (args[i] === "--quiet") { opts.quiet = true; continue; }
284-
if (args[i] === "--allow-warnings") { opts.allowWarnings = true; continue; }
290+
const arg = args[i];
291+
if (arg === "--help" || arg === "-h") {
292+
console.log(APPLY_HELP);
293+
process.exit(0);
294+
}
295+
if (arg === "--manifest" || arg === "--spec" || arg === "--project") {
296+
const value = args[i + 1];
297+
if (value === undefined || (typeof value === "string" && value.startsWith("--"))) {
298+
fail({
299+
code: "BAD_USAGE",
300+
message: `${arg} requires a value`,
301+
details: { flag: arg },
302+
});
303+
}
304+
if (arg === "--manifest") {
305+
if (opts.manifest !== null) {
306+
fail({
307+
code: "BAD_USAGE",
308+
message: "--manifest may only be provided once",
309+
details: { flag: "--manifest" },
310+
});
311+
}
312+
opts.manifest = value;
313+
} else if (arg === "--spec") {
314+
if (opts.spec !== null) {
315+
fail({
316+
code: "BAD_USAGE",
317+
message: "--spec may only be provided once",
318+
details: { flag: "--spec" },
319+
});
320+
}
321+
opts.spec = value;
322+
} else {
323+
opts.project = value;
324+
}
325+
i += 1;
326+
continue;
327+
}
328+
if (arg === "--quiet") { opts.quiet = true; continue; }
329+
if (arg === "--allow-warnings") { opts.allowWarnings = true; continue; }
330+
if (typeof arg === "string" && arg.startsWith("-")) {
331+
fail({
332+
code: "BAD_USAGE",
333+
message: `Unknown flag for deploy apply: ${arg}`,
334+
details: { flag: arg, allowed_flags: allowedFlags },
335+
});
336+
}
337+
fail({
338+
code: "BAD_USAGE",
339+
message: `Unexpected argument for deploy apply: ${arg}`,
340+
details: { argument: arg },
341+
});
285342
}
286343

344+
return opts;
345+
}
346+
347+
function applySourceField(opts) {
348+
if (opts.manifest !== null) return "manifest";
349+
if (opts.spec !== null) return "spec";
350+
return "stdin";
351+
}
352+
353+
function validateApplySources(opts) {
354+
const sources = [];
355+
if (opts.manifest !== null) sources.push("--manifest");
356+
if (opts.spec !== null) sources.push("--spec");
357+
if (hasStdinSource()) sources.push("stdin");
358+
if (sources.length > 1) {
359+
fail({
360+
code: "BAD_USAGE",
361+
message: "Only one deploy manifest source may be provided: --spec, --manifest, or stdin.",
362+
details: { sources },
363+
});
364+
}
365+
}
366+
367+
async function applyCmd(args) {
368+
const opts = parseApplyArgs(args);
369+
validateApplySources(opts);
370+
287371
let raw;
288372
let manifestPath = null;
289-
if (opts.spec) {
373+
if (opts.spec !== null) {
290374
raw = opts.spec;
291-
} else if (opts.manifest) {
375+
} else if (opts.manifest !== null) {
292376
try {
293377
manifestPath = isAbsolute(opts.manifest) ? opts.manifest : resolve(process.cwd(), opts.manifest);
294378
raw = readFileSync(manifestPath, "utf-8");
@@ -310,11 +394,11 @@ async function applyCmd(args) {
310394
fail({
311395
code: "BAD_USAGE",
312396
message: `Manifest is not valid JSON: ${err.message}`,
313-
details: { source: opts.manifest ? "manifest" : opts.spec ? "spec" : "stdin", parse_error: err.message },
397+
details: { source: applySourceField(opts), parse_error: err.message },
314398
});
315399
}
316400
rejectLegacySecretManifest(spec, {
317-
source: opts.manifest ? "manifest" : opts.spec ? "spec" : "stdin",
401+
source: applySourceField(opts),
318402
...(manifestPath ? { path: manifestPath } : {}),
319403
});
320404

@@ -355,7 +439,7 @@ async function applyCmd(args) {
355439
message: `Manifest contains no deployable sections. Expected at least one of: ${meaningful.join(", ")}`,
356440
hint: "Did you mean to write a 'site.replace' or 'database.migrations' block? See https://run402.com/schemas/manifest.v1.json",
357441
details: {
358-
field: opts.manifest ? "manifest" : opts.spec ? "spec" : "stdin",
442+
field: applySourceField(opts),
359443
...(manifestPath ? { path: manifestPath } : {}),
360444
meaningful_keys: meaningful,
361445
},

0 commit comments

Comments
 (0)