@gitpagedocs/cli 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,12 +24,13 @@ npx @gitpagedocs/cli
24
24
  | `gitpagedocs --home` | Standalone distribution (`gitpagedocshome/`: static site + .env + Dockerfile) |
25
25
  | `gitpagedocs -i` / `--interactive` | Interactive prompts |
26
26
  | `gitpagedocs ai` | Interactive AI documentation generator — writes pages in the gitpagedocs pattern (see below) |
27
+ | `gitpagedocs chat [question]` | Streaming AI chat in the terminal — REPL on a TTY, one-shot with a question or piped stdin; `--provider`, `--model`, `--system` (see below) |
27
28
  | `gitpagedocs provider [id]` · `models [provider]` | List AI providers / catalog models |
28
29
  | `gitpagedocs document[:repo\|:file\|:folder]` | Generate documentation with AI in the gitpagedocs pattern |
29
30
  | `gitpagedocs password` | Set a documentation access password (writes the public key to `config.json`, prints the private key) |
30
31
  | `gitpagedocs deploy` / `pages [actions\|deploy]` | Configure GitHub Pages via Actions + push |
31
32
  | `gitpagedocs docs` | Refresh managed regions of README/CONTRIBUTING/SECURITY |
32
- | `gitpagedocs config clear` | Delete the stored `.gitpagedocsconfig` (wipes saved AI credentials) |
33
+ | `gitpagedocs config clear` | Delete the stored `.gitpagedocsconfig` **and** the encrypted key vault `.gitpagedocsvault` (wipes saved AI credentials) |
33
34
  | `gitpagedocs doctor` · `version` · `update` | Diagnostics / version / registry update check |
34
35
  | `gitpagedocs mcp start` | Start the MCP server over stdio |
35
36
 
@@ -43,12 +44,17 @@ The AI-CLI implementation lives in `cli/ai/` (relocated from the frontend so the
43
44
  cli/
44
45
  |-- index.mjs # Node entry (bootstraps TS presentation via tsx)
45
46
  |-- package.json # the published `gitpagedocs` package
46
- |-- ai/ # AI documentation CLI (provider/model/paths → markdown)
47
+ |-- ai/ # AI CLI: docs generator + terminal chat
48
+ | |-- application/ # run-ai-cli-command, resolve-chat-credentials, ai-credentials (seal/unseal), vault-password
49
+ | |-- infrastructure/ # ai-config-file (.gitpagedocsconfig), ai-key-vault (.gitpagedocsvault), file-system-adapter, version-docs-writer
50
+ | |-- core/ # chat-session, config models + parser
51
+ | `-- presentation/ # @clack prompts (ai-prompts, vault-prompts)
47
52
  |-- contracts/ # Stable contracts for external tooling
48
53
  | `-- doc-versions.mjs
49
54
  |
50
55
  |-- presentation/ # Interface/composition root
51
56
  | |-- index.ts
57
+ | |-- commands/ # chat, config-clear, config-info, docs, pages, password, mcp, diagnostics, ai-info
52
58
  | |-- options/
53
59
  | `-- ui/
54
60
  |
@@ -139,7 +145,8 @@ npx @gitpagedocs/cli ai
139
145
  - asks API key or Ollama URL
140
146
  - asks paths to scan (supports one or many paths, including other repositories)
141
147
  - generates documentation in `pt`, `en`, `es` **in the gitpagedocs pattern**
142
- - optionally persists config in `.gitpagedocsconfig`
148
+ - optionally persists config in `.gitpagedocsconfig`, sealing the API key in the encrypted
149
+ vault `.gitpagedocsvault` (vault password created on first use, asked on every run)
143
150
 
144
151
  ### gitpagedocs pattern output
145
152
 
@@ -161,8 +168,8 @@ permissions on POSIX systems:
161
168
  - macOS: `~/Library/Application Support/gitpagedocs/.gitpagedocsconfig`
162
169
  - Linux: `$XDG_CONFIG_HOME/gitpagedocs/.gitpagedocsconfig` (or `~/.config/gitpagedocs/.gitpagedocsconfig`)
163
170
 
164
- Set `GITPAGEDOCS_CONFIG_DIR` to override the directory; delete the stored file
165
- and its credentials with `gitpagedocs config clear`; a legacy
171
+ Set `GITPAGEDOCS_CONFIG_DIR` to override the directory; delete the stored file,
172
+ the vault and their credentials with `gitpagedocs config clear`; a legacy
166
173
  `.gitpagedocsconfig` in the repository root is migrated automatically on the next
167
174
  run. You can create/edit this file manually and then run `npx @gitpagedocs/cli ai`:
168
175
 
@@ -172,7 +179,7 @@ run. You can create/edit this file manually and then run `npx @gitpagedocs/cli a
172
179
  "ai": {
173
180
  "provider": "openai",
174
181
  "model": "gpt-4o-mini",
175
- "apiKey": "<YOUR_API_KEY>",
182
+ "apiKeyEncrypted": true,
176
183
  "paths": ["src", "cli", "../another-repo/src"],
177
184
  "languages": ["pt", "en", "es"],
178
185
  "outputDir": "gitpagedocs/docs",
@@ -182,7 +189,38 @@ run. You can create/edit this file manually and then run `npx @gitpagedocs/cli a
182
189
  }
183
190
  ```
184
191
 
185
- For Ollama, use `baseUrl` instead of `apiKey`.
192
+ **The API key is not in this file.** Saving a configuration seals the key into the
193
+ encrypted vault `.gitpagedocsvault` in the same directory (`cli/ai/infrastructure/ai-key-vault.ts`
194
+ over `EncryptedCredentialVault` + `FileVaultStorage` from `@gitpagedocs/tools`: AES-256-GCM, key
195
+ derived from your password with PBKDF2-HMAC-SHA-256, 210k iterations) and the config only keeps
196
+ `"apiKeyEncrypted": true`.
197
+
198
+ - The **vault password is created on first use** (typed twice, at least 4 characters) and
199
+ **asked on every run** of `gitpagedocs ai` / `gitpagedocs chat` that uses the stored key
200
+ (3 attempts, then the run aborts with `Vault password rejected`).
201
+ - `GITPAGEDOCS_VAULT_PASSWORD` supplies the password without a prompt (CI, pipes, no TTY);
202
+ on a first run it initializes the vault, otherwise it must match the existing one.
203
+ - A legacy plaintext `"apiKey"` still present in an old file is sealed into the vault and
204
+ removed from the file the next time the config is read.
205
+ - If you hand-write the file, leave the key out: enter it in the `gitpagedocs ai` prompt (it is
206
+ sealed on save). `gitpagedocs chat` can also read the provider's environment variable
207
+ (`gitpagedocs provider <id>` lists it) when no key is stored.
208
+
209
+ For Ollama, use `baseUrl` instead of an API key (no vault involved).
210
+
211
+ ### `gitpagedocs chat`
212
+
213
+ ```bash
214
+ gitpagedocs chat # REPL on a TTY: /clear · /help · /exit (or Ctrl+C twice)
215
+ gitpagedocs chat "what does cli/ai do?" # one-shot answer, then exit
216
+ git diff | gitpagedocs chat "review this diff" # piped stdin is appended to the question
217
+ gitpagedocs chat --provider gemini --model gemini-2.5-pro --system "Answer in Portuguese" "..."
218
+ ```
219
+
220
+ Credentials are resolved in this order: `--provider`/`--model` flags, the stored
221
+ `.gitpagedocsconfig` (its key is decrypted after the vault password prompt, or from
222
+ `GITPAGEDOCS_VAULT_PASSWORD`), then the provider's environment variable. Output streams to
223
+ stdout while status lines go to stderr, so the answer can be piped.
186
224
 
187
225
  ### Interactive fallback
188
226
 
@@ -0,0 +1,100 @@
1
+ import { SecurityError } from "@gitpagedocs/tools";
2
+ import type { AiCliConfig } from "../core/models/ai-cli-config";
3
+ import type { AiConfigFileRepository } from "../infrastructure/ai-config-file";
4
+ import type { AiKeyVault } from "../infrastructure/ai-key-vault";
5
+ import { resolveVaultPassword, type VaultPasswordPrompt } from "./vault-password";
6
+
7
+ export type KeyVault = Pick<AiKeyVault, "isInitialized" | "initialize" | "unlock" | "setKey" | "getKey" | "getVaultPath">;
8
+
9
+ /** A config carries a key to protect (plaintext to migrate) or one sealed in the vault. */
10
+ export function needsVaultPassword(config: AiCliConfig): boolean {
11
+ return config.ai.apiKeyEncrypted === true || Boolean(config.ai.apiKey?.trim());
12
+ }
13
+
14
+ export interface SealApiKeyInput {
15
+ readonly config: AiCliConfig;
16
+ readonly vault: Pick<KeyVault, "setKey">;
17
+ readonly password: string;
18
+ }
19
+
20
+ /**
21
+ * Moves the plaintext API key into the vault and returns the config to persist:
22
+ * no `apiKey`, `apiKeyEncrypted: true`. A config without a key is returned as is.
23
+ */
24
+ export async function sealApiKey({ config, vault, password }: SealApiKeyInput): Promise<AiCliConfig> {
25
+ const apiKey = config.ai.apiKey?.trim();
26
+ if (!apiKey) return config;
27
+ await vault.setKey(password, config.ai.provider, apiKey);
28
+ const ai = { ...config.ai };
29
+ delete ai.apiKey;
30
+ return { ...config, ai: { ...ai, apiKeyEncrypted: true } };
31
+ }
32
+
33
+ export interface UnsealApiKeyInput {
34
+ readonly config: AiCliConfig;
35
+ readonly vault: Pick<KeyVault, "getKey">;
36
+ readonly password: string;
37
+ }
38
+
39
+ /** The stored config with its key decrypted into memory (never written back). */
40
+ export async function unsealApiKey({ config, vault, password }: UnsealApiKeyInput): Promise<AiCliConfig> {
41
+ if (!config.ai.apiKeyEncrypted) return config;
42
+ const apiKey = await vault.getKey(password, config.ai.provider);
43
+ return apiKey ? { ...config, ai: { ...config.ai, apiKey } } : config;
44
+ }
45
+
46
+ export interface UnlockStoredConfigInput {
47
+ readonly config: AiCliConfig;
48
+ readonly vault: KeyVault;
49
+ readonly configRepo: Pick<AiConfigFileRepository, "write">;
50
+ readonly env?: Readonly<Record<string, string | undefined>>;
51
+ readonly prompt?: VaultPasswordPrompt;
52
+ readonly onInfo?: (message: string) => void;
53
+ }
54
+
55
+ /**
56
+ * Turns the stored config into one a run can use. An encrypted key is decrypted
57
+ * into memory after the password check; a legacy plaintext key is sealed into
58
+ * the vault and stripped from `.gitpagedocsconfig` on the spot, so the file
59
+ * never keeps a key in clear again.
60
+ */
61
+ export async function unlockStoredConfig(input: UnlockStoredConfigInput): Promise<AiCliConfig> {
62
+ const { config } = input;
63
+ if (!needsVaultPassword(config)) return config;
64
+ const password = await resolveVaultPassword({ vault: input.vault, env: input.env, prompt: input.prompt });
65
+
66
+ if (config.ai.apiKeyEncrypted && !config.ai.apiKey?.trim()) {
67
+ const unsealed = await unsealApiKey({ config, vault: input.vault, password });
68
+ if (!unsealed.ai.apiKey) {
69
+ throw new SecurityError(
70
+ `No API key is stored for "${config.ai.provider}". Run "gitpagedocs ai" and enter the key again, or "gitpagedocs config clear".`,
71
+ );
72
+ }
73
+ return unsealed;
74
+ }
75
+
76
+ const sealed = await sealApiKey({ config, vault: input.vault, password });
77
+ await input.configRepo.write(sealed);
78
+ input.onInfo?.(
79
+ `[gitpagedocs:ai] API key moved from .gitpagedocsconfig into the encrypted vault (${input.vault.getVaultPath()}).`,
80
+ );
81
+ return config;
82
+ }
83
+
84
+ export type StoredKeyUnlocker = (stored: AiCliConfig) => Promise<string | undefined>;
85
+
86
+ /**
87
+ * Callback for credential resolvers: the decrypted key of the stored config, or
88
+ * undefined (with the reason reported) when the vault cannot be opened.
89
+ */
90
+ export function createStoredKeyUnlocker(deps: Omit<UnlockStoredConfigInput, "config">): StoredKeyUnlocker {
91
+ return async (stored) => {
92
+ try {
93
+ const unlocked = await unlockStoredConfig({ ...deps, config: stored });
94
+ return unlocked.ai.apiKey;
95
+ } catch (error) {
96
+ deps.onInfo?.(error instanceof Error ? error.message : String(error));
97
+ return undefined;
98
+ }
99
+ };
100
+ }
@@ -3,6 +3,7 @@ import type { AiProviderId } from "@gitpagedocs/tools/ports";
3
3
  import { PROVIDER_CATALOG } from "@gitpagedocs/tools";
4
4
  import { AiConfigFileRepository } from "../infrastructure/ai-config-file";
5
5
  import type { AiCliConfig } from "../core/models/ai-cli-config";
6
+ import { needsVaultPassword, type StoredKeyUnlocker } from "./ai-credentials";
6
7
 
7
8
  export interface ResolvedChatCredentials {
8
9
  readonly providerId: AiProviderId;
@@ -21,6 +22,12 @@ export interface ResolveChatCredentialsInput {
21
22
  readonly configRepo?: Pick<AiConfigFileRepository, "read">;
22
23
  /** Injectable for tests; defaults to process.env. */
23
24
  readonly env?: Readonly<Record<string, string | undefined>>;
25
+ /**
26
+ * Decrypts the key of a stored config (asking the vault password) and
27
+ * migrates a legacy plaintext key into the vault. Without it a plaintext
28
+ * key is used as is and an encrypted one is unreachable.
29
+ */
30
+ readonly unlockStoredKey?: StoredKeyUnlocker;
24
31
  }
25
32
 
26
33
  /**
@@ -40,7 +47,11 @@ export async function resolveChatCredentials(
40
47
  const spec = PROVIDER_CATALOG[providerId];
41
48
  const model = input.modelOverride?.trim() || stored?.ai.model?.trim() || spec.defaultModel;
42
49
  const baseUrl = stored?.ai.baseUrl?.trim() || undefined;
43
- const apiKey = stored?.ai.apiKey?.trim() || resolveApiKeyFromEnv(providerId, env);
50
+ const storedKey =
51
+ stored && input.unlockStoredKey && needsVaultPassword(stored)
52
+ ? await input.unlockStoredKey(stored)
53
+ : stored?.ai.apiKey?.trim();
54
+ const apiKey = storedKey?.trim() || resolveApiKeyFromEnv(providerId, env);
44
55
 
45
56
  const keyless = spec.auth === "none";
46
57
  if (!keyless && !apiKey) return null;
@@ -1,11 +1,15 @@
1
1
  import { AiCommandService } from "./ai-command";
2
2
  import { createAiProvider } from "./ai-provider-factory";
3
+ import { sealApiKey, unlockStoredConfig } from "./ai-credentials";
4
+ import { resolveVaultPassword, type VaultPasswordPrompt } from "./vault-password";
3
5
  import { FileSystemAdapter, type FilePayload } from "../infrastructure/file-system-adapter";
4
6
  import {
5
7
  AiConfigFileRepository,
6
8
  } from "../infrastructure/ai-config-file";
7
- import type { AiCliRunPlan, AiCliRunSummary } from "../core/models/ai-cli-config";
9
+ import { AiKeyVault } from "../infrastructure/ai-key-vault";
10
+ import type { AiCliConfig, AiCliRunPlan, AiCliRunSummary } from "../core/models/ai-cli-config";
8
11
  import { promptMissingDirectories, runAiInteractivePrompt } from "../presentation/ai-prompts";
12
+ import { clackVaultPasswordPrompt } from "../presentation/vault-prompts";
9
13
  import { GITPAGEDOCS_DOC_SYSTEM_PROMPT } from "../config";
10
14
  import { parseAiPages, type AiDocPage } from "./docs-pattern";
11
15
  import { writeVersionDocs } from "../infrastructure/version-docs-writer";
@@ -130,26 +134,62 @@ export async function executeAiRunPlan(
130
134
  };
131
135
  }
132
136
 
133
- export async function runAiCliCommand(options: {
137
+ export interface RunAiCliCommandOptions {
134
138
  cwd: string;
135
139
  onInfo?: (message: string) => void;
136
140
  /** Provided by the composition root to scaffold the base gitpagedocs/ tree
137
141
  * (so the version config.json exists before AI pages are wired into it). */
138
142
  onScaffold?: () => Promise<void>;
139
- }): Promise<{ summary: AiCliRunSummary; runConfigScaffold: boolean }> {
143
+ /** Injectable for tests; defaults to the per-user vault file. */
144
+ vault?: AiKeyVault;
145
+ /** Injectable for tests; defaults to the terminal prompts. */
146
+ passwordPrompt?: VaultPasswordPrompt;
147
+ /** Injectable for tests; defaults to process.env. */
148
+ env?: Readonly<Record<string, string | undefined>>;
149
+ }
150
+
151
+ interface SaveConfigDeps {
152
+ configRepo: AiConfigFileRepository;
153
+ vault: AiKeyVault;
154
+ prompt: VaultPasswordPrompt;
155
+ env?: Readonly<Record<string, string | undefined>>;
156
+ logInfo: (message: string) => void;
157
+ }
158
+
159
+ /** Persists the plan's config; an API key is sealed into the vault first, never written in clear. */
160
+ async function saveConfig(config: AiCliConfig, deps: SaveConfigDeps): Promise<void> {
161
+ if (!config.ai.apiKey?.trim()) {
162
+ await deps.configRepo.write(config);
163
+ deps.logInfo(`[gitpagedocs:ai] Configuration saved to ${deps.configRepo.getConfigPath()}`);
164
+ return;
165
+ }
166
+ const password = await resolveVaultPassword({ vault: deps.vault, env: deps.env, prompt: deps.prompt });
167
+ await deps.configRepo.write(await sealApiKey({ config, vault: deps.vault, password }));
168
+ deps.logInfo(
169
+ `[gitpagedocs:ai] Configuration saved to ${deps.configRepo.getConfigPath()} (API key encrypted in ${deps.vault.getVaultPath()})`,
170
+ );
171
+ }
172
+
173
+ export async function runAiCliCommand(options: RunAiCliCommandOptions): Promise<{ summary: AiCliRunSummary; runConfigScaffold: boolean }> {
140
174
  const logInfo = options.onInfo ?? (() => undefined);
141
175
  const configRepo = new AiConfigFileRepository({
142
176
  cwd: options.cwd,
143
177
  onMigrate: (fromPath, toPath) =>
144
178
  logInfo(`[gitpagedocs:ai] Moved configuration from ${fromPath} to ${toPath} (secure user config directory).`),
145
179
  });
180
+ const vault = options.vault ?? new AiKeyVault();
181
+ const prompt = options.passwordPrompt ?? clackVaultPasswordPrompt;
146
182
 
147
183
  const existingConfig = await configRepo.read();
148
184
  const plan = await runAiInteractivePrompt(existingConfig);
185
+ let runConfig = plan.config;
149
186
 
150
187
  if (plan.saveConfig) {
151
- await configRepo.write(plan.config);
152
- logInfo(`[gitpagedocs:ai] Configuration saved to ${configRepo.getConfigPath()}`);
188
+ await saveConfig(runConfig, { configRepo, vault, prompt, env: options.env, logInfo });
189
+ } else if (existingConfig && runConfig === existingConfig) {
190
+ // Reusing the stored config: decrypt its key for this run (the password is
191
+ // asked every time), or seal a legacy plaintext key into the vault.
192
+ runConfig = await unlockStoredConfig({ config: runConfig, vault, configRepo, env: options.env, prompt, onInfo: logInfo });
153
193
  }
154
194
 
155
195
  // Build the base gitpagedocs structure BEFORE generation so the version
@@ -159,7 +199,7 @@ export async function runAiCliCommand(options: {
159
199
  await options.onScaffold();
160
200
  }
161
201
 
162
- const summary = await executeAiRunPlan(plan, options.cwd);
202
+ const summary = await executeAiRunPlan({ ...plan, config: runConfig }, options.cwd);
163
203
  return {
164
204
  summary,
165
205
  runConfigScaffold: plan.runConfigScaffold,
@@ -0,0 +1,66 @@
1
+ import { SecurityError } from "@gitpagedocs/tools";
2
+ import type { AiKeyVault } from "../infrastructure/ai-key-vault";
3
+
4
+ /** Non-interactive runs (CI, pipes) supply the vault password through this variable. */
5
+ export const VAULT_PASSWORD_ENV = "GITPAGEDOCS_VAULT_PASSWORD";
6
+
7
+ const DEFAULT_MAX_ATTEMPTS = 3;
8
+
9
+ /** How the host asks for the password (terminal prompt, test double, ...). */
10
+ export interface VaultPasswordPrompt {
11
+ /** First run: let the user choose (and confirm) the password. */
12
+ create(): Promise<string>;
13
+ /** Later runs: ask for the existing password; `attempt` starts at 1. */
14
+ unlock(attempt: number): Promise<string>;
15
+ }
16
+
17
+ export type PasswordVault = Pick<AiKeyVault, "isInitialized" | "initialize" | "unlock">;
18
+
19
+ export interface ResolveVaultPasswordInput {
20
+ readonly vault: PasswordVault;
21
+ /** Defaults to process.env. */
22
+ readonly env?: Readonly<Record<string, string | undefined>>;
23
+ /** Absent when no terminal is available. */
24
+ readonly prompt?: VaultPasswordPrompt;
25
+ readonly maxAttempts?: number;
26
+ }
27
+
28
+ /**
29
+ * The verified vault password for this run. Order: the environment variable
30
+ * (initializing the vault on first use), otherwise the prompt — create on first
31
+ * run, unlock afterwards with up to three attempts. The password is asked on
32
+ * every command run; it is never persisted.
33
+ */
34
+ export async function resolveVaultPassword(input: ResolveVaultPasswordInput): Promise<string> {
35
+ const env = input.env ?? process.env;
36
+ const initialized = await input.vault.isInitialized();
37
+
38
+ const fromEnv = env[VAULT_PASSWORD_ENV]?.trim();
39
+ if (fromEnv) {
40
+ if (!initialized) {
41
+ await input.vault.initialize(fromEnv);
42
+ return fromEnv;
43
+ }
44
+ if (await input.vault.unlock(fromEnv)) return fromEnv;
45
+ throw new SecurityError(`${VAULT_PASSWORD_ENV} does not match the vault password.`);
46
+ }
47
+
48
+ if (!input.prompt) {
49
+ throw new SecurityError(
50
+ `A password is required to read the encrypted API key. Run the command in a terminal or set ${VAULT_PASSWORD_ENV}.`,
51
+ );
52
+ }
53
+
54
+ if (!initialized) {
55
+ const password = await input.prompt.create();
56
+ await input.vault.initialize(password);
57
+ return password;
58
+ }
59
+
60
+ const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
61
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
62
+ const password = await input.prompt.unlock(attempt);
63
+ if (await input.vault.unlock(password)) return password;
64
+ }
65
+ throw new SecurityError(`Vault password rejected after ${maxAttempts} attempts.`);
66
+ }
package/ai/config.ts CHANGED
@@ -11,8 +11,8 @@ export type AiProviderId = 'openai' | 'claude' | 'gemini' | 'ollama';
11
11
 
12
12
  export const AI_MODEL_DEFAULTS: Readonly<Record<AiProviderId, string>> = {
13
13
  openai: 'gpt-4o-mini',
14
- claude: 'claude-3-5-sonnet-20240620',
15
- gemini: 'gemini-1.5-flash',
14
+ claude: 'claude-sonnet-4-6',
15
+ gemini: 'gemini-2.5-flash',
16
16
  ollama: 'llama3',
17
17
  };
18
18
 
@@ -7,7 +7,10 @@ export interface AiCliConfig {
7
7
  ai: {
8
8
  provider: AiProviderId;
9
9
  model: string;
10
+ /** Only in memory during a run: the file never stores the key in clear. */
10
11
  apiKey?: string;
12
+ /** The key lives sealed in the `.gitpagedocsvault` next to this file. */
13
+ apiKeyEncrypted?: boolean;
11
14
  baseUrl?: string;
12
15
  paths: string[];
13
16
  languages: Array<"pt" | "en" | "es">;
@@ -57,6 +57,7 @@ export function parseAiCliConfig(raw: unknown): AiCliConfig | null {
57
57
 
58
58
  const model = asOptionalString(ai.model)?.trim() || AI_MODEL_DEFAULTS[provider];
59
59
  const apiKey = asOptionalString(ai.apiKey);
60
+ const apiKeyEncrypted = ai.apiKeyEncrypted === true;
60
61
  const baseUrl = asOptionalString(ai.baseUrl);
61
62
 
62
63
  return {
@@ -65,6 +66,7 @@ export function parseAiCliConfig(raw: unknown): AiCliConfig | null {
65
66
  provider,
66
67
  model,
67
68
  ...(apiKey !== undefined ? { apiKey } : {}),
69
+ ...(apiKeyEncrypted ? { apiKeyEncrypted: true } : {}),
68
70
  ...(baseUrl !== undefined ? { baseUrl } : {}),
69
71
  paths: asPaths(ai.paths),
70
72
  languages: asLanguages(ai.languages),
@@ -0,0 +1,65 @@
1
+ import path from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ EncryptedCredentialVault,
5
+ FileVaultStorage,
6
+ NodeCryptoService,
7
+ resolveUserConfigDir,
8
+ } from "@gitpagedocs/tools";
9
+
10
+ export const AI_KEY_VAULT_FILENAME = ".gitpagedocsvault";
11
+
12
+ export interface AiKeyVaultOptions {
13
+ /** Directory of the vault file; defaults to the per-user OS config directory. */
14
+ configDir?: string;
15
+ }
16
+
17
+ /**
18
+ * Password-encrypted store for the CLI's AI API keys, kept next to
19
+ * `.gitpagedocsconfig` in the per-user config directory. Every key is sealed
20
+ * with AES-256-GCM under a key derived from the password by PBKDF2-HMAC-SHA-256
21
+ * (210,000 iterations, random salt); the file never holds a key in clear and
22
+ * only the password decrypts it.
23
+ */
24
+ export class AiKeyVault {
25
+ private readonly filePath: string;
26
+ private readonly vault: EncryptedCredentialVault;
27
+
28
+ constructor(options: AiKeyVaultOptions = {}) {
29
+ this.filePath = path.join(options.configDir ?? resolveUserConfigDir(), AI_KEY_VAULT_FILENAME);
30
+ this.vault = new EncryptedCredentialVault(new FileVaultStorage(this.filePath), new NodeCryptoService());
31
+ }
32
+
33
+ getVaultPath(): string {
34
+ return this.filePath;
35
+ }
36
+
37
+ /** True once a password has been set (the vault file exists). */
38
+ isInitialized(): Promise<boolean> {
39
+ return this.vault.isInitialized();
40
+ }
41
+
42
+ initialize(password: string): Promise<void> {
43
+ return this.vault.initialize(password);
44
+ }
45
+
46
+ /** Whether `password` opens the vault; never throws for a wrong password. */
47
+ unlock(password: string): Promise<boolean> {
48
+ return this.vault.unlock(password);
49
+ }
50
+
51
+ setKey(password: string, providerId: string, apiKey: string): Promise<void> {
52
+ return this.vault.setCredential(password, providerId, apiKey);
53
+ }
54
+
55
+ getKey(password: string, providerId: string): Promise<string | undefined> {
56
+ return this.vault.getCredential(password, providerId);
57
+ }
58
+
59
+ /** Deletes the vault file with every sealed key. Returns whether a file existed. */
60
+ async clear(): Promise<boolean> {
61
+ const existed = existsSync(this.filePath);
62
+ await this.vault.reset();
63
+ return existed;
64
+ }
65
+ }
@@ -0,0 +1,35 @@
1
+ import { SecurityError } from "@gitpagedocs/tools";
2
+ import { askPassword, note } from "../../presentation/ui/clack";
3
+ import { VAULT_PASSWORD_ENV, type VaultPasswordPrompt } from "../application/vault-password";
4
+
5
+ const MIN_PASSWORD_LENGTH = 4;
6
+
7
+ function requireTerminal(): void {
8
+ if (!process.stdin.isTTY) {
9
+ throw new SecurityError(
10
+ `The encrypted API key needs its password. Run the command in a terminal or set ${VAULT_PASSWORD_ENV}.`,
11
+ );
12
+ }
13
+ }
14
+
15
+ /** Terminal prompts for the vault password: create + confirm on first run, unlock afterwards. */
16
+ export const clackVaultPasswordPrompt: VaultPasswordPrompt = {
17
+ async create() {
18
+ requireTerminal();
19
+ for (;;) {
20
+ const password = await askPassword({
21
+ message: "Create a local password to encrypt your API key (asked on every run):",
22
+ validate: (value) => (value.trim().length >= MIN_PASSWORD_LENGTH ? undefined : `Use at least ${MIN_PASSWORD_LENGTH} characters.`),
23
+ });
24
+ const confirmation = await askPassword({ message: "Confirm the password:" });
25
+ if (password === confirmation) return password;
26
+ note("The passwords do not match. Try again.");
27
+ }
28
+ },
29
+ async unlock(attempt) {
30
+ requireTerminal();
31
+ return askPassword({
32
+ message: attempt > 1 ? `Local password (attempt ${attempt} of 3):` : "Local password to decrypt the API key:",
33
+ });
34
+ },
35
+ };
@@ -187,9 +187,11 @@ The version config can render a **Source code** container through \`routes-sourc
187
187
  The docs ship an AI assistant in two surfaces: an in-docs **chat drawer** (the AI chat button in the sidebar, enabled via \`site.AiChatEnabled\`) and a dedicated **\`/ai\` console** page.
188
188
 
189
189
  - **14 providers** via one shared core: OpenAI, Anthropic, Gemini, OpenRouter, Ollama, Azure OpenAI, Mistral, DeepSeek, Cohere, Groq, xAI, Together, Fireworks, Perplexity.
190
- - **Model selection** — pick from each provider's catalog (\`gitpagedocs models <provider>\`) or type a custom id.
191
- - **Encrypted at rest** — your API key is sealed with AES-256-GCM behind a **local password** (one unlock per session) and is never stored in plaintext or logged. A legacy plaintext key is migrated and wiped on first unlock.
192
- - **AI documentation generation** — \`gitpagedocs ai\` scans chosen paths and writes multilingual markdown (pt/en/es); reusable via \`.gitpagedocsconfig\`.
190
+ - **Model selection** — pick from each provider's catalog (\`gitpagedocs models <provider>\`); a stored model id the provider has retired is replaced by the provider default automatically.
191
+ - **Encrypted at rest** — your API key is sealed with AES-256-GCM behind a **local password** and is never stored in plaintext or logged. A legacy plaintext key is migrated and wiped on first unlock.
192
+ - **Inactivity auto-lock** — the chat drawer locks itself after \`site.AiChatAutoLockSeconds\` idle seconds (default 30, \`0\` disables): a centered countdown modal in your language lets you cancel or lock now, and unlocking asks for the password again.
193
+ - **Resilient providers** — transient provider errors are retried (3 attempts) and a final failure is a plain message ending with "Try again!".
194
+ - **AI documentation generation** — \`gitpagedocs ai\` scans chosen paths and writes multilingual markdown (pt/en/es); reusable via \`.gitpagedocsconfig\`, whose API key lives sealed in the encrypted \`.gitpagedocsvault\` (the vault password is asked on every run). \`gitpagedocs chat\` brings the same assistant to the terminal.
193
195
 
194
196
  ## MCP server
195
197
 
@@ -165,6 +165,21 @@ El config de version puede renderizar un contenedor **Codigo fuente** via \`rout
165
165
 
166
166
  - \`GITPAGEDOCS_REPOSITORY_SEARCH\` – busqueda de repositorio (local)
167
167
  - \`GITHUB_ACTIONS\` – modo build GitHub Pages
168
+
169
+ ## Asistente de IA
170
+
171
+ Los docs traen un asistente de IA en dos superficies: un **panel de chat** dentro de los docs (boton de chat de IA en la barra lateral, activado por \`site.AiChatEnabled\`) y una pagina **consola \`/ai\`** dedicada.
172
+
173
+ - **14 proveedores** en un unico core compartido: OpenAI, Anthropic, Gemini, OpenRouter, Ollama, Azure OpenAI, Mistral, DeepSeek, Cohere, Groq, xAI, Together, Fireworks, Perplexity.
174
+ - **Eleccion de modelo** — desde el catalogo de cada proveedor (\`gitpagedocs models <proveedor>\`); un id de modelo guardado que el proveedor retiro se reemplaza por el predeterminado del proveedor automaticamente.
175
+ - **Cifrado en reposo** — tu clave de API se sella con AES-256-GCM detras de una **contrasena local** y nunca queda en texto plano ni en logs. Una clave heredada en texto plano se migra y se borra en el primer desbloqueo.
176
+ - **Bloqueo por inactividad** — el panel de chat se bloquea solo tras \`site.AiChatAutoLockSeconds\` segundos sin uso (por defecto 30, \`0\` lo desactiva): un modal centrado con cuenta regresiva, en tu idioma, permite cancelar o bloquear ahora, y desbloquear pide la contrasena de nuevo.
177
+ - **Proveedores resilientes** — los errores transitorios del proveedor se reintentan (3 intentos) y un fallo final es un mensaje simple que termina en "¡Inténtalo de nuevo!".
178
+ - **Generacion de documentacion con IA** — \`gitpagedocs ai\` recorre las rutas elegidas y escribe markdown multilingue (pt/en/es); reutilizable via \`.gitpagedocsconfig\`, cuya clave de API queda sellada en la boveda cifrada \`.gitpagedocsvault\` (la contrasena de la boveda se pide en cada ejecucion). \`gitpagedocs chat\` lleva el mismo asistente a la terminal.
179
+
180
+ ## Servidor MCP
181
+
182
+ \`gitpagedocs mcp start\` levanta un servidor Model Context Protocol (stdio) que expone **20 tools** (sistema de archivos, IA, generacion/analisis de docs) y **7 resources** (\`project://structure|docs|config|repository|readme|ai/providers|ai/models\`) para editores y agentes de IA.
168
183
  `,
169
184
  configuration: `# Configuracion
170
185
 
@@ -311,7 +326,7 @@ El proyecto esta organizado por fronteras de feature y responsabilidades de runt
311
326
 
312
327
  ## Seguridad: credenciales de IA cifradas
313
328
 
314
- La consola \`/ai\` y el panel de chat exigen una contrasena local que deriva (PBKDF2) una clave AES-256-GCM; las claves quedan cifradas en \`localStorage\` y solo se descifran durante la sesion (\`@gitpagedocs/tools/security\`).
329
+ La consola \`/ai\` y el panel de chat exigen una contrasena local que deriva (PBKDF2) una clave AES-256-GCM; las claves quedan cifradas en \`localStorage\` y solo se descifran durante la sesion (\`@gitpagedocs/tools/security\`). El panel se bloquea solo tras \`site.AiChatAutoLockSeconds\` segundos sin uso (por defecto 30; \`0\` lo desactiva): un modal centrado con cuenta regresiva, en el idioma seleccionado, permite cancelar o bloquear ahora, y volver exige la contrasena de nuevo. En la CLI, la clave de \`.gitpagedocsconfig\` queda sellada en la boveda cifrada \`.gitpagedocsvault\` y la contrasena de la boveda se pide en cada ejecucion.
315
330
 
316
331
  ## Flujo de datos
317
332
 
@@ -165,6 +165,21 @@ O config da versao pode renderizar um container **Codigo fonte** via \`routes-so
165
165
 
166
166
  - \`GITPAGEDOCS_REPOSITORY_SEARCH\` – busca de repositorio (local)
167
167
  - \`GITHUB_ACTIONS\` – modo build GitHub Pages
168
+
169
+ ## Assistente de IA
170
+
171
+ Os docs trazem um assistente de IA em duas superficies: um **chat drawer** dentro dos docs (botao de chat de IA na barra lateral, ativado por \`site.AiChatEnabled\`) e uma pagina **console \`/ai\`** dedicada.
172
+
173
+ - **14 provedores** em um unico core compartilhado: OpenAI, Anthropic, Gemini, OpenRouter, Ollama, Azure OpenAI, Mistral, DeepSeek, Cohere, Groq, xAI, Together, Fireworks, Perplexity.
174
+ - **Escolha de modelo** — a partir do catalogo de cada provedor (\`gitpagedocs models <provedor>\`); um id de modelo salvo que o provedor aposentou e trocado pelo padrao do provedor automaticamente.
175
+ - **Criptografia em repouso** — sua chave de API e selada com AES-256-GCM atras de uma **senha local** e nunca fica em texto puro nem em logs. Uma chave legada em texto puro e migrada e apagada no primeiro desbloqueio.
176
+ - **Bloqueio por inatividade** — o chat drawer se bloqueia sozinho apos \`site.AiChatAutoLockSeconds\` segundos sem uso (padrao 30, \`0\` desativa): um modal centralizado com contagem regressiva, no seu idioma, permite cancelar ou bloquear agora, e desbloquear pede a senha de novo.
177
+ - **Provedores resilientes** — erros transitorios do provedor sao repetidos (3 tentativas) e uma falha final vira uma mensagem simples terminando em "Tente novamente!".
178
+ - **Geracao de documentacao com IA** — \`gitpagedocs ai\` varre os caminhos escolhidos e escreve markdown multilingue (pt/en/es); reutilizavel via \`.gitpagedocsconfig\`, cuja chave de API fica selada no cofre criptografado \`.gitpagedocsvault\` (a senha do cofre e pedida em toda execucao). \`gitpagedocs chat\` leva o mesmo assistente ao terminal.
179
+
180
+ ## Servidor MCP
181
+
182
+ \`gitpagedocs mcp start\` sobe um servidor Model Context Protocol (stdio) expondo **20 tools** (sistema de arquivos, IA, geracao/analise de docs) e **7 resources** (\`project://structure|docs|config|repository|readme|ai/providers|ai/models\`) para editores e agentes de IA.
168
183
  `,
169
184
  configuration: `# Configuracao
170
185
 
@@ -311,7 +326,7 @@ O projeto e organizado por fronteiras de feature e responsabilidades do runtime.
311
326
 
312
327
  ## Seguranca: credenciais de IA criptografadas
313
328
 
314
- O console \`/ai\` e o chat drawer exigem uma senha local que deriva (PBKDF2) uma chave AES-256-GCM; as chaves ficam criptografadas no \`localStorage\` e so sao descriptografadas durante a sessao (\`@gitpagedocs/tools/security\`).
329
+ O console \`/ai\` e o chat drawer exigem uma senha local que deriva (PBKDF2) uma chave AES-256-GCM; as chaves ficam criptografadas no \`localStorage\` e so sao descriptografadas durante a sessao (\`@gitpagedocs/tools/security\`). O drawer se bloqueia sozinho apos \`site.AiChatAutoLockSeconds\` segundos sem uso (padrao 30; \`0\` desativa): um modal centralizado com contagem regressiva, no idioma selecionado, permite cancelar ou bloquear agora, e voltar exige a senha de novo. Na CLI, a chave de \`.gitpagedocsconfig\` fica selada no cofre criptografado \`.gitpagedocsvault\` e a senha do cofre e pedida em toda execucao.
315
330
 
316
331
  ## Fluxo de dados
317
332
 
@@ -1,3 +1,3 @@
1
- export const PACKAGE_VERSION = "0.0.1";
1
+ export const PACKAGE_VERSION = "0.0.3";
2
2
 
3
3
  export const DOC_VERSIONS = [PACKAGE_VERSION];
@@ -138,6 +138,7 @@ export function getDefaultSiteConfig(projectLink, languageToggles = buildLanguag
138
138
  audioSequentialPlayback: false,
139
139
  audioPopoverHideSource: false,
140
140
  audioPopoverShowMinutes: true,
141
+ AiChatAutoLockSeconds: 30,
141
142
  audioTracks: [
142
143
  {
143
144
  url: "https://www.youtube.com/watch?v=xAR6N9N8e6U",
@@ -81,12 +81,17 @@ export const defaultLangMenu = {
81
81
  aiChatResetPopupDesc: "Isso apaga todas as chaves de API salvas e a senha local. Você criará uma nova senha na próxima vez.",
82
82
  aiChatResetConfirmBtn: "Sim, redefinir",
83
83
  aiChatResetCancelBtn: "Cancelar",
84
+ aiChatRetryHint: "Tente novamente!",
85
+ aiChatAutoLockTitle: "Bloqueio por inatividade",
86
+ aiChatAutoLockDesc: "Você não usou a IA por um tempo. O chat será bloqueado e suas chaves continuarão criptografadas em {seconds}s.",
87
+ aiChatAutoLockConfirmBtn: "Salvar",
88
+ aiChatAutoLockCancelBtn: "Cancelar",
84
89
  aiChatProviderLabel: "Provedor:",
85
90
  aiChatApiKeyLabel: "Chave de API (deixe em branco para IA local):",
86
- aiChatProviderOpenAI: "OpenAI (GPT-4o-mini)",
87
- aiChatProviderClaude: "Anthropic Claude (3.5 Sonnet)",
88
- aiChatProviderGemini: "Google Gemini (1.5 Flash)",
89
- aiChatProviderOllama: "Ollama Network (Local LLMs)",
91
+ aiChatProviderOpenAI: "OpenAI",
92
+ aiChatProviderClaude: "Anthropic Claude",
93
+ aiChatProviderGemini: "Google Gemini",
94
+ aiChatProviderOllama: "Ollama",
90
95
  aiChatEmptyPageContent: "Nenhuma página ativa compatível.",
91
96
  aiChatNoPageId: "Sem página",
92
97
  aiChatOllamaUrlLabel: "URL da API Ollama (deixe em branco para local)",
@@ -197,12 +202,17 @@ export const defaultLangMenu = {
197
202
  aiChatResetPopupDesc: "This erases all saved API keys and the local password. You'll create a new password next time.",
198
203
  aiChatResetConfirmBtn: "Yes, reset",
199
204
  aiChatResetCancelBtn: "Cancel",
205
+ aiChatRetryHint: "Try again!",
206
+ aiChatAutoLockTitle: "Inactivity lock",
207
+ aiChatAutoLockDesc: "You have not used the AI for a while. The chat locks and your keys stay encrypted in {seconds}s.",
208
+ aiChatAutoLockConfirmBtn: "OK",
209
+ aiChatAutoLockCancelBtn: "Cancel",
200
210
  aiChatProviderLabel: "Provider:",
201
211
  aiChatApiKeyLabel: "API Key (leave blank for Local AI):",
202
- aiChatProviderOpenAI: "OpenAI (GPT-4o-mini)",
203
- aiChatProviderClaude: "Anthropic Claude (3.5 Sonnet)",
204
- aiChatProviderGemini: "Google Gemini (1.5 Flash)",
205
- aiChatProviderOllama: "Ollama Network (Local LLMs)",
212
+ aiChatProviderOpenAI: "OpenAI",
213
+ aiChatProviderClaude: "Anthropic Claude",
214
+ aiChatProviderGemini: "Google Gemini",
215
+ aiChatProviderOllama: "Ollama",
206
216
  aiChatEmptyPageContent: "No compatible active page.",
207
217
  aiChatNoPageId: "No page",
208
218
  aiChatOllamaUrlLabel: "Ollama API URL (leave blank for local)",
@@ -313,12 +323,17 @@ export const defaultLangMenu = {
313
323
  aiChatResetPopupDesc: "Esto borra todas las claves de API guardadas y la contraseña local. Crearás una nueva contraseña la próxima vez.",
314
324
  aiChatResetConfirmBtn: "Sí, restablecer",
315
325
  aiChatResetCancelBtn: "Cancelar",
326
+ aiChatRetryHint: "¡Inténtalo de nuevo!",
327
+ aiChatAutoLockTitle: "Bloqueo por inactividad",
328
+ aiChatAutoLockDesc: "No has usado la IA por un tiempo. El chat se bloqueará y tus claves seguirán cifradas en {seconds}s.",
329
+ aiChatAutoLockConfirmBtn: "Guardar",
330
+ aiChatAutoLockCancelBtn: "Cancelar",
316
331
  aiChatProviderLabel: "Proveedor:",
317
332
  aiChatApiKeyLabel: "Clave de API (déjalo en blanco para IA local):",
318
- aiChatProviderOpenAI: "OpenAI (GPT-4o-mini)",
319
- aiChatProviderClaude: "Anthropic Claude (3.5 Sonnet)",
320
- aiChatProviderGemini: "Google Gemini (1.5 Flash)",
321
- aiChatProviderOllama: "Ollama Network (Local LLMs)",
333
+ aiChatProviderOpenAI: "OpenAI",
334
+ aiChatProviderClaude: "Anthropic Claude",
335
+ aiChatProviderGemini: "Google Gemini",
336
+ aiChatProviderOllama: "Ollama",
322
337
  aiChatEmptyPageContent: "Ninguna página activa compatible.",
323
338
  aiChatNoPageId: "Sin página",
324
339
  aiChatOllamaUrlLabel: "URL de la API de Ollama (déjalo en blanco para local)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitpagedocs/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.",
5
5
  "bin": {
6
6
  "gitpagedocs": "index.mjs"
@@ -29,8 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.5.1",
32
- "@gitpagedocs/mcp": "^0.0.1",
33
- "@gitpagedocs/tools": "^0.0.1",
32
+ "@gitpagedocs/mcp": "^0.0.3",
33
+ "@gitpagedocs/tools": "^0.0.3",
34
34
  "tsx": "^4.21.0"
35
35
  },
36
36
  "repository": {
@@ -3,7 +3,11 @@ import { createDefaultFactory } from "@gitpagedocs/tools/ai";
3
3
  import { PROVIDER_CATALOG } from "@gitpagedocs/tools";
4
4
  import type { AIProvider, ProviderConfig } from "@gitpagedocs/tools/ports";
5
5
  import { ChatSession } from "../../ai/core/chat-session";
6
+ import { createStoredKeyUnlocker } from "../../ai/application/ai-credentials";
6
7
  import { resolveChatCredentials, type ResolvedChatCredentials } from "../../ai/application/resolve-chat-credentials";
8
+ import { AiConfigFileRepository } from "../../ai/infrastructure/ai-config-file";
9
+ import { AiKeyVault } from "../../ai/infrastructure/ai-key-vault";
10
+ import { clackVaultPasswordPrompt } from "../../ai/presentation/vault-prompts";
7
11
  import {
8
12
  ElapsedSpinner,
9
13
  accent,
@@ -215,10 +219,20 @@ function printSetupHelp(providerHint: string): void {
215
219
  */
216
220
  export async function runChat(ctx: CommandContext): Promise<void> {
217
221
  const flags = parseChatArgs(ctx.args);
222
+ const configRepo = new AiConfigFileRepository({ cwd: ctx.cwd });
218
223
  const creds: ResolvedChatCredentials | null = await resolveChatCredentials({
219
224
  cwd: ctx.cwd,
220
225
  providerOverride: flags.provider,
221
226
  modelOverride: flags.model,
227
+ configRepo,
228
+ // The stored key is sealed in the vault: the password is asked on every run
229
+ // (or read from GITPAGEDOCS_VAULT_PASSWORD when there is no terminal).
230
+ unlockStoredKey: createStoredKeyUnlocker({
231
+ configRepo,
232
+ vault: new AiKeyVault(),
233
+ prompt: process.stdin.isTTY ? clackVaultPasswordPrompt : undefined,
234
+ onInfo: (message) => writeChrome(danger(message)),
235
+ }),
222
236
  });
223
237
 
224
238
  if (!creds) {
@@ -1,14 +1,17 @@
1
1
  import { AiConfigFileRepository } from "../../ai/infrastructure/ai-config-file";
2
+ import { AiKeyVault } from "../../ai/infrastructure/ai-key-vault";
2
3
  import type { CommandContext } from "./run-command";
3
4
 
4
5
  /**
5
6
  * `gitpagedocs config clear` — delete the stored `.gitpagedocsconfig` from the
6
- * per-user config directory and any legacy repo-root copy, wiping the saved AI
7
- * credentials (API key, provider, scan paths).
7
+ * per-user config directory (and any legacy repo-root copy) together with the
8
+ * encrypted key vault, wiping the saved AI credentials, provider and scan paths.
8
9
  */
9
10
  export async function runConfigClear(ctx: CommandContext): Promise<void> {
10
11
  const repository = new AiConfigFileRepository({ cwd: ctx.cwd });
12
+ const vault = new AiKeyVault();
11
13
  const removed = await repository.clear();
14
+ if (await vault.clear()) removed.push(vault.getVaultPath());
12
15
 
13
16
  if (!removed.length) {
14
17
  // eslint-disable-next-line no-console