hazo_env 0.2.0 → 0.4.0

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.
Files changed (43) hide show
  1. package/CHANGE_LOG.md +47 -0
  2. package/README.md +83 -17
  3. package/SETUP_CHECKLIST.md +32 -0
  4. package/config/hazo_env_config.ini.sample +16 -0
  5. package/dist/cli.js +21 -7
  6. package/dist/doctor.d.ts.map +1 -1
  7. package/dist/doctor.js +168 -3
  8. package/dist/env.server.d.ts +7 -1
  9. package/dist/env.server.d.ts.map +1 -1
  10. package/dist/env.server.js +25 -2
  11. package/dist/index.client.d.ts +6 -2
  12. package/dist/index.client.d.ts.map +1 -1
  13. package/dist/index.client.js +15 -9
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -2
  17. package/dist/migrate/db.d.ts +3 -0
  18. package/dist/migrate/db.d.ts.map +1 -1
  19. package/dist/migrate/db.js +6 -1
  20. package/dist/migrate/db.postgrest.d.ts +4 -0
  21. package/dist/migrate/db.postgrest.d.ts.map +1 -0
  22. package/dist/migrate/db.postgrest.js +93 -0
  23. package/dist/migrate/files.d.ts.map +1 -1
  24. package/dist/migrate/files.js +2 -4
  25. package/dist/migrate/progress.d.ts +5 -0
  26. package/dist/migrate/progress.d.ts.map +1 -0
  27. package/dist/migrate/progress.js +24 -0
  28. package/dist/migrate/run.d.ts.map +1 -1
  29. package/dist/migrate/run.js +60 -25
  30. package/dist/migrate/snapshot.d.ts.map +1 -1
  31. package/dist/migrate/snapshot.js +3 -0
  32. package/dist/migrate/verify.d.ts +1 -0
  33. package/dist/migrate/verify.d.ts.map +1 -1
  34. package/dist/migrate/verify.js +120 -30
  35. package/dist/resolve/files.d.ts +6 -0
  36. package/dist/resolve/files.d.ts.map +1 -1
  37. package/dist/resolve/files.js +23 -0
  38. package/dist/resolve/migrate.d.ts +3 -0
  39. package/dist/resolve/migrate.d.ts.map +1 -0
  40. package/dist/resolve/migrate.js +29 -0
  41. package/dist/types/index.d.ts +13 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/package.json +9 -9
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # hazo_env — Change Log
2
2
 
3
+ ## 0.4.0 — 2026-06-25
4
+
5
+ ### Added
6
+ - `verifyFiles()` L2–L5 — DB-backed file existence (L2), size check (L3), hash check L4 (`full|sample|none`; sample = 1% + top-10 by size + top-10 by changed_at), orphan detection L5. Previously all layers were stubs.
7
+ - `VerifyOptions.adapter` — pass a `HazoConnectAdapter` to enable DB-backed checks; without it verify falls back to sentinel-only (disk) and sets `report.skippedReason`.
8
+ - `VerifyReport` — new fields: `hashed?: number`, `skippedReason?: string`.
9
+ - `runMigration` — step 7 verify now passes `tgtAdapter`; emits detailed warning on `!ok` (counts + snapshotId) but continues (G2: warns-not-aborts).
10
+ - CLI `hazo-env verify` — new `--hash full|sample|none` flag (default `sample`); exits 1 on failure (CI-safe).
11
+ - `doctor()` — section 7a: `_migrations` parity check across all SQLite envs (`--probe --all`); section 7b: masking ruleset column validation against live schema.
12
+ - `DoctorOptions.probe` + `DoctorOptions.all` — enable live DB reachability probes + multi-env checks.
13
+ - `hazo_files` added as optional peer dependency (`^3.5.0`).
14
+
15
+ ### Changed
16
+ - `DoctorReport.passed` replaces `DoctorReport.ok` (previously typo in docs).
17
+ - `DoctorCheck.status` is `'ok'|'warn'|'error'` (not `boolean`).
18
+
19
+ ### Test-app
20
+ - Seed script creates 7 `hazo_files` fixture rows (3 clean, 1 size-mismatch, 1 hash-mismatch, 1 missing, 1 orphan) + physical files under `data/test/files/`.
21
+ - `/api/verify` route + `/verify` report page added.
22
+ - `'verify-fixtures'` autotest scenario added (5 cases).
23
+
24
+ ## 0.3.0 — 2026-06-10
25
+
26
+ ### Added
27
+ - `EnvRoleMap` type (`Record<string, HazoEnvRole>`) in `src/types/index.ts`
28
+ - `roles?: EnvRoleMap` optional field on `EnvDescription` interface
29
+ - `getRoleMap()` client + server: config-driven role map; merges `DEFAULT_ROLE_MAP` → `globalThis.__HAZO_ENV_ROLES__` (client) → `[env.roles]` INI section (server); invalid role values skipped silently
30
+ - `[env.roles]` INI section support: arbitrary env names mapped to the four fixed roles (`development`, `test`, `staging`, `production`)
31
+ - `describeEnv()` server version now includes `roles` field
32
+ - Filesystem progress store: `writeMigrationProgress`, `readMigrationProgress`, `clearMigrationProgress` (`src/migrate/progress.ts`)
33
+ - `MigrationRequest.jobId` + `MigrationRequest.progressDir` optional fields — when both present, `runMigration` writes each progress event to `<progressDir>/<jobId>.json`
34
+ - `loadRuleset`, `syncRulesetFromIni`, `parseIniRules` masking ruleset functions now exported from package index
35
+ - `MaskRule` type now exported from package index
36
+ - `normalize()` utility now exported from package index (trim + lowercase env name string)
37
+
38
+ ### Changed
39
+ - `getEnvRole()` rewritten to be config-driven via role map; unknown env names now default to `'development'` instead of `'production'` — this is a safe behavior fix (unknown names no longer silently get production-level protection)
40
+
41
+ ## 0.2.0 — 2026-06-09
42
+
43
+ - feat: M2 migration engine — `runMigration`, `verifyFiles`, `takeSnapshot`, `restoreSnapshot`
44
+ - feat: M3 PII masking — `registerMask`, built-in transforms (`mask_email`, `fake_name`, `mask_phone`, `jitter_date`, `hash`, `tokenize`, `drop`, `nullify`, `redact_pii`)
45
+ - feat: masking ruleset management — `loadRuleset`, `syncRulesetFromIni`, `parseIniRules`, `MaskRule` (not yet exported from index — fixed in 0.3.0)
46
+ - feat: `MaskTransform` type in `src/types/index.ts`
47
+ - feat: CLI commands `migrate`, `verify`, `restore`, `mask sync`, `mask list`
48
+ - chore: version bump to 0.2.0
49
+
3
50
  ## 0.1.1 — 2026-06-09
4
51
 
5
52
  - fix: provision `sql-wasm.wasm` into test-app `public/` via seed script (fixes Connect page error when SQLite adapter loads via sql.js WASM)
package/README.md CHANGED
@@ -9,8 +9,8 @@ Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/s
9
9
  - **Per-env file config** — `resolveFilesConfig()` maps the current env to a `hazo_files` config rooted at the declared `data_root`.
10
10
  - **Secrets** — `getSecret()` resolves from `.env.local` only; placeholders in `hazo_env_config.ini` are substituted at runtime without storing secrets.
11
11
  - **Migration engine** — `runMigration({ from, to })` copies a DB + files between envs. Validates → snapshots target → copies tables (paged, schema-checked) → copies files → verifies → writes hazo_audit entry. Prod target refused without an explicit confirm token.
12
- - **PII masking** — masking rules declared per table/column in `hazo_env_masking.ini` (seed) and synced to `hazo_app_config` at runtime. Built-in transforms: `mask_email`, `fake_name`, `mask_phone`, `jitter_date`, `hash`, `tokenize`, `drop`, `nullify`. Custom transforms via `registerMask()`. Applied automatically when the migration target role is `testing` or `staging`.
13
- - **Doctor** — `doctor()` / `hazo-env doctor` validates pattern, DB reachability, required secrets (no values printed), data_root writability, and schema level.
12
+ - **PII masking** — masking rules declared per table/column in `hazo_env_masking.ini` (seed) and synced to `hazo_app_config` at runtime. Built-in transforms: `mask_email`, `fake_name`, `mask_phone`, `jitter_date`, `hash`, `tokenize`, `drop`, `nullify`. Custom transforms via `registerMask()`. Applied automatically when the migration target role is `test` or `staging`.
13
+ - **Doctor** — `doctor()` / `hazo-env doctor` validates pattern, DB reachability, required secrets (no values printed), data_root writability, schema level, and (with `--probe --all`) migration parity across envs + masking ruleset column validation.
14
14
  - **CLI** — `hazo-env current | doctor | snapshot | migrate | verify | restore | mask`.
15
15
 
16
16
  ## Installation
@@ -64,18 +64,67 @@ const adapter = await createHazoConnect(resolveConnectConfig());
64
64
  ### Env resolution (client-safe — also in `hazo_env/client`)
65
65
 
66
66
  ```ts
67
- import { getEnv, getEnvRole, getPattern, listEnvs, assertEnv, describeEnv,
67
+ import { getEnv, getEnvRole, getRoleMap, getPattern, listEnvs, assertEnv, describeEnv,
68
68
  isDev, isTest, isStaging, isProd } from 'hazo_env';
69
69
 
70
70
  getEnv() // 'dev' | 'test' | 'staging' | 'prod'
71
- getEnvRole() // 'development' | 'testing' | 'staging' | 'production'
71
+ getEnvRole() // 'development' | 'test' | 'staging' | 'production'
72
+ getRoleMap() // EnvRoleMap — full env→role mapping (config-driven; safe defaults)
72
73
  getPattern() // e.g. 'dev_prod'
73
74
  listEnvs() // ['dev', 'prod'] — valid envs for the declared pattern
74
75
  assertEnv() // throws HazoError(ENV_INVALID) if HAZO_ENV is outside the pattern
75
- describeEnv() // { env, role }
76
+ describeEnv() // { env, role, pattern, app, dataRoot, hostHint, roles? }
76
77
  isDev() / isTest() / isStaging() / isProd()
77
78
  ```
78
79
 
80
+ `getEnvRole()` uses a config-driven role map so custom env names (e.g. `preview`, `qa`) resolve to the correct broad role. Unknown names default to `'development'` — never silently treated as production. Configure custom mappings in `[env.roles]`:
81
+
82
+ ```ini
83
+ [env.roles]
84
+ preview = staging
85
+ qa = test
86
+ ```
87
+
88
+ ### Configuring environment roles
89
+
90
+ Canonical env names (`dev`, `test`, `staging`, `prod`) are pre-mapped and require no configuration. Non-canonical names (anything else) must declare their role in the `[env.roles]` INI section, or `doctor` will flag an error. Unknown names without a declaration fall back to `'development'` (safe: no masking, no prod-protection).
91
+
92
+ ```ini
93
+ [env.roles]
94
+ ; Map arbitrary env names to the four fixed roles:
95
+ ; development | test | staging | production
96
+ qa = test
97
+ live = production
98
+ rc = staging
99
+ ```
100
+
101
+ Valid role values: `development`, `test`, `staging`, `production`.
102
+
103
+ #### Client-side global injection
104
+
105
+ On the client (browser), the INI file is not available. Inject the resolved values at render time using `globalThis` globals — the same pattern used for the env pattern:
106
+
107
+ ```ts
108
+ // In your Next.js layout server component or _app (server-side):
109
+ // globalThis.__HAZO_ENV_PATTERN__ = 'dev,prod'; // comma-separated pattern
110
+ // globalThis.__HAZO_ENV_ROLES__ = { live: 'production', qa: 'test' };
111
+
112
+ // Client bundle reads these automatically:
113
+ import { getPattern, getRoleMap } from 'hazo_env/client';
114
+ getPattern() // reads __HAZO_ENV_PATTERN__
115
+ getRoleMap() // merges DEFAULT_ROLE_MAP → __HAZO_ENV_ROLES__
116
+ ```
117
+
118
+ Both globals are optional; omitting them makes the client fall back to canonical defaults.
119
+
120
+ ### Utility
121
+
122
+ ```ts
123
+ import { normalize } from 'hazo_env';
124
+
125
+ normalize(' Dev ') // → 'dev' (trim + lowercase)
126
+ ```
127
+
79
128
  ### Resolvers (server-only)
80
129
 
81
130
  ```ts
@@ -98,15 +147,18 @@ getSecret('POSTGREST_API_KEY', { required: true })
98
147
  ```ts
99
148
  import { doctor } from 'hazo_env';
100
149
 
101
- const report = await doctor();
102
- // report.ok boolean
103
- // report.checks DoctorCheck[] — { label, ok, message }
150
+ const report = await doctor({ probe: true, all: true });
151
+ // report.passed boolean
152
+ // report.checks DoctorCheck[] — { label, status: 'ok'|'warn'|'error', detail? }
153
+ // Migration-readiness checks (--probe --all): _migrations parity across envs,
154
+ // masking ruleset column validation against live schema.
104
155
  ```
105
156
 
106
157
  ### Migration engine (server-only)
107
158
 
108
159
  ```ts
109
- import { runMigration, verifyFiles, takeSnapshot, restoreSnapshot } from 'hazo_env';
160
+ import { runMigration, verifyFiles, takeSnapshot, restoreSnapshot,
161
+ writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from 'hazo_env';
110
162
 
111
163
  // Copy prod → staging (with PII masking applied automatically)
112
164
  const result = await runMigration({
@@ -116,19 +168,39 @@ const result = await runMigration({
116
168
  // allowProdTarget: true, // required when `to` is 'prod'
117
169
  // confirmToken: '<token>', // required together with allowProdTarget
118
170
  onProgress: (p) => console.log(p.phase, p.message),
171
+ // jobId: '<id>', // optional — persist progress to progressDir/<id>.json
172
+ // progressDir: '/tmp/hazo_jobs', // required when jobId is set
119
173
  });
120
174
  // result.ok, result.db, result.files, result.snapshotId, result.warnings, result.durationMs
121
175
 
122
- // Stand-alone verification
123
- await verifyFiles('staging', dataRoot, { hash: 'sample' });
176
+ // Stand-alone verification (with optional hazo_connect adapter for L2–L5 checks)
177
+ await verifyFiles('staging', dataRoot, { hash: 'sample', checkOrphans: true, adapter });
178
+ // report.ok, report.checked, report.hashed, report.missing[], report.sizeMismatch[],
179
+ // report.hashMismatch[], report.orphans[], report.skippedReason?
124
180
 
125
181
  // Manual snapshot / restore
126
182
  const snap = takeSnapshot(connectConfig); // returns { snapshotId }
127
183
  restoreSnapshot(connectConfig, snapshotId);
184
+
185
+ // Filesystem progress store (used by hazo_jobs integration)
186
+ writeMigrationProgress(dir, jobId, progress);
187
+ readMigrationProgress(dir, jobId); // → MigrationProgress | null
188
+ clearMigrationProgress(dir, jobId);
128
189
  ```
129
190
 
130
191
  **Masking** — customize per table/column in `config/hazo_env_masking.ini`. Sync to the DB with `hazo-env mask sync`. Register custom transforms with `registerMask(name, fn)`.
131
192
 
193
+ **Masking ruleset API** — load, parse, and sync rulesets programmatically:
194
+
195
+ ```ts
196
+ import { loadRuleset, syncRulesetFromIni, parseIniRules } from 'hazo_env';
197
+ import type { MaskRule } from 'hazo_env';
198
+
199
+ const rules: MaskRule[] = parseIniRules(iniText); // parse INI text → rules array
200
+ const loaded = await loadRuleset(adapter); // read from hazo_app_config DB
201
+ await syncRulesetFromIni(adapter, '/path/to/masking.ini'); // write parsed rules to DB
202
+ ```
203
+
132
204
  ## CLI
133
205
 
134
206
  ```
@@ -163,12 +235,6 @@ Key sections:
163
235
 
164
236
  Secret placeholders use `${ENV_VAR_NAME}` syntax — hazo_env substitutes them from `.env.local` at runtime.
165
237
 
166
- ## Tailwind v4 (`@source` required if consuming UI)
167
-
168
- ```css
169
- @source "../node_modules/hazo_env/dist";
170
- ```
171
-
172
238
  ## License
173
239
 
174
240
  MIT
@@ -33,6 +33,16 @@ api_key = ${POSTGREST_API_KEY}
33
33
 
34
34
  **Never put secret values directly in the ini file** — use `${ENV_VAR_NAME}` placeholders.
35
35
 
36
+ **Custom env names** — if your pattern uses names other than `dev`/`test`/`staging`/`prod` (e.g. `preview`, `qa`), add an `[env.roles]` section to map them to broad roles:
37
+
38
+ ```ini
39
+ [env.roles]
40
+ preview = staging
41
+ qa = testing
42
+ ```
43
+
44
+ Without this, unknown env names default to `'development'` role (never silently production).
45
+
36
46
  ## 3. Create `.env.local` (gitignored)
37
47
 
38
48
  One file per deployment (dev machine, staging host, prod host). Contains the secrets referenced by `${…}` placeholders in the ini:
@@ -84,6 +94,12 @@ All checks should be green before going to production. The doctor validates:
84
94
  - All required secrets are present in `.env.local` (values are never printed)
85
95
  - `data_root` exists and is writable
86
96
 
97
+ Add `--probe --all` for migration-readiness checks:
98
+ ```bash
99
+ npx hazo-env doctor --probe --all
100
+ ```
101
+ This additionally checks: live DB reachability for all envs, `_migrations` table parity across envs, masking ruleset column validation against live schema.
102
+
87
103
  ## 8. (Next.js) Ensure `hazo_env` is in `transpilePackages`
88
104
 
89
105
  ```js
@@ -137,3 +153,19 @@ Review row counts and scrub counts before running without `--dry-run`. The migra
137
153
  ```bash
138
154
  npx hazo-env restore staging --snapshot <snapshot-id>
139
155
  ```
156
+
157
+ ## 10. (Required for non-canonical env names) Declare roles in `[env.roles]`
158
+
159
+ If your pattern uses any env name other than `dev`, `test`, `staging`, or `prod` (e.g. `qa`, `preview`, `live`, `rc`), add an `[env.roles]` section to `config/hazo_env_config.ini` to map each name to one of the four fixed roles:
160
+
161
+ ```ini
162
+ [env.roles]
163
+ qa = test
164
+ preview = staging
165
+ live = production
166
+ rc = staging
167
+ ```
168
+
169
+ Valid role values: `development`, `test`, `staging`, `production`.
170
+
171
+ Without this declaration, undeclared non-canonical names default to `'development'` (safe — no masking, no prod-protection) **and `hazo-env doctor` will flag an error** until the mapping is added. Run `npx hazo-env doctor` to verify all env names have a declared role.
@@ -10,6 +10,22 @@ pattern = dev, test, staging, prod
10
10
  ; Application name (used in log output and doctor reports)
11
11
  app = myapp
12
12
 
13
+ ; ─── Environment role map ─────────────────────────────────────────────────────
14
+ ; Map each environment NAME to one of the four fixed ROLES.
15
+ ; Roles drive behavior: masking fires for test+staging roles;
16
+ ; prod-protection fires for the production role.
17
+ ; The four canonical names default automatically — list them only to be explicit.
18
+ ; Non-canonical names MUST be declared here or doctor will flag an error
19
+ ; and the role will default to 'development' (safe but unintended).
20
+ [env.roles]
21
+ ; dev = development (default — no declaration needed)
22
+ ; test = test (default)
23
+ ; staging = staging (default)
24
+ ; prod = production (default)
25
+ qa = test
26
+ preview = staging
27
+ ; live = production (example: a production env with a different name)
28
+
13
29
  [data]
14
30
  ; Base directory for all local file storage (relative to app root or absolute)
15
31
  ; Default: app_data
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // hazo_env/src/cli.ts — CLI entry: hazo-env current | doctor | snapshot | migrate | verify | restore
3
3
  import pc from 'picocolors';
4
- import { getEnv, getEnvRole } from './index.client.js';
5
- import { describeEnv } from './env.server.js';
4
+ import { getEnv } from './index.client.js';
5
+ import { describeEnv, getEnvRole, getPattern } from './env.server.js';
6
6
  import { doctor } from './doctor.js';
7
7
  const [, , command, ...args] = process.argv;
8
8
  function statusIcon(status) {
@@ -20,7 +20,7 @@ async function runCurrent() {
20
20
  catch {
21
21
  // Fallback if no config file
22
22
  const env = getEnv();
23
- desc = { env, role: getEnvRole(env), pattern: ['dev', 'test', 'staging', 'prod'], app: '', dataRoot: '(config not found)', hostHint: 'unknown' };
23
+ desc = { env, role: getEnvRole(env), pattern: getPattern(), app: '', dataRoot: '(config not found)', hostHint: 'unknown' };
24
24
  }
25
25
  console.log(`\n${pc.bold('hazo-env current')}\n`);
26
26
  console.log(` env: ${pc.bold(desc.env)}`);
@@ -122,12 +122,22 @@ async function runMigrate() {
122
122
  }
123
123
  async function runVerify() {
124
124
  const targetEnv = args[0] ?? getEnv();
125
- console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)}\n`);
125
+ // Parse --hash full|sample|none (default: sample) and --no-orphans
126
+ const hashIdx = args.indexOf('--hash');
127
+ const hashArg = hashIdx >= 0 ? args[hashIdx + 1] : 'sample';
128
+ const hash = (hashArg === 'full' || hashArg === 'none' || hashArg === 'sample') ? hashArg : 'sample';
129
+ const checkOrphans = !args.includes('--no-orphans');
130
+ console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)} ${pc.dim(`--hash ${hash}`)}\n`);
126
131
  const { verifyFiles } = await import('./migrate/verify.js');
127
132
  const { resolveFilesConfig } = await import('./resolve/files.js');
128
133
  const dataRoot = resolveFilesConfig().local.basePath;
129
- const report = await verifyFiles(targetEnv, dataRoot, { hash: 'sample', checkOrphans: true });
130
- console.log(` Checked: ${report.checked} files`);
134
+ const report = await verifyFiles(targetEnv, dataRoot, { hash, checkOrphans });
135
+ if (report.skippedReason) {
136
+ console.log(` ${pc.yellow('⚠')} ${report.skippedReason}`);
137
+ }
138
+ console.log(` Checked: ${report.checked} files${report.hashed != null ? `, hashed: ${report.hashed}` : ''}`);
139
+ if (report.orphans.length)
140
+ console.log(` ${pc.dim('ℹ')} Orphans (no DB row): ${report.orphans.join(', ')}`);
131
141
  if (report.ok) {
132
142
  console.log(` ${pc.green('✓')} All files verified\n`);
133
143
  }
@@ -136,8 +146,10 @@ async function runVerify() {
136
146
  console.log(` ${pc.red('✗')} Missing: ${report.missing.join(', ')}`);
137
147
  if (report.sizeMismatch.length)
138
148
  console.log(` ${pc.yellow('⚠')} Size mismatch: ${report.sizeMismatch.join(', ')}`);
149
+ if (report.hashMismatch.length)
150
+ console.log(` ${pc.yellow('⚠')} Hash mismatch: ${report.hashMismatch.join(', ')}`);
139
151
  console.log('');
140
- process.exit(1);
152
+ process.exit(1); // S4: standalone verify exits 1 on failure (CI-friendly)
141
153
  }
142
154
  }
143
155
  async function runRestore() {
@@ -221,6 +233,8 @@ Usage:
221
233
  --no-db / --no-files Skip DB or file copy
222
234
  --allow-prod-target --confirm <token> Override prod-target safety
223
235
  hazo-env verify <env> Verify files for an environment
236
+ --hash full|sample|none Hash-check all / sampled / none (default: sample)
237
+ --no-orphans Skip orphan detection
224
238
  hazo-env restore <env> --snapshot <id> Restore a snapshot
225
239
  hazo-env snapshot <env> Take a snapshot of an environment
226
240
  hazo-env mask sync Load masking ruleset from INI into DB
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,OAAO,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,kEAAkE;IAClE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAMD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CA+I5E"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,IAAI,GAAG,MAAM,GAAG,OAAO,CAAC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,kEAAkE;IAClE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAMD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CA8R5E"}
package/dist/doctor.js CHANGED
@@ -4,9 +4,11 @@ import path from 'node:path';
4
4
  import { HazoConfig } from 'hazo_config/server';
5
5
  import { optional_import } from 'hazo_core';
6
6
  import { getEnv } from './index.client.js';
7
- import { listEnvs } from './env.server.js';
7
+ import { listEnvs, getEnvRole, getRoleMap } from './env.server.js';
8
8
  import { resolveConnectConfig } from './resolve/connect.js';
9
9
  import { resolveFilesConfig } from './resolve/files.js';
10
+ // Canonical env names that have built-in role defaults — no [env.roles] entry required
11
+ const CANONICAL_ENV_NAMES = new Set(['dev', 'test', 'staging', 'prod']);
10
12
  function findConfigFile(pkg) {
11
13
  return path.resolve(process.cwd(), 'config', `${pkg}_config.ini`);
12
14
  }
@@ -110,8 +112,7 @@ export async function doctor(opts = {}) {
110
112
  if (opts.probe) {
111
113
  for (const env of envsToCheck) {
112
114
  // Get role for this env to guard against touching prod unattended
113
- const role = env === 'dev' ? 'development' : env === 'test' ? 'test'
114
- : env === 'staging' ? 'staging' : 'production';
115
+ const role = getEnvRole(env);
115
116
  if (role === 'production' && !opts.all) {
116
117
  checks.push({
117
118
  label: `DB probe [db.${env}]`,
@@ -153,6 +154,170 @@ export async function doctor(opts = {}) {
153
154
  }
154
155
  }
155
156
  }
157
+ // 6. Check that every env in the pattern has an explicit or canonical role mapping
158
+ const roleMap = getRoleMap();
159
+ const allEnvs = listEnvs();
160
+ for (const env of allEnvs) {
161
+ if (!CANONICAL_ENV_NAMES.has(env) && !(env in roleMap)) {
162
+ checks.push({
163
+ label: `Role for env '${env}'`,
164
+ status: 'error',
165
+ detail: `env '${env}' in pattern has no declared role in [env.roles]; defaulting to development`,
166
+ });
167
+ }
168
+ }
169
+ // 7. Migration readiness checks
170
+ // 7a. _migrations parity (probe mode, --all): open each SQLite env and compare name-sets
171
+ if (opts.probe && opts.all) {
172
+ const migSets = new Map();
173
+ for (const env of envsToCheck) {
174
+ let cfg;
175
+ try {
176
+ cfg = resolveConnectConfig({ env, allowOtherEnv: true });
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ if (cfg.type !== 'sqlite' || !cfg.sqlite)
182
+ continue;
183
+ try {
184
+ const Database = require('better-sqlite3');
185
+ const db = Database(cfg.sqlite.database_path);
186
+ let names;
187
+ try {
188
+ names = db.prepare('SELECT name FROM _migrations').all().map((r) => r.name);
189
+ }
190
+ catch {
191
+ names = [];
192
+ }
193
+ db.close();
194
+ migSets.set(env, new Set(names));
195
+ }
196
+ catch {
197
+ // DB not openable — skip (probe check already reported error)
198
+ }
199
+ }
200
+ if (migSets.size >= 2) {
201
+ const envList = [...migSets.keys()];
202
+ const referenceSet = migSets.get(envList[0]);
203
+ const referenceEnv = envList[0];
204
+ for (const env of envList.slice(1)) {
205
+ const s = migSets.get(env);
206
+ const onlyInRef = [...referenceSet].filter((n) => !s.has(n));
207
+ const onlyInEnv = [...s].filter((n) => !referenceSet.has(n));
208
+ if (onlyInRef.length === 0 && onlyInEnv.length === 0) {
209
+ checks.push({ label: `Migration parity (${referenceEnv} ↔ ${env})`, status: 'ok', detail: `${s.size} applied` });
210
+ }
211
+ else {
212
+ const detail = [
213
+ onlyInRef.length ? `only in ${referenceEnv}: ${onlyInRef.join(', ')}` : '',
214
+ onlyInEnv.length ? `only in ${env}: ${onlyInEnv.join(', ')}` : '',
215
+ ].filter(Boolean).join('; ');
216
+ checks.push({ label: `Migration parity (${referenceEnv} ↔ ${env})`, status: 'warn', detail });
217
+ }
218
+ }
219
+ }
220
+ }
221
+ // 7b. Masking ruleset column validation: every (table, column) in the masking INI must
222
+ // exist in at least one SQLite env's schema. Skip silently for PostgREST envs.
223
+ const maskingConfigPath = path.resolve(process.cwd(), 'config', 'hazo_env_masking.ini');
224
+ if (fs.existsSync(maskingConfigPath)) {
225
+ try {
226
+ // Parse the masking INI directly — HazoConfig doesn't expose section enumeration.
227
+ // Format: each [section] is a table name; keys are column names.
228
+ const iniContent = fs.readFileSync(maskingConfigPath, 'utf-8');
229
+ const rules = [];
230
+ let currentTable = '';
231
+ for (const rawLine of iniContent.split('\n')) {
232
+ const line = rawLine.trim();
233
+ if (!line || line.startsWith(';') || line.startsWith('#'))
234
+ continue;
235
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
236
+ if (sectionMatch) {
237
+ currentTable = sectionMatch[1].trim();
238
+ continue;
239
+ }
240
+ if (!currentTable)
241
+ continue;
242
+ const eqIdx = line.indexOf('=');
243
+ if (eqIdx < 0)
244
+ continue;
245
+ const column = line.slice(0, eqIdx).trim();
246
+ if (column)
247
+ rules.push({ table: currentTable, column });
248
+ }
249
+ if (rules.length > 0) {
250
+ // Collect SQLite schemas for checked envs
251
+ const schemas = new Map(); // "table.column" → present
252
+ for (const env of envsToCheck) {
253
+ let cfg;
254
+ try {
255
+ cfg = resolveConnectConfig({ env, allowOtherEnv: true });
256
+ }
257
+ catch {
258
+ continue;
259
+ }
260
+ if (cfg.type !== 'sqlite' || !cfg.sqlite)
261
+ continue;
262
+ try {
263
+ const Database = require('better-sqlite3');
264
+ const db = Database(cfg.sqlite.database_path);
265
+ try {
266
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
267
+ for (const tbl of tables) {
268
+ const cols = db.prepare(`PRAGMA table_info(${tbl})`).all();
269
+ for (const col of cols) {
270
+ schemas.set(`${tbl}.${col.name}`, new Set());
271
+ }
272
+ }
273
+ }
274
+ catch { /* DB empty or unreadable */ }
275
+ db.close();
276
+ }
277
+ catch { /* DB not openable */ }
278
+ }
279
+ if (schemas.size > 0) {
280
+ const badRules = [];
281
+ for (const { table, column } of rules) {
282
+ if (!schemas.has(`${table}.${column}`)) {
283
+ badRules.push(`${table}.${column}`);
284
+ }
285
+ }
286
+ if (badRules.length === 0) {
287
+ checks.push({ label: 'Masking ruleset columns', status: 'ok', detail: `${rules.length} rule(s) validated` });
288
+ }
289
+ else {
290
+ checks.push({
291
+ label: 'Masking ruleset columns',
292
+ status: 'warn',
293
+ detail: `Column(s) not found in schema: ${badRules.join(', ')} (PostgREST envs skipped)`,
294
+ });
295
+ }
296
+ }
297
+ else {
298
+ // No SQLite envs probed — can't validate columns; skip silently
299
+ const hasPostgrest = envsToCheck.some((env) => {
300
+ try {
301
+ return resolveConnectConfig({ env, allowOtherEnv: true }).type === 'postgrest';
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ });
307
+ if (hasPostgrest) {
308
+ checks.push({
309
+ label: 'Masking ruleset columns',
310
+ status: 'ok',
311
+ detail: 'PostgREST env — column validation skipped',
312
+ });
313
+ }
314
+ }
315
+ }
316
+ }
317
+ catch {
318
+ // Masking config parse failure — non-fatal, skip
319
+ }
320
+ }
156
321
  const passed = checks.every((c) => c.status !== 'error');
157
322
  return { env: targetEnv, checks, passed };
158
323
  }
@@ -1,4 +1,10 @@
1
- import type { EnvPattern, EnvDescription } from './types/index.js';
1
+ import type { EnvPattern, EnvDescription, EnvRoleMap, HazoEnv, HazoEnvRole } from './types/index.js';
2
+ /**
3
+ * Get the role map, merging client defaults with any [env.roles] section from hazo_env_config.ini.
4
+ */
5
+ export declare function getRoleMap(): EnvRoleMap;
6
+ /** Get the broad role for the current environment (server-aware, reads INI) */
7
+ export declare function getEnvRole(env?: HazoEnv): HazoEnvRole;
2
8
  /**
3
9
  * Get the declared env pattern from [env] pattern in hazo_env_config.ini.
4
10
  * Falls back to ['dev','test','staging','prod'] if the config is absent.
@@ -1 +1 @@
1
- {"version":3,"file":"env.server.d.ts","sourceRoot":"","sources":["../src/env.server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAY,MAAM,kBAAkB,CAAC;AAc7E;;;GAGG;AACH,wBAAgB,UAAU,IAAI,UAAU,CAKvC;AAED,gEAAgE;AAChE,wBAAgB,QAAQ,IAAI,UAAU,CAErC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,UAAU,GAAG,IAAI,CAUpD;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,cAAc,CAW5C"}
1
+ {"version":3,"file":"env.server.d.ts","sourceRoot":"","sources":["../src/env.server.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,UAAU,EAAY,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAgB/G;;GAEG;AACH,wBAAgB,UAAU,IAAI,UAAU,CAYvC;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,WAAW,CAGrD;AAED;;;GAGG;AACH,wBAAgB,UAAU,IAAI,UAAU,CAKvC;AAED,gEAAgE;AAChE,wBAAgB,QAAQ,IAAI,UAAU,CAErC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,UAAU,GAAG,IAAI,CAUpD;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,cAAc,CAY5C"}
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import { HazoConfig } from 'hazo_config/server';
4
4
  import { HazoError } from 'hazo_core';
5
- import { getEnv, getEnvRole } from './index.client.js';
5
+ import { getEnv, getRoleMap as clientGetRoleMap, normalize } from './index.client.js';
6
6
  function findConfigFile(pkg) {
7
7
  return path.resolve(process.cwd(), 'config', `${pkg}_config.ini`);
8
8
  }
@@ -14,6 +14,28 @@ function tryLoadConfig() {
14
14
  return null;
15
15
  }
16
16
  }
17
+ const VALID_ROLES = new Set(['development', 'test', 'staging', 'production']);
18
+ /**
19
+ * Get the role map, merging client defaults with any [env.roles] section from hazo_env_config.ini.
20
+ */
21
+ export function getRoleMap() {
22
+ const config = tryLoadConfig();
23
+ const section = config?.getSection?.('env.roles') ?? {};
24
+ const out = { ...clientGetRoleMap() };
25
+ for (const [name, role] of Object.entries(section)) {
26
+ const r = String(role).trim();
27
+ if (VALID_ROLES.has(r)) {
28
+ out[name.trim()] = r;
29
+ }
30
+ // invalid values: skip silently (doctor will flag them)
31
+ }
32
+ return out;
33
+ }
34
+ /** Get the broad role for the current environment (server-aware, reads INI) */
35
+ export function getEnvRole(env) {
36
+ const e = normalize(env ?? getEnv());
37
+ return getRoleMap()[e] ?? 'development';
38
+ }
17
39
  /**
18
40
  * Get the declared env pattern from [env] pattern in hazo_env_config.ini.
19
41
  * Falls back to ['dev','test','staging','prod'] if the config is absent.
@@ -57,5 +79,6 @@ export function describeEnv() {
57
79
  const dataRoot = config?.getSection('data')?.['root'] ?? 'app_data';
58
80
  const locationRaw = config?.getSection(`host.${env}`)?.['location'] ?? 'unknown';
59
81
  const hostHint = locationRaw === 'local' ? 'local' : locationRaw === 'remote' ? 'remote' : 'unknown';
60
- return { env, role, pattern, app, dataRoot, hostHint };
82
+ const roles = getRoleMap();
83
+ return { env, role, pattern, app, dataRoot, hostHint, roles };
61
84
  }
@@ -1,7 +1,11 @@
1
- import type { HazoEnv, HazoEnvRole, EnvPattern, EnvDescription } from './types/index.js';
2
- export type { HazoEnv, HazoEnvRole, EnvPattern, EnvDescription };
1
+ import type { HazoEnv, HazoEnvRole, EnvPattern, EnvDescription, EnvRoleMap } from './types/index.js';
2
+ export type { HazoEnv, HazoEnvRole, EnvPattern, EnvDescription, EnvRoleMap };
3
+ /** Normalize raw env string to canonical form */
4
+ export declare function normalize(raw: string): HazoEnv;
3
5
  /** Get the current environment name (normalized) */
4
6
  export declare function getEnv(): HazoEnv;
7
+ /** Get the role map, merging any build-time injected overrides from __HAZO_ENV_ROLES__ */
8
+ export declare function getRoleMap(): EnvRoleMap;
5
9
  /** Get the broad role for the current environment */
6
10
  export declare function getEnvRole(env?: HazoEnv): HazoEnvRole;
7
11
  export declare function isDev(env?: HazoEnv): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACzF,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,CAAC;AASjE,oDAAoD;AACpD,wBAAgB,MAAM,IAAI,OAAO,CAEhC;AAED,qDAAqD;AACrD,wBAAgB,UAAU,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,WAAW,CAMrD;AAED,wBAAgB,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAA8C;AAC3F,wBAAgB,MAAM,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAuC;AACrF,wBAAgB,SAAS,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAA0C;AAC3F,wBAAgB,MAAM,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAA6C;AAE3F;;;;GAIG;AACH,wBAAgB,UAAU,IAAI,UAAU,CAMvC;AAED,gEAAgE;AAChE,wBAAgB,QAAQ,IAAI,UAAU,CAErC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,UAAU,GAAG,IAAI,CAUpD;AAED,4DAA4D;AAC5D,wBAAgB,WAAW,IAAI,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,MAAM,CAAC,CAGlE"}
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACrG,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,UAAU,EAAE,CAAC;AAE7E,iDAAiD;AACjD,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAI9C;AAED,oDAAoD;AACpD,wBAAgB,MAAM,IAAI,OAAO,CAEhC;AASD,0FAA0F;AAC1F,wBAAgB,UAAU,IAAI,UAAU,CAIvC;AAED,qDAAqD;AACrD,wBAAgB,UAAU,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,WAAW,CAGrD;AAED,wBAAgB,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAA8C;AAC3F,wBAAgB,MAAM,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAuC;AACrF,wBAAgB,SAAS,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAA0C;AAC3F,wBAAgB,MAAM,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAA6C;AAE3F;;;;GAIG;AACH,wBAAgB,UAAU,IAAI,UAAU,CAMvC;AAED,gEAAgE;AAChE,wBAAgB,QAAQ,IAAI,UAAU,CAErC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,UAAU,GAAG,IAAI,CAUpD;AAED,4DAA4D;AAC5D,wBAAgB,WAAW,IAAI,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,MAAM,CAAC,CAGlE"}