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
65 changes: 62 additions & 3 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import {
getProjectApiKey,
} from '../lib/api/platform.js';
import { getAnonKey } from '../lib/api/oss.js';
import { getGlobalConfig, saveGlobalConfig, saveProjectConfig } from '../lib/config.js';
import { getGlobalConfig, saveGlobalConfig, saveProjectConfig, getFrontendUrl } from '../lib/config.js';
import { requireAuth } from '../lib/credentials.js';
import { handleError, getRootOpts, CLIError } from '../lib/errors.js';
import { outputJson } from '../lib/output.js';
import { installSkills } from '../lib/skills.js';
import { deployProject } from './deployments/deploy.js';
import type { ProjectConfig } from '../types.js';

const execAsync = promisify(exec);
Expand Down Expand Up @@ -150,21 +151,79 @@ export function registerCreateCommand(program: Command): void {
s?.stop(`Project "${project.name}" created and linked`);

// 6. Download template if selected
if (template !== 'empty') {
const hasTemplate = template !== 'empty';
if (hasTemplate) {
await downloadTemplate(template as Framework, projectConfig, projectName, json, apiUrl);
}

// Install InsForge agent skills
await installSkills(json);

// 7. Install npm dependencies (template projects only)
if (hasTemplate) {
const installSpinner = !json ? clack.spinner() : null;
installSpinner?.start('Installing dependencies...');
try {
await execAsync('npm install', { cwd: process.cwd(), maxBuffer: 10 * 1024 * 1024 });
installSpinner?.stop('Dependencies installed');
} catch (err) {
installSpinner?.stop('Failed to install dependencies');
if (!json) {
clack.log.warn(`npm install failed: ${(err as Error).message}`);
clack.log.info('Run `npm install` manually to install dependencies.');
}
}
}

// 8. Offer to deploy (template projects, interactive mode only)
let liveUrl: string | null = null;
if (hasTemplate && !json) {
const shouldDeploy = await clack.confirm({
message: 'Would you like to deploy now?',
});

if (!clack.isCancel(shouldDeploy) && shouldDeploy) {
try {
const deploySpinner = clack.spinner();
const result = await deployProject({
sourceDir: process.cwd(),
spinner: deploySpinner,
});

if (result.isReady) {
deploySpinner.stop('Deployment complete');
liveUrl = result.liveUrl;
} else {
deploySpinner.stop('Deployment is still building');
clack.log.info(`Deployment ID: ${result.deploymentId}`);
clack.log.warn('Deployment did not finish within 2 minutes.');
clack.log.info(`Check status with: insforge deployments status ${result.deploymentId}`);
}
} catch (err) {
clack.log.warn(`Deploy failed: ${(err as Error).message}`);
}
}
}

// 9. Show links
const dashboardUrl = `${getFrontendUrl()}/dashboard/project/${project.id}`;

if (json) {
outputJson({
success: true,
project: { id: project.id, name: project.name, appkey: project.appkey, region: project.region },
template,
urls: {
dashboard: dashboardUrl,
...(liveUrl ? { liveSite: liveUrl } : {}),
},
});
} else {
clack.outro('Done! Run `npm install` to get started.');
clack.log.step(`Dashboard: ${dashboardUrl}`);
if (liveUrl) {
clack.log.success(`Live site: ${liveUrl}`);
}
clack.outro('Done!');
}
} catch (err) {
handleError(err, json);
Expand Down
173 changes: 99 additions & 74 deletions src/commands/deployments/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,95 @@ async function createZipBuffer(sourceDir: string): Promise<Buffer> {
});
}

export interface DeployProjectOptions {
sourceDir: string;
startBody?: StartDeploymentRequest;
spinner?: ReturnType<typeof clack.spinner> | null;
}

export interface DeployProjectResult {
deploymentId: string;
deployment: SiteDeployment | null;
isReady: boolean;
liveUrl: string | null;
}

/**
* Core deploy logic: zip → upload → start → poll.
* Reusable from both the `deploy` command and `create` command.
*/
export async function deployProject(opts: DeployProjectOptions): Promise<DeployProjectResult> {
const { sourceDir, startBody = {}, spinner: s } = opts;

// Step 1: Create deployment to get presigned upload URL
s?.start('Creating deployment...');
const createRes = await ossFetch('/api/deployments', { method: 'POST' });
const { id: deploymentId, uploadUrl, uploadFields } =
(await createRes.json()) as CreateDeploymentResponse;

// Step 2: Create zip
s?.message('Compressing source files...');
const zipBuffer = await createZipBuffer(sourceDir);

// Step 3: Upload zip to presigned URL
s?.message('Uploading...');
const formData = new FormData();
for (const [key, value] of Object.entries(uploadFields)) {
formData.append(key, value);
}
formData.append(
'file',
new Blob([zipBuffer], { type: 'application/zip' }),
'deployment.zip',
);

const uploadRes = await fetch(uploadUrl, { method: 'POST', body: formData });
if (!uploadRes.ok) {
const uploadErr = await uploadRes.text();
throw new CLIError(`Failed to upload: ${uploadErr}`);
}

// Step 4: Start the deployment
s?.message('Starting deployment...');
const startRes = await ossFetch(`/api/deployments/${deploymentId}/start`, {
method: 'POST',
body: JSON.stringify(startBody),
});
await startRes.json();

// Step 5: Poll for deployment status
s?.message('Building and deploying...');
const startTime = Date.now();
let deployment: SiteDeployment | null = null;

while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
try {
const statusRes = await ossFetch(`/api/deployments/${deploymentId}`);
deployment = (await statusRes.json()) as SiteDeployment;

if (deployment.status === 'ready' || deployment.status === 'READY') {
break;
}
if (deployment.status === 'error' || deployment.status === 'ERROR' || deployment.status === 'canceled') {
s?.stop('Deployment failed');
throw new CLIError(deployment.error ?? `Deployment failed with status: ${deployment.status}`);
}

const elapsed = Math.round((Date.now() - startTime) / 1000);
s?.message(`Building and deploying... (${elapsed}s, status: ${deployment.status})`);
} catch (err) {
if (err instanceof CLIError) throw err;
// Ignore transient fetch errors during polling
}
}

const isReady = deployment?.status === 'ready' || deployment?.status === 'READY';
const liveUrl = isReady ? (deployment?.deploymentUrl ?? deployment?.url ?? null) : null;

return { deploymentId, deployment, isReady, liveUrl };
}

export function registerDeploymentsDeployCommand(deploymentsCmd: Command): void {
deploymentsCmd
.command('deploy [directory]')
Expand All @@ -81,41 +170,11 @@ export function registerDeploymentsDeployCommand(deploymentsCmd: Command): void

const s = !json ? clack.spinner() : null;

// Step 1: Create deployment to get presigned upload URL
s?.start('Creating deployment...');
const createRes = await ossFetch('/api/deployments', { method: 'POST' });
const { id: deploymentId, uploadUrl, uploadFields } =
(await createRes.json()) as CreateDeploymentResponse;

// Step 2: Create zip
s?.message('Compressing source files...');
const zipBuffer = await createZipBuffer(sourceDir);

// Step 3: Upload zip to presigned URL
s?.message('Uploading...');
const formData = new FormData();
for (const [key, value] of Object.entries(uploadFields)) {
formData.append(key, value);
}
formData.append(
'file',
new Blob([zipBuffer], { type: 'application/zip' }),
'deployment.zip',
);

const uploadRes = await fetch(uploadUrl, { method: 'POST', body: formData });
if (!uploadRes.ok) {
const uploadErr = await uploadRes.text();
throw new CLIError(`Failed to upload: ${uploadErr}`);
}

// Step 4: Start the deployment
s?.message('Starting deployment...');
// Parse env/meta from CLI flags
const startBody: StartDeploymentRequest = {};
if (opts.env) {
try {
const parsed = JSON.parse(opts.env) as Record<string, string>;
// Convert {"KEY":"value"} object to [{key,value}] array format
if (Array.isArray(parsed)) {
startBody.envVars = parsed;
} else {
Expand All @@ -127,60 +186,26 @@ export function registerDeploymentsDeployCommand(deploymentsCmd: Command): void
try { startBody.meta = JSON.parse(opts.meta); } catch { throw new CLIError('Invalid --meta JSON.'); }
}

const startRes = await ossFetch(`/api/deployments/${deploymentId}/start`, {
method: 'POST',
body: JSON.stringify(startBody),
});
await startRes.json();

// Step 5: Poll for deployment status
s?.message('Building and deploying...');
const startTime = Date.now();
let deployment: SiteDeployment | null = null;

while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
try {
const statusRes = await ossFetch(`/api/deployments/${deploymentId}`);
deployment = (await statusRes.json()) as SiteDeployment;

if (deployment.status === 'ready' || deployment.status === 'READY') {
break;
}
if (deployment.status === 'error' || deployment.status === 'ERROR' || deployment.status === 'canceled') {
s?.stop('Deployment failed');
throw new CLIError(deployment.error ?? `Deployment failed with status: ${deployment.status}`);
}

const elapsed = Math.round((Date.now() - startTime) / 1000);
s?.message(`Building and deploying... (${elapsed}s, status: ${deployment.status})`);
} catch (err) {
if (err instanceof CLIError) throw err;
// Ignore transient fetch errors during polling
}
}

const isReady = deployment?.status === 'ready' || deployment?.status === 'READY';
const result = await deployProject({ sourceDir, startBody, spinner: s });

if (isReady) {
if (result.isReady) {
s?.stop('Deployment complete');
if (json) {
outputJson(deployment);
outputJson(result.deployment);
} else {
const liveUrl = deployment?.deploymentUrl ?? deployment?.url;
if (liveUrl) {
clack.log.success(`Live at: ${liveUrl}`);
if (result.liveUrl) {
clack.log.success(`Live at: ${result.liveUrl}`);
}
clack.log.info(`Deployment ID: ${deploymentId}`);
clack.log.info(`Deployment ID: ${result.deploymentId}`);
}
} else {
s?.stop('Deployment is still building');
if (json) {
outputJson({ id: deploymentId, status: deployment?.status ?? 'building', timedOut: true });
outputJson({ id: result.deploymentId, status: result.deployment?.status ?? 'building', timedOut: true });
} else {
clack.log.info(`Deployment ID: ${deploymentId}`);
clack.log.info(`Deployment ID: ${result.deploymentId}`);
clack.log.warn('Deployment did not finish within 2 minutes.');
clack.log.info(`Check status with: insforge deployments status ${deploymentId}`);
clack.log.info(`Check status with: insforge deployments status ${result.deploymentId}`);
}
}
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const CREDENTIALS_FILE = join(GLOBAL_DIR, 'credentials.json');
const CONFIG_FILE = join(GLOBAL_DIR, 'config.json');

const DEFAULT_PLATFORM_URL = 'https://api.insforge.dev';
const DEFAULT_FRONTEND_URL = 'https://app.insforge.dev';
const DEFAULT_FRONTEND_URL = 'https://insforge.dev';

function ensureGlobalDir(): void {
if (!existsSync(GLOBAL_DIR)) {
Expand Down
Loading