Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,12 @@ async function readConfig(
allowNodeModules: boolean,
debug: boolean,
): Promise<Config> {
// Distinguish an explicit `--config <file>` (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,
Expand Down
6 changes: 4 additions & 2 deletions deploy/create/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalContext>()
Expand Down Expand Up @@ -180,7 +180,9 @@ export const createCommand = new Command<GlobalContext>()
},
)
.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 (
Expand Down
5 changes: 4 additions & 1 deletion deploy/mod.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command, ValidationError } from "@cliffy/command";
import { green, red, setColorEnabled, yellow } from "@std/fmt/colors";
import {
deployRootDir,
error,
renderTemporalTimestamp,
tablePrinter,
Expand Down Expand Up @@ -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,
Expand Down
128 changes: 119 additions & 9 deletions rs_lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,59 @@ pub struct ConfigLookup {
#[wasm_bindgen]
pub fn resolve_config(
root_path: String,
from_config: bool,
ignore_paths: Vec<String>,
allow_node_modules: bool,
debug: bool,
) -> Result<JsValue, JsValue> {
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())
}

fn inner_resolve_config(
root_path: String,
from_config: bool,
ignore_paths: Vec<String>,
allow_node_modules: bool,
debug: bool,
) -> Result<ConfigLookup, anyhow::Error> {
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
),
);

let real_sys = sys_traits::impls::RealSys;
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 <file>` 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()
Expand Down Expand Up @@ -284,6 +303,7 @@ mod tests {

let result = inner_resolve_config(
root.to_string_lossy().into_owned(),
false,
Vec::new(),
false,
false,
Expand All @@ -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,
);
}
}
17 changes: 17 additions & 0 deletions util.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading