11//! `mirrorstack module …` — developer module management.
22//!
3- //! Today: only `module init` (register a module on the platform). Filesystem
4- //! scaffolding (templates, SDK pull) is intentionally out of scope per the
5- //! current product direction — modules are developed locally with whatever
6- //! tooling the developer prefers, and `mirrorstack dev` (future) opens a WSS
7- //! tunnel into the platform.
3+ //! Today: `module init` registers the module on the platform AND scaffolds a
4+ //! local source tree from the SDK template. The scaffolded tree is what
5+ //! `mirrorstack dev` (future) launches and tunnels into the platform.
86
97use std:: io:: IsTerminal ;
8+ use std:: path:: { Path , PathBuf } ;
109use std:: time:: Duration ;
1110
12- use anyhow:: { Result , anyhow} ;
11+ use anyhow:: { Context , Result , anyhow} ;
1312use clap:: { Args , Subcommand } ;
1413use console:: style;
1514use dialoguer:: { Confirm , Input , theme:: ColorfulTheme } ;
@@ -19,6 +18,8 @@ use crate::api::{self, ApiError, CreateModuleInput};
1918use crate :: credentials;
2019use crate :: http;
2120
21+ mod scaffold;
22+
2223use super :: {
2324 DEFAULT_API_BASE , DEFAULT_APPS_API_BASE , DEFAULT_WEB_BASE , ENV_API_URL , ENV_APPS_API_URL ,
2425 ENV_WEB_URL , ok_mark, resolve_base,
@@ -54,6 +55,13 @@ pub struct InitArgs {
5455 /// continue. Useful for re-running init in CI without conditional logic.
5556 #[ arg( long) ]
5657 used : bool ,
58+ /// Skip filesystem scaffolding. Only register the module on the platform.
59+ #[ arg( long) ]
60+ no_scaffold : bool ,
61+ /// Where to scaffold the new module source tree. Defaults to ./<slug>/
62+ /// in the current directory. Pass `.` to scaffold into the cwd directly.
63+ #[ arg( long) ]
64+ dir : Option < PathBuf > ,
5765}
5866
5967pub fn run ( args : ModuleArgs ) -> Result < ( ) > {
@@ -93,75 +101,135 @@ fn init(args: InitArgs) -> Result<()> {
93101 ) ) ;
94102 }
95103
104+ // Pre-flight: scaffold target. If the caller wants scaffolding, fail now
105+ // (before any remote POST) so we never leave a registered module with no
106+ // local tree because of e.g. a non-empty target dir.
107+ let scaffold_target = if args. no_scaffold {
108+ None
109+ } else {
110+ let target = resolve_scaffold_target ( args. dir . as_deref ( ) , & slug) ;
111+ scaffold:: ensure_target_writable ( & target) ?;
112+ Some ( target)
113+ } ;
114+
96115 // Pre-flight: reject early if the caller already owns this slug. Catches
97116 // the common "I forgot I made this last week" case before we POST.
98117 // Reserved/invalid still surface server-side from the POST below.
99118 let pre_check = with_spinner ( "Checking availability…" , || {
100119 api:: get_module ( & client, & apps_base, & creds. access_token , & slug)
101120 } ) ;
102- match pre_check {
121+ // Determine whether we still need to POST. `--used` + already-exists
122+ // short-circuits without prompting for confirmation again.
123+ let needs_create = match pre_check {
103124 Ok ( Some ( existing) ) if args. used => {
104125 print_already_exists ( username, & existing. slug , Some ( & existing. id ) ) ;
105- return Ok ( ( ) ) ;
126+ false
106127 }
107128 Ok ( Some ( _) ) => {
108129 return Err ( anyhow ! (
109130 "@{username}/{slug} already exists (pass --used to ignore when re-running)"
110131 ) ) ;
111132 }
112- Ok ( None ) => { }
133+ Ok ( None ) => true ,
113134 Err ( ApiError :: Unauthenticated ) => return Err ( session_expired ( ) ) ,
114135 Err ( e) => return Err ( e. into ( ) ) ,
115- }
136+ } ;
116137
117- if !args. yes {
118- eprintln ! ( ) ;
119- eprintln ! ( " {} {}" , style( "Module:" ) . dim( ) , style( & name) . bold( ) ) ;
120- eprintln ! (
121- " {} {}" ,
122- style( "Slug:" ) . dim( ) ,
123- style( format!( "@{username}/{slug}" ) ) . cyan( ) . bold( )
124- ) ;
125- let confirmed = Confirm :: with_theme ( & theme)
126- . with_prompt ( "Create this module?" )
127- . default ( true )
128- . interact ( ) ?;
129- if !confirmed {
130- eprintln ! ( "{}" , style( "aborted." ) . yellow( ) ) ;
131- return Ok ( ( ) ) ;
138+ if needs_create {
139+ if !args. yes {
140+ eprintln ! ( ) ;
141+ eprintln ! ( " {} {}" , style( "Module:" ) . dim( ) , style( & name) . bold( ) ) ;
142+ eprintln ! (
143+ " {} {}" ,
144+ style( "Slug:" ) . dim( ) ,
145+ style( format!( "@{username}/{slug}" ) ) . cyan( ) . bold( )
146+ ) ;
147+ let confirmed = Confirm :: with_theme ( & theme)
148+ . with_prompt ( "Create this module?" )
149+ . default ( true )
150+ . interact ( ) ?;
151+ if !confirmed {
152+ eprintln ! ( "{}" , style( "aborted." ) . yellow( ) ) ;
153+ return Ok ( ( ) ) ;
154+ }
155+ }
156+
157+ let create_result = with_spinner ( "Creating module…" , || {
158+ api:: create_module (
159+ & client,
160+ & apps_base,
161+ & creds. access_token ,
162+ & CreateModuleInput {
163+ name : & name,
164+ slug : & slug,
165+ } ,
166+ )
167+ } ) ;
168+
169+ match create_result {
170+ Ok ( m) => print_created ( username, & m. slug , & m. id ) ,
171+ Err ( ApiError :: Server { code, .. } ) if code == "slug_taken" && args. used => {
172+ print_already_exists ( username, & slug, None ) ;
173+ }
174+ Err ( ApiError :: Server { code, message, .. } ) => {
175+ return Err ( anyhow ! (
176+ "{code}: {message}{hint}" ,
177+ hint = slug_error_hint( & code)
178+ ) ) ;
179+ }
180+ Err ( ApiError :: Unauthenticated ) => return Err ( session_expired ( ) ) ,
181+ Err ( e) => return Err ( e. into ( ) ) ,
132182 }
133183 }
134184
135- let create_result = with_spinner ( "Creating module…" , || {
136- api:: create_module (
137- & client,
138- & apps_base,
139- & creds. access_token ,
140- & CreateModuleInput {
141- name : & name,
142- slug : & slug,
143- } ,
144- )
145- } ) ;
185+ scaffold_if_requested (
186+ scaffold_target. as_deref ( ) ,
187+ & scaffold:: Inputs {
188+ slug : & slug,
189+ name : & name,
190+ } ,
191+ )
192+ }
146193
147- match create_result {
148- Ok ( m) => {
149- print_created ( username, & m. slug , & m. id ) ;
150- Ok ( ( ) )
151- }
152- Err ( ApiError :: Server { code, .. } ) if code == "slug_taken" && args. used => {
153- print_already_exists ( username, & slug, None ) ;
154- Ok ( ( ) )
155- }
156- Err ( ApiError :: Server { code, message, .. } ) => Err ( anyhow ! (
157- "{code}: {message}{hint}" ,
158- hint = slug_error_hint( & code)
159- ) ) ,
160- Err ( ApiError :: Unauthenticated ) => Err ( session_expired ( ) ) ,
161- Err ( e) => Err ( e. into ( ) ) ,
194+ /// Resolve the target dir for scaffolding. `--dir <path>` wins; otherwise
195+ /// default to `./<slug>/`. Pass `--dir .` to scaffold into the cwd directly.
196+ fn resolve_scaffold_target ( dir : Option < & Path > , slug : & str ) -> PathBuf {
197+ match dir {
198+ Some ( d) => d. to_path_buf ( ) ,
199+ None => PathBuf :: from ( slug) ,
162200 }
163201}
164202
203+ fn scaffold_if_requested ( target : Option < & Path > , inputs : & scaffold:: Inputs < ' _ > ) -> Result < ( ) > {
204+ let Some ( target) = target else { return Ok ( ( ) ) } ;
205+ scaffold:: write_tree ( target, inputs)
206+ . with_context ( || format ! ( "scaffold into {}" , target. display( ) ) ) ?;
207+ print_scaffold_summary ( target) ;
208+ Ok ( ( ) )
209+ }
210+
211+ fn print_scaffold_summary ( target : & Path ) {
212+ eprintln ! (
213+ "{} scaffolded {}" ,
214+ ok_mark( ) ,
215+ style( target. display( ) ) . cyan( ) . bold( )
216+ ) ;
217+ let next = if is_cwd ( target) {
218+ "go mod tidy && mirrorstack dev" . to_string ( )
219+ } else {
220+ format ! ( "cd {} && go mod tidy && mirrorstack dev" , target. display( ) )
221+ } ;
222+ eprintln ! ( " {} {next}" , style( "next:" ) . dim( ) ) ;
223+ }
224+
225+ /// Robust check for "scaffold into the current dir." Catches both `.` and
226+ /// `./` (and other normalizations of the same path) — bare `target.as_os_str()
227+ /// == "."` would miss `./` and trailing-slash variants.
228+ fn is_cwd ( target : & Path ) -> bool {
229+ let mut comps = target. components ( ) ;
230+ matches ! ( comps. next( ) , Some ( std:: path:: Component :: CurDir ) ) && comps. next ( ) . is_none ( )
231+ }
232+
165233fn session_expired ( ) -> anyhow:: Error {
166234 anyhow ! ( "session expired. Run `mirrorstack login` to sign in again." )
167235}
@@ -352,4 +420,18 @@ mod tests {
352420 fn slug_error_hint_for_taken ( ) {
353421 assert ! ( slug_error_hint( "slug_taken" ) . contains( "--used" ) ) ;
354422 }
423+
424+ #[ test]
425+ fn is_cwd_matches_cwd_variants ( ) {
426+ assert ! ( is_cwd( Path :: new( "." ) ) ) ;
427+ assert ! ( is_cwd( Path :: new( "./" ) ) ) ;
428+ }
429+
430+ #[ test]
431+ fn is_cwd_rejects_non_cwd_paths ( ) {
432+ assert ! ( !is_cwd( Path :: new( "media" ) ) ) ;
433+ assert ! ( !is_cwd( Path :: new( "./media" ) ) ) ;
434+ assert ! ( !is_cwd( Path :: new( "a/b" ) ) ) ;
435+ assert ! ( !is_cwd( Path :: new( "/tmp" ) ) ) ;
436+ }
355437}
0 commit comments