mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
feat(cli): non-interactive Slack connect/disconnect + sync round-trip fixes (#8935)
* feat(cli): non-interactive Slack connect/disconnect
Extract create_slack_workspace_artifacts / create_slack_instance_artifacts
from the browser OAuth callbacks and expose them via two new endpoints that
accept a pre-minted xoxb bot token:
- POST /w/{workspace}/workspaces/connect_slack (admin)
- POST /oauth/connect_slack_instance (super-admin)
Both produce bit-for-bit identical DB state to the UI browser flow.
Wire three CLI commands as thin wrappers:
- wmill workspace connect-slack
- wmill workspace disconnect-slack
- wmill instance connect-slack
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): round-trip stability for workspace settings handlers
wmill sync push was destroying UI-configured error_handler/success_handler
state on every deploy. Two orthogonal bugs:
(a) pushWorkspaceSettings called editErrorHandler with `path: undefined`
when the YAML lacked the handler block, which the backend treats as a
clear — so syncing settings.yaml that didn't mention the handler wiped
the DB row. Fix: skip the call entirely when absent from YAML.
(b) edit_error_handler omitted muted_on_cancel / muted_on_user_path when
false, but the CLI always sends them, causing perpetual deepEqual
drift and a spurious editErrorHandler call on every sync push. Fix:
always persist both booleans.
migrateToGroupedFormat now preserves explicit `null` on
error_handler / success_handler as a "clear remote" signal distinct from
absence. Widen ErrorHandlerConfig | null / SuccessHandlerConfig | null to
make this explicit in the type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): sync support for workspace-level Slack OAuth override
Add slack_oauth_client_id and slack_oauth_client_secret to the v2 tarball
export and to pushWorkspaceSettings, so the workspace-level OAuth override
is now fully managed as code through settings.yaml.
Semantics:
- both defined and truthy → setWorkspaceSlackOauthConfig (upsert)
- both defined but falsy (e.g. empty strings) and remote has a value
→ deleteWorkspaceSlackOauthConfig
- either omitted → leave remote alone ("not managed by git")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): normalize workspace settings sync to "omit = clear"
Earlier commits on this branch introduced an "omit = keep" rule for
error_handler / success_handler / slack_oauth_client_{id,secret} that
diverged from every other workspace setting (webhook, deploy_to, etc. all
treat YAML as canonical: absence = clear). Normalize:
- v2 tarball always emits these 4 fields (null when remote is NULL) so
round-trip is bijective and settings.yaml is a complete snapshot.
- pushWorkspaceSettings drops the absent-from-YAML guards; YAML is
canonical. Absence and explicit null both clear the remote — same rule
as every other field.
- set_slack_oauth_config / delete_slack_oauth_config now fire
handle_deployment_metadata so UI mutations reach git-sync-enabled
workspaces' committed settings.yaml.
Policy for users: pull before push (same as every other setting). On first
post-upgrade pull, explicit `null` keys appear for any workspace whose
handlers / oauth override are unset — one-time YAML diff, no semantic
change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): add unit + integration coverage for Slack settings sync
Unit tests (settings_unit.test.ts): cover migrateToGroupedFormat preserving
explicit `null` on error_handler / success_handler, and passthrough of
slack_oauth_client_id / _secret (both populated and null values).
Integration tests (slack_settings_sync.test.ts, skipped on CI per the same
convention as datatable_settings_sync.test.ts): exercise the full backend
via withTestBackend to verify
1. pull emits null for unset error_handler / success_handler /
slack_oauth_client_id / _secret;
2. round-trip with all-null handlers is idempotent;
3. push of populated slack_oauth_config upserts;
4. omitting the slack_oauth keys from YAML clears remote (universal
"omit = clear" rule);
5. explicit null error_handler in YAML clears remote;
6. round-trip preserves a populated error_handler exactly, including the
always-persisted muted_on_cancel / muted_on_user_path booleans.
Also feature-gates `use crate::oauth2_oss::workspace_connect_slack` and its
route registration behind `cfg(feature = "oauth2")`: the import caused a
build failure on subsets of the workspace without the oauth2 feature,
surfaced by the integration test harness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump ee-repo-ref to 59b6123
Pins windmill-ee-private to the tip of branch alp/slack_cli, which
contains the companion EE changes (helper extraction, non-interactive
Slack connect handlers, git-sync for Slack settings mutations).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Update SQLx metadata
* chore: regenerate system prompts for new slack CLI commands
Captures the new workspace connect-slack, workspace disconnect-slack,
and instance connect-slack commands in the auto-generated files that
CI enforces via system_prompts/check-freshness.sh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022
This commit updates the EE repository reference after PR #550 was merged in windmill-ee-private.
Previous ee-repo-ref: d7e44d0519327ec9077625130365e887826f324b
New ee-repo-ref: b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
Claude Opus 4.7
windmill-internal-app[bot]
Ruben Fiszel
parent
a1a73309fd
commit
95d4c6a94d
@@ -26,6 +26,7 @@ import {
|
||||
pushInstanceGroups,
|
||||
pushInstanceUsers,
|
||||
} from "../user/user.ts";
|
||||
import { connectSlackInstance } from "./slack.ts";
|
||||
import {
|
||||
add as workspaceSetup,
|
||||
addWorkspace,
|
||||
@@ -825,6 +826,18 @@ const command = new Command()
|
||||
"--instance <instance:string>",
|
||||
"Name of the instance, override the active instance",
|
||||
)
|
||||
.action(getConfig as any);
|
||||
.action(getConfig as any)
|
||||
.command("connect-slack")
|
||||
.description(
|
||||
"Non-interactively connect Slack at the instance level using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: global_settings 'slack' row + encrypted f/slack_bot/global_bot_token variable and resource in the admins workspace."
|
||||
)
|
||||
.option("--bot-token <bot_token:string>", "Slack bot token (xoxb-...)", { required: true })
|
||||
.option("--team-id <team_id:string>", "Slack team id", { required: true })
|
||||
.option("--team-name <team_name:string>", "Slack team name", { required: true })
|
||||
.option(
|
||||
"--instance <instance:string>",
|
||||
"Instance profile to connect against (defaults to the active instance)"
|
||||
)
|
||||
.action((opts: any) => connectSlackInstance(opts));
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { setClient } from "../../core/client.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
import { getActiveInstance, allInstances, Instance } from "./instance.ts";
|
||||
|
||||
async function resolveInstance(
|
||||
instanceName: string | undefined
|
||||
): Promise<Instance> {
|
||||
if (instanceName) {
|
||||
const match = (await allInstances()).find((i) => i.name === instanceName);
|
||||
if (!match) {
|
||||
throw new Error(`No local instance profile named ${instanceName}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
const activeName = await getActiveInstance({});
|
||||
if (!activeName) {
|
||||
throw new Error(
|
||||
"No active instance. Run 'wmill instance add' or pass --instance."
|
||||
);
|
||||
}
|
||||
const match = (await allInstances()).find((i) => i.name === activeName);
|
||||
if (!match) {
|
||||
throw new Error(`Active instance ${activeName} not found in config`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
export async function connectSlackInstance(opts: {
|
||||
instance?: string;
|
||||
botToken: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
}) {
|
||||
const instance = await resolveInstance(opts.instance);
|
||||
setClient(
|
||||
instance.token,
|
||||
instance.remote.substring(0, instance.remote.length - 1)
|
||||
);
|
||||
|
||||
await wmill.connectSlackInstance({
|
||||
requestBody: {
|
||||
bot_token: opts.botToken,
|
||||
team_id: opts.teamId,
|
||||
team_name: opts.teamName,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
colors.bold.underline.green(
|
||||
`Slack connected at instance ${instance.name} (team ${opts.teamName} / ${opts.teamId})`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
export async function connectSlack(
|
||||
opts: GlobalOptions & {
|
||||
botToken: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
}
|
||||
) {
|
||||
await requireLogin(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
|
||||
await wmill.connectSlack({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
bot_token: opts.botToken,
|
||||
team_id: opts.teamId,
|
||||
team_name: opts.teamName,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
colors.bold.underline.green(
|
||||
`Slack connected to workspace ${workspace.workspaceId} (team ${opts.teamName} / ${opts.teamId})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function disconnectSlack(opts: GlobalOptions) {
|
||||
await requireLogin(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
|
||||
await wmill.disconnectSlack({ workspace: workspace.workspaceId });
|
||||
|
||||
log.info(
|
||||
colors.bold.underline.green(
|
||||
`Slack disconnected from workspace ${workspace.workspaceId} (slack_team_id / slack_name cleared). ` +
|
||||
`To also remove the bot token variable/resource/folder/group, delete the corresponding files from the local sync folder and run 'wmill sync push'. ` +
|
||||
`To remove the workspace-level OAuth override (if any), set slack_oauth_client_id/_secret to '' in settings.yaml and push.`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { setClient } from "../../core/client.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
|
||||
import { mergeWorkspaces } from "./merge.ts";
|
||||
import { connectSlack, disconnectSlack } from "./slack.ts";
|
||||
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
@@ -787,6 +788,19 @@ const command = new Command()
|
||||
.option("--exclude <items:string>", "Comma-separated kind:path items to exclude")
|
||||
.option("--preserve-on-behalf-of", "Preserve original on_behalf_of/permissioned_as values")
|
||||
.option("-y --yes", "Non-interactive mode (deploy without prompts)")
|
||||
.action(mergeWorkspaces as any);
|
||||
.action(mergeWorkspaces as any)
|
||||
.command("connect-slack")
|
||||
.description(
|
||||
"Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token."
|
||||
)
|
||||
.option("--bot-token <bot_token:string>", "Slack bot token (xoxb-...)", { required: true })
|
||||
.option("--team-id <team_id:string>", "Slack team id", { required: true })
|
||||
.option("--team-name <team_name:string>", "Slack team name", { required: true })
|
||||
.action(connectSlack as any)
|
||||
.command("disconnect-slack")
|
||||
.description(
|
||||
"Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push."
|
||||
)
|
||||
.action(disconnectSlack as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -35,10 +35,11 @@ export interface SuccessHandlerConfig {
|
||||
}
|
||||
|
||||
export interface SimplifiedSettings {
|
||||
// Grouped format (current)
|
||||
// Grouped format (current). Explicit `null` on error_handler / success_handler
|
||||
// signals "clear the remote value"; `undefined` means "not managed by git".
|
||||
auto_invite?: AutoInviteConfig;
|
||||
error_handler?: ErrorHandlerConfig;
|
||||
success_handler?: SuccessHandlerConfig;
|
||||
error_handler?: ErrorHandlerConfig | null;
|
||||
success_handler?: SuccessHandlerConfig | null;
|
||||
|
||||
// Other fields
|
||||
webhook?: string;
|
||||
@@ -56,6 +57,8 @@ export interface SimplifiedSettings {
|
||||
slack_team_id?: string;
|
||||
slack_name?: string;
|
||||
slack_command_script?: string;
|
||||
slack_oauth_client_id?: string;
|
||||
slack_oauth_client_secret?: string;
|
||||
}
|
||||
|
||||
// Legacy settings interface for reading old settings.yaml files
|
||||
@@ -83,6 +86,8 @@ interface LegacySimplifiedSettings {
|
||||
slack_team_id?: string;
|
||||
slack_name?: string;
|
||||
slack_command_script?: string;
|
||||
slack_oauth_client_id?: string;
|
||||
slack_oauth_client_secret?: string;
|
||||
}
|
||||
|
||||
// Helper to convert legacy flat settings to new grouped format
|
||||
@@ -104,6 +109,8 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
|
||||
if (settings.slack_team_id !== undefined) result.slack_team_id = settings.slack_team_id;
|
||||
if (settings.slack_name !== undefined) result.slack_name = settings.slack_name;
|
||||
if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script;
|
||||
if (settings.slack_oauth_client_id !== undefined) result.slack_oauth_client_id = settings.slack_oauth_client_id;
|
||||
if (settings.slack_oauth_client_secret !== undefined) result.slack_oauth_client_secret = settings.slack_oauth_client_secret;
|
||||
|
||||
// Handle auto_invite: check if already grouped or needs migration
|
||||
if (settings.auto_invite && typeof settings.auto_invite === "object") {
|
||||
@@ -117,8 +124,12 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle error_handler: check if already grouped or needs migration
|
||||
if (settings.error_handler && typeof settings.error_handler === "object") {
|
||||
// Handle error_handler: check if already grouped or needs migration.
|
||||
// Preserve explicit null as a signal to clear the remote handler (distinct
|
||||
// from absent = "not managed by git, leave remote alone").
|
||||
if (settings.error_handler === null) {
|
||||
result.error_handler = null;
|
||||
} else if (settings.error_handler && typeof settings.error_handler === "object") {
|
||||
result.error_handler = settings.error_handler;
|
||||
} else if (typeof settings.error_handler === "string") {
|
||||
// Legacy format (error_handler was a string path)
|
||||
@@ -129,8 +140,10 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle success_handler: check if already grouped or needs migration
|
||||
if (settings.success_handler && typeof settings.success_handler === "object") {
|
||||
// Handle success_handler: same semantics.
|
||||
if (settings.success_handler === null) {
|
||||
result.success_handler = null;
|
||||
} else if (settings.success_handler && typeof settings.success_handler === "object") {
|
||||
result.success_handler = settings.success_handler;
|
||||
} else if (typeof settings.success_handler === "string") {
|
||||
// Legacy format (success_handler was a string path)
|
||||
@@ -197,6 +210,8 @@ export async function pushWorkspaceSettings(
|
||||
slack_team_id: remoteSettings.slack_team_id,
|
||||
slack_name: remoteSettings.slack_name,
|
||||
slack_command_script: remoteSettings.slack_command_script,
|
||||
slack_oauth_client_id: remoteSettings.slack_oauth_client_id,
|
||||
slack_oauth_client_secret: remoteSettings.slack_oauth_client_secret,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get workspace settings: ${err}`);
|
||||
@@ -268,7 +283,10 @@ export async function pushWorkspaceSettings(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error_handler using grouped format
|
||||
// Handle error_handler using grouped format. YAML is canonical:
|
||||
// absent / null → clear remote; present object → upsert.
|
||||
// (Same "omit = clear" rule as every other workspace setting. Pull always
|
||||
// emits the field as null when remote is NULL, so round-trip is bijective.)
|
||||
if (!deepEqual(localSettings.error_handler, settings.error_handler)) {
|
||||
log.debug(`Updating error handler...`);
|
||||
const localErrorHandler = localSettings.error_handler;
|
||||
@@ -283,7 +301,7 @@ export async function pushWorkspaceSettings(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle success_handler using grouped format
|
||||
// Handle success_handler using grouped format. Same semantics as error_handler.
|
||||
if (!deepEqual(localSettings.success_handler, settings.success_handler)) {
|
||||
log.debug(`Updating success handler...`);
|
||||
const localSuccessHandler = localSettings.success_handler;
|
||||
@@ -401,6 +419,30 @@ export async function pushWorkspaceSettings(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace-level Slack OAuth override. YAML is canonical (same rule as
|
||||
// every other setting): both present → upsert; anything else → delete.
|
||||
// Pull always emits both fields as null when remote is NULL.
|
||||
if (
|
||||
localSettings.slack_oauth_client_id != settings.slack_oauth_client_id ||
|
||||
localSettings.slack_oauth_client_secret != settings.slack_oauth_client_secret
|
||||
) {
|
||||
log.debug(`Updating slack oauth config...`);
|
||||
if (
|
||||
localSettings.slack_oauth_client_id &&
|
||||
localSettings.slack_oauth_client_secret
|
||||
) {
|
||||
await wmill.setWorkspaceSlackOauthConfig({
|
||||
workspace,
|
||||
requestBody: {
|
||||
slack_oauth_client_id: localSettings.slack_oauth_client_id,
|
||||
slack_oauth_client_secret: localSettings.slack_oauth_client_secret,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await wmill.deleteWorkspaceSlackOauthConfig({ workspace });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushWorkspaceKey(
|
||||
|
||||
@@ -5519,6 +5519,11 @@ sync local with a remote instance or the opposite (push or pull)
|
||||
- \`-o, --output-file <file:string>\` - Write YAML to a file instead of stdout
|
||||
- \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting
|
||||
- \`--instance <instance:string>\` - Name of the instance, override the active instance
|
||||
- \`instance connect-slack\`
|
||||
- \`--bot-token <bot_token:string>\` - Slack bot token (xoxb-...)
|
||||
- \`--team-id <team_id:string>\` - Slack team id
|
||||
- \`--team-name <team_name:string>\` - Slack team name
|
||||
- \`--instance <instance:string>\` - Instance profile to connect against (defaults to the active instance)
|
||||
|
||||
### job
|
||||
|
||||
@@ -5867,6 +5872,11 @@ workspace related commands
|
||||
- \`--exclude <items:string>\` - Comma-separated kind:path items to exclude
|
||||
- \`--preserve-on-behalf-of\` - Preserve original on_behalf_of/permissioned_as values
|
||||
- \`-y --yes\` - Non-interactive mode (deploy without prompts)
|
||||
- \`workspace connect-slack\` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.
|
||||
- \`--bot-token <bot_token:string>\` - Slack bot token (xoxb-...)
|
||||
- \`--team-id <team_id:string>\` - Slack team id
|
||||
- \`--team-name <team_name:string>\` - Slack team name
|
||||
- \`workspace disconnect-slack\`
|
||||
|
||||
`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user