Skip to content
Draft
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
15 changes: 12 additions & 3 deletions packages/cli/lib/commands/create.js
Original file line number Diff line number Diff line change
@@ -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 <name-or-url>',
'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);
});
};
52 changes: 44 additions & 8 deletions packages/create-apostrophe/src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand All @@ -58,20 +60,24 @@ 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')}
--password=PASS ${render.dim('Admin password (or use APOS_ADMIN_PASSWORD)')}
--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})`)}
Expand Down Expand Up @@ -147,6 +153,7 @@ export async function main(argv, deps = {}) {
});
}
return runInteractive({
starter: values.starter,
createProject,
createStore,
createTelemetry,
Expand All @@ -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<number>}
*/
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();
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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)}`);
}

Expand Down Expand Up @@ -306,6 +334,7 @@ async function runUnattended(values, {
shortName,
cwd,
kitId,
starter,
dbChoice,
dbUri,
// Unattended never drops a pre-existing DB — that needs interactive
Expand All @@ -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,
Expand Down
34 changes: 28 additions & 6 deletions packages/create-apostrophe/src/core/create-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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;
};

Expand All @@ -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.
Expand All @@ -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', () =>
Expand Down Expand Up @@ -144,7 +165,8 @@ export function makeCreateProject({

return finish({
ok: true,
packageManager
packageManager,
frontend
});
} catch (err) {
if (err instanceof StageError) {
Expand Down
80 changes: 80 additions & 0 deletions packages/create-apostrophe/src/core/starter.js
Original file line number Diff line number Diff line change
@@ -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-<name>`. 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;
}
21 changes: 19 additions & 2 deletions packages/create-apostrophe/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/create-apostrophe/src/telemetry/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>}
*/
const TELEMETRY_KIT_IDS = Object.freeze([ ...KIT_IDS, CUSTOM_KIT_ID ]);

/** @typedef {import('../index.js').KitId} KitId */
/** @typedef {import('../index.js').DbChoice} DbChoice */
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading