@yokio42/unit-labs-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # @yokio42/unit-labs-cli
2
+
3
+ CLI для Unit Labs: логин, выбор проекта/стейджа и работа с задачами из терминала.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g @yokio42/unit-labs-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ unit-labs auth login
15
+ unit-labs project ls
16
+ unit-labs project use 1
17
+ unit-labs task ls
18
+ unit-labs task new "Ship MVP"
19
+ ```
20
+
21
+ ## Commands
22
+
23
+ ### Auth
24
+ - `unit-labs auth login`
25
+ - `unit-labs auth whoami`
26
+ - `unit-labs auth logout`
27
+
28
+ ### Config
29
+ - `unit-labs config show`
30
+ - `unit-labs config set-api <url>`
31
+
32
+ ### Project
33
+ - `unit-labs project ls [--team <teamId>] [--json]`
34
+ - `unit-labs project new --name "<name>" [--workflow stages|flat] [--team <teamId>]`
35
+ - `unit-labs project view <number|name|id> [--json]`
36
+ - `unit-labs project use <number|name|id>`
37
+ - `unit-labs project current [--json]`
38
+
39
+ ### Stage
40
+ - `unit-labs stage ls [--project <number|name|id>] [--json]`
41
+ - `unit-labs stage new --name "<name>" --description "<text>" [--project <number|name|id>]`
42
+ - `unit-labs stage use <number|name|id> [--project <number|name|id>]`
43
+ - `unit-labs stage current [--json]`
44
+
45
+ ### Task
46
+ - `unit-labs task ls [--project <number|name|id>] [--stage <number|name|id>] [--all] [--json]`
47
+ - `unit-labs task new "<title>" [--project <number|name|id>] [--stage <number|name|id>]`
48
+ - `unit-labs task done <number|title|id>`
49
+ - `unit-labs task close <number|title|id>`
50
+ - `unit-labs task edit <number|title|id> --title "<new title>"`
51
+ - `unit-labs task rm <number|title|id>`
52
+ - `unit-labs task delete <number|title|id>`
53
+
54
+ ## Notes
55
+
56
+ - По умолчанию API: `https://popuitka2-be.onrender.com`
57
+ - CLI хранит `apiBase`, токен и текущий контекст в `~/.unit-labs/config.json`
58
+ - Для проектов с `workflow=flat` команды `stage` недоступны
59
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yokio42/unit-labs-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Unit Labs command line interface",
5
5
  "bin": {
6
6
  "unit-labs": "./bin/unit-labs.js"
package/src/config.js CHANGED
@@ -20,37 +20,53 @@ function ensureConfigDir() {
20
20
  }
21
21
  }
22
22
 
23
+ function getDefaultConfig() {
24
+ return {
25
+ apiBase: DEFAULT_API_BASE,
26
+ token: null,
27
+ currentProjectId: null,
28
+ currentProjectName: null,
29
+ currentProjectWorkflow: null,
30
+ currentStageId: null,
31
+ currentStageName: null,
32
+ }
33
+ }
34
+
35
+ function normalizeConfig(input) {
36
+ const base = getDefaultConfig()
37
+ const raw = input && typeof input === "object" ? input : {}
38
+
39
+ return {
40
+ ...base,
41
+ ...raw,
42
+ apiBase: normalizeApiBase(raw.apiBase || base.apiBase),
43
+ token: raw.token || null,
44
+ currentProjectId: raw.currentProjectId || null,
45
+ currentProjectName: raw.currentProjectName || null,
46
+ currentProjectWorkflow: raw.currentProjectWorkflow || null,
47
+ currentStageId: raw.currentStageId || null,
48
+ currentStageName: raw.currentStageName || null,
49
+ }
50
+ }
51
+
23
52
  function readConfig() {
24
53
  ensureConfigDir()
25
54
 
26
55
  if (!fs.existsSync(CONFIG_FILE)) {
27
- return {
28
- apiBase: DEFAULT_API_BASE,
29
- token: null,
30
- }
56
+ return getDefaultConfig()
31
57
  }
32
58
 
33
59
  try {
34
60
  const raw = fs.readFileSync(CONFIG_FILE, "utf-8")
35
- const parsed = JSON.parse(raw)
36
- return {
37
- apiBase: normalizeApiBase(parsed.apiBase),
38
- token: parsed.token || null,
39
- }
61
+ return normalizeConfig(JSON.parse(raw))
40
62
  } catch (error) {
41
- return {
42
- apiBase: DEFAULT_API_BASE,
43
- token: null,
44
- }
63
+ return getDefaultConfig()
45
64
  }
46
65
  }
47
66
 
48
67
  function writeConfig(config) {
49
68
  ensureConfigDir()
50
- const normalized = {
51
- apiBase: normalizeApiBase(config.apiBase),
52
- token: config.token || null,
53
- }
69
+ const normalized = normalizeConfig(config)
54
70
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalized, null, 2), "utf-8")
55
71
  }
56
72
 
@@ -61,3 +77,4 @@ module.exports = {
61
77
  readConfig,
62
78
  writeConfig,
63
79
  }
80
+
package/src/index.js CHANGED
@@ -3,78 +3,178 @@ 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
+ `
47
+
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]
55
+ `
56
+
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
+ `
23
64
 
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
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 edit <number|title|id> --title "<new title>" [--project ...] [--stage ...]
72
+ unit-labs task rm <number|title|id> [--project ...] [--stage ...]
73
+ unit-labs task delete <number|title|id> [--project ...] [--stage ...]
30
74
  `
31
75
 
32
- function parseFlags(argv) {
76
+ function parseArgs(argv, shortAliases = {}) {
33
77
  const flags = {}
78
+ const positionals = []
34
79
 
35
80
  for (let i = 0; i < argv.length; i += 1) {
36
- const key = argv[i]
37
- const value = argv[i + 1]
81
+ const arg = argv[i]
38
82
 
39
- if (key === "-l" || key === "--login") {
40
- flags.login = value
41
- i += 1
42
- continue
83
+ if (arg === "--") {
84
+ positionals.push(...argv.slice(i + 1))
85
+ break
43
86
  }
44
- if (key === "-e" || key === "--email") {
45
- flags.login = value
46
- i += 1
47
- continue
48
- }
49
- if (key === "-p" || key === "--password") {
50
- flags.password = value
51
- i += 1
87
+
88
+ if (arg.startsWith("--")) {
89
+ const [rawKey, inlineValue] = arg.slice(2).split("=")
90
+ if (inlineValue !== undefined) {
91
+ flags[rawKey] = inlineValue
92
+ continue
93
+ }
94
+
95
+ const next = argv[i + 1]
96
+ if (next && !next.startsWith("-")) {
97
+ flags[rawKey] = next
98
+ i += 1
99
+ } else {
100
+ flags[rawKey] = true
101
+ }
52
102
  continue
53
103
  }
54
- if (key === "--api") {
55
- flags.apiBase = value
56
- i += 1
104
+
105
+ if (arg.startsWith("-") && arg.length > 1) {
106
+ const short = arg.slice(1)
107
+ const key = shortAliases[short] || short
108
+ const next = argv[i + 1]
109
+ if (next && !next.startsWith("-")) {
110
+ flags[key] = next
111
+ i += 1
112
+ } else {
113
+ flags[key] = true
114
+ }
57
115
  continue
58
116
  }
117
+
118
+ positionals.push(arg)
59
119
  }
60
120
 
61
- return flags
121
+ return { flags, positionals }
62
122
  }
63
123
 
64
- async function promptMissingCredentials({ login, password }) {
65
- if (login && password) {
66
- return { login, password }
124
+ function shortId(id) {
125
+ if (!id) return "-"
126
+ const value = String(id)
127
+ if (value.length <= 8) return value
128
+ return `${value.slice(0, 4)}…${value.slice(-4)}`
129
+ }
130
+
131
+ function printJson(value) {
132
+ console.log(JSON.stringify(value, null, 2))
133
+ }
134
+
135
+ function printProjects(projects, currentProjectId) {
136
+ if (!Array.isArray(projects) || projects.length === 0) {
137
+ console.log("No projects")
138
+ return
67
139
  }
68
140
 
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()
141
+ projects.forEach((project, index) => {
142
+ const marker = String(project._id) === String(currentProjectId) ? "*" : " "
143
+ const workflow = project.workflow_type || "stages"
144
+ console.log(
145
+ `${String(index + 1).padStart(2)}.${marker} ${project.project_name} [${workflow}] ${shortId(project._id)}`
146
+ )
147
+ })
148
+ }
73
149
 
74
- return {
75
- login: nextLogin,
76
- password: nextPassword,
150
+ function printStages(stages, currentStageId) {
151
+ if (!Array.isArray(stages) || stages.length === 0) {
152
+ console.log("No stages")
153
+ return
77
154
  }
155
+
156
+ stages.forEach((stage, index) => {
157
+ const marker = String(stage._id) === String(currentStageId) ? "*" : " "
158
+ const status = stage.status || "-"
159
+ console.log(
160
+ `${String(index + 1).padStart(2)}.${marker} ${stage.stage_name} [${status}] ${shortId(stage._id)}`
161
+ )
162
+ })
163
+ }
164
+
165
+ function printTasks(tasks, withStage = false) {
166
+ if (!Array.isArray(tasks) || tasks.length === 0) {
167
+ console.log("No tasks")
168
+ return
169
+ }
170
+
171
+ tasks.forEach((task, index) => {
172
+ const done = task.is_done ? "x" : " "
173
+ const stagePrefix = withStage && task._stageName ? `(${task._stageName}) ` : ""
174
+ console.log(
175
+ `${String(index + 1).padStart(2)}. [${done}] ${stagePrefix}${task.title} ${shortId(task._id)}`
176
+ )
177
+ })
78
178
  }
79
179
 
80
180
  function printWelcomeBanner() {
@@ -82,180 +182,782 @@ function printWelcomeBanner() {
82
182
  console.log(UNIT_LABS_BANNER)
83
183
  }
84
184
 
85
- async function request({ method, url, token, body }) {
86
- const headers = {}
185
+ function getErrorMessage(payload) {
186
+ if (typeof payload === "string") return payload
187
+ if (payload && typeof payload === "object" && payload.message) return payload.message
188
+ try {
189
+ return JSON.stringify(payload)
190
+ } catch (error) {
191
+ return "unknown error"
192
+ }
193
+ }
87
194
 
88
- if (token) {
89
- headers.Authorization = `Bearer ${token}`
195
+ async function request({ apiBase, token, method, path, body, requiresAuth = true }) {
196
+ if (requiresAuth && !token) {
197
+ throw new Error("not logged in. run: unit-labs auth login")
90
198
  }
91
199
 
200
+ const headers = {}
201
+ if (requiresAuth && token) {
202
+ headers.Authorization = `Bearer ${token}`
203
+ }
92
204
  if (body) {
93
205
  headers["Content-Type"] = "application/json"
94
206
  }
95
207
 
96
- const response = await fetch(url, {
208
+ const response = await fetch(`${apiBase}${path}`, {
97
209
  method,
98
210
  headers,
99
211
  body: body ? JSON.stringify(body) : undefined,
100
212
  })
101
213
 
102
- const text = await response.text()
103
- let payload = text
214
+ const rawText = await response.text()
215
+ let payload = rawText
104
216
 
105
217
  try {
106
- payload = text ? JSON.parse(text) : null
218
+ payload = rawText ? JSON.parse(rawText) : null
107
219
  } catch (error) {
108
- payload = text
220
+ payload = rawText
109
221
  }
110
222
 
111
223
  if (!response.ok) {
112
- const message = typeof payload === "string"
113
- ? payload
114
- : payload?.message || JSON.stringify(payload)
115
- throw new Error(`${response.status} ${message}`)
224
+ throw new Error(`${response.status} ${getErrorMessage(payload)}`)
116
225
  }
117
226
 
118
227
  return payload
119
228
  }
120
229
 
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)
230
+ function resolveByRef(items, ref, nameSelector, label) {
231
+ if (!ref) {
232
+ throw new Error(`${label} reference is required`)
233
+ }
125
234
 
126
- if (!credentials.login || !credentials.password) {
127
- throw new Error("login and password are required")
235
+ if (!Array.isArray(items) || items.length === 0) {
236
+ throw new Error(`no ${label}s found`)
128
237
  }
129
238
 
130
- const signinPayload = await request({
131
- method: "POST",
132
- url: `${apiBase}/signin`,
133
- body: {
134
- email: credentials.login,
135
- password: credentials.password,
136
- },
137
- })
239
+ const raw = String(ref).trim()
240
+ if (!raw) {
241
+ throw new Error(`${label} reference is required`)
242
+ }
138
243
 
139
- const token = signinPayload?.token
140
- if (!token) {
141
- throw new Error("signin response does not contain token")
244
+ if (/^\d+$/.test(raw)) {
245
+ const index = Number(raw) - 1
246
+ if (index < 0 || index >= items.length) {
247
+ throw new Error(`${label} index out of range: ${raw}`)
248
+ }
249
+ return items[index]
142
250
  }
143
251
 
144
- writeConfig({
145
- apiBase,
146
- token,
147
- })
252
+ const exactId = items.find(item => String(item._id) === raw)
253
+ if (exactId) {
254
+ return exactId
255
+ }
148
256
 
149
- let me
150
- try {
151
- me = await request({
257
+ const lower = raw.toLowerCase()
258
+ const exactNameMatches = items.filter(item => String(nameSelector(item)).toLowerCase() === lower)
259
+ if (exactNameMatches.length === 1) {
260
+ return exactNameMatches[0]
261
+ }
262
+ if (exactNameMatches.length > 1) {
263
+ throw new Error(`ambiguous ${label} name "${raw}"`)
264
+ }
265
+
266
+ const partialMatches = items.filter(item => String(nameSelector(item)).toLowerCase().includes(lower))
267
+ if (partialMatches.length === 1) {
268
+ return partialMatches[0]
269
+ }
270
+ if (partialMatches.length > 1) {
271
+ throw new Error(`ambiguous ${label} reference "${raw}"`)
272
+ }
273
+
274
+ throw new Error(`${label} not found: ${raw}`)
275
+ }
276
+
277
+ async function fetchProjects(config, teamId = null) {
278
+ if (teamId) {
279
+ const payload = await request({
280
+ apiBase: normalizeApiBase(config.apiBase),
281
+ token: config.token,
152
282
  method: "GET",
153
- url: `${apiBase}/me`,
154
- token,
283
+ path: `/teams/${teamId}/projects`,
155
284
  })
156
- } catch (error) {
157
- me = null
285
+
286
+ return payload?.data?.projects || []
158
287
  }
159
288
 
160
- printWelcomeBanner()
289
+ const payload = await request({
290
+ apiBase: normalizeApiBase(config.apiBase),
291
+ token: config.token,
292
+ method: "GET",
293
+ path: "/projects",
294
+ })
161
295
 
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}`)
296
+ return payload?.projects || []
168
297
  }
169
298
 
170
- function authLogout() {
171
- const config = readConfig()
172
- writeConfig({
173
- apiBase: normalizeApiBase(config.apiBase || DEFAULT_API_BASE),
174
- token: null,
299
+ async function fetchProjectById(config, projectId) {
300
+ return request({
301
+ apiBase: normalizeApiBase(config.apiBase),
302
+ token: config.token,
303
+ method: "GET",
304
+ path: `/projects/${projectId}`,
175
305
  })
176
- console.log("Logged out")
177
306
  }
178
307
 
179
- async function authWhoAmI() {
180
- const config = readConfig()
181
- const token = config.token
308
+ async function fetchStages(config, projectId) {
309
+ const payload = await request({
310
+ apiBase: normalizeApiBase(config.apiBase),
311
+ token: config.token,
312
+ method: "GET",
313
+ path: `/projects/${projectId}/stages`,
314
+ })
182
315
 
183
- if (!token) {
184
- throw new Error("not logged in. run: unit-labs auth login")
185
- }
316
+ return Array.isArray(payload) ? payload : []
317
+ }
186
318
 
187
- const me = await request({
319
+ async function fetchFlatTasks(config, projectId) {
320
+ const payload = await request({
321
+ apiBase: normalizeApiBase(config.apiBase),
322
+ token: config.token,
188
323
  method: "GET",
189
- url: `${normalizeApiBase(config.apiBase || DEFAULT_API_BASE)}/me`,
190
- token,
324
+ path: `/projects/${projectId}/tasks`,
191
325
  })
192
326
 
193
- console.log(JSON.stringify(me, null, 2))
327
+ return Array.isArray(payload) ? payload : []
194
328
  }
195
329
 
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))
330
+ async function fetchStageTasks(config, projectId, stageId) {
331
+ const payload = await request({
332
+ apiBase: normalizeApiBase(config.apiBase),
333
+ token: config.token,
334
+ method: "GET",
335
+ path: `/projects/${projectId}/stages/${stageId}/tasks`,
336
+ })
337
+
338
+ return Array.isArray(payload) ? payload : []
202
339
  }
203
340
 
204
- function configSetApi(rawApi) {
205
- if (!rawApi) {
206
- throw new Error("api url is required. usage: unit-labs config set-api <url>")
341
+ async function resolveProjectContext(config, projectRef, requireProject = true) {
342
+ const projects = await fetchProjects(config)
343
+ let project = null
344
+
345
+ if (projectRef) {
346
+ project = resolveByRef(projects, projectRef, p => p.project_name, "project")
347
+ } else if (config.currentProjectId) {
348
+ project = projects.find(p => String(p._id) === String(config.currentProjectId)) || null
207
349
  }
208
350
 
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}`)
351
+ if (!project && requireProject) {
352
+ throw new Error("no project selected. run: unit-labs project ls && unit-labs project use <number|name>")
353
+ }
354
+
355
+ return { project, projects }
216
356
  }
217
357
 
218
- async function run(argv) {
219
- if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
220
- console.log(HELP_TEXT.trim())
358
+ async function resolveStageContext(config, project, stageRef, requireStage = true) {
359
+ if (!project) {
360
+ throw new Error("project is required")
361
+ }
362
+
363
+ if ((project.workflow_type || "stages") === "flat") {
364
+ throw new Error("this project uses flat workflow. stage commands are unavailable")
365
+ }
366
+
367
+ const stages = await fetchStages(config, project._id)
368
+ let stage = null
369
+
370
+ if (stageRef) {
371
+ stage = resolveByRef(stages, stageRef, s => s.stage_name, "stage")
372
+ } else if (config.currentStageId) {
373
+ stage = stages.find(s => String(s._id) === String(config.currentStageId)) || null
374
+ }
375
+
376
+ if (!stage && requireStage) {
377
+ throw new Error("no stage selected. run: unit-labs stage ls && unit-labs stage use <number|name>")
378
+ }
379
+
380
+ return { stage, stages }
381
+ }
382
+
383
+ function saveConfigPatch(config, patch) {
384
+ const next = {
385
+ ...config,
386
+ ...patch,
387
+ }
388
+ writeConfig(next)
389
+ return next
390
+ }
391
+
392
+ async function promptMissingCredentials({ login, password }) {
393
+ if (login && password) {
394
+ return { login, password }
395
+ }
396
+
397
+ const rl = readline.createInterface({ input, output })
398
+ const nextLogin = login || (await rl.question("Login: ")).trim()
399
+ const nextPassword = password || (await rl.question("Password: ")).trim()
400
+ rl.close()
401
+
402
+ return {
403
+ login: nextLogin,
404
+ password: nextPassword,
405
+ }
406
+ }
407
+
408
+ async function runAuth(command, args, config) {
409
+ const { flags } = parseArgs(args, { l: "login", e: "login", p: "password", h: "help" })
410
+ const apiBase = normalizeApiBase(flags.api || config.apiBase || DEFAULT_API_BASE)
411
+
412
+ if (!command || command === "--help" || command === "-h" || flags.help) {
413
+ console.log(AUTH_HELP.trim())
221
414
  return
222
415
  }
223
416
 
224
- const [scope, command, ...rest] = argv
417
+ if (command === "login") {
418
+ const credentials = await promptMissingCredentials(flags)
419
+ if (!credentials.login || !credentials.password) {
420
+ throw new Error("login and password are required")
421
+ }
225
422
 
226
- if (scope === "auth") {
227
- const flags = parseFlags(rest)
423
+ const signinPayload = await request({
424
+ apiBase,
425
+ method: "POST",
426
+ path: "/signin",
427
+ body: { email: credentials.login, password: credentials.password },
428
+ requiresAuth: false,
429
+ })
430
+
431
+ const token = signinPayload?.token
432
+ if (!token) {
433
+ throw new Error("signin response does not contain token")
434
+ }
435
+
436
+ let me = null
437
+ try {
438
+ me = await request({
439
+ apiBase,
440
+ token,
441
+ method: "GET",
442
+ path: "/me",
443
+ })
444
+ } catch (error) {
445
+ me = null
446
+ }
447
+
448
+ saveConfigPatch(config, {
449
+ apiBase,
450
+ token,
451
+ })
452
+
453
+ printWelcomeBanner()
454
+ if (me?.email) {
455
+ console.log(`Logged in as ${me.email}`)
456
+ } else {
457
+ console.log("Logged in")
458
+ }
459
+ console.log(`Config saved: ${CONFIG_FILE}`)
460
+ return
461
+ }
462
+
463
+ if (command === "logout") {
464
+ saveConfigPatch(config, { token: null })
465
+ console.log("Logged out")
466
+ return
467
+ }
468
+
469
+ if (command === "whoami") {
470
+ const me = await request({
471
+ apiBase,
472
+ token: config.token,
473
+ method: "GET",
474
+ path: "/me",
475
+ })
476
+ printJson(me)
477
+ return
478
+ }
479
+
480
+ throw new Error(`unknown auth command: ${command}`)
481
+ }
482
+
483
+ async function runConfig(command, args, config) {
484
+ const { flags, positionals } = parseArgs(args, { h: "help" })
485
+
486
+ if (!command || command === "--help" || command === "-h" || flags.help) {
487
+ console.log(CONFIG_HELP.trim())
488
+ return
489
+ }
490
+
491
+ if (command === "show") {
492
+ printJson({
493
+ apiBase: normalizeApiBase(config.apiBase || DEFAULT_API_BASE),
494
+ loggedIn: Boolean(config.token),
495
+ currentProjectId: config.currentProjectId,
496
+ currentProjectName: config.currentProjectName,
497
+ currentProjectWorkflow: config.currentProjectWorkflow,
498
+ currentStageId: config.currentStageId,
499
+ currentStageName: config.currentStageName,
500
+ })
501
+ return
502
+ }
503
+
504
+ if (command === "set-api") {
505
+ const rawApi = positionals[0] || flags.api
506
+ if (!rawApi) {
507
+ throw new Error("api url is required. usage: unit-labs config set-api <url>")
508
+ }
509
+ const apiBase = normalizeApiBase(rawApi)
510
+ saveConfigPatch(config, { apiBase })
511
+ console.log(`API saved: ${apiBase}`)
512
+ return
513
+ }
228
514
 
229
- if (command === "login") {
230
- await authLogin(flags)
515
+ throw new Error(`unknown config command: ${command}`)
516
+ }
517
+
518
+ async function runProject(command, args, config) {
519
+ const { flags, positionals } = parseArgs(args, { t: "team", w: "workflow", n: "name", j: "json", h: "help" })
520
+
521
+ if (!command || command === "--help" || command === "-h" || flags.help) {
522
+ console.log(PROJECT_HELP.trim())
523
+ return
524
+ }
525
+
526
+ if (command === "ls") {
527
+ const projects = await fetchProjects(config, flags.team || null)
528
+
529
+ if (flags.json) {
530
+ printJson(projects)
231
531
  return
232
532
  }
233
533
 
234
- if (command === "logout") {
235
- authLogout()
534
+ printProjects(projects, config.currentProjectId)
535
+ return
536
+ }
537
+
538
+ if (command === "new") {
539
+ const name = (flags.name || positionals.join(" ")).trim()
540
+ if (!name) {
541
+ throw new Error("project name is required. use: --name \"...\"")
542
+ }
543
+
544
+ const workflow = (flags.workflow || "stages").trim()
545
+ if (!["stages", "flat"].includes(workflow)) {
546
+ throw new Error("workflow must be one of: stages, flat")
547
+ }
548
+
549
+ const body = {
550
+ project_name: name,
551
+ workflow_type: workflow,
552
+ }
553
+ if (flags.team) {
554
+ body.team_id = flags.team
555
+ }
556
+
557
+ const payload = await request({
558
+ apiBase: normalizeApiBase(config.apiBase),
559
+ token: config.token,
560
+ method: "POST",
561
+ path: "/projects",
562
+ body,
563
+ })
564
+
565
+ if (typeof payload === "string") {
566
+ console.log(payload)
567
+ } else {
568
+ printJson(payload)
569
+ }
570
+ return
571
+ }
572
+
573
+ if (command === "view") {
574
+ const ref = positionals[0]
575
+ const { project } = await resolveProjectContext(config, ref, true)
576
+
577
+ let data = project
578
+ try {
579
+ data = await fetchProjectById(config, project._id)
580
+ } catch (error) {
581
+ data = project
582
+ }
583
+
584
+ if (flags.json) {
585
+ printJson(data)
236
586
  return
237
587
  }
238
588
 
239
- if (command === "whoami") {
240
- await authWhoAmI()
589
+ console.log(`Name: ${data.project_name}`)
590
+ console.log(`Workflow: ${data.workflow_type || "stages"}`)
591
+ console.log(`Status: ${data.status || "-"}`)
592
+ console.log(`ID: ${data._id}`)
593
+ return
594
+ }
595
+
596
+ if (command === "use") {
597
+ const ref = positionals[0]
598
+ if (!ref) {
599
+ throw new Error("project reference is required. use: unit-labs project use <number|name|id>")
600
+ }
601
+
602
+ const { project } = await resolveProjectContext(config, ref, true)
603
+ const workflow = project.workflow_type || "stages"
604
+ saveConfigPatch(config, {
605
+ currentProjectId: project._id,
606
+ currentProjectName: project.project_name,
607
+ currentProjectWorkflow: workflow,
608
+ currentStageId: null,
609
+ currentStageName: null,
610
+ })
611
+ console.log(`Current project: ${project.project_name} [${workflow}]`)
612
+ return
613
+ }
614
+
615
+ if (command === "current") {
616
+ if (!config.currentProjectId) {
617
+ throw new Error("no current project. use: unit-labs project use <number|name>")
618
+ }
619
+
620
+ const { project } = await resolveProjectContext(config, null, true)
621
+
622
+ if (flags.json) {
623
+ printJson(project)
241
624
  return
242
625
  }
243
626
 
244
- throw new Error(`unknown auth command: ${command}`)
627
+ console.log(`Current project: ${project.project_name}`)
628
+ console.log(`Workflow: ${project.workflow_type || "stages"}`)
629
+ console.log(`ID: ${project._id}`)
630
+ return
245
631
  }
246
632
 
247
- if (scope === "config") {
248
- if (command === "show") {
249
- configShow()
633
+ throw new Error(`unknown project command: ${command}`)
634
+ }
635
+
636
+ async function runStage(command, args, config) {
637
+ const { flags, positionals } = parseArgs(args, {
638
+ p: "project",
639
+ n: "name",
640
+ d: "description",
641
+ j: "json",
642
+ h: "help",
643
+ })
644
+
645
+ if (!command || command === "--help" || command === "-h" || flags.help) {
646
+ console.log(STAGE_HELP.trim())
647
+ return
648
+ }
649
+
650
+ const projectRef = flags.project || null
651
+ const { project } = await resolveProjectContext(config, projectRef, true)
652
+ if ((project.workflow_type || "stages") === "flat") {
653
+ throw new Error("this project uses flat workflow. stage commands are unavailable")
654
+ }
655
+
656
+ if (command === "ls") {
657
+ const stages = await fetchStages(config, project._id)
658
+ if (flags.json) {
659
+ printJson(stages)
250
660
  return
251
661
  }
662
+ printStages(stages, config.currentStageId)
663
+ return
664
+ }
665
+
666
+ if (command === "new") {
667
+ const stageName = (flags.name || "").trim()
668
+ const description = (flags.description || "").trim()
669
+
670
+ if (!stageName || !description) {
671
+ throw new Error("stage name and description are required. use: --name \"...\" --description \"...\"")
672
+ }
673
+
674
+ const payload = await request({
675
+ apiBase: normalizeApiBase(config.apiBase),
676
+ token: config.token,
677
+ method: "POST",
678
+ path: `/projects/${project._id}/stages`,
679
+ body: {
680
+ stage_name: stageName,
681
+ description,
682
+ },
683
+ })
252
684
 
253
- if (command === "set-api") {
254
- configSetApi(rest[0])
685
+ if (typeof payload === "string") {
686
+ console.log(payload)
687
+ } else {
688
+ printJson(payload)
689
+ }
690
+ return
691
+ }
692
+
693
+ if (command === "use") {
694
+ const ref = positionals[0]
695
+ if (!ref) {
696
+ throw new Error("stage reference is required. use: unit-labs stage use <number|name|id>")
697
+ }
698
+
699
+ const { stage } = await resolveStageContext(config, project, ref, true)
700
+ saveConfigPatch(config, {
701
+ currentProjectId: project._id,
702
+ currentProjectName: project.project_name,
703
+ currentProjectWorkflow: project.workflow_type || "stages",
704
+ currentStageId: stage._id,
705
+ currentStageName: stage.stage_name,
706
+ })
707
+ console.log(`Current stage: ${stage.stage_name}`)
708
+ return
709
+ }
710
+
711
+ if (command === "current") {
712
+ if (!config.currentStageId) {
713
+ throw new Error("no current stage. use: unit-labs stage use <number|name>")
714
+ }
715
+
716
+ const { stage } = await resolveStageContext(config, project, null, true)
717
+ if (flags.json) {
718
+ printJson(stage)
255
719
  return
256
720
  }
721
+ console.log(`Current stage: ${stage.stage_name}`)
722
+ console.log(`Status: ${stage.status || "-"}`)
723
+ console.log(`ID: ${stage._id}`)
724
+ return
725
+ }
726
+
727
+ throw new Error(`unknown stage command: ${command}`)
728
+ }
729
+
730
+ async function resolveTaskContext(config, flags, requireStage = true) {
731
+ const projectRef = flags.project || null
732
+ const { project } = await resolveProjectContext(config, projectRef, true)
733
+ const workflow = project.workflow_type || "stages"
257
734
 
258
- throw new Error(`unknown config command: ${command}`)
735
+ if (workflow === "flat") {
736
+ return { project, workflow, stage: null }
737
+ }
738
+
739
+ const stageRef = flags.stage || null
740
+ const { stage } = await resolveStageContext(config, project, stageRef, requireStage)
741
+ return { project, workflow, stage }
742
+ }
743
+
744
+ async function listAllStageTasks(config, project) {
745
+ const stages = await fetchStages(config, project._id)
746
+ const result = []
747
+
748
+ for (const stage of stages) {
749
+ const tasks = await fetchStageTasks(config, project._id, stage._id)
750
+ tasks.forEach(task => {
751
+ result.push({
752
+ ...task,
753
+ _stageName: stage.stage_name,
754
+ _stageId: stage._id,
755
+ })
756
+ })
757
+ }
758
+
759
+ return result
760
+ }
761
+
762
+ async function runTask(command, args, config) {
763
+ const { flags, positionals } = parseArgs(args, {
764
+ p: "project",
765
+ s: "stage",
766
+ t: "title",
767
+ a: "all",
768
+ j: "json",
769
+ h: "help",
770
+ })
771
+
772
+ if (!command || command === "--help" || command === "-h" || flags.help) {
773
+ console.log(TASK_HELP.trim())
774
+ return
775
+ }
776
+
777
+ if (command === "ls") {
778
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, !flags.all)
779
+
780
+ if (workflow === "flat") {
781
+ const tasks = await fetchFlatTasks(config, project._id)
782
+ if (flags.json) {
783
+ printJson(tasks)
784
+ return
785
+ }
786
+ printTasks(tasks, false)
787
+ return
788
+ }
789
+
790
+ if (flags.all) {
791
+ const tasks = await listAllStageTasks(config, project)
792
+ if (flags.json) {
793
+ printJson(tasks)
794
+ return
795
+ }
796
+ printTasks(tasks, true)
797
+ return
798
+ }
799
+
800
+ const tasks = await fetchStageTasks(config, project._id, stage._id)
801
+ if (flags.json) {
802
+ printJson(tasks)
803
+ return
804
+ }
805
+ printTasks(tasks, false)
806
+ return
807
+ }
808
+
809
+ if (command === "new") {
810
+ const title = (flags.title || positionals.join(" ")).trim()
811
+ if (!title) {
812
+ throw new Error("task title is required. use: unit-labs task new \"Task title\"")
813
+ }
814
+
815
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
816
+
817
+ const path = workflow === "flat"
818
+ ? `/projects/${project._id}/tasks`
819
+ : `/projects/${project._id}/stages/${stage._id}/tasks`
820
+
821
+ const payload = await request({
822
+ apiBase: normalizeApiBase(config.apiBase),
823
+ token: config.token,
824
+ method: "POST",
825
+ path,
826
+ body: { title },
827
+ })
828
+
829
+ if (Array.isArray(payload)) {
830
+ printTasks(payload, false)
831
+ } else if (typeof payload === "string") {
832
+ console.log(payload)
833
+ } else {
834
+ printJson(payload)
835
+ }
836
+ return
837
+ }
838
+
839
+ if (command === "done" || command === "close") {
840
+ const taskRef = positionals[0]
841
+ if (!taskRef) {
842
+ throw new Error("task reference is required. use: unit-labs task done <number|title|id>")
843
+ }
844
+
845
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
846
+ const tasks = workflow === "flat"
847
+ ? await fetchFlatTasks(config, project._id)
848
+ : await fetchStageTasks(config, project._id, stage._id)
849
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
850
+
851
+ const path = workflow === "flat"
852
+ ? `/projects/${project._id}/tasks/${task._id}/toggle`
853
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/toggle`
854
+
855
+ const updated = await request({
856
+ apiBase: normalizeApiBase(config.apiBase),
857
+ token: config.token,
858
+ method: "PATCH",
859
+ path,
860
+ })
861
+
862
+ printTasks([updated], false)
863
+ return
864
+ }
865
+
866
+ if (command === "edit") {
867
+ const taskRef = positionals[0]
868
+ const title = (flags.title || positionals.slice(1).join(" ")).trim()
869
+
870
+ if (!taskRef) {
871
+ throw new Error("task reference is required. use: unit-labs task edit <number|title|id> --title \"...\"")
872
+ }
873
+ if (!title) {
874
+ throw new Error("new title is required. use: --title \"New title\"")
875
+ }
876
+
877
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
878
+ const tasks = workflow === "flat"
879
+ ? await fetchFlatTasks(config, project._id)
880
+ : await fetchStageTasks(config, project._id, stage._id)
881
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
882
+
883
+ const path = workflow === "flat"
884
+ ? `/projects/${project._id}/tasks/${task._id}/title`
885
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}/title`
886
+
887
+ const updated = await request({
888
+ apiBase: normalizeApiBase(config.apiBase),
889
+ token: config.token,
890
+ method: "PATCH",
891
+ path,
892
+ body: { title },
893
+ })
894
+
895
+ printTasks([updated], false)
896
+ return
897
+ }
898
+
899
+ if (command === "rm" || command === "delete") {
900
+ const taskRef = positionals[0]
901
+ if (!taskRef) {
902
+ throw new Error("task reference is required. use: unit-labs task rm <number|title|id>")
903
+ }
904
+
905
+ const { project, workflow, stage } = await resolveTaskContext(config, flags, true)
906
+ const tasks = workflow === "flat"
907
+ ? await fetchFlatTasks(config, project._id)
908
+ : await fetchStageTasks(config, project._id, stage._id)
909
+ const task = resolveByRef(tasks, taskRef, t => t.title, "task")
910
+
911
+ const path = workflow === "flat"
912
+ ? `/projects/${project._id}/tasks/${task._id}`
913
+ : `/projects/${project._id}/stages/${stage._id}/tasks/${task._id}`
914
+
915
+ await request({
916
+ apiBase: normalizeApiBase(config.apiBase),
917
+ token: config.token,
918
+ method: "DELETE",
919
+ path,
920
+ })
921
+
922
+ console.log(`Deleted task: ${task.title}`)
923
+ return
924
+ }
925
+
926
+ throw new Error(`unknown task command: ${command}`)
927
+ }
928
+
929
+ async function run(argv) {
930
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
931
+ console.log(ROOT_HELP.trim())
932
+ return
933
+ }
934
+
935
+ const [scope, command, ...rest] = argv
936
+ const config = readConfig()
937
+
938
+ if (scope === "auth") {
939
+ await runAuth(command, rest, config)
940
+ return
941
+ }
942
+
943
+ if (scope === "config") {
944
+ await runConfig(command, rest, config)
945
+ return
946
+ }
947
+
948
+ if (scope === "project") {
949
+ await runProject(command, rest, config)
950
+ return
951
+ }
952
+
953
+ if (scope === "stage") {
954
+ await runStage(command, rest, config)
955
+ return
956
+ }
957
+
958
+ if (scope === "task") {
959
+ await runTask(command, rest, config)
960
+ return
259
961
  }
260
962
 
261
963
  throw new Error(`unknown scope: ${scope}`)
@@ -264,4 +966,3 @@ async function run(argv) {
264
966
  module.exports = {
265
967
  run,
266
968
  }
267
-