From e71fdba468e3c8939b3827b3ac83c93bcdd8b22a Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:16:19 +0000 Subject: [PATCH 1/8] feat(commands/util/walkthrough): add Other options and editable answers --- src/commands/util/walkthrough.ts | 129 ++++++++++++++---- .../operatingSystemFamilySelector.ts | 5 + src/ui/components/productSelector.ts | 5 + 3 files changed, 110 insertions(+), 29 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 27f14cb..51b4b90 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -1,6 +1,9 @@ import { config } from "@lib/config.js"; -import { isHelpPost as isHelpThread } from "@lib/discord/channels.js"; +import { + canMemberInteractWithThread, + isHelpPost as isHelpThread, +} from "@lib/discord/channels.js"; import { getCommandMention } from "@lib/discord/commands.js"; import issueCategorySelector from "@components/issueCategorySelector.js"; import productSelector from "@components/productSelector.js"; @@ -20,6 +23,7 @@ import { MessageFlags, type PublicThreadChannel, SectionBuilder, + type ThreadChannel, SeparatorBuilder, SlashCommandBuilder, StringSelectMenuBuilder, @@ -82,18 +86,21 @@ const row = (...components: MessageActionRowComponentBuilder[]) => ...components, ); -// A field row: the field name with a disabled button showing the chosen option -// (label and emoji), or "N/A" until it is answered. +// A field row: the field name with a button showing the chosen option (label +// and emoji), or a disabled "N/A" button until it is answered. The button +// carries the field index and the answers so far so a click can reopen that +// question for editing. function fieldSection( + index: number, field: string, menu: StringSelectMenuBuilder, - value?: string, + values: string[], ) { - const option = optionOf(menu, value); + const option = optionOf(menu, values[index]); const button = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) - .setCustomId(`${CUSTOM_ID}:field:${field}`) + .setCustomId([CUSTOM_ID, "field", index, ...values].join(":")) .setDisabled(!option) .setLabel(option?.label ?? "N/A"); @@ -119,15 +126,16 @@ async function buildMessage( client: Client, channelId: string, values: string[], + editIndex?: number, ) { const info = new ContainerBuilder() .setAccentColor(Colors.Blurple) .addTextDisplayComponents(text(`<#${channelId}>`)) .addSeparatorComponents(new SeparatorBuilder()) .addSectionComponents( - fieldSection("Category", issueCategorySelector, values[0]), - fieldSection("Product", productSelector, values[1]), - fieldSection("Platform", operatingSystemFamilySelector, values[2]), + fieldSection(0, "Category", issueCategorySelector, values), + fieldSection(1, "Product", productSelector, values), + fieldSection(2, "Platform", operatingSystemFamilySelector, values), ) .addSeparatorComponents(new SeparatorBuilder()) .addTextDisplayComponents(text(await lifecycleText(client))); @@ -137,19 +145,27 @@ async function buildMessage( | ActionRowBuilder )[] = [info]; - const step = steps[values.length]; + const product = optionOf(productSelector, values[1])?.label ?? "N/A"; + + // While editing, reopen the chosen field's selector instead of the next + // unanswered step. Otherwise ask the next question if any remain. + const step = + editIndex !== undefined ? steps[editIndex] : steps[values.length]; if (step) { - const product = optionOf(productSelector, values[1])?.label ?? "N/A"; + const prompt = + editIndex !== undefined + ? `Editing **${step.field}**. ${step.prompt(product)}` + : step.prompt(product); + const selectId = + editIndex !== undefined + ? [CUSTOM_ID, "edit", editIndex, ...values].join(":") + : [CUSTOM_ID, ...values].join(":"); components.push( new ContainerBuilder() .setAccentColor(Colors.Blurple) - .addTextDisplayComponents(text(step.prompt(product))), - row( - StringSelectMenuBuilder.from(step.menu).setCustomId( - [CUSTOM_ID, ...values].join(":"), - ), - ), + .addTextDisplayComponents(text(prompt)), + row(StringSelectMenuBuilder.from(step.menu).setCustomId(selectId)), ); } @@ -222,7 +238,8 @@ export async function doWalkthrough( } // Advances the walkthrough one step by editing the same message with the newly -// answered value appended. +// answered value appended. An "edit" selection instead replaces an existing +// answer in place, leaving any later answers untouched. export async function handleSelection( interaction: StringSelectMenuInteraction, ) { @@ -230,10 +247,25 @@ export async function handleSelection( return; } - const values = [ - ...interaction.customId.split(":").slice(1), - interaction.values[0], - ]; + const parts = interaction.customId.split(":"); + + if (parts[1] === "edit") { + if (!(await canEditWalkthrough(interaction))) { + await denyEdit(interaction); + return; + } + + const index = Number(parts[2]); + const values = parts.slice(3); + values[index] = interaction.values[0]; + + await interaction.update( + await buildMessage(interaction.client, interaction.channelId, values), + ); + return; + } + + const values = [...parts.slice(1), interaction.values[0]]; await interaction.update( await buildMessage(interaction.client, interaction.channelId, values), @@ -244,15 +276,54 @@ export async function handleSelection( } } -// The answer buttons only summarize the walkthrough answers, so a click just -// tells the user they can't be edited. +// Whether the interacting member may edit the walkthrough: the post owner or a +// moderator with Manage Channels. +async function canEditWalkthrough( + interaction: ButtonInteraction | StringSelectMenuInteraction, +) { + const channel = interaction.channel; + if (!channel?.isThread()) { + return false; + } + + const member = await interaction.guild?.members.fetch(interaction.user.id); + return member + ? canMemberInteractWithThread(channel as ThreadChannel, member) + : false; +} + +function denyEdit( + interaction: ButtonInteraction | StringSelectMenuInteraction, +) { + return interaction.reply({ + content: "Only the OP or a moderator can edit the walkthrough answers.", + flags: MessageFlags.Ephemeral, + }); +} + +// Clicking a field's button reopens that question so the answer can be changed. export async function handleFieldButton(interaction: ButtonInteraction) { - if (interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) { - await interaction.reply({ - content: "This is just a summary of your answers, you can't edit it.", - flags: MessageFlags.Ephemeral, - }); + if (!interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) { + return; } + + if (!(await canEditWalkthrough(interaction))) { + await denyEdit(interaction); + return; + } + + const parts = interaction.customId.split(":"); + const index = Number(parts[2]); + const values = parts.slice(3); + + await interaction.update( + await buildMessage( + interaction.client, + interaction.channelId, + values, + index, + ), + ); } export default { diff --git a/src/ui/components/operatingSystemFamilySelector.ts b/src/ui/components/operatingSystemFamilySelector.ts index ef2b0de..55753ef 100644 --- a/src/ui/components/operatingSystemFamilySelector.ts +++ b/src/ui/components/operatingSystemFamilySelector.ts @@ -20,6 +20,11 @@ const options = [ .setLabel("macOS") .setValue("macos") .setEmoji(config.emojis.macos), + + new StringSelectMenuOptionBuilder() + .setLabel("Other") + .setValue("other") + .setEmoji("❓"), ]; export default new StringSelectMenuBuilder() diff --git a/src/ui/components/productSelector.ts b/src/ui/components/productSelector.ts index d788b18..13d83bf 100644 --- a/src/ui/components/productSelector.ts +++ b/src/ui/components/productSelector.ts @@ -15,6 +15,11 @@ const options = [ .setLabel("code-server") .setValue("code-server") .setEmoji(config.emojis.vscode), + + new StringSelectMenuOptionBuilder() + .setLabel("Other") + .setValue("other") + .setEmoji("❓"), ]; export default new StringSelectMenuBuilder() From 03372df70890da11d7fc56739648b20a6ad7c865 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:32:48 +0000 Subject: [PATCH 2/8] fix(commands/util/walkthrough): format edit prompt as (Editing ) --- src/commands/util/walkthrough.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 51b4b90..46da237 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -154,7 +154,7 @@ async function buildMessage( if (step) { const prompt = editIndex !== undefined - ? `Editing **${step.field}**. ${step.prompt(product)}` + ? `(Editing ${step.field})\n${step.prompt(product)}` : step.prompt(product); const selectId = editIndex !== undefined From 2edb56b3cfffe7ef012e49c6b5d024939660d97a Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:35:57 +0000 Subject: [PATCH 3/8] refactor(commands/util/walkthrough): dedupe edit guard and message rendering --- src/commands/util/walkthrough.ts | 98 +++++++++++++++----------------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 46da237..6bc8a27 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -237,9 +237,45 @@ export async function doWalkthrough( await interaction.reply(walkthroughMessage); } -// Advances the walkthrough one step by editing the same message with the newly -// answered value appended. An "edit" selection instead replaces an existing -// answer in place, leaving any later answers untouched. +// Re-renders the walkthrough message in place from the given answers, optionally +// reopening a field for editing. +function render( + interaction: ButtonInteraction | StringSelectMenuInteraction, + values: string[], + editIndex?: number, +) { + return buildMessage( + interaction.client, + interaction.channelId, + values, + editIndex, + ).then((message) => interaction.update(message)); +} + +// Only the post owner or a moderator (Manage Channels) may edit answers. Replies +// with an ephemeral notice and returns false when the member may not. +async function ensureCanEdit( + interaction: ButtonInteraction | StringSelectMenuInteraction, +) { + const channel = interaction.channel; + const member = channel?.isThread() + ? await interaction.guild?.members.fetch(interaction.user.id) + : undefined; + + if (member && canMemberInteractWithThread(channel as ThreadChannel, member)) { + return true; + } + + await interaction.reply({ + content: "Only the OP or a moderator can edit the walkthrough answers.", + flags: MessageFlags.Ephemeral, + }); + return false; +} + +// Advances the walkthrough by re-rendering the same message with the new answer. +// A "walkthrough:..." id appends the answer; a "walkthrough:edit:..." id replaces +// an existing answer in place, leaving any later answers untouched. export async function handleSelection( interaction: StringSelectMenuInteraction, ) { @@ -250,80 +286,36 @@ export async function handleSelection( const parts = interaction.customId.split(":"); if (parts[1] === "edit") { - if (!(await canEditWalkthrough(interaction))) { - await denyEdit(interaction); + if (!(await ensureCanEdit(interaction))) { return; } - const index = Number(parts[2]); const values = parts.slice(3); - values[index] = interaction.values[0]; - - await interaction.update( - await buildMessage(interaction.client, interaction.channelId, values), - ); + values[Number(parts[2])] = interaction.values[0]; + await render(interaction, values); return; } const values = [...parts.slice(1), interaction.values[0]]; - - await interaction.update( - await buildMessage(interaction.client, interaction.channelId, values), - ); + await render(interaction, values); if (values.length === steps.length) { await interaction.message.pin(); } } -// Whether the interacting member may edit the walkthrough: the post owner or a -// moderator with Manage Channels. -async function canEditWalkthrough( - interaction: ButtonInteraction | StringSelectMenuInteraction, -) { - const channel = interaction.channel; - if (!channel?.isThread()) { - return false; - } - - const member = await interaction.guild?.members.fetch(interaction.user.id); - return member - ? canMemberInteractWithThread(channel as ThreadChannel, member) - : false; -} - -function denyEdit( - interaction: ButtonInteraction | StringSelectMenuInteraction, -) { - return interaction.reply({ - content: "Only the OP or a moderator can edit the walkthrough answers.", - flags: MessageFlags.Ephemeral, - }); -} - // Clicking a field's button reopens that question so the answer can be changed. export async function handleFieldButton(interaction: ButtonInteraction) { if (!interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) { return; } - if (!(await canEditWalkthrough(interaction))) { - await denyEdit(interaction); + if (!(await ensureCanEdit(interaction))) { return; } const parts = interaction.customId.split(":"); - const index = Number(parts[2]); - const values = parts.slice(3); - - await interaction.update( - await buildMessage( - interaction.client, - interaction.channelId, - values, - index, - ), - ); + await render(interaction, parts.slice(3), Number(parts[2])); } export default { From dbc4563d929f32a9d3e8e87cecea98f4a4c28e9c Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:36:35 +0000 Subject: [PATCH 4/8] fix(commands/util/walkthrough): bold the field name in the edit prompt --- src/commands/util/walkthrough.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 6bc8a27..09cccf3 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -154,7 +154,7 @@ async function buildMessage( if (step) { const prompt = editIndex !== undefined - ? `(Editing ${step.field})\n${step.prompt(product)}` + ? `(Editing **${step.field}**)\n${step.prompt(product)}` : step.prompt(product); const selectId = editIndex !== undefined From e0c7013f756416f1cd99aaf14879333a513b40b3 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:41:28 +0000 Subject: [PATCH 5/8] feat(commands/util/walkthrough): show per-platform log locations for coder and code-server --- src/commands/util/walkthrough.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 09cccf3..5996934 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -52,6 +52,25 @@ const productResources: Record = { ], }; +// Where each product writes its logs, keyed by product value then platform +// value. Shown once both are known. Paths come from the Coder docs. Values are +// Markdown; a product/platform without an entry simply shows no hint. +const logLocations: Record>> = { + coder: { + linux: + "Server: `journalctl -u coder` on a VM, or `kubectl logs deployment/coder -n ` on Kubernetes.\nWorkspace agent: `/tmp/coder-agent.log`.", + macos: "Workspace agent: `/tmp/coder-agent.log`.", + windows: + "Workspace agent logs live wherever your template writes them; check the template's startup script.", + }, + "code-server": { + linux: + "`~/.local/share/code-server/coder-logs/` (and `~/.vscode-server/data/logs/` for the VS Code server).", + macos: + "`~/.local/share/code-server/coder-logs/` (and `~/.vscode-server/data/logs/` for the VS Code server).", + }, +}; + // The walkthrough asks one selector per field, in this order. const steps = [ { @@ -169,6 +188,17 @@ async function buildMessage( ); } + const logHint = logLocations[values[1]]?.[values[2]]; + if (logHint) { + components.push( + new ContainerBuilder() + .setAccentColor(Colors.Blurple) + .addTextDisplayComponents( + text(`**Where to find your logs**\n${logHint}`), + ), + ); + } + const docs = productResources[values[1]] ?? []; if (docs.length > 0) { components.push( From 3f929988b9a364077342d0595979d61709a5cc12 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:46:18 +0000 Subject: [PATCH 6/8] fix(commands/util/walkthrough): reorder log embed when editing, hide it while editing platform --- src/commands/util/walkthrough.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 5996934..a0b4cb5 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -170,6 +170,10 @@ async function buildMessage( // unanswered step. Otherwise ask the next question if any remain. const step = editIndex !== undefined ? steps[editIndex] : steps[values.length]; + const question: ( + | ContainerBuilder + | ActionRowBuilder + )[] = []; if (step) { const prompt = editIndex !== undefined @@ -180,7 +184,7 @@ async function buildMessage( ? [CUSTOM_ID, "edit", editIndex, ...values].join(":") : [CUSTOM_ID, ...values].join(":"); - components.push( + question.push( new ContainerBuilder() .setAccentColor(Colors.Blurple) .addTextDisplayComponents(text(prompt)), @@ -188,15 +192,25 @@ async function buildMessage( ); } - const logHint = logLocations[values[1]]?.[values[2]]; - if (logHint) { - components.push( - new ContainerBuilder() + // The log locations depend on the platform, so hide them while that answer is + // being edited. When editing another field, show them above the question. + const logHint = + editIndex === 2 ? undefined : logLocations[values[1]]?.[values[2]]; + const logComponent = logHint + ? new ContainerBuilder() .setAccentColor(Colors.Blurple) .addTextDisplayComponents( text(`**Where to find your logs**\n${logHint}`), - ), - ); + ) + : undefined; + + if (editIndex !== undefined && logComponent) { + components.push(logComponent, ...question); + } else { + components.push(...question); + if (logComponent) { + components.push(logComponent); + } } const docs = productResources[values[1]] ?? []; From 5b8ba046a719a1b1f8459214a9b1fdd0ccdac76e Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:20:45 +0000 Subject: [PATCH 7/8] perf(lib/discord): avoid REST round-trips in the walkthrough edit flow Cache guild command-mention lookups and resolve edit permissions from the interaction payload instead of fetching the guild member, so a selection or edit re-renders without waiting on Discord REST calls. --- src/commands/util/walkthrough.ts | 20 ++++++++++---------- src/lib/discord/commands.ts | 25 +++++++++++++++++++++---- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index a0b4cb5..3b3ffb6 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -1,9 +1,6 @@ import { config } from "@lib/config.js"; -import { - canMemberInteractWithThread, - isHelpPost as isHelpThread, -} from "@lib/discord/channels.js"; +import { isHelpPost as isHelpThread } from "@lib/discord/channels.js"; import { getCommandMention } from "@lib/discord/commands.js"; import issueCategorySelector from "@components/issueCategorySelector.js"; import productSelector from "@components/productSelector.js"; @@ -21,9 +18,9 @@ import { type GuildTextBasedChannel, type MessageActionRowComponentBuilder, MessageFlags, + PermissionFlagsBits, type PublicThreadChannel, SectionBuilder, - type ThreadChannel, SeparatorBuilder, SlashCommandBuilder, StringSelectMenuBuilder, @@ -297,16 +294,19 @@ function render( } // Only the post owner or a moderator (Manage Channels) may edit answers. Replies -// with an ephemeral notice and returns false when the member may not. +// with an ephemeral notice and returns false when the member may not. Reads the +// owner and permissions from the interaction payload, so it needs no REST call. async function ensureCanEdit( interaction: ButtonInteraction | StringSelectMenuInteraction, ) { const channel = interaction.channel; - const member = channel?.isThread() - ? await interaction.guild?.members.fetch(interaction.user.id) - : undefined; + const isOwner = + channel?.isThread() && channel.ownerId === interaction.user.id; + const canManage = + interaction.memberPermissions?.has(PermissionFlagsBits.ManageChannels) ?? + false; - if (member && canMemberInteractWithThread(channel as ThreadChannel, member)) { + if (isOwner || canManage) { return true; } diff --git a/src/lib/discord/commands.ts b/src/lib/discord/commands.ts index 466f6a8..06cea89 100644 --- a/src/lib/discord/commands.ts +++ b/src/lib/discord/commands.ts @@ -1,6 +1,25 @@ import { config } from "@lib/config.js"; -import type { Client } from "discord.js"; +import type { ApplicationCommand, Client, Collection } from "discord.js"; + +// Guild command IDs are stable for the process lifetime, so the first lookup +// is cached and reused. This avoids a Discord REST round-trip on every call, +// which matters because the walkthrough re-resolves mentions on every render. +let commandsCache: Promise> | undefined; + +function fetchGuildCommands(client: Client) { + if (!commandsCache) { + commandsCache = client.application.commands + .fetch({ guildId: config.serverId }) + .catch((error) => { + // Don't cache a failed fetch; allow the next call to retry. + commandsCache = undefined; + throw error; + }); + } + + return commandsCache; +} // Resolves a slash command mention () by looking the command ID up // from the ones the bot registered in the guild. Falls back to plain text if @@ -9,9 +28,7 @@ export async function getCommandMention( client: Client, name: string, ): Promise { - const commands = await client.application.commands.fetch({ - guildId: config.serverId, - }); + const commands = await fetchGuildCommands(client); const command = commands.find((cmd) => cmd.name === name); From 2c43341afe22d57dceaebc7a57c7327767be584c Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:42:17 +0000 Subject: [PATCH 8/8] feat(commands/util/walkthrough): richer per-platform log guide (server + agent + docs) --- src/commands/util/walkthrough.ts | 148 ++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 3b3ffb6..f54f5b6 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -49,25 +49,102 @@ const productResources: Record = { ], }; -// Where each product writes its logs, keyed by product value then platform -// value. Shown once both are known. Paths come from the Coder docs. Values are -// Markdown; a product/platform without an entry simply shows no hint. -const logLocations: Record>> = { +// The log guide for a product/platform is one section per log source, split by +// dividers, then a docs link. `default` covers platforms without a specific +// entry. Paths and commands come from the Coder repo and docs. +type LogSection = { title: string; location: string; command: string }; +type LogGuide = { sections: LogSection[]; footer: string }; + +const architectureDocs = + "https://coder.com/docs/admin/infrastructure/architecture"; +const coderFooter = `Learn about the difference between the Coder Server and Agent at ${architectureDocs}`; + +const agentSectionUnix: LogSection = { + title: "Coder Workspace Agent/`coder agent`", + location: "`/tmp/coder-agent.log` inside the workspace", + command: "coder ssh -- cat /tmp/coder-agent.log", +}; + +const logGuides: Record>> = { coder: { - linux: - "Server: `journalctl -u coder` on a VM, or `kubectl logs deployment/coder -n ` on Kubernetes.\nWorkspace agent: `/tmp/coder-agent.log`.", - macos: "Workspace agent: `/tmp/coder-agent.log`.", - windows: - "Workspace agent logs live wherever your template writes them; check the template's startup script.", + linux: { + sections: [ + { + title: "Coder Server/`coderd`", + location: "the systemd journal (`coderd` writes to standard output)", + command: + "sudo journalctl -u coder.service --no-pager\n# Docker: docker logs coder\n# Kubernetes: kubectl logs deployment/coder -n coder", + }, + agentSectionUnix, + ], + footer: coderFooter, + }, + macos: { + sections: [ + { + title: "Coder Server/`coderd`", + location: + "standard output (`coderd` has no default log file on macOS)", + command: + 'CODER_LOGGING_HUMAN="$HOME/coder.log" coder server\ncat "$HOME/coder.log"', + }, + agentSectionUnix, + ], + footer: coderFooter, + }, + windows: { + sections: [ + { + title: "Coder Server/`coderd`", + location: + "standard output (`coderd` has no default log file on Windows)", + command: + '$env:CODER_LOGGING_HUMAN="$HOME\\coder.log"; coder server\nGet-Content "$HOME\\coder.log"', + }, + { + title: "Coder Workspace Agent/`coder agent`", + location: + "the path your template sets (the azure-windows example uses `C:\\AzureData\\CoderAgent.log`)", + command: + 'coder ssh -- powershell -Command "Get-Content C:\\AzureData\\CoderAgent.log"', + }, + ], + footer: coderFooter, + }, + default: { + sections: [ + { + title: "Coder Server/`coderd`", + location: + "standard output; capture it to a file with `CODER_LOGGING_HUMAN`", + command: 'CODER_LOGGING_HUMAN="$HOME/coder.log" coder server', + }, + agentSectionUnix, + ], + footer: coderFooter, + }, }, "code-server": { - linux: - "`~/.local/share/code-server/coder-logs/` (and `~/.vscode-server/data/logs/` for the VS Code server).", - macos: - "`~/.local/share/code-server/coder-logs/` (and `~/.vscode-server/data/logs/` for the VS Code server).", + default: { + sections: [ + { + title: "code-server", + location: + "`/tmp/code-server.log` (the Coder code-server module default; code-server's own logs live under `~/.local/share/code-server/`)", + command: "coder ssh -- cat /tmp/code-server.log", + }, + ], + footer: + "code-server is configured by the Coder code-server module: https://registry.coder.com/modules/coder/code-server", + }, }, }; +const logGuideFor = (product?: string, platform?: string) => { + const perProduct = product ? logGuides[product] : undefined; + return perProduct?.[platform ?? ""] ?? perProduct?.default; +}; + // The walkthrough asks one selector per field, in this order. const steps = [ { @@ -135,6 +212,33 @@ async function lifecycleText(client: Client) { return `When your issue is resolved, use ${close} to close it.\nUse ${reopen} to reopen it if needed.`; } +// Renders the "where are my logs" container for the chosen product and +// platform: one section per log source separated by dividers, then a docs link. +// Returns undefined when there is no guide for the product. +function logGuideComponent(product?: string, platform?: string) { + const guide = logGuideFor(product, platform); + if (!guide) { + return undefined; + } + + const container = new ContainerBuilder().setAccentColor(Colors.Blurple); + + guide.sections.forEach((section, index) => { + if (index > 0) { + container.addSeparatorComponents(new SeparatorBuilder()); + } + container.addTextDisplayComponents( + text( + `${section.title} logs are located at ${section.location}.\n\nYou can get them easily via:\n\`\`\`\n${section.command}\n\`\`\``, + ), + ); + }); + + return container + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents(text(guide.footer)); +} + // Builds the walkthrough message from the answered values so far: an info // container with a field row per answer, the current question and selector while // steps remain, and the selected product's documentation buttons at the bottom. @@ -189,17 +293,13 @@ async function buildMessage( ); } - // The log locations depend on the platform, so hide them while that answer is - // being edited. When editing another field, show them above the question. - const logHint = - editIndex === 2 ? undefined : logLocations[values[1]]?.[values[2]]; - const logComponent = logHint - ? new ContainerBuilder() - .setAccentColor(Colors.Blurple) - .addTextDisplayComponents( - text(`**Where to find your logs**\n${logHint}`), - ) - : undefined; + // The log guide depends on the platform, so only show it once the platform is + // known, and hide it while that answer is being edited. When editing another + // field it renders above the question. + const logComponent = + editIndex !== 2 && values[2] !== undefined + ? logGuideComponent(values[1], values[2]) + : undefined; if (editIndex !== undefined && logComponent) { components.push(logComponent, ...question);