The workspace with a keychain
Each project carries a credential. That credential does not go in the JSON file, and this is the passage that exists to say so.
projects.json gets synced, backed up, copied into a bug report, and read by anything on the machine. Secrets go to the OS keychain, keyed per project, and are deleted when the project is forgotten — an orphan keychain entry is the kind of thing nobody notices until a security review.
One trap, and it is silent: the macOS keychain hex-encodes a value containing a newline, which corrupts it on read. Keychain::put_json writes single-line JSON and asserts it in debug builds for exactly this reason.
What you are carrying
store::KeychainOne service per app, one entry per key.
hostThe facade that opens the project and holds the cursor.
appHome ⇄ workspace, two state scopes.
Start it
scripts/new.sh Acme ~/Dev/acme --template workspace
What it looks like
impl Store {
/// A project's secret. Never projects.json — that file gets synced,
/// backed up, and pasted into bug reports.
pub fn secret(&self, project_id: &str) -> Option<String> {
self.inner.keychain().get(&format!("project.{project_id}"))
}
pub fn save_secret(&self, project_id: &str, secret: &str) -> bool {
self.inner.keychain().put(&format!("project.{project_id}"), secret)
}
/// Called when a project is forgotten. An orphan entry outlives the app.
pub fn drop_secret(&self, project_id: &str) {
self.inner.keychain().delete(&format!("project.{project_id}"));
}
}