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:
parent
bba46b5c7f
commit
d2ce185441
60 changed files with 2443 additions and 246 deletions
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -2,6 +2,44 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
One product, one mental model: storebound is installed once per machine (`npx storebound` and the desktop app),
|
||||||
|
each project has its `storebound.config.ts` and `.storebound/`, and store accounts and app folders live once per
|
||||||
|
machine in `~/.storebound`, shared by the CLI and the app.
|
||||||
|
|
||||||
|
- **One version.** The desktop app has the CLI's version from now on; the next release is 0.8.0 for both.
|
||||||
|
`package.json`'s `version` is the authority (`desktopVersion` is gone); `tools/desktop-version.ts` keeps the plugin
|
||||||
|
manifests and the app's copies equal (CI, and `tests/desktop-release.test.ts` / `src-tauri/tests/d5.rs` as the
|
||||||
|
drift tests), `desktop-release.ts publish` refuses a folder of another version, and the release order is the app
|
||||||
|
first, then `npm publish`. The desktop 0.1.0 release candidate was never published, so nothing public changes.
|
||||||
|
- **`npx storebound desktop` just works.** The app has this CLI's version: it opens on the current folder. Not
|
||||||
|
installed: it downloads the app for this system (the CLI's own version, else the newest published), checks size,
|
||||||
|
SHA-256 and the minisign signature, installs it for this user without admin rights (macOS: from the read-only DMG
|
||||||
|
into /Applications or ~/Applications, no xattr changes; Windows: the per-user NSIS installer, silent; Linux: the
|
||||||
|
AppImage in ~/.local/bin with a `.desktop` entry and the `storebound://` handler) and opens it. An older app is
|
||||||
|
updated the same way (never while it runs), a newer one is opened with a note (downgrade only with `--force`). It
|
||||||
|
asks once (`[Y/n]`); `--yes` skips the question; without a terminal and `--yes` it prints the command and exits 3.
|
||||||
|
`--dry` / `--json` change nothing, `--download` only saves the checked installer. The publish script also writes
|
||||||
|
`desktop/dl/<version>/latest.json` (redeploy `tools/vds/storebound-desktop-publish` on the server).
|
||||||
|
- **Store accounts, one store.** Credential profiles in `~/.storebound/credentials` are shared by the CLI and the
|
||||||
|
desktop app: the app writes its keys there (files 0600, folders 0700) and shows the profiles the CLI made; accounts
|
||||||
|
of earlier desktop builds move from the OS keychain into profiles on first start (ids, studios and app mappings
|
||||||
|
kept; the keychain entry is deleted after the profile reads back). A removed account's key file goes to
|
||||||
|
`credentials/.removed/`. Precedence: env, then a profile the project names, then the project's
|
||||||
|
`.storebound/credentials.json`, then the default profile (the only one, or `default`). A project names its profile
|
||||||
|
with `account:` in the config, `"account"` in its `.storebound/credentials.json`, or `--account`.
|
||||||
|
- **`storebound accounts`** lists the profiles (masked) and which one this folder uses; `accounts import` moves the
|
||||||
|
store keys of a project's `.storebound/credentials.json` into a profile (key files copied to
|
||||||
|
`~/.storebound/credentials/<name>/`, the same Apple key id or service account e-mail = the same profile, the
|
||||||
|
keystore and review contact stay in the project, a backup of the project file is kept, safe to run again; `--dry`
|
||||||
|
first, never prints values); `accounts use <profile>` points a project at one.
|
||||||
|
- **App folders, one list.** `~/.storebound/projects.json` is the desktop's App folders and the CLI's
|
||||||
|
`storebound apps folders | add | remove`; `storebound desktop` in an app folder and `ws init` add to it, and `ws`
|
||||||
|
without a workspace file works over it. The app syncs it at start, when the list is shown and before a
|
||||||
|
`storebound://open` link, so a folder added in the terminal is selected without a question.
|
||||||
|
- **Desktop:** a folder whose `package.json` pins another storebound version shows "This project uses storebound X,
|
||||||
|
the app runs Y". `storebound-desktop --version`. `ws desktop import / export` carry the accounts' profile names.
|
||||||
|
- `ws` children get the app's own account choice when the workspace names no account (before: no profile at all).
|
||||||
|
|
||||||
Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37):
|
Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37):
|
||||||
|
|
||||||
- Fix (`scan`, review-prompt 3.2.2(x)): a bare "rate" next to "bonus" (`const rate = F.genRate(lv, bonus.gen(r))`) is
|
- Fix (`scan`, review-prompt 3.2.2(x)): a bare "rate" next to "bonus" (`const rate = F.genRate(lv, bonus.gen(r))`) is
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ gitignored) with `bun tools/build-node.ts`. Before publishing: `bun run ci` gree
|
||||||
Node), `npm pack --dry-run` lists `dist/storebound.mjs`, the privacy scan on the tree and on the bundle, and a run of
|
Node), `npm pack --dry-run` lists `dist/storebound.mjs`, the privacy scan on the tree and on the bundle, and a run of
|
||||||
the packed tarball in an empty folder (`npx --yes ./storebound-<version>.tgz --version`, then `init`).
|
the packed tarball in an empty folder (`npx --yes ./storebound-<version>.tgz --version`, then `init`).
|
||||||
|
|
||||||
|
The npm package and the desktop app are one release with one version (`bun tools/desktop-version.ts --set <version>`
|
||||||
|
writes it everywhere): publish the desktop artifacts of that version first (docs/desktop-release.md), then
|
||||||
|
`npm publish`, so `npx storebound desktop` of the new CLI finds its own app.
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
One topic per PR, `bun run ci` green, a line in `CHANGELOG.md` under an "Unreleased" heading, and for anything
|
One topic per PR, `bun run ci` green, a line in `CHANGELOG.md` under an "Unreleased" heading, and for anything
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -48,7 +48,8 @@ Every rule, what it proves and what only a reviewer can see: [docs/rejections.md
|
||||||
|
|
||||||
Every app and game you ship on Google Play and the App Store side by side: releases, charts, catalog and prices,
|
Every app and game you ship on Google Play and the App Store side by side: releases, charts, catalog and prices,
|
||||||
crash health, reviews, TestFlight and the rejection checks. Send for review from there too: a dry run first, then a
|
crash health, reviews, TestFlight and the rejection checks. Send for review from there too: a dry run first, then a
|
||||||
confirm, then your click. Same storebound underneath; store keys stay in the system keychain.
|
confirm, then your click. Same storebound underneath, same version number as the CLI, and the same store accounts
|
||||||
|
and app folders as your terminal (`~/.storebound`).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -64,8 +65,8 @@ confirm, then your click. Same storebound underneath; store keys stay in the sys
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
Download for macOS, Windows and Linux: [storebound.dev/#download](https://storebound.dev/#download), or
|
`npx storebound desktop` installs it for you (macOS, Windows, Linux; signature checked, no admin rights) and opens it on
|
||||||
`storebound desktop --download` (checks the signature, never installs). Internals: [docs/desktop.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md).
|
the current app folder. Manual downloads: [storebound.dev/#download](https://storebound.dev/#download). Internals: [docs/desktop.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md).
|
||||||
|
|
||||||
## Works with your AI, safe by design
|
## Works with your AI, safe by design
|
||||||
|
|
||||||
|
|
@ -84,16 +85,18 @@ You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmp
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd my-app
|
cd my-app
|
||||||
npm i -D storebound playwright # or: bun add -d storebound playwright
|
npx storebound init # storebound.config.ts + .storebound/ in this project (--profile app | subscription-app | game)
|
||||||
npx storebound init # --profile app (default) | subscription-app | game (Bun: bunx storebound init)
|
npx storebound desktop # installs the desktop app once (asks first) and opens it on this folder
|
||||||
npx storebound install --agent claude # or codex, cursor, gemini, copilot, windsurf, opencode
|
|
||||||
npx storebound check # offline: config, limits, Data safety, products, files
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Every `npx storebound ...` below works as `bunx storebound ...` too.
|
One product, installed once per machine: the CLI (`npx` / `bunx`) and the desktop app have the same version, the same
|
||||||
|
store accounts (`~/.storebound/credentials`, `npx storebound accounts`) and the same list of app folders. Each project
|
||||||
|
has its `storebound.config.ts` and a gitignored `.storebound/` (upload keystore, review contact). Then
|
||||||
|
`npx storebound install --agent claude` (or codex, cursor, gemini, copilot, windsurf, opencode) and
|
||||||
|
`npx storebound check`. Every `npx storebound ...` works as `bunx storebound ...` too.
|
||||||
|
|
||||||
Then ask your agent: "take my app to Google Play and the App Store". Claude Code plugin, credentials, coming from
|
Then ask your agent: "take my app to Google Play and the App Store". Claude Code plugin, credentials, coming from
|
||||||
fastlane: [docs/getting-started.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/getting-started.md). Desktop app: [storebound.dev/#download](https://storebound.dev/#download).
|
fastlane: [docs/getting-started.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/getting-started.md).
|
||||||
|
|
||||||
## What it covers
|
## What it covers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@storebound/desktop",
|
"name": "@storebound/desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
2
apps/desktop/src-tauri/Cargo.lock
generated
2
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -4010,7 +4010,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storebound-desktop"
|
name = "storebound-desktop"
|
||||||
version = "0.1.0"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "storebound-desktop"
|
name = "storebound-desktop"
|
||||||
version = "0.1.0"
|
version = "0.7.1"
|
||||||
description = "storebound desktop: every game's releases on both stores in one window"
|
description = "storebound desktop: every game's releases on both stores in one window"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer)
|
//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer)
|
||||||
//! goes into the OS credential store under the service `dev.storebound.desktop`; only a label, the kind and the
|
//! lives in the machine's credential profiles, shared with the CLI (profiles.rs: ~/.storebound/credentials, files
|
||||||
//! creation time are kept in the app data folder. Secrets are never sent to the webview: they are read from the
|
//! 0600); the app data folder keeps the label, the kind, the studio and the profile name. Versions before 0.8 kept
|
||||||
//! keychain right before a sidecar call and handed to it in that call's params (memory only), which passes them to
|
//! the secret in the OS keychain (service `dev.storebound.desktop`): `Keychain` is still here to move those entries
|
||||||
//! the storebound command as environment variables.
|
//! into profiles once (profiles::sync). Secrets are never sent to the webview: they are read right before a sidecar
|
||||||
|
//! call and handed to it in that call's params (memory only), which passes them to the command as environment
|
||||||
|
//! variables.
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
|
@ -25,6 +27,10 @@ pub struct Account {
|
||||||
/// The studio this account belongs to (workspace::Studio id), if any.
|
/// The studio this account belongs to (workspace::Studio id), if any.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub studio: Option<String>,
|
pub studio: Option<String>,
|
||||||
|
/// The credential profile that holds the key (~/.storebound/credentials/<profile>.json, shared with the CLI).
|
||||||
|
/// None only for an account of an earlier version whose keychain entry has not moved yet.
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keychain entry of one account (JSON).
|
/// The keychain entry of one account (JSON).
|
||||||
|
|
@ -52,7 +58,10 @@ impl Secret {
|
||||||
Secret::Apple { key_id, issuer_id, p8 } => {
|
Secret::Apple { key_id, issuer_id, p8 } => {
|
||||||
into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
|
into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
|
||||||
into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
|
into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
|
||||||
into.insert("ASC_P8".into(), Value::String(p8.clone()));
|
// a profile without ASC_P8: the CLI finds ~/.appstoreconnect/private_keys/AuthKey_<id>.p8 itself
|
||||||
|
if !p8.is_empty() {
|
||||||
|
into.insert("ASC_P8".into(), Value::String(p8.clone()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +119,7 @@ pub fn check_label(s: &str) -> Result<String, String> {
|
||||||
Ok(s.to_string())
|
Ok(s.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where secrets live. The OS keychain in the app; a map in tests.
|
/// The OS keychain entries of earlier versions (read once to move them into profiles, then deleted); a map in tests.
|
||||||
pub trait SecretStore: Send + Sync {
|
pub trait SecretStore: Send + Sync {
|
||||||
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
|
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
|
||||||
fn get(&self, id: &str) -> Result<Secret, String>;
|
fn get(&self, id: &str) -> Result<Secret, String>;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! App commands. Each one first checks the calling window and page (policy::require_main). Secrets never cross into
|
//! App commands. Each one first checks the calling window and page (policy::require_main). Secrets never cross into
|
||||||
//! the webview: account files are picked in native dialogs and read here, and credentials go from the keychain
|
//! the webview: account files are picked in native dialogs and read here, and credentials go from the machine's
|
||||||
//! straight into the sidecar call.
|
//! credential profiles (profiles.rs, shared with the CLI) straight into the sidecar call.
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
@ -12,6 +12,8 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
|
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
|
||||||
|
use crate::profiles::{self, ProfileStore};
|
||||||
|
use crate::registry;
|
||||||
use crate::cache::{Cache, Entry};
|
use crate::cache::{Cache, Entry};
|
||||||
use crate::downloads;
|
use crate::downloads;
|
||||||
use crate::policy::{self, Kind};
|
use crate::policy::{self, Kind};
|
||||||
|
|
@ -24,7 +26,12 @@ pub struct AppState {
|
||||||
pub sidecar: Sidecar,
|
pub sidecar: Sidecar,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub saved: Mutex<Saved>,
|
pub saved: Mutex<Saved>,
|
||||||
pub secrets: Box<dyn SecretStore>,
|
/// The machine's credential profiles (~/.storebound/credentials, shared with the CLI): where keys live.
|
||||||
|
pub profiles: ProfileStore,
|
||||||
|
/// Keychain entries of versions before 0.8, moved into profiles once by `sync_machine`.
|
||||||
|
pub legacy: Box<dyn SecretStore>,
|
||||||
|
/// The machine's app folder list (~/.storebound/projects.json, shared with the CLI).
|
||||||
|
pub registry: PathBuf,
|
||||||
pub cache: Cache,
|
pub cache: Cache,
|
||||||
pub downloads: downloads::Jobs,
|
pub downloads: downloads::Jobs,
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +51,50 @@ fn persist(s: &AppState, st: &Saved) -> Res<()> {
|
||||||
store::save(&s.data_dir, st).map_err(Fail::host)
|
store::save(&s.data_dir, st).map_err(Fail::host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brings the state in line with the machine's shared stores: accounts with the credential profiles (and moves old
|
||||||
|
/// keychain entries into profiles), the folder list with the registry. Logged without values; saved when it changed.
|
||||||
|
pub fn sync_machine(s: &AppState) -> Res<()> {
|
||||||
|
let mut st = saved(s)?;
|
||||||
|
let report = profiles::sync(&mut st.accounts, &s.profiles, s.legacy.as_ref(), &store::now_iso());
|
||||||
|
for m in &report.migrated {
|
||||||
|
crate::logs::host("info", &format!("account moved from the keychain into a credential profile: {m}"));
|
||||||
|
}
|
||||||
|
for a in &report.added {
|
||||||
|
crate::logs::host("info", &format!("account from a credential profile: {a}"));
|
||||||
|
}
|
||||||
|
for n in &report.notes {
|
||||||
|
crate::logs::host("warn", n);
|
||||||
|
}
|
||||||
|
let folders = match registry::sync(&mut st, &s.registry) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
crate::logs::host("warn", &format!("app folder list not synced: {e}"));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if report.changed() || folders {
|
||||||
|
persist(s, &st)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the app changed its folder list: the shared registry gets it (keeping folders another tool added).
|
||||||
|
fn publish_folders(s: &AppState, st: &Saved, removed: &[String]) {
|
||||||
|
if let Err(e) = registry::publish(st, &s.registry, removed) {
|
||||||
|
crate::logs::host("warn", &format!("app folder list not written: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One account's keys, read from its credential profile right now.
|
||||||
|
fn secret_of(s: &AppState, id: &str) -> Result<Secret, String> {
|
||||||
|
let (profile, kind) = {
|
||||||
|
let st = s.saved.lock().map_err(|_| "state poisoned".to_string())?;
|
||||||
|
let a = st.accounts.iter().find(|a| a.id == id).ok_or("unknown account")?;
|
||||||
|
(a.profile.clone().ok_or("the account's key has not moved from the keychain yet: restart storebound, or add the key again")?, a.kind)
|
||||||
|
};
|
||||||
|
s.profiles.secret(&profile, kind)
|
||||||
|
}
|
||||||
|
|
||||||
fn job_of(job: Option<String>) -> Res<Option<String>> {
|
fn job_of(job: Option<String>) -> Res<Option<String>> {
|
||||||
match job {
|
match job {
|
||||||
Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")),
|
Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")),
|
||||||
|
|
@ -56,8 +107,8 @@ fn project_params(s: &AppState, project_id: &str) -> Res<(Project, Map<String, V
|
||||||
let p = saved(s)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
let p = saved(s)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
||||||
let mut creds = Map::new();
|
let mut creds = Map::new();
|
||||||
for id in [&p.play_account, &p.apple_account].into_iter().flatten() {
|
for id in [&p.play_account, &p.apple_account].into_iter().flatten() {
|
||||||
// A missing keychain entry is not fatal: the command says which credential it lacks.
|
// A missing key is not fatal: the command says which credential it lacks.
|
||||||
if let Ok(secret) = s.secrets.get(id) {
|
if let Ok(secret) = secret_of(s, id) {
|
||||||
secret.to_credentials(&mut creds);
|
secret.to_credentials(&mut creds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +132,7 @@ fn target_params(s: &AppState, t: &StoreTarget) -> Res<Map<String, Value>> {
|
||||||
drop(st);
|
drop(st);
|
||||||
let mut creds = Map::new();
|
let mut creds = Map::new();
|
||||||
for id in t.accounts() {
|
for id in t.accounts() {
|
||||||
if let Ok(secret) = s.secrets.get(id) {
|
if let Ok(secret) = secret_of(s, id) {
|
||||||
secret.to_credentials(&mut creds);
|
secret.to_credentials(&mut creds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +145,7 @@ fn account_creds(s: &AppState, id: &str) -> Res<Map<String, Value>> {
|
||||||
return Err(Fail::host("unknown account"));
|
return Err(Fail::host("unknown account"));
|
||||||
}
|
}
|
||||||
let mut creds = Map::new();
|
let mut creds = Map::new();
|
||||||
s.secrets.get(id).map_err(Fail::host)?.to_credentials(&mut creds);
|
secret_of(s, id).map_err(Fail::host)?.to_credentials(&mut creds);
|
||||||
Ok(creds)
|
Ok(creds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,15 +433,21 @@ pub struct ProjectView {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub project: Project,
|
pub project: Project,
|
||||||
pub missing: bool,
|
pub missing: bool,
|
||||||
|
/// The storebound version the folder's package.json pins when it is not this app's (its bundled CLI): the window
|
||||||
|
/// says "project uses storebound X, app is Y".
|
||||||
|
pub storebound_pin: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn project_view(p: &Project) -> ProjectView {
|
pub fn project_view(p: &Project) -> ProjectView {
|
||||||
ProjectView { project: p.clone(), missing: !Path::new(&p.path).is_dir() }
|
let dir = Path::new(&p.path);
|
||||||
|
ProjectView { project: p.clone(), missing: !dir.is_dir(), storebound_pin: workspace::pinned_storebound(dir).filter(|pin| workspace::pin_differs(pin, env!("CARGO_PKG_VERSION"))) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The app folders: the machine's shared list (registry.rs), with the app's accounts per folder.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<ProjectView>> {
|
pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<ProjectView>> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
|
sync_machine(&state)?;
|
||||||
Ok(saved(&state)?.projects.iter().map(project_view).collect())
|
Ok(saved(&state)?.projects.iter().map(project_view).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -423,6 +480,7 @@ pub async fn project_locate(window: WebviewWindow, app: AppHandle, state: State<
|
||||||
}
|
}
|
||||||
let view = project_view(p);
|
let view = project_view(p);
|
||||||
persist(&state, &st)?;
|
persist(&state, &st)?;
|
||||||
|
publish_folders(&state, &st, &[]);
|
||||||
crate::logs::host("info", &format!("app folder located: {}", view.project.path));
|
crate::logs::host("info", &format!("app folder located: {}", view.project.path));
|
||||||
Ok(Some(view))
|
Ok(Some(view))
|
||||||
}
|
}
|
||||||
|
|
@ -462,6 +520,7 @@ async fn add_folder(app: &AppHandle, state: &AppState, dir: &Path) -> Res<Projec
|
||||||
let mut st = saved(state)?;
|
let mut st = saved(state)?;
|
||||||
st.projects.push(project.clone());
|
st.projects.push(project.clone());
|
||||||
persist(state, &st)?;
|
persist(state, &st)?;
|
||||||
|
publish_folders(state, &st, &[]);
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -639,13 +698,16 @@ pub fn project_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, Ap
|
||||||
pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
let mut st = saved(&state)?;
|
let mut st = saved(&state)?;
|
||||||
|
let removed: Vec<String> = st.projects.iter().filter(|p| p.id == id).map(|p| p.path.clone()).collect();
|
||||||
st.projects.retain(|p| p.id != id);
|
st.projects.retain(|p| p.id != id);
|
||||||
st.overrides.links.retain(|l| l.project != id);
|
st.overrides.links.retain(|l| l.project != id);
|
||||||
let (ig, key) = (format!("project:{id}"), format!("project:{id}"));
|
let (ig, key) = (format!("project:{id}"), format!("project:{id}"));
|
||||||
st.overrides.ignored.retain(|i| i != &ig);
|
st.overrides.ignored.retain(|i| i != &ig);
|
||||||
st.overrides.app_studio.retain(|a| a.key != key);
|
st.overrides.app_studio.retain(|a| a.key != key);
|
||||||
let _ = state.cache.forget_project(&id);
|
let _ = state.cache.forget_project(&id);
|
||||||
persist(&state, &st)
|
persist(&state, &st)?;
|
||||||
|
publish_folders(&state, &st, &removed);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -674,6 +736,7 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
|
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
|
sync_machine(&state)?;
|
||||||
Ok(saved(&state)?.accounts.clone())
|
Ok(saved(&state)?.accounts.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -686,17 +749,28 @@ fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new key goes into the machine's credential profiles (the profile that already has this key, else one named after
|
||||||
|
/// the label), so the CLI sees it too. The same key twice is one account.
|
||||||
fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<String>) -> Res<Account> {
|
fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<String>) -> Res<Account> {
|
||||||
let label = accounts::check_label(label).map_err(Fail::host)?;
|
let label = accounts::check_label(label).map_err(Fail::host)?;
|
||||||
check_studio(state, &studio)?;
|
check_studio(state, &studio)?;
|
||||||
let acc = Account { id: store::new_id()?, label, kind: secret.kind(), created: store::now_iso(), studio };
|
let profile = state.profiles.place(&label, &secret);
|
||||||
state.secrets.set(&acc.id, &secret).map_err(Fail::host)?;
|
state.profiles.save(&profile, &label, &secret).map_err(Fail::host)?;
|
||||||
let mut st = saved(state)?;
|
let mut st = saved(state)?;
|
||||||
st.accounts.push(acc.clone());
|
if let Some(a) = st.accounts.iter_mut().find(|a| a.kind == secret.kind() && a.profile.as_deref() == Some(profile.as_str())) {
|
||||||
if let Err(e) = persist(state, &st) {
|
a.label = label;
|
||||||
let _ = state.secrets.delete(&acc.id);
|
if studio.is_some() {
|
||||||
return Err(e);
|
a.studio = studio;
|
||||||
|
}
|
||||||
|
let out = a.clone();
|
||||||
|
persist(state, &st)?;
|
||||||
|
return Ok(out);
|
||||||
}
|
}
|
||||||
|
let id = profiles::account_id(secret.kind(), &profile);
|
||||||
|
let id = if st.accounts.iter().any(|a| a.id == id) { store::new_id()? } else { id };
|
||||||
|
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile) };
|
||||||
|
st.accounts.push(acc.clone());
|
||||||
|
persist(state, &st)?;
|
||||||
Ok(acc)
|
Ok(acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,7 +788,7 @@ async fn pick_file(window: &WebviewWindow, title: &'static str, filter: (&'stati
|
||||||
std::fs::read_to_string(&path).map(Some).map_err(|e| Fail::host(e.to_string()))
|
std::fs::read_to_string(&path).map(Some).map_err(|e| Fail::host(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Google Play: pick the service account JSON key in a native dialog; it goes to the keychain only.
|
/// Google Play: pick the service account JSON key in a native dialog; it goes into a credential profile (0600).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String, studio: Option<String>) -> Res<Option<Account>> {
|
pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String, studio: Option<String>) -> Res<Option<Account>> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
|
|
@ -725,7 +799,8 @@ pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>,
|
||||||
add_account(&state, &label, Secret::Play { service_account: sa }, studio).map(Some)
|
add_account(&state, &label, Secret::Play { service_account: sa }, studio).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App Store Connect: key id + issuer id typed in the app, the .p8 picked in a native dialog; all to the keychain.
|
/// App Store Connect: key id + issuer id typed in the app, the .p8 picked in a native dialog; all into a credential
|
||||||
|
/// profile (0600).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String, studio: Option<String>) -> Res<Option<Account>> {
|
pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String, studio: Option<String>) -> Res<Option<Account>> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
|
|
@ -741,8 +816,18 @@ pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
state.secrets.delete(&id).map_err(Fail::host)?;
|
|
||||||
let mut st = saved(&state)?;
|
let mut st = saved(&state)?;
|
||||||
|
let acc = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?;
|
||||||
|
// the key leaves the shared profile (moved to credentials/.removed/, never deleted outright), or the keychain
|
||||||
|
match &acc.profile {
|
||||||
|
Some(p) => {
|
||||||
|
if state.profiles.names().contains(p) && state.profiles.kinds(p).contains(&acc.kind) {
|
||||||
|
let stamp = store::now_iso().replace(['-', ':'], "");
|
||||||
|
state.profiles.remove(p, acc.kind, &stamp).map_err(Fail::host)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => state.legacy.delete(&id).map_err(Fail::host)?,
|
||||||
|
}
|
||||||
st.accounts.retain(|a| a.id != id);
|
st.accounts.retain(|a| a.id != id);
|
||||||
let ignored = format!("account:{id}");
|
let ignored = format!("account:{id}");
|
||||||
st.overrides.ignored.retain(|i| i != &ignored);
|
st.overrides.ignored.retain(|i| i != &ignored);
|
||||||
|
|
@ -969,7 +1054,7 @@ pub async fn studio_logo(window: WebviewWindow, state: State<'_, AppState>, id:
|
||||||
Ok(Some(out))
|
Ok(Some(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames an account or moves it to another studio (the key in the keychain stays as it is).
|
/// Renames an account or moves it to another studio (the key in its credential profile stays as it is).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: String, label: String, studio: Option<String>) -> Res<Account> {
|
pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: String, label: String, studio: Option<String>) -> Res<Account> {
|
||||||
guard(&window)?;
|
guard(&window)?;
|
||||||
|
|
@ -1105,7 +1190,7 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res
|
||||||
let yes = tauri::async_runtime::spawn_blocking(move || {
|
let yes = tauri::async_runtime::spawn_blocking(move || {
|
||||||
w.app_handle()
|
w.app_handle()
|
||||||
.dialog()
|
.dialog()
|
||||||
.message("Removes every store key from the system keychain and forgets studios, accounts, app folders (the folders and their files stay), pairing, the ignore list, settings and saved store reads.\n\nDownloaded tools stay. This cannot be undone.")
|
.message("Forgets studios, labels, pairing, the ignore list, settings and saved store reads of this app.\n\nThe store accounts in ~/.storebound/credentials and the app folder list in ~/.storebound/projects.json stay: the storebound CLI uses them too (remove an account in Accounts first to remove its key). Downloaded tools stay. This cannot be undone.")
|
||||||
.title("Reset storebound desktop?")
|
.title("Reset storebound desktop?")
|
||||||
.kind(MessageDialogKind::Warning)
|
.kind(MessageDialogKind::Warning)
|
||||||
.buttons(MessageDialogButtons::OkCancelCustom("Reset".into(), "Cancel".into()))
|
.buttons(MessageDialogButtons::OkCancelCustom("Reset".into(), "Cancel".into()))
|
||||||
|
|
@ -1118,13 +1203,15 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let mut st = saved(&state)?;
|
let mut st = saved(&state)?;
|
||||||
for a in &st.accounts {
|
// keys of earlier versions still in the keychain go; the shared credential profiles stay (the CLI uses them)
|
||||||
state.secrets.delete(&a.id).map_err(Fail::host)?;
|
for a in st.accounts.iter().filter(|a| a.profile.is_none()) {
|
||||||
|
state.legacy.delete(&a.id).map_err(Fail::host)?;
|
||||||
}
|
}
|
||||||
*st = Saved::default();
|
*st = Saved::default();
|
||||||
persist(&state, &st)?;
|
persist(&state, &st)?;
|
||||||
drop(st);
|
drop(st);
|
||||||
state.cache.clear().map_err(Fail::host)?;
|
state.cache.clear().map_err(Fail::host)?;
|
||||||
|
sync_machine(&state)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1138,7 +1225,7 @@ pub async fn state_reset(window: WebviewWindow, app: AppHandle, state: State<'_,
|
||||||
let yes = tauri::async_runtime::spawn_blocking(move || {
|
let yes = tauri::async_runtime::spawn_blocking(move || {
|
||||||
w.app_handle()
|
w.app_handle()
|
||||||
.dialog()
|
.dialog()
|
||||||
.message("storebound forgets its list of app folders, accounts, studios, pairing and settings, and starts empty.\n\nNothing is deleted: the saved state is kept as a backup file in the app data folder, store keys stay in the system keychain, and your app folders are not touched.")
|
.message("storebound forgets its studios, pairing and settings, and starts again from the machine's accounts and app folders.\n\nNothing is deleted: the saved state is kept as a backup file in the app data folder, store keys stay in ~/.storebound/credentials, and your app folders are not touched.")
|
||||||
.title("Reset saved state?")
|
.title("Reset saved state?")
|
||||||
.kind(MessageDialogKind::Warning)
|
.kind(MessageDialogKind::Warning)
|
||||||
.buttons(MessageDialogButtons::OkCancelCustom("Reset and keep a backup".into(), "Cancel".into()))
|
.buttons(MessageDialogButtons::OkCancelCustom("Reset and keep a backup".into(), "Cancel".into()))
|
||||||
|
|
@ -1302,6 +1389,8 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let path = dir.to_string_lossy().to_string();
|
let path = dir.to_string_lossy().to_string();
|
||||||
|
// `storebound desktop` put the folder on the machine's list first: then it is selected without a question
|
||||||
|
sync_machine(&state)?;
|
||||||
let known = saved(&state)?.projects.iter().find(|p| p.path == path).map(|p| p.id.clone());
|
let known = saved(&state)?.projects.iter().find(|p| p.path == path).map(|p| p.id.clone());
|
||||||
let id = match known {
|
let id = match known {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ pub mod confirm;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
|
pub mod profiles;
|
||||||
|
pub mod registry;
|
||||||
pub mod selftest;
|
pub mod selftest;
|
||||||
pub mod updates;
|
pub mod updates;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
|
@ -147,6 +149,7 @@ pub fn run() {
|
||||||
dev_seed(&data_dir);
|
dev_seed(&data_dir);
|
||||||
let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?;
|
let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?;
|
||||||
let (saved, report) = store::load_report(&data_dir);
|
let (saved, report) = store::load_report(&data_dir);
|
||||||
|
let home = app.path().home_dir().ok();
|
||||||
logs::host("info", &format!("saved state: {} app folders ({} not found), {} accounts, {} studios, format {}", saved.projects.len(), saved.projects.iter().filter(|p| !std::path::Path::new(&p.path).is_dir()).count(), saved.accounts.len(), saved.studios.len(), saved.version));
|
logs::host("info", &format!("saved state: {} app folders ({} not found), {} accounts, {} studios, format {}", saved.projects.len(), saved.projects.iter().filter(|p| !std::path::Path::new(&p.path).is_dir()).count(), saved.accounts.len(), saved.studios.len(), saved.version));
|
||||||
if let Some(from) = report.migrated_from {
|
if let Some(from) = report.migrated_from {
|
||||||
logs::host("info", &format!("saved state migrated from format {from} to {}", store::VERSION));
|
logs::host("info", &format!("saved state migrated from format {from} to {}", store::VERSION));
|
||||||
|
|
@ -162,12 +165,18 @@ pub fn run() {
|
||||||
sidecar: sidecar::Sidecar::default(),
|
sidecar: sidecar::Sidecar::default(),
|
||||||
saved: Mutex::new(saved),
|
saved: Mutex::new(saved),
|
||||||
data_dir,
|
data_dir,
|
||||||
secrets: Box::new(accounts::Keychain),
|
profiles: profiles::ProfileStore::new(profiles::default_dir(home.as_deref()), home.clone()),
|
||||||
|
legacy: Box::new(accounts::Keychain),
|
||||||
|
registry: registry::default_file(home.as_deref()),
|
||||||
cache,
|
cache,
|
||||||
downloads: downloads::Jobs::default(),
|
downloads: downloads::Jobs::default(),
|
||||||
};
|
};
|
||||||
// Settings > Offline cache: drop saved reads older than the chosen number of days
|
// Settings > Offline cache: drop saved reads older than the chosen number of days
|
||||||
commands::prune_cache(&state, keep_days);
|
commands::prune_cache(&state, keep_days);
|
||||||
|
// the machine's shared accounts and app folders (CLI and app); old keychain accounts move into profiles once
|
||||||
|
if let Err(e) = commands::sync_machine(&state) {
|
||||||
|
logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message));
|
||||||
|
}
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
app.set_menu(menu(app.handle())?)?;
|
app.set_menu(menu(app.handle())?)?;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// `storebound desktop` reads the installed version this way (Windows, Linux; macOS reads Info.plist)
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("--version") {
|
||||||
|
println!("storebound-desktop {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Release smoke test of the exact artifact (tools/desktop-release.ts, the Forgejo workflow): sidecar handshake,
|
// Release smoke test of the exact artifact (tools/desktop-release.ts, the Forgejo workflow): sidecar handshake,
|
||||||
// one JSON line, exit. Runs before Tauri starts, so no window opens.
|
// one JSON line, exit. Runs before Tauri starts, so no window opens.
|
||||||
if std::env::args().skip(1).any(|a| a == storebound_desktop::selftest::FLAG) {
|
if std::env::args().skip(1).any(|a| a == storebound_desktop::selftest::FLAG) {
|
||||||
|
|
|
||||||
375
apps/desktop/src-tauri/src/profiles.rs
Normal file
375
apps/desktop/src-tauri/src/profiles.rs
Normal 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
|
||||||
|
}
|
||||||
140
apps/desktop/src-tauri/src/registry.rs
Normal file
140
apps/desktop/src-tauri/src/registry.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
//! The app folders of this machine, shared with the storebound CLI: `~/.storebound/projects.json`
|
||||||
|
//! (STOREBOUND_REGISTRY), the same file as `storebound apps folders` / `apps add` / `storebound desktop` / `ws`
|
||||||
|
//! (cli/lib/registry.ts). The list of folders is the registry's; the app keeps its per-folder extras (mapped accounts)
|
||||||
|
//! in state.json under the same id. `sync` runs at start, when the list is shown and before a deep link is handled;
|
||||||
|
//! adding, removing and locating a folder in the app write the registry too. The first start with a registry missing
|
||||||
|
//! writes the app's own list into it.
|
||||||
|
//! { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::store::{Project, State};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Entry {
|
||||||
|
pub id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub added: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_file(home: Option<&Path>) -> PathBuf {
|
||||||
|
if let Some(f) = std::env::var_os("STOREBOUND_REGISTRY").filter(|f| !f.is_empty()) {
|
||||||
|
return PathBuf::from(f);
|
||||||
|
}
|
||||||
|
home.map(Path::to_path_buf).unwrap_or_default().join(".storebound").join("projects.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI's id for a folder it adds: sha256("project:<path>"), 16 hex (cli/lib/registry.ts folderId).
|
||||||
|
pub fn folder_id(path: &str) -> String {
|
||||||
|
Sha256::digest(format!("project:{path}").as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex16(s: &str) -> bool {
|
||||||
|
s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the file (pure): entries without an absolute path or with a repeated path are dropped.
|
||||||
|
pub fn parse(text: &str) -> Vec<Entry> {
|
||||||
|
let Ok(v) = serde_json::from_str::<Value>(text) else { return vec![] };
|
||||||
|
let mut out: Vec<Entry> = vec![];
|
||||||
|
for p in v.get("projects").and_then(Value::as_array).into_iter().flatten() {
|
||||||
|
let Some(path) = p.get("path").and_then(Value::as_str) else { continue };
|
||||||
|
if !Path::new(path).is_absolute() || out.iter().any(|e| e.path == path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = p.get("id").and_then(Value::as_str).filter(|i| hex16(i) && !out.iter().any(|e| e.id == *i)).map(str::to_string).unwrap_or_else(|| folder_id(path));
|
||||||
|
let name = p.get("name").and_then(Value::as_str).filter(|n| !n.trim().is_empty()).map(str::to_string)
|
||||||
|
.unwrap_or_else(|| Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "App".into()));
|
||||||
|
out.push(Entry { id, path: path.to_string(), name, added: p.get("added").and_then(Value::as_str).unwrap_or_default().to_string() });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// None = the file does not exist (first start); Some(list) otherwise (an unreadable file reads as empty).
|
||||||
|
pub fn read(file: &Path) -> Option<Vec<Entry>> {
|
||||||
|
match fs::read_to_string(file) {
|
||||||
|
Ok(t) => Some(parse(&t)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
|
Err(_) => Some(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
|
||||||
|
if let Some(d) = file.parent() {
|
||||||
|
fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let text = serde_json::to_string_pretty(&serde_json::json!({ "version": 1, "projects": entries })).map_err(|e| e.to_string())? + "\n";
|
||||||
|
let tmp = file.with_extension(format!("tmp-{}", std::process::id()));
|
||||||
|
fs::write(&tmp, text).map_err(|e| e.to_string())?;
|
||||||
|
fs::rename(&tmp, file).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_of(p: &Project) -> Entry {
|
||||||
|
Entry { id: p.id.clone(), path: p.path.clone(), name: p.name.clone(), added: p.added.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the app's folder list the registry's (pure on the values). A registry folder the app has (by path) keeps the
|
||||||
|
/// app's id, name and accounts; a new one comes in with the registry's id; a folder that left the registry leaves the
|
||||||
|
/// app (with its links, ignore entries and studio moves). Returns (changed state, the registry to write back if it
|
||||||
|
/// needs the app's ids or names).
|
||||||
|
pub fn merge(state: &mut State, reg: Option<Vec<Entry>>) -> (bool, Option<Vec<Entry>>) {
|
||||||
|
let Some(reg) = reg else {
|
||||||
|
// first start with a shared list: the app's folders become it
|
||||||
|
return (false, Some(state.projects.iter().map(entry_of).collect()));
|
||||||
|
};
|
||||||
|
let mut projects = Vec::with_capacity(reg.len());
|
||||||
|
let mut write_back = false;
|
||||||
|
for e in ® {
|
||||||
|
match state.projects.iter().find(|p| p.path == e.path) {
|
||||||
|
Some(p) => {
|
||||||
|
if p.id != e.id || p.name != e.name {
|
||||||
|
write_back = true;
|
||||||
|
}
|
||||||
|
projects.push(p.clone());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// an id the app already uses for another folder gets the CLI's id instead
|
||||||
|
let id = if state.projects.iter().any(|p| p.id == e.id) { folder_id(&e.path) } else { e.id.clone() };
|
||||||
|
write_back |= id != e.id;
|
||||||
|
projects.push(Project { id, path: e.path.clone(), name: e.name.clone(), play_account: None, apple_account: None, added: e.added.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let gone: Vec<String> = state.projects.iter().filter(|p| !projects.iter().any(|x| x.id == p.id)).map(|p| p.id.clone()).collect();
|
||||||
|
let changed = projects != state.projects;
|
||||||
|
for id in &gone {
|
||||||
|
let key = format!("project:{id}");
|
||||||
|
state.overrides.links.retain(|l| &l.project != id);
|
||||||
|
state.overrides.ignored.retain(|i| i != &key);
|
||||||
|
state.overrides.app_studio.retain(|a| a.key != key);
|
||||||
|
}
|
||||||
|
state.projects = projects;
|
||||||
|
(changed, if write_back { Some(state.projects.iter().map(entry_of).collect()) } else { None })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the registry, merges, writes it back when needed. Returns whether the state changed.
|
||||||
|
pub fn sync(state: &mut State, file: &Path) -> Result<bool, String> {
|
||||||
|
let (changed, back) = merge(state, read(file));
|
||||||
|
if let Some(list) = back {
|
||||||
|
write(file, &list)?;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the app changed its list (add, remove, locate): the registry gets the app's list, keeping folders another
|
||||||
|
/// tool added since the last sync.
|
||||||
|
pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), String> {
|
||||||
|
let mut list: Vec<Entry> = state.projects.iter().map(entry_of).collect();
|
||||||
|
for e in read(file).unwrap_or_default() {
|
||||||
|
if !list.iter().any(|x| x.path == e.path || x.id == e.id) && !removed.contains(&e.path) {
|
||||||
|
list.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(file, &list)
|
||||||
|
}
|
||||||
|
|
@ -322,3 +322,18 @@ pub fn check_settings(s: &Settings) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The storebound version an app folder pins: `storebound` in its package.json devDependencies or dependencies.
|
||||||
|
pub fn pinned_storebound(dir: &std::path::Path) -> Option<String> {
|
||||||
|
let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
|
||||||
|
let v: Value = serde_json::from_str(&text).ok()?;
|
||||||
|
["devDependencies", "dependencies"].iter().find_map(|k| v.get(*k)?.get("storebound")?.as_str().map(str::to_string))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pinned spec names another version than the app's (its bundled CLI). `^0.8.0`, `~0.8.0`, `=0.8.0` and
|
||||||
|
/// `0.8.0` name 0.8.0; tags, ranges like `*`, `latest`, `file:` / `link:` / `workspace:` specs name none (no notice).
|
||||||
|
pub fn pin_differs(spec: &str, app: &str) -> bool {
|
||||||
|
let s = spec.trim().trim_start_matches(['^', '~', '=', 'v']);
|
||||||
|
let looks = s.split('.').count() == 3 && s.split(['.', '-']).take(3).all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
|
||||||
|
looks && s != app
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "storebound",
|
"productName": "storebound",
|
||||||
"mainBinaryName": "storebound-desktop",
|
"mainBinaryName": "storebound-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.7.1",
|
||||||
"identifier": "dev.storebound.desktop",
|
"identifier": "dev.storebound.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shown_metadata_carries_no_secret() {
|
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();
|
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");
|
assert_eq!(v["kind"], "play");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@ fn conf() -> Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 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!(env!("CARGO_PKG_VERSION"), want, "Cargo.toml (bun tools/desktop-version.ts --set {want})");
|
||||||
assert_eq!(conf()["version"], want, "tauri.conf.json");
|
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();
|
let app: Value = serde_json::from_str(&fs::read_to_string(root().join("../package.json")).unwrap()).unwrap();
|
||||||
|
|
|
||||||
175
apps/desktop/src-tauri/tests/machine.rs
Normal file
175
apps/desktop/src-tauri/tests/machine.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,39 @@ use serde_json::{json, Value};
|
||||||
use storebound_desktop::commands::project_view;
|
use storebound_desktop::commands::project_view;
|
||||||
use storebound_desktop::logs;
|
use storebound_desktop::logs;
|
||||||
use storebound_desktop::store::{self, State, VERSION};
|
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};
|
use storebound_desktop::workspace::{Overrides, Settings};
|
||||||
|
|
||||||
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
|
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
|
||||||
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
|
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
|
||||||
const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.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) {
|
fn write(dir: &std::path::Path, text: &str) {
|
||||||
std::fs::write(dir.join(store::FILE), text).unwrap();
|
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
|
/// 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
|
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
|
||||||
#[test]
|
#[test]
|
||||||
fn host_answers_for_the_fixtures_are_current() {
|
fn host_answers_for_the_fixtures_are_current() {
|
||||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state");
|
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)] {
|
for (name, text, keychain) in [("empty", EMPTY, false), ("d1-missing-folders", D1, false), ("d3-full", D3, true), ("shared-profiles", SHARED, false)] {
|
||||||
let (st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
|
let (mut st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
|
||||||
assert!(notes.is_empty(), "{name}: {notes:?}");
|
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!({
|
let host = json!({
|
||||||
"projects_list": st.projects,
|
"projects_list": projects,
|
||||||
"accounts_list": st.accounts,
|
"accounts_list": st.accounts,
|
||||||
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
|
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ export type { AndroidSdkLookup } from '../../../cli/lib/tools';
|
||||||
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[]; androidSdk?: import('../../../cli/lib/tools').AndroidSdkLookup }
|
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[]; androidSdk?: import('../../../cli/lib/tools').AndroidSdkLookup }
|
||||||
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
|
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
|
||||||
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
|
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
|
||||||
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean }
|
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null }
|
||||||
export type AccountKind = 'play' | 'apple';
|
export type AccountKind = 'play' | 'apple';
|
||||||
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null }
|
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null }
|
||||||
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
|
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
|
||||||
export type StudioColor = (typeof STUDIO_COLORS)[number];
|
export type StudioColor = (typeof STUDIO_COLORS)[number];
|
||||||
/** A user-named group of store accounts (and so of apps). */
|
/** A user-named group of store accounts (and so of apps). */
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,11 @@ let projects: Project[] = [
|
||||||
{ id: 'p-tidy', path: `${HOME}/apps/tidy-lists`, name: 'Tidy Lists', playAccount: P1, appleAccount: A2, added: iso(60 * 24 * 5) },
|
{ id: 'p-tidy', path: `${HOME}/apps/tidy-lists`, name: 'Tidy Lists', playAccount: P1, appleAccount: A2, added: iso(60 * 24 * 5) },
|
||||||
];
|
];
|
||||||
let accounts: Account[] = [
|
let accounts: Account[] = [
|
||||||
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1 },
|
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
|
||||||
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1 },
|
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
|
||||||
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2 },
|
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
|
||||||
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2 },
|
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
|
||||||
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3 },
|
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3, profile: 'demo-indie' },
|
||||||
];
|
];
|
||||||
if (import.meta.env.VITE_FIXTURES === 'empty') { projects = []; accounts = []; studios = []; }
|
if (import.meta.env.VITE_FIXTURES === 'empty') { projects = []; accounts = []; studios = []; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ export const en = {
|
||||||
'checks.sev.accepted': 'Accepted',
|
'checks.sev.accepted': 'Accepted',
|
||||||
'checks.sev.low': 'Low',
|
'checks.sev.low': 'Low',
|
||||||
|
|
||||||
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). Pick the store accounts each app uses; the keys stay in the keychain.',
|
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). This list is the machine\'s (~/.storebound/projects.json): `storebound desktop` or `storebound apps add` in a terminal adds to it too. Pick the store accounts each app uses.',
|
||||||
'folders.list': 'App folders',
|
'folders.list': 'App folders',
|
||||||
'folders.listSub': '{count} folders',
|
'folders.listSub': '{count} folders',
|
||||||
'folders.listSub_one': '1 folder',
|
'folders.listSub_one': '1 folder',
|
||||||
|
|
@ -445,16 +445,19 @@ export const en = {
|
||||||
'run.dryFailed': 'The dry run failed: nothing was written',
|
'run.dryFailed': 'The dry run failed: nothing was written',
|
||||||
'run.done': 'Done',
|
'run.done': 'Done',
|
||||||
|
|
||||||
'accounts.lead': 'Store API keys live in the system keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
|
'accounts.lead': 'Store accounts live once per machine in ~/.storebound, shared with the storebound CLI (`storebound accounts`): one credential profile per account, key files readable only by you. They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
|
||||||
'accounts.list': 'Store accounts',
|
'accounts.list': 'Store accounts',
|
||||||
'accounts.listSub': 'Labels only: the keys are in the keychain',
|
'accounts.listSub': 'Labels and profile names only: the keys are in ~/.storebound',
|
||||||
'accounts.emptyTitle': 'No store accounts yet',
|
'accounts.emptyTitle': 'No store accounts yet',
|
||||||
'accounts.emptyText': 'Add a Google Play service account or an App Store Connect API key below. Store reads need them; app folders work without.',
|
'accounts.emptyText': 'Add a Google Play service account or an App Store Connect API key below. Store reads need them; app folders work without.',
|
||||||
'accounts.unused': 'Not used',
|
'accounts.unused': 'Not used',
|
||||||
'accounts.remove': 'Remove',
|
'accounts.remove': 'Remove',
|
||||||
'accounts.removeAria': 'Remove {label} and delete its key from the keychain',
|
'accounts.removeAria': 'Remove {label}; its key file moves to a .removed folder in ~/.storebound',
|
||||||
'accounts.saved': '"{label}" saved',
|
'accounts.saved': '"{label}" saved',
|
||||||
'accounts.savedDetail': 'The key is in the system keychain.',
|
'accounts.savedDetail': 'The key is in a credential profile in ~/.storebound (the CLI uses it too).',
|
||||||
|
'acct.profile': 'profile {name}',
|
||||||
|
'acct.noProfile': 'key not moved from the keychain yet',
|
||||||
|
'app.pinNotice': 'This project uses storebound {pin} (package.json), the app runs {app}. The app works with its own copy; update the project with `bun add -d storebound@{app}` (or npm i -D) to keep both the same.',
|
||||||
'accounts.playSub': 'Service account JSON key',
|
'accounts.playSub': 'Service account JSON key',
|
||||||
'accounts.appleSub': 'Team API key (.p8) from Users and Access, Integrations',
|
'accounts.appleSub': 'Team API key (.p8) from Users and Access, Integrations',
|
||||||
'accounts.label': 'Name',
|
'accounts.label': 'Name',
|
||||||
|
|
@ -465,7 +468,7 @@ export const en = {
|
||||||
'accounts.issuerId': 'Issuer ID',
|
'accounts.issuerId': 'Issuer ID',
|
||||||
'accounts.choosePlay': 'Choose the JSON key',
|
'accounts.choosePlay': 'Choose the JSON key',
|
||||||
'accounts.chooseApple': 'Choose the .p8 key',
|
'accounts.chooseApple': 'Choose the .p8 key',
|
||||||
'accounts.safety': 'Keys are picked in the system file dialog and go straight to the keychain. The window never sees them.',
|
'accounts.safety': 'Keys are picked in the system file dialog and go straight into ~/.storebound (readable only by you). The window never sees them.',
|
||||||
|
|
||||||
'deps.lead': 'Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases, reviews) needs none of them.',
|
'deps.lead': 'Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases, reviews) needs none of them.',
|
||||||
'deps.checkAgain': 'Check again',
|
'deps.checkAgain': 'Check again',
|
||||||
|
|
@ -547,7 +550,7 @@ export const en = {
|
||||||
'wz.p2t': 'Safe by design',
|
'wz.p2t': 'Safe by design',
|
||||||
'wz.p2s': 'Reads are read-only. Every change shows a dry run and a native confirmation first.',
|
'wz.p2s': 'Reads are read-only. Every change shows a dry run and a native confirmation first.',
|
||||||
'wz.p3t': 'Your keys stay yours',
|
'wz.p3t': 'Your keys stay yours',
|
||||||
'wz.p3s': 'Store keys live in the system keychain. No account, no telemetry.',
|
'wz.p3s': 'Store keys live in ~/.storebound, shared with the storebound CLI. No account, no telemetry.',
|
||||||
'wz.appsTitle': 'Add your apps',
|
'wz.appsTitle': 'Add your apps',
|
||||||
'wz.appsLead': 'Each app is a folder with a storebound.config.ts that describes it on both stores.',
|
'wz.appsLead': 'Each app is a folder with a storebound.config.ts that describes it on both stores.',
|
||||||
'wz.addTitle': 'Add an existing folder',
|
'wz.addTitle': 'Add an existing folder',
|
||||||
|
|
@ -607,11 +610,11 @@ export const en = {
|
||||||
'color.teal': 'Teal',
|
'color.teal': 'Teal',
|
||||||
'color.sky': 'Sky',
|
'color.sky': 'Sky',
|
||||||
'color.slate': 'Slate',
|
'color.slate': 'Slate',
|
||||||
'accounts.leadStudios': 'Studios group your store accounts: Google Play service accounts and App Store Connect team keys. Keys stay in the system keychain; apps come in with Import apps.',
|
'accounts.leadStudios': 'Studios group your store accounts: Google Play service accounts and App Store Connect team keys. Keys live in ~/.storebound, shared with the CLI; apps come in with Import apps.',
|
||||||
'accounts.emptyTextStudios': 'Add a studio, then its Google Play service account and App Store Connect keys. Import apps then lists every app those keys can see.',
|
'accounts.emptyTextStudios': 'Add a studio, then its Google Play service account and App Store Connect keys. Import apps then lists every app those keys can see.',
|
||||||
'accounts.playSubMany': 'Service account JSON key. One service account can be invited to several Play developer accounts: it sees all their apps.',
|
'accounts.playSubMany': 'Service account JSON key. One service account can be invited to several Play developer accounts: it sees all their apps.',
|
||||||
'accounts.appleSubMany': 'One API key per team (company or individual): key id, issuer id and the .p8 file.',
|
'accounts.appleSubMany': 'One API key per team (company or individual): key id, issuer id and the .p8 file.',
|
||||||
'accounts.safetyStudios': 'Keys are read once from the file you pick, stored in the system keychain and never shown again. storebound gets one account\'s key in memory for one call. Studio names, labels and app lists stay on this computer.',
|
'accounts.safetyStudios': 'Keys are read once from the file you pick, stored in ~/.storebound (readable only by you, shared with the storebound CLI) and never shown again. storebound gets one account\'s key in memory for one call. Studio names, labels and app lists stay on this computer.',
|
||||||
'acct.ok': 'Works',
|
'acct.ok': 'Works',
|
||||||
'acct.noApps': 'No apps',
|
'acct.noApps': 'No apps',
|
||||||
'acct.permission': 'Permission missing',
|
'acct.permission': 'Permission missing',
|
||||||
|
|
@ -768,7 +771,7 @@ export const en = {
|
||||||
'set.writesText': 'Dry run first, then your Apply in a system dialog. Submit, release and publish stay in the consoles.',
|
'set.writesText': 'Dry run first, then your Apply in a system dialog. Submit, release and publish stay in the consoles.',
|
||||||
'set.privacy': 'Privacy',
|
'set.privacy': 'Privacy',
|
||||||
'set.p1': 'No telemetry, no analytics, no crash reports: the app talks only to the stores, with your keys, when you ask.',
|
'set.p1': 'No telemetry, no analytics, no crash reports: the app talks only to the stores, with your keys, when you ask.',
|
||||||
'set.p2': 'Store keys live in the system keychain; the app data folder holds names, settings and saved reads, never keys.',
|
'set.p2': 'Store keys live in ~/.storebound and the app folder list in ~/.storebound/projects.json, shared with the CLI; the app data folder holds labels, settings and saved reads, never keys.',
|
||||||
'set.p3': 'Nothing is written into your app folders unless you confirm it in a system dialog.',
|
'set.p3': 'Nothing is written into your app folders unless you confirm it in a system dialog.',
|
||||||
'set.p4': 'Tester emails stay masked; review drafts stay in each app folder\'s .storebound/ (gitignored).',
|
'set.p4': 'Tester emails stay masked; review drafts stay in each app folder\'s .storebound/ (gitignored).',
|
||||||
'set.reveal': 'Show the data folder',
|
'set.reveal': 'Show the data folder',
|
||||||
|
|
@ -785,7 +788,7 @@ export const en = {
|
||||||
'set.updateInstall': 'Install {version}…',
|
'set.updateInstall': 'Install {version}…',
|
||||||
'set.updateInstalling': 'Downloading the update… {done}',
|
'set.updateInstalling': 'Downloading the update… {done}',
|
||||||
'set.reset': 'Reset',
|
'set.reset': 'Reset',
|
||||||
'set.resetText': 'Forgets {accounts} accounts (their keys are removed from the keychain), {studios} studios, {folders} app folders (the folders stay on disk), pairing, the ignore list, settings and saved reads. A system dialog asks first.',
|
'set.resetText': 'Forgets {studios} studios, the labels of {accounts} accounts, pairing, the ignore list, settings and saved reads. The accounts in ~/.storebound and the {folders} app folders stay: the CLI uses them too. A system dialog asks first.',
|
||||||
'set.resetButton': 'Reset the app',
|
'set.resetButton': 'Reset the app',
|
||||||
'set.resetDone': 'storebound desktop was reset',
|
'set.resetDone': 'storebound desktop was reset',
|
||||||
'set.footer': '{n} apps known to this computer',
|
'set.footer': '{n} apps known to this computer',
|
||||||
|
|
@ -1275,7 +1278,7 @@ export const en = {
|
||||||
'crash.retry': 'Try again',
|
'crash.retry': 'Try again',
|
||||||
'crash.reload': 'Reload',
|
'crash.reload': 'Reload',
|
||||||
'crash.reset': 'Reset saved state (keeps a backup)',
|
'crash.reset': 'Reset saved state (keeps a backup)',
|
||||||
'crash.resetHint': 'Starts with an empty list. The saved state is kept as a backup file, store keys stay in the keychain, app folders are not touched.',
|
'crash.resetHint': 'Starts again from the machine\'s accounts and app folders. The saved state is kept as a backup file, store keys stay in ~/.storebound, app folders are not touched.',
|
||||||
'crash.resetDone': 'Saved state reset. Backup: {path}. Reloading…',
|
'crash.resetDone': 'Saved state reset. Backup: {path}. Reloading…',
|
||||||
'crash.logNote': 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).',
|
'crash.logNote': 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).',
|
||||||
'folder.missing': 'Folder not found.',
|
'folder.missing': 'Folder not found.',
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,7 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'checks.sev.accepted': 'Kabul',
|
'checks.sev.accepted': 'Kabul',
|
||||||
'checks.sev.low': 'Düşük',
|
'checks.sev.low': 'Düşük',
|
||||||
|
|
||||||
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Her uygulamanın kullandığı mağaza hesaplarını seçin; anahtarlar anahtar zincirinde kalır.',
|
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Bu liste bilgisayarın listesidir (~/.storebound/projects.json): terminalde `storebound desktop` ya da `storebound apps add` da buraya ekler. Her uygulamanın kullandığı mağaza hesaplarını seçin.',
|
||||||
'folders.list': 'Uygulama klasörleri',
|
'folders.list': 'Uygulama klasörleri',
|
||||||
'folders.listSub': '{count} klasör',
|
'folders.listSub': '{count} klasör',
|
||||||
'folders.listSub_one': '1 klasör',
|
'folders.listSub_one': '1 klasör',
|
||||||
|
|
@ -447,16 +447,19 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'run.dryFailed': 'Deneme çalıştırması başarısız: hiçbir şey yazılmadı',
|
'run.dryFailed': 'Deneme çalıştırması başarısız: hiçbir şey yazılmadı',
|
||||||
'run.done': 'Bitti',
|
'run.done': 'Bitti',
|
||||||
|
|
||||||
'accounts.lead': 'Mağaza API anahtarları sistemin anahtar zincirinde durur (macOS\'ta Anahtar Zinciri, Windows\'ta Kimlik Bilgisi Yöneticisi, Linux\'ta Secret Service). Bir daha gösterilmez, uygulama klasörlerinize yazılmaz ve storebound\'a yalnızca bir çağrı için bellekte verilir.',
|
'accounts.lead': 'Mağaza hesapları bilgisayarda bir kez, ~/.storebound içinde durur ve storebound CLI ile paylaşılır (`storebound accounts`): her hesap için bir kimlik profili, anahtar dosyalarını yalnızca siz okuyabilirsiniz. Bir daha gösterilmez, uygulama klasörlerinize yazılmaz, storebound\'a bir çağrı için bellekte ulaşır.',
|
||||||
'accounts.list': 'Mağaza hesapları',
|
'accounts.list': 'Mağaza hesapları',
|
||||||
'accounts.listSub': 'Yalnızca etiketler: anahtarlar anahtar zincirinde',
|
'accounts.listSub': 'Yalnızca etiketler ve profil adları: anahtarlar ~/.storebound içinde',
|
||||||
'accounts.emptyTitle': 'Henüz mağaza hesabı yok',
|
'accounts.emptyTitle': 'Henüz mağaza hesabı yok',
|
||||||
'accounts.emptyText': 'Aşağıdan bir Google Play hizmet hesabı veya App Store Connect API anahtarı ekleyin. Mağaza okumaları bunlara ihtiyaç duyar; uygulama klasörleri onlarsız da çalışır.',
|
'accounts.emptyText': 'Aşağıdan bir Google Play hizmet hesabı veya App Store Connect API anahtarı ekleyin. Mağaza okumaları bunlara ihtiyaç duyar; uygulama klasörleri onlarsız da çalışır.',
|
||||||
'accounts.unused': 'Kullanılmıyor',
|
'accounts.unused': 'Kullanılmıyor',
|
||||||
'accounts.remove': 'Kaldır',
|
'accounts.remove': 'Kaldır',
|
||||||
'accounts.removeAria': '{label} hesabını kaldır ve anahtarını anahtar zincirinden sil',
|
'accounts.removeAria': '{label} hesabını kaldır; anahtar dosyası ~/.storebound içindeki .removed klasörüne taşınır',
|
||||||
'accounts.saved': '"{label}" kaydedildi',
|
'accounts.saved': '"{label}" kaydedildi',
|
||||||
'accounts.savedDetail': 'Anahtar sistemin anahtar zincirinde.',
|
'accounts.savedDetail': 'Anahtar ~/.storebound içindeki bir kimlik profilinde (CLI da kullanır).',
|
||||||
|
'acct.profile': 'profil {name}',
|
||||||
|
'acct.noProfile': 'anahtar henüz anahtar zincirinden taşınmadı',
|
||||||
|
'app.pinNotice': 'Bu proje storebound {pin} kullanıyor (package.json), uygulama {app} çalıştırıyor. Uygulama kendi kopyasıyla çalışır; ikisini aynı tutmak için projeyi `bun add -d storebound@{app}` (ya da npm i -D) ile güncelleyin.',
|
||||||
'accounts.playSub': 'Hizmet hesabı JSON anahtarı',
|
'accounts.playSub': 'Hizmet hesabı JSON anahtarı',
|
||||||
'accounts.appleSub': 'Kullanıcılar ve Erişim, Entegrasyonlar altındaki ekip API anahtarı (.p8)',
|
'accounts.appleSub': 'Kullanıcılar ve Erişim, Entegrasyonlar altındaki ekip API anahtarı (.p8)',
|
||||||
'accounts.label': 'Ad',
|
'accounts.label': 'Ad',
|
||||||
|
|
@ -467,7 +470,7 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'accounts.issuerId': 'Veren kimliği',
|
'accounts.issuerId': 'Veren kimliği',
|
||||||
'accounts.choosePlay': 'JSON anahtarını seç',
|
'accounts.choosePlay': 'JSON anahtarını seç',
|
||||||
'accounts.chooseApple': '.p8 anahtarını seç',
|
'accounts.chooseApple': '.p8 anahtarını seç',
|
||||||
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan anahtar zincirine gider. Uygulama penceresi onları hiç görmez.',
|
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan ~/.storebound klasörüne gider (yalnızca siz okuyabilirsiniz). Pencere onları hiç görmez.',
|
||||||
|
|
||||||
'deps.lead': 'Ekran görüntüleri, uygulama önizlemeleri, simgeler ve simülatör ya da emülatör kayıtları için yerel araçlar. Mağaza işleri (metinler, ürünler, sürümler, yorumlar) hiçbirine ihtiyaç duymaz.',
|
'deps.lead': 'Ekran görüntüleri, uygulama önizlemeleri, simgeler ve simülatör ya da emülatör kayıtları için yerel araçlar. Mağaza işleri (metinler, ürünler, sürümler, yorumlar) hiçbirine ihtiyaç duymaz.',
|
||||||
'deps.checkAgain': 'Yeniden kontrol et',
|
'deps.checkAgain': 'Yeniden kontrol et',
|
||||||
|
|
@ -549,7 +552,7 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'wz.p2t': 'Tasarımı gereği güvenli',
|
'wz.p2t': 'Tasarımı gereği güvenli',
|
||||||
'wz.p2s': 'Okumalar salt okunurdur. Her değişiklik önce bir deneme çalıştırması ve sistem onayı gösterir.',
|
'wz.p2s': 'Okumalar salt okunurdur. Her değişiklik önce bir deneme çalıştırması ve sistem onayı gösterir.',
|
||||||
'wz.p3t': 'Anahtarlarınız sizde kalır',
|
'wz.p3t': 'Anahtarlarınız sizde kalır',
|
||||||
'wz.p3s': 'Mağaza anahtarları sistemin anahtar zincirinde durur. Hesap yok, telemetri yok.',
|
'wz.p3s': 'Mağaza anahtarları ~/.storebound içinde, storebound CLI ile paylaşılır. Hesap yok, telemetri yok.',
|
||||||
'wz.appsTitle': 'Uygulamalarınızı ekleyin',
|
'wz.appsTitle': 'Uygulamalarınızı ekleyin',
|
||||||
'wz.appsLead': 'Her uygulama, onu iki mağazada anlatan bir storebound.config.ts dosyası olan bir klasördür.',
|
'wz.appsLead': 'Her uygulama, onu iki mağazada anlatan bir storebound.config.ts dosyası olan bir klasördür.',
|
||||||
'wz.addTitle': 'Var olan klasörü ekle',
|
'wz.addTitle': 'Var olan klasörü ekle',
|
||||||
|
|
@ -609,11 +612,11 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'color.teal': 'Deniz mavisi',
|
'color.teal': 'Deniz mavisi',
|
||||||
'color.sky': 'Gök mavisi',
|
'color.sky': 'Gök mavisi',
|
||||||
'color.slate': 'Arduvaz',
|
'color.slate': 'Arduvaz',
|
||||||
'accounts.leadStudios': 'Stüdyolar mağaza hesaplarınızı gruplar: Google Play hizmet hesapları ve App Store Connect ekip anahtarları. Anahtarlar sistem anahtar zincirinde kalır; uygulamalar "Uygulamaları içe aktar" ile gelir.',
|
'accounts.leadStudios': 'Stüdyolar mağaza hesaplarınızı gruplar: Google Play hizmet hesapları ve App Store Connect ekip anahtarları. Anahtarlar ~/.storebound içinde, CLI ile paylaşılır; uygulamalar Uygulamaları içe aktar ile gelir.',
|
||||||
'accounts.emptyTextStudios': 'Bir stüdyo, sonra onun Google Play hizmet hesabını ve App Store Connect anahtarlarını ekleyin. "Uygulamaları içe aktar" bu anahtarların gördüğü tüm uygulamaları listeler.',
|
'accounts.emptyTextStudios': 'Bir stüdyo, sonra onun Google Play hizmet hesabını ve App Store Connect anahtarlarını ekleyin. "Uygulamaları içe aktar" bu anahtarların gördüğü tüm uygulamaları listeler.',
|
||||||
'accounts.playSubMany': 'Hizmet hesabı JSON anahtarı. Bir hizmet hesabı birden çok Play geliştirici hesabına davet edilebilir: hepsinin uygulamalarını görür.',
|
'accounts.playSubMany': 'Hizmet hesabı JSON anahtarı. Bir hizmet hesabı birden çok Play geliştirici hesabına davet edilebilir: hepsinin uygulamalarını görür.',
|
||||||
'accounts.appleSubMany': 'Ekip başına bir API anahtarı (şirket ya da bireysel): anahtar kimliği, issuer kimliği ve .p8 dosyası.',
|
'accounts.appleSubMany': 'Ekip başına bir API anahtarı (şirket ya da bireysel): anahtar kimliği, issuer kimliği ve .p8 dosyası.',
|
||||||
'accounts.safetyStudios': 'Anahtarlar seçtiğiniz dosyadan bir kez okunur, sistem anahtar zincirine kaydedilir ve bir daha gösterilmez. storebound bir hesabın anahtarını tek bir çağrı için bellekte alır. Stüdyo adları, etiketler ve uygulama listeleri bu bilgisayarda kalır.',
|
'accounts.safetyStudios': 'Anahtarlar seçtiğiniz dosyadan bir kez okunur, ~/.storebound içinde saklanır (yalnızca siz okuyabilirsiniz, storebound CLI ile paylaşılır) ve bir daha gösterilmez. storebound bir hesabın anahtarını bir çağrı için bellekte alır. Stüdyo adları, etiketler ve uygulama listeleri bu bilgisayarda kalır.',
|
||||||
'acct.ok': 'Çalışıyor',
|
'acct.ok': 'Çalışıyor',
|
||||||
'acct.noApps': 'Uygulama yok',
|
'acct.noApps': 'Uygulama yok',
|
||||||
'acct.permission': 'İzin eksik',
|
'acct.permission': 'İzin eksik',
|
||||||
|
|
@ -770,7 +773,7 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'set.writesText': 'Önce deneme, sonra sistem penceresinde sizin Uygula onayınız. Gönderme, yayınlama ve yayımlama konsollarda kalır.',
|
'set.writesText': 'Önce deneme, sonra sistem penceresinde sizin Uygula onayınız. Gönderme, yayınlama ve yayımlama konsollarda kalır.',
|
||||||
'set.privacy': 'Gizlilik',
|
'set.privacy': 'Gizlilik',
|
||||||
'set.p1': 'Telemetri, analiz ya da çökme raporu yok: uygulama yalnızca siz istediğinizde, anahtarlarınızla mağazalarla konuşur.',
|
'set.p1': 'Telemetri, analiz ya da çökme raporu yok: uygulama yalnızca siz istediğinizde, anahtarlarınızla mağazalarla konuşur.',
|
||||||
'set.p2': 'Mağaza anahtarları sistem anahtar zincirinde durur; uygulama veri klasöründe adlar, ayarlar ve kayıtlı okumalar vardır, anahtar asla.',
|
'set.p2': 'Mağaza anahtarları ~/.storebound, uygulama klasörü listesi ~/.storebound/projects.json içinde, CLI ile paylaşılır; uygulama veri klasöründe etiketler, ayarlar ve kayıtlı okumalar durur, anahtar asla.',
|
||||||
'set.p3': 'Siz bir sistem penceresinde onaylamadıkça uygulama klasörlerinize hiçbir şey yazılmaz.',
|
'set.p3': 'Siz bir sistem penceresinde onaylamadıkça uygulama klasörlerinize hiçbir şey yazılmaz.',
|
||||||
'set.p4': 'Test kullanıcısı e-postaları maskeli kalır; yorum taslakları her uygulama klasörünün .storebound/ klasöründe (gitignore) kalır.',
|
'set.p4': 'Test kullanıcısı e-postaları maskeli kalır; yorum taslakları her uygulama klasörünün .storebound/ klasöründe (gitignore) kalır.',
|
||||||
'set.reveal': 'Veri klasörünü göster',
|
'set.reveal': 'Veri klasörünü göster',
|
||||||
|
|
@ -787,7 +790,7 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'set.updateInstall': '{version} sürümünü yükle…',
|
'set.updateInstall': '{version} sürümünü yükle…',
|
||||||
'set.updateInstalling': 'Güncelleme indiriliyor… {done}',
|
'set.updateInstalling': 'Güncelleme indiriliyor… {done}',
|
||||||
'set.reset': 'Sıfırla',
|
'set.reset': 'Sıfırla',
|
||||||
'set.resetText': '{accounts} hesabı (anahtarları anahtar zincirinden silinir), {studios} stüdyoyu, {folders} uygulama klasörünü (klasörler diskte kalır), eşleştirmeyi, yok sayılanları, ayarları ve kayıtlı okumaları unutur. Önce bir sistem penceresi sorar.',
|
'set.resetText': '{studios} stüdyoyu, {accounts} hesabın etiketlerini, eşleştirmeyi, yok sayılanları, ayarları ve kayıtlı okumaları unutur. ~/.storebound içindeki hesaplar ve {folders} uygulama klasörü kalır: CLI da kullanır. Önce bir sistem penceresi sorar.',
|
||||||
'set.resetButton': 'Uygulamayı sıfırla',
|
'set.resetButton': 'Uygulamayı sıfırla',
|
||||||
'set.resetDone': 'storebound masaüstü sıfırlandı',
|
'set.resetDone': 'storebound masaüstü sıfırlandı',
|
||||||
'set.footer': 'Bu bilgisayarın bildiği {n} uygulama',
|
'set.footer': 'Bu bilgisayarın bildiği {n} uygulama',
|
||||||
|
|
@ -1277,7 +1280,7 @@ export const tr: Record<keyof typeof en, string> = {
|
||||||
'crash.retry': 'Yeniden dene',
|
'crash.retry': 'Yeniden dene',
|
||||||
'crash.reload': 'Yeniden yükle',
|
'crash.reload': 'Yeniden yükle',
|
||||||
'crash.reset': 'Kayıtlı durumu sıfırla (yedek tutulur)',
|
'crash.reset': 'Kayıtlı durumu sıfırla (yedek tutulur)',
|
||||||
'crash.resetHint': 'Boş bir listeyle başlar. Kayıtlı durum bir yedek dosyada saklanır, mağaza anahtarları anahtar zincirinde kalır, uygulama klasörlerine dokunulmaz.',
|
'crash.resetHint': 'Bilgisayarın hesapları ve uygulama klasörleriyle yeniden başlar. Kayıtlı durum yedek dosya olarak saklanır, mağaza anahtarları ~/.storebound içinde kalır, uygulama klasörlerine dokunulmaz.',
|
||||||
'crash.resetDone': 'Kayıtlı durum sıfırlandı. Yedek: {path}. Yeniden yükleniyor…',
|
'crash.resetDone': 'Kayıtlı durum sıfırlandı. Yedek: {path}. Yeniden yükleniyor…',
|
||||||
'crash.logNote': 'Uygulama veri klasöründeki logs/webview.log dosyasına da yazıldı (ana klasör ~ olarak, anahtarlar olmadan).',
|
'crash.logNote': 'Uygulama veri klasöründeki logs/webview.log dosyasına da yazıldı (ana klasör ~ olarak, anahtarlar olmadan).',
|
||||||
'folder.missing': 'Klasör bulunamadı.',
|
'folder.missing': 'Klasör bulunamadı.',
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,13 @@ export function normalizeProjects(v: unknown): Project[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return arr(v).filter(isRec).filter(p => typeof p.id === 'string' && typeof p.path === 'string' && !seen.has(p.id) && !!seen.add(p.id)).map(p => {
|
return arr(v).filter(isRec).filter(p => typeof p.id === 'string' && typeof p.path === 'string' && !seen.has(p.id) && !!seen.add(p.id)).map(p => {
|
||||||
const path = p.path as string;
|
const path = p.path as string;
|
||||||
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true };
|
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true, storeboundPin: strOrNull(p.storeboundPin) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAccounts(v: unknown): Account[] {
|
export function normalizeAccounts(v: unknown): Account[] {
|
||||||
return arr(v).filter(isRec).filter(a => typeof a.id === 'string' && (a.kind === 'play' || a.kind === 'apple'))
|
return arr(v).filter(isRec).filter(a => typeof a.id === 'string' && (a.kind === 'play' || a.kind === 'apple'))
|
||||||
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio) }));
|
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio), profile: strOrNull(a.profile) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function studios(v: unknown): Studio[] {
|
function studios(v: unknown): Studio[] {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Studios & accounts: studios (name, colour, logo) group store accounts; each account is a Google Play service account
|
// Studios & accounts: studios (name, colour, logo) group store accounts; each account is a Google Play service account
|
||||||
// (one JSON key; it can be invited to several Play developer accounts) or an App Store Connect team key (key id,
|
// (one JSON key; it can be invited to several Play developer accounts) or an App Store Connect team key (key id,
|
||||||
// issuer id, .p8). Keys live in the system keychain; the app keeps a label and the studio only. Per account: a health
|
// issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI);
|
||||||
|
// the app keeps a label, the studio and the profile name only. Per account: a health
|
||||||
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
|
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
|
||||||
import { type FormEvent, useEffect, useState } from 'react';
|
import { type FormEvent, useEffect, useState } from 'react';
|
||||||
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
|
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
|
||||||
|
|
@ -59,6 +60,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
|
||||||
<div className="row" style={{ gap: 8 }}><span className="strong">{a.label}</span><HealthChip h={h} />{ignored && <Chip tone="neutral" icon="eyeOff">{t('acct.ignored')}</Chip>}</div>
|
<div className="row" style={{ gap: 8 }}><span className="strong">{a.label}</span><HealthChip h={h} />{ignored && <Chip tone="neutral" icon="eyeOff">{t('acct.ignored')}</Chip>}</div>
|
||||||
<div className="small muted">
|
<div className="small muted">
|
||||||
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')}
|
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')}
|
||||||
|
{' · '}{a.profile ? <span className="mono-id selectable" title="~/.storebound">{t('acct.profile', { name: a.profile })}</span> : t('acct.noProfile')}
|
||||||
{' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')}
|
{' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')}
|
||||||
{used ? ` · ${t('acct.folders', { count: used })}` : ''}
|
{used ? ` · ${t('acct.folders', { count: used })}` : ''}
|
||||||
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}
|
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
||||||
</div>
|
</div>
|
||||||
<AppControls app={app} />
|
<AppControls app={app} />
|
||||||
{!app.project && <StoreOnlyBanner app={app} />}
|
{!app.project && <StoreOnlyBanner app={app} />}
|
||||||
|
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
|
||||||
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
|
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
|
||||||
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
|
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
|
||||||
{tabs.map((x, i) => (
|
{tabs.map((x, i) => (
|
||||||
|
|
|
||||||
4
apps/desktop/tests/fixtures/state/README.txt
vendored
4
apps/desktop/tests/fixtures/state/README.txt
vendored
|
|
@ -6,3 +6,7 @@ empty.json no file content yet (fresh install)
|
||||||
d1-missing-folders.json desktop D1 format (projects + accounts only, no version) with two folders that are gone
|
d1-missing-folders.json desktop D1 format (projects + accounts only, no version) with two folders that are gone
|
||||||
d3-full.json D3 format without version: studios, accounts in studios, overrides, settings, one folder
|
d3-full.json D3 format without version: studios, accounts in studios, overrides, settings, one folder
|
||||||
that exists and one that is gone
|
that exists and one that is gone
|
||||||
|
shared-profiles.json 0.8 format: accounts name their credential profile (~/.storebound/credentials, shared with
|
||||||
|
the CLI); the apple account comes from a profile the CLI wrote; the folder pins storebound 0.6.0
|
||||||
|
The .host.json answers come after the start-up sync: D1's accounts have no keychain entry (they stay without a
|
||||||
|
profile), D3's keychain entries move into credential profiles.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"id": "0123456789abcdef",
|
"id": "0123456789abcdef",
|
||||||
"kind": "play",
|
"kind": "play",
|
||||||
"label": "Example Games Play",
|
"label": "Example Games Play",
|
||||||
|
"profile": "example-games-play",
|
||||||
"studio": "s1"
|
"studio": "s1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -12,6 +13,7 @@
|
||||||
"id": "fedcba9876543210",
|
"id": "fedcba9876543210",
|
||||||
"kind": "apple",
|
"kind": "apple",
|
||||||
"label": "Example Games App Store",
|
"label": "Example Games App Store",
|
||||||
|
"profile": "example-games-app-store",
|
||||||
"studio": "s1"
|
"studio": "s1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +21,7 @@
|
||||||
"id": "00112233445566ff",
|
"id": "00112233445566ff",
|
||||||
"kind": "apple",
|
"kind": "apple",
|
||||||
"label": "Side project",
|
"label": "Side project",
|
||||||
|
"profile": "side-project",
|
||||||
"studio": null
|
"studio": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
58
apps/desktop/tests/fixtures/state/shared-profiles.host.json
vendored
Normal file
58
apps/desktop/tests/fixtures/state/shared-profiles.host.json
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"accounts_list": [
|
||||||
|
{
|
||||||
|
"created": "2026-10-13T00:00:00Z",
|
||||||
|
"id": "82a8c2561d950999",
|
||||||
|
"kind": "play",
|
||||||
|
"label": "Acme Games",
|
||||||
|
"profile": "default",
|
||||||
|
"studio": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created": "2026-10-13T00:00:00Z",
|
||||||
|
"id": "8e4050ce9325b4da",
|
||||||
|
"kind": "apple",
|
||||||
|
"label": "Acme Games",
|
||||||
|
"profile": "default",
|
||||||
|
"studio": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"projects_list": [
|
||||||
|
{
|
||||||
|
"added": "2026-10-13T00:00:00Z",
|
||||||
|
"appleAccount": "8e4050ce9325b4da",
|
||||||
|
"id": "p0game0000000001",
|
||||||
|
"name": "demo-game",
|
||||||
|
"path": "@EXAMPLES@/demo-game",
|
||||||
|
"playAccount": "82a8c2561d950999",
|
||||||
|
"storeboundPin": "0.6.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workspace_get": {
|
||||||
|
"overrides": {
|
||||||
|
"appStudio": [],
|
||||||
|
"ignored": [],
|
||||||
|
"links": [],
|
||||||
|
"pairs": [],
|
||||||
|
"unpairs": []
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"cache": {
|
||||||
|
"keepDays": 30
|
||||||
|
},
|
||||||
|
"channel": "stable",
|
||||||
|
"notify": {
|
||||||
|
"crash": true,
|
||||||
|
"lowReviews": true,
|
||||||
|
"reviewState": true
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"autoMinutes": 0,
|
||||||
|
"icons": true,
|
||||||
|
"onLaunch": false,
|
||||||
|
"parallel": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"studios": []
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/desktop/tests/fixtures/state/shared-profiles.json
vendored
Normal file
17
apps/desktop/tests/fixtures/state/shared-profiles.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"projects": [
|
||||||
|
{ "id": "p0game0000000001", "path": "@EXAMPLES@/demo-game", "name": "demo-game", "playAccount": "82a8c2561d950999", "appleAccount": "8e4050ce9325b4da", "added": "2026-10-13T00:00:00Z" }
|
||||||
|
],
|
||||||
|
"accounts": [
|
||||||
|
{ "id": "82a8c2561d950999", "label": "Acme Games", "kind": "play", "created": "2026-10-13T00:00:00Z", "studio": null, "profile": "default" }
|
||||||
|
],
|
||||||
|
"studios": [],
|
||||||
|
"overrides": { "pairs": [], "unpairs": [], "links": [], "appStudio": [], "ignored": [] },
|
||||||
|
"settings": {
|
||||||
|
"refresh": { "autoMinutes": 0, "onLaunch": false, "parallel": 4, "icons": true },
|
||||||
|
"cache": { "keepDays": 30 },
|
||||||
|
"notify": { "reviewState": true, "lowReviews": true, "crash": true },
|
||||||
|
"channel": "stable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,7 +72,10 @@ describe('host data of any age', () => {
|
||||||
]);
|
]);
|
||||||
expect(p.map(x => [x.id, x.name, x.missing])).toEqual([['a', 'Prism Puddle', true], ['b', 'Tidy', false]]);
|
expect(p.map(x => [x.id, x.name, x.missing])).toEqual([['a', 'Prism Puddle', true], ['b', 'Tidy', false]]);
|
||||||
expect(normalizeProjects(undefined)).toEqual([]);
|
expect(normalizeProjects(undefined)).toEqual([]);
|
||||||
expect(normalizeAccounts([{ id: 'x', kind: 'play' }, { id: 'y', kind: 'fax' }])).toEqual([{ id: 'x', label: 'x', kind: 'play', created: '', studio: null }]);
|
expect(normalizeAccounts([{ id: 'x', kind: 'play' }, { id: 'y', kind: 'fax' }])).toEqual([{ id: 'x', label: 'x', kind: 'play', created: '', studio: null, profile: null }]);
|
||||||
|
// 0.8: the credential profile shared with the CLI; a folder pinning another storebound version
|
||||||
|
expect(normalizeAccounts([{ id: 'z', kind: 'apple', profile: 'default' }])[0]!.profile).toBe('default');
|
||||||
|
expect(normalizeProjects([{ id: 'p', path: '/w/a', storeboundPin: '0.6.0' }])[0]!.storeboundPin).toBe('0.6.0');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
65
cli/accounts.ts
Normal file
65
cli/accounts.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// `storebound accounts`: the store accounts of this machine, shared by every project, the CLI and the desktop app
|
||||||
|
// (credential profiles in ~/.storebound/credentials; model: cli/lib/credentials.ts, cli/lib/accounts.ts). Values
|
||||||
|
// are never printed.
|
||||||
|
// storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which
|
||||||
|
// one this folder uses and why
|
||||||
|
// storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]
|
||||||
|
// move the store keys of a project's .storebound/
|
||||||
|
// credentials.json into a profile (the same account as an
|
||||||
|
// existing profile = that profile); the keystore, review
|
||||||
|
// contact and demo account stay in the project
|
||||||
|
// storebound accounts use <profile> [--dir <project>] this project uses that profile ("account" in its
|
||||||
|
// .storebound/credentials.json)
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import type { Args } from './lib/args';
|
||||||
|
import { applyImport, describeProfiles, planImport } from './lib/accounts';
|
||||||
|
import { activeProfile, profileDir } from './lib/credentials';
|
||||||
|
import { confirm, type Ctx } from './lib/ctx';
|
||||||
|
import { textTable } from './lib/table';
|
||||||
|
|
||||||
|
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
|
||||||
|
|
||||||
|
export async function run(args: Args): Promise<void> {
|
||||||
|
const sub = args._[0] ?? 'list';
|
||||||
|
const dir = resolve(args.str('dir', '.'));
|
||||||
|
if (sub === 'list') {
|
||||||
|
const rows = describeProfiles();
|
||||||
|
let active = null;
|
||||||
|
try { active = activeProfile(dir); } catch { /* unreadable project file */ }
|
||||||
|
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows, thisFolder: active }, null, 2)); return; }
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-'])));
|
||||||
|
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
|
||||||
|
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and none named "default": storebound accounts use <profile>)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === 'import') {
|
||||||
|
const plan = planImport(dir, { name: args.get('name') });
|
||||||
|
const json = args.has('json');
|
||||||
|
const dry = args.has('dry');
|
||||||
|
if (json && dry) { console.log(JSON.stringify({ ...plan, writes: plan.writes.map(w => ({ path: tilde(w.path), what: w.what, action: w.action })), project: tilde(plan.project), projectFile: tilde(plan.projectFile) }, null, 2)); return; }
|
||||||
|
console.log(`${dry ? 'dry run: ' : ''}import the store accounts of ${tilde(plan.project)} into profile "${plan.profile}"${plan.reuse ? ' (the same account is already there)' : ' (new)'}`);
|
||||||
|
console.log(` stores: ${plan.stores.join(', ')}; moved out of the project file: ${plan.moved.join(', ')}`);
|
||||||
|
for (const w of plan.writes) console.log(` ${w.action === 'same' ? 'unchanged' : w.action.padEnd(9)} ${tilde(w.path)} (${w.what}${w.mode === 0o600 && w.action !== 'same' ? ', mode 600' : ''})`);
|
||||||
|
for (const n of plan.notes) console.log(` note: ${n}`);
|
||||||
|
console.log(' stays in the project: the Android upload keystore, the review contact and demo account, everything else');
|
||||||
|
if (dry) { console.log('\nnothing written. Run again without --dry to import.'); return; }
|
||||||
|
if (plan.writes.every(w => w.action === 'same')) { console.log('\nalready imported: nothing to change'); return; }
|
||||||
|
if (!(await confirm({ args } as unknown as Ctx, `Import into profile "${plan.profile}"?`))) return;
|
||||||
|
const r = applyImport(plan);
|
||||||
|
console.log(`\nimported: profile "${plan.profile}"${r.backup ? `; the old project file is kept as ${tilde(r.backup)}` : ''}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === 'use') {
|
||||||
|
const name = args._[1];
|
||||||
|
if (!name) throw new Error('storebound accounts use <profile>');
|
||||||
|
const { useProfile } = await import('./lib/accounts');
|
||||||
|
const f = useProfile(dir, name);
|
||||||
|
console.log(`${tilde(f)}: "account": "${name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile>]');
|
||||||
|
}
|
||||||
35
cli/apps.ts
35
cli/apps.ts
|
|
@ -5,6 +5,9 @@
|
||||||
// every Play developer account that invited the
|
// every Play developer account that invited the
|
||||||
// service account)
|
// service account)
|
||||||
// storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix
|
// storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix
|
||||||
|
// storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,
|
||||||
|
// the same list as the desktop app's App folders)
|
||||||
|
// storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)
|
||||||
import type { Args } from './lib/args';
|
import type { Args } from './lib/args';
|
||||||
import { ascKey, playServiceAccount } from './lib/credentials';
|
import { ascKey, playServiceAccount } from './lib/credentials';
|
||||||
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
|
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
|
||||||
|
|
@ -16,6 +19,10 @@ import { textTable } from './lib/table';
|
||||||
import { ascClient } from './apple/asc';
|
import { ascClient } from './apple/asc';
|
||||||
import { REPORTING_API } from './play/google';
|
import { REPORTING_API } from './play/google';
|
||||||
import * as rt from './lib/rt';
|
import * as rt from './lib/rt';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { CONFIG_NAMES } from './lib/config';
|
||||||
|
import { addFolder, readRegistry, registryFile, removeFolder } from './lib/registry';
|
||||||
|
|
||||||
type Store = 'apple' | 'play';
|
type Store = 'apple' | 'play';
|
||||||
|
|
||||||
|
|
@ -113,10 +120,36 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
|
||||||
} catch (e) { return playCheck(principal, { error: e }); }
|
} catch (e) { return playCheck(principal, { error: e }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
|
||||||
|
|
||||||
|
/** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */
|
||||||
|
function folders(sub: string, args: Args): void {
|
||||||
|
if (sub === 'folders') {
|
||||||
|
const r = readRegistry();
|
||||||
|
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })) }); return; }
|
||||||
|
if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
|
||||||
|
console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found'])));
|
||||||
|
console.log(`
|
||||||
|
${tilde(registryFile())}: the same list as the desktop app's App folders`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = resolve(args._[1] ?? '.');
|
||||||
|
if (sub === 'add') {
|
||||||
|
if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`);
|
||||||
|
const r = addFolder(dir);
|
||||||
|
log('apps', r.added ? `added ${tilde(r.project.path)} (it shows in the desktop app too)` : `${tilde(r.project.path)} is already in the list`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!args._[1]) throw new Error('storebound apps remove <folder>');
|
||||||
|
const removed = removeFolder(args._[1]!.match(/^[0-9a-f]{16}$/) ? args._[1]! : dir);
|
||||||
|
log('apps', removed ? `removed ${tilde(removed.path)} from the list (the folder and its files stay)` : `${tilde(dir)} is not in the list`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(args: Args): Promise<void> {
|
export async function run(args: Args): Promise<void> {
|
||||||
const sub = args._[0];
|
const sub = args._[0];
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const json = args.has('json');
|
const json = args.has('json');
|
||||||
|
if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args);
|
||||||
if (sub === 'list') {
|
if (sub === 'list') {
|
||||||
const out: AppsListResult[] = [];
|
const out: AppsListResult[] = [];
|
||||||
for (const s of stores(args, root)) {
|
for (const s of stores(args, root)) {
|
||||||
|
|
@ -148,6 +181,6 @@ export async function run(args: Args): Promise<void> {
|
||||||
if (out.some(c => c.status !== 'ok')) process.exitCode = 1;
|
if (out.some(c => c.status !== 'ok')) process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json]');
|
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps remove <folder>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
||||||
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
|
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
|
||||||
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
||||||
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
|
||||||
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only'],
|
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
|
||||||
'desktop': [() => import('./desktop'), 'open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, --download saves it to ~/Downloads after checking its signature (never installs)'],
|
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
|
||||||
|
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile>'],
|
||||||
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
|
||||||
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
|
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
|
||||||
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],
|
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
"yes"
|
"yes"
|
||||||
],
|
],
|
||||||
"commands": {
|
"commands": {
|
||||||
|
"accounts": [
|
||||||
|
"dir",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
"ads": [
|
"ads": [
|
||||||
"ios",
|
"ios",
|
||||||
"plist"
|
"plist"
|
||||||
|
|
@ -376,8 +380,11 @@
|
||||||
"channel",
|
"channel",
|
||||||
"dir",
|
"dir",
|
||||||
"download",
|
"download",
|
||||||
|
"force",
|
||||||
"format",
|
"format",
|
||||||
"no-folder"
|
"no-folder",
|
||||||
|
"update-desktop-database",
|
||||||
|
"xdg-mime"
|
||||||
],
|
],
|
||||||
"doctor": [
|
"doctor": [
|
||||||
"dir"
|
"dir"
|
||||||
|
|
@ -928,6 +935,7 @@
|
||||||
"undo",
|
"undo",
|
||||||
"unity",
|
"unity",
|
||||||
"update-bigmac",
|
"update-bigmac",
|
||||||
|
"update-desktop-database",
|
||||||
"upload",
|
"upload",
|
||||||
"url",
|
"url",
|
||||||
"use-chrome-session",
|
"use-chrome-session",
|
||||||
|
|
@ -944,10 +952,16 @@
|
||||||
"with-automatic",
|
"with-automatic",
|
||||||
"with-version",
|
"with-version",
|
||||||
"workspace",
|
"workspace",
|
||||||
"write"
|
"write",
|
||||||
|
"xdg-mime"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
|
"accounts": [
|
||||||
|
"storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which",
|
||||||
|
"storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]",
|
||||||
|
"storebound accounts use <profile> [--dir <project>] this project uses that profile (\"account\" in its"
|
||||||
|
],
|
||||||
"ads": [
|
"ads": [
|
||||||
"storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]"
|
"storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]"
|
||||||
],
|
],
|
||||||
|
|
@ -1085,7 +1099,9 @@
|
||||||
],
|
],
|
||||||
"apps": [
|
"apps": [
|
||||||
"storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team /",
|
"storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team /",
|
||||||
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix"
|
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix",
|
||||||
|
"storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,",
|
||||||
|
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)"
|
||||||
],
|
],
|
||||||
"aso": [
|
"aso": [
|
||||||
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
|
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
|
||||||
|
|
@ -1116,9 +1132,9 @@
|
||||||
"storebound console verify play-app-record --url <url after Create app> [--write]"
|
"storebound console verify play-app-record --url <url after Create app> [--write]"
|
||||||
],
|
],
|
||||||
"desktop": [
|
"desktop": [
|
||||||
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
|
"storebound desktop the app has this CLI's version: open it (in an app folder: on that folder, which",
|
||||||
"storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app",
|
"storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app",
|
||||||
"storebound desktop --download not installed: download the installer for this system into ~/Downloads,"
|
"storebound desktop --download only save the installer for this system into ~/Downloads (checked as above)"
|
||||||
],
|
],
|
||||||
"doctor": [
|
"doctor": [
|
||||||
"storebound doctor [--dir .] [--json]"
|
"storebound doctor [--dir .] [--json]"
|
||||||
|
|
|
||||||
250
cli/desktop.ts
250
cli/desktop.ts
|
|
@ -1,31 +1,53 @@
|
||||||
// `storebound desktop`: the bridge from the terminal to the desktop app. The app is a separate signed download from
|
// `storebound desktop`: the CLI and the desktop app are one product with one version number. This command makes the
|
||||||
// storebound.dev (it updates itself and brings its own copy of this CLI); the npm package never contains it.
|
// app on this machine that version and opens it on the current folder.
|
||||||
// storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that
|
// storebound desktop the app has this CLI's version: open it (in an app folder: on that folder, which
|
||||||
// folder (storebound://open?path=..., the app asks before adding)
|
// also joins this machine's app folder list, ~/.storebound/projects.json)
|
||||||
|
// not installed: download it for this system, check its size, SHA-256 and minisign
|
||||||
|
// signature, install it for this user (no admin rights), open it. Asks once in a
|
||||||
|
// terminal ([Y/n]); --yes skips the question; without a terminal and --yes it prints
|
||||||
|
// the command and exits 3
|
||||||
|
// an older app: the same download and install as an update; a newer app: opened as
|
||||||
|
// it is, with a note (a downgrade only with --force)
|
||||||
// storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app
|
// storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app
|
||||||
// storebound desktop --download not installed: download the installer for this system into ~/Downloads,
|
// storebound desktop --download only save the installer for this system into ~/Downloads (checked as above)
|
||||||
// check its SHA-256 and signature, and print where it is. It never installs.
|
// --dry (print the plan, change nothing), --json (the decision and plan as JSON, change nothing), --channel beta,
|
||||||
// --channel beta, --format deb (Linux), --json (print what it would do, change nothing)
|
// --format deb (Linux, download only), --force (allow a downgrade), --yes
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { homedir, tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
import pkg from '../package.json' with { type: 'json' };
|
||||||
import type { Args } from './lib/args';
|
import type { Args } from './lib/args';
|
||||||
import { CONFIG_NAMES } from './lib/config';
|
import { CONFIG_NAMES } from './lib/config';
|
||||||
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY } from './lib/desktop-dist';
|
import { DECLINED_EXIT } from './lib/ctx';
|
||||||
|
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, type Installed, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY, versionManifest } from './lib/desktop-dist';
|
||||||
|
import { consent, type Decision, decide, installPlan, isRunning, type Plan, question, runningCheck } from './lib/desktop-install';
|
||||||
import { verify } from './lib/minisign';
|
import { verify } from './lib/minisign';
|
||||||
|
import { parsePlist } from './lib/plist';
|
||||||
|
import { addFolder } from './lib/registry';
|
||||||
import * as rt from './lib/rt';
|
import * as rt from './lib/rt';
|
||||||
|
|
||||||
type Fetch = (url: string) => Promise<Response>;
|
type Fetch = (url: string) => Promise<Response>;
|
||||||
|
|
||||||
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
|
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch, url = MANIFEST[channel]): Promise<Manifest> {
|
||||||
const r = await get(MANIFEST[channel]);
|
const r = await get(url);
|
||||||
if (!r.ok) throw new Error(`${MANIFEST[channel]}: HTTP ${r.status}`);
|
if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`);
|
||||||
const m = await r.json() as unknown;
|
const m = await r.json() as unknown;
|
||||||
const problems = manifestProblems(m);
|
const problems = manifestProblems(m);
|
||||||
if (problems.length) throw new Error(`latest.json: ${problems.join('; ')}`);
|
if (problems.length) throw new Error(`latest.json: ${problems.join('; ')}`);
|
||||||
return m as Manifest;
|
return m as Manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The release for this CLI: its own version's manifest (storebound.dev/desktop/dl/<version>/latest.json) when that is
|
||||||
|
* published, else the channel's newest. */
|
||||||
|
export async function targetManifest(cli: string, channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
|
||||||
|
try {
|
||||||
|
const own = await fetchManifest(channel, get, versionManifest(cli));
|
||||||
|
if (own.version === cli) return own;
|
||||||
|
} catch { /* not published (yet): the channel's newest */ }
|
||||||
|
return fetchManifest(channel, get);
|
||||||
|
}
|
||||||
|
|
||||||
/** Picks the installer for this system: the first of downloadsFor() the release has (or the asked format). */
|
/** Picks the installer for this system: the first of downloadsFor() the release has (or the asked format). */
|
||||||
export function pick(m: Manifest, keys: DownloadKey[], format?: string): [DownloadKey, Download] | null {
|
export function pick(m: Manifest, keys: DownloadKey[], format?: string): [DownloadKey, Download] | null {
|
||||||
const want = format ? keys.filter(k => k.endsWith(`-${format}`)) : keys;
|
const want = format ? keys.filter(k => k.endsWith(`-${format}`)) : keys;
|
||||||
|
|
@ -51,58 +73,180 @@ export async function download(d: Download, dir: string, get: Fetch = fetch, pub
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VERSION_RE = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/;
|
||||||
|
|
||||||
|
/** The version of an installed app: Info.plist on macOS, `storebound-desktop --version` elsewhere (15 s at most). */
|
||||||
|
export function appVersion(platform: NodeJS.Platform, app: string): string | null {
|
||||||
|
try {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
const p = parsePlist(readFileSync(join(app, 'Contents', 'Info.plist'))) as Record<string, unknown>;
|
||||||
|
return typeof p.CFBundleShortVersionString === 'string' ? p.CFBundleShortVersionString : null;
|
||||||
|
}
|
||||||
|
const r = rt.spawnSync([app, '--version'], { timeout: 15_000 });
|
||||||
|
return r.success ? VERSION_RE.exec(r.stdout.toString())?.[1] ?? null : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a plan step by step; `always` steps (unmount) run after a failure too. Every command has a timeout. */
|
||||||
|
export function runPlan(plan: Plan, platform: NodeJS.Platform, say: (s: string) => void): void {
|
||||||
|
let failed: Error | null = null;
|
||||||
|
for (const s of plan.steps) {
|
||||||
|
const always = 'run' in s && s.always;
|
||||||
|
if (failed && !always) continue;
|
||||||
|
try {
|
||||||
|
if ('run' in s) {
|
||||||
|
const r = rt.spawnSync(s.run, { timeout: s.timeoutMs });
|
||||||
|
if (r.signalCode) throw new Error(`${s.what}: no answer within ${s.timeoutMs / 1000} s (${s.run[0]})`);
|
||||||
|
if (!r.success) {
|
||||||
|
const why = `${s.what}: ${s.run[0]} exited ${r.exitCode}: ${r.stderr.toString().trim().split('\n').slice(-3).join(' / ')}`;
|
||||||
|
if (s.optional || always) { say(` (skipped: ${why})`); continue; }
|
||||||
|
throw new Error(why);
|
||||||
|
}
|
||||||
|
} else if ('mkdir' in s) mkdirSync(s.mkdir, { recursive: true });
|
||||||
|
else if ('copy' in s) { copyFileSync(s.copy.from, s.copy.to); if (s.copy.mode) chmodSync(s.copy.to, s.copy.mode); }
|
||||||
|
else if ('rename' in s) { rmSync(s.rename.to, { recursive: true, force: true }); renameSync(s.rename.from, s.rename.to); }
|
||||||
|
else if ('remove' in s) rmSync(s.remove, { recursive: true, force: true });
|
||||||
|
else if ('write' in s) { writeFileSync(s.write.path, s.write.content); if (s.write.mode) chmodSync(s.write.path, s.write.mode); }
|
||||||
|
else if ('expectVersion' in s) {
|
||||||
|
const v = appVersion(platform, s.expectVersion.app);
|
||||||
|
if (v && v !== s.expectVersion.version) throw new Error(`the installed app is ${v}, expected ${s.expectVersion.version}`);
|
||||||
|
if (!v) say(` (could not read the new app's version; going on)`);
|
||||||
|
}
|
||||||
|
if (!always) say(` ${s.what}`);
|
||||||
|
} catch (e) { if (!always) failed = e as Error; }
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
// a staged copy that never made it into place is removed; the old app stays where it was
|
||||||
|
for (const s of plan.steps) if ('rename' in s && s.rename.from.includes('.new') && existsSync(s.rename.from)) rmSync(s.rename.from, { recursive: true, force: true });
|
||||||
|
throw failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canWrite(dir: string): boolean {
|
||||||
|
try { const probe = join(dir, `.storebound-probe-${process.pid}`); writeFileSync(probe, ''); rmSync(probe); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askYes(text: string): Promise<boolean> {
|
||||||
|
process.stdout.write(text);
|
||||||
|
for await (const line of console) return !/^n(o)?$/i.test(line.trim());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tilde = (p: string) => (p.startsWith(homedir()) ? '~' + p.slice(homedir().length) : p);
|
||||||
|
|
||||||
export async function run(args: Args): Promise<void> {
|
export async function run(args: Args): Promise<void> {
|
||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
|
const cli = pkg.version;
|
||||||
const channel = args.get('channel') === 'beta' ? 'beta' : 'stable';
|
const channel = args.get('channel') === 'beta' ? 'beta' : 'stable';
|
||||||
const dir = resolve(args.str('dir', '.'));
|
const dir = resolve(args.str('dir', '.'));
|
||||||
const isApp = !args.has('no-folder') && CONFIG_NAMES.some(n => existsSync(join(dir, n)));
|
const isApp = !args.has('no-folder') && CONFIG_NAMES.some(n => existsSync(join(dir, n)));
|
||||||
const installed = findInstalled(platform);
|
const link = isApp ? openFolderLink(dir) : null;
|
||||||
|
const json = args.has('json'), dry = args.has('dry') || json;
|
||||||
if (installed && !args.has('download')) {
|
let installed: Installed | null = findInstalled(platform);
|
||||||
const link = isApp ? openFolderLink(dir) : null;
|
const installedVersion = installed ? appVersion(platform, installed.path) : null;
|
||||||
const cmd = openCommand(platform, installed, link);
|
|
||||||
if (args.has('json')) { console.log(JSON.stringify({ installed, link, command: cmd }, null, 2)); return; }
|
|
||||||
const p = rt.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
const code = await p.exited;
|
|
||||||
if (code !== 0) throw new Error(`open -b ${BUNDLE_ID} failed: ${(await new Response(p.stderr).text()).trim()}`);
|
|
||||||
} else p.unref();
|
|
||||||
console.log(link ? `storebound desktop: opening ${dir} (the app asks before it adds a new folder)` : 'storebound desktop: opened');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = downloadsFor(platform, process.arch);
|
const keys = downloadsFor(platform, process.arch);
|
||||||
|
|
||||||
|
// the release: only when something has to be downloaded (opening the right version needs no network)
|
||||||
let manifest: Manifest | null = null;
|
let manifest: Manifest | null = null;
|
||||||
let error = '';
|
let error = '';
|
||||||
try { manifest = await fetchManifest(channel); } catch (e) { error = (e as Error).message; }
|
if (args.has('download') || !installed || installedVersion !== cli) {
|
||||||
const chosen = manifest ? pick(manifest, keys, args.get('format')) : null;
|
if (!keys.length) error = `there is no desktop build for ${platform} ${process.arch} yet`;
|
||||||
|
else try { manifest = await targetManifest(cli, channel); } catch (e) { error = /HTTP 404/.test((e as Error).message) ? 'no desktop release is published yet' : `could not read the release list: ${(e as Error).message}`; }
|
||||||
|
}
|
||||||
|
const chosen = manifest ? pick(manifest, keys, args.get('format') ?? (args.has('download') ? undefined : platform === 'linux' ? 'appimage' : undefined)) : null;
|
||||||
|
if (manifest && !chosen && !error) error = `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet`;
|
||||||
|
|
||||||
if (args.has('json')) {
|
if (args.has('download')) {
|
||||||
console.log(JSON.stringify({ installed, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2));
|
if (json) { console.log(JSON.stringify({ cli, installed, installedVersion, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2)); return; }
|
||||||
|
if (!chosen) throw new Error(`${error}; see ${PAGE}`);
|
||||||
|
const [kind, d] = chosen;
|
||||||
|
if (dry) { console.log(`dry run: would download ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]}) into ${tilde(downloadsDir())} and check it`); return; }
|
||||||
|
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]})`);
|
||||||
|
const target = downloadsDir();
|
||||||
|
let out: string;
|
||||||
|
try { out = await download(d, target); } catch (e) { rmSync(join(target, `${d.file}.part`), { force: true }); throw e; }
|
||||||
|
console.log(`saved ${out}\n size, SHA-256 and signature checked (key of storebound desktop). Not installed (storebound desktop without --download installs it).`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!args.has('download')) {
|
|
||||||
console.log(installed ? 'storebound desktop is installed.' : 'storebound desktop is not installed on this machine.');
|
const decision: Decision = decide({ installed, installedVersion, cli, available: chosen ? manifest!.version : null, unavailableReason: error || undefined, force: args.has('force') });
|
||||||
if (chosen) {
|
const changing = decision.action === 'install' || decision.action === 'update' || decision.action === 'downgrade';
|
||||||
console.log(`\n ${DOWNLOAD_LABEL[chosen[0]]}, version ${manifest!.version}${chosen[1].codeSigned ? '' : ' (unsigned preview)'}:\n ${chosen[1].url}\n SHA-256 ${chosen[1].sha256}`);
|
const tmp = changing && !dry ? mkdtempSync(join(tmpdir(), 'storebound-desktop-')) : join(tmpdir(), 'storebound-desktop-<temp>');
|
||||||
} else if (!keys.length) {
|
const plan = changing && chosen ? installPlan({
|
||||||
console.log(`\n There is no desktop build for ${platform} ${process.arch} yet.`);
|
platform, kind: chosen[0], file: join(tmp, chosen[1].file), version: manifest!.version, home: homedir(), existing: installed,
|
||||||
} else if (error) {
|
systemAppsWritable: platform === 'darwin' && canWrite('/Applications'), tmp, env: process.env, has: t => !!rt.which(t),
|
||||||
console.log(/HTTP 404/.test(error) ? '\n No desktop release is published yet.' : `\n (could not read the release list: ${error})`);
|
}) : null;
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
cli, installed, installedVersion, decision, link, folder: isApp ? dir : null,
|
||||||
|
download: chosen && changing ? { kind: chosen[0], ...chosen[1] } : null,
|
||||||
|
plan: plan ? { target: plan.target, place: plan.place, manual: plan.manual ?? null, steps: plan.steps.map(s => s.what) } : null,
|
||||||
|
command: installed && !changing ? openCommand(platform, installed, link) : null,
|
||||||
|
}, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const n of decision.notes) console.log(`storebound desktop: ${n}`);
|
||||||
|
if (decision.action === 'unavailable') {
|
||||||
|
console.log(`storebound desktop is not installed on this machine, and it cannot be installed from here: ${error || decision.notes[0]}.\n Every download and its checksum: ${PAGE}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (changing) {
|
||||||
|
const [, d] = chosen!;
|
||||||
|
if (plan!.manual) { console.log(`storebound desktop ${decision.to}: ${plan!.manual}`); process.exitCode = 1; return; }
|
||||||
|
if (dry) {
|
||||||
|
console.log(`dry run: ${decision.action} storebound desktop ${decision.from ? `${decision.from} -> ` : ''}${decision.to} (${Math.round(d.size / 1e6)} MB) in ${plan!.place}, then open it${link ? ` on ${tilde(dir)}` : ''}:`);
|
||||||
|
console.log(` download ${d.file}, check size, SHA-256 and signature`);
|
||||||
|
for (const s of plan!.steps) console.log(` ${s.what}`);
|
||||||
|
if (link) console.log(` add ${tilde(dir)} to this machine's app folders`);
|
||||||
|
console.log('nothing changed.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
console.log(`\n Every download and its checksum: ${PAGE}`);
|
if (installed) {
|
||||||
if (chosen) console.log(' Or: storebound desktop --download (saves the installer in ~/Downloads after checking its signature; it does not install it)');
|
const chk = runningCheck(platform);
|
||||||
return;
|
let running = false;
|
||||||
|
try { const r = rt.spawnSync(chk, { timeout: 10_000 }); running = isRunning(platform, r.exitCode, r.stdout.toString()); } catch { /* no pgrep / tasklist: go on */ }
|
||||||
|
if (running) throw new Error(`storebound desktop ${decision.from} is running: quit it first, then run this again (or update it from the app: Settings > Updates)`);
|
||||||
|
}
|
||||||
|
const how = consent(args.has('yes'), !!process.stdin.isTTY);
|
||||||
|
if (how !== 'yes') {
|
||||||
|
if (how === 'declined') {
|
||||||
|
const again = ['storebound', 'desktop', ...process.argv.slice(3).filter(a => a !== '--yes'), '--yes'].join(' ');
|
||||||
|
console.log(`${question(decision, d.size, plan!.place).replace(/ \[Y\/n\] $/, '')}\n not a terminal: run \`${again}\` to confirm`);
|
||||||
|
console.error('declined: not a terminal and no --yes');
|
||||||
|
process.exitCode = DECLINED_EXIT;
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await askYes(question(decision, d.size, plan!.place)))) {
|
||||||
|
console.error('declined: the owner answered no');
|
||||||
|
process.exitCode = DECLINED_EXIT;
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB)`);
|
||||||
|
await download(d, tmp);
|
||||||
|
console.log(' size, SHA-256 and signature checked');
|
||||||
|
runPlan(plan!, platform, s => console.log(s));
|
||||||
|
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
||||||
|
installed = findInstalled(platform) ?? { path: plan!.target, how: platform === 'darwin' ? 'bundle' : platform === 'win32' ? 'exe' : 'appimage' };
|
||||||
|
console.log(`storebound desktop ${decision.to} ${decision.action === 'install' ? 'installed' : 'updated'}: ${tilde(plan!.target)}`);
|
||||||
}
|
}
|
||||||
if (!manifest) throw new Error(`could not read the release list: ${error}`);
|
|
||||||
if (!chosen) throw new Error(keys.length ? `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet; see ${PAGE}` : `no desktop build for ${platform} ${process.arch}; see ${PAGE}`);
|
// open (the right version is there now)
|
||||||
const [kind, d] = chosen;
|
if (dry) { console.log(`dry run: open storebound desktop ${installedVersion ?? ''}${link ? ` on ${tilde(dir)}` : ''}; nothing changed`); return; }
|
||||||
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]})`);
|
if (isApp) {
|
||||||
const target = downloadsDir();
|
try { const r = addFolder(dir); if (r.added) console.log(`storebound desktop: ${tilde(dir)} added to this machine's app folders`); } catch (e) { console.log(`storebound desktop: the app folder list was not updated (${(e as Error).message})`); }
|
||||||
let out: string;
|
}
|
||||||
try { out = await download(d, target); } catch (e) { rmSync(join(target, `${d.file}.part`), { force: true }); throw e; }
|
const fresh = changing && platform === 'darwin';
|
||||||
console.log(`saved ${out}\n SHA-256 and signature checked (key of storebound desktop). Not installed: the owner opens it to install it on this machine.`);
|
const cmd = fresh ? ['open', '-a', installed!.path, ...(link ? [link] : [])] : openCommand(platform, installed!, link);
|
||||||
if (!d.codeSigned) console.log(platform === 'darwin'
|
const p = rt.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
? ' This preview is not signed by Apple yet: after copying storebound to Applications, open it once, then System Settings > Privacy & Security > Open Anyway.'
|
if (platform === 'darwin') {
|
||||||
: platform === 'win32' ? ' This preview is not code signed yet: Windows SmartScreen may ask; choose More info > Run anyway.' : '');
|
const code = await Promise.race([p.exited, rt.sleep(30_000).then(() => -1)]);
|
||||||
|
if (code !== 0) throw new Error(`${fresh ? 'open -a' : `open -b ${BUNDLE_ID}`} failed${code === -1 ? ' (no answer in 30 s)' : `: ${(await new Response(p.stderr).text()).trim()}`}`);
|
||||||
|
} else p.unref();
|
||||||
|
console.log(link ? `storebound desktop: opening ${tilde(dir)}` : 'storebound desktop: opened');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
207
cli/lib/accounts.ts
Normal file
207
cli/lib/accounts.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
// The machine's store accounts: credential profiles in ~/.storebound/credentials (cli/lib/credentials.ts), shared by
|
||||||
|
// every project, the CLI and the desktop app. This module reads what a profile can reach (without values) and builds
|
||||||
|
// a profile from a project's .storebound/credentials.json (`storebound accounts import`): the store-account part
|
||||||
|
// (Apple team key, Play service account, vendor number) moves to the profile, the key files are copied into
|
||||||
|
// <credentials>/<name>/ (0600, folder 0700), and the project file keeps everything that belongs to the app (upload
|
||||||
|
// keystore, review contact, demo account) plus "account": "<name>". Same key id or service account e-mail as an
|
||||||
|
// existing profile = that profile (run it on every project; one account, one profile). Values are never printed.
|
||||||
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { isAbsolute, join, resolve } from 'node:path';
|
||||||
|
import { maskUser, PROFILE_KEYS, PROFILE_RE, profileDir, profileEnv, profileNames } from './credentials';
|
||||||
|
|
||||||
|
export type Kind = 'apple' | 'play';
|
||||||
|
/** The store keys that move to a profile (one developer account's), never the app's own ones. */
|
||||||
|
export const STORE_KEYS = PROFILE_KEYS;
|
||||||
|
|
||||||
|
export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean }
|
||||||
|
|
||||||
|
const PEM = /BEGIN PRIVATE KEY/;
|
||||||
|
const isJsonText = (v: string) => /^\s*\{/.test(v);
|
||||||
|
const expand = (p: string, base: string) => { const x = p.replace(/^~(?=\/|\\)/, homedir()); return isAbsolute(x) ? x : resolve(base, x); };
|
||||||
|
|
||||||
|
/** The client_email of a service account (inline JSON or a file); null when unreadable. */
|
||||||
|
export function serviceAccountEmail(v: string, base: string): string | null {
|
||||||
|
try {
|
||||||
|
const text = isJsonText(v) ? v : readFileSync(expand(v, base), 'utf8');
|
||||||
|
const e = (JSON.parse(text) as { client_email?: unknown }).client_email;
|
||||||
|
return typeof e === 'string' && e ? e : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a profile can reach: the Apple key id and the Play service account e-mail (for matching, never printed). */
|
||||||
|
export function profileIdentity(name: string): Identity {
|
||||||
|
const env = profileEnv(name);
|
||||||
|
return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */
|
||||||
|
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; error?: string }[] {
|
||||||
|
return profileNames().map(profile => {
|
||||||
|
try {
|
||||||
|
const id = profileIdentity(profile);
|
||||||
|
return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor };
|
||||||
|
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileWrite { path: string; what: string; action: 'create' | 'update' | 'same'; mode: number; content?: string; copyFrom?: string }
|
||||||
|
export interface ImportPlan {
|
||||||
|
project: string;
|
||||||
|
projectFile: string;
|
||||||
|
profile: string;
|
||||||
|
/** new profile, or the existing one with the same Apple key id / Play service account */
|
||||||
|
reuse: boolean;
|
||||||
|
stores: Kind[];
|
||||||
|
writes: FileWrite[];
|
||||||
|
/** keys removed from the project file (moved to the profile) */
|
||||||
|
moved: string[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Json = Record<string, unknown>;
|
||||||
|
const readJson = (p: string): Json => JSON.parse(readFileSync(p, 'utf8')) as Json;
|
||||||
|
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 64);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan of `accounts import` for one project (reads only). Throws with the reason when there is nothing to move,
|
||||||
|
* when the project's Apple key and Play account belong to two different profiles, or when --name names a profile that
|
||||||
|
* already holds another account.
|
||||||
|
*/
|
||||||
|
export function planImport(project: string, o: { name?: string } = {}): ImportPlan {
|
||||||
|
const projectFile = join(project, '.storebound', 'credentials.json');
|
||||||
|
if (!existsSync(projectFile)) throw new Error(`${projectFile}: not found (nothing to import)`);
|
||||||
|
const file = readJson(projectFile);
|
||||||
|
const str = (k: string) => (typeof file[k] === 'string' && (file[k] as string).trim() ? (file[k] as string).trim() : undefined);
|
||||||
|
const notes: string[] = [];
|
||||||
|
const keyId = str('ASC_KEY_ID'), issuer = str('ASC_ISSUER_ID');
|
||||||
|
let p8 = str('ASC_P8');
|
||||||
|
if (keyId && !p8) {
|
||||||
|
const xcode = join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${keyId}.p8`);
|
||||||
|
if (existsSync(xcode)) { p8 = xcode; notes.push('ASC_P8 from ~/.appstoreconnect/private_keys (copied into the profile)'); }
|
||||||
|
}
|
||||||
|
const sa = str('PLAY_SERVICE_ACCOUNT');
|
||||||
|
const playEmail = sa ? serviceAccountEmail(sa, project) : null;
|
||||||
|
if (sa && !playEmail) throw new Error('PLAY_SERVICE_ACCOUNT in the project file is not a readable service account key (path relative to the project, or the JSON)');
|
||||||
|
if (p8 && !PEM.test(p8) && !existsSync(expand(p8, project))) throw new Error('ASC_P8 in the project file points to a file that does not exist');
|
||||||
|
const apple = !!(keyId && issuer);
|
||||||
|
if ((keyId || issuer) && !apple) throw new Error('the project file has only half an App Store Connect key (ASC_KEY_ID and ASC_ISSUER_ID go together)');
|
||||||
|
if (!apple && !sa && typeof file.account === 'string' && profileNames().includes(file.account)) {
|
||||||
|
return { project, projectFile, profile: file.account, reuse: true, stores: [], writes: [], moved: [], notes: [`already imported: the project uses profile "${file.account}"`] };
|
||||||
|
}
|
||||||
|
if (!apple && !sa) throw new Error('the project file holds no store account (ASC_KEY_ID + ASC_ISSUER_ID, PLAY_SERVICE_ACCOUNT): nothing to import');
|
||||||
|
if (o.name !== undefined && !PROFILE_RE.test(o.name)) throw new Error(`--name ${o.name}: letters, digits, . _ - (max 64)`);
|
||||||
|
|
||||||
|
// the profile: the existing one with this account, else --name, else "default" (first profile) or a free name
|
||||||
|
const ids = new Map(profileNames().map(n => [n, (() => { try { return profileIdentity(n); } catch { return null; } })()] as const));
|
||||||
|
const hits = [...ids].filter(([, id]) => id && ((apple && id.appleKeyId === keyId) || (playEmail && id.playEmail === playEmail))).map(([n]) => n);
|
||||||
|
if (hits.length > 1) throw new Error(`the Apple key and the Play account of this project are in two profiles (${hits.join(', ')}): import with --name <one of them> after merging them, or keep them apart`);
|
||||||
|
let profile: string;
|
||||||
|
let reuse = false;
|
||||||
|
if (hits.length === 1) {
|
||||||
|
profile = hits[0]!;
|
||||||
|
reuse = true;
|
||||||
|
if (o.name && o.name !== profile) notes.push(`this account is already in profile "${profile}": --name ${o.name} ignored`);
|
||||||
|
} else if (o.name) {
|
||||||
|
profile = o.name;
|
||||||
|
reuse = ids.has(profile);
|
||||||
|
} else if (!ids.size) profile = 'default';
|
||||||
|
else {
|
||||||
|
const base = slug(project.split(/[\\/]/).filter(Boolean).pop() ?? 'account') || 'account';
|
||||||
|
profile = base;
|
||||||
|
for (let i = 2; ids.has(profile); i++) profile = `${base}-${i}`;
|
||||||
|
notes.push(`a second store account on this machine: profile "${profile}" (pick another with --name)`);
|
||||||
|
}
|
||||||
|
const current: Json = ids.has(profile) ? readJson(join(profileDir(), `${profile}.json`)) : {};
|
||||||
|
const have = ids.get(profile) ?? null;
|
||||||
|
if (have && apple && have.appleKeyId && have.appleKeyId !== keyId) throw new Error(`profile "${profile}" already has another App Store Connect key: use --name <new profile>`);
|
||||||
|
if (have && playEmail && have.playEmail && have.playEmail !== playEmail) throw new Error(`profile "${profile}" already has another Play service account: use --name <new profile>`);
|
||||||
|
|
||||||
|
const dir = profileDir();
|
||||||
|
const writes: FileWrite[] = [];
|
||||||
|
const next: Json = { ...current };
|
||||||
|
const keyFile = (rel: string, what: string, v: string, base: string) => {
|
||||||
|
const path = join(dir, rel);
|
||||||
|
const content = PEM.test(v) || isJsonText(v) ? (PEM.test(v) ? v.trim() + '\n' : v) : undefined;
|
||||||
|
const src = content === undefined ? expand(v, base) : undefined;
|
||||||
|
const bytes = content ?? readFileSync(src!, 'utf8');
|
||||||
|
const action = !existsSync(path) ? 'create' : readFileSync(path, 'utf8') === bytes ? 'same' : 'update';
|
||||||
|
writes.push({ path, what, action, mode: 0o600, ...(content !== undefined ? { content } : { copyFrom: src }) });
|
||||||
|
return rel.split('\\').join('/');
|
||||||
|
};
|
||||||
|
const moved: string[] = [];
|
||||||
|
const stores: Kind[] = [];
|
||||||
|
if (apple) {
|
||||||
|
stores.push('apple');
|
||||||
|
// a profile that already has this key keeps its own key file
|
||||||
|
if (!(have?.appleKeyId === keyId && typeof current.ASC_P8 === 'string')) {
|
||||||
|
if (p8) next.ASC_P8 = keyFile(join(profile, 'app-store-connect.p8'), 'App Store Connect key (.p8)', p8, project);
|
||||||
|
}
|
||||||
|
next.ASC_KEY_ID = keyId; next.ASC_ISSUER_ID = issuer;
|
||||||
|
moved.push('ASC_KEY_ID', 'ASC_ISSUER_ID', ...(str('ASC_P8') ? ['ASC_P8'] : []));
|
||||||
|
}
|
||||||
|
if (sa) {
|
||||||
|
stores.push('play');
|
||||||
|
if (!(have?.playEmail === playEmail && typeof current.PLAY_SERVICE_ACCOUNT === 'string')) next.PLAY_SERVICE_ACCOUNT = keyFile(join(profile, 'play-service-account.json'), 'Play service account key (JSON)', sa, project);
|
||||||
|
moved.push('PLAY_SERVICE_ACCOUNT');
|
||||||
|
}
|
||||||
|
const vendor = str('ASC_VENDOR_NUMBER');
|
||||||
|
if (vendor) {
|
||||||
|
if (typeof current.ASC_VENDOR_NUMBER === 'string' && current.ASC_VENDOR_NUMBER !== vendor) notes.push('ASC_VENDOR_NUMBER: the profile keeps its own; the project value stays in the project');
|
||||||
|
else { next.ASC_VENDOR_NUMBER = vendor; moved.push('ASC_VENDOR_NUMBER'); }
|
||||||
|
}
|
||||||
|
const profileText = JSON.stringify(next, null, 2) + '\n';
|
||||||
|
const profilePath = join(dir, `${profile}.json`);
|
||||||
|
writes.push({ path: profilePath, what: `profile ${profile}`, action: !existsSync(profilePath) ? 'create' : readFileSync(profilePath, 'utf8') === profileText ? 'same' : 'update', mode: 0o600, content: profileText });
|
||||||
|
|
||||||
|
// the project file: store keys out, "account" in; everything else (keystore, review contact, demo account) stays
|
||||||
|
const proj: Json = {};
|
||||||
|
for (const [k, v] of Object.entries(file)) if (!moved.includes(k)) proj[k] = v;
|
||||||
|
proj.account = profile;
|
||||||
|
const projText = JSON.stringify(proj, null, 2) + '\n';
|
||||||
|
writes.push({ path: projectFile, what: 'project credentials (store keys removed, "account" set; a backup is kept)', action: readFileSync(projectFile, 'utf8') === projText ? 'same' : 'update', mode: 0o600, content: projText });
|
||||||
|
return { project, projectFile, profile, reuse, stores, writes, moved, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const atomic = (path: string, data: string | Buffer, mode: number) => {
|
||||||
|
const tmp = `${path}.tmp-${process.pid}`;
|
||||||
|
writeFileSync(tmp, data, { mode });
|
||||||
|
chmodSync(tmp, mode);
|
||||||
|
renameSync(tmp, path);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs a plan: key files and the profile first (0600, folders 0700), then reads the profile back, then the project
|
||||||
|
* file (its old version kept as credentials.json.bak-<time>, 0600). Safe to run again. */
|
||||||
|
export function applyImport(plan: ImportPlan, now = new Date()): { backup: string | null } {
|
||||||
|
const dir = profileDir();
|
||||||
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
try { chmodSync(dir, 0o700); } catch { /* not ours to change (Windows) */ }
|
||||||
|
let backup: string | null = null;
|
||||||
|
for (const w of plan.writes) {
|
||||||
|
if (w.action === 'same') continue;
|
||||||
|
if (w.path === plan.projectFile) {
|
||||||
|
// the profile must answer before the project forgets its keys
|
||||||
|
const id = profileIdentity(plan.profile);
|
||||||
|
if ((plan.stores.includes('apple') && !id.appleKeyId) || (plan.stores.includes('play') && !id.playEmail)) throw new Error(`profile ${plan.profile} does not read back: the project file was not changed`);
|
||||||
|
backup = `${plan.projectFile}.bak-${now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
|
||||||
|
copyFileSync(plan.projectFile, backup);
|
||||||
|
chmodSync(backup, 0o600);
|
||||||
|
} else {
|
||||||
|
const parent = resolve(w.path, '..');
|
||||||
|
if (parent !== dir) { mkdirSync(parent, { recursive: true, mode: 0o700 }); try { chmodSync(parent, 0o700); } catch { /* Windows */ } }
|
||||||
|
}
|
||||||
|
atomic(w.path, w.copyFrom ? readFileSync(w.copyFrom) : w.content!, w.mode);
|
||||||
|
}
|
||||||
|
return { backup };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Points a project at a profile ("account" in its .storebound/credentials.json; the file is created when missing). */
|
||||||
|
export function useProfile(project: string, name: string): string {
|
||||||
|
if (!PROFILE_RE.test(name)) throw new Error(`${name}: letters, digits, . _ - (max 64)`);
|
||||||
|
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" in ${profileDir()}: \`storebound accounts\` lists them`);
|
||||||
|
const file = join(project, '.storebound', 'credentials.json');
|
||||||
|
const cur: Json = existsSync(file) ? readJson(file) : {};
|
||||||
|
mkdirSync(join(project, '.storebound'), { recursive: true });
|
||||||
|
atomic(file, JSON.stringify({ ...cur, account: name }, null, 2) + '\n', 0o600);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||||
import * as rt from './rt';
|
import * as rt from './rt';
|
||||||
|
import { PROFILE_RE, setConfigAccount } from './credentials';
|
||||||
import { validateCapture, validateRecord } from '../shots/devices';
|
import { validateCapture, validateRecord } from '../shots/devices';
|
||||||
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
|
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
|
||||||
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
|
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
|
||||||
|
|
@ -518,6 +519,10 @@ export interface ShipConfig {
|
||||||
website: string;
|
website: string;
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
};
|
};
|
||||||
|
/** The credential profile (~/.storebound/credentials/<name>.json: the Apple team key and Play service account,
|
||||||
|
* shared by every project and the desktop app) this app's store commands use. Default: the only profile on this
|
||||||
|
* machine, or the one named "default". `--account <name>` overrides it for one run. A name, never a secret. */
|
||||||
|
account?: string;
|
||||||
/** Accounts in the app: account deletion checks (Apple 5.1.1(v), Play Data safety delete-account URL). */
|
/** Accounts in the app: account deletion checks (Apple 5.1.1(v), Play Data safety delete-account URL). */
|
||||||
accounts?: AccountsConfig;
|
accounts?: AccountsConfig;
|
||||||
/** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */
|
/** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */
|
||||||
|
|
@ -998,6 +1003,7 @@ export function validateConfig(c: unknown): string[] {
|
||||||
const e: string[] = [];
|
const e: string[] = [];
|
||||||
const cfg = c as Partial<ShipConfig>;
|
const cfg = c as Partial<ShipConfig>;
|
||||||
if (!cfg || typeof cfg !== 'object') return ['config must be an object'];
|
if (!cfg || typeof cfg !== 'object') return ['config must be an object'];
|
||||||
|
if (cfg.account !== undefined && (typeof cfg.account !== 'string' || !PROFILE_RE.test(cfg.account))) e.push('account: a credential profile name (letters, digits, . _ -), never a key');
|
||||||
const app = cfg.app;
|
const app = cfg.app;
|
||||||
if (!app) e.push('app: missing');
|
if (!app) e.push('app: missing');
|
||||||
else {
|
else {
|
||||||
|
|
@ -1187,6 +1193,7 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom
|
||||||
const errors = validateConfig(config);
|
const errors = validateConfig(config);
|
||||||
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
|
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
|
||||||
const root = dirname(path);
|
const root = dirname(path);
|
||||||
|
setConfigAccount(root, config.account);
|
||||||
return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
|
return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,28 @@
|
||||||
// Credentials never live in storebound.config.ts or in git. Sources, first hit wins:
|
// Credentials never live in storebound.config.ts or in git. storebound is installed once per machine, and so are the
|
||||||
|
// store accounts: a credential profile in ~/.storebound/credentials/<name>.json (folder: STOREBOUND_CREDENTIALS_DIR)
|
||||||
|
// holds one Apple team key and / or one Play service account, shared by the CLI and the desktop app. Everything that
|
||||||
|
// belongs to one app stays in the project: <project>/.storebound/credentials.json (gitignored) has its Android upload
|
||||||
|
// keystore, the App Review contact and demo account, and may still hold store keys of its own.
|
||||||
|
//
|
||||||
|
// Which profile a project uses (first hit): --account <name> / env STOREBOUND_PROFILE (set but empty = none), the
|
||||||
|
// config's `account`, "account" in <project>/.storebound/credentials.json (written by `storebound accounts import`),
|
||||||
|
// else the default: the only profile on this machine, or the one named "default".
|
||||||
|
//
|
||||||
|
// One value, first hit wins:
|
||||||
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
|
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
|
||||||
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself)
|
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself), ASC_VENDOR_NUMBER, ANDROID_*, ...
|
||||||
// 2. <project>/.storebound/credentials.json (gitignored), same names:
|
// 2. the project's profile when it names one (--account, config, credentials.json "account"): store keys only
|
||||||
// { "PLAY_SERVICE_ACCOUNT": "...", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "...",
|
// 3. <project>/.storebound/credentials.json, same names:
|
||||||
|
// { "account": "<profile>", "ANDROID_KEYSTORE": "...", ...,
|
||||||
// "reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" },
|
// "reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" },
|
||||||
// "reviewDemoAccount": { "username": "", "password": "" } }
|
// "reviewDemoAccount": { "username": "", "password": "" } }
|
||||||
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
|
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
|
||||||
// 3. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
// 4. the default profile (none named): store keys only
|
||||||
// With a credential profile (`--profile <name>` or STOREBOUND_PROFILE), the profile comes before all of these:
|
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
||||||
// ~/.storebound/credentials/<name>.json (same four names; relative paths are relative to that file; folder:
|
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||||
// STOREBOUND_CREDENTIALS_DIR) or env STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _).
|
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||||
// Many studios / store accounts from one machine: one profile per account; storebound.workspace.json names them.
|
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
|
||||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { homedir, tmpdir } from 'node:os';
|
import { homedir, tmpdir } from 'node:os';
|
||||||
import { isAbsolute, join, resolve } from 'node:path';
|
import { isAbsolute, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
|
@ -60,6 +71,38 @@ export function profileEnv(profile: string): Record<string, string> {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The profiles on this machine (the *.json files of the credentials folder), sorted. */
|
||||||
|
export function profileNames(): string[] {
|
||||||
|
let names: string[] = [];
|
||||||
|
try { names = readdirSync(profileDir()).filter(n => n.endsWith('.json')).map(n => n.slice(0, -5)); } catch { /* no folder yet */ }
|
||||||
|
return names.filter(n => PROFILE_RE.test(n)).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default profile: the only one on this machine, else the one named "default"; null when that is not clear. */
|
||||||
|
export function defaultProfile(names = profileNames()): string | null {
|
||||||
|
return names.length === 1 ? names[0]! : names.includes('default') ? 'default' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** config.account per project root, set by loadConfig (config.ts) so credentials need not load the config. */
|
||||||
|
const configAccounts = new Map<string, string>();
|
||||||
|
export function setConfigAccount(root: string, name: string | undefined): void {
|
||||||
|
if (name) configAccounts.set(resolve(root), name); else configAccounts.delete(resolve(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveProfile { name: string; from: 'env' | 'config' | 'project' | 'default' }
|
||||||
|
|
||||||
|
/** The profile a project uses (see the header), or null. Throws only on an unreadable project credentials file. */
|
||||||
|
export function activeProfile(root: string): ActiveProfile | null {
|
||||||
|
const env = process.env.STOREBOUND_PROFILE;
|
||||||
|
if (env !== undefined) return env ? { name: env, from: 'env' } : null;
|
||||||
|
const cfg = configAccounts.get(resolve(root));
|
||||||
|
if (cfg) return { name: cfg, from: 'config' };
|
||||||
|
const own = fileStore(root).account;
|
||||||
|
if (typeof own === 'string' && own) return { name: own, from: 'project' };
|
||||||
|
const d = defaultProfile();
|
||||||
|
return d ? { name: d, from: 'default' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Which stores a profile can reach, and where it comes from. No values. */
|
/** Which stores a profile can reach, and where it comes from. No values. */
|
||||||
export function profileStatus(profile: string): { profile: string; source: 'file' | 'env' | 'missing' | 'invalid'; apple: boolean; play: boolean; error?: string } {
|
export function profileStatus(profile: string): { profile: string; source: 'file' | 'env' | 'missing' | 'invalid'; apple: boolean; play: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
|
|
@ -98,23 +141,26 @@ export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUE
|
||||||
export function credentialStatus(root: string): { key: string; status: 'set' | 'missing' | 'placeholder' }[] {
|
export function credentialStatus(root: string): { key: string; status: 'set' | 'missing' | 'placeholder' }[] {
|
||||||
let file: Store = {};
|
let file: Store = {};
|
||||||
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
|
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
|
||||||
const profile = process.env.STOREBOUND_PROFILE;
|
|
||||||
let prof: Record<string, string> = {};
|
let prof: Record<string, string> = {};
|
||||||
try { if (profile) prof = profileEnv(profile); } catch { /* invalid profile */ }
|
try { const p = activeProfile(root); if (p) prof = profileEnv(p.name); } catch { /* invalid profile */ }
|
||||||
return CREDENTIAL_KEYS.map(key => {
|
return CREDENTIAL_KEYS.map(key => {
|
||||||
const raw = [prof[key], process.env[key], typeof file[key] === 'string' ? file[key] as string : undefined].filter((x): x is string => !!x && !!x.trim());
|
const raw = [prof[key], process.env[key], typeof file[key] === 'string' ? file[key] as string : undefined].filter((x): x is string => !!x && !!x.trim());
|
||||||
return { key, status: raw.some(x => !isPlaceholder(x)) ? 'set' as const : raw.length ? 'placeholder' as const : 'missing' as const };
|
return { key, status: raw.some(x => !isPlaceholder(x)) ? 'set' as const : raw.length ? 'placeholder' as const : 'missing' as const };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One value by the order in the header: env, a named profile, the project file, the default profile. */
|
||||||
function value(root: string, name: string): string | undefined {
|
function value(root: string, name: string): string | undefined {
|
||||||
const real = (v: unknown): v is string => typeof v === 'string' && !!v && !isPlaceholder(v);
|
const real = (v: unknown): v is string => typeof v === 'string' && !!v && !isPlaceholder(v);
|
||||||
const profile = process.env.STOREBOUND_PROFILE;
|
|
||||||
if (profile) { const v = profileEnv(profile)[name]; if (real(v)) return v; }
|
|
||||||
const env = process.env[name];
|
const env = process.env[name];
|
||||||
if (real(env)) return env;
|
if (real(env)) return env;
|
||||||
|
const p = activeProfile(root);
|
||||||
|
const fromProfile = () => (p && (PROFILE_KEYS as readonly string[]).includes(name) ? profileEnv(p.name)[name] : undefined);
|
||||||
|
if (p && p.from !== 'default') { const v = fromProfile(); if (real(v)) return v; }
|
||||||
const v = fileStore(root)[name];
|
const v = fileStore(root)[name];
|
||||||
return real(v) ? v : undefined;
|
if (real(v)) return v;
|
||||||
|
if (p?.from === 'default') { const d = fromProfile(); if (real(d)) return d; }
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A path or the inline content itself. */
|
/** A path or the inline content itself. */
|
||||||
|
|
@ -129,14 +175,14 @@ export function parseServiceAccount(text: string): ServiceAccount {
|
||||||
|
|
||||||
export function playServiceAccount(root: string): ServiceAccount {
|
export function playServiceAccount(root: string): ServiceAccount {
|
||||||
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
|
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
|
||||||
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, or .storebound/credentials.json): path to the service account JSON key');
|
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles');
|
||||||
return parseServiceAccount(inline(v, /^\s*\{/));
|
return parseServiceAccount(inline(v, /^\s*\{/));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ascKey(root: string): AscKey {
|
export function ascKey(root: string): AscKey {
|
||||||
const keyId = value(root, 'ASC_KEY_ID');
|
const keyId = value(root, 'ASC_KEY_ID');
|
||||||
const issuerId = value(root, 'ASC_ISSUER_ID');
|
const issuerId = value(root, 'ASC_ISSUER_ID');
|
||||||
if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, or .storebound/credentials.json)');
|
if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json); `storebound accounts` lists the profiles');
|
||||||
const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`);
|
const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`);
|
||||||
if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`);
|
if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`);
|
||||||
return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) };
|
return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Where the storebound desktop app comes from and where it lives once installed. The app is a separate signed
|
// Where the storebound desktop app comes from and where it lives once installed. The app is a signed download from
|
||||||
// download from storebound.dev (DMG, Windows installer, AppImage / deb) that updates itself; the npm package stays
|
// storebound.dev (DMG, Windows installer, AppImage / deb) with the same version number as the npm CLI; `storebound
|
||||||
// the CLI, MCP server and skills and carries no app binaries. `storebound desktop` (cli/desktop.ts) uses this.
|
// desktop` (cli/desktop.ts) installs, updates and opens it, and the app can update itself. The npm package carries no
|
||||||
|
// app binaries.
|
||||||
//
|
//
|
||||||
// storebound.dev/desktop/latest.json is Tauri's updater manifest (version, notes, pub_date, platforms: the update
|
// storebound.dev/desktop/latest.json is Tauri's updater manifest (version, notes, pub_date, platforms: the update
|
||||||
// bundles with their minisign signatures) plus a `downloads` map of the installers people download by hand, each
|
// bundles with their minisign signatures) plus a `downloads` map of the installers people download by hand, each
|
||||||
|
|
@ -16,6 +17,9 @@ export const MANIFEST: Record<'stable' | 'beta', string> = {
|
||||||
stable: 'https://storebound.dev/desktop/latest.json',
|
stable: 'https://storebound.dev/desktop/latest.json',
|
||||||
beta: 'https://storebound.dev/desktop/beta/latest.json',
|
beta: 'https://storebound.dev/desktop/beta/latest.json',
|
||||||
};
|
};
|
||||||
|
/** The manifest of one version (written next to its files on publish): `storebound desktop` installs the CLI's own
|
||||||
|
* version from it, so the app and the CLI have one version number. */
|
||||||
|
export const versionManifest = (version: string): string => `https://storebound.dev/desktop/dl/${encodeURIComponent(version)}/latest.json`;
|
||||||
/** The updater public key of the desktop app (tauri.conf.json plugins.updater.pubkey; a test keeps them equal). */
|
/** The updater public key of the desktop app (tauri.conf.json plugins.updater.pubkey; a test keeps them equal). */
|
||||||
export const PUBLIC_KEY = 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK';
|
export const PUBLIC_KEY = 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK';
|
||||||
|
|
||||||
|
|
|
||||||
186
cli/lib/desktop-install.ts
Normal file
186
cli/lib/desktop-install.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
// What `storebound desktop` does on this machine, as data: the decision (open, install, update, downgrade) from the
|
||||||
|
// installed app's version and the CLI's (one product, one version number), and the install steps per OS as a plan
|
||||||
|
// that cli/desktop.ts runs and the tests read. Installs are per user and never need admin rights:
|
||||||
|
// macOS the .app from the DMG (mounted read-only, never shown in Finder) into /Applications when that is writable,
|
||||||
|
// else ~/Applications; an existing install is replaced in place. Nothing touches extended attributes: the
|
||||||
|
// installer was downloaded by this process (no quarantine flag), its SHA-256 and minisign signature are
|
||||||
|
// checked, and a release build is signed and notarized.
|
||||||
|
// Windows the NSIS installer, silent (/S), per user (%LOCALAPPDATA%\storebound), which also registers storebound://.
|
||||||
|
// Linux the AppImage into ~/.local/bin (or where the existing one is) plus ~/.local/share/applications/
|
||||||
|
// storebound.desktop with the storebound:// handler, registered when update-desktop-database / xdg-mime
|
||||||
|
// exist. A .deb install needs root, so it is printed, never run.
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { DownloadKey, Installed } from './desktop-dist';
|
||||||
|
|
||||||
|
/** -1, 0, 1 like a comparator; a pre-release sorts before its release (0.8.0-beta.1 < 0.8.0). */
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const [ma, pa = ''] = a.split('-', 2) as [string, string?];
|
||||||
|
const [mb, pb = ''] = b.split('-', 2) as [string, string?];
|
||||||
|
const na = ma.split('.').map(Number), nb = mb.split('.').map(Number);
|
||||||
|
for (let i = 0; i < 3; i++) { const d = (na[i] ?? 0) - (nb[i] ?? 0); if (d) return d < 0 ? -1 : 1; }
|
||||||
|
if (pa === pb) return 0;
|
||||||
|
if (!pa) return 1;
|
||||||
|
if (!pb) return -1;
|
||||||
|
return pa < pb ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action = 'open' | 'install' | 'update' | 'downgrade' | 'unavailable';
|
||||||
|
export interface Decision { action: Action; from: string | null; to: string | null; notes: string[] }
|
||||||
|
|
||||||
|
export interface DecideIn {
|
||||||
|
installed: Installed | null;
|
||||||
|
/** the installed app's version; null when it could not be read */
|
||||||
|
installedVersion: string | null;
|
||||||
|
cli: string;
|
||||||
|
/** the version storebound.dev offers for this system: the CLI's own when published, else the channel's newest */
|
||||||
|
available: string | null;
|
||||||
|
/** why nothing is available (no build for this system, no release yet, offline) */
|
||||||
|
unavailableReason?: string;
|
||||||
|
force: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one rule: the app should have the CLI's version. Same: open. Missing: install. Older: update (to the CLI's
|
||||||
|
* version, or the newest published when the CLI's is not out yet). Newer: open and say so; a downgrade only with --force.
|
||||||
|
*/
|
||||||
|
export function decide(i: DecideIn): Decision {
|
||||||
|
const notes: string[] = [];
|
||||||
|
const offer = i.available;
|
||||||
|
if (offer && offer !== i.cli) notes.push(`storebound.dev has desktop ${offer} for this system, this CLI is ${i.cli}`);
|
||||||
|
if (!i.installed) {
|
||||||
|
if (!offer) return { action: 'unavailable', from: null, to: null, notes: [i.unavailableReason ?? 'no desktop build for this system'] };
|
||||||
|
return { action: 'install', from: null, to: offer, notes };
|
||||||
|
}
|
||||||
|
const have = i.installedVersion;
|
||||||
|
if (!have) return { action: 'open', from: null, to: null, notes: ['the installed app\'s version could not be read'] };
|
||||||
|
if (have === i.cli) return { action: 'open', from: have, to: null, notes: [] };
|
||||||
|
if (compareVersions(have, i.cli) < 0) {
|
||||||
|
if (offer && compareVersions(offer, have) > 0) return { action: 'update', from: have, to: offer, notes: [`the app is ${have}, this CLI is ${i.cli}`, ...notes] };
|
||||||
|
return { action: 'open', from: have, to: null, notes: [`the app is ${have}, this CLI is ${i.cli}: no newer app is published yet${i.unavailableReason ? ` (${i.unavailableReason})` : ''}`] };
|
||||||
|
}
|
||||||
|
// the app is newer than this CLI
|
||||||
|
if (i.force && offer && compareVersions(offer, have) < 0) return { action: 'downgrade', from: have, to: offer, notes: [`--force: replacing the newer app ${have} with ${offer}`] };
|
||||||
|
return { action: 'open', from: have, to: null, notes: [`the app (${have}) is newer than this CLI (${i.cli}): update the CLI (npx storebound@latest, or bun add -d storebound@latest in a project that pins it). Nothing was downgraded (--force installs ${offer ?? i.cli})`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step of an install. `run` is an argv (never a shell); `always` steps run even after a failure (unmount). */
|
||||||
|
export type Step =
|
||||||
|
| { what: string; run: string[]; timeoutMs: number; always?: boolean; optional?: boolean }
|
||||||
|
| { what: string; mkdir: string }
|
||||||
|
| { what: string; copy: { from: string; to: string; mode?: number } }
|
||||||
|
| { what: string; rename: { from: string; to: string } }
|
||||||
|
| { what: string; remove: string }
|
||||||
|
| { what: string; write: { path: string; content: string; mode?: number } }
|
||||||
|
| { what: string; expectVersion: { app: string; version: string } };
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
/** where the app ends up (the .app, the .exe, the AppImage) */
|
||||||
|
target: string;
|
||||||
|
/** a short place for the question ("~/Applications") */
|
||||||
|
place: string;
|
||||||
|
steps: Step[];
|
||||||
|
/** the plan cannot run (a .deb needs root): what to tell the owner instead */
|
||||||
|
manual?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanIn {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
kind: DownloadKey;
|
||||||
|
/** the verified installer on disk */
|
||||||
|
file: string;
|
||||||
|
version: string;
|
||||||
|
home: string;
|
||||||
|
/** the app already there (an update replaces it in place) */
|
||||||
|
existing: Installed | null;
|
||||||
|
/** /Applications is writable for this user (macOS) */
|
||||||
|
systemAppsWritable: boolean;
|
||||||
|
/** a private temp folder for the mount point / staging */
|
||||||
|
tmp: string;
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
/** which optional Linux tools exist */
|
||||||
|
has: (tool: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tildeOf = (p: string, home: string) => (p.startsWith(home) ? '~' + p.slice(home.length) : p);
|
||||||
|
|
||||||
|
export function installPlan(i: PlanIn): Plan {
|
||||||
|
if (i.platform === 'darwin') {
|
||||||
|
const dir = i.existing?.how === 'bundle' ? join(i.existing.path, '..') : i.systemAppsWritable ? '/Applications' : join(i.home, 'Applications');
|
||||||
|
const target = join(dir, 'storebound.app');
|
||||||
|
const mnt = join(i.tmp, 'mnt');
|
||||||
|
const staged = join(dir, '.storebound.app.new');
|
||||||
|
const old = join(dir, '.storebound.app.old');
|
||||||
|
const steps: Step[] = [
|
||||||
|
{ what: `make ${tildeOf(dir, i.home)}`, mkdir: dir },
|
||||||
|
{ what: 'a private mount point', mkdir: mnt },
|
||||||
|
{ what: 'mount the disk image read-only (not shown in Finder)', run: ['hdiutil', 'attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mnt, i.file], timeoutMs: 120_000 },
|
||||||
|
{ what: 'copy storebound.app', run: ['ditto', join(mnt, 'storebound.app'), staged], timeoutMs: 300_000 },
|
||||||
|
{ what: 'unmount the disk image', run: ['hdiutil', 'detach', mnt, '-quiet'], timeoutMs: 60_000, always: true },
|
||||||
|
{ what: `check the copied app is ${i.version}`, expectVersion: { app: staged, version: i.version } },
|
||||||
|
...(i.existing?.how === 'bundle' ? [{ what: 'move the old app aside', rename: { from: target, to: old } } as Step] : []),
|
||||||
|
{ what: `put storebound.app in ${tildeOf(dir, i.home)}`, rename: { from: staged, to: target } },
|
||||||
|
...(i.existing?.how === 'bundle' ? [{ what: 'remove the old app', remove: old } as Step] : []),
|
||||||
|
];
|
||||||
|
return { target, place: tildeOf(dir, i.home), steps };
|
||||||
|
}
|
||||||
|
if (i.platform === 'win32') {
|
||||||
|
const base = i.env.LOCALAPPDATA ?? join(i.home, 'AppData', 'Local');
|
||||||
|
const target = `${base}\\storebound\\storebound-desktop.exe`;
|
||||||
|
return {
|
||||||
|
target, place: '%LOCALAPPDATA%\\storebound',
|
||||||
|
steps: [
|
||||||
|
{ what: 'run the installer silently, for this user only (no admin rights)', run: [i.file, '/S'], timeoutMs: 300_000 },
|
||||||
|
{ what: `check the installed app is ${i.version}`, expectVersion: { app: target, version: i.version } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Linux
|
||||||
|
if (i.kind === 'linux-x86_64-deb' || i.existing?.how === 'deb') {
|
||||||
|
return { target: '/usr/bin/storebound-desktop', place: 'the system (.deb)', steps: [], manual: `a .deb installs system-wide and needs root: sudo apt install ${i.file}` };
|
||||||
|
}
|
||||||
|
const dir = i.existing?.how === 'appimage' ? join(i.existing.path, '..') : join(i.home, '.local', 'bin');
|
||||||
|
const target = i.existing?.how === 'appimage' ? i.existing.path : join(dir, 'storebound.AppImage');
|
||||||
|
const staged = join(dir, '.storebound.AppImage.new');
|
||||||
|
const apps = join(i.env.XDG_DATA_HOME ?? join(i.home, '.local', 'share'), 'applications');
|
||||||
|
const entry = [
|
||||||
|
'[Desktop Entry]', 'Type=Application', 'Name=storebound', 'Comment=Every game\'s releases on both stores in one window',
|
||||||
|
`Exec="${target}" %u`, 'Terminal=false', 'Categories=Development;', 'MimeType=x-scheme-handler/storebound;', '',
|
||||||
|
].join('\n');
|
||||||
|
const steps: Step[] = [
|
||||||
|
{ what: `make ${tildeOf(dir, i.home)}`, mkdir: dir },
|
||||||
|
{ what: 'copy the AppImage', copy: { from: i.file, to: staged, mode: 0o755 } },
|
||||||
|
{ what: `check the copied app is ${i.version}`, expectVersion: { app: staged, version: i.version } },
|
||||||
|
{ what: `put it at ${tildeOf(target, i.home)}`, rename: { from: staged, to: target } },
|
||||||
|
{ what: `make ${tildeOf(apps, i.home)}`, mkdir: apps },
|
||||||
|
{ what: 'menu entry and the storebound:// link handler', write: { path: join(apps, 'storebound.desktop'), content: entry, mode: 0o644 } },
|
||||||
|
];
|
||||||
|
if (i.has('update-desktop-database')) steps.push({ what: 'refresh the menu', run: ['update-desktop-database', apps], timeoutMs: 30_000, optional: true });
|
||||||
|
if (i.has('xdg-mime')) steps.push({ what: 'register storebound:// links', run: ['xdg-mime', 'default', 'storebound.desktop', 'x-scheme-handler/storebound'], timeoutMs: 30_000, optional: true });
|
||||||
|
return { target, place: tildeOf(dir, i.home), steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The command that tells whether the app is running (exit 0 = running), so an update never replaces a running app. */
|
||||||
|
export function runningCheck(platform: NodeJS.Platform): string[] {
|
||||||
|
if (platform === 'win32') return ['tasklist', '/FI', 'IMAGENAME eq storebound-desktop.exe', '/NH'];
|
||||||
|
return ['pgrep', '-x', 'storebound-desktop'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the tasklist / pgrep result "running"? (tasklist exits 0 either way and prints "INFO: No tasks ..."). */
|
||||||
|
export function isRunning(platform: NodeJS.Platform, code: number | null, stdout: string): boolean {
|
||||||
|
if (platform === 'win32') return /storebound-desktop\.exe/i.test(stdout);
|
||||||
|
return code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The question in a terminal; the default is yes. */
|
||||||
|
export function question(d: Decision, sizeBytes: number, place: string): string {
|
||||||
|
const mb = `${Math.round(sizeBytes / 1e6)} MB`;
|
||||||
|
if (d.action === 'install') return `Install storebound desktop ${d.to} (${mb}) to ${place}? [Y/n] `;
|
||||||
|
if (d.action === 'update') return `Update storebound desktop ${d.from} to ${d.to} (${mb}) in ${place}? [Y/n] `;
|
||||||
|
return `Replace storebound desktop ${d.from} with the older ${d.to} (${mb}) in ${place}? [Y/n] `;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How an install / update gets its yes: --yes, the question in a terminal, or nobody to ask (exit 3, the command
|
||||||
|
* printed), like every other confirmation of storebound. */
|
||||||
|
export function consent(yes: boolean, tty: boolean): 'yes' | 'ask' | 'declined' {
|
||||||
|
return yes ? 'yes' : tty ? 'ask' : 'declined';
|
||||||
|
}
|
||||||
82
cli/lib/registry.ts
Normal file
82
cli/lib/registry.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// The app folders of this machine: one list shared by the CLI and the desktop app, ~/.storebound/projects.json
|
||||||
|
// (STOREBOUND_REGISTRY names another file). `storebound desktop` in an app folder, `storebound apps add`, `ws init` and
|
||||||
|
// the desktop's App folders screen add to it; `storebound apps folders` and the desktop show it; `storebound ws ...`
|
||||||
|
// without a storebound.workspace.json works over it. The desktop keeps its per-folder extras (mapped accounts, saved
|
||||||
|
// reads) in its own state by the same id. Only paths and names: no keys, nothing from inside the folders.
|
||||||
|
// { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
|
||||||
|
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export interface RegistryProject { id: string; path: string; name: string; added: string }
|
||||||
|
export interface Registry { version: 1; projects: RegistryProject[] }
|
||||||
|
|
||||||
|
export const registryFile = (env: Record<string, string | undefined> = process.env): string => env.STOREBOUND_REGISTRY || join(homedir(), '.storebound', 'projects.json');
|
||||||
|
|
||||||
|
/** The id of a folder the CLI adds: stable per absolute path (the same as `ws desktop export`'s project ids). */
|
||||||
|
export const folderId = (absPath: string): string => createHash('sha256').update(`project:${absPath}`).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
const ID = /^[0-9a-f]{16}$/;
|
||||||
|
|
||||||
|
/** Parses the file's text; drops entries that are not well formed (an id, an absolute path). Pure. */
|
||||||
|
export function parseRegistry(text: string): Registry {
|
||||||
|
let raw: unknown;
|
||||||
|
try { raw = JSON.parse(text); } catch { return { version: 1, projects: [] }; }
|
||||||
|
const list = (raw as { projects?: unknown })?.projects;
|
||||||
|
const out: RegistryProject[] = [];
|
||||||
|
if (Array.isArray(list)) for (const p of list) {
|
||||||
|
const o = p as Partial<RegistryProject>;
|
||||||
|
if (typeof o?.path !== 'string' || !o.path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(o.path)) continue;
|
||||||
|
if (out.some(x => x.path === o.path)) continue;
|
||||||
|
out.push({ id: typeof o.id === 'string' && ID.test(o.id) && !out.some(x => x.id === o.id) ? o.id : folderId(o.path), path: o.path,
|
||||||
|
name: typeof o.name === 'string' && o.name.trim() ? o.name : basename(o.path), added: typeof o.added === 'string' ? o.added : '' });
|
||||||
|
}
|
||||||
|
return { version: 1, projects: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRegistry(file = registryFile()): Registry {
|
||||||
|
if (!existsSync(file)) return { version: 1, projects: [] };
|
||||||
|
return parseRegistry(readFileSync(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeRegistry(r: Registry, file = registryFile()): void {
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
if (existsSync(file)) {
|
||||||
|
// a file this version cannot read is kept once instead of being replaced silently
|
||||||
|
try { JSON.parse(readFileSync(file, 'utf8')); } catch { copyFileSync(file, `${file}.unreadable`); }
|
||||||
|
}
|
||||||
|
const tmp = `${file}.tmp-${process.pid}`;
|
||||||
|
writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects }, null, 2) + '\n');
|
||||||
|
renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a folder (by absolute path; the same path again only updates an empty name). Pure on the registry. */
|
||||||
|
export function withFolder(r: Registry, path: string, name: string | undefined, now: string): { registry: Registry; project: RegistryProject; added: boolean } {
|
||||||
|
const abs = resolve(path);
|
||||||
|
const have = r.projects.find(p => p.path === abs);
|
||||||
|
if (have) return { registry: r, project: have, added: false };
|
||||||
|
const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now };
|
||||||
|
return { registry: { version: 1, projects: [...r.projects, project] }, project, added: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a folder by path or id. Pure. */
|
||||||
|
export function withoutFolder(r: Registry, pathOrId: string): { registry: Registry; removed: RegistryProject | null } {
|
||||||
|
const abs = ID.test(pathOrId) ? '' : resolve(pathOrId);
|
||||||
|
const removed = r.projects.find(p => p.id === pathOrId || p.path === abs) ?? null;
|
||||||
|
return { registry: removed ? { version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads, adds and writes (only when it changed). */
|
||||||
|
export function addFolder(path: string, name?: string, file = registryFile()): { project: RegistryProject; added: boolean } {
|
||||||
|
const { registry, project, added } = withFolder(readRegistry(file), path, name, new Date().toISOString());
|
||||||
|
if (added) writeRegistry(registry, file);
|
||||||
|
return { project, added };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFolder(pathOrId: string, file = registryFile()): RegistryProject | null {
|
||||||
|
const { registry, removed } = withoutFolder(readRegistry(file), pathOrId);
|
||||||
|
if (removed) writeRegistry(registry, file);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
@ -280,7 +280,7 @@ export function readStoreApps(root: string, accounts: Account[]): { account: str
|
||||||
// ---- The desktop's state (state.json in its data folder) <-> the workspace ----
|
// ---- The desktop's state (state.json in its data folder) <-> the workspace ----
|
||||||
|
|
||||||
export interface DesktopProject { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string }
|
export interface DesktopProject { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string }
|
||||||
export interface DesktopAccount { id: string; label: string; kind: Store; created: string; studio: string | null }
|
export interface DesktopAccount { id: string; label: string; kind: Store; created: string; studio: string | null; /** 0.8+: the credential profile that holds the key (shared with the CLI) */ profile?: string | null }
|
||||||
export interface DesktopStudio { id: string; name: string; color: string; logo: string | null; created: string }
|
export interface DesktopStudio { id: string; name: string; color: string; logo: string | null; created: string }
|
||||||
export interface DesktopState {
|
export interface DesktopState {
|
||||||
version: number;
|
version: number;
|
||||||
|
|
@ -315,7 +315,7 @@ export function toDesktopState(ws: Workspace, apps: AppFolder[], now = new Date(
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
projects: apps.map(a => ({ id: idOf.get(a.id)!, path: a.path, name: a.name, playAccount: a.playAccount, appleAccount: a.appleAccount, added: now })),
|
projects: apps.map(a => ({ id: idOf.get(a.id)!, path: a.path, name: a.name, playAccount: a.playAccount, appleAccount: a.appleAccount, added: now })),
|
||||||
accounts: accounts.map(a => ({ id: a.id, label: a.label ?? a.profile, kind: a.store, created: now, studio: a.studio })),
|
accounts: accounts.map(a => ({ id: a.id, label: a.label ?? a.profile, kind: a.store, created: now, studio: a.studio, profile: a.profile })),
|
||||||
studios: studiosOf(ws).map(s => ({ id: s.id, name: s.name, color: s.color, logo: null, created: now })),
|
studios: studiosOf(ws).map(s => ({ id: s.id, name: s.name, color: s.color, logo: null, created: now })),
|
||||||
overrides: {
|
overrides: {
|
||||||
pairs: o.pairs, unpairs: o.unpairs,
|
pairs: o.pairs, unpairs: o.unpairs,
|
||||||
|
|
@ -336,13 +336,14 @@ function profileFor(a: DesktopAccount, taken: Set<string>): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop state -> workspace. Folders under `root` become relative paths, others stay absolute. Each account becomes
|
* Desktop state -> workspace. Folders under `root` become relative paths, others stay absolute. Each account keeps
|
||||||
* a credential profile name (the owner creates ~/.storebound/credentials/<profile>.json; the desktop's keychain is
|
* its credential profile (0.8+: the app keeps its keys in ~/.storebound/credentials, shared with the CLI), or gets a
|
||||||
* never read). Account ids are kept, so the overrides' store app keys stay valid.
|
* profile name to create (accounts of older apps). Account ids are kept, so the overrides' store app keys stay valid.
|
||||||
*/
|
*/
|
||||||
export function fromDesktopState(state: DesktopState, root: string): { ws: Workspace; profiles: { profile: string; store: Store; label: string }[] } {
|
export function fromDesktopState(state: DesktopState, root: string): { ws: Workspace; profiles: { profile: string; store: Store; label: string }[] } {
|
||||||
const taken = new Set<string>();
|
// 0.8+ accounts name their credential profile (the app and the CLI share them); older ones get a name to create
|
||||||
const profile = new Map(state.accounts.map(a => [a.id, profileFor(a, taken)] as const));
|
const taken = new Set<string>(state.accounts.map(a => a.profile).filter((p): p is string => !!p && PROFILE_RE.test(p)));
|
||||||
|
const profile = new Map(state.accounts.map(a => [a.id, a.profile && PROFILE_RE.test(a.profile) ? a.profile : profileFor(a, taken)] as const));
|
||||||
const acc = (a: DesktopAccount): WsAccount => ({ store: a.kind, profile: profile.get(a.id)!, label: a.label, id: a.id });
|
const acc = (a: DesktopAccount): WsAccount => ({ store: a.kind, profile: profile.get(a.id)!, label: a.label, id: a.id });
|
||||||
const relOf = new Map(state.projects.map(p => {
|
const relOf = new Map(state.projects.map(p => {
|
||||||
const r = posix(relative(root, p.path));
|
const r = posix(relative(root, p.path));
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,8 @@ export class Session {
|
||||||
/** apps.list / account.check: one account's credentials, no project folder. */
|
/** apps.list / account.check: one account's credentials, no project folder. */
|
||||||
private async account(cmd: string[], params: Json, id: Id, list: boolean): Promise<unknown> {
|
private async account(cmd: string[], params: Json, id: Id, list: boolean): Promise<unknown> {
|
||||||
for (const k of Object.keys(params)) if (!['credentials', 'args'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`);
|
for (const k of Object.keys(params)) if (!['credentials', 'args'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`);
|
||||||
const env = credentialsOf(params.credentials);
|
// exactly this account: no credential profile of the machine mixes in
|
||||||
|
const env: Record<string, string> = { ...credentialsOf(params.credentials), STOREBOUND_PROFILE: '' };
|
||||||
const play = !!env.PLAY_SERVICE_ACCOUNT, apple = !!(env.ASC_KEY_ID || env.ASC_ISSUER_ID || env.ASC_P8);
|
const play = !!env.PLAY_SERVICE_ACCOUNT, apple = !!(env.ASC_KEY_ID || env.ASC_ISSUER_ID || env.ASC_P8);
|
||||||
if (play === apple) throw new RpcError(ERR.invalidParams, 'credentials: exactly one store account (a Play service account, or an App Store Connect key)');
|
if (play === apple) throw new RpcError(ERR.invalidParams, 'credentials: exactly one store account (a Play service account, or an App Store Connect key)');
|
||||||
const args = (params.args ?? {}) as Json;
|
const args = (params.args ?? {}) as Json;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
// `storebound ws`: many apps and studios from one folder (storebound.workspace.json; model: cli/lib/workspace.ts).
|
// `storebound ws`: many apps and studios from one folder (storebound.workspace.json; model: cli/lib/workspace.ts).
|
||||||
// ws init [--dry] [--force] find the app folders below and write storebound.workspace.json
|
// ws init [--dry] [--force] find the app folders below and write storebound.workspace.json
|
||||||
|
// (they join this machine's app folder list too)
|
||||||
|
// Without a storebound.workspace.json in this folder or above, ws works over this machine's app folders
|
||||||
|
// (~/.storebound/projects.json: `storebound apps folders`, the desktop app's list).
|
||||||
// ws list [--all] [--json] apps (folders + store-only apps from `ws apps import`), studios, accounts
|
// ws list [--all] [--json] apps (folders + store-only apps from `ws apps import`), studios, accounts
|
||||||
// ws status [--offline] [--apps a,b] [--json] portfolio table: apps x stores (release trains, catalog, check), read
|
// ws status [--offline] [--apps a,b] [--json] portfolio table: apps x stores (release trains, catalog, check), read
|
||||||
// in parallel (concurrency from the workspace, default 4)
|
// in parallel (concurrency from the workspace, default 4)
|
||||||
|
|
@ -15,7 +18,7 @@
|
||||||
// Credentials: per account a credential profile (cli/lib/credentials.ts), handed to each app's child command as env.
|
// Credentials: per account a credential profile (cli/lib/credentials.ts), handed to each app's child command as env.
|
||||||
// Nothing here prints a secret.
|
// Nothing here prints a secret.
|
||||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { homedir, tmpdir } from 'node:os';
|
||||||
import { join, relative, resolve, sep } from 'node:path';
|
import { join, relative, resolve, sep } from 'node:path';
|
||||||
import type { Args } from './lib/args';
|
import type { Args } from './lib/args';
|
||||||
import { emptyStatus, fillAppStatus, lastLine, pool } from './lib/app-status';
|
import { emptyStatus, fillAppStatus, lastLine, pool } from './lib/app-status';
|
||||||
|
|
@ -29,9 +32,10 @@ import { textTable } from './lib/table';
|
||||||
import { laneOf, laneText } from './lib/train';
|
import { laneOf, laneText } from './lib/train';
|
||||||
import {
|
import {
|
||||||
type Account, accountsOf, type AppFolder, cacheFile, type DesktopState, desktopStateFile, fromDesktopState, type LoadedWorkspace, loadWorkspace, mergeDesktopState,
|
type Account, accountsOf, type AppFolder, cacheFile, type DesktopState, desktopStateFile, fromDesktopState, type LoadedWorkspace, loadWorkspace, mergeDesktopState,
|
||||||
overridesOf, readStoreApps, resolveApps, selectApps, type Store, type StoreAppsCache, studiosOf, toDesktopState, type Workspace, WORKSPACE_NAMES,
|
findWorkspace, overridesOf, readStoreApps, resolveApps, selectApps, type Store, type StoreAppsCache, studiosOf, toDesktopState, type Workspace, WORKSPACE_NAMES,
|
||||||
} from './lib/workspace';
|
} from './lib/workspace';
|
||||||
import { FORBIDDEN_ARGS, TOOLS } from './mcp-tools';
|
import { FORBIDDEN_ARGS, TOOLS } from './mcp-tools';
|
||||||
|
import { addFolder, readRegistry, registryFile } from './lib/registry';
|
||||||
import { checkVirtual, VIRTUAL_READS, virtualDir } from './serve';
|
import { checkVirtual, VIRTUAL_READS, virtualDir } from './serve';
|
||||||
|
|
||||||
/** One app of the workspace: a folder or a store-only app, with the credentials env its commands get. */
|
/** One app of the workspace: a folder or a store-only app, with the credentials env its commands get. */
|
||||||
|
|
@ -44,7 +48,9 @@ const USAGE = `usage: storebound ws <init | list | status | check | validate | r
|
||||||
|
|
||||||
/** Credentials env for one app: the apple account's ASC_* and the play account's PLAY_* from their profiles. */
|
/** Credentials env for one app: the apple account's ASC_* and the play account's PLAY_* from their profiles. */
|
||||||
export function appEnv(u: UnifiedApp<AppFolder>, accounts: Account[]): { env: Record<string, string>; problems: string[] } {
|
export function appEnv(u: UnifiedApp<AppFolder>, accounts: Account[]): { env: Record<string, string>; problems: string[] } {
|
||||||
const env: Record<string, string> = { STOREBOUND_PROFILE: '' };
|
// with workspace accounts the child gets exactly their keys (no other profile); without, the app's own choice
|
||||||
|
// (config `account`, its .storebound "account", the machine's default profile) applies as in a terminal
|
||||||
|
const env: Record<string, string> = {};
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
const profileOf = (store: Store): string | null => {
|
const profileOf = (store: Store): string | null => {
|
||||||
const fromFolder = store === 'apple' ? u.project?.appleProfile : u.project?.playProfile;
|
const fromFolder = store === 'apple' ? u.project?.appleProfile : u.project?.playProfile;
|
||||||
|
|
@ -62,6 +68,7 @@ export function appEnv(u: UnifiedApp<AppFolder>, accounts: Account[]): { env: Re
|
||||||
for (const store of ['apple', 'play'] as const) {
|
for (const store of ['apple', 'play'] as const) {
|
||||||
const p = profileOf(store);
|
const p = profileOf(store);
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
|
env.STOREBOUND_PROFILE = '';
|
||||||
try {
|
try {
|
||||||
const all = profileEnv(p);
|
const all = profileEnv(p);
|
||||||
const keys = store === 'apple' ? ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8'] : ['PLAY_SERVICE_ACCOUNT'];
|
const keys = store === 'apple' ? ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8'] : ['PLAY_SERVICE_ACCOUNT'];
|
||||||
|
|
@ -146,8 +153,13 @@ const catalogCell = (s: AppStatus): string => {
|
||||||
return `${c.products + c.subscriptions} items${c.checked === 'live' ? (issues ? `, ${issues} mismatched` : ', in parity') : ''}`;
|
return `${c.products + c.subscriptions} items${c.checked === 'live' ? (issues ? `, ${issues} mismatched` : ', in parity') : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The workspace file in this folder or above; without one, the app folders of this machine (cli/lib/registry.ts:
|
||||||
|
* the desktop app's list too), each with its own account choice. */
|
||||||
async function loadAll(args: Args): Promise<LoadedWorkspace> {
|
async function loadAll(args: Args): Promise<LoadedWorkspace> {
|
||||||
return loadWorkspace(process.cwd(), args.get('workspace'));
|
if (args.get('workspace') || findWorkspace(process.cwd())) return loadWorkspace(process.cwd(), args.get('workspace'));
|
||||||
|
const reg = readRegistry();
|
||||||
|
if (!reg.projects.length) return loadWorkspace(process.cwd());
|
||||||
|
return { file: registryFile(), root: homedir(), ws: { apps: reg.projects.map(p => ({ path: p.path, ...(p.name ? { name: p.name } : {}) })) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function concurrencyOf(args: Args, ws: Workspace): number {
|
function concurrencyOf(args: Args, ws: Workspace): number {
|
||||||
|
|
@ -220,6 +232,10 @@ async function init(args: Args): Promise<void> {
|
||||||
const giText = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
const giText = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
||||||
if (!/^\.storebound\/?$/m.test(giText)) writeFileSync(gi, giText + (giText && !giText.endsWith('\n') ? '\n' : '') + '.storebound/\n');
|
if (!/^\.storebound\/?$/m.test(giText)) writeFileSync(gi, giText + (giText && !giText.endsWith('\n') ? '\n' : '') + '.storebound/\n');
|
||||||
log('ws', `wrote ${file}: ${apps.length ? apps.join(', ') : 'no app folders found yet (add them under "apps")'}`);
|
log('ws', `wrote ${file}: ${apps.length ? apps.join(', ') : 'no app folders found yet (add them under "apps")'}`);
|
||||||
|
// the folders also join this machine's list (storebound apps folders, the desktop app)
|
||||||
|
const found = resolveApps({ file, root, ws }).apps;
|
||||||
|
for (const f of found) addFolder(f.path);
|
||||||
|
if (found.length) log('ws', `${found.length} folder(s) in this machine's app list too (${registryFile().replace(homedir(), '~')})`);
|
||||||
log('ws', 'next: add studios and accounts by credential profile (~/.storebound/credentials/<profile>.json), then storebound ws list');
|
log('ws', 'next: add studios and accounts by credential profile (~/.storebound/credentials/<profile>.json), then storebound ws list');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
|
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
|
||||||
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
||||||
| `api list \| coverage \| apple\|play <operation>` | API | every App Store Connect / Google Play operation (see [Every endpoint](api.md)); GET runs, writes are dry unless `--yes` in a terminal |
|
| `api list \| coverage \| apple\|play <operation>` | API | every App Store Connect / Google Play operation (see [Every endpoint](api.md)); GET runs, writes are dry unless `--yes` in a terminal |
|
||||||
| `desktop [--download]` | local | open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, `--download` saves it to ~/Downloads after checking its signature (never installs) |
|
| `desktop [--yes] [--dry] [--download]` | local + download | the desktop app of this CLI's version, opened on this folder: installs it for this user when missing (size, SHA-256 and signature checked; asks once, `--yes`), updates an older one, notes a newer one (`--force` to downgrade); `--download` only saves the checked installer |
|
||||||
|
| `accounts [list] \| import [--dry] \| use <profile>` | local | the store accounts of this machine (`~/.storebound/credentials`, shared with the desktop app); `import` moves a project's store keys into a profile (same key = same profile), never prints values |
|
||||||
|
| `apps folders \| add [<folder>] \| remove <folder>` | local | the app folders of this machine (`~/.storebound/projects.json`), the same list as the desktop app |
|
||||||
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
||||||
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,27 @@ How the desktop app (`apps/desktop`) is built, signed, checked and published. Fo
|
||||||
updater key. The app checks the signature before it replaces anything and installs only after the owner clicks
|
updater key. The app checks the signature before it replaces anything and installs only after the owner clicks
|
||||||
Install in a native dialog (Settings > Updates).
|
Install in a native dialog (Settings > Updates).
|
||||||
- **It brings its own CLI.** The app bundles the storebound CLI as a sidecar, so it never depends on an npm install.
|
- **It brings its own CLI.** The app bundles the storebound CLI as a sidecar, so it never depends on an npm install.
|
||||||
|
That CLI is the npm package of the same version.
|
||||||
- **The npm package stays the CLI, the MCP server and the skills.** It contains no Tauri binaries.
|
- **The npm package stays the CLI, the MCP server and the skills.** It contains no Tauri binaries.
|
||||||
- **Bridge: `storebound desktop`.**
|
- **Bridge: `storebound desktop`** installs, updates and opens the app of the CLI's own version (docs/desktop.md,
|
||||||
- Installed: it opens the app. In an app folder it sends `storebound://open?path=<folder>`, and the app selects
|
"One product with the CLI"). It reads `storebound.dev/desktop/dl/<version>/latest.json` (written by the publish
|
||||||
that folder, or adds it after a native dialog.
|
script next to the version's files) and falls back to the channel's `latest.json`. `--download` only saves the
|
||||||
- Not installed: it prints the download for this OS and CPU.
|
checked installer to ~/Downloads.
|
||||||
- `--download` saves the installer to ~/Downloads after checking its size, SHA-256 and minisign signature. It never
|
|
||||||
installs anything.
|
|
||||||
|
|
||||||
## One version
|
## One version, one release
|
||||||
|
|
||||||
`desktopVersion` in the root `package.json` is the desktop app's version. The npm package's own `version` belongs
|
The CLI and the desktop app are one product with one version number: `version` in the root `package.json`. 0.8.0 is
|
||||||
to the CLI and moves on its own.
|
the first release of both under one number. (The desktop had its own `desktopVersion`, 0.1.0; its release candidate
|
||||||
|
in `~/Desktop/storebound-dist/desktop-0.1.0` was never published, so no installed app or link depends on it.)
|
||||||
|
|
||||||
- `bun tools/desktop-version.ts` checks the copies: `apps/desktop/package.json`, `tauri.conf.json`, `Cargo.toml`
|
- `bun tools/desktop-version.ts` checks the copies: the agent plugin manifests (`.claude-plugin/plugin.json`,
|
||||||
and the app's entry in `Cargo.lock`. CI runs this check.
|
`marketplace.json`, `.codex-plugin/plugin.json`, `gemini-extension.json`), `apps/desktop/package.json`,
|
||||||
- `bun tools/desktop-version.ts --set 0.1.1` writes a new version into all of them.
|
`tauri.conf.json`, `Cargo.toml` and the app's entry in `Cargo.lock`. CI runs this check; `src-tauri/tests/d5.rs`
|
||||||
- `src-tauri/tests/d5.rs` checks the same thing again from the Rust side.
|
checks the app's copies from the Rust side.
|
||||||
|
- `bun tools/desktop-version.ts --set 0.8.0` writes a new version into package.json and all of them.
|
||||||
|
- **Release order** (one commit, one version): build and check the desktop artifacts (`mac`, `add`), `publish` them
|
||||||
|
(it refuses a folder whose version is not package.json's), then `npm publish` of the same version (CONTRIBUTING.md).
|
||||||
|
The app goes first, so `npx storebound desktop` of the new CLI finds its own version.
|
||||||
|
|
||||||
## Keys and private files (outside every repository)
|
## Keys and private files (outside every repository)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
|
||||||
- The webview gets no plugin API. It may call the app commands (each re-checks the window label and the page origin
|
- The webview gets no plugin API. It may call the app commands (each re-checks the window label and the page origin
|
||||||
in Rust) and listen to two events. Strict CSP, no remote content. `src-tauri/tests/security.rs` pins all of it.
|
in Rust) and listen to two events. Strict CSP, no remote content. `src-tauri/tests/security.rs` pins all of it.
|
||||||
- Store keys (Play service account JSON, App Store Connect key id, issuer id and .p8) are picked in native dialogs,
|
- Store keys (Play service account JSON, App Store Connect key id, issuer id and .p8) are picked in native dialogs,
|
||||||
stored in the OS keychain (`keyring`), never shown again and passed to the sidecar per call in memory only.
|
written into the machine's credential profiles (`~/.storebound/credentials`, files 0600 in folders 0700, the same
|
||||||
|
store the CLI reads: `profiles.rs`), never shown again and passed to the sidecar per call in memory only.
|
||||||
|
|
||||||
## Screens and data (D2)
|
## Screens and data (D2)
|
||||||
|
|
||||||
|
|
@ -64,7 +65,8 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
|
||||||
(`apple.testflight_status`), Checks (offline `scan`: rejection risks with guideline, evidence file:line, fix
|
(`apple.testflight_status`), Checks (offline `scan`: rejection risks with guideline, evidence file:line, fix
|
||||||
and skill, owner-accepted ones marked; offline `check`; `validate` with fix hints).
|
and skill, owner-accepted ones marked; offline `check`; `validate` with fix hints).
|
||||||
- **First-run guide**: what the app does, add or start an app folder (`project.init` = `storebound init --profile`,
|
- **First-run guide**: what the app does, add or start an app folder (`project.init` = `storebound init --profile`,
|
||||||
confirmed in a native dialog), store keys into the keychain and which app uses which, local tools, first read.
|
confirmed in a native dialog), store keys into the shared credential profiles and which app uses which, local
|
||||||
|
tools, first read.
|
||||||
- **Command palette** (Cmd/Ctrl+K): apps, tabs, screens and actions. Cmd/Ctrl+1 Portfolio, +2-5 workspace screens,
|
- **Command palette** (Cmd/Ctrl+K): apps, tabs, screens and actions. Cmd/Ctrl+1 Portfolio, +2-5 workspace screens,
|
||||||
Cmd/Ctrl+Shift+[ ] app tabs, arrow keys in the tab list. Toasts show background jobs with progress and Cancel.
|
Cmd/Ctrl+Shift+[ ] app tabs, arrow keys in the tab list. Toasts show background jobs with progress and Cancel.
|
||||||
- **Unknown, never false.** Store reads run on demand (they use the account's API quota) and are saved in app data
|
- **Unknown, never false.** Store reads run on demand (they use the account's API quota) and are saved in app data
|
||||||
|
|
@ -104,7 +106,8 @@ Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli
|
||||||
- A **studio** is a name, a colour and an optional logo. It groups any number of store accounts:
|
- A **studio** is a name, a colour and an optional logo. It groups any number of store accounts:
|
||||||
- a Play service account, which can be invited to several Play developer accounts and then sees all their apps;
|
- a Play service account, which can be invited to several Play developer accounts and then sees all their apps;
|
||||||
- an App Store Connect team key, one per team.
|
- an App Store Connect team key, one per team.
|
||||||
- Keys stay in the keychain. The state file keeps labels and studio ids only.
|
- Keys live in the credential profiles shared with the CLI. The state file keeps labels, studio ids and the profile
|
||||||
|
name of each account only (see "One product with the CLI" below).
|
||||||
- `storebound apps check` gives each account's health, with the fix in words:
|
- `storebound apps check` gives each account's health, with the fix in words:
|
||||||
- ok;
|
- ok;
|
||||||
- no-apps;
|
- no-apps;
|
||||||
|
|
@ -159,7 +162,8 @@ host state file (`overrides_set`, every key checked in `workspace.rs`).
|
||||||
- Privacy, with a button that reveals the data folder.
|
- Privacy, with a button that reveals the data folder.
|
||||||
- Shortcuts.
|
- Shortcuts.
|
||||||
- Updates: channel (stable / beta), check and install (D5).
|
- Updates: channel (stable / beta), check and install (D5).
|
||||||
- Reset: native confirmation, then the keychain entries and every saved state are removed.
|
- Reset: native confirmation, then every saved state of the app is removed; the shared credential profiles and the
|
||||||
|
app folder list stay (the CLI uses them).
|
||||||
|
|
||||||
### Writes in the window
|
### Writes in the window
|
||||||
|
|
||||||
|
|
@ -197,12 +201,12 @@ Each write is a dry run, then the native dialog, then the real run, with one pro
|
||||||
### The same model in the terminal
|
### The same model in the terminal
|
||||||
|
|
||||||
The CLI workspace (`storebound.workspace.json`, docs/studios.md "Many apps and studios") holds the same studios, accounts
|
The CLI workspace (`storebound.workspace.json`, docs/studios.md "Many apps and studios") holds the same studios, accounts
|
||||||
(credential profile names instead of keychain entries), folders and overrides. The pairing rules
|
(by credential profile name, the same profiles the app uses), folders and overrides. The pairing rules
|
||||||
(`cli/lib/identity.ts`) and the release lanes (`cli/lib/train.ts`) are one implementation for both; the window
|
(`cli/lib/identity.ts`) and the release lanes (`cli/lib/train.ts`) are one implementation for both; the window
|
||||||
re-exports them (`src/lib/identity.ts`, `src/lib/model.ts`). `storebound ws desktop import` reads `state.json` into a
|
re-exports them (`src/lib/identity.ts`, `src/lib/model.ts`). `storebound ws desktop import` reads `state.json` into a
|
||||||
workspace file (the keychain is never read; the profiles to create are listed); `storebound ws desktop export --merge
|
workspace file (each account keeps its profile; accounts of older apps get a profile name to create);
|
||||||
--apply` adds a workspace to `state.json` while the app is closed (backup kept, nothing removed, new accounts get
|
`storebound ws desktop export --merge --apply` adds a workspace to `state.json` while the app is closed (backup kept,
|
||||||
their keys in the Accounts screen). Project ids in an exported state are stable per folder path (16 hex).
|
nothing removed; accounts carry their profile, so their keys are already there). Project ids in an exported state are stable per folder path (16 hex).
|
||||||
|
|
||||||
### Fill in console
|
### Fill in console
|
||||||
|
|
||||||
|
|
@ -337,6 +341,45 @@ Download sources (one click on the Dependencies screen or in the first-run guide
|
||||||
- **Progress and cancel** go through the same progress events as sidecar jobs; a cancelled download removes its
|
- **Progress and cancel** go through the same progress events as sidecar jobs; a cancelled download removes its
|
||||||
temporary file.
|
temporary file.
|
||||||
|
|
||||||
|
## One product with the CLI (0.8)
|
||||||
|
|
||||||
|
The CLI and the app are one product: one version number, one set of store accounts, one list of app folders.
|
||||||
|
|
||||||
|
- **One version.** `package.json`'s `version` is the app's too (`tools/desktop-version.ts` keeps the copies equal,
|
||||||
|
CI and `src-tauri/tests/d5.rs` check it), and both are released together (docs/desktop-release.md). The app runs
|
||||||
|
its bundled CLI (the sidecar), which is that same version. A folder whose `package.json` pins another storebound
|
||||||
|
(`devDependencies.storebound`) gets a notice on its app page: "This project uses storebound X, the app runs Y"
|
||||||
|
(`workspace::pin_differs`; tags, ranges and `file:` specs give none).
|
||||||
|
- **`npx storebound desktop`** (cli/desktop.ts, the plans in cli/lib/desktop-install.ts): the app has the CLI's
|
||||||
|
version: open it on the current folder. Not installed: download it for this OS (the CLI's own version from
|
||||||
|
`storebound.dev/desktop/dl/<version>/latest.json`, else the channel's newest), check size, SHA-256 and the minisign
|
||||||
|
signature, install it for this user without admin rights (macOS: the .app from the read-only mounted DMG into
|
||||||
|
/Applications when writable, else ~/Applications, no extended attributes touched; Windows: the NSIS installer with
|
||||||
|
`/S`, per user; Linux: the AppImage into ~/.local/bin plus `~/.local/share/applications/storebound.desktop` with
|
||||||
|
the `storebound://` handler, registered with `update-desktop-database` / `xdg-mime` when they exist; a .deb is only
|
||||||
|
printed, it needs root), then open it. Older app: the same path as an update (never while the app runs); newer
|
||||||
|
app: opened with a note, a downgrade only with `--force`. In a terminal it asks once (`[Y/n]`), `--yes` skips the
|
||||||
|
question, without a terminal and `--yes` it prints the command and exits 3. `--dry` and `--json` change nothing;
|
||||||
|
`--download` only saves the checked installer. `storebound-desktop --version` prints the version (Windows, Linux).
|
||||||
|
- **Accounts** live in the machine's credential profiles, `~/.storebound/credentials/<name>.json` with key files in
|
||||||
|
`<name>/` (0600, folders 0700; `src-tauri/src/profiles.rs`, the CLI's cli/lib/credentials.ts and cli/lib/accounts.ts).
|
||||||
|
An app account is one store of one profile (`Account.profile`); its id for a profile the CLI made is the CLI
|
||||||
|
workspace's (`sha256("<store>:<profile>")`, 16 hex). Adding a key in the app writes the profile that already has
|
||||||
|
that key (same key id / service account e-mail), else one named after the label; removing one moves its key file to
|
||||||
|
`credentials/.removed/`. At start and whenever the list is shown, `profiles::sync` adds an account for every
|
||||||
|
profile store the app does not have yet. **Migration:** accounts of earlier versions kept their keys in the OS
|
||||||
|
keychain (`dev.storebound.desktop`); the first start of 0.8 writes each into a profile (keeping the account's id,
|
||||||
|
label, studio and app mappings), reads it back and only then deletes the keychain entry. An account whose keychain
|
||||||
|
entry is missing stays, marked "key not moved from the keychain yet" (host.log says which).
|
||||||
|
Why files and not the keychain: the CLI has no runtime dependencies and runs on three systems and in agents'
|
||||||
|
terminals; profile files were already its model, so one store means the app writes those files (owner-only modes,
|
||||||
|
like `~/.ssh` and `~/.appstoreconnect/private_keys`) instead of the CLI learning three keychain APIs.
|
||||||
|
- **App folders** are the machine's list `~/.storebound/projects.json` (`src-tauri/src/registry.rs`, the CLI's
|
||||||
|
cli/lib/registry.ts): `storebound desktop` in an app folder, `storebound apps add`, `ws init` and the App folders
|
||||||
|
screen add to it; `storebound apps folders` lists it; `storebound ws` without a workspace file works over it. The
|
||||||
|
app keeps its per-folder extras (accounts) in `state.json` under the same id and syncs at start, when the list is
|
||||||
|
shown and before a `storebound://open` link (a folder already on the list is selected without a question).
|
||||||
|
|
||||||
## Release, updates and the terminal bridge (D5)
|
## Release, updates and the terminal bridge (D5)
|
||||||
|
|
||||||
- Releases: [desktop-release.md](desktop-release.md) (version authority, privacy gate, macOS signing and
|
- Releases: [desktop-release.md](desktop-release.md) (version authority, privacy gate, macOS signing and
|
||||||
|
|
@ -344,9 +387,10 @@ Download sources (one click on the Dependencies screen or in the first-run guide
|
||||||
- **Updates**: Settings > Updates checks storebound.dev (stable `/desktop/latest.json` or beta
|
- **Updates**: Settings > Updates checks storebound.dev (stable `/desktop/latest.json` or beta
|
||||||
`/desktop/beta/latest.json`) only when asked; Install shows a native dialog, then the updater downloads the bundle,
|
`/desktop/beta/latest.json`) only when asked; Install shows a native dialog, then the updater downloads the bundle,
|
||||||
checks its minisign signature against `plugins.updater.pubkey` and restarts (`update_check`, `update_install`).
|
checks its minisign signature against `plugins.updater.pubkey` and restarts (`update_check`, `update_install`).
|
||||||
- **`storebound://open?path=<absolute folder>`** (sent by `storebound desktop` in an app folder): the host accepts
|
- **`storebound://open?path=<absolute folder>`** (sent by `storebound desktop` in an app folder, which first puts
|
||||||
one plain absolute path (`policy::deep_link_open_path`), selects that app folder when it is in the list, otherwise
|
the folder on the machine's list): the host accepts one plain absolute path (`policy::deep_link_open_path`), syncs
|
||||||
asks in a native dialog and adds it (`deep-link-folder` event). A folder without a storebound config gets a message,
|
the list, selects that app folder when it is on it, otherwise asks in a native dialog and adds it
|
||||||
|
(`deep-link-folder` event). A folder without a storebound config gets a message,
|
||||||
nothing else.
|
nothing else.
|
||||||
- **`storebound-desktop --self-test`**: starts the bundled sidecar, checks the `session.hello` handshake, prints one
|
- **`storebound-desktop --self-test`**: starts the bundled sidecar, checks the `session.hello` handshake, prints one
|
||||||
JSON line and exits, before Tauri starts (no window). The release runs it on the exact artifacts.
|
JSON line and exits, before Tauri starts (no window). The release runs it on the exact artifacts.
|
||||||
|
|
@ -368,7 +412,7 @@ Download sources (one click on the Dependencies screen or in the first-run guide
|
||||||
from list. Portfolio reads skip them.
|
from list. Portfolio reads skip them.
|
||||||
- **Error screens.** A React error boundary around the whole window and one per screen and app tab show the error,
|
- **Error screens.** A React error boundary around the whole window and one per screen and app tab show the error,
|
||||||
Copy details, Reload and Reset saved state (keeps a backup: `state_reset` moves `state.json` to
|
Copy details, Reload and Reset saved state (keeps a backup: `state_reset` moves `state.json` to
|
||||||
`state.json.reset-<time>.bak` after a native confirmation; keys stay in the keychain). `public/boot.js` runs before
|
`state.json.reset-<time>.bak` after a native confirmation; keys stay in `~/.storebound/credentials`). `public/boot.js` runs before
|
||||||
the bundle and shows the same panel in plain DOM when the bundle fails or the window stays empty.
|
the bundle and shows the same panel in plain DOM when the bundle fails or the window stays empty.
|
||||||
- **Logs** in `<app data>/logs`: `host.log` (start, saved state and migration, sidecar start / stop with its last
|
- **Logs** in `<app data>/logs`: `host.log` (start, saved state and migration, sidecar start / stop with its last
|
||||||
stderr lines, panics) and `webview.log` (window.onerror, unhandled rejections, boundaries, through `log_webview`,
|
stderr lines, panics) and `webview.log` (window.onerror, unhandled rejections, boundaries, through `log_webview`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
# Getting started
|
# Getting started
|
||||||
|
|
||||||
|
**The model.** storebound is installed once per machine (`npx storebound` / the desktop app, one version number).
|
||||||
|
Each project has its `storebound.config.ts` and a gitignored `.storebound/` folder (upload keystore, review contact
|
||||||
|
and demo account). Store accounts live once per machine in `~/.storebound/credentials` (shared by CLI and app:
|
||||||
|
`storebound accounts`), and so does the list of app folders (`~/.storebound/projects.json`: `storebound apps folders`,
|
||||||
|
the desktop's App folders). `npx storebound desktop` installs the app (asks once) and opens it on the current folder.
|
||||||
|
|
||||||
## 5-minute quick start
|
## 5-minute quick start
|
||||||
|
|
||||||
You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
|
You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
|
||||||
|
|
@ -75,7 +81,22 @@ the age rating config, lanes and signing (`match`) are listed with their storebo
|
||||||
|
|
||||||
## Credentials (never in the repo)
|
## Credentials (never in the repo)
|
||||||
|
|
||||||
Read from environment variables, or from `.storebound/credentials.json` in your project (gitignored by `init`):
|
Store accounts belong to the machine, everything else to the project:
|
||||||
|
|
||||||
|
- **Store accounts** (App Store Connect team key, Play service account, vendor number): a credential profile in
|
||||||
|
`~/.storebound/credentials/<name>.json`, its key files next to it in `<name>/` (mode 0600). The desktop app adds and
|
||||||
|
reads the same profiles. Already have the keys in a project's `.storebound/credentials.json`? Move them once:
|
||||||
|
`npx storebound accounts import --dry`, then `npx storebound accounts import --yes` in every project: the same key
|
||||||
|
id or service account is one profile (`default` for the first one), and the project file keeps the rest and gets
|
||||||
|
`"account": "<name>"`. `npx storebound accounts` lists the profiles and which one this folder uses.
|
||||||
|
- **Which profile:** `--account <name>` for one run, `account: '<name>'` in the config, `"account"` in the project's
|
||||||
|
`.storebound/credentials.json`, else the only profile on the machine (or the one named `default`).
|
||||||
|
- **Per project:** the Android upload keystore, the App Review contact and demo account stay in the project's
|
||||||
|
`.storebound/credentials.json`.
|
||||||
|
- **Order:** environment variables win over everything, then a profile the project names, then the project file,
|
||||||
|
then the default profile.
|
||||||
|
|
||||||
|
The names (env, profile and project file alike; the project file is gitignored by `init`):
|
||||||
|
|
||||||
| name | what |
|
| name | what |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -87,9 +108,8 @@ Read from environment variables, or from `.storebound/credentials.json` in your
|
||||||
| `reviewContact` | (file only) App Review contact name / phone / email |
|
| `reviewContact` | (file only) App Review contact name / phone / email |
|
||||||
| `reviewDemoAccount` / `REVIEW_DEMO_USER`, `REVIEW_DEMO_PASSWORD` | demo account for App Review (`reviewAccess.signInRequired`); never in the config or the repo, masked in logs |
|
| `reviewDemoAccount` / `REVIEW_DEMO_USER`, `REVIEW_DEMO_PASSWORD` | demo account for App Review (`reviewAccess.signInRequired`); never in the config or the repo, masked in logs |
|
||||||
|
|
||||||
Several store accounts on one machine: credential profiles (`~/.storebound/credentials/<profile>.json`, the same names
|
Several store accounts on one machine: one profile each, picked as above or by a workspace
|
||||||
except the per-app upload keystore)
|
([Many apps and studios](studios.md#many-apps-and-studios)).
|
||||||
picked with `--account <profile>` or by a workspace ([Many apps and studios](studios.md#many-apps-and-studios)).
|
|
||||||
|
|
||||||
|
|
||||||
## Install for your AI tool
|
## Install for your AI tool
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,11 @@ a folder takes the store apps its config names, the remaining iOS and Android ap
|
||||||
`unpairs` keep two apart, and the rest are store-only apps that `ws status` reads too.
|
`unpairs` keep two apart, and the rest are store-only apps that `ws status` reads too.
|
||||||
|
|
||||||
**The desktop app** keeps the same model (studios, accounts, folders, overrides). `ws desktop import` turns its
|
**The desktop app** keeps the same model (studios, accounts, folders, overrides). `ws desktop import` turns its
|
||||||
`state.json` (or `--state <file>`) into a workspace file and lists the credential profiles to create: the desktop's
|
`state.json` (or `--state <file>`) into a workspace file; its accounts name the credential profiles the app and the
|
||||||
keychain is never read. `ws desktop export` prints the workspace as a desktop state (`--out <file>`), and
|
CLI share (`~/.storebound/credentials`), so the keys are already there. `ws desktop export` prints the workspace as a
|
||||||
`--merge --apply` adds it to the desktop's `state.json` while the app is closed (a backup is kept; nothing is
|
desktop state (`--out <file>`), and `--merge --apply` adds it to the desktop's `state.json` while the app is closed (a
|
||||||
removed; new accounts get their keys in the desktop's Accounts screen).
|
backup is kept; nothing is removed). Without a workspace file, `ws` works over the machine's app folder list
|
||||||
|
(`~/.storebound/projects.json`, the desktop's App folders).
|
||||||
|
|
||||||
MCP: `workspace_list` (offline) and `workspace_status` (read-only).
|
MCP: `workspace_list` (offline) and `workspace_status` (read-only).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"desktopVersion": "0.1.0",
|
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "CRTkafa",
|
"author": "CRTkafa",
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,7 @@ Rejection risks: 2 blocker, 3 high, 0 medium, 0 low
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<p class="kicker">3 · The desktop app</p>
|
<p class="kicker">3 · The desktop app</p>
|
||||||
<h2 id="desktop-h">Every app on both stores, in one window.</h2>
|
<h2 id="desktop-h">Every app on both stores, in one window.</h2>
|
||||||
<p class="sub">Releases, charts, catalog and prices side by side, crash health, reviews, TestFlight and the rejection checks, for every app and game you ship. Send for review from there too: a dry run first, then a confirm. Same storebound underneath; store keys stay in the system keychain.</p>
|
<p class="sub">Releases, charts, catalog and prices side by side, crash health, reviews, TestFlight and the rejection checks, for every app and game you ship. Send for review from there too: a dry run first, then a confirm. Same storebound underneath, with the CLI's version number, store accounts and app folders: <code>npx storebound desktop</code> installs it and opens your app folder.</p>
|
||||||
<div class="tabs desk-tabs" data-tabs>
|
<div class="tabs desk-tabs" data-tabs>
|
||||||
<div class="tablist" role="tablist" aria-label="Desktop app screens">
|
<div class="tablist" role="tablist" aria-label="Desktop app screens">
|
||||||
<button role="tab" id="dt-send-for-review" aria-controls="dp-send-for-review" aria-selected="true">Send for review</button>
|
<button role="tab" id="dt-send-for-review" aria-controls="dp-send-for-review" aria-selected="true">Send for review</button>
|
||||||
|
|
@ -516,9 +516,9 @@ Rejection risks: 2 blocker, 3 high, 0 medium, 0 low
|
||||||
<li>Checksum: <code>shasum -a 256 <file></code> (macOS, Linux) or <code>Get-FileHash <file></code> (Windows) must print the SHA-256 above.</li>
|
<li>Checksum: <code>shasum -a 256 <file></code> (macOS, Linux) or <code>Get-FileHash <file></code> (Windows) must print the SHA-256 above.</li>
|
||||||
<li>Signature: every file has a <code>.sig</code> next to it (the app's update key, minisign). With <a href="https://jedisct1.github.io/minisign/" target="_blank" rel="noopener">minisign<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>: <code>base64 -d <file>.sig > f.minisig && minisign -Vm <file> -x f.minisig -P <key></code>, with the public key
|
<li>Signature: every file has a <code>.sig</code> next to it (the app's update key, minisign). With <a href="https://jedisct1.github.io/minisign/" target="_blank" rel="noopener">minisign<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>: <code>base64 -d <file>.sig > f.minisig && minisign -Vm <file> -x f.minisig -P <key></code>, with the public key
|
||||||
<code class="dl-key">RWQqOO/ZeIr3RBmolt/fx108m89QNEUndRTFrucjx0CBMoeFb7/xhrvW</code></li>
|
<code class="dl-key">RWQqOO/ZeIr3RBmolt/fx108m89QNEUndRTFrucjx0CBMoeFb7/xhrvW</code></li>
|
||||||
<li>Or let the CLI do both: <code>storebound desktop --download</code> saves the installer to ~/Downloads after checking its signature (it never installs anything).</li>
|
<li>Or let the CLI do both: <code>npx storebound desktop</code> downloads the app for your system, checks its size, SHA-256 and signature, installs it for your user (no admin rights; it asks first) and opens it on the current app folder. <code>--download</code> only saves the checked installer to ~/Downloads.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="fine">The app updates itself from this site: each update is signed, and the app checks the signature before it replaces anything. It asks before installing. The npm package <code>storebound</code> stays the CLI, MCP server and agent skills; the app brings its own copy of the CLI.</p>
|
<p class="fine">One product, one version: the app and the npm package <code>storebound</code> (CLI, MCP server, agent skills) are released together with the same number, and the app runs its own copy of that CLI. They share the store accounts (<code>~/.storebound/credentials</code>) and the list of app folders. The app also updates itself from this site: each update is signed, and the app checks the signature before it replaces anything. It asks before installing.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -625,7 +625,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||||
<ul class="safety">
|
<ul class="safety">
|
||||||
<li><h3>Dry run everywhere</h3><p>Every write command takes <code>--dry</code>; every MCP write tool has <code>dry_run</code>, <strong>default true</strong>.</p></li>
|
<li><h3>Dry run everywhere</h3><p>Every write command takes <code>--dry</code>; every MCP write tool has <code>dry_run</code>, <strong>default true</strong>.</p></li>
|
||||||
<li><h3>The owner's buttons stay yours</h3><p>Submitting, releasing, completing a rollout, canceling a submission, deleting and creating offer codes are not MCP tools; the terminal commands ask first.</p></li>
|
<li><h3>The owner's buttons stay yours</h3><p>Submitting, releasing, completing a rollout, canceling a submission, deleting and creating offer codes are not MCP tools; the terminal commands ask first.</p></li>
|
||||||
<li><h3>Credentials stay local</h3><p>Env vars or the gitignored <code>.storebound/credentials.json</code>; the desktop app uses the system keychain. Official store APIs only, with keys you create and can revoke, never your password.</p></li>
|
<li><h3>Credentials stay local</h3><p>Store accounts once per machine in <code>~/.storebound/credentials</code> (files only you can read, shared by the CLI and the desktop app), the rest in the project's gitignored <code>.storebound/</code>. Official store APIs only, with keys you create and can revoke, never your password.</p></li>
|
||||||
<li><h3>Idempotent writes</h3><p>Re-running converges (MD5 checks for screenshots and previews, one Play edit per language), so a failed upload is fixed by running it again.</p></li>
|
<li><h3>Idempotent writes</h3><p>Re-running converges (MD5 checks for screenshots and previews, one Play edit per language), so a failed upload is fixed by running it again.</p></li>
|
||||||
<li><h3>Personal data stays local</h3><p>Tester lists, the App Review demo account, reply drafts and reports live in <code>.storebound/</code>, masked in every log line.</p></li>
|
<li><h3>Personal data stays local</h3><p>Tester lists, the App Review demo account, reply drafts and reports live in <code>.storebound/</code>, masked in every log line.</p></li>
|
||||||
<li><h3>Careful with your devices</h3><p>storebound shuts down only a simulator it booted itself, and never boots, wipes or reboots an Android emulator or device.</p></li>
|
<li><h3>Careful with your devices</h3><p>storebound shuts down only a simulator it booted itself, and never boots, wipes or reboots an Android emulator or device.</p></li>
|
||||||
|
|
@ -706,7 +706,8 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||||
<div class="st-panel" id="sp-1">
|
<div class="st-panel" id="sp-1">
|
||||||
<div class="pv pv-term">
|
<div class="pv pv-term">
|
||||||
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="npx storebound init --profile app" aria-label="Copy step 1 command">Copy</button></div>
|
<div class="pv-bar"><span class="pv-k">Terminal</span><button class="copy" type="button" data-copy="npx storebound init --profile app" aria-label="Copy step 1 command">Copy</button></div>
|
||||||
<pre><code><span class="p">$</span> npx storebound init --profile app</code></pre>
|
<pre><code><span class="p">$</span> npx storebound init --profile app
|
||||||
|
<span class="p">$</span> npx storebound desktop <span class="c"># optional: installs the app once, opens this folder</span></code></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="pv-row">
|
<div class="pv-row">
|
||||||
<span class="pv-label">Profiles</span>
|
<span class="pv-label">Profiles</span>
|
||||||
|
|
@ -715,9 +716,9 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||||
<ul class="tree" aria-label="Files init adds">
|
<ul class="tree" aria-label="Files init adds">
|
||||||
<li><code>storebound.config.ts</code><span>ids, languages, products, Data safety</span></li>
|
<li><code>storebound.config.ts</code><span>ids, languages, products, Data safety</span></li>
|
||||||
<li><code>store/</code><span>listing text, questionnaire answers, screenshot captions</span></li>
|
<li><code>store/</code><span>listing text, questionnaire answers, screenshot captions</span></li>
|
||||||
<li><code>.storebound/</code><span>your keys, gitignored</span></li>
|
<li><code>.storebound/</code><span>this app's upload key and review contact, gitignored</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="pv-note">Needs Node 20+ (or <a href="https://bun.sh" target="_blank" rel="noopener">Bun<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> 1.1+ with <code>bunx</code>), plus <code>ffmpeg</code> for icons, screenshots and video.</p>
|
<p class="pv-note">storebound is installed once per machine; each project has its <code>storebound.config.ts</code> and <code>.storebound/</code>. Needs Node 20+ (or <a href="https://bun.sh" target="_blank" rel="noopener">Bun<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a> 1.1+ with <code>bunx</code>), plus <code>ffmpeg</code> for icons, screenshots and video.</p>
|
||||||
<div class="st-foot" hidden><span>Step 1 of 5</span><button class="st-next" type="button" data-next="1">Next: Describe the app once<span class="ic ic-caret-right" aria-hidden="true"></span></button></div>
|
<div class="st-foot" hidden><span>Step 1 of 5</span><button class="st-next" type="button" data-next="1">Next: Describe the app once<span class="ic ic-caret-right" aria-hidden="true"></span></button></div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -756,7 +757,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||||
</div>
|
</div>
|
||||||
<div class="st-panel" id="sp-3">
|
<div class="st-panel" id="sp-3">
|
||||||
<div class="pv pv-file">
|
<div class="pv pv-file">
|
||||||
<div class="pv-bar"><span class="pv-k">.storebound/credentials.json</span><span class="pv-tag">gitignored</span><button class="copy" type="button" data-copy-target="how-creds" aria-label="Copy credentials template">Copy</button></div>
|
<div class="pv-bar"><span class="pv-k">~/.storebound/credentials/default.json</span><span class="pv-tag">once per machine</span><button class="copy" type="button" data-copy-target="how-creds" aria-label="Copy credentials template">Copy</button></div>
|
||||||
<pre id="how-creds"><code>{
|
<pre id="how-creds"><code>{
|
||||||
<span class="s">"PLAY_SERVICE_ACCOUNT"</span>: <span class="s">"/keys/play.json"</span>,
|
<span class="s">"PLAY_SERVICE_ACCOUNT"</span>: <span class="s">"/keys/play.json"</span>,
|
||||||
<span class="s">"ASC_KEY_ID"</span>: <span class="s">"KEY_ID"</span>,
|
<span class="s">"ASC_KEY_ID"</span>: <span class="s">"KEY_ID"</span>,
|
||||||
|
|
@ -764,7 +765,7 @@ com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||||
<span class="s">"ASC_P8"</span>: <span class="s">"/keys/AuthKey_KEY_ID.p8"</span>
|
<span class="s">"ASC_P8"</span>: <span class="s">"/keys/AuthKey_KEY_ID.p8"</span>
|
||||||
}</code></pre>
|
}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="pv-note">A Play service account and an App Store Connect API key. Env vars work too. Never your password, never committed.</p>
|
<p class="pv-note">A Play service account and an App Store Connect API key, once for every project on this machine (the desktop app's Accounts screen writes the same file; keys already in a project: <code>npx storebound accounts import</code>). Env vars work too. Never your password, never committed.</p>
|
||||||
<div class="st-foot" hidden><span>Step 3 of 5</span><button class="st-next" type="button" data-next="3">Next: Connect your agent, ask in plain words<span class="ic ic-caret-right" aria-hidden="true"></span></button></div>
|
<div class="st-foot" hidden><span>Step 3 of 5</span><button class="st-next" type="button" data-next="3">Next: Connect your agent, ask in plain words<span class="ic ic-caret-right" aria-hidden="true"></span></button></div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@ import { describe, expect, test } from 'bun:test';
|
||||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems, commitProblems } from '../tools/desktop-release';
|
import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems, commitProblems, releaseTogetherProblems } from '../tools/desktop-release';
|
||||||
import { COPIES, setVersion, versions } from '../tools/desktop-version';
|
import { COPIES, setVersion, versions } from '../tools/desktop-version';
|
||||||
|
|
||||||
describe('desktop version authority', () => {
|
describe('desktop version authority', () => {
|
||||||
test('the repository has one desktop version', () => {
|
test('the repository has one version: the CLI, the plugin manifests and the desktop app', () => {
|
||||||
const { version, drift } = versions();
|
const { version, drift } = versions();
|
||||||
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
|
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
expect(drift).toEqual([]);
|
expect(drift).toEqual([]);
|
||||||
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '../package.json'), 'utf8'));
|
||||||
|
expect(version).toBe(pkg.version);
|
||||||
|
expect(pkg.desktopVersion).toBeUndefined();
|
||||||
|
expect(COPIES.map(c => c.file)).toContain('apps/desktop/src-tauri/tauri.conf.json');
|
||||||
|
expect(COPIES[0]!.file).toBe('package.json');
|
||||||
});
|
});
|
||||||
test('drift is found and --set fixes every copy', () => {
|
test('drift is found and --set fixes every copy', () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'sb-ver-'));
|
const root = mkdtempSync(join(tmpdir(), 'sb-ver-'));
|
||||||
|
|
@ -20,6 +25,8 @@ describe('desktop version authority', () => {
|
||||||
expect(versions(root).drift.map(d => d.file)).toEqual(['apps/desktop/src-tauri/tauri.conf.json']);
|
expect(versions(root).drift.map(d => d.file)).toEqual(['apps/desktop/src-tauri/tauri.conf.json']);
|
||||||
setVersion('0.2.0-beta.1', root);
|
setVersion('0.2.0-beta.1', root);
|
||||||
expect(versions(root)).toEqual({ version: '0.2.0-beta.1', drift: [] });
|
expect(versions(root)).toEqual({ version: '0.2.0-beta.1', drift: [] });
|
||||||
|
expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version).toBe('0.2.0-beta.1');
|
||||||
|
expect(JSON.parse(readFileSync(join(root, '.claude-plugin/marketplace.json'), 'utf8')).plugins[0].version).toBe('0.2.0-beta.1');
|
||||||
// only the package's own version line of Cargo.toml / Cargo.lock changes
|
// only the package's own version line of Cargo.toml / Cargo.lock changes
|
||||||
expect(readFileSync(join(root, 'apps/desktop/src-tauri/Cargo.toml'), 'utf8')).toContain('tauri = { version = "2.12"');
|
expect(readFileSync(join(root, 'apps/desktop/src-tauri/Cargo.toml'), 'utf8')).toContain('tauri = { version = "2.12"');
|
||||||
expect(() => setVersion('latest', root)).toThrow();
|
expect(() => setVersion('latest', root)).toThrow();
|
||||||
|
|
@ -27,6 +34,11 @@ describe('desktop version authority', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('desktop release', () => {
|
describe('desktop release', () => {
|
||||||
|
test('the app and the npm CLI are published with one version', () => {
|
||||||
|
expect(releaseTogetherProblems('0.8.0', '0.8.0')).toEqual([]);
|
||||||
|
expect(releaseTogetherProblems('0.1.0', '0.8.0')[0]).toMatch(/one version/);
|
||||||
|
expect(releaseTogetherProblems('0.8.0', null)).toHaveLength(1);
|
||||||
|
});
|
||||||
test('signing refuses SSH and non-GUI sessions', () => {
|
test('signing refuses SSH and non-GUI sessions', () => {
|
||||||
expect(remoteSessionReason({}, 'Aqua')).toBeNull();
|
expect(remoteSessionReason({}, 'Aqua')).toBeNull();
|
||||||
expect(remoteSessionReason({ SSH_CONNECTION: '1 2 3 4' }, 'Aqua')).toMatch(/SSH/);
|
expect(remoteSessionReason({ SSH_CONNECTION: '1 2 3 4' }, 'Aqua')).toMatch(/SSH/);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
// Loaded before every test file (bunfig.toml): no real waiting between the re-checks of cli/lib/upsert.ts or for
|
// Loaded before every test file (bunfig.toml): no real waiting between the re-checks of cli/lib/upsert.ts or for
|
||||||
// the store's list to settle after a create (cli/apple/iap.ts).
|
// the store's list to settle after a create (cli/apple/iap.ts).
|
||||||
import { afterEach } from 'bun:test';
|
import { afterEach } from 'bun:test';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
process.env.STOREBOUND_UPSERT_WAIT_MS = '0';
|
process.env.STOREBOUND_UPSERT_WAIT_MS = '0';
|
||||||
process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
|
process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
|
||||||
|
|
||||||
|
// The machine-wide stores (credential profiles, the app folder list) of the person running the tests are never read
|
||||||
|
// or written: every test process gets empty ones (a test that needs content points these at its own folder).
|
||||||
|
const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-'));
|
||||||
|
process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials');
|
||||||
|
process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json');
|
||||||
|
|
||||||
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
|
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
|
||||||
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.
|
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.
|
||||||
afterEach(() => { process.exitCode = 0; });
|
afterEach(() => { process.exitCode = 0; });
|
||||||
|
|
|
||||||
231
tests/machine.test.ts
Normal file
231
tests/machine.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
// One product per machine: credential profiles shared by every project and the desktop app (precedence, `accounts
|
||||||
|
// import` dedup and file modes), the machine's app folder list, and what `storebound desktop` decides and installs.
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { applyImport, planImport, useProfile } from '../cli/lib/accounts';
|
||||||
|
import { activeProfile, androidSigning, ascKey, defaultProfile, playServiceAccount, reviewContact, setConfigAccount } from '../cli/lib/credentials';
|
||||||
|
import { compareVersions, consent, decide, installPlan, isRunning, question, runningCheck } from '../cli/lib/desktop-install';
|
||||||
|
import { addFolder, folderId, parseRegistry, readRegistry, removeFolder, withFolder, withoutFolder } from '../cli/lib/registry';
|
||||||
|
import { desktopProjectId } from '../cli/lib/workspace';
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, '..');
|
||||||
|
const PEM = (s: string) => ['-----BEGIN PRIVATE KEY-----', s, '-----END PRIVATE KEY-----', ''].join('\n');
|
||||||
|
const SA = (email: string) => JSON.stringify({ type: 'service_account', client_email: email, private_key: PEM('play-secret-sentinel'), token_uri: 'https://oauth2.googleapis.com/token' });
|
||||||
|
|
||||||
|
/** Runs `fn` with an empty credentials folder and registry, and no store env; restores everything after. */
|
||||||
|
function machine(fn: (m: { creds: string; base: string }) => void) {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
const base = mkdtempSync(join(tmpdir(), 'sb-machine-'));
|
||||||
|
try {
|
||||||
|
for (const k of Object.keys(process.env)) if (/^(ASC_|PLAY_SERVICE|STOREBOUND_PROFILE|ANDROID_)/.test(k)) delete process.env[k];
|
||||||
|
process.env.STOREBOUND_CREDENTIALS_DIR = join(base, 'credentials');
|
||||||
|
process.env.STOREBOUND_REGISTRY = join(base, 'projects.json');
|
||||||
|
fn({ creds: join(base, 'credentials'), base });
|
||||||
|
} finally {
|
||||||
|
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
||||||
|
Object.assign(process.env, saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A project whose .storebound/credentials.json holds the shared store keys (as files in the project) and its own keystore. */
|
||||||
|
function project(base: string, name: string, o: { keyId?: string; email?: string } = {}): string {
|
||||||
|
const dir = join(base, name);
|
||||||
|
mkdirSync(join(dir, '.storebound'), { recursive: true });
|
||||||
|
writeFileSync(join(dir, 'storebound.config.json'), '{}');
|
||||||
|
writeFileSync(join(dir, '.storebound', `AuthKey_${o.keyId ?? 'ABCDE12345'}.p8`), PEM('apple-secret-sentinel'));
|
||||||
|
writeFileSync(join(dir, '.storebound', 'play.json'), SA(o.email ?? 'release@example.com'));
|
||||||
|
writeFileSync(join(dir, '.storebound', 'credentials.json'), JSON.stringify({
|
||||||
|
ASC_KEY_ID: o.keyId ?? 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: join(dir, '.storebound', `AuthKey_${o.keyId ?? 'ABCDE12345'}.p8`),
|
||||||
|
PLAY_SERVICE_ACCOUNT: join(dir, '.storebound', 'play.json'), ASC_VENDOR_NUMBER: '12345678',
|
||||||
|
ANDROID_KEYSTORE: `${name}.jks`, ANDROID_KEYSTORE_PASSWORD: `${name}-pw`, ANDROID_KEY_ALIAS: 'upload',
|
||||||
|
reviewContact: { firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100', email: `review@${name}.example.org` },
|
||||||
|
}, null, 2));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('credentials: one machine, many projects', () => {
|
||||||
|
test('project-only values from the project, store keys from the profile, env over everything', () => machine(({ creds, base }) => {
|
||||||
|
const a = project(base, 'Star Dash');
|
||||||
|
// the project's own keys work without any profile
|
||||||
|
expect(ascKey(a).keyId).toBe('ABCDE12345');
|
||||||
|
mkdirSync(creds, { recursive: true });
|
||||||
|
writeFileSync(join(creds, 'other.json'), JSON.stringify({ ASC_KEY_ID: 'OTHER00000', ASC_ISSUER_ID: 'iss', ASC_P8: PEM('x') }));
|
||||||
|
// the default profile (the only one) never beats keys the project holds itself
|
||||||
|
expect(defaultProfile()).toBe('other');
|
||||||
|
expect(activeProfile(a)).toEqual({ name: 'other', from: 'default' });
|
||||||
|
expect(ascKey(a).keyId).toBe('ABCDE12345');
|
||||||
|
// a named profile does ("account" in the project file, then the config, then --account / env)
|
||||||
|
const file = join(a, '.storebound', 'credentials.json');
|
||||||
|
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, 'utf8')), account: 'other' }));
|
||||||
|
expect(activeProfile(a)).toEqual({ name: 'other', from: 'project' });
|
||||||
|
expect(ascKey(a).keyId).toBe('OTHER00000');
|
||||||
|
setConfigAccount(a, 'nope');
|
||||||
|
expect(activeProfile(a)?.from).toBe('config');
|
||||||
|
setConfigAccount(a, undefined);
|
||||||
|
process.env.ASC_KEY_ID = 'ENV0000000';
|
||||||
|
expect(ascKey(a).keyId).toBe('ENV0000000');
|
||||||
|
delete process.env.ASC_KEY_ID;
|
||||||
|
// the app's own things never come from a profile
|
||||||
|
expect(androidSigning(a)?.keyAlias).toBe('upload');
|
||||||
|
expect(reviewContact(a)?.firstName).toBe('Ada');
|
||||||
|
// STOREBOUND_PROFILE set but empty: no profile at all
|
||||||
|
process.env.STOREBOUND_PROFILE = '';
|
||||||
|
expect(activeProfile(a)).toBeNull();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('accounts import: two projects with the same Apple key and Play account -> one profile, 0600 files, projects stay valid', () => machine(({ creds, base }) => {
|
||||||
|
const a = project(base, 'Star Dash'), b = project(base, 'a game');
|
||||||
|
const dry = planImport(a);
|
||||||
|
expect(dry.profile).toBe('default');
|
||||||
|
expect(dry.reuse).toBe(false);
|
||||||
|
expect(dry.moved.sort()).toEqual(['ASC_ISSUER_ID', 'ASC_KEY_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_SERVICE_ACCOUNT']);
|
||||||
|
expect(existsSync(creds)).toBe(false); // planning writes nothing
|
||||||
|
const r = applyImport(dry);
|
||||||
|
expect(r.backup && existsSync(r.backup)).toBeTruthy();
|
||||||
|
const files = readdirSync(join(creds, 'default')).sort();
|
||||||
|
expect(files).toEqual(['app-store-connect.p8', 'play-service-account.json']);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
for (const f of files) expect(statSync(join(creds, 'default', f)).mode & 0o777).toBe(0o600);
|
||||||
|
expect(statSync(join(creds, 'default.json')).mode & 0o777).toBe(0o600);
|
||||||
|
expect(statSync(join(creds, 'default')).mode & 0o777).toBe(0o700);
|
||||||
|
expect(statSync(join(a, '.storebound', 'credentials.json')).mode & 0o777).toBe(0o600);
|
||||||
|
}
|
||||||
|
// the profile refers to its key files by path; the project file keeps the app's own things and names the profile
|
||||||
|
const prof = JSON.parse(readFileSync(join(creds, 'default.json'), 'utf8'));
|
||||||
|
expect(prof.ASC_P8).toBe('default/app-store-connect.p8');
|
||||||
|
expect(JSON.stringify(prof)).not.toContain('sentinel');
|
||||||
|
const projA = JSON.parse(readFileSync(join(a, '.storebound', 'credentials.json'), 'utf8'));
|
||||||
|
expect(projA.account).toBe('default');
|
||||||
|
expect(projA.ASC_KEY_ID).toBeUndefined();
|
||||||
|
expect(projA.ANDROID_KEYSTORE).toBe('Star Dash.jks');
|
||||||
|
expect(ascKey(a).pem).toContain('apple-secret-sentinel');
|
||||||
|
expect(playServiceAccount(a).client_email).toBe('release@example.com');
|
||||||
|
// the second project with the same account: the same profile, its files unchanged
|
||||||
|
const second = planImport(b);
|
||||||
|
expect(second.profile).toBe('default');
|
||||||
|
expect(second.reuse).toBe(true);
|
||||||
|
expect(second.writes.filter(w => w.path.startsWith(creds)).map(w => w.action)).toEqual(['same']);
|
||||||
|
applyImport(second);
|
||||||
|
expect(JSON.parse(readFileSync(join(b, '.storebound', 'credentials.json'), 'utf8')).ANDROID_KEYSTORE_PASSWORD).toBe('a game-pw');
|
||||||
|
expect(ascKey(b).keyId).toBe('ABCDE12345');
|
||||||
|
// running it again changes nothing
|
||||||
|
expect(planImport(b).writes.every(w => w.action === 'same')).toBe(true);
|
||||||
|
expect(planImport(b).notes[0]).toMatch(/already imported/);
|
||||||
|
// another team: a profile of its own; --name may not take over an existing profile's account
|
||||||
|
const c = project(base, 'other-studio', { keyId: 'ZZZZZ99999', email: 'other@example.com' });
|
||||||
|
expect(() => planImport(c, { name: 'default' })).toThrow(/another App Store Connect key/);
|
||||||
|
const third = planImport(c);
|
||||||
|
expect(third.profile).toBe('other-studio');
|
||||||
|
applyImport(third);
|
||||||
|
expect(ascKey(c).keyId).toBe('ZZZZZ99999');
|
||||||
|
expect(ascKey(a).keyId).toBe('ABCDE12345'); // "account": "default" still points Star Dash at its own
|
||||||
|
expect(useProfile(c, 'default')).toContain('credentials.json');
|
||||||
|
expect(() => useProfile(c, 'missing')).toThrow(/no profile/);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('the import command prints paths and names, never key values', () => machine(({ base }) => {
|
||||||
|
const a = project(base, 'Star Dash');
|
||||||
|
const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'accounts', 'import', '--dry', '--dir', a], { env: { ...process.env, STOREBOUND_NO_NETWORK: '1' } });
|
||||||
|
const out = r.stdout.toString() + r.stderr.toString();
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expect(out).toContain('profile "default"');
|
||||||
|
expect(out).not.toContain('sentinel');
|
||||||
|
expect(out).not.toContain('ABCDE12345');
|
||||||
|
const no = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'accounts', 'import', '--dir', a], { stdin: 'ignore', env: { ...process.env, STOREBOUND_NO_NETWORK: '1' } });
|
||||||
|
expect(no.exitCode).toBe(3); // not a terminal and no --yes
|
||||||
|
expect(existsSync(join(process.env.STOREBOUND_CREDENTIALS_DIR!, 'default.json'))).toBe(false);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the machine\'s app folder list', () => {
|
||||||
|
test('add, re-add, remove; ids are the desktop export ids; bad entries are dropped', () => machine(({ base }) => {
|
||||||
|
const r0 = { version: 1 as const, projects: [] };
|
||||||
|
const { registry, added } = withFolder(r0, '/w/Star Dash', 'Star Dash', 't');
|
||||||
|
expect(added).toBe(true);
|
||||||
|
expect(registry.projects[0]!.id).toBe(desktopProjectId('/w/Star Dash'));
|
||||||
|
expect(withFolder(registry, '/w/Star Dash/', undefined, 't').added).toBe(false);
|
||||||
|
expect(withoutFolder(registry, folderId('/w/Star Dash')).removed?.name).toBe('Star Dash');
|
||||||
|
expect(parseRegistry('{"projects":[{"path":"relative"},{"path":"/a","id":"zz"},{"path":"/a"}]}').projects.map(p => [p.path, p.id])).toEqual([['/a', folderId('/a')]]);
|
||||||
|
expect(parseRegistry('not json').projects).toEqual([]);
|
||||||
|
const dir = project(base, 'a game');
|
||||||
|
expect(addFolder(dir).added).toBe(true);
|
||||||
|
expect(addFolder(dir).added).toBe(false);
|
||||||
|
expect(readRegistry().projects.map(p => p.path)).toEqual([dir]);
|
||||||
|
// `storebound apps folders` reads the same file the desktop does
|
||||||
|
const out = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'apps', 'folders', '--json'], { env: { ...process.env, STOREBOUND_NO_NETWORK: '1' } }).stdout.toString();
|
||||||
|
expect(JSON.parse(out).projects[0].path).toBe(dir);
|
||||||
|
expect(removeFolder(dir)?.path).toBe(dir);
|
||||||
|
expect(readRegistry().projects).toEqual([]);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storebound desktop: one version with the CLI', () => {
|
||||||
|
const app = { path: '/Applications/storebound.app', how: 'bundle' as const };
|
||||||
|
test('versions compare like semver', () => {
|
||||||
|
expect(compareVersions('0.8.0', '0.8.0')).toBe(0);
|
||||||
|
expect(compareVersions('0.7.9', '0.8.0')).toBe(-1);
|
||||||
|
expect(compareVersions('0.10.0', '0.9.1')).toBe(1);
|
||||||
|
expect(compareVersions('0.8.0-beta.1', '0.8.0')).toBe(-1);
|
||||||
|
});
|
||||||
|
test('installed and the same: open; missing: install; older: update; newer: open, downgrade only with --force; nothing published', () => {
|
||||||
|
expect(decide({ installed: app, installedVersion: '0.8.0', cli: '0.8.0', available: null, force: false }).action).toBe('open');
|
||||||
|
expect(decide({ installed: null, installedVersion: null, cli: '0.8.0', available: '0.8.0', force: false })).toEqual({ action: 'install', from: null, to: '0.8.0', notes: [] });
|
||||||
|
const up = decide({ installed: app, installedVersion: '0.7.1', cli: '0.8.0', available: '0.8.0', force: false });
|
||||||
|
expect([up.action, up.from, up.to]).toEqual(['update', '0.7.1', '0.8.0']);
|
||||||
|
const newer = decide({ installed: app, installedVersion: '0.9.0', cli: '0.8.0', available: '0.8.0', force: false });
|
||||||
|
expect(newer.action).toBe('open');
|
||||||
|
expect(newer.notes[0]).toMatch(/newer than this CLI/);
|
||||||
|
expect(decide({ installed: app, installedVersion: '0.9.0', cli: '0.8.0', available: '0.8.0', force: true }).action).toBe('downgrade');
|
||||||
|
// the CLI's own version is not out yet: the newest published one, said so
|
||||||
|
const lag = decide({ installed: null, installedVersion: null, cli: '0.8.1', available: '0.8.0', force: false });
|
||||||
|
expect([lag.action, lag.to]).toEqual(['install', '0.8.0']);
|
||||||
|
expect(lag.notes.join()).toMatch(/0\.8\.0.*0\.8\.1/);
|
||||||
|
expect(decide({ installed: app, installedVersion: '0.7.0', cli: '0.8.0', available: '0.7.0', force: false }).action).toBe('open');
|
||||||
|
expect(decide({ installed: null, installedVersion: null, cli: '0.8.0', available: null, unavailableReason: 'offline', force: false })).toEqual({ action: 'unavailable', from: null, to: null, notes: ['offline'] });
|
||||||
|
});
|
||||||
|
test('no terminal and no --yes: declined (exit 3, the command printed); the question defaults to yes', () => {
|
||||||
|
expect(consent(true, false)).toBe('yes');
|
||||||
|
expect(consent(false, true)).toBe('ask');
|
||||||
|
expect(consent(false, false)).toBe('declined');
|
||||||
|
expect(question({ action: 'install', from: null, to: '0.8.0', notes: [] }, 77_000_000, '~/Applications')).toBe('Install storebound desktop 0.8.0 (77 MB) to ~/Applications? [Y/n] ');
|
||||||
|
});
|
||||||
|
const base = { file: '/tmp/x/storebound_0.8.0_universal.dmg', version: '0.8.0', home: '/Users/you', tmp: '/tmp/x', env: {}, has: () => false, systemAppsWritable: false };
|
||||||
|
test('macOS: from the read-only DMG into ~/Applications (or /Applications), no xattr, an update in place', () => {
|
||||||
|
const p = installPlan({ ...base, platform: 'darwin', kind: 'macos-universal-dmg', existing: null });
|
||||||
|
expect(p.target).toBe('/Users/you/Applications/storebound.app');
|
||||||
|
expect(p.place).toBe('~/Applications');
|
||||||
|
const runs = p.steps.flatMap(s => ('run' in s ? [s.run] : []));
|
||||||
|
expect(runs[0]).toEqual(['hdiutil', 'attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', '/tmp/x/mnt', base.file]);
|
||||||
|
expect(runs.some(r => r[0] === 'hdiutil' && r[1] === 'detach')).toBe(true);
|
||||||
|
expect(JSON.stringify(p.steps)).not.toContain('xattr');
|
||||||
|
expect(p.steps.some(s => 'expectVersion' in s && s.expectVersion.version === '0.8.0')).toBe(true);
|
||||||
|
expect(installPlan({ ...base, platform: 'darwin', kind: 'macos-universal-dmg', existing: null, systemAppsWritable: true }).target).toBe('/Applications/storebound.app');
|
||||||
|
const upd = installPlan({ ...base, platform: 'darwin', kind: 'macos-universal-dmg', existing: app, systemAppsWritable: false });
|
||||||
|
expect(upd.target).toBe('/Applications/storebound.app');
|
||||||
|
expect(upd.steps.map(s => Object.keys(s)[1])).toContain('remove');
|
||||||
|
});
|
||||||
|
test('Windows: the per-user NSIS installer, silent; Linux: AppImage + .desktop with the link handler; .deb only printed', () => {
|
||||||
|
const w = installPlan({ ...base, platform: 'win32', kind: 'windows-x86_64-nsis', file: 'C:\\t\\storebound_0.8.0_x64-setup.exe', existing: null, env: { LOCALAPPDATA: 'C:\\Users\\you\\AppData\\Local' } });
|
||||||
|
expect(w.steps[0]).toMatchObject({ run: ['C:\\t\\storebound_0.8.0_x64-setup.exe', '/S'] });
|
||||||
|
expect(w.target).toBe('C:\\Users\\you\\AppData\\Local\\storebound\\storebound-desktop.exe');
|
||||||
|
const l = installPlan({ ...base, platform: 'linux', kind: 'linux-x86_64-appimage', home: '/home/you', file: '/tmp/x/storebound_0.8.0_amd64.AppImage', existing: null, has: t => t === 'xdg-mime' });
|
||||||
|
expect(l.target).toBe('/home/you/.local/bin/storebound.AppImage');
|
||||||
|
const entry = l.steps.find(s => 'write' in s) as { write: { path: string; content: string } };
|
||||||
|
expect(entry.write.path).toBe('/home/you/.local/share/applications/storebound.desktop');
|
||||||
|
expect(entry.write.content).toContain('MimeType=x-scheme-handler/storebound;');
|
||||||
|
expect(entry.write.content).toContain('Exec="/home/you/.local/bin/storebound.AppImage" %u');
|
||||||
|
expect(l.steps.flatMap(s => ('run' in s ? [s.run[0]] : []))).toEqual(['xdg-mime']);
|
||||||
|
const deb = installPlan({ ...base, platform: 'linux', kind: 'linux-x86_64-deb', home: '/home/you', existing: null });
|
||||||
|
expect(deb.steps).toEqual([]);
|
||||||
|
expect(deb.manual).toMatch(/sudo apt install/);
|
||||||
|
});
|
||||||
|
test('an update never replaces a running app', () => {
|
||||||
|
expect(runningCheck('darwin')).toEqual(['pgrep', '-x', 'storebound-desktop']);
|
||||||
|
expect(isRunning('darwin', 0, '123\n')).toBe(true);
|
||||||
|
expect(isRunning('linux', 1, '')).toBe(false);
|
||||||
|
expect(isRunning('win32', 0, 'INFO: No tasks are running which match the specified criteria.')).toBe(false);
|
||||||
|
expect(isRunning('win32', 0, 'storebound-desktop.exe 4242 Console')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -207,7 +207,8 @@ describe('serve: account methods', () => {
|
||||||
const r = await s.call('apps.list', { credentials: PLAY, args: { icons: true } });
|
const r = await s.call('apps.list', { credentials: PLAY, args: { icons: true } });
|
||||||
expect(r.result).toEqual({ store: 'android', apps: [], warnings: [], at: 'x' });
|
expect(r.result).toEqual({ store: 'android', apps: [], warnings: [], at: 'x' });
|
||||||
expect(s.calls[0]!.argv).toEqual(['apps', 'list', '--store', 'play', '--icons', '--json']);
|
expect(s.calls[0]!.argv).toEqual(['apps', 'list', '--store', 'play', '--icons', '--json']);
|
||||||
expect(s.calls[0]!.o.env).toEqual(PLAY);
|
// exactly this account: no credential profile of the machine mixes in
|
||||||
|
expect(s.calls[0]!.o.env).toEqual({ ...PLAY, STOREBOUND_PROFILE: '' });
|
||||||
expect(existsSync(join(s.calls[0]!.o.cwd, '.storebound'))).toBe(false);
|
expect(existsSync(join(s.calls[0]!.o.cwd, '.storebound'))).toBe(false);
|
||||||
await s.call('account.check', { credentials: APPLE });
|
await s.call('account.check', { credentials: APPLE });
|
||||||
expect(s.calls[1]!.argv).toEqual(['apps', 'check', '--store', 'apple', '--json']);
|
expect(s.calls[1]!.argv).toEqual(['apps', 'check', '--store', 'apple', '--json']);
|
||||||
|
|
|
||||||
|
|
@ -156,13 +156,14 @@ describe('credential profiles', () => {
|
||||||
expect(profileStatus('nobody')).toEqual({ profile: 'nobody', source: 'missing', apple: false, play: false });
|
expect(profileStatus('nobody')).toEqual({ profile: 'nobody', source: 'missing', apple: false, play: false });
|
||||||
expect(JSON.stringify(profileStatus('acme-apple'))).not.toContain(SENTINEL);
|
expect(JSON.stringify(profileStatus('acme-apple'))).not.toContain(SENTINEL);
|
||||||
expect(() => profileEnv('../x')).toThrow('credential profile');
|
expect(() => profileEnv('../x')).toThrow('credential profile');
|
||||||
// a profile chosen for the run wins over env and the project file
|
// env wins over everything; then a named profile (--account), then the project file
|
||||||
const pem = ['-----BEGIN PRIVATE KEY-----', 'abc', '-----END PRIVATE KEY-----'].join('\n');
|
const pem = ['-----BEGIN PRIVATE KEY-----', 'abc', '-----END PRIVATE KEY-----'].join('\n');
|
||||||
writeFileSync(join(creds, 'pick.json'), JSON.stringify({ ASC_KEY_ID: 'PICKED', ASC_ISSUER_ID: 'iss', ASC_P8: pem }));
|
writeFileSync(join(creds, 'pick.json'), JSON.stringify({ ASC_KEY_ID: 'PICKED', ASC_ISSUER_ID: 'iss', ASC_P8: pem }));
|
||||||
process.env.ASC_KEY_ID = 'FROM-ENV';
|
|
||||||
process.env.ASC_ISSUER_ID = 'iss-env';
|
|
||||||
process.env.STOREBOUND_PROFILE = 'pick';
|
process.env.STOREBOUND_PROFILE = 'pick';
|
||||||
expect(ascKey(root).keyId).toBe('PICKED');
|
expect(ascKey(root).keyId).toBe('PICKED');
|
||||||
|
process.env.ASC_KEY_ID = 'FROM-ENV';
|
||||||
|
process.env.ASC_ISSUER_ID = 'iss-env';
|
||||||
|
expect(ascKey(root).keyId).toBe('FROM-ENV');
|
||||||
delete process.env.STOREBOUND_PROFILE;
|
delete process.env.STOREBOUND_PROFILE;
|
||||||
process.env.ASC_P8 = pem;
|
process.env.ASC_P8 = pem;
|
||||||
expect(ascKey(root).keyId).toBe('FROM-ENV');
|
expect(ascKey(root).keyId).toBe('FROM-ENV');
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,32 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
|
||||||
if (!text.includes('Folder not found') || !text.includes('Locate…') || !text.includes('Remove from list')) problems.push(`${p.name}: no "Folder not found" panel with Locate… / Remove`);
|
if (!text.includes('Folder not found') || !text.includes('Locate…') || !text.includes('Remove from list')) problems.push(`${p.name}: no "Folder not found" panel with Locate… / Remove`);
|
||||||
else notes.push(`${p.name}: Folder not found, Locate… / Remove`);
|
else notes.push(`${p.name}: Folder not found, Locate… / Remove`);
|
||||||
}
|
}
|
||||||
|
// shared with the CLI: each account names its credential profile (or that its old keychain key has not moved)
|
||||||
|
const accts = fx.accounts_list as { label?: string; profile?: string | null }[];
|
||||||
|
if (accts.length) {
|
||||||
|
const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first();
|
||||||
|
if (await nav.count()) {
|
||||||
|
await nav.click({ timeout: WAIT_MS });
|
||||||
|
await settle(page);
|
||||||
|
const text = await page.locator('#content').innerText();
|
||||||
|
for (const a of accts) {
|
||||||
|
const want = a.profile ? `profile ${a.profile}` : 'key not moved from the keychain yet';
|
||||||
|
if (!text.includes(want)) problems.push(`Accounts: "${want}" not shown for ${a.label} (${text.replace(/\s+/g, " ").slice(text.replace(/\s+/g, " ").indexOf(a.label ?? "") , 400)})`);
|
||||||
|
}
|
||||||
|
notes.push(`Accounts: ${accts.map(a => a.profile ?? '(keychain)').join(', ')}`);
|
||||||
|
} else problems.push('no Accounts entry in the sidebar');
|
||||||
|
}
|
||||||
|
// a folder that pins another storebound version says so on its app page
|
||||||
|
for (const p of fx.projects_list.filter(x => (x as { storeboundPin?: string }).storeboundPin && isDir(x.path))) {
|
||||||
|
const pin = (p as { storeboundPin?: string }).storeboundPin!;
|
||||||
|
const item = page.locator('nav.side .nav-app', { hasText: p.name }).first();
|
||||||
|
if (!(await item.count())) { problems.push(`${p.name}: not in the sidebar`); continue; }
|
||||||
|
await item.click({ timeout: WAIT_MS });
|
||||||
|
await settle(page);
|
||||||
|
const text = await page.locator('#content').innerText();
|
||||||
|
if (!text.includes(`uses storebound ${pin}`)) problems.push(`${p.name}: no "project uses storebound ${pin}" notice`);
|
||||||
|
else notes.push(`${p.name}: pin notice ${pin}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
server.stop(true);
|
server.stop(true);
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,14 @@
|
||||||
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta] [--yes]
|
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta] [--yes]
|
||||||
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
|
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
|
||||||
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run).
|
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run).
|
||||||
|
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first,
|
||||||
|
// then `npm publish` of the same version.
|
||||||
// bun tools/desktop-release.ts verify --out <dir>
|
// bun tools/desktop-release.ts verify --out <dir>
|
||||||
// The checks of publish without uploading.
|
// The checks of publish without uploading.
|
||||||
// bun tools/desktop-release.ts exec -- <command...>
|
// bun tools/desktop-release.ts exec -- <command...>
|
||||||
// Runs a build command (tauri build on a runner) with the path-free environment described at pathFreeEnv.
|
// Runs a build command (tauri build on a runner) with the path-free environment described at pathFreeEnv.
|
||||||
//
|
//
|
||||||
// Version: package.json "desktopVersion" (bun tools/desktop-version.ts). Docs: docs/desktop-release.md.
|
// Version: package.json "version", the same as the npm CLI (bun tools/desktop-version.ts); both ship together. Docs: docs/desktop-release.md.
|
||||||
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
|
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
|
@ -634,6 +636,13 @@ function verifyFolder(out: string): { manifest: Manifest; files: string[] } {
|
||||||
return { manifest, files };
|
return { manifest, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json
|
||||||
|
* (the CLI that `npx storebound desktop` installs it for). Pure. */
|
||||||
|
export function releaseTogetherProblems(folderVersion: string, pkgVersion: string | null): string[] {
|
||||||
|
if (!pkgVersion) return ['package.json has no version'];
|
||||||
|
return folderVersion === pkgVersion ? [] : [`the folder is desktop ${folderVersion} but package.json (the npm CLI) is ${pkgVersion}: the CLI and the app ship with one version (bun tools/desktop-version.ts --set ${folderVersion}, or build the folder from the release commit)`];
|
||||||
|
}
|
||||||
|
|
||||||
function publish(argv: string[]): void {
|
function publish(argv: string[]): void {
|
||||||
const a = args(argv);
|
const a = args(argv);
|
||||||
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
|
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
|
||||||
|
|
@ -643,6 +652,8 @@ function publish(argv: string[]): void {
|
||||||
if (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta');
|
if (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta');
|
||||||
const { manifest, files } = verifyFolder(out);
|
const { manifest, files } = verifyFolder(out);
|
||||||
const v = manifest.version;
|
const v = manifest.version;
|
||||||
|
const together = releaseTogetherProblems(v, versions().version);
|
||||||
|
if (together.length) fail(together.join('; '));
|
||||||
const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file);
|
const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file);
|
||||||
const steps = [
|
const steps = [
|
||||||
['ssh', host, `mkdir -p ${REMOTE_INCOMING}/${v} && chmod 700 ${REMOTE_INCOMING}`],
|
['ssh', host, `mkdir -p ${REMOTE_INCOMING}/${v} && chmod 700 ${REMOTE_INCOMING}`],
|
||||||
|
|
@ -659,6 +670,7 @@ function publish(argv: string[]): void {
|
||||||
const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { encoding: 'utf8' });
|
const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { encoding: 'utf8' });
|
||||||
if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet');
|
if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet');
|
||||||
say(`live: ${BASE_URL}/ serves ${v}`);
|
say(`live: ${BASE_URL}/ serves ${v}`);
|
||||||
|
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
// One version for the desktop app. The authority is `desktopVersion` in the root package.json (the npm package's
|
// One version for storebound: the npm CLI and the desktop app are one product and are released together (0.8.0 was
|
||||||
// own `version` is the CLI's and moves on its own); it is copied into apps/desktop/package.json,
|
// the first release with one number). The authority is `version` in the root package.json; it is copied into the
|
||||||
// src-tauri/tauri.conf.json, src-tauri/Cargo.toml and the app's entry in src-tauri/Cargo.lock.
|
// agent plugin manifests (written by tools/gen-adapters.ts), apps/desktop/package.json, src-tauri/tauri.conf.json,
|
||||||
|
// src-tauri/Cargo.toml and the app's entry in src-tauri/Cargo.lock. `storebound desktop` installs, opens or updates the
|
||||||
|
// app of exactly this version.
|
||||||
// bun tools/desktop-version.ts check: exit 1 when any copy drifted (CI runs this)
|
// bun tools/desktop-version.ts check: exit 1 when any copy drifted (CI runs this)
|
||||||
// bun tools/desktop-version.ts --set 0.1.1 write the new version into the authority and every copy
|
// bun tools/desktop-version.ts --set 0.8.0 write the new version into package.json and every copy
|
||||||
// bun tools/desktop-version.ts --print print the version
|
// bun tools/desktop-version.ts --print print the version
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
@ -14,16 +16,22 @@ export const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||||
|
|
||||||
export interface Copy { file: string; read(text: string): string | null; write(text: string, v: string): string }
|
export interface Copy { file: string; read(text: string): string | null; write(text: string, v: string): string }
|
||||||
|
|
||||||
const jsonVersion = (key: string): Pick<Copy, 'read' | 'write'> => ({
|
/** The first `"version": "..."` of a JSON file (top level in package.json and the manifests, the plugin entry of
|
||||||
read: t => { const v = (JSON.parse(t) as Record<string, unknown>)[key]; return typeof v === 'string' ? v : null; },
|
* marketplace.json). */
|
||||||
write: (t, v) => t.replace(new RegExp(`("${key}"\\s*:\\s*)"[^"]*"`), `$1"${v}"`),
|
const jsonVersion: Pick<Copy, 'read' | 'write'> = {
|
||||||
});
|
read: t => /"version"\s*:\s*"([^"]*)"/.exec(t)?.[1] ?? null,
|
||||||
|
write: (t, v) => t.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${v}"`),
|
||||||
|
};
|
||||||
|
|
||||||
/** The authority first, then every copy. */
|
/** The authority first, then every copy. */
|
||||||
export const COPIES: Copy[] = [
|
export const COPIES: Copy[] = [
|
||||||
{ file: 'package.json', ...jsonVersion('desktopVersion') },
|
{ file: 'package.json', ...jsonVersion },
|
||||||
{ file: 'apps/desktop/package.json', ...jsonVersion('version') },
|
{ file: '.claude-plugin/plugin.json', ...jsonVersion },
|
||||||
{ file: `${TAURI}/tauri.conf.json`, ...jsonVersion('version') },
|
{ file: '.claude-plugin/marketplace.json', ...jsonVersion },
|
||||||
|
{ file: '.codex-plugin/plugin.json', ...jsonVersion },
|
||||||
|
{ file: 'gemini-extension.json', ...jsonVersion },
|
||||||
|
{ file: 'apps/desktop/package.json', ...jsonVersion },
|
||||||
|
{ file: `${TAURI}/tauri.conf.json`, ...jsonVersion },
|
||||||
{
|
{
|
||||||
file: `${TAURI}/Cargo.toml`,
|
file: `${TAURI}/Cargo.toml`,
|
||||||
// the [package] table's version (the first `version =` line of the file, before any dependency table)
|
// the [package] table's version (the first `version =` line of the file, before any dependency table)
|
||||||
|
|
@ -61,13 +69,13 @@ if (import.meta.main) {
|
||||||
const i = argv.indexOf('--set');
|
const i = argv.indexOf('--set');
|
||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
setVersion(argv[i + 1] ?? '');
|
setVersion(argv[i + 1] ?? '');
|
||||||
console.log(`desktop version ${argv[i + 1]} written to ${COPIES.map(c => c.file).join(', ')}`);
|
console.log(`version ${argv[i + 1]} written to ${COPIES.map(c => c.file).join(', ')}\nnext: a CHANGELOG section for it; the npm package and the desktop app are released together (docs/desktop-release.md)`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
const { version, drift } = versions();
|
const { version, drift } = versions();
|
||||||
if (argv.includes('--print')) { console.log(version ?? ''); process.exit(version ? 0 : 1); }
|
if (argv.includes('--print')) { console.log(version ?? ''); process.exit(version ? 0 : 1); }
|
||||||
if (!version || !SEMVER.test(version)) { console.error('desktop version: package.json has no valid "desktopVersion"'); process.exit(1); }
|
if (!version || !SEMVER.test(version)) { console.error('version: package.json has no valid "version"'); process.exit(1); }
|
||||||
for (const d of drift) console.error(`desktop version drift: ${d.file} has ${d.found ?? 'no version'}, package.json desktopVersion is ${version}`);
|
for (const d of drift) console.error(`version drift: ${d.file} has ${d.found ?? 'no version'}, package.json version is ${version}`);
|
||||||
if (drift.length) { console.error('fix: bun tools/desktop-version.ts --set ' + version); process.exit(1); }
|
if (drift.length) { console.error('fix: bun tools/desktop-version.ts --set ' + version); process.exit(1); }
|
||||||
console.log(`desktop version ${version}: ${COPIES.length - 1} copies match`);
|
console.log(`version ${version}: the CLI, the plugin manifests and the desktop app (${COPIES.length - 1} copies) match`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
|
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
|
||||||
# points at an uploaded file of /desktop/dl/<version>/), copies the files into /var/www/storebound-desktop/dl/<version>/
|
# points at an uploaded file of /desktop/dl/<version>/), copies the files into /var/www/storebound-desktop/dl/<version>/
|
||||||
# (a new version: a temp folder renamed into place; an existing version: only new files, a file already published is
|
# (a new version: a temp folder renamed into place; an existing version: only new files, a file already published is
|
||||||
# never replaced by different bytes), and only then swaps latest.json of the channel atomically (temp file + rename).
|
# never replaced by different bytes), writes the version's own dl/<version>/latest.json (what `storebound desktop` of
|
||||||
|
# the same CLI version installs), and only then swaps latest.json of the channel atomically (temp file + rename).
|
||||||
# Apache serves that folder under https://storebound.dev/desktop/ (tools/vds/apache-storebound-desktop.conf), apart
|
# Apache serves that folder under https://storebound.dev/desktop/ (tools/vds/apache-storebound-desktop.conf), apart
|
||||||
# from the site's own rsync, which deletes what is not in the repository. Nothing from the upload is executed.
|
# from the site's own rsync, which deletes what is not in the repository. Nothing from the upload is executed.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -70,6 +71,10 @@ else
|
||||||
install -m 644 "$IN/SHA256SUMS" "$DL/.SHA256SUMS.tmp" && mv -f "$DL/.SHA256SUMS.tmp" "$DL/SHA256SUMS"
|
install -m 644 "$IN/SHA256SUMS" "$DL/.SHA256SUMS.tmp" && mv -f "$DL/.SHA256SUMS.tmp" "$DL/SHA256SUMS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# the version's own manifest: dl/<version>/latest.json. `storebound desktop` of CLI <version> installs exactly that
|
||||||
|
# version from it (the CLI and the app share one version number); it may be updated when installers are added
|
||||||
|
install -m 644 "$IN/latest.json" "$DL/.latest.json.tmp" && mv -f "$DL/.latest.json.tmp" "$DL/latest.json"
|
||||||
|
|
||||||
# latest.json last: installed apps see the new version only once every file it names is in place
|
# latest.json last: installed apps see the new version only once every file it names is in place
|
||||||
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/beta/latest.json"; URL="$SITE/beta/latest.json"; fi
|
if [ "$CHANNEL" = stable ]; then LJ="$ROOT/latest.json"; URL="$SITE/latest.json"; else LJ="$ROOT/beta/latest.json"; URL="$SITE/beta/latest.json"; fi
|
||||||
install -m 644 "$IN/latest.json" "$LJ.tmp"
|
install -m 644 "$IN/latest.json" "$LJ.tmp"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue