kcg-code 0.1.0 → 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 +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +876 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- 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__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -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/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
|
@@ -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
|
+
}
|
package/src/cli/commands.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { resolveConfig } from "../config";
|
|
6
|
+
import { resolveConfig, saveConfigPort } from "../config";
|
|
7
7
|
import { openSessionStore } from "../db";
|
|
8
8
|
import spa from "../index.html";
|
|
9
9
|
import {
|
|
@@ -19,9 +19,25 @@ import { createKcgServer } from "../server/app";
|
|
|
19
19
|
import { resolveHostname } from "../server/host";
|
|
20
20
|
import { createAuthService } from "../server/services/auth";
|
|
21
21
|
import type { CliArgs } from "./args";
|
|
22
|
-
import {
|
|
22
|
+
import { attachToDaemon } from "./attach";
|
|
23
|
+
import {
|
|
24
|
+
clearDaemonState,
|
|
25
|
+
daemonLogPath,
|
|
26
|
+
isDaemonChild,
|
|
27
|
+
printAlreadyRunning,
|
|
28
|
+
printStarted,
|
|
29
|
+
runningDaemon,
|
|
30
|
+
startDetached,
|
|
31
|
+
writeDaemonState,
|
|
32
|
+
} from "./daemon";
|
|
33
|
+
import { type DashboardInfo, openBrowser, startDashboard, takeSnapshot } from "./dashboard";
|
|
34
|
+
import { disabledKeepAwake, startKeepAwake } from "./keep-awake";
|
|
35
|
+
import { choosePort, portIsFree, requestedPort } from "./port";
|
|
23
36
|
import { c, panel, row, symbols } from "./theme";
|
|
24
37
|
|
|
38
|
+
/** Interval daemon menulis state + snapshot dashboard. */
|
|
39
|
+
const DAEMON_STATE_MS = 2000;
|
|
40
|
+
|
|
25
41
|
const SPA_PATHS = ["/", "/projects", "/projects/*", "/skills", "/settings", "/settings/*"];
|
|
26
42
|
|
|
27
43
|
function applyEnvOverrides(args: CliArgs): void {
|
|
@@ -43,33 +59,93 @@ export async function runStart(args: CliArgs): Promise<void> {
|
|
|
43
59
|
if (process.env.NODE_ENV === undefined) {
|
|
44
60
|
process.env.NODE_ENV = "production";
|
|
45
61
|
}
|
|
62
|
+
// Baca KCG_PORT sebelum di-override `--port`, untuk membedakan asal port.
|
|
63
|
+
const envPort = process.env.KCG_PORT;
|
|
46
64
|
applyEnvOverrides(args);
|
|
47
65
|
|
|
48
66
|
const cfg = resolveConfig();
|
|
49
67
|
if (!cfg.ok) failConfig(cfg.error);
|
|
68
|
+
const config = cfg.data;
|
|
69
|
+
|
|
70
|
+
const daemonChild = isDaemonChild();
|
|
71
|
+
// Port daemon yang dipindah ke terminal ini ("Run in foreground").
|
|
72
|
+
let takeoverPort: number | undefined;
|
|
73
|
+
if (!daemonChild) {
|
|
74
|
+
const existing = await runningDaemon();
|
|
75
|
+
if (existing) {
|
|
76
|
+
if (args.detach || !process.stdout.isTTY) {
|
|
77
|
+
printAlreadyRunning(existing);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// Sudah jalan di background → buka dashboard untuk daemon tersebut.
|
|
81
|
+
takeoverPort = await attachToDaemon(args, config.configPath, existing);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
50
84
|
|
|
51
|
-
const port = args.port ?? Number(process.env.KCG_PORT ?? 3000);
|
|
52
85
|
const hostname = args.host ?? resolveHostname();
|
|
86
|
+
const choice = await choosePort(
|
|
87
|
+
hostname,
|
|
88
|
+
takeoverPort !== undefined
|
|
89
|
+
? { port: takeoverPort, source: "config" }
|
|
90
|
+
: requestedPort({ flag: args.port, env: envPort, config: config.port }),
|
|
91
|
+
);
|
|
92
|
+
if (!choice.ok) {
|
|
93
|
+
console.error(`${c.redBright(symbols.cross)} ${choice.error}`);
|
|
94
|
+
console.error(` ${c.dim("Pick another with")} ${c.cyan("--port <n>")}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const portNote = choice.fallbackFrom ? `${choice.fallbackFrom} in use` : null;
|
|
98
|
+
|
|
99
|
+
if (args.detach && !daemonChild) {
|
|
100
|
+
if (portNote) console.log(`${c.yellow(symbols.warn)} Port ${portNote} — using ${choice.port}.`);
|
|
101
|
+
try {
|
|
102
|
+
// Port final dikirim eksplisit agar anak tidak memilih ulang.
|
|
103
|
+
const state = await startDetached({ ...args, port: choice.port });
|
|
104
|
+
printStarted(state);
|
|
105
|
+
if (args.open) await openBrowser(state.localUrl);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`${c.redBright(symbols.cross)} ${(err as Error).message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
53
112
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
113
|
+
const boot = (port: number) =>
|
|
114
|
+
createKcgServer({
|
|
115
|
+
config,
|
|
116
|
+
spa,
|
|
117
|
+
spaPaths: SPA_PATHS,
|
|
118
|
+
port,
|
|
119
|
+
hostname,
|
|
120
|
+
uploadsRoot: resolveEffectiveUploadsDir(),
|
|
121
|
+
});
|
|
122
|
+
const localUrl = (port: number) =>
|
|
123
|
+
`http://${hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname}:${port}`;
|
|
124
|
+
|
|
125
|
+
let app = boot(choice.port);
|
|
126
|
+
const info: DashboardInfo = {
|
|
127
|
+
server: app,
|
|
128
|
+
port: app.server.port ?? choice.port,
|
|
59
129
|
hostname,
|
|
60
|
-
|
|
61
|
-
|
|
130
|
+
portNote,
|
|
131
|
+
startedAt: Date.now(),
|
|
132
|
+
};
|
|
62
133
|
|
|
63
|
-
const
|
|
64
|
-
const
|
|
134
|
+
const allowSleep = args.allowSleep || process.env.KCG_ALLOW_SLEEP === "1";
|
|
135
|
+
const keepAwake = allowSleep ? disabledKeepAwake() : await startKeepAwake();
|
|
136
|
+
info.keepAwake = keepAwake;
|
|
65
137
|
|
|
66
138
|
let shuttingDown = false;
|
|
67
139
|
let dash: { stop(): void } | null = null;
|
|
140
|
+
let stateTimer: ReturnType<typeof setInterval> | null = null;
|
|
68
141
|
|
|
69
142
|
async function shutdown(reason: string): Promise<void> {
|
|
70
143
|
if (shuttingDown) return;
|
|
71
144
|
shuttingDown = true;
|
|
72
145
|
dash?.stop();
|
|
146
|
+
if (stateTimer) clearInterval(stateTimer);
|
|
147
|
+
if (daemonChild) clearDaemonState(process.pid);
|
|
148
|
+
keepAwake.stop();
|
|
73
149
|
console.log(`\n${c.dim(`(${reason})`)} ${c.yellow("Saving session state and stopping…")}`);
|
|
74
150
|
try {
|
|
75
151
|
await app.close();
|
|
@@ -80,16 +156,93 @@ export async function runStart(args: CliArgs): Promise<void> {
|
|
|
80
156
|
process.exit(0);
|
|
81
157
|
}
|
|
82
158
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
port
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
159
|
+
/** Simpan port ke config lalu restart server di port tersebut. Null = sukses. */
|
|
160
|
+
async function changePort(next: number): Promise<string | null> {
|
|
161
|
+
if (next !== info.port && !(await portIsFree(hostname, next))) {
|
|
162
|
+
return `Port ${next} is already in use`;
|
|
163
|
+
}
|
|
164
|
+
const saved = saveConfigPort(config.configPath, next);
|
|
165
|
+
if (!saved.ok) return saved.error;
|
|
166
|
+
if (next === info.port) {
|
|
167
|
+
info.portNote = null;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
info.restarting = true;
|
|
172
|
+
const prev = info.port;
|
|
173
|
+
try {
|
|
174
|
+
await app.close();
|
|
175
|
+
try {
|
|
176
|
+
app = boot(next);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Gagal bind di port baru → kembali ke port lama agar server tetap hidup.
|
|
179
|
+
app = boot(prev);
|
|
180
|
+
info.server = app;
|
|
181
|
+
return `Cannot start on port ${next}: ${(err as Error).message}`;
|
|
182
|
+
}
|
|
183
|
+
info.server = app;
|
|
184
|
+
info.port = app.server.port ?? next;
|
|
185
|
+
info.portNote = null;
|
|
186
|
+
return null;
|
|
187
|
+
} finally {
|
|
188
|
+
info.restarting = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Pindahkan server ke daemon: tutup server foreground, spawn ulang di background. */
|
|
193
|
+
async function moveToBackground(): Promise<void> {
|
|
194
|
+
if (shuttingDown) return;
|
|
195
|
+
shuttingDown = true;
|
|
196
|
+
dash?.stop();
|
|
197
|
+
keepAwake.stop();
|
|
198
|
+
console.log(c.yellow("Moving kcgcode to background…"));
|
|
199
|
+
try {
|
|
200
|
+
await app.close();
|
|
201
|
+
const state = await startDetached({ ...args, port: info.port, detach: true });
|
|
202
|
+
printStarted(state);
|
|
203
|
+
process.exit(0);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(`${c.redBright(symbols.cross)} ${(err as Error).message}`);
|
|
206
|
+
console.error(
|
|
207
|
+
` ${c.dim("Server is stopped. Run")} ${c.cyan("kcgcode")} ${c.dim("to start again.")}`,
|
|
208
|
+
);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
90
212
|
|
|
91
|
-
if (
|
|
92
|
-
|
|
213
|
+
if (daemonChild) {
|
|
214
|
+
// Tanpa dashboard: tulis state + snapshot dashboard berkala untuk
|
|
215
|
+
// `status`/`stop` dan dashboard yang memantau daemon (`kcgcode`).
|
|
216
|
+
const writeState = () => {
|
|
217
|
+
const snapshot = takeSnapshot(info, "background");
|
|
218
|
+
return writeDaemonState({
|
|
219
|
+
pid: process.pid,
|
|
220
|
+
port: info.port,
|
|
221
|
+
hostname,
|
|
222
|
+
localUrl: localUrl(info.port),
|
|
223
|
+
publicUrl: snapshot.publicUrl,
|
|
224
|
+
startedAt: snapshot.startedAt,
|
|
225
|
+
logFile: daemonLogPath(),
|
|
226
|
+
snapshot,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
await writeState();
|
|
230
|
+
stateTimer = setInterval(() => void writeState(), DAEMON_STATE_MS);
|
|
231
|
+
// Terminal asal ditutup tidak boleh menghentikan server.
|
|
232
|
+
process.on("SIGHUP", () => {});
|
|
233
|
+
console.log(`kcgcode running in background · pid ${process.pid} · ${localUrl(info.port)}`);
|
|
234
|
+
} else {
|
|
235
|
+
// `info` dimutasi saat restart port; dashboard membacanya ulang tiap render.
|
|
236
|
+
dash = await startDashboard(() => takeSnapshot(info), {
|
|
237
|
+
onQuit: (reason) => void shutdown(reason),
|
|
238
|
+
onOpenUrl: () => openBrowser(localUrl(info.port)),
|
|
239
|
+
onChangePort: changePort,
|
|
240
|
+
onBackground: () => void moveToBackground(),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (args.open) {
|
|
244
|
+
void openBrowser(localUrl(info.port));
|
|
245
|
+
}
|
|
93
246
|
}
|
|
94
247
|
|
|
95
248
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode background (daemon) untuk kcgcode.
|
|
3
|
+
*
|
|
4
|
+
* `kcgcode start -d` men-spawn proses anak yang lepas dari terminal
|
|
5
|
+
* (session baru via `detached`, stdio ke file log), lalu parent keluar.
|
|
6
|
+
* Menutup terminal tidak membunuh server.
|
|
7
|
+
*
|
|
8
|
+
* State daemon disimpan di `<data>/daemon.json` (samping SQLite, ikut mode
|
|
9
|
+
* runtime) — dipakai `status`, `stop`, `logs`, dan cek single-instance.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
closeSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
renameSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { resolveEffectiveDbPath } from "../paths";
|
|
25
|
+
import type { CliArgs } from "./args";
|
|
26
|
+
import { type DashboardSnapshot, formatUptime } from "./dashboard";
|
|
27
|
+
import { c, panel, row, symbols } from "./theme";
|
|
28
|
+
|
|
29
|
+
/** Penanda env untuk proses anak daemon. */
|
|
30
|
+
export const DAEMON_CHILD_ENV = "KCG_DAEMON_CHILD";
|
|
31
|
+
|
|
32
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
33
|
+
const STOP_TIMEOUT_MS = 10_000;
|
|
34
|
+
const POLL_MS = 150;
|
|
35
|
+
/** Log dirotasi ke `.1` saat start bila melewati ukuran ini. */
|
|
36
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
export interface DaemonState {
|
|
39
|
+
pid: number;
|
|
40
|
+
port: number;
|
|
41
|
+
hostname: string;
|
|
42
|
+
localUrl: string;
|
|
43
|
+
/** URL publik tunnel terakhir yang diketahui (diperbarui berkala). */
|
|
44
|
+
publicUrl: string | null;
|
|
45
|
+
startedAt: number;
|
|
46
|
+
logFile: string;
|
|
47
|
+
/** Data dashboard terbaru — dibaca dashboard yang memantau daemon. */
|
|
48
|
+
snapshot?: DashboardSnapshot;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isDaemonChild(): boolean {
|
|
52
|
+
return process.env[DAEMON_CHILD_ENV] === "1";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runDir(): string {
|
|
56
|
+
return path.dirname(resolveEffectiveDbPath());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function daemonStatePath(): string {
|
|
60
|
+
return path.join(runDir(), "daemon.json");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function daemonLogPath(): string {
|
|
64
|
+
return path.join(runDir(), "kcgcode.log");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** true bila proses dengan pid ini masih hidup. */
|
|
68
|
+
export function isAlive(pid: number): boolean {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// EPERM = proses ada tapi milik user lain.
|
|
74
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readDaemonState(): Promise<DaemonState | null> {
|
|
79
|
+
const file = Bun.file(daemonStatePath());
|
|
80
|
+
if (!(await file.exists())) return null;
|
|
81
|
+
try {
|
|
82
|
+
return (await file.json()) as DaemonState;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** State daemon yang prosesnya masih hidup; state basi dibersihkan. */
|
|
89
|
+
export async function runningDaemon(): Promise<DaemonState | null> {
|
|
90
|
+
const state = await readDaemonState();
|
|
91
|
+
if (!state) return null;
|
|
92
|
+
if (isAlive(state.pid)) return state;
|
|
93
|
+
clearDaemonState();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function writeDaemonState(state: DaemonState): Promise<void> {
|
|
98
|
+
await Bun.write(daemonStatePath(), `${JSON.stringify(state, null, 2)}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Hapus state — hanya bila milik pid ini (default: proses sekarang). */
|
|
102
|
+
export function clearDaemonState(ownerPid?: number): void {
|
|
103
|
+
const p = daemonStatePath();
|
|
104
|
+
if (!existsSync(p)) return;
|
|
105
|
+
if (ownerPid !== undefined) {
|
|
106
|
+
try {
|
|
107
|
+
const state = JSON.parse(readFileSync(p, "utf8")) as DaemonState;
|
|
108
|
+
if (state.pid !== ownerPid) return;
|
|
109
|
+
} catch {
|
|
110
|
+
// state rusak — hapus saja
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
rmSync(p, { force: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Argumen yang diteruskan ke proses anak (tanpa -d / --open). */
|
|
117
|
+
export function childArgv(args: CliArgs): string[] {
|
|
118
|
+
const out = ["start"];
|
|
119
|
+
if (args.port !== undefined) out.push("--port", String(args.port));
|
|
120
|
+
if (args.host !== undefined) out.push("--host", args.host);
|
|
121
|
+
if (args.config !== undefined) out.push("--config", path.resolve(args.config));
|
|
122
|
+
if (args.allowSleep) out.push("--allow-sleep");
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rotateLog(logFile: string): void {
|
|
127
|
+
try {
|
|
128
|
+
if (statSync(logFile).size > MAX_LOG_BYTES) renameSync(logFile, `${logFile}.1`);
|
|
129
|
+
} catch {
|
|
130
|
+
// belum ada log
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function tailLines(file: string, n: number): Promise<string[]> {
|
|
135
|
+
const f = Bun.file(file);
|
|
136
|
+
if (!(await f.exists())) return [];
|
|
137
|
+
const size = f.size;
|
|
138
|
+
const text = await f.slice(Math.max(0, size - 64 * 1024), size).text();
|
|
139
|
+
return text
|
|
140
|
+
.split("\n")
|
|
141
|
+
.filter((l) => l.trim() !== "")
|
|
142
|
+
.slice(-n);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Spawn server di background lalu tunggu sampai siap. */
|
|
146
|
+
export async function startDetached(args: CliArgs): Promise<DaemonState> {
|
|
147
|
+
const logFile = daemonLogPath();
|
|
148
|
+
mkdirSync(runDir(), { recursive: true });
|
|
149
|
+
rotateLog(logFile);
|
|
150
|
+
const fd = openSync(logFile, "a");
|
|
151
|
+
writeFileSync(fd, `\n=== kcgcode start ${new Date().toISOString()} ===\n`);
|
|
152
|
+
|
|
153
|
+
const script = process.argv[1] as string;
|
|
154
|
+
const child = spawn(process.execPath, [script, ...childArgv(args)], {
|
|
155
|
+
detached: true,
|
|
156
|
+
stdio: ["ignore", fd, fd],
|
|
157
|
+
env: { ...process.env, [DAEMON_CHILD_ENV]: "1" },
|
|
158
|
+
cwd: process.cwd(),
|
|
159
|
+
windowsHide: true,
|
|
160
|
+
});
|
|
161
|
+
child.unref();
|
|
162
|
+
closeSync(fd);
|
|
163
|
+
|
|
164
|
+
const pid = child.pid;
|
|
165
|
+
if (!pid) throw new Error("failed to spawn background process");
|
|
166
|
+
|
|
167
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
168
|
+
while (Date.now() < deadline) {
|
|
169
|
+
await Bun.sleep(POLL_MS);
|
|
170
|
+
const state = await readDaemonState();
|
|
171
|
+
if (state?.pid === pid) return state;
|
|
172
|
+
if (!isAlive(pid)) {
|
|
173
|
+
const tail = await tailLines(logFile, 8);
|
|
174
|
+
throw new Error(
|
|
175
|
+
`background process exited during startup${tail.length ? `:\n${tail.join("\n")}` : ""}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`server not ready after ${READY_TIMEOUT_MS / 1000}s — check ${logFile}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function statePanel(state: DaemonState, title: string): string {
|
|
183
|
+
return panel(title, [
|
|
184
|
+
row("Status", c.greenBright(`${symbols.bullet} running in background`)),
|
|
185
|
+
row("Local", c.cyan(state.localUrl)),
|
|
186
|
+
...(state.publicUrl ? [row("Public", c.cyanBright(state.publicUrl))] : []),
|
|
187
|
+
row("PID", c.white(String(state.pid))),
|
|
188
|
+
row("Uptime", c.white(formatUptime(Date.now() - state.startedAt))),
|
|
189
|
+
row("Log", c.dim(state.logFile)),
|
|
190
|
+
"",
|
|
191
|
+
c.dim(`${c.cyan("kcgcode status")} · ${c.cyan("kcgcode logs -f")} · ${c.cyan("kcgcode stop")}`),
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function printStarted(state: DaemonState): void {
|
|
196
|
+
console.log(statePanel(state, "KCGCODE"));
|
|
197
|
+
console.log(c.dim("You can close this terminal — the server keeps running."));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Pesan saat instance lain sudah jalan di background. */
|
|
201
|
+
export function printAlreadyRunning(state: DaemonState): void {
|
|
202
|
+
console.error(
|
|
203
|
+
`${c.yellowBright(symbols.warn)} kcgcode is already running in background ${c.dim(`(pid ${state.pid})`)}`,
|
|
204
|
+
);
|
|
205
|
+
console.error(` ${c.dim("Open")} ${c.cyan(state.publicUrl ?? state.localUrl)}`);
|
|
206
|
+
console.error(` ${c.dim("Stop it first with")} ${c.cyan("kcgcode stop")}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function runStatus(): Promise<void> {
|
|
210
|
+
const state = await runningDaemon();
|
|
211
|
+
if (!state) {
|
|
212
|
+
console.log(`${c.dim(symbols.idle)} kcgcode is ${c.yellow("not running")} in background.`);
|
|
213
|
+
console.log(` ${c.dim("Start it with")} ${c.cyan("kcgcode start -d")}`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.log(statePanel(state, "KCGCODE"));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Kirim SIGTERM lalu tunggu; SIGKILL bila tidak keluar dalam batas waktu. */
|
|
221
|
+
export async function stopDaemon(state: DaemonState): Promise<"stopped" | "killed"> {
|
|
222
|
+
try {
|
|
223
|
+
process.kill(state.pid, "SIGTERM");
|
|
224
|
+
} catch {
|
|
225
|
+
// sudah mati
|
|
226
|
+
}
|
|
227
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
228
|
+
while (Date.now() < deadline && isAlive(state.pid)) await Bun.sleep(POLL_MS);
|
|
229
|
+
|
|
230
|
+
let result: "stopped" | "killed" = "stopped";
|
|
231
|
+
if (isAlive(state.pid)) {
|
|
232
|
+
process.kill(state.pid, "SIGKILL");
|
|
233
|
+
result = "killed";
|
|
234
|
+
}
|
|
235
|
+
clearDaemonState(state.pid);
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function runStop(): Promise<void> {
|
|
240
|
+
const state = await runningDaemon();
|
|
241
|
+
if (!state) {
|
|
242
|
+
console.log(`${c.dim(symbols.idle)} kcgcode is not running in background.`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
process.stdout.write(`${c.yellow("Stopping")} kcgcode ${c.dim(`(pid ${state.pid})`)}…`);
|
|
246
|
+
const result = await stopDaemon(state);
|
|
247
|
+
console.log(
|
|
248
|
+
result === "killed"
|
|
249
|
+
? ` ${c.yellow("force-killed")} ${c.dim("(did not exit within 10s)")}`
|
|
250
|
+
: ` ${c.greenBright(symbols.check)} stopped.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function runLogs(args: CliArgs): Promise<void> {
|
|
255
|
+
const logFile = daemonLogPath();
|
|
256
|
+
if (!existsSync(logFile)) {
|
|
257
|
+
console.log(`${c.dim("No log yet at")} ${c.cyan(logFile)}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const line of await tailLines(logFile, args.lines ?? 50)) console.log(line);
|
|
261
|
+
if (!args.follow) return;
|
|
262
|
+
|
|
263
|
+
// Follow: baca byte baru secara berkala (rotasi → mulai dari awal).
|
|
264
|
+
let offset = Bun.file(logFile).size;
|
|
265
|
+
for (;;) {
|
|
266
|
+
await Bun.sleep(500);
|
|
267
|
+
const f = Bun.file(logFile);
|
|
268
|
+
const size = f.size;
|
|
269
|
+
if (size < offset) offset = 0;
|
|
270
|
+
if (size > offset) {
|
|
271
|
+
process.stdout.write(await f.slice(offset, size).text());
|
|
272
|
+
offset = size;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|