@brainervirus/workit-cli 0.5.1 → 0.5.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/steps.tsx DELETED
@@ -1,605 +0,0 @@
1
- import { Box, Text, useInput } from "ink";
2
- import { ConfirmInput, MultiSelect, Select, TextInput } from "@inkjs/ui";
3
- import { useState, type Dispatch, type JSX, type SetStateAction } from "react";
4
- import { configDir, readConfig, writeConfig, type BranchPreset, type ToolkitConfig } from "@brainervirus/workit-core/src/core/config.ts";
5
- import type { WorkspaceConfig } from "@brainervirus/workit-core/src/core/workspaces.ts";
6
- import {
7
- collectConfigValues,
8
- DEFAULT_BASE_URL,
9
- loadWorkspaces,
10
- parseList,
11
- runProjectSetup,
12
- scaffoldVcs,
13
- scaffoldYouTrack,
14
- shouldWriteWorkspaces,
15
- validateBaseUrl,
16
- validateLocale,
17
- validateTimezone,
18
- writeWorkspaces,
19
- type ProjectSetupResult,
20
- type VcsProvider,
21
- type VcsScaffold,
22
- type YouTrackScaffold,
23
- } from "./logic";
24
-
25
- export type WizardResults = {
26
- platforms: string[];
27
- config: ToolkitConfig;
28
- workspaces: WorkspaceConfig[];
29
- youtrack: YouTrackScaffold | null;
30
- vcs: VcsScaffold | null;
31
- project: ProjectSetupResult | null;
32
- };
33
-
34
- type StepProps = {
35
- results: WizardResults;
36
- setResults: Dispatch<SetStateAction<WizardResults>>;
37
- onDone: () => void;
38
- onExit: () => void;
39
- };
40
-
41
- type StepComponent = (props: StepProps) => JSX.Element;
42
-
43
- const PLATFORMS = [
44
- { label: "OpenCode", value: "opencode" },
45
- { label: "Cursor", value: "cursor" },
46
- ];
47
-
48
- const BRANCH_PRESETS: { label: string; value: BranchPreset }[] = [
49
- { label: "GitFlow", value: "gitflow" },
50
- { label: "GitHub Flow", value: "github-flow" },
51
- { label: "Trunk-based", value: "trunk-based" },
52
- { label: "Custom", value: "custom" },
53
- ];
54
-
55
- const VCS_PROVIDERS = [
56
- { label: "GitLab", value: "gitlab" },
57
- { label: "GitHub", value: "github" },
58
- ];
59
-
60
- function continueLabel(): string {
61
- return " y to continue · n to stay · Esc to exit";
62
- }
63
-
64
- export function Wizard({ onExit }: { onExit: () => void }): JSX.Element {
65
- const [step, setStep] = useState(0);
66
- const [results, setResults] = useState<WizardResults>(() => ({
67
- platforms: [],
68
- config: readConfig(),
69
- workspaces: [],
70
- youtrack: null,
71
- vcs: null,
72
- project: null,
73
- }));
74
-
75
- useInput((input, key) => {
76
- if (key.escape || (key.ctrl && input.toLowerCase() === "c")) onExit();
77
- });
78
-
79
- const advance = () => setStep((s) => Math.min(s + 1, 6));
80
- const props: StepProps = { results, setResults, onDone: advance, onExit };
81
- const Step = (step === 6 ? SummaryStep : [PlatformStep, ConfigStep, YouTrackStep, VcsStep, WorkspacesStep, ProjectStep, SummaryStep][step]) as StepComponent;
82
-
83
- return (
84
- <Box flexDirection="column" gap={1}>
85
- <Text bold color="cyan">
86
- flowkit — workflow rails for agentic coding
87
- </Text>
88
- <Step {...props} />
89
- </Box>
90
- );
91
- }
92
-
93
- function PlatformStep({ results, setResults, onDone }: StepProps): JSX.Element {
94
- const [error, setError] = useState(false);
95
- return (
96
- <Box flexDirection="column" gap={1}>
97
- <Text bold>Step 1 — Platforms</Text>
98
- <Text dimColor>Select the tools to configure (space to toggle):</Text>
99
- <MultiSelect
100
- options={PLATFORMS}
101
- defaultValue={results.platforms}
102
- onSubmit={(values) => {
103
- if (values.length === 0) {
104
- setError(true);
105
- return;
106
- }
107
- setResults((r) => ({ ...r, platforms: values }));
108
- onDone();
109
- }}
110
- />
111
- {error && <Text color="red">Select at least one platform to continue.</Text>}
112
- <Text dimColor>Enter to continue · Esc to exit</Text>
113
- </Box>
114
- );
115
- }
116
-
117
- function ConfigStep({ results, setResults, onDone }: StepProps): JSX.Element {
118
- const current = results.config;
119
- const [locale, setLocale] = useState(current.locale);
120
- const [localeOk, setLocaleOk] = useState(validateLocale(current.locale) === null);
121
- const [localeError, setLocaleError] = useState<string | null>(null);
122
- const [timezone, setTimezone] = useState(current.timezone);
123
- const [tzOk, setTzOk] = useState(validateTimezone(current.timezone) === null);
124
- const [tzError, setTzError] = useState<string | null>(null);
125
- const [preset, setPreset] = useState<BranchPreset>(current.branchPolicy.preset);
126
- const [allowed, setAllowed] = useState(current.branchPolicy.allowed.join(", "));
127
- const [protectedNames, setProtectedNames] = useState(current.branchPolicy.protected.join(", "));
128
-
129
- const save = () => {
130
- const next = collectConfigValues(
131
- {
132
- locale: localeOk ? locale : undefined,
133
- timezone: tzOk ? timezone : undefined,
134
- preset,
135
- allowed: preset === "custom" ? parseList(allowed) : undefined,
136
- protectedNames: preset === "custom" ? parseList(protectedNames) : undefined,
137
- },
138
- current,
139
- );
140
- writeConfig(next);
141
- setResults((r) => ({ ...r, config: next }));
142
- onDone();
143
- };
144
-
145
- return (
146
- <Box flexDirection="column" gap={1}>
147
- <Text bold>Step 2 — Global config</Text>
148
- <Text dimColor>Locale (BCP-47, e.g. en or es-CL):</Text>
149
- <TextInput
150
- defaultValue={locale}
151
- onSubmit={(v) => {
152
- const err = validateLocale(v);
153
- if (err) {
154
- setLocaleError(err);
155
- setLocaleOk(false);
156
- } else {
157
- setLocaleError(null);
158
- setLocale(v);
159
- setLocaleOk(true);
160
- }
161
- }}
162
- />
163
- {localeError && <Text color="red">{localeError}</Text>}
164
- <Text dimColor>Timezone (IANA name, e.g. America/Santiago):</Text>
165
- <TextInput
166
- defaultValue={timezone}
167
- onSubmit={(v) => {
168
- const err = validateTimezone(v);
169
- if (err) {
170
- setTzError(err);
171
- setTzOk(false);
172
- } else {
173
- setTzError(null);
174
- setTimezone(v);
175
- setTzOk(true);
176
- }
177
- }}
178
- />
179
- {tzError && <Text color="red">{tzError}</Text>}
180
- <Text dimColor>Branch policy preset:</Text>
181
- <Select options={BRANCH_PRESETS} defaultValue={preset} onChange={(v) => setPreset(v as BranchPreset)} />
182
- {preset === "custom" && (
183
- <>
184
- <Text dimColor>Allowed branch patterns (comma-separated):</Text>
185
- <TextInput defaultValue={allowed} onChange={setAllowed} />
186
- <Text dimColor>Protected branch names (comma-separated):</Text>
187
- <TextInput defaultValue={protectedNames} onChange={setProtectedNames} />
188
- </>
189
- )}
190
- <ConfirmInput
191
- isDisabled={!localeOk || !tzOk}
192
- defaultChoice="confirm"
193
- submitOnEnter={false}
194
- onConfirm={save}
195
- onCancel={() => {}}
196
- />
197
- <Text dimColor>{continueLabel()}</Text>
198
- </Box>
199
- );
200
- }
201
-
202
- function YouTrackStep({ results, setResults, onDone }: StepProps): JSX.Element {
203
- const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
204
- const [urlError, setUrlError] = useState<string | null>(null);
205
- const [scaffold, setScaffold] = useState<YouTrackScaffold | null>(results.youtrack);
206
-
207
- return (
208
- <Box flexDirection="column" gap={1}>
209
- <Text bold>Step 3 — YouTrack</Text>
210
- <Text dimColor>Base URL (https):</Text>
211
- <TextInput
212
- defaultValue={baseUrl}
213
- isDisabled={scaffold !== null}
214
- onSubmit={(v) => {
215
- const err = validateBaseUrl(v);
216
- if (err) {
217
- setUrlError(err);
218
- return;
219
- }
220
- setUrlError(null);
221
- setBaseUrl(v);
222
- const s = scaffoldYouTrack(configDir(), v, {
223
- locale: results.config.locale,
224
- timezone: results.config.timezone,
225
- });
226
- setScaffold(s);
227
- setResults((r) => ({ ...r, youtrack: s }));
228
- }}
229
- />
230
- {urlError && <Text color="red">{urlError}</Text>}
231
- {scaffold ? (
232
- <>
233
- <Box flexDirection="column" gap={0}>
234
- <Text color="green">Scaffolded {scaffold.youtrackJson}</Text>
235
- <Text>Token placeholder: {scaffold.tokenPath}</Text>
236
- <Text>Create token: {scaffold.tokenCreateUrl}</Text>
237
- </Box>
238
- {/* ponytail: @inkjs/ui has no focus system — render the ConfirmInput only once the scaffold
239
- exists and disable the TextInput, so y reaches only the confirm (hint stays truthful) */}
240
- <ConfirmInput
241
- defaultChoice="confirm"
242
- submitOnEnter={false}
243
- onConfirm={onDone}
244
- onCancel={() => {}}
245
- />
246
- <Text dimColor>{continueLabel()}</Text>
247
- </>
248
- ) : (
249
- <Text dimColor>Enter to submit the URL — then y to continue</Text>
250
- )}
251
- </Box>
252
- );
253
- }
254
-
255
- function VcsStep({ results, setResults, onDone }: StepProps): JSX.Element {
256
- const [provider, setProvider] = useState<VcsProvider>(results.vcs?.provider ?? "gitlab");
257
- const [scaffold, setScaffold] = useState<VcsScaffold | null>(results.vcs);
258
-
259
- const apply = (p: VcsProvider) => {
260
- const s = scaffoldVcs(configDir(), p);
261
- setScaffold(s);
262
- setResults((r) => ({ ...r, vcs: s }));
263
- };
264
-
265
- // ponytail: Select's onChange only fires when Enter picks a different option — this useInput
266
- // covers Enter on the default provider; scaffoldVcs is idempotent so double-apply is harmless
267
- useInput((_input, key) => {
268
- if (key.return) apply(provider);
269
- });
270
-
271
- return (
272
- <Box flexDirection="column" gap={1}>
273
- <Text bold>Step 4 — Version control</Text>
274
- <Text dimColor>Provider:</Text>
275
- <Select
276
- options={VCS_PROVIDERS}
277
- defaultValue={provider}
278
- onChange={(v) => {
279
- const p = v as VcsProvider;
280
- setProvider(p);
281
- apply(p);
282
- }}
283
- />
284
- {scaffold ? (
285
- <>
286
- <Box flexDirection="column" gap={0}>
287
- <Text color="green">Scaffolded {scaffold.vcsJson} (provider: {scaffold.provider})</Text>
288
- <Text>Token placeholder: {scaffold.activeTokenPath}</Text>
289
- <Text>Create token: {scaffold.tokenCreateUrl}</Text>
290
- </Box>
291
- <ConfirmInput
292
- defaultChoice="confirm"
293
- submitOnEnter={false}
294
- onConfirm={onDone}
295
- onCancel={() => {}}
296
- />
297
- <Text dimColor>{continueLabel()}</Text>
298
- </>
299
- ) : (
300
- <Text dimColor>Enter to confirm the provider — then y to continue</Text>
301
- )}
302
- </Box>
303
- );
304
- }
305
-
306
- type WsLinking = "youtrack" | "github" | "none";
307
-
308
- type WsDraft = {
309
- name: string;
310
- glob: string;
311
- provider: VcsProvider;
312
- branch: string;
313
- linking: WsLinking;
314
- };
315
-
316
- type WsMode = "list" | "name" | "glob" | "provider" | "branch" | "linking" | "remove";
317
-
318
- // provider-gated linking: gitlab offers youtrack/none, github offers github-issues/none —
319
- // config.sh gates issues on provider github, and an ungated youtrack link would leak
320
- // "Related to: <youtrack>/issue/<id>" into GitHub PR bodies (writeWorkspaces enforces this too)
321
- const WS_LINKING: Record<VcsProvider, { label: string; value: WsLinking }[]> = {
322
- gitlab: [
323
- { label: "YouTrack", value: "youtrack" },
324
- { label: "None", value: "none" },
325
- ],
326
- github: [
327
- { label: "GitHub issues", value: "github" },
328
- { label: "None", value: "none" },
329
- ],
330
- };
331
-
332
- // ponytail: Select has no onSubmit — onChange fires on Enter once the value differs from
333
- // defaultValue, so action/provider/linking selects pass no defaultValue (undefined -> first
334
- // Enter is a change). TextInput onSubmit fires on Enter even for empty input (validation).
335
- // Each input gets a distinct key: mode swaps render the same element type at the same tree
336
- // position, so without keys React reuses the instance and the previous input's text leaks in.
337
- function WorkspacesStep({ setResults, onDone }: StepProps): JSX.Element {
338
- const [loaded] = useState<WorkspaceConfig[]>(() => loadWorkspaces());
339
- const [entries, setEntries] = useState<WorkspaceConfig[]>(loaded);
340
- const [mode, setMode] = useState<WsMode>("list");
341
- const [draft, setDraft] = useState<WsDraft>({ name: "", glob: "", provider: "gitlab", branch: "develop", linking: "none" });
342
- const [fieldError, setFieldError] = useState<string | null>(null);
343
- const [writeError, setWriteError] = useState<string | null>(null);
344
-
345
- const resetDraft = () => setDraft({ name: "", glob: "", provider: "gitlab", branch: "develop", linking: "none" });
346
-
347
- const finish = () => {
348
- setResults((r) => ({ ...r, workspaces: entries }));
349
- if (!shouldWriteWorkspaces(loaded, entries)) {
350
- onDone();
351
- return;
352
- }
353
- const result = writeWorkspaces(entries);
354
- if (result.ok) {
355
- onDone();
356
- } else {
357
- setWriteError(result.error ?? "failed to write workspaces.json");
358
- setMode("list");
359
- }
360
- };
361
-
362
- // ponytail: @inkjs/ui v2 Select options have no per-option isDisabled (whole Select only,
363
- // which would also block Done) — the empty-list guard stays in the onChange instead
364
- const actions = [
365
- { label: "Add workspace", value: "add" },
366
- { label: "Remove workspace", value: "remove" },
367
- { label: "Done", value: "done" },
368
- ];
369
-
370
- if (mode === "list") {
371
- return (
372
- <Box flexDirection="column" gap={1}>
373
- <Text bold>Step 5 — Workspaces</Text>
374
- {entries.length === 0 && <Text dimColor>No workspaces configured yet.</Text>}
375
- {entries.map((e) => (
376
- <Text key={`${e.name}|${e.glob}|${e.vcs?.provider ?? ""}`}>
377
- • {e.name} — {e.vcs?.provider ?? "?"} — {e.glob}
378
- </Text>
379
- ))}
380
- <Text dimColor>Select an action:</Text>
381
- <Select
382
- key="actions"
383
- options={actions}
384
- onChange={(v) => {
385
- if (v === "add") {
386
- setFieldError(null);
387
- setWriteError(null);
388
- setMode("name");
389
- } else if (v === "remove" && entries.length > 0) {
390
- setFieldError(null);
391
- setWriteError(null);
392
- setMode("remove");
393
- } else if (v === "done") {
394
- finish();
395
- }
396
- }}
397
- />
398
- {writeError && <Text color="red">{writeError}</Text>}
399
- <Text dimColor>Enter to pick · Esc to exit</Text>
400
- </Box>
401
- );
402
- }
403
-
404
- if (mode === "name") {
405
- return (
406
- <Box flexDirection="column" gap={1}>
407
- <Text bold>Step 5 — Workspaces · new workspace</Text>
408
- <Text dimColor>Name (e.g. work):</Text>
409
- <TextInput
410
- key="name"
411
- onSubmit={(v) => {
412
- const name = v.trim();
413
- if (!name) {
414
- setFieldError("name is required");
415
- return;
416
- }
417
- if (entries.some((e) => e.name === name)) {
418
- setFieldError(`"${name}" already exists — pick a unique name`);
419
- return;
420
- }
421
- setFieldError(null);
422
- setDraft({ ...draft, name });
423
- setMode("glob");
424
- }}
425
- />
426
- {fieldError && <Text color="red">{fieldError}</Text>}
427
- </Box>
428
- );
429
- }
430
-
431
- if (mode === "glob") {
432
- return (
433
- <Box flexDirection="column" gap={1}>
434
- <Text bold>Step 5 — Workspaces · {draft.name}</Text>
435
- <Text dimColor>Path glob (e.g. /home/*/Documents/projects/work/**):</Text>
436
- <TextInput
437
- key="glob"
438
- onSubmit={(v) => {
439
- if (!v.trim()) {
440
- setFieldError("glob is required");
441
- return;
442
- }
443
- setFieldError(null);
444
- setDraft({ ...draft, glob: v.trim() });
445
- setMode("provider");
446
- }}
447
- />
448
- {fieldError && <Text color="red">{fieldError}</Text>}
449
- </Box>
450
- );
451
- }
452
-
453
- if (mode === "provider") {
454
- return (
455
- <Box flexDirection="column" gap={1}>
456
- <Text bold>Step 5 — Workspaces · {draft.name}</Text>
457
- <Text dimColor>VCS provider:</Text>
458
- <Select
459
- key="provider"
460
- options={VCS_PROVIDERS}
461
- onChange={(v) => {
462
- const p = v as VcsProvider;
463
- setDraft({ ...draft, provider: p, branch: p === "gitlab" ? "develop" : "main" });
464
- setMode("branch");
465
- }}
466
- />
467
- </Box>
468
- );
469
- }
470
-
471
- if (mode === "branch") {
472
- return (
473
- <Box flexDirection="column" gap={1}>
474
- <Text bold>Step 5 — Workspaces · {draft.name}</Text>
475
- <Text dimColor>Default target branch (Enter to keep "{draft.branch}"):</Text>
476
- <TextInput
477
- key="branch"
478
- defaultValue={draft.branch}
479
- onSubmit={(v) => {
480
- setDraft({ ...draft, branch: v.trim() });
481
- setMode("linking");
482
- }}
483
- />
484
- </Box>
485
- );
486
- }
487
-
488
- if (mode === "linking") {
489
- return (
490
- <Box flexDirection="column" gap={1}>
491
- <Text bold>Step 5 — Workspaces · {draft.name}</Text>
492
- <Text dimColor>Issue linking:</Text>
493
- <Select
494
- key="linking"
495
- options={WS_LINKING[draft.provider]}
496
- onChange={(v) => {
497
- const linking = v as WsLinking;
498
- const vcs = { provider: draft.provider, ...(draft.branch ? { defaultTargetBranch: draft.branch } : {}) };
499
- setEntries([
500
- ...entries,
501
- {
502
- name: draft.name,
503
- glob: draft.glob,
504
- vcs,
505
- ...(linking === "youtrack" ? { youtrack: { link_issues: true } } : {}),
506
- ...(linking === "github" ? { issues: { provider: "github", link_on_pr: true } } : {}),
507
- },
508
- ]);
509
- resetDraft();
510
- setWriteError(null);
511
- setMode("list");
512
- }}
513
- />
514
- </Box>
515
- );
516
- }
517
-
518
- return (
519
- <Box flexDirection="column" gap={1}>
520
- <Text bold>Step 5 — Workspaces · remove</Text>
521
- <Text dimColor>Select a workspace to remove:</Text>
522
- <Select
523
- key="remove"
524
- options={entries.map((e) => ({ label: `${e.name} — ${e.vcs?.provider ?? "?"}`, value: e.name }))}
525
- onChange={(v) => {
526
- setEntries(entries.filter((e) => e.name !== v));
527
- setWriteError(null);
528
- setMode("list");
529
- }}
530
- />
531
- </Box>
532
- );
533
- }
534
-
535
- function ProjectStep({ setResults, onDone }: StepProps): JSX.Element {
536
- const apply = () => {
537
- const result = runProjectSetup(process.cwd());
538
- setResults((r) => ({ ...r, project: result }));
539
- onDone();
540
- };
541
-
542
- return (
543
- <Box flexDirection="column" gap={1}>
544
- <Text bold>Step 6 — Project setup</Text>
545
- <Text dimColor>Will apply gitignore + hygiene in {process.cwd()} (existing files are never overwritten):</Text>
546
- <ConfirmInput defaultChoice="confirm" submitOnEnter={false} onConfirm={apply} onCancel={() => {}} />
547
- <Text dimColor>{continueLabel()}</Text>
548
- </Box>
549
- );
550
- }
551
-
552
- function SummaryStep({ results, onExit }: StepProps): JSX.Element {
553
- return (
554
- <Box flexDirection="column" gap={1}>
555
- <Text bold color="cyan">
556
- Setup complete
557
- </Text>
558
- <Text>
559
- Platforms: <Text color="green">{results.platforms.join(", ")}</Text>
560
- </Text>
561
- <Text>
562
- Global config: <Text color="green">{configDir()}/config.json</Text>
563
- </Text>
564
- {results.youtrack && (
565
- <Box flexDirection="column" gap={0}>
566
- <Text>
567
- YouTrack: <Text color="green">{results.youtrack.youtrackJson}</Text>
568
- </Text>
569
- <Text> token placeholder: {results.youtrack.tokenPath}</Text>
570
- <Text> create token: {results.youtrack.tokenCreateUrl}</Text>
571
- </Box>
572
- )}
573
- {results.vcs && (
574
- <Box flexDirection="column" gap={0}>
575
- <Text>
576
- VCS: <Text color="green">{results.vcs.vcsJson}</Text> (provider: {results.vcs.provider})
577
- </Text>
578
- <Text> token placeholder: {results.vcs.activeTokenPath}</Text>
579
- <Text> create token: {results.vcs.tokenCreateUrl}</Text>
580
- </Box>
581
- )}
582
- {results.workspaces.length > 0 && (
583
- <Box flexDirection="column" gap={0}>
584
- <Text>Workspaces:</Text>
585
- {results.workspaces.map((w) => (
586
- <Text key={`${w.name}|${w.glob}|${w.vcs?.provider ?? ""}`}>
587
- {" "}{w.name} — {w.vcs?.provider ?? "?"}
588
- </Text>
589
- ))}
590
- </Box>
591
- )}
592
- {results.project && results.project.created.length > 0 && (
593
- <Box flexDirection="column" gap={0}>
594
- <Text>Project files:</Text>
595
- {results.project.created.map((file) => (
596
- <Text key={file}> + {file}</Text>
597
- ))}
598
- </Box>
599
- )}
600
- <Text dimColor>Paste the token(s) into the placeholder files, then run /wf-status to verify.</Text>
601
- <ConfirmInput defaultChoice="confirm" submitOnEnter={false} onConfirm={onExit} onCancel={() => {}} />
602
- <Text dimColor>{continueLabel()}</Text>
603
- </Box>
604
- );
605
- }