hazo_env 0.1.1 → 0.3.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 (56) hide show
  1. package/CHANGE_LOG.md +26 -0
  2. package/README.md +117 -13
  3. package/SETUP_CHECKLIST.md +73 -0
  4. package/config/hazo_env_config.ini.sample +16 -0
  5. package/config/hazo_env_masking.ini.sample +16 -16
  6. package/dist/cli.js +170 -9
  7. package/dist/doctor.d.ts.map +1 -1
  8. package/dist/doctor.js +16 -3
  9. package/dist/env.server.d.ts +7 -1
  10. package/dist/env.server.d.ts.map +1 -1
  11. package/dist/env.server.js +25 -2
  12. package/dist/index.client.d.ts +6 -2
  13. package/dist/index.client.d.ts.map +1 -1
  14. package/dist/index.client.js +15 -9
  15. package/dist/index.d.ts +11 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +13 -2
  18. package/dist/lib/index.d.ts +1 -1
  19. package/dist/lib/index.d.ts.map +1 -1
  20. package/dist/lib/index.js +2 -1
  21. package/dist/lib/secret_columns.d.ts +3 -0
  22. package/dist/lib/secret_columns.d.ts.map +1 -0
  23. package/dist/lib/secret_columns.js +14 -0
  24. package/dist/mask/registry.d.ts +8 -0
  25. package/dist/mask/registry.d.ts.map +1 -0
  26. package/dist/mask/registry.js +29 -0
  27. package/dist/mask/ruleset.d.ts +15 -0
  28. package/dist/mask/ruleset.d.ts.map +1 -0
  29. package/dist/mask/ruleset.js +77 -0
  30. package/dist/migrate/audit.d.ts +21 -0
  31. package/dist/migrate/audit.d.ts.map +1 -0
  32. package/dist/migrate/audit.js +36 -0
  33. package/dist/migrate/db.d.ts +13 -0
  34. package/dist/migrate/db.d.ts.map +1 -0
  35. package/dist/migrate/db.js +122 -0
  36. package/dist/migrate/files.d.ts +14 -0
  37. package/dist/migrate/files.d.ts.map +1 -0
  38. package/dist/migrate/files.js +72 -0
  39. package/dist/migrate/progress.d.ts +5 -0
  40. package/dist/migrate/progress.d.ts.map +1 -0
  41. package/dist/migrate/progress.js +24 -0
  42. package/dist/migrate/run.d.ts +3 -0
  43. package/dist/migrate/run.d.ts.map +1 -0
  44. package/dist/migrate/run.js +195 -0
  45. package/dist/migrate/snapshot.d.ts +8 -0
  46. package/dist/migrate/snapshot.d.ts.map +1 -0
  47. package/dist/migrate/snapshot.js +48 -0
  48. package/dist/migrate/transport.d.ts +3 -0
  49. package/dist/migrate/transport.d.ts.map +1 -0
  50. package/dist/migrate/transport.js +17 -0
  51. package/dist/migrate/verify.d.ts +8 -0
  52. package/dist/migrate/verify.d.ts.map +1 -0
  53. package/dist/migrate/verify.js +51 -0
  54. package/dist/types/index.d.ts +47 -27
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/package.json +15 -3
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # hazo_env — Change Log
2
2
 
3
+ ## 0.3.0 — 2026-06-10
4
+
5
+ ### Added
6
+ - `EnvRoleMap` type (`Record<string, HazoEnvRole>`) in `src/types/index.ts`
7
+ - `roles?: EnvRoleMap` optional field on `EnvDescription` interface
8
+ - `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
9
+ - `[env.roles]` INI section support: arbitrary env names mapped to the four fixed roles (`development`, `test`, `staging`, `production`)
10
+ - `describeEnv()` server version now includes `roles` field
11
+ - Filesystem progress store: `writeMigrationProgress`, `readMigrationProgress`, `clearMigrationProgress` (`src/migrate/progress.ts`)
12
+ - `MigrationRequest.jobId` + `MigrationRequest.progressDir` optional fields — when both present, `runMigration` writes each progress event to `<progressDir>/<jobId>.json`
13
+ - `loadRuleset`, `syncRulesetFromIni`, `parseIniRules` masking ruleset functions now exported from package index
14
+ - `MaskRule` type now exported from package index
15
+ - `normalize()` utility now exported from package index (trim + lowercase env name string)
16
+
17
+ ### Changed
18
+ - `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)
19
+
20
+ ## 0.2.0 — 2026-06-09
21
+
22
+ - feat: M2 migration engine — `runMigration`, `verifyFiles`, `takeSnapshot`, `restoreSnapshot`
23
+ - feat: M3 PII masking — `registerMask`, built-in transforms (`mask_email`, `fake_name`, `mask_phone`, `jitter_date`, `hash`, `tokenize`, `drop`, `nullify`, `redact_pii`)
24
+ - feat: masking ruleset management — `loadRuleset`, `syncRulesetFromIni`, `parseIniRules`, `MaskRule` (not yet exported from index — fixed in 0.3.0)
25
+ - feat: `MaskTransform` type in `src/types/index.ts`
26
+ - feat: CLI commands `migrate`, `verify`, `restore`, `mask sync`, `mask list`
27
+ - chore: version bump to 0.2.0
28
+
3
29
  ## 0.1.1 — 2026-06-09
4
30
 
5
31
  - 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
@@ -1,6 +1,6 @@
1
1
  # hazo_env
2
2
 
3
- Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/secret config, a `doctor` diagnostic command, and a `hazo-env` CLI.
3
+ Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/secret config, a DB+files migration engine with PII masking, a `doctor` diagnostic command, and a `hazo-env` CLI.
4
4
 
5
5
  ## What it does
6
6
 
@@ -8,8 +8,10 @@ Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/s
8
8
  - **Per-env DB config** — `resolveConnectConfig()` maps the current env to its `hazo_connect` config (SQLite or PostgREST) with zero hardcoded connection strings in app code.
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
+ - **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 `test` or `staging`.
11
13
  - **Doctor** — `doctor()` / `hazo-env doctor` validates pattern, DB reachability, required secrets (no values printed), data_root writability, and schema level.
12
- - **CLI** — `hazo-env current | doctor | snapshot`.
14
+ - **CLI** — `hazo-env current | doctor | snapshot | migrate | verify | restore | mask`.
13
15
 
14
16
  ## Installation
15
17
 
@@ -18,7 +20,7 @@ npm install hazo_env
18
20
  ```
19
21
 
20
22
  Peer deps (required): `hazo_core`, `hazo_config`.
21
- Peer deps (optional): `hazo_connect` (for `resolveConnectConfig`), `hazo_files` (for `resolveFilesConfig`).
23
+ Peer deps (optional): `hazo_connect` (for `resolveConnectConfig`), `hazo_files` (for `resolveFilesConfig`), `hazo_secure` (for masking transforms), `hazo_pdf` (for `mask_pdf` transform), `hazo_audit` (for migration audit entries).
22
24
 
23
25
  ## Quick start
24
26
 
@@ -62,18 +64,67 @@ const adapter = await createHazoConnect(resolveConnectConfig());
62
64
  ### Env resolution (client-safe — also in `hazo_env/client`)
63
65
 
64
66
  ```ts
65
- import { getEnv, getEnvRole, getPattern, listEnvs, assertEnv, describeEnv,
67
+ import { getEnv, getEnvRole, getRoleMap, getPattern, listEnvs, assertEnv, describeEnv,
66
68
  isDev, isTest, isStaging, isProd } from 'hazo_env';
67
69
 
68
70
  getEnv() // 'dev' | 'test' | 'staging' | 'prod'
69
- getEnvRole() // 'development' | 'testing' | 'staging' | 'production'
71
+ getEnvRole() // 'development' | 'test' | 'staging' | 'production'
72
+ getRoleMap() // EnvRoleMap — full env→role mapping (config-driven; safe defaults)
70
73
  getPattern() // e.g. 'dev_prod'
71
74
  listEnvs() // ['dev', 'prod'] — valid envs for the declared pattern
72
75
  assertEnv() // throws HazoError(ENV_INVALID) if HAZO_ENV is outside the pattern
73
- describeEnv() // { env, role }
76
+ describeEnv() // { env, role, pattern, app, dataRoot, hostHint, roles? }
74
77
  isDev() / isTest() / isStaging() / isProd()
75
78
  ```
76
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
+
77
128
  ### Resolvers (server-only)
78
129
 
79
130
  ```ts
@@ -101,12 +152,71 @@ const report = await doctor();
101
152
  // report.checks DoctorCheck[] — { label, ok, message }
102
153
  ```
103
154
 
155
+ ### Migration engine (server-only)
156
+
157
+ ```ts
158
+ import { runMigration, verifyFiles, takeSnapshot, restoreSnapshot,
159
+ writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from 'hazo_env';
160
+
161
+ // Copy prod → staging (with PII masking applied automatically)
162
+ const result = await runMigration({
163
+ from: 'prod',
164
+ to: 'staging',
165
+ dryRun: false, // set true for a plan with counts, no writes
166
+ // allowProdTarget: true, // required when `to` is 'prod'
167
+ // confirmToken: '<token>', // required together with allowProdTarget
168
+ onProgress: (p) => console.log(p.phase, p.message),
169
+ // jobId: '<id>', // optional — persist progress to progressDir/<id>.json
170
+ // progressDir: '/tmp/hazo_jobs', // required when jobId is set
171
+ });
172
+ // result.ok, result.db, result.files, result.snapshotId, result.warnings, result.durationMs
173
+
174
+ // Stand-alone verification
175
+ await verifyFiles('staging', dataRoot, { hash: 'sample' });
176
+
177
+ // Manual snapshot / restore
178
+ const snap = takeSnapshot(connectConfig); // returns { snapshotId }
179
+ restoreSnapshot(connectConfig, snapshotId);
180
+
181
+ // Filesystem progress store (used by hazo_jobs integration)
182
+ writeMigrationProgress(dir, jobId, progress);
183
+ readMigrationProgress(dir, jobId); // → MigrationProgress | null
184
+ clearMigrationProgress(dir, jobId);
185
+ ```
186
+
187
+ **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)`.
188
+
189
+ **Masking ruleset API** — load, parse, and sync rulesets programmatically:
190
+
191
+ ```ts
192
+ import { loadRuleset, syncRulesetFromIni, parseIniRules } from 'hazo_env';
193
+ import type { MaskRule } from 'hazo_env';
194
+
195
+ const rules: MaskRule[] = parseIniRules(iniText); // parse INI text → rules array
196
+ const loaded = await loadRuleset(adapter); // read from hazo_app_config DB
197
+ await syncRulesetFromIni(adapter, '/path/to/masking.ini'); // write parsed rules to DB
198
+ ```
199
+
104
200
  ## CLI
105
201
 
106
202
  ```
107
203
  hazo-env current # prints env, role, pattern, app, data_root
108
204
  hazo-env doctor [--env <e>] [--all] # red/green validation table
109
- hazo-env snapshot <env> # placeholder full impl arrives in M2
205
+ hazo-env snapshot <env> # snapshot the target env DB
206
+
207
+ hazo-env migrate --from <env> --to <env> [options]
208
+ --transport auto|local|ssh|api # default auto
209
+ --no-db | --no-files # skip DB or file copy
210
+ --scrub auto|none # default auto (mask when target is test/staging)
211
+ --tables a,b,c # restrict to specific tables
212
+ --dry-run # plan + counts, no writes
213
+ --allow-prod-target --confirm <tok> # required when writing prod
214
+
215
+ hazo-env verify <env> [--hash none|sample|full] [--no-orphans]
216
+ hazo-env restore <env> --snapshot <snapshot-id>
217
+
218
+ hazo-env mask sync # sync hazo_env_masking.ini → hazo_app_config
219
+ hazo-env mask list # print active masking rules
110
220
  ```
111
221
 
112
222
  ## Config reference (`hazo_env_config.ini`)
@@ -121,12 +231,6 @@ Key sections:
121
231
 
122
232
  Secret placeholders use `${ENV_VAR_NAME}` syntax — hazo_env substitutes them from `.env.local` at runtime.
123
233
 
124
- ## Tailwind v4 (`@source` required if consuming UI)
125
-
126
- ```css
127
- @source "../node_modules/hazo_env/dist";
128
- ```
129
-
130
234
  ## License
131
235
 
132
236
  MIT
@@ -8,6 +8,8 @@ Follow these steps when adding `hazo_env` to a consuming application.
8
8
  npm install hazo_env hazo_core hazo_config
9
9
  # optional — only if you use resolveConnectConfig / resolveFilesConfig:
10
10
  npm install hazo_connect hazo_files
11
+ # optional — only if you use the migration engine with masking + PDF scrub + audit trail:
12
+ npm install hazo_secure hazo_pdf hazo_audit
11
13
  ```
12
14
 
13
15
  ## 2. Create `hazo_env_config.ini`
@@ -31,6 +33,16 @@ api_key = ${POSTGREST_API_KEY}
31
33
 
32
34
  **Never put secret values directly in the ini file** — use `${ENV_VAR_NAME}` placeholders.
33
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
+
34
46
  ## 3. Create `.env.local` (gitignored)
35
47
 
36
48
  One file per deployment (dev machine, staging host, prod host). Contains the secrets referenced by `${…}` placeholders in the ini:
@@ -90,3 +102,64 @@ const nextConfig = {
90
102
  transpilePackages: ['hazo_env', 'hazo_core', /* … */],
91
103
  };
92
104
  ```
105
+
106
+ ## 9. (Optional) Set up the migration engine
107
+
108
+ Skip this section if you only need env resolution and resolvers.
109
+
110
+ ### 9a. Create `hazo_env_masking.ini`
111
+
112
+ Copy `node_modules/hazo_env/config/hazo_env_masking.ini.sample` to your app's `config/` directory and declare per-table masking rules:
113
+
114
+ ```ini
115
+ [hazo_users]
116
+ email = mask_email
117
+ full_name = fake_name
118
+ password_hash = drop
119
+
120
+ [my_app_clients]
121
+ notes = redact_pii
122
+ dob = jitter_date
123
+ ```
124
+
125
+ Sync to the DB so the runtime engine can read them:
126
+
127
+ ```bash
128
+ npx hazo-env mask sync
129
+ ```
130
+
131
+ ### 9b. Set `HAZO_ENV_MASK_KEY` in `.env.local`
132
+
133
+ Required for deterministic masking transforms (`hash`, `tokenize`). If absent, migration aborts when scrub mode is `auto`.
134
+
135
+ ```
136
+ HAZO_ENV_MASK_KEY=<random-32-char-hex>
137
+ ```
138
+
139
+ ### 9c. Run a dry-run migration to verify the plan
140
+
141
+ ```bash
142
+ npx hazo-env migrate --from prod --to staging --dry-run
143
+ ```
144
+
145
+ Review row counts and scrub counts before running without `--dry-run`. The migration will snapshot the target env before writing so you can restore if needed:
146
+
147
+ ```bash
148
+ npx hazo-env restore staging --snapshot <snapshot-id>
149
+ ```
150
+
151
+ ## 10. (Required for non-canonical env names) Declare roles in `[env.roles]`
152
+
153
+ 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:
154
+
155
+ ```ini
156
+ [env.roles]
157
+ qa = test
158
+ preview = staging
159
+ live = production
160
+ rc = staging
161
+ ```
162
+
163
+ Valid role values: `development`, `test`, `staging`, `production`.
164
+
165
+ 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
@@ -1,19 +1,19 @@
1
1
  ; hazo_env masking ruleset sample
2
- ; Copy to hazo_env_masking.ini and customize.
3
- ; This file seeds the masking engine with default rules for PII fields.
4
- ; The runtime source of truth (once hazo_admin is available) is the DB-backed ruleset.
2
+ ; Copy to config/hazo_env_masking.ini and customise.
3
+ ; Load into hazo_app_config with: hazo-env mask sync
4
+ ;
5
+ ; Format: [table_name] then column = transform_name
6
+ ; Transforms: mask_email, mask_phone, fake_name, jitter_date, hash, tokenize, drop, nullify
7
+ ; Custom transforms can be registered via registerMask() in your app.
5
8
 
6
- [rule.users_email]
7
- table = hazo_auth_users
8
- column = email
9
- strategy = mask_email
9
+ [hazo_users]
10
+ email = mask_email
11
+ full_name = fake_name
12
+ phone = mask_phone
13
+ dob = jitter_date
14
+ password_hash = drop
10
15
 
11
- [rule.users_name]
12
- table = hazo_auth_users
13
- column = full_name
14
- strategy = fake_name
15
-
16
- [rule.api_keys]
17
- table = hazo_api_keys
18
- column = key_hash
19
- strategy = drop
16
+ [hazo_auth_users]
17
+ email = mask_email
18
+ full_name = fake_name
19
+ password_hash = drop
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // hazo_env/src/cli.ts — CLI entry: hazo-env current | doctor | snapshot
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)}`);
@@ -60,10 +60,151 @@ async function runDoctor() {
60
60
  }
61
61
  async function runSnapshot() {
62
62
  const targetEnv = args[0] ?? getEnv();
63
- console.log(`\n${pc.yellow('snapshot')} — not implemented until Phase 2 (migration engine)\n`);
64
- console.log(` Requested snapshot of env: ${pc.bold(targetEnv)}`);
65
- console.log(` Run "hazo-env snapshot" again after the migration engine is built.\n`);
66
- process.exit(1);
63
+ console.log(`\n${pc.bold('hazo-env snapshot')} ${pc.dim(targetEnv)}\n`);
64
+ const { takeSnapshot } = await import('./migrate/snapshot.js');
65
+ const { resolveConnectConfig } = await import('./resolve/connect.js');
66
+ const toConfig = resolveConnectConfig({ env: targetEnv, allowOtherEnv: true });
67
+ const result = takeSnapshot(toConfig);
68
+ console.log(` ${pc.green('✓')} Snapshot: ${result.snapshotId}\n`);
69
+ }
70
+ async function runMigrate() {
71
+ const fromIdx = args.findIndex((a) => a === '--from');
72
+ const toIdx = args.findIndex((a) => a === '--to');
73
+ const from = fromIdx >= 0 ? args[fromIdx + 1] : undefined;
74
+ const to = toIdx >= 0 ? args[toIdx + 1] : undefined;
75
+ const dryRun = args.includes('--dry-run');
76
+ const noFiles = args.includes('--no-files');
77
+ const noDb = args.includes('--no-db');
78
+ const allowProdTarget = args.includes('--allow-prod-target');
79
+ const confirmIdx = args.findIndex((a) => a === '--confirm');
80
+ const confirmToken = confirmIdx >= 0 ? args[confirmIdx + 1] : undefined;
81
+ const tablesIdx = args.findIndex((a) => a === '--tables');
82
+ const tablesArg = tablesIdx >= 0 ? args[tablesIdx + 1] : undefined;
83
+ const tables = tablesArg ? tablesArg.split(',').map((t) => t.trim()) : undefined;
84
+ if (!from || !to) {
85
+ console.error(pc.red('Error: migrate requires --from <env> and --to <env>'));
86
+ process.exit(1);
87
+ }
88
+ console.log(`\n${pc.bold('hazo-env migrate')} ${pc.dim(`${from} → ${to}${dryRun ? ' (dry-run)' : ''}`)}\n`);
89
+ const { runMigration } = await import('./migrate/run.js');
90
+ try {
91
+ const result = await runMigration({
92
+ from,
93
+ to,
94
+ include: { db: !noDb, files: !noFiles },
95
+ tables: tables ?? '*',
96
+ dryRun,
97
+ allowProdTarget,
98
+ confirmToken,
99
+ onProgress: (p) => {
100
+ const pct = p.percent != null ? `${p.percent}%`.padStart(4) + ' ' : ' ';
101
+ console.log(` ${pct}${pc.dim(p.phase.padEnd(12))} ${p.message}`);
102
+ },
103
+ });
104
+ if (result.ok) {
105
+ console.log(`\n ${pc.green('✓')} Migration ${dryRun ? 'plan built (dry-run)' : 'complete'}`);
106
+ if (result.db)
107
+ console.log(` ${pc.dim('DB:')} ${result.db.tables} tables, ${result.db.rows} rows${result.db.scrubbed ? `, ${result.db.scrubbed} scrubbed` : ''}`);
108
+ if (result.files)
109
+ console.log(` ${pc.dim('Files:')} ${result.files.copied} copied`);
110
+ if (result.snapshotId && !result.snapshotId.startsWith('no-db:'))
111
+ console.log(` ${pc.dim('Snapshot:')} ${result.snapshotId}`);
112
+ if (result.warnings.length)
113
+ result.warnings.forEach((w) => console.log(` ${pc.yellow('⚠')} ${w}`));
114
+ }
115
+ }
116
+ catch (e) {
117
+ const msg = e instanceof Error ? e.message : String(e);
118
+ console.error(`\n ${pc.red('✗')} ${msg}\n`);
119
+ process.exit(1);
120
+ }
121
+ console.log('');
122
+ }
123
+ async function runVerify() {
124
+ const targetEnv = args[0] ?? getEnv();
125
+ console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)}\n`);
126
+ const { verifyFiles } = await import('./migrate/verify.js');
127
+ const { resolveFilesConfig } = await import('./resolve/files.js');
128
+ const dataRoot = resolveFilesConfig().local.basePath;
129
+ const report = await verifyFiles(targetEnv, dataRoot, { hash: 'sample', checkOrphans: true });
130
+ console.log(` Checked: ${report.checked} files`);
131
+ if (report.ok) {
132
+ console.log(` ${pc.green('✓')} All files verified\n`);
133
+ }
134
+ else {
135
+ if (report.missing.length)
136
+ console.log(` ${pc.red('✗')} Missing: ${report.missing.join(', ')}`);
137
+ if (report.sizeMismatch.length)
138
+ console.log(` ${pc.yellow('⚠')} Size mismatch: ${report.sizeMismatch.join(', ')}`);
139
+ console.log('');
140
+ process.exit(1);
141
+ }
142
+ }
143
+ async function runRestore() {
144
+ const targetEnv = args[0];
145
+ const snapshotIdx = args.findIndex((a) => a === '--snapshot');
146
+ const snapshotId = snapshotIdx >= 0 ? args[snapshotIdx + 1] : undefined;
147
+ if (!targetEnv || !snapshotId) {
148
+ console.error(pc.red('Error: restore requires <env> --snapshot <snapshot-id>'));
149
+ process.exit(1);
150
+ }
151
+ console.log(`\n${pc.bold('hazo-env restore')} ${pc.dim(`${targetEnv} ← ${snapshotId}`)}\n`);
152
+ const { restoreSnapshot } = await import('./migrate/snapshot.js');
153
+ const { resolveConnectConfig } = await import('./resolve/connect.js');
154
+ const toConfig = resolveConnectConfig({ env: targetEnv, allowOtherEnv: true });
155
+ restoreSnapshot(toConfig, snapshotId);
156
+ console.log(` ${pc.green('✓')} Restored\n`);
157
+ }
158
+ async function runMask() {
159
+ const subCmd = args[0];
160
+ if (subCmd === 'sync') {
161
+ console.log(`\n${pc.bold('hazo-env mask sync')}\n`);
162
+ try {
163
+ const connectMod = await import('hazo_connect/server').catch(() => null);
164
+ if (!connectMod) {
165
+ console.error(pc.red('Error: hazo_connect is required for mask sync.'));
166
+ process.exit(1);
167
+ }
168
+ const { resolveConnectConfig } = await import('./resolve/connect.js');
169
+ const { syncRulesetFromIni } = await import('./mask/ruleset.js');
170
+ const dbConfig = resolveConnectConfig({ allowOtherEnv: false });
171
+ if (dbConfig.type !== 'sqlite' || !dbConfig.sqlite) {
172
+ console.error(pc.red('Error: mask sync only supports SQLite databases.'));
173
+ process.exit(1);
174
+ }
175
+ const adapter = connectMod.createHazoConnect({
176
+ type: 'sqlite',
177
+ sqlite: { database_path: dbConfig.sqlite.database_path },
178
+ });
179
+ const count = await syncRulesetFromIni(adapter);
180
+ console.log(` ${pc.green('✓')} Synced ${count} rules into hazo_app_config\n`);
181
+ }
182
+ catch (e) {
183
+ console.error(`\n ${pc.red('✗')} ${e instanceof Error ? e.message : String(e)}\n`);
184
+ process.exit(1);
185
+ }
186
+ return;
187
+ }
188
+ if (subCmd === 'list') {
189
+ console.log(`\n${pc.bold('hazo-env mask list')}\n`);
190
+ const { listTransforms } = await import('./mask/registry.js');
191
+ const transforms = await listTransforms();
192
+ if (transforms.length === 0) {
193
+ console.log(' (no transforms registered)\n');
194
+ }
195
+ else {
196
+ for (const name of transforms)
197
+ console.log(` ${pc.dim('•')} ${name}`);
198
+ console.log('');
199
+ }
200
+ return;
201
+ }
202
+ console.log(`
203
+ ${pc.bold('hazo-env mask')} — masking commands
204
+
205
+ hazo-env mask sync Load hazo_env_masking.ini into hazo_app_config
206
+ hazo-env mask list List registered transform functions
207
+ `);
67
208
  }
68
209
  function printHelp() {
69
210
  console.log(`
@@ -75,7 +216,15 @@ Usage:
75
216
  hazo-env doctor --all Run health checks for all declared environments
76
217
  hazo-env doctor --env <env> Run health checks for a specific environment
77
218
  hazo-env doctor --probe Also probe live DB reachability
78
- hazo-env snapshot <env> (Phase 2 not yet implemented)
219
+ hazo-env migrate --from <env> --to <env> Migrate DB + files between environments
220
+ --dry-run Show plan without executing
221
+ --no-db / --no-files Skip DB or file copy
222
+ --allow-prod-target --confirm <token> Override prod-target safety
223
+ hazo-env verify <env> Verify files for an environment
224
+ hazo-env restore <env> --snapshot <id> Restore a snapshot
225
+ hazo-env snapshot <env> Take a snapshot of an environment
226
+ hazo-env mask sync Load masking ruleset from INI into DB
227
+ hazo-env mask list List registered mask transforms
79
228
  `);
80
229
  }
81
230
  (async () => {
@@ -89,6 +238,18 @@ Usage:
89
238
  else if (command === 'snapshot') {
90
239
  await runSnapshot();
91
240
  }
241
+ else if (command === 'migrate') {
242
+ await runMigrate();
243
+ }
244
+ else if (command === 'verify') {
245
+ await runVerify();
246
+ }
247
+ else if (command === 'restore') {
248
+ await runRestore();
249
+ }
250
+ else if (command === 'mask') {
251
+ await runMask();
252
+ }
92
253
  else {
93
254
  printHelp();
94
255
  }
@@ -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,CA2J5E"}
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,18 @@ 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
+ }
156
169
  const passed = checks.every((c) => c.status !== 'error');
157
170
  return { env: targetEnv, checks, passed };
158
171
  }
@@ -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"}