import inquirer from 'inquirer'; import * as fs from 'fs/promises'; import * as path from 'path'; import { OutputHelper } from '@quenty/cli-output-helpers'; import { DeployConfig, discoverUniverseIdAsync, } from '@quenty/nevermore-deploy'; import { getRobloxCookieAsync, createPlaceInUniverseAsync, findGitRepoRootAsync, } from '@quenty/nevermore-cli-helpers'; import { fileExistsAsync, buildPlaceNameAsync, } from '../../utils/nevermore-cli-utils.js'; import { DeployArgs } from './index.js'; import { promptPlaceIdAsync } from './deploy-init-prompts.js'; import { detectProjectFileAsync, detectScriptFileAsync, detectTargetNameAsync, } from './deploy-init-utils.js'; /** * Stands in for the place ID in a `--dryrun` preview. A dryrun never creates a * place, so there is no real ID to render — and the preview is never written. */ const DRYRUN_PLACE_ID = ''; interface InitState { packagePath: string; packageName: string; placeName: string; targetName: string; universeId?: number; /** Resolved place ID, or {@link DRYRUN_PLACE_ID} on a dryrun. */ placeId?: number | typeof DRYRUN_PLACE_ID; project?: string; scriptTemplate?: string; mode: 'auto' | 'interactive' | 'non-interactive'; } export async function handleInitAsync(args: DeployArgs): Promise { const packagePath = process.cwd(); const deployJsonPath = path.join(packagePath, 'deploy.nevermore.json'); if ((await fileExistsAsync(deployJsonPath)) && !args.force) { OutputHelper.warn( `deploy.nevermore.json already exists at ${deployJsonPath}` ); OutputHelper.hint('Use --force to overwrite, or edit it manually.'); return; } if (!(await fileExistsAsync(path.join(packagePath, 'package.json')))) { throw new Error('No package.json found — are you in a package directory?'); } const state = await detectDefaults(args, packagePath); if (args.yes) { await resolveNonInteractive(args, state); } else { await resolveInteractiveAsync(args, state); } await writeConfig(args, state, deployJsonPath); } async function detectDefaults( args: DeployArgs, packagePath: string ): Promise { const packageName = path.basename(packagePath); const placeName = await buildPlaceNameAsync(packagePath); const detectedProject = await detectProjectFileAsync(packagePath); const detectedScript = await detectScriptFileAsync(packagePath); const detectedTarget = await detectTargetNameAsync(packagePath); let universeId = args.universeId; if (!universeId) { const discovered = await discoverUniverseIdAsync(packagePath); if (discovered) { universeId = discovered; } } return { packagePath, packageName, placeName, targetName: args.target ?? detectedTarget, universeId, placeId: args.placeId, project: args.project ?? detectedProject, scriptTemplate: args.scriptTemplate ?? detectedScript, mode: args.yes ? 'non-interactive' : 'interactive', }; } async function resolveNonInteractive( args: DeployArgs, state: InitState ): Promise { const missing: string[] = []; if (!state.universeId) missing.push('--universe-id'); if (!state.project) missing.push('--project'); if (isTestTarget(state) && !state.scriptTemplate) { missing.push('--script-template'); } if (missing.length > 0) { throw new Error( `Missing required flags for non-interactive mode: ${missing.join(', ')}` ); } if (args.createPlace && state.universeId && !state.placeId) { state.placeId = await createPlaceAsync( args, state.universeId, state.placeName ); } if (!state.placeId) { throw new Error( 'Missing required flag for non-interactive mode: --place-id (or use --create-place)' ); } } async function resolveInteractiveAsync( args: DeployArgs, state: InitState ): Promise { await promptModeAsync(state); await ensureUniverseIdAsync(args, state); // Resolves targetName before script prompt await ensureProjectAndScriptAsync(args, state); await ensurePromptPlaceIdAsync(args, state); // This modifies the cloud, so is last } function isTestTarget(state: InitState): boolean { return state.targetName === 'test'; } async function promptModeAsync(state: InitState): Promise { const repoRoot = await findGitRepoRootAsync(state.packagePath); const deployJsonPath = path.join(state.packagePath, 'deploy.nevermore.json'); const displayPath = repoRoot ? path.relative(repoRoot, deployJsonPath).replace(/\\/g, '/') : `${state.packageName}/deploy.nevermore.json`; const autoPreview = buildAutoPreview(state); const { mode } = await inquirer.prompt([ { type: 'select', name: 'mode', message: `How would you like to set up ${displayPath}?`, choices: [ { name: `Automatically set up as '${state.targetName}' target`, value: 'auto', description: `\nPreview:\n${autoPreview}`, }, { name: 'Manually configure each field', value: 'interactive', description: '\nWalk through prompts for each field.', }, ], }, ]); state.mode = mode; } function buildAutoPreview(state: InitState): string { const projectDefault = 'test/default.project.json'; const scriptDefault = 'test/scripts/Server/ServerMain.server.lua'; const target: Record = { universeId: state.universeId ?? '', placeId: '', project: state.project ?? ``, }; if (state.scriptTemplate) { target.scriptTemplate = state.scriptTemplate; } else if (isTestTarget(state)) { target.scriptTemplate = ``; } return JSON.stringify({ targets: { [state.targetName]: target } }, null, 2); } async function ensureUniverseIdAsync( args: DeployArgs, state: InitState ): Promise { const answers = await inquirer.prompt([ { type: 'select', name: 'targetName', message: 'Target name:', default: state.targetName, choices: [ { name: 'test — unit-test place with a script template', value: 'test', }, { name: 'integration — integration game / place without a test script', value: 'integration', }, ], when: () => !args.target && state.mode !== 'auto', }, { type: 'number', name: 'universeId', message: 'Universe ID (find at https://create.roblox.com/dashboard/creations):', default: state.universeId, when: () => !args.universeId && (state.mode === 'interactive' || !state.universeId), validate: (input: number) => Number.isInteger(input) && input > 0 ? true : 'Must be a positive integer', }, ]); state.targetName = answers.targetName ?? state.targetName; state.universeId = answers.universeId ?? state.universeId; } async function ensurePromptPlaceIdAsync( args: DeployArgs, state: InitState ): Promise { if (!state.universeId) { throw new Error('Universe ID is required to set up place ID'); } try { state.placeId = await createPlaceAsync( args, state.universeId, state.placeName ); } catch (err) { OutputHelper.warn( `Failed to auto-create place in universe ${state.universeId}: ${ err instanceof Error ? err.message : String(err) }` ); state.placeId = await promptPlaceIdAsync(state.universeId, state.placeName); } } /** * Creates the place the target will point at. On a dryrun nothing is created * and nothing is fetched — not even the cookie — so the caller gets * {@link DRYRUN_PLACE_ID} to render instead. */ async function createPlaceAsync( args: DeployArgs, universeId: number, placeName: string ): Promise { if (args.dryrun) { OutputHelper.info( `[DRYRUN] Would create place '${placeName}' in universe ${universeId}` ); return DRYRUN_PLACE_ID; } const cookie = await getRobloxCookieAsync(); return await createPlaceInUniverseAsync(cookie, universeId, placeName); } async function ensureProjectAndScriptAsync( args: DeployArgs, state: InitState ): Promise { const scriptRequired = isTestTarget(state); const needsScript = !state.scriptTemplate && !args.scriptTemplate; const answers = await inquirer.prompt([ { type: 'input', name: 'project', message: 'Rojo project file (relative to package):', default: state.project ?? 'test/default.project.json', when: () => !state.project, validate: async (input: string) => { const fullPath = path.resolve(state.packagePath, input); if (await fileExistsAsync(fullPath)) { return true; } return `File not found: ${fullPath}`; }, }, { type: 'confirm', name: 'hasScript', message: 'Include a Luau testing script?', default: false, when: () => needsScript && state.mode !== 'auto' && !scriptRequired, }, { type: 'input', name: 'scriptTemplate', message: scriptRequired ? 'Script template file for test target (required, relative to package):' : 'Script template file (relative to package):', default: 'test/scripts/Server/ServerMain.server.lua', when: (promptAnswers: { hasScript?: boolean }) => needsScript && (scriptRequired || (state.mode !== 'auto' && !!promptAnswers.hasScript)), validate: async (input: string) => { const fullPath = path.resolve(state.packagePath, input); if (await fileExistsAsync(fullPath)) { return true; } return `File not found: ${fullPath}`; }, }, ]); state.project = answers.project ?? state.project; if (answers.scriptTemplate) { state.scriptTemplate = answers.scriptTemplate; } } async function writeConfig( args: DeployArgs, state: InitState, deployJsonPath: string ): Promise { const config: DeployConfig = { targets: { [state.targetName]: { universeId: state.universeId!, // On a dryrun this is DRYRUN_PLACE_ID, which only ever gets printed. placeId: state.placeId as number, project: state.project!.replace(/\\/g, '/'), ...(state.scriptTemplate ? { scriptTemplate: state.scriptTemplate.replace(/\\/g, '/') } : {}), }, }, }; const configJson = JSON.stringify(config, null, 2); if (args.dryrun) { OutputHelper.info('[DRYRUN] Would write deploy.nevermore.json:'); console.log(configJson); return; } console.log(configJson); await fs.writeFile(deployJsonPath, configJson + '\n'); OutputHelper.info(`Created ${deployJsonPath}`); }