@zinn-dev/cli 0.0.6 → 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 CHANGED
@@ -1,3 +1,129 @@
1
1
  # @zinn-dev/cli
2
2
 
3
- The `zinn` binary. Requires [Bun](https://bun.sh).
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
@@ -1,93 +1,49 @@
1
1
  #!/usr/bin/env bun
2
+ import { quit } from "./src/lib";
2
3
 
3
4
  const args = process.argv.slice(2);
4
- const FLAG = {
5
- help: ["-h", "--help"],
6
- project: ["project"],
7
- create: ["create"],
8
- delete: ["delete"],
9
- };
10
-
11
- const MESSAGE = {
12
- displayUnknownCommand: (cmd: string) =>
13
- `Unrecognized command "${cmd}". Please run \`zinn --help\` for a list of available commands`,
14
- };
15
5
 
16
6
  if (args.length === 0) {
17
7
  if (!!process.stdout.isTTY) {
18
8
  const tui = await import("@zinn-dev/tui");
19
9
  tui.launch();
20
10
  } else {
21
- console.error("Direct launch in a non-TTY environment is not supported.");
22
- process.exit(1);
11
+ quit("Direct launch in a non-TTY environment is not supported.");
23
12
  }
24
13
  } else {
25
14
  // TODO: isTTY check for human readable colored and structured formatting like tables
26
15
  const firstArg = args[0]!;
27
- if (FLAG.help.includes(firstArg)) {
28
- // TODO: document that project keys are always uppercased
29
- console.log(`ZINN - A kanban workflow in the terminal
30
-
31
- usage: zinn [options]
32
- -h, --help For help using Zinn`);
16
+ // TODO: root routing with flags
17
+ if (["-h", "--help"].includes(firstArg)) {
18
+ console.info(`ZINN - A kanban workflow in the terminal
19
+
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.`);
33
43
  } else {
34
- const { createProject, deleteProject } = await import("@zinn-dev/core");
35
-
36
- if (FLAG.project.includes(firstArg)) {
37
- const secondArg = args[1];
38
- if (secondArg == null) {
39
- // TODO: implement `zinn project --help` for better guiding
40
- console.error(
41
- `The command "${firstArg}" requires a secondary command: zinn ${firstArg} <command>`,
42
- );
43
- process.exit(1);
44
- } else if (FLAG.create.includes(secondArg)) {
45
- // TODO: allow direct key value pairs with flags like --name and --key
46
- const projectName = args[2];
47
- // TODO: maybe auto generate project key from name instead of forcing explicit input (however, very open to collision)
48
- const projectKey = args[3];
49
-
50
- if (projectName == null || projectKey == null) {
51
- console.error("Both the project name and the project key must be defined");
52
- process.exit(1);
53
- }
54
-
55
- try {
56
- createProject(projectKey, projectName);
57
- } catch (err) {
58
- if (err instanceof Error) {
59
- console.error(err.message);
60
- } else {
61
- console.error(err);
62
- }
63
- process.exit(1);
64
- }
65
- } else if (FLAG.delete.includes(secondArg)) {
66
- const projectKey = args[2];
67
- if (projectKey == null) {
68
- console.error("You need to specificy which project to delete");
69
- process.exit(1);
70
- }
71
-
72
- try {
73
- // TODO: add y/n confirmation
74
- deleteProject(projectKey);
75
- } catch (err) {
76
- if (err instanceof Error) {
77
- console.error(err.message);
78
- } else {
79
- console.error(err);
80
- }
81
-
82
- process.exit(1);
83
- }
84
- } else {
85
- console.error(MESSAGE.displayUnknownCommand(`${firstArg} ${secondArg}`));
86
- process.exit(1);
87
- }
88
- } else {
89
- console.error(MESSAGE.displayUnknownCommand(firstArg));
90
- process.exit(1);
91
- }
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);
92
48
  }
93
49
  }
package/package.json CHANGED
@@ -1,47 +1,48 @@
1
1
  {
2
- "name": "@zinn-dev/cli",
3
- "version": "0.0.6",
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
- ],
36
- "type": "module",
37
- "bin": {
38
- "zinn": "./index.ts"
39
- },
40
- "publishConfig": {
41
- "access": "public"
42
- },
43
- "dependencies": {
44
- "@zinn-dev/core": "0.0.1",
45
- "@zinn-dev/tui": "0.0.1"
46
- }
47
- }
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
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,4 @@
1
+ export function quit(reason: string): never {
2
+ console.error(reason);
3
+ process.exit(1);
4
+ }
@@ -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,8 @@
1
+ import { projectRouter } from "./project-router";
2
+ import { taskRouter } from "./task-router";
3
+
4
+ // TODO: lazy load?
5
+ export const routes = {
6
+ project: projectRouter,
7
+ task: taskRouter,
8
+ };
@@ -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;
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type Command = { run: (args: Array<string>) => void; help: string };
2
+ export type ParsedRoute = {
3
+ command: string;
4
+ } & Command;
5
+
6
+ export type RouteDef = { [key: string]: Command | RouteDef };