kcg-code 0.0.0-stage → 0.2.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/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode_Server — lifecycle proses `opencode serve` per Project.
|
|
3
|
+
*
|
|
4
|
+
* Satu instance `opencode serve` hanya dapat membuat Session di direktori
|
|
5
|
+
* kerja (cwd) tempat ia dijalankan (terverifikasi: `POST /session` tidak
|
|
6
|
+
* menerima field `directory`). Karena Project KCG Code bisa berbeda-beda
|
|
7
|
+
* direktori, setiap Project mendapat satu server sendiri yang di-spawn di
|
|
8
|
+
* `project.path` — menggantikan model "satu PTY per Session".
|
|
9
|
+
*
|
|
10
|
+
* - `--port 0` meminta port acak; port ditemukan dari baris log
|
|
11
|
+
* "opencode server listening on http://127.0.0.1:<port>" di stderr.
|
|
12
|
+
* - stdout/stderr terus di-drain (buffer log bergulir) agar pipe tidak penuh
|
|
13
|
+
* dan memblokir proses server yang berumur panjang.
|
|
14
|
+
* - `ensureServer` idempoten dan aman terhadap konkurensi (in-flight promise).
|
|
15
|
+
* - Exit callback hanya dipanggil bila instance yang keluar masih instance
|
|
16
|
+
* aktif — instance pengganti tidak terpengaruh.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, statSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import type { Result } from "../result";
|
|
23
|
+
import type { OpenCodeClient, OpenCodeEvent } from "./opencode-client";
|
|
24
|
+
import { createOpenCodeClient } from "./opencode-client";
|
|
25
|
+
|
|
26
|
+
export interface OpenCodeServerHandle {
|
|
27
|
+
projectId: string;
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
client: OpenCodeClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Kontrak proses minimal (di-mock pada test). */
|
|
33
|
+
export interface ServerProcessLike {
|
|
34
|
+
kill(signal: "SIGTERM" | "SIGKILL"): void;
|
|
35
|
+
exited: Promise<number | null>;
|
|
36
|
+
/** Stream stderr (untuk menemukan port). */
|
|
37
|
+
stderr: ReadableStream<Uint8Array>;
|
|
38
|
+
/** Stream stdout (di-drain agar tidak penuh). */
|
|
39
|
+
stdout?: ReadableStream<Uint8Array>;
|
|
40
|
+
/** PID proses (untuk SIGKILL fallback). */
|
|
41
|
+
pid?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OpenCodeServerManagerOptions {
|
|
45
|
+
/** Spawn `opencode serve` (injectable untuk test). */
|
|
46
|
+
spawn?: (cwd: string) => ServerProcessLike;
|
|
47
|
+
/** Factory client (injectable untuk test). */
|
|
48
|
+
clientFactory?: (baseUrl: string) => OpenCodeClient;
|
|
49
|
+
/** Batas waktu menunggu port tercetak di stderr (ms). */
|
|
50
|
+
portTimeoutMs?: number;
|
|
51
|
+
/** Batas waktu menunggu health (ms). */
|
|
52
|
+
readyTimeoutMs?: number;
|
|
53
|
+
/** Hook event SSE dari tiap server (diteruskan ke Session_Manager). */
|
|
54
|
+
onEvent?: (projectId: string, ev: OpenCodeEvent) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface OpenCodeServerManager {
|
|
58
|
+
ensureServer(projectId: string, projectPath: string): Promise<Result<OpenCodeServerHandle>>;
|
|
59
|
+
getServer(projectId: string): OpenCodeServerHandle | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Pastikan server hidup; bila file config opencode berubah sejak spawn
|
|
62
|
+
* terakhir, server di-restart agar `/config/providers` (daftar model)
|
|
63
|
+
* dan `/agent` ikut terbaca ulang — `opencode serve` tidak hot-reload.
|
|
64
|
+
*/
|
|
65
|
+
ensureFreshServer(projectId: string, projectPath: string): Promise<Result<OpenCodeServerHandle>>;
|
|
66
|
+
stopServer(projectId: string): Promise<void>;
|
|
67
|
+
stopAll(): Promise<void>;
|
|
68
|
+
/** Daftarkan hook saat server keluar tak terduga. */
|
|
69
|
+
onServerExit(cb: (projectId: string) => void): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ServerInstance {
|
|
73
|
+
handle: OpenCodeServerHandle;
|
|
74
|
+
proc: ServerProcessLike;
|
|
75
|
+
exiting: boolean;
|
|
76
|
+
/** Fingerprint config opencode saat server di-spawn (deteksi perubahan). */
|
|
77
|
+
configFingerprint: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Path file config opencode yang memengaruhi provider/model/agent:
|
|
82
|
+
* project (`opencode.json`/`opencode.jsonc`) + global user config.
|
|
83
|
+
*/
|
|
84
|
+
export function opencodeConfigPaths(projectPath: string): string[] {
|
|
85
|
+
const home = homedir();
|
|
86
|
+
return [
|
|
87
|
+
join(projectPath, "opencode.json"),
|
|
88
|
+
join(projectPath, "opencode.jsonc"),
|
|
89
|
+
join(home, ".config", "opencode", "opencode.json"),
|
|
90
|
+
join(home, ".config", "opencode", "opencode.jsonc"),
|
|
91
|
+
join(home, ".opencode", "opencode.json"),
|
|
92
|
+
join(home, ".opencode", "opencode.jsonc"),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fingerprint config: path:mtimeMs untuk tiap file yang ada. Bila nilainya
|
|
98
|
+
* berbeda dari saat spawn, config dianggap berubah dan server perlu restart.
|
|
99
|
+
*/
|
|
100
|
+
export function configFingerprint(projectPath: string): string {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
for (const p of opencodeConfigPaths(projectPath)) {
|
|
103
|
+
try {
|
|
104
|
+
if (existsSync(p)) parts.push(`${p}:${statSync(p).mtimeMs}`);
|
|
105
|
+
} catch {
|
|
106
|
+
/* file hilang / tidak readable — lewati */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return parts.join("|");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DEFAULT_SPAWN: (cwd: string) => ServerProcessLike = (cwd) => {
|
|
113
|
+
const proc = Bun.spawn(["opencode", "serve", "--port", "0", "--hostname", "127.0.0.1"], {
|
|
114
|
+
cwd,
|
|
115
|
+
stdout: "pipe",
|
|
116
|
+
stderr: "pipe",
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
kill: (signal) => proc.kill(signal as NodeJS.Signals),
|
|
120
|
+
exited: proc.exited.then((code) => code),
|
|
121
|
+
stderr: proc.stderr,
|
|
122
|
+
stdout: proc.stdout,
|
|
123
|
+
pid: proc.pid,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Ekstrak port dari baris log stderr "listening on http://host:port". */
|
|
128
|
+
export function parseListeningPort(line: string): number | null {
|
|
129
|
+
const m = line.match(/listening on http:\/\/[^:]+:(\d+)/);
|
|
130
|
+
return m ? Number(m[1]) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Poll `GET /global/health` hingga siap atau timeout. */
|
|
134
|
+
export async function waitHealth(client: OpenCodeClient, timeoutMs: number): Promise<boolean> {
|
|
135
|
+
const deadline = Date.now() + timeoutMs;
|
|
136
|
+
while (Date.now() < deadline) {
|
|
137
|
+
if (await client.health()) return true;
|
|
138
|
+
await Bun.sleep(300);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createOpenCodeServerManager(
|
|
144
|
+
opts: OpenCodeServerManagerOptions = {},
|
|
145
|
+
): OpenCodeServerManager {
|
|
146
|
+
const spawn = opts.spawn ?? DEFAULT_SPAWN;
|
|
147
|
+
const clientFactory = opts.clientFactory ?? createOpenCodeClient;
|
|
148
|
+
const portTimeoutMs = opts.portTimeoutMs ?? 30000;
|
|
149
|
+
const readyTimeoutMs = opts.readyTimeoutMs ?? 60000;
|
|
150
|
+
const onEvent = opts.onEvent;
|
|
151
|
+
const instances = new Map<string, ServerInstance>();
|
|
152
|
+
/** In-flight ensure agar dua panggilan paralel tidak men-spawn dua server. */
|
|
153
|
+
const pending = new Map<string, Promise<Result<OpenCodeServerHandle>>>();
|
|
154
|
+
const exitCbs = new Set<(projectId: string) => void>();
|
|
155
|
+
|
|
156
|
+
function ensureServer(
|
|
157
|
+
projectId: string,
|
|
158
|
+
projectPath: string,
|
|
159
|
+
): Promise<Result<OpenCodeServerHandle>> {
|
|
160
|
+
const existing = instances.get(projectId);
|
|
161
|
+
if (existing && !existing.exiting) {
|
|
162
|
+
return Promise.resolve({ ok: true, data: existing.handle });
|
|
163
|
+
}
|
|
164
|
+
const inflight = pending.get(projectId);
|
|
165
|
+
if (inflight) return inflight;
|
|
166
|
+
const p = startServer(projectId, projectPath).finally(() => pending.delete(projectId));
|
|
167
|
+
pending.set(projectId, p);
|
|
168
|
+
return p;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Seperti `ensureServer`, tapi restart dulu bila fingerprint config
|
|
173
|
+
* berubah. Restart adalah stop + ensure (in-flight aman terhadap
|
|
174
|
+
* dua panggilan paralel lewat map `pending`).
|
|
175
|
+
*/
|
|
176
|
+
async function ensureFreshServer(
|
|
177
|
+
projectId: string,
|
|
178
|
+
projectPath: string,
|
|
179
|
+
): Promise<Result<OpenCodeServerHandle>> {
|
|
180
|
+
const existing = instances.get(projectId);
|
|
181
|
+
if (existing && !existing.exiting) {
|
|
182
|
+
if (existing.configFingerprint === configFingerprint(projectPath)) {
|
|
183
|
+
return { ok: true, data: existing.handle };
|
|
184
|
+
}
|
|
185
|
+
await stopServer(projectId);
|
|
186
|
+
}
|
|
187
|
+
return ensureServer(projectId, projectPath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function startServer(
|
|
191
|
+
projectId: string,
|
|
192
|
+
projectPath: string,
|
|
193
|
+
): Promise<Result<OpenCodeServerHandle>> {
|
|
194
|
+
let proc: ServerProcessLike;
|
|
195
|
+
try {
|
|
196
|
+
proc = spawn(projectPath);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return { ok: false, error: `SERVER_START_FAILED: ${(e as Error).message}` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- Drain stdout/stderr berkelanjutan + deteksi port dari stderr ----
|
|
202
|
+
let serverLog = "";
|
|
203
|
+
let port: number | null = null;
|
|
204
|
+
let portResolved = false;
|
|
205
|
+
let resolvePort: (p: number | null) => void = () => {};
|
|
206
|
+
const portPromise = new Promise<number | null>((res) => {
|
|
207
|
+
resolvePort = res;
|
|
208
|
+
});
|
|
209
|
+
const resolveOnce = (p: number | null): void => {
|
|
210
|
+
if (!portResolved) {
|
|
211
|
+
portResolved = true;
|
|
212
|
+
resolvePort(p);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const drain = (stream: ReadableStream<Uint8Array>): void => {
|
|
217
|
+
const decoder = new TextDecoder();
|
|
218
|
+
void (async () => {
|
|
219
|
+
const reader = stream.getReader();
|
|
220
|
+
for (;;) {
|
|
221
|
+
const { done, value } = await reader.read();
|
|
222
|
+
if (done) {
|
|
223
|
+
resolveOnce(port);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const text = decoder.decode(value, { stream: true });
|
|
227
|
+
serverLog = (serverLog + text).slice(-16000);
|
|
228
|
+
if (port === null) {
|
|
229
|
+
const found = parseListeningPort(text);
|
|
230
|
+
if (found !== null) {
|
|
231
|
+
port = found;
|
|
232
|
+
resolveOnce(found);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
};
|
|
238
|
+
drain(proc.stderr);
|
|
239
|
+
if (proc.stdout) drain(proc.stdout);
|
|
240
|
+
|
|
241
|
+
const found = await Promise.race([
|
|
242
|
+
portPromise,
|
|
243
|
+
Bun.sleep(portTimeoutMs).then(() => {
|
|
244
|
+
resolveOnce(port);
|
|
245
|
+
return port;
|
|
246
|
+
}),
|
|
247
|
+
]);
|
|
248
|
+
if (found === null) {
|
|
249
|
+
proc.kill("SIGKILL");
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: `SERVER_START_FAILED: port tidak ditemukan. Log:\n${serverLog.slice(-800)}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const baseUrl = `http://127.0.0.1:${found}`;
|
|
257
|
+
const client = clientFactory(baseUrl);
|
|
258
|
+
if (!(await waitHealth(client, readyTimeoutMs))) {
|
|
259
|
+
proc.kill("SIGKILL");
|
|
260
|
+
return { ok: false, error: "SERVER_START_FAILED: health check gagal" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const handle: OpenCodeServerHandle = { projectId, baseUrl, client };
|
|
264
|
+
const instance: ServerInstance = {
|
|
265
|
+
handle,
|
|
266
|
+
proc,
|
|
267
|
+
exiting: false,
|
|
268
|
+
configFingerprint: configFingerprint(projectPath),
|
|
269
|
+
};
|
|
270
|
+
instances.set(projectId, instance);
|
|
271
|
+
|
|
272
|
+
if (onEvent) {
|
|
273
|
+
client.subscribeEvents((ev) => onEvent(projectId, ev));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Server mati tak terduga: hanya bila instance ini masih yang aktif.
|
|
277
|
+
void proc.exited.then(() => {
|
|
278
|
+
if (instance.exiting) return;
|
|
279
|
+
if (instances.get(projectId) !== instance) return; // sudah diganti — abaikan
|
|
280
|
+
instances.delete(projectId);
|
|
281
|
+
for (const cb of exitCbs) cb(projectId);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return { ok: true, data: handle };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function stopServer(projectId: string): Promise<void> {
|
|
288
|
+
const instance = instances.get(projectId);
|
|
289
|
+
if (!instance) return;
|
|
290
|
+
instance.exiting = true;
|
|
291
|
+
instances.delete(projectId);
|
|
292
|
+
instance.proc.kill("SIGTERM");
|
|
293
|
+
await Promise.race([instance.proc.exited, Bun.sleep(3000)]);
|
|
294
|
+
if (instance.proc.pid !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
Bun.spawnSync(["kill", "-9", String(instance.proc.pid)]);
|
|
297
|
+
} catch {
|
|
298
|
+
/* best effort */
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
instance.proc.kill("SIGKILL");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function stopAll(): Promise<void> {
|
|
306
|
+
await Promise.all([...instances.keys()].map((id) => stopServer(id)));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
ensureServer,
|
|
311
|
+
ensureFreshServer,
|
|
312
|
+
getServer: (projectId) => instances.get(projectId)?.handle,
|
|
313
|
+
stopServer,
|
|
314
|
+
stopAll,
|
|
315
|
+
onServerExit: (cb) => {
|
|
316
|
+
exitCbs.add(cb);
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event SSE server headless opencode — parsing & langganan.
|
|
3
|
+
*
|
|
4
|
+
* Dipisah dari `opencode-client.ts`:
|
|
5
|
+
* - `parseSseFrame` mengambil baris `data:` dari satu frame SSE.
|
|
6
|
+
* - `normalizeEvent` meratakan payload `{ id, type, properties }` menjadi
|
|
7
|
+
* `{ type, id, ...properties }` agar konsumen mudah membaca `requestID`,
|
|
8
|
+
* `sessionID`, `permission`, `questions`, dst.
|
|
9
|
+
* - `subscribeOpenCodeEvents` menjalankan loop baca `GET /event` dengan
|
|
10
|
+
* reconnect otomatis; mengembalikan fungsi untuk berhenti.
|
|
11
|
+
*/
|
|
12
|
+
import type { OpenCodeEvent } from "./opencode-client";
|
|
13
|
+
|
|
14
|
+
/** Parsing satu frame SSE: baris `data:` (dan `event:` bila ada). */
|
|
15
|
+
export function parseSseFrame(frame: string): string | null {
|
|
16
|
+
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
17
|
+
if (!dataLine) return null;
|
|
18
|
+
return dataLine.slice(5).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Normalisasi payload event SSE `{ id, type, properties }` -> event datar. */
|
|
22
|
+
export function normalizeEvent(payload: unknown): OpenCodeEvent | null {
|
|
23
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
24
|
+
const raw = payload as Record<string, unknown>;
|
|
25
|
+
if (typeof raw.type !== "string") return null;
|
|
26
|
+
const props =
|
|
27
|
+
typeof raw.properties === "object" && raw.properties !== null
|
|
28
|
+
? (raw.properties as Record<string, unknown>)
|
|
29
|
+
: {};
|
|
30
|
+
return { type: raw.type, ...(raw.id !== undefined ? { id: String(raw.id) } : {}), ...props };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Buka langganan event SSE (`GET /event`) dan panggil `cb` untuk setiap
|
|
35
|
+
* event. Bila koneksi putus/error, coba lagi setelah jeda 1 detik. Fungsi
|
|
36
|
+
* yang dikembalikan menghentikan langganan (membatalkan reader aktif).
|
|
37
|
+
*/
|
|
38
|
+
export function subscribeOpenCodeEvents(
|
|
39
|
+
baseUrl: string,
|
|
40
|
+
cb: (ev: OpenCodeEvent) => void,
|
|
41
|
+
): () => void {
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
44
|
+
void (async () => {
|
|
45
|
+
while (!cancelled) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${baseUrl}/event`);
|
|
48
|
+
if (!res.ok || !res.body) {
|
|
49
|
+
await Bun.sleep(1000);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
reader = res.body.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let buf = "";
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done || cancelled) break;
|
|
58
|
+
buf += decoder.decode(value, { stream: true });
|
|
59
|
+
const frames = buf.split("\n\n");
|
|
60
|
+
buf = frames.pop() ?? "";
|
|
61
|
+
for (const frame of frames) {
|
|
62
|
+
const data = parseSseFrame(frame);
|
|
63
|
+
if (data === null) continue;
|
|
64
|
+
try {
|
|
65
|
+
const ev = normalizeEvent(JSON.parse(data));
|
|
66
|
+
if (ev) cb(ev);
|
|
67
|
+
} catch {
|
|
68
|
+
/* frame tidak valid — abaikan */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* koneksi SSE terputus — coba lagi setelah jeda */
|
|
74
|
+
}
|
|
75
|
+
if (!cancelled) await Bun.sleep(1000);
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
reader?.cancel().catch(() => {});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project_Manager & Folder_Browser.
|
|
3
|
+
* Sesuai `design.md` — `project-manager.ts`.
|
|
4
|
+
*
|
|
5
|
+
* `listDirectory` dan `createProject` selalu memvalidasi path lewat
|
|
6
|
+
* `sandbox.ts` sebelum operasi filesystem apa pun, dan memakai `db.ts`
|
|
7
|
+
* untuk persistensi.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { mkdirSync, readdirSync } from "node:fs";
|
|
11
|
+
import type { SessionStore } from "../../db";
|
|
12
|
+
import type { Project } from "../../types";
|
|
13
|
+
import type { Result, SimpleResult } from "../result";
|
|
14
|
+
import { resolveWithinSandbox } from "./sandbox";
|
|
15
|
+
|
|
16
|
+
export interface ProjectManager {
|
|
17
|
+
listDirectory(relativePath: string): Result<{ entries: string[] }>;
|
|
18
|
+
createProject(name: string, relativePath: string): Result<Project>;
|
|
19
|
+
listProjects(): Project[];
|
|
20
|
+
getProject(projectId: string): Result<Project>;
|
|
21
|
+
/**
|
|
22
|
+
* Simpan custom instruction Project. Teks di-trim; kosong -> `null`
|
|
23
|
+
* (hapus). Menolak teks di atas `MAX_PROJECT_INSTRUCTIONS_LENGTH`.
|
|
24
|
+
*/
|
|
25
|
+
updateInstructions(projectId: string, instructions: string): Result<Project>;
|
|
26
|
+
/**
|
|
27
|
+
* Hapus registrasi Project dari Session_Store. Direktori kerja di
|
|
28
|
+
* filesystem TIDAK disentuh — Project hanyalah pendaftaran sebuah folder,
|
|
29
|
+
* jadi menghapusnya tidak boleh menghapus kode/pekerjaan pengguna.
|
|
30
|
+
* Menolak bila masih ada Session miliknya (`PROJECT_HAS_SESSIONS`).
|
|
31
|
+
*/
|
|
32
|
+
deleteProject(projectId: string): SimpleResult;
|
|
33
|
+
/** Sandbox_Root aktif (path absolut). */
|
|
34
|
+
getSandboxRoot(): string;
|
|
35
|
+
/**
|
|
36
|
+
* Ganti Sandbox_Root saat berjalan (Settings -> Workspace). Pemanggil
|
|
37
|
+
* WAJIB sudah memvalidasi root lewat `workspace.ts`; dari titik ini semua
|
|
38
|
+
* operasi (listing, create) memakai root baru.
|
|
39
|
+
*/
|
|
40
|
+
setSandboxRoot(root: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Batas panjang custom instruction Project (karakter). */
|
|
44
|
+
export const MAX_PROJECT_INSTRUCTIONS_LENGTH = 20_000;
|
|
45
|
+
|
|
46
|
+
/** Peta hasil validasi sandbox -> pesan error publik. */
|
|
47
|
+
function sandboxError(reason: string): string {
|
|
48
|
+
return reason === "outside_sandbox" ? "PATH_OUTSIDE_SANDBOX" : "INVALID_PATH_CHARS";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Membuat instance Project_Manager terikat pada `store`. Sandbox_Root
|
|
53
|
+
* awal dari config; bisa diganti lewat `setSandboxRoot` tanpa restart.
|
|
54
|
+
*/
|
|
55
|
+
export function createProjectManager(initialRoot: string, store: SessionStore): ProjectManager {
|
|
56
|
+
let sandboxRoot = initialRoot;
|
|
57
|
+
function listDirectory(relativePath: string): Result<{ entries: string[] }> {
|
|
58
|
+
const res = resolveWithinSandbox(sandboxRoot, relativePath);
|
|
59
|
+
if (!res.ok) return { ok: false, error: sandboxError(res.reason) };
|
|
60
|
+
|
|
61
|
+
const realPath = res.realPath;
|
|
62
|
+
let dirents: import("node:fs").Dirent[];
|
|
63
|
+
try {
|
|
64
|
+
dirents = readdirSync(realPath, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return { ok: false, error: "PATH_NOT_FOUND" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Hanya sub-direktori (Requirement 10.2). Symlink tidak diikuti sehingga
|
|
70
|
+
// entri dari luar sandbox tidak pernah bocor ke hasil listing.
|
|
71
|
+
const entries = dirents
|
|
72
|
+
.filter((d) => d.isDirectory())
|
|
73
|
+
.map((d) => d.name)
|
|
74
|
+
.sort();
|
|
75
|
+
return { ok: true, data: { entries } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createProject(name: string, relativePath: string): Result<Project> {
|
|
79
|
+
const trimmed = name.trim();
|
|
80
|
+
if (!trimmed) return { ok: false, error: "NAME_REQUIRED" };
|
|
81
|
+
|
|
82
|
+
const byName = store.getProjectByName(trimmed);
|
|
83
|
+
if (byName.ok) return { ok: false, error: "NAME_TAKEN" };
|
|
84
|
+
|
|
85
|
+
const res = resolveWithinSandbox(sandboxRoot, relativePath);
|
|
86
|
+
if (!res.ok) return { ok: false, error: sandboxError(res.reason) };
|
|
87
|
+
const realPath = res.realPath;
|
|
88
|
+
|
|
89
|
+
const byPath = store.getProjectByPath(realPath);
|
|
90
|
+
if (byPath.ok) return { ok: false, error: "PATH_TAKEN" };
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
mkdirSync(realPath, { recursive: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, error: "MKDIR_FAILED" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const project: Project = {
|
|
99
|
+
id: randomUUID(),
|
|
100
|
+
name: trimmed,
|
|
101
|
+
path: realPath,
|
|
102
|
+
createdAt: Date.now(),
|
|
103
|
+
instructions: null,
|
|
104
|
+
};
|
|
105
|
+
const inserted = store.insertProject(project);
|
|
106
|
+
if (!inserted.ok) return inserted;
|
|
107
|
+
return { ok: true, data: project };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function listProjects(): Project[] {
|
|
111
|
+
return store.listProjects();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getProject(projectId: string): Result<Project> {
|
|
115
|
+
return store.getProjectById(projectId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function updateInstructions(projectId: string, instructions: string): Result<Project> {
|
|
119
|
+
const trimmed = instructions.trim();
|
|
120
|
+
if (trimmed.length > MAX_PROJECT_INSTRUCTIONS_LENGTH) {
|
|
121
|
+
return { ok: false, error: "INSTRUCTIONS_TOO_LONG" };
|
|
122
|
+
}
|
|
123
|
+
return store.updateProjectInstructions(projectId, trimmed === "" ? null : trimmed);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Hapus pendaftaran Project. Direktori kerjanya dibiarkan utuh: Project
|
|
128
|
+
* adalah referensi ke sebuah folder Sandbox, bukan pemiliknya — menghapus
|
|
129
|
+
* folder berisi kode pengguna jauh melampaui maksud aksi ini.
|
|
130
|
+
*/
|
|
131
|
+
function deleteProject(projectId: string): SimpleResult {
|
|
132
|
+
return store.deleteProject(projectId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
listDirectory,
|
|
137
|
+
createProject,
|
|
138
|
+
listProjects,
|
|
139
|
+
getProject,
|
|
140
|
+
updateInstructions,
|
|
141
|
+
deleteProject,
|
|
142
|
+
getSandboxRoot: () => sandboxRoot,
|
|
143
|
+
setSandboxRoot: (root: string) => {
|
|
144
|
+
sandboxRoot = root;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validator Sandbox_Root — `resolveWithinSandbox`.
|
|
3
|
+
* Sesuai `design.md` — `sandbox.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Langkah validasi:
|
|
6
|
+
* 1. Tolak segmen path yang berisi `..` sebelum resolusi apa pun.
|
|
7
|
+
* 2. Validasi karakter terlarang OS pada tiap segmen nama direktori.
|
|
8
|
+
* 3. Resolve path (mengikuti symlink) lalu pastikan hasil berada di dalam
|
|
9
|
+
* `sandboxRoot` (startsWith `rootReal + sep` atau sama persis).
|
|
10
|
+
*
|
|
11
|
+
* Path yang belum ada di filesystem (mis. saat `createProject` akan mkdir)
|
|
12
|
+
* tetap diperbolehkan selama seluruh segmen valid dan ancestor terdalam yang
|
|
13
|
+
* sudah ada berada di dalam sandbox.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export type SandboxReason = "outside_sandbox" | "invalid_chars";
|
|
19
|
+
|
|
20
|
+
export type SandboxResult = { ok: true; realPath: string } | { ok: false; reason: SandboxReason };
|
|
21
|
+
|
|
22
|
+
/** Karakter terlarang untuk nama direktori sesuai OS server. */
|
|
23
|
+
const FORBIDDEN_CHARS = process.platform === "win32" ? /[<>:"|?*\0/\\]/ : /[\0]/;
|
|
24
|
+
|
|
25
|
+
function isWithin(rootReal: string, p: string): boolean {
|
|
26
|
+
return p === rootReal || p.startsWith(rootReal + path.sep);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function splitSegments(userPath: string): string[] {
|
|
30
|
+
return userPath.split(/[\\/]/).filter((s) => s.length > 0 && s !== ".");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Memvalidasi dan me-resolve `userPath` terhadap `sandboxRoot`.
|
|
35
|
+
* Mengembalikan path absolut hasil resolusi bila valid.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveWithinSandbox(sandboxRoot: string, userPath: string): SandboxResult {
|
|
38
|
+
let rootReal: string;
|
|
39
|
+
try {
|
|
40
|
+
rootReal = realpathSync(sandboxRoot);
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, reason: "outside_sandbox" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const segments = splitSegments(userPath);
|
|
46
|
+
|
|
47
|
+
// 1. Tolak ".." sebelum resolusi apa pun
|
|
48
|
+
// 2. Validasi karakter terlarang OS
|
|
49
|
+
for (const seg of segments) {
|
|
50
|
+
if (seg === "..") return { ok: false, reason: "outside_sandbox" };
|
|
51
|
+
if (FORBIDDEN_CHARS.test(seg)) return { ok: false, reason: "invalid_chars" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Gabungkan + resolve, mengikuti symlink.
|
|
55
|
+
// Path absolut dipakai langsung (bukan digabung) sehingga path absolut di
|
|
56
|
+
// luar sandbox pasti tertolak oleh pemeriksaan kontainmen.
|
|
57
|
+
const candidate = path.isAbsolute(userPath)
|
|
58
|
+
? path.normalize(userPath)
|
|
59
|
+
: path.join(rootReal, ...segments);
|
|
60
|
+
|
|
61
|
+
// Cari ancestor terdalam yang sudah ada di filesystem agar `realpath`
|
|
62
|
+
// (yang mengikuti symlink) dapat dijalankan meski path tujuan belum dibuat.
|
|
63
|
+
let existing = candidate;
|
|
64
|
+
const tail: string[] = [];
|
|
65
|
+
while (!existsSync(existing)) {
|
|
66
|
+
const parent = path.dirname(existing);
|
|
67
|
+
if (parent === existing) return { ok: false, reason: "outside_sandbox" };
|
|
68
|
+
tail.unshift(path.basename(existing));
|
|
69
|
+
existing = parent;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let realExisting: string;
|
|
73
|
+
try {
|
|
74
|
+
realExisting = realpathSync(existing);
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: false, reason: "outside_sandbox" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isWithin(rootReal, realExisting)) return { ok: false, reason: "outside_sandbox" };
|
|
80
|
+
|
|
81
|
+
const realPath = tail.length === 0 ? realExisting : path.join(realExisting, ...tail);
|
|
82
|
+
return { ok: true, realPath };
|
|
83
|
+
}
|