smart_ide/services/ia_dev/tools/site-generate.sh
Nicolas Cantu 58cc2493e5 chore: consolidate ia_dev module, sync tooling, and harden gateways (0.0.5)
Initial state:
- ia_dev was historically referenced as ./ia_dev in docs and integrations, while the vendored module lives under services/ia_dev.
- AnythingLLM sync and hook installation had error masking / weak exit signaling.
- Proxy layers did not validate proxy path segments, allowing path normalization tricks.

Motivation:
- Make the IDE-oriented workflow usable (sync -> act -> deploy/preview) with explicit errors.
- Reduce security footguns in proxying and script automation.

Resolution:
- Standardize IA_DEV_ROOT usage and documentation to services/ia_dev.
- Add SSH remote data mirroring + optional AnythingLLM ingestion.
- Extend AnythingLLM pull sync to support upload-all/prefix and fail on upload errors.
- Harden smart-ide-sso-gateway and smart-ide-global-api proxying with safe-path checks and non-leaking error responses.
- Improve ia-dev-gateway runner validation and reduce sensitive path leakage.
- Add site scaffold tool (Vite/React) with OIDC + chat via sso-gateway -> orchestrator.

Root cause:
- Historical layout changes (submodule -> vendored tree) and missing central contracts for path resolution.
- Missing validation for proxy path traversal patterns.
- Overuse of silent fallbacks (|| true, exit 0 on partial failures) in automation scripts.

Impacted features:
- Project sync: git pull + AnythingLLM sync + remote data mirror ingestion.
- Site frontends: SSO gateway proxy and orchestrator intents (rag.query, chat.local).
- Agent execution: ia-dev-gateway script runner and SSE output.

Code modified:
- scripts/remote-data-ssh-sync.sh
- scripts/anythingllm-pull-sync/sync.mjs
- scripts/install-anythingllm-post-merge-hook.sh
- cron/git-pull-project-clones.sh
- services/smart-ide-sso-gateway/src/server.ts
- services/smart-ide-global-api/src/server.ts
- services/smart-ide-orchestrator/src/server.ts
- services/ia-dev-gateway/src/server.ts
- services/ia_dev/tools/site-generate.sh

Documentation modified:
- docs/** (architecture, API docs, ia_dev module + integration, scripts)

Configurations modified:
- config/services.local.env.example
- services/*/.env.example

Files in deploy modified:
- services/ia_dev/deploy/*

Files in logs impacted:
- logs/ia_dev.log (runtime only)
- .logs/* (runtime only)

Databases and other sources modified:
- None

Off-project modifications:
- None

Files in .smartIde modified:
- .smartIde/agents/*.md
- services/ia_dev/.smartIde/**

Files in .secrets modified:
- None

New patch version in VERSION:
- 0.0.5

CHANGELOG.md updated:
- yes
2026-04-04 18:36:43 +02:00

700 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# Scaffold a "lovable-style" front-end website:
# - Vite + React + TypeScript
# - OIDC login (PKCE) with oidc-client-ts (sessionStorage)
# - Chat UI that calls smart-ide-sso-gateway -> proxy/orchestrator -> intents (rag.query, chat.local)
#
# This script creates files and installs npm dependencies. It does not deploy.
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./tools/site-generate.sh --dir <target_dir> [--name <app_name>] [--skip-install]
Examples:
./tools/site-generate.sh --dir ../sites/my-site --name my-site
Notes:
- Requires: node (>=20), npm, git (optional).
- Output is intentionally verbose (no filtering).
EOF
}
DIR=""
NAME=""
SKIP_INSTALL="false"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--dir)
DIR="${2:-}"
shift 2
;;
--name)
NAME="${2:-}"
shift 2
;;
--skip-install)
SKIP_INSTALL="true"
shift 1
;;
*)
echo "[site-generate][ERROR] Unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
command -v node >/dev/null 2>&1 || { echo "[site-generate][ERROR] Missing dependency: node" >&2; exit 1; }
command -v npm >/dev/null 2>&1 || { echo "[site-generate][ERROR] Missing dependency: npm" >&2; exit 1; }
if [[ -z "${DIR}" ]]; then
echo "[site-generate][ERROR] Missing --dir <target_dir>" >&2
usage >&2
exit 2
fi
TARGET="${DIR}"
mkdir -p "$(dirname "${TARGET}")"
if [[ -e "${TARGET}" && ! -d "${TARGET}" ]]; then
echo "[site-generate][ERROR] Target exists and is not a directory: ${TARGET}" >&2
exit 1
fi
mkdir -p "${TARGET}"
if [[ ! -r "${TARGET}" || ! -x "${TARGET}" ]]; then
echo "[site-generate][ERROR] Target directory is not accessible: ${TARGET}" >&2
exit 1
fi
shopt -s nullglob dotglob
entries=( "${TARGET}"/* )
shopt -u nullglob dotglob
if (( ${#entries[@]} > 0 )); then
echo "[site-generate][ERROR] Target directory is not empty: ${TARGET}" >&2
exit 1
fi
if [[ -z "${NAME}" ]]; then
NAME="$(basename "${TARGET}")"
fi
echo "[site-generate] target=${TARGET}"
echo "[site-generate] name=${NAME}"
(
cd "${TARGET}"
npm init -y
if [[ "${SKIP_INSTALL}" != "true" ]]; then
# Runtime
npm install react react-dom oidc-client-ts
# Tooling
npm install -D vite typescript @types/react @types/react-dom @vitejs/plugin-react
else
echo "[site-generate] --skip-install: dependencies will not be installed."
fi
cat > vite.config.ts <<'EOF'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5173, strictPort: true },
});
EOF
cat > tsconfig.json <<'EOF'
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": ["src"]
}
EOF
cat > index.html <<'EOF'
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Smart IDE — Site</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
EOF
mkdir -p src/auth src/api src/components
cat > src/i18n.ts <<'EOF'
type Dict = Record<string, string>;
const fr: Dict = {
"app.title": "Smart IDE — Site",
"auth.signIn": "Se connecter",
"auth.signOut": "Se déconnecter",
"auth.status.signedOut": "Non connecté",
"auth.status.signedInAs": "Connecté en tant que {email}",
"auth.error.title": "Connexion",
"auth.error.message": "Un problème empêche la connexion. Réessayez ou contactez le support.",
"auth.error.details": "Détails techniques",
"chat.title": "Chat",
"chat.mode.rag": "Contexte du projet",
"chat.mode.local": "Assistant local",
"chat.mode.aria": "Mode de chat",
"chat.role.you": "Vous",
"chat.role.assistant": "Assistant",
"chat.input.placeholder": "Écrivez votre message…",
"chat.send": "Envoyer",
"chat.error.unauthorized": "Session expirée. Reconnectez-vous.",
"chat.error.network": "Le service est indisponible. Réessayez plus tard.",
};
export type I18nKey = keyof typeof fr;
export const t = (key: I18nKey, vars?: Record<string, string>): string => {
const raw = fr[key] ?? String(key);
if (!vars) return raw;
return raw.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? "");
};
EOF
cat > src/auth/oidc.ts <<'EOF'
import { UserManager, WebStorageStateStore, type User } from "oidc-client-ts";
const requiredEnv = (k: string): string => {
const v = (import.meta as any).env?.[k] as string | undefined;
if (!v || v.trim().length === 0) {
throw new Error(`Missing ${k}`);
}
return v.trim();
};
export const userManager = new UserManager({
authority: requiredEnv("VITE_OIDC_ISSUER"),
client_id: requiredEnv("VITE_OIDC_CLIENT_ID"),
redirect_uri: requiredEnv("VITE_OIDC_REDIRECT_URI"),
post_logout_redirect_uri: requiredEnv("VITE_OIDC_POST_LOGOUT_REDIRECT_URI"),
response_type: "code",
scope: "openid profile email",
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
export const signin = async (): Promise<void> => {
await userManager.signinRedirect();
};
export const signout = async (): Promise<void> => {
await userManager.signoutRedirect();
};
export const completeSignin = async (): Promise<User> => {
return await userManager.signinRedirectCallback();
};
export const getUser = async (): Promise<User | null> => {
return await userManager.getUser();
};
EOF
cat > src/api/orchestrator.ts <<'EOF'
export type OrchestratorExecuteBody = {
intent: string;
projectId?: string;
context?: Record<string, unknown>;
};
const requiredEnv = (k: string): string => {
const v = (import.meta as any).env?.[k] as string | undefined;
if (!v || v.trim().length === 0) {
throw new Error(`Missing ${k}`);
}
return v.trim().replace(/\/+$/, "");
};
const ssoBase = (): string => requiredEnv("VITE_SSO_GATEWAY_BASE_URL");
export const orchestratorExecute = async (accessToken: string, body: OrchestratorExecuteBody): Promise<unknown> => {
const url = `${ssoBase()}/proxy/orchestrator/v1/execute`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: unknown = undefined;
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
(err as any).response = parsed;
throw err;
}
return parsed;
};
EOF
cat > src/components/App.tsx <<'EOF'
import { useEffect, useMemo, useState } from "react";
import type { User } from "oidc-client-ts";
import { completeSignin, getUser, signin, signout, userManager } from "../auth/oidc";
import { t } from "../i18n";
import { ChatPanel } from "./ChatPanel";
import "./styles.css";
export const App = () => {
const [user, setUser] = useState<User | null>(null);
const [authError, setAuthError] = useState<string>("");
const email = useMemo(() => (user?.profile?.email as string | undefined) ?? "", [user]);
useEffect(() => {
const run = async () => {
try {
if (window.location.pathname.startsWith("/auth/callback")) {
await completeSignin();
window.history.replaceState({}, "", "/");
}
const u = await getUser();
setUser(u);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setAuthError(msg);
}
};
void run();
const onUserLoaded = (u: User) => setUser(u);
const onUserUnloaded = () => setUser(null);
userManager.events.addUserLoaded(onUserLoaded);
userManager.events.addUserUnloaded(onUserUnloaded);
return () => {
userManager.events.removeUserLoaded(onUserLoaded);
userManager.events.removeUserUnloaded(onUserUnloaded);
};
}, []);
return (
<div className="page">
<header className="header" role="banner">
<div className="title">{t("app.title")}</div>
<div className="auth">
{user ? (
<>
<div className="authStatus" aria-live="polite">
{t("auth.status.signedInAs", { email: email || user.profile.sub })}
</div>
<button className="button" type="button" onClick={() => void signout()}>
{t("auth.signOut")}
</button>
</>
) : (
<>
<div className="authStatus" aria-live="polite">
{t("auth.status.signedOut")}
</div>
<button className="button" type="button" onClick={() => void signin()}>
{t("auth.signIn")}
</button>
</>
)}
</div>
</header>
{authError ? (
<main className="main" role="main">
<div className="panel" role="alert">
<div className="panelTitle">{t("auth.error.title")}</div>
<div className="muted">{t("auth.error.message")}</div>
{import.meta.env.DEV ? (
<details className="details">
<summary>{t("auth.error.details")}</summary>
<pre className="mono">{authError}</pre>
</details>
) : null}
</div>
</main>
) : (
<main className="main" role="main">
<ChatPanel user={user} />
</main>
)}
</div>
);
};
EOF
cat > src/components/ChatPanel.tsx <<'EOF'
import { useMemo, useState } from "react";
import type { User } from "oidc-client-ts";
import { orchestratorExecute } from "../api/orchestrator";
import { t } from "../i18n";
type ChatMode = "rag" | "local";
const requiredEnv = (k: string): string => {
const v = (import.meta as any).env?.[k] as string | undefined;
if (!v || v.trim().length === 0) {
throw new Error(`Missing ${k}`);
}
return v.trim();
};
export const ChatPanel = ({ user }: { user: User | null }) => {
const [mode, setMode] = useState<ChatMode>("rag");
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [messages, setMessages] = useState<{ role: "user" | "assistant"; content: string }[]>([]);
const accessToken = useMemo(() => user?.access_token ?? "", [user]);
const send = async () => {
if (!accessToken) {
setError(t("chat.error.unauthorized"));
return;
}
const content = input.trim();
if (!content) return;
setInput("");
setError("");
setMessages((m) => [...m, { role: "user", content }]);
setBusy(true);
try {
if (mode === "local") {
const model = requiredEnv("VITE_OLLAMA_MODEL");
const r = await orchestratorExecute(accessToken, {
intent: "chat.local",
context: { model, prompt: content, stream: false },
});
const text = (r as any)?.response?.response ?? (r as any)?.response?.message ?? JSON.stringify(r);
setMessages((m) => [...m, { role: "assistant", content: String(text) }]);
} else {
const workspace = requiredEnv("VITE_ANYTHINGLLM_WORKSPACE_SLUG");
const r = await orchestratorExecute(accessToken, {
intent: "rag.query",
context: {
model: workspace,
messages: [
{
role: "system",
content:
"Vous êtes un assistant utile. Répondez en utilisant le contexte du projet quand c'est pertinent.",
},
...messages.map((m) => ({ role: m.role, content: m.content })),
{ role: "user", content },
],
},
});
const text =
(r as any)?.response?.choices?.[0]?.message?.content ??
(r as any)?.response?.choices?.[0]?.text ??
JSON.stringify(r);
setMessages((m) => [...m, { role: "assistant", content: String(text) }]);
}
} catch (e) {
const anyE = e as any;
if (anyE?.message === "HTTP 401") {
setError(t("chat.error.unauthorized"));
} else {
setError(t("chat.error.network"));
}
} finally {
setBusy(false);
}
};
return (
<section className="panel" aria-label={t("chat.title")}>
<div className="panelHeader">
<div className="panelTitle">{t("chat.title")}</div>
<div className="tabs" role="tablist" aria-label={t("chat.mode.aria")}>
<button
className={mode === "rag" ? "tab tabActive" : "tab"}
type="button"
role="tab"
aria-selected={mode === "rag"}
onClick={() => setMode("rag")}
>
{t("chat.mode.rag")}
</button>
<button
className={mode === "local" ? "tab tabActive" : "tab"}
type="button"
role="tab"
aria-selected={mode === "local"}
onClick={() => setMode("local")}
>
{t("chat.mode.local")}
</button>
</div>
</div>
<div className="chatLog" role="log" aria-live="polite">
{messages.map((m, idx) => (
<div key={idx} className={m.role === "user" ? "msg msgUser" : "msg msgAssistant"}>
<div className="msgRole">{m.role === "user" ? t("chat.role.you") : t("chat.role.assistant")}</div>
<div className="msgText">{m.content}</div>
</div>
))}
</div>
{error ? (
<div className="error" role="alert">
{error}
</div>
) : null}
<div className="chatComposer">
<label className="srOnly" htmlFor="chatInput">
{t("chat.input.placeholder")}
</label>
<input
id="chatInput"
className="input"
type="text"
value={input}
placeholder={t("chat.input.placeholder")}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) void send();
}
}}
disabled={busy}
/>
<button className="button" type="button" onClick={() => void send()} disabled={busy}>
{t("chat.send")}
</button>
</div>
</section>
);
};
EOF
cat > src/components/styles.css <<'EOF'
:root {
--bg: #0b0f17;
--panel: #101826;
--text: #e6eaf2;
--muted: #aab4c5;
--border: #1b2940;
--accent: #60a5fa;
--danger: #f87171;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: radial-gradient(1200px 900px at 20% 10%, #13213b, var(--bg));
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.page { min-height: 100%; display: flex; flex-direction: column; }
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
background: rgba(16, 24, 38, 0.7);
backdrop-filter: blur(8px);
}
.title { font-weight: 700; letter-spacing: 0.2px; }
.auth { display: flex; align-items: center; gap: 10px; }
.authStatus { color: var(--muted); font-size: 13px; }
.main { padding: 18px; display: flex; justify-content: center; }
.panel {
width: min(980px, 100%);
border: 1px solid var(--border);
background: rgba(16, 24, 38, 0.9);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 18px 60px rgba(0,0,0,0.35);
}
.panelHeader { padding: 14px 14px 10px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.panelTitle { font-weight: 700; }
.panel[role="alert"] { padding: 14px; }
.muted { color: var(--muted); margin-top: 8px; }
.details { margin-top: 10px; }
.details summary { cursor: pointer; color: var(--muted); }
.details summary:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; border-radius: 6px; }
.tabs { display: inline-flex; gap: 6px; }
.tab {
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
padding: 6px 10px;
border-radius: 10px;
cursor: pointer;
}
.tabActive { border-color: rgba(96, 165, 250, 0.35); color: var(--text); }
.chatLog { padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 60vh; overflow: auto; }
.msg { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; }
.msgUser { background: rgba(96, 165, 250, 0.07); }
.msgAssistant { background: rgba(170, 180, 197, 0.06); }
.msgRole { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.msgText { white-space: pre-wrap; line-height: 1.4; }
.error { padding: 10px 14px; border-top: 1px solid var(--border); color: var(--danger); }
.chatComposer {
display: flex;
gap: 10px;
padding: 14px;
border-top: 1px solid var(--border);
}
.input {
flex: 1;
border: 1px solid var(--border);
background: rgba(11, 15, 23, 0.6);
color: var(--text);
border-radius: 12px;
padding: 10px 12px;
outline: none;
}
.input:focus { border-color: rgba(96, 165, 250, 0.6); }
.input:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; }
.button {
border: 1px solid rgba(96, 165, 250, 0.45);
background: rgba(96, 165, 250, 0.18);
color: var(--text);
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
}
.button:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; }
.tab:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; }
.button:disabled { opacity: 0.6; cursor: not-allowed; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
EOF
cat > src/main.tsx <<'EOF'
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./components/App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
EOF
cat > .env.example <<'EOF'
# Public app config (Vite)
VITE_SSO_GATEWAY_BASE_URL=https://smart-ide-sso.example.test
# OIDC PKCE client config
VITE_OIDC_ISSUER=https://docv.example.test
VITE_OIDC_CLIENT_ID=smart-ide-site
VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
# Chat modes
VITE_ANYTHINGLLM_WORKSPACE_SLUG=enso-test
VITE_OLLAMA_MODEL=llama3.1
EOF
# Update package.json
APP_NAME="${NAME}" node - <<'EOF'
const fs = require("fs");
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
p.name = process.env.APP_NAME || p.name;
p.private = true;
p.type = "module";
p.scripts = {
dev: "vite",
build: "tsc -p tsconfig.json && vite build",
preview: "vite preview --strictPort --port 4173",
};
fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n", "utf8");
EOF
cat > README.md <<'EOF'
## Smart IDE — Lovable-style site scaffold
This scaffold is a front-end web app that:
- logs users in via **OIDC (PKCE)**
- calls `smart-ide-sso-gateway` which proxies to the internal services (including the orchestrator)
- exposes two chat modes:
- **RAG** (`rag.query`) via AnythingLLM OpenAI-compatible endpoint
- **Local chat** (`chat.local`) via Ollama
### Setup
1. Copy `.env.example` to `.env.local` and set:
- `VITE_SSO_GATEWAY_BASE_URL`
- OIDC settings (`VITE_OIDC_*`)
- chat settings (`VITE_ANYTHINGLLM_WORKSPACE_SLUG`, `VITE_OLLAMA_MODEL`)
2. Run:
```bash
npm install
npm run dev
```
### Runtime notes
- Tokens are stored in **sessionStorage** (not localStorage).
- The app does not embed service tokens; it only uses the user OIDC access token.
EOF
echo "[site-generate] Files created."
echo "[site-generate] Next steps:"
echo " - cd ${TARGET}"
echo " - cp .env.example .env.local"
echo " - npm run dev"
)