diff --git a/packages/sdk/docs/cli/workspace.md b/packages/sdk/docs/cli/workspace.md index c5b654766..7e0808814 100644 --- a/packages/sdk/docs/cli/workspace.md +++ b/packages/sdk/docs/cli/workspace.md @@ -16,11 +16,15 @@ tailor-sdk workspace [command] **Commands** -| Command | Description | -| --------------------------------------- | --------------------------------------- | -| [`workspace create`](#workspace-create) | Create a new Tailor Platform workspace. | -| [`workspace delete`](#workspace-delete) | Delete a Tailor Platform workspace. | -| [`workspace list`](#workspace-list) | List all Tailor Platform workspaces. | +| Command | Description | +| ------------------------------------------- | ------------------------------------------- | +| [`workspace app`](#workspace-app) | Manage workspace applications | +| [`workspace create`](#workspace-create) | Create a new Tailor Platform workspace. | +| [`workspace delete`](#workspace-delete) | Delete a Tailor Platform workspace. | +| [`workspace describe`](#workspace-describe) | Show detailed information about a workspace | +| [`workspace list`](#workspace-list) | List all Tailor Platform workspaces. | +| [`workspace restore`](#workspace-restore) | Restore a deleted workspace | +| [`workspace user`](#workspace-user) | Manage workspace users | @@ -203,3 +207,227 @@ tailor-sdk profile delete [options] | `name` | Profile name | Yes | + + + +### workspace app + +Manage workspace applications + +**Usage** + +``` +tailor-sdk workspace app [command] +``` + +**Commands** + +| Command | Description | +| ----------------------------------------------- | -------------------------------- | +| [`workspace app health`](#workspace-app-health) | Check application schema health | +| [`workspace app list`](#workspace-app-list) | List applications in a workspace | + + + + + +#### workspace app health + +Check application schema health + +**Usage** + +``` +tailor-sdk workspace app health [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | ----------------- | ------- | +| `--json` | `-j` | Output as JSON | `false` | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--profile ` | `-p` | Workspace profile | - | +| `--name ` | `-n` | Application name | - | + + + + + +#### workspace app list + +List applications in a workspace + +**Usage** + +``` +tailor-sdk workspace app list [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | -------------------------------------- | ------- | +| `--json` | `-j` | Output as JSON | `false` | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--profile ` | `-p` | Workspace profile | - | +| `--limit ` | `-l` | Maximum number of applications to list | - | + + + + + +### workspace describe + +Show detailed information about a workspace + +**Usage** + +``` +tailor-sdk workspace describe [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | -------------- | ------- | +| `--json` | `-j` | Output as JSON | `false` | +| `--workspace-id ` | `-w` | Workspace ID | - | + + + + + +### workspace restore + +Restore a deleted workspace + +**Usage** + +``` +tailor-sdk workspace restore [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | ------------------------- | ------- | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--yes` | `-y` | Skip confirmation prompts | `false` | + + + + + +### workspace user + +Manage workspace users + +**Usage** + +``` +tailor-sdk workspace user [command] +``` + +**Commands** + +| Command | Description | +| ------------------------------------------------- | ----------------------------------- | +| [`workspace user invite`](#workspace-user-invite) | Invite a user to a workspace | +| [`workspace user list`](#workspace-user-list) | List users in a workspace | +| [`workspace user remove`](#workspace-user-remove) | Remove a user from a workspace | +| [`workspace user update`](#workspace-user-update) | Update a user's role in a workspace | + + + + + +#### workspace user invite + +Invite a user to a workspace + +**Usage** + +``` +tailor-sdk workspace user invite [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | -------------------------------------- | ------- | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--profile ` | `-p` | Workspace profile | - | +| `--email ` | - | Email address of the user to invite | - | +| `--role ` | `-r` | Role to assign (admin, editor, viewer) | - | + + + + + +#### workspace user list + +List users in a workspace + +**Usage** + +``` +tailor-sdk workspace user list [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | ------------------------------- | ------- | +| `--json` | `-j` | Output as JSON | `false` | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--profile ` | `-p` | Workspace profile | - | +| `--limit ` | `-l` | Maximum number of users to list | - | + + + + + +#### workspace user remove + +Remove a user from a workspace + +**Usage** + +``` +tailor-sdk workspace user remove [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | ----------------------------------- | ------- | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--profile ` | `-p` | Workspace profile | - | +| `--email ` | - | Email address of the user to remove | - | +| `--yes` | `-y` | Skip confirmation prompts | `false` | + + + + + +#### workspace user update + +Update a user's role in a workspace + +**Usage** + +``` +tailor-sdk workspace user update [options] +``` + +**Options** + +| Option | Alias | Description | Default | +| ------------------------------- | ----- | ------------------------------------------ | ------- | +| `--workspace-id ` | `-w` | Workspace ID | - | +| `--profile ` | `-p` | Workspace profile | - | +| `--email ` | - | Email address of the user to update | - | +| `--role ` | `-r` | New role to assign (admin, editor, viewer) | - | + + diff --git a/packages/sdk/knip.json b/packages/sdk/knip.json index f4288b9bf..07d9baaf2 100644 --- a/packages/sdk/knip.json +++ b/packages/sdk/knip.json @@ -6,6 +6,10 @@ }, "tags": ["-lintignore"], "ignore": ["scripts/**", "e2e/fixtures/**"], + "ignoreFiles": [ + "src/cli/workspace/function/index.ts", + "src/cli/workspace/function/registry/index.ts" + ], "ignoreDependencies": [ "@tailor-platform/function-kysely-tailordb", "@typescript/native-preview", diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index af5372c05..69d529ae3 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -34,7 +34,19 @@ export { remove, type RemoveOptions } from "./remove"; export { createWorkspace, type CreateWorkspaceOptions } from "./workspace/create"; export { listWorkspaces, type ListWorkspacesOptions } from "./workspace/list"; export { deleteWorkspace, type DeleteWorkspaceOptions } from "./workspace/delete"; -export type { WorkspaceInfo } from "./workspace/transform"; +export { describeWorkspace, type DescribeWorkspaceOptions } from "./workspace/describe"; +export { restoreWorkspace, type RestoreWorkspaceOptions } from "./workspace/restore"; +export type { WorkspaceInfo, WorkspaceDetails } from "./workspace/transform"; +export { listUsers, type ListUsersOptions } from "./workspace/user/list"; +export { inviteUser, type InviteUserOptions } from "./workspace/user/invite"; +export { updateUser, type UpdateUserOptions } from "./workspace/user/update"; +export { removeUser, type RemoveUserOptions } from "./workspace/user/remove"; +export type { UserInfo } from "./workspace/user/transform"; +export { listApps, type ListAppsOptions } from "./workspace/app/list"; +export { getAppHealth, type HealthOptions as GetAppHealthOptions } from "./workspace/app/health"; +export type { AppInfo, AppHealthInfo } from "./workspace/app/transform"; +export { getFunctionRegistry } from "./workspace/function/registry/get"; +export { listFunctionRegistries } from "./workspace/function/registry/list"; export { listMachineUsers, type ListMachineUsersOptions, diff --git a/packages/sdk/src/cli/utils/format.ts b/packages/sdk/src/cli/utils/format.ts index 433054230..576f34979 100644 --- a/packages/sdk/src/cli/utils/format.ts +++ b/packages/sdk/src/cli/utils/format.ts @@ -1,8 +1,26 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; import { formatDistanceToNowStrict } from "date-fns"; // eslint-disable-next-line no-restricted-imports import { getBorderCharacters, table } from "table"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; import type { TableUserConfig } from "table"; +/** + * Format a protobuf Timestamp to ISO string. + * @param timestamp - Protobuf timestamp + * @returns Date object or null if invalid + */ +export function formatTimestamp(timestamp: Timestamp | undefined): Date | null { + if (!timestamp) { + return null; + } + const date = timestampDate(timestamp); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; +} + /** * Formats a table with consistent single-line border style. * Use this instead of importing `table` directly. diff --git a/packages/sdk/src/cli/workspace/app/health.ts b/packages/sdk/src/cli/workspace/app/health.ts new file mode 100644 index 000000000..ffeaeae69 --- /dev/null +++ b/packages/sdk/src/cli/workspace/app/health.ts @@ -0,0 +1,83 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, jsonArgs, withCommonArgs, workspaceArgs } from "../../args"; +import { initOperatorClient } from "../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../context"; +import { humanizeRelativeTime } from "../../utils/format"; +import { logger } from "../../utils/logger"; +import { appHealthInfo, type AppHealthInfo } from "./transform"; + +const healthOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + name: z.string().min(1, { message: "name is required" }), +}); + +export type HealthOptions = z.input; + +async function loadOptions(options: HealthOptions) { + const result = healthOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + name: result.data.name, + }; +} + +/** + * Get application schema health status. + * @param options - Health check options + * @returns Application health information + */ +export async function getAppHealth(options: HealthOptions): Promise { + const { client, workspaceId, name } = await loadOptions(options); + + const response = await client.getApplicationSchemaHealth({ + workspaceId, + applicationName: name, + }); + + return appHealthInfo(name, response); +} + +export const healthCommand = defineCommand({ + name: "health", + description: "Check application schema health", + args: z.object({ + ...commonArgs, + ...jsonArgs, + ...workspaceArgs, + name: arg(z.string(), { + description: "Application name", + alias: "n", + }), + }), + run: withCommonArgs(async (args) => { + const health = await getAppHealth({ + workspaceId: args["workspace-id"], + profile: args.profile, + name: args.name, + }); + + const formattedHealth = args.json + ? health + : { + ...health, + currentServingSchemaUpdatedAt: humanizeRelativeTime(health.currentServingSchemaUpdatedAt), + lastAttemptAt: humanizeRelativeTime(health.lastAttemptAt), + }; + + logger.out(formattedHealth); + }), +}); diff --git a/packages/sdk/src/cli/workspace/app/index.ts b/packages/sdk/src/cli/workspace/app/index.ts new file mode 100644 index 000000000..b6a8443fb --- /dev/null +++ b/packages/sdk/src/cli/workspace/app/index.ts @@ -0,0 +1,15 @@ +import { defineCommand, runCommand } from "politty"; +import { healthCommand } from "./health"; +import { listCommand } from "./list"; + +export const appCommand = defineCommand({ + name: "app", + description: "Manage workspace applications", + subCommands: { + health: healthCommand, + list: listCommand, + }, + async run() { + await runCommand(listCommand, []); + }, +}); diff --git a/packages/sdk/src/cli/workspace/app/list.ts b/packages/sdk/src/cli/workspace/app/list.ts new file mode 100644 index 000000000..347462c0a --- /dev/null +++ b/packages/sdk/src/cli/workspace/app/list.ts @@ -0,0 +1,109 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, jsonArgs, positiveIntArg, withCommonArgs, workspaceArgs } from "../../args"; +import { initOperatorClient } from "../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../context"; +import { humanizeRelativeTime } from "../../utils/format"; +import { logger } from "../../utils/logger"; +import { appInfo, type AppInfo } from "./transform"; + +const listAppsOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + limit: z.coerce.number().int().positive().optional(), +}); + +export type ListAppsOptions = z.input; + +async function loadOptions(options: ListAppsOptions) { + const result = listAppsOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + limit: result.data.limit, + }; +} + +/** + * List applications in a workspace with an optional limit. + * @param options - Application listing options + * @returns List of applications + */ +export async function listApps(options: ListAppsOptions): Promise { + const { client, workspaceId, limit } = await loadOptions(options); + const hasLimit = limit !== undefined; + + const results: AppInfo[] = []; + let pageToken = ""; + + while (true) { + if (hasLimit && results.length >= limit!) { + break; + } + + const remaining = hasLimit ? limit! - results.length : undefined; + const pageSize = remaining !== undefined && remaining > 0 ? remaining : undefined; + + const { applications, nextPageToken } = await client.listApplications({ + workspaceId, + pageToken, + ...(pageSize !== undefined ? { pageSize } : {}), + }); + + const mapped = applications.map(appInfo); + + if (remaining !== undefined && mapped.length > remaining) { + results.push(...mapped.slice(0, remaining)); + } else { + results.push(...mapped); + } + + if (!nextPageToken) { + break; + } + pageToken = nextPageToken; + } + + return results; +} + +export const listCommand = defineCommand({ + name: "list", + description: "List applications in a workspace", + args: z.object({ + ...commonArgs, + ...jsonArgs, + ...workspaceArgs, + limit: arg(positiveIntArg.optional(), { + alias: "l", + description: "Maximum number of applications to list", + }), + }), + run: withCommonArgs(async (args) => { + const apps = await listApps({ + workspaceId: args["workspace-id"], + profile: args.profile, + limit: args.limit, + }); + + const formattedApps = args.json + ? apps + : apps.map(({ updatedAt: _, createdAt, ...rest }) => ({ + ...rest, + createdAt: humanizeRelativeTime(createdAt), + })); + + logger.out(formattedApps); + }), +}); diff --git a/packages/sdk/src/cli/workspace/app/transform.ts b/packages/sdk/src/cli/workspace/app/transform.ts new file mode 100644 index 000000000..5bf6186c3 --- /dev/null +++ b/packages/sdk/src/cli/workspace/app/transform.ts @@ -0,0 +1,73 @@ +import { + type GetApplicationSchemaHealthResponse, + GetApplicationSchemaHealthResponse_ApplicationSchemaHealthStatus, +} from "@tailor-proto/tailor/v1/application_pb"; +import { ApplicationSchemaUpdateAttemptStatus } from "@tailor-proto/tailor/v1/application_resource_pb"; +import { formatTimestamp } from "../../utils/format"; +import type { Application } from "@tailor-proto/tailor/v1/application_resource_pb"; + +export interface AppInfo { + name: string; + domain: string; + authNamespace: string; + createdAt: Date | null; + updatedAt: Date | null; +} + +export interface AppHealthInfo { + name: string; + status: string; + currentServingSchemaUpdatedAt: Date | null; + lastAttemptStatus: string; + lastAttemptAt: Date | null; + lastAttemptError: string; +} + +const statusToString = ( + status: GetApplicationSchemaHealthResponse_ApplicationSchemaHealthStatus, +): string => { + switch (status) { + case GetApplicationSchemaHealthResponse_ApplicationSchemaHealthStatus.OK: + return "ok"; + case GetApplicationSchemaHealthResponse_ApplicationSchemaHealthStatus.COMPOSITION_ERROR: + return "composition_error"; + default: + return "unknown"; + } +}; + +const attemptStatusToString = (status: ApplicationSchemaUpdateAttemptStatus): string => { + switch (status) { + case ApplicationSchemaUpdateAttemptStatus.SUCCEEDED: + return "success"; + case ApplicationSchemaUpdateAttemptStatus.FAILED: + return "failure"; + default: + return "unknown"; + } +}; + +export const appInfo = (app: Application): AppInfo => { + return { + name: app.name, + domain: app.domain, + authNamespace: app.authNamespace, + createdAt: formatTimestamp(app.createTime), + updatedAt: formatTimestamp(app.updateTime), + }; +}; + +export const appHealthInfo = ( + name: string, + health: GetApplicationSchemaHealthResponse, +): AppHealthInfo => { + const attempt = health.lastSchemaUpdateAttempt; + return { + name, + status: statusToString(health.status), + currentServingSchemaUpdatedAt: formatTimestamp(health.currentServingSchemaUpdateTime), + lastAttemptStatus: attempt ? attemptStatusToString(attempt.status) : "N/A", + lastAttemptAt: formatTimestamp(attempt?.attemptTime), + lastAttemptError: attempt?.error ?? "", + }; +}; diff --git a/packages/sdk/src/cli/workspace/describe.ts b/packages/sdk/src/cli/workspace/describe.ts new file mode 100644 index 000000000..6dd82a7fe --- /dev/null +++ b/packages/sdk/src/cli/workspace/describe.ts @@ -0,0 +1,78 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, jsonArgs, withCommonArgs } from "../args"; +import { initOperatorClient } from "../client"; +import { loadAccessToken } from "../context"; +import { humanizeRelativeTime } from "../utils/format"; +import { logger } from "../utils/logger"; +import { workspaceDetails, type WorkspaceDetails } from "./transform"; + +const describeWorkspaceOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), +}); + +export type DescribeWorkspaceOptions = z.input; + +async function loadOptions(options: DescribeWorkspaceOptions) { + const result = describeWorkspaceOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + + return { + client, + workspaceId: result.data.workspaceId, + }; +} + +/** + * Get detailed information about a workspace. + * @param options - Workspace describe options + * @returns Workspace details + */ +export async function describeWorkspace( + options: DescribeWorkspaceOptions, +): Promise { + const { client, workspaceId } = await loadOptions(options); + + const response = await client.getWorkspace({ + workspaceId, + }); + + if (!response.workspace) { + throw new Error(`Workspace "${workspaceId}" not found.`); + } + + return workspaceDetails(response.workspace); +} + +export const describeCommand = defineCommand({ + name: "describe", + description: "Show detailed information about a workspace", + args: z.object({ + ...commonArgs, + ...jsonArgs, + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + }), + run: withCommonArgs(async (args) => { + const workspace = await describeWorkspace({ + workspaceId: args["workspace-id"], + }); + + const formattedWorkspace = args.json + ? workspace + : { + ...workspace, + createdAt: humanizeRelativeTime(workspace.createdAt), + updatedAt: humanizeRelativeTime(workspace.updatedAt), + }; + + logger.out(formattedWorkspace); + }), +}); diff --git a/packages/sdk/src/cli/workspace/function/index.ts b/packages/sdk/src/cli/workspace/function/index.ts new file mode 100644 index 000000000..88d8c1eac --- /dev/null +++ b/packages/sdk/src/cli/workspace/function/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from "politty"; +// import { registryCommand } from "./registry"; + +export const functionCommand = defineCommand({ + name: "function", + description: "Manage workspace functions", + subCommands: { + // The implementation of Registry get-type commands is complete, but currently the registry is not deployed, + // resulting in always returning 0 records. This command will be enabled after the fix. + // registry: registryCommand, + }, +}); diff --git a/packages/sdk/src/cli/workspace/function/registry/get.ts b/packages/sdk/src/cli/workspace/function/registry/get.ts new file mode 100644 index 000000000..bcf2ee0e6 --- /dev/null +++ b/packages/sdk/src/cli/workspace/function/registry/get.ts @@ -0,0 +1,100 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, jsonArgs, withCommonArgs, workspaceArgs } from "../../../args"; +import { initOperatorClient } from "../../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../../context"; +import { humanizeRelativeTime } from "../../../utils/format"; +import { logger } from "../../../utils/logger"; +import { functionRegistryInfo, type FunctionRegistryInfo } from "./transform"; + +const getRegistryOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + name: z.string().min(1, { message: "name is required" }), +}); + +export type GetRegistryOptions = z.input; + +async function loadOptions(options: GetRegistryOptions) { + const result = getRegistryOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + name: result.data.name, + }; +} + +/** + * Get a function registry by name. + * @param options - Function registry get options + * @returns Function registry info + */ +export async function getFunctionRegistry( + options: GetRegistryOptions, +): Promise { + const { client, workspaceId, name } = await loadOptions(options); + + const notFoundErrorMessage = `Function "${name}" not found.`; + try { + const response = await client.getFunctionRegistry({ + workspaceId, + name, + }); + + if (!response.function) { + throw new Error(notFoundErrorMessage); + } + + return functionRegistryInfo(response.function); + } catch (error) { + if (error instanceof ConnectError && error.code === Code.NotFound) { + throw new Error(notFoundErrorMessage); + } + throw error; + } +} + +// oxlint-disable-next-line jsdoc/check-tag-names +/** @lintignore */ +export const getCommand = defineCommand({ + name: "get", + description: "Get a function registry by name", + args: z.object({ + ...commonArgs, + ...jsonArgs, + ...workspaceArgs, + name: arg(z.string(), { + description: "Function name", + alias: "n", + }), + }), + run: withCommonArgs(async (args) => { + const fn = await getFunctionRegistry({ + workspaceId: args["workspace-id"], + profile: args.profile, + name: args.name, + }); + + const formatted = args.json + ? fn + : { + ...fn, + createdAt: humanizeRelativeTime(fn.createdAt), + updatedAt: humanizeRelativeTime(fn.updatedAt), + }; + + logger.out(formatted); + }), +}); diff --git a/packages/sdk/src/cli/workspace/function/registry/index.ts b/packages/sdk/src/cli/workspace/function/registry/index.ts new file mode 100644 index 000000000..d42617caa --- /dev/null +++ b/packages/sdk/src/cli/workspace/function/registry/index.ts @@ -0,0 +1,15 @@ +import { defineCommand, runCommand } from "politty"; +import { getCommand } from "./get"; +import { listCommand } from "./list"; + +export const registryCommand = defineCommand({ + name: "registry", + description: "Manage function registry entries", + subCommands: { + get: getCommand, + list: listCommand, + }, + async run() { + await runCommand(listCommand, []); + }, +}); diff --git a/packages/sdk/src/cli/workspace/function/registry/list.ts b/packages/sdk/src/cli/workspace/function/registry/list.ts new file mode 100644 index 000000000..650d244ff --- /dev/null +++ b/packages/sdk/src/cli/workspace/function/registry/list.ts @@ -0,0 +1,113 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, jsonArgs, positiveIntArg, withCommonArgs, workspaceArgs } from "../../../args"; +import { initOperatorClient } from "../../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../../context"; +import { humanizeRelativeTime } from "../../../utils/format"; +import { logger } from "../../../utils/logger"; +import { functionRegistryInfo, type FunctionRegistryInfo } from "./transform"; + +const listRegistryOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + limit: z.coerce.number().int().positive().optional(), +}); + +export type ListRegistryOptions = z.input; + +async function loadOptions(options: ListRegistryOptions) { + const result = listRegistryOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + limit: result.data.limit, + }; +} + +/** + * List function registries in a workspace with an optional limit. + * @param options - Function registry listing options + * @returns List of function registries + */ +export async function listFunctionRegistries( + options: ListRegistryOptions, +): Promise { + const { client, workspaceId, limit } = await loadOptions(options); + const hasLimit = limit !== undefined; + + const results: FunctionRegistryInfo[] = []; + let pageToken = ""; + + while (true) { + if (hasLimit && results.length >= limit!) { + break; + } + + const remaining = hasLimit ? limit! - results.length : undefined; + const pageSize = remaining !== undefined && remaining > 0 ? remaining : undefined; + + const { functions, nextPageToken } = await client.listFunctionRegistries({ + workspaceId, + pageToken, + ...(pageSize !== undefined ? { pageSize } : {}), + }); + + const mapped = functions.map(functionRegistryInfo); + if (remaining !== undefined && mapped.length > remaining) { + results.push(...mapped.slice(0, remaining)); + } else { + results.push(...mapped); + } + + if (!nextPageToken) { + break; + } + pageToken = nextPageToken; + } + + return results; +} + +// oxlint-disable-next-line jsdoc/check-tag-names +/** @lintignore */ +export const listCommand = defineCommand({ + name: "list", + description: "List function registries in a workspace", + args: z.object({ + ...commonArgs, + ...jsonArgs, + ...workspaceArgs, + limit: arg(positiveIntArg.optional(), { + alias: "l", + description: "Maximum number of functions to list", + }), + }), + run: withCommonArgs(async (args) => { + const functions = await listFunctionRegistries({ + workspaceId: args["workspace-id"], + profile: args.profile, + limit: args.limit, + }); + + const formatted = args.json + ? functions + : functions.map(({ createdAt, updatedAt, ...rest }) => ({ + ...rest, + createdAt: humanizeRelativeTime(createdAt), + updatedAt: humanizeRelativeTime(updatedAt), + })); + + logger.out(formatted); + }), +}); diff --git a/packages/sdk/src/cli/workspace/function/registry/transform.ts b/packages/sdk/src/cli/workspace/function/registry/transform.ts new file mode 100644 index 000000000..d4c74c66d --- /dev/null +++ b/packages/sdk/src/cli/workspace/function/registry/transform.ts @@ -0,0 +1,22 @@ +import { formatTimestamp } from "../../../utils/format"; +import type { FunctionRegistry } from "@tailor-proto/tailor/v1/function_registry_pb"; + +export interface FunctionRegistryInfo { + name: string; + description: string; + sizeBytes: string; + contentHash: string; + createdAt: Date | null; + updatedAt: Date | null; +} + +export const functionRegistryInfo = (fn: FunctionRegistry): FunctionRegistryInfo => { + return { + name: fn.name, + description: fn.description, + sizeBytes: fn.sizeBytes.toString(), + contentHash: fn.contentHash, + createdAt: formatTimestamp(fn.createdAt), + updatedAt: formatTimestamp(fn.updatedAt), + }; +}; diff --git a/packages/sdk/src/cli/workspace/index.ts b/packages/sdk/src/cli/workspace/index.ts index d3469c1fc..4e140c7e4 100644 --- a/packages/sdk/src/cli/workspace/index.ts +++ b/packages/sdk/src/cli/workspace/index.ts @@ -1,15 +1,25 @@ import { defineCommand, runCommand } from "politty"; +import { appCommand } from "./app"; import { createCommand } from "./create"; import { deleteCommand } from "./delete"; +import { describeCommand } from "./describe"; +// import { functionCommand } from "./function"; import { listCommand } from "./list"; +import { restoreCommand } from "./restore"; +import { userCommand } from "./user"; export const workspaceCommand = defineCommand({ name: "workspace", description: "Manage Tailor Platform workspaces.", subCommands: { + app: appCommand, create: createCommand, delete: deleteCommand, + describe: describeCommand, + // function: functionCommand, list: listCommand, + restore: restoreCommand, + user: userCommand, }, async run() { await runCommand(listCommand, []); diff --git a/packages/sdk/src/cli/workspace/restore.ts b/packages/sdk/src/cli/workspace/restore.ts new file mode 100644 index 000000000..730fe09e5 --- /dev/null +++ b/packages/sdk/src/cli/workspace/restore.ts @@ -0,0 +1,77 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, confirmationArgs, withCommonArgs } from "../args"; +import { initOperatorClient } from "../client"; +import { loadAccessToken } from "../context"; +import { logger } from "../utils/logger"; + +const restoreWorkspaceOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), +}); + +export type RestoreWorkspaceOptions = z.input; + +async function loadOptions(options: RestoreWorkspaceOptions) { + const result = restoreWorkspaceOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + + return { + client, + workspaceId: result.data.workspaceId, + }; +} + +/** + * Restore a deleted workspace by ID. + * @param options - Workspace restore options + * @returns Promise that resolves when restoration completes + */ +export async function restoreWorkspace(options: RestoreWorkspaceOptions): Promise { + const { client, workspaceId } = await loadOptions(options); + + await client.restoreWorkspace({ + workspaceId, + }); +} + +export const restoreCommand = defineCommand({ + name: "restore", + description: "Restore a deleted workspace", + args: z.object({ + ...commonArgs, + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + ...confirmationArgs, + }), + run: withCommonArgs(async (args) => { + const { client, workspaceId } = await loadOptions({ + workspaceId: args["workspace-id"], + }); + + if (!args.yes) { + const confirmation = await logger.prompt( + `Are you sure you want to restore workspace "${workspaceId}"? (yes/no):`, + { + type: "text", + }, + ); + if (confirmation !== "yes") { + logger.info("Workspace restoration cancelled."); + return; + } + } + + await client.restoreWorkspace({ + workspaceId, + }); + + logger.success(`Workspace "${workspaceId}" restored successfully.`); + }), +}); diff --git a/packages/sdk/src/cli/workspace/transform.ts b/packages/sdk/src/cli/workspace/transform.ts index 823270dd7..e54bf20ba 100644 --- a/packages/sdk/src/cli/workspace/transform.ts +++ b/packages/sdk/src/cli/workspace/transform.ts @@ -1,5 +1,4 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; -import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { formatTimestamp } from "../utils/format"; import type { Workspace } from "@tailor-proto/tailor/v1/workspace_resource_pb"; export interface WorkspaceInfo { @@ -10,16 +9,11 @@ export interface WorkspaceInfo { updatedAt: Date | null; } -const formatTimestamp = (timestamp: Timestamp | undefined): Date | null => { - if (!timestamp) { - return null; - } - const date = timestampDate(timestamp); - if (Number.isNaN(date.getTime())) { - return null; - } - return date; -}; +export interface WorkspaceDetails extends WorkspaceInfo { + deleteProtection: boolean; + organizationId: string; + folderId: string; +} export const workspaceInfo = (workspace: Workspace): WorkspaceInfo => { return { @@ -30,3 +24,12 @@ export const workspaceInfo = (workspace: Workspace): WorkspaceInfo => { updatedAt: formatTimestamp(workspace.updateTime), }; }; + +export const workspaceDetails = (workspace: Workspace): WorkspaceDetails => { + return { + ...workspaceInfo(workspace), + deleteProtection: workspace.deleteProtection, + organizationId: workspace.organizationId, + folderId: workspace.folderId, + }; +}; diff --git a/packages/sdk/src/cli/workspace/user/index.ts b/packages/sdk/src/cli/workspace/user/index.ts new file mode 100644 index 000000000..120c4e5ff --- /dev/null +++ b/packages/sdk/src/cli/workspace/user/index.ts @@ -0,0 +1,19 @@ +import { defineCommand, runCommand } from "politty"; +import { inviteCommand } from "./invite"; +import { listCommand } from "./list"; +import { removeCommand } from "./remove"; +import { updateCommand } from "./update"; + +export const userCommand = defineCommand({ + name: "user", + description: "Manage workspace users", + subCommands: { + invite: inviteCommand, + list: listCommand, + remove: removeCommand, + update: updateCommand, + }, + async run() { + await runCommand(listCommand, []); + }, +}); diff --git a/packages/sdk/src/cli/workspace/user/invite.ts b/packages/sdk/src/cli/workspace/user/invite.ts new file mode 100644 index 000000000..76c9b7649 --- /dev/null +++ b/packages/sdk/src/cli/workspace/user/invite.ts @@ -0,0 +1,78 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, withCommonArgs, workspaceArgs } from "../../args"; +import { initOperatorClient } from "../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../context"; +import { logger } from "../../utils/logger"; +import { stringToRole, validRoles } from "./transform"; + +const inviteUserOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + email: z.email({ message: "email must be a valid email address" }), + role: z.enum(validRoles, { message: `role must be one of: ${validRoles.join(", ")}` }), +}); + +export type InviteUserOptions = z.input; + +async function loadOptions(options: InviteUserOptions) { + const result = inviteUserOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + email: result.data.email, + role: stringToRole(result.data.role), + }; +} + +/** + * Invite a user to a workspace. + * @param options - User invite options + * @returns Promise that resolves when invitation is sent + */ +export async function inviteUser(options: InviteUserOptions): Promise { + const { client, workspaceId, email, role } = await loadOptions(options); + + await client.inviteWorkspacePlatformUser({ + workspaceId, + email, + role, + }); +} + +export const inviteCommand = defineCommand({ + name: "invite", + description: "Invite a user to a workspace", + args: z.object({ + ...commonArgs, + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to invite", + }), + role: arg(z.enum(validRoles), { + description: `Role to assign (${validRoles.join(", ")})`, + alias: "r", + }), + }), + run: withCommonArgs(async (args) => { + await inviteUser({ + workspaceId: args["workspace-id"], + profile: args.profile, + email: args.email, + role: args.role as (typeof validRoles)[number], + }); + + logger.success(`User "${args.email}" invited successfully with role "${args.role}".`); + }), +}); diff --git a/packages/sdk/src/cli/workspace/user/list.ts b/packages/sdk/src/cli/workspace/user/list.ts new file mode 100644 index 000000000..0ea6f4b42 --- /dev/null +++ b/packages/sdk/src/cli/workspace/user/list.ts @@ -0,0 +1,101 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, jsonArgs, positiveIntArg, withCommonArgs, workspaceArgs } from "../../args"; +import { initOperatorClient } from "../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../context"; +import { logger } from "../../utils/logger"; +import { userInfo, type UserInfo } from "./transform"; + +const listUsersOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + limit: z.coerce.number().int().positive().optional(), +}); + +export type ListUsersOptions = z.input; + +async function loadOptions(options: ListUsersOptions) { + const result = listUsersOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + limit: result.data.limit, + }; +} + +/** + * List users in a workspace with an optional limit. + * @param options - User listing options + * @returns List of workspace users + */ +export async function listUsers(options: ListUsersOptions): Promise { + const { client, workspaceId, limit } = await loadOptions(options); + const hasLimit = limit !== undefined; + + const results: UserInfo[] = []; + let pageToken = ""; + + while (true) { + if (hasLimit && results.length >= limit!) { + break; + } + + const remaining = hasLimit ? limit! - results.length : undefined; + const pageSize = remaining !== undefined && remaining > 0 ? remaining : undefined; + + const { workspacePlatformUsers, nextPageToken } = await client.listWorkspacePlatformUsers({ + workspaceId, + pageToken, + ...(pageSize !== undefined ? { pageSize } : {}), + }); + + const mapped = workspacePlatformUsers.map(userInfo); + + if (remaining !== undefined && mapped.length > remaining) { + results.push(...mapped.slice(0, remaining)); + } else { + results.push(...mapped); + } + + if (!nextPageToken) { + break; + } + pageToken = nextPageToken; + } + + return results; +} + +export const listCommand = defineCommand({ + name: "list", + description: "List users in a workspace", + args: z.object({ + ...commonArgs, + ...jsonArgs, + ...workspaceArgs, + limit: arg(positiveIntArg.optional(), { + alias: "l", + description: "Maximum number of users to list", + }), + }), + run: withCommonArgs(async (args) => { + const users = await listUsers({ + workspaceId: args["workspace-id"], + profile: args.profile, + limit: args.limit, + }); + + logger.out(users); + }), +}); diff --git a/packages/sdk/src/cli/workspace/user/remove.ts b/packages/sdk/src/cli/workspace/user/remove.ts new file mode 100644 index 000000000..85ad0a1e6 --- /dev/null +++ b/packages/sdk/src/cli/workspace/user/remove.ts @@ -0,0 +1,83 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, confirmationArgs, withCommonArgs, workspaceArgs } from "../../args"; +import { initOperatorClient } from "../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../context"; +import { logger } from "../../utils/logger"; + +const removeUserOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + email: z.string().email({ message: "email must be a valid email address" }), +}); + +export type RemoveUserOptions = z.input; + +async function loadOptions(options: RemoveUserOptions) { + const result = removeUserOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + email: result.data.email, + }; +} + +/** + * Remove a user from a workspace. + * @param options - User remove options + * @returns Promise that resolves when removal completes + */ +export async function removeUser(options: RemoveUserOptions): Promise { + const { client, workspaceId, email } = await loadOptions(options); + + await client.removeWorkspacePlatformUser({ + workspaceId, + email, + }); +} + +export const removeCommand = defineCommand({ + name: "remove", + description: "Remove a user from a workspace", + args: z.object({ + ...commonArgs, + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to remove", + }), + ...confirmationArgs, + }), + run: withCommonArgs(async (args) => { + if (!args.yes) { + const confirmation = await logger.prompt( + `Are you sure you want to remove user "${args.email}" from the workspace? (yes/no):`, + { + type: "text", + }, + ); + if (confirmation !== "yes") { + logger.info("User removal cancelled."); + return; + } + } + + await removeUser({ + workspaceId: args["workspace-id"], + profile: args.profile, + email: args.email, + }); + + logger.success(`User "${args.email}" removed from workspace.`); + }), +}); diff --git a/packages/sdk/src/cli/workspace/user/transform.ts b/packages/sdk/src/cli/workspace/user/transform.ts new file mode 100644 index 000000000..563fa5325 --- /dev/null +++ b/packages/sdk/src/cli/workspace/user/transform.ts @@ -0,0 +1,44 @@ +import { WorkspacePlatformUserRole } from "@tailor-proto/tailor/v1/workspace_resource_pb"; +import type { WorkspacePlatformUser } from "@tailor-proto/tailor/v1/workspace_resource_pb"; + +export interface UserInfo { + userId: string; + email: string; + role: string; +} + +const roleToString = (role: WorkspacePlatformUserRole): string => { + switch (role) { + case WorkspacePlatformUserRole.ADMIN: + return "admin"; + case WorkspacePlatformUserRole.EDITOR: + return "editor"; + case WorkspacePlatformUserRole.VIEWER: + return "viewer"; + default: + return "unknown"; + } +}; + +export const stringToRole = (role: string): WorkspacePlatformUserRole => { + switch (role.toLowerCase()) { + case "admin": + return WorkspacePlatformUserRole.ADMIN; + case "editor": + return WorkspacePlatformUserRole.EDITOR; + case "viewer": + return WorkspacePlatformUserRole.VIEWER; + default: + throw new Error(`Invalid role: ${role}. Valid roles: admin, editor, viewer`); + } +}; + +export const userInfo = (user: WorkspacePlatformUser): UserInfo => { + return { + userId: user.platformUser?.userId ?? "", + email: user.platformUser?.email ?? "", + role: roleToString(user.role), + }; +}; + +export const validRoles = ["admin", "editor", "viewer"] as const; diff --git a/packages/sdk/src/cli/workspace/user/update.ts b/packages/sdk/src/cli/workspace/user/update.ts new file mode 100644 index 000000000..4717bdd2d --- /dev/null +++ b/packages/sdk/src/cli/workspace/user/update.ts @@ -0,0 +1,78 @@ +import { arg, defineCommand } from "politty"; +import { z } from "zod"; +import { commonArgs, withCommonArgs, workspaceArgs } from "../../args"; +import { initOperatorClient } from "../../client"; +import { loadAccessToken, loadWorkspaceId } from "../../context"; +import { logger } from "../../utils/logger"; +import { stringToRole, validRoles } from "./transform"; + +const updateUserOptionsSchema = z.object({ + workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), + profile: z.string().optional(), + email: z.string().email({ message: "email must be a valid email address" }), + role: z.enum(validRoles, { message: `role must be one of: ${validRoles.join(", ")}` }), +}); + +export type UpdateUserOptions = z.input; + +async function loadOptions(options: UpdateUserOptions) { + const result = updateUserOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + + const accessToken = await loadAccessToken(); + const client = await initOperatorClient(accessToken); + const workspaceId = loadWorkspaceId({ + workspaceId: result.data.workspaceId, + profile: result.data.profile, + }); + + return { + client, + workspaceId, + email: result.data.email, + role: stringToRole(result.data.role), + }; +} + +/** + * Update a user's role in a workspace. + * @param options - User update options + * @returns Promise that resolves when update completes + */ +export async function updateUser(options: UpdateUserOptions): Promise { + const { client, workspaceId, email, role } = await loadOptions(options); + + await client.updateWorkspacePlatformUser({ + workspaceId, + email, + role, + }); +} + +export const updateCommand = defineCommand({ + name: "update", + description: "Update a user's role in a workspace", + args: z.object({ + ...commonArgs, + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to update", + }), + role: arg(z.enum(validRoles), { + description: `New role to assign (${validRoles.join(", ")})`, + alias: "r", + }), + }), + run: withCommonArgs(async (args) => { + await updateUser({ + workspaceId: args["workspace-id"], + profile: args.profile, + email: args.email, + role: args.role as (typeof validRoles)[number], + }); + + logger.success(`User "${args.email}" updated to role "${args.role}".`); + }), +});