use std::{
collections::BTreeMap,
fs::OpenOptions,
io::{BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
};
use serde::{Deserialize, de::DeserializeOwned};
use serde_json::Value;
use crate::{
CliResult,
config::Config,
output::{CliError, Report},
project::{Project, check_wasm_target},
secret::{self, SECRET_KEY_BASE},
};
pub const CF_VERSION: &str = "1.0.0-beta.5";
pub const WRANGLER_VERSION: &str = "4.144.0";
pub const TYPESCRIPT_VERSION: &str = "5.9.3";
pub const WRANGLER_MIN: (u32, u32) = (4, 136);
pub const NODE_MIN: u32 = 22;
const LOGIN_HINT: &str = "log in with `ocre login`, or set CLOUDFLARE_API_TOKEN (and CLOUDFLARE_ACCOUNT_ID when the token sees several accounts)";
#[derive(Clone, Copy)]
pub enum Echo {
Stdout,
Stderr,
Capture,
}
impl Echo {
pub fn for_json(json: bool) -> Self {
if json { Self::Stderr } else { Self::Stdout }
}
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct Account {
pub id: String,
pub name: String,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct Session {
#[serde(default)]
authenticated: bool,
pub email: Option<String>,
#[serde(default)]
pub accounts: Vec<Account>,
}
#[derive(Debug)]
struct ApiFailure {
code: Option<u32>,
message: String,
}
pub struct Cloudflare<'a> {
root: &'a Path,
echo: Echo,
build: &'static str,
}
impl<'a> Cloudflare<'a> {
pub fn new(root: &'a Path, echo: Echo) -> Self {
Self { root, echo, build: "--release" }
}
pub fn dev_build(self) -> Self {
Self { build: "--dev", ..self }
}
pub fn whoami(&self) -> Result<Option<Session>, CliError> {
let output = self.command().args(["auth", "whoami"]).stderr(Stdio::piped()).output().map_err(cf_missing)?;
if !output.status.success() {
return Ok(None);
}
let session: Session = serde_json::from_slice(&output.stdout)
.map_err(|err| CliError::new(format!("unexpected `cf auth whoami` output: {err}")))?;
Ok(session.authenticated.then_some(session))
}
pub fn ensure_login(&self) -> Result<Session, CliError> {
if let Some(session) = self.whoami()? {
return Ok(session);
}
self.run(&["auth", "login"])?;
self.whoami()?.ok_or_else(|| {
CliError::new("Cloudflare login did not complete")
.hint("run `ocre login` and approve access in the browser, or set CLOUDFLARE_API_TOKEN")
})
}
pub fn database_id(&self, name: &str) -> Result<Option<String>, CliError> {
let databases: Vec<Value> = self.api_json(&["d1", "list", "--name", name])?;
Ok(databases.iter().find(|db| db["name"] == name).and_then(|db| db["uuid"].as_str()).map(str::to_owned))
}
pub fn ensure_database(&self, name: &str) -> Result<(String, bool), CliError> {
if let Some(id) = self.database_id(name)? {
return Ok((id, false));
}
let created: Value = self.api_json(&["d1", "create", "--name", name])?;
let id = created["uuid"].as_str().ok_or_else(|| {
CliError::new(format!("`cf d1 create --name {name}` returned no uuid: {created}"))
.hint("run `ocre deploy` again: it finds the database by name once Cloudflare lists it")
})?;
Ok((id.to_owned(), true))
}
pub fn migrate_remote(&self, id: &str) -> Result<(), CliError> {
self.run(&["d1", "migrations", "apply", id]).map(drop)
}
pub fn pending_remote(&self, id: &str) -> Result<Vec<String>, CliError> {
let listed: Vec<Value> = self.api_json(&["d1", "migrations", "list", id])?;
Ok(listed.iter().filter_map(|migration| migration["Name"].as_str().map(str::to_owned)).collect())
}
pub fn query_remote(&self, id: &str, sql: &str) -> Result<String, CliError> {
const BATCH: &str = ".wrangler/ocre-batch.json";
let path = self.root.join(BATCH);
std::fs::create_dir_all(path.parent().expect("the batch file is in .wrangler/"))?;
std::fs::write(&path, serde_json::json!([{ "sql": sql }]).to_string())?;
let result = self.api(&["d1", "query", id, "--batch", &format!("@{BATCH}")]);
let _ = std::fs::remove_file(&path);
result?.map_err(|failure| failure.error(&format!("cf d1 query {id}")))
}
fn ensure_queues(&self, config: &Config) -> Result<Vec<String>, CliError> {
let wanted = config.queue_names()?;
if wanted.is_empty() {
return Ok(Vec::new());
}
let existing: Vec<Value> = self.api_json(&["queues", "list"])?;
let mut created = Vec::new();
for queue in wanted {
if existing.iter().any(|q| q["queue_name"] == queue.as_str()) {
continue;
}
self.api_json::<Value>(&["queues", "create", "--queue-name", &queue])?;
created.push(format!("queue {queue}"));
}
Ok(created)
}
fn ensure_buckets(&self, config: &Config) -> Result<Vec<String>, CliError> {
let mut created = Vec::new();
for bucket in config.bucket_names()? {
match self.api(&["r2", "buckets", "get", &bucket])? {
Ok(_) => continue,
Err(failure) if failure.code == Some(10006) => {}
Err(failure) if failure.code == Some(10042) => {
return Err(CliError::new(format!("`cf r2 buckets get {bucket}` failed: {}", failure.message)).hint(
"enable R2 once in the Cloudflare dashboard (Storage & databases > R2; the free plan asks for a payment method but charges nothing within 10 GB, 1M writes and 10M reads a month), then run `ocre deploy` again",
));
}
Err(failure) => return Err(failure.error(&format!("cf r2 buckets get {bucket}"))),
}
self.api_json::<Value>(&["r2", "buckets", "create", "--name", &bucket])?;
created.push(format!("R2 bucket {bucket}"));
}
Ok(created)
}
fn ensure_kv_namespaces(&self, config: Config) -> Result<Vec<String>, CliError> {
let bindings: Vec<String> = config.kv_without_id().into_iter().map(str::to_owned).collect();
if bindings.is_empty() {
return Ok(Vec::new());
}
let worker = config.worker_name()?.to_owned();
let namespaces = self.kv_namespaces()?;
let mut config = config;
let mut created = Vec::new();
for binding in bindings {
let title = format!("{worker}-{}", binding.to_ascii_lowercase().replace('_', "-"));
let id = match namespaces.iter().find(|ns| ns["title"] == title.as_str()).and_then(|ns| ns["id"].as_str()) {
Some(id) => id.to_owned(),
None => {
let namespace: Value = self.api_json(&["kv", "namespaces", "create", "--title", &title])?;
created.push(format!("KV namespace {title} (id written to {})", crate::config::FILE));
namespace["id"].as_str().map(str::to_owned).ok_or_else(|| {
CliError::new(format!("`cf kv namespaces create --title {title}` returned no id: {namespace}"))
.hint("run `ocre deploy` again: it links the namespace once Cloudflare lists it")
})?
}
};
let text = config.set_kv_id(&binding, &id)?;
std::fs::write(self.root.join(crate::config::FILE), &text)?;
config = Config::parse(text)?;
}
Ok(created)
}
fn kv_namespaces(&self) -> Result<Vec<Value>, CliError> {
const PER_PAGE: usize = 100;
let mut all = Vec::new();
for page in 1.. {
let batch: Vec<Value> =
self.api_json(&["kv", "namespaces", "list", "--per-page", "100", "--page", &page.to_string()])?;
let last = batch.len() < PER_PAGE;
all.extend(batch);
if last {
break;
}
}
Ok(all)
}
pub fn secret_names(&self, worker: &str) -> Result<Option<Vec<String>>, CliError> {
match self.api(&["workers", "secrets", "list", "--worker", worker])? {
Ok(stdout) => {
let secrets: Vec<Value> = serde_json::from_str(&stdout)
.map_err(|err| CliError::new(format!("unexpected `cf workers secrets list` output: {err}")))?;
Ok(Some(secrets.iter().filter_map(|secret| secret["name"].as_str().map(str::to_owned)).collect()))
}
Err(failure) if failure.code == Some(10007) => Ok(None),
Err(failure) => {
Err(CliError::new(format!("`cf workers secrets list --worker {worker}` failed: {}", failure.message))
.hint(format!("{LOGIN_HINT}; Ocre only creates SECRET_KEY_BASE when sure the Worker has none")))
}
}
}
pub fn put_secrets(&self, worker: &str, values: &BTreeMap<String, String>) -> Result<(), CliError> {
const PUSH_FILE: &str = ".wrangler/ocre-secrets-push.json";
let secrets: serde_json::Map<String, Value> = values
.iter()
.map(|(name, text)| {
(name.clone(), serde_json::json!({ "name": name, "type": "secret_text", "text": text }))
})
.collect();
let body = serde_json::json!({ "secrets": secrets });
let file = PrivateFile::create(self.root, PUSH_FILE, &body.to_string())?;
let result = self.run(&["workers", "secrets", "bulk", "--worker", worker, "--file", PUSH_FILE]);
drop(file);
result.map(drop)
}
pub fn secret_stores(&self) -> Result<Vec<(String, String)>, CliError> {
let stores: Value = self.api_json(&["secrets-store", "stores", "list"])?;
Ok(id_names(&stores))
}
pub fn store_secrets(&self, store_id: &str) -> Result<Vec<(String, String)>, CliError> {
let secrets: Value = self.api_json(&["secrets-store", "secrets", "list", "--store-id", store_id])?;
Ok(id_names(&secrets))
}
pub fn put_store_secret(&self, store_id: &str, name: &str, value: &str) -> Result<(), CliError> {
const BODY_FILE: &str = ".wrangler/ocre-store-secret.json";
let existing = self.store_secrets(store_id)?.into_iter().find(|(_, secret)| secret == name);
let body = match &existing {
Some(_) => serde_json::json!({ "value": value, "scopes": ["workers"] }),
None => serde_json::json!([{ "name": name, "value": value, "scopes": ["workers"] }]),
};
let file = PrivateFile::create(self.root, BODY_FILE, &body.to_string())?;
let body = format!("@{BODY_FILE}");
let result = match &existing {
Some((id, _)) => {
self.run(&["secrets-store", "secrets", "edit", id, "--store-id", store_id, "--body", &body])
}
None => self.run(&["secrets-store", "secrets", "create", store_id, "--body", &body]),
};
drop(file);
result.map(drop)
}
pub fn deploy(&self, project: &Project) -> Result<Deployed, CliError> {
require_install(self.root)?;
let config = project.config()?;
let worker = config.worker_name()?.to_owned();
let (database, created) = self.ensure_database(&project.database_name)?;
let mut provisioned = Vec::new();
if created {
provisioned.push(format!("D1 database {}", project.database_name));
}
provisioned.extend(self.ensure_queues(&config)?);
provisioned.extend(self.ensure_buckets(&config)?);
provisioned.extend(self.ensure_kv_namespaces(config)?);
let has_secret = self.secret_names(&worker)?.is_some_and(|names| names.iter().any(|n| n == SECRET_KEY_BASE));
let kept = crate::secrets::read_vars(self.root, PROD_VARS)?.remove(SECRET_KEY_BASE);
let new_secret = if has_secret { None } else { Some(kept.clone().unwrap_or_else(secret::generate)) };
let secrets = match &new_secret {
Some(value) => serde_json::json!({ (SECRET_KEY_BASE): value }),
None => serde_json::json!({}),
};
let secrets_file = PrivateFile::create(self.root, SECRETS_FILE, &secrets.to_string())?;
self.migrate_remote(&database)?;
let output = self.run(&["deploy", "--secrets-file", SECRETS_FILE])?;
drop(secrets_file);
let url = output
.split_whitespace()
.find(|word| word.starts_with("https://") && word.contains(".workers.dev"))
.map(str::to_owned);
let secret_saved = match (&new_secret, &kept) {
(Some(value), None) => {
save_secret(self.root, value)?;
true
}
_ => false,
};
Ok(Deployed { url, secret_created: new_secret.is_some(), secret_saved, provisioned })
}
pub fn run(&self, args: &[&str]) -> Result<String, CliError> {
let mut command = self.command();
command.args(args);
run(command, &format!("cf {}", args.join(" ")), self.echo)
}
fn api(&self, args: &[&str]) -> Result<Result<String, ApiFailure>, CliError> {
let output = self.command().args(args).stderr(Stdio::piped()).output().map_err(cf_missing)?;
if output.status.success() {
return Ok(Ok(String::from_utf8_lossy(&output.stdout).into_owned()));
}
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(Err(ApiFailure { code: api_code(&stderr), message: error_box(&stderr) }))
}
fn api_json<T: DeserializeOwned>(&self, args: &[&str]) -> Result<T, CliError> {
let label = format!("cf {}", args.join(" "));
let stdout = self.api(args)?.map_err(|failure| failure.error(&label))?;
serde_json::from_str(&stdout).map_err(|err| CliError::new(format!("unexpected `{label}` output: {err}")))
}
fn command(&self) -> Command {
let local = self.root.join("node_modules/.bin/cf");
let mut command = if local.is_file() {
Command::new(local)
} else {
let mut npx = Command::new("npx");
npx.args(["--yes", &format!("cf@{CF_VERSION}")]);
npx
};
command.current_dir(self.root).stdin(Stdio::null()).env("OCRE_BUILD", self.build);
command
}
}
impl ApiFailure {
fn error(self, label: &str) -> CliError {
CliError::new(format!("`{label}` failed: {}", self.message)).hint(LOGIN_HINT)
}
}
fn api_code(stderr: &str) -> Option<u32> {
stderr.match_indices('[').find_map(|(at, _)| {
let rest = &stderr[at + 1..];
let end = rest.find(']')?;
rest[..end].parse().ok()
})
}
fn error_box(stderr: &str) -> String {
stderr.find('┌').map_or(stderr, |at| &stderr[at..]).trim().to_owned()
}
fn run(mut command: Command, label: &str, echo: Echo) -> Result<String, CliError> {
command.stdout(Stdio::piped());
if let Echo::Capture = echo {
command.stderr(Stdio::piped());
}
let tool = label.split(' ').next().expect("labels start with the tool");
let mut child = command.spawn().map_err(|err| missing(tool, err))?;
let stderr = child.stderr.take().map(|mut pipe| {
std::thread::spawn(move || {
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
});
let mut captured = String::new();
for line in BufReader::new(child.stdout.take().expect("stdout is piped")).lines() {
let line = line?;
match echo {
Echo::Stdout => writeln!(std::io::stdout(), "{line}")?,
Echo::Stderr => writeln!(std::io::stderr(), "{line}")?,
Echo::Capture => {}
}
captured.push_str(&line);
captured.push('\n');
}
let status = child.wait()?;
let stderr = stderr.map(|reader| reader.join().expect("stderr reader does not panic")).unwrap_or_default();
if status.success() {
return Ok(captured);
}
let mut message = format!("`{label}` failed ({status})");
let hint = if let Echo::Capture = echo {
captured.push_str(&stderr);
message.push_str(":\n");
message.push_str(captured.trim_end());
format!("the {tool} output above names the cause")
} else {
format!("read the {tool} output above; the first error line names the cause")
};
Err(CliError::new(message).hint(hint))
}
fn cf_missing(err: std::io::Error) -> CliError {
missing("cf", err)
}
fn missing(tool: &str, err: std::io::Error) -> CliError {
CliError::new(format!("could not run {tool}: {err}"))
.hint(format!("install Node.js {NODE_MIN} or newer, then run `npm install` in the app"))
}
fn require_install(root: &Path) -> Result<(), CliError> {
if root.join("node_modules/.bin/cf").is_file() && root.join("node_modules/.bin/wrangler").is_file() {
return Ok(());
}
Err(CliError::new("the app's npm packages are not installed (node_modules/.bin/cf, node_modules/.bin/wrangler)")
.hint(format!("run `npm install` in {} (needs Node.js {NODE_MIN} or newer)", root.display())))
}
pub enum Sql<'a> {
Command(&'a str),
File(&'a str),
}
pub struct LocalD1<'a> {
project: &'a Project,
echo: Echo,
state: &'static str,
}
impl<'a> LocalD1<'a> {
pub const CONFIG: &'static str = ".wrangler/ocre-d1.json";
pub const STATE: &'static str = ".wrangler/state";
pub const TEST_STATE: &'static str = ".wrangler/test-state";
pub fn new(project: &'a Project, echo: Echo) -> Self {
Self { project, echo, state: Self::STATE }
}
pub fn for_tests(self) -> Self {
Self { state: Self::TEST_STATE, ..self }
}
pub fn migrate(&self) -> Result<(), CliError> {
self.run(&["d1", "migrations", "apply", "DB", "--local"], self.echo).map(drop)
}
pub fn pending(&self) -> Result<Vec<String>, CliError> {
Ok(pending_migrations(&self.run(&["d1", "migrations", "list", "DB", "--local"], Echo::Capture)?))
}
pub fn execute(&self, sql: Sql) -> Result<String, CliError> {
let mut args = vec!["d1", "execute", "DB", "--local"];
match sql {
Sql::Command(command) => args.extend(["--command", command]),
Sql::File(file) => args.extend(["--file", file, "--yes"]),
}
self.run(&args, self.echo)
}
pub fn query(&self, sql: &str) -> Result<String, CliError> {
self.run(&["d1", "execute", "DB", "--local", "--command", sql, "--json"], Echo::Capture)
}
pub fn sync_store_secrets(&self) -> Result<Vec<String>, CliError> {
let config = self.project.config()?;
let vars = crate::secrets::read_vars(&self.project.root, ".dev.vars")?;
let mut copied = Vec::new();
for call in config.bindings("secretsStoreSecret") {
let (Some(name), Some(store), Some(secret)) =
(call.key.as_deref(), call.field("storeId"), call.field("secretName"))
else {
continue;
};
let Some(value) = vars.get(name) else { continue };
let mut command = Command::new(self.wrangler()?);
command
.args([
"secrets-store",
"secret",
"create",
store,
"--name",
secret,
"--value",
value,
"--scopes",
"workers",
])
.args(["--persist-to", self.state])
.current_dir(&self.project.root)
.stdin(Stdio::null());
run(command, &format!("wrangler secrets-store secret create {store} --name {secret}"), Echo::Capture)
.map_err(|err| {
CliError::new(format!(
"cannot copy {name} from .dev.vars into the local Secrets Store: {}",
err.message
))
.hint("check the storeId and secretName of its binding in cloudflare.config.ts")
})?;
copied.push(name.to_owned());
}
Ok(copied)
}
pub fn spawn_server(&self, port: u16) -> Result<std::process::Child, CliError> {
let mut command = Command::new(self.wrangler()?);
command
.args(["dev", "--x-new-config", "--port", &port.to_string(), "--persist-to", self.state])
.current_dir(&self.project.root)
.env("OCRE_BUILD", "--dev")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut command, 0);
command.spawn().map_err(|err| missing("wrangler", err))
}
fn wrangler(&self) -> Result<std::path::PathBuf, CliError> {
let root = &self.project.root;
let wrangler = root.join("node_modules/.bin/wrangler");
if !wrangler.is_file() {
return Err(CliError::new("the app's wrangler is not installed (node_modules/.bin/wrangler)").hint(
format!("run `npm install` in {} (local databases run through the app's wrangler)", root.display()),
));
}
Ok(wrangler)
}
fn run(&self, args: &[&str], echo: Echo) -> Result<String, CliError> {
let root = &self.project.root;
let wrangler = self.wrangler()?;
self.write_config()?;
let mut command = Command::new(wrangler);
command
.args(args)
.args(["-c", Self::CONFIG, "--persist-to", self.state])
.current_dir(root)
.stdin(Stdio::null());
run(command, &format!("wrangler {}", args.join(" ")), echo)
}
fn write_config(&self) -> Result<(), CliError> {
let config = self.project.config()?;
let derived = serde_json::json!({
"name": config.name.as_deref().unwrap_or(&self.project.database_name),
"compatibility_date": config.compatibility_date.as_deref().unwrap_or("2026-09-01"),
"d1_databases": [{
"binding": "DB",
"database_name": self.project.database_name,
"migrations_dir": "../migrations",
}],
});
let path = self.project.root.join(Self::CONFIG);
std::fs::create_dir_all(path.parent().expect("the config is in .wrangler/"))?;
std::fs::write(path, format!("{derived:#}\n"))?;
Ok(())
}
}
pub enum Database<'a> {
Local(LocalD1<'a>),
Remote(Cloudflare<'a>, String),
}
impl<'a> Database<'a> {
pub fn open(project: &'a Project, echo: Echo, remote: bool) -> Result<Self, CliError> {
if !remote {
return Ok(Self::Local(LocalD1::new(project, echo)));
}
let cf = Cloudflare::new(&project.root, echo);
let id = cf.database_id(&project.database_name)?.ok_or_else(|| {
CliError::new(format!("the D1 database {} does not exist on Cloudflare yet", project.database_name))
.hint("run `ocre deploy` (or `ocre db create --remote`) first")
})?;
Ok(Self::Remote(cf, id))
}
pub fn migrate(&self) -> Result<(), CliError> {
match self {
Self::Local(local) => local.migrate(),
Self::Remote(cf, id) => cf.migrate_remote(id),
}
}
pub fn pending(&self) -> Result<Vec<String>, CliError> {
match self {
Self::Local(local) => local.pending(),
Self::Remote(cf, id) => cf.pending_remote(id),
}
}
pub fn run_file(&self, file: &str) -> Result<(), CliError> {
match self {
Self::Local(local) => local.execute(Sql::File(file)).map(drop),
Self::Remote(cf, id) => cf.query_remote(id, &std::fs::read_to_string(cf.root.join(file))?).map(drop),
}
}
pub fn query(&self, sql: &str) -> Result<String, CliError> {
match self {
Self::Local(local) => local.query(sql),
Self::Remote(cf, id) => cf.query_remote(id, sql),
}
}
pub fn target(&self) -> &'static str {
match self {
Self::Local(_) => "--local",
Self::Remote(..) => "--remote",
}
}
}
pub(crate) fn pending_migrations(output: &str) -> Vec<String> {
output
.lines()
.filter_map(|line| line.trim().strip_prefix('│')?.strip_suffix('│'))
.map(str::trim)
.filter(|name| name.ends_with(".sql"))
.map(str::to_owned)
.collect()
}
pub fn stop(mut child: std::process::Child) {
#[cfg(unix)]
let _ = Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
let _ = child.kill();
let _ = child.wait();
}
pub fn pick_account(session: &Session, requested: Option<&str>) -> Result<Option<String>, CliError> {
let listing = || session.accounts.iter().map(|a| format!("{} ({})", a.id, a.name)).collect::<Vec<_>>().join(", ");
match requested {
Some(id) if session.accounts.iter().any(|a| a.id == id) => Ok(Some(id.to_owned())),
Some(id) => Err(CliError::new(format!("account `{id}` is not available to this Cloudflare login"))
.hint(format!("use one of: {}", listing()))),
None if session.accounts.len() == 1 => Ok(None),
None if session.accounts.is_empty() => Err(CliError::new("this Cloudflare login has no accounts")
.hint("create an account at https://dash.cloudflare.com/sign-up, then run `ocre login` again")),
None => Err(CliError::new("this Cloudflare login has several accounts")
.hint(format!("pass --account-id with one of: {}", listing()))),
}
}
pub fn login(json: bool) -> CliResult {
let cwd = std::env::current_dir()?;
let session = Cloudflare::new(&cwd, Echo::for_json(json)).ensure_login()?;
Ok(Report { email: session.email, ..Report::new("login") })
}
pub fn migrate(remote: bool, json: bool) -> CliResult {
let project = Project::find()?;
Database::open(&project, Echo::for_json(json), remote)?.migrate()?;
Ok(Report { remote, ..Report::new("migrate") })
}
pub fn dev(port: u16, cache: Option<bool>, json: bool) -> CliResult {
let project = Project::find()?;
crate::i18n::check_syntax(&project.root)?;
check_wasm_target()?;
require_install(&project.root)?;
let ran = match cache {
Some(on) => vec![crate::secrets::set_dev_cache(&project.root, on)?],
None => Vec::new(),
};
let local = LocalD1::new(&project, Echo::for_json(json));
local.migrate()?;
let mut ran = ran;
ran.extend(
local
.sync_store_secrets()?
.into_iter()
.map(|name| format!("{name}: .dev.vars value copied into the local Secrets Store")),
);
Cloudflare::new(&project.root, Echo::for_json(json)).dev_build().run(&["dev", "--port", &port.to_string()])?;
Ok(Report { url: Some(format!("http://localhost:{port}")), ran, ..Report::new("dev") })
}
pub fn logs(format: &str, status: &[String], search: Option<&str>, json: bool) -> CliResult {
let project = Project::find()?;
require_install(&project.root)?;
let config = project.config()?;
let name = config.worker_name()?;
let mut args = vec!["tail", name, "--format", format];
for status in status {
args.extend(["--status", status.as_str()]);
}
if let Some(search) = search {
args.extend(["--search", search]);
}
let mut command = Command::new(project.root.join("node_modules/.bin/wrangler"));
command.args(&args).current_dir(&project.root).stdin(Stdio::null());
run(command, &format!("wrangler {}", args.join(" ")), Echo::for_json(json)).map_err(|err| {
err.hint(format!(
"wrangler tail uses wrangler's own login: run `npx wrangler login` in {}, or set CLOUDFLARE_API_TOKEN; the Worker must be deployed (`ocre deploy`)",
project.root.display()
))
})?;
Ok(Report::new("logs"))
}
pub fn deploy(json: bool) -> CliResult {
let project = Project::find()?;
crate::i18n::check_syntax(&project.root)?;
check_wasm_target()?;
let deployed = Cloudflare::new(&project.root, Echo::for_json(json)).deploy(&project)?;
Ok(Report {
url: deployed.url,
secret_created: deployed.secret_created,
secret_saved: deployed.secret_saved.then_some(PROD_VARS),
provisioned: deployed.provisioned,
..Report::new("deploy")
})
}
pub struct Deployed {
pub url: Option<String>,
pub secret_created: bool,
pub secret_saved: bool,
pub provisioned: Vec<String>,
}
fn id_names(list: &Value) -> Vec<(String, String)> {
let items = list.get("result").unwrap_or(list);
items
.as_array()
.into_iter()
.flatten()
.filter_map(|item| Some((item["id"].as_str()?.to_owned(), item["name"].as_str()?.to_owned())))
.collect()
}
const PROD_VARS: &str = ".prod.vars";
fn save_secret(root: &Path, value: &str) -> Result<(), CliError> {
let mut options = OpenOptions::new();
options.append(true).create(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let line = format!(
"# Created by `ocre deploy` and uploaded to the Worker, which never gives it back.\n\
# Back it up (a password manager): losing it signs everyone out and makes encrypted columns unreadable.\n\
{SECRET_KEY_BASE}={value}\n"
);
options.open(root.join(PROD_VARS))?.write_all(line.as_bytes())?;
Ok(())
}
const SECRETS_FILE: &str = ".wrangler/ocre-secrets.json";
struct PrivateFile(PathBuf);
impl PrivateFile {
fn create(root: &Path, relative: &str, contents: &str) -> Result<Self, CliError> {
let file = Self(root.join(relative));
std::fs::create_dir_all(file.0.parent().expect("the path has a parent"))?;
let _ = std::fs::remove_file(&file.0);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
options.open(&file.0)?.write_all(contents.as_bytes())?;
Ok(file)
}
}
impl Drop for PrivateFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[cfg(test)]
#[path = "../tests/cloudflare.rs"]
mod tests;