Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ jobs:
- name: Typecheck
run: pnpm typecheck

- name: Lint
run: pnpm lint

- name: Test
env:
DEVSPACE_REQUIRE_PI_SANDBOX: ${{ matrix.os == 'ubuntu-latest' && '1' || '0' }}
Expand Down
3 changes: 3 additions & 0 deletions bin/run-entrypoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";

export async function runEntrypoint(sourcePath, distPath) {
const sourceUrl = new URL(sourcePath, import.meta.url);

if (existsSync(fileURLToPath(sourceUrl))) {
try {
await import("tsx/esm");
Expand All @@ -12,7 +13,9 @@ export async function runEntrypoint(sourcePath, distPath) {
{ cause: error },
);
}

await import(sourceUrl.href);

return;
}

Expand Down
21 changes: 20 additions & 1 deletion scripts/dev-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}):
const sourceConfigDir = resolve(
expandHomePath(process.env.DEVSPACE_CONFIG_DIR ?? join(homedir(), ".devspace")),
);

if (isWithin(devRoot, sourceConfigDir)) {
throw new Error("Refusing to seed development state from this checkout's own .devspace-dev directory.");
}
Expand All @@ -39,7 +40,6 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}):
const stagingRoot = `${devRoot}.staging-${process.pid}-${Date.now()}`;
const stagingConfigDir = join(stagingRoot, "config");
const stagingStateDir = join(stagingRoot, "state");
const devConfigDir = join(devRoot, "config");
const devStateDir = join(devRoot, "state");

try {
Expand All @@ -53,10 +53,12 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}):
stateDir: devStateDir,
},
};

const localConfigPath = join(stagingConfigDir, "config.jsonc");
await writeFile(localConfigPath, `${JSON.stringify(localConfig, null, 2)}\n`, { mode: 0o600 });

const sourceAuthPath = join(sourceConfigDir, "auth.json");

if (existsSync(sourceAuthPath)) {
const localAuthPath = join(stagingConfigDir, "auth.json");
await cp(sourceAuthPath, localAuthPath);
Expand All @@ -67,6 +69,7 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}):

for (const directory of ["skills", "agents"] as const) {
const sourceDirectory = join(sourceConfigDir, directory);

if (existsSync(sourceDirectory)) {
await cp(sourceDirectory, join(stagingConfigDir, directory), { recursive: true });
}
Expand All @@ -87,22 +90,26 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}):
async function promoteStagedState(stagingRoot: string, reset: boolean): Promise<void> {
if (!reset || !existsSync(devRoot)) {
await rename(stagingRoot, devRoot);

return;
}

const previousRoot = `${devRoot}.previous-${process.pid}-${Date.now()}`;
await rename(devRoot, previousRoot);

try {
await rename(stagingRoot, devRoot);
} catch (error) {
await rename(previousRoot, devRoot);
throw error;
}

await rm(previousRoot, { recursive: true, force: true });
}

function isWithin(parent: string, candidate: string): boolean {
const pathFromParent = relative(parent, candidate);

return pathFromParent === ""
|| (pathFromParent !== ".."
&& !pathFromParent.startsWith(`..${sep}`)
Expand All @@ -111,19 +118,25 @@ function isWithin(parent: string, candidate: string): boolean {

async function readSourceConfig(configDir: string): Promise<{ config: DevspaceConfig }> {
const configPath = join(configDir, "config.jsonc");

if (existsSync(configPath)) {
const source = await readFile(configPath, "utf8");
const errors: ParseError[] = [];
const value = parse(source, errors, { allowTrailingComma: true });

if (errors.length > 0) {
throw new Error(`Unable to parse ${configPath}.`);
}

return { config: devspaceConfigSchema.parse(value) };
}

const legacyPath = join(configDir, "config.json");

if (existsSync(legacyPath)) {
// SAFETY: The surrounding checks or construction already match this type.
const value = JSON.parse(await readFile(legacyPath, "utf8")) as unknown;

return { config: migrateLegacyConfig(value) };
}

Expand All @@ -133,6 +146,7 @@ async function readSourceConfig(configDir: string): Promise<{ config: DevspaceCo
async function backupDatabase(sourcePath: string, destinationPath: string): Promise<void> {
await mkdir(dirname(destinationPath), { recursive: true });
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });

try {
await source.backup(destinationPath);
await chmod(destinationPath, 0o600);
Expand All @@ -143,14 +157,19 @@ async function backupDatabase(sourcePath: string, destinationPath: string): Prom

async function main(): Promise<void> {
const command = process.argv[2];

if (command === "seed") {
await seedDevState();

return;
}

if (command === "reset") {
await seedDevState({ reset: true });

return;
}

throw new Error("Usage: dev-state <seed|reset>");
}

Expand Down
2 changes: 2 additions & 0 deletions scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ if (!hasConfig) {
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);

return;
}

process.exitCode = code ?? 1;
});
}
2 changes: 2 additions & 0 deletions scripts/fix-node-pty-permissions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";

if (process.platform === "darwin") {
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");

for (const architecture of ["arm64", "x64"]) {
const helper = resolve(
projectRoot,
Expand All @@ -13,6 +14,7 @@ if (process.platform === "darwin") {
`darwin-${architecture}`,
"spawn-helper",
);

try {
await chmod(helper, 0o755);
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions scripts/generate-config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import {
const outputPath = new URL("../schema/v1/devspace.schema.json", import.meta.url);

mkdirSync(new URL(".", outputPath), { recursive: true });

writeFileSync(outputPath, `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`);
3 changes: 3 additions & 0 deletions scripts/qa/host/host-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";

// ext-apps' package root re-exports its spec types extensionlessly, which NodeNext cannot follow.
type HostContext = NonNullable<NonNullable<ConstructorParameters<typeof AppBridge>[3]>["hostContext"]>;

type McpUiTheme = NonNullable<HostContext["theme"]>;

type McpUiHostStyles = NonNullable<HostContext["styles"]>;

// Representative host palettes using the spec's style variable names. Real
Expand Down Expand Up @@ -37,6 +39,7 @@ const palettes: Record<McpUiTheme, Record<string, string>> = {

export function hostStyles(theme: McpUiTheme): McpUiHostStyles {
return {
// SAFETY: The surrounding checks or construction already match this type.
variables: {
...palettes[theme],
"--font-sans": "ui-sans-serif, system-ui, sans-serif",
Expand Down
45 changes: 40 additions & 5 deletions scripts/qa/host/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { optionalFields } from "../../../src/json-value.js";
import type { WireInput } from "../../../src/json-value.js";
import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { QaCardCall, QaRun, QaStep } from "../transcript.js";
Expand All @@ -20,20 +22,29 @@ declare global {
}

const CARD_TIMEOUT_MS = 15_000;

// Cards keep resizing while fonts and diff highlighting load; wait for a quiet period.
const SIZE_QUIET_MS = 800;

const params = new URLSearchParams(location.search);
const theme = params.get("theme") === "dark" ? "dark" : "light";

// A bare host sends only the theme, like hosts that do not implement style variables.
const bareHost = params.get("styles") === "none";

document.documentElement.dataset.theme = theme;

window.__devspaceQa = { settled: false, cards: [], hostEvents: [] };

// SAFETY: The surrounding checks or construction already match this type.
const run = await (await fetch("/api/run")).json() as QaRun & { sandboxOrigin: string };

renderSummary(run);

const pending = run.steps.map((step) => renderStep(run, step));

await Promise.all(pending);

window.__devspaceQa.settled = true;

function renderSummary(qaRun: QaRun): void {
Expand All @@ -59,6 +70,7 @@ async function renderStep(qaRun: QaRun & { sandboxOrigin: string }, step: QaStep
details("arguments", JSON.stringify(step.arguments, null, 2)),
details("result", step.error ?? JSON.stringify(step.result, null, 2)),
);

if (!ok) section.append(element("ul", { className: "failures" }, ...step.failures.map((failure) => element("li", {}, failure))));
document.getElementById("steps")!.append(section);

Expand All @@ -82,26 +94,27 @@ async function mountCard(
section.append(iframe, callLog);

const tool = qaRun.tools.find((item) => item.name === step.tool);

const bridge = new AppBridge(
null,
{ name: "devspace-qa-host", version: "1.0.0" },
{ serverTools: {}, openLinks: {}, downloadFile: {}, logging: {} },
{
hostContext: {
theme,
...(bareHost ? {} : { styles: hostStyles(theme) }),
...optionalFields(bareHost ? undefined : { styles: hostStyles(theme) }),
displayMode: "inline",
availableDisplayModes: ["inline"],
platform: "web",
locale: "en-US",
timeZone: "UTC",
containerDimensions: { maxHeight: 2000 },
...(tool ? { toolInfo: { tool } } : {}),
...optionalFields(tool ? { toolInfo: { tool } } : undefined),
},
},
);

const hostEvent = (kind: string, detail: unknown) => {
const hostEvent = (kind: string, detail: WireInput) => {
window.__devspaceQa.hostEvents.push({ step: step.index, kind, detail });
callLog.append(element("li", {}, `card → ${kind}: ${summarize(detail)}`));
};
Expand All @@ -112,55 +125,74 @@ async function mountCard(
headers: { "content-type": "application/json" },
body: JSON.stringify({ step: step.index, name: params.name, arguments: params.arguments, _meta: params._meta }),
});

// SAFETY: The surrounding checks or construction already match this type.
const call = await response.json() as QaCardCall;
hostEvent("tools/call", { name: params.name, error: call.error, isError: call.result?.isError });

if (call.error || !call.result) throw new Error(call.error ?? "Tool call failed");

return call.result;
};

bridge.onopenlink = async (params) => {
hostEvent("open-link", params);

return {};
};

bridge.ondownloadfile = async (params) => {
hostEvent("download-file", params);

return {};
};

bridge.onmessage = async (params) => {
hostEvent("message", params);

return {};
};

bridge.onupdatemodelcontext = async (params) => {
hostEvent("update-model-context", params);

return {};
};

bridge.onrequestdisplaymode = async (params) => {
hostEvent("request-display-mode", params);

return { mode: "inline" };
};

bridge.onloggingmessage = (params) => console.log(`[card ${step.index}]`, params.level, params.data);

let quietTimer: number | undefined;
let finish: () => void = () => undefined;
const settled = new Promise<void>((resolveSettled) => { finish = resolveSettled; });

const markReady = () => {
window.clearTimeout(quietTimer);
quietTimer = window.setTimeout(() => {
if (status.state === "initialized") status.state = "ready";
finish();
}, SIZE_QUIET_MS);
};

bridge.onsizechange = ({ height }) => {
if (height === undefined) return;
status.height = Math.ceil(height);
iframe.style.height = `${status.height}px`;

if (status.state === "initialized") markReady();
};

bridge.oninitialized = () => {
status.state = "initialized";
void bridge.sendToolInput({ arguments: step.arguments })
.then(() => bridge.sendToolResult(result))
.then(markReady)
.catch((error: unknown) => {
.catch((error: WireInput) => {
status.state = "error";
status.error = error instanceof Error ? error.message : String(error);
finish();
Expand All @@ -181,6 +213,7 @@ async function mountCard(
await bridge.connect(new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!));
iframe.src = `${qaRun.sandboxOrigin}/resource?uri=${encodeURIComponent(step.resourceUri!)}`;
};

try {
await Promise.race([setup(), settled]);
} catch (error) {
Expand All @@ -197,8 +230,9 @@ function details(label: string, text: string): HTMLElement {
return element("details", {}, element("summary", {}, label), element("pre", {}, text));
}

function summarize(value: unknown): string {
function summarize(value: WireInput): string {
const text = JSON.stringify(value);

return text.length > 160 ? `${text.slice(0, 157)}...` : text;
}

Expand All @@ -209,5 +243,6 @@ function element<K extends keyof HTMLElementTagNameMap>(
): HTMLElementTagNameMap[K] {
const node = Object.assign(document.createElement(tag), props);
node.append(...children);

return node;
}
Loading
Loading