@seedcord/cli 0.2.1-next.0 → 0.3.0-next.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/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { n as SEEDCORD_CONFIG_FILENAMES, t as version } from "./src-DdCl8vfD.mjs";
1
+ import { n as SEEDCORD_CONFIG_FILENAMES, t as version } from "./src-DP3zjFwE.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { Command } from "@commander-js/extra-typings";
4
4
  import { Logger, LoggerChannelRegistry, StrictEventEmitter } from "@seedcord/services";
5
5
  import { SeedcordErrorCode, isSeedcordError } from "@seedcord/errors";
6
6
  import { existsSync } from "node:fs";
7
- import { SeedcordError } from "@seedcord/errors/internal";
7
+ import { SeedcordError, validateDiscordToken } from "@seedcord/errors/internal";
8
8
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { createJiti } from "jiti";
@@ -13,14 +13,18 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
13
13
  import { spawn } from "node:child_process";
14
14
  import { SeedcordBrand } from "@seedcord/types/internal";
15
15
  import { assertNever, formatFilePath, isTsOrJsFile } from "@seedcord/utils";
16
- import { ApplicationCommandOptionType, ApplicationCommandType } from "discord-api-types/v10";
16
+ import { ApplicationCommandOptionType, ApplicationCommandType, Routes } from "discord-api-types/v10";
17
17
  import { routeLeavesOf } from "@seedcord/utils/internal";
18
+ import { Envapter } from "envapt";
19
+ import { REST } from "@discordjs/rest";
20
+ import chalk from "chalk";
21
+ import { autocompleteMultiselect, cancel, confirm, intro, isCancel, log, multiselect, note, outro, select, spinner } from "@clack/prompts";
22
+ import { createInterface } from "node:readline";
18
23
  import { Box, Text, measureElement, render, useAnimation, useInput, useWindowSize } from "ink";
19
24
  import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
20
25
  import Spinner from "ink-spinner";
21
26
  import { createServer, createServerModuleRunner, defineConfig, mergeConfig } from "vite";
22
27
  import { EvaluatedModules } from "vite/module-runner";
23
- import chalk from "chalk";
24
28
  import { minimatch } from "minimatch";
25
29
 
26
30
  //#region src/core/BaseCommand.ts
@@ -469,7 +473,7 @@ var BuildCommand = class extends BaseCommand {
469
473
  };
470
474
 
471
475
  //#endregion
472
- //#region src/commands/codegen/RegistryGenerator.ts
476
+ //#region src/commands/codegen/AugmentationBuilder.ts
473
477
  const KIND_BY_TYPE = {
474
478
  [ApplicationCommandOptionType.String]: "string",
475
479
  [ApplicationCommandOptionType.Integer]: "integer",
@@ -481,17 +485,23 @@ const KIND_BY_TYPE = {
481
485
  [ApplicationCommandOptionType.Mentionable]: "mentionable",
482
486
  [ApplicationCommandOptionType.Attachment]: "attachment"
483
487
  };
488
+ function mapEmojis(emojiConfig) {
489
+ const emojis = {};
490
+ for (const [key, value] of Object.entries(emojiConfig)) emojis[key] = Array.isArray(value) ? "tuple" : "string";
491
+ return emojis;
492
+ }
484
493
  /**
485
- * Builds the generated registry from each command's `toJSON()`. Chat-input commands become the slash-option
486
- * tables, context-menu commands contribute their name to the user or message set. Reads the builder back
487
- * because djs erases option names at the type level.
494
+ * Builds the generated augmentations from each command's `toJSON()` plus the emoji config. Chat-input
495
+ * commands become the slash-option tables, context-menu commands contribute their name to the user or
496
+ * message set, and each emoji key becomes a kind tag. Reads the builder back because djs erases option
497
+ * names at the type level.
488
498
  */
489
- var RegistryGenerator = class {
499
+ var AugmentationBuilder = class {
490
500
  logger;
491
501
  constructor(logger) {
492
502
  this.logger = logger;
493
503
  }
494
- generate(commands) {
504
+ generate(commands, emojiConfig) {
495
505
  const slash = {};
496
506
  const sourceByRoute = /* @__PURE__ */ new Map();
497
507
  const sourceByUserName = /* @__PURE__ */ new Map();
@@ -504,11 +514,13 @@ var RegistryGenerator = class {
504
514
  }
505
515
  const userContextMenus = [...sourceByUserName.keys()];
506
516
  const messageContextMenus = [...sourceByMessageName.keys()];
507
- this.logger.debug(`Generated ${Object.keys(slash).length} slash route(s), ${userContextMenus.length} user and ${messageContextMenus.length} message context-menu command(s)`);
517
+ const emojis = mapEmojis(emojiConfig);
518
+ this.logger.debug(`Generated ${Object.keys(slash).length} slash route(s), ${userContextMenus.length} user and ${messageContextMenus.length} message context-menu command(s), ${Object.keys(emojis).length} emoji(s)`);
508
519
  return {
509
520
  slash,
510
521
  userContextMenus,
511
- messageContextMenus
522
+ messageContextMenus,
523
+ emojis
512
524
  };
513
525
  }
514
526
  collectSlash(json, sourceFile, slash, sourceByRoute) {
@@ -555,24 +567,20 @@ var RegistryGenerator = class {
555
567
  };
556
568
 
557
569
  //#endregion
558
- //#region src/commands/codegen/renderRegistry.ts
570
+ //#region src/commands/codegen/renderAugmentation.ts
559
571
  const BANNER = `// Generated by \`seedcord codegen\`. Do not edit by hand.
560
- // Run \`seedcord codegen\` after changing a command's options.`;
561
- const DISCLAIMER = ` /**
562
- * These option types come from your command source. Redeploy your commands to Discord after regenerating,
563
- * or an interaction from a stale command can return null for an option this file types as non-null.
564
- */`;
572
+ // Run \`seedcord codegen\` after changing your commands or emoji config.`;
565
573
  const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
566
574
  const CONTROL_CHAR = 32;
567
575
  const HEX_RADIX = 16;
568
576
  /**
569
- * Renders the generated registry into the committed `declare module 'seedcord'` file, the slash option
570
- * tables plus the user and message context-menu name registries, all in one augmentation block. Keys are
577
+ * Renders the built augmentations into the committed `declare module 'seedcord'` file, the slash option
578
+ * tables, the user and message context-menu name registries, and the emoji map, all in one block. Keys are
571
579
  * sorted so the output is stable across filesystems. The result is byte-stable so `seedcord codegen --check`
572
580
  * can diff it directly.
573
581
  */
574
- function renderRegistry(registry) {
575
- return `${BANNER}\n\ndeclare module 'seedcord' {\n${DISCLAIMER}\n interface SlashOptionRegistry {\n${renderSlashRows(registry.slash)}\n }\n interface UserContextMenuRegistry {\n${renderContextMenuRows(registry.userContextMenus)}\n }\n interface MessageContextMenuRegistry {\n${renderContextMenuRows(registry.messageContextMenus)}\n }\n}\n\nexport {};\n`;
582
+ function renderAugmentation(registry) {
583
+ return `${BANNER}\n\ndeclare module 'seedcord' {\n interface SlashOptionRegistry {\n${renderSlashRows(registry.slash)}\n }\n interface UserContextMenuRegistry {\n${renderContextMenuRows(registry.userContextMenus)}\n }\n interface MessageContextMenuRegistry {\n${renderContextMenuRows(registry.messageContextMenus)}\n }\n interface EmojiMap {\n${renderEmojiRows(registry.emojis)}\n }\n}\n\nexport {};\n`;
576
584
  }
577
585
  function renderSlashRows(tables) {
578
586
  return Object.entries(tables).sort(([first], [second]) => compare(first, second)).map(([route, options]) => ` ${renderKey(route)}: ${renderRow(options)};`).join("\n");
@@ -580,6 +588,9 @@ function renderSlashRows(tables) {
580
588
  function renderContextMenuRows(names) {
581
589
  return [...names].sort(compare).map((name) => ` ${renderName(name)}: true;`).join("\n");
582
590
  }
591
+ function renderEmojiRows(emojis) {
592
+ return Object.entries(emojis).sort(([first], [second]) => compare(first, second)).map(([key, kind]) => ` ${renderName(key)}: '${kind === "tuple" ? "guild" : "application"}';`).join("\n");
593
+ }
583
594
  function renderRow(options) {
584
595
  const entries = Object.entries(options);
585
596
  if (entries.length === 0) return "{}";
@@ -622,11 +633,11 @@ function compare(first, second) {
622
633
 
623
634
  //#endregion
624
635
  //#region src/commands/codegen/CodegenRunner.ts
625
- const OUTPUT_FILENAME = "command-registry.gen.ts";
636
+ const OUTPUT_FILENAME = "seedcord-gen.d.ts";
626
637
  /**
627
638
  * Orchestrates `seedcord codegen`. Locates and loads the CLI config, imports the user's Seedcord instance to
628
- * read its commands directory, scans and instantiates each command for its `toJSON()`, then renders the
629
- * registry and either writes it or, under `--check`, diffs it against the committed file.
639
+ * read its commands directory and emoji config, scans and instantiates each command for its `toJSON()`, then
640
+ * renders the augmentations and either writes them or, under `--check`, diffs against the committed file.
630
641
  */
631
642
  var CodegenRunner = class CodegenRunner {
632
643
  locator;
@@ -643,11 +654,12 @@ var CodegenRunner = class CodegenRunner {
643
654
  }
644
655
  static create(logger) {
645
656
  const moduleLoader = new RuntimeModuleLoader();
646
- return new CodegenRunner(new ConfigLocator(logger), new ConfigLoader(moduleLoader, logger), moduleLoader, new RegistryGenerator(logger), logger);
657
+ return new CodegenRunner(new ConfigLocator(logger), new ConfigLoader(moduleLoader, logger), moduleLoader, new AugmentationBuilder(logger), logger);
647
658
  }
648
659
  async run(check) {
649
660
  const config = await this.loadConfig();
650
- const rendered = renderRegistry(this.generator.generate(await this.scan(config)));
661
+ const { commands, emojis } = await this.scan(config);
662
+ const rendered = renderAugmentation(this.generator.generate(commands, emojis));
651
663
  const outputPath = resolve(config.root, OUTPUT_FILENAME);
652
664
  if (check) {
653
665
  await this.check(rendered, outputPath);
@@ -659,11 +671,13 @@ var CodegenRunner = class CodegenRunner {
659
671
  return this.configLoader.load(this.locator.locate());
660
672
  }
661
673
  async scan(config) {
662
- const commandsDir = await this.resolveCommandsDir(config);
663
- if (!commandsDir) return [];
674
+ const { commandsDir, emojis } = await this.resolveInstance(config);
664
675
  const commands = [];
665
- await this.walk(commandsDir, commands, /* @__PURE__ */ new Set(), true);
666
- return commands;
676
+ if (commandsDir) await this.walk(commandsDir, commands, /* @__PURE__ */ new Set(), true);
677
+ return {
678
+ commands,
679
+ emojis
680
+ };
667
681
  }
668
682
  async walk(dir, commands, seen, isRoot) {
669
683
  let entries;
@@ -692,12 +706,15 @@ var CodegenRunner = class CodegenRunner {
692
706
  }
693
707
  }
694
708
  }
695
- async resolveCommandsDir(config) {
709
+ async resolveInstance(config) {
696
710
  this.logger.info("Loading instance to resolve the commands directory");
697
711
  const instance = resolveDefaultExport(await this.moduleLoader.importModule(config.instance));
698
712
  if (!this.isSeedcordInstance(instance)) throw new SeedcordError(SeedcordErrorCode.CliInstanceInvalid);
699
713
  const commandsPath = instance.config.bot.commands.path;
700
- return commandsPath ? resolve(process.cwd(), commandsPath) : void 0;
714
+ return {
715
+ commandsDir: commandsPath ? resolve(process.cwd(), commandsPath) : void 0,
716
+ emojis: instance.config.bot.emojis ?? {}
717
+ };
701
718
  }
702
719
  commandJsonOf(exported) {
703
720
  if (typeof exported !== "function") return void 0;
@@ -729,11 +746,11 @@ var CodegenRunner = class CodegenRunner {
729
746
  async write(rendered, outputPath) {
730
747
  await mkdir(dirname(outputPath), { recursive: true });
731
748
  await writeFile(outputPath, rendered, "utf8");
732
- this.logger.info(`Command registry written to ${outputPath}`);
749
+ this.logger.info(`Augmentations written to ${outputPath}`);
733
750
  }
734
751
  async check(rendered, outputPath) {
735
752
  if ((existsSync(outputPath) ? await readFile(outputPath, "utf8") : "") === rendered) return;
736
- this.logger.error(`Command registry is out of date. Run \`seedcord codegen\` and commit ${outputPath}.`);
753
+ this.logger.error(`Augmentations are out of date. Run \`seedcord codegen\` and commit ${outputPath}.`);
737
754
  process.exitCode = 1;
738
755
  }
739
756
  };
@@ -743,11 +760,11 @@ var CodegenRunner = class CodegenRunner {
743
760
  var CodegenCommand = class extends BaseCommand {
744
761
  runner;
745
762
  constructor() {
746
- super("codegen", "Generate the typed command registry (slash options and context menus) from your commands", "CLI:Codegen");
763
+ super("codegen", "Generate typed augmentations (slash options, context menus, emojis) from your commands and config", "CLI:Codegen");
747
764
  this.runner = CodegenRunner.create(this.logger);
748
765
  }
749
766
  register(program) {
750
- program.command(this.name).description(this.description).option("--check", "Verify the committed registry is up to date instead of writing it").action(async (options) => {
767
+ program.command(this.name).description(this.description).option("--check", "Verify the committed augmentations are up to date instead of writing them").action(async (options) => {
751
768
  try {
752
769
  await this.runner.run(options.check ?? false);
753
770
  } catch (error) {
@@ -759,6 +776,547 @@ var CodegenCommand = class extends BaseCommand {
759
776
  }
760
777
  };
761
778
 
779
+ //#endregion
780
+ //#region src/core/interactive.ts
781
+ function isInteractive(opts, hasActionFlags) {
782
+ return !hasActionFlags && process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI && !opts.yes;
783
+ }
784
+
785
+ //#endregion
786
+ //#region src/commands/commands/classify.ts
787
+ /**
788
+ * Selects which deployed guild commands `commands --clean` would delete. An overlap is a guild command whose
789
+ * name also exists globally, so it renders twice in the picker. Under `purge` every guild command is selected.
790
+ * Global commands are never passed in here, so they are never deleted.
791
+ */
792
+ function classifyGuildCommands(globalNames, guilds, purge) {
793
+ const flagged = [];
794
+ for (const { guildId, guildName, commands } of guilds) for (const command of commands) if (purge) flagged.push({
795
+ guildId,
796
+ guildName,
797
+ id: command.id,
798
+ name: command.name,
799
+ reason: "purge"
800
+ });
801
+ else if (globalNames.has(command.name)) flagged.push({
802
+ guildId,
803
+ guildName,
804
+ id: command.id,
805
+ name: command.name,
806
+ reason: "overlap"
807
+ });
808
+ return flagged;
809
+ }
810
+
811
+ //#endregion
812
+ //#region src/commands/commands/CleanRunner.ts
813
+ const GUILD_PAGE = 200;
814
+ var CleanRunner = class CleanRunner {
815
+ makeRest;
816
+ constructor(makeRest) {
817
+ this.makeRest = makeRest;
818
+ }
819
+ static create() {
820
+ return new CleanRunner((token) => new REST({ version: "10" }).setToken(token));
821
+ }
822
+ async resolveTargets(scope, token) {
823
+ if (scope.purge && scope.allGuilds) throw new SeedcordError(SeedcordErrorCode.CliCleanPurgeAllGuilds);
824
+ if (!scope.allGuilds && scope.guildIds.length === 0) throw new SeedcordError(SeedcordErrorCode.CliCleanNoGuilds);
825
+ const rest = this.makeRest(token);
826
+ return {
827
+ appId: await this.resolveAppId(rest),
828
+ guilds: scope.allGuilds ? await this.fetchBotGuilds(rest) : scope.guildIds.map((id) => ({
829
+ id,
830
+ name: id
831
+ }))
832
+ };
833
+ }
834
+ async listBotGuilds(token) {
835
+ return this.fetchBotGuilds(this.makeRest(token));
836
+ }
837
+ async scanGuilds(token, appId, guilds, purge) {
838
+ const rest = this.makeRest(token);
839
+ const globalNames = await this.fetchGlobalNames(rest, appId);
840
+ const buckets = [];
841
+ const skipped = [];
842
+ let scannedCommandCount = 0;
843
+ for (const guild of guilds) try {
844
+ const commands = (await rest.get(Routes.applicationGuildCommands(appId, guild.id))).map((command) => ({
845
+ id: command.id,
846
+ name: command.name
847
+ }));
848
+ scannedCommandCount += commands.length;
849
+ buckets.push({
850
+ guildId: guild.id,
851
+ guildName: guild.name,
852
+ commands
853
+ });
854
+ } catch (error) {
855
+ skipped.push({
856
+ guildId: guild.id,
857
+ guildName: guild.name,
858
+ reason: error instanceof Error ? error.message : "Unknown error"
859
+ });
860
+ }
861
+ return {
862
+ flagged: classifyGuildCommands(globalNames, buckets, purge),
863
+ skipped,
864
+ scannedGuildCount: buckets.length,
865
+ scannedCommandCount,
866
+ globalCommandCount: globalNames.size
867
+ };
868
+ }
869
+ async applyDeletions(token, appId, flagged) {
870
+ const rest = this.makeRest(token);
871
+ let deleted = 0;
872
+ const failed = [];
873
+ for (const command of flagged) try {
874
+ await rest.delete(Routes.applicationGuildCommand(appId, command.guildId, command.id));
875
+ deleted++;
876
+ } catch (error) {
877
+ failed.push({
878
+ command,
879
+ reason: error instanceof Error ? error.message : "Unknown error"
880
+ });
881
+ }
882
+ return {
883
+ deleted,
884
+ failed
885
+ };
886
+ }
887
+ async resolveAppId(rest) {
888
+ try {
889
+ return (await rest.get(Routes.currentApplication())).id;
890
+ } catch (error) {
891
+ const reason = error instanceof Error ? error.message : "Unknown error";
892
+ throw new SeedcordError(SeedcordErrorCode.CliCleanAppFetchFailed, [reason]);
893
+ }
894
+ }
895
+ async fetchBotGuilds(rest) {
896
+ const guilds = [];
897
+ let after;
898
+ for (;;) {
899
+ const query = new URLSearchParams({ limit: String(GUILD_PAGE) });
900
+ if (after) query.set("after", after);
901
+ const page = await rest.get(Routes.userGuilds(), { query });
902
+ for (const guild of page) guilds.push({
903
+ id: guild.id,
904
+ name: guild.name
905
+ });
906
+ after = page.at(-1)?.id;
907
+ if (page.length < GUILD_PAGE || !after) break;
908
+ }
909
+ return guilds;
910
+ }
911
+ async fetchGlobalNames(rest, appId) {
912
+ const global = await rest.get(Routes.applicationCommands(appId));
913
+ return new Set(global.map((command) => command.name));
914
+ }
915
+ };
916
+
917
+ //#endregion
918
+ //#region src/core/format.ts
919
+ function plural(count, singular, pluralForm = `${singular}s`) {
920
+ return `${count} ${count === 1 ? singular : pluralForm}`;
921
+ }
922
+ function includesIgnoreCase(text, search) {
923
+ return text.toLowerCase().includes(search.trim().toLowerCase());
924
+ }
925
+
926
+ //#endregion
927
+ //#region src/core/prompts/requireValue.ts
928
+ function requireValue(value) {
929
+ if (isCancel(value)) {
930
+ cancel("Cancelled.");
931
+ throw new SeedcordError(SeedcordErrorCode.CliCancelled);
932
+ }
933
+ return value;
934
+ }
935
+
936
+ //#endregion
937
+ //#region src/core/prompts/pickFromList.ts
938
+ const DEFAULT_MAX_ITEMS = 10;
939
+ const SEARCH_THRESHOLD = 12;
940
+ /**
941
+ * Multi-pick from a set, sized to the list: a plain checkbox multiselect for a short list, search-as-you-type
942
+ * for a long one. Returns the selected ids, or throws CliCancelled if the prompt is cancelled.
943
+ */
944
+ async function pickFromList(opts) {
945
+ const options = opts.items.map((item) => ({
946
+ value: item.id,
947
+ label: item.name
948
+ }));
949
+ const maxItems = opts.maxItems ?? DEFAULT_MAX_ITEMS;
950
+ if (opts.items.length <= SEARCH_THRESHOLD) return requireValue(await multiselect({
951
+ message: opts.message,
952
+ options,
953
+ maxItems,
954
+ required: false
955
+ }));
956
+ return requireValue(await autocompleteMultiselect({
957
+ message: opts.message,
958
+ options,
959
+ maxItems,
960
+ placeholder: "Type to search...",
961
+ filter: (search, option) => includesIgnoreCase(option.label ?? "", search) || includesIgnoreCase(String(option.value), search)
962
+ }));
963
+ }
964
+
965
+ //#endregion
966
+ //#region src/core/prompts/index.ts
967
+ async function select$1(opts) {
968
+ return requireValue(await select(opts));
969
+ }
970
+ async function confirm$1(opts) {
971
+ return requireValue(await confirm(opts));
972
+ }
973
+
974
+ //#endregion
975
+ //#region src/commands/commands/confirm.ts
976
+ async function readlineAsk(question) {
977
+ const rl = createInterface({
978
+ input: process.stdin,
979
+ output: process.stdout
980
+ });
981
+ try {
982
+ return await new Promise((resolve) => rl.question(question, resolve));
983
+ } finally {
984
+ rl.close();
985
+ }
986
+ }
987
+ /**
988
+ * Gates a destructive delete behind typing the exact count, so a mistyped, empty, or non-numeric answer
989
+ * aborts with nothing deleted.
990
+ */
991
+ async function confirmCount(count, logger, ask = readlineAsk) {
992
+ logger.warn(`About to delete ${count} guild command(s). This cannot be undone.`);
993
+ return (await ask(`Type ${count} to confirm (anything else aborts) `)).trim() === String(count);
994
+ }
995
+
996
+ //#endregion
997
+ //#region src/commands/commands/cleanPresenters.ts
998
+ function groupByGuild(flagged) {
999
+ const groups = /* @__PURE__ */ new Map();
1000
+ for (const command of flagged) {
1001
+ const group = groups.get(command.guildId) ?? {
1002
+ guildName: command.guildName,
1003
+ commands: []
1004
+ };
1005
+ group.commands.push(command);
1006
+ groups.set(command.guildId, group);
1007
+ }
1008
+ return [...groups.values()];
1009
+ }
1010
+ function skippedSummary(skipped) {
1011
+ return `Could not read ${plural(skipped.length, "guild")} (${skipped.map((s) => s.guildName).join(", ")}).`;
1012
+ }
1013
+ function emptyLines(scan, outcome) {
1014
+ return {
1015
+ "all-skipped": {
1016
+ info: [],
1017
+ outro: `Could not read any of the ${plural(scan.skipped.length, "guild")}, nothing was scanned.`
1018
+ },
1019
+ "no-commands": {
1020
+ info: [],
1021
+ outro: `Scanned ${plural(scan.scannedGuildCount, "guild")} with no guild commands deployed. Nothing to clean.`
1022
+ },
1023
+ "no-globals": {
1024
+ info: ["This app has no global commands, so nothing can duplicate one.", "Re-run and choose a full reset to remove guild commands."],
1025
+ outro: "Nothing to clean."
1026
+ },
1027
+ "no-overlaps": {
1028
+ info: [`Scanned ${plural(scan.scannedCommandCount, "guild command")} across ${plural(scan.scannedGuildCount, "guild")}. None duplicate a global command.`, "Re-run and choose a full reset to remove all of them."],
1029
+ outro: "Nothing to clean."
1030
+ }
1031
+ }[outcome];
1032
+ }
1033
+ /** Renders the clean flow as clack framed output for an interactive terminal. */
1034
+ var InteractivePresenter = class {
1035
+ async status(message, task, done) {
1036
+ const status = spinner();
1037
+ status.start(message);
1038
+ try {
1039
+ return await task();
1040
+ } finally {
1041
+ status.stop(done ?? message);
1042
+ }
1043
+ }
1044
+ preview(scan) {
1045
+ note(groupByGuild(scan.flagged).map((group) => `${chalk.bold(group.guildName)}\n${group.commands.map((c) => ` ${c.name}`).join("\n")}`).join("\n\n"), `Will delete ${plural(scan.flagged.length, "guild command")} (nothing deleted yet)`);
1046
+ if (scan.skipped.length > 0) log.warn(skippedSummary(scan.skipped));
1047
+ }
1048
+ largeBotGuard(guildCount) {
1049
+ return confirm$1({
1050
+ message: `The bot is in ${guildCount} guilds. Scan all of them? This makes ${guildCount} requests.`,
1051
+ initialValue: false
1052
+ });
1053
+ }
1054
+ confirmDelete(count, skippedCount) {
1055
+ const skips = skippedCount > 0 ? ` ${plural(skippedCount, "guild")} could not be read.` : "";
1056
+ return confirm$1({
1057
+ message: `Delete ${plural(count, "guild command")}? This cannot be undone.${skips}`,
1058
+ initialValue: false
1059
+ });
1060
+ }
1061
+ result(deletion, skipped) {
1062
+ for (const failure of deletion.failed) log.warn(`Could not delete ${failure.command.name} in ${failure.command.guildName} (${failure.reason}).`);
1063
+ if (skipped.length > 0) log.warn(skippedSummary(skipped));
1064
+ outro(`Deleted ${plural(deletion.deleted, "guild command")}. Global commands untouched.`);
1065
+ }
1066
+ nothingToClean(scan, outcome) {
1067
+ const lines = emptyLines(scan, outcome);
1068
+ for (const line of lines.info) log.info(line);
1069
+ outro(lines.outro);
1070
+ }
1071
+ dryRunHint() {
1072
+ outro("Dry run, nothing was deleted.");
1073
+ }
1074
+ };
1075
+ /** Renders the clean flow as plain logger lines for the flag, CI, and non-TTY path. */
1076
+ var FlagPresenter = class {
1077
+ logger;
1078
+ yes;
1079
+ constructor(logger, yes) {
1080
+ this.logger = logger;
1081
+ this.yes = yes;
1082
+ }
1083
+ status(message, task) {
1084
+ this.logger.info(message);
1085
+ return task();
1086
+ }
1087
+ preview(scan) {
1088
+ this.logger.info(chalk.bold(`${plural(scan.flagged.length, "guild command")} selected for deletion`));
1089
+ for (const group of groupByGuild(scan.flagged)) {
1090
+ this.logger.info(chalk.cyan(group.guildName));
1091
+ for (const command of group.commands) this.logger.info(` ${command.name}`);
1092
+ }
1093
+ for (const skip of scan.skipped) this.logger.warn(`Skipped ${skip.guildName} (${skip.reason}).`);
1094
+ }
1095
+ largeBotGuard(guildCount) {
1096
+ if (this.yes) return Promise.resolve(true);
1097
+ return Promise.reject(new SeedcordError(SeedcordErrorCode.CliCleanLargeBotUnconfirmed, [guildCount]));
1098
+ }
1099
+ confirmDelete(count, skippedCount) {
1100
+ if (this.yes) return Promise.resolve(true);
1101
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.reject(new SeedcordError(SeedcordErrorCode.CliCleanApplyNeedsYes));
1102
+ if (skippedCount > 0) this.logger.warn(`${plural(skippedCount, "guild")} could not be read and were not scanned.`);
1103
+ return confirmCount(count, this.logger);
1104
+ }
1105
+ result(deletion, skipped) {
1106
+ for (const failure of deletion.failed) this.logger.warn(`Could not delete ${failure.command.name} in ${failure.command.guildName} (${failure.reason}).`);
1107
+ if (skipped.length > 0) this.logger.warn(skippedSummary(skipped));
1108
+ this.logger.info(chalk.green(`Deleted ${plural(deletion.deleted, "guild command")}. Global commands untouched.`));
1109
+ }
1110
+ nothingToClean(scan, outcome) {
1111
+ const lines = emptyLines(scan, outcome);
1112
+ for (const line of lines.info) this.logger.info(line);
1113
+ this.logger.info(chalk.green(lines.outro));
1114
+ }
1115
+ dryRunHint() {
1116
+ this.logger.info(chalk.italic("Dry run. Re-run with --apply to delete the above."));
1117
+ }
1118
+ };
1119
+
1120
+ //#endregion
1121
+ //#region src/commands/commands/executeClean.ts
1122
+ const LARGE_BOT_THRESHOLD = 200;
1123
+ /** Why a scan found nothing to delete, so the caller can explain it instead of a bare "nothing to clean". */
1124
+ function emptyOutcome(scan, purge) {
1125
+ if (scan.scannedGuildCount === 0 && scan.skipped.length > 0) return "all-skipped";
1126
+ if (scan.scannedCommandCount === 0) return "no-commands";
1127
+ if (!purge && scan.globalCommandCount === 0) return "no-globals";
1128
+ return "no-overlaps";
1129
+ }
1130
+ /**
1131
+ * The dry-run-then-confirm flow for `commands --clean`. Resolves targets, scans, previews, and only deletes
1132
+ * once `apply` is set and the presenter confirms. The presenter owns all output and the two confirmations,
1133
+ * so the wizard and the flag path share this flow with different presenters.
1134
+ */
1135
+ async function executeClean(request) {
1136
+ const { runner, scope, apply, token, presenter, knownGuilds } = request;
1137
+ const { appId, guilds } = await presenter.status("Connecting to Discord...", () => runner.resolveTargets(scope, token), "Connected to Discord.");
1138
+ if (scope.allGuilds && guilds.length > 200) {
1139
+ if (!await presenter.largeBotGuard(guilds.length)) return;
1140
+ }
1141
+ const scan = await presenter.status(`Scanning ${plural(guilds.length, "guild")} for commands...`, () => runner.scanGuilds(token, appId, overlayNames(guilds, knownGuilds), scope.purge), `Scanned ${plural(guilds.length, "guild")}.`);
1142
+ if (scan.flagged.length === 0) {
1143
+ presenter.nothingToClean(scan, emptyOutcome(scan, scope.purge));
1144
+ return;
1145
+ }
1146
+ presenter.preview(scan);
1147
+ if (!apply) {
1148
+ presenter.dryRunHint();
1149
+ return;
1150
+ }
1151
+ if (!await presenter.confirmDelete(scan.flagged.length, scan.skipped.length)) return;
1152
+ const deletion = await presenter.status(`Deleting ${plural(scan.flagged.length, "guild command")}...`, () => runner.applyDeletions(token, appId, scan.flagged), "Deletion complete.");
1153
+ presenter.result(deletion, scan.skipped);
1154
+ }
1155
+ function overlayNames(guilds, knownGuilds) {
1156
+ if (!knownGuilds?.length) return guilds;
1157
+ const known = new Map(knownGuilds.map((guild) => [guild.id, guild.name]));
1158
+ return guilds.map((guild) => ({
1159
+ id: guild.id,
1160
+ name: known.get(guild.id) ?? guild.name
1161
+ }));
1162
+ }
1163
+
1164
+ //#endregion
1165
+ //#region src/commands/commands/flagClean.ts
1166
+ /** Runs `seedcord commands --clean ...` headlessly from parsed flags, reproducing any wizard run. */
1167
+ async function runCleanFromFlags(runner, flags, token, logger) {
1168
+ await executeClean({
1169
+ runner,
1170
+ scope: {
1171
+ guildIds: flags.guildIds,
1172
+ allGuilds: flags.allGuilds,
1173
+ purge: flags.purge
1174
+ },
1175
+ apply: flags.apply,
1176
+ token,
1177
+ presenter: new FlagPresenter(logger, flags.yes)
1178
+ });
1179
+ }
1180
+
1181
+ //#endregion
1182
+ //#region src/commands/commands/wizard.ts
1183
+ async function runCleanWizard(runner, token) {
1184
+ intro("seedcord commands");
1185
+ const plan = await buildScope(runner, token);
1186
+ if (!plan) return;
1187
+ await executeClean({
1188
+ runner,
1189
+ scope: plan.scope,
1190
+ apply: true,
1191
+ token,
1192
+ presenter: new InteractivePresenter(),
1193
+ knownGuilds: plan.knownGuilds
1194
+ });
1195
+ }
1196
+ async function buildScope(runner, token) {
1197
+ if (await select$1({
1198
+ message: "Remove guild commands that duplicate a global command. Which guilds?",
1199
+ options: [{
1200
+ value: "pick",
1201
+ label: "Pick specific guilds"
1202
+ }, {
1203
+ value: "all",
1204
+ label: "All guilds the bot is in",
1205
+ hint: "duplicates only, pick specific guilds for a full reset"
1206
+ }]
1207
+ }) === "all") return { scope: {
1208
+ guildIds: [],
1209
+ allGuilds: true,
1210
+ purge: false
1211
+ } };
1212
+ const status = spinner();
1213
+ status.start("Fetching the guilds the bot is in...");
1214
+ let guilds;
1215
+ try {
1216
+ guilds = await runner.listBotGuilds(token);
1217
+ } catch (error) {
1218
+ status.stop("Could not fetch the guild list.");
1219
+ throw error;
1220
+ }
1221
+ status.stop(`Found ${plural(guilds.length, "guild")}.`);
1222
+ if (guilds.length === 0) {
1223
+ outro("The bot is in no guilds.");
1224
+ return;
1225
+ }
1226
+ let guildIds;
1227
+ if (guilds.length > 1) {
1228
+ guildIds = await pickFromList({
1229
+ message: "Select guilds to clean",
1230
+ items: guilds
1231
+ });
1232
+ if (guildIds.length === 0) {
1233
+ outro("No guilds selected.");
1234
+ return;
1235
+ }
1236
+ } else {
1237
+ const only = guilds[0];
1238
+ if (!only) return void 0;
1239
+ guildIds = [only.id];
1240
+ log.info(`Only one guild, using ${only.name}.`);
1241
+ }
1242
+ const mode = await select$1({
1243
+ message: "What should I remove?",
1244
+ options: [{
1245
+ value: "overlap",
1246
+ label: "Only duplicates of a global command (recommended)"
1247
+ }, {
1248
+ value: "purge",
1249
+ label: "Every command in these guilds (full reset)"
1250
+ }]
1251
+ });
1252
+ return {
1253
+ scope: {
1254
+ guildIds,
1255
+ allGuilds: false,
1256
+ purge: mode === "purge"
1257
+ },
1258
+ knownGuilds: guilds
1259
+ };
1260
+ }
1261
+
1262
+ //#endregion
1263
+ //#region src/commands/commands/CommandsCommand.ts
1264
+ function hasCleanFlags(options) {
1265
+ return [
1266
+ options.clean,
1267
+ options.allGuilds,
1268
+ options.apply,
1269
+ options.purge
1270
+ ].some(Boolean) || options.guild.length > 0;
1271
+ }
1272
+ var CommandsCommand = class extends BaseCommand {
1273
+ cleanRunner;
1274
+ constructor() {
1275
+ super("commands", "Inspect and clean deployed application commands", "CLI:Commands");
1276
+ this.cleanRunner = CleanRunner.create();
1277
+ }
1278
+ register(program) {
1279
+ program.command(this.name).description(this.description).option("--clean", "Report guild commands that duplicate a global command (deletes only with --apply)").option("--guild <ids...>", "Guild ids to inspect").option("--all-guilds", "Scan every guild the bot is in (overlaps only, cannot combine with --purge)").option("--apply", "Delete the reported commands instead of running a dry run").option("--purge", "Select every command in the named guilds, not only global overlaps").option("--yes", "Skip prompts and the typed-count confirm (for scripts and CI)").action(async (options) => this.run({
1280
+ clean: options.clean ?? false,
1281
+ guild: options.guild ?? [],
1282
+ allGuilds: options.allGuilds ?? false,
1283
+ apply: options.apply ?? false,
1284
+ purge: options.purge ?? false,
1285
+ yes: options.yes ?? false
1286
+ }));
1287
+ }
1288
+ async run(options) {
1289
+ try {
1290
+ await this.dispatch(options);
1291
+ } catch (error) {
1292
+ if (isSeedcordError(error, "SeedcordError", SeedcordErrorCode.CliCancelled)) return;
1293
+ this.logger.error("seedcord commands failed", error);
1294
+ if (isSeedcordError(error)) process.exitCode = 1;
1295
+ else process.exit(1);
1296
+ }
1297
+ }
1298
+ async dispatch(options) {
1299
+ const flags = hasCleanFlags(options);
1300
+ const interactive = isInteractive(options, flags);
1301
+ if (!interactive && !flags) {
1302
+ this.logger.info("Nothing to do. Pass --clean to inspect deployed commands.");
1303
+ return;
1304
+ }
1305
+ const token = validateDiscordToken(Envapter.get("DISCORD_BOT_TOKEN"));
1306
+ if (interactive) {
1307
+ await runCleanWizard(this.cleanRunner, token);
1308
+ return;
1309
+ }
1310
+ await runCleanFromFlags(this.cleanRunner, {
1311
+ guildIds: options.guild,
1312
+ allGuilds: options.allGuilds,
1313
+ apply: options.apply,
1314
+ purge: options.purge,
1315
+ yes: options.yes
1316
+ }, token, this.logger);
1317
+ }
1318
+ };
1319
+
762
1320
  //#endregion
763
1321
  //#region src/ui/hooks/useDevState.ts
764
1322
  function useDevState(store) {
@@ -1012,10 +1570,10 @@ function handlePrompt(ctx) {
1012
1570
  function handleToggleMode(ctx) {
1013
1571
  if (!ctx.showToggles) return false;
1014
1572
  const channels = LogStore.instance.getChannels();
1015
- if (ctx.key.escape || ctx.input === "c") ctx.setShowToggles(false);
1573
+ if (ctx.key.escape || ctx.key.return || ctx.input === "c") ctx.setShowToggles(false);
1016
1574
  else if (ctx.key.upArrow && channels.length > 0) ctx.setCursor((ctx.cursor + channels.length - 1) % channels.length);
1017
1575
  else if (ctx.key.downArrow && channels.length > 0) ctx.setCursor((ctx.cursor + 1) % channels.length);
1018
- else if (ctx.input === " " || ctx.key.return) {
1576
+ else if (ctx.input === " ") {
1019
1577
  const channel = channels[ctx.cursor];
1020
1578
  if (channel !== void 0) ctx.setEnabled(toggleChannel(ctx.enabled, channel, channels));
1021
1579
  }
@@ -1344,7 +1902,7 @@ function HotkeyBar({ phase, interactive, mode, following }) {
1344
1902
  keyLabel: "space",
1345
1903
  action: "toggle"
1346
1904
  }), /* @__PURE__ */ React.createElement(Hotkey, {
1347
- keyLabel: "esc",
1905
+ keyLabel: "↵/esc",
1348
1906
  action: "done"
1349
1907
  })), mode === "default" && /* @__PURE__ */ React.createElement(DefaultKeys, {
1350
1908
  phase,
@@ -2276,13 +2834,14 @@ var DevCommand = class extends BaseCommand {
2276
2834
 
2277
2835
  //#endregion
2278
2836
  //#region src/cli.ts
2279
- const LOGGER_LABEL = "Seedcord CLI";
2837
+ const LOGGER_LABEL = "seedcord CLI";
2280
2838
  async function main() {
2281
2839
  if (!process.env.ENV && !process.env.ENVIRONMENT && !process.env.NODE_ENV) process.env.NODE_ENV = "development";
2282
- const program = new Command().name("seedcord").description("Seedcord CLI").version(version);
2840
+ const program = new Command().name("seedcord").description("seedcord CLI").version(version);
2283
2841
  new DevCommand().register(program);
2284
2842
  new BuildCommand().register(program);
2285
2843
  new CodegenCommand().register(program);
2844
+ new CommandsCommand().register(program);
2286
2845
  await program.parseAsync(process.argv);
2287
2846
  }
2288
2847
  main().catch((error) => {