import { Role, createNoydb, NoydbStore } from '@noy-db/hub'; /** * Internationalization types for the `create-noy-db` wizard. * * `WizardMessages` is the full set of user-facing strings the * wizard emits: prompt labels, note titles, note bodies, outro * messages, confirmation questions. Every locale bundle exports * a `WizardMessages` constant; the key-parity test ensures they * all have the exact same set of keys so we never ship a locale * that's missing a string. * * ## What's translated, what's not * * **Translated:** prompts, note titles, confirmation messages, * success banners, and the short summaries shown before each * major step. These are the load-bearing "does the user * understand what's happening?" strings. * * **Not translated:** validation error messages ("Project name * cannot be empty"), diagnostic output, stack traces, structured * errors from `@noy-db/core`. These stay in English so bug * reports from any locale look the same in an issue tracker. * Thai developers filing bugs with English error messages can * get help from English-speaking maintainers; the reverse is * harder. * * ## Why a flat shape instead of nested namespaces * * Flat keys are the simplest thing that can work. With ~30 * strings, namespacing (e.g., `prompts.projectName`) would just * add ceremony without helping discoverability. If the set grows * past ~100 strings we can revisit. */ type Locale = 'en' | 'th'; interface WizardMessages { /** Banner shown under the intro badge in fresh-project mode. */ wizardIntro: string; /** "Project name" prompt label. */ promptProjectName: string; /** Placeholder shown inside the project-name input. */ promptProjectNamePlaceholder: string; /** "Storage adapter" select prompt label. */ promptAdapter: string; /** Label for the browser adapter option. */ adapterBrowserLabel: string; /** Label for the file adapter option. */ adapterFileLabel: string; /** Label for the memory adapter option. */ adapterMemoryLabel: string; /** "Include sample invoice records?" confirm label. */ promptSampleData: string; /** Title of the "Next steps" note block. */ freshNextStepsTitle: string; /** Success banner shown after the fresh project is created. */ freshOutroDone: string; /** Title of the "augment mode detected" note block. */ augmentModeTitle: string; /** First line of the augment-mode intro body — followed by the path. */ augmentDetectedPrefix: string; /** Second/third lines explaining what augment mode will do. */ augmentDescription: string; /** Title of the diff preview note block. */ augmentProposedChangesTitle: string; /** Question shown at the confirm prompt. */ augmentApplyConfirm: string; /** Title when the config is already configured. */ augmentAlreadyConfiguredTitle: string; /** Prefix for the "already configured" reason line. */ augmentNothingToDo: string; /** Success banner when there's nothing to do. */ augmentAlreadyOutro: string; /** Cancel message when the user declines the confirm prompt. */ augmentAborted: string; /** Success banner on dry-run success. */ augmentDryRunOutro: string; /** Title of the "install these packages next" note block. */ augmentNextStepTitle: string; /** Prose line above the install command. */ augmentInstallIntro: string; /** Dim hint under the install command. */ augmentInstallPmHint: string; /** Success banner after a real augmentation write. */ augmentDoneOutro: string; /** Prefix for the "unsupported shape" error message. */ augmentUnsupportedPrefix: string; /** Cancellation message used by Ctrl-C handlers. */ cancelled: string; } /** * Types shared between the wizard, the bins, and the test harness. * * `WizardOptions` is the input shape — both the prompt UI and the test * helper accept the same object so tests can skip the interactive prompts * by passing answers up front. */ /** * Which built-in adapter to wire into the generated `nuxt.config.ts`. * * - `browser` — localStorage / IndexedDB. The recommended default for * because it makes the generated app a real PWA-friendly demo. * - `file` — JSON files on disk. Useful for Electron / Tauri wraps and * for the USB-stick workflow. * - `memory` — no persistence. Mostly useful for tests and demos. Picked * automatically when running in CI to avoid touching the test runner's * localStorage. */ type WizardAdapter = 'browser' | 'file' | 'memory'; /** * Which starter template to scaffold in fresh-project mode. * * - `nuxt-default` — Nuxt 4 + Pinia + in-nuxt, SSR-friendly. Default. * - `vanilla` — Vite + TS, no framework. Smallest footprint. * - `vite-vue` — Vite + Vue 3 + Pinia + in-pinia. Client-side SPA. * - `electron` — Electron + Vue 3 + to-file. USB-stick / local-disk workflow. * * Augment mode (detected Nuxt project) ignores this field — the * mutation path is shared regardless of the original scaffold. */ type WizardTemplate = 'nuxt-default' | 'vanilla' | 'vite-vue' | 'electron'; /** * Inputs to `runWizard()`. All fields are optional — when a field is * omitted the wizard prompts for it. Tests pass everything to skip * prompts entirely. */ interface WizardOptions { /** * Project directory name. The wizard creates `//` * and refuses to overwrite an existing non-empty directory. */ projectName?: string; /** * Adapter to use in the generated `nuxt.config.ts`. See `WizardAdapter`. */ adapter?: WizardAdapter; /** * Starter template to scaffold. Defaults to `'nuxt-default'` when * omitted. Ignored in augment mode (the existing project's shape * determines the mutation path). */ template?: WizardTemplate; /** * Optional sync-target adapter. When set, the * generated project wires a `sync: [...]` entry in * `createNoydb()` alongside the primary store. When omitted, the * project runs local-only. * * Accepts the same adapter identifiers as `adapter` but is * semantically orthogonal — pick the primary adapter based on * "where do I want records persisted locally?" and the sync * adapter based on "where do I want them replicated?". */ syncAdapter?: WizardAdapter | 'none'; /** * Whether to include the seed-data invoices in the generated app. When * `true`, the page renders pre-filled records on first load so the user * sees something immediately. When `false`, the page starts empty and * waits for the user to click "Add invoice". */ sampleData?: boolean; /** * Working directory the project should be created in. Defaults to * `process.cwd()`. Tests pass a temp directory. */ cwd?: string; /** * When `true`, skip ALL interactive prompts and use only the values * supplied above. Missing values become defaults (`browser`, `true`, * a generated project name). This is the path tests take. */ yes?: boolean; /** * Augment mode: show the proposed diff against an existing * `nuxt.config.ts` but do not write the file. Only meaningful * when the wizard detects an existing Nuxt project in `cwd`. A * no-op in fresh-project mode. */ dryRun?: boolean; /** * Force fresh-project mode even when cwd looks like an existing * Nuxt project. Useful for CI tests that create a scratch * directory inside a parent that happens to have a nuxt.config. */ forceFresh?: boolean; /** * Locale for the wizard's user-facing prompts and notes. When * omitted, the wizard auto-detects from `LC_ALL` / `LANG` env * vars and falls back to `'en'`. Tests pin a value to make * snapshot output deterministic. * * Validation/error messages are NOT translated — they stay in * English so bug reports look the same across locales. */ locale?: Locale; } /** * Output of `runWizard()` in fresh-project mode. The augment-mode * path uses `WizardAugmentResult` instead; the caller narrows on * the `kind` discriminator. */ interface WizardFreshResult { readonly kind: 'fresh'; /** Resolved options after prompts/defaults. */ readonly options: { readonly projectName: string; readonly adapter: WizardAdapter; readonly sampleData: boolean; readonly cwd: string; readonly template: WizardTemplate; readonly syncAdapter: WizardAdapter | 'none'; }; /** Absolute path of the created project directory. */ readonly projectPath: string; /** Relative paths of every file the wizard wrote, sorted alphabetically. */ readonly files: string[]; } /** * Output of `runWizard()` in augment mode. Carries the outcome of * the magicast-based config mutation — either the file was * actually written (`changed: true`), the file was already * configured (`changed: false, reason: 'already-configured'`), * or the user cancelled at the confirmation prompt (`changed: false, * reason: 'cancelled'`), or we were in dry-run (`changed: false, * reason: 'dry-run'`). */ interface WizardAugmentResult { readonly kind: 'augment'; readonly configPath: string; readonly adapter: WizardAdapter; readonly changed: boolean; readonly reason: 'written' | 'already-configured' | 'cancelled' | 'dry-run' | 'unsupported-shape'; /** The unified diff that was shown to the user, if any. */ readonly diff?: string; } type WizardResult = WizardFreshResult | WizardAugmentResult; /** * The wizard entry point — `runWizard()`. * * Two modes: * * 1. **Interactive (default).** Uses `@clack/prompts` to ask the user * for project name, adapter, and sample-data inclusion. Cancellation * at any prompt aborts cleanly with a non-zero exit code. * * 2. **Non-interactive (`yes: true`).** Skips every prompt and uses the * values supplied in `WizardOptions`. Missing values become defaults. * This is the path tests take — no terminal needed, fully scriptable. * * The function never spawns child processes (no `npm install` etc.). It * only writes files and returns. The shell wrapper around `npm create` is * responsible for installing — we keep this layer pure so it's trivially * testable and so adding a `--no-install` flag later is a no-op. */ /** * Main entry point. Detects whether `cwd` is an existing Nuxt 4 * project and routes to one of two modes: * * - **Fresh mode** (the original behavior): prompts for * project name, creates a new directory, renders the Nuxt 4 * starter template. Returns a `WizardFreshResult`. * * - **Augment mode** (new in, ): patches the existing * `nuxt.config.ts` via magicast to add `@noy-db/in-nuxt` to the * modules array and a `noydb:` config key. Shows a unified * diff and asks for confirmation before writing. Supports * `--dry-run`. Returns a `WizardAugmentResult`. * * The auto-detection rule: if cwd has both a `nuxt.config.ts` * (or `.js`/`.mjs`) AND a `package.json` that lists `nuxt` in any * dependency section, augment mode fires. Otherwise fresh mode. * Users can force fresh mode via `forceFresh: true` (CLI: * `--force-fresh`) when they want to create a sub-project inside * an existing Nuxt workspace. * * Both modes refuse to clobber existing work: fresh mode rejects * non-empty target dirs; augment mode rejects unsupported config * shapes (opaque exports, non-array modules, etc.). */ declare function runWizard(options?: WizardOptions): Promise; /** * i18n entrypoint for the `create-noy-db` wizard. * * Three responsibilities: * * 1. Re-export `Locale` and `WizardMessages` so callers don't * need to know about the bundle layout. * 2. `detectLocale(env)` — pure function that maps Unix-style * `LC_ALL` / `LANG` / `LANGUAGE` env vars to a supported * `Locale`. Returns `'en'` for anything we don't recognise. * 3. `loadMessages(locale)` — synchronous lookup that returns * the message bundle for a locale. Synchronous (not dynamic * `import()`) on purpose: bundles are tiny (< 2 KB each), the * wizard reads them on every prompt, and async would force * every caller to be async. tsup tree-shakes unused locales * out of the bin only if we use top-level `import`s. * * ## Why env-var detection instead of `Intl.DateTimeFormat().resolvedOptions().locale` * * The Intl approach reads the JS engine's *display* locale, which * on most CI runners and Docker images is `en-US` regardless of * the user's actual setup. The Unix env vars (`LC_ALL`, `LANG`) * are how shells, terminals, and CLI tools have negotiated locale * for 30+ years — that's what a Thai-speaking dev's terminal will * actually have set. Following that convention also means power * users can override per-invocation with `LANG=th_TH.UTF-8 npm * create noy-db`, no flag required. */ /** Every locale we ship a bundle for. Used by tests and `--lang` validation. */ declare const SUPPORTED_LOCALES: readonly Locale[]; /** * Resolve a locale code to its message bundle. Falls back to `en` * if the requested locale isn't shipped — defensive, since * `Locale` is a union type and TS already prevents this at compile * time, but `--lang` parsing comes from user input at runtime. */ declare function loadMessages(locale: Locale): WizardMessages; /** * Auto-detect a locale from POSIX env vars. Returns `'en'` when * nothing is set or when the value doesn't match a supported * locale — never throws. * * Inspection order matches the POSIX spec: * 1. `LC_ALL` (overrides everything) * 2. `LC_MESSAGES` (the category we actually care about) * 3. `LANG` (system default) * 4. `LANGUAGE` (GNU extension, comma-separated preference list) * * The first non-empty value wins. We then strip the encoding * suffix (`th_TH.UTF-8` → `th_TH`) and the region (`th_TH` → `th`) * before matching against `SUPPORTED_LOCALES`. */ declare function detectLocale(env?: NodeJS.ProcessEnv): Locale; /** * Parse a `--lang` CLI argument into a `Locale`. Throws a clear * error for unsupported values — the caller (parse-args) catches * and reformats into a usage message. */ declare function parseLocaleFlag(value: string): Locale; /** * `noy-db add ` — scaffold a new collection inside an existing * Nuxt 4 project that already has `@noy-db/in-nuxt` configured. * * The command writes two files: * * 1. `app/stores/.ts` — a `defineNoydbStore()` call with * a placeholder `T` interface and one example field. The user fills * in the real shape after the file is created. * * 2. `app/pages/.vue` — a minimal CRUD page that lists, * adds, and deletes records. The store ID and collection name are * derived from the argument; everything else is boilerplate. * * The command refuses to overwrite existing files. If either target * already exists it logs which one and exits non-zero — the user has to * delete or move the file first. There's no `--force` because forcing an * overwrite of generated UI code is almost always a footgun in disguise. */ interface AddCollectionOptions { /** The collection name. Must be a lowercase identifier. */ name: string; /** Project root. Defaults to `process.cwd()`. */ cwd?: string; /** Compartment id to embed in the generated store. Defaults to `default`. */ vault?: string; } /** * Result returned to callers (the bin entry uses this to format output; * tests assert on the file paths). */ interface AddCollectionResult { /** Files written, in the order they were created. */ files: string[]; } declare function addCollection(options: AddCollectionOptions): Promise; /** * `noy-db verify` — end-to-end integrity check. * * Opens an in-memory NOYDB instance, writes a record, reads it back, * decrypts it, and asserts the round-trip is byte-identical. The check * exercises the full crypto path (PBKDF2 → KEK → DEK → AES-GCM) without * touching any user data on disk. * * Why an in-memory check is the right scope: * - It validates that @noy-db/core, @noy-db/memory, and the user's * installed Node version all agree on Web Crypto. That's the most * common silent failure for first-time installers. * - It cannot accidentally corrupt user data because there isn't any. * - It runs in well under one second, so users actually run it. * * What this command does NOT do (intentionally): * - Open the user's actual vault file/dynamo/s3/browser store. * That requires the user's secret — not something we want a CLI * `verify` command to prompt for. The full secret-driven verify * belongs in `nuxi noydb verify` once the auth story for CLIs lands * in. For now `noy-db verify` is the dependency-graph smoke test. */ interface VerifyResult { /** `true` if the round-trip succeeded; `false` if anything diverged. */ ok: boolean; /** Human-readable status. Always set, even on success. */ message: string; /** Wall-clock time the integrity check took, in ms. */ durationMs: number; } /** * Runs the end-to-end check. Pure function — no console output, no * `process.exit`. The bin wrapper handles formatting and exit codes so * the function is trivial to call from tests. */ declare function verifyIntegrity(): Promise; /** * Shared primitives for the interactive `noy-db` subcommands that * need to unlock a real vault. * * Three things live here: * * 1. `ReadSecret` — a tiny interface for "prompt the user for * a secret", with a test-friendly default. Subcommands take * this as an injected dependency so tests can short-circuit * the prompt without spawning a pty. * * 2. `defaultReadSecret` — the production implementation, * built on `@clack/prompts` `password()`. Never echoes the * value to the terminal, never logs it, clears it from the * returned promise after the caller consumes it. * * 3. `assertRole` — narrow unknown string input to the Role type * with a consistent error message. * * ## Why pull this out * * `rotate`, `addUser`, and `backup` all need the same "prompt for * a secret" shape and the same "open a file adapter and get * back a Noydb instance" shape. Duplicating it in three files would * drift over time; centralizing means one place to audit the * secret-handling contract (never log, never persist, clear * local variables after use). */ /** * Asynchronous secret reader. Production code passes * `defaultReadSecret`; tests pass a stub that returns a fixed * string without touching stdin. * * The `label` is shown to the user as the prompt message. It * should never contain the expected secret or any secret. */ type ReadSecret = (label: string) => Promise; /** * Narrow an unknown string to the `Role` type from @noy-db/core. * Used by the `add user` subcommand to validate the role argument * before passing it to `noydb.grant()`. */ declare function assertRole(input: string): Role; /** * Split a comma-separated collection list into an array of names, * trimming whitespace and dropping empties. Returns null if the * input itself is empty or undefined — the caller decides whether * that means "all collections" or "error". */ declare function parseCollectionList(input: string | undefined): string[] | null; /** * `noy-db rotate` — rotate the DEKs for one or more collections in * a vault. * * What it does * ------------ * For each target collection: * * 1. Generate a fresh DEK * 2. Decrypt every record with the old DEK * 3. Re-encrypt every record with the new DEK * 4. Re-wrap the new DEK into every remaining user's keyring * * The old DEKs become unreachable as soon as the keyring files are * updated. This is the "just rotate" path — nobody is revoked, * everybody keeps their current permissions, but the key material * is replaced. * * Why expose this as a CLI command * -------------------------------- * Two real-world scenarios: * * 1. **Suspected key leak.** An operator lost a laptop, a * developer accidentally pasted a secret into a Slack * channel, a USB stick went missing. Even if you think the * secret is safe, rotating is cheap insurance. * * 2. **Scheduled rotation.** Some compliance regimes require * periodic key rotation regardless of exposure. A CLI makes * this scriptable from cron or a CI job. * * This module is test-first: all inputs are plain options, the * secret reader is injected, and the Noydb factory is * injectable. The production bin is a thin wrapper that defaults * those injections to their real implementations. */ interface RotateOptions { /** Directory containing the vault data (file adapter only). */ dir: string; /** Vault (tenant) name to rotate keys in. */ vault: string; /** The user id of the operator running the rotate. */ user: string; /** * Explicit list of collections to rotate. When undefined, the * rotation targets every collection the user has a DEK for — * resolved at run time by reading the vault snapshot. */ collections?: string[]; /** Injected secret reader. Defaults to the clack implementation. */ readSecret?: ReadSecret; /** * Injected Noydb factory. Production code leaves this undefined * and gets `createNoydb`; tests pass a constructor that builds * against an in-memory adapter. */ createDb?: typeof createNoydb; /** * Injected adapter factory. Production code leaves this undefined * and gets `toFile`; tests pass one that returns the shared * in-memory adapter their fixture used. */ buildAdapter?: (dir: string) => NoydbStore; } interface RotateResult { /** The collections that were actually rotated. */ rotated: string[]; } /** * Run the rotate flow against a file-adapter vault. Returns * the list of collections that were rotated so callers can display * it to the user. * * Throws `Error` on any auth/adapter/rotate failure. The bin * catches these and prints a friendly message; direct callers * (tests) can inspect the error message to assert specific * failure modes. */ declare function rotate(options: RotateOptions): Promise; /** * `noy-db add user ` — grant a new user access to a * vault. * * What it does * ------------ * Wraps `noydb.grant()` in the CLI's auth-prompt ritual: * * 1. Prompt the caller for their own secret (to unlock the * caller's keyring and derive the wrapping key). * 2. Prompt for the new user's secret. * 3. Prompt for confirmation of the new secret. * 4. Reject on mismatch. * 5. Call `noydb.grant(vault, { userId, role, secret, permissions })`. * * For owner/admin/viewer roles, every collection is granted * automatically (the core keyring.ts grant logic handles that via * the `permissions` field). For operator/client, the caller must * pass a `--collections` list because those roles need explicit * per-collection permissions. * * ## What this does NOT do * * - No email/invite flow — is about local-CLI key management, * not out-of-band user enrollment. * - No rollback on partial failure — `grant()` is atomic at the * core level (keyring file writes last, after DEK wrapping), so * partial-state-on-crash is already handled. */ interface AddUserOptions { /** Directory containing the vault data (file adapter only). */ dir: string; /** Vault (tenant) name to grant access to. */ vault: string; /** The user id of the caller running the grant. */ callerUser: string; /** The new user's id (must not already exist in the vault keyring). */ newUserId: string; /** The new user's display name — shown in UI and audit logs. Defaults to `newUserId`. */ newUserDisplayName?: string; /** The new user's role. */ role: Role; /** * Per-collection permissions. Required when `role` is operator or * client; ignored for owner/admin/viewer (they get everything * via the core's resolvePermissions logic). * * Shape: `{ invoices: 'rw', clients: 'ro' }`. CLI callers pass * `--collections invoices:rw,clients:ro` and the argv parser * converts it to this shape. */ permissions?: Record; /** Injected secret reader. Defaults to the clack implementation. */ readSecret?: ReadSecret; /** Injected Noydb factory. */ createDb?: typeof createNoydb; /** Injected adapter factory. */ buildAdapter?: (dir: string) => NoydbStore; } interface AddUserResult { /** The userId that was granted access. */ userId: string; /** The role they were granted. */ role: Role; } /** * Run the grant flow. Two secret prompts: caller's, then new * user's (twice for confirmation). Calls `noydb.grant()` with the * collected values. */ declare function addUser(options: AddUserOptions): Promise; /** * `noy-db backup ` — dump a vault to a local file. * * What it does * ------------ * Wraps `vault.dump()` in the CLI's auth-prompt ritual, then * writes the serialized backup to the requested path. As of, * `dump()` already produces a verifiable backup (embedded * ledgerHead, full `_ledger` / `_ledger_deltas` snapshots) — the * CLI just moves bytes; the integrity guarantees come from core. * * ## Target URI support * * ships **`file://` only** (or a plain filesystem path). * The issue spec originally called for `s3://` as well, but * wiring @aws-sdk into create-noy-db would defeat the * zero-runtime-deps story for the CLI package. S3 backup is * deferred to a follow-up that can live in @noy-db/s3-cli or a * similar optional companion package. * * Accepted forms: * - `file:///absolute/path.json` * - `file://./relative/path.json` * - `/absolute/path.json` (treated as `file://`) * - `./relative/path.json` (treated as `file://`) * * ## What this does NOT do * * - No encryption of the backup BEYOND what noy-db already does. * The dumped file is a valid noy-db backup, which means * individual records are still encrypted but the keyring is * included (wrapped with each user's KEK). Anyone who loads * the backup still needs the correct secret to read. * - No restore — that's a separate subcommand tracked as a * follow-up. For now users can restore via * `vault.load(backupString)` from their own app code. */ interface BackupOptions { /** Directory containing the vault data (file adapter only). */ dir: string; /** Vault (tenant) name to back up. */ vault: string; /** The user id of the operator running the backup. */ user: string; /** * Where to write the backup. Accepts a `file://` URI or a plain * filesystem path. Relative paths resolve against `process.cwd()`. */ target: string; /** Injected secret reader. */ readSecret?: ReadSecret; /** Injected Noydb factory. */ createDb?: typeof createNoydb; /** Injected adapter factory. */ buildAdapter?: (dir: string) => NoydbStore; } interface BackupResult { /** Absolute filesystem path the backup was written to. */ path: string; /** Size of the serialized backup in bytes. */ bytes: number; } /** * Parse a backup target into an absolute filesystem path. Rejects * unsupported URI schemes (s3://, https://, etc.) early so the * caller doesn't silently write to the wrong place. */ declare function resolveBackupTarget(target: string, cwd?: string): string; declare function backup(options: BackupOptions): Promise; export { type AddCollectionOptions, type AddUserOptions, type AddUserResult, type BackupOptions, type BackupResult, type Locale, type ReadSecret, type RotateOptions, type RotateResult, SUPPORTED_LOCALES, type VerifyResult, type WizardMessages, type WizardOptions, type WizardResult, addCollection, addUser, assertRole, backup, detectLocale, loadMessages, parseCollectionList, parseLocaleFlag, resolveBackupTarget, rotate, runWizard, verifyIntegrity };