From 220cd35cf799c42ebf588bc97a6d8e6f4e97c2e3 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 3 Jun 2026 13:58:12 +0200 Subject: [PATCH] feat: support $f/ and $u/ import path aliases for scripts (#9378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support $f/ and $u/ import path aliases for scripts $f/ and $u/ are local-friendly aliases for the absolute workspace import paths /f/ and /u/. Unlike the /-prefixed form (which local tools treat as a filesystem-root path), the $-prefixed form is a bare specifier that can be remapped via tsconfig paths / Deno import maps, so the same import resolves on the Windmill worker and in a local editor. - worker: recognize $f//$u/ in the Deno import map and both Bun loaders - dep-map/parser: normalize $f/->f/, $u/->u/ for lockgen + dep tracking - cli: emit $f/$u path aliases in generated tsconfig.json / deno.json - frontend: ATA + Monaco paths resolve $f//$u/ type hints in the editor Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): split generated tsconfig into managed + user file with refresh command Mirror the AGENTS.cli.md/AGENTS.md prompts model for the IDE tsconfig so the recommended settings can evolve without ever clobbering user customizations: - tsconfig.wmill.json: wmill-managed, always refreshed, holds recommended compilerOptions incl. the $f/$u path aliases (Deno: import_map.wmill.json) - tsconfig.json: user-owned, created once, just extends the managed file; warn (never auto-edit) when an existing one doesn't reference it - add 'wmill refresh tsconfig'; init generates it unconditionally (no longer gated behind resource-type namespace / a bound workspace) - regenerate CLI guidance docs for the new subcommand Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): address PR review on $f/ tsconfig generation - handle existing deno.jsonc so we don't shadow it with a new deno.json (P1 identified by cubic) - fix the bun-types hint that pointed users at the managed do-not-edit tsconfig.wmill.json; tell them to install + re-run 'wmill refresh tsconfig' - document the .ts-extension-only local-resolution limitation (cross-flavor .bun.ts/.deno.ts/.fetch.ts scripts won't resolve in a local editor) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): warn when a project's tsconfig isn't wired to tsconfig.wmill.json Mirror the prompts freshness check for the managed tsconfig so users with an existing setup actually discover they're missing $f//$u/ resolution: - embed a version hash in tsconfig.wmill.json (excludes the env-dependent bun-types 'types' entry so it doesn't false-positive) - add warnIfTsconfigStale to the main.ts freshness hook, gated identically to the prompts check (skips init/refresh/help/version). When a tsconfig.json exists it warns one line (stderr) if the managed file is missing, not referenced via extends, or out of date; silent for non-TS projects. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(cli): make tsconfig setup equivalent to prompts (auto-wire + stale-only) Unify the two managed-file systems so they behave identically: - auto-wire an existing unlinked tsconfig.json/deno.json on init/refresh (add extends / importMap; merge into an array extends), instead of only warning. Parses JSON and falls back to a warning when it can't round-trip (JSONC comments, or a conflicting deno imports/importMap) — never corrupts. - narrow warnIfTsconfigStale to stale-only, gated on the managed file existing, exactly like warnIfPromptsStale: it no longer nags about a missing or unlinked tsconfig.json, so a deliberately-custom/unlinked setup stays silent and a not-yet-initialized project isn't bothered. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): place tsconfig.wmill.json first in extends to preserve user base config Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): migrate legacy tsconfig and require consent for custom configs Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(cli): align prompts wiring to the same consent model Co-Authored-By: Claude Opus 4.8 (1M context) * chore(cli): bump windmill-parser-wasm-ts to 1.714.0 for $f/ $u/ aliases Co-Authored-By: Claude Opus 4.8 (1M context) * fix(worker): resolve $f/ and $u/ in deno lock generation Co-Authored-By: Claude Opus 4.8 (1M context) * test: narrow relative-imports lock-gen guard to deno import-map failure Co-Authored-By: Claude Opus 4.8 (1M context) * chore(cli): sync bun.lock with windmill-parser-wasm-ts 1.714.0 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): warn when a custom tsconfig's paths would shadow $f/ $u/ aliases Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/parsers/windmill-parser-ts/src/lib.rs | 20 +- .../parsers/windmill-parser-ts/tests/tests.rs | 26 +- backend/tests/worker.rs | 43 ++ backend/windmill-dep-map/src/lib.rs | 5 + backend/windmill-test-utils/src/lib.rs | 20 + backend/windmill-worker/loader.bun.js | 26 +- backend/windmill-worker/loader.bun.windows.js | 12 +- backend/windmill-worker/src/deno_executor.rs | 9 +- cli/bun.lock | 4 +- cli/package-lock.json | 8 +- cli/package.json | 2 +- cli/src/commands/init/init.ts | 13 + cli/src/commands/refresh/prompts.ts | 14 +- cli/src/commands/refresh/refresh.ts | 8 +- cli/src/commands/refresh/tsconfig.ts | 499 ++++++++++++++++++ .../commands/resource-type/resource-type.ts | 3 - cli/src/commands/resource-type/tsconfig.ts | 87 --- cli/src/guidance/skills.gen.ts | 6 +- cli/src/main.ts | 4 + frontend/src/lib/ata/index.ts | 16 +- .../lib/components/monacoLanguagesOptions.ts | 8 + frontend/src/lib/relative_imports.ts | 4 +- .../auto-generated/cli/cli-commands.md | 6 +- system_prompts/auto-generated/prompts.ts | 6 +- .../skills/cli-commands/SKILL.md | 6 +- 25 files changed, 720 insertions(+), 135 deletions(-) create mode 100644 cli/src/commands/refresh/tsconfig.ts delete mode 100644 cli/src/commands/resource-type/tsconfig.ts diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 1e78ec8665..29fc376eb0 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -129,7 +129,10 @@ impl Visit for ImportsFinder { /// See also: [`parse_relative_imports`] for resolved absolute paths. pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Result> { let cm: Lrc = Default::default(); - let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.to_string()); + let fm = cm.new_source_file( + FileName::Custom("main.d.ts".into()).into(), + code.to_string(), + ); let mut tss = TsSyntax::default(); tss.disallow_ambiguous_jsx_like; tss.tsx = true; @@ -194,8 +197,10 @@ pub fn parse_relative_imports(code: &str, path: &str) -> anyhow::Result anyhow::Result bool { - import_path.starts_with("./") || import_path.starts_with("../") || import_path.starts_with("/") + import_path.starts_with("./") + || import_path.starts_with("../") + || import_path.starts_with("/") + || import_path.starts_with("$f/") + || import_path.starts_with("$u/") } /// Normalize a path by resolving `.` and `..` components diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 4309018fb4..1f0e757c36 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -891,18 +891,40 @@ mod tests { assert_eq!(result, vec!["f/shared/utils"]); } + #[test] + fn test_relative_imports_dollar_alias() { + // `$f/`/`$u/` are local-friendly aliases for the absolute workspace paths + // `/f/`/`/u/` and must resolve to the same workspace-rooted path. + let code = r#" + import { shared } from "$f/shared/utils.ts"; + import { mine } from "$u/me/lib"; + export async function main() { return shared() + mine(); } + "#; + let result = parse_relative_imports(code, "f/folder/script").unwrap(); + assert_eq!(result, vec!["f/shared/utils", "u/me/lib"]); + } + #[test] fn test_relative_imports_mixed() { let code = r#" import { helper } from "./helper"; import { utils } from "../utils"; import { shared } from "/f/shared/lib"; + import { aliased } from "$f/shared/aliased.ts"; import lodash from "lodash"; - export async function main() { return helper() + utils() + shared(); } + export async function main() { return helper() + utils() + shared() + aliased(); } "#; let result = parse_relative_imports(code, "f/folder/script").unwrap(); // Should only include relative imports, not external packages like lodash - assert_eq!(result, vec!["f/folder/helper", "f/shared/lib", "f/utils"]); + assert_eq!( + result, + vec![ + "f/folder/helper", + "f/shared/aliased", + "f/shared/lib", + "f/utils" + ] + ); } #[test] diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index f21862fd38..14fc9ffb76 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4763,6 +4763,28 @@ export async function main() { Ok(()) } +#[sqlx::test(fixtures("base", "relative_bun"))] +async fn test_relative_imports_bun_dollar_alias(db: Pool) -> anyhow::Result<()> { + // `$f/` is a local-friendly alias for the absolute workspace path `/f/` and must + // resolve identically on the worker. Mix it with relative imports to confirm both + // paths coexist. + let content = r#" +import { main as test1 } from "$f/system/same_folder_script.ts"; +import { main as test2 } from "./same_folder_script.ts"; +import { main as test3 } from "$f/system_relative/different_folder_script.ts"; +import { main as test4 } from "../system_relative/different_folder_script.ts"; + +export async function main() { + return [test1(), test2(), test3(), test4()]; +} +"# + .to_string(); + + run_deployed_relative_imports(&db, content.clone(), ScriptLang::Bun).await?; + run_preview_relative_imports(&db, content, ScriptLang::Bun).await?; + Ok(()) +} + #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base", "relative_bun"))] async fn test_nested_imports_bun(db: Pool) -> anyhow::Result<()> { @@ -4799,6 +4821,27 @@ export async function main() { Ok(()) } +#[sqlx::test(fixtures("base", "relative_deno"))] +async fn test_relative_imports_deno_dollar_alias(db: Pool) -> anyhow::Result<()> { + // `$f/` is a local-friendly alias for the absolute workspace path `/f/` and must + // resolve identically via the Deno import map. Mix it with relative imports. + let content = r#" +import { main as test1 } from "$f/system/same_folder_script.ts"; +import { main as test2 } from "./same_folder_script.ts"; +import { main as test3 } from "$f/system_relative/different_folder_script.ts"; +import { main as test4 } from "../system_relative/different_folder_script.ts"; + +export async function main() { + return [test1(), test2(), test3(), test4()]; +} +"# + .to_string(); + + run_deployed_relative_imports(&db, content.clone(), ScriptLang::Deno).await?; + run_preview_relative_imports(&db, content, ScriptLang::Deno).await?; + Ok(()) +} + #[sqlx::test(fixtures("base", "relative_deno"))] async fn test_nested_imports_deno(db: Pool) -> anyhow::Result<()> { let content = r#" diff --git a/backend/windmill-dep-map/src/lib.rs b/backend/windmill-dep-map/src/lib.rs index ee6a3c3fd4..ca45012bdf 100644 --- a/backend/windmill-dep-map/src/lib.rs +++ b/backend/windmill-dep-map/src/lib.rs @@ -50,6 +50,11 @@ fn parse_ts_relative_imports( let import = import.trim_end_matches(".ts"); if import.starts_with("/") { relative_imports.push(import.trim_start_matches("/").to_string()); + } else if import.starts_with("$f/") || import.starts_with("$u/") { + // `$f/...`/`$u/...` are local-friendly aliases for the absolute workspace + // paths `/f/...`/`/u/...`. Stripping the leading `$` yields the same + // workspace-rooted path the rest of the dependency machinery expects. + relative_imports.push(import.trim_start_matches("$").to_string()); } else if import.starts_with(".") { let normalized = try_normalize(std::path::Path::new(&format!( "{}/../{}", diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 79dcc3bd84..6aab1b06fd 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -914,6 +914,26 @@ pub async fn run_deployed_relative_imports( .await .unwrap(); + // Regression guard for the Deno lock-gen import map (generate_deno_lock): + // it must resolve the `$f/`/`$u/` aliases, otherwise `deno cache --lock` + // fails with "not a dependency and not in import map". We match that + // specific failure rather than asserting lock_error_logs is empty — + // the field also captures benign, non-fatal lock-job output (e.g. Bun's + // "empty dependencies, skipping install"). (Runtime query to avoid + // touching the sqlx offline cache.) + let lock_error: Option = + sqlx::query_scalar("SELECT lock_error_logs FROM script WHERE path = $1") + .bind("f/system/test_import") + .fetch_one(&db2) + .await + .unwrap(); + if let Some(err) = &lock_error { + assert!( + !err.contains("not in import map"), + "lock generation failed to resolve a workspace import (likely $f//$u/): {err}" + ); + } + let job = RunJob::from(JobPayload::ScriptHash { path: "f/system/test_import".to_string(), hash: ScriptHash(script.hash), diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index d03f19b6ba..c48cff4a4a 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -38,7 +38,9 @@ const p = { if ( (imp.path.startsWith(".") || imp.path.startsWith("/u/") || - imp.path.startsWith("/f/")) && + imp.path.startsWith("/f/") || + imp.path.startsWith("$u/") || + imp.path.startsWith("$f/")) && !imp.path.endsWith(".ts") ) { code = code.replaceAll(imp.path, imp.path + ".ts"); @@ -97,21 +99,29 @@ const p = { ? current_path : args.importer.replace(cdir + "/", ""); - const isRelative = !args.path.startsWith("/"); - const endExt = args.path.endsWith(".ts") ? "" : ".ts"; - const pathNoExt = args.path.replace(/\.ts$/, ""); + // `$f/...`/`$u/...` are local-friendly aliases for the absolute workspace + // paths `/f/...`/`/u/...`; rewrite to the `/`-prefixed form so the absolute + // resolution branch below handles them identically. + const importPath = + args.path.startsWith("$f/") || args.path.startsWith("$u/") + ? "/" + args.path.slice(1) + : args.path; + + const isRelative = !importPath.startsWith("/"); + const endExt = importPath.endsWith(".ts") ? "" : ".ts"; + const pathNoExt = importPath.replace(/\.ts$/, ""); // Lookup temp script hash const normalized = (isRelative ? join(dirname(file_path), pathNoExt) : pathNoExt.slice(1)).replace(/\\/g, "/"); const hash = TEMP_SCRIPT_REFS?.[normalized]; const url = (isRelative - ? `${base_internal_url}/api/w/${w_id}/scripts/raw_unpinned/p/${file_path}/../${args.path}${endExt}` - : `${base_internal_url}/api/w/${w_id}/scripts/raw_unpinned/p/${args.path}${endExt}` + ? `${base_internal_url}/api/w/${w_id}/scripts/raw_unpinned/p/${file_path}/../${importPath}${endExt}` + : `${base_internal_url}/api/w/${w_id}/scripts/raw_unpinned/p/${importPath}${endExt}` ) + (hash ? `?temp_script_hash=${hash}` : ""); const file = isRelative - ? resolve("./" + file_path + "/../" + args.path + ".url") - : resolve("./" + args.path + ".url"); + ? resolve("./" + file_path + "/../" + importPath + ".url") + : resolve("./" + importPath + ".url"); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, url); return { diff --git a/backend/windmill-worker/loader.bun.windows.js b/backend/windmill-worker/loader.bun.windows.js index e89b70c26c..91cb94b3c4 100644 --- a/backend/windmill-worker/loader.bun.windows.js +++ b/backend/windmill-worker/loader.bun.windows.js @@ -44,7 +44,9 @@ const p = { if ( (imp.path.startsWith(".") || imp.path.startsWith("/u/") || - imp.path.startsWith("/f/")) && + imp.path.startsWith("/f/") || + imp.path.startsWith("$u/") || + imp.path.startsWith("$f/")) && !imp.path.endsWith(".ts") ) { code = code.replaceAll(imp.path, imp.path + ".ts"); @@ -67,7 +69,13 @@ const p = { // Resolve a windmill script import path relative to an importer path. // Bun on Windows may prefix args with "windmill-url:" or strip leading "/". function resolveWindmillImport(importerPath, importPath) { - const path = importPath.replace(/^windmill-url:/, "").replace(/^\//, ""); + // Strip the "windmill-url:" namespace prefix, an optional leading "/" + // (absolute `/f/`,`/u/`), and the local-friendly "$" alias prefix + // (`$f/`,`$u/`) so absolute workspace imports normalize to `f/`,`u/`. + const path = importPath + .replace(/^windmill-url:/, "") + .replace(/^\//, "") + .replace(/^\$(?=[fu]\/)/, ""); const isAbsolute = path.startsWith("f/") || path.startsWith("u/"); const endExt = path.endsWith(".ts") ? "" : ".ts"; const rawScriptPath = isAbsolute diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index c39e9e999e..c7762818ae 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -170,10 +170,15 @@ pub async fn generate_deno_lock( let _ = write_file(job_dir, "main.ts", code)?; let import_map_path = format!("{job_dir}/import_map.json"); + // `$f/`/`$u/` are local-friendly aliases for the absolute workspace paths `/f/`,`/u/`. + // The runtime import map (`build_import_map`) maps them too; without these keys here, + // `deno cache --lock` can't resolve `$f/...` imports and lock generation fails. let import_map = format!( r#"{{ "imports": {{ - "/": "{base_internal_url}/api/scripts_u/empty_ts/" + "/": "{base_internal_url}/api/scripts_u/empty_ts/", + "$f/": "{base_internal_url}/api/scripts_u/empty_ts/f/", + "$u/": "{base_internal_url}/api/scripts_u/empty_ts/u/" }} }}"#, ); @@ -581,6 +586,8 @@ pub(crate) async fn build_import_map( "{base_internal_url}/api/w/{w_id}/scripts/raw/p/": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/", "{base_internal_url}": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/", "/": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/", + "$f/": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/f/", + "$u/": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/u/", "./wrapper.ts": "./wrapper.ts", "./main.ts": "./main.ts"{relative_mounts} {extra_import_map} diff --git a/cli/bun.lock b/cli/bun.lock index c94551beb3..d5765a9fc8 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -29,7 +29,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.695.0", + "windmill-parser-wasm-ts": "1.714.0", "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", @@ -308,7 +308,7 @@ "windmill-parser-wasm-rust": ["windmill-parser-wasm-rust@1.647.1", "", {}, "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="], - "windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.695.0", "", {}, "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw=="], + "windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.714.0", "", {}, "sha512-IvkR+tMupzIviST6IahLyNt+feK1A6ZBFUGHWl0NwJQUT0FHdjSk1BmSULSVdwSXTvvp+xcd2WrkB9pBEM5Giw=="], "windmill-parser-wasm-yaml": ["windmill-parser-wasm-yaml@1.593.0", "", {}, "sha512-Gyx4aR2jsJYuDrD3mCNTmz7LWOQQXPw5yKNCC1xRgUOPfjsD/tINAFfsBLwVOSmlQQcFZO+wHm4KtDtXOcnGVw=="], diff --git a/cli/package-lock.json b/cli/package-lock.json index f14fd32f8d..a20a9e9934 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -29,7 +29,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.695.0", + "windmill-parser-wasm-ts": "1.714.0", "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", @@ -1453,9 +1453,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.695.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz", - "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" + "version": "1.714.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.714.0.tgz", + "integrity": "sha512-IvkR+tMupzIviST6IahLyNt+feK1A6ZBFUGHWl0NwJQUT0FHdjSk1BmSULSVdwSXTvvp+xcd2WrkB9pBEM5Giw==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", diff --git a/cli/package.json b/cli/package.json index 2f46e3829b..ed2759599e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -38,7 +38,7 @@ "windmill-parser-wasm-regex": "1.692.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.695.0", + "windmill-parser-wasm-ts": "1.714.0", "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index fd6b93380d..f1a10d7cc9 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -18,6 +18,7 @@ import { import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { generateCommentedTemplate } from "./template.ts"; import { refreshPrompts } from "../refresh/prompts.ts"; +import { refreshTsconfig } from "../refresh/tsconfig.ts"; export interface InitOptions { useDefault?: boolean; @@ -238,6 +239,18 @@ async function initAction(opts: InitOptions) { await refreshPrompts({ yes: opts.useDefault === true }); + // Generate the IDE tsconfig (managed tsconfig.wmill.json + user tsconfig.json + // that extends it). Independent of any workspace binding — it's purely local. + try { + await refreshTsconfig({ yes: opts.useDefault === true }); + } catch (error) { + log.warn( + `Could not generate tsconfig: ${ + error instanceof Error ? error.message : error + }` + ); + } + // Generate resource type namespace (only if a workspace was bound) if (didBindWorkspace && boundProfile) { try { diff --git a/cli/src/commands/refresh/prompts.ts b/cli/src/commands/refresh/prompts.ts index 3f933d3de2..863ab4f9ff 100644 --- a/cli/src/commands/refresh/prompts.ts +++ b/cli/src/commands/refresh/prompts.ts @@ -32,7 +32,8 @@ export async function refreshPrompts(opts: { // If config can't be read, use the conservative default above. } - const interactive = process.stdin.isTTY && !opts.yes; + const assumeYes = opts.yes === true; + const interactive = process.stdin.isTTY && !assumeYes; try { const result = await writeAiGuidanceFiles({ @@ -42,8 +43,13 @@ export async function refreshPrompts(opts: { agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV], claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV], resolveAgentsMdMigration: async () => { - if (!interactive) return "append"; - return await promptMigration(); + // Consent model (matches `wmill refresh tsconfig`): we only touch an + // existing user-owned file that we don't recognize when the user opts + // in. `--yes` (and `wmill init --default`) appends without asking; an + // interactive run prompts; a plain non-interactive run leaves it alone. + if (assumeYes) return "append"; + if (interactive) return await promptMigration(); + return "skip"; }, }); @@ -175,7 +181,7 @@ const command = new Command() .description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.") .option( "--yes", - "Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include." + "Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched." ) .action(promptsAction as any); diff --git a/cli/src/commands/refresh/refresh.ts b/cli/src/commands/refresh/refresh.ts index 882284c428..d1fea23af2 100644 --- a/cli/src/commands/refresh/refresh.ts +++ b/cli/src/commands/refresh/refresh.ts @@ -1,8 +1,12 @@ import { Command } from "@cliffy/command"; import promptsCommand from "./prompts.ts"; +import tsconfigCommand from "./tsconfig.ts"; const command = new Command() - .description("Refresh wmill-managed project files (AGENTS.cli.md and skills)") - .command("prompts", promptsCommand); + .description( + "Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)" + ) + .command("prompts", promptsCommand) + .command("tsconfig", tsconfigCommand); export default command; diff --git a/cli/src/commands/refresh/tsconfig.ts b/cli/src/commands/refresh/tsconfig.ts new file mode 100644 index 0000000000..02f16b9094 --- /dev/null +++ b/cli/src/commands/refresh/tsconfig.ts @@ -0,0 +1,499 @@ +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; +import { readConfigFile } from "../../core/conf.ts"; + +/** + * Local-friendly aliases for the absolute workspace import paths `/f/...` and + * `/u/...`. Unlike the `/`-prefixed form (which every local tool treats as a + * filesystem-root path), `$f/`/`$u/` are bare specifiers that can be remapped to + * the on-disk `f/`/`u/` folders via tsconfig `paths` and Deno import maps, so the + * same import resolves both on the Windmill worker and in a local editor. + */ +const WORKSPACE_IMPORT_ALIASES: Record = { + $f: "f", + $u: "u", +}; + +// wmill-managed files holding the recommended config. They are always +// (re)written so we can ship updated recommendations over time; users keep +// their own overrides in tsconfig.json / deno.json, which reference these +// managed files and are never overwritten. This mirrors how AGENTS.cli.md +// (managed) and AGENTS.md (user-owned) work for AI prompts. +const MANAGED_TSCONFIG = "tsconfig.wmill.json"; +const MANAGED_IMPORT_MAP = "import_map.wmill.json"; + +const MANAGED_NOTICE = + "// Managed by wmill — regenerated by `wmill init` / `wmill refresh tsconfig`.\n" + + "// Do not edit; put your overrides in tsconfig.json (which extends this file).\n"; + +// Embedded in tsconfig.wmill.json so any command can detect a stale managed file +// (the recommended config changed) and nudge the user to `wmill refresh tsconfig` +// — mirroring the prompts freshness marker in AGENTS.cli.md. +const TSCONFIG_HASH_PREFIX = "// wmill-tsconfig-hash: "; +const TSCONFIG_HASH_REGEX = /^\/\/ wmill-tsconfig-hash: ([0-9a-f]{12})/m; + +/** + * The recommended managed tsconfig, minus environment-dependent bits (`types` + * depends on whether bun-types is installed locally). This is both the source + * of the written file and the input to the freshness hash, so the hash only + * changes when wmill's *recommended* config changes — not when bun-types + * appears/disappears on a given machine. + */ +function buildManagedTsconfig(): { + compilerOptions: Record; + include: string[]; +} { + // Map "$f/*" -> ["./f/*"], "$u/*" -> ["./u/*"] so the editor resolves + // workspace imports against the local script folders. + // + // Known limitation: this resolves imports written with a plain `.ts` extension + // (the canonical form). Scripts stored with a flavor-specific extension — + // `.bun.ts`/`.deno.ts`/`.fetch.ts` for languages other than the project default + // (see filePathExtensionFromContentType) — won't resolve via these aliases in a + // local editor. The worker (extension-agnostic API) and the in-app editor (ATA + // normalizes to `.ts`) handle those fine; only local tsc / VS Code is affected. + const paths: Record = {}; + for (const [alias, dir] of Object.entries(WORKSPACE_IMPORT_ALIASES)) { + paths[`${alias}/*`] = [`./${dir}/*`]; + } + return { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + // Workspace imports carry an explicit `.ts` extension (e.g. "$f/foo/bar.ts"); + // allow it so the editor doesn't flag every cross-script import. + allowImportingTsExtensions: true, + noEmit: true, + strict: false, + // No `baseUrl`: with moduleResolution "bundler" the `paths` patterns resolve + // relative to this file, and `baseUrl` is deprecated in TypeScript 7+. + paths, + }, + include: ["**/*.ts", "rt.d.ts"], + }; +} + +function currentTsconfigHash(): string { + return createHash("sha256") + .update(JSON.stringify(buildManagedTsconfig())) + .digest("hex") + .slice(0, 12); +} + +// Exact `tsconfig.json` shapes the *previous* CLI generated (single-file, before +// the managed/user split — see the now-deleted resource-type/tsconfig.ts). When +// an existing tsconfig.json matches one of these verbatim, we know it's ours (not +// a user customization), so we can safely replace it wholesale with the thin stub +// that extends tsconfig.wmill.json. Anything else is treated as user-authored. +const LEGACY_GENERATED_TSCONFIGS: Record[] = [ + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + noEmit: true, + strict: false, + }, + include: ["**/*.ts", "rt.d.ts"], + }, + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + noEmit: true, + strict: false, + types: ["bun-types"], + }, + include: ["**/*.ts", "rt.d.ts"], + }, +]; + +// Order-sensitive deep equality (arrays compared positionally, objects by key +// set). Used only on small parsed JSON config objects. +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((x, i) => deepEqual(x, b[i])); + } + if (a && b && typeof a === "object" && typeof b === "object") { + const ka = Object.keys(a as object); + const kb = Object.keys(b as object); + if (ka.length !== kb.length) return false; + return ka.every((k) => + deepEqual((a as Record)[k], (b as Record)[k]) + ); + } + return false; +} + +// How to handle an existing, user-authored config that doesn't yet reference the +// managed file. `assumeYes` (from `--yes` / `wmill init --default`) wires it +// without asking; otherwise we only wire it after an interactive confirmation — +// a non-interactive run with neither leaves the file untouched. +type WireMode = { interactive: boolean; assumeYes: boolean }; + +/** + * (Re)generate the wmill-managed TypeScript/Deno IDE config so the editor + * resolves `$f/`/`$u/` workspace imports against the local script folders. + * + * Split into a managed base file (always refreshed) and a user-owned file that + * references it. We only ever touch files that are ours: the managed file is + * regenerated, a tsconfig.json still in the previously-generated shape is + * migrated to the new split, and a genuinely custom config is wired only with + * the user's consent (interactive prompt, or `--yes`). + * + * Programmatic entry point reused by `wmill init`; also exposed as + * `wmill refresh tsconfig`. + */ +export async function refreshTsconfig(opts?: { yes?: boolean }): Promise { + let defaultTs: "bun" | "deno" = "bun"; + try { + const conf = await readConfigFile({ warnIfMissing: false }); + if (conf?.defaultTs === "deno") { + defaultTs = "deno"; + } + } catch { + // fall back to bun if wmill.yaml is missing or unreadable + } + + const assumeYes = opts?.yes === true; + const mode: WireMode = { + assumeYes, + interactive: !!process.stdin.isTTY && !assumeYes, + }; + + // tsconfig.json is useful for Bun and general TS tooling regardless of the + // default; the Deno import map is only relevant for Deno-default projects + // (the Deno LSP ignores tsconfig.json). + await refreshManagedTsconfig(defaultTs, mode); + if (defaultTs === "deno") { + await refreshManagedDenoImportMap(mode); + } +} + +async function refreshManagedTsconfig(defaultTs: "bun" | "deno", mode: WireMode) { + const managed = buildManagedTsconfig(); + + // Only reference bun-types if it's actually available; otherwise the IDE + // would flag the missing type definitions. (Excluded from the freshness hash + // since it's environment-, not recommendation-, dependent.) + const bunTypesAvailable = + defaultTs === "bun" ? ensureBunTypesAvailable() : false; + if (bunTypesAvailable) { + managed.compilerOptions.types = ["bun-types"]; + } + + const header = MANAGED_NOTICE + TSCONFIG_HASH_PREFIX + currentTsconfigHash() + "\n"; + writeFileSync( + path.join(process.cwd(), MANAGED_TSCONFIG), + header + JSON.stringify(managed, null, 2) + "\n" + ); + log.info(colors.green(`Refreshed ${MANAGED_TSCONFIG}`)); + + await ensureUserReferencesManaged({ + file: "tsconfig.json", + create: { extends: `./${MANAGED_TSCONFIG}` }, + legacyFormats: LEGACY_GENERATED_TSCONFIGS, + mode, + wire: (parsed) => { + // TypeScript does NOT merge `compilerOptions.paths` across `extends` — the + // nearest config that defines `paths` wins wholesale. So a config with its + // own `paths` would shadow the managed `$f/`/`$u/` mappings and the aliases + // would silently fail to resolve. We don't touch the user's paths — warn + // and leave it for them to wire manually. + const co = parsed.compilerOptions; + const paths = + co && typeof co === "object" && !Array.isArray(co) + ? (co as Record).paths + : undefined; + if ( + paths && + typeof paths === "object" && + !Array.isArray(paths) && + Object.keys(paths).length > 0 + ) { + return ( + "defines its own `compilerOptions.paths` (TS won't merge ours in via " + + '`extends`); add "$f/*": ["./f/*"] and "$u/*": ["./u/*"] to it' + ); + } + const ext = parsed.extends; + const managed = `./${MANAGED_TSCONFIG}`; + // Insert the managed config FIRST in `extends` so the user's own base config + // keeps precedence on overlapping compilerOptions (strict/target/module) + // rather than being overridden by our defaults. + if (ext === undefined) { + parsed.extends = managed; + } else if (typeof ext === "string") { + parsed.extends = [managed, ext]; + } else if (Array.isArray(ext)) { + ext.unshift(managed); + } else { + return "unexpected `extends` value"; + } + return true; + }, + token: MANAGED_TSCONFIG, + hint: `"extends": "./${MANAGED_TSCONFIG}"`, + }); +} + +async function refreshManagedDenoImportMap(mode: WireMode) { + // Import-map prefix keys must end with "/": "$f/" -> "./f/", "$u/" -> "./u/". + const imports: Record = {}; + for (const [alias, dir] of Object.entries(WORKSPACE_IMPORT_ALIASES)) { + imports[`${alias}/`] = `./${dir}/`; + } + + // Deno import maps only allow `imports`/`scopes`, so no comment header here. + writeFileSync( + path.join(process.cwd(), MANAGED_IMPORT_MAP), + JSON.stringify({ imports }, null, 2) + "\n" + ); + log.info(colors.green(`Refreshed ${MANAGED_IMPORT_MAP}`)); + + await ensureUserReferencesManaged({ + file: "deno.json", + // Don't write deno.json if the project already uses deno.jsonc — a new + // deno.json would take precedence and shadow the existing config. + altFiles: ["deno.jsonc"], + create: { importMap: `./${MANAGED_IMPORT_MAP}` }, + mode, + wire: (parsed) => { + // Deno rejects `imports` + `importMap` together, so we can't auto-wire a + // deno.json that already defines its own imports. + if (parsed.imports !== undefined) { + return "deno.json already defines `imports` (can't also use importMap)"; + } + if ( + parsed.importMap !== undefined && + parsed.importMap !== `./${MANAGED_IMPORT_MAP}` + ) { + return "deno.json already sets a different `importMap`"; + } + parsed.importMap = `./${MANAGED_IMPORT_MAP}`; + return true; + }, + token: MANAGED_IMPORT_MAP, + hint: `"importMap": "./${MANAGED_IMPORT_MAP}"`, + }); +} + +/** + * Ensure a user-owned config file references the wmill-managed file. Mirrors how + * `wmill refresh prompts` wires `@AGENTS.cli.md` into AGENTS.md: + * - missing → create the minimal file (already linked); + * - exists & linked → leave it alone; + * - exists & unlinked → auto-wire it (parse JSON, apply `wire`, write back). + * Falls back to a one-line warning when the file can't be auto-edited safely + * (JSONC comments fail JSON.parse, or the structure already conflicts) — we + * never corrupt a file we can't round-trip. + */ +async function ensureUserReferencesManaged(opts: { + file: string; + // Sibling configs that, if already present, must not be shadowed by writing + // `opts.file` next to them (e.g. an existing deno.jsonc vs a new deno.json). + altFiles?: string[]; + create: Record; + // Mutate the parsed user config to reference the managed file. Returns true + // when wired, or a short reason string when it can't be wired cleanly (→ warn). + wire: (parsed: Record) => true | string; + // Verbatim shapes a previous CLI generated for this file. A match means the + // file is ours, so it's replaced wholesale (no prompt); anything else is + // treated as user-authored and only wired with consent. + legacyFormats?: Record[]; + mode: WireMode; + token: string; + hint: string; +}) { + // Prefer any existing config (including alternates) over creating a fresh one, + // so we never shadow a config the user already has. + const existing = [opts.file, ...(opts.altFiles ?? [])] + .map((f) => path.join(process.cwd(), f)) + .find((p) => existsSync(p)); + + if (!existing) { + const userPath = path.join(process.cwd(), opts.file); + writeFileSync(userPath, JSON.stringify(opts.create, null, 2) + "\n"); + log.info(colors.green(`Created ${opts.file} (references ${opts.token})`)); + return; + } + + const existingName = path.basename(existing); + let text = ""; + try { + text = readFileSync(existing, "utf-8"); + } catch { + return; + } + if (text.includes(opts.token)) { + log.info( + colors.gray(`${existingName} already references ${opts.token}, leaving it untouched`) + ); + return; + } + + // We only ever rewrite a config we can round-trip as JSON. Files with comments + // (JSONC) fail JSON.parse, so we warn instead of corrupting them. + let parsed: Record; + try { + parsed = JSON.parse(text); + } catch { + log.warn( + `${existingName} couldn't be auto-edited (it may contain comments). Add ${opts.hint} ` + + `to pick up wmill's recommended settings (incl. $f//$u/ import aliases).` + ); + return; + } + + // The file is still exactly what a previous CLI generated → it's ours, so + // migrate it to the new split (replace with the thin stub that extends the + // managed file). No prompt: we're not touching user-authored content. + if (opts.legacyFormats?.some((fmt) => deepEqual(parsed, fmt))) { + writeFileSync(existing, JSON.stringify(opts.create, null, 2) + "\n"); + log.info( + colors.green( + `Migrated previously-generated ${existingName} to reference ${opts.token}` + ) + ); + return; + } + + // Custom config. Try wiring a clone so we can report un-wireable cases without + // mutating, then only persist with the user's consent. + const next = JSON.parse(JSON.stringify(parsed)) as Record; + const wired = opts.wire(next); + if (wired !== true) { + log.warn( + `${existingName}: ${wired}. Add ${opts.hint} manually to pick up wmill's ` + + `recommended settings (incl. $f//$u/ import aliases).` + ); + return; + } + + const consent = opts.mode.assumeYes + ? true + : opts.mode.interactive + ? await Confirm.prompt({ + message: + `${existingName} isn't linked to wmill's ${opts.token}. Add ${opts.hint}? ` + + `Your settings are preserved (it's inserted first, so your config wins).`, + default: true, + }) + : false; + + if (!consent) { + log.info( + colors.gray( + `Left ${existingName} unchanged — add ${opts.hint} when ready to enable ` + + `$f//$u/ import aliases (or re-run \`wmill refresh tsconfig\`).` + ) + ); + return; + } + + writeFileSync(existing, JSON.stringify(next, null, 2) + "\n"); + log.info(colors.green(`Linked ${existingName} → ${opts.token}`)); +} + +function ensureBunTypesAvailable(): boolean { + const cwd = process.cwd(); + if (existsSync(path.join(cwd, "node_modules", "bun-types"))) { + return true; + } + + try { + execSync("bun --version", { stdio: "ignore" }); + } catch { + log.info( + "Install bun (https://bun.sh), run 'bun add -d bun-types', then re-run 'wmill refresh tsconfig' for Bun API autocompletion." + ); + return false; + } + + try { + log.info( + colors.yellow("Installing bun-types with 'bun add -d bun-types'...") + ); + execSync("bun add -d bun-types", { stdio: "inherit" }); + log.info(colors.green("Installed bun-types.")); + return true; + } catch (e) { + log.warn( + `Failed to install bun-types automatically: ${ + e instanceof Error ? e.message : e + }` + ); + log.info( + "Run 'bun add -d bun-types' manually, then 'wmill refresh tsconfig', for Bun API autocompletion." + ); + return false; + } +} + +/** + * One-line, non-blocking warning (to stderr) when the managed config is out of + * date. Mirrors the prompts freshness check (`warnIfPromptsStale`) exactly: + * gated on the *managed* file existing (= the project opted in by running + * init/refresh), and only ever warns that it's **stale** — never about a + * missing or unlinked user tsconfig.json. So a deliberately-unlinked / custom + * setup is never nagged, and a not-yet-initialized project stays silent. + * Gated identically in main.ts so it never fires during `wmill init`/`refresh`. + */ +export async function warnIfTsconfigStale(opts?: { cwd?: string }): Promise { + const cwd = opts?.cwd ?? process.cwd(); + const managedPath = path.join(cwd, MANAGED_TSCONFIG); + if (!existsSync(managedPath)) return; + + let managedText: string; + try { + managedText = readFileSync(managedPath, "utf-8"); + } catch { + return; + } + const match = managedText.match(TSCONFIG_HASH_REGEX); + if (!match) { + emitTsconfigWarning( + `${MANAGED_TSCONFIG} predates versioning. Run \`wmill refresh tsconfig\` to refresh it.` + ); + return; + } + if (match[1] !== currentTsconfigHash()) { + emitTsconfigWarning( + `${MANAGED_TSCONFIG} is out of date. Run \`wmill refresh tsconfig\` to refresh.` + ); + } +} + +// Send to stderr (not log.warn → stdout) so it never contaminates a piped +// command's output, matching the prompts freshness warning. +function emitTsconfigWarning(message: string): void { + process.stderr.write(`${colors.yellow(message)}\n`); +} + +const command = new Command() + .description( + "Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)" + ) + .option( + "--yes", + "Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically)." + ) + .action((async (opts: { yes?: boolean }) => { + await refreshTsconfig({ yes: opts.yes === true }); + }) as any); + +export default command; diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index c51e4bff3a..96c8429eb4 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -12,7 +12,6 @@ import { } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; -import { generateTsconfigForIde } from "./tsconfig.ts"; import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Table } from "@cliffy/table"; @@ -190,8 +189,6 @@ export async function generateRTNamespace(opts: GlobalOptions) { "Created rt.d.ts with resource types namespace (RT) for TypeScript." ) ); - - await generateTsconfigForIde(); } const command = new Command() diff --git a/cli/src/commands/resource-type/tsconfig.ts b/cli/src/commands/resource-type/tsconfig.ts deleted file mode 100644 index 2aa2c5335b..0000000000 --- a/cli/src/commands/resource-type/tsconfig.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { execSync } from "node:child_process"; -import { existsSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import process from "node:process"; - -import { colors } from "@cliffy/ansi/colors"; -import * as log from "../../core/log.ts"; -import { readConfigFile } from "../../core/conf.ts"; - -export async function generateTsconfigForIde() { - const tsconfigPath = path.join(process.cwd(), "tsconfig.json"); - if (existsSync(tsconfigPath)) { - log.info(colors.gray("tsconfig.json already exists, skipping")); - return; - } - - let defaultTs: "bun" | "deno" = "bun"; - try { - const conf = await readConfigFile({ warnIfMissing: false }); - if (conf?.defaultTs === "deno") { - defaultTs = "deno"; - } - } catch { - // fall back to bun if wmill.yaml is missing or unreadable - } - - // Only reference bun-types in tsconfig if it's actually available; otherwise - // the IDE will flag the missing type definitions. - const bunTypesAvailable = - defaultTs === "bun" ? ensureBunTypesAvailable() : false; - - const tsconfig: { - compilerOptions: Record; - include: string[]; - } = { - compilerOptions: { - target: "ESNext", - module: "ESNext", - moduleResolution: "bundler", - noEmit: true, - strict: false, - }, - include: ["**/*.ts", "rt.d.ts"], - }; - - if (bunTypesAvailable) { - tsconfig.compilerOptions.types = ["bun-types"]; - } - - writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n"); - log.info(colors.green("Created tsconfig.json for IDE type support.")); -} - -function ensureBunTypesAvailable(): boolean { - const cwd = process.cwd(); - if (existsSync(path.join(cwd, "node_modules", "bun-types"))) { - return true; - } - - try { - execSync("bun --version", { stdio: "ignore" }); - } catch { - log.info( - "Install bun (https://bun.sh) then run 'bun add -d bun-types' and add \"types\": [\"bun-types\"] to tsconfig.json for Bun API autocompletion." - ); - return false; - } - - try { - log.info( - colors.yellow("Installing bun-types with 'bun add -d bun-types'...") - ); - execSync("bun add -d bun-types", { stdio: "inherit" }); - log.info(colors.green("Installed bun-types.")); - return true; - } catch (e) { - log.warn( - `Failed to install bun-types automatically: ${ - e instanceof Error ? e.message : e - }` - ); - log.info( - "Run 'bun add -d bun-types' manually and add \"types\": [\"bun-types\"] to tsconfig.json for Bun API autocompletion." - ); - return false; - } -} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 8b6f21053d..35cbbfef35 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6455,12 +6455,14 @@ List all queues with their metrics ### refresh -Refresh wmill-managed project files (AGENTS.cli.md and skills) +Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json) **Subcommands:** - \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. - - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + - \`--yes\` - Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. +- \`refresh tsconfig\` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects) + - \`--yes\` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically). ### resource diff --git a/cli/src/main.ts b/cli/src/main.ts index 2ae6db1be7..318f8660a5 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -304,6 +304,10 @@ async function main() { if (shouldRunFreshnessCheck(process.argv)) { const { warnIfPromptsStale } = await import("./guidance/freshness.ts"); await warnIfPromptsStale({ argv: process.argv }).catch(() => {}); + const { warnIfTsconfigStale } = await import( + "./commands/refresh/tsconfig.ts" + ); + await warnIfTsconfigStale().catch(() => {}); } await command.parse(args); diff --git a/frontend/src/lib/ata/index.ts b/frontend/src/lib/ata/index.ts index 004fe7f6df..ed432de57e 100644 --- a/frontend/src/lib/ata/index.ts +++ b/frontend/src/lib/ata/index.ts @@ -131,12 +131,18 @@ export const setupTypeAcquisition = (config: ATABootstrapConfig) => { if (depth == 0) { const relativeDeps = depsToGet.filter((f) => isTypescriptRelativePath(f.raw)) relativeDeps.forEach(async (f) => { - let path = f.raw.startsWith('/') - ? f.raw - : '/' + config.scriptPath + (f.raw.startsWith('../') ? '/../' : '/.') + f.raw + // `$f/`/`$u/` are local-friendly aliases for the absolute workspace paths + // `/f/`,`/u/`. Normalize to the absolute form so the fetch URL and the + // registered extra-lib path match what the worker resolves (the editor's + // `paths` config maps `$f/` -> `/f/` back to this lib). + const raw = + f.raw.startsWith('$f/') || f.raw.startsWith('$u/') ? '/' + f.raw.slice(1) : f.raw + let path = raw.startsWith('/') + ? raw + : '/' + config.scriptPath + (raw.startsWith('../') ? '/../' : '/.') + raw let url = config.root + path - let localPath = f.raw - if ((f.raw.startsWith('.') || f.raw.startsWith('/')) && !f.raw.endsWith('.ts')) { + let localPath = raw + if ((raw.startsWith('.') || raw.startsWith('/')) && !raw.endsWith('.ts')) { url += '.ts' localPath += '.ts' } diff --git a/frontend/src/lib/components/monacoLanguagesOptions.ts b/frontend/src/lib/components/monacoLanguagesOptions.ts index 5e0a19c08a..f3e82f65c8 100644 --- a/frontend/src/lib/components/monacoLanguagesOptions.ts +++ b/frontend/src/lib/components/monacoLanguagesOptions.ts @@ -100,6 +100,14 @@ export function setMonacoTypescriptOptions() { allowImportingTsExtensions: true, allowSyntheticDefaultImports: true, moduleResolution: ModuleResolutionKind.NodeJs, + // `$f/`/`$u/` are local-friendly aliases for the absolute workspace paths + // `/f/`,`/u/`. ATA registers imported scripts as extra libs under their + // absolute `/f/...` path, so map the aliases back so type acquisition resolves. + baseUrl: '/', + paths: { + '$f/*': ['/f/*'], + '$u/*': ['/u/*'] + }, jsx: JsxEmit.React }) } diff --git a/frontend/src/lib/relative_imports.ts b/frontend/src/lib/relative_imports.ts index fd4b44c9ba..ea0d81393b 100644 --- a/frontend/src/lib/relative_imports.ts +++ b/frontend/src/lib/relative_imports.ts @@ -16,7 +16,9 @@ export function isTypescriptRelativePath(d: string) { d.startsWith('../') || d.startsWith('/') || d.startsWith('.../') || - d.startsWith('/') + // `$f/`/`$u/` are local-friendly aliases for the absolute workspace paths `/f/`,`/u/`. + d.startsWith('$f/') || + d.startsWith('$u/') ) } diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 662bb58a53..19e1144c95 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -430,12 +430,14 @@ List all queues with their metrics ### refresh -Refresh wmill-managed project files (AGENTS.cli.md and skills) +Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json) **Subcommands:** - `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. - - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + - `--yes` - Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. +- `refresh tsconfig` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects) + - `--yes` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically). ### resource diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index b0341495d3..ecd42d6eff 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2980,12 +2980,14 @@ List all queues with their metrics ### refresh -Refresh wmill-managed project files (AGENTS.cli.md and skills) +Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json) **Subcommands:** - \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. - - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + - \`--yes\` - Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. +- \`refresh tsconfig\` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects) + - \`--yes\` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically). ### resource diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 9eade530c7..dc2ab710ed 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -435,12 +435,14 @@ List all queues with their metrics ### refresh -Refresh wmill-managed project files (AGENTS.cli.md and skills) +Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json) **Subcommands:** - `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. - - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + - `--yes` - Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. +- `refresh tsconfig` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects) + - `--yes` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically). ### resource