Skip to content

Commit 508a4e7

Browse files
committed
PRO-9557 bring back "starter"
1 parent a70541a commit 508a4e7

8 files changed

Lines changed: 344 additions & 81 deletions

File tree

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
// Thin delegate: hands off to create-apostrophe's guided installer. Owns
2-
// no UI of its own — Commander supplies `--help` for this subcommand.
2+
// no UI of its own — Commander supplies `--help` for this subcommand. The
3+
// only thing it forwards is `--starter`, so a developer can name a custom
4+
// starter (kit name, org/repo, or git URL) and skip the kit prompt.
35

46
module.exports = function (program) {
5-
program
7+
const command = program
68
.command('create')
79
.description('Create an Apostrophe project — launches the guided installer.')
10+
.option(
11+
'--starter <name-or-url>',
12+
'Start from a specific starter instead of choosing a kit interactively. ' +
13+
'Accepts the short name of an official starter kit (e.g. "ecommerce"), ' +
14+
'an org/repo (e.g. "myorg/my-starter"), or a full git URL.'
15+
)
816
.action(async function () {
17+
const { starter } = command.opts();
918
const { runInteractive } = await import('create-apostrophe');
10-
const code = await runInteractive();
19+
const code = await runInteractive({ starter });
1120
process.exit(typeof code === 'number' ? code : 0);
1221
});
1322
};

packages/create-apostrophe/src/cli/main.js

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { parseArgs } from 'node:util';
1010
import { createStore as defaultCreateStore } from '../core/store.js';
1111
import { createProject as defaultCreateProject } from '../core/create-project.js';
1212
import { isKnownKit } from '../core/kits.js';
13+
import { resolveStarter, CUSTOM_KIT_ID } from '../core/starter.js';
1314
import { assertSafeShortName } from '../core/validate.js';
1415
import { createTelemetry as defaultCreateTelemetry } from '../telemetry/index.js';
1516
import { DB_CHOICES } from '../telemetry/schema.js';
@@ -40,6 +41,7 @@ const DEFAULT_USERNAME = 'admin';
4041
const OPTION_SPEC = Object.freeze({
4142
'project-name': { type: 'string' },
4243
kit: { type: 'string' },
44+
starter: { type: 'string' },
4345
db: { type: 'string' },
4446
'db-uri': { type: 'string' },
4547
username: { type: 'string' },
@@ -58,20 +60,24 @@ const OPTION_SPEC = Object.freeze({
5860

5961
const USAGE = `${render.bold('Usage:')}
6062
npm create apostrophe@latest ${render.dim('Run the guided installer')}
63+
npm create apostrophe@latest -- --starter=NAME|URL ${render.dim('Start from a custom starter (skips the kit prompt)')}
6164
npm create apostrophe@latest -- --unattended [flags] ${render.dim('Run without prompts')}
6265
npm create apostrophe@latest -- telemetry [status|on|off|preview] ${render.dim('Manage telemetry preference')}
6366
npm create apostrophe@latest -- --help | --version
6467
6568
${render.dim('(everything after the `--` is forwarded to the installer; npm consumes args without it)')}
6669
70+
${render.bold('Starter selection')} ${render.dim('(either path; --starter overrides --kit)')}:
71+
--starter=NAME|ORG/REPO|URL ${render.dim('Custom starter: a starter-kit name (e.g. ecommerce), an org/repo, or a git URL')}
72+
6773
${render.bold('Unattended flags')} ${render.dim('(--unattended is the only trigger for the headless path)')}:
6874
${render.bold('Required:')}
6975
--project-name=NAME ${render.dim('Project directory name')}
7076
--password=PASS ${render.dim('Admin password (or use APOS_ADMIN_PASSWORD)')}
7177
--telemetry=on|off ${render.dim('Telemetry consent (no default — explicit choice)')}
7278
7379
${render.bold('Defaulted (override to change):')}
74-
--kit=KITID ${render.dim(`Starter kit id (default: ${DEFAULT_KIT})`)}
80+
--kit=KITID ${render.dim(`Starter kit id (default: ${DEFAULT_KIT}); ignored when --starter is set`)}
7581
--db=sqlite|mongodb|postgres ${render.dim(`Database choice (default: ${DEFAULT_DB})`)}
7682
--db-uri=URI ${render.dim('Connection string (required when --db is mongodb or postgres)')}
7783
--username=NAME ${render.dim(`Admin username or email (default: ${DEFAULT_USERNAME})`)}
@@ -147,6 +153,7 @@ export async function main(argv, deps = {}) {
147153
});
148154
}
149155
return runInteractive({
156+
starter: values.starter,
150157
createProject,
151158
createStore,
152159
createTelemetry,
@@ -161,16 +168,24 @@ export async function main(argv, deps = {}) {
161168
* flow without going through argv parsing — their command framework keeps
162169
* ownership of help text and subcommand routing.
163170
*
164-
* @param {MainDeps} [deps]
171+
* `starter` is the only non-injectable option here: a raw `--starter` value
172+
* (starter-kit name, org/repo, or git URL). When present, it's resolved to a
173+
* clone URL and the guided flow skips the kit-selection questions entirely.
174+
*
175+
* @param {MainDeps & { starter?: string }} [deps]
165176
* @returns {Promise<number>}
166177
*/
167178
export async function runInteractive(deps = {}) {
168179
const {
180+
starter: rawStarter,
169181
createProject = defaultCreateProject,
170182
createStore = defaultCreateStore,
171183
createTelemetry = defaultCreateTelemetry,
172184
runFlow = defaultRunFlow
173185
} = deps;
186+
const starter = (typeof rawStarter === 'string' && rawStarter.trim().length > 0)
187+
? resolveStarter(rawStarter)
188+
: undefined;
174189
const cwd = process.cwd();
175190
const env = process.env;
176191
const store = createStore();
@@ -180,7 +195,8 @@ export async function runInteractive(deps = {}) {
180195
answers = await runFlow({
181196
store,
182197
cliVersion: CLI_VERSION,
183-
env
198+
env,
199+
starter
184200
});
185201
} catch (err) {
186202
if (err instanceof UserCancelled) {
@@ -201,6 +217,7 @@ export async function runInteractive(deps = {}) {
201217
shortName: answers.shortName,
202218
cwd,
203219
kitId: answers.kitId,
220+
starter,
204221
dbChoice: answers.dbChoice,
205222
dbUri: answers.dbUri,
206223
dbReset: answers.dbReset,
@@ -245,8 +262,19 @@ async function runUnattended(values, {
245262
}
246263
}
247264

248-
const kitId = values.kit ?? DEFAULT_KIT;
249-
if (!isKnownKit(kitId)) {
265+
// --starter overrides --kit: clone an arbitrary repo instead of a registry
266+
// kit. Validate the empty and both-given cases up front.
267+
const usingStarter =
268+
typeof values.starter === 'string' && values.starter.trim().length > 0;
269+
if (values.starter !== undefined && !usingStarter) {
270+
issues.push('--starter cannot be empty');
271+
}
272+
if (usingStarter && values.kit !== undefined) {
273+
issues.push('Use either --kit or --starter, not both');
274+
}
275+
const starter = usingStarter ? resolveStarter(values.starter) : undefined;
276+
const kitId = usingStarter ? CUSTOM_KIT_ID : (values.kit ?? DEFAULT_KIT);
277+
if (!usingStarter && !isKnownKit(kitId)) {
250278
issues.push(`Unknown --kit: ${JSON.stringify(kitId)}`);
251279
}
252280

@@ -306,6 +334,7 @@ async function runUnattended(values, {
306334
shortName,
307335
cwd,
308336
kitId,
337+
starter,
309338
dbChoice,
310339
dbUri,
311340
// Unattended never drops a pre-existing DB — that needs interactive
@@ -324,12 +353,19 @@ async function runUnattended(values, {
324353
logger
325354
});
326355

356+
// A custom starter has no kit to derive build/startingPoint from; its layout
357+
// (and thus the dev-server port the success screen shows) comes back on the
358+
// result. The kit-derived fields are filled only for a registry kit.
327359
/** @type {import('../ui/flow.js').FlowAnswers} */
328360
const answers = {
329361
shortName,
330-
build: kitId.startsWith('apostrophe-astro') ? 'astro' : 'standalone',
331-
startingPoint: kitId.endsWith('-essentials') ? 'essentials' : 'demo',
332-
sampleContent: kitId.endsWith('-demo-data'),
362+
...(usingStarter
363+
? { starter }
364+
: {
365+
build: kitId.startsWith('apostrophe-astro') ? 'astro' : 'standalone',
366+
startingPoint: kitId.endsWith('-essentials') ? 'essentials' : 'demo',
367+
sampleContent: kitId.endsWith('-demo-data')
368+
}),
333369
kitId,
334370
dbChoice,
335371
dbUri,

packages/create-apostrophe/src/core/create-project.js

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// unexpected throws propagate to the caller (which converts + emits).
55

66
import { getKit } from './kits.js';
7+
import { detectFrontend } from './starter.js';
78
import { detectPackageManager, assertSupportedPackageManager } from './pm.js';
89
import { StageError, UnsupportedPackageManagerError } from './errors.js';
910
import { checkConnection, dropDatabase } from './db.js';
@@ -30,8 +31,11 @@ export function makeCreateProject({
3031
};
3132

3233
// Single terminal point: build the result, emit exactly one event, return.
34+
// `frontend` is the resolved layout (null = standalone), known only once a
35+
// step has determined it; it rides on the returned result but is added
36+
// AFTER the telemetry payload is sliced off, so it never reaches the wire.
3337
const finish = ({
34-
ok, packageManager, failStage, errorCode
38+
ok, packageManager, failStage, errorCode, frontend
3539
}) => {
3640
const result = {
3741
ok,
@@ -47,6 +51,9 @@ export function makeCreateProject({
4751
}
4852
const { ok: _ok, ...payload } = result;
4953
telemetry.event(ok ? 'install_success' : 'install_fail', payload);
54+
if (frontend !== undefined) {
55+
result.frontend = frontend;
56+
}
5057
return result;
5158
};
5259

@@ -66,9 +73,17 @@ export function makeCreateProject({
6673
throw err;
6774
}
6875

69-
// Validation errors (unknown kit, unsafe shortName, …) propagate as
70-
// unexpected throws — the caller converts and emits.
71-
const kit = getKit(options.kitId);
76+
// A `--starter` install escapes the kit registry: the repo is the resolved
77+
// starter URL and the frontend is detected from the clone, not declared.
78+
// Otherwise resolve the registry kit — validation errors (unknown kit,
79+
// unsafe shortName, …) propagate as unexpected throws; the caller converts.
80+
const usingStarter = Boolean(options.starter);
81+
const kit = usingStarter
82+
? {
83+
repo: options.starter.repo,
84+
seedData: false
85+
}
86+
: getKit(options.kitId);
7287

7388
// The task handle is passed to the step fn so long-running steps can
7489
// report progress (task.progress?.(...)); steps that don't, ignore it.
@@ -92,11 +107,17 @@ export function makeCreateProject({
92107
cwd: options.cwd
93108
}));
94109

110+
// A registry kit declares its frontend; a custom starter's is detected
111+
// from the cloned layout (a `backend/` directory means hybrid Astro).
112+
const declaredFrontend = usingStarter
113+
? detectFrontend(projectDir)
114+
: kit.frontend;
115+
95116
const { frontend, appRoot } = await step('Configuring project', () =>
96117
scaffold({
97118
projectDir,
98119
shortName: options.shortName,
99-
frontend: kit.frontend
120+
frontend: declaredFrontend
100121
}));
101122

102123
await step('Installing dependencies', () =>
@@ -144,7 +165,8 @@ export function makeCreateProject({
144165

145166
return finish({
146167
ok: true,
147-
packageManager
168+
packageManager,
169+
frontend
148170
});
149171
} catch (err) {
150172
if (err instanceof StageError) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Custom-starter support for the `--starter` option. A "starter" escapes the
2+
// fixed kit registry (core/kits.js): instead of one of the six known kits, the
3+
// installer clones an arbitrary git repository the developer names directly.
4+
// This module owns the two things that differ from a registry kit — turning the
5+
// `--starter` value into a clone URL, and discovering the project layout after
6+
// the clone (a registry kit declares its frontend up front; a custom one can't).
7+
8+
import { existsSync } from 'node:fs';
9+
import { join } from 'node:path';
10+
11+
/** @typedef {import('./kits.js').Frontend} Frontend */
12+
13+
/**
14+
* Telemetry kitId reported for a custom `--starter` install. Deliberately NOT a
15+
* member of the kit registry — `getKit`/`isKnownKit` still reject it — so the
16+
* six-kit invariants hold; the telemetry schema allowlists it on its own so the
17+
* "which kit?" question stays answerable as "a custom one".
18+
* @type {'custom'}
19+
*/
20+
export const CUSTOM_KIT_ID = 'custom';
21+
22+
/**
23+
* A `--starter` value resolved to something clonable.
24+
* @typedef {object} ResolvedStarter
25+
* @property {string} repo Clone URL handed to `git clone`.
26+
* @property {string} source The original `--starter` input, kept verbatim for
27+
* display in the review/summary screens.
28+
*/
29+
30+
/**
31+
* Resolve a `--starter` value to a git clone URL. Three forms, matched in order
32+
* (the grammar the legacy `@apostrophecms/cli` accepted, preserved exactly):
33+
*
34+
* 1. A URL with a scheme (`https:`, `git:`, …) — used verbatim.
35+
* e.g. `https://github.com/apostrophecms/apostrophe-open-museum.git`
36+
* 2. A value containing `/` — treated as a GitHub `org/repo` (a leading `/`
37+
* is dropped). e.g. `mycoolcompany/my-starter` →
38+
* `https://github.com/mycoolcompany/my-starter`
39+
* 3. A bare name — a shorthand for an official starter kit, expanded to
40+
* `starter-kit-<name>`. e.g. `ecommerce` →
41+
* `https://github.com/apostrophecms/starter-kit-ecommerce.git`
42+
*
43+
* @param {string} input The raw `--starter` value.
44+
* @returns {ResolvedStarter}
45+
* @throws {TypeError} when `input` is missing or blank.
46+
*/
47+
export function resolveStarter(input) {
48+
if (typeof input !== 'string' || input.trim().length === 0) {
49+
throw new TypeError(
50+
'A --starter value is required (a starter-kit name, an org/repo, or a git URL).'
51+
);
52+
}
53+
const value = input.trim();
54+
let repo;
55+
if (/^\w+:/.test(value)) {
56+
repo = value;
57+
} else if (value.includes('/')) {
58+
repo = `https://github.com/${value.startsWith('/') ? value.slice(1) : value}`;
59+
} else {
60+
repo = `https://github.com/apostrophecms/starter-kit-${value}.git`;
61+
}
62+
return {
63+
repo,
64+
source: value
65+
};
66+
}
67+
68+
/**
69+
* Discover a freshly cloned custom starter's frontend. A hybrid Astro project
70+
* nests the Apostrophe app under `backend/`; a standalone project keeps it at
71+
* the root. Mirrors the legacy CLI's auto-detection, and the README's promise
72+
* that hybrid projects are "automatically detected by the presence of a
73+
* `backend/` directory."
74+
*
75+
* @param {string} projectDir The cloned project's root.
76+
* @returns {Frontend} `'astro'` when a `backend/` directory exists, else `null`.
77+
*/
78+
export function detectFrontend(projectDir) {
79+
return existsSync(join(projectDir, 'backend')) ? 'astro' : null;
80+
}

packages/create-apostrophe/src/index.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,16 @@
7676
* captures it once and threads
7777
* it in; steps must not call
7878
* process.cwd() on their own.
79-
* @property {KitId} kitId Starter kit.
79+
* @property {KitId | 'custom'} kitId Starter kit, or `'custom'` for a
80+
* `--starter` install (escapes the
81+
* kit registry; see `starter`).
82+
* @property {import('./core/starter.js').ResolvedStarter} [starter]
83+
* A custom starter resolved from
84+
* `--starter`. When present it
85+
* overrides `kitId` for cloning:
86+
* the repo is cloned directly and
87+
* the frontend is detected from the
88+
* clone rather than the registry.
8089
* @property {DbChoice} dbChoice Database selection.
8190
* @property {string} [dbUri] Connection string for
8291
* mongodb/postgres. Never sent to
@@ -104,10 +113,18 @@
104113
* result.
105114
* @typedef {object} CreateProjectResult
106115
* @property {boolean} ok Whether the project was created.
107-
* @property {KitId} kitId
116+
* @property {KitId | 'custom'} kitId Echo of the input (`'custom'` for
117+
* a `--starter` install).
108118
* @property {DbChoice} dbChoice Echo of the input.
109119
* @property {PackageManager} packageManager Resolved value.
110120
* @property {number} durationMs `Date.now() - options.confirmedAt`.
121+
* @property {import('./core/kits.js').Frontend} [frontend] Resolved layout
122+
* (`'astro'` | `null`). Present only
123+
* on success; never sent to
124+
* telemetry. Lets the UI pick the
125+
* right dev-server port for a custom
126+
* starter whose frontend wasn't known
127+
* until after the clone.
111128
* @property {FailStage} [failStage] Present iff `ok === false`;
112129
* `null` for a preflight failure.
113130
* @property {string} [errorCode] Present only for known,

packages/create-apostrophe/src/telemetry/schema.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55
// upstream caller passing extra keys.
66

77
import { KIT_IDS } from '../core/kits.js';
8+
import { CUSTOM_KIT_ID } from '../core/starter.js';
9+
10+
/**
11+
* kitId values accepted on the wire: the six registry kits plus the `'custom'`
12+
* sentinel a `--starter` install reports. Kept separate from {@link KIT_IDS} so
13+
* the registry stays exactly the six known kits while telemetry can still say
14+
* "a custom starter was used" without naming the (potentially private) repo.
15+
* @type {ReadonlyArray<string>}
16+
*/
17+
const TELEMETRY_KIT_IDS = Object.freeze([ ...KIT_IDS, CUSTOM_KIT_ID ]);
818

919
/** @typedef {import('../index.js').KitId} KitId */
1020
/** @typedef {import('../index.js').DbChoice} DbChoice */
@@ -209,7 +219,7 @@ function requireFiniteNumber(name, value) {
209219
export function buildPayload(event, input) {
210220
requireOneOf('event', event, EVENT_NAMES);
211221
requireString('cliVersion', input.cliVersion);
212-
requireOneOf('kitId', input.kitId, KIT_IDS);
222+
requireOneOf('kitId', input.kitId, TELEMETRY_KIT_IDS);
213223
requireOneOf('dbChoice', input.dbChoice, DB_CHOICES);
214224
requireOneOf('packageManager', input.packageManager, PACKAGE_MANAGERS);
215225
requireFiniteNumber('durationMs', input.durationMs);

0 commit comments

Comments
 (0)