@eunjae/il 1.1.0 → 1.3.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.
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { access, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
2
|
import { Command } from "commander";
|
|
6
|
-
import writeFileAtomic from "write-file-atomic";
|
|
7
3
|
import { z } from "zod";
|
|
4
|
+
import { access, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
8
5
|
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import writeFileAtomic from "write-file-atomic";
|
|
8
|
+
import lockfile from "proper-lockfile";
|
|
9
9
|
import { Octokit } from "octokit";
|
|
10
10
|
import { DateTime } from "luxon";
|
|
11
|
-
import lockfile from "proper-lockfile";
|
|
12
11
|
import { createHash } from "node:crypto";
|
|
13
12
|
import { ulid } from "ulid";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
//#region lib/constants.ts
|
|
16
|
+
const APP_NAME = "il";
|
|
17
|
+
const APP_DIR = `.${APP_NAME}`;
|
|
18
|
+
const LOCK_DIR = ".lock";
|
|
19
|
+
const LOCK_FILE = "store.lock";
|
|
20
|
+
const TASKS_DIR = "tasks";
|
|
21
|
+
const STATUS_ORDER = [
|
|
22
|
+
"backlog",
|
|
23
|
+
"active",
|
|
24
|
+
"paused",
|
|
25
|
+
"completed",
|
|
26
|
+
"cancelled"
|
|
27
|
+
];
|
|
14
28
|
|
|
29
|
+
//#endregion
|
|
15
30
|
//#region lib/json.ts
|
|
16
31
|
const readJsonFile = async (filePath) => {
|
|
17
32
|
const raw = await readFile(filePath, "utf8");
|
|
@@ -25,12 +40,7 @@ const writeJsonAtomic = async (filePath, data) => {
|
|
|
25
40
|
};
|
|
26
41
|
|
|
27
42
|
//#endregion
|
|
28
|
-
//#region lib/
|
|
29
|
-
const emptyAliases = () => ({
|
|
30
|
-
T: {},
|
|
31
|
-
R: {}
|
|
32
|
-
});
|
|
33
|
-
const aliasFilePath = (workspaceRoot) => path.join(workspaceRoot, "aliases.json");
|
|
43
|
+
//#region lib/config.ts
|
|
34
44
|
const fileExists$2 = async (filePath) => {
|
|
35
45
|
try {
|
|
36
46
|
await access(filePath);
|
|
@@ -39,62 +49,194 @@ const fileExists$2 = async (filePath) => {
|
|
|
39
49
|
return false;
|
|
40
50
|
}
|
|
41
51
|
};
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
const resolveGlobalConfigPath = () => {
|
|
53
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
54
|
+
return path.join(configHome, APP_NAME, "config.json");
|
|
55
|
+
};
|
|
56
|
+
const readConfigFile = async (filePath) => {
|
|
57
|
+
if (!await fileExists$2(filePath)) return {};
|
|
58
|
+
return await readJsonFile(filePath) ?? {};
|
|
59
|
+
};
|
|
60
|
+
const resolveGithubToken = async (workspaceRoot) => {
|
|
61
|
+
if (process.env.IL_GITHUB_TOKEN) return process.env.IL_GITHUB_TOKEN;
|
|
62
|
+
const workspaceConfig = await readConfigFile(path.join(workspaceRoot, "config.json"));
|
|
63
|
+
if (workspaceConfig.github?.token) return workspaceConfig.github.token;
|
|
64
|
+
return (await readConfigFile(resolveGlobalConfigPath())).github?.token ?? null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region lib/workspace.ts
|
|
69
|
+
const exists = async (filePath) => {
|
|
70
|
+
try {
|
|
71
|
+
await access(filePath);
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const findGitRoot = async (startDir) => {
|
|
78
|
+
let current = path.resolve(startDir);
|
|
79
|
+
while (true) {
|
|
80
|
+
if (await exists(path.join(current, ".git"))) return current;
|
|
81
|
+
const parent = path.dirname(current);
|
|
82
|
+
if (parent === current) return null;
|
|
83
|
+
current = parent;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const resolveGlobalWorkspaceRoot = () => {
|
|
87
|
+
const dataHome = process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share");
|
|
88
|
+
return path.join(dataHome, APP_NAME);
|
|
89
|
+
};
|
|
90
|
+
const resolveWorkspace = async (options) => {
|
|
91
|
+
if (options.store) return {
|
|
92
|
+
root: path.resolve(options.store),
|
|
93
|
+
kind: "explicit"
|
|
94
|
+
};
|
|
95
|
+
const repoRoot = await findGitRoot(options.cwd ?? process.cwd());
|
|
96
|
+
if (options.repo) {
|
|
97
|
+
if (!repoRoot) throw new Error("Not inside a git repository");
|
|
98
|
+
return {
|
|
99
|
+
root: path.join(repoRoot, APP_DIR),
|
|
100
|
+
kind: "repo"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (options.global) return {
|
|
104
|
+
root: resolveGlobalWorkspaceRoot(),
|
|
105
|
+
kind: "global"
|
|
106
|
+
};
|
|
107
|
+
if (repoRoot) {
|
|
108
|
+
const repoWorkspace = path.join(repoRoot, APP_DIR);
|
|
109
|
+
if (await exists(repoWorkspace)) return {
|
|
110
|
+
root: repoWorkspace,
|
|
111
|
+
kind: "repo"
|
|
112
|
+
};
|
|
113
|
+
}
|
|
46
114
|
return {
|
|
47
|
-
|
|
48
|
-
|
|
115
|
+
root: resolveGlobalWorkspaceRoot(),
|
|
116
|
+
kind: "global"
|
|
49
117
|
};
|
|
50
118
|
};
|
|
51
|
-
const
|
|
52
|
-
await
|
|
119
|
+
const ensureWorkspaceLayout = async (workspaceRoot) => {
|
|
120
|
+
await mkdir(path.join(workspaceRoot, TASKS_DIR), { recursive: true });
|
|
121
|
+
await mkdir(path.join(workspaceRoot, LOCK_DIR), { recursive: true });
|
|
122
|
+
await Promise.all(STATUS_ORDER.map((status) => mkdir(path.join(workspaceRoot, TASKS_DIR, status), { recursive: true })));
|
|
53
123
|
};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region lib/lock.ts
|
|
127
|
+
const withWorkspaceLock = async (workspaceRoot, fn) => {
|
|
128
|
+
await ensureWorkspaceLayout(workspaceRoot);
|
|
129
|
+
const lockPath = path.join(workspaceRoot, LOCK_DIR, LOCK_FILE);
|
|
130
|
+
await writeFile(lockPath, "", { flag: "a" });
|
|
131
|
+
const release = await lockfile.lock(lockPath, {
|
|
132
|
+
stale: 6e4,
|
|
133
|
+
retries: {
|
|
134
|
+
retries: 5,
|
|
135
|
+
factor: 1.5,
|
|
136
|
+
minTimeout: 50,
|
|
137
|
+
maxTimeout: 1e3
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
return await fn();
|
|
142
|
+
} finally {
|
|
143
|
+
await release();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region lib/time.ts
|
|
149
|
+
const DEFAULT_ZONE = "Europe/Paris";
|
|
150
|
+
const toIsoDate = (value, label) => {
|
|
151
|
+
const date = value.toISODate();
|
|
152
|
+
if (!date) throw new Error(`Failed to generate ${label} date`);
|
|
153
|
+
return date;
|
|
154
|
+
};
|
|
155
|
+
const buildDateRange = (start, end) => {
|
|
156
|
+
const dates = [];
|
|
157
|
+
let cursor = start.startOf("day");
|
|
158
|
+
const final = end.startOf("day");
|
|
159
|
+
while (cursor <= final) {
|
|
160
|
+
dates.push(toIsoDate(cursor, "range"));
|
|
161
|
+
cursor = cursor.plus({ days: 1 });
|
|
162
|
+
}
|
|
163
|
+
return dates;
|
|
164
|
+
};
|
|
165
|
+
const nowIso = () => {
|
|
166
|
+
const value = DateTime.now().setZone(DEFAULT_ZONE).toISO();
|
|
167
|
+
if (!value) throw new Error("Failed to generate timestamp");
|
|
168
|
+
return value;
|
|
169
|
+
};
|
|
170
|
+
const todayDate = (date) => {
|
|
171
|
+
const value = date ? DateTime.fromISO(date, { zone: DEFAULT_ZONE }).toISODate() : DateTime.now().setZone(DEFAULT_ZONE).toISODate();
|
|
172
|
+
if (!value) throw new Error("Failed to generate date");
|
|
173
|
+
return value;
|
|
174
|
+
};
|
|
175
|
+
const listDatesForScope = (scope) => {
|
|
176
|
+
const now = DateTime.now().setZone(DEFAULT_ZONE);
|
|
177
|
+
switch (scope) {
|
|
178
|
+
case "today": return [toIsoDate(now, "today")];
|
|
179
|
+
case "yesterday": return [toIsoDate(now.minus({ days: 1 }), "yesterday")];
|
|
180
|
+
case "this_week": return buildDateRange(now.startOf("week"), now.endOf("week"));
|
|
181
|
+
case "last_week": {
|
|
182
|
+
const lastWeek = now.minus({ weeks: 1 });
|
|
183
|
+
return buildDateRange(lastWeek.startOf("week"), lastWeek.endOf("week"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region lib/pr.ts
|
|
190
|
+
const parseGitHubPrUrl = (value) => {
|
|
191
|
+
let url;
|
|
192
|
+
try {
|
|
193
|
+
url = new URL(value);
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
198
|
+
if (parts.length < 4 || parts[2] !== "pull") return null;
|
|
199
|
+
const number = Number(parts[3]);
|
|
200
|
+
if (!Number.isInteger(number)) return null;
|
|
57
201
|
return {
|
|
58
|
-
|
|
59
|
-
|
|
202
|
+
url: value,
|
|
203
|
+
provider: "github",
|
|
204
|
+
repo: {
|
|
205
|
+
host: url.host,
|
|
206
|
+
owner: parts[0],
|
|
207
|
+
name: parts[1]
|
|
208
|
+
},
|
|
209
|
+
number
|
|
60
210
|
};
|
|
61
211
|
};
|
|
62
|
-
const
|
|
63
|
-
const allocateAliasInMap = (aliases, prefix, taskId) => {
|
|
64
|
-
const used = new Set(Object.keys(aliases[prefix]).map((key) => Number.parseInt(key, 10)));
|
|
65
|
-
let next = 1;
|
|
66
|
-
while (used.has(next)) next += 1;
|
|
67
|
-
const key = formatAliasKey(next);
|
|
68
|
-
aliases[prefix][key] = taskId;
|
|
212
|
+
const buildPrAttachment = (parsed, fetched) => {
|
|
69
213
|
return {
|
|
70
|
-
|
|
71
|
-
|
|
214
|
+
url: parsed.url,
|
|
215
|
+
provider: "github",
|
|
216
|
+
repo: parsed.repo,
|
|
217
|
+
number: parsed.number,
|
|
218
|
+
fetched
|
|
72
219
|
};
|
|
73
220
|
};
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
221
|
+
const fetchGitHubPr = async (parsed, token) => {
|
|
222
|
+
if (!parsed.repo || !parsed.number) return null;
|
|
223
|
+
if (!token) return null;
|
|
224
|
+
const data = (await new Octokit({ auth: token }).rest.pulls.get({
|
|
225
|
+
owner: parsed.repo.owner,
|
|
226
|
+
repo: parsed.repo.name,
|
|
227
|
+
pull_number: parsed.number
|
|
228
|
+
})).data;
|
|
229
|
+
const state = data.merged ? "merged" : data.state === "open" ? "open" : "closed";
|
|
230
|
+
return {
|
|
231
|
+
at: nowIso(),
|
|
232
|
+
title: data.title,
|
|
233
|
+
author: { login: data.user?.login ?? "unknown" },
|
|
234
|
+
state,
|
|
235
|
+
draft: data.draft ?? false,
|
|
236
|
+
updated_at: data.updated_at
|
|
237
|
+
};
|
|
81
238
|
};
|
|
82
239
|
|
|
83
|
-
//#endregion
|
|
84
|
-
//#region lib/constants.ts
|
|
85
|
-
const APP_NAME = "il";
|
|
86
|
-
const APP_DIR = `.${APP_NAME}`;
|
|
87
|
-
const LOCK_DIR = ".lock";
|
|
88
|
-
const LOCK_FILE = "store.lock";
|
|
89
|
-
const TASKS_DIR = "tasks";
|
|
90
|
-
const STATUS_ORDER = [
|
|
91
|
-
"backlog",
|
|
92
|
-
"active",
|
|
93
|
-
"paused",
|
|
94
|
-
"completed",
|
|
95
|
-
"cancelled"
|
|
96
|
-
];
|
|
97
|
-
|
|
98
240
|
//#endregion
|
|
99
241
|
//#region lib/types.ts
|
|
100
242
|
const taskStatuses = [
|
|
@@ -107,18 +249,29 @@ const taskStatuses = [
|
|
|
107
249
|
const taskTypes = ["regular", "pr_review"];
|
|
108
250
|
|
|
109
251
|
//#endregion
|
|
110
|
-
//#region lib/
|
|
111
|
-
const logEntrySchema = z.object({
|
|
112
|
-
ts: z.string(),
|
|
113
|
-
msg: z.string(),
|
|
114
|
-
status: z.enum(taskStatuses).optional()
|
|
115
|
-
});
|
|
252
|
+
//#region lib/schemas/dayAssignment.ts
|
|
116
253
|
const dayAssignmentSchema = z.object({
|
|
117
254
|
date: z.string(),
|
|
118
|
-
action: z.enum([
|
|
255
|
+
action: z.enum([
|
|
256
|
+
"assign",
|
|
257
|
+
"unassign",
|
|
258
|
+
"reorder"
|
|
259
|
+
]),
|
|
119
260
|
ts: z.string(),
|
|
261
|
+
order: z.number().int().positive().optional(),
|
|
120
262
|
msg: z.string().optional()
|
|
121
263
|
});
|
|
264
|
+
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region lib/schemas/logEntry.ts
|
|
267
|
+
const logEntrySchema = z.object({
|
|
268
|
+
ts: z.string(),
|
|
269
|
+
msg: z.string(),
|
|
270
|
+
status: z.enum(taskStatuses).optional()
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region lib/schemas/pr.ts
|
|
122
275
|
const prRepoSchema = z.object({
|
|
123
276
|
host: z.string(),
|
|
124
277
|
owner: z.string(),
|
|
@@ -143,6 +296,9 @@ const prAttachmentSchema = z.object({
|
|
|
143
296
|
number: z.number().int().positive().optional(),
|
|
144
297
|
fetched: prFetchedSchema.optional()
|
|
145
298
|
});
|
|
299
|
+
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region lib/schemas/task.ts
|
|
146
302
|
const taskMetadataSchema = z.object({
|
|
147
303
|
url: z.string().optional(),
|
|
148
304
|
pr: prAttachmentSchema.optional()
|
|
@@ -173,6 +329,107 @@ const validateTaskOrThrow = (task) => {
|
|
|
173
329
|
return taskSchema.parse(task);
|
|
174
330
|
};
|
|
175
331
|
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region lib/id.ts
|
|
334
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
335
|
+
const base32Encode = (input) => {
|
|
336
|
+
let bits = 0;
|
|
337
|
+
let value = 0;
|
|
338
|
+
let output = "";
|
|
339
|
+
for (const byte of input) {
|
|
340
|
+
value = value << 8 | byte;
|
|
341
|
+
bits += 8;
|
|
342
|
+
while (bits >= 5) {
|
|
343
|
+
output += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
344
|
+
bits -= 5;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (bits > 0) output += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
348
|
+
return output;
|
|
349
|
+
};
|
|
350
|
+
const generateTaskId = (taskType) => {
|
|
351
|
+
return `${taskType === "pr_review" ? "R" : "T"}${ulid()}`;
|
|
352
|
+
};
|
|
353
|
+
const generateStableRef = (taskType, taskId) => {
|
|
354
|
+
return `${taskType === "pr_review" ? "R" : "T"}-${base32Encode(createHash("sha256").update(taskId).digest()).slice(0, 6)}`;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region lib/taskFactory.ts
|
|
359
|
+
const buildTask = (input) => {
|
|
360
|
+
const id = generateTaskId(input.type);
|
|
361
|
+
const ref = generateStableRef(input.type, id);
|
|
362
|
+
const timestamp = nowIso();
|
|
363
|
+
return {
|
|
364
|
+
id,
|
|
365
|
+
ref,
|
|
366
|
+
type: input.type,
|
|
367
|
+
title: input.title,
|
|
368
|
+
status: "backlog",
|
|
369
|
+
created_at: timestamp,
|
|
370
|
+
updated_at: timestamp,
|
|
371
|
+
metadata: input.metadata ?? {},
|
|
372
|
+
logs: [],
|
|
373
|
+
day_assignments: []
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region lib/aliasRepo.ts
|
|
379
|
+
const emptyAliases = () => ({
|
|
380
|
+
T: {},
|
|
381
|
+
R: {}
|
|
382
|
+
});
|
|
383
|
+
const aliasFilePath = (workspaceRoot) => path.join(workspaceRoot, "aliases.json");
|
|
384
|
+
const fileExists$1 = async (filePath) => {
|
|
385
|
+
try {
|
|
386
|
+
await access(filePath);
|
|
387
|
+
return true;
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const readAliases = async (workspaceRoot) => {
|
|
393
|
+
const filePath = aliasFilePath(workspaceRoot);
|
|
394
|
+
if (!await fileExists$1(filePath)) return emptyAliases();
|
|
395
|
+
const data = await readJsonFile(filePath);
|
|
396
|
+
return {
|
|
397
|
+
T: data.T ?? {},
|
|
398
|
+
R: data.R ?? {}
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
const writeAliases = async (workspaceRoot, aliases) => {
|
|
402
|
+
await writeJsonAtomic(aliasFilePath(workspaceRoot), aliases);
|
|
403
|
+
};
|
|
404
|
+
const parseAlias = (alias) => {
|
|
405
|
+
const match = alias.match(/^([TR])(\d+)$/);
|
|
406
|
+
if (!match) return null;
|
|
407
|
+
return {
|
|
408
|
+
prefix: match[1],
|
|
409
|
+
key: match[2]
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
const formatAliasKey = (value) => String(value).padStart(2, "0");
|
|
413
|
+
const allocateAliasInMap = (aliases, prefix, taskId) => {
|
|
414
|
+
const used = new Set(Object.keys(aliases[prefix]).map((key) => Number.parseInt(key, 10)));
|
|
415
|
+
let next = 1;
|
|
416
|
+
while (used.has(next)) next += 1;
|
|
417
|
+
const key = formatAliasKey(next);
|
|
418
|
+
aliases[prefix][key] = taskId;
|
|
419
|
+
return {
|
|
420
|
+
alias: `${prefix}${key}`,
|
|
421
|
+
aliases
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
const allocateAlias = async (workspaceRoot, prefix, taskId) => {
|
|
425
|
+
return allocateAliasInMap(await readAliases(workspaceRoot), prefix, taskId);
|
|
426
|
+
};
|
|
427
|
+
const resolveAlias = async (workspaceRoot, alias) => {
|
|
428
|
+
const parsed = parseAlias(alias);
|
|
429
|
+
if (!parsed) return null;
|
|
430
|
+
return (await readAliases(workspaceRoot))[parsed.prefix][parsed.key] ?? null;
|
|
431
|
+
};
|
|
432
|
+
|
|
176
433
|
//#endregion
|
|
177
434
|
//#region lib/taskRepo.ts
|
|
178
435
|
const taskFileName = (taskId) => `${taskId}.json`;
|
|
@@ -180,7 +437,7 @@ const getStatusDir = (workspaceRoot, status) => path.join(workspaceRoot, TASKS_D
|
|
|
180
437
|
const getTaskPath = (workspaceRoot, status, taskId) => {
|
|
181
438
|
return path.join(getStatusDir(workspaceRoot, status), taskFileName(taskId));
|
|
182
439
|
};
|
|
183
|
-
const fileExists
|
|
440
|
+
const fileExists = async (filePath) => {
|
|
184
441
|
try {
|
|
185
442
|
await access(filePath);
|
|
186
443
|
return true;
|
|
@@ -191,7 +448,7 @@ const fileExists$1 = async (filePath) => {
|
|
|
191
448
|
const findTaskPathById = async (workspaceRoot, taskId) => {
|
|
192
449
|
for (const status of STATUS_ORDER) {
|
|
193
450
|
const candidate = getTaskPath(workspaceRoot, status, taskId);
|
|
194
|
-
if (await fileExists
|
|
451
|
+
if (await fileExists(candidate)) return {
|
|
195
452
|
filePath: candidate,
|
|
196
453
|
status
|
|
197
454
|
};
|
|
@@ -230,141 +487,145 @@ const moveTaskFile = async (workspaceRoot, taskId, fromStatus, toStatus) => {
|
|
|
230
487
|
};
|
|
231
488
|
|
|
232
489
|
//#endregion
|
|
233
|
-
//#region lib/
|
|
234
|
-
const
|
|
235
|
-
const aliases = await
|
|
236
|
-
const tasks = await listAllTasks(workspaceRoot);
|
|
237
|
-
const tasksById = new Map(tasks.map((stored) => [stored.task.id, stored.task]));
|
|
238
|
-
const normalizePrefix = (prefix) => {
|
|
239
|
-
const next = {};
|
|
240
|
-
for (const [key, value] of Object.entries(aliases[prefix])) if (tasksById.has(value)) next[key] = value;
|
|
241
|
-
aliases[prefix] = next;
|
|
242
|
-
};
|
|
243
|
-
normalizePrefix("T");
|
|
244
|
-
normalizePrefix("R");
|
|
245
|
-
const existingIds = new Set([...Object.values(aliases.T), ...Object.values(aliases.R)]);
|
|
246
|
-
const missing = tasks.filter((stored) => !existingIds.has(stored.task.id)).sort((a, b) => a.task.created_at.localeCompare(b.task.created_at));
|
|
247
|
-
for (const stored of missing) allocateAliasInMap(aliases, stored.task.type === "pr_review" ? "R" : "T", stored.task.id);
|
|
490
|
+
//#region lib/taskStore.ts
|
|
491
|
+
const createTaskInStore = async (workspaceRoot, task) => {
|
|
492
|
+
const { alias, aliases } = await allocateAlias(workspaceRoot, task.type === "pr_review" ? "R" : "T", task.id);
|
|
248
493
|
await writeAliases(workspaceRoot, aliases);
|
|
249
|
-
|
|
494
|
+
await saveTask(workspaceRoot, task.status, task);
|
|
495
|
+
return {
|
|
496
|
+
alias,
|
|
497
|
+
task
|
|
498
|
+
};
|
|
250
499
|
};
|
|
251
500
|
|
|
252
501
|
//#endregion
|
|
253
|
-
//#region lib/
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
502
|
+
//#region lib/addTask.ts
|
|
503
|
+
var AddTaskError = class extends Error {
|
|
504
|
+
code;
|
|
505
|
+
constructor(code, message) {
|
|
506
|
+
super(message);
|
|
507
|
+
this.code = code;
|
|
508
|
+
this.name = "AddTaskError";
|
|
260
509
|
}
|
|
261
510
|
};
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
511
|
+
const addTaskInputSchema = z.object({
|
|
512
|
+
type: z.enum(taskTypes),
|
|
513
|
+
title: z.string().optional(),
|
|
514
|
+
url: z.string().optional(),
|
|
515
|
+
prUrl: z.string().optional(),
|
|
516
|
+
workspaceRoot: z.string()
|
|
517
|
+
});
|
|
518
|
+
const addTask = async (input) => {
|
|
519
|
+
const parsedInput = addTaskInputSchema.parse(input);
|
|
520
|
+
return withWorkspaceLock(parsedInput.workspaceRoot, async () => {
|
|
521
|
+
let prAttachment;
|
|
522
|
+
let prTitle;
|
|
523
|
+
if (parsedInput.type === "pr_review" && !parsedInput.prUrl) throw new AddTaskError("MISSING_PR_URL", "PR review tasks require PR URL");
|
|
524
|
+
if (parsedInput.prUrl) {
|
|
525
|
+
const parsed = parseGitHubPrUrl(parsedInput.prUrl);
|
|
526
|
+
if (!parsed) throw new AddTaskError("INVALID_PR_URL", "Invalid PR URL");
|
|
527
|
+
const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(parsedInput.workspaceRoot));
|
|
528
|
+
prAttachment = buildPrAttachment(parsed, fetched ?? void 0);
|
|
529
|
+
prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
|
|
530
|
+
}
|
|
531
|
+
const isExplicitTitle = Boolean(parsedInput.title);
|
|
532
|
+
let finalTitle = parsedInput.title ?? prTitle;
|
|
533
|
+
if (!finalTitle) throw new AddTaskError("MISSING_TITLE", "Title is required when no PR URL is provided");
|
|
534
|
+
if (parsedInput.type === "pr_review" && !isExplicitTitle) finalTitle = `Review: ${finalTitle}`;
|
|
535
|
+
const metadata = {
|
|
536
|
+
url: parsedInput.url,
|
|
537
|
+
pr: prAttachment
|
|
538
|
+
};
|
|
539
|
+
const task = buildTask({
|
|
540
|
+
type: parsedInput.type,
|
|
541
|
+
title: finalTitle,
|
|
542
|
+
metadata
|
|
543
|
+
});
|
|
544
|
+
taskSchema.parse(task);
|
|
545
|
+
return createTaskInStore(parsedInput.workspaceRoot, task);
|
|
546
|
+
});
|
|
275
547
|
};
|
|
276
548
|
|
|
277
549
|
//#endregion
|
|
278
|
-
//#region
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const now = DateTime.now().setZone(DEFAULT_ZONE);
|
|
307
|
-
switch (scope) {
|
|
308
|
-
case "today": return [toIsoDate(now, "today")];
|
|
309
|
-
case "yesterday": return [toIsoDate(now.minus({ days: 1 }), "yesterday")];
|
|
310
|
-
case "this_week": return buildDateRange(now.startOf("week"), now.endOf("week"));
|
|
311
|
-
case "last_week": {
|
|
312
|
-
const lastWeek = now.minus({ weeks: 1 });
|
|
313
|
-
return buildDateRange(lastWeek.startOf("week"), lastWeek.endOf("week"));
|
|
550
|
+
//#region cli/schemas/addTask.ts
|
|
551
|
+
const addTaskCliSchema = z.object({
|
|
552
|
+
title: z.string().optional(),
|
|
553
|
+
type: z.enum(taskTypes),
|
|
554
|
+
url: z.string().optional(),
|
|
555
|
+
pr: z.string().optional()
|
|
556
|
+
}).superRefine((data, ctx) => {
|
|
557
|
+
if (data.type === "pr_review" && !data.pr) ctx.addIssue({
|
|
558
|
+
code: z.ZodIssueCode.custom,
|
|
559
|
+
message: "PR review tasks require --pr",
|
|
560
|
+
path: ["pr"]
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region cli/commands/add.ts
|
|
566
|
+
const registerAddCommand = (program, helpers) => {
|
|
567
|
+
program.command("add").description("add a task").argument("[title]", "task title").option("--type <type>", "regular|pr_review", "regular").option("--url <url>", "attach URL").option("--pr <url>", "attach PR URL").action(helpers.handleAction(async (title, options, command) => {
|
|
568
|
+
const parsed = addTaskCliSchema.safeParse({
|
|
569
|
+
title,
|
|
570
|
+
type: options.type ?? "regular",
|
|
571
|
+
url: options.url,
|
|
572
|
+
pr: options.pr
|
|
573
|
+
});
|
|
574
|
+
if (!parsed.success) {
|
|
575
|
+
const message = parsed.error.errors.map((issue) => issue.message).join(", ");
|
|
576
|
+
helpers.failWithHelp(command, message);
|
|
577
|
+
return;
|
|
314
578
|
}
|
|
315
|
-
|
|
579
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
580
|
+
try {
|
|
581
|
+
const created = await addTask({
|
|
582
|
+
type: parsed.data.type,
|
|
583
|
+
title: parsed.data.title,
|
|
584
|
+
url: parsed.data.url,
|
|
585
|
+
prUrl: parsed.data.pr,
|
|
586
|
+
workspaceRoot
|
|
587
|
+
});
|
|
588
|
+
process.stdout.write(`Created ${created.alias} ${created.task.ref} ${created.task.title}\n`);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error instanceof AddTaskError) {
|
|
591
|
+
helpers.failWithHelp(command, error.message);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
}));
|
|
316
597
|
};
|
|
317
598
|
|
|
318
599
|
//#endregion
|
|
319
|
-
//#region lib/
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (parts.length < 4 || parts[2] !== "pull") return null;
|
|
329
|
-
const number = Number(parts[3]);
|
|
330
|
-
if (!Number.isInteger(number)) return null;
|
|
331
|
-
return {
|
|
332
|
-
url: value,
|
|
333
|
-
provider: "github",
|
|
334
|
-
repo: {
|
|
335
|
-
host: url.host,
|
|
336
|
-
owner: parts[0],
|
|
337
|
-
name: parts[1]
|
|
338
|
-
},
|
|
339
|
-
number
|
|
340
|
-
};
|
|
341
|
-
};
|
|
342
|
-
const buildPrAttachment = (parsed, fetched) => {
|
|
343
|
-
return {
|
|
344
|
-
url: parsed.url,
|
|
345
|
-
provider: "github",
|
|
346
|
-
repo: parsed.repo,
|
|
347
|
-
number: parsed.number,
|
|
348
|
-
fetched
|
|
600
|
+
//#region lib/aliasReconcile.ts
|
|
601
|
+
const reconcileAliases = async (workspaceRoot) => {
|
|
602
|
+
const aliases = await readAliases(workspaceRoot);
|
|
603
|
+
const tasks = await listAllTasks(workspaceRoot);
|
|
604
|
+
const tasksById = new Map(tasks.map((stored) => [stored.task.id, stored.task]));
|
|
605
|
+
const normalizePrefix = (prefix) => {
|
|
606
|
+
const next = {};
|
|
607
|
+
for (const [key, value] of Object.entries(aliases[prefix])) if (tasksById.has(value)) next[key] = value;
|
|
608
|
+
aliases[prefix] = next;
|
|
349
609
|
};
|
|
610
|
+
normalizePrefix("T");
|
|
611
|
+
normalizePrefix("R");
|
|
612
|
+
const existingIds = new Set([...Object.values(aliases.T), ...Object.values(aliases.R)]);
|
|
613
|
+
const missing = tasks.filter((stored) => !existingIds.has(stored.task.id)).sort((a, b) => a.task.created_at.localeCompare(b.task.created_at));
|
|
614
|
+
for (const stored of missing) allocateAliasInMap(aliases, stored.task.type === "pr_review" ? "R" : "T", stored.task.id);
|
|
615
|
+
await writeAliases(workspaceRoot, aliases);
|
|
616
|
+
return { updated: true };
|
|
350
617
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
title: data.title,
|
|
363
|
-
author: { login: data.user?.login ?? "unknown" },
|
|
364
|
-
state,
|
|
365
|
-
draft: data.draft ?? false,
|
|
366
|
-
updated_at: data.updated_at
|
|
367
|
-
};
|
|
618
|
+
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region cli/commands/alias.ts
|
|
621
|
+
const registerAliasCommand = (program, helpers) => {
|
|
622
|
+
program.command("alias").description("alias helpers").command("reconcile").description("reconcile alias mapping").action(helpers.handleAction(async () => {
|
|
623
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
624
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
625
|
+
await reconcileAliases(workspaceRoot);
|
|
626
|
+
process.stdout.write("Aliases reconciled.\n");
|
|
627
|
+
});
|
|
628
|
+
}));
|
|
368
629
|
};
|
|
369
630
|
|
|
370
631
|
//#endregion
|
|
@@ -403,51 +664,41 @@ const applyTaskEdit = (task, dottedPath, rawValue) => {
|
|
|
403
664
|
};
|
|
404
665
|
|
|
405
666
|
//#endregion
|
|
406
|
-
//#region lib/
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
667
|
+
//#region lib/taskResolver.ts
|
|
668
|
+
const stableRefPattern = /^[TR]-[A-Z0-9]{6}$/;
|
|
669
|
+
const resolveTaskId = async (workspaceRoot, identifier) => {
|
|
670
|
+
const aliasMatch = await resolveAlias(workspaceRoot, identifier);
|
|
671
|
+
if (aliasMatch) return aliasMatch;
|
|
672
|
+
if (stableRefPattern.test(identifier)) {
|
|
673
|
+
const match = (await listAllTasks(workspaceRoot)).find((stored) => stored.task.ref === identifier);
|
|
674
|
+
if (!match) throw new Error(`Task not found for ref: ${identifier}`);
|
|
675
|
+
return match.task.id;
|
|
676
|
+
}
|
|
677
|
+
if (!await findTaskPathById(workspaceRoot, identifier)) throw new Error(`Task not found: ${identifier}`);
|
|
678
|
+
return identifier;
|
|
412
679
|
};
|
|
413
|
-
const
|
|
414
|
-
|
|
680
|
+
const resolveTask = async (workspaceRoot, identifier) => {
|
|
681
|
+
const found = await findTaskPathById(workspaceRoot, await resolveTaskId(workspaceRoot, identifier));
|
|
682
|
+
if (!found) throw new Error(`Task not found: ${identifier}`);
|
|
683
|
+
return {
|
|
684
|
+
task: await loadTaskFromPath(found.filePath),
|
|
685
|
+
status: found.status,
|
|
686
|
+
filePath: found.filePath
|
|
687
|
+
};
|
|
415
688
|
};
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
lines.push(`pr_state: ${fetched.state}`);
|
|
430
|
-
lines.push(`pr_title: ${fetched.title}`);
|
|
431
|
-
lines.push(`pr_author: ${fetched.author.login}`);
|
|
432
|
-
lines.push(`pr_updated_at: ${fetched.updated_at}`);
|
|
433
|
-
lines.push(`pr_refreshed_at: ${fetched.at}`);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
if (task.logs.length > 0) {
|
|
437
|
-
lines.push("logs:");
|
|
438
|
-
for (const log of task.logs) {
|
|
439
|
-
const status = log.status ? ` [${log.status}]` : "";
|
|
440
|
-
lines.push(`- ${log.ts}${status} ${log.msg}`);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
if (task.day_assignments.length > 0) {
|
|
444
|
-
lines.push("day_assignments:");
|
|
445
|
-
for (const entry of task.day_assignments) {
|
|
446
|
-
const msg = entry.msg ? ` (${entry.msg})` : "";
|
|
447
|
-
lines.push(`- ${entry.date} ${entry.action} ${entry.ts}${msg}`);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return lines.join("\n");
|
|
689
|
+
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region cli/commands/edit.ts
|
|
692
|
+
const registerEditCommand = (program, helpers) => {
|
|
693
|
+
program.command("edit").description("edit a task field").argument("<id>", "task identifier").argument("<path>", "dotted path").argument("<value>", "new value").action(helpers.handleAction(async (identifier, dottedPath, value) => {
|
|
694
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
695
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
696
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
697
|
+
const updated = applyTaskEdit(stored.task, dottedPath, value);
|
|
698
|
+
await saveTask(workspaceRoot, stored.status, updated);
|
|
699
|
+
process.stdout.write(`Updated ${updated.ref}\n`);
|
|
700
|
+
});
|
|
701
|
+
}));
|
|
451
702
|
};
|
|
452
703
|
|
|
453
704
|
//#endregion
|
|
@@ -468,140 +719,65 @@ const ensureLockIgnored = async (repoRoot) => {
|
|
|
468
719
|
};
|
|
469
720
|
|
|
470
721
|
//#endregion
|
|
471
|
-
//#region
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
await
|
|
475
|
-
return true;
|
|
476
|
-
} catch {
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
479
|
-
};
|
|
480
|
-
const findGitRoot = async (startDir) => {
|
|
481
|
-
let current = path.resolve(startDir);
|
|
482
|
-
while (true) {
|
|
483
|
-
if (await exists(path.join(current, ".git"))) return current;
|
|
484
|
-
const parent = path.dirname(current);
|
|
485
|
-
if (parent === current) return null;
|
|
486
|
-
current = parent;
|
|
487
|
-
}
|
|
488
|
-
};
|
|
489
|
-
const resolveGlobalWorkspaceRoot = () => {
|
|
490
|
-
const dataHome = process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share");
|
|
491
|
-
return path.join(dataHome, APP_NAME);
|
|
492
|
-
};
|
|
493
|
-
const resolveWorkspace = async (options) => {
|
|
494
|
-
if (options.store) return {
|
|
495
|
-
root: path.resolve(options.store),
|
|
496
|
-
kind: "explicit"
|
|
497
|
-
};
|
|
498
|
-
const repoRoot = await findGitRoot(options.cwd ?? process.cwd());
|
|
499
|
-
if (options.repo) {
|
|
722
|
+
//#region cli/commands/init.ts
|
|
723
|
+
const registerInitCommand = (program, helpers) => {
|
|
724
|
+
program.command("init").description("initialize repo workspace").action(helpers.handleAction(async () => {
|
|
725
|
+
const repoRoot = await findGitRoot(process.cwd());
|
|
500
726
|
if (!repoRoot) throw new Error("Not inside a git repository");
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
if (options.global) return {
|
|
507
|
-
root: resolveGlobalWorkspaceRoot(),
|
|
508
|
-
kind: "global"
|
|
509
|
-
};
|
|
510
|
-
if (repoRoot) {
|
|
511
|
-
const repoWorkspace = path.join(repoRoot, APP_DIR);
|
|
512
|
-
if (await exists(repoWorkspace)) return {
|
|
513
|
-
root: repoWorkspace,
|
|
514
|
-
kind: "repo"
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
return {
|
|
518
|
-
root: resolveGlobalWorkspaceRoot(),
|
|
519
|
-
kind: "global"
|
|
520
|
-
};
|
|
521
|
-
};
|
|
522
|
-
const ensureWorkspaceLayout = async (workspaceRoot) => {
|
|
523
|
-
await mkdir(path.join(workspaceRoot, TASKS_DIR), { recursive: true });
|
|
524
|
-
await mkdir(path.join(workspaceRoot, LOCK_DIR), { recursive: true });
|
|
525
|
-
await Promise.all(STATUS_ORDER.map((status) => mkdir(path.join(workspaceRoot, TASKS_DIR, status), { recursive: true })));
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
//#endregion
|
|
529
|
-
//#region lib/lock.ts
|
|
530
|
-
const withWorkspaceLock = async (workspaceRoot, fn) => {
|
|
531
|
-
await ensureWorkspaceLayout(workspaceRoot);
|
|
532
|
-
const lockPath = path.join(workspaceRoot, LOCK_DIR, LOCK_FILE);
|
|
533
|
-
await writeFile(lockPath, "", { flag: "a" });
|
|
534
|
-
const release = await lockfile.lock(lockPath, {
|
|
535
|
-
stale: 6e4,
|
|
536
|
-
retries: {
|
|
537
|
-
retries: 5,
|
|
538
|
-
factor: 1.5,
|
|
539
|
-
minTimeout: 50,
|
|
540
|
-
maxTimeout: 1e3
|
|
541
|
-
}
|
|
542
|
-
});
|
|
543
|
-
try {
|
|
544
|
-
return await fn();
|
|
545
|
-
} finally {
|
|
546
|
-
await release();
|
|
547
|
-
}
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
//#endregion
|
|
551
|
-
//#region lib/search.ts
|
|
552
|
-
const taskMatchesQuery = (task, query) => {
|
|
553
|
-
const needle = query.toLowerCase();
|
|
554
|
-
const contains = (value) => value ? value.toLowerCase().includes(needle) : false;
|
|
555
|
-
if (contains(task.title)) return true;
|
|
556
|
-
if (task.logs.some((log) => contains(log.msg))) return true;
|
|
557
|
-
if (contains(task.metadata.pr?.url)) return true;
|
|
558
|
-
if (contains(task.metadata.pr?.fetched?.title)) return true;
|
|
559
|
-
return false;
|
|
727
|
+
const workspaceRoot = path.join(repoRoot, APP_DIR);
|
|
728
|
+
await ensureWorkspaceLayout(workspaceRoot);
|
|
729
|
+
await ensureLockIgnored(repoRoot);
|
|
730
|
+
process.stdout.write(`Initialized workspace at ${workspaceRoot}\n`);
|
|
731
|
+
}));
|
|
560
732
|
};
|
|
561
733
|
|
|
562
734
|
//#endregion
|
|
563
|
-
//#region lib/
|
|
564
|
-
const
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
for (const byte of input) {
|
|
570
|
-
value = value << 8 | byte;
|
|
571
|
-
bits += 8;
|
|
572
|
-
while (bits >= 5) {
|
|
573
|
-
output += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
574
|
-
bits -= 5;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
if (bits > 0) output += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
578
|
-
return output;
|
|
579
|
-
};
|
|
580
|
-
const generateTaskId = (taskType) => {
|
|
581
|
-
return `${taskType === "pr_review" ? "R" : "T"}${ulid()}`;
|
|
735
|
+
//#region lib/format.ts
|
|
736
|
+
const buildAliasLookup = (aliases) => {
|
|
737
|
+
const map = /* @__PURE__ */ new Map();
|
|
738
|
+
for (const [key, value] of Object.entries(aliases.T)) map.set(value, `T${key}`);
|
|
739
|
+
for (const [key, value] of Object.entries(aliases.R)) map.set(value, `R${key}`);
|
|
740
|
+
return map;
|
|
582
741
|
};
|
|
583
|
-
const
|
|
584
|
-
return `${
|
|
742
|
+
const formatTaskListLine = (task, alias) => {
|
|
743
|
+
return `${(alias ?? "--").padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
|
|
585
744
|
};
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
745
|
+
const formatTaskDetails = (task, alias) => {
|
|
746
|
+
const lines = [];
|
|
747
|
+
lines.push(`${alias ?? "--"} ${task.ref} ${task.title}`);
|
|
748
|
+
lines.push(`id: ${task.id}`);
|
|
749
|
+
lines.push(`type: ${task.type}`);
|
|
750
|
+
lines.push(`status: ${task.status}`);
|
|
751
|
+
lines.push(`created: ${task.created_at}`);
|
|
752
|
+
lines.push(`updated: ${task.updated_at}`);
|
|
753
|
+
if (task.metadata.url) lines.push(`url: ${task.metadata.url}`);
|
|
754
|
+
if (task.metadata.pr?.url) {
|
|
755
|
+
lines.push(`pr: ${task.metadata.pr.url}`);
|
|
756
|
+
if (task.metadata.pr.fetched) {
|
|
757
|
+
const fetched = task.metadata.pr.fetched;
|
|
758
|
+
lines.push(`pr_state: ${fetched.state}`);
|
|
759
|
+
lines.push(`pr_title: ${fetched.title}`);
|
|
760
|
+
lines.push(`pr_author: ${fetched.author.login}`);
|
|
761
|
+
lines.push(`pr_updated_at: ${fetched.updated_at}`);
|
|
762
|
+
lines.push(`pr_refreshed_at: ${fetched.at}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (task.logs.length > 0) {
|
|
766
|
+
lines.push("logs:");
|
|
767
|
+
for (const log of task.logs) {
|
|
768
|
+
const status = log.status ? ` [${log.status}]` : "";
|
|
769
|
+
lines.push(`- ${log.ts}${status} ${log.msg}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (task.day_assignments.length > 0) {
|
|
773
|
+
lines.push("day_assignments:");
|
|
774
|
+
for (const entry of task.day_assignments) {
|
|
775
|
+
const order = entry.order ? ` order:${entry.order}` : "";
|
|
776
|
+
const msg = entry.msg ? ` (${entry.msg})` : "";
|
|
777
|
+
lines.push(`- ${entry.date} ${entry.action}${order} ${entry.ts}${msg}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return lines.join("\n");
|
|
605
781
|
};
|
|
606
782
|
|
|
607
783
|
//#endregion
|
|
@@ -669,15 +845,27 @@ const applyTransition = (task, action, message) => {
|
|
|
669
845
|
};
|
|
670
846
|
};
|
|
671
847
|
const isAssignedOnDate = (task, date) => {
|
|
672
|
-
const
|
|
673
|
-
if (
|
|
674
|
-
return
|
|
848
|
+
const latest = getLatestDayAssignment(task, date);
|
|
849
|
+
if (!latest) return false;
|
|
850
|
+
return latest.event.action === "assign" || latest.event.action === "reorder";
|
|
851
|
+
};
|
|
852
|
+
const getLatestDayAssignment = (task, date) => {
|
|
853
|
+
let latestIndex = -1;
|
|
854
|
+
for (let i = 0; i < task.day_assignments.length; i += 1) if (task.day_assignments[i]?.date === date) latestIndex = i;
|
|
855
|
+
if (latestIndex === -1) return null;
|
|
856
|
+
const event = task.day_assignments[latestIndex];
|
|
857
|
+
if (!event) return null;
|
|
858
|
+
return {
|
|
859
|
+
event,
|
|
860
|
+
index: latestIndex
|
|
861
|
+
};
|
|
675
862
|
};
|
|
676
|
-
const appendDayAssignment = (task, date, action, msg) => {
|
|
863
|
+
const appendDayAssignment = (task, date, action, msg, order) => {
|
|
677
864
|
const event = {
|
|
678
865
|
date,
|
|
679
866
|
action,
|
|
680
867
|
ts: nowIso(),
|
|
868
|
+
order,
|
|
681
869
|
msg
|
|
682
870
|
};
|
|
683
871
|
return {
|
|
@@ -688,44 +876,346 @@ const appendDayAssignment = (task, date, action, msg) => {
|
|
|
688
876
|
};
|
|
689
877
|
|
|
690
878
|
//#endregion
|
|
691
|
-
//#region
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
879
|
+
//#region cli/commands/list.ts
|
|
880
|
+
const listScopes = [
|
|
881
|
+
"today",
|
|
882
|
+
"yesterday",
|
|
883
|
+
"this_week",
|
|
884
|
+
"last_week"
|
|
885
|
+
];
|
|
886
|
+
const isTaskStatus = (value) => {
|
|
887
|
+
return taskStatuses.includes(value);
|
|
888
|
+
};
|
|
889
|
+
const isListScope = (value) => {
|
|
890
|
+
return listScopes.includes(value);
|
|
891
|
+
};
|
|
892
|
+
const registerListCommand = (program, helpers) => {
|
|
893
|
+
program.command("list").description("list tasks").argument("[status]", "task status or date scope").action(helpers.handleAction(async (status, command) => {
|
|
894
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
895
|
+
const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
|
|
896
|
+
const lines = [];
|
|
897
|
+
if (status && !isTaskStatus(status) && !isListScope(status)) {
|
|
898
|
+
helpers.failWithHelp(command, `Invalid status or scope: ${status}`);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (status && isListScope(status)) {
|
|
902
|
+
const dates = listDatesForScope(status);
|
|
903
|
+
const tasks = await listAllTasks(workspaceRoot);
|
|
904
|
+
for (const entry of tasks) {
|
|
905
|
+
if (!dates.some((date) => isAssignedOnDate(entry.task, date))) continue;
|
|
906
|
+
const alias = aliasLookup.get(entry.task.id);
|
|
907
|
+
let line = formatTaskListLine(entry.task, alias);
|
|
908
|
+
if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
|
|
909
|
+
lines.push(line);
|
|
910
|
+
}
|
|
911
|
+
helpers.printLines(lines);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const statuses = status ? [status] : [...taskStatuses];
|
|
915
|
+
for (const currentStatus of statuses) {
|
|
916
|
+
const stored = await listTasksByStatus(workspaceRoot, currentStatus);
|
|
917
|
+
for (const entry of stored) {
|
|
918
|
+
const alias = aliasLookup.get(entry.task.id);
|
|
919
|
+
let line = formatTaskListLine(entry.task, alias);
|
|
920
|
+
if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
|
|
921
|
+
lines.push(line);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
helpers.printLines(lines);
|
|
925
|
+
}));
|
|
700
926
|
};
|
|
701
927
|
|
|
702
928
|
//#endregion
|
|
703
|
-
//#region
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
929
|
+
//#region cli/commands/log.ts
|
|
930
|
+
const registerLogCommand = (program, helpers) => {
|
|
931
|
+
program.command("log").description("append a log entry").argument("<id>", "task identifier").argument("<message>", "log message").option("--status <status>", "include status in log entry").action(helpers.handleAction(async (identifier, message, options, command) => {
|
|
932
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
933
|
+
const status = options.status;
|
|
934
|
+
if (status && !taskStatuses.includes(status)) {
|
|
935
|
+
helpers.failWithHelp(command, `Invalid status: ${status}`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
939
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
940
|
+
const updated = appendLog(stored.task, message, status);
|
|
941
|
+
await saveTask(workspaceRoot, stored.status, updated);
|
|
942
|
+
process.stdout.write(`Logged on ${updated.ref}\n`);
|
|
943
|
+
});
|
|
944
|
+
}));
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region cli/commands/pr.ts
|
|
949
|
+
const registerPrCommands = (program, helpers) => {
|
|
950
|
+
program.command("attach-pr").description("attach PR metadata to a task").argument("<id>", "task identifier").argument("<url>", "PR URL").option("-m, --message <message>", "log message").action(helpers.handleAction(async (identifier, url, options, command) => {
|
|
951
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
952
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
953
|
+
const parsed = parseGitHubPrUrl(url);
|
|
954
|
+
if (!parsed) {
|
|
955
|
+
helpers.failWithHelp(command, "Invalid PR URL");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
|
|
959
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
960
|
+
const next = appendLog({
|
|
961
|
+
...stored.task,
|
|
962
|
+
metadata: {
|
|
963
|
+
...stored.task.metadata,
|
|
964
|
+
pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched)
|
|
965
|
+
},
|
|
966
|
+
updated_at: nowIso()
|
|
967
|
+
}, options.message ?? `Attached PR ${url}`);
|
|
968
|
+
await saveTask(workspaceRoot, stored.status, next);
|
|
969
|
+
process.stdout.write(`Attached PR to ${next.ref}\n`);
|
|
970
|
+
});
|
|
971
|
+
}));
|
|
972
|
+
program.command("refresh").description("refresh PR metadata").argument("<id>", "task identifier").option("-m, --message <message>", "log message").action(helpers.handleAction(async (identifier, options) => {
|
|
973
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
974
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
975
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
976
|
+
const prUrl = stored.task.metadata.pr?.url;
|
|
977
|
+
if (!prUrl) {
|
|
978
|
+
process.stdout.write("No PR attached.\n");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const parsed = parseGitHubPrUrl(prUrl);
|
|
982
|
+
if (!parsed) throw new Error("Invalid PR URL");
|
|
983
|
+
const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
|
|
984
|
+
if (!fetched) throw new Error("No GitHub token configured");
|
|
985
|
+
const baseUpdated = {
|
|
986
|
+
...stored.task,
|
|
987
|
+
metadata: {
|
|
988
|
+
...stored.task.metadata,
|
|
989
|
+
pr: buildPrAttachment(parsed, fetched)
|
|
990
|
+
},
|
|
991
|
+
updated_at: nowIso()
|
|
992
|
+
};
|
|
993
|
+
const previousState = stored.task.metadata.pr?.fetched?.state;
|
|
994
|
+
const nextState = fetched.state;
|
|
995
|
+
const isTerminal = stored.task.status === "completed" || stored.task.status === "cancelled";
|
|
996
|
+
let finalTask = baseUpdated;
|
|
997
|
+
let nextStatus = stored.status;
|
|
998
|
+
if (!isTerminal && previousState !== nextState) {
|
|
999
|
+
if (nextState === "merged") {
|
|
1000
|
+
const result = applyTransition(baseUpdated, "complete", options.message ?? "PR merged");
|
|
1001
|
+
finalTask = result.task;
|
|
1002
|
+
nextStatus = result.nextStatus;
|
|
1003
|
+
} else if (nextState === "closed") {
|
|
1004
|
+
const result = applyTransition(baseUpdated, "cancel", options.message ?? "PR closed without merge");
|
|
1005
|
+
finalTask = result.task;
|
|
1006
|
+
nextStatus = result.nextStatus;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
await saveTask(workspaceRoot, stored.status, finalTask);
|
|
1010
|
+
if (nextStatus !== stored.status) await moveTaskFile(workspaceRoot, finalTask.id, stored.status, nextStatus);
|
|
1011
|
+
process.stdout.write(`Refreshed ${finalTask.ref}\n`);
|
|
1012
|
+
});
|
|
1013
|
+
}));
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region lib/search.ts
|
|
1018
|
+
const taskMatchesQuery = (task, query) => {
|
|
1019
|
+
const needle = query.toLowerCase();
|
|
1020
|
+
const contains = (value) => value ? value.toLowerCase().includes(needle) : false;
|
|
1021
|
+
if (contains(task.title)) return true;
|
|
1022
|
+
if (task.logs.some((log) => contains(log.msg))) return true;
|
|
1023
|
+
if (contains(task.metadata.pr?.url)) return true;
|
|
1024
|
+
if (contains(task.metadata.pr?.fetched?.title)) return true;
|
|
1025
|
+
return false;
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region cli/commands/search.ts
|
|
1030
|
+
const registerSearchCommand = (program, helpers) => {
|
|
1031
|
+
program.command("search").description("search tasks").argument("<query>", "search query").action(helpers.handleAction(async (query) => {
|
|
1032
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
1033
|
+
const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
|
|
1034
|
+
const lines = (await listAllTasks(workspaceRoot)).filter((stored) => taskMatchesQuery(stored.task, query)).map((stored) => {
|
|
1035
|
+
const alias = aliasLookup.get(stored.task.id);
|
|
1036
|
+
let line = formatTaskListLine(stored.task, alias);
|
|
1037
|
+
if (stored.task.metadata.pr?.fetched?.state) line += ` PR:${stored.task.metadata.pr.fetched.state}`;
|
|
1038
|
+
return line;
|
|
1039
|
+
});
|
|
1040
|
+
helpers.printLines(lines);
|
|
1041
|
+
}));
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region cli/commands/show.ts
|
|
1046
|
+
const registerShowCommand = (program, helpers) => {
|
|
1047
|
+
program.command("show").description("show a task").argument("<id>", "task identifier").action(helpers.handleAction(async (identifier) => {
|
|
1048
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
1049
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
1050
|
+
const alias = buildAliasLookup(await readAliases(workspaceRoot)).get(stored.task.id);
|
|
1051
|
+
process.stdout.write(`${formatTaskDetails(stored.task, alias)}\n`);
|
|
1052
|
+
}));
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region cli/commands/today.ts
|
|
1057
|
+
const resolveDate = (helpers, command, dateInput) => {
|
|
1058
|
+
try {
|
|
1059
|
+
return todayDate(dateInput);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1062
|
+
helpers.failWithHelp(command, message);
|
|
1063
|
+
return;
|
|
712
1064
|
}
|
|
713
|
-
if (!await findTaskPathById(workspaceRoot, identifier)) throw new Error(`Task not found: ${identifier}`);
|
|
714
|
-
return identifier;
|
|
715
1065
|
};
|
|
716
|
-
const
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1066
|
+
const collectAssignments = (tasks, date) => {
|
|
1067
|
+
const entries = [];
|
|
1068
|
+
for (const stored of tasks) {
|
|
1069
|
+
const latest = getLatestDayAssignment(stored.task, date);
|
|
1070
|
+
if (!latest) continue;
|
|
1071
|
+
if (latest.event.action === "assign" || latest.event.action === "reorder") entries.push({
|
|
1072
|
+
stored,
|
|
1073
|
+
lastEvent: latest.event,
|
|
1074
|
+
lastIndex: latest.index
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
return entries;
|
|
1078
|
+
};
|
|
1079
|
+
const sortAssignments = (entries) => {
|
|
1080
|
+
return [...entries].sort((a, b) => {
|
|
1081
|
+
const orderA = a.lastEvent.order;
|
|
1082
|
+
const orderB = b.lastEvent.order;
|
|
1083
|
+
if (orderA != null && orderB != null) return orderA - orderB;
|
|
1084
|
+
if (orderA != null) return -1;
|
|
1085
|
+
if (orderB != null) return 1;
|
|
1086
|
+
return a.lastIndex - b.lastIndex;
|
|
1087
|
+
});
|
|
1088
|
+
};
|
|
1089
|
+
const buildOrderedAssignments = (tasks, date) => {
|
|
1090
|
+
return sortAssignments(collectAssignments(tasks, date));
|
|
1091
|
+
};
|
|
1092
|
+
const resolveMoveTarget = (helpers, command, position, currentIndex, total) => {
|
|
1093
|
+
if (position === "up") return Math.max(0, currentIndex - 1);
|
|
1094
|
+
if (position === "down") return Math.min(total - 1, currentIndex + 1);
|
|
1095
|
+
if (position === "top") return 0;
|
|
1096
|
+
if (position === "bottom") return total - 1;
|
|
1097
|
+
const parsed = Number(position);
|
|
1098
|
+
if (!Number.isInteger(parsed)) {
|
|
1099
|
+
helpers.failWithHelp(command, `Invalid position: ${position}`);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (parsed < 1 || parsed > total) {
|
|
1103
|
+
helpers.failWithHelp(command, `Position out of range: ${position}`);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
return parsed - 1;
|
|
1107
|
+
};
|
|
1108
|
+
const registerTodayCommands = (program, helpers) => {
|
|
1109
|
+
program.command("today").description("assign a task for today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").option("-m, --message <message>", "assignment message").action(helpers.handleAction(async (identifier, options, command) => {
|
|
1110
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
1111
|
+
const date = resolveDate(helpers, command, options.date);
|
|
1112
|
+
if (!date) return;
|
|
1113
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
1114
|
+
const tasks = await listAllTasks(workspaceRoot);
|
|
1115
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
1116
|
+
if (isAssignedOnDate(stored.task, date)) {
|
|
1117
|
+
process.stdout.write(`Already assigned ${stored.task.ref} to ${date}\n`);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
const order = buildOrderedAssignments(tasks, date).length + 1;
|
|
1121
|
+
const updated = appendDayAssignment(stored.task, date, "assign", options.message, order);
|
|
1122
|
+
await saveTask(workspaceRoot, stored.status, updated);
|
|
1123
|
+
process.stdout.write(`Assigned ${updated.ref} to ${date}\n`);
|
|
1124
|
+
});
|
|
1125
|
+
})).command("move").description("reorder tasks assigned to a day").argument("<id>", "task identifier").argument("<position>", "up, down, top, bottom, or 1-based position").option("--date <date>", "YYYY-MM-DD").action(helpers.handleAction(async (identifier, position, options, command) => {
|
|
1126
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
1127
|
+
const date = resolveDate(helpers, command, options.date);
|
|
1128
|
+
if (!date) return;
|
|
1129
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
1130
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
1131
|
+
const ordered = buildOrderedAssignments(await listAllTasks(workspaceRoot), date);
|
|
1132
|
+
const currentIndex = ordered.findIndex((entry) => entry.stored.task.id === stored.task.id);
|
|
1133
|
+
if (currentIndex === -1) {
|
|
1134
|
+
process.stdout.write(`Task ${stored.task.ref} is not assigned to ${date}\n`);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const targetIndex = resolveMoveTarget(helpers, command, position, currentIndex, ordered.length);
|
|
1138
|
+
if (targetIndex === void 0) return;
|
|
1139
|
+
if (targetIndex === currentIndex) {
|
|
1140
|
+
process.stdout.write(`Task ${stored.task.ref} is already in position ${currentIndex + 1}\n`);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const reordered = [...ordered];
|
|
1144
|
+
const [moved] = reordered.splice(currentIndex, 1);
|
|
1145
|
+
if (!moved) {
|
|
1146
|
+
process.stdout.write(`Task ${stored.task.ref} is not assigned to ${date}\n`);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
reordered.splice(targetIndex, 0, moved);
|
|
1150
|
+
for (const [index, entry] of reordered.entries()) {
|
|
1151
|
+
const nextOrder = index + 1;
|
|
1152
|
+
if (entry.lastEvent.order === nextOrder) continue;
|
|
1153
|
+
const updated = appendDayAssignment(entry.stored.task, date, "reorder", void 0, nextOrder);
|
|
1154
|
+
await saveTask(workspaceRoot, entry.stored.status, updated);
|
|
1155
|
+
}
|
|
1156
|
+
process.stdout.write(`Moved ${stored.task.ref} to position ${targetIndex + 1}\n`);
|
|
1157
|
+
});
|
|
1158
|
+
}));
|
|
1159
|
+
program.command("untoday").description("unassign a task from today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").action(helpers.handleAction(async (identifier, options, command) => {
|
|
1160
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
1161
|
+
const date = resolveDate(helpers, command, options.date);
|
|
1162
|
+
if (!date) return;
|
|
1163
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
1164
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
1165
|
+
if (!isAssignedOnDate(stored.task, date)) {
|
|
1166
|
+
process.stdout.write(`Already unassigned ${stored.task.ref} from ${date}\n`);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const updated = appendDayAssignment(stored.task, date, "unassign");
|
|
1170
|
+
await saveTask(workspaceRoot, stored.status, updated);
|
|
1171
|
+
process.stdout.write(`Unassigned ${updated.ref} from ${date}\n`);
|
|
1172
|
+
});
|
|
1173
|
+
}));
|
|
724
1174
|
};
|
|
725
1175
|
|
|
726
1176
|
//#endregion
|
|
727
|
-
//#region
|
|
728
|
-
const
|
|
1177
|
+
//#region cli/commands/transitions.ts
|
|
1178
|
+
const transitionNames = [
|
|
1179
|
+
"start",
|
|
1180
|
+
"pause",
|
|
1181
|
+
"complete",
|
|
1182
|
+
"cancel"
|
|
1183
|
+
];
|
|
1184
|
+
const registerTransitionCommand = (program, helpers, name) => {
|
|
1185
|
+
program.command(name).description(`${name} a task`).argument("<id>", "task identifier").option("-m, --message <message>", "custom log message").action(helpers.handleAction(async (identifier, options) => {
|
|
1186
|
+
const workspaceRoot = await helpers.resolveWorkspaceFor(true);
|
|
1187
|
+
await withWorkspaceLock(workspaceRoot, async () => {
|
|
1188
|
+
const stored = await resolveTask(workspaceRoot, identifier);
|
|
1189
|
+
const { task: updated, nextStatus } = applyTransition(stored.task, name, options.message);
|
|
1190
|
+
await saveTask(workspaceRoot, stored.status, updated);
|
|
1191
|
+
await moveTaskFile(workspaceRoot, updated.id, stored.status, nextStatus);
|
|
1192
|
+
process.stdout.write(`Updated ${updated.ref} -> ${nextStatus}\n`);
|
|
1193
|
+
});
|
|
1194
|
+
}));
|
|
1195
|
+
};
|
|
1196
|
+
const registerTransitionCommands = (program, helpers) => {
|
|
1197
|
+
for (const name of transitionNames) registerTransitionCommand(program, helpers, name);
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
//#endregion
|
|
1201
|
+
//#region cli/commands/where.ts
|
|
1202
|
+
const registerWhereCommand = (program, helpers) => {
|
|
1203
|
+
program.command("where").description("show resolved workspace").action(helpers.handleAction(async () => {
|
|
1204
|
+
const opts = program.opts();
|
|
1205
|
+
const workspace = await resolveWorkspace({
|
|
1206
|
+
store: opts.store,
|
|
1207
|
+
global: opts.global,
|
|
1208
|
+
repo: opts.repo
|
|
1209
|
+
});
|
|
1210
|
+
process.stdout.write(`${workspace.root} (${workspace.kind})\n`);
|
|
1211
|
+
}));
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
//#endregion
|
|
1215
|
+
//#region cli/helpers.ts
|
|
1216
|
+
const isPackageJson = (value) => {
|
|
1217
|
+
return value !== null && typeof value === "object" && "version" in value;
|
|
1218
|
+
};
|
|
729
1219
|
const handleAction = (fn) => async (...args) => {
|
|
730
1220
|
try {
|
|
731
1221
|
await fn(...args);
|
|
@@ -735,16 +1225,6 @@ const handleAction = (fn) => async (...args) => {
|
|
|
735
1225
|
process.exitCode = 1;
|
|
736
1226
|
}
|
|
737
1227
|
};
|
|
738
|
-
const resolveWorkspaceFor = async (ensure) => {
|
|
739
|
-
const opts = program.opts();
|
|
740
|
-
const workspace = await resolveWorkspace({
|
|
741
|
-
store: opts.store,
|
|
742
|
-
global: opts.global,
|
|
743
|
-
repo: opts.repo
|
|
744
|
-
});
|
|
745
|
-
if (ensure) await ensureWorkspaceLayout(workspace.root);
|
|
746
|
-
return workspace.root;
|
|
747
|
-
};
|
|
748
1228
|
const printLines = (lines) => {
|
|
749
1229
|
if (lines.length === 0) {
|
|
750
1230
|
process.stdout.write("No tasks.\n");
|
|
@@ -752,20 +1232,28 @@ const printLines = (lines) => {
|
|
|
752
1232
|
}
|
|
753
1233
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
754
1234
|
};
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1235
|
+
const failWithHelp = (command, message) => {
|
|
1236
|
+
process.stderr.write(`${message}\n`);
|
|
1237
|
+
command.outputHelp();
|
|
1238
|
+
process.exitCode = 1;
|
|
1239
|
+
};
|
|
1240
|
+
const createCliHelpers = (program) => {
|
|
1241
|
+
const resolveWorkspaceFor = async (ensure) => {
|
|
1242
|
+
const opts = program.opts();
|
|
1243
|
+
const workspace = await resolveWorkspace({
|
|
1244
|
+
store: opts.store,
|
|
1245
|
+
global: opts.global,
|
|
1246
|
+
repo: opts.repo
|
|
1247
|
+
});
|
|
1248
|
+
if (ensure) await ensureWorkspaceLayout(workspace.root);
|
|
1249
|
+
return workspace.root;
|
|
1250
|
+
};
|
|
1251
|
+
return {
|
|
1252
|
+
handleAction,
|
|
1253
|
+
resolveWorkspaceFor,
|
|
1254
|
+
printLines,
|
|
1255
|
+
failWithHelp
|
|
1256
|
+
};
|
|
769
1257
|
};
|
|
770
1258
|
const resolvePackageVersion = async () => {
|
|
771
1259
|
let current = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -784,237 +1272,27 @@ const resolvePackageVersion = async () => {
|
|
|
784
1272
|
current = parent;
|
|
785
1273
|
}
|
|
786
1274
|
};
|
|
1275
|
+
|
|
1276
|
+
//#endregion
|
|
1277
|
+
//#region cli/index.ts
|
|
1278
|
+
const program = new Command();
|
|
1279
|
+
const helpers = createCliHelpers(program);
|
|
787
1280
|
const packageVersion = await resolvePackageVersion();
|
|
788
1281
|
program.name("il").description("Terminal task manager").option("--store <path>", "explicit workspace path").option("--global", "use global workspace").option("--repo", "use repo workspace");
|
|
789
1282
|
if (packageVersion) program.version(packageVersion);
|
|
790
|
-
program.
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
program
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
repo: opts.repo
|
|
804
|
-
});
|
|
805
|
-
process.stdout.write(`${workspace.root} (${workspace.kind})\n`);
|
|
806
|
-
}));
|
|
807
|
-
program.command("add").description("add a task").argument("[title]", "task title").option("--type <type>", "regular|pr_review", "regular").option("--url <url>", "attach URL").option("--pr <url>", "attach PR URL").action(handleAction(async (title, options, command) => {
|
|
808
|
-
const taskType = options.type;
|
|
809
|
-
if (!taskTypes.includes(taskType)) throw new Error(`Invalid type: ${taskType}`);
|
|
810
|
-
if (taskType === "pr_review" && !options.pr) throw new Error("PR review tasks require --pr");
|
|
811
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
812
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
813
|
-
let prAttachment;
|
|
814
|
-
let prTitle;
|
|
815
|
-
if (options.pr) {
|
|
816
|
-
const parsed = parseGitHubPrUrl(options.pr);
|
|
817
|
-
if (!parsed) throw new Error("Invalid PR URL");
|
|
818
|
-
const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
|
|
819
|
-
prAttachment = buildPrAttachment(parsed, fetched ?? void 0);
|
|
820
|
-
prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
|
|
821
|
-
}
|
|
822
|
-
const isExplicitTitle = Boolean(title);
|
|
823
|
-
let finalTitle = title ?? prTitle;
|
|
824
|
-
if (!finalTitle) throw new Error("Title is required when no PR URL is provided");
|
|
825
|
-
if (taskType === "pr_review" && !isExplicitTitle) finalTitle = `Review: ${finalTitle}`;
|
|
826
|
-
const metadata = {
|
|
827
|
-
url: options.url,
|
|
828
|
-
pr: prAttachment
|
|
829
|
-
};
|
|
830
|
-
const task = buildTask({
|
|
831
|
-
type: taskType,
|
|
832
|
-
title: finalTitle,
|
|
833
|
-
metadata
|
|
834
|
-
});
|
|
835
|
-
taskSchema.parse(task);
|
|
836
|
-
const created = await createTaskInStore(workspaceRoot, task);
|
|
837
|
-
process.stdout.write(`Created ${created.alias} ${created.task.ref} ${created.task.title}\n`);
|
|
838
|
-
});
|
|
839
|
-
}));
|
|
840
|
-
program.command("list").description("list tasks").argument("[status]", "task status or date scope").action(handleAction(async (status, command) => {
|
|
841
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
842
|
-
const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
|
|
843
|
-
const lines = [];
|
|
844
|
-
if (status && !isTaskStatus(status) && !isListScope(status)) throw new Error(`Invalid status or scope: ${status}`);
|
|
845
|
-
if (status && isListScope(status)) {
|
|
846
|
-
const dates = listDatesForScope(status);
|
|
847
|
-
const tasks = await listAllTasks(workspaceRoot);
|
|
848
|
-
for (const entry of tasks) {
|
|
849
|
-
if (!dates.some((date) => isAssignedOnDate(entry.task, date))) continue;
|
|
850
|
-
const alias = aliasLookup.get(entry.task.id);
|
|
851
|
-
let line = formatTaskListLine(entry.task, alias);
|
|
852
|
-
if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
|
|
853
|
-
lines.push(line);
|
|
854
|
-
}
|
|
855
|
-
printLines(lines);
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
const statuses = status ? [status] : [...taskStatuses];
|
|
859
|
-
for (const currentStatus of statuses) {
|
|
860
|
-
const stored = await listTasksByStatus(workspaceRoot, currentStatus);
|
|
861
|
-
for (const entry of stored) {
|
|
862
|
-
const alias = aliasLookup.get(entry.task.id);
|
|
863
|
-
let line = formatTaskListLine(entry.task, alias);
|
|
864
|
-
if (entry.task.metadata.pr?.fetched?.state) line += ` PR:${entry.task.metadata.pr.fetched.state}`;
|
|
865
|
-
lines.push(line);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
printLines(lines);
|
|
869
|
-
}));
|
|
870
|
-
program.command("show").description("show a task").argument("<id>", "task identifier").action(handleAction(async (identifier, command) => {
|
|
871
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
872
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
873
|
-
const alias = buildAliasLookup(await readAliases(workspaceRoot)).get(stored.task.id);
|
|
874
|
-
process.stdout.write(`${formatTaskDetails(stored.task, alias)}\n`);
|
|
875
|
-
}));
|
|
876
|
-
const addTransitionCommand = (name) => {
|
|
877
|
-
program.command(name).description(`${name} a task`).argument("<id>", "task identifier").option("-m, --message <message>", "custom log message").action(handleAction(async (identifier, options, command) => {
|
|
878
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
879
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
880
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
881
|
-
const { task: updated, nextStatus } = applyTransition(stored.task, name, options.message);
|
|
882
|
-
await saveTask(workspaceRoot, stored.status, updated);
|
|
883
|
-
await moveTaskFile(workspaceRoot, updated.id, stored.status, nextStatus);
|
|
884
|
-
process.stdout.write(`Updated ${updated.ref} -> ${nextStatus}\n`);
|
|
885
|
-
});
|
|
886
|
-
}));
|
|
887
|
-
};
|
|
888
|
-
addTransitionCommand("start");
|
|
889
|
-
addTransitionCommand("pause");
|
|
890
|
-
addTransitionCommand("complete");
|
|
891
|
-
addTransitionCommand("cancel");
|
|
892
|
-
program.command("log").description("append a log entry").argument("<id>", "task identifier").argument("<message>", "log message").option("--status <status>", "include status in log entry").action(handleAction(async (identifier, message, options, command) => {
|
|
893
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
894
|
-
const status = options.status;
|
|
895
|
-
if (status && !taskStatuses.includes(status)) throw new Error(`Invalid status: ${status}`);
|
|
896
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
897
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
898
|
-
const updated = appendLog(stored.task, message, status);
|
|
899
|
-
await saveTask(workspaceRoot, stored.status, updated);
|
|
900
|
-
process.stdout.write(`Logged on ${updated.ref}\n`);
|
|
901
|
-
});
|
|
902
|
-
}));
|
|
903
|
-
program.command("edit").description("edit a task field").argument("<id>", "task identifier").argument("<path>", "dotted path").argument("<value>", "new value").action(handleAction(async (identifier, dottedPath, value, command) => {
|
|
904
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
905
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
906
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
907
|
-
const updated = applyTaskEdit(stored.task, dottedPath, value);
|
|
908
|
-
await saveTask(workspaceRoot, stored.status, updated);
|
|
909
|
-
process.stdout.write(`Updated ${updated.ref}\n`);
|
|
910
|
-
});
|
|
911
|
-
}));
|
|
912
|
-
program.command("search").description("search tasks").argument("<query>", "search query").action(handleAction(async (query, command) => {
|
|
913
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
914
|
-
const aliasLookup = buildAliasLookup(await readAliases(workspaceRoot));
|
|
915
|
-
printLines((await listAllTasks(workspaceRoot)).filter((stored) => taskMatchesQuery(stored.task, query)).map((stored) => {
|
|
916
|
-
const alias = aliasLookup.get(stored.task.id);
|
|
917
|
-
let line = formatTaskListLine(stored.task, alias);
|
|
918
|
-
if (stored.task.metadata.pr?.fetched?.state) line += ` PR:${stored.task.metadata.pr.fetched.state}`;
|
|
919
|
-
return line;
|
|
920
|
-
}));
|
|
921
|
-
}));
|
|
922
|
-
program.command("today").description("assign a task for today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").option("-m, --message <message>", "assignment message").action(handleAction(async (identifier, options, command) => {
|
|
923
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
924
|
-
const date = todayDate(options.date);
|
|
925
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
926
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
927
|
-
if (isAssignedOnDate(stored.task, date)) {
|
|
928
|
-
process.stdout.write(`Already assigned ${stored.task.ref} to ${date}\n`);
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
const updated = appendDayAssignment(stored.task, date, "assign", options.message);
|
|
932
|
-
await saveTask(workspaceRoot, stored.status, updated);
|
|
933
|
-
process.stdout.write(`Assigned ${updated.ref} to ${date}\n`);
|
|
934
|
-
});
|
|
935
|
-
}));
|
|
936
|
-
program.command("untoday").description("unassign a task from today").argument("<id>", "task identifier").option("--date <date>", "YYYY-MM-DD").action(handleAction(async (identifier, options, command) => {
|
|
937
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
938
|
-
const date = todayDate(options.date);
|
|
939
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
940
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
941
|
-
if (!isAssignedOnDate(stored.task, date)) {
|
|
942
|
-
process.stdout.write(`Already unassigned ${stored.task.ref} from ${date}\n`);
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
const updated = appendDayAssignment(stored.task, date, "unassign");
|
|
946
|
-
await saveTask(workspaceRoot, stored.status, updated);
|
|
947
|
-
process.stdout.write(`Unassigned ${updated.ref} from ${date}\n`);
|
|
948
|
-
});
|
|
949
|
-
}));
|
|
950
|
-
program.command("attach-pr").description("attach PR metadata to a task").argument("<id>", "task identifier").argument("<url>", "PR URL").option("-m, --message <message>", "log message").action(handleAction(async (identifier, url, options, command) => {
|
|
951
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
952
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
953
|
-
const parsed = parseGitHubPrUrl(url);
|
|
954
|
-
if (!parsed) throw new Error("Invalid PR URL");
|
|
955
|
-
const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
|
|
956
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
957
|
-
const next = appendLog({
|
|
958
|
-
...stored.task,
|
|
959
|
-
metadata: {
|
|
960
|
-
...stored.task.metadata,
|
|
961
|
-
pr: buildPrAttachment(parsed, fetched ?? stored.task.metadata.pr?.fetched)
|
|
962
|
-
},
|
|
963
|
-
updated_at: nowIso()
|
|
964
|
-
}, options.message ?? `Attached PR ${url}`);
|
|
965
|
-
await saveTask(workspaceRoot, stored.status, next);
|
|
966
|
-
process.stdout.write(`Attached PR to ${next.ref}\n`);
|
|
967
|
-
});
|
|
968
|
-
}));
|
|
969
|
-
program.command("refresh").description("refresh PR metadata").argument("<id>", "task identifier").option("-m, --message <message>", "log message").action(handleAction(async (identifier, options, command) => {
|
|
970
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
971
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
972
|
-
const stored = await resolveTask(workspaceRoot, identifier);
|
|
973
|
-
const prUrl = stored.task.metadata.pr?.url;
|
|
974
|
-
if (!prUrl) {
|
|
975
|
-
process.stdout.write("No PR attached.\n");
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
const parsed = parseGitHubPrUrl(prUrl);
|
|
979
|
-
if (!parsed) throw new Error("Invalid PR URL");
|
|
980
|
-
const fetched = await fetchGitHubPr(parsed, await resolveGithubToken(workspaceRoot));
|
|
981
|
-
if (!fetched) throw new Error("No GitHub token configured");
|
|
982
|
-
const baseUpdated = {
|
|
983
|
-
...stored.task,
|
|
984
|
-
metadata: {
|
|
985
|
-
...stored.task.metadata,
|
|
986
|
-
pr: buildPrAttachment(parsed, fetched)
|
|
987
|
-
},
|
|
988
|
-
updated_at: nowIso()
|
|
989
|
-
};
|
|
990
|
-
const previousState = stored.task.metadata.pr?.fetched?.state;
|
|
991
|
-
const nextState = fetched.state;
|
|
992
|
-
const isTerminal = stored.task.status === "completed" || stored.task.status === "cancelled";
|
|
993
|
-
let finalTask = baseUpdated;
|
|
994
|
-
let nextStatus = stored.status;
|
|
995
|
-
if (!isTerminal && previousState !== nextState) {
|
|
996
|
-
if (nextState === "merged") {
|
|
997
|
-
const result = applyTransition(baseUpdated, "complete", options.message ?? "PR merged");
|
|
998
|
-
finalTask = result.task;
|
|
999
|
-
nextStatus = result.nextStatus;
|
|
1000
|
-
} else if (nextState === "closed") {
|
|
1001
|
-
const result = applyTransition(baseUpdated, "cancel", options.message ?? "PR closed without merge");
|
|
1002
|
-
finalTask = result.task;
|
|
1003
|
-
nextStatus = result.nextStatus;
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
await saveTask(workspaceRoot, stored.status, finalTask);
|
|
1007
|
-
if (nextStatus !== stored.status) await moveTaskFile(workspaceRoot, finalTask.id, stored.status, nextStatus);
|
|
1008
|
-
process.stdout.write(`Refreshed ${finalTask.ref}\n`);
|
|
1009
|
-
});
|
|
1010
|
-
}));
|
|
1011
|
-
program.command("alias").description("alias helpers").command("reconcile").description("reconcile alias mapping").action(handleAction(async (command) => {
|
|
1012
|
-
const workspaceRoot = await resolveWorkspaceFor(true);
|
|
1013
|
-
await withWorkspaceLock(workspaceRoot, async () => {
|
|
1014
|
-
await reconcileAliases(workspaceRoot);
|
|
1015
|
-
process.stdout.write("Aliases reconciled.\n");
|
|
1016
|
-
});
|
|
1017
|
-
}));
|
|
1283
|
+
program.showHelpAfterError();
|
|
1284
|
+
registerInitCommand(program, helpers);
|
|
1285
|
+
registerWhereCommand(program, helpers);
|
|
1286
|
+
registerAddCommand(program, helpers);
|
|
1287
|
+
registerListCommand(program, helpers);
|
|
1288
|
+
registerShowCommand(program, helpers);
|
|
1289
|
+
registerTransitionCommands(program, helpers);
|
|
1290
|
+
registerLogCommand(program, helpers);
|
|
1291
|
+
registerEditCommand(program, helpers);
|
|
1292
|
+
registerSearchCommand(program, helpers);
|
|
1293
|
+
registerTodayCommands(program, helpers);
|
|
1294
|
+
registerPrCommands(program, helpers);
|
|
1295
|
+
registerAliasCommand(program, helpers);
|
|
1018
1296
|
program.parseAsync(process.argv);
|
|
1019
1297
|
|
|
1020
1298
|
//#endregion
|