hazo_env 0.6.1 → 0.8.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.
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # hazo_env — Change Log
2
2
 
3
+ ## 0.8.0 — 2026-07-23
4
+
5
+ ### Removed / Breaking
6
+ The old SSH/PostgREST-row-copy migration stack is fully removed — it predates `envsync` (0.7.0) and is completely superseded by it. `envsync` (`hazo-env sync download-db|upload-db|download-files|upload-files`, `hazo-env serve`) is the only supported way to move a DB or files between environments going forward; it runs entirely local `pg_dump`/`pg_restore`/`tar`, no SSH, no PostgREST row copy.
7
+
8
+ - **Deleted modules:** `src/migrate/transport.ts`, `src/migrate/db.ts`, `src/migrate/db.postgrest.ts`, `src/migrate/db-dump-restore.ts`, `src/migrate/files.ts` (rsync-over-SSH), `src/migrate/snapshot.ts`, `src/migrate/verify.ts`, `src/migrate/audit.ts`, `src/mask/registry.ts`, `src/mask/ruleset.ts` (all of `src/mask/`), `src/resolve/backup.ts`, `src/resolve/migrate.ts`.
9
+ - **Deleted orchestrators (cascade — their entire purpose was wiring the modules above):** `src/migrate/run.ts` (`runMigration`), `src/migrate/clear.ts` (`clearEnv`), `src/migrate/progress.ts` (`writeMigrationProgress`/`readMigrationProgress`/`clearMigrationProgress` — had no remaining caller once `run.ts`/`clear.ts` were gone).
10
+ - **Removed exports from `src/index.ts`:** `runMigration`, `clearEnv`, `verifyFiles`, `takeSnapshot`, `restoreSnapshot`, `writeMigrationProgress`, `readMigrationProgress`, `clearMigrationProgress`, `registerMask`, `loadRuleset`, `syncRulesetFromIni`, `parseIniRules`, `resolveBackupConfig`, `restoreDbViaDump`. Removed types: `MaskRule`, `MaskTransform`, `BackupConfig`.
11
+ - **Removed types from `src/types/index.ts`:** `TransportMode`, `ScrubMode`, `HashMode`, `MigrationProgress`, `MigrationRequest`, `MigrationResult`, `VerifyReport`, `MigrateConfig`, `ClearRequest`, `ClearResult`, `MaskTransform`.
12
+ - **Removed CLI commands:** `hazo-env migrate`, `hazo-env verify`, `hazo-env snapshot`, `hazo-env restore`, `hazo-env mask sync|list`, `hazo-env clear`. Remaining commands: `current`, `doctor`, `sync`, `serve`.
13
+ - **Kept as generic, currently-unconsumed-in-this-repo infra** (per explicit design decision — `hazo_env` is a shared package, and these are SSH-exec primitives, not migration-specific): `runSsh`, `assertSafeSshField` (`src/migrate/ssh-exec.ts`), `resolveSshConfig` (`src/resolve/ssh.ts`), `src/resolve/connect.ts`, `src/resolve/files.ts`, `src/env.server.ts`, `src/doctor.ts` — all still exported from `src/index.ts` exactly as before.
14
+ - **test-app:** removed `/migrate`, `/masking`, `/verify` pages + nav entries, `/api/migrate`, `/api/clear`, `/api/verify` routes, `config/hazo_env_masking.ini`, and the `verify-fixtures`/`clear-env` harness scenarios (both drove the now-deleted API routes). `scripts/seed.mjs` no longer seeds the `hazo_users` PII fixture or `hazo_files`/on-disk verify fixtures — nothing consumes them anymore.
15
+ - **Removed `config/hazo_env_masking.ini.sample`** — sample masking ruleset, orphaned now that `mask sync` / `registerMask()` no longer exist.
16
+ - **Removed test suites:** `__tests__/migration.test.ts`, `__tests__/masking.test.ts`, `__tests__/verify.test.ts`, `__tests__/backup.test.ts`, `__tests__/progress.test.ts` (all covered deleted modules).
17
+
18
+ ### Migration guide
19
+ Replace any `runMigration({ from, to, ... })` call with `hazo-env sync download-db` on the source host + `hazo-env sync upload-db --dump <path> --confirm` on the target, or the equivalent `downloadDb`/`uploadDb` from `hazo_env/dist/envsync/engine.js` for programmatic use. Files: `hazo-env sync download-files` / `upload-files`. There is no PII-masking pass in `envsync` — if you relied on `registerMask()`/`hazo_env_masking.ini`, scrub the dump/archive yourself before `upload-db`/`upload-files` (or scrub at the destination) until/unless masking is reintroduced as an `envsync` feature.
20
+
21
+ ## 0.7.0 — 2026-07-23
22
+
23
+ ### Added
24
+ - **`envsync`** — local Postgres/files sync engine, independent of the existing SSH-based `migrate`/`backup` transports. Orchestrates `pg_dump`/`pg_restore`/`dropdb`/`createdb`/`tar` as real argv (never shell-string-built) against local Postgres + a local files root.
25
+ - `src/envsync/engine.ts`: `downloadDb`, `uploadDb`, `downloadFiles`, `uploadFiles`. `downloadFiles` packs the current `.env.local` into the archive under a fixed member name; `uploadFiles` extracts it, merges `[migrate.env_overrides]` on top, and diffs the result against the *current* target `.env.local` before ever writing (dry-run by default — pass `confirm:true` to apply).
26
+ - `src/envsync/lock.ts`: `withLock()` — single-run guard (in-process + on-disk `<work_dir>/.envsync.lock`, self-healing on a dead PID) so two envsync ops (CLI or HTTP) can never race each other.
27
+ - `src/envsync/retention.ts`: `pruneWorkDir()` (keep-N per file kind) + `assertFreeSpace()` (500MiB floor before writing a new dump/archive).
28
+ - `src/envsync/guards.ts`: `assertConfirmed` / `assertNotProd` — destructive ops (`uploadDb`, confirmed `uploadFiles`) refuse to run without an explicit `confirm:true`, and refuse a target that looks like production without `allowProd:true`.
29
+ - `src/resolve/envsync.ts`: `resolveEnvsyncConfig()` — reads `[envsync]` + `[migrate.env_overrides]` from `hazo_env_config.ini`, `${VAR}`-expanded, null-on-missing (mirrors `resolveBackupConfig`).
30
+ - **`hazo-env sync` CLI** — `download-db`, `upload-db --dump <path> [--confirm] [--allow-prod-target]`, `download-files`, `upload-files --archive <path> [--confirm] [--allow-prod-target]`. Every op runs under `withLock`; `upload-files` without `--confirm` pretty-prints the env-var diff preview (key, before → after) instead of writing anything.
31
+ - **`hazo-env serve` CLI + `startEnvsyncService()`** (`src/envsync/service.ts`) — a plain `node:http` service (no framework dependency) exposing the same engine over HTTP, for driving envsync from a browser/ops UI instead of a terminal:
32
+ - `GET /status`, `POST /db/download`, `POST /db/upload`, `POST /files/archive`, `POST /files/restore`, plus `GET /` — a minimal self-contained HTML control page (inline CSS/JS, no build step, no external assets).
33
+ - Security model: every route requires `Authorization: Bearer <HAZO_ENVSYNC_TOKEN>` (no exceptions — the server refuses to start at all if the token env var is unset); binds `127.0.0.1` by default (`HAZO_ENVSYNC_BIND` to widen); default port `7845` (`HAZO_ENVSYNC_PORT` to override); every write op goes through the same `withLock` + `assertConfirmed`/`assertNotProd` guards the CLI uses, so the HTTP layer adds no bypass — a concurrent request gets `409`, an unconfirmed/prod-looking destructive op gets `400`.
34
+ - `startEnvsyncService(opts?: {port, bind, token})` — opts override env vars, making the service testable with `port: 0` (OS-assigned ephemeral port) and no real token/env-var setup.
35
+
36
+ ### Notes
37
+ - `envsync` is a server/ops-only surface (CLI + HTTP service) — it is not wired into the browser-based `hazo_ui` autotest harness in `test-app/` (that harness only exercises pure client-safe functions; `envsync` needs a real local Postgres + real `files_root`). Its behavior is verified in `__tests__/envsync-*.test.ts`, in particular `__tests__/envsync-service.test.ts` for the HTTP surface.
38
+ - `src/envsync/*` and `src/resolve/envsync.ts` are not re-exported from the package's `index.ts` — reach them via `hazo_env/dist/envsync/*` / the `hazo-env` CLI, not the public API surface.
39
+
3
40
  ## 0.6.1 — 2026-07-21
4
41
 
5
42
  ### Fixed
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 DB+files migration engine with PII masking, 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 local Postgres/files sync engine (`envsync`), a `doctor` diagnostic command, and a `hazo-env` CLI.
4
4
 
5
5
  ## What it does
6
6
 
@@ -8,10 +8,9 @@ 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`.
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
- - **CLI** — `hazo-env current | doctor | snapshot | migrate | verify | restore | mask`.
11
+ - **`envsync`**local Postgres/files sync engine (`hazo-env sync` / `hazo-env serve`). Dumps/restores a database via `pg_dump`/`pg_restore` and archives/restores a files root via `tar`, entirely on one host no SSH, no PostgREST row-copy. See "envsync" below.
12
+ - **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.
13
+ - **CLI** — `hazo-env current | doctor | sync | serve`.
15
14
 
16
15
  ## Installation
17
16
 
@@ -20,7 +19,7 @@ npm install hazo_env
20
19
  ```
21
20
 
22
21
  Peer deps (required): `hazo_core`, `hazo_config`.
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
+ Peer deps (optional): `hazo_connect` (for `resolveConnectConfig`), `hazo_files` (for `resolveFilesConfig`).
24
23
 
25
24
  ## Quick start
26
25
 
@@ -154,73 +153,33 @@ const report = await doctor({ probe: true, all: true });
154
153
  // masking ruleset column validation against live schema.
155
154
  ```
156
155
 
157
- ### Migration engine (server-only)
156
+ ### envsync local Postgres/files sync (server/ops-only)
158
157
 
159
- ```ts
160
- import { runMigration, verifyFiles, takeSnapshot, restoreSnapshot,
161
- writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from 'hazo_env';
162
-
163
- // Copy prod → staging (with PII masking applied automatically)
164
- const result = await runMigration({
165
- from: 'prod',
166
- to: 'staging',
167
- dryRun: false, // set true for a plan with counts, no writes
168
- // allowProdTarget: true, // required when `to` is 'prod'
169
- // confirmToken: '<token>', // required together with allowProdTarget
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
173
- });
174
- // result.ok, result.db, result.files, result.snapshotId, result.warnings, result.durationMs
175
-
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?
180
-
181
- // Manual snapshot / restore
182
- const snap = takeSnapshot(connectConfig); // returns { snapshotId }
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);
189
- ```
190
-
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)`.
158
+ `envsync` copies a database or files root between environments that live on the same host (or where you're willing to move a dump/archive by hand) using plain `pg_dump`/`pg_restore`/`tar` — no SSH, no PostgREST row-copy loop, no masking pass. It's driven via the CLI or an optional local HTTP service; the engine functions (`downloadDb`, `uploadDb`, `downloadFiles`, `uploadFiles`) live at `hazo_env/dist/envsync/engine.js` and are intentionally **not** re-exported from the package's main `index.ts` — reach them through that subpath or through the CLI.
192
159
 
193
- **Masking ruleset API** — load, parse, and sync rulesets programmatically:
160
+ ```bash
161
+ # Config: [envsync] section in hazo_env_config.ini — source_db, target_db, files_root, work_dir, keep
194
162
 
195
- ```ts
196
- import { loadRuleset, syncRulesetFromIni, parseIniRules } from 'hazo_env';
197
- import type { MaskRule } from 'hazo_env';
163
+ hazo-env sync download-db # pg_dump source_db → work_dir
164
+ hazo-env sync upload-db --dump <path> --confirm # pg_restore into target_db (--allow-prod-target for a prod target)
165
+ hazo-env sync download-files # tar files_root (+ current .env.local) → work_dir
166
+ hazo-env sync upload-files --archive <path> # dry-run: prints the .env.local diff it would apply
167
+ hazo-env sync upload-files --archive <path> --confirm # actually restores files + merges env overrides
198
168
 
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
169
+ hazo-env serve [--port <n>] [--bind <host>] # HTTP service over the same engine — requires HAZO_ENVSYNC_TOKEN
202
170
  ```
203
171
 
172
+ Every destructive op runs under a single-writer lock (`withLock`) and refuses to touch anything that looks like a production target without `--allow-prod-target` (`allowProd: true` programmatically). See `CHANGE_LOG.md` (0.7.0 entry) for the full engine/service design notes.
173
+
204
174
  ## CLI
205
175
 
206
176
  ```
207
177
  hazo-env current # prints env, role, pattern, app, data_root
208
178
  hazo-env doctor [--env <e>] [--all] # red/green validation table
209
- hazo-env snapshot <env> # snapshot the target env DB
210
179
 
211
- hazo-env migrate --from <env> --to <env> [options]
212
- --transport auto|local|ssh|api # default auto
213
- --no-db | --no-files # skip DB or file copy
214
- --scrub auto|none # default auto (mask when target is test/staging)
215
- --tables a,b,c # restrict to specific tables
216
- --dry-run # plan + counts, no writes
217
- --allow-prod-target --confirm <tok> # required when writing prod
218
-
219
- hazo-env verify <env> [--hash none|sample|full] [--no-orphans]
220
- hazo-env restore <env> --snapshot <snapshot-id>
221
-
222
- hazo-env mask sync # sync hazo_env_masking.ini → hazo_app_config
223
- hazo-env mask list # print active masking rules
180
+ hazo-env sync download-db | upload-db --dump <p> [--confirm] [--allow-prod-target]
181
+ hazo-env sync download-files | upload-files --archive <p> [--confirm] [--allow-prod-target]
182
+ hazo-env serve [--port <n>] [--bind <host>] # requires HAZO_ENVSYNC_TOKEN
224
183
  ```
225
184
 
226
185
  ## Config reference (`hazo_env_config.ini`)
@@ -235,46 +194,7 @@ Key sections:
235
194
 
236
195
  Secret placeholders use `${ENV_VAR_NAME}` syntax — hazo_env substitutes them from `.env.local` at runtime.
237
196
 
238
- ## dump-restore transport
239
-
240
- The `dump-restore` transport copies a database via SSH `pg_dump`/`pg_restore` (binary format). Use it when the source and target share the same Postgres server (e.g. prod→dev on the same VPS) and you want a fast binary copy without going through the PostgREST row-copy loop.
241
-
242
- ### Config: `[backup.<env>]`
243
-
244
- Add a section to `config/hazo_env_config.ini`:
245
-
246
- ```ini
247
- [backup.dev]
248
- ssh_env = prod ; reuse [transport.ssh.prod] SSH connection
249
- target_db = myapp_dev ; DB to drop/recreate
250
- owner = adminuser ; createdb -O <owner>
251
- dump_source = /var/backups/myapp-*.pgdump ; glob (newest used) or "fresh"
252
- fresh_dump_db = myapp ; source DB for pg_dump when dump_source=fresh
253
- pre_restore_cmd = sudo systemctl stop myapp-postgrest-dev
254
- post_restore_cmd= sudo systemctl start myapp-postgrest-dev
255
- ```
256
-
257
- > **No PII scrubbing on this path.** The binary `pg_restore` copies raw rows; hazo_env's `scrubHook` only runs inside the PostgREST row-copy loop. If you need scrubbed data in dev, use the default `api` transport instead.
258
-
259
- ### CLI
260
-
261
- ```bash
262
- npx hazo-env migrate --from prod --to dev --transport dump-restore
263
- ```
264
-
265
- ### Programmatic API
266
-
267
- ```ts
268
- import { resolveBackupConfig, restoreDbViaDump } from 'hazo_env';
269
-
270
- const backup = resolveBackupConfig('dev'); // reads [backup.dev] from INI
271
- if (backup) {
272
- const result = await restoreDbViaDump(backup, {
273
- onProgress: (msg) => console.log(msg),
274
- });
275
- // result.mode === 'dump-restore'
276
- }
277
- ```
197
+ For `envsync`'s own `[envsync]` / `[migrate.env_overrides]` config sections, see the `envsync` section above and `CHANGE_LOG.md` (0.7.0 entry).
278
198
 
279
199
  ## License
280
200
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // hazo_env/src/cli.ts — CLI entry: hazo-env current | doctor | snapshot | migrate | verify | restore
2
+ // hazo_env/src/cli.ts — CLI entry: hazo-env current | doctor | sync | serve
3
3
  import pc from 'picocolors';
4
4
  import { getEnv } from './index.client.js';
5
5
  import { describeEnv, getEnvRole, getPattern } from './env.server.js';
@@ -58,201 +58,120 @@ async function runDoctor() {
58
58
  process.exit(1);
59
59
  }
60
60
  }
61
- async function runSnapshot() {
62
- const targetEnv = args[0] ?? getEnv();
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`);
61
+ function printEnvOverrideDiff(diff) {
62
+ if (!diff.length) {
63
+ console.log(` ${pc.dim('(no env vars to apply)')}`);
64
+ return;
65
+ }
66
+ console.log(` ${pc.bold('.env.local changes:')}`);
67
+ const keyWidth = Math.max(...diff.map((d) => d.key.length)) + 2;
68
+ for (const d of diff) {
69
+ const marker = d.before === d.after ? pc.dim('=') : pc.yellow('~');
70
+ const before = d.before ?? pc.dim('(unset)');
71
+ console.log(` ${marker} ${d.key.padEnd(keyWidth)} ${before} -> ${d.after}`);
72
+ }
69
73
  }
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
- const transportIdx = args.findIndex((a) => a === '--transport');
85
- const transport = transportIdx >= 0 ? args[transportIdx + 1] : undefined;
86
- if (!from || !to) {
87
- console.error(pc.red('Error: migrate requires --from <env> and --to <env>'));
74
+ async function runSync() {
75
+ const subCmd = args[0];
76
+ const subArgs = args.slice(1);
77
+ const { resolveEnvsyncConfig } = await import('./resolve/envsync.js');
78
+ const { withLock, EnvsyncLockError } = await import('./envsync/lock.js');
79
+ const { pruneWorkDir } = await import('./envsync/retention.js');
80
+ const { downloadDb, uploadDb, downloadFiles, uploadFiles } = await import('./envsync/engine.js');
81
+ if (!subCmd || !['download-db', 'upload-db', 'download-files', 'upload-files'].includes(subCmd)) {
82
+ console.log(`
83
+ ${pc.bold('hazo-env sync')} local Postgres/files sync (pg_dump/pg_restore/tar)
84
+
85
+ hazo-env sync download-db Dump [envsync] source_db to work_dir
86
+ hazo-env sync upload-db --dump <path> Restore a dump into target_db
87
+ --confirm --allow-prod-target Required to actually apply
88
+ hazo-env sync download-files Archive files_root (+ .env.local) to work_dir
89
+ hazo-env sync upload-files --archive <path> Restore an archive into files_root + .env.local
90
+ --confirm --allow-prod-target Required to apply (else prints a diff preview)
91
+ `);
92
+ return;
93
+ }
94
+ const cfg = resolveEnvsyncConfig();
95
+ if (!cfg) {
96
+ console.error(pc.red('Error: envsync not configured — missing [envsync] section in hazo_env_config.ini'));
88
97
  process.exit(1);
89
98
  }
90
- console.log(`\n${pc.bold('hazo-env migrate')} ${pc.dim(`${from} ${to}${dryRun ? ' (dry-run)' : ''}`)}\n`);
91
- const { runMigration } = await import('./migrate/run.js');
99
+ const onProgress = (msg) => console.log(` ${pc.dim('·')} ${msg}`);
92
100
  try {
93
- const result = await runMigration({
94
- from,
95
- to,
96
- include: { db: !noDb, files: !noFiles },
97
- tables: tables ?? '*',
98
- dryRun,
99
- allowProdTarget,
100
- confirmToken,
101
- transport: transport,
102
- onProgress: (p) => {
103
- const pct = p.percent != null ? `${p.percent}%`.padStart(4) + ' ' : ' ';
104
- console.log(` ${pct}${pc.dim(p.phase.padEnd(12))} ${p.message}`);
105
- },
106
- });
107
- if (result.ok) {
108
- console.log(`\n ${pc.green('')} Migration ${dryRun ? 'plan built (dry-run)' : 'complete'}`);
109
- if (result.db)
110
- console.log(` ${pc.dim('DB:')} ${result.db.tables} tables, ${result.db.rows} rows${result.db.scrubbed ? `, ${result.db.scrubbed} scrubbed` : ''}`);
111
- if (result.files)
112
- console.log(` ${pc.dim('Files:')} ${result.files.copied} copied`);
113
- if (result.snapshotId && !result.snapshotId.startsWith('no-db:'))
114
- console.log(` ${pc.dim('Snapshot:')} ${result.snapshotId}`);
115
- if (result.warnings.length)
116
- result.warnings.forEach((w) => console.log(` ${pc.yellow('')} ${w}`));
101
+ if (subCmd === 'download-db') {
102
+ console.log(`\n${pc.bold('hazo-env sync download-db')} ${pc.dim(cfg.source_db)}\n`);
103
+ const result = await withLock(cfg.work_dir, 'download_db', () => downloadDb(cfg, { onProgress }));
104
+ pruneWorkDir(cfg.work_dir, { keep: cfg.keep });
105
+ console.log(`\n ${pc.green('✓')} Dumped -> ${result.path} (${result.bytes} bytes)\n`);
106
+ }
107
+ else if (subCmd === 'upload-db') {
108
+ const dumpIdx = subArgs.findIndex((a) => a === '--dump');
109
+ const dumpPath = dumpIdx >= 0 ? subArgs[dumpIdx + 1] : undefined;
110
+ const confirm = subArgs.includes('--confirm');
111
+ const allowProd = subArgs.includes('--allow-prod-target');
112
+ if (!dumpPath) {
113
+ console.error(pc.red('Error: sync upload-db requires --dump <path>'));
114
+ process.exit(1);
115
+ }
116
+ console.log(`\n${pc.bold('hazo-env sync upload-db')} ${pc.dim(`${dumpPath} -> ${cfg.target_db}`)}\n`);
117
+ await withLock(cfg.work_dir, 'upload_db', () => uploadDb(cfg, dumpPath, { confirm, allowProd, onProgress }));
118
+ console.log(`\n ${pc.green('')} Restored -> ${cfg.target_db}\n`);
119
+ }
120
+ else if (subCmd === 'download-files') {
121
+ console.log(`\n${pc.bold('hazo-env sync download-files')} ${pc.dim(cfg.files_root)}\n`);
122
+ const result = await withLock(cfg.work_dir, 'download_files', () => downloadFiles(cfg, { onProgress }));
123
+ pruneWorkDir(cfg.work_dir, { keep: cfg.keep });
124
+ console.log(`\n ${pc.green('')} Archived -> ${result.path} (${result.bytes} bytes)\n`);
125
+ }
126
+ else if (subCmd === 'upload-files') {
127
+ const archiveIdx = subArgs.findIndex((a) => a === '--archive');
128
+ const archivePath = archiveIdx >= 0 ? subArgs[archiveIdx + 1] : undefined;
129
+ const confirm = subArgs.includes('--confirm');
130
+ const allowProd = subArgs.includes('--allow-prod-target');
131
+ if (!archivePath) {
132
+ console.error(pc.red('Error: sync upload-files requires --archive <path>'));
133
+ process.exit(1);
134
+ }
135
+ console.log(`\n${pc.bold('hazo-env sync upload-files')} ${pc.dim(archivePath)}${confirm ? '' : pc.dim(' (preview — pass --confirm to apply)')}\n`);
136
+ const result = await withLock(cfg.work_dir, 'upload_files', () => uploadFiles(cfg, archivePath, { confirm, allowProd, onProgress }));
137
+ console.log('');
138
+ printEnvOverrideDiff(result.diff);
139
+ if (result.ok) {
140
+ console.log(`\n ${pc.green('✓')} Restored -> ${cfg.files_root}\n`);
141
+ }
142
+ else {
143
+ console.log(`\n ${pc.yellow('⚠')} Dry-run only — re-run with --confirm to apply\n`);
144
+ }
117
145
  }
118
146
  }
119
147
  catch (e) {
148
+ if (e instanceof EnvsyncLockError) {
149
+ console.error(`\n ${pc.red('✗')} ${e.message}\n`);
150
+ process.exit(1);
151
+ }
120
152
  const msg = e instanceof Error ? e.message : String(e);
121
153
  console.error(`\n ${pc.red('✗')} ${msg}\n`);
122
154
  process.exit(1);
123
155
  }
124
- console.log('');
125
- }
126
- async function runVerify() {
127
- const targetEnv = args[0] ?? getEnv();
128
- // Parse --hash full|sample|none (default: sample) and --no-orphans
129
- const hashIdx = args.indexOf('--hash');
130
- const hashArg = hashIdx >= 0 ? args[hashIdx + 1] : 'sample';
131
- const hash = (hashArg === 'full' || hashArg === 'none' || hashArg === 'sample') ? hashArg : 'sample';
132
- const checkOrphans = !args.includes('--no-orphans');
133
- console.log(`\n${pc.bold('hazo-env verify')} ${pc.dim(targetEnv)} ${pc.dim(`--hash ${hash}`)}\n`);
134
- const { verifyFiles } = await import('./migrate/verify.js');
135
- const { resolveFilesConfig } = await import('./resolve/files.js');
136
- const dataRoot = resolveFilesConfig().local.basePath;
137
- const report = await verifyFiles(targetEnv, dataRoot, { hash, checkOrphans });
138
- if (report.skippedReason) {
139
- console.log(` ${pc.yellow('⚠')} ${report.skippedReason}`);
140
- }
141
- console.log(` Checked: ${report.checked} files${report.hashed != null ? `, hashed: ${report.hashed}` : ''}`);
142
- if (report.orphans.length)
143
- console.log(` ${pc.dim('ℹ')} Orphans (no DB row): ${report.orphans.join(', ')}`);
144
- if (report.ok) {
145
- console.log(` ${pc.green('✓')} All files verified\n`);
146
- }
147
- else {
148
- if (report.missing.length)
149
- console.log(` ${pc.red('✗')} Missing: ${report.missing.join(', ')}`);
150
- if (report.sizeMismatch.length)
151
- console.log(` ${pc.yellow('⚠')} Size mismatch: ${report.sizeMismatch.join(', ')}`);
152
- if (report.hashMismatch.length)
153
- console.log(` ${pc.yellow('⚠')} Hash mismatch: ${report.hashMismatch.join(', ')}`);
154
- console.log('');
155
- process.exit(1); // S4: standalone verify exits 1 on failure (CI-friendly)
156
- }
157
- }
158
- async function runRestore() {
159
- const targetEnv = args[0];
160
- const snapshotIdx = args.findIndex((a) => a === '--snapshot');
161
- const snapshotId = snapshotIdx >= 0 ? args[snapshotIdx + 1] : undefined;
162
- if (!targetEnv || !snapshotId) {
163
- console.error(pc.red('Error: restore requires <env> --snapshot <snapshot-id>'));
164
- process.exit(1);
165
- }
166
- console.log(`\n${pc.bold('hazo-env restore')} ${pc.dim(`${targetEnv} ← ${snapshotId}`)}\n`);
167
- const { restoreSnapshot } = await import('./migrate/snapshot.js');
168
- const { resolveConnectConfig } = await import('./resolve/connect.js');
169
- const toConfig = resolveConnectConfig({ env: targetEnv, allowOtherEnv: true });
170
- restoreSnapshot(toConfig, snapshotId);
171
- console.log(` ${pc.green('✓')} Restored\n`);
172
156
  }
173
- async function runClear() {
174
- const targetEnv = args[0] ?? getEnv();
175
- const allowProd = args.includes('--allow-prod-target');
176
- const confirm = args.find((a) => a.startsWith('--confirm='))?.split('=')[1];
177
- console.log(`\n${pc.bold('hazo-env clear')} ${pc.dim(targetEnv)}\n`);
178
- const { clearEnv } = await import('./migrate/clear.js');
157
+ async function runServe() {
158
+ const portIdx = args.findIndex((a) => a === '--port');
159
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : undefined;
160
+ const bindIdx = args.findIndex((a) => a === '--bind');
161
+ const bind = bindIdx >= 0 ? args[bindIdx + 1] : undefined;
162
+ const { startEnvsyncService } = await import('./envsync/service.js');
179
163
  try {
180
- let lastMsg = '';
181
- const result = await clearEnv({
182
- env: targetEnv,
183
- allowProdTarget: allowProd,
184
- confirmToken: confirm,
185
- onProgress: (p) => {
186
- if (p.message !== lastMsg) {
187
- const pctStr = p.percent != null ? ` [${p.percent}%]` : '';
188
- console.log(` ${pc.dim('·')} ${p.message}${pctStr}`);
189
- lastMsg = p.message;
190
- }
191
- },
192
- });
193
- if (result.ok) {
194
- console.log(` ${pc.green('✓')} Cleared ${result.tablesCleared} tables${result.filesCleared ? ' + files' : ''} in ${result.durationMs}ms\n`);
195
- if (result.warnings.length)
196
- result.warnings.forEach((w) => console.log(` ${pc.yellow('⚠')} ${w}`));
197
- }
164
+ const { server } = startEnvsyncService({ port, bind });
165
+ const address = server.address();
166
+ const actualPort = address && typeof address === 'object' ? address.port : port;
167
+ const actualBind = bind ?? process.env['HAZO_ENVSYNC_BIND'] ?? '127.0.0.1';
168
+ console.log(`\n${pc.bold('hazo-env serve')} ${pc.dim(`envsync service listening on ${actualBind}:${actualPort}`)}\n`);
198
169
  }
199
170
  catch (e) {
200
171
  const msg = e instanceof Error ? e.message : String(e);
201
- console.error(`\n ${pc.red('')} ${msg}\n`);
172
+ console.error(`\n${pc.red('Error:')} ${msg}\n`);
202
173
  process.exit(1);
203
174
  }
204
- console.log('');
205
- }
206
- async function runMask() {
207
- const subCmd = args[0];
208
- if (subCmd === 'sync') {
209
- console.log(`\n${pc.bold('hazo-env mask sync')}\n`);
210
- try {
211
- const connectMod = await import('hazo_connect/server').catch(() => null);
212
- if (!connectMod) {
213
- console.error(pc.red('Error: hazo_connect is required for mask sync.'));
214
- process.exit(1);
215
- }
216
- const { resolveConnectConfig } = await import('./resolve/connect.js');
217
- const { syncRulesetFromIni } = await import('./mask/ruleset.js');
218
- const dbConfig = resolveConnectConfig({ allowOtherEnv: false });
219
- if (dbConfig.type !== 'sqlite' || !dbConfig.sqlite) {
220
- console.error(pc.red('Error: mask sync only supports SQLite databases.'));
221
- process.exit(1);
222
- }
223
- const adapter = connectMod.createHazoConnect({
224
- type: 'sqlite',
225
- sqlite: { database_path: dbConfig.sqlite.database_path },
226
- });
227
- const count = await syncRulesetFromIni(adapter);
228
- console.log(` ${pc.green('✓')} Synced ${count} rules into hazo_app_config\n`);
229
- }
230
- catch (e) {
231
- console.error(`\n ${pc.red('✗')} ${e instanceof Error ? e.message : String(e)}\n`);
232
- process.exit(1);
233
- }
234
- return;
235
- }
236
- if (subCmd === 'list') {
237
- console.log(`\n${pc.bold('hazo-env mask list')}\n`);
238
- const { listTransforms } = await import('./mask/registry.js');
239
- const transforms = await listTransforms();
240
- if (transforms.length === 0) {
241
- console.log(' (no transforms registered)\n');
242
- }
243
- else {
244
- for (const name of transforms)
245
- console.log(` ${pc.dim('•')} ${name}`);
246
- console.log('');
247
- }
248
- return;
249
- }
250
- console.log(`
251
- ${pc.bold('hazo-env mask')} — masking commands
252
-
253
- hazo-env mask sync Load hazo_env_masking.ini into hazo_app_config
254
- hazo-env mask list List registered transform functions
255
- `);
256
175
  }
257
176
  function printHelp() {
258
177
  console.log(`
@@ -264,19 +183,14 @@ Usage:
264
183
  hazo-env doctor --all Run health checks for all declared environments
265
184
  hazo-env doctor --env <env> Run health checks for a specific environment
266
185
  hazo-env doctor --probe Also probe live DB reachability
267
- hazo-env migrate --from <env> --to <env> Migrate DB + files between environments
268
- --dry-run Show plan without executing
269
- --no-db / --no-files Skip DB or file copy
270
- --allow-prod-target --confirm <token> Override prod-target safety
271
- hazo-env verify <env> Verify files for an environment
272
- --hash full|sample|none Hash-check all / sampled / none (default: sample)
273
- --no-orphans Skip orphan detection
274
- hazo-env restore <env> --snapshot <id> Restore a snapshot
275
- hazo-env snapshot <env> Take a snapshot of an environment
276
- hazo-env mask sync Load masking ruleset from INI into DB
277
- hazo-env mask list List registered mask transforms
278
- hazo-env clear <env> Wipe DB tables + files for an environment
279
- --allow-prod-target --confirm=<token> Override prod-target safety
186
+ hazo-env sync download-db Dump [envsync] source_db to work_dir
187
+ hazo-env sync upload-db --dump <path> Restore a dump into target_db
188
+ --confirm --allow-prod-target Required to actually apply
189
+ hazo-env sync download-files Archive files_root (+ .env.local) to work_dir
190
+ hazo-env sync upload-files --archive <p> Restore an archive into files_root + .env.local
191
+ --confirm --allow-prod-target Required to apply (else prints a diff preview)
192
+ hazo-env serve Start the envsync HTTP service (HAZO_ENVSYNC_TOKEN required)
193
+ --port <n> --bind <host> Override HAZO_ENVSYNC_PORT / HAZO_ENVSYNC_BIND
280
194
  `);
281
195
  }
282
196
  (async () => {
@@ -287,23 +201,11 @@ Usage:
287
201
  else if (command === 'doctor') {
288
202
  await runDoctor();
289
203
  }
290
- else if (command === 'snapshot') {
291
- await runSnapshot();
292
- }
293
- else if (command === 'migrate') {
294
- await runMigrate();
295
- }
296
- else if (command === 'verify') {
297
- await runVerify();
298
- }
299
- else if (command === 'restore') {
300
- await runRestore();
301
- }
302
- else if (command === 'mask') {
303
- await runMask();
204
+ else if (command === 'sync') {
205
+ await runSync();
304
206
  }
305
- else if (command === 'clear') {
306
- await runClear();
207
+ else if (command === 'serve') {
208
+ await runServe();
307
209
  }
308
210
  else {
309
211
  printHelp();
@@ -0,0 +1,24 @@
1
+ export interface EnvOverrideDiff {
2
+ key: string;
3
+ before: string | undefined;
4
+ after: string;
5
+ }
6
+ /** Parse dotenv-style KEY=VALUE text into a plain record. */
7
+ export declare function parseDotenvText(text: string): Record<string, string>;
8
+ /**
9
+ * Merge an archive's .env.local contents with operator-configured overrides.
10
+ * Overrides always win.
11
+ */
12
+ export declare function mergeEnvOverrides(archiveEnv: Record<string, string>, overrides: Record<string, string>): Record<string, string>;
13
+ /**
14
+ * Diff the merged (about-to-be-written) env against the CURRENT target
15
+ * .env.local content — this is "what's about to change" from the
16
+ * operator's point of view, since currentEnv is what uploadFiles is about
17
+ * to overwrite. (Diffing against the archive's original values instead
18
+ * would show "what changed since the download", which is less useful when
19
+ * deciding whether to confirm an upload.)
20
+ */
21
+ export declare function diffEnvAgainstCurrent(merged: Record<string, string>, currentEnv: Record<string, string>): EnvOverrideDiff[];
22
+ /** Serialize a record back into KEY=VALUE dotenv text. */
23
+ export declare function serializeDotenv(env: Record<string, string>): string;
24
+ //# sourceMappingURL=dotenv-merge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dotenv-merge.d.ts","sourceRoot":"","sources":["../../src/envsync/dotenv-merge.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;CACf;AAED,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEpE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAExB;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,eAAe,EAAE,CAInB;AAED,0DAA0D;AAC1D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAInE"}