@zinn-dev/cli 0.0.8 → 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 +127 -1
- package/index.ts +29 -97
- package/package.json +47 -47
- package/src/routes/project-router.ts +111 -0
- package/src/routes/router.test.ts +79 -0
- package/src/routes/router.ts +107 -0
- package/src/routes/routes.ts +8 -0
- package/src/routes/task-router.ts +319 -0
- package/src/types.ts +6 -0
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
1
|
# @zinn-dev/cli
|
|
2
2
|
|
|
3
|
-
The `zinn`
|
|
3
|
+
The `zinn` command-line interface. Requires [Bun](https://bun.sh).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add --global @zinn-dev/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Zinn stores projects and tasks in the shared local database at `~/.zinn/data/zinn.sqlite`.
|
|
12
|
+
Set `ZINN_DIR` to use another Zinn directory and keep using the same value for commands that should share that database.
|
|
13
|
+
|
|
14
|
+
## Start a project
|
|
15
|
+
|
|
16
|
+
Create a project with a name and a short key that begins with a letter and then uses only letters and numbers:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
zinn project create "Website refresh" SITE
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Project keys are stored in uppercase. Each project starts with _Backlog_, _TODO_, _In Progress_, _Review_, and _Done_ columns. List projects or inspect a project's columns with:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
zinn project list
|
|
26
|
+
zinn project column list SITE
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Add another column at the end of the board:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
zinn project column create SITE "Waiting"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`zinn project delete SITE` permanently deletes the project and all of its columns and tasks. The command does not ask for confirmation.
|
|
36
|
+
|
|
37
|
+
## Create and read tasks
|
|
38
|
+
|
|
39
|
+
Create a task with a title and an optional description. New tasks enter the project's first column:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
zinn task create SITE "Rewrite the home page"
|
|
43
|
+
zinn task create SITE "Check the mobile layout" "Test the navigation at narrow widths"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Tasks are created with keys such as `SITE-1`. List active tasks, optionally limited to one project, or view one task:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
zinn task list
|
|
50
|
+
zinn task list SITE
|
|
51
|
+
zinn task view SITE-1
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
When a project key is supplied, the list follows the project's column order and the task order within each column.
|
|
55
|
+
|
|
56
|
+
## Edit tasks
|
|
57
|
+
|
|
58
|
+
Edit a task's title, description, or both:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
zinn task edit SITE-1 --title "Rewrite the landing page"
|
|
62
|
+
zinn task edit SITE-1 --description "Include the new product screenshots"
|
|
63
|
+
zinn task edit SITE-1 --title "Rewrite the landing page" --description ""
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Omitted fields stay unchanged. An empty description is stored as an empty string.
|
|
67
|
+
|
|
68
|
+
At least one flag is required and task titles cannot be blank. Archived tasks can be edited without unarchiving them. Supplying unchanged values does not change the modification timestamp.
|
|
69
|
+
Use `--title="--example"` when a value begins with a dash.
|
|
70
|
+
|
|
71
|
+
## Move and order tasks
|
|
72
|
+
|
|
73
|
+
Move a task to another column in its project:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
zinn task move SITE-1 "In Progress"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A moved task appears at the bottom of its destination column. Moving it to its current column does nothing. Archived tasks must be unarchived before they can be moved.
|
|
80
|
+
|
|
81
|
+
Change a task's position within its current column:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
zinn task order SITE-2 top
|
|
85
|
+
zinn task order SITE-2 up
|
|
86
|
+
zinn task order SITE-2 down
|
|
87
|
+
zinn task order SITE-2 bottom
|
|
88
|
+
zinn task move SITE-2 "In Progress"
|
|
89
|
+
zinn task order SITE-2 before SITE-1
|
|
90
|
+
zinn task order SITE-2 after SITE-1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The task being reordered and the target of `before` or `after` must be active tasks in the same column.
|
|
94
|
+
|
|
95
|
+
## Archive and delete tasks
|
|
96
|
+
|
|
97
|
+
Active tasks are shown by default. Archive a task to hide it from active lists, then list archived tasks or all tasks:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
zinn task archive SITE-1
|
|
101
|
+
zinn task list SITE --archived
|
|
102
|
+
zinn task list SITE --all
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--archived` and `--all` cannot be used together. In an `--all` listing, Zinn adds an Active or Archived status column.
|
|
106
|
+
|
|
107
|
+
Unarchiving puts a task at the bottom of its previous column:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
zinn task unarchive SITE-1
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Delete a task permanently with `zinn task delete SITE-1`. Unlike project deletion, task deletion asks for confirmation.
|
|
114
|
+
|
|
115
|
+
## Help
|
|
116
|
+
|
|
117
|
+
Use root or namespace help to discover commands, then open a command's help for its arguments and behavior:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
zinn --help
|
|
121
|
+
zinn project --help
|
|
122
|
+
zinn project column --help
|
|
123
|
+
zinn task --help
|
|
124
|
+
zinn task move --help
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The short `-h` form works in the same positions.
|
|
128
|
+
|
|
129
|
+
Running `zinn` without a command launches the work-in-progress TUI when attached to an interactive terminal. The commands documented above provide the complete current workflow.
|
package/index.ts
CHANGED
|
@@ -2,18 +2,6 @@
|
|
|
2
2
|
import { quit } from "./src/lib";
|
|
3
3
|
|
|
4
4
|
const args = process.argv.slice(2);
|
|
5
|
-
const FLAG = {
|
|
6
|
-
help: ["-h", "--help"],
|
|
7
|
-
project: ["project"],
|
|
8
|
-
task: ["task"],
|
|
9
|
-
create: ["create"],
|
|
10
|
-
delete: ["delete"],
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const MESSAGE = {
|
|
14
|
-
displayUnknownCommand: (cmd: string) =>
|
|
15
|
-
`Unrecognized command "${cmd}". Please run \`zinn --help\` for a list of available commands`,
|
|
16
|
-
};
|
|
17
5
|
|
|
18
6
|
if (args.length === 0) {
|
|
19
7
|
if (!!process.stdout.isTTY) {
|
|
@@ -25,93 +13,37 @@ if (args.length === 0) {
|
|
|
25
13
|
} else {
|
|
26
14
|
// TODO: isTTY check for human readable colored and structured formatting like tables
|
|
27
15
|
const firstArg = args[0]!;
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
// TODO: root routing with flags
|
|
17
|
+
if (["-h", "--help"].includes(firstArg)) {
|
|
30
18
|
console.info(`ZINN - A kanban workflow in the terminal
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
Usage: zinn <command>
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
-h, --help Show help
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
project create
|
|
27
|
+
project list
|
|
28
|
+
project delete
|
|
29
|
+
project column create
|
|
30
|
+
project column list
|
|
31
|
+
task create
|
|
32
|
+
task list
|
|
33
|
+
task view
|
|
34
|
+
task edit
|
|
35
|
+
task move
|
|
36
|
+
task order
|
|
37
|
+
task archive
|
|
38
|
+
task unarchive
|
|
39
|
+
task delete
|
|
40
|
+
|
|
41
|
+
Run zinn <namespace> --help or zinn <command> --help for more information.
|
|
42
|
+
Running zinn without a command opens the work-in-progress TUI in an interactive terminal.`);
|
|
34
43
|
} else {
|
|
35
|
-
const {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (secondArg == null) {
|
|
40
|
-
// TODO: implement `zinn project --help` for better guiding
|
|
41
|
-
quit(`The command "${firstArg}" requires a secondary command: zinn ${firstArg} <command>`);
|
|
42
|
-
} else if (FLAG.create.includes(secondArg)) {
|
|
43
|
-
// TODO: allow direct key value pairs with flags like --name and --key
|
|
44
|
-
const projectName = args[2];
|
|
45
|
-
// TODO: maybe auto generate project key from name instead of forcing explicit input (however, very open to collision)
|
|
46
|
-
const projectKey = args[3];
|
|
47
|
-
|
|
48
|
-
if (projectName == null || projectKey == null) {
|
|
49
|
-
quit("Both the project name and the project key must be defined");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
project.create({ key: projectKey, name: projectName });
|
|
54
|
-
} catch (err: any) {
|
|
55
|
-
quit(err?.message ?? "Something went wrong");
|
|
56
|
-
}
|
|
57
|
-
} else if (FLAG.delete.includes(secondArg)) {
|
|
58
|
-
const projectKey = args[2];
|
|
59
|
-
if (projectKey == null) {
|
|
60
|
-
quit("You need to specificy which project to delete");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
// TODO: add y/n confirmation
|
|
65
|
-
project.delete(projectKey);
|
|
66
|
-
} catch (err: any) {
|
|
67
|
-
quit(err?.message ?? "Something went wrong");
|
|
68
|
-
}
|
|
69
|
-
} else {
|
|
70
|
-
quit(MESSAGE.displayUnknownCommand(`${firstArg} ${secondArg}`));
|
|
71
|
-
}
|
|
72
|
-
} else if (FLAG.task.includes(firstArg)) {
|
|
73
|
-
const secondArg = args[1];
|
|
74
|
-
if (secondArg == null) {
|
|
75
|
-
// TODO: implement `zinn project --help` for better guiding
|
|
76
|
-
console.error(
|
|
77
|
-
`The command "${firstArg}" requires a secondary command: zinn ${firstArg} <command>`,
|
|
78
|
-
);
|
|
79
|
-
process.exit(1);
|
|
80
|
-
} else if (FLAG.create.includes(secondArg)) {
|
|
81
|
-
const projectKey = args[2];
|
|
82
|
-
if (projectKey == null) {
|
|
83
|
-
quit("A task needs to belong to a project");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const taskName = args[3];
|
|
87
|
-
if (taskName == null) {
|
|
88
|
-
quit("A task needs at least a title");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const taskDesc = args[4];
|
|
92
|
-
|
|
93
|
-
// TODO: do empty strings bypass this check?
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const standardizedKey = project.standardizeKey(projectKey);
|
|
97
|
-
// TODO: `getByKey` already standardizes the key, but to display it in standardized format, I also used it here. Should it run twice on the same thing?
|
|
98
|
-
const projectMatch = project.getByKey(standardizedKey);
|
|
99
|
-
if (projectMatch == null) {
|
|
100
|
-
quit(`Project with key "${standardizedKey}" does not exist`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// TODO: should it non-null (??) or non-falsy (||) check?
|
|
104
|
-
task.create({
|
|
105
|
-
project_id: projectMatch.id,
|
|
106
|
-
name: taskName,
|
|
107
|
-
description: taskDesc ?? null,
|
|
108
|
-
});
|
|
109
|
-
} catch (err: any) {
|
|
110
|
-
quit(err?.message ?? "Something went wrong");
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
quit(MESSAGE.displayUnknownCommand(firstArg));
|
|
115
|
-
}
|
|
44
|
+
const { createRouter } = await import("./src/routes/router");
|
|
45
|
+
const { routes } = await import("./src/routes/routes");
|
|
46
|
+
const router = createRouter(routes);
|
|
47
|
+
router.route(args);
|
|
116
48
|
}
|
|
117
49
|
}
|
package/package.json
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
2
|
+
"name": "@zinn-dev/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A kanban workflow in the terminal.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/yethranayeh/zinn/issues"
|
|
8
|
+
},
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Alper Halil",
|
|
11
|
+
"email": "contact@aktasalper.com",
|
|
12
|
+
"url": "https://aktasalper.com"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/yethranayeh/zinn.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"kanban",
|
|
20
|
+
"cli",
|
|
21
|
+
"terminal",
|
|
22
|
+
"tui",
|
|
23
|
+
"opentui",
|
|
24
|
+
"bun"
|
|
25
|
+
],
|
|
26
|
+
"module": "index.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./index.ts",
|
|
30
|
+
"import": "./index.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"index.ts",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"type": "module",
|
|
38
|
+
"bin": {
|
|
39
|
+
"zinn": "./index.ts"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@zinn-dev/core": "0.1.0",
|
|
46
|
+
"@zinn-dev/tui": "0.0.1"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { RouteDef } from "../types";
|
|
2
|
+
|
|
3
|
+
import { column, project } from "@zinn-dev/core";
|
|
4
|
+
import { quit } from "../lib";
|
|
5
|
+
|
|
6
|
+
export const projectRouter = {
|
|
7
|
+
create: {
|
|
8
|
+
run: (args: Array<string>) => {
|
|
9
|
+
const projectName = args[0];
|
|
10
|
+
// TODO: maybe auto generate project key from name instead of forcing explicit input (however, very open to collision)
|
|
11
|
+
const projectKey = args[1];
|
|
12
|
+
|
|
13
|
+
if (projectName == null || projectKey == null) {
|
|
14
|
+
quit("Both the project name and the project key must be defined");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
project.create({ key: projectKey, name: projectName });
|
|
19
|
+
} catch (err: any) {
|
|
20
|
+
quit(err?.message ?? "Something went wrong");
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
help: `Usage: zinn project create <name> <project-key>
|
|
24
|
+
|
|
25
|
+
Create a project with the default Backlog, TODO, In Progress, Review, and Done columns.
|
|
26
|
+
Project keys are stored in uppercase.
|
|
27
|
+
|
|
28
|
+
Example: zinn project create "Website refresh" SITE`,
|
|
29
|
+
},
|
|
30
|
+
list: {
|
|
31
|
+
run: () => {
|
|
32
|
+
const projects = project.getAll();
|
|
33
|
+
if (projects.length === 0) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const longestKeyLength = projects.reduce(
|
|
38
|
+
(prev, current) => Math.max(prev, current.key.length),
|
|
39
|
+
0,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
console.info(projects.map((p) => `${p.key.padEnd(longestKeyLength)} | ${p.name}`).join("\n"));
|
|
43
|
+
},
|
|
44
|
+
help: `Usage: zinn project list
|
|
45
|
+
|
|
46
|
+
List projects and their keys.
|
|
47
|
+
|
|
48
|
+
Example: zinn project list`,
|
|
49
|
+
},
|
|
50
|
+
delete: {
|
|
51
|
+
run: (args: Array<string>) => {
|
|
52
|
+
const projectKey = args[0];
|
|
53
|
+
|
|
54
|
+
if (projectKey == null) {
|
|
55
|
+
quit("You need to specificy which project to delete");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// TODO: add y/n confirmation
|
|
60
|
+
project.delete(projectKey);
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
quit(err?.message ?? "Something went wrong");
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
help: `Usage: zinn project delete <project-key>
|
|
66
|
+
|
|
67
|
+
Permanently delete a project and all of its columns and tasks without confirmation.
|
|
68
|
+
|
|
69
|
+
Example: zinn project delete SITE`,
|
|
70
|
+
},
|
|
71
|
+
column: {
|
|
72
|
+
create: {
|
|
73
|
+
run: (args: Array<string>) => {
|
|
74
|
+
const projectKey = args[0];
|
|
75
|
+
const columnName = args[1];
|
|
76
|
+
|
|
77
|
+
if (projectKey == null || columnName == null) {
|
|
78
|
+
quit("Both the project name and the column name must be defined");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
column.create({ projectKey, name: columnName });
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
quit(err?.message ?? "Something went wrong");
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
help: `Usage: zinn project column create <project-key> <column-name>
|
|
88
|
+
|
|
89
|
+
Add a column at the end of a project's board.
|
|
90
|
+
|
|
91
|
+
Example: zinn project column create SITE "Waiting for review"`,
|
|
92
|
+
},
|
|
93
|
+
list: {
|
|
94
|
+
run: (args: Array<string>) => {
|
|
95
|
+
const projectKey = args[0];
|
|
96
|
+
if (projectKey == null) {
|
|
97
|
+
quit("Project key needs to be specified to list columns");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// TODO: attach per-column task count
|
|
101
|
+
// TODO: terminal formatting
|
|
102
|
+
console.info(column.getAllByProjectKey(projectKey).map((c) => c.name));
|
|
103
|
+
},
|
|
104
|
+
help: `Usage: zinn project column list <project-key>
|
|
105
|
+
|
|
106
|
+
List a project's columns in board order.
|
|
107
|
+
|
|
108
|
+
Example: zinn project column list SITE`,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
} satisfies RouteDef;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Command, RouteDef } from "../types";
|
|
2
|
+
|
|
3
|
+
import { test, expect, mock, spyOn } from "bun:test";
|
|
4
|
+
import { createRouter, parseRoutes } from "./router";
|
|
5
|
+
|
|
6
|
+
const mockCommand: Command = { run: mock(() => {}), help: `` };
|
|
7
|
+
|
|
8
|
+
test("command nesting is properly parsed", () => {
|
|
9
|
+
const routes: RouteDef = {
|
|
10
|
+
foo: {
|
|
11
|
+
create: mockCommand,
|
|
12
|
+
bar: {
|
|
13
|
+
create: mockCommand,
|
|
14
|
+
baz: {
|
|
15
|
+
create: mockCommand,
|
|
16
|
+
foo: {
|
|
17
|
+
create: mockCommand,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
expect(parseRoutes(routes).map((r) => r.command)).toEqual([
|
|
24
|
+
"foo create",
|
|
25
|
+
"foo bar create",
|
|
26
|
+
"foo bar baz create",
|
|
27
|
+
"foo bar baz foo create",
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("router calls the run functions", () => {
|
|
32
|
+
const routes: RouteDef = {
|
|
33
|
+
foo: {
|
|
34
|
+
create: mockCommand,
|
|
35
|
+
bar: {
|
|
36
|
+
create: mockCommand,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let router = createRouter(routes);
|
|
42
|
+
router.route(["foo", "create"]);
|
|
43
|
+
expect(mockCommand.run).toHaveBeenCalledTimes(1);
|
|
44
|
+
|
|
45
|
+
router.route(["foo", "bar", "create"]);
|
|
46
|
+
expect(mockCommand.run).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("router calls the deepest nesting command", () => {
|
|
50
|
+
const fooCreate = mock(() => {});
|
|
51
|
+
const barCreate = mock(() => {});
|
|
52
|
+
const routes = {
|
|
53
|
+
foo: {
|
|
54
|
+
create: { run: fooCreate, help: "" },
|
|
55
|
+
bar: {
|
|
56
|
+
create: { run: barCreate, help: "" },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
} satisfies RouteDef;
|
|
60
|
+
|
|
61
|
+
let router = createRouter(routes);
|
|
62
|
+
router.route(["foo", "bar", "create"]);
|
|
63
|
+
expect(routes.foo.bar.create.run).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(routes.foo.create.run).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a help request never runs a command even when its help is empty", () => {
|
|
68
|
+
const run = mock(() => {});
|
|
69
|
+
const info = spyOn(console, "info").mockImplementation(() => {});
|
|
70
|
+
const router = createRouter({ foo: { run, help: "" } });
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
router.route(["foo", "--help"]);
|
|
74
|
+
expect(run).not.toHaveBeenCalled();
|
|
75
|
+
expect(info).toHaveBeenCalledWith("");
|
|
76
|
+
} finally {
|
|
77
|
+
info.mockRestore();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Command, ParsedRoute, RouteDef } from "../types";
|
|
2
|
+
|
|
3
|
+
import { quit } from "../lib";
|
|
4
|
+
|
|
5
|
+
function getIsRunnable(obj: any): obj is Command {
|
|
6
|
+
return Object.hasOwn(obj, "run");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toCommand(args: Array<string>) {
|
|
10
|
+
return args.join(" ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getNamespaceHelp(parsedRoutes: Array<ParsedRoute>, namespace: string) {
|
|
14
|
+
const prefix = `${namespace} `;
|
|
15
|
+
const commands = parsedRoutes
|
|
16
|
+
.map((route) => route.command)
|
|
17
|
+
.filter((command) => command.startsWith(prefix));
|
|
18
|
+
|
|
19
|
+
if (commands.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `Usage: zinn ${namespace} <command>\n\nCommands:\n${commands.map((command) => ` ${command}`).join("\n")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseRoutes(routesDef: RouteDef, prefix?: string) {
|
|
27
|
+
const routeDefinitions: Array<ParsedRoute> = [];
|
|
28
|
+
if (process.env.DEBUG) {
|
|
29
|
+
console.debug(`::router.parseRoutes[${prefix ?? ""}]`, routesDef);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const key of Object.keys(routesDef)) {
|
|
33
|
+
const definition = routesDef[key as keyof typeof routesDef]!;
|
|
34
|
+
|
|
35
|
+
const isRunnable = getIsRunnable(definition);
|
|
36
|
+
const combinedRoute = prefix ? `${prefix} ${key}` : key;
|
|
37
|
+
if (isRunnable) {
|
|
38
|
+
routeDefinitions.push({ command: combinedRoute, ...definition });
|
|
39
|
+
} else {
|
|
40
|
+
const result = parseRoutes(definition, combinedRoute);
|
|
41
|
+
routeDefinitions.push(...result);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return routeDefinitions;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function route(parsedRoutes: Array<ParsedRoute>, args: Array<string>) {
|
|
49
|
+
const isHelpRequest = ["-h", "--help"].includes(args.at(-1) ?? "");
|
|
50
|
+
|
|
51
|
+
if (isHelpRequest && args.length > 1) {
|
|
52
|
+
const namespace = toCommand(args.slice(0, -1));
|
|
53
|
+
const namespaceHelp = getNamespaceHelp(parsedRoutes, namespace);
|
|
54
|
+
if (namespaceHelp != null) {
|
|
55
|
+
console.info(namespaceHelp);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let match: ParsedRoute | null = null;
|
|
61
|
+
let nestingLevel = 0;
|
|
62
|
+
|
|
63
|
+
for (let endIndex = args.length; endIndex >= 0; endIndex--) {
|
|
64
|
+
const command = toCommand(args.slice(0, endIndex));
|
|
65
|
+
const matchedRoute = parsedRoutes.find((r) => r.command === command);
|
|
66
|
+
|
|
67
|
+
if (matchedRoute) {
|
|
68
|
+
match = matchedRoute;
|
|
69
|
+
nestingLevel = endIndex;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.env.DEBUG) {
|
|
75
|
+
console.log("::router.route", { args, result: parsedRoutes.map((r) => r.command), match });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (match == null) {
|
|
79
|
+
quit(
|
|
80
|
+
`Unrecognized command "${toCommand(args)}". Please run \`zinn --help\` for a list of available commands`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const commandArgs = args.slice(nestingLevel);
|
|
85
|
+
const isCommandHelpRequest =
|
|
86
|
+
commandArgs.length === 1 && ["-h", "--help"].includes(commandArgs[0]!);
|
|
87
|
+
|
|
88
|
+
if (isCommandHelpRequest) {
|
|
89
|
+
console.info(match.help);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
match.run(commandArgs);
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
quit(
|
|
97
|
+
err?.message ??
|
|
98
|
+
`Something went wrong while running "${match.command}" with args: ${commandArgs.join(",")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createRouter(routesDef: RouteDef) {
|
|
104
|
+
const parsedRoutes = parseRoutes(routesDef);
|
|
105
|
+
|
|
106
|
+
return { route: (args: Array<string>) => route(parsedRoutes, args) };
|
|
107
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { RouteDef } from "../types";
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { task, project, column } from "@zinn-dev/core";
|
|
6
|
+
import { quit } from "../lib";
|
|
7
|
+
|
|
8
|
+
export const taskRouter = {
|
|
9
|
+
edit: {
|
|
10
|
+
run: (args) => {
|
|
11
|
+
const { values, positionals, tokens } = parseArgs({
|
|
12
|
+
args,
|
|
13
|
+
options: { title: { type: "string" }, description: { type: "string" } },
|
|
14
|
+
allowPositionals: true,
|
|
15
|
+
strict: true,
|
|
16
|
+
tokens: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const taskKey = positionals[0];
|
|
20
|
+
|
|
21
|
+
if (taskKey == null) {
|
|
22
|
+
quit("Task key must be specified");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (positionals.length > 1) {
|
|
26
|
+
quit("Only one task key can be specified");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parsedArgs = new Set<string>();
|
|
30
|
+
for (const token of tokens) {
|
|
31
|
+
if (token.kind !== "option") {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (parsedArgs.has(token.name)) {
|
|
36
|
+
quit(`Option "--${token.name}" can only be specified once`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
parsedArgs.add(token.name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (values.title === undefined && values.description === undefined) {
|
|
43
|
+
quit("Provide at least one edit flag: --title or --description");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
task.edit({ taskKey, ...values });
|
|
47
|
+
},
|
|
48
|
+
help: `Usage: zinn task edit <task-key> [--title <text>] [--description <text>]
|
|
49
|
+
|
|
50
|
+
Change the supplied fields and preserve everything else.
|
|
51
|
+
Provide at least one edit flag. Use --description "" for an empty description.
|
|
52
|
+
|
|
53
|
+
Titles cannot be blank.
|
|
54
|
+
Archived tasks can be edited. Unchanged values leave the task unchanged.
|
|
55
|
+
Use --title="--example" for text beginning with a dash.
|
|
56
|
+
|
|
57
|
+
Example: zinn task edit SITE-1 --title "Rewrite the landing page"`,
|
|
58
|
+
},
|
|
59
|
+
create: {
|
|
60
|
+
run: (args: Array<string>) => {
|
|
61
|
+
const projectKey = args[0];
|
|
62
|
+
if (projectKey == null) {
|
|
63
|
+
quit("A task needs to belong to a project");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const taskTitle = args[1];
|
|
67
|
+
if (taskTitle == null) {
|
|
68
|
+
quit("A task needs at least a title");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const taskDesc = args[2];
|
|
72
|
+
|
|
73
|
+
// TODO: do empty strings bypass this check?
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const standardizedKey = project.standardizeKey(projectKey);
|
|
77
|
+
// TODO: `getByKey` already standardizes the key, but to display it in standardized format, I also used it here. Should it run twice on the same thing?
|
|
78
|
+
const projectMatch = project.getByKey(standardizedKey);
|
|
79
|
+
if (projectMatch == null) {
|
|
80
|
+
quit(`Project with key "${standardizedKey}" does not exist`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// TODO: should it non-null (??) or non-falsy (||) check?
|
|
84
|
+
task.create({
|
|
85
|
+
project_id: projectMatch.id,
|
|
86
|
+
title: taskTitle,
|
|
87
|
+
description: taskDesc ?? null,
|
|
88
|
+
});
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
quit(err?.message ?? "Something went wrong");
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
help: `Usage: zinn task create <project-key> <title> [description]
|
|
94
|
+
|
|
95
|
+
Create a task in the project's first column.
|
|
96
|
+
|
|
97
|
+
Example: zinn task create SITE "Rewrite the home page" "Update the product copy"`,
|
|
98
|
+
},
|
|
99
|
+
list: {
|
|
100
|
+
run: (args) => {
|
|
101
|
+
const showsArchived = args.includes("--archived");
|
|
102
|
+
const showsAll = args.includes("--all");
|
|
103
|
+
|
|
104
|
+
if (showsArchived && showsAll) {
|
|
105
|
+
quit('Use either "--archived" or "--all", not both');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const positionalArgs = args.filter((arg) => !["--archived", "--all"].includes(arg));
|
|
109
|
+
const projectKey = positionalArgs[0];
|
|
110
|
+
|
|
111
|
+
if (positionalArgs.length > 1) {
|
|
112
|
+
quit("Only one project key can be specified");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const archive = showsAll ? "all" : showsArchived ? "archived" : "active";
|
|
116
|
+
|
|
117
|
+
const tasks = task.getAll({ projectKey, archive });
|
|
118
|
+
|
|
119
|
+
if (tasks.length === 0) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const presentableTasks = tasks.map((t) => {
|
|
124
|
+
// TODO: A task's project and column is fetched in both `list` and `view`. Make a reusable fetcher?
|
|
125
|
+
const taskProject = project.getById(t.project_id)!;
|
|
126
|
+
const taskColumn = column.getById(t.column_id)!;
|
|
127
|
+
const taskKey = `${taskProject.key}-${t.number}`;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
id: taskKey,
|
|
131
|
+
status: t.archived_at == null ? "Active" : "Archived",
|
|
132
|
+
title: t.title,
|
|
133
|
+
description: t.description,
|
|
134
|
+
column: taskColumn.name,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const longestIdLength = presentableTasks.reduce(
|
|
139
|
+
(prev, current) => Math.max(prev, current.id.length),
|
|
140
|
+
0,
|
|
141
|
+
);
|
|
142
|
+
const longestStatusLength = presentableTasks.reduce(
|
|
143
|
+
(prev, current) => Math.max(prev, current.status.length),
|
|
144
|
+
0,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
console.info(
|
|
148
|
+
presentableTasks
|
|
149
|
+
.map((t) => {
|
|
150
|
+
// TODO: console output formatting for standardizied output
|
|
151
|
+
const status = showsAll ? ` | ${t.status.padEnd(longestStatusLength)}` : "";
|
|
152
|
+
const description = t.description == null ? "" : ` | ${t.description}`;
|
|
153
|
+
return `${t.id.padEnd(longestIdLength)}${status} | ${t.column} | ${t.title}${description}`;
|
|
154
|
+
})
|
|
155
|
+
.join("\n"),
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
help: `Usage: zinn task list [project-key] [--archived | --all]
|
|
159
|
+
|
|
160
|
+
List active tasks by default.
|
|
161
|
+
Use --archived to list archived tasks or --all to list both.
|
|
162
|
+
|
|
163
|
+
Example: zinn task list SITE --all`,
|
|
164
|
+
},
|
|
165
|
+
view: {
|
|
166
|
+
run: (args) => {
|
|
167
|
+
const taskKey = args[0];
|
|
168
|
+
|
|
169
|
+
if (taskKey == null) {
|
|
170
|
+
quit("Task key must be specified");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const taskMatch = task.getByKey(taskKey);
|
|
174
|
+
const taskProject = project.getById(taskMatch.project_id)!;
|
|
175
|
+
const taskColumn = column.getById(taskMatch.column_id)!;
|
|
176
|
+
|
|
177
|
+
const canonicalTaskKey = `${taskProject.key}-${taskMatch.number}`;
|
|
178
|
+
const description = taskMatch.description == null ? "" : ` | ${taskMatch.description}`;
|
|
179
|
+
console.info(`${canonicalTaskKey} | ${taskColumn?.name} | ${taskMatch.title}${description}`);
|
|
180
|
+
},
|
|
181
|
+
help: `Usage: zinn task view <task-key>
|
|
182
|
+
|
|
183
|
+
Show a task with its current column and description.
|
|
184
|
+
|
|
185
|
+
Example: zinn task view SITE-1`,
|
|
186
|
+
},
|
|
187
|
+
move: {
|
|
188
|
+
run: (args) => {
|
|
189
|
+
const [taskKey, targetColumn] = args;
|
|
190
|
+
|
|
191
|
+
if (taskKey == null) {
|
|
192
|
+
quit("Task key must be specified");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (targetColumn == null) {
|
|
196
|
+
quit("Target column must be specified");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
task.move({ taskKey, targetColumn });
|
|
200
|
+
},
|
|
201
|
+
help: `Usage: zinn task move <task-key> <target-column>
|
|
202
|
+
|
|
203
|
+
Move a task to another column in its project.
|
|
204
|
+
Moving a task to a different column lists it last in that column,
|
|
205
|
+
matching placement at the bottom of a visual kanban column.
|
|
206
|
+
|
|
207
|
+
Giving a task's current column as the target will not do anything.
|
|
208
|
+
Archived tasks must be unarchived before they can be moved.
|
|
209
|
+
|
|
210
|
+
Example: zinn task move SITE-1 "In Progress"`,
|
|
211
|
+
},
|
|
212
|
+
order: {
|
|
213
|
+
run: (args) => {
|
|
214
|
+
const [taskKey, direction, targetTaskKey, ...extraArgs] = args;
|
|
215
|
+
|
|
216
|
+
if (taskKey == null) {
|
|
217
|
+
quit("Task key must be specified");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (direction == null) {
|
|
221
|
+
quit("Order direction must be specified");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
switch (direction) {
|
|
225
|
+
case "before":
|
|
226
|
+
case "after": {
|
|
227
|
+
if (targetTaskKey == null) {
|
|
228
|
+
quit(`Target task must be specified for "${direction}"`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (extraArgs.length > 0) {
|
|
232
|
+
quit("Only one target task can be specified");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
task.order({ taskKey, direction, targetTaskKey });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case "top":
|
|
239
|
+
case "up":
|
|
240
|
+
case "down":
|
|
241
|
+
case "bottom": {
|
|
242
|
+
if (targetTaskKey != null) {
|
|
243
|
+
quit(`Order direction "${direction}" does not accept a target task`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
task.order({ taskKey, direction });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
default:
|
|
250
|
+
quit(`Unknown order direction "${direction}"`);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
help: `Usage: zinn task order <task-key> <top | up | down | bottom>
|
|
254
|
+
zinn task order <task-key> <before | after> <target-task-key>
|
|
255
|
+
|
|
256
|
+
Change a task's position within its current column.
|
|
257
|
+
Use top or bottom for either end, up or down for one position,
|
|
258
|
+
or before or after to place it relative to another task.
|
|
259
|
+
|
|
260
|
+
Example: zinn task order SITE-2 before SITE-1`,
|
|
261
|
+
},
|
|
262
|
+
delete: {
|
|
263
|
+
run: (args: Array<string>) => {
|
|
264
|
+
const taskKey = args[0];
|
|
265
|
+
|
|
266
|
+
if (taskKey == null) {
|
|
267
|
+
quit("Task key must be specified");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
task.getByKey(taskKey);
|
|
272
|
+
const canDelete = confirm(`Are you sure you want to delete ${taskKey}?`);
|
|
273
|
+
|
|
274
|
+
if (canDelete) {
|
|
275
|
+
task.delete(taskKey);
|
|
276
|
+
}
|
|
277
|
+
} catch (err: any) {
|
|
278
|
+
quit(err?.message ?? "Something went wrong");
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
help: `Usage: zinn task delete <task-key>
|
|
282
|
+
|
|
283
|
+
Permanently delete a task after confirmation.
|
|
284
|
+
|
|
285
|
+
Example: zinn task delete SITE-1`,
|
|
286
|
+
},
|
|
287
|
+
archive: {
|
|
288
|
+
run: (args: Array<string>) => {
|
|
289
|
+
const taskKey = args[0];
|
|
290
|
+
|
|
291
|
+
if (taskKey == null) {
|
|
292
|
+
quit("Task key must be specified");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
task.archive(taskKey);
|
|
296
|
+
},
|
|
297
|
+
help: `Usage: zinn task archive <task-key>
|
|
298
|
+
|
|
299
|
+
Archive a task so it no longer appears in active task lists.
|
|
300
|
+
|
|
301
|
+
Example: zinn task archive SITE-1`,
|
|
302
|
+
},
|
|
303
|
+
unarchive: {
|
|
304
|
+
run: (args: Array<string>) => {
|
|
305
|
+
const taskKey = args[0];
|
|
306
|
+
|
|
307
|
+
if (taskKey == null) {
|
|
308
|
+
quit("Task key must be specified");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
task.unarchive(taskKey);
|
|
312
|
+
},
|
|
313
|
+
help: `Usage: zinn task unarchive <task-key>
|
|
314
|
+
|
|
315
|
+
Unarchive a task at the bottom of its previous column.
|
|
316
|
+
|
|
317
|
+
Example: zinn task unarchive SITE-1`,
|
|
318
|
+
},
|
|
319
|
+
} satisfies RouteDef;
|