mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
feat: support $f/ and $u/ import path aliases for scripts (#9378)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(cli): place tsconfig.wmill.json first in extends to preserve user base config Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): migrate legacy tsconfig and require consent for custom configs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): align prompts wiring to the same consent model Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(cli): bump windmill-parser-wasm-ts to 1.714.0 for $f/ $u/ aliases Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(worker): resolve $f/ and $u/ in deno lock generation Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: narrow relative-imports lock-gen guard to deno import-map failure Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(cli): sync bun.lock with windmill-parser-wasm-ts 1.714.0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): warn when a custom tsconfig's paths would shadow $f/ $u/ aliases Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
$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<string, unknown>;
|
||||
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<string, string[]> = {};
|
||||
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<string, unknown>[] = [
|
||||
{
|
||||
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<string, unknown>)[k], (b as Record<string, unknown>)[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<void> {
|
||||
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<string, unknown>).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<string, string> = {};
|
||||
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<string, unknown>;
|
||||
// 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<string, unknown>) => 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<string, unknown>[];
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user