Skip to content

Commit d087c29

Browse files
authored
feat: harden wizard flow and env mapping (#8)
1 parent b17f486 commit d087c29

File tree

11 files changed

+308
-319
lines changed

11 files changed

+308
-319
lines changed

README.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,27 +173,31 @@ export function getPrivateConfig() {
173173

174174
[Shelve](https://shelve.cloud) is a secrets management service. This module fetches secrets from Shelve at build time and merges them into your runtime config before validation.
175175

176-
### Zero-Config Setup
176+
### Configure Shelve
177177

178-
If you have a `shelve.json` file in your project root, the integration enables automatically:
178+
Configure Shelve directly in your Nuxt config:
179179

180180
```ts
181181
export default defineNuxtConfig({
182182
safeRuntimeConfig: {
183183
$schema: runtimeConfigSchema,
184-
shelve: true, // Auto-detects project, team, and environment
184+
shelve: {
185+
project: 'my-app',
186+
slug: 'my-team',
187+
},
185188
},
186189
})
187190
```
188191

189192
The module resolves configuration from multiple sources (highest priority first):
190193

191-
| Config | Sources |
192-
| ----------- | ---------------------------------------------------------------------- |
193-
| project | `nuxt.config``SHELVE_PROJECT``shelve.json``package.json` name |
194-
| slug | `nuxt.config``SHELVE_TEAM_SLUG``shelve.json` |
195-
| environment | `nuxt.config``SHELVE_ENV``shelve.json` → dev mode auto |
196-
| token | `SHELVE_TOKEN``~/.shelve` file |
194+
| Config | Sources |
195+
| ----------- | ------------------------------------------------------------ |
196+
| project | `nuxt.config``SHELVE_PROJECT``package.json` name |
197+
| slug | `nuxt.config.slug/team``SHELVE_TEAM``SHELVE_TEAM_SLUG` |
198+
| environment | `nuxt.config``SHELVE_ENV` → dev mode auto |
199+
| url | `nuxt.config``SHELVE_URL``https://app.shelve.cloud` |
200+
| token | `SHELVE_TOKEN``~/.shelve` |
197201

198202
### Explicit Configuration
199203

@@ -245,6 +249,14 @@ export default defineNuxtConfig({
245249

246250
The runtime plugin runs before validation, so freshly fetched secrets are validated against your schema.
247251

252+
### Install Wizard UX
253+
254+
On module install, an interactive setup wizard can help bootstrap validation and Shelve config. The wizard now:
255+
256+
- shows a preview of planned actions first (install deps, write `~/.shelve`, edit `nuxt.config`)
257+
- asks for a final confirmation before applying any change
258+
- skips automatically in CI and non-interactive terminals (non-TTY)
259+
248260
## Runtime Validation
249261

250262
By default, validation only runs at build time. Enable runtime validation to catch environment variable issues when the server starts:
@@ -324,6 +336,12 @@ When validation fails, you see detailed error messages:
324336

325337
The module stops the build process until all validation errors are resolved.
326338

339+
## Upcoming Major Release Notes
340+
341+
- Shelve setup no longer documents `shelve.json` auto-enablement; supported sources are `nuxt.config`, env vars, and `package.json` fallback for project name.
342+
- The install wizard now previews actions and requires explicit confirmation before mutating files or writing credentials.
343+
- Runtime and wizard key-shaping now use the same env-key mapping rules to avoid schema/runtime drift.
344+
327345
## Why This Module?
328346

329347
Nuxt's built-in schema validation is designed for module authors and broader configuration. This module focuses specifically on **runtime config validation** using Standard Schema, allowing you to:

docs/3.integrations/2.shelve.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export default defineNuxtConfig({
5757

5858
For local development, run `shelve login` to authenticate. For CI/CD, set the `SHELVE_TOKEN` environment variable.
5959

60+
## Setup Wizard
61+
62+
On module install (interactive terminals only), the setup wizard can bootstrap schema + Shelve configuration.
63+
64+
Before mutating anything, it shows the planned actions and asks for final confirmation:
65+
66+
- install validation dependencies (if needed)
67+
- write `~/.shelve` token (if newly entered)
68+
- update `nuxt.config`
69+
70+
In CI and non-interactive terminals, the wizard is skipped automatically.
71+
6072
## How It Works
6173

6274
1. The module reads your Shelve configuration from nuxt.config
@@ -170,7 +182,17 @@ You can override any configuration using environment variables:
170182
| ------------------ | ------------------------------ |
171183
| `SHELVE_TOKEN` | Authentication token |
172184
| `SHELVE_PROJECT` | Project name |
185+
| `SHELVE_TEAM` | Team slug |
173186
| `SHELVE_TEAM_SLUG` | Team slug |
174187
| `SHELVE_ENV` | Environment name |
175188
| `SHELVE_URL` | API URL (for self-hosted) |
176189

190+
Resolution priority (high to low):
191+
192+
| Config | Priority |
193+
| ------------- | ------------------------------------------------------------------ |
194+
| `project` | `nuxt.config``SHELVE_PROJECT``package.json` name |
195+
| `slug` | `nuxt.config.slug/team``SHELVE_TEAM``SHELVE_TEAM_SLUG` |
196+
| `environment` | `nuxt.config``SHELVE_ENV` → auto (`development`/`production`) |
197+
| `url` | `nuxt.config``SHELVE_URL``https://app.shelve.cloud` |
198+
| `token` | `SHELVE_TOKEN``~/.shelve` |

src/module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Nuxt } from '@nuxt/schema'
22
import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'
33
import type { ErrorBehavior, ModuleOptions } from './types'
4+
import process from 'node:process'
45
import { addImportsDir, addServerImportsDir, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
56
import { toJsonSchema } from '@standard-community/standard-json'
67
import defu from 'defu'
@@ -61,7 +62,7 @@ export default defineNuxtModule<ModuleOptions>({
6162
// onInstall requires Nuxt 4.1+ - ignored on older versions
6263
// @ts-expect-error onInstall is Nuxt 4.1+ feature
6364
async onInstall(nuxt: Nuxt) {
64-
if (isCI || isTest)
65+
if (isCI || isTest || !process.stdin.isTTY || !process.stdout.isTTY)
6566
return
6667
await runShelveWizard(nuxt)
6768
},

src/module.ts.bak

Lines changed: 0 additions & 90 deletions
This file was deleted.

src/utils/transform.ts

Lines changed: 43 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,59 +9,63 @@ export function getPrefix(key: string): string | null {
99
return parts.length > 1 ? parts[0]! : null
1010
}
1111

12-
function countPrefixes(vars: EnvVar[]): Map<string, number> {
12+
function countPrefixes(keys: string[]): Map<string, number> {
1313
const counts = new Map<string, number>()
14-
for (const { key } of vars) {
14+
for (const key of keys) {
1515
const prefix = getPrefix(key)
1616
if (prefix)
1717
counts.set(prefix, (counts.get(prefix) || 0) + 1)
1818
}
1919
return counts
2020
}
2121

22-
/** Transforms EnvVar[] to camelCase runtimeConfig. Groups repeated prefixes (2+ keys) and routes PUBLIC_* / NUXT_PUBLIC_* to public namespace */
23-
export function transformEnvVars(vars: EnvVar[]): TransformedConfig {
24-
const prefixCounts = countPrefixes(vars)
25-
const result: TransformedConfig = {}
22+
export type ConfigStructure = Record<string, true | Record<string, true>>
2623

27-
for (const { key, value } of vars) {
28-
if (key.startsWith('PUBLIC_')) {
29-
result.public ??= {}
30-
assignNested(result.public as Record<string, unknown>, key.slice(7), value, prefixCounts)
31-
continue
32-
}
33-
if (key.startsWith('NUXT_PUBLIC_')) {
34-
result.public ??= {}
35-
assignNested(result.public as Record<string, unknown>, key.slice(12), value, prefixCounts)
36-
continue
37-
}
24+
function resolveConfigPath(key: string, prefixCounts: Map<string, number>): string[] {
25+
if (key.startsWith('PUBLIC_'))
26+
return ['public', toCamelCase(key.slice(7))]
27+
if (key.startsWith('NUXT_PUBLIC_'))
28+
return ['public', toCamelCase(key.slice(12))]
3829

39-
const prefix = getPrefix(key)
40-
if (prefix && (prefixCounts.get(prefix) || 0) >= 2) {
41-
const groupKey = toCamelCase(prefix)
42-
result[groupKey] ??= {}
43-
const nestedKey = toCamelCase(key.slice(prefix.length + 1))
44-
; (result[groupKey] as Record<string, unknown>)[nestedKey] = value
45-
}
46-
else {
47-
result[toCamelCase(key)] = value
48-
}
30+
const prefix = getPrefix(key)
31+
if (prefix && (prefixCounts.get(prefix) || 0) >= 2) {
32+
return [toCamelCase(prefix), toCamelCase(key.slice(prefix.length + 1))]
4933
}
34+
return [toCamelCase(key)]
35+
}
5036

51-
return result
37+
function assignPath<TValue>(target: Record<string, TValue | Record<string, TValue>>, path: string[], value: TValue): void {
38+
if (path.length === 1) {
39+
target[path[0]!] = value
40+
return
41+
}
42+
43+
const [head, ...tail] = path
44+
target[head!] ??= {}
45+
assignPath(target[head!] as Record<string, TValue | Record<string, TValue>>, tail, value)
5246
}
5347

54-
/** Applies same grouping rules to PUBLIC_* vars within the public namespace */
55-
function assignNested(obj: Record<string, unknown>, key: string, value: string, prefixCounts: Map<string, number>): void {
56-
const prefix = getPrefix(key)
57-
if (prefix && (prefixCounts.get(`PUBLIC_${prefix}`) || prefixCounts.get(`NUXT_PUBLIC_${prefix}`) || 0) >= 2) {
58-
const groupKey = toCamelCase(prefix)
59-
obj[groupKey] ??= {}
60-
const rest = key.slice(prefix.length + 1)
61-
const nestedKey = toCamelCase(rest)
62-
; (obj[groupKey] as Record<string, unknown>)[nestedKey] = value
48+
export function buildConfigStructureFromEnvKeys(keys: string[]): ConfigStructure {
49+
const result: ConfigStructure = {}
50+
const prefixCounts = countPrefixes(keys)
51+
52+
for (const key of keys) {
53+
const path = resolveConfigPath(key, prefixCounts)
54+
assignPath(result, path, true)
6355
}
64-
else {
65-
obj[toCamelCase(key)] = value
56+
57+
return result
58+
}
59+
60+
/** Transforms EnvVar[] to camelCase runtimeConfig. Groups repeated prefixes (2+ keys) and routes PUBLIC_* / NUXT_PUBLIC_* to public namespace */
61+
export function transformEnvVars(vars: EnvVar[]): TransformedConfig {
62+
const result: TransformedConfig = {}
63+
const prefixCounts = countPrefixes(vars.map(v => v.key))
64+
65+
for (const { key, value } of vars) {
66+
const path = resolveConfigPath(key, prefixCounts)
67+
assignPath(result as Record<string, string | Record<string, string>>, path, value)
6668
}
69+
70+
return result
6771
}

0 commit comments

Comments
 (0)