@brainervirus/workit-cli 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@brainervirus/workit-cli",
3
+ "version": "0.4.0",
4
+ "private": false,
5
+ "description": "Workit interactive setup wizard (Ink TUI)",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "opencode",
9
+ "cursor",
10
+ "workflow",
11
+ "cli",
12
+ "setup"
13
+ ],
14
+ "bugs": {
15
+ "url": "https://github.com/BrainerVirus/workflow-toolkit/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/BrainerVirus/workflow-toolkit.git"
20
+ },
21
+ "files": [
22
+ "src/"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "workit": "./src/index.tsx"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@brainervirus/workit-core": "^0.4.0",
36
+ "@inkjs/ui": "2.0.0",
37
+ "ink": "7.1.1",
38
+ "react": "19.2.8"
39
+ }
40
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+ import { render } from "ink";
3
+ import { Wizard } from "./steps";
4
+
5
+ const HELP = `workit — workflow rails for agentic coding
6
+
7
+ Usage:
8
+ workit init Run the interactive setup wizard
9
+ workit Show this help
10
+
11
+ Run \`npx workit init\` to configure platforms, YouTrack, VCS and project hygiene.
12
+ `;
13
+
14
+ async function runInit() {
15
+ // ponytail: no-TTY guard — piping/disabling stdin would hang render(); print and exit cleanly
16
+ if (process.stdin.isTTY !== true) {
17
+ console.log("workit init requires an interactive terminal (TTY).");
18
+ process.exit(0);
19
+ }
20
+ let done: () => void = () => {};
21
+ const { waitUntilExit, unmount } = render(<Wizard onExit={() => done()} />);
22
+ done = unmount;
23
+ await waitUntilExit();
24
+ process.exit(0);
25
+ }
26
+
27
+ if (import.meta.main) {
28
+ const [subcommand] = process.argv.slice(2);
29
+ if (subcommand === "init") {
30
+ await runInit();
31
+ } else {
32
+ console.log(HELP);
33
+ process.exit(0);
34
+ }
35
+ }
package/src/logic.ts ADDED
@@ -0,0 +1,263 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import path from "node:path";
4
+ import { PRESETS, LOCALE_RE, type BranchPreset, type ToolkitConfig } from "@brainervirus/workit-core/src/core/config.ts";
5
+ import { workspacesPath, type WorkspaceConfig } from "@brainervirus/workit-core/src/core/workspaces.ts";
6
+ import { ensureProjectGitignore } from "@brainervirus/workit-core/src/core/gitignore.ts";
7
+ import { ensureHygieneFiles, hygieneFiles } from "@brainervirus/workit-core/src/core/hygiene.ts";
8
+
9
+ export const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
10
+
11
+ export function validateLocale(locale: string): string | null {
12
+ if (!LOCALE_RE.test(locale)) {
13
+ return `invalid locale "${locale}" — expected BCP-47 like en or es-CL`;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ const KNOWN_TIMEZONES: string[] | null =
19
+ typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : null;
20
+
21
+ export function validateTimezone(timezone: string): string | null {
22
+ const tz = timezone.trim();
23
+ if (!tz) return "timezone is required";
24
+ if (KNOWN_TIMEZONES && !KNOWN_TIMEZONES.includes(tz)) {
25
+ return `unknown timezone "${tz}" — check the IANA name (e.g. America/Santiago)`;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function validateBaseUrl(url: string): string | null {
31
+ let parsed: URL;
32
+ try {
33
+ parsed = new URL(url.trim());
34
+ } catch {
35
+ return `invalid URL "${url}"`;
36
+ }
37
+ if (parsed.protocol !== "https:") return "base URL must use https";
38
+ return null;
39
+ }
40
+
41
+ export function parseList(raw: string): string[] {
42
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
43
+ }
44
+
45
+ export type ConfigInput = {
46
+ locale?: string;
47
+ timezone?: string;
48
+ preset?: BranchPreset;
49
+ allowed?: string[];
50
+ protectedNames?: string[];
51
+ };
52
+
53
+ export function collectConfigValues(input: ConfigInput, current: ToolkitConfig): ToolkitConfig {
54
+ const preset = input.preset ?? current.branchPolicy.preset;
55
+ const presetDefs = PRESETS[preset];
56
+ return {
57
+ locale: input.locale ?? current.locale,
58
+ localeOptions: current.localeOptions,
59
+ timezone: input.timezone ?? current.timezone,
60
+ branchPolicy: {
61
+ preset,
62
+ allowed: preset === "custom" ? (input.allowed ?? current.branchPolicy.allowed) : [...presetDefs.allowed],
63
+ protected: preset === "custom" ? (input.protectedNames ?? current.branchPolicy.protected) : [...presetDefs.protected],
64
+ },
65
+ };
66
+ }
67
+
68
+ export type ProjectSetupResult = {
69
+ gitignore: { ok: true; path: string; added: string[] } | { ok: false; error: string };
70
+ hygiene: { ok: true; created: string[] } | { ok: false; error: string };
71
+ openSource: boolean;
72
+ created: string[];
73
+ };
74
+
75
+ export function runProjectSetup(root: string, opts: { includeOpenSource?: boolean } = {}): ProjectSetupResult {
76
+ const openSource = opts.includeOpenSource ?? hygieneFiles(root).openSource;
77
+ const gitignore = ensureProjectGitignore(root, true);
78
+ const hygiene = ensureHygieneFiles(root, { confirmed: true, includeOpenSource: openSource });
79
+ const created = [
80
+ ...(gitignore.ok ? gitignore.added : []),
81
+ ...(hygiene.ok ? hygiene.created : []),
82
+ ];
83
+ return { gitignore, hygiene, openSource, created };
84
+ }
85
+
86
+ export const DEFAULT_BASE_URL = "https://enghouseamg.youtrack.cloud";
87
+
88
+ export type YouTrackScaffold = {
89
+ youtrackJson: string;
90
+ tokenPath: string;
91
+ tokenCreateUrl: string;
92
+ };
93
+
94
+ // ponytail: mirrors scripts/init/apply.sh write_youtrack_json + write_token_placeholder +
95
+ // scripts/youtrack/token-create-url.sh in TS — initApply shells out to bash (CA-01 forbids bash)
96
+ // ponytail: mirrors apply.sh; WORKFLOW_YT_*/WORKFLOW_VCS_* env overrides intentionally ignored
97
+ // (wizard takes values from prompts instead of env; parity pinned by test/scaffold-parity.test.ts)
98
+ export function scaffoldYouTrack(dir: string, baseUrl: string, opts: { locale?: string; timezone?: string } = {}): YouTrackScaffold {
99
+ mkdirSync(dir, { recursive: true });
100
+ const youtrackJson = path.join(dir, "youtrack.json");
101
+ const tokenPath = path.join(dir, "youtrack.token");
102
+ const config = {
103
+ baseUrl,
104
+ tokenFile: tokenPath,
105
+ timezone: opts.timezone ?? "America/Santiago",
106
+ locale: opts.locale ?? "es-CL",
107
+ defaultMention: "Alejandra.Flores",
108
+ greetings: { morning: "buenos días", afternoon: "buenas tardes" },
109
+ greetingCutoff: "12:00",
110
+ meetingIssue: "IRPT-12",
111
+ meetingIssues: {
112
+ general: {
113
+ issue: "IRPT-12",
114
+ label: "General meetings (Reuniones internas Team IRP)",
115
+ workItemText: "Reuniones",
116
+ },
117
+ web: {
118
+ issue: "NSXFT-21",
119
+ label: "Web meetings",
120
+ workItemText: "Reuniones web",
121
+ url: "https://enghouseamg.youtrack.cloud/projects/NSXFT/issues/NSXFT-21",
122
+ },
123
+ },
124
+ commentHeader: "# Actualización",
125
+ attachmentsHeaderImages: "## Adjunto capturas",
126
+ attachmentsHeaderFiles: "## Archivos adjuntos",
127
+ attachmentsHeaderMixed: "## Adjuntos",
128
+ tokenDefaults: {
129
+ name: "workit",
130
+ description: "OpenCode workit — /wk-issue-update and /wk-meetings",
131
+ scopes: ["YouTrack"],
132
+ profileTab: "account-security",
133
+ },
134
+ };
135
+ writeFileSync(youtrackJson, JSON.stringify(config, null, 2) + "\n", "utf8");
136
+ writeFileSync(tokenPath, TOKEN_PLACEHOLDER + "\n", { encoding: "utf8", mode: 0o600 });
137
+ const base = baseUrl.replace(/\/+$/, "");
138
+ return {
139
+ youtrackJson,
140
+ tokenPath,
141
+ tokenCreateUrl: `${base}/users/me?tab=account-security`,
142
+ };
143
+ }
144
+
145
+ export type VcsProvider = "gitlab" | "github";
146
+
147
+ export type VcsScaffold = {
148
+ vcsJson: string;
149
+ tokenPaths: string[];
150
+ activeTokenPath: string;
151
+ tokenCreateUrl: string;
152
+ provider: VcsProvider;
153
+ };
154
+
155
+ const TOKEN_DEFAULTS = {
156
+ name: "workit",
157
+ description: "OpenCode workit — /wk-pr and glab/gh",
158
+ gitlabScopes: ["api"],
159
+ githubPermissions: { pull_requests: "write", contents: "write", metadata: "read" },
160
+ githubClassicScopes: ["repo"],
161
+ };
162
+
163
+ // ponytail: mirrors scripts/init/apply.sh write_vcs_json + write_vcs_token_placeholder +
164
+ // scripts/vcs/token-create-urls.sh in TS — same no-bash rationale as scaffoldYouTrack
165
+ export function scaffoldVcs(dir: string, provider: VcsProvider): VcsScaffold {
166
+ mkdirSync(dir, { recursive: true });
167
+ const vcsJson = path.join(dir, "vcs.json");
168
+ const gitlabToken = path.join(dir, "gitlab.token");
169
+ const githubToken = path.join(dir, "github.token");
170
+ const config = {
171
+ provider,
172
+ defaultTargetBranch: "develop",
173
+ gitlab: { host: "gitlab.com", apiUrl: "https://gitlab.com/api/v4", tokenFile: gitlabToken },
174
+ github: { host: "github.com", tokenFile: githubToken },
175
+ pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
176
+ tokenDefaults: TOKEN_DEFAULTS,
177
+ };
178
+ writeFileSync(vcsJson, JSON.stringify(config, null, 2) + "\n", "utf8");
179
+ for (const p of [gitlabToken, githubToken]) {
180
+ writeFileSync(p, TOKEN_PLACEHOLDER + "\n", { encoding: "utf8", mode: 0o600 });
181
+ }
182
+
183
+ const gitlabUrl = `https://gitlab.com/-/user_settings/personal_access_tokens?${new URLSearchParams({
184
+ name: TOKEN_DEFAULTS.name,
185
+ description: TOKEN_DEFAULTS.description,
186
+ scopes: "api",
187
+ })}`;
188
+ const githubUrl = `https://github.com/settings/personal-access-tokens/new?${new URLSearchParams({
189
+ name: TOKEN_DEFAULTS.name,
190
+ description: TOKEN_DEFAULTS.description,
191
+ pull_requests: "write",
192
+ contents: "write",
193
+ metadata: "read",
194
+ })}`;
195
+
196
+ return {
197
+ vcsJson,
198
+ tokenPaths: [gitlabToken, githubToken],
199
+ activeTokenPath: provider === "gitlab" ? gitlabToken : githubToken,
200
+ tokenCreateUrl: provider === "gitlab" ? gitlabUrl : githubUrl,
201
+ provider,
202
+ };
203
+ }
204
+
205
+ export function shouldWriteWorkspaces(loaded: WorkspaceConfig[], current: WorkspaceConfig[]): boolean {
206
+ return !isDeepStrictEqual(loaded, current);
207
+ }
208
+
209
+ export function loadWorkspaces(): WorkspaceConfig[] {
210
+ let raw: string;
211
+ try {
212
+ raw = readFileSync(workspacesPath(), "utf8");
213
+ } catch {
214
+ return [];
215
+ }
216
+ let parsed: unknown;
217
+ try {
218
+ parsed = JSON.parse(raw);
219
+ } catch {
220
+ return [];
221
+ }
222
+ if (!parsed || typeof parsed !== "object") return [];
223
+ const list = (parsed as { workspaces?: unknown }).workspaces;
224
+ return Array.isArray(list) ? (list as WorkspaceConfig[]) : [];
225
+ }
226
+
227
+ export type WriteWorkspacesResult = { ok: boolean; error?: string; path: string };
228
+
229
+ const VALID_PROVIDERS: VcsProvider[] = ["gitlab", "github"];
230
+
231
+ export function writeWorkspaces(entries: WorkspaceConfig[]): WriteWorkspacesResult {
232
+ const file = workspacesPath();
233
+ for (const [i, entry] of entries.entries()) {
234
+ if (!entry || typeof entry !== "object") {
235
+ return { ok: false, error: `workspace #${i + 1} is null`, path: file };
236
+ }
237
+ if (typeof entry.name !== "string" || !entry.name.trim()) {
238
+ return { ok: false, error: `workspace #${i + 1} missing a name`, path: file };
239
+ }
240
+ if (typeof entry.glob !== "string" || !entry.glob.trim()) {
241
+ return { ok: false, error: `workspace "${entry.name}" missing a glob`, path: file };
242
+ }
243
+ const provider = entry.vcs?.provider;
244
+ if (provider && !VALID_PROVIDERS.includes(provider)) {
245
+ return { ok: false, error: `workspace "${entry.name}" has unknown provider "${provider}"`, path: file };
246
+ }
247
+ if (entry.youtrack && provider !== "gitlab") {
248
+ return { ok: false, error: `workspace "${entry.name}" links YouTrack issues but provider is "${provider ?? "unset"}" — youtrack linking requires the gitlab provider`, path: file };
249
+ }
250
+ if (entry.issues && provider !== "github") {
251
+ return { ok: false, error: `workspace "${entry.name}" links GitHub issues but provider is "${provider ?? "unset"}" — github issues require the github provider`, path: file };
252
+ }
253
+ }
254
+ mkdirSync(path.dirname(file), { recursive: true });
255
+ const tmp = `${file}.${process.pid}.tmp`;
256
+ try {
257
+ writeFileSync(tmp, JSON.stringify({ workspaces: entries }, null, 2) + "\n", "utf8");
258
+ renameSync(tmp, file);
259
+ } catch (err) {
260
+ return { ok: false, error: `failed to write ${file}: ${(err as Error).message}`, path: file };
261
+ }
262
+ return { ok: true, path: file };
263
+ }
package/src/steps.tsx ADDED
@@ -0,0 +1,605 @@
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
+ }