Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 78 additions & 51 deletions src/commands/module/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,79 +118,106 @@ fn init(args: InitArgs) -> Result<()> {
let pre_check = with_spinner("Checking availability…", || {
api::get_module(&client, &apps_base, &creds.access_token, &slug)
});
// Determine whether we still need to POST. `--used` + already-exists
// short-circuits without prompting for confirmation again.
let needs_create = match pre_check {
// Capture the platform-assigned module ID so scaffolding can substitute
// it into Config.ID and the table prefix. Sourced from whichever branch
// succeeds (`--used` + already-exists, fresh create, or a race-refetch).
let module_id: String = match pre_check {
Ok(Some(existing)) if args.used => {
print_already_exists(username, &existing.slug, Some(&existing.id));
false
existing.id
}
Ok(Some(_)) => {
return Err(anyhow!(
"@{username}/{slug} already exists (pass --used to ignore when re-running)"
));
}
Ok(None) => true,
Err(ApiError::Unauthenticated) => return Err(session_expired()),
Err(e) => return Err(e.into()),
};

if needs_create {
if !args.yes {
eprintln!();
eprintln!(" {} {}", style("Module:").dim(), style(&name).bold());
eprintln!(
" {} {}",
style("Slug:").dim(),
style(format!("@{username}/{slug}")).cyan().bold()
);
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Create this module?")
.default(true)
.interact()?;
if !confirmed {
eprintln!("{}", style("aborted.").yellow());
return Ok(());
Ok(None) => {
if !args.yes {
eprintln!();
eprintln!(" {} {}", style("Module:").dim(), style(&name).bold());
eprintln!(
" {} {}",
style("Slug:").dim(),
style(format!("@{username}/{slug}")).cyan().bold()
);
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Create this module?")
.default(true)
.interact()?;
if !confirmed {
eprintln!("{}", style("aborted.").yellow());
return Ok(());
}
}
}

let create_result = with_spinner("Creating module…", || {
api::create_module(
&client,
&apps_base,
&creds.access_token,
&CreateModuleInput {
name: &name,
slug: &slug,
},
)
});

match create_result {
Ok(m) => print_created(username, &m.slug, &m.id),
Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => {
print_already_exists(username, &slug, None);
}
Err(ApiError::Server { code, message, .. }) => {
return Err(anyhow!(
"{code}: {message}{hint}",
hint = slug_error_hint(&code)
));
let create_result = with_spinner("Creating module…", || {
api::create_module(
&client,
&apps_base,
&creds.access_token,
&CreateModuleInput {
name: &name,
slug: &slug,
},
)
});

match create_result {
Ok(m) => {
print_created(username, &m.slug, &m.id);
m.id
}
Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => {
// Race: the slug was free at pre-check but taken by the
// time we POST'd. Re-fetch so scaffold still has a real
// module ID to substitute.
print_already_exists(username, &slug, None);
refetch_module_id(&client, &apps_base, &creds.access_token, username, &slug)?
}
Err(ApiError::Server { code, message, .. }) => {
return Err(anyhow!(
"{code}: {message}{hint}",
hint = slug_error_hint(&code)
));
}
Err(ApiError::Unauthenticated) => return Err(session_expired()),
Err(e) => return Err(e.into()),
}
Err(ApiError::Unauthenticated) => return Err(session_expired()),
Err(e) => return Err(e.into()),
}
}
Err(ApiError::Unauthenticated) => return Err(session_expired()),
Err(e) => return Err(e.into()),
};

scaffold_if_requested(
scaffold_target.as_deref(),
&scaffold::Inputs {
slug: &slug,
name: &name,
module_id: &module_id,
},
)
}

fn refetch_module_id(
client: &reqwest::blocking::Client,
apps_base: &str,
access_token: &str,
username: &str,
slug: &str,
) -> Result<String> {
let refetch = with_spinner("Resolving existing module…", || {
api::get_module(client, apps_base, access_token, slug)
});
match refetch {
Ok(Some(m)) => Ok(m.id),
Ok(None) => Err(anyhow!(
"module @{username}/{slug} disappeared between create and re-fetch"
)),
Err(ApiError::Unauthenticated) => Err(session_expired()),
Err(e) => Err(e.into()),
}
}

/// Resolve the target dir for scaffolding. `--dir <path>` wins; otherwise
/// default to `./<slug>/`. Pass `--dir .` to scaffold into the cwd directly.
fn resolve_scaffold_target(dir: Option<&Path>, slug: &str) -> PathBuf {
Expand Down
89 changes: 71 additions & 18 deletions src/commands/module/scaffold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ const SQL_INIT: &str = include_str!("../../../templates/module/sql/app/0001_init
// files and this renderer is auditable from a single grep.
const SLUG_TOKEN: &str = "__MS_SLUG__";
const NAME_TOKEN: &str = "__MS_NAME__";
const TABLE_PREFIX_TOKEN: &str = "__MS_TABLE_PREFIX__";
const MODULE_ID_TOKEN: &str = "__MS_MODULE_ID__";

/// Inputs collected by the caller before scaffolding.
///
/// `module_id` is the platform-assigned UUID — stable across slug renames,
/// which is why both `Config.ID` and the table prefix derive from it rather
/// than from the URL slug.
pub(super) struct Inputs<'a> {
pub slug: &'a str,
pub name: &'a str,
pub module_id: &'a str,
}

/// Refuse if `target` already contains files. Empty dir or missing dir = OK.
Expand Down Expand Up @@ -82,31 +87,55 @@ fn write_file(target: &Path, rel: &str, body: &str, inputs: &Inputs<'_>) -> Resu
fn render(body: &str, inputs: &Inputs<'_>) -> String {
body.replace(SLUG_TOKEN, inputs.slug)
.replace(NAME_TOKEN, inputs.name)
.replace(TABLE_PREFIX_TOKEN, &table_prefix(inputs.slug))
.replace(MODULE_ID_TOKEN, &sanitize_module_id(inputs.module_id))
}

/// Convert a module slug (`my-mod`) into a valid Postgres identifier prefix
/// (`my_mod`). The SQL template appends `_items` etc. — caller controls the
/// suffix.
fn table_prefix(slug: &str) -> String {
slug.replace('-', "_")
/// Convert a platform UUID (`bb8a3f8b-1234-5678-9abc-def012345678`) into a
/// valid Postgres identifier and `Config.ID`: strip hyphens, prepend a
/// literal `m` so the result always starts with a letter (UUIDs may begin
/// with a digit), and lowercase the hex (already lowercase out of
/// `gen_random_uuid()`, but normalize defensively for any other source).
///
/// The SDK's `moduleIDPattern` regex caps `Config.ID` at 36 chars; this
/// produces 33 (`m` + 32 hex), which fits with margin.
fn sanitize_module_id(uuid: &str) -> String {
let mut out = String::with_capacity(33);
out.push('m');
for c in uuid.chars() {
if c == '-' {
continue;
}
out.extend(c.to_lowercase());
}
out
}

#[cfg(test)]
mod tests {
use super::*;

const SAMPLE_UUID: &str = "bb8a3f8b-1234-5678-9abc-def012345678";
const SAMPLE_SANITIZED: &str = "mbb8a3f8b123456789abcdef012345678";

fn ins<'a>(slug: &'a str, name: &'a str) -> Inputs<'a> {
Inputs { slug, name }
Inputs {
slug,
name,
module_id: SAMPLE_UUID,
}
}

#[test]
fn render_substitutes_slug_and_name() {
fn render_substitutes_module_id_into_config_id() {
let out = render(MAIN_GO, &ins("my-mod", "My Mod"));
assert!(out.contains(r#"ID: "my-mod""#));
assert!(
out.contains(&format!(r#"ID: "{}""#, SAMPLE_SANITIZED)),
"rendered main.go missing UUID-derived Config.ID"
);
assert!(out.contains(r#"Name: "My Mod""#));
assert!(!out.contains("__MS_SLUG__"));
assert!(!out.contains("__MS_NAME__"));
assert!(!out.contains("__MS_MODULE_ID__"));
}

#[test]
Expand All @@ -117,21 +146,45 @@ mod tests {
}

#[test]
fn table_prefix_replaces_hyphens() {
assert_eq!(table_prefix("media"), "media");
assert_eq!(table_prefix("my-mod"), "my_mod");
assert_eq!(table_prefix("a-b-c"), "a_b_c");
fn sanitize_module_id_strips_hyphens_and_prepends_letter() {
assert_eq!(sanitize_module_id(SAMPLE_UUID), SAMPLE_SANITIZED);
// UUIDs that start with a digit still produce an identifier-safe ID.
assert_eq!(
sanitize_module_id("01234567-89ab-cdef-0123-456789abcdef"),
"m0123456789abcdef0123456789abcdef"
);
}

#[test]
fn sanitize_module_id_normalizes_uppercase() {
assert_eq!(
sanitize_module_id("BB8A3F8B-1234-5678-9ABC-DEF012345678"),
SAMPLE_SANITIZED
);
}

#[test]
fn sanitize_module_id_fits_under_sdk_regex_ceiling() {
// SDK's moduleIDPattern allows up to 36 chars. We emit "m" + 32 hex = 33.
let out = sanitize_module_id(SAMPLE_UUID);
assert!(
out.len() <= 36,
"sanitized ID {out} exceeds SDK regex limit"
);
assert!(out.starts_with('m'));
}

#[test]
fn render_sql_uses_table_prefix() {
fn render_sql_uses_module_id_table_prefix() {
let out = render(SQL_INIT, &ins("my-mod", "My Mod"));
assert!(out.contains("CREATE TABLE IF NOT EXISTS my_mod_items"));
assert!(!out.contains("__MS_TABLE_PREFIX__"));
let expected = format!("CREATE TABLE IF NOT EXISTS {}_items", SAMPLE_SANITIZED);
assert!(out.contains(&expected), "missing {expected:?}");
assert!(!out.contains("__MS_MODULE_ID__"));
}

#[test]
fn render_routes_substitutes_slug_in_response_body() {
// Slug stays the user-facing label even though Config.ID is UUID-derived.
let out = render(ROUTES_GO, &ins("media", "Media"));
assert!(out.contains(r#""hello from media""#));
}
Expand Down Expand Up @@ -173,7 +226,7 @@ mod tests {
assert!(p.exists(), "missing {}", p.display());
}
let main = fs::read_to_string(target.join("main.go")).unwrap();
assert!(main.contains(r#"ID: "media""#));
assert!(main.contains(&format!(r#"ID: "{}""#, SAMPLE_SANITIZED)));
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion templates/module/go.mod.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ go 1.26

require (
github.com/go-chi/chi/v5 v5.2.5
github.com/mirrorstack-ai/app-module-sdk v0.1.0
github.com/mirrorstack-ai/app-module-sdk v0.1.1
)
6 changes: 5 additions & 1 deletion templates/module/main.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ var postInitHooks []func()

func main() {
if err := ms.Init(ms.Config{
ID: "__MS_SLUG__",
// ID is derived from the platform-assigned UUID — stable across slug
// renames. Don't change it; renaming the URL slug doesn't require
// changing this. Renaming this WOULD require migrating every schema
// and table whose name is derived from it.
ID: "__MS_MODULE_ID__",
Name: "__MS_NAME__",
Icon: "extension",
SQL: sqlFS,
Expand Down
5 changes: 3 additions & 2 deletions templates/module/sql/app/0001_init.up.sql.tmpl
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
-- App-scoped migrations run per-app-tenant. The platform applies them via the
-- lifecycle install/upgrade routes. Rows are isolated per app by the DB role.
--
-- CONVENTION: every table name MUST start with your module ID + underscore.
-- CONVENTION: every table name MUST start with __MS_MODULE_ID__ + underscore.
-- This prevents collisions when multiple modules share the same app_<id> schema.
-- The module ID is UUID-derived, so URL slug renames do NOT affect table names.
--
-- Replace this example with your module's real schema.

CREATE TABLE IF NOT EXISTS __MS_TABLE_PREFIX___items (
CREATE TABLE IF NOT EXISTS __MS_MODULE_ID___items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
Expand Down