@fanzie/task-cli 0.1.1
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 +55 -0
- package/dist/cli.js +598 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @fanzie/task-cli
|
|
2
|
+
|
|
3
|
+
CLI tool for managing Feature/UserStory/AC task hierarchies in Feishu Bitable.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @fanzie/task-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
fz-task-cli auth init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
### Feature
|
|
20
|
+
|
|
21
|
+
- `fz-task-cli feature add <title>` — Create a Feature
|
|
22
|
+
- `fz-task-cli feature add --json '{"title":"x","priority":"P0"}'` — Create with fields
|
|
23
|
+
- `fz-task-cli feature list` — List all Features
|
|
24
|
+
- `fz-task-cli feature get <id>` — Get Feature detail + child Stories
|
|
25
|
+
- `fz-task-cli feature update <id> --status TEST_SPEC` — Update fields
|
|
26
|
+
- `fz-task-cli feature delete <id>` — Delete (no children required)
|
|
27
|
+
|
|
28
|
+
### Story
|
|
29
|
+
|
|
30
|
+
- `fz-task-cli story add <title> --feature <id>` — Create under Feature
|
|
31
|
+
- `fz-task-cli story list [--feature <id>]` — List Stories
|
|
32
|
+
- `fz-task-cli story get <id>` — Get Story + child ACs
|
|
33
|
+
- `fz-task-cli story update <id> --status DOING` — Update
|
|
34
|
+
- `fz-task-cli story delete <id>` — Delete (no child ACs required)
|
|
35
|
+
|
|
36
|
+
### AC (Acceptance Criteria)
|
|
37
|
+
|
|
38
|
+
- `fz-task-cli ac add <title> --story <id>` — Create under Story
|
|
39
|
+
- `fz-task-cli ac list [--story <id>]` — List ACs
|
|
40
|
+
- `fz-task-cli ac get <id>` — Get AC detail
|
|
41
|
+
- `fz-task-cli ac update <id> --status TESTING` — Update
|
|
42
|
+
- `fz-task-cli ac delete <id>` — Delete
|
|
43
|
+
|
|
44
|
+
### Options
|
|
45
|
+
|
|
46
|
+
- `--format json` — JSON output
|
|
47
|
+
- `--profile <name>` — Config profile
|
|
48
|
+
|
|
49
|
+
## Status Flow
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
TODO → TEST_SPEC → DOING → TESTING → VERIFIED → DONE
|
|
53
|
+
↕ ↕ ↕ ↕
|
|
54
|
+
BLOCKED (from any, except DONE)
|
|
55
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command as e } from "commander";
|
|
3
|
+
import * as t from "node:readline";
|
|
4
|
+
import * as n from "node:fs";
|
|
5
|
+
import * as r from "node:path";
|
|
6
|
+
import * as i from "node:os";
|
|
7
|
+
import * as a from "js-yaml";
|
|
8
|
+
import * as o from "@larksuiteoapi/node-sdk";
|
|
9
|
+
//#region src/config.ts
|
|
10
|
+
var s = r.join(i.homedir(), ".config", "fz-task"), c = r.join(s, "config.yaml"), l = r.join(s, ".env");
|
|
11
|
+
function u(e) {
|
|
12
|
+
let t = new URL(e), n = t.pathname.split("/").filter(Boolean), r = n.indexOf("wiki"), i = n.indexOf("base"), a = r === -1 ? i : r;
|
|
13
|
+
if (a === -1 || a + 1 >= n.length) throw Error(`Invalid bitable URL: cannot extract appToken from ${e}`);
|
|
14
|
+
let o = n[a + 1], s = t.searchParams.get("table");
|
|
15
|
+
if (!s) throw Error(`Invalid bitable URL: missing table parameter in ${e}`);
|
|
16
|
+
return {
|
|
17
|
+
appToken: o,
|
|
18
|
+
tableId: s
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function d(e, t) {
|
|
22
|
+
return e.replace(/\$\{(\w+)\}/g, (e, n) => t[n] ?? e);
|
|
23
|
+
}
|
|
24
|
+
function f(e) {
|
|
25
|
+
let t = e ?? l, r = {};
|
|
26
|
+
if (!n.existsSync(t)) return r;
|
|
27
|
+
let i = n.readFileSync(t, "utf-8");
|
|
28
|
+
for (let e of i.split("\n")) {
|
|
29
|
+
let t = e.trim();
|
|
30
|
+
if (!t || t.startsWith("#")) continue;
|
|
31
|
+
let n = t.indexOf("=");
|
|
32
|
+
if (n === -1) continue;
|
|
33
|
+
let i = t.slice(0, n).trim();
|
|
34
|
+
r[i] = t.slice(n + 1).trim();
|
|
35
|
+
}
|
|
36
|
+
return r;
|
|
37
|
+
}
|
|
38
|
+
function p() {
|
|
39
|
+
let e = f(r.join(process.cwd(), ".env")), t = f(), i = {
|
|
40
|
+
...process.env,
|
|
41
|
+
...t,
|
|
42
|
+
...e
|
|
43
|
+
};
|
|
44
|
+
if (!n.existsSync(c)) return null;
|
|
45
|
+
let o = n.readFileSync(c, "utf-8"), s = a.load(o);
|
|
46
|
+
if (!s?.profiles) return null;
|
|
47
|
+
let l = {
|
|
48
|
+
profiles: {},
|
|
49
|
+
defaultProfile: s.defaultProfile ?? "default"
|
|
50
|
+
};
|
|
51
|
+
for (let [e, t] of Object.entries(s.profiles)) {
|
|
52
|
+
let n = t;
|
|
53
|
+
l.profiles[e] = {
|
|
54
|
+
auth: {
|
|
55
|
+
appId: d(n.auth?.appId ?? "", i),
|
|
56
|
+
appSecret: d(n.auth?.appSecret ?? "", i)
|
|
57
|
+
},
|
|
58
|
+
bitable: {
|
|
59
|
+
url: n.bitable?.url ?? "",
|
|
60
|
+
appToken: n.bitable?.appToken ?? "",
|
|
61
|
+
tableId: n.bitable?.tableId ?? ""
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return l;
|
|
66
|
+
}
|
|
67
|
+
function m(e, t) {
|
|
68
|
+
let n = t ?? process.env.FZ_TASK_PROFILE ?? e.defaultProfile, r = e.profiles[n];
|
|
69
|
+
if (!r) throw Error(`Profile "${n}" not found. Available: ${Object.keys(e.profiles).join(", ")}`);
|
|
70
|
+
return r;
|
|
71
|
+
}
|
|
72
|
+
function h() {
|
|
73
|
+
n.mkdirSync(s, { recursive: !0 });
|
|
74
|
+
}
|
|
75
|
+
function g(e) {
|
|
76
|
+
h();
|
|
77
|
+
let t = a.dump(e);
|
|
78
|
+
n.writeFileSync(c, t, "utf-8");
|
|
79
|
+
}
|
|
80
|
+
function _(e, t) {
|
|
81
|
+
h();
|
|
82
|
+
let r = `FZ_TASK_APP_ID=${e}\nFZ_TASK_APP_SECRET=${t}\n`;
|
|
83
|
+
n.writeFileSync(l, r, "utf-8");
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/client.ts
|
|
87
|
+
function v(e) {
|
|
88
|
+
let t = {};
|
|
89
|
+
return e.title !== void 0 && (t.Text = e.title), e.type !== void 0 && (t.type = e.type), e.status !== void 0 && (t.status = e.status), e.parent !== void 0 && (t.parent = e.parent), e.doc_url !== void 0 && (t.doc_url = {
|
|
90
|
+
link: e.doc_url,
|
|
91
|
+
text: e.doc_url
|
|
92
|
+
}), e.assignee !== void 0 && (t.assignee = e.assignee), e.description !== void 0 && (t.description = e.description), e.previous_status !== void 0 && (t.previous_status = e.previous_status), e.priority !== void 0 && (t.priority = e.priority), t;
|
|
93
|
+
}
|
|
94
|
+
function y(e) {
|
|
95
|
+
let t = e.fields, n, r = t.parent;
|
|
96
|
+
if (Array.isArray(r) && r.length > 0) {
|
|
97
|
+
let e = r[0];
|
|
98
|
+
e.record_ids?.length > 0 && (n = e.record_ids);
|
|
99
|
+
}
|
|
100
|
+
let i;
|
|
101
|
+
return typeof t.doc_url == "object" && t.doc_url !== null ? i = t.doc_url.link : typeof t.doc_url == "string" && (i = t.doc_url), {
|
|
102
|
+
record_id: e.record_id,
|
|
103
|
+
fields: {
|
|
104
|
+
title: t.Text ?? "",
|
|
105
|
+
type: t.type,
|
|
106
|
+
status: t.status,
|
|
107
|
+
parent: n,
|
|
108
|
+
priority: t.priority,
|
|
109
|
+
doc_url: i,
|
|
110
|
+
assignee: t.assignee,
|
|
111
|
+
description: t.description,
|
|
112
|
+
previous_status: t.previous_status
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
var b = class {
|
|
117
|
+
larkClient;
|
|
118
|
+
appToken;
|
|
119
|
+
tableId;
|
|
120
|
+
constructor(e) {
|
|
121
|
+
this.appToken = e.appToken, this.tableId = e.tableId, this.larkClient = new o.Client({
|
|
122
|
+
appId: e.appId,
|
|
123
|
+
appSecret: e.appSecret,
|
|
124
|
+
appType: o.AppType.SelfBuild,
|
|
125
|
+
domain: o.Domain.Feishu
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async createRecord(e) {
|
|
129
|
+
let t = v(e), n = await this.larkClient.bitable.appTableRecord.create({
|
|
130
|
+
path: {
|
|
131
|
+
app_token: this.appToken,
|
|
132
|
+
table_id: this.tableId
|
|
133
|
+
},
|
|
134
|
+
data: { fields: t }
|
|
135
|
+
});
|
|
136
|
+
if (n.code !== 0) throw Error(`createRecord failed: ${n.msg}`);
|
|
137
|
+
let r = n.data?.record;
|
|
138
|
+
if (!r) throw Error("createRecord: no record in response");
|
|
139
|
+
return y(r);
|
|
140
|
+
}
|
|
141
|
+
async getRecord(e) {
|
|
142
|
+
let t = await this.larkClient.bitable.appTableRecord.get({ path: {
|
|
143
|
+
app_token: this.appToken,
|
|
144
|
+
table_id: this.tableId,
|
|
145
|
+
record_id: e
|
|
146
|
+
} });
|
|
147
|
+
if (t.code !== 0) throw Error(`getRecord failed: ${t.msg}`);
|
|
148
|
+
let n = t.data?.record;
|
|
149
|
+
if (!n) throw Error("getRecord: no record in response");
|
|
150
|
+
return y(n);
|
|
151
|
+
}
|
|
152
|
+
async listRecords(e) {
|
|
153
|
+
let t = [];
|
|
154
|
+
e?.type && t.push(`CurrentValue.[type] = "${e.type}"`), e?.status && t.push(`CurrentValue.[status] = "${e.status}"`);
|
|
155
|
+
let n = t.length > 0 ? t.length === 1 ? t[0] : `AND(${t.join(",")})` : void 0, r = [], i;
|
|
156
|
+
do {
|
|
157
|
+
let e = { page_size: 500 };
|
|
158
|
+
n && (e.filter = n), i && (e.page_token = i);
|
|
159
|
+
let t = await this.larkClient.bitable.appTableRecord.list({
|
|
160
|
+
path: {
|
|
161
|
+
app_token: this.appToken,
|
|
162
|
+
table_id: this.tableId
|
|
163
|
+
},
|
|
164
|
+
params: e
|
|
165
|
+
});
|
|
166
|
+
if (t.code !== 0) throw Error(`listRecords failed: ${t.msg}`);
|
|
167
|
+
let a = t.data?.items ?? [];
|
|
168
|
+
for (let e of a) r.push(y(e));
|
|
169
|
+
i = t.data?.has_more ? t.data.page_token : void 0;
|
|
170
|
+
} while (i);
|
|
171
|
+
return r;
|
|
172
|
+
}
|
|
173
|
+
async updateRecord(e, t) {
|
|
174
|
+
let n = v(t), r = await this.larkClient.bitable.appTableRecord.update({
|
|
175
|
+
path: {
|
|
176
|
+
app_token: this.appToken,
|
|
177
|
+
table_id: this.tableId,
|
|
178
|
+
record_id: e
|
|
179
|
+
},
|
|
180
|
+
data: { fields: n }
|
|
181
|
+
});
|
|
182
|
+
if (r.code !== 0) throw Error(`updateRecord failed: ${r.msg}`);
|
|
183
|
+
let i = r.data?.record;
|
|
184
|
+
if (!i) throw Error("updateRecord: no record in response");
|
|
185
|
+
return y(i);
|
|
186
|
+
}
|
|
187
|
+
async deleteRecord(e) {
|
|
188
|
+
let t = await this.larkClient.bitable.appTableRecord.delete({ path: {
|
|
189
|
+
app_token: this.appToken,
|
|
190
|
+
table_id: this.tableId,
|
|
191
|
+
record_id: e
|
|
192
|
+
} });
|
|
193
|
+
if (t.code !== 0) throw Error(`deleteRecord failed: ${t.msg}`);
|
|
194
|
+
}
|
|
195
|
+
async getChildren(e) {
|
|
196
|
+
return (await this.listRecords()).filter((t) => Array.isArray(t.fields.parent) && t.fields.parent.includes(e));
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/commands/auth.ts
|
|
201
|
+
function x(e) {
|
|
202
|
+
let n = t.createInterface({
|
|
203
|
+
input: process.stdin,
|
|
204
|
+
output: process.stdout
|
|
205
|
+
});
|
|
206
|
+
return new Promise((t) => {
|
|
207
|
+
n.question(e, (e) => {
|
|
208
|
+
n.close(), t(e.trim());
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function S(e) {
|
|
213
|
+
let t = e.command("auth").description("Authentication management");
|
|
214
|
+
t.command("status").description("Show current config and connection status").action(async () => {
|
|
215
|
+
let t = p();
|
|
216
|
+
t || (console.error("No config found. Run `fz-task-cli auth init` to set up."), process.exit(3));
|
|
217
|
+
try {
|
|
218
|
+
let n = e.opts().profile ?? t.defaultProfile, r = m(t, e.opts().profile);
|
|
219
|
+
console.log(`Profile: ${n}`), console.log(`App ID: ${r.auth.appId.slice(0, 6)}...${r.auth.appId.slice(-4)}`), console.log(`App Token: ${r.bitable.appToken}`), console.log(`Table ID: ${r.bitable.tableId}`), console.log("Status: OK");
|
|
220
|
+
} catch (e) {
|
|
221
|
+
console.error(e.message), process.exit(3);
|
|
222
|
+
}
|
|
223
|
+
}), t.command("init").description("Interactive setup").action(async () => {
|
|
224
|
+
let e = await x("Feishu App ID: "), t = await x("Feishu App Secret: "), n = await x("Bitable URL: "), r, i;
|
|
225
|
+
try {
|
|
226
|
+
({appToken: r, tableId: i} = u(n));
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error(`URL parsing failed: ${e.message}`), process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
g({
|
|
231
|
+
profiles: { default: {
|
|
232
|
+
auth: {
|
|
233
|
+
appId: "${FZ_TASK_APP_ID}",
|
|
234
|
+
appSecret: "${FZ_TASK_APP_SECRET}"
|
|
235
|
+
},
|
|
236
|
+
bitable: {
|
|
237
|
+
url: n,
|
|
238
|
+
appToken: r,
|
|
239
|
+
tableId: i
|
|
240
|
+
}
|
|
241
|
+
} },
|
|
242
|
+
defaultProfile: "default"
|
|
243
|
+
}), _(e, t), console.log("Config written to ~/.config/fz-task/config.yaml"), console.log("Credentials written to ~/.config/fz-task/.env"), console.log(`App Token: ${r}`), console.log(`Table ID: ${i}`), console.log("Verifying connection...");
|
|
244
|
+
try {
|
|
245
|
+
await new b({
|
|
246
|
+
appId: e,
|
|
247
|
+
appSecret: t,
|
|
248
|
+
appToken: r,
|
|
249
|
+
tableId: i
|
|
250
|
+
}).listRecords({ type: "Feature" }), console.log("Connection verified successfully!");
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error(`Connection failed: ${e.message}`), process.exit(3);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/types.ts
|
|
258
|
+
var C = [
|
|
259
|
+
"TODO",
|
|
260
|
+
"TEST_SPEC",
|
|
261
|
+
"DOING",
|
|
262
|
+
"TESTING",
|
|
263
|
+
"VERIFIED",
|
|
264
|
+
"DONE"
|
|
265
|
+
], w = new Map(C.map((e, t) => [e, t]));
|
|
266
|
+
function T(e, t) {
|
|
267
|
+
if (e === "DONE") return !1;
|
|
268
|
+
if (t === "BLOCKED" || e === "BLOCKED") return !0;
|
|
269
|
+
let n = w.get(e), r = w.get(t);
|
|
270
|
+
return n === void 0 || r === void 0 ? !1 : r === n + 1 || r === n - 1;
|
|
271
|
+
}
|
|
272
|
+
function E(e) {
|
|
273
|
+
if (e === "DONE") return [];
|
|
274
|
+
if (e === "BLOCKED") return [...C];
|
|
275
|
+
let t = w.get(e);
|
|
276
|
+
if (t === void 0) return [];
|
|
277
|
+
let n = [];
|
|
278
|
+
return t > 0 && n.push(C[t - 1]), t < C.length - 1 && n.push(C[t + 1]), n.push("BLOCKED"), n;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/formatter.ts
|
|
282
|
+
function D(e, t, n) {
|
|
283
|
+
if (n === "json") return JSON.stringify({
|
|
284
|
+
...e,
|
|
285
|
+
children: t
|
|
286
|
+
}, null, 2);
|
|
287
|
+
let r = [];
|
|
288
|
+
if (r.push(`ID: ${e.record_id}`), r.push(`Title: ${e.fields.title}`), r.push(`Type: ${e.fields.type}`), r.push(`Status: ${e.fields.status}`), e.fields.priority && r.push(`Priority: ${e.fields.priority}`), e.fields.assignee && r.push(`Assignee: ${e.fields.assignee}`), e.fields.doc_url && r.push(`Doc URL: ${e.fields.doc_url}`), e.fields.description && r.push(`Description: ${e.fields.description}`), t.length > 0) {
|
|
289
|
+
let n = e.fields.type === "Feature" ? "Stories" : "ACs";
|
|
290
|
+
r.push(""), r.push(`Children (${t.length} ${n}):`);
|
|
291
|
+
for (let e of t) r.push(` ${e.record_id} [${e.fields.status}] ${e.fields.title}`);
|
|
292
|
+
}
|
|
293
|
+
return r.join("\n");
|
|
294
|
+
}
|
|
295
|
+
function O(e, t) {
|
|
296
|
+
if (t === "json") return JSON.stringify(e, null, 2);
|
|
297
|
+
if (e.length === 0) return "No records found.";
|
|
298
|
+
let n = [];
|
|
299
|
+
n.push("ID Type Status Priority Title"), n.push("-".repeat(80));
|
|
300
|
+
for (let t of e) n.push(`${t.record_id}\t${t.fields.type}\t${t.fields.status}\t${t.fields.priority ?? "-"}\t${t.fields.title}`);
|
|
301
|
+
return n.join("\n");
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/commands/feature.ts
|
|
305
|
+
function k(e) {
|
|
306
|
+
let t = p();
|
|
307
|
+
t || (console.error("No config found. Run `fz-task-cli auth init` to set up."), process.exit(3));
|
|
308
|
+
let n = m(t, e.parent?.opts().profile);
|
|
309
|
+
return new b({
|
|
310
|
+
appId: n.auth.appId,
|
|
311
|
+
appSecret: n.auth.appSecret,
|
|
312
|
+
appToken: n.bitable.appToken,
|
|
313
|
+
tableId: n.bitable.tableId
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function A(e) {
|
|
317
|
+
return e.parent?.opts().format ?? "table";
|
|
318
|
+
}
|
|
319
|
+
function j(e) {
|
|
320
|
+
let t = e.command("feature").description("Feature management commands");
|
|
321
|
+
t.command("add [title]").description("Create a new Feature").option("--json <json>", "JSON string with fields").action(async (e, n, r) => {
|
|
322
|
+
let i = k(t), a = A(t), o = {};
|
|
323
|
+
if (n.json) try {
|
|
324
|
+
o = JSON.parse(n.json);
|
|
325
|
+
} catch {
|
|
326
|
+
console.error("Invalid JSON provided."), process.exit(2);
|
|
327
|
+
}
|
|
328
|
+
e && (o.title = e), o.title || (console.error("Title is required. Provide as argument or in --json."), process.exit(2)), o.type = "Feature", o.status ||= "TODO";
|
|
329
|
+
try {
|
|
330
|
+
let e = await i.createRecord(o);
|
|
331
|
+
console.log(D(e, [], a));
|
|
332
|
+
} catch (e) {
|
|
333
|
+
console.error(`Failed to create feature: ${e.message}`), process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
}), t.command("list").description("List all Features").action(async (e, n) => {
|
|
336
|
+
let r = k(t), i = A(t);
|
|
337
|
+
try {
|
|
338
|
+
let e = await r.listRecords({ type: "Feature" });
|
|
339
|
+
console.log(O(e, i));
|
|
340
|
+
} catch (e) {
|
|
341
|
+
console.error(`Failed to list features: ${e.message}`), process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
}), t.command("get <id>").description("Get a Feature by ID").action(async (e, n, r) => {
|
|
344
|
+
let i = k(t), a = A(t), o;
|
|
345
|
+
try {
|
|
346
|
+
o = await i.getRecord(e);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
349
|
+
}
|
|
350
|
+
o.fields.type !== "Feature" && (console.error(`Record ${e} is not a Feature (type: ${o.fields.type}).`), process.exit(2));
|
|
351
|
+
try {
|
|
352
|
+
let t = await i.getChildren(e);
|
|
353
|
+
console.log(D(o, t, a));
|
|
354
|
+
} catch (e) {
|
|
355
|
+
console.error(`Failed to get children: ${e.message}`), process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
}), t.command("update <id>").description("Update a Feature").option("--status <status>", "New status").option("--priority <priority>", "New priority").option("--assignee <assignee>", "New assignee").option("--doc-url <url>", "New doc URL").option("--title <title>", "New title").option("--description <description>", "New description").action(async (e, n, r) => {
|
|
358
|
+
let i = k(t), a = A(t), o;
|
|
359
|
+
try {
|
|
360
|
+
o = await i.getRecord(e);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
363
|
+
}
|
|
364
|
+
o.fields.type !== "Feature" && (console.error(`Record ${e} is not a Feature (type: ${o.fields.type}).`), process.exit(2));
|
|
365
|
+
let s = {};
|
|
366
|
+
if (n.status) {
|
|
367
|
+
let e = n.status, t = o.fields.status;
|
|
368
|
+
if (!T(t, e)) {
|
|
369
|
+
let n = E(t);
|
|
370
|
+
console.error(`Invalid status transition: ${t} -> ${e}. Allowed: ${n.join(", ")}`), process.exit(2);
|
|
371
|
+
}
|
|
372
|
+
s.status = e, e === "BLOCKED" && (s.previous_status = t), t === "BLOCKED" && (s.previous_status = void 0);
|
|
373
|
+
}
|
|
374
|
+
n.priority && (s.priority = n.priority), n.assignee && (s.assignee = n.assignee), n.docUrl && (s.doc_url = n.docUrl), n.title && (s.title = n.title), n.description && (s.description = n.description);
|
|
375
|
+
try {
|
|
376
|
+
let t = await i.updateRecord(e, s), n = await i.getChildren(e);
|
|
377
|
+
console.log(D(t, n, a));
|
|
378
|
+
} catch (e) {
|
|
379
|
+
console.error(`Failed to update feature: ${e.message}`), process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
}), t.command("delete <id>").description("Delete a Feature").action(async (e, n, r) => {
|
|
382
|
+
let i = k(t), a;
|
|
383
|
+
try {
|
|
384
|
+
a = await i.getRecord(e);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
387
|
+
}
|
|
388
|
+
a.fields.type !== "Feature" && (console.error(`Record ${e} is not a Feature (type: ${a.fields.type}).`), process.exit(2));
|
|
389
|
+
let o = await i.getChildren(e);
|
|
390
|
+
o.length > 0 && (console.error(`Cannot delete Feature ${e}: has ${o.length} child stories. Delete children first.`), process.exit(2));
|
|
391
|
+
try {
|
|
392
|
+
await i.deleteRecord(e), console.log(`Feature ${e} deleted.`);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
console.error(`Failed to delete feature: ${e.message}`), process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/commands/story.ts
|
|
400
|
+
function M(e) {
|
|
401
|
+
let t = p();
|
|
402
|
+
t || (console.error("No config found. Run `fz-task-cli auth init` to set up."), process.exit(3));
|
|
403
|
+
let n = m(t, e.parent?.opts().profile);
|
|
404
|
+
return new b({
|
|
405
|
+
appId: n.auth.appId,
|
|
406
|
+
appSecret: n.auth.appSecret,
|
|
407
|
+
appToken: n.bitable.appToken,
|
|
408
|
+
tableId: n.bitable.tableId
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
function N(e) {
|
|
412
|
+
return e.parent?.opts().format ?? "table";
|
|
413
|
+
}
|
|
414
|
+
function P(e) {
|
|
415
|
+
let t = e.command("story").description("UserStory management commands");
|
|
416
|
+
t.command("add [title]").description("Create a new UserStory under a Feature").requiredOption("--feature <id>", "Parent Feature record ID").option("--json <json>", "JSON string with fields").action(async (e, n, r) => {
|
|
417
|
+
let i = M(t), a = N(t), o;
|
|
418
|
+
try {
|
|
419
|
+
o = await i.getRecord(n.feature);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
console.error(`Feature not found: ${e.message}`), process.exit(4);
|
|
422
|
+
}
|
|
423
|
+
o.fields.type !== "Feature" && (console.error(`Record ${n.feature} is not a Feature (type: ${o.fields.type}).`), process.exit(2));
|
|
424
|
+
let s = {};
|
|
425
|
+
if (n.json) try {
|
|
426
|
+
s = JSON.parse(n.json);
|
|
427
|
+
} catch {
|
|
428
|
+
console.error("Invalid JSON provided."), process.exit(2);
|
|
429
|
+
}
|
|
430
|
+
e && (s.title = e), s.title || (console.error("Title is required. Provide as argument or in --json."), process.exit(2)), s.type = "UserStory", s.parent = [n.feature], s.status ||= "TODO";
|
|
431
|
+
try {
|
|
432
|
+
let e = await i.createRecord(s);
|
|
433
|
+
console.log(D(e, [], a));
|
|
434
|
+
} catch (e) {
|
|
435
|
+
console.error(`Failed to create story: ${e.message}`), process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
}), t.command("list").description("List UserStories, optionally filtered by Feature").option("--feature <id>", "Parent Feature record ID to filter by").action(async (e, n) => {
|
|
438
|
+
let r = M(t), i = N(t);
|
|
439
|
+
try {
|
|
440
|
+
let t;
|
|
441
|
+
t = e.feature ? (await r.getChildren(e.feature)).filter((e) => e.fields.type === "UserStory") : await r.listRecords({ type: "UserStory" }), console.log(O(t, i));
|
|
442
|
+
} catch (e) {
|
|
443
|
+
console.error(`Failed to list stories: ${e.message}`), process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
}), t.command("get <id>").description("Get a UserStory by ID").action(async (e, n, r) => {
|
|
446
|
+
let i = M(t), a = N(t), o;
|
|
447
|
+
try {
|
|
448
|
+
o = await i.getRecord(e);
|
|
449
|
+
} catch (e) {
|
|
450
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
451
|
+
}
|
|
452
|
+
o.fields.type !== "UserStory" && (console.error(`Record ${e} is not a UserStory (type: ${o.fields.type}).`), process.exit(2));
|
|
453
|
+
try {
|
|
454
|
+
let t = await i.getChildren(e);
|
|
455
|
+
console.log(D(o, t, a));
|
|
456
|
+
} catch (e) {
|
|
457
|
+
console.error(`Failed to get children: ${e.message}`), process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
}), t.command("update <id>").description("Update a UserStory").option("--status <status>", "New status").option("--priority <priority>", "New priority").option("--assignee <assignee>", "New assignee").option("--doc-url <url>", "New doc URL").option("--title <title>", "New title").option("--description <description>", "New description").action(async (e, n, r) => {
|
|
460
|
+
let i = M(t), a = N(t), o;
|
|
461
|
+
try {
|
|
462
|
+
o = await i.getRecord(e);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
465
|
+
}
|
|
466
|
+
o.fields.type !== "UserStory" && (console.error(`Record ${e} is not a UserStory (type: ${o.fields.type}).`), process.exit(2));
|
|
467
|
+
let s = {};
|
|
468
|
+
if (n.status) {
|
|
469
|
+
let e = n.status, t = o.fields.status;
|
|
470
|
+
if (!T(t, e)) {
|
|
471
|
+
let n = E(t);
|
|
472
|
+
console.error(`Invalid status transition: ${t} -> ${e}. Allowed: ${n.join(", ")}`), process.exit(2);
|
|
473
|
+
}
|
|
474
|
+
s.status = e, e === "BLOCKED" && (s.previous_status = t), t === "BLOCKED" && (s.previous_status = void 0);
|
|
475
|
+
}
|
|
476
|
+
n.priority && (s.priority = n.priority), n.assignee && (s.assignee = n.assignee), n.docUrl && (s.doc_url = n.docUrl), n.title && (s.title = n.title), n.description && (s.description = n.description);
|
|
477
|
+
try {
|
|
478
|
+
let t = await i.updateRecord(e, s), n = await i.getChildren(e);
|
|
479
|
+
console.log(D(t, n, a));
|
|
480
|
+
} catch (e) {
|
|
481
|
+
console.error(`Failed to update story: ${e.message}`), process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
}), t.command("delete <id>").description("Delete a UserStory").action(async (e, n, r) => {
|
|
484
|
+
let i = M(t), a;
|
|
485
|
+
try {
|
|
486
|
+
a = await i.getRecord(e);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
489
|
+
}
|
|
490
|
+
a.fields.type !== "UserStory" && (console.error(`Record ${e} is not a UserStory (type: ${a.fields.type}).`), process.exit(2));
|
|
491
|
+
let o = await i.getChildren(e);
|
|
492
|
+
o.length > 0 && (console.error(`Cannot delete UserStory ${e}: has ${o.length} child ACs. Delete children first.`), process.exit(2));
|
|
493
|
+
try {
|
|
494
|
+
await i.deleteRecord(e), console.log(`UserStory ${e} deleted.`);
|
|
495
|
+
} catch (e) {
|
|
496
|
+
console.error(`Failed to delete story: ${e.message}`), process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/commands/ac.ts
|
|
502
|
+
function F(e) {
|
|
503
|
+
let t = p();
|
|
504
|
+
t || (console.error("No config found. Run `fz-task-cli auth init` to set up."), process.exit(3));
|
|
505
|
+
let n = m(t, e.parent?.opts().profile);
|
|
506
|
+
return new b({
|
|
507
|
+
appId: n.auth.appId,
|
|
508
|
+
appSecret: n.auth.appSecret,
|
|
509
|
+
appToken: n.bitable.appToken,
|
|
510
|
+
tableId: n.bitable.tableId
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function I(e) {
|
|
514
|
+
return e.parent?.opts().format ?? "table";
|
|
515
|
+
}
|
|
516
|
+
function L(e) {
|
|
517
|
+
let t = e.command("ac").description("AC (Acceptance Criteria) management commands");
|
|
518
|
+
t.command("add [title]").description("Create a new AC under a UserStory").requiredOption("--story <id>", "Parent UserStory record ID").option("--json <json>", "JSON string with fields").action(async (e, n, r) => {
|
|
519
|
+
let i = F(t), a = I(t), o;
|
|
520
|
+
try {
|
|
521
|
+
o = await i.getRecord(n.story);
|
|
522
|
+
} catch (e) {
|
|
523
|
+
console.error(`UserStory not found: ${e.message}`), process.exit(4);
|
|
524
|
+
}
|
|
525
|
+
o.fields.type !== "UserStory" && (console.error(`Record ${n.story} is not a UserStory (type: ${o.fields.type}).`), process.exit(2));
|
|
526
|
+
let s = {};
|
|
527
|
+
if (n.json) try {
|
|
528
|
+
s = JSON.parse(n.json);
|
|
529
|
+
} catch {
|
|
530
|
+
console.error("Invalid JSON provided."), process.exit(2);
|
|
531
|
+
}
|
|
532
|
+
e && (s.title = e), s.title || (console.error("Title is required. Provide as argument or in --json."), process.exit(2)), s.type = "AC", s.parent = [n.story], s.status ||= "TODO";
|
|
533
|
+
try {
|
|
534
|
+
let e = await i.createRecord(s);
|
|
535
|
+
console.log(D(e, [], a));
|
|
536
|
+
} catch (e) {
|
|
537
|
+
console.error(`Failed to create AC: ${e.message}`), process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
}), t.command("list").description("List ACs, optionally filtered by UserStory").option("--story <id>", "Parent UserStory record ID to filter by").action(async (e, n) => {
|
|
540
|
+
let r = F(t), i = I(t);
|
|
541
|
+
try {
|
|
542
|
+
let t;
|
|
543
|
+
t = e.story ? (await r.getChildren(e.story)).filter((e) => e.fields.type === "AC") : await r.listRecords({ type: "AC" }), console.log(O(t, i));
|
|
544
|
+
} catch (e) {
|
|
545
|
+
console.error(`Failed to list ACs: ${e.message}`), process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
}), t.command("get <id>").description("Get an AC by ID").action(async (e, n, r) => {
|
|
548
|
+
let i = F(t), a = I(t), o;
|
|
549
|
+
try {
|
|
550
|
+
o = await i.getRecord(e);
|
|
551
|
+
} catch (e) {
|
|
552
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
553
|
+
}
|
|
554
|
+
o.fields.type !== "AC" && (console.error(`Record ${e} is not an AC (type: ${o.fields.type}).`), process.exit(2)), console.log(D(o, [], a));
|
|
555
|
+
}), t.command("update <id>").description("Update an AC").option("--status <status>", "New status").option("--priority <priority>", "New priority").option("--assignee <assignee>", "New assignee").option("--doc-url <url>", "New doc URL").option("--title <title>", "New title").option("--description <description>", "New description").action(async (e, n, r) => {
|
|
556
|
+
let i = F(t), a = I(t), o;
|
|
557
|
+
try {
|
|
558
|
+
o = await i.getRecord(e);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
561
|
+
}
|
|
562
|
+
o.fields.type !== "AC" && (console.error(`Record ${e} is not an AC (type: ${o.fields.type}).`), process.exit(2));
|
|
563
|
+
let s = {};
|
|
564
|
+
if (n.status) {
|
|
565
|
+
let e = n.status, t = o.fields.status;
|
|
566
|
+
if (!T(t, e)) {
|
|
567
|
+
let n = E(t);
|
|
568
|
+
console.error(`Invalid status transition: ${t} -> ${e}. Allowed: ${n.join(", ")}`), process.exit(2);
|
|
569
|
+
}
|
|
570
|
+
s.status = e, e === "BLOCKED" && (s.previous_status = t), t === "BLOCKED" && (s.previous_status = void 0);
|
|
571
|
+
}
|
|
572
|
+
n.priority && (s.priority = n.priority), n.assignee && (s.assignee = n.assignee), n.docUrl && (s.doc_url = n.docUrl), n.title && (s.title = n.title), n.description && (s.description = n.description);
|
|
573
|
+
try {
|
|
574
|
+
let t = await i.updateRecord(e, s);
|
|
575
|
+
console.log(D(t, [], a));
|
|
576
|
+
} catch (e) {
|
|
577
|
+
console.error(`Failed to update AC: ${e.message}`), process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
}), t.command("delete <id>").description("Delete an AC").action(async (e, n, r) => {
|
|
580
|
+
let i = F(t), a;
|
|
581
|
+
try {
|
|
582
|
+
a = await i.getRecord(e);
|
|
583
|
+
} catch (e) {
|
|
584
|
+
console.error(`Record not found: ${e.message}`), process.exit(4);
|
|
585
|
+
}
|
|
586
|
+
a.fields.type !== "AC" && (console.error(`Record ${e} is not an AC (type: ${a.fields.type}).`), process.exit(2));
|
|
587
|
+
try {
|
|
588
|
+
await i.deleteRecord(e), console.log(`AC ${e} deleted.`);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
console.error(`Failed to delete AC: ${e.message}`), process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/cli.ts
|
|
596
|
+
var R = new e();
|
|
597
|
+
R.name("fz-task-cli").description("Feishu Bitable task management CLI").version("0.1.0").option("--profile <name>", "config profile name").option("--format <format>", "output format (json|table)", "table"), S(R), j(R), P(R), L(R), R.parse();
|
|
598
|
+
//#endregion
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fanzie/task-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "CLI tool for managing Feature/UserStory/AC task hierarchies in Feishu Bitable",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"fz-task-cli": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"feishu",
|
|
17
|
+
"bitable",
|
|
18
|
+
"task",
|
|
19
|
+
"cli",
|
|
20
|
+
"project-management"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/dannnney/fz-task-cli.git"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
33
|
+
"commander": "^14.0.3",
|
|
34
|
+
"js-yaml": "^4.1.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/js-yaml": "^4.0.9",
|
|
38
|
+
"@types/node": "^25.5.0",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"vite": "^8.0.1"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"dev": "node --experimental-strip-types src/cli.ts",
|
|
44
|
+
"build": "vite build",
|
|
45
|
+
"test": "bun test"
|
|
46
|
+
}
|
|
47
|
+
}
|