hazo_env 0.6.1 → 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.
@@ -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"}
@@ -0,0 +1,35 @@
1
+ // hazo_env/src/envsync/dotenv-merge.ts — pure env-merge/diff helpers for uploadFiles
2
+ //
3
+ // Split out from engine.ts so the merge/diff/override logic is unit-testable
4
+ // with plain fixture strings, no filesystem or tar involved.
5
+ import dotenv from 'dotenv';
6
+ /** Parse dotenv-style KEY=VALUE text into a plain record. */
7
+ export function parseDotenvText(text) {
8
+ return dotenv.parse(text);
9
+ }
10
+ /**
11
+ * Merge an archive's .env.local contents with operator-configured overrides.
12
+ * Overrides always win.
13
+ */
14
+ export function mergeEnvOverrides(archiveEnv, overrides) {
15
+ return { ...archiveEnv, ...overrides };
16
+ }
17
+ /**
18
+ * Diff the merged (about-to-be-written) env against the CURRENT target
19
+ * .env.local content — this is "what's about to change" from the
20
+ * operator's point of view, since currentEnv is what uploadFiles is about
21
+ * to overwrite. (Diffing against the archive's original values instead
22
+ * would show "what changed since the download", which is less useful when
23
+ * deciding whether to confirm an upload.)
24
+ */
25
+ export function diffEnvAgainstCurrent(merged, currentEnv) {
26
+ return Object.keys(merged)
27
+ .sort()
28
+ .map((key) => ({ key, before: currentEnv[key], after: merged[key] }));
29
+ }
30
+ /** Serialize a record back into KEY=VALUE dotenv text. */
31
+ export function serializeDotenv(env) {
32
+ return Object.entries(env)
33
+ .map(([key, value]) => `${key}=${value}`)
34
+ .join('\n') + (Object.keys(env).length ? '\n' : '');
35
+ }
@@ -0,0 +1,85 @@
1
+ import { type EnvOverrideDiff } from './dotenv-merge.js';
2
+ import type { EnvsyncConfig } from '../resolve/envsync.js';
3
+ export type { EnvOverrideDiff };
4
+ export interface EnvsyncFileResult {
5
+ id: string;
6
+ path: string;
7
+ bytes: number;
8
+ }
9
+ /**
10
+ * pg_dump the configured source_db to <work_dir>/db-<id>.pgdump.
11
+ */
12
+ export declare function downloadDb(cfg: EnvsyncConfig, opts?: {
13
+ onProgress?: (msg: string) => void;
14
+ id?: string;
15
+ now?: () => number;
16
+ }): Promise<EnvsyncFileResult>;
17
+ /**
18
+ * Drop, recreate, and pg_restore the configured target_db from dumpPath.
19
+ * Guarded by assertConfirmed + assertNotProd — never runs unconfirmed or
20
+ * against a target that looks like production.
21
+ */
22
+ export declare function uploadDb(cfg: EnvsyncConfig, dumpPath: string, opts: {
23
+ confirm?: boolean;
24
+ allowProd?: boolean;
25
+ onProgress?: (msg: string) => void;
26
+ }): Promise<{
27
+ ok: true;
28
+ }>;
29
+ /**
30
+ * tar cfg.files_root into <work_dir>/files-<id>.tar.gz, plus the current
31
+ * .env.local (from process.cwd()) added under the fixed member name
32
+ * __env.local.
33
+ *
34
+ * Approach: stage a temp dir containing ONLY __env.local, then run a single
35
+ * `tar -czf out -C files_root . -C stageDir __env.local` invocation — tar
36
+ * accepts multiple -C flags, each retargeting the base dir for the file
37
+ * args that follow it, so one archive gets both "everything in files_root"
38
+ * and "one file from elsewhere" without a second tar --append pass (which
39
+ * would mean rewriting/recompressing the whole gzip stream).
40
+ */
41
+ export declare function downloadFiles(cfg: EnvsyncConfig, opts?: {
42
+ onProgress?: (msg: string) => void;
43
+ id?: string;
44
+ now?: () => number;
45
+ }): Promise<EnvsyncFileResult>;
46
+ /**
47
+ * Preview or apply an archive from downloadFiles into cfg.files_root +
48
+ * process.cwd()/.env.local.
49
+ *
50
+ * Always: extract __env.local from the archive, merge cfg.envOverrides on
51
+ * top (overrides win), and diff the merged result against the CURRENT
52
+ * target .env.local — i.e. "what's about to change" (see dotenv-merge.ts
53
+ * for why that's the more useful diff direction than "vs the archive's
54
+ * original values").
55
+ *
56
+ * If opts.confirm is falsy: returns { ok: false, diff } — a dry-run
57
+ * preview, no writes at all.
58
+ *
59
+ * If opts.confirm is truthy: guarded by assertNotProd against
60
+ * cfg.target_db (reusing the same DB-name guard uploadDb uses — files and
61
+ * DB uploads are two halves of syncing INTO the same target environment,
62
+ * so it's the meaningful "is this prod" check available here), then backs
63
+ * up the existing target .env.local, wipes and repopulates files_root from
64
+ * the archive, and writes the merged .env.local.
65
+ */
66
+ export declare function uploadFiles(cfg: EnvsyncConfig, archivePath: string, opts: {
67
+ confirm?: boolean;
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;
77
+ onProgress?: (msg: string) => void;
78
+ }): Promise<{
79
+ ok: true;
80
+ diff: EnvOverrideDiff[];
81
+ } | {
82
+ ok: false;
83
+ diff: EnvOverrideDiff[];
84
+ }>;
85
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +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;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"}
@@ -0,0 +1,165 @@
1
+ // hazo_env/src/envsync/engine.ts — local pg_dump/pg_restore/tar orchestration
2
+ //
3
+ // Four pure-ish orchestration functions over an EnvsyncConfig. Everything
4
+ // runs locally via exec.ts's spawn wrapper (no SSH, no bash script string-
5
+ // building) — real argv arrays for pg_dump/pg_restore/dropdb/createdb/tar.
6
+ // Modeled on migrate/db-dump-restore.ts's step shape, minus the SSH layer.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { runCmd, runShell } from './exec.js';
11
+ import { assertFreeSpace } from './retention.js';
12
+ import { assertConfirmed, assertNotProd } from './guards.js';
13
+ import { mergeEnvOverrides, diffEnvAgainstCurrent, parseDotenvText, serializeDotenv, } from './dotenv-merge.js';
14
+ // Free-space floor checked before writing a new dump/archive into work_dir.
15
+ // Not configurable in Phase 1 — a fixed conservative floor is good enough
16
+ // to catch "disk is basically full" before pg_dump/tar fail mid-write.
17
+ const MIN_FREE_BYTES = 500 * 1024 * 1024; // 500 MiB
18
+ const ENV_LOCAL_MEMBER = '__env.local';
19
+ function defaultId(now) {
20
+ return String((now ?? Date.now)());
21
+ }
22
+ /**
23
+ * pg_dump the configured source_db to <work_dir>/db-<id>.pgdump.
24
+ */
25
+ export async function downloadDb(cfg, opts) {
26
+ assertFreeSpace(cfg.work_dir, MIN_FREE_BYTES);
27
+ fs.mkdirSync(cfg.work_dir, { recursive: true });
28
+ const id = opts?.id ?? defaultId(opts?.now);
29
+ const filePath = path.join(cfg.work_dir, `db-${id}.pgdump`);
30
+ opts?.onProgress?.(`Dumping ${cfg.source_db} -> ${filePath}`);
31
+ await runCmd('pg_dump', ['-Fc', cfg.source_db, '-f', filePath], { onProgress: opts?.onProgress });
32
+ const bytes = fs.statSync(filePath).size;
33
+ return { id, path: filePath, bytes };
34
+ }
35
+ /**
36
+ * Drop, recreate, and pg_restore the configured target_db from dumpPath.
37
+ * Guarded by assertConfirmed + assertNotProd — never runs unconfirmed or
38
+ * against a target that looks like production.
39
+ */
40
+ export async function uploadDb(cfg, dumpPath, opts) {
41
+ assertConfirmed(opts.confirm, cfg.target_db);
42
+ assertNotProd(cfg.target_db, cfg.prodDbNames, opts.allowProd);
43
+ if (cfg.pre_restore_cmd) {
44
+ opts.onProgress?.('Running pre_restore_cmd...');
45
+ await runShell(cfg.pre_restore_cmd, { onProgress: opts.onProgress });
46
+ }
47
+ opts.onProgress?.(`Dropping ${cfg.target_db}...`);
48
+ await runCmd('dropdb', ['--if-exists', cfg.target_db], { onProgress: opts.onProgress });
49
+ opts.onProgress?.(`Creating ${cfg.target_db}...`);
50
+ await runCmd('createdb', ['-O', cfg.owner, cfg.target_db], { onProgress: opts.onProgress });
51
+ opts.onProgress?.(`Restoring ${dumpPath} -> ${cfg.target_db}...`);
52
+ await runCmd('pg_restore', ['-d', cfg.target_db, dumpPath], { onProgress: opts.onProgress });
53
+ if (cfg.post_restore_cmd) {
54
+ opts.onProgress?.('Running post_restore_cmd...');
55
+ await runShell(cfg.post_restore_cmd, { onProgress: opts.onProgress });
56
+ }
57
+ return { ok: true };
58
+ }
59
+ /**
60
+ * tar cfg.files_root into <work_dir>/files-<id>.tar.gz, plus the current
61
+ * .env.local (from process.cwd()) added under the fixed member name
62
+ * __env.local.
63
+ *
64
+ * Approach: stage a temp dir containing ONLY __env.local, then run a single
65
+ * `tar -czf out -C files_root . -C stageDir __env.local` invocation — tar
66
+ * accepts multiple -C flags, each retargeting the base dir for the file
67
+ * args that follow it, so one archive gets both "everything in files_root"
68
+ * and "one file from elsewhere" without a second tar --append pass (which
69
+ * would mean rewriting/recompressing the whole gzip stream).
70
+ */
71
+ export async function downloadFiles(cfg, opts) {
72
+ assertFreeSpace(cfg.work_dir, MIN_FREE_BYTES);
73
+ fs.mkdirSync(cfg.work_dir, { recursive: true });
74
+ fs.mkdirSync(cfg.files_root, { recursive: true });
75
+ const id = opts?.id ?? defaultId(opts?.now);
76
+ const filePath = path.join(cfg.work_dir, `files-${id}.tar.gz`);
77
+ const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hazo_envsync_stage_'));
78
+ try {
79
+ const envLocalSrc = path.join(process.cwd(), '.env.local');
80
+ const stagedEnvLocal = path.join(stageDir, ENV_LOCAL_MEMBER);
81
+ fs.writeFileSync(stagedEnvLocal, fs.existsSync(envLocalSrc) ? fs.readFileSync(envLocalSrc) : '');
82
+ opts?.onProgress?.(`Archiving ${cfg.files_root} -> ${filePath}`);
83
+ await runCmd('tar', ['-czf', filePath, '-C', cfg.files_root, '.', '-C', stageDir, ENV_LOCAL_MEMBER], { onProgress: opts?.onProgress });
84
+ }
85
+ finally {
86
+ fs.rmSync(stageDir, { recursive: true, force: true });
87
+ }
88
+ const bytes = fs.statSync(filePath).size;
89
+ return { id, path: filePath, bytes };
90
+ }
91
+ /**
92
+ * Preview or apply an archive from downloadFiles into cfg.files_root +
93
+ * process.cwd()/.env.local.
94
+ *
95
+ * Always: extract __env.local from the archive, merge cfg.envOverrides on
96
+ * top (overrides win), and diff the merged result against the CURRENT
97
+ * target .env.local — i.e. "what's about to change" (see dotenv-merge.ts
98
+ * for why that's the more useful diff direction than "vs the archive's
99
+ * original values").
100
+ *
101
+ * If opts.confirm is falsy: returns { ok: false, diff } — a dry-run
102
+ * preview, no writes at all.
103
+ *
104
+ * If opts.confirm is truthy: guarded by assertNotProd against
105
+ * cfg.target_db (reusing the same DB-name guard uploadDb uses — files and
106
+ * DB uploads are two halves of syncing INTO the same target environment,
107
+ * so it's the meaningful "is this prod" check available here), then backs
108
+ * up the existing target .env.local, wipes and repopulates files_root from
109
+ * the archive, and writes the merged .env.local.
110
+ */
111
+ export async function uploadFiles(cfg, archivePath, opts) {
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
+ }
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);
140
+ }
141
+ if (!opts.confirm) {
142
+ return { ok: false, diff };
143
+ }
144
+ assertNotProd(cfg.target_db, cfg.prodDbNames, opts.allowProd);
145
+ if (!opts.skipEnv && fs.existsSync(targetEnvLocalPath)) {
146
+ const ts = Date.now();
147
+ fs.copyFileSync(targetEnvLocalPath, `${targetEnvLocalPath}.bak-${ts}`);
148
+ }
149
+ opts.onProgress?.(`Clearing ${cfg.files_root}...`);
150
+ fs.rmSync(cfg.files_root, { recursive: true, force: true });
151
+ fs.mkdirSync(cfg.files_root, { recursive: true });
152
+ opts.onProgress?.(`Extracting ${archivePath} -> ${cfg.files_root}...`);
153
+ await runCmd('tar', ['-xzf', archivePath, '-C', cfg.files_root], { onProgress: opts.onProgress });
154
+ // __env.local extracts as a real file inside files_root — it isn't a real
155
+ // asset, so remove it rather than trying to get tar to exclude it during
156
+ // extraction (simplest correct approach per spec). Done regardless of
157
+ // skipEnv: the member is in the archive either way.
158
+ const extractedEnvMember = path.join(cfg.files_root, ENV_LOCAL_MEMBER);
159
+ if (fs.existsSync(extractedEnvMember))
160
+ fs.rmSync(extractedEnvMember, { force: true });
161
+ if (!opts.skipEnv) {
162
+ fs.writeFileSync(targetEnvLocalPath, serializeDotenv(merged));
163
+ }
164
+ return { ok: true, diff };
165
+ }
@@ -0,0 +1,21 @@
1
+ export interface ExecOptions {
2
+ onProgress?: (line: string) => void;
3
+ }
4
+ /**
5
+ * Run `cmd` with `args` as a real argv array — never a shell string, so no
6
+ * config value can smuggle shell metacharacters into the command line.
7
+ * Streams stdout/stderr lines to opts.onProgress. Resolves on exit 0,
8
+ * rejects on non-zero exit or a spawn error (e.g. ENOENT).
9
+ */
10
+ export declare function runCmd(cmd: string, args: string[], opts?: ExecOptions): Promise<void>;
11
+ /**
12
+ * Shell-string execution — the ONE exception to "always argv arrays".
13
+ * Reserved exclusively for the two operator-supplied full command strings
14
+ * (EnvsyncConfig.pre_restore_cmd / post_restore_cmd). Those come from the
15
+ * INI file the operator controls (not end-user input), the same trust
16
+ * level as backup.ts's pre_restore_cmd/post_restore_cmd which already run
17
+ * as raw bash on the remote in migrate/db-dump-restore.ts. Do not call
18
+ * this with any value that isn't operator config.
19
+ */
20
+ export declare function runShell(command: string, opts?: ExecOptions): Promise<void>;
21
+ //# sourceMappingURL=exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/envsync/exec.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AASD;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBrF;AAED;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB3E"}
@@ -0,0 +1,62 @@
1
+ // hazo_env/src/envsync/exec.ts — local command execution for envsync
2
+ //
3
+ // Simpler cousin of migrate/ssh-exec.ts::runSsh: no SSH, no known_hosts,
4
+ // just spawn(cmd, args) against the local machine. envsync only ever talks
5
+ // to local Postgres and local paths — operators move dump/archive files
6
+ // between hosts manually, outside this code.
7
+ import { spawn } from 'node:child_process';
8
+ function streamLines(chunk, onProgress) {
9
+ if (!onProgress)
10
+ return;
11
+ for (const line of chunk.toString().split('\n')) {
12
+ if (line.trim())
13
+ onProgress(line);
14
+ }
15
+ }
16
+ /**
17
+ * Run `cmd` with `args` as a real argv array — never a shell string, so no
18
+ * config value can smuggle shell metacharacters into the command line.
19
+ * Streams stdout/stderr lines to opts.onProgress. Resolves on exit 0,
20
+ * rejects on non-zero exit or a spawn error (e.g. ENOENT).
21
+ */
22
+ export function runCmd(cmd, args, opts) {
23
+ return new Promise((resolve, reject) => {
24
+ const proc = spawn(cmd, args);
25
+ proc.stdout.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
26
+ proc.stderr.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
27
+ proc.on('error', (err) => reject(err));
28
+ proc.on('close', (code) => {
29
+ if (code !== 0) {
30
+ reject(new Error(`${cmd} exited with code ${code}`));
31
+ }
32
+ else {
33
+ resolve();
34
+ }
35
+ });
36
+ });
37
+ }
38
+ /**
39
+ * Shell-string execution — the ONE exception to "always argv arrays".
40
+ * Reserved exclusively for the two operator-supplied full command strings
41
+ * (EnvsyncConfig.pre_restore_cmd / post_restore_cmd). Those come from the
42
+ * INI file the operator controls (not end-user input), the same trust
43
+ * level as backup.ts's pre_restore_cmd/post_restore_cmd which already run
44
+ * as raw bash on the remote in migrate/db-dump-restore.ts. Do not call
45
+ * this with any value that isn't operator config.
46
+ */
47
+ export function runShell(command, opts) {
48
+ return new Promise((resolve, reject) => {
49
+ const proc = spawn(command, { shell: true });
50
+ proc.stdout.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
51
+ proc.stderr.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
52
+ proc.on('error', (err) => reject(err));
53
+ proc.on('close', (code) => {
54
+ if (code !== 0) {
55
+ reject(new Error(`shell command exited with code ${code}`));
56
+ }
57
+ else {
58
+ resolve();
59
+ }
60
+ });
61
+ });
62
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Throw unless confirm is explicitly true. Used to gate destructive ops
3
+ * (dropdb/createdb/pg_restore, wiping files_root) behind an explicit
4
+ * caller opt-in rather than running on a bare invocation.
5
+ */
6
+ export declare function assertConfirmed(confirm: boolean | undefined, targetName: string): void;
7
+ /**
8
+ * Throw if targetName looks like a production target (is in prodNames) and
9
+ * the caller hasn't explicitly opted in via allowProd.
10
+ */
11
+ export declare function assertNotProd(targetName: string, prodNames: string[], allowProd: boolean | undefined): void;
12
+ //# sourceMappingURL=guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../src/envsync/guards.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAMtF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI,CAM3G"}
@@ -0,0 +1,20 @@
1
+ // hazo_env/src/envsync/guards.ts — destructive-op safety helpers for envsync
2
+ /**
3
+ * Throw unless confirm is explicitly true. Used to gate destructive ops
4
+ * (dropdb/createdb/pg_restore, wiping files_root) behind an explicit
5
+ * caller opt-in rather than running on a bare invocation.
6
+ */
7
+ export function assertConfirmed(confirm, targetName) {
8
+ if (!confirm) {
9
+ throw new Error(`Refusing to upload_db into "${targetName}" without confirm=true — pass confirm to proceed.`);
10
+ }
11
+ }
12
+ /**
13
+ * Throw if targetName looks like a production target (is in prodNames) and
14
+ * the caller hasn't explicitly opted in via allowProd.
15
+ */
16
+ export function assertNotProd(targetName, prodNames, allowProd) {
17
+ if (prodNames.includes(targetName) && !allowProd) {
18
+ throw new Error(`Refusing: "${targetName}" looks like a production target. Pass allowProd:true to override.`);
19
+ }
20
+ }
@@ -0,0 +1,12 @@
1
+ export declare class EnvsyncLockError extends Error {
2
+ code: string;
3
+ constructor(message: string);
4
+ }
5
+ /**
6
+ * Run fn() while holding a single-run lock for workDir. Throws
7
+ * EnvsyncLockError (code 'ENVSYNC_LOCKED') if a lock is already held,
8
+ * either in-process or on disk. Always releases the lock in a finally,
9
+ * even if fn() throws.
10
+ */
11
+ export declare function withLock<T>(workDir: string, op: string, fn: () => Promise<T>): Promise<T>;
12
+ //# sourceMappingURL=lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/envsync/lock.ts"],"names":[],"mappings":"AAWA,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,IAAI,SAAoB;gBACZ,OAAO,EAAE,MAAM;CAI5B;AAqDD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAyB/F"}
@@ -0,0 +1,84 @@
1
+ // hazo_env/src/envsync/lock.ts — single-run guard for envsync operations
2
+ //
3
+ // Combines an in-process mutex (so two calls within the same Node process
4
+ // can't overlap) with an on-disk lockfile inside work_dir (so a second CLI
5
+ // invocation, or a second process, can't overlap either). The on-disk lock
6
+ // self-heals: if the PID recorded in the lockfile is no longer alive (e.g.
7
+ // the previous run crashed), the stale lock is cleared automatically.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ export class EnvsyncLockError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.code = 'ENVSYNC_LOCKED';
14
+ this.name = 'EnvsyncLockError';
15
+ }
16
+ }
17
+ // In-process mutex, keyed by work_dir so unrelated envsync configs (e.g. in
18
+ // tests using different temp dirs) never contend with each other.
19
+ const inProcessLocks = new Set();
20
+ function lockFilePath(workDir) {
21
+ return path.join(workDir, '.envsync.lock');
22
+ }
23
+ function isPidAlive(pid) {
24
+ try {
25
+ // Signal 0 performs no-op existence/permission check without killing anything.
26
+ process.kill(pid, 0);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Inspect (and clear, if stale) any on-disk lockfile in workDir.
35
+ * Throws EnvsyncLockError if a live lock is held by another process.
36
+ */
37
+ function checkOnDiskLock(workDir, op) {
38
+ const lockPath = lockFilePath(workDir);
39
+ if (!fs.existsSync(lockPath))
40
+ return;
41
+ let data = null;
42
+ try {
43
+ data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
44
+ }
45
+ catch {
46
+ // Corrupt/unreadable lockfile — treat as stale and clear it.
47
+ fs.rmSync(lockPath, { force: true });
48
+ return;
49
+ }
50
+ if (data && typeof data.pid === 'number' && isPidAlive(data.pid)) {
51
+ throw new EnvsyncLockError(`envsync: another operation ("${data.op}", pid ${data.pid}) is already in progress in ${workDir} — refusing to start "${op}"`);
52
+ }
53
+ // Stale lock from a dead process — auto-clear.
54
+ fs.rmSync(lockPath, { force: true });
55
+ }
56
+ /**
57
+ * Run fn() while holding a single-run lock for workDir. Throws
58
+ * EnvsyncLockError (code 'ENVSYNC_LOCKED') if a lock is already held,
59
+ * either in-process or on disk. Always releases the lock in a finally,
60
+ * even if fn() throws.
61
+ */
62
+ export async function withLock(workDir, op, fn) {
63
+ if (inProcessLocks.has(workDir)) {
64
+ throw new EnvsyncLockError(`envsync: another operation is already in progress in this process for ${workDir} — refusing to start "${op}"`);
65
+ }
66
+ fs.mkdirSync(workDir, { recursive: true });
67
+ checkOnDiskLock(workDir, op);
68
+ inProcessLocks.add(workDir);
69
+ const lockPath = lockFilePath(workDir);
70
+ const data = { pid: process.pid, timestamp: Date.now(), op };
71
+ fs.writeFileSync(lockPath, JSON.stringify(data));
72
+ try {
73
+ return await fn();
74
+ }
75
+ finally {
76
+ inProcessLocks.delete(workDir);
77
+ try {
78
+ fs.rmSync(lockPath, { force: true });
79
+ }
80
+ catch {
81
+ // best-effort cleanup
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Delete old envsync files in workDir, keeping the newest `opts.keep` per
3
+ * pattern (db-*.pgdump and files-*.tar.gz are pruned as separate groups).
4
+ * "Newest" is by mtime. Returns the names of files that were deleted.
5
+ */
6
+ export declare function pruneWorkDir(workDir: string, opts: {
7
+ keep: number;
8
+ }): {
9
+ deleted: string[];
10
+ };
11
+ /**
12
+ * Throw if workDir's filesystem has less than minBytes available.
13
+ * Uses fs.statfsSync (Node 18.15+/20+) to read available blocks directly —
14
+ * no shelling out to `df`.
15
+ */
16
+ export declare function assertFreeSpace(workDir: string, minBytes: number): void;
17
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/envsync/retention.ts"],"names":[],"mappings":"AAgBA;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAuB3F;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CASvE"}
@@ -0,0 +1,52 @@
1
+ // hazo_env/src/envsync/retention.ts — work_dir housekeeping for envsync
2
+ //
3
+ // Prunes old dump/archive files so work_dir doesn't grow without bound, and
4
+ // checks free disk space before a new dump/archive is created.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ // The two file kinds envsync ever writes into work_dir (see engine.ts).
8
+ // Each kind is pruned independently, keeping the newest N of *each* kind —
9
+ // otherwise a single "keep 3" would let one kind starve the other.
10
+ const RETAINED_PATTERNS = [
11
+ /^db-.+\.pgdump$/,
12
+ /^files-.+\.tar\.gz$/,
13
+ ];
14
+ /**
15
+ * Delete old envsync files in workDir, keeping the newest `opts.keep` per
16
+ * pattern (db-*.pgdump and files-*.tar.gz are pruned as separate groups).
17
+ * "Newest" is by mtime. Returns the names of files that were deleted.
18
+ */
19
+ export function pruneWorkDir(workDir, opts) {
20
+ const deleted = [];
21
+ if (!fs.existsSync(workDir))
22
+ return { deleted };
23
+ const entries = fs.readdirSync(workDir);
24
+ for (const pattern of RETAINED_PATTERNS) {
25
+ const group = entries
26
+ .filter((name) => pattern.test(name))
27
+ .map((name) => {
28
+ const full = path.join(workDir, name);
29
+ const mtime = fs.statSync(full).mtimeMs;
30
+ return { name, full, mtime };
31
+ })
32
+ .sort((a, b) => b.mtime - a.mtime); // newest first
33
+ for (const stale of group.slice(Math.max(opts.keep, 0))) {
34
+ fs.rmSync(stale.full, { force: true });
35
+ deleted.push(stale.name);
36
+ }
37
+ }
38
+ return { deleted };
39
+ }
40
+ /**
41
+ * Throw if workDir's filesystem has less than minBytes available.
42
+ * Uses fs.statfsSync (Node 18.15+/20+) to read available blocks directly —
43
+ * no shelling out to `df`.
44
+ */
45
+ export function assertFreeSpace(workDir, minBytes) {
46
+ fs.mkdirSync(workDir, { recursive: true });
47
+ const stat = fs.statfsSync(workDir);
48
+ const availableBytes = stat.bavail * stat.bsize;
49
+ if (availableBytes < minBytes) {
50
+ throw new Error(`envsync: insufficient free space in ${workDir} — ${availableBytes} bytes available, ${minBytes} required`);
51
+ }
52
+ }
@@ -0,0 +1,21 @@
1
+ import http from 'node:http';
2
+ export interface StartEnvsyncServiceOptions {
3
+ port?: number;
4
+ bind?: string;
5
+ token?: string;
6
+ }
7
+ export interface EnvsyncServiceHandle {
8
+ server: http.Server;
9
+ close(): Promise<void>;
10
+ }
11
+ /**
12
+ * Start the envsync HTTP service. Throws synchronously if no bearer token
13
+ * is configured (via opts.token or HAZO_ENVSYNC_TOKEN) — this service must
14
+ * never run unauthenticated.
15
+ *
16
+ * opts override env vars, which makes this testable without real env vars
17
+ * or real ports: pass { port: 0 } for an OS-assigned ephemeral port and read
18
+ * the actual port off `server.address()`.
19
+ */
20
+ export declare function startEnvsyncService(opts?: StartEnvsyncServiceOptions): EnvsyncServiceHandle;
21
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +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;AA+VD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,0BAA+B,GAAG,oBAAoB,CA8B/F"}