diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 60bf7f7..f91482f 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -830,6 +830,19 @@ pub struct TriggerTemplateInfo { pub sealed: bool, /// §11.6: `true` for a shared-collection template. pub shared: bool, + /// Full `TriggerDetails` as internally-tagged JSON (`{ "kind": …, … }`), + /// so `pic pull --group` can round-trip a template's ops / schedule / + /// timezone / queue name / visibility timeout, not just the display target. + /// `#[serde(default)]` on the read side so an older server (which omits it) + /// still deserializes; the display command reads `target`, not this. + #[serde(default)] + pub details: serde_json::Value, + /// Wire dispatch mode (`sync`/`async`) — round-trip only. + #[serde(default)] + pub dispatch_mode: String, + /// Per-trigger retry ceiling — round-trip only. + #[serde(default)] + pub retry_max_attempts: u32, } /// One row of the read-only §11 tail route-template report (`pic routes ls @@ -838,12 +851,17 @@ pub struct TriggerTemplateInfo { /// rows, so the semantic bits are shown instead. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RouteTemplateInfo { - pub method: String, + /// `None` = any method. Carried raw (not the "ANY" display munge) so + /// `pic pull --group` round-trips it; the display command applies the munge. + pub method: Option, + pub host_kind: HostKind, + /// Raw host (empty for `Any`, suffix for `Wildcard`), not the display munge. pub host: String, - pub path_kind: String, + pub host_param_name: Option, + pub path_kind: PathKind, pub path: String, pub script: String, - pub dispatch: String, + pub dispatch_mode: DispatchMode, pub enabled: bool, /// §11 tail: `true` for a sealed (non-suppressible) template. pub sealed: bool, @@ -3651,6 +3669,9 @@ impl ApplyService { enabled: t.enabled, sealed: t.sealed, shared: t.shared, + details: serde_json::to_value(&t.details).unwrap_or(serde_json::Value::Null), + dispatch_mode: t.dispatch_mode.as_str().to_string(), + retry_max_attempts: t.retry_max_attempts, } }) .collect()) @@ -3681,16 +3702,14 @@ impl ApplyService { Ok(routes .into_iter() .map(|r| RouteTemplateInfo { - method: r.method.clone().unwrap_or_else(|| "ANY".into()), - host: match r.host_kind { - HostKind::Any => "any".to_string(), - HostKind::Strict => format!("strict:{}", r.host), - HostKind::Wildcard => format!("*.{}", r.host), - }, - path_kind: path_kind_str(r.path_kind).to_string(), + method: r.method.clone(), + host_kind: r.host_kind, + host: r.host.clone(), + host_param_name: r.host_param_name.clone(), + path_kind: r.path_kind, path: r.path.clone(), script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(), - dispatch: r.dispatch_mode.as_str().to_string(), + dispatch_mode: r.dispatch_mode, enabled: r.enabled, sealed: r.sealed, }) diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 084b77e..a07b3e3 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1702,7 +1702,9 @@ pub struct SuppressionDto { pub reference: String, } -/// One row of the §11 tail trigger-template report. +/// One row of the §11 tail trigger-template report. Carries both the display +/// `target` and the full round-trip `details`/`dispatch_mode`/`retry_max_attempts` +/// (`pic triggers ls --group` reads the former; `pic pull --group` the latter). #[derive(Debug, Deserialize)] pub struct TriggerTemplateDto { pub kind: String, @@ -1717,23 +1719,36 @@ pub struct TriggerTemplateDto { /// §11.6: `true` for a shared-collection template. #[serde(default)] pub shared: bool, + /// Full `TriggerDetails` JSON (internally tagged) — round-trip only. + #[serde(default)] + pub details: serde_json::Value, + /// Wire dispatch mode (`sync`/`async`) — round-trip only. + #[serde(default)] + pub dispatch_mode: String, + /// Per-trigger retry ceiling — round-trip only. + #[serde(default)] + pub retry_max_attempts: u32, } -/// One row of the §11 tail route-template report. +/// One row of the §11 tail route-template report. Raw manifest-shaped fields so +/// `pic pull --group` round-trips; the display command applies the "ANY"/host +/// munge client-side (consistent with the app `pic routes ls`). #[derive(Debug, Deserialize)] pub struct RouteTemplateDto { #[serde(default)] - pub method: String, + pub method: Option, + pub host_kind: HostKind, #[serde(default)] pub host: String, #[serde(default)] - pub path_kind: String, + pub host_param_name: Option, + pub path_kind: PathKind, #[serde(default)] pub path: String, #[serde(default)] pub script: String, #[serde(default)] - pub dispatch: String, + pub dispatch_mode: DispatchMode, pub enabled: bool, /// §11 tail: `true` for a sealed (non-suppressible) template. #[serde(default)] diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 4b4f8ea..fcff743 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -1,11 +1,13 @@ -//! `pic pull [--dir .]` — export an app's current server state into -//! a `picloud.toml` manifest plus `scripts/.rhai` source files, for -//! declarative management with `pic plan` / `pic apply`. +//! `pic pull [--dir .]` / `pic pull --group ` — export a node's +//! current server state into a `picloud.toml` manifest plus +//! `scripts/.rhai` source files, for declarative management with `pic +//! plan` / `pic apply`. //! //! Read-only: issues `GET`s only and writes local files. Every trigger -//! kind is exported except `email` — the server stores the *sealed secret -//! value*, not the secret name, so the manifest's `inbound_secret_ref` -//! can't be reconstructed (email triggers must be set up by hand). +//! kind is exported except `email` (the server stores the *sealed secret +//! value*, not the secret name, so `inbound_secret_ref` can't be +//! reconstructed) and `dead_letter` (not manifest-representable) — both are +//! skipped with a warning. use std::collections::HashMap; use std::path::Path; @@ -14,25 +16,40 @@ use anyhow::{Context, Result}; use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId}; use serde::Deserialize; -use crate::client::{Client, VarOwnerArg}; +use crate::client::{Client, NodeKind, VarOwnerArg}; +use crate::cmds::{require_one_owner, OwnerRef}; use crate::config; use crate::manifest::{ - CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp, - ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, ManifestWorkflow, - ManifestWorkflowRetry, ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec, - MANIFEST_FILE, + CollectionDecl, CollectionKind, CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, + KvTriggerSpec, Manifest, ManifestApp, ManifestGroup, ManifestRoute, ManifestScript, + ManifestSecrets, ManifestSuppress, ManifestTriggers, ManifestWorkflow, ManifestWorkflowRetry, + ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE, }; use crate::output::{KvBlock, OutputMode}; -pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> { +/// Dispatch on the owner: an app (positional, the original form) or a group +/// (`--group`). Exactly one is required. +pub async fn run( + app: Option<&str>, + group: Option<&str>, + dir: &Path, + force: bool, + mode: OutputMode, +) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; + match require_one_owner(app, group)? { + OwnerRef::App(a) => pull_app(&client, a, dir, force, mode).await, + OwnerRef::Group(g) => pull_group(&client, g, dir, force, mode).await, + } +} - // Refuse to clobber an existing project (mirrors `pic init`). `pull` - // overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a - // stray `pic pull ` in a populated dir would destroy local - // edits. Fail fast — before any network call or file write — unless the - // operator opted in with `--force`. +/// Refuse to clobber an existing project (mirrors `pic init`). `pull` overwrites +/// `picloud.toml` and every colliding `scripts/*.rhai`, so a stray `pic pull +/// ` in a populated dir would destroy local edits. Fail fast — +/// before any network call or file write — unless the operator opted in with +/// `--force`. +fn check_clobber(dir: &Path, force: bool) -> Result { let manifest_path = dir.join(MANIFEST_FILE); if !force && manifest_path.exists() { anyhow::bail!( @@ -41,6 +58,17 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> manifest_path.display() ); } + Ok(manifest_path) +} + +async fn pull_app( + client: &Client, + app_ident: &str, + dir: &Path, + force: bool, + mode: OutputMode, +) -> Result<()> { + let manifest_path = check_clobber(dir, force)?; // One GET per resource kind (routes are per-script, below). let app = client.apps_get(app_ident).await?; @@ -117,48 +145,11 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> } } - // The server does not constrain script names to a filesystem-safe - // charset, so a name containing a path separator or `..` would let `pull` - // write outside the project dir. Validate ALL names up front, before any - // file is written, so a single bad name can't leave a half-written dir. - // Reject rather than sanitize: a silent rename would desync the manifest - // `name` from its `file`. - for s in &scripts { - if !is_safe_filename(&s.name) { - anyhow::bail!( - "script name {:?} is not filesystem-safe (a path separator, \ - `..`, a leading dot, a control character, or longer than 200 \ - bytes); cannot pull", - s.name - ); - } - } - // Scripts: write each source next to the manifest and record a path ref. - let scripts_dir = dir.join("scripts"); - std::fs::create_dir_all(&scripts_dir) - .with_context(|| format!("creating {}", scripts_dir.display()))?; - let mut manifest_scripts = Vec::with_capacity(scripts.len()); - for s in &scripts { - let rel = format!("scripts/{}.rhai", s.name); - std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?; - manifest_scripts.push(ManifestScript { - name: s.name.clone(), - file: rel, - kind: s.kind, - description: s.description.clone(), - timeout_seconds: i32::try_from(s.timeout_seconds).ok(), - memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(), - sandbox: if s.sandbox.is_empty() { - None - } else { - Some(s.sandbox) - }, - enabled: s.enabled, - }); - } + let manifest_scripts = write_scripts(dir, &scripts)?; - // Triggers: map the five settled kinds; warn + skip the rest. + // Triggers: map the settled kinds; warn + skip the rest. App-owned triggers + // are never sealed/shared (both group-only), so pass `false`. let mut manifest_triggers = ManifestTriggers::default(); let mut skipped: Vec = Vec::new(); for t in &triggers { @@ -166,89 +157,20 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> .get(&t.script_id) .cloned() .unwrap_or_else(|| t.script_id.to_string()); - let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode); - let retry_max_attempts = Some(t.retry_max_attempts); - match t.kind.as_str() { - "kv" => { - let d: CollectionDetails = decode_details(&t.details, &t.kind)?; - manifest_triggers.kv.push(KvTriggerSpec { - script, - collection_glob: d.collection_glob, - ops: d.ops, - dispatch_mode, - retry_max_attempts, - // app-owned triggers are never sealed/shared (group-only). - sealed: false, - shared: false, - }); - } - "docs" => { - let d: CollectionDetails = decode_details(&t.details, &t.kind)?; - manifest_triggers.docs.push(DocsTriggerSpec { - script, - collection_glob: d.collection_glob, - ops: d.ops, - dispatch_mode, - retry_max_attempts, - sealed: false, - shared: false, - }); - } - "files" => { - let d: CollectionDetails = decode_details(&t.details, &t.kind)?; - manifest_triggers.files.push(FilesTriggerSpec { - script, - collection_glob: d.collection_glob, - ops: d.ops, - dispatch_mode, - retry_max_attempts, - sealed: false, - shared: false, - }); - } - "cron" => { - let d: CronDetails = decode_details(&t.details, &t.kind)?; - manifest_triggers.cron.push(CronTriggerSpec { - script, - schedule: d.schedule, - timezone: d.timezone, - dispatch_mode, - retry_max_attempts, - }); - } - "pubsub" => { - let d: PubsubDetails = decode_details(&t.details, &t.kind)?; - manifest_triggers.pubsub.push(PubsubTriggerSpec { - script, - topic_pattern: d.topic_pattern, - dispatch_mode, - retry_max_attempts, - // app-owned triggers are never sealed/shared (group-only). - sealed: false, - shared: false, - }); - } - "queue" => { - let d: QueueDetails = decode_details(&t.details, &t.kind)?; - manifest_triggers.queue.push(QueueTriggerSpec { - script, - queue_name: d.queue_name, - visibility_timeout_secs: Some(d.visibility_timeout_secs), - dispatch_mode, - retry_max_attempts, - // app-owned triggers are never shared (group-only). - shared: false, - }); - } - // `email` is skipped: the server stores the sealed secret value, - // not the secret *name*, so the manifest's `inbound_secret_ref` - // can't be reconstructed — set it up by hand. - other => skipped.push(format!("{other} ({})", t.id)), - } - } - for s in &skipped { - eprintln!("warning: skipping {s} trigger — not yet representable in the manifest"); + push_trigger( + &mut manifest_triggers, + &mut skipped, + &t.kind, + &t.details, + script, + DispatchMode::from_wire(&t.dispatch_mode), + Some(t.retry_max_attempts), + false, + false, + &t.id.to_string(), + )?; } + warn_skipped(&skipped); let manifest = Manifest { app: Some(ManifestApp { @@ -266,20 +188,321 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> names: secrets.iter().map(|s| s.name.clone()).collect(), }, vars: manifest_vars, - suppress: crate::manifest::ManifestSuppress::default(), + suppress: ManifestSuppress::default(), workflows: workflows.iter().map(wire_workflow_to_manifest).collect(), // `pull` does not round-trip interceptor markers yet (read surface TBD); // an interceptor authored in the manifest survives re-apply regardless. interceptors: Vec::new(), }; - std::fs::write(&manifest_path, manifest.to_toml()?) + write_manifest_and_report(&manifest_path, &manifest, mode) +} + +/// `pic pull --group ` — export a group node (a `[group]` manifest): its +/// own scripts, `[[routes]]`/`[[triggers.*]]` TEMPLATES (which can be +/// `sealed`/`shared`, unlike app resources), `[vars]`/`[secrets]`, +/// `extension_points`, `collections`, and `[suppress]`. Group nodes own no +/// workflows. +async fn pull_group( + client: &Client, + group_ident: &str, + dir: &Path, + force: bool, + mode: OutputMode, +) -> Result<()> { + let manifest_path = check_clobber(dir, force)?; + + let group = client.groups_get(group_ident).await?; + let scripts = client.group_scripts_list(group_ident).await?; + let routes_wire = client.group_routes_list(group_ident).await?; + let triggers_wire = client.group_triggers_list(group_ident).await?; + let secrets = client + .secrets_list(VarOwnerArg::Group(group_ident), None) + .await? + .secrets; + let extension_points: Vec = client + .extension_points_list(NodeKind::Group, group_ident) + .await? + .into_iter() + .filter(|ep| ep.declared_here) + .map(|ep| ep.name) + .collect(); + // The group's OWN env-agnostic vars (inherited/tombstone excluded, as the + // app path). A value TOML can't represent is skipped with a warning. + let mut manifest_vars = std::collections::BTreeMap::new(); + for v in client + .vars_list(VarOwnerArg::Group(group_ident)) + .await? + .vars + { + if v.env != "*" || v.is_tombstone { + continue; + } + match toml::Value::try_from(&v.value) { + Ok(tv) => { + manifest_vars.insert(v.key, tv); + } + Err(_) => eprintln!( + "warning: skipping var `{}` — its value can't be represented in TOML", + v.key + ), + } + } + + // Shared collections this group offers (§11.6): a bare `kv` normalizes to a + // string, everything else to a `{ name, kind }` table. + let mut collections = Vec::new(); + for c in client.collections_list(group_ident).await? { + let kind = match c.kind.as_str() { + "kv" => { + collections.push(CollectionDecl::Name(c.name)); + continue; + } + "docs" => CollectionKind::Docs, + "files" => CollectionKind::Files, + "topic" => CollectionKind::Topic, + "queue" => CollectionKind::Queue, + other => { + eprintln!( + "warning: skipping collection `{}` — unknown kind `{other}`", + c.name + ); + continue; + } + }; + collections.push(CollectionDecl::Full { name: c.name, kind }); + } + + // `[suppress]` this group declares for its whole subtree (§11 tail M1). + let mut suppress = ManifestSuppress::default(); + for s in client.group_suppressions_list(group_ident).await? { + match s.target_kind.as_str() { + "trigger" => suppress.triggers.push(s.reference), + "route" => suppress.routes.push(s.reference), + _ => {} + } + } + + // Scripts. + let manifest_scripts = write_scripts(dir, &scripts)?; + + // Route TEMPLATES → `[[routes]]` (host/method un-munged; `sealed` carried). + let routes = routes_wire + .into_iter() + .map(|r| ManifestRoute { + script: r.script, + method: r.method, + host_kind: r.host_kind, + host: r.host, + host_param_name: r.host_param_name, + path_kind: r.path_kind, + path: r.path, + dispatch_mode: r.dispatch_mode, + enabled: r.enabled, + sealed: r.sealed, + }) + .collect(); + + // Trigger TEMPLATES → `[[triggers.*]]`. Group templates carry `sealed`/ + // `shared`; `email`/`dead_letter` are skipped (see the module header). + let mut manifest_triggers = ManifestTriggers::default(); + let mut skipped: Vec = Vec::new(); + for t in &triggers_wire { + push_trigger( + &mut manifest_triggers, + &mut skipped, + &t.kind, + &t.details, + t.script.clone(), + DispatchMode::from_wire(&t.dispatch_mode), + Some(t.retry_max_attempts), + t.sealed, + t.shared, + &t.kind, + )?; + } + warn_skipped(&skipped); + + let manifest = Manifest { + app: None, + group: Some(ManifestGroup { + slug: group.group.slug.clone(), + name: group.group.name.clone(), + description: group.group.description.clone(), + extension_points, + collections, + }), + project: None, + scripts: manifest_scripts, + routes, + triggers: manifest_triggers, + secrets: ManifestSecrets { + names: secrets.iter().map(|s| s.name.clone()).collect(), + }, + vars: manifest_vars, + suppress, + // Groups own no workflows. + workflows: Vec::new(), + interceptors: Vec::new(), + }; + + write_manifest_and_report(&manifest_path, &manifest, mode) +} + +/// Validate every script name is filesystem-safe (up front, so a single bad +/// name can't leave a half-written dir), then write each source under +/// `scripts/` and return the manifest specs. Shared by the app + group paths. +fn write_scripts(dir: &Path, scripts: &[picloud_shared::Script]) -> Result> { + // Reject rather than sanitize: a silent rename would desync the manifest + // `name` from its `file`. + for s in scripts { + if !is_safe_filename(&s.name) { + anyhow::bail!( + "script name {:?} is not filesystem-safe (a path separator, \ + `..`, a leading dot, a control character, or longer than 200 \ + bytes); cannot pull", + s.name + ); + } + } + let scripts_dir = dir.join("scripts"); + std::fs::create_dir_all(&scripts_dir) + .with_context(|| format!("creating {}", scripts_dir.display()))?; + let mut manifest_scripts = Vec::with_capacity(scripts.len()); + for s in scripts { + let rel = format!("scripts/{}.rhai", s.name); + std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?; + manifest_scripts.push(ManifestScript { + name: s.name.clone(), + file: rel, + kind: s.kind, + description: s.description.clone(), + timeout_seconds: i32::try_from(s.timeout_seconds).ok(), + memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(), + sandbox: if s.sandbox.is_empty() { + None + } else { + Some(s.sandbox) + }, + enabled: s.enabled, + }); + } + Ok(manifest_scripts) +} + +/// Decode one trigger's `details` JSON and push a manifest spec of the right +/// kind. `email`/`dead_letter`/unknown are recorded in `skipped` (keyed by +/// `id_label` — a trigger id for apps, the kind for group templates). `sealed`/ +/// `shared` are group-only (pass `false` for an app). +#[allow(clippy::too_many_arguments)] +fn push_trigger( + triggers: &mut ManifestTriggers, + skipped: &mut Vec, + kind: &str, + details: &serde_json::Value, + script: String, + dispatch_mode: Option, + retry_max_attempts: Option, + sealed: bool, + shared: bool, + id_label: &str, +) -> Result<()> { + match kind { + "kv" => { + let d: CollectionDetails = decode_details(details, kind)?; + triggers.kv.push(KvTriggerSpec { + script, + collection_glob: d.collection_glob, + ops: d.ops, + dispatch_mode, + retry_max_attempts, + sealed, + shared, + }); + } + "docs" => { + let d: CollectionDetails = decode_details(details, kind)?; + triggers.docs.push(DocsTriggerSpec { + script, + collection_glob: d.collection_glob, + ops: d.ops, + dispatch_mode, + retry_max_attempts, + sealed, + shared, + }); + } + "files" => { + let d: CollectionDetails = decode_details(details, kind)?; + triggers.files.push(FilesTriggerSpec { + script, + collection_glob: d.collection_glob, + ops: d.ops, + dispatch_mode, + retry_max_attempts, + sealed, + shared, + }); + } + "cron" => { + let d: CronDetails = decode_details(details, kind)?; + triggers.cron.push(CronTriggerSpec { + script, + schedule: d.schedule, + timezone: d.timezone, + dispatch_mode, + retry_max_attempts, + }); + } + "pubsub" => { + let d: PubsubDetails = decode_details(details, kind)?; + triggers.pubsub.push(PubsubTriggerSpec { + script, + topic_pattern: d.topic_pattern, + dispatch_mode, + retry_max_attempts, + sealed, + shared, + }); + } + "queue" => { + let d: QueueDetails = decode_details(details, kind)?; + triggers.queue.push(QueueTriggerSpec { + script, + queue_name: d.queue_name, + visibility_timeout_secs: Some(d.visibility_timeout_secs), + dispatch_mode, + retry_max_attempts, + shared, + }); + } + // `email` (sealed secret, no recoverable ref) + `dead_letter` (no + // manifest spec) are not representable. + other => skipped.push(format!("{other} ({id_label})")), + } + Ok(()) +} + +fn warn_skipped(skipped: &[String]) { + for s in skipped { + eprintln!("warning: skipping {s} trigger — not yet representable in the manifest"); + } +} + +/// Serialize the manifest to `picloud.toml` and print the summary block. +fn write_manifest_and_report( + manifest_path: &Path, + manifest: &Manifest, + mode: OutputMode, +) -> Result<()> { + std::fs::write(manifest_path, manifest.to_toml()?) .with_context(|| format!("writing {}", manifest_path.display()))?; + let owner_label = if manifest.is_group() { "group" } else { "app" }; let mut block = KvBlock::new(); block .field("manifest", manifest_path.display().to_string()) - .field("app", manifest.slug().to_string()) + .field(owner_label, manifest.slug().to_string()) .field("scripts", manifest.scripts.len().to_string()) .field("routes", manifest.routes.len().to_string()) .field("triggers", trigger_count(&manifest.triggers).to_string()) diff --git a/crates/picloud-cli/src/cmds/routes.rs b/crates/picloud-cli/src/cmds/routes.rs index 2da912a..c3f7c68 100644 --- a/crates/picloud-cli/src/cmds/routes.rs +++ b/crates/picloud-cli/src/cmds/routes.rs @@ -54,12 +54,12 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> { ]); for r in rows { table.row([ - r.method, - r.host, - r.path_kind, + r.method.clone().unwrap_or_else(|| "ANY".into()), + host_label(r.host_kind, &r.host), + path_kind_label(r.path_kind).to_string(), r.path, r.script, - r.dispatch, + dispatch_label(r.dispatch_mode).to_string(), r.enabled.to_string(), r.sealed.to_string(), ]); diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 9d0f35e..0aace3b 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -309,8 +309,11 @@ struct PlanArgs { #[derive(Args)] struct PullArgs { - /// App slug or id to export. - app: String, + /// App slug or id to export (positional). Omit when using `--group`. + app: Option, + /// Group slug or id to export as a `[group]` node instead of an app. + #[arg(long, conflicts_with = "app")] + group: Option, /// Directory to write `picloud.toml` + `scripts/` into. #[arg(long, default_value = ".")] dir: PathBuf, @@ -1569,7 +1572,16 @@ async fn main() -> ExitCode { Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await, None => cmds::plan::run(&args.file, args.env.as_deref(), mode).await, }, - Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await, + Cmd::Pull(args) => { + cmds::pull::run( + args.app.as_deref(), + args.group.as_deref(), + &args.dir, + args.force, + mode, + ) + .await + } Cmd::Config(args) => { cmds::config::run( &args.file, diff --git a/crates/picloud-cli/tests/pull.rs b/crates/picloud-cli/tests/pull.rs index 8e752a3..32b649f 100644 --- a/crates/picloud-cli/tests/pull.rs +++ b/crates/picloud-cli/tests/pull.rs @@ -1,9 +1,17 @@ //! `pic pull` journey: stand up an app with a script, route, cron trigger, //! and a secret, then export it and assert the manifest + script file. +//! +//! B3: a second case exports a GROUP node (`pic pull --group`) — its scripts, +//! a `sealed`+`shared` `[[triggers.kv]]` template, a `[[routes]]` template, a +//! shared `collections` entry, and a `[vars]` value — and asserts the exported +//! manifest re-applies clean (the round-trip carries the group-only flags). + +use std::fs; use tempfile::TempDir; use crate::common; +use crate::common::cleanup::{GroupGuard, ScriptGuard}; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] @@ -89,3 +97,123 @@ fn pull_exports_manifest_and_sources() { drop(guard); } + +fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String { + let ls = common::pic_as(env) + .args(["scripts", "ls", "--group", group]) + .output() + .expect("scripts ls"); + let table = String::from_utf8(ls.stdout).unwrap(); + table + .lines() + .map(common::cells) + .find(|c| c.get(2) == Some(&name)) + .and_then(|c| c.first().map(|s| (*s).to_string())) + .unwrap_or_else(|| panic!("group script `{name}` not found:\n{table}")) +} + +/// B3: `pic pull --group` round-trips a group node, including the group-only +/// `sealed`/`shared` template flags and a shared `collections` entry. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn pull_group_round_trips_templates_and_collections() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("pullg"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + // A group-owned handler the templates bind. + let dir = TempDir::new().expect("group pull tempdir"); + fs::create_dir_all(dir.path().join("scripts")).unwrap(); + fs::write( + dir.path().join("scripts/gh.rhai"), + r#"log::info("group handler"); "ok""#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/gh.rhai")) + .args(["--group", &group, "--name", "gh"]) + .assert() + .success(); + let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group, "gh")); + + // The group declares a shared collection, a sealed+shared kv trigger + // template, a sealed route template, and a var. + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"PullG\"\n\n\ + collections = [\"catalog\"]\n\n\ + [vars]\ntier = \"gold\"\n\n\ + [[routes]]\nscript = \"gh\"\npath = \"/gpull\"\npath_kind = \"exact\"\n\ + host_kind = \"any\"\nsealed = true\n\n\ + [[triggers.kv]]\nscript = \"gh\"\ncollection_glob = \"catalog\"\nops = [\"insert\"]\n\ + sealed = true\nshared = true\n" + ); + let gpath = dir.path().join("picloud.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + // Pull the group into a FRESH dir. + let out_dir = TempDir::new().expect("pull out dir"); + common::pic_as(&env) + .args(["pull", "--group", &group, "--dir"]) + .arg(out_dir.path()) + .assert() + .success(); + + let manifest = + std::fs::read_to_string(out_dir.path().join("picloud.toml")).expect("picloud.toml written"); + assert!( + manifest.contains("[group]") && manifest.contains(&format!("slug = \"{group}\"")), + "manifest must be a [group] node:\n{manifest}" + ); + assert!( + manifest.contains("catalog"), + "shared collection must round-trip:\n{manifest}" + ); + assert!( + manifest.contains("tier = \"gold\"") || manifest.contains("tier = 'gold'"), + "group var must round-trip:\n{manifest}" + ); + assert!( + manifest.contains("[[routes]]") && manifest.contains("/gpull"), + "route template must round-trip:\n{manifest}" + ); + assert!( + manifest.contains("[[triggers.kv]]") && manifest.contains("collection_glob = \"catalog\""), + "kv trigger template must round-trip:\n{manifest}" + ); + // The group-only flags survive the round-trip (the whole point of B3). + assert!( + manifest.matches("sealed = true").count() >= 2, + "both the route and trigger `sealed = true` must round-trip:\n{manifest}" + ); + assert!( + manifest.contains("shared = true"), + "the trigger `shared = true` must round-trip:\n{manifest}" + ); + + // The exported script source landed too. + let src = std::fs::read_to_string(out_dir.path().join("scripts/gh.rhai")) + .expect("scripts/gh.rhai written"); + assert!(src.contains("group handler"), "source mismatch:\n{src}"); + + // The strong round-trip proof: re-applying the PULLED manifest is accepted + // (a faithful export re-plans clean; the server would reject a malformed one). + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(out_dir.path().join("picloud.toml")) + .assert() + .success(); +}