// Edit the app folder's sources of truth from the desktop (or a terminal): config values by path, listing fields per // store and language, the App Review contact. Local files only; nothing goes to a store. // storebound edit --ops '' [--dry] [--json] // storebound edit --show --json every value the desktop's editors show (listings, config sections, git state) // --ops is one edit or a list: // { "kind": "config", "path": "app.supportUrl", "value": "https://example.com/help" } // { "kind": "config", "path": "products[id=com.example.no_ads].listings.en.title", "value": "No ads" } // { "kind": "config", "path": "apple.info.reviewNotes", "remove": true } // { "kind": "listing", "store": "apple", "lang": "en-US", "field": "subtitle", "value": "Lists that stay tidy" } // { "kind": "listing", "store": "play", "lang": "tr-TR", "field": "short", "value": "..." } // { "kind": "contact", "field": "email", "value": "review@example.com" } (.storebound/credentials.json, masked) // { "kind": "shotsCopy", "lang": "en", "scene": "lists", "field": "title", "line": 0, "value": "Every list," } // Every run plans first: old -> new per edit, the diff per file and the checks (store limits, required fields, keyword // and metadata rules of `check --aso` / `scan`, URLs, and the whole config validated as `check` loads it). --dry stops // there. An error stops the write; the files are written only when every check passes (comments, order and // formatting of the files stay as they are: only the edited values change). Never commits: a git folder shows the // changed files as uncommitted. import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { appleName, validateConfig } from './lib/config'; import type { Ctx } from './lib/ctx'; import { type EditPlan, type EditSources, getByPath, parseOps, planEdits, planLines } from './lib/edit'; import { editableValues, gitState } from './lib/edit-values'; import { done, log, printJson, warn } from './lib/log'; import * as rt from './lib/rt'; const TAG = 'edit'; export function sources(ctx: Ctx): EditSources { const { config, root, path, resolve } = ctx.loaded; const rel = (f: string): string => relative(root, f) || f; const file = (f: string) => ({ file: f, rel: rel(f), text: existsSync(f) ? readFileSync(f, 'utf8') : null }); const contact = join(root, '.storebound', 'credentials.json'); return { config: { file: path, rel: rel(path), text: readFileSync(path, 'utf8') }, play: config.play ? file(resolve(config.play.listing ?? 'store/listing.md')) : null, apple: config.apple ? file(resolve(config.apple.listing ?? 'store/appstore.md')) : null, contact: file(contact), shotsCopy: config.shots?.copy ? file(resolve(config.shots.copy)) : null, appName: config.app.name, appleName: appleName(config), }; } /** The edited config loads and validates like `check` loads it (a temp file next to it, so relative imports work). */ async function validateConfigText(path: string, text: string): Promise { const ext = path.slice(path.lastIndexOf('.')); const tmp = join(dirname(path), `.storebound-edit-${process.pid}-${Date.now().toString(36)}${ext}`); writeFileSync(tmp, text); try { if (ext === '.json') return validateConfig(JSON.parse(text)); const mod = await rt.importConfigModule(tmp); return validateConfig(mod.default ?? mod); } catch (e) { return [`the edited config does not load: ${(e as Error).message.split('\n')[0]}`]; } finally { rmSync(tmp, { force: true }); } } /** Writes a file through a temp file and a rename, keeping the old file's mode (credentials.json stays 0600). */ function writeAtomic(file: string, text: string): void { let mode: number | null = null; try { mode = statSync(file).mode & 0o777; } catch { /* new */ } mkdirSync(dirname(file), { recursive: true, ...(file.includes(`${join('.storebound', '')}`) ? { mode: 0o700 } : {}) }); const tmp = `${file}.storebound-${process.pid}.tmp`; writeFileSync(tmp, text, { mode: mode ?? (file.endsWith('credentials.json') ? 0o600 : 0o644) }); if (mode !== null) chmodSync(tmp, mode); renameSync(tmp, file); } export async function plan(ctx: Ctx, opsJson: string): Promise<{ plan: EditPlan; texts: Map }> { const src = sources(ctx); const ops = parseOps(opsJson); const r = planEdits(ops, src, p => getByPath(ctx.loaded.config, p)); const cfgText = r.texts.get(src.config.file); if (cfgText !== undefined) for (const e of await validateConfigText(src.config.file, cfgText)) { r.plan.issues.push({ level: 'error', field: 'config', where: src.config.rel, msg: e }); r.plan.errors++; } return r; } export async function run(ctx: Ctx): Promise { const a = ctx.args; const { root } = ctx.loaded; if (a.has('show')) { printJson(editableValues(ctx.loaded)); return; } const opsJson = a.get('ops'); if (!opsJson) throw new Error('usage: storebound edit --ops \'\' [--dry] [--json] | storebound edit --show --json'); const { plan: p, texts } = await plan(ctx, opsJson); const dry = a.has('dry'); // with --json in a terminal, stdout is the JSON result only: the lines go to stderr (under `serve` the result is a // marked line, and the lines stay on stdout: the confirm dialog shows them) const toErr = a.has('json') && process.env.STOREBOUND_MACHINE !== '1'; const say = (f: (tag: string, msg: string) => void) => (msg: string): void => { if (toErr) console.error(`[${TAG}] ${msg}`); else f(TAG, msg); }; const sDone = say(done), sLog = say(log), sWarn = say(warn); const lines = planLines(p); for (const l of lines) (toErr ? console.error : console.log)(l); const git = () => gitState(root, p.files.map(f => f.file)); if (p.errors) { if (a.has('json')) printJson({ ...p, written: false, dry, git: git() }); throw new Error(`nothing written: ${p.errors} error${p.errors === 1 ? '' : 's'} (see above)`); } if (!p.files.length) { if (a.has('json')) printJson({ ...p, written: false, dry, git: git() }); sDone('nothing to change: the files already hold these values'); return; } if (dry) { if (a.has('json')) printJson({ ...p, written: false, dry, git: git() }); sDone(`${p.changes.length} change(s) in ${p.files.map(f => f.rel).join(', ')}; nothing written (--dry)`); return; } for (const f of p.files) writeAtomic(f.file, texts.get(f.file)!); const g = git(); if (a.has('json')) printJson({ ...p, written: true, dry: false, git: g }); for (const f of p.files) sLog(`wrote ${f.rel}`); if (g.repo && g.uncommitted.length) sWarn(`uncommitted in git: ${g.uncommitted.join(', ')} (storebound never commits)`); sDone(`${p.changes.length} change(s) saved. Next: apply them to the stores (the desktop's Apply, or play listing / apple listing ...)`); }