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
@@ -0,0 +1,166 @@
1
+ import { test, describe, beforeEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { Secret } from "./secret.js";
7
+ import { Config } from "./config.js";
8
+ import { Output } from "./output.js";
9
+ // Import clients to mock their prototype methods
10
+ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
11
+ import { SSMClient } from "@aws-sdk/client-ssm";
12
+ import { GoogleAuth } from "google-auth-library";
13
+ describe("Secrets at Deploy Time Unit Tests", () => {
14
+ beforeEach(() => {
15
+ // Reset global config before each test
16
+ Config.set({
17
+ dryRun: false,
18
+ providers: {},
19
+ });
20
+ });
21
+ test("Secret.env resolves existing environment variables", async () => {
22
+ process.env.TEST_MY_SECRET = "super-secret-value";
23
+ try {
24
+ const secret = Secret.env("TEST_MY_SECRET");
25
+ const val = await secret.get();
26
+ assert.strictEqual(val, "super-secret-value");
27
+ }
28
+ finally {
29
+ delete process.env.TEST_MY_SECRET;
30
+ }
31
+ });
32
+ test("Secret.env uses fallback when environment variable is missing", async () => {
33
+ const secret = Secret.env("TEST_MISSING_SECRET", "fallback-value");
34
+ const val = await secret.get();
35
+ assert.strictEqual(val, "fallback-value");
36
+ });
37
+ test("Secret.env throws error when missing and no fallback is set", async () => {
38
+ const secret = Secret.env("TEST_MISSING_SECRET_NO_FALLBACK");
39
+ await assert.rejects(async () => {
40
+ await secret.get();
41
+ }, /Environment secret "TEST_MISSING_SECRET_NO_FALLBACK" is not set and has no fallback./);
42
+ });
43
+ test("Secret.resolve correctly resolves strings, Outputs, and Secrets", async () => {
44
+ // 1. Resolve plain string
45
+ const r1 = await Secret.resolve("plain-string");
46
+ assert.strictEqual(r1, "plain-string");
47
+ // 2. Resolve general Output
48
+ const out = new Output();
49
+ out.resolve("resolved-output");
50
+ const r2 = await Secret.resolve(out);
51
+ assert.strictEqual(r2, "resolved-output");
52
+ // 3. Resolve Secret
53
+ process.env.TEST_RESOLVE = "secret-val";
54
+ try {
55
+ const sec = Secret.env("TEST_RESOLVE");
56
+ const r3 = await Secret.resolve(sec);
57
+ assert.strictEqual(r3, "secret-val");
58
+ }
59
+ finally {
60
+ delete process.env.TEST_RESOLVE;
61
+ }
62
+ });
63
+ test("Secret resolves immediately to placeholder [SECRET:name] in dry-run mode", async () => {
64
+ Config.set({ dryRun: true });
65
+ // No actual env vars, cloud requests, or file systems will be hit
66
+ const s1 = Secret.env("SOME_ENV");
67
+ const s2 = Secret.aws("some/aws/secret");
68
+ const s3 = Secret.ssm("/some/ssm/param");
69
+ const s4 = Secret.gcp("some-gcp-secret");
70
+ assert.strictEqual(await s1.get(), "[SECRET:SOME_ENV]");
71
+ assert.strictEqual(await s2.get(), "[SECRET:some/aws/secret]");
72
+ assert.strictEqual(await s3.get(), "[SECRET:/some/ssm/param]");
73
+ assert.strictEqual(await s4.get(), "[SECRET:some-gcp-secret]");
74
+ });
75
+ test("Secret.aws resolves value using mocked AWS Secrets Manager", async () => {
76
+ const originalSend = SecretsManagerClient.prototype.send;
77
+ mock.method(SecretsManagerClient.prototype, "send", async (command) => {
78
+ return { SecretString: "my-aws-vault-password" };
79
+ });
80
+ try {
81
+ const secret = Secret.aws("prod/db/pass", { region: "eu-west-1" });
82
+ const val = await secret.get();
83
+ assert.strictEqual(val, "my-aws-vault-password");
84
+ }
85
+ finally {
86
+ SecretsManagerClient.prototype.send = originalSend;
87
+ }
88
+ });
89
+ test("Secret.ssm resolves value using mocked AWS SSM Parameter Store", async () => {
90
+ const originalSend = SSMClient.prototype.send;
91
+ mock.method(SSMClient.prototype, "send", async (command) => {
92
+ return {
93
+ Parameter: {
94
+ Value: "my-ssm-token-value",
95
+ },
96
+ };
97
+ });
98
+ try {
99
+ const secret = Secret.ssm("/prod/api/token");
100
+ const val = await secret.get();
101
+ assert.strictEqual(val, "my-ssm-token-value");
102
+ }
103
+ finally {
104
+ SSMClient.prototype.send = originalSend;
105
+ }
106
+ });
107
+ test("Secret.gcp resolves value using mocked GCP Secret Manager fetcher", async () => {
108
+ // 1. Create a dummy service account file
109
+ const dummySaPath = join(tmpdir(), `puls-gcp-sa-test-${Date.now()}.json`);
110
+ const dummySa = {
111
+ project_id: "test-gcp-project",
112
+ client_email: "test@developer.gserviceaccount.com",
113
+ };
114
+ fs.writeFileSync(dummySaPath, JSON.stringify(dummySa));
115
+ // Configure config to use the dummy file
116
+ Config.set({
117
+ providers: {
118
+ gcp: {
119
+ projectId: "test-gcp-project",
120
+ serviceAccountPath: dummySaPath,
121
+ },
122
+ },
123
+ });
124
+ // 2. Mock GoogleAuth class prototype
125
+ const originalGetClient = GoogleAuth.prototype.getClient;
126
+ mock.method(GoogleAuth.prototype, "getClient", async () => {
127
+ return {
128
+ getAccessToken: async () => ({ token: "mocked-gcp-access-token" }),
129
+ };
130
+ });
131
+ // 3. Mock globalThis.fetch to intercept Secret Manager API request
132
+ const originalFetch = globalThis.fetch;
133
+ let requestedUrl = "";
134
+ let authHeader = "";
135
+ globalThis.fetch = (async (url, init) => {
136
+ requestedUrl = url;
137
+ authHeader = init?.headers?.["Authorization"] ?? "";
138
+ return {
139
+ ok: true,
140
+ status: 200,
141
+ text: async () => JSON.stringify({
142
+ name: "projects/test-gcp-project/secrets/my-secret/versions/latest",
143
+ payload: {
144
+ data: Buffer.from("my-gcp-payload-value").toString("base64"),
145
+ },
146
+ }),
147
+ };
148
+ });
149
+ try {
150
+ const secret = Secret.gcp("my-secret");
151
+ const val = await secret.get();
152
+ assert.strictEqual(val, "my-gcp-payload-value");
153
+ assert.strictEqual(requestedUrl, "https://secretmanager.googleapis.com/v1/projects/test-gcp-project/secrets/my-secret/versions/latest:access");
154
+ assert.strictEqual(authHeader, "Bearer mocked-gcp-access-token");
155
+ }
156
+ finally {
157
+ // Cleanup
158
+ globalThis.fetch = originalFetch;
159
+ GoogleAuth.prototype.getClient = originalGetClient;
160
+ try {
161
+ fs.unlinkSync(dummySaPath);
162
+ }
163
+ catch { }
164
+ }
165
+ });
166
+ });
@@ -1,20 +1,21 @@
1
1
  import "reflect-metadata";
2
2
  export declare abstract class Stack {
3
3
  /** @internal - called by @Deploy to register the instance for cross-stack references. */
4
- static _register(cls: Function, instance: Stack): void;
4
+ static _register(cls: Function, instance: Stack, region?: string): void;
5
5
  /**
6
6
  * Returns the already-constructed instance of another Stack so you can reference
7
7
  * its resource Output fields before deployment completes.
8
8
  *
9
9
  * The target stack must be decorated with @Deploy and imported before this call.
10
+ * An optional region parameter can be supplied for multi-region configurations.
10
11
  *
11
12
  * @example
12
13
  * class DNSStack extends Stack {
13
- * private infra = Stack.from(InfraStack);
14
+ * private infra = Stack.from(InfraStack, REGION.US_EAST_1);
14
15
  * dns = DO.Domain("example.com").pointer("app", this.infra.app.ip);
15
16
  * }
16
17
  */
17
- static from<T extends Stack>(cls: new (...args: any[]) => T): T;
18
+ static from<T extends Stack>(cls: new (...args: any[]) => T, region?: string): T;
18
19
  deploy(): Promise<Record<string, any>>;
19
20
  destroy(): Promise<Record<string, any>>;
20
21
  }
@@ -1,24 +1,37 @@
1
1
  import "reflect-metadata";
2
2
  import { BaseBuilder } from "./resource.js";
3
+ import { Config } from "./config.js";
4
+ import { resourceContextStorage } from "./context.js";
5
+ import { resolvedSecrets } from "./secret.js";
3
6
  const _registry = new Map();
4
- function formatEntry(val) {
7
+ function formatEntry(val, parentKey) {
8
+ const isSensitiveKey = (k) => /password|secret|token|key/i.test(k);
9
+ if (parentKey && isSensitiveKey(parentKey)) {
10
+ return { primary: "********" };
11
+ }
5
12
  if (!val || typeof val !== "object")
6
13
  return { primary: String(val) };
14
+ const redactedVal = { ...val };
15
+ for (const k of Object.keys(redactedVal)) {
16
+ if (isSensitiveKey(k)) {
17
+ redactedVal[k] = "********";
18
+ }
19
+ }
7
20
  // Known shapes
8
- if ("destroyed" in val)
9
- return { primary: val.destroyed ? "šŸ—‘ļø destroyed" : "─ not found" };
10
- if (val.zone)
11
- return { primary: val.zone };
12
- if (val.name && val.id)
13
- return { primary: `${val.name} [${val.id}]` };
14
- if (val.name)
15
- return { primary: val.name };
16
- if (val.arn)
17
- return { primary: val.arn };
21
+ if ("destroyed" in redactedVal)
22
+ return { primary: redactedVal.destroyed ? "šŸ—‘ļø destroyed" : "─ not found" };
23
+ if (redactedVal.zone)
24
+ return { primary: redactedVal.zone };
25
+ if (redactedVal.name && redactedVal.id)
26
+ return { primary: `${redactedVal.name} [${redactedVal.id}]` };
27
+ if (redactedVal.name)
28
+ return { primary: redactedVal.name };
29
+ if (redactedVal.arn)
30
+ return { primary: redactedVal.arn };
18
31
  // Generic: pull all scalar values
19
- const pairs = Object.entries(val).filter(([, v]) => typeof v === "string" || typeof v === "number");
32
+ const pairs = Object.entries(redactedVal).filter(([, v]) => typeof v === "string" || typeof v === "number");
20
33
  if (pairs.length === 0)
21
- return { primary: JSON.stringify(val) };
34
+ return { primary: JSON.stringify(redactedVal) };
22
35
  // Try compact inline (values only, dot-separated)
23
36
  const inline = pairs.map(([, v]) => v).join(" Ā· ");
24
37
  if (inline.length <= 52)
@@ -35,7 +48,7 @@ function printOutputs(stackName, outputs) {
35
48
  const keyWidth = Math.max(...Object.keys(outputs).map((k) => k.length));
36
49
  const rows = Object.entries(outputs).map(([key, val]) => ({
37
50
  key,
38
- ...formatEntry(val),
51
+ ...formatEntry(val, key),
39
52
  }));
40
53
  // textWidth = width of row text content (without the 2-space padding on each side)
41
54
  const textWidth = Math.max(...rows.flatMap(({ key, primary, sub }) => [
@@ -60,7 +73,10 @@ function printOutputs(stackName, outputs) {
60
73
  }
61
74
  export class Stack {
62
75
  /** @internal - called by @Deploy to register the instance for cross-stack references. */
63
- static _register(cls, instance) {
76
+ static _register(cls, instance, region) {
77
+ if (region) {
78
+ _registry.set(`${cls.name}:${region}`, instance);
79
+ }
64
80
  _registry.set(cls, instance);
65
81
  }
66
82
  /**
@@ -68,53 +84,311 @@ export class Stack {
68
84
  * its resource Output fields before deployment completes.
69
85
  *
70
86
  * The target stack must be decorated with @Deploy and imported before this call.
87
+ * An optional region parameter can be supplied for multi-region configurations.
71
88
  *
72
89
  * @example
73
90
  * class DNSStack extends Stack {
74
- * private infra = Stack.from(InfraStack);
91
+ * private infra = Stack.from(InfraStack, REGION.US_EAST_1);
75
92
  * dns = DO.Domain("example.com").pointer("app", this.infra.app.ip);
76
93
  * }
77
94
  */
78
- static from(cls) {
79
- const instance = _registry.get(cls);
95
+ static from(cls, region) {
96
+ const key = region ? `${cls.name}:${region}` : cls;
97
+ const instance = _registry.get(key);
80
98
  if (!instance)
81
- throw new Error(`Stack "${cls.name}" is not registered. Make sure it is decorated with @Deploy and its module is imported before referencing it.`);
99
+ 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.`);
82
100
  return instance;
83
101
  }
84
102
  async deploy() {
85
- console.log(`\nšŸ—ļø Deploying Stack: ${this.constructor.name}`);
86
- const props = Object.getOwnPropertyNames(this);
87
- const outputs = {};
88
- for (const prop of props) {
89
- const resource = this[prop];
90
- if (resource instanceof BaseBuilder) {
91
- const isProtected = Reflect.getMetadata("protected", this, prop);
92
- const isDestroyed = Reflect.getMetadata("destroy", this, prop);
93
- if (isProtected)
94
- resource.protect();
95
- outputs[prop] = isDestroyed
96
- ? await resource.destroy()
97
- : await resource.deploy();
103
+ const controller = new AbortController();
104
+ const hosts = [];
105
+ const context = {
106
+ abortSignal: controller.signal,
107
+ hosts,
108
+ stackName: this.constructor.name
109
+ };
110
+ return resourceContextStorage.run(context, async () => {
111
+ const originalLog = console.log;
112
+ console.log = (...args) => {
113
+ const redact = (message) => {
114
+ if (typeof message !== "string")
115
+ return message;
116
+ let result = message;
117
+ for (const secret of resolvedSecrets) {
118
+ if (secret && secret.length >= 3) {
119
+ const escaped = secret.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
120
+ const regex = new RegExp(escaped, 'g');
121
+ result = result.replace(regex, '********');
122
+ }
123
+ }
124
+ return result;
125
+ };
126
+ const redactedArgs = args.map(arg => {
127
+ if (typeof arg === "string") {
128
+ return redact(arg);
129
+ }
130
+ try {
131
+ const str = String(arg);
132
+ let hasSecret = false;
133
+ for (const secret of resolvedSecrets) {
134
+ if (secret && secret.length >= 3 && str.includes(secret)) {
135
+ hasSecret = true;
136
+ break;
137
+ }
138
+ }
139
+ if (hasSecret) {
140
+ return redact(str);
141
+ }
142
+ }
143
+ catch { }
144
+ return arg;
145
+ });
146
+ originalLog(...redactedArgs);
147
+ };
148
+ try {
149
+ console.log(`\nšŸ—ļø Deploying Stack: ${this.constructor.name}`);
150
+ // Stack-level beforeDeploy hook
151
+ if (typeof this.beforeDeploy === "function") {
152
+ console.log(` ⚔ Running Stack-level beforeDeploy hook...`);
153
+ await this.beforeDeploy();
154
+ }
155
+ const props = Object.getOwnPropertyNames(this);
156
+ const outputs = {};
157
+ const isParallel = Config.isParallelActive();
158
+ // 1. Gather all resources
159
+ const resources = [];
160
+ for (const prop of props) {
161
+ const val = this[prop];
162
+ if (val instanceof BaseBuilder) {
163
+ resources.push({ prop, resource: val });
164
+ // Apply metadata properties eagerly
165
+ const isProtected = Reflect.getMetadata("protected", this, prop);
166
+ if (isProtected)
167
+ val.protect();
168
+ const forceConfigCheck = Reflect.getMetadata("forceConfigCheck", this, prop);
169
+ if (forceConfigCheck && typeof val.forceConfigCheck === "function") {
170
+ val.forceConfigCheck();
171
+ }
172
+ }
173
+ }
174
+ // 2. Schedule execution
175
+ if (isParallel) {
176
+ const startPromise = Promise.resolve();
177
+ const promises = resources.map(({ prop, resource }) => {
178
+ resource._deployPromise = (async () => {
179
+ try {
180
+ await startPromise;
181
+ if (controller.signal.aborted) {
182
+ throw new Error("Deployment aborted due to previous failure");
183
+ }
184
+ // Wait for explicit dependencies
185
+ for (const dep of resource._dependencies) {
186
+ if (dep._deployPromise) {
187
+ await dep._deployPromise;
188
+ }
189
+ }
190
+ if (controller.signal.aborted) {
191
+ throw new Error("Deployment aborted due to previous failure");
192
+ }
193
+ // Execute hooks and deploy
194
+ const isDestroyed = Reflect.getMetadata("destroy", this, prop);
195
+ let res;
196
+ if (isDestroyed) {
197
+ await resource._runBeforeDestroy();
198
+ res = await resource.destroy();
199
+ await resource._runAfterDestroy(res);
200
+ }
201
+ else {
202
+ await resource._runBeforeDeploy();
203
+ res = await resource.deploy();
204
+ await resource._runAfterDeploy(res);
205
+ }
206
+ outputs[prop] = res;
207
+ return res;
208
+ }
209
+ catch (err) {
210
+ controller.abort();
211
+ throw err;
212
+ }
213
+ })();
214
+ return resource._deployPromise;
215
+ });
216
+ await Promise.all(promises);
217
+ }
218
+ else {
219
+ // Sequential mode
220
+ for (const { prop, resource } of resources) {
221
+ if (controller.signal.aborted) {
222
+ throw new Error("Deployment aborted due to previous failure");
223
+ }
224
+ try {
225
+ const isDestroyed = Reflect.getMetadata("destroy", this, prop);
226
+ let res;
227
+ if (isDestroyed) {
228
+ await resource._runBeforeDestroy();
229
+ res = await resource.destroy();
230
+ await resource._runAfterDestroy(res);
231
+ }
232
+ else {
233
+ await resource._runBeforeDeploy();
234
+ res = await resource.deploy();
235
+ await resource._runAfterDeploy(res);
236
+ }
237
+ outputs[prop] = res;
238
+ }
239
+ catch (err) {
240
+ controller.abort();
241
+ throw err;
242
+ }
243
+ }
244
+ }
245
+ printOutputs(this.constructor.name, outputs);
246
+ // Stack-level afterDeploy hook
247
+ if (typeof this.afterDeploy === "function") {
248
+ console.log(` ⚔ Running Stack-level afterDeploy hook...`);
249
+ await this.afterDeploy(outputs);
250
+ }
251
+ return outputs;
98
252
  }
99
- }
100
- printOutputs(this.constructor.name, outputs);
101
- return outputs;
253
+ finally {
254
+ console.log = originalLog;
255
+ }
256
+ });
102
257
  }
103
258
  async destroy() {
104
- console.log(`\nšŸ’„ Tearing down Stack: ${this.constructor.name}`);
105
- const props = Object.getOwnPropertyNames(this).reverse();
106
- const outputs = {};
107
- for (const prop of props) {
108
- const resource = this[prop];
109
- if (resource instanceof BaseBuilder) {
110
- if (Reflect.getMetadata("protected", this, prop)) {
111
- console.log(` šŸ”’ Skipping protected resource "${prop}"`);
112
- continue;
259
+ const controller = new AbortController();
260
+ const hosts = [];
261
+ const context = {
262
+ abortSignal: controller.signal,
263
+ hosts,
264
+ stackName: this.constructor.name
265
+ };
266
+ return resourceContextStorage.run(context, async () => {
267
+ const originalLog = console.log;
268
+ console.log = (...args) => {
269
+ const redact = (message) => {
270
+ if (typeof message !== "string")
271
+ return message;
272
+ let result = message;
273
+ for (const secret of resolvedSecrets) {
274
+ if (secret && secret.length >= 3) {
275
+ const escaped = secret.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
276
+ const regex = new RegExp(escaped, 'g');
277
+ result = result.replace(regex, '********');
278
+ }
279
+ }
280
+ return result;
281
+ };
282
+ const redactedArgs = args.map(arg => {
283
+ if (typeof arg === "string") {
284
+ return redact(arg);
285
+ }
286
+ try {
287
+ const str = String(arg);
288
+ let hasSecret = false;
289
+ for (const secret of resolvedSecrets) {
290
+ if (secret && secret.length >= 3 && str.includes(secret)) {
291
+ hasSecret = true;
292
+ break;
293
+ }
294
+ }
295
+ if (hasSecret) {
296
+ return redact(str);
297
+ }
298
+ }
299
+ catch { }
300
+ return arg;
301
+ });
302
+ originalLog(...redactedArgs);
303
+ };
304
+ try {
305
+ console.log(`\nšŸ’„ Tearing down Stack: ${this.constructor.name}`);
306
+ // Stack-level beforeDestroy hook
307
+ if (typeof this.beforeDestroy === "function") {
308
+ console.log(` ⚔ Running Stack-level beforeDestroy hook...`);
309
+ await this.beforeDestroy();
310
+ }
311
+ const props = Object.getOwnPropertyNames(this).reverse();
312
+ const outputs = {};
313
+ const isParallel = Config.isParallelActive();
314
+ // 1. Gather all resources
315
+ const resources = [];
316
+ for (const prop of props) {
317
+ const val = this[prop];
318
+ if (val instanceof BaseBuilder) {
319
+ if (Reflect.getMetadata("protected", this, prop)) {
320
+ console.log(` šŸ”’ Skipping protected resource "${prop}"`);
321
+ continue;
322
+ }
323
+ resources.push({ prop, resource: val });
324
+ }
325
+ }
326
+ // 2. Schedule execution
327
+ if (isParallel) {
328
+ const startPromise = Promise.resolve();
329
+ // In parallel destroy, await all dependents (reverse dependencies) first
330
+ const promises = resources.map(({ prop, resource }) => {
331
+ resource._destroyPromise = (async () => {
332
+ try {
333
+ await startPromise;
334
+ if (controller.signal.aborted) {
335
+ throw new Error("Teardown aborted due to previous failure");
336
+ }
337
+ // Wait for all resources that explicitly declare this one as a dependency
338
+ const dependents = resources.filter(r => r.resource._dependencies.includes(resource));
339
+ for (const dep of dependents) {
340
+ if (dep.resource._destroyPromise) {
341
+ await dep.resource._destroyPromise;
342
+ }
343
+ }
344
+ if (controller.signal.aborted) {
345
+ throw new Error("Teardown aborted due to previous failure");
346
+ }
347
+ // Execute teardown
348
+ await resource._runBeforeDestroy();
349
+ const res = await resource.destroy();
350
+ await resource._runAfterDestroy(res);
351
+ outputs[prop] = res;
352
+ return res;
353
+ }
354
+ catch (err) {
355
+ controller.abort();
356
+ throw err;
357
+ }
358
+ })();
359
+ return resource._destroyPromise;
360
+ });
361
+ await Promise.all(promises);
113
362
  }
114
- outputs[prop] = await resource.destroy();
363
+ else {
364
+ // Sequential mode
365
+ for (const { prop, resource } of resources) {
366
+ if (controller.signal.aborted) {
367
+ throw new Error("Teardown aborted due to previous failure");
368
+ }
369
+ try {
370
+ await resource._runBeforeDestroy();
371
+ const res = await resource.destroy();
372
+ await resource._runAfterDestroy(res);
373
+ outputs[prop] = res;
374
+ }
375
+ catch (err) {
376
+ controller.abort();
377
+ throw err;
378
+ }
379
+ }
380
+ }
381
+ printOutputs(this.constructor.name, outputs);
382
+ // Stack-level afterDestroy hook
383
+ if (typeof this.afterDestroy === "function") {
384
+ console.log(` ⚔ Running Stack-level afterDestroy hook...`);
385
+ await this.afterDestroy(outputs);
386
+ }
387
+ return outputs;
115
388
  }
116
- }
117
- printOutputs(this.constructor.name, outputs);
118
- return outputs;
389
+ finally {
390
+ console.log = originalLog;
391
+ }
392
+ });
119
393
  }
120
394
  }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,7 @@ export * from "./core/stack.js";
2
2
  export * from "./core/decorators.js";
3
3
  export * from "./core/checker.js";
4
4
  export * from "./core/resource.js";
5
+ export { Secret } from "./core/secret.js";
6
+ export { Output } from "./core/output.js";
5
7
  export * as INVENTORY_TYPES from "./types/inventory.js";
8
+ export { SLACK, DISCORD } from "./core/hooks.js";
package/dist/index.js CHANGED
@@ -2,4 +2,7 @@ export * from "./core/stack.js";
2
2
  export * from "./core/decorators.js";
3
3
  export * from "./core/checker.js";
4
4
  export * from "./core/resource.js";
5
+ export { Secret } from "./core/secret.js";
6
+ export { Output } from "./core/output.js";
5
7
  export * as INVENTORY_TYPES from "./types/inventory.js";
8
+ export { SLACK, DISCORD } from "./core/hooks.js";