From 7105a988feb837973ce59004007100833c061d72 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Mon, 30 Mar 2026 22:20:20 +0700 Subject: [PATCH 01/20] Add layered config and saved project workflows --- .gitignore | 3 +- README.md | 38 +++- SKILL.md | 46 ++++- src/api.ts | 52 ++--- src/app.ts | 40 +++- src/commands/cycles.ts | 18 +- src/commands/init.ts | 380 +++++++++++++++++++++++++++++++---- src/commands/intake.ts | 20 +- src/commands/issue.ts | 22 +- src/commands/issues.ts | 16 +- src/commands/labels.ts | 14 +- src/commands/local.ts | 9 + src/commands/modules.ts | 18 +- src/commands/pages.ts | 54 +++-- src/commands/projects.ts | 163 ++++++++++++++- src/commands/states.ts | 12 +- src/resolve.ts | 24 ++- src/user-config.ts | 248 +++++++++++++++++++++++ tests/api.test.ts | 19 +- tests/issue-commands.test.ts | 46 ++++- tests/projects.test.ts | 235 ++++++++++++++++++++++ tests/resolve.test.ts | 37 +++- tests/user-config.test.ts | 127 ++++++++++++ 23 files changed, 1441 insertions(+), 200 deletions(-) create mode 100644 src/commands/local.ts create mode 100644 src/user-config.ts create mode 100644 tests/projects.test.ts create mode 100644 tests/user-config.test.ts diff --git a/.gitignore b/.gitignore index 40557fa..3d3a8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,12 @@ node_modules/ dist/ .env *.env +.plane/ # Local AI/editor customization files .github/copilot-instructions.md .github/instructions/ .github/prompts/ .github/agents/ -.vscode/docs/ +.vscode/ diff --git a/README.md b/README.md index 5894658..7adf333 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,26 @@ bun install -g @backslash-ux/plane ## Setup ```bash -plane init +plane init -g ``` -Prompts for your Plane host, workspace slug, and API token. Saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. +Prompts for your Plane host, workspace, and API token. Global setup saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. +It also offers an optional current-project selection so repeated project-scoped commands can reuse the same context. + +For path-local overrides in the current project directory: + +```bash +plane init --local +plane . init +``` + +Local setup writes `./.plane/config.json`. When the CLI runs, it resolves config with this precedence: + +```text +environment variables > nearest .plane/config.json > ~/.config/plane/config.json +``` + +The local config is discovered from the current working directory upward, so a config written at the repo root applies inside nested folders unless a deeper `.plane/config.json` overrides it. You can also use environment variables (override saved config): @@ -48,19 +64,37 @@ You can also use environment variables (override saved config): PLANE_API_TOKEN=... PLANE_HOST=https://plane.so PLANE_WORKSPACE=myworkspace +PLANE_PROJECT=PROJ # optional saved-project override ``` +To persist a current project after setup: + +```bash +plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects use PROJ --global +plane projects current +``` + +When a local config is active in the current path, `plane projects use PROJ` writes there by default; otherwise it writes to global config. Once a current project is saved, list-style commands such as `plane issues list`, `plane cycles list`, `plane modules list`, `plane pages list`, `plane states list`, `plane labels list`, and `plane intake list` can omit the project argument. Other project-scoped commands can use `@current` instead of repeating the identifier. + ## Common Commands ```bash # Projects plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects current # Issues +plane issues list plane issues list PROJ plane issues list PROJ --state started plane issue get PROJ-29 plane issue create PROJ "Title" +plane issue create @current "Title" plane issue update PROJ-29 --state done --priority high plane issue delete PROJ-29 diff --git a/SKILL.md b/SKILL.md index 65730de..8203ef2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -22,19 +22,45 @@ bun install -g @backslash-ux/plane Run once to save credentials interactively: ```bash -plane init +plane init -g ``` -Saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. +Saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. The interactive flow can also save a current project for repeated project-scoped commands. + +For path-local overrides in the current directory: + +```bash +plane init --local +plane . init +``` + +Local setup writes `./.plane/config.json`. Effective config resolution is: + +```text +PLANE_* environment variables > nearest .plane/config.json > ~/.config/plane/config.json +``` Or set environment variables (override saved config): ```bash export PLANE_API_TOKEN=your-token -export PLANE_HOST=https://plane.so # or your self-hosted URL -export PLANE_WORKSPACE=your-workspace-slug +export PLANE_HOST=https://plane.so +export PLANE_WORKSPACE=your-workspace +export PLANE_PROJECT=PROJ # optional current-project override ``` +You can also save a current project explicitly: + +```bash +plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects use PROJ --global +plane projects current +``` + +If a local config is active in the current path, `plane projects use PROJ` writes there by default. + --- ## Concepts @@ -42,6 +68,7 @@ export PLANE_WORKSPACE=your-workspace-slug | Term | Meaning | |---|---| | **Project identifier** | Short uppercase string, e.g. `ACME`, `WEB`. Shown by `plane projects list`. | +| **Current project** | Optional saved project identifier used when a list-style command omits the project argument or when a command uses `@current`. | | **Issue ref** | `PROJ-29` — identifier + sequence number. | | **State group** | `backlog` \| `unstarted` \| `started` \| `completed` \| `cancelled` | | **Priority** | `urgent` \| `high` \| `medium` \| `low` \| `none` | @@ -73,6 +100,9 @@ plane modules list PROJ --xml ```bash plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects current plane projects list --xml ``` @@ -83,6 +113,7 @@ plane projects list --xml ### List ```bash +plane issues list plane issues list PROJ plane issues list PROJ --state started plane issues list PROJ --state backlog @@ -103,6 +134,7 @@ plane issue get PROJ-29 ```bash plane issue create PROJ "Issue title" +plane issue create @current "Issue title" plane issue create --priority high --state started PROJ "Fix lint pipeline" plane issue create --description "Detailed context" PROJ "Add dark mode" plane issue create --assignee "Jane Doe" PROJ "Onboarding bug" @@ -173,6 +205,7 @@ plane issue worklogs add --description "code review" PROJ-29 30 ## States ```bash +plane states list plane states list PROJ plane states list PROJ --xml ``` @@ -184,6 +217,7 @@ State IDs are UUIDs unique per project. Always fetch live — never hardcode. ## Labels ```bash +plane labels list plane labels list PROJ plane labels list PROJ --xml plane labels create PROJ "bug" @@ -204,6 +238,7 @@ plane members list --xml ## Cycles (sprints) ```bash +plane cycles list plane cycles list PROJ plane cycles list PROJ --xml plane cycles issues list PROJ @@ -217,6 +252,7 @@ Cycle IDs are UUIDs. Fetch them from `plane cycles list PROJ`. ## Modules ```bash +plane modules list plane modules list PROJ plane modules list PROJ --xml plane modules issues list PROJ @@ -229,6 +265,7 @@ plane modules issues remove PROJ # use join ID, n ## Intake (triage) ```bash +plane intake list plane intake list PROJ plane intake accept PROJ plane intake reject PROJ @@ -241,6 +278,7 @@ Statuses: `pending`, `accepted`, `rejected`, `snoozed`, `duplicate`. ## Pages (documentation) ```bash +plane pages list plane pages list PROJ plane pages list PROJ --xml plane pages get PROJ # full JSON including description_html diff --git a/src/api.ts b/src/api.ts index d396582..7212f50 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,34 +1,5 @@ import { Effect, Schema } from "effect"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; - -const CONFIG_FILE = path.join(os.homedir(), ".config", "plane", "config.json"); - -function readConfigFile(): Partial<{ - token: string; - host: string; - workspace: string; -}> { - try { - return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); - } catch { - return {}; - } -} - -function getConfig() { - const file = readConfigFile(); - return { - token: process.env["PLANE_API_TOKEN"] ?? file.token ?? "", - host: ( - process.env["PLANE_HOST"] ?? - file.host ?? - "https://plane.so" - ).replace(/\/$/, ""), - workspace: process.env["PLANE_WORKSPACE"] ?? file.workspace ?? "", - }; -} +import { getConfig } from "./user-config.js"; function request( method: string, @@ -38,8 +9,14 @@ function request( return Effect.tryPromise({ try: async () => { const { token, host, workspace } = getConfig(); - if (!token) throw new Error("No API token configured. Run 'plane init' or set PLANE_API_TOKEN."); - if (!workspace) throw new Error("No workspace configured. Run 'plane init' or set PLANE_WORKSPACE."); + if (!token) + throw new Error( + "No API token configured. Run 'plane init', 'plane init --local', 'plane . init', or set PLANE_API_TOKEN.", + ); + if (!workspace) + throw new Error( + "No workspace configured. Run 'plane init', 'plane init --local', 'plane . init', or set PLANE_WORKSPACE.", + ); let url = `${host}/api/v1/workspaces/${workspace}/${path}`; // Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/) @@ -75,10 +52,13 @@ function request( return JSON.parse(text); } catch { // Escape bare control characters inside JSON string values and retry. - const sanitized = text.replace( - /"(?:[^"\\]|\\.)*"/g, - (match) => match.replace(/[\x00-\x1F]/g, (c) => { - const hex = c.charCodeAt(0).toString(16).padStart(4, "0"); + const sanitized = text.replace(/"(?:[^"\\]|\\.)*"/g, (match) => + match.replace(/./gsu, (c) => { + const code = c.charCodeAt(0); + if (code > 0x1f) { + return c; + } + const hex = code.toString(16).padStart(4, "0"); return `\\u${hex}`; }), ); diff --git a/src/app.ts b/src/app.ts index 23fabed..49245d8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,31 +1,43 @@ import { Command } from "@effect/cli"; +import { cycles } from "./commands/cycles.js"; +import { init } from "./commands/init.js"; +import { intake } from "./commands/intake.js"; import { issue } from "./commands/issue.js"; import { issues } from "./commands/issues.js"; -import { states } from "./commands/states.js"; import { labels } from "./commands/labels.js"; +import { local } from "./commands/local.js"; import { members } from "./commands/members.js"; -import { cycles } from "./commands/cycles.js"; import { modules } from "./commands/modules.js"; -import { intake } from "./commands/intake.js"; import { pages } from "./commands/pages.js"; import { projects } from "./commands/projects.js"; -import { init } from "./commands/init.js"; +import { states } from "./commands/states.js"; const plane = Command.make("plane").pipe( Command.withDescription( `CLI for the Plane project management API. Useful for humans and AI agents/bots. CONFIGURATION - Config file: ~/.config/plane/config.json (written by 'plane init') - Env vars: PLANE_API_TOKEN, PLANE_HOST, PLANE_WORKSPACE - Env vars take priority over the config file. + Global config: ~/.config/plane/config.json + Local config: nearest .plane/config.json from the current directory upward + Env vars: PLANE_API_TOKEN + PLANE_HOST + PLANE_WORKSPACE + PLANE_PROJECT for a default project identifier + Precedence: env vars > local config > global config QUICK START - plane init Interactive setup — saves host/workspace/token + plane init -g Interactive global setup + plane init --local Interactive local setup in the current directory + plane . init Local setup alias for the current directory plane projects list List projects and their identifiers + plane projects use PROJ Save a current project in the active config scope + plane projects use PROJ --global Force the saved current project into global config + plane projects use PROJ --local Force the saved current project into local config + plane issues list List issues for the saved current project plane issues list PROJ List issues for a project plane issue get PROJ-29 Get full JSON for an issue plane issue create PROJ "title" Create an issue + plane issue create @current "title" Create an issue in the saved current project plane issue update --state done PROJ-29 plane issue comment PROJ-29 "text" Add a comment @@ -36,8 +48,9 @@ CONCEPTS Priorities urgent | high | medium | low | none ALL SUBCOMMANDS - init Set up config interactively - projects list List all projects + init Set up global or local config interactively + . local init + projects list | current | use issues list List issues (supports --state, --assignee, --priority) issue get | create | update | delete | comment | activity | link | comments | worklogs @@ -53,11 +66,16 @@ FOR AI AGENTS / BOTS - Add --json to any list command for JSON output (array of objects) - Add --xml to any list command for XML output - 'plane issue get PROJ-N' always outputs full JSON - - Use PLANE_API_TOKEN / PLANE_HOST / PLANE_WORKSPACE env vars to avoid 'plane init' + - Use PLANE_API_TOKEN to avoid 'plane init' + - Use PLANE_HOST for self-hosted Plane instances + - Use PLANE_WORKSPACE to select the workspace + - Use PLANE_PROJECT or 'plane projects use PROJ' to persist a current project + - Local config lives in '.plane/config.json' and is resolved from the current directory upward - Full Plane REST API reference (180+ endpoints): https://developers.plane.so/api-reference/introduction`, ), Command.withSubcommands([ + local, init, projects, issues, diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 0700711..3c2ce22 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -1,14 +1,18 @@ -import { Command, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { CyclesResponseSchema, CycleIssuesResponseSchema } from "../config.js"; -import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { CycleIssuesResponseSchema, CyclesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { findIssueBySeq, parseIssueRef, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const cycleIdArg = Args.text({ name: "cycle-id" }).pipe( Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"), ); @@ -44,11 +48,11 @@ export function cyclesListHandler({ project }: { project: string }) { export const cyclesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, cyclesListHandler, ).pipe( Command.withDescription( - "List cycles for a project. Shows cycle UUID, status, date range, and name.\n\nExample:\n plane cycles list PROJ", + "List cycles for a project. Shows cycle UUID, status, date range, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane cycles list PROJ", ), ); diff --git a/src/commands/init.ts b/src/commands/init.ts index 1ff097c..0e55352 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,31 +1,205 @@ -import { Command } from "@effect/cli"; -import { Console, Effect } from "effect"; import * as readline from "node:readline"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; +import { Command, Options } from "@effect/cli"; +import { Console, Effect } from "effect"; +import { decodeOrFail } from "../api.js"; +import { ProjectsResponseSchema } from "../config.js"; +import { + type ConfigScope, + getConfigDetails, + getGlobalConfigFilePath, + getLocalConfigFilePath, + readGlobalStoredConfig, + readLocalStoredConfigAtPath, + writeGlobalStoredConfig, + writeLocalStoredConfig, +} from "../user-config.js"; -export const CONFIG_DIR = path.join(os.homedir(), ".config", "plane"); -export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); +function prompt(rl: readline.Interface, question: string): Promise { + return new Promise((resolve) => rl.question(question, resolve)); +} -export interface PlaneConfig { - token: string; - host: string; - workspace: string; +function resolveProjectSelection( + input: string, + projects: ReadonlyArray<{ identifier: string; name: string }>, + existingDefaultProject: string | undefined, + scope: ConfigScope, +): string | undefined { + const trimmed = input.trim(); + if (!trimmed) { + return existingDefaultProject; + } + if (trimmed === "-") { + return scope === "global" ? "" : undefined; + } + const byNumber = Number.parseInt(trimmed, 10); + if ( + Number.isInteger(byNumber) && + byNumber >= 1 && + byNumber <= projects.length + ) { + return projects[byNumber - 1].identifier; + } + const byIdentifier = projects.find( + (project) => project.identifier.toUpperCase() === trimmed.toUpperCase(), + ); + if (byIdentifier) { + return byIdentifier.identifier; + } + throw new Error( + `Unknown project selection: ${trimmed}. Enter a number, identifier, or '-' to clear.`, + ); } -function prompt(rl: readline.Interface, question: string): Promise { - return new Promise((resolve) => rl.question(question, resolve)); +function resolveScope( + { global, local }: { global: boolean; local: boolean }, + defaultScope: ConfigScope, +): Effect.Effect { + if (global && local) { + return Effect.fail( + new Error("Choose either --global or --local, not both."), + ); + } + if (local) { + return Effect.succeed("local"); + } + if (global) { + return Effect.succeed("global"); + } + return Effect.succeed(defaultScope); +} + +function promptLabel( + label: string, + scope: ConfigScope, + existingValue: string | undefined, + effectiveValue: string, + options?: { hidden?: boolean; clearHint?: boolean }, +): string { + const displayValue = (value: string) => + options?.hidden && value ? "***" : value; + + if (scope === "local") { + const currentValue = existingValue?.trim(); + const inheritedValue = effectiveValue.trim(); + const shownValue = currentValue + ? displayValue(currentValue) + : inheritedValue + ? `inherit: ${displayValue(inheritedValue)}` + : "inherit"; + const clearHint = options?.clearHint + ? " ('-' to clear)" + : " ('-' to inherit)"; + return `${label} [${shownValue}]${clearHint}: `; + } + + return `${label} [${displayValue(existingValue?.trim() ?? "")}]: `; +} + +function resolveLocalValue( + input: string, + existingValue: string | undefined, +): string | undefined { + const trimmed = input.trim(); + if (!trimmed) { + return existingValue?.trim() || undefined; + } + if (trimmed === "-") { + return undefined; + } + return trimmed; } -export const init = Command.make("init", {}, () => - Effect.gen(function* () { - let existing: Partial = {}; - try { - existing = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); - } catch { - // no existing config +function resolveGlobalValue( + input: string, + existingValue: string | undefined, +): string { + const trimmed = input.trim(); + return trimmed || existingValue?.trim() || ""; +} + +function describeValue( + scope: ConfigScope, + localValue: string | undefined, + effectiveValue: string, + options?: { hidden?: boolean }, +): string { + const displayValue = (value: string) => + options?.hidden && value ? "***" : value; + + if (scope === "local") { + if (localValue?.trim()) { + return displayValue(localValue.trim()); } + return effectiveValue + ? `inherit (${displayValue(effectiveValue)})` + : "inherit"; + } + + return displayValue(effectiveValue); +} + +const globalOption = Options.boolean("global").pipe( + Options.withAlias("g"), + Options.withDescription("Save to ~/.config/plane/config.json"), + Options.withDefault(false), +); + +const localOption = Options.boolean("local").pipe( + Options.withAlias("l"), + Options.withDescription( + "Save to ./.plane/config.json in the current directory", + ), + Options.withDefault(false), +); + +function fetchProjectsForConfig(config: { + host: string; + workspace: string; + token: string; +}) { + return Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch( + `${config.host}/api/v1/workspaces/${config.workspace}/projects/`, + { + headers: { "X-Api-Key": config.token }, + }, + ), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), + }); + if (!response.ok) { + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), + }); + return yield* Effect.fail(new Error(`HTTP ${response.status}: ${text}`)); + } + const raw = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), + }); + const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); + return results; + }); +} + +export function initHandler( + { global, local }: { global: boolean; local: boolean }, + defaultScope: ConfigScope = "global", +) { + return Effect.gen(function* () { + const scope = yield* resolveScope({ global, local }, defaultScope); + const effective = getConfigDetails(); + const existing = + scope === "global" + ? readGlobalStoredConfig() + : readLocalStoredConfigAtPath(); + const savePath = + scope === "global" ? getGlobalConfigFilePath() : getLocalConfigFilePath(); const rl = readline.createInterface({ input: process.stdin, @@ -33,42 +207,166 @@ export const init = Command.make("init", {}, () => }); const host = yield* Effect.promise(() => - prompt(rl, `Plane host [${existing.host ?? "https://plane.so"}]: `), + prompt( + rl, + promptLabel("Plane host URL", scope, existing.host, effective.host), + ), ); const workspace = yield* Effect.promise(() => - prompt(rl, `Workspace slug [${existing.workspace ?? ""}]: `), + prompt( + rl, + promptLabel( + "Workspace", + scope, + existing.workspace, + effective.workspace, + ), + ), ); const token = yield* Effect.promise(() => - prompt(rl, `API token [${existing.token ? "***" : ""}]: `), + prompt( + rl, + promptLabel("API token", scope, existing.token, effective.token, { + hidden: true, + }), + ), ); - rl.close(); + const savedHost = + scope === "global" + ? resolveGlobalValue( + host, + existing.host || effective.host || "https://plane.so", + ) + : resolveLocalValue(host, existing.host); + const savedWorkspace = + scope === "global" + ? resolveGlobalValue( + workspace, + existing.workspace || effective.workspace, + ) + : resolveLocalValue(workspace, existing.workspace); + const savedToken = + scope === "global" + ? resolveGlobalValue(token, existing.token || effective.token) + : resolveLocalValue(token, existing.token); - const config: PlaneConfig = { - host: host.trim() || existing.host || "https://plane.so", - workspace: workspace.trim() || existing.workspace || "", - token: token.trim() || existing.token || "", - }; + const mergedHost = ( + savedHost ?? + effective.host ?? + "https://plane.so" + ).replace(/\/$/, ""); + const mergedWorkspace = savedWorkspace ?? effective.workspace; + const mergedToken = savedToken ?? effective.token; - if (!config.token) { + if (!mergedToken) { + rl.close(); yield* Effect.fail(new Error("API token is required")); } - if (!config.workspace) { - yield* Effect.fail(new Error("Workspace slug is required")); + if (!mergedWorkspace) { + rl.close(); + yield* Effect.fail(new Error("Workspace is required")); } - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { - mode: 0o600, - }); + let savedDefaultProject = existing.defaultProject; + const projectsResult = yield* Effect.either( + fetchProjectsForConfig({ + host: mergedHost, + workspace: mergedWorkspace, + token: mergedToken, + }), + ); + if (projectsResult._tag === "Right" && projectsResult.right.length > 0) { + yield* Console.log("\nAvailable projects:"); + yield* Console.log( + projectsResult.right + .map( + (project, index) => + `${index + 1}. ${project.identifier} ${project.name}`, + ) + .join("\n"), + ); + const selectedProject = yield* Effect.promise(() => + prompt( + rl, + promptLabel( + "Default project number or identifier", + scope, + existing.defaultProject, + effective.defaultProject, + { clearHint: true }, + ), + ), + ); + savedDefaultProject = resolveProjectSelection( + selectedProject, + projectsResult.right, + existing.defaultProject, + scope, + ); + } else if (projectsResult._tag === "Left") { + yield* Console.log( + `\nWarning: could not load projects for selection (${projectsResult.left.message}). Continuing without changing the current-project override.`, + ); + } + + rl.close(); - yield* Console.log(`\nConfig saved to ${CONFIG_FILE}`); - yield* Console.log(` Host: ${config.host}`); - yield* Console.log(` Workspace: ${config.workspace}`); + if (scope === "global") { + writeGlobalStoredConfig({ + host: mergedHost, + workspace: mergedWorkspace, + token: mergedToken, + defaultProject: savedDefaultProject, + }); + } else { + writeLocalStoredConfig( + { + host: savedHost, + workspace: savedWorkspace, + token: savedToken, + defaultProject: savedDefaultProject, + }, + { target: "cwd" }, + ); + } + + yield* Console.log( + `\n${scope === "global" ? "Global" : "Local"} config saved to ${savePath}`, + ); + yield* Console.log( + ` Host: ${describeValue(scope, savedHost, mergedHost)}`, + ); + yield* Console.log( + ` Workspace: ${describeValue(scope, savedWorkspace, mergedWorkspace)}`, + ); yield* Console.log(` Token: ***`); - }), + if ((savedDefaultProject ?? effective.defaultProject).trim()) { + yield* Console.log( + ` Project: ${describeValue( + scope, + savedDefaultProject, + savedDefaultProject ?? effective.defaultProject, + )}`, + ); + } + }); +} + +export const init = Command.make( + "init", + { global: globalOption, local: localOption }, + (options) => initHandler(options, "global"), +).pipe( + Command.withDescription( + "Interactive setup. Defaults to global config, supports --global/-g and --local/-l, and can save an optional current-project override.", + ), +); + +export const localInit = Command.make("init", {}, () => + initHandler({ global: false, local: true }, "local"), ).pipe( Command.withDescription( - "Interactive setup. Prompts for host, workspace slug, and API token, then saves to ~/.config/plane/config.json (mode 0600). Safe to re-run — existing values shown as defaults.", + "Interactive local setup. Saves overrides to ./.plane/config.json in the current directory.", ), ); diff --git a/src/commands/intake.ts b/src/commands/intake.ts index b382029..dba133d 100644 --- a/src/commands/intake.ts +++ b/src/commands/intake.ts @@ -1,21 +1,25 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { IntakeIssuesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + // Intake status codes: -2=rejected, -1=snoozed, 0=pending, 1=accepted, 2=duplicate const STATUS_LABELS: Record = { [-2]: "rejected", [-1]: "snoozed", - [0]: "pending", - [1]: "accepted", - [2]: "duplicate", + 0: "pending", + 1: "accepted", + 2: "duplicate", }; // --- intake list --- @@ -51,11 +55,11 @@ export function intakeListHandler({ project }: { project: string }) { export const intakeList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, intakeListHandler, ).pipe( Command.withDescription( - "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate.\n\nExample:\n plane intake list PROJ", + "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate. Omit PROJECT to use the saved current project.\n\nExample:\n plane intake list PROJ", ), ); diff --git a/src/commands/issue.ts b/src/commands/issue.ts index c474514..4184268 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,15 +1,17 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; import { - IssueSchema, ActivitiesResponseSchema, - IssueLinksResponseSchema, - IssueLinkSchema, CommentsResponseSchema, - WorklogsResponseSchema, + IssueLinkSchema, + IssueLinksResponseSchema, + IssueSchema, WorklogSchema, + WorklogsResponseSchema, } from "../config.js"; +import { escapeHtmlText } from "../format.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { findIssueBySeq, getLabelId, @@ -18,8 +20,6 @@ import { parseIssueRef, resolveProject, } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; -import { escapeHtmlText } from "../format.js"; const refArg = Args.text({ name: "ref" }).pipe( Args.withDescription("Issue reference, e.g. PROJ-29"), @@ -219,7 +219,9 @@ const titleArg = Args.text({ name: "title" }).pipe( Args.withDescription("Issue title"), ); const projectRefArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ)"), + Args.withDescription( + "Project identifier (e.g. PROJ). Use '@current' for the saved default project.", + ), ); const createPriorityOption = Options.optional( @@ -300,7 +302,7 @@ export const issueCreate = Command.make( issueCreateHandler, ).pipe( Command.withDescription( - 'Create a new issue in a project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"', + 'Create a new issue in a project. Use @current to target the saved default project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create @current "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"', ), ); // --- issue activity --- @@ -401,7 +403,7 @@ export function issueLinkAddHandler({ const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const body: Record = { url }; - if (Option.isSome(title)) body["title"] = title.value; + if (Option.isSome(title)) body.title = title.value; const raw = yield* api.post( `projects/${projectId}/issues/${issue.id}/issue-links/`, body, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index fb35ba5..e3b7c15 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,18 +1,20 @@ -import { Command, Options, Args } from "@effect/cli"; -import { Console, Effect, Option } from "effect"; +import { Args, Command, Options } from "@effect/cli"; +import { Console, Effect, type Option } from "effect"; import { api, decodeOrFail } from "../api.js"; +import type { State } from "../config.js"; import { IssuesResponseSchema } from "../config.js"; import { formatIssue } from "../format.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { getMemberId, resolveProject } from "../resolve.js"; -import type { State } from "../config.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( - "Project identifier — see 'plane projects list' for available identifiers", + "Project identifier — see 'plane projects list' for available identifiers. Use '@current' for the saved default project.", ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const stateOption = Options.optional(Options.text("state")).pipe( Options.withDescription( "Filter by state group (backlog | unstarted | started | completed | cancelled) or exact state name", @@ -91,12 +93,12 @@ export const issuesList = Command.make( state: stateOption, assignee: assigneeOption, priority: priorityOption, - project: projectArg, + project: listProjectArg, }, issuesListHandler, ).pipe( Command.withDescription( - "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title", + "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title. Omit PROJECT to use the saved current project.", ), ); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index e23aff3..216810b 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,19 +1,23 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { LabelsResponseSchema, LabelSchema } from "../config.js"; +import { LabelSchema, LabelsResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + // --- labels list --- export const labelsList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, ({ project }) => Effect.gen(function* () { const { id } = yield* resolveProject(project); diff --git a/src/commands/local.ts b/src/commands/local.ts new file mode 100644 index 0000000..24805cf --- /dev/null +++ b/src/commands/local.ts @@ -0,0 +1,9 @@ +import { Command } from "@effect/cli"; +import { localInit } from "./init.js"; + +export const local = Command.make(".").pipe( + Command.withDescription( + "Manage path-local Plane config for the current directory.", + ), + Command.withSubcommands([localInit]), +); diff --git a/src/commands/modules.ts b/src/commands/modules.ts index 03d6b21..d029c3e 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -1,17 +1,21 @@ -import { Command, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { - ModulesResponseSchema, ModuleIssuesResponseSchema, + ModulesResponseSchema, } from "../config.js"; -import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { findIssueBySeq, parseIssueRef, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const moduleIdArg = Args.text({ name: "module-id" }).pipe( Args.withDescription("Module UUID (from 'plane modules list PROJECT')"), ); @@ -45,11 +49,11 @@ export function modulesListHandler({ project }: { project: string }) { export const modulesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, modulesListHandler, ).pipe( Command.withDescription( - "List modules for a project. Shows module UUID, status, and name.\n\nExample:\n plane modules list PROJ", + "List modules for a project. Shows module UUID, status, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane modules list PROJ", ), ); diff --git a/src/commands/pages.ts b/src/commands/pages.ts index 68d63dc..bdd0963 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -1,14 +1,18 @@ -import { Command, Args, Options } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { PagesResponseSchema, PageSchema } from "../config.js"; +import { PageSchema, PagesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const pageIdArg = Args.text({ name: "page-id" }).pipe( Args.withDescription("Page UUID (from 'plane pages list')"), ); @@ -25,8 +29,14 @@ const descriptionOption = Options.optional(Options.text("description")).pipe( Options.withDescription("Page description as HTML (e.g. '

Hello

')"), ); -interface PageCreatePayload { name: string; description_html?: string; } -interface PageUpdatePayload { name?: string; description_html?: string; } +interface PageCreatePayload { + name: string; + description_html?: string; +} +interface PageUpdatePayload { + name?: string; + description_html?: string; +} // --- pages list --- @@ -57,11 +67,11 @@ export function pagesListHandler({ project }: { project: string }) { export const pagesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, pagesListHandler, ).pipe( Command.withDescription( - "List pages for a project. Shows page UUID, last updated date, and title.\n\nExample:\n plane pages list PROJ", + "List pages for a project. Shows page UUID, last updated date, and title. Omit PROJECT to use the saved current project.\n\nExample:\n plane pages list PROJ", ), ); @@ -121,7 +131,7 @@ export const pagesCreate = Command.make( pagesCreateHandler, ).pipe( Command.withDescription( - "Create a new page.\n\nExample:\n plane pages create --name \"My Page\" PROJ", + 'Create a new page.\n\nExample:\n plane pages create --name "My Page" PROJ', ), ); @@ -154,11 +164,16 @@ export function pagesUpdateHandler({ export const pagesUpdate = Command.make( "update", - { project: projectArg, pageId: pageIdArg, name: nameOptionalOption, description: descriptionOption }, + { + project: projectArg, + pageId: pageIdArg, + name: nameOptionalOption, + description: descriptionOption, + }, pagesUpdateHandler, ).pipe( Command.withDescription( - "Update a page's name or description.\n\nExample:\n plane pages update --name \"New Title\" PROJ ", + 'Update a page\'s name or description.\n\nExample:\n plane pages update --name "New Title" PROJ ', ), ); @@ -303,7 +318,10 @@ export function pagesDuplicateHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {}); + const raw = yield* api.post( + `projects/${id}/pages/${pageId}/duplicate/`, + {}, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Duplicated page ${page.id}: ${page.name}`); }); @@ -326,7 +344,15 @@ export const pages = Command.make("pages").pipe( "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 ", ), Command.withSubcommands([ - pagesList, pagesGet, pagesCreate, pagesUpdate, pagesDelete, - pagesArchive, pagesUnarchive, pagesLock, pagesUnlock, pagesDuplicate, + pagesList, + pagesGet, + pagesCreate, + pagesUpdate, + pagesDelete, + pagesArchive, + pagesUnarchive, + pagesLock, + pagesUnlock, + pagesDuplicate, ]), ); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 2860455..0e9cb3c 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,13 +1,74 @@ -import { Command } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { ProjectsResponseSchema } from "../config.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { resolveProject } from "../resolve.js"; +import { + type ConfigScope, + getConfigDetails, + getDefaultConfigWriteScope, + readLocalStoredConfig, + readStoredConfig, + writeLocalStoredConfig, + writeStoredConfig, +} from "../user-config.js"; -export const projectsList = Command.make("list", {}, () => - Effect.gen(function* () { +const projectArg = Args.text({ name: "project" }).pipe( + Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), +); + +const globalOption = Options.boolean("global").pipe( + Options.withAlias("g"), + Options.withDescription("Write the current project to global config"), + Options.withDefault(false), +); + +const localOption = Options.boolean("local").pipe( + Options.withAlias("l"), + Options.withDescription("Write the current project to local config"), + Options.withDefault(false), +); + +function resolveWriteScope({ + global, + local, +}: { + global: boolean; + local: boolean; +}): Effect.Effect { + if (global && local) { + return Effect.fail( + new Error("Choose either --global or --local, not both."), + ); + } + if (local) { + return Effect.succeed("local"); + } + if (global) { + return Effect.succeed("global"); + } + return Effect.succeed(getDefaultConfigWriteScope()); +} + +function describeProjectSource(source: string): string { + switch (source) { + case "env": + return "env"; + case "local": + return "local"; + case "global": + return "global"; + default: + return "config"; + } +} + +export function projectsListHandler() { + return Effect.gen(function* () { const raw = yield* api.get("projects/"); const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); + const currentProject = getConfigDetails().defaultProject.toUpperCase(); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return; @@ -16,18 +77,100 @@ export const projectsList = Command.make("list", {}, () => yield* Console.log(toXml(results)); return; } - const lines = results.map( - (p) => `${p.identifier.padEnd(6)} ${p.id} ${p.name}`, - ); + const lines = results.map((project) => { + const marker = + currentProject === project.identifier.toUpperCase() ? "*" : " "; + return `${marker} ${project.identifier.padEnd(6)} ${project.id} ${project.name}`; + }); yield* Console.log(lines.join("\n")); - }), + }); +} + +export const projectsList = Command.make("list", {}, projectsListHandler).pipe( + Command.withDescription( + "List all projects in the workspace. The IDENTIFIER column is what you pass to other commands. A leading '*' marks the saved current project.", + ), +); + +export function projectsCurrentHandler() { + return Effect.gen(function* () { + const config = getConfigDetails(); + const configuredProject = config.defaultProject; + if (!configuredProject) { + return yield* Effect.fail( + new Error( + "No default project configured. Run 'plane init', 'plane init --local', 'plane . init', or 'plane projects use PROJ'.", + ), + ); + } + const source = describeProjectSource(config.sources.defaultProject); + const { key, id } = yield* resolveProject("@current"); + const raw = yield* api.get("projects/"); + const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); + const project = results.find((candidate) => candidate.id === id); + if (!project) { + yield* Console.log(`${key} (${source})`); + return; + } + yield* Console.log( + `${project.identifier} ${project.id} ${project.name} (${source})`, + ); + }); +} + +export const projectsCurrent = Command.make( + "current", + {}, + projectsCurrentHandler, +).pipe( + Command.withDescription( + "Show the effective current project and whether it came from env, local config, or global config.", + ), +); + +export function projectsUseHandler({ + project, + global, + local, +}: { + project: string; + global: boolean; + local: boolean; +}) { + return Effect.gen(function* () { + const scope = yield* resolveWriteScope({ global, local }); + const { key } = yield* resolveProject(project); + if (scope === "local") { + const existing = readLocalStoredConfig(); + writeLocalStoredConfig( + { + ...existing, + defaultProject: key, + }, + { target: "active" }, + ); + } else { + const existing = readStoredConfig(); + writeStoredConfig({ + ...existing, + defaultProject: key, + }); + } + yield* Console.log(`Current project set to ${key} (${scope})`); + }); +} + +export const projectsUse = Command.make( + "use", + { project: projectArg, global: globalOption, local: localOption }, + projectsUseHandler, ).pipe( Command.withDescription( - "List all projects in the workspace. The IDENTIFIER column is what you pass to other commands (e.g. 'plane issues list PROJ').", + "Persist a current project. Defaults to local scope when a local config is active in the current path; use --global or --local to force the target scope.", ), ); export const projects = Command.make("projects").pipe( Command.withDescription("Manage projects."), - Command.withSubcommands([projectsList]), + Command.withSubcommands([projectsList, projectsCurrent, projectsUse]), ); diff --git a/src/commands/states.ts b/src/commands/states.ts index 092a747..b3a67d6 100644 --- a/src/commands/states.ts +++ b/src/commands/states.ts @@ -1,17 +1,21 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { StatesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + export const statesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, ({ project }) => Effect.gen(function* () { const { id } = yield* resolveProject(project); diff --git a/src/resolve.ts b/src/resolve.ts index 7f77c91..2eb4c49 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,5 +1,6 @@ import { Effect } from "effect"; import { api, decodeOrFail } from "./api.js"; +import type { Issue } from "./config.js"; import { IssuesResponseSchema, LabelsResponseSchema, @@ -7,11 +8,30 @@ import { ProjectsResponseSchema, StatesResponseSchema, } from "./config.js"; -import type { Issue } from "./config.js"; +import { getConfig } from "./user-config.js"; // Cache project list within a process invocation let _projectCache: Record | null = null; +function getConfiguredProject(identifier: string): string { + const trimmed = identifier.trim(); + if ( + trimmed && + trimmed !== "." && + trimmed.toLowerCase() !== "@current" && + trimmed.toLowerCase() !== "@default" + ) { + return trimmed; + } + const defaultProject = getConfig().defaultProject.trim(); + if (defaultProject) { + return defaultProject; + } + throw new Error( + "No default project configured. Run 'plane init', 'plane init --local', 'plane . init', 'plane projects use PROJ', or set PLANE_PROJECT.", + ); +} + /** Clear the project cache — for use in tests only */ export function _clearProjectCache(): void { _projectCache = null; @@ -32,7 +52,7 @@ function getProjectMap(): Effect.Effect, Error> { export function resolveProject( identifier: string, ): Effect.Effect<{ key: string; id: string }, Error> { - const key = identifier.toUpperCase(); + const key = getConfiguredProject(identifier).toUpperCase(); return getProjectMap().pipe( Effect.flatMap((map) => { const id = map[key]; diff --git a/src/user-config.ts b/src/user-config.ts new file mode 100644 index 0000000..7603e9e --- /dev/null +++ b/src/user-config.ts @@ -0,0 +1,248 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export type ConfigScope = "global" | "local"; + +type ConfigSource = "env" | "local" | "global" | "default" | "none"; + +export interface StoredPlaneConfig { + token?: string; + host?: string; + workspace?: string; + defaultProject?: string; +} + +export interface PlaneConfig { + token: string; + host: string; + workspace: string; + defaultProject: string; +} + +export interface PlaneConfigDetails extends PlaneConfig { + sources: { + token: ConfigSource; + host: ConfigSource; + workspace: ConfigSource; + defaultProject: ConfigSource; + }; + paths: { + globalConfigFile: string; + localConfigFile: string | null; + localConfigTargetFile: string; + }; +} + +const DEFAULT_HOST = "https://plane.so"; + +function normalizeHost(host: string): string { + return host.replace(/\/$/, ""); +} + +function cleanConfig(config: StoredPlaneConfig): StoredPlaneConfig { + const cleaned: StoredPlaneConfig = {}; + + if (config.token?.trim()) { + cleaned.token = config.token.trim(); + } + if (config.host?.trim()) { + cleaned.host = normalizeHost(config.host.trim()); + } + if (config.workspace?.trim()) { + cleaned.workspace = config.workspace.trim(); + } + if (config.defaultProject?.trim()) { + cleaned.defaultProject = config.defaultProject.trim(); + } + + return cleaned; +} + +function readConfigFile(filePath: string): StoredPlaneConfig { + try { + return cleanConfig(JSON.parse(fs.readFileSync(filePath, "utf8"))); + } catch { + return {}; + } +} + +function writeConfigFile(filePath: string, config: StoredPlaneConfig): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + `${JSON.stringify(cleanConfig(config), null, 2)}\n`, + { + mode: 0o600, + }, + ); + fs.chmodSync(filePath, 0o600); +} + +export function getGlobalConfigDir(): string { + return path.join(os.homedir(), ".config", "plane"); +} + +export function getConfigDir(): string { + return getGlobalConfigDir(); +} + +export function getGlobalConfigFilePath(): string { + return path.join(getGlobalConfigDir(), "config.json"); +} + +export function getConfigFilePath(): string { + return getGlobalConfigFilePath(); +} + +export function getLocalConfigDir(cwd = process.cwd()): string { + return path.join(path.resolve(cwd), ".plane"); +} + +export function getLocalConfigFilePath(cwd = process.cwd()): string { + return path.join(getLocalConfigDir(cwd), "config.json"); +} + +export function findNearestLocalConfigFilePath( + cwd = process.cwd(), +): string | null { + let currentDir = path.resolve(cwd); + + for (;;) { + const candidate = getLocalConfigFilePath(currentDir); + if (fs.existsSync(candidate)) { + return candidate; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getDefaultConfigWriteScope(cwd = process.cwd()): ConfigScope { + return findNearestLocalConfigFilePath(cwd) ? "local" : "global"; +} + +export function getLocalConfigTargetFilePath( + cwd = process.cwd(), + target: "active" | "cwd" = "active", +): string { + if (target === "cwd") { + return getLocalConfigFilePath(cwd); + } + + return findNearestLocalConfigFilePath(cwd) ?? getLocalConfigFilePath(cwd); +} + +export function readGlobalStoredConfig(): StoredPlaneConfig { + return readConfigFile(getGlobalConfigFilePath()); +} + +export function readStoredConfig(): StoredPlaneConfig { + return readGlobalStoredConfig(); +} + +export function readLocalStoredConfig(cwd = process.cwd()): StoredPlaneConfig { + const filePath = findNearestLocalConfigFilePath(cwd); + return filePath ? readConfigFile(filePath) : {}; +} + +export function readLocalStoredConfigAtPath( + cwd = process.cwd(), +): StoredPlaneConfig { + return readConfigFile(getLocalConfigFilePath(cwd)); +} + +export function writeGlobalStoredConfig(config: StoredPlaneConfig): void { + writeConfigFile(getGlobalConfigFilePath(), config); +} + +export function writeStoredConfig(config: StoredPlaneConfig): void { + writeGlobalStoredConfig(config); +} + +export function writeLocalStoredConfig( + config: StoredPlaneConfig, + options?: { + cwd?: string; + target?: "active" | "cwd"; + }, +): void { + writeConfigFile( + getLocalConfigTargetFilePath(options?.cwd, options?.target), + config, + ); +} + +export function getConfigDetails(cwd = process.cwd()): PlaneConfigDetails { + const globalConfig = readGlobalStoredConfig(); + const localConfigFile = findNearestLocalConfigFilePath(cwd); + const localConfig = localConfigFile ? readConfigFile(localConfigFile) : {}; + + const envToken = process.env.PLANE_API_TOKEN; + const envHost = process.env.PLANE_HOST; + const envWorkspace = process.env.PLANE_WORKSPACE; + const envProject = process.env.PLANE_PROJECT; + + const token = envToken ?? localConfig.token ?? globalConfig.token ?? ""; + const host = normalizeHost( + envHost ?? localConfig.host ?? globalConfig.host ?? DEFAULT_HOST, + ); + const workspace = + envWorkspace ?? localConfig.workspace ?? globalConfig.workspace ?? ""; + const defaultProject = + envProject ?? + localConfig.defaultProject ?? + globalConfig.defaultProject ?? + ""; + + return { + token, + host, + workspace, + defaultProject, + sources: { + token: envToken + ? "env" + : localConfig.token + ? "local" + : globalConfig.token + ? "global" + : "none", + host: envHost + ? "env" + : localConfig.host + ? "local" + : globalConfig.host + ? "global" + : "default", + workspace: envWorkspace + ? "env" + : localConfig.workspace + ? "local" + : globalConfig.workspace + ? "global" + : "none", + defaultProject: envProject + ? "env" + : localConfig.defaultProject + ? "local" + : globalConfig.defaultProject + ? "global" + : "none", + }, + paths: { + globalConfigFile: getGlobalConfigFilePath(), + localConfigFile, + localConfigTargetFile: getLocalConfigTargetFilePath(cwd), + }, + }; +} + +export function getConfig(): PlaneConfig { + const { sources: _sources, paths: _paths, ...config } = getConfigDetails(); + return config; +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 18101c1..1c8e1b4 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -7,11 +7,10 @@ import { expect, it, } from "bun:test"; -import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { Effect, Schema } from "effect"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { api, decodeOrFail } from "@/api"; -import { Schema } from "effect"; const BASE = "http://api-test.local"; const WS = "testws"; @@ -22,16 +21,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterAll(() => server.close()); beforeEach(() => { - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("api.get", () => { @@ -50,7 +49,7 @@ describe("api.get", () => { }); it("strips trailing slash from PLANE_HOST", async () => { - process.env["PLANE_HOST"] = `${BASE}/`; + process.env.PLANE_HOST = `${BASE}/`; server.use( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: [] }), diff --git a/tests/issue-commands.test.ts b/tests/issue-commands.test.ts index 58548f2..2ca48f3 100644 --- a/tests/issue-commands.test.ts +++ b/tests/issue-commands.test.ts @@ -10,7 +10,7 @@ import { import { Command } from "@effect/cli"; import { NodeContext } from "@effect/platform-node"; import { Effect, Layer, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -76,16 +76,17 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; }); describe("issueGet", () => { @@ -261,6 +262,31 @@ describe("issuesList", () => { expect(output).toContain("Urgent fix"); expect(output).not.toContain("Low cleanup"); }); + + it("uses the saved current project when the project input is blank", async () => { + process.env.PLANE_PROJECT = "ACME"; + const { issuesListHandler } = await import("@/commands/issues"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + issuesListHandler({ + project: "", + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), + }), + ); + } finally { + console.log = orig; + } + + const output = logs.join("\n"); + expect(output).toContain("ACME-"); + expect(output).toContain("Migrate Button"); + }); }); describe("issueUpdate", () => { @@ -630,9 +656,9 @@ describe("issueCreate description", () => { }), ); - expect( - (postedBody as { description_html?: string }).description_html, - ).toBe("

Raw HTML

"); + expect((postedBody as { description_html?: string }).description_html).toBe( + "

Raw HTML

", + ); }); }); diff --git a/tests/projects.test.ts b/tests/projects.test.ts new file mode 100644 index 0000000..00ccf4d --- /dev/null +++ b/tests/projects.test.ts @@ -0,0 +1,235 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Command } from "@effect/cli"; +import { NodeContext } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://projects-test.local"; +const WS = "testws"; +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_CWD = process.cwd(); + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, + { id: "proj-web", identifier: "WEB", name: "Web Project" }, +]; + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +let tempHome = ""; + +beforeEach(() => { + _clearProjectCache(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-projects-")); + process.env.HOME = tempHome; + process.chdir(tempHome); + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; + delete process.env.PLANE_PROJECT; +}); + +afterEach(() => { + server.resetHandlers(); + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + process.chdir(ORIGINAL_CWD); + fs.rmSync(tempHome, { force: true, recursive: true }); +}); + +describe("projectsUse", () => { + it("routes through the root CLI for local project persistence", async () => { + const { projects } = await import("@/commands/projects"); + const { getLocalConfigFilePath } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + const root = Command.make("plane").pipe( + Command.withSubcommands([projects]), + ); + const cli = Command.run(root, { name: "plane", version: "0.0.0" }); + + await Effect.runPromise( + cli(["_", "_", "projects", "use", "--local", "WEB"]).pipe( + Effect.provide(Layer.mergeAll(NodeContext.layer)), + ), + ); + + const saved = JSON.parse( + fs.readFileSync(getLocalConfigFilePath(repoDir), "utf8"), + ) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("WEB"); + }); + + it("persists the current project in global config by default", async () => { + const { projectsUseHandler } = await import("@/commands/projects"); + const { getConfigFilePath } = await import("@/user-config"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + projectsUseHandler({ project: "ACME", global: false, local: false }), + ); + } finally { + console.log = orig; + } + + const saved = JSON.parse(fs.readFileSync(getConfigFilePath(), "utf8")) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("ACME"); + expect(logs.join("\n")).toContain("Current project set to ACME (global)"); + }); + + it("persists the current project in local config when a local config is active", async () => { + const { projectsUseHandler } = await import("@/commands/projects"); + const { getLocalConfigFilePath, writeLocalStoredConfig } = await import( + "@/user-config" + ); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "packages", "web"); + fs.mkdirSync(nestedDir, { recursive: true }); + writeLocalStoredConfig( + { workspace: WS, token: "test-token", host: BASE }, + { cwd: repoDir, target: "cwd" }, + ); + process.chdir(nestedDir); + + await Effect.runPromise( + projectsUseHandler({ project: "WEB", global: false, local: false }), + ); + + const saved = JSON.parse( + fs.readFileSync(getLocalConfigFilePath(repoDir), "utf8"), + ) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("WEB"); + }); + + it("allows forcing a global current project even when local config is active", async () => { + const { projectsUseHandler } = await import("@/commands/projects"); + const { getConfigFilePath, writeLocalStoredConfig } = await import( + "@/user-config" + ); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "apps", "cli"); + fs.mkdirSync(nestedDir, { recursive: true }); + writeLocalStoredConfig( + { workspace: WS, token: "test-token", host: BASE }, + { cwd: repoDir, target: "cwd" }, + ); + process.chdir(nestedDir); + + await Effect.runPromise( + projectsUseHandler({ project: "ACME", global: true, local: false }), + ); + + const saved = JSON.parse(fs.readFileSync(getConfigFilePath(), "utf8")) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("ACME"); + }); +}); + +describe("projectsCurrent", () => { + it("prints the saved current project", async () => { + const { writeStoredConfig } = await import("@/user-config"); + writeStoredConfig({ defaultProject: "WEB" }); + const { projectsCurrentHandler } = await import("@/commands/projects"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(projectsCurrentHandler()); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("WEB"); + expect(logs.join("\n")).toContain("proj-web"); + expect(logs.join("\n")).toContain("Web Project"); + expect(logs.join("\n")).toContain("(global)"); + }); + + it("reports when the effective current project comes from local config", async () => { + const { writeLocalStoredConfig } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "services", "api"); + fs.mkdirSync(nestedDir, { recursive: true }); + writeLocalStoredConfig( + { + workspace: WS, + token: "test-token", + host: BASE, + defaultProject: "ACME", + }, + { cwd: repoDir, target: "cwd" }, + ); + process.chdir(nestedDir); + const { projectsCurrentHandler } = await import("@/commands/projects"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(projectsCurrentHandler()); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("ACME"); + expect(logs.join("\n")).toContain("(local)"); + }); +}); + +describe("projectsList", () => { + it("marks the saved current project", async () => { + const { writeStoredConfig } = await import("@/user-config"); + writeStoredConfig({ defaultProject: "WEB" }); + const { projectsListHandler } = await import("@/commands/projects"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(projectsListHandler()); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("* WEB"); + }); +}); diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index b1d2293..1f3175a 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -8,15 +8,15 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { - resolveProject, - parseIssueRef, + _clearProjectCache, findIssueBySeq, - getStateId, getMemberId, - _clearProjectCache, + getStateId, + parseIssueRef, + resolveProject, } from "@/resolve"; const BASE = "http://test.local"; @@ -75,16 +75,17 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; }); describe("resolveProject", () => { @@ -126,6 +127,20 @@ describe("resolveProject", () => { const result = await Effect.runPromise(resolveProject("WEB")); expect(result.id).toBe("proj-web"); // still from cache }); + + it("resolves @current from PLANE_PROJECT", async () => { + process.env.PLANE_PROJECT = "WEB"; + const result = await Effect.runPromise(resolveProject("@current")); + expect(result.key).toBe("WEB"); + expect(result.id).toBe("proj-web"); + }); + + it("resolves blank project from PLANE_PROJECT", async () => { + process.env.PLANE_PROJECT = "ACME"; + const result = await Effect.runPromise(resolveProject("")); + expect(result.key).toBe("ACME"); + expect(result.id).toBe("proj-acme"); + }); }); describe("parseIssueRef", () => { diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts new file mode 100644 index 0000000..c2450f7 --- /dev/null +++ b/tests/user-config.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_CWD = process.cwd(); + +let tempHome = ""; + +beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-config-")); + process.env.HOME = tempHome; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_PROJECT; + process.chdir(tempHome); +}); + +afterEach(() => { + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_PROJECT; + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + process.chdir(ORIGINAL_CWD); + fs.rmSync(tempHome, { force: true, recursive: true }); +}); + +describe("user config layering", () => { + it("uses the nearest local config over global config", async () => { + const { + findNearestLocalConfigFilePath, + getConfigDetails, + getLocalConfigFilePath, + writeGlobalStoredConfig, + writeLocalStoredConfig, + } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + const appDir = path.join(repoDir, "apps", "web"); + const nestedDir = path.join(appDir, "src"); + fs.mkdirSync(nestedDir, { recursive: true }); + + writeGlobalStoredConfig({ + token: "global-token", + host: "https://global.plane.local", + workspace: "global-workspace", + defaultProject: "GLOBAL", + }); + writeLocalStoredConfig( + { + workspace: "repo-workspace", + defaultProject: "REPO", + }, + { cwd: repoDir, target: "cwd" }, + ); + writeLocalStoredConfig( + { + host: "https://app.plane.local/", + defaultProject: "APP", + }, + { cwd: appDir, target: "cwd" }, + ); + + const config = getConfigDetails(nestedDir); + + expect(findNearestLocalConfigFilePath(nestedDir)).toBe( + getLocalConfigFilePath(appDir), + ); + expect(config.token).toBe("global-token"); + expect(config.workspace).toBe("global-workspace"); + expect(config.host).toBe("https://app.plane.local"); + expect(config.defaultProject).toBe("APP"); + expect(config.sources.token).toBe("global"); + expect(config.sources.host).toBe("local"); + expect(config.sources.workspace).toBe("global"); + expect(config.sources.defaultProject).toBe("local"); + }); + + it("applies canonical env vars above local and global config", async () => { + const { + getConfigDetails, + writeGlobalStoredConfig, + writeLocalStoredConfig, + } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "packages", "sdk"); + fs.mkdirSync(nestedDir, { recursive: true }); + + writeGlobalStoredConfig({ + token: "global-token", + host: "https://global.plane.local", + workspace: "global-workspace", + defaultProject: "GLOBAL", + }); + writeLocalStoredConfig( + { + token: "local-token", + host: "https://local.plane.local", + workspace: "local-workspace", + defaultProject: "LOCAL", + }, + { cwd: repoDir, target: "cwd" }, + ); + + process.env.PLANE_API_TOKEN = "env-token"; + process.env.PLANE_HOST = "https://env.plane.local/"; + process.env.PLANE_WORKSPACE = "env-workspace"; + process.env.PLANE_PROJECT = "ENV"; + + const config = getConfigDetails(nestedDir); + + expect(config.token).toBe("env-token"); + expect(config.host).toBe("https://env.plane.local"); + expect(config.workspace).toBe("env-workspace"); + expect(config.defaultProject).toBe("ENV"); + expect(config.sources.token).toBe("env"); + expect(config.sources.host).toBe("env"); + expect(config.sources.workspace).toBe("env"); + expect(config.sources.defaultProject).toBe("env"); + }); +}); From 20c676bfe56ca4bc8868089979cbc84c6828aa4e Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Mon, 30 Mar 2026 22:20:41 +0700 Subject: [PATCH 02/20] Clean test harnesses for repo quality gates --- src/commands/members.ts | 2 +- src/output.ts | 4 +- tests/cycles-extended.test.ts | 14 +++--- tests/format.test.ts | 2 +- tests/helpers/mock-api.ts | 2 +- tests/intake.test.ts | 14 +++--- tests/issue-activity.test.ts | 15 +++--- tests/issue-comments-worklogs.test.ts | 14 +++--- tests/issue-links.test.ts | 14 +++--- tests/json-output.test.ts | 70 +++++++++++++-------------- tests/modules.test.ts | 14 +++--- tests/new-schemas.test.ts | 6 +-- tests/new-schemas2.test.ts | 12 ++--- tests/output.test.ts | 2 +- tests/pages.test.ts | 21 +++++--- tests/schemas.test.ts | 12 ++--- tests/xml-output.test.ts | 70 +++++++++++++-------------- 17 files changed, 146 insertions(+), 142 deletions(-) diff --git a/src/commands/members.ts b/src/commands/members.ts index 0dce79d..788e327 100644 --- a/src/commands/members.ts +++ b/src/commands/members.ts @@ -2,7 +2,7 @@ import { Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { MembersResponseSchema } from "../config.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; export const membersList = Command.make("list", {}, () => Effect.gen(function* () { diff --git a/src/output.ts b/src/output.ts index f911b04..8f6af21 100644 --- a/src/output.ts +++ b/src/output.ts @@ -34,9 +34,9 @@ function toXmlItem(obj: Record, tag = "item"): string { : toXmlItem(v as Record, k), ) .join(""); - return `<${tag}${attrs ? " " + attrs : ""}>${children}`; + return `<${tag}${attrs ? ` ${attrs}` : ""}>${children}`; } export function toXml(results: readonly unknown[]): string { - return `\n${results.map((r) => " " + toXmlItem(r as Record)).join("\n")}\n`; + return `\n${results.map((r) => ` ${toXmlItem(r as Record)}`).join("\n")}\n`; } diff --git a/tests/cycles-extended.test.ts b/tests/cycles-extended.test.ts index c1ddee6..f6e5632 100644 --- a/tests/cycles-extended.test.ts +++ b/tests/cycles-extended.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -66,16 +66,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("cyclesList", () => { diff --git a/tests/format.test.ts b/tests/format.test.ts index 999d480..3d22fdc 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { escapeHtmlText, formatIssue } from "@/format"; import type { Issue } from "@/config"; +import { escapeHtmlText, formatIssue } from "@/format"; const stateObj = { id: "s1", name: "In Progress", group: "started" }; diff --git a/tests/helpers/mock-api.ts b/tests/helpers/mock-api.ts index 3046f76..43922dc 100644 --- a/tests/helpers/mock-api.ts +++ b/tests/helpers/mock-api.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; export const BASE = "http://localhost:3737"; diff --git a/tests/intake.test.ts b/tests/intake.test.ts index 639d1e0..b78d77b 100644 --- a/tests/intake.test.ts +++ b/tests/intake.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -62,16 +62,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("intakeList", () => { diff --git a/tests/issue-activity.test.ts b/tests/issue-activity.test.ts index fe7dc63..2abd76d 100644 --- a/tests/issue-activity.test.ts +++ b/tests/issue-activity.test.ts @@ -6,10 +6,9 @@ import { describe, expect, it, - mock, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -67,16 +66,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("issueActivity command handler", () => { diff --git a/tests/issue-comments-worklogs.test.ts b/tests/issue-comments-worklogs.test.ts index d86d77a..47a19e2 100644 --- a/tests/issue-comments-worklogs.test.ts +++ b/tests/issue-comments-worklogs.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -73,16 +73,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("issueCommentsList", () => { diff --git a/tests/issue-links.test.ts b/tests/issue-links.test.ts index 0a81e00..6119cc2 100644 --- a/tests/issue-links.test.ts +++ b/tests/issue-links.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -60,16 +60,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("issueLinkList", () => { diff --git a/tests/json-output.test.ts b/tests/json-output.test.ts index 6c30604..a146684 100644 --- a/tests/json-output.test.ts +++ b/tests/json-output.test.ts @@ -10,13 +10,13 @@ import { describe, expect, it, + mock, } from "bun:test"; -import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; -import { mock } from "bun:test"; -import { _clearProjectCache } from "@/resolve"; import { toXml } from "@/output"; +import { _clearProjectCache } from "@/resolve"; // Set jsonMode=true for this entire test file before command modules load mock.module("@/output", () => ({ @@ -182,16 +182,16 @@ afterAll(() => { beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); async function captureLogs(fn: () => Promise): Promise { @@ -208,9 +208,9 @@ async function captureLogs(fn: () => Promise): Promise { describe("cyclesList --json", () => { it("outputs JSON array of cycles", async () => { - const { cyclesList } = await import("@/commands/cycles"); + const { cyclesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => - Effect.runPromise((cyclesList as any).handler({ project: "ACME" })), + Effect.runPromise(cyclesListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -220,10 +220,10 @@ describe("cyclesList --json", () => { describe("cycleIssuesList --json", () => { it("outputs JSON array of cycle issues", async () => { - const { cycleIssuesList } = await import("@/commands/cycles"); + const { cycleIssuesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => Effect.runPromise( - (cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }), + cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }), ), ); const parsed = JSON.parse(output); @@ -234,9 +234,9 @@ describe("cycleIssuesList --json", () => { describe("modulesList --json", () => { it("outputs JSON array of modules", async () => { - const { modulesList } = await import("@/commands/modules"); + const { modulesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => - Effect.runPromise((modulesList as any).handler({ project: "ACME" })), + Effect.runPromise(modulesListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -246,10 +246,10 @@ describe("modulesList --json", () => { describe("moduleIssuesList --json", () => { it("outputs JSON array of module issues", async () => { - const { moduleIssuesList } = await import("@/commands/modules"); + const { moduleIssuesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => Effect.runPromise( - (moduleIssuesList as any).handler({ + moduleIssuesListHandler({ project: "ACME", moduleId: "mod1", }), @@ -263,9 +263,9 @@ describe("moduleIssuesList --json", () => { describe("intakeList --json", () => { it("outputs JSON array of intake issues", async () => { - const { intakeList } = await import("@/commands/intake"); + const { intakeListHandler } = await import("@/commands/intake"); const output = await captureLogs(() => - Effect.runPromise((intakeList as any).handler({ project: "ACME" })), + Effect.runPromise(intakeListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -275,9 +275,9 @@ describe("intakeList --json", () => { describe("pagesList --json", () => { it("outputs JSON array of pages", async () => { - const { pagesList } = await import("@/commands/pages"); + const { pagesListHandler } = await import("@/commands/pages"); const output = await captureLogs(() => - Effect.runPromise((pagesList as any).handler({ project: "ACME" })), + Effect.runPromise(pagesListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -287,9 +287,9 @@ describe("pagesList --json", () => { describe("issueActivity --json", () => { it("outputs JSON array of activities", async () => { - const { issueActivity } = await import("@/commands/issue"); + const { issueActivityHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueActivity as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueActivityHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -299,9 +299,9 @@ describe("issueActivity --json", () => { describe("issueLinkList --json", () => { it("outputs JSON array of links", async () => { - const { issueLinkList } = await import("@/commands/issue"); + const { issueLinkListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueLinkListHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -311,9 +311,9 @@ describe("issueLinkList --json", () => { describe("issueCommentsList --json", () => { it("outputs JSON array of comments", async () => { - const { issueCommentsList } = await import("@/commands/issue"); + const { issueCommentsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueCommentsListHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -323,9 +323,9 @@ describe("issueCommentsList --json", () => { describe("issueWorklogsList --json", () => { it("outputs JSON array of worklogs", async () => { - const { issueWorklogsList } = await import("@/commands/issue"); + const { issueWorklogsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueWorklogsListHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -335,14 +335,14 @@ describe("issueWorklogsList --json", () => { describe("issuesList --json", () => { it("outputs JSON array of issues", async () => { - const { issuesList } = await import("@/commands/issues"); + const { issuesListHandler } = await import("@/commands/issues"); const output = await captureLogs(() => Effect.runPromise( - (issuesList as any).handler({ + issuesListHandler({ project: "ACME", - state: { _tag: "None" }, - assignee: { _tag: "None" }, - priority: { _tag: "None" }, + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), }), ), ); diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 0094fc6..26b80ad 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -60,16 +60,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("modulesList", () => { diff --git a/tests/new-schemas.test.ts b/tests/new-schemas.test.ts index 7e51f4f..80574bd 100644 --- a/tests/new-schemas.test.ts +++ b/tests/new-schemas.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "bun:test"; import { Effect, Schema } from "effect"; import { - ActivitySchema, ActivitiesResponseSchema, + ActivitySchema, IssueLinkSchema, IssueLinksResponseSchema, - ModuleSchema, - ModulesResponseSchema, ModuleIssueSchema, ModuleIssuesResponseSchema, + ModuleSchema, + ModulesResponseSchema, } from "@/config"; async function decode( diff --git a/tests/new-schemas2.test.ts b/tests/new-schemas2.test.ts index 3ad4d1c..9d05eba 100644 --- a/tests/new-schemas2.test.ts +++ b/tests/new-schemas2.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "bun:test"; import { Effect, Schema } from "effect"; import { - WorklogSchema, - WorklogsResponseSchema, - IntakeIssueSchema, - IntakeIssuesResponseSchema, - PageSchema, - PagesResponseSchema, CommentSchema, CommentsResponseSchema, CycleIssueSchema, CycleIssuesResponseSchema, + IntakeIssueSchema, + IntakeIssuesResponseSchema, + PageSchema, + PagesResponseSchema, + WorklogSchema, + WorklogsResponseSchema, } from "@/config"; async function decode( diff --git a/tests/output.test.ts b/tests/output.test.ts index d7906be..ff61cb9 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { toXml } from "@/output"; describe("toXml", () => { diff --git a/tests/pages.test.ts b/tests/pages.test.ts index 898422d..8a88bf3 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -60,16 +60,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("pagesList", () => { @@ -364,7 +364,12 @@ describe("pagesDuplicate", () => { server.use( http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/duplicate/`, - () => HttpResponse.json({ ...NEW_PAGE, id: "pg-dup", name: "New Page (copy)" }), + () => + HttpResponse.json({ + ...NEW_PAGE, + id: "pg-dup", + name: "New Page (copy)", + }), ), ); const { pagesDuplicateHandler } = await import("@/commands/pages"); diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index 4e279f1..5bb481a 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "bun:test"; import { Effect, Schema } from "effect"; import { - StateSchema, + CycleSchema, + CyclesResponseSchema, IssueSchema, IssuesResponseSchema, - StatesResponseSchema, - ProjectSchema, - ProjectsResponseSchema, LabelSchema, LabelsResponseSchema, MemberSchema, MembersResponseSchema, - CycleSchema, - CyclesResponseSchema, + ProjectSchema, + ProjectsResponseSchema, + StateSchema, + StatesResponseSchema, } from "@/config"; async function decode( diff --git a/tests/xml-output.test.ts b/tests/xml-output.test.ts index 5f42e5d..e961851 100644 --- a/tests/xml-output.test.ts +++ b/tests/xml-output.test.ts @@ -10,13 +10,13 @@ import { describe, expect, it, + mock, } from "bun:test"; -import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; -import { mock } from "bun:test"; -import { _clearProjectCache } from "@/resolve"; import { toXml } from "@/output"; +import { _clearProjectCache } from "@/resolve"; // Set xmlMode=true for this entire test file before command modules load mock.module("@/output", () => ({ @@ -182,16 +182,16 @@ afterAll(() => { beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; }); afterEach(() => { server.resetHandlers(); - delete process.env["PLANE_HOST"]; - delete process.env["PLANE_WORKSPACE"]; - delete process.env["PLANE_API_TOKEN"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); async function captureLogs(fn: () => Promise): Promise { @@ -208,9 +208,9 @@ async function captureLogs(fn: () => Promise): Promise { describe("cyclesList --xml", () => { it("outputs XML of cycles", async () => { - const { cyclesList } = await import("@/commands/cycles"); + const { cyclesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => - Effect.runPromise((cyclesList as any).handler({ project: "ACME" })), + Effect.runPromise(cyclesListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("cyc1"); @@ -219,10 +219,10 @@ describe("cyclesList --xml", () => { describe("cycleIssuesList --xml", () => { it("outputs XML of cycle issues", async () => { - const { cycleIssuesList } = await import("@/commands/cycles"); + const { cycleIssuesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => Effect.runPromise( - (cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }), + cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }), ), ); expect(output).toContain(""); @@ -232,9 +232,9 @@ describe("cycleIssuesList --xml", () => { describe("modulesList --xml", () => { it("outputs XML of modules", async () => { - const { modulesList } = await import("@/commands/modules"); + const { modulesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => - Effect.runPromise((modulesList as any).handler({ project: "ACME" })), + Effect.runPromise(modulesListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("mod1"); @@ -243,10 +243,10 @@ describe("modulesList --xml", () => { describe("moduleIssuesList --xml", () => { it("outputs XML of module issues", async () => { - const { moduleIssuesList } = await import("@/commands/modules"); + const { moduleIssuesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => Effect.runPromise( - (moduleIssuesList as any).handler({ + moduleIssuesListHandler({ project: "ACME", moduleId: "mod1", }), @@ -259,9 +259,9 @@ describe("moduleIssuesList --xml", () => { describe("intakeList --xml", () => { it("outputs XML of intake issues", async () => { - const { intakeList } = await import("@/commands/intake"); + const { intakeListHandler } = await import("@/commands/intake"); const output = await captureLogs(() => - Effect.runPromise((intakeList as any).handler({ project: "ACME" })), + Effect.runPromise(intakeListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("int1"); @@ -270,9 +270,9 @@ describe("intakeList --xml", () => { describe("pagesList --xml", () => { it("outputs XML of pages", async () => { - const { pagesList } = await import("@/commands/pages"); + const { pagesListHandler } = await import("@/commands/pages"); const output = await captureLogs(() => - Effect.runPromise((pagesList as any).handler({ project: "ACME" })), + Effect.runPromise(pagesListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("pg1"); @@ -281,9 +281,9 @@ describe("pagesList --xml", () => { describe("issueActivity --xml", () => { it("outputs XML of activities", async () => { - const { issueActivity } = await import("@/commands/issue"); + const { issueActivityHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueActivity as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueActivityHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("act1"); @@ -292,9 +292,9 @@ describe("issueActivity --xml", () => { describe("issueLinkList --xml", () => { it("outputs XML of links", async () => { - const { issueLinkList } = await import("@/commands/issue"); + const { issueLinkListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueLinkListHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("lnk1"); @@ -303,9 +303,9 @@ describe("issueLinkList --xml", () => { describe("issueCommentsList --xml", () => { it("outputs XML of comments", async () => { - const { issueCommentsList } = await import("@/commands/issue"); + const { issueCommentsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueCommentsListHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("cmt1"); @@ -314,9 +314,9 @@ describe("issueCommentsList --xml", () => { describe("issueWorklogsList --xml", () => { it("outputs XML of worklogs", async () => { - const { issueWorklogsList } = await import("@/commands/issue"); + const { issueWorklogsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueWorklogsListHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("wl1"); @@ -325,14 +325,14 @@ describe("issueWorklogsList --xml", () => { describe("issuesList --xml", () => { it("outputs XML of issues", async () => { - const { issuesList } = await import("@/commands/issues"); + const { issuesListHandler } = await import("@/commands/issues"); const output = await captureLogs(() => Effect.runPromise( - (issuesList as any).handler({ + issuesListHandler({ project: "ACME", - state: { _tag: "None" }, - assignee: { _tag: "None" }, - priority: { _tag: "None" }, + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), }), ), ); From 8c2d4fc56101bf26e7c8ecc710a5d7611d964b96 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Mon, 30 Mar 2026 23:30:08 +0700 Subject: [PATCH 03/20] feat: gate commands on project feature flags --- src/commands/cycles.ts | 10 ++++- src/commands/intake.ts | 5 ++- src/commands/modules.ts | 11 +++++- src/commands/pages.ts | 12 +++++- src/config.ts | 37 ++++++++++++++++++ src/resolve.ts | 74 ++++++++++++++++++++++++++++++++++- tests/cycles-extended.test.ts | 13 ++++++ tests/intake.test.ts | 13 ++++++ tests/json-output.test.ts | 13 ++++++ tests/modules.test.ts | 13 ++++++ tests/pages.test.ts | 13 ++++++ tests/xml-output.test.ts | 13 ++++++ 12 files changed, 222 insertions(+), 5 deletions(-) diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 3c2ce22..f69dc9f 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -3,7 +3,12 @@ import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { CycleIssuesResponseSchema, CyclesResponseSchema } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { findIssueBySeq, parseIssueRef, resolveProject } from "../resolve.js"; +import { + findIssueBySeq, + parseIssueRef, + requireProjectFeature, + resolveProject, +} from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( @@ -22,6 +27,7 @@ const cycleIdArg = Args.text({ name: "cycle-id" }).pipe( export function cyclesListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); const raw = yield* api.get(`projects/${id}/cycles/`); const { results } = yield* decodeOrFail(CyclesResponseSchema, raw); if (jsonMode) { @@ -67,6 +73,7 @@ export function cycleIssuesListHandler({ }) { return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); const raw = yield* api.get( `projects/${id}/cycles/${cycleId}/cycle-issues/`, ); @@ -121,6 +128,7 @@ export function cycleIssuesAddHandler({ }) { return Effect.gen(function* () { const { id: projectId } = yield* resolveProject(project); + yield* requireProjectFeature(projectId, "cycle_view"); const { seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.post(`projects/${projectId}/cycles/${cycleId}/cycle-issues/`, { diff --git a/src/commands/intake.ts b/src/commands/intake.ts index dba133d..70e0b0b 100644 --- a/src/commands/intake.ts +++ b/src/commands/intake.ts @@ -3,7 +3,7 @@ import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { IntakeIssuesResponseSchema } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { resolveProject } from "../resolve.js"; +import { requireProjectFeature, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( @@ -27,6 +27,7 @@ const STATUS_LABELS: Record = { export function intakeListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "inbox_view"); const raw = yield* api.get(`projects/${id}/intake-issues/`); const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw); if (jsonMode) { @@ -78,6 +79,7 @@ export function intakeAcceptHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "inbox_view"); yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { status: 1, }); @@ -106,6 +108,7 @@ export function intakeRejectHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "inbox_view"); yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { status: -2, }); diff --git a/src/commands/modules.ts b/src/commands/modules.ts index d029c3e..4890e2f 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -6,7 +6,12 @@ import { ModulesResponseSchema, } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { findIssueBySeq, parseIssueRef, resolveProject } from "../resolve.js"; +import { + findIssueBySeq, + parseIssueRef, + requireProjectFeature, + resolveProject, +} from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( @@ -25,6 +30,7 @@ const moduleIdArg = Args.text({ name: "module-id" }).pipe( export function modulesListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); const raw = yield* api.get(`projects/${id}/modules/`); const { results } = yield* decodeOrFail(ModulesResponseSchema, raw); if (jsonMode) { @@ -68,6 +74,7 @@ export function moduleIssuesListHandler({ }) { return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); const raw = yield* api.get( `projects/${id}/modules/${moduleId}/module-issues/`, ); @@ -122,6 +129,7 @@ export function moduleIssuesAddHandler({ }) { return Effect.gen(function* () { const { id: projectId } = yield* resolveProject(project); + yield* requireProjectFeature(projectId, "module_view"); const { seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.post( @@ -163,6 +171,7 @@ export function moduleIssuesRemoveHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); yield* api.delete( `projects/${id}/modules/${moduleId}/module-issues/${moduleIssueId}/`, ); diff --git a/src/commands/pages.ts b/src/commands/pages.ts index bdd0963..01306ed 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -3,7 +3,7 @@ import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; import { PageSchema, PagesResponseSchema } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { resolveProject } from "../resolve.js"; +import { requireProjectFeature, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( @@ -43,6 +43,7 @@ interface PageUpdatePayload { export function pagesListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const raw = yield* api.get(`projects/${id}/pages/`); const { results } = yield* decodeOrFail(PagesResponseSchema, raw); if (jsonMode) { @@ -86,6 +87,7 @@ export function pagesGetHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const raw = yield* api.get(`projects/${id}/pages/${pageId}/`); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(JSON.stringify(page, null, 2)); @@ -115,6 +117,7 @@ export function pagesCreateHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const body: PageCreatePayload = { name }; if (Option.isSome(description)) { body.description_html = description.value; @@ -153,6 +156,7 @@ export function pagesUpdateHandler({ yield* Effect.fail(new Error("provide at least --name or --description")); } const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const body: PageUpdatePayload = {}; if (Option.isSome(name)) body.name = name.value; if (Option.isSome(description)) body.description_html = description.value; @@ -188,6 +192,7 @@ export function pagesDeleteHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/`); yield* Console.log(`Deleted page ${pageId}`); }); @@ -214,6 +219,7 @@ export function pagesArchiveHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {}); yield* Console.log(`Archived page ${pageId}`); }); @@ -240,6 +246,7 @@ export function pagesUnarchiveHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/archive/`); yield* Console.log(`Unarchived page ${pageId}`); }); @@ -266,6 +273,7 @@ export function pagesLockHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {}); yield* Console.log(`Locked page ${pageId}`); }); @@ -292,6 +300,7 @@ export function pagesUnlockHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/lock/`); yield* Console.log(`Unlocked page ${pageId}`); }); @@ -318,6 +327,7 @@ export function pagesDuplicateHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const raw = yield* api.post( `projects/${id}/pages/${pageId}/duplicate/`, {}, diff --git a/src/config.ts b/src/config.ts index 0ff9bf9..aef2570 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,6 +73,43 @@ export const ProjectSchema = Schema.Struct({ }); export type Project = typeof ProjectSchema.Type; +export const ProjectDetailSchema = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + name: Schema.String, + module_view: Schema.Boolean, + cycle_view: Schema.Boolean, + issue_views_view: Schema.Boolean, + page_view: Schema.Boolean, + inbox_view: Schema.Boolean, + estimate: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type ProjectDetail = typeof ProjectDetailSchema.Type; + +export const EstimateSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + type: Schema.String, + last_used: Schema.optional(Schema.Boolean), + project: Schema.String, + workspace: Schema.String, +}); +export type Estimate = typeof EstimateSchema.Type; + +export const EstimatePointSchema = Schema.Struct({ + id: Schema.String, + estimate: Schema.String, + key: Schema.optional(Schema.Number), + value: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + project: Schema.String, + workspace: Schema.String, +}); +export type EstimatePoint = typeof EstimatePointSchema.Type; + +export const EstimatePointsResponseSchema = Schema.Array(EstimatePointSchema); + export const ProjectsResponseSchema = Schema.Struct({ results: Schema.Array(ProjectSchema), }); diff --git a/src/resolve.ts b/src/resolve.ts index 2eb4c49..51b416a 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,10 +1,11 @@ import { Effect } from "effect"; import { api, decodeOrFail } from "./api.js"; -import type { Issue } from "./config.js"; +import type { Issue, ProjectDetail } from "./config.js"; import { IssuesResponseSchema, LabelsResponseSchema, MembersResponseSchema, + ProjectDetailSchema, ProjectsResponseSchema, StatesResponseSchema, } from "./config.js"; @@ -12,6 +13,27 @@ import { getConfig } from "./user-config.js"; // Cache project list within a process invocation let _projectCache: Record | null = null; +let _projectDetailCache: Record | null = null; + +type ProjectFeatureKey = + | "cycle_view" + | "module_view" + | "page_view" + | "inbox_view"; + +const FEATURE_LABELS: Record = { + cycle_view: "Cycles", + module_view: "Modules", + page_view: "Pages", + inbox_view: "Intake", +}; + +const FEATURE_HINTS: Record = { + cycle_view: "Enable Cycles in the Plane project settings.", + module_view: "Enable Modules in the Plane project settings.", + page_view: "Enable Pages in the Plane project settings.", + inbox_view: "Enable Intake in the Plane project settings.", +}; function getConfiguredProject(identifier: string): string { const trimmed = identifier.trim(); @@ -35,6 +57,7 @@ function getConfiguredProject(identifier: string): string { /** Clear the project cache — for use in tests only */ export function _clearProjectCache(): void { _projectCache = null; + _projectDetailCache = null; } function getProjectMap(): Effect.Effect, Error> { @@ -49,6 +72,55 @@ function getProjectMap(): Effect.Effect, Error> { }); } +function getProjectDetail( + projectId: string, +): Effect.Effect { + if (_projectDetailCache?.[projectId]) { + return Effect.succeed(_projectDetailCache[projectId]); + } + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/`); + const project = yield* decodeOrFail(ProjectDetailSchema, raw); + _projectDetailCache ??= {}; + _projectDetailCache[projectId] = project; + return project; + }); +} + +export function getProjectFeatureDetails(projectId: string) { + return getProjectDetail(projectId).pipe( + Effect.map((project) => ({ + project, + features: { + Cycles: project.cycle_view, + Modules: project.module_view, + Views: project.issue_views_view, + Pages: project.page_view, + Intake: project.inbox_view, + }, + })), + ); +} + +export function requireProjectFeature( + projectId: string, + feature: ProjectFeatureKey, +): Effect.Effect { + return getProjectDetail(projectId).pipe( + Effect.flatMap((project) => { + if (project[feature]) { + return Effect.succeed(void 0); + } + const featureLabel = FEATURE_LABELS[feature]; + return Effect.fail( + new Error( + `Project ${project.identifier} has ${featureLabel} disabled (${feature}=false). ${FEATURE_HINTS[feature]}`, + ), + ); + }), + ); +} + export function resolveProject( identifier: string, ): Effect.Effect<{ key: string; id: string }, Error> { diff --git a/tests/cycles-extended.test.ts b/tests/cycles-extended.test.ts index f6e5632..e69c50d 100644 --- a/tests/cycles-extended.test.ts +++ b/tests/cycles-extended.test.ts @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -49,6 +59,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), diff --git a/tests/intake.test.ts b/tests/intake.test.ts index b78d77b..a3510af 100644 --- a/tests/intake.test.ts +++ b/tests/intake.test.ts @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const INTAKE_ISSUES = [ { id: "int1", @@ -51,6 +61,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`, () => HttpResponse.json({ results: INTAKE_ISSUES }), diff --git a/tests/json-output.test.ts b/tests/json-output.test.ts index a146684..19b8e7b 100644 --- a/tests/json-output.test.ts +++ b/tests/json-output.test.ts @@ -29,6 +29,16 @@ const BASE = "http://json-output-test.local"; const WS = "testws"; const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme" }]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -124,6 +134,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 26b80ad..1e35039 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -43,6 +53,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), diff --git a/tests/pages.test.ts b/tests/pages.test.ts index 8a88bf3..6a22af3 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const PAGES = [ { id: "pg1", @@ -46,6 +56,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, () => HttpResponse.json({ results: PAGES }), ), diff --git a/tests/xml-output.test.ts b/tests/xml-output.test.ts index e961851..fa631bb 100644 --- a/tests/xml-output.test.ts +++ b/tests/xml-output.test.ts @@ -29,6 +29,16 @@ const BASE = "http://xml-output-test.local"; const WS = "testws"; const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme" }]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -124,6 +134,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), From 56e69e5193d8bdc68041cf4a97f714e5b12757fc Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Mon, 30 Mar 2026 23:30:46 +0700 Subject: [PATCH 04/20] feat: add local project context for AI agents --- README.md | 3 + SKILL.md | 4 + src/app.ts | 2 + src/commands/init.ts | 204 +++++++++++++++++++++++-- src/project-agents.ts | 69 +++++++++ src/project-context.ts | 175 +++++++++++++++++++++ tests/project-features.test.ts | 272 +++++++++++++++++++++++++++++++++ 7 files changed, 718 insertions(+), 11 deletions(-) create mode 100644 src/project-agents.ts create mode 100644 src/project-context.ts create mode 100644 tests/project-features.test.ts diff --git a/README.md b/README.md index 7adf333..6807f7d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ environment variables > nearest .plane/config.json > ~/.config/plane/config.json ``` The local config is discovered from the current working directory upward, so a config written at the repo root applies inside nested folders unless a deeper `.plane/config.json` overrides it. +When you run `plane init --local`, the CLI also reads the project's feature flags from Plane and reports which project-scoped features are actually enabled. Cycles, modules, pages, and intake commands return explicit feature-disabled errors when the project has them turned off. +It also writes `.plane/project-context.json`, a machine-readable helper snapshot of the project's existing states, labels, and estimate points so agents can reuse what already exists instead of inventing duplicates. +If `AGENTS.md` already exists in that directory, `plane init --local` appends a managed Plane project context section at the bottom without removing the existing content. If it does not exist, the CLI creates it and points agents at `.plane/project-context.json`. You can also use environment variables (override saved config): diff --git a/SKILL.md b/SKILL.md index 8203ef2..9b8bb40 100644 --- a/SKILL.md +++ b/SKILL.md @@ -40,6 +40,10 @@ Local setup writes `./.plane/config.json`. Effective config resolution is: PLANE_* environment variables > nearest .plane/config.json > ~/.config/plane/config.json ``` +`plane init --local` also fetches the project's feature flags from Plane and reports which project-scoped features are actually enabled. Cycles, modules, pages, and intake commands fail with explicit feature-disabled errors when the project has them turned off. +It also writes `.plane/project-context.json`, a machine-readable helper snapshot of the project's existing states, labels, and estimate points so agents can reuse current project conventions instead of creating duplicates. +It also creates or updates `AGENTS.md` in that directory with a managed Plane context section at the bottom so AI agents know to read `.plane/project-context.json` before changing project-specific Plane resources. + Or set environment variables (override saved config): ```bash diff --git a/src/app.ts b/src/app.ts index 49245d8..3606383 100644 --- a/src/app.ts +++ b/src/app.ts @@ -71,6 +71,8 @@ FOR AI AGENTS / BOTS - Use PLANE_WORKSPACE to select the workspace - Use PLANE_PROJECT or 'plane projects use PROJ' to persist a current project - Local config lives in '.plane/config.json' and is resolved from the current directory upward + - 'plane init --local' also writes '.plane/project-context.json' with existing states, labels, and estimate points for the selected project + - 'plane init --local' also creates or updates 'AGENTS.md' so local AI agents reuse '.plane/project-context.json' for project-specific context - Full Plane REST API reference (180+ endpoints): https://developers.plane.so/api-reference/introduction`, ), diff --git a/src/commands/init.ts b/src/commands/init.ts index 0e55352..534668b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,8 +1,24 @@ import * as readline from "node:readline"; import { Command, Options } from "@effect/cli"; -import { Console, Effect } from "effect"; +import { Console, Effect, type Schema } from "effect"; import { decodeOrFail } from "../api.js"; -import { ProjectsResponseSchema } from "../config.js"; +import { + EstimatePointsResponseSchema, + EstimateSchema, + LabelsResponseSchema, + ProjectDetailSchema, + ProjectsResponseSchema, + StatesResponseSchema, +} from "../config.js"; +import { + getLocalAgentsFilePath, + writeLocalProjectAgentsFile, +} from "../project-agents.js"; +import { + buildProjectContextSnapshot, + getLocalProjectContextFilePath, + writeLocalProjectContextSnapshot, +} from "../project-context.js"; import { type ConfigScope, getConfigDetails, @@ -14,6 +30,11 @@ import { writeLocalStoredConfig, } from "../user-config.js"; +interface ProjectFeatureSummary { + label: string; + enabled: boolean; +} + function prompt(rl: readline.Interface, question: string): Promise { return new Promise((resolve) => rl.question(question, resolve)); } @@ -157,15 +178,27 @@ function fetchProjectsForConfig(config: { workspace: string; token: string; }) { + return fetchDecodedFromConfig( + ProjectsResponseSchema, + config, + "projects/", + ).pipe(Effect.map(({ results }) => results)); +} + +function requestJsonFromConfig( + config: { + host: string; + workspace: string; + token: string; + }, + path: string, +) { return Effect.gen(function* () { const response = yield* Effect.tryPromise({ try: () => - fetch( - `${config.host}/api/v1/workspaces/${config.workspace}/projects/`, - { - headers: { "X-Api-Key": config.token }, - }, - ), + fetch(`${config.host}/api/v1/workspaces/${config.workspace}/${path}`, { + headers: { "X-Api-Key": config.token }, + }), catch: (error) => error instanceof Error ? error : new Error(String(error)), }); @@ -182,11 +215,94 @@ function fetchProjectsForConfig(config: { catch: (error) => error instanceof Error ? error : new Error(String(error)), }); - const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); - return results; + return raw; + }); +} + +function fetchDecodedFromConfig( + schema: Schema.Schema, + config: { + host: string; + workspace: string; + token: string; + }, + path: string, +) { + return requestJsonFromConfig(config, path).pipe( + Effect.flatMap((raw) => decodeOrFail(schema, raw)), + ); +} + +function fetchLocalProjectHelperForConfig( + config: { + host: string; + workspace: string; + token: string; + }, + project: { id: string; identifier: string; name: string }, +) { + return Effect.gen(function* () { + const detail = yield* fetchDecodedFromConfig( + ProjectDetailSchema, + config, + `projects/${project.id}/`, + ); + const { results: states } = yield* fetchDecodedFromConfig( + StatesResponseSchema, + config, + `projects/${project.id}/states/`, + ); + const { results: labels } = yield* fetchDecodedFromConfig( + LabelsResponseSchema, + config, + `projects/${project.id}/labels/`, + ); + + let estimate = null; + let estimatePoints: readonly import("../config.js").EstimatePoint[] = []; + if (detail.estimate) { + estimate = yield* fetchDecodedFromConfig( + EstimateSchema, + config, + `projects/${project.id}/estimates/`, + ); + estimatePoints = yield* fetchDecodedFromConfig( + EstimatePointsResponseSchema, + config, + `projects/${project.id}/estimates/${estimate.id}/estimate-points/`, + ); + } + + return { + detail, + snapshot: buildProjectContextSnapshot({ + project, + detail, + states, + labels, + estimate, + estimatePoints, + }), + }; }); } +function summarizeProjectFeatures(project: { + cycle_view: boolean; + module_view: boolean; + issue_views_view: boolean; + page_view: boolean; + inbox_view: boolean; +}): ProjectFeatureSummary[] { + return [ + { label: "Cycles", enabled: project.cycle_view }, + { label: "Modules", enabled: project.module_view }, + { label: "Views", enabled: project.issue_views_view }, + { label: "Pages", enabled: project.page_view }, + { label: "Intake", enabled: project.inbox_view }, + ]; +} + export function initHandler( { global, local }: { global: boolean; local: boolean }, defaultScope: ConfigScope = "global", @@ -350,6 +466,72 @@ export function initHandler( )}`, ); } + + if (scope === "local" && savedDefaultProject) { + const selectedProject = + projectsResult._tag === "Right" + ? projectsResult.right.find( + (project) => + project.identifier.toUpperCase() === + savedDefaultProject.toUpperCase(), + ) + : undefined; + if (selectedProject) { + const projectHelper = yield* Effect.either( + fetchLocalProjectHelperForConfig( + { + host: mergedHost, + workspace: mergedWorkspace, + token: mergedToken, + }, + selectedProject, + ), + ); + if (projectHelper._tag === "Right") { + yield* Console.log("\nProject feature flags:"); + const featureSummary = summarizeProjectFeatures( + projectHelper.right.detail, + ); + for (const feature of featureSummary) { + yield* Console.log( + ` ${feature.label}: ${feature.enabled ? "enabled" : "disabled"}`, + ); + } + const disabled = featureSummary + .filter((feature) => !feature.enabled) + .map((feature) => feature.label); + if (disabled.length > 0) { + yield* Console.log( + ` Disabled features will fail with explicit errors until Plane enables them: ${disabled.join(", ")}`, + ); + } + + writeLocalProjectContextSnapshot(projectHelper.right.snapshot); + const helperPath = getLocalProjectContextFilePath(); + writeLocalProjectAgentsFile(projectHelper.right.snapshot); + const agentsPath = getLocalAgentsFilePath(); + yield* Console.log(`\nProject helper saved to ${helperPath}`); + yield* Console.log( + ` States: ${projectHelper.right.snapshot.helpers.states.total}`, + ); + yield* Console.log( + ` Labels: ${projectHelper.right.snapshot.helpers.labels.total}`, + ); + if (projectHelper.right.snapshot.helpers.estimate.enabled) { + yield* Console.log( + ` Estimate: ${projectHelper.right.snapshot.helpers.estimate.name} (${projectHelper.right.snapshot.helpers.estimate.points.length} points)`, + ); + } else { + yield* Console.log(" Estimate: disabled"); + } + yield* Console.log(`Local AGENTS.md updated at ${agentsPath}`); + } else { + yield* Console.log( + `\nWarning: could not load project helper data for ${selectedProject.identifier}: ${projectHelper.left.message}`, + ); + } + } + } }); } @@ -367,6 +549,6 @@ export const localInit = Command.make("init", {}, () => initHandler({ global: false, local: true }, "local"), ).pipe( Command.withDescription( - "Interactive local setup. Saves overrides to ./.plane/config.json in the current directory.", + "Interactive local setup. Saves overrides to ./.plane/config.json in the current directory, reports project feature flags, writes a local project helper snapshot for states, labels, and estimate points, and updates AGENTS.md with project-context guidance for AI agents.", ), ); diff --git a/src/project-agents.ts b/src/project-agents.ts new file mode 100644 index 0000000..0e86f1e --- /dev/null +++ b/src/project-agents.ts @@ -0,0 +1,69 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ProjectContextSnapshot } from "./project-context.js"; +import { getLocalConfigDir } from "./user-config.js"; + +const MANAGED_SECTION_START = ""; +const MANAGED_SECTION_END = ""; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildManagedSection(snapshot: ProjectContextSnapshot): string { + return [ + MANAGED_SECTION_START, + "## Plane Project Context", + `This directory is scoped to Plane project ${snapshot.project.identifier} (${snapshot.project.name}).`, + "", + "When working as an AI agent in this directory:", + "- Read `./.plane/project-context.json` before planning or applying Plane project changes.", + "- Reuse the existing states, labels, and estimate points in that snapshot instead of creating duplicates.", + "- Respect the feature flags in that snapshot before using cycles, modules, pages, intake, or estimates.", + "- Rerun `plane init --local` from this directory whenever the Plane project configuration changes so this context stays current.", + "", + "This section is managed by `plane-cli` and is updated by `plane init --local`.", + MANAGED_SECTION_END, + "", + ].join("\n"); +} + +function upsertManagedSection( + existingContent: string, + managedSection: string, +): string { + const managedPattern = new RegExp( + `${escapeRegExp(MANAGED_SECTION_START)}[\\s\\S]*?${escapeRegExp(MANAGED_SECTION_END)}\\n?`, + "m", + ); + + if (managedPattern.test(existingContent)) { + return existingContent.replace(managedPattern, managedSection); + } + + const trimmed = existingContent.trimEnd(); + if (!trimmed) { + return managedSection; + } + + return `${trimmed}\n\n${managedSection}`; +} + +export function getLocalAgentsFilePath(cwd = process.cwd()): string { + return path.join(path.dirname(getLocalConfigDir(cwd)), "AGENTS.md"); +} + +export function writeLocalProjectAgentsFile( + snapshot: ProjectContextSnapshot, + cwd = process.cwd(), +): void { + const filePath = getLocalAgentsFilePath(cwd); + const existingContent = fs.existsSync(filePath) + ? fs.readFileSync(filePath, "utf8") + : ""; + const nextContent = upsertManagedSection( + existingContent, + buildManagedSection(snapshot), + ); + fs.writeFileSync(filePath, nextContent, "utf8"); +} diff --git a/src/project-context.ts b/src/project-context.ts new file mode 100644 index 0000000..7ef0140 --- /dev/null +++ b/src/project-context.ts @@ -0,0 +1,175 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { + Estimate, + EstimatePoint, + Label, + ProjectDetail, + State, +} from "./config.js"; +import { getLocalConfigDir } from "./user-config.js"; + +interface ProjectSummary { + id: string; + identifier: string; + name: string; +} + +interface ProjectFeaturesSummary { + cycles: boolean; + modules: boolean; + views: boolean; + pages: boolean; + intake: boolean; + estimates: boolean; +} + +interface ProjectStateHelperEntry { + id: string; + name: string; + group: string; + color?: string; +} + +interface ProjectLabelHelperEntry { + id: string; + name: string; + color?: string | null; + parent?: string | null; +} + +interface ProjectEstimatePointHelperEntry { + id: string; + key?: number; + value: string; + description?: string | null; +} + +export interface ProjectContextSnapshot { + generatedAt: string; + project: ProjectSummary; + features: ProjectFeaturesSummary; + helpers: { + states: { + total: number; + byName: Record; + byGroup: Record; + }; + labels: { + total: number; + byName: Record; + }; + estimate: { + enabled: boolean; + id?: string; + name?: string; + type?: string; + points: ProjectEstimatePointHelperEntry[]; + pointsByValue: Record; + }; + }; +} + +function normalizeLookupKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function buildProjectContextSnapshot({ + project, + detail, + states, + labels, + estimate, + estimatePoints, +}: { + project: ProjectSummary; + detail: ProjectDetail; + states: readonly State[]; + labels: readonly Label[]; + estimate: Estimate | null; + estimatePoints: readonly EstimatePoint[]; +}): ProjectContextSnapshot { + const statesByName: Record = {}; + const statesByGroup: Record = {}; + for (const state of states) { + const entry: ProjectStateHelperEntry = { + id: state.id, + name: state.name, + group: state.group, + color: state.color, + }; + statesByName[normalizeLookupKey(state.name)] = entry; + statesByGroup[state.group] ??= []; + statesByGroup[state.group].push(entry); + } + + const labelsByName: Record = {}; + for (const label of labels) { + labelsByName[normalizeLookupKey(label.name)] = { + id: label.id, + name: label.name, + color: label.color, + parent: label.parent, + }; + } + + const points = estimatePoints + .map((point) => ({ + id: point.id, + key: point.key, + value: point.value, + description: point.description, + })) + .sort((left, right) => (left.key ?? 0) - (right.key ?? 0)); + const pointsByValue = Object.fromEntries( + points.map((point) => [normalizeLookupKey(point.value), point]), + ); + + return { + generatedAt: new Date().toISOString(), + project, + features: { + cycles: detail.cycle_view, + modules: detail.module_view, + views: detail.issue_views_view, + pages: detail.page_view, + intake: detail.inbox_view, + estimates: estimate !== null, + }, + helpers: { + states: { + total: states.length, + byName: statesByName, + byGroup: statesByGroup, + }, + labels: { + total: labels.length, + byName: labelsByName, + }, + estimate: { + enabled: estimate !== null, + id: estimate?.id, + name: estimate?.name, + type: estimate?.type, + points, + pointsByValue, + }, + }, + }; +} + +export function getLocalProjectContextFilePath(cwd = process.cwd()): string { + return path.join(getLocalConfigDir(cwd), "project-context.json"); +} + +export function writeLocalProjectContextSnapshot( + snapshot: ProjectContextSnapshot, + cwd = process.cwd(), +): void { + const filePath = getLocalProjectContextFilePath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, { + mode: 0o600, + }); + fs.chmodSync(filePath, 0o600); +} diff --git a/tests/project-features.test.ts b/tests/project-features.test.ts new file mode 100644 index 0000000..19446b2 --- /dev/null +++ b/tests/project-features.test.ts @@ -0,0 +1,272 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Effect } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://feature-gates-test.local"; +const WS = "testws"; +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_CWD = process.cwd(); + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, +]; + +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: false, + issue_views_view: true, + page_view: true, + inbox_view: true, + estimate: "est1", +}; + +const STATES = [ + { id: "st-backlog", name: "Backlog", group: "backlog", color: "#888888" }, + { + id: "st-progress", + name: "In Progress", + group: "started", + color: "#ffaa00", + }, +]; + +const LABELS = [ + { id: "lbl-ready", name: "Ready to Deploy", color: "#00aa88", parent: null }, + { id: "lbl-backend", name: "Backend", color: "#00bb66", parent: null }, +]; + +const ESTIMATE = { + id: "est1", + name: "Story Points", + description: "Default scale", + type: "points", + last_used: true, + project: "proj-acme", + workspace: "ws1", +}; + +const ESTIMATE_POINTS = [ + { + id: "ep1", + estimate: "est1", + key: 1, + value: "1", + description: "Tiny", + project: "proj-acme", + workspace: "ws1", + }, + { + id: "ep2", + estimate: "est1", + key: 2, + value: "2", + description: "Small", + project: "proj-acme", + workspace: "ws1", + }, +]; + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () => + HttpResponse.json({ results: STATES }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () => + HttpResponse.json({ results: LABELS }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/`, + () => HttpResponse.json(ESTIMATE), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/est1/estimate-points/`, + () => HttpResponse.json(ESTIMATE_POINTS), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +let tempHome = ""; +let promptResponses: string[] = []; + +mock.module("node:readline", () => ({ + createInterface: () => ({ + question: (_question: string, callback: (answer: string) => void) => { + callback(promptResponses.shift() ?? ""); + }, + close: () => undefined, + }), +})); + +beforeEach(() => { + _clearProjectCache(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-features-")); + process.env.HOME = tempHome; + process.chdir(tempHome); + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; + delete process.env.PLANE_PROJECT; + promptResponses = []; +}); + +afterEach(() => { + server.resetHandlers(); + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + process.chdir(ORIGINAL_CWD); + fs.rmSync(tempHome, { force: true, recursive: true }); +}); + +describe("feature gates", () => { + it("fails with a definitive error when cycles are disabled", async () => { + const { cyclesListHandler } = await import("@/commands/cycles"); + const result = await Effect.runPromise( + Effect.either(cyclesListHandler({ project: "ACME" })), + ); + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left.message).toContain("Project ACME has Cycles disabled"); + expect(result.left.message).toContain("cycle_view=false"); + expect(result.left.message).toContain("Enable Cycles"); + } + }); + + it("reports project feature flags during local init", async () => { + const { initHandler } = await import("@/commands/init"); + const { getLocalAgentsFilePath } = await import("@/project-agents"); + const { getLocalProjectContextFilePath } = await import( + "@/project-context" + ); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + promptResponses = ["", "", "", "1"]; + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + } finally { + console.log = orig; + } + + const output = logs.join("\n"); + expect(output).toContain("Project feature flags:"); + expect(output).toContain("Cycles: disabled"); + expect(output).toContain("Modules: enabled"); + expect(output).toContain("Project helper saved to"); + expect(output).toContain("States: 2"); + expect(output).toContain("Labels: 2"); + expect(output).toContain("Estimate: Story Points (2 points)"); + expect(output).toContain("Local AGENTS.md updated at"); + expect(output).toContain( + "Disabled features will fail with explicit errors until Plane enables them", + ); + expect(fs.existsSync(path.join(repoDir, ".plane", "config.json"))).toBe( + true, + ); + const helperPath = getLocalProjectContextFilePath(repoDir); + expect(fs.existsSync(helperPath)).toBe(true); + const agentsPath = getLocalAgentsFilePath(repoDir); + expect(fs.existsSync(agentsPath)).toBe(true); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent).toContain("## Plane Project Context"); + expect(agentsContent).toContain("Plane project ACME (Acme Project)"); + expect(agentsContent).toContain("./.plane/project-context.json"); + const helper = JSON.parse(fs.readFileSync(helperPath, "utf8")) as { + features: { estimates: boolean }; + helpers: { + states: { byName: Record }; + labels: { byName: Record }; + estimate: { + enabled: boolean; + pointsByValue: Record; + }; + }; + }; + expect(helper.features.estimates).toBe(true); + expect(helper.helpers.states.byName.backlog.id).toBe("st-backlog"); + expect(helper.helpers.labels.byName["ready to deploy"].id).toBe( + "lbl-ready", + ); + expect(helper.helpers.estimate.enabled).toBe(true); + expect(helper.helpers.estimate.pointsByValue["1"].id).toBe("ep1"); + }); + + it("preserves user AGENTS content and refreshes the managed project section", async () => { + const { initHandler } = await import("@/commands/init"); + const { getLocalAgentsFilePath } = await import("@/project-agents"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + const existingAgents = [ + "# Team Instructions", + "", + "Keep release notes short.", + ].join("\n"); + fs.writeFileSync( + path.join(repoDir, "AGENTS.md"), + `${existingAgents}\n`, + "utf8", + ); + + promptResponses = ["", "", "", "1"]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + + promptResponses = ["", "", "", ""]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + + const agentsPath = getLocalAgentsFilePath(repoDir); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent).toContain(existingAgents); + expect( + agentsContent + .trimEnd() + .endsWith(""), + ).toBe(true); + expect( + agentsContent.match(/plane-cli local project context start/g)?.length, + ).toBe(1); + expect(agentsContent).toContain( + "Read `./.plane/project-context.json` before planning or applying Plane project changes.", + ); + }); +}); From e2c94f75a28f8ca5baea5a593480a96ecc0968c8 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Mon, 30 Mar 2026 23:31:34 +0700 Subject: [PATCH 05/20] docs: expand release setup checklist --- docs/RELEASING.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1647e02..7ef6773 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,6 +4,30 @@ This repository publishes from Git tags that match `v*` through [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). +## One-Time Maintainer Setup + +Before the first public release, make sure the publication path itself is ready: + +1. Verify the npm package name is available and that the publishing account or npm organization has access to `@backslash-ux/plane`. +2. Enable npm account 2FA for maintainers. For CI publishing, either: + - keep using the current `NPM_CONFIG_TOKEN` secret with a token that is allowed to publish this package, or + - migrate the workflow to npm trusted publishing so long-lived tokens are no longer required. +3. Add the `NPM_CONFIG_TOKEN` repository secret in GitHub if the token-based workflow remains in use. +4. Confirm GitHub Actions is enabled for the repository and that the publish workflow can create releases. The current workflow already requests `contents: write`. +5. Verify the default branch is healthy before tagging: CI should pass on `main` and the version in `package.json` should match the intended release. +6. Confirm the repository URLs in `package.json` and the install instructions in `README.md` and `SKILL.md` point at the maintained fork. + +## Recommended Preflight Checks + +Run these checks before cutting a release: + +```bash +bun run check:all +bun publish --dry-run +``` + +The dry run confirms the package contents and publish metadata without pushing a release to npm. + ## Before Releasing 1. Update [CHANGELOG.md](../CHANGELOG.md) with user-facing changes. @@ -32,6 +56,18 @@ git push origin vX.Y.Z - publish the package to npm - create a GitHub release with generated notes +## After Releasing + +1. Confirm the GitHub release was created from the pushed tag. +2. Verify the package is visible on npm and that the published version matches `package.json`. +3. Smoke-test installation from the public registry: + +```bash +bunx @backslash-ux/plane --help +``` + +4. If the release changes agent workflows, confirm `README.md`, `SKILL.md`, and `AGENTS.md` guidance still matches the shipped package. + ## Required Repository Secrets - `NPM_CONFIG_TOKEN` for package publication. @@ -39,4 +75,5 @@ git push origin vX.Y.Z ## Notes - If the release changes command behavior, keep related GitHub issues, release notes, and docs aligned as part of the same change. -- If a release uncovers a workflow gap, document it here instead of relying on maintainer memory. \ No newline at end of file +- If a release uncovers a workflow gap, document it here instead of relying on maintainer memory. +- npm currently recommends trusted publishing for GitHub Actions when possible. This repository still uses `NPM_CONFIG_TOKEN`, so moving to trusted publishing plus provenance is a useful follow-up when maintainers are ready. \ No newline at end of file From 3955891689973b4a657d5271ebfc048233513034 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:46:06 +0700 Subject: [PATCH 06/20] fix: normalize host URL by auto-prepending https:// when scheme is missing Users can now enter `plane.example.com` during init instead of requiring the full `https://plane.example.com` URL. The normalizeHost helper trims whitespace and prepends https:// when no scheme is detected. --- src/user-config.ts | 11 +++++++++-- tests/user-config.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/user-config.ts b/src/user-config.ts index 7603e9e..05663fb 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -36,8 +36,15 @@ export interface PlaneConfigDetails extends PlaneConfig { const DEFAULT_HOST = "https://plane.so"; -function normalizeHost(host: string): string { - return host.replace(/\/$/, ""); +export function normalizeHost(host: string): string { + const trimmed = host.trim().replace(/\/$/, ""); + if (!trimmed) { + return trimmed; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + return trimmed; + } + return `https://${trimmed}`; } function cleanConfig(config: StoredPlaneConfig): StoredPlaneConfig { diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts index c2450f7..20ffe80 100644 --- a/tests/user-config.test.ts +++ b/tests/user-config.test.ts @@ -124,4 +124,26 @@ describe("user config layering", () => { expect(config.sources.workspace).toBe("env"); expect(config.sources.defaultProject).toBe("env"); }); + + it("normalizes inherited hosts without an explicit scheme", async () => { + const { getConfigDetails, writeGlobalStoredConfig } = await import( + "@/user-config" + ); + + writeGlobalStoredConfig({ + host: "plane.domain.com/", + workspace: "workspace-1", + token: "token-1", + }); + + const config = getConfigDetails(tempHome); + + expect(config.host).toBe("https://plane.domain.com"); + expect(config.sources.host).toBe("global"); + + process.env.PLANE_HOST = "api.plane.local"; + const envConfig = getConfigDetails(tempHome); + expect(envConfig.host).toBe("https://api.plane.local"); + expect(envConfig.sources.host).toBe("env"); + }); }); From 52cdaf4af6952f6572bf0ae4c2af3cef4b32c66e Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:46:28 +0700 Subject: [PATCH 07/20] fix: support both inbox_view and intake_view feature flag names Newer Plane deployments may use `intake_view` instead of `inbox_view`. The CLI now checks both fields via `isProjectIntakeEnabled()` and falls back transparently. Also fixes intake status codes: -2=pending, -1=rejected, 0=snoozed. --- src/commands/init.ts | 22 +++++++++++----------- src/commands/intake.ts | 39 +++++++++++++++++++++++++++++---------- src/config.ts | 35 ++++++++++++++++++++++++++++++++--- src/project-context.ts | 3 ++- src/resolve.ts | 24 ++++++++++++++++++------ tests/intake.test.ts | 15 +++++++++------ 6 files changed, 101 insertions(+), 37 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 534668b..536213c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,6 +5,7 @@ import { decodeOrFail } from "../api.js"; import { EstimatePointsResponseSchema, EstimateSchema, + isProjectIntakeEnabled, LabelsResponseSchema, ProjectDetailSchema, ProjectsResponseSchema, @@ -24,6 +25,7 @@ import { getConfigDetails, getGlobalConfigFilePath, getLocalConfigFilePath, + normalizeHost, readGlobalStoredConfig, readLocalStoredConfigAtPath, writeGlobalStoredConfig, @@ -292,14 +294,15 @@ function summarizeProjectFeatures(project: { module_view: boolean; issue_views_view: boolean; page_view: boolean; - inbox_view: boolean; + inbox_view?: boolean; + intake_view?: boolean; }): ProjectFeatureSummary[] { return [ { label: "Cycles", enabled: project.cycle_view }, { label: "Modules", enabled: project.module_view }, { label: "Views", enabled: project.issue_views_view }, { label: "Pages", enabled: project.page_view }, - { label: "Intake", enabled: project.inbox_view }, + { label: "Intake", enabled: isProjectIntakeEnabled(project) }, ]; } @@ -367,11 +370,8 @@ export function initHandler( ? resolveGlobalValue(token, existing.token || effective.token) : resolveLocalValue(token, existing.token); - const mergedHost = ( - savedHost ?? - effective.host ?? - "https://plane.so" - ).replace(/\/$/, ""); + const mergedHost = savedHost ?? effective.host ?? "https://plane.so"; + const normalizedHost = normalizeHost(mergedHost); const mergedWorkspace = savedWorkspace ?? effective.workspace; const mergedToken = savedToken ?? effective.token; @@ -387,7 +387,7 @@ export function initHandler( let savedDefaultProject = existing.defaultProject; const projectsResult = yield* Effect.either( fetchProjectsForConfig({ - host: mergedHost, + host: normalizedHost, workspace: mergedWorkspace, token: mergedToken, }), @@ -430,7 +430,7 @@ export function initHandler( if (scope === "global") { writeGlobalStoredConfig({ - host: mergedHost, + host: normalizedHost, workspace: mergedWorkspace, token: mergedToken, defaultProject: savedDefaultProject, @@ -451,7 +451,7 @@ export function initHandler( `\n${scope === "global" ? "Global" : "Local"} config saved to ${savePath}`, ); yield* Console.log( - ` Host: ${describeValue(scope, savedHost, mergedHost)}`, + ` Host: ${describeValue(scope, savedHost, normalizedHost)}`, ); yield* Console.log( ` Workspace: ${describeValue(scope, savedWorkspace, mergedWorkspace)}`, @@ -480,7 +480,7 @@ export function initHandler( const projectHelper = yield* Effect.either( fetchLocalProjectHelperForConfig( { - host: mergedHost, + host: normalizedHost, workspace: mergedWorkspace, token: mergedToken, }, diff --git a/src/commands/intake.ts b/src/commands/intake.ts index 70e0b0b..8ef216f 100644 --- a/src/commands/intake.ts +++ b/src/commands/intake.ts @@ -13,21 +13,38 @@ const projectArg = Args.text({ name: "project" }).pipe( const listProjectArg = projectArg.pipe(Args.withDefault("")); -// Intake status codes: -2=rejected, -1=snoozed, 0=pending, 1=accepted, 2=duplicate +// Intake status codes: -2=pending, -1=rejected, 0=snoozed, 1=accepted, 2=duplicate const STATUS_LABELS: Record = { - [-2]: "rejected", - [-1]: "snoozed", - 0: "pending", + [-2]: "pending", + [-1]: "rejected", + 0: "snoozed", 1: "accepted", 2: "duplicate", }; +function resolveIntakeMutationId(projectId: string, intakeId: string) { + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/intake-issues/`); + const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw); + const match = results.find( + (item) => + item.id === intakeId || + item.issue === intakeId || + item.issue_detail?.id === intakeId, + ); + if (!match) { + return yield* Effect.fail(new Error(`Unknown intake issue: ${intakeId}`)); + } + return match.issue ?? match.issue_detail?.id ?? match.id; + }); +} + // --- intake list --- export function intakeListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - yield* requireProjectFeature(id, "inbox_view"); + yield* requireProjectFeature(id, "intake_view"); const raw = yield* api.get(`projects/${id}/intake-issues/`); const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw); if (jsonMode) { @@ -79,8 +96,9 @@ export function intakeAcceptHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - yield* requireProjectFeature(id, "inbox_view"); - yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { + yield* requireProjectFeature(id, "intake_view"); + const mutationId = yield* resolveIntakeMutationId(id, intakeId); + yield* api.patch(`projects/${id}/intake-issues/${mutationId}/`, { status: 1, }); yield* Console.log(`Intake issue ${intakeId} accepted`); @@ -108,9 +126,10 @@ export function intakeRejectHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - yield* requireProjectFeature(id, "inbox_view"); - yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { - status: -2, + yield* requireProjectFeature(id, "intake_view"); + const mutationId = yield* resolveIntakeMutationId(id, intakeId); + yield* api.patch(`projects/${id}/intake-issues/${mutationId}/`, { + status: -1, }); yield* Console.log(`Intake issue ${intakeId} rejected`); }); diff --git a/src/config.ts b/src/config.ts index aef2570..18e1148 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,11 +81,18 @@ export const ProjectDetailSchema = Schema.Struct({ cycle_view: Schema.Boolean, issue_views_view: Schema.Boolean, page_view: Schema.Boolean, - inbox_view: Schema.Boolean, + inbox_view: Schema.optional(Schema.Boolean), + intake_view: Schema.optional(Schema.Boolean), estimate: Schema.optional(Schema.NullOr(Schema.String)), }); export type ProjectDetail = typeof ProjectDetailSchema.Type; +export function isProjectIntakeEnabled( + project: Pick, +): boolean { + return project.inbox_view ?? project.intake_view ?? false; +} + export const EstimateSchema = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -159,7 +166,7 @@ export const ModulesResponseSchema = Schema.Struct({ results: Schema.Array(ModuleSchema), }); -export const ModuleIssueSchema = Schema.Struct({ +export const ModuleIssueRelationSchema = Schema.Struct({ id: Schema.String, issue: Schema.String, issue_detail: Schema.optional( @@ -170,6 +177,17 @@ export const ModuleIssueSchema = Schema.Struct({ }), ), }); + +export const ModuleIssueRawSchema = Schema.Struct({ + id: Schema.String, + sequence_id: Schema.Number, + name: Schema.String, +}); + +export const ModuleIssueSchema = Schema.Union( + ModuleIssueRelationSchema, + ModuleIssueRawSchema, +); export type ModuleIssue = typeof ModuleIssueSchema.Type; export const ModuleIssuesResponseSchema = Schema.Struct({ @@ -236,7 +254,7 @@ export const CommentsResponseSchema = Schema.Struct({ results: Schema.Array(CommentSchema), }); -export const CycleIssueSchema = Schema.Struct({ +export const CycleIssueRelationSchema = Schema.Struct({ id: Schema.String, issue: Schema.String, issue_detail: Schema.optional( @@ -247,6 +265,17 @@ export const CycleIssueSchema = Schema.Struct({ }), ), }); + +export const CycleIssueRawSchema = Schema.Struct({ + id: Schema.String, + sequence_id: Schema.Number, + name: Schema.String, +}); + +export const CycleIssueSchema = Schema.Union( + CycleIssueRelationSchema, + CycleIssueRawSchema, +); export type CycleIssue = typeof CycleIssueSchema.Type; export const CycleIssuesResponseSchema = Schema.Struct({ diff --git a/src/project-context.ts b/src/project-context.ts index 7ef0140..6b8df64 100644 --- a/src/project-context.ts +++ b/src/project-context.ts @@ -7,6 +7,7 @@ import type { ProjectDetail, State, } from "./config.js"; +import { isProjectIntakeEnabled } from "./config.js"; import { getLocalConfigDir } from "./user-config.js"; interface ProjectSummary { @@ -133,7 +134,7 @@ export function buildProjectContextSnapshot({ modules: detail.module_view, views: detail.issue_views_view, pages: detail.page_view, - intake: detail.inbox_view, + intake: isProjectIntakeEnabled(detail), estimates: estimate !== null, }, helpers: { diff --git a/src/resolve.ts b/src/resolve.ts index 51b416a..d239e16 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -3,6 +3,7 @@ import { api, decodeOrFail } from "./api.js"; import type { Issue, ProjectDetail } from "./config.js"; import { IssuesResponseSchema, + isProjectIntakeEnabled, LabelsResponseSchema, MembersResponseSchema, ProjectDetailSchema, @@ -19,22 +20,32 @@ type ProjectFeatureKey = | "cycle_view" | "module_view" | "page_view" - | "inbox_view"; + | "intake_view"; const FEATURE_LABELS: Record = { cycle_view: "Cycles", module_view: "Modules", page_view: "Pages", - inbox_view: "Intake", + intake_view: "Intake", }; const FEATURE_HINTS: Record = { cycle_view: "Enable Cycles in the Plane project settings.", module_view: "Enable Modules in the Plane project settings.", page_view: "Enable Pages in the Plane project settings.", - inbox_view: "Enable Intake in the Plane project settings.", + intake_view: "Enable Intake in the Plane project settings.", }; +function isProjectFeatureEnabled( + project: ProjectDetail, + feature: ProjectFeatureKey, +): boolean { + if (feature === "intake_view") { + return isProjectIntakeEnabled(project); + } + return project[feature]; +} + function getConfiguredProject(identifier: string): string { const trimmed = identifier.trim(); if ( @@ -96,7 +107,7 @@ export function getProjectFeatureDetails(projectId: string) { Modules: project.module_view, Views: project.issue_views_view, Pages: project.page_view, - Intake: project.inbox_view, + Intake: isProjectIntakeEnabled(project), }, })), ); @@ -108,13 +119,14 @@ export function requireProjectFeature( ): Effect.Effect { return getProjectDetail(projectId).pipe( Effect.flatMap((project) => { - if (project[feature]) { + if (isProjectFeatureEnabled(project, feature)) { return Effect.succeed(void 0); } const featureLabel = FEATURE_LABELS[feature]; + const featureFlag = feature === "intake_view" ? "intake_view" : feature; return Effect.fail( new Error( - `Project ${project.identifier} has ${featureLabel} disabled (${feature}=false). ${FEATURE_HINTS[feature]}`, + `Project ${project.identifier} has ${featureLabel} disabled (${featureFlag}=false). ${FEATURE_HINTS[feature]}`, ), ); }), diff --git a/tests/intake.test.ts b/tests/intake.test.ts index a3510af..d4bf21e 100644 --- a/tests/intake.test.ts +++ b/tests/intake.test.ts @@ -26,22 +26,24 @@ const PROJECT_DETAIL = { cycle_view: true, issue_views_view: true, page_view: true, - inbox_view: true, + intake_view: true, }; const INTAKE_ISSUES = [ { id: "int1", + issue: "i1", issue_detail: { id: "i1", sequence_id: 5, name: "Bug report", priority: "high", }, - status: 0, + status: -2, created_at: "2025-01-15T10:00:00Z", }, { id: "int2", + issue: "i2", issue_detail: { id: "i2", sequence_id: 6, @@ -53,6 +55,7 @@ const INTAKE_ISSUES = [ }, { id: "int3", + issue: "i3", created_at: "2025-01-13T10:00:00Z", }, ]; @@ -144,7 +147,7 @@ describe("intakeAccept", () => { let patchedBody: unknown; server.use( http.patch( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/i1/`, async ({ request }) => { patchedBody = await request.json(); return HttpResponse.json({ @@ -176,12 +179,12 @@ describe("intakeReject", () => { let patchedBody: unknown; server.use( http.patch( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/i1/`, async ({ request }) => { patchedBody = await request.json(); return HttpResponse.json({ id: "int1", - status: -2, + status: -1, created_at: "2025-01-15T10:00:00Z", }); }, @@ -198,7 +201,7 @@ describe("intakeReject", () => { } finally { console.log = orig; } - expect((patchedBody as { status?: number }).status).toBe(-2); + expect((patchedBody as { status?: number }).status).toBe(-1); expect(logs.join("\n")).toContain("rejected"); }); }); From 431752b8910420a2944f96306a2512bd2315e3d1 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:46:50 +0700 Subject: [PATCH 08/20] fix: accept both join-table and raw issue payloads for cycle/module issues Plane API may return raw issue objects instead of join-table records for cycle-issues and module-issues endpoints. Schemas now accept both formats via union types (CycleIssueSchema, ModuleIssueSchema) so the CLI handles either response shape gracefully. --- src/commands/cycles.ts | 8 +++++-- src/commands/modules.ts | 10 ++++++--- tests/cycles-extended.test.ts | 40 ++++++++++++++++++++++++++++++++--- tests/json-output.test.ts | 23 ++++++++++++-------- tests/modules.test.ts | 36 ++++++++++++++++++++++++++++--- tests/new-schemas.test.ts | 22 ++++++++++++++----- tests/new-schemas2.test.ts | 19 +++++++++++++++++ tests/xml-output.test.ts | 23 ++++++++++++-------- 8 files changed, 147 insertions(+), 34 deletions(-) diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index f69dc9f..e1ec34a 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -91,11 +91,15 @@ export function cycleIssuesListHandler({ return; } const lines = results.map((ci) => { + if ("sequence_id" in ci) { + const seq = String(ci.sequence_id).padStart(3, " "); + return `${key}-${seq} ${ci.name}`; + } if (ci.issue_detail) { const seq = String(ci.issue_detail.sequence_id).padStart(3, " "); - return `${key}-${seq} ${ci.issue_detail.name} (${ci.id})`; + return `${key}-${seq} ${ci.issue_detail.name}`; } - return `${ci.issue} (cycle-issue: ${ci.id})`; + return `${ci.issue}`; }); yield* Console.log(lines.join("\n")); }); diff --git a/src/commands/modules.ts b/src/commands/modules.ts index 4890e2f..a581c18 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -92,10 +92,14 @@ export function moduleIssuesListHandler({ return; } const lines = results.map((mi) => { - if (mi.issue_detail) { + if ("issue_detail" in mi && mi.issue_detail) { const seq = String(mi.issue_detail.sequence_id).padStart(3, " "); return `${key}-${seq} ${mi.issue_detail.name} (${mi.id})`; } + if ("sequence_id" in mi) { + const seq = String(mi.sequence_id).padStart(3, " "); + return `${key}-${seq} ${mi.name}`; + } return `${mi.issue} (module-issue: ${mi.id})`; }); yield* Console.log(lines.join("\n")); @@ -156,7 +160,7 @@ export const moduleIssuesAdd = Command.make( const moduleIssueIdArg = Args.text({ name: "module-issue-id" }).pipe( Args.withDescription( - "Module-issue join ID (from 'plane modules issues list')", + "Module issue identifier from 'plane modules issues list' (legacy join ID or live raw issue ID)", ), ); @@ -191,7 +195,7 @@ export const moduleIssuesRemove = Command.make( moduleIssuesRemoveHandler, ).pipe( Command.withDescription( - "Remove an issue from a module using the module-issue join ID.\n\nExample:\n plane modules issues remove PROJ ", + "Remove an issue from a module using the identifier returned by 'plane modules issues list'.\n\nExample:\n plane modules issues remove PROJ ", ), ); diff --git a/tests/cycles-extended.test.ts b/tests/cycles-extended.test.ts index e69c50d..755e587 100644 --- a/tests/cycles-extended.test.ts +++ b/tests/cycles-extended.test.ts @@ -49,9 +49,9 @@ const CYCLES = [ ]; const CYCLE_ISSUES = [ { - id: "ci1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" }, + id: "i1", + sequence_id: 29, + name: "Migrate Button", }, ]; @@ -161,6 +161,40 @@ describe("cycleIssuesList", () => { expect(output).toContain("Migrate Button"); }); + it("accepts legacy cycle-issue join payloads", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, + () => + HttpResponse.json({ + results: [ + { + id: "ci1", + issue: "i1", + issue_detail: { + id: "i1", + sequence_id: 29, + name: "Migrate Button", + }, + }, + ], + }), + ), + ); + const { cycleIssuesListHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }), + ); + } finally { + console.log = orig; + } + expect(logs.join("\n")).toContain("Migrate Button"); + }); + it("falls back to issue UUID without detail", async () => { server.use( http.get( diff --git a/tests/json-output.test.ts b/tests/json-output.test.ts index 19b8e7b..bc08630 100644 --- a/tests/json-output.test.ts +++ b/tests/json-output.test.ts @@ -59,17 +59,17 @@ const CYCLES = [ ]; const CYCLE_ISSUES = [ { - id: "ci1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }]; const MODULE_ISSUES = [ { - id: "mi1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const INTAKE_ISSUES = [ @@ -166,13 +166,17 @@ const server = setupServer( () => HttpResponse.json({ results: ACTIVITIES }), ), http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: LINKS }), ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () => HttpResponse.json({ results: COMMENTS }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: WORKLOGS }), @@ -241,7 +245,7 @@ describe("cycleIssuesList --json", () => { ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].id).toBe("ci1"); + expect(parsed[0].id).toBe("i1"); }); }); @@ -270,7 +274,8 @@ describe("moduleIssuesList --json", () => { ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].id).toBe("mi1"); + expect(parsed[0].id).toBe("i1"); + expect(parsed[0].sequence_id).toBe(1); }); }); diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 1e35039..09c1ffa 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -43,9 +43,9 @@ const MODULES = [ ]; const MODULE_ISSUES = [ { - id: "mi1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" }, + id: "i1", + sequence_id: 29, + name: "Migrate Button", }, ]; @@ -178,6 +178,36 @@ describe("moduleIssuesList", () => { expect(output).toContain("Migrate Button"); }); + it("lists raw issue payloads returned by newer Plane APIs", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`, + () => + HttpResponse.json({ + results: [{ id: "i1", sequence_id: 29, name: "Migrate Button" }], + }), + ), + ); + + const { moduleIssuesListHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + moduleIssuesListHandler({ + project: "ACME", + moduleId: "mod1", + }), + ); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("ACME- 29 Migrate Button"); + }); + it("falls back to issue UUID when no issue_detail", async () => { server.use( http.get( diff --git a/tests/new-schemas.test.ts b/tests/new-schemas.test.ts index 80574bd..b6d42df 100644 --- a/tests/new-schemas.test.ts +++ b/tests/new-schemas.test.ts @@ -194,7 +194,19 @@ describe("ModuleIssueSchema", () => { }, }); expect(mi.id).toBe("mi1"); - expect(mi.issue_detail?.sequence_id).toBe(29); + expect( + "issue_detail" in mi ? mi.issue_detail?.sequence_id : undefined, + ).toBe(29); + }); + + it("decodes a raw issue payload", async () => { + const mi = await decode(ModuleIssueSchema, { + id: "issue-uuid", + sequence_id: 29, + name: "Migrate Button", + }); + expect(mi.id).toBe("issue-uuid"); + expect("sequence_id" in mi ? mi.sequence_id : undefined).toBe(29); }); it("decodes without issue_detail", async () => { @@ -202,11 +214,11 @@ describe("ModuleIssueSchema", () => { id: "mi1", issue: "issue-uuid", }); - expect(mi.issue).toBe("issue-uuid"); - expect(mi.issue_detail).toBeUndefined(); + expect("issue" in mi ? mi.issue : undefined).toBe("issue-uuid"); + expect("issue_detail" in mi ? mi.issue_detail : undefined).toBeUndefined(); }); - it("rejects missing issue", async () => { + it("rejects payloads missing both issue relation and raw issue fields", async () => { await expect(decode(ModuleIssueSchema, { id: "mi1" })).rejects.toThrow(); }); }); @@ -214,7 +226,7 @@ describe("ModuleIssueSchema", () => { describe("ModuleIssuesResponseSchema", () => { it("decodes results", async () => { const resp = await decode(ModuleIssuesResponseSchema, { - results: [{ id: "mi1", issue: "uuid1" }], + results: [{ id: "issue-uuid", sequence_id: 29, name: "Migrate Button" }], }); expect(resp.results).toHaveLength(1); }); diff --git a/tests/new-schemas2.test.ts b/tests/new-schemas2.test.ts index 9d05eba..1c9d280 100644 --- a/tests/new-schemas2.test.ts +++ b/tests/new-schemas2.test.ts @@ -194,14 +194,33 @@ describe("CycleIssueSchema", () => { issue: "i1", issue_detail: { id: "i1", sequence_id: 5, name: "Fix bug" }, }); + if (!("issue" in ci)) { + throw new Error("Expected legacy cycle-issue payload"); + } expect(ci.issue_detail?.sequence_id).toBe(5); }); it("decodes without detail", async () => { const ci = await decode(CycleIssueSchema, { id: "ci2", issue: "i2" }); + if (!("issue" in ci)) { + throw new Error("Expected legacy cycle-issue payload"); + } expect(ci.issue).toBe("i2"); }); + it("decodes raw issue payloads", async () => { + const ci = await decode(CycleIssueSchema, { + id: "i3", + sequence_id: 9, + name: "Ship campaign", + }); + if (!("sequence_id" in ci)) { + throw new Error("Expected raw issue payload"); + } + expect(ci.sequence_id).toBe(9); + expect(ci.name).toBe("Ship campaign"); + }); + it("rejects missing issue", async () => { await expect(decode(CycleIssueSchema, { id: "ci3" })).rejects.toThrow(); }); diff --git a/tests/xml-output.test.ts b/tests/xml-output.test.ts index fa631bb..bc6f452 100644 --- a/tests/xml-output.test.ts +++ b/tests/xml-output.test.ts @@ -59,17 +59,17 @@ const CYCLES = [ ]; const CYCLE_ISSUES = [ { - id: "ci1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }]; const MODULE_ISSUES = [ { - id: "mi1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const INTAKE_ISSUES = [ @@ -166,13 +166,17 @@ const server = setupServer( () => HttpResponse.json({ results: ACTIVITIES }), ), http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: LINKS }), ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () => HttpResponse.json({ results: COMMENTS }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: WORKLOGS }), @@ -239,7 +243,7 @@ describe("cycleIssuesList --xml", () => { ), ); expect(output).toContain(""); - expect(output).toContain("ci1"); + expect(output).toContain("Issue One"); }); }); @@ -266,7 +270,8 @@ describe("moduleIssuesList --xml", () => { ), ); expect(output).toContain(""); - expect(output).toContain("mi1"); + expect(output).toContain('id="i1"'); + expect(output).toContain('sequence_id="1"'); }); }); From ebe8db89f38c05cb03143d83e3fab41547378fc7 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:47:11 +0700 Subject: [PATCH 09/20] feat: add API endpoint fallback for issue links and worklogs Extracts payload types and a requestWithFallback helper into src/issue-support.ts. Issue link and worklog commands now try /work-items/ paths before falling back to legacy /issues/ paths, so the CLI works across Plane API versions. Also refreshes the issue after PATCH in update to ensure the displayed state reflects the server response. --- src/commands/issue.ts | 69 ++++++++++++------------- src/issue-support.ts | 72 +++++++++++++++++++++++++++ tests/issue-commands.test.ts | 19 +++++++ tests/issue-comments-worklogs.test.ts | 20 ++++++++ tests/issue-links.test.ts | 10 ++-- 5 files changed, 149 insertions(+), 41 deletions(-) create mode 100644 src/issue-support.ts diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 4184268..c10f479 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -11,6 +11,14 @@ import { WorklogsResponseSchema, } from "../config.js"; import { escapeHtmlText } from "../format.js"; +import { + type IssueCreatePayload, + type IssueUpdatePayload, + issueLinkPaths, + issueWorklogPaths, + requestWithFallback, + type WorklogPayload, +} from "../issue-support.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; import { findIssueBySeq, @@ -24,29 +32,6 @@ import { const refArg = Args.text({ name: "ref" }).pipe( Args.withDescription("Issue reference, e.g. PROJ-29"), ); -// --- Typed payload interfaces --- -interface IssueUpdatePayload { - state?: string; - priority?: string; - name?: string; - description_html?: string; - assignees?: string[]; - label_ids?: string[]; -} - -interface IssueCreatePayload { - name: string; - priority?: string; - state?: string; - description_html?: string; - assignees?: string[]; - label_ids?: string[]; -} - -interface WorklogPayload { - duration: number; - description?: string; -} // --- issue get --- export function issueGetHandler({ ref }: { ref: string }) { return Effect.gen(function* () { @@ -155,7 +140,11 @@ export function issueUpdateHandler({ `projects/${projectId}/issues/${issue.id}/`, body, ); - const updated = yield* decodeOrFail(IssueSchema, raw); + yield* decodeOrFail(IssueSchema, raw); + const refreshedRaw = yield* api.get( + `projects/${projectId}/issues/${issue.id}/`, + ); + const updated = yield* decodeOrFail(IssueSchema, refreshedRaw); const stateName = typeof updated.state === "object" ? updated.state.name : updated.state; yield* Console.log( @@ -354,8 +343,10 @@ export function issueLinkListHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); - const raw = yield* api.get( - `projects/${projectId}/issues/${issue.id}/issue-links/`, + const raw = yield* requestWithFallback( + issueLinkPaths(projectId, issue.id), + (path) => api.get(path), + `Issue links are not available for ${ref} on this Plane instance or API version.`, ); const { results } = yield* decodeOrFail(IssueLinksResponseSchema, raw); if (jsonMode) { @@ -404,9 +395,10 @@ export function issueLinkAddHandler({ const issue = yield* findIssueBySeq(projectId, seq); const body: Record = { url }; if (Option.isSome(title)) body.title = title.value; - const raw = yield* api.post( - `projects/${projectId}/issues/${issue.id}/issue-links/`, - body, + const raw = yield* requestWithFallback( + issueLinkPaths(projectId, issue.id), + (path) => api.post(path, body), + `Issue links are not available for ${ref} on this Plane instance or API version.`, ); const link = yield* decodeOrFail(IssueLinkSchema, raw); yield* Console.log(`Link added: ${link.id} ${link.url}`); @@ -437,8 +429,10 @@ export function issueLinkRemoveHandler({ return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); - yield* api.delete( - `projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`, + yield* requestWithFallback( + issueLinkPaths(projectId, issue.id).map((path) => `${path}${linkId}/`), + (path) => api.delete(path), + `Issue links are not available for ${ref} on this Plane instance or API version.`, ); yield* Console.log(`Link ${linkId} removed from ${ref}`); }); @@ -570,8 +564,10 @@ export function issueWorklogsListHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); - const raw = yield* api.get( - `projects/${projectId}/issues/${issue.id}/worklogs/`, + const raw = yield* requestWithFallback( + issueWorklogPaths(projectId, issue.id), + (path) => api.get(path), + `Issue worklogs are not available for ${ref} on this Plane instance or API version.`, ); const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw); if (jsonMode) { @@ -628,9 +624,10 @@ export function issueWorklogsAddHandler({ const issue = yield* findIssueBySeq(projectId, seq); const body: WorklogPayload = { duration }; if (Option.isSome(description)) body.description = description.value; - const raw = yield* api.post( - `projects/${projectId}/issues/${issue.id}/worklogs/`, - body, + const raw = yield* requestWithFallback( + issueWorklogPaths(projectId, issue.id), + (path) => api.post(path, body), + `Issue worklogs are not available for ${ref} on this Plane instance or API version.`, ); const log = yield* decodeOrFail(WorklogSchema, raw); const hrs = (log.duration / 60).toFixed(1); diff --git a/src/issue-support.ts b/src/issue-support.ts new file mode 100644 index 0000000..2c44e47 --- /dev/null +++ b/src/issue-support.ts @@ -0,0 +1,72 @@ +import { Effect } from "effect"; + +export interface IssueUpdatePayload { + state?: string; + priority?: string; + name?: string; + description_html?: string; + assignees?: string[]; + label_ids?: string[]; +} + +export interface IssueCreatePayload { + name: string; + priority?: string; + state?: string; + description_html?: string; + assignees?: string[]; + label_ids?: string[]; +} + +export interface WorklogPayload { + duration: number; + description?: string; +} + +function isNotFoundError(error: Error): boolean { + return /^HTTP 404:/.test(error.message); +} + +export function requestWithFallback( + paths: ReadonlyArray, + request: (path: string) => Effect.Effect, + notFoundMessage: string, +): Effect.Effect { + const [current, ...rest] = paths; + if (!current) { + return Effect.fail(new Error(notFoundMessage)); + } + return request(current).pipe( + Effect.catchAll((error) => { + if (!isNotFoundError(error)) { + return Effect.fail(error); + } + if (rest.length === 0) { + return Effect.fail(new Error(notFoundMessage)); + } + return requestWithFallback(rest, request, notFoundMessage); + }), + ); +} + +export function issueLinkPaths( + projectId: string, + issueId: string, +): ReadonlyArray { + return [ + `projects/${projectId}/work-items/${issueId}/links/`, + `projects/${projectId}/issues/${issueId}/links/`, + `projects/${projectId}/issues/${issueId}/issue-links/`, + ]; +} + +export function issueWorklogPaths( + projectId: string, + issueId: string, +): ReadonlyArray { + return [ + `projects/${projectId}/work-items/${issueId}/worklogs/`, + `projects/${projectId}/issues/${issueId}/worklogs/`, + `projects/${projectId}/issues/${issueId}/issue-worklogs/`, + ]; +} diff --git a/tests/issue-commands.test.ts b/tests/issue-commands.test.ts index 2ca48f3..29e7e9c 100644 --- a/tests/issue-commands.test.ts +++ b/tests/issue-commands.test.ts @@ -57,6 +57,14 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/:issueId/`, + ({ params }) => { + const issue = ISSUES.find((i) => i.id === params.issueId); + if (issue) return HttpResponse.json(issue); + return new HttpResponse(null, { status: 404 }); + }, + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () => HttpResponse.json({ results: STATES }), ), @@ -347,6 +355,17 @@ describe("issueUpdate", () => { }); }, ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, + () => + HttpResponse.json({ + id: "i1", + sequence_id: 29, + name: "Migrate Button", + priority: "urgent", + state: "s1", + }), + ), ); const { issueUpdateHandler } = await import("@/commands/issue"); diff --git a/tests/issue-comments-worklogs.test.ts b/tests/issue-comments-worklogs.test.ts index 47a19e2..e81028f 100644 --- a/tests/issue-comments-worklogs.test.ts +++ b/tests/issue-comments-worklogs.test.ts @@ -62,6 +62,10 @@ const server = setupServer( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () => HttpResponse.json({ results: COMMENTS }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: WORKLOGS }), @@ -241,6 +245,10 @@ describe("issueWorklogsList", () => { it("shows 'No worklogs' when empty", async () => { server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: [] }), @@ -262,6 +270,10 @@ describe("issueWorklogsList", () => { describe("issueWorklogsAdd", () => { it("logs time without description", async () => { server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, async ({ request }) => { @@ -295,6 +307,10 @@ describe("issueWorklogsAdd", () => { it("logs time with description", async () => { server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, async ({ request }) => { @@ -331,6 +347,10 @@ describe("issueWorklogsAdd", () => { it("handles missing logged_by_detail in worklogs list", async () => { server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => diff --git a/tests/issue-links.test.ts b/tests/issue-links.test.ts index 6119cc2..8e37fb4 100644 --- a/tests/issue-links.test.ts +++ b/tests/issue-links.test.ts @@ -50,7 +50,7 @@ const server = setupServer( HttpResponse.json({ results: ISSUES }), ), http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: LINKS }), ), ); @@ -109,7 +109,7 @@ describe("issueLinkList", () => { it("shows 'No links' when empty", async () => { server.use( http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: [] }), ), ); @@ -133,7 +133,7 @@ describe("issueLinkAdd", () => { it("adds a link without title", async () => { server.use( http.post( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, async ({ request }) => { const body = (await request.json()) as { url?: string }; return HttpResponse.json({ @@ -170,7 +170,7 @@ describe("issueLinkAdd", () => { it("adds a link with title", async () => { server.use( http.post( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, async ({ request }) => { const body = (await request.json()) as { url?: string; @@ -212,7 +212,7 @@ describe("issueLinkRemove", () => { let deleted = false; server.use( http.delete( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/lnk1/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/lnk1/`, () => { deleted = true; return new HttpResponse(null, { status: 204 }); From 7492f8dec8dc1c316fd3034d30d7e755ae7a325e Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:47:30 +0700 Subject: [PATCH 10/20] fix: return friendly compatibility error when pages API is unavailable When a Plane deployment returns 404 for page endpoints, the CLI now shows a clear message instead of a raw error. Wraps all page API calls with mapPageAvailabilityError to detect and explain the situation. --- src/commands/pages.ts | 89 ++++++++++++++++++++++++++++++++----------- tests/pages.test.ts | 15 ++++++++ 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/commands/pages.ts b/src/commands/pages.ts index 01306ed..1dcf083 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -38,13 +38,34 @@ interface PageUpdatePayload { description_html?: string; } +function isNotFoundError(error: Error): boolean { + return /^HTTP 404:/.test(error.message); +} + +function mapPageAvailabilityError( + effect: Effect.Effect, + message: string, +): Effect.Effect { + return effect.pipe( + Effect.catchAll((error) => { + if (isNotFoundError(error)) { + return Effect.fail(new Error(message)); + } + return Effect.fail(error); + }), + ); +} + // --- pages list --- export function pagesListHandler({ project }: { project: string }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - const raw = yield* api.get(`projects/${id}/pages/`); + const raw = yield* mapPageAvailabilityError( + api.get(`projects/${id}/pages/`), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); const { results } = yield* decodeOrFail(PagesResponseSchema, raw); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); @@ -86,9 +107,12 @@ export function pagesGetHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - const raw = yield* api.get(`projects/${id}/pages/${pageId}/`); + const raw = yield* mapPageAvailabilityError( + api.get(`projects/${id}/pages/${pageId}/`), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(JSON.stringify(page, null, 2)); }); @@ -116,13 +140,16 @@ export function pagesCreateHandler({ description: Option.Option; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); const body: PageCreatePayload = { name }; if (Option.isSome(description)) { body.description_html = description.value; } - const raw = yield* api.post(`projects/${id}/pages/`, body); + const raw = yield* mapPageAvailabilityError( + api.post(`projects/${id}/pages/`, body), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Created page ${page.id}: ${page.name}`); }); @@ -155,12 +182,15 @@ export function pagesUpdateHandler({ if (Option.isNone(name) && Option.isNone(description)) { yield* Effect.fail(new Error("provide at least --name or --description")); } - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); const body: PageUpdatePayload = {}; if (Option.isSome(name)) body.name = name.value; if (Option.isSome(description)) body.description_html = description.value; - const raw = yield* api.patch(`projects/${id}/pages/${pageId}/`, body); + const raw = yield* mapPageAvailabilityError( + api.patch(`projects/${id}/pages/${pageId}/`, body), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Updated page ${page.id}: ${page.name}`); }); @@ -191,9 +221,12 @@ export function pagesDeleteHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* api.delete(`projects/${id}/pages/${pageId}/`); + yield* mapPageAvailabilityError( + api.delete(`projects/${id}/pages/${pageId}/`), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); yield* Console.log(`Deleted page ${pageId}`); }); } @@ -218,9 +251,12 @@ export function pagesArchiveHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {}); + yield* mapPageAvailabilityError( + api.post(`projects/${id}/pages/${pageId}/archive/`, {}), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); yield* Console.log(`Archived page ${pageId}`); }); } @@ -245,9 +281,12 @@ export function pagesUnarchiveHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* api.delete(`projects/${id}/pages/${pageId}/archive/`); + yield* mapPageAvailabilityError( + api.delete(`projects/${id}/pages/${pageId}/archive/`), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); yield* Console.log(`Unarchived page ${pageId}`); }); } @@ -272,9 +311,12 @@ export function pagesLockHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {}); + yield* mapPageAvailabilityError( + api.post(`projects/${id}/pages/${pageId}/lock/`, {}), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); yield* Console.log(`Locked page ${pageId}`); }); } @@ -299,9 +341,12 @@ export function pagesUnlockHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* api.delete(`projects/${id}/pages/${pageId}/lock/`); + yield* mapPageAvailabilityError( + api.delete(`projects/${id}/pages/${pageId}/lock/`), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); yield* Console.log(`Unlocked page ${pageId}`); }); } @@ -326,11 +371,11 @@ export function pagesDuplicateHandler({ pageId: string; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - const raw = yield* api.post( - `projects/${id}/pages/${pageId}/duplicate/`, - {}, + const raw = yield* mapPageAvailabilityError( + api.post(`projects/${id}/pages/${pageId}/duplicate/`, {}), + `Project pages are not available for ${key} on this Plane instance or API version.`, ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Duplicated page ${page.id}: ${page.name}`); diff --git a/tests/pages.test.ts b/tests/pages.test.ts index 6a22af3..99a0622 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -136,6 +136,21 @@ describe("pagesList", () => { } expect(logs.join("\n")).toBe("No pages"); }); + + it("returns a definitive error when the page API is unavailable", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), + ); + const { pagesListHandler } = await import("@/commands/pages"); + await expect( + Effect.runPromise(pagesListHandler({ project: "ACME" })), + ).rejects.toThrow( + "Project pages are not available for ACME on this Plane instance or API version.", + ); + }); }); describe("pagesGet", () => { From 20d5b3d1ddae3d18a8afca92632ea8d5f0d6612d Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:47:50 +0700 Subject: [PATCH 11/20] fix: correct members list help text to say workspace members The command lists workspace members, not project members. Updated the help text to match the actual behavior. --- src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 3606383..82d495b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,7 +60,7 @@ ALL SUBCOMMANDS pages list | get | create | update | delete | archive | unarchive | lock | unlock | duplicate states list List workflow states for a project labels list List labels for a project - members list List members of a project + members list List workspace members FOR AI AGENTS / BOTS - Add --json to any list command for JSON output (array of objects) From eb6e60aeef07c63fe2d73047324945613a2878dd Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:48:15 +0700 Subject: [PATCH 12/20] feat: include CLI usage guidance in generated AGENTS.md managed section The managed section generated by `plane init --local` now includes CLI preference, @current selector guidance, PLANE_* override clearing, and a small command pattern block. This helps AGENTS.md-aware AI clients discover the CLI without manual documentation. --- src/project-agents.ts | 14 ++++++++++++++ tests/project-features.test.ts | 11 ++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/project-agents.ts b/src/project-agents.ts index 0e86f1e..1bdbc89 100644 --- a/src/project-agents.ts +++ b/src/project-agents.ts @@ -20,6 +20,20 @@ function buildManagedSection(snapshot: ProjectContextSnapshot): string { "- Read `./.plane/project-context.json` before planning or applying Plane project changes.", "- Reuse the existing states, labels, and estimate points in that snapshot instead of creating duplicates.", "- Respect the feature flags in that snapshot before using cycles, modules, pages, intake, or estimates.", + "- Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls.", + "- Use `@current` as the default project selector once local init has been run.", + "- If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`.", + "", + "Common agent commands:", + "", + "```sh", + "unset PLANE_HOST PLANE_WORKSPACE PLANE_API_TOKEN PLANE_PROJECT", + "plane projects current", + "plane issues list @current", + `plane issue get ${snapshot.project.identifier}-12`, + `plane issue update --state started ${snapshot.project.identifier}-12`, + "```", + "", "- Rerun `plane init --local` from this directory whenever the Plane project configuration changes so this context stays current.", "", "This section is managed by `plane-cli` and is updated by `plane init --local`.", diff --git a/tests/project-features.test.ts b/tests/project-features.test.ts index 19446b2..c11c1fc 100644 --- a/tests/project-features.test.ts +++ b/tests/project-features.test.ts @@ -33,7 +33,7 @@ const PROJECT_DETAIL = { cycle_view: false, issue_views_view: true, page_view: true, - inbox_view: true, + intake_view: true, estimate: "est1", }; @@ -207,6 +207,11 @@ describe("feature gates", () => { expect(agentsContent).toContain("## Plane Project Context"); expect(agentsContent).toContain("Plane project ACME (Acme Project)"); expect(agentsContent).toContain("./.plane/project-context.json"); + expect(agentsContent).toContain( + "Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls.", + ); + expect(agentsContent).toContain("plane issues list @current"); + expect(agentsContent).toContain("plane issue get ACME-12"); const helper = JSON.parse(fs.readFileSync(helperPath, "utf8")) as { features: { estimates: boolean }; helpers: { @@ -268,5 +273,9 @@ describe("feature gates", () => { expect(agentsContent).toContain( "Read `./.plane/project-context.json` before planning or applying Plane project changes.", ); + expect(agentsContent).toContain( + "If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`.", + ); + expect(agentsContent).toContain("plane projects current"); }); }); From ab176328a25bf325e2584689b30d331593d22237 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 16:48:31 +0700 Subject: [PATCH 13/20] docs: add deployment compatibility notes and align public docs Adds Known Deployment Compatibility section to AGENTS.md documenting pages, worklogs, feature gating, and missing CLI commands. Updates README and SKILL with matching compatibility notes. Removes misplaced Agent CLI Usage section from AGENTS.md (that guidance now lives in the generated managed section instead). --- AGENTS.md | 38 +++++++++++++++++++++++++++++++++++++- CHANGELOG.md | 14 +++++++++++++- README.md | 37 +++++++++++++++++++++++++++++-------- SKILL.md | 28 +++++++++++++++++++++------- 4 files changed, 100 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5a13b76..7aa6686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,4 +51,40 @@ Before making non-trivial changes: - Keep public documentation presentable and accurate. - Use [CHANGELOG.md](./CHANGELOG.md) for notable user-facing changes. - Follow [docs/RELEASING.md](./docs/RELEASING.md) for release workflow expectations. -- Respect [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) and [SECURITY.md](./SECURITY.md). \ No newline at end of file +- Respect [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) and [SECURITY.md](./SECURITY.md). + +## Known Deployment Compatibility + +The CLI has been validated against a real Plane instance. Be aware of these deployment-dependent behaviors when developing or testing: + +- **Pages**: The CLI targets the project-page API surface. Plane also has a separate workspace wiki page surface that the CLI does not cover. Both may return 404 on some deployments even when the project feature flag is enabled. +- **Worklogs**: Time tracking is a Pro-plan feature. Non-Pro deployments will not expose worklog API endpoints. +- **Feature gating**: The CLI returns explicit compatibility errors (not raw 404s) when a project feature is flagged on but the backing API route is absent. +- **Missing CLI commands**: `labels delete` and `modules delete` are not yet implemented. Use the Plane REST API directly for these operations. + + +## Plane Project Context +This directory is scoped to Plane project PLANECLI (Plane CLI). + +When working as an AI agent in this directory: +- Read `./.plane/project-context.json` before planning or applying Plane project changes. +- Reuse the existing states, labels, and estimate points in that snapshot instead of creating duplicates. +- Respect the feature flags in that snapshot before using cycles, modules, pages, intake, or estimates. +- Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls. +- Use `@current` as the default project selector once local init has been run. +- If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`. + +Common agent commands: + +```sh +unset PLANE_HOST PLANE_WORKSPACE PLANE_API_TOKEN PLANE_PROJECT +plane projects current +plane issues list @current +plane issue get PLANECLI-12 +plane issue update --state started PLANECLI-12 +``` + +- Rerun `plane init --local` from this directory whenever the Plane project configuration changes so this context stays current. + +This section is managed by `plane-cli` and is updated by `plane init --local`. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 1227c64..5a5a733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,4 +14,16 @@ Earlier project history may predate this file. - GitHub issue and pull request templates plus issue intake routing. - Stricter repository quality gates covering formatting, file-size limits, and coverage thresholds. - Fork-specific package identity and public metadata alignment under `@backslash-ux/plane`. -- A versioned root `AGENTS.md` file that provides baseline context for AI coding agents contributing to the repository. \ No newline at end of file +- A versioned root `AGENTS.md` file that provides baseline context for AI coding agents contributing to the repository. + +### Changed + +- Tightened published CLI documentation so README and SKILL examples match the validated command grammar, identifier semantics, and deployment-compatibility behavior. +- Updated compatibility notes in README and SKILL to document confirmed page and worklog deployment dependencies. + +### Validated + +- Full live test sweep completed against a real Plane instance. All core CLI workflows exercised: init (global, local, alias), project resolution, issue CRUD with rich options, comments, links, activity, cycles, modules, intake mutations, states, labels, members, and structured output. +- Confirmed the CLI's project-page endpoint routes are correct; both page API surfaces (project pages and workspace wiki pages) return 404 on some deployments regardless of feature flags. +- Confirmed worklogs are a Pro-plan-gated feature; the CLI returns explicit compatibility errors on non-Pro deployments. +- Identified missing CLI commands for label delete and module delete as follow-up items. \ No newline at end of file diff --git a/README.md b/README.md index 6807f7d..8b4c355 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ environment variables > nearest .plane/config.json > ~/.config/plane/config.json The local config is discovered from the current working directory upward, so a config written at the repo root applies inside nested folders unless a deeper `.plane/config.json` overrides it. When you run `plane init --local`, the CLI also reads the project's feature flags from Plane and reports which project-scoped features are actually enabled. Cycles, modules, pages, and intake commands return explicit feature-disabled errors when the project has them turned off. It also writes `.plane/project-context.json`, a machine-readable helper snapshot of the project's existing states, labels, and estimate points so agents can reuse what already exists instead of inventing duplicates. -If `AGENTS.md` already exists in that directory, `plane init --local` appends a managed Plane project context section at the bottom without removing the existing content. If it does not exist, the CLI creates it and points agents at `.plane/project-context.json`. +If `AGENTS.md` already exists in that directory, `plane init --local` appends a managed Plane project context section at the bottom without removing the existing content. If it does not exist, the CLI creates it. The managed section points agents at `.plane/project-context.json`, tells them to prefer the repo-local `plane` CLI for Plane work, and includes a small command pattern for clearing inherited `PLANE_*` overrides before using the local config. You can also use environment variables (override saved config): @@ -82,6 +82,14 @@ plane projects current When a local config is active in the current path, `plane projects use PROJ` writes there by default; otherwise it writes to global config. Once a current project is saved, list-style commands such as `plane issues list`, `plane cycles list`, `plane modules list`, `plane pages list`, `plane states list`, `plane labels list`, and `plane intake list` can omit the project argument. Other project-scoped commands can use `@current` instead of repeating the identifier. +Project-scoped feature availability still depends on the target Plane project. On deployments where a feature is flagged on but the backing API is unavailable, the CLI returns an explicit compatibility error instead of a raw backend `404`. + +**Known deployment dependencies:** + +- **Pages**: The CLI targets the project-page API surface (`/projects/{id}/pages/`). Some Plane deployments do not expose page endpoints even when the project feature flag is present. Additionally, Plane has a separate workspace wiki page surface that the CLI does not cover. +- **Worklogs**: Time tracking is a Pro-plan feature. Non-Pro deployments will return compatibility errors for worklog commands. +- **Labels delete / Modules delete**: Not yet available as CLI commands. Use the Plane REST API directly for these operations. + ## Common Commands ```bash @@ -98,18 +106,18 @@ plane issues list PROJ --state started plane issue get PROJ-29 plane issue create PROJ "Title" plane issue create @current "Title" -plane issue update PROJ-29 --state done --priority high +plane issue update --state completed --priority high PROJ-29 plane issue delete PROJ-29 # Comments plane issue comments list PROJ-29 -plane issue comments add PROJ-29 "text" +plane issue comment PROJ-29 "text" plane issue comments update PROJ-29 COMMENT_ID "new text" plane issue comments delete PROJ-29 COMMENT_ID # Links plane issue link list PROJ-29 -plane issue link add PROJ-29 https://example.com "title" +plane issue link add --title "title" PROJ-29 https://example.com plane issue link remove PROJ-29 LINK_ID # Activity @@ -117,8 +125,8 @@ plane issue activity PROJ-29 # Worklogs plane issue worklogs list PROJ-29 -plane issue worklogs add PROJ-29 --duration 90 -plane issue worklogs add PROJ-29 --duration 30 --description "standup" +plane issue worklogs add PROJ-29 90 +plane issue worklogs add --description "standup" PROJ-29 30 # Cycles plane cycles list PROJ @@ -129,7 +137,7 @@ plane cycles issues add PROJ CYCLE_ID PROJ-29 plane modules list PROJ plane modules issues list PROJ MODULE_ID plane modules issues add PROJ MODULE_ID PROJ-29 -plane modules issues remove PROJ MODULE_ID PROJ-29 +plane modules issues remove PROJ MODULE_ID MODULE_ISSUE_ID # Intake plane intake list PROJ @@ -143,7 +151,7 @@ plane pages get PROJ PAGE_ID # States, labels, members plane states list PROJ plane labels list PROJ -plane members list PROJ +plane members list ``` Project identifiers: short strings like `PROJ`, `WEB`. Issue refs: `PROJ-29`, `WEB-5`. @@ -164,6 +172,19 @@ plane issues list PROJ --xml plane cycles list PROJ --json ``` +## Command Notes + +- `plane issue update` expects flags before the issue ref, for example `plane issue update --state completed PROJ-29`. +- `--description` for issue and page create or update commands is sent through to Plane as HTML in `description_html`. +- `plane issue link add` accepts an optional link title via `--title`. +- `plane modules issues remove` expects the module-issue identifier returned by `plane modules issues list`, not an issue ref. +- `plane members list` is workspace-scoped and does not take a project argument. + +## Compatibility Notes + +- `plane init --local` reports which project-scoped features are enabled for the selected project. +- Pages and worklogs can be deployment-dependent even when a feature flag is present. The CLI now returns explicit compatibility errors for unsupported endpoints. + ## Upgrade ```bash diff --git a/SKILL.md b/SKILL.md index 9b8bb40..0b21433 100644 --- a/SKILL.md +++ b/SKILL.md @@ -42,7 +42,7 @@ PLANE_* environment variables > nearest .plane/config.json > ~/.config/plane/con `plane init --local` also fetches the project's feature flags from Plane and reports which project-scoped features are actually enabled. Cycles, modules, pages, and intake commands fail with explicit feature-disabled errors when the project has them turned off. It also writes `.plane/project-context.json`, a machine-readable helper snapshot of the project's existing states, labels, and estimate points so agents can reuse current project conventions instead of creating duplicates. -It also creates or updates `AGENTS.md` in that directory with a managed Plane context section at the bottom so AI agents know to read `.plane/project-context.json` before changing project-specific Plane resources. +It also creates or updates `AGENTS.md` in that directory with a managed Plane context section at the bottom so AI agents know to read `.plane/project-context.json`, prefer the repo-local `plane` CLI, and clear inherited `PLANE_*` overrides before relying on local project config. Or set environment variables (override saved config): @@ -98,6 +98,14 @@ plane cycles list PROJ --xml plane modules list PROJ --xml ``` +## Compatibility Notes + +- Project-scoped feature availability depends on the target project's Plane feature flags. +- Some Plane deployments expose pages or worklogs in project settings but still do not provide the backing API routes. In those cases, the CLI returns an explicit compatibility error instead of a raw backend `404`. +- **Pages**: The CLI targets the project-page API surface. Plane also has a separate workspace wiki page surface that the CLI does not cover. Both may be absent on some deployments even when feature flags are present. +- **Worklogs**: Time tracking (worklogs) is a Pro-plan feature in Plane. Non-Pro deployments will not expose worklog endpoints. +- **Missing commands**: `labels delete` and `modules delete` are not yet available in the CLI — use the Plane REST API directly for these operations. + --- ## Projects @@ -140,7 +148,7 @@ plane issue get PROJ-29 plane issue create PROJ "Issue title" plane issue create @current "Issue title" plane issue create --priority high --state started PROJ "Fix lint pipeline" -plane issue create --description "Detailed context" PROJ "Add dark mode" +plane issue create --description '

Detailed context

' PROJ "Add dark mode" plane issue create --assignee "Jane Doe" PROJ "Onboarding bug" plane issue create --label "bug" PROJ "Regression in login flow" ``` @@ -155,7 +163,7 @@ plane issue create --label "bug" PROJ "Regression in login flow" plane issue update --state completed PROJ-29 plane issue update --priority high WEB-5 plane issue update --title "New title" PROJ-29 -plane issue update --description "Updated context" PROJ-29 +plane issue update --description '

Updated context

' PROJ-29 plane issue update --assignee "Jane Doe" PROJ-29 plane issue update --no-assignee PROJ-29 plane issue update --label "enhancement" PROJ-29 @@ -204,6 +212,8 @@ plane issue worklogs add PROJ-29 90 # 90 minutes plane issue worklogs add --description "code review" PROJ-29 30 ``` +Some deployments do not expose worklog endpoints even when time tracking appears enabled. Expect an explicit compatibility error in that case. + --- ## States @@ -237,6 +247,8 @@ plane members list plane members list --xml ``` +Members are workspace-scoped. This command does not take a project argument. + --- ## Cycles (sprints) @@ -261,7 +273,7 @@ plane modules list PROJ plane modules list PROJ --xml plane modules issues list PROJ plane modules issues add PROJ PROJ-29 -plane modules issues remove PROJ # use join ID, not issue ref +plane modules issues remove PROJ # use the identifier returned by `plane modules issues list` ``` --- @@ -287,9 +299,9 @@ plane pages list PROJ plane pages list PROJ --xml plane pages get PROJ # full JSON including description_html plane pages create --name "My Page" PROJ -plane pages create --name "My Page" --description "Content here" PROJ +plane pages create --name "My Page" --description '

Content here

' PROJ plane pages update --name "New Title" PROJ -plane pages update --description "New content" PROJ +plane pages update --description '

New content

' PROJ plane pages delete PROJ plane pages archive PROJ plane pages unarchive PROJ @@ -298,6 +310,8 @@ plane pages unlock PROJ plane pages duplicate PROJ ``` +Some deployments do not expose page endpoints even when the project advertises page support. Expect an explicit compatibility error in that case. + --- ## Issue Fields Reference @@ -321,7 +335,7 @@ plane pages duplicate PROJ - No server-side text search — fetch all issues and filter locally. - No epics — use labels or modules to group related issues. -- `description` in `issue create`/`update` is plain text; the CLI wraps it in `

` tags automatically. +- `description` in issue or page create and update flows is passed through to `description_html`; send HTML such as `

Details

` when you want formatted output. - Always fetch state/label/member IDs live — never hardcode UUIDs across workspaces. - `plane issue get PROJ-N` is the fastest way to inspect all fields on a single issue. From 78adb8754e930daead7f77518be07f10d34a309e Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 17:23:04 +0700 Subject: [PATCH 14/20] feat: add delete commands for labels and modules Add plane labels delete and plane modules delete so cleanup can stay inside the CLI. Both commands resolve targets from discoverable list output using either exact names or UUIDs, keeping the flows friendly for humans and agents. Also adds focused Bun/MSW coverage for the new delete handlers and brings labels command coverage up to the repo threshold. --- src/app.ts | 4 +- src/commands/labels.ts | 123 +++++++++++++++++-------- src/commands/modules.ts | 43 ++++++++- src/resolve.ts | 35 ++++++- tests/labels.test.ts | 196 ++++++++++++++++++++++++++++++++++++++++ tests/modules.test.ts | 66 ++++++++++++++ 6 files changed, 422 insertions(+), 45 deletions(-) create mode 100644 tests/labels.test.ts diff --git a/src/app.ts b/src/app.ts index 82d495b..164b885 100644 --- a/src/app.ts +++ b/src/app.ts @@ -55,11 +55,11 @@ ALL SUBCOMMANDS issue get | create | update | delete | comment | activity | link | comments | worklogs cycles list | issues (list, add) - modules list | issues (list, add, remove) + modules list | delete | issues (list, add, remove) intake list | accept | reject pages list | get | create | update | delete | archive | unarchive | lock | unlock | duplicate states list List workflow states for a project - labels list List labels for a project + labels list | create | delete members list List workspace members FOR AI AGENTS / BOTS diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 216810b..55397d9 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -3,7 +3,7 @@ import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { LabelSchema, LabelsResponseSchema } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { resolveProject } from "../resolve.js"; +import { resolveLabel, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( @@ -15,31 +15,34 @@ const listProjectArg = projectArg.pipe(Args.withDefault("")); // --- labels list --- +export function labelsListHandler({ project }: { project: string }) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + const raw = yield* api.get(`projects/${id}/labels/`); + const { results } = yield* decodeOrFail(LabelsResponseSchema, raw); + if (jsonMode) { + yield* Console.log(JSON.stringify(results, null, 2)); + return; + } + if (xmlMode) { + yield* Console.log(toXml(results)); + return; + } + if (results.length === 0) { + yield* Console.log("No labels found"); + return; + } + const lines = results.map( + (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`, + ); + yield* Console.log(lines.join("\n")); + }); +} + export const labelsList = Command.make( "list", { project: listProjectArg }, - ({ project }) => - Effect.gen(function* () { - const { id } = yield* resolveProject(project); - const raw = yield* api.get(`projects/${id}/labels/`); - const { results } = yield* decodeOrFail(LabelsResponseSchema, raw); - if (jsonMode) { - yield* Console.log(JSON.stringify(results, null, 2)); - return; - } - if (xmlMode) { - yield* Console.log(toXml(results)); - return; - } - if (results.length === 0) { - yield* Console.log("No labels found"); - return; - } - const lines = results.map( - (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`, - ); - yield* Console.log(lines.join("\n")); - }), + labelsListHandler, ); // --- labels create --- @@ -50,25 +53,71 @@ const nameArg = Args.text({ name: "name" }).pipe( const colorOption = Options.optional(Options.text("color")).pipe( Options.withDescription("Hex color e.g. #ff0000"), ); +const labelArg = Args.text({ name: "label" }).pipe( + Args.withDescription( + "Label UUID or exact name (from 'plane labels list PROJECT')", + ), +); export const labelsCreate = Command.make( "create", { color: colorOption, project: projectArg, name: nameArg }, - ({ project, name, color }) => - Effect.gen(function* () { - const { id } = yield* resolveProject(project); - interface LabelPayload { - name: string; - color?: string; - } - const body: LabelPayload = { name }; - if (color._tag === "Some") body.color = color.value; - const raw = yield* api.post(`projects/${id}/labels/`, body); - const label = yield* decodeOrFail(LabelSchema, raw); - yield* Console.log(`Created label: ${label.name} (${label.id})`); - }), + labelsCreateHandler, +); + +export function labelsCreateHandler({ + project, + name, + color, +}: { + project: string; + name: string; + color: { _tag: "Some"; value: string } | { _tag: "None" }; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + interface LabelPayload { + name: string; + color?: string; + } + const body: LabelPayload = { name }; + if (color._tag === "Some") body.color = color.value; + const raw = yield* api.post(`projects/${id}/labels/`, body); + const label = yield* decodeOrFail(LabelSchema, raw); + yield* Console.log(`Created label: ${label.name} (${label.id})`); + }); +} + +export function labelsDeleteHandler({ + project, + label, +}: { + project: string; + label: string; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + const resolvedLabel = yield* resolveLabel(id, label); + yield* api.delete(`projects/${id}/labels/${resolvedLabel.id}/`); + yield* Console.log( + `Deleted label: ${resolvedLabel.name} (${resolvedLabel.id})`, + ); + }); +} + +export const labelsDelete = Command.make( + "delete", + { project: projectArg, label: labelArg }, + labelsDeleteHandler, +).pipe( + Command.withDescription( + `Delete a label by UUID or exact name. + +Example: + plane labels delete PROJ bug`, + ), ); export const labels = Command.make("labels").pipe( - Command.withSubcommands([labelsList, labelsCreate]), + Command.withSubcommands([labelsList, labelsCreate, labelsDelete]), ); diff --git a/src/commands/modules.ts b/src/commands/modules.ts index a581c18..4f30893 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -10,6 +10,7 @@ import { findIssueBySeq, parseIssueRef, requireProjectFeature, + resolveModule, resolveProject, } from "../resolve.js"; @@ -24,6 +25,11 @@ const listProjectArg = projectArg.pipe(Args.withDefault("")); const moduleIdArg = Args.text({ name: "module-id" }).pipe( Args.withDescription("Module UUID (from 'plane modules list PROJECT')"), ); +const moduleArg = Args.text({ name: "module" }).pipe( + Args.withDescription( + "Module UUID or exact name (from 'plane modules list PROJECT')", + ), +); // --- modules list --- @@ -63,6 +69,39 @@ export const modulesList = Command.make( ), ); +// --- modules delete --- + +export function modulesDeleteHandler({ + project, + module, +}: { + project: string; + module: string; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); + const resolvedModule = yield* resolveModule(id, module); + yield* api.delete(`projects/${id}/modules/${resolvedModule.id}/`); + yield* Console.log( + `Deleted module: ${resolvedModule.name} (${resolvedModule.id})`, + ); + }); +} + +export const modulesDelete = Command.make( + "delete", + { project: projectArg, module: moduleArg }, + modulesDeleteHandler, +).pipe( + Command.withDescription( + `Delete a module by UUID or exact name. + +Example: + plane modules delete PROJ `, + ), +); + // --- modules issues list --- export function moduleIssuesListHandler({ @@ -216,7 +255,7 @@ export const moduleIssues = Command.make("issues").pipe( export const modules = Command.make("modules").pipe( Command.withDescription( - "Manage modules (groups of related issues). Subcommands: list, issues\n\nExamples:\n plane modules list PROJ\n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29", + "Manage modules (groups of related issues). Subcommands: list, delete, issues\n\nExamples:\n plane modules list PROJ\n plane modules delete PROJ \n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29", ), - Command.withSubcommands([modulesList, moduleIssues]), + Command.withSubcommands([modulesList, modulesDelete, moduleIssues]), ); diff --git a/src/resolve.ts b/src/resolve.ts index d239e16..11192d4 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -6,6 +6,7 @@ import { isProjectIntakeEnabled, LabelsResponseSchema, MembersResponseSchema, + ModulesResponseSchema, ProjectDetailSchema, ProjectsResponseSchema, StatesResponseSchema, @@ -228,13 +229,39 @@ export function getLabelId( projectId: string, name: string, ): Effect.Effect { + return resolveLabel(projectId, name).pipe(Effect.map((label) => label.id)); +} + +export function resolveLabel( + projectId: string, + nameOrId: string, +): Effect.Effect<{ id: string; name: string }, Error> { return Effect.gen(function* () { const raw = yield* api.get(`projects/${projectId}/labels/`); const { results } = yield* decodeOrFail(LabelsResponseSchema, raw); - const lower = name.toLowerCase(); - const label = results.find((l) => l.name.toLowerCase() === lower); + const lower = nameOrId.toLowerCase(); + const label = results.find( + (l) => l.id === nameOrId || l.name.toLowerCase() === lower, + ); if (!label) - return yield* Effect.fail(new Error(`Label not found: ${name}`)); - return label.id; + return yield* Effect.fail(new Error(`Label not found: ${nameOrId}`)); + return { id: label.id, name: label.name }; + }); +} + +export function resolveModule( + projectId: string, + nameOrId: string, +): Effect.Effect<{ id: string; name: string }, Error> { + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/modules/`); + const { results } = yield* decodeOrFail(ModulesResponseSchema, raw); + const lower = nameOrId.toLowerCase(); + const module = results.find( + (m) => m.id === nameOrId || m.name.toLowerCase() === lower, + ); + if (!module) + return yield* Effect.fail(new Error(`Module not found: ${nameOrId}`)); + return { id: module.id, name: module.name }; }); } diff --git a/tests/labels.test.ts b/tests/labels.test.ts new file mode 100644 index 0000000..9ce6b1a --- /dev/null +++ b/tests/labels.test.ts @@ -0,0 +1,196 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://labels-test.local"; +const WS = "testws"; + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, +]; +const LABELS = [ + { id: "l-bug", name: "bug", color: "#ff0000" }, + { id: "l-ready", name: "ready", color: "#00ff00" }, +]; + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () => + HttpResponse.json({ results: LABELS }), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +beforeEach(() => { + _clearProjectCache(); + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; +}); + +afterEach(() => { + server.resetHandlers(); + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; +}); + +describe("labelsDelete", () => { + it("deletes a label by exact name", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/l-bug/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { labelsDeleteHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + labelsDeleteHandler({ project: "ACME", label: "bug" }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted label: bug (l-bug)"); + }); + + it("deletes a label by UUID", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/l-ready/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { labelsDeleteHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + labelsDeleteHandler({ project: "ACME", label: "l-ready" }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted label: ready (l-ready)"); + }); +}); + +describe("labelsList", () => { + it("lists labels for a project", async () => { + const { labelsListHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(labelsListHandler({ project: "ACME" })); + } finally { + console.log = orig; + } + + const output = logs.join("\n"); + expect(output).toContain("l-bug"); + expect(output).toContain("bug"); + expect(output).toContain("l-ready"); + }); + + it("shows 'No labels found' when empty", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, + () => HttpResponse.json({ results: [] }), + ), + ); + + const { labelsListHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(labelsListHandler({ project: "ACME" })); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toBe("No labels found"); + }); +}); + +describe("labelsCreate", () => { + it("creates a label with color", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { id: "l-new", name: "critical", color: "#ff0000" }, + { status: 201 }, + ); + }, + ), + ); + + const { labelsCreateHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + labelsCreateHandler({ + project: "ACME", + name: "critical", + color: Option.some("#ff0000"), + }), + ); + } finally { + console.log = orig; + } + + expect((postedBody as { name?: string; color?: string }).name).toBe( + "critical", + ); + expect((postedBody as { name?: string; color?: string }).color).toBe( + "#ff0000", + ); + expect(logs.join("\n")).toContain("Created label: critical (l-new)"); + }); +}); diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 09c1ffa..5170c5a 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -154,6 +154,72 @@ describe("modulesList", () => { }); }); +describe("modulesDelete", () => { + it("deletes a module by UUID", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { modulesDeleteHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + modulesDeleteHandler({ + project: "ACME", + module: "mod1", + }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted module: Sprint 1 (mod1)"); + }); + + it("deletes a module by exact name", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod2/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { modulesDeleteHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + modulesDeleteHandler({ + project: "ACME", + module: "Sprint 2", + }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted module: Sprint 2 (mod2)"); + }); +}); + describe("moduleIssuesList", () => { it("lists issues in a module with detail", async () => { const { moduleIssuesListHandler } = await import("@/commands/modules"); From 99069b06d3e0eb3771b88003cb15fa3073be8fba Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 17:23:14 +0700 Subject: [PATCH 15/20] docs: stop describing label and module delete as missing Update the public docs and contributor guidance to reflect the new plane labels delete and plane modules delete commands. Remove the old compatibility note that told users to fall back to direct API cleanup for those two operations. --- AGENTS.md | 1 - CHANGELOG.md | 3 ++- README.md | 5 ++++- SKILL.md | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7aa6686..06f9d4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,6 @@ The CLI has been validated against a real Plane instance. Be aware of these depl - **Pages**: The CLI targets the project-page API surface. Plane also has a separate workspace wiki page surface that the CLI does not cover. Both may return 404 on some deployments even when the project feature flag is enabled. - **Worklogs**: Time tracking is a Pro-plan feature. Non-Pro deployments will not expose worklog API endpoints. - **Feature gating**: The CLI returns explicit compatibility errors (not raw 404s) when a project feature is flagged on but the backing API route is absent. -- **Missing CLI commands**: `labels delete` and `modules delete` are not yet implemented. Use the Plane REST API directly for these operations. ## Plane Project Context diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5a733..752270a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Earlier project history may predate this file. - Stricter repository quality gates covering formatting, file-size limits, and coverage thresholds. - Fork-specific package identity and public metadata alignment under `@backslash-ux/plane`. - A versioned root `AGENTS.md` file that provides baseline context for AI coding agents contributing to the repository. +- `plane labels delete` and `plane modules delete` cleanup commands. ### Changed @@ -26,4 +27,4 @@ Earlier project history may predate this file. - Full live test sweep completed against a real Plane instance. All core CLI workflows exercised: init (global, local, alias), project resolution, issue CRUD with rich options, comments, links, activity, cycles, modules, intake mutations, states, labels, members, and structured output. - Confirmed the CLI's project-page endpoint routes are correct; both page API surfaces (project pages and workspace wiki pages) return 404 on some deployments regardless of feature flags. - Confirmed worklogs are a Pro-plan-gated feature; the CLI returns explicit compatibility errors on non-Pro deployments. -- Identified missing CLI commands for label delete and module delete as follow-up items. \ No newline at end of file +- Added and validated first-class CLI cleanup commands for label delete and module delete. \ No newline at end of file diff --git a/README.md b/README.md index 8b4c355..234bdf3 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ Project-scoped feature availability still depends on the target Plane project. O - **Pages**: The CLI targets the project-page API surface (`/projects/{id}/pages/`). Some Plane deployments do not expose page endpoints even when the project feature flag is present. Additionally, Plane has a separate workspace wiki page surface that the CLI does not cover. - **Worklogs**: Time tracking is a Pro-plan feature. Non-Pro deployments will return compatibility errors for worklog commands. -- **Labels delete / Modules delete**: Not yet available as CLI commands. Use the Plane REST API directly for these operations. ## Common Commands @@ -135,6 +134,7 @@ plane cycles issues add PROJ CYCLE_ID PROJ-29 # Modules plane modules list PROJ +plane modules delete PROJ MODULE_ID plane modules issues list PROJ MODULE_ID plane modules issues add PROJ MODULE_ID PROJ-29 plane modules issues remove PROJ MODULE_ID MODULE_ISSUE_ID @@ -151,6 +151,7 @@ plane pages get PROJ PAGE_ID # States, labels, members plane states list PROJ plane labels list PROJ +plane labels delete PROJ bug plane members list ``` @@ -177,6 +178,8 @@ plane cycles list PROJ --json - `plane issue update` expects flags before the issue ref, for example `plane issue update --state completed PROJ-29`. - `--description` for issue and page create or update commands is sent through to Plane as HTML in `description_html`. - `plane issue link add` accepts an optional link title via `--title`. +- `plane labels delete` accepts either the label UUID or the exact label name returned by `plane labels list`. +- `plane modules delete` accepts either the module UUID or the exact module name returned by `plane modules list`. - `plane modules issues remove` expects the module-issue identifier returned by `plane modules issues list`, not an issue ref. - `plane members list` is workspace-scoped and does not take a project argument. diff --git a/SKILL.md b/SKILL.md index 0b21433..a698136 100644 --- a/SKILL.md +++ b/SKILL.md @@ -104,7 +104,6 @@ plane modules list PROJ --xml - Some Plane deployments expose pages or worklogs in project settings but still do not provide the backing API routes. In those cases, the CLI returns an explicit compatibility error instead of a raw backend `404`. - **Pages**: The CLI targets the project-page API surface. Plane also has a separate workspace wiki page surface that the CLI does not cover. Both may be absent on some deployments even when feature flags are present. - **Worklogs**: Time tracking (worklogs) is a Pro-plan feature in Plane. Non-Pro deployments will not expose worklog endpoints. -- **Missing commands**: `labels delete` and `modules delete` are not yet available in the CLI — use the Plane REST API directly for these operations. --- @@ -236,6 +235,7 @@ plane labels list PROJ plane labels list PROJ --xml plane labels create PROJ "bug" plane labels create --color "#ff0000" PROJ "critical" +plane labels delete PROJ bug ``` --- @@ -271,6 +271,7 @@ Cycle IDs are UUIDs. Fetch them from `plane cycles list PROJ`. plane modules list plane modules list PROJ plane modules list PROJ --xml +plane modules delete PROJ plane modules issues list PROJ plane modules issues add PROJ PROJ-29 plane modules issues remove PROJ # use the identifier returned by `plane modules issues list` From ef0920c0fb7702b880787ce2843f286e766bb78e Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 17:31:08 +0700 Subject: [PATCH 16/20] fix: update URLs in configuration and documentation to reflect correct repository name --- .github/ISSUE_TEMPLATE/config.yml | 6 +++--- CHANGELOG.md | 2 +- README.md | 8 ++++---- SKILL.md | 2 +- docs/RELEASING.md | 4 ++-- package.json | 8 ++++---- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 113d334..5214e15 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Security policy - url: https://github.com/backslash-ux/plane-cli/blob/main/SECURITY.md + url: https://github.com/backslash-ux/plane-cli-cli/blob/main/SECURITY.md about: Report suspected vulnerabilities privately instead of opening a public issue. - name: Contributing guide - url: https://github.com/backslash-ux/plane-cli/blob/main/CONTRIBUTING.md + url: https://github.com/backslash-ux/plane-cli-cli/blob/main/CONTRIBUTING.md about: Review contribution expectations, quality gates, and documentation requirements first. - name: Existing issues - url: https://github.com/backslash-ux/plane-cli/issues + url: https://github.com/backslash-ux/plane-cli-cli/issues about: Check open issues and feature requests before filing a new one. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 752270a..87b5185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Earlier project history may predate this file. - Public open-source repository baseline with contributor, governance, security, architecture, and release documentation. - GitHub issue and pull request templates plus issue intake routing. - Stricter repository quality gates covering formatting, file-size limits, and coverage thresholds. -- Fork-specific package identity and public metadata alignment under `@backslash-ux/plane`. +- Fork-specific package identity and public metadata alignment under `@backslash-ux/plane-cli`. - A versioned root `AGENTS.md` file that provides baseline context for AI coding agents contributing to the repository. - `plane labels delete` and `plane modules delete` cleanup commands. diff --git a/README.md b/README.md index 234bdf3..22a4057 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # plane -[![CI](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![CI](https://github.com/backslash-ux/plane-cli-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/backslash-ux/plane-cli-cli/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) CLI for the [Plane](https://plane.so) project management API. @@ -31,7 +31,7 @@ This repository is a fork of [aaronshaf/plane-cli](https://github.com/aaronshaf/ ```bash curl -fsSL https://bun.sh/install | bash -bun install -g @backslash-ux/plane +bun install -g @backslash-ux/plane-cli ``` ## Setup @@ -191,13 +191,13 @@ plane cycles list PROJ --json ## Upgrade ```bash -bun update -g @backslash-ux/plane +bun update -g @backslash-ux/plane-cli ``` ## Development ```bash -git clone https://github.com/backslash-ux/plane-cli +git clone https://github.com/backslash-ux/plane-cli-cli cd plane-cli bun install diff --git a/SKILL.md b/SKILL.md index a698136..d37a5d8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,7 +14,7 @@ The `plane` CLI wraps the Plane REST API. It is designed for both human and AI agent use. Install it globally with bun: ```bash -bun install -g @backslash-ux/plane +bun install -g @backslash-ux/plane-cli ``` ## Configuration diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7ef6773..1c027f0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,7 +8,7 @@ This repository publishes from Git tags that match `v*` through [`.github/workfl Before the first public release, make sure the publication path itself is ready: -1. Verify the npm package name is available and that the publishing account or npm organization has access to `@backslash-ux/plane`. +1. Verify the npm package name is available and that the publishing account or npm organization has access to `@backslash-ux/plane-cli`. 2. Enable npm account 2FA for maintainers. For CI publishing, either: - keep using the current `NPM_CONFIG_TOKEN` secret with a token that is allowed to publish this package, or - migrate the workflow to npm trusted publishing so long-lived tokens are no longer required. @@ -63,7 +63,7 @@ git push origin vX.Y.Z 3. Smoke-test installation from the public registry: ```bash -bunx @backslash-ux/plane --help +bunx @backslash-ux/plane-cli --help ``` 4. If the release changes agent workflows, confirm `README.md`, `SKILL.md`, and `AGENTS.md` guidance still matches the shipped package. diff --git a/package.json b/package.json index 6e37257..e0318f9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@backslash-ux/plane", + "name": "@backslash-ux/plane-cli", "publishConfig": { "access": "public" }, @@ -33,12 +33,12 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/backslash-ux/plane-cli.git" + "url": "git+https://github.com/backslash-ux/plane-cli-cli.git" }, "bugs": { - "url": "https://github.com/backslash-ux/plane-cli/issues" + "url": "https://github.com/backslash-ux/plane-cli-cli/issues" }, - "homepage": "https://github.com/backslash-ux/plane-cli#readme", + "homepage": "https://github.com/backslash-ux/plane-cli-cli#readme", "engines": { "bun": ">=1.0.0" }, From d8e0845e9f7ecc31853140882710f1970a0dd271 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 17:31:20 +0700 Subject: [PATCH 17/20] fix: update pre-commit hook to correctly locate bun and adjust coverage check command --- .husky/pre-commit | 13 +++++++++++-- scripts/check-coverage.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index dc2222b..ccf6944 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,16 @@ +# Locate bun - git hooks run with a stripped PATH that often omits ~/.bun/bin +BUN=$(command -v bun 2>/dev/null \ + || command -v "$HOME/.bun/bin/bun" 2>/dev/null \ + || echo "") +if [ -z "$BUN" ]; then + echo "husky: bun not found in PATH or ~/.bun/bin – please install bun or add it to your PATH" >&2 + exit 1 +fi + # Check file sizes echo "Checking file sizes..." -bun scripts/check-file-size.ts +"$BUN" scripts/check-file-size.ts # Check test coverage echo "Checking test coverage..." -bun run test:coverage:check +"$BUN" scripts/check-coverage.ts diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index 9d270ff..bbc3102 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -10,7 +10,7 @@ const THRESHOLDS = { console.log("Running tests with coverage...\n") try { - const output = execSync("bun test --coverage 2>&1", { encoding: "utf8" }) + const output = execSync(`"${process.execPath}" test --coverage 2>&1`, { encoding: "utf8" }) console.log(output) const coverageMatch = output.match(/All files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|/) From 7794ca467eac4c9dd32881b2f518d256e67c7a7c Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 18:44:03 +0700 Subject: [PATCH 18/20] feat: enhance initHandler and config handling, improve error messages, and streamline environment variable management --- src/commands/init.ts | 52 ++++++++++++++++++++---------- src/commands/intake.ts | 5 ++- src/commands/issue.ts | 9 ++++-- src/commands/modules.ts | 2 +- src/commands/pages.ts | 40 +++++------------------ src/config.ts | 2 +- src/user-config.ts | 53 ++++++++++++++++++++----------- tests/user-config.test.ts | 67 +++++++++++++++++++++++++++++++++++---- 8 files changed, 151 insertions(+), 79 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 536213c..0b92ef3 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -325,6 +325,19 @@ export function initHandler( output: process.stdout, }); + let savedHost: string | undefined; + let savedWorkspace: string | undefined; + let savedToken: string | undefined; + let savedDefaultProject = existing.defaultProject; + let normalizedHost = ""; + let mergedWorkspace = ""; + let mergedToken = ""; + let projectsResult: import("effect").Either.Either< + ReadonlyArray<{ id: string; identifier: string; name: string }>, + Error + >; + + try { const host = yield* Effect.promise(() => prompt( rl, @@ -351,41 +364,38 @@ export function initHandler( ), ); - const savedHost = + savedHost = scope === "global" ? resolveGlobalValue( host, existing.host || effective.host || "https://plane.so", ) : resolveLocalValue(host, existing.host); - const savedWorkspace = + savedWorkspace = scope === "global" ? resolveGlobalValue( workspace, existing.workspace || effective.workspace, ) : resolveLocalValue(workspace, existing.workspace); - const savedToken = + savedToken = scope === "global" ? resolveGlobalValue(token, existing.token || effective.token) : resolveLocalValue(token, existing.token); const mergedHost = savedHost ?? effective.host ?? "https://plane.so"; - const normalizedHost = normalizeHost(mergedHost); - const mergedWorkspace = savedWorkspace ?? effective.workspace; - const mergedToken = savedToken ?? effective.token; + normalizedHost = normalizeHost(mergedHost); + mergedWorkspace = savedWorkspace ?? effective.workspace; + mergedToken = savedToken ?? effective.token; if (!mergedToken) { - rl.close(); yield* Effect.fail(new Error("API token is required")); } if (!mergedWorkspace) { - rl.close(); yield* Effect.fail(new Error("Workspace is required")); } - let savedDefaultProject = existing.defaultProject; - const projectsResult = yield* Effect.either( + projectsResult = yield* Effect.either( fetchProjectsForConfig({ host: normalizedHost, workspace: mergedWorkspace, @@ -426,7 +436,9 @@ export function initHandler( ); } - rl.close(); + } finally { + rl.close(); + } if (scope === "global") { writeGlobalStoredConfig({ @@ -447,11 +459,18 @@ export function initHandler( ); } + const hostForDisplay = + scope === "global" + ? normalizedHost + : savedHost + ? normalizeHost(savedHost) + : normalizedHost; + yield* Console.log( `\n${scope === "global" ? "Global" : "Local"} config saved to ${savePath}`, ); yield* Console.log( - ` Host: ${describeValue(scope, savedHost, normalizedHost)}`, + ` Host: ${describeValue(scope, savedHost, hostForDisplay)}`, ); yield* Console.log( ` Workspace: ${describeValue(scope, savedWorkspace, mergedWorkspace)}`, @@ -467,13 +486,14 @@ export function initHandler( ); } - if (scope === "local" && savedDefaultProject) { + const activeDefaultProject = savedDefaultProject ?? effective.defaultProject; + if (scope === "local" && activeDefaultProject) { const selectedProject = - projectsResult._tag === "Right" - ? projectsResult.right.find( + projectsResult!._tag === "Right" + ? projectsResult!.right.find( (project) => project.identifier.toUpperCase() === - savedDefaultProject.toUpperCase(), + activeDefaultProject.toUpperCase(), ) : undefined; if (selectedProject) { diff --git a/src/commands/intake.ts b/src/commands/intake.ts index 8ef216f..bb859f5 100644 --- a/src/commands/intake.ts +++ b/src/commands/intake.ts @@ -60,7 +60,10 @@ export function intakeListHandler({ project }: { project: string }) { return; } const lines = results.map((i) => { - const status = STATUS_LABELS[i.status ?? 0] ?? String(i.status ?? "?"); + const status = + i.status != null + ? (STATUS_LABELS[i.status] ?? String(i.status)) + : "unknown"; const statusPad = status.padEnd(10); if (i.issue_detail) { return `${i.id} [${statusPad}] ${i.issue_detail.name}`; diff --git a/src/commands/issue.ts b/src/commands/issue.ts index c10f479..8f3efef 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -429,11 +429,14 @@ export function issueLinkRemoveHandler({ return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); - yield* requestWithFallback( - issueLinkPaths(projectId, issue.id).map((path) => `${path}${linkId}/`), - (path) => api.delete(path), + // Resolve the correct base path for issue links via a safe probe, + // then delete once so a missing link ID results in a proper not-found error. + const basePath = yield* requestWithFallback( + issueLinkPaths(projectId, issue.id), + (path) => api.get(path).pipe(Effect.as(path)), `Issue links are not available for ${ref} on this Plane instance or API version.`, ); + yield* api.delete(`${basePath}${linkId}/`); yield* Console.log(`Link ${linkId} removed from ${ref}`); }); } diff --git a/src/commands/modules.ts b/src/commands/modules.ts index 4f30893..9d9baa7 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -137,7 +137,7 @@ export function moduleIssuesListHandler({ } if ("sequence_id" in mi) { const seq = String(mi.sequence_id).padStart(3, " "); - return `${key}-${seq} ${mi.name}`; + return `${key}-${seq} ${mi.name} (${mi.id})`; } return `${mi.issue} (module-issue: ${mi.id})`; }); diff --git a/src/commands/pages.ts b/src/commands/pages.ts index 1dcf083..2803d82 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -109,10 +109,7 @@ export function pagesGetHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - const raw = yield* mapPageAvailabilityError( - api.get(`projects/${id}/pages/${pageId}/`), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + const raw = yield* api.get(`projects/${id}/pages/${pageId}/`); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(JSON.stringify(page, null, 2)); }); @@ -187,10 +184,7 @@ export function pagesUpdateHandler({ const body: PageUpdatePayload = {}; if (Option.isSome(name)) body.name = name.value; if (Option.isSome(description)) body.description_html = description.value; - const raw = yield* mapPageAvailabilityError( - api.patch(`projects/${id}/pages/${pageId}/`, body), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + const raw = yield* api.patch(`projects/${id}/pages/${pageId}/`, body); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Updated page ${page.id}: ${page.name}`); }); @@ -223,10 +217,7 @@ export function pagesDeleteHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* mapPageAvailabilityError( - api.delete(`projects/${id}/pages/${pageId}/`), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + yield* api.delete(`projects/${id}/pages/${pageId}/`); yield* Console.log(`Deleted page ${pageId}`); }); } @@ -253,10 +244,7 @@ export function pagesArchiveHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* mapPageAvailabilityError( - api.post(`projects/${id}/pages/${pageId}/archive/`, {}), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {}); yield* Console.log(`Archived page ${pageId}`); }); } @@ -283,10 +271,7 @@ export function pagesUnarchiveHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* mapPageAvailabilityError( - api.delete(`projects/${id}/pages/${pageId}/archive/`), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + yield* api.delete(`projects/${id}/pages/${pageId}/archive/`); yield* Console.log(`Unarchived page ${pageId}`); }); } @@ -313,10 +298,7 @@ export function pagesLockHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* mapPageAvailabilityError( - api.post(`projects/${id}/pages/${pageId}/lock/`, {}), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {}); yield* Console.log(`Locked page ${pageId}`); }); } @@ -343,10 +325,7 @@ export function pagesUnlockHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - yield* mapPageAvailabilityError( - api.delete(`projects/${id}/pages/${pageId}/lock/`), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + yield* api.delete(`projects/${id}/pages/${pageId}/lock/`); yield* Console.log(`Unlocked page ${pageId}`); }); } @@ -373,10 +352,7 @@ export function pagesDuplicateHandler({ return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - const raw = yield* mapPageAvailabilityError( - api.post(`projects/${id}/pages/${pageId}/duplicate/`, {}), - `Project pages are not available for ${key} on this Plane instance or API version.`, - ); + const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {}); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Duplicated page ${page.id}: ${page.name}`); }); diff --git a/src/config.ts b/src/config.ts index 18e1148..400c54b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,7 +90,7 @@ export type ProjectDetail = typeof ProjectDetailSchema.Type; export function isProjectIntakeEnabled( project: Pick, ): boolean { - return project.inbox_view ?? project.intake_view ?? false; + return (project.inbox_view || project.intake_view) ?? false; } export const EstimateSchema = Schema.Struct({ diff --git a/src/user-config.ts b/src/user-config.ts index 05663fb..c34d6a7 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -189,14 +189,41 @@ export function getConfigDetails(cwd = process.cwd()): PlaneConfigDetails { const localConfigFile = findNearestLocalConfigFilePath(cwd); const localConfig = localConfigFile ? readConfigFile(localConfigFile) : {}; - const envToken = process.env.PLANE_API_TOKEN; - const envHost = process.env.PLANE_HOST; - const envWorkspace = process.env.PLANE_WORKSPACE; - const envProject = process.env.PLANE_PROJECT; + const envToken = process.env.PLANE_API_TOKEN?.trim() || undefined; + const envHost = process.env.PLANE_HOST?.trim() || undefined; + const envWorkspace = process.env.PLANE_WORKSPACE?.trim() || undefined; + const envProject = process.env.PLANE_PROJECT?.trim() || undefined; + + const hostSource: ConfigSource = envHost + ? "env" + : localConfig.host + ? "local" + : globalConfig.host + ? "global" + : "default"; + const tokenSource: ConfigSource = envToken + ? "env" + : localConfig.token + ? "local" + : globalConfig.token + ? "global" + : "none"; + + // Security: if the host comes from local config but the token comes from + // global config, an untrusted repo could redirect a real token to an + // attacker-controlled host. In that case, fall back to the global host. + const safeHostSource = + hostSource === "local" && tokenSource === "global" ? "global" : hostSource; const token = envToken ?? localConfig.token ?? globalConfig.token ?? ""; const host = normalizeHost( - envHost ?? localConfig.host ?? globalConfig.host ?? DEFAULT_HOST, + safeHostSource === "local" + ? (localConfig.host ?? globalConfig.host ?? DEFAULT_HOST) + : safeHostSource === "env" + ? (envHost ?? DEFAULT_HOST) + : safeHostSource === "global" + ? (globalConfig.host ?? DEFAULT_HOST) + : DEFAULT_HOST, ); const workspace = envWorkspace ?? localConfig.workspace ?? globalConfig.workspace ?? ""; @@ -212,20 +239,8 @@ export function getConfigDetails(cwd = process.cwd()): PlaneConfigDetails { workspace, defaultProject, sources: { - token: envToken - ? "env" - : localConfig.token - ? "local" - : globalConfig.token - ? "global" - : "none", - host: envHost - ? "env" - : localConfig.host - ? "local" - : globalConfig.host - ? "global" - : "default", + token: tokenSource, + host: safeHostSource, workspace: envWorkspace ? "env" : localConfig.workspace diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts index 20ffe80..5d04aac 100644 --- a/tests/user-config.test.ts +++ b/tests/user-config.test.ts @@ -5,6 +5,10 @@ import * as path from "node:path"; const ORIGINAL_HOME = process.env.HOME; const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_PLANE_API_TOKEN = process.env.PLANE_API_TOKEN; +const ORIGINAL_PLANE_HOST = process.env.PLANE_HOST; +const ORIGINAL_PLANE_WORKSPACE = process.env.PLANE_WORKSPACE; +const ORIGINAL_PLANE_PROJECT = process.env.PLANE_PROJECT; let tempHome = ""; @@ -19,10 +23,26 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.PLANE_API_TOKEN; - delete process.env.PLANE_HOST; - delete process.env.PLANE_WORKSPACE; - delete process.env.PLANE_PROJECT; + if (ORIGINAL_PLANE_API_TOKEN === undefined) { + delete process.env.PLANE_API_TOKEN; + } else { + process.env.PLANE_API_TOKEN = ORIGINAL_PLANE_API_TOKEN; + } + if (ORIGINAL_PLANE_HOST === undefined) { + delete process.env.PLANE_HOST; + } else { + process.env.PLANE_HOST = ORIGINAL_PLANE_HOST; + } + if (ORIGINAL_PLANE_WORKSPACE === undefined) { + delete process.env.PLANE_WORKSPACE; + } else { + process.env.PLANE_WORKSPACE = ORIGINAL_PLANE_WORKSPACE; + } + if (ORIGINAL_PLANE_PROJECT === undefined) { + delete process.env.PLANE_PROJECT; + } else { + process.env.PLANE_PROJECT = ORIGINAL_PLANE_PROJECT; + } if (ORIGINAL_HOME === undefined) { delete process.env.HOME; } else { @@ -74,14 +94,49 @@ describe("user config layering", () => { ); expect(config.token).toBe("global-token"); expect(config.workspace).toBe("global-workspace"); - expect(config.host).toBe("https://app.plane.local"); + // Security: local host is ignored when the token comes from global + // to prevent an untrusted repo from redirecting a real token. + expect(config.host).toBe("https://global.plane.local"); expect(config.defaultProject).toBe("APP"); expect(config.sources.token).toBe("global"); - expect(config.sources.host).toBe("local"); + expect(config.sources.host).toBe("global"); expect(config.sources.workspace).toBe("global"); expect(config.sources.defaultProject).toBe("local"); }); + it("uses local host when the local config also provides a token", async () => { + const { + getConfigDetails, + writeGlobalStoredConfig, + writeLocalStoredConfig, + } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + + writeGlobalStoredConfig({ + token: "global-token", + host: "https://global.plane.local", + workspace: "global-workspace", + }); + writeLocalStoredConfig( + { + token: "local-token", + host: "https://local.plane.local", + workspace: "local-workspace", + }, + { cwd: repoDir, target: "cwd" }, + ); + + const config = getConfigDetails(repoDir); + + expect(config.token).toBe("local-token"); + expect(config.host).toBe("https://local.plane.local"); + expect(config.workspace).toBe("local-workspace"); + expect(config.sources.token).toBe("local"); + expect(config.sources.host).toBe("local"); + expect(config.sources.workspace).toBe("local"); + }); + it("applies canonical env vars above local and global config", async () => { const { getConfigDetails, From 9a1f054a327f98eb105a701b82ed07f0ca120280 Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 18:48:54 +0700 Subject: [PATCH 19/20] refactor: simplify project resolution by removing unused key variable in pages handlers --- src/commands/init.ts | 180 +++++++++++++++++++++--------------------- src/commands/pages.ts | 21 ++--- 2 files changed, 102 insertions(+), 99 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 0b92ef3..98b5e4a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -338,104 +338,103 @@ export function initHandler( >; try { - const host = yield* Effect.promise(() => - prompt( - rl, - promptLabel("Plane host URL", scope, existing.host, effective.host), - ), - ); - const workspace = yield* Effect.promise(() => - prompt( - rl, - promptLabel( - "Workspace", - scope, - existing.workspace, - effective.workspace, + const host = yield* Effect.promise(() => + prompt( + rl, + promptLabel("Plane host URL", scope, existing.host, effective.host), ), - ), - ); - const token = yield* Effect.promise(() => - prompt( - rl, - promptLabel("API token", scope, existing.token, effective.token, { - hidden: true, - }), - ), - ); - - savedHost = - scope === "global" - ? resolveGlobalValue( - host, - existing.host || effective.host || "https://plane.so", - ) - : resolveLocalValue(host, existing.host); - savedWorkspace = - scope === "global" - ? resolveGlobalValue( - workspace, - existing.workspace || effective.workspace, - ) - : resolveLocalValue(workspace, existing.workspace); - savedToken = - scope === "global" - ? resolveGlobalValue(token, existing.token || effective.token) - : resolveLocalValue(token, existing.token); - - const mergedHost = savedHost ?? effective.host ?? "https://plane.so"; - normalizedHost = normalizeHost(mergedHost); - mergedWorkspace = savedWorkspace ?? effective.workspace; - mergedToken = savedToken ?? effective.token; - - if (!mergedToken) { - yield* Effect.fail(new Error("API token is required")); - } - if (!mergedWorkspace) { - yield* Effect.fail(new Error("Workspace is required")); - } - - projectsResult = yield* Effect.either( - fetchProjectsForConfig({ - host: normalizedHost, - workspace: mergedWorkspace, - token: mergedToken, - }), - ); - if (projectsResult._tag === "Right" && projectsResult.right.length > 0) { - yield* Console.log("\nAvailable projects:"); - yield* Console.log( - projectsResult.right - .map( - (project, index) => - `${index + 1}. ${project.identifier} ${project.name}`, - ) - .join("\n"), ); - const selectedProject = yield* Effect.promise(() => + const workspace = yield* Effect.promise(() => prompt( rl, promptLabel( - "Default project number or identifier", + "Workspace", scope, - existing.defaultProject, - effective.defaultProject, - { clearHint: true }, + existing.workspace, + effective.workspace, ), ), ); - savedDefaultProject = resolveProjectSelection( - selectedProject, - projectsResult.right, - existing.defaultProject, - scope, - ); - } else if (projectsResult._tag === "Left") { - yield* Console.log( - `\nWarning: could not load projects for selection (${projectsResult.left.message}). Continuing without changing the current-project override.`, + const token = yield* Effect.promise(() => + prompt( + rl, + promptLabel("API token", scope, existing.token, effective.token, { + hidden: true, + }), + ), ); - } + savedHost = + scope === "global" + ? resolveGlobalValue( + host, + existing.host || effective.host || "https://plane.so", + ) + : resolveLocalValue(host, existing.host); + savedWorkspace = + scope === "global" + ? resolveGlobalValue( + workspace, + existing.workspace || effective.workspace, + ) + : resolveLocalValue(workspace, existing.workspace); + savedToken = + scope === "global" + ? resolveGlobalValue(token, existing.token || effective.token) + : resolveLocalValue(token, existing.token); + + const mergedHost = savedHost ?? effective.host ?? "https://plane.so"; + normalizedHost = normalizeHost(mergedHost); + mergedWorkspace = savedWorkspace ?? effective.workspace; + mergedToken = savedToken ?? effective.token; + + if (!mergedToken) { + yield* Effect.fail(new Error("API token is required")); + } + if (!mergedWorkspace) { + yield* Effect.fail(new Error("Workspace is required")); + } + + projectsResult = yield* Effect.either( + fetchProjectsForConfig({ + host: normalizedHost, + workspace: mergedWorkspace, + token: mergedToken, + }), + ); + if (projectsResult._tag === "Right" && projectsResult.right.length > 0) { + yield* Console.log("\nAvailable projects:"); + yield* Console.log( + projectsResult.right + .map( + (project, index) => + `${index + 1}. ${project.identifier} ${project.name}`, + ) + .join("\n"), + ); + const selectedProject = yield* Effect.promise(() => + prompt( + rl, + promptLabel( + "Default project number or identifier", + scope, + existing.defaultProject, + effective.defaultProject, + { clearHint: true }, + ), + ), + ); + savedDefaultProject = resolveProjectSelection( + selectedProject, + projectsResult.right, + existing.defaultProject, + scope, + ); + } else if (projectsResult._tag === "Left") { + yield* Console.log( + `\nWarning: could not load projects for selection (${projectsResult.left.message}). Continuing without changing the current-project override.`, + ); + } } finally { rl.close(); } @@ -486,11 +485,12 @@ export function initHandler( ); } - const activeDefaultProject = savedDefaultProject ?? effective.defaultProject; + const activeDefaultProject = + savedDefaultProject ?? effective.defaultProject; if (scope === "local" && activeDefaultProject) { const selectedProject = - projectsResult!._tag === "Right" - ? projectsResult!.right.find( + projectsResult?._tag === "Right" + ? projectsResult?.right.find( (project) => project.identifier.toUpperCase() === activeDefaultProject.toUpperCase(), diff --git a/src/commands/pages.ts b/src/commands/pages.ts index 2803d82..21815ab 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -107,7 +107,7 @@ export function pagesGetHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); const raw = yield* api.get(`projects/${id}/pages/${pageId}/`); const page = yield* decodeOrFail(PageSchema, raw); @@ -179,7 +179,7 @@ export function pagesUpdateHandler({ if (Option.isNone(name) && Option.isNone(description)) { yield* Effect.fail(new Error("provide at least --name or --description")); } - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); const body: PageUpdatePayload = {}; if (Option.isSome(name)) body.name = name.value; @@ -215,7 +215,7 @@ export function pagesDeleteHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/`); yield* Console.log(`Deleted page ${pageId}`); @@ -242,7 +242,7 @@ export function pagesArchiveHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {}); yield* Console.log(`Archived page ${pageId}`); @@ -269,7 +269,7 @@ export function pagesUnarchiveHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/archive/`); yield* Console.log(`Unarchived page ${pageId}`); @@ -296,7 +296,7 @@ export function pagesLockHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {}); yield* Console.log(`Locked page ${pageId}`); @@ -323,7 +323,7 @@ export function pagesUnlockHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/lock/`); yield* Console.log(`Unlocked page ${pageId}`); @@ -350,9 +350,12 @@ export function pagesDuplicateHandler({ pageId: string; }) { return Effect.gen(function* () { - const { key, id } = yield* resolveProject(project); + const { id } = yield* resolveProject(project); yield* requireProjectFeature(id, "page_view"); - const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {}); + const raw = yield* api.post( + `projects/${id}/pages/${pageId}/duplicate/`, + {}, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Duplicated page ${page.id}: ${page.name}`); }); From 3eb7714111b3d9b73ae5b84bc7ab92b03e284e7c Mon Sep 17 00:00:00 2001 From: backslash-ux Date: Tue, 31 Mar 2026 19:25:52 +0700 Subject: [PATCH 20/20] release: 1.0.0 --- src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 164b885..34a7c53 100644 --- a/src/app.ts +++ b/src/app.ts @@ -94,5 +94,5 @@ FOR AI AGENTS / BOTS export const cli = Command.run(plane, { name: "plane", - version: "0.1.11", + version: "1.0.0", });