* feat(cli): add consistent get/list/new subcommands for all item types Make the CLI consistent so every item type (script, flow, app, resource, resource-type, variable, schedule, folder, trigger) supports get/list/new subcommands, enabling the CLI to be used as a full API client in bash scripts with jq piping. - Add --json flag to all list commands for machine-readable output - Register explicit "list" subcommand alongside default action - Add "get <path> [--json]" subcommand to fetch single items from API - Rename "bootstrap" to "new" for script/flow, keep "bootstrap" as alias - Add "new" subcommand for resource, resource-type, variable, schedule, folder, and trigger to create local template YAML files - Update cli-commands skill documentation for wmill init - Add integration tests for all new commands Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * feat: install wmill CLI in Docker images and use it for bash variable/resource access - Install windmill-cli via bun in all Dockerfiles that include bun - DockerfileCli: switch from node:slim to oven/bun:slim - CLI: auto-configure from WM_WORKSPACE/WM_TOKEN/BASE_INTERNAL_URL env vars as last-resort fallback when no workspace is configured - Frontend: replace curl-based bash snippets with wmill variable/resource get - Add backend integration tests for wmill CLI in bash scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): install windmill-cli in backend test workflow Ensures wmill is available on PATH for bash integration tests that use `wmill variable get` and `wmill resource get`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(cli): replace @std/* Deno dependencies with Node.js equivalents Replace @std/log with a lightweight custom logger (core/log.ts), @std/path with node:path, and @std/yaml with the yaml npm package. Also fix process hang on exit, add --node option to install_dev.sh, and add missing hasRequiredPermissions to NpmProvider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * all * all * refactor(cli): replace @ayonli/jsext and @std/encoding with lightweight alternatives Replace @ayonli/jsext (8.4MB) with tar-stream (32kB) for tar creation, replace @std/encoding with Node.js Buffer.toString("hex"), and fix @windmill-labs/shared-utils to use direct npm instead of JSR mirror. Also resolve merge conflicts in sync.ts and fix pre-existing type errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): use singleQuote YAML output and pass yamlOptions in gitsync pull The yaml library defaults to double quotes, but the codebase (and tests) expect single-quoted strings. Add singleQuote: true to yamlOptions and pass yamlOptions to gitsync-settings pull writeFile calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * all * fix(cli): address code review feedback - Install CLI from source in backend tests instead of npm - Fix script bootstrap catch block to re-throw "File already exists" - Add type-safe local variable after trigger kind validation - Use created_by instead of policy.on_behalf_of for app get output - Note --kind is recommended for faster trigger lookup in help text - Document node symlink purpose in Dockerfiles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): use /usr/bin for wmill wrapper to ensure it's in PATH Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): install wmill to ~/.local/bin to avoid permission issues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci(backend): switch to Blacksmith runner and add cargo caching - Switch from ubicloud-standard-16 to blacksmith-16vcpu-ubuntu-2404 for faster NVMe-backed builds - Add stickydisk for cargo target directory (persistent NVMe cache across runs) - Add cache for cargo registry and git dependencies - Upgrade DuckDB FFI cache from actions/cache@v3 to useblacksmith/cache@v1 - Enable CARGO_INCREMENTAL=1 to benefit from persistent target cache Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix ci --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
588 lines
17 KiB
TypeScript
588 lines
17 KiB
TypeScript
import { colors } from "@cliffy/ansi/colors";
|
|
import * as log from "./log.ts";
|
|
import { Select } from "@cliffy/prompt/select";
|
|
import { Confirm } from "@cliffy/prompt/confirm";
|
|
import { Input } from "@cliffy/prompt/input";
|
|
|
|
import { loginInteractive } from "./login.ts";
|
|
import { GlobalOptions } from "../types.ts";
|
|
import { getHeaders } from "../utils/utils.ts";
|
|
|
|
import {
|
|
getActiveWorkspace,
|
|
getWorkspaceByName,
|
|
Workspace,
|
|
allWorkspaces,
|
|
addWorkspace,
|
|
} from "../commands/workspace/workspace.ts";
|
|
import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts";
|
|
import { readConfigFile } from "./conf.ts";
|
|
import {
|
|
getCurrentGitBranch,
|
|
getOriginalBranchForWorkspaceForks,
|
|
getWorkspaceIdForWorkspaceForkFromBranchName,
|
|
isGitRepository,
|
|
} from "../utils/git.ts";
|
|
import { WM_FORK_PREFIX } from "./constants.ts";
|
|
|
|
// Helper function to select from multiple matching profiles
|
|
async function selectFromMultipleProfiles(
|
|
profiles: Workspace[],
|
|
baseUrl: string,
|
|
workspaceId: string,
|
|
context: string,
|
|
configDir?: string
|
|
): Promise<Workspace> {
|
|
if (profiles.length === 1) {
|
|
return profiles[0];
|
|
}
|
|
|
|
// Check for last used profile
|
|
const lastUsedProfileName = await getLastUsedProfile(
|
|
"",
|
|
baseUrl,
|
|
workspaceId,
|
|
configDir
|
|
);
|
|
if (lastUsedProfileName) {
|
|
const lastUsedProfile = profiles.find(
|
|
(p) => p.name === lastUsedProfileName
|
|
);
|
|
if (lastUsedProfile) {
|
|
log.info(
|
|
colors.green(
|
|
`Using last used profile '${lastUsedProfile.name}' for ${context}`
|
|
)
|
|
);
|
|
return lastUsedProfile;
|
|
}
|
|
}
|
|
|
|
// No last used or it no longer exists - prompt for selection
|
|
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
|
|
const selectedProfile = profiles[0];
|
|
log.info(
|
|
colors.yellow(
|
|
`Multiple profiles found for ${context}. Using first available profile: '${selectedProfile.name}'`
|
|
)
|
|
);
|
|
|
|
// Save selection for next time
|
|
await setLastUsedProfile(
|
|
"", // No branch context for general workspace selection
|
|
baseUrl,
|
|
workspaceId,
|
|
selectedProfile.name,
|
|
configDir
|
|
);
|
|
|
|
return selectedProfile;
|
|
}
|
|
|
|
log.info(
|
|
colors.yellow(`\nMultiple workspace profiles found for ${context}:`)
|
|
);
|
|
|
|
const selectedName = await Select.prompt({
|
|
message: "Select profile",
|
|
options: profiles.map((p) => ({
|
|
name: `${p.name} (${p.workspaceId} on ${p.remote})`,
|
|
value: p.name,
|
|
})),
|
|
});
|
|
|
|
const selectedProfile = profiles.find((p) => p.name === selectedName)!;
|
|
|
|
// Save selection for next time
|
|
await setLastUsedProfile(
|
|
"", // No branch context for general workspace selection
|
|
baseUrl,
|
|
workspaceId,
|
|
selectedProfile.name,
|
|
configDir
|
|
);
|
|
|
|
return selectedProfile;
|
|
}
|
|
|
|
/**
|
|
* Prompts the user to create a new workspace profile interactively
|
|
*/
|
|
async function createWorkspaceProfileInteractively(
|
|
normalizedBaseUrl: string,
|
|
workspaceId: string,
|
|
currentBranch: string,
|
|
opts: GlobalOptions,
|
|
context: { rawBranch: string; isForked: boolean }
|
|
): Promise<Workspace | undefined> {
|
|
// Log appropriate message based on context
|
|
if (!context.isForked) {
|
|
log.info(
|
|
colors.yellow(
|
|
`\nNo workspace profile found for branch '${context.rawBranch}'\n` +
|
|
`(${normalizedBaseUrl}, ${workspaceId})`
|
|
)
|
|
);
|
|
} else {
|
|
log.info(
|
|
colors.yellow(
|
|
`\nNo workspace profile was found for this forked workspace\n` +
|
|
`(${normalizedBaseUrl}, ${workspaceId})`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
|
|
log.info(
|
|
"Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first."
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const shouldCreate = await Confirm.prompt({
|
|
message: "Would you like to create a new workspace profile?",
|
|
default: true,
|
|
});
|
|
|
|
if (!shouldCreate) {
|
|
return undefined;
|
|
}
|
|
|
|
// Prompt for profile details
|
|
const profileName = await Input.prompt({
|
|
message: "Profile name",
|
|
default: workspaceId,
|
|
});
|
|
|
|
const token = await loginInteractive(normalizedBaseUrl);
|
|
if (!token) {
|
|
log.error("Failed to obtain token");
|
|
return undefined;
|
|
}
|
|
|
|
// Create the new profile
|
|
const newWorkspace: Workspace = {
|
|
name: profileName,
|
|
remote: normalizedBaseUrl,
|
|
workspaceId: workspaceId,
|
|
token: token,
|
|
};
|
|
|
|
await addWorkspace(newWorkspace, opts);
|
|
|
|
// Set as last used for this branch
|
|
await setLastUsedProfile(
|
|
currentBranch,
|
|
normalizedBaseUrl,
|
|
workspaceId,
|
|
profileName,
|
|
opts.configDir
|
|
);
|
|
|
|
log.info(
|
|
colors.green(
|
|
`✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}`
|
|
)
|
|
);
|
|
log.info(colors.green(`✓ Profile '${profileName}' is now active`));
|
|
|
|
return newWorkspace;
|
|
}
|
|
|
|
export type Context = {
|
|
workspace: string;
|
|
baseUrl: string;
|
|
urlStore: string;
|
|
token: string;
|
|
};
|
|
|
|
async function tryResolveWorkspace(
|
|
opts: GlobalOptions
|
|
): Promise<
|
|
{ isError: false; value: Workspace } | { isError: true; error: string }
|
|
> {
|
|
const cache = (opts as any).__secret_workspace;
|
|
if (cache) return { isError: false, value: cache };
|
|
|
|
if (opts.workspace) {
|
|
const e = await getWorkspaceByName(opts.workspace, opts.configDir);
|
|
if (!e) {
|
|
return {
|
|
isError: true,
|
|
error: colors.red.underline("Given workspace does not exist."),
|
|
};
|
|
}
|
|
(opts as any).__secret_workspace = e;
|
|
return { isError: false, value: e };
|
|
}
|
|
|
|
// Only check for explicit workspace, don't fallback to active workspace here
|
|
return {
|
|
isError: true,
|
|
error: colors.red.underline("No explicit workspace given."),
|
|
};
|
|
}
|
|
|
|
export async function tryResolveBranchWorkspace(
|
|
opts: GlobalOptions,
|
|
branchOverride?: string
|
|
): Promise<Workspace | undefined> {
|
|
let rawBranch: string | null = null;
|
|
let currentBranch: string;
|
|
let originalBranchIfForked: string | null = null;
|
|
let workspaceIdIfForked: string | null = null;
|
|
|
|
if (branchOverride) {
|
|
// Use branch override directly
|
|
currentBranch = branchOverride;
|
|
log.info(`Using branch override: ${branchOverride}`);
|
|
} else {
|
|
// Only try branch-based resolution if in a Git repository
|
|
if (!isGitRepository()) {
|
|
return undefined;
|
|
}
|
|
|
|
rawBranch = getCurrentGitBranch();
|
|
if (!rawBranch) {
|
|
return undefined;
|
|
}
|
|
|
|
originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch);
|
|
workspaceIdIfForked =
|
|
getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch);
|
|
if (originalBranchIfForked) {
|
|
log.info(
|
|
`Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml`
|
|
);
|
|
currentBranch = originalBranchIfForked;
|
|
} else {
|
|
currentBranch = rawBranch;
|
|
}
|
|
}
|
|
|
|
// Read wmill.yaml to check for branch workspace configuration
|
|
const config = await readConfigFile();
|
|
const branchConfig = config.gitBranches?.[currentBranch];
|
|
|
|
// Check if branch has workspace configuration
|
|
if (!branchConfig?.baseUrl || !branchConfig?.workspaceId) {
|
|
return undefined;
|
|
}
|
|
|
|
log.info(
|
|
`Using branch configuration for branch \`${currentBranch}\` set in gitBranches`
|
|
);
|
|
|
|
const { baseUrl, workspaceId } = branchConfig;
|
|
|
|
let normalizedBaseUrl: string;
|
|
try {
|
|
normalizedBaseUrl = new URL(baseUrl).toString();
|
|
} catch (error) {
|
|
log.error(
|
|
colors.red(`Invalid baseUrl in branch configuration: ${baseUrl}`)
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
// Find all profiles matching this baseUrl and workspaceId
|
|
const allProfiles = await allWorkspaces(opts.configDir);
|
|
const matchingProfiles = allProfiles.filter(
|
|
(w) => w.remote === normalizedBaseUrl && w.workspaceId === workspaceId
|
|
);
|
|
|
|
if (matchingProfiles.length === 0) {
|
|
// No matching profile exists - prompt to create one
|
|
return await createWorkspaceProfileInteractively(
|
|
normalizedBaseUrl,
|
|
workspaceId,
|
|
currentBranch,
|
|
opts,
|
|
{ rawBranch: rawBranch ?? currentBranch, isForked: !!originalBranchIfForked }
|
|
);
|
|
}
|
|
|
|
// Handle multiple profiles - use special branch-aware logic
|
|
let selectedProfile: Workspace;
|
|
|
|
if (matchingProfiles.length === 1) {
|
|
selectedProfile = matchingProfiles[0];
|
|
log.info(
|
|
colors.green(
|
|
`Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}' with workspace id \`${workspaceId}\``
|
|
)
|
|
);
|
|
} else {
|
|
// For multiple profiles, check branch-specific last used first
|
|
const lastUsedName = await getLastUsedProfile(
|
|
currentBranch,
|
|
normalizedBaseUrl,
|
|
workspaceId,
|
|
opts.configDir
|
|
);
|
|
|
|
if (lastUsedName) {
|
|
const lastUsedProfile = matchingProfiles.find(
|
|
(p) => p.name === lastUsedName
|
|
);
|
|
if (lastUsedProfile) {
|
|
log.info(
|
|
colors.green(
|
|
`Using workspace profile '${lastUsedProfile.name}' for branch '${currentBranch}' (last used)`
|
|
)
|
|
);
|
|
return lastUsedProfile;
|
|
}
|
|
}
|
|
|
|
// Fall back to general selection logic
|
|
selectedProfile = await selectFromMultipleProfiles(
|
|
matchingProfiles,
|
|
normalizedBaseUrl,
|
|
workspaceId,
|
|
`branch '${currentBranch}'`,
|
|
opts.configDir
|
|
);
|
|
|
|
// Save branch-specific selection
|
|
await setLastUsedProfile(
|
|
currentBranch,
|
|
normalizedBaseUrl,
|
|
workspaceId,
|
|
selectedProfile.name,
|
|
opts.configDir
|
|
);
|
|
|
|
log.info(
|
|
colors.green(
|
|
`Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}'`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (workspaceIdIfForked) {
|
|
selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`;
|
|
selectedProfile.workspaceId = workspaceIdIfForked;
|
|
log.info(
|
|
`Inferred workspace id \`${workspaceId}\` from branch name because this is a workspace fork branch (\`${rawBranch}\`). `
|
|
);
|
|
}
|
|
|
|
return selectedProfile;
|
|
}
|
|
|
|
export async function resolveWorkspace(
|
|
opts: GlobalOptions,
|
|
branchOverride?: string
|
|
): Promise<Workspace> {
|
|
const cache = (opts as any).__secret_workspace;
|
|
if (cache) return cache;
|
|
|
|
if (opts.baseUrl) {
|
|
if (opts.workspace && opts.token) {
|
|
let normalizedBaseUrl: string;
|
|
try {
|
|
normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present
|
|
} catch (error) {
|
|
log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`));
|
|
return process.exit(-1);
|
|
}
|
|
|
|
// Try to find existing workspace profile by name, then by workspaceId + remote
|
|
if (opts.workspace) {
|
|
// Try by workspace name first
|
|
let existingWorkspace = await getWorkspaceByName(
|
|
opts.workspace,
|
|
opts.configDir
|
|
);
|
|
|
|
// If not found by name, try to find by workspaceId + remote match
|
|
if (!existingWorkspace) {
|
|
const { allWorkspaces } = await import(
|
|
"../commands/workspace/workspace.ts"
|
|
);
|
|
const workspaces = await allWorkspaces(opts.configDir);
|
|
const matchingWorkspaces = workspaces.filter(
|
|
(w) =>
|
|
w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl
|
|
);
|
|
|
|
if (matchingWorkspaces.length >= 1) {
|
|
existingWorkspace = await selectFromMultipleProfiles(
|
|
matchingWorkspaces,
|
|
normalizedBaseUrl,
|
|
opts.workspace!,
|
|
`workspace "${opts.workspace}" on ${normalizedBaseUrl}`,
|
|
opts.configDir
|
|
);
|
|
}
|
|
}
|
|
|
|
if (existingWorkspace) {
|
|
// Validate that the base URL matches the profile's remote
|
|
if (existingWorkspace.remote !== normalizedBaseUrl) {
|
|
log.info(
|
|
colors.red(
|
|
`Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}`
|
|
)
|
|
);
|
|
return process.exit(-1);
|
|
}
|
|
// Use the existing workspace profile (preserves workspace name)
|
|
return {
|
|
...existingWorkspace,
|
|
token: opts.token, // Use the provided token
|
|
};
|
|
}
|
|
}
|
|
|
|
// No existing profile found, create temporary workspace
|
|
return {
|
|
remote: normalizedBaseUrl,
|
|
workspaceId: opts.workspace,
|
|
name: opts.workspace,
|
|
token: opts.token,
|
|
};
|
|
} else {
|
|
log.info(
|
|
colors.red(
|
|
"If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)."
|
|
)
|
|
);
|
|
return process.exit(-1);
|
|
}
|
|
}
|
|
|
|
const branch = branchOverride ?? getCurrentGitBranch();
|
|
|
|
// Try explicit workspace flag first (should override branch-based resolution). Unless it's a
|
|
// forked workspace, that we detect through the branch name (only when not using branchOverride)
|
|
const res = await tryResolveWorkspace(opts);
|
|
if (!res.isError) {
|
|
const workspace = (res as { isError: false; value: Workspace }).value;
|
|
if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
|
|
return workspace;
|
|
} else {
|
|
log.info(
|
|
`Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\``
|
|
);
|
|
}
|
|
}
|
|
|
|
// Try branch-based resolution (medium priority)
|
|
const branchWorkspace = await tryResolveBranchWorkspace(opts, branchOverride);
|
|
if (branchWorkspace) {
|
|
(opts as any).__secret_workspace = branchWorkspace;
|
|
return branchWorkspace;
|
|
} else if (!branchOverride) {
|
|
// Only check for fork errors when not using branchOverride
|
|
const originalBranch = getOriginalBranchForWorkspaceForks(branch);
|
|
if (originalBranch) {
|
|
log.error(
|
|
colors.red.bold(
|
|
`Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.`
|
|
)
|
|
);
|
|
return process.exit(-1);
|
|
}
|
|
}
|
|
|
|
// Fall back to active workspace
|
|
const activeWorkspace = await getActiveWorkspace(opts);
|
|
if (activeWorkspace) {
|
|
(opts as any).__secret_workspace = activeWorkspace;
|
|
return activeWorkspace;
|
|
}
|
|
|
|
// Last resort: auto-configure from Windmill environment variables
|
|
// (set by the worker for bash/script execution)
|
|
const envWorkspace = process.env["WM_WORKSPACE"];
|
|
const envToken = process.env["WM_TOKEN"];
|
|
const envBaseUrl =
|
|
process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"];
|
|
|
|
if (envWorkspace && envToken && envBaseUrl) {
|
|
let normalizedBaseUrl: string;
|
|
try {
|
|
normalizedBaseUrl = new URL(envBaseUrl).toString();
|
|
} catch {
|
|
log.info(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
|
|
return process.exit(-1);
|
|
}
|
|
log.debug(
|
|
`Using workspace from environment variables: ${envWorkspace} on ${normalizedBaseUrl}`
|
|
);
|
|
const ws: Workspace = {
|
|
name: envWorkspace,
|
|
workspaceId: envWorkspace,
|
|
remote: normalizedBaseUrl,
|
|
token: envToken,
|
|
};
|
|
(opts as any).__secret_workspace = ws;
|
|
return ws;
|
|
}
|
|
|
|
// If everything failed, show error
|
|
log.info(colors.red.bold("No workspace given and no default set."));
|
|
return process.exit(-1);
|
|
}
|
|
|
|
export async function fetchVersion(baseUrl: string): Promise<string> {
|
|
const requestHeaders = new Headers();
|
|
|
|
const extraHeaders = getHeaders();
|
|
if (extraHeaders) {
|
|
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
requestHeaders.set(key, value);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(
|
|
new URL(new URL(baseUrl).origin + "/api/version"),
|
|
{ headers: requestHeaders, method: "GET" }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
// Consume response body even on error to avoid resource leak
|
|
await response.text();
|
|
throw new Error(
|
|
`Failed to fetch version: ${response.status} ${response.statusText}`
|
|
);
|
|
}
|
|
|
|
return await response.text();
|
|
}
|
|
export async function tryResolveVersion(
|
|
opts: GlobalOptions
|
|
): Promise<number | undefined> {
|
|
if ((opts as any).__cache_version) {
|
|
return (opts as any).__cache_version;
|
|
}
|
|
|
|
const workspaceRes = await tryResolveWorkspace(opts);
|
|
if (workspaceRes.isError) return undefined;
|
|
const workspace = (workspaceRes as { isError: false; value: Workspace }).value;
|
|
const version = await fetchVersion(workspace.remote);
|
|
|
|
try {
|
|
return Number.parseInt(
|
|
version.split("-", 1)[0].replaceAll(".", "").replace("v", "")
|
|
);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function validatePath(path: string): boolean {
|
|
if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) {
|
|
log.info(
|
|
colors.red(
|
|
"Given remote path looks invalid. Remote paths are typically of the form <u|g|f>/<username|group|folder>/..."
|
|
)
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|