`pic plan` now records a fingerprint of the live state it diffed against; `pic apply` replays it and the server refuses (HTTP 409) if the app changed underneath the reviewed plan — the §4.2 "apply exactly what you reviewed" guarantee, in its content-addressed form (no migration, no changes to interactive write paths). Server (manager-core): - `state_token(CurrentState)`: deterministic FNV-1a fingerprint over what the diff keys on — script name+version (version bumps on any edit), route identity+binding/attrs, trigger membership+enabled, secret names. Order-independent; a collision can only yield a false "unchanged", never a false refusal. - `plan` returns it (flattened onto the plan JSON, so the wire stays additive); `apply` takes an optional `expected_token` and, under the apply lock before any write, returns `StateMoved` (409) on mismatch. CLI: - `.picloud/` link state (`linkstate`): `pic plan` writes the token scoped to the app slug; `pic apply` replays it, then clears it on success (the token is single-use — the next apply re-plans). `--force` skips the check; apply with no recorded plan still works standalone (today's behavior). `.picloud/` is already gitignored by `pic init`. The tree-structure version (the other half of §4.2's counter) stays a deliberate no-op until groups exist — it only guards reparent/structural moves, which don't exist single-app. Tested: state_token unit test (stable/order-independent/sensitive) + a staleness journey (plan → out-of-band deploy → apply refuses → --force applies); manager-core lib 363 + cli bins 31 + 13 journeys green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
2.2 KiB
Rust
81 lines
2.2 KiB
Rust
//! `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}"
|
|
);
|
|
}
|