diff --git a/src/commands/create.ts b/src/commands/create.ts index ed416e4..77886ba 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -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); @@ -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); diff --git a/src/commands/deployments/deploy.ts b/src/commands/deployments/deploy.ts index a40d382..552d8a4 100644 --- a/src/commands/deployments/deploy.ts +++ b/src/commands/deployments/deploy.ts @@ -59,6 +59,95 @@ async function createZipBuffer(sourceDir: string): Promise { }); } +export interface DeployProjectOptions { + sourceDir: string; + startBody?: StartDeploymentRequest; + spinner?: ReturnType | 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 { + 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]') @@ -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; - // Convert {"KEY":"value"} object to [{key,value}] array format if (Array.isArray(parsed)) { startBody.envVars = parsed; } else { @@ -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) { diff --git a/src/lib/config.ts b/src/lib/config.ts index 00170b2..17daf7f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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)) {