@ashlr/stack-core 0.1.1
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 +21 -0
- package/README.md +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
package/src/detect.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given the name of an env var, guess which curated provider owns it. Used by
|
|
3
|
+
* `stack import` to auto-wire existing .env files into Stack's service map.
|
|
4
|
+
*
|
|
5
|
+
* Heuristics are deliberately conservative — if the match isn't obvious we
|
|
6
|
+
* return `undefined` and the caller stores the secret without associating it
|
|
7
|
+
* with any provider. Users can always `stack add <name>` later to reconcile.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ServiceEntry } from "./config.ts";
|
|
11
|
+
|
|
12
|
+
export interface DetectedService {
|
|
13
|
+
provider: string;
|
|
14
|
+
secrets: string[];
|
|
15
|
+
mcp?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DetectionRule {
|
|
19
|
+
provider: string;
|
|
20
|
+
mcp?: string;
|
|
21
|
+
/** Match a single env var name to this provider. */
|
|
22
|
+
matches: (name: string) => boolean;
|
|
23
|
+
/** Optional: pick the "primary" secret name that implies the service is present. */
|
|
24
|
+
primary?: (name: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RULES: DetectionRule[] = [
|
|
28
|
+
{
|
|
29
|
+
provider: "supabase",
|
|
30
|
+
mcp: "supabase",
|
|
31
|
+
matches: (n) => /^(NEXT_PUBLIC_)?SUPABASE_(URL|ANON_KEY|SERVICE_ROLE_KEY|JWT_SECRET)$/.test(n),
|
|
32
|
+
primary: (n) => /^(NEXT_PUBLIC_)?SUPABASE_URL$/.test(n),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
// DATABASE_URL is intentionally NOT attributed here — it's a generic
|
|
36
|
+
// Postgres convention used by Supabase, Railway, Render, PlanetScale,
|
|
37
|
+
// and plain self-hosted Postgres. Only NEON_* keys identify Neon.
|
|
38
|
+
provider: "neon",
|
|
39
|
+
matches: (n) => /^NEON_/.test(n),
|
|
40
|
+
primary: (n) => n === "NEON_API_KEY",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
provider: "vercel",
|
|
44
|
+
matches: (n) => /^VERCEL_/.test(n),
|
|
45
|
+
primary: (n) => n === "VERCEL_TOKEN",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
provider: "railway",
|
|
49
|
+
matches: (n) => /^RAILWAY_/.test(n),
|
|
50
|
+
primary: (n) => n === "RAILWAY_TOKEN",
|
|
51
|
+
},
|
|
52
|
+
{ provider: "fly", matches: (n) => /^FLY_/.test(n), primary: (n) => n === "FLY_API_TOKEN" },
|
|
53
|
+
{
|
|
54
|
+
provider: "upstash",
|
|
55
|
+
matches: (n) => /^UPSTASH_/.test(n),
|
|
56
|
+
primary: (n) => n === "UPSTASH_MANAGEMENT_TOKEN",
|
|
57
|
+
},
|
|
58
|
+
{ provider: "openai", matches: (n) => /^OPENAI_/.test(n), primary: (n) => n === "OPENAI_API_KEY" },
|
|
59
|
+
{
|
|
60
|
+
provider: "anthropic",
|
|
61
|
+
matches: (n) => /^ANTHROPIC_/.test(n),
|
|
62
|
+
primary: (n) => n === "ANTHROPIC_API_KEY",
|
|
63
|
+
},
|
|
64
|
+
{ provider: "xai", matches: (n) => /^XAI_/.test(n), primary: (n) => n === "XAI_API_KEY" },
|
|
65
|
+
{
|
|
66
|
+
provider: "deepseek",
|
|
67
|
+
matches: (n) => /^DEEPSEEK_/.test(n),
|
|
68
|
+
primary: (n) => n === "DEEPSEEK_API_KEY",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
provider: "posthog",
|
|
72
|
+
mcp: "posthog",
|
|
73
|
+
matches: (n) => /^(NEXT_PUBLIC_)?POSTHOG_/.test(n),
|
|
74
|
+
primary: (n) => n === "POSTHOG_PERSONAL_API_KEY",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
provider: "sentry",
|
|
78
|
+
mcp: "sentry",
|
|
79
|
+
matches: (n) => /^SENTRY_/.test(n) || /^(NEXT_PUBLIC_)?SENTRY_DSN$/.test(n),
|
|
80
|
+
primary: (n) => n === "SENTRY_AUTH_TOKEN",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
provider: "github",
|
|
84
|
+
mcp: "github",
|
|
85
|
+
matches: (n) => /^GITHUB_(TOKEN|PERSONAL_ACCESS_TOKEN)$/.test(n),
|
|
86
|
+
primary: (n) => n === "GITHUB_TOKEN",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
provider: "linear",
|
|
90
|
+
mcp: "linear",
|
|
91
|
+
matches: (n) => /^LINEAR_/.test(n),
|
|
92
|
+
primary: (n) => n === "LINEAR_API_KEY",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
provider: "stripe",
|
|
96
|
+
matches: (n) => /^STRIPE_/.test(n),
|
|
97
|
+
primary: (n) => n === "STRIPE_SECRET_KEY",
|
|
98
|
+
},
|
|
99
|
+
{ provider: "resend", matches: (n) => /^RESEND_/.test(n), primary: (n) => n === "RESEND_API_KEY" },
|
|
100
|
+
{
|
|
101
|
+
provider: "clerk",
|
|
102
|
+
matches: (n) =>
|
|
103
|
+
/^CLERK_/.test(n) || /^NEXT_PUBLIC_CLERK_/.test(n),
|
|
104
|
+
primary: (n) => n === "CLERK_SECRET_KEY",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
provider: "aws",
|
|
108
|
+
matches: (n) => /^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|REGION|SESSION_TOKEN)$/.test(n),
|
|
109
|
+
primary: (n) => n === "AWS_ACCESS_KEY_ID",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
provider: "cloudflare",
|
|
113
|
+
matches: (n) => /^CLOUDFLARE_(API_TOKEN|ACCOUNT_ID|ZONE_ID)$/.test(n) || /^CF_/.test(n),
|
|
114
|
+
primary: (n) => n === "CLOUDFLARE_API_TOKEN",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
provider: "turso",
|
|
118
|
+
matches: (n) => /^TURSO_(DATABASE_URL|AUTH_TOKEN|PLATFORM_TOKEN)$/.test(n),
|
|
119
|
+
primary: (n) => n === "TURSO_DATABASE_URL",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
provider: "convex",
|
|
123
|
+
matches: (n) => /^(NEXT_PUBLIC_)?CONVEX_(URL|DEPLOY_KEY)$/.test(n),
|
|
124
|
+
primary: (n) => n === "CONVEX_DEPLOY_KEY",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
provider: "render",
|
|
128
|
+
matches: (n) => /^RENDER_/.test(n),
|
|
129
|
+
primary: (n) => n === "RENDER_API_KEY",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
provider: "firebase",
|
|
133
|
+
matches: (n) => /^FIREBASE_/.test(n),
|
|
134
|
+
primary: (n) => n === "FIREBASE_SERVICE_ACCOUNT_JSON",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
provider: "replicate",
|
|
138
|
+
matches: (n) => /^REPLICATE_/.test(n),
|
|
139
|
+
primary: (n) => n === "REPLICATE_API_TOKEN",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
provider: "braintrust",
|
|
143
|
+
matches: (n) => /^BRAINTRUST_/.test(n),
|
|
144
|
+
primary: (n) => n === "BRAINTRUST_API_KEY",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
provider: "modal",
|
|
148
|
+
matches: (n) => /^MODAL_/.test(n),
|
|
149
|
+
primary: (n) => n === "MODAL_TOKEN",
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
export function detectProvider(envName: string): string | undefined {
|
|
154
|
+
const rule = RULES.find((r) => r.matches(envName));
|
|
155
|
+
return rule?.provider;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function groupByProvider(envNames: string[]): Record<string, string[]> {
|
|
159
|
+
const out: Record<string, string[]> = {};
|
|
160
|
+
for (const name of envNames) {
|
|
161
|
+
const provider = detectProvider(name);
|
|
162
|
+
if (!provider) continue;
|
|
163
|
+
(out[provider] ??= []).push(name);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function mcpForProvider(provider: string): string | undefined {
|
|
169
|
+
return RULES.find((r) => r.provider === provider && r.mcp)?.mcp;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build a minimal ServiceEntry from a detected group of env vars. The caller
|
|
174
|
+
* is responsible for attaching provider-specific metadata (resource_id,
|
|
175
|
+
* region) later via `stack doctor --fix` or a manual edit.
|
|
176
|
+
*/
|
|
177
|
+
export function toServiceEntry(provider: string, secrets: string[]): ServiceEntry {
|
|
178
|
+
return {
|
|
179
|
+
provider,
|
|
180
|
+
secrets,
|
|
181
|
+
mcp: mcpForProvider(provider),
|
|
182
|
+
created_at: new Date().toISOString(),
|
|
183
|
+
created_by: "stack import",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Lightweight .env parser (no interpolation, no export support — good enough for import). */
|
|
188
|
+
export function parseEnv(text: string): Array<{ key: string; value: string }> {
|
|
189
|
+
const out: Array<{ key: string; value: string }> = [];
|
|
190
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
191
|
+
const line = rawLine.trim();
|
|
192
|
+
if (!line || line.startsWith("#")) continue;
|
|
193
|
+
const eq = line.indexOf("=");
|
|
194
|
+
if (eq < 0) continue;
|
|
195
|
+
const key = line.slice(0, eq).trim();
|
|
196
|
+
let value = line.slice(eq + 1).trim();
|
|
197
|
+
if (
|
|
198
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
199
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
200
|
+
) {
|
|
201
|
+
value = value.slice(1, -1);
|
|
202
|
+
}
|
|
203
|
+
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
|
|
204
|
+
out.push({ key, value });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth client IDs for the Ashlr-owned apps registered at each provider.
|
|
3
|
+
*
|
|
4
|
+
* These are PUBLIC values — the client secret stays server-side (at the
|
|
5
|
+
* provider, or in Phantom Cloud for confidential flows). The client ID
|
|
6
|
+
* alone is not sensitive; it's sent as a URL query parameter in the
|
|
7
|
+
* browser redirect.
|
|
8
|
+
*
|
|
9
|
+
* Values are baked into the published tarball by
|
|
10
|
+
* `scripts/inject-client-ids.mjs`, which reads the
|
|
11
|
+
* `OAUTH_*_CLIENT_ID` env vars (provided by GitHub Actions secrets at
|
|
12
|
+
* release time) and rewrites this file in-place before `npm publish`.
|
|
13
|
+
* The release workflow restores the file from git after publish, so
|
|
14
|
+
* these defaults stay empty strings in the repo.
|
|
15
|
+
*
|
|
16
|
+
* Provider code resolves the effective client ID with this precedence:
|
|
17
|
+
* 1. `process.env.<PROVIDER>_STACK_CLIENT_ID` — runtime override
|
|
18
|
+
* 2. `OAUTH_DEFAULTS.<provider>` — shipped default
|
|
19
|
+
* 3. (none) — fall back to PAT paste
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const OAUTH_DEFAULTS = {
|
|
23
|
+
supabase: "",
|
|
24
|
+
github: "",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export type OAuthProviderName = keyof typeof OAUTH_DEFAULTS;
|
|
28
|
+
|
|
29
|
+
export function resolveOAuthClientId(
|
|
30
|
+
provider: OAuthProviderName,
|
|
31
|
+
envOverride?: string,
|
|
32
|
+
): string | undefined {
|
|
33
|
+
const value = envOverride ?? OAUTH_DEFAULTS[provider];
|
|
34
|
+
return value.length > 0 ? value : undefined;
|
|
35
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class StackError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(code: string, message: string, options?: { cause?: unknown }) {
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = "StackError";
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ProviderNotFoundError extends StackError {
|
|
11
|
+
constructor(name: string) {
|
|
12
|
+
super("PROVIDER_NOT_FOUND", `No provider registered for "${name}"`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PhantomNotInstalledError extends StackError {
|
|
17
|
+
constructor() {
|
|
18
|
+
super(
|
|
19
|
+
"PHANTOM_NOT_INSTALLED",
|
|
20
|
+
"Phantom Secrets is required but not found on PATH. Install with: brew install ashlrai/phantom/phantom",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ConfigNotFoundError extends StackError {
|
|
26
|
+
constructor(path: string) {
|
|
27
|
+
super("CONFIG_NOT_FOUND", `No .stack.toml at ${path}. Run \`stack init\` first.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry-with-backoff wrapper for `fetch`. Used by provider healthchecks and
|
|
3
|
+
* read-only API calls; NEVER wire this in on mutating requests (POST / PUT /
|
|
4
|
+
* PATCH / DELETE) unless the caller explicitly opts in via `idempotent: true`
|
|
5
|
+
* — we'd rather surface a failure than risk double-charging a provider API or
|
|
6
|
+
* duplicating an upstream resource.
|
|
7
|
+
*
|
|
8
|
+
* Design notes:
|
|
9
|
+
* - Pure dependency: `globalThis.fetch` + `setTimeout`. No new runtime deps.
|
|
10
|
+
* - Honors `Retry-After` (both integer-seconds and HTTP-date formats).
|
|
11
|
+
* - Exponential backoff with jitter so a flock of retries doesn't stampede.
|
|
12
|
+
* - Abortable via the passed `AbortSignal` — stops the loop between attempts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface RetryOptions {
|
|
16
|
+
/** How many retry attempts (after the initial try). Default: 3. */
|
|
17
|
+
retries?: number;
|
|
18
|
+
/** Base delay in ms for the exponential backoff. Default: 400. */
|
|
19
|
+
baseDelayMs?: number;
|
|
20
|
+
/** Cap on a single sleep interval (before jitter). Default: 8000. */
|
|
21
|
+
maxDelayMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Predicate deciding whether a non-ok Response should be retried. Default
|
|
24
|
+
* matches the common "transient" set: 429, 500, 502, 503, 504.
|
|
25
|
+
*/
|
|
26
|
+
retryOn?: (res: Response) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the caller considers the request safe to retry. If omitted, we
|
|
29
|
+
* derive from `init.method`: GET and HEAD are idempotent, everything else
|
|
30
|
+
* is not. Set this explicitly for edge cases (e.g. a PUT to a versioned
|
|
31
|
+
* URL that the caller knows is safe).
|
|
32
|
+
*/
|
|
33
|
+
idempotent?: boolean;
|
|
34
|
+
/** Caller-supplied abort signal. Cancels both the active fetch and any pending sleep. */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
39
|
+
|
|
40
|
+
export function defaultRetryOn(res: Response): boolean {
|
|
41
|
+
return DEFAULT_RETRY_STATUSES.has(res.status);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drop-in replacement for `fetch` that retries idempotent requests on
|
|
46
|
+
* transient failures. Returns the final `Response` — callers still check
|
|
47
|
+
* `.ok` themselves. Non-idempotent methods (POST/PUT/PATCH/DELETE) are
|
|
48
|
+
* executed exactly once unless `idempotent: true` is passed.
|
|
49
|
+
*/
|
|
50
|
+
export async function fetchWithRetry(
|
|
51
|
+
input: RequestInfo | URL,
|
|
52
|
+
init?: RequestInit,
|
|
53
|
+
opts: RetryOptions = {},
|
|
54
|
+
): Promise<Response> {
|
|
55
|
+
const retries = opts.retries ?? 3;
|
|
56
|
+
const baseDelayMs = opts.baseDelayMs ?? 400;
|
|
57
|
+
const maxDelayMs = opts.maxDelayMs ?? 8000;
|
|
58
|
+
const retryOn = opts.retryOn ?? defaultRetryOn;
|
|
59
|
+
const idempotent = opts.idempotent ?? isIdempotent(init?.method);
|
|
60
|
+
const signal = opts.signal;
|
|
61
|
+
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
// Track the last non-ok response so a final failure returns something the
|
|
64
|
+
// caller can inspect (status / body) rather than a synthetic error.
|
|
65
|
+
let lastResponse: Response | undefined;
|
|
66
|
+
let lastError: unknown;
|
|
67
|
+
|
|
68
|
+
while (true) {
|
|
69
|
+
if (signal?.aborted) throw abortError(signal);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(input, mergeSignal(init, signal));
|
|
73
|
+
if (res.ok || !idempotent || !retryOn(res)) return res;
|
|
74
|
+
|
|
75
|
+
lastResponse = res;
|
|
76
|
+
if (attempt >= retries) return res;
|
|
77
|
+
|
|
78
|
+
const waitMs = computeDelay(res, attempt, baseDelayMs, maxDelayMs);
|
|
79
|
+
await sleep(waitMs, signal);
|
|
80
|
+
attempt++;
|
|
81
|
+
continue;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Network-level failure (DNS, connection reset, fetch threw). Only retry
|
|
84
|
+
// for idempotent requests — a POST that "failed" may have actually been
|
|
85
|
+
// accepted by the remote before the connection dropped.
|
|
86
|
+
if (isAbortError(err)) throw err;
|
|
87
|
+
lastError = err;
|
|
88
|
+
if (!idempotent || attempt >= retries) throw err;
|
|
89
|
+
const waitMs = computeDelay(undefined, attempt, baseDelayMs, maxDelayMs);
|
|
90
|
+
await sleep(waitMs, signal);
|
|
91
|
+
attempt++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unreachable — both branches above either return or throw. The assignment
|
|
97
|
+
// to lastResponse/lastError keeps TS happy if the loop ever changes shape.
|
|
98
|
+
void lastResponse;
|
|
99
|
+
void lastError;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Internals
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function isIdempotent(method?: string): boolean {
|
|
107
|
+
if (!method) return true; // fetch defaults to GET
|
|
108
|
+
const upper = method.toUpperCase();
|
|
109
|
+
return upper === "GET" || upper === "HEAD";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Exponential backoff with jitter, overridden by a server-supplied
|
|
114
|
+
* `Retry-After` if present. Jitter prevents thundering-herd when a burst of
|
|
115
|
+
* callers all see the same 429 and pick the same deterministic delay.
|
|
116
|
+
*/
|
|
117
|
+
function computeDelay(
|
|
118
|
+
res: Response | undefined,
|
|
119
|
+
attempt: number,
|
|
120
|
+
baseDelayMs: number,
|
|
121
|
+
maxDelayMs: number,
|
|
122
|
+
): number {
|
|
123
|
+
if (res) {
|
|
124
|
+
const header = res.headers.get("retry-after");
|
|
125
|
+
const parsed = parseRetryAfter(header);
|
|
126
|
+
if (parsed !== undefined) return Math.max(0, Math.min(parsed, maxDelayMs * 4));
|
|
127
|
+
}
|
|
128
|
+
const exp = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
129
|
+
const jitter = Math.random() * baseDelayMs;
|
|
130
|
+
return exp + jitter;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse a Retry-After header. The spec allows either a non-negative integer
|
|
135
|
+
* (seconds) or an HTTP-date (RFC 7231). Returns ms, or undefined if we can't
|
|
136
|
+
* parse it — callers then fall back to exponential backoff.
|
|
137
|
+
*/
|
|
138
|
+
export function parseRetryAfter(header: string | null): number | undefined {
|
|
139
|
+
if (!header) return undefined;
|
|
140
|
+
const trimmed = header.trim();
|
|
141
|
+
if (trimmed === "") return undefined;
|
|
142
|
+
|
|
143
|
+
// Integer-seconds form.
|
|
144
|
+
if (/^\d+$/.test(trimmed)) {
|
|
145
|
+
const seconds = Number.parseInt(trimmed, 10);
|
|
146
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// HTTP-date form. Date.parse understands RFC 7231 formats; if it fails it
|
|
150
|
+
// returns NaN and we fall through.
|
|
151
|
+
const asDate = Date.parse(trimmed);
|
|
152
|
+
if (Number.isFinite(asDate)) {
|
|
153
|
+
const delta = asDate - Date.now();
|
|
154
|
+
return delta > 0 ? delta : 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
if (signal?.aborted) {
|
|
163
|
+
reject(abortError(signal));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
signal?.removeEventListener("abort", onAbort);
|
|
168
|
+
resolve();
|
|
169
|
+
}, ms);
|
|
170
|
+
const onAbort = () => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
reject(abortError(signal));
|
|
173
|
+
};
|
|
174
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Merge a caller-supplied `AbortSignal` into the `init.signal` passed to
|
|
180
|
+
* `fetch`. If both are set, either aborting should cancel the request. Using
|
|
181
|
+
* `AbortSignal.any` where available; falling back to a manual linkage on older
|
|
182
|
+
* runtimes.
|
|
183
|
+
*/
|
|
184
|
+
function mergeSignal(init: RequestInit | undefined, external?: AbortSignal): RequestInit | undefined {
|
|
185
|
+
if (!external) return init;
|
|
186
|
+
const existing = init?.signal ?? undefined;
|
|
187
|
+
if (!existing) return { ...init, signal: external };
|
|
188
|
+
const anyFn = (AbortSignal as unknown as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
|
|
189
|
+
if (typeof anyFn === "function") {
|
|
190
|
+
return { ...init, signal: anyFn([existing, external]) };
|
|
191
|
+
}
|
|
192
|
+
// Manual linkage fallback.
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const onAbort = (signal: AbortSignal) => () => controller.abort(signal.reason);
|
|
195
|
+
existing.addEventListener("abort", onAbort(existing), { once: true });
|
|
196
|
+
external.addEventListener("abort", onAbort(external), { once: true });
|
|
197
|
+
if (existing.aborted) controller.abort(existing.reason);
|
|
198
|
+
if (external.aborted) controller.abort(external.reason);
|
|
199
|
+
return { ...init, signal: controller.signal };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function abortError(signal?: AbortSignal): Error {
|
|
203
|
+
const reason = signal?.reason;
|
|
204
|
+
if (reason instanceof Error) return reason;
|
|
205
|
+
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted.");
|
|
206
|
+
err.name = "AbortError";
|
|
207
|
+
return err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isAbortError(err: unknown): boolean {
|
|
211
|
+
return err instanceof Error && err.name === "AbortError";
|
|
212
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export * from "./config.ts";
|
|
2
|
+
export * from "./phantom.ts";
|
|
3
|
+
export * from "./providers/_base.ts";
|
|
4
|
+
export { providers, getProvider, listProviderNames } from "./providers/index.ts";
|
|
5
|
+
export * from "./errors.ts";
|
|
6
|
+
export * from "./templates.ts";
|
|
7
|
+
export * from "./pipeline.ts";
|
|
8
|
+
export * from "./detect.ts";
|
|
9
|
+
export * from "./detect-source.ts";
|
|
10
|
+
export * from "./registry.ts";
|
|
11
|
+
export {
|
|
12
|
+
PROVIDER_CATEGORIES,
|
|
13
|
+
PROVIDERS_REF,
|
|
14
|
+
findProviderRef,
|
|
15
|
+
groupByCategory,
|
|
16
|
+
} from "./catalog.ts";
|
|
17
|
+
export type { AuthKind as CatalogAuthKind, ProviderRef } from "./catalog.ts";
|
|
18
|
+
export {
|
|
19
|
+
retrieve,
|
|
20
|
+
retrieveByCategory,
|
|
21
|
+
type RetrievalHit,
|
|
22
|
+
type RetrieveOptions,
|
|
23
|
+
} from "./ai/catalog-index.ts";
|
|
24
|
+
export {
|
|
25
|
+
listRecipes,
|
|
26
|
+
readRecipe,
|
|
27
|
+
recipeFromRetrieval,
|
|
28
|
+
slugifyQuery,
|
|
29
|
+
writeRecipe,
|
|
30
|
+
type Recipe,
|
|
31
|
+
} from "./ai/recipe.ts";
|
|
32
|
+
export { wirePhantomForRecipe, type WireResult } from "./ai/phantom-wire.ts";
|
|
33
|
+
export {
|
|
34
|
+
ClaudeMCPBackend,
|
|
35
|
+
LocalSLMBackend,
|
|
36
|
+
NoInferenceBackendError,
|
|
37
|
+
getInferenceBackend,
|
|
38
|
+
parseRecipeDraft,
|
|
39
|
+
type InferenceBackend,
|
|
40
|
+
type InferenceMode,
|
|
41
|
+
type InferenceRequest,
|
|
42
|
+
type InferenceResult,
|
|
43
|
+
type InferenceUsage,
|
|
44
|
+
type LocalSLMBackendOptions,
|
|
45
|
+
type LocalSLMEndpoint,
|
|
46
|
+
type GetInferenceBackendOptions,
|
|
47
|
+
type RecipeDraft,
|
|
48
|
+
type RecipeProvider,
|
|
49
|
+
} from "./ai/inference.ts";
|
|
50
|
+
export {
|
|
51
|
+
CircuitBreaker,
|
|
52
|
+
type CircuitBreakerOptions,
|
|
53
|
+
type CircuitState,
|
|
54
|
+
} from "./ai/circuit-breaker.ts";
|
|
55
|
+
export {
|
|
56
|
+
CostTracker,
|
|
57
|
+
defaultCostTracker,
|
|
58
|
+
type CostSummary,
|
|
59
|
+
type RateCard,
|
|
60
|
+
type UsageRecord,
|
|
61
|
+
} from "./ai/cost-tracker.ts";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { McpServerEntry } from "./providers/_base.ts";
|
|
5
|
+
|
|
6
|
+
const MCP_FILENAME = ".mcp.json";
|
|
7
|
+
|
|
8
|
+
interface McpFile {
|
|
9
|
+
mcpServers: Record<string, Record<string, unknown>>;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function mergeMcpEntry(entry: McpServerEntry, cwd: string = process.cwd()): Promise<void> {
|
|
14
|
+
const path = join(cwd, MCP_FILENAME);
|
|
15
|
+
const current = await readMcp(path);
|
|
16
|
+
current.mcpServers[entry.name] = toJsonShape(entry);
|
|
17
|
+
await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, "utf-8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function removeMcpEntry(name: string, cwd: string = process.cwd()): Promise<void> {
|
|
21
|
+
const path = join(cwd, MCP_FILENAME);
|
|
22
|
+
if (!existsSync(path)) return;
|
|
23
|
+
const current = await readMcp(path);
|
|
24
|
+
if (current.mcpServers[name]) {
|
|
25
|
+
delete current.mcpServers[name];
|
|
26
|
+
await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, "utf-8");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readMcp(path: string): Promise<McpFile> {
|
|
31
|
+
if (!existsSync(path)) return { mcpServers: {} };
|
|
32
|
+
const raw = await readFile(path, "utf-8");
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw) as Partial<McpFile>;
|
|
35
|
+
return { ...parsed, mcpServers: parsed.mcpServers ?? {} };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`Failed to parse ${path}: ${(err as Error).message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toJsonShape(entry: McpServerEntry): Record<string, unknown> {
|
|
42
|
+
const out: Record<string, unknown> = {};
|
|
43
|
+
if (entry.type) out.type = entry.type;
|
|
44
|
+
if (entry.command) out.command = entry.command;
|
|
45
|
+
if (entry.args) out.args = entry.args;
|
|
46
|
+
if (entry.url) out.url = entry.url;
|
|
47
|
+
if (entry.env) out.env = entry.env;
|
|
48
|
+
if (entry.headers) out.headers = entry.headers;
|
|
49
|
+
return out;
|
|
50
|
+
}
|