use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use clap::Parser;
use notify::{RecursiveMode, Watcher};
use sha2::{Digest as _, Sha256};
use crate::ui;
const DEBOUNCE: Duration = Duration::from_millis(300);
const MANIFEST: &str = "portaki.module.json";
#[derive(Debug, Parser)]
pub struct DevArgs {
#[arg(long)]
pub watch: bool,
#[arg(long)]
pub url: Option<String>,
#[arg(long, conflicts_with_all = ["watch", "dispatch"])]
pub forget: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "")]
pub dispatch: Option<String>,
#[arg(long, default_value = "{}")]
pub params: String,
#[arg(long, default_value = "query")]
pub kind: String,
}
pub async fn run(args: DevArgs) -> Result<()> {
ui::header(
"portaki dev",
"Runs against the real host in the hosted sandbox — not a local mock.",
);
let module_root = std::env::current_dir().context("current_dir")?;
if args.dispatch.as_deref() == Some("") {
return list_operations(&module_root);
}
let mut token = crate::auth::access_token()?;
let module_id = read_module_id(&module_root)?;
let base_url = base_url(&args);
if args.forget {
return forget(&base_url, &module_id, &token).await;
}
let session = crate::dev_session::start(&base_url, &module_id, &token).await?;
{
let release = session.release();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
release.now().await;
ui::blank();
std::process::exit(130);
}
});
}
let mut last_digest = String::new();
let first = cycle(
&args,
&base_url,
&module_root,
&module_id,
&mut token,
&mut last_digest,
session.session_id(),
)
.await;
if first.is_err() || !args.watch {
session.release().now().await;
}
first?;
if !args.watch {
ui::blank();
return Ok(());
}
let src = module_root.join("src");
let manifest = module_root.join(MANIFEST);
ui::list(
"watching",
&[
(
"src/",
"every save rebuilds, redeploys and dispatches again",
),
(
MANIFEST,
"surfaces and permissions take effect without touching a .rs file",
),
],
);
ui::blank();
ui::advice("a build that fails does not stop the loop — fix and save again");
ui::detail(format!("from {}", module_root.display()));
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |event| {
let _ = tx.send(event);
})
.context("start file watcher")?;
watcher
.watch(&src, RecursiveMode::Recursive)
.with_context(|| format!("watch {}", src.display()))?;
watcher
.watch(&manifest, RecursiveMode::NonRecursive)
.with_context(|| format!("watch {}", manifest.display()))?;
loop {
if rx.recv().is_err() {
return Ok(());
}
while rx.recv_timeout(DEBOUNCE).is_ok() {}
ui::blank();
ui::rule(&chrono::Local::now().format("%H:%M:%S").to_string());
if let Err(failure) = cycle(
&args,
&base_url,
&module_root,
&module_id,
&mut token,
&mut last_digest,
session.session_id(),
)
.await
{
ui::report(&failure);
}
}
}
fn list_operations(module_root: &Path) -> Result<()> {
let (manifest, source) = crate::manifest::load_manifest(module_root, None)?;
match source {
crate::manifest::ManifestSource::Built(path) => ui::detail(format!(
"from {}",
path.strip_prefix(module_root).unwrap_or(&path).display()
)),
crate::manifest::ManifestSource::Emissions => {
ui::detail("from the SDK emissions — no build output yet")
}
}
if manifest.queries.is_empty() && manifest.commands.is_empty() {
ui::warn(format!("{} exposes no operation", manifest.id));
ui::detail("add a #[portaki_sdk::query] or #[portaki_sdk::command] function, then build");
ui::blank();
return Ok(());
}
show(
"queries · read-only",
manifest
.queries
.iter()
.map(|query| (query.name.as_str(), query.r#fn.as_str())),
);
show(
"commands · mutating",
manifest
.commands
.iter()
.map(|command| (command.name.as_str(), command.r#fn.as_str())),
);
let sample = manifest
.queries
.first()
.map(|query| query.name.as_str())
.or_else(|| {
manifest
.commands
.first()
.map(|command| command.name.as_str())
})
.unwrap_or("listThings");
ui::next(&[(
&format!("portaki dev --dispatch {sample}"),
"build, deploy, then run it",
)]);
ui::blank();
ui::advice("--kind command for a mutating one · --params '{…}' passes arguments");
ui::blank();
Ok(())
}
fn show<'a>(title: &str, operations: impl Iterator<Item = (&'a str, &'a str)>) {
let rows: Vec<(&str, &str)> = operations.collect();
if rows.is_empty() {
return;
}
ui::list(title, &rows);
}
async fn cycle(
args: &DevArgs,
base_url: &str,
module_root: &Path,
module_id: &str,
token: &mut String,
last_digest: &mut String,
session: Option<&str>,
) -> Result<()> {
build(module_root)?;
crate::commands::build::refresh_outputs(module_root)?;
let wasm_path = crate::oci::pack::find_wasm_artifact(module_root, module_id)?;
let wasm = std::fs::read(&wasm_path)
.with_context(|| format!("read {} — did the build produce it?", wasm_path.display()))?;
let manifest = sandbox_manifest(module_root)?;
let fingerprint = upload_fingerprint(&wasm, &manifest);
if fingerprint == *last_digest {
ui::skipped(format!(
"unchanged ({}) — nothing to upload",
short(&sha256(&wasm))
));
return Ok(());
}
let uploading = ui::step(format!("deploying {module_id} to the sandbox"));
let first = deploy(base_url, module_id, token, &wasm, &manifest, session).await;
let deployed = match first {
Err(failure) if failure.is::<Unauthorized>() => {
uploading.say("renewing the access token");
*token = reauthenticate().await?;
uploading.say(format!("deploying {module_id} to the sandbox"));
deploy(base_url, module_id, token, &wasm, &manifest, session).await?
}
other => other.map_err(|failure| {
uploading.abandon();
failure
})?,
};
uploading.done(format!("deployed {module_id}"));
ui::field("digest", short(&deployed.digest));
ui::field("size", ui::bytes(deployed.size_bytes));
*last_digest = fingerprint;
if let Some(operation) = &args.dispatch {
let running = ui::step(format!("dispatching {} {operation}", args.kind));
let first = dispatch(args, base_url, module_id, token, operation).await;
let trace = match first {
Err(failure) if failure.is::<Unauthorized>() => {
running.say("renewing the access token");
*token = reauthenticate().await?;
dispatch(args, base_url, module_id, token, operation).await?
}
other => other.map_err(|failure| {
running.abandon();
failure
})?,
};
running.done(format!(
"{} {operation} — {} ms",
args.kind, trace.duration_ms
));
print_trace(&trace);
}
Ok(())
}
pub(crate) async fn reauthenticate() -> Result<String> {
crate::auth::refresh()
.await
.context("renew the session — run `portaki login` if this keeps failing")
}
pub(crate) fn build(module_root: &Path) -> Result<()> {
let mut cmd = std::process::Command::new("cargo");
cmd.current_dir(module_root)
.args(["build", "--release", "--target", "wasm32-unknown-unknown"]);
ui::command("compiling wasm32-unknown-unknown (release)", &mut cmd)
.context("cargo build wasm32")
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeployResponse {
pub(crate) digest: String,
pub(crate) size_bytes: u64,
}
pub(crate) async fn deploy(
base_url: &str,
module_id: &str,
token: &str,
wasm: &[u8],
manifest: &str,
session: Option<&str>,
) -> Result<DeployResponse> {
let mut form = reqwest::multipart::Form::new()
.part(
"wasm",
reqwest::multipart::Part::bytes(wasm.to_vec()).file_name("backend.wasm"),
)
.text("manifest", manifest.to_owned());
if let Some(session) = session {
form = form.text("sessionId", session.to_owned());
}
let response = reqwest::Client::new()
.post(format!(
"{}/dev/v1/modules/{module_id}/dev-deploy",
base_url
))
.bearer_auth(token)
.multipart(form)
.send()
.await
.context("upload to the dev platform")?;
read_json(response).await
}
async fn forget(base_url: &str, module_id: &str, token: &str) -> Result<()> {
let forgetting = ui::step(format!("forgetting {module_id}"));
let response = reqwest::Client::new()
.delete(format!("{base_url}/dev/v1/modules/{module_id}/dev-deploy"))
.bearer_auth(token)
.send()
.await
.map_err(|failure| {
forgetting.abandon();
failure
})
.context("ask the dev platform to forget this module")?;
let status = response.status();
if !status.is_success() {
forgetting.abandon();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("the dev platform answered {status}: {}", body.trim());
}
forgetting.done(format!("{module_id} is gone from the sandbox"));
ui::advice("its inventory row is what goes — a published version, if any, is untouched");
ui::blank();
Ok(())
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct DispatchResponse {
#[serde(default)]
result_json: String,
#[serde(default)]
duration_ms: u64,
#[serde(default)]
host_calls: Vec<HostCall>,
#[serde(default)]
captured_effects: Vec<CapturedEffect>,
#[serde(default)]
published_events: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct HostCall {
op: String,
duration_micros: u64,
#[serde(default)]
error_code: String,
#[serde(default)]
args_json: Option<String>,
#[serde(default)]
result_json: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CapturedEffect {
op: String,
detail_json: String,
}
async fn dispatch(
args: &DevArgs,
base_url: &str,
module_id: &str,
token: &str,
operation: &str,
) -> Result<DispatchResponse> {
let body = serde_json::json!({
"operation": operation,
"kind": args.kind,
"paramsJson": args.params,
});
let response = reqwest::Client::new()
.post(format!("{}/dev/v1/modules/{module_id}/dispatch", base_url))
.bearer_auth(token)
.json(&body)
.send()
.await
.context("dispatch on the dev platform")?;
read_json(response).await
}
fn print_trace(trace: &DispatchResponse) {
if !trace.host_calls.is_empty() || !trace.captured_effects.is_empty() {
ui::detail("what the run asked the host for:");
}
for call in &trace.host_calls {
if let Some(line) = log_line(call) {
ui::detail(line);
continue;
}
let outcome = if call.error_code.is_empty() {
String::new()
} else {
format!(" ← {}", call.error_code)
};
ui::detail(format!(
"{:>7} µs {}{}",
call.duration_micros, call.op, outcome
));
if ui::verbose() {
if let Some(args) = value_line("args", call.args_json.as_deref()) {
ui::detail(args);
}
if let Some(result) = value_line("result", call.result_json.as_deref()) {
ui::detail(result);
}
}
}
for effect in &trace.captured_effects {
ui::detail(format!("captured {} {}", effect.op, effect.detail_json));
}
for event in &trace.published_events {
ui::detail(format!("would publish {event}"));
}
if !trace.captured_effects.is_empty() || !trace.published_events.is_empty() {
ui::detail("captured and would-publish lines were held, not performed");
}
if !trace.result_json.is_empty() {
ui::result(&trace.result_json);
}
}
const VALUE_WIDTH: usize = 160;
fn log_line(call: &HostCall) -> Option<String> {
if call.op != "log" {
return None;
}
let args: serde_json::Value = serde_json::from_str(call.args_json.as_deref()?).ok()?;
let level = args
.get("level")
.and_then(serde_json::Value::as_str)
.unwrap_or("info");
let message = args.get("message").and_then(serde_json::Value::as_str)?;
let fields = args
.get("fieldsJson")
.and_then(serde_json::Value::as_str)
.filter(|fields| !fields.is_empty() && *fields != "{}")
.map(|fields| format!(" {}", truncate(fields)))
.unwrap_or_default();
Some(format!("{level:>7} {message}{fields}"))
}
fn value_line(label: &str, value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() {
return None;
}
Some(format!("{label:>7} {}", truncate(value)))
}
fn truncate(value: &str) -> String {
if value.chars().count() <= VALUE_WIDTH {
return value.to_string();
}
let kept: String = value.chars().take(VALUE_WIDTH).collect();
format!("{kept}…")
}
#[derive(Debug)]
struct Unauthorized;
impl std::fmt::Display for Unauthorized {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("the dev platform refused the token")
}
}
impl std::error::Error for Unauthorized {}
pub(crate) async fn read_json<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
) -> Result<T> {
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(anyhow::Error::new(Unauthorized));
}
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
bail!("the dev platform answered {status}: {body}");
}
serde_json::from_str(&body).with_context(|| format!("unexpected answer: {body}"))
}
fn base_url(args: &DevArgs) -> String {
resolve_base_url(
args.url.as_deref(),
std::env::var("PORTAKI_DEV_URL").ok().as_deref(),
std::env::var("PORTAKI_API_URL").ok().as_deref(),
)
}
pub(crate) fn resolve_base_url(
explicit: Option<&str>,
dev_var: Option<&str>,
api_var: Option<&str>,
) -> String {
let candidate = [explicit, dev_var, api_var]
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
.unwrap_or("https://api.portaki.app");
candidate.trim_end_matches('/').to_string()
}
pub(crate) fn read_module_id(module_root: &Path) -> Result<String> {
let manifest = module_root.join(MANIFEST);
let raw = std::fs::read_to_string(&manifest)
.with_context(|| format!("read {} — run from the module root", manifest.display()))?;
let parsed: serde_json::Value =
serde_json::from_str(&raw).context("parse portaki.module.json")?;
parsed
.get("id")
.and_then(|id| id.as_str())
.map(str::to_owned)
.context("portaki.module.json carries no id")
}
pub(crate) fn sandbox_manifest(module_root: &Path) -> Result<String> {
let raw_manifest =
std::fs::read_to_string(module_root.join(MANIFEST)).context("read portaki.module.json")?;
let manifest = crate::oci::pack::stamp_sdk_version(
&raw_manifest,
crate::oci::pack::resolved_sdk_version(module_root)?,
)?;
let manifest =
match std::fs::read_to_string(module_root.join(crate::manifest::loader::BUILT_MANIFEST)) {
Ok(built) => crate::oci::pack::stamp_built_declarations(&manifest, &built)?,
Err(_) => manifest,
};
Ok(manifest)
}
fn upload_fingerprint(wasm: &[u8], manifest: &str) -> String {
sha256(&[wasm, b"\0", manifest.as_bytes()].concat())
}
fn sha256(bytes: &[u8]) -> String {
format!("sha256:{:x}", Sha256::digest(bytes))
}
pub(crate) fn short(digest: &str) -> String {
digest.chars().take("sha256:".len() + 12).collect()
}
#[cfg(test)]
mod tests {
use super::DevArgs;
use clap::Parser;
#[test]
fn forget_does_not_go_with_the_flags_that_deploy() {
let forgetting = DevArgs::try_parse_from(["dev", "--forget"]).expect("forget alone");
assert!(forgetting.forget);
assert!(!forgetting.watch && forgetting.dispatch.is_none());
for pushing in [
vec!["dev", "--forget", "--watch"],
vec!["dev", "--forget", "--dispatch", "getConfig"],
] {
assert!(DevArgs::try_parse_from(&pushing).is_err(), "{pushing:?}");
}
}
#[test]
fn a_manifest_change_alone_is_something_to_upload() {
let wasm = b"\0asm same bytes";
assert_ne!(
upload_fingerprint(wasm, r#"{"version":"0.4.0"}"#),
upload_fingerprint(wasm, r#"{"version":"0.4.1"}"#)
);
assert_eq!(
upload_fingerprint(wasm, "{}"),
upload_fingerprint(wasm, "{}")
);
}
use super::*;
const PROD: &str = "https://api.portaki.app";
#[test]
fn a_digest_is_computed_on_the_bytes() {
assert_eq!(
sha256(b"\0asm"),
"sha256:cd5d4935a48c0672cb06407bb443bc0087aff947c6b864bac886982c73b3027f"
);
}
#[test]
fn a_short_digest_stays_recognisable() {
assert_eq!(short("sha256:abcdef0123456789"), "sha256:abcdef012345");
}
#[test]
fn the_module_id_comes_from_the_manifest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("portaki.module.json"),
r#"{"id":"nuki","version":"1.4.0"}"#,
)
.unwrap();
assert_eq!(read_module_id(dir.path()).unwrap(), "nuki");
}
#[test]
fn a_deploy_response_is_read_as_devapi_writes_it() {
let body = r#"{"moduleId":"access-guide","version":"0.3.2",
"digest":"sha256:3e9fc0c17866a50a005799fc53c854e696a101a9a6be0884b701cf157b4a9d1a",
"permissions":["email","kv","platform"],"sizeBytes":1024374,
"deployedAt":"2026-09-09T10:21:22.119744997Z"}"#;
let parsed: DeployResponse = serde_json::from_str(body).expect("réponse de deploy lisible");
assert_eq!(parsed.size_bytes, 1_024_374);
assert!(parsed.digest.starts_with("sha256:"));
}
#[test]
fn a_dispatch_response_carries_its_values_not_defaults() {
let body = r#"{"runId":"4d7a","hasResult":true,"resultJson":"{\"ok\":true}",
"durationMs":42,"publishedEvents":[],
"capturedEffects":[{"op":"email.send","detailJson":"{}","at":"2026-09-09T10:00:00Z"}],
"hostCalls":[{"op":"kv.get","durationMicros":128,"errorCode":""}]}"#;
let parsed: DispatchResponse =
serde_json::from_str(body).expect("réponse de dispatch lisible");
assert_eq!(parsed.duration_ms, 42);
assert_eq!(parsed.host_calls.len(), 1);
assert_eq!(parsed.host_calls[0].duration_micros, 128);
assert_eq!(parsed.captured_effects[0].detail_json, "{}");
}
#[test]
fn a_host_call_keeps_the_values_the_sandbox_sent() {
let body = r#"{"runId":"4d7a","hasResult":false,"resultJson":"","durationMs":3,
"hostCalls":[{"op":"kv.get","durationMicros":128,"errorCode":"",
"argsJson":"{\"key\":\"wifi\"}","resultJson":"{\"value\":\"soleil\"}"}]}"#;
let parsed: DispatchResponse =
serde_json::from_str(body).expect("réponse de dispatch lisible");
assert_eq!(
parsed.host_calls[0].args_json.as_deref(),
Some(r#"{"key":"wifi"}"#)
);
assert_eq!(
parsed.host_calls[0].result_json.as_deref(),
Some(r#"{"value":"soleil"}"#)
);
}
#[test]
fn a_log_call_reads_as_the_line_the_module_wrote() {
let call = HostCall {
op: "log".into(),
duration_micros: 41,
error_code: String::new(),
args_json: Some(
r#"{"level":"warn","message":"clé absente","fieldsJson":"{\"key\":\"wifi\"}"}"#
.into(),
),
result_json: None,
};
let line = log_line(&call).expect("une ligne de journal");
assert!(line.contains("warn"), "{line}");
assert!(line.contains("clé absente"), "{line}");
assert!(line.contains(r#"{"key":"wifi"}"#), "{line}");
}
#[test]
fn a_log_line_drops_empty_fields() {
let call = HostCall {
op: "log".into(),
duration_micros: 12,
error_code: String::new(),
args_json: Some(r#"{"level":"info","message":"prêt","fieldsJson":"{}"}"#.into()),
result_json: None,
};
assert_eq!(log_line(&call).unwrap().trim_end(), " info prêt");
}
#[test]
fn a_call_without_values_is_not_a_log_line() {
let without_values = HostCall {
op: "log".into(),
duration_micros: 41,
error_code: String::new(),
args_json: None,
result_json: None,
};
let other_op = HostCall {
op: "kv.get".into(),
duration_micros: 41,
error_code: String::new(),
args_json: Some(r#"{"key":"wifi"}"#.into()),
result_json: None,
};
assert!(log_line(&without_values).is_none());
assert!(log_line(&other_op).is_none());
}
#[test]
fn a_value_line_is_cut_before_it_floods_the_trace() {
let long = format!("{{\"v\":\"{}\"}}", "a".repeat(400));
let line = value_line("result", Some(&long)).expect("une ligne de valeur");
assert!(line.ends_with('…'), "{line}");
assert!(line.chars().count() <= VALUE_WIDTH + 12, "{line}");
assert!(value_line("result", None).is_none());
assert!(value_line("result", Some(" ")).is_none());
}
#[test]
fn falls_back_to_the_shared_api_variable() {
assert_eq!(
resolve_base_url(None, None, Some("https://api-staging.portaki.app")),
"https://api-staging.portaki.app"
);
}
#[test]
fn prefers_the_dedicated_variable() {
assert_eq!(
resolve_base_url(
None,
Some("https://sandbox.example"),
Some("https://api.example")
),
"https://sandbox.example"
);
}
#[test]
fn prefers_the_flag_and_trims_the_trailing_slash() {
assert_eq!(
resolve_base_url(
Some("https://explicit.example/"),
Some("https://ignored.example"),
None
),
"https://explicit.example"
);
}
#[test]
fn ignores_empty_and_blank_variables() {
assert_eq!(resolve_base_url(None, Some(""), Some(" ")), PROD);
assert_eq!(resolve_base_url(Some(""), None, None), PROD);
}
#[test]
fn falls_back_to_production_when_nothing_is_set() {
assert_eq!(resolve_base_url(None, None, None), PROD);
}
}