@ashlr/stack-core 0.1.1 → 0.3.0
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/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
6
|
+
import {
|
|
7
|
+
CONFIG_FILENAME,
|
|
8
|
+
LOCAL_FILENAME,
|
|
9
|
+
type ServiceEntry,
|
|
10
|
+
emptyConfig,
|
|
11
|
+
readConfig,
|
|
12
|
+
writeConfig,
|
|
13
|
+
} from "../config.ts";
|
|
14
|
+
|
|
15
|
+
const SERVICE_NAMES = ["__proto__", "constructor", "toString", "github"];
|
|
16
|
+
const ENTRY: ServiceEntry = {
|
|
17
|
+
provider: "github",
|
|
18
|
+
secrets: ["FIXTURE_TOKEN_SLOT"],
|
|
19
|
+
mcp: "fixture-mcp",
|
|
20
|
+
resource_id: "fixture-resource",
|
|
21
|
+
region: "fixture-region",
|
|
22
|
+
meta: { account: "fixture-local-account" },
|
|
23
|
+
created_at: "2026-10-14T00:00:00Z",
|
|
24
|
+
created_by: "fixture-user",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function expectOwnService(
|
|
28
|
+
services: Record<string, ServiceEntry>,
|
|
29
|
+
name: string,
|
|
30
|
+
entry: ServiceEntry,
|
|
31
|
+
): void {
|
|
32
|
+
expect(Object.getPrototypeOf(services)).toBe(Object.prototype);
|
|
33
|
+
expect(Object.hasOwn(services, name)).toBe(true);
|
|
34
|
+
expect(Object.keys(services)).toEqual([name]);
|
|
35
|
+
expect(services[name]).toEqual(entry);
|
|
36
|
+
expect(JSON.parse(JSON.stringify(services))).toEqual(Object.fromEntries([[name, entry]]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("service names remain own configuration data", () => {
|
|
40
|
+
let root: string;
|
|
41
|
+
let cwd: string;
|
|
42
|
+
let registryDir: string;
|
|
43
|
+
let previousRegistryDir: string | undefined;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
root = mkdtempSync(join(tmpdir(), "stack-own-services-"));
|
|
47
|
+
cwd = join(root, "project");
|
|
48
|
+
registryDir = join(root, "registry");
|
|
49
|
+
mkdirSync(cwd);
|
|
50
|
+
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
51
|
+
process.env.STACK_REGISTRY_DIR = registryDir;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
if (previousRegistryDir === undefined)
|
|
56
|
+
Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
|
|
57
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test.each(SERVICE_NAMES)("reads split files without losing the %s service", async (name) => {
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(cwd, CONFIG_FILENAME),
|
|
64
|
+
stringifyToml({
|
|
65
|
+
stack: { version: "1" },
|
|
66
|
+
services: Object.fromEntries([
|
|
67
|
+
[
|
|
68
|
+
name,
|
|
69
|
+
{
|
|
70
|
+
provider: ENTRY.provider,
|
|
71
|
+
secrets: ENTRY.secrets,
|
|
72
|
+
mcp: ENTRY.mcp,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
]),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(cwd, LOCAL_FILENAME),
|
|
80
|
+
stringifyToml({
|
|
81
|
+
stack: { project_id: "stk_fixture" },
|
|
82
|
+
services: Object.fromEntries([
|
|
83
|
+
[
|
|
84
|
+
name,
|
|
85
|
+
{
|
|
86
|
+
resource_id: ENTRY.resource_id,
|
|
87
|
+
region: ENTRY.region,
|
|
88
|
+
meta: ENTRY.meta,
|
|
89
|
+
created_at: ENTRY.created_at,
|
|
90
|
+
created_by: ENTRY.created_by,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
]),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const config = await readConfig(cwd);
|
|
98
|
+
|
|
99
|
+
expect(config.stack.project_id).toBe("stk_fixture");
|
|
100
|
+
expectOwnService(config.services, name, ENTRY);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test.each(SERVICE_NAMES)("keeps %s when no local service entry exists", async (name) => {
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(cwd, CONFIG_FILENAME),
|
|
106
|
+
stringifyToml({
|
|
107
|
+
stack: { version: "1" },
|
|
108
|
+
services: Object.fromEntries([
|
|
109
|
+
[name, { provider: ENTRY.provider, secrets: ENTRY.secrets }],
|
|
110
|
+
]),
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(cwd, LOCAL_FILENAME),
|
|
115
|
+
stringifyToml({
|
|
116
|
+
stack: { project_id: "stk_fixture" },
|
|
117
|
+
services: {},
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const config = await readConfig(cwd);
|
|
122
|
+
|
|
123
|
+
expectOwnService(config.services, name, {
|
|
124
|
+
provider: ENTRY.provider,
|
|
125
|
+
secrets: ENTRY.secrets,
|
|
126
|
+
created_at: "1970-01-01T00:00:00Z",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test.each(SERVICE_NAMES)(
|
|
131
|
+
"writes and registers %s while keeping instance fields local",
|
|
132
|
+
async (name) => {
|
|
133
|
+
const original = emptyConfig("fixture-template");
|
|
134
|
+
original.services = Object.fromEntries([[name, ENTRY]]);
|
|
135
|
+
|
|
136
|
+
await writeConfig(original, cwd);
|
|
137
|
+
|
|
138
|
+
const shapeText = readFileSync(join(cwd, CONFIG_FILENAME), "utf8");
|
|
139
|
+
const shape = parseToml(shapeText);
|
|
140
|
+
const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf8"));
|
|
141
|
+
expect(Object.hasOwn(shape.services as object, name)).toBe(true);
|
|
142
|
+
expect(Object.hasOwn(local.services as object, name)).toBe(true);
|
|
143
|
+
expect((shape.services as Record<string, unknown>)[name]).toEqual({
|
|
144
|
+
provider: ENTRY.provider,
|
|
145
|
+
secrets: ENTRY.secrets,
|
|
146
|
+
mcp: ENTRY.mcp,
|
|
147
|
+
});
|
|
148
|
+
expect((local.services as Record<string, unknown>)[name]).toEqual({
|
|
149
|
+
resource_id: ENTRY.resource_id,
|
|
150
|
+
region: ENTRY.region,
|
|
151
|
+
meta: ENTRY.meta,
|
|
152
|
+
created_at: ENTRY.created_at,
|
|
153
|
+
created_by: ENTRY.created_by,
|
|
154
|
+
});
|
|
155
|
+
expect(shapeText).not.toContain(ENTRY.resource_id!);
|
|
156
|
+
expect(shapeText).not.toContain("fixture-local-account");
|
|
157
|
+
expect(shapeText).not.toContain(original.stack.project_id);
|
|
158
|
+
expectOwnService((await readConfig(cwd)).services, name, ENTRY);
|
|
159
|
+
const registry = JSON.parse(readFileSync(join(registryDir, "projects.json"), "utf8"));
|
|
160
|
+
expect(registry.projects[0].services).toEqual([name]);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
test.each(SERVICE_NAMES)("migrates a legacy %s service without losing it", async (name) => {
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(cwd, CONFIG_FILENAME),
|
|
167
|
+
stringifyToml({
|
|
168
|
+
stack: { version: "1", project_id: "stk_legacy_fixture" },
|
|
169
|
+
services: Object.fromEntries([[name, ENTRY]]),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const legacy = await readConfig(cwd);
|
|
174
|
+
expectOwnService(legacy.services, name, ENTRY);
|
|
175
|
+
expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
|
|
176
|
+
|
|
177
|
+
await writeConfig(legacy, cwd);
|
|
178
|
+
|
|
179
|
+
const roundTrip = await readConfig(cwd);
|
|
180
|
+
expect(roundTrip.stack.project_id).toBe("stk_legacy_fixture");
|
|
181
|
+
expectOwnService(roundTrip.services, name, ENTRY);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync,
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
6
|
import {
|
|
6
7
|
CONFIG_FILENAME,
|
|
7
8
|
LOCAL_FILENAME,
|
|
8
9
|
emptyConfig,
|
|
10
|
+
getService,
|
|
9
11
|
readConfig,
|
|
10
12
|
writeConfig,
|
|
11
13
|
} from "../config.ts";
|
|
@@ -22,7 +24,8 @@ describe("committed + local config split", () => {
|
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
afterEach(() => {
|
|
25
|
-
if (previousRegistryDir === undefined)
|
|
27
|
+
if (previousRegistryDir === undefined)
|
|
28
|
+
Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
|
|
26
29
|
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
27
30
|
});
|
|
28
31
|
|
|
@@ -70,6 +73,71 @@ describe("committed + local config split", () => {
|
|
|
70
73
|
expect(roundtrip.services.supabase.secrets).toEqual(["FOO"]);
|
|
71
74
|
});
|
|
72
75
|
|
|
76
|
+
test("round-trips a service name containing a hyphen through both files", async () => {
|
|
77
|
+
const original = emptyConfig("fixture-template");
|
|
78
|
+
original.services["fixture-api"] = {
|
|
79
|
+
provider: "neon",
|
|
80
|
+
resource_id: "hyphen-resource",
|
|
81
|
+
region: "us-east-1",
|
|
82
|
+
secrets: ["FIXTURE_API_URL", "FIXTURE_API_KEY"],
|
|
83
|
+
mcp: "fixture-api",
|
|
84
|
+
meta: { endpoint: "fixture-endpoint" },
|
|
85
|
+
created_at: "2026-10-30T00:00:00Z",
|
|
86
|
+
created_by: "test",
|
|
87
|
+
};
|
|
88
|
+
await writeConfig(original, cwd);
|
|
89
|
+
|
|
90
|
+
const shape = parseToml(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8"));
|
|
91
|
+
const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8"));
|
|
92
|
+
expect(Object.keys(shape.services as object)).toEqual(["fixture-api"]);
|
|
93
|
+
expect(Object.keys(local.services as object)).toEqual(["fixture-api"]);
|
|
94
|
+
expect(shape.services).toEqual({
|
|
95
|
+
"fixture-api": {
|
|
96
|
+
provider: "neon",
|
|
97
|
+
secrets: ["FIXTURE_API_URL", "FIXTURE_API_KEY"],
|
|
98
|
+
mcp: "fixture-api",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
expect(local.services).toEqual({
|
|
102
|
+
"fixture-api": {
|
|
103
|
+
resource_id: "hyphen-resource",
|
|
104
|
+
region: "us-east-1",
|
|
105
|
+
meta: { endpoint: "fixture-endpoint" },
|
|
106
|
+
created_at: "2026-10-30T00:00:00Z",
|
|
107
|
+
created_by: "test",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const merged = await readConfig(cwd);
|
|
112
|
+
expect(Object.keys(merged.services)).toEqual(["fixture-api"]);
|
|
113
|
+
expect(getService(merged, "fixture-api")).toEqual(original.services["fixture-api"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("legacy file with a hyphenated service name migrates without losing it", async () => {
|
|
117
|
+
const legacy = `
|
|
118
|
+
[stack]
|
|
119
|
+
version = "1"
|
|
120
|
+
project_id = "stk_hyphen"
|
|
121
|
+
|
|
122
|
+
[services.fixture-api]
|
|
123
|
+
provider = "neon"
|
|
124
|
+
resource_id = "hyphen-legacy"
|
|
125
|
+
secrets = ["X"]
|
|
126
|
+
created_at = "2026-04-17T00:00:00Z"
|
|
127
|
+
`.trim();
|
|
128
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), legacy);
|
|
129
|
+
const config = await readConfig(cwd);
|
|
130
|
+
expect(Object.keys(config.services)).toEqual(["fixture-api"]);
|
|
131
|
+
expect(config.services["fixture-api"].resource_id).toBe("hyphen-legacy");
|
|
132
|
+
|
|
133
|
+
await writeConfig(config, cwd);
|
|
134
|
+
const shape = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
|
|
135
|
+
expect(shape).toContain("fixture-api");
|
|
136
|
+
expect(shape).not.toContain("hyphen-legacy");
|
|
137
|
+
const roundTrip = await readConfig(cwd);
|
|
138
|
+
expect(roundTrip.services["fixture-api"]).toEqual(config.services["fixture-api"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
73
141
|
test("writeConfig auto-appends .stack.local.toml to .gitignore", async () => {
|
|
74
142
|
await writeConfig(emptyConfig(), cwd);
|
|
75
143
|
const gitignore = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
@@ -136,3 +204,205 @@ default = true
|
|
|
136
204
|
expect(shape).toContain("OPENAI_API_KEY");
|
|
137
205
|
});
|
|
138
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("public config reference matches produced data", () => {
|
|
209
|
+
let cwd: string;
|
|
210
|
+
let registry: string;
|
|
211
|
+
let previousRegistryDir: string | undefined;
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
cwd = mkdtempSync(join(tmpdir(), "stack-schema-config-"));
|
|
215
|
+
registry = mkdtempSync(join(tmpdir(), "stack-schema-registry-"));
|
|
216
|
+
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
217
|
+
process.env.STACK_REGISTRY_DIR = registry;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
if (previousRegistryDir === undefined)
|
|
222
|
+
Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
|
|
223
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
224
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
225
|
+
rmSync(registry, { recursive: true, force: true });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
function reference() {
|
|
229
|
+
return JSON.parse(
|
|
230
|
+
readFileSync(new URL("../../../site/public/openapi.json", import.meta.url), "utf-8"),
|
|
231
|
+
).components.schemas;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// These are field-coverage checks on actual serialized/merged values, not a
|
|
235
|
+
// JSON Schema validator or a claim that readConfig validates arbitrary input.
|
|
236
|
+
function expectDocumentedFields(
|
|
237
|
+
value: object,
|
|
238
|
+
schema: { properties: object; required?: string[] },
|
|
239
|
+
) {
|
|
240
|
+
for (const key of Object.keys(value)) expect(schema.properties).toHaveProperty(key);
|
|
241
|
+
for (const key of schema.required ?? []) expect(value).toHaveProperty(key);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function expectTable(value: unknown): asserts value is Record<string, unknown> {
|
|
245
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
246
|
+
throw new Error("Expected a parsed TOML table in the synthetic config fixture.");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
test("StackToml describes the parsed committed output of a blank write", async () => {
|
|
251
|
+
await writeConfig(emptyConfig(), cwd);
|
|
252
|
+
const shape = parseToml(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8"));
|
|
253
|
+
const schema = reference().StackToml;
|
|
254
|
+
|
|
255
|
+
expectDocumentedFields(shape, schema);
|
|
256
|
+
expect(Object.keys(shape)).toEqual(["stack", "services", "environments"]);
|
|
257
|
+
expect(schema.required).toEqual(Object.keys(shape));
|
|
258
|
+
expect(schema.properties.stack.properties.version.type).toBe("string");
|
|
259
|
+
expect(schema.properties).not.toHaveProperty("version");
|
|
260
|
+
expect(schema.properties).not.toHaveProperty("project");
|
|
261
|
+
expect(shape.stack).toEqual({ version: "1" });
|
|
262
|
+
expect(shape.services).toEqual({});
|
|
263
|
+
expect(shape.environments).toEqual([{ name: "dev", default: true }]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("committed service fields stay separate from the full merged ServiceEntry", async () => {
|
|
267
|
+
const config = emptyConfig("fixture-template");
|
|
268
|
+
const metadata = { note: "synthetic metadata", nested: { enabled: true } };
|
|
269
|
+
config.services.primary = {
|
|
270
|
+
provider: "supabase",
|
|
271
|
+
resource_id: "fixture-resource",
|
|
272
|
+
region: "fixture-region",
|
|
273
|
+
secrets: ["FIXTURE_URL"],
|
|
274
|
+
mcp: "fixture-mcp",
|
|
275
|
+
meta: metadata,
|
|
276
|
+
created_at: "fixture-time",
|
|
277
|
+
created_by: "fixture-owner",
|
|
278
|
+
};
|
|
279
|
+
await writeConfig(config, cwd);
|
|
280
|
+
const shape = parseToml(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8"));
|
|
281
|
+
const local = parseToml(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8"));
|
|
282
|
+
const merged = await readConfig(cwd);
|
|
283
|
+
const schemas = reference();
|
|
284
|
+
const committed = schemas.StackToml.properties.services.additionalProperties;
|
|
285
|
+
expectTable(shape.services);
|
|
286
|
+
expectTable(shape.services.primary);
|
|
287
|
+
|
|
288
|
+
expect(committed).not.toHaveProperty("$ref");
|
|
289
|
+
expectDocumentedFields(shape.services.primary, committed);
|
|
290
|
+
expect(committed.required).toEqual(["provider", "secrets"]);
|
|
291
|
+
expect(Object.keys(committed.properties)).toEqual(["provider", "secrets", "mcp"]);
|
|
292
|
+
expect(shape.services).toEqual({
|
|
293
|
+
primary: { provider: "supabase", secrets: ["FIXTURE_URL"], mcp: "fixture-mcp" },
|
|
294
|
+
});
|
|
295
|
+
expect(local.stack).toEqual({ project_id: config.stack.project_id });
|
|
296
|
+
expect(local.services).toEqual({
|
|
297
|
+
primary: {
|
|
298
|
+
resource_id: "fixture-resource",
|
|
299
|
+
region: "fixture-region",
|
|
300
|
+
meta: metadata,
|
|
301
|
+
created_at: "fixture-time",
|
|
302
|
+
created_by: "fixture-owner",
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
expect(merged.services.primary).toEqual(config.services.primary);
|
|
306
|
+
expectDocumentedFields(merged.services.primary, schemas.ServiceEntry);
|
|
307
|
+
expect(schemas.ServiceEntry.required).toEqual(["provider", "secrets", "created_at"]);
|
|
308
|
+
expect(schemas.ServiceEntry.properties.created_at).not.toHaveProperty("format");
|
|
309
|
+
expect(schemas.ServiceEntry.properties.meta.type).toBe("object");
|
|
310
|
+
expect(schemas.ServiceEntry.properties.secrets.items.type).toBe("string");
|
|
311
|
+
expectDocumentedFields(merged.stack, {
|
|
312
|
+
properties: { version: {}, project_id: {}, template: {} },
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("merged entry documentation covers actual instances independently of the committed subset", async () => {
|
|
317
|
+
const config = emptyConfig();
|
|
318
|
+
config.services.custom_slot = {
|
|
319
|
+
provider: "neon",
|
|
320
|
+
secrets: [],
|
|
321
|
+
resource_id: "opaque-fixture-id",
|
|
322
|
+
meta: { arbitrary: "not a credential fixture" },
|
|
323
|
+
created_at: "not-a-date-validator",
|
|
324
|
+
};
|
|
325
|
+
await writeConfig(config, cwd);
|
|
326
|
+
const merged = await readConfig(cwd);
|
|
327
|
+
const schema = reference().ServiceEntry;
|
|
328
|
+
|
|
329
|
+
expectDocumentedFields(merged.services.custom_slot, schema);
|
|
330
|
+
expect(schema.properties.resource_id.type).toBe("string");
|
|
331
|
+
expect(schema.properties.meta.type).toBe("object");
|
|
332
|
+
expect(schema.properties.created_at.type).toBe("string");
|
|
333
|
+
expect(merged.services.custom_slot.secrets).toEqual([]);
|
|
334
|
+
expect(merged.services.custom_slot.mcp).toBeUndefined();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("the writer preserves other version strings and an empty environments array", async () => {
|
|
338
|
+
const config = emptyConfig();
|
|
339
|
+
config.stack.version = "fixture-future-version";
|
|
340
|
+
config.environments = [];
|
|
341
|
+
config.services.first = { provider: "neon", secrets: [], created_at: "fixture" };
|
|
342
|
+
config.services.second = { provider: "neon", secrets: [], created_at: "fixture" };
|
|
343
|
+
await writeConfig(config, cwd);
|
|
344
|
+
const shape = parseToml(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8"));
|
|
345
|
+
const schema = reference().StackToml;
|
|
346
|
+
|
|
347
|
+
expect(shape.stack).toEqual({ version: "fixture-future-version" });
|
|
348
|
+
expect(shape.environments).toEqual([]);
|
|
349
|
+
expect(shape.services).toEqual({
|
|
350
|
+
first: { provider: "neon", secrets: [] },
|
|
351
|
+
second: { provider: "neon", secrets: [] },
|
|
352
|
+
});
|
|
353
|
+
expect(schema.properties.stack.properties.version.type).toBe(typeof config.stack.version);
|
|
354
|
+
expect(schema.properties.stack.properties.version).not.toHaveProperty("const");
|
|
355
|
+
expect(schema.properties.stack.properties.version).not.toHaveProperty("enum");
|
|
356
|
+
expect(schema.properties.environments.type).toBe("array");
|
|
357
|
+
expect(schema.properties.environments).not.toHaveProperty("minItems");
|
|
358
|
+
expect((await readConfig(cwd)).environments).toEqual([{ name: "dev", default: true }]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("environment documentation covers actual overrides without enforcing one default", async () => {
|
|
362
|
+
const config = emptyConfig();
|
|
363
|
+
config.environments = [
|
|
364
|
+
{ name: "one", default: true, overrides: { FIXTURE_URL: "_ONE" } },
|
|
365
|
+
{ name: "two", default: true },
|
|
366
|
+
];
|
|
367
|
+
await writeConfig(config, cwd);
|
|
368
|
+
const merged = await readConfig(cwd);
|
|
369
|
+
const schema = reference().StackToml.properties.environments.items;
|
|
370
|
+
|
|
371
|
+
expect(merged.environments).toEqual(config.environments);
|
|
372
|
+
for (const entry of merged.environments) expectDocumentedFields(entry, schema);
|
|
373
|
+
expect(schema.required).toEqual(["name"]);
|
|
374
|
+
expect(schema.properties.name.type).toBe("string");
|
|
375
|
+
expect(schema.properties.default.type).toBe("boolean");
|
|
376
|
+
expect(schema.properties.overrides.additionalProperties.type).toBe("string");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("normal defaults and legacy precedence remain distinct from canonical file documentation", async () => {
|
|
380
|
+
writeFileSync(
|
|
381
|
+
join(cwd, CONFIG_FILENAME),
|
|
382
|
+
'[stack]\nversion = "fixture"\n[services.item]\nprovider = "neon"\nsecrets = []\nregion = "ignored-shape-region"\n',
|
|
383
|
+
);
|
|
384
|
+
writeFileSync(
|
|
385
|
+
join(cwd, LOCAL_FILENAME),
|
|
386
|
+
'[stack]\nproject_id = "fixture-local"\n[services.item]\nregion = "local-region"\n',
|
|
387
|
+
);
|
|
388
|
+
const normal = await readConfig(cwd);
|
|
389
|
+
expect(normal.stack.project_id).toBe("fixture-local");
|
|
390
|
+
expect(normal.services.item.region).toBe("local-region");
|
|
391
|
+
expect(normal.services.item.created_at).toBe("1970-01-01T00:00:00Z");
|
|
392
|
+
expect(normal.environments).toEqual([{ name: "dev", default: true }]);
|
|
393
|
+
|
|
394
|
+
writeFileSync(
|
|
395
|
+
join(cwd, CONFIG_FILENAME),
|
|
396
|
+
'environments = []\n[stack]\nversion = "fixture"\nproject_id = "fixture-legacy"\n[services.item]\nresource_id = "legacy-resource"\n',
|
|
397
|
+
);
|
|
398
|
+
const legacy = await readConfig(cwd);
|
|
399
|
+
expect(legacy.stack.project_id).toBe("fixture-legacy");
|
|
400
|
+
expect(legacy.services.item.provider).toBe("item");
|
|
401
|
+
expect(legacy.services.item.secrets).toEqual([]);
|
|
402
|
+
expect(legacy.services.item.resource_id).toBe("legacy-resource");
|
|
403
|
+
expect(legacy.services.item.region).toBeUndefined();
|
|
404
|
+
expect(legacy.environments).toEqual([]);
|
|
405
|
+
expect(legacy.services.item.created_at).not.toBe("1970-01-01T00:00:00Z");
|
|
406
|
+
expect(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8")).toContain("fixture-local");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
CONFIG_FILENAME,
|
|
7
|
+
LOCAL_FILENAME,
|
|
7
8
|
emptyConfig,
|
|
8
9
|
hasConfig,
|
|
9
10
|
readConfig,
|
|
@@ -11,8 +12,15 @@ import {
|
|
|
11
12
|
writeConfig,
|
|
12
13
|
} from "../config.ts";
|
|
13
14
|
|
|
15
|
+
const scratch: string[] = [];
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
14
20
|
function freshCwd(): string {
|
|
15
|
-
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-test-"));
|
|
22
|
+
scratch.push(dir);
|
|
23
|
+
return dir;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
describe("config", () => {
|
|
@@ -20,11 +28,12 @@ describe("config", () => {
|
|
|
20
28
|
|
|
21
29
|
beforeEach(() => {
|
|
22
30
|
previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
23
|
-
process.env.STACK_REGISTRY_DIR =
|
|
31
|
+
process.env.STACK_REGISTRY_DIR = freshCwd();
|
|
24
32
|
});
|
|
25
33
|
|
|
26
34
|
afterEach(() => {
|
|
27
|
-
if (previousRegistryDir === undefined)
|
|
35
|
+
if (previousRegistryDir === undefined)
|
|
36
|
+
Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
|
|
28
37
|
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
29
38
|
});
|
|
30
39
|
|
|
@@ -63,4 +72,85 @@ describe("config", () => {
|
|
|
63
72
|
const cwd = freshCwd();
|
|
64
73
|
await expect(readConfig(cwd)).rejects.toThrow(/No .stack.toml at/);
|
|
65
74
|
});
|
|
75
|
+
test.each([".stack.toml", ".stack.local.toml"])(
|
|
76
|
+
"rejects malformed %s without rewriting files",
|
|
77
|
+
async (file) => {
|
|
78
|
+
const cwd = freshCwd();
|
|
79
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), '[stack]\nversion = "1"');
|
|
80
|
+
writeFileSync(join(cwd, file), "[broken");
|
|
81
|
+
await expect(readConfig(cwd)).rejects.toThrow();
|
|
82
|
+
expect(readFileSync(join(cwd, file), "utf-8")).toBe("[broken");
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
test.each(["", "node_modules/", "node_modules/\n", " .stack.local.toml \r\n"])(
|
|
87
|
+
"gitignore update preserves existing content %j and is idempotent",
|
|
88
|
+
async (initial) => {
|
|
89
|
+
const cwd = freshCwd();
|
|
90
|
+
writeFileSync(join(cwd, ".gitignore"), initial);
|
|
91
|
+
const config = emptyConfig();
|
|
92
|
+
await writeConfig(config, cwd);
|
|
93
|
+
const first = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
94
|
+
expect(first.startsWith(initial)).toBe(true);
|
|
95
|
+
expect(
|
|
96
|
+
first.split(/\r?\n/).filter((line) => line.trim() === ".stack.local.toml"),
|
|
97
|
+
).toHaveLength(1);
|
|
98
|
+
await writeConfig(config, cwd);
|
|
99
|
+
expect(readFileSync(join(cwd, ".gitignore"), "utf-8")).toBe(first);
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
test("empty shape supplies defaults without creating local files", async () => {
|
|
104
|
+
const cwd = freshCwd();
|
|
105
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), "");
|
|
106
|
+
const config = await readConfig(cwd);
|
|
107
|
+
expect(config.stack.version).toBe("1");
|
|
108
|
+
expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
|
|
109
|
+
expect(config.services).toEqual({});
|
|
110
|
+
expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe("");
|
|
111
|
+
expect(existsSync(join(cwd, LOCAL_FILENAME))).toBe(false);
|
|
112
|
+
expect(existsSync(join(cwd, ".gitignore"))).toBe(false);
|
|
113
|
+
expect(config.environments).toEqual([{ name: "dev", default: true }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("empty shape keeps the local project id but does not add local-only services", async () => {
|
|
117
|
+
const cwd = freshCwd();
|
|
118
|
+
const local =
|
|
119
|
+
'[stack]\nproject_id = "stk_fixture"\n[services.orphan]\nresource_id = "fixture-resource"\n';
|
|
120
|
+
writeFileSync(join(cwd, CONFIG_FILENAME), "");
|
|
121
|
+
writeFileSync(join(cwd, LOCAL_FILENAME), local);
|
|
122
|
+
|
|
123
|
+
expect(await readConfig(cwd)).toEqual({
|
|
124
|
+
stack: { version: "1", project_id: "stk_fixture" },
|
|
125
|
+
services: {},
|
|
126
|
+
environments: [{ name: "dev", default: true }],
|
|
127
|
+
});
|
|
128
|
+
expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe("");
|
|
129
|
+
expect(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8")).toBe(local);
|
|
130
|
+
expect(existsSync(join(cwd, ".gitignore"))).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
test.each(['created_at = "2026-01-01T00:00:00Z"', 'resource_id = "resource-1"'])(
|
|
133
|
+
"legacy service marker %s normalizes optional fields",
|
|
134
|
+
async (marker) => {
|
|
135
|
+
const cwd = freshCwd();
|
|
136
|
+
writeFileSync(
|
|
137
|
+
join(cwd, CONFIG_FILENAME),
|
|
138
|
+
`[services.example]\n${marker}\nmcp = "example"\nregion = "local"\ncreated_by = "test"\n[services.example.meta]\ncount = 2\n`,
|
|
139
|
+
);
|
|
140
|
+
const config = await readConfig(cwd);
|
|
141
|
+
expect(config.stack.version).toBe("1");
|
|
142
|
+
expect(config.stack.project_id).toMatch(/^stk_[a-f0-9]{12}$/);
|
|
143
|
+
expect(config.services.example).toMatchObject({
|
|
144
|
+
provider: "example",
|
|
145
|
+
secrets: [],
|
|
146
|
+
mcp: "example",
|
|
147
|
+
region: "local",
|
|
148
|
+
created_by: "test",
|
|
149
|
+
meta: { count: 2 },
|
|
150
|
+
});
|
|
151
|
+
expect(Number.isNaN(Date.parse(config.services.example!.created_at))).toBe(false);
|
|
152
|
+
await writeConfig(config, cwd);
|
|
153
|
+
expect(await readConfig(cwd)).toEqual(config);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
66
156
|
});
|