diff --git a/Cargo.toml b/Cargo.toml index 72c1860..5e295e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } tokio = { version = "1", features = ["full"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid", "migrate"] } axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["cors", "fs"] } ts-rs = { version = "10", features = ["serde-json-impl", "uuid-impl", "chrono-impl"] } diff --git a/crates/colony-types/bindings/Channel.ts b/crates/colony-types/bindings/Channel.ts new file mode 100644 index 0000000..a204fd6 --- /dev/null +++ b/crates/colony-types/bindings/Channel.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Channel = { id: string, name: string, description: string, created_by: string, created_at: string, }; diff --git a/crates/colony-types/bindings/CreateChannel.ts b/crates/colony-types/bindings/CreateChannel.ts new file mode 100644 index 0000000..d58866a --- /dev/null +++ b/crates/colony-types/bindings/CreateChannel.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CreateChannel = { name: string, description: string, }; diff --git a/crates/colony-types/bindings/Message.ts b/crates/colony-types/bindings/Message.ts new file mode 100644 index 0000000..2c237e1 --- /dev/null +++ b/crates/colony-types/bindings/Message.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; +import type { MessageType } from "./MessageType"; +import type { User } from "./User"; + +export type Message = { id: string, seq: bigint, channel_id: string, user: User, type: MessageType, content: string, metadata?: JsonValue, reply_to?: string, created_at: string, updated_at?: string, deleted_at?: string, }; diff --git a/crates/colony-types/bindings/MessageType.ts b/crates/colony-types/bindings/MessageType.ts new file mode 100644 index 0000000..5359b3c --- /dev/null +++ b/crates/colony-types/bindings/MessageType.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MessageType = "text" | "code" | "result" | "error" | "plan"; diff --git a/crates/colony-types/bindings/PostMessage.ts b/crates/colony-types/bindings/PostMessage.ts new file mode 100644 index 0000000..4d0e7df --- /dev/null +++ b/crates/colony-types/bindings/PostMessage.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; +import type { MessageType } from "./MessageType"; + +export type PostMessage = { content: string, type: MessageType, metadata?: JsonValue, reply_to?: string, }; diff --git a/crates/colony-types/bindings/User.ts b/crates/colony-types/bindings/User.ts new file mode 100644 index 0000000..add669b --- /dev/null +++ b/crates/colony-types/bindings/User.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UserRole } from "./UserRole"; + +export type User = { id: string, username: string, display_name: string, role: UserRole, created_at: string, }; diff --git a/crates/colony-types/bindings/UserRole.ts b/crates/colony-types/bindings/UserRole.ts new file mode 100644 index 0000000..313b8ae --- /dev/null +++ b/crates/colony-types/bindings/UserRole.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UserRole = "ape" | "agent"; diff --git a/crates/colony-types/bindings/WsEvent.ts b/crates/colony-types/bindings/WsEvent.ts new file mode 100644 index 0000000..d49557a --- /dev/null +++ b/crates/colony-types/bindings/WsEvent.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Message } from "./Message"; + +export type WsEvent = { "event": "message", "data": Message } | { "event": "edit", "data": Message } | { "event": "delete", "data": { id: string, } }; diff --git a/crates/colony-types/bindings/serde_json/JsonValue.ts b/crates/colony-types/bindings/serde_json/JsonValue.ts new file mode 100644 index 0000000..3ad5da8 --- /dev/null +++ b/crates/colony-types/bindings/serde_json/JsonValue.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; diff --git a/crates/colony-types/src/lib.rs b/crates/colony-types/src/lib.rs index 51e4f8d..97f3ac7 100644 --- a/crates/colony-types/src/lib.rs +++ b/crates/colony-types/src/lib.rs @@ -100,7 +100,7 @@ pub enum WsEvent { #[derive(Debug, Deserialize)] pub struct MessageQuery { - pub since: Option>, + pub after_seq: Option, pub r#type: Option, pub user_id: Option, } diff --git a/crates/colony/migrations/001_init.sql b/crates/colony/migrations/20260329000001_init.sql similarity index 62% rename from crates/colony/migrations/001_init.sql rename to crates/colony/migrations/20260329000001_init.sql index 390e57d..6e98571 100644 --- a/crates/colony/migrations/001_init.sql +++ b/crates/colony/migrations/20260329000001_init.sql @@ -24,9 +24,11 @@ CREATE TABLE IF NOT EXISTS channels ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); +-- seq is INTEGER PRIMARY KEY AUTOINCREMENT = global monotonic, no race conditions +-- id is a separate UUID with a unique index CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY NOT NULL, - seq INTEGER NOT NULL, + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT UNIQUE NOT NULL, channel_id TEXT NOT NULL REFERENCES channels(id), user_id TEXT NOT NULL REFERENCES users(id), type TEXT NOT NULL CHECK (type IN ('text', 'code', 'result', 'error', 'plan')), @@ -36,40 +38,18 @@ CREATE TABLE IF NOT EXISTS messages ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT, deleted_at TEXT + -- reply_to same-channel constraint enforced in application layer (routes.rs) ); -CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, seq); -CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_messages_channel_seq ON messages(channel_id, seq); --- Auto-increment seq per channel using a trigger -CREATE TABLE IF NOT EXISTS channel_seq ( - channel_id TEXT PRIMARY KEY NOT NULL REFERENCES channels(id), - next_seq INTEGER NOT NULL DEFAULT 1 -); - -CREATE TRIGGER IF NOT EXISTS trg_message_seq -AFTER INSERT ON messages -BEGIN - INSERT INTO channel_seq (channel_id, next_seq) VALUES (NEW.channel_id, 2) - ON CONFLICT(channel_id) DO UPDATE SET next_seq = next_seq + 1; -END; - --- Enforce reply_to same-channel constraint -CREATE TRIGGER IF NOT EXISTS trg_reply_same_channel -BEFORE INSERT ON messages -WHEN NEW.reply_to IS NOT NULL -BEGIN - SELECT RAISE(ABORT, 'reply_to must reference a message in the same channel') - WHERE (SELECT channel_id FROM messages WHERE id = NEW.reply_to) != NEW.channel_id; -END; - --- Seed user for development (no auth yet) +-- Seed users INSERT OR IGNORE INTO users (id, username, display_name, role) VALUES ('00000000-0000-0000-0000-000000000001', 'benji', 'Benji', 'ape'); INSERT OR IGNORE INTO users (id, username, display_name, role) VALUES ('00000000-0000-0000-0000-000000000002', 'neeraj', 'Neeraj', 'ape'); --- Seed a general channel +-- Seed general channel INSERT OR IGNORE INTO channels (id, name, description, created_by) VALUES ('00000000-0000-0000-0000-000000000010', 'general', 'General discussion', '00000000-0000-0000-0000-000000000001'); diff --git a/crates/colony/src/main.rs b/crates/colony/src/main.rs index c689403..3d40469 100644 --- a/crates/colony/src/main.rs +++ b/crates/colony/src/main.rs @@ -22,22 +22,20 @@ async fn main() { .await .unwrap(); - // Run migrations - let migration_sql = include_str!("../migrations/001_init.sql"); - for statement in migration_sql.split(';') { - let stmt = statement.trim(); - if !stmt.is_empty() { - if let Err(e) = sqlx::query(stmt).execute(&pool).await { - eprintln!("Migration warning (may be OK): {}", e); - } - } - } + // Run migrations using sqlx's proper migration system + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); println!("Colony running on port {}", port); let app = Router::new() .route("/api/health", get(routes::health)) - .route("/api/channels", get(routes::list_channels).post(routes::create_channel)) + .route( + "/api/channels", + get(routes::list_channels).post(routes::create_channel), + ) .route("/api/channels/{id}", get(routes::get_channel)) .route( "/api/channels/{channel_id}/messages", diff --git a/crates/colony/src/routes.rs b/crates/colony/src/routes.rs index 448cb49..da8aaa3 100644 --- a/crates/colony/src/routes.rs +++ b/crates/colony/src/routes.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - response::IntoResponse, + response::{IntoResponse, Response}, Json, }; use colony_types::*; @@ -10,6 +10,41 @@ use uuid::Uuid; use crate::db::*; +// ── Error handling ── + +pub enum AppError { + NotFound(String), + Conflict(String), + BadRequest(String), + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self { + AppError::NotFound(m) => (StatusCode::NOT_FOUND, m), + AppError::Conflict(m) => (StatusCode::CONFLICT, m), + AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + AppError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m), + }; + (status, Json(serde_json::json!({"error": message}))).into_response() + } +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + match &e { + sqlx::Error::Database(db_err) if db_err.message().contains("UNIQUE") => { + AppError::Conflict(format!("Already exists: {}", db_err.message())) + } + sqlx::Error::RowNotFound => AppError::NotFound("Not found".into()), + _ => AppError::Internal(format!("Database error: {e}")), + } + } +} + +type Result = std::result::Result; + // ── Health ── pub async fn health() -> &'static str { @@ -18,22 +53,21 @@ pub async fn health() -> &'static str { // ── Channels ── -pub async fn list_channels(State(db): State) -> impl IntoResponse { +pub async fn list_channels(State(db): State) -> Result>> { let rows = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels ORDER BY created_at") .fetch_all(&db) - .await - .unwrap(); + .await?; let channels: Vec = rows.iter().map(|r| r.to_api()).collect(); - Json(channels) + Ok(Json(channels)) } pub async fn create_channel( State(db): State, Json(body): Json, -) -> impl IntoResponse { +) -> Result { let id = Uuid::new_v4().to_string(); - // Hardcoded to benji for now (no auth yet) + // Hardcoded to benji for now (no auth yet — S4 will extract from middleware) let created_by = "00000000-0000-0000-0000-000000000001"; sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)") @@ -42,31 +76,28 @@ pub async fn create_channel( .bind(&body.description) .bind(created_by) .execute(&db) - .await - .unwrap(); + .await?; let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?") .bind(&id) .fetch_one(&db) - .await - .unwrap(); + .await?; - (StatusCode::CREATED, Json(row.to_api())) + Ok((StatusCode::CREATED, Json(row.to_api()))) } pub async fn get_channel( State(db): State, Path(id): Path, -) -> impl IntoResponse { +) -> Result> { let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?") .bind(&id) .fetch_optional(&db) - .await - .unwrap(); + .await?; match row { Some(r) => Ok(Json(r.to_api())), - None => Err(StatusCode::NOT_FOUND), + None => Err(AppError::NotFound(format!("Channel {id} not found"))), } } @@ -76,8 +107,7 @@ pub async fn list_messages( State(db): State, Path(channel_id): Path, Query(query): Query, -) -> impl IntoResponse { - // Build query dynamically based on filters +) -> Result>> { let mut sql = String::from( "SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \ FROM messages m JOIN users u ON m.user_id = u.id \ @@ -85,9 +115,9 @@ pub async fn list_messages( ); let mut binds: Vec = vec![channel_id.clone()]; - if let Some(since) = &query.since { - sql.push_str(" AND m.created_at > ?"); - binds.push(since.to_rfc3339()); + if let Some(after_seq) = &query.after_seq { + sql.push_str(" AND m.seq > ?"); + binds.push(after_seq.to_string()); } if let Some(msg_type) = &query.r#type { sql.push_str(" AND m.type = ?"); @@ -97,7 +127,8 @@ pub async fn list_messages( MessageType::Result => "result", MessageType::Error => "error", MessageType::Plan => "plan", - }.to_string()); + } + .to_string()); } if let Some(user_id) = &query.user_id { sql.push_str(" AND m.user_id = ?"); @@ -106,24 +137,53 @@ pub async fn list_messages( sql.push_str(" ORDER BY m.seq ASC"); - // Use raw query with dynamic binds let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql); for b in &binds { q = q.bind(b); } - let rows = q.fetch_all(&db).await.unwrap(); + let rows = q.fetch_all(&db).await?; let messages: Vec = rows.iter().map(|r| r.to_api_message()).collect(); - Json(messages) + Ok(Json(messages)) } pub async fn post_message( State(db): State, Path(channel_id): Path, Json(body): Json, -) -> impl IntoResponse { +) -> Result { + // Verify channel exists + let channel_exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM channels WHERE id = ?", + ) + .bind(&channel_id) + .fetch_one(&db) + .await?; + + if channel_exists == 0 { + return Err(AppError::NotFound(format!("Channel {channel_id} not found"))); + } + + // Verify reply_to is in same channel (if provided) + if let Some(ref reply_id) = body.reply_to { + let reply_channel = sqlx::query_scalar::<_, String>( + "SELECT channel_id FROM messages WHERE id = ?", + ) + .bind(reply_id.to_string()) + .fetch_optional(&db) + .await?; + + match reply_channel { + None => return Err(AppError::BadRequest(format!("reply_to message {reply_id} not found"))), + Some(ch) if ch != channel_id => { + return Err(AppError::BadRequest("reply_to must reference a message in the same channel".into())); + } + _ => {} + } + } + let id = Uuid::new_v4().to_string(); - // Hardcoded to benji for now (no auth yet) + // Hardcoded to benji for now (no auth yet — S4 will extract from middleware) let user_id = "00000000-0000-0000-0000-000000000001"; let msg_type = match body.r#type { @@ -134,24 +194,18 @@ pub async fn post_message( MessageType::Plan => "plan", }; - let metadata_json = body.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap()); + let metadata_json = body + .metadata + .as_ref() + .map(|m| serde_json::to_string(m).unwrap()); let reply_to = body.reply_to.map(|r| r.to_string()); - // Get next seq - let seq: i64 = sqlx::query_scalar::<_, i64>( - "SELECT COALESCE((SELECT next_seq FROM channel_seq WHERE channel_id = ?), 1)", - ) - .bind(&channel_id) - .fetch_one(&db) - .await - .unwrap(); - + // seq is AUTOINCREMENT — no race conditions, no manual tracking sqlx::query( - "INSERT INTO messages (id, seq, channel_id, user_id, type, content, metadata, reply_to) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO messages (id, channel_id, user_id, type, content, metadata, reply_to) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) - .bind(seq) .bind(&channel_id) .bind(user_id) .bind(msg_type) @@ -159,8 +213,7 @@ pub async fn post_message( .bind(&metadata_json) .bind(&reply_to) .execute(&db) - .await - .unwrap(); + .await?; // Fetch the full message with user let row = sqlx::query_as::<_, MessageWithUserRow>( @@ -169,17 +222,15 @@ pub async fn post_message( ) .bind(&id) .fetch_one(&db) - .await - .unwrap(); + .await?; - (StatusCode::CREATED, Json(row.to_api_message())) + Ok((StatusCode::CREATED, Json(row.to_api_message()))) } // ── Joined row type for message + user ── #[derive(Debug, sqlx::FromRow)] pub struct MessageWithUserRow { - // message fields pub id: String, pub seq: i64, pub channel_id: String, @@ -191,7 +242,6 @@ pub struct MessageWithUserRow { pub created_at: String, pub updated_at: Option, pub deleted_at: Option, - // user fields (aliased) pub u_id: String, pub username: String, pub display_name: String, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..88751f3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,175 @@ +{ + "name": "apes", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@biomejs/biome": "^2.4.9" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz", + "integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.9", + "@biomejs/cli-darwin-x64": "2.4.9", + "@biomejs/cli-linux-arm64": "2.4.9", + "@biomejs/cli-linux-arm64-musl": "2.4.9", + "@biomejs/cli-linux-x64": "2.4.9", + "@biomejs/cli-linux-x64-musl": "2.4.9", + "@biomejs/cli-win32-arm64": "2.4.9", + "@biomejs/cli-win32-x64": "2.4.9" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz", + "integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz", + "integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz", + "integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz", + "integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz", + "integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz", + "integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz", + "integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz", + "integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5942b97 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@biomejs/biome": "^2.4.9" + } +} diff --git a/ui/colony b/ui/colony new file mode 160000 index 0000000..1e8367a --- /dev/null +++ b/ui/colony @@ -0,0 +1 @@ +Subproject commit 1e8367a5bbe3863044b9fcfd7db3617010e28ad8