Files
IdeA/frontend/src/features/first-run/profile.ts
Blomios c181b43d04 feat(frontend): support des providers OpenCode cloud (#92)
Ajoute l'UI de configuration des providers OpenCode cloud dans le wizard
first-run (sélection, édition, sauvegarde, suppression), le domaine et
les ports associés, ainsi que les adapters HTTP/mock correspondants.

Build + 952 tests verts, comportements clés vérifiés en exécution réelle,
y compris un test de couverture ajouté pour le parcours d'édition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:18:02 +02:00

240 lines
8.0 KiB
TypeScript

/**
* Pure, framework-free helpers for the first-run wizard (testable without React
* or Tauri, ARCHITECTURE §1.3). Validation of a custom/edited profile and small
* factories live here; the components only render.
*/
import type {
AgentProfile,
ContextInjection,
HttpChatConfig,
InjectionStrategy,
OpenCodeConfig,
} from "@/domain";
/** A field-keyed validation error map (empty ⇒ valid). */
export type ProfileErrors = Partial<
Record<
| "name"
| "command"
| "target"
| "flag"
| "var"
| "endpoint"
| "baseURL"
| "model"
| "apiKeyEnv"
| "requestTimeoutMs"
| "connectTimeoutMs"
| "maxToolIterations",
string
>
>;
/** Whether a path is a relative, traversal-free file name (mirror of backend). */
export function isRelativeSafe(path: string): boolean {
if (path.length === 0) return false;
if (path.startsWith("/") || path.startsWith("\\")) return false;
// Windows drive (C:) / UNC.
if (/^[a-zA-Z]:/.test(path)) return false;
return !path.split(/[/\\]/).includes("..");
}
/** Whether a string is a valid environment-variable identifier. */
export function isValidEnvVar(v: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v);
}
/**
* Whether a string is a valid `http://`/`https://` endpoint (mirror of the
* backend `HttpChatConfig::new`: non-empty once trimmed, and the raw value
* starts with `http://` or `https://`).
*/
export function isValidHttpUrl(v: string): boolean {
if (v.trim().length === 0) return false;
return v.startsWith("http://") || v.startsWith("https://");
}
/**
* Whether a value is an acceptable positive-integer timeout/guard (mirror of the
* backend, which rejects `0` and only ever holds `u32`/`u16`). `undefined` means
* "left blank" and is valid (the backend applies its own default).
*/
function isValidPositiveInt(value: number | undefined, max: number): boolean {
if (value === undefined) return true;
return Number.isInteger(value) && value > 0 && value <= max;
}
const MAX_U32 = 0xffff_ffff;
const MAX_U16 = 0xffff;
/**
* Validates an {@link HttpChatConfig} draft the way the backend
* `HttpChatConfig::new` would, so the wizard can surface errors before any
* `invoke`. Returns an empty object when the draft is valid.
*/
export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors {
const errors: ProfileErrors = {};
if (!isValidHttpUrl(c.endpoint)) {
errors.endpoint = "Endpoint must start with http:// or https://.";
}
if (c.model.trim().length === 0) {
errors.model = "Model is required.";
}
// The API key field holds the NAME of an environment variable, never a key.
if (c.apiKeyEnv !== undefined && c.apiKeyEnv.length > 0 && !isValidEnvVar(c.apiKeyEnv)) {
errors.apiKeyEnv = "Must be a valid env var name (not the key itself).";
}
if (!isValidPositiveInt(c.requestTimeoutMs, MAX_U32)) {
errors.requestTimeoutMs = "Must be a positive integer (ms).";
}
if (!isValidPositiveInt(c.connectTimeoutMs, MAX_U32)) {
errors.connectTimeoutMs = "Must be a positive integer (ms).";
}
if (!isValidPositiveInt(c.maxToolIterations, MAX_U16)) {
errors.maxToolIterations = "Must be a positive integer.";
}
return errors;
}
/**
* Validates an {@link OpenCodeConfig} draft the way the backend
* `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty;
* the API key is a free-form, optional secret). Returns an empty object when the
* draft is valid.
*/
export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors {
const errors: ProfileErrors = {};
if (!isValidHttpUrl(c.baseURL)) {
errors.baseURL = "Base URL must start with http:// or https://.";
}
if (c.model.trim().length === 0) {
errors.model = "Model is required.";
}
return errors;
}
/**
* Validates a profile draft the way the backend would, so the wizard can surface
* errors before any `invoke`. Returns an empty object when the draft is valid.
*/
export function validateProfile(p: AgentProfile): ProfileErrors {
const errors: ProfileErrors = {};
if (p.name.trim().length === 0) errors.name = "Name is required.";
if (p.command.trim().length === 0) errors.command = "Command is required.";
const ci = p.contextInjection;
if (ci.strategy === "conventionFile") {
if (!isRelativeSafe(ci.target)) {
errors.target = "Target must be a relative file name (no .. or absolute).";
}
} else if (ci.strategy === "flag") {
if (ci.flag.trim().length === 0) errors.flag = "Flag is required.";
} else if (ci.strategy === "env") {
if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier.";
}
// An OpenAI-compatible profile additionally carries an HTTP chat config; the
// backend refuses to persist it unless it is well-formed, so mirror that here.
if (p.structuredAdapter === "openAiCompatible") {
if (!p.chatHttp) {
errors.endpoint = "Endpoint must start with http:// or https://.";
errors.model = "Model is required.";
} else {
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
}
}
// An OpenCode profile carries its own llama.cpp endpoint config (local mode)
// or a cloud provider config (`opencodeProvider`, ticket #92) — never both,
// and the cloud sub-form owns its own submit-time validation (provider,
// model, API key), so only the local-mode shape is mirrored here.
if (p.structuredAdapter === "openCode" && !p.opencodeProvider) {
if (!p.opencode) {
errors.baseURL = "Base URL must start with http:// or https://.";
errors.model = "Model is required.";
} else {
Object.assign(errors, validateOpenCodeConfig(p.opencode));
}
}
return errors;
}
/** Whether a profile draft is valid (no errors). */
export function isProfileValid(p: AgentProfile): boolean {
return Object.keys(validateProfile(p)).length === 0;
}
/**
* Builds a default {@link ContextInjection} for a strategy, so switching the
* strategy dropdown produces a sensible editable shape.
*/
export function defaultInjection(strategy: InjectionStrategy): ContextInjection {
switch (strategy) {
case "conventionFile":
return { strategy, target: "CONTEXT.md" };
case "flag":
return { strategy, flag: "--context-file {path}" };
case "env":
return { strategy, var: "AGENT_CONTEXT_FILE" };
case "stdin":
return { strategy };
}
}
/**
* A sensible default {@link HttpChatConfig} for a fresh OpenAI-compatible
* profile draft — pre-fills a local Ollama endpoint so the form starts valid.
* Mirrors the backend reference profile (`http://localhost:11434/v1`).
*/
export function defaultHttpChatConfig(): HttpChatConfig {
return {
endpoint: "http://localhost:11434/v1",
model: "qwen2.5-coder",
requestTimeoutMs: 120_000,
connectTimeoutMs: 5_000,
maxToolIterations: 16,
};
}
/**
* A sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft —
* pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the
* backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`).
*/
export function defaultOpenCodeConfig(): OpenCodeConfig {
return {
baseURL: "http://localhost:8080/v1",
apiKey: "sk-no-key",
model: "qwen3-coder-30b",
};
}
/** A fresh, empty custom-profile draft (id minted client-side). */
export function emptyCustomProfile(): AgentProfile {
return {
id: newProfileId(),
name: "",
command: "",
args: [],
contextInjection: { strategy: "conventionFile", target: "CONTEXT.md" },
detect: null,
cwdTemplate: "{projectRoot}",
};
}
/** Generates a UUID for a client-created profile (crypto when available). */
export function newProfileId(): string {
const c = globalThis.crypto as Crypto | undefined;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
// Fallback for non-secure contexts/tests.
return `profile-${Math.random().toString(36).slice(2, 10)}`;
}
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
export function parseArgs(raw: string): string[] {
return raw
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}