storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects
This commit is contained in:
@@ -21,6 +21,19 @@ export type WsRequest =
|
|||||||
config: ProviderConfig;
|
config: ProviderConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface WizardStepInfo {
|
||||||
|
step: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardStateData {
|
||||||
|
steps: WizardStepInfo[];
|
||||||
|
current_step_index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentAssignment {
|
export interface AgentAssignment {
|
||||||
agent_name: string;
|
agent_name: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -80,6 +93,13 @@ export type WsResponse =
|
|||||||
| { type: "pong" }
|
| { type: "pong" }
|
||||||
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
/** Sent on connect when the project still needs onboarding (specs are placeholders). */
|
||||||
| { type: "onboarding_status"; needs_onboarding: boolean }
|
| { type: "onboarding_status"; needs_onboarding: boolean }
|
||||||
|
/** Sent on connect when a setup wizard is active. */
|
||||||
|
| {
|
||||||
|
type: "wizard_state";
|
||||||
|
steps: WizardStepInfo[];
|
||||||
|
current_step_index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
/** Streaming thinking token from an extended-thinking block, separate from regular text. */
|
||||||
| { type: "thinking_token"; content: string }
|
| { type: "thinking_token"; content: string }
|
||||||
/** Streaming token from a /btw side question response. */
|
/** Streaming token from a /btw side question response. */
|
||||||
@@ -438,6 +458,7 @@ export class ChatWebSocket {
|
|||||||
private onAgentConfigChanged?: () => void;
|
private onAgentConfigChanged?: () => void;
|
||||||
private onAgentStateChanged?: () => void;
|
private onAgentStateChanged?: () => void;
|
||||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
|
private onWizardState?: (state: WizardStateData) => void;
|
||||||
private onSideQuestionToken?: (content: string) => void;
|
private onSideQuestionToken?: (content: string) => void;
|
||||||
private onSideQuestionDone?: (response: string) => void;
|
private onSideQuestionDone?: (response: string) => void;
|
||||||
private onLogEntry?: (
|
private onLogEntry?: (
|
||||||
@@ -528,6 +549,12 @@ export class ChatWebSocket {
|
|||||||
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
if (data.type === "agent_state_changed") this.onAgentStateChanged?.();
|
||||||
if (data.type === "onboarding_status")
|
if (data.type === "onboarding_status")
|
||||||
this.onOnboardingStatus?.(data.needs_onboarding);
|
this.onOnboardingStatus?.(data.needs_onboarding);
|
||||||
|
if (data.type === "wizard_state")
|
||||||
|
this.onWizardState?.({
|
||||||
|
steps: data.steps,
|
||||||
|
current_step_index: data.current_step_index,
|
||||||
|
completed: data.completed,
|
||||||
|
});
|
||||||
if (data.type === "side_question_token")
|
if (data.type === "side_question_token")
|
||||||
this.onSideQuestionToken?.(data.content);
|
this.onSideQuestionToken?.(data.content);
|
||||||
if (data.type === "side_question_done")
|
if (data.type === "side_question_done")
|
||||||
@@ -587,6 +614,7 @@ export class ChatWebSocket {
|
|||||||
onAgentConfigChanged?: () => void;
|
onAgentConfigChanged?: () => void;
|
||||||
onAgentStateChanged?: () => void;
|
onAgentStateChanged?: () => void;
|
||||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
|
onWizardState?: (state: WizardStateData) => void;
|
||||||
onSideQuestionToken?: (content: string) => void;
|
onSideQuestionToken?: (content: string) => void;
|
||||||
onSideQuestionDone?: (response: string) => void;
|
onSideQuestionDone?: (response: string) => void;
|
||||||
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||||
@@ -606,6 +634,7 @@ export class ChatWebSocket {
|
|||||||
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||||
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
this.onAgentStateChanged = handlers.onAgentStateChanged;
|
||||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||||
|
this.onWizardState = handlers.onWizardState;
|
||||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||||
this.onLogEntry = handlers.onLogEntry;
|
this.onLogEntry = handlers.onLogEntry;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import type { AgentConfigInfo } from "../api/agents";
|
import type { AgentConfigInfo } from "../api/agents";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import type { AnthropicModelInfo, PipelineState } from "../api/client";
|
import type {
|
||||||
|
AnthropicModelInfo,
|
||||||
|
PipelineState,
|
||||||
|
WizardStateData,
|
||||||
|
} from "../api/client";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
import { useChatHistory } from "../hooks/useChatHistory";
|
import { useChatHistory } from "../hooks/useChatHistory";
|
||||||
import type { Message, ProviderConfig } from "../types";
|
import type { Message, ProviderConfig } from "../types";
|
||||||
@@ -17,6 +21,7 @@ import { LozengeFlyProvider } from "./LozengeFlyContext";
|
|||||||
import { MessageItem } from "./MessageItem";
|
import { MessageItem } from "./MessageItem";
|
||||||
import type { LogEntry } from "./ServerLogsPanel";
|
import type { LogEntry } from "./ServerLogsPanel";
|
||||||
import { ServerLogsPanel } from "./ServerLogsPanel";
|
import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||||
|
import SetupWizard from "./SetupWizard";
|
||||||
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
||||||
import { StagePanel } from "./StagePanel";
|
import { StagePanel } from "./StagePanel";
|
||||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||||
@@ -217,6 +222,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
|
const [wizardState, setWizardState] = useState<WizardStateData | null>(null);
|
||||||
const onboardingTriggeredRef = useRef(false);
|
const onboardingTriggeredRef = useRef(false);
|
||||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -466,6 +472,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onOnboardingStatus: (onboarding: boolean) => {
|
onOnboardingStatus: (onboarding: boolean) => {
|
||||||
setNeedsOnboarding(onboarding);
|
setNeedsOnboarding(onboarding);
|
||||||
},
|
},
|
||||||
|
onWizardState: (state: WizardStateData) => {
|
||||||
|
setWizardState(state);
|
||||||
|
},
|
||||||
onSideQuestionToken: (content) => {
|
onSideQuestionToken: (content) => {
|
||||||
setSideQuestion((prev) =>
|
setSideQuestion((prev) =>
|
||||||
prev ? { ...prev, response: prev.response + content } : prev,
|
prev ? { ...prev, response: prev.response + content } : prev,
|
||||||
@@ -978,63 +987,76 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
gap: "24px",
|
gap: "24px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{needsOnboarding && messages.length === 0 && !loading && (
|
{wizardState &&
|
||||||
<div
|
!wizardState.completed &&
|
||||||
data-testid="onboarding-welcome"
|
messages.length === 0 &&
|
||||||
style={{
|
!loading && (
|
||||||
padding: "24px",
|
<SetupWizard
|
||||||
borderRadius: "12px",
|
wizardState={wizardState}
|
||||||
background: "#1c2a1c",
|
onWizardUpdate={setWizardState}
|
||||||
border: "1px solid #2d4a2d",
|
sendMessage={sendMessage}
|
||||||
marginBottom: "8px",
|
/>
|
||||||
}}
|
)}
|
||||||
>
|
{needsOnboarding &&
|
||||||
<h3
|
!wizardState &&
|
||||||
|
messages.length === 0 &&
|
||||||
|
!loading && (
|
||||||
|
<div
|
||||||
|
data-testid="onboarding-welcome"
|
||||||
style={{
|
style={{
|
||||||
margin: "0 0 8px 0",
|
padding: "24px",
|
||||||
color: "#a0d4a0",
|
borderRadius: "12px",
|
||||||
fontSize: "1.1rem",
|
background: "#1c2a1c",
|
||||||
|
border: "1px solid #2d4a2d",
|
||||||
|
marginBottom: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Welcome to Storkit
|
<h3
|
||||||
</h3>
|
style={{
|
||||||
<p
|
margin: "0 0 8px 0",
|
||||||
style={{
|
color: "#a0d4a0",
|
||||||
margin: "0 0 16px 0",
|
fontSize: "1.1rem",
|
||||||
color: "#ccc",
|
}}
|
||||||
lineHeight: 1.5,
|
>
|
||||||
}}
|
Welcome to Storkit
|
||||||
>
|
</h3>
|
||||||
This project needs to be set up before you can start writing
|
<p
|
||||||
stories. The agent will guide you through configuring your
|
style={{
|
||||||
project goals and tech stack.
|
margin: "0 0 16px 0",
|
||||||
</p>
|
color: "#ccc",
|
||||||
<button
|
lineHeight: 1.5,
|
||||||
type="button"
|
}}
|
||||||
data-testid="onboarding-start-button"
|
>
|
||||||
onClick={() => {
|
This project needs to be set up before you can start
|
||||||
if (onboardingTriggeredRef.current) return;
|
writing stories. The agent will guide you through
|
||||||
onboardingTriggeredRef.current = true;
|
configuring your project goals and tech stack.
|
||||||
setNeedsOnboarding(false);
|
</p>
|
||||||
sendMessage(
|
<button
|
||||||
"I just created a new project. Help me set it up.",
|
type="button"
|
||||||
);
|
data-testid="onboarding-start-button"
|
||||||
}}
|
onClick={() => {
|
||||||
style={{
|
if (onboardingTriggeredRef.current) return;
|
||||||
padding: "10px 20px",
|
onboardingTriggeredRef.current = true;
|
||||||
borderRadius: "8px",
|
setNeedsOnboarding(false);
|
||||||
border: "none",
|
sendMessage(
|
||||||
backgroundColor: "#a0d4a0",
|
"I just created a new project. Help me set it up.",
|
||||||
color: "#1a1a1a",
|
);
|
||||||
cursor: "pointer",
|
}}
|
||||||
fontSize: "0.95rem",
|
style={{
|
||||||
fontWeight: 600,
|
padding: "10px 20px",
|
||||||
}}
|
borderRadius: "8px",
|
||||||
>
|
border: "none",
|
||||||
Start Project Setup
|
backgroundColor: "#a0d4a0",
|
||||||
</button>
|
color: "#1a1a1a",
|
||||||
</div>
|
cursor: "pointer",
|
||||||
)}
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Project Setup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{messages.map((msg: Message, idx: number) => (
|
{messages.map((msg: Message, idx: number) => (
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { WizardStateData, WizardStepInfo } from "../api/client";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
interface SetupWizardProps {
|
||||||
|
wizardState: WizardStateData;
|
||||||
|
onWizardUpdate: (state: WizardStateData) => void;
|
||||||
|
sendMessage: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style constants for the wizard UI. */
|
||||||
|
const STEP_BG_PENDING = "#1a1f2e";
|
||||||
|
const STEP_BG_ACTIVE = "#1c2a1c";
|
||||||
|
const STEP_BG_DONE = "#1a2a1a";
|
||||||
|
const STEP_BORDER_PENDING = "#2a2f3e";
|
||||||
|
const STEP_BORDER_ACTIVE = "#2d4a2d";
|
||||||
|
const STEP_BORDER_DONE = "#2d4a2d";
|
||||||
|
const COLOR_LABEL = "#ccc";
|
||||||
|
const COLOR_LABEL_DONE = "#a0d4a0";
|
||||||
|
const COLOR_ACCENT = "#a0d4a0";
|
||||||
|
|
||||||
|
function statusIcon(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "confirmed":
|
||||||
|
return "\u2713";
|
||||||
|
case "skipped":
|
||||||
|
return "\u2013";
|
||||||
|
case "generating":
|
||||||
|
return "\u2026";
|
||||||
|
case "awaiting_confirmation":
|
||||||
|
return "?";
|
||||||
|
default:
|
||||||
|
return "\u00B7";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBackground(status: string, isActive: boolean): string {
|
||||||
|
if (status === "confirmed" || status === "skipped") return STEP_BG_DONE;
|
||||||
|
if (isActive) return STEP_BG_ACTIVE;
|
||||||
|
return STEP_BG_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBorder(status: string, isActive: boolean): string {
|
||||||
|
if (status === "confirmed" || status === "skipped") return STEP_BORDER_DONE;
|
||||||
|
if (isActive) return STEP_BORDER_ACTIVE;
|
||||||
|
return STEP_BORDER_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages sent to the chat to trigger agent generation for each step. */
|
||||||
|
const STEP_PROMPTS: Record<string, string> = {
|
||||||
|
context:
|
||||||
|
"Read the codebase and generate .storkit/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content",
|
||||||
|
stack:
|
||||||
|
"Read the tech stack and generate .storkit/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content",
|
||||||
|
test_script:
|
||||||
|
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content",
|
||||||
|
release_script:
|
||||||
|
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content",
|
||||||
|
test_coverage:
|
||||||
|
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function apiPost(path: string): Promise<WizardStateData | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, { method: "POST" });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return (await resp.json()) as WizardStateData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard({
|
||||||
|
step,
|
||||||
|
isActive,
|
||||||
|
onGenerate,
|
||||||
|
onConfirm,
|
||||||
|
onSkip,
|
||||||
|
}: {
|
||||||
|
step: WizardStepInfo;
|
||||||
|
isActive: boolean;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}) {
|
||||||
|
const isDone = step.status === "confirmed" || step.status === "skipped";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`wizard-step-${step.step}`}
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: stepBackground(step.status, isActive),
|
||||||
|
border: `1px solid ${stepBorder(step.status, isActive)}`,
|
||||||
|
opacity: !isActive && !isDone ? 0.5 : 1,
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: isDone ? COLOR_ACCENT : "transparent",
|
||||||
|
border: isDone ? "none" : `1px solid ${COLOR_LABEL}`,
|
||||||
|
color: isDone ? "#1a1a1a" : COLOR_LABEL,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusIcon(step.status)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: isDone ? COLOR_LABEL_DONE : COLOR_LABEL,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
{isActive && step.status === "pending" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-generate-${step.step}`}
|
||||||
|
onClick={onGenerate}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: COLOR_ACCENT,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && step.status === "generating" && (
|
||||||
|
<span style={{ color: "#aaa", fontSize: "0.85rem" }}>
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.content && step.status === "awaiting_confirmation" && (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<pre
|
||||||
|
data-testid={`wizard-preview-${step.step}`}
|
||||||
|
style={{
|
||||||
|
background: "#111",
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#ddd",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
maxHeight: "200px",
|
||||||
|
overflow: "auto",
|
||||||
|
margin: "0 0 12px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.content}
|
||||||
|
</pre>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-confirm-${step.step}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: COLOR_ACCENT,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-revise-${step.step}`}
|
||||||
|
onClick={onGenerate}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #555",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#ccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revise
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-skip-${step.step}`}
|
||||||
|
onClick={onSkip}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #555",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && step.status === "pending" && !step.content && (
|
||||||
|
<div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`wizard-skip-${step.step}`}
|
||||||
|
onClick={onSkip}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #444",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip this step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SetupWizard({
|
||||||
|
wizardState,
|
||||||
|
onWizardUpdate,
|
||||||
|
sendMessage,
|
||||||
|
}: SetupWizardProps) {
|
||||||
|
const [, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(
|
||||||
|
(step: WizardStepInfo) => {
|
||||||
|
const prompt = STEP_PROMPTS[step.step];
|
||||||
|
if (prompt) {
|
||||||
|
sendMessage(prompt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(
|
||||||
|
async (step: WizardStepInfo) => {
|
||||||
|
const result = await apiPost(`/wizard/step/${step.step}/confirm`);
|
||||||
|
if (result) {
|
||||||
|
onWizardUpdate(result);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onWizardUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(
|
||||||
|
async (step: WizardStepInfo) => {
|
||||||
|
const result = await apiPost(`/wizard/step/${step.step}/skip`);
|
||||||
|
if (result) {
|
||||||
|
onWizardUpdate(result);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onWizardUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wizardState.completed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="wizard-complete"
|
||||||
|
style={{
|
||||||
|
padding: "24px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: STEP_BG_DONE,
|
||||||
|
border: `1px solid ${STEP_BORDER_DONE}`,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: "0 0 8px 0", color: COLOR_ACCENT }}>
|
||||||
|
Setup Complete
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, color: COLOR_LABEL }}>
|
||||||
|
Your project is configured. You can start writing stories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="setup-wizard"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "8px" }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 4px 0",
|
||||||
|
color: COLOR_ACCENT,
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Project Setup Wizard
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, color: "#999", fontSize: "0.85rem" }}>
|
||||||
|
Step {wizardState.current_step_index + 1} of{" "}
|
||||||
|
{wizardState.steps.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wizardState.steps.map((step, idx) => (
|
||||||
|
<StepCard
|
||||||
|
key={step.step}
|
||||||
|
step={step}
|
||||||
|
isActive={idx === wizardState.current_step_index}
|
||||||
|
onGenerate={() => handleGenerate(step)}
|
||||||
|
onConfirm={() => handleConfirm(step)}
|
||||||
|
onSkip={() => handleSkip(step)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod settings;
|
|||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod wizard;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
use agents::AgentsApi;
|
use agents::AgentsApi;
|
||||||
@@ -131,6 +132,7 @@ type ApiTuple = (
|
|||||||
SettingsApi,
|
SettingsApi,
|
||||||
HealthApi,
|
HealthApi,
|
||||||
BotCommandApi,
|
BotCommandApi,
|
||||||
|
wizard::WizardApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||||
@@ -147,6 +149,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
SettingsApi { ctx: ctx.clone() },
|
SettingsApi { ctx: ctx.clone() },
|
||||||
HealthApi,
|
HealthApi,
|
||||||
BotCommandApi { ctx: ctx.clone() },
|
BotCommandApi { ctx: ctx.clone() },
|
||||||
|
wizard::WizardApi { ctx: ctx.clone() },
|
||||||
);
|
);
|
||||||
|
|
||||||
let api_service =
|
let api_service =
|
||||||
@@ -161,7 +164,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
AgentsApi { ctx: ctx.clone() },
|
AgentsApi { ctx: ctx.clone() },
|
||||||
SettingsApi { ctx: ctx.clone() },
|
SettingsApi { ctx: ctx.clone() },
|
||||||
HealthApi,
|
HealthApi,
|
||||||
BotCommandApi { ctx },
|
BotCommandApi { ctx: ctx.clone() },
|
||||||
|
wizard::WizardApi { ctx },
|
||||||
);
|
);
|
||||||
|
|
||||||
let docs_service =
|
let docs_service =
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||||
|
use crate::io::wizard::{StepStatus, WizardState, WizardStep};
|
||||||
|
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Tags)]
|
||||||
|
enum WizardTags {
|
||||||
|
Wizard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for a single wizard step.
|
||||||
|
#[derive(Serialize, Object)]
|
||||||
|
struct StepResponse {
|
||||||
|
step: String,
|
||||||
|
label: String,
|
||||||
|
status: String,
|
||||||
|
#[oai(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full wizard state response.
|
||||||
|
#[derive(Serialize, Object)]
|
||||||
|
struct WizardResponse {
|
||||||
|
steps: Vec<StepResponse>,
|
||||||
|
current_step_index: usize,
|
||||||
|
completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for confirming/skipping a step or submitting content.
|
||||||
|
#[derive(Deserialize, Object)]
|
||||||
|
struct StepActionPayload {
|
||||||
|
/// Optional content to store for the step (e.g., generated spec).
|
||||||
|
#[oai(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&WizardState> for WizardResponse {
|
||||||
|
fn from(state: &WizardState) -> Self {
|
||||||
|
WizardResponse {
|
||||||
|
steps: state
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| StepResponse {
|
||||||
|
step: serde_json::to_value(s.step)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
label: s.step.label().to_string(),
|
||||||
|
status: serde_json::to_value(&s.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
content: s.content.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
current_step_index: state.current_step_index(),
|
||||||
|
completed: state.completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_step(step_str: &str) -> Result<WizardStep, poem::Error> {
|
||||||
|
let quoted = format!("\"{step_str}\"");
|
||||||
|
serde_json::from_str::<WizardStep>("ed)
|
||||||
|
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WizardApi {
|
||||||
|
pub ctx: Arc<AppContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenApi(tag = "WizardTags::Wizard")]
|
||||||
|
impl WizardApi {
|
||||||
|
/// Get the current wizard state.
|
||||||
|
///
|
||||||
|
/// Returns the full setup wizard progress including all steps and their
|
||||||
|
/// statuses. Returns 404 if no wizard is active.
|
||||||
|
#[oai(path = "/wizard", method = "get")]
|
||||||
|
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a step's content and mark it as awaiting confirmation.
|
||||||
|
///
|
||||||
|
/// Used after the agent generates content for a step. The content is
|
||||||
|
/// stored for preview and the step is marked as awaiting user confirmation.
|
||||||
|
#[oai(path = "/wizard/step/:step/content", method = "put")]
|
||||||
|
async fn set_step_content(
|
||||||
|
&self,
|
||||||
|
step: Path<String>,
|
||||||
|
payload: Json<StepActionPayload>,
|
||||||
|
) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.set_step_status(
|
||||||
|
wizard_step,
|
||||||
|
StepStatus::AwaitingConfirmation,
|
||||||
|
payload.0.content,
|
||||||
|
);
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm a step and advance to the next.
|
||||||
|
///
|
||||||
|
/// The step must be the current active step. Returns the updated wizard state.
|
||||||
|
#[oai(path = "/wizard/step/:step/confirm", method = "post")]
|
||||||
|
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.confirm_step(wizard_step).map_err(bad_request)?;
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip a step and advance to the next.
|
||||||
|
///
|
||||||
|
/// The step must be the current active step.
|
||||||
|
#[oai(path = "/wizard/step/:step/skip", method = "post")]
|
||||||
|
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.skip_step(wizard_step).map_err(bad_request)?;
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a step as generating (agent is working on it).
|
||||||
|
#[oai(path = "/wizard/step/:step/generating", method = "post")]
|
||||||
|
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let wizard_step = parse_step(&step.0)?;
|
||||||
|
let mut state =
|
||||||
|
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
||||||
|
|
||||||
|
state.set_step_status(wizard_step, StepStatus::Generating, None);
|
||||||
|
state.save(&root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
use poem::http::StatusCode;
|
||||||
|
use poem::test::TestClient;
|
||||||
|
use poem_openapi::OpenApiService;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup() -> (TempDir, TestClient<impl poem::Endpoint>) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
std::fs::create_dir_all(root.join(".storkit")).unwrap();
|
||||||
|
|
||||||
|
let ctx = Arc::new(AppContext::new_test(root.clone()));
|
||||||
|
let api = WizardApi { ctx };
|
||||||
|
let service = OpenApiService::new(api, "test", "0.1.0");
|
||||||
|
let client = TestClient::new(service);
|
||||||
|
(dir, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_wizard_returns_404_when_no_wizard() {
|
||||||
|
let (_dir, client) = setup();
|
||||||
|
let resp = client.get("/wizard").send().await;
|
||||||
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_wizard_returns_state_when_active() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client.get("/wizard").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["current_step_index"], 1);
|
||||||
|
assert!(!body["completed"].as_bool().unwrap());
|
||||||
|
assert_eq!(body["steps"].as_array().unwrap().len(), 6);
|
||||||
|
assert_eq!(body["steps"][0]["status"], "confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirm_step_advances_wizard() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client.post("/wizard/step/context/confirm").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["current_step_index"], 2);
|
||||||
|
assert_eq!(body["steps"][1]["status"], "confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirm_wrong_step_returns_error() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
// Try to confirm step 3 (stack) when current is step 2 (context)
|
||||||
|
let resp = client.post("/wizard/step/stack/confirm").send().await;
|
||||||
|
resp.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skip_step_advances_wizard() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client.post("/wizard/step/context/skip").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["steps"][1]["status"], "skipped");
|
||||||
|
assert_eq!(body["current_step_index"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_step_content_marks_awaiting_confirmation() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.put("/wizard/step/context/content")
|
||||||
|
.body_json(&serde_json::json!({
|
||||||
|
"content": "# My Project\n\nA great project."
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["steps"][1]["status"], "awaiting_confirmation");
|
||||||
|
assert_eq!(
|
||||||
|
body["steps"][1]["content"],
|
||||||
|
"# My Project\n\nA great project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_generating_updates_step() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("/wizard/step/context/generating")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert_eq!(body["steps"][1]["status"], "generating");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_step_returns_404() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post("/wizard/step/nonexistent/confirm")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_wizard_flow_completes() {
|
||||||
|
let (dir, client) = setup();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
|
||||||
|
// Steps 2-6 (scaffold is already confirmed)
|
||||||
|
let steps = ["context", "stack", "test_script", "release_script", "test_coverage"];
|
||||||
|
for step in steps {
|
||||||
|
let resp = client
|
||||||
|
.post(format!("/wizard/step/{step}/confirm"))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check final state
|
||||||
|
let resp = client.get("/wizard").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||||
|
assert!(body["completed"].as_bool().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use crate::http::context::{AppContext, PermissionDecision};
|
|||||||
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
use crate::http::workflow::{PipelineState, load_pipeline_state};
|
||||||
use crate::io::onboarding;
|
use crate::io::onboarding;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
use crate::io::wizard;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
use crate::log_buffer;
|
use crate::log_buffer;
|
||||||
@@ -46,6 +47,16 @@ enum WsRequest {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialisable summary of a single wizard step for WebSocket broadcast.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct WizardStepInfo {
|
||||||
|
pub step: String,
|
||||||
|
pub label: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
/// WebSocket response messages sent by the server.
|
/// WebSocket response messages sent by the server.
|
||||||
@@ -125,6 +136,13 @@ enum WsResponse {
|
|||||||
OnboardingStatus {
|
OnboardingStatus {
|
||||||
needs_onboarding: bool,
|
needs_onboarding: bool,
|
||||||
},
|
},
|
||||||
|
/// Sent on connect when a setup wizard is active. Contains the full
|
||||||
|
/// wizard state so the frontend can render the step-by-step UI.
|
||||||
|
WizardState {
|
||||||
|
steps: Vec<WizardStepInfo>,
|
||||||
|
current_step_index: usize,
|
||||||
|
completed: bool,
|
||||||
|
},
|
||||||
/// Streaming token from a `/btw` side question response.
|
/// Streaming token from a `/btw` side question response.
|
||||||
SideQuestionToken {
|
SideQuestionToken {
|
||||||
content: String,
|
content: String,
|
||||||
@@ -219,6 +237,35 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push wizard state if an active wizard exists.
|
||||||
|
{
|
||||||
|
if let Ok(root) = ctx.state.get_project_root()
|
||||||
|
&& let Some(ws) = wizard::WizardState::load(&root)
|
||||||
|
{
|
||||||
|
let steps: Vec<WizardStepInfo> = ws
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| WizardStepInfo {
|
||||||
|
step: serde_json::to_value(s.step)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
label: s.step.label().to_string(),
|
||||||
|
status: serde_json::to_value(&s.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
content: s.content.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = tx.send(WsResponse::WizardState {
|
||||||
|
steps,
|
||||||
|
current_step_index: ws.current_step_index(),
|
||||||
|
completed: ws.completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Push recent server log entries so the client has history on connect.
|
// Push recent server log entries so the client has history on connect.
|
||||||
{
|
{
|
||||||
let entries = log_buffer::global().get_recent_entries(100, None, None);
|
let entries = log_buffer::global().get_recent_entries(100, None, None);
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
"work/4_merge/",
|
"work/4_merge/",
|
||||||
"logs/",
|
"logs/",
|
||||||
"token_usage.jsonl",
|
"token_usage.jsonl",
|
||||||
|
"wizard_state.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
let gitignore_path = root.join(".storkit").join(".gitignore");
|
let gitignore_path = root.join(".storkit").join(".gitignore");
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pub mod search;
|
|||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod story_metadata;
|
pub mod story_metadata;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
|
pub mod wizard;
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Ordered wizard steps for project setup.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WizardStep {
|
||||||
|
/// Step 1: scaffold .storkit/ directory structure and project.toml
|
||||||
|
Scaffold,
|
||||||
|
/// Step 2: generate specs/00_CONTEXT.md
|
||||||
|
Context,
|
||||||
|
/// Step 3: generate specs/tech/STACK.md
|
||||||
|
Stack,
|
||||||
|
/// Step 4: create script/test
|
||||||
|
TestScript,
|
||||||
|
/// Step 5: create script/release
|
||||||
|
ReleaseScript,
|
||||||
|
/// Step 6: create script/test_coverage
|
||||||
|
TestCoverage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WizardStep {
|
||||||
|
/// All steps in order.
|
||||||
|
pub const ALL: &[WizardStep] = &[
|
||||||
|
WizardStep::Scaffold,
|
||||||
|
WizardStep::Context,
|
||||||
|
WizardStep::Stack,
|
||||||
|
WizardStep::TestScript,
|
||||||
|
WizardStep::ReleaseScript,
|
||||||
|
WizardStep::TestCoverage,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Human-readable label for this step.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
WizardStep::Scaffold => "Scaffold directory structure",
|
||||||
|
WizardStep::Context => "Generate project context (00_CONTEXT.md)",
|
||||||
|
WizardStep::Stack => "Generate tech stack spec (STACK.md)",
|
||||||
|
WizardStep::TestScript => "Create test script (script/test)",
|
||||||
|
WizardStep::ReleaseScript => "Create release script (script/release)",
|
||||||
|
WizardStep::TestCoverage => "Create test coverage script (script/test_coverage)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero-based index of this step.
|
||||||
|
pub fn index(&self) -> usize {
|
||||||
|
Self::ALL.iter().position(|s| s == self).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of an individual wizard step.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StepStatus {
|
||||||
|
/// Not yet started.
|
||||||
|
Pending,
|
||||||
|
/// Agent is generating content for this step.
|
||||||
|
Generating,
|
||||||
|
/// Content generated, awaiting user confirmation.
|
||||||
|
AwaitingConfirmation,
|
||||||
|
/// User confirmed this step.
|
||||||
|
Confirmed,
|
||||||
|
/// User skipped this step.
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of a single wizard step.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StepState {
|
||||||
|
pub step: WizardStep,
|
||||||
|
pub status: StepStatus,
|
||||||
|
/// The generated content (if any) for preview.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent wizard state, stored in `.storkit/wizard_state.json`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WizardState {
|
||||||
|
pub steps: Vec<StepState>,
|
||||||
|
/// True when all steps are confirmed or skipped.
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WizardState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
steps: WizardStep::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&step| StepState {
|
||||||
|
step,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
content: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
completed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WizardState {
|
||||||
|
/// Path to the wizard state file relative to the project root.
|
||||||
|
fn state_path(project_root: &Path) -> std::path::PathBuf {
|
||||||
|
project_root.join(".storkit").join("wizard_state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load wizard state from disk, or return None if it doesn't exist.
|
||||||
|
pub fn load(project_root: &Path) -> Option<Self> {
|
||||||
|
let path = Self::state_path(project_root);
|
||||||
|
let content = fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&content).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save wizard state to disk.
|
||||||
|
pub fn save(&self, project_root: &Path) -> Result<(), String> {
|
||||||
|
let path = Self::state_path(project_root);
|
||||||
|
let content =
|
||||||
|
serde_json::to_string_pretty(self).map_err(|e| format!("Serialize error: {e}"))?;
|
||||||
|
fs::write(&path, content).map_err(|e| format!("Failed to write wizard state: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create wizard state file if it doesn't already exist.
|
||||||
|
/// Step 1 (Scaffold) is automatically confirmed since `storkit init`
|
||||||
|
/// has already run the scaffold.
|
||||||
|
pub fn init_if_missing(project_root: &Path) {
|
||||||
|
if Self::load(project_root).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut state = Self::default();
|
||||||
|
// Scaffold step is done by the time the server starts.
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
let _ = state.save(project_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current step index (0-based).
|
||||||
|
pub fn current_step_index(&self) -> usize {
|
||||||
|
self.steps
|
||||||
|
.iter()
|
||||||
|
.position(|s| !matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped))
|
||||||
|
.unwrap_or(self.steps.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a step's status and update completion state.
|
||||||
|
pub fn set_step_status(
|
||||||
|
&mut self,
|
||||||
|
step: WizardStep,
|
||||||
|
status: StepStatus,
|
||||||
|
content: Option<String>,
|
||||||
|
) {
|
||||||
|
if let Some(s) = self.steps.iter_mut().find(|s| s.step == step) {
|
||||||
|
s.status = status;
|
||||||
|
if content.is_some() {
|
||||||
|
s.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.completed = self
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.all(|s| matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm a step. Returns error if the step is not the current one
|
||||||
|
/// (enforces sequential progression).
|
||||||
|
pub fn confirm_step(&mut self, step: WizardStep) -> Result<(), String> {
|
||||||
|
let current_idx = self.current_step_index();
|
||||||
|
let target_idx = step.index();
|
||||||
|
if target_idx != current_idx {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot confirm step {:?}: current step is {}",
|
||||||
|
step, current_idx
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.set_step_status(step, StepStatus::Confirmed, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip a step. Only the current step can be skipped.
|
||||||
|
pub fn skip_step(&mut self, step: WizardStep) -> Result<(), String> {
|
||||||
|
let current_idx = self.current_step_index();
|
||||||
|
let target_idx = step.index();
|
||||||
|
if target_idx != current_idx {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot skip step {:?}: current step is {}",
|
||||||
|
step, current_idx
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.set_step_status(step, StepStatus::Skipped, None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
let sk = root.join(".storkit");
|
||||||
|
std::fs::create_dir_all(&sk).unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_state_has_all_steps_pending() {
|
||||||
|
let state = WizardState::default();
|
||||||
|
assert_eq!(state.steps.len(), 6);
|
||||||
|
for step in &state.steps {
|
||||||
|
assert_eq!(step.status, StepStatus::Pending);
|
||||||
|
}
|
||||||
|
assert!(!state.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_if_missing_creates_state_with_scaffold_confirmed() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&dir);
|
||||||
|
|
||||||
|
WizardState::init_if_missing(&root);
|
||||||
|
|
||||||
|
let state = WizardState::load(&root).unwrap();
|
||||||
|
assert_eq!(state.steps[0].status, StepStatus::Confirmed);
|
||||||
|
assert_eq!(state.steps[0].step, WizardStep::Scaffold);
|
||||||
|
// Rest should be pending
|
||||||
|
for step in &state.steps[1..] {
|
||||||
|
assert_eq!(step.status, StepStatus::Pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_if_missing_does_not_overwrite_existing() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&dir);
|
||||||
|
|
||||||
|
// Create a custom state
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
state.steps[1].status = StepStatus::Confirmed;
|
||||||
|
state.save(&root).unwrap();
|
||||||
|
|
||||||
|
// init_if_missing should not overwrite
|
||||||
|
WizardState::init_if_missing(&root);
|
||||||
|
|
||||||
|
let loaded = WizardState::load(&root).unwrap();
|
||||||
|
assert_eq!(loaded.steps[1].status, StepStatus::Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_round_trip() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&dir);
|
||||||
|
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
state.steps[1].status = StepStatus::AwaitingConfirmation;
|
||||||
|
state.steps[1].content = Some("# My Project\n\nA cool project.".to_string());
|
||||||
|
state.save(&root).unwrap();
|
||||||
|
|
||||||
|
let loaded = WizardState::load(&root).unwrap();
|
||||||
|
assert_eq!(loaded.steps[0].status, StepStatus::Confirmed);
|
||||||
|
assert_eq!(loaded.steps[1].status, StepStatus::AwaitingConfirmation);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.steps[1].content.as_deref(),
|
||||||
|
Some("# My Project\n\nA cool project.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_step_index_correct() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
assert_eq!(state.current_step_index(), 1);
|
||||||
|
|
||||||
|
state.steps[1].status = StepStatus::Skipped;
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_step_enforces_order() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
|
||||||
|
// Can confirm the current step (Context, index 1)
|
||||||
|
assert!(state.confirm_step(WizardStep::Context).is_ok());
|
||||||
|
|
||||||
|
// Cannot confirm a step that's not current
|
||||||
|
assert!(state.confirm_step(WizardStep::TestScript).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skip_step_works() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.steps[0].status = StepStatus::Confirmed;
|
||||||
|
|
||||||
|
assert!(state.skip_step(WizardStep::Context).is_ok());
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::Skipped);
|
||||||
|
assert_eq!(state.current_step_index(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_when_all_confirmed_or_skipped() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
for step in WizardStep::ALL {
|
||||||
|
state.set_step_status(*step, StepStatus::Confirmed, None);
|
||||||
|
}
|
||||||
|
assert!(state.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_completed_when_some_pending() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.set_step_status(WizardStep::Scaffold, StepStatus::Confirmed, None);
|
||||||
|
assert!(!state.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_step_status_with_content() {
|
||||||
|
let mut state = WizardState::default();
|
||||||
|
state.set_step_status(
|
||||||
|
WizardStep::Context,
|
||||||
|
StepStatus::AwaitingConfirmation,
|
||||||
|
Some("generated content".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
|
||||||
|
assert_eq!(
|
||||||
|
state.steps[1].content.as_deref(),
|
||||||
|
Some("generated content")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_returns_none_when_no_file() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
assert!(WizardState::load(dir.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_labels_are_non_empty() {
|
||||||
|
for step in WizardStep::ALL {
|
||||||
|
assert!(!step.label().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_indices_are_sequential() {
|
||||||
|
for (i, step) in WizardStep::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(step.index(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-3
@@ -39,6 +39,8 @@ enum CliDirective {
|
|||||||
Help,
|
Help,
|
||||||
/// `--version` / `-V`
|
/// `--version` / `-V`
|
||||||
Version,
|
Version,
|
||||||
|
/// `init [PATH]` — scaffold and start the setup wizard.
|
||||||
|
Init,
|
||||||
/// An unrecognised flag (starts with `-`).
|
/// An unrecognised flag (starts with `-`).
|
||||||
UnknownFlag(String),
|
UnknownFlag(String),
|
||||||
/// A positional path argument.
|
/// A positional path argument.
|
||||||
@@ -53,6 +55,7 @@ fn classify_cli_args(args: &[String]) -> CliDirective {
|
|||||||
None => CliDirective::None,
|
None => CliDirective::None,
|
||||||
Some("--help" | "-h") => CliDirective::Help,
|
Some("--help" | "-h") => CliDirective::Help,
|
||||||
Some("--version" | "-V") => CliDirective::Version,
|
Some("--version" | "-V") => CliDirective::Version,
|
||||||
|
Some("init") => CliDirective::Init,
|
||||||
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
|
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
|
||||||
Some(_) => CliDirective::Path,
|
Some(_) => CliDirective::Path,
|
||||||
}
|
}
|
||||||
@@ -79,14 +82,22 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
let cli_args: Vec<String> = std::env::args().skip(1).collect();
|
let cli_args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
// Handle CLI flags before treating anything as a project path.
|
// Handle CLI flags before treating anything as a project path.
|
||||||
|
let is_init = matches!(classify_cli_args(&cli_args), CliDirective::Init);
|
||||||
match classify_cli_args(&cli_args) {
|
match classify_cli_args(&cli_args) {
|
||||||
CliDirective::Help => {
|
CliDirective::Help => {
|
||||||
println!("storkit [PATH]");
|
println!("storkit [PATH]");
|
||||||
|
println!("storkit init [PATH]");
|
||||||
println!();
|
println!();
|
||||||
println!("Serve a storkit project.");
|
println!("Serve a storkit project.");
|
||||||
println!();
|
println!();
|
||||||
println!("USAGE:");
|
println!("USAGE:");
|
||||||
println!(" storkit [PATH]");
|
println!(" storkit [PATH]");
|
||||||
|
println!(" storkit init [PATH]");
|
||||||
|
println!();
|
||||||
|
println!("COMMANDS:");
|
||||||
|
println!(
|
||||||
|
" init Scaffold a new .storkit/ project and start the interactive setup wizard."
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
println!("ARGS:");
|
println!("ARGS:");
|
||||||
println!(
|
println!(
|
||||||
@@ -108,10 +119,15 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
eprintln!("Run 'storkit --help' for usage.");
|
eprintln!("Run 'storkit --help' for usage.");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
CliDirective::Path | CliDirective::None => {}
|
CliDirective::Init | CliDirective::Path | CliDirective::None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let explicit_path = parse_project_path_arg(&cli_args, &cwd);
|
// For `storkit init [PATH]`, the path argument follows "init".
|
||||||
|
let explicit_path = if is_init {
|
||||||
|
parse_project_path_arg(&cli_args[1..], &cwd)
|
||||||
|
} else {
|
||||||
|
parse_project_path_arg(&cli_args, &cwd)
|
||||||
|
};
|
||||||
|
|
||||||
// When a path is given explicitly on the CLI, it must already exist as a
|
// When a path is given explicitly on the CLI, it must already exist as a
|
||||||
// directory. We do not create directories from the command line.
|
// directory. We do not create directories from the command line.
|
||||||
@@ -126,7 +142,37 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(explicit_root) = explicit_path {
|
if is_init {
|
||||||
|
// `storkit init [PATH]` — always scaffold, never search parents.
|
||||||
|
let init_root = explicit_path.unwrap_or_else(|| cwd.clone());
|
||||||
|
if !init_root.exists() {
|
||||||
|
std::fs::create_dir_all(&init_root).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error: cannot create directory {}: {e}", init_root.display());
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match io::fs::open_project(
|
||||||
|
init_root.to_string_lossy().to_string(),
|
||||||
|
&app_state,
|
||||||
|
store.as_ref(),
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
if let Some(root) = app_state.project_root.lock().unwrap().as_ref() {
|
||||||
|
config::ProjectConfig::load(root)
|
||||||
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
|
// Initialize wizard state for the setup flow.
|
||||||
|
io::wizard::WizardState::init_if_missing(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(explicit_root) = explicit_path {
|
||||||
// An explicit path was given on the command line.
|
// An explicit path was given on the command line.
|
||||||
// Open it directly — scaffold .storkit/ if it is missing — and
|
// Open it directly — scaffold .storkit/ if it is missing — and
|
||||||
// exit with a clear error message if the path is invalid.
|
// exit with a clear error message if the path is invalid.
|
||||||
|
|||||||
Reference in New Issue
Block a user