Merge remote-tracking branch 'origin/main' into fork-datatable-schema-export

This commit is contained in:
Diego Imbert
2026-03-24 13:16:46 +01:00
717 changed files with 37037 additions and 5391 deletions
+176 -72
View File
@@ -7,6 +7,7 @@ import { yamlParseFile } from "../../utils/yaml.ts";
import { stringify as yamlStringify } from "yaml";
import { GlobalOptions } from "../../types.ts";
import {
readLockfile,
checkifMetadataUptodate,
blueColor,
clearGlobalLock,
@@ -41,6 +42,8 @@ import { mergeConfigWithConfigFile, SyncOptions } from "../../core/conf.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { requireLogin } from "../../core/auth.ts";
import { getNonDottedPaths } from "../../utils/resource_folders.ts";
import { extractRelativeImports } from "../../utils/relative_imports.ts";
import { DoubleLinkedDependencyTree } from "../../utils/dependency_tree.ts";
const TOP_HASH = "__app_hash";
export const APP_BACKEND_FOLDER = "backend";
@@ -93,7 +96,16 @@ async function generateAppHash(
}
/**
* Updates locks for inline scripts in an app
* Result of generating app locks, including which scripts were updated
*/
export interface AppLocksResult {
path: string;
updatedScripts: string[];
}
/**
* Updates locks for inline scripts in an app.
* Returns the path if dry-run, or AppLocksResult with updated scripts if actual update occurred.
*/
export async function generateAppLocksInternal(
appFolder: string,
@@ -104,8 +116,10 @@ export async function generateAppLocksInternal(
defaultTs?: "bun" | "deno";
},
justUpdateMetadataLock?: boolean,
noStaleMessage?: boolean
): Promise<string | void> {
noStaleMessage?: boolean,
legacyBehaviour?: boolean,
tree?: DoubleLinkedDependencyTree
): Promise<string | AppLocksResult | void> {
if (appFolder.endsWith(SEP)) {
appFolder = appFolder.substring(0, appFolder.length - 1);
}
@@ -116,9 +130,6 @@ export async function generateAppLocksInternal(
log.info(`Generating locks for app ${appFolder} at ${remote_path}`);
}
const rawWorkspaceDependencies: Record<string, string> =
await getRawWorkspaceDependencies();
// Read the app file first to filter workspace dependencies
const appFilePath = path.join(
appFolder,
@@ -126,35 +137,80 @@ export async function generateAppLocksInternal(
);
const appFile = (await yamlParseFile(appFilePath)) as AppFile;
// Filter workspace dependencies based on inline scripts' languages and annotations
const appValue = rawApp ? (appFile as RawAppFile).runnables : (appFile as NormalAppFile).value;
const filteredDeps = await filterWorkspaceDependenciesForApp(
appValue,
rawWorkspaceDependencies,
appFolder
);
const folderNormalized = appFolder.replaceAll(SEP, "/");
let hashes = await generateAppHash(
filteredDeps,
appFolder,
rawApp,
opts.defaultTs
);
let filteredDeps: Record<string, string> = {};
const conf = await readLockfile();
const conf = await import("../../utils/metadata.ts").then((m) =>
m.readLockfile()
);
if (
await checkifMetadataUptodate(appFolder, hashes[TOP_HASH], conf, TOP_HASH)
) {
if (!noStaleMessage) {
log.info(
colors.green(`App ${remote_path} metadata is up-to-date, skipping`)
);
// New behaviour: tree-based dependency tracking
if (!legacyBehaviour && tree) {
if (dryRun) {
const hashes = await generateAppHash({}, appFolder, rawApp, opts.defaultTs);
const isDirectlyStale = !(await checkifMetadataUptodate(appFolder, hashes[TOP_HASH], conf, TOP_HASH));
// For raw apps in new format, runnables are in separate files under backend/
let treeAppValue = structuredClone(appValue);
if (rawApp) {
const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER);
const runnablesFromFiles = await loadRunnablesFromBackend(runnablesPath);
if (Object.keys(runnablesFromFiles).length > 0) {
treeAppValue = runnablesFromFiles;
}
}
// First pass: add inline scripts as separate nodes, then add app node importing them
const inlineScriptPaths: string[] = [];
await traverseAndProcessInlineScripts(treeAppValue, async (inlineScript, context) => {
if (!inlineScript.content || !inlineScript.language) {
return inlineScript;
}
let content = inlineScript.content;
// Resolve !inline references
if (typeof content === "string" && content.startsWith("!inline ")) {
const filePath = appFolder + SEP + content.replace("!inline ", "");
try {
content = await readFile(filePath, "utf-8");
} catch {
return inlineScript;
}
}
const treePath = folderNormalized + "/" + context.path.join("/");
const language = inlineScript.language as ScriptLanguage;
const imports = await extractRelativeImports(content, treePath, language);
await tree.addNode(treePath, content, language, "", imports, "inline_script", folderNormalized, appFolder, false);
inlineScriptPaths.push(treePath);
return inlineScript;
});
await tree.addNode(folderNormalized, "", "bun", "", inlineScriptPaths, "app", folderNormalized, appFolder, isDirectlyStale, rawApp);
return;
}
// Second pass: get mismatched workspace deps from tree
// TODO: pass raw workspace deps more precisely to every inline script lock generation call
// (currently we pass the union of all mismatched deps filtered for the whole app)
filteredDeps = await filterWorkspaceDependenciesForApp(appValue, tree.getMismatchedWorkspaceDeps(), appFolder);
} else {
// Legacy behaviour
const rawWorkspaceDependencies = await getRawWorkspaceDependencies(true);
filteredDeps = await filterWorkspaceDependenciesForApp(appValue, rawWorkspaceDependencies, appFolder);
const hashes = await generateAppHash(filteredDeps, appFolder, rawApp, opts.defaultTs);
const isDirectlyStale = !(await checkifMetadataUptodate(appFolder, hashes[TOP_HASH], conf, TOP_HASH));
if (!isDirectlyStale) {
if (!noStaleMessage) {
log.info(
colors.green(`App ${remote_path} metadata is up-to-date, skipping`)
);
}
return;
} else if (dryRun) {
return remote_path;
}
return;
} else if (dryRun) {
return remote_path;
}
if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) {
@@ -167,7 +223,11 @@ export async function generateAppLocksInternal(
);
}
let updatedScripts: string[] = [];
if (!justUpdateMetadataLock) {
const hashes = await generateAppHash(filteredDeps, appFolder, rawApp, opts.defaultTs);
const changedScripts = [];
// Find hashes that do not correspond to previous hashes
for (const [scriptPath, hash] of Object.entries(hashes)) {
@@ -179,7 +239,13 @@ export async function generateAppLocksInternal(
}
}
if (changedScripts.length > 0) {
// Get temp_script_refs from tree for relative import resolution
const tempScriptRefs = tree?.getTempScriptRefs(folderNormalized);
// In tree mode, the tree already verified this app is stale (possibly via dependency change).
// Per-script hashes only detect content changes, not transitive dependency changes,
// so we must regenerate locks for all inline scripts regardless.
if (changedScripts.length > 0 || (tree && !legacyBehaviour)) {
if (!noStaleMessage) {
log.info(
`Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}`
@@ -201,13 +267,15 @@ export async function generateAppLocksInternal(
replaceInlineScripts(runnables, runnablesPath + SEP, false);
// Update the app runnables with new locks (writes to separate files)
await updateRawAppRunnables(
updatedScripts = await updateRawAppRunnables(
workspace,
runnables,
remote_path,
appFolder,
filteredDeps,
opts.defaultTs
opts.defaultTs,
noStaleMessage,
tempScriptRefs
);
// Note: updateRawAppRunnables now writes each runnable to its own file
} else {
@@ -217,14 +285,18 @@ export async function generateAppLocksInternal(
replaceInlineScripts(normalAppFile.value, appFolder + SEP, false);
// Update the app value with new locks
normalAppFile.value = await updateAppInlineScripts(
const result = await updateAppInlineScripts(
workspace,
normalAppFile.value,
remote_path,
appFolder,
filteredDeps,
opts.defaultTs
opts.defaultTs,
noStaleMessage,
tempScriptRefs
);
normalAppFile.value = result.value;
updatedScripts = result.updatedScripts;
// Write the updated app file (only for normal apps, raw apps use separate files)
writeIfChanged(
@@ -237,20 +309,23 @@ export async function generateAppLocksInternal(
}
}
// Regenerate hashes after updates
hashes = await generateAppHash(
filteredDeps,
// Non-legacy mode excludes workspace deps from hash (tracked via tree instead)
const depsForHash = (tree && !legacyBehaviour) ? {} : filteredDeps;
const finalHashes = await generateAppHash(
depsForHash,
appFolder,
rawApp,
opts.defaultTs
);
await clearGlobalLock(appFolder);
for (const [scriptPath, hash] of Object.entries(hashes)) {
for (const [scriptPath, hash] of Object.entries(finalHashes)) {
await updateMetadataGlobalLock(appFolder, hash, scriptPath);
}
if (!noStaleMessage) {
log.info(colors.green(`App ${remote_path} lockfiles updated`));
}
return { path: remote_path, updatedScripts };
}
/**
@@ -340,6 +415,7 @@ async function traverseAndProcessInlineScripts(
* Updates locks for all runnables in a raw app, generating locks inline script by inline script.
* Writes each runnable to its own YAML file in the backend folder (new format).
* Also writes content and lock files to the runnables folder.
* Returns the list of runnable IDs that had their locks updated.
*/
async function updateRawAppRunnables(
workspace: Workspace,
@@ -347,8 +423,11 @@ async function updateRawAppRunnables(
remotePath: string,
appFolder: string,
rawDeps?: Record<string, string>,
defaultTs: "bun" | "deno" = "bun"
): Promise<void> {
defaultTs: "bun" | "deno" = "bun",
noStaleMessage?: boolean,
tempScriptRefs?: Record<string, string>
): Promise<string[]> {
const updatedRunnables: string[] = [];
const runnablesFolder = path.join(appFolder, APP_BACKEND_FOLDER);
// Ensure runnables folder exists
@@ -414,12 +493,11 @@ async function updateRawAppRunnables(
continue;
}
log.info(
colors.gray(
`Generating lock for runnable ${runnableId} (${language})
}`
)
);
if (!noStaleMessage) {
log.info(
colors.gray(`Generating lock for runnable ${runnableId} (${language})`)
);
}
try {
const lock = await generateInlineScriptLock(
@@ -427,7 +505,8 @@ async function updateRawAppRunnables(
content,
language,
`${remotePath}/${runnableId}`,
rawDeps
rawDeps,
tempScriptRefs
);
// Determine file extension for this language
@@ -459,11 +538,15 @@ async function updateRawAppRunnables(
// Write the runnable to its own YAML file
writeRunnableToBackend(runnablesFolder, runnableId, simplifiedRunnable);
log.info(
colors.gray(
` Written ${runnableId}.yaml, ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}`
)
);
updatedRunnables.push(runnableId);
if (!noStaleMessage) {
log.info(
colors.gray(
` Written ${runnableId}.yaml, ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}`
)
);
}
} catch (error: any) {
log.error(
colors.red(
@@ -474,11 +557,14 @@ async function updateRawAppRunnables(
writeRunnableToBackend(runnablesFolder, runnableId, runnable);
}
}
return updatedRunnables;
}
/**
* Updates locks for all inline scripts in a normal app, similar to updateRawAppRunnables
* but for the app.value structure instead of app.runnables
* but for the app.value structure instead of app.runnables.
* Returns a tuple of [updated app value, list of script names that were updated].
*/
async function updateAppInlineScripts(
workspace: Workspace,
@@ -486,9 +572,12 @@ async function updateAppInlineScripts(
remotePath: string,
appFolder: string,
rawDeps?: Record<string, string>,
defaultTs: "bun" | "deno" = "bun"
): Promise<any> {
defaultTs: "bun" | "deno" = "bun",
noStaleMessage?: boolean,
tempScriptRefs?: Record<string, string>
): Promise<{ value: any; updatedScripts: string[] }> {
const pathAssigner = newPathAssigner(defaultTs, { skipInlineScriptSuffix: getNonDottedPaths() });
const updatedScripts: string[] = [];
const processor: InlineScriptProcessor = async (inlineScript, context) => {
const language = inlineScript.language as SupportedLanguage;
@@ -518,20 +607,23 @@ async function updateAppInlineScripts(
try {
let lock: string | undefined;
if (language !== "frontend") {
log.info(
colors.gray(
`Generating lock for inline script "${scriptName}" at ${context.path.join(
"."
)} (${language})`
)
);
if (!noStaleMessage) {
log.info(
colors.gray(
`Generating lock for inline script "${scriptName}" at ${context.path.join(
"."
)} (${language})`
)
);
}
lock = await generateInlineScriptLock(
workspace,
content,
language,
scriptPath,
rawDeps
rawDeps,
tempScriptRefs
);
}
// Determine file extension for this language (following extractInlineScriptsForApps pattern)
@@ -553,11 +645,18 @@ async function updateAppInlineScripts(
const inlineLockRef =
lock && lock !== "" ? `!inline ${basePath}lock` : "";
log.info(
colors.gray(
` Written ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}`
)
);
if (!noStaleMessage) {
log.info(
colors.gray(
` Written ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}`
)
);
}
// Track that this script was updated (only for non-frontend scripts that needed locks)
if (language !== "frontend") {
updatedScripts.push(scriptName);
}
return {
...inlineScript,
@@ -577,7 +676,8 @@ async function updateAppInlineScripts(
}
};
return await traverseAndProcessInlineScripts(appValue, processor);
const updatedValue = await traverseAndProcessInlineScripts(appValue, processor);
return { value: updatedValue, updatedScripts };
}
/**
@@ -588,7 +688,8 @@ async function generateInlineScriptLock(
content: string,
language: string,
scriptPath: string,
rawWorkspaceDependencies: Record<string, string> | undefined
rawWorkspaceDependencies: Record<string, string> | undefined,
tempScriptRefs?: Record<string, string>
): Promise<string> {
// Filter workspace dependencies to only include those matching this script's language and annotations
const filteredDeps = rawWorkspaceDependencies
@@ -619,6 +720,9 @@ async function generateInlineScriptLock(
? filteredDeps
: null,
entrypoint: scriptPath,
...(tempScriptRefs && Object.keys(tempScriptRefs).length > 0
? { temp_script_refs: tempScriptRefs }
: {}),
}),
}
);
+18 -6
View File
@@ -16,33 +16,34 @@ import { resolveWorkspace } from "../../core/context.ts";
import {
SyncOptions,
mergeConfigWithConfigFile,
readConfigFile,
} from "../../core/conf.ts";
import { exts, removeExtensionToPath } from "../script/script.ts";
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { FlowFile } from "../flow/flow.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
getFolderSuffixWithSep,
getMetadataFileName,
extractFolderPath,
} from "../../utils/resource_folders.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
const PORT = 3001;
async function dev(opts: GlobalOptions & SyncOptions) {
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
log.info("Started dev mode");
const conf = await readConfigFile();
let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined;
const fsWatcher = watch(".", { recursive: true });
const base = await realpath(".");
opts = await mergeConfigWithConfigFile(opts);
const ignore = await ignoreF(opts);
const codebases = await listSyncCodebases(opts);
const changesTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
function watchChanges() {
@@ -56,7 +57,11 @@ async function dev(opts: GlobalOptions & SyncOptions) {
}
changesTimeouts[key] = setTimeout(async () => {
delete changesTimeouts[key];
await loadPaths([filePath]);
await loadPaths([filePath]).catch((error) => {
log.error(
`Failed to reload ${filePath}: ${error instanceof Error ? error.message : error}`
);
});
}, 100);
});
fsWatcher.on("error", (err) => {
@@ -94,6 +99,13 @@ async function dev(opts: GlobalOptions & SyncOptions) {
SEP,
undefined,
);
// Replace PathScript modules with local file content so dev mode uses local versions
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
currentLastEdit = {
type: "flow",
flow: localFlow,
@@ -105,7 +117,7 @@ async function dev(opts: GlobalOptions & SyncOptions) {
const content = await readFile(cpath, "utf-8");
const splitted = cpath.split(".");
const wmPath = splitted[0];
const lang = inferContentTypeFromFilePath(cpath, conf.defaultTs);
const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
const typed =
(await parseMetadataFile(
removeExtensionToPath(cpath),
+139 -3
View File
@@ -18,8 +18,19 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
import { Flow } from "../../../gen/types.gen.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import {
collectPathScriptPaths,
replaceInlineScripts,
replaceAllPathScriptsWithLocal,
} from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { generateFlowLockInternal } from "./flow_metadata.ts";
import { exts } from "../script/script.ts";
import type { SyncCodebase } from "../../utils/codebase.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import {
createPreviewLocalScriptReader,
resolvePreviewLocalScriptState,
} from "../../utils/local_path_scripts.ts";
export interface FlowFile {
summary: string;
@@ -28,6 +39,90 @@ export interface FlowFile {
schema?: any;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
return typeof value === "string" && value.trim() === "" ? undefined : value ?? undefined;
}
function normalizeComparableContent(value: string | undefined): string | undefined {
return value?.replaceAll("\r\n", "\n").replace(/\n$/, "");
}
async function findDivergedLocalPathScripts(
workspaceId: string,
scriptPaths: string[],
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<{ changed: string[]; missing: string[] }> {
const changed: string[] = [];
const missing: string[] = [];
for (const scriptPath of scriptPaths) {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
continue;
}
let remoteScript;
try {
remoteScript = await wmill.getScriptByPath({
workspace: workspaceId,
path: scriptPath,
});
} catch {
missing.push(scriptPath);
continue;
}
const remoteLock = normalizeOptionalString(remoteScript.lock);
const diverged =
normalizeComparableContent(localScript.content) !==
normalizeComparableContent(remoteScript.content) ||
localScript.language !== remoteScript.language ||
(localScript.lock !== undefined &&
normalizeComparableContent(localScript.lock) !==
normalizeComparableContent(remoteLock)) ||
localScript.tag !== normalizeOptionalString(remoteScript.tag) ||
localScript.codebaseDigest !== normalizeOptionalString(remoteScript.codebase);
if (diverged) {
changed.push(scriptPath);
}
}
return { changed, missing };
}
function warnAboutLocalPathScriptDivergence(
divergence: { changed: string[]; missing: string[] }
): void {
if (divergence.changed.length === 0 && divergence.missing.length === 0) {
return;
}
const details: string[] = [];
if (divergence.changed.length > 0) {
details.push(
`These workspace scripts differ from the deployed version:\n${divergence.changed
.map((path) => `- ${path}`)
.join("\n")}`
);
}
if (divergence.missing.length > 0) {
details.push(
`These scripts do not exist in the workspace yet:\n${divergence.missing
.map((path) => `- ${path}`)
.join("\n")}`
);
}
log.warn(
`Using local PathScript files for flow preview.\n${details.join("\n")}\nUse --remote to preview deployed workspace scripts instead.`
);
}
const alreadySynced: string[] = [];
export async function pushFlow(
@@ -233,11 +328,17 @@ async function preview(
opts: GlobalOptions & {
data?: string;
silent: boolean;
},
remote?: boolean;
} & SyncOptions,
flowPath: string
) {
const useLocalPathScripts = !opts.remote;
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
// Normalize path - ensure it's a directory path to a .flow folder
if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) {
@@ -274,6 +375,31 @@ async function preview(
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP);
}
if (useLocalPathScripts) {
const scriptPaths = collectPathScriptPaths(localFlow.value);
if (scriptPaths.length > 0) {
const divergence = await findDivergedLocalPathScripts(
workspace.workspaceId,
scriptPaths,
{
exts,
defaultTs: opts.defaultTs,
codebases,
}
);
if (!opts.silent) {
warnAboutLocalPathScriptDivergence(divergence);
}
}
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
}
const input = opts.data ? await resolve(opts.data) : {};
if (!opts.silent) {
@@ -311,6 +437,7 @@ async function preview(
export async function generateLocks(
opts: GlobalOptions & {
yes?: boolean;
dryRun?: boolean;
} & SyncOptions,
folder: string | undefined
) {
@@ -361,6 +488,10 @@ export async function generateLocks(
}
if (hasAny) {
if (opts.dryRun) {
log.info(colors.gray("Dry run complete."));
return;
}
if (
!opts.yes &&
!(await Confirm.prompt({
@@ -444,7 +575,7 @@ const command = new Command()
.action(run as any)
.command(
"preview",
"preview a local flow without deploying it. Runs the flow definition from local files."
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default."
)
.arguments("<flow_path:string>")
.option(
@@ -455,6 +586,10 @@ const command = new Command()
"-s --silent",
"Do not output anything other then the final output. Useful for scripting."
)
.option(
"--remote",
"Use deployed workspace scripts for PathScript steps instead of local files."
)
.action(preview as any)
.command(
"generate-locks",
@@ -462,6 +597,7 @@ const command = new Command()
)
.arguments("[flow:file]")
.option("--yes", "Skip confirmation prompt")
.option("--dry-run", "Perform a dry run without making changes")
.option(
"-i --includes <patterns:file[]>",
"Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)"
+134 -32
View File
@@ -29,7 +29,9 @@ import { FlowFile } from "./flow.ts";
import { FlowValue } from "../../../gen/types.gen.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { workspaceDependenciesLanguages } from "../../utils/script_common.ts";
import { extractNameFromFolder, getFolderSuffix } from "../../utils/resource_folders.ts";
import { extractNameFromFolder, getFolderSuffix, getNonDottedPaths } from "../../utils/resource_folders.ts";
import { extractRelativeImports } from "../../utils/relative_imports.ts";
import { DoubleLinkedDependencyTree } from "../../utils/dependency_tree.ts";
const TOP_HASH = "__flow_hash";
async function generateFlowHash(
@@ -51,6 +53,14 @@ async function generateFlowHash(
}
return { ...hashes, [TOP_HASH]: await generateHash(JSON.stringify(hashes)) };
}
/**
* Result of generating flow locks, including which scripts were updated
*/
export interface FlowLocksResult {
path: string;
updatedScripts: string[];
}
export async function generateFlowLockInternal(
folder: string,
dryRun: boolean,
@@ -59,8 +69,10 @@ export async function generateFlowLockInternal(
defaultTs?: "bun" | "deno";
},
justUpdateMetadataLock?: boolean,
noStaleMessage?: boolean
): Promise<string | void> {
noStaleMessage?: boolean,
legacyBehaviour?: boolean,
tree?: DoubleLinkedDependencyTree
): Promise<string | FlowLocksResult | void> {
if (folder.endsWith(SEP)) {
folder = folder.substring(0, folder.length - 1);
}
@@ -69,33 +81,67 @@ export async function generateFlowLockInternal(
log.info(`Generating lock for flow ${folder} at ${remote_path}`);
}
// Always get out-of-sync workspace dependencies
const rawWorkspaceDependencies: Record<string, string> =
await getRawWorkspaceDependencies();
const flowValue = (await yamlParseFile(
folder! + SEP + "flow.yaml"
)) as FlowFile;
// Filter workspace dependencies based on inline scripts' languages and annotations
const filteredDeps = await filterWorkspaceDependenciesForFlow(flowValue.value as FlowValue, rawWorkspaceDependencies, folder);
let hashes = await generateFlowHash(
filteredDeps,
folder,
const folderNormalized = folder.replaceAll(SEP, "/");
const inlineScriptsForTree = extractInlineScriptsForFlows(
structuredClone(flowValue.value.modules),
{},
SEP,
opts.defaultTs
);
).filter(s => !s.is_lock);
let filteredDeps: Record<string, string> = {};
const conf = await readLockfile();
if (await checkifMetadataUptodate(folder, hashes[TOP_HASH], conf, TOP_HASH)) {
if (!noStaleMessage) {
log.info(
colors.green(`Flow ${remote_path} metadata is up-to-date, skipping`)
);
if (!legacyBehaviour && tree) {
if (dryRun) {
const inlineScriptPaths: string[] = [];
for (const script of inlineScriptsForTree) {
let content = script.content;
if (content.startsWith("!inline ")) {
const filePath = folder + SEP + content.replace("!inline ", "");
try {
content = await readFile(filePath, "utf-8");
} catch {
continue;
}
}
const treePath = folderNormalized + "/" + path.basename(script.path, path.extname(script.path));
const language = script.language as ScriptLanguage;
const imports = await extractRelativeImports(content, treePath, language);
await tree.addNode(treePath, content, language, "", imports, "inline_script", folderNormalized, folder, false);
inlineScriptPaths.push(treePath);
}
const hashes = await generateFlowHash({}, folder, opts.defaultTs);
const isDirectlyStale = !(await checkifMetadataUptodate(folder, hashes[TOP_HASH], conf, TOP_HASH));
await tree.addNode(folderNormalized, "", "bun", "", inlineScriptPaths, "flow", folderNormalized, folder, isDirectlyStale);
return;
}
// Second pass: get mismatched workspace deps from tree
filteredDeps = await filterWorkspaceDependenciesForFlow(flowValue.value as FlowValue, tree.getMismatchedWorkspaceDeps(), folder);
} else {
const rawWorkspaceDependencies = await getRawWorkspaceDependencies(true);
filteredDeps = await filterWorkspaceDependenciesForFlow(flowValue.value as FlowValue, rawWorkspaceDependencies, folder);
const hashes = await generateFlowHash(filteredDeps, folder, opts.defaultTs);
const isDirectlyStale = !(await checkifMetadataUptodate(folder, hashes[TOP_HASH], conf, TOP_HASH));
if (!isDirectlyStale) {
if (!noStaleMessage) {
log.info(
colors.green(`Flow ${remote_path} metadata is up-to-date, skipping`)
);
}
return;
} else if (dryRun) {
return remote_path;
}
return;
} else if (dryRun) {
return remote_path;
}
if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) {
@@ -109,8 +155,25 @@ export async function generateFlowLockInternal(
}
let changedScripts: string[] = [];
// Build mapping from on-disk file names (hash keys like "a.py") to tree paths
// (like "folder/a.inline_script"). The tree uses extractInlineScriptsForFlows without
// a path assigner, so paths always have .inline_script suffix, but on-disk files
// may not (non-dotted mode).
const fileToTreePath = new Map<string, string>();
for (const script of inlineScriptsForTree) {
const c = script.content;
if (c.startsWith("!inline ")) {
const fileName = c.replace("!inline ", "");
const treePath = folderNormalized + "/" + path.basename(script.path, path.extname(script.path));
fileToTreePath.set(fileName, treePath);
}
}
if (!justUpdateMetadataLock) {
const changedScripts = [];
const hashes = await generateFlowHash(filteredDeps, folder, opts.defaultTs);
//find hashes that do not correspond to previous hashes
for (const [path, hash] of Object.entries(hashes)) {
if (path == TOP_HASH) {
@@ -125,30 +188,44 @@ export async function generateFlowLockInternal(
log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`);
}
const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8");
// In tree mode, use the tree's staleness info (which includes transitive dependency changes)
// to determine which scripts need relocking, instead of only content-changed ones.
const locksToRemove = (tree && !legacyBehaviour)
? Object.keys(hashes).filter(k => {
if (k === TOP_HASH) return false;
const treePath = fileToTreePath.get(k)
?? (folderNormalized + "/" + path.basename(k, path.extname(k)));
return tree.isStale(treePath);
})
: changedScripts;
await replaceInlineScripts(
flowValue.value.modules,
fileReader,
log,
folder + SEP!,
SEP,
changedScripts
locksToRemove
);
if (flowValue.value.failure_module) {
await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, changedScripts);
await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove);
}
if (flowValue.value.preprocessor_module) {
await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, changedScripts);
await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove);
}
//removeChangedLocks
const tempScriptRefs = tree?.getTempScriptRefs(folderNormalized);
flowValue.value = await updateFlow(
workspace,
flowValue.value,
remote_path,
filteredDeps
filteredDeps,
tempScriptRefs
);
const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun");
const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", {
skipInlineScriptSuffix: getNonDottedPaths(),
});
const inlineScripts = extractInlineScriptsForFlows(
flowValue.value.modules,
{},
@@ -173,18 +250,36 @@ export async function generateFlowLockInternal(
);
}
hashes = await generateFlowHash(
filteredDeps,
// Non-legacy mode excludes workspace deps from hash (tracked via tree instead)
const depsForHash = (tree && !legacyBehaviour) ? {} : filteredDeps;
const finalHashes = await generateFlowHash(
depsForHash,
folder,
opts.defaultTs
);
await clearGlobalLock(folder);
for (const [path, hash] of Object.entries(hashes)) {
for (const [path, hash] of Object.entries(finalHashes)) {
await updateMetadataGlobalLock(folder, hash, path);
}
if (!noStaleMessage) {
log.info(colors.green(`Flow ${remote_path} lockfiles updated`));
}
// Return the list of updated scripts (extract just the filename from the path)
// In tree mode, use the same staleness-aware list we used for lock removal
const relocked = (tree && !legacyBehaviour)
? Object.keys(finalHashes).filter(k => {
if (k === TOP_HASH) return false;
const treePath = fileToTreePath.get(k)
?? (folderNormalized + "/" + path.basename(k, path.extname(k)));
return tree.isStale(treePath);
})
: changedScripts;
const updatedScripts = relocked.map(p => {
const parts = p.split(SEP);
return parts[parts.length - 1].replace(/\.[^.]+$/, ""); // Remove extension
});
return { path: remote_path, updatedScripts };
}
/**
@@ -218,7 +313,8 @@ export async function updateFlow(
workspace: Workspace,
flow_value: FlowValue,
remotePath: string,
rawWorkspaceDependencies: Record<string, string>
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>
): Promise<FlowValue | undefined> {
let rawResponse;
@@ -243,6 +339,9 @@ export async function updateFlow(
path: remotePath,
use_local_lockfiles: true,
raw_workspace_dependencies: rawWorkspaceDependencies,
...(tempScriptRefs && Object.keys(tempScriptRefs).length > 0
? { temp_script_refs: tempScriptRefs }
: {}),
}),
}
);
@@ -261,6 +360,9 @@ export async function updateFlow(
body: JSON.stringify({
flow_value,
path: remotePath,
...(tempScriptRefs && Object.keys(tempScriptRefs).length > 0
? { temp_script_refs: tempScriptRefs }
: {}),
}),
}
);
@@ -10,23 +10,31 @@ import * as log from "../../core/log.ts";
import {
generateScriptMetadataInternal,
getRawWorkspaceDependencies,
readLockfile,
checkifMetadataUptodate,
} from "../../utils/metadata.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { generateAppLocksInternal, getAppFolders } from "../app/app_metadata.ts";
import { generateFlowLockInternal, FlowLocksResult } from "../flow/flow_metadata.ts";
import { generateAppLocksInternal, getAppFolders, AppLocksResult } from "../app/app_metadata.ts";
import {
elementsToMap,
FSFSElement,
ignoreF,
} from "../sync/sync.ts";
import { exts } from "../script/script.ts";
import { isFlowPath, isAppPath } from "../../utils/resource_folders.ts";
import { isFolderResourcePathAnyFormat, isScriptModulePath, isModuleEntryPoint } from "../../utils/resource_folders.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import {
DoubleLinkedDependencyTree,
uploadScripts,
ItemType,
} from "../../utils/dependency_tree.ts";
interface StaleItem {
type: "script" | "flow" | "app";
type: ItemType;
path: string;
folder: string;
isRawApp?: boolean;
staleReason?: string;
}
async function generateMetadata(
@@ -38,6 +46,7 @@ async function generateMetadata(
skipScripts?: boolean;
skipFlows?: boolean;
skipApps?: boolean;
strictFolderBoundaries?: boolean;
} & SyncOptions,
folder?: string
) {
@@ -49,12 +58,10 @@ async function generateMetadata(
await requireLogin(opts);
opts = await mergeConfigWithConfigFile(opts);
const rawWorkspaceDependencies = await getRawWorkspaceDependencies();
const rawWorkspaceDependencies = await getRawWorkspaceDependencies(false);
const codebases = await listSyncCodebases(opts);
const ignore = await ignoreF(opts);
const staleItems: StaleItem[] = [];
// --schema-only implies skipping flows and apps (they only have locks, no schemas)
const skipScripts = opts.skipScripts ?? false;
const skipFlows = opts.skipFlows ?? opts.schemaOnly ?? false;
@@ -70,7 +77,11 @@ async function generateMetadata(
return;
}
log.info(colors.gray(`Checking ${checking.join(", ")}...`));
log.info(`Checking ${checking.join(", ")}...`);
// Build dependency tree for relative import tracking
const tree = new DoubleLinkedDependencyTree();
tree.setWorkspaceDeps(rawWorkspaceDependencies);
// === Collect stale scripts ===
if (!skipScripts) {
@@ -81,8 +92,8 @@ async function generateMetadata(
return (
(!isD && !exts.some((ext) => p.endsWith(ext))) ||
ignore(p, isD) ||
isFlowPath(p) ||
isAppPath(p)
isFolderResourcePathAnyFormat(p) ||
(isScriptModulePath(p) && !isModuleEntryPoint(p))
);
},
false,
@@ -90,19 +101,18 @@ async function generateMetadata(
);
for (const e of Object.keys(scriptElems)) {
const candidate = await generateScriptMetadataInternal(
await generateScriptMetadataInternal(
e,
workspace,
opts,
true, // dryRun
true, // dryRun - populate tree
true, // noStaleMessage
rawWorkspaceDependencies,
codebases,
false
false,
false, // legacyBehaviour
tree
);
if (candidate) {
staleItems.push({ type: "script", path: candidate, folder: e });
}
}
}
@@ -124,18 +134,17 @@ async function generateMetadata(
)
).map((x) => x.substring(0, x.lastIndexOf(SEP)));
for (const folder of flowElems) {
const candidate = await generateFlowLockInternal(
folder,
true, // dryRun
for (const flowFolder of flowElems) {
await generateFlowLockInternal(
flowFolder,
true, // dryRun - populate tree
workspace,
opts,
false,
true // noStaleMessage
true, // noStaleMessage
false, // legacyBehaviour
tree
);
if (candidate) {
staleItems.push({ type: "flow", path: candidate, folder });
}
}
}
@@ -159,45 +168,134 @@ async function generateMetadata(
const appFolders = getAppFolders(elems, "app.yaml");
for (const appFolder of rawAppFolders) {
const candidate = await generateAppLocksInternal(
await generateAppLocksInternal(
appFolder,
true, // rawApp
true, // dryRun
true, // dryRun - populate tree
workspace,
opts,
false,
true // noStaleMessage
true, // noStaleMessage
false, // legacyBehaviour
tree
);
if (candidate) {
staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: true });
}
}
for (const appFolder of appFolders) {
const candidate = await generateAppLocksInternal(
await generateAppLocksInternal(
appFolder,
false, // rawApp
true, // dryRun
true, // dryRun - populate tree
workspace,
opts,
false,
true // noStaleMessage
true, // noStaleMessage
false, // legacyBehaviour
tree
);
if (candidate) {
staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: false });
}
}
}
// === Propagate staleness through imports ===
tree.propagateStaleness();
// Upload stale scripts to temp storage so the backend can resolve relative imports.
// If this fails (e.g. backend is older and doesn't have /raw_temp endpoints),
// degrade gracefully: locks will be generated using deployed script content only.
try {
await uploadScripts(tree, workspace);
} catch (e) {
log.warn(colors.yellow(
`Failed to upload scripts to temp storage (backend may be too old): ${e}. ` +
`Locks will be generated using deployed script versions only — locally modified ` +
`relative imports may not be reflected.`
));
}
// === Populate staleItems from tree ===
const staleItems: StaleItem[] = [];
const seenFolders = new Set<string>();
for (const p of tree.allPaths()) {
const staleReason = tree.getStaleReason(p);
if (!staleReason) continue;
const itemType = tree.getItemType(p)!;
const itemFolder = tree.getFolder(p)!;
if (itemType === "dependencies") {
staleItems.push({ type: itemType, path: p, folder: itemFolder, staleReason });
} else if (itemType === "inline_script") {
// Inline scripts are not listed separately — their parent flow/app is stale via propagation
continue;
} else if (itemType === "script") {
const originalPath = tree.getOriginalPath(p)!;
staleItems.push({ type: itemType, path: originalPath, folder: itemFolder, staleReason });
} else if (!seenFolders.has(itemFolder)) {
// Flows/Apps: one entry per folder (dedupe multiple inline scripts)
seenFolders.add(itemFolder);
const originalPath = tree.getOriginalPath(p)!;
staleItems.push({ type: itemType, path: originalPath, folder: itemFolder, isRawApp: tree.getIsRawApp(p), staleReason });
}
}
// === Filter by folder if specified ===
let filteredItems = staleItems;
if (folder) {
// Strip trailing separator to match deprecated flow/app handler behavior
// (see generateFlowLockInternal line 64-66, generateAppLocksInternal line 109-110)
if (folder.endsWith(SEP)) {
// Normalize to forward slashes (Windows users may use backslashes)
folder = folder.replaceAll("\\", "/");
// Strip trailing slash to match deprecated flow/app handler behavior
if (folder.endsWith("/")) {
folder = folder.substring(0, folder.length - 1);
}
filteredItems = staleItems.filter((item) => item.folder === folder || item.folder.startsWith(folder + SEP));
// Strip file extension if user passed a specific file path (e.g. f/test/script.ts)
const folderNoExt = folder.replace(/\.[^/.]+$/, "");
// Check if an item is inside the specified folder
const isInsideFolder = (item: StaleItem) => {
const normalizedFolder = item.folder.replaceAll("\\", "/");
const normalizedPath = item.path.replaceAll("\\", "/");
return normalizedFolder === folder || normalizedFolder.startsWith(folder + "/")
|| normalizedPath === folder || normalizedPath === folderNoExt;
};
const isPathInFolder = (p: string) => p.startsWith(folder + "/") || p === folder || p === folderNoExt;
// Check if a tree path or any of its transitive deps is inside the folder
const touchesFolder = (treePath: string) => {
if (isPathInFolder(treePath)) return true;
let found = false;
tree.traverseTransitive(treePath, (importPath) => {
if (isPathInFolder(importPath)) {
found = true;
return true; // stop early
}
});
return found;
};
const isRelevant = (item: StaleItem) => {
if (isInsideFolder(item)) return true;
if (item.type === "dependencies") return true;
const treePath = (item.type === "script"
? item.path.replace(/\.[^/.]+$/, "")
: item.folder).replaceAll("\\", "/");
return touchesFolder(treePath);
};
if (opts.strictFolderBoundaries) {
// Strict mode: only items inside the folder
filteredItems = staleItems.filter(isInsideFolder);
// Warn about stale items outside the folder that would be included by default
const excludedStale = staleItems.filter((item) => !isInsideFolder(item) && isRelevant(item) && item.type !== "dependencies");
for (const item of excludedStale) {
const normalizedPath = item.path.replaceAll("\\", "/");
log.warn(colors.yellow(
`Warning: ${normalizedPath} depends on something inside "${folder}" but is outside it — skipped due to --strict-folder-boundaries. Next generate-metadata will not detect it as stale.`
));
}
} else {
// Default: include items inside the folder and any stale importers that transitively depend on it
filteredItems = staleItems.filter(isRelevant);
}
}
// === Show stale items and confirm ===
@@ -210,28 +308,24 @@ async function generateMetadata(
const scripts = filteredItems.filter((i) => i.type === "script");
const flows = filteredItems.filter((i) => i.type === "flow");
const apps = filteredItems.filter((i) => i.type === "app");
const deps = filteredItems.filter((i) => i.type === "dependencies");
log.info("");
log.info(`Found ${filteredItems.length} item(s) with stale metadata:`);
log.info(`Found ${colors.bold(String(filteredItems.length))} item(s) with stale metadata:`);
if (scripts.length > 0) {
log.info(colors.gray(` Scripts (${scripts.length}):`));
for (const item of scripts) {
log.info(colors.yellow(` ${item.path}`));
const printItems = (label: string, items: StaleItem[]) => {
if (items.length === 0) return;
log.info(` ${label} (${items.length}):`);
for (const item of items) {
const reason = item.staleReason ? colors.dim(colors.white(`${item.staleReason}`)) : "";
log.info(` ~ ${item.path}` + reason);
}
}
if (flows.length > 0) {
log.info(colors.gray(` Flows (${flows.length}):`));
for (const item of flows) {
log.info(colors.yellow(` ${item.path}`));
}
}
if (apps.length > 0) {
log.info(colors.gray(` Apps (${apps.length}):`));
for (const item of apps) {
log.info(colors.yellow(` ${item.path}`));
}
}
};
printItems("Workspace dependencies", deps);
printItems("Scripts", scripts);
printItems("Flows", flows);
printItems("Apps", apps);
if (opts.dryRun) {
return;
@@ -252,61 +346,80 @@ async function generateMetadata(
log.info("");
// === Process all stale items with progress counter ===
const total = filteredItems.length;
const mismatchedWorkspaceDeps = tree.getMismatchedWorkspaceDeps();
const total = filteredItems.length - deps.length;
const maxWidth = `[${total}/${total}]`.length;
let current = 0;
const formatProgress = (n: number) => {
const bracket = `[${n}/${total}]`;
return colors.gray(bracket.padEnd(maxWidth, " "));
return colors.dim(colors.white(`[${n}/${total}]`.padEnd(maxWidth, " ")));
};
// Process scripts
for (const item of scripts) {
current++;
log.info(`${formatProgress(current)} script ${colors.cyan(item.path)}`);
log.info(`${formatProgress(current)} script ${item.path}`);
await generateScriptMetadataInternal(
item.folder,
item.path, // originalPath with extension
workspace,
opts,
false, // dryRun
true, // noStaleMessage - we handle output
rawWorkspaceDependencies,
true, // noStaleMessage
mismatchedWorkspaceDeps,
codebases,
false
false,
false, // legacyBehaviour
tree
);
}
// Process flows
for (const item of flows) {
current++;
log.info(`${formatProgress(current)} flow ${colors.cyan(item.path)}`);
await generateFlowLockInternal(
item.folder,
const result = await generateFlowLockInternal(
item.folder.replaceAll("/", SEP),
false, // dryRun
workspace,
opts,
false,
true // noStaleMessage - we handle output
true, // noStaleMessage
false, // legacyBehaviour
tree
);
const flowResult = result as FlowLocksResult | undefined;
const scriptsInfo = flowResult?.updatedScripts?.length
? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`))
: "";
log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`);
}
// Process apps
for (const item of apps) {
current++;
log.info(`${formatProgress(current)} app ${colors.cyan(item.path)}`);
await generateAppLocksInternal(
item.folder,
const result = await generateAppLocksInternal(
item.folder.replaceAll("/", SEP),
item.isRawApp!, // rawApp
false, // dryRun
workspace,
opts,
false,
true // noStaleMessage - we handle output
true, // noStaleMessage
false, // legacyBehaviour
tree
);
const appResult = result as AppLocksResult | undefined;
const scriptsInfo = appResult?.updatedScripts?.length
? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`))
: "";
log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`);
}
// Persist all stale workspace dep hashes (not just filtered — deps are global, not folder-scoped)
const allStaleDeps = staleItems.filter((i) => i.type === "dependencies");
await tree.persistDepsHashes(allStaleDeps.map((d) => d.path));
log.info("");
log.info(colors.green(`Done. Updated ${total} item(s).`));
log.info(`Done. Updated ${colors.bold(String(total))} item(s).`);
}
const command = new Command()
@@ -319,6 +432,7 @@ const command = new Command()
.option("--skip-scripts", "Skip processing scripts")
.option("--skip-flows", "Skip processing flows")
.option("--skip-apps", "Skip processing apps")
.option("--strict-folder-boundaries", "Only update items inside the specified folder (requires folder argument)")
.option(
"-i --includes <patterns:file[]>",
"Comma separated patterns to specify which files to include"
+24
View File
@@ -252,6 +252,16 @@ async function initAction(opts: InitOptions) {
}
}
// Read nonDottedPaths from config to specialize generated skills
let nonDottedPaths = true; // default for new inits
try {
const { readConfigFile } = await import("../../core/conf.ts");
const config = await readConfigFile();
nonDottedPaths = config.nonDottedPaths ?? true;
} catch {
// If config can't be read, use default
}
// Create guidance files (AGENTS.md, CLAUDE.md, and Claude skills)
try {
// Generate skills reference section for AGENTS.md
@@ -290,6 +300,20 @@ async function initAction(opts: InitOptions) {
let skillContent = SKILL_CONTENT[skill.name];
if (skillContent) {
// Replace placeholders with actual suffixes based on nonDottedPaths
if (nonDottedPaths) {
skillContent = skillContent
.replaceAll("{{FLOW_SUFFIX}}", "__flow")
.replaceAll("{{APP_SUFFIX}}", "__app")
.replaceAll("{{RAW_APP_SUFFIX}}", "__raw_app")
.replaceAll("{{INLINE_SCRIPT_NAMING}}", "Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`).");
} else {
skillContent = skillContent
.replaceAll("{{FLOW_SUFFIX}}", ".flow")
.replaceAll("{{APP_SUFFIX}}", ".app")
.replaceAll("{{RAW_APP_SUFFIX}}", ".raw_app")
.replaceAll("{{INLINE_SCRIPT_NAMING}}", "Inline script files use the `.inline_script.` naming convention (e.g. `a.inline_script.ts`).");
}
// Check if this skill has schemas that need to be appended
const schemaMappings = SCHEMA_MAPPINGS[skill.name];
if (schemaMappings && schemaMappings.length > 0) {
+183 -15
View File
@@ -9,6 +9,7 @@ import { Confirm } from "@cliffy/prompt/confirm";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import * as path from "node:path";
import { stringify as yamlStringify } from "yaml";
import { deepEqual } from "../../utils/utils.ts";
import * as wmill from "../../../gen/services.gen.ts";
@@ -51,13 +52,19 @@ import fs from "node:fs";
import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
import { execSync } from "node:child_process";
import { NewScript, Script } from "../../../gen/types.gen.ts";
import { NewScript, Script, ScriptModule } from "../../../gen/types.gen.ts";
import {
isRawAppBackendPath as isRawAppBackendPathInternal,
isAppInlineScriptPath as isAppInlineScriptPathInternal,
isFlowInlineScriptPath as isFlowInlineScriptPathInternal,
isFlowPath,
isAppPath,
isScriptModulePath,
buildModuleFolderPath,
getModuleFolderSuffix,
isModuleEntryPoint,
getScriptBasePathFromModulePath,
isRawAppPath,
} from "../../utils/resource_folders.ts";
export interface ScriptFile {
@@ -123,7 +130,7 @@ async function push(opts: PushOptions, filePath: string) {
[],
undefined,
opts,
await getRawWorkspaceDependencies(),
await getRawWorkspaceDependencies(true),
codebases
);
log.info(colors.bold.underline.green(`Script ${filePath} pushed`));
@@ -188,11 +195,17 @@ export async function handleScriptMetadata(
codebases: SyncCodebase[],
opts: GlobalOptions
): Promise<boolean> {
if (
path.endsWith(".script.json") ||
// Flat layout: my_script.script.yaml
const isFlatMeta = path.endsWith(".script.json") ||
path.endsWith(".script.yaml") ||
path.endsWith(".script.lock")
) {
path.endsWith(".script.lock");
// Folder layout: my_script__mod/script.yaml
const isFolderMeta = !isFlatMeta && isScriptModulePath(path) && (
path.endsWith("/script.yaml") ||
path.endsWith("/script.json") ||
path.endsWith("/script.lock")
);
if (isFlatMeta || isFolderMeta) {
const contentPath = await findContentFile(path);
return handleFile(
contentPath,
@@ -225,10 +238,13 @@ export async function handleFile(
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[]
): Promise<boolean> {
// Detect module entry point: e.g., my_script__mod/script.ts
const moduleEntryPoint = isModuleEntryPoint(path);
if (
!isAppInlineScriptPath(path) &&
!isFlowInlineScriptPath(path) &&
!isRawAppBackendPath(path) &&
(!isScriptModulePath(path) || moduleEntryPoint) &&
exts.some((exts) => path.endsWith(exts))
) {
if (alreadySynced.includes(path)) {
@@ -237,9 +253,9 @@ export async function handleFile(
log.debug(`Processing local script ${path}`);
alreadySynced.push(path);
const remotePath = path
.substring(0, path.indexOf("."))
.replaceAll(SEP, "/");
const remotePath = moduleEntryPoint
? getScriptBasePathFromModulePath(path)!.replaceAll(SEP, "/")
: path.substring(0, path.indexOf(".")).replaceAll(SEP, "/");
const language = inferContentTypeFromFilePath(path, opts?.defaultTs);
@@ -391,6 +407,13 @@ export async function handleFile(
typed.codebase = await codebase.getDigest(forceTar);
}
// Scan for modules: folder layout (entry point inside __mod/) or flat layout
const scriptBasePath = moduleEntryPoint
? getScriptBasePathFromModulePath(path)!
: path.substring(0, path.indexOf("."));
const moduleFolderPath = scriptBasePath + getModuleFolderSuffix();
const modules = await readModulesFromDisk(moduleFolderPath, opts?.defaultTs, moduleEntryPoint);
const requestBodyCommon: NewScript = {
content,
description: typed?.description ?? "",
@@ -409,7 +432,6 @@ export async function handleFile(
deployment_message: message,
restart_unless_cancelled: typed?.restart_unless_cancelled,
visible_to_runner_only: typed?.visible_to_runner_only,
no_main_func: typed?.no_main_func,
has_preprocessor: typed?.has_preprocessor,
priority: typed?.priority,
concurrency_key: typed?.concurrency_key,
@@ -419,6 +441,7 @@ export async function handleFile(
timeout: typed?.timeout,
on_behalf_of_email: typed?.on_behalf_of_email,
envs: typed?.envs,
modules: modules,
};
// console.log(requestBodyCommon.codebase);
@@ -449,7 +472,6 @@ export async function handleFile(
Boolean(remote.restart_unless_cancelled) &&
Boolean(typed.visible_to_runner_only) ==
Boolean(remote.visible_to_runner_only) &&
Boolean(typed.no_main_func) == Boolean(remote.no_main_func) &&
Boolean(typed.has_preprocessor) ==
Boolean(remote.has_preprocessor) &&
typed.priority == Boolean(remote.priority) &&
@@ -460,7 +482,8 @@ export async function handleFile(
typed.debounce_delay_s == remote["debounce_delay_s"] &&
typed.codebase == remote.codebase &&
typed.on_behalf_of_email == remote.on_behalf_of_email &&
deepEqual(typed.envs, remote.envs))
deepEqual(typed.envs, remote.envs) &&
deepEqual(modules ?? null, remote.modules ?? null))
) {
log.info(colors.green(`Script ${remotePath} is up to date`));
return true;
@@ -506,6 +529,135 @@ export async function handleFile(
return false;
}
/**
* Read module files from a __mod/ directory on disk.
* Returns the modules record for the API, or undefined if no module folder exists.
*/
export async function readModulesFromDisk(
moduleFolderPath: string,
defaultTs: "bun" | "deno" | undefined,
folderLayout: boolean = false,
): Promise<Record<string, ScriptModule> | undefined> {
if (!fs.existsSync(moduleFolderPath) || !fs.statSync(moduleFolderPath).isDirectory()) {
return undefined;
}
const modules: Record<string, ScriptModule> = {};
// In folder layout mode, skip the entry point files (script.*, script.yaml, etc.)
const isEntryPointFile = (name: string, isTopLevel: boolean) => {
if (!folderLayout || !isTopLevel) return false;
return (
name.startsWith("script.") ||
name === "script.lock" ||
name === "script.yaml" ||
name === "script.json"
);
};
function readDir(dirPath: string, relPrefix: string) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name;
const isTopLevel = relPrefix === "";
if (entry.isDirectory()) {
readDir(fullPath, relPath);
} else if (entry.isFile() && !entry.name.endsWith(".lock") && !isEntryPointFile(entry.name, isTopLevel)) {
// Skip lock files — they're handled as the `lock` field on ScriptModule
if (exts.some((ext) => entry.name.endsWith(ext))) {
const content = fs.readFileSync(fullPath, "utf-8");
const language = inferContentTypeFromFilePath(entry.name, defaultTs);
// Check for an accompanying lock file (helper.lock)
const baseName = entry.name.replace(/\.[^.]+$/, '');
const lockPath = path.join(dirPath, baseName + ".lock");
let lock: string | undefined;
if (fs.existsSync(lockPath)) {
lock = fs.readFileSync(lockPath, "utf-8");
}
modules[relPath] = {
content,
language: language as ScriptModule["language"],
lock: lock ?? undefined,
};
}
}
}
}
readDir(moduleFolderPath, "");
if (Object.keys(modules).length === 0) {
return undefined;
}
log.debug(`Found ${Object.keys(modules).length} module(s) in ${moduleFolderPath}`);
return modules;
}
/**
* Write module files to a __mod/ directory on disk during pull.
*/
export async function writeModulesToDisk(
moduleFolderPath: string,
modules: Record<string, ScriptModule>,
defaultTs: "bun" | "deno" | undefined
): Promise<void> {
// Ensure the module folder exists
fs.mkdirSync(moduleFolderPath, { recursive: true });
// Clean up stale module files that are no longer in the modules map
const expectedFiles = new Set<string>();
for (const [relPath, mod] of Object.entries(modules)) {
expectedFiles.add(relPath);
if (mod.lock) {
expectedFiles.add(relPath.replace(/\.[^.]+$/, '') + ".lock");
}
}
function cleanDir(dirPath: string, relPrefix: string) {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) return;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name;
if (entry.isDirectory()) {
cleanDir(path.join(dirPath, entry.name), relPath);
// Remove empty directories after cleaning
try {
const remaining = fs.readdirSync(path.join(dirPath, entry.name));
if (remaining.length === 0) {
fs.rmdirSync(path.join(dirPath, entry.name));
}
} catch {}
} else if (!expectedFiles.has(relPath)) {
fs.unlinkSync(path.join(dirPath, entry.name));
}
}
}
cleanDir(moduleFolderPath, "");
for (const [relPath, mod] of Object.entries(modules)) {
const fullPath = path.join(moduleFolderPath, relPath);
const dir = path.dirname(fullPath);
fs.mkdirSync(dir, { recursive: true });
// Write the module content
fs.writeFileSync(fullPath, mod.content, "utf-8");
// Write the lock file if present
if (mod.lock) {
const baseName = relPath.replace(/\.[^.]+$/, '');
const lockPath = path.join(moduleFolderPath, baseName + ".lock");
const lockDir = path.dirname(lockPath);
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(lockPath, mod.lock, "utf-8");
}
}
}
async function createScript(
bundleContent: string | Blob | undefined,
workspaceId: string,
@@ -559,7 +711,12 @@ async function createScript(
}
export async function findContentFile(filePath: string) {
const candidates = filePath.endsWith("script.json")
// Folder layout: __mod/script.yaml -> __mod/script.ts
const isModuleFolderMeta =
filePath.endsWith("/script.yaml") || filePath.endsWith("/script.json") || filePath.endsWith("/script.lock");
const candidates = isModuleFolderMeta
? exts.map((x) => filePath.replace(/\/script\.(yaml|json|lock)$/, "/script" + x))
: filePath.endsWith("script.json")
? exts.map((x) => filePath.replace(".script.json", x))
: filePath.endsWith("script.lock")
? exts.map((x) => filePath.replace(".script.lock", x))
@@ -1004,7 +1161,7 @@ export async function generateMetadata(
opts = await mergeConfigWithConfigFile(opts);
const codebases = await listSyncCodebases(opts);
const rawWorkspaceDependencies = await getRawWorkspaceDependencies();
const rawWorkspaceDependencies = await getRawWorkspaceDependencies(true);
if (scriptPath) {
// read script metadata file
await generateScriptMetadataInternal(
@@ -1027,7 +1184,10 @@ export async function generateMetadata(
(!isD && !exts.some((ext) => p.endsWith(ext))) ||
ignore(p, isD) ||
isFlowPath(p) ||
isAppPath(p)
isAppPath(p) ||
isRawAppPath(p) ||
// Skip module helper files; only entry points (script.{ext}) are processed
(isScriptModulePath(p) && !isModuleEntryPoint(p))
);
},
false,
@@ -1116,6 +1276,13 @@ async function preview(
const content = await readFile(filePath, "utf-8");
const input = opts.data ? await resolve(opts.data) : {};
// Read modules from __mod/ folder if present
const isFolderLayout = isModuleEntryPoint(filePath);
const moduleFolderPath = isFolderLayout
? path.dirname(filePath)
: filePath.substring(0, filePath.indexOf(".")) + getModuleFolderSuffix();
const modules = await readModulesFromDisk(moduleFolderPath, opts?.defaultTs, isFolderLayout);
// Check if this is a codebase script
const codebase =
language == "bun" ? findCodebase(filePath, codebases) : undefined;
@@ -1275,6 +1442,7 @@ async function preview(
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
args: input,
language: language as any,
modules: modules ?? undefined,
},
});
+260 -16
View File
@@ -31,6 +31,7 @@ import {
findResourceFile,
handleScriptMetadata,
removeExtensionToPath,
filePathExtensionFromContentType,
} from "../script/script.ts";
import { handleFile } from "../script/script.ts";
@@ -68,7 +69,7 @@ import {
readLockfile,
workspaceDependenciesPathToLanguageAndFilename,
} from "../../utils/metadata.ts";
import { OpenFlow, NativeServiceName } from "../../../gen/types.gen.ts";
import { OpenFlow, NativeServiceName, ScriptModule } from "../../../gen/types.gen.ts";
import { pushResource } from "../resource/resource.ts";
import {
newPathAssigner,
@@ -97,6 +98,10 @@ import {
getFolderSuffix,
getFolderSuffixWithSep,
getNonDottedPaths,
isScriptModulePath,
getModuleFolderSuffix,
isModuleEntryPoint,
getScriptBasePathFromModulePath,
} from "../../utils/resource_folders.ts";
// Merge CLI options with effective settings, preserving CLI flags as overrides
@@ -534,6 +539,29 @@ function ZipFSElement(
resourceTypeToIsFileset: Record<string, boolean>,
ignoreCodebaseChanges: boolean,
): DynFSElement {
// Pre-scan: find zip base paths of scripts that have modules.
// These scripts use the folder layout: {basePath}__mod/script.{ext}
let _moduleScriptPaths: Set<string> | null = null;
async function getModuleScriptPaths(): Promise<Set<string>> {
if (_moduleScriptPaths === null) {
_moduleScriptPaths = new Set();
for (const filename in zip.files) {
if (filename.endsWith(".script.json") && !zip.files[filename].dir) {
try {
const content = await zip.files[filename].async("text");
const parsed = JSON.parse(content);
if (parsed.modules && Object.keys(parsed.modules).length > 0) {
_moduleScriptPaths.add(
filename.slice(0, -".script.json".length)
);
}
} catch {}
}
}
}
return _moduleScriptPaths;
}
async function _internal_file(
p: string,
f: JSZip.JSZipObject,
@@ -575,7 +603,22 @@ function ZipFSElement(
}
}
const finalPath = transformPath();
let finalPath = transformPath();
// Redirect content files for scripts with modules into __mod/ folder
if (kind == "other" && exts.some((ext) => p.endsWith(ext))) {
const normalizedP = p.replace(/^\.[\\/]/, "");
const moduleScripts = await getModuleScriptPaths();
for (const basePath of moduleScripts) {
if (normalizedP.startsWith(basePath + ".")) {
const ext = normalizedP.slice(basePath.length); // e.g., ".ts", ".py"
const dir = path.dirname(finalPath);
const base = path.basename(basePath);
finalPath = path.join(dir, base + getModuleFolderSuffix(), "script" + ext);
break;
}
}
}
const r = [
{
@@ -893,15 +936,23 @@ function ZipFSElement(
log.error(`Failed to parse script.yaml at path: ${p}`);
throw error;
}
const hasModules = parsed["modules"] && Object.keys(parsed["modules"]).length > 0;
if (
parsed["lock"] &&
parsed["lock"] != "" &&
parsed["codebase"] == undefined
) {
parsed["lock"] =
"!inline " +
removeSuffix(p.replaceAll(SEP, "/"), ".json") +
".lock";
if (hasModules) {
// Lock lives inside __mod/ folder as script.lock
const scriptBase = removeSuffix(removeSuffix(p.replaceAll(SEP, "/"), ".json"), ".script");
parsed["lock"] =
"!inline " + scriptBase + getModuleFolderSuffix() + "/script.lock";
} else {
parsed["lock"] =
"!inline " +
removeSuffix(p.replaceAll(SEP, "/"), ".json") +
".lock";
}
} else if (parsed["lock"] == "") {
parsed["lock"] = "";
} else {
@@ -910,6 +961,8 @@ function ZipFSElement(
if (ignoreCodebaseChanges && parsed["codebase"]) {
parsed["codebase"] = undefined;
}
// Modules are stored as files in __mod/ folder, not in metadata
delete parsed["modules"];
return useYaml
? yamlStringify(parsed, yamlOptions)
: JSON.stringify(parsed, null, 2);
@@ -969,16 +1022,71 @@ function ZipFSElement(
throw error;
}
const lock = parsed["lock"];
const scriptModules: Record<string, ScriptModule> | undefined = parsed["modules"];
const hasModules = scriptModules && Object.keys(scriptModules).length > 0;
// Compute base path and module folder
const metaExt = useYaml ? ".yaml" : ".json";
const scriptBasePath = removeSuffix(
removeSuffix(finalPath, metaExt),
".script"
);
const moduleFolderPath = scriptBasePath + getModuleFolderSuffix();
if (hasModules) {
// Redirect metadata into __mod/script.yaml
r[0].path = path.join(moduleFolderPath, "script" + metaExt);
}
if (lock && lock != "") {
r.push({
isDirectory: false,
path: removeSuffix(finalPath, ".json") + ".lock",
path: hasModules
? path.join(moduleFolderPath, "script.lock")
: removeSuffix(finalPath, metaExt) + ".lock",
async *getChildren() {},
async getContentText() {
return lock;
},
});
}
// Extract script modules into __mod/ folder
if (hasModules) {
r.push({
isDirectory: true,
path: moduleFolderPath,
async *getChildren() {
for (const [relPath, mod] of Object.entries(scriptModules!)) {
// Yield the module content file
yield {
isDirectory: false,
path: path.join(moduleFolderPath, relPath),
async *getChildren() {},
async getContentText() {
return mod.content;
},
};
// Yield the module lock file if present
if (mod.lock) {
const baseName = relPath.replace(/\.[^.]+$/, '');
yield {
isDirectory: false,
path: path.join(moduleFolderPath, baseName + ".lock"),
async *getChildren() {},
async getContentText() {
return mod.lock!;
},
};
}
}
},
async getContentText() {
throw new Error("Cannot get content of directory");
},
});
}
}
if (kind == "resource") {
const content = await f.async("text");
@@ -1154,6 +1262,12 @@ export async function elementsToMap(
continue;
}
const path = entry.path;
// Include module files in the map so they're compared for changes,
// but they're pushed as part of their parent script via handleFile
if (isScriptModulePath(path)) {
map[path] = await entry.getContentText();
continue;
}
if (
!isFileResource(path) &&
!isFilesetResource(path) &&
@@ -1600,6 +1714,11 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => {
);
}
// Files inside __mod/ folders are script module files — always valid wmill files
if (isScriptModulePath(p)) {
return false;
}
try {
const typ = getTypeStrFromPath(p);
if (
@@ -1744,6 +1863,37 @@ async function addToChangedIfNotExists(p: string, tracker: ChangeTracker) {
if (!tracker.rawApps.includes(folder)) {
tracker.rawApps.push(folder);
}
} else if (isScriptModulePath(p)) {
if (isModuleEntryPoint(p)) {
// Entry point (e.g. __mod/script.ts) IS the parent script content file
if (!tracker.scripts.includes(p)) {
tracker.scripts.push(p);
}
} else {
// Module file changed — find the parent script content file
const moduleSuffix = getModuleFolderSuffix() + "/";
const idx = p.indexOf(moduleSuffix);
if (idx !== -1) {
const scriptBasePath = p.substring(0, idx);
// Try folder layout first: __mod/script.{ext}
try {
const contentPath = await findContentFile(scriptBasePath + getModuleFolderSuffix() + "/script.yaml");
if (contentPath && !tracker.scripts.includes(contentPath)) {
tracker.scripts.push(contentPath);
}
} catch {
// Fall back to flat layout: scriptBasePath.script.yaml
try {
const contentPath = await findContentFile(scriptBasePath + ".script.yaml");
if (contentPath && !tracker.scripts.includes(contentPath)) {
tracker.scripts.push(contentPath);
}
} catch {
// ignore — content file not found
}
}
}
}
} else {
if (!tracker.scripts.includes(p)) {
tracker.scripts.push(p);
@@ -1776,6 +1926,61 @@ async function buildTracker(changes: Change[]) {
return tracker;
}
/**
* When a module file changes, find and push the parent script.
* The parent script's handleFile will read the __mod/ folder and include all modules.
*/
async function pushParentScriptForModule(
modulePath: string,
workspace: Workspace,
alreadySynced: string[],
message: string | undefined,
opts: (GlobalOptions & { defaultTs?: "bun" | "deno" } & Skips) | undefined,
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[],
): Promise<void> {
const moduleSuffix = getModuleFolderSuffix() + "/";
const idx = modulePath.indexOf(moduleSuffix);
if (idx === -1) return;
const scriptBasePath = modulePath.substring(0, idx);
const moduleFolderPath = scriptBasePath + getModuleFolderSuffix();
// Try folder layout first: look for script.{ext} inside __mod/
try {
const entryPoint = await findContentFile(moduleFolderPath + "/script.yaml");
if (entryPoint) {
await handleFile(
entryPoint,
workspace,
alreadySynced,
message,
opts,
rawWorkspaceDependencies,
codebases,
);
return;
}
} catch {}
// Fall back to flat layout: look for content file alongside __mod/
try {
const contentPath = await findContentFile(scriptBasePath + ".script.yaml");
if (contentPath) {
await handleFile(
contentPath,
workspace,
alreadySynced,
message,
opts,
rawWorkspaceDependencies,
codebases,
);
}
} catch {
log.debug(`Could not find parent script for module: ${modulePath}`);
}
}
export async function pull(
opts: GlobalOptions &
SyncOptions & { repository?: string; promotion?: string; branch?: string },
@@ -2075,7 +2280,7 @@ export async function pull(
const tracker: ChangeTracker = await buildTracker(changes);
const rawWorkspaceDependencies: Record<string, string> =
await getRawWorkspaceDependencies();
await getRawWorkspaceDependencies(true);
for (const change of tracker.scripts) {
await generateScriptMetadataInternal(
@@ -2406,7 +2611,7 @@ export async function push(
false, // els1 (local) is not the remote source
);
const rawWorkspaceDependencies = await getRawWorkspaceDependencies();
const rawWorkspaceDependencies = await getRawWorkspaceDependencies(true);
const tracker: ChangeTracker = await buildTracker(changes);
@@ -2452,7 +2657,7 @@ export async function push(
true,
);
if (stale) {
staleFlows.push(stale);
staleFlows.push(stale as string);
}
}
@@ -2477,7 +2682,7 @@ export async function push(
true,
);
if (stale) {
staleApps.push(stale);
staleApps.push(stale as string);
}
}
@@ -2492,7 +2697,7 @@ export async function push(
true,
);
if (stale) {
staleApps.push(stale);
staleApps.push(stale as string);
}
}
@@ -2704,6 +2909,21 @@ export async function push(
await writeFile(stateTarget, change.after, "utf-8");
}
continue;
} else if (isScriptModulePath(change.path)) {
// Module file changed — push the parent script
await pushParentScriptForModule(
change.path,
workspace,
alreadySynced,
opts.message,
opts,
rawWorkspaceDependencies,
codebases,
);
if (stateTarget) {
await writeFile(stateTarget, change.after, "utf-8");
}
continue;
}
if (stateTarget) {
await mkdir(path.dirname(stateTarget), { recursive: true });
@@ -2828,6 +3048,17 @@ export async function push(
)
) {
continue;
} else if (isScriptModulePath(change.path)) {
await pushParentScriptForModule(
change.path,
workspace,
alreadySynced,
opts.message,
opts,
rawWorkspaceDependencies,
codebases,
);
continue;
}
if (stateTarget) {
await mkdir(path.dirname(stateTarget), { recursive: true });
@@ -2869,6 +3100,19 @@ export async function push(
if (change.path.endsWith(".lock")) {
continue;
}
if (isScriptModulePath(change.path)) {
// Module file deleted — push the parent script (which will now have fewer modules)
await pushParentScriptForModule(
change.path,
workspace,
alreadySynced,
opts.message,
opts,
rawWorkspaceDependencies,
codebases,
);
continue;
}
const typ = getTypeStrFromPath(change.path);
if (typ == "script") {
@@ -3244,8 +3488,8 @@ const command = new Command()
"Use promotionOverrides from the specified branch instead of regular overrides",
)
.option(
"--branch <branch:string>",
"Override the current git branch (works even outside a git repository)",
"--branch, --env <branch:string>",
"Override the current git branch/environment (works even outside a git repository)",
)
.action(pull as any)
.command("push")
@@ -3300,8 +3544,8 @@ const command = new Command()
"Specify repository path (e.g., u/user/repo) when multiple repositories exist",
)
.option(
"--branch <branch:string>",
"Override the current git branch (works even outside a git repository)",
"--branch, --env <branch:string>",
"Override the current git branch/environment (works even outside a git repository)",
)
.option("--lint", "Run lint validation before pushing")
.option(
+2 -2
View File
@@ -568,11 +568,11 @@ const command = new Command()
.action(listRemote as any)
.command("bind")
.description("Bind the current Git branch to the active workspace")
.option("--branch <branch:string>", "Specify branch (defaults to current)")
.option("--branch, --env <branch:string>", "Specify branch/environment (defaults to current)")
.action((opts) => bind(opts as any, true))
.command("unbind")
.description("Remove workspace binding from the current Git branch")
.option("--branch <branch:string>", "Specify branch (defaults to current)")
.option("--branch, --env <branch:string>", "Specify branch/environment (defaults to current)")
.action((opts) => bind(opts as any, false))
.command("fork")
.description("Create a forked workspace")
+14
View File
@@ -75,6 +75,8 @@ export interface SyncOptions {
};
};
};
// Alias for gitBranches - for users who prefer environment-based terminology
environments?: SyncOptions["gitBranches"];
// Legacy field - deprecated, use gitBranches instead
git_branches?: {
commonSpecificItems?: {
@@ -231,6 +233,18 @@ export async function readConfigFile(): Promise<SyncOptions> {
}
}
// Handle environments -> gitBranches alias (permanent alias, not a deprecation)
if (conf && "environments" in conf) {
if (!conf.gitBranches) {
conf.gitBranches = conf.environments as any;
} else {
log.warn(
"⚠️ Both 'environments' and 'gitBranches' found in wmill.yaml. Using 'gitBranches' and ignoring 'environments'."
);
}
delete (conf as any).environments;
}
// Handle git_branches to gitBranches migration
if (conf && "git_branches" in conf) {
if (!conf.gitBranches) {
+11
View File
@@ -53,6 +53,7 @@ export interface SimplifiedSettings {
mute_critical_alerts?: boolean;
color?: string;
operator_settings?: any;
datatable?: any;
slack_team_id?: string;
slack_name?: string;
slack_command_script?: string;
@@ -100,6 +101,7 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
if (settings.mute_critical_alerts !== undefined) result.mute_critical_alerts = settings.mute_critical_alerts;
if (settings.color !== undefined) result.color = settings.color;
if (settings.operator_settings !== undefined) result.operator_settings = settings.operator_settings;
if (settings.datatable !== undefined) result.datatable = settings.datatable;
if (settings.slack_team_id !== undefined) result.slack_team_id = settings.slack_team_id;
if (settings.slack_name !== undefined) result.slack_name = settings.slack_name;
if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script;
@@ -192,6 +194,7 @@ export async function pushWorkspaceSettings(
mute_critical_alerts: remoteSettings.mute_critical_alerts,
color: remoteSettings.color,
operator_settings: remoteSettings.operator_settings,
datatable: remoteSettings.datatable,
slack_team_id: remoteSettings.slack_team_id,
slack_name: remoteSettings.slack_name,
slack_command_script: remoteSettings.slack_command_script,
@@ -382,6 +385,14 @@ export async function pushWorkspaceSettings(
});
}
if (!deepEqual(localSettings.datatable, settings.datatable)) {
log.debug(`Updating datatable config...`);
await wmill.editDataTableConfig({
workspace,
requestBody: { settings: localSettings.datatable ?? { datatables: {} } },
});
}
if (localSettings.slack_command_script != settings.slack_command_script) {
log.debug(`Updating slack command script...`);
await wmill.editSlackCommand({
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -68,7 +68,7 @@ export {
workspaceAdd,
};
export const VERSION = "1.655.0";
export const VERSION = "1.662.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+4
View File
@@ -28,6 +28,7 @@ import {
isRawAppPath,
extractResourceName,
buildFolderPath,
isScriptModulePath,
} from "./utils/resource_folders.ts";
export interface DifferenceCreate {
@@ -259,6 +260,9 @@ export function getTypeStrFromPath(
| "settings"
| "encryption_key"
| "workspace_dependencies" {
if (isScriptModulePath(p)) {
return "script";
}
if (isFlowPath(p)) {
return "flow";
}
+373
View File
@@ -0,0 +1,373 @@
/**
* Double-linked dependency tree for tracking script imports and propagating staleness.
*/
import { Workspace } from "../commands/workspace/workspace.ts";
import * as wmill from "../../gen/services.gen.ts";
import type { ScriptLang } from "../../gen/types.gen.ts";
import { ScriptLanguage } from "./script_common.ts";
import {
filterWorkspaceDependencies,
generateScriptHash,
checkifMetadataUptodate,
workspaceDependenciesPathToLanguageAndFilename,
updateMetadataGlobalLock,
} from "./metadata.ts";
import { generateHash } from "./utils.ts";
/**
* Diff local scripts against deployed versions, upload only those that differ.
* Only uploaded (mismatched) scripts get contentHash set, so flatten() returns
* temp_script_refs only for scripts the backend can't resolve from deployed versions.
*/
export async function uploadScripts(
tree: DoubleLinkedDependencyTree,
workspace: Workspace
): Promise<void> {
// Split into scripts vs workspace deps and compute SHA256(content) for each
const scriptHashes: Record<string, string> = {};
const workspaceDeps: { path: string; language: ScriptLang; name?: string; hash: string }[] = [];
for (const path of tree.allPaths()) {
const content = tree.getContent(path);
const itemType = tree.getItemType(path);
if (itemType === "dependencies") {
// Empty string is valid for workspace deps (means "no deps") — only skip undefined
if (content === undefined) continue;
const info = workspaceDependenciesPathToLanguageAndFilename(path);
if (info) {
const hash = await generateHash(content);
workspaceDeps.push({ path, language: info.language as ScriptLang, name: info.name, hash });
}
} else if (itemType === "script") {
if (!content) continue;
const hash = await generateHash(content);
scriptHashes[path] = hash;
}
// Skip inline_script, flow, app — they don't need temp storage uploads
}
if (Object.keys(scriptHashes).length === 0 && workspaceDeps.length === 0) return;
// Single batch query: find which scripts/deps differ from deployed versions
const mismatched = await wmill.diffRawScriptsWithDeployed({
workspace: workspace.workspaceId,
requestBody: {
scripts: scriptHashes,
workspace_deps: workspaceDeps,
},
});
// Upload only mismatched scripts to temp storage
for (const path of mismatched) {
const content = tree.getContent(path);
const itemType = tree.getItemType(path);
if (itemType === "dependencies") {
// Workspace deps don't need temp storage — just mark as mismatched.
// Empty string is valid (means the dep file was emptied locally).
if (content !== undefined) {
tree.setContentHash(path, "mismatched");
}
} else if (content) {
const hash = await wmill.storeRawScriptTemp({
workspace: workspace.workspaceId,
requestBody: content,
});
tree.setContentHash(path, hash);
}
}
}
export type ItemType = "script" | "inline_script" | "flow" | "app" | "dependencies";
interface DependencyNode {
content: string;
stalenessHash: string; // Hash for staleness detection (includes deps, content, metadata)
contentHash?: string; // Hash for temp storage lookup (content only)
language: ScriptLanguage;
metadata: string;
imports: Set<string>;
importedBy: Set<string>;
staleReason?: string;
// Item metadata for generate-metadata command
itemType: ItemType;
folder: string; // Folder path (for flows/apps) or remote path (for scripts)
originalPath: string; // Original path passed to handler (with extension for scripts)
isRawApp?: boolean; // Only set for apps
isDirectlyStale: boolean; // True if this item's content changed (vs transitively stale)
}
export class DoubleLinkedDependencyTree {
private nodes: Map<string, DependencyNode> = new Map();
private workspaceDeps: Record<string, string> = {};
setWorkspaceDeps(deps: Record<string, string>): void {
this.workspaceDeps = deps;
}
async addNode(
path: string,
content: string,
language: ScriptLanguage,
metadata: string,
imports: string[],
itemType: ItemType,
folder: string,
originalPath: string,
isDirectlyStale: boolean,
isRawApp?: boolean
): Promise<void> {
const hasWorkspaceDeps = itemType === "script" || itemType === "inline_script";
const filteredDeps = hasWorkspaceDeps
? filterWorkspaceDependencies(this.workspaceDeps, content, language)
: {};
const stalenessHash = await generateScriptHash({}, content, metadata);
if (!this.nodes.has(path)) {
this.nodes.set(path, {
content: "", stalenessHash: "", language: "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
});
}
const node = this.nodes.get(path)!;
node.content = content;
node.stalenessHash = stalenessHash;
node.language = language;
node.metadata = metadata;
node.itemType = itemType;
node.folder = folder;
node.originalPath = originalPath;
node.isDirectlyStale = isDirectlyStale;
node.isRawApp = isRawApp;
// Create nodes for referenced workspace deps with content and language.
const filteredDepsPaths = Object.keys(filteredDeps);
for (const depsPath of filteredDepsPaths) {
if (!this.nodes.has(depsPath)) {
const depsInfo = workspaceDependenciesPathToLanguageAndFilename(depsPath);
const contentHash = await generateHash(filteredDeps[depsPath] + depsPath);
const isUpToDate = await checkifMetadataUptodate(depsPath, contentHash, undefined);
this.nodes.set(depsPath, {
content: filteredDeps[depsPath],
stalenessHash: "", language: depsInfo?.language ?? "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "dependencies", folder: "", originalPath: depsPath,
isDirectlyStale: !isUpToDate,
});
}
}
const allImports = [...imports, ...filteredDepsPaths];
for (const importPath of allImports) {
node.imports.add(importPath);
if (!this.nodes.has(importPath)) {
this.nodes.set(importPath, {
content: "", stalenessHash: "", language: "deno", metadata: "",
imports: new Set(), importedBy: new Set(),
itemType: "script", folder: "", originalPath: "", isDirectlyStale: false,
});
}
this.nodes.get(importPath)!.importedBy.add(path);
}
}
getContent(path: string): string | undefined {
return this.nodes.get(path)?.content;
}
getStalenessHash(path: string): string | undefined {
return this.nodes.get(path)?.stalenessHash;
}
getContentHash(path: string): string | undefined {
return this.nodes.get(path)?.contentHash;
}
setContentHash(path: string, hash: string): void {
const node = this.nodes.get(path);
if (node) {
node.contentHash = hash;
}
}
getLanguage(path: string): ScriptLanguage | undefined {
return this.nodes.get(path)?.language;
}
getMetadata(path: string): string | undefined {
return this.nodes.get(path)?.metadata;
}
getStaleReason(path: string): string | undefined {
return this.nodes.get(path)?.staleReason;
}
getItemType(path: string): ItemType | undefined {
return this.nodes.get(path)?.itemType;
}
getFolder(path: string): string | undefined {
return this.nodes.get(path)?.folder;
}
getIsRawApp(path: string): boolean | undefined {
return this.nodes.get(path)?.isRawApp;
}
getIsDirectlyStale(path: string): boolean {
return this.nodes.get(path)?.isDirectlyStale ?? false;
}
getOriginalPath(path: string): string | undefined {
return this.nodes.get(path)?.originalPath;
}
getImports(path: string): Set<string> | undefined {
return this.nodes.get(path)?.imports;
}
/**
* Returns true if this node has been marked stale (directly or transitively).
*/
isStale(path: string): boolean {
return this.nodes.get(path)?.staleReason !== undefined;
}
/**
* Mutates the tree by removing all nodes that are not stale.
* Uses BFS on reverse graph (importedBy) to find all stale scripts.
* Starts from nodes with isDirectlyStale=true.
*/
propagateStaleness(): void {
// Collect directly stale nodes
const directlyStale = new Set<string>();
for (const [path, node] of this.nodes.entries()) {
if (node.isDirectlyStale) {
directlyStale.add(path);
node.staleReason = "content changed";
}
}
const allStale = new Set(directlyStale);
const queue = [...directlyStale];
const visited = new Set<string>();
while (queue.length > 0) {
const scriptPath = queue.shift()!;
if (visited.has(scriptPath)) continue;
visited.add(scriptPath);
const node = this.nodes.get(scriptPath);
if (!node) continue;
for (const importer of node.importedBy) {
if (!allStale.has(importer)) {
allStale.add(importer);
queue.push(importer);
// Set reason for transitively stale scripts
const importerNode = this.nodes.get(importer);
if (importerNode) importerNode.staleReason = `depends on ${scriptPath}`;
}
}
}
}
/**
* Walks all transitive imports for a node, calling the callback for each.
* Callback may return true to stop traversing that branch.
*/
traverseTransitive(scriptPath: string, callback: (importPath: string, node: DependencyNode) => boolean | void): void {
const queue = [scriptPath];
const visited = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
const node = this.nodes.get(current);
if (!node) continue;
for (const importPath of node.imports) {
const importNode = this.nodes.get(importPath);
if (importNode) {
const stop = callback(importPath, importNode);
if (!stop) {
queue.push(importPath);
}
}
}
}
}
allPaths(): IterableIterator<string> {
return this.nodes.keys();
}
/**
* Returns paths of all stale nodes (those with a staleReason).
*/
*stalePaths(): IterableIterator<string> {
for (const [path, node] of this.nodes.entries()) {
if (node.staleReason) {
yield path;
}
}
}
has(path: string): boolean {
return this.nodes.has(path);
}
/**
* Returns workspace deps that were uploaded as mismatched with remote.
* These need to be passed as raw_workspace_dependencies in job args
* so the backend uses local content instead of deployed.
*/
getMismatchedWorkspaceDeps(): Record<string, string> {
const result: Record<string, string> = {};
for (const [path, node] of this.nodes.entries()) {
if (node.itemType === "dependencies" && node.contentHash && node.content !== undefined) {
result[path] = node.content;
}
}
return result;
}
/**
* Returns path → contentHash for all transitive imports that have been uploaded.
* Must be called after uploadScripts() has populated contentHash values.
*/
getTempScriptRefs(scriptPath: string): Record<string, string> {
const result: Record<string, string> = {};
this.traverseTransitive(scriptPath, (_path, node) => {
if (node.contentHash) {
result[_path] = node.contentHash;
}
});
return result;
}
/**
* Persist workspace dep hashes to wmill-lock.yaml so getRawWorkspaceDependencies
* considers them up-to-date on the next run.
*/
async persistDepsHashes(depsPaths: string[]): Promise<void> {
for (const path of depsPaths) {
const node = this.nodes.get(path);
if (node?.itemType === "dependencies" && node.content !== undefined) {
const hash = await generateHash(node.content + path);
await updateMetadataGlobalLock(path, hash);
}
}
}
get size(): number {
return this.nodes.size;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { execFileSync } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import type { SyncCodebase } from "./codebase.ts";
import { parseMetadataFileIfExists } from "./metadata.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
import { findCodebase } from "../commands/sync/sync.ts";
import type { LocalScriptInfo } from "../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import type { RawScript } from "../../../gen/types.gen.ts";
export class UnsupportedLocalPathScriptPreviewError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsupportedLocalPathScriptPreviewError";
}
}
async function readOptionalLock(scriptPath: string): Promise<string | undefined> {
try {
return await readFile(scriptPath + ".script.lock", "utf-8");
} catch {
return undefined;
}
}
function normalizeOptionalLock(lock: string | undefined): string | undefined {
return typeof lock === "string" && lock.trim() === "" ? undefined : lock;
}
async function bundleSingleFileCodebaseScript(
filePath: string,
codebase: SyncCodebase
): Promise<string> {
if (codebase.customBundler) {
// Pass the script path as a positional shell argument so existing shell-based
// custom bundlers still work without interpolating the path into the command.
return execFileSync(
"sh",
["-lc", `${codebase.customBundler} "$1"`, "sh", filePath],
{
maxBuffer: 1024 * 1024 * 50,
}
).toString();
}
const esbuild = await import("esbuild");
const out = await esbuild.build({
entryPoints: [filePath],
// Inline rawscripts are executed through the standard module wrapper,
// so the bundle must expose `main` as an ESM export.
format: "esm",
bundle: true,
write: false,
external: codebase.external,
inject: codebase.inject,
define: codebase.define,
loader: codebase.loader ?? { ".node": "file" },
outdir: "/",
platform: "node",
packages: "bundle",
target: "esnext",
banner: codebase.banner,
});
if (out.outputFiles.length === 0) {
throw new Error(`No output files found for ${filePath}`);
}
if (out.outputFiles.length > 1) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires a multi-file bundle, which flow preview/dev cannot inline yet`
);
}
if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires codebase assets, which flow preview/dev cannot inline yet`
);
}
return out.outputFiles[0].text;
}
export function createPreviewLocalScriptReader(opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}): (scriptPath: string) => Promise<LocalScriptInfo | undefined> {
return async (scriptPath) => {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
return undefined;
}
const content = localScript.codebase
? await bundleSingleFileCodebaseScript(localScript.filePath, localScript.codebase)
: localScript.content;
return {
content,
language: localScript.language,
lock: localScript.lock,
tag: localScript.tag,
};
};
}
export type PreviewLocalScriptState = {
filePath: string;
content: string;
language: RawScript["language"];
lock?: string;
tag?: string;
codebase?: SyncCodebase;
codebaseDigest?: string;
};
export async function resolvePreviewLocalScriptState(
scriptPath: string,
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<PreviewLocalScriptState | undefined> {
for (const ext of opts.exts) {
const filePath = scriptPath + ext;
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const language = inferContentTypeFromFilePath(filePath, opts.defaultTs);
const metadata = await parseMetadataFileIfExists(scriptPath);
const rawLock = metadata?.payload?.lock ?? (await readOptionalLock(scriptPath));
const codebase =
language === "bun" ? findCodebase(filePath, opts.codebases) : undefined;
return {
filePath,
content: await readFile(filePath, "utf-8"),
language,
lock: normalizeOptionalLock(rawLock),
tag: metadata?.payload?.tag,
codebase,
codebaseDigest: codebase
? await codebase.getDigest(
Array.isArray(codebase.assets) && codebase.assets.length > 0
)
: undefined,
};
}
return undefined;
}
+422 -104
View File
@@ -5,7 +5,8 @@ import * as log from "../core/log.ts";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "./yaml.ts";
import { readFile, writeFile, stat, rm, readdir } from "node:fs/promises";
import { readFileSync } from "node:fs";
import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import { createRequire } from "node:module";
import {
ScriptMetadata,
@@ -15,19 +16,23 @@ import { Workspace } from "../commands/workspace/workspace.ts";
import {
ScriptLanguage,
workspaceDependenciesLanguages,
languageNeedsLock,
} from "./script_common.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
import { getModuleFolderSuffix, isModuleEntryPoint, getScriptBasePathFromModulePath } from "./resource_folders.ts";
import { findCodebase, yamlOptions } from "../commands/sync/sync.ts";
import { generateHash, readInlinePathSync, getHeaders } from "./utils.ts";
import { SyncCodebase } from "./codebase.ts";
import { argSigToJsonSchemaType } from "../../windmill-utils-internal/src/parse/parse-schema.ts";
import { getIsWin } from "./utils.ts";
import { extractRelativeImports } from "./relative_imports.ts";
import { DoubleLinkedDependencyTree } from "./dependency_tree.ts";
const _require = createRequire(import.meta.url);
const _parserCache = new Map<string, Promise<any>>();
function loadParser(pkgName: string): Promise<any> {
export function loadParser(pkgName: string): Promise<any> {
let p = _parserCache.get(pkgName);
if (!p) {
p = (async () => {
@@ -50,27 +55,28 @@ export class LockfileGenerationError extends Error {
}
}
export async function generateAllMetadata() {}
export async function getRawWorkspaceDependencies(): Promise<Record<string, string>> {
export async function getRawWorkspaceDependencies(legacyBehaviour: boolean): Promise<Record<string, string>> {
const rawWorkspaceDeps: Record<string, string> = {};
try {
const entries = await readdir("dependencies", { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) continue;
const filePath = `dependencies/${entry.name}`;
const content = await readFile(filePath, "utf-8");
// Find matching language
for (const lang of workspaceDependenciesLanguages) {
if (entry.name.endsWith(lang.filename)) {
// Check if out of sync
const contentHash = await generateHash(content + filePath);
const isUpToDate = await checkifMetadataUptodate(filePath, contentHash, undefined);
if (!isUpToDate) {
if (legacyBehaviour) {
const contentHash = await generateHash(content + filePath);
const isUpToDate = await checkifMetadataUptodate(filePath, contentHash, undefined);
if (!isUpToDate) {
rawWorkspaceDeps[filePath] = content;
}
} else {
rawWorkspaceDeps[filePath] = content;
}
break;
@@ -184,15 +190,22 @@ export async function generateScriptMetadataInternal(
noStaleMessage: boolean,
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[],
justUpdateMetadataLock?: boolean
justUpdateMetadataLock?: boolean,
legacyBehaviour?: boolean,
tree?: DoubleLinkedDependencyTree
): Promise<string | undefined> {
const remotePath = scriptPath
.substring(0, scriptPath.indexOf("."))
.replaceAll(SEP, "/");
// Detect folder layout: my_script__mod/script.ts
const isFolderLayout = isModuleEntryPoint(scriptPath);
// remotePath is the Windmill API path (e.g., "u/admin/my_script")
const remotePath = isFolderLayout
? getScriptBasePathFromModulePath(scriptPath)!.replaceAll(SEP, "/")
: scriptPath.substring(0, scriptPath.indexOf(".")).replaceAll(SEP, "/");
const language = inferContentTypeFromFilePath(scriptPath, opts.defaultTs);
// For folder layout, parseMetadataFile is called with remotePath which
// will find __mod/script.yaml via the folder layout fallback
const metadataWithType = await parseMetadataFile(
remotePath,
undefined,
@@ -208,19 +221,73 @@ export async function generateScriptMetadataInternal(
language
);
// Compute the module folder path early so we can include module hashes in stale check
const moduleFolderPath = isFolderLayout
? path.dirname(scriptPath)
: scriptPath.substring(0, scriptPath.indexOf(".")) + getModuleFolderSuffix();
// Note: rawWorkspaceDependencies are now passed in as parameter instead of being searched hierarchically
let hash = await generateScriptHash(filteredRawWorkspaceDependencies, scriptContent, metadataContent);
const hasModules = existsSync(moduleFolderPath) && statSync(moduleFolderPath).isDirectory();
if (await checkifMetadataUptodate(remotePath, hash, undefined)) {
if (!noStaleMessage) {
log.info(
colors.green(`Script ${remotePath} metadata is up-to-date, skipping`)
);
// In non-legacy mode, workspace deps are tracked via the tree — exclude from hash
const depsForHash = (!legacyBehaviour && tree) ? {} : filteredRawWorkspaceDependencies;
let hash = await generateScriptHash(depsForHash, scriptContent, metadataContent);
// Compute per-module hashes for stale detection (like flow inline scripts)
let moduleHashes: Record<string, string> = {};
if (hasModules) {
moduleHashes = await computeModuleHashes(
moduleFolderPath, opts.defaultTs, (!legacyBehaviour && tree) ? {} : rawWorkspaceDependencies, isFolderLayout
);
}
const hasModuleHashes = Object.keys(moduleHashes).length > 0;
// If modules exist, combine main script hash + module hashes into a meta-hash
let checkHash = hash;
let checkSubpath: string | undefined;
if (hasModuleHashes) {
const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b));
checkHash = await generateHash(hash + JSON.stringify(sortedEntries));
checkSubpath = SCRIPT_TOP_HASH;
}
const conf = await readLockfile();
// Use checkHash (includes module hashes) so module changes are detected as stale
const isDirectlyStale = !(await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath));
// New behaviour: tree-based dependency tracking
if (!legacyBehaviour && tree) {
if (dryRun) {
// First pass: populate tree with script and its imports
const imports = await extractRelativeImports(scriptContent, remotePath, language);
await tree.addNode(remotePath, scriptContent, language, metadataContent, imports, "script", remotePath, scriptPath, isDirectlyStale);
return;
}
// Second pass: proceed to generate (caller verified this script is stale via tree)
} else {
// Legacy behaviour: use existing staleness check
if (await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath)) {
if (!noStaleMessage) {
log.info(
colors.green(`Script ${remotePath} metadata is up-to-date, skipping`)
);
}
return;
} else if (dryRun) {
let detail = `${remotePath} (${language})`;
if (hasModuleHashes) {
const changed: string[] = [];
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
if (!(await checkifMetadataUptodate(remotePath, moduleHash, conf, modulePath))) {
changed.push(modulePath);
}
}
if (changed.length > 0) {
detail += ` [changed modules: ${changed.join(", ")}]`;
}
}
return detail;
}
return;
} else if (dryRun) {
return `${remotePath} (${language})`;
}
if (!justUpdateMetadataLock && !noStaleMessage) {
@@ -245,36 +312,97 @@ export async function generateScriptMetadataInternal(
const hasCodebase = findCodebase(scriptPath, codebases) != undefined;
if (!hasCodebase) {
const tempScriptRefs = tree?.getTempScriptRefs(remotePath);
const lockPathOverride = isFolderLayout
? path.dirname(scriptPath) + "/script.lock"
: undefined;
await updateScriptLock(
workspace,
scriptContent,
language,
remotePath,
metadataParsedContent,
filteredRawWorkspaceDependencies
filteredRawWorkspaceDependencies,
tempScriptRefs,
lockPathOverride,
);
} else {
metadataParsedContent.lock = "";
}
// Generate locks for modules in __mod/ folder
if (hasModules) {
// Identify which modules changed by comparing per-module hashes
let changedModules: string[] | undefined;
if (hasModuleHashes) {
changedModules = [];
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
if (!(await checkifMetadataUptodate(remotePath, moduleHash, conf, modulePath))) {
changedModules.push(modulePath);
}
}
if (changedModules.length === 0) {
changedModules = undefined; // no modules changed, skip lock regeneration
}
}
await updateModuleLocks(
workspace, moduleFolderPath, "", remotePath,
rawWorkspaceDependencies, opts.defaultTs, changedModules,
);
}
} else {
metadataParsedContent.lock =
"!inline " + remotePath.replaceAll(SEP, "/") + ".script.lock";
if (isFolderLayout) {
metadataParsedContent.lock =
"!inline " + remotePath.replaceAll(SEP, "/") + getModuleFolderSuffix() + "/script.lock";
} else {
metadataParsedContent.lock =
"!inline " + remotePath.replaceAll(SEP, "/") + ".script.lock";
}
}
let metaPath = remotePath + ".script.yaml";
let newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions);
if (metadataWithType.isJson) {
metaPath = remotePath + ".script.json";
newMetadataContent = JSON.stringify(metadataParsedContent);
// Write metadata back to the correct path
let metaPath: string;
let newMetadataContent: string;
if (isFolderLayout) {
if (metadataWithType.isJson) {
metaPath = path.dirname(scriptPath) + "/script.json";
newMetadataContent = JSON.stringify(metadataParsedContent);
} else {
metaPath = path.dirname(scriptPath) + "/script.yaml";
newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions);
}
} else {
if (metadataWithType.isJson) {
metaPath = remotePath + ".script.json";
newMetadataContent = JSON.stringify(metadataParsedContent);
} else {
metaPath = remotePath + ".script.yaml";
newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions);
}
}
const metadataContentUsedForHash = newMetadataContent;
hash = await generateScriptHash(
filteredRawWorkspaceDependencies,
depsForHash,
scriptContent,
metadataContentUsedForHash
);
await updateMetadataGlobalLock(remotePath, hash);
// Store hashes in wmill-lock.yaml
if (hasModuleHashes) {
// Use per-module hash tracking (like flow inline scripts)
const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b));
const metaHash = await generateHash(hash + JSON.stringify(sortedEntries));
await clearGlobalLock(remotePath);
await updateMetadataGlobalLock(remotePath, metaHash, SCRIPT_TOP_HASH);
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
await updateMetadataGlobalLock(remotePath, moduleHash, modulePath);
}
} else {
await updateMetadataGlobalLock(remotePath, hash);
}
if (!justUpdateMetadataLock) {
await writeFile(metaPath, newMetadataContent, "utf-8");
}
@@ -300,11 +428,9 @@ export async function updateScriptSchema(
} else {
delete metadataContent.has_preprocessor;
}
if (result.no_main_func) {
metadataContent.no_main_func = result.no_main_func;
} else {
delete metadataContent.no_main_func;
}
// auto_kind is intentionally not written to metadata — it is auto-detected
// by the parser at deploy time from script content.
delete metadataContent.auto_kind;
}
// ---------------------------------------------------------------------------
@@ -329,6 +455,7 @@ const LANG_ANNOTATION_CONFIG: Partial<
nativets: { comment: "//", keyword: "package_json" },
go: { comment: "//", keyword: "go_mod" },
php: { comment: "//", keyword: "composer_json" },
powershell: { comment: "#", keyword: "modules_json" },
};
export function extractWorkspaceDepsAnnotation(
@@ -410,6 +537,7 @@ export async function computeLockCacheKey(
scriptContent: string,
language: ScriptLanguage,
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>
): Promise<string> {
const annotation = extractWorkspaceDepsAnnotation(scriptContent, language);
const annotationStr = annotation
@@ -417,7 +545,10 @@ export async function computeLockCacheKey(
: "none";
const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort();
const depsStr = sortedDepsKeys.map((k) => `${k}=${rawWorkspaceDependencies[k]}`).join(";");
return await generateHash(`${language}|${annotationStr}|${depsStr}`);
const tempRefsStr = tempScriptRefs
? Object.keys(tempScriptRefs).sort().map((k) => `${k}=${tempScriptRefs[k]}`).join(";")
: "";
return await generateHash(`${language}|${annotationStr}|${depsStr}|${tempRefsStr}`);
}
const lockCache = new Map<string, string>();
@@ -432,13 +563,15 @@ async function fetchScriptLock(
language: ScriptLanguage,
remotePath: string,
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>
): Promise<string> {
const hasRawDeps = Object.keys(rawWorkspaceDependencies).length > 0;
const cacheKey = hasRawDeps
? await computeLockCacheKey(scriptContent, language, rawWorkspaceDependencies)
const hasTempRefs = tempScriptRefs && Object.keys(tempScriptRefs).length > 0;
const cacheKey = (hasRawDeps || hasTempRefs)
? await computeLockCacheKey(scriptContent, language, rawWorkspaceDependencies, tempScriptRefs)
: undefined;
if (cacheKey && lockCache.has(cacheKey)) {
log.info(`Using cached lockfile for ${remotePath}`);
log.debug(`Using cached lockfile for ${remotePath}`);
return lockCache.get(cacheKey)!;
}
@@ -463,6 +596,8 @@ async function fetchScriptLock(
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
? rawWorkspaceDependencies : null,
entrypoint: remotePath,
temp_script_refs: tempScriptRefs && Object.keys(tempScriptRefs).length > 0
? tempScriptRefs : null,
}),
}
);
@@ -502,11 +637,14 @@ async function updateScriptLock(
language: ScriptLanguage,
remotePath: string,
metadataContent: Record<string, any>,
rawWorkspaceDependencies: Record<string, string>
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>,
lockPathOverride?: string,
): Promise<void> {
if (
!(
workspaceDependenciesLanguages.some((l) => l.language == language) ||
(workspaceDependenciesLanguages.some((l) => l.language == language) &&
language !== "powershell") ||
language == "deno" ||
language == "rust" ||
language == "ansible"
@@ -518,7 +656,7 @@ async function updateScriptLock(
if (Object.keys(rawWorkspaceDependencies).length > 0) {
const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(', ');
log.info(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`);
log.debug(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`);
}
const lock = await fetchScriptLock(
@@ -527,9 +665,10 @@ async function updateScriptLock(
language,
remotePath,
rawWorkspaceDependencies,
tempScriptRefs
);
const lockPath = remotePath + ".script.lock";
const lockPath = lockPathOverride ?? remotePath + ".script.lock";
if (lock != "") {
await writeFile(lockPath, lock, "utf-8");
metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/");
@@ -545,6 +684,82 @@ async function updateScriptLock(
}
}
/**
* Generate locks for all module files in a __mod/ directory.
* Recursively walks the directory and generates a lock for each module
* whose language requires one.
*/
async function updateModuleLocks(
workspace: Workspace,
dirPath: string,
relPrefix: string,
scriptRemotePath: string,
rawWorkspaceDependencies: Record<string, string>,
defaultTs: "bun" | "deno" | undefined,
changedModules?: string[],
): Promise<void> {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name;
if (entry.isDirectory()) {
await updateModuleLocks(workspace, fullPath, relPath, scriptRemotePath, rawWorkspaceDependencies, defaultTs, changedModules);
} else if (entry.isFile()
&& !entry.name.endsWith(".lock")
// In folder layout, skip entry point files (script.{ext}, script.yaml, script.json, script.lock)
&& !(relPrefix === "" && entry.name.startsWith("script."))
) {
let modLanguage: ScriptLanguage;
try {
modLanguage = inferContentTypeFromFilePath(entry.name, defaultTs);
} catch {
continue; // skip files with unrecognized extensions
}
if (!languageNeedsLock(modLanguage)) continue;
// Skip unchanged modules when per-module hash tracking is active
if (changedModules) {
const normalizedRelPath = normalizeLockPath(relPath);
if (!changedModules.includes(normalizedRelPath)) continue;
}
const moduleContent = readFileSync(fullPath, "utf-8");
const moduleRemotePath = scriptRemotePath + "/" + relPath;
log.debug(`Generating lock for module ${relPath}`);
try {
const lock = await fetchScriptLock(
workspace,
moduleContent,
modLanguage,
moduleRemotePath,
rawWorkspaceDependencies,
);
const baseName = entry.name.replace(/\.[^.]+$/, '');
const lockPath = path.join(dirPath, baseName + ".lock");
if (lock != "") {
writeFileSync(lockPath, lock, "utf-8");
} else {
try {
if (existsSync(lockPath)) {
const { rm: rmAsync } = await import("node:fs/promises");
await rmAsync(lockPath);
}
} catch {
// ignore
}
}
} catch (e) {
log.info(colors.yellow(`Failed to generate lock for module ${relPath}: ${e}`));
}
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////
// below functions copied from Windmill's FE inferArgs function. TODO: refactor //
////////////////////////////////////////////////////////////////////////////////////////////
@@ -556,7 +771,7 @@ export async function inferSchema(
): Promise<{
schema: any;
has_preprocessor: boolean | undefined;
no_main_func: boolean | undefined;
auto_kind: string | undefined;
}> {
let inferedSchema: any;
if (language === "python3") {
@@ -666,7 +881,7 @@ export async function inferSchema(
return {
schema: defaultScriptMetadata().schema,
has_preprocessor: false,
no_main_func: false,
auto_kind: undefined,
};
}
@@ -691,6 +906,11 @@ export async function inferSchema(
argSigToJsonSchemaType(arg.typ, currentSchema.properties[arg.name]);
// For T | T[] detection for debouncing arg accumulation
if ((arg as any).otyp && (arg as any).otyp.includes('[') && (arg as any).otyp.includes('|')) {
currentSchema.properties[arg.name].originalType = (arg as any).otyp
}
currentSchema.properties[arg.name].default = arg.default;
if (!arg.has_default && !currentSchema.required.includes(arg.name)) {
@@ -701,7 +921,7 @@ export async function inferSchema(
return {
schema: currentSchema,
has_preprocessor: inferedSchema.has_preprocessor,
no_main_func: inferedSchema.no_main_func,
auto_kind: inferedSchema.auto_kind,
};
}
@@ -737,6 +957,38 @@ export function replaceLock(o?: { lock?: string | string[] }) {
}
}
}
export async function parseMetadataFileIfExists(
scriptPath: string
): Promise<{ isJson: boolean; payload: any; path: string } | undefined> {
let metadataFilePath = scriptPath + ".script.json";
try {
await stat(metadataFilePath);
const payload = JSON.parse(await readFile(metadataFilePath, "utf-8"));
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: true,
};
} catch {
try {
metadataFilePath = scriptPath + ".script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
return undefined;
}
}
}
export async function parseMetadataFile(
scriptPath: string,
generateMetadataIfMissing:
@@ -770,62 +1022,87 @@ export async function parseMetadataFile(
isJson: false,
};
} catch {
// no metadata file at all. Create it
log.info(
(await blueColor())(
`Creating script metadata file for ${metadataFilePath}`
)
);
metadataFilePath = scriptPath + ".script.yaml";
let scriptInitialMetadata = defaultScriptMetadata();
const lockPath = scriptPath + ".script.lock";
scriptInitialMetadata.lock = "!inline " + lockPath;
const scriptInitialMetadataYaml = yamlStringify(
scriptInitialMetadata as Record<string, any>,
yamlOptions
);
await writeFile(metadataFilePath, scriptInitialMetadataYaml, { flag: "wx", encoding: "utf-8" });
await writeFile(lockPath, "", { flag: "wx", encoding: "utf-8" });
if (generateMetadataIfMissing) {
log.info(
(await blueColor())(
`Generating lockfile and schema for ${metadataFilePath}`
)
);
// Try folder layout: {scriptPath}__mod/script.yaml or .json
const moduleFolderMeta = scriptPath + getModuleFolderSuffix();
try {
metadataFilePath = moduleFolderMeta + "/script.json";
await stat(metadataFilePath);
return {
path: metadataFilePath,
payload: JSON.parse(await readFile(metadataFilePath, "utf-8")),
isJson: true,
};
} catch {
try {
await generateScriptMetadataInternal(
generateMetadataIfMissing.path,
generateMetadataIfMissing.workspaceRemote,
generateMetadataIfMissing,
false,
false,
generateMetadataIfMissing.rawWorkspaceDependencies,
generateMetadataIfMissing.codebases,
false
);
scriptInitialMetadata = (await yamlParseFile(
metadataFilePath
)) as ScriptMetadata;
if (!generateMetadataIfMissing.schemaOnly) {
replaceLock(scriptInitialMetadata);
}
} catch (e) {
log.info(
colors.yellow(
`Failed to generate lockfile and schema for ${metadataFilePath}: ${e}`
)
);
metadataFilePath = moduleFolderMeta + "/script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
// fall through to create metadata
}
}
return {
path: metadataFilePath,
payload: scriptInitialMetadata,
isJson: false,
};
}
}
// no metadata file at all. Create it
log.info(
(await blueColor())(
`Creating script metadata file for ${metadataFilePath}`
)
);
metadataFilePath = scriptPath + ".script.yaml";
let scriptInitialMetadata = defaultScriptMetadata();
const lockPath = scriptPath + ".script.lock";
scriptInitialMetadata.lock = "!inline " + lockPath;
const scriptInitialMetadataYaml = yamlStringify(
scriptInitialMetadata as Record<string, any>,
yamlOptions
);
await writeFile(metadataFilePath, scriptInitialMetadataYaml, { flag: "wx", encoding: "utf-8" });
await writeFile(lockPath, "", { flag: "wx", encoding: "utf-8" });
if (generateMetadataIfMissing) {
log.info(
(await blueColor())(
`Generating lockfile and schema for ${metadataFilePath}`
)
);
try {
await generateScriptMetadataInternal(
generateMetadataIfMissing.path,
generateMetadataIfMissing.workspaceRemote,
generateMetadataIfMissing,
false,
false,
generateMetadataIfMissing.rawWorkspaceDependencies,
generateMetadataIfMissing.codebases,
false
);
scriptInitialMetadata = (await yamlParseFile(
metadataFilePath
)) as ScriptMetadata;
if (!generateMetadataIfMissing.schemaOnly) {
replaceLock(scriptInitialMetadata);
}
} catch (e) {
log.info(
colors.yellow(
`Failed to generate lockfile and schema for ${metadataFilePath}: ${e}`
)
);
}
}
return {
path: metadataFilePath,
payload: scriptInitialMetadata,
isJson: false,
};
}
interface Lock {
@@ -834,6 +1111,7 @@ interface Lock {
}
const WMILL_LOCKFILE = "wmill-lock.yaml";
const SCRIPT_TOP_HASH = "__script_hash";
/**
* Normalizes a path to use Linux separators (forward slashes).
@@ -902,6 +1180,46 @@ export async function generateScriptHash(
);
}
async function computeModuleHashes(
moduleFolderPath: string,
defaultTs: "bun" | "deno" | undefined,
rawWorkspaceDependencies: Record<string, string>,
isFolderLayout: boolean,
): Promise<Record<string, string>> {
const hashes: Record<string, string> = {};
async function readDir(dirPath: string, relPrefix: string) {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name;
const isTopLevel = relPrefix === "";
if (entry.isDirectory()) {
await readDir(fullPath, relPath);
} else if (
entry.isFile() &&
!entry.name.endsWith(".lock") &&
!(isFolderLayout && isTopLevel && entry.name.startsWith("script."))
) {
try {
inferContentTypeFromFilePath(entry.name, defaultTs);
} catch {
continue;
}
const content = readFileSync(fullPath, "utf-8");
const normalizedPath = normalizeLockPath(relPath);
hashes[normalizedPath] = await generateHash(
content + JSON.stringify(rawWorkspaceDependencies)
);
}
}
}
await readDir(moduleFolderPath, "");
return hashes;
}
export async function clearGlobalLock(path: string): Promise<void> {
const conf = await readLockfile();
if (!conf?.locks) {
+39
View File
@@ -0,0 +1,39 @@
/**
* Relative Imports Utilities for CLI
*
* Provides functions to parse relative imports from TypeScript/Python scripts using WASM.
*/
import { ScriptLanguage } from "./script_common.ts";
import { loadParser } from "./metadata.ts";
import * as log from "../core/log.ts";
/**
* Extract relative imports from script content based on language.
* Returns resolved absolute Windmill paths (e.g., "f/folder/helper").
*/
export async function extractRelativeImports(
code: string,
scriptPath: string,
language: ScriptLanguage
): Promise<string[]> {
try {
switch (language) {
case "bun":
case "nativets":
case "deno": {
const { parse_ts_relative_imports } = await loadParser("windmill-parser-wasm-ts");
return parse_ts_relative_imports(code, scriptPath);
}
case "python3": {
const { parse_py_relative_imports } = await loadParser("windmill-parser-wasm-py-imports");
return parse_py_relative_imports(code, scriptPath);
}
default:
return [];
}
} catch (e) {
log.warn(`Failed to parse relative imports for ${scriptPath}: ${e}. Dependency tracking for relative imports will be disabled.`);
return [];
}
}
+80
View File
@@ -189,6 +189,26 @@ export function isFolderResourcePath(p: string): boolean {
return isFlowPath(p) || isAppPath(p) || isRawAppPath(p);
}
/**
* Check if a path is inside a folder-based resource, checking BOTH dotted (.flow, .app, .raw_app)
* and non-dotted (__flow, __app, __raw_app) formats regardless of the global nonDottedPaths setting.
* Use this instead of isFolderResourcePath when the config may not yet be loaded or when
* you need to handle mixed-format workspaces (e.g. generate-metadata scanning all files).
*/
export function isFolderResourcePathAnyFormat(p: string): boolean {
const n = normalizeSep(p);
for (const suffixes of [DOTTED_SUFFIXES, NON_DOTTED_SUFFIXES]) {
if (
n.includes(suffixes.flow + "/") ||
n.includes(suffixes.app + "/") ||
n.includes(suffixes.raw_app + "/")
) {
return true;
}
}
return false;
}
/**
* Detect the resource type from a path, if any
*/
@@ -433,6 +453,66 @@ export function isRawAppFolderMetadataFile(p: string): boolean {
);
}
// ============================================================================
// Script Module Path Functions
// ============================================================================
/**
* The suffix used for script module folders.
* Unlike flows/apps, modules always use `__mod` (never dotted `.mod`)
* to avoid confusion with file extensions.
*/
const MODULE_SUFFIX = "__mod";
/**
* Get the module folder suffix (always "__mod")
*/
export function getModuleFolderSuffix(): string {
return MODULE_SUFFIX;
}
/**
* Check if a path is inside a script module folder.
* Matches patterns like: .../my_script__mod/...
*/
export function isScriptModulePath(p: string): boolean {
return normalizeSep(p).includes(MODULE_SUFFIX + "/");
}
/**
* Build the module folder path from a script's base path (without extension).
* e.g., "f/my_script" -> "f/my_script__mod"
*/
export function buildModuleFolderPath(scriptBasePath: string): string {
return scriptBasePath + MODULE_SUFFIX;
}
/**
* Check if a file inside a __mod/ folder is the main entry point (script.{ext}).
* Entry points are files named "script.*" directly under __mod/ (not in subdirs).
*/
export function isModuleEntryPoint(p: string): boolean {
const norm = normalizeSep(p);
const suffix = MODULE_SUFFIX + "/";
const idx = norm.indexOf(suffix);
if (idx === -1) return false;
const rest = norm.slice(idx + suffix.length);
return rest.startsWith("script.") && !rest.includes("/");
}
/**
* Extract the script base path from a module folder entry.
* e.g., "u/admin/my_script__mod/script.ts" -> "u/admin/my_script"
* e.g., "u/admin/my_script__mod/helper.ts" -> "u/admin/my_script"
*/
export function getScriptBasePathFromModulePath(p: string): string | undefined {
const norm = normalizeSep(p);
const suffix = MODULE_SUFFIX + "/";
const idx = norm.indexOf(suffix);
if (idx === -1) return undefined;
return norm.slice(0, idx);
}
// ============================================================================
// Sync-related Path Functions
// ============================================================================
+5 -2
View File
@@ -30,13 +30,15 @@ export type WorkspaceDependenciesLanguage =
| { language: "bun", filename /** (raw requirements filename) */: "package.json" }
| { language: "python3", filename: "requirements.in" }
| { language: "php", filename: "composer.json" }
| { language: "go", filename: "go.mod" };
| { language: "go", filename: "go.mod" }
| { language: "powershell", filename: "modules.json" };
export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [
{ language: "bun", filename: "package.json" },
{ language: "python3", filename: "requirements.in" },
{ language: "php", filename: "composer.json" },
{ language: "go", filename: "go.mod" },
{ language: "powershell", filename: "modules.json" },
] as const;
/**
@@ -45,7 +47,8 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [
*/
export function languageNeedsLock(language: ScriptLanguage | string): boolean {
return (
workspaceDependenciesLanguages.some((l) => l.language === language) ||
(workspaceDependenciesLanguages.some((l) => l.language === language) &&
language !== "powershell") ||
language === "deno" ||
language === "rust" ||
language === "ansible"