mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
feat: deploy a raw app from its sources, bundling them on a worker (#10500)
* feat: deploy a raw app from its sources, bundling them on a worker * refactor: bundle raw app sources with the wmill CLI instead of a second bundler * fix: address review findings on the raw app source deploy * fix: bound bundle decompression, drop the npm dependency on slim workers * fix: stop minting jobs:run for the source deploy, share the decode budget * feat: let an MCP token grant the scopes its selected tools require * fix: carry a caller-held extra scope through the MCP proxy * fix: confine the run scope to the proxied request instead of the token * fix: mint the run scope only for a token that names the tool * fix: require write access before compiling, and state the grant where it is granted * fix: let the database decide write access instead of restating its policies * fix: answer a write denial with 403, not 401
This commit is contained in:
+23
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
@@ -411,6 +411,19 @@ async fn test_raw_app_kind_is_not_flipped_by_update(db: Pool<Postgres>) -> 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" }))
|
||||
|
||||
@@ -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/<path> 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
|
||||
|
||||
@@ -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<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> Result<String> {
|
||||
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<String, String>,
|
||||
}
|
||||
|
||||
fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option<bool>) -> 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/<group>/…` 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<bool> {
|
||||
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<Option<bool>> {
|
||||
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<UserDB>,
|
||||
@@ -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<String> = None;
|
||||
|
||||
@@ -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<String> {
|
||||
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<String, String>,
|
||||
) -> 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<String, Box<serde_json::value::RawValue>> = 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<String> {
|
||||
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<HashMap<String, String>> {
|
||||
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::<HashMap<String, String>>(f).ok())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
@@ -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<string, string>,
|
||||
shared_ui: Record<string, string> | 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) }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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/<path> 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <dir:string>",
|
||||
"Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)",
|
||||
)
|
||||
.option("--no-minify", "Skip minification")
|
||||
.action(bundleApp as any);
|
||||
|
||||
export default command;
|
||||
Generated
+3
@@ -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 <dir:string>\` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
|
||||
- \`--no-minify\` - Skip minification
|
||||
- \`app new\` - create a new raw app from a template
|
||||
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
|
||||
@@ -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/<path> 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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 <dir:string>` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
|
||||
- `--no-minify` - Skip minification
|
||||
- `app new` - create a new raw app from a template
|
||||
- `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
- `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
|
||||
@@ -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 <dir:string>\` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
|
||||
- \`--no-minify\` - Skip minification
|
||||
- \`app new\` - create a new raw app from a template
|
||||
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
|
||||
@@ -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 <dir:string>` - Directory to write bundle.js and bundle.css into (default: <app_folder>/dist)
|
||||
- `--no-minify` - Skip minification
|
||||
- `app new` - create a new raw app from a template
|
||||
- `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
- `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
|
||||
|
||||
Reference in New Issue
Block a user