@nessielabs/daemon 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/dist/cli.js CHANGED
@@ -7,12 +7,13 @@ const program = new Command();
7
7
  program
8
8
  .name("nessie-daemon")
9
9
  .description("Headless Nessie ingestion daemon for Linux agent machines.")
10
- .version("0.1.0");
10
+ .version("0.2.0");
11
11
  program
12
12
  .command("setup")
13
13
  .description("Authenticate, select local sources, and optionally install the user systemd service.")
14
14
  .option("--api-url <url>", "Nessie sync API URL")
15
15
  .option("--auth-api-url <url>", "Nessie auth API URL")
16
+ .option("--api-key <key>", "Use a Nessie API key for non-interactive headless setup.")
16
17
  .action(runSetup);
17
18
  program
18
19
  .command("run")
@@ -20,10 +21,15 @@ program
20
21
  .option("--once", "Run one sync pass and exit.")
21
22
  .option("--interval-seconds <seconds>", "Seconds between sync passes.", "60")
22
23
  .action(runDaemon);
23
- for (const commandName of ["start", "stop", "status"]) {
24
+ program
25
+ .command("start")
26
+ .description("Start the daemon.")
27
+ .option("--background", "Start as a detached background process without systemd.")
28
+ .action((options) => runServiceCommand("start", options));
29
+ for (const commandName of ["stop", "status"]) {
24
30
  program
25
31
  .command(commandName)
26
- .description(`${commandName[0]?.toUpperCase()}${commandName.slice(1)} the user systemd service.`)
32
+ .description(`${commandName[0]?.toUpperCase()}${commandName.slice(1)} the daemon.`)
27
33
  .action(() => runServiceCommand(commandName));
28
34
  }
29
35
  await program.parseAsync();
@@ -1,6 +1,45 @@
1
+ import { readConfig } from "../config/store.js";
2
+ import { backgroundDaemonStatus, startBackgroundDaemon, stopBackgroundDaemon } from "../service/process.js";
1
3
  import { systemctl } from "../service/systemd.js";
2
- export async function runServiceCommand(command) {
3
- const output = await systemctl(command, "nessie-daemon.service");
4
+ const defaultServiceDeps = {
5
+ backgroundDaemonStatus,
6
+ startBackgroundDaemon,
7
+ stopBackgroundDaemon,
8
+ systemctl,
9
+ readConfig,
10
+ };
11
+ export async function runServiceCommand(command, options = {}, depsOverride = {}) {
12
+ const deps = { ...defaultServiceDeps, ...depsOverride };
13
+ if (command === "start" && await shouldStartBackground(options, deps)) {
14
+ console.log(await deps.startBackgroundDaemon());
15
+ return;
16
+ }
17
+ if (command === "stop") {
18
+ const status = await deps.backgroundDaemonStatus();
19
+ if (status !== null) {
20
+ console.log(await deps.stopBackgroundDaemon());
21
+ return;
22
+ }
23
+ }
24
+ if (command === "status") {
25
+ const status = await deps.backgroundDaemonStatus();
26
+ if (status !== null) {
27
+ console.log(status);
28
+ return;
29
+ }
30
+ }
31
+ const output = await deps.systemctl(command, "nessie-daemon.service");
4
32
  if (output.trim())
5
33
  console.log(output.trim());
6
34
  }
35
+ async function shouldStartBackground(options, deps) {
36
+ if (options.background)
37
+ return true;
38
+ try {
39
+ const config = await deps.readConfig();
40
+ return Boolean(config.apiKey);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
@@ -5,12 +5,23 @@ import { getDaemonPaths } from "../config/paths.js";
5
5
  import { createConfig, createInitialState, defaultApiUrl, defaultAuthApiUrl, writeConfig, writeState } from "../config/store.js";
6
6
  import { installUserService } from "../service/systemd.js";
7
7
  import { detectSources } from "../sources/detect.js";
8
- export async function runSetup(options) {
8
+ export async function runSetup(options, depsOverride = {}) {
9
+ const deps = resolveSetupDeps(depsOverride);
9
10
  const apiUrl = options.apiUrl ?? defaultApiUrl;
10
11
  const authApiUrl = options.authApiUrl ?? defaultAuthApiUrl;
11
12
  const deviceName = hostname();
13
+ if (options.apiKey !== undefined) {
14
+ await runAPIKeySetup({
15
+ apiKey: options.apiKey,
16
+ apiUrl,
17
+ authApiUrl,
18
+ deviceName,
19
+ deps,
20
+ });
21
+ return;
22
+ }
12
23
  const auth = await runDeviceAuth({ authApiUrl, deviceName });
13
- const detectedSources = await detectSources();
24
+ const detectedSources = await deps.detectSources();
14
25
  if (detectedSources.length === 0) {
15
26
  throw new Error("No supported Codex or Claude Code history directories were found.");
16
27
  }
@@ -23,7 +34,6 @@ export async function runSetup(options) {
23
34
  })),
24
35
  required: true,
25
36
  });
26
- const paths = getDaemonPaths();
27
37
  const config = createConfig({
28
38
  accessToken: auth.accessToken,
29
39
  refreshToken: auth.refreshToken,
@@ -33,11 +43,44 @@ export async function runSetup(options) {
33
43
  authApiUrl,
34
44
  selectedSources,
35
45
  });
36
- await writeConfig(config, paths);
37
- await writeState(createInitialState(), paths);
38
- console.log(`Saved config to ${paths.configFile}`);
46
+ await deps.writeConfig(config, deps.paths);
47
+ await deps.writeState(createInitialState(), deps.paths);
48
+ console.log(`Saved config to ${deps.paths.configFile}`);
39
49
  if (await confirm({ message: "Install and start the user systemd service?", default: true })) {
40
- await installUserService(paths);
50
+ await installUserService(deps.paths);
41
51
  console.log("Installed and started nessie-daemon.service");
42
52
  }
43
53
  }
54
+ async function runAPIKeySetup(input) {
55
+ const apiKey = input.apiKey.trim();
56
+ if (!apiKey)
57
+ throw new Error("--api-key cannot be empty.");
58
+ const detectedSources = await input.deps.detectSources();
59
+ if (detectedSources.length === 0) {
60
+ throw new Error("No supported Codex or Claude Code history directories were found.");
61
+ }
62
+ const config = createConfig({
63
+ apiKey,
64
+ deviceName: input.deviceName,
65
+ apiUrl: input.apiUrl,
66
+ authApiUrl: input.authApiUrl,
67
+ selectedSources: detectedSources,
68
+ });
69
+ await input.deps.writeConfig(config, input.deps.paths);
70
+ await input.deps.writeState(createInitialState(), input.deps.paths);
71
+ console.log("Configured Nessie daemon with API key auth.");
72
+ console.log(`Selected ${detectedSources.length} source(s):`);
73
+ for (const source of detectedSources) {
74
+ console.log(`- ${source.displayName} (${source.basePath})`);
75
+ }
76
+ console.log(`Saved config to ${input.deps.paths.configFile}`);
77
+ console.log("Run `nessie-daemon start` to start syncing.");
78
+ }
79
+ function resolveSetupDeps(overrides) {
80
+ return {
81
+ detectSources: overrides.detectSources ?? detectSources,
82
+ writeConfig: overrides.writeConfig ?? writeConfig,
83
+ writeState: overrides.writeState ?? writeState,
84
+ paths: overrides.paths ?? getDaemonPaths(),
85
+ };
86
+ }
@@ -11,6 +11,8 @@ export function getDaemonPaths(env = process.env, home = homedir()) {
11
11
  stateDir,
12
12
  configFile: join(configDir, "config.json"),
13
13
  stateFile: join(stateDir, "state.json"),
14
+ pidFile: join(stateDir, "daemon.pid"),
15
+ logFile: join(stateDir, "daemon.log"),
14
16
  systemdUserDir,
15
17
  systemdServiceFile: join(systemdUserDir, "nessie-daemon.service"),
16
18
  };
@@ -14,6 +14,7 @@ export function createConfig(input) {
14
14
  deviceName: input.deviceName ?? hostname(),
15
15
  accessToken: input.accessToken,
16
16
  refreshToken: input.refreshToken ?? null,
17
+ apiKey: input.apiKey ?? null,
17
18
  selectedSources: input.selectedSources.map((source) => ({
18
19
  ...source,
19
20
  localId: randomUUID(),
@@ -0,0 +1,134 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
4
+ import { getDaemonPaths } from "../config/paths.js";
5
+ const defaultProcessDeps = {
6
+ spawn,
7
+ argv: process.argv,
8
+ execPath: process.execPath,
9
+ kill: process.kill,
10
+ readProcessCmdline,
11
+ };
12
+ export async function startBackgroundDaemon(paths = getDaemonPaths(), depsOverride = {}) {
13
+ const deps = resolveProcessDeps(depsOverride);
14
+ const existingPid = await readPid(paths);
15
+ if (existingPid && await isDaemonRunning(existingPid, deps)) {
16
+ return `nessie-daemon is already running with pid ${existingPid}.`;
17
+ }
18
+ if (existingPid)
19
+ await removePid(paths);
20
+ await mkdir(paths.stateDir, { recursive: true });
21
+ if (!await reservePidFile(paths))
22
+ return "nessie-daemon is already starting.";
23
+ const out = openSync(paths.logFile, "a");
24
+ const err = openSync(paths.logFile, "a");
25
+ let child;
26
+ try {
27
+ child = deps.spawn(deps.execPath, [cliPath(deps.argv), "run"], {
28
+ detached: true,
29
+ stdio: ["ignore", out, err],
30
+ });
31
+ child.unref();
32
+ }
33
+ finally {
34
+ closeSync(out);
35
+ closeSync(err);
36
+ }
37
+ if (!child.pid) {
38
+ await removePid(paths);
39
+ throw new Error("Failed to start nessie-daemon in background.");
40
+ }
41
+ await writeFile(paths.pidFile, `${child.pid}\n`, { mode: 0o600 });
42
+ return `Started nessie-daemon with pid ${child.pid}. Logs: ${paths.logFile}`;
43
+ }
44
+ export async function stopBackgroundDaemon(paths = getDaemonPaths(), depsOverride = {}) {
45
+ const deps = resolveProcessDeps(depsOverride);
46
+ const pid = await readPid(paths);
47
+ if (!pid)
48
+ return "nessie-daemon is not running.";
49
+ if (!await isDaemonRunning(pid, deps)) {
50
+ await removePid(paths);
51
+ return "nessie-daemon is not running.";
52
+ }
53
+ deps.kill(pid, "SIGTERM");
54
+ await removePid(paths);
55
+ return `Stopped nessie-daemon with pid ${pid}.`;
56
+ }
57
+ export async function backgroundDaemonStatus(paths = getDaemonPaths(), depsOverride = {}) {
58
+ const deps = resolveProcessDeps(depsOverride);
59
+ const pid = await readPid(paths);
60
+ if (!pid)
61
+ return null;
62
+ if (!await isDaemonRunning(pid, deps)) {
63
+ await removePid(paths);
64
+ return "nessie-daemon is not running.";
65
+ }
66
+ return `nessie-daemon is running with pid ${pid}. Logs: ${paths.logFile}`;
67
+ }
68
+ async function readPid(paths) {
69
+ try {
70
+ const raw = await readFile(paths.pidFile, "utf8");
71
+ const pid = Number.parseInt(raw.trim(), 10);
72
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
73
+ }
74
+ catch (error) {
75
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
76
+ return null;
77
+ throw error;
78
+ }
79
+ }
80
+ async function removePid(paths) {
81
+ try {
82
+ await unlink(paths.pidFile);
83
+ }
84
+ catch (error) {
85
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
86
+ return;
87
+ throw error;
88
+ }
89
+ }
90
+ async function reservePidFile(paths) {
91
+ try {
92
+ await writeFile(paths.pidFile, "starting\n", { mode: 0o600, flag: "wx" });
93
+ return true;
94
+ }
95
+ catch (error) {
96
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST")
97
+ return false;
98
+ throw error;
99
+ }
100
+ }
101
+ async function isDaemonRunning(pid, deps) {
102
+ try {
103
+ deps.kill(pid, 0);
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ const cmdline = await deps.readProcessCmdline(pid);
109
+ if (cmdline === null)
110
+ return false;
111
+ return cmdline.includes(cliPath(deps.argv)) && cmdline.includes(" run");
112
+ }
113
+ async function readProcessCmdline(pid) {
114
+ try {
115
+ const raw = await readFile(`/proc/${pid}/cmdline`);
116
+ const cmdline = raw.toString("utf8").replace(/\0/g, " ").trim();
117
+ return cmdline || null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ function resolveProcessDeps(overrides) {
124
+ return {
125
+ ...defaultProcessDeps,
126
+ ...overrides,
127
+ };
128
+ }
129
+ function cliPath(argv) {
130
+ const path = argv[1];
131
+ if (!path)
132
+ throw new Error("Cannot determine nessie-daemon CLI path.");
133
+ return path;
134
+ }
@@ -22,16 +22,25 @@ export async function pushSyncPayload(config, payload, options = {}) {
22
22
  }, options);
23
23
  }
24
24
  async function postJsonWithRefresh(config, path, body, options) {
25
+ const token = authToken(config);
25
26
  try {
26
- await postJson(config.apiUrl, path, body, { token: config.accessToken, fetchImpl: options.fetchImpl });
27
+ await postJson(config.apiUrl, path, body, { token, fetchImpl: options.fetchImpl });
27
28
  return config;
28
29
  }
29
30
  catch (error) {
31
+ if (config.apiKey)
32
+ throw error;
30
33
  if (!(error instanceof ApiError) || error.status !== 401)
31
34
  throw error;
32
35
  }
33
36
  const refreshed = await refreshAccessToken(config, { fetchImpl: options.fetchImpl });
34
37
  await (options.persistConfig ?? writeConfig)(refreshed);
35
- await postJson(refreshed.apiUrl, path, body, { token: refreshed.accessToken, fetchImpl: options.fetchImpl });
38
+ await postJson(refreshed.apiUrl, path, body, { token: authToken(refreshed), fetchImpl: options.fetchImpl });
36
39
  return refreshed;
37
40
  }
41
+ function authToken(config) {
42
+ const token = config.apiKey ?? config.accessToken;
43
+ if (!token)
44
+ throw new Error("Daemon config is missing auth credentials. Run nessie-daemon setup.");
45
+ return token;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nessielabs/daemon",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Headless Linux ingestion daemon for Nessie agent traces.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",