From 09358e010e4df170876c0a1c5deaf557d26a9d3f Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:25:42 +0100 Subject: [PATCH 1/2] fix(config): treat positional file root as deploy target, not config file `deno deploy --org o --app a --prod main.ts` failed with "Failed deserializing config file '.../main.ts'" because the positional `[root-path]` was fed into config discovery as a config file whenever it pointed at a file. Only an explicit `--config ` should force ConfigFile discovery. `resolve_config` now takes a `from_config` flag: a positional file root discovers config from its parent directory (Paths) and includes the file in the upload manifest, while `--config ` keeps ConfigFile discovery. The command actions also normalize a positional file argument to its containing directory (`deployRootDir`) so the publish manifest and framework detection operate on the project rather than the single file. Fixes #107 --- config.ts | 4 ++ deploy/create/mod.ts | 6 +- deploy/mod.ts | 5 +- rs_lib/src/lib.rs | 128 ++++++++++++++++++++++++++++++++++++++++--- util.ts | 17 ++++++ 5 files changed, 148 insertions(+), 12 deletions(-) diff --git a/config.ts b/config.ts index b885639..c44bcf6 100644 --- a/config.ts +++ b/config.ts @@ -248,8 +248,12 @@ async function readConfig( allowNodeModules: boolean, debug: boolean, ): Promise { + // Distinguish an explicit `--config ` (parsed as a config file) from a + // positional `[root-path]` that may itself be a file (a deploy target). + 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..c6c32da 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,23 @@ 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 an explicit `--config ` is parsed as a config file, so + // non-standard filenames like deno-staging.json work via ConfigFile + // discovery. A positional `[root-path]` that happens to be a file (e.g. + // `main.ts`) is a deploy target, not a config file: config is discovered + // from its parent directory and the file is included in the manifest. + 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 + ), + ); + // For an explicit config file or a positional file root, discovery and file + // collection operate on the containing directory. + let dir_path = if path_is_file { root_path.parent().unwrap().to_path_buf() } else { root_path.clone() @@ -284,6 +303,7 @@ mod tests { let result = inner_resolve_config( root.to_string_lossy().into_owned(), + false, Vec::new(), false, false, @@ -301,4 +321,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..ce2e84d 100644 --- a/util.ts +++ b/util.ts @@ -1,8 +1,25 @@ import { red, stripAnsiCode } from "@std/fmt/colors"; +import { dirname } from "@std/path"; import { Temporal } from "temporal-polyfill"; import type { GlobalContext } from "./main.ts"; +/** + * Normalize a deploy root argument to a directory. A positional `[root-path]` + * may point at a file (e.g. an entrypoint like `main.ts`); the deploy root is + * then its containing directory, so the upload manifest and framework + * detection operate on the project rather than the single file. A path that + * does not resolve to a file (a directory, or a missing path) is returned + * unchanged so downstream resolution surfaces the original behavior/error. + */ +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. From 5454d8dee6d3e5991fcc0b2ee507fce6ac905b73 Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Tue, 30 Jun 2026 12:38:34 +0100 Subject: [PATCH 2/2] docs: trim narrative comments Reduce the verbose doc/inline comments added in the previous commit to terse one-liners. No logic, signature, or test changes. --- config.ts | 3 +-- rs_lib/src/lib.rs | 9 ++------- util.ts | 9 +-------- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/config.ts b/config.ts index c44bcf6..4f7b9c0 100644 --- a/config.ts +++ b/config.ts @@ -248,8 +248,7 @@ async function readConfig( allowNodeModules: boolean, debug: boolean, ): Promise { - // Distinguish an explicit `--config ` (parsed as a config file) from a - // positional `[root-path]` that may itself be a file (a deploy target). + // Only `--config ` is parsed as a config file; a positional root is not. const fromConfig = Boolean(maybeConfigPath); const config = resolve_config( resolve(maybeConfigPath || rootPath), diff --git a/rs_lib/src/lib.rs b/rs_lib/src/lib.rs index c6c32da..031bb06 100644 --- a/rs_lib/src/lib.rs +++ b/rs_lib/src/lib.rs @@ -62,11 +62,8 @@ fn inner_resolve_config( debug_log(debug, &format!("resolved absolute root_path={:?}", root_path)); let path_is_file = real_sys.fs_is_file(&root_path).unwrap_or(false); - // Only an explicit `--config ` is parsed as a config file, so - // non-standard filenames like deno-staging.json work via ConfigFile - // discovery. A positional `[root-path]` that happens to be a file (e.g. - // `main.ts`) is a deploy target, not a config file: config is discovered - // from its parent directory and the file is included in the manifest. + // 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, @@ -75,8 +72,6 @@ fn inner_resolve_config( path_is_file, is_config_file ), ); - // For an explicit config file or a positional file root, discovery and file - // collection operate on the containing directory. let dir_path = if path_is_file { root_path.parent().unwrap().to_path_buf() } else { diff --git a/util.ts b/util.ts index ce2e84d..5b1fb05 100644 --- a/util.ts +++ b/util.ts @@ -4,14 +4,7 @@ import { Temporal } from "temporal-polyfill"; import type { GlobalContext } from "./main.ts"; -/** - * Normalize a deploy root argument to a directory. A positional `[root-path]` - * may point at a file (e.g. an entrypoint like `main.ts`); the deploy root is - * then its containing directory, so the upload manifest and framework - * detection operate on the project rather than the single file. A path that - * does not resolve to a file (a directory, or a missing path) is returned - * unchanged so downstream resolution surfaces the original behavior/error. - */ +/** 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;