diff --git a/README.md b/README.md index 525ef21..d36142b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe tag-value list` | Search tag values in the knowledge graph | | `observe skill list` | List AI agent skills | | `observe skill view` | View skill details and content | +| `observe workspace view` | View current workspace ID, name, and metadata | | `observe query` | Execute OPAL queries on datasets | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration | diff --git a/src/app.ts b/src/app.ts index df05fd6..5e960cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import { queryCommand } from "./commands/query.js"; import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; import { tagValueRoutes } from "./commands/tag-value/index.js"; +import { workspaceRoutes } from "./commands/workspace/index.js"; import { CURRENT_CLI_VERSION } from "./lib/constants.js"; /** Top-level route map containing all CLI commands */ @@ -36,6 +37,7 @@ export const routes = buildRouteMap({ content: contentRoutes, "ingest-token": ingestTokenRoutes, datastream: datastreamRoutes, + workspace: workspaceRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index e01619b..1fb3c4d 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -37,12 +37,14 @@ async function status( let valid = false; let workspaceName: string | null = null; + let workspaceId: string | null = null; let errorMessage: string | null = null; try { const { workspace } = await getDefaultWorkspace(config); valid = true; workspaceName = workspace?.label ?? null; + workspaceId = workspace?.id ?? null; } catch (error) { if (error instanceof GqlApiError) { errorMessage = `${error.statusCode}: ${error.message}`; @@ -61,6 +63,7 @@ async function status( configPath: getConfigPath(), ...(config.tokenId && { tokenId: config.tokenId }), ...(workspaceName && { workspace: workspaceName }), + ...(workspaceId && { workspaceId }), ...(errorMessage && { error: errorMessage }), }; writer.write(JSON.stringify(result, null, 2)); @@ -73,17 +76,20 @@ async function status( writer.error("Authentication invalid\n"); } - writer.write(chalk.dim(" Customer ID ") + config.customerId); - writer.write(chalk.dim(" Domain ") + config.domain); - writer.write(chalk.dim(" API URL ") + baseUrl); - writer.write(chalk.dim(" Token ") + maskedToken); + writer.write(chalk.dim(" Customer ID ") + config.customerId); + writer.write(chalk.dim(" Domain ") + config.domain); + writer.write(chalk.dim(" API URL ") + baseUrl); + writer.write(chalk.dim(" Token ") + maskedToken); if (config.tokenId) { - writer.write(chalk.dim(" Token ID ") + config.tokenId); + writer.write(chalk.dim(" Token ID ") + config.tokenId); } if (workspaceName) { - writer.write(chalk.dim(" Workspace ") + workspaceName); + writer.write(chalk.dim(" Workspace ") + workspaceName); } - writer.write(chalk.dim(" Config ") + getConfigPath()); + if (workspaceId) { + writer.write(chalk.dim(" Workspace ID ") + workspaceId); + } + writer.write(chalk.dim(" Config ") + getConfigPath()); if (errorMessage) { writer.write("\n" + chalk.red(" Error: ") + errorMessage); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts new file mode 100644 index 0000000..db4726e --- /dev/null +++ b/src/commands/workspace/index.ts @@ -0,0 +1,12 @@ +import { buildRouteMap } from "@stricli/core"; +import { viewCommand } from "./view"; + +export const workspaceRoutes = buildRouteMap({ + routes: { + view: viewCommand, + }, + docs: { + brief: "View workspace information", + fullDescription: "View workspace information including the workspace ID.", + }, +}); diff --git a/src/commands/workspace/view.test.ts b/src/commands/workspace/view.test.ts new file mode 100644 index 0000000..e215129 --- /dev/null +++ b/src/commands/workspace/view.test.ts @@ -0,0 +1,162 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const gqlModulePath = resolve(repoRoot, "src/gql/workspace/view-workspace.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const viewWorkspaceFn = mock( + ( + _config: unknown, + ): Promise<{ + id: string; + label: string; + timezone: string; + locale: string; + createdDate: string; + } | null> => + Promise.resolve({ + id: "42587555", + label: "Default", + timezone: "America/Los_Angeles", + locale: "en_US", + createdDate: "2024-01-01T00:00:00Z", + }), +); + +let view: (typeof import("./view"))["view"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + viewWorkspace: viewWorkspaceFn, +} as Parameters<(typeof import("./view"))["view"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(gqlModulePath, () => ({ + viewWorkspace: viewWorkspaceFn, + })); + + const mod = await import("./view.ts"); + view = mod.view; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("workspace view", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + viewWorkspaceFn.mockClear(); + }); + + test("outputs workspace id, label, and metadata", async () => { + const { context, stdout } = createMockContext(); + await view.call(context, {}, deps); + + expect(viewWorkspaceFn).toHaveBeenCalledTimes(1); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("42587555"); + expect(output.label).toBe("Default"); + expect(output.timezone).toBe("America/Los_Angeles"); + expect(output.locale).toBe("en_US"); + }); + + test("exits with code 1 when no workspace is found", async () => { + viewWorkspaceFn.mockImplementationOnce(() => Promise.resolve(null)); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("No workspace found"); + }); + + test("exits with code 1 on API error", async () => { + viewWorkspaceFn.mockImplementationOnce(() => { + const err = new Error("Unauthorized"); + err.name = "GqlApiError"; + (err as unknown as { statusCode: number }).statusCode = 401; + throw err; + }); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/workspace/view.ts b/src/commands/workspace/view.ts new file mode 100644 index 0000000..81e95f1 --- /dev/null +++ b/src/commands/workspace/view.ts @@ -0,0 +1,59 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { viewWorkspace } from "../../gql/workspace/view-workspace"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface ViewWorkspaceDeps { + loadConfig?: typeof loadConfig; + viewWorkspace?: typeof viewWorkspace; +} + +export async function view( + this: LocalContext, + _flags: Record, + deps: ViewWorkspaceDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + viewWorkspace: viewWorkspaceImpl = viewWorkspace, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const workspace = await viewWorkspaceImpl(config); + if (!workspace) { + writer.error("No workspace found"); + process.exit(1); + return; + } + writer.write(JSON.stringify(workspace, null, 2)); + } catch (error) { + if (error instanceof GqlApiError) { + writer.error(`API Error (${error.statusCode}): ${error.message}`); + } else { + const message = error instanceof Error ? error.message : String(error); + writer.error(`Error: ${message}`); + } + process.exit(1); + } +} + +export const viewCommand = buildCommand({ + loader: async () => view, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: {}, + }, + docs: { + brief: "View the current workspace", + fullDescription: + "Displays the current workspace ID, name, timezone, locale, and creation date.\n\n" + + "Examples:\n" + + " observe workspace view", + }, +}); diff --git a/src/gql/workspace/view-workspace.graphql b/src/gql/workspace/view-workspace.graphql new file mode 100644 index 0000000..f0e7812 --- /dev/null +++ b/src/gql/workspace/view-workspace.graphql @@ -0,0 +1,11 @@ +query ViewWorkspace { + currentUser { + workspaces { + id + label + timezone + locale + createdDate + } + } +} diff --git a/src/gql/workspace/view-workspace.ts b/src/gql/workspace/view-workspace.ts new file mode 100644 index 0000000..8ddea89 --- /dev/null +++ b/src/gql/workspace/view-workspace.ts @@ -0,0 +1,17 @@ +import type { Config } from "../../lib/config"; +import { + ViewWorkspaceDocument, + type ViewWorkspaceQuery, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlWorkspaceDetail = NonNullable< + ViewWorkspaceQuery["currentUser"] +>["workspaces"][number]; + +export async function viewWorkspace( + config: Config, +): Promise { + const response = await executeGraphQL(config, ViewWorkspaceDocument, {}); + return response.data.currentUser?.workspaces[0] ?? null; +}