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.
- package/README.md +165 -54
- package/dist/bin/install-shell.d.ts +2 -0
- package/dist/bin/install-shell.js +136 -0
- package/dist/bin/puls.js +32 -10
- package/dist/core/checker.js +74 -0
- package/dist/core/decorators.js +17 -1
- package/dist/core/resource.d.ts +35 -0
- package/dist/core/resource.js +57 -1
- package/dist/core/stack.d.ts +11 -0
- package/dist/core/stack.js +88 -1
- package/dist/index.d.ts +1 -0
- package/dist/providers/aws/api.js +3 -0
- package/dist/providers/aws/ec2.d.ts +5 -0
- package/dist/providers/aws/ec2.js +7 -0
- package/dist/providers/aws/lambda.d.ts +5 -0
- package/dist/providers/aws/lambda.js +24 -0
- package/dist/providers/aws/list.js +15 -3
- package/dist/providers/aws/rds.d.ts +9 -0
- package/dist/providers/aws/rds.js +19 -0
- package/dist/providers/do/database.d.ts +9 -0
- package/dist/providers/do/database.js +19 -0
- package/dist/providers/do/domain.js +1 -1
- package/dist/providers/do/droplet.d.ts +5 -0
- package/dist/providers/do/droplet.js +10 -0
- package/dist/providers/do/list.js +25 -2
- package/dist/providers/do/load_balancer.d.ts +5 -0
- package/dist/providers/do/load_balancer.js +7 -0
- package/dist/providers/do/vpc.d.ts +5 -0
- package/dist/providers/do/vpc.js +8 -0
- package/dist/providers/firebase/functions.d.ts +9 -0
- package/dist/providers/firebase/functions.js +28 -0
- package/dist/providers/firebase/list.js +34 -2
- package/dist/providers/gcp/api.js +6 -0
- package/dist/providers/gcp/cloudrun.d.ts +13 -0
- package/dist/providers/gcp/cloudrun.js +30 -0
- package/dist/providers/gcp/cloudsql.d.ts +9 -0
- package/dist/providers/gcp/cloudsql.js +20 -0
- package/dist/providers/gcp/list.js +12 -2
- package/dist/providers/gcp/vm.d.ts +5 -0
- package/dist/providers/gcp/vm.js +8 -0
- package/dist/providers/proxmox/list.js +8 -1
- package/dist/providers/proxmox/vm.d.ts +13 -0
- package/dist/providers/proxmox/vm.js +16 -0
- package/dist/types/diff.d.ts +17 -0
- package/dist/types/diff.js +1 -0
- package/dist/types/inventory.d.ts +65 -0
- 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
|
|
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
|
|
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("
|
|
14
|
+
server = Proxmox.VM("ix-app01")
|
|
16
15
|
.image(OS.UBUNTU_24_04)
|
|
17
16
|
.cores(4).memory(8192)
|
|
18
|
-
.ip("
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
108
|
+
import { AWS, REGION, RUNTIME, DB } from "puls-dev/aws";
|
|
75
109
|
|
|
76
|
-
@Deploy({ region: REGION.
|
|
77
|
-
class
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
120
|
+
```typescript
|
|
121
|
+
import "dotenv/config";
|
|
122
|
+
import { Stack, Deploy } from "puls-dev";
|
|
123
|
+
import { GCP } from "puls-dev/gcp";
|
|
83
124
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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-
|
|
100
|
-
|
|
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-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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`
|
|
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
|
-
##
|
|
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
|
|
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=
|
|
262
|
+
PROXMOX_TOKEN_SECRET=
|
|
155
263
|
PROXMOX_NODES=pve1,pve2
|
|
156
|
-
|
|
157
|
-
|
|
264
|
+
|
|
265
|
+
# GCP / Firebase
|
|
266
|
+
GCP_SA=./service-account.json
|
|
158
267
|
```
|
|
268
|
+
|
|
269
|
+
Requires Node 20+.
|
|
@@ -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
|
|
36
|
-
puls deploy
|
|
37
|
-
puls destroy
|
|
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
|
|
41
|
-
--dry-run
|
|
42
|
-
--
|
|
43
|
-
--
|
|
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}".
|
|
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",
|
package/dist/core/checker.js
CHANGED
|
@@ -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 {
|
package/dist/core/decorators.js
CHANGED
|
@@ -23,7 +23,7 @@ function applyConfig(opts) {
|
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
-
// CLI env-var overrides
|
|
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();
|