name: nn description: "@nextnode-solutions/nn — local dev DX CLI. Auto-load when working on nn package or any project that uses nn." version: 1.2.1 user-invocable: false autoload-dirs: - /Users/walid/Development/nextnode - /Users/walid/Development/saas
nn — Local Development CLI
Published as @nextnode-solutions/nn (v1.2.1). Built with citty, uses @nextnode/cli as workspace dependency for shared types and config.
Package: packages/nn/ in the infrastructure monorepo.
Commands
| Command | Purpose | Key Args |
|---|---|---|
nn up |
Start local dev (Docker services + app dev server) | --containerized, --reset-ports |
nn down |
Stop local dev (kills dev server + Docker services) | --clean (removes volumes) |
nn env show |
Display resolved env vars | <env> (default: local), --reveal, --diff <env> |
nn env check |
Validate env vars against requirements | <env> (default: local) |
nn env init |
Create .env.local, .env.dev, .env.prod templates |
— |
nn env push |
Push env vars to GitHub environment secrets | <env> (required: dev, prod), --file |
nn up Flow
- Detect services from
nextnode.toml(detectServices()) - Check Docker availability (only if services exist)
- Resolve port assignments (persisted to
.nn/ports.json, reusable across restarts) - Ensure
.nn/directory +.gitignoreentries - Read
.env.local→userVars(empty if file missing) - Generate missing
[secrets]vars → write to.env.local(add-only,0o600perms) - Resolve Docker env (Supabase keys + postgres password →
.nn/.env) - Compute auto-generated vars (
buildLocalEnvVars()) — computed always wins over user - Merge
{ ...userVars, ...computedVars }→mergedVars(warns on overrides) - Print env summary table with
[auto]/[secret]/[user]labels - Generate
docker-compose.local.ymlin.nn/(if services fresh) - Start Docker services + wait for healthy
- Detect and start app dev server (
detectDevCommand()) - Multiplex logs (app + Docker interleaved with color-coded prefixes)
Three-Tier Env Var Sources
process.env (lowest)
<- .env.local vars (user-provided + persisted [secrets])
<- computed service vars from buildLocalEnvVars() + resolveDockerEnv() (highest)
[auto]: Service-derived vars computed bynn up(SUPABASE_URL, DATABASE_URL, REDIS_URL, etc.)[secret]: Config-declared secrets generated bynn upvia[secrets]innextnode.toml[user]: User-provided vars from.env.local
Supabase Key Persistence
Keys are generated/reused in resolveDockerEnv(), written to .nn/.env, then passed to buildLocalEnvVars() so both Docker and app use the same values. Single owner — no split brain.
nn env Subcommands
nn env init
- Creates
.env.local,.env.dev,.env.prodtemplates (skips existing files) .env.local: excludes auto-generated vars (only user-provided + placeholder for secrets).env.dev/.env.prod: includes ALL vars, generates unique random[secrets]values per env- Ensures
.gitignoreincludes.env.*patterns
nn env check
- Validates required env vars for a given environment (default: local)
- Detects required vars from: Docker Compose file,
SERVICE_REQUIRED_VARS,[secrets]config - Local env: excludes auto-generated vars (
getAutoGeneratedVars()) from required list - Rejects placeholder values (
<...>, empty, whitespace-only) as MISSING - Exit code 1 if missing vars, 0 if all present
nn env push
- Pushes env vars from
.env.{env}to GitHub environment secrets - Blocks pushing to
local(error exit) - Interactive checkbox picker (all selected by default)
- Checks existing secrets, prompts to overwrite
- Uses
gh secret set --env <ghEnvName>per selected var
nn env show
- Displays env vars with masked sensitive values by default
--reveal: show actual values--diff <env>: compare two environments (+only in first,-only in second,~different values)
Libraries (packages/nn/src/lib/)
| Library | Key Exports |
|---|---|
| config | requireConfig() — loads nextnode.toml via @nextnode/cli, bundles nextnode.default.toml |
| services | detectServices(config): DetectedServices, getAutoGeneratedVars(config): ReadonlySet<string> — returns max set of auto-generated var names for configured services |
| ports | resolvePortMap(services, appPort, nnDir, opts?), isPortAvailable(port), findAvailablePort(start, max), DEFAULT_PORTS, PortMap |
| compose | generateLocalCompose(config, services, ports), writeLocalComposeFiles(projectDir, config, composeContent) — writes compose + Supabase support files (kong.yml, roles.sql, init-db.sh, kong-entrypoint.sh) to .nn/ |
| docker | checkDockerAvailable(), startDockerServices(composePath, envFilePath?), stopDockerServices(composePath, opts?), areServicesRunning(composePath), getServiceStatuses(composePath) |
| env | readEnvFile(env, cwd, filePath?), parseEnvFile(content), writeEnvLocal(projectDir, vars): EnvVars, buildLocalEnvVars(services, ports, supabaseKeys?, pgPassword?), isSensitive(name), maskValue(value), formatValuePreview(value, name, reveal), isPlaceholderValue(value), resolveEnvFile(env, filePath?), mapGitHubEnvName(env), SERVICE_REQUIRED_VARS, EnvVars (re-export) |
| oauth | computeLocalOAuthEnvVars(oauthConfig, apiPort, localVars: Readonly<EnvVars>) — pure function, receives pre-parsed vars instead of reading .env.local. Falls back to process.env for credentials. Warns and skips providers with missing credentials. |
| secret-gen | generateSecret(decl: SecretDeclaration): string — hex via randomBytes, base64url via randomBytes, uuid via randomUUID. Exhaustive default: never on discriminant. |
| dev-server | detectDevCommand(projectDir) — checks package.json scripts.dev, then framework-specific defaults (astro, next, vite) |
| logs | createPrefixWriter(serviceName, colorIndex), streamDockerLogs(composePath), pipeWithPrefix(proc, serviceName, colorIndex) |
| process | gracefulKill(proc, timeoutMs), waitForHealthy(checkFn, label, maxMs), writePidFile(nnDir, pid), readPidFile(nnDir), removePidFile(nnDir), killProcess(pid), isProcessRunning(pid) |
Types
type ServiceName = 'supabase' | 'redis'
interface DetectedServices {
names: ServiceName[]
hasSupabase: boolean
hasRedis: boolean
}
interface PortMap {
supabaseApi?: number // default 54321
supabaseStudio?: number // default 54323
supabaseDb?: number // default 54322
supabaseAuth?: number // default 9999
redis?: number // default 6379
app: number // default 4321
}
SecretDeclaration, GenerateAlgorithm, EnvVars, and ProjectConfig are defined in @nextnode/cli types — see nextnode-infra skill.
File Layout
| File/Dir | Purpose | Written by |
|---|---|---|
.env.local |
User secrets + generated [secrets] |
User + nn up (secrets only, add-only) |
.nn/.env |
Infra vars for Docker Compose | nn up (every run) |
.nn/ports.json |
Persisted port assignments | nn up |
.nn/docker-compose.local.yml |
Local Docker Compose | nn up |
.nn/supabase/ |
Kong config, init scripts, roles.sql | nn up |
.nn/app.pid |
Dev server PID | nn up |
.env.dev |
Deploy vars for dev | User + nn env init |
.env.prod |
Deploy vars for prod | User + nn env init |
All secret-containing files (.env.local, .nn/.env) enforced 0o600 via chmodSync after every write.
[secrets] Config (nextnode.toml)
[secrets]
TOKEN_ENCRYPTION_KEY = { generate = "hex", bytes = 32 }
SESSION_SECRET = { generate = "base64url", bytes = 32 }
INTERNAL_API_KEY = { generate = "uuid" }
- Three generation types:
hex,base64url(both requirebytes >= 16),uuid nn upgenerates missing secrets → writes to.env.local(add-only, never overwrites)- Subsequent runs read persisted values from
.env.local - Config validation lives in
packages/cli/src/lib/config.ts(validateConfig()) - Runtime generation in
packages/nn/src/lib/secret-gen.ts
Auto-Generated Vars (excluded from .env.local templates and local checks)
getAutoGeneratedVars(config) returns the maximum possible set:
| Service | Vars |
|---|---|
| Supabase | SUPABASE_URL, DATABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET, POSTGRES_PASSWORD |
| Redis | REDIS_URL |
SERVICE_REQUIRED_VARS
| Service | Required in deploy envs |
|---|---|
| supabase | JWT_SECRET, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, POSTGRES_PASSWORD |
| redis | REDIS_URL |
| r2 | R2_BUCKET_NAME, R2_ACCOUNT_ID, R2_ENDPOINT_URL |
Key Patterns
- Port persistence:
.nn/ports.jsonreused across restarts.--reset-portsto reassign. - OAuth credential lookup: from
localVars(pre-parsed.env.local) withprocess.envfallback. GitHub OAuth usesGH_*prefix (notGITHUB_*). - Local compose: generated from scratch (NOT transformed from deploy compose) — differences are substantial (host ports, named volumes, studio always included).
- Sensitive masking:
isSensitive()checks suffixes (_KEY,_SECRET,_PASSWORD,_TOKEN,_CREDENTIALS,_API_KEY), prefixes (JWT_), exact matches (DATABASE_URL,REDIS_URL). - Placeholder rejection:
isPlaceholderValue()returnstruefor<...>, empty, whitespace-only values. .nn/cleanup:writeLocalComposeFiles()cleans up stale directory artifacts (Docker creates bind-mount paths as directories when source files don't exist).