@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.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # plane-cli
2
+
3
+ 基于 Plane 外部 API(`/api/v1`)的轻量命令行工具,使用 Node.js 原生能力实现,不依赖第三方包。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install -g @ysicing/plane-cli
9
+ ```
10
+
11
+ 或直接用 `npx`:
12
+
13
+ ```bash
14
+ npx @ysicing/plane-cli --help
15
+ ```
16
+
17
+ ## 约束
18
+
19
+ - 鉴权方式:`X-Api-Key`
20
+ - 默认面向 `project` 与 `work-item`
21
+ - 暂不提供危险删除命令
22
+
23
+ ## 要求
24
+
25
+ - Node.js 22+
26
+
27
+ ## 初始化
28
+
29
+ ```bash
30
+ node ./src/cli.js config set \
31
+ --base-url https://plane.example.com \
32
+ --api-key your-api-key \
33
+ --workspace your-workspace-slug
34
+ ```
35
+
36
+ 也支持环境变量覆盖:
37
+
38
+ ```bash
39
+ export PLANE_BASE_URL=https://plane.example.com
40
+ export PLANE_API_KEY=your-api-key
41
+ export PLANE_WORKSPACE=your-workspace-slug
42
+ ```
43
+
44
+ ## 登录并自动生成 API Token
45
+
46
+ 普通账号密码登录:
47
+
48
+ ```bash
49
+ node ./src/cli.js auth login \
50
+ --base-url https://plane.example.com \
51
+ --username you@example.com \
52
+ --password 'your-password'
53
+ ```
54
+
55
+ LDAP 登录:
56
+
57
+ ```bash
58
+ node ./src/cli.js auth login \
59
+ --base-url https://plane.example.com \
60
+ --ldap \
61
+ --username your-ldap-account \
62
+ --password 'your-password'
63
+ ```
64
+
65
+ 如果不想把密码放进命令行:
66
+
67
+ ```bash
68
+ printf '%s' 'your-password' | node ./src/cli.js auth login \
69
+ --base-url https://plane.example.com \
70
+ --ldap \
71
+ --username your-ldap-account \
72
+ --password-stdin
73
+ ```
74
+
75
+ 如果账号下有多个 workspace,且没有显式传 `--workspace`,CLI 会保存登录态生成的 API token,但不会自动选中 workspace。此时请先查看并选择:
76
+
77
+ ```bash
78
+ node ./src/cli.js workspace ls
79
+ node ./src/cli.js workspace current
80
+ node ./src/cli.js workspace use <slug>
81
+ ```
82
+
83
+ ## 示例
84
+
85
+ ```bash
86
+ node ./src/cli.js me
87
+ node ./src/cli.js project ls
88
+ node ./src/cli.js project get <project-id>
89
+ node ./src/cli.js project create --name Demo --identifier DEMO
90
+ node ./src/cli.js issue ls --project <project-id>
91
+ node ./src/cli.js issue get --project <project-id> <issue-id>
92
+ node ./src/cli.js issue create --project <project-id> --name "First work item"
93
+ node ./src/cli.js issue update --project <project-id> <issue-id> --priority high
94
+ ```
95
+
96
+ `work-item` 是 `issue` 的别名:
97
+
98
+ ```bash
99
+ node ./src/cli.js work-item ls --project <project-id>
100
+ ```
101
+
102
+ ## 发布
103
+
104
+ 当前包名使用 `@ysicing/plane-cli`,因为 `plane-cli` 已被 npm 占用。
105
+
106
+ 发布前检查:
107
+
108
+ ```bash
109
+ npm test
110
+ npm run pack:check
111
+ ```
112
+
113
+ 发布:
114
+
115
+ ```bash
116
+ npm publish
117
+ ```
118
+
119
+ 如果本机还没登录 npm:
120
+
121
+ ```bash
122
+ npm login
123
+ ```
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@ysicing/plane-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Node.js CLI for managing Plane via the external API",
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+ssh://git@github.com/ysicing/plane-cli.git"
10
+ },
11
+ "homepage": "https://github.com/ysicing/plane-cli#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ysicing/plane-cli/issues"
14
+ },
15
+ "keywords": [
16
+ "plane",
17
+ "cli",
18
+ "project-management",
19
+ "ldap",
20
+ "api"
21
+ ],
22
+ "files": [
23
+ "src",
24
+ "README.md"
25
+ ],
26
+ "bin": {
27
+ "plane": "./src/cli.js"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org/"
32
+ },
33
+ "scripts": {
34
+ "start": "node ./src/cli.js",
35
+ "test": "node --test",
36
+ "pack:check": "npm pack --dry-run",
37
+ "prepublishOnly": "npm test && npm run pack:check"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.0.0"
41
+ }
42
+ }
@@ -0,0 +1,21 @@
1
+ export class IssueClient {
2
+ constructor(client) {
3
+ this.client = client;
4
+ }
5
+
6
+ list(projectId, query = {}) {
7
+ return this.client.get(this.client.workspacePath(`/projects/${projectId}/work-items/`), query);
8
+ }
9
+
10
+ get(projectId, issueId) {
11
+ return this.client.get(this.client.workspacePath(`/projects/${projectId}/work-items/${issueId}/`));
12
+ }
13
+
14
+ create(projectId, body) {
15
+ return this.client.post(this.client.workspacePath(`/projects/${projectId}/work-items/`), body);
16
+ }
17
+
18
+ update(projectId, issueId, body) {
19
+ return this.client.patch(this.client.workspacePath(`/projects/${projectId}/work-items/${issueId}/`), body);
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ export class ProjectClient {
2
+ constructor(client) {
3
+ this.client = client;
4
+ }
5
+
6
+ list(query = {}) {
7
+ return this.client.get(this.client.workspacePath("/projects/"), query);
8
+ }
9
+
10
+ get(projectId) {
11
+ return this.client.get(this.client.workspacePath(`/projects/${projectId}/`));
12
+ }
13
+
14
+ create(body) {
15
+ return this.client.post(this.client.workspacePath("/projects/"), body);
16
+ }
17
+
18
+ update(projectId, body) {
19
+ return this.client.patch(this.client.workspacePath(`/projects/${projectId}/`), body);
20
+ }
21
+ }
@@ -0,0 +1,9 @@
1
+ export class UserClient {
2
+ constructor(client) {
3
+ this.client = client;
4
+ }
5
+
6
+ me() {
7
+ return this.client.get("/users/me/");
8
+ }
9
+ }
package/src/cli.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runAuthCommand } from "./commands/auth.js";
4
+ import { runConfigCommand } from "./commands/config.js";
5
+ import { runIssueCommand } from "./commands/issue.js";
6
+ import { runMeCommand } from "./commands/me.js";
7
+ import { runProjectCommand } from "./commands/project.js";
8
+ import { runWorkspaceCommand } from "./commands/workspace.js";
9
+ import { CliError } from "./core/errors.js";
10
+
11
+ function extractGlobalOptions(argv) {
12
+ const args = [];
13
+ const options = {
14
+ json: false,
15
+ };
16
+
17
+ for (const arg of argv) {
18
+ if (arg === "--json") {
19
+ options.json = true;
20
+ continue;
21
+ }
22
+ args.push(arg);
23
+ }
24
+
25
+ return { args, options };
26
+ }
27
+
28
+ function printHelp() {
29
+ console.log(`plane-cli
30
+
31
+ Usage:
32
+ plane <command> [options]
33
+
34
+ Commands:
35
+ auth Sign in and bootstrap API credentials
36
+ config Manage local CLI config
37
+ me Show current authenticated user
38
+ project Manage projects
39
+ issue Manage work items through issue commands
40
+ work-item Alias of issue
41
+ workspace Manage selected workspace
42
+
43
+ Global options:
44
+ --json Print raw JSON
45
+ --help Show help
46
+ `);
47
+ }
48
+
49
+ async function main() {
50
+ const { args, options } = extractGlobalOptions(process.argv.slice(2));
51
+ const [command, ...rest] = args;
52
+
53
+ if (!command || command === "help" || command === "--help" || command === "-h") {
54
+ printHelp();
55
+ return;
56
+ }
57
+
58
+ const context = { output: options };
59
+
60
+ switch (command) {
61
+ case "auth":
62
+ await runAuthCommand(rest, context);
63
+ return;
64
+ case "config":
65
+ await runConfigCommand(rest, context);
66
+ return;
67
+ case "me":
68
+ await runMeCommand(rest, context);
69
+ return;
70
+ case "project":
71
+ await runProjectCommand(rest, context);
72
+ return;
73
+ case "workspace":
74
+ await runWorkspaceCommand(rest, context);
75
+ return;
76
+ case "issue":
77
+ case "work-item":
78
+ await runIssueCommand(rest, context);
79
+ return;
80
+ default:
81
+ throw new CliError(`Unknown command: ${command}`);
82
+ }
83
+ }
84
+
85
+ main().catch((error) => {
86
+ if (error instanceof CliError) {
87
+ console.error(`Error: ${error.message}`);
88
+ if (error.details) {
89
+ console.error(JSON.stringify(error.details, null, 2));
90
+ }
91
+ process.exit(error.exitCode);
92
+ }
93
+
94
+ console.error(error);
95
+ process.exit(1);
96
+ });
@@ -0,0 +1,136 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { loadConfig, maskApiKey, saveConfig } from "../core/config.js";
4
+ import { CliError } from "../core/errors.js";
5
+ import { ensureValue, parseCommandArgs } from "../core/options.js";
6
+ import { printData } from "../core/output.js";
7
+ import { PlaneSessionClient } from "../core/session.js";
8
+
9
+ function printHelp() {
10
+ console.log(`Usage:
11
+ plane auth login --base-url <plane-url> [--ldap] (--username <value> | --email <value>) [--password <value> | --password-stdin] [--workspace <slug>] [--token-label <label>]
12
+ `);
13
+ }
14
+
15
+ function defaultTokenLabel() {
16
+ return `plane-cli-${new Date().toISOString()}`;
17
+ }
18
+
19
+ async function readPasswordFromStdin() {
20
+ const content = await readFile(0, "utf8");
21
+ return content.trim();
22
+ }
23
+
24
+ function resolveWorkspaceToSave({ explicitWorkspace, existingWorkspace, userWorkspaces }) {
25
+ const known = Array.isArray(userWorkspaces) ? userWorkspaces.map((item) => item.slug) : [];
26
+
27
+ if (explicitWorkspace) {
28
+ if (!known.includes(explicitWorkspace)) {
29
+ throw new CliError(`Workspace not found for this account: ${explicitWorkspace}`, {
30
+ details: {
31
+ knownWorkspaces: known,
32
+ },
33
+ });
34
+ }
35
+ return explicitWorkspace;
36
+ }
37
+
38
+ if (known.length === 1) {
39
+ return known[0];
40
+ }
41
+
42
+ if (known.length > 1) {
43
+ return null;
44
+ }
45
+
46
+ return existingWorkspace || null;
47
+ }
48
+
49
+ export async function runAuthCommand(args, context) {
50
+ const [subcommand, ...rest] = args;
51
+
52
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
53
+ printHelp();
54
+ return;
55
+ }
56
+
57
+ if (subcommand !== "login") {
58
+ throw new CliError(`Unknown auth subcommand: ${subcommand}`);
59
+ }
60
+
61
+ if (rest.includes("--help") || rest.includes("help")) {
62
+ printHelp();
63
+ return;
64
+ }
65
+
66
+ const parsed = parseCommandArgs(
67
+ rest,
68
+ {
69
+ "base-url": { type: "string" },
70
+ ldap: { type: "boolean" },
71
+ username: { type: "string" },
72
+ email: { type: "string" },
73
+ password: { type: "string" },
74
+ "password-stdin": { type: "boolean" },
75
+ workspace: { type: "string" },
76
+ "token-label": { type: "string" },
77
+ },
78
+ false
79
+ );
80
+
81
+ const existingConfig = await loadConfig();
82
+ const baseUrl = parsed.values["base-url"] || existingConfig.baseUrl;
83
+ ensureValue(baseUrl, "Base URL is required. Pass --base-url or set it with `plane config set --base-url ...`.");
84
+
85
+ const username = parsed.values.username || parsed.values.email;
86
+ ensureValue(username, "Username is required. Pass --username or --email.");
87
+
88
+ let password = parsed.values.password;
89
+ if (!password && parsed.values["password-stdin"]) {
90
+ password = await readPasswordFromStdin();
91
+ }
92
+ ensureValue(password, "Password is required. Pass --password or --password-stdin.");
93
+
94
+ const sessionClient = new PlaneSessionClient(baseUrl);
95
+ await sessionClient.login({
96
+ username,
97
+ password,
98
+ ldap: Boolean(parsed.values.ldap),
99
+ });
100
+
101
+ const tokenLabel = parsed.values["token-label"] || defaultTokenLabel();
102
+ const token = await sessionClient.createApiToken({
103
+ label: tokenLabel,
104
+ description: "Created by plane-cli auth login",
105
+ });
106
+ const workspaces = await sessionClient.listUserWorkspaces();
107
+ const knownWorkspaces = Array.isArray(workspaces) ? workspaces.map((item) => item.slug) : [];
108
+
109
+ const workspace = resolveWorkspaceToSave({
110
+ explicitWorkspace: parsed.values.workspace,
111
+ existingWorkspace: existingConfig.workspace,
112
+ userWorkspaces: workspaces,
113
+ });
114
+
115
+ const saved = await saveConfig({
116
+ baseUrl,
117
+ apiKey: token.token,
118
+ workspace,
119
+ knownWorkspaces,
120
+ });
121
+
122
+ printData(
123
+ {
124
+ path: saved.path,
125
+ baseUrl: saved.config.baseUrl,
126
+ workspace: saved.config.workspace || "",
127
+ apiKey: maskApiKey(saved.config.apiKey),
128
+ loginMode: parsed.values.ldap ? "ldap" : "password",
129
+ tokenId: token.id,
130
+ tokenLabel: token.label,
131
+ detectedWorkspaces: knownWorkspaces,
132
+ workspaceSelectionRequired: !saved.config.workspace && knownWorkspaces.length > 1,
133
+ },
134
+ context.output
135
+ );
136
+ }
@@ -0,0 +1,84 @@
1
+ import { loadConfig, maskApiKey, resolveConfigPath, saveConfig } from "../core/config.js";
2
+ import { CliError } from "../core/errors.js";
3
+ import { parseCommandArgs } from "../core/options.js";
4
+ import { printData } from "../core/output.js";
5
+
6
+ function configSnapshot(config, path) {
7
+ return {
8
+ path,
9
+ baseUrl: config.baseUrl || "",
10
+ apiKey: maskApiKey(config.apiKey),
11
+ workspace: config.workspace || "",
12
+ knownWorkspaces: config.knownWorkspaces || [],
13
+ };
14
+ }
15
+
16
+ function printHelp() {
17
+ console.log(`Usage:
18
+ plane config list
19
+ plane config get [baseUrl|apiKey|workspace|knownWorkspaces]
20
+ plane config set [--base-url <url>] [--api-key <key>] [--workspace <slug>]
21
+ `);
22
+ }
23
+
24
+ export async function runConfigCommand(args, context) {
25
+ const [subcommand = "list", ...rest] = args;
26
+ const path = resolveConfigPath();
27
+
28
+ if (subcommand === "--help" || subcommand === "help") {
29
+ printHelp();
30
+ return;
31
+ }
32
+
33
+ if (subcommand === "list") {
34
+ const config = await loadConfig();
35
+ printData(configSnapshot(config, path), context.output);
36
+ return;
37
+ }
38
+
39
+ if (subcommand === "get") {
40
+ const [key] = rest;
41
+ const config = await loadConfig();
42
+ const snapshot = configSnapshot(config, path);
43
+
44
+ if (!key) {
45
+ printData(snapshot, context.output);
46
+ return;
47
+ }
48
+
49
+ if (!(key in snapshot)) {
50
+ throw new CliError(`Unknown config key: ${key}`);
51
+ }
52
+
53
+ printData({ [key]: snapshot[key] }, context.output);
54
+ return;
55
+ }
56
+
57
+ if (subcommand === "set") {
58
+ const parsed = parseCommandArgs(
59
+ rest,
60
+ {
61
+ "base-url": { type: "string" },
62
+ "api-key": { type: "string" },
63
+ workspace: { type: "string" },
64
+ },
65
+ false
66
+ );
67
+
68
+ const update = {
69
+ baseUrl: parsed.values["base-url"],
70
+ apiKey: parsed.values["api-key"],
71
+ workspace: parsed.values.workspace,
72
+ };
73
+
74
+ if (!update.baseUrl && !update.apiKey && !update.workspace) {
75
+ throw new CliError("Nothing to update. Pass at least one of --base-url, --api-key, or --workspace.");
76
+ }
77
+
78
+ const result = await saveConfig(update);
79
+ printData(configSnapshot(result.config, result.path), context.output);
80
+ return;
81
+ }
82
+
83
+ throw new CliError(`Unknown config subcommand: ${subcommand}`);
84
+ }
@@ -0,0 +1,171 @@
1
+ import { IssueClient } from "../api/issue-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, splitCsv } from "../core/options.js";
6
+ import { printData, printTable } from "../core/output.js";
7
+
8
+ function renderIssueList(data) {
9
+ const rows = Array.isArray(data) ? data : data.results || [];
10
+ printTable(rows, [
11
+ { label: "ID", get: (row) => row.id },
12
+ { label: "Seq", get: (row) => row.sequence_id || "" },
13
+ { label: "Name", get: (row) => row.name },
14
+ { label: "Priority", get: (row) => row.priority || "" },
15
+ { label: "State", get: (row) => row.state || row.state_id || "" },
16
+ ]);
17
+ }
18
+
19
+ export function buildIssuePayload(values) {
20
+ return pickDefined({
21
+ name: values.name,
22
+ description_html: values["description-html"],
23
+ state: values.state,
24
+ priority: values.priority,
25
+ start_date: values["start-date"],
26
+ target_date: values["target-date"],
27
+ parent: values.parent,
28
+ type_id: values["type-id"],
29
+ assignees: splitCsv(values.assignees),
30
+ labels: splitCsv(values.labels),
31
+ });
32
+ }
33
+
34
+ function printHelp() {
35
+ console.log(`Usage:
36
+ plane issue ls --project <project-id> [--limit <n>] [--cursor <cursor>] [--order-by <field>] [--state <state-id>] [--priority <value>] [--assignees <id1,id2>] [--expand <field1,field2>]
37
+ plane issue get --project <project-id> <issue-id>
38
+ plane issue create --project <project-id> --name <name> [--description-html <html>] [--state <state-id>] [--priority <value>] [--assignees <id1,id2>] [--labels <id1,id2>]
39
+ plane issue update --project <project-id> <issue-id> [--name <name>] [--description-html <html>] [--state <state-id>] [--priority <value>] [--assignees <id1,id2>] [--labels <id1,id2>]
40
+ `);
41
+ }
42
+
43
+ export async function runIssueCommand(args, context) {
44
+ const [subcommand, ...rest] = args;
45
+
46
+ if (!subcommand || subcommand === "--help" || subcommand === "help") {
47
+ printHelp();
48
+ return;
49
+ }
50
+
51
+ const config = await resolveRuntimeConfig();
52
+ const issueClient = new IssueClient(new PlaneClient(config));
53
+
54
+ if (subcommand === "ls") {
55
+ const parsed = parseCommandArgs(
56
+ rest,
57
+ {
58
+ project: { type: "string" },
59
+ limit: { type: "string" },
60
+ cursor: { type: "string" },
61
+ "order-by": { type: "string" },
62
+ state: { type: "string" },
63
+ priority: { type: "string" },
64
+ assignees: { type: "string" },
65
+ labels: { type: "string" },
66
+ expand: { type: "string" },
67
+ fields: { type: "string" },
68
+ },
69
+ false
70
+ );
71
+
72
+ ensureValue(parsed.values.project, "Project ID is required.");
73
+
74
+ const result = await issueClient.list(
75
+ parsed.values.project,
76
+ pickDefined({
77
+ per_page: parsed.values.limit,
78
+ cursor: parsed.values.cursor,
79
+ order_by: parsed.values["order-by"],
80
+ state: parsed.values.state,
81
+ priority: parsed.values.priority,
82
+ assignees: parsed.values.assignees,
83
+ labels: parsed.values.labels,
84
+ expand: parsed.values.expand,
85
+ fields: parsed.values.fields,
86
+ })
87
+ );
88
+
89
+ printData(result, {
90
+ ...context.output,
91
+ render: renderIssueList,
92
+ });
93
+ return;
94
+ }
95
+
96
+ if (subcommand === "get") {
97
+ const parsed = parseCommandArgs(
98
+ rest,
99
+ {
100
+ project: { type: "string" },
101
+ }
102
+ );
103
+
104
+ ensureValue(parsed.values.project, "Project ID is required.");
105
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
106
+
107
+ const result = await issueClient.get(parsed.values.project, parsed.positionals[0]);
108
+ printData(result, context.output);
109
+ return;
110
+ }
111
+
112
+ if (subcommand === "create") {
113
+ const parsed = parseCommandArgs(
114
+ rest,
115
+ {
116
+ project: { type: "string" },
117
+ name: { type: "string" },
118
+ "description-html": { type: "string" },
119
+ state: { type: "string" },
120
+ priority: { type: "string" },
121
+ assignees: { type: "string" },
122
+ labels: { type: "string" },
123
+ "start-date": { type: "string" },
124
+ "target-date": { type: "string" },
125
+ parent: { type: "string" },
126
+ "type-id": { type: "string" },
127
+ },
128
+ false
129
+ );
130
+
131
+ ensureValue(parsed.values.project, "Project ID is required.");
132
+ ensureValue(parsed.values.name, "Issue name is required.");
133
+
134
+ const result = await issueClient.create(parsed.values.project, buildIssuePayload(parsed.values));
135
+ printData(result, context.output);
136
+ return;
137
+ }
138
+
139
+ if (subcommand === "update") {
140
+ const parsed = parseCommandArgs(
141
+ rest,
142
+ {
143
+ project: { type: "string" },
144
+ name: { type: "string" },
145
+ "description-html": { type: "string" },
146
+ state: { type: "string" },
147
+ priority: { type: "string" },
148
+ assignees: { type: "string" },
149
+ labels: { type: "string" },
150
+ "start-date": { type: "string" },
151
+ "target-date": { type: "string" },
152
+ parent: { type: "string" },
153
+ "type-id": { type: "string" },
154
+ }
155
+ );
156
+
157
+ ensureValue(parsed.values.project, "Project ID is required.");
158
+ ensureValue(parsed.positionals[0], "Issue ID is required.");
159
+
160
+ const payload = buildIssuePayload(parsed.values);
161
+ if (Object.keys(payload).length === 0) {
162
+ throw new CliError("At least one update field is required.");
163
+ }
164
+
165
+ const result = await issueClient.update(parsed.values.project, parsed.positionals[0], payload);
166
+ printData(result, context.output);
167
+ return;
168
+ }
169
+
170
+ throw new CliError(`Unknown issue subcommand: ${subcommand}`);
171
+ }