@ysicing/plane-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import { UserClient } from "../api/user-client.js";
2
+ import { resolveRuntimeConfig } from "../core/config.js";
3
+ import { CliError } from "../core/errors.js";
4
+ import { PlaneClient } from "../core/http.js";
5
+ import { printData } from "../core/output.js";
6
+
7
+ export async function runMeCommand(args, context) {
8
+ if (args.includes("--help") || args.includes("help")) {
9
+ console.log("Usage:\n plane me");
10
+ return;
11
+ }
12
+
13
+ if (args.length > 0) {
14
+ throw new CliError("`plane me` does not accept extra arguments.");
15
+ }
16
+
17
+ const config = await resolveRuntimeConfig();
18
+ const client = new PlaneClient(config);
19
+ const userClient = new UserClient(client);
20
+ const user = await userClient.me();
21
+
22
+ printData(user, context.output);
23
+ }
@@ -0,0 +1,135 @@
1
+ import { ProjectClient } from "../api/project-client.js";
2
+ import { resolveRuntimeConfig } from "../core/config.js";
3
+ import { CliError } from "../core/errors.js";
4
+ import { PlaneClient } from "../core/http.js";
5
+ import { ensureValue, parseCommandArgs, pickDefined } from "../core/options.js";
6
+ import { printData, printTable } from "../core/output.js";
7
+
8
+ function createProjectRender(data) {
9
+ const rows = Array.isArray(data) ? data : data.results || [];
10
+ printTable(rows, [
11
+ { label: "ID", get: (row) => row.id },
12
+ { label: "Identifier", get: (row) => row.identifier },
13
+ { label: "Name", get: (row) => row.name },
14
+ { label: "Lead", get: (row) => row.project_lead || "" },
15
+ { label: "Archived", get: (row) => (row.archived_at ? "yes" : "no") },
16
+ ]);
17
+ }
18
+
19
+ export function buildProjectPayload(values) {
20
+ return pickDefined({
21
+ name: values.name,
22
+ identifier: values.identifier ? values.identifier.toUpperCase() : undefined,
23
+ description: values.description,
24
+ project_lead: values["project-lead"],
25
+ default_assignee: values["default-assignee"],
26
+ });
27
+ }
28
+
29
+ function printHelp() {
30
+ console.log(`Usage:
31
+ plane project ls [--limit <n>] [--cursor <cursor>] [--order-by <field>]
32
+ plane project get <project-id>
33
+ plane project create --name <name> --identifier <identifier> [--description <text>] [--project-lead <user-id>] [--default-assignee <user-id>]
34
+ plane project update <project-id> [--name <name>] [--identifier <identifier>] [--description <text>] [--project-lead <user-id>] [--default-assignee <user-id>]
35
+ `);
36
+ }
37
+
38
+ export async function runProjectCommand(args, context) {
39
+ const [subcommand, ...rest] = args;
40
+
41
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
42
+ printHelp();
43
+ return;
44
+ }
45
+
46
+ const config = await resolveRuntimeConfig();
47
+ const projectClient = new ProjectClient(new PlaneClient(config));
48
+
49
+ if (subcommand === "ls") {
50
+ const parsed = parseCommandArgs(
51
+ rest,
52
+ {
53
+ limit: { type: "string" },
54
+ cursor: { type: "string" },
55
+ "order-by": { type: "string" },
56
+ fields: { type: "string" },
57
+ expand: { type: "string" },
58
+ },
59
+ false
60
+ );
61
+
62
+ const result = await projectClient.list(
63
+ pickDefined({
64
+ per_page: parsed.values.limit,
65
+ cursor: parsed.values.cursor,
66
+ order_by: parsed.values["order-by"],
67
+ fields: parsed.values.fields,
68
+ expand: parsed.values.expand,
69
+ })
70
+ );
71
+
72
+ printData(result, {
73
+ ...context.output,
74
+ render: createProjectRender,
75
+ });
76
+ return;
77
+ }
78
+
79
+ if (subcommand === "get") {
80
+ const [projectId] = rest;
81
+ ensureValue(projectId, "Project ID is required.");
82
+ const result = await projectClient.get(projectId);
83
+ printData(result, context.output);
84
+ return;
85
+ }
86
+
87
+ if (subcommand === "create") {
88
+ const parsed = parseCommandArgs(
89
+ rest,
90
+ {
91
+ name: { type: "string" },
92
+ identifier: { type: "string" },
93
+ description: { type: "string" },
94
+ "project-lead": { type: "string" },
95
+ "default-assignee": { type: "string" },
96
+ },
97
+ false
98
+ );
99
+
100
+ ensureValue(parsed.values.name, "Project name is required.");
101
+ ensureValue(parsed.values.identifier, "Project identifier is required.");
102
+
103
+ const result = await projectClient.create(buildProjectPayload(parsed.values));
104
+ printData(result, context.output);
105
+ return;
106
+ }
107
+
108
+ if (subcommand === "update") {
109
+ const [projectId, ...optionArgs] = rest;
110
+ ensureValue(projectId, "Project ID is required.");
111
+
112
+ const parsed = parseCommandArgs(
113
+ optionArgs,
114
+ {
115
+ name: { type: "string" },
116
+ identifier: { type: "string" },
117
+ description: { type: "string" },
118
+ "project-lead": { type: "string" },
119
+ "default-assignee": { type: "string" },
120
+ },
121
+ false
122
+ );
123
+
124
+ const payload = buildProjectPayload(parsed.values);
125
+ if (Object.keys(payload).length === 0) {
126
+ throw new CliError("At least one update field is required.");
127
+ }
128
+
129
+ const result = await projectClient.update(projectId, payload);
130
+ printData(result, context.output);
131
+ return;
132
+ }
133
+
134
+ throw new CliError(`Unknown project subcommand: ${subcommand}`);
135
+ }
@@ -0,0 +1,102 @@
1
+ import { loadConfig, saveConfig } from "../core/config.js";
2
+ import { CliError } from "../core/errors.js";
3
+ import { parseCommandArgs } from "../core/options.js";
4
+ import { printData, printTable } from "../core/output.js";
5
+
6
+ function printHelp() {
7
+ console.log(`Usage:
8
+ plane workspace ls
9
+ plane workspace current
10
+ plane workspace use <slug> [--force]
11
+ `);
12
+ }
13
+
14
+ function workspaceRows(config) {
15
+ const known = config.knownWorkspaces || [];
16
+ return known.map((slug) => ({
17
+ slug,
18
+ current: config.workspace === slug,
19
+ }));
20
+ }
21
+
22
+ export async function runWorkspaceCommand(args, context) {
23
+ const [subcommand = "ls", ...rest] = args;
24
+
25
+ if (subcommand === "--help" || subcommand === "help") {
26
+ printHelp();
27
+ return;
28
+ }
29
+
30
+ if (rest.includes("--help") || rest.includes("help")) {
31
+ printHelp();
32
+ return;
33
+ }
34
+
35
+ const config = await loadConfig();
36
+
37
+ if (subcommand === "ls") {
38
+ const rows = workspaceRows(config);
39
+ printData(rows, {
40
+ ...context.output,
41
+ render(data) {
42
+ printTable(data, [
43
+ { label: "Current", get: (row) => (row.current ? "*" : "") },
44
+ { label: "Workspace", get: (row) => row.slug },
45
+ ]);
46
+ },
47
+ });
48
+ return;
49
+ }
50
+
51
+ if (subcommand === "current") {
52
+ printData(
53
+ {
54
+ workspace: config.workspace || "",
55
+ knownWorkspaces: config.knownWorkspaces || [],
56
+ },
57
+ context.output
58
+ );
59
+ return;
60
+ }
61
+
62
+ if (subcommand === "use") {
63
+ const parsed = parseCommandArgs(
64
+ rest,
65
+ {
66
+ force: { type: "boolean" },
67
+ }
68
+ );
69
+
70
+ const [slug] = parsed.positionals;
71
+ if (!slug) {
72
+ throw new CliError("Workspace slug is required.");
73
+ }
74
+
75
+ const known = config.knownWorkspaces || [];
76
+ if (!parsed.values.force && known.length > 0 && !known.includes(slug)) {
77
+ throw new CliError(`Unknown workspace: ${slug}`, {
78
+ details: {
79
+ knownWorkspaces: known,
80
+ hint: "Run `plane auth login` again or use --force if you know the slug is valid.",
81
+ },
82
+ });
83
+ }
84
+
85
+ const nextKnown = known.includes(slug) ? known : [...known, slug];
86
+ const result = await saveConfig({
87
+ workspace: slug,
88
+ knownWorkspaces: nextKnown,
89
+ });
90
+
91
+ printData(
92
+ {
93
+ workspace: result.config.workspace,
94
+ knownWorkspaces: result.config.knownWorkspaces || [],
95
+ },
96
+ context.output
97
+ );
98
+ return;
99
+ }
100
+
101
+ throw new CliError(`Unknown workspace subcommand: ${subcommand}`);
102
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const CONFIG_KEYS = ["baseUrl", "apiKey", "workspace", "knownWorkspaces"];
6
+
7
+ function normalizeBaseUrl(value) {
8
+ if (!value) return value;
9
+ return value.replace(/\/+$/, "").replace(/\/api\/v1$/, "").replace(/\/api$/, "");
10
+ }
11
+
12
+ function normalizeValue(key, value) {
13
+ if (key === "knownWorkspaces") {
14
+ if (!Array.isArray(value)) return undefined;
15
+ return value
16
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
17
+ .filter(Boolean);
18
+ }
19
+
20
+ if (typeof value !== "string") return value;
21
+ const trimmed = value.trim();
22
+ if (trimmed === "") return undefined;
23
+ if (key === "baseUrl") return normalizeBaseUrl(trimmed);
24
+ return trimmed;
25
+ }
26
+
27
+ function sanitizeConfig(input) {
28
+ const next = {};
29
+
30
+ for (const key of CONFIG_KEYS) {
31
+ const normalized = normalizeValue(key, input[key]);
32
+ if (normalized !== undefined) {
33
+ next[key] = normalized;
34
+ }
35
+ }
36
+
37
+ return next;
38
+ }
39
+
40
+ export function resolveConfigPath() {
41
+ if (process.env.PLANE_CLI_CONFIG_PATH) {
42
+ return process.env.PLANE_CLI_CONFIG_PATH;
43
+ }
44
+
45
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
46
+ return join(configHome, "plane-cli", "config.json");
47
+ }
48
+
49
+ export async function loadConfig() {
50
+ const configPath = resolveConfigPath();
51
+
52
+ try {
53
+ const raw = await readFile(configPath, "utf8");
54
+ return sanitizeConfig(JSON.parse(raw));
55
+ } catch (error) {
56
+ if (error && error.code === "ENOENT") {
57
+ return {};
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ export async function saveConfig(partial) {
64
+ const configPath = resolveConfigPath();
65
+ const current = await loadConfig();
66
+ const merged = { ...current };
67
+
68
+ for (const [key, value] of Object.entries(partial)) {
69
+ if (value === null) {
70
+ delete merged[key];
71
+ continue;
72
+ }
73
+ merged[key] = value;
74
+ }
75
+
76
+ const next = sanitizeConfig(merged);
77
+
78
+ await mkdir(dirname(configPath), { recursive: true });
79
+ await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
80
+
81
+ return { path: configPath, config: next };
82
+ }
83
+
84
+ export async function resolveRuntimeConfig(overrides = {}) {
85
+ const fileConfig = await loadConfig();
86
+ const envConfig = sanitizeConfig({
87
+ baseUrl: process.env.PLANE_BASE_URL,
88
+ apiKey: process.env.PLANE_API_KEY,
89
+ workspace: process.env.PLANE_WORKSPACE,
90
+ });
91
+
92
+ return sanitizeConfig({
93
+ ...fileConfig,
94
+ ...envConfig,
95
+ ...overrides,
96
+ });
97
+ }
98
+
99
+ export function maskApiKey(value) {
100
+ if (!value) return "";
101
+ if (value.length <= 8) return "*".repeat(value.length);
102
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
103
+ }
@@ -0,0 +1,8 @@
1
+ export class CliError extends Error {
2
+ constructor(message, options = {}) {
3
+ super(message);
4
+ this.name = "CliError";
5
+ this.exitCode = options.exitCode ?? 1;
6
+ this.details = options.details;
7
+ }
8
+ }
@@ -0,0 +1,109 @@
1
+ import { CliError } from "./errors.js";
2
+
3
+ function normalizeBaseUrl(baseUrl) {
4
+ return baseUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "").replace(/\/api$/, "");
5
+ }
6
+
7
+ export function buildApiUrl(baseUrl, path, query = {}) {
8
+ const root = normalizeBaseUrl(baseUrl);
9
+ const url = new URL(`/api/v1${path}`, `${root}/`);
10
+
11
+ for (const [key, value] of Object.entries(query)) {
12
+ if (value === undefined || value === null || value === "") continue;
13
+
14
+ if (Array.isArray(value)) {
15
+ for (const item of value) {
16
+ url.searchParams.append(key, String(item));
17
+ }
18
+ continue;
19
+ }
20
+
21
+ url.searchParams.set(key, String(value));
22
+ }
23
+
24
+ return url;
25
+ }
26
+
27
+ export class PlaneClient {
28
+ constructor(config) {
29
+ this.baseUrl = config.baseUrl;
30
+ this.apiKey = config.apiKey;
31
+ this.workspace = config.workspace;
32
+ this.knownWorkspaces = config.knownWorkspaces || [];
33
+ }
34
+
35
+ workspacePath(resource = "") {
36
+ if (!this.workspace) {
37
+ const hasKnownWorkspaces = this.knownWorkspaces.length > 0;
38
+ throw new CliError(
39
+ hasKnownWorkspaces
40
+ ? "Workspace is not selected. Use `plane workspace current` to inspect state and `plane workspace use <slug>` to choose one."
41
+ : "Workspace is required. Use `plane auth login` or `plane config set --workspace <slug>` first.",
42
+ hasKnownWorkspaces
43
+ ? {
44
+ details: {
45
+ knownWorkspaces: this.knownWorkspaces,
46
+ hint: "Run `plane workspace ls` and then `plane workspace use <slug>`.",
47
+ },
48
+ }
49
+ : undefined
50
+ );
51
+ }
52
+
53
+ const suffix = resource.startsWith("/") ? resource : `/${resource}`;
54
+ return `/workspaces/${this.workspace}${resource ? suffix : ""}`;
55
+ }
56
+
57
+ async request(method, path, options = {}) {
58
+ if (!this.baseUrl) {
59
+ throw new CliError("Base URL is required. Use `plane config set --base-url <url>` first.");
60
+ }
61
+
62
+ if (!this.apiKey) {
63
+ throw new CliError("API key is required. Use `plane config set --api-key <key>` first.");
64
+ }
65
+
66
+ const url = buildApiUrl(this.baseUrl, path, options.query);
67
+ const headers = {
68
+ Accept: "application/json",
69
+ "X-Api-Key": this.apiKey,
70
+ };
71
+
72
+ const requestInit = {
73
+ method,
74
+ headers,
75
+ };
76
+
77
+ if (options.body !== undefined) {
78
+ headers["Content-Type"] = "application/json";
79
+ requestInit.body = JSON.stringify(options.body);
80
+ }
81
+
82
+ const response = await fetch(url, requestInit);
83
+ const contentType = response.headers.get("content-type") || "";
84
+ const payload = contentType.includes("application/json")
85
+ ? await response.json()
86
+ : await response.text();
87
+
88
+ if (!response.ok) {
89
+ throw new CliError(`Plane API request failed: ${response.status} ${response.statusText}`, {
90
+ exitCode: 1,
91
+ details: payload,
92
+ });
93
+ }
94
+
95
+ return payload;
96
+ }
97
+
98
+ get(path, query) {
99
+ return this.request("GET", path, { query });
100
+ }
101
+
102
+ post(path, body) {
103
+ return this.request("POST", path, { body });
104
+ }
105
+
106
+ patch(path, body) {
107
+ return this.request("PATCH", path, { body });
108
+ }
109
+ }
@@ -0,0 +1,33 @@
1
+ import { parseArgs } from "node:util";
2
+ import { CliError } from "./errors.js";
3
+
4
+ export function parseCommandArgs(args, options, allowPositionals = true) {
5
+ try {
6
+ return parseArgs({
7
+ args,
8
+ options,
9
+ allowPositionals,
10
+ strict: true,
11
+ });
12
+ } catch (error) {
13
+ throw new CliError(error.message);
14
+ }
15
+ }
16
+
17
+ export function ensureValue(value, message) {
18
+ if (!value) {
19
+ throw new CliError(message);
20
+ }
21
+ }
22
+
23
+ export function splitCsv(value) {
24
+ if (!value) return undefined;
25
+ return value
26
+ .split(",")
27
+ .map((item) => item.trim())
28
+ .filter(Boolean);
29
+ }
30
+
31
+ export function pickDefined(object) {
32
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
33
+ }
@@ -0,0 +1,51 @@
1
+ function stringifyValue(value) {
2
+ if (value === undefined || value === null) return "";
3
+ if (typeof value === "object") return JSON.stringify(value);
4
+ return String(value);
5
+ }
6
+
7
+ export function printJson(data) {
8
+ console.log(JSON.stringify(data, null, 2));
9
+ }
10
+
11
+ export function printTable(rows, columns) {
12
+ if (!rows.length) {
13
+ console.log("(empty)");
14
+ return;
15
+ }
16
+
17
+ const widths = columns.map((column) => {
18
+ const headerWidth = column.label.length;
19
+ const valueWidth = Math.max(...rows.map((row) => stringifyValue(column.get(row)).length));
20
+ return Math.max(headerWidth, valueWidth);
21
+ });
22
+
23
+ const header = columns
24
+ .map((column, index) => column.label.padEnd(widths[index], " "))
25
+ .join(" ");
26
+ const separator = widths.map((width) => "-".repeat(width)).join(" ");
27
+
28
+ console.log(header);
29
+ console.log(separator);
30
+
31
+ for (const row of rows) {
32
+ const line = columns
33
+ .map((column, index) => stringifyValue(column.get(row)).padEnd(widths[index], " "))
34
+ .join(" ");
35
+ console.log(line);
36
+ }
37
+ }
38
+
39
+ export function printData(data, options = {}) {
40
+ if (options.json) {
41
+ printJson(data);
42
+ return;
43
+ }
44
+
45
+ if (typeof options.render === "function") {
46
+ options.render(data);
47
+ return;
48
+ }
49
+
50
+ printJson(data);
51
+ }