@stigmer/cli 3.12.6 → 3.12.8

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.
@@ -1 +1 @@
1
- {"version":3,"file":"wizard.js","sourceRoot":"","sources":["../../../src/local/setup/wizard.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,4EAA4E;AAC5E,wEAAwE;AACxE,iEAAiE;AACjE,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,4EAA4E;AAC5E,2EAA2E;AAC3E,2EAA2E;AAC3E,uDAAuD;AAGvD,OAAO,EAAoB,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,WAAW,EAAE,MAAM,CAAU,CAAC;AAQ/D;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAsB,EAAE,SAA0B,EAAE;IACpF,IAAI,MAAM,KAAK,MAAM;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,QAAQ,GAAgB,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnD,IAAI,MAAM,CAAC,MAAM;QAAE,QAAQ,CAAC,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC;IACpD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,WAAW,CAAC,MAAc,EAAE,MAAsB,EAAE,SAA0B,EAAE;IAC9F,OAAO,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,MAAc,EAAE,MAAyB,OAAO,CAAC,GAAG;IAC7F,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,2DAA2D,CAAC,CAAC;IAClF,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,yEAAyE,CAAC,CAAC;IAEhG,IAAI,GAAG,CAAC,iBAAiB,EAAE,CAAC;QAC1B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAC;QACnE,OAAO,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC1C,CAAC;IAED,MAAM,GAAG,GAAG,CAAC,MAAM,YAAY,CAAC,oDAAoD,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9F,IAAI,GAAG,KAAK,EAAE,EAAE,CAAC;QACf,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,iFAAiF,CAAC,CAAC;QACxG,OAAO,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;AAC3D,CAAC"}
1
+ {"version":3,"file":"wizard.js","sourceRoot":"","sources":["../../../src/local/setup/wizard.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/cli",
3
- "version": "3.12.6",
3
+ "version": "3.12.8",
4
4
  "description": "Stigmer command-line interface — manage agents, workflows, MCP servers, skills, and executions from the terminal",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,10 +35,10 @@
35
35
  "@connectrpc/connect": "^2.0.0",
36
36
  "@connectrpc/connect-node": "^2.1.1",
37
37
  "@modelcontextprotocol/sdk": "^1.26.0",
38
- "@stigmer/ink": "3.12.6",
39
- "@stigmer/mcp-server": "3.12.6",
40
- "@stigmer/protos": "3.12.6",
41
- "@stigmer/sdk": "3.12.6",
38
+ "@stigmer/ink": "3.12.8",
39
+ "@stigmer/mcp-server": "3.12.8",
40
+ "@stigmer/protos": "3.12.8",
41
+ "@stigmer/sdk": "3.12.8",
42
42
  "commander": "^12.1.0",
43
43
  "fflate": "^0.8.3",
44
44
  "ink": "^7.0.0",
@@ -35,7 +35,7 @@ Environment variables:
35
35
  STIGMER_SERVER_ADDRESS gRPC address (default: CLI config, else localhost:7234)
36
36
  STIGMER_API_KEY API key (auto-resolved from CLI config for cloud)
37
37
  STIGMER_MCP_TRANSPORT stdio | http | both (default: stdio)
38
- STIGMER_MCP_ROSTER full | records | channels (default: full; records = the five record tools only, channels = send_channel_message only)
38
+ STIGMER_MCP_ROSTER full | channels | memory (default: full; channels = send_channel_message only, memory = remember only)
39
39
  STIGMER_MCP_HTTP_PORT HTTP listen port (default: 8080)
40
40
  STIGMER_MCP_LOG_FORMAT text | json (default: text)
41
41
  STIGMER_MCP_LOG_LEVEL debug | info | warn | error (default: info)
@@ -1,11 +1,14 @@
1
- // `stigmer setup` — configure the LLM provider used for agent execution.
1
+ // `stigmer setup` — configure the LLM provider used for agent execution and
2
+ // the optional self-hosted operator identity (oss#796).
2
3
  //
3
4
  // Two paths share one decision core (local/setup/wizard.ts): a non-interactive
4
- // flag path (`--provider`, `--api-key`) for scripts and CI, and an
5
- // interactive API-key prompt when no provider flag is given. Both write to
6
- // backend.local.llm, preserving every other config field. The valid provider
7
- // set is owned by wizard.ts's PROVIDER_CHOICES (Anthropic-only + skip — the
8
- // only story the native runner serves).
5
+ // flag path (`--provider`/`--api-key`, `--operator-email`/`--operator-name`)
6
+ // for scripts and CI, and an interactive flow when NO flag is given. Flags
7
+ // are independently applicable — operator flags without `--provider` leave
8
+ // the LLM section untouched, and vice versa. Writes land under
9
+ // backend.local.llm / backend.local.operator, preserving every other config
10
+ // field. The valid provider set is owned by wizard.ts's PROVIDER_CHOICES
11
+ // (Anthropic-only + skip — the only story the native runner serves).
9
12
  //
10
13
  // Setup deliberately has NO model concept: the platform model registry owns
11
14
  // the execution default, and per-run overrides belong to `stigmer run
@@ -21,45 +24,72 @@ import { addResultFlags, resultFormat } from "./shared.js";
21
24
  interface SetupFlags extends OutputFlags {
22
25
  provider?: string;
23
26
  apiKey?: string;
27
+ operatorEmail?: string;
28
+ operatorName?: string;
24
29
  }
25
30
 
26
31
  export function registerSetup(program: Command): void {
27
32
  const setup = program
28
33
  .command("setup")
29
- .description("configure the LLM provider for agent execution")
34
+ .description("configure the LLM provider and operator identity for local execution")
30
35
  .option("--provider <name>", "provider: anthropic, or skip to clear (non-interactive)")
31
36
  .option("--api-key <key>", "Anthropic API key")
37
+ .option("--operator-email <email>", "operator identity for local caller attribution (non-interactive)")
38
+ .option("--operator-name <name>", "operator display name (requires --operator-email)")
32
39
  .action((options: SetupFlags) => runSetup(options));
33
40
  addResultFlags(setup);
34
41
  }
35
42
 
36
43
  async function runSetup(options: SetupFlags): Promise<void> {
37
44
  const { load, save } = await import("../config/config.js");
38
- const { PROVIDER_CHOICES, applyChoice, runInteractiveWizard } = await import("../local/setup/wizard.js");
45
+ const { PROVIDER_CHOICES, applyChoice, applyOperatorIdentity, runInteractiveWizard } =
46
+ await import("../local/setup/wizard.js");
39
47
  const { isRunning } = await import("../local/daemon/launch.js");
40
48
  const { CliExitError } = await import("../errors/cli-exit-error.js");
41
49
  const { ExitCode } = await import("../errors/exit-codes.js");
42
50
 
43
51
  const config = load();
52
+ const nonInteractive =
53
+ options.provider !== undefined ||
54
+ options.operatorEmail !== undefined ||
55
+ options.operatorName !== undefined;
44
56
 
45
57
  let updated: typeof config;
46
- if (options.provider !== undefined) {
47
- const provider = options.provider.toLowerCase();
48
- if (!(PROVIDER_CHOICES as readonly string[]).includes(provider)) {
49
- throw new CliExitError(`unknown provider: ${options.provider}`, ExitCode.Usage, [
50
- `Valid providers: ${PROVIDER_CHOICES.join(", ")}`,
51
- ]);
58
+ if (nonInteractive) {
59
+ updated = config;
60
+ if (options.provider !== undefined) {
61
+ const provider = options.provider.toLowerCase();
62
+ if (!(PROVIDER_CHOICES as readonly string[]).includes(provider)) {
63
+ throw new CliExitError(`unknown provider: ${options.provider}`, ExitCode.Usage, [
64
+ `Valid providers: ${PROVIDER_CHOICES.join(", ")}`,
65
+ ]);
66
+ }
67
+ updated = applyChoice(updated, provider as (typeof PROVIDER_CHOICES)[number], {
68
+ apiKey: options.apiKey,
69
+ });
70
+ }
71
+ if (options.operatorEmail !== undefined || options.operatorName !== undefined) {
72
+ try {
73
+ updated = applyOperatorIdentity(
74
+ updated,
75
+ options.operatorEmail ?? "",
76
+ options.operatorName ?? "",
77
+ );
78
+ } catch (error) {
79
+ // Same rule the server enforces at boot, surfaced at setup time.
80
+ throw new CliExitError(
81
+ error instanceof Error ? error.message : String(error),
82
+ ExitCode.Usage,
83
+ );
84
+ }
52
85
  }
53
- updated = applyChoice(config, provider as (typeof PROVIDER_CHOICES)[number], {
54
- apiKey: options.apiKey,
55
- });
56
86
  } else {
57
87
  updated = await runInteractiveWizard(config);
58
88
  }
59
89
 
60
90
  save(updated);
61
91
 
62
- const result = CommandResult.success("LLM configuration saved");
92
+ const result = CommandResult.success("Configuration saved");
63
93
  if (await isRunning(homedir())) {
64
94
  result.hint("Restart to apply: stigmer down && stigmer up");
65
95
  }
@@ -26,6 +26,26 @@ describe("buildServerEnv", () => {
26
26
  expect(env.DB_PATH).toBe("/home/u/.stigmer/stigmer.db");
27
27
  expect(env.STORAGE_PATH).toBe("/home/u/.stigmer/storage");
28
28
  });
29
+
30
+ it("forwards the operator identity only when set, resolved value winning over the base env (oss#796)", () => {
31
+ const bare = buildServerEnv(config, {});
32
+ expect(bare.STIGMER_OPERATOR_EMAIL).toBeUndefined();
33
+ expect(bare.STIGMER_OPERATOR_NAME).toBeUndefined();
34
+
35
+ // Contract value authoritative over a stale inherited export — the
36
+ // ANTHROPIC_API_KEY precedent in buildRunnerEnv.
37
+ const env = buildServerEnv(
38
+ { ...config, operatorEmail: "ada@example.com", operatorName: "Ada" },
39
+ { STIGMER_OPERATOR_EMAIL: "stale@example.com" },
40
+ );
41
+ expect(env.STIGMER_OPERATOR_EMAIL).toBe("ada@example.com");
42
+ expect(env.STIGMER_OPERATOR_NAME).toBe("Ada");
43
+ });
44
+
45
+ it("does not deliver the operator identity to the runner — it resolves identity from session data", () => {
46
+ const env = buildRunnerEnv({ ...config, operatorEmail: "ada@example.com" }, {});
47
+ expect(env.STIGMER_OPERATOR_EMAIL).toBeUndefined();
48
+ });
29
49
  });
30
50
 
31
51
  describe("buildRunnerEnv", () => {
@@ -23,7 +23,7 @@ const SERVER_GATE_TIMEOUT_MS = 30_000;
23
23
  */
24
24
  export function buildServerEnv(config: DaemonConfig, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
25
25
  const configDir = dirname(config.dataDir); // ~/.stigmer
26
- return {
26
+ const env: NodeJS.ProcessEnv = {
27
27
  ...base,
28
28
  GRPC_PORT: String(SERVER_PORT),
29
29
  TEMPORAL_HOST_PORT: config.temporalAddress,
@@ -37,6 +37,14 @@ export function buildServerEnv(config: DaemonConfig, base: NodeJS.ProcessEnv = p
37
37
  // is visible and enforceable here, next to the runner's LOCAL_ARTIFACT_PATH.
38
38
  ARTIFACT_LOCAL_BASE_PATH: join(config.dataDir, ARTIFACTS_SUBDIR),
39
39
  };
40
+ // Explicit set (after the base spread) so the launcher-resolved identity wins
41
+ // over a stale inherited value — the delivery path for a `stigmer setup`-
42
+ // persisted operator identity (oss#796; the ANTHROPIC_API_KEY precedent in
43
+ // buildRunnerEnv). Server child only: the runner resolves caller identity
44
+ // from the session's stamped created_by, never from its own env.
45
+ if (config.operatorEmail !== undefined) env.STIGMER_OPERATOR_EMAIL = config.operatorEmail;
46
+ if (config.operatorName !== undefined) env.STIGMER_OPERATOR_NAME = config.operatorName;
47
+ return env;
40
48
  }
41
49
 
42
50
  /**
@@ -28,6 +28,8 @@ describe("buildDaemonEnv + readDaemonConfig", () => {
28
28
  cursorApiKey: undefined,
29
29
  anthropicApiKey: undefined,
30
30
  activityRouting: undefined,
31
+ operatorEmail: undefined,
32
+ operatorName: undefined,
31
33
  });
32
34
  });
33
35
 
@@ -61,6 +63,25 @@ describe("buildDaemonEnv + readDaemonConfig", () => {
61
63
  expect(config.anthropicApiKey).toBe("sk-ant-env");
62
64
  });
63
65
 
66
+ // The operator identity rides the same config-file-only delivery path as the
67
+ // Anthropic key (oss#796): persisted by `stigmer setup`, absent from the
68
+ // shell, written into the contract explicitly by the launcher.
69
+ it("delivers a launcher-resolved operator identity with no help from the base env", () => {
70
+ const env = buildDaemonEnv(
71
+ { ...baseInputs, operatorEmail: "ada@example.com", operatorName: "Ada Lovelace" },
72
+ {},
73
+ );
74
+ const config = readDaemonConfig(env);
75
+ expect(config.operatorEmail).toBe("ada@example.com");
76
+ expect(config.operatorName).toBe("Ada Lovelace");
77
+ });
78
+
79
+ it("passes a shell-exported operator identity through from the base env", () => {
80
+ const env = buildDaemonEnv(baseInputs, { STIGMER_OPERATOR_EMAIL: "env@example.com" });
81
+ expect(readDaemonConfig(env).operatorEmail).toBe("env@example.com");
82
+ expect(readDaemonConfig(env).operatorName).toBeUndefined();
83
+ });
84
+
64
85
  // Other provider keys have no contract slot; they reach the runner solely via
65
86
  // shell-env inheritance (the child env spreads the base env).
66
87
  it("leaves shell-exported non-Anthropic keys in the env without parsing them", () => {
@@ -19,6 +19,8 @@ export const DaemonEnvVar = {
19
19
  CursorApiKey: "CURSOR_API_KEY",
20
20
  AnthropicApiKey: "ANTHROPIC_API_KEY",
21
21
  ActivityRouting: "STIGMER_ACTIVITY_ROUTING",
22
+ OperatorEmail: "STIGMER_OPERATOR_EMAIL",
23
+ OperatorName: "STIGMER_OPERATOR_NAME",
22
24
  } as const;
23
25
 
24
26
  /** Resolved runner launch coordinates. */
@@ -41,6 +43,8 @@ export interface DaemonConfig {
41
43
  cursorApiKey?: string;
42
44
  anthropicApiKey?: string;
43
45
  activityRouting?: string;
46
+ operatorEmail?: string;
47
+ operatorName?: string;
44
48
  }
45
49
 
46
50
  /** Inputs the launcher already resolved, to encode into the daemon env. */
@@ -60,6 +64,12 @@ export interface DaemonEnvInputs {
60
64
  // persisted delivery path — other keys (OPENAI_API_KEY, CURSOR_API_KEY)
61
65
  // reach the runner solely via shell-env inheritance.
62
66
  anthropicApiKey?: string;
67
+ // Operator identity resolved by the launcher (env > config file), the same
68
+ // persisted-delivery reasoning as the Anthropic key (oss#796). Consumed by
69
+ // the SERVER child only — the runner resolves caller identity from the
70
+ // session's stamped created_by, never from its own env.
71
+ operatorEmail?: string;
72
+ operatorName?: string;
63
73
  }
64
74
 
65
75
  /**
@@ -82,6 +92,8 @@ export function buildDaemonEnv(inputs: DaemonEnvInputs, base: NodeJS.ProcessEnv
82
92
  env[DaemonEnvVar.RunnerAppDir] = inputs.runner.appDir;
83
93
  }
84
94
  if (inputs.anthropicApiKey !== undefined) env[DaemonEnvVar.AnthropicApiKey] = inputs.anthropicApiKey;
95
+ if (inputs.operatorEmail !== undefined) env[DaemonEnvVar.OperatorEmail] = inputs.operatorEmail;
96
+ if (inputs.operatorName !== undefined) env[DaemonEnvVar.OperatorName] = inputs.operatorName;
85
97
  return env;
86
98
  }
87
99
 
@@ -105,6 +117,8 @@ export function readDaemonConfig(env: NodeJS.ProcessEnv = process.env): DaemonCo
105
117
  cursorApiKey: nonEmpty(env[DaemonEnvVar.CursorApiKey]),
106
118
  anthropicApiKey: nonEmpty(env[DaemonEnvVar.AnthropicApiKey]),
107
119
  activityRouting: nonEmpty(env[DaemonEnvVar.ActivityRouting]),
120
+ operatorEmail: nonEmpty(env[DaemonEnvVar.OperatorEmail]),
121
+ operatorName: nonEmpty(env[DaemonEnvVar.OperatorName]),
108
122
  };
109
123
  }
110
124
 
@@ -22,6 +22,7 @@ import { findProcessByPort, isProcessAlive, killProcess } from "../state/proc.js
22
22
  import { type StartupConfig, removeStartupConfig, saveStartupConfig } from "../state/startup-config.js";
23
23
  import { rotateLogs } from "../state/log-rotation.js";
24
24
  import { resolveApiKey, resolveProvider } from "../llm-config.js";
25
+ import { resolveOperatorIdentity } from "../operator-config.js";
25
26
  import { ensureRunner } from "../runtime/runner.js";
26
27
  import { ensureServerBinary } from "../runtime/server.js";
27
28
  import { TemporalManager } from "../temporal/manager.js";
@@ -77,6 +78,7 @@ export async function up(options: UpOptions = {}, home: string = homedir()): Pro
77
78
  serverBin,
78
79
  runner,
79
80
  ...resolveLlmKeyInputs(config),
81
+ ...resolveOperatorIdentityInputs(config),
80
82
  },
81
83
  process.env,
82
84
  );
@@ -235,6 +237,21 @@ function resolveLlmKeyInputs(config: Config): Pick<DaemonEnvInputs, "anthropicAp
235
237
  return key === "" ? {} : { anthropicApiKey: key };
236
238
  }
237
239
 
240
+ // Operator-identity delivery for the server child (oss#796): same persisted-
241
+ // delivery reasoning as the LLM key above — a setup-persisted identity lives
242
+ // only in the config file, so the launcher must write it into the daemon
243
+ // contract explicitly. resolveOperatorIdentity's precedence (env > config,
244
+ // sources never mixed) makes both cases one code path.
245
+ function resolveOperatorIdentityInputs(
246
+ config: Config,
247
+ ): Pick<DaemonEnvInputs, "operatorEmail" | "operatorName"> {
248
+ const identity = resolveOperatorIdentity(config);
249
+ if (identity === undefined) return {};
250
+ return identity.name === undefined
251
+ ? { operatorEmail: identity.email }
252
+ : { operatorEmail: identity.email, operatorName: identity.name };
253
+ }
254
+
238
255
  // Default to managed Temporal unless the opaque local config explicitly opts
239
256
  // out — zero-config `stigmer up` must work on a fresh machine.
240
257
  function isTemporalManaged(config: Config): boolean {
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Config } from "../config/config.js";
3
+ import {
4
+ readOperator,
5
+ resolveOperatorIdentity,
6
+ setOperator,
7
+ validateOperatorIdentity,
8
+ } from "./operator-config.js";
9
+
10
+ function config(local?: unknown): Config {
11
+ return { backend: { type: "local", local } };
12
+ }
13
+
14
+ describe("resolveOperatorIdentity", () => {
15
+ it("honors env > config, never mixing sources", () => {
16
+ const cfg = config({ operator: { email: "cfg@example.com", name: "Config Name" } });
17
+
18
+ // Env email brings the env name (or none) — the config name must not
19
+ // ride along under an env identity, that would be attribution lying.
20
+ expect(resolveOperatorIdentity(cfg, { STIGMER_OPERATOR_EMAIL: "env@example.com" })).toEqual({
21
+ email: "env@example.com",
22
+ });
23
+ expect(
24
+ resolveOperatorIdentity(cfg, {
25
+ STIGMER_OPERATOR_EMAIL: "env@example.com",
26
+ STIGMER_OPERATOR_NAME: "Env Name",
27
+ }),
28
+ ).toEqual({ email: "env@example.com", name: "Env Name" });
29
+
30
+ expect(resolveOperatorIdentity(cfg, {})).toEqual({
31
+ email: "cfg@example.com",
32
+ name: "Config Name",
33
+ });
34
+ });
35
+
36
+ it("returns undefined when neither source has an email — the anonymous default", () => {
37
+ expect(resolveOperatorIdentity(config(), {})).toBeUndefined();
38
+ expect(resolveOperatorIdentity(config({ operator: {} }), {})).toBeUndefined();
39
+ // Whitespace-only values are unset, matching the server's TrimSpace.
40
+ expect(resolveOperatorIdentity(config(), { STIGMER_OPERATOR_EMAIL: " " })).toBeUndefined();
41
+ });
42
+
43
+ it("trims values, matching the server's boot check", () => {
44
+ const cfg = config({ operator: { email: " ada@example.com ", name: " Ada " } });
45
+ expect(resolveOperatorIdentity(cfg, {})).toEqual({ email: "ada@example.com", name: "Ada" });
46
+ });
47
+ });
48
+
49
+ describe("validateOperatorIdentity", () => {
50
+ it("mirrors the server's boot rules: minimal @ check, name requires email", () => {
51
+ expect(validateOperatorIdentity("ada@example.com", "Ada")).toBeUndefined();
52
+ expect(validateOperatorIdentity("ada@example.com", "")).toBeUndefined();
53
+ expect(validateOperatorIdentity("", "")).toBeUndefined(); // "no identity" is valid
54
+
55
+ expect(validateOperatorIdentity("not-an-email", "")).toContain("missing '@'");
56
+ expect(validateOperatorIdentity("", "Ada")).toContain("set both or neither");
57
+ });
58
+ });
59
+
60
+ describe("setOperator", () => {
61
+ it("preserves sibling local keys and can clear the section", () => {
62
+ const cfg = config({ temporal: { managed: true }, llm: { provider: "anthropic" } });
63
+ const updated = setOperator(cfg, { email: "ada@example.com", name: "Ada" });
64
+ expect(readOperator(updated)).toEqual({ email: "ada@example.com", name: "Ada" });
65
+ expect((updated.backend.local as { temporal: unknown }).temporal).toEqual({ managed: true });
66
+ expect((updated.backend.local as { llm: unknown }).llm).toEqual({ provider: "anthropic" });
67
+
68
+ const cleared = setOperator(updated, undefined);
69
+ expect(readOperator(cleared)).toBeUndefined();
70
+ expect((cleared.backend.local as { temporal: unknown }).temporal).toEqual({ managed: true });
71
+ });
72
+ });
@@ -0,0 +1,95 @@
1
+ // Typed view over `backend.local.operator` — the self-hosted operator
2
+ // identity `setup` persists and the launcher delivers to the server child
3
+ // (oss#796, the UX-completion arm of oss#400's configured operator identity).
4
+ //
5
+ // The lens pattern mirrors llm-config.ts: config/config.ts carries
6
+ // `backend.local` verbatim so unknown siblings are never dropped; this module
7
+ // owns exactly one sub-tree (`local.operator`) and resolves the effective
8
+ // identity with env-override precedence (STIGMER_OPERATOR_EMAIL >
9
+ // config file). Sources never mix: an env-provided email brings the
10
+ // env-provided name (or none), a config-provided email brings the config
11
+ // name — half-and-half identities would be attribution lies.
12
+ //
13
+ // Validation deliberately duplicates the server's boot check
14
+ // (stigmer-server pkg/config/config.go loadOperatorIdentity): trim both
15
+ // values; a non-empty email must contain '@' (nothing more — the server is
16
+ // equally minimal on purpose); a name without an email is refused because
17
+ // the email IS the identity. Checking here surfaces the failure at setup
18
+ // time instead of at the next `stigmer up`.
19
+
20
+ import type { Config } from "../config/config.js";
21
+
22
+ /** Operator identity as persisted under `backend.local.operator` (snake_case = YAML keys). */
23
+ export interface OperatorSettings {
24
+ email?: string;
25
+ name?: string;
26
+ }
27
+
28
+ interface LocalSection {
29
+ operator?: OperatorSettings;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ function localSection(config: Config): LocalSection {
34
+ const local = config.backend.local;
35
+ return local !== null && typeof local === "object" ? (local as LocalSection) : {};
36
+ }
37
+
38
+ /** The persisted operator settings, if any. */
39
+ export function readOperator(config: Config): OperatorSettings | undefined {
40
+ return localSection(config).operator;
41
+ }
42
+
43
+ /**
44
+ * Effective operator identity: env > config file, sources never mixed.
45
+ * Returns `undefined` when neither source provides an email (the anonymous
46
+ * default oss#400 preserves).
47
+ */
48
+ export function resolveOperatorIdentity(
49
+ config: Config,
50
+ env: NodeJS.ProcessEnv = process.env,
51
+ ): OperatorSettings | undefined {
52
+ const envEmail = (env.STIGMER_OPERATOR_EMAIL ?? "").trim();
53
+ if (envEmail !== "") {
54
+ const envName = (env.STIGMER_OPERATOR_NAME ?? "").trim();
55
+ return envName === "" ? { email: envEmail } : { email: envEmail, name: envName };
56
+ }
57
+ const persisted = readOperator(config);
58
+ const email = (persisted?.email ?? "").trim();
59
+ if (email === "") return undefined;
60
+ const name = (persisted?.name ?? "").trim();
61
+ return name === "" ? { email } : { email, name };
62
+ }
63
+
64
+ /**
65
+ * Validate an operator identity the way the server's boot check will
66
+ * (byte-similar messages so setup-time and boot-time failures read as one
67
+ * rule). Returns an error message, or undefined when valid. An empty email
68
+ * with an empty name is valid — it means "no identity", the wizard's skip.
69
+ */
70
+ export function validateOperatorIdentity(email: string, name: string): string | undefined {
71
+ const trimmedEmail = email.trim();
72
+ const trimmedName = name.trim();
73
+ if (trimmedEmail !== "" && !trimmedEmail.includes("@")) {
74
+ return `"${trimmedEmail}" is not an email address (missing '@')`;
75
+ }
76
+ if (trimmedName !== "" && trimmedEmail === "") {
77
+ return "an operator name without an email is not an identity — set both or neither";
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Return a copy of `config` with the operator section replaced, preserving
84
+ * every other key under `backend.local`. Passing `undefined` clears the
85
+ * section.
86
+ */
87
+ export function setOperator(config: Config, settings: OperatorSettings | undefined): Config {
88
+ const local: LocalSection = { ...localSection(config) };
89
+ if (settings === undefined) {
90
+ delete local.operator;
91
+ } else {
92
+ local.operator = settings;
93
+ }
94
+ return { ...config, backend: { ...config.backend, local } };
95
+ }
@@ -1,7 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { Config } from "../../config/config.js";
3
3
  import { readLlm } from "../llm-config.js";
4
- import { PROVIDER_CHOICES, applyChoice, buildLlmForChoice } from "./wizard.js";
4
+ import { readOperator } from "../operator-config.js";
5
+ import {
6
+ PROVIDER_CHOICES,
7
+ applyChoice,
8
+ applyOperatorIdentity,
9
+ buildLlmForChoice,
10
+ buildOperatorForInputs,
11
+ } from "./wizard.js";
5
12
 
6
13
  function config(local?: unknown): Config {
7
14
  return { backend: { type: "local", local } };
@@ -47,3 +54,41 @@ describe("applyChoice", () => {
47
54
  expect(readLlm(updated)).toBeUndefined();
48
55
  });
49
56
  });
57
+
58
+ describe("buildOperatorForInputs", () => {
59
+ it("persists email (and optional name), trimmed", () => {
60
+ expect(buildOperatorForInputs(" ada@example.com ", "")).toEqual({ email: "ada@example.com" });
61
+ expect(buildOperatorForInputs("ada@example.com", " Ada ")).toEqual({
62
+ email: "ada@example.com",
63
+ name: "Ada",
64
+ });
65
+ });
66
+
67
+ it("returns undefined for empty inputs — nothing to persist, identity stays anonymous", () => {
68
+ expect(buildOperatorForInputs("", "")).toBeUndefined();
69
+ expect(buildOperatorForInputs(" ", "")).toBeUndefined();
70
+ });
71
+
72
+ // The server's boot rules, surfaced at setup time (oss#796): a malformed
73
+ // identity must never be persisted only to fail the next `stigmer up`.
74
+ it("refuses what the server's boot check would refuse", () => {
75
+ expect(() => buildOperatorForInputs("not-an-email", "")).toThrow(/missing '@'/);
76
+ expect(() => buildOperatorForInputs("", "Ada")).toThrow(/set both or neither/);
77
+ });
78
+ });
79
+
80
+ describe("applyOperatorIdentity", () => {
81
+ it("writes the identity while preserving siblings", () => {
82
+ const cfg = config({ temporal: { managed: true }, llm: { provider: "anthropic" } });
83
+ const updated = applyOperatorIdentity(cfg, "ada@example.com", "Ada");
84
+ expect(readOperator(updated)).toEqual({ email: "ada@example.com", name: "Ada" });
85
+ expect(readLlm(updated)).toEqual({ provider: "anthropic" });
86
+ expect((updated.backend.local as { temporal: unknown }).temporal).toEqual({ managed: true });
87
+ });
88
+
89
+ it("empty inputs leave the config unchanged (skip, not clear)", () => {
90
+ const cfg = config({ operator: { email: "keep@example.com" } });
91
+ const updated = applyOperatorIdentity(cfg, "", "");
92
+ expect(readOperator(updated)).toEqual({ email: "keep@example.com" });
93
+ });
94
+ });