mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
feat: implement two-phase review flow for config updates
- Introduced a new API route for committing changes after user review, allowing the agent to keep the sandbox alive for inspection before finalizing updates. - Enhanced the existing applyConfigUpdate function to transition to an awaiting review state, storing the diff for user visibility. - Added progress tracking and stage reporting for the config agent run, improving user feedback during the update process. - Updated the dashboard to reflect the new review stages and provide a more interactive experience for managing configuration changes. Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
f6e121f816
commit
2558a63a81
@ -2,9 +2,10 @@
|
||||
* End-to-end smoke test for the config-update repo agent against a REAL Vercel
|
||||
* Sandbox and a REAL GitHub repo. This is a scratch script, not a unit test.
|
||||
*
|
||||
* It runs the single-phase apply flow: boot a sandbox (warm from the shared base
|
||||
* It runs the two-phase review flow: boot a sandbox (warm from the shared base
|
||||
* snapshot if STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID is set, else cold-install the
|
||||
* agent SDK), clone the repo, agent edits the config, COMMIT + PUSH to the branch.
|
||||
* agent SDK), clone the repo, agent edits the config, print the generated diff,
|
||||
* then explicitly COMMIT + PUSH to the branch.
|
||||
*
|
||||
* WARNING: this pushes a commit to the target repo. Point SPIKE_OWNER,
|
||||
* SPIKE_REPO, and SPIKE_BRANCH at a throwaway repo/branch.
|
||||
@ -18,6 +19,7 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
applyConfigUpdate,
|
||||
commitConfigUpdate,
|
||||
type GithubRepoRef,
|
||||
} from "../src/lib/config/repo-agent";
|
||||
|
||||
@ -53,21 +55,27 @@ async function main() {
|
||||
const getGithubToken = async () => githubToken();
|
||||
log(`Target: ${REF.owner}/${REF.repo}@${REF.branch}`);
|
||||
|
||||
log("applyConfigUpdate (boot + clone + agent edit + push)…");
|
||||
const t2 = Date.now();
|
||||
log("applyConfigUpdate (boot + clone + agent edit)…");
|
||||
const t2 = performance.now();
|
||||
const result = await applyConfigUpdate({
|
||||
getGithubToken,
|
||||
ref: REF,
|
||||
completeConfig: COMPLETE_CONFIG,
|
||||
commitMessage: "chore(hexclave): e2e smoke — set auth.allowSignUp=false",
|
||||
});
|
||||
log(`Done in ${((Date.now() - t2) / 1000).toFixed(0)}s`);
|
||||
log(`Done in ${((performance.now() - t2) / 1000).toFixed(0)}s`);
|
||||
log(`Result: ${JSON.stringify(result)}`);
|
||||
|
||||
if (result.mode === "commit-to-branch") {
|
||||
log(`✅ Pushed: ${result.commitUrl}`);
|
||||
} else {
|
||||
if (result.mode === "no-change") {
|
||||
log("⚠️ Agent produced no change (config already matched).");
|
||||
} else {
|
||||
log(`Review diff has ${result.diff.length} characters. Committing reviewed changes…`);
|
||||
const commit = await commitConfigUpdate({
|
||||
sandboxId: result.sandboxId,
|
||||
getGithubToken,
|
||||
ref: REF,
|
||||
commitMessage: "chore(hexclave): e2e smoke — set auth.allowSignUp=false",
|
||||
});
|
||||
log(`✅ Pushed: ${commit.commitUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,9 +4,11 @@ import {
|
||||
recordConfigAgentRunProgress,
|
||||
recordConfigAgentRunResult,
|
||||
recordConfigAgentRunSandbox,
|
||||
recordConfigAgentRunStage,
|
||||
setConfigAgentRunAwaitingReview,
|
||||
tryStartConfigAgentRun,
|
||||
} from "@/lib/config";
|
||||
import { applyConfigUpdate, type GithubRepoRef } from "@/lib/config/repo-agent";
|
||||
import { applyConfigUpdate, stopConfigAgentSandbox, type GithubRepoRef } from "@/lib/config/repo-agent";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
|
||||
@ -107,6 +109,10 @@ export const POST = createSmartRouteHandler({
|
||||
const onProgress = async (activity: string) => {
|
||||
await recordConfigAgentRunProgress({ projectId, branchId, progress: activity });
|
||||
};
|
||||
// Stage updates drive the dashboard progress bar.
|
||||
const onStage = async (stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes") => {
|
||||
await recordConfigAgentRunStage({ projectId, branchId, stage });
|
||||
};
|
||||
|
||||
runAsynchronouslyAndWaitUntil(async () => {
|
||||
try {
|
||||
@ -115,25 +121,32 @@ export const POST = createSmartRouteHandler({
|
||||
// computed from the current branch override merged with this change.
|
||||
const completeConfig = await getCompleteBranchConfigForFile({ projectId, branchId, configUpdate });
|
||||
|
||||
// Boot a sandbox (warm from the shared base snapshot if configured, else
|
||||
// cold-install the agent SDK), clone the repo fresh, edit + commit + push.
|
||||
// Boot a sandbox, clone the repo, run the agent. The sandbox stays alive
|
||||
// in `awaiting_review` so the user can inspect the diff before committing.
|
||||
const result = await applyConfigUpdate({
|
||||
getGithubToken,
|
||||
ref,
|
||||
completeConfig,
|
||||
commitMessage,
|
||||
onSandboxId,
|
||||
onStage,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
await recordConfigAgentRunResult({
|
||||
projectId,
|
||||
branchId,
|
||||
nowMs: Date.now(),
|
||||
outcome: result.mode === "commit-to-branch"
|
||||
? { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha }
|
||||
: { status: "no-change" },
|
||||
});
|
||||
if (result.mode === "no-change") {
|
||||
await recordConfigAgentRunResult({
|
||||
projectId,
|
||||
branchId,
|
||||
nowMs: Date.now(),
|
||||
outcome: { status: "no-change" },
|
||||
});
|
||||
} else {
|
||||
// Transition to awaiting_review — sandbox stays alive.
|
||||
await setConfigAgentRunAwaitingReview({
|
||||
projectId,
|
||||
branchId,
|
||||
diff: result.diff,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
captureError("config-github-apply", error);
|
||||
await recordConfigAgentRunResult({
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import {
|
||||
getBranchConfigOverrideSource,
|
||||
recordConfigAgentRunResult,
|
||||
} from "@/lib/config";
|
||||
import { commitConfigUpdate, stopConfigAgentSandbox, type GithubRepoRef } from "@/lib/config/repo-agent";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { StatusError, captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
|
||||
// The commit+push itself is fast (~10 s) but we give generous room for sandbox
|
||||
// reconnect latency and slow GitHub API responses.
|
||||
export const maxDuration = 120;
|
||||
|
||||
/**
|
||||
* Commits and pushes the agent's already-applied changes from an `awaiting_review`
|
||||
* sandbox. The user explicitly triggered this after reviewing the diff in the
|
||||
* dashboard. Returns immediately and does the work in the background.
|
||||
*/
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
summary: "Commit config agent changes to GitHub",
|
||||
description: "Commits and pushes the agent's already-applied config changes after user review.",
|
||||
tags: ["Config"],
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: adminAuthTypeSchema,
|
||||
tenancy: adaptSchema,
|
||||
}).defined(),
|
||||
body: yupObject({
|
||||
github_access_token: yupString().defined(),
|
||||
commit_message: yupString().optional(),
|
||||
}).defined(),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
status: yupString().oneOf(["committing", "not-awaiting-review", "sandbox-expired"]).defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
handler: async (req) => {
|
||||
const projectId = req.auth.tenancy.project.id;
|
||||
const branchId = req.auth.tenancy.branchId;
|
||||
|
||||
const source = await getBranchConfigOverrideSource({ projectId, branchId });
|
||||
if (source.type !== "pushed-from-github") {
|
||||
throw new StatusError(StatusError.BadRequest, "This project's configuration is not linked to a GitHub repository.");
|
||||
}
|
||||
|
||||
const run = source.agent_run;
|
||||
if (!run || run.status !== "awaiting_review") {
|
||||
return { statusCode: 200, bodyType: "json", body: { status: "not-awaiting-review" } };
|
||||
}
|
||||
|
||||
const sandboxId = run.sandbox_id;
|
||||
if (!sandboxId) {
|
||||
// Sandbox id was never recorded — can't commit. Treat as an error and clean up.
|
||||
await recordConfigAgentRunResult({
|
||||
projectId,
|
||||
branchId,
|
||||
nowMs: Date.now(),
|
||||
outcome: { status: "error", error: "Sandbox session expired. Please retry the update." },
|
||||
});
|
||||
return { statusCode: 200, bodyType: "json", body: { status: "sandbox-expired" } };
|
||||
}
|
||||
|
||||
const githubToken = req.body.github_access_token;
|
||||
const commitMessage = req.body.commit_message?.trim() || "chore(hexclave): update config from dashboard";
|
||||
const ref: GithubRepoRef = { owner: source.owner, repo: source.repo, branch: source.branch };
|
||||
const getGithubToken = async () => githubToken;
|
||||
|
||||
runAsynchronouslyAndWaitUntil(async () => {
|
||||
try {
|
||||
const result = await commitConfigUpdate({ sandboxId, getGithubToken, ref, commitMessage });
|
||||
await recordConfigAgentRunResult({
|
||||
projectId,
|
||||
branchId,
|
||||
nowMs: Date.now(),
|
||||
outcome: { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError("config-github-commit", error);
|
||||
await stopConfigAgentSandbox(sandboxId);
|
||||
await recordConfigAgentRunResult({
|
||||
projectId,
|
||||
branchId,
|
||||
nowMs: Date.now(),
|
||||
outcome: { status: "error", error: "Failed to commit and push the config changes." },
|
||||
}).catch((e) => captureError("config-github-commit-record-error", e));
|
||||
}
|
||||
});
|
||||
|
||||
return { statusCode: 200, bodyType: "json", body: { status: "committing" } };
|
||||
},
|
||||
});
|
||||
@ -494,7 +494,10 @@ export async function getCompleteBranchConfigForFile(options: {
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const current = await rawQuery(globalPrismaClient, getBranchConfigOverrideQuery({ projectId: options.projectId, branchId: options.branchId }));
|
||||
const merged = override(current as Config, options.configUpdate as Config);
|
||||
return normalize(merged, { onDotIntoNonObject: "ignore" }) as Record<string, unknown>;
|
||||
// Dashboard saves usually arrive as dot-notation deltas (for example
|
||||
// `auth.allowSignUp: false`). The branch override can be empty, so missing
|
||||
// parents must be materialized instead of silently dropping the pending edit.
|
||||
return normalize(merged, { onDotIntoNonObject: "ignore", onDotIntoNull: "empty-object" }) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -587,6 +590,10 @@ export async function recordConfigAgentRunProgress(options: {
|
||||
if (!source || source.type !== "pushed-from-github") return;
|
||||
if (source.agent_run?.status !== "running") return;
|
||||
const next: GithubConfigSource = {
|
||||
// Stored cap (2000 chars): a coarse DB-size guard on the persisted feed.
|
||||
// The runner already trims each line to 100 chars and keeps only the last
|
||||
// ~6 lines (see buildRunnerScript), so this only bites pathological input;
|
||||
// the two limits are independent on purpose (storage vs. in-sandbox feed).
|
||||
...source,
|
||||
agent_run: { ...source.agent_run, progress: options.progress.slice(0, 2000) },
|
||||
};
|
||||
@ -597,13 +604,80 @@ export async function recordConfigAgentRunProgress(options: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the current stage of an in-flight run for the dashboard progress bar.
|
||||
* No-ops unless a run is still `running`.
|
||||
*/
|
||||
export async function recordConfigAgentRunStage(options: {
|
||||
projectId: string,
|
||||
branchId: string,
|
||||
stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes",
|
||||
}): Promise<void> {
|
||||
await retryTransaction(globalPrismaClient, async (tx) => {
|
||||
const rows = await tx.$queryRaw<{ source: any }[]>`
|
||||
SELECT "source" FROM "BranchConfigOverride"
|
||||
WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const source = rows[0]?.source;
|
||||
if (!source || source.type !== "pushed-from-github") return;
|
||||
if (source.agent_run?.status !== "running") return;
|
||||
const next: GithubConfigSource = {
|
||||
...source,
|
||||
agent_run: { ...source.agent_run, stage: options.stage },
|
||||
};
|
||||
await tx.branchConfigOverride.update({
|
||||
where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } },
|
||||
data: { source: next as any },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a `running` agent run to `awaiting_review`: the agent has finished
|
||||
* editing the config but has not yet committed or pushed. The diff is stored so
|
||||
* the dashboard can display it. The sandbox_id must still be set (the sandbox
|
||||
* stays alive — it will be used to commit+push when the user confirms, or
|
||||
* hard-stopped on cancel).
|
||||
*/
|
||||
export async function setConfigAgentRunAwaitingReview(options: {
|
||||
projectId: string,
|
||||
branchId: string,
|
||||
diff: string,
|
||||
}): Promise<{ sandboxId: string | undefined }> {
|
||||
return await retryTransaction(globalPrismaClient, async (tx) => {
|
||||
const rows = await tx.$queryRaw<{ source: any }[]>`
|
||||
SELECT "source" FROM "BranchConfigOverride"
|
||||
WHERE "projectId" = ${options.projectId} AND "branchId" = ${options.branchId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const source = rows[0]?.source;
|
||||
if (!source || source.type !== "pushed-from-github") return { sandboxId: undefined };
|
||||
if (source.agent_run?.status !== "running") return { sandboxId: undefined };
|
||||
const next: GithubConfigSource = {
|
||||
...source,
|
||||
agent_run: {
|
||||
...source.agent_run,
|
||||
status: "awaiting_review",
|
||||
stage: "awaiting_review",
|
||||
diff: options.diff.slice(0, 100_000),
|
||||
},
|
||||
};
|
||||
await tx.branchConfigOverride.update({
|
||||
where: { projectId_branchId: { projectId: options.projectId, branchId: options.branchId } },
|
||||
data: { source: next as any },
|
||||
});
|
||||
return { sandboxId: source.agent_run?.sandbox_id };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests cancellation of the in-flight config agent run. Atomically flips a
|
||||
* `running` run to the terminal `cancelled` status (so the original run's late
|
||||
* result is ignored — see {@link recordConfigAgentRunResult}) and returns the
|
||||
* sandbox id (if recorded) so the caller can hard-stop the sandbox. Returns
|
||||
* `{ cancelled: false }` when no fresh run is in flight. (No revert: stopping the
|
||||
* sandbox before the push undoes the change; a commit that already landed stays.)
|
||||
* `running` or `awaiting_review` run to the terminal `cancelled` status and
|
||||
* returns the sandbox id (if recorded) so the caller can hard-stop the sandbox.
|
||||
* Returns `{ cancelled: false }` when no fresh run is in flight. (No revert:
|
||||
* stopping the sandbox before the push undoes the change; a commit that already
|
||||
* landed stays.)
|
||||
*/
|
||||
export async function cancelConfigAgentRun(options: {
|
||||
projectId: string,
|
||||
@ -619,7 +693,7 @@ export async function cancelConfigAgentRun(options: {
|
||||
const source = rows[0]?.source;
|
||||
if (!source || source.type !== "pushed-from-github") return { cancelled: false };
|
||||
const run = source.agent_run;
|
||||
if (!run || run.status !== "running") return { cancelled: false };
|
||||
if (!run || (run.status !== "running" && run.status !== "awaiting_review")) return { cancelled: false };
|
||||
const sandboxId: string | undefined = run.sandbox_id;
|
||||
const next: GithubConfigSource = {
|
||||
...source,
|
||||
|
||||
@ -18,8 +18,9 @@
|
||||
*
|
||||
* Each update then does a FRESH shallow clone of the target branch inside the
|
||||
* sandbox (we take that clone cost per write instead of caching it), runs the
|
||||
* agent, commits, and pushes. The sandbox is destroyed afterwards and is NEVER
|
||||
* snapshotted, so the token that lives in `origin` for the clone/push dies with it.
|
||||
* agent, keeps the sandbox alive for review, then either commits/pushes or
|
||||
* stops it on discard. The sandbox is NEVER snapshotted, so the token that lives
|
||||
* in `origin` for the clone/push dies with it.
|
||||
*
|
||||
* Phases:
|
||||
* buildConfigAgentBaseSnapshot — one-off (build script): node24 + agent SDK + git
|
||||
@ -49,6 +50,7 @@ const TOOLS_DIR = BASE; // agent SDK + runner live here, separate from the repo
|
||||
const DEFAULT_AGENT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
const DEFAULT_PROXY_URL = "https://api.hexclave.com/api/latest/integrations/ai-proxy";
|
||||
const SANDBOX_TIMEOUT_MS = 900_000;
|
||||
const REVIEW_SANDBOX_KEEPALIVE_MS = 5 * 60_000;
|
||||
const GIT_BOT_NAME = "Hexclave Config Bot";
|
||||
const GIT_BOT_EMAIL = "config-bot@hexclave.com";
|
||||
|
||||
@ -63,9 +65,7 @@ export type GithubRepoRef = { owner: string, repo: string, branch: string };
|
||||
*/
|
||||
export type GithubTokenProvider = () => Promise<string>;
|
||||
|
||||
export type ConfigUpdatePushResult =
|
||||
| { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string }
|
||||
| { mode: "no-change" };
|
||||
export type ConfigUpdateCommitResult = { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string };
|
||||
|
||||
export class ConfigRepoAgentError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
@ -92,6 +92,51 @@ function sandboxCreds(): SandboxCreds {
|
||||
};
|
||||
}
|
||||
|
||||
async function getConfigAgentSandbox(sandboxId: string): Promise<Sandbox> {
|
||||
const creds = sandboxCreds();
|
||||
return await Sandbox.get({ sandboxId, token: creds.token, teamId: creds.teamId, projectId: creds.projectId });
|
||||
}
|
||||
|
||||
async function stopSandboxWithContext(sandboxId: string, context: string): Promise<void> {
|
||||
try {
|
||||
const sandbox = await getConfigAgentSandbox(sandboxId);
|
||||
await sandbox.stop();
|
||||
} catch (error) {
|
||||
captureError(context, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function keepSandboxAliveForReview(sandbox: Sandbox): Promise<void> {
|
||||
// The review UI needs the live sandbox because commit runs from the edited
|
||||
// working tree. Top it back up to a five-minute review window once the diff is
|
||||
// ready instead of relying on whatever time is left after agent execution.
|
||||
const timeoutRemainingMs = sandbox.timeout;
|
||||
if (timeoutRemainingMs < REVIEW_SANDBOX_KEEPALIVE_MS) {
|
||||
await sandbox.extendTimeout(REVIEW_SANDBOX_KEEPALIVE_MS - timeoutRemainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function reportConfigAgentProgress(onProgress: AgentProgressSink | undefined, progress: string, context: string): Promise<void> {
|
||||
if (!onProgress) return;
|
||||
try {
|
||||
await onProgress(progress);
|
||||
} catch (error) {
|
||||
captureError(context, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function reportConfigAgentStage(
|
||||
onStage: ((stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes") => Promise<void>) | undefined,
|
||||
stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes",
|
||||
): Promise<void> {
|
||||
if (!onStage) return;
|
||||
try {
|
||||
await onStage(stage);
|
||||
} catch (error) {
|
||||
captureError("config-repo-agent-stage", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any tokenized remote URL (`https://x-access-token:<token>@github.com/...`)
|
||||
* out of a string before it can be thrown, captured, persisted, or logged. The
|
||||
@ -183,6 +228,9 @@ process.on("unhandledRejection", (e) => { status({ ok: false, error: "unhandledR
|
||||
const PROGRESS = ${JSON.stringify(`${TOOLS_DIR}/progress.json`)};
|
||||
const recent = [];
|
||||
const base = (p) => (typeof p === "string" ? (p.split("/").pop() || p) : "");
|
||||
// In-sandbox feed cap: 100 chars/line, last 6 lines. Separate from the coarser
|
||||
// 2000-char storage cap in recordConfigAgentRunProgress (config/index.tsx) —
|
||||
// this one shapes what the user sees live; that one guards DB size.
|
||||
const emit = (s) => { recent.push(String(s).replace(/[\\r\\n]+/g, " ").slice(0, 100)); while (recent.length > 6) recent.shift(); try { writeFileSync(PROGRESS, JSON.stringify(recent)); } catch {} };
|
||||
const describeTool = (name, inp) => {
|
||||
inp = inp || {};
|
||||
@ -268,7 +316,7 @@ async function pollAgentProgress(
|
||||
const text = redactTokens(lines.map((l) => String(l)).join("\n")).trim();
|
||||
if (text && text !== last) {
|
||||
last = text;
|
||||
await onProgress(text).catch(() => {});
|
||||
await reportConfigAgentProgress(onProgress, text, "config-repo-agent-progress-record");
|
||||
}
|
||||
};
|
||||
while (!finished) {
|
||||
@ -393,55 +441,102 @@ export async function buildConfigAgentBaseSnapshot(onProgress?: (msg: string) =>
|
||||
// Apply update (on save)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The result of `applyConfigUpdate`. When the agent found and edited the config
|
||||
* file, the sandbox is left alive (status `awaiting_review`) and the caller is
|
||||
* responsible for either calling `commitConfigUpdate` or `stopConfigAgentSandbox`.
|
||||
*/
|
||||
export type ConfigUpdateApplyResult =
|
||||
| {
|
||||
mode: "awaiting_review",
|
||||
sandboxId: string,
|
||||
/** Unified git diff of the agent's changes; never contains secrets. */
|
||||
diff: string,
|
||||
}
|
||||
| { mode: "no-change" };
|
||||
|
||||
export async function applyConfigUpdate(options: {
|
||||
getGithubToken: GithubTokenProvider,
|
||||
ref: GithubRepoRef,
|
||||
completeConfig: Record<string, unknown>,
|
||||
commitMessage?: string,
|
||||
onSandboxId?: (sandboxId: string) => Promise<void>,
|
||||
onStage?: (stage: "initializing_sandbox" | "cloning_repo" | "agent_making_changes") => Promise<void>,
|
||||
onProgress?: AgentProgressSink,
|
||||
}): Promise<ConfigUpdatePushResult> {
|
||||
const { getGithubToken, ref, completeConfig, onSandboxId, onProgress } = options;
|
||||
}): Promise<ConfigUpdateApplyResult> {
|
||||
const { getGithubToken, ref, completeConfig, onSandboxId, onStage, onProgress } = options;
|
||||
const creds = sandboxCreds();
|
||||
const commitMessage = options.commitMessage?.trim() || "chore(hexclave): update config from dashboard";
|
||||
const step = async (msg: string) => {
|
||||
if (onProgress) await onProgress(msg).catch(() => {});
|
||||
await reportConfigAgentProgress(onProgress, msg, "config-repo-agent-step-record");
|
||||
};
|
||||
const githubToken = await getGithubToken(); // fresh token for this boot
|
||||
|
||||
await step("Starting the config agent…");
|
||||
await reportConfigAgentStage(onStage, "initializing_sandbox");
|
||||
await step("Initializing the sandbox…");
|
||||
const sandbox = await bootAgentSandbox(creds);
|
||||
// Do NOT stop the sandbox in a finally block — when changes are found, we leave
|
||||
// it alive for the user to review and commit. The caller must stop it.
|
||||
await onSandboxId?.(sandbox.sandboxId);
|
||||
// Configure the bot identity for the commit (idempotent; cheap on a warm boot).
|
||||
await run(sandbox, "git", ["config", "--global", "user.email", GIT_BOT_EMAIL]);
|
||||
await run(sandbox, "git", ["config", "--global", "user.name", GIT_BOT_NAME]);
|
||||
|
||||
// Fresh shallow clone of just the target branch. The tokenized URL is used
|
||||
// only for the clone; immediately after, we reset `origin` to a tokenless URL
|
||||
// so the agent (which has Bash access) cannot read the token from `.git/config`
|
||||
// or `git remote -v`. The token is re-injected only for our own push command.
|
||||
await reportConfigAgentStage(onStage, "cloning_repo");
|
||||
await step(`Cloning ${ref.owner}/${ref.repo}@${ref.branch}…`);
|
||||
await run(sandbox, "git", ["clone", "--depth", "1", "--single-branch", "--branch", ref.branch, tokenUrl(githubToken, ref), REPO_DIR]);
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "remote", "set-url", "origin", tokenlessUrl(ref)]);
|
||||
|
||||
// Agent writes the COMPLETE config to the file — no dependency install, no
|
||||
// typecheck (the linked repo's CI validates the committed change). See buildUpdatePrompt.
|
||||
await reportConfigAgentStage(onStage, "agent_making_changes");
|
||||
await step("Agent editing config…");
|
||||
await runAgent(sandbox, buildUpdatePrompt(completeConfig), onProgress);
|
||||
|
||||
const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim();
|
||||
if (dirty === "") {
|
||||
// No changes — stop the sandbox immediately (nothing to review).
|
||||
await stopSandboxWithContext(sandbox.sandboxId, "config-repo-agent-no-change-stop");
|
||||
return { mode: "no-change" };
|
||||
}
|
||||
|
||||
// Capture the diff before staging so the user can review it. Redact tokens
|
||||
// (defensive; the diff shouldn't contain any, but be safe).
|
||||
const diff = redactTokens(
|
||||
(await runRaw(sandbox, "git", ["-C", REPO_DIR, "diff"])).stdout,
|
||||
);
|
||||
|
||||
await keepSandboxAliveForReview(sandbox);
|
||||
// Leave the sandbox alive — the user must confirm before we commit+push.
|
||||
return { mode: "awaiting_review", sandboxId: sandbox.sandboxId, diff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits and pushes the agent's already-applied changes from an existing sandbox
|
||||
* that is currently in `awaiting_review` state. The sandbox is stopped afterwards.
|
||||
* The GitHub token is obtained freshly — the user may have been reviewing for a
|
||||
* while so we don't reuse the token from the original `applyConfigUpdate` call.
|
||||
*/
|
||||
export async function commitConfigUpdate(options: {
|
||||
sandboxId: string,
|
||||
getGithubToken: GithubTokenProvider,
|
||||
ref: GithubRepoRef,
|
||||
commitMessage: string,
|
||||
}): Promise<ConfigUpdateCommitResult> {
|
||||
const { sandboxId, ref, commitMessage } = options;
|
||||
const githubToken = await options.getGithubToken();
|
||||
const sandbox = await getConfigAgentSandbox(sandboxId);
|
||||
try {
|
||||
await onSandboxId?.(sandbox.sandboxId);
|
||||
// Configure the bot identity for the commit (idempotent; cheap on a warm boot).
|
||||
await run(sandbox, "git", ["config", "--global", "user.email", GIT_BOT_EMAIL]);
|
||||
await run(sandbox, "git", ["config", "--global", "user.name", GIT_BOT_NAME]);
|
||||
|
||||
// Fresh shallow clone of just the target branch. The tokenized URL is used
|
||||
// only for the clone; immediately after, we reset `origin` to a tokenless URL
|
||||
// so the agent (which has Bash access) cannot read the token from `.git/config`
|
||||
// or `git remote -v`. The token is re-injected only for our own push command.
|
||||
await step(`Cloning ${ref.owner}/${ref.repo}@${ref.branch}…`);
|
||||
await run(sandbox, "git", ["clone", "--depth", "1", "--single-branch", "--branch", ref.branch, tokenUrl(githubToken, ref), REPO_DIR]);
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "remote", "set-url", "origin", tokenlessUrl(ref)]);
|
||||
|
||||
// Agent writes the COMPLETE config to the file — no dependency install, no
|
||||
// typecheck (the linked repo's CI validates the committed change). See buildUpdatePrompt.
|
||||
await runAgent(sandbox, buildUpdatePrompt(completeConfig), onProgress);
|
||||
|
||||
const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim();
|
||||
if (dirty === "") {
|
||||
return { mode: "no-change" };
|
||||
}
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "add", "-A"]);
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "commit", "-m", commitMessage]);
|
||||
const commitSha = await gitHead(sandbox);
|
||||
await step("Pushing the commit…");
|
||||
// Re-inject the token for the push only (origin was reset to tokenless after clone).
|
||||
// Re-inject the token for the push only.
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "push", tokenUrl(githubToken, ref), `HEAD:refs/heads/${ref.branch}`]);
|
||||
return { mode: "commit-to-branch", branch: ref.branch, commitUrl: `https://github.com/${ref.owner}/${ref.repo}/commit/${commitSha}`, commitSha };
|
||||
} finally {
|
||||
await sandbox.stop().catch(() => {});
|
||||
await stopSandboxWithContext(sandboxId, "config-repo-agent-commit-stop");
|
||||
}
|
||||
}
|
||||
|
||||
@ -456,11 +551,5 @@ export async function applyConfigUpdate(options: {
|
||||
* undoes the change; if a commit already landed, it stays (no revert).
|
||||
*/
|
||||
export async function stopConfigAgentSandbox(sandboxId: string): Promise<void> {
|
||||
const creds = sandboxCreds();
|
||||
try {
|
||||
const sandbox = await Sandbox.get({ sandboxId, token: creds.token, teamId: creds.teamId, projectId: creds.projectId });
|
||||
await sandbox.stop();
|
||||
} catch (error) {
|
||||
captureError("config-repo-agent-cancel-stop", error);
|
||||
}
|
||||
await stopSandboxWithContext(sandboxId, "config-repo-agent-cancel-stop");
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
|
||||
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval";
|
||||
import { detectImportPackageFromDir, evalConfigFileContent, type ParsedConfigValue } from "@hexclave/shared/dist/config-eval";
|
||||
import { isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
@ -18,7 +18,7 @@ export const LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE =
|
||||
export const LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV = "STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT";
|
||||
export const LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE = showOnboardingHexclaveConfigValue;
|
||||
|
||||
type LocalEmulatorConfigValue = Record<string, unknown> | typeof LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE;
|
||||
type LocalEmulatorConfigValue = ParsedConfigValue;
|
||||
|
||||
export function isLocalEmulatorEnabled() {
|
||||
return getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true";
|
||||
@ -76,14 +76,10 @@ async function readConfigContent(filePath: string): Promise<string> {
|
||||
async function readConfigValueFromFile(filePath: string): Promise<LocalEmulatorConfigValue> {
|
||||
const content = await readConfigContent(filePath);
|
||||
try {
|
||||
const result = evalConfigFileContent(content, filePath);
|
||||
if (typeof result === "string") {
|
||||
if (result !== LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE) {
|
||||
throw new Error(`Unexpected string config value: ${JSON.stringify(result)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
// `evalConfigFileContent` already guarantees the value is a config object or
|
||||
// the `"show-onboarding"` sentinel (it throws otherwise), so no further
|
||||
// shape check is needed here.
|
||||
return evalConfigFileContent(content, filePath);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new StatusError(StatusError.BadRequest, `Error evaluating config in ${filePath}: ${message}`);
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.72",
|
||||
"@pierre/diffs": "^1.2.11",
|
||||
"@assistant-ui/react": "^0.10.24",
|
||||
"@assistant-ui/react-ai-sdk": "^0.10.14",
|
||||
"@assistant-ui/react-markdown": "^0.10.5",
|
||||
|
||||
@ -2,17 +2,18 @@
|
||||
|
||||
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
|
||||
import { ActionDialog } from "@/components/ui/action-dialog";
|
||||
import { GitBranch } from "@phosphor-icons/react";
|
||||
import type { StackAdminApp } from "@hexclave/next";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ConfigAgentActivityFeed, useGithubRunActive } from "./config-update";
|
||||
import { ConfigAgentRunProgressContent, type AgentStage, getAdminInterface, isGithubPushedSourceWithAgentRun, useGithubRunActive, type GithubPushedSourceWithAgentRun } from "./config-update";
|
||||
|
||||
/**
|
||||
* Watches the linked-GitHub config source for an in-flight agent run and, when
|
||||
* one is running, pops a NON-DISMISSIBLE progress modal — so opening the project
|
||||
* (in any tab, or after a reload) while a run is going surfaces it and prevents
|
||||
* starting a conflicting edit. The only way out is Cancel (hard-stops the
|
||||
* sandbox) or the run reaching a terminal status.
|
||||
* one is running, pops a NON-DISMISSIBLE progress modal so opening the project
|
||||
* in another tab surfaces it and prevents starting a conflicting edit. Review
|
||||
* and commit stay owned by the push dialog that started the run.
|
||||
*
|
||||
* Mounted once per project (inside `AdminAppProvider`). It deliberately stays
|
||||
* silent for runs THIS tab started via the push dialog — that dialog owns the
|
||||
@ -25,11 +26,12 @@ const ACTIVE_POLL_MS = 3_000; // a run is on screen — poll tightly
|
||||
const LINKED_IDLE_POLL_MS = 10_000; // linked repo, no run — watch for new runs
|
||||
const UNLINKED_POLL_MS = 30_000; // not a GitHub-linked project — back off
|
||||
|
||||
async function readPushedConfigSource(adminApp: unknown): Promise<any | null> {
|
||||
const iface = (adminApp as any)?._interface;
|
||||
async function readPushedConfigSource(adminApp: StackAdminApp<false> | null): Promise<GithubPushedSourceWithAgentRun | null> {
|
||||
const iface = getAdminInterface(adminApp);
|
||||
if (iface == null || typeof iface.getPushedConfigSource !== "function") return null;
|
||||
try {
|
||||
return await iface.getPushedConfigSource();
|
||||
const source: unknown = await iface.getPushedConfigSource();
|
||||
return isGithubPushedSourceWithAgentRun(source) ? source : null;
|
||||
} catch {
|
||||
return null; // transient — try again next tick
|
||||
}
|
||||
@ -43,13 +45,14 @@ export function ConfigAgentRunWatcher() {
|
||||
const [sourceInfo, setSourceInfo] = useState<SourceInfo | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [activity, setActivity] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<AgentStage | null>(null);
|
||||
const [startedAt, setStartedAt] = useState<number>(0);
|
||||
|
||||
// Keep the latest phase readable inside the polling loop without retriggering it.
|
||||
const phaseRef = useRef(phase);
|
||||
phaseRef.current = phase;
|
||||
|
||||
const handleCancel = useCallback(async (): Promise<"prevent-close" | undefined> => {
|
||||
const iface = (adminApp as any)?._interface;
|
||||
const iface = getAdminInterface(adminApp);
|
||||
if (iface == null || typeof iface.cancelConfigAgentRun !== "function") {
|
||||
setErrorMessage("This dashboard build can't cancel a config run. Please refresh and try again.");
|
||||
setPhase("error");
|
||||
@ -60,7 +63,6 @@ export function ConfigAgentRunWatcher() {
|
||||
try {
|
||||
await iface.cancelConfigAgentRun();
|
||||
} catch (error) {
|
||||
// Best-effort: the poll loop still settles if the cancel landed.
|
||||
captureError("config-agent-watcher-cancel", error);
|
||||
}
|
||||
return "prevent-close";
|
||||
@ -68,13 +70,11 @@ export function ConfigAgentRunWatcher() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminApp) return;
|
||||
// Mutable holder (not a `let`) so eslint's flow analysis doesn't treat the
|
||||
// post-await `stopped` check as always-false — the flag is flipped in cleanup.
|
||||
const loop = { stopped: false };
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const apply = (source: any): number => {
|
||||
if (!source || source.type !== "pushed-from-github") {
|
||||
const apply = (source: GithubPushedSourceWithAgentRun | null): number => {
|
||||
if (source == null) {
|
||||
if (phaseRef.current !== "error") setPhase("hidden");
|
||||
return UNLINKED_POLL_MS;
|
||||
}
|
||||
@ -88,11 +88,11 @@ export function ConfigAgentRunWatcher() {
|
||||
}
|
||||
if (status === "running") {
|
||||
setActivity(typeof source.agent_run?.progress === "string" ? source.agent_run.progress : null);
|
||||
if (source.agent_run?.stage != null) setStage(source.agent_run.stage);
|
||||
if (typeof source.agent_run?.started_at === "number") setStartedAt(source.agent_run.started_at);
|
||||
if (phaseRef.current !== "cancelling") setPhase("running");
|
||||
return ACTIVE_POLL_MS;
|
||||
}
|
||||
// Terminal (or no run). Only surface an error if we were actively showing
|
||||
// this run — a stale error from before the page loaded shouldn't pop a modal.
|
||||
if (status === "error" && (phaseRef.current === "running" || phaseRef.current === "cancelling")) {
|
||||
setErrorMessage(source.agent_run?.error ?? "The config agent failed to apply the change.");
|
||||
setPhase("error");
|
||||
@ -104,7 +104,7 @@ export function ConfigAgentRunWatcher() {
|
||||
|
||||
const tick = async () => {
|
||||
const source = await readPushedConfigSource(adminApp);
|
||||
if (loop.stopped) return; // unmounted while the request was in flight
|
||||
if (loop.stopped) return;
|
||||
const delay = apply(source);
|
||||
timer = setTimeout(() => void tick(), delay);
|
||||
};
|
||||
@ -139,27 +139,22 @@ export function ConfigAgentRunWatcher() {
|
||||
<ActionDialog
|
||||
open
|
||||
preventClose
|
||||
title="Configuration update in progress"
|
||||
description={`A configuration change is being applied to ${linked} via the config agent.`}
|
||||
titleIcon={GitBranch}
|
||||
title="Push configuration to GitHub"
|
||||
description={`Applying your change in a sandbox — ${linked}`}
|
||||
cancelButton={{
|
||||
label: phase === "cancelling" ? "Cancelling…" : "Cancel update",
|
||||
label: phase === "cancelling" ? "Cancelling…" : "Cancel",
|
||||
onClick: handleCancel,
|
||||
props: { disabled: phase === "cancelling" },
|
||||
props: { disabled: phase === "cancelling", variant: "outline" },
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{phase === "cancelling"
|
||||
? "Cancelling the update and stopping the agent…"
|
||||
: "Applying your change in a sandbox and committing it to GitHub. This can take a couple of minutes."}
|
||||
</p>
|
||||
{phase !== "cancelling" && activity != null && activity.trim().length > 0 && (
|
||||
<ConfigAgentActivityFeed activity={activity} />
|
||||
)}
|
||||
{errorMessage != null && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<ConfigAgentRunProgressContent
|
||||
isCancelling={phase === "cancelling"}
|
||||
stage={stage}
|
||||
startedAt={startedAt}
|
||||
activity={activity}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</ActionDialog>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -421,7 +421,7 @@ describe("oauth config", () => {
|
||||
expect(invalidTypeResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch",
|
||||
"body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch, custom_oidc",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
@ -1765,6 +1765,18 @@ describe("branch config source", () => {
|
||||
branch: string,
|
||||
commit_hash: string,
|
||||
config_file_path: string,
|
||||
agent_run: {
|
||||
status: "running" | "awaiting_review" | "success" | "no-change" | "error" | "cancelled",
|
||||
started_at: number,
|
||||
finished_at?: number,
|
||||
progress?: string,
|
||||
sandbox_id?: string,
|
||||
commit_url?: string,
|
||||
new_commit_hash?: string,
|
||||
error?: string,
|
||||
stage?: "initializing_sandbox" | "cloning_repo" | "agent_making_changes" | "awaiting_review",
|
||||
diff?: string,
|
||||
},
|
||||
}>) => ({
|
||||
type: "pushed-from-github" as const,
|
||||
owner: overrides?.owner ?? "myorg",
|
||||
@ -1772,6 +1784,7 @@ describe("branch config source", () => {
|
||||
branch: overrides?.branch ?? "main",
|
||||
commit_hash: overrides?.commit_hash ?? "abc123def456",
|
||||
config_file_path: overrides?.config_file_path ?? "stack.config.ts",
|
||||
...(overrides?.agent_run != null ? { agent_run: overrides.agent_run } : {}),
|
||||
});
|
||||
|
||||
const createUnknownSource = () => ({
|
||||
@ -2364,6 +2377,26 @@ describe("branch config source", () => {
|
||||
expect(source).toEqual(sourceWithEmptyStrings);
|
||||
});
|
||||
|
||||
it("preserves config agent review state on github source fields", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
|
||||
const sourceWithReviewRun = createGitHubSource({
|
||||
agent_run: {
|
||||
status: "awaiting_review" as const,
|
||||
started_at: 1_777_000_000_000,
|
||||
sandbox_id: "sbx_review_123",
|
||||
stage: "awaiting_review" as const,
|
||||
progress: "Editing hexclave.config.ts",
|
||||
diff: "diff --git a/hexclave.config.ts b/hexclave.config.ts\n",
|
||||
},
|
||||
});
|
||||
|
||||
await Project.pushConfig({ 'teams.allowClientTeamCreation': true }, sourceWithReviewRun);
|
||||
|
||||
const source = await Project.getConfigSource();
|
||||
expect(source).toEqual(sourceWithReviewRun);
|
||||
});
|
||||
|
||||
it("handles source fields at string length boundaries", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ export async function readConfigFile(configFilePath: string): Promise<{ config:
|
||||
// user-facing message (the raw jiti/framework error is captured for diagnostics
|
||||
// but not attached as `cause` — dashboard error formatting renders causes
|
||||
// recursively and would leak framework internals).
|
||||
let parsed: Record<string, unknown> | string;
|
||||
let parsed: ReturnType<typeof evalConfigFileContent>;
|
||||
try {
|
||||
parsed = evalConfigFileContent(content, configFilePath);
|
||||
} catch (error) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { createJiti } from "jiti";
|
||||
import path from "path";
|
||||
import { showOnboardingHexclaveConfigValue } from "./config-authoring";
|
||||
import { detectConfigImportPackage } from "./config-rendering";
|
||||
|
||||
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
||||
@ -49,11 +50,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
type ParsedConfigValue = Record<string, unknown> | string;
|
||||
/** A config object, or the `"show-onboarding"` sentinel that stands in for one. */
|
||||
export type ParsedConfigValue = Record<string, unknown> | typeof showOnboardingHexclaveConfigValue;
|
||||
|
||||
function invalidConfigShape(filePath: string): ConfigFileEvalError {
|
||||
return new ConfigFileEvalError(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "${showOnboardingHexclaveConfigValue}".`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates config file content using jiti and returns the exported `config`
|
||||
* value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`.
|
||||
* value.
|
||||
*
|
||||
* WARNING: This executes arbitrary code via `jiti.evalModule` — only use on
|
||||
* content that is fully operator-controlled (local filesystem). Never call
|
||||
@ -64,32 +70,20 @@ export function evalConfigFileContent(content: string, filePath: string): Parsed
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
const mod: unknown = jiti.evalModule(content, { filename: resolvedPath });
|
||||
if (!isRecord(mod)) {
|
||||
throw new ConfigFileEvalError(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
throw invalidConfigShape(filePath);
|
||||
}
|
||||
const config = mod.config;
|
||||
if (config === undefined) {
|
||||
throw new ConfigFileEvalError(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
throw invalidConfigShape(filePath);
|
||||
}
|
||||
if (typeof config === "string") {
|
||||
if (config !== "show-onboarding") {
|
||||
throw new ConfigFileEvalError(`Invalid config in ${filePath}. String config values must be "show-onboarding", got "${config}".`);
|
||||
if (config !== showOnboardingHexclaveConfigValue) {
|
||||
throw new ConfigFileEvalError(`Invalid config in ${filePath}. String config values must be "${showOnboardingHexclaveConfigValue}", got "${config}".`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
if (isRecord(config)) return config;
|
||||
throw new ConfigFileEvalError(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link evalConfigFileContent}, but returns `null` instead of throwing
|
||||
* when the content cannot be evaluated.
|
||||
*/
|
||||
export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null {
|
||||
try {
|
||||
return evalConfigFileContent(content, filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
throw invalidConfigShape(filePath);
|
||||
}
|
||||
|
||||
// --- inline vitest tests ---
|
||||
@ -124,21 +118,3 @@ import.meta.vitest?.test("evalConfigFileContent rejects content without config e
|
||||
import.meta.vitest?.test("evalConfigFileContent rejects arbitrary string config values", ({ expect }) => {
|
||||
expect(() => evalConfigFileContent('export const config = "arbitrary-string";', "stack.config.ts")).toThrow(/must be "show-onboarding"/);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable function call", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = someUndefinedFunction();", "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable import", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent('import x from "./nonexistent-file";\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on syntax error", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
@ -375,6 +375,24 @@ import.meta.vitest?.test("normalize(...)", ({ expect }) => {
|
||||
expect(normalize({
|
||||
"b.c": 2,
|
||||
}, { onDotIntoNonObject: "ignore" })).toEqual({});
|
||||
expect(normalize({
|
||||
"auth.allowSignUp": false,
|
||||
"apps.installed.payments.enabled": false,
|
||||
}, {
|
||||
onDotIntoNonObject: "ignore",
|
||||
onDotIntoNull: "empty-object",
|
||||
})).toEqual({
|
||||
auth: {
|
||||
allowSignUp: false,
|
||||
},
|
||||
apps: {
|
||||
installed: {
|
||||
payments: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// dotting into non-object
|
||||
expect(() => normalize({
|
||||
|
||||
@ -859,8 +859,9 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
||||
|
||||
/**
|
||||
* Cancels the in-flight agent-driven config write: hard-stops the sandbox so
|
||||
* the agent stops mid-work. No revert — if the agent already pushed, the commit
|
||||
* stays. Returns `not-running` if no run is in flight.
|
||||
* the agent stops mid-work. Also cancels runs in `awaiting_review`. No revert
|
||||
* — if the agent already pushed, the commit stays. Returns `not-running` if
|
||||
* no run is in flight.
|
||||
*/
|
||||
async cancelConfigAgentRun(): Promise<{ status: "cancelling" | "not-running" }> {
|
||||
const response = await this.sendAdminRequest(
|
||||
@ -875,6 +876,28 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits and pushes the agent's already-applied changes after the user has
|
||||
* reviewed the diff. Only valid when a run is in `awaiting_review` status.
|
||||
* Returns `sandbox-expired` if the review state exists but its sandbox id is
|
||||
* missing, which means the user needs to rerun the agent.
|
||||
*/
|
||||
async commitConfigAgentRun(options: { githubAccessToken: string, commitMessage?: string }): Promise<{ status: "committing" | "not-awaiting-review" | "sandbox-expired" }> {
|
||||
const response = await this.sendAdminRequest(
|
||||
`/internal/config/github/commit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
github_access_token: options.githubAccessToken,
|
||||
...(options.commitMessage ? { commit_message: options.commitMessage } : {}),
|
||||
}),
|
||||
},
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]): Promise<void> {
|
||||
await this.sendAdminRequest(
|
||||
`/internal/config/override/${level}/reset-keys`,
|
||||
|
||||
@ -942,12 +942,14 @@ export const branchConfigSourceSchema = yupUnion(
|
||||
// Status of the most recent agent-driven config write, so the dashboard can
|
||||
// poll for progress and surface the resulting commit (or error) across tabs.
|
||||
agent_run: yupObject({
|
||||
status: yupString().oneOf(["running", "success", "no-change", "error", "cancelled"]).defined(),
|
||||
// "running": agent is working; "awaiting_review": agent done, diff ready, waiting for user to commit;
|
||||
// "success" | "no-change" | "error" | "cancelled": terminal.
|
||||
status: yupString().oneOf(["running", "awaiting_review", "success", "no-change", "error", "cancelled"]).defined(),
|
||||
started_at: yupNumber().defined(),
|
||||
finished_at: yupNumber().optional(),
|
||||
commit_url: urlSchema.optional(),
|
||||
error: yupString().optional(),
|
||||
// Vercel Sandbox id of the in-flight run, recorded while `status === "running"`
|
||||
// Vercel Sandbox id of the in-flight run, recorded while `status === "running"` or `"awaiting_review"`
|
||||
// so a cancel request (a different invocation) can hard-stop the sandbox.
|
||||
// Cleared/ignored once the run reaches a terminal status.
|
||||
sandbox_id: yupString().optional(),
|
||||
@ -955,6 +957,12 @@ export const branchConfigSourceSchema = yupUnion(
|
||||
// "Editing hexclave.config.ts", "Running: git push") so the dashboard can
|
||||
// show what's happening. Never contains file contents, tool inputs, or tokens.
|
||||
progress: yupString().optional(),
|
||||
// Current stage of the run, for showing a progress bar in the dashboard.
|
||||
// Cleared on terminal status.
|
||||
stage: yupString().oneOf(["initializing_sandbox", "cloning_repo", "agent_making_changes", "awaiting_review"]).optional(),
|
||||
// The git unified diff produced by the agent, set when status becomes "awaiting_review".
|
||||
// Displayed in the dashboard for review before the user commits.
|
||||
diff: yupString().optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
yupObject({
|
||||
|
||||
215
pnpm-lock.yaml
215
pnpm-lock.yaml
@ -404,6 +404,9 @@ importers:
|
||||
'@phosphor-icons/react':
|
||||
specifier: ^2.1.10
|
||||
version: 2.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@pierre/diffs':
|
||||
specifier: ^1.2.11
|
||||
version: 1.2.11(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@ -798,7 +801,7 @@ importers:
|
||||
version: 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@tanstack/react-start':
|
||||
specifier: ^1.121.3
|
||||
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
'@tanstack/react-start-client':
|
||||
specifier: ^1.121.3
|
||||
version: 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
@ -813,7 +816,7 @@ importers:
|
||||
version: 1.166.6
|
||||
'@tanstack/start-plugin-core':
|
||||
specifier: ^1.121.3
|
||||
version: 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
version: 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
'@tanstack/start-server-core':
|
||||
specifier: ^1.121.3
|
||||
version: 1.166.6(crossws@0.4.4(srvx@0.8.16))
|
||||
@ -837,7 +840,7 @@ importers:
|
||||
version: 0.468.0(react@19.2.1)
|
||||
nitro:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
|
||||
version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2)
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@ -874,7 +877,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.7)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
|
||||
version: 5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
@ -892,10 +895,10 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^7.0.0
|
||||
version: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
version: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
|
||||
version: 4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
|
||||
|
||||
apps/internal-tool:
|
||||
dependencies:
|
||||
@ -2545,7 +2548,7 @@ importers:
|
||||
devDependencies:
|
||||
'@quetzallabs/i18n':
|
||||
specifier: ^0.1.19
|
||||
version: 0.1.19(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
version: 0.1.19(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.167.4
|
||||
version: 1.169.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@ -6853,6 +6856,36 @@ packages:
|
||||
react: '>= 16.8'
|
||||
react-dom: '>= 16.8'
|
||||
|
||||
'@pierre/diffs@1.2.11':
|
||||
resolution: {integrity: sha512-lSkl5C7eb8Zq7Ote0+J5ZdVOlI72r2EU3vW4+06wULSQqkIMP8mkxG70lVj593b1XYlsM2hCvuyt0cKTA96plQ==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
|
||||
'@pierre/theme@1.0.3':
|
||||
resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==}
|
||||
engines: {vscode: ^1.0.0}
|
||||
|
||||
'@pierre/theming@0.0.1':
|
||||
resolution: {integrity: sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A==}
|
||||
peerDependencies:
|
||||
'@pierre/theme': ^1.0.0
|
||||
'@shikijs/themes': ^3.0.0 || ^4.0.0
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
shiki: ^3.0.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
'@pierre/theme':
|
||||
optional: true
|
||||
'@shikijs/themes':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
shiki:
|
||||
optional: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@ -14603,6 +14636,9 @@ packages:
|
||||
resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
lru_map@0.4.1:
|
||||
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
|
||||
|
||||
lucide-react@0.378.0:
|
||||
resolution: {integrity: sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==}
|
||||
peerDependencies:
|
||||
@ -22417,7 +22453,7 @@ snapshots:
|
||||
remark-gfm: 4.0.1
|
||||
remark-math: 6.0.0
|
||||
remark-smartypants: 3.0.2
|
||||
shiki: 3.14.0
|
||||
shiki: 3.23.0
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
@ -22443,7 +22479,7 @@ snapshots:
|
||||
remark-gfm: 4.0.1
|
||||
remark-math: 6.0.0
|
||||
remark-smartypants: 3.0.2
|
||||
shiki: 3.14.0
|
||||
shiki: 3.23.0
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
@ -24193,6 +24229,30 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
'@pierre/diffs@1.2.11(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@pierre/theme': 1.0.3
|
||||
'@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@3.23.0)
|
||||
'@shikijs/transformers': 3.14.0
|
||||
diff: 8.0.3
|
||||
hast-util-to-html: 9.0.5
|
||||
lru_map: 0.4.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
shiki: 3.23.0
|
||||
transitivePeerDependencies:
|
||||
- '@shikijs/themes'
|
||||
|
||||
'@pierre/theme@1.0.3': {}
|
||||
|
||||
'@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@3.23.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@3.23.0)':
|
||||
optionalDependencies:
|
||||
'@pierre/theme': 1.0.3
|
||||
'@shikijs/themes': 3.23.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
shiki: 3.23.0
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@ -24438,7 +24498,7 @@ snapshots:
|
||||
- next
|
||||
- supports-color
|
||||
|
||||
'@quetzallabs/i18n@0.1.19(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
|
||||
'@quetzallabs/i18n@0.1.19(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/traverse': 7.29.0
|
||||
@ -24446,7 +24506,7 @@ snapshots:
|
||||
dotenv: 10.0.0
|
||||
i18next: 21.10.0
|
||||
i18next-parser: 9.0.2
|
||||
next-intl: 3.19.1(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1)
|
||||
next-intl: 3.19.1(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1)
|
||||
path: 0.12.7
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
@ -28510,19 +28570,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- crossws
|
||||
|
||||
'@tanstack/react-start@1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
|
||||
'@tanstack/react-start@1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
|
||||
dependencies:
|
||||
'@tanstack/react-router': 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@tanstack/react-start-client': 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@tanstack/react-start-server': 1.166.6(crossws@0.4.4(srvx@0.8.16))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@tanstack/router-utils': 1.161.4
|
||||
'@tanstack/start-client-core': 1.166.6
|
||||
'@tanstack/start-plugin-core': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
'@tanstack/start-plugin-core': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
'@tanstack/start-server-core': 1.166.6(crossws@0.4.4(srvx@0.8.16))
|
||||
pathe: 2.0.3
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
transitivePeerDependencies:
|
||||
- '@rsbuild/core'
|
||||
- crossws
|
||||
@ -28687,7 +28747,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-plugin@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
|
||||
'@tanstack/router-plugin@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
|
||||
@ -28704,7 +28764,7 @@ snapshots:
|
||||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
'@tanstack/react-router': 1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
webpack: 5.92.0(esbuild@0.24.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@ -28823,7 +28883,7 @@ snapshots:
|
||||
|
||||
'@tanstack/start-fn-stubs@1.161.6': {}
|
||||
|
||||
'@tanstack/start-plugin-core@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
|
||||
'@tanstack/start-plugin-core@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(crossws@0.4.4(srvx@0.8.16))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/core': 7.28.5
|
||||
@ -28831,7 +28891,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.40
|
||||
'@tanstack/router-core': 1.166.6
|
||||
'@tanstack/router-generator': 1.166.6
|
||||
'@tanstack/router-plugin': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
'@tanstack/router-plugin': 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(webpack@5.92.0(esbuild@0.24.2))
|
||||
'@tanstack/router-utils': 1.161.4
|
||||
'@tanstack/start-client-core': 1.166.6
|
||||
'@tanstack/start-server-core': 1.166.6(crossws@0.4.4(srvx@0.8.16))
|
||||
@ -28843,8 +28903,8 @@ snapshots:
|
||||
srvx: 0.11.9
|
||||
tinyglobby: 0.2.15
|
||||
ufo: 1.5.4
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))
|
||||
xmlbuilder2: 4.0.3
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
@ -29841,18 +29901,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
|
||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@ -33471,7 +33519,7 @@ snapshots:
|
||||
remark: 15.0.1
|
||||
remark-gfm: 4.0.1
|
||||
scroll-into-view-if-needed: 3.1.0
|
||||
shiki: 3.14.0
|
||||
shiki: 3.23.0
|
||||
unist-util-visit: 5.0.0
|
||||
optionalDependencies:
|
||||
next: 15.5.19(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@ -33909,15 +33957,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
crossws: 0.4.4(srvx@0.8.16)
|
||||
|
||||
h3@2.0.1-rc.2(crossws@0.4.4(srvx@0.11.15)):
|
||||
dependencies:
|
||||
cookie-es: 2.0.0
|
||||
fetchdts: 0.1.7
|
||||
rou3: 0.7.12
|
||||
srvx: 0.8.16
|
||||
optionalDependencies:
|
||||
crossws: 0.4.4(srvx@0.11.15)
|
||||
|
||||
h3@2.0.1-rc.2(crossws@0.4.4(srvx@0.8.16)):
|
||||
dependencies:
|
||||
cookie-es: 2.0.0
|
||||
@ -35245,6 +35284,8 @@ snapshots:
|
||||
|
||||
lru.min@1.1.3: {}
|
||||
|
||||
lru_map@0.4.1: {}
|
||||
|
||||
lucide-react@0.378.0(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
@ -36105,11 +36146,11 @@ snapshots:
|
||||
react: 18.3.1
|
||||
use-intl: 3.19.1(react@18.3.1)
|
||||
|
||||
next-intl@3.19.1(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1):
|
||||
next-intl@3.19.1(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@18.3.1):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.5.4
|
||||
negotiator: 0.6.4
|
||||
next: 16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 18.3.1
|
||||
use-intl: 3.19.1(react@18.3.1)
|
||||
|
||||
@ -36436,58 +36477,6 @@ snapshots:
|
||||
jsonpath-plus: 10.4.0
|
||||
lodash.topath: 4.5.2
|
||||
|
||||
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
cookie-es: 2.0.0
|
||||
crossws: 0.4.4(srvx@0.8.16)
|
||||
db0: 0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3)
|
||||
esbuild: 0.25.11
|
||||
fetchdts: 0.1.7
|
||||
h3: 2.0.1-rc.2(crossws@0.4.4(srvx@0.8.16))
|
||||
jiti: 2.6.1
|
||||
nf3: 0.1.12
|
||||
ofetch: 1.5.1
|
||||
ohash: 2.0.11
|
||||
rendu: 0.0.6
|
||||
rollup: 4.57.1
|
||||
srvx: 0.8.16
|
||||
undici: 7.18.2
|
||||
unenv: 2.0.0-rc.21
|
||||
unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3))(lru-cache@11.2.2)(ofetch@1.5.1)
|
||||
optionalDependencies:
|
||||
rolldown: 1.0.0-rc.3
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
xml2js: 0.6.2
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
- '@azure/data-tables'
|
||||
- '@azure/identity'
|
||||
- '@azure/keyvault-secrets'
|
||||
- '@azure/storage-blob'
|
||||
- '@capacitor/preferences'
|
||||
- '@deno/kv'
|
||||
- '@electric-sql/pglite'
|
||||
- '@libsql/client'
|
||||
- '@netlify/blobs'
|
||||
- '@planetscale/database'
|
||||
- '@upstash/redis'
|
||||
- '@vercel/blob'
|
||||
- '@vercel/functions'
|
||||
- '@vercel/kv'
|
||||
- aws4fetch
|
||||
- better-sqlite3
|
||||
- chokidar
|
||||
- drizzle-orm
|
||||
- idb-keyval
|
||||
- ioredis
|
||||
- lru-cache
|
||||
- mongodb
|
||||
- mysql2
|
||||
- sqlite3
|
||||
- uploadthing
|
||||
|
||||
nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2):
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
@ -36496,7 +36485,7 @@ snapshots:
|
||||
db0: 0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3)
|
||||
esbuild: 0.25.11
|
||||
fetchdts: 0.1.7
|
||||
h3: 2.0.1-rc.2(crossws@0.4.4(srvx@0.11.15))
|
||||
h3: 2.0.1-rc.2(crossws@0.4.4(srvx@0.8.16))
|
||||
jiti: 2.6.1
|
||||
nf3: 0.1.12
|
||||
ofetch: 1.5.1
|
||||
@ -40542,17 +40531,6 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-tsconfig-paths@4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.5(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-tsconfig-paths@4.3.2(typescript@6.0.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@ -40642,23 +40620,6 @@ snapshots:
|
||||
tsx: 4.19.3
|
||||
yaml: 2.6.0
|
||||
|
||||
vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.1
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.57.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.44.0
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.0
|
||||
|
||||
vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.1
|
||||
@ -40676,10 +40637,6 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.0
|
||||
|
||||
vitefu@1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
|
||||
vitefu@1.1.2(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)):
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user