@@ -10,6 +10,7 @@ import { parseArgs } from 'node:util';
1010import { createStore as defaultCreateStore } from '../core/store.js' ;
1111import { createProject as defaultCreateProject } from '../core/create-project.js' ;
1212import { isKnownKit } from '../core/kits.js' ;
13+ import { resolveStarter , CUSTOM_KIT_ID } from '../core/starter.js' ;
1314import { assertSafeShortName } from '../core/validate.js' ;
1415import { createTelemetry as defaultCreateTelemetry } from '../telemetry/index.js' ;
1516import { DB_CHOICES } from '../telemetry/schema.js' ;
@@ -40,6 +41,7 @@ const DEFAULT_USERNAME = 'admin';
4041const 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
5961const 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 */
167178export 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,
0 commit comments