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:
MechaCat02
2026-06-22 21:32:07 +02:00
parent d9b3e9973c
commit b8a4f30219
5 changed files with 369 additions and 0 deletions

View 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");
}
}

View File

@@ -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;

View File

@@ -171,6 +171,11 @@ enum Cmd {
/// (+ `scripts/<name>.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<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)]
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:

View File

@@ -20,6 +20,7 @@ mod apps;
mod auth;
mod dead_letters;
mod email_queue;
mod init;
mod invoke;
mod logs;
mod output;

View 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}"
);
}