Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers already carried `enabled`; this adds it to scripts and routes and threads it through the declarative project tool. Runtime honoring (disabled route 404 / script non-invocable / dispatcher fire-time re-check) is the next commit — this change only stores and reconciles the flag. - Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts + routes. Default true ⇒ no behavior change on migrate. - `Script`/`Route` (shared) gain `enabled` (serde default true via the new `picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it. - Apply diff treats `enabled` as a declarative field (omitted ⇒ active): `script_update_reason` + `diff_routes` detect a toggle as an Update, and the create/update/insert paths persist it. The bound-plan `state_token` re-includes script/route `enabled` (removed in the earlier review fix precisely because the diff didn't key on it yet — now it does). - CLI manifest model + `build_bundle` + `pull` round-trip `enabled` (serialized only when false; omitted ⇒ active). `pic init` scaffold and the interactive script/route create paths default active. Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) + cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
9.5 KiB
Rust
279 lines
9.5 KiB
Rust
//! `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,
|
|
enabled: true,
|
|
}],
|
|
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,
|
|
enabled: true,
|
|
}],
|
|
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());
|
|
}
|
|
// Derive from the directory's own name. Use the path as given first —
|
|
// `canonicalize` requires the dir to already exist, which breaks the
|
|
// natural `pic init --dir new-project` flow. Fall back to canonicalizing
|
|
// only when the path has no final component of its own (e.g. `.`).
|
|
let base = dir
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.or_else(|| {
|
|
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");
|
|
// Only a genuinely-absent file is treated as empty; an existing-but-
|
|
// unreadable `.gitignore` must error rather than be silently clobbered.
|
|
let existing = match fs::read_to_string(&path) {
|
|
Ok(s) => s,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
|
Err(e) => return Err(e).context("reading .gitignore"),
|
|
};
|
|
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");
|
|
}
|
|
}
|