One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list

- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
This commit is contained in:
CRTkafa 2026-10-13 06:17:31 +08:00
parent bba46b5c7f
commit d2ce185441
60 changed files with 2443 additions and 246 deletions

View file

@ -9,9 +9,9 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR
#[test]
fn shown_metadata_carries_no_secret() {
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()) };
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) };
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "studio"]);
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]);
assert_eq!(v["kind"], "play");
}

View file

@ -18,9 +18,10 @@ fn conf() -> Value {
}
#[test]
fn one_version_from_the_root_package_json() {
fn one_version_with_the_cli() {
let pkg: Value = serde_json::from_str(&fs::read_to_string(root().join("../../../package.json")).unwrap()).unwrap();
let want = pkg["desktopVersion"].as_str().expect("package.json desktopVersion");
// one product, one number: the desktop app has the npm CLI's version (its bundled sidecar is that CLI)
let want = pkg["version"].as_str().expect("package.json version");
assert_eq!(env!("CARGO_PKG_VERSION"), want, "Cargo.toml (bun tools/desktop-version.ts --set {want})");
assert_eq!(conf()["version"], want, "tauri.conf.json");
let app: Value = serde_json::from_str(&fs::read_to_string(root().join("../package.json")).unwrap()).unwrap();

View file

@ -0,0 +1,175 @@
//! One product per machine: the app's accounts are the CLI's credential profiles (~/.storebound/credentials), keychain
//! accounts of earlier versions move there once, the app folder list is the CLI's (~/.storebound/projects.json), and
//! a folder that pins another storebound version says so. No real keys anywhere.
use std::collections::HashMap;
use std::sync::Mutex;
use serde_json::json;
use storebound_desktop::accounts::{Account, AccountKind, Secret, SecretStore};
use storebound_desktop::profiles::{self, ProfileStore};
use storebound_desktop::registry::{self, Entry};
use storebound_desktop::store::{Project, State};
use storebound_desktop::workspace::{pin_differs, pinned_storebound};
const PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
fn sa(email: &str) -> String {
json!({ "type": "service_account", "client_email": email, "private_key": PEM }).to_string()
}
fn apple(key: &str) -> Secret {
Secret::Apple { key_id: key.into(), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: PEM.into() }
}
#[derive(Default)]
struct MapStore(Mutex<HashMap<String, Secret>>);
impl SecretStore for MapStore {
fn set(&self, id: &str, s: &Secret) -> Result<(), String> {
self.0.lock().unwrap().insert(id.into(), s.clone());
Ok(())
}
fn get(&self, id: &str) -> Result<Secret, String> {
self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into())
}
fn delete(&self, id: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(id);
Ok(())
}
}
fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account {
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) }
}
#[test]
fn ids_match_the_cli() {
// cli/lib/workspace.ts accountId and cli/lib/registry.ts folderId give the same values
assert_eq!(profiles::account_id(AccountKind::Apple, "acme"), "747351dbf2569d17");
assert_eq!(profiles::account_id(AccountKind::Play, "default"), "82a8c2561d950999");
assert_eq!(registry::folder_id("/w/Star Dash"), "f4994b9746e7eb18");
assert!(profiles::valid_name("default") && profiles::valid_name("acme-games.2") && !profiles::valid_name("../x") && !profiles::valid_name("-a"));
assert_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play");
assert_eq!(profiles::slug("???"), "account");
}
#[test]
fn a_profile_round_trips_in_the_cli_format_with_private_files() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
store.save("acme", "Acme", &apple("ABCDE12345")).unwrap();
store.save("acme", "Acme", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
let m = store.read("acme").unwrap();
assert_eq!(m["ASC_P8"], "acme/app-store-connect.p8");
assert_eq!(m["PLAY_SERVICE_ACCOUNT"], "acme/play-service-account.json");
assert_eq!(m["label"], "Acme");
assert!(!serde_json::to_string(&m).unwrap().contains("not-a-real-key"), "keys stay in their own files");
assert_eq!(store.secret("acme", AccountKind::Apple).unwrap(), apple("ABCDE12345"));
assert_eq!(store.kinds("acme"), vec![AccountKind::Apple, AccountKind::Play]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&store.dir.join("acme.json")), 0o600);
assert_eq!(mode(&store.dir.join("acme").join(profiles::P8_FILE)), 0o600);
assert_eq!(mode(&store.dir.join("acme")), 0o700);
assert_eq!(mode(&store.dir), 0o700);
}
// the same key again lands in the same profile; another key in a profile of its own
assert_eq!(store.place("Anything", &apple("ABCDE12345")), "acme");
assert_eq!(store.place("Acme", &apple("ZZZZZ99999")), "acme-apple");
assert_eq!(store.place("Acme", &Secret::Play { service_account: sa("bot@example.com") }), "acme");
// a profile the CLI wrote by hand, with an absolute key path and no ASC_P8
std::fs::write(store.dir.join("cli.json"), json!({ "ASC_KEY_ID": "CLI0000000", "ASC_ISSUER_ID": "i" }).to_string()).unwrap();
let mut creds = serde_json::Map::new();
store.secret("cli", AccountKind::Apple).unwrap().to_credentials(&mut creds);
assert_eq!(creds.keys().cloned().collect::<Vec<_>>(), vec!["ASC_ISSUER_ID", "ASC_KEY_ID"], "no ASC_P8: the CLI looks in ~/.appstoreconnect itself");
// removing one store archives its key file; the last store archives the profile
store.remove("acme", AccountKind::Play, "20261013T000000Z").unwrap();
assert_eq!(store.kinds("acme"), vec![AccountKind::Apple]);
assert!(store.dir.join(".removed/acme-20261013T000000Z").join(profiles::PLAY_FILE).is_file());
store.remove("acme", AccountKind::Apple, "20261013T000001Z").unwrap();
assert!(!store.names().contains(&"acme".to_string()));
assert!(store.dir.join(".removed/acme-20261013T000001Z/acme.json").is_file());
}
#[test]
fn keychain_accounts_move_into_profiles_once_and_cli_profiles_show_up() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
// the CLI already imported the shared account ("accounts import" -> profile default)
store.save("default", "", &apple("ABCDE12345")).unwrap();
store.save("default", "", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
let legacy = MapStore::default();
legacy.set("0123456789abcdef", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
legacy.set("fedcba9876543210", &apple("SIDE000000")).unwrap();
let mut accs = vec![
account("0123456789abcdef", "Example Games Play", AccountKind::Play, None),
account("fedcba9876543210", "Side project", AccountKind::Apple, None),
account("00112233445566ff", "Lost key", AccountKind::Apple, None),
];
let r = profiles::sync(&mut accs, &store, &legacy, "2026-10-13T00:00:00Z");
// the same service account as the CLI's profile: that profile; the other key: a profile named after its label
assert_eq!(accs[0].profile.as_deref(), Some("default"));
assert_eq!(accs[1].profile.as_deref(), Some("side-project"));
assert_eq!(accs[2].profile, None, "no keychain entry: stays, said in the log");
assert_eq!(accs[0].studio.as_deref(), Some("s1"), "id, label, studio stay");
assert!(legacy.get("0123456789abcdef").is_err() && legacy.get("fedcba9876543210").is_err(), "moved entries leave the keychain");
assert_eq!(r.migrated.len(), 2);
assert_eq!(r.notes.len(), 1);
// the CLI profile's Apple key had no app account yet: it comes in with the CLI workspace's id
let added: Vec<_> = accs.iter().skip(3).map(|a| (a.id.clone(), a.kind, a.profile.clone())).collect();
assert_eq!(added, vec![(profiles::account_id(AccountKind::Apple, "default"), AccountKind::Apple, Some("default".into()))]);
// a second start changes nothing
let again = profiles::sync(&mut accs, &store, &legacy, "2026-10-14T00:00:00Z");
assert!(!again.changed());
assert_eq!(accs.len(), 4);
}
fn project(id: &str, path: &str, play: Option<&str>) -> Project {
Project { id: id.into(), path: path.into(), name: id.into(), play_account: play.map(str::to_string), apple_account: None, added: String::new() }
}
#[test]
fn the_folder_list_is_the_machines() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("projects.json");
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", Some("acc"))], ..State::default() };
// first start: the app's list becomes the shared one
assert!(!registry::sync(&mut st, &file).unwrap());
assert_eq!(registry::read(&file).unwrap()[0].id, "aaaaaaaaaaaaaaaa");
// the CLI adds a folder (storebound desktop / apps add): the app shows it, the first keeps its accounts
let mut list = registry::read(&file).unwrap();
list.push(Entry { id: registry::folder_id("/w/a game"), path: "/w/a game".into(), name: "a game".into(), added: "t".into() });
registry::write(&file, &list).unwrap();
assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["/w/Star Dash", "/w/a game"]);
assert_eq!(st.projects[0].play_account.as_deref(), Some("acc"));
// the CLI removes one (apps remove): it leaves the app with its overrides
st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/a game")));
registry::write(&file, &list[..1]).unwrap();
assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.len(), 1);
assert!(st.overrides.ignored.is_empty());
// the app adds one; a folder another tool added meanwhile is kept
st.projects.push(project("bbbbbbbbbbbbbbbb", "/w/third", None));
registry::write(&file, &[list[0].clone(), Entry { id: registry::folder_id("/w/cli"), path: "/w/cli".into(), name: "cli".into(), added: String::new() }]).unwrap();
registry::publish(&st, &file, &[]).unwrap();
let paths: Vec<String> = registry::read(&file).unwrap().into_iter().map(|e| e.path).collect();
assert_eq!(paths, vec!["/w/Star Dash", "/w/third", "/w/cli"]);
// bad entries are dropped, not the whole file
assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]);
}
#[test]
fn a_folder_that_pins_another_storebound_says_so() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(pinned_storebound(dir.path()), None);
std::fs::write(dir.path().join("package.json"), r#"{"devDependencies":{"storebound":"^0.7.1"}}"#).unwrap();
assert_eq!(pinned_storebound(dir.path()).as_deref(), Some("^0.7.1"));
assert!(pin_differs("^0.7.1", "0.8.0"));
assert!(!pin_differs("0.8.0", "0.8.0"));
assert!(!pin_differs("~0.8.0", "0.8.0"));
for spec in ["latest", "*", "file:../storebound", "workspace:*", ">=0.7"] {
assert!(!pin_differs(spec, "0.8.0"), "{spec}");
}
}

View file

@ -4,11 +4,39 @@ use serde_json::{json, Value};
use storebound_desktop::commands::project_view;
use storebound_desktop::logs;
use storebound_desktop::store::{self, State, VERSION};
use storebound_desktop::accounts::{AccountKind, Secret, SecretStore};
use storebound_desktop::profiles::{self, ProfileStore};
use storebound_desktop::registry;
use storebound_desktop::workspace::{Overrides, Settings};
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.json");
const SHARED: &str = include_str!("../../tests/fixtures/state/shared-profiles.json");
/// Keychain entries of versions before 0.8, for the migration into credential profiles.
#[derive(Default)]
struct Keychain(std::sync::Mutex<std::collections::HashMap<String, Secret>>);
impl SecretStore for Keychain {
fn set(&self, id: &str, s: &Secret) -> Result<(), String> {
self.0.lock().unwrap().insert(id.into(), s.clone());
Ok(())
}
fn get(&self, id: &str) -> Result<Secret, String> {
self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into())
}
fn delete(&self, id: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(id);
Ok(())
}
}
const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
fn fake_secret(kind: AccountKind, n: usize) -> Secret {
match kind {
AccountKind::Apple => Secret::Apple { key_id: format!("FAKEKEY{n:03}"), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: FAKE_PEM.into() },
AccountKind::Play => Secret::Play { service_account: json!({ "client_email": format!("bot{n}@example.iam.gserviceaccount.com"), "private_key": FAKE_PEM }).to_string() },
}
}
fn write(dir: &std::path::Path, text: &str) {
std::fs::write(dir.join(store::FILE), text).unwrap();
@ -177,16 +205,43 @@ fn log_entries_are_one_block_and_rotate() {
}
/// What the host answers for each fixture (projects_list without `missing`, accounts_list, workspace_get), written
/// next to it as `<name>.host.json`. tools/desktop-boot-check.ts boots the built window on exactly these answers.
/// next to it as `<name>.host.json`, after the start-up sync with the machine's shared stores: D1 keeps its accounts in a
/// keychain that has no entries (the keys were never added: the accounts stay without a profile), D3's keychain has
/// them (they move into credential profiles), and shared-profiles is the 0.8 format whose apple account comes from a
/// profile the CLI wrote, with a folder that pins storebound 0.6.0. tools/desktop-boot-check.ts boots the built window on exactly these answers.
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
#[test]
fn host_answers_for_the_fixtures_are_current() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state");
for (name, text) in [("empty", EMPTY), ("d1-missing-folders", D1), ("d3-full", D3)] {
let (st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
for (name, text, keychain) in [("empty", EMPTY, false), ("d1-missing-folders", D1, false), ("d3-full", D3, true), ("shared-profiles", SHARED, false)] {
let (mut st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
assert!(notes.is_empty(), "{name}: {notes:?}");
let machine = tempfile::tempdir().unwrap();
let profiles = ProfileStore::new(machine.path().join("credentials"), None);
let legacy = Keychain::default();
if keychain {
for (i, a) in st.accounts.iter().enumerate() {
legacy.set(&a.id, &fake_secret(a.kind, i)).unwrap();
}
}
if name == "shared-profiles" {
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Play, 0)).unwrap();
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Apple, 0)).unwrap();
}
profiles::sync(&mut st.accounts, &profiles, &legacy, "2026-10-13T00:00:00Z");
registry::sync(&mut st, &machine.path().join("projects.json")).unwrap();
let mut projects = serde_json::to_value(&st.projects).unwrap();
if name == "shared-profiles" {
// what project_view says for a folder whose package.json pins storebound 0.6.0
let pinned = tempfile::tempdir().unwrap();
std::fs::write(pinned.path().join("package.json"), r#"{"devDependencies":{"storebound":"0.6.0"}}"#).unwrap();
let mut p = st.projects[0].clone();
p.path = pinned.path().to_string_lossy().to_string();
let pin = project_view(&p).storebound_pin.expect("0.6.0 is not this app");
projects[0]["storeboundPin"] = json!(pin);
}
let host = json!({
"projects_list": st.projects,
"projects_list": projects,
"accounts_list": st.accounts,
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
});