diff --git a/packages/cli/lib/commands/create.js b/packages/cli/lib/commands/create.js index 216bf4db2c..ab07d35e10 100644 --- a/packages/cli/lib/commands/create.js +++ b/packages/cli/lib/commands/create.js @@ -1,13 +1,22 @@ // Thin delegate: hands off to create-apostrophe's guided installer. Owns -// no UI of its own — Commander supplies `--help` for this subcommand. +// no UI of its own — Commander supplies `--help` for this subcommand. The +// only thing it forwards is `--starter`, so a developer can name a custom +// starter (kit name, org/repo, or git URL) and skip the kit prompt. module.exports = function (program) { - program + const command = program .command('create') .description('Create an Apostrophe project — launches the guided installer.') + .option( + '--starter ', + 'Start from a specific starter instead of choosing a kit interactively. ' + + 'Accepts the short name of an official starter kit (e.g. "ecommerce"), ' + + 'an org/repo (e.g. "myorg/my-starter"), or a full git URL.' + ) .action(async function () { + const { starter } = command.opts(); const { runInteractive } = await import('create-apostrophe'); - const code = await runInteractive(); + const code = await runInteractive({ starter }); process.exit(typeof code === 'number' ? code : 0); }); }; diff --git a/packages/create-apostrophe/src/cli/main.js b/packages/create-apostrophe/src/cli/main.js index e0372ee74c..141e4f62fb 100644 --- a/packages/create-apostrophe/src/cli/main.js +++ b/packages/create-apostrophe/src/cli/main.js @@ -10,6 +10,7 @@ import { parseArgs } from 'node:util'; import { createStore as defaultCreateStore } from '../core/store.js'; import { createProject as defaultCreateProject } from '../core/create-project.js'; import { isKnownKit } from '../core/kits.js'; +import { resolveStarter, CUSTOM_KIT_ID } from '../core/starter.js'; import { assertSafeShortName } from '../core/validate.js'; import { createTelemetry as defaultCreateTelemetry } from '../telemetry/index.js'; import { DB_CHOICES } from '../telemetry/schema.js'; @@ -40,6 +41,7 @@ const DEFAULT_USERNAME = 'admin'; const OPTION_SPEC = Object.freeze({ 'project-name': { type: 'string' }, kit: { type: 'string' }, + starter: { type: 'string' }, db: { type: 'string' }, 'db-uri': { type: 'string' }, username: { type: 'string' }, @@ -58,12 +60,16 @@ const OPTION_SPEC = Object.freeze({ const USAGE = `${render.bold('Usage:')} npm create apostrophe@latest ${render.dim('Run the guided installer')} + npm create apostrophe@latest -- --starter=NAME|URL ${render.dim('Start from a custom starter (skips the kit prompt)')} npm create apostrophe@latest -- --unattended [flags] ${render.dim('Run without prompts')} npm create apostrophe@latest -- telemetry [status|on|off|preview] ${render.dim('Manage telemetry preference')} npm create apostrophe@latest -- --help | --version ${render.dim('(everything after the `--` is forwarded to the installer; npm consumes args without it)')} +${render.bold('Starter selection')} ${render.dim('(either path; --starter overrides --kit)')}: + --starter=NAME|ORG/REPO|URL ${render.dim('Custom starter: a starter-kit name (e.g. ecommerce), an org/repo, or a git URL')} + ${render.bold('Unattended flags')} ${render.dim('(--unattended is the only trigger for the headless path)')}: ${render.bold('Required:')} --project-name=NAME ${render.dim('Project directory name')} @@ -71,7 +77,7 @@ ${render.bold('Unattended flags')} ${render.dim('(--unattended is the only trigg --telemetry=on|off ${render.dim('Telemetry consent (no default — explicit choice)')} ${render.bold('Defaulted (override to change):')} - --kit=KITID ${render.dim(`Starter kit id (default: ${DEFAULT_KIT})`)} + --kit=KITID ${render.dim(`Starter kit id (default: ${DEFAULT_KIT}); ignored when --starter is set`)} --db=sqlite|mongodb|postgres ${render.dim(`Database choice (default: ${DEFAULT_DB})`)} --db-uri=URI ${render.dim('Connection string (required when --db is mongodb or postgres)')} --username=NAME ${render.dim(`Admin username or email (default: ${DEFAULT_USERNAME})`)} @@ -147,6 +153,7 @@ export async function main(argv, deps = {}) { }); } return runInteractive({ + starter: values.starter, createProject, createStore, createTelemetry, @@ -161,16 +168,24 @@ export async function main(argv, deps = {}) { * flow without going through argv parsing — their command framework keeps * ownership of help text and subcommand routing. * - * @param {MainDeps} [deps] + * `starter` is the only non-injectable option here: a raw `--starter` value + * (starter-kit name, org/repo, or git URL). When present, it's resolved to a + * clone URL and the guided flow skips the kit-selection questions entirely. + * + * @param {MainDeps & { starter?: string }} [deps] * @returns {Promise} */ export async function runInteractive(deps = {}) { const { + starter: rawStarter, createProject = defaultCreateProject, createStore = defaultCreateStore, createTelemetry = defaultCreateTelemetry, runFlow = defaultRunFlow } = deps; + const starter = (typeof rawStarter === 'string' && rawStarter.trim().length > 0) + ? resolveStarter(rawStarter) + : undefined; const cwd = process.cwd(); const env = process.env; const store = createStore(); @@ -180,7 +195,8 @@ export async function runInteractive(deps = {}) { answers = await runFlow({ store, cliVersion: CLI_VERSION, - env + env, + starter }); } catch (err) { if (err instanceof UserCancelled) { @@ -201,6 +217,7 @@ export async function runInteractive(deps = {}) { shortName: answers.shortName, cwd, kitId: answers.kitId, + starter, dbChoice: answers.dbChoice, dbUri: answers.dbUri, dbReset: answers.dbReset, @@ -245,8 +262,19 @@ async function runUnattended(values, { } } - const kitId = values.kit ?? DEFAULT_KIT; - if (!isKnownKit(kitId)) { + // --starter overrides --kit: clone an arbitrary repo instead of a registry + // kit. Validate the empty and both-given cases up front. + const usingStarter = + typeof values.starter === 'string' && values.starter.trim().length > 0; + if (values.starter !== undefined && !usingStarter) { + issues.push('--starter cannot be empty'); + } + if (usingStarter && values.kit !== undefined) { + issues.push('Use either --kit or --starter, not both'); + } + const starter = usingStarter ? resolveStarter(values.starter) : undefined; + const kitId = usingStarter ? CUSTOM_KIT_ID : (values.kit ?? DEFAULT_KIT); + if (!usingStarter && !isKnownKit(kitId)) { issues.push(`Unknown --kit: ${JSON.stringify(kitId)}`); } @@ -306,6 +334,7 @@ async function runUnattended(values, { shortName, cwd, kitId, + starter, dbChoice, dbUri, // Unattended never drops a pre-existing DB — that needs interactive @@ -324,12 +353,19 @@ async function runUnattended(values, { logger }); + // A custom starter has no kit to derive build/startingPoint from; its layout + // (and thus the dev-server port the success screen shows) comes back on the + // result. The kit-derived fields are filled only for a registry kit. /** @type {import('../ui/flow.js').FlowAnswers} */ const answers = { shortName, - build: kitId.startsWith('apostrophe-astro') ? 'astro' : 'standalone', - startingPoint: kitId.endsWith('-essentials') ? 'essentials' : 'demo', - sampleContent: kitId.endsWith('-demo-data'), + ...(usingStarter + ? { starter } + : { + build: kitId.startsWith('apostrophe-astro') ? 'astro' : 'standalone', + startingPoint: kitId.endsWith('-essentials') ? 'essentials' : 'demo', + sampleContent: kitId.endsWith('-demo-data') + }), kitId, dbChoice, dbUri, diff --git a/packages/create-apostrophe/src/core/create-project.js b/packages/create-apostrophe/src/core/create-project.js index 28863ae545..b459c2a43f 100644 --- a/packages/create-apostrophe/src/core/create-project.js +++ b/packages/create-apostrophe/src/core/create-project.js @@ -4,6 +4,7 @@ // unexpected throws propagate to the caller (which converts + emits). import { getKit } from './kits.js'; +import { detectFrontend } from './starter.js'; import { detectPackageManager, assertSupportedPackageManager } from './pm.js'; import { StageError, UnsupportedPackageManagerError } from './errors.js'; import { checkConnection, dropDatabase } from './db.js'; @@ -30,8 +31,11 @@ export function makeCreateProject({ }; // Single terminal point: build the result, emit exactly one event, return. + // `frontend` is the resolved layout (null = standalone), known only once a + // step has determined it; it rides on the returned result but is added + // AFTER the telemetry payload is sliced off, so it never reaches the wire. const finish = ({ - ok, packageManager, failStage, errorCode + ok, packageManager, failStage, errorCode, frontend }) => { const result = { ok, @@ -47,6 +51,9 @@ export function makeCreateProject({ } const { ok: _ok, ...payload } = result; telemetry.event(ok ? 'install_success' : 'install_fail', payload); + if (frontend !== undefined) { + result.frontend = frontend; + } return result; }; @@ -66,9 +73,17 @@ export function makeCreateProject({ throw err; } - // Validation errors (unknown kit, unsafe shortName, …) propagate as - // unexpected throws — the caller converts and emits. - const kit = getKit(options.kitId); + // A `--starter` install escapes the kit registry: the repo is the resolved + // starter URL and the frontend is detected from the clone, not declared. + // Otherwise resolve the registry kit — validation errors (unknown kit, + // unsafe shortName, …) propagate as unexpected throws; the caller converts. + const usingStarter = Boolean(options.starter); + const kit = usingStarter + ? { + repo: options.starter.repo, + seedData: false + } + : getKit(options.kitId); // The task handle is passed to the step fn so long-running steps can // report progress (task.progress?.(...)); steps that don't, ignore it. @@ -92,11 +107,17 @@ export function makeCreateProject({ cwd: options.cwd })); + // A registry kit declares its frontend; a custom starter's is detected + // from the cloned layout (a `backend/` directory means hybrid Astro). + const declaredFrontend = usingStarter + ? detectFrontend(projectDir) + : kit.frontend; + const { frontend, appRoot } = await step('Configuring project', () => scaffold({ projectDir, shortName: options.shortName, - frontend: kit.frontend + frontend: declaredFrontend })); await step('Installing dependencies', () => @@ -144,7 +165,8 @@ export function makeCreateProject({ return finish({ ok: true, - packageManager + packageManager, + frontend }); } catch (err) { if (err instanceof StageError) { diff --git a/packages/create-apostrophe/src/core/starter.js b/packages/create-apostrophe/src/core/starter.js new file mode 100644 index 0000000000..1a5fee98b4 --- /dev/null +++ b/packages/create-apostrophe/src/core/starter.js @@ -0,0 +1,80 @@ +// Custom-starter support for the `--starter` option. A "starter" escapes the +// fixed kit registry (core/kits.js): instead of one of the six known kits, the +// installer clones an arbitrary git repository the developer names directly. +// This module owns the two things that differ from a registry kit — turning the +// `--starter` value into a clone URL, and discovering the project layout after +// the clone (a registry kit declares its frontend up front; a custom one can't). + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** @typedef {import('./kits.js').Frontend} Frontend */ + +/** + * Telemetry kitId reported for a custom `--starter` install. Deliberately NOT a + * member of the kit registry — `getKit`/`isKnownKit` still reject it — so the + * six-kit invariants hold; the telemetry schema allowlists it on its own so the + * "which kit?" question stays answerable as "a custom one". + * @type {'custom'} + */ +export const CUSTOM_KIT_ID = 'custom'; + +/** + * A `--starter` value resolved to something clonable. + * @typedef {object} ResolvedStarter + * @property {string} repo Clone URL handed to `git clone`. + * @property {string} source The original `--starter` input, kept verbatim for + * display in the review/summary screens. + */ + +/** + * Resolve a `--starter` value to a git clone URL. Three forms, matched in order + * (the grammar the legacy `@apostrophecms/cli` accepted, preserved exactly): + * + * 1. A URL with a scheme (`https:`, `git:`, …) — used verbatim. + * e.g. `https://github.com/apostrophecms/apostrophe-open-museum.git` + * 2. A value containing `/` — treated as a GitHub `org/repo` (a leading `/` + * is dropped). e.g. `mycoolcompany/my-starter` → + * `https://github.com/mycoolcompany/my-starter` + * 3. A bare name — a shorthand for an official starter kit, expanded to + * `starter-kit-`. e.g. `ecommerce` → + * `https://github.com/apostrophecms/starter-kit-ecommerce.git` + * + * @param {string} input The raw `--starter` value. + * @returns {ResolvedStarter} + * @throws {TypeError} when `input` is missing or blank. + */ +export function resolveStarter(input) { + if (typeof input !== 'string' || input.trim().length === 0) { + throw new TypeError( + 'A --starter value is required (a starter-kit name, an org/repo, or a git URL).' + ); + } + const value = input.trim(); + let repo; + if (/^\w+:/.test(value)) { + repo = value; + } else if (value.includes('/')) { + repo = `https://github.com/${value.startsWith('/') ? value.slice(1) : value}`; + } else { + repo = `https://github.com/apostrophecms/starter-kit-${value}.git`; + } + return { + repo, + source: value + }; +} + +/** + * Discover a freshly cloned custom starter's frontend. A hybrid Astro project + * nests the Apostrophe app under `backend/`; a standalone project keeps it at + * the root. Mirrors the legacy CLI's auto-detection, and the README's promise + * that hybrid projects are "automatically detected by the presence of a + * `backend/` directory." + * + * @param {string} projectDir The cloned project's root. + * @returns {Frontend} `'astro'` when a `backend/` directory exists, else `null`. + */ +export function detectFrontend(projectDir) { + return existsSync(join(projectDir, 'backend')) ? 'astro' : null; +} diff --git a/packages/create-apostrophe/src/index.js b/packages/create-apostrophe/src/index.js index c8640ec7f0..8620e7b091 100644 --- a/packages/create-apostrophe/src/index.js +++ b/packages/create-apostrophe/src/index.js @@ -76,7 +76,16 @@ * captures it once and threads * it in; steps must not call * process.cwd() on their own. - * @property {KitId} kitId Starter kit. + * @property {KitId | 'custom'} kitId Starter kit, or `'custom'` for a + * `--starter` install (escapes the + * kit registry; see `starter`). + * @property {import('./core/starter.js').ResolvedStarter} [starter] + * A custom starter resolved from + * `--starter`. When present it + * overrides `kitId` for cloning: + * the repo is cloned directly and + * the frontend is detected from the + * clone rather than the registry. * @property {DbChoice} dbChoice Database selection. * @property {string} [dbUri] Connection string for * mongodb/postgres. Never sent to @@ -104,10 +113,18 @@ * result. * @typedef {object} CreateProjectResult * @property {boolean} ok Whether the project was created. - * @property {KitId} kitId + * @property {KitId | 'custom'} kitId Echo of the input (`'custom'` for + * a `--starter` install). * @property {DbChoice} dbChoice Echo of the input. * @property {PackageManager} packageManager Resolved value. * @property {number} durationMs `Date.now() - options.confirmedAt`. + * @property {import('./core/kits.js').Frontend} [frontend] Resolved layout + * (`'astro'` | `null`). Present only + * on success; never sent to + * telemetry. Lets the UI pick the + * right dev-server port for a custom + * starter whose frontend wasn't known + * until after the clone. * @property {FailStage} [failStage] Present iff `ok === false`; * `null` for a preflight failure. * @property {string} [errorCode] Present only for known, diff --git a/packages/create-apostrophe/src/telemetry/schema.js b/packages/create-apostrophe/src/telemetry/schema.js index 4e9ffc5fd8..8a63af6c2b 100644 --- a/packages/create-apostrophe/src/telemetry/schema.js +++ b/packages/create-apostrophe/src/telemetry/schema.js @@ -5,6 +5,16 @@ // upstream caller passing extra keys. import { KIT_IDS } from '../core/kits.js'; +import { CUSTOM_KIT_ID } from '../core/starter.js'; + +/** + * kitId values accepted on the wire: the six registry kits plus the `'custom'` + * sentinel a `--starter` install reports. Kept separate from {@link KIT_IDS} so + * the registry stays exactly the six known kits while telemetry can still say + * "a custom starter was used" without naming the (potentially private) repo. + * @type {ReadonlyArray} + */ +const TELEMETRY_KIT_IDS = Object.freeze([ ...KIT_IDS, CUSTOM_KIT_ID ]); /** @typedef {import('../index.js').KitId} KitId */ /** @typedef {import('../index.js').DbChoice} DbChoice */ @@ -209,7 +219,7 @@ function requireFiniteNumber(name, value) { export function buildPayload(event, input) { requireOneOf('event', event, EVENT_NAMES); requireString('cliVersion', input.cliVersion); - requireOneOf('kitId', input.kitId, KIT_IDS); + requireOneOf('kitId', input.kitId, TELEMETRY_KIT_IDS); requireOneOf('dbChoice', input.dbChoice, DB_CHOICES); requireOneOf('packageManager', input.packageManager, PACKAGE_MANAGERS); requireFiniteNumber('durationMs', input.durationMs); diff --git a/packages/create-apostrophe/src/ui/flow.js b/packages/create-apostrophe/src/ui/flow.js index 59db858d71..d17a8930f7 100644 --- a/packages/create-apostrophe/src/ui/flow.js +++ b/packages/create-apostrophe/src/ui/flow.js @@ -8,9 +8,10 @@ import * as render from './render.js'; import * as prompts from './prompts.js'; import { defaultDbUri } from './db-uri.js'; import * as db from '../core/db.js'; -import { link, kitGuide } from './links.js'; +import { link, kitGuide, hasKitGuide } from './links.js'; import { assertSafeShortName } from '../core/validate.js'; import { deriveKitId, getKit } from '../core/kits.js'; +import { CUSTOM_KIT_ID } from '../core/starter.js'; import { status as telemetryStatus, optIn as telemetryOptIn, @@ -34,10 +35,14 @@ let totalSteps = 6; /** * @typedef {object} FlowAnswers * @property {string} shortName - * @property {BuildType} build - * @property {StartingPoint} startingPoint - * @property {boolean} sampleContent - * @property {string} kitId + * @property {BuildType} [build] Absent for a `--starter` install. + * @property {StartingPoint} [startingPoint] Absent for a `--starter` install. + * @property {boolean} [sampleContent] Absent for a `--starter` install. + * @property {string} kitId `'custom'` for a `--starter` + * install (see `starter`). + * @property {import('../core/starter.js').ResolvedStarter} [starter] The + * resolved custom starter, present when `--starter` was given. Its presence + * is what makes this a custom install. * @property {DbChoice} dbChoice * @property {string} [dbUri] * @property {'keep' | 'drop'} dbReset Consent to drop a pre-existing @@ -58,6 +63,9 @@ let totalSteps = 6; * @property {Store} store * @property {string} cliVersion * @property {NodeJS.ProcessEnv} [env] + * @property {import('../core/starter.js').ResolvedStarter} [starter] When set + * (from `--starter`), the three kit-selection questions are skipped and this + * starter is used instead. */ /** @@ -91,31 +99,60 @@ export async function runFlow(deps) { * @returns {Promise>} */ async function collectAnswers(deps, defaults) { - totalSteps = willPromptForConsent(deps) ? 6 : 5; - const shortName = await askProjectName(defaults?.shortName); - const build = await askBuildType(defaults?.build); - const startingPoint = await askStartingPoint(defaults?.startingPoint); - const sampleContent = startingPoint === 'demo' - ? await askSampleContent(defaults?.sampleContent) - : false; - const kitId = deriveKitId({ - build, - startingPoint, - sampleContent - }); + const { starter } = deps; + // Ordered list of the primary steps shown on THIS run. A `--starter` install + // drops the three kit-selection questions; the telemetry-consent step is + // dropped when a preference is already known. The 1-based index into this + // list is each step's number, so the "N/total" counter stays contiguous + // whatever combination of steps is shown. + const order = starter + ? [ 'name', 'db', 'admin' ] + : [ 'name', 'build', 'start', 'db', 'admin' ]; + if (willPromptForConsent(deps)) { + order.push('consent'); + } + totalSteps = order.length; + const stepOf = (key) => order.indexOf(key) + 1; + + const shortName = await askProjectName(stepOf('name'), defaults?.shortName); + + // Kit selection — skipped entirely when a custom starter was supplied. + let build; + let startingPoint; + let sampleContent; + let kitId; + if (starter) { + kitId = CUSTOM_KIT_ID; + } else { + build = await askBuildType(stepOf('build'), defaults?.build); + startingPoint = await askStartingPoint(stepOf('start'), defaults?.startingPoint); + sampleContent = startingPoint === 'demo' + ? await askSampleContent(stepOf('start'), defaults?.sampleContent) + : false; + kitId = deriveKitId({ + build, + startingPoint, + sampleContent + }); + } + + // A custom starter has no registry entry to read seedData from; the sample- + // data step is for the `*-demo-data` kits only. + const seedData = starter ? false : getKit(kitId).seedData; const { dbChoice, dbUri, dbReset, replacesData } = await askDatabase( - shortName, defaults, getKit(kitId).seedData + stepOf('db'), shortName, defaults, seedData ); - const admin = await askAdminAccount(defaults?.admin); - const telemetryConsent = await resolveTelemetryConsent(deps); + const admin = await askAdminAccount(stepOf('admin'), defaults?.admin); + const telemetryConsent = await resolveTelemetryConsent(deps, stepOf('consent')); return { shortName, build, startingPoint, sampleContent, kitId, + starter, dbChoice, dbUri, dbReset, @@ -125,10 +162,13 @@ async function collectAnswers(deps, defaults) { }; } -/** @param {string} [defaultShortName] */ -async function askProjectName(defaultShortName = 'my-project') { +/** + * @param {number} step + * @param {string} [defaultShortName] + */ +async function askProjectName(step, defaultShortName = 'my-project') { return prompts.text({ - message: render.stepLabel(1, totalSteps, 'Project name'), + message: render.stepLabel(step, totalSteps, 'Project name'), placeholder: 'my-project', defaultValue: defaultShortName, // clack runs validate against the *typed* value, then substitutes @@ -140,12 +180,13 @@ async function askProjectName(defaultShortName = 'my-project') { } /** + * @param {number} step * @param {BuildType} [initial] * @returns {Promise} */ -async function askBuildType(initial = 'astro') { +async function askBuildType(step, initial = 'astro') { return prompts.select({ - message: render.stepLabel(2, totalSteps, 'How would you like to build?'), + message: render.stepLabel(step, totalSteps, 'How would you like to build?'), initialValue: initial, options: [ { @@ -163,12 +204,13 @@ async function askBuildType(initial = 'astro') { } /** + * @param {number} step * @param {StartingPoint} [initial] * @returns {Promise} */ -async function askStartingPoint(initial = 'demo') { +async function askStartingPoint(step, initial = 'demo') { const message = - `${render.stepLabel(3, totalSteps, 'Choose a starting point:')}\n` + + `${render.stepLabel(step, totalSteps, 'Choose a starting point:')}\n` + render.dim(`Preview the Demo site: ${link('demoSite')}`); return prompts.select({ message, @@ -188,10 +230,13 @@ async function askStartingPoint(initial = 'demo') { }); } -/** @param {boolean} [initial] */ -async function askSampleContent(initial = false) { +/** + * @param {number} step Parent step number; the prompt renders as `${step}b`. + * @param {boolean} [initial] + */ +async function askSampleContent(step, initial = false) { return prompts.confirm({ - message: render.stepLabel('3b', totalSteps, 'Pre-fill with sample content?'), + message: render.stepLabel(`${step}b`, totalSteps, 'Pre-fill with sample content?'), initialValue: initial }); } @@ -202,6 +247,7 @@ async function askSampleContent(initial = false) { * When `defaults` is provided (restart pass) the previous dbChoice and, * if the user re-picks the same DB, the previous URI are reused. * + * @param {number} step * @param {string} shortName * @param {{ dbChoice?: DbChoice, dbUri?: string }} [defaults] * @param {boolean} seedData True for `*-demo-data` kits; drives the @@ -211,10 +257,10 @@ async function askSampleContent(initial = false) { * replacesData: boolean * }>} */ -async function askDatabase(shortName, defaults, seedData) { +async function askDatabase(step, shortName, defaults, seedData) { let initialChoice = defaults?.dbChoice; while (true) { - const dbChoice = await askDbChoice(initialChoice); + const dbChoice = await askDbChoice(step, initialChoice); if (dbChoice === 'sqlite') { // SQLite lives inside the fresh project dir — no pre-existing DB to // confront here. @@ -228,7 +274,7 @@ async function askDatabase(shortName, defaults, seedData) { ? defaults?.dbUri : undefined; const result = await collectAndVerifyDbUri( - dbChoice, shortName, initialUri, seedData + dbChoice, shortName, initialUri, seedData, step ); if (result.kind === 'ok') { return { @@ -245,12 +291,13 @@ async function askDatabase(shortName, defaults, seedData) { } /** + * @param {number} step * @param {DbChoice} [initial] * @returns {Promise} */ -async function askDbChoice(initial = 'sqlite') { +async function askDbChoice(step, initial = 'sqlite') { const message = - `${render.stepLabel(4, totalSteps, 'Choose a database:')}\n` + + `${render.stepLabel(step, totalSteps, 'Choose a database:')}\n` + render.dim(`Help choosing a database: ${link('dbGuide')}`); const choice = await prompts.select({ message, @@ -320,17 +367,20 @@ function describeConnectionFailure(result) { * previously-entered URI across a flow restart so they don't have to * retype it. * @param {boolean} seedData + * @param {number} step Parent DB step; this sub-step renders + * as `${step}b`. * @returns {Promise< * { kind: 'ok', dbUri: string, dbReset: 'keep' | 'drop', replacesData: boolean } * | { kind: 'switch' } * >} */ -async function collectAndVerifyDbUri(dbChoice, shortName, initialUri, seedData) { +async function collectAndVerifyDbUri(dbChoice, shortName, initialUri, seedData, step) { let candidate = initialUri ?? defaultDbUri(dbChoice, shortName); const hint = dbUriHint(dbChoice); + const label = `${step}b`; const message = hint - ? `${render.stepLabel('4b', totalSteps, 'Connection string:')}\n${render.dim(hint)}` - : render.stepLabel('4b', totalSteps, 'Connection string:'); + ? `${render.stepLabel(label, totalSteps, 'Connection string:')}\n${render.dim(hint)}` + : render.stepLabel(label, totalSteps, 'Connection string:'); while (true) { candidate = await prompts.text({ message, @@ -449,12 +499,13 @@ async function confirmExistingData(count, seedData) { * The password is intentionally NOT carried — re-typed every time, never * echoed back to the terminal as a default. * + * @param {number} step * @param {AdminAccount} [defaults] * @returns {Promise} */ -async function askAdminAccount(defaults) { +async function askAdminAccount(step, defaults) { const username = await prompts.text({ - message: render.stepLabel(5, totalSteps, 'Create your admin account — username or email:'), + message: render.stepLabel(step, totalSteps, 'Create your admin account — username or email:'), defaultValue: defaults?.username ?? 'admin', placeholder: 'admin', validate: validateAdminUsername @@ -508,9 +559,10 @@ function validateAdminPassword(value) { * switch reaches the interactive prompt. * * @param {RunFlowDeps} deps + * @param {number} step * @returns {Promise} */ -async function resolveTelemetryConsent(deps) { +async function resolveTelemetryConsent(deps, step) { const current = telemetryStatus(deps.store, deps.env); if (current.killSwitchOn) { return false; @@ -518,7 +570,7 @@ async function resolveTelemetryConsent(deps) { if (current.storedConsent !== undefined) { return current.storedConsent; } - return askTelemetryConsent(deps); + return askTelemetryConsent(deps, step); } /** @@ -542,14 +594,15 @@ function willPromptForConsent(deps) { * stored preference yet). * * @param {RunFlowDeps} deps + * @param {number} step * @returns {Promise} */ async function askTelemetryConsent({ store, cliVersion, env -}) { +}, step) { while (true) { const choice = await prompts.select({ - message: telemetryPromptMessage(), + message: telemetryPromptMessage(step), initialValue: 'details', options: [ { @@ -587,10 +640,11 @@ async function askTelemetryConsent({ /** Header line + dimmed two-line body (lead + policy link) for the consent * select. Returned as a single string; clack renders newlines below the - * header in the same prompt rail. */ -function telemetryPromptMessage() { + * header in the same prompt rail. + * @param {number} step */ +function telemetryPromptMessage(step) { return [ - render.stepLabel(6, totalSteps, 'Help us improve Apostrophe?'), + render.stepLabel(step, totalSteps, 'Help us improve Apostrophe?'), render.dim('Anonymous usage data — no content, no personal info.'), render.dim(`Policy: ${link('telemetryPolicy')}`) ].join('\n'); @@ -628,14 +682,21 @@ function renderTelemetryPreview({ * @returns {Promise} */ async function reviewAndConfirm(answers, deps) { - render.summary('Review your choices', [ - [ 'Project', answers.shortName ], - [ 'Type', buildLabel(answers.build) ], - [ 'Starter', starterLabel(answers.startingPoint, answers.sampleContent) ], + // A custom `--starter` install has no build/kit to summarize — show the + // starter it will clone from instead of the Type/Starter kit rows. + const rows = [ [ 'Project', answers.shortName ] ]; + if (answers.starter) { + rows.push([ 'Starter', `${answers.starter.source} (custom)` ]); + } else { + rows.push([ 'Type', buildLabel(answers.build) ]); + rows.push([ 'Starter', starterLabel(answers.startingPoint, answers.sampleContent) ]); + } + rows.push( [ 'Database', dbLabel(answers.dbChoice) ], [ 'Username', answers.admin.username ], [ 'Telemetry', telemetryLabel(answers.telemetryConsent, deps) ] - ]); + ); + render.summary('Review your choices', rows); if (answers.dbReset === 'drop') { render.warn('The existing contents of this database will be dropped before install.'); } else if (answers.replacesData) { @@ -702,7 +763,7 @@ function telemetryLabel(consent, deps) { */ export function renderInstallResult(result, answers) { if (result.ok) { - renderSuccess(answers); + renderSuccess(answers, result); } else { renderFailure(answers, result); } @@ -714,23 +775,40 @@ const DEV_PORT = Object.freeze({ standalone: 3000 }); -/** @param {FlowAnswers} answers */ -function renderSuccess(answers) { - const port = DEV_PORT[answers.build]; - const body = [ +/** + * @param {FlowAnswers} answers + * @param {CreateProjectResult} result + */ +function renderSuccess(answers, result) { + // A registry kit's frontend is known from answers.build; a custom starter's + // is only known once the clone is on disk, so it comes back on the result. + const frontend = result.frontend; + const build = frontend === 'astro' + ? 'astro' + : frontend === null + ? 'standalone' + : answers.build; + const port = DEV_PORT[build] ?? DEV_PORT.standalone; + const lines = [ 'Your project is ready.', '', ` cd ${answers.shortName}`, ' npm run dev', '', ` Open: http://localhost:${port}`, - ` Login: http://localhost:${port}/login`, - ` Get Oriented: ${kitGuide(answers.kitId)}`, + ` Login: http://localhost:${port}/login` + ]; + // The per-kit "get oriented" guide exists only for the registry kits; a + // custom starter has none, so that line is dropped (Docs/Discord still show). + if (hasKitGuide(answers.kitId)) { + lines.push(` Get Oriented: ${kitGuide(answers.kitId)}`); + } + lines.push( '', ` Docs: ${link('docs')}`, ` Discord: ${link('discord', { stamp: false })}` - ].join('\n'); - render.note('All set', body); + ); + render.note('All set', lines.join('\n')); render.outro('Happy building!'); } diff --git a/packages/create-apostrophe/src/ui/links.js b/packages/create-apostrophe/src/ui/links.js index bcbd2ff7aa..83ed7bead8 100644 --- a/packages/create-apostrophe/src/ui/links.js +++ b/packages/create-apostrophe/src/ui/links.js @@ -48,6 +48,17 @@ export function link(name, { utmSource = 'cli', stamp = true } = {}) { return stamp ? stampUtm(base, utmSource) : base; } +/** + * Whether a per-kit "get oriented" guide is registered for `kitId`. False for + * a custom `--starter` install (kitId `'custom'`), whose repo has no guide. + * + * @param {string} kitId + * @returns {boolean} + */ +export function hasKitGuide(kitId) { + return Object.hasOwn(KIT_GUIDES, kitId); +} + /** * @param {string} kitId * @param {{ utmSource?: string }} [opts]