@automagik/omni 2.260910.16 → 2.260910.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -137198,7 +137198,7 @@ import { fileURLToPath } from "url";
137198
137198
  // package.json
137199
137199
  var package_default = {
137200
137200
  name: "@automagik/omni",
137201
- version: "2.260910.16",
137201
+ version: "2.260910.17",
137202
137202
  repository: {
137203
137203
  type: "git",
137204
137204
  url: "git+https://github.com/automagik-dev/omni.git",
@@ -150668,2282 +150668,2673 @@ function createSlackCommand() {
150668
150668
  return cmd;
150669
150669
  }
150670
150670
 
150671
- // src/commands/speak.ts
150672
- import { writeFile as writeFile2 } from "fs/promises";
150671
+ // src/commands/sources.ts
150672
+ import { randomBytes as randomBytes4 } from "crypto";
150673
+ init_config();
150673
150674
  init_output();
150674
- var ALLOWED_FORMATS2 = ["mp3", "ogg", "opus", "wav", "pcm", "flac", "aac"];
150675
- function parseFormat2(value) {
150676
- if (!value)
150677
- return;
150678
- const normalized = value.toLowerCase();
150679
- if (!ALLOWED_FORMATS2.includes(normalized)) {
150680
- error(`Invalid --format "${value}". Allowed: ${ALLOWED_FORMATS2.join(", ")}`);
150681
- }
150682
- return normalized;
150683
- }
150684
- function parseSpeed(value) {
150685
- if (value === undefined)
150686
- return;
150687
- const n2 = Number.parseFloat(value);
150688
- if (Number.isNaN(n2) || n2 < 0.5 || n2 > 2) {
150689
- error(`Invalid --speed "${value}". Must be a number between 0.5 and 2.0.`);
150690
- }
150691
- return n2;
150692
- }
150693
- function createSpeakCommand() {
150694
- return new Command("speak").description("Synthesize text to speech and send as a voice note (or save with --output)").argument("<text>", "Text to convert to speech").option("--provider <name>", "TTS provider (gemini, openai, elevenlabs). Default: server config.").option("--model <model>", "TTS model override (provider-specific)").option("--voice <name>", "Voice identifier (e.g. Kore for Gemini, JBFqn... for ElevenLabs)").option("--style <prompt>", 'Style prompt prepended to text (Gemini only, e.g. "Say cheerfully")').option("--instructions <text>", "Provider-native speaking instructions").option("--tone <text>", "Tone guidance").option("--accent <text>", "Accent guidance").option("--pace <text>", "Pace guidance").option("--emotion <text>", "Emotion guidance").option("--voice-note-profile <name>", "Voice-note profile/preset name").option("--language <code>", "BCP-47 language code (e.g. en-US, pt-BR)").option("--speed <factor>", "Speaking speed multiplier 0.5-2.0 (provider-dependent)").option("--format <fmt>", `Audio format: ${ALLOWED_FORMATS2.join(", ")} (default: ogg)`).option("--output <path>", "Save audio to file instead of sending").option("--instance <id>", "Override instance (default: from context)").option("--chat <id>", "Override chat (default: from context)").action(async (text3, options3) => {
150695
- const client = getClient();
150696
- const format = parseFormat2(options3.format);
150697
- const speed = parseSpeed(options3.speed);
150698
- let audioBuffer;
150699
- let mimeType;
150700
- let durationMs;
150701
- let providerName;
150702
- try {
150703
- const result = await client.media.tts({
150704
- text: text3,
150705
- provider: options3.provider,
150706
- voice: options3.voice,
150707
- language: options3.language,
150708
- speed,
150709
- format,
150710
- style: options3.style,
150711
- model: options3.model,
150712
- instructions: options3.instructions,
150713
- tone: options3.tone,
150714
- accent: options3.accent,
150715
- pace: options3.pace,
150716
- emotion: options3.emotion,
150717
- voiceNoteProfile: options3.voiceNoteProfile
150718
- });
150719
- audioBuffer = result.audio;
150720
- mimeType = result.mimeType;
150721
- durationMs = result.durationMs;
150722
- providerName = result.provider;
150723
- } catch (err2) {
150724
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
150725
- return error(`TTS synthesis failed: ${message2}`);
150726
- }
150727
- if (options3.output) {
150728
- try {
150729
- await writeFile2(options3.output, audioBuffer);
150730
- return success("Audio saved", {
150731
- path: options3.output,
150732
- provider: providerName,
150733
- mimeType,
150734
- durationMs,
150735
- sizeKb: Math.round(audioBuffer.length / 1024 * 100) / 100
150736
- });
150737
- } catch (err2) {
150738
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
150739
- return error(`Failed to write ${options3.output}: ${message2}`);
150675
+ // src/commands/sources/github/custom.github.issues.json
150676
+ var custom_github_issues_default = {
150677
+ $schema: "http://json-schema.org/draft-07/schema#",
150678
+ title: "custom.github.issues",
150679
+ description: "GitHub issues webhook delivery (X-GitHub-Event: issues). Minimal-but-real contract: the fields consumers rely on are required; everything else GitHub sends passes through (additionalProperties: true). Evolution rule: revisions must be additive-optional (issue #959).",
150680
+ type: "object",
150681
+ additionalProperties: true,
150682
+ required: ["action", "issue", "repository", "sender"],
150683
+ properties: {
150684
+ action: {
150685
+ type: "string",
150686
+ description: "What happened: opened, closed, edited, labeled, ..."
150687
+ },
150688
+ issue: {
150689
+ type: "object",
150690
+ additionalProperties: true,
150691
+ required: ["number"],
150692
+ properties: {
150693
+ number: { type: "integer", description: "Issue number" },
150694
+ title: { type: "string" },
150695
+ state: { type: "string" },
150696
+ html_url: { type: "string" }
150697
+ }
150698
+ },
150699
+ repository: {
150700
+ type: "object",
150701
+ additionalProperties: true,
150702
+ required: ["full_name"],
150703
+ properties: {
150704
+ full_name: {
150705
+ type: "string",
150706
+ description: "owner/repo, e.g. automagik-dev/omni"
150707
+ }
150708
+ }
150709
+ },
150710
+ sender: {
150711
+ type: "object",
150712
+ additionalProperties: true,
150713
+ required: ["login"],
150714
+ properties: {
150715
+ login: {
150716
+ type: "string",
150717
+ description: "GitHub username that triggered the delivery"
150718
+ }
150740
150719
  }
150741
150720
  }
150742
- const ctx = await resolveContext({
150743
- instance: options3.instance,
150744
- chat: options3.chat
150745
- });
150746
- if (!ctx.instanceId) {
150747
- return error("No instance in context. Set OMNI_INSTANCE, use --instance, run: omni use <instance>, or pass --output to save.");
150721
+ }
150722
+ };
150723
+ // src/commands/sources/github/custom.github.pull_request.json
150724
+ var custom_github_pull_request_default = {
150725
+ $schema: "http://json-schema.org/draft-07/schema#",
150726
+ title: "custom.github.pull_request",
150727
+ description: "GitHub pull_request webhook delivery (X-GitHub-Event: pull_request). Minimal-but-real contract: the fields consumers rely on are required; everything else GitHub sends passes through (additionalProperties: true). Evolution rule: revisions must be additive-optional (issue #959).",
150728
+ type: "object",
150729
+ additionalProperties: true,
150730
+ required: ["action", "number", "repository", "sender"],
150731
+ properties: {
150732
+ action: {
150733
+ type: "string",
150734
+ description: "What happened: opened, closed, synchronize, reopened, ..."
150735
+ },
150736
+ number: {
150737
+ type: "integer",
150738
+ description: "Pull request number"
150739
+ },
150740
+ pull_request: {
150741
+ type: "object",
150742
+ additionalProperties: true,
150743
+ properties: {
150744
+ title: { type: "string" },
150745
+ state: { type: "string" },
150746
+ merged: { type: "boolean" },
150747
+ html_url: { type: "string" }
150748
+ }
150749
+ },
150750
+ repository: {
150751
+ type: "object",
150752
+ additionalProperties: true,
150753
+ required: ["full_name"],
150754
+ properties: {
150755
+ full_name: {
150756
+ type: "string",
150757
+ description: "owner/repo, e.g. automagik-dev/omni"
150758
+ }
150759
+ }
150760
+ },
150761
+ sender: {
150762
+ type: "object",
150763
+ additionalProperties: true,
150764
+ required: ["login"],
150765
+ properties: {
150766
+ login: {
150767
+ type: "string",
150768
+ description: "GitHub username that triggered the delivery"
150769
+ }
150770
+ }
150748
150771
  }
150749
- if (!ctx.chatId) {
150750
- return error("No chat in context. Set OMNI_CHAT, use --chat, run: omni open <contact>, or pass --output to save.");
150772
+ }
150773
+ };
150774
+ // src/commands/sources/github/custom.github.push.json
150775
+ var custom_github_push_default = {
150776
+ $schema: "http://json-schema.org/draft-07/schema#",
150777
+ title: "custom.github.push",
150778
+ description: "GitHub push webhook delivery (X-GitHub-Event: push). Minimal-but-real contract: the fields consumers rely on are required; everything else GitHub sends passes through (additionalProperties: true). Evolution rule: revisions must be additive-optional (issue #959).",
150779
+ type: "object",
150780
+ additionalProperties: true,
150781
+ required: ["ref", "repository", "sender"],
150782
+ properties: {
150783
+ ref: {
150784
+ type: "string",
150785
+ description: "Full git ref that was pushed, e.g. refs/heads/main"
150786
+ },
150787
+ before: {
150788
+ type: "string",
150789
+ description: "SHA of the most recent commit on ref before the push"
150790
+ },
150791
+ after: {
150792
+ type: "string",
150793
+ description: "SHA of the most recent commit on ref after the push"
150794
+ },
150795
+ commits: {
150796
+ type: "array",
150797
+ description: "Commits in the push (GitHub caps this at 2048 entries)",
150798
+ items: {
150799
+ type: "object",
150800
+ additionalProperties: true,
150801
+ properties: {
150802
+ id: { type: "string" },
150803
+ message: { type: "string" },
150804
+ url: { type: "string" }
150805
+ }
150806
+ }
150807
+ },
150808
+ repository: {
150809
+ type: "object",
150810
+ additionalProperties: true,
150811
+ required: ["full_name"],
150812
+ properties: {
150813
+ full_name: {
150814
+ type: "string",
150815
+ description: "owner/repo, e.g. automagik-dev/omni"
150816
+ }
150817
+ }
150818
+ },
150819
+ sender: {
150820
+ type: "object",
150821
+ additionalProperties: true,
150822
+ required: ["login"],
150823
+ properties: {
150824
+ login: {
150825
+ type: "string",
150826
+ description: "GitHub username that triggered the delivery"
150827
+ }
150828
+ }
150751
150829
  }
150752
- try {
150753
- const result = await client.messages.sendMedia({
150754
- instanceId: ctx.instanceId,
150755
- to: ctx.chatId,
150756
- type: "audio",
150757
- base64: audioBuffer.toString("base64"),
150758
- filename: pickFilename(mimeType, providerName),
150759
- voiceNote: true
150760
- });
150761
- success("Voice note sent", {
150762
- provider: providerName,
150763
- durationMs,
150764
- sizeKb: Math.round(audioBuffer.length / 1024 * 100) / 100,
150765
- ...result
150766
- });
150767
- } catch (err2) {
150768
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
150769
- error(`Failed to send voice note: ${message2}`);
150830
+ }
150831
+ };
150832
+ // src/commands/sources/github/custom.github.release.json
150833
+ var custom_github_release_default = {
150834
+ $schema: "http://json-schema.org/draft-07/schema#",
150835
+ title: "custom.github.release",
150836
+ description: "GitHub release webhook delivery (X-GitHub-Event: release). Minimal-but-real contract: the fields consumers rely on are required; everything else GitHub sends passes through (additionalProperties: true). Evolution rule: revisions must be additive-optional (issue #959).",
150837
+ type: "object",
150838
+ additionalProperties: true,
150839
+ required: ["action", "release", "repository", "sender"],
150840
+ properties: {
150841
+ action: {
150842
+ type: "string",
150843
+ description: "What happened: published, created, edited, released, ..."
150844
+ },
150845
+ release: {
150846
+ type: "object",
150847
+ additionalProperties: true,
150848
+ required: ["tag_name"],
150849
+ properties: {
150850
+ tag_name: { type: "string", description: "Git tag of the release, e.g. v2.260907.4" },
150851
+ name: { type: "string" },
150852
+ draft: { type: "boolean" },
150853
+ prerelease: { type: "boolean" },
150854
+ html_url: { type: "string" }
150855
+ }
150856
+ },
150857
+ repository: {
150858
+ type: "object",
150859
+ additionalProperties: true,
150860
+ required: ["full_name"],
150861
+ properties: {
150862
+ full_name: {
150863
+ type: "string",
150864
+ description: "owner/repo, e.g. automagik-dev/omni"
150865
+ }
150866
+ }
150867
+ },
150868
+ sender: {
150869
+ type: "object",
150870
+ additionalProperties: true,
150871
+ required: ["login"],
150872
+ properties: {
150873
+ login: {
150874
+ type: "string",
150875
+ description: "GitHub username that triggered the delivery"
150876
+ }
150877
+ }
150770
150878
  }
150771
- });
150772
- }
150773
- function pickFilename(mimeType, provider) {
150774
- const lower = mimeType.toLowerCase();
150775
- if (lower.includes("ogg") || lower.includes("opus"))
150776
- return `${provider}-speak.ogg`;
150777
- if (lower.includes("wav"))
150778
- return `${provider}-speak.wav`;
150779
- if (lower.includes("mp3") || lower.includes("mpeg"))
150780
- return `${provider}-speak.mp3`;
150781
- if (lower.includes("flac"))
150782
- return `${provider}-speak.flac`;
150783
- if (lower.includes("aac") || lower.includes("m4a"))
150784
- return `${provider}-speak.m4a`;
150785
- return `${provider}-speak.bin`;
150786
- }
150879
+ }
150880
+ };
150787
150881
 
150788
- // src/commands/start.ts
150789
- import { existsSync as existsSync18, mkdirSync as mkdirSync11 } from "fs";
150790
- import { homedir as homedir12 } from "os";
150791
- import { join as join17 } from "path";
150792
- init_config();
150793
- init_embedded_canonical_migration();
150882
+ // src/commands/webhooks.ts
150883
+ import { readFileSync as readFileSync18 } from "fs";
150884
+ init_zod();
150794
150885
  init_output();
150795
- var START_HEALTH_TIMEOUT_MS = 1e4;
150796
- function legacyEmbeddedNeedsMigration(useCanonicalPgserve, embeddedDataPresent = existsSync18(join17(EMBEDDED_PGSERVE_DATA_DIR, "PG_VERSION"))) {
150797
- return useCanonicalPgserve !== true && embeddedDataPresent;
150798
- }
150799
- async function runStart() {
150800
- if (!await isPm2Available()) {
150801
- pm2NotFoundError();
150802
- }
150803
- const bundlePath = getServerBundlePath();
150804
- if (!existsSync18(bundlePath)) {
150805
- bundleNotFoundError(bundlePath);
150886
+ var SIGNATURE_ALGORITHMS = ["hmac-sha256", "hmac-sha1", "token-match"];
150887
+ var signatureSecretSchema = exports_external.string().min(8, "at least 8 characters").max(512, "at most 512 characters");
150888
+ var SECRET_SOURCE_FLAGS = "--signature-secret, --signature-secret-env, --signature-secret-stdin";
150889
+ async function readSecretFromStdin() {
150890
+ if (process.stdin.isTTY) {
150891
+ throw new Error('--signature-secret-stdin requires piped stdin (e.g. `printf %s "$SECRET" | omni webhooks ...`)');
150806
150892
  }
150807
- const serverConfig = loadServerConfig();
150808
- const apiPort = serverConfig.port;
150809
- if (legacyEmbeddedNeedsMigration(serverConfig.useCanonicalPgserve)) {
150810
- const major = readDataDirMajor(EMBEDDED_PGSERVE_DATA_DIR);
150811
- error(`Legacy embedded pgserve detected \u2014 this omni build does not run an embedded Postgres.
150812
- Embedded data dir: ${EMBEDDED_PGSERVE_DATA_DIR}${major ? ` (PostgreSQL ${major})` : ""}
150813
- Your data is intact. Migrate to canonical pgserve first:
150814
- omni doctor --fix
150815
- (idempotent \u2014 preserves data; do not \`omni start\` until it completes).`, undefined, 1);
150893
+ const chunks = [];
150894
+ for await (const chunk of process.stdin) {
150895
+ chunks.push(Buffer.from(chunk));
150816
150896
  }
150817
- mkdirSync11(getPm2LogDir(), { recursive: true });
150818
- info(`Starting ${PM2_PROCESSES.api} (port ${apiPort})...`);
150819
- const cliConfig = loadLocalRuntimeConfig();
150820
- const env2 = buildRuntimeEnv(serverConfig, cliConfig);
150821
- const launcherPath = getServerLauncherPath();
150822
- const apiArgs = buildPm2StartArgs({
150823
- kind: "api",
150824
- script: launcherPath,
150825
- name: PM2_PROCESSES.api,
150826
- interpreter: "bash"
150827
- });
150828
- const apiCode = await runPm2(apiArgs, env2);
150829
- if (apiCode !== 0) {
150830
- error(`Failed to start ${PM2_PROCESSES.api} (pm2 exit code ${apiCode})`, undefined, 1);
150897
+ return Buffer.concat(chunks).toString("utf-8");
150898
+ }
150899
+ async function resolveSignatureSecret(options3, readStdin = readSecretFromStdin) {
150900
+ const sources = [
150901
+ options3.signatureSecret !== undefined,
150902
+ options3.signatureSecretEnv !== undefined,
150903
+ options3.signatureSecretStdin === true
150904
+ ].filter(Boolean).length;
150905
+ if (sources === 0)
150831
150906
  return;
150907
+ if (sources > 1) {
150908
+ throw new Error(`Use only one of ${SECRET_SOURCE_FLAGS}`);
150832
150909
  }
150833
- const natsPath = join17(homedir12(), ".omni", "nats-server");
150834
- if (existsSync18(natsPath)) {
150835
- info(`Starting ${PM2_PROCESSES.nats}...`);
150836
- const natsDataDir = join17(serverConfig.dataDir, "nats");
150837
- mkdirSync11(natsDataDir, { recursive: true });
150838
- const natsArgs = buildPm2StartArgs({
150839
- kind: "nats",
150840
- script: natsPath,
150841
- name: PM2_PROCESSES.nats,
150842
- scriptArgs: ["-js", "-sd", natsDataDir]
150843
- });
150844
- const natsCode = await runPm2(natsArgs);
150845
- if (natsCode !== 0) {
150846
- warn(`${PM2_PROCESSES.nats} failed to start \u2014 run 'omni install' to download NATS first`);
150910
+ let raw2;
150911
+ let origin;
150912
+ if (options3.signatureSecretEnv !== undefined) {
150913
+ const name = options3.signatureSecretEnv;
150914
+ if (!name)
150915
+ throw new Error("--signature-secret-env requires a variable name");
150916
+ raw2 = process.env[name];
150917
+ origin = `environment variable ${name}`;
150918
+ if (raw2 === undefined || raw2 === "") {
150919
+ throw new Error(`${origin} is not set or empty`);
150847
150920
  }
150921
+ } else if (options3.signatureSecretStdin) {
150922
+ raw2 = (await readStdin()).replace(/\r?\n$/, "");
150923
+ origin = "stdin";
150924
+ if (!raw2)
150925
+ throw new Error("no secret received on stdin");
150848
150926
  } else {
150849
- warn(`NATS binary not found at ${natsPath} \u2014 skipping. Run 'omni install' to set it up.`);
150927
+ raw2 = options3.signatureSecret;
150928
+ origin = "--signature-secret";
150850
150929
  }
150851
- const healthUrl = getHealthCheckUrl(apiPort);
150852
- info(`Waiting for health check at ${healthUrl}...`);
150853
- const healthy = await waitForHealth(apiPort, START_HEALTH_TIMEOUT_MS);
150854
- if (healthy) {
150855
- success(`Server is healthy at ${healthUrl}`);
150856
- } else {
150857
- warn(`Health check did not pass within ${START_HEALTH_TIMEOUT_MS / 1000}s \u2014 server may still be starting`);
150930
+ const parsed = signatureSecretSchema.safeParse(raw2);
150931
+ if (!parsed.success) {
150932
+ const reason = parsed.error.issues.map((issue) => issue.message).join("; ");
150933
+ throw new Error(`Invalid signature secret from ${origin}: ${reason}`);
150858
150934
  }
150935
+ return parsed.data;
150859
150936
  }
150860
- function createStartCommand() {
150861
- return new Command("start").description("Start API and NATS via PM2").action(runStart);
150862
- }
150863
-
150864
- // src/commands/status.ts
150865
- init_config();
150866
- init_output();
150867
- function formatUptime(ms) {
150868
- if (ms === undefined || ms === 0)
150869
- return "-";
150870
- const seconds = Math.floor(ms / 1000);
150871
- if (seconds < 60)
150872
- return `${seconds}s`;
150873
- const minutes = Math.floor(seconds / 60);
150874
- if (minutes < 60)
150875
- return `${minutes}m`;
150876
- const hours = Math.floor(minutes / 60);
150877
- if (hours < 24)
150878
- return `${hours}h`;
150879
- const days = Math.floor(hours / 24);
150880
- return `${days}d`;
150881
- }
150882
- function formatMemory(bytes) {
150883
- if (bytes === undefined || bytes === 0)
150884
- return "-";
150885
- return `${Math.round(bytes / 1024 / 1024)}MB`;
150886
- }
150887
- async function getProcessStatus() {
150888
- if (!await isPm2Available()) {
150889
- return null;
150937
+ function buildSignatureConfig(options3) {
150938
+ const { signatureAlgorithm, signatureHeader, signaturePrefix } = options3;
150939
+ if (!signatureAlgorithm && !signatureHeader && !signaturePrefix)
150940
+ return;
150941
+ if (!signatureAlgorithm || !signatureHeader) {
150942
+ error("--signature-algorithm and --signature-header must be provided together");
150890
150943
  }
150891
- const { code, stdout } = await capturePm2("jlist");
150892
- if (code !== 0) {
150893
- return null;
150944
+ const algorithm = SIGNATURE_ALGORITHMS.find((a2) => a2 === signatureAlgorithm);
150945
+ if (!algorithm) {
150946
+ error(`Invalid --signature-algorithm; expected one of: ${SIGNATURE_ALGORITHMS.join(", ")}`);
150894
150947
  }
150895
- let processes = [];
150896
- try {
150897
- processes = JSON.parse(stdout);
150898
- } catch {
150899
- return null;
150948
+ if (algorithm === "token-match" && signaturePrefix) {
150949
+ error("--signature-prefix is not applicable to token-match (the header carries the secret verbatim)");
150900
150950
  }
150901
- const omniProcesses = processes.filter((p11) => typeof p11.name === "string" && p11.name.startsWith("omni-"));
150902
- return omniProcesses.map((p11) => ({
150903
- service: p11.name ?? "-",
150904
- pid: p11.pid !== undefined ? String(p11.pid) : "-",
150905
- status: p11.pm2_env?.status ?? "-",
150906
- uptime: p11.pm2_env?.pm_uptime ? formatUptime(Date.now() - p11.pm2_env.pm_uptime) : "-",
150907
- cpu: p11.monit?.cpu !== undefined ? `${p11.monit.cpu}%` : "-",
150908
- memory: formatMemory(p11.monit?.memory)
150909
- }));
150951
+ return { algorithm, header: signatureHeader, prefix: signaturePrefix };
150910
150952
  }
150911
- async function checkApiHealth(statusInfo, apiUrl) {
150912
- try {
150913
- const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/v2/health`, {
150914
- headers: {
150915
- "Accept-Encoding": "identity",
150916
- [CLI_VERSION_HEADER]: VERSION
150917
- }
150918
- });
150919
- const health = await response.json();
150920
- statusInfo.apiStatus = health.status ?? "unknown";
150921
- statusInfo.apiVersion = health.version;
150922
- if (health.checks) {
150923
- statusInfo.checks = health.checks;
150953
+ var eventTypeMappingSchema = exports_external.discriminatedUnion("source", [
150954
+ exports_external.object({
150955
+ source: exports_external.literal("header"),
150956
+ header: exports_external.string().min(1, "header must be 1-200 characters").max(200, "header must be 1-200 characters")
150957
+ }),
150958
+ exports_external.object({
150959
+ source: exports_external.literal("body"),
150960
+ path: exports_external.string().min(1, "path must be 1-200 characters").max(200, "path must be 1-200 characters")
150961
+ })
150962
+ ]);
150963
+ var EVENT_TYPE_MAPPING_HINT = 'expected {"source":"header","header":"X-GitHub-Event"} or {"source":"body","path":"event"}';
150964
+ function resolveEventTypeMapping(raw2) {
150965
+ if (raw2 === undefined)
150966
+ return;
150967
+ let text3 = raw2;
150968
+ if (raw2.startsWith("@")) {
150969
+ const path = raw2.slice(1);
150970
+ try {
150971
+ text3 = readFileSync18(path, "utf-8");
150972
+ } catch (err2) {
150973
+ const reason = err2 instanceof Error ? err2.message : "Unknown error";
150974
+ throw new Error(`Cannot read --event-type-mapping file '${path}': ${reason}`);
150924
150975
  }
150925
- return response.headers.get(SERVER_VERSION_HEADER);
150926
- } catch (err2) {
150927
- statusInfo.apiStatus = "unreachable";
150928
- statusInfo.apiError = err2 instanceof Error ? err2.message : "Unknown error";
150929
- return null;
150930
150976
  }
150931
- }
150932
- async function validateAuthKey(statusInfo, client) {
150977
+ let parsed;
150933
150978
  try {
150934
- const auth = await client.auth.validate();
150935
- statusInfo.keyValid = auth.valid;
150936
- statusInfo.keyName = auth.keyName;
150937
- statusInfo.keyPrefix = auth.keyPrefix;
150938
- statusInfo.scopes = auth.scopes;
150939
- Object.assign(statusInfo, credentialStatusFields(auth));
150940
- Object.assign(statusInfo, serverPostureFields(auth));
150979
+ parsed = JSON.parse(text3);
150941
150980
  } catch {
150942
- statusInfo.keyValid = false;
150981
+ throw new Error(`Invalid JSON for --event-type-mapping; ${EVENT_TYPE_MAPPING_HINT}`);
150982
+ }
150983
+ const result = eventTypeMappingSchema.safeParse(parsed);
150984
+ if (!result.success) {
150985
+ const reason = result.error.issues.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
150986
+ throw new Error(`Invalid --event-type-mapping: ${reason}; ${EVENT_TYPE_MAPPING_HINT}`);
150943
150987
  }
150988
+ return result.data;
150944
150989
  }
150945
- async function collectStatusInfo() {
150946
- const config2 = loadConfig();
150947
- const isAuthenticated = hasAuth();
150948
- const apiUrl = config2.apiUrl ?? "http://localhost:8882";
150949
- const statusInfo = {
150950
- configDir: getConfigDir(),
150951
- apiUrl,
150952
- authenticated: isAuthenticated,
150953
- defaultInstance: config2.defaultInstance ?? "-",
150954
- format: config2.format ?? "auto (TTY detection)"
150955
- };
150956
- const serverVersion = await checkApiHealth(statusInfo, apiUrl);
150957
- if (isAuthenticated) {
150958
- const client = getOptionalClient();
150959
- if (client) {
150960
- await validateAuthKey(statusInfo, client);
150961
- }
150990
+ async function buildUpdateSourcePatch(options3) {
150991
+ const updates = buildLifecycleUpdates(options3);
150992
+ if (options3.name)
150993
+ updates.name = options3.name;
150994
+ if (options3.description)
150995
+ updates.description = options3.description;
150996
+ if (options3.idempotencyKeyTemplate)
150997
+ updates.idempotencyKeyTemplate = options3.idempotencyKeyTemplate;
150998
+ if (options3.strictSchemas !== undefined)
150999
+ updates.strictSchemas = options3.strictSchemas;
151000
+ if (options3.clearEventTypeMapping && options3.eventTypeMapping !== undefined) {
151001
+ throw new Error("Use only one of --event-type-mapping and --clear-event-type-mapping");
150962
151002
  }
150963
- const processes = await getProcessStatus();
150964
- if (processes !== null) {
150965
- statusInfo.processes = processes;
151003
+ if (options3.clearEventTypeMapping)
151004
+ updates.eventTypeMapping = null;
151005
+ const eventTypeMapping = resolveEventTypeMapping(options3.eventTypeMapping);
151006
+ if (eventTypeMapping !== undefined)
151007
+ updates.eventTypeMapping = eventTypeMapping;
151008
+ if (options3.enable)
151009
+ updates.enabled = true;
151010
+ if (options3.disable)
151011
+ updates.enabled = false;
151012
+ if (options3.clearSignature) {
151013
+ updates.signatureConfig = null;
151014
+ return updates;
150966
151015
  }
150967
- return { statusInfo, serverVersion, processes };
151016
+ const signatureConfig = buildSignatureConfig(options3);
151017
+ if (signatureConfig)
151018
+ updates.signatureConfig = signatureConfig;
151019
+ const signatureSecret = await resolveSignatureSecret(options3);
151020
+ if (signatureSecret)
151021
+ updates.signatureSecret = signatureSecret;
151022
+ return updates;
150968
151023
  }
150969
- function renderHumanExtras(serverVersion, processes) {
150970
- if (serverVersion) {
150971
- raw(formatStatusVersionHint(VERSION, serverVersion));
151024
+ function assertPairedSignatureOnCreate(signatureConfig, signatureSecret) {
151025
+ if (signatureConfig === undefined === (signatureSecret === undefined))
151026
+ return;
151027
+ if (signatureConfig === undefined) {
151028
+ throw new Error(`a signature secret (${SECRET_SOURCE_FLAGS}) requires --signature-algorithm and --signature-header`);
150972
151029
  }
150973
- if (processes !== null && processes.length > 0) {
150974
- header("Services");
150975
- list(processes);
150976
- } else if (processes !== null) {
150977
- header("Services");
150978
- dim("No omni services running. Start with: omni start");
151030
+ throw new Error(`--signature-algorithm and --signature-header require a signature secret (${SECRET_SOURCE_FLAGS})`);
151031
+ }
151032
+ var WINDOW_SEMANTICS = ["future_only", "includes_in_progress"];
151033
+ var MUTATION_POLICIES = ["same_id", "new_id"];
151034
+ function parseExpectedInterval(raw2) {
151035
+ if (raw2 === undefined)
151036
+ return;
151037
+ const seconds = Number(raw2);
151038
+ if (!Number.isInteger(seconds) || seconds < 1) {
151039
+ error("--expected-interval must be a positive integer number of seconds");
150979
151040
  }
151041
+ return seconds;
150980
151042
  }
150981
- function createStatusCommand() {
150982
- const status = new Command("status").description("Show API health and connection info").action(async () => {
150983
- const { statusInfo, serverVersion, processes } = await collectStatusInfo();
150984
- data(statusInfo);
150985
- if (getCurrentFormat() === "human") {
150986
- renderHumanExtras(serverVersion, processes);
150987
- }
150988
- });
150989
- return status;
151043
+ function parseWindowSemantics(raw2) {
151044
+ if (raw2 === undefined)
151045
+ return;
151046
+ const value = WINDOW_SEMANTICS.find((v4) => v4 === raw2);
151047
+ if (!value) {
151048
+ error(`Invalid --window-semantics; expected one of: ${WINDOW_SEMANTICS.join(", ")}`);
151049
+ }
151050
+ return value;
150990
151051
  }
150991
-
150992
- // src/commands/stop.ts
150993
- init_output();
150994
- var LEGACY_PROCESSES = ["omni-v2-api", "omni-v2-nats"];
150995
- async function runStop() {
150996
- if (!await isPm2Available()) {
150997
- pm2NotFoundError();
150998
- }
150999
- info("Stopping omni services...");
151000
- for (const name of Object.values(PM2_PROCESSES)) {
151001
- await runPm2(["delete", name]);
151052
+ function parseMutationPolicy(raw2) {
151053
+ if (raw2 === undefined)
151054
+ return;
151055
+ const value = MUTATION_POLICIES.find((v4) => v4 === raw2);
151056
+ if (!value) {
151057
+ error(`Invalid --mutation-policy; expected one of: ${MUTATION_POLICIES.join(", ")}`);
151002
151058
  }
151003
- for (const name of LEGACY_PROCESSES) {
151004
- await runPm2(["delete", name]);
151059
+ return value;
151060
+ }
151061
+ function buildLifecycleUpdates(options3) {
151062
+ if (options3.clearCadence && options3.expectedInterval !== undefined) {
151063
+ error("Use only one of --expected-interval and --clear-cadence");
151005
151064
  }
151006
- success("Omni services stopped");
151065
+ const updates = {};
151066
+ if (options3.clearCadence)
151067
+ updates.expectedIntervalSeconds = null;
151068
+ const expectedIntervalSeconds = parseExpectedInterval(options3.expectedInterval);
151069
+ if (expectedIntervalSeconds !== undefined)
151070
+ updates.expectedIntervalSeconds = expectedIntervalSeconds;
151071
+ const windowSemantics = parseWindowSemantics(options3.windowSemantics);
151072
+ if (windowSemantics !== undefined)
151073
+ updates.windowSemantics = windowSemantics;
151074
+ const mutationPolicy = parseMutationPolicy(options3.mutationPolicy);
151075
+ if (mutationPolicy !== undefined)
151076
+ updates.mutationPolicy = mutationPolicy;
151077
+ return updates;
151007
151078
  }
151008
- function createStopCommand() {
151009
- return new Command("stop").description("Stop all omni PM2 processes").action(runStop);
151079
+ function createWebhooksCommand() {
151080
+ const webhooks = new Command("webhooks").description("Manage webhook sources");
151081
+ webhooks.command("list").description("List webhook sources").option("--enabled", "Show only enabled sources").option("--disabled", "Show only disabled sources").action(async (options3) => {
151082
+ const client = getClient();
151083
+ try {
151084
+ let enabledFilter;
151085
+ if (options3.enabled)
151086
+ enabledFilter = true;
151087
+ if (options3.disabled)
151088
+ enabledFilter = false;
151089
+ const result = await client.webhooks.listSources({
151090
+ enabled: enabledFilter
151091
+ });
151092
+ const items = result.map((w) => ({
151093
+ id: w.id,
151094
+ name: w.name,
151095
+ enabled: w.enabled ? "yes" : "no",
151096
+ health: w.livenessStatus ?? "-",
151097
+ cadence: w.expectedIntervalSeconds != null ? `${w.expectedIntervalSeconds}s` : "-",
151098
+ createdAt: w.createdAt
151099
+ }));
151100
+ list(items, { emptyMessage: "No webhook sources found." });
151101
+ } catch (err2) {
151102
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151103
+ error(`Failed to list webhook sources: ${message2}`);
151104
+ }
151105
+ });
151106
+ webhooks.command("get <id>").description("Get webhook source details").action(async (id) => {
151107
+ const resolvedId = await resolveWebhookId(id);
151108
+ const client = getClient();
151109
+ try {
151110
+ const source = await client.webhooks.getSource(resolvedId);
151111
+ data(source);
151112
+ } catch (err2) {
151113
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151114
+ error(`Failed to get webhook source: ${message2}`);
151115
+ }
151116
+ });
151117
+ webhooks.command("create").description("Create a webhook source").requiredOption("--name <name>", "Webhook source name").option("--description <desc>", "Description").option("--disabled", "Create in disabled state").option("--headers <json>", `Expected headers as JSON (e.g., '{"X-Secret": true}')`).option("--signature-algorithm <alg>", "Signature verification: hmac-sha256, hmac-sha1, or token-match").option("--signature-header <name>", "Header carrying the signature/token (e.g., X-Hub-Signature-256)").option("--signature-prefix <prefix>", "Prefix before the hex digest (e.g., 'sha256=')").option("--signature-secret <secret>", "Shared secret for signature verification (write-only). Visible in shell history and process lists \u2014 prefer --signature-secret-env or --signature-secret-stdin").option("--signature-secret-env <VAR>", "Read the shared secret from environment variable VAR").option("--signature-secret-stdin", "Read the shared secret from stdin (trailing newline stripped)").option("--idempotency-key-template <template>", "Delivery-identity key template for redelivery dedup (#958). Placeholders: {source}, {sha256(body)}, " + "{headers.<name>}, {payload.<dot.path>}. Defaults to '{source}:{sha256(body)}'").option("--strict-schemas", "Refuse deliveries whose event type has no enabled registered schema (dead-lettered as " + "schema_not_registered, #1000). Recommended for new sources. Defaults to off").option("--event-type-mapping <json>", "Semantic event-type extraction (#959): inline JSON or @path/to/file.json, e.g. " + `'{"source":"header","header":"X-GitHub-Event"}' or '{"source":"body","path":"event"}'. ` + "A mapped source emits custom.{source}.{event} instead of the collapsed custom.webhook.{source}").option("--expected-interval <seconds>", "Declared cadence: >=1 event or heartbeat per N seconds. Arms liveness supervision (#961)").option("--window-semantics <value>", `Declared window semantics: ${WINDOW_SEMANTICS.join(" or ")}`).option("--mutation-policy <value>", `Declared mutation re-emit policy: ${MUTATION_POLICIES.join(" or ")}`).action(async (options3) => {
151118
+ const client = getClient();
151119
+ try {
151120
+ let expectedHeaders;
151121
+ if (options3.headers) {
151122
+ try {
151123
+ expectedHeaders = JSON.parse(options3.headers);
151124
+ } catch {
151125
+ error("Invalid JSON for --headers");
151126
+ }
151127
+ }
151128
+ const signatureConfig = buildSignatureConfig(options3);
151129
+ const signatureSecret = await resolveSignatureSecret(options3);
151130
+ assertPairedSignatureOnCreate(signatureConfig, signatureSecret);
151131
+ const source = await client.webhooks.createSource({
151132
+ name: options3.name,
151133
+ description: options3.description,
151134
+ enabled: !options3.disabled,
151135
+ expectedHeaders,
151136
+ signatureConfig,
151137
+ signatureSecret,
151138
+ idempotencyKeyTemplate: options3.idempotencyKeyTemplate,
151139
+ strictSchemas: options3.strictSchemas,
151140
+ eventTypeMapping: resolveEventTypeMapping(options3.eventTypeMapping),
151141
+ expectedIntervalSeconds: parseExpectedInterval(options3.expectedInterval),
151142
+ windowSemantics: parseWindowSemantics(options3.windowSemantics),
151143
+ mutationPolicy: parseMutationPolicy(options3.mutationPolicy)
151144
+ });
151145
+ const details = {
151146
+ id: source.id,
151147
+ name: source.name,
151148
+ url: `POST /api/v2/webhooks/${source.name}`
151149
+ };
151150
+ if (signatureConfig) {
151151
+ details.publicUrl = `POST /api/v2/webhooks/ingress/${source.name}`;
151152
+ }
151153
+ success(`Webhook source created: ${source.id}`, details);
151154
+ } catch (err2) {
151155
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151156
+ error(`Failed to create webhook source: ${message2}`);
151157
+ }
151158
+ });
151159
+ webhooks.command("update <id>").description("Update a webhook source").option("--name <name>", "New name").option("--description <desc>", "New description").option("--enable", "Enable the webhook").option("--disable", "Disable the webhook").option("--signature-algorithm <alg>", "Signature verification: hmac-sha256, hmac-sha1, or token-match").option("--signature-header <name>", "Header carrying the signature/token (e.g., X-Hub-Signature-256)").option("--signature-prefix <prefix>", "Prefix before the hex digest (e.g., 'sha256=')").option("--signature-secret <secret>", "Shared secret for signature verification (write-only). Visible in shell history and process lists \u2014 prefer --signature-secret-env or --signature-secret-stdin").option("--signature-secret-env <VAR>", "Read the shared secret from environment variable VAR").option("--signature-secret-stdin", "Read the shared secret from stdin (trailing newline stripped)").option("--clear-signature", "Remove the signature config and stored secret").option("--idempotency-key-template <template>", "Delivery-identity key template for redelivery dedup (#958). Placeholders: {source}, {sha256(body)}, " + "{headers.<name>}, {payload.<dot.path>}").option("--strict-schemas", "Refuse deliveries whose event type has no enabled registered schema (dead-lettered as " + "schema_not_registered, #1000)").option("--no-strict-schemas", "Return the source to opt-in pass-through for unregistered event types").option("--event-type-mapping <json>", "Semantic event-type extraction (#959): inline JSON or @path/to/file.json, e.g. " + `'{"source":"header","header":"X-GitHub-Event"}' or '{"source":"body","path":"event"}'`).option("--clear-event-type-mapping", "Remove the mapping (deliveries fall back to the collapsed custom.webhook.{source} type)").option("--expected-interval <seconds>", "Declared cadence: >=1 event or heartbeat per N seconds. (Re)arms liveness supervision (#961)").option("--clear-cadence", "Remove the declared cadence (disarms liveness supervision)").option("--window-semantics <value>", `Declared window semantics: ${WINDOW_SEMANTICS.join(" or ")}`).option("--mutation-policy <value>", `Declared mutation re-emit policy: ${MUTATION_POLICIES.join(" or ")}`).action(async (id, options3) => {
151160
+ const resolvedId = await resolveWebhookId(id);
151161
+ const client = getClient();
151162
+ try {
151163
+ const updates = await buildUpdateSourcePatch(options3);
151164
+ const source = await client.webhooks.updateSource(resolvedId, updates);
151165
+ success(`Webhook source updated: ${source.id}`, {
151166
+ id: source.id,
151167
+ name: source.name,
151168
+ enabled: source.enabled
151169
+ });
151170
+ } catch (err2) {
151171
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151172
+ error(`Failed to update webhook source: ${message2}`);
151173
+ }
151174
+ });
151175
+ webhooks.command("delete <id>").description("Delete a webhook source").action(async (id) => {
151176
+ const resolvedId = await resolveWebhookId(id);
151177
+ const client = getClient();
151178
+ try {
151179
+ await client.webhooks.deleteSource(resolvedId);
151180
+ success(`Webhook source deleted: ${resolvedId}`);
151181
+ } catch (err2) {
151182
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151183
+ error(`Failed to delete webhook source: ${message2}`);
151184
+ }
151185
+ });
151186
+ webhooks.command("trigger").description("Trigger a custom event").requiredOption("--type <type>", "Event type").requiredOption("--payload <json>", "Event payload as JSON").option("--instance <id>", "Instance ID").option("--correlation-id <id>", "Correlation ID").option("--causation-id <event-id>", "Parent event ID (keeps the causality tree for mid-flow emissions, #1072)").action(async (options3) => {
151187
+ const client = getClient();
151188
+ try {
151189
+ let payload;
151190
+ try {
151191
+ payload = JSON.parse(options3.payload);
151192
+ } catch {
151193
+ error("Invalid JSON for --payload");
151194
+ return;
151195
+ }
151196
+ const result = await client.webhooks.trigger({
151197
+ eventType: options3.type,
151198
+ payload,
151199
+ instanceId: options3.instance,
151200
+ correlationId: options3.correlationId,
151201
+ causationId: options3.causationId
151202
+ });
151203
+ success(`Event triggered: ${result.eventId}`, {
151204
+ eventId: result.eventId,
151205
+ eventType: result.eventType
151206
+ });
151207
+ } catch (err2) {
151208
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151209
+ error(`Failed to trigger event: ${message2}`);
151210
+ }
151211
+ });
151212
+ webhooks.command("heartbeat <source>").description('Record a connector heartbeat ("ran, zero events found") \u2014 resets the liveness window (#961)').action(async (source) => {
151213
+ const client = getClient();
151214
+ try {
151215
+ const result = await client.webhooks.heartbeat(source);
151216
+ success(`Heartbeat recorded for '${result.source}'`, {
151217
+ heartbeatAt: result.heartbeatAt,
151218
+ livenessStatus: result.livenessStatus ?? "unsupervised",
151219
+ expectedIntervalSeconds: result.expectedIntervalSeconds
151220
+ });
151221
+ } catch (err2) {
151222
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151223
+ error(`Failed to record heartbeat: ${message2}`);
151224
+ }
151225
+ });
151226
+ return webhooks;
151010
151227
  }
151011
151228
 
151012
- // src/commands/tenants.ts
151013
- init_output();
151014
- var TENANT_ROLES = ["tenant-owner", "tenant-admin", "tenant-operator", "tenant-viewer"];
151015
- var MEMBERSHIP_STATUSES = ["active", "disabled"];
151016
- var REASON_REQUIRED_HINT = "Every platform control-plane call is audited; " + 'provide a justification of 3-500 characters, e.g. --reason "onboarding request OPS-1421".';
151017
- var CONTROL_PLANE_UNAVAILABLE = "the platform control plane is not available on this server. " + "Either multitenancy is disabled (start the API with OMNI_MULTITENANCY_ENABLED=true) " + "or the server predates the tenant control plane \u2014 upgrade it.";
151018
- var CREDENTIAL_REJECTED_401 = "the server rejected the credential (401). " + "The platform control plane accepts only PLATFORM-class credentials \u2014 legacy admin/god keys and " + "tenant data-plane keys are denied by design. Authenticate with a platform key: " + "omni auth login --api-key <platform-key>.";
151019
- var CREDENTIAL_FORBIDDEN_403 = "the credential authenticated but is not allowed (403). " + "A PLATFORM-class credential with the matching platform:* scope is required \u2014 tenant-class " + "credentials can never reach the control plane, and platform keys act only within their scopes.";
151020
- function requireReason(reason, action) {
151021
- const trimmed = reason?.trim() ?? "";
151022
- if (trimmed.length < 3 || trimmed.length > 500) {
151023
- error(`--reason is required to ${action}. ${REASON_REQUIRED_HINT}`, undefined, 1);
151229
+ // src/commands/sources.ts
151230
+ var github = {
151231
+ name: "github",
151232
+ description: "GitHub repo webhooks (RFC #925 Phase 3 recipe)",
151233
+ signatureConfig: { algorithm: "hmac-sha256", header: "X-Hub-Signature-256", prefix: "sha256=" },
151234
+ idempotencyKeyTemplate: "github:{headers.x-github-delivery}",
151235
+ eventTypeMapping: { source: "header", header: "X-GitHub-Event" },
151236
+ expectedIntervalSeconds: 86400,
151237
+ schemas: { push: custom_github_push_default, pull_request: custom_github_pull_request_default, issues: custom_github_issues_default, release: custom_github_release_default },
151238
+ async ensureProviderWebhook(api, repo, hook) {
151239
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo))
151240
+ throw new Error(`--repo must be OWNER/NAME, got '${repo}'`);
151241
+ const body = {
151242
+ active: true,
151243
+ events: hook.events,
151244
+ config: { url: hook.url, content_type: "json", secret: hook.secret, insecure_ssl: "0" }
151245
+ };
151246
+ const existing = await api("GET", `/repos/${repo}/hooks`);
151247
+ const match = existing.find((h10) => h10.config?.url === hook.url);
151248
+ if (match) {
151249
+ await api("PATCH", `/repos/${repo}/hooks/${match.id}`, body);
151250
+ return String(match.id);
151251
+ }
151252
+ const created = await api("POST", `/repos/${repo}/hooks`, { name: "web", ...body });
151253
+ return String(created.id);
151024
151254
  }
151025
- return trimmed;
151026
- }
151027
- function parseRole(value) {
151028
- if (!TENANT_ROLES.includes(value)) {
151029
- error(`Invalid --role "${value}". Valid roles: ${TENANT_ROLES.join(", ")}`, undefined, 1);
151255
+ };
151256
+ var PRESETS = { github };
151257
+ var GITHUB_API = "https://api.github.com";
151258
+ async function ghAvailable() {
151259
+ try {
151260
+ return await Bun.spawn({ cmd: ["gh", "auth", "status"], stdout: "ignore", stderr: "ignore" }).exited === 0;
151261
+ } catch {
151262
+ return false;
151030
151263
  }
151031
- return value;
151032
151264
  }
151033
- function parseStatus(value) {
151034
- if (!MEMBERSHIP_STATUSES.includes(value)) {
151035
- error(`Invalid --status "${value}". Valid statuses: ${MEMBERSHIP_STATUSES.join(", ")}`, undefined, 1);
151265
+ async function githubApiViaGh(method, path, body) {
151266
+ const cmd = ["gh", "api", "--method", method, path];
151267
+ if (body !== undefined)
151268
+ cmd.push("--input", "-");
151269
+ const proc = Bun.spawn({ cmd, stdin: body === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe" });
151270
+ if (body !== undefined && proc.stdin) {
151271
+ proc.stdin.write(JSON.stringify(body));
151272
+ proc.stdin.end();
151036
151273
  }
151037
- return value;
151274
+ const [stdout, stderr, code] = await Promise.all([
151275
+ new Response(proc.stdout).text(),
151276
+ new Response(proc.stderr).text(),
151277
+ proc.exited
151278
+ ]);
151279
+ if (code !== 0)
151280
+ throw new Error(`gh api ${method} ${path} failed: ${stderr.trim() || stdout.trim()}`);
151281
+ return stdout.trim() ? JSON.parse(stdout) : undefined;
151038
151282
  }
151039
- function parseScopes(value) {
151040
- const scopes = value.split(",").map((s2) => s2.trim()).filter((s2) => s2.length > 0);
151041
- if (scopes.length === 0) {
151042
- error('--scopes must list at least one explicit tenant scope (comma-separated), e.g. --scopes "messages:send,chats:read".', undefined, 1);
151043
- }
151044
- return scopes;
151283
+ function githubApiViaToken(token) {
151284
+ return async (method, path, body) => {
151285
+ const resp = await fetch(`${GITHUB_API}${path}`, {
151286
+ method,
151287
+ headers: {
151288
+ authorization: `Bearer ${token}`,
151289
+ accept: "application/vnd.github+json",
151290
+ "content-type": "application/json"
151291
+ },
151292
+ body: body === undefined ? undefined : JSON.stringify(body)
151293
+ });
151294
+ if (!resp.ok)
151295
+ throw new Error(`GitHub API ${method} ${path} returned ${resp.status}: ${await resp.text()}`);
151296
+ return resp.status === 204 ? undefined : await resp.json();
151297
+ };
151045
151298
  }
151046
- function parseConstraints(value) {
151047
- const invalid = () => error(`Invalid --constraints "${value}". Expected a JSON object of string arrays, e.g. '{"instanceAllowlist":["<instance-id>"]}'.`, undefined, 1);
151048
- let parsed;
151049
- try {
151050
- parsed = JSON.parse(value);
151051
- } catch {
151052
- invalid();
151299
+ async function resolveGithubApi() {
151300
+ if (await ghAvailable())
151301
+ return githubApiViaGh;
151302
+ const token = process.env.GITHUB_TOKEN;
151303
+ if (token)
151304
+ return githubApiViaToken(token);
151305
+ throw new Error("no GitHub credentials: log in with `gh auth login` or set GITHUB_TOKEN (or pass --no-provider-webhook)");
151306
+ }
151307
+
151308
+ class StepFailure extends Error {
151309
+ step;
151310
+ completed;
151311
+ constructor(step, completed, cause) {
151312
+ super(cause instanceof Error ? cause.message : String(cause));
151313
+ this.step = step;
151314
+ this.completed = completed;
151053
151315
  }
151054
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
151055
- invalid();
151316
+ }
151317
+ var STEPS = ["source", "idempotency", "event-type-mapping", "schemas", "provider-webhook"];
151318
+ async function addSource(input) {
151319
+ const { preset, client } = input;
151320
+ const unknown2 = input.events.filter((e10) => !(e10 in preset.schemas));
151321
+ if (unknown2.length > 0) {
151322
+ throw new Error(`unknown ${preset.name} events: ${unknown2.join(", ")} (known: ${Object.keys(preset.schemas).join(", ")})`);
151056
151323
  }
151057
- const record2 = parsed;
151058
- for (const entry of Object.values(record2)) {
151059
- if (!Array.isArray(entry) || entry.some((item) => typeof item !== "string")) {
151060
- invalid();
151324
+ const webhookUrl = `${input.publicUrl.replace(/\/+$/, "")}/api/v2/webhooks/ingress/${preset.name}`;
151325
+ const completed = [];
151326
+ const run = async (step, fn) => {
151327
+ try {
151328
+ const value = await fn();
151329
+ completed.push(step);
151330
+ return value;
151331
+ } catch (err2) {
151332
+ throw new StepFailure(step, completed, err2);
151061
151333
  }
151334
+ };
151335
+ const source = await run(STEPS[0], async () => {
151336
+ const existing = (await client.listSources()).find((s2) => s2.name === preset.name);
151337
+ const body = {
151338
+ description: preset.description,
151339
+ signatureConfig: preset.signatureConfig,
151340
+ signatureSecret: input.secret,
151341
+ expectedIntervalSeconds: preset.expectedIntervalSeconds
151342
+ };
151343
+ if (existing)
151344
+ return { id: (await client.updateSource(existing.id, body)).id, created: false };
151345
+ return { id: (await client.createSource({ name: preset.name, enabled: true, ...body })).id, created: true };
151346
+ });
151347
+ await run(STEPS[1], () => client.updateSource(source.id, { idempotencyKeyTemplate: preset.idempotencyKeyTemplate }));
151348
+ await run(STEPS[2], () => client.updateSource(source.id, { eventTypeMapping: preset.eventTypeMapping }));
151349
+ await run(STEPS[3], async () => {
151350
+ for (const event of input.events) {
151351
+ const eventType = `custom.${preset.name}.${event}`;
151352
+ await input.registerSchema(eventType, preset.schemas[event], `${preset.name} ${event} deliveries`);
151353
+ }
151354
+ });
151355
+ let providerHookId;
151356
+ if (input.providerWebhook) {
151357
+ providerHookId = await run(STEPS[4], async () => preset.ensureProviderWebhook(await input.providerApi(), input.target, {
151358
+ url: webhookUrl,
151359
+ secret: input.secret,
151360
+ events: input.events
151361
+ }));
151062
151362
  }
151063
- return record2;
151363
+ return { sourceId: source.id, created: source.created, webhookUrl, providerHookId, completed };
151064
151364
  }
151065
- function renderPlatformError(err2, action) {
151066
- if (err2 instanceof OmniApiError) {
151067
- if (err2.status === 404 && err2.message.startsWith("Endpoint not found")) {
151068
- error(`Failed to ${action}: ${CONTROL_PLANE_UNAVAILABLE}`, undefined, 1);
151069
- }
151070
- if (err2.status === 401) {
151071
- error(`Failed to ${action}: ${CREDENTIAL_REJECTED_401}`, undefined, 1);
151072
- }
151073
- if (err2.status === 403) {
151074
- error(`Failed to ${action}: ${CREDENTIAL_FORBIDDEN_403}`, undefined, 1);
151075
- }
151076
- }
151077
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151078
- error(`Failed to ${action}: ${message2}`);
151079
- }
151080
- var TENANT_NOT_ACTIVE_409 = "the tenant is not active (409). Root keys can be issued only while the tenant is active \u2014 " + "a suspended or archived tenant refuses new credentials.";
151081
- var ROOT_KEY_EXCEEDS_POLICY_403 = "the request exceeds the tenant policy ceilings (403). Keep --expires within the tenant " + "max-key-ttl and --rate-limit/--budget at or below the tenant ceilings (see: omni tenants get <id>).";
151082
- function renderRootKeyError(err2, action) {
151083
- if (err2 instanceof OmniApiError) {
151084
- if (err2.status === 409) {
151085
- error(`Failed to ${action}: ${TENANT_NOT_ACTIVE_409}`, undefined, 1);
151086
- }
151087
- if (err2.status === 400) {
151088
- error(`Failed to ${action}: ${err2.message}`, undefined, 1);
151089
- }
151090
- if (err2.status === 403 && err2.message.includes("exceeds tenant policy")) {
151091
- error(`Failed to ${action}: ${ROOT_KEY_EXCEEDS_POLICY_403}`, undefined, 1);
151365
+ function createSourcesCommand() {
151366
+ const sources = new Command("sources").description("One-command onboarding of external webhook sources");
151367
+ sources.command("add <provider>").description(`Create or update a provider source end to end (presets: ${Object.keys(PRESETS).join(", ")})`).requiredOption("--repo <owner/name>", "Provider-side target (GitHub: OWNER/NAME)").option("--events <list>", "Comma-separated provider events (default: every bundled schema)").option("--public-url <url>", "Omni URL reachable from the provider (default: configured apiUrl)").option("--secret-env <VAR>", "Reuse the webhook secret from environment variable VAR instead of generating one").option("--no-provider-webhook", "Skip creating the webhook on the provider side").action(async (provider, options3) => {
151368
+ const preset = PRESETS[provider];
151369
+ if (!preset)
151370
+ error(`Unknown provider '${provider}'. Presets: ${Object.keys(PRESETS).join(", ")}`);
151371
+ const client = getClient();
151372
+ try {
151373
+ const secret = await resolveSignatureSecret({ signatureSecretEnv: options3.secretEnv }) ?? randomBytes4(32).toString("hex");
151374
+ const result = await addSource({
151375
+ preset,
151376
+ target: options3.repo,
151377
+ events: options3.events ? options3.events.split(",").map((e10) => e10.trim()) : Object.keys(preset.schemas),
151378
+ secret,
151379
+ publicUrl: options3.publicUrl ?? loadConfig().apiUrl ?? "http://localhost:8882",
151380
+ providerWebhook: options3.providerWebhook,
151381
+ client: client.webhooks,
151382
+ registerSchema: (eventType, schema3, description) => schemaApiRequest("", { method: "POST", body: JSON.stringify({ eventType, schema: schema3, description }) }),
151383
+ providerApi: resolveGithubApi
151384
+ });
151385
+ success(`Source ${result.created ? "created" : "updated"}: ${result.sourceId}`, {
151386
+ id: result.sourceId,
151387
+ name: preset.name,
151388
+ secret,
151389
+ webhookUrl: result.webhookUrl,
151390
+ providerHookId: result.providerHookId ?? "(skipped)",
151391
+ steps: result.completed.join(", ")
151392
+ });
151393
+ warn("The secret is shown once; store it now (re-run with --secret-env VAR to reuse it).");
151394
+ } catch (err2) {
151395
+ if (err2 instanceof StepFailure) {
151396
+ const remaining = STEPS.filter((s2) => !err2.completed.includes(s2));
151397
+ error(`Step '${err2.step}' failed: ${err2.message}`, {
151398
+ completed: err2.completed,
151399
+ remaining,
151400
+ resume: "re-run the same command; completed steps are updated in place, not duplicated"
151401
+ });
151402
+ }
151403
+ error(`Failed to add source: ${err2 instanceof Error ? err2.message : String(err2)}`);
151092
151404
  }
151093
- }
151094
- renderPlatformError(err2, action);
151095
- }
151096
- function formatTenantRow(tenant) {
151097
- return {
151098
- id: tenant.id,
151099
- slug: tenant.slug,
151100
- name: tenant.displayName,
151101
- status: tenant.status,
151102
- keyTtl: tenant.maxKeyTtlSeconds,
151103
- keyRate: tenant.maxKeyRateLimit,
151104
- keyBudget: tenant.maxKeyBudget,
151105
- created: new Date(tenant.createdAt).toLocaleDateString()
151106
- };
151107
- }
151108
- function formatMembershipRow(membership) {
151109
- return {
151110
- id: membership.id,
151111
- principal: membership.principalId,
151112
- role: membership.role,
151113
- status: membership.status,
151114
- invitedBy: membership.invitedByPrincipalId ?? "-",
151115
- created: new Date(membership.createdAt).toLocaleDateString()
151116
- };
151117
- }
151118
- async function handleList5(client, options3) {
151119
- const reason = requireReason(options3.reason, "list tenants");
151120
- const result = await client.platform.tenants.list(reason);
151121
- list(result.items.map(formatTenantRow), {
151122
- emptyMessage: "No tenants found.",
151123
- rawData: result.items
151124
- });
151125
- }
151126
- async function handleGet3(client, id, options3) {
151127
- const reason = requireReason(options3.reason, "read a tenant");
151128
- const tenant = await client.platform.tenants.get(id, reason);
151129
- data(tenant);
151130
- }
151131
- async function handleCreate4(client, options3) {
151132
- const reason = requireReason(options3.reason, "create a tenant");
151133
- const tenant = await client.platform.tenants.create({
151134
- slug: options3.slug,
151135
- displayName: options3.name,
151136
- maxKeyTtlSeconds: options3.maxKeyTtl,
151137
- maxKeyRateLimit: options3.maxKeyRate,
151138
- maxKeyBudget: options3.maxKeyBudget,
151139
- reason
151140
- });
151141
- success(`Tenant created: ${tenant.slug} (${tenant.id})`);
151142
- data(tenant);
151143
- }
151144
- async function handleSuspend(client, id, options3) {
151145
- const reason = requireReason(options3.reason, "suspend a tenant");
151146
- const tenant = await client.platform.tenants.suspend(id, reason);
151147
- success(`Tenant suspended: ${tenant.slug} (${tenant.id})`);
151148
- info(`Reason: ${reason}`);
151149
- }
151150
- async function handleArchive(client, id, options3) {
151151
- const reason = requireReason(options3.reason, "archive a tenant");
151152
- const tenant = await client.platform.tenants.archive(id, reason);
151153
- success(`Tenant archived: ${tenant.slug} (${tenant.id})`);
151154
- warn("Archived is terminal \u2014 there is no un-archive and no hard delete.");
151155
- info(`Reason: ${reason}`);
151156
- }
151157
- async function handleMembershipsList(client, tenantId, options3) {
151158
- const reason = requireReason(options3.reason, "list memberships");
151159
- const result = await client.platform.tenants.memberships.list(tenantId, reason);
151160
- list(result.items.map(formatMembershipRow), {
151161
- emptyMessage: "No memberships found.",
151162
- rawData: result.items
151163
- });
151164
- }
151165
- async function handleMembershipsAdd(client, tenantId, options3) {
151166
- const reason = requireReason(options3.reason, "attach a membership");
151167
- const role = parseRole(options3.role);
151168
- const membership = await client.platform.tenants.memberships.attach(tenantId, {
151169
- principalId: options3.principal,
151170
- role,
151171
- reason
151172
151405
  });
151173
- success(`Membership attached: ${membership.principalId} as ${membership.role} (${membership.id})`);
151174
- }
151175
- async function handleMembershipsDisable(client, tenantId, membershipId, options3) {
151176
- const reason = requireReason(options3.reason, "disable a membership");
151177
- const membership = await client.platform.tenants.memberships.disable(tenantId, membershipId, reason);
151178
- success(`Membership disabled: ${membership.id}`);
151179
- info(`Reason: ${reason}`);
151180
- }
151181
- async function handleMembershipsStatus(client, tenantId, membershipId, options3) {
151182
- const reason = requireReason(options3.reason, "set a membership status");
151183
- const status = parseStatus(options3.status);
151184
- const membership = await client.platform.tenants.memberships.setStatus(tenantId, membershipId, status, reason);
151185
- success(`Membership ${membership.id} status set to ${membership.status}`);
151406
+ return sources;
151186
151407
  }
151187
- async function handleMembershipsRole(client, tenantId, membershipId, options3) {
151188
- const reason = requireReason(options3.reason, "set a membership role");
151189
- const role = parseRole(options3.role);
151190
- const membership = await client.platform.tenants.memberships.setRole(tenantId, membershipId, role, reason);
151191
- success(`Membership ${membership.id} role set to ${membership.role}`);
151408
+
151409
+ // src/commands/speak.ts
151410
+ import { writeFile as writeFile2 } from "fs/promises";
151411
+ init_output();
151412
+ var ALLOWED_FORMATS2 = ["mp3", "ogg", "opus", "wav", "pcm", "flac", "aac"];
151413
+ function parseFormat2(value) {
151414
+ if (!value)
151415
+ return;
151416
+ const normalized = value.toLowerCase();
151417
+ if (!ALLOWED_FORMATS2.includes(normalized)) {
151418
+ error(`Invalid --format "${value}". Allowed: ${ALLOWED_FORMATS2.join(", ")}`);
151419
+ }
151420
+ return normalized;
151192
151421
  }
151193
- async function handleKeysIssueRoot(client, tenantId, options3) {
151194
- const reason = requireReason(options3.reason, "issue a root key");
151195
- const role = parseRole(options3.role);
151196
- const scopes = parseScopes(options3.scopes);
151197
- const constraints = options3.constraints === undefined ? undefined : parseConstraints(options3.constraints);
151198
- const body = {
151199
- principalId: options3.principal,
151200
- membershipId: options3.membership,
151201
- role,
151202
- name: options3.name,
151203
- scopes,
151204
- expiresAt: options3.expires,
151205
- rateLimit: options3.rateLimit,
151206
- budget: options3.budget,
151207
- ...constraints ? { resourceConstraints: constraints } : {},
151208
- reason
151209
- };
151210
- const result = await client.platform.tenants.keys.issueRoot(tenantId, body);
151211
- if (getCurrentFormat() === "json") {
151212
- warn("plainTextKey is shown once and can never be retrieved again \u2014 store it now.");
151213
- data(result);
151422
+ function parseSpeed(value) {
151423
+ if (value === undefined)
151214
151424
  return;
151425
+ const n2 = Number.parseFloat(value);
151426
+ if (Number.isNaN(n2) || n2 < 0.5 || n2 > 2) {
151427
+ error(`Invalid --speed "${value}". Must be a number between 0.5 and 2.0.`);
151215
151428
  }
151216
- const { plainTextKey, ...issued } = result;
151217
- success(`Root key issued: ${issued.name} (${issued.id})`);
151218
- warn("The key below is shown ONCE and can never be retrieved again \u2014 store it now.");
151219
- raw(`
151220
- ${plainTextKey}
151221
- `);
151222
- data(issued);
151429
+ return n2;
151223
151430
  }
151224
- var REASON_HELP = "Audited justification (required, 3-500 chars)";
151225
- function createTenantsCommand() {
151226
- const tenants2 = new Command("tenants").description("Platform tenant control plane (requires a PLATFORM-class credential and OMNI_MULTITENANCY_ENABLED=true)");
151227
- tenants2.command("list").description("List tenants, newest first (the read itself is audited)").option("--reason <text>", REASON_HELP).action(async (options3) => {
151431
+ function createSpeakCommand() {
151432
+ return new Command("speak").description("Synthesize text to speech and send as a voice note (or save with --output)").argument("<text>", "Text to convert to speech").option("--provider <name>", "TTS provider (gemini, openai, elevenlabs). Default: server config.").option("--model <model>", "TTS model override (provider-specific)").option("--voice <name>", "Voice identifier (e.g. Kore for Gemini, JBFqn... for ElevenLabs)").option("--style <prompt>", 'Style prompt prepended to text (Gemini only, e.g. "Say cheerfully")').option("--instructions <text>", "Provider-native speaking instructions").option("--tone <text>", "Tone guidance").option("--accent <text>", "Accent guidance").option("--pace <text>", "Pace guidance").option("--emotion <text>", "Emotion guidance").option("--voice-note-profile <name>", "Voice-note profile/preset name").option("--language <code>", "BCP-47 language code (e.g. en-US, pt-BR)").option("--speed <factor>", "Speaking speed multiplier 0.5-2.0 (provider-dependent)").option("--format <fmt>", `Audio format: ${ALLOWED_FORMATS2.join(", ")} (default: ogg)`).option("--output <path>", "Save audio to file instead of sending").option("--instance <id>", "Override instance (default: from context)").option("--chat <id>", "Override chat (default: from context)").action(async (text3, options3) => {
151228
151433
  const client = getClient();
151434
+ const format = parseFormat2(options3.format);
151435
+ const speed = parseSpeed(options3.speed);
151436
+ let audioBuffer;
151437
+ let mimeType;
151438
+ let durationMs;
151439
+ let providerName;
151229
151440
  try {
151230
- await handleList5(client, options3);
151441
+ const result = await client.media.tts({
151442
+ text: text3,
151443
+ provider: options3.provider,
151444
+ voice: options3.voice,
151445
+ language: options3.language,
151446
+ speed,
151447
+ format,
151448
+ style: options3.style,
151449
+ model: options3.model,
151450
+ instructions: options3.instructions,
151451
+ tone: options3.tone,
151452
+ accent: options3.accent,
151453
+ pace: options3.pace,
151454
+ emotion: options3.emotion,
151455
+ voiceNoteProfile: options3.voiceNoteProfile
151456
+ });
151457
+ audioBuffer = result.audio;
151458
+ mimeType = result.mimeType;
151459
+ durationMs = result.durationMs;
151460
+ providerName = result.provider;
151231
151461
  } catch (err2) {
151232
- renderPlatformError(err2, "list tenants");
151462
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151463
+ return error(`TTS synthesis failed: ${message2}`);
151233
151464
  }
151234
- });
151235
- tenants2.command("get <id>").description("Get one tenant by id").option("--reason <text>", REASON_HELP).action(async (id, options3) => {
151236
- const client = getClient();
151237
- try {
151238
- await handleGet3(client, id, options3);
151239
- } catch (err2) {
151240
- renderPlatformError(err2, "get the tenant");
151465
+ if (options3.output) {
151466
+ try {
151467
+ await writeFile2(options3.output, audioBuffer);
151468
+ return success("Audio saved", {
151469
+ path: options3.output,
151470
+ provider: providerName,
151471
+ mimeType,
151472
+ durationMs,
151473
+ sizeKb: Math.round(audioBuffer.length / 1024 * 100) / 100
151474
+ });
151475
+ } catch (err2) {
151476
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151477
+ return error(`Failed to write ${options3.output}: ${message2}`);
151478
+ }
151241
151479
  }
151242
- });
151243
- tenants2.command("create").description("Create a tenant with its mandatory credential ceilings").requiredOption("--slug <slug>", "Immutable lowercase DNS-like slug (e.g. acme)").requiredOption("--name <name>", "Human-readable tenant name").requiredOption("--max-key-ttl <seconds>", "Ceiling for credential TTL, in seconds", Number.parseInt).requiredOption("--max-key-rate <n>", "Ceiling for credential rate limit", Number.parseInt).requiredOption("--max-key-budget <n>", "Ceiling for credential budget", Number.parseInt).option("--reason <text>", REASON_HELP).action(async (options3) => {
151244
- const client = getClient();
151245
- try {
151246
- await handleCreate4(client, options3);
151247
- } catch (err2) {
151248
- renderPlatformError(err2, "create the tenant");
151480
+ const ctx = await resolveContext({
151481
+ instance: options3.instance,
151482
+ chat: options3.chat
151483
+ });
151484
+ if (!ctx.instanceId) {
151485
+ return error("No instance in context. Set OMNI_INSTANCE, use --instance, run: omni use <instance>, or pass --output to save.");
151249
151486
  }
151250
- });
151251
- tenants2.command("suspend <id>").description("Suspend a tenant (bumps its revocation epoch, invalidating tenant credentials)").option("--reason <text>", REASON_HELP).action(async (id, options3) => {
151252
- const client = getClient();
151253
- try {
151254
- await handleSuspend(client, id, options3);
151255
- } catch (err2) {
151256
- renderPlatformError(err2, "suspend the tenant");
151487
+ if (!ctx.chatId) {
151488
+ return error("No chat in context. Set OMNI_CHAT, use --chat, run: omni open <contact>, or pass --output to save.");
151257
151489
  }
151258
- });
151259
- tenants2.command("archive <id>").description("Archive a tenant (TERMINAL \u2014 no un-archive, no hard delete)").option("--reason <text>", REASON_HELP).action(async (id, options3) => {
151260
- const client = getClient();
151261
151490
  try {
151262
- await handleArchive(client, id, options3);
151263
- } catch (err2) {
151264
- renderPlatformError(err2, "archive the tenant");
151265
- }
151266
- });
151267
- const memberships = new Command("memberships").description("Manage tenant memberships");
151268
- memberships.command("list <tenant-id>").description("List the memberships of one tenant (the read itself is audited)").option("--reason <text>", REASON_HELP).action(async (tenantId, options3) => {
151269
- const client = getClient();
151270
- try {
151271
- await handleMembershipsList(client, tenantId, options3);
151272
- } catch (err2) {
151273
- renderPlatformError(err2, "list memberships");
151274
- }
151275
- });
151276
- memberships.command("add <tenant-id>").description("Grant a principal a role in the tenant").requiredOption("--principal <uuid>", "Principal to grant access to").requiredOption("--role <role>", `Tenant role: ${TENANT_ROLES.join(" | ")}`).option("--reason <text>", REASON_HELP).action(async (tenantId, options3) => {
151277
- const client = getClient();
151278
- try {
151279
- await handleMembershipsAdd(client, tenantId, options3);
151280
- } catch (err2) {
151281
- renderPlatformError(err2, "attach the membership");
151282
- }
151283
- });
151284
- memberships.command("disable <tenant-id> <membership-id>").description("Disable a membership (memberships are never hard-deleted)").option("--reason <text>", REASON_HELP).action(async (tenantId, membershipId, options3) => {
151285
- const client = getClient();
151286
- try {
151287
- await handleMembershipsDisable(client, tenantId, membershipId, options3);
151288
- } catch (err2) {
151289
- renderPlatformError(err2, "disable the membership");
151290
- }
151291
- });
151292
- memberships.command("status <tenant-id> <membership-id>").description("Activate or disable an existing membership").requiredOption("--status <status>", `New status: ${MEMBERSHIP_STATUSES.join(" | ")}`).option("--reason <text>", REASON_HELP).action(async (tenantId, membershipId, options3) => {
151293
- const client = getClient();
151294
- try {
151295
- await handleMembershipsStatus(client, tenantId, membershipId, options3);
151296
- } catch (err2) {
151297
- renderPlatformError(err2, "set the membership status");
151298
- }
151299
- });
151300
- memberships.command("role <tenant-id> <membership-id>").description("Change the role an existing membership acts under").requiredOption("--role <role>", `New role: ${TENANT_ROLES.join(" | ")}`).option("--reason <text>", REASON_HELP).action(async (tenantId, membershipId, options3) => {
151301
- const client = getClient();
151302
- try {
151303
- await handleMembershipsRole(client, tenantId, membershipId, options3);
151304
- } catch (err2) {
151305
- renderPlatformError(err2, "set the membership role");
151306
- }
151307
- });
151308
- tenants2.addCommand(memberships);
151309
- const keys = new Command("keys").description("Manage tenant credentials");
151310
- keys.command("issue-root <tenant-id>").description("Issue the initial tenant ROOT key (the plaintext is shown ONCE and never again)").requiredOption("--principal <uuid>", "Principal the key acts as (must hold the membership)").requiredOption("--membership <uuid>", "Active membership binding the principal to the tenant").requiredOption("--role <role>", `Tenant role: ${TENANT_ROLES.join(" | ")}`).requiredOption("--name <name>", "Human-readable key name").requiredOption("--scopes <list>", "Comma-separated explicit tenant scopes (platform/wildcard scopes are refused)").requiredOption("--expires <iso>", "Expiry (ISO 8601); must be in the future and within the tenant TTL ceiling").requiredOption("--rate-limit <n>", "Rate limit; capped by the tenant policy ceiling", Number.parseInt).requiredOption("--budget <n>", "Budget; capped by the tenant policy ceiling", Number.parseInt).option("--constraints <json>", `Optional resource allowlists as JSON, e.g. '{"instanceAllowlist":["<id>"]}'`).option("--reason <text>", REASON_HELP).action(async (tenantId, options3) => {
151311
- const client = getClient();
151312
- try {
151313
- await handleKeysIssueRoot(client, tenantId, options3);
151491
+ const result = await client.messages.sendMedia({
151492
+ instanceId: ctx.instanceId,
151493
+ to: ctx.chatId,
151494
+ type: "audio",
151495
+ base64: audioBuffer.toString("base64"),
151496
+ filename: pickFilename(mimeType, providerName),
151497
+ voiceNote: true
151498
+ });
151499
+ success("Voice note sent", {
151500
+ provider: providerName,
151501
+ durationMs,
151502
+ sizeKb: Math.round(audioBuffer.length / 1024 * 100) / 100,
151503
+ ...result
151504
+ });
151314
151505
  } catch (err2) {
151315
- renderRootKeyError(err2, "issue the root key");
151506
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151507
+ error(`Failed to send voice note: ${message2}`);
151316
151508
  }
151317
151509
  });
151318
- tenants2.addCommand(keys);
151319
- return tenants2;
151510
+ }
151511
+ function pickFilename(mimeType, provider) {
151512
+ const lower = mimeType.toLowerCase();
151513
+ if (lower.includes("ogg") || lower.includes("opus"))
151514
+ return `${provider}-speak.ogg`;
151515
+ if (lower.includes("wav"))
151516
+ return `${provider}-speak.wav`;
151517
+ if (lower.includes("mp3") || lower.includes("mpeg"))
151518
+ return `${provider}-speak.mp3`;
151519
+ if (lower.includes("flac"))
151520
+ return `${provider}-speak.flac`;
151521
+ if (lower.includes("aac") || lower.includes("m4a"))
151522
+ return `${provider}-speak.m4a`;
151523
+ return `${provider}-speak.bin`;
151320
151524
  }
151321
151525
 
151322
- // src/commands/trust.ts
151323
- import { hostname as osHostname } from "os";
151526
+ // src/commands/start.ts
151527
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11 } from "fs";
151528
+ import { homedir as homedir12 } from "os";
151529
+ import { join as join17 } from "path";
151324
151530
  init_config();
151531
+ init_embedded_canonical_migration();
151325
151532
  init_output();
151326
- function targetBaseUrl() {
151327
- return normalizeServerUrl(loadConfig().apiUrl ?? "http://localhost:8882");
151328
- }
151329
- function trustEndpoint(path) {
151330
- if (!hasAuth()) {
151331
- exitNotAuthenticated();
151332
- }
151333
- return `${targetBaseUrl()}/api/v2/trust${path}`;
151533
+ var START_HEALTH_TIMEOUT_MS = 1e4;
151534
+ function legacyEmbeddedNeedsMigration(useCanonicalPgserve, embeddedDataPresent = existsSync18(join17(EMBEDDED_PGSERVE_DATA_DIR, "PG_VERSION"))) {
151535
+ return useCanonicalPgserve !== true && embeddedDataPresent;
151334
151536
  }
151335
- function authHeaders() {
151336
- const config2 = loadConfig();
151337
- const headers = { "Content-Type": "application/json" };
151338
- if (config2.apiKey) {
151339
- headers.Authorization = `Bearer ${config2.apiKey}`;
151537
+ async function runStart() {
151538
+ if (!await isPm2Available()) {
151539
+ pm2NotFoundError();
151340
151540
  }
151341
- return headers;
151342
- }
151343
- var _cachedSigningContext;
151344
- function getSigningContextOnce() {
151345
- if (_cachedSigningContext === undefined) {
151346
- _cachedSigningContext = loadSigningContextForServer(targetBaseUrl());
151541
+ const bundlePath = getServerBundlePath();
151542
+ if (!existsSync18(bundlePath)) {
151543
+ bundleNotFoundError(bundlePath);
151347
151544
  }
151348
- return _cachedSigningContext;
151349
- }
151350
- async function callApi(method, path, body) {
151351
- const url = trustEndpoint(path);
151352
- const bodyString = body === undefined ? "" : JSON.stringify(body);
151353
- const headers = authHeaders();
151354
- const ctx = getSigningContextOnce();
151355
- if (ctx && !path.startsWith("/handshake")) {
151356
- const reqPath = new URL(url).pathname + new URL(url).search;
151357
- const sigHeaders = ctx.signRequest(method, reqPath, bodyString);
151358
- Object.assign(headers, sigHeaders);
151545
+ const serverConfig = loadServerConfig();
151546
+ const apiPort = serverConfig.port;
151547
+ if (legacyEmbeddedNeedsMigration(serverConfig.useCanonicalPgserve)) {
151548
+ const major = readDataDirMajor(EMBEDDED_PGSERVE_DATA_DIR);
151549
+ error(`Legacy embedded pgserve detected \u2014 this omni build does not run an embedded Postgres.
151550
+ Embedded data dir: ${EMBEDDED_PGSERVE_DATA_DIR}${major ? ` (PostgreSQL ${major})` : ""}
151551
+ Your data is intact. Migrate to canonical pgserve first:
151552
+ omni doctor --fix
151553
+ (idempotent \u2014 preserves data; do not \`omni start\` until it completes).`, undefined, 1);
151359
151554
  }
151360
- const res = await fetch(url, {
151361
- method,
151362
- headers,
151363
- body: body === undefined ? undefined : bodyString
151555
+ mkdirSync11(getPm2LogDir(), { recursive: true });
151556
+ info(`Starting ${PM2_PROCESSES.api} (port ${apiPort})...`);
151557
+ const cliConfig = loadLocalRuntimeConfig();
151558
+ const env2 = buildRuntimeEnv(serverConfig, cliConfig);
151559
+ const launcherPath = getServerLauncherPath();
151560
+ const apiArgs = buildPm2StartArgs({
151561
+ kind: "api",
151562
+ script: launcherPath,
151563
+ name: PM2_PROCESSES.api,
151564
+ interpreter: "bash"
151364
151565
  });
151365
- if (!res.ok) {
151366
- const text3 = await res.text().catch(() => "");
151367
- throw new Error(`HTTP ${res.status} ${res.statusText}${text3 ? `: ${text3}` : ""}`);
151566
+ const apiCode = await runPm2(apiArgs, env2);
151567
+ if (apiCode !== 0) {
151568
+ error(`Failed to start ${PM2_PROCESSES.api} (pm2 exit code ${apiCode})`, undefined, 1);
151569
+ return;
151368
151570
  }
151369
- return await res.json();
151370
- }
151371
- function formatHostRow(host) {
151372
- return {
151373
- id: host.id.slice(0, 8),
151374
- hostname: host.hostname,
151375
- scopes: host.scopes.join(", ") || "(none)",
151376
- pubkeyPrefix: `${host.pubkey.slice(0, 12)}\u2026`,
151377
- lastSeen: host.lastSeenAt ? new Date(host.lastSeenAt).toISOString().replace("T", " ").slice(0, 16) : "never",
151378
- status: host.revokedAt ? "revoked" : "active"
151379
- };
151380
- }
151381
- async function handleList6() {
151382
- try {
151383
- const { items } = await callApi("GET", "/hosts");
151384
- list(items.map(formatHostRow), {
151385
- emptyMessage: "No genie hosts registered. Run `genie omni handshake` from a genie installation to register one.",
151386
- rawData: items
151571
+ const natsPath = join17(homedir12(), ".omni", "nats-server");
151572
+ if (existsSync18(natsPath)) {
151573
+ info(`Starting ${PM2_PROCESSES.nats}...`);
151574
+ const natsDataDir = join17(serverConfig.dataDir, "nats");
151575
+ mkdirSync11(natsDataDir, { recursive: true });
151576
+ const natsArgs = buildPm2StartArgs({
151577
+ kind: "nats",
151578
+ script: natsPath,
151579
+ name: PM2_PROCESSES.nats,
151580
+ scriptArgs: ["-js", "-sd", natsDataDir]
151387
151581
  });
151388
- } catch (err2) {
151389
- error(`Failed to list genie hosts: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
151582
+ const natsCode = await runPm2(natsArgs);
151583
+ if (natsCode !== 0) {
151584
+ warn(`${PM2_PROCESSES.nats} failed to start \u2014 run 'omni install' to download NATS first`);
151585
+ }
151586
+ } else {
151587
+ warn(`NATS binary not found at ${natsPath} \u2014 skipping. Run 'omni install' to set it up.`);
151390
151588
  }
151391
- }
151392
- async function handleGet4(id) {
151393
- try {
151394
- const { data: data2 } = await callApi("GET", `/hosts/${encodeURIComponent(id)}`);
151395
- data(data2);
151396
- } catch (err2) {
151397
- error(`Failed to get genie host: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
151589
+ const healthUrl = getHealthCheckUrl(apiPort);
151590
+ info(`Waiting for health check at ${healthUrl}...`);
151591
+ const healthy = await waitForHealth(apiPort, START_HEALTH_TIMEOUT_MS);
151592
+ if (healthy) {
151593
+ success(`Server is healthy at ${healthUrl}`);
151594
+ } else {
151595
+ warn(`Health check did not pass within ${START_HEALTH_TIMEOUT_MS / 1000}s \u2014 server may still be starting`);
151398
151596
  }
151399
151597
  }
151400
- async function handleUpdate3(id, options3) {
151401
- const scopes = options3.scope.split(",").map((s2) => s2.trim()).filter(Boolean);
151402
- if (scopes.length === 0) {
151403
- error("--scope must contain at least one scope (use `omni trust revoke <id>` to deny everything).");
151598
+ function createStartCommand() {
151599
+ return new Command("start").description("Start API and NATS via PM2").action(runStart);
151600
+ }
151601
+
151602
+ // src/commands/status.ts
151603
+ init_config();
151604
+ init_output();
151605
+ function formatUptime(ms) {
151606
+ if (ms === undefined || ms === 0)
151607
+ return "-";
151608
+ const seconds = Math.floor(ms / 1000);
151609
+ if (seconds < 60)
151610
+ return `${seconds}s`;
151611
+ const minutes = Math.floor(seconds / 60);
151612
+ if (minutes < 60)
151613
+ return `${minutes}m`;
151614
+ const hours = Math.floor(minutes / 60);
151615
+ if (hours < 24)
151616
+ return `${hours}h`;
151617
+ const days = Math.floor(hours / 24);
151618
+ return `${days}d`;
151619
+ }
151620
+ function formatMemory(bytes) {
151621
+ if (bytes === undefined || bytes === 0)
151622
+ return "-";
151623
+ return `${Math.round(bytes / 1024 / 1024)}MB`;
151624
+ }
151625
+ async function getProcessStatus() {
151626
+ if (!await isPm2Available()) {
151627
+ return null;
151404
151628
  }
151405
- try {
151406
- const { data: data2 } = await callApi("PATCH", `/hosts/${encodeURIComponent(id)}`, { scopes });
151407
- success(`Updated genie host ${data2.id} scopes: ${data2.scopes.join(", ")}`);
151408
- data(data2);
151409
- } catch (err2) {
151410
- error(`Failed to update genie host: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
151629
+ const { code, stdout } = await capturePm2("jlist");
151630
+ if (code !== 0) {
151631
+ return null;
151411
151632
  }
151412
- }
151413
- async function registerPubkey(pubkey, hostname) {
151633
+ let processes = [];
151414
151634
  try {
151415
- const res = await callApi("POST", "/handshake", {
151416
- pubkey,
151417
- hostname,
151418
- capabilities: {
151419
- client: "omni-cli",
151420
- platform: process.platform,
151421
- nodeVersion: process.version
151635
+ processes = JSON.parse(stdout);
151636
+ } catch {
151637
+ return null;
151638
+ }
151639
+ const omniProcesses = processes.filter((p11) => typeof p11.name === "string" && p11.name.startsWith("omni-"));
151640
+ return omniProcesses.map((p11) => ({
151641
+ service: p11.name ?? "-",
151642
+ pid: p11.pid !== undefined ? String(p11.pid) : "-",
151643
+ status: p11.pm2_env?.status ?? "-",
151644
+ uptime: p11.pm2_env?.pm_uptime ? formatUptime(Date.now() - p11.pm2_env.pm_uptime) : "-",
151645
+ cpu: p11.monit?.cpu !== undefined ? `${p11.monit.cpu}%` : "-",
151646
+ memory: formatMemory(p11.monit?.memory)
151647
+ }));
151648
+ }
151649
+ async function checkApiHealth(statusInfo, apiUrl) {
151650
+ try {
151651
+ const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/v2/health`, {
151652
+ headers: {
151653
+ "Accept-Encoding": "identity",
151654
+ [CLI_VERSION_HEADER]: VERSION
151422
151655
  }
151423
151656
  });
151424
- return res.data;
151657
+ const health = await response.json();
151658
+ statusInfo.apiStatus = health.status ?? "unknown";
151659
+ statusInfo.apiVersion = health.version;
151660
+ if (health.checks) {
151661
+ statusInfo.checks = health.checks;
151662
+ }
151663
+ return response.headers.get(SERVER_VERSION_HEADER);
151425
151664
  } catch (err2) {
151426
- return error(`Handshake failed: ${err2 instanceof Error ? err2.message : String(err2)}`);
151665
+ statusInfo.apiStatus = "unreachable";
151666
+ statusInfo.apiError = err2 instanceof Error ? err2.message : "Unknown error";
151667
+ return null;
151427
151668
  }
151428
151669
  }
151429
- async function handleHandshake(options3) {
151430
- const targetUrl = targetBaseUrl();
151431
- const serverName = getTargetServerName();
151432
- const existing = loadHostMetadata();
151433
- if (existing && !options3.rotate) {
151434
- const bindings = boundServerBindings(existing);
151435
- const bound = boundServerUrls(existing);
151436
- const alreadyBound = bindingForServer(existing, targetUrl);
151437
- if (alreadyBound) {
151438
- success(`Already handshook as host ${alreadyBound.hostId} for server '${serverName}' (${targetUrl}). Bound servers: ${bound.join(", ")} (each with its own host id). Requests to any other server entry are sent UNSIGNED (bearer only) \u2014 run \`omni trust handshake --server <name>\` there to bind it. Pass --rotate to issue a new keypair.`);
151439
- return;
151440
- }
151441
- const registered2 = await registerPubkey(existing.pubkey, options3.hostname ?? existing.hostname);
151442
- const meta2 = {
151443
- ...existing,
151444
- pubkey: registered2.pubkey,
151445
- boundServers: [...bindings, { url: targetUrl, hostId: registered2.id }]
151446
- };
151447
- writeHostMetadata(meta2);
151448
- success(`Bound existing host key to server '${serverName}' (${targetUrl}) \u2014 no rotation.`);
151449
- data({
151450
- hostId: registered2.id,
151451
- hostname: meta2.hostname,
151452
- pubkey: meta2.pubkey,
151453
- boundServers: meta2.boundServers,
151454
- rotated: false
151455
- });
151456
- return;
151670
+ async function validateAuthKey(statusInfo, client) {
151671
+ try {
151672
+ const auth = await client.auth.validate();
151673
+ statusInfo.keyValid = auth.valid;
151674
+ statusInfo.keyName = auth.keyName;
151675
+ statusInfo.keyPrefix = auth.keyPrefix;
151676
+ statusInfo.scopes = auth.scopes;
151677
+ Object.assign(statusInfo, credentialStatusFields(auth));
151678
+ Object.assign(statusInfo, serverPostureFields(auth));
151679
+ } catch {
151680
+ statusInfo.keyValid = false;
151457
151681
  }
151458
- const { pubkeyB64Url } = generateAndStoreKeypair();
151459
- const hostname = options3.hostname ?? osHostname();
151460
- const registered = await registerPubkey(pubkeyB64Url, hostname);
151461
- const meta = {
151462
- hostId: registered.id,
151463
- pubkey: registered.pubkey,
151464
- hostname: registered.hostname,
151465
- registeredAt: new Date().toISOString(),
151466
- boundServers: [{ url: targetUrl, hostId: registered.id }]
151682
+ }
151683
+ async function collectStatusInfo() {
151684
+ const config2 = loadConfig();
151685
+ const isAuthenticated = hasAuth();
151686
+ const apiUrl = config2.apiUrl ?? "http://localhost:8882";
151687
+ const statusInfo = {
151688
+ configDir: getConfigDir(),
151689
+ apiUrl,
151690
+ authenticated: isAuthenticated,
151691
+ defaultInstance: config2.defaultInstance ?? "-",
151692
+ format: config2.format ?? "auto (TTY detection)"
151467
151693
  };
151468
- writeHostMetadata(meta);
151469
- success(`Operator-host registered: ${meta.hostId} (server: ${serverName})`);
151470
- data({
151471
- hostId: meta.hostId,
151472
- hostname: meta.hostname,
151473
- pubkey: meta.pubkey,
151474
- boundServers: meta.boundServers,
151475
- keysDir: "~/.omni/keys/",
151476
- nextStep: `Calls to '${serverName}' will now be signed automatically; other server entries stay bearer-only until you run \`omni trust handshake --server <name>\` against them. Run \`omni trust list\` to verify (lastSeenAt should advance after subsequent calls).`
151477
- });
151694
+ const serverVersion = await checkApiHealth(statusInfo, apiUrl);
151695
+ if (isAuthenticated) {
151696
+ const client = getOptionalClient();
151697
+ if (client) {
151698
+ await validateAuthKey(statusInfo, client);
151699
+ }
151700
+ }
151701
+ const processes = await getProcessStatus();
151702
+ if (processes !== null) {
151703
+ statusInfo.processes = processes;
151704
+ }
151705
+ return { statusInfo, serverVersion, processes };
151478
151706
  }
151479
- async function handleRevoke2(id) {
151480
- try {
151481
- const { data: data2 } = await callApi("DELETE", `/hosts/${encodeURIComponent(id)}`);
151482
- success(`Revoked genie host ${data2.id} (${data2.hostname}). Tombstone kept for audit.`);
151483
- data(data2);
151484
- } catch (err2) {
151485
- error(`Failed to revoke genie host: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
151707
+ function renderHumanExtras(serverVersion, processes) {
151708
+ if (serverVersion) {
151709
+ raw(formatStatusVersionHint(VERSION, serverVersion));
151710
+ }
151711
+ if (processes !== null && processes.length > 0) {
151712
+ header("Services");
151713
+ list(processes);
151714
+ } else if (processes !== null) {
151715
+ header("Services");
151716
+ dim("No omni services running. Start with: omni start");
151486
151717
  }
151487
151718
  }
151488
- function createTrustCommand() {
151489
- const trust = new Command("trust").description("Manage genie host fingerprint registrations");
151490
- trust.command("handshake").description("Register THIS omni CLI as a host with the targeted server (mints ed25519 keypair in ~/.omni/keys/ on first run; later servers reuse it).").addHelpText("after", `
151491
- Signing is per-server: only servers you have handshaken against recognize this
151492
- keypair, so calls to other entries stay bearer-only. Bind another one with:
151493
- omni trust handshake --server <name>
151494
- `).option("--rotate", "Issue a new keypair even if one already exists (unbinds every previously bound server)").option("--hostname <name>", "Override the hostname reported to omni (defaults to os.hostname())").action(handleHandshake);
151495
- trust.command("list").description("List active (non-revoked) genie hosts").action(handleList6);
151496
- trust.command("get <id>").description("Show details for one genie host (active or revoked)").action(handleGet4);
151497
- trust.command("update <id>").description("Replace a genie host scopes (comma-separated)").requiredOption("--scope <list>", 'Comma-separated scope list, e.g. "agents:write,providers:write"').action(handleUpdate3);
151498
- trust.command("revoke <id>").description("Revoke a genie host (irreversible \u2014 re-register with a fresh keypair to restore trust)").action(handleRevoke2);
151499
- return trust;
151719
+ function createStatusCommand() {
151720
+ const status = new Command("status").description("Show API health and connection info").action(async () => {
151721
+ const { statusInfo, serverVersion, processes } = await collectStatusInfo();
151722
+ data(statusInfo);
151723
+ if (getCurrentFormat() === "human") {
151724
+ renderHumanExtras(serverVersion, processes);
151725
+ }
151726
+ });
151727
+ return status;
151500
151728
  }
151501
151729
 
151502
- // src/commands/tts.ts
151730
+ // src/commands/stop.ts
151503
151731
  init_output();
151504
- function createTtsCommand() {
151505
- const tts = new Command("tts").description("Text-to-speech operations");
151506
- tts.command("voices").description("List available TTS voices").action(async () => {
151507
- const client = getClient();
151508
- try {
151509
- const voices = await client.messages.listVoices();
151510
- if (voices.length === 0) {
151511
- info("No TTS voices available. Check ElevenLabs configuration.");
151512
- return;
151513
- }
151514
- const items = voices.map((v4) => ({
151515
- voiceId: v4.voiceId,
151516
- name: v4.name,
151517
- category: v4.category ?? "-"
151518
- }));
151519
- list(items, { emptyMessage: "No voices found." });
151520
- } catch (err2) {
151521
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151522
- error(`Failed to list voices: ${message2}`);
151523
- }
151524
- });
151525
- return tts;
151732
+ var LEGACY_PROCESSES = ["omni-v2-api", "omni-v2-nats"];
151733
+ async function runStop() {
151734
+ if (!await isPm2Available()) {
151735
+ pm2NotFoundError();
151736
+ }
151737
+ info("Stopping omni services...");
151738
+ for (const name of Object.values(PM2_PROCESSES)) {
151739
+ await runPm2(["delete", name]);
151740
+ }
151741
+ for (const name of LEGACY_PROCESSES) {
151742
+ await runPm2(["delete", name]);
151743
+ }
151744
+ success("Omni services stopped");
151745
+ }
151746
+ function createStopCommand() {
151747
+ return new Command("stop").description("Stop all omni PM2 processes").action(runStop);
151526
151748
  }
151527
151749
 
151528
- // src/commands/turns.ts
151750
+ // src/commands/tenants.ts
151529
151751
  init_output();
151530
- function formatDuration2(startedAt, closedAt) {
151531
- const start = new Date(startedAt).getTime();
151532
- const end = closedAt ? new Date(closedAt).getTime() : Date.now();
151533
- const ms = end - start;
151534
- if (ms < 1000)
151535
- return `${ms}ms`;
151536
- if (ms < 60000)
151537
- return `${Math.round(ms / 1000)}s`;
151538
- return `${Math.round(ms / 60000)}m`;
151752
+ var TENANT_ROLES = ["tenant-owner", "tenant-admin", "tenant-operator", "tenant-viewer"];
151753
+ var MEMBERSHIP_STATUSES = ["active", "disabled"];
151754
+ var REASON_REQUIRED_HINT = "Every platform control-plane call is audited; " + 'provide a justification of 3-500 characters, e.g. --reason "onboarding request OPS-1421".';
151755
+ var CONTROL_PLANE_UNAVAILABLE = "the platform control plane is not available on this server. " + "Either multitenancy is disabled (start the API with OMNI_MULTITENANCY_ENABLED=true) " + "or the server predates the tenant control plane \u2014 upgrade it.";
151756
+ var CREDENTIAL_REJECTED_401 = "the server rejected the credential (401). " + "The platform control plane accepts only PLATFORM-class credentials \u2014 legacy admin/god keys and " + "tenant data-plane keys are denied by design. Authenticate with a platform key: " + "omni auth login --api-key <platform-key>.";
151757
+ var CREDENTIAL_FORBIDDEN_403 = "the credential authenticated but is not allowed (403). " + "A PLATFORM-class credential with the matching platform:* scope is required \u2014 tenant-class " + "credentials can never reach the control plane, and platform keys act only within their scopes.";
151758
+ function requireReason(reason, action) {
151759
+ const trimmed = reason?.trim() ?? "";
151760
+ if (trimmed.length < 3 || trimmed.length > 500) {
151761
+ error(`--reason is required to ${action}. ${REASON_REQUIRED_HINT}`, undefined, 1);
151762
+ }
151763
+ return trimmed;
151539
151764
  }
151540
- function createTurnsCommand() {
151541
- const cmd = new Command("turns").description("Admin turn management");
151542
- cmd.command("list").description("List turns with optional filters").option("--status <status>", "Filter by status (open, done, timeout)").option("--instance <id>", "Filter by instance ID").option("--chat <id>", "Filter by chat ID").option("--agent <id>", "Filter by agent ID").option("--limit <n>", "Max results (default 50)", "50").option("--offset <n>", "Offset for pagination (default 0)", "0").action(async (opts) => {
151543
- const client = getClient();
151544
- const result = await client.turns.list({
151545
- status: opts.status,
151546
- instanceId: opts.instance,
151547
- chatId: opts.chat,
151548
- agentId: opts.agent,
151549
- limit: Number(opts.limit),
151550
- offset: Number(opts.offset)
151551
- });
151552
- const rows = result.items.map((t) => ({
151553
- id: t.id.slice(0, 8),
151554
- status: t.status,
151555
- chat: t.chatId.length > 20 ? `${t.chatId.slice(0, 20)}\u2026` : t.chatId,
151556
- agent: t.agentId.slice(0, 8),
151557
- duration: formatDuration2(t.startedAt, t.closedAt),
151558
- nudges: t.nudgeCount,
151559
- msgs: t.messagesSent
151560
- }));
151561
- list(rows, {
151562
- emptyMessage: "No turns found.",
151563
- rawData: result.items
151564
- });
151565
- if (getCurrentFormat() !== "json") {
151566
- dim(`Total: ${result.total} | Showing ${result.offset + 1}\u2013${result.offset + result.items.length}`);
151567
- }
151568
- });
151569
- cmd.command("get").description("Get a single turn by ID").argument("<id>", "Turn ID").action(async (id) => {
151570
- const client = getClient();
151571
- const turn = await client.turns.get(id);
151572
- data(turn);
151573
- });
151574
- cmd.command("close").description("Admin force-close a turn").argument("<id>", "Turn ID to close").option("--reason <text>", "Close reason").action(async (id, opts) => {
151575
- const client = getClient();
151576
- const result = await client.turns.forceClose(id, opts.reason);
151577
- success(`Turn ${result.turnId} closed`, result);
151578
- });
151579
- cmd.command("close-all").description("Bulk close all open turns (requires --confirm)").option("--confirm", "Confirm bulk close").option("--reason <text>", "Close reason").action(async (opts) => {
151580
- if (!opts.confirm) {
151581
- error("Bulk close requires --confirm flag to proceed.");
151582
- }
151583
- const client = getClient();
151584
- const result = await client.turns.bulkClose(opts.reason);
151585
- success(result.message, result);
151586
- });
151587
- cmd.command("stats").description("Show aggregate turn metrics").action(async () => {
151588
- const client = getClient();
151589
- const stats = await client.turns.stats();
151590
- data({
151591
- openCount: stats.openCount,
151592
- totalCount: stats.totalCount,
151593
- avgDurationMs: stats.avgDurationMs,
151594
- timeoutRate: `${(stats.timeoutRate * 100).toFixed(1)}%`
151595
- });
151596
- });
151597
- return cmd;
151598
- }
151599
-
151600
- // src/commands/update.ts
151601
- import { existsSync as existsSync20 } from "fs";
151602
- import { join as join19 } from "path";
151603
- import { createInterface as createInterface3 } from "readline";
151604
- init_source();
151605
- init_config();
151606
-
151607
- // src/sidecar-cleanup.ts
151608
- var SIDECAR_BASENAME = "nats-reply-sidecar.mjs";
151609
- var SIDECAR_NAME_HINT = "nats-reply-sidecar";
151610
- function pm2EntryToMatch(entry) {
151611
- if (!entry || typeof entry !== "object")
151612
- return null;
151613
- const name = typeof entry.name === "string" ? entry.name : "";
151614
- const scriptPath = typeof entry.pm2_env?.pm_exec_path === "string" ? entry.pm2_env.pm_exec_path : "";
151615
- const pmId = typeof entry.pm_id === "number" ? entry.pm_id : null;
151616
- const nameMatch = name.includes(SIDECAR_NAME_HINT);
151617
- const pathMatch = scriptPath.endsWith(`/${SIDECAR_BASENAME}`) || scriptPath.endsWith(SIDECAR_BASENAME);
151618
- if (!nameMatch && !pathMatch)
151619
- return null;
151620
- return {
151621
- name,
151622
- pmId,
151623
- scriptPath,
151624
- matchedBy: nameMatch ? "name" : "script-path"
151625
- };
151626
- }
151627
- function parsePm2SidecarMatches(rawJson) {
151628
- const trimmed = rawJson.trim();
151629
- if (trimmed.length === 0 || trimmed === "[]")
151630
- return [];
151631
- let parsed;
151632
- try {
151633
- parsed = JSON.parse(trimmed);
151634
- } catch {
151635
- return [];
151636
- }
151637
- if (!Array.isArray(parsed))
151638
- return [];
151639
- const matches = [];
151640
- for (const entry of parsed) {
151641
- const match = pm2EntryToMatch(entry);
151642
- if (match !== null)
151643
- matches.push(match);
151765
+ function parseRole(value) {
151766
+ if (!TENANT_ROLES.includes(value)) {
151767
+ error(`Invalid --role "${value}". Valid roles: ${TENANT_ROLES.join(", ")}`, undefined, 1);
151644
151768
  }
151645
- return matches;
151769
+ return value;
151646
151770
  }
151647
- function parseRawSidecarMatches(rawPgrep) {
151648
- const lines = rawPgrep.split(`
151649
- `);
151650
- const matches = [];
151651
- for (const line3 of lines) {
151652
- const trimmed = line3.trim();
151653
- if (trimmed.length === 0)
151654
- continue;
151655
- const firstSpace = trimmed.indexOf(" ");
151656
- if (firstSpace === -1)
151657
- continue;
151658
- const pidStr = trimmed.slice(0, firstSpace);
151659
- const command = trimmed.slice(firstSpace + 1);
151660
- const pid = Number.parseInt(pidStr, 10);
151661
- if (!Number.isFinite(pid) || pid <= 0)
151662
- continue;
151663
- if (!command.includes(SIDECAR_BASENAME))
151664
- continue;
151665
- matches.push({ pid, command });
151771
+ function parseStatus(value) {
151772
+ if (!MEMBERSHIP_STATUSES.includes(value)) {
151773
+ error(`Invalid --status "${value}". Valid statuses: ${MEMBERSHIP_STATUSES.join(", ")}`, undefined, 1);
151666
151774
  }
151667
- return matches;
151775
+ return value;
151668
151776
  }
151669
- async function detectPm2Sidecars() {
151670
- try {
151671
- const proc = Bun.spawn({
151672
- cmd: ["pm2", "jlist"],
151673
- stdout: "pipe",
151674
- stderr: "pipe"
151675
- });
151676
- const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
151677
- if (exitCode !== 0)
151678
- return [];
151679
- return parsePm2SidecarMatches(stdout);
151680
- } catch {
151681
- return [];
151777
+ function parseScopes(value) {
151778
+ const scopes = value.split(",").map((s2) => s2.trim()).filter((s2) => s2.length > 0);
151779
+ if (scopes.length === 0) {
151780
+ error('--scopes must list at least one explicit tenant scope (comma-separated), e.g. --scopes "messages:send,chats:read".', undefined, 1);
151682
151781
  }
151782
+ return scopes;
151683
151783
  }
151684
- async function detectRawSidecars() {
151784
+ function parseConstraints(value) {
151785
+ const invalid = () => error(`Invalid --constraints "${value}". Expected a JSON object of string arrays, e.g. '{"instanceAllowlist":["<instance-id>"]}'.`, undefined, 1);
151786
+ let parsed;
151685
151787
  try {
151686
- const proc = Bun.spawn({
151687
- cmd: ["pgrep", "-fa", SIDECAR_BASENAME],
151688
- stdout: "pipe",
151689
- stderr: "pipe"
151690
- });
151691
- const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
151692
- if (exitCode !== 0 && exitCode !== 1)
151693
- return [];
151694
- const matches = parseRawSidecarMatches(stdout);
151695
- const ownPid = process.pid;
151696
- return matches.filter((m10) => m10.pid !== ownPid);
151788
+ parsed = JSON.parse(value);
151697
151789
  } catch {
151698
- return [];
151790
+ invalid();
151699
151791
  }
151700
- }
151701
- async function stopPm2Sidecar(match) {
151702
- const target = match.name.length > 0 ? match.name : match.pmId !== null ? String(match.pmId) : null;
151703
- if (target === null)
151704
- return false;
151705
- for (const verb of ["stop", "delete"]) {
151706
- const proc = Bun.spawn({
151707
- cmd: ["pm2", verb, target],
151708
- stdout: "pipe",
151709
- stderr: "pipe"
151710
- });
151711
- const exitCode = await proc.exited;
151712
- if (exitCode !== 0)
151713
- return false;
151792
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
151793
+ invalid();
151714
151794
  }
151715
- return true;
151716
- }
151717
- async function killRawSidecar(match) {
151718
- try {
151719
- process.kill(match.pid, "SIGTERM");
151720
- return true;
151721
- } catch {
151722
- return false;
151795
+ const record2 = parsed;
151796
+ for (const entry of Object.values(record2)) {
151797
+ if (!Array.isArray(entry) || entry.some((item) => typeof item !== "string")) {
151798
+ invalid();
151799
+ }
151723
151800
  }
151801
+ return record2;
151724
151802
  }
151725
- async function cleanupSidecars() {
151726
- const [pm2Detected, rawDetected] = await Promise.all([detectPm2Sidecars(), detectRawSidecars()]);
151727
- const pm2Stopped = [];
151728
- const pm2Failed = [];
151729
- for (const match of pm2Detected) {
151730
- const ok3 = await stopPm2Sidecar(match);
151731
- if (ok3) {
151732
- pm2Stopped.push(match);
151733
- } else {
151734
- pm2Failed.push(match);
151803
+ function renderPlatformError(err2, action) {
151804
+ if (err2 instanceof OmniApiError) {
151805
+ if (err2.status === 404 && err2.message.startsWith("Endpoint not found")) {
151806
+ error(`Failed to ${action}: ${CONTROL_PLANE_UNAVAILABLE}`, undefined, 1);
151807
+ }
151808
+ if (err2.status === 401) {
151809
+ error(`Failed to ${action}: ${CREDENTIAL_REJECTED_401}`, undefined, 1);
151810
+ }
151811
+ if (err2.status === 403) {
151812
+ error(`Failed to ${action}: ${CREDENTIAL_FORBIDDEN_403}`, undefined, 1);
151735
151813
  }
151736
151814
  }
151737
- const rawKilled = [];
151738
- const rawFailed = [];
151739
- for (const match of rawDetected) {
151740
- const ok3 = await killRawSidecar(match);
151741
- if (ok3) {
151742
- rawKilled.push(match);
151743
- } else {
151744
- rawFailed.push(match);
151815
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
151816
+ error(`Failed to ${action}: ${message2}`);
151817
+ }
151818
+ var TENANT_NOT_ACTIVE_409 = "the tenant is not active (409). Root keys can be issued only while the tenant is active \u2014 " + "a suspended or archived tenant refuses new credentials.";
151819
+ var ROOT_KEY_EXCEEDS_POLICY_403 = "the request exceeds the tenant policy ceilings (403). Keep --expires within the tenant " + "max-key-ttl and --rate-limit/--budget at or below the tenant ceilings (see: omni tenants get <id>).";
151820
+ function renderRootKeyError(err2, action) {
151821
+ if (err2 instanceof OmniApiError) {
151822
+ if (err2.status === 409) {
151823
+ error(`Failed to ${action}: ${TENANT_NOT_ACTIVE_409}`, undefined, 1);
151824
+ }
151825
+ if (err2.status === 400) {
151826
+ error(`Failed to ${action}: ${err2.message}`, undefined, 1);
151827
+ }
151828
+ if (err2.status === 403 && err2.message.includes("exceeds tenant policy")) {
151829
+ error(`Failed to ${action}: ${ROOT_KEY_EXCEEDS_POLICY_403}`, undefined, 1);
151745
151830
  }
151746
151831
  }
151832
+ renderPlatformError(err2, action);
151833
+ }
151834
+ function formatTenantRow(tenant) {
151747
151835
  return {
151748
- pm2Detected,
151749
- rawDetected,
151750
- pm2Stopped,
151751
- pm2Failed,
151752
- rawKilled,
151753
- rawFailed
151836
+ id: tenant.id,
151837
+ slug: tenant.slug,
151838
+ name: tenant.displayName,
151839
+ status: tenant.status,
151840
+ keyTtl: tenant.maxKeyTtlSeconds,
151841
+ keyRate: tenant.maxKeyRateLimit,
151842
+ keyBudget: tenant.maxKeyBudget,
151843
+ created: new Date(tenant.createdAt).toLocaleDateString()
151754
151844
  };
151755
151845
  }
151756
- function formatCleanupSummary(result) {
151757
- const totalDetected = result.pm2Detected.length + result.rawDetected.length;
151758
- if (totalDetected === 0)
151759
- return "";
151760
- const lines = [];
151761
- lines.push(`Found ${totalDetected} legacy nats-reply-sidecar process(es) \u2014 stopping to prevent duplicate replies.`);
151762
- for (const match of result.pm2Stopped) {
151763
- const label = match.name || `pm_id=${match.pmId}`;
151764
- lines.push(` \u2713 pm2: stopped and deleted "${label}" (matched by ${match.matchedBy})`);
151765
- }
151766
- for (const match of result.pm2Failed) {
151767
- const label = match.name || `pm_id=${match.pmId}`;
151768
- lines.push(` \u2717 pm2: failed to stop "${label}" \u2014 run \`pm2 stop ${label} && pm2 delete ${label}\` manually`);
151769
- }
151770
- for (const match of result.rawKilled) {
151771
- lines.push(` \u2713 raw: SIGTERM sent to pid ${match.pid}`);
151772
- }
151773
- for (const match of result.rawFailed) {
151774
- lines.push(` \u2717 raw: failed to signal pid ${match.pid} \u2014 kill it manually with \`kill ${match.pid}\``);
151775
- }
151776
- if (result.pm2Failed.length > 0 || result.rawFailed.length > 0) {
151777
- lines.push("See docs/migration/nats-genie-sidecar-decommission.md for the manual cleanup runbook.");
151778
- }
151779
- return lines.join(`
151780
- `);
151781
- }
151782
-
151783
- // src/legacy-cleanup.ts
151784
- function createNatsReplySidecarArtifact() {
151785
- let lastResult = null;
151846
+ function formatMembershipRow(membership) {
151786
151847
  return {
151787
- name: "nats-reply-sidecar",
151788
- async detect() {
151789
- return true;
151790
- },
151791
- async cleanup() {
151792
- const result = await cleanupSidecars();
151793
- lastResult = result;
151794
- const removed = [];
151795
- const warnings = [];
151796
- for (const m10 of result.pm2Stopped) {
151797
- removed.push(`pm2:${m10.name.length > 0 ? m10.name : `pm_id=${m10.pmId}`}`);
151798
- }
151799
- for (const m10 of result.rawKilled) {
151800
- removed.push(`pid:${m10.pid}`);
151801
- }
151802
- for (const m10 of result.pm2Failed) {
151803
- warnings.push(`pm2-failed:${m10.name.length > 0 ? m10.name : `pm_id=${m10.pmId}`}`);
151804
- }
151805
- for (const m10 of result.rawFailed) {
151806
- warnings.push(`raw-failed:pid=${m10.pid}`);
151807
- }
151808
- return { removed, warnings };
151809
- },
151810
- summary() {
151811
- return lastResult === null ? "" : formatCleanupSummary(lastResult);
151812
- }
151848
+ id: membership.id,
151849
+ principal: membership.principalId,
151850
+ role: membership.role,
151851
+ status: membership.status,
151852
+ invitedBy: membership.invitedByPrincipalId ?? "-",
151853
+ created: new Date(membership.createdAt).toLocaleDateString()
151813
151854
  };
151814
151855
  }
151815
- var REGISTRY = [createNatsReplySidecarArtifact()];
151816
- function errorMessage2(error2) {
151817
- return error2 instanceof Error ? error2.message : String(error2);
151856
+ async function handleList5(client, options3) {
151857
+ const reason = requireReason(options3.reason, "list tenants");
151858
+ const result = await client.platform.tenants.list(reason);
151859
+ list(result.items.map(formatTenantRow), {
151860
+ emptyMessage: "No tenants found.",
151861
+ rawData: result.items
151862
+ });
151818
151863
  }
151819
- function erroredOutcome(name, phase, error2) {
151820
- const message2 = errorMessage2(error2);
151821
- return {
151822
- name,
151823
- state: "errored",
151824
- removed: [],
151825
- warnings: [`${phase}-threw:${message2}`],
151826
- summary: "",
151827
- error: message2
151828
- };
151864
+ async function handleGet3(client, id, options3) {
151865
+ const reason = requireReason(options3.reason, "read a tenant");
151866
+ const tenant = await client.platform.tenants.get(id, reason);
151867
+ data(tenant);
151829
151868
  }
151830
- async function runArtifact(artifact) {
151831
- let detected;
151832
- try {
151833
- detected = await artifact.detect();
151834
- } catch (error2) {
151835
- return erroredOutcome(artifact.name, "detect", error2);
151836
- }
151837
- if (!detected) {
151838
- return {
151839
- name: artifact.name,
151840
- state: "not-detected",
151841
- removed: [],
151842
- warnings: [],
151843
- summary: ""
151844
- };
151845
- }
151846
- try {
151847
- const { removed, warnings } = await artifact.cleanup();
151848
- return {
151849
- name: artifact.name,
151850
- state: "ran",
151851
- removed,
151852
- warnings,
151853
- summary: artifact.summary()
151854
- };
151855
- } catch (error2) {
151856
- return erroredOutcome(artifact.name, "cleanup", error2);
151857
- }
151869
+ async function handleCreate4(client, options3) {
151870
+ const reason = requireReason(options3.reason, "create a tenant");
151871
+ const tenant = await client.platform.tenants.create({
151872
+ slug: options3.slug,
151873
+ displayName: options3.name,
151874
+ maxKeyTtlSeconds: options3.maxKeyTtl,
151875
+ maxKeyRateLimit: options3.maxKeyRate,
151876
+ maxKeyBudget: options3.maxKeyBudget,
151877
+ reason
151878
+ });
151879
+ success(`Tenant created: ${tenant.slug} (${tenant.id})`);
151880
+ data(tenant);
151858
151881
  }
151859
- async function cleanupLegacyArtifacts(skipList) {
151860
- const outcomes = [];
151861
- const skipped = [];
151862
- for (const artifact of REGISTRY) {
151863
- if (skipList.has(artifact.name)) {
151864
- skipped.push(artifact.name);
151865
- outcomes.push({
151866
- name: artifact.name,
151867
- state: "skipped",
151868
- removed: [],
151869
- warnings: [],
151870
- summary: ""
151871
- });
151872
- continue;
151873
- }
151874
- outcomes.push(await runArtifact(artifact));
151875
- }
151876
- const succeeded = outcomes.every((o2) => o2.state !== "errored" && (o2.state !== "ran" || o2.warnings.length === 0));
151877
- return { outcomes, succeeded, skipped };
151882
+ async function handleSuspend(client, id, options3) {
151883
+ const reason = requireReason(options3.reason, "suspend a tenant");
151884
+ const tenant = await client.platform.tenants.suspend(id, reason);
151885
+ success(`Tenant suspended: ${tenant.slug} (${tenant.id})`);
151886
+ info(`Reason: ${reason}`);
151878
151887
  }
151879
-
151880
- // src/commands/update.ts
151881
- init_output();
151882
-
151883
- // src/update-diagnostics.ts
151884
- import { existsSync as existsSync19, mkdirSync as mkdirSync12, readFileSync as readFileSync18, writeFileSync as writeFileSync11 } from "fs";
151885
- import { homedir as homedir13 } from "os";
151886
- import { join as join18 } from "path";
151887
- var UPDATE_DIAGNOSTICS_SCHEMA_VERSION = 1;
151888
- function createDiagnostics(args) {
151889
- const startedAt = new Date().toISOString();
151890
- return {
151891
- schemaVersion: UPDATE_DIAGNOSTICS_SCHEMA_VERSION,
151892
- startedAt,
151893
- finishedAt: startedAt,
151894
- cli: { runningVersion: args.runningVersion, channel: args.channel },
151895
- registry: { latestVersion: null },
151896
- preflight: { ran: false, blocked: false },
151897
- install: { attempted: false, succeeded: null, targetVersion: null },
151898
- restart: { attempted: false, succeeded: null, services: [] },
151899
- verify: null,
151900
- cleanups: null,
151901
- maintenance: null,
151902
- parallelNpmGlobal: null,
151903
- recentLogSignals: [],
151904
- exitCode: 0
151905
- };
151888
+ async function handleArchive(client, id, options3) {
151889
+ const reason = requireReason(options3.reason, "archive a tenant");
151890
+ const tenant = await client.platform.tenants.archive(id, reason);
151891
+ success(`Tenant archived: ${tenant.slug} (${tenant.id})`);
151892
+ warn("Archived is terminal \u2014 there is no un-archive and no hard delete.");
151893
+ info(`Reason: ${reason}`);
151906
151894
  }
151907
- function getDiagnosticsDir() {
151908
- const base = process.env.OMNI_CONFIG_DIR ?? join18(homedir13(), ".omni");
151909
- return join18(base, "logs");
151895
+ async function handleMembershipsList(client, tenantId, options3) {
151896
+ const reason = requireReason(options3.reason, "list memberships");
151897
+ const result = await client.platform.tenants.memberships.list(tenantId, reason);
151898
+ list(result.items.map(formatMembershipRow), {
151899
+ emptyMessage: "No memberships found.",
151900
+ rawData: result.items
151901
+ });
151910
151902
  }
151911
- function getDiagnosticsPath(startedAt) {
151912
- const safe = startedAt.replace(/:/g, "-");
151913
- return join18(getDiagnosticsDir(), `update-diagnostics-${safe}.json`);
151903
+ async function handleMembershipsAdd(client, tenantId, options3) {
151904
+ const reason = requireReason(options3.reason, "attach a membership");
151905
+ const role = parseRole(options3.role);
151906
+ const membership = await client.platform.tenants.memberships.attach(tenantId, {
151907
+ principalId: options3.principal,
151908
+ role,
151909
+ reason
151910
+ });
151911
+ success(`Membership attached: ${membership.principalId} as ${membership.role} (${membership.id})`);
151914
151912
  }
151915
- function tailFileLines(path, maxLines) {
151916
- if (!existsSync19(path))
151917
- return [];
151918
- try {
151919
- const raw2 = readFileSync18(path, "utf8");
151920
- const lines = raw2.split(/\r?\n/);
151921
- if (lines.length > 0 && lines[lines.length - 1] === "")
151922
- lines.pop();
151923
- return lines.slice(-maxLines);
151924
- } catch {
151925
- return [];
151913
+ async function handleMembershipsDisable(client, tenantId, membershipId, options3) {
151914
+ const reason = requireReason(options3.reason, "disable a membership");
151915
+ const membership = await client.platform.tenants.memberships.disable(tenantId, membershipId, reason);
151916
+ success(`Membership disabled: ${membership.id}`);
151917
+ info(`Reason: ${reason}`);
151918
+ }
151919
+ async function handleMembershipsStatus(client, tenantId, membershipId, options3) {
151920
+ const reason = requireReason(options3.reason, "set a membership status");
151921
+ const status = parseStatus(options3.status);
151922
+ const membership = await client.platform.tenants.memberships.setStatus(tenantId, membershipId, status, reason);
151923
+ success(`Membership ${membership.id} status set to ${membership.status}`);
151924
+ }
151925
+ async function handleMembershipsRole(client, tenantId, membershipId, options3) {
151926
+ const reason = requireReason(options3.reason, "set a membership role");
151927
+ const role = parseRole(options3.role);
151928
+ const membership = await client.platform.tenants.memberships.setRole(tenantId, membershipId, role, reason);
151929
+ success(`Membership ${membership.id} role set to ${membership.role}`);
151930
+ }
151931
+ async function handleKeysIssueRoot(client, tenantId, options3) {
151932
+ const reason = requireReason(options3.reason, "issue a root key");
151933
+ const role = parseRole(options3.role);
151934
+ const scopes = parseScopes(options3.scopes);
151935
+ const constraints = options3.constraints === undefined ? undefined : parseConstraints(options3.constraints);
151936
+ const body = {
151937
+ principalId: options3.principal,
151938
+ membershipId: options3.membership,
151939
+ role,
151940
+ name: options3.name,
151941
+ scopes,
151942
+ expiresAt: options3.expires,
151943
+ rateLimit: options3.rateLimit,
151944
+ budget: options3.budget,
151945
+ ...constraints ? { resourceConstraints: constraints } : {},
151946
+ reason
151947
+ };
151948
+ const result = await client.platform.tenants.keys.issueRoot(tenantId, body);
151949
+ if (getCurrentFormat() === "json") {
151950
+ warn("plainTextKey is shown once and can never be retrieved again \u2014 store it now.");
151951
+ data(result);
151952
+ return;
151926
151953
  }
151954
+ const { plainTextKey, ...issued } = result;
151955
+ success(`Root key issued: ${issued.name} (${issued.id})`);
151956
+ warn("The key below is shown ONCE and can never be retrieved again \u2014 store it now.");
151957
+ raw(`
151958
+ ${plainTextKey}
151959
+ `);
151960
+ data(issued);
151927
151961
  }
151928
- function collectRecentLogSignals(maxLinesPerStream = 30, logPathsFor = getPm2LogPaths) {
151929
- const signals2 = [];
151930
- for (const name of Object.values(PM2_PROCESSES)) {
151931
- const { out, error: error2 } = logPathsFor(name);
151932
- const outLines = tailFileLines(out, maxLinesPerStream);
151933
- if (outLines.length > 0) {
151934
- signals2.push({ source: name, stream: "out", lines: outLines });
151962
+ var REASON_HELP = "Audited justification (required, 3-500 chars)";
151963
+ function createTenantsCommand() {
151964
+ const tenants2 = new Command("tenants").description("Platform tenant control plane (requires a PLATFORM-class credential and OMNI_MULTITENANCY_ENABLED=true)");
151965
+ tenants2.command("list").description("List tenants, newest first (the read itself is audited)").option("--reason <text>", REASON_HELP).action(async (options3) => {
151966
+ const client = getClient();
151967
+ try {
151968
+ await handleList5(client, options3);
151969
+ } catch (err2) {
151970
+ renderPlatformError(err2, "list tenants");
151935
151971
  }
151936
- const errLines = tailFileLines(error2, maxLinesPerStream);
151937
- if (errLines.length > 0) {
151938
- signals2.push({ source: name, stream: "error", lines: errLines });
151972
+ });
151973
+ tenants2.command("get <id>").description("Get one tenant by id").option("--reason <text>", REASON_HELP).action(async (id, options3) => {
151974
+ const client = getClient();
151975
+ try {
151976
+ await handleGet3(client, id, options3);
151977
+ } catch (err2) {
151978
+ renderPlatformError(err2, "get the tenant");
151939
151979
  }
151940
- }
151941
- return signals2;
151942
- }
151943
- function writeDiagnostics(state, exitCode) {
151944
- state.exitCode = exitCode;
151945
- state.finishedAt = new Date().toISOString();
151946
- if (state.recentLogSignals.length === 0) {
151947
- state.recentLogSignals = collectRecentLogSignals();
151948
- }
151949
- const dir = getDiagnosticsDir();
151950
- const path = getDiagnosticsPath(state.startedAt);
151951
- try {
151952
- if (!existsSync19(dir)) {
151953
- mkdirSync12(dir, { recursive: true, mode: 448 });
151980
+ });
151981
+ tenants2.command("create").description("Create a tenant with its mandatory credential ceilings").requiredOption("--slug <slug>", "Immutable lowercase DNS-like slug (e.g. acme)").requiredOption("--name <name>", "Human-readable tenant name").requiredOption("--max-key-ttl <seconds>", "Ceiling for credential TTL, in seconds", Number.parseInt).requiredOption("--max-key-rate <n>", "Ceiling for credential rate limit", Number.parseInt).requiredOption("--max-key-budget <n>", "Ceiling for credential budget", Number.parseInt).option("--reason <text>", REASON_HELP).action(async (options3) => {
151982
+ const client = getClient();
151983
+ try {
151984
+ await handleCreate4(client, options3);
151985
+ } catch (err2) {
151986
+ renderPlatformError(err2, "create the tenant");
151954
151987
  }
151955
- writeFileSync11(path, `${JSON.stringify(state, null, 2)}
151956
- `, { mode: 384 });
151957
- return path;
151958
- } catch {
151959
- return null;
151960
- }
151988
+ });
151989
+ tenants2.command("suspend <id>").description("Suspend a tenant (bumps its revocation epoch, invalidating tenant credentials)").option("--reason <text>", REASON_HELP).action(async (id, options3) => {
151990
+ const client = getClient();
151991
+ try {
151992
+ await handleSuspend(client, id, options3);
151993
+ } catch (err2) {
151994
+ renderPlatformError(err2, "suspend the tenant");
151995
+ }
151996
+ });
151997
+ tenants2.command("archive <id>").description("Archive a tenant (TERMINAL \u2014 no un-archive, no hard delete)").option("--reason <text>", REASON_HELP).action(async (id, options3) => {
151998
+ const client = getClient();
151999
+ try {
152000
+ await handleArchive(client, id, options3);
152001
+ } catch (err2) {
152002
+ renderPlatformError(err2, "archive the tenant");
152003
+ }
152004
+ });
152005
+ const memberships = new Command("memberships").description("Manage tenant memberships");
152006
+ memberships.command("list <tenant-id>").description("List the memberships of one tenant (the read itself is audited)").option("--reason <text>", REASON_HELP).action(async (tenantId, options3) => {
152007
+ const client = getClient();
152008
+ try {
152009
+ await handleMembershipsList(client, tenantId, options3);
152010
+ } catch (err2) {
152011
+ renderPlatformError(err2, "list memberships");
152012
+ }
152013
+ });
152014
+ memberships.command("add <tenant-id>").description("Grant a principal a role in the tenant").requiredOption("--principal <uuid>", "Principal to grant access to").requiredOption("--role <role>", `Tenant role: ${TENANT_ROLES.join(" | ")}`).option("--reason <text>", REASON_HELP).action(async (tenantId, options3) => {
152015
+ const client = getClient();
152016
+ try {
152017
+ await handleMembershipsAdd(client, tenantId, options3);
152018
+ } catch (err2) {
152019
+ renderPlatformError(err2, "attach the membership");
152020
+ }
152021
+ });
152022
+ memberships.command("disable <tenant-id> <membership-id>").description("Disable a membership (memberships are never hard-deleted)").option("--reason <text>", REASON_HELP).action(async (tenantId, membershipId, options3) => {
152023
+ const client = getClient();
152024
+ try {
152025
+ await handleMembershipsDisable(client, tenantId, membershipId, options3);
152026
+ } catch (err2) {
152027
+ renderPlatformError(err2, "disable the membership");
152028
+ }
152029
+ });
152030
+ memberships.command("status <tenant-id> <membership-id>").description("Activate or disable an existing membership").requiredOption("--status <status>", `New status: ${MEMBERSHIP_STATUSES.join(" | ")}`).option("--reason <text>", REASON_HELP).action(async (tenantId, membershipId, options3) => {
152031
+ const client = getClient();
152032
+ try {
152033
+ await handleMembershipsStatus(client, tenantId, membershipId, options3);
152034
+ } catch (err2) {
152035
+ renderPlatformError(err2, "set the membership status");
152036
+ }
152037
+ });
152038
+ memberships.command("role <tenant-id> <membership-id>").description("Change the role an existing membership acts under").requiredOption("--role <role>", `New role: ${TENANT_ROLES.join(" | ")}`).option("--reason <text>", REASON_HELP).action(async (tenantId, membershipId, options3) => {
152039
+ const client = getClient();
152040
+ try {
152041
+ await handleMembershipsRole(client, tenantId, membershipId, options3);
152042
+ } catch (err2) {
152043
+ renderPlatformError(err2, "set the membership role");
152044
+ }
152045
+ });
152046
+ tenants2.addCommand(memberships);
152047
+ const keys = new Command("keys").description("Manage tenant credentials");
152048
+ keys.command("issue-root <tenant-id>").description("Issue the initial tenant ROOT key (the plaintext is shown ONCE and never again)").requiredOption("--principal <uuid>", "Principal the key acts as (must hold the membership)").requiredOption("--membership <uuid>", "Active membership binding the principal to the tenant").requiredOption("--role <role>", `Tenant role: ${TENANT_ROLES.join(" | ")}`).requiredOption("--name <name>", "Human-readable key name").requiredOption("--scopes <list>", "Comma-separated explicit tenant scopes (platform/wildcard scopes are refused)").requiredOption("--expires <iso>", "Expiry (ISO 8601); must be in the future and within the tenant TTL ceiling").requiredOption("--rate-limit <n>", "Rate limit; capped by the tenant policy ceiling", Number.parseInt).requiredOption("--budget <n>", "Budget; capped by the tenant policy ceiling", Number.parseInt).option("--constraints <json>", `Optional resource allowlists as JSON, e.g. '{"instanceAllowlist":["<id>"]}'`).option("--reason <text>", REASON_HELP).action(async (tenantId, options3) => {
152049
+ const client = getClient();
152050
+ try {
152051
+ await handleKeysIssueRoot(client, tenantId, options3);
152052
+ } catch (err2) {
152053
+ renderRootKeyError(err2, "issue the root key");
152054
+ }
152055
+ });
152056
+ tenants2.addCommand(keys);
152057
+ return tenants2;
151961
152058
  }
151962
152059
 
151963
- // src/commands/update.ts
151964
- var PACKAGE_NAME = "@automagik/omni";
151965
- var UPDATE_HEALTH_TIMEOUT_MS = 1e4;
151966
- var VERIFY_POLL_INTERVAL_MS = 500;
151967
- function resolveChannel(options3, config2) {
151968
- if (options3.next)
151969
- return "next";
151970
- if (options3.stable)
151971
- return "latest";
151972
- const saved = (config2 ?? loadConfig()).updateChannel;
151973
- if (saved === "latest" || saved === "next")
151974
- return saved;
151975
- return "latest";
152060
+ // src/commands/trust.ts
152061
+ import { hostname as osHostname } from "os";
152062
+ init_config();
152063
+ init_output();
152064
+ function targetBaseUrl() {
152065
+ return normalizeServerUrl(loadConfig().apiUrl ?? "http://localhost:8882");
151976
152066
  }
151977
- function persistChannel(channel2) {
151978
- try {
151979
- const config2 = loadConfig();
151980
- config2.updateChannel = channel2;
151981
- saveConfig(config2);
151982
- } catch {}
152067
+ function trustEndpoint(path) {
152068
+ if (!hasAuth()) {
152069
+ exitNotAuthenticated();
152070
+ }
152071
+ return `${targetBaseUrl()}/api/v2/trust${path}`;
151983
152072
  }
151984
- async function fetchLatestVersion(channel2) {
151985
- try {
151986
- const proc = Bun.spawn({
151987
- cmd: ["bunx", "npm", "view", `${PACKAGE_NAME}@${channel2}`, "version"],
151988
- stdout: "pipe",
151989
- stderr: "pipe"
151990
- });
151991
- const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
151992
- if (exitCode !== 0) {
151993
- return null;
151994
- }
151995
- const text3 = stdout.trim();
151996
- return text3.length > 0 ? text3 : null;
151997
- } catch {
151998
- return null;
152073
+ function authHeaders() {
152074
+ const config2 = loadConfig();
152075
+ const headers = { "Content-Type": "application/json" };
152076
+ if (config2.apiKey) {
152077
+ headers.Authorization = `Bearer ${config2.apiKey}`;
151999
152078
  }
152079
+ return headers;
152000
152080
  }
152001
- function getRunningPm2Services() {
152002
- try {
152003
- const result = Bun.spawnSync({
152004
- cmd: ["pm2", "jlist"],
152005
- stdout: "pipe",
152006
- stderr: "pipe"
152007
- });
152008
- if (result.exitCode !== 0) {
152009
- return [];
152010
- }
152011
- const raw2 = new TextDecoder().decode(result.stdout).trim();
152012
- if (!raw2 || raw2 === "[]")
152013
- return [];
152014
- const pm2Names = new Set(Object.values(PM2_PROCESSES));
152015
- const list2 = JSON.parse(raw2);
152016
- const running = new Set;
152017
- for (const proc of list2) {
152018
- const name = proc.name;
152019
- if (name && pm2Names.has(name) && proc.pm2_env?.status === "online") {
152020
- running.add(name);
152021
- }
152022
- }
152023
- return [...running];
152024
- } catch {
152025
- return [];
152081
+ var _cachedSigningContext;
152082
+ function getSigningContextOnce() {
152083
+ if (_cachedSigningContext === undefined) {
152084
+ _cachedSigningContext = loadSigningContextForServer(targetBaseUrl());
152026
152085
  }
152086
+ return _cachedSigningContext;
152027
152087
  }
152028
- async function installLatest(channel2) {
152029
- const proc = Bun.spawn({
152030
- cmd: ["bun", "add", "-g", "--force", "--no-cache", `${PACKAGE_NAME}@${channel2}`],
152031
- stdin: "inherit",
152032
- stdout: "inherit",
152033
- stderr: "inherit",
152034
- env: process.env
152088
+ async function callApi(method, path, body) {
152089
+ const url = trustEndpoint(path);
152090
+ const bodyString = body === undefined ? "" : JSON.stringify(body);
152091
+ const headers = authHeaders();
152092
+ const ctx = getSigningContextOnce();
152093
+ if (ctx && !path.startsWith("/handshake")) {
152094
+ const reqPath = new URL(url).pathname + new URL(url).search;
152095
+ const sigHeaders = ctx.signRequest(method, reqPath, bodyString);
152096
+ Object.assign(headers, sigHeaders);
152097
+ }
152098
+ const res = await fetch(url, {
152099
+ method,
152100
+ headers,
152101
+ body: body === undefined ? undefined : bodyString
152035
152102
  });
152036
- const exitCode = await proc.exited;
152037
- return exitCode === 0;
152103
+ if (!res.ok) {
152104
+ const text3 = await res.text().catch(() => "");
152105
+ throw new Error(`HTTP ${res.status} ${res.statusText}${text3 ? `: ${text3}` : ""}`);
152106
+ }
152107
+ return await res.json();
152038
152108
  }
152039
- async function restartPm2Services(processNames) {
152040
- const serverConfig = loadServerConfig();
152041
- const cliConfig = loadLocalRuntimeConfig();
152042
- const runtimeEnv = buildRuntimeEnv(serverConfig, cliConfig);
152043
- let allSucceeded = true;
152044
- for (const name of processNames) {
152045
- const proc = Bun.spawn({
152046
- cmd: ["pm2", "restart", name],
152047
- stdout: "pipe",
152048
- stderr: "pipe",
152049
- env: { ...process.env, ...runtimeEnv }
152109
+ function formatHostRow(host) {
152110
+ return {
152111
+ id: host.id.slice(0, 8),
152112
+ hostname: host.hostname,
152113
+ scopes: host.scopes.join(", ") || "(none)",
152114
+ pubkeyPrefix: `${host.pubkey.slice(0, 12)}\u2026`,
152115
+ lastSeen: host.lastSeenAt ? new Date(host.lastSeenAt).toISOString().replace("T", " ").slice(0, 16) : "never",
152116
+ status: host.revokedAt ? "revoked" : "active"
152117
+ };
152118
+ }
152119
+ async function handleList6() {
152120
+ try {
152121
+ const { items } = await callApi("GET", "/hosts");
152122
+ list(items.map(formatHostRow), {
152123
+ emptyMessage: "No genie hosts registered. Run `genie omni handshake` from a genie installation to register one.",
152124
+ rawData: items
152050
152125
  });
152051
- const exitCode = await proc.exited;
152052
- if (exitCode !== 0) {
152053
- allSucceeded = false;
152054
- }
152126
+ } catch (err2) {
152127
+ error(`Failed to list genie hosts: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
152055
152128
  }
152056
- return allSucceeded;
152057
152129
  }
152058
- function normalizeVersion(version2) {
152059
- return version2.split("+")[0] ?? version2;
152130
+ async function handleGet4(id) {
152131
+ try {
152132
+ const { data: data2 } = await callApi("GET", `/hosts/${encodeURIComponent(id)}`);
152133
+ data(data2);
152134
+ } catch (err2) {
152135
+ error(`Failed to get genie host: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
152136
+ }
152060
152137
  }
152061
- function decideVerify(args) {
152062
- if (args.skipReason !== undefined) {
152063
- return { kind: "skipped", reason: args.skipReason };
152138
+ async function handleUpdate3(id, options3) {
152139
+ const scopes = options3.scope.split(",").map((s2) => s2.trim()).filter(Boolean);
152140
+ if (scopes.length === 0) {
152141
+ error("--scope must contain at least one scope (use `omni trust revoke <id>` to deny everything).");
152064
152142
  }
152065
- const cliVersion = normalizeVersion(args.latest);
152066
- if (args.healthBody === null) {
152067
- return { kind: "health-unreachable", apiPort: args.apiPort };
152068
- }
152069
- const serverVersion = args.healthBody.version ? normalizeVersion(args.healthBody.version) : null;
152070
- if (!serverVersion || serverVersion !== cliVersion) {
152071
- return { kind: "version-mismatch", cliVersion, serverVersion };
152072
- }
152073
- if (!args.keyValid) {
152074
- return { kind: "auth-invalid" };
152075
- }
152076
- return { kind: "ok", cliVersion, serverVersion };
152077
- }
152078
- function updateErrorVersionMismatch(cli, server) {
152079
- return `Server version mismatch: cli=v${cli} server=v${server ?? "unknown"}. Run: omni doctor`;
152080
- }
152081
- var UPDATE_ERROR_AUTH_INVALID = "Auth key invalid after restart. Run: omni doctor --fix";
152082
- async function fetchHealthBody(apiPort, timeoutMs) {
152083
- const url = getHealthCheckUrl(apiPort);
152084
- const deadline = Date.now() + timeoutMs;
152085
- while (Date.now() < deadline) {
152086
- try {
152087
- const resp = await fetch(url, {
152088
- signal: AbortSignal.timeout(1500),
152089
- headers: { "Accept-Encoding": "identity" }
152090
- });
152091
- if (resp.ok) {
152092
- const body = await resp.json();
152093
- return body;
152094
- }
152095
- } catch {}
152096
- await Bun.sleep(VERIFY_POLL_INTERVAL_MS);
152097
- }
152098
- return null;
152099
- }
152100
- async function validateStoredKey(apiPort) {
152101
- const cliConfig = loadLocalRuntimeConfig();
152102
- if (!cliConfig.apiKey) {
152103
- return false;
152104
- }
152105
- const baseUrl = cliConfig.apiUrl ?? `http://localhost:${apiPort}`;
152106
- try {
152107
- const client = createOmniClient({ baseUrl, apiKey: cliConfig.apiKey, cliVersion: VERSION });
152108
- const result = await client.auth.validate();
152109
- return result.valid === true;
152110
- } catch {
152111
- return false;
152112
- }
152113
- }
152114
- function printVerifyBanner(latest) {
152115
- console.log(`${source_default.green("\u2713")} CLI: v${latest}`);
152116
- console.log(`${source_default.green("\u2713")} Server: v${latest} (healthy)`);
152117
- console.log(`${source_default.green("\u2713")} Auth: key valid`);
152118
- }
152119
- function printVerifySkippedBanner(latest) {
152120
- console.log(`${source_default.green("\u2713")} CLI: v${latest}`);
152121
- console.log(`${source_default.yellow("-")} Server: v${latest} (skipped)`);
152122
- console.log(`${source_default.yellow("-")} Auth: skipped`);
152123
- }
152124
- function detectParallelNpmGlobalInstall(deps) {
152125
- const npmRootFn = deps?.npmRoot ?? defaultNpmRoot;
152126
- const existsFn = deps?.exists ?? existsSync20;
152127
- let root;
152128
152143
  try {
152129
- root = npmRootFn();
152130
- } catch {
152131
- return { detected: false, skipped: "npm-root-failed" };
152132
- }
152133
- if (root === null || root.length === 0) {
152134
- return { detected: false, skipped: "npm-not-on-path" };
152135
- }
152136
- const candidate = join19(root, "@automagik", "omni");
152137
- if (existsFn(candidate)) {
152138
- return { detected: true, path: candidate };
152144
+ const { data: data2 } = await callApi("PATCH", `/hosts/${encodeURIComponent(id)}`, { scopes });
152145
+ success(`Updated genie host ${data2.id} scopes: ${data2.scopes.join(", ")}`);
152146
+ data(data2);
152147
+ } catch (err2) {
152148
+ error(`Failed to update genie host: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
152139
152149
  }
152140
- return { detected: false };
152141
152150
  }
152142
- function defaultNpmRoot() {
152151
+ async function registerPubkey(pubkey, hostname) {
152143
152152
  try {
152144
- const result = Bun.spawnSync({
152145
- cmd: ["npm", "root", "-g"],
152146
- stdout: "pipe",
152147
- stderr: "pipe",
152148
- timeout: 5000
152153
+ const res = await callApi("POST", "/handshake", {
152154
+ pubkey,
152155
+ hostname,
152156
+ capabilities: {
152157
+ client: "omni-cli",
152158
+ platform: process.platform,
152159
+ nodeVersion: process.version
152160
+ }
152149
152161
  });
152150
- if (result.exitCode !== 0)
152151
- return null;
152152
- const text3 = new TextDecoder().decode(result.stdout).trim();
152153
- return text3.length > 0 ? text3 : null;
152154
- } catch {
152155
- return null;
152162
+ return res.data;
152163
+ } catch (err2) {
152164
+ return error(`Handshake failed: ${err2 instanceof Error ? err2.message : String(err2)}`);
152156
152165
  }
152157
152166
  }
152158
- async function runLegacyCleanup(skipList) {
152159
- const cleanupSpinner = ora("Checking for legacy artifacts to clean up...").start();
152160
- const report = await cleanupLegacyArtifacts(skipList);
152161
- cleanupSpinner.stop();
152162
- for (const outcome of report.outcomes) {
152163
- if (outcome.state === "ran" && outcome.summary.length > 0) {
152164
- console.log(outcome.summary);
152167
+ async function handleHandshake(options3) {
152168
+ const targetUrl = targetBaseUrl();
152169
+ const serverName = getTargetServerName();
152170
+ const existing = loadHostMetadata();
152171
+ if (existing && !options3.rotate) {
152172
+ const bindings = boundServerBindings(existing);
152173
+ const bound = boundServerUrls(existing);
152174
+ const alreadyBound = bindingForServer(existing, targetUrl);
152175
+ if (alreadyBound) {
152176
+ success(`Already handshook as host ${alreadyBound.hostId} for server '${serverName}' (${targetUrl}). Bound servers: ${bound.join(", ")} (each with its own host id). Requests to any other server entry are sent UNSIGNED (bearer only) \u2014 run \`omni trust handshake --server <name>\` there to bind it. Pass --rotate to issue a new keypair.`);
152177
+ return;
152165
152178
  }
152179
+ const registered2 = await registerPubkey(existing.pubkey, options3.hostname ?? existing.hostname);
152180
+ const meta2 = {
152181
+ ...existing,
152182
+ pubkey: registered2.pubkey,
152183
+ boundServers: [...bindings, { url: targetUrl, hostId: registered2.id }]
152184
+ };
152185
+ writeHostMetadata(meta2);
152186
+ success(`Bound existing host key to server '${serverName}' (${targetUrl}) \u2014 no rotation.`);
152187
+ data({
152188
+ hostId: registered2.id,
152189
+ hostname: meta2.hostname,
152190
+ pubkey: meta2.pubkey,
152191
+ boundServers: meta2.boundServers,
152192
+ rotated: false
152193
+ });
152194
+ return;
152166
152195
  }
152167
- return report;
152168
- }
152169
- var OMNI_UPDATE_SKIP_MAINTENANCE_ENV = "OMNI_UPDATE_SKIP_MAINTENANCE";
152170
- function resolveMaintenanceSkipReason(args) {
152171
- if (!args.verifyOk)
152172
- return "verify-failed";
152173
- if (args.skipMaintenance === true)
152174
- return "cli-flag";
152175
- const raw2 = args.env[OMNI_UPDATE_SKIP_MAINTENANCE_ENV];
152176
- if (raw2 !== undefined && raw2 !== "" && raw2 !== "0" && raw2.toLowerCase() !== "false") {
152177
- return "env";
152178
- }
152179
- return null;
152180
- }
152181
- function formatMaintenanceSummary(report) {
152182
- const { ok: ok3, warn: warn2, fail } = report.summary;
152183
- return `Maintenance: ${ok3} ok, ${warn2} warn, ${fail} fail`;
152196
+ const { pubkeyB64Url } = generateAndStoreKeypair();
152197
+ const hostname = options3.hostname ?? osHostname();
152198
+ const registered = await registerPubkey(pubkeyB64Url, hostname);
152199
+ const meta = {
152200
+ hostId: registered.id,
152201
+ pubkey: registered.pubkey,
152202
+ hostname: registered.hostname,
152203
+ registeredAt: new Date().toISOString(),
152204
+ boundServers: [{ url: targetUrl, hostId: registered.id }]
152205
+ };
152206
+ writeHostMetadata(meta);
152207
+ success(`Operator-host registered: ${meta.hostId} (server: ${serverName})`);
152208
+ data({
152209
+ hostId: meta.hostId,
152210
+ hostname: meta.hostname,
152211
+ pubkey: meta.pubkey,
152212
+ boundServers: meta.boundServers,
152213
+ keysDir: "~/.omni/keys/",
152214
+ nextStep: `Calls to '${serverName}' will now be signed automatically; other server entries stay bearer-only until you run \`omni trust handshake --server <name>\` against them. Run \`omni trust list\` to verify (lastSeenAt should advance after subsequent calls).`
152215
+ });
152184
152216
  }
152185
- async function runPostUpdateMaintenance(args) {
152186
- if (args.skipReason !== null) {
152187
- return { outcome: "skipped", durationMs: 0, skipReason: args.skipReason };
152188
- }
152189
- const impl = args.runDoctorImpl ?? runDoctor;
152190
- const startedAt = Date.now();
152217
+ async function handleRevoke2(id) {
152191
152218
  try {
152192
- const doctorReport = await impl({ json: true, dryRun: true });
152193
- return {
152194
- outcome: "completed",
152195
- durationMs: Date.now() - startedAt,
152196
- doctorReport
152197
- };
152219
+ const { data: data2 } = await callApi("DELETE", `/hosts/${encodeURIComponent(id)}`);
152220
+ success(`Revoked genie host ${data2.id} (${data2.hostname}). Tombstone kept for audit.`);
152221
+ data(data2);
152198
152222
  } catch (err2) {
152199
- return {
152200
- outcome: "failed",
152201
- durationMs: Date.now() - startedAt,
152202
- error: err2 instanceof Error ? err2.message : String(err2)
152203
- };
152223
+ error(`Failed to revoke genie host: ${err2 instanceof Error ? err2.message : "Unknown error"}`);
152204
152224
  }
152205
152225
  }
152206
- async function restartServicesAndVerify(servicesToRestart, latest, options3, diagnostics, finalize) {
152207
- const apiPort = loadServerConfig().port;
152208
- diagnostics.restart.attempted = true;
152209
- diagnostics.restart.services = [...servicesToRestart];
152210
- const restartSpinner = ora("Restarting services...").start();
152211
- const restartSucceeded = await restartPm2Services(servicesToRestart);
152212
- restartSpinner.stop();
152213
- diagnostics.restart.succeeded = restartSucceeded;
152214
- if (!restartSucceeded) {
152215
- warn(`omni CLI updated to v${latest}, but one or more service restarts failed. Run \`omni status\`.`);
152216
- finalize(1);
152217
- }
152218
- let cleanupReport = null;
152219
- if (options3.legacyCleanup) {
152220
- cleanupReport = await runLegacyCleanup(options3.skipList);
152221
- diagnostics.cleanups = cleanupReport;
152222
- }
152223
- if (options3.skipVerify) {
152224
- const result2 = decideVerify({ skipReason: "no-verify-flag" });
152225
- diagnostics.verify = result2;
152226
- printVerifySkippedBanner(latest);
152227
- if (cleanupReport !== null && !cleanupReport.succeeded) {
152228
- warn("One or more legacy artifacts could not be cleaned up automatically. " + "See messages above and docs/migration/nats-genie-sidecar-decommission.md.");
152226
+ function createTrustCommand() {
152227
+ const trust = new Command("trust").description("Manage genie host fingerprint registrations");
152228
+ trust.command("handshake").description("Register THIS omni CLI as a host with the targeted server (mints ed25519 keypair in ~/.omni/keys/ on first run; later servers reuse it).").addHelpText("after", `
152229
+ Signing is per-server: only servers you have handshaken against recognize this
152230
+ keypair, so calls to other entries stay bearer-only. Bind another one with:
152231
+ omni trust handshake --server <name>
152232
+ `).option("--rotate", "Issue a new keypair even if one already exists (unbinds every previously bound server)").option("--hostname <name>", "Override the hostname reported to omni (defaults to os.hostname())").action(handleHandshake);
152233
+ trust.command("list").description("List active (non-revoked) genie hosts").action(handleList6);
152234
+ trust.command("get <id>").description("Show details for one genie host (active or revoked)").action(handleGet4);
152235
+ trust.command("update <id>").description("Replace a genie host scopes (comma-separated)").requiredOption("--scope <list>", 'Comma-separated scope list, e.g. "agents:write,providers:write"').action(handleUpdate3);
152236
+ trust.command("revoke <id>").description("Revoke a genie host (irreversible \u2014 re-register with a fresh keypair to restore trust)").action(handleRevoke2);
152237
+ return trust;
152238
+ }
152239
+
152240
+ // src/commands/tts.ts
152241
+ init_output();
152242
+ function createTtsCommand() {
152243
+ const tts = new Command("tts").description("Text-to-speech operations");
152244
+ tts.command("voices").description("List available TTS voices").action(async () => {
152245
+ const client = getClient();
152246
+ try {
152247
+ const voices = await client.messages.listVoices();
152248
+ if (voices.length === 0) {
152249
+ info("No TTS voices available. Check ElevenLabs configuration.");
152250
+ return;
152251
+ }
152252
+ const items = voices.map((v4) => ({
152253
+ voiceId: v4.voiceId,
152254
+ name: v4.name,
152255
+ category: v4.category ?? "-"
152256
+ }));
152257
+ list(items, { emptyMessage: "No voices found." });
152258
+ } catch (err2) {
152259
+ const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152260
+ error(`Failed to list voices: ${message2}`);
152229
152261
  }
152230
- const maintenance = await runPostUpdateMaintenance({ skipReason: "verify-failed" });
152231
- diagnostics.maintenance = maintenance;
152232
- return;
152233
- }
152234
- const verifySpinner = ora("Verifying server version...").start();
152235
- const healthBody = await fetchHealthBody(apiPort, UPDATE_HEALTH_TIMEOUT_MS);
152236
- verifySpinner.stop();
152237
- const keyValid = healthBody !== null ? await validateStoredKey(apiPort) : false;
152238
- const result = decideVerify({ latest, apiPort, healthBody, keyValid });
152239
- diagnostics.verify = result;
152240
- switch (result.kind) {
152241
- case "ok": {
152242
- printVerifyBanner(result.cliVersion);
152243
- if (cleanupReport !== null && !cleanupReport.succeeded) {
152244
- warn("One or more legacy artifacts could not be cleaned up automatically. " + "See messages above and docs/migration/nats-genie-sidecar-decommission.md.");
152245
- }
152246
- const skipReason = resolveMaintenanceSkipReason({
152247
- verifyOk: true,
152248
- skipMaintenance: options3.skipMaintenance,
152249
- env: process.env
152250
- });
152251
- const maintenance = await runPostUpdateMaintenance({ skipReason });
152252
- diagnostics.maintenance = maintenance;
152253
- if (maintenance.outcome === "completed" && maintenance.doctorReport) {
152254
- info(formatMaintenanceSummary(maintenance.doctorReport));
152255
- } else if (maintenance.outcome === "failed") {
152256
- warn(`Maintenance: skipped (runDoctor failed: ${maintenance.error ?? "unknown error"})`);
152257
- }
152258
- return;
152259
- }
152260
- case "health-unreachable":
152261
- warn(`omni CLI updated to v${latest}, but health check failed on port ${result.apiPort}. Run \`omni status\`.`);
152262
- finalize(1);
152263
- break;
152264
- case "version-mismatch":
152265
- console.error(`${source_default.red("\u2717")} ${updateErrorVersionMismatch(result.cliVersion, result.serverVersion)}`);
152266
- finalize(1);
152267
- break;
152268
- case "auth-invalid":
152269
- console.error(`${source_default.red("\u2717")} ${UPDATE_ERROR_AUTH_INVALID}`);
152270
- finalize(1);
152271
- break;
152272
- }
152262
+ });
152263
+ return tts;
152273
152264
  }
152274
- async function promptConfirm(question) {
152275
- const rl = createInterface3({
152276
- input: process.stdin,
152277
- output: process.stdout
152265
+
152266
+ // src/commands/turns.ts
152267
+ init_output();
152268
+ function formatDuration2(startedAt, closedAt) {
152269
+ const start = new Date(startedAt).getTime();
152270
+ const end = closedAt ? new Date(closedAt).getTime() : Date.now();
152271
+ const ms = end - start;
152272
+ if (ms < 1000)
152273
+ return `${ms}ms`;
152274
+ if (ms < 60000)
152275
+ return `${Math.round(ms / 1000)}s`;
152276
+ return `${Math.round(ms / 60000)}m`;
152277
+ }
152278
+ function createTurnsCommand() {
152279
+ const cmd = new Command("turns").description("Admin turn management");
152280
+ cmd.command("list").description("List turns with optional filters").option("--status <status>", "Filter by status (open, done, timeout)").option("--instance <id>", "Filter by instance ID").option("--chat <id>", "Filter by chat ID").option("--agent <id>", "Filter by agent ID").option("--limit <n>", "Max results (default 50)", "50").option("--offset <n>", "Offset for pagination (default 0)", "0").action(async (opts) => {
152281
+ const client = getClient();
152282
+ const result = await client.turns.list({
152283
+ status: opts.status,
152284
+ instanceId: opts.instance,
152285
+ chatId: opts.chat,
152286
+ agentId: opts.agent,
152287
+ limit: Number(opts.limit),
152288
+ offset: Number(opts.offset)
152289
+ });
152290
+ const rows = result.items.map((t) => ({
152291
+ id: t.id.slice(0, 8),
152292
+ status: t.status,
152293
+ chat: t.chatId.length > 20 ? `${t.chatId.slice(0, 20)}\u2026` : t.chatId,
152294
+ agent: t.agentId.slice(0, 8),
152295
+ duration: formatDuration2(t.startedAt, t.closedAt),
152296
+ nudges: t.nudgeCount,
152297
+ msgs: t.messagesSent
152298
+ }));
152299
+ list(rows, {
152300
+ emptyMessage: "No turns found.",
152301
+ rawData: result.items
152302
+ });
152303
+ if (getCurrentFormat() !== "json") {
152304
+ dim(`Total: ${result.total} | Showing ${result.offset + 1}\u2013${result.offset + result.items.length}`);
152305
+ }
152278
152306
  });
152279
- return new Promise((resolve4) => {
152280
- rl.question(question, (answer) => {
152281
- rl.close();
152282
- const trimmed = answer.trim().toLowerCase();
152283
- resolve4(trimmed === "" || trimmed === "y" || trimmed === "yes");
152307
+ cmd.command("get").description("Get a single turn by ID").argument("<id>", "Turn ID").action(async (id) => {
152308
+ const client = getClient();
152309
+ const turn = await client.turns.get(id);
152310
+ data(turn);
152311
+ });
152312
+ cmd.command("close").description("Admin force-close a turn").argument("<id>", "Turn ID to close").option("--reason <text>", "Close reason").action(async (id, opts) => {
152313
+ const client = getClient();
152314
+ const result = await client.turns.forceClose(id, opts.reason);
152315
+ success(`Turn ${result.turnId} closed`, result);
152316
+ });
152317
+ cmd.command("close-all").description("Bulk close all open turns (requires --confirm)").option("--confirm", "Confirm bulk close").option("--reason <text>", "Close reason").action(async (opts) => {
152318
+ if (!opts.confirm) {
152319
+ error("Bulk close requires --confirm flag to proceed.");
152320
+ }
152321
+ const client = getClient();
152322
+ const result = await client.turns.bulkClose(opts.reason);
152323
+ success(result.message, result);
152324
+ });
152325
+ cmd.command("stats").description("Show aggregate turn metrics").action(async () => {
152326
+ const client = getClient();
152327
+ const stats = await client.turns.stats();
152328
+ data({
152329
+ openCount: stats.openCount,
152330
+ totalCount: stats.totalCount,
152331
+ avgDurationMs: stats.avgDurationMs,
152332
+ timeoutRate: `${(stats.timeoutRate * 100).toFixed(1)}%`
152284
152333
  });
152285
152334
  });
152335
+ return cmd;
152286
152336
  }
152287
- async function runUpdate(options3) {
152288
- const channel2 = resolveChannel(options3);
152289
- if (options3.next || options3.stable) {
152290
- persistChannel(channel2);
152337
+
152338
+ // src/commands/update.ts
152339
+ import { existsSync as existsSync20 } from "fs";
152340
+ import { join as join19 } from "path";
152341
+ import { createInterface as createInterface3 } from "readline";
152342
+ init_source();
152343
+ init_config();
152344
+
152345
+ // src/sidecar-cleanup.ts
152346
+ var SIDECAR_BASENAME = "nats-reply-sidecar.mjs";
152347
+ var SIDECAR_NAME_HINT = "nats-reply-sidecar";
152348
+ function pm2EntryToMatch(entry) {
152349
+ if (!entry || typeof entry !== "object")
152350
+ return null;
152351
+ const name = typeof entry.name === "string" ? entry.name : "";
152352
+ const scriptPath = typeof entry.pm2_env?.pm_exec_path === "string" ? entry.pm2_env.pm_exec_path : "";
152353
+ const pmId = typeof entry.pm_id === "number" ? entry.pm_id : null;
152354
+ const nameMatch = name.includes(SIDECAR_NAME_HINT);
152355
+ const pathMatch = scriptPath.endsWith(`/${SIDECAR_BASENAME}`) || scriptPath.endsWith(SIDECAR_BASENAME);
152356
+ if (!nameMatch && !pathMatch)
152357
+ return null;
152358
+ return {
152359
+ name,
152360
+ pmId,
152361
+ scriptPath,
152362
+ matchedBy: nameMatch ? "name" : "script-path"
152363
+ };
152364
+ }
152365
+ function parsePm2SidecarMatches(rawJson) {
152366
+ const trimmed = rawJson.trim();
152367
+ if (trimmed.length === 0 || trimmed === "[]")
152368
+ return [];
152369
+ let parsed;
152370
+ try {
152371
+ parsed = JSON.parse(trimmed);
152372
+ } catch {
152373
+ return [];
152291
152374
  }
152292
- const currentClean = VERSION.split("+")[0];
152293
- const diagnostics = createDiagnostics({ runningVersion: currentClean, channel: channel2 });
152294
- const finalize = (exitCode) => {
152295
- const path = writeDiagnostics(diagnostics, exitCode);
152296
- if (path !== null && process.env.OMNI_UPDATE_DIAGNOSTICS_VERBOSE === "1") {
152297
- info(`Diagnostics written: ${path}`);
152375
+ if (!Array.isArray(parsed))
152376
+ return [];
152377
+ const matches = [];
152378
+ for (const entry of parsed) {
152379
+ const match = pm2EntryToMatch(entry);
152380
+ if (match !== null)
152381
+ matches.push(match);
152382
+ }
152383
+ return matches;
152384
+ }
152385
+ function parseRawSidecarMatches(rawPgrep) {
152386
+ const lines = rawPgrep.split(`
152387
+ `);
152388
+ const matches = [];
152389
+ for (const line3 of lines) {
152390
+ const trimmed = line3.trim();
152391
+ if (trimmed.length === 0)
152392
+ continue;
152393
+ const firstSpace = trimmed.indexOf(" ");
152394
+ if (firstSpace === -1)
152395
+ continue;
152396
+ const pidStr = trimmed.slice(0, firstSpace);
152397
+ const command = trimmed.slice(firstSpace + 1);
152398
+ const pid = Number.parseInt(pidStr, 10);
152399
+ if (!Number.isFinite(pid) || pid <= 0)
152400
+ continue;
152401
+ if (!command.includes(SIDECAR_BASENAME))
152402
+ continue;
152403
+ matches.push({ pid, command });
152404
+ }
152405
+ return matches;
152406
+ }
152407
+ async function detectPm2Sidecars() {
152408
+ try {
152409
+ const proc = Bun.spawn({
152410
+ cmd: ["pm2", "jlist"],
152411
+ stdout: "pipe",
152412
+ stderr: "pipe"
152413
+ });
152414
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
152415
+ if (exitCode !== 0)
152416
+ return [];
152417
+ return parsePm2SidecarMatches(stdout);
152418
+ } catch {
152419
+ return [];
152420
+ }
152421
+ }
152422
+ async function detectRawSidecars() {
152423
+ try {
152424
+ const proc = Bun.spawn({
152425
+ cmd: ["pgrep", "-fa", SIDECAR_BASENAME],
152426
+ stdout: "pipe",
152427
+ stderr: "pipe"
152428
+ });
152429
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
152430
+ if (exitCode !== 0 && exitCode !== 1)
152431
+ return [];
152432
+ const matches = parseRawSidecarMatches(stdout);
152433
+ const ownPid = process.pid;
152434
+ return matches.filter((m10) => m10.pid !== ownPid);
152435
+ } catch {
152436
+ return [];
152437
+ }
152438
+ }
152439
+ async function stopPm2Sidecar(match) {
152440
+ const target = match.name.length > 0 ? match.name : match.pmId !== null ? String(match.pmId) : null;
152441
+ if (target === null)
152442
+ return false;
152443
+ for (const verb of ["stop", "delete"]) {
152444
+ const proc = Bun.spawn({
152445
+ cmd: ["pm2", verb, target],
152446
+ stdout: "pipe",
152447
+ stderr: "pipe"
152448
+ });
152449
+ const exitCode = await proc.exited;
152450
+ if (exitCode !== 0)
152451
+ return false;
152452
+ }
152453
+ return true;
152454
+ }
152455
+ async function killRawSidecar(match) {
152456
+ try {
152457
+ process.kill(match.pid, "SIGTERM");
152458
+ return true;
152459
+ } catch {
152460
+ return false;
152461
+ }
152462
+ }
152463
+ async function cleanupSidecars() {
152464
+ const [pm2Detected, rawDetected] = await Promise.all([detectPm2Sidecars(), detectRawSidecars()]);
152465
+ const pm2Stopped = [];
152466
+ const pm2Failed = [];
152467
+ for (const match of pm2Detected) {
152468
+ const ok3 = await stopPm2Sidecar(match);
152469
+ if (ok3) {
152470
+ pm2Stopped.push(match);
152471
+ } else {
152472
+ pm2Failed.push(match);
152298
152473
  }
152299
- process.exit(exitCode);
152474
+ }
152475
+ const rawKilled = [];
152476
+ const rawFailed = [];
152477
+ for (const match of rawDetected) {
152478
+ const ok3 = await killRawSidecar(match);
152479
+ if (ok3) {
152480
+ rawKilled.push(match);
152481
+ } else {
152482
+ rawFailed.push(match);
152483
+ }
152484
+ }
152485
+ return {
152486
+ pm2Detected,
152487
+ rawDetected,
152488
+ pm2Stopped,
152489
+ pm2Failed,
152490
+ rawKilled,
152491
+ rawFailed
152492
+ };
152493
+ }
152494
+ function formatCleanupSummary(result) {
152495
+ const totalDetected = result.pm2Detected.length + result.rawDetected.length;
152496
+ if (totalDetected === 0)
152497
+ return "";
152498
+ const lines = [];
152499
+ lines.push(`Found ${totalDetected} legacy nats-reply-sidecar process(es) \u2014 stopping to prevent duplicate replies.`);
152500
+ for (const match of result.pm2Stopped) {
152501
+ const label = match.name || `pm_id=${match.pmId}`;
152502
+ lines.push(` \u2713 pm2: stopped and deleted "${label}" (matched by ${match.matchedBy})`);
152503
+ }
152504
+ for (const match of result.pm2Failed) {
152505
+ const label = match.name || `pm_id=${match.pmId}`;
152506
+ lines.push(` \u2717 pm2: failed to stop "${label}" \u2014 run \`pm2 stop ${label} && pm2 delete ${label}\` manually`);
152507
+ }
152508
+ for (const match of result.rawKilled) {
152509
+ lines.push(` \u2713 raw: SIGTERM sent to pid ${match.pid}`);
152510
+ }
152511
+ for (const match of result.rawFailed) {
152512
+ lines.push(` \u2717 raw: failed to signal pid ${match.pid} \u2014 kill it manually with \`kill ${match.pid}\``);
152513
+ }
152514
+ if (result.pm2Failed.length > 0 || result.rawFailed.length > 0) {
152515
+ lines.push("See docs/migration/nats-genie-sidecar-decommission.md for the manual cleanup runbook.");
152516
+ }
152517
+ return lines.join(`
152518
+ `);
152519
+ }
152520
+
152521
+ // src/legacy-cleanup.ts
152522
+ function createNatsReplySidecarArtifact() {
152523
+ let lastResult = null;
152524
+ return {
152525
+ name: "nats-reply-sidecar",
152526
+ async detect() {
152527
+ return true;
152528
+ },
152529
+ async cleanup() {
152530
+ const result = await cleanupSidecars();
152531
+ lastResult = result;
152532
+ const removed = [];
152533
+ const warnings = [];
152534
+ for (const m10 of result.pm2Stopped) {
152535
+ removed.push(`pm2:${m10.name.length > 0 ? m10.name : `pm_id=${m10.pmId}`}`);
152536
+ }
152537
+ for (const m10 of result.rawKilled) {
152538
+ removed.push(`pid:${m10.pid}`);
152539
+ }
152540
+ for (const m10 of result.pm2Failed) {
152541
+ warnings.push(`pm2-failed:${m10.name.length > 0 ? m10.name : `pm_id=${m10.pmId}`}`);
152542
+ }
152543
+ for (const m10 of result.rawFailed) {
152544
+ warnings.push(`raw-failed:pid=${m10.pid}`);
152545
+ }
152546
+ return { removed, warnings };
152547
+ },
152548
+ summary() {
152549
+ return lastResult === null ? "" : formatCleanupSummary(lastResult);
152550
+ }
152551
+ };
152552
+ }
152553
+ var REGISTRY = [createNatsReplySidecarArtifact()];
152554
+ function errorMessage2(error2) {
152555
+ return error2 instanceof Error ? error2.message : String(error2);
152556
+ }
152557
+ function erroredOutcome(name, phase, error2) {
152558
+ const message2 = errorMessage2(error2);
152559
+ return {
152560
+ name,
152561
+ state: "errored",
152562
+ removed: [],
152563
+ warnings: [`${phase}-threw:${message2}`],
152564
+ summary: "",
152565
+ error: message2
152300
152566
  };
152301
- const parallelInstall = detectParallelNpmGlobalInstall();
152302
- diagnostics.parallelNpmGlobal = parallelInstall;
152303
- if (parallelInstall.detected && parallelInstall.path) {
152304
- warn(`Parallel npm-global install of ${PACKAGE_NAME} detected at ${parallelInstall.path}. This may shadow the bun-installed binary on PATH. Recommended: npm uninstall -g ${PACKAGE_NAME}`);
152567
+ }
152568
+ async function runArtifact(artifact) {
152569
+ let detected;
152570
+ try {
152571
+ detected = await artifact.detect();
152572
+ } catch (error2) {
152573
+ return erroredOutcome(artifact.name, "detect", error2);
152305
152574
  }
152306
- info(`Channel: ${channel2}${channel2 === "next" ? " (dev builds)" : " (stable)"}`);
152307
- const versionSpinner = ora(`Checking ${channel2} version of ${PACKAGE_NAME}...`).start();
152308
- const latest = await fetchLatestVersion(channel2);
152309
- versionSpinner.stop();
152310
- diagnostics.registry.latestVersion = latest;
152311
- if (latest === null) {
152312
- warn("Could not reach npm registry. Check your network connection and try again.");
152313
- return finalize(1);
152575
+ if (!detected) {
152576
+ return {
152577
+ name: artifact.name,
152578
+ state: "not-detected",
152579
+ removed: [],
152580
+ warnings: [],
152581
+ summary: ""
152582
+ };
152314
152583
  }
152315
- if (currentClean === latest) {
152316
- success(`Already up to date (v${latest}, channel: ${channel2})`);
152317
- return finalize(0);
152584
+ try {
152585
+ const { removed, warnings } = await artifact.cleanup();
152586
+ return {
152587
+ name: artifact.name,
152588
+ state: "ran",
152589
+ removed,
152590
+ warnings,
152591
+ summary: artifact.summary()
152592
+ };
152593
+ } catch (error2) {
152594
+ return erroredOutcome(artifact.name, "cleanup", error2);
152318
152595
  }
152319
- info(`Update available: v${currentClean} \u2192 v${latest} (${channel2})`);
152320
- if (!options3.yes) {
152321
- const confirmed = await promptConfirm(`Update from v${currentClean} to v${latest}? [Y/n] `);
152322
- if (!confirmed) {
152323
- info("Update cancelled.");
152324
- return finalize(0);
152596
+ }
152597
+ async function cleanupLegacyArtifacts(skipList) {
152598
+ const outcomes = [];
152599
+ const skipped = [];
152600
+ for (const artifact of REGISTRY) {
152601
+ if (skipList.has(artifact.name)) {
152602
+ skipped.push(artifact.name);
152603
+ outcomes.push({
152604
+ name: artifact.name,
152605
+ state: "skipped",
152606
+ removed: [],
152607
+ warnings: [],
152608
+ summary: ""
152609
+ });
152610
+ continue;
152325
152611
  }
152612
+ outcomes.push(await runArtifact(artifact));
152326
152613
  }
152327
- const servicesToRestart = options3.restart !== false ? getRunningPm2Services() : [];
152328
- const installSpinner = ora(`Updating ${PACKAGE_NAME}@${channel2}...`).start();
152329
- diagnostics.install.attempted = true;
152330
- diagnostics.install.targetVersion = latest;
152331
- const installed = await installLatest(channel2);
152332
- installSpinner.stop();
152333
- diagnostics.install.succeeded = installed;
152334
- if (!installed) {
152335
- warn(`Installation failed. Your current version (v${currentClean}) is still intact.`);
152336
- return finalize(1);
152337
- }
152338
- const sidecarCleanupExplicit = options3.sidecarCleanup === false;
152339
- if (sidecarCleanupExplicit) {
152340
- info("--no-sidecar-cleanup (deprecated alias for --no-legacy-cleanup)");
152341
- }
152342
- const legacyCleanupEnabled = options3.legacyCleanup !== false && options3.sidecarCleanup !== false;
152343
- const skipList = parseSkipCleanupList(options3.skipCleanup);
152344
- if (servicesToRestart.length > 0) {
152345
- await restartServicesAndVerify(servicesToRestart, latest, {
152346
- legacyCleanup: legacyCleanupEnabled,
152347
- skipList,
152348
- skipMaintenance: options3.skipMaintenance === true,
152349
- skipVerify: options3.verify === false
152350
- }, diagnostics, finalize);
152351
- return finalize(0);
152352
- }
152353
- diagnostics.verify = decideVerify({ skipReason: "no-restart" });
152354
- success(`omni updated to v${latest}`);
152355
- return finalize(0);
152356
- }
152357
- function parseSkipCleanupList(value) {
152358
- if (!value)
152359
- return new Set;
152360
- const names = value.split(",").map((s2) => s2.trim()).filter((s2) => s2.length > 0);
152361
- return new Set(names);
152614
+ const succeeded = outcomes.every((o2) => o2.state !== "errored" && (o2.state !== "ran" || o2.warnings.length === 0));
152615
+ return { outcomes, succeeded, skipped };
152362
152616
  }
152363
- function createUpdateCommand() {
152364
- return new Command("update").description(`Update ${PACKAGE_NAME} to the latest version (restart only services already running)`).option("-y, --yes", "Skip confirmation prompts (non-interactive)").option("--no-restart", "Update CLI only; skip service restarts and verification").option("--no-verify", "Restart services but skip the post-restart probe (use when a release ships a broken /api/v2/health and operators need to roll forward)").option("--no-legacy-cleanup", "Skip every registered legacy-artifact cleanup (e.g. nats-reply-sidecar)").option("--no-sidecar-cleanup", "Deprecated alias for --no-legacy-cleanup").option("--skip-cleanup <names>", 'Comma-separated list of legacy-cleanup registry entries to skip (e.g. "nats-reply-sidecar")').option("--next", "Switch to dev builds (npm @next tag) and persist as default").option("--stable", "Switch to stable releases (npm @latest tag) and persist as default").option("--skip-maintenance", `Skip the post-update maintenance hook (read-only \`omni doctor\` sweep). Also honored via the ${OMNI_UPDATE_SKIP_MAINTENANCE_ENV} env var.`).addHelpText("after", `
152365
- Channels:
152366
- - stable (default) \u2014 tracks the npm @latest tag, bumped from main branch releases.
152367
- - next (dev builds) \u2014 tracks the npm @next tag, bumped on every CI-green dev merge.
152368
152617
 
152369
- Use --next to switch to dev builds; --stable to switch back. The choice is
152370
- persisted to ~/.omni/config.json under 'updateChannel' so subsequent
152371
- 'omni update' calls stay on the selected track. Check or change manually:
152372
- omni config get updateChannel
152373
- omni config set updateChannel next
152374
- omni config set updateChannel latest
152618
+ // src/commands/update.ts
152619
+ init_output();
152375
152620
 
152376
- Behavior:
152377
- - Installs the latest CLI package first.
152378
- - Restarts tracked Omni services only when they were online before the update.
152379
- - When that restart path runs, update performs 3-step verification:
152380
- 1. Server version matches the new CLI version (via /api/v2/health).
152381
- 2. Stored CLI API key still validates against the server.
152382
- 3. On success, prints:
152383
- \u2713 CLI: v<latest>
152384
- \u2713 Server: v<latest> (healthy)
152385
- \u2713 Auth: key valid
152386
- - On mismatch, exits non-zero with:
152387
- "Server version mismatch: cli=v<X> server=v<Y>. Run: omni doctor"
152388
- - On auth failure, exits non-zero with:
152389
- "Auth key invalid after restart. Run: omni doctor --fix"
152390
- - After a successful restart, runs every registered legacy-artifact cleanup.
152391
- The day-one entry is nats-reply-sidecar: it scans for any legacy
152392
- nats-reply-sidecar.mjs process (PM2-managed or raw) and stops it. The
152393
- sidecar was an external workaround for bugs that were fixed in #362;
152394
- leaving it running causes every agent reply to be delivered twice.
152395
- Skip every cleanup with --no-legacy-cleanup, or skip a single registry
152396
- entry with --skip-cleanup=<name1,name2>. The deprecated alias
152397
- --no-sidecar-cleanup still works and behaves identically. Manual runbook:
152398
- docs/migration/nats-genie-sidecar-decommission.md
152399
- - Use --no-restart to skip restart + verification entirely. --no-restart
152400
- also skips legacy-artifact cleanup; manage those services manually.
152401
- - Use --no-verify to restart services but skip the post-restart probe.
152402
- The banner shows the new CLI version on both lines but tags Server /
152403
- Auth as "(skipped)"; maintenance is auto-skipped because verify never
152404
- confirmed health.
152405
- - Detects parallel npm-global installs of ${PACKAGE_NAME} (omni installs
152406
- via bun-global; an npm-global install hides stale binaries on PATH and
152407
- confuses 'which omni'). When found, prints a one-line warning naming
152408
- the offending path; recommended remediation: npm uninstall -g ${PACKAGE_NAME}.
152409
- - Every invocation writes a diagnostics record to
152410
- ~/.omni/logs/update-diagnostics-<iso>.json (schemaVersion: 1). Captures
152411
- install attempt, registry probe, restart outcome, verify result, cleanup
152412
- registry result, maintenance hook, and a tail of pm2 log signals. Set
152413
- OMNI_UPDATE_DIAGNOSTICS_VERBOSE=1 to print the path on completion. A
152414
- failed write never changes the update exit code.
152415
- - On a successful restart + verify, runs a post-update maintenance hook:
152416
- a read-only \`omni doctor\` sweep that prints a one-line summary
152417
- ("Maintenance: <ok> ok, <warn> warn, <fail> fail"). The probe never
152418
- mutates pm2 / config / DB state (\`omni doctor --fix\` remains the
152419
- explicit operator action for repair). A failing maintenance call is
152420
- non-fatal \u2014 exit code stays 0 with a banner warning. Skip with
152421
- --skip-maintenance or ${OMNI_UPDATE_SKIP_MAINTENANCE_ENV}=1.
152422
- - Verify runtime health after update with: omni status
152423
- `).action(runUpdate);
152621
+ // src/update-diagnostics.ts
152622
+ import { existsSync as existsSync19, mkdirSync as mkdirSync12, readFileSync as readFileSync19, writeFileSync as writeFileSync11 } from "fs";
152623
+ import { homedir as homedir13 } from "os";
152624
+ import { join as join18 } from "path";
152625
+ var UPDATE_DIAGNOSTICS_SCHEMA_VERSION = 1;
152626
+ function createDiagnostics(args) {
152627
+ const startedAt = new Date().toISOString();
152628
+ return {
152629
+ schemaVersion: UPDATE_DIAGNOSTICS_SCHEMA_VERSION,
152630
+ startedAt,
152631
+ finishedAt: startedAt,
152632
+ cli: { runningVersion: args.runningVersion, channel: args.channel },
152633
+ registry: { latestVersion: null },
152634
+ preflight: { ran: false, blocked: false },
152635
+ install: { attempted: false, succeeded: null, targetVersion: null },
152636
+ restart: { attempted: false, succeeded: null, services: [] },
152637
+ verify: null,
152638
+ cleanups: null,
152639
+ maintenance: null,
152640
+ parallelNpmGlobal: null,
152641
+ recentLogSignals: [],
152642
+ exitCode: 0
152643
+ };
152424
152644
  }
152425
-
152426
- // src/commands/use.ts
152427
- init_output();
152428
- function createUseCommand() {
152429
- return new Command("use").description("Set active instance for verb commands").argument("<instance>", "Instance name, ID, or prefix").action(async (instance3) => {
152430
- const client = getClient();
152431
- const instanceId = await resolveInstanceId(instance3);
152432
- try {
152433
- await client.context.use(instanceId);
152434
- } catch (err2) {
152435
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152436
- return error(`Failed to set active instance: ${message2}`);
152645
+ function getDiagnosticsDir() {
152646
+ const base = process.env.OMNI_CONFIG_DIR ?? join18(homedir13(), ".omni");
152647
+ return join18(base, "logs");
152648
+ }
152649
+ function getDiagnosticsPath(startedAt) {
152650
+ const safe = startedAt.replace(/:/g, "-");
152651
+ return join18(getDiagnosticsDir(), `update-diagnostics-${safe}.json`);
152652
+ }
152653
+ function tailFileLines(path, maxLines) {
152654
+ if (!existsSync19(path))
152655
+ return [];
152656
+ try {
152657
+ const raw2 = readFileSync19(path, "utf8");
152658
+ const lines = raw2.split(/\r?\n/);
152659
+ if (lines.length > 0 && lines[lines.length - 1] === "")
152660
+ lines.pop();
152661
+ return lines.slice(-maxLines);
152662
+ } catch {
152663
+ return [];
152664
+ }
152665
+ }
152666
+ function collectRecentLogSignals(maxLinesPerStream = 30, logPathsFor = getPm2LogPaths) {
152667
+ const signals2 = [];
152668
+ for (const name of Object.values(PM2_PROCESSES)) {
152669
+ const { out, error: error2 } = logPathsFor(name);
152670
+ const outLines = tailFileLines(out, maxLinesPerStream);
152671
+ if (outLines.length > 0) {
152672
+ signals2.push({ source: name, stream: "out", lines: outLines });
152437
152673
  }
152438
- try {
152439
- const inst = await client.instances.get(instanceId);
152440
- success(`Active instance: ${inst.name} (${instanceId.slice(0, 8)}\u2026)`);
152441
- } catch {
152442
- success(`Active instance: ${instanceId.slice(0, 8)}\u2026`);
152674
+ const errLines = tailFileLines(error2, maxLinesPerStream);
152675
+ if (errLines.length > 0) {
152676
+ signals2.push({ source: name, stream: "error", lines: errLines });
152443
152677
  }
152444
- });
152678
+ }
152679
+ return signals2;
152680
+ }
152681
+ function writeDiagnostics(state, exitCode) {
152682
+ state.exitCode = exitCode;
152683
+ state.finishedAt = new Date().toISOString();
152684
+ if (state.recentLogSignals.length === 0) {
152685
+ state.recentLogSignals = collectRecentLogSignals();
152686
+ }
152687
+ const dir = getDiagnosticsDir();
152688
+ const path = getDiagnosticsPath(state.startedAt);
152689
+ try {
152690
+ if (!existsSync19(dir)) {
152691
+ mkdirSync12(dir, { recursive: true, mode: 448 });
152692
+ }
152693
+ writeFileSync11(path, `${JSON.stringify(state, null, 2)}
152694
+ `, { mode: 384 });
152695
+ return path;
152696
+ } catch {
152697
+ return null;
152698
+ }
152445
152699
  }
152446
152700
 
152447
- // src/commands/voice.ts
152448
- init_config();
152449
- init_output();
152450
- function apiHeaders() {
152451
- const config2 = loadConfig();
152452
- return { "x-api-key": config2.apiKey ?? "", "Content-Type": "application/json" };
152701
+ // src/commands/update.ts
152702
+ var PACKAGE_NAME = "@automagik/omni";
152703
+ var UPDATE_HEALTH_TIMEOUT_MS = 1e4;
152704
+ var VERIFY_POLL_INTERVAL_MS = 500;
152705
+ function resolveChannel(options3, config2) {
152706
+ if (options3.next)
152707
+ return "next";
152708
+ if (options3.stable)
152709
+ return "latest";
152710
+ const saved = (config2 ?? loadConfig()).updateChannel;
152711
+ if (saved === "latest" || saved === "next")
152712
+ return saved;
152713
+ return "latest";
152453
152714
  }
152454
- function apiUrl(path) {
152455
- const config2 = loadConfig();
152456
- return `${config2.apiUrl ?? "http://localhost:8882"}/api/v2${path}`;
152715
+ function persistChannel(channel2) {
152716
+ try {
152717
+ const config2 = loadConfig();
152718
+ config2.updateChannel = channel2;
152719
+ saveConfig(config2);
152720
+ } catch {}
152457
152721
  }
152458
- function createVoiceCommand() {
152459
- const voice = new Command("voice").description("Voice channel operations");
152460
- voice.command("join").description("Join a Discord voice channel").requiredOption("--instance <id>", "Instance ID or name").requiredOption("--channel <channelId>", "Discord voice channel ID").requiredOption("--guild <guildId>", "Discord guild ID").action(async (opts) => {
152461
- try {
152462
- const res = await fetch(apiUrl("/voice/join"), {
152463
- method: "POST",
152464
- headers: apiHeaders(),
152465
- body: JSON.stringify({
152466
- instanceId: opts.instance,
152467
- channelId: opts.channel,
152468
- guildId: opts.guild
152469
- })
152470
- });
152471
- const body = await res.json();
152472
- if (!res.ok) {
152473
- error("Failed to join", body);
152474
- process.exit(1);
152475
- }
152476
- success("Joined voice channel");
152477
- data(body);
152478
- } catch (err2) {
152479
- error("Failed to join voice channel", err2);
152480
- process.exit(1);
152722
+ async function fetchLatestVersion(channel2) {
152723
+ try {
152724
+ const proc = Bun.spawn({
152725
+ cmd: ["bunx", "npm", "view", `${PACKAGE_NAME}@${channel2}`, "version"],
152726
+ stdout: "pipe",
152727
+ stderr: "pipe"
152728
+ });
152729
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
152730
+ if (exitCode !== 0) {
152731
+ return null;
152481
152732
  }
152482
- });
152483
- voice.command("leave").description("Leave a voice session").requiredOption("--session <sessionId>", "Voice session ID").action(async (opts) => {
152484
- try {
152485
- const res = await fetch(apiUrl("/voice/leave"), {
152486
- method: "POST",
152487
- headers: apiHeaders(),
152488
- body: JSON.stringify({ sessionId: opts.session })
152489
- });
152490
- if (!res.ok) {
152491
- error("Failed to leave", await res.json());
152492
- process.exit(1);
152493
- }
152494
- success("Left voice session");
152495
- } catch (err2) {
152496
- error("Failed to leave voice session", err2);
152497
- process.exit(1);
152733
+ const text3 = stdout.trim();
152734
+ return text3.length > 0 ? text3 : null;
152735
+ } catch {
152736
+ return null;
152737
+ }
152738
+ }
152739
+ function getRunningPm2Services() {
152740
+ try {
152741
+ const result = Bun.spawnSync({
152742
+ cmd: ["pm2", "jlist"],
152743
+ stdout: "pipe",
152744
+ stderr: "pipe"
152745
+ });
152746
+ if (result.exitCode !== 0) {
152747
+ return [];
152498
152748
  }
152499
- });
152500
- voice.command("sessions").description("List active voice sessions").action(async () => {
152501
- try {
152502
- const res = await fetch(apiUrl("/voice/sessions"), { headers: apiHeaders() });
152503
- const body = await res.json();
152504
- if (body.items.length === 0) {
152505
- info("No active voice sessions");
152506
- } else {
152507
- data(body.items);
152749
+ const raw2 = new TextDecoder().decode(result.stdout).trim();
152750
+ if (!raw2 || raw2 === "[]")
152751
+ return [];
152752
+ const pm2Names = new Set(Object.values(PM2_PROCESSES));
152753
+ const list2 = JSON.parse(raw2);
152754
+ const running = new Set;
152755
+ for (const proc of list2) {
152756
+ const name = proc.name;
152757
+ if (name && pm2Names.has(name) && proc.pm2_env?.status === "online") {
152758
+ running.add(name);
152508
152759
  }
152509
- } catch (err2) {
152510
- error("Failed to list sessions", err2);
152511
- process.exit(1);
152512
152760
  }
152761
+ return [...running];
152762
+ } catch {
152763
+ return [];
152764
+ }
152765
+ }
152766
+ async function installLatest(channel2) {
152767
+ const proc = Bun.spawn({
152768
+ cmd: ["bun", "add", "-g", "--force", "--no-cache", `${PACKAGE_NAME}@${channel2}`],
152769
+ stdin: "inherit",
152770
+ stdout: "inherit",
152771
+ stderr: "inherit",
152772
+ env: process.env
152513
152773
  });
152514
- voice.command("stream <sessionId>").description("Stream voice audio via WebSocket").option("--format <format>", "Audio format: opus or pcm", "opus").option("--user <userId>", "Filter to specific user").option("--save <dir>", "Save audio frames to directory").option("--events-only", "Show only control events, no audio stats").action(async (sessionId, opts) => {
152515
- const config2 = loadConfig();
152516
- if (!config2.apiKey || !config2.apiUrl) {
152517
- error("Not authenticated. Run: omni auth login");
152518
- process.exit(1);
152519
- }
152520
- const wsUrl = config2.apiUrl.replace(/^http/, "ws");
152521
- let url = `${wsUrl}/api/v2/voice/stream/${sessionId}?api_key=${config2.apiKey}&format=${opts.format}`;
152522
- if (opts.user)
152523
- url += `&user=${opts.user}`;
152524
- info(`Connecting to voice stream: ${sessionId}`);
152525
- info(`Format: ${opts.format} | User filter: ${opts.user || "all"}`);
152526
- const ws = new WebSocket(url);
152527
- ws.binaryType = "arraybuffer";
152528
- const stats = new Map;
152529
- let totalFrames = 0;
152530
- const startTime = Date.now();
152531
- let saveDir = "";
152532
- if (opts.save) {
152533
- const { mkdirSync: mkdirSync13 } = await import("fs");
152534
- saveDir = opts.save;
152535
- mkdirSync13(saveDir, { recursive: true });
152536
- info(`Saving audio to: ${saveDir}`);
152774
+ const exitCode = await proc.exited;
152775
+ return exitCode === 0;
152776
+ }
152777
+ async function restartPm2Services(processNames) {
152778
+ const serverConfig = loadServerConfig();
152779
+ const cliConfig = loadLocalRuntimeConfig();
152780
+ const runtimeEnv = buildRuntimeEnv(serverConfig, cliConfig);
152781
+ let allSucceeded = true;
152782
+ for (const name of processNames) {
152783
+ const proc = Bun.spawn({
152784
+ cmd: ["pm2", "restart", name],
152785
+ stdout: "pipe",
152786
+ stderr: "pipe",
152787
+ env: { ...process.env, ...runtimeEnv }
152788
+ });
152789
+ const exitCode = await proc.exited;
152790
+ if (exitCode !== 0) {
152791
+ allSucceeded = false;
152537
152792
  }
152538
- ws.onopen = () => {
152539
- success("Connected to voice stream");
152540
- };
152541
- const handleControl = (data2) => {
152542
- const msg = JSON.parse(data2);
152543
- const ts = new Date().toLocaleTimeString();
152544
- const label = msg.userId || msg.sessionId || "";
152545
- info(`[${ts}] ${String(msg.type)}: ${String(label)}`);
152546
- };
152547
- ws.onmessage = async (ev2) => {
152548
- if (typeof ev2.data === "string") {
152549
- handleControl(ev2.data);
152550
- return;
152551
- }
152552
- const buf = Buffer.from(ev2.data);
152553
- const userIdLen = buf[0] ?? 0;
152554
- const userId = buf.subarray(1, 1 + userIdLen).toString("utf8");
152555
- const audio = buf.subarray(1 + userIdLen);
152556
- let s2 = stats.get(userId);
152557
- if (!s2) {
152558
- s2 = { frames: 0, bytes: 0 };
152559
- stats.set(userId, s2);
152560
- const ts = new Date().toLocaleTimeString();
152561
- info(`[${ts}] New speaker: ${userId}`);
152562
- }
152563
- s2.frames++;
152564
- s2.bytes += audio.length;
152565
- totalFrames++;
152566
- if (saveDir) {
152567
- const { appendFileSync: appendFileSync2 } = await import("fs");
152568
- appendFileSync2(`${saveDir}/${userId}.opus`, audio);
152569
- }
152570
- if (!opts.eventsOnly && totalFrames % 500 === 0) {
152571
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
152572
- const lines = [];
152573
- for (const [uid2, st] of stats) {
152574
- lines.push(` ${uid2}: ${st.frames} frames, ${(st.bytes / 1024).toFixed(1)}KB`);
152575
- }
152576
- info(`[${elapsed}s] ${totalFrames} frames total
152577
- ${lines.join(`
152578
- `)}`);
152579
- }
152580
- };
152581
- ws.onerror = () => {
152582
- error("WebSocket error");
152583
- };
152584
- ws.onclose = (ev2) => {
152585
- info(`Disconnected: code=${ev2.code} ${ev2.reason}`);
152586
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
152587
- info(`Session lasted ${elapsed}s, ${totalFrames} total frames`);
152588
- for (const [uid2, st] of stats) {
152589
- info(` ${uid2}: ${st.frames} frames, ${(st.bytes / 1024).toFixed(1)}KB`);
152793
+ }
152794
+ return allSucceeded;
152795
+ }
152796
+ function normalizeVersion(version2) {
152797
+ return version2.split("+")[0] ?? version2;
152798
+ }
152799
+ function decideVerify(args) {
152800
+ if (args.skipReason !== undefined) {
152801
+ return { kind: "skipped", reason: args.skipReason };
152802
+ }
152803
+ const cliVersion = normalizeVersion(args.latest);
152804
+ if (args.healthBody === null) {
152805
+ return { kind: "health-unreachable", apiPort: args.apiPort };
152806
+ }
152807
+ const serverVersion = args.healthBody.version ? normalizeVersion(args.healthBody.version) : null;
152808
+ if (!serverVersion || serverVersion !== cliVersion) {
152809
+ return { kind: "version-mismatch", cliVersion, serverVersion };
152810
+ }
152811
+ if (!args.keyValid) {
152812
+ return { kind: "auth-invalid" };
152813
+ }
152814
+ return { kind: "ok", cliVersion, serverVersion };
152815
+ }
152816
+ function updateErrorVersionMismatch(cli, server) {
152817
+ return `Server version mismatch: cli=v${cli} server=v${server ?? "unknown"}. Run: omni doctor`;
152818
+ }
152819
+ var UPDATE_ERROR_AUTH_INVALID = "Auth key invalid after restart. Run: omni doctor --fix";
152820
+ async function fetchHealthBody(apiPort, timeoutMs) {
152821
+ const url = getHealthCheckUrl(apiPort);
152822
+ const deadline = Date.now() + timeoutMs;
152823
+ while (Date.now() < deadline) {
152824
+ try {
152825
+ const resp = await fetch(url, {
152826
+ signal: AbortSignal.timeout(1500),
152827
+ headers: { "Accept-Encoding": "identity" }
152828
+ });
152829
+ if (resp.ok) {
152830
+ const body = await resp.json();
152831
+ return body;
152590
152832
  }
152591
- process.exit(0);
152592
- };
152593
- process.on("SIGINT", () => {
152594
- info("Closing stream...");
152595
- ws.close();
152596
- });
152597
- await new Promise(() => {});
152598
- });
152599
- return voice;
152833
+ } catch {}
152834
+ await Bun.sleep(VERIFY_POLL_INTERVAL_MS);
152835
+ }
152836
+ return null;
152600
152837
  }
152601
-
152602
- // src/commands/webhooks.ts
152603
- import { readFileSync as readFileSync19 } from "fs";
152604
- init_zod();
152605
- init_output();
152606
- var SIGNATURE_ALGORITHMS = ["hmac-sha256", "hmac-sha1", "token-match"];
152607
- var signatureSecretSchema = exports_external.string().min(8, "at least 8 characters").max(512, "at most 512 characters");
152608
- var SECRET_SOURCE_FLAGS = "--signature-secret, --signature-secret-env, --signature-secret-stdin";
152609
- async function readSecretFromStdin() {
152610
- if (process.stdin.isTTY) {
152611
- throw new Error('--signature-secret-stdin requires piped stdin (e.g. `printf %s "$SECRET" | omni webhooks ...`)');
152838
+ async function validateStoredKey(apiPort) {
152839
+ const cliConfig = loadLocalRuntimeConfig();
152840
+ if (!cliConfig.apiKey) {
152841
+ return false;
152612
152842
  }
152613
- const chunks = [];
152614
- for await (const chunk of process.stdin) {
152615
- chunks.push(Buffer.from(chunk));
152843
+ const baseUrl = cliConfig.apiUrl ?? `http://localhost:${apiPort}`;
152844
+ try {
152845
+ const client = createOmniClient({ baseUrl, apiKey: cliConfig.apiKey, cliVersion: VERSION });
152846
+ const result = await client.auth.validate();
152847
+ return result.valid === true;
152848
+ } catch {
152849
+ return false;
152616
152850
  }
152617
- return Buffer.concat(chunks).toString("utf-8");
152618
152851
  }
152619
- async function resolveSignatureSecret(options3, readStdin = readSecretFromStdin) {
152620
- const sources = [
152621
- options3.signatureSecret !== undefined,
152622
- options3.signatureSecretEnv !== undefined,
152623
- options3.signatureSecretStdin === true
152624
- ].filter(Boolean).length;
152625
- if (sources === 0)
152626
- return;
152627
- if (sources > 1) {
152628
- throw new Error(`Use only one of ${SECRET_SOURCE_FLAGS}`);
152852
+ function printVerifyBanner(latest) {
152853
+ console.log(`${source_default.green("\u2713")} CLI: v${latest}`);
152854
+ console.log(`${source_default.green("\u2713")} Server: v${latest} (healthy)`);
152855
+ console.log(`${source_default.green("\u2713")} Auth: key valid`);
152856
+ }
152857
+ function printVerifySkippedBanner(latest) {
152858
+ console.log(`${source_default.green("\u2713")} CLI: v${latest}`);
152859
+ console.log(`${source_default.yellow("-")} Server: v${latest} (skipped)`);
152860
+ console.log(`${source_default.yellow("-")} Auth: skipped`);
152861
+ }
152862
+ function detectParallelNpmGlobalInstall(deps) {
152863
+ const npmRootFn = deps?.npmRoot ?? defaultNpmRoot;
152864
+ const existsFn = deps?.exists ?? existsSync20;
152865
+ let root;
152866
+ try {
152867
+ root = npmRootFn();
152868
+ } catch {
152869
+ return { detected: false, skipped: "npm-root-failed" };
152629
152870
  }
152630
- let raw2;
152631
- let origin;
152632
- if (options3.signatureSecretEnv !== undefined) {
152633
- const name = options3.signatureSecretEnv;
152634
- if (!name)
152635
- throw new Error("--signature-secret-env requires a variable name");
152636
- raw2 = process.env[name];
152637
- origin = `environment variable ${name}`;
152638
- if (raw2 === undefined || raw2 === "") {
152639
- throw new Error(`${origin} is not set or empty`);
152640
- }
152641
- } else if (options3.signatureSecretStdin) {
152642
- raw2 = (await readStdin()).replace(/\r?\n$/, "");
152643
- origin = "stdin";
152644
- if (!raw2)
152645
- throw new Error("no secret received on stdin");
152646
- } else {
152647
- raw2 = options3.signatureSecret;
152648
- origin = "--signature-secret";
152871
+ if (root === null || root.length === 0) {
152872
+ return { detected: false, skipped: "npm-not-on-path" };
152649
152873
  }
152650
- const parsed = signatureSecretSchema.safeParse(raw2);
152651
- if (!parsed.success) {
152652
- const reason = parsed.error.issues.map((issue) => issue.message).join("; ");
152653
- throw new Error(`Invalid signature secret from ${origin}: ${reason}`);
152874
+ const candidate = join19(root, "@automagik", "omni");
152875
+ if (existsFn(candidate)) {
152876
+ return { detected: true, path: candidate };
152654
152877
  }
152655
- return parsed.data;
152878
+ return { detected: false };
152656
152879
  }
152657
- function buildSignatureConfig(options3) {
152658
- const { signatureAlgorithm, signatureHeader, signaturePrefix } = options3;
152659
- if (!signatureAlgorithm && !signatureHeader && !signaturePrefix)
152660
- return;
152661
- if (!signatureAlgorithm || !signatureHeader) {
152662
- error("--signature-algorithm and --signature-header must be provided together");
152880
+ function defaultNpmRoot() {
152881
+ try {
152882
+ const result = Bun.spawnSync({
152883
+ cmd: ["npm", "root", "-g"],
152884
+ stdout: "pipe",
152885
+ stderr: "pipe",
152886
+ timeout: 5000
152887
+ });
152888
+ if (result.exitCode !== 0)
152889
+ return null;
152890
+ const text3 = new TextDecoder().decode(result.stdout).trim();
152891
+ return text3.length > 0 ? text3 : null;
152892
+ } catch {
152893
+ return null;
152663
152894
  }
152664
- const algorithm = SIGNATURE_ALGORITHMS.find((a2) => a2 === signatureAlgorithm);
152665
- if (!algorithm) {
152666
- error(`Invalid --signature-algorithm; expected one of: ${SIGNATURE_ALGORITHMS.join(", ")}`);
152895
+ }
152896
+ async function runLegacyCleanup(skipList) {
152897
+ const cleanupSpinner = ora("Checking for legacy artifacts to clean up...").start();
152898
+ const report = await cleanupLegacyArtifacts(skipList);
152899
+ cleanupSpinner.stop();
152900
+ for (const outcome of report.outcomes) {
152901
+ if (outcome.state === "ran" && outcome.summary.length > 0) {
152902
+ console.log(outcome.summary);
152903
+ }
152667
152904
  }
152668
- if (algorithm === "token-match" && signaturePrefix) {
152669
- error("--signature-prefix is not applicable to token-match (the header carries the secret verbatim)");
152905
+ return report;
152906
+ }
152907
+ var OMNI_UPDATE_SKIP_MAINTENANCE_ENV = "OMNI_UPDATE_SKIP_MAINTENANCE";
152908
+ function resolveMaintenanceSkipReason(args) {
152909
+ if (!args.verifyOk)
152910
+ return "verify-failed";
152911
+ if (args.skipMaintenance === true)
152912
+ return "cli-flag";
152913
+ const raw2 = args.env[OMNI_UPDATE_SKIP_MAINTENANCE_ENV];
152914
+ if (raw2 !== undefined && raw2 !== "" && raw2 !== "0" && raw2.toLowerCase() !== "false") {
152915
+ return "env";
152670
152916
  }
152671
- return { algorithm, header: signatureHeader, prefix: signaturePrefix };
152917
+ return null;
152672
152918
  }
152673
- var eventTypeMappingSchema = exports_external.discriminatedUnion("source", [
152674
- exports_external.object({
152675
- source: exports_external.literal("header"),
152676
- header: exports_external.string().min(1, "header must be 1-200 characters").max(200, "header must be 1-200 characters")
152677
- }),
152678
- exports_external.object({
152679
- source: exports_external.literal("body"),
152680
- path: exports_external.string().min(1, "path must be 1-200 characters").max(200, "path must be 1-200 characters")
152681
- })
152682
- ]);
152683
- var EVENT_TYPE_MAPPING_HINT = 'expected {"source":"header","header":"X-GitHub-Event"} or {"source":"body","path":"event"}';
152684
- function resolveEventTypeMapping(raw2) {
152685
- if (raw2 === undefined)
152686
- return;
152687
- let text3 = raw2;
152688
- if (raw2.startsWith("@")) {
152689
- const path = raw2.slice(1);
152690
- try {
152691
- text3 = readFileSync19(path, "utf-8");
152692
- } catch (err2) {
152693
- const reason = err2 instanceof Error ? err2.message : "Unknown error";
152694
- throw new Error(`Cannot read --event-type-mapping file '${path}': ${reason}`);
152695
- }
152919
+ function formatMaintenanceSummary(report) {
152920
+ const { ok: ok3, warn: warn2, fail } = report.summary;
152921
+ return `Maintenance: ${ok3} ok, ${warn2} warn, ${fail} fail`;
152922
+ }
152923
+ async function runPostUpdateMaintenance(args) {
152924
+ if (args.skipReason !== null) {
152925
+ return { outcome: "skipped", durationMs: 0, skipReason: args.skipReason };
152696
152926
  }
152697
- let parsed;
152927
+ const impl = args.runDoctorImpl ?? runDoctor;
152928
+ const startedAt = Date.now();
152698
152929
  try {
152699
- parsed = JSON.parse(text3);
152700
- } catch {
152701
- throw new Error(`Invalid JSON for --event-type-mapping; ${EVENT_TYPE_MAPPING_HINT}`);
152930
+ const doctorReport = await impl({ json: true, dryRun: true });
152931
+ return {
152932
+ outcome: "completed",
152933
+ durationMs: Date.now() - startedAt,
152934
+ doctorReport
152935
+ };
152936
+ } catch (err2) {
152937
+ return {
152938
+ outcome: "failed",
152939
+ durationMs: Date.now() - startedAt,
152940
+ error: err2 instanceof Error ? err2.message : String(err2)
152941
+ };
152702
152942
  }
152703
- const result = eventTypeMappingSchema.safeParse(parsed);
152704
- if (!result.success) {
152705
- const reason = result.error.issues.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
152706
- throw new Error(`Invalid --event-type-mapping: ${reason}; ${EVENT_TYPE_MAPPING_HINT}`);
152943
+ }
152944
+ async function restartServicesAndVerify(servicesToRestart, latest, options3, diagnostics, finalize) {
152945
+ const apiPort = loadServerConfig().port;
152946
+ diagnostics.restart.attempted = true;
152947
+ diagnostics.restart.services = [...servicesToRestart];
152948
+ const restartSpinner = ora("Restarting services...").start();
152949
+ const restartSucceeded = await restartPm2Services(servicesToRestart);
152950
+ restartSpinner.stop();
152951
+ diagnostics.restart.succeeded = restartSucceeded;
152952
+ if (!restartSucceeded) {
152953
+ warn(`omni CLI updated to v${latest}, but one or more service restarts failed. Run \`omni status\`.`);
152954
+ finalize(1);
152955
+ }
152956
+ let cleanupReport = null;
152957
+ if (options3.legacyCleanup) {
152958
+ cleanupReport = await runLegacyCleanup(options3.skipList);
152959
+ diagnostics.cleanups = cleanupReport;
152960
+ }
152961
+ if (options3.skipVerify) {
152962
+ const result2 = decideVerify({ skipReason: "no-verify-flag" });
152963
+ diagnostics.verify = result2;
152964
+ printVerifySkippedBanner(latest);
152965
+ if (cleanupReport !== null && !cleanupReport.succeeded) {
152966
+ warn("One or more legacy artifacts could not be cleaned up automatically. " + "See messages above and docs/migration/nats-genie-sidecar-decommission.md.");
152967
+ }
152968
+ const maintenance = await runPostUpdateMaintenance({ skipReason: "verify-failed" });
152969
+ diagnostics.maintenance = maintenance;
152970
+ return;
152971
+ }
152972
+ const verifySpinner = ora("Verifying server version...").start();
152973
+ const healthBody = await fetchHealthBody(apiPort, UPDATE_HEALTH_TIMEOUT_MS);
152974
+ verifySpinner.stop();
152975
+ const keyValid = healthBody !== null ? await validateStoredKey(apiPort) : false;
152976
+ const result = decideVerify({ latest, apiPort, healthBody, keyValid });
152977
+ diagnostics.verify = result;
152978
+ switch (result.kind) {
152979
+ case "ok": {
152980
+ printVerifyBanner(result.cliVersion);
152981
+ if (cleanupReport !== null && !cleanupReport.succeeded) {
152982
+ warn("One or more legacy artifacts could not be cleaned up automatically. " + "See messages above and docs/migration/nats-genie-sidecar-decommission.md.");
152983
+ }
152984
+ const skipReason = resolveMaintenanceSkipReason({
152985
+ verifyOk: true,
152986
+ skipMaintenance: options3.skipMaintenance,
152987
+ env: process.env
152988
+ });
152989
+ const maintenance = await runPostUpdateMaintenance({ skipReason });
152990
+ diagnostics.maintenance = maintenance;
152991
+ if (maintenance.outcome === "completed" && maintenance.doctorReport) {
152992
+ info(formatMaintenanceSummary(maintenance.doctorReport));
152993
+ } else if (maintenance.outcome === "failed") {
152994
+ warn(`Maintenance: skipped (runDoctor failed: ${maintenance.error ?? "unknown error"})`);
152995
+ }
152996
+ return;
152997
+ }
152998
+ case "health-unreachable":
152999
+ warn(`omni CLI updated to v${latest}, but health check failed on port ${result.apiPort}. Run \`omni status\`.`);
153000
+ finalize(1);
153001
+ break;
153002
+ case "version-mismatch":
153003
+ console.error(`${source_default.red("\u2717")} ${updateErrorVersionMismatch(result.cliVersion, result.serverVersion)}`);
153004
+ finalize(1);
153005
+ break;
153006
+ case "auth-invalid":
153007
+ console.error(`${source_default.red("\u2717")} ${UPDATE_ERROR_AUTH_INVALID}`);
153008
+ finalize(1);
153009
+ break;
152707
153010
  }
152708
- return result.data;
152709
153011
  }
152710
- async function buildUpdateSourcePatch(options3) {
152711
- const updates = buildLifecycleUpdates(options3);
152712
- if (options3.name)
152713
- updates.name = options3.name;
152714
- if (options3.description)
152715
- updates.description = options3.description;
152716
- if (options3.idempotencyKeyTemplate)
152717
- updates.idempotencyKeyTemplate = options3.idempotencyKeyTemplate;
152718
- if (options3.strictSchemas !== undefined)
152719
- updates.strictSchemas = options3.strictSchemas;
152720
- if (options3.clearEventTypeMapping && options3.eventTypeMapping !== undefined) {
152721
- throw new Error("Use only one of --event-type-mapping and --clear-event-type-mapping");
153012
+ async function promptConfirm(question) {
153013
+ const rl = createInterface3({
153014
+ input: process.stdin,
153015
+ output: process.stdout
153016
+ });
153017
+ return new Promise((resolve4) => {
153018
+ rl.question(question, (answer) => {
153019
+ rl.close();
153020
+ const trimmed = answer.trim().toLowerCase();
153021
+ resolve4(trimmed === "" || trimmed === "y" || trimmed === "yes");
153022
+ });
153023
+ });
153024
+ }
153025
+ async function runUpdate(options3) {
153026
+ const channel2 = resolveChannel(options3);
153027
+ if (options3.next || options3.stable) {
153028
+ persistChannel(channel2);
152722
153029
  }
152723
- if (options3.clearEventTypeMapping)
152724
- updates.eventTypeMapping = null;
152725
- const eventTypeMapping = resolveEventTypeMapping(options3.eventTypeMapping);
152726
- if (eventTypeMapping !== undefined)
152727
- updates.eventTypeMapping = eventTypeMapping;
152728
- if (options3.enable)
152729
- updates.enabled = true;
152730
- if (options3.disable)
152731
- updates.enabled = false;
152732
- if (options3.clearSignature) {
152733
- updates.signatureConfig = null;
152734
- return updates;
153030
+ const currentClean = VERSION.split("+")[0];
153031
+ const diagnostics = createDiagnostics({ runningVersion: currentClean, channel: channel2 });
153032
+ const finalize = (exitCode) => {
153033
+ const path = writeDiagnostics(diagnostics, exitCode);
153034
+ if (path !== null && process.env.OMNI_UPDATE_DIAGNOSTICS_VERBOSE === "1") {
153035
+ info(`Diagnostics written: ${path}`);
153036
+ }
153037
+ process.exit(exitCode);
153038
+ };
153039
+ const parallelInstall = detectParallelNpmGlobalInstall();
153040
+ diagnostics.parallelNpmGlobal = parallelInstall;
153041
+ if (parallelInstall.detected && parallelInstall.path) {
153042
+ warn(`Parallel npm-global install of ${PACKAGE_NAME} detected at ${parallelInstall.path}. This may shadow the bun-installed binary on PATH. Recommended: npm uninstall -g ${PACKAGE_NAME}`);
152735
153043
  }
152736
- const signatureConfig = buildSignatureConfig(options3);
152737
- if (signatureConfig)
152738
- updates.signatureConfig = signatureConfig;
152739
- const signatureSecret = await resolveSignatureSecret(options3);
152740
- if (signatureSecret)
152741
- updates.signatureSecret = signatureSecret;
152742
- return updates;
152743
- }
152744
- function assertPairedSignatureOnCreate(signatureConfig, signatureSecret) {
152745
- if (signatureConfig === undefined === (signatureSecret === undefined))
152746
- return;
152747
- if (signatureConfig === undefined) {
152748
- throw new Error(`a signature secret (${SECRET_SOURCE_FLAGS}) requires --signature-algorithm and --signature-header`);
153044
+ info(`Channel: ${channel2}${channel2 === "next" ? " (dev builds)" : " (stable)"}`);
153045
+ const versionSpinner = ora(`Checking ${channel2} version of ${PACKAGE_NAME}...`).start();
153046
+ const latest = await fetchLatestVersion(channel2);
153047
+ versionSpinner.stop();
153048
+ diagnostics.registry.latestVersion = latest;
153049
+ if (latest === null) {
153050
+ warn("Could not reach npm registry. Check your network connection and try again.");
153051
+ return finalize(1);
152749
153052
  }
152750
- throw new Error(`--signature-algorithm and --signature-header require a signature secret (${SECRET_SOURCE_FLAGS})`);
152751
- }
152752
- var WINDOW_SEMANTICS = ["future_only", "includes_in_progress"];
152753
- var MUTATION_POLICIES = ["same_id", "new_id"];
152754
- function parseExpectedInterval(raw2) {
152755
- if (raw2 === undefined)
152756
- return;
152757
- const seconds = Number(raw2);
152758
- if (!Number.isInteger(seconds) || seconds < 1) {
152759
- error("--expected-interval must be a positive integer number of seconds");
153053
+ if (currentClean === latest) {
153054
+ success(`Already up to date (v${latest}, channel: ${channel2})`);
153055
+ return finalize(0);
152760
153056
  }
152761
- return seconds;
152762
- }
152763
- function parseWindowSemantics(raw2) {
152764
- if (raw2 === undefined)
152765
- return;
152766
- const value = WINDOW_SEMANTICS.find((v4) => v4 === raw2);
152767
- if (!value) {
152768
- error(`Invalid --window-semantics; expected one of: ${WINDOW_SEMANTICS.join(", ")}`);
153057
+ info(`Update available: v${currentClean} \u2192 v${latest} (${channel2})`);
153058
+ if (!options3.yes) {
153059
+ const confirmed = await promptConfirm(`Update from v${currentClean} to v${latest}? [Y/n] `);
153060
+ if (!confirmed) {
153061
+ info("Update cancelled.");
153062
+ return finalize(0);
153063
+ }
152769
153064
  }
152770
- return value;
152771
- }
152772
- function parseMutationPolicy(raw2) {
152773
- if (raw2 === undefined)
152774
- return;
152775
- const value = MUTATION_POLICIES.find((v4) => v4 === raw2);
152776
- if (!value) {
152777
- error(`Invalid --mutation-policy; expected one of: ${MUTATION_POLICIES.join(", ")}`);
153065
+ const servicesToRestart = options3.restart !== false ? getRunningPm2Services() : [];
153066
+ const installSpinner = ora(`Updating ${PACKAGE_NAME}@${channel2}...`).start();
153067
+ diagnostics.install.attempted = true;
153068
+ diagnostics.install.targetVersion = latest;
153069
+ const installed = await installLatest(channel2);
153070
+ installSpinner.stop();
153071
+ diagnostics.install.succeeded = installed;
153072
+ if (!installed) {
153073
+ warn(`Installation failed. Your current version (v${currentClean}) is still intact.`);
153074
+ return finalize(1);
152778
153075
  }
152779
- return value;
152780
- }
152781
- function buildLifecycleUpdates(options3) {
152782
- if (options3.clearCadence && options3.expectedInterval !== undefined) {
152783
- error("Use only one of --expected-interval and --clear-cadence");
153076
+ const sidecarCleanupExplicit = options3.sidecarCleanup === false;
153077
+ if (sidecarCleanupExplicit) {
153078
+ info("--no-sidecar-cleanup (deprecated alias for --no-legacy-cleanup)");
152784
153079
  }
152785
- const updates = {};
152786
- if (options3.clearCadence)
152787
- updates.expectedIntervalSeconds = null;
152788
- const expectedIntervalSeconds = parseExpectedInterval(options3.expectedInterval);
152789
- if (expectedIntervalSeconds !== undefined)
152790
- updates.expectedIntervalSeconds = expectedIntervalSeconds;
152791
- const windowSemantics = parseWindowSemantics(options3.windowSemantics);
152792
- if (windowSemantics !== undefined)
152793
- updates.windowSemantics = windowSemantics;
152794
- const mutationPolicy = parseMutationPolicy(options3.mutationPolicy);
152795
- if (mutationPolicy !== undefined)
152796
- updates.mutationPolicy = mutationPolicy;
152797
- return updates;
153080
+ const legacyCleanupEnabled = options3.legacyCleanup !== false && options3.sidecarCleanup !== false;
153081
+ const skipList = parseSkipCleanupList(options3.skipCleanup);
153082
+ if (servicesToRestart.length > 0) {
153083
+ await restartServicesAndVerify(servicesToRestart, latest, {
153084
+ legacyCleanup: legacyCleanupEnabled,
153085
+ skipList,
153086
+ skipMaintenance: options3.skipMaintenance === true,
153087
+ skipVerify: options3.verify === false
153088
+ }, diagnostics, finalize);
153089
+ return finalize(0);
153090
+ }
153091
+ diagnostics.verify = decideVerify({ skipReason: "no-restart" });
153092
+ success(`omni updated to v${latest}`);
153093
+ return finalize(0);
152798
153094
  }
152799
- function createWebhooksCommand() {
152800
- const webhooks = new Command("webhooks").description("Manage webhook sources");
152801
- webhooks.command("list").description("List webhook sources").option("--enabled", "Show only enabled sources").option("--disabled", "Show only disabled sources").action(async (options3) => {
152802
- const client = getClient();
152803
- try {
152804
- let enabledFilter;
152805
- if (options3.enabled)
152806
- enabledFilter = true;
152807
- if (options3.disabled)
152808
- enabledFilter = false;
152809
- const result = await client.webhooks.listSources({
152810
- enabled: enabledFilter
152811
- });
152812
- const items = result.map((w) => ({
152813
- id: w.id,
152814
- name: w.name,
152815
- enabled: w.enabled ? "yes" : "no",
152816
- health: w.livenessStatus ?? "-",
152817
- cadence: w.expectedIntervalSeconds != null ? `${w.expectedIntervalSeconds}s` : "-",
152818
- createdAt: w.createdAt
152819
- }));
152820
- list(items, { emptyMessage: "No webhook sources found." });
152821
- } catch (err2) {
152822
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152823
- error(`Failed to list webhook sources: ${message2}`);
152824
- }
152825
- });
152826
- webhooks.command("get <id>").description("Get webhook source details").action(async (id) => {
152827
- const resolvedId = await resolveWebhookId(id);
153095
+ function parseSkipCleanupList(value) {
153096
+ if (!value)
153097
+ return new Set;
153098
+ const names = value.split(",").map((s2) => s2.trim()).filter((s2) => s2.length > 0);
153099
+ return new Set(names);
153100
+ }
153101
+ function createUpdateCommand() {
153102
+ return new Command("update").description(`Update ${PACKAGE_NAME} to the latest version (restart only services already running)`).option("-y, --yes", "Skip confirmation prompts (non-interactive)").option("--no-restart", "Update CLI only; skip service restarts and verification").option("--no-verify", "Restart services but skip the post-restart probe (use when a release ships a broken /api/v2/health and operators need to roll forward)").option("--no-legacy-cleanup", "Skip every registered legacy-artifact cleanup (e.g. nats-reply-sidecar)").option("--no-sidecar-cleanup", "Deprecated alias for --no-legacy-cleanup").option("--skip-cleanup <names>", 'Comma-separated list of legacy-cleanup registry entries to skip (e.g. "nats-reply-sidecar")').option("--next", "Switch to dev builds (npm @next tag) and persist as default").option("--stable", "Switch to stable releases (npm @latest tag) and persist as default").option("--skip-maintenance", `Skip the post-update maintenance hook (read-only \`omni doctor\` sweep). Also honored via the ${OMNI_UPDATE_SKIP_MAINTENANCE_ENV} env var.`).addHelpText("after", `
153103
+ Channels:
153104
+ - stable (default) \u2014 tracks the npm @latest tag, bumped from main branch releases.
153105
+ - next (dev builds) \u2014 tracks the npm @next tag, bumped on every CI-green dev merge.
153106
+
153107
+ Use --next to switch to dev builds; --stable to switch back. The choice is
153108
+ persisted to ~/.omni/config.json under 'updateChannel' so subsequent
153109
+ 'omni update' calls stay on the selected track. Check or change manually:
153110
+ omni config get updateChannel
153111
+ omni config set updateChannel next
153112
+ omni config set updateChannel latest
153113
+
153114
+ Behavior:
153115
+ - Installs the latest CLI package first.
153116
+ - Restarts tracked Omni services only when they were online before the update.
153117
+ - When that restart path runs, update performs 3-step verification:
153118
+ 1. Server version matches the new CLI version (via /api/v2/health).
153119
+ 2. Stored CLI API key still validates against the server.
153120
+ 3. On success, prints:
153121
+ \u2713 CLI: v<latest>
153122
+ \u2713 Server: v<latest> (healthy)
153123
+ \u2713 Auth: key valid
153124
+ - On mismatch, exits non-zero with:
153125
+ "Server version mismatch: cli=v<X> server=v<Y>. Run: omni doctor"
153126
+ - On auth failure, exits non-zero with:
153127
+ "Auth key invalid after restart. Run: omni doctor --fix"
153128
+ - After a successful restart, runs every registered legacy-artifact cleanup.
153129
+ The day-one entry is nats-reply-sidecar: it scans for any legacy
153130
+ nats-reply-sidecar.mjs process (PM2-managed or raw) and stops it. The
153131
+ sidecar was an external workaround for bugs that were fixed in #362;
153132
+ leaving it running causes every agent reply to be delivered twice.
153133
+ Skip every cleanup with --no-legacy-cleanup, or skip a single registry
153134
+ entry with --skip-cleanup=<name1,name2>. The deprecated alias
153135
+ --no-sidecar-cleanup still works and behaves identically. Manual runbook:
153136
+ docs/migration/nats-genie-sidecar-decommission.md
153137
+ - Use --no-restart to skip restart + verification entirely. --no-restart
153138
+ also skips legacy-artifact cleanup; manage those services manually.
153139
+ - Use --no-verify to restart services but skip the post-restart probe.
153140
+ The banner shows the new CLI version on both lines but tags Server /
153141
+ Auth as "(skipped)"; maintenance is auto-skipped because verify never
153142
+ confirmed health.
153143
+ - Detects parallel npm-global installs of ${PACKAGE_NAME} (omni installs
153144
+ via bun-global; an npm-global install hides stale binaries on PATH and
153145
+ confuses 'which omni'). When found, prints a one-line warning naming
153146
+ the offending path; recommended remediation: npm uninstall -g ${PACKAGE_NAME}.
153147
+ - Every invocation writes a diagnostics record to
153148
+ ~/.omni/logs/update-diagnostics-<iso>.json (schemaVersion: 1). Captures
153149
+ install attempt, registry probe, restart outcome, verify result, cleanup
153150
+ registry result, maintenance hook, and a tail of pm2 log signals. Set
153151
+ OMNI_UPDATE_DIAGNOSTICS_VERBOSE=1 to print the path on completion. A
153152
+ failed write never changes the update exit code.
153153
+ - On a successful restart + verify, runs a post-update maintenance hook:
153154
+ a read-only \`omni doctor\` sweep that prints a one-line summary
153155
+ ("Maintenance: <ok> ok, <warn> warn, <fail> fail"). The probe never
153156
+ mutates pm2 / config / DB state (\`omni doctor --fix\` remains the
153157
+ explicit operator action for repair). A failing maintenance call is
153158
+ non-fatal \u2014 exit code stays 0 with a banner warning. Skip with
153159
+ --skip-maintenance or ${OMNI_UPDATE_SKIP_MAINTENANCE_ENV}=1.
153160
+ - Verify runtime health after update with: omni status
153161
+ `).action(runUpdate);
153162
+ }
153163
+
153164
+ // src/commands/use.ts
153165
+ init_output();
153166
+ function createUseCommand() {
153167
+ return new Command("use").description("Set active instance for verb commands").argument("<instance>", "Instance name, ID, or prefix").action(async (instance3) => {
152828
153168
  const client = getClient();
153169
+ const instanceId = await resolveInstanceId(instance3);
152829
153170
  try {
152830
- const source = await client.webhooks.getSource(resolvedId);
152831
- data(source);
153171
+ await client.context.use(instanceId);
152832
153172
  } catch (err2) {
152833
153173
  const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152834
- error(`Failed to get webhook source: ${message2}`);
153174
+ return error(`Failed to set active instance: ${message2}`);
152835
153175
  }
152836
- });
152837
- webhooks.command("create").description("Create a webhook source").requiredOption("--name <name>", "Webhook source name").option("--description <desc>", "Description").option("--disabled", "Create in disabled state").option("--headers <json>", `Expected headers as JSON (e.g., '{"X-Secret": true}')`).option("--signature-algorithm <alg>", "Signature verification: hmac-sha256, hmac-sha1, or token-match").option("--signature-header <name>", "Header carrying the signature/token (e.g., X-Hub-Signature-256)").option("--signature-prefix <prefix>", "Prefix before the hex digest (e.g., 'sha256=')").option("--signature-secret <secret>", "Shared secret for signature verification (write-only). Visible in shell history and process lists \u2014 prefer --signature-secret-env or --signature-secret-stdin").option("--signature-secret-env <VAR>", "Read the shared secret from environment variable VAR").option("--signature-secret-stdin", "Read the shared secret from stdin (trailing newline stripped)").option("--idempotency-key-template <template>", "Delivery-identity key template for redelivery dedup (#958). Placeholders: {source}, {sha256(body)}, " + "{headers.<name>}, {payload.<dot.path>}. Defaults to '{source}:{sha256(body)}'").option("--strict-schemas", "Refuse deliveries whose event type has no enabled registered schema (dead-lettered as " + "schema_not_registered, #1000). Recommended for new sources. Defaults to off").option("--event-type-mapping <json>", "Semantic event-type extraction (#959): inline JSON or @path/to/file.json, e.g. " + `'{"source":"header","header":"X-GitHub-Event"}' or '{"source":"body","path":"event"}'. ` + "A mapped source emits custom.{source}.{event} instead of the collapsed custom.webhook.{source}").option("--expected-interval <seconds>", "Declared cadence: >=1 event or heartbeat per N seconds. Arms liveness supervision (#961)").option("--window-semantics <value>", `Declared window semantics: ${WINDOW_SEMANTICS.join(" or ")}`).option("--mutation-policy <value>", `Declared mutation re-emit policy: ${MUTATION_POLICIES.join(" or ")}`).action(async (options3) => {
152838
- const client = getClient();
152839
153176
  try {
152840
- let expectedHeaders;
152841
- if (options3.headers) {
152842
- try {
152843
- expectedHeaders = JSON.parse(options3.headers);
152844
- } catch {
152845
- error("Invalid JSON for --headers");
152846
- }
152847
- }
152848
- const signatureConfig = buildSignatureConfig(options3);
152849
- const signatureSecret = await resolveSignatureSecret(options3);
152850
- assertPairedSignatureOnCreate(signatureConfig, signatureSecret);
152851
- const source = await client.webhooks.createSource({
152852
- name: options3.name,
152853
- description: options3.description,
152854
- enabled: !options3.disabled,
152855
- expectedHeaders,
152856
- signatureConfig,
152857
- signatureSecret,
152858
- idempotencyKeyTemplate: options3.idempotencyKeyTemplate,
152859
- strictSchemas: options3.strictSchemas,
152860
- eventTypeMapping: resolveEventTypeMapping(options3.eventTypeMapping),
152861
- expectedIntervalSeconds: parseExpectedInterval(options3.expectedInterval),
152862
- windowSemantics: parseWindowSemantics(options3.windowSemantics),
152863
- mutationPolicy: parseMutationPolicy(options3.mutationPolicy)
152864
- });
152865
- const details = {
152866
- id: source.id,
152867
- name: source.name,
152868
- url: `POST /api/v2/webhooks/${source.name}`
152869
- };
152870
- if (signatureConfig) {
152871
- details.publicUrl = `POST /api/v2/webhooks/ingress/${source.name}`;
152872
- }
152873
- success(`Webhook source created: ${source.id}`, details);
152874
- } catch (err2) {
152875
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152876
- error(`Failed to create webhook source: ${message2}`);
153177
+ const inst = await client.instances.get(instanceId);
153178
+ success(`Active instance: ${inst.name} (${instanceId.slice(0, 8)}\u2026)`);
153179
+ } catch {
153180
+ success(`Active instance: ${instanceId.slice(0, 8)}\u2026`);
152877
153181
  }
152878
153182
  });
152879
- webhooks.command("update <id>").description("Update a webhook source").option("--name <name>", "New name").option("--description <desc>", "New description").option("--enable", "Enable the webhook").option("--disable", "Disable the webhook").option("--signature-algorithm <alg>", "Signature verification: hmac-sha256, hmac-sha1, or token-match").option("--signature-header <name>", "Header carrying the signature/token (e.g., X-Hub-Signature-256)").option("--signature-prefix <prefix>", "Prefix before the hex digest (e.g., 'sha256=')").option("--signature-secret <secret>", "Shared secret for signature verification (write-only). Visible in shell history and process lists \u2014 prefer --signature-secret-env or --signature-secret-stdin").option("--signature-secret-env <VAR>", "Read the shared secret from environment variable VAR").option("--signature-secret-stdin", "Read the shared secret from stdin (trailing newline stripped)").option("--clear-signature", "Remove the signature config and stored secret").option("--idempotency-key-template <template>", "Delivery-identity key template for redelivery dedup (#958). Placeholders: {source}, {sha256(body)}, " + "{headers.<name>}, {payload.<dot.path>}").option("--strict-schemas", "Refuse deliveries whose event type has no enabled registered schema (dead-lettered as " + "schema_not_registered, #1000)").option("--no-strict-schemas", "Return the source to opt-in pass-through for unregistered event types").option("--event-type-mapping <json>", "Semantic event-type extraction (#959): inline JSON or @path/to/file.json, e.g. " + `'{"source":"header","header":"X-GitHub-Event"}' or '{"source":"body","path":"event"}'`).option("--clear-event-type-mapping", "Remove the mapping (deliveries fall back to the collapsed custom.webhook.{source} type)").option("--expected-interval <seconds>", "Declared cadence: >=1 event or heartbeat per N seconds. (Re)arms liveness supervision (#961)").option("--clear-cadence", "Remove the declared cadence (disarms liveness supervision)").option("--window-semantics <value>", `Declared window semantics: ${WINDOW_SEMANTICS.join(" or ")}`).option("--mutation-policy <value>", `Declared mutation re-emit policy: ${MUTATION_POLICIES.join(" or ")}`).action(async (id, options3) => {
152880
- const resolvedId = await resolveWebhookId(id);
152881
- const client = getClient();
153183
+ }
153184
+
153185
+ // src/commands/voice.ts
153186
+ init_config();
153187
+ init_output();
153188
+ function apiHeaders() {
153189
+ const config2 = loadConfig();
153190
+ return { "x-api-key": config2.apiKey ?? "", "Content-Type": "application/json" };
153191
+ }
153192
+ function apiUrl(path) {
153193
+ const config2 = loadConfig();
153194
+ return `${config2.apiUrl ?? "http://localhost:8882"}/api/v2${path}`;
153195
+ }
153196
+ function createVoiceCommand() {
153197
+ const voice = new Command("voice").description("Voice channel operations");
153198
+ voice.command("join").description("Join a Discord voice channel").requiredOption("--instance <id>", "Instance ID or name").requiredOption("--channel <channelId>", "Discord voice channel ID").requiredOption("--guild <guildId>", "Discord guild ID").action(async (opts) => {
152882
153199
  try {
152883
- const updates = await buildUpdateSourcePatch(options3);
152884
- const source = await client.webhooks.updateSource(resolvedId, updates);
152885
- success(`Webhook source updated: ${source.id}`, {
152886
- id: source.id,
152887
- name: source.name,
152888
- enabled: source.enabled
153200
+ const res = await fetch(apiUrl("/voice/join"), {
153201
+ method: "POST",
153202
+ headers: apiHeaders(),
153203
+ body: JSON.stringify({
153204
+ instanceId: opts.instance,
153205
+ channelId: opts.channel,
153206
+ guildId: opts.guild
153207
+ })
152889
153208
  });
153209
+ const body = await res.json();
153210
+ if (!res.ok) {
153211
+ error("Failed to join", body);
153212
+ process.exit(1);
153213
+ }
153214
+ success("Joined voice channel");
153215
+ data(body);
152890
153216
  } catch (err2) {
152891
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152892
- error(`Failed to update webhook source: ${message2}`);
153217
+ error("Failed to join voice channel", err2);
153218
+ process.exit(1);
152893
153219
  }
152894
153220
  });
152895
- webhooks.command("delete <id>").description("Delete a webhook source").action(async (id) => {
152896
- const resolvedId = await resolveWebhookId(id);
152897
- const client = getClient();
153221
+ voice.command("leave").description("Leave a voice session").requiredOption("--session <sessionId>", "Voice session ID").action(async (opts) => {
152898
153222
  try {
152899
- await client.webhooks.deleteSource(resolvedId);
152900
- success(`Webhook source deleted: ${resolvedId}`);
153223
+ const res = await fetch(apiUrl("/voice/leave"), {
153224
+ method: "POST",
153225
+ headers: apiHeaders(),
153226
+ body: JSON.stringify({ sessionId: opts.session })
153227
+ });
153228
+ if (!res.ok) {
153229
+ error("Failed to leave", await res.json());
153230
+ process.exit(1);
153231
+ }
153232
+ success("Left voice session");
152901
153233
  } catch (err2) {
152902
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152903
- error(`Failed to delete webhook source: ${message2}`);
153234
+ error("Failed to leave voice session", err2);
153235
+ process.exit(1);
152904
153236
  }
152905
153237
  });
152906
- webhooks.command("trigger").description("Trigger a custom event").requiredOption("--type <type>", "Event type").requiredOption("--payload <json>", "Event payload as JSON").option("--instance <id>", "Instance ID").option("--correlation-id <id>", "Correlation ID").option("--causation-id <event-id>", "Parent event ID (keeps the causality tree for mid-flow emissions, #1072)").action(async (options3) => {
152907
- const client = getClient();
153238
+ voice.command("sessions").description("List active voice sessions").action(async () => {
152908
153239
  try {
152909
- let payload;
152910
- try {
152911
- payload = JSON.parse(options3.payload);
152912
- } catch {
152913
- error("Invalid JSON for --payload");
152914
- return;
153240
+ const res = await fetch(apiUrl("/voice/sessions"), { headers: apiHeaders() });
153241
+ const body = await res.json();
153242
+ if (body.items.length === 0) {
153243
+ info("No active voice sessions");
153244
+ } else {
153245
+ data(body.items);
152915
153246
  }
152916
- const result = await client.webhooks.trigger({
152917
- eventType: options3.type,
152918
- payload,
152919
- instanceId: options3.instance,
152920
- correlationId: options3.correlationId,
152921
- causationId: options3.causationId
152922
- });
152923
- success(`Event triggered: ${result.eventId}`, {
152924
- eventId: result.eventId,
152925
- eventType: result.eventType
152926
- });
152927
153247
  } catch (err2) {
152928
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152929
- error(`Failed to trigger event: ${message2}`);
153248
+ error("Failed to list sessions", err2);
153249
+ process.exit(1);
152930
153250
  }
152931
153251
  });
152932
- webhooks.command("heartbeat <source>").description('Record a connector heartbeat ("ran, zero events found") \u2014 resets the liveness window (#961)').action(async (source) => {
152933
- const client = getClient();
152934
- try {
152935
- const result = await client.webhooks.heartbeat(source);
152936
- success(`Heartbeat recorded for '${result.source}'`, {
152937
- heartbeatAt: result.heartbeatAt,
152938
- livenessStatus: result.livenessStatus ?? "unsupervised",
152939
- expectedIntervalSeconds: result.expectedIntervalSeconds
152940
- });
152941
- } catch (err2) {
152942
- const message2 = err2 instanceof Error ? err2.message : "Unknown error";
152943
- error(`Failed to record heartbeat: ${message2}`);
153252
+ voice.command("stream <sessionId>").description("Stream voice audio via WebSocket").option("--format <format>", "Audio format: opus or pcm", "opus").option("--user <userId>", "Filter to specific user").option("--save <dir>", "Save audio frames to directory").option("--events-only", "Show only control events, no audio stats").action(async (sessionId, opts) => {
153253
+ const config2 = loadConfig();
153254
+ if (!config2.apiKey || !config2.apiUrl) {
153255
+ error("Not authenticated. Run: omni auth login");
153256
+ process.exit(1);
153257
+ }
153258
+ const wsUrl = config2.apiUrl.replace(/^http/, "ws");
153259
+ let url = `${wsUrl}/api/v2/voice/stream/${sessionId}?api_key=${config2.apiKey}&format=${opts.format}`;
153260
+ if (opts.user)
153261
+ url += `&user=${opts.user}`;
153262
+ info(`Connecting to voice stream: ${sessionId}`);
153263
+ info(`Format: ${opts.format} | User filter: ${opts.user || "all"}`);
153264
+ const ws = new WebSocket(url);
153265
+ ws.binaryType = "arraybuffer";
153266
+ const stats = new Map;
153267
+ let totalFrames = 0;
153268
+ const startTime = Date.now();
153269
+ let saveDir = "";
153270
+ if (opts.save) {
153271
+ const { mkdirSync: mkdirSync13 } = await import("fs");
153272
+ saveDir = opts.save;
153273
+ mkdirSync13(saveDir, { recursive: true });
153274
+ info(`Saving audio to: ${saveDir}`);
152944
153275
  }
153276
+ ws.onopen = () => {
153277
+ success("Connected to voice stream");
153278
+ };
153279
+ const handleControl = (data2) => {
153280
+ const msg = JSON.parse(data2);
153281
+ const ts = new Date().toLocaleTimeString();
153282
+ const label = msg.userId || msg.sessionId || "";
153283
+ info(`[${ts}] ${String(msg.type)}: ${String(label)}`);
153284
+ };
153285
+ ws.onmessage = async (ev2) => {
153286
+ if (typeof ev2.data === "string") {
153287
+ handleControl(ev2.data);
153288
+ return;
153289
+ }
153290
+ const buf = Buffer.from(ev2.data);
153291
+ const userIdLen = buf[0] ?? 0;
153292
+ const userId = buf.subarray(1, 1 + userIdLen).toString("utf8");
153293
+ const audio = buf.subarray(1 + userIdLen);
153294
+ let s2 = stats.get(userId);
153295
+ if (!s2) {
153296
+ s2 = { frames: 0, bytes: 0 };
153297
+ stats.set(userId, s2);
153298
+ const ts = new Date().toLocaleTimeString();
153299
+ info(`[${ts}] New speaker: ${userId}`);
153300
+ }
153301
+ s2.frames++;
153302
+ s2.bytes += audio.length;
153303
+ totalFrames++;
153304
+ if (saveDir) {
153305
+ const { appendFileSync: appendFileSync2 } = await import("fs");
153306
+ appendFileSync2(`${saveDir}/${userId}.opus`, audio);
153307
+ }
153308
+ if (!opts.eventsOnly && totalFrames % 500 === 0) {
153309
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
153310
+ const lines = [];
153311
+ for (const [uid2, st] of stats) {
153312
+ lines.push(` ${uid2}: ${st.frames} frames, ${(st.bytes / 1024).toFixed(1)}KB`);
153313
+ }
153314
+ info(`[${elapsed}s] ${totalFrames} frames total
153315
+ ${lines.join(`
153316
+ `)}`);
153317
+ }
153318
+ };
153319
+ ws.onerror = () => {
153320
+ error("WebSocket error");
153321
+ };
153322
+ ws.onclose = (ev2) => {
153323
+ info(`Disconnected: code=${ev2.code} ${ev2.reason}`);
153324
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
153325
+ info(`Session lasted ${elapsed}s, ${totalFrames} total frames`);
153326
+ for (const [uid2, st] of stats) {
153327
+ info(` ${uid2}: ${st.frames} frames, ${(st.bytes / 1024).toFixed(1)}KB`);
153328
+ }
153329
+ process.exit(0);
153330
+ };
153331
+ process.on("SIGINT", () => {
153332
+ info("Closing stream...");
153333
+ ws.close();
153334
+ });
153335
+ await new Promise(() => {});
152945
153336
  });
152946
- return webhooks;
153337
+ return voice;
152947
153338
  }
152948
153339
 
152949
153340
  // src/commands/where.ts
@@ -153438,6 +153829,12 @@ var COMMANDS = [
153438
153829
  helpGroup: "Management",
153439
153830
  helpDescription: "Webhook management"
153440
153831
  },
153832
+ {
153833
+ create: createSourcesCommand,
153834
+ category: "advanced",
153835
+ helpGroup: "Management",
153836
+ helpDescription: "One-command external source onboarding (github, ...)"
153837
+ },
153441
153838
  {
153442
153839
  create: createTurnsCommand,
153443
153840
  category: "advanced",