diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 998546d364..8d15488491 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -166,8 +166,10 @@ export async function createBundle( // Dynamically import esbuild const esbuild = await import("esbuild"); - // Detect frameworks to determine default entry point - const frameworks = detectFrameworks(process.cwd()); + // Detect frameworks to determine default entry point. + // Use the entryPoint's directory if provided, otherwise fall back to cwd. + const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd(); + const frameworks = detectFrameworks(appDir); const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx"; const entryPoint = options.entryPoint ?? defaultEntry; @@ -184,7 +186,6 @@ export async function createBundle( } // Ensure node_modules exists in the app directory - const appDir = path.dirname(entryPoint) || process.cwd(); await ensureNodeModules(appDir); // Load framework-specific plugins (svelte, vue) based on package.json diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index c66d637e16..ff978c1545 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -7,6 +7,7 @@ import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; import { yamlParseFile } from "../../utils/yaml.ts"; +import { validateRequiredArgs } from "../../utils/utils.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { readFile } from "node:fs/promises"; import { mkdirSync, writeFileSync } from "node:fs"; @@ -302,14 +303,10 @@ async function run( workspace: workspace.workspaceId, path, }); - const required = (flow.schema as any)?.required ?? []; - if (required.length > 0) { - throw new Error( - `Missing required arguments: ${required.join(", ")}.\nUse -d '{"${required[0]}": ...}' to provide input data.` - ); - } + validateRequiredArgs(flow.schema as Record); } catch (e: any) { if (e.message?.startsWith("Missing required")) throw e; + log.warn(`Could not fetch schema to validate args: ${e.message}`); } } diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 2ff8822acc..bdc7f3efd5 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -29,7 +29,7 @@ import { parseMetadataFile, readLockfile, } from "../../utils/metadata.ts"; -import { generateHash } from "../../utils/utils.ts"; +import { generateHash, validateRequiredArgs } from "../../utils/utils.ts"; import { WorkspaceDependenciesLanguage, ScriptLanguage, @@ -956,14 +956,10 @@ async function run( workspace: workspace.workspaceId, path, }); - const required = (script.schema as any)?.required ?? []; - if (required.length > 0) { - throw new Error( - `Missing required arguments: ${required.join(", ")}.\nUse -d '{"${required[0]}": ...}' to provide input data.` - ); - } + validateRequiredArgs(script.schema as Record); } catch (e: any) { if (e.message?.startsWith("Missing required")) throw e; + log.warn(`Could not fetch schema to validate args: ${e.message}`); } } diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index 96db9d6492..1fe60a183d 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -291,3 +291,20 @@ export function capitalize(str: string): string { export function formatTimestamp(ts: string): string { return new Date(ts).toISOString().replace("T", " ").substring(0, 19); } + +/** + * Validate that required arguments are present when no -d data was provided. + * Fetches the schema from the API and checks required fields. + * @param schema - The JSON schema object from the script/flow definition + * @throws Error if required arguments are missing + */ +export function validateRequiredArgs( + schema: Record | undefined | null, +): void { + const required = (schema as { required?: string[] })?.required ?? []; + if (required.length > 0) { + throw new Error( + `Missing required arguments: ${required.join(", ")}.\nUse -d '{"${required[0]}": ...}' to provide input data.` + ); + } +} diff --git a/cli/test/job_commands.test.ts b/cli/test/job_commands.test.ts index 9786dfff2b..9c41d4c06c 100644 --- a/cli/test/job_commands.test.ts +++ b/cli/test/job_commands.test.ts @@ -8,7 +8,9 @@ import { withTestBackend } from "./test_backend.ts"; import { setupWorkspaceProfile, createRemoteScript, + createRemoteFlow, runRemoteScript, + runRemoteFlow, waitForJob, } from "./new_commands_helpers.ts"; @@ -53,7 +55,7 @@ describe("job command", () => { expect(result.code).toEqual(0); expect(result.stdout).toContain("ID"); expect(result.stdout).toContain("Status"); - expect(result.stdout).toContain(jobId.substring(0, 8)); + expect(result.stdout).toContain(jobId); }); }); @@ -167,6 +169,26 @@ describe("job command", () => { }); }); + test("job logs for flow job shows helpful message", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const flowPath = `f/test/flow_logs_${uniqueId}`; + await createRemoteFlow(backend, flowPath); + const jobId = await runRemoteFlow(backend, flowPath); + await waitForJob(backend, jobId); + + const result = await backend.runCLICommand( + ["job", "logs", jobId], + tempDir + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("Flow jobs don't have direct logs"); + }); + }); + test("default action (wmill job) lists jobs", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/cli/test/new_commands_helpers.ts b/cli/test/new_commands_helpers.ts index ac92b1f54f..b42244cd1d 100644 --- a/cli/test/new_commands_helpers.ts +++ b/cli/test/new_commands_helpers.ts @@ -148,6 +148,28 @@ export async function createRemoteFlow( await resp.text(); } +export async function runRemoteFlow( + backend: TestBackend, + flowPath: string, + retries: number = 10 +): Promise { + for (let i = 0; i < retries; i++) { + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/jobs/run/f/${flowPath}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); + if (resp.status < 300) { + return (await resp.text()).replace(/"/g, ""); + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Failed to run flow ${flowPath} after ${retries} retries`); +} + export async function createRemoteSchedule( backend: TestBackend, schedulePath: string, diff --git a/cli/test/standalone_commands.test.ts b/cli/test/standalone_commands.test.ts index bc17ef0d32..bb1e13cde8 100644 --- a/cli/test/standalone_commands.test.ts +++ b/cli/test/standalone_commands.test.ts @@ -305,6 +305,79 @@ describe("script run command", () => { expect(result.stdout).toContain(`run_result_${uniqueId}`); }); }); + + test("exits with code 1 when script fails", { timeout: 60000 }, async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/fail_script_${uniqueId}`; + const scriptContent = `export async function main() { throw new Error("intentional failure"); }`; + + await createRemoteScript(backend, scriptPath, scriptContent); + + const result = await backend.runCLICommand( + ["script", "run", scriptPath, "--silent"], + tempDir + ); + + expect(result.code).toEqual(1); + }); + }); + + test("errors when required args are missing", { timeout: 60000 }, async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/args_script_${uniqueId}`; + const scriptContent = `export async function main(name: string) { return name; }`; + + // Create script with an explicit schema that has required args + // (createRemoteScript defaults to empty schema, so we call the API directly) + const parts = scriptPath.split("/"); + if (parts[0] === "f" && parts.length > 2) { + await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: parts[1] }), + } + ).catch(() => {}); + } + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: scriptContent, + language: "bun", + summary: "Test script with required args", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); + + const result = await backend.runCLICommand( + ["script", "run", scriptPath], + tempDir + ); + + expect(result.code).not.toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("Missing required arguments"); + }); + }); }); // ============================================================================= @@ -571,3 +644,82 @@ describe("user commands", () => { }); }); }); + +// ============================================================================= +// Script Push --message +// ============================================================================= + +describe("script push --message", () => { + test("push with --message flag succeeds", { timeout: 60000 }, async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/msg_script_${uniqueId}`; + const scriptFile = join(tempDir, scriptPath + ".ts"); + const metaFile = join(tempDir, scriptPath + ".script.yaml"); + const deployMsg = `deploy_msg_${uniqueId}`; + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + await writeFile(scriptFile, 'export async function main() { return "v1"; }'); + await writeFile(metaFile, [ + "summary: test", + "description: ''", + "lock: ''", + "kind: script", + "schema:", + " $schema: https://json-schema.org/draft/2020-12/schema", + " type: object", + " properties: {}", + " required: []", + ].join("\n")); + + // Verify push with --message flag succeeds (doesn't error on unknown flag) + const pushResult = await backend.runCLICommand( + ["script", "push", scriptPath + ".ts", "--message", deployMsg], + tempDir + ); + expect(pushResult.code).toEqual(0); + expect(pushResult.stdout).toContain("pushed"); + + // Verify history returns at least one version + const histResult = await backend.runCLICommand( + ["script", "history", scriptPath, "--json"], + tempDir + ); + expect(histResult.code).toEqual(0); + const versions = JSON.parse(histResult.stdout); + expect(versions.length).toBeGreaterThan(0); + }); + }); +}); + +// ============================================================================= +// Variable Add + Get (encryption roundtrip) +// ============================================================================= + +describe("variable add encryption", () => { + test("variable add creates a retrievable secret variable", { timeout: 30000 }, async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const varPath = `f/test/secret_${uniqueId}`; + const secretValue = `secret_value_${uniqueId}`; + + const addResult = await backend.runCLICommand( + ["variable", "add", secretValue, varPath], + tempDir + ); + expect(addResult.code).toEqual(0); + + const getResult = await backend.runCLICommand( + ["variable", "get", varPath], + tempDir + ); + expect(getResult.code).toEqual(0); + expect(getResult.stdout).toContain(secretValue); + expect(getResult.stdout).toContain("true"); // is_secret + }); + }); +}); diff --git a/cli/test/utils_unit.test.ts b/cli/test/utils_unit.test.ts index 288814987e..47edf45236 100644 --- a/cli/test/utils_unit.test.ts +++ b/cli/test/utils_unit.test.ts @@ -4,7 +4,7 @@ */ import { expect, test, describe } from "bun:test"; -import { deepEqual, isFileResource, isFilesetResource, toCamel, capitalize } from "../src/utils/utils.ts"; +import { deepEqual, isFileResource, isFilesetResource, toCamel, capitalize, validateRequiredArgs } from "../src/utils/utils.ts"; import { getTypeStrFromPath, removeType, @@ -596,3 +596,103 @@ describe("removeExtensionToPath", () => { expect(removeExtensionToPath("f/test/api.fetch.ts")).toBe("f/test/api"); }); }); + +// ============================================================================= +// validateRequiredArgs +// ============================================================================= + +describe("validateRequiredArgs", () => { + test("throws when required args are missing", () => { + expect(() => + validateRequiredArgs({ required: ["name", "count"] }) + ).toThrow("Missing required arguments: name, count"); + }); + + test("does not throw when no required args", () => { + expect(() => validateRequiredArgs({ required: [] })).not.toThrow(); + }); + + test("does not throw for undefined schema", () => { + expect(() => validateRequiredArgs(undefined)).not.toThrow(); + expect(() => validateRequiredArgs(null)).not.toThrow(); + }); + + test("does not throw for schema without required field", () => { + expect(() => validateRequiredArgs({ type: "object", properties: {} })).not.toThrow(); + }); + + test("error message includes usage hint", () => { + try { + validateRequiredArgs({ required: ["name"] }); + } catch (e: any) { + expect(e.message).toContain('-d \'{"name":'); + } + }); +}); + +// ============================================================================= +// TarAsZip adapter +// ============================================================================= + +describe("TarAsZip adapter", () => { + // Import the adapter — it's not exported but we can test via tar creation + parsing + const { extract } = require("tar-stream"); + const { Readable } = require("node:stream"); + + // Helper: build a TarAsZip from entries via the actual class + async function buildTarAsZip(entries: Map) { + // Dynamically import to get the class + const pullModule = await import("../src/commands/sync/pull.ts"); + // TarAsZip is not exported, so we test indirectly via parseTarResponse + // Instead, test the tar creation → extraction round-trip + const { createTarBlob } = await import("../src/utils/tar.ts"); + + const tarEntries = Array.from(entries).map(([name, { content }]) => ({ + name, + content, + })); + const blob = await createTarBlob(tarEntries); + + // Parse via the same extract pattern used by TarAsZip + const buffer = Buffer.from(await blob.arrayBuffer()); + const result = new Map(); + const ex = extract(); + + return new Promise>((resolve, reject) => { + ex.on("entry", (header: any, stream: any, next: () => void) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => { + result.set(header.name, { + content: Buffer.concat(chunks).toString("utf-8"), + isDir: header.type === "directory", + }); + next(); + }); + stream.on("error", reject); + stream.resume(); + }); + ex.on("finish", () => { + // Convert to simple map for assertions + const simpleMap = new Map(); + for (const [name, { content }] of result) { + simpleMap.set(name, content); + } + resolve(simpleMap); + }); + ex.on("error", reject); + Readable.from(buffer).pipe(ex); + }); + } + + test("tar round-trip preserves content", async () => { + const entries = new Map([ + ["f/scripts/hello.ts", { content: 'export async function main() { return "hello"; }', isDir: false }], + ["f/scripts/hello.script.yaml", { content: "summary: Hello\nkind: script\n", isDir: false }], + ]); + + const result = await buildTarAsZip(entries); + expect(result.get("f/scripts/hello.ts")).toBe('export async function main() { return "hello"; }'); + expect(result.get("f/scripts/hello.script.yaml")).toContain("summary: Hello"); + }); +});