diff --git a/AGENTS.md b/AGENTS.md index 0198696..5c485ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ src/ │ ├── skill/ # AI agent skill commands (list, view) │ ├── tag-key/ # Tag key commands (list) │ ├── tag-value/ # Tag value commands (list) +│ ├── worksheet/ # Worksheet commands (list, get, create, delete) │ ├── query.ts # OPAL query execution │ └── help.ts # Help command ├── gql/ # GraphQL layer @@ -58,6 +59,7 @@ src/ │ ├── ingest-token/ # Ingest token queries/mutations │ ├── metric/ # Metric queries │ ├── workspace/ # Workspace queries +│ ├── worksheet/ # Worksheet queries/mutations │ ├── gql-request.ts # GraphQL client/executor │ └── gql-codegen.config.ts # Codegen configuration ├── rest/ # REST API layer diff --git a/README.md b/README.md index a7f6baa..7252ef8 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe datastream view` | View a datastream by ID | | `observe datastream update` | Update a datastream | | `observe datastream-token check-status` | Poll a datastream token until ingest data arrives | +| `observe worksheet list` | List worksheets | +| `observe worksheet get` | Get a worksheet by ID (includes stages) | +| `observe worksheet create` | Create a worksheet from a JSON file | +| `observe worksheet delete` | Delete a worksheet by ID | | `observe cli install` | Configure shell integration (PATH, completions) | | `observe cli uninstall` | Remove shell integration | | `observe cli upgrade` | Upgrade to the latest version | diff --git a/src/app.ts b/src/app.ts index a51b600..f5525d9 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 { worksheetRoutes } from "./commands/worksheet/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({ "data-connection": dataConnectionRoutes, datastream: datastreamRoutes, "datastream-token": datastreamTokenRoutes, + worksheet: worksheetRoutes, cli: cliRoutes, }, defaultCommand: "help", diff --git a/src/commands/worksheet/create.test.ts b/src/commands/worksheet/create.test.ts new file mode 100644 index 0000000..e2c69cf --- /dev/null +++ b/src/commands/worksheet/create.test.ts @@ -0,0 +1,141 @@ +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/worksheet/save-worksheet.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const saveWorksheetFn = mock((_config: unknown, _variables: unknown) => + Promise.resolve({ id: "ws-9", name: "New Sheet", workspaceId: "41000001" }), +); + +let create: (typeof import("./create"))["create"]; + +beforeAll(async () => { + void mock.module(gqlModulePath, () => ({ + saveWorksheet: saveWorksheetFn, + })); + const mod = await import("./create.ts"); + create = mod.create; +}); + +afterAll(() => { + mock.restore(); +}); + +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 }; +} + +function depsWith(fileContents: string) { + return { + loadConfig: loadConfigFn, + readFile: () => fileContents, + } as Parameters<(typeof import("./create"))["create"]>[3]; +} + +describe("worksheet create", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + saveWorksheetFn.mockClear(); + }); + + test("reads file, strips read-only fields, and saves", async () => { + const { context, stdout } = createMockContext(); + const file = JSON.stringify({ + name: "New Sheet", + workspaceId: "41000001", + stages: [{ id: "s0", pipeline: "filter true" }], + updatedDate: "should-be-stripped", + }); + await create.call(context, {}, "ws.json", depsWith(file)); + + expect(saveWorksheetFn).toHaveBeenCalledTimes(1); + const [, variables] = saveWorksheetFn.mock.calls[0]!; + const wks = (variables as { wks: Record }).wks; + expect(wks.updatedDate).toBeUndefined(); + expect(wks.name).toBe("New Sheet"); + expect(stdout.join("")).toContain("Created: New Sheet (id: ws-9)"); + }); + + test("--workspace overrides the file's workspaceId", async () => { + const { context } = createMockContext(); + const file = JSON.stringify({ + name: "New Sheet", + workspaceId: "old", + stages: [{ id: "s0", pipeline: "filter true" }], + }); + await create.call(context, { workspace: "99" }, "ws.json", depsWith(file)); + + const [, variables] = saveWorksheetFn.mock.calls[0]!; + const wks = (variables as { wks: Record }).wks; + expect(wks.workspaceId).toBe("99"); + }); + + test("exits 1 when required fields missing", async () => { + const { context, stderr, getExitCode } = createMockContext(); + const file = JSON.stringify({ name: "No stages", workspaceId: "1" }); + try { + await create.call(context, {}, "ws.json", depsWith(file)); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("stages"); + expect(saveWorksheetFn).not.toHaveBeenCalled(); + }); + + test("exits 1 on invalid JSON", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, {}, "ws.json", depsWith("{not json")); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("could not parse JSON"); + }); +}); diff --git a/src/commands/worksheet/create.ts b/src/commands/worksheet/create.ts new file mode 100644 index 0000000..0a32120 --- /dev/null +++ b/src/commands/worksheet/create.ts @@ -0,0 +1,124 @@ +import { buildCommand } from "@stricli/core"; +import * as fs from "node:fs"; +import type { LocalContext } from "../../context"; +import { + saveWorksheet, + type WorksheetInput, +} from "../../gql/worksheet/save-worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface CreateWorksheetFlags { + workspace?: string; +} + +export interface CreateWorksheetDeps { + loadConfig?: typeof loadConfig; + saveWorksheet?: typeof saveWorksheet; + readFile?: (path: string) => string; +} + +// Fields the Observe API returns but rejects on the saveWorksheet input. +const READ_ONLY_FIELDS = ["updatedDate"] as const; + +export async function create( + this: LocalContext, + flags: CreateWorksheetFlags, + file: string, + deps: CreateWorksheetDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + saveWorksheet: saveWorksheetImpl = saveWorksheet, + readFile = (path: string) => fs.readFileSync(path, "utf-8"), + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + + let raw: string; + try { + raw = readFile(file); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`could not read file "${file}": ${message}`); + } + + let input: Record; + try { + input = JSON.parse(raw) as Record; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`could not parse JSON from "${file}": ${message}`); + } + + for (const field of READ_ONLY_FIELDS) { + delete input[field]; + } + + // A --workspace flag overrides the workspaceId in the file. + if (flags.workspace) { + input.workspaceId = flags.workspace; + } + + if (!input.name) { + throw new Error("worksheet input requires a name"); + } + if (!input.workspaceId) { + throw new Error( + "worksheet input requires a workspaceId (set it in the file or pass --workspace)", + ); + } + if (!input.stages) { + throw new Error("worksheet input requires stages"); + } + + const result = await saveWorksheetImpl(config, { + wks: input as WorksheetInput, + }); + + writer.write(`Created: ${result.name} (id: ${result.id})`); + } 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 createCommand = buildCommand({ + loader: async () => create, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to a JSON file describing the worksheet (WorksheetInput)", + parse: String, + }, + ], + }, + flags: { + workspace: { + kind: "parsed", + parse: String, + brief: "Workspace ID to create the worksheet in (overrides the file)", + optional: true, + }, + }, + }, + docs: { + brief: "Create a worksheet from a JSON file", + fullDescription: + "Create a worksheet from a JSON file describing a WorksheetInput.\n\n" + + "The file must contain at least: name, workspaceId, and stages.\n" + + "Each stage is an object with an id and a pipeline (OPAL).\n\n" + + "Example:\n" + + " observe worksheet create ./worksheet.json\n" + + " observe worksheet create ./worksheet.json --workspace 41000001", + }, +}); diff --git a/src/commands/worksheet/delete.test.ts b/src/commands/worksheet/delete.test.ts new file mode 100644 index 0000000..b0d70ed --- /dev/null +++ b/src/commands/worksheet/delete.test.ts @@ -0,0 +1,111 @@ +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/worksheet/delete-worksheet.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const deleteWorksheetFn = mock((_config: unknown, _variables: unknown) => + Promise.resolve({ success: true, errorMessage: "" }), +); + +let deleteWorksheetCommand: (typeof import("./delete"))["deleteWorksheetCommand"]; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters< + (typeof import("./delete"))["deleteWorksheetCommand"] +>[3]; + +beforeAll(async () => { + void mock.module(gqlModulePath, () => ({ + deleteWorksheet: deleteWorksheetFn, + })); + const mod = await import("./delete.ts"); + deleteWorksheetCommand = mod.deleteWorksheetCommand; +}); + +afterAll(() => { + mock.restore(); +}); + +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("worksheet delete", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + deleteWorksheetFn.mockClear(); + }); + + test("deletes and prints confirmation", async () => { + const { context, stdout } = createMockContext(); + await deleteWorksheetCommand.call(context, {}, "ws-1", deps); + + expect(deleteWorksheetFn).toHaveBeenCalledTimes(1); + const [, variables] = deleteWorksheetFn.mock.calls[0]!; + expect(variables).toEqual({ id: "ws-1" }); + expect(stdout.join("")).toContain("Deleted worksheet ws-1"); + }); + + test("exits 1 when the API reports failure", async () => { + deleteWorksheetFn.mockImplementationOnce(() => + Promise.resolve({ success: false, errorMessage: "in use" }), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteWorksheetCommand.call(context, {}, "ws-1", deps); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("in use"); + }); +}); diff --git a/src/commands/worksheet/delete.ts b/src/commands/worksheet/delete.ts new file mode 100644 index 0000000..55e6c2f --- /dev/null +++ b/src/commands/worksheet/delete.ts @@ -0,0 +1,61 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { deleteWorksheet } from "../../gql/worksheet/delete-worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +export interface DeleteWorksheetDeps { + loadConfig?: typeof loadConfig; + deleteWorksheet?: typeof deleteWorksheet; +} + +export async function deleteWorksheetCommand( + this: LocalContext, + _flags: Record, + id: string, + deps: DeleteWorksheetDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + deleteWorksheet: deleteWorksheetImpl = deleteWorksheet, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const result = await deleteWorksheetImpl(config, { id }); + + if (result && result.success === false) { + throw new Error(result.errorMessage || "delete failed"); + } + + writer.success(`Deleted worksheet ${id}`); + } 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 deleteCommand = buildCommand({ + loader: async () => deleteWorksheetCommand, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Worksheet ID", + parse: String, + }, + ], + }, + flags: {}, + }, + docs: { + brief: "Delete a worksheet by ID", + }, +}); diff --git a/src/commands/worksheet/get.test.ts b/src/commands/worksheet/get.test.ts new file mode 100644 index 0000000..be4181e --- /dev/null +++ b/src/commands/worksheet/get.test.ts @@ -0,0 +1,113 @@ +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/worksheet/get-worksheet.ts"); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const getWorksheetFn = mock((_config: unknown, _variables: unknown) => + Promise.resolve({ + id: "ws-1", + name: "Sheet A", + workspaceId: "41000001", + updatedDate: "x", + stages: [{ id: "stage-0", pipeline: "filter true" }], + }), +); + +let get: (typeof import("./get"))["get"]; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./get"))["get"]>[3]; + +beforeAll(async () => { + void mock.module(gqlModulePath, () => ({ + getWorksheet: getWorksheetFn, + })); + const mod = await import("./get.ts"); + get = mod.get; +}); + +afterAll(() => { + mock.restore(); +}); + +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("worksheet get", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + getWorksheetFn.mockClear(); + }); + + test("outputs worksheet JSON including stages", async () => { + const { context, stdout } = createMockContext(); + await get.call(context, {}, "ws-1", deps); + + expect(getWorksheetFn).toHaveBeenCalledTimes(1); + const [, variables] = getWorksheetFn.mock.calls[0]!; + expect(variables).toEqual({ id: "ws-1" }); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("ws-1"); + expect(output.stages).toHaveLength(1); + expect(output.stages[0].id).toBe("stage-0"); + }); + + test("exits 1 when worksheet not found", async () => { + getWorksheetFn.mockImplementationOnce(() => Promise.resolve(null)); + const { context, stderr, getExitCode } = createMockContext(); + try { + await get.call(context, {}, "missing", deps); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("not found"); + }); +}); diff --git a/src/commands/worksheet/get.ts b/src/commands/worksheet/get.ts new file mode 100644 index 0000000..9545344 --- /dev/null +++ b/src/commands/worksheet/get.ts @@ -0,0 +1,74 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { getWorksheet } from "../../gql/worksheet/get-worksheet"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; + +interface GetWorksheetFlags { + format?: "json"; +} + +export interface GetWorksheetDeps { + loadConfig?: typeof loadConfig; + getWorksheet?: typeof getWorksheet; +} + +export async function get( + this: LocalContext, + flags: GetWorksheetFlags, + id: string, + deps: GetWorksheetDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + getWorksheet: getWorksheetImpl = getWorksheet, + } = deps; + const { process, writer } = this; + + try { + const config = loadConfigImpl(); + const worksheet = await getWorksheetImpl(config, { id }); + + if (worksheet === null) { + writer.error(`Error: worksheet ${id} not found`); + process.exit(1); + return; + } + + writer.write(JSON.stringify(worksheet, 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 getCommand = buildCommand({ + loader: async () => get, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Worksheet ID", + parse: String, + }, + ], + }, + flags: { + format: { + kind: "enum", + values: ["json"], + brief: "Output format (json)", + optional: true, + }, + }, + }, + docs: { + brief: "Get a worksheet by ID (includes stages)", + }, +}); diff --git a/src/commands/worksheet/index.ts b/src/commands/worksheet/index.ts new file mode 100644 index 0000000..0a98320 --- /dev/null +++ b/src/commands/worksheet/index.ts @@ -0,0 +1,26 @@ +import { buildRouteMap } from "@stricli/core"; +import { createCommand } from "./create"; +import { deleteCommand } from "./delete"; +import { getCommand } from "./get"; +import { listCommand } from "./list"; + +export const worksheetRoutes = buildRouteMap({ + routes: { + list: listCommand, + get: getCommand, + create: createCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage worksheets", + fullDescription: [ + "List, get, create, or delete worksheets in Observe.", + "", + "Commands:", + " list List worksheets", + " get Get a worksheet by ID (includes stages)", + " create Create a worksheet from a JSON file", + " delete Delete a worksheet by ID", + ].join("\n"), + }, +}); diff --git a/src/commands/worksheet/list.test.ts b/src/commands/worksheet/list.test.ts new file mode 100644 index 0000000..a0962eb --- /dev/null +++ b/src/commands/worksheet/list.test.ts @@ -0,0 +1,159 @@ +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/worksheet/list-worksheets.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const listWorksheetsFn = mock((_config: unknown, _variables: unknown) => + Promise.resolve([ + { id: "ws-1", name: "Sheet A", workspaceId: "41000001", updatedDate: "x" }, + { id: "ws-2", name: "Sheet B", workspaceId: "41000001", updatedDate: "y" }, + ]), +); + +let list: (typeof import("./list"))["list"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./list"))["list"]>[2]; + +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, () => ({ + listWorksheets: listWorksheetsFn, + })); + + const mod = await import("./list.ts"); + list = mod.list; +}); + +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("worksheet list", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + listWorksheetsFn.mockClear(); + }); + + test("outputs JSON array with --format json", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { format: "json" }, deps); + + expect(listWorksheetsFn).toHaveBeenCalledTimes(1); + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(2); + expect(output[0].name).toBe("Sheet A"); + }); + + test("passes workspace into search terms as a list", async () => { + const { context } = createMockContext(); + await list.call(context, { workspace: "41000001", format: "json" }, deps); + + const [, variables] = listWorksheetsFn.mock.calls[0]!; + expect(variables).toEqual({ terms: { workspaceId: ["41000001"] } }); + }); + + test("filters by name substring (case-insensitive) client-side", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { name: "sheet a", format: "json" }, deps); + + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(1); + expect(output[0].name).toBe("Sheet A"); + }); + + test("emits CSV with --format csv", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { format: "csv" }, deps); + + const output = stdout.join(""); + expect(output).toContain("id,name,workspaceId,updatedDate"); + expect(output).toContain("ws-1,Sheet A"); + }); + + test("exits with code 1 on API error", async () => { + listWorksheetsFn.mockImplementationOnce(() => { + throw new Error("Server error"); + }); + + const { context, stderr, getExitCode } = createMockContext(); + try { + await list.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/worksheet/list.ts b/src/commands/worksheet/list.ts new file mode 100644 index 0000000..24d91e5 --- /dev/null +++ b/src/commands/worksheet/list.ts @@ -0,0 +1,127 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { listWorksheets, type GqlWorksheet } from "../../gql/worksheet/list-worksheets"; +import { GqlApiError } from "../../gql/gql-request"; +import { loadConfig } from "../../lib/config"; +import { filterByName } from "../../lib/filter"; +import { renderAsCSV } from "../../lib/formatters/csv"; +import { + createColumnHelper, + formatTable, + type ColumnDef, +} from "../../lib/formatters/table"; +import { muteStatusWriter } from "../../lib/writer"; + +type OutputFormat = "json" | "csv"; + +interface ListWorksheetsFlags { + name?: string; + workspace?: string; + format?: OutputFormat; +} + +export interface ListWorksheetsDeps { + loadConfig?: typeof loadConfig; + listWorksheets?: typeof listWorksheets; +} + +const col = createColumnHelper(); + +const COLUMNS: ColumnDef[] = [ + col.accessor((row) => row.id, { + header: "ID", + format: (value) => chalk.cyan(value), + }), + col.accessor((row) => row.name, { header: "NAME", flex: true }), +]; + +export async function list( + this: LocalContext, + flags: ListWorksheetsFlags, + deps: ListWorksheetsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + listWorksheets: listWorksheetsImpl = listWorksheets, + } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { + muted: flags.format === "json" || flags.format === "csv", + }); + + try { + const config = loadConfigImpl(); + + // worksheetSearch.terms.workspaceId is a list; pass the selected + // workspace when provided so the server scopes the search. + const worksheets = await listWorksheetsImpl(config, { + terms: { + ...(flags.workspace ? { workspaceId: [flags.workspace] } : {}), + ...(flags.name ? { name: [flags.name] } : {}), + }, + }); + + // The server search is fuzzy; apply the case-insensitive substring filter + // client-side to match the old CLI's contract. + const filtered = filterByName(worksheets, flags.name); + + if (flags.format === "json") { + writer.write(JSON.stringify(filtered, null, 2)); + return; + } + + if (flags.format === "csv") { + writer.write(renderAsCSV(filtered)); + return; + } + + if (filtered.length === 0) { + writer.warn("No worksheets found."); + return; + } + + writer.write(formatTable(filtered, COLUMNS)); + } 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 listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "Filter worksheets by name substring (case-insensitive)", + optional: true, + }, + workspace: { + kind: "parsed", + parse: String, + brief: "Workspace ID to list worksheets from", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + }, + }, + docs: { + brief: "List worksheets", + }, +}); diff --git a/src/gql/worksheet/delete-worksheet.graphql b/src/gql/worksheet/delete-worksheet.graphql new file mode 100644 index 0000000..28f90ee --- /dev/null +++ b/src/gql/worksheet/delete-worksheet.graphql @@ -0,0 +1,6 @@ +mutation DeleteWorksheet($id: ObjectId!) { + deleteWorksheet(wks: $id) { + success + errorMessage + } +} diff --git a/src/gql/worksheet/delete-worksheet.ts b/src/gql/worksheet/delete-worksheet.ts new file mode 100644 index 0000000..3b7ab8d --- /dev/null +++ b/src/gql/worksheet/delete-worksheet.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import { + DeleteWorksheetDocument, + type DeleteWorksheetMutation, + type DeleteWorksheetMutationVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlDeleteWorksheetResult = + DeleteWorksheetMutation["deleteWorksheet"]; + +export async function deleteWorksheet( + config: Config, + variables: DeleteWorksheetMutationVariables, +): Promise { + const response = await executeGraphQL( + config, + DeleteWorksheetDocument, + variables, + ); + return response.data.deleteWorksheet; +} diff --git a/src/gql/worksheet/get-worksheet.graphql b/src/gql/worksheet/get-worksheet.graphql new file mode 100644 index 0000000..87a9cf6 --- /dev/null +++ b/src/gql/worksheet/get-worksheet.graphql @@ -0,0 +1,12 @@ +query GetWorksheet($id: ObjectId!) { + worksheet(id: $id) { + id + name + workspaceId + updatedDate + stages { + id + pipeline + } + } +} diff --git a/src/gql/worksheet/get-worksheet.ts b/src/gql/worksheet/get-worksheet.ts new file mode 100644 index 0000000..3197c0a --- /dev/null +++ b/src/gql/worksheet/get-worksheet.ts @@ -0,0 +1,21 @@ +import type { Config } from "../../lib/config"; +import { + GetWorksheetDocument, + type GetWorksheetQuery, + type GetWorksheetQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlWorksheetDetail = NonNullable; + +export async function getWorksheet( + config: Config, + variables: GetWorksheetQueryVariables, +): Promise { + const response = await executeGraphQL( + config, + GetWorksheetDocument, + variables, + ); + return response.data.worksheet; +} diff --git a/src/gql/worksheet/list-worksheets.graphql b/src/gql/worksheet/list-worksheets.graphql new file mode 100644 index 0000000..d2b0f29 --- /dev/null +++ b/src/gql/worksheet/list-worksheets.graphql @@ -0,0 +1,12 @@ +query ListWorksheets($terms: DWSearchInput!) { + worksheetSearch(terms: $terms) { + worksheets { + worksheet { + id + name + workspaceId + updatedDate + } + } + } +} diff --git a/src/gql/worksheet/list-worksheets.ts b/src/gql/worksheet/list-worksheets.ts new file mode 100644 index 0000000..0b3acd6 --- /dev/null +++ b/src/gql/worksheet/list-worksheets.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import { + ListWorksheetsDocument, + type ListWorksheetsQuery, + type ListWorksheetsQueryVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type GqlWorksheet = + ListWorksheetsQuery["worksheetSearch"]["worksheets"][number]["worksheet"]; + +export async function listWorksheets( + config: Config, + variables: ListWorksheetsQueryVariables, +): Promise { + const response = await executeGraphQL( + config, + ListWorksheetsDocument, + variables, + ); + return response.data.worksheetSearch.worksheets.map((r) => r.worksheet); +} diff --git a/src/gql/worksheet/save-worksheet.graphql b/src/gql/worksheet/save-worksheet.graphql new file mode 100644 index 0000000..364bc26 --- /dev/null +++ b/src/gql/worksheet/save-worksheet.graphql @@ -0,0 +1,7 @@ +mutation SaveWorksheet($wks: WorksheetInput!) { + saveWorksheet(wks: $wks) { + id + name + workspaceId + } +} diff --git a/src/gql/worksheet/save-worksheet.ts b/src/gql/worksheet/save-worksheet.ts new file mode 100644 index 0000000..5cbce0a --- /dev/null +++ b/src/gql/worksheet/save-worksheet.ts @@ -0,0 +1,23 @@ +import type { Config } from "../../lib/config"; +import { + SaveWorksheetDocument, + type SaveWorksheetMutation, + type SaveWorksheetMutationVariables, +} from "../generated/graphql"; +import { executeGraphQL } from "../gql-request"; + +export type { WorksheetInput } from "../generated/graphql"; + +export type GqlSavedWorksheet = SaveWorksheetMutation["saveWorksheet"]; + +export async function saveWorksheet( + config: Config, + variables: SaveWorksheetMutationVariables, +): Promise { + const response = await executeGraphQL( + config, + SaveWorksheetDocument, + variables, + ); + return response.data.saveWorksheet; +}