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
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Convoy environment — copy to .env (gitignored) or let scripts/install do it.
#
# cp .env.example .env
#
# The `convoy` npm script passes --env-file-if-exists=.env to tsx, so these
# land in process.env automatically. Without ANTHROPIC_API_KEY, Convoy runs
# with deterministic fallbacks (no Opus narratives, no medic diagnosis).

# Required for AI enrichment + the medic agent loop.
ANTHROPIC_API_KEY=

# Optional — default VPS target for --platform=vps (IP or hostname).
# CONVOY_VPS_HOST=

# Optional — GHCR image for the VPS GHCR lane (ghcr.io/<owner>/<image>).
# CONVOY_GHCR_IMAGE=

# Optional — override the web viewer URL printed in CLI output (default http://localhost:3737).
# CONVOY_WEB_URL=

# Optional — set to 1 to stop the CLI from auto-spawning the web viewer.
# CONVOY_NO_AUTOSPAWN=
21 changes: 15 additions & 6 deletions scripts/install
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,27 @@ fi
ENV_FILE="$CONVOY_HOME/.env"
ENV_EXAMPLE="$CONVOY_HOME/.env.example"

if [ ! -f "$ENV_FILE" ] && [ -f "$ENV_EXAMPLE" ]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
echo " ✓ Created .env from .env.example"
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE" ]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
echo " ✓ Created .env from .env.example"
else
# No example shipped — create a minimal .env so the key prompt below
# still fires instead of telling the user to assemble it by hand.
printf 'ANTHROPIC_API_KEY=\n' > "$ENV_FILE"
echo " ✓ Created .env"
fi
fi

# Prompt for ANTHROPIC_API_KEY if the file exists but the key is still a placeholder.
# Read from /dev/tty, not stdin — under `curl | bash` stdin is the script
# itself, so a plain `read` would either eat script text or hit EOF.
if [ -f "$ENV_FILE" ] && grep -qE '^ANTHROPIC_API_KEY=(your[_-]|sk-placeholder|$)' "$ENV_FILE"; then
if [ -t 0 ]; then
if [ -e /dev/tty ] && (exec < /dev/tty) 2>/dev/null; then
echo
echo "Convoy uses Claude for enrichment and diagnosis."
printf " Enter your Anthropic API key (sk-ant-...): "
read -r API_KEY
printf " Enter your Anthropic API key (sk-ant-..., or press Enter to skip): "
read -r API_KEY < /dev/tty
if [ -n "$API_KEY" ]; then
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$API_KEY|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
Expand Down
110 changes: 110 additions & 0 deletions src/adapters/cloudrun/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);

export interface CloudRunAuthStatus { ok: boolean; account?: string; project?: string; error?: string; }
export interface CloudRunDeployResult { ok: boolean; url?: string; revision?: string; error?: string; logs: string[]; }
export interface CloudRunRollbackResult { ok: boolean; error?: string; }

export async function gcloudAvailable(): Promise<boolean> {
try { await exec('gcloud', ['--version']); return true; } catch { return false; }
}

export async function cloudRunAuthStatus(): Promise<CloudRunAuthStatus> {
try {
const [acctResult, projResult] = await Promise.all([
exec('gcloud', ['auth', 'list', '--filter=status:ACTIVE', '--format=value(account)']),
exec('gcloud', ['config', 'get-value', 'project']),
]);
const account = acctResult.stdout.trim();
const project = projResult.stdout.trim();
return { ok: Boolean(account), account, project: project || undefined };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

export async function cloudRunDeploy(opts: {
service: string;
image: string;
region: string;
project?: string;
envVars?: Record<string, string>;
onLog?: (line: string) => void;
}): Promise<CloudRunDeployResult> {
const logs: string[] = [];
try {
const args = [
'run', 'deploy', opts.service,
'--image', opts.image,
'--region', opts.region,
'--platform', 'managed',
'--allow-unauthenticated',
'--format', 'json',
];
if (opts.project) args.push('--project', opts.project);
if (opts.envVars && Object.keys(opts.envVars).length > 0) {
const envStr = Object.entries(opts.envVars).map(([k, v]) => `${k}=${v}`).join(',');
args.push('--set-env-vars', envStr);
}
const { stdout, stderr } = await exec('gcloud', args);
for (const line of (stdout + stderr).split('\n')) {
if (line.trim()) { logs.push(line); opts.onLog?.(line); }
}
const data = JSON.parse(stdout) as Record<string, unknown>;
const status = data?.['status'] as Record<string, unknown> | undefined;
const metadata = data?.['metadata'] as Record<string, unknown> | undefined;
const annotations = metadata?.['annotations'] as Record<string, unknown> | undefined;
const url = status?.['url'] ?? annotations?.['run.googleapis.com/last-ready-revision-name'] ?? null;
const revision = status?.['latestCreatedRevisionName'] ?? null;
return { ok: true, url: url != null ? String(url) : undefined, revision: revision != null ? String(revision) : undefined, logs };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logs.push(msg);
return { ok: false, error: msg, logs };
}
}

export async function cloudRunGetCurrentRevision(service: string, region: string, project?: string): Promise<string | null> {
try {
const args = ['run', 'services', 'describe', service, '--region', region, '--format=value(status.latestReadyRevisionName)'];
if (project) args.push('--project', project);
const { stdout } = await exec('gcloud', args);
return stdout.trim() || null;
} catch {
return null;
}
}

export async function cloudRunRollback(opts: {
service: string;
region: string;
previousRevision: string;
project?: string;
}): Promise<CloudRunRollbackResult> {
try {
const args = [
'run', 'services', 'update-traffic', opts.service,
'--region', opts.region,
`--to-revisions=${opts.previousRevision}=100`,
];
if (opts.project) args.push('--project', opts.project);
await exec('gcloud', args);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

export async function cloudRunSetEnvVars(opts: {
service: string;
region: string;
envVars: Record<string, string>;
project?: string;
}): Promise<void> {
if (Object.keys(opts.envVars).length === 0) return;
const envStr = Object.entries(opts.envVars).map(([k, v]) => `${k}=${v}`).join(',');
const args = ['run', 'services', 'update', opts.service, '--region', opts.region, '--set-env-vars', envStr];
if (opts.project) args.push('--project', opts.project);
await exec('gcloud', args);
}
94 changes: 94 additions & 0 deletions src/adapters/railway/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);

export interface RailwayAuthStatus { ok: boolean; user?: string; error?: string; }
export interface RailwayDeployResult { ok: boolean; url?: string; deploymentId?: string; error?: string; logs: string[]; }
export interface RailwayRollbackResult { ok: boolean; error?: string; }

export async function railwayAvailable(): Promise<boolean> {
try { await exec('railway', ['--version']); return true; } catch { return false; }
}

export async function railwayAuthStatus(): Promise<RailwayAuthStatus> {
try {
const { stdout } = await exec('railway', ['whoami']);
const user = stdout.trim();
return { ok: Boolean(user), user };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

export async function railwayDeploy(opts: {
projectId?: string;
cwd: string;
onLog?: (line: string) => void;
}): Promise<RailwayDeployResult> {
const logs: string[] = [];
try {
const args = ['up', '--detach'];
if (opts.projectId) args.push('--project', opts.projectId);
const { stdout, stderr } = await exec('railway', args, { cwd: opts.cwd });
const combined = (stdout + stderr);
for (const line of combined.split('\n')) {
if (line.trim()) { logs.push(line); opts.onLog?.(line); }
}
// Poll for completion
const url = await pollRailwayStatus(opts.cwd, opts.projectId);
return { ok: true, url: url ?? undefined, logs };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logs.push(msg);
return { ok: false, error: msg, logs };
}
}

async function pollRailwayStatus(cwd: string, projectId?: string): Promise<string | null> {
const args = ['status', '--json'];
if (projectId) args.push('--project', projectId);
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 4000));
try {
const { stdout } = await exec('railway', args, { cwd });
const data = JSON.parse(stdout) as Record<string, unknown>;
const status = data?.['deploymentStatus'] ?? data?.['status'];
if (status === 'SUCCESS' || status === 'COMPLETE') {
return (data?.['url'] ?? data?.['deploymentUrl'] ?? null) as string | null;
}
if (status === 'FAILED' || status === 'CRASHED') {
throw new Error(`Railway deploy failed with status: ${String(status)}`);
}
} catch (err) {
if (err instanceof Error && err.message.includes('failed with status')) throw err;
// parse error — keep polling
}
}
throw new Error('Railway deploy timed out after 120 seconds');
}

export async function railwaySetSecrets(secrets: Record<string, string>, cwd: string, projectId?: string): Promise<void> {
for (const [key, value] of Object.entries(secrets)) {
const args = ['variables', 'set', `${key}=${value}`];
if (projectId) args.push('--project', projectId);
await exec('railway', args, { cwd });
}
}

export async function railwayRollback(cwd: string, projectId?: string): Promise<RailwayRollbackResult> {
try {
// Get prior deployment ID and redeploy it
const listArgs = ['deployments', '--json'];
if (projectId) listArgs.push('--project', projectId);
const { stdout } = await exec('railway', listArgs, { cwd });
const deployments = JSON.parse(stdout) as unknown[];
const prior = Array.isArray(deployments) ? (deployments[1] as Record<string, unknown> | undefined) : null; // index 0 is current
if (!prior?.['id']) return { ok: false, error: 'No prior deployment to roll back to' };
const redeployArgs = ['redeploy', String(prior['id'])];
if (projectId) redeployArgs.push('--project', projectId);
await exec('railway', redeployArgs, { cwd });
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
Loading
Loading