hazo_env 0.8.0 → 0.9.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,14 @@
1
1
  # hazo_env — Change Log
2
2
 
3
+ ## 0.9.0 — 2026-07-24
4
+
5
+ ### Added
6
+ - **`upload-files --skip-env`** (`uploadFiles({ skipEnv: true })`) — restore `files_root` from an archive while leaving the target `.env.local` **completely untouched**: no read, no merge, no backup, no write. For destinations whose env is hand-maintained and must survive a files refresh (e.g. a laptop dev site whose local URLs/secrets differ from the prod archive's — the common case where the default env-merge diff is all `~` clobbers). When set, the returned `diff` is empty (no env comparison is performed) and the CLI prints `.env.local skipped (--skip-env)` in place of the diff.
7
+ - `src/envsync/engine.ts`: `uploadFiles` gains an optional `skipEnv` flag. Files are still wiped-and-repopulated and the stray `__env.local` archive member is still stripped from `files_root`; only the env half is opted out. Orthogonal to `confirm`/`allowProd` — a dry-run with `skipEnv` returns `{ ok: false, diff: [] }` and writes nothing.
8
+ - `src/cli.ts`: `hazo-env sync upload-files --skip-env`.
9
+ - `src/envsync/service.ts`: `POST /files/restore` accepts `{ "skipEnv": true }`; the `serve` control page gains a "skip .env.local" checkbox.
10
+ - **Tests:** `__tests__/envsync-engine-files.test.ts` adds confirmed-restore-with-skipEnv (files replaced, `.env.local` byte-identical, no `.bak-` written) and dry-run-with-skipEnv (empty diff, zero writes) cases.
11
+
3
12
  ## 0.8.0 — 2026-07-23
4
13
 
5
14
  ### Removed / Breaking
package/README.md CHANGED
@@ -165,6 +165,7 @@ hazo-env sync upload-db --dump <path> --confirm # pg_restore into target_db
165
165
  hazo-env sync download-files # tar files_root (+ current .env.local) → work_dir
166
166
  hazo-env sync upload-files --archive <path> # dry-run: prints the .env.local diff it would apply
167
167
  hazo-env sync upload-files --archive <path> --confirm # actually restores files + merges env overrides
168
+ hazo-env sync upload-files --archive <path> --confirm --skip-env # restore files only; leave .env.local untouched
168
169
 
169
170
  hazo-env serve [--port <n>] [--bind <host>] # HTTP service over the same engine — requires HAZO_ENVSYNC_TOKEN
170
171
  ```
@@ -178,7 +179,7 @@ hazo-env current # prints env, role, pattern, app, data_ro
178
179
  hazo-env doctor [--env <e>] [--all] # red/green validation table
179
180
 
180
181
  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 sync download-files | upload-files --archive <p> [--confirm] [--allow-prod-target] [--skip-env]
182
183
  hazo-env serve [--port <n>] [--bind <host>] # requires HAZO_ENVSYNC_TOKEN
183
184
  ```
184
185
 
package/dist/cli.js CHANGED
@@ -88,6 +88,7 @@ ${pc.bold('hazo-env sync')} — local Postgres/files sync (pg_dump/pg_restore/ta
88
88
  hazo-env sync download-files Archive files_root (+ .env.local) to work_dir
89
89
  hazo-env sync upload-files --archive <path> Restore an archive into files_root + .env.local
90
90
  --confirm --allow-prod-target Required to apply (else prints a diff preview)
91
+ --skip-env Restore files only; leave .env.local untouched
91
92
  `);
92
93
  return;
93
94
  }
@@ -128,14 +129,20 @@ ${pc.bold('hazo-env sync')} — local Postgres/files sync (pg_dump/pg_restore/ta
128
129
  const archivePath = archiveIdx >= 0 ? subArgs[archiveIdx + 1] : undefined;
129
130
  const confirm = subArgs.includes('--confirm');
130
131
  const allowProd = subArgs.includes('--allow-prod-target');
132
+ const skipEnv = subArgs.includes('--skip-env');
131
133
  if (!archivePath) {
132
134
  console.error(pc.red('Error: sync upload-files requires --archive <path>'));
133
135
  process.exit(1);
134
136
  }
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(`\n${pc.bold('hazo-env sync upload-files')} ${pc.dim(archivePath)}${skipEnv ? pc.dim(' (--skip-env: .env.local left untouched)') : ''}${confirm ? '' : pc.dim(' (preview — pass --confirm to apply)')}\n`);
138
+ const result = await withLock(cfg.work_dir, 'upload_files', () => uploadFiles(cfg, archivePath, { confirm, allowProd, skipEnv, onProgress }));
137
139
  console.log('');
138
- printEnvOverrideDiff(result.diff);
140
+ if (skipEnv) {
141
+ console.log(` ${pc.dim('· .env.local skipped (--skip-env) — no env diff computed, target left as-is')}`);
142
+ }
143
+ else {
144
+ printEnvOverrideDiff(result.diff);
145
+ }
139
146
  if (result.ok) {
140
147
  console.log(`\n ${pc.green('✓')} Restored -> ${cfg.files_root}\n`);
141
148
  }
@@ -189,6 +196,7 @@ Usage:
189
196
  hazo-env sync download-files Archive files_root (+ .env.local) to work_dir
190
197
  hazo-env sync upload-files --archive <p> Restore an archive into files_root + .env.local
191
198
  --confirm --allow-prod-target Required to apply (else prints a diff preview)
199
+ --skip-env Restore files only; leave .env.local untouched
192
200
  hazo-env serve Start the envsync HTTP service (HAZO_ENVSYNC_TOKEN required)
193
201
  --port <n> --bind <host> Override HAZO_ENVSYNC_PORT / HAZO_ENVSYNC_BIND
194
202
  `);
@@ -66,6 +66,14 @@ export declare function downloadFiles(cfg: EnvsyncConfig, opts?: {
66
66
  export declare function uploadFiles(cfg: EnvsyncConfig, archivePath: string, opts: {
67
67
  confirm?: boolean;
68
68
  allowProd?: boolean;
69
+ /**
70
+ * Restore files_root but leave the target `.env.local` completely
71
+ * untouched — no backup, no merge, no write. Use when the destination
72
+ * env is hand-maintained (e.g. a laptop dev site whose local URLs/secrets
73
+ * must survive a prod files refresh). When set, the returned `diff` is
74
+ * empty because no env comparison is performed.
75
+ */
76
+ skipEnv?: boolean;
69
77
  onProgress?: (msg: string) => void;
70
78
  }): Promise<{
71
79
  ok: true;
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/envsync/engine.ts"],"names":[],"mappings":"AAaA,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAaD;;GAEG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,aAAa,EAClB,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7E,OAAO,CAAC,iBAAiB,CAAC,CAY5B;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,aAAa,EAClB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACnF,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,CAwBvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,aAAa,EAClB,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7E,OAAO,CAAC,iBAAiB,CAAC,CA0B5B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,aAAa,EAClB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACnF,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,eAAe,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,eAAe,EAAE,CAAA;CAAE,CAAC,CAqDzF"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/envsync/engine.ts"],"names":[],"mappings":"AAaA,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAaD;;GAEG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,aAAa,EAClB,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7E,OAAO,CAAC,iBAAiB,CAAC,CAY5B;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,aAAa,EAClB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACnF,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,CAwBvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,aAAa,EAClB,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7E,OAAO,CAAC,iBAAiB,CAAC,CA0B5B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,aAAa,EAClB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE;IACJ,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC,GACA,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,eAAe,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,eAAe,EAAE,CAAA;CAAE,CAAC,CAgEzF"}
@@ -109,33 +109,40 @@ export async function downloadFiles(cfg, opts) {
109
109
  * the archive, and writes the merged .env.local.
110
110
  */
111
111
  export async function uploadFiles(cfg, archivePath, opts) {
112
- const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hazo_envsync_extract_'));
113
- let archiveEnvText = '';
114
- try {
115
- await runCmd('tar', ['-xzf', archivePath, '-C', extractDir, ENV_LOCAL_MEMBER], {
116
- onProgress: opts.onProgress,
117
- });
118
- const extractedEnvLocal = path.join(extractDir, ENV_LOCAL_MEMBER);
119
- if (fs.existsSync(extractedEnvLocal)) {
120
- archiveEnvText = fs.readFileSync(extractedEnvLocal, 'utf8');
112
+ const targetEnvLocalPath = path.join(process.cwd(), '.env.local');
113
+ // When skipEnv is set we never read/merge/diff the archive's env member —
114
+ // the env half of upload-files is opted out of entirely, so the diff is
115
+ // empty and no .env.local is ever backed up or written below.
116
+ let diff = [];
117
+ let merged = {};
118
+ if (!opts.skipEnv) {
119
+ const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hazo_envsync_extract_'));
120
+ let archiveEnvText = '';
121
+ try {
122
+ await runCmd('tar', ['-xzf', archivePath, '-C', extractDir, ENV_LOCAL_MEMBER], {
123
+ onProgress: opts.onProgress,
124
+ });
125
+ const extractedEnvLocal = path.join(extractDir, ENV_LOCAL_MEMBER);
126
+ if (fs.existsSync(extractedEnvLocal)) {
127
+ archiveEnvText = fs.readFileSync(extractedEnvLocal, 'utf8');
128
+ }
121
129
  }
130
+ finally {
131
+ fs.rmSync(extractDir, { recursive: true, force: true });
132
+ }
133
+ const archiveEnv = parseDotenvText(archiveEnvText);
134
+ merged = mergeEnvOverrides(archiveEnv, cfg.envOverrides);
135
+ const currentEnvText = fs.existsSync(targetEnvLocalPath)
136
+ ? fs.readFileSync(targetEnvLocalPath, 'utf8')
137
+ : '';
138
+ const currentEnv = parseDotenvText(currentEnvText);
139
+ diff = diffEnvAgainstCurrent(merged, currentEnv);
122
140
  }
123
- finally {
124
- fs.rmSync(extractDir, { recursive: true, force: true });
125
- }
126
- const archiveEnv = parseDotenvText(archiveEnvText);
127
- const merged = mergeEnvOverrides(archiveEnv, cfg.envOverrides);
128
- const targetEnvLocalPath = path.join(process.cwd(), '.env.local');
129
- const currentEnvText = fs.existsSync(targetEnvLocalPath)
130
- ? fs.readFileSync(targetEnvLocalPath, 'utf8')
131
- : '';
132
- const currentEnv = parseDotenvText(currentEnvText);
133
- const diff = diffEnvAgainstCurrent(merged, currentEnv);
134
141
  if (!opts.confirm) {
135
142
  return { ok: false, diff };
136
143
  }
137
144
  assertNotProd(cfg.target_db, cfg.prodDbNames, opts.allowProd);
138
- if (fs.existsSync(targetEnvLocalPath)) {
145
+ if (!opts.skipEnv && fs.existsSync(targetEnvLocalPath)) {
139
146
  const ts = Date.now();
140
147
  fs.copyFileSync(targetEnvLocalPath, `${targetEnvLocalPath}.bak-${ts}`);
141
148
  }
@@ -146,10 +153,13 @@ export async function uploadFiles(cfg, archivePath, opts) {
146
153
  await runCmd('tar', ['-xzf', archivePath, '-C', cfg.files_root], { onProgress: opts.onProgress });
147
154
  // __env.local extracts as a real file inside files_root — it isn't a real
148
155
  // asset, so remove it rather than trying to get tar to exclude it during
149
- // extraction (simplest correct approach per spec).
156
+ // extraction (simplest correct approach per spec). Done regardless of
157
+ // skipEnv: the member is in the archive either way.
150
158
  const extractedEnvMember = path.join(cfg.files_root, ENV_LOCAL_MEMBER);
151
159
  if (fs.existsSync(extractedEnvMember))
152
160
  fs.rmSync(extractedEnvMember, { force: true });
153
- fs.writeFileSync(targetEnvLocalPath, serializeDotenv(merged));
161
+ if (!opts.skipEnv) {
162
+ fs.writeFileSync(targetEnvLocalPath, serializeDotenv(merged));
163
+ }
154
164
  return { ok: true, diff };
155
165
  }
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/envsync/service.ts"],"names":[],"mappings":"AA4BA,OAAO,IAAI,MAAM,WAAW,CAAC;AAgB7B,MAAM,WAAW,0BAA0B;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA4VD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,0BAA+B,GAAG,oBAAoB,CA8B/F"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/envsync/service.ts"],"names":[],"mappings":"AA4BA,OAAO,IAAI,MAAM,WAAW,CAAC;AAgB7B,MAAM,WAAW,0BAA0B;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA+VD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,0BAA+B,GAAG,oBAAoB,CA8B/F"}
@@ -220,6 +220,7 @@ async function handleFilesRestore(req, res) {
220
220
  const result = await withLock(cfg.work_dir, 'upload_files', () => uploadFiles(cfg, archivePath, {
221
221
  confirm: body['confirm'] === true,
222
222
  allowProd: body['allowProd'] === true,
223
+ skipEnv: body['skipEnv'] === true,
223
224
  onProgress: progressLogger('upload_files'),
224
225
  }));
225
226
  sendJson(res, 200, result);
@@ -286,6 +287,7 @@ function controlPageHtml() {
286
287
  <div class="row">
287
288
  <label><input type="checkbox" id="filesConfirm" /> confirm</label>
288
289
  <label><input type="checkbox" id="filesAllowProd" /> allow prod</label>
290
+ <label><input type="checkbox" id="filesSkipEnv" /> skip .env.local</label>
289
291
  <button id="filesRestore" type="button">Restore Files</button>
290
292
  </div>
291
293
  </section>
@@ -335,6 +337,7 @@ document.getElementById('filesRestore').addEventListener('click', function () {
335
337
  archivePath: document.getElementById('archivePath').value,
336
338
  confirm: document.getElementById('filesConfirm').checked,
337
339
  allowProd: document.getElementById('filesAllowProd').checked,
340
+ skipEnv: document.getElementById('filesSkipEnv').checked,
338
341
  });
339
342
  });
340
343
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_env",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Canonical environment resolver — typed env names, per-env DB/file/secret config, doctor and CLI for hazo apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",