From b8a4f302197b817c16b25afe4ad6dcbc20c59cd6 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 22 Jun 2026 21:32:07 +0200 Subject: [PATCH] feat(cli): `pic init` scaffolds a new declarative project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline project scaffolding — the on-ramp to pull/plan/apply. Writes a `picloud.toml` describing a minimal *working* app (a `hello` endpoint bound to GET /hello) plus commented examples for the other blocks, `scripts/hello.rhai`, and a `.gitignore` that pre-ignores the project tool's `.picloud/` link-state directory (the home for the bound-plan state token, landing next). Refuses to overwrite an existing manifest without `--force`; derives the app slug from the directory name when not given (validated against the canonical slug rule). The active manifest is rendered through the same `to_toml` model the rest of the tool uses, so a freshly-init'd project is guaranteed valid and immediately `pic plan`-able. Offline — no server contact, so the journey tests need no DATABASE_URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/picloud-cli/src/cmds/init.rs | 262 ++++++++++++++++++++++++++++ crates/picloud-cli/src/cmds/mod.rs | 1 + crates/picloud-cli/src/main.rs | 28 +++ crates/picloud-cli/tests/cli.rs | 1 + crates/picloud-cli/tests/init.rs | 77 ++++++++ 5 files changed, 369 insertions(+) create mode 100644 crates/picloud-cli/src/cmds/init.rs create mode 100644 crates/picloud-cli/tests/init.rs diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs new file mode 100644 index 0000000..2c0b6c1 --- /dev/null +++ b/crates/picloud-cli/src/cmds/init.rs @@ -0,0 +1,262 @@ +//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a +//! `picloud.toml` describing a minimal working app (one `hello` endpoint + +//! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores +//! the project tool's `.picloud/` link-state directory. +//! +//! Offline by design — it never contacts the server. Run `pic plan` to +//! preview the create, then `pic apply` to deploy. Refuses to overwrite an +//! existing `picloud.toml` unless `--force`. + +use std::fs; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind}; + +use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE}; +use crate::output::{KvBlock, OutputMode}; + +const HEADER: &str = "\ +# picloud project manifest — the declarative desired state for one app. +# Edit, then `pic plan` to preview changes and `pic apply` to reconcile. +# Scripts live under scripts/ and are referenced by `file`. +\n"; + +const EXAMPLES: &str = "\ +\n# --------------------------------------------------------------------------- +# More to add (uncomment and adapt): +# +# [[scripts]] +# name = \"lib\" +# file = \"scripts/lib.rhai\" +# kind = \"module\" # default: endpoint +# +# [[routes]] +# script = \"hello\" +# method = \"POST\" # omit for ANY +# host_kind = \"any\" +# path_kind = \"param\" # exact | prefix | param +# path = \"/hello/:name\" +# +# [[triggers.cron]] +# script = \"hello\" +# schedule = \"0 0 * * * *\" # 6-field cron (seconds first) +# timezone = \"UTC\" +# +# [secrets] # names only — push values with `pic secret set` +# names = [\"STRIPE_KEY\"] +"; + +const HELLO_RHAI: &str = "\ +// A minimal endpoint script. Its return value is the HTTP response body. +// `ctx` exposes the request; see the stdlib reference for the full SDK. +\"Hello from PiCloud!\" +"; + +const GITIGNORE_LINE: &str = ".picloud/"; + +pub fn run( + dir: &Path, + slug_arg: Option<&str>, + name_arg: Option<&str>, + force: bool, + mode: OutputMode, +) -> Result<()> { + let slug = resolve_slug(dir, slug_arg)?; + let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string); + + let manifest_path = dir.join(MANIFEST_FILE); + if manifest_path.exists() && !force { + bail!( + "{} already exists; refusing to overwrite (use --force)", + manifest_path.display() + ); + } + + let manifest = scaffold_manifest(&slug, &name); + // Build the file as a commented header + the (valid, round-trippable) + // active manifest + commented examples. Rendering the active part through + // `to_toml` guarantees it parses and matches the wire model. + let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?); + + fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?; + let hello_path = dir.join("scripts/hello.rhai"); + let wrote_hello = !hello_path.exists(); + if wrote_hello { + fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?; + } + fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?; + ensure_gitignored(dir)?; + + let mut block = KvBlock::new(); + block + .field("manifest", manifest_path.display().to_string()) + .field("app", slug) + .field( + "scripts", + if wrote_hello { + "scripts/hello.rhai" + } else { + "scripts/hello.rhai (kept existing)" + } + .to_string(), + ) + .field("next", "pic plan then pic apply".to_string()); + block.print(mode); + Ok(()) +} + +/// The minimal working project: one `hello` endpoint bound to `GET /hello`. +fn scaffold_manifest(slug: &str, name: &str) -> Manifest { + Manifest { + app: ManifestApp { + slug: slug.to_string(), + name: name.to_string(), + description: None, + }, + scripts: vec![ManifestScript { + name: "hello".into(), + file: "scripts/hello.rhai".into(), + kind: ScriptKind::Endpoint, + description: None, + timeout_seconds: None, + memory_limit_mb: None, + sandbox: None, + }], + routes: vec![ManifestRoute { + script: "hello".into(), + method: Some("GET".into()), + host_kind: HostKind::Any, + host: String::new(), + host_param_name: None, + path_kind: PathKind::Exact, + path: "/hello".into(), + dispatch_mode: DispatchMode::Sync, + }], + triggers: crate::manifest::ManifestTriggers::default(), + secrets: crate::manifest::ManifestSecrets::default(), + } +} + +/// Use the explicit slug if given, else derive one from the target +/// directory's name. Either way it must satisfy the app-slug rule. +fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result { + if let Some(s) = slug_arg { + if !is_valid_slug(s) { + bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)"); + } + return Ok(s.to_string()); + } + let base = dir + .canonicalize() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + .unwrap_or_default(); + let derived = slugify(&base); + if !is_valid_slug(&derived) { + bail!( + "could not derive a valid app slug from directory `{base}`; \ + pass one explicitly, e.g. `pic init my-app`" + ); + } + Ok(derived) +} + +/// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes. +fn slugify(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_dash = false; + for c in s.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_string() +} + +/// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`. +fn is_valid_slug(s: &str) -> bool { + if s.is_empty() || s.len() > 63 { + return false; + } + let mut chars = s.chars(); + let first = chars.next().expect("non-empty checked above"); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return false; + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// Title-case a slug for a default display name: `my-blog` → `My Blog`. +fn title_from_slug(slug: &str) -> String { + slug.split('-') + .filter(|w| !w.is_empty()) + .map(|w| { + let mut c = w.chars(); + c.next().map_or_else(String::new, |f| { + f.to_ascii_uppercase().to_string() + c.as_str() + }) + }) + .collect::>() + .join(" ") +} + +/// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state). +/// Appends the line if missing; creates the file if absent. A repo without +/// git still gets a correct `.gitignore` for when it's initialized. +fn ensure_gitignored(dir: &Path) -> Result<()> { + let path = dir.join(".gitignore"); + let existing = fs::read_to_string(&path).unwrap_or_default(); + if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) { + return Ok(()); + } + let mut next = existing; + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(GITIGNORE_LINE); + next.push('\n'); + fs::write(&path, next).context("updating .gitignore")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaffold_is_valid_and_round_trips() { + let m = scaffold_manifest("blog", "Blog"); + let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap()); + // The active manifest (header/examples are comments) must parse back + // to exactly the scaffold — the commented examples are inert. + let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML"); + assert_eq!(parsed, m); + // And it's a deployable project: one endpoint + its route. + assert_eq!(parsed.scripts.len(), 1); + assert_eq!(parsed.routes.len(), 1); + assert_eq!(parsed.routes[0].script, "hello"); + } + + #[test] + fn slugify_and_validation() { + assert_eq!(slugify("My Blog!"), "my-blog"); + assert_eq!(slugify(" weird__name "), "weird-name"); + assert_eq!(slugify("Project (2026)"), "project-2026"); + assert!(is_valid_slug("blog")); + assert!(is_valid_slug("a1-b2")); + assert!(!is_valid_slug("")); + assert!(!is_valid_slug("-leading")); + assert!(!is_valid_slug(&"a".repeat(64))); + } + + #[test] + fn title_from_slug_humanizes() { + assert_eq!(title_from_slug("my-blog"), "My Blog"); + assert_eq!(title_from_slug("api"), "Api"); + } +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index aaa2244..821ec24 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -5,6 +5,7 @@ pub mod apps; pub mod apps_domains; pub mod dead_letters; pub mod files; +pub mod init; pub mod kv; pub mod login; pub mod logout; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index e740651..505fd2d 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -171,6 +171,11 @@ enum Cmd { /// (+ `scripts/.rhai` sources) for declarative management with /// `pic plan` / `pic apply`. Pull(PullArgs), + + /// Scaffold a new declarative project: a `picloud.toml` (minimal working + /// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with + /// `pic plan` then `pic apply`. + Init(InitArgs), } #[derive(Args)] @@ -204,6 +209,22 @@ struct PullArgs { dir: PathBuf, } +#[derive(Args)] +struct InitArgs { + /// App slug for the new project. Defaults to a slug derived from the + /// target directory's name. + slug: Option, + /// Directory to scaffold into. + #[arg(long, default_value = ".")] + dir: PathBuf, + /// Display name for the app. Defaults to a title-cased slug. + #[arg(long)] + name: Option, + /// Overwrite an existing `picloud.toml`. + #[arg(long)] + force: bool, +} + #[derive(Subcommand)] enum KvCmd { /// List keys in a collection. @@ -1094,6 +1115,13 @@ async fn main() -> ExitCode { Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, args.yes, mode).await, Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await, Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await, + Cmd::Init(args) => cmds::init::run( + &args.dir, + args.slug.as_deref(), + args.name.as_deref(), + args.force, + mode, + ), Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await, Cmd::Apps { cmd: diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 20f2ad7..5cd0959 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -20,6 +20,7 @@ mod apps; mod auth; mod dead_letters; mod email_queue; +mod init; mod invoke; mod logs; mod output; diff --git a/crates/picloud-cli/tests/init.rs b/crates/picloud-cli/tests/init.rs new file mode 100644 index 0000000..c739f34 --- /dev/null +++ b/crates/picloud-cli/tests/init.rs @@ -0,0 +1,77 @@ +//! `pic init` journey — offline scaffolding, no server/DB needed (so these +//! tests are NOT gated on `DATABASE_URL` and never touch `common::fixture`). + +use std::fs; + +use assert_cmd::Command; +use tempfile::TempDir; + +fn pic() -> Command { + Command::cargo_bin("pic").expect("pic binary") +} + +#[test] +fn init_scaffolds_a_deployable_project() { + let dir = TempDir::new().unwrap(); + pic() + .current_dir(dir.path()) + .args(["init", "demo-app"]) + .assert() + .success(); + + let toml = fs::read_to_string(dir.path().join("picloud.toml")).unwrap(); + assert!(toml.contains("slug = \"demo-app\""), "got:\n{toml}"); + assert!( + toml.contains("name = \"Demo App\""), + "name defaults to title-cased slug:\n{toml}" + ); + assert!( + dir.path().join("scripts/hello.rhai").exists(), + "scaffold writes the example script" + ); + let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!( + gitignore.lines().any(|l| l.trim() == ".picloud/"), + "init must gitignore .picloud/:\n{gitignore}" + ); +} + +#[test] +fn init_refuses_to_overwrite_without_force() { + let dir = TempDir::new().unwrap(); + pic() + .current_dir(dir.path()) + .args(["init", "demo-app"]) + .assert() + .success(); + // A second run must refuse rather than clobber edits. + pic() + .current_dir(dir.path()) + .args(["init", "demo-app"]) + .assert() + .failure(); + // `--force` overrides. + pic() + .current_dir(dir.path()) + .args(["init", "demo-app", "--force"]) + .assert() + .success(); +} + +#[test] +fn init_appends_to_an_existing_gitignore_once() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join(".gitignore"), "target/\n").unwrap(); + pic() + .current_dir(dir.path()) + .args(["init", "demo-app"]) + .assert() + .success(); + let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(gitignore.contains("target/"), "must preserve existing rules"); + assert_eq!( + gitignore.matches(".picloud/").count(), + 1, + "must add the ignore exactly once:\n{gitignore}" + ); +}