// Writes one top-level block (dataSafety, appPrivacy) into storebound.config.ts / .js / .json text: replaces the // existing `key: { ... }` property, or inserts it after the `app: { ... }` block. Pure text in, text out; the rest of // the file (comments, order, formatting) stays as it is. Used by `storebound scan --write`; upsertNestedString sets one // nested string (play.appId: `console verify play-app-record --write`, `play app-id --write`). /** Index of the brace that closes the one at `open`, skipping strings, template literals and comments. -1 if none. */ export function matchBrace(text: string, open: number): number { const pairs: Record = { '{': '}', '[': ']', '(': ')' }; const stack: string[] = []; for (let i = open; i < text.length; i++) { const ch = text[i]!; if (ch === '/' && text[i + 1] === '/') { i = text.indexOf('\n', i); if (i < 0) return -1; continue; } if (ch === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i + 2); if (i < 0) return -1; i++; continue; } if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < text.length && text[i] !== ch; i++) if (text[i] === '\\') i++; continue; } if (pairs[ch]) stack.push(pairs[ch]!); else if (ch === '}' || ch === ']' || ch === ')') { if (stack.pop() !== ch) return -1; if (!stack.length) return i; } } return -1; } const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** A plain value as TypeScript source (single quotes, unquoted keys, trailing commas), indented by `ind`. */ export function toTs(v: unknown, ind = ''): string { const next = ind + ' '; if (v === null || v === undefined) return 'undefined'; if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`; if (typeof v === 'number' || typeof v === 'boolean') return String(v); if (Array.isArray(v)) { const parts = v.map(x => toTs(x, next)); const one = `[${parts.join(', ')}]`; return one.length <= 100 && !one.includes('\n') ? one : `[\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}]`; } const entries = Object.entries(v as Record).filter(([, x]) => x !== undefined); if (!entries.length) return '{}'; const parts = entries.map(([k, x]) => `${ID.test(k) ? k : `'${k}'`}: ${toTs(x, next)}`); const one = `{ ${parts.join(', ')} }`; return one.length <= 110 && !one.includes('\n') && entries.every(([, x]) => typeof x !== 'object' || Array.isArray(x)) ? one : `{\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}}`; } /** The config text with the top-level `key` set to `value` (an object, or an array: `products`, `subscriptions`; * plus an optional comment line above it when it is new). The `key:` with the smallest indent is the top-level one. */ export function upsertBlock(text: string, file: string, key: string, value: unknown, comment?: string): string { if (file.endsWith('.json')) { const j = JSON.parse(text) as Record; j[key] = value; return JSON.stringify(j, null, 2) + '\n'; } const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*[{[]`, 'gm'); let m: RegExpExecArray | null = null; for (let x = re.exec(text); x; x = re.exec(text)) if (!m || x[1]!.length < m[1]!.length) m = x; if (m) { const ind = m[1]!; const open = m.index + m[0].length - 1; const close = matchBrace(text, open); if (close < 0) throw new Error(`${file}: could not find the end of the ${key} block`); return `${text.slice(0, m.index)}${ind}${key}: ${toTs(value, ind)}${text.slice(close + 1)}`; } const app = /^([ \t]*)app\s*:\s*\{/m.exec(text); if (!app) throw new Error(`${file}: no app block found to insert ${key} after; add it to the config file directly`); const ind = app[1]!; const close = matchBrace(text, app.index + app[0].length - 1); if (close < 0) throw new Error(`${file}: could not find the end of the app block`); let at = close + 1; if (text[at] === ',') at++; const block = `\n${comment ? `${ind}// ${comment}\n` : ''}${ind}${key}: ${toTs(value, ind)},`; return text.slice(0, at) + block + text.slice(at); } /** * The config text with `parent.key` set to a string `value` (an upsert: `play.appId`, used by `console verify * play-app-record --write` and `play app-id --write`): JSON is parsed and written back; in .ts / .js the `key: '…'` * inside the top-level `parent: { … }` block is replaced, or `key: '',` is added right after its opening brace. * Throws when the block is missing or the key holds something other than a string literal. The rest of the file * (comments, order, formatting) stays as it is. */ export function upsertNestedString(text: string, file: string, parent: string, key: string, value: string): string { if (file.endsWith('.json')) { const j = JSON.parse(text) as Record; const p = j[parent]; if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${file}: no ${parent} block to put ${parent}.${key} into`); (p as Record)[key] = value; return JSON.stringify(j, null, 2) + '\n'; } // the top-level block: the `parent: {` with the smallest indent const re = new RegExp(`^([ \\t]*)${parent}\\s*:\\s*\\{`, 'gm'); let best: RegExpExecArray | null = null; for (let m = re.exec(text); m; m = re.exec(text)) if (!best || m[1]!.length < best[1]!.length) best = m; // a one-line config (`export default { play: { … } }`): the first `parent: {` anywhere best ??= new RegExp(`()\\b${parent}\\s*:\\s*\\{`).exec(text); if (!best) throw new Error(`${file}: no ${parent} block to put ${parent}.${key} into; add ${key}: '${value}' to ${parent} in the config file`); const open = best.index + best[0].length - 1; const close = matchBrace(text, open); if (close < 0) throw new Error(`${file}: could not find the end of the ${parent} block`); const body = text.slice(open + 1, close); const lit = toTs(value); const keyRe = new RegExp(`(^|[{,\\s])${key}\\s*:\\s*`, 'g'); for (let k = keyRe.exec(body); k; k = keyRe.exec(body)) { if (depthAt(body, k.index + k[1]!.length) !== 0) continue; // a key of a nested object const valStart = k.index + k[0].length; const q = body[valStart]; if (q !== '\'' && q !== '"' && q !== '`') throw new Error(`${file}: ${parent}.${key} is not a plain string; set it to '${value}' in the config file`); let end = valStart + 1; for (; end < body.length && body[end] !== q; end++) if (body[end] === '\\') end++; const abs = open + 1; return text.slice(0, abs + valStart) + lit + text.slice(abs + end + 1); } if (!body.includes('\n')) { const inner = body.trim().replace(/,$/, ''); return `${text.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${key}: ${lit} ${text.slice(close)}`; } const ind = /\n([ \t]*)\S/.exec(body)?.[1] ?? `${best[1]} `; return `${text.slice(0, open + 1)}\n${ind}${key}: ${lit},${body}${text.slice(close)}`; } /** Bracket depth at `at` in `s` (strings and comments skipped). */ function depthAt(s: string, at: number): number { let d = 0; for (let i = 0; i < at; i++) { const ch = s[i]!; if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0 || n >= at) return d; i = n; continue; } if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0 || n >= at) return d; i = n + 1; continue; } if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < at && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; } if (ch === '{' || ch === '[' || ch === '(') d++; else if (ch === '}' || ch === ']' || ch === ')') d--; } return d; } /** End of the value that starts at `i` in `s`: the comma or closing bracket at depth 0. */ function valueEnd(s: string, i: number): number { let d = 0; for (; i < s.length; i++) { const ch = s[i]!; if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0) return s.length; i = n; continue; } if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0) return s.length; i = n + 1; continue; } if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < s.length && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; } if (ch === '{' || ch === '[' || ch === '(') d++; else if (ch === '}' || ch === ']' || ch === ')') { if (d === 0) return i; d--; } else if (ch === ',' && d === 0) return i; } return s.length; } /** * The config text without the property at `path` (['dataSafety'], ['apple', 'info', 'reviewNotes']): the key, its * value (any expression), its trailing comma and the comment lines right above it. Unchanged when the path is not * there. .json is parsed and written back. */ export function removeKeyPath(text: string, file: string, path: string[]): string { if (file.endsWith('.json')) { const j = JSON.parse(text) as Record; let o: Record | undefined = j; for (const k of path.slice(0, -1)) o = o?.[k] as Record | undefined; if (!o || typeof o !== 'object' || !(path.at(-1)! in o)) return text; delete o[path.at(-1)!]; return JSON.stringify(j, null, 2) + '\n'; } let from = 0, to = text.length; for (let n = 0; n < path.length; n++) { const region = text.slice(from, to); const re = new RegExp(`(^|[{,\\s])${path[n]}\\s*:\\s*`, 'gm'); let hit: { at: number; val: number } | null = null, bestInd = Infinity; for (let m = re.exec(region); m; m = re.exec(region)) { const at = m.index + m[1]!.length; const lineStart = region.lastIndexOf('\n', at - 1) + 1; if (region.slice(lineStart, at).includes('//')) continue; // in a comment if (n > 0) { if (depthAt(region, at) !== 0) continue; hit = { at: from + at, val: from + m.index + m[0].length }; break; } if (at - lineStart >= bestInd) continue; hit = { at: from + at, val: from + m.index + m[0].length }; bestInd = at - lineStart; } if (!hit) return text; if (n < path.length - 1) { if (text[hit.val] !== '{') return text; // the parent is not an object literal const close = matchBrace(text, hit.val); if (close < 0) return text; from = hit.val + 1; to = close; continue; } let end = valueEnd(text, hit.val); if (text[end] === ',') end++; let start = hit.at; const ls = text.lastIndexOf('\n', start - 1) + 1; if (!text.slice(ls, start).trim()) { start = ls; while (start > 0) { const prev = text.lastIndexOf('\n', start - 2) + 1; if (!/^\s*\/\//.test(text.slice(prev, start - 1))) break; start = prev; } const nl = /^[ \t]*\n/.exec(text.slice(end)); if (nl) end += nl[0].length; } return text.slice(0, start) + text.slice(end); } return text; }