desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case
This commit is contained in:
parent
714352ec18
commit
3cea2af4e1
37 changed files with 3020 additions and 54 deletions
118
cli/edit.ts
Normal file
118
cli/edit.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// 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 '<json>' [--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)
|
||||
// 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),
|
||||
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<string[]> {
|
||||
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<string, string> }> {
|
||||
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<void> {
|
||||
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 \'<json>\' [--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 ...)`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue