puls-dev 0.3.3 → 0.3.5

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.
Files changed (64) hide show
  1. package/dist/bin/install-shell.d.ts +2 -0
  2. package/dist/bin/install-shell.js +136 -0
  3. package/dist/bin/puls.d.ts +1 -0
  4. package/dist/bin/puls.js +145 -0
  5. package/dist/core/checker.js +74 -0
  6. package/dist/core/config.d.ts +3 -0
  7. package/dist/core/context.d.ts +1 -0
  8. package/dist/core/decorators.d.ts +1 -0
  9. package/dist/core/decorators.js +39 -5
  10. package/dist/core/output.js +8 -1
  11. package/dist/core/production.test.js +1 -0
  12. package/dist/core/resource.d.ts +35 -0
  13. package/dist/core/resource.js +57 -1
  14. package/dist/core/secret.d.ts +1 -0
  15. package/dist/core/secret.js +5 -0
  16. package/dist/core/stack.d.ts +11 -0
  17. package/dist/core/stack.js +141 -90
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.js +1 -1
  20. package/dist/providers/aws/api.js +3 -0
  21. package/dist/providers/aws/ec2.d.ts +5 -0
  22. package/dist/providers/aws/ec2.js +7 -0
  23. package/dist/providers/aws/lambda.d.ts +5 -0
  24. package/dist/providers/aws/lambda.js +24 -0
  25. package/dist/providers/aws/list.js +15 -3
  26. package/dist/providers/aws/rds.d.ts +9 -0
  27. package/dist/providers/aws/rds.js +19 -0
  28. package/dist/providers/do/database.d.ts +9 -0
  29. package/dist/providers/do/database.js +19 -0
  30. package/dist/providers/do/domain.js +1 -1
  31. package/dist/providers/do/droplet.d.ts +10 -0
  32. package/dist/providers/do/droplet.js +28 -3
  33. package/dist/providers/do/droplet.test.js +1 -1
  34. package/dist/providers/do/list.js +25 -2
  35. package/dist/providers/do/load_balancer.d.ts +5 -0
  36. package/dist/providers/do/load_balancer.js +7 -0
  37. package/dist/providers/do/vpc.d.ts +5 -0
  38. package/dist/providers/do/vpc.js +8 -0
  39. package/dist/providers/firebase/functions.d.ts +9 -0
  40. package/dist/providers/firebase/functions.js +28 -0
  41. package/dist/providers/firebase/list.js +34 -2
  42. package/dist/providers/gcp/api.js +6 -0
  43. package/dist/providers/gcp/cloudrun.d.ts +13 -0
  44. package/dist/providers/gcp/cloudrun.js +30 -0
  45. package/dist/providers/gcp/cloudsql.d.ts +9 -0
  46. package/dist/providers/gcp/cloudsql.js +20 -0
  47. package/dist/providers/gcp/list.js +12 -2
  48. package/dist/providers/gcp/template.d.ts +3 -0
  49. package/dist/providers/gcp/template.js +13 -1
  50. package/dist/providers/gcp/vm.d.ts +8 -0
  51. package/dist/providers/gcp/vm.js +22 -2
  52. package/dist/providers/proxmox/api.d.ts +1 -0
  53. package/dist/providers/proxmox/api.js +18 -3
  54. package/dist/providers/proxmox/base.d.ts +16 -0
  55. package/dist/providers/proxmox/base.js +121 -0
  56. package/dist/providers/proxmox/list.js +8 -1
  57. package/dist/providers/proxmox/template.d.ts +3 -10
  58. package/dist/providers/proxmox/template.js +51 -139
  59. package/dist/providers/proxmox/vm.d.ts +18 -10
  60. package/dist/providers/proxmox/vm.js +73 -152
  61. package/dist/types/diff.d.ts +17 -0
  62. package/dist/types/diff.js +1 -0
  63. package/dist/types/inventory.d.ts +65 -0
  64. package/package.json +7 -22
@@ -0,0 +1,2 @@
1
+ export declare function installShell(): void;
2
+ export declare function uninstallShell(): void;
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const MARKER = "# added by puls-dev";
5
+ const LAUNCHER_CONTENT = `#!/bin/sh
6
+ exec npx --yes puls-dev "$@"
7
+ `;
8
+ function detectShellConfig() {
9
+ const shellBin = process.env.SHELL ?? "";
10
+ if (shellBin.endsWith("zsh")) {
11
+ return { shell: "zsh", configFile: path.join(os.homedir(), ".zshrc") };
12
+ }
13
+ if (shellBin.endsWith("bash")) {
14
+ // macOS uses ~/.bash_profile for login shells; Linux uses ~/.bashrc
15
+ const isMac = process.platform === "darwin";
16
+ const configFile = isMac
17
+ ? path.join(os.homedir(), ".bash_profile")
18
+ : path.join(os.homedir(), ".bashrc");
19
+ return { shell: "bash", configFile };
20
+ }
21
+ if (shellBin.endsWith("fish")) {
22
+ return {
23
+ shell: "fish",
24
+ configFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+ function buildPathLine(shell) {
30
+ if (shell === "fish") {
31
+ return `fish_add_path "$HOME/.puls/bin"`;
32
+ }
33
+ return `export PATH="$HOME/.puls/bin:$PATH"`;
34
+ }
35
+ export function installShell() {
36
+ const home = os.homedir();
37
+ const launcherDir = path.join(home, ".puls", "bin");
38
+ const launcherPath = path.join(launcherDir, "puls");
39
+ // 1. Create launcher
40
+ const launcherExists = fs.existsSync(launcherPath);
41
+ if (!launcherExists) {
42
+ fs.mkdirSync(launcherDir, { recursive: true });
43
+ fs.writeFileSync(launcherPath, LAUNCHER_CONTENT, { encoding: "utf8" });
44
+ fs.chmodSync(launcherPath, 0o755);
45
+ console.log(`✅ Created launcher at ${launcherPath}`);
46
+ }
47
+ else {
48
+ console.log(` Launcher already exists at ${launcherPath}`);
49
+ }
50
+ // 2. Detect shell config
51
+ const detected = detectShellConfig();
52
+ if (!detected) {
53
+ console.log(`\n⚠️ Could not detect your shell from $SHELL="${process.env.SHELL ?? ""}".`);
54
+ console.log(` Add this line manually to your shell config:\n`);
55
+ console.log(` export PATH="$HOME/.puls/bin:$PATH"\n`);
56
+ return;
57
+ }
58
+ const { shell, configFile } = detected;
59
+ const pathLine = buildPathLine(shell);
60
+ // 3. Ensure config file exists
61
+ if (!fs.existsSync(configFile)) {
62
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
63
+ fs.writeFileSync(configFile, "", "utf8");
64
+ }
65
+ // 4. Check if already present (idempotent)
66
+ const existing = fs.readFileSync(configFile, "utf8");
67
+ if (existing.includes(MARKER)) {
68
+ console.log(` Shell config already updated (${configFile})`);
69
+ console.log(`\n✅ puls is already set up. Open a new terminal or run:\n`);
70
+ console.log(` source ${configFile}\n`);
71
+ return;
72
+ }
73
+ // 5. Append PATH entry with marker
74
+ const addition = `\n${MARKER}\n${pathLine}\n`;
75
+ fs.appendFileSync(configFile, addition, "utf8");
76
+ console.log(`✅ Added puls to PATH in ${configFile}`);
77
+ console.log(`\n🎉 All done! Activate now by running:\n`);
78
+ console.log(` source ${configFile}`);
79
+ console.log(`\nThen use puls directly:\n`);
80
+ console.log(` puls plan infra/stack.ts`);
81
+ console.log(` puls deploy infra/stack.ts`);
82
+ console.log(` puls diff infra/stack.ts\n`);
83
+ }
84
+ export function uninstallShell() {
85
+ const home = os.homedir();
86
+ const launcherPath = path.join(home, ".puls", "bin", "puls");
87
+ const launcherDir = path.join(home, ".puls", "bin");
88
+ // 1. Remove launcher
89
+ if (fs.existsSync(launcherPath)) {
90
+ fs.rmSync(launcherPath);
91
+ console.log(`✅ Removed launcher at ${launcherPath}`);
92
+ // Clean up empty dirs
93
+ try {
94
+ fs.rmdirSync(launcherDir);
95
+ fs.rmdirSync(path.join(home, ".puls"));
96
+ }
97
+ catch {
98
+ // Non-empty dirs left behind (user may have other files) — that's fine
99
+ }
100
+ }
101
+ else {
102
+ console.log(` Launcher not found at ${launcherPath} — nothing to remove.`);
103
+ }
104
+ // 2. Remove PATH line from shell config
105
+ const detected = detectShellConfig();
106
+ if (!detected) {
107
+ console.log(`\n⚠️ Could not detect shell config. Remove the puls PATH line manually.`);
108
+ return;
109
+ }
110
+ const { configFile } = detected;
111
+ if (!fs.existsSync(configFile)) {
112
+ console.log(` Shell config not found at ${configFile} — nothing to clean up.`);
113
+ return;
114
+ }
115
+ const content = fs.readFileSync(configFile, "utf8");
116
+ if (!content.includes(MARKER)) {
117
+ console.log(` Shell config at ${configFile} has no puls entry — nothing to remove.`);
118
+ return;
119
+ }
120
+ // Remove the marker line and the PATH line that follows it
121
+ const cleaned = content
122
+ .split("\n")
123
+ .reduce((acc, line) => {
124
+ if (line.trim() === MARKER)
125
+ return { out: acc.out, skip: true };
126
+ if (acc.skip)
127
+ return { out: acc.out, skip: false }; // skip the PATH line
128
+ return { out: [...acc.out, line], skip: false };
129
+ }, { out: [], skip: false })
130
+ .out
131
+ .join("\n")
132
+ .replace(/\n{3,}/g, "\n\n"); // collapse triple+ blank lines
133
+ fs.writeFileSync(configFile, cleaned, "utf8");
134
+ console.log(`✅ Removed puls PATH entry from ${configFile}`);
135
+ console.log(`\n Restart your terminal for changes to take effect.\n`);
136
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createRequire } from "node:module";
8
+ import { installShell, uninstallShell } from "./install-shell.js";
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const require = createRequire(import.meta.url);
12
+ function findTsx() {
13
+ let dir = process.cwd();
14
+ while (true) {
15
+ const candidate = path.join(dir, "node_modules", ".bin", "tsx");
16
+ if (existsSync(candidate))
17
+ return candidate;
18
+ const parent = path.dirname(dir);
19
+ if (parent === dir)
20
+ break;
21
+ dir = parent;
22
+ }
23
+ return null;
24
+ }
25
+ function getVersion() {
26
+ try {
27
+ const pkg = require(path.join(__dirname, "../../package.json"));
28
+ return pkg.version;
29
+ }
30
+ catch {
31
+ return "unknown";
32
+ }
33
+ }
34
+ const HELP = `
35
+ Usage:
36
+ puls plan <file> Dry-run the stack - prints what would change, no API writes
37
+ puls deploy <file> Deploy the stack
38
+ puls destroy <file> Destroy the stack
39
+ puls diff <file> Compare declared intent against live cloud state
40
+ puls install-shell Add puls to your shell so you never need npx again
41
+ puls uninstall-shell Remove the puls shell integration
42
+
43
+ Options:
44
+ --parallel Enable parallel resource execution
45
+ --dry-run Force dry-run mode (alias: same as plan)
46
+ --fail-on-drift Exit with code 1 if drift is detected (diff command only)
47
+ --version Print version and exit
48
+ --help Print this help and exit
49
+
50
+ Examples:
51
+ npx puls install-shell # one-time setup — then just use "puls" directly
52
+ puls plan infra/staging.ts
53
+ puls deploy infra/staging.ts --parallel
54
+ puls destroy infra/staging.ts
55
+ puls diff infra/staging.ts --fail-on-drift
56
+ `.trim();
57
+ let parsed;
58
+ try {
59
+ parsed = parseArgs({
60
+ args: process.argv.slice(2),
61
+ options: {
62
+ parallel: { type: "boolean" },
63
+ "dry-run": { type: "boolean" },
64
+ "fail-on-drift": { type: "boolean" },
65
+ version: { type: "boolean", short: "v" },
66
+ help: { type: "boolean", short: "h" },
67
+ },
68
+ allowPositionals: true,
69
+ strict: true,
70
+ });
71
+ }
72
+ catch (err) {
73
+ console.error(`Error: ${err.message}`);
74
+ console.error('Run "puls --help" for usage.');
75
+ process.exit(1);
76
+ }
77
+ const { values, positionals } = parsed;
78
+ if (values.version) {
79
+ console.log(`puls v${getVersion()}`);
80
+ process.exit(0);
81
+ }
82
+ if (values.help || positionals.length === 0) {
83
+ console.log(HELP);
84
+ process.exit(0);
85
+ }
86
+ const [command, userFile] = positionals;
87
+ const COMMANDS = ["plan", "deploy", "destroy", "diff", "install-shell", "uninstall-shell"];
88
+ if (!COMMANDS.includes(command)) {
89
+ console.error(`Error: Unknown command "${command}". Run "puls --help" for usage.`);
90
+ process.exit(1);
91
+ }
92
+ // Shell management commands run directly — no stack file needed
93
+ if (command === "install-shell") {
94
+ installShell();
95
+ process.exit(0);
96
+ }
97
+ if (command === "uninstall-shell") {
98
+ uninstallShell();
99
+ process.exit(0);
100
+ }
101
+ if (!userFile) {
102
+ console.error(`Error: Missing file argument.\nUsage: puls ${command} <file>`);
103
+ process.exit(1);
104
+ }
105
+ const resolvedFile = path.resolve(process.cwd(), userFile);
106
+ if (!existsSync(resolvedFile)) {
107
+ console.error(`Error: File not found: ${resolvedFile}`);
108
+ process.exit(1);
109
+ }
110
+ const childEnv = { ...process.env };
111
+ if (command === "plan" || values["dry-run"]) {
112
+ childEnv.PULS_DRY_RUN = "true";
113
+ }
114
+ if (command === "destroy") {
115
+ childEnv.PULS_MODE = "destroy";
116
+ }
117
+ if (command === "diff") {
118
+ childEnv.PULS_MODE = "diff";
119
+ }
120
+ if (values.parallel) {
121
+ childEnv.PULS_PARALLEL = "true";
122
+ }
123
+ if (values["fail-on-drift"]) {
124
+ childEnv.PULS_FAIL_ON_DRIFT = "true";
125
+ }
126
+ const tsxBin = findTsx() ?? "tsx";
127
+ const child = spawn(tsxBin, [resolvedFile], {
128
+ stdio: "inherit",
129
+ env: childEnv,
130
+ });
131
+ child.on("error", (err) => {
132
+ if (err.code === "ENOENT") {
133
+ console.error("Error: Could not find tsx. Install it in your project:\n\n" +
134
+ " npm install --save-dev tsx\n\n" +
135
+ "or globally:\n\n" +
136
+ " npm install -g tsx");
137
+ }
138
+ else {
139
+ console.error(`Error spawning tsx: ${err.message}`);
140
+ }
141
+ process.exit(1);
142
+ });
143
+ child.on("close", (code) => {
144
+ process.exit(code ?? 1);
145
+ });
@@ -48,6 +48,13 @@ function renderProxmox(inv) {
48
48
  render: (v) => `${Math.round(v.maxdisk / 1024 ** 3)}GB`,
49
49
  },
50
50
  ]);
51
+ if (inv.templates.length > 0) {
52
+ printSection(`Proxmox Templates · ${inv.templates.length}`, inv.templates, [
53
+ { header: "Name", width: 32, render: (t) => t.name },
54
+ { header: "VMID", width: 6, render: (t) => String(t.vmid) },
55
+ { header: "Node", width: 12, render: (t) => t.node },
56
+ ]);
57
+ }
51
58
  }
52
59
  function renderDo(inv) {
53
60
  const costStr = inv.totalMonthlyCost > 0 ? ` · $${inv.totalMonthlyCost}/mo` : "";
@@ -83,8 +90,40 @@ function renderDo(inv) {
83
90
  { header: "TTL", width: 6, render: (d) => String(d.ttl) },
84
91
  ]);
85
92
  }
93
+ if (inv.databases.length > 0) {
94
+ printSection(`DigitalOcean Databases · ${inv.databases.length}`, inv.databases, [
95
+ { header: "Name", width: 24, render: (d) => d.name },
96
+ { header: "Engine", width: 14, render: (d) => d.engine },
97
+ { header: "Region", width: 8, render: (d) => d.region },
98
+ { header: "Status", width: 10, render: (d) => d.status },
99
+ { header: "Nodes", width: 5, render: (d) => String(d.nodeCount) },
100
+ ]);
101
+ }
102
+ if (inv.apps.length > 0) {
103
+ printSection(`DigitalOcean Apps · ${inv.apps.length}`, inv.apps, [
104
+ { header: "Name", width: 24, render: (a) => a.name },
105
+ { header: "Status", width: 12, render: (a) => a.status },
106
+ { header: "URL", width: 40, render: (a) => a.liveUrl || "-" },
107
+ ]);
108
+ }
109
+ if (inv.vpcs.length > 0) {
110
+ printSection(`DigitalOcean VPCs · ${inv.vpcs.length}`, inv.vpcs, [
111
+ { header: "Name", width: 24, render: (v) => v.name },
112
+ { header: "Region", width: 8, render: (v) => v.region },
113
+ { header: "IP Range", width: 20, render: (v) => v.ipRange },
114
+ ]);
115
+ }
86
116
  }
87
117
  function renderAws(inv) {
118
+ if (inv.ec2Instances.length > 0) {
119
+ printSection(`AWS EC2 · ${inv.ec2Instances.length} instances · ${inv.region}`, inv.ec2Instances, [
120
+ { header: "Name", width: 24, render: (i) => i.name },
121
+ { header: "ID", width: 20, render: (i) => i.id },
122
+ { header: "Type", width: 14, render: (i) => i.type },
123
+ { header: "State", width: 10, render: (i) => i.state },
124
+ { header: "IP", width: 15, render: (i) => i.publicIp ?? "-" },
125
+ ]);
126
+ }
88
127
  if (inv.distributions.length > 0) {
89
128
  printSection(`AWS CloudFront · ${inv.distributions.length} · ${inv.region}`, inv.distributions, [
90
129
  { header: "ID", width: 14, render: (d) => d.id },
@@ -154,6 +193,16 @@ function renderGcp(inv) {
154
193
  { header: "DNS Name", width: 32, render: (z) => z.dnsName },
155
194
  ]);
156
195
  }
196
+ if (inv.pubSubTopics.length > 0) {
197
+ printSection(`GCP Pub/Sub Topics · ${inv.pubSubTopics.length}`, inv.pubSubTopics, [
198
+ { header: "Topic", width: 52, render: (t) => t.name },
199
+ ]);
200
+ }
201
+ if (inv.secrets.length > 0) {
202
+ printSection(`GCP Secret Manager · ${inv.secrets.length}`, inv.secrets, [
203
+ { header: "Secret", width: 52, render: (s) => s.name },
204
+ ]);
205
+ }
157
206
  }
158
207
  function renderFirebase(inv) {
159
208
  if (inv.hostingSites.length > 0) {
@@ -169,6 +218,31 @@ function renderFirebase(inv) {
169
218
  { header: "Runtime", width: 10, render: (f) => f.runtime },
170
219
  ]);
171
220
  }
221
+ if (inv.firestoreDbs.length > 0) {
222
+ printSection(`Firebase Firestore · ${inv.firestoreDbs.length}`, inv.firestoreDbs, [
223
+ { header: "Database", width: 24, render: (d) => d.name },
224
+ { header: "Type", width: 20, render: (d) => d.type },
225
+ { header: "State", width: 10, render: (d) => d.state },
226
+ ]);
227
+ }
228
+ if (inv.storageBuckets.length > 0) {
229
+ printSection(`Firebase Storage · ${inv.storageBuckets.length}`, inv.storageBuckets, [
230
+ { header: "Bucket", width: 40, render: (b) => b.name },
231
+ { header: "Location", width: 12, render: (b) => b.location },
232
+ ]);
233
+ }
234
+ if (inv.authProviders.length > 0) {
235
+ printSection(`Firebase Auth · ${inv.authProviders.length} provider${inv.authProviders.length !== 1 ? "s" : ""}`, inv.authProviders, [
236
+ { header: "Provider", width: 32, render: (p) => p.providerId },
237
+ ]);
238
+ }
239
+ if (inv.remoteConfig) {
240
+ const rc = inv.remoteConfig;
241
+ printSection(`Firebase RemoteConfig · v${rc.version}`, [rc], [
242
+ { header: "Parameters", width: 10, render: (r) => String(r.parameterCount) },
243
+ { header: "Version", width: 12, render: (r) => r.version },
244
+ ]);
245
+ }
172
246
  }
173
247
  // ─── Checker ──────────────────────────────────────────────────────────────────
174
248
  export class Checker {
@@ -9,6 +9,7 @@ export interface GlobalConfig {
9
9
  defaultRegion?: string;
10
10
  spacesAccessKey?: string;
11
11
  spacesSecretKey?: string;
12
+ sshUser?: string;
12
13
  };
13
14
  aws?: {
14
15
  region: string;
@@ -23,6 +24,7 @@ export interface GlobalConfig {
23
24
  dnsDomain?: string;
24
25
  dnsServers?: string[];
25
26
  verifySsl?: boolean;
27
+ sshUser?: string;
26
28
  };
27
29
  firebase?: {
28
30
  projectId: string;
@@ -32,6 +34,7 @@ export interface GlobalConfig {
32
34
  projectId?: string;
33
35
  serviceAccountPath?: string;
34
36
  region?: string;
37
+ sshUser?: string;
35
38
  };
36
39
  };
37
40
  }
@@ -10,5 +10,6 @@ export interface ResourceContext {
10
10
  abortSignal?: AbortSignal;
11
11
  hosts?: HostEntry[];
12
12
  stackName?: string;
13
+ secrets: Set<string>;
13
14
  }
14
15
  export declare const resourceContextStorage: AsyncLocalStorage<ResourceContext>;
@@ -17,6 +17,7 @@ type ProviderOpts = {
17
17
  dnsDomain?: string;
18
18
  dnsServers?: string[];
19
19
  verifySsl?: boolean;
20
+ sshUser?: string;
20
21
  };
21
22
  };
22
23
  export declare function Protected(target: any, propertyKey: string): void;
@@ -23,6 +23,11 @@ function applyConfig(opts) {
23
23
  },
24
24
  });
25
25
  }
26
+ // CLI env-var overrides - applied last so `puls plan/destroy/--parallel` wins over decorator options
27
+ if (process.env.PULS_DRY_RUN === "true")
28
+ Config.set({ dryRun: true });
29
+ if (process.env.PULS_PARALLEL === "true")
30
+ Config.set({ parallel: true });
26
31
  }
27
32
  export function Protected(target, propertyKey) {
28
33
  Reflect.defineMetadata("protected", true, target, propertyKey);
@@ -73,15 +78,30 @@ export function Destroy(optsOrTarget, propertyKey) {
73
78
  export function Deploy(opts = {}) {
74
79
  return function (constructor) {
75
80
  const regions = opts.regions ?? [];
81
+ const mode = process.env.PULS_MODE;
76
82
  if (regions.length > 0) {
77
83
  Promise.resolve().then(async () => {
78
84
  for (const r of regions) {
79
- console.log(`\n🌍 [MULTI-REGION] Deploying stack to region: ${r}`);
85
+ const label = mode === "destroy" ? "Tearing down" : "Deploying";
86
+ console.log(`\n🌍 [MULTI-REGION] ${label} stack in region: ${r}`);
80
87
  applyConfig({ ...opts, region: r });
81
88
  const instance = new constructor();
82
89
  Stack._register(constructor, instance, r);
83
- if (typeof instance.deploy === "function") {
84
- await instance.deploy();
90
+ if (mode === "destroy") {
91
+ if (typeof instance.destroy === "function")
92
+ await instance.destroy();
93
+ }
94
+ else if (mode === "diff") {
95
+ if (typeof instance.diff === "function") {
96
+ const result = await instance.diff();
97
+ if (process.env.PULS_FAIL_ON_DRIFT === "true" && result?.hasDrift) {
98
+ process.exitCode = 1;
99
+ }
100
+ }
101
+ }
102
+ else {
103
+ if (typeof instance.deploy === "function")
104
+ await instance.deploy();
85
105
  }
86
106
  }
87
107
  });
@@ -91,8 +111,22 @@ export function Deploy(opts = {}) {
91
111
  const instance = new constructor();
92
112
  Stack._register(constructor, instance);
93
113
  Promise.resolve().then(async () => {
94
- if (typeof instance.deploy === "function")
95
- await instance.deploy();
114
+ if (mode === "destroy") {
115
+ if (typeof instance.destroy === "function")
116
+ await instance.destroy();
117
+ }
118
+ else if (mode === "diff") {
119
+ if (typeof instance.diff === "function") {
120
+ const result = await instance.diff();
121
+ if (process.env.PULS_FAIL_ON_DRIFT === "true" && result?.hasDrift) {
122
+ process.exitCode = 1;
123
+ }
124
+ }
125
+ }
126
+ else {
127
+ if (typeof instance.deploy === "function")
128
+ await instance.deploy();
129
+ }
96
130
  });
97
131
  }
98
132
  };
@@ -20,7 +20,14 @@ export class Output {
20
20
  // Transform this output into a new Output<U> without awaiting it yourself.
21
21
  apply(fn) {
22
22
  const out = new Output();
23
- this._promise.then(v => out.resolve(fn(v)), err => out.reject(err));
23
+ this._promise.then(v => {
24
+ try {
25
+ out.resolve(fn(v));
26
+ }
27
+ catch (e) {
28
+ out.reject(e);
29
+ }
30
+ }, err => out.reject(err));
24
31
  return out;
25
32
  }
26
33
  }
@@ -165,6 +165,7 @@ describe("Production Features Unit Tests", () => {
165
165
  test("Ansible Provisioner Stack-Wide Dynamic Inventory Generation", async () => {
166
166
  const context = {
167
167
  stackName: "my-test-stack",
168
+ secrets: new Set(),
168
169
  hosts: [
169
170
  { name: "web1", ip: "1.2.3.4", user: "root", sshKey: "/path/to/key", provider: "do" },
170
171
  { name: "db1", ip: "5.6.7.8", user: "ubuntu", sshKey: "/path/to/other-key", provider: "aws" }
@@ -1,12 +1,16 @@
1
+ import type { FieldDiff } from "../types/diff.js";
1
2
  export declare abstract class BaseBuilder {
2
3
  name: string;
3
4
  protected isProtected: boolean;
4
5
  protected localDryRun: boolean | null;
5
6
  protected discoveryPromise: Promise<any>;
6
7
  protected sidecars: BaseBuilder[];
8
+ protected _adoptedId: string | null;
7
9
  /** @internal */
8
10
  _deployPromise: Promise<any>;
9
11
  /** @internal */
12
+ _resolveDiscovery(): Promise<any>;
13
+ /** @internal */
10
14
  _destroyPromise?: Promise<any>;
11
15
  /** @internal */
12
16
  _dependencies: BaseBuilder[];
@@ -17,6 +21,37 @@ export declare abstract class BaseBuilder {
17
21
  constructor(name: string);
18
22
  dependsOn(resource: BaseBuilder): this;
19
23
  protect(): this;
24
+ /**
25
+ * Adopt an existing cloud resource by its provider ID, bringing it under
26
+ * Puls management without recreating it. If name-based discovery finds the
27
+ * resource, that result wins; adoptId only kicks in when discovery returns
28
+ * null (i.e. the resource was created outside this stack or with a different
29
+ * naming convention).
30
+ *
31
+ * Outputs that depend on live API response fields (e.g. `out.host`) won't be
32
+ * resolved automatically — chain `.adoptOutput(key, value)` for each one you
33
+ * need for cross-stack wiring.
34
+ */
35
+ adoptId(id: string): this;
36
+ /**
37
+ * Pre-resolve a named output on this builder. Use alongside `adoptId` to
38
+ * supply known connection details (host, port, uri, etc.) so downstream
39
+ * resources can reference them before this builder deploys.
40
+ *
41
+ * @example
42
+ * db = DO.Database("prod-db")
43
+ * .adoptId("abc123")
44
+ * .adoptOutput("host", "db.internal.example.com")
45
+ * .adoptOutput("uri", "postgres://...");
46
+ */
47
+ /**
48
+ * Returns field-level differences between declared intent and live cloud state.
49
+ * Called by `Stack.diff()` for each resource that exists. Override in provider
50
+ * builders to surface meaningful drift fields. The default returns an empty array
51
+ * (no field-level diff available).
52
+ */
53
+ getDiff(_existing: any): FieldDiff[];
54
+ adoptOutput(key: string, value: any): this;
20
55
  dryRun(enabled?: boolean): this;
21
56
  beforeDeploy(callback: () => Promise<void> | void): this;
22
57
  afterDeploy(callback: (result: any) => Promise<void> | void): this;
@@ -5,9 +5,14 @@ export class BaseBuilder {
5
5
  localDryRun = null;
6
6
  discoveryPromise;
7
7
  sidecars = [];
8
+ _adoptedId = null;
8
9
  /** @internal */
9
10
  _deployPromise;
10
11
  /** @internal */
12
+ async _resolveDiscovery() {
13
+ return this.discoveryPromise;
14
+ }
15
+ /** @internal */
11
16
  _destroyPromise;
12
17
  /** @internal */
13
18
  _dependencies = [];
@@ -26,6 +31,56 @@ export class BaseBuilder {
26
31
  this.isProtected = true;
27
32
  return this;
28
33
  }
34
+ /**
35
+ * Adopt an existing cloud resource by its provider ID, bringing it under
36
+ * Puls management without recreating it. If name-based discovery finds the
37
+ * resource, that result wins; adoptId only kicks in when discovery returns
38
+ * null (i.e. the resource was created outside this stack or with a different
39
+ * naming convention).
40
+ *
41
+ * Outputs that depend on live API response fields (e.g. `out.host`) won't be
42
+ * resolved automatically — chain `.adoptOutput(key, value)` for each one you
43
+ * need for cross-stack wiring.
44
+ */
45
+ adoptId(id) {
46
+ this._adoptedId = id;
47
+ const original = this.discoveryPromise;
48
+ this.discoveryPromise = original.then((found) => {
49
+ if (!found) {
50
+ this.out?.id?.resolve?.(id);
51
+ return { id, status: "adopted", _adopted: true };
52
+ }
53
+ return found;
54
+ });
55
+ return this;
56
+ }
57
+ /**
58
+ * Pre-resolve a named output on this builder. Use alongside `adoptId` to
59
+ * supply known connection details (host, port, uri, etc.) so downstream
60
+ * resources can reference them before this builder deploys.
61
+ *
62
+ * @example
63
+ * db = DO.Database("prod-db")
64
+ * .adoptId("abc123")
65
+ * .adoptOutput("host", "db.internal.example.com")
66
+ * .adoptOutput("uri", "postgres://...");
67
+ */
68
+ /**
69
+ * Returns field-level differences between declared intent and live cloud state.
70
+ * Called by `Stack.diff()` for each resource that exists. Override in provider
71
+ * builders to surface meaningful drift fields. The default returns an empty array
72
+ * (no field-level diff available).
73
+ */
74
+ getDiff(_existing) {
75
+ return [];
76
+ }
77
+ adoptOutput(key, value) {
78
+ const out = this.out;
79
+ if (typeof out?.[key]?.resolve === "function") {
80
+ out[key].resolve(value);
81
+ }
82
+ return this;
83
+ }
29
84
  dryRun(enabled = true) {
30
85
  this.localDryRun = enabled;
31
86
  return this;
@@ -123,7 +178,8 @@ export class BaseBuilder {
123
178
  }
124
179
  async destroy() {
125
180
  const dryRun = this.isDryRunActive();
126
- console.log(`\n🗑️ Destroying "${this.name}"...`);
181
+ const adoptedSuffix = this._adoptedId ? ` [adopted id=${this._adoptedId}]` : "";
182
+ console.log(`\n🗑️ Destroying "${this.name}"${adoptedSuffix}...`);
127
183
  console.log(` ✅ [${dryRun ? "PLAN" : "OK"}] Resource "${this.name}" marked for destruction.`);
128
184
  await this.destroySidecars();
129
185
  return { destroyed: this.name };
@@ -1,5 +1,6 @@
1
1
  import { Output } from "./output.js";
2
2
  export declare const resolvedSecrets: Set<string>;
3
+ export declare function clearResolvedSecrets(): void;
3
4
  /**
4
5
  * Secret represents a lazy, secure credential that is fetched asynchronously
5
6
  * at deployment time instead of during the eager construction phase.