118 lines
6.6 KiB
TypeScript
118 lines
6.6 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)
|
|
// 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 ...)`);
|
|
}
|