Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -36,6 +37,7 @@ export const routes = buildRouteMap({
"data-connection": dataConnectionRoutes,
datastream: datastreamRoutes,
"datastream-token": datastreamTokenRoutes,
worksheet: worksheetRoutes,
cli: cliRoutes,
},
defaultCommand: "help",
Expand Down
141 changes: 141 additions & 0 deletions src/commands/worksheet/create.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }).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<string, unknown> }).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");
});
});
124 changes: 124 additions & 0 deletions src/commands/worksheet/create.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown>;
try {
input = JSON.parse(raw) as Record<string, unknown>;
} 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",
},
});
Loading