mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +00:00
fb82748296
* fix: make on_behalf_of control permissions for scripts and flows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: inherit the recorded on-behalf-of identity when a preserving deploy omits it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep an omitted permissioned_as from re-versioning an unchanged script Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: derive the on-behalf-of principal from the email and reject mismatched pairs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop workspace deploys from carrying a source-workspace principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the onBehalfOfPermissionedAs param doc Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin that workspace deploys never carry a source-workspace principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the omitted-principal contract and refresh generated prompts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep external-superadmin principals on email-only redeploys Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope the recorded principal to its workspace and prefer real accounts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: carry the recorded principal correctly through drafts and set-permissioned-as Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: sweep draft identity pairs on email change and offboarding Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: leave group identities alone when sweeping a user's email Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: treat only g/ without an email as a group, and match the offboard preview Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop the group guard from skipping rows with no recorded principal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state the group guard once instead of restating it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: make the permissioned_as the only stored on-behalf-of identity Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: skip resolving the on-behalf-of address for sync clients that discard it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address the local review of the identity refactor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve the on-behalf-of identity coherently across clones, offboarding and no-op deploys * test: pin that a fork keeps only the on-behalf-of identities that resolve in it * fix: decide a principal prefix-first everywhere and canonicalize bare addresses * fix: prefix a slash-containing address so a reader cannot take it for a group * fix: read an address as a username before the group- convention * fix: rewrite the canonical principal when an account's address moves * fix: keep the address form of a principal to accounts without a usr row * fix: reject an identity a job row cannot carry and read it uncached at dispatch * fix: count characters against the job identity width and cap the backfill * refactor: name the script/flow principal on_behalf_of, as apps do * docs: state the caller-must-authorize contract on the identity resolvers * fix: keep writing on_behalf_of_email until every worker reads the principal * fix: err high on the compatibility version and document the last resolver * fix: keep the compatibility address current through identity mutations * fix: carry the compatibility address with the principal on every copy path * chore: re-pin the EE ref to the companion branch merged with EE main * fix: key the dbt retry lookup on the stored principal * fix: keep a mixed-version address recoverable through a fork * fix: read a round-tripped address uncached so a redeploy is not rejected * fix: refuse an email change that would make a principal unenqueueable * chore: update ee-repo-ref to ac3d7d015296f041ae44ab6bc4953485f44d36e4 This commit updates the EE repository reference after PR #704 was merged in windmill-ee-private. Previous ee-repo-ref: 219b0b03905a1a0028054b3a4985724e77d09036 New ee-repo-ref: ac3d7d015296f041ae44ab6bc4953485f44d36e4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2252 lines
74 KiB
TypeScript
2252 lines
74 KiB
TypeScript
import { GlobalOptions } from "../../types.ts";
|
|
import { requireLogin } from "../../core/auth.ts";
|
|
import { resolveWorkspace, validatePath } from "../../core/context.ts";
|
|
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
|
|
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
|
|
import { writeFile, stat, mkdir } from "node:fs/promises";
|
|
import { Buffer } from "node:buffer";
|
|
import { colors } from "@cliffy/ansi/colors";
|
|
import { Command } from "@cliffy/command";
|
|
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, getHeaders, readTextFile, readTextFileSync } from "../../utils/utils.ts";
|
|
import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts";
|
|
import * as wmill from "../../../gen/services.gen.ts";
|
|
import * as specificItems from "../../core/specific_items.ts";
|
|
import { getCurrentGitBranch } from "../../utils/git.ts";
|
|
|
|
import {
|
|
defaultScriptMetadata,
|
|
scriptBootstrapCode,
|
|
} from "../../../bootstrap/script_bootstrap.ts";
|
|
|
|
import { Workspace } from "../workspace/workspace.ts";
|
|
import {
|
|
checkifMetadataUptodate,
|
|
generateScriptHash,
|
|
generateScriptMetadataInternal,
|
|
getRawWorkspaceDependencies,
|
|
parseMetadataFile,
|
|
readLockfile,
|
|
} from "../../utils/metadata.ts";
|
|
import { validateRequiredArgs } from "../../utils/utils.ts";
|
|
import {
|
|
WorkspaceDependenciesLanguage,
|
|
ScriptLanguage,
|
|
inferContentTypeFromFilePath,
|
|
workspaceDependenciesLanguages,
|
|
} from "../../utils/script_common.ts";
|
|
import {
|
|
elementsToMap,
|
|
findCodebase,
|
|
readDirRecursiveWithIgnore,
|
|
Skips,
|
|
yamlOptions,
|
|
} from "../sync/sync.ts";
|
|
import { ignoreF } from "../sync/sync.ts";
|
|
import { FSFSElement } from "../sync/sync.ts";
|
|
import {
|
|
SyncOptions,
|
|
mergeConfigWithConfigFile,
|
|
readConfigFile,
|
|
} from "../../core/conf.ts";
|
|
import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts";
|
|
import { pollJobWithQueueLogging } from "../../utils/job_polling.ts";
|
|
import fs from "node:fs";
|
|
import { createTarBlob, type TarEntry } from "../../utils/tar.ts";
|
|
import { getEsbuild } from "../../utils/esbuild_loader.ts";
|
|
|
|
import { execSync } from "node:child_process";
|
|
import { NewScript, Script, ScriptModule } from "../../../gen/types.gen.ts";
|
|
import {
|
|
isRawAppBackendPath as isRawAppBackendPathInternal,
|
|
isAppInlineScriptPath as isAppInlineScriptPathInternal,
|
|
isFlowInlineScriptPath as isFlowInlineScriptPathInternal,
|
|
isFlowPath,
|
|
isAppPath,
|
|
isScriptModulePath,
|
|
buildModuleFolderPath,
|
|
getModuleFolderSuffix,
|
|
dbtGeneratedDirs,
|
|
isUnderGeneratedDir,
|
|
isLocalSecretFile,
|
|
moduleFileExclusion,
|
|
oversizedModuleFileError,
|
|
MAX_MODULE_BYTES,
|
|
isModuleEntryPoint,
|
|
getScriptBasePathFromModulePath,
|
|
scriptPathToRemotePath,
|
|
isRawAppPath,
|
|
DBT_DESCRIPTOR_NAME,
|
|
isDbtDescriptorPath,
|
|
isMissingDbtDescriptor,
|
|
} from "../../utils/resource_folders.ts";
|
|
|
|
export interface ScriptFile {
|
|
parent_hash?: string;
|
|
summary: string;
|
|
description: string;
|
|
schema?: any;
|
|
is_template?: boolean;
|
|
lock?: Array<string>;
|
|
kind?: "script" | "failure" | "trigger" | "command" | "approval";
|
|
// Mirrors granular ACLs on the script path. Omitted from .script.yaml when
|
|
// no perms are set. The CLI applies diffs through /acls/add and /acls/remove
|
|
// (see applyExtraPermsDiff) — never through create_script — so a perm-only
|
|
// change never bumps the script hash/version.
|
|
extra_perms?: Record<string, boolean>;
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is inside a raw app backend folder.
|
|
* Matches patterns like: .../myApp.raw_app/backend/...
|
|
*/
|
|
export function isRawAppBackendPath(filePath: string): boolean {
|
|
return isRawAppBackendPathInternal(filePath);
|
|
}
|
|
|
|
/**
|
|
* The positive-only runnable settings (concurrent_limit, timeout, ...) treat any `<= 0`
|
|
* value as "unset": the backend coerces it to null (a 0-slot concurrency limit bricks the
|
|
* runnable, a 0s timeout kills every run). Coerce to undefined so it is serialized as
|
|
* omitted, never as 0, and redeploys don't churn against the backend-normalized value.
|
|
*/
|
|
export function nonePositiveInt(
|
|
v: number | undefined | null
|
|
): number | undefined {
|
|
return v != null && v > 0 ? v : undefined;
|
|
}
|
|
|
|
/**
|
|
* Normalize a concurrent_limit + its time window together: when the limit is disabled
|
|
* (<= 0) the window is dropped too. Returns [concurrent_limit, concurrency_time_window_s].
|
|
*/
|
|
export function normalizeConcurrency(
|
|
concurrentLimit: number | undefined | null,
|
|
concurrencyTimeWindowS?: number | undefined | null
|
|
): [number | undefined, number | undefined] {
|
|
const limit = nonePositiveInt(concurrentLimit);
|
|
return limit === undefined ? [undefined, undefined] : [limit, concurrencyTimeWindowS ?? undefined];
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is inside a normal app folder (inline script).
|
|
* Matches patterns like: .../myApp.app/... or .../myApp__app/...
|
|
*/
|
|
export function isAppInlineScriptPath(filePath: string): boolean {
|
|
return isAppInlineScriptPathInternal(filePath);
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is inside a flow folder (inline script).
|
|
* Matches patterns like: .../myFlow.flow/... or .../myFlow__flow/...
|
|
*/
|
|
export function isFlowInlineScriptPath(filePath: string): boolean {
|
|
return isFlowInlineScriptPathInternal(filePath);
|
|
}
|
|
|
|
type PushOptions = GlobalOptions & { message?: string };
|
|
export async function computePushMetadataHash(
|
|
filePath: string,
|
|
content: string
|
|
): Promise<string> {
|
|
const remotePath = removeExtensionToPath(filePath).replaceAll(SEP, "/");
|
|
const metadataWithType = await parseMetadataFile(remotePath, undefined);
|
|
const metadataContent = await readTextFile(metadataWithType.path);
|
|
return await generateScriptHash({}, content, metadataContent);
|
|
}
|
|
|
|
async function push(opts: PushOptions, filePath: string) {
|
|
opts = await mergeConfigWithConfigFile(opts);
|
|
const workspace = await resolveWorkspace(opts);
|
|
|
|
if (!validatePath(filePath)) {
|
|
return;
|
|
}
|
|
|
|
// A dbt project's descriptor is optional, so the one content path a
|
|
// descriptor-less project has is deliberately not on disk. The project beside
|
|
// it is what says the script is real.
|
|
const absentDescriptor = await stat(filePath).then(
|
|
() => false,
|
|
(e) => isMissingDbtDescriptor(filePath, e)
|
|
);
|
|
if (!absentDescriptor) {
|
|
const fstat = await stat(filePath);
|
|
if (!fstat.isFile()) {
|
|
throw new Error("file path must refer to a file.");
|
|
}
|
|
}
|
|
|
|
if (filePath.endsWith(".script.json") || filePath.endsWith(".script.yaml")) {
|
|
throw Error(
|
|
"Cannot push a script metadata file, point to the script content file instead (.py, .ts, .go|.sh)"
|
|
);
|
|
}
|
|
|
|
await requireLogin(opts);
|
|
|
|
// Warn about metadata state before pushing
|
|
try {
|
|
const content = await readScriptContent(filePath);
|
|
const remotePath = removeExtensionToPath(filePath).replaceAll(SEP, "/");
|
|
const contentHash = await computePushMetadataHash(filePath, content);
|
|
const conf = await readLockfile();
|
|
const hasLockEntry = conf.locks && (conf.locks[remotePath] !== undefined || conf.locks[`${remotePath}.ts`] !== undefined);
|
|
if (!hasLockEntry) {
|
|
log.warn(colors.yellow(
|
|
`No metadata generated yet for ${filePath}. Run 'wmill generate-metadata' to generate schema and lock.`
|
|
));
|
|
} else if (!(await checkifMetadataUptodate(remotePath, contentHash, conf))) {
|
|
log.warn(colors.yellow(
|
|
`Metadata for ${filePath} appears stale (content changed since last 'wmill generate-metadata').\n` +
|
|
`The schema and lock may not match the current code. Consider running 'wmill generate-metadata' first.`
|
|
));
|
|
}
|
|
} catch {
|
|
// Don't block push if check fails
|
|
}
|
|
|
|
const codebases = await listSyncCodebases(opts as SyncOptions);
|
|
|
|
await handleFile(
|
|
filePath,
|
|
workspace,
|
|
[],
|
|
opts.message,
|
|
opts,
|
|
await getRawWorkspaceDependencies(true),
|
|
codebases
|
|
);
|
|
log.info(colors.bold.underline.green(`Script ${filePath} pushed`));
|
|
}
|
|
|
|
export async function findResourceFile(path: string) {
|
|
const splitPath = path.split(".");
|
|
|
|
let contentBasePathJSON = splitPath[0] + "." + splitPath[1] + ".json";
|
|
let contentBasePathYAML = splitPath[0] + "." + splitPath[1] + ".yaml";
|
|
|
|
// Check for workspace-specific metadata files first, using the wmill.yaml
|
|
// config key for the current git branch as the filename suffix (falls back
|
|
// to the branch name when no matching workspace entry exists).
|
|
const currentBranch = getCurrentGitBranch();
|
|
const wsName = currentBranch
|
|
? await specificItems.resolveWsNameForGitBranch(currentBranch)
|
|
: null;
|
|
|
|
const candidates = [contentBasePathJSON, contentBasePathYAML];
|
|
|
|
if (wsName) {
|
|
// Add workspace-specific candidates at the beginning (higher priority)
|
|
const branchSpecificJSON = specificItems.toWorkspaceSpecificPath(
|
|
contentBasePathJSON,
|
|
wsName
|
|
);
|
|
const branchSpecificYAML = specificItems.toWorkspaceSpecificPath(
|
|
contentBasePathYAML,
|
|
wsName
|
|
);
|
|
candidates.unshift(branchSpecificJSON, branchSpecificYAML);
|
|
}
|
|
|
|
const validCandidates = (
|
|
await Promise.all(
|
|
candidates.map((x) => {
|
|
return stat(x)
|
|
.catch(() => undefined)
|
|
.then((x) => x?.isFile())
|
|
.then((e) => {
|
|
return { path: x, file: e };
|
|
});
|
|
})
|
|
)
|
|
)
|
|
.filter((x) => x.file)
|
|
.map((x) => x.path);
|
|
if (validCandidates.length > 1) {
|
|
throw new Error(
|
|
"Found two resource files for the same resource" +
|
|
validCandidates.join(", ")
|
|
);
|
|
}
|
|
if (validCandidates.length < 1) {
|
|
throw new Error(`No resource matching file resource: ${path}.`);
|
|
}
|
|
return validCandidates[0];
|
|
}
|
|
|
|
// The separator is whatever the local filesystem uses, so both are accepted:
|
|
// on Windows these paths reach us as `my_script__mod\script.yaml`.
|
|
const MODULE_ENTRY_META_RE = /([\\/])script\.(yaml|json|lock)$/;
|
|
|
|
/**
|
|
* Whether a path is a module folder's own metadata file (`__mod/script.yaml`).
|
|
* `isModuleEntryPoint` already pins the file to `script.*` directly under
|
|
* `__mod/`, so this only narrows it to the metadata extensions: a `script.yaml`
|
|
* nested deeper in the module tree is a module file, not the script's metadata.
|
|
*/
|
|
export function isModuleEntryMetadata(p: string): boolean {
|
|
return isModuleEntryPoint(p) && MODULE_ENTRY_META_RE.test(p);
|
|
}
|
|
|
|
export async function handleScriptMetadata(
|
|
path: string,
|
|
workspace: Workspace,
|
|
alreadySynced: string[],
|
|
message: string | undefined,
|
|
rawWorkspaceDependencies: Record<string, string>,
|
|
codebases: SyncCodebase[],
|
|
opts: GlobalOptions,
|
|
permissionedAsContext?: PermissionedAsContext
|
|
): Promise<boolean> {
|
|
// Flat layout: my_script.script.yaml
|
|
const isFlatMeta = path.endsWith(".script.json") ||
|
|
path.endsWith(".script.yaml") ||
|
|
path.endsWith(".script.lock");
|
|
const isFolderMeta = !isFlatMeta && isModuleEntryMetadata(path);
|
|
if (isFlatMeta || isFolderMeta) {
|
|
const contentPath = await findContentFile(path);
|
|
return handleFile(
|
|
contentPath,
|
|
workspace,
|
|
alreadySynced,
|
|
message,
|
|
opts,
|
|
rawWorkspaceDependencies,
|
|
codebases,
|
|
permissionedAsContext
|
|
);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export interface OutputFile {
|
|
path: string;
|
|
contents: Uint8Array;
|
|
hash: string;
|
|
/** "contents" as text (changes automatically with "contents") */
|
|
readonly text: string;
|
|
}
|
|
|
|
export async function handleFile(
|
|
path: string,
|
|
workspace: Workspace,
|
|
alreadySynced: string[],
|
|
message: string | undefined,
|
|
opts: (GlobalOptions & { defaultTs?: "bun" | "deno" } & Skips) | undefined,
|
|
rawWorkspaceDependencies: Record<string, string>,
|
|
codebases: SyncCodebase[],
|
|
permissionedAsContext?: PermissionedAsContext
|
|
): Promise<boolean> {
|
|
// Detect module entry point: e.g., my_script__mod/script.ts
|
|
const moduleEntryPoint = isModuleEntryPoint(path);
|
|
if (
|
|
!isAppInlineScriptPath(path) &&
|
|
!isFlowInlineScriptPath(path) &&
|
|
// Raw-app files (frontend included) belong to the app bundle, never
|
|
// standalone scripts — pushed via pushRawApp, not here.
|
|
!isRawAppPath(path) &&
|
|
(!isScriptModulePath(path) || moduleEntryPoint) &&
|
|
hasScriptExt(path)
|
|
) {
|
|
if (alreadySynced.includes(path)) {
|
|
return true;
|
|
}
|
|
log.debug(`Processing local script ${path}`);
|
|
|
|
alreadySynced.push(path);
|
|
const remotePath = scriptPathToRemotePath(path);
|
|
|
|
// Before anything is written: `<base>.py` and `<base>__dbt/` deploy to ONE
|
|
// remote path, so whichever is pushed last replaces the other's script.
|
|
// Refused from either side — the descriptor is exempt only from finding its
|
|
// OWN project (it is that project's content file, so its base resolves to
|
|
// the same `dbt_project.yml`), never from an ordinary sibling.
|
|
// A folder-layout script is `<base>__mod/script.ts`, so stripping its
|
|
// extension yields `<base>__mod/script`, not the base both layouts deploy
|
|
// to. Wrong base, and the probe below looks in a directory that cannot
|
|
// exist — which is how a `__mod` script and a dbt project at one path were
|
|
// both pushed, each replacing the other.
|
|
const base = isScriptModulePath(path)
|
|
? getScriptBasePathFromModulePath(path) ?? removeExtensionToPath(path)
|
|
: removeExtensionToPath(path);
|
|
const isDescriptor = isDbtDescriptorPath(path);
|
|
const other = isDescriptor
|
|
? await collidingOrdinaryScript(base)
|
|
: await collidingDbtProject(base);
|
|
if (other) {
|
|
throw isDescriptor
|
|
? dbtPathCollisionError(path, other)
|
|
: dbtPathCollisionError(other, path);
|
|
}
|
|
|
|
const language = inferContentTypeFromFilePath(path, opts?.defaultTs);
|
|
|
|
const codebase =
|
|
language == "bun" ? findCodebase(path, codebases) : undefined;
|
|
|
|
let bundleContent: string | Blob | undefined = undefined;
|
|
|
|
let forceTar = false;
|
|
if (codebase) {
|
|
let outputFiles: OutputFile[] = [];
|
|
if (codebase.customBundler) {
|
|
log.info(`Using custom bundler ${codebase.customBundler} for ${path}`);
|
|
bundleContent = execSync(codebase.customBundler + " " + path, {
|
|
maxBuffer: 1024 * 1024 * 50,
|
|
}).toString();
|
|
log.info("Custom bundler executed for " + path);
|
|
} else {
|
|
const esbuild = await getEsbuild();
|
|
|
|
log.info(`Started bundling ${path} ...`);
|
|
const startTime = performance.now();
|
|
const format = codebase.format ?? "cjs";
|
|
const out = await esbuild.build({
|
|
entryPoints: [path],
|
|
format: format,
|
|
bundle: true,
|
|
write: false,
|
|
external: codebase.external,
|
|
inject: codebase.inject,
|
|
define: codebase.define,
|
|
loader: codebase.loader ?? { ".node": "file" },
|
|
outdir: "/",
|
|
platform: "node",
|
|
packages: "bundle",
|
|
target: format == "cjs" ? "node20.15.1" : "esnext",
|
|
banner: codebase.banner,
|
|
// ...(codebase.banner != null && { banner: codebase.banner }),
|
|
});
|
|
const endTime = performance.now();
|
|
bundleContent = out.outputFiles[0].text;
|
|
outputFiles = out.outputFiles ?? [];
|
|
if (outputFiles.length == 0) {
|
|
throw new Error(`No output files found for ${path}`);
|
|
}
|
|
log.info(
|
|
`Finished bundling ${path}: ${(bundleContent.length / 1024).toFixed(
|
|
0
|
|
)}kB (${(endTime - startTime).toFixed(0)}ms)`
|
|
);
|
|
}
|
|
if (outputFiles.length > 1) {
|
|
log.info(
|
|
`Found multiple output files for ${path}, creating a tarball... ${outputFiles
|
|
.map((file) => file.path)
|
|
.join(", ")}`
|
|
);
|
|
forceTar = true;
|
|
const startTime = performance.now();
|
|
const mainPath = path.split(SEP).pop()?.split(".")[0] + ".js";
|
|
const mainContent =
|
|
outputFiles.find((file) => file.path == "/" + mainPath)?.text ?? "";
|
|
log.info(`Main content: ${mainContent.length}chars`);
|
|
const entries: TarEntry[] = [
|
|
{ name: "main.js", content: mainContent },
|
|
];
|
|
for (const file of outputFiles) {
|
|
if (file.path == "/" + mainPath) {
|
|
continue;
|
|
}
|
|
log.info(`Adding file: ${file.path.substring(1)}`);
|
|
entries.push({ name: file.path.substring(1), content: file.contents });
|
|
}
|
|
bundleContent = await createTarBlob(entries);
|
|
const endTime = performance.now();
|
|
log.info(
|
|
`Finished creating tarball for ${path}: ${(
|
|
bundleContent.size / 1024
|
|
).toFixed(0)}kB (${(endTime - startTime).toFixed(0)}ms)`
|
|
);
|
|
} else {
|
|
if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
|
|
log.info(
|
|
`Using the following asset configuration for ${path}: ${JSON.stringify(
|
|
codebase.assets
|
|
)}`
|
|
);
|
|
const startTime = performance.now();
|
|
const entries: TarEntry[] = [
|
|
{ name: "main.js", content: bundleContent },
|
|
];
|
|
for (const asset of codebase.assets) {
|
|
const data = fs.readFileSync(asset.from);
|
|
entries.push({ name: asset.to, content: data });
|
|
}
|
|
bundleContent = await createTarBlob(entries);
|
|
const endTime = performance.now();
|
|
log.info(
|
|
`Finished creating tarball for ${path}: ${(
|
|
bundleContent.size / 1024
|
|
).toFixed(0)}kB (${(endTime - startTime).toFixed(0)}ms)`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let typed = opts?.skipScriptsMetadata
|
|
? undefined
|
|
: (
|
|
await parseMetadataFile(
|
|
remotePath,
|
|
opts
|
|
? {
|
|
...opts,
|
|
path,
|
|
workspaceRemote: workspace,
|
|
schemaOnly: codebase ? true : undefined,
|
|
rawWorkspaceDependencies,
|
|
codebases,
|
|
}
|
|
: undefined
|
|
)
|
|
)?.payload;
|
|
|
|
const workspaceId = workspace.workspaceId;
|
|
|
|
let remote = undefined;
|
|
try {
|
|
remote = await wmill.getScriptByPath({
|
|
workspace: workspaceId,
|
|
path: remotePath,
|
|
});
|
|
log.debug(`Script ${remotePath} exists on remote`);
|
|
} catch {
|
|
log.debug(`Script ${remotePath} does not exist on remote`);
|
|
}
|
|
const content = await readScriptContent(path);
|
|
|
|
if (opts?.skipScriptsMetadata) {
|
|
// if (codebase) {
|
|
// const typedBefore = JSON.parse(JSON.stringify(typed.schema));
|
|
// await updateScriptSchema(content, language, typed, path);
|
|
// if (typedBefore != typed.schema) {
|
|
// log.info(`Updated metadata for bundle ${path}`);
|
|
typed = structuredClone(remote);
|
|
// }
|
|
}
|
|
|
|
if (typed && codebase) {
|
|
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 isDbt = language === "dbt";
|
|
const moduleFolderPath = scriptBasePath + getModuleFolderSuffix(language);
|
|
const modules = await readModulesFromDisk(
|
|
moduleFolderPath,
|
|
opts?.defaultTs,
|
|
moduleEntryPoint,
|
|
isDbt,
|
|
);
|
|
|
|
// A concurrent_limit of <= 0 means "concurrency disabled", not "zero slots" (which
|
|
// would brick the runnable at the queue's concurrency gate). Emit it as omitted rather
|
|
// than 0 so a redeploy never re-persists a zero-slot limit, and drop the now-meaningless
|
|
// time window alongside it. Mirrors the backend's ConcurrencySettings::normalized.
|
|
const [normConcurrentLimit, normConcurrencyTimeWindowS] = normalizeConcurrency(
|
|
typed?.concurrent_limit,
|
|
typed?.concurrency_time_window_s
|
|
);
|
|
|
|
const requestBodyCommon: NewScript = {
|
|
content,
|
|
description: typed?.description ?? "",
|
|
language: language as NewScript["language"],
|
|
path: remotePath.replaceAll(SEP, "/"),
|
|
summary: typed?.summary ?? "",
|
|
kind: typed?.kind,
|
|
// A dbt lock pins a resolved commit and engine versions that only a
|
|
// dependency job can determine, and that job is also what publishes the
|
|
// script's manifest graph. Sending one suppresses that job, so the push
|
|
// would deploy a stale lock AND leave the graph unpublished.
|
|
lock: language === "dbt" ? undefined : typed?.lock,
|
|
schema: typed?.schema,
|
|
tag: typed?.tag,
|
|
ws_error_handler_muted: typed?.ws_error_handler_muted,
|
|
dedicated_worker: typed?.dedicated_worker,
|
|
cache_ttl: typed?.cache_ttl,
|
|
concurrency_time_window_s: normConcurrencyTimeWindowS,
|
|
concurrent_limit: normConcurrentLimit,
|
|
deployment_message: message,
|
|
restart_unless_cancelled: typed?.restart_unless_cancelled,
|
|
visible_to_runner_only: typed?.visible_to_runner_only,
|
|
has_preprocessor: typed?.has_preprocessor,
|
|
priority: typed?.priority,
|
|
concurrency_key: typed?.concurrency_key,
|
|
debounce_key: typed?.debounce_key,
|
|
debounce_delay_s: typed?.debounce_delay_s,
|
|
codebase: await codebase?.getDigest(forceTar),
|
|
timeout: nonePositiveInt(typed?.timeout),
|
|
on_behalf_of_email: typed?.on_behalf_of_email,
|
|
envs: typed?.envs,
|
|
modules: modules,
|
|
labels: typed?.labels,
|
|
};
|
|
|
|
const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email;
|
|
delete (typed as any)?.has_on_behalf_of;
|
|
// The authorization half of the identity is never exported to the repo (the
|
|
// workspace tarball strips it); it only ever travels back from the remote row.
|
|
delete (typed as any)?.on_behalf_of;
|
|
|
|
if (permissionedAsContext?.userIsAdminOrDeployer && hasOnBehalfOf) {
|
|
if (remote && remote.on_behalf_of_email) {
|
|
requestBodyCommon.on_behalf_of_email = remote.on_behalf_of_email;
|
|
(requestBodyCommon as any).on_behalf_of = (
|
|
remote as any
|
|
).on_behalf_of;
|
|
(requestBodyCommon as any).preserve_on_behalf_of = true;
|
|
log.info(`Preserving ${remote.on_behalf_of_email} as on_behalf_of for script ${remotePath}`);
|
|
}
|
|
// On create: backend applies folder defaults — no client-side resolution needed
|
|
}
|
|
|
|
if (remote) {
|
|
if (content === remote.content) {
|
|
if (
|
|
typed == undefined ||
|
|
(typed.description === remote.description &&
|
|
typed.summary === remote.summary &&
|
|
typed.kind == remote.kind &&
|
|
!remote.archived &&
|
|
(Array.isArray(remote?.lock)
|
|
? remote?.lock?.join("\n")
|
|
: remote?.lock ?? ""
|
|
).trim() == (typed?.lock ?? "").trim() &&
|
|
deepEqual(typed.schema, remote.schema) &&
|
|
typed.tag == remote.tag &&
|
|
(typed.ws_error_handler_muted ?? false) ==
|
|
remote.ws_error_handler_muted &&
|
|
typed.dedicated_worker == remote.dedicated_worker &&
|
|
typed.cache_ttl == remote.cache_ttl &&
|
|
normConcurrencyTimeWindowS ==
|
|
normalizeConcurrency(
|
|
remote.concurrent_limit,
|
|
remote.concurrency_time_window_s
|
|
)[1] &&
|
|
normConcurrentLimit ==
|
|
normalizeConcurrency(remote.concurrent_limit)[0] &&
|
|
Boolean(typed.restart_unless_cancelled) ==
|
|
Boolean(remote.restart_unless_cancelled) &&
|
|
Boolean(typed.visible_to_runner_only) ==
|
|
Boolean(remote.visible_to_runner_only) &&
|
|
Boolean(typed.has_preprocessor) ==
|
|
Boolean(remote.has_preprocessor) &&
|
|
typed.priority == Boolean(remote.priority) &&
|
|
nonePositiveInt(typed.timeout) == nonePositiveInt(remote.timeout) &&
|
|
//@ts-ignore
|
|
typed.concurrency_key == remote["concurrency_key"] &&
|
|
typed.debounce_key == remote["debounce_key"] &&
|
|
typed.debounce_delay_s == remote["debounce_delay_s"] &&
|
|
typed.codebase == remote.codebase &&
|
|
(hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) &&
|
|
deepEqual(typed.envs, remote.envs) &&
|
|
deepEqual(modules ?? null, remote.modules ?? null))
|
|
) {
|
|
log.info(colors.green(`Script ${remotePath} is up to date`));
|
|
// Even when the body is unchanged, perms may still drift — sync them
|
|
// independently before returning.
|
|
await applyExtraPermsDiff(
|
|
workspaceId,
|
|
"script",
|
|
remotePath.replaceAll(SEP, "/"),
|
|
(typed as any)?.extra_perms,
|
|
(remote as any)?.extra_perms,
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
log.info(`Updating script ${remotePath} ...`);
|
|
const body = {
|
|
...requestBodyCommon,
|
|
parent_hash: remote.hash,
|
|
auto_parent: true,
|
|
};
|
|
const execTime = await createScript(
|
|
bundleContent,
|
|
workspaceId,
|
|
body,
|
|
workspace
|
|
);
|
|
log.info(
|
|
colors.yellow.bold(
|
|
`Updated script ${remotePath} (${execTime.toFixed(0)}ms)`
|
|
)
|
|
);
|
|
} else {
|
|
log.info(`Creating new script ${remotePath} ...`);
|
|
const body = {
|
|
...requestBodyCommon,
|
|
parent_hash: undefined,
|
|
};
|
|
const execTime = await createScript(
|
|
bundleContent,
|
|
workspaceId,
|
|
body,
|
|
workspace
|
|
);
|
|
log.info(
|
|
colors.yellow.bold(
|
|
`Created new script ${remotePath} (${execTime.toFixed(0)}ms)`
|
|
)
|
|
);
|
|
}
|
|
|
|
// Sync granular ACLs as an independent step — perm-only edits never reach
|
|
// create_script (which would bump the script hash) and instead route
|
|
// through /acls/* via applyExtraPermsDiff.
|
|
//
|
|
// No refetch is needed:
|
|
// - folder perms are additive at auth time, never merged onto item rows;
|
|
// - the body sent to create_script doesn't carry extra_perms, so a fresh
|
|
// deploy of an existing path inherits the previous version's perms
|
|
// unchanged. The diff against `remote` (captured before the deploy)
|
|
// therefore matches what `wmill acl remove` would do — and the granular
|
|
// ACL endpoint updates every matching row, so the inheritance on the
|
|
// new version doesn't leave ghost entries.
|
|
await applyExtraPermsDiff(
|
|
workspaceId,
|
|
"script",
|
|
remotePath.replaceAll(SEP, "/"),
|
|
(typed as any)?.extra_perms,
|
|
(remote as any)?.extra_perms,
|
|
);
|
|
|
|
return true;
|
|
}
|
|
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,
|
|
// A dbt project rides in its module folder as-is: `.sql` models (which the
|
|
// language inference below rejects as an ambiguous dialect), `.yml` schemas
|
|
// and `.csv` seeds are all part of the project and none is a Windmill script.
|
|
// Verbatim, or dbt receives a project missing exactly the files it needs.
|
|
verbatim: boolean = false,
|
|
): Promise<Record<string, ScriptModule> | undefined> {
|
|
if (!fs.existsSync(moduleFolderPath) || !fs.statSync(moduleFolderPath).isDirectory()) {
|
|
return undefined;
|
|
}
|
|
|
|
const modules: Record<string, ScriptModule> = {};
|
|
|
|
const skipDirs = verbatim
|
|
? dbtGeneratedDirs(moduleFolderPath)
|
|
: new Set<string>();
|
|
|
|
// In folder layout mode, skip the entry point files (script.*, script.yaml, etc.)
|
|
const isEntryPointFile = (name: string, isTopLevel: boolean) => {
|
|
if (!isTopLevel) return false;
|
|
// A dbt project's descriptor is the script's CONTENT, so it must not also
|
|
// ride along as a module: the push would send the same text twice and dbt
|
|
// would find a stray file at its project root.
|
|
if (verbatim) return name === DBT_DESCRIPTOR_NAME;
|
|
if (!folderLayout) 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()) {
|
|
// A configured `target-path` may be nested (`build/target`), so the
|
|
// comparison is on the project-relative path, not the entry name.
|
|
if (skipDirs.size > 0 && isUnderGeneratedDir(relPath, skipDirs)) continue;
|
|
readDir(fullPath, relPath);
|
|
// `.lock` is the script's own lockfile in a `__mod` bundle (the `lock`
|
|
// field on ScriptModule) — but a dbt project's files are its author's,
|
|
// and one may legitimately be named `uv.lock`. Dropping it would break
|
|
// the unmodified-project round trip this bundle exists to keep.
|
|
} else if (
|
|
entry.isFile() &&
|
|
(verbatim || !entry.name.endsWith(".lock")) &&
|
|
!isEntryPointFile(entry.name, isTopLevel)
|
|
) {
|
|
if (verbatim) {
|
|
// Secrets stay on the machine that holds them. Skipped before the
|
|
// read, and loudly: a `.env` swept into the bundle is a credential
|
|
// stored in every version of the script and handed back on pull.
|
|
if (isLocalSecretFile(entry.name)) {
|
|
log.warn(
|
|
`Skipping ${relPath}: a local secrets file is not part of the dbt project — ` +
|
|
`dbt reads its values from the environment, so set them in the script's ` +
|
|
`environment variables or the descriptor's \`env\``,
|
|
);
|
|
continue;
|
|
}
|
|
// A dbt project's authored files are text. A binary one -- an image
|
|
// under `docs/`, a `.DS_Store`, a parquet seed -- would be read as
|
|
// mojibake and, if it carries a NUL, rejected by Postgres with an
|
|
// opaque `unsupported Unicode escape sequence`, which the push then
|
|
// reports as success. Skip it, loudly: dbt does not read it either.
|
|
//
|
|
// Asked BEFORE reading: the predicate only stats the file and reads
|
|
// its first 8 KB, so a multi-gigabyte seed next to the project costs
|
|
// that rather than being loaded whole just to be rejected.
|
|
const exclusion = moduleFileExclusion(fullPath);
|
|
if (exclusion !== undefined) {
|
|
// Over the limit but readable as text — a large seed CSV is the
|
|
// realistic case — is refused rather than skipped: dbt WOULD have
|
|
// read it, so shipping the project without it deploys something that
|
|
// compiles here and fails at run time with a missing relation.
|
|
if (exclusion === "oversized") {
|
|
throw oversizedModuleFileError(relPath, fs.statSync(fullPath).size);
|
|
}
|
|
log.warn(
|
|
`Skipping ${relPath}: not a text file, so it is not part of the dbt project the ` +
|
|
`bundle carries — dbt does not read it either`,
|
|
);
|
|
continue;
|
|
}
|
|
// `language` is a required field of the API type and is not used for
|
|
// these: the worker writes them to their relative path and dbt reads
|
|
// the tree.
|
|
modules[relPath] = {
|
|
content: fs.readFileSync(fullPath).toString("utf-8"),
|
|
language: "dbt" as ScriptModule["language"],
|
|
};
|
|
} else if (exts.some((ext) => entry.name.endsWith(ext))) {
|
|
const content = readTextFileSync(fullPath);
|
|
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 = readTextFileSync(lockPath);
|
|
}
|
|
|
|
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,
|
|
body: NewScript,
|
|
workspace: Workspace
|
|
): Promise<number> {
|
|
const start = performance.now();
|
|
// Preserve any user draft at this path: a CLI / git-sync deploy must not wipe
|
|
// an in-progress draft the way a UI "deploy from draft" intentionally does.
|
|
body = { ...body, skip_draft_deletion: true };
|
|
// skip_if_noop asks the backend to treat deploys identical to the parent
|
|
// (same content, lockfile, and metadata) as a no-op, so the CLI does not
|
|
// produce phantom git-sync / promotion commits on re-pushes.
|
|
const skipIfNoop = "skip_if_noop=true";
|
|
const extraHeaders = getHeaders();
|
|
if (!bundleContent) {
|
|
try {
|
|
const url =
|
|
workspace.remote +
|
|
"api/w/" +
|
|
workspaceId +
|
|
"/scripts/create?" +
|
|
skipIfNoop;
|
|
const req = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${workspace.token}`,
|
|
"Content-Type": "application/json",
|
|
...extraHeaders,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
await detectAuthGatewayChallenge(req, url);
|
|
if (req.status != 201) {
|
|
throw Error(
|
|
`${req.status} - ${req.statusText} - ${await req.text()}`
|
|
);
|
|
}
|
|
} catch (e: any) {
|
|
throw Error(
|
|
`Script creation for ${body.path} with parent ${
|
|
body.parent_hash
|
|
} was not successful: ${e.body ?? e.message} `
|
|
);
|
|
}
|
|
} else {
|
|
const form = new FormData();
|
|
form.append("script", JSON.stringify(body));
|
|
form.append(
|
|
"file",
|
|
typeof bundleContent == "string"
|
|
? bundleContent
|
|
: bundleContent
|
|
);
|
|
|
|
const url =
|
|
workspace.remote +
|
|
"api/w/" +
|
|
workspace.workspaceId +
|
|
"/scripts/create_snapshot?" +
|
|
skipIfNoop;
|
|
const req = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${workspace.token} `,
|
|
...extraHeaders,
|
|
},
|
|
body: form,
|
|
});
|
|
await detectAuthGatewayChallenge(req, url);
|
|
if (req.status != 201) {
|
|
throw Error(
|
|
`Script snapshot creation was not successful: ${req.status} - ${
|
|
req.statusText
|
|
} - ${await req.text()} `
|
|
);
|
|
}
|
|
}
|
|
return performance.now() - start;
|
|
}
|
|
|
|
/**
|
|
* A script metadata file could not be paired with exactly one script file on
|
|
* disk, so nothing can be deployed for it. Distinct from a deploy that reached
|
|
* the remote and was rejected: callers that can carry on with the rest of a
|
|
* changeset catch this specifically.
|
|
*/
|
|
export class UnresolvableScriptContentFileError extends Error {}
|
|
|
|
/**
|
|
* A path claimed by both a dbt project and an ordinary script.
|
|
*
|
|
* Its own class because the module push tolerates "no parent found" and must
|
|
* NOT tolerate this: swallowed, the command reports success while deploying
|
|
* nothing.
|
|
*/
|
|
export class DbtPathCollisionError extends UnresolvableScriptContentFileError {}
|
|
|
|
/**
|
|
* The dbt project a path would collide with, if there is one.
|
|
*
|
|
* `<base>.py` and `<base>__dbt/` deploy to the SAME remote path, so whichever
|
|
* is pushed last wins and replaces the other's script. The descriptor is
|
|
* optional, so `dbt_project.yml` — not the descriptor — is what says a project
|
|
* is there. Asked on BOTH push paths: an ordinary file goes straight to
|
|
* `handleFile`, a model reaches its parent through `findContentFile`, and a
|
|
* guard on one of them leaves the other silently overwriting.
|
|
*/
|
|
export async function collidingDbtProject(
|
|
basePath: string
|
|
): Promise<string | undefined> {
|
|
const project = basePath + "__dbt/dbt_project.yml";
|
|
return (await stat(project).then(() => true).catch(() => false))
|
|
? project
|
|
: undefined;
|
|
}
|
|
|
|
/**
|
|
* The ordinary script file sharing a base with a dbt project, if there is one —
|
|
* the same collision as [`collidingDbtProject`], seen from the dbt side.
|
|
*
|
|
* Needed because a descriptor may be pushed DIRECTLY (`wmill script push
|
|
* <base>__dbt/wm_dbt.yaml`), which never passes through the metadata resolution
|
|
* that would otherwise catch it.
|
|
*/
|
|
export async function collidingOrdinaryScript(
|
|
basePath: string
|
|
): Promise<string | undefined> {
|
|
for (const ext of exts) {
|
|
if (ext === "__dbt/" + DBT_DESCRIPTOR_NAME) continue;
|
|
// Both layouts, because both deploy to `basePath`: the flat file, and the
|
|
// folder layout's entry point.
|
|
for (const candidate of [
|
|
basePath + ext,
|
|
`${basePath}${getModuleFolderSuffix()}/script${ext}`,
|
|
]) {
|
|
const isFile = await stat(candidate)
|
|
.then((s) => s.isFile())
|
|
.catch(() => false);
|
|
if (isFile) return candidate;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function dbtPathCollisionError(
|
|
project: string,
|
|
other: string
|
|
): DbtPathCollisionError {
|
|
return new DbtPathCollisionError(
|
|
`${project} and ${other} deploy to the same path, so pushing either one ` +
|
|
`replaces the other's script. Keep one: move the dbt project to a path ` +
|
|
`of its own, or remove ${other}.`
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* A script's content, tolerating the one content file that may not exist: a dbt
|
|
* project's descriptor is optional, and absent means an empty descriptor.
|
|
*/
|
|
async function readScriptContent(filePath: string): Promise<string> {
|
|
try {
|
|
return await readTextFile(filePath);
|
|
} catch (e) {
|
|
// ONLY a missing file is an empty descriptor. A permission or I/O error on a
|
|
// descriptor that does exist would otherwise deploy the defaults — the
|
|
// `main` warehouse and the whole project — in place of what the file says.
|
|
if (isMissingDbtDescriptor(filePath, e)) return "";
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export async function findContentFile(filePath: string) {
|
|
// Folder layout: __mod/script.yaml -> __mod/script.ts
|
|
const isModuleFolderMeta = isModuleEntryMetadata(filePath);
|
|
const toCandidate = (ext: string) =>
|
|
isModuleFolderMeta
|
|
? filePath.replace(MODULE_ENTRY_META_RE, "$1script" + ext)
|
|
: filePath.endsWith("script.json")
|
|
? filePath.replace(".script.json", ext)
|
|
: filePath.endsWith("script.lock")
|
|
? filePath.replace(".script.lock", ext)
|
|
: filePath.replace(".script.yaml", ext);
|
|
// Every branch above is a no-op on a path that is neither flat nor
|
|
// module-entry metadata, which would make toCandidate the identity function
|
|
// and "resolve" the input to itself.
|
|
if (!isModuleFolderMeta && !/\.script\.(yaml|json|lock)$/.test(filePath)) {
|
|
throw new UnresolvableScriptContentFileError(
|
|
`${filePath} is not a script metadata file — no script file can be resolved from it.`
|
|
);
|
|
}
|
|
const candidates = exts.map(toCandidate);
|
|
|
|
const validCandidates = (
|
|
await Promise.all(
|
|
candidates.map((x) => {
|
|
return stat(x)
|
|
.catch(() => undefined)
|
|
.then((x) => x?.isFile())
|
|
.then((e) => {
|
|
return { path: x, file: e };
|
|
});
|
|
})
|
|
)
|
|
)
|
|
.filter((x) => x.file)
|
|
.map((x) => x.path);
|
|
// A dbt project's descriptor is OPTIONAL, so `dbt_project.yml` is what says a
|
|
// dbt script lives at this path — the descriptor is often absent from the
|
|
// candidates above while the project is perfectly real. Asked BEFORE the
|
|
// counts below: a project beside an ordinary script is not "one candidate",
|
|
// it is two scripts claiming one remote path, and returning the ordinary one
|
|
// deploys it OVER the dbt script on the next push of any model.
|
|
const dbtCandidate = toCandidate("__dbt/" + DBT_DESCRIPTOR_NAME);
|
|
const dbtProject = await collidingDbtProject(
|
|
dbtCandidate.slice(0, -("__dbt/" + DBT_DESCRIPTOR_NAME).length),
|
|
);
|
|
const nonDbtCandidates = validCandidates.filter((c) => c !== dbtCandidate);
|
|
if (dbtProject && nonDbtCandidates.length > 0) {
|
|
throw dbtPathCollisionError(dbtProject, nonDbtCandidates.join(", "));
|
|
}
|
|
if (validCandidates.length > 1) {
|
|
throw new UnresolvableScriptContentFileError(
|
|
`Multiple script files found next to ${filePath}: ${validCandidates.join(", ")} — ` +
|
|
`cannot tell which one the metadata belongs to. Keep exactly one.`
|
|
);
|
|
}
|
|
if (validCandidates.length < 1) {
|
|
// Resolving to the absent descriptor keeps one content path for every
|
|
// caller; reading it yields an empty descriptor.
|
|
if (dbtProject) {
|
|
return dbtCandidate;
|
|
}
|
|
throw new UnresolvableScriptContentFileError(
|
|
`No script file found next to ${filePath} — a script cannot be deployed from its metadata alone. ` +
|
|
`Add the matching script file (e.g. ${toCandidate(".ts")} or ${toCandidate(
|
|
".py"
|
|
)}) or remove ${filePath}.`
|
|
);
|
|
}
|
|
return validCandidates[0];
|
|
}
|
|
|
|
export function filePathExtensionFromContentType(
|
|
language: ScriptLanguage,
|
|
defaultTs: "bun" | "deno" | undefined
|
|
): string {
|
|
if (language === "python3") {
|
|
return ".py";
|
|
} else if (language === "nativets") {
|
|
return ".fetch.ts";
|
|
} else if (language === "bun") {
|
|
if (defaultTs == "deno") {
|
|
return ".bun.ts";
|
|
} else {
|
|
return ".ts";
|
|
}
|
|
} else if (language === "deno") {
|
|
if (defaultTs == undefined || defaultTs == "bun") {
|
|
return ".deno.ts";
|
|
} else {
|
|
return ".ts";
|
|
}
|
|
} else if (language === "go") {
|
|
return ".go";
|
|
} else if (language === "mysql") {
|
|
return ".my.sql";
|
|
} else if (language === "bigquery") {
|
|
return ".bq.sql";
|
|
} else if (language === "duckdb") {
|
|
return ".duckdb.sql";
|
|
} else if (language === "oracledb") {
|
|
return ".odb.sql";
|
|
} else if (language === "snowflake") {
|
|
return ".sf.sql";
|
|
} else if (language === "mssql") {
|
|
return ".ms.sql";
|
|
} else if (language === "postgresql") {
|
|
return ".pg.sql";
|
|
} else if (language === "graphql") {
|
|
return ".gql";
|
|
} else if (language === "bash") {
|
|
return ".sh";
|
|
} else if (language === "powershell") {
|
|
return ".ps1";
|
|
} else if (language === "php") {
|
|
return ".php";
|
|
} else if (language === "rust") {
|
|
return ".rs";
|
|
} else if (language === "ansible") {
|
|
return ".playbook.yml";
|
|
} else if (language === "csharp") {
|
|
return ".cs";
|
|
} else if (language === "nu") {
|
|
return ".nu";
|
|
} else if (language === "java") {
|
|
return ".java";
|
|
} else if (language === "ruby") {
|
|
return ".rb";
|
|
} else if (language === "rlang") {
|
|
return ".r";
|
|
} else if (language === "dbt") {
|
|
// Not an extension but a path suffix: a dbt script's content file lives
|
|
// inside the project folder, so `<base> + this` is where it belongs.
|
|
return "__dbt/" + DBT_DESCRIPTOR_NAME;
|
|
// for related places search: ADD_NEW_LANG
|
|
} else {
|
|
throw new Error("Invalid language: " + language);
|
|
}
|
|
}
|
|
|
|
export const exts = [
|
|
".fetch.ts",
|
|
".deno.ts",
|
|
".bun.ts",
|
|
".ts",
|
|
".py",
|
|
".go",
|
|
".sh",
|
|
".pg.sql",
|
|
".my.sql",
|
|
".bq.sql",
|
|
".odb.sql",
|
|
".sf.sql",
|
|
".ms.sql",
|
|
".duckdb.sql",
|
|
".sql",
|
|
".gql",
|
|
".ps1",
|
|
".php",
|
|
".rs",
|
|
".cs",
|
|
".nu",
|
|
".playbook.yml",
|
|
".java",
|
|
".rb",
|
|
".r",
|
|
// Not an extension: a dbt script's content file is its descriptor, inside
|
|
// the project folder. `<base>.script.yaml` -> `<base>__dbt/wm_dbt.yaml`.
|
|
"__dbt/" + DBT_DESCRIPTOR_NAME,
|
|
// for related places search: ADD_NEW_LANG
|
|
];
|
|
|
|
/**
|
|
* Whether a path is a script's content file.
|
|
*
|
|
* Separators are normalized first: one "extension" is the path suffix
|
|
* `__dbt/wm_dbt.yaml`, which on Windows is spelled `__dbt\wm_dbt.yaml` and
|
|
* would match nothing — silently skipping every dbt project on that platform.
|
|
*/
|
|
export function hasScriptExt(p: string): boolean {
|
|
const norm = p.replaceAll("\\", "/");
|
|
return exts.some((ext) => norm.endsWith(ext));
|
|
}
|
|
|
|
export function removeExtensionToPath(path: string): string {
|
|
const norm = path.replaceAll("\\", "/");
|
|
for (const ext of exts) {
|
|
if (norm.endsWith(ext)) {
|
|
return path.substring(0, path.length - ext.length);
|
|
}
|
|
}
|
|
throw new Error("Invalid extension: " + path);
|
|
}
|
|
|
|
async function list(
|
|
opts: GlobalOptions & {
|
|
showArchived?: boolean;
|
|
includeWithoutMain?: boolean;
|
|
includeDraftOnly?: boolean;
|
|
json?: boolean;
|
|
}
|
|
) {
|
|
if (opts.json) log.setSilent(true);
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
|
|
let page = 0;
|
|
const perPage = 10;
|
|
const total: Script[] = [];
|
|
while (true) {
|
|
const res = await wmill.listScripts({
|
|
workspace: workspace.workspaceId,
|
|
page,
|
|
perPage,
|
|
showArchived: opts.showArchived ?? false,
|
|
includeWithoutMain: opts.includeWithoutMain ?? false,
|
|
includeDraftOnly: opts.includeDraftOnly ?? true,
|
|
});
|
|
page += 1;
|
|
total.push(...res);
|
|
if (res.length < perPage) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(total));
|
|
} else {
|
|
new Table()
|
|
.header(["path", "summary", "language", "created by"])
|
|
.padding(2)
|
|
.border(true)
|
|
.body(total.map((x) => [x.path, x.summary, x.language, x.created_by]))
|
|
.render();
|
|
}
|
|
}
|
|
|
|
export async function resolve(input: string): Promise<Record<string, any>> {
|
|
if (!input) {
|
|
throw new Error("No data given");
|
|
}
|
|
|
|
if (input == "@-") {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
input = new TextDecoder().decode(Buffer.concat(chunks));
|
|
}
|
|
if (input[0] == "@") {
|
|
input = await readTextFile(input.substring(1));
|
|
}
|
|
try {
|
|
return JSON.parse(input);
|
|
} catch (e) {
|
|
console.error("Impossible to parse input as JSON", input);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function run(
|
|
opts: GlobalOptions & {
|
|
data?: string;
|
|
silent: boolean;
|
|
tag?: string;
|
|
},
|
|
path: string
|
|
) {
|
|
if (opts.silent) {
|
|
log.setSilent(true);
|
|
}
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
|
|
const input = opts.data ? await resolve(opts.data) : {};
|
|
|
|
// Validate required args against schema when no data provided
|
|
if (!opts.data) {
|
|
try {
|
|
const script = await wmill.getScriptByPath({
|
|
workspace: workspace.workspaceId,
|
|
path,
|
|
});
|
|
validateRequiredArgs(script.schema as Record<string, unknown>);
|
|
} catch (e: any) {
|
|
if (e.message?.startsWith("Missing required")) throw e;
|
|
log.warn(`Could not fetch schema to validate args: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
let id: string;
|
|
try {
|
|
id = await wmill.runScriptByPath({
|
|
workspace: workspace.workspaceId,
|
|
path,
|
|
tag: opts.tag,
|
|
requestBody: input,
|
|
});
|
|
} catch (e: any) {
|
|
if (e?.status === 404) {
|
|
// Script might exist but have a lock/deployment error — check before giving up
|
|
try {
|
|
const script = await wmill.getScriptByPath({
|
|
workspace: workspace.workspaceId,
|
|
path,
|
|
});
|
|
if (script.lock_error_logs) {
|
|
throw new Error(
|
|
`Script '${path}' has a deployment error and cannot be run:\n${script.lock_error_logs}`
|
|
);
|
|
}
|
|
} catch (lookupErr: any) {
|
|
if (lookupErr?.message?.includes("deployment error")) throw lookupErr;
|
|
// Re-throw non-404 lookup errors (e.g. auth/network issues)
|
|
if (lookupErr?.status && lookupErr.status !== 404) throw lookupErr;
|
|
}
|
|
throw new Error(
|
|
`Script '${path}' not found. Run 'wmill script list' to see available scripts.`
|
|
);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
if (!opts.silent) {
|
|
await track_job(workspace.workspaceId, id);
|
|
}
|
|
|
|
const MAX_RETRIES = 600; // ~60 seconds at 100ms intervals
|
|
let retries = 0;
|
|
while (retries < MAX_RETRIES) {
|
|
try {
|
|
const completedJob = await wmill.getCompletedJob({
|
|
workspace: workspace.workspaceId,
|
|
id,
|
|
});
|
|
|
|
if (completedJob.success === false) {
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
const result = completedJob.result ?? {};
|
|
if (opts.silent) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
log.info(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
break;
|
|
} catch {
|
|
retries++;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
if (retries >= MAX_RETRIES) {
|
|
throw new Error(`Timed out waiting for job ${id} to complete`);
|
|
}
|
|
}
|
|
|
|
export async function track_job(workspace: string, id: string) {
|
|
try {
|
|
const result = await wmill.getCompletedJob({ workspace, id });
|
|
|
|
log.info(result.logs);
|
|
log.info("\n");
|
|
log.info(colors.bold.underline.green("Job Completed"));
|
|
log.info("\n");
|
|
return;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
log.info(colors.yellow("Waiting for Job " + id + " to start..."));
|
|
|
|
let logOffset = 0;
|
|
let running = false;
|
|
let retry = 0;
|
|
while (true) {
|
|
let updates: {
|
|
running?: boolean | undefined;
|
|
completed?: boolean | undefined;
|
|
new_logs?: string | undefined;
|
|
};
|
|
try {
|
|
updates = await wmill.getJobUpdates({
|
|
workspace,
|
|
id,
|
|
logOffset,
|
|
running,
|
|
});
|
|
} catch {
|
|
retry++;
|
|
if (retry > 3) {
|
|
log.info("failed to get job updated. skipping log streaming.");
|
|
break;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
continue;
|
|
}
|
|
|
|
if (!running && updates.running === true) {
|
|
running = true;
|
|
log.info(colors.green("Job running. Streaming logs..."));
|
|
}
|
|
|
|
if (updates.new_logs) {
|
|
process.stdout.write(updates.new_logs);
|
|
logOffset += updates.new_logs.length;
|
|
}
|
|
|
|
if (updates.completed === true) {
|
|
running = false;
|
|
break;
|
|
}
|
|
|
|
if (running && updates.running === false) {
|
|
running = false;
|
|
log.info(colors.yellow("Job suspended. Waiting for it to continue..."));
|
|
}
|
|
}
|
|
await new Promise((resolve, _) => setTimeout(() => resolve(undefined), 1000));
|
|
|
|
try {
|
|
const final_job = await wmill.getCompletedJob({ workspace, id });
|
|
if ((final_job.logs?.length ?? -1) > logOffset) {
|
|
log.info(final_job.logs!.substring(logOffset));
|
|
}
|
|
log.info("\n");
|
|
if (final_job.success) {
|
|
log.info(colors.bold.underline.green("Job Completed"));
|
|
} else {
|
|
log.info(colors.bold.underline.red("Job Completed"));
|
|
}
|
|
log.info("\n");
|
|
} catch {
|
|
log.info("Job appears to have completed, but no data can be retrieved");
|
|
}
|
|
}
|
|
|
|
export async function pollForJobResult(
|
|
workspace: string,
|
|
jobId: string,
|
|
): Promise<{ result: unknown; success: boolean }> {
|
|
return await pollJobWithQueueLogging(workspace, jobId);
|
|
}
|
|
|
|
async function show(opts: GlobalOptions, path: string) {
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
const s = await wmill.getScriptByPath({
|
|
workspace: workspace.workspaceId,
|
|
path,
|
|
});
|
|
log.info(colors.underline(s.path));
|
|
if (s.description) log.info(s.description);
|
|
log.info("");
|
|
log.info(s.content);
|
|
}
|
|
|
|
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
|
|
if (opts.json) log.setSilent(true);
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
const s = await wmill.getScriptByPath({
|
|
workspace: workspace.workspaceId,
|
|
path,
|
|
});
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(s));
|
|
} else {
|
|
console.log(colors.bold("Path:") + " " + s.path);
|
|
console.log(colors.bold("Summary:") + " " + (s.summary ?? ""));
|
|
console.log(colors.bold("Description:") + " " + (s.description ?? ""));
|
|
console.log(colors.bold("Language:") + " " + s.language);
|
|
console.log(colors.bold("Kind:") + " " + (s.kind ?? "script"));
|
|
console.log(colors.bold("Created by:") + " " + (s.created_by ?? ""));
|
|
console.log(colors.bold("Created at:") + " " + (s.created_at ?? ""));
|
|
}
|
|
}
|
|
|
|
const languageAliases: Record<string, ScriptLanguage> = {
|
|
python: "python3",
|
|
};
|
|
|
|
async function bootstrap(
|
|
opts: GlobalOptions & { summary: string; description: string },
|
|
scriptPath: string,
|
|
language: ScriptLanguage | string
|
|
) {
|
|
if (!validatePath(scriptPath)) {
|
|
return;
|
|
}
|
|
|
|
const resolvedLanguage = (languageAliases[language] ?? language) as ScriptLanguage;
|
|
|
|
const scriptInitialCode = scriptBootstrapCode[resolvedLanguage];
|
|
if (scriptInitialCode === undefined) {
|
|
const validLanguages = Object.keys(scriptBootstrapCode).sort().join(", ");
|
|
throw new Error(
|
|
`Unknown language '${language}'. Valid languages: ${validLanguages}`
|
|
);
|
|
}
|
|
|
|
const config = await readConfigFile();
|
|
|
|
const extension = filePathExtensionFromContentType(
|
|
resolvedLanguage,
|
|
config.defaultTs
|
|
);
|
|
const scriptCodeFileFullPath = scriptPath + extension;
|
|
const scriptMetadataFileFullPath = scriptPath + ".script.yaml";
|
|
|
|
try {
|
|
await stat(scriptCodeFileFullPath);
|
|
throw new Error("File already exists: " + scriptCodeFileFullPath);
|
|
} catch (e: any) {
|
|
if (e.message?.startsWith("File already exists")) throw e;
|
|
}
|
|
try {
|
|
await stat(scriptMetadataFileFullPath);
|
|
throw new Error("File already exists: " + scriptMetadataFileFullPath);
|
|
} catch (e: any) {
|
|
if (e.message?.startsWith("File already exists")) throw e;
|
|
}
|
|
|
|
const scriptMetadata = defaultScriptMetadata();
|
|
if (opts.summary !== undefined) {
|
|
scriptMetadata.summary = opts.summary;
|
|
}
|
|
if (opts.description !== undefined) {
|
|
scriptMetadata.description = opts.description;
|
|
}
|
|
|
|
const scriptInitialMetadataYaml = yamlStringify(
|
|
scriptMetadata as Record<string, any>,
|
|
yamlOptions
|
|
);
|
|
|
|
const parentDir = path.dirname(scriptCodeFileFullPath);
|
|
await mkdir(parentDir, { recursive: true });
|
|
|
|
await writeFile(scriptCodeFileFullPath, scriptInitialCode, {
|
|
flag: 'wx', encoding: 'utf-8',
|
|
});
|
|
await writeFile(
|
|
scriptMetadataFileFullPath,
|
|
scriptInitialMetadataYaml,
|
|
{
|
|
flag: 'wx', encoding: 'utf-8',
|
|
}
|
|
);
|
|
}
|
|
|
|
export type GlobalDeps = Map<
|
|
WorkspaceDependenciesLanguage,
|
|
Record<string, string>
|
|
>;
|
|
|
|
export async function generateMetadata(
|
|
opts: GlobalOptions & {
|
|
lockOnly?: boolean;
|
|
schemaOnly?: boolean;
|
|
yes?: boolean;
|
|
} & SyncOptions,
|
|
scriptPath: string | undefined
|
|
) {
|
|
log.warn(
|
|
colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.')
|
|
);
|
|
log.info(
|
|
"This command only works for workspace scripts. For flows or apps, run `wmill generate-metadata` from the affected folder."
|
|
);
|
|
if (scriptPath == "") {
|
|
scriptPath = undefined;
|
|
}
|
|
if (scriptPath && !validatePath(scriptPath)) {
|
|
return;
|
|
}
|
|
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
opts = await mergeConfigWithConfigFile(opts);
|
|
const codebases = await listSyncCodebases(opts);
|
|
|
|
const rawWorkspaceDependencies = await getRawWorkspaceDependencies(true);
|
|
if (scriptPath) {
|
|
// read script metadata file
|
|
await generateScriptMetadataInternal(
|
|
scriptPath,
|
|
workspace,
|
|
opts,
|
|
false,
|
|
false,
|
|
rawWorkspaceDependencies,
|
|
codebases,
|
|
false
|
|
);
|
|
} else {
|
|
// TODO: test this as well.
|
|
const ignore = await ignoreF(opts);
|
|
const elems = await elementsToMap(
|
|
await FSFSElement(process.cwd(), codebases, false),
|
|
(p, isD) => {
|
|
return (
|
|
(!isD && !hasScriptExt(p)) ||
|
|
ignore(p, isD) ||
|
|
isFlowPath(p) ||
|
|
isAppPath(p) ||
|
|
isRawAppPath(p) ||
|
|
// Skip module helper files; only entry points (script.{ext}) are processed
|
|
(isScriptModulePath(p) && !isModuleEntryPoint(p))
|
|
);
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
let hasAny = false;
|
|
log.info("Generating metadata for all stale scripts:");
|
|
for (const e of Object.keys(elems)) {
|
|
const candidate = await generateScriptMetadataInternal(
|
|
e,
|
|
workspace,
|
|
opts,
|
|
true,
|
|
true,
|
|
rawWorkspaceDependencies,
|
|
codebases,
|
|
false
|
|
);
|
|
if (candidate) {
|
|
hasAny = true;
|
|
log.info(colors.green(`+ ${candidate} `));
|
|
}
|
|
}
|
|
if (hasAny) {
|
|
if (opts.dryRun) {
|
|
log.info(colors.gray(`Dry run complete.`));
|
|
return;
|
|
}
|
|
if (
|
|
!opts.yes &&
|
|
!(await Confirm.prompt({
|
|
message: "Update the metadata of the above scripts?",
|
|
default: true,
|
|
}))
|
|
) {
|
|
return;
|
|
}
|
|
} else {
|
|
log.info(colors.green.bold("No metadata to update"));
|
|
return;
|
|
}
|
|
|
|
// Build a DoubleLinkedDependencyTree and upload mismatched scripts to
|
|
// raw_script_temp before the actual generation pass. Without this,
|
|
// dep jobs for scripts that import other not-yet-deployed scripts via
|
|
// relative paths would 404 on the import target (the very bug this
|
|
// alias was introducing on fresh-DB pushes).
|
|
const { DoubleLinkedDependencyTree, uploadScripts } = await import(
|
|
"../../utils/dependency_tree.ts"
|
|
);
|
|
const tree = new DoubleLinkedDependencyTree();
|
|
tree.setWorkspaceDeps(rawWorkspaceDependencies);
|
|
for (const e of Object.keys(elems)) {
|
|
await generateScriptMetadataInternal(
|
|
e,
|
|
workspace,
|
|
opts,
|
|
true, // dryRun: populate tree
|
|
true,
|
|
rawWorkspaceDependencies,
|
|
codebases,
|
|
false,
|
|
tree,
|
|
);
|
|
}
|
|
tree.propagateStaleness();
|
|
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.`,
|
|
),
|
|
);
|
|
}
|
|
for (const e of Object.keys(elems)) {
|
|
await generateScriptMetadataInternal(
|
|
e,
|
|
workspace,
|
|
opts,
|
|
false,
|
|
true,
|
|
rawWorkspaceDependencies,
|
|
codebases,
|
|
false,
|
|
tree,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function preview(
|
|
opts: GlobalOptions & {
|
|
data?: string;
|
|
silent: boolean;
|
|
tag?: string;
|
|
} & SyncOptions,
|
|
filePath: string
|
|
) {
|
|
if (opts.silent) {
|
|
log.setSilent(true);
|
|
}
|
|
opts = await mergeConfigWithConfigFile(opts);
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
|
|
if (!validatePath(filePath)) {
|
|
return;
|
|
}
|
|
|
|
// Same as push: a descriptor-less dbt project's content path is deliberately
|
|
// absent, and the project beside it is what says the script is real.
|
|
const absentDescriptor = await stat(filePath).then(
|
|
() => false,
|
|
(e) => isMissingDbtDescriptor(filePath, e)
|
|
);
|
|
if (!absentDescriptor) {
|
|
const fstat = await stat(filePath);
|
|
if (!fstat.isFile()) {
|
|
throw new Error("file path must refer to a file.");
|
|
}
|
|
}
|
|
|
|
if (filePath.endsWith(".script.json") || filePath.endsWith(".script.yaml")) {
|
|
throw Error(
|
|
"Cannot preview a script metadata file, point to the script content file instead (.py, .ts, .go, .sh)"
|
|
);
|
|
}
|
|
|
|
const codebases = await listSyncCodebases(opts);
|
|
const language = inferContentTypeFromFilePath(filePath, opts?.defaultTs);
|
|
const content = await readScriptContent(filePath);
|
|
const input = opts.data ? await resolve(opts.data) : {};
|
|
|
|
// Read modules from the bundle folder if present. Same suffix and same
|
|
// verbatim read as deploy: a dbt project lives in `__dbt/`, and parsing its
|
|
// files as scripts would drop the `dbt_project.yml` the executor looks for.
|
|
const isFolderLayout = isModuleEntryPoint(filePath);
|
|
const isDbt = language === "dbt";
|
|
const moduleFolderPath = isFolderLayout
|
|
? path.dirname(filePath)
|
|
: filePath.substring(0, filePath.indexOf(".")) + getModuleFolderSuffix(language);
|
|
const modules = await readModulesFromDisk(
|
|
moduleFolderPath,
|
|
opts?.defaultTs,
|
|
isFolderLayout,
|
|
isDbt
|
|
);
|
|
|
|
// Check if this is a codebase script
|
|
const codebase =
|
|
language == "bun" ? findCodebase(filePath, codebases) : undefined;
|
|
|
|
// Resolve relative imports from local (not-yet-deployed) content so previewing
|
|
// a script that imports other locally-edited scripts uses the local versions
|
|
// instead of the deployed ones. Shared with `wmill flow preview` so both
|
|
// entry points behave identically; degrades gracefully on older backends.
|
|
// Short-circuit when the script has no relative imports: the full-workspace
|
|
// dependency walk + diff round-trip is pure overhead in that (common) case.
|
|
let tempScriptRefs: Record<string, string> | undefined = undefined;
|
|
const { extractRelativeImports } = await import(
|
|
"../../utils/relative_imports.ts"
|
|
);
|
|
const relImports = await extractRelativeImports(
|
|
content,
|
|
scriptPathToRemotePath(filePath),
|
|
language
|
|
);
|
|
if (relImports.length > 0) {
|
|
const { buildPreviewTempScriptRefs } = await import(
|
|
"../generate-metadata/generate-metadata.ts"
|
|
);
|
|
tempScriptRefs = await buildPreviewTempScriptRefs(
|
|
workspace,
|
|
opts,
|
|
codebases,
|
|
{ kind: "script", path: filePath }
|
|
);
|
|
}
|
|
|
|
let bundledContent: string | Blob | undefined = undefined;
|
|
let isTar = false;
|
|
|
|
if (codebase) {
|
|
if (codebase.customBundler) {
|
|
if (!opts.silent) {
|
|
log.info(`Using custom bundler ${codebase.customBundler} for preview`);
|
|
}
|
|
bundledContent = execSync(codebase.customBundler + " " + filePath, {
|
|
maxBuffer: 1024 * 1024 * 50,
|
|
}).toString();
|
|
} else {
|
|
const esbuild = await getEsbuild();
|
|
|
|
if (!opts.silent) {
|
|
log.info(`Bundling ${filePath} for preview...`);
|
|
}
|
|
const startTime = performance.now();
|
|
const format = codebase.format ?? "cjs";
|
|
const out = await esbuild.build({
|
|
entryPoints: [filePath],
|
|
format: format,
|
|
bundle: true,
|
|
write: false,
|
|
external: codebase.external,
|
|
inject: codebase.inject,
|
|
define: codebase.define,
|
|
loader: codebase.loader ?? { ".node": "file" },
|
|
outdir: "/",
|
|
platform: "node",
|
|
packages: "bundle",
|
|
target: format == "cjs" ? "node20.15.1" : "esnext",
|
|
banner: codebase.banner,
|
|
});
|
|
const endTime = performance.now();
|
|
bundledContent = out.outputFiles[0].text;
|
|
|
|
// Handle multiple output files (create tarball)
|
|
if (out.outputFiles.length > 1) {
|
|
if (!opts.silent) {
|
|
log.info(`Creating tarball for multiple output files...`);
|
|
}
|
|
const mainPath = filePath.split(SEP).pop()?.split(".")[0] + ".js";
|
|
const mainContent =
|
|
out.outputFiles.find((file: OutputFile) => file.path == "/" + mainPath)?.text ?? "";
|
|
const entries: TarEntry[] = [
|
|
{ name: "main.js", content: mainContent },
|
|
];
|
|
for (const file of out.outputFiles) {
|
|
if (file.path == "/" + mainPath) continue;
|
|
entries.push({ name: file.path.substring(1), content: file.contents });
|
|
}
|
|
bundledContent = await createTarBlob(entries);
|
|
isTar = true;
|
|
} else if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
|
|
// Handle assets
|
|
if (!opts.silent) {
|
|
log.info(`Adding assets to tarball...`);
|
|
}
|
|
const entries: TarEntry[] = [
|
|
{ name: "main.js", content: bundledContent },
|
|
];
|
|
for (const asset of codebase.assets) {
|
|
const data = fs.readFileSync(asset.from);
|
|
entries.push({ name: asset.to, content: data });
|
|
}
|
|
bundledContent = await createTarBlob(entries);
|
|
isTar = true;
|
|
}
|
|
|
|
if (!opts.silent) {
|
|
const size = typeof bundledContent === "string" ? bundledContent.length : bundledContent.size;
|
|
log.info(
|
|
`Bundled ${filePath}: ${(size / 1024).toFixed(0)}kB (${(
|
|
endTime - startTime
|
|
).toFixed(0)}ms)`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!opts.silent) {
|
|
log.info(colors.yellow(`Running preview for ${filePath}...`));
|
|
}
|
|
|
|
// For codebase scripts with bundles, we need to use a multipart form upload
|
|
if (bundledContent) {
|
|
const form = new FormData();
|
|
const previewPayload = {
|
|
content: content, // Pass the original content (frontend does this too)
|
|
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
|
|
args: input,
|
|
language: language,
|
|
tag: opts.tag,
|
|
kind: isTar ? "tarbundle" : "bundle",
|
|
format: codebase?.format ?? "cjs",
|
|
temp_script_refs: tempScriptRefs,
|
|
};
|
|
form.append("preview", JSON.stringify(previewPayload));
|
|
form.append(
|
|
"file",
|
|
typeof bundledContent === "string"
|
|
? new Blob([bundledContent], { type: "application/javascript" })
|
|
: bundledContent
|
|
);
|
|
|
|
const url =
|
|
workspace.remote +
|
|
"api/w/" +
|
|
workspace.workspaceId +
|
|
"/jobs/run/preview_bundle";
|
|
|
|
const extraHeaders = getHeaders();
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${workspace.token}`,
|
|
...extraHeaders,
|
|
},
|
|
body: form,
|
|
});
|
|
|
|
await detectAuthGatewayChallenge(response, url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Preview failed: ${response.status} - ${response.statusText} - ${await response.text()}`
|
|
);
|
|
}
|
|
|
|
const jobId = await response.text();
|
|
if (!opts.silent) {
|
|
await track_job(workspace.workspaceId, jobId);
|
|
}
|
|
|
|
// Wait for the job to complete and get the result
|
|
while (true) {
|
|
try {
|
|
const completedJob = await wmill.getCompletedJob({
|
|
workspace: workspace.workspaceId,
|
|
id: jobId,
|
|
});
|
|
|
|
const result = completedJob.result ?? {};
|
|
if (opts.silent) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else {
|
|
log.info(JSON.stringify(result, null, 2));
|
|
}
|
|
break;
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
} else {
|
|
// For regular scripts, start the preview job then poll for completion
|
|
const jobId = await wmill.runScriptPreview({
|
|
workspace: workspace.workspaceId,
|
|
requestBody: {
|
|
content,
|
|
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
|
|
args: input,
|
|
language: language as any,
|
|
tag: opts.tag,
|
|
modules: modules ?? undefined,
|
|
temp_script_refs: tempScriptRefs,
|
|
},
|
|
});
|
|
|
|
const { result, success } = await pollForJobResult(workspace.workspaceId, jobId);
|
|
|
|
if (!success) {
|
|
if (opts.silent) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
log.info(colors.red.bold("Preview failed"));
|
|
log.info(JSON.stringify(result, null, 2));
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (opts.silent) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
log.info(colors.bold.underline.green("Preview completed"));
|
|
log.info(JSON.stringify(result, null, 2));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function history(
|
|
opts: GlobalOptions & { json?: boolean },
|
|
scriptPath: string
|
|
) {
|
|
if (opts.json) log.setSilent(true);
|
|
opts = await mergeConfigWithConfigFile(opts);
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
|
|
const versions = await wmill.getScriptHistoryByPath({
|
|
workspace: workspace.workspaceId,
|
|
path: scriptPath,
|
|
});
|
|
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(versions));
|
|
} else {
|
|
if (versions.length === 0) {
|
|
log.info("No version history found for " + scriptPath);
|
|
return;
|
|
}
|
|
new Table()
|
|
.header(["#", "Hash", "Created At", "Deployment Message"])
|
|
.padding(2)
|
|
.border(true)
|
|
.body(
|
|
versions.map((v, i) => [
|
|
String(versions.length - i),
|
|
v.script_hash,
|
|
v.created_at ? new Date(v.created_at).toLocaleString() : "-",
|
|
v.deployment_msg ?? "-",
|
|
])
|
|
)
|
|
.render();
|
|
}
|
|
}
|
|
|
|
async function setPermissionedAs(
|
|
opts: GlobalOptions,
|
|
scriptPath: string,
|
|
email: string,
|
|
) {
|
|
const workspace = await resolveWorkspace(opts);
|
|
await requireLogin(opts);
|
|
|
|
const remote = await wmill.getScriptByPath({
|
|
workspace: workspace.workspaceId,
|
|
path: scriptPath,
|
|
});
|
|
if (!remote) throw new Error(`Script ${scriptPath} not found`);
|
|
|
|
await wmill.createScript({
|
|
workspace: workspace.workspaceId,
|
|
requestBody: {
|
|
...(remote as any),
|
|
lock: Array.isArray(remote.lock) ? remote.lock.join("\n") : remote.lock ?? undefined,
|
|
parent_hash: remote.hash,
|
|
on_behalf_of_email: email,
|
|
// The principal is derived server-side from the email, which resolves workspace
|
|
// members, groups and superadmins acting outside their workspaces alike — a
|
|
// client-side `usr` lookup would see only the first of those.
|
|
on_behalf_of: undefined,
|
|
preserve_on_behalf_of: true,
|
|
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
|
skip_draft_deletion: true,
|
|
},
|
|
});
|
|
log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`));
|
|
}
|
|
|
|
const command = new Command()
|
|
.description("script related commands")
|
|
.option("--show-archived", "Show archived scripts instead of active ones")
|
|
.option("--json", "Output as JSON (for piping to jq)")
|
|
.action(list as any)
|
|
.command("list", "list all scripts")
|
|
.option("--show-archived", "Show archived scripts instead of active ones")
|
|
.option("--json", "Output as JSON (for piping to jq)")
|
|
.action(list as any)
|
|
.command(
|
|
"push",
|
|
"push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)"
|
|
)
|
|
.arguments("<path:file>")
|
|
.option("--message <message:string>", "Deployment message")
|
|
.action(push as any)
|
|
.command("get", "get a script's details")
|
|
.arguments("<path:file>")
|
|
.option("--json", "Output as JSON (for piping to jq)")
|
|
.action(get as any)
|
|
.command("show", "show a script's content (alias for get)")
|
|
.arguments("<path:file>")
|
|
.action(show as any)
|
|
.command("run", "run a script by path")
|
|
.arguments("<path:file>")
|
|
.option(
|
|
"-d --data <data:file>",
|
|
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
|
|
)
|
|
.option(
|
|
"-s --silent",
|
|
"Do not output anything other then the final output. Useful for scripting."
|
|
)
|
|
.option(
|
|
"--tag <tag:string>",
|
|
"Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the script's default tag)."
|
|
)
|
|
.action(run as any)
|
|
.command(
|
|
"preview",
|
|
"preview a local script without deploying it. Supports both regular and codebase scripts."
|
|
)
|
|
.arguments("<path:file>")
|
|
.option(
|
|
"-d --data <data:file>",
|
|
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
|
|
)
|
|
.option(
|
|
"-s --silent",
|
|
"Do not output anything other than the final output. Useful for scripting."
|
|
)
|
|
.option(
|
|
"--tag <tag:string>",
|
|
"Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag)."
|
|
)
|
|
.action(preview as any)
|
|
.command("new", "create a new script")
|
|
.arguments("<path:file> <language:string>")
|
|
.option("--summary <summary:string>", "script summary")
|
|
.option("--description <description:string>", "script description")
|
|
.action(bootstrap as any)
|
|
.command("bootstrap", "create a new script (alias for new)")
|
|
.arguments("<path:file> <language:string>")
|
|
.option("--summary <summary:string>", "script summary")
|
|
.option("--description <description:string>", "script description")
|
|
.action(bootstrap as any)
|
|
.command(
|
|
"generate-metadata",
|
|
'DEPRECATED: re-generate script metadata. Use top-level "wmill generate-metadata" instead.'
|
|
)
|
|
// Deprecated compatibility command. Keep it working for older repos, but
|
|
// exclude it from generated system prompt docs.
|
|
// @deprecated use `wmill generate-metadata`
|
|
.arguments("[script:file]")
|
|
.option("--yes", "Skip confirmation prompt")
|
|
.option("--dry-run", "Perform a dry run without making changes")
|
|
.option("--lock-only", "re-generate only the lock")
|
|
.option("--schema-only", "re-generate only script schema")
|
|
.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)"
|
|
)
|
|
.option(
|
|
"-e --excludes <patterns:file[]>",
|
|
"Comma separated patterns to specify which file to NOT take into account."
|
|
)
|
|
.action(generateMetadata as any)
|
|
.command(
|
|
"set-permissioned-as",
|
|
"Set the on_behalf_of_email for a script (requires admin or wm_deployers group)"
|
|
)
|
|
.arguments("<path:string> <email:string>")
|
|
.action(setPermissionedAs as any)
|
|
.command(
|
|
"history",
|
|
"show version history for a script"
|
|
)
|
|
.arguments("<path:string>")
|
|
.option("--json", "Output as JSON (for piping to jq)")
|
|
.action(history as any);
|
|
|
|
export default command;
|