@absolutejs/deploy 0.6.0 → 0.7.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/README.md CHANGED
@@ -287,6 +287,83 @@ limit). Persist the JSON; pass `account` back to subsequent
287
287
  mode `600`. Override `certPath` / `keyPath` / `mode` / `owner` /
288
288
  `reload` as needed.
289
289
 
290
+ ## `@absolutejs/deploy/env` — env-file sync + secret propagation (0.7.0)
291
+
292
+ The "universal place to rotate a key across the myriad of services"
293
+ loop. Composes with `@absolutejs/secrets`: that library handles the
294
+ in-process side (resolve, rotate, redact, in-process listeners);
295
+ this module handles the deploy-side (push values to remote env
296
+ files, atomic swap, conditional service reload).
297
+
298
+ ```ts
299
+ import { createSecretBroker, inMemoryAdapter } from '@absolutejs/secrets';
300
+ import { hetznerTarget } from '@absolutejs/deploy/hetzner';
301
+ import {
302
+ syncSecretsToDeployments,
303
+ deploymentsUsing,
304
+ type EnvDeployment,
305
+ } from '@absolutejs/deploy/env';
306
+
307
+ // One source of truth — the SecretBroker. Swap in whatever adapter
308
+ // (env, file, vault, etc.) makes sense for your team.
309
+ const broker = createSecretBroker({
310
+ adapter: inMemoryAdapter({
311
+ initial: {
312
+ DATABASE_URL: 'postgres://prod-db',
313
+ STRIPE_KEY: 'sk_live_old',
314
+ },
315
+ }),
316
+ });
317
+
318
+ // Each deployed service is one EnvDeployment.
319
+ const api = await hetznerTarget({ name: 'api-1', /* … */ });
320
+ const worker = await hetznerTarget({ name: 'worker-1', /* … */ });
321
+
322
+ const deployments: EnvDeployment[] = [
323
+ {
324
+ target: api,
325
+ remotePath: '/etc/api.env',
326
+ secretNames: ['STRIPE_KEY', 'DATABASE_URL'],
327
+ extras: { NODE_ENV: 'production', PORT: '3000' },
328
+ reload: 'systemctl reload api',
329
+ },
330
+ {
331
+ target: worker,
332
+ remotePath: '/etc/worker.env',
333
+ secretNames: ['DATABASE_URL'],
334
+ extras: { NODE_ENV: 'production' },
335
+ reload: 'systemctl restart worker',
336
+ },
337
+ ];
338
+
339
+ // First-time push (and every subsequent re-sync — idempotent).
340
+ await syncSecretsToDeployments(broker, deployments);
341
+
342
+ // Rotate STRIPE_KEY everywhere it's used:
343
+ await broker.rotate('STRIPE_KEY');
344
+ await syncSecretsToDeployments(
345
+ broker,
346
+ deploymentsUsing('STRIPE_KEY', deployments)
347
+ );
348
+ ```
349
+
350
+ `broker.rotate()` updates the broker's underlying store + fires the
351
+ existing `onRotate` listeners (long-lived DB clients swap creds in
352
+ place). `syncSecretsToDeployments` propagates to every deployed box
353
+ that uses the secret, atomically rewrites the env file, runs the
354
+ reload command only if the diff was non-empty.
355
+
356
+ Format: standard `KEY=value` per line, sorted alphabetically (stable
357
+ diffs), values double-quoted when needed. systemd reads it via
358
+ `EnvironmentFile=`; Docker via `--env-file`; most shell start
359
+ scripts source it. The serializer rejects newlines in values + keys
360
+ that don't match `[A-Z_][A-Z0-9_]*`.
361
+
362
+ Best-effort fan-out: one broken target doesn't stop the rest. Each
363
+ result carries either `result: EnvSyncResult` or `error: Error`.
364
+ The operator inspects the array, fixes the broken target, re-runs —
365
+ re-runs are idempotent.
366
+
290
367
  ## Renewals — `renewCertificate` (0.6.0)
291
368
 
292
369
  `issueCertificate` is one-shot; `renewCertificate` is the conditional
package/dist/env.d.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @absolutejs/deploy/env — push environment-variable files to remote
3
+ * targets and fan-out secret rotations across the fleet.
4
+ *
5
+ * Composes with `@absolutejs/secrets`: that library handles the
6
+ * in-process side (resolve, rotate, redact, in-process listeners);
7
+ * this module handles the deploy-side (push values to remote env
8
+ * files, atomic swap, conditional service reload).
9
+ *
10
+ * Narrow `SecretSource` interface — `@absolutejs/secrets`'
11
+ * `SecretBroker` satisfies it structurally without a hard dep
12
+ * either direction.
13
+ *
14
+ * Standard format: a remote env file at
15
+ * `/etc/<appName>.env` (or wherever you choose) that systemd reads
16
+ * via `EnvironmentFile=`, Docker reads via `--env-file`, and most
17
+ * shell start scripts source. One `KEY=value` per line, no
18
+ * newlines inside values.
19
+ */
20
+ import type { Target } from './targets';
21
+ /**
22
+ * Minimal interface this module needs to read secret values. The
23
+ * `SecretBroker` from `@absolutejs/secrets` satisfies it structurally;
24
+ * any other implementation (a Map, an AWS Secrets Manager client
25
+ * wrapper, etc.) works as long as it exposes `resolve(name)`.
26
+ */
27
+ export type SecretSource = {
28
+ resolve: (name: string) => Promise<{
29
+ value: string;
30
+ fingerprint?: string;
31
+ } | null>;
32
+ };
33
+ export declare const serializeEnvFile: (values: Record<string, string>) => string;
34
+ /**
35
+ * Parse a remote env file's contents. Ignores blank lines and `#`
36
+ * comments. Lines that don't match `KEY=VALUE` throw — the deploy
37
+ * primitive owns the file, so unknown content is loud, not silent.
38
+ */
39
+ export declare const parseEnvFile: (text: string) => Record<string, string>;
40
+ export type EnvDeployment = {
41
+ /** The cloud Target whose remote filesystem we write to. */
42
+ target: Target;
43
+ /** Remote path for the env file (e.g. `/etc/myapp.env`). */
44
+ remotePath: string;
45
+ /**
46
+ * Names of secrets this deployment consumes from the SecretSource.
47
+ * Each name is resolved before the file is written.
48
+ */
49
+ secretNames?: ReadonlyArray<string>;
50
+ /**
51
+ * Non-secret env vars to merge in (NODE_ENV, PORT, LOG_LEVEL).
52
+ * If an `extras` key collides with a `secretNames` entry, throws —
53
+ * silent overrides are worse than a loud rejection.
54
+ */
55
+ extras?: Record<string, string>;
56
+ /** chmod for the file. Default `'600'`. */
57
+ mode?: string;
58
+ /** chown for the file. Default unchanged. */
59
+ owner?: string;
60
+ /**
61
+ * Command to run after the file changes. Skipped when the diff is
62
+ * empty — bouncing a service for an identical file is the wrong
63
+ * default.
64
+ */
65
+ reload?: string;
66
+ };
67
+ export type EnvSyncResult = {
68
+ /** Keys present in the new spec but not the existing file. */
69
+ added: string[];
70
+ /** Keys whose values changed (fingerprints, NOT plaintext, for safety). */
71
+ changed: string[];
72
+ /** Keys removed from the spec (still in the existing file). */
73
+ removed: string[];
74
+ /** Keys whose values matched — file wasn't rewritten for these. */
75
+ unchanged: string[];
76
+ /** True iff `deployment.reload` was invoked. */
77
+ reloaded: boolean;
78
+ /** True iff the file content changed (atomic-write happened). */
79
+ wrote: boolean;
80
+ /** Remote path that was synced. */
81
+ remotePath: string;
82
+ };
83
+ /**
84
+ * Push an env file to a single target. Atomic: write to a `.new`
85
+ * tempfile, chmod / chown, then `mv` into place. Diffs the merged
86
+ * values against the existing file content and skips the
87
+ * write+reload when nothing changed.
88
+ */
89
+ export declare const syncEnvToTarget: (deployment: EnvDeployment, values: Record<string, string>) => Promise<EnvSyncResult>;
90
+ export type DeploymentSyncResult = {
91
+ deployment: EnvDeployment;
92
+ result?: EnvSyncResult;
93
+ error?: Error;
94
+ };
95
+ /**
96
+ * Fan-out push: for each deployment, resolve its `secretNames` via
97
+ * the source, merge with `extras`, and push to the target. Resolves
98
+ * are fresh every call — so calling this again after
99
+ * `broker.rotate(name)` propagates the new value to every
100
+ * deployment that uses it.
101
+ *
102
+ * Best-effort across the fan-out: a per-target failure is captured
103
+ * in the result, but doesn't stop the rest. Operator inspects the
104
+ * returned array, fixes the broken target, re-runs.
105
+ */
106
+ export declare const syncSecretsToDeployments: (source: SecretSource, deployments: ReadonlyArray<EnvDeployment>) => Promise<DeploymentSyncResult[]>;
107
+ /**
108
+ * Returns just the deployments that use a given secret name. Useful
109
+ * for "rotate one secret and propagate ONLY to the consumers" —
110
+ * `syncSecretsToDeployments(source, deploymentsUsing(name, deployments))`.
111
+ */
112
+ export declare const deploymentsUsing: (name: string, deployments: ReadonlyArray<EnvDeployment>) => EnvDeployment[];
package/dist/env.js ADDED
@@ -0,0 +1,203 @@
1
+ // @bun
2
+ // src/env.ts
3
+ var KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
4
+ var NEEDS_QUOTING = /[\s"'`$\\#&|;<>(){}*?!]/;
5
+ var validateKey = (key) => {
6
+ if (!KEY_PATTERN.test(key)) {
7
+ throw new Error(`[deploy/env] invalid env key "${key}" \u2014 must match /^[A-Z_][A-Z0-9_]*$/`);
8
+ }
9
+ };
10
+ var serializeLine = (key, value) => {
11
+ validateKey(key);
12
+ if (value.includes(`
13
+ `) || value.includes("\r")) {
14
+ throw new Error(`[deploy/env] value for "${key}" contains a newline \u2014 env files cannot represent multi-line values. Use a separate file path for multi-line secrets.`);
15
+ }
16
+ if (NEEDS_QUOTING.test(value) || value.startsWith("=") || value === "") {
17
+ const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
18
+ return `${key}="${escaped}"`;
19
+ }
20
+ return `${key}=${value}`;
21
+ };
22
+ var serializeEnvFile = (values) => {
23
+ const lines = [];
24
+ for (const key of Object.keys(values).sort()) {
25
+ const value = values[key];
26
+ if (value === undefined)
27
+ continue;
28
+ lines.push(serializeLine(key, value));
29
+ }
30
+ return `${lines.join(`
31
+ `)}
32
+ `;
33
+ };
34
+ var unquoteValue = (raw) => {
35
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
36
+ const inner = raw.slice(1, -1);
37
+ return inner.replaceAll("\\\"", '"').replaceAll("\\\\", "\\");
38
+ }
39
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
40
+ return raw.slice(1, -1);
41
+ }
42
+ return raw;
43
+ };
44
+ var parseEnvFile = (text) => {
45
+ const result = {};
46
+ for (const rawLine of text.split(`
47
+ `)) {
48
+ const line = rawLine.trim();
49
+ if (line.length === 0 || line.startsWith("#"))
50
+ continue;
51
+ const eq = line.indexOf("=");
52
+ if (eq <= 0) {
53
+ throw new Error(`[deploy/env] malformed env line: "${rawLine}"`);
54
+ }
55
+ const key = line.slice(0, eq).trim();
56
+ const value = unquoteValue(line.slice(eq + 1).trim());
57
+ validateKey(key);
58
+ result[key] = value;
59
+ }
60
+ return result;
61
+ };
62
+ var shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
63
+ var tryReadRemoteFile = async (target, remotePath) => {
64
+ const result = await target.exec(`if [ -f ${shellQuote(remotePath)} ]; then cat ${shellQuote(remotePath)}; else echo __ABS_DEPLOY_ENV_ABSENT__; fi`);
65
+ if (result.exitCode !== 0) {
66
+ throw new Error(`[deploy/env] failed to read ${remotePath}: exit ${result.exitCode}: ${result.stderr || result.stdout}`);
67
+ }
68
+ if (result.stdout.trim() === "__ABS_DEPLOY_ENV_ABSENT__")
69
+ return;
70
+ return result.stdout;
71
+ };
72
+ var mergeValues = (deployment, resolved) => {
73
+ const out = {};
74
+ for (const [key, value] of Object.entries(deployment.extras ?? {})) {
75
+ validateKey(key);
76
+ out[key] = value;
77
+ }
78
+ for (const [key, value] of Object.entries(resolved)) {
79
+ if (out[key] !== undefined) {
80
+ throw new Error(`[deploy/env] "${key}" defined in BOTH extras and secretNames for ${deployment.remotePath} \u2014 remove one`);
81
+ }
82
+ out[key] = value;
83
+ }
84
+ return out;
85
+ };
86
+ var diffEnv = (previous, next) => {
87
+ const added = [];
88
+ const changed = [];
89
+ const removed = [];
90
+ const unchanged = [];
91
+ const allKeys = new Set([...Object.keys(previous), ...Object.keys(next)]);
92
+ for (const key of [...allKeys].sort()) {
93
+ const prev = previous[key];
94
+ const curr = next[key];
95
+ if (curr === undefined)
96
+ removed.push(key);
97
+ else if (prev === undefined)
98
+ added.push(key);
99
+ else if (prev === curr)
100
+ unchanged.push(key);
101
+ else
102
+ changed.push(key);
103
+ }
104
+ return { added, changed, removed, unchanged };
105
+ };
106
+ var writeRemoteFileAtomically = async (target, remotePath, contents, mode, owner) => {
107
+ const tempPath = `${remotePath}.new.${Math.floor(Date.now() / 1000)}`;
108
+ const dir = remotePath.split("/").slice(0, -1).join("/") || "/";
109
+ const mkdir = await target.exec(`mkdir -p ${shellQuote(dir)}`);
110
+ if (mkdir.exitCode !== 0) {
111
+ throw new Error(`[deploy/env] mkdir ${dir} failed: ${mkdir.stderr || mkdir.stdout}`);
112
+ }
113
+ const writeResult = await target.exec(`cat > ${shellQuote(tempPath)}`, {
114
+ stdin: contents
115
+ });
116
+ if (writeResult.exitCode !== 0) {
117
+ throw new Error(`[deploy/env] write to ${tempPath} failed: ${writeResult.stderr || writeResult.stdout}`);
118
+ }
119
+ const chmod = await target.exec(`chmod ${shellQuote(mode)} ${shellQuote(tempPath)}`);
120
+ if (chmod.exitCode !== 0) {
121
+ throw new Error(`[deploy/env] chmod failed: ${chmod.stderr || chmod.stdout}`);
122
+ }
123
+ if (owner !== undefined) {
124
+ const chown = await target.exec(`chown ${shellQuote(owner)} ${shellQuote(tempPath)}`);
125
+ if (chown.exitCode !== 0) {
126
+ throw new Error(`[deploy/env] chown failed: ${chown.stderr || chown.stdout}`);
127
+ }
128
+ }
129
+ const mv = await target.exec(`mv ${shellQuote(tempPath)} ${shellQuote(remotePath)}`);
130
+ if (mv.exitCode !== 0) {
131
+ throw new Error(`[deploy/env] mv failed: ${mv.stderr || mv.stdout}`);
132
+ }
133
+ };
134
+ var syncEnvToTarget = async (deployment, values) => {
135
+ const mode = deployment.mode ?? "600";
136
+ const merged = mergeValues(deployment, values);
137
+ const previousText = await tryReadRemoteFile(deployment.target, deployment.remotePath);
138
+ const previous = previousText === undefined ? {} : parseEnvFile(previousText);
139
+ const diff = diffEnv(previous, merged);
140
+ const changed = diff.added.length + diff.changed.length + diff.removed.length;
141
+ if (changed === 0) {
142
+ return {
143
+ ...diff,
144
+ reloaded: false,
145
+ remotePath: deployment.remotePath,
146
+ wrote: false
147
+ };
148
+ }
149
+ const nextText = serializeEnvFile(merged);
150
+ await writeRemoteFileAtomically(deployment.target, deployment.remotePath, nextText, mode, deployment.owner);
151
+ let reloaded = false;
152
+ if (deployment.reload !== undefined) {
153
+ const reload = await deployment.target.exec(deployment.reload);
154
+ if (reload.exitCode !== 0) {
155
+ throw new Error(`[deploy/env] reload command failed: ${reload.stderr || reload.stdout}`);
156
+ }
157
+ reloaded = true;
158
+ }
159
+ return {
160
+ ...diff,
161
+ reloaded,
162
+ remotePath: deployment.remotePath,
163
+ wrote: true
164
+ };
165
+ };
166
+ var resolveSecrets = async (source, names) => {
167
+ const out = {};
168
+ for (const name of names) {
169
+ const resolved = await source.resolve(name);
170
+ if (resolved === null) {
171
+ throw new Error(`[deploy/env] secret "${name}" not found in source`);
172
+ }
173
+ out[name] = resolved.value;
174
+ }
175
+ return out;
176
+ };
177
+ var syncSecretsToDeployments = async (source, deployments) => {
178
+ const results = [];
179
+ for (const deployment of deployments) {
180
+ try {
181
+ const resolved = await resolveSecrets(source, deployment.secretNames ?? []);
182
+ const result = await syncEnvToTarget(deployment, resolved);
183
+ results.push({ deployment, result });
184
+ } catch (error) {
185
+ results.push({
186
+ deployment,
187
+ error: error instanceof Error ? error : new Error(String(error))
188
+ });
189
+ }
190
+ }
191
+ return results;
192
+ };
193
+ var deploymentsUsing = (name, deployments) => deployments.filter((d) => (d.secretNames ?? []).includes(name));
194
+ export {
195
+ syncSecretsToDeployments,
196
+ syncEnvToTarget,
197
+ serializeEnvFile,
198
+ parseEnvFile,
199
+ deploymentsUsing
200
+ };
201
+
202
+ //# debugId=61C727D3C5F9CC6864756E2164756E21
203
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/env.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * @absolutejs/deploy/env — push environment-variable files to remote\n * targets and fan-out secret rotations across the fleet.\n *\n * Composes with `@absolutejs/secrets`: that library handles the\n * in-process side (resolve, rotate, redact, in-process listeners);\n * this module handles the deploy-side (push values to remote env\n * files, atomic swap, conditional service reload).\n *\n * Narrow `SecretSource` interface — `@absolutejs/secrets`'\n * `SecretBroker` satisfies it structurally without a hard dep\n * either direction.\n *\n * Standard format: a remote env file at\n * `/etc/<appName>.env` (or wherever you choose) that systemd reads\n * via `EnvironmentFile=`, Docker reads via `--env-file`, and most\n * shell start scripts source. One `KEY=value` per line, no\n * newlines inside values.\n */\n\nimport type { Target } from './targets';\n\n// =============================================================================\n// SecretSource — narrow interface @absolutejs/secrets' broker satisfies\n// =============================================================================\n\n/**\n * Minimal interface this module needs to read secret values. The\n * `SecretBroker` from `@absolutejs/secrets` satisfies it structurally;\n * any other implementation (a Map, an AWS Secrets Manager client\n * wrapper, etc.) works as long as it exposes `resolve(name)`.\n */\nexport type SecretSource = {\n\tresolve: (\n\t\tname: string\n\t) => Promise<{ value: string; fingerprint?: string } | null>;\n};\n\n// =============================================================================\n// Env-file (de)serialization\n// =============================================================================\n\nconst KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;\nconst NEEDS_QUOTING = /[\\s\"'`$\\\\#&|;<>(){}*?!]/;\n\nconst validateKey = (key: string): void => {\n\tif (!KEY_PATTERN.test(key)) {\n\t\tthrow new Error(\n\t\t\t`[deploy/env] invalid env key \"${key}\" — must match /^[A-Z_][A-Z0-9_]*$/`\n\t\t);\n\t}\n};\n\nconst serializeLine = (key: string, value: string): string => {\n\tvalidateKey(key);\n\tif (value.includes('\\n') || value.includes('\\r')) {\n\t\tthrow new Error(\n\t\t\t`[deploy/env] value for \"${key}\" contains a newline — env files cannot represent multi-line values. Use a separate file path for multi-line secrets.`\n\t\t);\n\t}\n\tif (NEEDS_QUOTING.test(value) || value.startsWith('=') || value === '') {\n\t\tconst escaped = value.replaceAll('\\\\', '\\\\\\\\').replaceAll('\"', '\\\\\"');\n\t\treturn `${key}=\"${escaped}\"`;\n\t}\n\treturn `${key}=${value}`;\n};\n\nexport const serializeEnvFile = (values: Record<string, string>): string => {\n\tconst lines: string[] = [];\n\tfor (const key of Object.keys(values).sort()) {\n\t\tconst value = values[key];\n\t\tif (value === undefined) continue;\n\t\tlines.push(serializeLine(key, value));\n\t}\n\treturn `${lines.join('\\n')}\\n`;\n};\n\nconst unquoteValue = (raw: string): string => {\n\tif (\n\t\traw.length >= 2 &&\n\t\traw.startsWith('\"') &&\n\t\traw.endsWith('\"')\n\t) {\n\t\tconst inner = raw.slice(1, -1);\n\t\treturn inner.replaceAll('\\\\\"', '\"').replaceAll('\\\\\\\\', '\\\\');\n\t}\n\tif (\n\t\traw.length >= 2 &&\n\t\traw.startsWith(\"'\") &&\n\t\traw.endsWith(\"'\")\n\t) {\n\t\treturn raw.slice(1, -1);\n\t}\n\treturn raw;\n};\n\n/**\n * Parse a remote env file's contents. Ignores blank lines and `#`\n * comments. Lines that don't match `KEY=VALUE` throw — the deploy\n * primitive owns the file, so unknown content is loud, not silent.\n */\nexport const parseEnvFile = (text: string): Record<string, string> => {\n\tconst result: Record<string, string> = {};\n\tfor (const rawLine of text.split('\\n')) {\n\t\tconst line = rawLine.trim();\n\t\tif (line.length === 0 || line.startsWith('#')) continue;\n\t\tconst eq = line.indexOf('=');\n\t\tif (eq <= 0) {\n\t\t\tthrow new Error(`[deploy/env] malformed env line: \"${rawLine}\"`);\n\t\t}\n\t\tconst key = line.slice(0, eq).trim();\n\t\tconst value = unquoteValue(line.slice(eq + 1).trim());\n\t\tvalidateKey(key);\n\t\tresult[key] = value;\n\t}\n\treturn result;\n};\n\n// =============================================================================\n// Deployment spec + sync primitive\n// =============================================================================\n\nexport type EnvDeployment = {\n\t/** The cloud Target whose remote filesystem we write to. */\n\ttarget: Target;\n\t/** Remote path for the env file (e.g. `/etc/myapp.env`). */\n\tremotePath: string;\n\t/**\n\t * Names of secrets this deployment consumes from the SecretSource.\n\t * Each name is resolved before the file is written.\n\t */\n\tsecretNames?: ReadonlyArray<string>;\n\t/**\n\t * Non-secret env vars to merge in (NODE_ENV, PORT, LOG_LEVEL).\n\t * If an `extras` key collides with a `secretNames` entry, throws —\n\t * silent overrides are worse than a loud rejection.\n\t */\n\textras?: Record<string, string>;\n\t/** chmod for the file. Default `'600'`. */\n\tmode?: string;\n\t/** chown for the file. Default unchanged. */\n\towner?: string;\n\t/**\n\t * Command to run after the file changes. Skipped when the diff is\n\t * empty — bouncing a service for an identical file is the wrong\n\t * default.\n\t */\n\treload?: string;\n};\n\nexport type EnvSyncResult = {\n\t/** Keys present in the new spec but not the existing file. */\n\tadded: string[];\n\t/** Keys whose values changed (fingerprints, NOT plaintext, for safety). */\n\tchanged: string[];\n\t/** Keys removed from the spec (still in the existing file). */\n\tremoved: string[];\n\t/** Keys whose values matched — file wasn't rewritten for these. */\n\tunchanged: string[];\n\t/** True iff `deployment.reload` was invoked. */\n\treloaded: boolean;\n\t/** True iff the file content changed (atomic-write happened). */\n\twrote: boolean;\n\t/** Remote path that was synced. */\n\tremotePath: string;\n};\n\nconst shellQuote = (value: string): string =>\n\t`'${value.replaceAll(\"'\", \"'\\\\''\")}'`;\n\nconst tryReadRemoteFile = async (\n\ttarget: Target,\n\tremotePath: string\n): Promise<string | undefined> => {\n\tconst result = await target.exec(\n\t\t`if [ -f ${shellQuote(remotePath)} ]; then cat ${shellQuote(remotePath)}; else echo __ABS_DEPLOY_ENV_ABSENT__; fi`\n\t);\n\tif (result.exitCode !== 0) {\n\t\tthrow new Error(\n\t\t\t`[deploy/env] failed to read ${remotePath}: exit ${result.exitCode}: ${result.stderr || result.stdout}`\n\t\t);\n\t}\n\tif (result.stdout.trim() === '__ABS_DEPLOY_ENV_ABSENT__') return undefined;\n\treturn result.stdout;\n};\n\nconst mergeValues = (\n\tdeployment: EnvDeployment,\n\tresolved: Record<string, string>\n): Record<string, string> => {\n\tconst out: Record<string, string> = {};\n\tfor (const [key, value] of Object.entries(deployment.extras ?? {})) {\n\t\tvalidateKey(key);\n\t\tout[key] = value;\n\t}\n\tfor (const [key, value] of Object.entries(resolved)) {\n\t\tif (out[key] !== undefined) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/env] \"${key}\" defined in BOTH extras and secretNames for ${deployment.remotePath} — remove one`\n\t\t\t);\n\t\t}\n\t\tout[key] = value;\n\t}\n\treturn out;\n};\n\nconst diffEnv = (\n\tprevious: Record<string, string>,\n\tnext: Record<string, string>\n): Pick<EnvSyncResult, 'added' | 'changed' | 'removed' | 'unchanged'> => {\n\tconst added: string[] = [];\n\tconst changed: string[] = [];\n\tconst removed: string[] = [];\n\tconst unchanged: string[] = [];\n\tconst allKeys = new Set([...Object.keys(previous), ...Object.keys(next)]);\n\tfor (const key of [...allKeys].sort()) {\n\t\tconst prev = previous[key];\n\t\tconst curr = next[key];\n\t\tif (curr === undefined) removed.push(key);\n\t\telse if (prev === undefined) added.push(key);\n\t\telse if (prev === curr) unchanged.push(key);\n\t\telse changed.push(key);\n\t}\n\treturn { added, changed, removed, unchanged };\n};\n\nconst writeRemoteFileAtomically = async (\n\ttarget: Target,\n\tremotePath: string,\n\tcontents: string,\n\tmode: string,\n\towner?: string\n): Promise<void> => {\n\tconst tempPath = `${remotePath}.new.${Math.floor(Date.now() / 1000)}`;\n\tconst dir = remotePath.split('/').slice(0, -1).join('/') || '/';\n\tconst mkdir = await target.exec(`mkdir -p ${shellQuote(dir)}`);\n\tif (mkdir.exitCode !== 0) {\n\t\tthrow new Error(\n\t\t\t`[deploy/env] mkdir ${dir} failed: ${mkdir.stderr || mkdir.stdout}`\n\t\t);\n\t}\n\t// Write via stdin redirect — no shell escaping of the env file content.\n\tconst writeResult = await target.exec(`cat > ${shellQuote(tempPath)}`, {\n\t\tstdin: contents\n\t});\n\tif (writeResult.exitCode !== 0) {\n\t\tthrow new Error(\n\t\t\t`[deploy/env] write to ${tempPath} failed: ${writeResult.stderr || writeResult.stdout}`\n\t\t);\n\t}\n\tconst chmod = await target.exec(\n\t\t`chmod ${shellQuote(mode)} ${shellQuote(tempPath)}`\n\t);\n\tif (chmod.exitCode !== 0) {\n\t\tthrow new Error(\n\t\t\t`[deploy/env] chmod failed: ${chmod.stderr || chmod.stdout}`\n\t\t);\n\t}\n\tif (owner !== undefined) {\n\t\tconst chown = await target.exec(\n\t\t\t`chown ${shellQuote(owner)} ${shellQuote(tempPath)}`\n\t\t);\n\t\tif (chown.exitCode !== 0) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/env] chown failed: ${chown.stderr || chown.stdout}`\n\t\t\t);\n\t\t}\n\t}\n\tconst mv = await target.exec(\n\t\t`mv ${shellQuote(tempPath)} ${shellQuote(remotePath)}`\n\t);\n\tif (mv.exitCode !== 0) {\n\t\tthrow new Error(`[deploy/env] mv failed: ${mv.stderr || mv.stdout}`);\n\t}\n};\n\n/**\n * Push an env file to a single target. Atomic: write to a `.new`\n * tempfile, chmod / chown, then `mv` into place. Diffs the merged\n * values against the existing file content and skips the\n * write+reload when nothing changed.\n */\nexport const syncEnvToTarget = async (\n\tdeployment: EnvDeployment,\n\tvalues: Record<string, string>\n): Promise<EnvSyncResult> => {\n\tconst mode = deployment.mode ?? '600';\n\tconst merged = mergeValues(deployment, values);\n\tconst previousText = await tryReadRemoteFile(\n\t\tdeployment.target,\n\t\tdeployment.remotePath\n\t);\n\tconst previous = previousText === undefined ? {} : parseEnvFile(previousText);\n\tconst diff = diffEnv(previous, merged);\n\tconst changed = diff.added.length + diff.changed.length + diff.removed.length;\n\n\tif (changed === 0) {\n\t\treturn {\n\t\t\t...diff,\n\t\t\treloaded: false,\n\t\t\tremotePath: deployment.remotePath,\n\t\t\twrote: false\n\t\t};\n\t}\n\n\tconst nextText = serializeEnvFile(merged);\n\tawait writeRemoteFileAtomically(\n\t\tdeployment.target,\n\t\tdeployment.remotePath,\n\t\tnextText,\n\t\tmode,\n\t\tdeployment.owner\n\t);\n\n\tlet reloaded = false;\n\tif (deployment.reload !== undefined) {\n\t\tconst reload = await deployment.target.exec(deployment.reload);\n\t\tif (reload.exitCode !== 0) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/env] reload command failed: ${reload.stderr || reload.stdout}`\n\t\t\t);\n\t\t}\n\t\treloaded = true;\n\t}\n\n\treturn {\n\t\t...diff,\n\t\treloaded,\n\t\tremotePath: deployment.remotePath,\n\t\twrote: true\n\t};\n};\n\n// =============================================================================\n// Multi-target fan-out via a SecretSource\n// =============================================================================\n\nconst resolveSecrets = async (\n\tsource: SecretSource,\n\tnames: ReadonlyArray<string>\n): Promise<Record<string, string>> => {\n\tconst out: Record<string, string> = {};\n\tfor (const name of names) {\n\t\tconst resolved = await source.resolve(name);\n\t\tif (resolved === null) {\n\t\t\tthrow new Error(\n\t\t\t\t`[deploy/env] secret \"${name}\" not found in source`\n\t\t\t);\n\t\t}\n\t\tout[name] = resolved.value;\n\t}\n\treturn out;\n};\n\nexport type DeploymentSyncResult = {\n\tdeployment: EnvDeployment;\n\tresult?: EnvSyncResult;\n\terror?: Error;\n};\n\n/**\n * Fan-out push: for each deployment, resolve its `secretNames` via\n * the source, merge with `extras`, and push to the target. Resolves\n * are fresh every call — so calling this again after\n * `broker.rotate(name)` propagates the new value to every\n * deployment that uses it.\n *\n * Best-effort across the fan-out: a per-target failure is captured\n * in the result, but doesn't stop the rest. Operator inspects the\n * returned array, fixes the broken target, re-runs.\n */\nexport const syncSecretsToDeployments = async (\n\tsource: SecretSource,\n\tdeployments: ReadonlyArray<EnvDeployment>\n): Promise<DeploymentSyncResult[]> => {\n\tconst results: DeploymentSyncResult[] = [];\n\tfor (const deployment of deployments) {\n\t\ttry {\n\t\t\tconst resolved = await resolveSecrets(\n\t\t\t\tsource,\n\t\t\t\tdeployment.secretNames ?? []\n\t\t\t);\n\t\t\tconst result = await syncEnvToTarget(deployment, resolved);\n\t\t\tresults.push({ deployment, result });\n\t\t} catch (error) {\n\t\t\tresults.push({\n\t\t\t\tdeployment,\n\t\t\t\terror: error instanceof Error ? error : new Error(String(error))\n\t\t\t});\n\t\t}\n\t}\n\treturn results;\n};\n\n/**\n * Returns just the deployments that use a given secret name. Useful\n * for \"rotate one secret and propagate ONLY to the consumers\" —\n * `syncSecretsToDeployments(source, deploymentsUsing(name, deployments))`.\n */\nexport const deploymentsUsing = (\n\tname: string,\n\tdeployments: ReadonlyArray<EnvDeployment>\n): EnvDeployment[] =>\n\tdeployments.filter((d) => (d.secretNames ?? []).includes(name));\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "61C727D3C5F9CC6864756E2164756E21",
9
+ "names": []
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/deploy",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Generic Bun-project deploy pipeline. A Target (localTarget / sshTarget) is anywhere you can exec + upload — DigitalOcean droplets, Linode, Hetzner, Vultr, your own boxes. Bundled pipeline: prepare → upload → install → build → link → restart → verify. Atomic symlink swap, release history, prune, hooks. SSH shells out to system ssh/rsync — zero ssh2 deps.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "release"
27
27
  ],
28
28
  "scripts": {
29
- "build": "rm -rf dist && bun build src/index.ts src/digitalocean.ts src/hetzner.ts src/dns.ts src/cloudflare.ts src/tls.ts --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json",
29
+ "build": "rm -rf dist && bun build src/index.ts src/digitalocean.ts src/hetzner.ts src/dns.ts src/cloudflare.ts src/tls.ts src/env.ts --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json",
30
30
  "test": "bun test tests/",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "format": "prettier --write \"./**/*.{ts,json,md}\"",
@@ -71,6 +71,11 @@
71
71
  "types": "./dist/tls.d.ts",
72
72
  "import": "./dist/tls.js",
73
73
  "default": "./dist/tls.js"
74
+ },
75
+ "./env": {
76
+ "types": "./dist/env.d.ts",
77
+ "import": "./dist/env.js",
78
+ "default": "./dist/env.js"
74
79
  }
75
80
  },
76
81
  "files": ["dist", "README.md"]