storebound/cli/edit.ts

120 lines
6.8 KiB
TypeScript

// 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)
// { "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),
allowPlatformMentions: config.listing?.allowPlatformMentions ?? [],
};
}
/** 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.required('ops', 'json', 'storebound edit --show --json lists the editable values');
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 ...)`);
}