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
17 changes: 17 additions & 0 deletions bun.lock

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

4 changes: 0 additions & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./vite-plugin": {
"import": "./dist/cmd/build/vite/index.js",
"types": "./dist/cmd/build/vite/index.d.ts"
}
},
"files": [
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,23 @@ export function getAppBaseURL(config?: Config | null): string {
const overrides = config?.overrides as { app_url?: string } | undefined;
return baseGetAppBaseURL(config?.name, overrides);
}

/**
* URL the gravity tunnel binary connects to.
*
* Profile overrides win, then a hardcoded `local` profile target,
* then the default production endpoint. Region is currently unused
* because the platform exposes a single global devmode endpoint, but
* the parameter is accepted so callers can pass it forward without
* branching.
*/
export function getGravityDevModeURL(_region: string, config?: Config | null): string {
const overrides = config?.overrides as { gravity_url?: string } | undefined;
if (overrides?.gravity_url) {
return overrides.gravity_url;
}
if (config?.name === 'local') {
return 'grpc://gravity.agentuity.io:443';
}
return 'grpc://devmode-us.agentuity.com';
}
69 changes: 69 additions & 0 deletions packages/cli/src/cmd/dev/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createPublicKey } from 'node:crypto';
import { APIResponseSchema } from '@agentuity/server';
import { z } from 'zod';
import { StructuredError } from '@agentuity/core';
import type { APIClient } from '../../api.ts';

const DevmodeRequestSchema = z.object({
hostname: z.string().optional().describe('the hostname for the endpoint'),
publicKey: z.string().optional().describe('the public key PEM for the endpoint'),
});

type DevmodeRequest = z.infer<typeof DevmodeRequestSchema>;

function extractPublicKeyPEM(privateKeyPEM: string): string | undefined {
try {
const publicKey = createPublicKey(privateKeyPEM);
return publicKey.export({ type: 'spki', format: 'pem' }) as string;
} catch {
return undefined;
}
}

const DevmodeResponseSchema = z.object({
id: z.string(),
hostname: z.string(),
privateKey: z.string().optional(),
});
export type DevmodeResponse = z.infer<typeof DevmodeResponseSchema>;

const DevmodeResponseAPISchema = APIResponseSchema(DevmodeResponseSchema);
type DevmodeResponseAPI = z.infer<typeof DevmodeResponseAPISchema>;

const DevmodeEndpointError = StructuredError('DevmodeEndpointError');

/**
* Reserve (or re-use) an Agentuity devmode endpoint for the current
* project. The platform returns a hostname plus a private key the
* gravity binary uses to authenticate when it dials the public-URL
* tunnel. Re-passing a previously-issued private key keeps the same
* hostname stable across dev sessions on the same machine.
*
* KNOWN PLATFORM BUG: as of 2026-05-07 this endpoint routes hostnames
* by the caller's `User-Agent` and v3-shaped UAs (`Agentuity CLI/3.x`)
* receive hostnames under `*.agentuity.live`, which has no wildcard
* DNS configured — so the URL never resolves. v2 UAs and curl get
* `*.agentuity-us.live`, which works. Tracking in agentuity/infra#210.
*/
export async function generateEndpoint(
apiClient: APIClient,
projectId: string,
hostname?: string,
privateKey?: string
): Promise<DevmodeResponse> {
const publicKey = privateKey ? extractPublicKeyPEM(privateKey) : undefined;

const resp = await apiClient.request<DevmodeResponseAPI, DevmodeRequest>(
'POST',
`/cli/devmode/3/${projectId}`,
DevmodeResponseAPISchema,
{ hostname, publicKey },
DevmodeRequestSchema
);

if (!resp.success) {
throw new DevmodeEndpointError({ message: resp.message });
}

return resp.data;
}
149 changes: 149 additions & 0 deletions packages/cli/src/cmd/dev/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir, platform } from 'node:os';
import { join, dirname } from 'node:path';
import * as tar from 'tar';
import { StructuredError } from '@agentuity/core';
import { spinner } from '../../tui.ts';

interface GravityClient {
filename: string;
version: string;
}

/**
* Remove previously downloaded gravity version directories after a
* newer version has started successfully.
*
* Safety guard: only removes sibling directories that contain a
* gravity binary, leaving any unrelated files/folders untouched.
*/
export function sweepOldGravityVersions(gravityDir: string, currentVersion: string): string[] {
if (!existsSync(gravityDir)) {
return [];
}

const removed: string[] = [];
for (const entry of readdirSync(gravityDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === currentVersion) {
continue;
}

const candidateDir = join(gravityDir, entry.name);
const candidateBinary = join(candidateDir, 'gravity');
if (!existsSync(candidateBinary)) {
continue;
}

rmSync(candidateDir, { recursive: true, force: true });
removed.push(candidateDir);
}

return removed;
}

const GravityVersionError = StructuredError('GravityVersionError')<{
status: number;
statusText: string;
}>();
const GravityDownloadError = StructuredError('GravityDownloadError')<{
status: number;
statusText: string;
}>();
const GravityExtractionError = StructuredError('GravityExtractionError')<{
path: string;
}>();

function getBaseURL(): string {
return process.env.AGENTUITY_SH_URL || 'https://agentuity.sh';
}

/**
* Resolve the latest gravity version, download (or re-use) the
* binary for the host platform, and extract it to
* `<gravityDir>/<version>/gravity`.
*/
export async function download(gravityDir: string): Promise<GravityClient> {
const baseURL = getBaseURL();

// Step 1: Get the latest version from agentuity.sh
const tag = (await spinner({
message: 'Checking Agentuity Gravity',
callback: async () => {
const resp = await fetch(`${baseURL}/release/gravity/version`, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new GravityVersionError({
status: resp.status,
statusText: resp.statusText,
});
}
const text = (await resp.text()).trim();
return text.startsWith('v') ? text : `v${text}`;
},
clearOnSuccess: true,
})) as string;

const version = tag.startsWith('v') ? tag.slice(1) : tag;
const releaseFilename = join(gravityDir, version, 'gravity');

// Step 2: Check if already downloaded
if (existsSync(releaseFilename)) {
return { filename: releaseFilename, version };
}

// Step 3: Download the binary from agentuity.sh
const os = platform();
let arch: string = process.arch;
if (arch === 'x64') {
arch = 'x86_64';
}

const tmpFile = join(tmpdir(), `${randomUUID()}.tar.gz`);

try {
await spinner({
message: `Downloading Gravity ${version}`,
callback: async () => {
const resp = await fetch(`${baseURL}/release/gravity/${tag}/${os}/${arch}`, {
signal: AbortSignal.timeout(60_000),
});
if (!resp.ok) {
throw new GravityDownloadError({
status: resp.status,
statusText: resp.statusText,
});
}
const buffer = await resp.arrayBuffer();
writeFileSync(tmpFile, Buffer.from(buffer));
},
clearOnSuccess: true,
});

// Step 4: Extract the tarball
await spinner({
message: 'Extracting release',
callback: async () => {
const downloadDir = dirname(releaseFilename);
if (!existsSync(downloadDir)) {
mkdirSync(downloadDir, { recursive: true });
}
await tar.x({ file: tmpFile, cwd: downloadDir, chmod: true });
},
clearOnSuccess: true,
});
} finally {
// Clean up temp file regardless of success or failure
if (existsSync(tmpFile)) {
rmSync(tmpFile);
}
}

// Step 5: Verify the binary was extracted
if (!existsSync(releaseFilename)) {
throw new GravityExtractionError({ path: releaseFilename });
}

return { filename: releaseFilename, version };
}
Loading
Loading