@ashlr/stack-core 0.1.1 → 0.2.2

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 (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. package/src/templates.ts +6 -2
@@ -0,0 +1,269 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { emptyConfig, readConfig, writeConfig } from "../config.ts";
6
+ import { StackError } from "../errors.ts";
7
+ import { addService } from "../pipeline.ts";
8
+ import type { LogEvent, ProviderContext } from "../providers/_base.ts";
9
+ import { providers } from "../providers/index.ts";
10
+ import supabase from "../providers/supabase.ts";
11
+ import turso from "../providers/turso.ts";
12
+ import vercel from "../providers/vercel.ts";
13
+ import { setupFakePhantom } from "./_harness.ts";
14
+
15
+ const inertPreconnect: typeof fetch.preconnect = () => {
16
+ throw new Error("Unexpected fetch preconnect in inert fixture.");
17
+ };
18
+
19
+ const token = "synthetic-token-sentinel";
20
+ const resourceSentinel = "synthetic-password-query-sentinel";
21
+ const fixtures = [
22
+ {
23
+ provider: vercel,
24
+ resource: resourceSentinel,
25
+ url: `https://api.vercel.com/v9/projects/${resourceSentinel}`,
26
+ successful: [204],
27
+ unconfirmed: [200, 202, 404, 410, 401, 403, 429, 500],
28
+ },
29
+ {
30
+ provider: supabase,
31
+ resource: resourceSentinel,
32
+ url: `https://api.supabase.com/v1/projects/${resourceSentinel}`,
33
+ successful: [200],
34
+ unconfirmed: [204, 202, 404, 410, 401, 403, 429, 500],
35
+ },
36
+ {
37
+ provider: turso,
38
+ resource: `synthetic-org/${resourceSentinel}`,
39
+ url: `https://api.turso.tech/v1/organizations/synthetic-org/databases/${resourceSentinel}`,
40
+ successful: [200, 404],
41
+ unconfirmed: [204, 202, 410, 401, 403, 429, 500],
42
+ },
43
+ ];
44
+ const originalFetch = globalThis.fetch;
45
+
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ });
49
+
50
+ function inertResponse(status: number, bodyReads: { count: number }): Response {
51
+ const response = new Response(null, { status });
52
+ for (const method of ["text", "json", "arrayBuffer", "blob", "formData"] as const) {
53
+ Object.defineProperty(response, method, {
54
+ value: () => {
55
+ bodyReads.count++;
56
+ throw new Error("synthetic-response-body-secret-sentinel");
57
+ },
58
+ });
59
+ }
60
+ return response;
61
+ }
62
+
63
+ function context(logs: LogEvent[], cwd = process.cwd()): ProviderContext {
64
+ return { cwd, interactive: false, log: (event) => logs.push(event) };
65
+ }
66
+
67
+ describe("provider deletion completion (inert fetch only)", () => {
68
+ for (const fixture of fixtures) {
69
+ const { provider } = fixture;
70
+ for (const status of fixture.successful) {
71
+ test(`${provider.name} admits documented HTTP ${status} with one exact DELETE`, async () => {
72
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
73
+ const bodyReads = { count: 0 };
74
+ const logs: LogEvent[] = [];
75
+ globalThis.fetch = Object.assign(
76
+ async (url: string | URL | Request, init?: RequestInit) => {
77
+ calls.push({ url: String(url), init });
78
+ return inertResponse(status, bodyReads);
79
+ },
80
+ { preconnect: inertPreconnect },
81
+ );
82
+ await provider.deprovision?.(context(logs), { token }, fixture.resource);
83
+ expect(calls).toHaveLength(1);
84
+ expect(calls[0].url).toBe(fixture.url);
85
+ expect(calls[0].init?.method).toBe("DELETE");
86
+ expect(new Headers(calls[0].init?.headers).get("authorization")).toBe(`Bearer ${token}`);
87
+ expect(calls[0].init?.body).toBeUndefined();
88
+ expect(bodyReads.count).toBe(0);
89
+ expect(logs).toEqual([]);
90
+ });
91
+ }
92
+
93
+ test(`${provider.name} refuses unsupported completion, permission and server statuses`, async () => {
94
+ for (const status of fixture.unconfirmed) {
95
+ let contacts = 0;
96
+ const bodyReads = { count: 0 };
97
+ const logs: LogEvent[] = [];
98
+ globalThis.fetch = Object.assign(
99
+ async () => {
100
+ contacts++;
101
+ return inertResponse(status, bodyReads);
102
+ },
103
+ { preconnect: inertPreconnect },
104
+ );
105
+ const error = await provider
106
+ .deprovision?.(context(logs), { token }, fixture.resource)
107
+ .then(() => undefined)
108
+ .catch((error: unknown) => error);
109
+ expect(error).toBeInstanceOf(StackError);
110
+ expect(error).toMatchObject({ code: `${provider.name.toUpperCase()}_DELETE_FAILED` });
111
+ expect((error as Error).message).toBe(
112
+ `${provider.displayName} resource deletion failed (HTTP ${status}).`,
113
+ );
114
+ expect((error as Error).cause).toBeUndefined();
115
+ expect(contacts).toBe(1);
116
+ expect(bodyReads.count).toBe(0);
117
+ expect(logs).toEqual([]);
118
+ }
119
+ });
120
+
121
+ test(`${provider.name} refuses Error and non-Error transport outcomes without credential output`, async () => {
122
+ for (const failure of [
123
+ new Error(`synthetic-transport-secret-sentinel ${token} ${resourceSentinel}`),
124
+ {
125
+ token,
126
+ resourceSentinel,
127
+ url: "https://user:password-sentinel@example.invalid/?query=secret",
128
+ },
129
+ ]) {
130
+ let contacts = 0;
131
+ const logs: LogEvent[] = [];
132
+ globalThis.fetch = Object.assign(
133
+ async () => {
134
+ contacts++;
135
+ throw failure;
136
+ },
137
+ { preconnect: inertPreconnect },
138
+ );
139
+ const error = await provider
140
+ .deprovision?.(context(logs), { token }, fixture.resource)
141
+ .then(() => undefined)
142
+ .catch((error: unknown) => error);
143
+ expect(error).toBeInstanceOf(StackError);
144
+ expect(error).toMatchObject({ code: `${provider.name.toUpperCase()}_DELETE_UNCONFIRMED` });
145
+ expect((error as Error).message).toBe(
146
+ `${provider.displayName} resource deletion could not be confirmed.`,
147
+ );
148
+ expect((error as Error).cause).toBeUndefined();
149
+ expect(contacts).toBe(1);
150
+ expect(logs).toEqual([]);
151
+ }
152
+ });
153
+ }
154
+
155
+ test("Turso refuses only originally rejected missing components before contact", async () => {
156
+ let contacts = 0;
157
+ const logs: LogEvent[] = [];
158
+ globalThis.fetch = Object.assign(
159
+ async () => {
160
+ contacts++;
161
+ throw new Error("an invalid selector must not contact the provider");
162
+ },
163
+ { preconnect: inertPreconnect },
164
+ );
165
+ for (const resource of ["", "missing-db-secret-sentinel", "/missing-org-secret", "org/"]) {
166
+ const error = await turso
167
+ .deprovision?.(context(logs), { token }, resource)
168
+ .then(() => undefined)
169
+ .catch((error: unknown) => error);
170
+ expect(error).toBeInstanceOf(StackError);
171
+ expect(error).toMatchObject({ code: "TURSO_DELETE_ID_INVALID" });
172
+ expect((error as Error).message).toBe(
173
+ "Turso resource deletion requires an organization and database.",
174
+ );
175
+ }
176
+ expect(contacts).toBe(0);
177
+ expect(logs).toEqual([]);
178
+ });
179
+
180
+ test("Turso retains the existing extra-segment routing behavior", async () => {
181
+ const calls: string[] = [];
182
+ globalThis.fetch = Object.assign(
183
+ async (url: string | URL | Request) => {
184
+ calls.push(String(url));
185
+ return new Response(null, { status: 200 });
186
+ },
187
+ { preconnect: inertPreconnect },
188
+ );
189
+ await turso.deprovision?.(context([]), { token }, "synthetic-org/synthetic-db/ignored-segment");
190
+ expect(calls).toEqual([
191
+ "https://api.turso.tech/v1/organizations/synthetic-org/databases/synthetic-db",
192
+ ]);
193
+ });
194
+ });
195
+
196
+ describe("provider deletion failure in the approved pipeline custody guards", () => {
197
+ for (const fixture of fixtures) {
198
+ for (const attached of [false, true]) {
199
+ test(`${fixture.provider.name} ${attached ? "preserves attached resources" : "reports created cleanup as partial"}`, async () => {
200
+ const h = setupFakePhantom();
201
+ const cwd = mkdtempSync(join(tmpdir(), "stack-delete-pipeline-"));
202
+ const name = `delete-fixture-${fixture.provider.name}`;
203
+ const logs: LogEvent[] = [];
204
+ let contacts = 0;
205
+ const bodyReads = { count: 0 };
206
+ let requested: string | undefined;
207
+ globalThis.fetch = Object.assign(
208
+ async () => {
209
+ contacts++;
210
+ return inertResponse(403, bodyReads);
211
+ },
212
+ { preconnect: inertPreconnect },
213
+ );
214
+ providers[name] = async () => ({
215
+ ...fixture.provider,
216
+ name,
217
+ async login() {
218
+ return { token };
219
+ },
220
+ async provision(_ctx, _auth, opts) {
221
+ requested = opts.existingResourceId;
222
+ return { id: fixture.resource, displayName: "synthetic fixture" };
223
+ },
224
+ async materialize() {
225
+ throw new Error("synthetic original materialize failure");
226
+ },
227
+ });
228
+ try {
229
+ await writeConfig(emptyConfig("synthetic-delete"), cwd);
230
+ const configBefore = readFileSync(join(cwd, ".stack.toml"));
231
+ const instanceBefore = readFileSync(join(cwd, ".stack.local.toml"));
232
+ const error = await addService({
233
+ providerName: name,
234
+ cwd,
235
+ interactive: false,
236
+ existingResourceId: attached ? "requested-existing-safe-id" : undefined,
237
+ log: (event) => logs.push(event),
238
+ }).catch((error: unknown) => error);
239
+ expect(error).toMatchObject({ code: "ADD_SERVICE_PARTIAL_FAILURE" });
240
+ const message = (error as Error).message;
241
+ expect(message).toContain("synthetic original materialize failure");
242
+ expect(message).not.toContain("has been torn down");
243
+ expect(message).not.toContain("synthetic-response-body-secret-sentinel");
244
+ if (attached) {
245
+ expect(requested).toBe("requested-existing-safe-id");
246
+ expect(message).toContain("preserved");
247
+ expect(contacts).toBe(0);
248
+ } else {
249
+ expect(requested).toBeUndefined();
250
+ expect(message).toContain("may still exist");
251
+ expect(message).toContain("HTTP 403");
252
+ expect(contacts).toBe(1);
253
+ }
254
+ expect(bodyReads.count).toBe(0);
255
+ expect(logs).toEqual([]);
256
+ expect(h.callsTo("add")).toEqual([]);
257
+ expect(h.callsTo("remove")).toEqual([]);
258
+ expect((await readConfig(cwd)).services[name]).toBeUndefined();
259
+ expect(readFileSync(join(cwd, ".stack.toml"))).toEqual(configBefore);
260
+ expect(readFileSync(join(cwd, ".stack.local.toml"))).toEqual(instanceBefore);
261
+ } finally {
262
+ Reflect.deleteProperty(providers, name);
263
+ h.cleanup();
264
+ rmSync(cwd, { recursive: true, force: true });
265
+ }
266
+ });
267
+ }
268
+ }
269
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { PROVIDERS_REF } from "../catalog.ts";
5
+ import { getProvider, listProviderNames } from "../providers/index.ts";
6
+
7
+ /**
8
+ * A provider is registered in several places (docs/adding-a-provider.md).
9
+ * These checks catch a provider added to one list and forgotten in another.
10
+ */
11
+
12
+ const repoRoot = join(import.meta.dir, "..", "..", "..", "..");
13
+
14
+ function matrixRows(): Array<{ name: string; category: string; authKind: string }> {
15
+ const md = readFileSync(join(repoRoot, "docs", "provider-auth-matrix.md"), "utf8");
16
+ return [...md.matchAll(/^\| ([a-z0-9]+) \| ([a-z]+) \| ([a-z_]+) \|/gm)].map((m) => ({
17
+ name: m[1] as string,
18
+ category: m[2] as string,
19
+ authKind: m[3] as string,
20
+ }));
21
+ }
22
+
23
+ describe("provider registration parity", () => {
24
+ test("the loader registry and the catalog name the same providers", () => {
25
+ const catalog = PROVIDERS_REF.map((p) => p.name).sort();
26
+ expect(listProviderNames()).toEqual(catalog);
27
+ });
28
+
29
+ test("docs/provider-auth-matrix.md has one row per provider, matching its module", async () => {
30
+ const rows = matrixRows();
31
+ expect(rows.map((r) => r.name).sort()).toEqual(listProviderNames());
32
+ for (const row of rows) {
33
+ const provider = await getProvider(row.name);
34
+ expect(row).toEqual({
35
+ name: provider.name,
36
+ category: provider.category,
37
+ authKind: provider.authKind,
38
+ });
39
+ }
40
+ });
41
+
42
+ test("the matrix header states the current provider count", () => {
43
+ const md = readFileSync(join(repoRoot, "docs", "provider-auth-matrix.md"), "utf8");
44
+ expect(md).toContain(`(${listProviderNames().length} providers)`);
45
+ });
46
+ });