iteration-cli 0.6.1 → 0.7.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 +1 -1
- package/dist/auto-connect.js +2 -2
- package/dist/background-service.d.ts +7 -0
- package/dist/background-service.js +37 -0
- package/dist/cli.js +20 -26
- package/dist/history-import.js +6 -6
- package/dist/status-view.js +5 -5
- package/dist/systemd-user.d.ts +7 -0
- package/dist/systemd-user.js +137 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ npm install --global iteration-cli
|
|
|
7
7
|
iteration connect
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
The collectors
|
|
10
|
+
The collectors run on macOS or Linux with Node.js 22 or later. Linux needs systemd. The CLI automatically enables background collection after logout and installs a user service that starts at boot and restarts on failure. If server policy does not let the user enable this, an administrator must allow background services for the account. The setting applies to the whole Linux account and remains enabled after `iteration disconnect`; the CLI does not turn off other user services. `iteration connect` finds supported agents with local session directories, asks once before uploading their saved sessions and future activity, and connects them to one workspace. A connected collector checks for newly detected supported agents and connects them under that approval. Each source has its own background process, local checkpoint, and upload queue. Run `iteration` to see connected sources, `iteration status codex` or `iteration status claude` for details, and `iteration help` for other commands. `iteration connect codex` and `iteration connect claude` remain available for advanced source-specific setups.
|
|
11
11
|
|
|
12
12
|
Claude Code sessions are read from `~/.claude/projects`, including session subagents. If Claude uses `CLAUDE_CONFIG_DIR`, the collector follows that setting during connection; `iteration connect claude --claude-home DIR` selects another directory explicitly. The collector reads only saved local JSONL records. Claude Code may remove old transcripts under its own retention settings, so connect before local history is removed. The records can contain prompts, reasoning, tool input and output, and context snapshots; workspace members can read what uploads. Unknown record shapes are reported as capture gaps in the collector log. Remote Claude sessions are outside this local collector.
|
|
13
13
|
|
package/dist/auto-connect.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import * as z from "zod";
|
|
6
6
|
import { COLLECTOR_SOURCES, parseCollectorEnrollmentResponse, } from "iteration-trace-contracts";
|
|
7
|
-
import {
|
|
7
|
+
import { installBackgroundService } from "./background-service.js";
|
|
8
8
|
import { installTurnSummary } from "./turn-summary.js";
|
|
9
9
|
import { assertFreshCollectorDirectory, collectorPaths, defaultCollectorDirectory, ensurePrivateDirectory, loadConfig, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
|
|
10
10
|
const consentSchema = z
|
|
@@ -159,7 +159,7 @@ export async function activateSource(source, home, endpoint, enrollment, state)
|
|
|
159
159
|
await saveConfig(config, paths);
|
|
160
160
|
try {
|
|
161
161
|
await saveState(state, paths);
|
|
162
|
-
await
|
|
162
|
+
await installBackgroundService(directory, source);
|
|
163
163
|
}
|
|
164
164
|
catch (error) {
|
|
165
165
|
throw new Error(`${source} was connected, but local startup failed. Run iteration start ${source} to repair it.`, { cause: error });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CollectorSource } from "iteration-trace-contracts";
|
|
2
|
+
export declare function requireCollectorPlatform(): void;
|
|
3
|
+
export declare function prepareBackgroundPersistence(): Promise<void>;
|
|
4
|
+
export declare function collectorLogPath(source: CollectorSource): string;
|
|
5
|
+
export declare function installBackgroundService(directory: string, source: CollectorSource): Promise<string>;
|
|
6
|
+
export declare function uninstallBackgroundService(source: CollectorSource): Promise<void>;
|
|
7
|
+
export declare function backgroundServiceStatus(source: CollectorSource): Promise<boolean>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
|
|
4
|
+
import { installSystemdUserService, ensureSystemdUserLinger, systemdLogPath, systemdUserServiceStatus, uninstallSystemdUserService, } from "./systemd-user.js";
|
|
5
|
+
export function requireCollectorPlatform() {
|
|
6
|
+
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
7
|
+
throw new Error("Automatic startup is supported on macOS and Linux only.");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function prepareBackgroundPersistence() {
|
|
11
|
+
requireCollectorPlatform();
|
|
12
|
+
if (process.platform === "linux")
|
|
13
|
+
await ensureSystemdUserLinger();
|
|
14
|
+
}
|
|
15
|
+
export function collectorLogPath(source) {
|
|
16
|
+
if (process.platform === "linux")
|
|
17
|
+
return systemdLogPath(source);
|
|
18
|
+
return path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`);
|
|
19
|
+
}
|
|
20
|
+
export async function installBackgroundService(directory, source) {
|
|
21
|
+
requireCollectorPlatform();
|
|
22
|
+
return process.platform === "darwin"
|
|
23
|
+
? installLaunchAgent(directory, source)
|
|
24
|
+
: installSystemdUserService(directory, source);
|
|
25
|
+
}
|
|
26
|
+
export async function uninstallBackgroundService(source) {
|
|
27
|
+
requireCollectorPlatform();
|
|
28
|
+
return process.platform === "darwin"
|
|
29
|
+
? uninstallLaunchAgent(source)
|
|
30
|
+
: uninstallSystemdUserService(source);
|
|
31
|
+
}
|
|
32
|
+
export async function backgroundServiceStatus(source) {
|
|
33
|
+
requireCollectorPlatform();
|
|
34
|
+
return process.platform === "darwin"
|
|
35
|
+
? launchAgentStatus(source)
|
|
36
|
+
: systemdUserServiceStatus(source);
|
|
37
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { importSourceHistory } from "./history-import.js";
|
|
|
12
12
|
import { runCollector } from "./service.js";
|
|
13
13
|
import { renderDetailedStatus, renderHome, safeWorkspaceName, } from "./status-view.js";
|
|
14
14
|
import { installTurnSummary, codexTurnSummaryApproval, uninstallTurnSummary, } from "./turn-summary.js";
|
|
15
|
-
import {
|
|
15
|
+
import { backgroundServiceStatus, collectorLogPath, installBackgroundService, prepareBackgroundPersistence, requireCollectorPlatform, uninstallBackgroundService, } from "./background-service.js";
|
|
16
16
|
import { assertFreshCollectorDirectory, collectorSource, collectorSourceDirectory, collectorPaths, defaultCollectorDirectory, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
|
|
17
17
|
const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
|
|
18
18
|
function argumentsForCli() {
|
|
@@ -55,14 +55,14 @@ function usage(command) {
|
|
|
55
55
|
if (command === "connect") {
|
|
56
56
|
process.stdout.write([
|
|
57
57
|
"Connect a coding agent to Iteration",
|
|
58
|
-
" iteration connect Connect all detected agents on this
|
|
58
|
+
" iteration connect Connect all detected agents on this computer",
|
|
59
59
|
" iteration connect codex",
|
|
60
60
|
" iteration connect claude",
|
|
61
61
|
"",
|
|
62
62
|
"Existing local sessions and new activity are collected automatically.",
|
|
63
63
|
"",
|
|
64
64
|
"Options for advanced setups:",
|
|
65
|
-
" --device-name NAME Name this
|
|
65
|
+
" --device-name NAME Name this computer in Iteration",
|
|
66
66
|
" --organization-id UUID Select a workspace without a prompt",
|
|
67
67
|
" --codex-home DIR Use a different Codex data directory",
|
|
68
68
|
" --claude-home DIR Use a different Claude data directory",
|
|
@@ -118,7 +118,7 @@ async function defaultCommand() {
|
|
|
118
118
|
});
|
|
119
119
|
const missing = discovered.filter((source) => (source === "codex" ? codex : claude).kind === "not_connected");
|
|
120
120
|
if (missing.length > 0) {
|
|
121
|
-
process.stdout.write(`\n${missing.map(collectorSourceLabel).join(" and ")} found on this
|
|
121
|
+
process.stdout.write(`\n${missing.map(collectorSourceLabel).join(" and ")} found on this computer but not connected. Run iteration connect to include ${missing.length === 1 ? "it" : "them"}.\n`);
|
|
122
122
|
}
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
@@ -145,9 +145,7 @@ async function turnSummaryNotice(config) {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
async function initialize(source, values) {
|
|
148
|
-
|
|
149
|
-
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
150
|
-
}
|
|
148
|
+
requireCollectorPlatform();
|
|
151
149
|
const endpoint = optionalString(values, "endpoint") ?? DEFAULT_INGEST_ENDPOINT;
|
|
152
150
|
const endpointUrl = validateCollectorEndpoint(endpoint);
|
|
153
151
|
const deviceName = optionalString(values, "device-name") ?? os.hostname();
|
|
@@ -163,6 +161,7 @@ async function initialize(source, values) {
|
|
|
163
161
|
path.join(os.homedir(), ".claude")));
|
|
164
162
|
await assertFreshCollectorDirectory(paths, source);
|
|
165
163
|
const preparedState = await prepareSourceInitialState(source, sourceHome, true);
|
|
164
|
+
await prepareBackgroundPersistence();
|
|
166
165
|
const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId, source);
|
|
167
166
|
const connection = {
|
|
168
167
|
endpoint: endpointUrl.toString(),
|
|
@@ -182,7 +181,7 @@ async function initialize(source, values) {
|
|
|
182
181
|
throw new Error(`Collector access was approved, but local setup did not finish. Run iteration start ${source} to repair it.`, { cause: error });
|
|
183
182
|
}
|
|
184
183
|
try {
|
|
185
|
-
await
|
|
184
|
+
await installBackgroundService(directory, source);
|
|
186
185
|
}
|
|
187
186
|
catch (error) {
|
|
188
187
|
throw new Error(`Collector access was approved, but background startup failed. Run iteration start ${source} to retry it.`, { cause: error });
|
|
@@ -194,8 +193,8 @@ async function initialize(source, values) {
|
|
|
194
193
|
async function approveAllSources(sources, workspaceName, yes) {
|
|
195
194
|
const names = sources.map(collectorSourceLabel).join(" and ");
|
|
196
195
|
const safeName = safeWorkspaceName(workspaceName);
|
|
197
|
-
process.stdout.write(`Detected ${names || "no saved agent sessions"} on this
|
|
198
|
-
`Connect supported agents${safeName ? ` to ${safeName}` : " to the workspace you select"}? Existing sessions and future activity, including prompts, code, and tool output, will be uploaded. Workspace members can read them. Newly detected supported agents will connect automatically
|
|
196
|
+
process.stdout.write(`Detected ${names || "no saved agent sessions"} on this computer.\n` +
|
|
197
|
+
`Connect supported agents${safeName ? ` to ${safeName}` : " to the workspace you select"}? Existing sessions and future activity, including prompts, code, and tool output, will be uploaded. Workspace members can read them. Newly detected supported agents will connect automatically.${process.platform === "linux" ? " Collection will continue after you log out." : ""}\n`);
|
|
199
198
|
if (yes)
|
|
200
199
|
return;
|
|
201
200
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -217,9 +216,7 @@ async function approveAllSources(sources, workspaceName, yes) {
|
|
|
217
216
|
}
|
|
218
217
|
}
|
|
219
218
|
async function connectAll(values) {
|
|
220
|
-
|
|
221
|
-
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
222
|
-
}
|
|
219
|
+
requireCollectorPlatform();
|
|
223
220
|
if (values["data-directory"] !== undefined) {
|
|
224
221
|
throw new Error("--data-directory requires a source-specific connect command.");
|
|
225
222
|
}
|
|
@@ -237,7 +234,7 @@ async function connectAll(values) {
|
|
|
237
234
|
const detected = await detectedSources(sourceHomes);
|
|
238
235
|
const existing = COLLECTOR_SOURCES.flatMap((source, index) => configs[index] ? [{ source, config: configs[index] }] : []);
|
|
239
236
|
if (detected.length === 0 && existing.length === 0) {
|
|
240
|
-
process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex or Claude Code on this
|
|
237
|
+
process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex or Claude Code on this computer.\n");
|
|
241
238
|
return;
|
|
242
239
|
}
|
|
243
240
|
const anchorSource = existing[0]?.source ?? detected[0];
|
|
@@ -275,6 +272,7 @@ async function connectAll(values) {
|
|
|
275
272
|
previousConsent.sourceHomes.claude !== sourceHomes.claude) {
|
|
276
273
|
await approveAllSources(detected, existingScopes[0]?.scope.organizationName ?? null, values.yes === true);
|
|
277
274
|
}
|
|
275
|
+
await prepareBackgroundPersistence();
|
|
278
276
|
let anchor = anchorExisting ?? (await initialize(anchorSource, values));
|
|
279
277
|
let organizationId = requestedOrganizationId ??
|
|
280
278
|
existingScopes[0]?.scope.organizationId ??
|
|
@@ -310,16 +308,14 @@ async function connectAll(values) {
|
|
|
310
308
|
});
|
|
311
309
|
for (const item of existing) {
|
|
312
310
|
await ensureStartableState(item.config, collectorPaths(item.config.dataDirectory));
|
|
313
|
-
await
|
|
311
|
+
await installBackgroundService(item.config.dataDirectory, item.source);
|
|
314
312
|
const notice = await turnSummaryNotice(item.config);
|
|
315
313
|
process.stdout.write(`${collectorSourceLabel(item.source)} is still connected.${notice}\n`);
|
|
316
314
|
}
|
|
317
|
-
process.stdout.write("Detected agents are connected. New supported agents on this
|
|
315
|
+
process.stdout.write("Detected agents are connected. New supported agents on this computer will connect automatically.\n");
|
|
318
316
|
}
|
|
319
317
|
async function importHistory(source, values) {
|
|
320
|
-
|
|
321
|
-
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
322
|
-
}
|
|
318
|
+
requireCollectorPlatform();
|
|
323
319
|
const directory = dataDirectory(source, values);
|
|
324
320
|
const paths = collectorPaths(directory);
|
|
325
321
|
const config = await loadConfig(paths);
|
|
@@ -335,16 +331,14 @@ async function importHistory(source, values) {
|
|
|
335
331
|
process.stdout.write(`History scan scheduled for ${result.size.files} local session files (${result.size.bytes} bytes). Scanning and uploads run in bounded batches and may take time. Older sessions appear under the date Iteration receives them. Use iteration status ${source} to see local scan progress and pending uploads.${result.backgroundRunning ? "" : ` The collector was not running; run iteration start ${source} to begin scanning.`}\n`);
|
|
336
332
|
}
|
|
337
333
|
async function start(source, values) {
|
|
338
|
-
|
|
339
|
-
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
340
|
-
}
|
|
334
|
+
requireCollectorPlatform();
|
|
341
335
|
const directory = dataDirectory(source, values);
|
|
342
336
|
const paths = collectorPaths(directory);
|
|
343
337
|
const config = await loadConfig(paths);
|
|
344
338
|
if (collectorSource(config) !== source)
|
|
345
339
|
throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
|
|
346
340
|
const repairedState = await ensureStartableState(config, paths);
|
|
347
|
-
await
|
|
341
|
+
await installBackgroundService(directory, source);
|
|
348
342
|
const notice = await turnSummaryNotice(config);
|
|
349
343
|
process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}${notice}\n`);
|
|
350
344
|
}
|
|
@@ -364,7 +358,7 @@ async function statusSnapshot(source, values) {
|
|
|
364
358
|
if (collectorSource(config) !== source)
|
|
365
359
|
throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
|
|
366
360
|
const [running, size, ids, scan, localHealth, approval] = await Promise.all([
|
|
367
|
-
|
|
361
|
+
backgroundServiceStatus(source),
|
|
368
362
|
outboxSize(paths),
|
|
369
363
|
pendingEventIds(paths),
|
|
370
364
|
historyScanProgress(collectorSourceDirectory(config), paths, source),
|
|
@@ -384,7 +378,7 @@ async function statusSnapshot(source, values) {
|
|
|
384
378
|
healthAvailable: localHealth.kind === "available",
|
|
385
379
|
turnSummaryNeedsApproval: approval === "needs_approval",
|
|
386
380
|
tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
|
|
387
|
-
logPath:
|
|
381
|
+
logPath: collectorLogPath(source),
|
|
388
382
|
};
|
|
389
383
|
}
|
|
390
384
|
async function status(source, values) {
|
|
@@ -444,7 +438,7 @@ async function main() {
|
|
|
444
438
|
if (command === "start" && selectedSource !== null)
|
|
445
439
|
return start(selectedSource, values);
|
|
446
440
|
if (command === "disconnect" && selectedSource !== null) {
|
|
447
|
-
await
|
|
441
|
+
await uninstallBackgroundService(selectedSource);
|
|
448
442
|
const paths = collectorPaths(dataDirectory(selectedSource, values));
|
|
449
443
|
if (await fileExists(paths.config)) {
|
|
450
444
|
await uninstallTurnSummary(await loadConfig(paths));
|
package/dist/history-import.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { backgroundServiceStatus, installBackgroundService, uninstallBackgroundService, } from "./background-service.js";
|
|
3
3
|
import { waitForCollectorLock } from "./service.js";
|
|
4
4
|
import { collectorSource, collectorSourceDirectory, historyImportSize, loadState, resetHistoryCursors, } from "./storage.js";
|
|
5
|
-
function
|
|
5
|
+
function backgroundControl(source) {
|
|
6
6
|
return {
|
|
7
|
-
isRunning: () =>
|
|
8
|
-
stop: () =>
|
|
9
|
-
start: (directory) =>
|
|
7
|
+
isRunning: () => backgroundServiceStatus(source),
|
|
8
|
+
stop: () => uninstallBackgroundService(source),
|
|
9
|
+
start: (directory) => installBackgroundService(directory, source),
|
|
10
10
|
acquireLock: waitForCollectorLock,
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
-
export async function importSourceHistory(config, paths, confirmed, control =
|
|
13
|
+
export async function importSourceHistory(config, paths, confirmed, control = backgroundControl(collectorSource(config))) {
|
|
14
14
|
const source = collectorSource(config);
|
|
15
15
|
const directory = collectorSourceDirectory(config);
|
|
16
16
|
const state = await loadState(paths);
|
package/dist/status-view.js
CHANGED
|
@@ -44,7 +44,7 @@ function relativeTime(atMs, nowMs) {
|
|
|
44
44
|
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`;
|
|
45
45
|
}
|
|
46
46
|
function pendingDescription(status) {
|
|
47
|
-
return `${status.pendingEvents} event${status.pendingEvents === 1 ? "" : "s"} (${readableBytes(status.pendingBytes)}) waiting on this
|
|
47
|
+
return `${status.pendingEvents} event${status.pendingEvents === 1 ? "" : "s"} (${readableBytes(status.pendingBytes)}) waiting on this computer`;
|
|
48
48
|
}
|
|
49
49
|
function progressDescription(scan, source) {
|
|
50
50
|
if (scan.kind === "waiting")
|
|
@@ -83,7 +83,7 @@ function failureDescription(status, source) {
|
|
|
83
83
|
if (failure.status === 401 || failure.status === 403) {
|
|
84
84
|
return {
|
|
85
85
|
heading: `${name} upload access needs attention`,
|
|
86
|
-
explanation: `Iteration denied this collector (${code}). Queued data stays on this
|
|
86
|
+
explanation: `Iteration denied this collector (${code}). Queued data stays on this computer. Contact Iteration support to reconnect it safely.`,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
if (failure.status !== null &&
|
|
@@ -133,8 +133,8 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
|
|
|
133
133
|
`${name} connection needs repair`,
|
|
134
134
|
"Local collector data exists, but its connection details are missing.",
|
|
135
135
|
status.pendingBytes > 0
|
|
136
|
-
? `${readableBytes(status.pendingBytes)} of queued data remains on this
|
|
137
|
-
: "Existing local progress remains on this
|
|
136
|
+
? `${readableBytes(status.pendingBytes)} of queued data remains on this computer.`
|
|
137
|
+
: "Existing local progress remains on this computer.",
|
|
138
138
|
`Run iteration status ${source} for recovery details.`,
|
|
139
139
|
].join("\n");
|
|
140
140
|
}
|
|
@@ -184,7 +184,7 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
|
|
|
184
184
|
lines.push(`${name} is uploading captured sessions`, `${pendingDescription(status)}.`);
|
|
185
185
|
}
|
|
186
186
|
else if (status.scan.totalBytes === 0) {
|
|
187
|
-
lines.push(`${name} is connected`, `Waiting for ${name} sessions on this
|
|
187
|
+
lines.push(`${name} is connected`, `Waiting for ${name} sessions on this computer.`);
|
|
188
188
|
}
|
|
189
189
|
else if (status.scan.kind === "scanning" &&
|
|
190
190
|
status.scan.scannedBytes < status.scan.totalBytes) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CollectorSource } from "iteration-trace-contracts";
|
|
2
|
+
export declare function ensureSystemdUserLinger(): Promise<void>;
|
|
3
|
+
export declare function systemdLogPath(source: CollectorSource): string;
|
|
4
|
+
export declare function systemdUserUnit(cliPath?: string, nodePath?: string, dataDirectory?: string, source?: CollectorSource, outputPath?: string, errorPath?: string): string;
|
|
5
|
+
export declare function installSystemdUserService(dataDirectory: string, source: CollectorSource): Promise<string>;
|
|
6
|
+
export declare function uninstallSystemdUserService(source: CollectorSource): Promise<void>;
|
|
7
|
+
export declare function systemdUserServiceStatus(source: CollectorSource): Promise<boolean>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFile = promisify(execFileCallback);
|
|
8
|
+
const SYSTEMCTL_TIMEOUT_MS = 10_000;
|
|
9
|
+
const LOGINCTL_TIMEOUT_MS = 10_000;
|
|
10
|
+
function currentUserId() {
|
|
11
|
+
const userId = process.getuid?.();
|
|
12
|
+
if (userId === undefined)
|
|
13
|
+
throw new Error("Could not determine the current Linux user id.");
|
|
14
|
+
return String(userId);
|
|
15
|
+
}
|
|
16
|
+
async function lingerState(userId) {
|
|
17
|
+
const { stdout } = await execFile("loginctl", ["show-user", userId, "--property=Linger", "--value"], { timeout: LOGINCTL_TIMEOUT_MS });
|
|
18
|
+
const state = stdout.trim();
|
|
19
|
+
if (state === "yes" || state === "no")
|
|
20
|
+
return state;
|
|
21
|
+
throw new Error(`Could not read Linux background service setting: ${state || "empty response"}.`);
|
|
22
|
+
}
|
|
23
|
+
export async function ensureSystemdUserLinger() {
|
|
24
|
+
const userId = currentUserId();
|
|
25
|
+
let state = "no";
|
|
26
|
+
let queryFailure;
|
|
27
|
+
try {
|
|
28
|
+
state = await lingerState(userId);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
queryFailure = error;
|
|
32
|
+
}
|
|
33
|
+
if (state === "yes")
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
await execFile("loginctl", ["--no-ask-password", "enable-linger", userId], {
|
|
37
|
+
timeout: LOGINCTL_TIMEOUT_MS,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (userError) {
|
|
41
|
+
try {
|
|
42
|
+
await execFile("sudo", ["-n", "loginctl", "enable-linger", userId], {
|
|
43
|
+
timeout: LOGINCTL_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (adminError) {
|
|
47
|
+
throw new Error("This server does not allow Iteration to keep collecting after you log out. Ask an administrator to allow background services for your account, then run iteration connect again.", {
|
|
48
|
+
cause: new AggregateError(queryFailure
|
|
49
|
+
? [queryFailure, userError, adminError]
|
|
50
|
+
: [userError, adminError], "Could not enable Linux background collection."),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if ((await lingerState(userId)) === "yes")
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new Error("Could not verify Linux background collection after logout.", {
|
|
60
|
+
cause: error,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
throw new Error("Linux did not enable background collection after logout.");
|
|
64
|
+
}
|
|
65
|
+
function unitName(source) {
|
|
66
|
+
return `iteration-${source}-collector.service`;
|
|
67
|
+
}
|
|
68
|
+
function unitPath(source) {
|
|
69
|
+
return path.join(os.homedir(), ".config", "systemd", "user", unitName(source));
|
|
70
|
+
}
|
|
71
|
+
export function systemdLogPath(source) {
|
|
72
|
+
return path.join(os.homedir(), ".config", "iteration", "logs", `iteration-${source}-collector.error.log`);
|
|
73
|
+
}
|
|
74
|
+
function unitValue(value) {
|
|
75
|
+
if (/[\r\n\0]/.test(value))
|
|
76
|
+
throw new Error("Collector paths cannot contain line breaks.");
|
|
77
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
78
|
+
}
|
|
79
|
+
export function systemdUserUnit(cliPath = fileURLToPath(new URL("./cli.js", import.meta.url)), nodePath = process.execPath, dataDirectory = path.join(os.homedir(), ".config", "iteration", "codex-collector"), source = "codex", outputPath = path.join(os.homedir(), ".config", "iteration", "logs", `iteration-${source}-collector.log`), errorPath = systemdLogPath(source)) {
|
|
80
|
+
return [
|
|
81
|
+
"[Unit]",
|
|
82
|
+
`Description=Iteration ${source} collector`,
|
|
83
|
+
"[Service]",
|
|
84
|
+
"Type=exec",
|
|
85
|
+
`ExecStart=${[nodePath, cliPath, "run", source, "--data-directory", dataDirectory].map(unitValue).join(" ")}`,
|
|
86
|
+
"Restart=always",
|
|
87
|
+
"RestartSec=5",
|
|
88
|
+
`StandardOutput=append:${unitValue(outputPath)}`,
|
|
89
|
+
`StandardError=append:${unitValue(errorPath)}`,
|
|
90
|
+
"[Install]",
|
|
91
|
+
"WantedBy=default.target",
|
|
92
|
+
"",
|
|
93
|
+
].join("\n");
|
|
94
|
+
}
|
|
95
|
+
async function systemctl(...args) {
|
|
96
|
+
const { stdout } = await execFile("systemctl", ["--user", ...args], {
|
|
97
|
+
timeout: SYSTEMCTL_TIMEOUT_MS,
|
|
98
|
+
});
|
|
99
|
+
return stdout.trim();
|
|
100
|
+
}
|
|
101
|
+
export async function installSystemdUserService(dataDirectory, source) {
|
|
102
|
+
await ensureSystemdUserLinger();
|
|
103
|
+
const file = unitPath(source);
|
|
104
|
+
const logDirectory = path.dirname(systemdLogPath(source));
|
|
105
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
106
|
+
await fs.mkdir(logDirectory, { recursive: true, mode: 0o700 });
|
|
107
|
+
await fs.writeFile(file, systemdUserUnit(undefined, undefined, dataDirectory, source), {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
mode: 0o644,
|
|
110
|
+
});
|
|
111
|
+
await systemctl("daemon-reload");
|
|
112
|
+
await systemctl("enable", unitName(source));
|
|
113
|
+
await systemctl("restart", unitName(source));
|
|
114
|
+
return file;
|
|
115
|
+
}
|
|
116
|
+
export async function uninstallSystemdUserService(source) {
|
|
117
|
+
const file = unitPath(source);
|
|
118
|
+
try {
|
|
119
|
+
await fs.access(file);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
123
|
+
return;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
await systemctl("disable", "--now", unitName(source));
|
|
127
|
+
await fs.rm(file);
|
|
128
|
+
await systemctl("daemon-reload");
|
|
129
|
+
}
|
|
130
|
+
export async function systemdUserServiceStatus(source) {
|
|
131
|
+
const state = await systemctl("show", "--property=ActiveState", "--value", unitName(source));
|
|
132
|
+
if (state === "active")
|
|
133
|
+
return true;
|
|
134
|
+
if (state === "inactive" || state === "failed")
|
|
135
|
+
return false;
|
|
136
|
+
throw new Error(`Could not determine ${source} collector service state: ${state || "empty response"}.`);
|
|
137
|
+
}
|