hazo_env 0.6.0 → 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 +43 -0
- package/README.md +21 -101
- package/dist/cli.js +109 -207
- package/dist/envsync/dotenv-merge.d.ts +24 -0
- package/dist/envsync/dotenv-merge.d.ts.map +1 -0
- package/dist/envsync/dotenv-merge.js +35 -0
- package/dist/envsync/engine.d.ts +77 -0
- package/dist/envsync/engine.d.ts.map +1 -0
- package/dist/envsync/engine.js +155 -0
- package/dist/envsync/exec.d.ts +21 -0
- package/dist/envsync/exec.d.ts.map +1 -0
- package/dist/envsync/exec.js +62 -0
- package/dist/envsync/guards.d.ts +12 -0
- package/dist/envsync/guards.d.ts.map +1 -0
- package/dist/envsync/guards.js +20 -0
- package/dist/envsync/lock.d.ts +12 -0
- package/dist/envsync/lock.d.ts.map +1 -0
- package/dist/envsync/lock.js +84 -0
- package/dist/envsync/retention.d.ts +17 -0
- package/dist/envsync/retention.d.ts.map +1 -0
- package/dist/envsync/retention.js +52 -0
- package/dist/envsync/service.d.ts +21 -0
- package/dist/envsync/service.d.ts.map +1 -0
- package/dist/envsync/service.js +406 -0
- package/dist/index.d.ts +0 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -13
- package/dist/migrate/run.d.ts +13 -0
- package/dist/migrate/run.d.ts.map +1 -1
- package/dist/migrate/run.js +18 -1
- package/dist/resolve/envsync.d.ts +35 -0
- package/dist/resolve/envsync.d.ts.map +1 -0
- package/dist/resolve/envsync.js +77 -0
- package/dist/types/index.d.ts +0 -80
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -7
- package/config/hazo_env_masking.ini.sample +0 -19
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
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
|
+
|
|
40
|
+
## 0.6.1 — 2026-07-21
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- **PostgREST migration with `tables = "*"` no longer fails.** `runMigration` now resolves a `'*'` (or unset) table request to the configured `[migrate] tables` list via the new `resolveTableList()` helper, instead of passing `'*'` straight to `copyDb`. The PostgREST copy path rejects `'*'` (no FK-order auto-discovery), so any caller that sent `'*'` — including the `hazo-env migrate` CLI default (`tables ?? '*'`) and the hazo_admin Env Migration UI (hardcoded `'*'`) — hit `HAZO_ENV_MISSING_TABLE_LIST` and could never trigger the config-list fallback. An explicit `string[]` request still wins; `'*'` with no config list still passes through (SQL auto-discovers, PostgREST errors as designed).
|
|
44
|
+
- `resolveTableList(reqTables, configTables)` exported from `src/migrate/run.ts` and unit-tested (branch matrix in `__tests__/migration.test.ts`).
|
|
45
|
+
|
|
3
46
|
## 0.5.0 — 2026-06-25
|
|
4
47
|
|
|
5
48
|
### Added
|
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
|
|
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
|
-
-
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
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`)
|
|
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
|
-
###
|
|
156
|
+
### envsync — local Postgres/files sync (server/ops-only)
|
|
158
157
|
|
|
159
|
-
|
|
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
|
-
|
|
160
|
+
```bash
|
|
161
|
+
# Config: [envsync] section in hazo_env_config.ini — source_db, target_db, files_root, work_dir, keep
|
|
194
162
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
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
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
91
|
-
const { runMigration } = await import('./migrate/run.js');
|
|
99
|
+
const onProgress = (msg) => console.log(` ${pc.dim('·')} ${msg}`);
|
|
92
100
|
try {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
console.log(`\n
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
const {
|
|
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
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
|
268
|
-
|
|
269
|
-
--
|
|
270
|
-
|
|
271
|
-
hazo-env
|
|
272
|
-
--
|
|
273
|
-
|
|
274
|
-
|
|
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 === '
|
|
291
|
-
await
|
|
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 === '
|
|
306
|
-
await
|
|
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"}
|