diff --git a/backend/.sqlx/query-80a3a4e8f35190352aa4e4c5716013ce4a6f7b3ba27c63c77c4c765bf9076624.json b/backend/.sqlx/query-80a3a4e8f35190352aa4e4c5716013ce4a6f7b3ba27c63c77c4c765bf9076624.json new file mode 100644 index 0000000000..a72789aa6b --- /dev/null +++ b/backend/.sqlx/query-80a3a4e8f35190352aa4e4c5716013ce4a6f7b3ba27c63c77c4c765bf9076624.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET path = path WHERE path = $1 AND workspace_id = $2 RETURNING 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "80a3a4e8f35190352aa4e4c5716013ce4a6f7b3ba27c63c77c4c765bf9076624" +} diff --git a/backend/.sqlx/query-98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941.json b/backend/.sqlx/query-98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941.json new file mode 100644 index 0000000000..5d1b35b17d --- /dev/null +++ b/backend/.sqlx/query-98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT files FROM workspace_shared_ui WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "files", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941" +} diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index 20ea1a0a6c..e07e58dac7 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -398,7 +398,10 @@ def schema_to_rust_value(schema: Optional[Dict[str, Any]]) -> str: """Convert a schema dict to a Rust serde_json::json! expression.""" if schema is None: return "None" - return f"Some(serde_json::json!({json.dumps(schema, indent=8)}))" + # ensure_ascii=False: json.dumps would escape a non-ASCII character in a + # description as \uXXXX, which Rust rejects — its string literals spell it + # \u{XXXX}. Emitting the character itself is valid in both. + return f"Some(serde_json::json!({json.dumps(schema, indent=8, ensure_ascii=False)}))" def build_tool_description(operation: Dict[str, Any], method: str, path: str) -> str: """Build the MCP tool description from OpenAPI summary and description.""" diff --git a/backend/windmill-api-integration-tests/tests/apps.rs b/backend/windmill-api-integration-tests/tests/apps.rs index cf04e377d2..f0c77149f6 100644 --- a/backend/windmill-api-integration-tests/tests/apps.rs +++ b/backend/windmill-api-integration-tests/tests/apps.rs @@ -411,6 +411,19 @@ async fn test_raw_app_kind_is_not_flipped_by_update(db: Pool) -> anyho "expected the raw update of a low-code app to be refused" ); + // The source endpoint refuses the same mismatch up front — it must not queue + // a bundle job (which no worker would pick up here) to find that out. + let resp = authed(client().post(format!("{base}/update_raw_source/{low_code_path}"))) + .json(&json!({ "value": { "files": { "/index.tsx": "export {}" } } })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + assert!( + resp.text().await?.contains("is a low-code app"), + "expected the source deploy of a low-code app to be refused" + ); + // Metadata-only updates and same-kind deploys still go through. let resp = authed(client().post(format!("{base}/update/{raw_path}"))) .json(&json!({ "summary": "Renamed" })) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 650f3649a4..ea3c014aa6 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12299,7 +12299,7 @@ paths: summary: update app operationId: updateApp x-mcp-tool: true - x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/ instead." + x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those." x-mcp-tool-include-fields: - path - value @@ -12354,6 +12354,77 @@ paths: schema: type: string + /w/{workspace}/apps/update_raw_source/{path}: + post: + # The summary is what the MCP token picker shows next to the tool, so it has + # to carry the consequence of granting it, not just what it does. + summary: update a raw app from its sources, compiling them on a worker (which runs the app's own dependencies to do so) + operationId: updateAppRawSource + x-mcp-tool: true + x-mcp-instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker by the same build the editor and the CLI run, so a compile error comes back as the error of this call. Compiling runs the app's own dependencies on a worker, so this tool can execute code there. Low-code apps use updateApp instead." + x-mcp-tool-include-fields: + - path + - value + - summary + - policy + tags: + - app + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + description: raw app sources to bundle and deploy + required: true + content: + application/json: + schema: + type: object + properties: + path: + type: string + summary: + type: string + value: + type: object + description: "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged." + properties: + files: + type: object + additionalProperties: + type: string + runnables: + type: object + data: + type: object + required: [files] + policy: + $ref: "#/components/schemas/Policy" + deployment_message: + type: string + custom_path: + type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." + labels: + type: array + items: + type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." + allow_kind_change: + type: boolean + description: "When true, this deploy may switch the app between low-code and raw. Without it, deploying a value to an app of the other kind is refused so an app is never converted by accident." + required: [value] + responses: + "200": + description: app updated + content: + text/plain: + schema: + type: string + /w/{workspace}/apps/update_raw/{path}: post: summary: update app diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 98c1f1ba2d..49f5dd52c3 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -13,6 +13,7 @@ use crate::job_helpers_oss::{ spawn_storage_usage_recount_floored, }; use crate::{ + apps_raw_bundle, auth::{get_end_user_email, AuthCache, OptTokened}, db::{ApiAuthed, DB}, jobs::RunJobQuery, @@ -117,6 +118,11 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { "/update_raw/{*path}", post(update_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)), ) + .route( + "/update_raw_source/{*path}", + post(update_app_raw_source) + .layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)), + ) .route("/delete/{*path}", delete(delete_app)) .route("/create", post(create_app)) .route( @@ -2618,6 +2624,167 @@ async fn update_app( Ok(format!("app {} updated (npath: {:?})", opath, npath)) } +/// Deploy a raw app from its sources, compiling them on a worker. `update_raw` +/// takes the bundle the caller already built (the editor and the CLI both +/// bundle before calling it); an API client has nothing to build with, so this +/// is the raw-app deploy an MCP agent or a script can actually reach. +async fn update_app_raw_source( + authed: ApiAuthed, + Extension(user_db): Extension, + Extension(db): Extension, + Extension(webhook): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Json(ns): Json, +) -> Result { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot update apps for security reasons".to_string(), + )); + } + + let path = path.to_path(); + check_scopes(&authed, || format!("apps:write:{}", path))?; + // The sources are compiled by a job on a worker: dependency resolution and + // the build run there, on caller-supplied input. A token that can't run jobs + // must not gain that through an app write, so require both scopes. + check_scopes(&authed, || "jobs:run".to_string())?; + + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( + &w_id, + AuditAuthorable::username(&authed), + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + + let Some(value) = ns.value.as_ref() else { + return Err(Error::BadRequest( + "value with the app's `files` is required to deploy a raw app".to_string(), + )); + }; + let files: RawAppSourceFiles = serde_json::from_str(value.0.get()) + .map_err(|e| Error::BadRequest(format!("app value is not a raw app source: {e}")))?; + + // All before the compile, which costs a job on a worker: it must not run for + // a path with no app, for a caller who can only read one (RLS refuses the + // write, but not until their sources have been built), or for an app of the + // other kind, which update_app_internal refuses anyway. + let deployed_raw_app = deployed_app_kind(&user_db, &authed, &w_id, path) + .await? + .ok_or_else(|| Error::NotFound(format!("App {path} not found")))?; + if !can_write_app(&user_db, &authed, &w_id, path).await? { + return Err(Error::PermissionDenied(format!( + "You do not have permission to update app {path}" + ))); + } + if !ns.allow_kind_change.unwrap_or(false) { + reject_kind_change(path, true, Some(deployed_raw_app))?; + } + + let (js, css) = + apps_raw_bundle::bundle_raw_app_sources(&db, &user_db, &authed, &w_id, &files.files) + .await?; + + let opath = path.to_string(); + let db2 = db.clone(); + let (mut tx, npath, v_id) = + update_app_internal(authed, db, user_db, &w_id, path, true, ns).await?; + store_raw_app_file(&w_id, &v_id, "js", bytes::Bytes::from(js), &mut tx).await?; + if !css.is_empty() { + store_raw_app_file(&w_id, &v_id, "css", bytes::Bytes::from(css), &mut tx).await?; + } + tx.commit().await?; + tally_app_rename(&db2, &w_id, &opath, &npath, v_id).await; + + webhook.send_message( + w_id.clone(), + WebhookMessage::UpdateApp { + workspace: w_id.clone(), + old_path: opath.clone(), + new_path: npath.clone(), + }, + ); + + Ok(format!("app {} updated (npath: {:?})", opath, npath)) +} + +/// The part of a raw app's value the bundler needs. +#[derive(Deserialize)] +struct RawAppSourceFiles { + #[serde(default)] + files: HashMap, +} + +fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option) -> Result<()> { + if !deployed_raw_app.is_some_and(|deployed| deployed != raw_app) { + return Ok(()); + } + // Name the folder suffix too: a sync push picks the endpoint from the repo + // layout, so its operator has no endpoint to swap, only a folder. + let (kind, endpoint, folder) = if raw_app { + ("a low-code app", "/apps/update", ".app") + } else { + ("a raw app", "/apps/update_raw", ".raw_app") + }; + Err(Error::BadRequest(format!( + "App {path} is {kind}: deploying a value to it through the other kind's endpoint \ + would convert it and strand its bundle. Deploy it through {endpoint} instead \ + (from a synced repo, from a `{folder}` folder), or set allow_kind_change to \ + convert it on purpose." + ))) +} + +/// Whether the caller may write the app — decided by the database rather than by +/// restating its policies here, which is how a hand-written check came to miss +/// that the app's RLS grants group members write on a `g//…` path. The +/// probe is the write itself, rolled back; `path = path` touches no column any +/// trigger watches. +async fn can_write_app( + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + path: &str, +) -> Result { + let mut tx = user_db.clone().begin(authed).await?; + let allowed = sqlx::query_scalar!( + "UPDATE app SET path = path WHERE path = $1 AND workspace_id = $2 RETURNING 1", + path, + w_id + ) + .fetch_optional(&mut *tx) + .await? + .is_some(); + tx.rollback().await?; + Ok(allowed) +} + +/// Whether the app deployed at `path` is raw, or None when there is none the +/// caller can see. Through `user_db`, so it can't tell a caller anything about +/// an app they aren't allowed to read. +async fn deployed_app_kind( + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + path: &str, +) -> Result> { + let mut tx = user_db.clone().begin(authed).await?; + let deployed_raw_app = sqlx::query_scalar!( + "SELECT app_version.raw_app FROM app + JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.path = $1 AND app.workspace_id = $2", + path, + w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + Ok(deployed_raw_app) +} + async fn update_app_raw<'a>( authed: ApiAuthed, Extension(user_db): Extension, @@ -2722,21 +2889,7 @@ async fn update_app_internal<'a>( } None => None, }; - if deployed_raw_app.is_some_and(|deployed| deployed != raw_app) { - // Name the folder suffix too: a sync push picks the endpoint from the - // repo layout, so its operator has no endpoint to swap, only a folder. - let (kind, endpoint, folder) = if raw_app { - ("a low-code app", "/apps/update", ".app") - } else { - ("a raw app", "/apps/update_raw", ".raw_app") - }; - return Err(Error::BadRequest(format!( - "App {path} is {kind}: deploying a value to it through the other kind's endpoint \ - would convert it and strand its bundle. Deploy it through {endpoint} instead \ - (from a synced repo, from a `{folder}` folder), or set allow_kind_change to \ - convert it on purpose." - ))); - } + reject_kind_change(path, raw_app, deployed_raw_app)?; } let mut preserved_on_behalf_of: Option = None; diff --git a/backend/windmill-api/src/apps_raw_bundle.rs b/backend/windmill-api/src/apps_raw_bundle.rs new file mode 100644 index 0000000000..833e7c6b58 --- /dev/null +++ b/backend/windmill-api/src/apps_raw_bundle.rs @@ -0,0 +1,256 @@ +//! Server-side bundling of a raw app's sources. +//! +//! A raw app is served as a compiled js/css bundle, so every deploy path has to +//! compile first: the editor bundles in a browser iframe, the CLI bundles on the +//! developer's machine. Neither is reachable from a plain API call, which is why +//! `/apps/update_raw` takes the bundle as multipart and why there was no way to +//! deploy a raw app from an API client (an MCP agent, most of all — its only +//! app-write tool was the low-code one, which converted the app instead). +//! +//! The compile runs as a normal bun job on a worker: no new job kind, no new +//! executor, and the build's logs, timeout, cancellation and attribution are the +//! ones every other job gets. The build itself is `wmill app bundle`, so this +//! adds no bundler of its own to keep in step with the CLI's and the editor's. + +use std::collections::HashMap; +use std::io::Read; + +use base64::Engine; +use serde::Deserialize; +use uuid::Uuid; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + jobs::{JobPayload, RawCode}, + scripts::ScriptLang, + worker::to_raw_value, + DB, +}; +use windmill_queue::{push, PushArgs, PushIsolationLevel}; + +use crate::db::ApiAuthed; + +/// The bundle job's script, as its own file so it stays readable TypeScript. +const BUNDLER_TS: &str = include_str!("apps_raw_bundler.ts"); + +/// Cap the job so a pathological `package.json` can't sit on a worker forever. +/// This bounds the *run*, not the wait: see `wait_for_bundle`. +const BUNDLE_TIMEOUT_SECS: i32 = 300; + +#[derive(Deserialize)] +struct BundleResult { + js_gz: String, + css_gz: String, +} + +/// This server's release, without the git describe suffix an off-tag build +/// carries — the CLI is published per release, so that is the version to ask npm +/// for, and the one an installed CLI must report to be used instead. +fn release_version() -> String { + let v = &*windmill_common::utils::GIT_SEM_VERSION; + format!("{}.{}.{}", v.major, v.minor, v.patch) +} + +/// The build command the job falls back to when the worker has no usable `wmill` +/// installed: the CLI for this server's release, fetched on the spot. A dev +/// server is off-tag and so asks for the last release; to build with an +/// unreleased CLI set `WM_RAW_APP_BUNDLER_CLI` to the whole command, e.g. +/// `bun run /path/to/cli/src/main.ts app bundle` — which also stops the job from +/// preferring an installed `wmill`. +fn bundler_cli_command() -> Vec { + match std::env::var("WM_RAW_APP_BUNDLER_CLI") { + Ok(cmd) if !cmd.trim().is_empty() => { + cmd.split_whitespace().map(|s| s.to_string()).collect() + } + // `bun x`, not `bunx`: the images copy the `bun` binary alone, so the + // `bunx` entry point isn't on a worker's PATH. + _ => vec![ + "bun".to_string(), + "x".to_string(), + "--bun".to_string(), + format!("windmill-cli@{}", release_version()), + "app".to_string(), + "bundle".to_string(), + ], + } +} + +/// Compile `files` into the js/css a deployed raw app serves. Returns the +/// build's own error when it fails, so the caller sees the compile error rather +/// than a generic failure. +/// +/// This makes a worker run a build on caller-supplied sources, so it requires +/// `jobs:run` here rather than trusting each caller to have checked: a token +/// that can't run jobs must not gain that by writing an app. +pub(crate) async fn bundle_raw_app_sources( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + files: &HashMap, +) -> Result<(String, String)> { + crate::utils::check_scopes(authed, || "jobs:run".to_string())?; + + if files.is_empty() { + return Err(Error::BadRequest( + "app value has no `files` to bundle".to_string(), + )); + } + + // A queued bundle holds this request open until it runs, so refuse early + // rather than pile up connections waiting behind a backlog. + windmill_api_jobs::execution::check_queue_too_long( + db, + *windmill_api_jobs::execution::QUEUE_LIMIT_WAIT_RESULT, + ) + .await?; + + let shared_ui = shared_ui_files(user_db, authed, w_id).await?; + + let mut args: HashMap> = HashMap::new(); + args.insert("files".to_string(), to_raw_value(files)); + args.insert("shared_ui".to_string(), to_raw_value(&shared_ui)); + let overridden = std::env::var("WM_RAW_APP_BUNDLER_CLI").is_ok_and(|c| !c.trim().is_empty()); + args.insert( + "cli_command".to_string(), + to_raw_value(&bundler_cli_command()), + ); + args.insert( + "prefer_installed_cli".to_string(), + to_raw_value(&!overridden), + ); + + let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let (uuid, tx) = push( + db, + tx, + w_id, + JobPayload::Code(RawCode { + hash: None, + content: BUNDLER_TS.to_string(), + path: Some("bundle raw app".to_string()), + language: ScriptLang::Bun, + lock: None, + concurrency_settings: Default::default(), + debouncing_settings: Default::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }), + PushArgs { args: &args, extra: None }, + authed.display_username(), + &authed.email, + windmill_common::users::username_to_permissioned_as(&authed.username), + authed.token_prefix.as_deref(), + authed.username_override.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + Some(BUNDLE_TIMEOUT_SECS), + None, + None, + Some(&authed.clone().into()), + false, + None, + authed.trigger_or_fallback(None), + None, + ) + .await?; + tx.commit().await?; + + wait_for_bundle(db, w_id, uuid, authed).await +} + +/// Waits for the bundle job. The wait itself is bounded by +/// `TIMEOUT_WAIT_RESULT`, not by `BUNDLE_TIMEOUT_SECS` — the job's timeout only +/// starts once a worker picks it up. +async fn wait_for_bundle( + db: &DB, + w_id: &str, + uuid: Uuid, + authed: &ApiAuthed, +) -> Result<(String, String)> { + let (result, success) = windmill_api_jobs::execution::run_wait_result_internal( + db, + uuid, + w_id, + None, + false, + &authed.username, + ) + .await?; + + if !success { + // The job's error is the compile error the caller needs to act on. + return Err(Error::BadRequest(format!( + "raw app bundling failed (job {uuid}): {}", + result.get() + ))); + } + + let bundle: BundleResult = serde_json::from_str(result.get()).map_err(|e| { + Error::internal_err(format!( + "unexpected raw app bundler result (job {uuid}): {e}" + )) + })?; + // One budget across both, so the pair can't hold twice the limit in memory. + let limit = *crate::REQUEST_SIZE_LIMIT.read().await * 5; + let js = gunzip_b64(&bundle.js_gz, limit)?; + let css = gunzip_b64(&bundle.css_gz, limit - js.len())?; + Ok((js, css)) +} + +/// Bounded: what the job returns is compressed, so the result-size cap says +/// nothing about what it expands to, and the sources that produced it came from +/// the caller. The budget is shared by the js and css of one bundle, and matches +/// what `/apps/update_raw` accepts as a whole body. +fn gunzip_b64(b64: &str, limit: usize) -> Result { + let compressed = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| Error::internal_err(format!("raw app bundle is not valid base64: {e}")))?; + // Bytes, not a String: over the limit the read stops mid-stream, and + // read_to_string would report that as invalid utf-8 rather than as the size + // it is. One byte past, so a bundle that just fits is told from one cut short. + let mut out = Vec::new(); + flate2::read::GzDecoder::new(&compressed[..]) + .take(limit as u64 + 1) + .read_to_end(&mut out) + .map_err(|e| Error::internal_err(format!("raw app bundle is not valid gzip: {e}")))?; + if out.len() > limit { + // `limit` is what is left of the budget, not the whole of it, so say so + // rather than report a nearly-exhausted budget as the limit itself. + return Err(Error::BadRequest(format!( + "raw app bundle is too large: {limit} bytes left of the budget the js and css share" + ))); + } + String::from_utf8(out) + .map_err(|e| Error::internal_err(format!("raw app bundle is not valid utf-8: {e}"))) +} + +async fn shared_ui_files( + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, +) -> Result> { + let mut tx = user_db.clone().begin(authed).await?; + let files = sqlx::query_scalar!( + "SELECT files FROM workspace_shared_ui WHERE workspace_id = $1", + w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + Ok(files + .and_then(|f| serde_json::from_value::>(f).ok()) + .unwrap_or_default()) +} diff --git a/backend/windmill-api/src/apps_raw_bundler.ts b/backend/windmill-api/src/apps_raw_bundler.ts new file mode 100644 index 0000000000..ceefc93207 --- /dev/null +++ b/backend/windmill-api/src/apps_raw_bundler.ts @@ -0,0 +1,121 @@ +/** + * Bundles a raw app's sources into the js/css a deployed raw app serves. Runs as + * a bun job so the compile happens on a worker, not in the API process — see + * `apps_raw_bundle.rs`, which is the only thing that runs it. + * + * The build itself is `wmill app bundle`, the same one `wmill app push` runs, so + * an app deployed through the API is compiled exactly as the CLI and the editor + * compile it — entry point, virtual `wmill` module, `/ui/` shared-UI resolution + * and the Svelte/Vue plugins included. Reimplementing any of that here would be + * a third bundler to keep in step with the other two. + */ +export async function main( + files: Record, + shared_ui: Record | undefined, + cli_command: string[], + // Set unless the server was told to build with a specific command. + prefer_installed_cli: boolean | undefined +): Promise<{ js_gz: string; css_gz: string }> { + const fs = await import('node:fs/promises') + const path = await import('node:path') + + const dir = path.join(process.cwd(), 'wm_raw_app') + await fs.rm(dir, { recursive: true, force: true }) + + // Where a key lands, `path.join` normalising `./` and `..` away. Everything + // that reasons about a file goes through this, so nothing disagrees with what + // was actually written. + const target = (rel: string) => { + const abs = path.join(dir, rel.replace(/^\/+/, '')) + if (!abs.startsWith(dir + path.sep)) { + throw new Error(`file path escapes the build directory: ${rel}`) + } + return abs + } + const write = async (rel: string, content: string) => { + const abs = target(rel) + await fs.mkdir(path.dirname(abs), { recursive: true }) + await fs.writeFile(abs, content) + } + for (const [p, content] of Object.entries(files ?? {})) { + await write(p, content) + } + // `ui/` next to the app is where `wmill app bundle` looks for the shared UI. + for (const [p, content] of Object.entries(shared_ui ?? {})) { + await write('ui/' + p.replace(/^\/+/, ''), content) + } + + const manifest = path.join(dir, 'package.json') + const hasPackageJson = Object.keys(files ?? {}).some((p) => target(p) === manifest) + // Piped rather than inherited so the output can go in the error too, then + // echoed either way — the job's log is where someone looks to see what the + // build did. + const spawn = (argv: string[]) => { + const proc = Bun.spawnSync(argv, { cwd: dir, stdout: 'pipe', stderr: 'pipe' }) + const output = proc.stdout.toString() + proc.stderr.toString() + console.log(output) + return { ok: proc.exitCode === 0, output } + } + const run = (argv: string[], what: string) => { + const { ok, output } = spawn(argv) + if (!ok) { + throw new Error(`${what} failed:\n${output}`) + } + return output + } + + if (hasPackageJson) { + // Installed here rather than left to the CLI so it can be --ignore-scripts: + // the app's dependencies are compiled, never run, so a package's lifecycle + // script has no business executing on the worker. The CLI skips its own + // install once node_modules exists. + run(['bun', 'install', '--ignore-scripts'], 'bun install') + } else { + // The CLI installs when node_modules is missing, and it shells out to npm, + // which the slim images don't ship. An app with no manifest has nothing to + // install, so hand it the empty directory it would have produced. + await fs.mkdir(path.join(dir, 'node_modules'), { recursive: true }) + } + + const outDir = path.join(dir, 'dist') + // Prefer the CLI the image installed: no npm reachability needed at deploy + // time. It can predate `app bundle` (the images install it unpinned), and a + // CLI without the command exits with cliffy's usage text before building + // anything — so that specific failure, and only it, falls back to fetching + // the one for this server's release. `wmill --version` isn't used to decide: + // it reports npm's latest release as well as its own, and it reaches out to + // npm to do so, which is the cost this branch exists to avoid. + const installed = prefer_installed_cli ? Bun.which('wmill') : null + let buildOutput: string | undefined + if (installed) { + const attempt = spawn([installed, 'app', 'bundle', dir, '--out', outDir]) + // Colours sit between the words cliffy prints, so match on the stripped text. + const plain = attempt.output.replace(/\x1b\[[0-9;]*m/g, '') + if (attempt.ok) { + buildOutput = attempt.output + } else if (!/Unknown command|Usage:\s+wmill app\b/.test(plain)) { + throw new Error(`bundle failed:\n${attempt.output}`) + } + } + if (buildOutput === undefined) { + buildOutput = run([...cli_command, dir, '--out', outDir], 'bundle') + } + + const read = async (name: string) => { + try { + return await fs.readFile(path.join(outDir, name), 'utf8') + } catch { + return '' + } + } + const js = await read('bundle.js') + const css = await read('bundle.css') + if (js === '') { + throw new Error('bundle produced no javascript:\n' + buildOutput) + } + + // Gzipped so a large app's bundle stays well inside MAX_RESULT_SIZE_MB, which + // a deployment can set far below the 500MB default. + const gz = (s: string) => Buffer.from(Bun.gzipSync(Buffer.from(s, 'utf8'))).toString('base64') + return { js_gz: gz(js), css_gz: gz(css) } +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 3e63831828..e4ffa16c9e 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -68,6 +68,7 @@ use windmill_common::error::AppError; mod ai; mod ai_skills; mod apps; +mod apps_raw_bundle; pub use apps::invalidate_app_policy_cache; pub mod args; mod audit; diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index f4f39c2412..1584423d16 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -1113,7 +1113,7 @@ Creates a new version of an existing script when called with the same path and t EndpointTool { name: Cow::Borrowed("updateApp"), description: Cow::Borrowed("update app"), - instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/ instead."), + instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those."), path: Cow::Borrowed("/w/{workspace}/apps/update/{path}"), method: Cow::Borrowed("POST"), path_params_schema: Some(serde_json::json!({ @@ -1152,6 +1152,123 @@ Creates a new version of an existing script when called with the same path and t query_field_renames: None, body_field_renames: Some(serde_json::json!({ "path__body": "path" +})), + }, + EndpointTool { + name: Cow::Borrowed("updateAppRawSource"), + description: Cow::Borrowed("update a raw app from its sources, compiling them on a worker (which runs the app's own dependencies to do so)"), + instructions: Cow::Borrowed("Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker by the same build the editor and the CLI run, so a compile error comes back as the error of this call. Compiling runs the app's own dependencies on a worker, so this tool can execute code there. Low-code apps use updateApp instead."), + path: Cow::Borrowed("/w/{workspace}/apps/update_raw_source/{path}"), + method: Cow::Borrowed("POST"), + path_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] +})), + query_params_schema: None, + body_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "value": { + "type": "object", + "description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.", + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "runnables": { + "type": "object" + }, + "data": { + "type": "object" + } + }, + "required": [ + "files" + ] + }, + "policy": { + "type": "object", + "properties": { + "triggerables": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "triggerables_v2": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "s3_inputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "allowed_s3_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "s3_path": { + "type": "string" + }, + "resource": { + "type": "string" + } + } + } + }, + "execution_mode": { + "type": "string", + "description": "Possible values: viewer, publisher, anonymous" + }, + "on_behalf_of": { + "type": "string" + }, + "on_behalf_of_email": { + "type": "string" + }, + "sandbox": { + "type": "boolean", + "description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n" + }, + "frontend_sdk_scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read).\n" + } + } + }, + "path__body": { + "type": "string", + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." + } + }, + "required": [ + "value" + ] +})), + query_field_renames: None, + body_field_renames: Some(serde_json::json!({ + "path__body": "path" })), }, EndpointTool { diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index d68afe826d..379f007f89 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -551,7 +551,46 @@ fn jwt_scopes_for_proxied_route( None, ) })?; - Ok(Some(vec![scope])) + let mut scopes = vec![scope]; + if let Some((tool, extras)) = extra_scopes_for_route(route_path) { + // Only when the token names this tool. `mcp:all` and `mcp:favorites` reach + // every endpoint without naming any, and they are the create-token and + // OAuth defaults — a token whose consent screen talks about scripts and + // flows must not come with this. + if caller_scopes.is_some_and(|s| selects_endpoint_tool(s, tool)) { + scopes.extend(extras.iter().map(|s| (*s).to_string())); + } + } + Ok(Some(scopes)) +} + +/// The tool a route belongs to and the scopes its handler requires beyond the +/// one the route's own domain implies, added to the JWT minted for that single +/// proxied request. +/// +/// Minting is what keeps the grant *confined*: the JWT is built here and handed +/// to the internal request, never to the client, so the MCP token itself stays +/// `mcp:`-only and can't reach `/jobs/run/preview`. Putting the scope on the +/// token instead would widen every request it makes, which is a far larger grant +/// than the tool needs. +fn extra_scopes_for_route(route_path: &str) -> Option<(&'static str, &'static [&'static str])> { + // Matched on segments: a runnable path is caller-chosen and can contain + // anything, so a script called `f/apps/update_raw_source/x` must not decide + // what its own request is minted. + let mut segments = route_path.split('/').skip_while(|s| *s != "w"); + let (_, _ws) = (segments.next()?, segments.next()?); + // Compiling an app's sources runs the app's own dependencies on a worker, so + // a token reaches that capability only by naming this tool. + (segments.next() == Some("apps") && segments.next() == Some("update_raw_source")) + .then_some(("updateAppRawSource", &["jobs:run"])) +} + +/// Whether the token names `tool` rather than reaching it through a blanket +/// grant. Parsed by `windmill-mcp`, which owns the scope grammar; `mcp:all` and +/// `mcp:favorites` yield `*` or nothing, neither of which names a tool. +fn selects_endpoint_tool(caller_scopes: &[String], tool: &str) -> bool { + windmill_mcp::common::scope::parse_mcp_scopes(caller_scopes) + .is_ok_and(|config| config.endpoints.iter().any(|e| e == tool)) } /// Create HTTP request with authentication @@ -668,6 +707,44 @@ mod tests { ); } + #[test] + fn proxy_jwt_raw_app_source_deploy_needs_the_tool_named() { + // The handler requires jobs:run as well: compiling an app's sources runs + // its dependencies on a worker. It goes in the per-request JWT, not on the + // token — the token stays mcp:-only and so can't reach /jobs/run/preview — + // and only for a token that named this tool. The defaults (mcp:favorites + // on create, mcp:all through OAuth) reach every endpoint without naming + // one, and must not carry a capability their consent screen never showed. + let route = "/api/w/ws/apps/update_raw_source/u/admin/app"; + let named = scopes(&["mcp:endpoints:listScripts,updateAppRawSource"]); + assert_eq!( + jwt_scopes_for_proxied_route(Some(&named), "POST", route).unwrap(), + Some(scopes(&["apps:write", "jobs:run"])) + ); + for implicit in [ + scopes(&["mcp:all"]), + scopes(&["mcp:favorites"]), + scopes(&["mcp:endpoints:*"]), + ] { + assert_eq!( + jwt_scopes_for_proxied_route(Some(&implicit), "POST", route).unwrap(), + Some(scopes(&["apps:write"])), + "a token that never named the tool must not get jobs:run" + ); + } + // A runnable path is caller-chosen, so it must not select the extras of a + // route it merely spells out. + assert_eq!( + jwt_scopes_for_proxied_route( + Some(&named), + "POST", + "/api/w/ws/jobs/run/p/f/apps/update_raw_source/x" + ) + .unwrap(), + Some(scopes(&["jobs:run:scripts"])) + ); + } + #[test] fn proxy_jwt_mixed_token_passes_through_caller_route_scope() { // The caller's route scope is preserved so the target handler's per-path diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index f597398a95..7772dafedd 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -16,6 +16,7 @@ import { getWmillYamlPath, mergeConfigWithConfigFile } from "../../core/conf.ts" import { readInlinePathSync } from "../../utils/utils.ts"; import devCommand from "./dev.ts"; import lintCommand from "./lint.ts"; +import bundleCommand from "./bundle_command.ts"; import newCommand from "./new.ts"; import generateAgentsCommand from "./generate_agents.ts"; import { isVersionsGeq1585 } from "../sync/global.ts"; @@ -436,6 +437,7 @@ const command = new Command() .action(push as any) .command("dev", devCommand) .command("lint", lintCommand) + .command("bundle", bundleCommand) .command("new", newCommand) .command("generate-agents", generateAgentsCommand) .command( diff --git a/cli/src/commands/app/bundle_command.ts b/cli/src/commands/app/bundle_command.ts new file mode 100644 index 0000000000..bef13f4268 --- /dev/null +++ b/cli/src/commands/app/bundle_command.ts @@ -0,0 +1,72 @@ +import { Command } from "@cliffy/command"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import process from "node:process"; +import * as log from "../../core/log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { createBundle, detectFrameworks } from "./bundle.ts"; + +interface BundleOptions { + out?: string; + minify?: boolean; +} + +/** Every entry point a raw app may have, most specific first, with the + * framework's preferred one promoted — Svelte and Vue mount from a `.ts`. */ +function entryPointFor(appDir: string): string { + const frameworks = detectFrameworks(appDir); + const candidates = frameworks.svelte || frameworks.vue + ? ["index.ts", "index.tsx", "index.js"] + : ["index.tsx", "index.ts", "index.js"]; + const entry = candidates.find((c) => fs.existsSync(path.join(appDir, c))); + if (!entry) { + throw new Error( + `No entry point in ${appDir}: expected one of ${candidates.join(", ")}`, + ); + } + return path.join(appDir, entry); +} + +/** + * Bundle a raw app folder without deploying it. `wmill app push` bundles as part + * of deploying; this exposes the same build on its own, so anything that needs a + * raw app's js/css — the server-side bundler behind `/apps/update_raw_source`, + * most of all — runs this exact build rather than reimplementing it. + * + * The result goes to files, not stdout: the build logs to stdout as it runs, so + * a caller can't read the bundle off the pipe. + */ +async function bundleApp(opts: BundleOptions, appFolder?: string) { + const appDir = path.resolve(appFolder ?? process.cwd()); + + const { js, css } = await createBundle({ + entryPoint: entryPointFor(appDir), + production: true, + minify: opts.minify ?? true, + // Same as `wmill app push`: the workspace's shared UI is `ui/` under the + // directory the command runs from, not under the app. + sharedUiDir: path.join(process.cwd(), "ui"), + // createBundle removes its own outDir when it is done, and it resolves the + // path against cwd — so name one that is ours to delete rather than let it + // default to `dist`, which is very likely the caller's. + outDir: `.wmill-bundle-${process.pid}`, + }); + + const outDir = path.resolve(opts.out ?? path.join(appDir, "dist")); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "bundle.js"), js); + fs.writeFileSync(path.join(outDir, "bundle.css"), css); + log.info(colors.green(`Wrote bundle.js and bundle.css to ${outDir}`)); +} + +const command = new Command() + .description("Bundle a raw app folder to js/css without deploying it") + .arguments("[app_folder:string]") + .option( + "--out ", + "Directory to write bundle.js and bundle.css into (default: /dist)", + ) + .option("--no-minify", "Skip minification") + .action(bundleApp as any); + +export default command; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 5a2b599ea8..71047bbec5 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6737,6 +6737,9 @@ app related commands - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability - \`--fix\` - Attempt to fix common issues (not implemented yet) +- \`app bundle [app_folder:string]\` - Bundle a raw app folder to js/css without deploying it + - \`--out \` - Directory to write bundle.js and bundle.css into (default: /dist) + - \`--no-minify\` - Skip minification - \`app new\` - create a new raw app from a template - \`--summary \` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - \`--path \` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 103969952f..fc68e885dc 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -1121,7 +1121,7 @@ export const mcpEndpointTools: EndpointTool[] = [ { name: "updateApp", description: "update app", - instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/ instead.", + instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those.", path: "/w/{workspace}/apps/update/{path}", method: "POST", pathParamsSchema: { @@ -1160,6 +1160,123 @@ export const mcpEndpointTools: EndpointTool[] = [ queryFieldRenames: undefined, bodyFieldRenames: { "path__body": "path" +} + }, + { + name: "updateAppRawSource", + description: "update a raw app from its sources, compiling them on a worker (which runs the app's own dependencies to do so)", + instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker by the same build the editor and the CLI run, so a compile error comes back as the error of this call. Compiling runs the app's own dependencies on a worker, so this tool can execute code there. Low-code apps use updateApp instead.", + path: "/w/{workspace}/apps/update_raw_source/{path}", + method: "POST", + pathParamsSchema: { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] +}, + queryParamsSchema: undefined, + bodySchema: { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "value": { + "type": "object", + "description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.", + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "runnables": { + "type": "object" + }, + "data": { + "type": "object" + } + }, + "required": [ + "files" + ] + }, + "policy": { + "type": "object", + "properties": { + "triggerables": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "triggerables_v2": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "s3_inputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "allowed_s3_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "s3_path": { + "type": "string" + }, + "resource": { + "type": "string" + } + } + } + }, + "execution_mode": { + "type": "string", + "description": "Possible values: viewer, publisher, anonymous" + }, + "on_behalf_of": { + "type": "string" + }, + "on_behalf_of_email": { + "type": "string" + }, + "sandbox": { + "type": "boolean", + "description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n" + }, + "frontend_sdk_scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read).\n" + } + } + }, + "path__body": { + "type": "string", + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." + } + }, + "required": [ + "value" + ] +}, + queryFieldRenames: undefined, + bodyFieldRenames: { + "path__body": "path" } }, { diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 38d0974f75..8029ae48c7 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -35,6 +35,9 @@ app related commands - `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability - `--fix` - Attempt to fix common issues (not implemented yet) +- `app bundle [app_folder:string]` - Bundle a raw app folder to js/css without deploying it + - `--out ` - Directory to write bundle.js and bundle.css into (default: /dist) + - `--no-minify` - Skip minification - `app new` - create a new raw app from a template - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 173a5b50d0..285b217db6 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2935,6 +2935,9 @@ app related commands - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability - \`--fix\` - Attempt to fix common issues (not implemented yet) +- \`app bundle [app_folder:string]\` - Bundle a raw app folder to js/css without deploying it + - \`--out \` - Directory to write bundle.js and bundle.css into (default: /dist) + - \`--no-minify\` - Skip minification - \`app new\` - create a new raw app from a template - \`--summary \` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - \`--path \` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 3538432694..ebd0d7f4da 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -40,6 +40,9 @@ app related commands - `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability - `--fix` - Attempt to fix common issues (not implemented yet) +- `app bundle [app_folder:string]` - Bundle a raw app folder to js/css without deploying it + - `--out ` - Directory to write bundle.js and bundle.css into (default: /dist) + - `--no-minify` - Skip minification - `app new` - create a new raw app from a template - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.