diff --git a/config.ts b/config.ts index b885639..4f7b9c0 100644 --- a/config.ts +++ b/config.ts @@ -248,8 +248,11 @@ async function readConfig( allowNodeModules: boolean, debug: boolean, ): Promise { + // Only `--config ` is parsed as a config file; a positional root is not. + const fromConfig = Boolean(maybeConfigPath); const config = resolve_config( resolve(maybeConfigPath || rootPath), + fromConfig, ignorePaths, allowNodeModules, debug, diff --git a/deploy/create/mod.ts b/deploy/create/mod.ts index f7fe6e1..6b5a57a 100644 --- a/deploy/create/mod.ts +++ b/deploy/create/mod.ts @@ -20,7 +20,7 @@ import { import { publish, waitForRevision } from "../publish.ts"; import { resolve } from "@std/path"; -import { error, writeJsonResult } from "../../util.ts"; +import { deployRootDir, error, writeJsonResult } from "../../util.ts"; import { green } from "@std/fmt/colors"; export const createCommand = new Command() @@ -180,7 +180,9 @@ export const createCommand = new Command() }, ) .arguments("[root-path:string]") - .action(actionHandler(async (config, options, rootPath = Deno.cwd()) => { + .action(actionHandler(async (config, options, rawRootPath = Deno.cwd()) => { + // A positional file argument (e.g. `main.ts`) deploys its directory. + const rootPath = deployRootDir(rawRootPath); await getAuth(options); let data; if ( diff --git a/deploy/mod.ts b/deploy/mod.ts index a5f9b7a..56f4142 100644 --- a/deploy/mod.ts +++ b/deploy/mod.ts @@ -1,6 +1,7 @@ import { Command, ValidationError } from "@cliffy/command"; import { green, red, setColorEnabled, yellow } from "@std/fmt/colors"; import { + deployRootDir, error, renderTemporalTimestamp, tablePrinter, @@ -402,7 +403,9 @@ for the full reference.`) }) .action( actionHandler( - async (config, options, rootPath = Deno.cwd()) => { + async (config, options, rawRootPath = Deno.cwd()) => { + // A positional file argument (e.g. `main.ts`) deploys its directory. + const rootPath = deployRootDir(rawRootPath); const org = await getOrg(options, config, options.org); const { app, created } = await getApp( options, diff --git a/rs_lib/src/lib.rs b/rs_lib/src/lib.rs index 99e46e5..031bb06 100644 --- a/rs_lib/src/lib.rs +++ b/rs_lib/src/lib.rs @@ -25,12 +25,18 @@ pub struct ConfigLookup { #[wasm_bindgen] pub fn resolve_config( root_path: String, + from_config: bool, ignore_paths: Vec, allow_node_modules: bool, debug: bool, ) -> Result { - let result = - inner_resolve_config(root_path, ignore_paths, allow_node_modules, debug); + let result = inner_resolve_config( + root_path, + from_config, + ignore_paths, + allow_node_modules, + debug, + ); result .map_err(|err| create_js_error(&err)) .map(|val| serde_wasm_bindgen::to_value(&val).unwrap()) @@ -38,6 +44,7 @@ pub fn resolve_config( fn inner_resolve_config( root_path: String, + from_config: bool, ignore_paths: Vec, allow_node_modules: bool, debug: bool, @@ -45,8 +52,8 @@ fn inner_resolve_config( debug_log( debug, &format!( - "resolve_config(root_path={:?}, ignore_paths={:?}, allow_node_modules={})", - root_path, ignore_paths, allow_node_modules + "resolve_config(root_path={:?}, from_config={}, ignore_paths={:?}, allow_node_modules={})", + root_path, from_config, ignore_paths, allow_node_modules ), ); @@ -54,11 +61,18 @@ fn inner_resolve_config( let root_path = resolve_absolute_path(root_path)?; debug_log(debug, &format!("resolved absolute root_path={:?}", root_path)); - // When --config points to a file (not a directory), use ConfigFile - // discovery so non-standard filenames like deno-staging.json work. - let is_config_file = real_sys.fs_is_file(&root_path).unwrap_or(false); - debug_log(debug, &format!("is_config_file={}", is_config_file)); - let dir_path = if is_config_file { + let path_is_file = real_sys.fs_is_file(&root_path).unwrap_or(false); + // Only `--config ` is parsed as a config file; a positional file root + // is a deploy target whose config is discovered from its parent directory. + let is_config_file = from_config && path_is_file; + debug_log( + debug, + &format!( + "path_is_file={} is_config_file={}", + path_is_file, is_config_file + ), + ); + let dir_path = if path_is_file { root_path.parent().unwrap().to_path_buf() } else { root_path.clone() @@ -284,6 +298,7 @@ mod tests { let result = inner_resolve_config( root.to_string_lossy().into_owned(), + false, Vec::new(), false, false, @@ -301,4 +316,94 @@ mod tests { result.files, ); } + + // Regression test for denoland/deploy-cli#107: a positional file root (e.g. + // `deno deploy main.ts`) must not be deserialized as a config file. Config is + // discovered from the file's parent directory and the file itself is part of + // the upload manifest. Before the fix this errored with "Failed deserializing + // config file" because any file path was treated as a config file. + #[test] + fn positional_file_root_discovers_parent_config() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write_file( + root, + "deno.json", + r#"{ "deploy": { "org": "myorg", "app": "myapp" } }"#, + ); + write_file(root, "main.ts", "Deno.serve(() => new Response('hello'));"); + + let entry = root.join("main.ts"); + let result = inner_resolve_config( + entry.to_string_lossy().into_owned(), + false, + Vec::new(), + false, + false, + ) + .unwrap(); + + let config_path = result + .path + .as_deref() + .expect("expected a discovered config path"); + assert!( + config_path.ends_with("deno.json"), + "expected parent deno.json as config; got {}", + config_path, + ); + assert!( + result + .files + .iter() + .any(|f| Path::new(f) == entry.as_path()), + "expected {} in deploy files; got {:?}", + entry.display(), + result.files, + ); + } + + // A non-standard config filename passed via `--config` must still use + // ConfigFile discovery so it is loaded directly regardless of its name. + #[test] + fn explicit_config_flag_uses_named_config_file() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write_file( + root, + "deno-staging.json", + r#"{ "deploy": { "org": "myorg", "app": "staging" } }"#, + ); + write_file(root, "main.ts", "Deno.serve(() => new Response('hello'));"); + + let config = root.join("deno-staging.json"); + let result = inner_resolve_config( + config.to_string_lossy().into_owned(), + true, + Vec::new(), + false, + false, + ) + .unwrap(); + + let config_path = result + .path + .as_deref() + .expect("expected a discovered config path"); + assert!( + config_path.ends_with("deno-staging.json"), + "expected deno-staging.json as config; got {}", + config_path, + ); + let entry = root.join("main.ts"); + assert!( + result + .files + .iter() + .any(|f| Path::new(f) == entry.as_path()), + "expected {} in deploy files; got {:?}", + entry.display(), + result.files, + ); + } } diff --git a/util.ts b/util.ts index 4cd65d3..5b1fb05 100644 --- a/util.ts +++ b/util.ts @@ -1,8 +1,18 @@ import { red, stripAnsiCode } from "@std/fmt/colors"; +import { dirname } from "@std/path"; import { Temporal } from "temporal-polyfill"; import type { GlobalContext } from "./main.ts"; +/** Resolve a deploy root to a directory: a file argument maps to its parent. */ +export function deployRootDir(path: string): string { + try { + return Deno.statSync(path).isFile ? dirname(path) : path; + } catch { + return path; + } +} + /** * Exit codes returned by the CLI. Agents pattern-match on these before parsing * stderr. Keep this list small and stable — new categories require a docs bump.