Skip to content

Commit d555c04

Browse files
I-am-nothingclaude
andcommitted
feat(module init): apps-service routing, pre-flight slug check, rich UX
Three issues from local dev testing: 1. **404 on POST /v1/modules**: the CLI was talking to the account service (8081), but modules live on the applications service (8082). Split out `MIRRORSTACK_APPS_API_URL` (default `https://apps-api.mirrorstack.ai`, local `http://localhost:8082`) and route `create_module` / `get_module` through it. The `me` lookup still goes to the account service. 2. **No availability check before POST**: easy to forget you already created `media` last week and end up with a confusing 409. New `GET /v1/modules/{slug}` pre-flight catches the common already-owned-by-caller case with a clear error before we POST. Reserved/format checks still happen server-side (no equivalent endpoint, and not worth adding for one CLI consumer). 3. **Plain-text prompts**: replaced ad-hoc `prompt`/`confirm` helpers with `dialoguer` (themed Input + Confirm), `console` (styled output: bold cyan slugs, dim labels, green ✓), and `indicatif` (steady-tick spinner during network calls). `--yes` mode unchanged — no prompts, no spinner-suppression needed since indicatif already detects non-tty stderr. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d21251d commit d555c04

6 files changed

Lines changed: 314 additions & 54 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
# Process env vars override .env if both are set.
33

44
MIRRORSTACK_API_URL=http://localhost:8081
5+
MIRRORSTACK_APPS_API_URL=http://localhost:8082
56
MIRRORSTACK_WEB_URL=http://localhost:3000

Cargo.lock

Lines changed: 104 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ anyhow = "1"
2525
thiserror = "2"
2626
tempfile = "3"
2727
dotenvy = "0.15"
28+
dialoguer = { version = "0.11", default-features = false }
29+
console = "0.15"
30+
indicatif = "0.17"
2831

2932
[dev-dependencies]
3033
mockito = "1.6"

src/api.rs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,57 @@ pub fn me(api_base: &str, access_token: &str) -> Result<Identity, ApiError> {
108108
})
109109
}
110110

111+
/// GET /v1/modules/{slug} — returns the caller's module by slug.
112+
/// `Ok(None)` on 404 (caller has no module with that slug). Note this
113+
/// only checks ownership by the *current* user — module slugs are scoped
114+
/// per-owner, so 404 here does NOT mean the platform-wide name is unique
115+
/// (reserved/format checks still happen on POST).
116+
pub fn get_module(
117+
apps_base: &str,
118+
access_token: &str,
119+
slug: &str,
120+
) -> Result<Option<Module>, ApiError> {
121+
// Slug is pre-validated against `^[a-z][a-z0-9-]{1,38}[a-z0-9]$` before
122+
// this call, so it's URL-safe with no encoding needed.
123+
let endpoint = format!("{}/v1/modules/{}", apps_base.trim_end_matches('/'), slug);
124+
let http = http::client(Duration::from_secs(15))?;
125+
126+
let resp = http
127+
.get(&endpoint)
128+
.bearer_auth(access_token)
129+
.header("Accept", "application/json")
130+
.send()?;
131+
132+
let status = resp.status();
133+
if status.is_success() {
134+
return Ok(Some(resp.json::<Module>()?));
135+
}
136+
if status == reqwest::StatusCode::NOT_FOUND {
137+
return Ok(None);
138+
}
139+
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
140+
return Err(ApiError::Unauthenticated);
141+
}
142+
let mut body = Vec::with_capacity(1024);
143+
resp.take(MAX_RESPONSE_BYTES)
144+
.read_to_end(&mut body)
145+
.map_err(|e| ApiError::Unexpected {
146+
status: status.as_u16(),
147+
body: format!("(read body failed: {e})"),
148+
})?;
149+
Err(ApiError::Unexpected {
150+
status: status.as_u16(),
151+
body: String::from_utf8_lossy(&body).into_owned(),
152+
})
153+
}
154+
111155
/// POST /v1/modules — create a developer-owned module.
112156
pub fn create_module(
113-
api_base: &str,
157+
apps_base: &str,
114158
access_token: &str,
115159
input: &CreateModuleInput,
116160
) -> Result<Module, ApiError> {
117-
let endpoint = format!("{}/v1/modules", api_base.trim_end_matches('/'));
161+
let endpoint = format!("{}/v1/modules", apps_base.trim_end_matches('/'));
118162
let http = http::client(Duration::from_secs(15))?;
119163

120164
let resp = http
@@ -234,6 +278,41 @@ mod tests {
234278
assert_eq!(m.id, "m-1");
235279
}
236280

281+
#[test]
282+
fn get_module_200_returns_some() {
283+
let mut server = Server::new();
284+
let _m = server
285+
.mock("GET", "/v1/modules/media")
286+
.match_header("authorization", "Bearer AT")
287+
.with_status(200)
288+
.with_body(
289+
json!({
290+
"id": "m-1",
291+
"name": "Media",
292+
"slug": "media",
293+
"owner_id": "u-1",
294+
})
295+
.to_string(),
296+
)
297+
.create();
298+
299+
let m = get_module(&server.url(), "AT", "media").expect("ok");
300+
assert!(m.is_some());
301+
assert_eq!(m.unwrap().slug, "media");
302+
}
303+
304+
#[test]
305+
fn get_module_404_returns_none() {
306+
let mut server = Server::new();
307+
let _m = server
308+
.mock("GET", "/v1/modules/none")
309+
.with_status(404)
310+
.create();
311+
312+
let m = get_module(&server.url(), "AT", "none").expect("ok");
313+
assert!(m.is_none());
314+
}
315+
237316
#[test]
238317
fn create_module_409_surfaces_code() {
239318
let mut server = Server::new();

src/commands/mod.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@ mod login;
88
mod module;
99
mod whoami;
1010

11-
/// Default api-platform host. Override per-invocation with
11+
/// Default api-platform account-service host. Override per-invocation with
1212
/// `MIRRORSTACK_API_URL` (or via `.env`).
1313
pub(crate) const DEFAULT_API_BASE: &str = "https://api.mirrorstack.ai";
1414

15+
/// Default api-platform applications-service host. Modules and apps live
16+
/// here (separate Lambda from the account service). Override with
17+
/// `MIRRORSTACK_APPS_API_URL`.
18+
pub(crate) const DEFAULT_APPS_API_BASE: &str = "https://apps-api.mirrorstack.ai";
19+
1520
/// Default web-account host. Override with `MIRRORSTACK_WEB_URL`.
1621
pub(crate) const DEFAULT_WEB_BASE: &str = "https://account.mirrorstack.ai";
1722

1823
pub(crate) const ENV_API_URL: &str = "MIRRORSTACK_API_URL";
24+
pub(crate) const ENV_APPS_API_URL: &str = "MIRRORSTACK_APPS_API_URL";
1925
pub(crate) const ENV_WEB_URL: &str = "MIRRORSTACK_WEB_URL";
2026

2127
/// Official command-line tool for the MirrorStack platform.

0 commit comments

Comments
 (0)