Skip to content

Commit 8be0bda

Browse files
I-am-nothingclaude
andcommitted
feat(module): scaffold local source tree on init
`mirrorstack module init` now does both halves of "create a module": register on the platform AND scaffold a local Go source tree from the SDK template. Without this, every developer's first `mirrorstack dev` (future) would have nothing to launch. Two new flags: - --no-scaffold: register only, skip filesystem (CI / re-registration) - --dir <path>: override default ./<slug>/ (use --dir . for cwd) The scaffold target's writability is checked before any remote POST so we never leave a registered module without a local tree because of e.g. a non-empty target dir. Each file is written via OpenOptions::create_new so a file slipped in during the API round-trip surfaces as an error instead of a silent overwrite. Templates are vendored under templates/module/ and embedded via include_str!. Source of truth for the shape is app-module-sdk's examples/template/; vendored copies must be re-synced when the SDK changes shape (no automatic mirror yet). go.mod.tmpl pins github.com/mirrorstack-ai/app-module-sdk v0.1.0 — the SDK's first published tag — so scaffolded modules `go mod tidy` cleanly from anywhere on disk, no replace directive needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 722b818 commit 8be0bda

7 files changed

Lines changed: 485 additions & 51 deletions

File tree

Lines changed: 133 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
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
97
use std::io::IsTerminal;
8+
use std::path::{Path, PathBuf};
109
use std::time::Duration;
1110

12-
use anyhow::{Result, anyhow};
11+
use anyhow::{Context, Result, anyhow};
1312
use clap::{Args, Subcommand};
1413
use console::style;
1514
use dialoguer::{Confirm, Input, theme::ColorfulTheme};
@@ -19,6 +18,8 @@ use crate::api::{self, ApiError, CreateModuleInput};
1918
use crate::credentials;
2019
use crate::http;
2120

21+
mod scaffold;
22+
2223
use 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

5967
pub 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+
165233
fn 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

Comments
 (0)