@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 +40 -0
- package/src/index.tsx +35 -0
- package/src/logic.ts +263 -0
- package/src/steps.tsx +605 -0
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
|
+
}
|