{"version":3,"file":"configWriter-DCcjsPdx.cjs","names":["fs","parseDocument","path","randomUUID"],"sources":["../src/services/configWriter/index.ts"],"sourcesContent":["/**\n * Writes into the global Doom config file, one key path at a time.\n *\n * Extracted from doom-file-edit, which owned the only writer in the repo and had\n * it hard-coded to `editor.command`. Several extensions now edit their own\n * settings from the config panel, and every one of them needs the same four\n * properties: keep the user's comments, never publish a file the parser would\n * reject, never leave a half-written file behind, and apply a set of related\n * edits together.\n *\n * That last one is not a convenience. `voice.adapters.<engine>.model` accepts a\n * path or an id but not both, so switching between them is a set and an unset\n * that must land in the same write, or the file is briefly invalid.\n *\n * Atomicity is the same rename trick as `atomicJson.ts` next door, and carries\n * the same caveat: it prevents torn reads, not lost updates. Two sessions with\n * the panel open still clobber each other, because the rename only publishes\n * what the caller already computed.\n *\n * AVOID:\n * - Treating this as a lock\n * - Writing an empty string; the parser rejects blank values, so clearing a key\n *   means removing it\n */\n\nimport { randomUUID } from 'node:crypto';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\n\nimport { parseDocument } from 'yaml';\n\nimport { parseDoomConfig } from '../configPolicy';\n\n/**\n * The global file holds machine-wide credentials-adjacent settings and lives in\n * the user's home, so it stays private.\n */\nconst PRIVATE_FILE_MODE = 0o600;\nconst PRIVATE_DIRECTORY_MODE = 0o700;\n\n/**\n * The repository file is committed to git and read by everyone who checks the\n * project out, so it takes the modes `init.ts` already seeds it with. Writing it\n * private would produce a 0600 file in a working tree, which is both wrong and\n * invisible until someone else clones.\n */\nconst REPOSITORY_FILE_MODE = 0o644;\nconst REPOSITORY_DIRECTORY_MODE = 0o755;\n\n/** Which file a write is aimed at; the modes follow from it. */\nexport type DoomConfigScope = 'global' | 'repository';\n\n/** The permissions a scope's file and its parent directory are created with. */\nfunction modesForScope(scope: DoomConfigScope): { file: number; directory: number } {\n  return scope === 'repository'\n    ? { file: REPOSITORY_FILE_MODE, directory: REPOSITORY_DIRECTORY_MODE }\n    : { file: PRIVATE_FILE_MODE, directory: PRIVATE_DIRECTORY_MODE };\n}\n\nexport interface DoomConfigEdit {\n  /** Key path into the document, e.g. `['voice', 'adapters', 'mlx-whisper', 'model', 'id']`. */\n  readonly keyPath: readonly string[];\n  /**\n   * The value to write. `undefined` removes the key.\n   *\n   * Not only strings: the config holds numbers (`utteranceIdleMs`) and lists\n   * (`startPhrases`), and a panel that edits them as text has to hand back the\n   * parsed value or the next load rejects what it wrote.\n   */\n  readonly value?: string | number | boolean | readonly string[];\n}\n\nexport interface WriteDoomConfigOptions {\n  /**\n   * Remove ancestors left holding nothing. Defaults to true, and is not\n   * cosmetic: an adapter left as `{}` fails validation, because the parser\n   * requires a model once the adapter exists at all.\n   */\n  readonly prune?: boolean;\n  /**\n   * Which file this is, so the write lands with the right permissions.\n   * Defaults to `global`, which is what every caller wrote before the\n   * repository file was writable at all.\n   */\n  readonly scope?: DoomConfigScope;\n}\n\nasync function readIfPresent(filePath: string): Promise<string> {\n  try {\n    return await fs.readFile(filePath, 'utf8');\n  } catch (error) {\n    if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return '';\n    throw error;\n  }\n}\n\nfunction isEmptyCollection(value: unknown): boolean {\n  if (value === null || typeof value !== 'object') return false;\n  const items = (value as { items?: unknown }).items;\n  return Array.isArray(items) && items.length === 0;\n}\n\n/** Drops ancestors of a removed key that are now empty, deepest first. */\nfunction pruneEmptyAncestors(document: ReturnType<typeof parseDocument>, keyPath: readonly string[]): void {\n  for (let depth = keyPath.length - 1; depth > 0; depth -= 1) {\n    const ancestor = keyPath.slice(0, depth);\n    if (!document.hasIn(ancestor)) continue;\n    if (!isEmptyCollection(document.getIn(ancestor, true))) return;\n    document.deleteIn(ancestor);\n  }\n}\n\n/**\n * Applies every edit as one document mutation, or throws having written nothing.\n *\n * Validation runs against the serialized result rather than each edit, so a pair\n * of edits only valid together is accepted, and a single edit that breaks an\n * invariant elsewhere is still caught.\n */\nexport async function writeDoomConfigValues(\n  filePath: string,\n  edits: readonly DoomConfigEdit[],\n  options: WriteDoomConfigOptions = {},\n): Promise<void> {\n  if (edits.length === 0) return;\n  const content = await readIfPresent(filePath);\n  // Not `content || '{}\\n'`: seeding an absent file with a flow map makes yaml\n  // keep that style, so the first write produces `{ editor: { command: ... } }`\n  // and every later one grows that one line. An empty source starts a block map.\n  const document = parseDocument(content, { keepSourceTokens: true });\n  if (document.errors.length > 0) throw new Error(`Could not parse Doom config at ${filePath}`);\n\n  for (const edit of edits) {\n    const keyPath = [...edit.keyPath];\n    if (edit.value !== undefined) {\n      document.setIn(keyPath, edit.value);\n      continue;\n    }\n    // deleteIn throws when an ancestor is missing rather than treating the key as\n    // already absent, and clearing an unset key is worth supporting.\n    if (!document.hasIn(keyPath)) continue;\n    document.deleteIn(keyPath);\n    if (options.prune !== false) pruneEmptyAncestors(document, keyPath);\n  }\n\n  const output = document.toString();\n  parseDoomConfig(output, filePath);\n\n  const modes = modesForScope(options.scope ?? 'global');\n  await fs.mkdir(path.dirname(filePath), { mode: modes.directory, recursive: true });\n  const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;\n  try {\n    await fs.writeFile(temporaryPath, output, { encoding: 'utf8', mode: modes.file });\n    await fs.rename(temporaryPath, filePath);\n  } catch (error) {\n    await fs.rm(temporaryPath, { force: true });\n    throw error;\n  }\n}\n\nexport async function setDoomConfigValue(\n  filePath: string,\n  keyPath: readonly string[],\n  value: string,\n  options: WriteDoomConfigOptions = {},\n): Promise<void> {\n  await writeDoomConfigValues(filePath, [{ keyPath, value }], options);\n}\n\nexport async function unsetDoomConfigValue(\n  filePath: string,\n  keyPath: readonly string[],\n  options: WriteDoomConfigOptions = {},\n): Promise<void> {\n  await writeDoomConfigValues(filePath, [{ keyPath }], options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAM,oBAAoB;AAC1B,MAAM,yBAAyB;;;;;;;AAQ/B,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;;AAMlC,SAAS,cAAc,OAA6D;CAClF,OAAO,UAAU,eACb;EAAE,MAAM;EAAsB,WAAW;CAA0B,IACnE;EAAE,MAAM;EAAmB,WAAW;CAAuB;AACnE;AA8BA,eAAe,cAAc,UAAmC;CAC9D,IAAI;EACF,OAAO,MAAMA,iBAAAA,QAAG,SAAS,UAAU,MAAM;CAC3C,SAAS,OAAO;EACd,IAAI,iBAAiB,SAAS,UAAU,SAAS,MAAM,SAAS,UAAU,OAAO;EACjF,MAAM;CACR;AACF;AAEA,SAAS,kBAAkB,OAAyB;CAClD,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO;CACxD,MAAM,QAAS,MAA8B;CAC7C,OAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW;AAClD;;AAGA,SAAS,oBAAoB,UAA4C,SAAkC;CACzG,KAAK,IAAI,QAAQ,QAAQ,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG;EAC1D,MAAM,WAAW,QAAQ,MAAM,GAAG,KAAK;EACvC,IAAI,CAAC,SAAS,MAAM,QAAQ,GAAG;EAC/B,IAAI,CAAC,kBAAkB,SAAS,MAAM,UAAU,IAAI,CAAC,GAAG;EACxD,SAAS,SAAS,QAAQ;CAC5B;AACF;;;;;;;;AASA,eAAsB,sBACpB,UACA,OACA,UAAkC,CAAC,GACpB;CACf,IAAI,MAAM,WAAW,GAAG;CACxB,MAAM,UAAU,MAAM,cAAc,QAAQ;CAI5C,MAAM,YAAA,GAAWC,KAAAA,cAAAA,CAAc,SAAS,EAAE,kBAAkB,KAAK,CAAC;CAClE,IAAI,SAAS,OAAO,SAAS,GAAG,MAAM,IAAI,MAAM,kCAAkC,UAAU;CAE5F,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,UAAU,CAAC,GAAG,KAAK,OAAO;EAChC,IAAI,KAAK,UAAU,KAAA,GAAW;GAC5B,SAAS,MAAM,SAAS,KAAK,KAAK;GAClC;EACF;EAGA,IAAI,CAAC,SAAS,MAAM,OAAO,GAAG;EAC9B,SAAS,SAAS,OAAO;EACzB,IAAI,QAAQ,UAAU,OAAO,oBAAoB,UAAU,OAAO;CACpE;CAEA,MAAM,SAAS,SAAS,SAAS;CACjC,qBAAA,gBAAgB,QAAQ,QAAQ;CAEhC,MAAM,QAAQ,cAAc,QAAQ,SAAS,QAAQ;CACrD,MAAMD,iBAAAA,QAAG,MAAME,UAAAA,QAAK,QAAQ,QAAQ,GAAG;EAAE,MAAM,MAAM;EAAW,WAAW;CAAK,CAAC;CACjF,MAAM,gBAAgB,GAAG,SAAS,GAAG,QAAQ,IAAI,IAAA,GAAGC,YAAAA,WAAAA,CAAW,EAAE;CACjE,IAAI;EACF,MAAMH,iBAAAA,QAAG,UAAU,eAAe,QAAQ;GAAE,UAAU;GAAQ,MAAM,MAAM;EAAK,CAAC;EAChF,MAAMA,iBAAAA,QAAG,OAAO,eAAe,QAAQ;CACzC,SAAS,OAAO;EACd,MAAMA,iBAAAA,QAAG,GAAG,eAAe,EAAE,OAAO,KAAK,CAAC;EAC1C,MAAM;CACR;AACF;AAEA,eAAsB,mBACpB,UACA,SACA,OACA,UAAkC,CAAC,GACpB;CACf,MAAM,sBAAsB,UAAU,CAAC;EAAE;EAAS;CAAM,CAAC,GAAG,OAAO;AACrE;AAEA,eAAsB,qBACpB,UACA,SACA,UAAkC,CAAC,GACpB;CACf,MAAM,sBAAsB,UAAU,CAAC,EAAE,QAAQ,CAAC,GAAG,OAAO;AAC9D"}