@nurix/nustack 0.20.1 → 0.20.3

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.
@@ -4,9 +4,9 @@ import { printJsonSuccess } from "../lib/json_output.js";
4
4
  import { openLocalStore } from "../lib/local_store/database.js";
5
5
  import { getBindingByRepositoryRoot, listActiveBindings, setBindingTelemetry, } from "../lib/local_store/bindings.js";
6
6
  import { upsertFilebeatState } from "../lib/local_store/filebeat_state.js";
7
- import { ensureFilebeatBinary, unregisterService } from "../lib/filebeat_installer.js";
7
+ import { ensureFilebeatBinary } from "../lib/filebeat_installer.js";
8
8
  import { collectTelemetryStatus } from "../lib/telemetry/health_report.js";
9
- import { ensureBindingTelemetry } from "../lib/session_telemetry.js";
9
+ import { ensureBindingTelemetry, setLocalOutput, switchMachineOff } from "../lib/session_telemetry.js";
10
10
  import { resolveRepository } from "../lib/git-sync/core/repository.js";
11
11
  function resolveTargetBinding(store, options) {
12
12
  if (options.device)
@@ -50,6 +50,38 @@ export async function runTelemetry(action, options) {
50
50
  p.outro("Telemetry status.");
51
51
  return;
52
52
  }
53
+ if (action === "output") {
54
+ const elkUrl = options.elasticUrl ?? process.env.NUSTACK_ELASTIC_URL;
55
+ const apiKey = process.env.NUSTACK_ELASTIC_API_KEY;
56
+ if (!elkUrl || !apiKey) {
57
+ throw new Error("telemetry output needs --elastic-url (or NUSTACK_ELASTIC_URL) and NUSTACK_ELASTIC_API_KEY in the environment");
58
+ }
59
+ const result = await setLocalOutput({
60
+ baseUrl: resolveNuStackUrl(options),
61
+ store,
62
+ elkUrl,
63
+ apiKey,
64
+ index: options.index,
65
+ });
66
+ const status = await collectTelemetryStatus(store, undefined, verbose);
67
+ if (!result.ok) {
68
+ process.exitCode = 1;
69
+ if (json) {
70
+ printJsonSuccess("telemetry output", { scope: "device", bindingId: null, applied: false, errorCode: result.errorCode, ...status });
71
+ return;
72
+ }
73
+ p.log.error(`The shipper refused the output (${result.errorCode}); the previous config is still live.`);
74
+ return;
75
+ }
76
+ if (json) {
77
+ printJsonSuccess("telemetry output", { scope: "device", bindingId: null, applied: true, ...status });
78
+ return;
79
+ }
80
+ p.intro("nustack telemetry output");
81
+ p.log.success(`Shipping to ${new URL(elkUrl).host}; reconciled ${result.reconciled} folder${result.reconciled === 1 ? "" : "s"}; install state ${status.filebeatState}.`);
82
+ p.outro("A credential the server brokers later replaces this output.");
83
+ return;
84
+ }
53
85
  const target = action === "reinstall" ? null : resolveTargetBinding(store, options);
54
86
  if (target) {
55
87
  const enabled = action === "enable";
@@ -69,22 +101,24 @@ export async function runTelemetry(action, options) {
69
101
  p.intro(`nustack telemetry ${action}`);
70
102
  p.log.success(enabled
71
103
  ? `${folderLabel(target)} is shipping. Other folders are unchanged.`
72
- : `${folderLabel(target)} has stopped shipping and its input config is removed. Other folders are unchanged.`);
104
+ : `${folderLabel(target)} has stopped shipping; nothing it records while off will ever ship. Other folders are unchanged.`);
73
105
  p.outro(`Run \`nustack telemetry ${enabled ? "disable" : "enable"}\` here to reverse it.`);
74
106
  return;
75
107
  }
76
108
  if (action === "disable") {
77
- unregisterService();
78
- upsertFilebeatState(store, { telemetryEnabled: false, processState: "stopped" });
109
+ const result = await switchMachineOff({ store });
79
110
  if (json) {
80
111
  printJsonSuccess("telemetry disable", {
81
112
  scope: "device",
82
113
  bindingId: null,
83
114
  telemetryEnabled: false,
115
+ shipper: result,
84
116
  });
85
117
  return;
86
118
  }
87
- p.log.success("Stopped the telemetry shipper on this machine; every folder reports disabled (Document sync untouched).");
119
+ p.log.success(result === "skipping"
120
+ ? "Telemetry is off on this machine: the shipper keeps reading and discards everything, so nothing recorded while off will ever ship (Document sync untouched)."
121
+ : "Telemetry is off on this machine. The shipper refused the switch-off config, so it was stopped instead: sessions recorded while off will ship when you turn it back on.");
88
122
  p.outro("Run `nustack telemetry enable` to resume.");
89
123
  return;
90
124
  }
package/dist/index.js CHANGED
@@ -179,18 +179,22 @@ device
179
179
  .action(async (deviceId, options) => runDeviceRevoke(deviceId, options, program.opts()));
180
180
  program
181
181
  .command("telemetry [action]")
182
- .description("Inspect or control telemetry (status | enable | disable | reinstall). Inside a bound folder, enable/disable move that folder alone")
182
+ .description("Inspect or control telemetry (status | enable | disable | reinstall | output). Inside a bound folder, enable/disable move that folder alone; output points the shipper at your own Elastic, its key read from NUSTACK_ELASTIC_API_KEY")
183
183
  .option("--json", "emit the versioned machine envelope")
184
184
  .option("--device", "move this machine's switch instead of the current folder's")
185
185
  .option("--local-verbose", "include each folder's absolute path (local consumers only — never pipe into a log)")
186
+ .option("--elastic-url <url>", "output: the Elastic endpoint (default NUSTACK_ELASTIC_URL)")
187
+ .option("--index <name>", "output: the index events are written to (default claude-sessions)")
186
188
  .action(async (action, options) => {
187
- const allowed = ["status", "enable", "disable", "reinstall"];
189
+ const allowed = ["status", "enable", "disable", "reinstall", "output"];
188
190
  const chosen = (allowed.includes(action ?? "") ? action : "status");
189
191
  return runTelemetry(chosen, {
190
192
  ...program.opts(),
191
193
  json: options.json,
192
194
  device: options.device,
193
195
  localVerbose: options.localVerbose,
196
+ elasticUrl: options.elasticUrl,
197
+ index: options.index,
194
198
  });
195
199
  });
196
200
  const toLimit = (value) => Number.parseInt(value, 10);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { readdir } from "node:fs/promises";
3
+ import { readFile, readdir } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { ensureAccessToken } from "./auth_grant.js";
6
6
  import { fetchBrokeredCredential } from "./nustack_client.js";
@@ -9,8 +9,8 @@ import { openLocalStore } from "./local_store/database.js";
9
9
  import { readDeviceState } from "./local_store/device_state.js";
10
10
  import { listActiveBindings } from "./local_store/bindings.js";
11
11
  import { listActivityBindings, readFilebeatState, upsertActivityBinding, upsertFilebeatState, } from "./local_store/filebeat_state.js";
12
- import { ensureFilebeatBinary, filebeatBinPath, filebeatDir, desiredFilebeatVersion, observedFilebeatVersion, probeServiceRegistered, registerAndStartService, restartService, testFilebeatConfig, } from "./filebeat_installer.js";
13
- import { renderBindingInput, renderCentralConfig, renderCodexSharedInput, } from "./telemetry/config_renderer.js";
12
+ import { ensureFilebeatBinary, filebeatBinPath, filebeatDir, mainConfigPath, desiredFilebeatVersion, observedFilebeatVersion, probeServiceRegistered, registerAndStartService, restartService, testFilebeatConfig, unregisterService, } from "./filebeat_installer.js";
13
+ import { INPUTS_FILE, LOCAL_OUTPUT_HEADER, renderCentralConfig, renderInputsFile, } from "./telemetry/config_renderer.js";
14
14
  import { applyConfigChange, removeInputFile } from "./telemetry/reload.js";
15
15
  import { discoverProviderBindings } from "./telemetry/provider_paths.js";
16
16
  import { reportTelemetryHealth } from "./telemetry/health_report.js";
@@ -28,6 +28,7 @@ function defaultSeams() {
28
28
  registerService: () => registerAndStartService(),
29
29
  serviceRegistered: () => probeServiceRegistered(),
30
30
  restartService: () => restartService(),
31
+ unregisterService: () => unregisterService(),
31
32
  fetchImpl: fetch,
32
33
  ensureAccessToken: (baseUrl) => ensureAccessToken(baseUrl),
33
34
  };
@@ -109,62 +110,64 @@ export async function ensureBindingTelemetry(opts) {
109
110
  const device = readDeviceState(store);
110
111
  const deviceRef = device?.deviceRef ?? "";
111
112
  const userId = device?.activeUserId ?? "";
113
+ const localOutput = await hasLocalOutput();
112
114
  const auth = await seams.ensureAccessToken(baseUrl);
113
- if (!auth) {
115
+ if (!auth && !localOutput) {
114
116
  recordDegraded(store, "no_device_token");
115
117
  await reportBestEffort(baseUrl, store, seams, null);
116
118
  return;
117
119
  }
120
+ const accessToken = auth?.accessToken ?? null;
118
121
  if (!binding.telemetryEnabled) {
119
122
  await removeBindingTelemetry({ store, bindingId: binding.id, seams: opts.seams });
120
- await reportBestEffort(baseUrl, store, seams, auth.accessToken);
123
+ await reportBestEffort(baseUrl, store, seams, accessToken);
121
124
  return;
122
125
  }
123
126
  let brokerFailure = null;
124
- const credential = await fetchBrokeredCredential(baseUrl, auth.accessToken, {
125
- projectId: binding.projectId,
126
- service: TELEMETRY_SERVICE,
127
- }).catch((error) => {
128
- brokerFailure = error instanceof Error ? error.message : String(error);
129
- return null;
130
- });
131
- if (!credential?.url) {
127
+ const credential = accessToken
128
+ ? await fetchBrokeredCredential(baseUrl, accessToken, {
129
+ projectId: binding.projectId,
130
+ service: TELEMETRY_SERVICE,
131
+ }).catch((error) => {
132
+ brokerFailure = error instanceof Error ? error.message : String(error);
133
+ return null;
134
+ })
135
+ : null;
136
+ if (!credential?.url && !localOutput) {
132
137
  const unserved = brokerFailure !== null && brokerFailure.includes("does not serve");
133
138
  recordDegraded(store, unserved ? "credential_plane_unserved" : "no_output_credential");
134
- await reportBestEffort(baseUrl, store, seams, auth.accessToken);
139
+ await reportBestEffort(baseUrl, store, seams, accessToken);
135
140
  return;
136
141
  }
137
- const index = credential.meta?.index || DEFAULT_INDEX;
138
- const central = {
139
- relativePath: "filebeat.yml",
140
- contents: renderCentralConfig({ elkUrl: credential.url, apiKey: credential.key, index }),
141
- };
142
- const centralApply = await applyConfigChange({
143
- files: [central],
144
- scope: "central",
145
- context: centralReloadContext(seams, filebeatDir()),
146
- });
147
- if (!centralApply.ok) {
148
- recordDegraded(store, centralApply.errorCode);
149
- await reportBestEffort(baseUrl, store, seams, auth.accessToken);
150
- return;
142
+ if (credential?.url) {
143
+ const index = credential.meta?.index || DEFAULT_INDEX;
144
+ const central = {
145
+ relativePath: "filebeat.yml",
146
+ contents: renderCentralConfig({ elkUrl: credential.url, apiKey: credential.key, index }),
147
+ };
148
+ const centralApply = await applyConfigChange({
149
+ files: [central],
150
+ scope: "central",
151
+ context: centralReloadContext(seams, filebeatDir()),
152
+ });
153
+ if (!centralApply.ok) {
154
+ recordDegraded(store, centralApply.errorCode);
155
+ await reportBestEffort(baseUrl, store, seams, accessToken);
156
+ return;
157
+ }
151
158
  }
152
159
  if (!seams.serviceRegistered())
153
160
  await seams.registerService();
154
- const bindings = activeBindingSet(store, binding, deviceRef, userId);
155
- const inputs = [
156
- { relativePath: `inputs.d/${binding.id}.yml`, contents: renderBindingInput(toTelemetryBinding(binding, deviceRef, userId)) },
157
- { relativePath: "inputs.d/codex-shared.yml", contents: renderCodexSharedInput(bindings) },
158
- ];
159
- await removeOrphanInputs(bindings);
161
+ const inputs = renderInputs(store, { id: binding.id, binding, enabled: true }, deviceRef, userId) ?? "";
162
+ await removeOrphanInputs();
160
163
  const inputsApply = await applyConfigChange({
161
- files: inputs,
164
+ files: [{ relativePath: INPUTS_FILE, contents: inputs }],
162
165
  scope: "inputs",
163
166
  context: seams.reloadContext(filebeatDir()),
164
167
  });
165
168
  if (!inputsApply.ok) {
166
169
  recordDegraded(store, inputsApply.errorCode);
167
- await reportBestEffort(baseUrl, store, seams, auth.accessToken);
170
+ await reportBestEffort(baseUrl, store, seams, accessToken);
168
171
  return;
169
172
  }
170
173
  for (const discovered of discoverProviderBindings(binding.repositoryRoot)) {
@@ -185,31 +188,83 @@ export async function ensureBindingTelemetry(opts) {
185
188
  processState: "running",
186
189
  lastErrorCode: null,
187
190
  });
188
- await reportBestEffort(baseUrl, store, seams, auth.accessToken);
191
+ await reportBestEffort(baseUrl, store, seams, accessToken);
189
192
  }
190
193
  catch (error) {
191
194
  recordDegraded(store, "binding_reconcile_failed");
192
195
  void message(error);
193
196
  }
194
197
  }
198
+ export async function hasLocalOutput() {
199
+ try {
200
+ const contents = await readFile(mainConfigPath(), "utf8");
201
+ return contents.split("\n", 1)[0] === LOCAL_OUTPUT_HEADER;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ export async function setLocalOutput(opts) {
208
+ const seams = { ...defaultSeams(), ...opts.seams };
209
+ const { store, baseUrl } = opts;
210
+ await seams.ensureBinary(NO_OP_LOG);
211
+ const central = {
212
+ relativePath: "filebeat.yml",
213
+ contents: renderCentralConfig({
214
+ elkUrl: opts.elkUrl,
215
+ apiKey: opts.apiKey,
216
+ index: opts.index || DEFAULT_INDEX,
217
+ origin: "local",
218
+ }),
219
+ };
220
+ const centralApply = await applyConfigChange({
221
+ files: [central],
222
+ scope: "central",
223
+ context: centralReloadContext(seams, filebeatDir()),
224
+ });
225
+ if (!centralApply.ok) {
226
+ recordDegraded(store, centralApply.errorCode);
227
+ return { ok: false, errorCode: centralApply.errorCode };
228
+ }
229
+ if (!seams.serviceRegistered())
230
+ await seams.registerService();
231
+ upsertFilebeatState(store, {
232
+ binaryPath: existsSync(filebeatBinPath()) ? filebeatBinPath() : null,
233
+ configDir: filebeatDir(),
234
+ desiredVersion: desiredFilebeatVersion(),
235
+ observedVersion: observedFilebeatVersion(),
236
+ processState: "running",
237
+ telemetryEnabled: true,
238
+ lastErrorCode: null,
239
+ });
240
+ const bindings = listActiveBindings(store);
241
+ for (const binding of bindings) {
242
+ await ensureBindingTelemetry({ baseUrl, store, binding, seams: opts.seams });
243
+ }
244
+ if (bindings.length === 0)
245
+ await reportBestEffort(baseUrl, store, seams);
246
+ return { ok: true, reconciled: bindings.length };
247
+ }
195
248
  export async function removeBindingTelemetry(opts) {
196
249
  const seams = { ...defaultSeams(), ...opts.seams };
197
250
  const { store, bindingId } = opts;
198
251
  try {
199
- await removeInputFile(filebeatDir(), `inputs.d/${bindingId}.yml`);
200
252
  const device = readDeviceState(store);
201
- const remaining = listActiveBindings(store)
202
- .filter((b) => b.id !== bindingId)
203
- .map((b) => toTelemetryBinding(b, device?.deviceRef ?? "", device?.activeUserId ?? ""));
204
- if (remaining.length === 0) {
205
- await removeInputFile(filebeatDir(), "inputs.d/codex-shared.yml");
253
+ await removeOrphanInputs();
254
+ const inputs = renderInputs(store, { id: bindingId, enabled: false }, device?.deviceRef ?? "", device?.activeUserId ?? "");
255
+ if (inputs === null) {
256
+ await removeInputFile(filebeatDir(), INPUTS_FILE);
206
257
  }
207
258
  else {
208
- await applyConfigChange({
209
- files: [{ relativePath: "inputs.d/codex-shared.yml", contents: renderCodexSharedInput(remaining) }],
259
+ const applied = await applyConfigChange({
260
+ files: [{ relativePath: INPUTS_FILE, contents: inputs }],
210
261
  scope: "inputs",
211
262
  context: seams.reloadContext(filebeatDir()),
212
263
  });
264
+ if (!applied.ok) {
265
+ await removeInputFile(filebeatDir(), INPUTS_FILE);
266
+ recordDegraded(store, applied.errorCode);
267
+ }
213
268
  }
214
269
  for (const row of listActivityBindings(store, bindingId)) {
215
270
  upsertActivityBinding(store, {
@@ -225,22 +280,53 @@ export async function removeBindingTelemetry(opts) {
225
280
  catch {
226
281
  }
227
282
  }
228
- function activeBindingSet(store, binding, deviceRef, userId) {
283
+ export async function switchMachineOff(opts) {
284
+ const seams = { ...defaultSeams(), ...opts.seams };
285
+ const { store } = opts;
286
+ upsertFilebeatState(store, { telemetryEnabled: false });
287
+ const device = readDeviceState(store);
288
+ const deviceRef = device?.deviceRef ?? "";
289
+ const userId = device?.activeUserId ?? "";
290
+ const entries = listActiveBindings(store).map((b) => ({ binding: toTelemetryBinding(b, deviceRef, userId), enabled: false }));
291
+ try {
292
+ await removeOrphanInputs();
293
+ if (entries.length === 0) {
294
+ await removeInputFile(filebeatDir(), INPUTS_FILE);
295
+ return "skipping";
296
+ }
297
+ const applied = await applyConfigChange({
298
+ files: [{ relativePath: INPUTS_FILE, contents: renderInputsFile(entries, { deviceRef, userId }) }],
299
+ scope: "inputs",
300
+ context: seams.reloadContext(filebeatDir()),
301
+ });
302
+ if (applied.ok)
303
+ return "skipping";
304
+ recordDegraded(store, applied.errorCode);
305
+ }
306
+ catch {
307
+ recordDegraded(store, "config_invalid");
308
+ }
309
+ seams.unregisterService();
310
+ upsertFilebeatState(store, { processState: "stopped" });
311
+ return "stopped";
312
+ }
313
+ function renderInputs(store, reconciled, deviceRef, userId) {
229
314
  const byId = new Map();
230
315
  for (const b of listActiveBindings(store)) {
231
- if (b.telemetryEnabled)
232
- byId.set(b.id, toTelemetryBinding(b, deviceRef, userId));
316
+ byId.set(b.id, { binding: toTelemetryBinding(b, deviceRef, userId), enabled: b.telemetryEnabled });
233
317
  }
234
- byId.set(binding.id, toTelemetryBinding(binding, deviceRef, userId));
235
- return [...byId.values()];
318
+ const known = reconciled.binding ? toTelemetryBinding(reconciled.binding, deviceRef, userId) : byId.get(reconciled.id)?.binding;
319
+ if (known)
320
+ byId.set(reconciled.id, { binding: known, enabled: reconciled.enabled });
321
+ return byId.size === 0 ? null : renderInputsFile([...byId.values()], { deviceRef, userId });
236
322
  }
237
- async function removeOrphanInputs(bindings) {
323
+ async function removeOrphanInputs() {
238
324
  const inputsDirPath = path.join(filebeatDir(), "inputs.d");
239
325
  if (!existsSync(inputsDirPath))
240
326
  return;
241
- const keep = new Set(["codex-shared.yml", ...bindings.map((b) => `${b.bindingId}.yml`)]);
327
+ const keep = path.basename(INPUTS_FILE);
242
328
  for (const name of await readdir(inputsDirPath)) {
243
- if (name.endsWith(".yml") && !keep.has(name)) {
329
+ if (name.endsWith(".yml") && name !== keep) {
244
330
  await removeInputFile(filebeatDir(), `inputs.d/${name}`);
245
331
  }
246
332
  }
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { assertSafeApiKey, assertSafeElkUrl, assertSafeIndexName } from "../guards.js";
3
3
  import { claudeProjectDir, codexSessionsDir } from "./provider_paths.js";
4
4
  import { REDACTION_POLICY_VERSION, SLASH_COMMAND_CLOSE, SLASH_COMMAND_OPEN, providerDropFields, } from "./redaction_policy.js";
5
- export const TELEMETRY_CONFIG_VERSION = 2;
5
+ export const TELEMETRY_CONFIG_VERSION = 3;
6
6
  export const ENVELOPE_SCHEMA_VERSION = 2;
7
7
  export function renderTemplate(template, vars) {
8
8
  return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, name) => {
@@ -32,7 +32,9 @@ export function claudeFilestreamId(repoRoot) {
32
32
  export function desiredConfigHash(bytes) {
33
33
  return createHash("sha256").update(bytes).digest("hex");
34
34
  }
35
- const CENTRAL_TEMPLATE = `# Managed by the nustack CLI — do not edit by hand.
35
+ export const BROKERED_OUTPUT_HEADER = "# Managed by the nustack CLI — do not edit by hand.";
36
+ export const LOCAL_OUTPUT_HEADER = "# Managed by the nustack CLI — local output set by `nustack telemetry output`; do not edit by hand.";
37
+ const CENTRAL_TEMPLATE = `{{header}}
36
38
  filebeat.config.inputs:
37
39
  enabled: true
38
40
  path: \${path.config}/inputs.d/*.yml
@@ -60,9 +62,9 @@ output.elasticsearch:
60
62
  index: {{index}}
61
63
  ssl.verification_mode: full
62
64
 
65
+ # The index and its template already exist, so the shipping key needs no template privilege.
63
66
  setup.ilm.enabled: false
64
- setup.template.name: {{index}}
65
- setup.template.pattern: {{index_pattern}}
67
+ setup.template.enabled: false
66
68
 
67
69
  logging.level: warning
68
70
  `;
@@ -71,13 +73,21 @@ export function renderCentralConfig(opts) {
71
73
  const apiKey = assertSafeApiKey(opts.apiKey);
72
74
  const index = assertSafeIndexName(opts.index);
73
75
  return renderTemplate(CENTRAL_TEMPLATE, {
76
+ header: opts.origin === "local" ? LOCAL_OUTPUT_HEADER : BROKERED_OUTPUT_HEADER,
74
77
  elk_url: yamlScalar(elkUrl),
75
78
  api_key: yamlScalar(apiKey),
76
79
  index: yamlScalar(index),
77
- index_pattern: yamlScalar(`${index}-*`),
78
80
  });
79
81
  }
80
- const BINDING_TEMPLATE = `# Managed by the nustack CLI — binding: {{binding_id_raw}}
82
+ export const INPUTS_FILE = "inputs.d/inputs.yml";
83
+ export const CODEX_INPUT_KEY = "codex-shared";
84
+ const INPUTS_HEADER = `# Managed by the nustack CLI — one input per bound folder, then the shared Codex input.
85
+ # A switched-off folder's input still reads its sessions and drops every event, so nothing written
86
+ # while it is off ever ships — \`nustack telemetry enable | disable\` inside the folder flips it;
87
+ # do not edit by hand.
88
+ `;
89
+ const BLOCK_MARKER = /^# (?:binding: (\S+)|codex: shared)$/gm;
90
+ const BINDING_TEMPLATE = `# binding: {{binding_id_raw}}
81
91
  - type: filestream
82
92
  id: {{filestream_id}}
83
93
  paths:
@@ -109,8 +119,9 @@ const BINDING_TEMPLATE = `# Managed by the nustack CLI — binding: {{binding_id
109
119
  fields: [{{drop_fields}}]
110
120
  ignore_missing: true
111
121
  `;
112
- export function renderBindingInput(binding) {
113
- return renderTemplate(BINDING_TEMPLATE, {
122
+ const SWITCHED_OFF = " - drop_event: {} # switched off: read and discarded, never shipped\n";
123
+ export function renderBindingInput(binding, enabled = true) {
124
+ const block = renderTemplate(BINDING_TEMPLATE, {
114
125
  binding_id_raw: binding.bindingId,
115
126
  filestream_id: claudeFilestreamId(binding.repoRoot),
116
127
  claude_glob: yamlScalar(`${claudeProjectDir(binding.repoRoot)}/**/*.jsonl`),
@@ -126,15 +137,15 @@ export function renderBindingInput(binding) {
126
137
  slash_close: SLASH_COMMAND_CLOSE,
127
138
  drop_fields: providerDropFields("claude_code").join(", "),
128
139
  });
140
+ return enabled ? block : block.replace("\n processors:\n", `\n processors:\n${SWITCHED_OFF}`);
129
141
  }
130
- export function renderCodexSharedInput(bindings) {
131
- if (bindings.length === 0) {
132
- throw new Error("renderCodexSharedInput requires at least one binding");
133
- }
134
- const sorted = [...bindings].sort((a, b) => (a.bindingId < b.bindingId ? -1 : a.bindingId > b.bindingId ? 1 : 0));
135
- const device = sorted[0];
142
+ export function renderCodexSharedInput(entries, device) {
143
+ const ordered = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
144
+ const sorted = ordered.filter((entry) => entry.enabled).map((entry) => entry.binding);
145
+ const drops = sorted.length === 0 ? SWITCHED_OFF : "";
136
146
  const conditionals = sorted
137
- .map((binding) => ` - if:
147
+ .map((binding) => `
148
+ - if:
138
149
  equals:
139
150
  cwd: ${yamlScalar(binding.repoRoot)}
140
151
  then:
@@ -144,9 +155,9 @@ export function renderCodexSharedInput(bindings) {
144
155
  organization_id: ${yamlScalar(binding.organizationId)}
145
156
  project_id: ${yamlScalar(binding.projectId)}
146
157
  binding_id: ${yamlScalar(binding.bindingId)}`)
147
- .join("\n");
158
+ .join("");
148
159
  const dropList = ["cwd", ...providerDropFields("codex")].join(", ");
149
- return `# Managed by the nustack CLI — codex shared input (all bindings)
160
+ return `# codex: shared
150
161
  - type: filestream
151
162
  id: codex-shared
152
163
  paths:
@@ -158,7 +169,7 @@ export function renderCodexSharedInput(bindings) {
158
169
  add_error_key: true
159
170
  fields_under_root: true
160
171
  processors:
161
- - add_fields:
172
+ ${drops} - add_fields:
162
173
  target: nustack
163
174
  fields:
164
175
  schema_version: ${ENVELOPE_SCHEMA_VERSION}
@@ -166,10 +177,25 @@ export function renderCodexSharedInput(bindings) {
166
177
  user_id: ${yamlScalar(device.userId)}
167
178
  provider: ${yamlScalar("codex")}
168
179
  redaction_policy_version: ${REDACTION_POLICY_VERSION}
169
- redaction_applied: true
170
- ${conditionals}
180
+ redaction_applied: true${conditionals}
171
181
  - drop_fields:
172
182
  fields: [${dropList}]
173
183
  ignore_missing: true
174
184
  `;
175
185
  }
186
+ export function renderInputsFile(entries, device) {
187
+ const sorted = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
188
+ const blocks = sorted.map((entry) => renderBindingInput(entry.binding, entry.enabled));
189
+ return INPUTS_HEADER + blocks.join("") + renderCodexSharedInput(sorted, device);
190
+ }
191
+ export function splitInputBlocks(contents) {
192
+ const starts = [...contents.matchAll(BLOCK_MARKER)].map((match) => ({
193
+ key: match[1] ?? CODEX_INPUT_KEY,
194
+ index: match.index ?? 0,
195
+ }));
196
+ const blocks = new Map();
197
+ starts.forEach((start, position) => {
198
+ blocks.set(start.key, contents.slice(start.index, starts[position + 1]?.index ?? contents.length));
199
+ });
200
+ return blocks;
201
+ }
@@ -4,9 +4,9 @@ import path from "node:path";
4
4
  import { listActiveBindings } from "../local_store/bindings.js";
5
5
  import { readDeviceState } from "../local_store/device_state.js";
6
6
  import { listActivityBindings, readFilebeatState, upsertFilebeatState, } from "../local_store/filebeat_state.js";
7
- import { desiredFilebeatVersion, filebeatBinPath, filebeatDir, inputsDir, observedFilebeatVersion, probeServiceRegistered, } from "../filebeat_installer.js";
7
+ import { desiredFilebeatVersion, filebeatBinPath, filebeatDir, observedFilebeatVersion, probeServiceRegistered, } from "../filebeat_installer.js";
8
8
  import { REDACTION_POLICY_VERSION, } from "./redaction_policy.js";
9
- import { TELEMETRY_CONFIG_VERSION, desiredConfigHash, renderBindingInput, } from "./config_renderer.js";
9
+ import { INPUTS_FILE, TELEMETRY_CONFIG_VERSION, desiredConfigHash, renderBindingInput, splitInputBlocks, } from "./config_renderer.js";
10
10
  import { discoverProviderBindings } from "./provider_paths.js";
11
11
  import { readObservedConfigBytes } from "./reload.js";
12
12
  export function defaultProbes() {
@@ -18,8 +18,11 @@ export function defaultProbes() {
18
18
  serviceRegistered: () => probeServiceRegistered(),
19
19
  observedConfigBytes: () => readObservedConfigBytes(filebeatDir()),
20
20
  bindingInputHash: async (bindingId) => {
21
- const file = path.join(inputsDir(), `${bindingId}.yml`);
22
- return existsSync(file) ? desiredConfigHash(await readFile(file, "utf8")) : null;
21
+ const file = path.join(filebeatDir(), INPUTS_FILE);
22
+ if (!existsSync(file))
23
+ return null;
24
+ const block = splitInputBlocks(await readFile(file, "utf8")).get(bindingId);
25
+ return block === undefined ? null : desiredConfigHash(block);
23
26
  },
24
27
  };
25
28
  }
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { copyFile, readdir, readFile, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { mkdirSecure, writeFileSecure } from "../secure_file.js";
5
+ import { CODEX_INPUT_KEY, INPUTS_FILE, splitInputBlocks } from "./config_renderer.js";
5
6
  const MAIN_CONFIG = "filebeat.yml";
6
7
  const INPUTS_DIR = "inputs.d";
7
8
  async function liveConfigFiles(configDir) {
@@ -75,10 +76,8 @@ export async function removeInputFile(configDir, relativePath) {
75
76
  await rm(path.join(configDir, relativePath), { force: true });
76
77
  }
77
78
  export async function readObservedConfigBytes(configDir) {
78
- const parts = [];
79
- for (const relativePath of [MAIN_CONFIG, `${INPUTS_DIR}/codex-shared.yml`]) {
80
- const file = path.join(configDir, relativePath);
81
- parts.push(existsSync(file) ? await readFile(file, "utf8") : "");
82
- }
83
- return parts.join("\n");
79
+ const central = path.join(configDir, MAIN_CONFIG);
80
+ const inputs = path.join(configDir, INPUTS_FILE);
81
+ const codex = existsSync(inputs) ? (splitInputBlocks(await readFile(inputs, "utf8")).get(CODEX_INPUT_KEY) ?? "") : "";
82
+ return [existsSync(central) ? await readFile(central, "utf8") : "", codex].join("\n");
84
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,