netlify
Version:
Netlify command line tool
477 lines • 19.9 kB
JavaScript
import { execFile as execFileCb } from 'child_process';
import { createWriteStream } from 'fs';
import { mkdir, rm, unlink, readdir } from 'fs/promises';
import path from 'path';
import process from 'process';
import readline from 'readline';
import { pipeline } from 'stream/promises';
import { promisify } from 'util';
import extractZip from 'extract-zip';
import inquirer from 'inquirer';
import fetch from 'node-fetch';
import { LocalState } from '@netlify/dev-utils';
import { Octokit } from '@octokit/rest';
import { chalk, logAndThrowError, log, logJson, warn } from '../../utils/command-helpers.js';
import { ensureNetlifyIgnore } from '../../utils/gitignore.js';
import { getGitHubToken as promptForGitHubToken } from '../../utils/gh-auth.js';
import { startSpinner, stopSpinner } from '../../lib/spinner.js';
import { isInteractive } from '../../utils/scripted-commands.js';
import { track } from '../../utils/telemetry/index.js';
import { validatePrompt, validateAgent, formatStatus } from '../agents/utils.js';
const execFile = promisify(execFileCb);
const resolveGitHubToken = async (globalConfig) => {
const userId = globalConfig.get('userId');
if (userId) {
const cached = globalConfig.get(`users.${userId}.auth.github`);
if (cached?.token) {
try {
const octokit = new Octokit({ auth: `token ${cached.token}` });
await octokit.rest.users.getAuthenticated();
return cached.token;
}
catch {
// Token expired or invalid, fall through to re-auth
}
}
}
const newToken = await promptForGitHubToken();
if (userId) {
globalConfig.set(`users.${userId}.auth.github`, newToken);
}
return newToken.token;
};
const POLL_INTERVAL = 2000;
const TERMINAL_STATES = ['done', 'error', 'cancelled'];
const fetchAgentRunner = async (id, api) => {
const result = await api.getAgentRunner({ agent_runner_id: id });
return result;
};
const readMultilineInput = () => new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const lines = [];
rl.on('line', (line) => {
if (line === '' && lines.length > 0) {
rl.close();
return;
}
lines.push(line);
});
rl.on('close', () => {
resolve(lines.join('\n').trim());
});
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// TODO: Replace with api client call once the deploy download endpoint is added to @netlify/open-api
const downloadAndExtractSource = async (deployId, projectDir, api, apiOpts) => {
const urlResponse = await fetch(`${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/deploys/${deployId}/download`, {
headers: {
Authorization: `Bearer ${api.accessToken ?? ''}`,
'User-Agent': apiOpts.userAgent,
},
});
if (!urlResponse.ok) {
throw new Error(`Failed to get source download URL (HTTP ${urlResponse.status.toString()})`);
}
const { url } = (await urlResponse.json());
const zipResponse = await fetch(url, { redirect: 'follow' });
if (!zipResponse.ok || !zipResponse.body) {
throw new Error(`Failed to download source zip (HTTP ${zipResponse.status.toString()})`);
}
await mkdir(projectDir, { recursive: true });
const tmpFile = path.join(projectDir, '_source.zip');
await pipeline(zipResponse.body, createWriteStream(tmpFile));
try {
await extractZip(tmpFile, { dir: projectDir });
}
finally {
await unlink(tmpFile);
}
};
const selectRepoOwner = async (ghToken, repoOwnerFlag) => {
if (repoOwnerFlag) {
return repoOwnerFlag;
}
const octokit = new Octokit({ auth: `token ${ghToken}` });
const { data: user } = await octokit.rest.users.getAuthenticated();
const { data: orgs } = await octokit.rest.orgs.listForAuthenticatedUser();
if (orgs.length === 0) {
return user.login;
}
const choices = [
{ name: `${user.login} (personal)`, value: user.login },
...orgs.map((org) => ({ name: org.login, value: org.login })),
];
const { owner } = await inquirer.prompt([
{
type: 'list',
name: 'owner',
message: 'Where should the GitHub repo be created?',
choices,
},
]);
return owner;
};
// TODO: Replace with api client call once the site repo endpoint is added to @netlify/open-api
const createGitHubRepo = async (siteId, repoName, providerToken, repoOwner, api, apiOpts) => {
const body = {
provider: 'github',
provider_token: providerToken,
repo_name: repoName,
repo_owner: repoOwner,
private: true,
create_initial_commit: true,
};
const response = await fetch(`${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}/api/v1/sites/${siteId}/repo`, {
method: 'POST',
headers: {
Authorization: `Bearer ${api.accessToken ?? ''}`,
'Content-Type': 'application/json',
'User-Agent': apiOpts.userAgent,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({})));
throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`);
}
return (await response.json());
};
const PUSH_TERMINAL_STATES = ['complete', 'failed'];
const PUSH_STATE_LABELS = {
pending: 'Preparing to push to GitHub...',
fetching_files: 'Fetching source files...',
pushing: 'Pushing to GitHub...',
};
const pollRepoPush = async (siteId, api, spinner) => {
let lastState = '';
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
await sleep(POLL_INTERVAL);
const siteData = (await api.getSite({ siteId }));
const progress = siteData.git_initial_push_progress;
if (progress && progress.state !== lastState) {
lastState = progress.state;
const label = PUSH_STATE_LABELS[progress.state];
if (label) {
spinner.update({ text: label });
}
}
if (progress && PUSH_TERMINAL_STATES.includes(progress.state)) {
if (progress.state === 'failed') {
throw new Error(progress.error_message ?? 'Push to GitHub failed');
}
return siteData;
}
if (!progress && siteData.repo?.repo_path) {
return siteData;
}
}
};
export const createAction = async (promptArg, options, command) => {
const { accounts, api, apiOpts } = command.netlify;
await command.authenticate();
const { prompt, agent: initialAgent, model, name: siteName, dir, accountSlug: accountSlugFlag } = options;
// Resolve prompt
let finalPrompt;
if (!prompt && !promptArg) {
log(chalk.bold('What do you want to build? Type out the prompt for your project:'));
log(chalk.dim('(Press Enter on an empty line to submit)'));
finalPrompt = await readMultilineInput();
}
else {
finalPrompt = (promptArg || prompt) ?? '';
}
const promptIsValid = validatePrompt(finalPrompt);
if (promptIsValid !== true) {
return logAndThrowError(promptIsValid);
}
// Resolve agent (default to claude)
const agent = initialAgent ?? 'claude';
const agentIsValid = validateAgent(agent);
if (agentIsValid !== true) {
return logAndThrowError(agentIsValid);
}
// Resolve team
let accountSlug;
if (accountSlugFlag) {
accountSlug = accountSlugFlag;
}
else if (accounts.length > 1) {
const { accountSlug: selected } = await inquirer.prompt([
{
type: 'list',
name: 'accountSlug',
message: 'Team:',
choices: accounts.map((account) => ({
value: account.slug,
name: account.name,
})),
},
]);
accountSlug = selected;
}
else {
accountSlug = accounts[0]?.slug;
}
if (!accountSlug) {
return logAndThrowError('No account found. Please log in first.');
}
// Step 1: Create site (with retry on name collision)
const MAX_NAME_RETRIES = 2;
const siteSpinner = startSpinner({ text: 'Creating project...' });
let site;
let nameAttempt = siteName;
let retries = 0;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
try {
const body = { created_via: 'agent_runner' };
if (nameAttempt && nameAttempt !== 'undefined') {
body.name = nameAttempt.trim();
}
site = (await api.createSiteInTeam({
accountSlug,
body,
}));
stopSpinner({ spinner: siteSpinner });
log(`${chalk.green('✓')} Project created: ${chalk.cyan(site.name)}`);
break;
}
catch (error_) {
if (error_.status === 422 && siteName && retries < MAX_NAME_RETRIES) {
retries++;
const suffix = Math.floor(Math.random() * 900 + 100).toString();
nameAttempt = `${siteName}-${suffix}`;
warn(`${siteName}.netlify.app already exists. Trying ${nameAttempt}...`);
continue;
}
stopSpinner({ spinner: siteSpinner, error: true });
if (error_.status === 422) {
const name = nameAttempt ?? siteName;
return logAndThrowError(name
? `Project name "${name}" is already taken. Please try a different name.`
: `Failed to create project: ${error_.message}`);
}
return logAndThrowError(`Failed to create project: ${error_.message}`);
}
}
// Step 2: Create agent runner
const agentSpinner = startSpinner({ text: 'Starting agent...' });
let agentRunner;
try {
// TODO: Replace with api.createAgentRunner() once @netlify/open-api supports body params and the mode field
const agentRunnerUrl = new URL(`/api/v1/agent_runners`, `${apiOpts.scheme ?? 'https'}://${apiOpts.host ?? api.host}`);
agentRunnerUrl.searchParams.set('site_id', site.id);
const response = await fetch(agentRunnerUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${api.accessToken ?? ''}`,
'Content-Type': 'application/json',
'User-Agent': apiOpts.userAgent,
},
body: JSON.stringify({
prompt: finalPrompt,
agent,
...(model ? { model } : {}),
mode: 'create',
}),
});
if (!response.ok) {
const errorData = (await response.json().catch(() => ({})));
throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`);
}
agentRunner = (await response.json());
stopSpinner({ spinner: agentSpinner });
}
catch (error_) {
stopSpinner({ spinner: agentSpinner, error: true });
return logAndThrowError(`Failed to start agent: ${error_.message}`);
}
const agentRunUrl = `https://app.netlify.com/projects/${site.name}/agent-runs/${agentRunner.id}`;
const agentRunCreateUrl = `${agentRunUrl}/create`;
const showCmd = `netlify agents:show ${agentRunner.id} --project ${site.name}`;
const shouldSkipPolling = options.wait === false || options.json || !isInteractive();
if (shouldSkipPolling) {
void track('sites_createStarted', {
siteId: site.id,
agentRunnerId: agentRunner.id,
noWait: true,
});
if (options.json) {
logJson({
site: {
id: site.id,
name: site.name,
admin_url: site.admin_url,
},
agentRunner: {
id: agentRunner.id,
state: agentRunner.state,
url: agentRunCreateUrl,
},
});
return;
}
log();
log(`${chalk.green('✓')} Agent run started! The agent is now building your site in the background.`);
log();
log(chalk.bold('Next steps:'));
log(` View progress in the browser:`);
log(` ${chalk.blue(agentRunCreateUrl)}`);
log();
log(` Check status from the CLI:`);
log(` ${chalk.cyan(showCmd)}`);
log();
log(chalk.dim("The agent typically takes a few minutes to complete. You'll be able to see the site URL once it's done."));
log();
return;
}
// Step 3: Poll for completion
const pollSpinner = startSpinner({ text: 'Agent is working...' });
let lastTask = '';
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
await sleep(POLL_INTERVAL);
const runner = await fetchAgentRunner(agentRunner.id, api);
if (runner.current_task && runner.current_task !== lastTask) {
lastTask = runner.current_task;
pollSpinner.update({ text: `Agent: ${runner.current_task}` });
}
if (TERMINAL_STATES.includes(runner.state ?? '')) {
agentRunner = runner;
break;
}
}
}
catch (error_) {
stopSpinner({ spinner: pollSpinner, error: true });
log();
log(` View details: ${chalk.blue(agentRunUrl)}`);
return logAndThrowError(`Error polling agent status: ${error_.message}`);
}
stopSpinner({ spinner: pollSpinner });
// Fetch final site info for URL
let finalSite;
try {
finalSite = (await api.getSite({ siteId: site.id }));
}
catch {
finalSite = site;
}
let githubRepoPath;
const siteUrl = finalSite.ssl_url || finalSite.url;
void track('sites_createCompleted', {
siteId: site.id,
agentRunnerId: agentRunner.id,
state: agentRunner.state,
});
if (agentRunner.state === 'done') {
log(`${chalk.green('✓')} Agent run complete!`);
// Step 4: Download source and link project
const projectDir = path.resolve(dir || '.', site.name);
const relativeDir = path.relative(command.workingDir, projectDir) || '.';
let downloaded = false;
if (options.download !== false && agentRunner.latest_session_deploy_id) {
let dirExists = false;
try {
const entries = await readdir(projectDir);
dirExists = entries.length > 0;
}
catch {
// Directory doesn't exist, which is what we want
}
if (dirExists) {
warn(`Directory ${relativeDir} already exists and is not empty. Skipping source download.`);
}
else {
const downloadSpinner = startSpinner({ text: 'Downloading source...' });
try {
await downloadAndExtractSource(agentRunner.latest_session_deploy_id, projectDir, api, apiOpts);
stopSpinner({ spinner: downloadSpinner });
log(`${chalk.green('✓')} Source downloaded to ${chalk.cyan(relativeDir)}`);
const state = new LocalState(projectDir);
state.set('siteId', site.id);
await ensureNetlifyIgnore(projectDir);
log(`${chalk.green('✓')} Project linked to ${chalk.cyan(site.name)}`);
downloaded = true;
}
catch (error_) {
stopSpinner({ spinner: downloadSpinner, error: true });
await rm(projectDir, { recursive: true, force: true }).catch(() => { });
warn(`Failed to download source: ${error_.message}`);
}
}
}
else if (options.download !== false && !agentRunner.latest_session_deploy_id) {
warn('No deploy found for this agent run. Skipping source download.');
}
// Step 5: Create GitHub repo and push source
if (options.git) {
if (options.git !== 'github') {
warn(`Unsupported git provider "${options.git}". Only "github" is supported.`);
}
else {
try {
const ghToken = await resolveGitHubToken(command.netlify.globalConfig);
const repoOwner = await selectRepoOwner(ghToken, options.repoOwner);
const repoSpinner = startSpinner({ text: 'Creating GitHub repository...' });
try {
await createGitHubRepo(site.id, site.name, ghToken, repoOwner, api, apiOpts);
repoSpinner.update({ text: 'Pushing source to GitHub...' });
await pollRepoPush(site.id, api, repoSpinner);
stopSpinner({ spinner: repoSpinner });
githubRepoPath = `${repoOwner}/${site.name}`;
log(`${chalk.green('✓')} GitHub repo created: ${chalk.cyan(`https://github.com/${githubRepoPath}`)}`);
if (downloaded) {
try {
const repoUrl = `https://github.com/${githubRepoPath}.git`;
await execFile('git', ['init'], { cwd: projectDir });
await execFile('git', ['remote', 'add', 'origin', repoUrl], { cwd: projectDir });
await execFile('git', ['fetch', 'origin'], { cwd: projectDir });
await execFile('git', ['reset', 'origin/main'], { cwd: projectDir });
await execFile('git', ['branch', '-u', 'origin/main'], { cwd: projectDir });
log(`${chalk.green('✓')} Git repository initialized`);
}
catch {
// Non-fatal: local git init is best-effort
}
}
}
catch (error_) {
stopSpinner({ spinner: repoSpinner, error: true });
warn(`Failed to create GitHub repo: ${error_.message}`);
}
}
catch (error_) {
warn(`GitHub authentication failed: ${error_.message}`);
}
}
}
log();
log(` Site URL: ${chalk.cyan(siteUrl)}`);
log(` Admin URL: ${chalk.blue(site.admin_url)}`);
if (githubRepoPath) {
log(` Repo URL: ${chalk.blue(`https://github.com/${githubRepoPath}`)}`);
}
log();
if (downloaded) {
log(chalk.bold('Next steps:'));
log(` cd ${chalk.cyan(relativeDir)} and start making changes`);
if (githubRepoPath) {
log(' When ready, push your changes to your repo and Netlify will automatically deploy your changes');
}
else {
log(` When ready, run ${chalk.cyan('netlify deploy')} to publish your new changes`);
}
log();
}
}
else {
log(`${chalk.red('✗')} Agent run ${formatStatus(agentRunner.state ?? 'error')}`);
log();
log(` View details: ${chalk.blue(agentRunUrl)}`);
}
log();
};
//# sourceMappingURL=create-action.js.map