@interactive-inc/claude-funnel 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -570,40 +570,14 @@ declare class FunnelClaude {
570
570
  */
571
571
  type OnFunnelError = (error: Error, context?: Record<string, unknown>) => void;
572
572
  //#endregion
573
- //#region lib/engine/local-config/dotenv-reader.d.ts
574
- type Deps$10 = {
575
- fs: FunnelFileSystem;
576
- };
577
- /**
578
- * Minimal `.env.local` parser. Supports `KEY=value` lines, blank lines, and
579
- * `#` comments. Strips matching surrounding single or double quotes. No
580
- * interpolation, no `export` prefix — anything fancier should live in a real
581
- * env file loaded by the shell.
582
- */
583
- declare class FunnelDotenvReader {
584
- private readonly fs;
585
- constructor(deps: Deps$10);
586
- read(cwd: string): Record<string, string>;
587
- }
588
- //#endregion
589
573
  //#region lib/engine/local-config/local-config-schema.d.ts
590
574
  declare const connectorSpecSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
591
575
  type: z.ZodLiteral<"slack">;
592
576
  name: z.ZodString;
593
- botToken: z.ZodOptional<z.ZodString>;
594
- appToken: z.ZodOptional<z.ZodString>;
595
577
  minify: z.ZodOptional<z.ZodBoolean>;
596
- env: z.ZodOptional<z.ZodObject<{
597
- botToken: z.ZodOptional<z.ZodString>;
598
- appToken: z.ZodOptional<z.ZodString>;
599
- }, z.core.$strip>>;
600
578
  }, z.core.$strip>, z.ZodObject<{
601
579
  type: z.ZodLiteral<"discord">;
602
580
  name: z.ZodString;
603
- botToken: z.ZodOptional<z.ZodString>;
604
- env: z.ZodOptional<z.ZodObject<{
605
- botToken: z.ZodOptional<z.ZodString>;
606
- }, z.core.$strip>>;
607
581
  }, z.core.$strip>, z.ZodObject<{
608
582
  type: z.ZodLiteral<"gh">;
609
583
  name: z.ZodString;
@@ -618,20 +592,10 @@ declare const channelSpecSchema: z.ZodObject<{
618
592
  connectors: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
619
593
  type: z.ZodLiteral<"slack">;
620
594
  name: z.ZodString;
621
- botToken: z.ZodOptional<z.ZodString>;
622
- appToken: z.ZodOptional<z.ZodString>;
623
595
  minify: z.ZodOptional<z.ZodBoolean>;
624
- env: z.ZodOptional<z.ZodObject<{
625
- botToken: z.ZodOptional<z.ZodString>;
626
- appToken: z.ZodOptional<z.ZodString>;
627
- }, z.core.$strip>>;
628
596
  }, z.core.$strip>, z.ZodObject<{
629
597
  type: z.ZodLiteral<"discord">;
630
598
  name: z.ZodString;
631
- botToken: z.ZodOptional<z.ZodString>;
632
- env: z.ZodOptional<z.ZodObject<{
633
- botToken: z.ZodOptional<z.ZodString>;
634
- }, z.core.$strip>>;
635
599
  }, z.core.$strip>, z.ZodObject<{
636
600
  type: z.ZodLiteral<"gh">;
637
601
  name: z.ZodString;
@@ -651,25 +615,16 @@ declare const profileSpecSchema: z.ZodObject<{
651
615
  type ProfileSpec = z.infer<typeof profileSpecSchema>;
652
616
  declare const localConfigSchema: z.ZodObject<{
653
617
  $schema: z.ZodOptional<z.ZodString>;
618
+ id: z.ZodOptional<z.ZodString>;
654
619
  channels: z.ZodArray<z.ZodObject<{
655
620
  name: z.ZodString;
656
621
  connectors: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
657
622
  type: z.ZodLiteral<"slack">;
658
623
  name: z.ZodString;
659
- botToken: z.ZodOptional<z.ZodString>;
660
- appToken: z.ZodOptional<z.ZodString>;
661
624
  minify: z.ZodOptional<z.ZodBoolean>;
662
- env: z.ZodOptional<z.ZodObject<{
663
- botToken: z.ZodOptional<z.ZodString>;
664
- appToken: z.ZodOptional<z.ZodString>;
665
- }, z.core.$strip>>;
666
625
  }, z.core.$strip>, z.ZodObject<{
667
626
  type: z.ZodLiteral<"discord">;
668
627
  name: z.ZodString;
669
- botToken: z.ZodOptional<z.ZodString>;
670
- env: z.ZodOptional<z.ZodObject<{
671
- botToken: z.ZodOptional<z.ZodString>;
672
- }, z.core.$strip>>;
673
628
  }, z.core.$strip>, z.ZodObject<{
674
629
  type: z.ZodLiteral<"gh">;
675
630
  name: z.ZodString;
@@ -688,10 +643,9 @@ declare const localConfigSchema: z.ZodObject<{
688
643
  }, z.core.$strip>;
689
644
  type LocalConfig = z.infer<typeof localConfigSchema>;
690
645
  declare const LOCAL_CONFIG_FILENAME = "funnel.json";
691
- declare const LOCAL_ENV_FILENAME = ".env.local";
692
646
  //#endregion
693
647
  //#region lib/engine/local-config/local-config.d.ts
694
- type Deps$9 = {
648
+ type Deps$10 = {
695
649
  fs: FunnelFileSystem;
696
650
  };
697
651
  /**
@@ -702,7 +656,7 @@ type Deps$9 = {
702
656
  */
703
657
  declare class FunnelLocalConfig {
704
658
  private readonly fs;
705
- constructor(deps: Deps$9);
659
+ constructor(deps: Deps$10);
706
660
  read(cwd: string): LocalConfig | null;
707
661
  private assertProfilesValid;
708
662
  }
@@ -719,11 +673,9 @@ declare abstract class FunnelTokenPrompter {
719
673
  }
720
674
  //#endregion
721
675
  //#region lib/engine/local-config/local-config-sync.d.ts
722
- type Deps$8 = {
676
+ type Deps$9 = {
723
677
  channels: FunnelChannels;
724
- dotenv: FunnelDotenvReader;
725
678
  prompter: FunnelTokenPrompter;
726
- env?: NodeJS.ProcessEnv;
727
679
  };
728
680
  type ConnectorSyncOutcome = {
729
681
  name: string;
@@ -754,11 +706,9 @@ type LocalConfigSyncResult = {
754
706
  */
755
707
  declare class FunnelLocalConfigSync {
756
708
  private readonly channels;
757
- private readonly dotenv;
758
709
  private readonly prompter;
759
- private readonly env;
760
- constructor(deps: Deps$8);
761
- ensure(channel: ChannelSpec, cwd: string): Promise<LocalConfigSyncResult>;
710
+ constructor(deps: Deps$9);
711
+ ensure(channel: ChannelSpec): Promise<LocalConfigSyncResult>;
762
712
  private ensureConnector;
763
713
  private ensureSlack;
764
714
  private ensureDiscord;
@@ -768,20 +718,33 @@ declare class FunnelLocalConfigSync {
768
718
  private findExistingDiscord;
769
719
  private removeExtras;
770
720
  /**
771
- * Decides how a single token slot is stored in settings.json:
772
- *
773
- * - `env.<field>` reference `{ tokenEnv: "<VAR>" }`; the secret is NOT
774
- * resolved into settings, it stays in the environment / `.env.local` and
775
- * the listener resolves it at start. We still assert the var is set so a
776
- * typo fails loudly here instead of as a dead listener later.
777
- * - literal → `{ token: "<secret>" }`.
778
- * - neither, but a prior value exists → carry it over verbatim (whichever
779
- * form it already was), so a tokenless re-sync is a no-op.
780
- * - nothing at all → prompt for a literal (TTY only; throws otherwise).
721
+ * Decides how a single token slot is stored in settings.json. funnel.json
722
+ * never carries tokens, so the only sources are a value already in
723
+ * settings.json (carried over verbatim, whichever form it was literal or an
724
+ * `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
725
+ * literal (throws when stdin is not a TTY). Either way the secret lands in the
726
+ * repo-scoped settings, never in the repo itself.
781
727
  */
782
728
  private resolveSlot;
783
729
  }
784
730
  //#endregion
731
+ //#region lib/engine/local-config/local-config-writer.d.ts
732
+ type Deps$8 = {
733
+ fs: FunnelFileSystem;
734
+ };
735
+ /**
736
+ * The one path that mutates the repo-committed funnel.json, and it only ever
737
+ * inserts `id`. On first launch a repo has no `id`; funnel generates one and
738
+ * writes it back here so future launches resolve the same `~/.funnel/projects/<id>/`.
739
+ * Idempotent — a no-op once `id` is present. Kept separate from the read-only
740
+ * FunnelLocalConfig so reads stay side-effect free.
741
+ */
742
+ declare class FunnelLocalConfigWriter {
743
+ private readonly fs;
744
+ constructor(deps: Deps$8);
745
+ ensureId(cwd: string, id: string): void;
746
+ }
747
+ //#endregion
785
748
  //#region lib/gateway/publish-schema.d.ts
786
749
  /**
787
750
  * Shared schema for `POST /channels/:channel/publish` — used by both the
@@ -1159,7 +1122,8 @@ declare class FunnelGateway {
1159
1122
  type Deps$3 = {
1160
1123
  channels: FunnelChannels;
1161
1124
  settings: FunnelSettingsReader;
1162
- port?: number; /** SQLite event store file path. Parent directory is created on demand. Defaults to `<os.tmpdir()>/funnel/events.db`. Ignored when `eventLog` is supplied. */
1125
+ port?: number; /** Bind address for `Bun.serve`. Defaults to `127.0.0.1` (loopback only). Set to `0.0.0.0` to expose on the network. */
1126
+ hostname?: string; /** SQLite event store file path. Parent directory is created on demand. Defaults to `<os.tmpdir()>/funnel/events.db`. Ignored when `eventLog` is supplied. */
1163
1127
  dbPath?: string; /** Durable replay log. Defaults to a `SqliteFunnelEventLog` at `dbPath`. Inject a `MemoryFunnelEventLog` (or any `FunnelEventLog`) to swap or disable persistence. */
1164
1128
  eventLog?: FunnelEventLog;
1165
1129
  process?: FunnelProcessRunner;
@@ -1200,6 +1164,7 @@ declare class FunnelGatewayServer {
1200
1164
  private readonly channels;
1201
1165
  private readonly settings;
1202
1166
  private readonly port;
1167
+ private readonly hostname;
1203
1168
  private readonly dbPath;
1204
1169
  private readonly process?;
1205
1170
  private readonly logger;
@@ -1450,8 +1415,8 @@ declare class Funnel {
1450
1415
  get profiles(): FunnelProfiles;
1451
1416
  /** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
1452
1417
  get localConfig(): FunnelLocalConfig;
1453
- /** Parses `.env.local` from a cwd (used by sync to back $VAR references). */
1454
- get dotenv(): FunnelDotenvReader;
1418
+ /** Writes the stable `id` into funnel.json on first launch so state can be scoped to `~/.funnel/projects/<id>/`. */
1419
+ get localConfigWriter(): FunnelLocalConfigWriter;
1455
1420
  /** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
1456
1421
  get tokenPrompter(): FunnelTokenPrompter;
1457
1422
  /** Reconciles funnel.json's channel + connectors with `~/.funnel/settings.json` on launch. */
@@ -1482,7 +1447,8 @@ declare class Funnel {
1482
1447
  * useful for tests, embedding, or custom hosts.
1483
1448
  */
1484
1449
  gatewayServer(options?: {
1485
- port?: number;
1450
+ port?: number; /** Bind address. Defaults to `127.0.0.1` (loopback only). Set to `0.0.0.0` to expose on the network. */
1451
+ hostname?: string;
1486
1452
  dbPath?: string;
1487
1453
  killCompetingSlack?: boolean; /** Override the auth token. Defaults to the persisted gateway.token. Pass "" to disable auth (tests). */
1488
1454
  token?: string; /** Durable replay log. Defaults to a SqliteFunnelEventLog at dbPath; inject a MemoryFunnelEventLog (or any FunnelEventLog) to swap or disable persistence. */
@@ -1843,6 +1809,7 @@ declare class MemoryFunnelEventLog extends FunnelEventLog {
1843
1809
  record(record: FunnelEventRecord): void;
1844
1810
  loadSince(since: number): ReplayableEvent[];
1845
1811
  findMaxOffset(): number;
1812
+ clear(): void;
1846
1813
  close(): void;
1847
1814
  }
1848
1815
  //#endregion
@@ -1917,6 +1884,7 @@ declare class MemoryConnectorDiagnosticLog extends ConnectorDiagnosticLog {
1917
1884
  queryRaw(query: ConnectorRawQuery): StoredRawEvent[];
1918
1885
  queryProcessed(query: ConnectorProcessedQuery): StoredProcessedEvent[];
1919
1886
  queryConnection(query: ConnectorConnectionQuery): StoredConnectionEvent[];
1887
+ clear(): void;
1920
1888
  close(): void;
1921
1889
  }
1922
1890
  //#endregion
@@ -4613,4 +4581,4 @@ ${string}`;
4613
4581
  //#region lib/tui/tui.d.ts
4614
4582
  declare function launchTui(funnel: Funnel): Promise<void>;
4615
4583
  //#endregion
4616
- export { AliveStub, AttachOptions, BroadcastEvent, BroadcastSubscriber, CONNECTOR_CONNECTION_STATUSES, ChannelConfig, ChannelConnectorView, ChannelDeliveryMode, ChannelServerOptions, ChannelSpec, ConnectorConfig, ConnectorConnectionEvent, ConnectorConnectionQuery, ConnectorConnectionRecord, ConnectorConnectionStatus, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, ConnectorProcessedEvent, ConnectorProcessedQuery, ConnectorProcessedRecord, ConnectorQuery, ConnectorRawEvent, ConnectorRawQuery, ConnectorRawRecord, ConnectorSpec, ConnectorSyncOutcome, ConnectorType, DEFAULT_GATEWAY_TOKEN_PATH, DetachOptions, DiscordConnectorConfig, Env, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, FileStat, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEvent, FunnelEventLog, FunnelEventRecord, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, type GatewayEmitInput, type GatewayRouteDeps, type Env$1 as GatewayServerEnv, GhConnectorConfig, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, LaunchOptions, ListListenersResult, ListenerEntry, ListenerOpResult, LocalConfig, LocalConfigSyncResult, LogEntry, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MemoryProcessCall, MemoryProcessHandler, MemoryProcessResponse, MemoryProcessSyncHandler, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, NotifyFn, OnFunnelError, ProcessListStub, ProcessSnapshot, ProfileConfig, ProfileSpec, PublishRequest, PublishResponse, PublishResult, ReplayableEvent, RunOptions, RunResult, SETTINGS_PATH, SETTINGS_VERSION, ScheduleCatchupPolicy, ScheduleConnectorConfig, ScheduleEntry, ScheduleListenerOptions, Settings, SlackConnectorConfig, SlackListenerOptions, SlackProcessed, SlackProcessedEmit, SlackProcessedSkip, SlackRawEvent, SlackSkipReason, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, StoredConnectionEvent, StoredProcessedEvent, StoredRawEvent, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
4584
+ export { AliveStub, AttachOptions, BroadcastEvent, BroadcastSubscriber, CONNECTOR_CONNECTION_STATUSES, ChannelConfig, ChannelConnectorView, ChannelDeliveryMode, ChannelServerOptions, ChannelSpec, ConnectorConfig, ConnectorConnectionEvent, ConnectorConnectionQuery, ConnectorConnectionRecord, ConnectorConnectionStatus, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, ConnectorProcessedEvent, ConnectorProcessedQuery, ConnectorProcessedRecord, ConnectorQuery, ConnectorRawEvent, ConnectorRawQuery, ConnectorRawRecord, ConnectorSpec, ConnectorSyncOutcome, ConnectorType, DEFAULT_GATEWAY_TOKEN_PATH, DetachOptions, DiscordConnectorConfig, Env, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, FileStat, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEvent, FunnelEventLog, FunnelEventRecord, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, type GatewayEmitInput, type GatewayRouteDeps, type Env$1 as GatewayServerEnv, GhConnectorConfig, LOCAL_CONFIG_FILENAME, LaunchOptions, ListListenersResult, ListenerEntry, ListenerOpResult, LocalConfig, LocalConfigSyncResult, LogEntry, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MemoryProcessCall, MemoryProcessHandler, MemoryProcessResponse, MemoryProcessSyncHandler, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, NotifyFn, OnFunnelError, ProcessListStub, ProcessSnapshot, ProfileConfig, ProfileSpec, PublishRequest, PublishResponse, PublishResult, ReplayableEvent, RunOptions, RunResult, SETTINGS_PATH, SETTINGS_VERSION, ScheduleCatchupPolicy, ScheduleConnectorConfig, ScheduleEntry, ScheduleListenerOptions, Settings, SlackConnectorConfig, SlackListenerOptions, SlackProcessed, SlackProcessedEmit, SlackProcessedSkip, SlackRawEvent, SlackSkipReason, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, StoredConnectionEvent, StoredProcessedEvent, StoredRawEvent, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
package/dist/index.js CHANGED
@@ -187,7 +187,7 @@ var FunnelSettingsStore = class extends FunnelSettingsReader {
187
187
  ...settings,
188
188
  version: 1
189
189
  };
190
- this.fs.writeFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`);
190
+ this.fs.writeSecretFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`);
191
191
  }
192
192
  };
193
193
  //#endregion
@@ -887,8 +887,9 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
887
887
  * `fnl claude` reads this when no global --profile preset is used. It picks one
888
888
  * of the declared channels (`--channel <name>` selects by name; otherwise the
889
889
  * first entry wins) and materializes its transport (connectors / delivery) into
890
- * `~/.funnel/settings.json` on launch — token fields in connectors resolve via
891
- * literal / `env.<field>` / TTY prompt.
890
+ * the repo's scoped settings (`~/.funnel/projects/<id>/settings.json`) on launch.
891
+ * Connectors carry no tokens here — a token absent from settings is prompted for
892
+ * at launch (TTY) and saved there, never in the repo.
892
893
  *
893
894
  * The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
894
895
  * the channel: a channel only describes where events come from. `fnl claude`
@@ -896,25 +897,15 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
896
897
  * straight to the launcher and is not persisted into the global profile list.
897
898
  * These profiles are selected by their `channel` binding, not by name.
898
899
  */
899
- const slackEnvSchema = z.object({
900
- botToken: z.string().optional(),
901
- appToken: z.string().optional()
902
- }).optional();
903
900
  const slackConnectorSpecSchema = z.object({
904
901
  type: z.literal("slack"),
905
902
  name: z.string(),
906
- botToken: z.string().optional(),
907
- appToken: z.string().optional(),
908
903
  /** Shrink raw Slack events before fanout. Defaults to true. */
909
- minify: z.boolean().optional(),
910
- env: slackEnvSchema
904
+ minify: z.boolean().optional()
911
905
  });
912
- const discordEnvSchema = z.object({ botToken: z.string().optional() }).optional();
913
906
  const discordConnectorSpecSchema = z.object({
914
907
  type: z.literal("discord"),
915
- name: z.string(),
916
- botToken: z.string().optional(),
917
- env: discordEnvSchema
908
+ name: z.string()
918
909
  });
919
910
  const ghConnectorSpecSchema = z.object({
920
911
  type: z.literal("gh"),
@@ -952,54 +943,18 @@ const profileSpecSchema = z.object({
952
943
  });
953
944
  const localConfigSchema = z.object({
954
945
  $schema: z.string().optional(),
946
+ /**
947
+ * Stable per-repo identifier. funnel writes this on first launch when absent;
948
+ * all funnel state for this repo lives under `~/.funnel/projects/<id>/`, so the
949
+ * repo itself never holds settings or tokens. Committed alongside funnel.json.
950
+ */
951
+ id: z.string().optional(),
955
952
  /** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
956
953
  channels: z.array(channelSpecSchema).min(1),
957
954
  /** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
958
955
  profiles: z.array(profileSpecSchema).optional()
959
956
  });
960
957
  const LOCAL_CONFIG_FILENAME = "funnel.json";
961
- const LOCAL_ENV_FILENAME = ".env.local";
962
- //#endregion
963
- //#region lib/engine/local-config/dotenv-reader.ts
964
- const VARIABLE_LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
965
- const unquote = (value) => {
966
- if (value.length < 2) return value;
967
- const first = value[0];
968
- const last = value[value.length - 1];
969
- if (first === "\"" && last === "\"") return value.slice(1, -1);
970
- if (first === "'" && last === "'") return value.slice(1, -1);
971
- return value;
972
- };
973
- /**
974
- * Minimal `.env.local` parser. Supports `KEY=value` lines, blank lines, and
975
- * `#` comments. Strips matching surrounding single or double quotes. No
976
- * interpolation, no `export` prefix — anything fancier should live in a real
977
- * env file loaded by the shell.
978
- */
979
- var FunnelDotenvReader = class {
980
- fs;
981
- constructor(deps) {
982
- this.fs = deps.fs;
983
- Object.freeze(this);
984
- }
985
- read(cwd) {
986
- const path = join(cwd, LOCAL_ENV_FILENAME);
987
- if (!this.fs.existsSync(path)) return {};
988
- const raw = this.fs.readFileSync(path);
989
- const out = {};
990
- for (const line of raw.split("\n")) {
991
- const trimmed = line.trim();
992
- if (trimmed === "" || trimmed.startsWith("#")) continue;
993
- const match = trimmed.match(VARIABLE_LINE);
994
- if (!match) continue;
995
- const key = match[1];
996
- const value = match[2];
997
- if (!key) continue;
998
- out[key] = unquote(value ?? "");
999
- }
1000
- return out;
1001
- }
1002
- };
1003
958
  //#endregion
1004
959
  //#region lib/engine/local-config/local-config.ts
1005
960
  /**
@@ -1075,27 +1030,22 @@ var FunnelTokenPrompter = class {};
1075
1030
  */
1076
1031
  var FunnelLocalConfigSync = class {
1077
1032
  channels;
1078
- dotenv;
1079
1033
  prompter;
1080
- env;
1081
1034
  constructor(deps) {
1082
1035
  this.channels = deps.channels;
1083
- this.dotenv = deps.dotenv;
1084
1036
  this.prompter = deps.prompter;
1085
- this.env = deps.env ?? process.env;
1086
1037
  Object.freeze(this);
1087
1038
  }
1088
- async ensure(channel, cwd) {
1039
+ async ensure(channel) {
1089
1040
  if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
1090
1041
  if (channel.connectors === void 0) return {
1091
1042
  touched: [],
1092
1043
  removed: []
1093
1044
  };
1094
- const dotenv = this.dotenv.read(cwd);
1095
1045
  const touched = [];
1096
1046
  const touchedIds = /* @__PURE__ */ new Set();
1097
1047
  for (const spec of channel.connectors) {
1098
- const outcome = await this.ensureConnector(channel.name, spec, dotenv);
1048
+ const outcome = await this.ensureConnector(channel.name, spec);
1099
1049
  touched.push({
1100
1050
  name: outcome.name,
1101
1051
  changed: outcome.changed
@@ -1107,26 +1057,20 @@ var FunnelLocalConfigSync = class {
1107
1057
  removed: this.removeExtras(channel.name, touchedIds)
1108
1058
  };
1109
1059
  }
1110
- async ensureConnector(channelName, spec, dotenv) {
1111
- if (spec.type === "slack") return await this.ensureSlack(channelName, spec, dotenv);
1112
- if (spec.type === "discord") return await this.ensureDiscord(channelName, spec, dotenv);
1060
+ async ensureConnector(channelName, spec) {
1061
+ if (spec.type === "slack") return await this.ensureSlack(channelName, spec);
1062
+ if (spec.type === "discord") return await this.ensureDiscord(channelName, spec);
1113
1063
  if (spec.type === "gh") return this.ensureGh(channelName, spec);
1114
1064
  return this.ensureSchedule(channelName, spec);
1115
1065
  }
1116
- async ensureSlack(channelName, spec, dotenv) {
1066
+ async ensureSlack(channelName, spec) {
1117
1067
  const byName = this.findExistingSlack(channelName, spec.name);
1118
1068
  const bot = await this.resolveSlot({
1119
- literal: spec.botToken,
1120
- envVar: spec.env?.botToken,
1121
- dotenv,
1122
1069
  label: `${spec.name}.botToken`,
1123
1070
  existingLiteral: byName?.botToken,
1124
1071
  existingEnv: byName?.botTokenEnv
1125
1072
  });
1126
1073
  const app = await this.resolveSlot({
1127
- literal: spec.appToken,
1128
- envVar: spec.env?.appToken,
1129
- dotenv,
1130
1074
  label: `${spec.name}.appToken`,
1131
1075
  existingLiteral: byName?.appToken,
1132
1076
  existingEnv: byName?.appTokenEnv
@@ -1163,12 +1107,9 @@ var FunnelLocalConfigSync = class {
1163
1107
  changed: true
1164
1108
  };
1165
1109
  }
1166
- async ensureDiscord(channelName, spec, dotenv) {
1110
+ async ensureDiscord(channelName, spec) {
1167
1111
  const byName = this.findExistingDiscord(channelName, spec.name);
1168
1112
  const bot = await this.resolveSlot({
1169
- literal: spec.botToken,
1170
- envVar: spec.env?.botToken,
1171
- dotenv,
1172
1113
  label: `${spec.name}.botToken`,
1173
1114
  existingLiteral: byName?.botToken,
1174
1115
  existingEnv: byName?.botTokenEnv
@@ -1267,30 +1208,14 @@ var FunnelLocalConfigSync = class {
1267
1208
  return stale.map((c) => c.name);
1268
1209
  }
1269
1210
  /**
1270
- * Decides how a single token slot is stored in settings.json:
1271
- *
1272
- * - `env.<field>` reference `{ tokenEnv: "<VAR>" }`; the secret is NOT
1273
- * resolved into settings, it stays in the environment / `.env.local` and
1274
- * the listener resolves it at start. We still assert the var is set so a
1275
- * typo fails loudly here instead of as a dead listener later.
1276
- * - literal → `{ token: "<secret>" }`.
1277
- * - neither, but a prior value exists → carry it over verbatim (whichever
1278
- * form it already was), so a tokenless re-sync is a no-op.
1279
- * - nothing at all → prompt for a literal (TTY only; throws otherwise).
1211
+ * Decides how a single token slot is stored in settings.json. funnel.json
1212
+ * never carries tokens, so the only sources are a value already in
1213
+ * settings.json (carried over verbatim, whichever form it was literal or an
1214
+ * `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
1215
+ * literal (throws when stdin is not a TTY). Either way the secret lands in the
1216
+ * repo-scoped settings, never in the repo itself.
1280
1217
  */
1281
1218
  async resolveSlot(input) {
1282
- if (input.literal !== void 0 && input.envVar !== void 0) throw new Error(`${input.label} is set both as a literal and as env.${input.label.split(".").pop()}; pick one`);
1283
- if (input.envVar !== void 0 && input.envVar !== "") {
1284
- if (!this.env[input.envVar] && !input.dotenv[input.envVar]) throw new Error(`${input.label} references env var "${input.envVar}" but it is not set in process env or .env.local`);
1285
- return {
1286
- token: void 0,
1287
- tokenEnv: input.envVar
1288
- };
1289
- }
1290
- if (input.literal !== void 0 && input.literal !== "") return {
1291
- token: input.literal,
1292
- tokenEnv: void 0
1293
- };
1294
1219
  if (input.existingEnv !== void 0) return {
1295
1220
  token: void 0,
1296
1221
  tokenEnv: input.existingEnv
@@ -1306,6 +1231,44 @@ var FunnelLocalConfigSync = class {
1306
1231
  }
1307
1232
  };
1308
1233
  //#endregion
1234
+ //#region lib/engine/local-config/local-config-writer.ts
1235
+ const isRecord = (value) => {
1236
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1237
+ };
1238
+ const withIdFirst = (config, id) => {
1239
+ const ordered = {};
1240
+ if (config.$schema !== void 0) ordered.$schema = config.$schema;
1241
+ ordered.id = id;
1242
+ for (const key of Object.keys(config)) {
1243
+ if (key === "$schema" || key === "id") continue;
1244
+ ordered[key] = config[key];
1245
+ }
1246
+ return ordered;
1247
+ };
1248
+ /**
1249
+ * The one path that mutates the repo-committed funnel.json, and it only ever
1250
+ * inserts `id`. On first launch a repo has no `id`; funnel generates one and
1251
+ * writes it back here so future launches resolve the same `~/.funnel/projects/<id>/`.
1252
+ * Idempotent — a no-op once `id` is present. Kept separate from the read-only
1253
+ * FunnelLocalConfig so reads stay side-effect free.
1254
+ */
1255
+ var FunnelLocalConfigWriter = class {
1256
+ fs;
1257
+ constructor(deps) {
1258
+ this.fs = deps.fs;
1259
+ Object.freeze(this);
1260
+ }
1261
+ ensureId(cwd, id) {
1262
+ const path = join(cwd, LOCAL_CONFIG_FILENAME);
1263
+ if (!this.fs.existsSync(path)) return;
1264
+ const parsed = JSON.parse(this.fs.readFileSync(path));
1265
+ if (!isRecord(parsed)) return;
1266
+ if (typeof parsed.id === "string" && parsed.id !== "") return;
1267
+ const ordered = withIdFirst(parsed, id);
1268
+ this.fs.writeFileSync(path, `${JSON.stringify(ordered, null, 2)}\n`);
1269
+ }
1270
+ };
1271
+ //#endregion
1309
1272
  //#region lib/engine/logger/memory-logger.ts
1310
1273
  var MemoryFunnelLogger = class extends FunnelLogger {
1311
1274
  file = null;
@@ -3084,6 +3047,13 @@ const gatewayRoutes = factory$1.createApp().get("/health", ...healthHandler).get
3084
3047
  //#endregion
3085
3048
  //#region lib/gateway/gateway-server.ts
3086
3049
  const DEFAULT_PORT = 9742;
3050
+ const DEFAULT_HOST = "127.0.0.1";
3051
+ const LOOPBACK_HOSTS = new Set([
3052
+ "127.0.0.1",
3053
+ "localhost",
3054
+ "::1",
3055
+ "::ffff:127.0.0.1"
3056
+ ]);
3087
3057
  const defaultDbPath = () => join(funnelTmpDir(), "events.db");
3088
3058
  const defaultOnError = () => {};
3089
3059
  /**
@@ -3100,6 +3070,7 @@ var FunnelGatewayServer = class {
3100
3070
  channels;
3101
3071
  settings;
3102
3072
  port;
3073
+ hostname;
3103
3074
  dbPath;
3104
3075
  process;
3105
3076
  logger;
@@ -3119,6 +3090,7 @@ var FunnelGatewayServer = class {
3119
3090
  this.channels = deps.channels;
3120
3091
  this.settings = deps.settings;
3121
3092
  this.port = deps.port ?? DEFAULT_PORT;
3093
+ this.hostname = deps.hostname ?? DEFAULT_HOST;
3122
3094
  this.dbPath = deps.dbPath ?? defaultDbPath();
3123
3095
  this.process = deps.process;
3124
3096
  this.logger = deps.logger;
@@ -3163,10 +3135,12 @@ var FunnelGatewayServer = class {
3163
3135
  }
3164
3136
  async start() {
3165
3137
  if (this.server) return this.server;
3138
+ if (!this.token && !LOOPBACK_HOSTS.has(this.hostname)) this.logger?.warn("gateway auth is disabled on a non-loopback bind — every endpoint is reachable without a token", { hostname: this.hostname });
3166
3139
  const app = this.buildApp();
3167
3140
  this.startedAt = this.nowMs();
3168
3141
  this.server = Bun.serve({
3169
3142
  port: this.port,
3143
+ hostname: this.hostname,
3170
3144
  development: false,
3171
3145
  fetch: (request, server) => this.handleFetch(request, server, app),
3172
3146
  websocket: {
@@ -3670,10 +3644,10 @@ var Funnel = class Funnel {
3670
3644
  if (!this.memos.localConfig) this.memos.localConfig = new FunnelLocalConfig({ fs: this.fs });
3671
3645
  return this.memos.localConfig;
3672
3646
  }
3673
- /** Parses `.env.local` from a cwd (used by sync to back $VAR references). */
3674
- get dotenv() {
3675
- if (!this.memos.dotenv) this.memos.dotenv = new FunnelDotenvReader({ fs: this.fs });
3676
- return this.memos.dotenv;
3647
+ /** Writes the stable `id` into funnel.json on first launch so state can be scoped to `~/.funnel/projects/<id>/`. */
3648
+ get localConfigWriter() {
3649
+ if (!this.memos.localConfigWriter) this.memos.localConfigWriter = new FunnelLocalConfigWriter({ fs: this.fs });
3650
+ return this.memos.localConfigWriter;
3677
3651
  }
3678
3652
  /** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
3679
3653
  get tokenPrompter() {
@@ -3684,7 +3658,6 @@ var Funnel = class Funnel {
3684
3658
  get localConfigSync() {
3685
3659
  if (!this.memos.localConfigSync) this.memos.localConfigSync = new FunnelLocalConfigSync({
3686
3660
  channels: this.channels,
3687
- dotenv: this.dotenv,
3688
3661
  prompter: this.tokenPrompter
3689
3662
  });
3690
3663
  return this.memos.localConfigSync;
@@ -3772,6 +3745,7 @@ var Funnel = class Funnel {
3772
3745
  channels: this.channels,
3773
3746
  settings: this.store,
3774
3747
  port: options.port,
3748
+ hostname: options.hostname,
3775
3749
  dbPath: options.dbPath,
3776
3750
  eventLog: options.eventLog,
3777
3751
  process: this.process,
@@ -4080,6 +4054,9 @@ var MemoryFunnelEventLog = class extends FunnelEventLog {
4080
4054
  for (const event of this.events) if (event.offset > max) max = event.offset;
4081
4055
  return max;
4082
4056
  }
4057
+ clear() {
4058
+ this.events.length = 0;
4059
+ }
4083
4060
  close() {}
4084
4061
  };
4085
4062
  //#endregion
@@ -4488,6 +4465,11 @@ var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4488
4465
  return true;
4489
4466
  }), query.limit);
4490
4467
  }
4468
+ clear() {
4469
+ this.raws.length = 0;
4470
+ this.processeds.length = 0;
4471
+ this.connections.length = 0;
4472
+ }
4491
4473
  close() {}
4492
4474
  };
4493
4475
  const matches$1 = (event, query) => {
@@ -5132,7 +5114,7 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
5132
5114
  if (local) {
5133
5115
  const picked = query.channel !== void 0 ? local.channels.find((c) => c.name === query.channel) : local.channels[0];
5134
5116
  if (!picked) throw new HTTPException(404, { message: query.channel ? `channel "${query.channel}" is not declared in funnel.json` : `funnel.json declares no channels` });
5135
- const synced = await funnel.localConfigSync.ensure(picked, cwd);
5117
+ const synced = await funnel.localConfigSync.ensure(picked);
5136
5118
  for (const outcome of synced.touched) if (outcome.changed) await funnel.listeners.restart(picked.name, outcome.name);
5137
5119
  else await funnel.listeners.start(picked.name, outcome.name);
5138
5120
  for (const name of synced.removed) await funnel.listeners.stop(picked.name, name);
@@ -7972,4 +7954,4 @@ async function launchTui(funnel) {
7972
7954
  });
7973
7955
  }
7974
7956
  //#endregion
7975
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
7957
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };