@yokio42/unit-labs-cli 0.1.0 → 0.1.2

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/src/index.js CHANGED
@@ -3,78 +3,183 @@ const { stdin: input, stdout: output } = require("process")
3
3
  const { CONFIG_FILE, DEFAULT_API_BASE, normalizeApiBase, readConfig, writeConfig } = require("./config")
4
4
 
5
5
  const UNIT_LABS_BANNER = String.raw`
6
- _ _ _ _ ___ _____ _ _ ____ ____
7
- | | | | \ | |_ _|_ _| | | / \ | __ )/ ___|
8
- | | | | \| || | | | | | / _ \ | _ \\___ \
9
- | |_| | |\ || | | | | |___ / ___ \| |_) |___) |
10
- \___/|_| \_|___| |_| |_____/_/ \_\____/|____/
6
+ /$$ /$$ /$$ /$$ /$$$$$$ /$$$$$$$$ /$$ /$$$$$$ /$$$$$$$ /$$$$$$
7
+ | $$ | $$| $$$ | $$|_ $$_/|__ $$__/ | $$ /$$__ $$| $$__ $$ /$$__ $$
8
+ | $$ | $$| $$$$| $$ | $$ | $$ | $$ | $$ \ $$| $$ \ $$| $$ \__/
9
+ | $$ | $$| $$ $$ $$ | $$ | $$ /$$$$$$| $$ | $$$$$$$$| $$$$$$$ | $$$$$$
10
+ | $$ | $$| $$ $$$$ | $$ | $$|______/| $$ | $$__ $$| $$__ $$ \____ $$
11
+ | $$ | $$| $$\ $$$ | $$ | $$ | $$ | $$ | $$| $$ \ $$ /$$ \ $$
12
+ | $$$$$$/| $$ \ $$ /$$$$$$ | $$ | $$$$$$$$| $$ | $$| $$$$$$$/| $$$$$$/
13
+ \______/ |__/ \__/|______/ |__/ |________/|__/ |__/|_______/ \______/
11
14
  `
12
15
 
13
- const HELP_TEXT = `
16
+ const ROOT_HELP = `
14
17
  Unit Labs CLI
15
18
 
19
+ Usage:
20
+ unit-labs auth <command>
21
+ unit-labs config <command>
22
+ unit-labs project <command>
23
+ unit-labs stage <command>
24
+ unit-labs task <command>
25
+ unit-labs --help
26
+
27
+ Quick start:
28
+ unit-labs auth login
29
+ unit-labs project ls
30
+ unit-labs project use 1
31
+ unit-labs task ls
32
+ unit-labs task new "Ship MVP"
33
+ `
34
+
35
+ const AUTH_HELP = `
16
36
  Usage:
17
37
  unit-labs auth login [--login <email>] [--password <password>] [--api <url>]
18
- unit-labs auth logout
19
38
  unit-labs auth whoami
39
+ unit-labs auth logout
40
+ `
41
+
42
+ const CONFIG_HELP = `
43
+ Usage:
20
44
  unit-labs config show
21
45
  unit-labs config set-api <url>
22
- unit-labs --help
46
+ `
23
47
 
24
- Options:
25
- -l, --login Login (email) for login
26
- -e, --email Alias for --login
27
- -p, --password Password for login
28
- --api API base URL (default: ${DEFAULT_API_BASE})
29
- -h, --help Show help
48
+ const PROJECT_HELP = `
49
+ Usage:
50
+ unit-labs project ls [--team <teamId>] [--json]
51
+ unit-labs project new --name "<name>" [--workflow stages|flat] [--team <teamId>]
52
+ unit-labs project view <number|name|id> [--json]
53
+ unit-labs project use <number|name|id>
54
+ unit-labs project current [--json]
30
55
  `
31
56
 
32
- function parseFlags(argv) {
57
+ const STAGE_HELP = `
58
+ Usage:
59
+ unit-labs stage ls [--project <number|name|id>] [--json]
60
+ unit-labs stage new --name "<name>" --description "<text>" [--project <number|name|id>]
61
+ unit-labs stage use <number|name|id> [--project <number|name|id>]
62
+ unit-labs stage current [--json]
63
+ `
64
+
65
+ const TASK_HELP = `
66
+ Usage:
67
+ unit-labs task ls [--project <number|name|id>] [--stage <number|name|id>] [--all] [--json]
68
+ unit-labs task new "<title>" [--project <number|name|id>] [--stage <number|name|id>]
69
+ unit-labs task done <number|title|id> [--project <number|name|id>] [--stage <number|name|id>]
70
+ unit-labs task close <number|title|id> [--project <number|name|id>] [--stage <number|name|id>]
71
+ unit-labs task assign <number|title|id> <me|none|number|username|email|id> [--project ...] [--stage ...]
72
+ unit-labs task unassign <number|title|id> [--project ...] [--stage ...]
73
+ unit-labs task edit <number|title|id> --title "<new title>" [--project ...] [--stage ...]
74
+ unit-labs task rm <number|title|id> [--project ...] [--stage ...]
75
+ unit-labs task delete <number|title|id> [--project ...] [--stage ...]
76
+ `
77
+
78
+ const ASSIGNEE_SELF_REFS = new Set(["me", "self", "@me"])
79
+ const ASSIGNEE_CLEAR_REFS = new Set(["none", "null", "-", "unassign", "clear"])
80
+
81
+ function parseArgs(argv, shortAliases = {}) {
33
82
  const flags = {}
83
+ const positionals = []
34
84
 
35
85
  for (let i = 0; i < argv.length; i += 1) {
36
- const key = argv[i]
37
- const value = argv[i + 1]
86
+ const arg = argv[i]
38
87
 
39
- if (key === "-l" || key === "--login") {
40
- flags.login = value
41
- i += 1
42
- continue
43
- }
44
- if (key === "-e" || key === "--email") {
45
- flags.login = value
46
- i += 1
47
- continue
88
+ if (arg === "--") {
89
+ positionals.push(...argv.slice(i + 1))
90
+ break
48
91
  }
49
- if (key === "-p" || key === "--password") {
50
- flags.password = value
51
- i += 1
92
+
93
+ if (arg.startsWith("--")) {
94
+ const [rawKey, inlineValue] = arg.slice(2).split("=")
95
+ if (inlineValue !== undefined) {
96
+ flags[rawKey] = inlineValue
97
+ continue
98
+ }
99
+
100
+ const next = argv[i + 1]
101
+ if (next && !next.startsWith("-")) {
102
+ flags[rawKey] = next
103
+ i += 1
104
+ } else {
105
+ flags[rawKey] = true
106
+ }
52
107
  continue
53
108
  }
54
- if (key === "--api") {
55
- flags.apiBase = value
56
- i += 1
109
+
110
+ if (arg.startsWith("-") && arg.length > 1) {
111
+ const short = arg.slice(1)
112
+ const key = shortAliases[short] || short
113
+ const next = argv[i + 1]
114
+ if (next && !next.startsWith("-")) {
115
+ flags[key] = next
116
+ i += 1
117
+ } else {
118
+ flags[key] = true
119
+ }
57
120
  continue
58
121
  }
122
+
123
+ positionals.push(arg)
59
124
  }
60
125
 
61
- return flags
126
+ return { flags, positionals }
62
127
  }
63
128
 
64
- async function promptMissingCredentials({ login, password }) {
65
- if (login && password) {
66
- return { login, password }
129
+ function shortId(id) {
130
+ if (!id) return "-"
131
+ const value = String(id)
132
+ if (value.length <= 8) return value
133
+ return `${value.slice(0, 4)}…${value.slice(-4)}`
134
+ }
135
+
136
+ function printJson(value) {
137
+ console.log(JSON.stringify(value, null, 2))
138
+ }
139
+
140
+ function printProjects(projects, currentProjectId) {
141
+ if (!Array.isArray(projects) || projects.length === 0) {
142
+ console.log("No projects")
143
+ return
67
144
  }
68
145
 
69
- const rl = readline.createInterface({ input, output })
70
- const nextLogin = login || (await rl.question("Login: ")).trim()
71
- const nextPassword = password || (await rl.question("Password: ")).trim()
72
- rl.close()
146
+ projects.forEach((project, index) => {
147
+ const marker = String(project._id) === String(currentProjectId) ? "*" : " "
148
+ const workflow = project.workflow_type || "stages"
149
+ console.log(
150
+ `${String(index + 1).padStart(2)}.${marker} ${project.project_name} [${workflow}] ${shortId(project._id)}`
151
+ )
152
+ })
153
+ }
73
154
 
74
- return {
75
- login: nextLogin,
76
- password: nextPassword,
155
+ function printStages(stages, currentStageId) {
156
+ if (!Array.isArray(stages) || stages.length === 0) {
157
+ console.log("No stages")
158
+ return
159
+ }
160
+
161
+ stages.forEach((stage, index) => {
162
+ const marker = String(stage._id) === String(currentStageId) ? "*" : " "
163
+ const status = stage.status || "-"
164
+ console.log(
165
+ `${String(index + 1).padStart(2)}.${marker} ${stage.stage_name} [${status}] ${shortId(stage._id)}`
166
+ )
167
+ })
168
+ }
169
+
170
+ function printTasks(tasks, withStage = false) {
171
+ if (!Array.isArray(tasks) || tasks.length === 0) {
172
+ console.log("No tasks")
173
+ return
77
174
  }
175
+
176
+ tasks.forEach((task, index) => {
177
+ const done = task.is_done ? "x" : " "
178
+ const stagePrefix = withStage && task._stageName ? `(${task._stageName}) ` : ""
179
+ console.log(
180
+ `${String(index + 1).padStart(2)}. [${done}] ${stagePrefix}${task.title} ${shortId(task._id)}`
181
+ )
182
+ })
78
183
  }
79
184
 
80
185
  function printWelcomeBanner() {
@@ -82,180 +187,958 @@ function printWelcomeBanner() {
82
187
  console.log(UNIT_LABS_BANNER)
83
188
  }
84
189
 
85
- async function request({ method, url, token, body }) {
86
- const headers = {}
190
+ function getErrorMessage(payload) {
191
+ if (typeof payload === "string") return payload
192
+ if (payload && typeof payload === "object" && payload.message) return payload.message
193
+ try {
194
+ return JSON.stringify(payload)
195
+ } catch (error) {
196
+ return "unknown error"
197
+ }
198
+ }
87
199
 
88
- if (token) {
89
- headers.Authorization = `Bearer ${token}`
200
+ function resolveMemberByRef(members, ref) {
201
+ if (!ref) {
202
+ throw new Error("assignee reference is required")
203
+ }
204
+
205
+ if (!Array.isArray(members) || members.length === 0) {
206
+ throw new Error("this team has no members to assign")
207
+ }
208
+
209
+ const raw = String(ref).trim()
210
+ if (!raw) {
211
+ throw new Error("assignee reference is required")
212
+ }
213
+
214
+ if (/^\d+$/.test(raw)) {
215
+ const index = Number(raw) - 1
216
+ if (index < 0 || index >= members.length) {
217
+ throw new Error(`assignee index out of range: ${raw}`)
218
+ }
219
+ return members[index]
220
+ }
221
+
222
+ const exactId = members.find(member => String(member.id || member._id) === raw)
223
+ if (exactId) {
224
+ return exactId
225
+ }
226
+
227
+ const lower = raw.toLowerCase()
228
+ const exactEmail = members.filter(member => String(member.email || "").toLowerCase() === lower)
229
+ if (exactEmail.length === 1) {
230
+ return exactEmail[0]
231
+ }
232
+ if (exactEmail.length > 1) {
233
+ throw new Error(`ambiguous assignee email "${raw}"`)
234
+ }
235
+
236
+ const exactUsername = members.filter(member => String(member.username || "").toLowerCase() === lower)
237
+ if (exactUsername.length === 1) {
238
+ return exactUsername[0]
239
+ }
240
+ if (exactUsername.length > 1) {
241
+ throw new Error(`ambiguous assignee username "${raw}"`)
242
+ }
243
+
244
+ const partial = members.filter(member => {
245
+ const username = String(member.username || "").toLowerCase()
246
+ const email = String(member.email || "").toLowerCase()
247
+ return username.includes(lower) || email.includes(lower)
248
+ })
249
+ if (partial.length === 1) {
250
+ return partial[0]
251
+ }
252
+ if (partial.length > 1) {
253
+ throw new Error(`ambiguous assignee reference "${raw}"`)
90
254
  }
91
255
 
256
+ throw new Error(`assignee not found: ${raw}`)
257
+ }
258
+
259
+ async function request({ apiBase, token, method, path, body, requiresAuth = true }) {
260
+ if (requiresAuth && !token) {
261
+ throw new Error("not logged in. run: unit-labs auth login")
262
+ }
263
+
264
+ const headers = {}
265
+ headers["X-Client-Source"] = "cli"
266
+ headers["User-Agent"] = "unit-labs-cli/0.1.0"
267
+ if (requiresAuth && token) {
268
+ headers.Authorization = `Bearer ${token}`
269
+ }
92
270
  if (body) {
93
271
  headers["Content-Type"] = "application/json"
94
272
  }
95
273
 
96
- const response = await fetch(url, {
274
+ const response = await fetch(`${apiBase}${path}`, {
97
275
  method,
98
276
  headers,
99
277
  body: body ? JSON.stringify(body) : undefined,
100
278
  })
101
279
 
102
- const text = await response.text()
103
- let payload = text
280
+ const rawText = await response.text()
281
+ let payload = rawText
104
282
 
105
283
  try {
106
- payload = text ? JSON.parse(text) : null
284
+ payload = rawText ? JSON.parse(rawText) : null
107
285
  } catch (error) {
108
- payload = text
286
+ payload = rawText
109
287
  }
110
288
 
111
289
  if (!response.ok) {
112
- const message = typeof payload === "string"
113
- ? payload
114
- : payload?.message || JSON.stringify(payload)
115
- throw new Error(`${response.status} ${message}`)
290
+ throw new Error(`${response.status} ${getErrorMessage(payload)}`)
116
291
  }
117
292
 
118
293
  return payload
119
294
  }
120
295
 
121
- async function authLogin(flags) {
122
- const config = readConfig()
123
- const apiBase = normalizeApiBase(flags.apiBase || config.apiBase || DEFAULT_API_BASE)
124
- const credentials = await promptMissingCredentials(flags)
296
+ function resolveByRef(items, ref, nameSelector, label) {
297
+ if (!ref) {
298
+ throw new Error(`${label} reference is required`)
299
+ }
125
300
 
126
- if (!credentials.login || !credentials.password) {
127
- throw new Error("login and password are required")
301
+ if (!Array.isArray(items) || items.length === 0) {
302
+ throw new Error(`no ${label}s found`)
128
303
  }
129
304
 
130
- const signinPayload = await request({
131
- method: "POST",
132
- url: `${apiBase}/signin`,
133
- body: {
134
- email: credentials.login,
135
- password: credentials.password,
136
- },
137
- })
305
+ const raw = String(ref).trim()
306
+ if (!raw) {
307
+ throw new Error(`${label} reference is required`)
308
+ }
138
309
 
139
- const token = signinPayload?.token
140
- if (!token) {
141
- throw new Error("signin response does not contain token")
310
+ if (/^\d+$/.test(raw)) {
311
+ const index = Number(raw) - 1
312
+ if (index < 0 || index >= items.length) {
313
+ throw new Error(`${label} index out of range: ${raw}`)
314
+ }
315
+ return items[index]
142
316
  }
143
317
 
144
- writeConfig({
145
- apiBase,
146
- token,
147
- })
318
+ const exactId = items.find(item => String(item._id) === raw)
319
+ if (exactId) {
320
+ return exactId
321
+ }
148
322
 
149
- let me
150
- try {
151
- me = await request({
323
+ const lower = raw.toLowerCase()
324
+ const exactNameMatches = items.filter(item => String(nameSelector(item)).toLowerCase() === lower)
325
+ if (exactNameMatches.length === 1) {
326
+ return exactNameMatches[0]
327
+ }
328
+ if (exactNameMatches.length > 1) {
329
+ throw new Error(`ambiguous ${label} name "${raw}"`)
330
+ }
331
+
332
+ const partialMatches = items.filter(item => String(nameSelector(item)).toLowerCase().includes(lower))
333
+ if (partialMatches.length === 1) {
334
+ return partialMatches[0]
335
+ }
336
+ if (partialMatches.length > 1) {
337
+ throw new Error(`ambiguous ${label} reference "${raw}"`)
338
+ }
339
+
340
+ throw new Error(`${label} not found: ${raw}`)
341
+ }
342
+
343
+ async function fetchProjects(config, teamId = null) {
344
+ if (teamId) {
345
+ const payload = await request({
346
+ apiBase: normalizeApiBase(config.apiBase),
347
+ token: config.token,
152
348
  method: "GET",
153
- url: `${apiBase}/me`,
154
- token,
349
+ path: `/teams/${teamId}/projects`,
155
350
  })
156
- } catch (error) {
157
- me = null
351
+
352
+ return payload?.data?.projects || []
158
353
  }
159
354
 
160
- printWelcomeBanner()
355
+ const payload = await request({
356
+ apiBase: normalizeApiBase(config.apiBase),
357
+ token: config.token,
358
+ method: "GET",
359
+ path: "/projects",
360
+ })
161
361
 
162
- if (me?.email) {
163
- console.log(`Logged in as ${me.email}`)
164
- } else {
165
- console.log("Logged in")
166
- }
167
- console.log(`Config saved: ${CONFIG_FILE}`)
362
+ return payload?.projects || []
168
363
  }
169
364
 
170
- function authLogout() {
171
- const config = readConfig()
172
- writeConfig({
173
- apiBase: normalizeApiBase(config.apiBase || DEFAULT_API_BASE),
174
- token: null,
365
+ async function fetchProjectById(config, projectId) {
366
+ return request({
367
+ apiBase: normalizeApiBase(config.apiBase),
368
+ token: config.token,
369
+ method: "GET",
370
+ path: `/projects/${projectId}`,
175
371
  })
176
- console.log("Logged out")
177
372
  }
178
373
 
179
- async function authWhoAmI() {
180
- const config = readConfig()
181
- const token = config.token
374
+ async function fetchStages(config, projectId) {
375
+ const payload = await request({
376
+ apiBase: normalizeApiBase(config.apiBase),
377
+ token: config.token,
378
+ method: "GET",
379
+ path: `/projects/${projectId}/stages`,
380
+ })
182
381
 
183
- if (!token) {
184
- throw new Error("not logged in. run: unit-labs auth login")
185
- }
382
+ return Array.isArray(payload) ? payload : []
383
+ }
384
+
385
+ async function fetchCurrentUser(config) {
386
+ return request({
387
+ apiBase: normalizeApiBase(config.apiBase),
388
+ token: config.token,
389
+ method: "GET",
390
+ path: "/me",
391
+ })
392
+ }
186
393
 
187
- const me = await request({
394
+ async function fetchTeamMembers(config, teamId) {
395
+ const payload = await request({
396
+ apiBase: normalizeApiBase(config.apiBase),
397
+ token: config.token,
188
398
  method: "GET",
189
- url: `${normalizeApiBase(config.apiBase || DEFAULT_API_BASE)}/me`,
190
- token,
399
+ path: `/teams/${teamId}/members`,
191
400
  })
192
401
 
193
- console.log(JSON.stringify(me, null, 2))
402
+ return payload?.data?.members || []
194
403
  }
195
404
 
196
- function configShow() {
197
- const config = readConfig()
198
- console.log(JSON.stringify({
199
- apiBase: normalizeApiBase(config.apiBase || DEFAULT_API_BASE),
200
- loggedIn: Boolean(config.token),
201
- }, null, 2))
405
+ async function fetchFlatTasks(config, projectId) {
406
+ const payload = await request({
407
+ apiBase: normalizeApiBase(config.apiBase),
408
+ token: config.token,
409
+ method: "GET",
410
+ path: `/projects/${projectId}/tasks`,
411
+ })
412
+
413
+ return Array.isArray(payload) ? payload : []
414
+ }
415
+
416
+ async function fetchStageTasks(config, projectId, stageId) {
417
+ const payload = await request({
418
+ apiBase: normalizeApiBase(config.apiBase),
419
+ token: config.token,
420
+ method: "GET",
421
+ path: `/projects/${projectId}/stages/${stageId}/tasks`,
422
+ })
423
+
424
+ return Array.isArray(payload) ? payload : []
202
425
  }
203
426
 
204
- function configSetApi(rawApi) {
205
- if (!rawApi) {
206
- throw new Error("api url is required. usage: unit-labs config set-api <url>")
427
+ async function resolveProjectContext(config, projectRef, requireProject = true) {
428
+ const projects = await fetchProjects(config)
429
+ let project = null
430
+
431
+ if (projectRef) {
432
+ project = resolveByRef(projects, projectRef, p => p.project_name, "project")
433
+ } else if (config.currentProjectId) {
434
+ project = projects.find(p => String(p._id) === String(config.currentProjectId)) || null
207
435
  }
208
436
 
209
- const config = readConfig()
210
- const apiBase = normalizeApiBase(rawApi)
211
- writeConfig({
212
- apiBase,
213
- token: config.token || null,
214
- })
215
- console.log(`API saved: ${apiBase}`)
437
+ if (!project && requireProject) {
438
+ throw new Error("no project selected. run: unit-labs project ls && unit-labs project use <number|name>")
439
+ }
440
+
441
+ return { project, projects }
216
442
  }
217
443
 
218
- async function run(argv) {
219
- if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
220
- console.log(HELP_TEXT.trim())
444
+ async function resolveStageContext(config, project, stageRef, requireStage = true) {
445
+ if (!project) {
446
+ throw new Error("project is required")
447
+ }
448
+
449
+ if ((project.workflow_type || "stages") === "flat") {
450
+ throw new Error("this project uses flat workflow. stage commands are unavailable")
451
+ }
452
+
453
+ const stages = await fetchStages(config, project._id)
454
+ let stage = null
455
+
456
+ if (stageRef) {
457
+ stage = resolveByRef(stages, stageRef, s => s.stage_name, "stage")
458
+ } else if (config.currentStageId) {
459
+ stage = stages.find(s => String(s._id) === String(config.currentStageId)) || null
460
+ }
461
+
462
+ if (!stage && requireStage) {
463
+ throw new Error("no stage selected. run: unit-labs stage ls && unit-labs stage use <number|name>")
464
+ }
465
+
466
+ return { stage, stages }
467
+ }
468
+
469
+ async function resolveAssigneeUserId(config, project, assigneeRef) {
470
+ const raw = String(assigneeRef || "").trim()
471
+ if (!raw) {
472
+ throw new Error("assignee reference is required. use: unit-labs task assign <task> <me|none|number|username|email|id>")
473
+ }
474
+
475
+ const normalized = raw.toLowerCase()
476
+ if (ASSIGNEE_CLEAR_REFS.has(normalized)) {
477
+ return { userId: null, label: "none" }
478
+ }
479
+
480
+ if (ASSIGNEE_SELF_REFS.has(normalized)) {
481
+ const me = await fetchCurrentUser(config)
482
+ const meId = me?.id || me?._id
483
+ if (!meId) {
484
+ throw new Error("failed to resolve current user id from /me")
485
+ }
486
+ return {
487
+ userId: String(meId),
488
+ label: me?.email || me?.username || String(meId),
489
+ }
490
+ }
491
+
492
+ if (project.team_id) {
493
+ const members = await fetchTeamMembers(config, project.team_id)
494
+ const member = resolveMemberByRef(members, raw)
495
+ const memberId = member?.id || member?._id
496
+ if (!memberId) {
497
+ throw new Error("failed to resolve member id")
498
+ }
499
+ return {
500
+ userId: String(memberId),
501
+ label: member?.email || member?.username || String(memberId),
502
+ }
503
+ }
504
+
505
+ const me = await fetchCurrentUser(config)
506
+ const meId = me?.id || me?._id
507
+ const meEmail = String(me?.email || "").toLowerCase()
508
+ const meUsername = String(me?.username || "").toLowerCase()
509
+ const matchesMe = [String(meId || ""), meEmail, meUsername].includes(normalized)
510
+ if (!matchesMe) {
511
+ throw new Error("for personal project assignee can only be project owner (use `me`)")
512
+ }
513
+
514
+ return {
515
+ userId: String(meId),
516
+ label: me?.email || me?.username || String(meId),
517
+ }
518
+ }
519
+
520
+ function saveConfigPatch(config, patch) {
521
+ const next = {
522
+ ...config,
523
+ ...patch,
524
+ }
525
+ writeConfig(next)
526
+ return next
527
+ }
528
+
529
+ async function promptMissingCredentials({ login, password }) {
530
+ if (login && password) {
531
+ return { login, password }
532
+ }
533
+
534
+ const rl = readline.createInterface({ input, output })
535
+ const nextLogin = login || (await rl.question("Login: ")).trim()
536
+ const nextPassword = password || (await rl.question("Password: ")).trim()
537
+ rl.close()
538
+
539
+ return {
540
+ login: nextLogin,
541
+ password: nextPassword,
542
+ }
543
+ }
544
+
545
+ async function runAuth(command, args, config) {
546
+ const { flags } = parseArgs(args, { l: "login", e: "login", p: "password", h: "help" })
547
+ const apiBase = normalizeApiBase(flags.api || config.apiBase || DEFAULT_API_BASE)
548
+
549
+ if (!command || command === "--help" || command === "-h" || flags.help) {
550
+ console.log(AUTH_HELP.trim())
221
551
  return
222
552
  }
223
553
 
224
- const [scope, command, ...rest] = argv
554
+ if (command === "login") {
555
+ const credentials = await promptMissingCredentials(flags)
556
+ if (!credentials.login || !credentials.password) {
557
+ throw new Error("login and password are required")
558
+ }
225
559
 
226
- if (scope === "auth") {
227
- const flags = parseFlags(rest)
560
+ const signinPayload = await request({
561
+ apiBase,
562
+ method: "POST",
563
+ path: "/signin",
564
+ body: { email: credentials.login, password: credentials.password },
565
+ requiresAuth: false,
566
+ })
567
+
568
+ const token = signinPayload?.token
569
+ if (!token) {
570
+ throw new Error("signin response does not contain token")
571
+ }
572
+
573
+ let me = null
574
+ try {
575
+ me = await request({
576
+ apiBase,
577
+ token,
578
+ method: "GET",
579
+ path: "/me",
580
+ })
581
+ } catch (error) {
582
+ me = null
583
+ }
584
+
585
+ saveConfigPatch(config, {
586
+ apiBase,
587
+ token,
588
+ })
589
+
590
+ printWelcomeBanner()
591
+ if (me?.email) {
592
+ console.log(`Logged in as ${me.email}`)
593
+ } else {
594
+ console.log("Logged in")
595
+ }
596
+ console.log(`Config saved: ${CONFIG_FILE}`)
597
+ return
598
+ }
599
+
600
+ if (command === "logout") {
601
+ saveConfigPatch(config, { token: null })
602
+ console.log("Logged out")
603
+ return
604
+ }
605
+
606
+ if (command === "whoami") {
607
+ const me = await request({
608
+ apiBase,
609
+ token: config.token,
610
+ method: "GET",
611
+ path: "/me",
612
+ })
613
+ printJson(me)
614
+ return
615
+ }
616
+
617
+ throw new Error(`unknown auth command: ${command}`)
618
+ }
619
+
620
+ async function runConfig(command, args, config) {
621
+ const { flags, positionals } = parseArgs(args, { h: "help" })
622
+
623
+ if (!command || command === "--help" || command === "-h" || flags.help) {
624
+ console.log(CONFIG_HELP.trim())
625
+ return
626
+ }
627
+
628
+ if (command === "show") {
629
+ printJson({
630
+ apiBase: normalizeApiBase(config.apiBase || DEFAULT_API_BASE),
631
+ loggedIn: Boolean(config.token),
632
+ currentProjectId: config.currentProjectId,
633
+ currentProjectName: config.currentProjectName,
634
+ currentProjectWorkflow: config.currentProjectWorkflow,
635
+ currentStageId: config.currentStageId,
636
+ currentStageName: config.currentStageName,
637
+ })
638
+ return
639
+ }
640
+
641
+ if (command === "set-api") {
642
+ const rawApi = positionals[0] || flags.api
643
+ if (!rawApi) {
644
+ throw new Error("api url is required. usage: unit-labs config set-api <url>")
645
+ }
646
+ const apiBase = normalizeApiBase(rawApi)
647
+ saveConfigPatch(config, { apiBase })
648
+ console.log(`API saved: ${apiBase}`)
649
+ return
650
+ }
651
+
652
+ throw new Error(`unknown config command: ${command}`)
653
+ }
654
+
655
+ async function runProject(command, args, config) {
656
+ const { flags, positionals } = parseArgs(args, { t: "team", w: "workflow", n: "name", j: "json", h: "help" })
657
+
658
+ if (!command || command === "--help" || command === "-h" || flags.help) {
659
+ console.log(PROJECT_HELP.trim())
660
+ return
661
+ }
662
+
663
+ if (command === "ls") {
664
+ const projects = await fetchProjects(config, flags.team || null)
665
+
666
+ if (flags.json) {
667
+ printJson(projects)
668
+ return
669
+ }
670
+
671
+ printProjects(projects, config.currentProjectId)
672
+ return
673
+ }
674
+
675
+ if (command === "new") {
676
+ const name = (flags.name || positionals.join(" ")).trim()
677
+ if (!name) {
678
+ throw new Error("project name is required. use: --name \"...\"")
679
+ }
680
+
681
+ const workflow = (flags.workflow || "stages").trim()
682
+ if (!["stages", "flat"].includes(workflow)) {
683
+ throw new Error("workflow must be one of: stages, flat")
684
+ }
685
+
686
+ const body = {
687
+ project_name: name,
688
+ workflow_type: workflow,
689
+ }
690
+ if (flags.team) {
691
+ body.team_id = flags.team
692
+ }
693
+
694
+ const payload = await request({
695
+ apiBase: normalizeApiBase(config.apiBase),
696
+ token: config.token,
697
+ method: "POST",
698
+ path: "/projects",
699
+ body,
700
+ })
701
+
702
+ if (typeof payload === "string") {
703
+ console.log(payload)
704
+ } else {
705
+ printJson(payload)
706
+ }
707
+ return
708
+ }
709
+
710
+ if (command === "view") {
711
+ const ref = positionals[0]
712
+ const { project } = await resolveProjectContext(config, ref, true)
713
+
714
+ let data = project
715
+ try {
716
+ data = await fetchProjectById(config, project._id)
717
+ } catch (error) {
718
+ data = project
719
+ }
228
720
 
229
- if (command === "login") {
230
- await authLogin(flags)
721
+ if (flags.json) {
722
+ printJson(data)
231
723
  return
232
724
  }
233
725
 
234
- if (command === "logout") {
235
- authLogout()
726
+ console.log(`Name: ${data.project_name}`)
727
+ console.log(`Workflow: ${data.workflow_type || "stages"}`)
728
+ console.log(`Status: ${data.status || "-"}`)
729
+ console.log(`ID: ${data._id}`)
730
+ return
731
+ }
732
+
733
+ if (command === "use") {
734
+ const ref = positionals[0]
735
+ if (!ref) {
736
+ throw new Error("project reference is required. use: unit-labs project use <number|name|id>")
737
+ }
738
+
739
+ const { project } = await resolveProjectContext(config, ref, true)
740
+ const workflow = project.workflow_type || "stages"
741
+ saveConfigPatch(config, {
742
+ currentProjectId: project._id,
743
+ currentProjectName: project.project_name,
744
+ currentProjectWorkflow: workflow,
745
+ currentStageId: null,
746
+ currentStageName: null,
747
+ })
748
+ console.log(`Current project: ${project.project_name} [${workflow}]`)
749
+ return
750
+ }
751
+
752
+ if (command === "current") {
753
+ if (!config.currentProjectId) {
754
+ throw new Error("no current project. use: unit-labs project use <number|name>")
755
+ }
756
+
757
+ const { project } = await resolveProjectContext(config, null, true)
758
+
759
+ if (flags.json) {
760
+ printJson(project)
236
761
  return
237
762
  }
238
763
 
239
- if (command === "whoami") {
240
- await authWhoAmI()
764
+ console.log(`Current project: ${project.project_name}`)
765
+ console.log(`Workflow: ${project.workflow_type || "stages"}`)
766
+ console.log(`ID: ${project._id}`)
767
+ return
768
+ }
769
+
770
+ throw new Error(`unknown project command: ${command}`)
771
+ }
772
+
773
+ async function runStage(command, args, config) {
774
+ const { flags, positionals } = parseArgs(args, {
775
+ p: "project",
776
+ n: "name",
777
+ d: "description",
778
+ j: "json",
779
+ h: "help",
780
+ })
781
+
782
+ if (!command || command === "--help" || command === "-h" || flags.help) {
783
+ console.log(STAGE_HELP.trim())
784
+ return
785
+ }
786
+
787
+ const projectRef = flags.project || null
788
+ const { project } = await resolveProjectContext(config, projectRef, true)
789
+ if ((project.workflow_type || "stages") === "flat") {
790
+ throw new Error("this project uses flat workflow. stage commands are unavailable")
791
+ }
792
+
793
+ if (command === "ls") {
794
+ const stages = await fetchStages(config, project._id)
795
+ if (flags.json) {
796
+ printJson(stages)
241
797
  return
242
798
  }
799
+ printStages(stages, config.currentStageId)
800
+ return
801
+ }
802
+
803
+ if (command === "new") {
804
+ const stageName = (flags.name || "").trim()
805
+ const description = (flags.description || "").trim()
806
+
807
+ if (!stageName || !description) {
808
+ throw new Error("stage name and description are required. use: --name \"...\" --description \"...\"")
809
+ }
243
810
 
244
- throw new Error(`unknown auth command: ${command}`)
811
+ const payload = await request({
812
+ apiBase: normalizeApiBase(config.apiBase),
813
+ token: config.token,
814
+ method: "POST",
815
+ path: `/projects/${project._id}/stages`,
816
+ body: {
817
+ stage_name: stageName,
818
+ description,
819
+ },
820
+ })
821
+
822
+ if (typeof payload === "string") {
823
+ console.log(payload)
824
+ } else {
825
+ printJson(payload)
826
+ }
827
+ return
245
828
  }
246
829
 
247
- if (scope === "config") {
248
- if (command === "show") {
249
- configShow()
830
+ if (command === "use") {
831
+ const ref = positionals[0]
832
+ if (!ref) {
833
+ throw new Error("stage reference is required. use: unit-labs stage use <number|name|id>")
834
+ }
835
+
836
+ const { stage } = await resolveStageContext(config, project, ref, true)
837
+ saveConfigPatch(config, {
838
+ currentProjectId: project._id,
839
+ currentProjectName: project.project_name,
840
+ currentProjectWorkflow: project.workflow_type || "stages",
841
+ currentStageId: stage._id,
842
+ currentStageName: stage.stage_name,
843
+ })
844
+ console.log(`Current stage: ${stage.stage_name}`)
845
+ return
846
+ }
847
+
848
+ if (command === "current") {
849
+ if (!config.currentStageId) {
850
+ throw new Error("no current stage. use: unit-labs stage use <number|name>")
851
+ }
852
+
853
+ const { stage } = await resolveStageContext(config, project, null, true)
854
+ if (flags.json) {
855
+ printJson(stage)
856
+ return
857
+ }
858
+ console.log(`Current stage: ${stage.stage_name}`)
859
+ console.log(`Status: ${stage.status || "-"}`)
860
+ console.log(`ID: ${stage._id}`)
861
+ return
862
+ }
863
+
864
+ throw new Error(`unknown stage command: ${command}`)
865
+ }
866
+
867
+ async function resolveTaskContext(config, flags, requireStage = true) {
868
+ const projectRef = flags.project || null
869
+ const { project } = await resolveProjectContext(config, projectRef, true)
870
+ const workflow = project.workflow_type || "stages"
871
+
872
+ if (workflow === "flat") {
873
+ return { project, workflow, stage: null }
874
+ }
875
+
876
+ const stageRef = flags.stage || null
877
+ const { stage } = await resolveStageContext(config, project, stageRef, requireStage)
878
+ return { project, workflow, stage }
879
+ }
880
+
881
+ async function listAllStageTasks(config, project) {
882
+ const stages = await fetchStages(config, project._id)
883
+ const result = []
884
+
885
+ for (const stage of stages) {
886
+ const tasks = await fetchStageTasks(config, project._id, stage._id)
887
+ tasks.forEach(task => {
888
+ result.push({
889
+ ...task,
890
+ _stageName: stage.stage_name,
891
+ _stageId: stage._id,
892
+ })
893
+ })
894
+ }
895
+
896
+ return result
897
+ }
898
+
899
+ async function runTask(command, args, config) {
900
+ const { flags, positionals } = parseArgs(args, {
901
+ p: "project",
902
+ s: "stage",
903
+ t: "title",
904
+ a: "all",
905
+ j: "json",
906
+ h: "help",
907
+ })
908
+
909
+ if (!command || command === "--help" || command === "-h" || flags.help) {
910
+ console.log(TASK_HELP.trim())
911
+ return
912
+ }
913
+
914
+ if (command === "ls") {
915
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, !flags.all)
916
+
917
+ if (workflow === "flat") {
918
+ const tasks = await fetchFlatTasks(config, project._id)
919
+ if (flags.json) {
920
+ printJson(tasks)
921
+ return
922
+ }
923
+ printTasks(tasks, false)
924
+ return
925
+ }
926
+
927
+ if (flags.all) {
928
+ const tasks = await listAllStageTasks(config, project)
929
+ if (flags.json) {
930
+ printJson(tasks)
931
+ return
932
+ }
933
+ printTasks(tasks, true)
250
934
  return
251
935
  }
252
936
 
253
- if (command === "set-api") {
254
- configSetApi(rest[0])
937
+ const tasks = await fetchStageTasks(config, project._id, stage._id)
938
+ if (flags.json) {
939
+ printJson(tasks)
940
+ return
941
+ }
942
+ printTasks(tasks, false)
943
+ return
944
+ }
945
+
946
+ if (command === "new") {
947
+ const title = (flags.title || positionals.join(" ")).trim()
948
+ if (!title) {
949
+ throw new Error("task title is required. use: unit-labs task new \"Task title\"")
950
+ }
951
+
952
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
953
+
954
+ const path = workflow === "flat"
955
+ ? `/projects/${project._id}/tasks`
956
+ : `/projects/${project._id}/stages/${stage._id}/tasks`
957
+
958
+ const payload = await request({
959
+ apiBase: normalizeApiBase(config.apiBase),
960
+ token: config.token,
961
+ method: "POST",
962
+ path,
963
+ body: { title },
964
+ })
965
+
966
+ if (Array.isArray(payload)) {
967
+ printTasks(payload, false)
968
+ } else if (typeof payload === "string") {
969
+ console.log(payload)
970
+ } else {
971
+ printJson(payload)
972
+ }
973
+ return
974
+ }
975
+
976
+ if (command === "done" || command === "close") {
977
+ const taskRef = positionals[0]
978
+ if (!taskRef) {
979
+ throw new Error("task reference is required. use: unit-labs task done <number|title|id>")
980
+ }
981
+
982
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
983
+ const tasks = workflow === "flat"
984
+ ? await fetchFlatTasks(config, project._id)
985
+ : await fetchStageTasks(config, project._id, stage._id)
986
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
987
+
988
+ const path = workflow === "flat"
989
+ ? `/projects/${project._id}/tasks/${task._id}/toggle`
990
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/toggle`
991
+
992
+ const updated = await request({
993
+ apiBase: normalizeApiBase(config.apiBase),
994
+ token: config.token,
995
+ method: "PATCH",
996
+ path,
997
+ })
998
+
999
+ printTasks([updated], false)
1000
+ return
1001
+ }
1002
+
1003
+ if (command === "assign" || command === "unassign") {
1004
+ const taskRef = positionals[0]
1005
+ const assigneeRef = command === "unassign" ? "none" : positionals[1]
1006
+
1007
+ if (!taskRef) {
1008
+ throw new Error("task reference is required. use: unit-labs task assign <number|title|id> <assignee>")
1009
+ }
1010
+ if (!assigneeRef) {
1011
+ throw new Error("assignee reference is required. use: unit-labs task assign <task> <me|none|number|username|email|id>")
1012
+ }
1013
+
1014
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
1015
+ const tasks = workflow === "flat"
1016
+ ? await fetchFlatTasks(config, project._id)
1017
+ : await fetchStageTasks(config, project._id, stage._id)
1018
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
1019
+ const assignee = await resolveAssigneeUserId(config, project, assigneeRef)
1020
+
1021
+ const path = workflow === "flat"
1022
+ ? `/projects/${project._id}/tasks/${task._id}/assign`
1023
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/assign`
1024
+
1025
+ const updated = await request({
1026
+ apiBase: normalizeApiBase(config.apiBase),
1027
+ token: config.token,
1028
+ method: "PATCH",
1029
+ path,
1030
+ body: { user_id: assignee.userId },
1031
+ })
1032
+
1033
+ if (flags.json) {
1034
+ printJson(updated)
255
1035
  return
256
1036
  }
257
1037
 
258
- throw new Error(`unknown config command: ${command}`)
1038
+ printTasks([updated], false)
1039
+ if (assignee.userId) {
1040
+ console.log(`Assigned to: ${assignee.label}`)
1041
+ } else {
1042
+ console.log("Assignee cleared")
1043
+ }
1044
+ return
1045
+ }
1046
+
1047
+ if (command === "edit") {
1048
+ const taskRef = positionals[0]
1049
+ const title = (flags.title || positionals.slice(1).join(" ")).trim()
1050
+
1051
+ if (!taskRef) {
1052
+ throw new Error("task reference is required. use: unit-labs task edit <number|title|id> --title \"...\"")
1053
+ }
1054
+ if (!title) {
1055
+ throw new Error("new title is required. use: --title \"New title\"")
1056
+ }
1057
+
1058
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
1059
+ const tasks = workflow === "flat"
1060
+ ? await fetchFlatTasks(config, project._id)
1061
+ : await fetchStageTasks(config, project._id, stage._id)
1062
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
1063
+
1064
+ const path = workflow === "flat"
1065
+ ? `/projects/${project._id}/tasks/${task._id}/title`
1066
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/title`
1067
+
1068
+ const updated = await request({
1069
+ apiBase: normalizeApiBase(config.apiBase),
1070
+ token: config.token,
1071
+ method: "PATCH",
1072
+ path,
1073
+ body: { title },
1074
+ })
1075
+
1076
+ printTasks([updated], false)
1077
+ return
1078
+ }
1079
+
1080
+ if (command === "rm" || command === "delete") {
1081
+ const taskRef = positionals[0]
1082
+ if (!taskRef) {
1083
+ throw new Error("task reference is required. use: unit-labs task rm <number|title|id>")
1084
+ }
1085
+
1086
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
1087
+ const tasks = workflow === "flat"
1088
+ ? await fetchFlatTasks(config, project._id)
1089
+ : await fetchStageTasks(config, project._id, stage._id)
1090
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
1091
+
1092
+ const path = workflow === "flat"
1093
+ ? `/projects/${project._id}/tasks/${task._id}`
1094
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}`
1095
+
1096
+ await request({
1097
+ apiBase: normalizeApiBase(config.apiBase),
1098
+ token: config.token,
1099
+ method: "DELETE",
1100
+ path,
1101
+ })
1102
+
1103
+ console.log(`Deleted task: ${task.title}`)
1104
+ return
1105
+ }
1106
+
1107
+ throw new Error(`unknown task command: ${command}`)
1108
+ }
1109
+
1110
+ async function run(argv) {
1111
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
1112
+ console.log(ROOT_HELP.trim())
1113
+ return
1114
+ }
1115
+
1116
+ const [scope, command, ...rest] = argv
1117
+ const config = readConfig()
1118
+
1119
+ if (scope === "auth") {
1120
+ await runAuth(command, rest, config)
1121
+ return
1122
+ }
1123
+
1124
+ if (scope === "config") {
1125
+ await runConfig(command, rest, config)
1126
+ return
1127
+ }
1128
+
1129
+ if (scope === "project") {
1130
+ await runProject(command, rest, config)
1131
+ return
1132
+ }
1133
+
1134
+ if (scope === "stage") {
1135
+ await runStage(command, rest, config)
1136
+ return
1137
+ }
1138
+
1139
+ if (scope === "task") {
1140
+ await runTask(command, rest, config)
1141
+ return
259
1142
  }
260
1143
 
261
1144
  throw new Error(`unknown scope: ${scope}`)
@@ -264,4 +1147,3 @@ async function run(argv) {
264
1147
  module.exports = {
265
1148
  run,
266
1149
  }
267
-