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
|
|
@ -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,
|
||||
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself)
|
||||
// 2. <project>/.storebound/credentials.json (gitignored), same names:
|
||||
// { "PLAY_SERVICE_ACCOUNT": "...", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "...",
|
||||
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself), ASC_VENDOR_NUMBER, ANDROID_*, ...
|
||||
// 2. the project's profile when it names one (--account, config, credentials.json "account"): store keys only
|
||||
// 3. <project>/.storebound/credentials.json, same names:
|
||||
// { "account": "<profile>", "ANDROID_KEYSTORE": "...", ...,
|
||||
// "reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" },
|
||||
// "reviewDemoAccount": { "username": "", "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)
|
||||
// With a credential profile (`--profile <name>` or STOREBOUND_PROFILE), the profile comes before all of these:
|
||||
// ~/.storebound/credentials/<name>.json (same four names; relative paths are relative to that file; folder:
|
||||
// STOREBOUND_CREDENTIALS_DIR) or env STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _).
|
||||
// Many studios / store accounts from one machine: one profile per account; storebound.workspace.json names them.
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
// 4. the default profile (none named): store keys only
|
||||
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
||||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
|
||||
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
|
|
@ -60,6 +71,38 @@ export function profileEnv(profile: string): Record<string, string> {
|
|||
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. */
|
||||
export function profileStatus(profile: string): { profile: string; source: 'file' | 'env' | 'missing' | 'invalid'; apple: boolean; play: boolean; error?: string } {
|
||||
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' }[] {
|
||||
let file: Store = {};
|
||||
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
|
||||
const profile = process.env.STOREBOUND_PROFILE;
|
||||
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 => {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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];
|
||||
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];
|
||||
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. */
|
||||
|
|
@ -129,14 +175,14 @@ export function parseServiceAccount(text: string): ServiceAccount {
|
|||
|
||||
export function playServiceAccount(root: string): ServiceAccount {
|
||||
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*\{/));
|
||||
}
|
||||
|
||||
export function ascKey(root: string): AscKey {
|
||||
const keyId = value(root, 'ASC_KEY_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`);
|
||||
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/) };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue