puls-dev 0.3.4 → 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.
- 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
|
@@ -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();
|
package/dist/core/resource.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/resource.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/core/stack.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "reflect-metadata";
|
|
2
|
+
import type { StackDiff } from "../types/diff.js";
|
|
2
3
|
export declare abstract class Stack {
|
|
3
4
|
/** @internal - called by @Deploy to register the instance for cross-stack references. */
|
|
4
5
|
static _register(cls: Function, instance: Stack, region?: string): void;
|
|
@@ -16,6 +17,16 @@ export declare abstract class Stack {
|
|
|
16
17
|
* }
|
|
17
18
|
*/
|
|
18
19
|
static from<T extends Stack>(cls: new (...args: any[]) => T, region?: string): T;
|
|
20
|
+
/**
|
|
21
|
+
* Compares every declared resource against its live cloud state without
|
|
22
|
+
* making any API writes. Returns a structured `StackDiff` and prints a
|
|
23
|
+
* formatted report to the console.
|
|
24
|
+
*
|
|
25
|
+
* Field-level drift is surfaced for providers that implement `getDiff()`.
|
|
26
|
+
* Resources with no `getDiff()` override show only existence status
|
|
27
|
+
* (missing / in-sync / adopted).
|
|
28
|
+
*/
|
|
29
|
+
diff(): Promise<StackDiff>;
|
|
19
30
|
deploy(): Promise<Record<string, any>>;
|
|
20
31
|
destroy(): Promise<Record<string, any>>;
|
|
21
32
|
}
|
package/dist/core/stack.js
CHANGED
|
@@ -107,6 +107,45 @@ function printOutputs(stackName, outputs) {
|
|
|
107
107
|
}
|
|
108
108
|
console.log(` └${line}┘`);
|
|
109
109
|
}
|
|
110
|
+
function printDiff(diff) {
|
|
111
|
+
console.log(`\n🔍 Diff: ${diff.stackName}`);
|
|
112
|
+
const propWidth = Math.max(...diff.resources.map((r) => r.prop.length), 4);
|
|
113
|
+
const nameWidth = Math.max(...diff.resources.map((r) => r.resource.length), 8);
|
|
114
|
+
for (const r of diff.resources) {
|
|
115
|
+
const prop = r.prop.padEnd(propWidth);
|
|
116
|
+
const name = r.resource.padEnd(nameWidth);
|
|
117
|
+
if (r.status === "in-sync") {
|
|
118
|
+
console.log(` ${prop} ${name} ✅ in-sync`);
|
|
119
|
+
}
|
|
120
|
+
else if (r.status === "adopted") {
|
|
121
|
+
console.log(` ${prop} ${name} 🔗 adopted`);
|
|
122
|
+
}
|
|
123
|
+
else if (r.status === "missing") {
|
|
124
|
+
console.log(` ${prop} ${name} ❌ missing (will create)`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
console.log(` ${prop} ${name} ⚠️ drift`);
|
|
128
|
+
const fieldWidth = Math.max(...r.changes.map((c) => String(c.field).length), 8);
|
|
129
|
+
for (const c of r.changes) {
|
|
130
|
+
const field = String(c.field).padEnd(fieldWidth);
|
|
131
|
+
console.log(` └─ ${field} ${String(c.declared)} → ${c.live}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const driftCount = diff.resources.filter((r) => r.status === "drift").length;
|
|
136
|
+
const missingCount = diff.resources.filter((r) => r.status === "missing").length;
|
|
137
|
+
if (driftCount === 0 && missingCount === 0) {
|
|
138
|
+
console.log(`\n ✅ All ${diff.resources.length} resources are in sync.`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const parts = [];
|
|
142
|
+
if (driftCount > 0)
|
|
143
|
+
parts.push(`${driftCount} drifted`);
|
|
144
|
+
if (missingCount > 0)
|
|
145
|
+
parts.push(`${missingCount} missing`);
|
|
146
|
+
console.log(`\n ⚠️ ${parts.join(", ")} out of ${diff.resources.length} resources.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
110
149
|
export class Stack {
|
|
111
150
|
/** @internal - called by @Deploy to register the instance for cross-stack references. */
|
|
112
151
|
static _register(cls, instance, region) {
|
|
@@ -135,6 +174,54 @@ export class Stack {
|
|
|
135
174
|
throw new Error(`Stack "${cls.name}" ${region ? `for region "${region}" ` : ""}is not registered. Make sure it is decorated with @Deploy and its module is imported before referencing it.`);
|
|
136
175
|
return instance;
|
|
137
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Compares every declared resource against its live cloud state without
|
|
179
|
+
* making any API writes. Returns a structured `StackDiff` and prints a
|
|
180
|
+
* formatted report to the console.
|
|
181
|
+
*
|
|
182
|
+
* Field-level drift is surfaced for providers that implement `getDiff()`.
|
|
183
|
+
* Resources with no `getDiff()` override show only existence status
|
|
184
|
+
* (missing / in-sync / adopted).
|
|
185
|
+
*/
|
|
186
|
+
async diff() {
|
|
187
|
+
const props = Object.getOwnPropertyNames(this);
|
|
188
|
+
const entries = [];
|
|
189
|
+
for (const prop of props) {
|
|
190
|
+
const val = this[prop];
|
|
191
|
+
if (val instanceof BaseBuilder) {
|
|
192
|
+
entries.push({ prop, resource: val });
|
|
193
|
+
}
|
|
194
|
+
else if (Array.isArray(val)) {
|
|
195
|
+
for (const item of val) {
|
|
196
|
+
if (item instanceof BaseBuilder) {
|
|
197
|
+
entries.push({ prop, resource: item });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const resources = [];
|
|
203
|
+
for (const { prop, resource } of entries) {
|
|
204
|
+
const existing = await resource._resolveDiscovery();
|
|
205
|
+
let status;
|
|
206
|
+
let changes = resource.getDiff(existing ?? {});
|
|
207
|
+
if (!existing) {
|
|
208
|
+
status = "missing";
|
|
209
|
+
changes = [];
|
|
210
|
+
}
|
|
211
|
+
else if (existing._adopted === true) {
|
|
212
|
+
status = "adopted";
|
|
213
|
+
changes = [];
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
status = changes.length > 0 ? "drift" : "in-sync";
|
|
217
|
+
}
|
|
218
|
+
resources.push({ prop, resource: resource.name, status, changes });
|
|
219
|
+
}
|
|
220
|
+
const hasDrift = resources.some((r) => r.status === "drift" || r.status === "missing");
|
|
221
|
+
const result = { stackName: this.constructor.name, resources, hasDrift };
|
|
222
|
+
printDiff(result);
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
138
225
|
async deploy() {
|
|
139
226
|
const controller = new AbortController();
|
|
140
227
|
const hosts = [];
|
|
@@ -193,7 +280,7 @@ export class Stack {
|
|
|
193
280
|
resource._deployPromise = (async () => {
|
|
194
281
|
try {
|
|
195
282
|
// Yield so the map() loop finishes assigning all _deployPromise values before
|
|
196
|
-
// any task checks its dependencies
|
|
283
|
+
// any task checks its dependencies - a dependency that appears later in the list
|
|
197
284
|
// would otherwise have an undefined _deployPromise and be silently skipped.
|
|
198
285
|
await Promise.resolve();
|
|
199
286
|
if (controller.signal.aborted) {
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ export * from "./core/resource.js";
|
|
|
5
5
|
export { Secret, clearResolvedSecrets } from "./core/secret.js";
|
|
6
6
|
export { Output } from "./core/output.js";
|
|
7
7
|
export * as INVENTORY_TYPES from "./types/inventory.js";
|
|
8
|
+
export type { FieldDiff, ResourceDiff, StackDiff, ResourceStatus } from "./types/diff.js";
|
|
8
9
|
export { SLACK, DISCORD } from "./core/hooks.js";
|
|
@@ -60,6 +60,9 @@ function createAwsOfflineMock(command) {
|
|
|
60
60
|
get(target, prop) {
|
|
61
61
|
if (prop === "then")
|
|
62
62
|
return undefined;
|
|
63
|
+
// Pagination tokens must be undefined so discovery loops exit cleanly
|
|
64
|
+
if (prop === "NextToken" || prop === "NextMarker" || prop === "Marker" || prop === "ContinuationToken")
|
|
65
|
+
return undefined;
|
|
63
66
|
if (prop === "CertificateArn")
|
|
64
67
|
return "arn:aws:acm:us-east-1:123456789012:certificate/mock-cert-uuid";
|
|
65
68
|
if (prop === "HostedZoneId")
|
|
@@ -29,6 +29,11 @@ export declare class EC2VMBuilder extends BaseBuilder {
|
|
|
29
29
|
sshPrivateKey(path: string): this;
|
|
30
30
|
provision(...playbookPaths: (string | string[])[]): this;
|
|
31
31
|
forceConfigCheck(): this;
|
|
32
|
+
getDiff(existing: any): {
|
|
33
|
+
field: string;
|
|
34
|
+
declared: string;
|
|
35
|
+
live: any;
|
|
36
|
+
}[];
|
|
32
37
|
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
33
38
|
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
34
39
|
private discoverVM;
|