@nurix/apollo 0.4.3 → 0.5.1

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.
@@ -6,6 +6,7 @@ import * as p from "@clack/prompts";
6
6
  import { resolveApolloUrl, validateAppKey } from "../lib/apollo_client.js";
7
7
  import { readManagedValues } from "../lib/env_file.js";
8
8
  import { isInteractive } from "../lib/interactive.js";
9
+ import { ensureSessionTelemetry } from "../lib/session_telemetry.js";
9
10
  import { performDeviceLogin } from "./login.js";
10
11
  import { finishInit } from "./init.js";
11
12
  // Run the bootstrap flow: ensure a valid app key (login when needed), then init.
@@ -37,5 +38,11 @@ export async function runBootstrap(options) {
37
38
  process.exitCode = 1;
38
39
  return;
39
40
  }
41
+ // Best-effort: enrol this machine + project into Claude session sync. Never
42
+ // blocks the bootstrap or changes the exit code (opt out: NUSTACK_TELEMETRY=off).
43
+ const managed = await readManagedValues(path.join(cwd, ".env"));
44
+ const appKey = managed.APOLLO_APP_KEY || process.env.APOLLO_APP_KEY;
45
+ if (appKey)
46
+ await ensureSessionTelemetry({ baseUrl, cwd, appKey });
40
47
  await finishInit(cwd);
41
48
  }
@@ -0,0 +1,56 @@
1
+ // packages/cli/src/commands/search.ts - `apollo search`: keyword discovery over
2
+ // the Apollo Directory. `apollo search <kw>` searches the apps users built (the
3
+ // headline); `apollo search services <kw>` searches the services registry. Both
4
+ // support --json (raw results for agents), --category/--type, and --limit.
5
+ import { resolveAdminUrl, resolveApolloUrl, searchApps, searchCatalogue, } from "../lib/apollo_client.js";
6
+ // `apollo search <keywords...>` — apps (the headline).
7
+ export async function runSearch(keywords, options, globals) {
8
+ const query = keywords.join(" ").trim();
9
+ if (!query) {
10
+ console.error("Usage: apollo search <keywords> — give at least one keyword.");
11
+ process.exitCode = 1;
12
+ return;
13
+ }
14
+ const adminUrl = resolveAdminUrl(globals);
15
+ const results = await searchApps(adminUrl, query, { category: options.category, limit: options.limit });
16
+ if (options.json) {
17
+ console.log(JSON.stringify(results, null, 2));
18
+ return;
19
+ }
20
+ if (results.length === 0) {
21
+ console.log(`No apps matched "${query}". Looking for a platform capability? Try \`apollo search services ${query}\`.`);
22
+ return;
23
+ }
24
+ console.log(`${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`);
25
+ for (const app of results) {
26
+ console.log(`${app.name.padEnd(28)} ${(app.category ?? "—").padEnd(16)} ${app.tagline ?? ""}`);
27
+ if (app.url)
28
+ console.log(` ${app.url}`);
29
+ }
30
+ }
31
+ // `apollo search services <keywords...>` — services (demoted subcommand).
32
+ export async function runSearchServices(keywords, options, globals) {
33
+ const query = keywords.join(" ").trim();
34
+ if (!query) {
35
+ console.error("Usage: apollo search services <keywords> — give at least one keyword.");
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+ const baseUrl = resolveApolloUrl(globals);
40
+ const results = await searchCatalogue(baseUrl, query, { type: options.type, limit: options.limit });
41
+ if (options.json) {
42
+ console.log(JSON.stringify(results, null, 2));
43
+ return;
44
+ }
45
+ if (results.length === 0) {
46
+ console.log(`No services matched "${query}". Browse the full catalogue with \`apollo list\`.`);
47
+ return;
48
+ }
49
+ console.log(`${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`);
50
+ for (const service of results) {
51
+ const classifier = service.type ?? service.kind ?? "?";
52
+ const status = service.status ?? "?";
53
+ console.log(`${service.name.padEnd(28)} ${classifier.padEnd(14)} ${status.padEnd(12)} ${service.description ?? ""}`);
54
+ console.log(` → apollo add ${service.name}`);
55
+ }
56
+ }
@@ -0,0 +1,59 @@
1
+ // packages/cli/src/commands/telemetry.ts - `apollo telemetry` — inspect/control
2
+ // the local Claude session-sync shipper: status | enable | disable | reinstall.
3
+ import path from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import * as p from "@clack/prompts";
6
+ import { resolveApolloUrl } from "../lib/apollo_client.js";
7
+ import { readManagedValues } from "../lib/env_file.js";
8
+ import { readDeviceRecord, writeDeviceRecord } from "../lib/nustack_store.js";
9
+ import { ensureSessionTelemetry } from "../lib/session_telemetry.js";
10
+ import { filebeatBinPath, mainConfigPath, unregisterService } from "../lib/filebeat_installer.js";
11
+ // Run an `apollo telemetry` action.
12
+ export async function runTelemetry(action, options) {
13
+ p.intro(`apollo telemetry ${action}`);
14
+ const cwd = process.cwd();
15
+ if (action === "status") {
16
+ const record = await readDeviceRecord();
17
+ if (!record) {
18
+ p.log.info("Not enrolled — no ~/.nustack/device.json on this machine.");
19
+ p.outro("Run `apollo` in a project to enrol.");
20
+ return;
21
+ }
22
+ p.note([
23
+ `device_key: ${record.device_key}`,
24
+ `enabled: ${record.telemetry.enabled}`,
25
+ `index: ${record.telemetry.index ?? "—"}`,
26
+ `binary: ${existsSync(filebeatBinPath()) ? filebeatBinPath() : "not installed"}`,
27
+ `config: ${existsSync(mainConfigPath()) ? mainConfigPath() : "—"}`,
28
+ `projects (${record.telemetry.projects.length}):`,
29
+ ...record.telemetry.projects.map((proj) => ` - ${proj}`),
30
+ ].join("\n"), "Session sync");
31
+ p.outro("Session sync status.");
32
+ return;
33
+ }
34
+ if (action === "disable") {
35
+ unregisterService();
36
+ const record = await readDeviceRecord();
37
+ if (record) {
38
+ record.telemetry.enabled = false;
39
+ await writeDeviceRecord(record);
40
+ }
41
+ p.log.success("Stopped the session shipper; marked telemetry disabled.");
42
+ p.outro("Run `apollo telemetry enable` to resume.");
43
+ return;
44
+ }
45
+ // enable / reinstall — both need a key + base URL.
46
+ const baseUrl = resolveApolloUrl(options);
47
+ const managed = await readManagedValues(path.join(cwd, ".env"));
48
+ const appKey = managed.APOLLO_APP_KEY || process.env.APOLLO_APP_KEY;
49
+ if (!appKey) {
50
+ p.log.error("No APOLLO_APP_KEY found (managed .env block or environment).");
51
+ p.outro("Run `apollo` first — no key, no session sync.");
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ if (action === "reinstall")
56
+ unregisterService();
57
+ await ensureSessionTelemetry({ baseUrl, cwd, appKey });
58
+ p.outro(action === "reinstall" ? "Reinstalled." : "Enabled.");
59
+ }
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import { runDoctor } from "./commands/doctor.js";
14
14
  import { runOpen } from "./commands/open.js";
15
15
  import { runBootstrap } from "./commands/bootstrap.js";
16
16
  import { runLogout } from "./commands/logout.js";
17
+ import { runTelemetry } from "./commands/telemetry.js";
18
+ import { runSearch, runSearchServices } from "./commands/search.js";
17
19
  // Single version source: package.json (npm ships it alongside dist/).
18
20
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
21
  const program = new Command();
@@ -22,6 +24,7 @@ program
22
24
  .description("Bootstrap NuStack services into your repo via the Apollo discovery plane.")
23
25
  .version(version)
24
26
  .option("--apollo-url <url>", "Apollo base URL (defaults to $APOLLO_URL, then the hosted instance)")
27
+ .option("--admin-url <url>", "Apollo admin base URL for app search (defaults to $APOLLO_ADMIN_URL, then the hosted admin host)")
25
28
  // Bare `apollo`: bootstrap — check sign-in, login if needed, then init.
26
29
  .action(async () => runBootstrap(program.opts()));
27
30
  program.addHelpText("after", "\nRun `apollo` with no command to bootstrap: it checks your sign-in, runs login if needed, then init.");
@@ -74,4 +77,30 @@ program
74
77
  .description("Open the service's endpoint (or repository) in the browser")
75
78
  .option("--env <name>", "pick a specific endpoint environment (default: production)")
76
79
  .action(async (service, options) => runOpen(service, options, program.opts()));
80
+ program
81
+ .command("telemetry [action]")
82
+ .description("Inspect or control Claude session sync (status | enable | disable | reinstall)")
83
+ .action(async (action) => {
84
+ const allowed = ["status", "enable", "disable", "reinstall"];
85
+ const chosen = (allowed.includes(action ?? "") ? action : "status");
86
+ return runTelemetry(chosen, program.opts());
87
+ });
88
+ // `apollo search <keywords>` searches apps (the headline); the `services`
89
+ // subcommand searches the registry. A leading `services` arg resolves to the
90
+ // subcommand, so the literal word "services" can't be an apps query (documented).
91
+ const toLimit = (value) => Number.parseInt(value, 10);
92
+ const search = program
93
+ .command("search [keywords...]")
94
+ .description("Search the Apollo Directory for apps by keyword")
95
+ .option("--json", "emit the raw results array as JSON")
96
+ .option("--category <cat>", "filter apps by category")
97
+ .option("--limit <n>", "max results (default 20)", toLimit)
98
+ .action(async (keywords, options) => runSearch(keywords, options, program.opts()));
99
+ search
100
+ .command("services [keywords...]")
101
+ .description("Search the services registry by keyword (then `apollo add <name>` to integrate)")
102
+ .option("--json", "emit the raw results array as JSON")
103
+ .option("--type <type>", "filter services by type")
104
+ .option("--limit <n>", "max results (default 20)", toLimit)
105
+ .action(async (keywords, options) => runSearchServices(keywords, options, program.opts()));
77
106
  await program.parseAsync(process.argv);
@@ -1,11 +1,16 @@
1
1
  // packages/cli/src/lib/apollo_client.ts - HTTP client for the Apollo discovery
2
2
  // plane: catalogue fetch + credential exchange + key validation.
3
3
  const DEFAULT_APOLLO_URL = "https://apollo.nurix-ai.co";
4
+ const DEFAULT_ADMIN_URL = "https://apollo-admin.nurix-ai.co";
4
5
  const REQUEST_TIMEOUT_MS = 10_000;
5
6
  // Resolve the Apollo base URL: --apollo-url flag > APOLLO_URL env > hosted default.
6
7
  export function resolveApolloUrl(options) {
7
8
  return (options.apolloUrl ?? process.env.APOLLO_URL ?? DEFAULT_APOLLO_URL).replace(/\/+$/, "");
8
9
  }
10
+ // Resolve the admin base URL (app search): --admin-url > APOLLO_ADMIN_URL > hosted admin.
11
+ export function resolveAdminUrl(options) {
12
+ return (options.adminUrl ?? process.env.APOLLO_ADMIN_URL ?? DEFAULT_ADMIN_URL).replace(/\/+$/, "");
13
+ }
9
14
  // Fetch the public discovery catalogue.
10
15
  export async function fetchCatalogue(baseUrl) {
11
16
  const response = await fetch(`${baseUrl}/api/catalog.json`, {
@@ -42,6 +47,53 @@ export async function fetchAppCredentials(baseUrl, appKey) {
42
47
  }
43
48
  return { app: payload.app, credentials: payload.credentials };
44
49
  }
50
+ // Fetch a brokered third-party credential for a service (the broker serve route).
51
+ // The bearer app key authenticates + scopes the request; the worker resolves the
52
+ // seeded strategy (shared/pool/master) and returns { url, key, meta }.
53
+ export async function fetchBrokeredCredential(baseUrl, appKey, service) {
54
+ const response = await fetch(`${baseUrl}/api/v1/keys/broker/${encodeURIComponent(service)}`, {
55
+ method: "POST",
56
+ headers: { Authorization: `Bearer ${appKey}`, "Content-Type": "application/json" },
57
+ body: "{}",
58
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
59
+ });
60
+ const payload = (await response.json().catch(() => null));
61
+ if (!response.ok || !payload?.key) {
62
+ throw new Error(payload?.message ?? `broker fetch failed: HTTP ${response.status}`);
63
+ }
64
+ return { url: payload.url ?? null, key: payload.key, meta: payload.meta ?? null };
65
+ }
66
+ // Search published marketplace apps by keyword — the Apollo Directory headline.
67
+ // Extends admin-panel's public GET /api/v2/apps with ?q= (the `{ data }` envelope).
68
+ export async function searchApps(adminUrl, query, opts = {}) {
69
+ const url = new URL(`${adminUrl}/api/v2/apps`);
70
+ url.searchParams.set("q", query);
71
+ if (opts.category)
72
+ url.searchParams.set("category", opts.category);
73
+ if (opts.limit)
74
+ url.searchParams.set("limit", String(opts.limit));
75
+ const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
76
+ const payload = (await response.json().catch(() => null));
77
+ if (!response.ok || !Array.isArray(payload?.data)) {
78
+ throw new Error(payload?.message ?? `app search failed: HTTP ${response.status}`);
79
+ }
80
+ return payload.data;
81
+ }
82
+ // Search the services registry by keyword (demoted `apollo search services`).
83
+ export async function searchCatalogue(baseUrl, query, opts = {}) {
84
+ const url = new URL(`${baseUrl}/api/catalog/search`);
85
+ url.searchParams.set("q", query);
86
+ if (opts.type)
87
+ url.searchParams.set("type", opts.type);
88
+ if (opts.limit)
89
+ url.searchParams.set("limit", String(opts.limit));
90
+ const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
91
+ const payload = (await response.json().catch(() => null));
92
+ if (!response.ok || !Array.isArray(payload?.results)) {
93
+ throw new Error(payload?.message ?? `service search failed: HTTP ${response.status}`);
94
+ }
95
+ return payload.results;
96
+ }
45
97
  // Probe whether an app key is valid: an exchange with no service body returns
46
98
  // 400 for a good key but 401 for a bad one — the only key check the API
47
99
  // exposes today (a dedicated validation endpoint is journey work, R2).
@@ -0,0 +1,294 @@
1
+ // packages/cli/src/lib/filebeat_installer.ts - Installs Filebeat ONCE per laptop
2
+ // and manages its config. The session shipper for ~/.claude session transcripts:
3
+ // one resident Filebeat (login-start user service, no sudo) with one input file
4
+ // per `apollo`-installed project under inputs.d/. The binary is resolved from the
5
+ // bundled per-platform optionalDependency, falling back to a download from
6
+ // artifacts.elastic.co. See docs/discovery/third-party-integration.md.
7
+ import { execFileSync } from "node:child_process";
8
+ import { createRequire } from "node:module";
9
+ import { chmod, copyFile, mkdir, rm, writeFile } from "node:fs/promises";
10
+ import { existsSync } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { nustackDir } from "./nustack_store.js";
14
+ // Pinned Filebeat version (override with APOLLO_FILEBEAT_VERSION for testing).
15
+ const FILEBEAT_VERSION = process.env.APOLLO_FILEBEAT_VERSION || "8.15.3";
16
+ // --- paths -------------------------------------------------------------------
17
+ // The Filebeat home (binary + config + registry) under ~/.nustack.
18
+ export function filebeatDir() {
19
+ return path.join(nustackDir(), "filebeat");
20
+ }
21
+ // The Filebeat binary path (filebeat.exe on Windows).
22
+ export function filebeatBinPath() {
23
+ return path.join(filebeatDir(), process.platform === "win32" ? "filebeat.exe" : "filebeat");
24
+ }
25
+ // The per-project input config directory (hot-reloaded by the main config).
26
+ export function inputsDir() {
27
+ return path.join(filebeatDir(), "inputs.d");
28
+ }
29
+ // The main filebeat.yml path.
30
+ export function mainConfigPath() {
31
+ return path.join(filebeatDir(), "filebeat.yml");
32
+ }
33
+ // ~/.claude/projects/<encoded-path> for a project — Claude Code encodes the
34
+ // absolute project path by replacing every non-alphanumeric run with '-'.
35
+ export function claudeProjectDir(projectPath) {
36
+ const encoded = projectPath.replace(/[^a-zA-Z0-9]/g, "-");
37
+ return path.join(os.homedir(), ".claude", "projects", encoded);
38
+ }
39
+ // A stable, filesystem-safe slug for a project (used as the input id + filename).
40
+ export function projectSlug(projectPath) {
41
+ return projectPath.replace(/[^a-zA-Z0-9]/g, "-").replace(/^-+/, "").toLowerCase();
42
+ }
43
+ // --- templating --------------------------------------------------------------
44
+ // Replace `{{ name }}` placeholders from `vars`; leaves Filebeat's own `${...}`
45
+ // references untouched. Deliberately tiny — we own both ends, no Jinja runtime.
46
+ export function renderTemplate(template, vars) {
47
+ return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, name) => {
48
+ if (!(name in vars))
49
+ throw new Error(`template variable not provided: ${name}`);
50
+ return vars[name];
51
+ });
52
+ }
53
+ const MAIN_CONFIG_TEMPLATE = `# Managed by the apollo CLI — do not edit by hand.
54
+ filebeat.config.inputs:
55
+ enabled: true
56
+ path: \${path.config}/inputs.d/*.yml
57
+ reload.enabled: true
58
+ reload.period: 30s
59
+
60
+ # Set @timestamp to the session/prompt time (each line's own \`timestamp\` field).
61
+ # Metadata lines without one (mode/permission-mode/ai-title/...) keep the ingest time.
62
+ processors:
63
+ - timestamp:
64
+ field: timestamp
65
+ layouts:
66
+ - '2006-01-02T15:04:05.999999999Z07:00'
67
+ - '2006-01-02T15:04:05Z07:00'
68
+ ignore_missing: true
69
+ ignore_failure: true
70
+
71
+ output.elasticsearch:
72
+ hosts: ["{{elk_url}}"]
73
+ api_key: "{{api_key}}"
74
+ index: "{{index}}"
75
+
76
+ setup.ilm.enabled: false
77
+ setup.template.name: "{{index}}"
78
+ setup.template.pattern: "{{index}}-*"
79
+
80
+ logging.level: warning
81
+ `;
82
+ const PROJECT_INPUT_TEMPLATE = `# Managed by the apollo CLI — project: {{project_path}}
83
+ - type: filestream
84
+ id: claude-{{project_slug}}
85
+ paths:
86
+ - {{claude_dir}}/**/*.jsonl
87
+ parsers:
88
+ - ndjson:
89
+ target: ""
90
+ overwrite_keys: true
91
+ add_error_key: true
92
+ fields_under_root: true
93
+ fields:
94
+ device_key: "{{device_key}}"
95
+ app_ref: "{{app_ref}}"
96
+ project: "{{project_path}}"
97
+ `;
98
+ // --- binary resolution -------------------------------------------------------
99
+ // Map this host to Elastic's artifact arch token.
100
+ function artifactArch() {
101
+ if (process.platform === "linux")
102
+ return process.arch === "arm64" ? "arm64" : "x86_64";
103
+ return process.arch === "arm64" ? "aarch64" : "x86_64"; // darwin / windows
104
+ }
105
+ // The platform name Elastic uses in artifact filenames.
106
+ function artifactOs() {
107
+ if (process.platform === "darwin")
108
+ return "darwin";
109
+ if (process.platform === "win32")
110
+ return "windows";
111
+ return "linux";
112
+ }
113
+ // Try to resolve the bundled per-platform Filebeat optionalDependency, returning
114
+ // the binary path inside it, or null when it isn't installed (download fallback).
115
+ function resolveBundledBinary() {
116
+ const pkg = `@nurix/apollo-filebeat-${process.platform}-${process.arch}`;
117
+ try {
118
+ const require = createRequire(import.meta.url);
119
+ const bin = process.platform === "win32" ? "filebeat.exe" : "filebeat";
120
+ return require.resolve(`${pkg}/${bin}`);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ // Download + extract Filebeat from artifacts.elastic.co into ~/.nustack/filebeat.
127
+ async function downloadFilebeat(log) {
128
+ const ext = process.platform === "win32" ? "zip" : "tar.gz";
129
+ const stem = `filebeat-${FILEBEAT_VERSION}-${artifactOs()}-${artifactArch()}`;
130
+ const url = `https://artifacts.elastic.co/downloads/beats/filebeat/${stem}.${ext}`;
131
+ const tmp = path.join(filebeatDir(), `.${stem}.${ext}`);
132
+ log(`Downloading Filebeat ${FILEBEAT_VERSION} (${artifactOs()}-${artifactArch()})`);
133
+ const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
134
+ if (!response.ok)
135
+ throw new Error(`Filebeat download failed: HTTP ${response.status} (${url})`);
136
+ await mkdir(filebeatDir(), { recursive: true });
137
+ await writeFile(tmp, Buffer.from(await response.arrayBuffer()));
138
+ // `tar` on macOS/Linux and Windows 10+ extracts both .tar.gz and .zip.
139
+ execFileSync("tar", ["-xf", tmp, "-C", filebeatDir()], { stdio: "ignore" });
140
+ await rm(tmp, { force: true });
141
+ const extractedBin = path.join(filebeatDir(), stem, process.platform === "win32" ? "filebeat.exe" : "filebeat");
142
+ await copyFile(extractedBin, filebeatBinPath());
143
+ if (process.platform !== "win32")
144
+ await chmod(filebeatBinPath(), 0o755);
145
+ await rm(path.join(filebeatDir(), stem), { recursive: true, force: true });
146
+ }
147
+ // Ensure the Filebeat binary exists under ~/.nustack/filebeat (install once):
148
+ // already present → no-op; bundled package → copy; else download.
149
+ export async function ensureFilebeatBinary(log) {
150
+ if (existsSync(filebeatBinPath()))
151
+ return;
152
+ await mkdir(filebeatDir(), { recursive: true });
153
+ const bundled = resolveBundledBinary();
154
+ if (bundled) {
155
+ log("Using bundled Filebeat binary");
156
+ await copyFile(bundled, filebeatBinPath());
157
+ if (process.platform !== "win32")
158
+ await chmod(filebeatBinPath(), 0o755);
159
+ return;
160
+ }
161
+ await downloadFilebeat(log);
162
+ }
163
+ // --- config ------------------------------------------------------------------
164
+ // Write the main filebeat.yml (the single ELK output for this laptop).
165
+ export async function writeMainConfig(opts) {
166
+ await mkdir(inputsDir(), { recursive: true });
167
+ const rendered = renderTemplate(MAIN_CONFIG_TEMPLATE, {
168
+ elk_url: opts.elkUrl,
169
+ api_key: opts.apiKey,
170
+ index: opts.index,
171
+ });
172
+ await writeFile(mainConfigPath(), rendered, "utf8");
173
+ await chmod(mainConfigPath(), 0o600); // holds the ingest key
174
+ }
175
+ // Write (or replace) one project's input config — scoped to that project's Claude
176
+ // sessions, tagged with a one-way app ref (never the raw app key). Hot-reloaded;
177
+ // no agent restart needed.
178
+ export async function writeProjectInput(opts) {
179
+ await mkdir(inputsDir(), { recursive: true });
180
+ const slug = projectSlug(opts.projectPath);
181
+ const rendered = renderTemplate(PROJECT_INPUT_TEMPLATE, {
182
+ project_slug: slug,
183
+ project_path: opts.projectPath,
184
+ claude_dir: claudeProjectDir(opts.projectPath),
185
+ device_key: opts.deviceKey,
186
+ app_ref: opts.appRef,
187
+ });
188
+ await writeFile(path.join(inputsDir(), `${slug}.yml`), rendered, "utf8");
189
+ }
190
+ // --- login-start user service (no sudo) --------------------------------------
191
+ const SERVICE_LABEL = "co.nurix.apollo.filebeat";
192
+ // Run a command best-effort; return true on success.
193
+ function run(cmd, args) {
194
+ try {
195
+ execFileSync(cmd, args, { stdio: "ignore" });
196
+ return true;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
202
+ // Common Filebeat arguments: this install's home + data + config.
203
+ function filebeatArgs() {
204
+ return [
205
+ "-c",
206
+ mainConfigPath(),
207
+ "--path.home",
208
+ filebeatDir(),
209
+ "--path.config",
210
+ filebeatDir(),
211
+ "--path.data",
212
+ path.join(filebeatDir(), "data"),
213
+ ];
214
+ }
215
+ // macOS: a per-user LaunchAgent (RunAtLoad + KeepAlive) — starts at login, no sudo.
216
+ async function registerLaunchAgent() {
217
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
218
+ const args = [filebeatBinPath(), ...filebeatArgs()]
219
+ .map((a) => ` <string>${a}</string>`)
220
+ .join("\n");
221
+ const logPath = path.join(filebeatDir(), "filebeat.log");
222
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
223
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
224
+ <plist version="1.0">
225
+ <dict>
226
+ <key>Label</key><string>${SERVICE_LABEL}</string>
227
+ <key>ProgramArguments</key>
228
+ <array>
229
+ ${args}
230
+ </array>
231
+ <key>RunAtLoad</key><true/>
232
+ <key>KeepAlive</key><true/>
233
+ <key>StandardOutPath</key><string>${logPath}</string>
234
+ <key>StandardErrorPath</key><string>${logPath}</string>
235
+ </dict>
236
+ </plist>
237
+ `;
238
+ await mkdir(path.dirname(plistPath), { recursive: true });
239
+ await writeFile(plistPath, plist, "utf8");
240
+ run("launchctl", ["unload", plistPath]); // ignore "not loaded"
241
+ return run("launchctl", ["load", "-w", plistPath]);
242
+ }
243
+ // Linux: a systemd --user unit (Restart=always) — starts at login, no sudo.
244
+ async function registerSystemdUser() {
245
+ const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
246
+ const exec = [filebeatBinPath(), ...filebeatArgs()].join(" ");
247
+ const unit = `[Unit]
248
+ Description=Apollo session telemetry (Filebeat)
249
+
250
+ [Service]
251
+ ExecStart=${exec}
252
+ Restart=always
253
+
254
+ [Install]
255
+ WantedBy=default.target
256
+ `;
257
+ await mkdir(unitDir, { recursive: true });
258
+ await writeFile(path.join(unitDir, "apollo-filebeat.service"), unit, "utf8");
259
+ run("systemctl", ["--user", "daemon-reload"]);
260
+ return run("systemctl", ["--user", "enable", "--now", "apollo-filebeat.service"]);
261
+ }
262
+ // Windows: an at-logon Scheduled Task — starts at login, no admin.
263
+ function registerScheduledTask() {
264
+ const tr = [filebeatBinPath(), ...filebeatArgs()].map((a) => `"${a}"`).join(" ");
265
+ return run("schtasks", ["/Create", "/TN", "ApolloFilebeat", "/TR", tr, "/SC", "ONLOGON", "/F"]);
266
+ }
267
+ // Register + start the login-start user service for this OS. Best-effort: returns
268
+ // false if the platform service manager couldn't be driven.
269
+ export async function registerAndStartService() {
270
+ await mkdir(path.join(filebeatDir(), "data"), { recursive: true });
271
+ if (process.platform === "darwin")
272
+ return registerLaunchAgent();
273
+ if (process.platform === "linux")
274
+ return registerSystemdUser();
275
+ if (process.platform === "win32")
276
+ return registerScheduledTask();
277
+ return false;
278
+ }
279
+ // Stop + remove the login-start service (for `apollo telemetry disable`).
280
+ export function unregisterService() {
281
+ if (process.platform === "darwin") {
282
+ run("launchctl", [
283
+ "unload",
284
+ "-w",
285
+ path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`),
286
+ ]);
287
+ }
288
+ else if (process.platform === "linux") {
289
+ run("systemctl", ["--user", "disable", "--now", "apollo-filebeat.service"]);
290
+ }
291
+ else if (process.platform === "win32") {
292
+ run("schtasks", ["/Delete", "/TN", "ApolloFilebeat", "/F"]);
293
+ }
294
+ }
@@ -0,0 +1,71 @@
1
+ // packages/cli/src/lib/machine_identity.ts - Stable per-machine hardware identity
2
+ // for session telemetry. Uses the OS hardware UUID (NOT the MAC address, which
3
+ // randomises): macOS IOPlatformUUID, Linux /etc/machine-id, Windows MachineGuid.
4
+ // Formatted / deliberately-anonymised machines are explicitly out of scope.
5
+ import { execFileSync } from "node:child_process";
6
+ import { readFileSync } from "node:fs";
7
+ import os from "node:os";
8
+ // Run a command, returning trimmed stdout or "" on any failure.
9
+ function tryExec(cmd, args) {
10
+ try {
11
+ return execFileSync(cmd, args, {
12
+ encoding: "utf8",
13
+ stdio: ["ignore", "pipe", "ignore"],
14
+ }).trim();
15
+ }
16
+ catch {
17
+ return "";
18
+ }
19
+ }
20
+ // macOS: parse IOPlatformUUID + serial number from ioreg.
21
+ function macIdentity() {
22
+ const out = tryExec("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
23
+ const uuid = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? "";
24
+ const serial = out.match(/"IOPlatformSerialNumber"\s*=\s*"([^"]+)"/)?.[1];
25
+ return { uuid, serial };
26
+ }
27
+ // Linux: /etc/machine-id (falling back to the dbus machine-id).
28
+ function linuxMachineId() {
29
+ for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
30
+ try {
31
+ const value = readFileSync(file, "utf8").trim();
32
+ if (value)
33
+ return value;
34
+ }
35
+ catch {
36
+ /* try the next path */
37
+ }
38
+ }
39
+ return "";
40
+ }
41
+ // Windows: MachineGuid from the registry.
42
+ function windowsMachineGuid() {
43
+ const out = tryExec("reg", [
44
+ "query",
45
+ "HKLM\\SOFTWARE\\Microsoft\\Cryptography",
46
+ "/v",
47
+ "MachineGuid",
48
+ ]);
49
+ return out.match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i)?.[1] ?? "";
50
+ }
51
+ // Resolve the stable hardware identity for this machine. Best-effort: if the OS
52
+ // probe yields nothing, falls back to a hostname-derived id.
53
+ export function getMachineIdentity() {
54
+ const platform = process.platform;
55
+ let raw = "";
56
+ let serial;
57
+ if (platform === "darwin") {
58
+ const mac = macIdentity();
59
+ raw = mac.uuid;
60
+ serial = mac.serial;
61
+ }
62
+ else if (platform === "linux") {
63
+ raw = linuxMachineId();
64
+ }
65
+ else if (platform === "win32") {
66
+ raw = windowsMachineGuid();
67
+ }
68
+ const hostname = os.hostname();
69
+ const deviceKey = raw ? raw.toLowerCase() : `host-${hostname.toLowerCase()}`;
70
+ return { deviceKey, platform, arch: process.arch, hostname, serial };
71
+ }
@@ -0,0 +1,33 @@
1
+ // packages/cli/src/lib/nustack_store.ts - The ~/.nustack store: plain-JSON device
2
+ // identity + telemetry state for session sync. Independent of ~/.apollo (the
3
+ // device-trust token store) — fresh install only, nothing is read or migrated.
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ // The ~/.nustack root directory.
9
+ export function nustackDir() {
10
+ return path.join(os.homedir(), ".nustack");
11
+ }
12
+ // Absolute path of the device record.
13
+ export function deviceFilePath() {
14
+ return path.join(nustackDir(), "device.json");
15
+ }
16
+ // True if telemetry has already been enrolled on this machine.
17
+ export function isEnrolled() {
18
+ return existsSync(deviceFilePath());
19
+ }
20
+ // Read device.json, or null when absent / unreadable.
21
+ export async function readDeviceRecord() {
22
+ try {
23
+ return JSON.parse(await readFile(deviceFilePath(), "utf8"));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ // Write device.json (plain JSON), creating ~/.nustack if needed.
30
+ export async function writeDeviceRecord(record) {
31
+ await mkdir(nustackDir(), { recursive: true });
32
+ await writeFile(deviceFilePath(), `${JSON.stringify(record, null, 2)}\n`, "utf8");
33
+ }
@@ -0,0 +1,80 @@
1
+ // packages/cli/src/lib/session_telemetry.ts - One-time, idempotent session-sync
2
+ // enrolment: capture machine identity → ~/.nustack, fetch the brokered Elastic
3
+ // credential, install Filebeat once, add this project's input, start the
4
+ // login-start service. Best-effort — never throws into the caller and never
5
+ // changes the CLI exit code. Opt out with NUSTACK_TELEMETRY=off.
6
+ import { createHash } from "node:crypto";
7
+ import * as p from "@clack/prompts";
8
+ import { fetchBrokeredCredential } from "./apollo_client.js";
9
+ import { getMachineIdentity } from "./machine_identity.js";
10
+ import { isEnrolled, readDeviceRecord, writeDeviceRecord } from "./nustack_store.js";
11
+ import { ensureFilebeatBinary, registerAndStartService, writeMainConfig, writeProjectInput, } from "./filebeat_installer.js";
12
+ const TELEMETRY_SERVICE = "elastic-observability";
13
+ const DEFAULT_INDEX = "claude-sessions";
14
+ // Enrol this machine + project into Claude session sync. Safe to call on every
15
+ // bootstrap — idempotent, and only ships projects where `apollo` was run.
16
+ export async function ensureSessionTelemetry(opts) {
17
+ if (process.env.NUSTACK_TELEMETRY === "off")
18
+ return;
19
+ try {
20
+ const firstRun = !isEnrolled();
21
+ if (firstRun) {
22
+ p.log.info("Session sync: this machine ships the Claude session transcripts of apollo-installed\nprojects to the company observability index. Opt out any time with NUSTACK_TELEMETRY=off.");
23
+ }
24
+ // 1. Brokered credential — skip quietly when the broker isn't provisioned.
25
+ const credential = await fetchBrokeredCredential(opts.baseUrl, opts.appKey, TELEMETRY_SERVICE).catch((error) => {
26
+ if (firstRun) {
27
+ p.log.info(`Session sync not configured server-side yet (${error instanceof Error ? error.message : String(error)}) — skipping.`);
28
+ }
29
+ return null;
30
+ });
31
+ if (!credential)
32
+ return;
33
+ if (!credential.url) {
34
+ p.log.warn("Session sync: broker returned no ELK URL — skipping.");
35
+ return;
36
+ }
37
+ const index = credential.meta?.index || DEFAULT_INDEX;
38
+ // 2. Machine identity → ~/.nustack/device.json (reuse if already present).
39
+ const identity = getMachineIdentity();
40
+ const record = (await readDeviceRecord()) ?? {
41
+ schema_version: 1,
42
+ device_key: identity.deviceKey,
43
+ hardware: {
44
+ platform: identity.platform,
45
+ arch: identity.arch,
46
+ hostname: identity.hostname,
47
+ serial: identity.serial,
48
+ },
49
+ telemetry: { enabled: true, shipper: "filebeat", index, projects: [] },
50
+ };
51
+ // 3. Install Filebeat once + (re)write the main ELK-output config.
52
+ const spinner = p.spinner();
53
+ spinner.start(firstRun ? "Installing the session shipper (Filebeat)" : "Updating session sync");
54
+ await ensureFilebeatBinary((message) => spinner.message(message));
55
+ await writeMainConfig({ elkUrl: credential.url, apiKey: credential.key, index });
56
+ // 4. This project's input (scoped to its Claude sessions). Tag a one-way
57
+ // hash of the app key — NEVER the raw key: apps.id IS the bearer credential,
58
+ // so the raw value must not land in shipped documents.
59
+ const appRef = createHash("sha256").update(opts.appKey).digest("hex").slice(0, 16);
60
+ await writeProjectInput({
61
+ projectPath: opts.cwd,
62
+ appRef,
63
+ deviceKey: identity.deviceKey,
64
+ });
65
+ // 5. Start the login-start user service.
66
+ const started = await registerAndStartService();
67
+ spinner.stop(started ? "Session sync active" : "Session sync configured (service start skipped)");
68
+ // 6. Record state.
69
+ record.telemetry.enabled = true;
70
+ record.telemetry.index = index;
71
+ record.telemetry.installed_at = record.telemetry.installed_at ?? new Date().toISOString();
72
+ if (!record.telemetry.projects.includes(opts.cwd))
73
+ record.telemetry.projects.push(opts.cwd);
74
+ await writeDeviceRecord(record);
75
+ }
76
+ catch (error) {
77
+ // Best-effort: a telemetry failure must never break the CLI.
78
+ p.log.warn(`Session sync skipped (non-blocking): ${error instanceof Error ? error.message : String(error)}`);
79
+ }
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/apollo",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "The apollo CLI — bootstrap NuStack services into a repo via the Apollo discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.10.0",
36
- "typescript": "^5.7.2"
36
+ "typescript": "catalog:"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "restricted"