feat(cli): pic init scaffolds a new declarative project
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) <noreply@anthropic.com>
This commit is contained in:
262
crates/picloud-cli/src/cmds/init.rs
Normal file
262
crates/picloud-cli/src/cmds/init.rs
Normal file
@@ -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<String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod apps;
|
|||||||
pub mod apps_domains;
|
pub mod apps_domains;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod init;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ enum Cmd {
|
|||||||
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
||||||
/// `pic plan` / `pic apply`.
|
/// `pic plan` / `pic apply`.
|
||||||
Pull(PullArgs),
|
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)]
|
#[derive(Args)]
|
||||||
@@ -204,6 +209,22 @@ struct PullArgs {
|
|||||||
dir: PathBuf,
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Overwrite an existing `picloud.toml`.
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum KvCmd {
|
enum KvCmd {
|
||||||
/// List keys in a collection.
|
/// 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::Apply(args) => cmds::apply::run(&args.file, args.prune, args.yes, mode).await,
|
||||||
Cmd::Plan(args) => cmds::plan::run(&args.file, 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::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: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||||
Cmd::Apps {
|
Cmd::Apps {
|
||||||
cmd:
|
cmd:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ mod apps;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod dead_letters;
|
mod dead_letters;
|
||||||
mod email_queue;
|
mod email_queue;
|
||||||
|
mod init;
|
||||||
mod invoke;
|
mod invoke;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod output;
|
mod output;
|
||||||
|
|||||||
77
crates/picloud-cli/tests/init.rs
Normal file
77
crates/picloud-cli/tests/init.rs
Normal file
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user