puls-dev 0.2.7 → 0.2.9

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 (117) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +6 -0
  3. package/dist/core/config.js +11 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +4 -0
  7. package/dist/core/decorators.js +56 -30
  8. package/dist/core/hooks.d.ts +21 -0
  9. package/dist/core/hooks.js +116 -0
  10. package/dist/core/hooks.test.d.ts +1 -0
  11. package/dist/core/hooks.test.js +194 -0
  12. package/dist/core/multiregion.test.d.ts +1 -0
  13. package/dist/core/multiregion.test.js +87 -0
  14. package/dist/core/output.d.ts +2 -0
  15. package/dist/core/output.js +9 -2
  16. package/dist/core/parallel.test.d.ts +1 -0
  17. package/dist/core/parallel.test.js +215 -0
  18. package/dist/core/parser.d.ts +10 -0
  19. package/dist/core/parser.js +140 -0
  20. package/dist/core/parser.test.d.ts +1 -0
  21. package/dist/core/parser.test.js +117 -0
  22. package/dist/core/production.test.d.ts +1 -0
  23. package/dist/core/production.test.js +189 -0
  24. package/dist/core/provisioner.d.ts +4 -0
  25. package/dist/core/provisioner.js +123 -0
  26. package/dist/core/resource.d.ts +23 -0
  27. package/dist/core/resource.js +54 -0
  28. package/dist/core/retry.d.ts +9 -0
  29. package/dist/core/retry.js +28 -0
  30. package/dist/core/retry.test.d.ts +1 -0
  31. package/dist/core/retry.test.js +66 -0
  32. package/dist/core/secret.d.ts +41 -0
  33. package/dist/core/secret.js +105 -0
  34. package/dist/core/secret.test.d.ts +1 -0
  35. package/dist/core/secret.test.js +166 -0
  36. package/dist/core/stack.d.ts +4 -3
  37. package/dist/core/stack.js +322 -48
  38. package/dist/index.d.ts +3 -0
  39. package/dist/index.js +3 -0
  40. package/dist/providers/aws/api.js +97 -17
  41. package/dist/providers/aws/ec2.d.ts +51 -0
  42. package/dist/providers/aws/ec2.js +331 -0
  43. package/dist/providers/aws/ec2.test.d.ts +1 -0
  44. package/dist/providers/aws/ec2.test.js +281 -0
  45. package/dist/providers/aws/index.d.ts +4 -0
  46. package/dist/providers/aws/index.js +4 -0
  47. package/dist/providers/aws/route53.d.ts +1 -0
  48. package/dist/providers/aws/route53.js +15 -2
  49. package/dist/providers/aws/route53.test.js +47 -0
  50. package/dist/providers/aws/template.d.ts +34 -0
  51. package/dist/providers/aws/template.js +252 -0
  52. package/dist/providers/aws/template.test.d.ts +1 -0
  53. package/dist/providers/aws/template.test.js +208 -0
  54. package/dist/providers/do/api.d.ts +3 -1
  55. package/dist/providers/do/api.js +126 -27
  56. package/dist/providers/do/app.d.ts +26 -0
  57. package/dist/providers/do/app.js +124 -0
  58. package/dist/providers/do/app.test.d.ts +1 -0
  59. package/dist/providers/do/app.test.js +268 -0
  60. package/dist/providers/do/database.d.ts +44 -0
  61. package/dist/providers/do/database.js +208 -0
  62. package/dist/providers/do/database.test.d.ts +1 -0
  63. package/dist/providers/do/database.test.js +293 -0
  64. package/dist/providers/do/domain.d.ts +2 -0
  65. package/dist/providers/do/domain.js +30 -0
  66. package/dist/providers/do/domain.test.js +49 -0
  67. package/dist/providers/do/droplet.d.ts +9 -0
  68. package/dist/providers/do/droplet.js +146 -8
  69. package/dist/providers/do/droplet.test.js +228 -1
  70. package/dist/providers/do/firewall.d.ts +2 -1
  71. package/dist/providers/do/firewall.js +23 -9
  72. package/dist/providers/do/firewall.test.js +54 -0
  73. package/dist/providers/do/index.d.ts +11 -0
  74. package/dist/providers/do/index.js +8 -0
  75. package/dist/providers/do/spaces.d.ts +27 -0
  76. package/dist/providers/do/spaces.js +142 -0
  77. package/dist/providers/do/spaces.test.d.ts +1 -0
  78. package/dist/providers/do/spaces.test.js +180 -0
  79. package/dist/providers/do/spaces_api.d.ts +2 -0
  80. package/dist/providers/do/spaces_api.js +20 -0
  81. package/dist/providers/do/vpc.d.ts +30 -0
  82. package/dist/providers/do/vpc.js +128 -0
  83. package/dist/providers/do/vpc.test.d.ts +1 -0
  84. package/dist/providers/do/vpc.test.js +258 -0
  85. package/dist/providers/firebase/api.js +92 -29
  86. package/dist/providers/firebase/list.d.ts +2 -0
  87. package/dist/providers/firebase/list.js +25 -0
  88. package/dist/providers/gcp/api.js +88 -14
  89. package/dist/providers/gcp/clouddns.d.ts +1 -0
  90. package/dist/providers/gcp/clouddns.js +15 -2
  91. package/dist/providers/gcp/clouddns.test.js +45 -0
  92. package/dist/providers/gcp/index.d.ts +5 -1
  93. package/dist/providers/gcp/index.js +5 -1
  94. package/dist/providers/gcp/list.d.ts +2 -0
  95. package/dist/providers/gcp/list.js +55 -0
  96. package/dist/providers/gcp/secrets.js +1 -1
  97. package/dist/providers/gcp/template.d.ts +32 -0
  98. package/dist/providers/gcp/template.js +252 -0
  99. package/dist/providers/gcp/template.test.d.ts +1 -0
  100. package/dist/providers/gcp/template.test.js +227 -0
  101. package/dist/providers/gcp/vm.d.ts +48 -0
  102. package/dist/providers/gcp/vm.js +375 -0
  103. package/dist/providers/gcp/vm.test.d.ts +1 -0
  104. package/dist/providers/gcp/vm.test.js +321 -0
  105. package/dist/providers/proxmox/api.d.ts +1 -0
  106. package/dist/providers/proxmox/api.js +72 -16
  107. package/dist/providers/proxmox/index.d.ts +2 -0
  108. package/dist/providers/proxmox/index.js +2 -0
  109. package/dist/providers/proxmox/template.d.ts +44 -0
  110. package/dist/providers/proxmox/template.js +349 -0
  111. package/dist/providers/proxmox/template.test.d.ts +1 -0
  112. package/dist/providers/proxmox/template.test.js +179 -0
  113. package/dist/providers/proxmox/vm.d.ts +7 -4
  114. package/dist/providers/proxmox/vm.js +57 -102
  115. package/dist/providers/proxmox/vm.test.js +77 -0
  116. package/dist/types/inventory.d.ts +44 -1
  117. package/package.json +3 -1
@@ -123,6 +123,53 @@ function renderAws(inv) {
123
123
  ]);
124
124
  }
125
125
  }
126
+ function renderGcp(inv) {
127
+ if (inv.vms.length > 0) {
128
+ printSection(`GCP Compute VMs · ${inv.vms.length}`, inv.vms, [
129
+ { header: "Name", width: 24, render: (v) => v.name },
130
+ { header: "Zone", width: 15, render: (v) => v.zone },
131
+ { header: "Machine Type", width: 14, render: (v) => v.machineType },
132
+ { header: "Status", width: 8, render: (v) => v.status },
133
+ { header: "IP", width: 15, render: (v) => v.ip },
134
+ ]);
135
+ }
136
+ if (inv.rdsInstances.length > 0) {
137
+ printSection(`GCP Cloud SQL · ${inv.rdsInstances.length}`, inv.rdsInstances, [
138
+ { header: "Name", width: 24, render: (i) => i.name },
139
+ { header: "Engine", width: 18, render: (i) => i.engine },
140
+ { header: "Tier", width: 12, render: (i) => i.tier },
141
+ { header: "Status", width: 10, render: (i) => i.status },
142
+ ]);
143
+ }
144
+ if (inv.distributions.length > 0) {
145
+ printSection(`GCP Cloud Run · ${inv.distributions.length}`, inv.distributions, [
146
+ { header: "Service", width: 24, render: (s) => s.name },
147
+ { header: "Region", width: 12, render: (s) => s.region },
148
+ { header: "URL", width: 42, render: (s) => s.url },
149
+ ]);
150
+ }
151
+ if (inv.hostedZones.length > 0) {
152
+ printSection(`GCP Cloud DNS · ${inv.hostedZones.length}`, inv.hostedZones, [
153
+ { header: "Zone", width: 24, render: (z) => z.name },
154
+ { header: "DNS Name", width: 32, render: (z) => z.dnsName },
155
+ ]);
156
+ }
157
+ }
158
+ function renderFirebase(inv) {
159
+ if (inv.hostingSites.length > 0) {
160
+ printSection(`Firebase Hosting · ${inv.hostingSites.length}`, inv.hostingSites, [
161
+ { header: "Site ID", width: 32, render: (s) => s.site },
162
+ ]);
163
+ }
164
+ if (inv.functions.length > 0) {
165
+ printSection(`Firebase Functions · ${inv.functions.length}`, inv.functions, [
166
+ { header: "Function", width: 24, render: (f) => f.name },
167
+ { header: "Region", width: 12, render: (f) => f.region },
168
+ { header: "Entry Point", width: 18, render: (f) => f.entryPoint },
169
+ { header: "Runtime", width: 10, render: (f) => f.runtime },
170
+ ]);
171
+ }
172
+ }
126
173
  // ─── Checker ──────────────────────────────────────────────────────────────────
127
174
  export class Checker {
128
175
  async check() {
@@ -161,6 +208,26 @@ export class Checker {
161
208
  errors.push({ provider: "aws", message: err.message });
162
209
  }));
163
210
  }
211
+ if (cfg.providers.gcp?.serviceAccountPath || process.env.GCP_SA) {
212
+ tasks.push(import("../providers/gcp/list.js")
213
+ .then((m) => m.listGcpResources())
214
+ .then((inv) => {
215
+ result.gcp = inv;
216
+ })
217
+ .catch((err) => {
218
+ errors.push({ provider: "gcp", message: err.message });
219
+ }));
220
+ }
221
+ if (cfg.providers.firebase?.serviceAccountPath || process.env.FIREBASE_SA) {
222
+ tasks.push(import("../providers/firebase/list.js")
223
+ .then((m) => m.listFirebaseResources())
224
+ .then((inv) => {
225
+ result.firebase = inv;
226
+ })
227
+ .catch((err) => {
228
+ errors.push({ provider: "firebase", message: err.message });
229
+ }));
230
+ }
164
231
  await Promise.all(tasks);
165
232
  if (result.proxmox)
166
233
  renderProxmox(result.proxmox);
@@ -168,6 +235,10 @@ export class Checker {
168
235
  renderDo(result.do);
169
236
  if (result.aws)
170
237
  renderAws(result.aws);
238
+ if (result.gcp)
239
+ renderGcp(result.gcp);
240
+ if (result.firebase)
241
+ renderFirebase(result.firebase);
171
242
  for (const e of errors) {
172
243
  console.warn(`\n [!] ${e.provider}: ${e.message}`);
173
244
  }
@@ -1,9 +1,13 @@
1
1
  export interface GlobalConfig {
2
2
  dryRun?: boolean;
3
+ parallel?: boolean;
4
+ offline?: boolean;
3
5
  providers: {
4
6
  do?: {
5
7
  token: string;
6
8
  defaultRegion?: string;
9
+ spacesAccessKey?: string;
10
+ spacesSecretKey?: string;
7
11
  };
8
12
  aws?: {
9
13
  region: string;
@@ -35,6 +39,8 @@ declare class ConfigManager {
35
39
  set(newConfig: Partial<GlobalConfig>): void;
36
40
  get(): GlobalConfig;
37
41
  isGlobalDryRun(): boolean;
42
+ isParallelActive(): boolean;
43
+ isOfflineMode(): boolean;
38
44
  }
39
45
  export declare const Config: ConfigManager;
40
46
  export {};
@@ -3,7 +3,11 @@ class ConfigManager {
3
3
  providers: {},
4
4
  };
5
5
  set(newConfig) {
6
- this.config = { ...this.config, ...newConfig };
6
+ this.config = {
7
+ ...this.config,
8
+ ...newConfig,
9
+ providers: { ...this.config.providers, ...newConfig.providers },
10
+ };
7
11
  }
8
12
  get() {
9
13
  return this.config;
@@ -11,5 +15,11 @@ class ConfigManager {
11
15
  isGlobalDryRun() {
12
16
  return this.config.dryRun ?? false;
13
17
  }
18
+ isParallelActive() {
19
+ return this.config.parallel ?? false;
20
+ }
21
+ isOfflineMode() {
22
+ return this.config.offline ?? false;
23
+ }
14
24
  }
15
25
  export const Config = new ConfigManager();
@@ -0,0 +1,14 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export interface HostEntry {
3
+ name: string;
4
+ ip: string;
5
+ user: string;
6
+ sshKey?: string;
7
+ provider: string;
8
+ }
9
+ export interface ResourceContext {
10
+ abortSignal?: AbortSignal;
11
+ hosts?: HostEntry[];
12
+ stackName?: string;
13
+ }
14
+ export declare const resourceContextStorage: AsyncLocalStorage<ResourceContext>;
@@ -0,0 +1,2 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export const resourceContextStorage = new AsyncLocalStorage();
@@ -2,7 +2,10 @@ import "reflect-metadata";
2
2
  type ProviderOpts = {
3
3
  token?: string;
4
4
  region?: string;
5
+ regions?: string[];
5
6
  dryRun?: boolean;
7
+ parallel?: boolean;
8
+ offline?: boolean;
6
9
  firebase?: string;
7
10
  proxmox?: {
8
11
  url: string;
@@ -17,6 +20,7 @@ type ProviderOpts = {
17
20
  };
18
21
  };
19
22
  export declare function Protected(target: any, propertyKey: string): void;
23
+ export declare function ForceConfigCheck(target: any, propertyKey: string): void;
20
24
  export declare function Destroy(target: any, propertyKey: string): void;
21
25
  export declare function Destroy(target: Function): void;
22
26
  export declare function Destroy(opts: ProviderOpts): (constructor: any) => void;
@@ -5,27 +5,21 @@ import { Stack } from "./stack.js";
5
5
  function applyConfig(opts) {
6
6
  if (opts.dryRun !== undefined)
7
7
  Config.set({ dryRun: opts.dryRun });
8
+ if (opts.parallel !== undefined)
9
+ Config.set({ parallel: opts.parallel });
10
+ if (opts.offline !== undefined)
11
+ Config.set({ offline: opts.offline });
8
12
  if (opts.token)
9
- Config.set({
10
- providers: { ...Config.get().providers, do: { token: opts.token } },
11
- });
13
+ Config.set({ providers: { do: { token: opts.token } } });
12
14
  if (opts.region)
13
- Config.set({
14
- providers: { ...Config.get().providers, aws: { region: opts.region } },
15
- });
15
+ Config.set({ providers: { aws: { region: opts.region } } });
16
16
  if (opts.proxmox)
17
- Config.set({
18
- providers: { ...Config.get().providers, proxmox: opts.proxmox },
19
- });
17
+ Config.set({ providers: { proxmox: opts.proxmox } });
20
18
  if (opts.firebase) {
21
19
  const sa = JSON.parse(readFileSync(opts.firebase, "utf8"));
22
20
  Config.set({
23
21
  providers: {
24
- ...Config.get().providers,
25
- firebase: {
26
- projectId: sa.project_id,
27
- serviceAccountPath: opts.firebase,
28
- },
22
+ firebase: { projectId: sa.project_id, serviceAccountPath: opts.firebase },
29
23
  },
30
24
  });
31
25
  }
@@ -33,6 +27,9 @@ function applyConfig(opts) {
33
27
  export function Protected(target, propertyKey) {
34
28
  Reflect.defineMetadata("protected", true, target, propertyKey);
35
29
  }
30
+ export function ForceConfigCheck(target, propertyKey) {
31
+ Reflect.defineMetadata("forceConfigCheck", true, target, propertyKey);
32
+ }
36
33
  export function Destroy(optsOrTarget, propertyKey) {
37
34
  if (propertyKey !== undefined) {
38
35
  Reflect.defineMetadata("destroy", true, optsOrTarget, propertyKey);
@@ -48,27 +45,56 @@ export function Destroy(optsOrTarget, propertyKey) {
48
45
  return;
49
46
  }
50
47
  return function (constructor) {
51
- applyConfig(optsOrTarget);
52
- const instance = new constructor();
53
- Stack._register(constructor, instance);
54
- Promise.resolve().then(async () => {
55
- if (typeof instance.destroy === "function")
56
- await instance.destroy();
57
- });
48
+ const regions = optsOrTarget.regions ?? [];
49
+ if (regions.length > 0) {
50
+ Promise.resolve().then(async () => {
51
+ for (const r of regions) {
52
+ console.log(`\n🌍 [MULTI-REGION] Tearing down stack in region: ${r}`);
53
+ applyConfig({ ...optsOrTarget, region: r });
54
+ const instance = new constructor();
55
+ Stack._register(constructor, instance, r);
56
+ if (typeof instance.destroy === "function")
57
+ await instance.destroy();
58
+ }
59
+ });
60
+ }
61
+ else {
62
+ applyConfig(optsOrTarget);
63
+ const instance = new constructor();
64
+ Stack._register(constructor, instance);
65
+ Promise.resolve().then(async () => {
66
+ if (typeof instance.destroy === "function")
67
+ await instance.destroy();
68
+ });
69
+ }
58
70
  };
59
71
  }
60
72
  // THE "MAGIC": Auto-executing Stack Decorator
61
73
  export function Deploy(opts = {}) {
62
74
  return function (constructor) {
63
- applyConfig(opts);
64
- // Instantiate synchronously so resource discovery kicks off immediately and
65
- // other stacks can call Stack.from(ThisClass) to reference its Output fields.
66
- const instance = new constructor();
67
- Stack._register(constructor, instance);
68
- Promise.resolve().then(async () => {
69
- if (typeof instance.deploy === "function")
70
- await instance.deploy();
71
- });
75
+ const regions = opts.regions ?? [];
76
+ if (regions.length > 0) {
77
+ Promise.resolve().then(async () => {
78
+ for (const r of regions) {
79
+ console.log(`\n🌍 [MULTI-REGION] Deploying stack to region: ${r}`);
80
+ applyConfig({ ...opts, region: r });
81
+ const instance = new constructor();
82
+ Stack._register(constructor, instance, r);
83
+ if (typeof instance.deploy === "function") {
84
+ await instance.deploy();
85
+ }
86
+ }
87
+ });
88
+ }
89
+ else {
90
+ applyConfig(opts);
91
+ const instance = new constructor();
92
+ Stack._register(constructor, instance);
93
+ Promise.resolve().then(async () => {
94
+ if (typeof instance.deploy === "function")
95
+ await instance.deploy();
96
+ });
97
+ }
72
98
  };
73
99
  }
74
100
  export function Check(opts = {}) {
@@ -0,0 +1,21 @@
1
+ export declare const SLACK: {
2
+ /**
3
+ * Generates a callback that posts a structured lifecycle notification to a Slack webhook.
4
+ * In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
5
+ *
6
+ * @param webhookUrl The Slack Incoming Webhook URL
7
+ */
8
+ notify: (webhookUrl: string) => (result: any) => Promise<void>;
9
+ };
10
+ export declare const DISCORD: {
11
+ /**
12
+ * Generates a callback that posts a rich embed lifecycle notification to a Discord webhook.
13
+ * In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
14
+ *
15
+ * @param webhookUrl The Discord Incoming Webhook URL
16
+ * @example
17
+ * GCP.CloudRun("api")
18
+ * .afterDeploy(DISCORD.notify("https://discord.com/api/webhooks/..."))
19
+ */
20
+ notify: (webhookUrl: string) => (result: any) => Promise<void>;
21
+ };
@@ -0,0 +1,116 @@
1
+ import { Config } from "./config.js";
2
+ export const SLACK = {
3
+ /**
4
+ * Generates a callback that posts a structured lifecycle notification to a Slack webhook.
5
+ * In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
6
+ *
7
+ * @param webhookUrl The Slack Incoming Webhook URL
8
+ */
9
+ notify: (webhookUrl) => {
10
+ return async (result) => {
11
+ const name = result?.name ?? "unknown-resource";
12
+ const isDryRun = Config.isGlobalDryRun();
13
+ // Check if it's a destroy result or deploy result
14
+ const isDestroy = result && ("destroyed" in result || result.destroyed === true);
15
+ const action = isDestroy ? "destroyed" : "deployed/updated";
16
+ const statusEmoji = isDestroy ? "🗑️" : "🚀";
17
+ // Filter and print simple key-value attributes
18
+ const details = Object.entries(result ?? {})
19
+ .filter(([k, v]) => k !== "name" &&
20
+ k !== "destroyed" &&
21
+ (typeof v === "string" || typeof v === "number" || typeof v === "boolean"))
22
+ .map(([k, v]) => `• *${k}*: \`${v}\``)
23
+ .join("\n");
24
+ const text = `${statusEmoji} *Puls Notification*: Resource *${name}* was successfully *${action}*!\n${details ? `*Details*:\n${details}` : ""}`;
25
+ if (isDryRun) {
26
+ console.log(`\n📢 [DRY RUN] Would post Slack notification to webhook: ${webhookUrl}`);
27
+ console.log(` Message: ${text.replace(/\n/g, "\n ")}`);
28
+ return;
29
+ }
30
+ try {
31
+ const res = await fetch(webhookUrl, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ text }),
35
+ });
36
+ if (!res.ok) {
37
+ console.warn(`[WARN] Slack webhook returned status ${res.status}`);
38
+ }
39
+ }
40
+ catch (err) {
41
+ console.error(`[ERROR] Failed to send Slack notification: ${err.message}`);
42
+ }
43
+ };
44
+ },
45
+ };
46
+ export const DISCORD = {
47
+ /**
48
+ * Generates a callback that posts a rich embed lifecycle notification to a Discord webhook.
49
+ * In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
50
+ *
51
+ * @param webhookUrl The Discord Incoming Webhook URL
52
+ * @example
53
+ * GCP.CloudRun("api")
54
+ * .afterDeploy(DISCORD.notify("https://discord.com/api/webhooks/..."))
55
+ */
56
+ notify: (webhookUrl) => {
57
+ return async (result) => {
58
+ const name = result?.name ?? "unknown-resource";
59
+ const isDryRun = Config.isGlobalDryRun();
60
+ // Check if it's a destroy result or deploy result
61
+ const isDestroy = result && ("destroyed" in result || result.destroyed === true);
62
+ const action = isDestroy ? "destroyed" : "deployed/updated";
63
+ const statusEmoji = isDestroy ? "🗑️" : "🚀";
64
+ // Green (0x2ecc71) for deploy, Red (0xe74c3c) for destroy
65
+ const color = isDestroy ? 15158332 : 3066993;
66
+ const fields = Object.entries(result ?? {})
67
+ .filter(([k, v]) => k !== "name" &&
68
+ k !== "destroyed" &&
69
+ (typeof v === "string" || typeof v === "number" || typeof v === "boolean"))
70
+ .map(([k, v]) => ({
71
+ name: k,
72
+ value: `\`${v}\``,
73
+ inline: true,
74
+ }));
75
+ const payload = {
76
+ embeds: [
77
+ {
78
+ title: `${statusEmoji} Puls Notification`,
79
+ description: `Resource **${name}** was successfully **${action}**!`,
80
+ color,
81
+ fields,
82
+ footer: {
83
+ text: "Puls Dev Suite",
84
+ },
85
+ timestamp: new Date().toISOString(),
86
+ },
87
+ ],
88
+ };
89
+ if (isDryRun) {
90
+ console.log(`\n📢 [DRY RUN] Would post Discord notification to webhook: ${webhookUrl}`);
91
+ console.log(` Embed Title: ${statusEmoji} Puls Notification`);
92
+ console.log(` Embed Description: Resource **${name}** was successfully **${action}**!`);
93
+ if (fields.length > 0) {
94
+ console.log(" Fields:");
95
+ for (const f of fields) {
96
+ console.log(` • ${f.name}: ${f.value}`);
97
+ }
98
+ }
99
+ return;
100
+ }
101
+ try {
102
+ const res = await fetch(webhookUrl, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify(payload),
106
+ });
107
+ if (!res.ok) {
108
+ console.warn(`[WARN] Discord webhook returned status ${res.status}`);
109
+ }
110
+ }
111
+ catch (err) {
112
+ console.error(`[ERROR] Failed to send Discord notification: ${err.message}`);
113
+ }
114
+ };
115
+ },
116
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,194 @@
1
+ import { test, describe, beforeEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import { Stack } from "./stack.js";
4
+ import { BaseBuilder } from "./resource.js";
5
+ import { Config } from "./config.js";
6
+ import { SLACK, DISCORD } from "./hooks.js";
7
+ // A minimal dummy resource builder for testing hooks
8
+ class TestResource extends BaseBuilder {
9
+ resultValue;
10
+ constructor(name, resultValue = { name: "test-res", ip: "1.2.3.4" }) {
11
+ super(name);
12
+ this.resultValue = resultValue;
13
+ }
14
+ async deploy() {
15
+ return this.resultValue;
16
+ }
17
+ async destroy() {
18
+ return { name: this.name, destroyed: true };
19
+ }
20
+ }
21
+ describe("Lifecycle Hooks & Notifier Tests", () => {
22
+ let executionLogs = [];
23
+ beforeEach(() => {
24
+ Config.set({
25
+ dryRun: false,
26
+ providers: {},
27
+ });
28
+ executionLogs = [];
29
+ });
30
+ test("runs Stack-level and Resource-level deploy hooks in the correct order", async () => {
31
+ class MyStack extends Stack {
32
+ async beforeDeploy() {
33
+ executionLogs.push("stack-before-deploy");
34
+ }
35
+ async afterDeploy(outputs) {
36
+ executionLogs.push(`stack-after-deploy:${outputs.res.ip}`);
37
+ }
38
+ res = new TestResource("my-res", { name: "my-res", ip: "9.9.9.9" })
39
+ .beforeDeploy(() => {
40
+ executionLogs.push("res-before-deploy");
41
+ })
42
+ .afterDeploy((result) => {
43
+ executionLogs.push(`res-after-deploy:${result.ip}`);
44
+ });
45
+ }
46
+ const stack = new MyStack();
47
+ const outputs = await stack.deploy();
48
+ // Verify ordering
49
+ assert.deepStrictEqual(executionLogs, [
50
+ "stack-before-deploy",
51
+ "res-before-deploy",
52
+ "res-after-deploy:9.9.9.9",
53
+ "stack-after-deploy:9.9.9.9",
54
+ ]);
55
+ assert.deepStrictEqual(outputs.res, { name: "my-res", ip: "9.9.9.9" });
56
+ });
57
+ test("runs Stack-level and Resource-level destroy hooks in the correct order", async () => {
58
+ class MyStack extends Stack {
59
+ async beforeDestroy() {
60
+ executionLogs.push("stack-before-destroy");
61
+ }
62
+ async afterDestroy(outputs) {
63
+ executionLogs.push(`stack-after-destroy:${outputs.res.destroyed}`);
64
+ }
65
+ res = new TestResource("my-res")
66
+ .beforeDestroy(() => {
67
+ executionLogs.push("res-before-destroy");
68
+ })
69
+ .afterDestroy((result) => {
70
+ executionLogs.push(`res-after-destroy:${result.destroyed}`);
71
+ });
72
+ }
73
+ const stack = new MyStack();
74
+ const outputs = await stack.destroy();
75
+ // Verify ordering (destroy properties are iterated in reverse order, which doesn't affect single resource)
76
+ assert.deepStrictEqual(executionLogs, [
77
+ "stack-before-destroy",
78
+ "res-before-destroy",
79
+ "res-after-destroy:true",
80
+ "stack-after-destroy:true",
81
+ ]);
82
+ assert.deepStrictEqual(outputs.res, { name: "my-res", destroyed: true });
83
+ });
84
+ test("SLACK.notify helper sends fetch call on real deploys", async () => {
85
+ const originalFetch = globalThis.fetch;
86
+ let fetchPayload = null;
87
+ let fetchUrl = "";
88
+ globalThis.fetch = (async (url, init) => {
89
+ fetchUrl = url;
90
+ fetchPayload = JSON.parse(init?.body);
91
+ return {
92
+ ok: true,
93
+ status: 200,
94
+ };
95
+ });
96
+ try {
97
+ const webhook = "https://hooks.slack.com/services/T00/B00/X00";
98
+ const notifyHook = SLACK.notify(webhook);
99
+ const deployResult = { name: "web-app", url: "https://web.run.app", ip: "1.2.3.4" };
100
+ await notifyHook(deployResult);
101
+ assert.strictEqual(fetchUrl, webhook);
102
+ assert.ok(fetchPayload);
103
+ assert.ok(fetchPayload.text.includes("🚀"));
104
+ assert.ok(fetchPayload.text.includes("web-app"));
105
+ assert.ok(fetchPayload.text.includes("https://web.run.app"));
106
+ assert.ok(fetchPayload.text.includes("1.2.3.4"));
107
+ }
108
+ finally {
109
+ globalThis.fetch = originalFetch;
110
+ }
111
+ });
112
+ test("SLACK.notify helper formats text and logs to console on dry-runs", async () => {
113
+ Config.set({
114
+ dryRun: true,
115
+ providers: {},
116
+ });
117
+ const originalLog = console.log;
118
+ let logOutput = "";
119
+ console.log = (...args) => {
120
+ logOutput += args.join(" ") + "\n";
121
+ };
122
+ try {
123
+ const notifyHook = SLACK.notify("https://hooks.slack.com/services/T00");
124
+ const destroyResult = { name: "old-db", destroyed: true };
125
+ await notifyHook(destroyResult);
126
+ assert.ok(logOutput.includes("📢 [DRY RUN]"));
127
+ assert.ok(logOutput.includes("Would post Slack notification"));
128
+ assert.ok(logOutput.includes("🗑️"));
129
+ assert.ok(logOutput.includes("old-db"));
130
+ assert.ok(logOutput.includes("destroyed"));
131
+ }
132
+ finally {
133
+ console.log = originalLog;
134
+ }
135
+ });
136
+ test("DISCORD.notify helper sends fetch call with rich embed on real deploys", async () => {
137
+ const originalFetch = globalThis.fetch;
138
+ let fetchPayload = null;
139
+ let fetchUrl = "";
140
+ globalThis.fetch = (async (url, init) => {
141
+ fetchUrl = url;
142
+ fetchPayload = JSON.parse(init?.body);
143
+ return {
144
+ ok: true,
145
+ status: 200,
146
+ };
147
+ });
148
+ try {
149
+ const webhook = "https://discord.com/api/webhooks/123/abc";
150
+ const notifyHook = DISCORD.notify(webhook);
151
+ const deployResult = { name: "api-srv", url: "https://api.run.app", active: true };
152
+ await notifyHook(deployResult);
153
+ assert.strictEqual(fetchUrl, webhook);
154
+ assert.ok(fetchPayload);
155
+ assert.ok(fetchPayload.embeds);
156
+ assert.strictEqual(fetchPayload.embeds.length, 1);
157
+ const embed = fetchPayload.embeds[0];
158
+ assert.strictEqual(embed.title, "🚀 Puls Notification");
159
+ assert.ok(embed.description.includes("api-srv"));
160
+ assert.ok(embed.description.includes("deployed/updated"));
161
+ assert.strictEqual(embed.color, 3066993); // Green
162
+ assert.strictEqual(embed.fields.length, 2);
163
+ assert.deepStrictEqual(embed.fields[0], { name: "url", value: "`https://api.run.app`", inline: true });
164
+ assert.deepStrictEqual(embed.fields[1], { name: "active", value: "`true`", inline: true });
165
+ }
166
+ finally {
167
+ globalThis.fetch = originalFetch;
168
+ }
169
+ });
170
+ test("DISCORD.notify helper formats embeds and logs to console on dry-runs", async () => {
171
+ Config.set({
172
+ dryRun: true,
173
+ providers: {},
174
+ });
175
+ const originalLog = console.log;
176
+ let logOutput = "";
177
+ console.log = (...args) => {
178
+ logOutput += args.join(" ") + "\n";
179
+ };
180
+ try {
181
+ const notifyHook = DISCORD.notify("https://discord.com/api/webhooks/123");
182
+ const destroyResult = { name: "old-cache", destroyed: true };
183
+ await notifyHook(destroyResult);
184
+ assert.ok(logOutput.includes("📢 [DRY RUN]"));
185
+ assert.ok(logOutput.includes("Would post Discord notification"));
186
+ assert.ok(logOutput.includes("🗑️"));
187
+ assert.ok(logOutput.includes("old-cache"));
188
+ assert.ok(logOutput.includes("destroyed"));
189
+ }
190
+ finally {
191
+ console.log = originalLog;
192
+ }
193
+ });
194
+ });
@@ -0,0 +1 @@
1
+ export {};