@aaronshaf/plane 0.1.11 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.11",
6
+ "version": "1.0.0",
7
7
  "description": "CLI for the Plane project management API",
8
8
  "keywords": [
9
9
  "plane",
package/src/api.ts CHANGED
@@ -38,6 +38,8 @@ function request(
38
38
  return Effect.tryPromise({
39
39
  try: async () => {
40
40
  const { token, host, workspace } = getConfig();
41
+ if (!token) throw new Error("No API token configured. Run 'plane init' or set PLANE_API_TOKEN.");
42
+ if (!workspace) throw new Error("No workspace configured. Run 'plane init' or set PLANE_WORKSPACE.");
41
43
  let url = `${host}/api/v1/workspaces/${workspace}/${path}`;
42
44
 
43
45
  // Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/)
package/src/app.ts CHANGED
@@ -44,7 +44,7 @@ ALL SUBCOMMANDS
44
44
  cycles list | issues (list, add)
45
45
  modules list | issues (list, add, remove)
46
46
  intake list | accept | reject
47
- pages list | get
47
+ pages list | get | create | update | delete | archive | unarchive | lock | unlock | duplicate
48
48
  states list List workflow states for a project
49
49
  labels list List labels for a project
50
50
  members list List members of a project
@@ -74,5 +74,5 @@ FOR AI AGENTS / BOTS
74
74
 
75
75
  export const cli = Command.run(plane, {
76
76
  name: "plane",
77
- version: "0.1.0",
77
+ version: "0.1.11",
78
78
  });
@@ -79,7 +79,7 @@ const titleUpdateOption = Options.optional(Options.text("title")).pipe(
79
79
  );
80
80
 
81
81
  const descriptionOption = Options.optional(Options.text("description")).pipe(
82
- Options.withDescription("Issue description (plain text, stored as HTML)"),
82
+ Options.withDescription("Issue description as HTML (e.g. '<p>Details</p>')"),
83
83
  );
84
84
 
85
85
  const assigneeOption = Options.optional(Options.text("assignee")).pipe(
@@ -120,26 +120,25 @@ export function issueUpdateHandler({
120
120
 
121
121
  const body: IssueUpdatePayload = {};
122
122
 
123
- if (state._tag === "Some") {
123
+ if (Option.isSome(state)) {
124
124
  body.state = yield* getStateId(projectId, state.value);
125
125
  }
126
- if (priority._tag === "Some") {
126
+ if (Option.isSome(priority)) {
127
127
  body.priority = priority.value;
128
128
  }
129
- if (title._tag === "Some") {
129
+ if (Option.isSome(title)) {
130
130
  body.name = title.value;
131
131
  }
132
- if (description._tag === "Some") {
133
- const escaped = escapeHtmlText(description.value);
134
- body.description_html = `<p>${escaped}</p>`;
132
+ if (Option.isSome(description)) {
133
+ body.description_html = description.value;
135
134
  }
136
135
  if (noAssignee) {
137
136
  body.assignees = [];
138
- } else if (assignee._tag === "Some") {
137
+ } else if (Option.isSome(assignee)) {
139
138
  const memberId = yield* getMemberId(assignee.value);
140
139
  body.assignees = [memberId];
141
140
  }
142
- if (label._tag === "Some") {
141
+ if (Option.isSome(label)) {
143
142
  const labelId = yield* getLabelId(projectId, label.value);
144
143
  body.label_ids = [labelId];
145
144
  }
@@ -234,7 +233,7 @@ const createStateOption = Options.optional(Options.text("state")).pipe(
234
233
  const createDescriptionOption = Options.optional(
235
234
  Options.text("description"),
236
235
  ).pipe(
237
- Options.withDescription("Issue description (plain text, stored as HTML)"),
236
+ Options.withDescription("Issue description as HTML (e.g. '<p>Details</p>')"),
238
237
  );
239
238
 
240
239
  const createAssigneeOption = Options.optional(Options.text("assignee")).pipe(
@@ -265,18 +264,17 @@ export function issueCreateHandler({
265
264
  return Effect.gen(function* () {
266
265
  const { key, id: projectId } = yield* resolveProject(project);
267
266
  const body: IssueCreatePayload = { name: title };
268
- if (priority._tag === "Some") body.priority = priority.value;
269
- if (state._tag === "Some")
267
+ if (Option.isSome(priority)) body.priority = priority.value;
268
+ if (Option.isSome(state))
270
269
  body.state = yield* getStateId(projectId, state.value);
271
- if (description._tag === "Some") {
272
- const escaped = escapeHtmlText(description.value);
273
- body.description_html = `<p>${escaped}</p>`;
270
+ if (Option.isSome(description)) {
271
+ body.description_html = description.value;
274
272
  }
275
- if (assignee._tag === "Some") {
273
+ if (Option.isSome(assignee)) {
276
274
  const memberId = yield* getMemberId(assignee.value);
277
275
  body.assignees = [memberId];
278
276
  }
279
- if (label._tag === "Some") {
277
+ if (Option.isSome(label)) {
280
278
  const labelId = yield* getLabelId(projectId, label.value);
281
279
  body.label_ids = [labelId];
282
280
  }
@@ -403,7 +401,7 @@ export function issueLinkAddHandler({
403
401
  const { projectId, seq } = yield* parseIssueRef(ref);
404
402
  const issue = yield* findIssueBySeq(projectId, seq);
405
403
  const body: Record<string, string> = { url };
406
- if (title._tag === "Some") body["title"] = title.value;
404
+ if (Option.isSome(title)) body["title"] = title.value;
407
405
  const raw = yield* api.post(
408
406
  `projects/${projectId}/issues/${issue.id}/issue-links/`,
409
407
  body,
@@ -627,7 +625,7 @@ export function issueWorklogsAddHandler({
627
625
  const { projectId, seq } = yield* parseIssueRef(ref);
628
626
  const issue = yield* findIssueBySeq(projectId, seq);
629
627
  const body: WorklogPayload = { duration };
630
- if (description._tag === "Some") body.description = description.value;
628
+ if (Option.isSome(description)) body.description = description.value;
631
629
  const raw = yield* api.post(
632
630
  `projects/${projectId}/issues/${issue.id}/worklogs/`,
633
631
  body,
@@ -1,5 +1,5 @@
1
- import { Command, Args } from "@effect/cli";
2
- import { Console, Effect } from "effect";
1
+ import { Command, Args, Options } from "@effect/cli";
2
+ import { Console, Effect, Option } from "effect";
3
3
  import { api, decodeOrFail } from "../api.js";
4
4
  import { PagesResponseSchema, PageSchema } from "../config.js";
5
5
  import { resolveProject } from "../resolve.js";
@@ -9,6 +9,25 @@ const projectArg = Args.text({ name: "project" }).pipe(
9
9
  Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"),
10
10
  );
11
11
 
12
+ const pageIdArg = Args.text({ name: "page-id" }).pipe(
13
+ Args.withDescription("Page UUID (from 'plane pages list')"),
14
+ );
15
+
16
+ const nameOption = Options.text("name").pipe(
17
+ Options.withDescription("Page name/title"),
18
+ );
19
+
20
+ const nameOptionalOption = Options.optional(Options.text("name")).pipe(
21
+ Options.withDescription("New page name/title"),
22
+ );
23
+
24
+ const descriptionOption = Options.optional(Options.text("description")).pipe(
25
+ Options.withDescription("Page description as HTML (e.g. '<p>Hello</p>')"),
26
+ );
27
+
28
+ interface PageCreatePayload { name: string; description_html?: string; }
29
+ interface PageUpdatePayload { name?: string; description_html?: string; }
30
+
12
31
  // --- pages list ---
13
32
 
14
33
  export function pagesListHandler({ project }: { project: string }) {
@@ -48,10 +67,6 @@ export const pagesList = Command.make(
48
67
 
49
68
  // --- pages get ---
50
69
 
51
- const pageIdArg = Args.text({ name: "page-id" }).pipe(
52
- Args.withDescription("Page UUID (from 'plane pages list')"),
53
- );
54
-
55
70
  export function pagesGetHandler({
56
71
  project,
57
72
  pageId,
@@ -77,11 +92,241 @@ export const pagesGet = Command.make(
77
92
  ),
78
93
  );
79
94
 
95
+ // --- pages create ---
96
+
97
+ export function pagesCreateHandler({
98
+ project,
99
+ name,
100
+ description,
101
+ }: {
102
+ project: string;
103
+ name: string;
104
+ description: Option.Option<string>;
105
+ }) {
106
+ return Effect.gen(function* () {
107
+ const { id } = yield* resolveProject(project);
108
+ const body: PageCreatePayload = { name };
109
+ if (Option.isSome(description)) {
110
+ body.description_html = description.value;
111
+ }
112
+ const raw = yield* api.post(`projects/${id}/pages/`, body);
113
+ const page = yield* decodeOrFail(PageSchema, raw);
114
+ yield* Console.log(`Created page ${page.id}: ${page.name}`);
115
+ });
116
+ }
117
+
118
+ export const pagesCreate = Command.make(
119
+ "create",
120
+ { project: projectArg, name: nameOption, description: descriptionOption },
121
+ pagesCreateHandler,
122
+ ).pipe(
123
+ Command.withDescription(
124
+ "Create a new page.\n\nExample:\n plane pages create --name \"My Page\" PROJ",
125
+ ),
126
+ );
127
+
128
+ // --- pages update ---
129
+
130
+ export function pagesUpdateHandler({
131
+ project,
132
+ pageId,
133
+ name,
134
+ description,
135
+ }: {
136
+ project: string;
137
+ pageId: string;
138
+ name: Option.Option<string>;
139
+ description: Option.Option<string>;
140
+ }) {
141
+ return Effect.gen(function* () {
142
+ if (Option.isNone(name) && Option.isNone(description)) {
143
+ yield* Effect.fail(new Error("provide at least --name or --description"));
144
+ }
145
+ const { id } = yield* resolveProject(project);
146
+ const body: PageUpdatePayload = {};
147
+ if (Option.isSome(name)) body.name = name.value;
148
+ if (Option.isSome(description)) body.description_html = description.value;
149
+ const raw = yield* api.patch(`projects/${id}/pages/${pageId}/`, body);
150
+ const page = yield* decodeOrFail(PageSchema, raw);
151
+ yield* Console.log(`Updated page ${page.id}: ${page.name}`);
152
+ });
153
+ }
154
+
155
+ export const pagesUpdate = Command.make(
156
+ "update",
157
+ { project: projectArg, pageId: pageIdArg, name: nameOptionalOption, description: descriptionOption },
158
+ pagesUpdateHandler,
159
+ ).pipe(
160
+ Command.withDescription(
161
+ "Update a page's name or description.\n\nExample:\n plane pages update --name \"New Title\" PROJ <page-id>",
162
+ ),
163
+ );
164
+
165
+ // --- pages delete ---
166
+
167
+ export function pagesDeleteHandler({
168
+ project,
169
+ pageId,
170
+ }: {
171
+ project: string;
172
+ pageId: string;
173
+ }) {
174
+ return Effect.gen(function* () {
175
+ const { id } = yield* resolveProject(project);
176
+ yield* api.delete(`projects/${id}/pages/${pageId}/`);
177
+ yield* Console.log(`Deleted page ${pageId}`);
178
+ });
179
+ }
180
+
181
+ export const pagesDelete = Command.make(
182
+ "delete",
183
+ { project: projectArg, pageId: pageIdArg },
184
+ pagesDeleteHandler,
185
+ ).pipe(
186
+ Command.withDescription(
187
+ "Delete a page.\n\nExample:\n plane pages delete PROJ <page-id>",
188
+ ),
189
+ );
190
+
191
+ // --- pages archive ---
192
+
193
+ export function pagesArchiveHandler({
194
+ project,
195
+ pageId,
196
+ }: {
197
+ project: string;
198
+ pageId: string;
199
+ }) {
200
+ return Effect.gen(function* () {
201
+ const { id } = yield* resolveProject(project);
202
+ yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {});
203
+ yield* Console.log(`Archived page ${pageId}`);
204
+ });
205
+ }
206
+
207
+ export const pagesArchive = Command.make(
208
+ "archive",
209
+ { project: projectArg, pageId: pageIdArg },
210
+ pagesArchiveHandler,
211
+ ).pipe(
212
+ Command.withDescription(
213
+ "Archive a page.\n\nExample:\n plane pages archive PROJ <page-id>",
214
+ ),
215
+ );
216
+
217
+ // --- pages unarchive ---
218
+
219
+ export function pagesUnarchiveHandler({
220
+ project,
221
+ pageId,
222
+ }: {
223
+ project: string;
224
+ pageId: string;
225
+ }) {
226
+ return Effect.gen(function* () {
227
+ const { id } = yield* resolveProject(project);
228
+ yield* api.delete(`projects/${id}/pages/${pageId}/archive/`);
229
+ yield* Console.log(`Unarchived page ${pageId}`);
230
+ });
231
+ }
232
+
233
+ export const pagesUnarchive = Command.make(
234
+ "unarchive",
235
+ { project: projectArg, pageId: pageIdArg },
236
+ pagesUnarchiveHandler,
237
+ ).pipe(
238
+ Command.withDescription(
239
+ "Unarchive a page.\n\nExample:\n plane pages unarchive PROJ <page-id>",
240
+ ),
241
+ );
242
+
243
+ // --- pages lock ---
244
+
245
+ export function pagesLockHandler({
246
+ project,
247
+ pageId,
248
+ }: {
249
+ project: string;
250
+ pageId: string;
251
+ }) {
252
+ return Effect.gen(function* () {
253
+ const { id } = yield* resolveProject(project);
254
+ yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {});
255
+ yield* Console.log(`Locked page ${pageId}`);
256
+ });
257
+ }
258
+
259
+ export const pagesLock = Command.make(
260
+ "lock",
261
+ { project: projectArg, pageId: pageIdArg },
262
+ pagesLockHandler,
263
+ ).pipe(
264
+ Command.withDescription(
265
+ "Lock a page (prevent edits).\n\nExample:\n plane pages lock PROJ <page-id>",
266
+ ),
267
+ );
268
+
269
+ // --- pages unlock ---
270
+
271
+ export function pagesUnlockHandler({
272
+ project,
273
+ pageId,
274
+ }: {
275
+ project: string;
276
+ pageId: string;
277
+ }) {
278
+ return Effect.gen(function* () {
279
+ const { id } = yield* resolveProject(project);
280
+ yield* api.delete(`projects/${id}/pages/${pageId}/lock/`);
281
+ yield* Console.log(`Unlocked page ${pageId}`);
282
+ });
283
+ }
284
+
285
+ export const pagesUnlock = Command.make(
286
+ "unlock",
287
+ { project: projectArg, pageId: pageIdArg },
288
+ pagesUnlockHandler,
289
+ ).pipe(
290
+ Command.withDescription(
291
+ "Unlock a page.\n\nExample:\n plane pages unlock PROJ <page-id>",
292
+ ),
293
+ );
294
+
295
+ // --- pages duplicate ---
296
+
297
+ export function pagesDuplicateHandler({
298
+ project,
299
+ pageId,
300
+ }: {
301
+ project: string;
302
+ pageId: string;
303
+ }) {
304
+ return Effect.gen(function* () {
305
+ const { id } = yield* resolveProject(project);
306
+ const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {});
307
+ const page = yield* decodeOrFail(PageSchema, raw);
308
+ yield* Console.log(`Duplicated page ${page.id}: ${page.name}`);
309
+ });
310
+ }
311
+
312
+ export const pagesDuplicate = Command.make(
313
+ "duplicate",
314
+ { project: projectArg, pageId: pageIdArg },
315
+ pagesDuplicateHandler,
316
+ ).pipe(
317
+ Command.withDescription(
318
+ "Duplicate a page.\n\nExample:\n plane pages duplicate PROJ <page-id>",
319
+ ),
320
+ );
321
+
80
322
  // --- pages (parent) ---
81
323
 
82
324
  export const pages = Command.make("pages").pipe(
83
325
  Command.withDescription(
84
- "Manage project pages (documentation). Subcommands: list, get\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ <page-id>",
326
+ "Manage project pages (documentation). Subcommands: list, get, create, update, delete, archive, unarchive, lock, unlock, duplicate\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ <page-id>",
85
327
  ),
86
- Command.withSubcommands([pagesList, pagesGet]),
328
+ Command.withSubcommands([
329
+ pagesList, pagesGet, pagesCreate, pagesUpdate, pagesDelete,
330
+ pagesArchive, pagesUnarchive, pagesLock, pagesUnlock, pagesDuplicate,
331
+ ]),
87
332
  );
package/src/config.ts CHANGED
@@ -179,7 +179,7 @@ export const PageSchema = Schema.Struct({
179
179
  name: Schema.String,
180
180
  description_html: Schema.optional(Schema.NullOr(Schema.String)),
181
181
  created_at: Schema.String,
182
- updated_at: Schema.optional(Schema.String),
182
+ updated_at: Schema.optional(Schema.NullOr(Schema.String)),
183
183
  });
184
184
  export type Page = typeof PageSchema.Type;
185
185
 
@@ -595,11 +595,11 @@ describe("issueCreate description", () => {
595
595
  );
596
596
 
597
597
  expect((postedBody as { description_html?: string }).description_html).toBe(
598
- "<p>Some context here</p>",
598
+ "Some context here",
599
599
  );
600
600
  });
601
601
 
602
- it("HTML-escapes angle brackets in description", async () => {
602
+ it("passes raw HTML description as-is", async () => {
603
603
  let postedBody: unknown;
604
604
  server.use(
605
605
  http.post(
@@ -621,10 +621,10 @@ describe("issueCreate description", () => {
621
621
  await Effect.runPromise(
622
622
  issueCreateHandler({
623
623
  project: "ACME",
624
- title: "XSS test",
624
+ title: "HTML test",
625
625
  priority: Option.none(),
626
626
  state: Option.none(),
627
- description: Option.some("<script>alert(1)</script>"),
627
+ description: Option.some("<p>Raw <b>HTML</b></p>"),
628
628
  assignee: Option.none(),
629
629
  label: Option.none(),
630
630
  }),
@@ -632,10 +632,7 @@ describe("issueCreate description", () => {
632
632
 
633
633
  expect(
634
634
  (postedBody as { description_html?: string }).description_html,
635
- ).toContain("&lt;script&gt;");
636
- expect(
637
- (postedBody as { description_html?: string }).description_html,
638
- ).not.toContain("<script>");
635
+ ).toBe("<p>Raw <b>HTML</b></p>");
639
636
  });
640
637
  });
641
638
 
@@ -674,10 +671,10 @@ describe("issueUpdate description", () => {
674
671
 
675
672
  expect(
676
673
  (patchedBody as { description_html?: string }).description_html,
677
- ).toBe("<p>Updated description</p>");
674
+ ).toBe("Updated description");
678
675
  });
679
676
 
680
- it("HTML-escapes angle brackets in update description", async () => {
677
+ it("passes raw HTML as-is in update description", async () => {
681
678
  let patchedBody: unknown;
682
679
  server.use(
683
680
  http.patch(
@@ -711,10 +708,7 @@ describe("issueUpdate description", () => {
711
708
 
712
709
  expect(
713
710
  (patchedBody as { description_html?: string }).description_html,
714
- ).toContain("&lt;b&gt;");
715
- expect(
716
- (patchedBody as { description_html?: string }).description_html,
717
- ).not.toContain("<b>");
711
+ ).toBe("<b>bold</b>");
718
712
  });
719
713
  });
720
714
 
@@ -1030,11 +1024,11 @@ describe("--description argv parsing", () => {
1030
1024
  ]);
1031
1025
  expect(logs.join("\n")).toContain("Created");
1032
1026
  expect((postedBody as { description_html?: string }).description_html).toBe(
1033
- "<p>Hello world</p>",
1027
+ "Hello world",
1034
1028
  );
1035
1029
  });
1036
1030
 
1037
- it("issue create HTML-escapes & in description via argv", async () => {
1031
+ it("issue create passes raw HTML description via argv", async () => {
1038
1032
  let postedBody: unknown;
1039
1033
  server.use(
1040
1034
  http.post(
@@ -1056,12 +1050,12 @@ describe("--description argv parsing", () => {
1056
1050
  "issue",
1057
1051
  "create",
1058
1052
  "--description",
1059
- "a & b",
1053
+ "<p>Raw HTML</p>",
1060
1054
  "ACME",
1061
- "Ampersand test",
1055
+ "HTML test",
1062
1056
  ]);
1063
1057
  expect((postedBody as { description_html?: string }).description_html).toBe(
1064
- "<p>a &amp; b</p>",
1058
+ "<p>Raw HTML</p>",
1065
1059
  );
1066
1060
  });
1067
1061
 
@@ -1086,6 +1080,6 @@ describe("--description argv parsing", () => {
1086
1080
  await runCli(["issue", "update", "--description", "New desc", "ACME-29"]);
1087
1081
  expect(
1088
1082
  (patchedBody as { description_html?: string }).description_html,
1089
- ).toBe("<p>New desc</p>");
1083
+ ).toBe("New desc");
1090
1084
  });
1091
1085
  });
@@ -7,7 +7,7 @@ import {
7
7
  expect,
8
8
  it,
9
9
  } from "bun:test";
10
- import { Effect } from "effect";
10
+ import { Effect, Option } from "effect";
11
11
  import { http, HttpResponse } from "msw";
12
12
  import { setupServer } from "msw/node";
13
13
  import { _clearProjectCache } from "@/resolve";
@@ -34,6 +34,14 @@ const PAGES = [
34
34
  },
35
35
  ];
36
36
 
37
+ const NEW_PAGE = {
38
+ id: "pg-new",
39
+ name: "New Page",
40
+ description_html: null,
41
+ created_at: "2025-02-01T10:00:00Z",
42
+ updated_at: "2025-02-01T10:00:00Z",
43
+ };
44
+
37
45
  const server = setupServer(
38
46
  http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
39
47
  HttpResponse.json({ results: PROJECTS }),
@@ -136,3 +144,240 @@ describe("pagesGet", () => {
136
144
  expect(parsed.description_html).toContain("architecture");
137
145
  });
138
146
  });
147
+
148
+ describe("pagesCreate", () => {
149
+ it("creates a page and logs confirmation", async () => {
150
+ let postedBody: unknown;
151
+ server.use(
152
+ http.post(
153
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`,
154
+ async ({ request }) => {
155
+ postedBody = await request.json();
156
+ return HttpResponse.json(NEW_PAGE);
157
+ },
158
+ ),
159
+ );
160
+ const { pagesCreateHandler } = await import("@/commands/pages");
161
+ const logs: string[] = [];
162
+ const orig = console.log;
163
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
164
+ try {
165
+ await Effect.runPromise(
166
+ pagesCreateHandler({
167
+ project: "ACME",
168
+ name: "New Page",
169
+ description: Option.none(),
170
+ }),
171
+ );
172
+ } finally {
173
+ console.log = orig;
174
+ }
175
+ expect((postedBody as { name?: string }).name).toBe("New Page");
176
+ expect(logs.join("\n")).toContain("Created page pg-new");
177
+ });
178
+
179
+ it("sends description_html when provided", async () => {
180
+ let postedBody: unknown;
181
+ server.use(
182
+ http.post(
183
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`,
184
+ async ({ request }) => {
185
+ postedBody = await request.json();
186
+ return HttpResponse.json(NEW_PAGE);
187
+ },
188
+ ),
189
+ );
190
+ const { pagesCreateHandler } = await import("@/commands/pages");
191
+ await Effect.runPromise(
192
+ pagesCreateHandler({
193
+ project: "ACME",
194
+ name: "New Page",
195
+ description: Option.some("<p>Hello</p>"),
196
+ }),
197
+ );
198
+ expect((postedBody as { description_html?: string }).description_html).toBe(
199
+ "<p>Hello</p>",
200
+ );
201
+ });
202
+ });
203
+
204
+ describe("pagesUpdate", () => {
205
+ it("updates a page name", async () => {
206
+ let patchedBody: unknown;
207
+ server.use(
208
+ http.patch(
209
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/`,
210
+ async ({ request }) => {
211
+ patchedBody = await request.json();
212
+ return HttpResponse.json({ ...PAGES[0], name: "Updated Name" });
213
+ },
214
+ ),
215
+ );
216
+ const { pagesUpdateHandler } = await import("@/commands/pages");
217
+ const logs: string[] = [];
218
+ const orig = console.log;
219
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
220
+ try {
221
+ await Effect.runPromise(
222
+ pagesUpdateHandler({
223
+ project: "ACME",
224
+ pageId: "pg1",
225
+ name: Option.some("Updated Name"),
226
+ description: Option.none(),
227
+ }),
228
+ );
229
+ } finally {
230
+ console.log = orig;
231
+ }
232
+ expect((patchedBody as { name?: string }).name).toBe("Updated Name");
233
+ expect(logs.join("\n")).toContain("Updated page");
234
+ });
235
+
236
+ it("fails when no options provided", async () => {
237
+ const { pagesUpdateHandler } = await import("@/commands/pages");
238
+ await expect(
239
+ Effect.runPromise(
240
+ pagesUpdateHandler({
241
+ project: "ACME",
242
+ pageId: "pg1",
243
+ name: Option.none(),
244
+ description: Option.none(),
245
+ }),
246
+ ),
247
+ ).rejects.toThrow("provide at least --name or --description");
248
+ });
249
+ });
250
+
251
+ describe("pagesDelete", () => {
252
+ it("deletes a page and logs confirmation", async () => {
253
+ server.use(
254
+ http.delete(
255
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/`,
256
+ () => new HttpResponse(null, { status: 204 }),
257
+ ),
258
+ );
259
+ const { pagesDeleteHandler } = await import("@/commands/pages");
260
+ const logs: string[] = [];
261
+ const orig = console.log;
262
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
263
+ try {
264
+ await Effect.runPromise(
265
+ pagesDeleteHandler({ project: "ACME", pageId: "pg1" }),
266
+ );
267
+ } finally {
268
+ console.log = orig;
269
+ }
270
+ expect(logs.join("\n")).toContain("Deleted page pg1");
271
+ });
272
+ });
273
+
274
+ describe("pagesArchive / pagesUnarchive", () => {
275
+ it("archives a page", async () => {
276
+ server.use(
277
+ http.post(
278
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/archive/`,
279
+ () => new HttpResponse(null, { status: 204 }),
280
+ ),
281
+ );
282
+ const { pagesArchiveHandler } = await import("@/commands/pages");
283
+ const logs: string[] = [];
284
+ const orig = console.log;
285
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
286
+ try {
287
+ await Effect.runPromise(
288
+ pagesArchiveHandler({ project: "ACME", pageId: "pg1" }),
289
+ );
290
+ } finally {
291
+ console.log = orig;
292
+ }
293
+ expect(logs.join("\n")).toContain("Archived page pg1");
294
+ });
295
+
296
+ it("unarchives a page", async () => {
297
+ server.use(
298
+ http.delete(
299
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/archive/`,
300
+ () => new HttpResponse(null, { status: 204 }),
301
+ ),
302
+ );
303
+ const { pagesUnarchiveHandler } = await import("@/commands/pages");
304
+ const logs: string[] = [];
305
+ const orig = console.log;
306
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
307
+ try {
308
+ await Effect.runPromise(
309
+ pagesUnarchiveHandler({ project: "ACME", pageId: "pg1" }),
310
+ );
311
+ } finally {
312
+ console.log = orig;
313
+ }
314
+ expect(logs.join("\n")).toContain("Unarchived page pg1");
315
+ });
316
+ });
317
+
318
+ describe("pagesLock / pagesUnlock", () => {
319
+ it("locks a page", async () => {
320
+ server.use(
321
+ http.post(
322
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/lock/`,
323
+ () => new HttpResponse(null, { status: 204 }),
324
+ ),
325
+ );
326
+ const { pagesLockHandler } = await import("@/commands/pages");
327
+ const logs: string[] = [];
328
+ const orig = console.log;
329
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
330
+ try {
331
+ await Effect.runPromise(
332
+ pagesLockHandler({ project: "ACME", pageId: "pg1" }),
333
+ );
334
+ } finally {
335
+ console.log = orig;
336
+ }
337
+ expect(logs.join("\n")).toContain("Locked page pg1");
338
+ });
339
+
340
+ it("unlocks a page", async () => {
341
+ server.use(
342
+ http.delete(
343
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/lock/`,
344
+ () => new HttpResponse(null, { status: 204 }),
345
+ ),
346
+ );
347
+ const { pagesUnlockHandler } = await import("@/commands/pages");
348
+ const logs: string[] = [];
349
+ const orig = console.log;
350
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
351
+ try {
352
+ await Effect.runPromise(
353
+ pagesUnlockHandler({ project: "ACME", pageId: "pg1" }),
354
+ );
355
+ } finally {
356
+ console.log = orig;
357
+ }
358
+ expect(logs.join("\n")).toContain("Unlocked page pg1");
359
+ });
360
+ });
361
+
362
+ describe("pagesDuplicate", () => {
363
+ it("duplicates a page and logs confirmation", async () => {
364
+ server.use(
365
+ http.post(
366
+ `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/duplicate/`,
367
+ () => HttpResponse.json({ ...NEW_PAGE, id: "pg-dup", name: "New Page (copy)" }),
368
+ ),
369
+ );
370
+ const { pagesDuplicateHandler } = await import("@/commands/pages");
371
+ const logs: string[] = [];
372
+ const orig = console.log;
373
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
374
+ try {
375
+ await Effect.runPromise(
376
+ pagesDuplicateHandler({ project: "ACME", pageId: "pg1" }),
377
+ );
378
+ } finally {
379
+ console.log = orig;
380
+ }
381
+ expect(logs.join("\n")).toContain("Duplicated page pg-dup");
382
+ });
383
+ });