@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
package/src/ai/phantom-wire.ts
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recipe → Phantom
|
|
2
|
+
* Recipe → Phantom vault check + webhook stub materializer.
|
|
3
3
|
*
|
|
4
4
|
* For each provider in a Recipe:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
5
|
+
* 1. Report which declared secrets are already in the Phantom vault and
|
|
6
|
+
* which are missing. `stack add` stores the real values; this step never
|
|
7
|
+
* writes to the vault. Phantom Secrets refuses secret values in argv and
|
|
8
|
+
* denies overwriting existing names, so placeholder entries would either
|
|
9
|
+
* fail or block the real value from landing later.
|
|
7
10
|
* 2. For webhook-capable providers (Stripe, Clerk, Supabase, GitHub),
|
|
8
11
|
* scaffold a commented-out handler stub in `.stack/webhooks/<provider>.ts`
|
|
9
|
-
*
|
|
12
|
+
* as an example only. Signature verification is not implemented: the
|
|
13
|
+
* stub's TODO states the trust limitation — a present signature header
|
|
14
|
+
* authenticates nothing and the body is untrusted input — and points at
|
|
15
|
+
* the provider-specific verification to add before deploying a handler.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* An opt-out (`--no-wire`) skips both steps.
|
|
18
|
+
*
|
|
19
|
+
* Recipe manifests are unsigned: signature verification is not implemented.
|
|
20
|
+
* Checking provider names against the catalog does not authenticate a recipe's
|
|
21
|
+
* origin or integrity; callers must use recipes from a trusted source.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
25
|
import { dirname, resolve } from "node:path";
|
|
19
26
|
import { findProviderRef } from "../catalog.ts";
|
|
20
|
-
import {
|
|
21
|
-
assertPhantomInstalled,
|
|
22
|
-
exec as phantomExec,
|
|
23
|
-
PhantomExecResult,
|
|
24
|
-
} from "../phantom.ts";
|
|
25
27
|
import { PhantomNotInstalledError } from "../errors.ts";
|
|
28
|
+
import { assertPhantomInstalled, listSecrets } from "../phantom.ts";
|
|
26
29
|
import type { Recipe } from "./recipe.ts";
|
|
27
30
|
|
|
28
31
|
export interface WireResult {
|
|
29
|
-
/** `PROVIDER:SECRET_NAME` entries
|
|
30
|
-
|
|
32
|
+
/** `PROVIDER:SECRET_NAME` entries already stored in the Phantom vault. */
|
|
33
|
+
present: string[];
|
|
34
|
+
/** `PROVIDER:SECRET_NAME` entries the recipe declares but the vault lacks. */
|
|
35
|
+
missing: string[];
|
|
31
36
|
/** Absolute paths of webhook stub files created. */
|
|
32
37
|
webhooks: string[];
|
|
33
|
-
/** Names of providers skipped (phantom missing, unknown provider
|
|
38
|
+
/** Names of providers skipped (phantom missing, vault unreadable, unknown provider). */
|
|
34
39
|
skipped: string[];
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
const PLACEHOLDER = "TODO_STACK_APPLY";
|
|
38
|
-
|
|
39
42
|
/**
|
|
40
|
-
* Providers with webhooks worth pre-wiring. Keep this list tight
|
|
41
|
-
*
|
|
42
|
-
* without also adding its stub produces an empty file, which is worse than
|
|
43
|
-
* no file.
|
|
43
|
+
* Providers with webhooks worth pre-wiring. Keep this list tight and update
|
|
44
|
+
* the provider-specific secret and signature header mappings for each entry.
|
|
44
45
|
*/
|
|
45
46
|
const WEBHOOK_PROVIDERS = new Set(["stripe", "clerk", "supabase", "github"]);
|
|
46
47
|
|
|
@@ -48,7 +49,7 @@ export async function wirePhantomForRecipe(
|
|
|
48
49
|
recipe: Recipe,
|
|
49
50
|
opts: { cwd?: string; noWire?: boolean } = {},
|
|
50
51
|
): Promise<WireResult> {
|
|
51
|
-
const result: WireResult = {
|
|
52
|
+
const result: WireResult = { present: [], missing: [], webhooks: [], skipped: [] };
|
|
52
53
|
if (opts.noWire) return result;
|
|
53
54
|
|
|
54
55
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -57,7 +58,7 @@ export async function wirePhantomForRecipe(
|
|
|
57
58
|
await assertPhantomInstalled();
|
|
58
59
|
} catch (err) {
|
|
59
60
|
if (err instanceof PhantomNotInstalledError) {
|
|
60
|
-
// Phantom missing → skip
|
|
61
|
+
// Phantom missing → skip the vault check but still drop webhook stubs
|
|
61
62
|
// (they're just files). Surface the provider names so the CLI can tell
|
|
62
63
|
// the user why nothing landed in the vault.
|
|
63
64
|
for (const p of recipe.providers) result.skipped.push(p.name);
|
|
@@ -72,16 +73,16 @@ export async function wirePhantomForRecipe(
|
|
|
72
73
|
throw err;
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
const stored = await listVaultNames(cwd);
|
|
77
|
+
|
|
75
78
|
for (const { name } of recipe.providers) {
|
|
76
79
|
const ref = findProviderRef(name);
|
|
77
|
-
if (!ref) {
|
|
80
|
+
if (!ref || !stored) {
|
|
78
81
|
result.skipped.push(name);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const added = await addEnvelope(secret, cwd);
|
|
84
|
-
if (added) result.envelopes.push(`${name}:${secret}`);
|
|
82
|
+
} else {
|
|
83
|
+
for (const secret of ref.secrets) {
|
|
84
|
+
(stored.has(secret) ? result.present : result.missing).push(`${name}:${secret}`);
|
|
85
|
+
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
if (WEBHOOK_PROVIDERS.has(name)) {
|
|
@@ -94,34 +95,15 @@ export async function wirePhantomForRecipe(
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
98
|
+
* Secret names in the project's Phantom vault, via `phantom list --json`
|
|
99
|
+
* (names and metadata only, never values). Returns undefined when the vault
|
|
100
|
+
* can't be read (not initialized, older Phantom without `--json`, etc).
|
|
100
101
|
*/
|
|
101
|
-
async function
|
|
102
|
-
const rotateAttempt = await tryExec(["add", key, PLACEHOLDER, "--rotate"], cwd);
|
|
103
|
-
if (rotateAttempt.code === 0) return true;
|
|
104
|
-
|
|
105
|
-
// Heuristic for "Phantom rejected --rotate because it's an older build":
|
|
106
|
-
// stderr mentions the flag name or "unknown option". On any other failure,
|
|
107
|
-
// we already logged it via stderr — the caller's noWire path can skip.
|
|
108
|
-
const stderrLower = rotateAttempt.stderr.toLowerCase();
|
|
109
|
-
const looksLikeUnknownFlag =
|
|
110
|
-
stderrLower.includes("--rotate") ||
|
|
111
|
-
stderrLower.includes("unknown option") ||
|
|
112
|
-
stderrLower.includes("unrecognized");
|
|
113
|
-
|
|
114
|
-
if (!looksLikeUnknownFlag) return false;
|
|
115
|
-
|
|
116
|
-
const fallback = await tryExec(["add", key, PLACEHOLDER], cwd);
|
|
117
|
-
return fallback.code === 0;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function tryExec(args: string[], cwd: string): Promise<PhantomExecResult> {
|
|
102
|
+
async function listVaultNames(cwd: string): Promise<Set<string> | undefined> {
|
|
121
103
|
try {
|
|
122
|
-
return await
|
|
104
|
+
return new Set(await listSecrets(cwd));
|
|
123
105
|
} catch {
|
|
124
|
-
return
|
|
106
|
+
return undefined;
|
|
125
107
|
}
|
|
126
108
|
}
|
|
127
109
|
|
|
@@ -135,20 +117,53 @@ async function writeWebhookStub(cwd: string, provider: string): Promise<string>
|
|
|
135
117
|
function webhookStubFor(provider: string): string {
|
|
136
118
|
const base = `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.
|
|
137
119
|
// Fill in the secret retrieval + signature verification before deploying.
|
|
120
|
+
// This entire example is commented out and does not verify signatures.
|
|
121
|
+
// If uncommented as-is, it accepts any non-empty signature header and returns
|
|
122
|
+
// 200 without authenticating the request. Implement verification first.
|
|
138
123
|
//
|
|
139
|
-
//
|
|
124
|
+
// The secret lives in Phantom; read it at call time with:
|
|
140
125
|
// phantom reveal ${webhookSecretKeyFor(provider)}
|
|
141
126
|
//
|
|
142
127
|
// export async function handle${capitalize(provider)}Webhook(req: Request): Promise<Response> {
|
|
143
128
|
// const signature = req.headers.get("${signatureHeaderFor(provider)}");
|
|
129
|
+
// if (!signature) return new Response("Missing signature", { status: 400 });
|
|
144
130
|
// const body = await req.text();
|
|
145
|
-
// // TODO:
|
|
131
|
+
// // TODO(security): signature verification is NOT implemented here. The
|
|
132
|
+
// // header check above proves only that a signature header arrived; its
|
|
133
|
+
// // value is never compared to anything. Until this TODO is done, \`body\`
|
|
134
|
+
// // is unauthenticated, attacker-controlled input — anyone who can POST
|
|
135
|
+
// // to this endpoint can forge a ${provider} webhook.
|
|
136
|
+
// // ${verificationHintFor(provider)}
|
|
137
|
+
// // Verify over the exact raw body bytes with the webhook secret
|
|
138
|
+
// // (\`phantom reveal ${webhookSecretKeyFor(provider)}\`) and compare in
|
|
139
|
+
// // constant time; reject missing or invalid signatures, and only then
|
|
140
|
+
// // parse, trust, or act on the body.
|
|
146
141
|
// return new Response("ok", { status: 200 });
|
|
147
142
|
// }
|
|
148
143
|
`;
|
|
149
144
|
return base;
|
|
150
145
|
}
|
|
151
146
|
|
|
147
|
+
/**
|
|
148
|
+
* One-line pointer to the provider's documented signature scheme, inlined into
|
|
149
|
+
* the stub's TODO so the implementer starts at the right verification API.
|
|
150
|
+
* Falls back to a generic instruction for providers added later.
|
|
151
|
+
*/
|
|
152
|
+
function verificationHintFor(provider: string): string {
|
|
153
|
+
switch (provider) {
|
|
154
|
+
case "stripe":
|
|
155
|
+
return "Stripe: `stripe.webhooks.constructEvent` — HMAC-SHA256 with timestamp tolerance.";
|
|
156
|
+
case "clerk":
|
|
157
|
+
return "Clerk (svix): `new Webhook(secret).verify(body, headers)`.";
|
|
158
|
+
case "supabase":
|
|
159
|
+
return "Supabase: `x-supabase-signature` per Supabase's documented webhook scheme.";
|
|
160
|
+
case "github":
|
|
161
|
+
return "GitHub: `x-hub-signature-256` is `sha256=` + HMAC-SHA256 of the raw body.";
|
|
162
|
+
default:
|
|
163
|
+
return "Verify per the provider's documented signature scheme.";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
152
167
|
function webhookSecretKeyFor(provider: string): string {
|
|
153
168
|
switch (provider) {
|
|
154
169
|
case "stripe":
|
package/src/ai/recipe.ts
CHANGED
|
@@ -176,3 +176,72 @@ function coerceRecipe(raw: Record<string, unknown>, fallbackId: string): Recipe
|
|
|
176
176
|
}
|
|
177
177
|
return recipe;
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remote recipe marketplace.
|
|
182
|
+
*
|
|
183
|
+
* Opt-in via env var — local recipes always take precedence.
|
|
184
|
+
* Set STACK_RECIPE_BASE_URL to enable remote fallback, e.g.:
|
|
185
|
+
* STACK_RECIPE_BASE_URL=https://rjowfztlxxstcrwjdaet.supabase.co/functions/v1/list-recipes
|
|
186
|
+
*/
|
|
187
|
+
const REMOTE_BASE_URL = process.env.STACK_RECIPE_BASE_URL;
|
|
188
|
+
|
|
189
|
+
/** Fetch all recipes from the remote marketplace (opt-in). Returns [] if disabled or unreachable. */
|
|
190
|
+
export async function listRemoteRecipes(): Promise<Recipe[]> {
|
|
191
|
+
if (!REMOTE_BASE_URL) return [];
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(REMOTE_BASE_URL, {
|
|
194
|
+
headers: { Accept: "application/json" },
|
|
195
|
+
signal: AbortSignal.timeout(5000),
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) return [];
|
|
198
|
+
const json = (await res.json()) as { recipes?: unknown[] };
|
|
199
|
+
const rows = Array.isArray(json.recipes) ? json.recipes : [];
|
|
200
|
+
return rows.map((row) => {
|
|
201
|
+
const r = row as Record<string, unknown>;
|
|
202
|
+
return coerceRecipe(
|
|
203
|
+
{
|
|
204
|
+
id: r.slug,
|
|
205
|
+
query: r.query,
|
|
206
|
+
createdAt: r.created_at,
|
|
207
|
+
providers: r.providers,
|
|
208
|
+
guidance: undefined,
|
|
209
|
+
},
|
|
210
|
+
String(r.slug ?? ""),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
} catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fetch a single recipe by slug from the remote marketplace (opt-in).
|
|
220
|
+
* Returns undefined if disabled, not found, or unreachable.
|
|
221
|
+
*/
|
|
222
|
+
export async function getRemoteRecipe(slug: string): Promise<Recipe | undefined> {
|
|
223
|
+
if (!REMOTE_BASE_URL) return undefined;
|
|
224
|
+
try {
|
|
225
|
+
const url = `${REMOTE_BASE_URL}?slug=${encodeURIComponent(slug)}`;
|
|
226
|
+
const res = await fetch(url, {
|
|
227
|
+
headers: { Accept: "application/json" },
|
|
228
|
+
signal: AbortSignal.timeout(5000),
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) return undefined;
|
|
231
|
+
const json = (await res.json()) as { recipe?: unknown };
|
|
232
|
+
if (!json.recipe || typeof json.recipe !== "object") return undefined;
|
|
233
|
+
const r = json.recipe as Record<string, unknown>;
|
|
234
|
+
return coerceRecipe(
|
|
235
|
+
{
|
|
236
|
+
id: r.slug,
|
|
237
|
+
query: r.query,
|
|
238
|
+
createdAt: r.created_at,
|
|
239
|
+
providers: r.providers,
|
|
240
|
+
guidance: undefined,
|
|
241
|
+
},
|
|
242
|
+
String(r.slug ?? ""),
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/base-urls.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model provider base URLs, overridable from the environment.
|
|
3
|
+
*
|
|
4
|
+
* `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL` and `OLLAMA_HOST` let a gateway
|
|
5
|
+
* (a LiteLLM proxy, a Phantom proxy, or Ollama on another machine) sit in
|
|
6
|
+
* front of the providers. Unset or blank values give exactly the URLs Stack
|
|
7
|
+
* always used. Values are read when called, not at import.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Env = Record<string, string | undefined>;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
13
|
+
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
14
|
+
export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
|
|
15
|
+
|
|
16
|
+
/** Trim whitespace and trailing slashes; `undefined` when nothing is left. */
|
|
17
|
+
function clean(raw: string | undefined): string | undefined {
|
|
18
|
+
const v = raw?.trim().replace(/\/+$/, "").trim();
|
|
19
|
+
return v ? v : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A keyed gateway URL must be https, or plain http only to the loopback
|
|
24
|
+
* literals 127.0.0.1 and [::1], so an API key is never sent in clear text
|
|
25
|
+
* across a network. Throws with the variable name only, never the URL (it
|
|
26
|
+
* can carry credentials).
|
|
27
|
+
*/
|
|
28
|
+
export function assertGatewayUrl(name: string, value: string): string {
|
|
29
|
+
let url: URL;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`${name} is not a valid URL`);
|
|
34
|
+
}
|
|
35
|
+
if (url.protocol === "https:") return value;
|
|
36
|
+
if (url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "[::1]")) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`${name} must use https, or http only to 127.0.0.1 or [::1]`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Anthropic API root; Stack appends `/v1/...`. */
|
|
43
|
+
export function anthropicBaseUrl(env: Env = process.env): string {
|
|
44
|
+
const v = clean(env.ANTHROPIC_BASE_URL);
|
|
45
|
+
return v ? assertGatewayUrl("ANTHROPIC_BASE_URL", v) : DEFAULT_ANTHROPIC_BASE_URL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** OpenAI API root including `/v1`, the same convention as the OpenAI SDKs. */
|
|
49
|
+
export function openaiBaseUrl(env: Env = process.env): string {
|
|
50
|
+
const v = clean(env.OPENAI_BASE_URL);
|
|
51
|
+
return v ? assertGatewayUrl("OPENAI_BASE_URL", v) : DEFAULT_OPENAI_BASE_URL;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ollama server root. `OLLAMA_HOST` may be `host`, `host:port`, `:port`
|
|
56
|
+
* (localhost) or a full `http://` / `https://` URL, as Ollama itself
|
|
57
|
+
* accepts. A missing scheme becomes `http://`, a missing port 11434.
|
|
58
|
+
*/
|
|
59
|
+
export function ollamaBaseUrl(env: Env = process.env): string {
|
|
60
|
+
let v = clean(env.OLLAMA_HOST);
|
|
61
|
+
if (!v) return DEFAULT_OLLAMA_BASE_URL;
|
|
62
|
+
if (/^https?:\/\//i.test(v)) return v;
|
|
63
|
+
if (v.startsWith(":")) v = `localhost${v}`;
|
|
64
|
+
const slash = v.indexOf("/");
|
|
65
|
+
const authority = slash === -1 ? v : v.slice(0, slash);
|
|
66
|
+
const path = slash === -1 ? "" : v.slice(slash);
|
|
67
|
+
const close = authority.lastIndexOf("]");
|
|
68
|
+
const hasPort =
|
|
69
|
+
close === -1 ? authority.includes(":") : authority.slice(close + 1).startsWith(":");
|
|
70
|
+
return `http://${authority}${hasPort ? "" : ":11434"}${path}`;
|
|
71
|
+
}
|