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,224 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
activeProvider,
|
|
4
|
+
bestUrl,
|
|
5
|
+
type DashboardInfo,
|
|
6
|
+
menuItems,
|
|
7
|
+
portInputError,
|
|
8
|
+
renderDashboard,
|
|
9
|
+
renderSnapshot,
|
|
10
|
+
takeSnapshot,
|
|
11
|
+
} from "../dashboard";
|
|
12
|
+
|
|
13
|
+
function makeInfo(overrides: Partial<DashboardInfo> = {}): DashboardInfo {
|
|
14
|
+
return {
|
|
15
|
+
server: {
|
|
16
|
+
store: {
|
|
17
|
+
listProjects: () => [{ id: "p1" }],
|
|
18
|
+
listSessions: () => [
|
|
19
|
+
{ id: "s1", status: "running", title: "active job" },
|
|
20
|
+
{ id: "s2", status: "idle", title: null },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
tunnel: { status: () => ({ phase: "signed_out", account: null, error: null }) },
|
|
24
|
+
} as unknown as DashboardInfo["server"],
|
|
25
|
+
port: 3000,
|
|
26
|
+
hostname: "127.0.0.1",
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("renderDashboard", () => {
|
|
32
|
+
test("logo KCG + status simpel Running", () => {
|
|
33
|
+
const frame = renderDashboard(makeInfo(), "0.1.0");
|
|
34
|
+
expect(frame).toContain("kcgcode");
|
|
35
|
+
expect(frame).toContain("v0.1.0");
|
|
36
|
+
expect(frame).toContain("██╗");
|
|
37
|
+
expect(frame).toContain("Running");
|
|
38
|
+
expect(frame).toContain("Local");
|
|
39
|
+
expect(frame).toContain("PID");
|
|
40
|
+
expect(frame).toContain("up 0s");
|
|
41
|
+
expect(frame).toContain("Tunnel");
|
|
42
|
+
expect(frame).toContain("Open in browser");
|
|
43
|
+
expect(frame).toContain("↑↓ move");
|
|
44
|
+
expect(frame).not.toContain("CONFIG");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("baris Awake menampilkan status keep-awake", () => {
|
|
48
|
+
const on = renderDashboard(
|
|
49
|
+
makeInfo({
|
|
50
|
+
keepAwake: { status: () => ({ active: true, method: "systemd-inhibit", error: null }) },
|
|
51
|
+
}),
|
|
52
|
+
"0.1.0",
|
|
53
|
+
);
|
|
54
|
+
expect(on).toContain("Awake");
|
|
55
|
+
expect(on).toContain("sleep blocked");
|
|
56
|
+
|
|
57
|
+
const off = renderDashboard(
|
|
58
|
+
makeInfo({
|
|
59
|
+
keepAwake: {
|
|
60
|
+
status: () => ({ active: false, method: null, error: "disabled (--allow-sleep)" }),
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
"0.1.0",
|
|
64
|
+
);
|
|
65
|
+
expect(off).toContain("off · disabled");
|
|
66
|
+
expect(renderDashboard(makeInfo(), "0.1.0")).not.toContain("Awake");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("stats berubah saat data store berubah (refresh in-place)", () => {
|
|
70
|
+
let sessions: { id: string; status: string; title: string | null }[] = [];
|
|
71
|
+
const info = makeInfo({
|
|
72
|
+
server: {
|
|
73
|
+
store: {
|
|
74
|
+
listProjects: () => [],
|
|
75
|
+
listSessions: () => sessions,
|
|
76
|
+
},
|
|
77
|
+
tunnel: {
|
|
78
|
+
status: () => ({
|
|
79
|
+
phase: "online",
|
|
80
|
+
account: { url: "https://demo.example.com" },
|
|
81
|
+
error: null,
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
} as unknown as DashboardInfo["server"],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const before = renderDashboard(info, "0.1.0");
|
|
88
|
+
expect(before).toContain("Sessions");
|
|
89
|
+
|
|
90
|
+
sessions = [{ id: "s9", status: "running", title: "fresh" }];
|
|
91
|
+
const after = renderDashboard(info, "0.1.0");
|
|
92
|
+
expect(after).toContain("1 running");
|
|
93
|
+
expect(after).toContain("Tunnel");
|
|
94
|
+
expect(after).toContain("https://demo.example.com");
|
|
95
|
+
expect(after).not.toBe(before);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("menu simpel + info provider kcg/lhr", () => {
|
|
99
|
+
const info = makeInfo({
|
|
100
|
+
server: {
|
|
101
|
+
store: {
|
|
102
|
+
listProjects: () => [],
|
|
103
|
+
listSessions: () => [],
|
|
104
|
+
getTunnelSettings: () => ({ provider: "lhr" }),
|
|
105
|
+
},
|
|
106
|
+
tunnel: {
|
|
107
|
+
status: () => ({ phase: "stopped", account: null, error: null, pairing: null }),
|
|
108
|
+
},
|
|
109
|
+
lhr: {
|
|
110
|
+
status: () => ({ phase: "online", url: "https://abc.lhr.life", error: null }),
|
|
111
|
+
},
|
|
112
|
+
tunnelProvider: () => "lhr" as const,
|
|
113
|
+
} as unknown as DashboardInfo["server"],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(activeProvider(info)).toBe("lhr");
|
|
117
|
+
expect(bestUrl(info)).toEqual({ label: "public (lhr)", url: "https://abc.lhr.life" });
|
|
118
|
+
expect(menuItems({}).map((m) => m.id)).toEqual([
|
|
119
|
+
"open",
|
|
120
|
+
"copy",
|
|
121
|
+
"qr",
|
|
122
|
+
"detail",
|
|
123
|
+
"port",
|
|
124
|
+
"background",
|
|
125
|
+
"quit",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const frame = renderDashboard(info, "0.1.0", { showQr: true, showDetail: true, menuIndex: 2 });
|
|
129
|
+
expect(frame).toContain("Running");
|
|
130
|
+
expect(frame).toContain("TUNNEL DETAIL");
|
|
131
|
+
expect(frame).toContain("OPEN ON PHONE");
|
|
132
|
+
expect(frame).toContain("lhr");
|
|
133
|
+
expect(frame).toContain("https://abc.lhr.life");
|
|
134
|
+
expect(frame).toContain("Hide phone QR");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("URL publik punya baris sendiri", () => {
|
|
138
|
+
const info = makeInfo({
|
|
139
|
+
server: {
|
|
140
|
+
store: { listProjects: () => [], listSessions: () => [] },
|
|
141
|
+
tunnel: {
|
|
142
|
+
status: () => ({
|
|
143
|
+
phase: "online",
|
|
144
|
+
account: { url: "https://me.example.com" },
|
|
145
|
+
error: null,
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
} as unknown as DashboardInfo["server"],
|
|
149
|
+
});
|
|
150
|
+
const frame = renderDashboard(info, "0.1.0");
|
|
151
|
+
expect(frame).toMatch(/Public\s+https:\/\/me\.example\.com/);
|
|
152
|
+
expect(frame).toContain("kcg · online");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("konfirmasi quit saat ada session running", () => {
|
|
156
|
+
const frame = renderDashboard(makeInfo(), "0.1.0", { confirm: "quit" });
|
|
157
|
+
expect(frame).toContain("press q again");
|
|
158
|
+
expect(renderDashboard(makeInfo(), "0.1.0")).not.toContain("press q again");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("form ganti port + catatan port fallback", () => {
|
|
162
|
+
const info = makeInfo({ portNote: "3000 in use", port: 3001 });
|
|
163
|
+
const frame = renderDashboard(info, "0.1.0", {
|
|
164
|
+
portInput: "40",
|
|
165
|
+
portError: "Enter a port number",
|
|
166
|
+
});
|
|
167
|
+
expect(frame).toContain("CHANGE PORT");
|
|
168
|
+
expect(frame).toContain("current 3001");
|
|
169
|
+
expect(frame).toContain("› 40");
|
|
170
|
+
expect(frame).toContain("Enter a port number");
|
|
171
|
+
expect(frame).toContain("(3000 in use)");
|
|
172
|
+
expect(frame).toContain("esc cancel");
|
|
173
|
+
expect(renderDashboard(makeInfo(), "0.1.0")).not.toContain("CHANGE PORT");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("status Restarting saat ganti port", () => {
|
|
177
|
+
expect(renderDashboard(makeInfo({ restarting: true }), "0.1.0")).toContain("Restarting");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("portInputError", () => {
|
|
182
|
+
test("validasi input port", () => {
|
|
183
|
+
expect(portInputError("")).not.toBeNull();
|
|
184
|
+
expect(portInputError("0")).not.toBeNull();
|
|
185
|
+
expect(portInputError("70000")).not.toBeNull();
|
|
186
|
+
expect(portInputError("80")).toContain("1024");
|
|
187
|
+
expect(portInputError("4000")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("mode background (dashboard untuk daemon)", () => {
|
|
192
|
+
test("menu Stop / Run in foreground / Close dashboard", () => {
|
|
193
|
+
expect(menuItems({}, "background").map((m) => m.id)).toEqual([
|
|
194
|
+
"open",
|
|
195
|
+
"copy",
|
|
196
|
+
"qr",
|
|
197
|
+
"detail",
|
|
198
|
+
"port",
|
|
199
|
+
"foreground",
|
|
200
|
+
"stop",
|
|
201
|
+
"quit",
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("render snapshot daemon", () => {
|
|
206
|
+
const snap = { ...takeSnapshot(makeInfo()), mode: "background" as const, pid: 4242 };
|
|
207
|
+
const frame = renderSnapshot(snap, "0.1.0");
|
|
208
|
+
expect(frame).toContain("Running in background");
|
|
209
|
+
expect(frame).toContain("PID 4242");
|
|
210
|
+
expect(frame).toContain("Stop server");
|
|
211
|
+
expect(frame).toContain("Close dashboard");
|
|
212
|
+
expect(frame).toContain("server keeps running");
|
|
213
|
+
expect(frame).toContain("q close");
|
|
214
|
+
expect(frame).not.toContain("Run in background");
|
|
215
|
+
|
|
216
|
+
const busy = renderSnapshot({ ...snap, restarting: true, busyLabel: "Stopping…" }, "0.1.0");
|
|
217
|
+
expect(busy).toContain("Stopping…");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("snapshot bisa diserialisasi ke daemon.json", () => {
|
|
221
|
+
const snap = takeSnapshot(makeInfo(), "background");
|
|
222
|
+
expect(JSON.parse(JSON.stringify(snap))).toEqual(snap);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseArgs } from "../args";
|
|
3
|
+
import { disabledKeepAwake, inhibitCommand, startKeepAwake } from "../keep-awake";
|
|
4
|
+
|
|
5
|
+
describe("inhibitCommand", () => {
|
|
6
|
+
test("linux: systemd-inhibit memblok sleep & idle, child cat terikat stdin", () => {
|
|
7
|
+
const cmd = inhibitCommand("linux", 123);
|
|
8
|
+
expect(cmd?.[0]).toBe("systemd-inhibit");
|
|
9
|
+
expect(cmd).toContain("--what=sleep:idle");
|
|
10
|
+
expect(cmd).toContain("--mode=block");
|
|
11
|
+
expect(cmd?.at(-1)).toBe("cat");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("darwin: caffeinate menunggu pid kcgcode", () => {
|
|
15
|
+
expect(inhibitCommand("darwin", 123)).toEqual(["caffeinate", "-i", "-s", "-w", "123"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("platform lain tidak didukung", () => {
|
|
19
|
+
expect(inhibitCommand("freebsd", 123)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("startKeepAwake", () => {
|
|
24
|
+
test("platform tak didukung → nonaktif dengan alasan", async () => {
|
|
25
|
+
const ka = await startKeepAwake("aix");
|
|
26
|
+
expect(ka.status()).toEqual({ active: false, method: null, error: "unsupported on aix" });
|
|
27
|
+
ka.stop();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("disabledKeepAwake melaporkan --allow-sleep", () => {
|
|
31
|
+
expect(disabledKeepAwake().status().active).toBe(false);
|
|
32
|
+
expect(disabledKeepAwake().status().error).toContain("--allow-sleep");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("parseArgs --allow-sleep", () => {
|
|
37
|
+
test("default keep awake", () => {
|
|
38
|
+
expect(parseArgs([]).allowSleep).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("flag mengizinkan sleep", () => {
|
|
42
|
+
expect(parseArgs(["start", "--allow-sleep"]).allowSleep).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { resolveConfig, saveConfigPort } from "../../config";
|
|
6
|
+
import { choosePort, portIsFree, requestedPort } from "../port";
|
|
7
|
+
|
|
8
|
+
describe("requestedPort", () => {
|
|
9
|
+
test("prioritas flag > env > config > default", () => {
|
|
10
|
+
expect(requestedPort({ flag: 1, env: "2", config: 3 })).toEqual({ port: 1, source: "flag" });
|
|
11
|
+
expect(requestedPort({ env: "2", config: 3 })).toEqual({ port: 2, source: "env" });
|
|
12
|
+
expect(requestedPort({ env: "abc", config: 3 })).toEqual({ port: 3, source: "config" });
|
|
13
|
+
expect(requestedPort({})).toEqual({ port: 3000, source: "default" });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("choosePort", () => {
|
|
18
|
+
const busy = (taken: number[]) => (_h: string, p: number) => !taken.includes(p);
|
|
19
|
+
|
|
20
|
+
test("port kosong dipakai apa adanya", async () => {
|
|
21
|
+
expect(await choosePort("127.0.0.1", { port: 3000, source: "default" }, busy([]))).toEqual({
|
|
22
|
+
ok: true,
|
|
23
|
+
port: 3000,
|
|
24
|
+
fallbackFrom: null,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("default/config terpakai → pindah ke port kosong berikutnya", async () => {
|
|
29
|
+
expect(
|
|
30
|
+
await choosePort("127.0.0.1", { port: 3000, source: "default" }, busy([3000, 3001])),
|
|
31
|
+
).toEqual({
|
|
32
|
+
ok: true,
|
|
33
|
+
port: 3002,
|
|
34
|
+
fallbackFrom: 3000,
|
|
35
|
+
});
|
|
36
|
+
expect(
|
|
37
|
+
await choosePort("127.0.0.1", { port: 4000, source: "config" }, busy([4000])),
|
|
38
|
+
).toMatchObject({
|
|
39
|
+
ok: true,
|
|
40
|
+
port: 4001,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("port eksplisit terpakai → error, tanpa fallback", async () => {
|
|
45
|
+
const res = await choosePort("127.0.0.1", { port: 3000, source: "flag" }, busy([3000]));
|
|
46
|
+
expect(res.ok).toBe(false);
|
|
47
|
+
if (!res.ok) expect(res.error).toContain("--port");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("portIsFree mendeteksi port yang sedang dipakai", async () => {
|
|
51
|
+
const s = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
|
52
|
+
try {
|
|
53
|
+
expect(await portIsFree("127.0.0.1", s.port as number)).toBe(false);
|
|
54
|
+
} finally {
|
|
55
|
+
s.stop(true);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("config port", () => {
|
|
61
|
+
let dir = "";
|
|
62
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
63
|
+
|
|
64
|
+
test("saveConfigPort menyimpan port & mempertahankan key lain", () => {
|
|
65
|
+
dir = mkdtempSync(path.join(tmpdir(), "kcg-port-"));
|
|
66
|
+
const file = path.join(dir, "config.json");
|
|
67
|
+
writeFileSync(file, JSON.stringify({ sandboxRoot: dir, extra: true }));
|
|
68
|
+
|
|
69
|
+
expect(saveConfigPort(file, 4321).ok).toBe(true);
|
|
70
|
+
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
|
|
71
|
+
sandboxRoot: dir,
|
|
72
|
+
extra: true,
|
|
73
|
+
port: 4321,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const cfg = resolveConfig(file);
|
|
77
|
+
expect(cfg.ok && cfg.data.port).toBe(4321);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("port tidak valid di config ditolak", () => {
|
|
81
|
+
dir = mkdtempSync(path.join(tmpdir(), "kcg-port-"));
|
|
82
|
+
const file = path.join(dir, "config.json");
|
|
83
|
+
writeFileSync(file, JSON.stringify({ sandboxRoot: dir, port: "abc" }));
|
|
84
|
+
expect(resolveConfig(file).ok).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/cli/args.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser argumen CLI kcgcode (tanpa dependency eksternal).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { c } from "./theme";
|
|
6
|
+
|
|
7
|
+
export interface CliArgs {
|
|
8
|
+
command:
|
|
9
|
+
| "start"
|
|
10
|
+
| "stop"
|
|
11
|
+
| "status"
|
|
12
|
+
| "logs"
|
|
13
|
+
| "init"
|
|
14
|
+
| "reset-lock"
|
|
15
|
+
| "help"
|
|
16
|
+
| "version"
|
|
17
|
+
| "invalid";
|
|
18
|
+
port?: number;
|
|
19
|
+
host?: string;
|
|
20
|
+
/** Path berkas konfigurasi (override KCG_CONFIG_PATH). */
|
|
21
|
+
config?: string;
|
|
22
|
+
open: boolean;
|
|
23
|
+
/** Izinkan OS sleep (matikan keep-awake). */
|
|
24
|
+
allowSleep?: boolean;
|
|
25
|
+
/** Jalankan di background (lepas dari terminal). */
|
|
26
|
+
detach?: boolean;
|
|
27
|
+
/** `logs`: ikuti log baru. */
|
|
28
|
+
follow?: boolean;
|
|
29
|
+
/** `logs`: jumlah baris terakhir. */
|
|
30
|
+
lines?: number;
|
|
31
|
+
/** Argumen tak dikenal / pesan error. */
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HELP_TEXT = `kcgcode — control CLI AI agents from your phone
|
|
36
|
+
|
|
37
|
+
USAGE
|
|
38
|
+
kcgcode [start] [options]
|
|
39
|
+
kcgcode start -d [options]
|
|
40
|
+
kcgcode stop | status | logs [-f]
|
|
41
|
+
kcgcode init [options]
|
|
42
|
+
kcgcode help | --help | -h
|
|
43
|
+
kcgcode version | --version | -v
|
|
44
|
+
|
|
45
|
+
COMMANDS
|
|
46
|
+
start (default) Run server + terminal dashboard; if it already runs
|
|
47
|
+
in background, open its dashboard instead
|
|
48
|
+
stop Stop the background server
|
|
49
|
+
status Show background server status
|
|
50
|
+
logs Show background server log (-f to follow)
|
|
51
|
+
init Create kcg-code.config.json & local sandbox folder
|
|
52
|
+
reset-lock Remove the app lock (forgot PIN/password) & sign out all devices
|
|
53
|
+
|
|
54
|
+
OPTIONS
|
|
55
|
+
-p, --port <n> HTTP port (default: config "port" or 3000; a busy
|
|
56
|
+
default is swapped for the next free port)
|
|
57
|
+
-H, --host <h> Bind host (default 127.0.0.1 / env KCG_HOST)
|
|
58
|
+
-c, --config <f> Config path (default ~/.kcgcode/config.json)
|
|
59
|
+
-d, --detach Run in background (keeps running after terminal closes)
|
|
60
|
+
--open Open the web dashboard in a browser after start
|
|
61
|
+
-f, --follow logs: keep printing new lines
|
|
62
|
+
-n, --lines <n> logs: number of lines to show (default 50)
|
|
63
|
+
--allow-sleep Let the OS sleep while running (default: keep awake)
|
|
64
|
+
-h, --help Show help
|
|
65
|
+
-v, --version Show version
|
|
66
|
+
|
|
67
|
+
ENV
|
|
68
|
+
KCG_PORT HTTP port
|
|
69
|
+
KCG_HOST Bind host
|
|
70
|
+
KCG_CONFIG_PATH Config file path
|
|
71
|
+
KCG_DB_PATH SQLite path (default ~/.kcgcode/data/kcg-code.sqlite)
|
|
72
|
+
KCG_ALLOW_SLEEP=1 Same as --allow-sleep
|
|
73
|
+
|
|
74
|
+
EXAMPLES
|
|
75
|
+
kcgcode
|
|
76
|
+
kcgcode init
|
|
77
|
+
kcgcode start --port 4000 --open
|
|
78
|
+
kcgcode start -d && kcgcode logs -f
|
|
79
|
+
kcgcode init --config ~/.kcgcode/config.json
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
/** Help berwarna: heading cyan, flag/command hijau, deskripsi redup. */
|
|
83
|
+
export function helpText(): string {
|
|
84
|
+
return HELP_TEXT.split("\n")
|
|
85
|
+
.map((line, i) => {
|
|
86
|
+
if (i === 0) return c.cyanBright(line);
|
|
87
|
+
if (/^[A-Z]+$/.test(line)) return c.cyanBright(line);
|
|
88
|
+
const m = line.match(/^(\s{2})(\S.*?)(\s{2,})(\S.*)$/);
|
|
89
|
+
if (m) return `${m[1]}${c.green(m[2] as string)}${m[3]}${c.dim(m[4] as string)}`;
|
|
90
|
+
if (/^\s+kcgcode/.test(line)) return c.white(line);
|
|
91
|
+
return line;
|
|
92
|
+
})
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseArgs(argv: string[]): CliArgs {
|
|
97
|
+
const args: CliArgs = { command: "start", open: false };
|
|
98
|
+
const rest = [...argv];
|
|
99
|
+
|
|
100
|
+
// Command eksplisit di posisi pertama (bukan flag)
|
|
101
|
+
const first = rest[0];
|
|
102
|
+
if (first === "init") {
|
|
103
|
+
args.command = "init";
|
|
104
|
+
rest.shift();
|
|
105
|
+
} else if (first === "reset-lock") {
|
|
106
|
+
args.command = "reset-lock";
|
|
107
|
+
rest.shift();
|
|
108
|
+
} else if (first === "start" || first === "stop" || first === "status" || first === "logs") {
|
|
109
|
+
args.command = first;
|
|
110
|
+
rest.shift();
|
|
111
|
+
} else if (first === "help") {
|
|
112
|
+
args.command = "help";
|
|
113
|
+
rest.shift();
|
|
114
|
+
} else if (first === "version") {
|
|
115
|
+
args.command = "version";
|
|
116
|
+
rest.shift();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < rest.length; i++) {
|
|
120
|
+
const a = rest[i];
|
|
121
|
+
if (a === undefined) continue;
|
|
122
|
+
|
|
123
|
+
if (a === "-h" || a === "--help") {
|
|
124
|
+
return { command: "help", open: false };
|
|
125
|
+
}
|
|
126
|
+
if (a === "-v" || a === "--version") {
|
|
127
|
+
return { command: "version", open: false };
|
|
128
|
+
}
|
|
129
|
+
if (a === "--open") {
|
|
130
|
+
args.open = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (a === "-d" || a === "--detach") {
|
|
134
|
+
args.detach = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (a === "-f" || a === "--follow") {
|
|
138
|
+
args.follow = true;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (a === "-n" || a === "--lines") {
|
|
142
|
+
const raw = rest[++i];
|
|
143
|
+
const n = Number(raw);
|
|
144
|
+
if (raw === undefined || !Number.isInteger(n) || n <= 0) {
|
|
145
|
+
return { ...args, command: "invalid", error: `Invalid value for ${a}` };
|
|
146
|
+
}
|
|
147
|
+
args.lines = n;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (a === "--allow-sleep") {
|
|
151
|
+
args.allowSleep = true;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (a === "-p" || a === "--port") {
|
|
155
|
+
const raw = rest[++i];
|
|
156
|
+
if (raw === undefined)
|
|
157
|
+
return { ...args, command: "invalid", error: `Missing value for ${a}` };
|
|
158
|
+
const n = Number(raw);
|
|
159
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
160
|
+
return { ...args, command: "invalid", error: `Invalid port "${raw}"` };
|
|
161
|
+
}
|
|
162
|
+
args.port = n;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (a === "-H" || a === "--host") {
|
|
166
|
+
const raw = rest[++i];
|
|
167
|
+
if (raw === undefined)
|
|
168
|
+
return { ...args, command: "invalid", error: `Missing value for ${a}` };
|
|
169
|
+
args.host = raw;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (a === "-c" || a === "--config") {
|
|
173
|
+
const raw = rest[++i];
|
|
174
|
+
if (raw === undefined)
|
|
175
|
+
return { ...args, command: "invalid", error: `Missing value for ${a}` };
|
|
176
|
+
args.config = raw;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (a.startsWith("-")) {
|
|
180
|
+
return { ...args, command: "invalid", error: `Unknown option "${a}"` };
|
|
181
|
+
}
|
|
182
|
+
return { ...args, command: "invalid", error: `Unknown command "${a}"` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return args;
|
|
186
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard untuk server yang sudah jalan di background (daemon).
|
|
3
|
+
*
|
|
4
|
+
* Data dibaca dari `daemon.json` yang ditulis daemon tiap beberapa detik;
|
|
5
|
+
* kontrol lewat sinyal (stop) atau spawn ulang daemon (ganti port).
|
|
6
|
+
* Menutup dashboard (q) tidak menghentikan server.
|
|
7
|
+
*/
|
|
8
|
+
import { saveConfigPort } from "../config";
|
|
9
|
+
import type { CliArgs } from "./args";
|
|
10
|
+
import { type DaemonState, isAlive, readDaemonState, startDetached, stopDaemon } from "./daemon";
|
|
11
|
+
import { type DashboardSnapshot, openBrowser, startDashboard } from "./dashboard";
|
|
12
|
+
import { portIsFree } from "./port";
|
|
13
|
+
import { c, symbols } from "./theme";
|
|
14
|
+
|
|
15
|
+
const POLL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
/** Snapshot minimal untuk state daemon versi lama (tanpa `snapshot`). */
|
|
18
|
+
export function snapshotFromState(state: DaemonState): DashboardSnapshot {
|
|
19
|
+
const target = state.publicUrl
|
|
20
|
+
? { label: "public", url: state.publicUrl }
|
|
21
|
+
: { label: "local", url: state.localUrl };
|
|
22
|
+
return {
|
|
23
|
+
mode: "background",
|
|
24
|
+
port: state.port,
|
|
25
|
+
pid: state.pid,
|
|
26
|
+
memory: "—",
|
|
27
|
+
startedAt: state.startedAt,
|
|
28
|
+
localUrl: state.localUrl,
|
|
29
|
+
networkUrl: null,
|
|
30
|
+
target,
|
|
31
|
+
publicUrl: state.publicUrl,
|
|
32
|
+
tunnel: { text: state.publicUrl ? "online" : "—", color: state.publicUrl ? "green" : "gray" },
|
|
33
|
+
pairingUri: null,
|
|
34
|
+
tunnelDetail: {
|
|
35
|
+
provider: null,
|
|
36
|
+
kcg: { phase: "—", url: null },
|
|
37
|
+
lhr: null,
|
|
38
|
+
},
|
|
39
|
+
keepAwake: null,
|
|
40
|
+
sessions: 0,
|
|
41
|
+
running: 0,
|
|
42
|
+
projects: 0,
|
|
43
|
+
portNote: null,
|
|
44
|
+
restarting: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hints(): string {
|
|
49
|
+
return `${c.cyan("kcgcode")} ${c.dim("to reopen ·")} ${c.cyan("kcgcode stop")} ${c.dim("to stop")}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tampilkan dashboard untuk daemon yang berjalan. Hanya resolve bila user
|
|
54
|
+
* memilih "Run in foreground" — daemon sudah dihentikan, dan nilai yang
|
|
55
|
+
* dikembalikan adalah port yang dipakai daemon (untuk diteruskan pemanggil).
|
|
56
|
+
*/
|
|
57
|
+
export async function attachToDaemon(
|
|
58
|
+
args: CliArgs,
|
|
59
|
+
configPath: string,
|
|
60
|
+
initial: DaemonState,
|
|
61
|
+
): Promise<number> {
|
|
62
|
+
let current = initial;
|
|
63
|
+
let busyLabel: string | null = null;
|
|
64
|
+
|
|
65
|
+
const source = (): DashboardSnapshot => {
|
|
66
|
+
const base = current.snapshot ?? snapshotFromState(current);
|
|
67
|
+
return { ...base, mode: "background", restarting: busyLabel !== null, busyLabel };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return new Promise<number>((resolve) => {
|
|
71
|
+
let dash: { stop(): void } | null = null;
|
|
72
|
+
let poller: ReturnType<typeof setInterval> | null = null;
|
|
73
|
+
|
|
74
|
+
const leave = (lines: string[], code = 0): never => {
|
|
75
|
+
if (poller) clearInterval(poller);
|
|
76
|
+
dash?.stop();
|
|
77
|
+
for (const line of lines) console.log(line);
|
|
78
|
+
process.exit(code);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Ikuti state terbaru; bila daemon mati di luar kendali dashboard, keluar.
|
|
82
|
+
poller = setInterval(async () => {
|
|
83
|
+
if (busyLabel) return;
|
|
84
|
+
const state = await readDaemonState();
|
|
85
|
+
if (state && state.pid === current.pid && isAlive(state.pid)) {
|
|
86
|
+
current = state;
|
|
87
|
+
} else if (!isAlive(current.pid)) {
|
|
88
|
+
leave([`${c.yellow(symbols.warn)} kcgcode background server has stopped.`]);
|
|
89
|
+
}
|
|
90
|
+
}, POLL_MS);
|
|
91
|
+
|
|
92
|
+
const respawn = (port: number) => startDetached({ ...args, port, detach: true });
|
|
93
|
+
|
|
94
|
+
void startDashboard(source, {
|
|
95
|
+
onQuit: () =>
|
|
96
|
+
leave([
|
|
97
|
+
`${c.greenBright(symbols.check)} Dashboard closed — kcgcode keeps running in background.`,
|
|
98
|
+
` ${hints()}`,
|
|
99
|
+
]),
|
|
100
|
+
onOpenUrl: () => openBrowser(source().localUrl),
|
|
101
|
+
onStop: async () => {
|
|
102
|
+
busyLabel = "Stopping…";
|
|
103
|
+
const result = await stopDaemon(current);
|
|
104
|
+
leave([
|
|
105
|
+
result === "killed"
|
|
106
|
+
? `${c.yellow(symbols.warn)} kcgcode force-killed (did not exit within 10s).`
|
|
107
|
+
: `${c.greenBright(symbols.check)} kcgcode stopped.`,
|
|
108
|
+
]);
|
|
109
|
+
},
|
|
110
|
+
onForeground: async () => {
|
|
111
|
+
busyLabel = "Moving to this terminal…";
|
|
112
|
+
await stopDaemon(current);
|
|
113
|
+
if (poller) clearInterval(poller);
|
|
114
|
+
dash?.stop();
|
|
115
|
+
resolve(current.port);
|
|
116
|
+
},
|
|
117
|
+
onChangePort: async (next) => {
|
|
118
|
+
if (next !== current.port && !(await portIsFree(current.hostname, next))) {
|
|
119
|
+
return `Port ${next} is already in use`;
|
|
120
|
+
}
|
|
121
|
+
const saved = saveConfigPort(configPath, next);
|
|
122
|
+
if (!saved.ok) return saved.error;
|
|
123
|
+
if (next === current.port) return null;
|
|
124
|
+
|
|
125
|
+
const prev = current.port;
|
|
126
|
+
busyLabel = "Restarting…";
|
|
127
|
+
try {
|
|
128
|
+
await stopDaemon(current);
|
|
129
|
+
try {
|
|
130
|
+
current = await respawn(next);
|
|
131
|
+
return null;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Gagal di port baru → hidupkan lagi di port lama.
|
|
134
|
+
current = await respawn(prev);
|
|
135
|
+
return `Cannot start on port ${next}: ${(err as Error).message}`;
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
leave(
|
|
139
|
+
[
|
|
140
|
+
`${c.redBright(symbols.cross)} ${(err as Error).message}`,
|
|
141
|
+
` ${c.dim("Server is stopped. Run")} ${c.cyan("kcgcode")} ${c.dim("to start again.")}`,
|
|
142
|
+
],
|
|
143
|
+
1,
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
} finally {
|
|
147
|
+
busyLabel = null;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}).then((d) => {
|
|
151
|
+
dash = d;
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|