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

@ -0,0 +1,375 @@
//! The store accounts of this machine, shared with the storebound CLI: credential profiles in
//! `~/.storebound/credentials` (STOREBOUND_CREDENTIALS_DIR), the same files `storebound accounts` lists and every
//! terminal command reads (cli/lib/credentials.ts). A profile `<name>.json` holds one App Store Connect team key and / or
//! one Play service account; its key files live next to it in `<name>/` (mode 0600, folders 0700) and the profile names
//! them by relative path:
//! { "label": "Acme", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "acme/app-store-connect.p8",
//! "PLAY_SERVICE_ACCOUNT": "acme/play-service-account.json" }
//! One desktop account = one store of one profile (`Account::profile`). Accounts the CLI added show up in the app
//! (`sync`), and keys the app adds are there for the CLI. Desktop accounts of earlier versions kept their keys in the
//! OS keychain; `sync` moves each one into a profile once (the same key as an existing profile = that profile) and
//! then deletes the keychain entry. Secrets still never reach the webview: they are read here right before a sidecar
//! call. A removed account's key files are moved to `.removed/` (never deleted outright).
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::accounts::{Account, AccountKind, Secret, SecretStore};
pub const P8_FILE: &str = "app-store-connect.p8";
pub const PLAY_FILE: &str = "play-service-account.json";
/// The folder: STOREBOUND_CREDENTIALS_DIR, else ~/.storebound/credentials (the CLI's profileDir()).
pub fn default_dir(home: Option<&Path>) -> PathBuf {
if let Some(d) = std::env::var_os("STOREBOUND_CREDENTIALS_DIR").filter(|d| !d.is_empty()) {
return PathBuf::from(d);
}
home.map(Path::to_path_buf).unwrap_or_default().join(".storebound").join("credentials")
}
/// A profile name: the CLI's PROFILE_RE (letters, digits, . _ -, starting with a letter or digit, max 64).
pub fn valid_name(n: &str) -> bool {
let mut c = n.chars();
matches!(c.next(), Some(f) if f.is_ascii_alphanumeric()) && n.len() <= 64 && n.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
/// A profile name from a label: lower case, other characters as `-` (the CLI's slug).
pub fn slug(label: &str) -> String {
let mut out = String::new();
for c in label.to_lowercase().chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let s: String = out.trim_matches(|c| c == '-' || c == '.').chars().take(56).collect();
if s.is_empty() || !valid_name(&s) {
"account".into()
} else {
s
}
}
/// The CLI workspace's account id for a profile's store: sha256("<apple|play>:<profile>"), 16 hex (cli/lib/workspace.ts
/// accountId), so the same account has the same id in `storebound.workspace.json` and in the app.
pub fn account_id(kind: AccountKind, profile: &str) -> String {
let store = match kind {
AccountKind::Apple => "apple",
AccountKind::Play => "play",
};
let d = Sha256::digest(format!("{store}:{profile}").as_bytes());
d.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
fn str_of<'a>(m: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
m.get(k).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
}
pub struct ProfileStore {
pub dir: PathBuf,
home: Option<PathBuf>,
}
/// What a profile reaches, for matching (never shown).
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Identity {
pub apple_key_id: Option<String>,
pub play_email: Option<String>,
}
impl ProfileStore {
pub fn new(dir: PathBuf, home: Option<PathBuf>) -> ProfileStore {
ProfileStore { dir, home }
}
/// The profiles (`*.json` with a valid name), sorted.
pub fn names(&self) -> Vec<String> {
let mut out: Vec<String> = fs::read_dir(&self.dir)
.map(|rd| rd.filter_map(Result::ok).filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(str::to_string)).filter(|n| valid_name(n)).collect())
.unwrap_or_default();
out.sort();
out
}
pub fn read(&self, name: &str) -> Result<Map<String, Value>, String> {
if !valid_name(name) {
return Err(format!("profile {name}: not a profile name"));
}
let text = fs::read_to_string(self.dir.join(format!("{name}.json"))).map_err(|e| format!("profile {name}: {e}"))?;
match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(m)) => Ok(m),
_ => Err(format!("profile {name}: not a JSON object")),
}
}
/// A value that is a key file path: relative to the credentials folder, `~/` from home, or absolute.
fn path_of(&self, v: &str) -> PathBuf {
if let (Some(rest), Some(h)) = (v.strip_prefix("~/"), self.home.as_ref()) {
return h.join(rest);
}
let p = Path::new(v);
if p.is_absolute() {
p.to_path_buf()
} else {
self.dir.join(p)
}
}
/// The content of a value that is inline (PEM / JSON) or a file.
fn content(&self, v: &str) -> Result<String, String> {
if v.contains("BEGIN PRIVATE KEY") || v.trim_start().starts_with('{') {
return Ok(v.to_string());
}
fs::read_to_string(self.path_of(v)).map_err(|e| format!("key file: {e}"))
}
pub fn kinds(&self, name: &str) -> Vec<AccountKind> {
let Ok(m) = self.read(name) else { return vec![] };
let mut out = vec![];
if str_of(&m, "ASC_KEY_ID").is_some() && str_of(&m, "ASC_ISSUER_ID").is_some() {
out.push(AccountKind::Apple);
}
if str_of(&m, "PLAY_SERVICE_ACCOUNT").is_some() {
out.push(AccountKind::Play);
}
out
}
/// The label the profile carries, if any (written by the app; the CLI ignores it).
pub fn label(&self, name: &str) -> Option<String> {
self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string))
}
/// One store's keys of a profile, read from its files right now.
pub fn secret(&self, name: &str, kind: AccountKind) -> Result<Secret, String> {
let m = self.read(name)?;
match kind {
AccountKind::Apple => {
let key_id = str_of(&m, "ASC_KEY_ID").ok_or_else(|| format!("profile {name}: no ASC_KEY_ID"))?.to_string();
let issuer_id = str_of(&m, "ASC_ISSUER_ID").ok_or_else(|| format!("profile {name}: no ASC_ISSUER_ID"))?.to_string();
// no ASC_P8: the CLI looks in ~/.appstoreconnect/private_keys itself
let p8 = match str_of(&m, "ASC_P8") {
Some(v) => self.content(v)?,
None => String::new(),
};
Ok(Secret::Apple { key_id, issuer_id, p8 })
}
AccountKind::Play => {
let v = str_of(&m, "PLAY_SERVICE_ACCOUNT").ok_or_else(|| format!("profile {name}: no PLAY_SERVICE_ACCOUNT"))?;
Ok(Secret::Play { service_account: self.content(v)? })
}
}
}
pub fn identity(&self, name: &str) -> Identity {
let apple_key_id = self.read(name).ok().and_then(|m| str_of(&m, "ASC_KEY_ID").map(str::to_string));
let play_email = match self.secret(name, AccountKind::Play) {
Ok(Secret::Play { service_account }) => email_of(&service_account),
_ => None,
};
Identity { apple_key_id, play_email }
}
/// Where a new key goes: the profile that already has this key (same key id / service account e-mail), else the
/// profile named after the label when it has no key of this store yet, else a free name.
pub fn place(&self, label: &str, secret: &Secret) -> String {
let names = self.names();
for n in &names {
let id = self.identity(n);
let same = match secret {
Secret::Apple { key_id, .. } => id.apple_key_id.as_deref() == Some(key_id.as_str()),
Secret::Play { service_account } => id.play_email.is_some() && id.play_email == email_of(service_account),
};
if same {
return n.clone();
}
}
let base = slug(label);
if !names.contains(&base) || !self.kinds(&base).contains(&secret.kind()) {
return base;
}
let kind = match secret.kind() {
AccountKind::Apple => "apple",
AccountKind::Play => "play",
};
let mut i = 1;
loop {
let n = if i == 1 { format!("{base}-{kind}") } else { format!("{base}-{kind}-{i}") };
if !names.contains(&n) || !self.kinds(&n).contains(&secret.kind()) {
return n;
}
i += 1;
}
}
fn ensure_dir(&self, d: &Path) -> Result<(), String> {
fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
private_dir(d);
Ok(())
}
/// Writes one store's keys into profile `name` (created when missing): the key file in `<name>/` (0600), then the
/// profile JSON (0600, atomic), keeping the profile's other store and fields. The written profile is read back.
pub fn save(&self, name: &str, label: &str, secret: &Secret) -> Result<(), String> {
if !valid_name(name) {
return Err(format!("profile {name}: not a profile name"));
}
self.ensure_dir(&self.dir)?;
let mut m = if self.dir.join(format!("{name}.json")).exists() { self.read(name)? } else { Map::new() };
let keys = self.dir.join(name);
self.ensure_dir(&keys)?;
match secret {
Secret::Apple { key_id, issuer_id, p8 } => {
if !p8.is_empty() {
write_private(&keys.join(P8_FILE), p8.as_bytes())?;
m.insert("ASC_P8".into(), Value::String(format!("{name}/{P8_FILE}")));
}
m.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
m.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
}
Secret::Play { service_account } => {
write_private(&keys.join(PLAY_FILE), service_account.as_bytes())?;
m.insert("PLAY_SERVICE_ACCOUNT".into(), Value::String(format!("{name}/{PLAY_FILE}")));
}
}
if !m.contains_key("label") && !label.trim().is_empty() {
m.insert("label".into(), Value::String(label.trim().to_string()));
}
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "\n";
write_private(&self.dir.join(format!("{name}.json")), text.as_bytes())?;
match self.secret(name, secret.kind()) {
Ok(back) if back == *secret || matches!((&back, secret), (Secret::Apple { key_id: a, .. }, Secret::Apple { key_id: b, p8, .. }) if a == b && p8.is_empty()) => Ok(()),
Ok(_) => Err(format!("profile {name}: the key did not read back the same")),
Err(e) => Err(e),
}
}
/// Removes one store from a profile: its key file goes to `.removed/<name>-<time>/`, its fields leave the profile,
/// and a profile with no store left moves there too. Nothing is deleted outright.
pub fn remove(&self, name: &str, kind: AccountKind, stamp: &str) -> Result<(), String> {
let mut m = self.read(name)?;
let archive = self.dir.join(".removed").join(format!("{name}-{stamp}"));
self.ensure_dir(&archive)?;
let (fields, file): (&[&str], &str) = match kind {
AccountKind::Apple => (&["ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "ASC_VENDOR_NUMBER"], "ASC_P8"),
AccountKind::Play => (&["PLAY_SERVICE_ACCOUNT"], "PLAY_SERVICE_ACCOUNT"),
};
// only key files inside the credentials folder are moved; a file the owner keeps elsewhere stays where it is
if let Some(v) = str_of(&m, file) {
let p = self.path_of(v);
if p.starts_with(&self.dir) && p.is_file() {
let to = archive.join(p.file_name().unwrap_or_default());
fs::rename(&p, &to).map_err(|e| format!("{}: {e}", p.display()))?;
}
}
for f in fields {
m.remove(*f);
}
let left = ["ASC_KEY_ID", "PLAY_SERVICE_ACCOUNT"].iter().any(|k| m.contains_key(*k));
let json = self.dir.join(format!("{name}.json"));
if left {
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "\n";
write_private(&json, text.as_bytes())
} else {
fs::rename(&json, archive.join(format!("{name}.json"))).map_err(|e| e.to_string())?;
let _ = fs::remove_dir(self.dir.join(name));
Ok(())
}
}
}
/// The client_email of a service account JSON.
pub fn email_of(sa: &str) -> Option<String> {
serde_json::from_str::<Value>(sa).ok()?.get("client_email")?.as_str().map(str::to_string)
}
fn private_dir(d: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(d, fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = d;
}
/// Writes a file only its owner can read (0600 on unix), atomically (temp file, then rename).
pub fn write_private(path: &Path, data: &[u8]) -> Result<(), String> {
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
{
let mut o = fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
let mut f = o.open(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
f.write_all(data).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
}
fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
}
/// What `sync` did, for host.log (no values).
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SyncReport {
pub migrated: Vec<String>,
pub added: Vec<String>,
pub notes: Vec<String>,
}
impl SyncReport {
pub fn changed(&self) -> bool {
!self.migrated.is_empty() || !self.added.is_empty()
}
}
/// Brings the app's account list in line with the shared profiles: (1) an account of an earlier version still in the
/// keychain moves into a profile (its id, label, studio and app mappings stay) and its keychain entry is deleted once
/// the profile reads back; (2) every profile store without an account gets one (id: `account_id`, label: the
/// profile's label or name). Accounts whose profile disappeared stay (the app says the key is missing).
pub fn sync(accounts: &mut Vec<Account>, store: &ProfileStore, legacy: &dyn SecretStore, now: &str) -> SyncReport {
let mut r = SyncReport::default();
for a in accounts.iter_mut().filter(|a| a.profile.is_none()) {
match legacy.get(&a.id) {
Ok(secret) => {
let name = store.place(&a.label, &secret);
match store.save(&name, &a.label, &secret) {
Ok(()) => {
a.profile = Some(name.clone());
if let Err(e) = legacy.delete(&a.id) {
r.notes.push(format!("account {}: moved to profile {name}, the keychain entry stays ({e})", a.id));
}
r.migrated.push(format!("{} -> {name}", a.id));
}
Err(e) => r.notes.push(format!("account {}: not moved to a profile yet ({e}); the keychain keeps it", a.id)),
}
}
Err(e) => r.notes.push(format!("account {}: no key in the keychain ({e})", a.id)),
}
}
for name in store.names() {
for kind in store.kinds(&name) {
if accounts.iter().any(|a| a.kind == kind && a.profile.as_deref() == Some(name.as_str())) {
continue;
}
let id = account_id(kind, &name);
if accounts.iter().any(|a| a.id == id) {
continue;
}
accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()) });
r.added.push(format!("{id} ({name})"));
}
}
r
}