Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a90162a
feat(stack-stripe): --webhook-endpoint flag creates and persists whsec
masonwyatt23 Apr 29, 2026
0c5ba33
chore: remove tracked .claude/scheduled_tasks.lock (now globally igno…
masonwyatt23 Apr 30, 2026
9a94f3a
refactor: stack routes secret resolution through @ashlr/config
masonwyatt23 Jun 28, 2026
3cd3b89
feat: provision timeout + cancellation guards (prevent indefinite hangs)
masonwyatt23 Jun 29, 2026
d65eb8f
feat: multi-provider orchestration groups with dependency ordering
masonwyatt23 Jun 29, 2026
0e5ce95
feat(core): Provider Provision Validation Framework — schema + codegen
masonwyatt23 Jun 29, 2026
52fb099
feat: comprehensive healthcheck coverage for all 32 providers
masonwyatt23 Jun 29, 2026
d7a4bdc
feat: atomic partial-failure rollback + deprovision for 11 providers
masonwyatt23 Jun 29, 2026
d308a6e
feat: provision dry-run mode — prediction + cost estimation
masonwyatt23 Jun 29, 2026
423e361
test: comprehensive healthcheck coverage for all 39 providers
masonwyatt23 Jun 29, 2026
82157d8
feat(core): deprovision() for render, convex, posthog, clerk, auth0, …
masonwyatt23 Jun 29, 2026
ab95672
feat(core): provision response schema validation enforcement
masonwyatt23 Jun 29, 2026
6fa2704
feat: structured instrumentation & audit trail for provisioning pipeline
masonwyatt23 Jun 29, 2026
00201ca
feat(core): provider health check probe suite — quota/rate-limit/burn…
masonwyatt23 Jun 29, 2026
e315b9e
feat(core): provision schema codegen — OpenAPI 3.1 + Terraform HCL ex…
masonwyatt23 Jun 29, 2026
8aefc67
feat(orchestration): atomic multi-provider rollback with DAG dependen…
masonwyatt23 Jun 29, 2026
40d3b11
feat: structured provision error classification, recovery hints, and …
masonwyatt23 Jun 29, 2026
0f60c81
feat(providers): deprovision() for all 19 API-key-only providers
masonwyatt23 Jun 29, 2026
284db02
feat: multi-provider quota consensus & spend forecast engine
masonwyatt23 Jun 29, 2026
e324795
feat: probe registry, stub codegen, generate-missing CLI, doctor cove…
masonwyatt23 Jun 29, 2026
ecbfd2a
test: multi-provider interop provisioning tests with dependency valid…
masonwyatt23 Jun 29, 2026
2c2a771
feat(probes): comprehensive health probe coverage for all 39 providers
masonwyatt23 Jun 29, 2026
7d76896
feat(core): Provider Resource Conflict Detection & Auto-Recovery
masonwyatt23 Jun 29, 2026
c8d589c
feat(core): provision response validation & type-safe codegen
masonwyatt23 Jun 29, 2026
6f16cd3
feat(core): live provider pricing & quota lookup via MCP broker
masonwyatt23 Jun 29, 2026
35dd9db
feat(probes): register auth0 probe + add 9 comprehensive tests
masonwyatt23 Jun 29, 2026
72027dc
feat(core): rollback audit framework — orphan/stale state detection +…
masonwyatt23 Jun 29, 2026
e87a81e
feat(core): multi-provider resource conflict detection for 11 new pro…
masonwyatt23 Jun 29, 2026
55dc507
feat(core): cross-provider permission validation & remediation engine
masonwyatt23 Jun 29, 2026
703a067
feat: provider state reconciliation & lifecycle hooks
masonwyatt23 Jun 29, 2026
42ff3e0
feat: provider readiness gate & pre-provision input validation framework
masonwyatt23 Jun 29, 2026
276b570
fix: review findings — populate compliant[] in scanProviderSchemaRegi…
masonwyatt23 Jun 29, 2026
5a2462a
fix: review findings (medium) — cycle-report dup node + stripe scope …
masonwyatt23 Jun 29, 2026
81e0633
chore: land coherent WIP — complete provision provenance/replay/compl…
masonwyatt23 Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,48 @@ jobs:
console.log('stack recommend smoke ok: ' + names.join(', '));
"

- name: Probe stub coverage gate
run: |
set -e
# Every provider added to the catalog must have a probe stub declared
# within 1 sprint. This gate fails if any catalog provider has no probe
# stub registered AND the coverage has dropped below the committed floor.
#
# The floor is: 11 out of 43 providers (the built-in probes).
# New providers that ship without a stub will cause coverage to drop
# below the floor and break CI until a stub is added.
OUT=$(bun run packages/cli/src/index.ts doctor --coverage --json)
echo "Probe coverage report: $OUT"
node -e "
const report = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const probes = report.probes;
if (!probes) { console.log('No probe section — skipping gate'); process.exit(0); }
// Floor: 11 built-in probes across the current catalog (39 providers).
// When the catalog grows, covered must grow with it or CI fails.
const FLOOR_COVERED = 11;
console.log('Probe coverage: ' + probes.covered + '/' + probes.total + ' (' + probes.pct + '%)');
// Fail if covered count regressed from the known floor.
if (probes.covered < FLOOR_COVERED) {
throw new Error(
'Probe coverage regressed: expected >= ' + FLOOR_COVERED + ' but got ' + probes.covered + '. ' +
'Do not remove probe implementations without a replacement.'
);
}
// Warn (but do not fail yet) when new providers were added without probes.
// Change the condition below to a throw to enforce strict 1-sprint rule.
if (probes.missing && probes.missing.length > 0) {
const newMissing = probes.total - probes.covered - (probes.total - 39);
if (probes.total > 39 && probes.covered <= FLOOR_COVERED) {
throw new Error(
'New provider(s) added to catalog without a probe stub. ' +
'Missing: ' + probes.missing.join(', ') + '. ' +
'Run: bun run packages/cli/src/index.ts probes generate-missing'
);
}
}
console.log('Probe stub coverage gate passed.');
" <<< "$OUT"

site:
name: Site build (astro check + build)
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ Stack is the *control plane*. [Phantom](https://phm.dev) is the *vault*. ashlr-p
```bash
stack init # interactive template picker
stack add supabase # OAuth → new project → secrets → .mcp.json
stack add stripe # paste sk_live_… → validates → stores STRIPE_SECRET_KEY

# Stripe webhook endpoint — fully agent-driveable, no dashboard copy-paste
stack add stripe --webhook-endpoint https://example.com/webhooks/stripe
# → creates the endpoint via Stripe API
# → stores STRIPE_WEBHOOK_SECRET (whsec_…) + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom
# → default events: customer.subscription.{created,updated,deleted,trial_will_end}
# invoice.payment_failed

# Custom event list
stack add stripe \
--webhook-endpoint https://example.com/webhooks/stripe \
--events "payment_intent.succeeded,charge.failed"

# Reuse an existing sk_… from the vault (skip the interactive paste)
stack add stripe \
--webhook-endpoint https://example.com/webhooks/stripe \
--secret-key-from-vault

stack providers # full catalog (29 services across 11 categories)
stack doctor --fix # verify every service; re-run setup for anything broken
stack exec -- bun dev # run with Phantom's secret proxy active
Expand Down
14 changes: 13 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 89 additions & 23 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { spawnSync } from "node:child_process";
import {
FileCollector,
addService,
detectPackageManager,
findProviderRef,
getProvider,
hasConfig,
installCommand,
instrumentation,
listProviderNames,
} from "@ashlr/stack-core";
import {
dryRunProvider,
formatDryRunReport,
formatDryRunReportJson,
} from "@ashlr/stack-core";
import { defineCommand } from "citty";
import { requirePhantom } from "../lib/phantom-preflight.ts";
import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
Expand Down Expand Up @@ -41,6 +48,32 @@ export const addCommand = defineCommand({
default: "ask",
description: "SDK install behaviour after provisioning: ask (default), always, never.",
},
webhookEndpoint: {
type: "string",
description:
"Stripe only: HTTPS URL to register as a webhook endpoint. Triggers webhook provisioning and stores STRIPE_WEBHOOK_SECRET + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom.",
},
events: {
type: "string",
description:
"Stripe only: comma-separated list of Stripe events to subscribe to. Defaults to the subscription-lifecycle set when --webhook-endpoint is given.",
},
secretKeyFromVault: {
type: "boolean",
default: false,
description:
"Stripe only: skip the interactive sk_… paste and reuse STRIPE_SECRET_KEY already in Phantom.",
},
timeout: {
type: "string",
description:
"Wall-clock timeout in seconds for each provider step (login, provision, materialize). Defaults to 30. Pass 0 to disable.",
},
trace: {
type: "string",
description:
"Collect all instrumentation events (step timings, rollbacks, partial failures) to a JSON file. Useful for debugging multi-provider orchestration failures.",
},
},
async run({ args }) {
if (!hasConfig()) {
Expand Down Expand Up @@ -104,49 +137,62 @@ export const addCommand = defineCommand({
return;
}

// Dry-run: describe what the flow would do without executing any step.
// Dry-run: use the dry-run engine to produce a realistic preview.
if (args.dryRun) {
const provider = await getProvider(service);
const hints = buildHints(args);
const result = await dryRunProvider(service, {
existingResourceId: args.use ? String(args.use) : undefined,
hints,
costEstimate: false,
});

// Always emit the structured table report
const report = formatDryRunReport({
generatedAt: new Date().toISOString(),
providers: [result],
totalSecrets: Object.keys(result.secrets).length,
totalMcpEntries: result.mcpEntry ? 1 : 0,
});

console.log();
console.log(
` ${colors.bold(provider.displayName)} ${colors.dim(`(${provider.category} · ${provider.authKind})`)}`,
);
console.log(
` ${colors.dim("1.")} login ${colors.dim("(prompt for PAT or run OAuth)")}`,
);
console.log(
` ${colors.dim("2.")} provision ${args.use ? `attach to existing resource ${colors.dim(String(args.use))}` : colors.dim("create a new upstream resource")}`,
);
console.log(
` ${colors.dim("3.")} materialize ${colors.dim("fetch credentials for the resource")}`,
);
console.log(
` ${colors.dim("4.")} persist ${colors.dim("write secrets to Phantom + MCP + .stack.toml")}`,
);
for (const line of report.split("\n")) {
console.log(` ${line}`);
}

// SDK packages hint
const dryRef = findProviderRef(service);
const dryPkgs = dryRef?.sdkPackages ?? [];
if (dryPkgs.length > 0) {
console.log();
console.log(
` ${colors.dim("5.")} sdk install ${colors.dim(`install ${dryPkgs.join(", ")} (or skip if --install=never)`)}`,
);
} else {
console.log(
` ${colors.dim("5.")} sdk install ${colors.dim("(skipped — no sdk packages for this provider)")}`,
` ${colors.dim("sdk:")} would offer to install ${colors.bold(dryPkgs.join(", "))}`,
);
}

console.log();
outro(colors.dim("dry-run complete — nothing written."));
return;
}

// Attach trace collector if --trace was given.
const traceFile = args.trace ? String(args.trace) : undefined;
const traceCollector = traceFile ? new FileCollector(traceFile) : undefined;
if (traceCollector) {
instrumentation.attach(traceCollector);
}

const spinner = prompts.spinner();
try {
spinner.start(`Wiring ${service}…`);
const timeoutMs = args.timeout !== undefined
? Number(args.timeout) * 1000
: undefined;
const result = await addService({
providerName: service,
existingResourceId: args.use ? String(args.use) : undefined,
hints: args.region ? { region: String(args.region) } : undefined,
hints: buildHints(args),
interactive: process.stdout.isTTY === true,
timeoutMs,
log: (event) => {
spinner.stop();
logEvent(event);
Expand Down Expand Up @@ -175,6 +221,15 @@ export const addCommand = defineCommand({
} catch (err) {
spinner.stop(colors.red("Failed."));
outroError((err as Error).message);
} finally {
// Always flush trace (captures both success and failure events).
if (traceCollector) {
await instrumentation.flush();
instrumentation.detach();
console.log(
` ${colors.dim("trace:")} instrumentation events written to ${colors.bold(traceFile!)}`,
);
}
}
},
});
Expand Down Expand Up @@ -221,6 +276,17 @@ async function handleSdkInstall(pkgs: string[], mode: string, dryRun: boolean):
}
}

function buildHints(
args: Record<string, unknown>,
): Record<string, unknown> | undefined {
const hints: Record<string, unknown> = {};
if (args.region) hints.region = String(args.region);
if (args.webhookEndpoint) hints.webhookEndpoint = String(args.webhookEndpoint);
if (args.events) hints.events = String(args.events);
if (args.secretKeyFromVault) hints.secretKeyFromVault = true;
return Object.keys(hints).length > 0 ? hints : undefined;
}

async function groupProvidersForPicker(names: string[]) {
const out: Array<{ label: string; value: string; hint?: string }> = [];
for (const name of names) {
Expand Down
50 changes: 49 additions & 1 deletion packages/cli/src/commands/apply.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {
type Recipe,
dryRunProviders,
formatDryRunReport,
formatDryRunReportJson,
hasConfig,
listRecipes,
readRecipe,
Expand Down Expand Up @@ -42,9 +45,29 @@ export const applyCommand = defineCommand({
description:
"On partial failure, leave successfully-added services in .stack.toml instead of rolling them back.",
},
dryRun: {
type: "boolean",
default: false,
description:
"Preview what would be provisioned without making any upstream API calls, writing secrets, or updating config.",
},
costEstimate: {
type: "boolean",
default: false,
description:
"When combined with --dry-run, include a monthly cost estimate for each provider (uses static pricing data; Moat 2 live MCP pricing coming in v0.4).",
},
json: {
type: "boolean",
default: false,
description: "Output dry-run report as JSON (useful for CI / programmatic consumption). Implies --dry-run.",
},
},
async run({ args }) {
intro(`stack apply${args.noWire ? colors.dim(" (no-wire)") : ""}`);
const isDryRun = Boolean(args.dryRun) || Boolean(args.json);
intro(
`stack apply${isDryRun ? colors.dim(" (dry-run)") : ""}${args.noWire ? colors.dim(" (no-wire)") : ""}`,
);

// The marketed golden path is `stack recommend --save && stack apply <id>`,
// which must work from a blank directory. Auto-init if no .stack.toml so
Expand Down Expand Up @@ -78,6 +101,31 @@ export const applyCommand = defineCommand({
const recipe = await pickRecipe(args.recipeId ? String(args.recipeId) : undefined);
if (!recipe) return; // pickRecipe already surfaced the error.

// --- Dry-run path ---
if (isDryRun) {
const providerNames = recipe.providers.map((p) => p.name);
const report = await dryRunProviders(providerNames, {
costEstimate: Boolean(args.costEstimate),
});

if (args.json) {
// Machine-readable JSON — write directly to stdout, no clack decoration.
process.stdout.write(formatDryRunReportJson(report) + "\n");
} else {
// Human-readable table
console.log();
console.log(` ${colors.bold("recipe")} ${recipe.id}`);
console.log(` ${colors.dim("query")} ${recipe.query}`);
console.log();
const table = formatDryRunReport(report);
for (const line of table.split("\n")) {
console.log(` ${line}`);
}
outro(colors.dim("dry-run complete — nothing provisioned."));
}
return;
}

await requirePhantom();

console.log();
Expand Down
Loading