puls-dev 0.3.4 → 0.3.6

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 (47) hide show
  1. package/README.md +165 -54
  2. package/dist/bin/install-shell.d.ts +2 -0
  3. package/dist/bin/install-shell.js +136 -0
  4. package/dist/bin/puls.js +32 -10
  5. package/dist/core/checker.js +74 -0
  6. package/dist/core/decorators.js +17 -1
  7. package/dist/core/resource.d.ts +35 -0
  8. package/dist/core/resource.js +57 -1
  9. package/dist/core/stack.d.ts +11 -0
  10. package/dist/core/stack.js +88 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/providers/aws/api.js +3 -0
  13. package/dist/providers/aws/ec2.d.ts +5 -0
  14. package/dist/providers/aws/ec2.js +7 -0
  15. package/dist/providers/aws/lambda.d.ts +5 -0
  16. package/dist/providers/aws/lambda.js +24 -0
  17. package/dist/providers/aws/list.js +15 -3
  18. package/dist/providers/aws/rds.d.ts +9 -0
  19. package/dist/providers/aws/rds.js +19 -0
  20. package/dist/providers/do/database.d.ts +9 -0
  21. package/dist/providers/do/database.js +19 -0
  22. package/dist/providers/do/domain.js +1 -1
  23. package/dist/providers/do/droplet.d.ts +5 -0
  24. package/dist/providers/do/droplet.js +10 -0
  25. package/dist/providers/do/list.js +25 -2
  26. package/dist/providers/do/load_balancer.d.ts +5 -0
  27. package/dist/providers/do/load_balancer.js +7 -0
  28. package/dist/providers/do/vpc.d.ts +5 -0
  29. package/dist/providers/do/vpc.js +8 -0
  30. package/dist/providers/firebase/functions.d.ts +9 -0
  31. package/dist/providers/firebase/functions.js +28 -0
  32. package/dist/providers/firebase/list.js +34 -2
  33. package/dist/providers/gcp/api.js +6 -0
  34. package/dist/providers/gcp/cloudrun.d.ts +13 -0
  35. package/dist/providers/gcp/cloudrun.js +30 -0
  36. package/dist/providers/gcp/cloudsql.d.ts +9 -0
  37. package/dist/providers/gcp/cloudsql.js +20 -0
  38. package/dist/providers/gcp/list.js +12 -2
  39. package/dist/providers/gcp/vm.d.ts +5 -0
  40. package/dist/providers/gcp/vm.js +8 -0
  41. package/dist/providers/proxmox/list.js +8 -1
  42. package/dist/providers/proxmox/vm.d.ts +13 -0
  43. package/dist/providers/proxmox/vm.js +16 -0
  44. package/dist/types/diff.d.ts +17 -0
  45. package/dist/types/diff.js +1 -0
  46. package/dist/types/inventory.d.ts +65 -0
  47. package/package.json +2 -2
package/README.md CHANGED
@@ -1,27 +1,26 @@
1
1
  # Pulsdev.io
2
2
 
3
- **Intent-driven infrastructure-as-code. Describe what you want - Puls figures out create, update, or skip.**
3
+ **Intent-driven infrastructure-as-code. Describe what you want Puls figures out create, update, or skip.**
4
4
 
5
- [Live Documentation](https://pulsdev.io/) | Matrix|Gitter: **pulsdev.io** ([Join](https://matrix.to/#/#pulsdevio:gitter.im))
5
+ [Live Documentation](https://pulsdev.io/) | [GitHub Actions](docs/github-actions.md) | Matrix|Gitter: **pulsdev.io** ([Join](https://matrix.to/#/#pulsdevio:gitter.im))
6
6
 
7
7
  > [!IMPORTANT]
8
8
  > **Active Pre-1.0 Development**
9
- > `pulsdev.io` is currently undergoing **active development**. While the framework features exhaustive 100% test coverage and strict production safety locks (such as dry-run planning and protected resource decorators), APIs and features are actively evolving.
10
- > We are aggressively rolling out new resources and provider integrations. We welcome your feedback, bug reports, and contributions!
9
+ > `pulsdev.io` is under active development. APIs and features are evolving we welcome feedback, bug reports, and contributions!
11
10
 
12
11
  ```typescript
13
12
  @Deploy({ proxmox: CONFIG.STAGING })
14
13
  class GameInfra extends Stack {
15
- server = Proxmox.VM("example-vm")
14
+ server = Proxmox.VM("ix-app01")
16
15
  .image(OS.UBUNTU_24_04)
17
16
  .cores(4).memory(8192)
18
- .ip("1.1.1.1").vlan(2010)
17
+ .ip("10.8.10.51").vlan(2010)
19
18
  .sshKey(KEYS)
20
19
  .provision("config/default.yaml");
21
20
  }
22
21
  ```
23
22
 
24
- No state files. No plan step. Runs against real APIs - idempotent by default.
23
+ No state files. No plan step. Runs against real APIs idempotent by default.
25
24
 
26
25
  ---
27
26
 
@@ -35,20 +34,52 @@ Declare resource → Discovery fires immediately (async)
35
34
  → deploy() awaits discovery, diffs, acts
36
35
  ```
37
36
 
38
- Running the same stack twice is always safe - existing resources are detected and skipped.
37
+ Running the same stack twice is always safe existing resources are detected and skipped or updated in place.
39
38
 
40
39
  ---
41
40
 
42
- ## Providers
41
+ ## Install
42
+
43
+ ```bash
44
+ npm install puls-dev
45
+ ```
46
+
47
+ **One-time shell setup** — so you never have to type `npx puls` again:
48
+
49
+ ```bash
50
+ npx puls install-shell
51
+ ```
52
+
53
+ This adds a `puls` launcher to `~/.puls/bin` and wires it into your shell config (`~/.zshrc`, `~/.bashrc`, or Fish). Open a new terminal and `puls` works everywhere.
43
54
 
44
- | Provider | Resources | Status |
45
- |----------|-----------|--------|
46
- | [Google Cloud Platform (GCP)](docs/providers/gcp.md) | Cloud Run, Cloud SQL, Secret Manager, Pub/Sub, Cloud DNS, IAM (Service Accounts & Bindings) | **Completed** |
47
- | [AWS](docs/providers/aws.md) | Route53, ACM (wildcard SSL), CloudFront, S3 | **Completed** |
48
- | [Firebase](docs/providers/firebase.md) | Hosting, Functions, Firestore (Indexes & Rules), Storage (Rules/CORS), Auth, Remote Config, App Check | **Completed** |
49
- | [DigitalOcean](docs/providers/digitalocean.md) | Droplet, Domain, Firewall, Certificate, LoadBalancer | **Completed** |
50
- | [Proxmox](docs/providers/proxmox.md) | VM (clone, cloud-init, provision, cluster-aware node selection, replace) | **Completed** |
55
+ ---
56
+
57
+ ## CLI
58
+
59
+ ```bash
60
+ puls plan infra/stack.ts # dry-run prints what would change, no API writes
61
+ puls deploy infra/stack.ts # apply the stack
62
+ puls destroy infra/stack.ts # tear down the stack
63
+ puls diff infra/stack.ts # compare declared intent vs live cloud state
64
+ puls diff infra/stack.ts --fail-on-drift # exit 1 if anything has drifted
65
+
66
+ puls install-shell # one-time shell setup
67
+ puls uninstall-shell # remove shell integration
68
+ ```
69
+
70
+ Always run `plan` before `deploy` — it activates dry-run mode automatically.
71
+
72
+ ---
73
+
74
+ ## Providers
51
75
 
76
+ | Provider | Resources |
77
+ |----------|-----------|
78
+ | **AWS** | EC2, RDS, Lambda, ECS/Fargate, API Gateway, S3, CloudFront, Route53, ACM, SQS, SNS, IAM, CloudWatch, SecretsManager |
79
+ | **DigitalOcean** | Droplet, Domain (full DNS), Firewall, Certificate, LoadBalancer, Database, App Platform, VPC, Spaces |
80
+ | **GCP** | Compute VM, Cloud Run, Cloud SQL, Secret Manager, Pub/Sub, Cloud DNS, IAM |
81
+ | **Firebase** | Hosting, Functions, Firestore, Storage, Auth, RemoteConfig, App Check |
82
+ | **Proxmox** | VM (clone, cloud-init, provision, cluster-aware scheduling), Templates (golden images) |
52
83
 
53
84
  ---
54
85
 
@@ -57,11 +88,13 @@ Running the same stack twice is always safe - existing resources are detected an
57
88
  ### DigitalOcean
58
89
 
59
90
  ```typescript
91
+ import "dotenv/config";
60
92
  import { Stack, Deploy } from "puls-dev";
61
93
  import { DO, SIZE, REGION } from "puls-dev/do";
62
94
 
63
95
  @Deploy({ token: process.env.DO_TOKEN! })
64
96
  class Production extends Stack {
97
+ db = DO.Database("prod-db").engine("pg").size("db-s-2vcpu-2gb").nodes(2);
65
98
  web = DO.Droplet("prod-web").size(SIZE.MEDIUM).region(REGION.FRA).allowPublicWeb();
66
99
  dns = DO.Domain("example.com").pointer("@", this.web).withSSL();
67
100
  }
@@ -70,47 +103,132 @@ class Production extends Stack {
70
103
  ### AWS
71
104
 
72
105
  ```typescript
106
+ import "dotenv/config";
73
107
  import { Stack, Deploy } from "puls-dev";
74
- import { AWS, DISTRO, BUCKET, DOMAIN_REGISTER, REGION } from "puls-dev/aws";
108
+ import { AWS, REGION, RUNTIME, DB } from "puls-dev/aws";
75
109
 
76
- @Deploy({ region: REGION.US_EAST_1 })
77
- class CDNStack extends Stack {
78
- domain = AWS.Route53().randomDomain().register(DOMAIN_REGISTER).withWildcardSSL();
110
+ @Deploy({ region: REGION.EU_CENTRAL_1 })
111
+ class AppStack extends Stack {
112
+ db = AWS.RDS("app-db").engine(DB.POSTGRES_16).size("db.t3.micro");
113
+ api = AWS.Lambda("app-api").code("./functions/api").runtime(RUNTIME.NODEJS_20);
114
+ cdn = AWS.S3("app-assets").staticSite().allowFrom(this.api);
115
+ }
116
+ ```
117
+
118
+ ### GCP
79
119
 
80
- cdn = AWS.CloudFront(`CDN-${this.domain.zoneName.slice(0, 8)}`)
81
- .copyFrom(DISTRO.CDN)
82
- .forDomain(this.domain, ["ec", "nc"]);
120
+ ```typescript
121
+ import "dotenv/config";
122
+ import { Stack, Deploy } from "puls-dev";
123
+ import { GCP } from "puls-dev/gcp";
83
124
 
84
- bucket = AWS.S3(BUCKET.NLC_GAMES_UREG)
85
- .allowFrom(this.cdn)
86
- .region(REGION.EU_WEST_1);
125
+ @Deploy({})
126
+ class CloudStack extends Stack {
127
+ secret = GCP.Secret("db-password").value(process.env.DB_PASS!);
128
+ api = GCP.CloudRun("app-api").image("gcr.io/my-project/api:latest").port(8080).public();
129
+ db = GCP.CloudSQL("app-db").engine("postgres").version("16").tier("db-f1-micro");
87
130
  }
88
131
  ```
89
132
 
90
133
  ### Proxmox
91
134
 
92
135
  ```typescript
136
+ import "dotenv/config";
93
137
  import { Stack, Deploy, Protected } from "puls-dev";
94
138
  import { Proxmox, CONFIG, OS, KEYS } from "puls-dev/proxmox";
95
139
 
96
140
  @Deploy({ proxmox: CONFIG.STAGING })
97
141
  class StagingInfra extends Stack {
98
142
  @Protected
99
- db = Proxmox.VM("ix-sto1-db01")
100
- .image(OS.UBUNTU_24_04)
101
- .cores(2).memory(4096)
102
- .ip("1.1.1.1").vlan(2010)
103
- .sshKey(KEYS);
143
+ db = Proxmox.VM("ix-db01").image(OS.UBUNTU_24_04).cores(2).memory(4096)
144
+ .ip("10.8.10.50").vlan(2010).sshKey(KEYS);
104
145
 
105
- app = Proxmox.VM("ix-sto1-app01")
106
- .image(OS.UBUNTU_24_04)
107
- .cores(4).memory(8192)
108
- .ip("1.1.1.1").vlan(2010)
109
- .sshKey(KEYS)
110
- .provision("config/default.yaml");
146
+ app = Proxmox.VM("ix-app01").image(OS.UBUNTU_24_04).cores(4).memory(8192)
147
+ .ip("10.8.10.51").vlan(2010).sshKey(KEYS)
148
+ .provision("config/default.yaml");
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Key features
155
+
156
+ ### Drift detection
157
+
158
+ `Stack.diff()` compares every declared resource against its live cloud state — no API writes, structured output:
159
+
160
+ ```bash
161
+ puls diff infra/production.ts
162
+ ```
163
+
164
+ ```
165
+ 🔍 Diff: Production
166
+
167
+ db prod-db ⚠️ drift
168
+ └─ size db-s-1vcpu-1gb → db-s-2vcpu-2gb
169
+ └─ nodes 1 → 2
170
+ web prod-web ✅ in-sync
171
+ dns example.com ✅ in-sync
172
+
173
+ ⚠️ 1 drifted out of 3 resources.
174
+ ```
175
+
176
+ ### Resource adoption
177
+
178
+ Bring existing cloud infrastructure under Puls management without recreating it:
179
+
180
+ ```typescript
181
+ db = DO.Database("prod-db")
182
+ .adoptId("existing-cluster-uuid")
183
+ .adoptOutput("host", "db.internal.example.com")
184
+ .adoptOutput("uri", "postgres://...");
185
+ ```
186
+
187
+ ### GitHub Actions integration
188
+
189
+ Post plan output as a PR comment automatically. Add to your repo:
190
+
191
+ ```yaml
192
+ # .github/workflows/puls-plan.yml
193
+ - uses: puls-dev/puls-dev@v1
194
+ with:
195
+ command: plan
196
+ stack-file: infra/production.ts
197
+ env:
198
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
199
+ DO_TOKEN: ${{ secrets.DO_TOKEN }}
200
+ ```
201
+
202
+ Every PR that touches infra files gets a comment showing exactly what would change. See [docs/github-actions.md](docs/github-actions.md) for deploy and drift-check workflows.
203
+
204
+ ### Stack outputs & cross-stack wiring
205
+
206
+ ```typescript
207
+ @Deploy({ proxmox: CONFIG.STAGING, token: process.env.DO_TOKEN })
208
+ class Infra extends Stack {
209
+ vm = Proxmox.VM("ix-app01").cores(4).memory(8192).ip("10.8.10.51").vlan(2010);
210
+ dns = DO.Domain("example.com").pointer("app", this.vm.out.ip); // Output<string>
111
211
  }
112
212
  ```
113
213
 
214
+ Outputs resolve lazily — downstream resources unblock the moment their dependency finishes deploying.
215
+
216
+ ### Dry run / plan
217
+
218
+ ```typescript
219
+ @Deploy({ dryRun: true, proxmox: CONFIG.STAGING })
220
+ class MyStack extends Stack { ... }
221
+ ```
222
+
223
+ Or via the CLI: `puls plan infra/stack.ts` — no config change required.
224
+
225
+ ### Protected resources
226
+
227
+ ```typescript
228
+ @Protected
229
+ db = Proxmox.VM("ix-db01")...; // Puls will refuse to modify or destroy this
230
+ ```
231
+
114
232
  ---
115
233
 
116
234
  ## Decorators
@@ -120,29 +238,19 @@ class StagingInfra extends Stack {
120
238
  | `@Deploy({ ... })` | Deploy all resources in the stack |
121
239
  | `@Deploy({ dryRun: true })` | Print plan without making changes |
122
240
  | `@Destroy` | Tear down all resources in the stack |
123
- | `@Destroy({ proxmox: CONFIG.STAGING })` | Tear down with provider credentials |
124
241
  | `@DryRun` | Shorthand for `@Deploy({ dryRun: true })` |
125
- | `@Protected` (property) | Block changes/destruction of that resource |
126
-
127
- See [docs/decorators.md](docs/decorators.md) for full reference.
242
+ | `@Protected` | Block changes/destruction of that resource |
243
+ | `@Check` | Inventory query — lists all live resources across providers |
128
244
 
129
245
  ---
130
246
 
131
- ## Running
247
+ ## .env
132
248
 
133
249
  ```bash
134
- npm install puls-dev
135
- npx tsx your-stack.ts
136
- ```
137
-
138
- Requires Node 20+.
139
-
140
- **.env**
141
- ```
142
250
  # DigitalOcean
143
251
  DO_TOKEN=
144
252
 
145
- # AWS (standard SDK env vars)
253
+ # AWS
146
254
  AWS_ACCESS_KEY_ID=
147
255
  AWS_SECRET_ACCESS_KEY=
148
256
  AWS_REGION=us-east-1
@@ -151,8 +259,11 @@ AWS_REGION=us-east-1
151
259
  PROXMOX_URL=https://pve.example.com:8006
152
260
  PROXMOX_USER=root@pam
153
261
  PROXMOX_TOKEN_NAME=puls
154
- PROXMOX_TOKEN_SECRET=some-super-secret
262
+ PROXMOX_TOKEN_SECRET=
155
263
  PROXMOX_NODES=pve1,pve2
156
- PROXMOX_DNS_DOMAIN=nolimit.int
157
- PROXMOX_DNS_SERVERS=1.1.1.1,2.2.2.2
264
+
265
+ # GCP / Firebase
266
+ GCP_SA=./service-account.json
158
267
  ```
268
+
269
+ Requires Node 20+.
@@ -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
+ }
package/dist/bin/puls.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createRequire } from "node:module";
8
+ import { installShell, uninstallShell } from "./install-shell.js";
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
10
11
  const require = createRequire(import.meta.url);
@@ -32,20 +33,26 @@ function getVersion() {
32
33
  }
33
34
  const HELP = `
34
35
  Usage:
35
- puls plan <file> Dry-run the stack prints what would change, no API writes
36
- puls deploy <file> Deploy the stack
37
- puls destroy <file> Destroy the stack
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
38
42
 
39
43
  Options:
40
- --parallel Enable parallel resource execution
41
- --dry-run Force dry-run mode (alias: same as plan)
42
- --version Print version and exit
43
- --help Print this help and exit
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
44
49
 
45
50
  Examples:
51
+ npx puls install-shell # one-time setup — then just use "puls" directly
46
52
  puls plan infra/staging.ts
47
53
  puls deploy infra/staging.ts --parallel
48
54
  puls destroy infra/staging.ts
55
+ puls diff infra/staging.ts --fail-on-drift
49
56
  `.trim();
50
57
  let parsed;
51
58
  try {
@@ -54,6 +61,7 @@ try {
54
61
  options: {
55
62
  parallel: { type: "boolean" },
56
63
  "dry-run": { type: "boolean" },
64
+ "fail-on-drift": { type: "boolean" },
57
65
  version: { type: "boolean", short: "v" },
58
66
  help: { type: "boolean", short: "h" },
59
67
  },
@@ -76,12 +84,20 @@ if (values.help || positionals.length === 0) {
76
84
  process.exit(0);
77
85
  }
78
86
  const [command, userFile] = positionals;
79
- const COMMANDS = ["plan", "deploy", "destroy"];
87
+ const COMMANDS = ["plan", "deploy", "destroy", "diff", "install-shell", "uninstall-shell"];
80
88
  if (!COMMANDS.includes(command)) {
81
- console.error(`Error: Unknown command "${command}". Expected: plan, deploy, or destroy.`);
82
- console.error('Run "puls --help" for usage.');
89
+ console.error(`Error: Unknown command "${command}". Run "puls --help" for usage.`);
83
90
  process.exit(1);
84
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
+ }
85
101
  if (!userFile) {
86
102
  console.error(`Error: Missing file argument.\nUsage: puls ${command} <file>`);
87
103
  process.exit(1);
@@ -98,9 +114,15 @@ if (command === "plan" || values["dry-run"]) {
98
114
  if (command === "destroy") {
99
115
  childEnv.PULS_MODE = "destroy";
100
116
  }
117
+ if (command === "diff") {
118
+ childEnv.PULS_MODE = "diff";
119
+ }
101
120
  if (values.parallel) {
102
121
  childEnv.PULS_PARALLEL = "true";
103
122
  }
123
+ if (values["fail-on-drift"]) {
124
+ childEnv.PULS_FAIL_ON_DRIFT = "true";
125
+ }
104
126
  const tsxBin = findTsx() ?? "tsx";
105
127
  const child = spawn(tsxBin, [resolvedFile], {
106
128
  stdio: "inherit",
@@ -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 {
@@ -23,7 +23,7 @@ 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
26
+ // CLI env-var overrides - applied last so `puls plan/destroy/--parallel` wins over decorator options
27
27
  if (process.env.PULS_DRY_RUN === "true")
28
28
  Config.set({ dryRun: true });
29
29
  if (process.env.PULS_PARALLEL === "true")
@@ -91,6 +91,14 @@ export function Deploy(opts = {}) {
91
91
  if (typeof instance.destroy === "function")
92
92
  await instance.destroy();
93
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
+ }
94
102
  else {
95
103
  if (typeof instance.deploy === "function")
96
104
  await instance.deploy();
@@ -107,6 +115,14 @@ export function Deploy(opts = {}) {
107
115
  if (typeof instance.destroy === "function")
108
116
  await instance.destroy();
109
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
+ }
110
126
  else {
111
127
  if (typeof instance.deploy === "function")
112
128
  await instance.deploy();