mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
feat: bundle agent-slack CLI and route all calls through shared executor
Pins agent-slack@0.9.3, bundles it next to main.cjs (replaces the startup npm install -g), adds a structured-result executor with bundled/global/PATH resolution, a slack:cliStatus IPC probe, and a PATH shim so the Copilot skill keeps working.
This commit is contained in:
parent
3e2ffa9eb0
commit
2554a9b8da
10 changed files with 742 additions and 108 deletions
|
|
@ -42,4 +42,32 @@ await esbuild.build({
|
|||
},
|
||||
});
|
||||
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
// Bundle the vendored agent-slack CLI into a single self-contained script next
|
||||
// to main.cjs. It runs as a child process (process.execPath with
|
||||
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
|
||||
// be inlined into main.cjs. Bundling here means the packaged app needs neither
|
||||
// node_modules nor a global npm install.
|
||||
const agentSlackPkg = JSON.parse(
|
||||
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
await esbuild.build({
|
||||
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
outfile: './.package/dist/agent-slack.cjs',
|
||||
format: 'cjs',
|
||||
banner: { js: cjsBanner },
|
||||
define: {
|
||||
'import.meta.url': '__import_meta_url',
|
||||
// Without this constant the CLI's --version walks up the directory tree
|
||||
// for a package.json and would find Rowboat's instead of agent-slack's.
|
||||
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
|
||||
},
|
||||
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
|
||||
// back to node:sqlite; keep it external so the probe fails at runtime the
|
||||
// same way it does under plain node.
|
||||
external: ['bun:sqlite'],
|
||||
});
|
||||
|
||||
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"agent-slack": "0.9.3",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
|
|
|
|||
|
|
@ -16,12 +16,8 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
import { serviceBus } from '@x/core/dist/services/service_bus.js';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import z from 'zod';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
|
|
@ -38,6 +34,7 @@ import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
|||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { runAgentSlack, getAgentSlackCliStatus } from '@x/core/dist/slack/agent-slack-exec.js';
|
||||
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
|
||||
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
||||
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
||||
|
|
@ -100,8 +97,7 @@ type SlackHomeMessage = {
|
|||
url?: string;
|
||||
};
|
||||
|
||||
function parseJsonArrayPayload(stdout: string): unknown[] {
|
||||
const parsed = JSON.parse(stdout || '[]');
|
||||
function extractArrayPayload(parsed: unknown): unknown[] {
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
|
@ -168,16 +164,15 @@ async function resolveSlackUserName(
|
|||
args.push('--workspace', workspaceUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('agent-slack', args, { timeout: 10000, maxBuffer: 512 * 1024 });
|
||||
const parsed = JSON.parse(stdout || '{}');
|
||||
const name = extractSlackUserName(parsed);
|
||||
const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 });
|
||||
if (result.ok) {
|
||||
const name = extractSlackUserName(result.data ?? {});
|
||||
if (name) {
|
||||
cache.set(key, name);
|
||||
return name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Slack] Failed to resolve user ${userId}:`, error);
|
||||
} else {
|
||||
console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`);
|
||||
}
|
||||
|
||||
cache.set(key, userId);
|
||||
|
|
@ -844,43 +839,41 @@ export function setupIpcHandlers() {
|
|||
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
|
||||
return { success: true };
|
||||
},
|
||||
'slack:cliStatus': async () => {
|
||||
return await getAgentSlackCliStatus();
|
||||
},
|
||||
'slack:listWorkspaces': async () => {
|
||||
try {
|
||||
const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
|
||||
const parsed = JSON.parse(stdout);
|
||||
const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
return { workspaces };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
|
||||
return { workspaces: [], error: message };
|
||||
const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
||||
if (!result.ok) {
|
||||
return { workspaces: [], error: result.message };
|
||||
}
|
||||
const parsed = (result.data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
|
||||
const workspaces = (parsed.workspaces || []).map((w) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
return { workspaces };
|
||||
},
|
||||
'slack:listChannels': async (_event, args) => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('agent-slack', ['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeout: 15000 });
|
||||
const parsed = JSON.parse(stdout);
|
||||
const rawChannels = Array.isArray(parsed) ? parsed : (parsed.channels || parsed.items || parsed.results || []);
|
||||
const channels = rawChannels.map((ch: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
is_private?: boolean;
|
||||
isPrivate?: boolean;
|
||||
is_member?: boolean;
|
||||
isMember?: boolean;
|
||||
}) => ({
|
||||
id: ch.id || ch.name || '',
|
||||
name: ch.name || ch.id || '',
|
||||
isPrivate: ch.is_private ?? ch.isPrivate,
|
||||
isMember: ch.is_member ?? ch.isMember,
|
||||
})).filter((ch: { id: string; name: string }) => ch.id && ch.name);
|
||||
return { channels };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list Slack channels';
|
||||
return { channels: [], error: message };
|
||||
const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
|
||||
if (!result.ok) {
|
||||
return { channels: [], error: result.message };
|
||||
}
|
||||
const rawChannels = extractArrayPayload(result.data) as Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
is_private?: boolean;
|
||||
isPrivate?: boolean;
|
||||
is_member?: boolean;
|
||||
isMember?: boolean;
|
||||
}>;
|
||||
const channels = rawChannels.map((ch) => ({
|
||||
id: ch.id || ch.name || '',
|
||||
name: ch.name || ch.id || '',
|
||||
isPrivate: ch.is_private ?? ch.isPrivate,
|
||||
isMember: ch.is_member ?? ch.isMember,
|
||||
})).filter((ch) => ch.id && ch.name);
|
||||
return { channels };
|
||||
},
|
||||
'slack:getRecentMessages': async (_event, args) => {
|
||||
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
|
|
@ -907,8 +900,11 @@ export function setupIpcHandlers() {
|
|||
|
||||
if (channels.length === 0) {
|
||||
for (const workspace of config.workspaces) {
|
||||
const { stdout } = await execFileAsync('agent-slack', ['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeout: 15000 });
|
||||
const rawChannels = parseJsonArrayPayload(stdout);
|
||||
const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
|
||||
if (!channelList.ok) {
|
||||
throw new Error(channelList.message);
|
||||
}
|
||||
const rawChannels = extractArrayPayload(channelList.data);
|
||||
for (const raw of rawChannels) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const channel = raw as Record<string, unknown>;
|
||||
|
|
@ -928,36 +924,36 @@ export function setupIpcHandlers() {
|
|||
if (channel.workspaceUrl) {
|
||||
commandArgs.push('--workspace', channel.workspaceUrl);
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync('agent-slack', commandArgs, { timeout: 15000, maxBuffer: 1024 * 1024 });
|
||||
const rawMessages = parseJsonArrayPayload(stdout);
|
||||
for (const raw of rawMessages) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const message = raw as Record<string, unknown>;
|
||||
const ts = typeof message.ts === 'string' ? message.ts : undefined;
|
||||
const text = slackMessageText(message);
|
||||
if (!ts || !text) continue;
|
||||
const channelId = typeof message.channel_id === 'string'
|
||||
? message.channel_id
|
||||
: typeof message.channel === 'string'
|
||||
? message.channel
|
||||
: channel.id;
|
||||
const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
|
||||
const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
|
||||
messages.push({
|
||||
id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
|
||||
workspaceName: channel.workspaceName,
|
||||
workspaceUrl: channel.workspaceUrl,
|
||||
channelId,
|
||||
channelName: channel.name,
|
||||
author: resolvedAuthor,
|
||||
text: resolvedText,
|
||||
ts,
|
||||
url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Slack] Failed to load messages for ${channel.name}:`, error);
|
||||
const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 });
|
||||
if (!messageList.ok) {
|
||||
console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`);
|
||||
continue;
|
||||
}
|
||||
const rawMessages = extractArrayPayload(messageList.data);
|
||||
for (const raw of rawMessages) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const message = raw as Record<string, unknown>;
|
||||
const ts = typeof message.ts === 'string' ? message.ts : undefined;
|
||||
const text = slackMessageText(message);
|
||||
if (!ts || !text) continue;
|
||||
const channelId = typeof message.channel_id === 'string'
|
||||
? message.channel_id
|
||||
: typeof message.channel === 'string'
|
||||
? message.channel
|
||||
: channel.id;
|
||||
const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
|
||||
const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
|
||||
messages.push({
|
||||
id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
|
||||
workspaceName: channel.workspaceName,
|
||||
workspaceUrl: channel.workspaceUrl,
|
||||
channelId,
|
||||
channelName: channel.name,
|
||||
author: resolvedAuthor,
|
||||
text: resolvedText,
|
||||
ts,
|
||||
url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
|
|||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
|
||||
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
|
|
@ -54,8 +54,6 @@ import {
|
|||
} from "./deeplink.js";
|
||||
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
|
|
@ -311,18 +309,13 @@ app.whenReady().then(async () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Ensure agent-slack CLI is available
|
||||
try {
|
||||
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
|
||||
} catch {
|
||||
try {
|
||||
console.log('agent-slack not found, installing...');
|
||||
await execAsync('npm install -g agent-slack', { timeout: 60000 });
|
||||
console.log('agent-slack installed successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to install agent-slack:', e);
|
||||
}
|
||||
}
|
||||
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
|
||||
// and is resolved per call by the shared executor in @x/core. Availability is
|
||||
// exposed to the UI via the slack:cliStatus IPC channel; this startup log is
|
||||
// diagnostics only.
|
||||
getAgentSlackCliStatus().then((status) => {
|
||||
console.log('[Slack] agent-slack CLI status:', status);
|
||||
}).catch(() => { /* probe failures already surface through slack:cliStatus */ });
|
||||
|
||||
// Initialize all config files before UI can access them
|
||||
await initConfigs();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z, ZodType } from "zod";
|
|||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
|
|
@ -740,6 +741,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
try {
|
||||
const rootDir = path.resolve(WorkDir);
|
||||
const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir;
|
||||
// Make `agent-slack` resolvable for skill-authored shell
|
||||
// commands; the shim forwards to the bundled CLI.
|
||||
const env = agentSlackShimEnv(path.join(rootDir, 'bin'));
|
||||
|
||||
// TODO: Re-enable this check
|
||||
// const rootPrefix = rootDir.endsWith(path.sep)
|
||||
|
|
@ -758,6 +762,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
if (ctx?.signal) {
|
||||
const { promise, process: proc } = executeCommandAbortable(command, {
|
||||
cwd: workingDir,
|
||||
env,
|
||||
signal: ctx.signal,
|
||||
onData: (chunk: string) => {
|
||||
ctx.publish({
|
||||
|
|
@ -788,7 +793,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
|
||||
// Fallback to original for backward compatibility
|
||||
const result = await executeCommand(command, { cwd: workingDir });
|
||||
const result = await executeCommand(command, { cwd: workingDir, env });
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { execFile } from 'child_process';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { runAgentSlack as execAgentSlack } from '../../slack/agent-slack-exec.js';
|
||||
import { serviceLogger } from '../../services/service_logger.js';
|
||||
import { limitEventItems } from '../limit_event_items.js';
|
||||
import { createEvent } from '../../events/producer.js';
|
||||
import { knowledgeSourcesRepo } from './repo.js';
|
||||
import type { KnowledgeArtifact, KnowledgeSourceConfig, KnowledgeSourceScope } from './types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_RECENT_BACKFILL_SECONDS = 6 * 60 * 60;
|
||||
|
|
@ -97,12 +95,6 @@ function compareSlackTs(a: string | undefined, b: string | undefined): number {
|
|||
return an - bn;
|
||||
}
|
||||
|
||||
function parseJsonOutput(stdout: string): unknown {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return [];
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
function extractMessages(raw: unknown): SlackMessage[] {
|
||||
if (Array.isArray(raw)) return raw as SlackMessage[];
|
||||
if (raw && typeof raw === 'object') {
|
||||
|
|
@ -124,11 +116,12 @@ function getMessageAuthor(message: SlackMessage): string {
|
|||
}
|
||||
|
||||
async function runAgentSlack(args: string[]): Promise<unknown> {
|
||||
const { stdout } = await execFileAsync('agent-slack', args, {
|
||||
timeout: 30_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
});
|
||||
return parseJsonOutput(stdout);
|
||||
const result = await execAgentSlack(args, { timeoutMs: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
||||
if (!result.ok) {
|
||||
// Sync error handling stays throw-based for now; callers log per run.
|
||||
throw new Error(`agent-slack ${result.kind}: ${result.message}`);
|
||||
}
|
||||
return result.data ?? [];
|
||||
}
|
||||
|
||||
async function listMessages(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, oldest?: string): Promise<SlackMessage[]> {
|
||||
|
|
|
|||
139
apps/x/packages/core/src/slack/agent-slack-exec.test.ts
Normal file
139
apps/x/packages/core/src/slack/agent-slack-exec.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { exec } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { agentSlackShimEnv, resolveAgentSlackCli, runAgentSlack } from './agent-slack-exec.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Fixture CLI scripts spawned via process.execPath (real node under vitest),
|
||||
// exercising the same spawn path the app uses.
|
||||
let fixtureDir: string;
|
||||
let jsonCli: string;
|
||||
let garbageCli: string;
|
||||
let sleepCli: string;
|
||||
let failingCli: string;
|
||||
|
||||
function writeFixture(name: string, code: string): string {
|
||||
const file = path.join(fixtureDir, name);
|
||||
fs.writeFileSync(file, code, 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-slack-exec-test-'));
|
||||
jsonCli = writeFixture('json.cjs', `process.stdout.write(JSON.stringify({ args: process.argv.slice(2) }));`);
|
||||
garbageCli = writeFixture('garbage.cjs', `process.stdout.write('definitely: not json');`);
|
||||
sleepCli = writeFixture('sleep.cjs', `setTimeout(() => {}, 60_000);`);
|
||||
failingCli = writeFixture('fail.cjs', `process.stderr.write('boom'); process.exit(2);`);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const missing = path.join('/nonexistent', 'agent-slack.cjs');
|
||||
|
||||
describe('resolveAgentSlackCli', () => {
|
||||
it('prefers the bundled bin over global and PATH', () => {
|
||||
const resolved = resolveAgentSlackCli({
|
||||
bundledCandidates: [jsonCli],
|
||||
globalCandidates: [garbageCli],
|
||||
pathProbe: () => garbageCli,
|
||||
});
|
||||
expect(resolved).toEqual({ entry: jsonCli, source: 'bundled' });
|
||||
});
|
||||
|
||||
it('falls back to a global install when the bundled bin is missing', () => {
|
||||
const resolved = resolveAgentSlackCli({
|
||||
bundledCandidates: [missing],
|
||||
globalCandidates: [jsonCli],
|
||||
pathProbe: () => garbageCli,
|
||||
});
|
||||
expect(resolved).toEqual({ entry: jsonCli, source: 'global' });
|
||||
});
|
||||
|
||||
it('falls back to PATH last', () => {
|
||||
const resolved = resolveAgentSlackCli({
|
||||
bundledCandidates: [missing],
|
||||
globalCandidates: [missing],
|
||||
pathProbe: () => jsonCli,
|
||||
});
|
||||
expect(resolved).toEqual({ entry: jsonCli, source: 'path' });
|
||||
});
|
||||
|
||||
it('returns null when nothing is found', () => {
|
||||
const resolved = resolveAgentSlackCli({
|
||||
bundledCandidates: [missing],
|
||||
globalCandidates: [missing],
|
||||
pathProbe: () => null,
|
||||
});
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAgentSlack', () => {
|
||||
const via = (entry: string) => ({
|
||||
bundledCandidates: [entry],
|
||||
globalCandidates: [],
|
||||
pathProbe: () => null,
|
||||
});
|
||||
|
||||
it('returns parsed JSON stdout and forwards args', async () => {
|
||||
const result = await runAgentSlack(['auth', 'whoami'], { resolve: via(jsonCli) });
|
||||
expect(result).toMatchObject({ ok: true, data: { args: ['auth', 'whoami'] } });
|
||||
});
|
||||
|
||||
it('returns raw stdout when parseJson is false', async () => {
|
||||
const result = await runAgentSlack([], { resolve: via(garbageCli), parseJson: false });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.stdout).toBe('definitely: not json');
|
||||
});
|
||||
|
||||
it('reports not_installed when no binary resolves', async () => {
|
||||
const result = await runAgentSlack(['--version'], {
|
||||
resolve: { bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, kind: 'not_installed' });
|
||||
});
|
||||
|
||||
it('reports parse_error on malformed JSON stdout', async () => {
|
||||
const result = await runAgentSlack([], { resolve: via(garbageCli) });
|
||||
expect(result).toMatchObject({ ok: false, kind: 'parse_error' });
|
||||
});
|
||||
|
||||
it('kills a hung CLI and reports timeout', async () => {
|
||||
const result = await runAgentSlack([], { resolve: via(sleepCli), timeoutMs: 300 });
|
||||
expect(result).toMatchObject({ ok: false, kind: 'timeout' });
|
||||
}, 10_000);
|
||||
|
||||
it('reports exec_error with stderr on non-zero exit', async () => {
|
||||
const result = await runAgentSlack([], { resolve: via(failingCli) });
|
||||
expect(result).toMatchObject({ ok: false, kind: 'exec_error', stderr: 'boom' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentSlackShimEnv', () => {
|
||||
it('returns the base env unchanged when no CLI resolves', () => {
|
||||
const base = { PATH: '/usr/bin' };
|
||||
const env = agentSlackShimEnv(path.join(fixtureDir, 'bin'), base, {
|
||||
bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null,
|
||||
});
|
||||
expect(env).toBe(base);
|
||||
});
|
||||
|
||||
it('makes `agent-slack` runnable by name through a shell', async () => {
|
||||
const shimDir = path.join(fixtureDir, 'bin');
|
||||
const env = agentSlackShimEnv(shimDir, process.env, {
|
||||
bundledCandidates: [jsonCli], globalCandidates: [], pathProbe: () => null,
|
||||
});
|
||||
const pathKey = Object.keys(env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH';
|
||||
expect(env[pathKey]!.startsWith(`${shimDir}${path.delimiter}`)).toBe(true);
|
||||
|
||||
// Same spawn shape as executeCommand: command string through a shell.
|
||||
const { stdout } = await execAsync('agent-slack hello world', { env });
|
||||
expect(JSON.parse(stdout)).toEqual({ args: ['hello', 'world'] });
|
||||
});
|
||||
});
|
||||
267
apps/x/packages/core/src/slack/agent-slack-exec.ts
Normal file
267
apps/x/packages/core/src/slack/agent-slack-exec.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Single shared executor for the agent-slack CLI.
|
||||
*
|
||||
* Every agent-slack invocation in the app must go through runAgentSlack() —
|
||||
* never execFile('agent-slack', ...) directly. Spawning the bare command
|
||||
* requires it on PATH (we no longer auto-install it) and on Windows hits the
|
||||
* .cmd-shim EINVAL bug. Instead we resolve a JS entry file and spawn it with
|
||||
* process.execPath, which works without Node/npm on the user's machine.
|
||||
*/
|
||||
|
||||
export type AgentSlackSource = 'bundled' | 'global' | 'path';
|
||||
|
||||
export interface ResolvedAgentSlack {
|
||||
/** Absolute path to a JS entry file runnable via `node <entry>`. */
|
||||
entry: string;
|
||||
source: AgentSlackSource;
|
||||
}
|
||||
|
||||
export type AgentSlackErrorKind = 'not_installed' | 'timeout' | 'parse_error' | 'exec_error';
|
||||
|
||||
export type AgentSlackResult =
|
||||
| { ok: true; stdout: string; data: unknown }
|
||||
| { ok: false; kind: AgentSlackErrorKind; message: string; stderr: string };
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Re-probe even if a previous resolution succeeded. */
|
||||
refresh?: boolean;
|
||||
/** Test hooks — override the default probe locations. */
|
||||
bundledCandidates?: string[];
|
||||
globalCandidates?: string[];
|
||||
pathProbe?: () => string | null;
|
||||
}
|
||||
|
||||
export interface RunAgentSlackOptions {
|
||||
timeoutMs?: number;
|
||||
maxBuffer?: number;
|
||||
/** Set false for commands with non-JSON output (e.g. --version). */
|
||||
parseJson?: boolean;
|
||||
/** Test hook — bypass the default resolver. */
|
||||
resolve?: ResolveOptions;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
|
||||
// The CLI is bundled by apps/main/bundle.mjs to agent-slack.cjs next to
|
||||
// main.cjs. At runtime import.meta.url is rewritten by esbuild to point at
|
||||
// main.cjs, so the sibling lookup works in dev and packaged builds alike.
|
||||
// (Under vitest/tsc output the sibling doesn't exist and we fall through.)
|
||||
function defaultBundledCandidates(): string[] {
|
||||
return [path.join(path.dirname(fileURLToPath(import.meta.url)), 'agent-slack.cjs')];
|
||||
}
|
||||
|
||||
const GLOBAL_BIN_REL = path.join('node_modules', 'agent-slack', 'bin', 'agent-slack.js');
|
||||
|
||||
function defaultGlobalCandidates(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
return [path.join(appData, 'npm', GLOBAL_BIN_REL)];
|
||||
}
|
||||
return [
|
||||
path.join('/usr/local/lib', GLOBAL_BIN_REL),
|
||||
path.join('/opt/homebrew/lib', GLOBAL_BIN_REL),
|
||||
];
|
||||
}
|
||||
|
||||
/** Map a PATH hit (symlink, npm .cmd/.ps1/sh shim) to the underlying JS bin. */
|
||||
function jsEntryFromPathHit(hit: string): string | null {
|
||||
try {
|
||||
const real = fs.realpathSync(hit);
|
||||
if (/\.(c|m)?js$/.test(real)) return real;
|
||||
// npm shims live next to the global node_modules tree.
|
||||
const sibling = path.join(path.dirname(real), GLOBAL_BIN_REL);
|
||||
if (fs.existsSync(sibling)) return sibling;
|
||||
} catch {
|
||||
// Broken symlink or unreadable shim — treat as no hit.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultPathProbe(): string | null {
|
||||
const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync(lookup, ['agent-slack'], {
|
||||
timeout: 5_000,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const hit = line.trim();
|
||||
if (!hit) continue;
|
||||
const entry = jsEntryFromPathHit(hit);
|
||||
if (entry) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let cachedResolution: ResolvedAgentSlack | null = null;
|
||||
|
||||
export function resolveAgentSlackCli(opts: ResolveOptions = {}): ResolvedAgentSlack | null {
|
||||
if (cachedResolution && !opts.refresh
|
||||
&& !opts.bundledCandidates && !opts.globalCandidates && !opts.pathProbe) {
|
||||
return cachedResolution;
|
||||
}
|
||||
|
||||
let resolved: ResolvedAgentSlack | null = null;
|
||||
for (const candidate of opts.bundledCandidates ?? defaultBundledCandidates()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
resolved = { entry: candidate, source: 'bundled' };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!resolved) {
|
||||
for (const candidate of opts.globalCandidates ?? defaultGlobalCandidates()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
resolved = { entry: candidate, source: 'global' };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resolved) {
|
||||
const entry = (opts.pathProbe ?? defaultPathProbe)();
|
||||
if (entry) resolved = { entry, source: 'path' };
|
||||
}
|
||||
|
||||
// Only cache the default probe — test overrides must not leak, and a
|
||||
// failed probe should retry next call (the user may install meanwhile).
|
||||
if (resolved && !opts.bundledCandidates && !opts.globalCandidates && !opts.pathProbe) {
|
||||
cachedResolution = resolved;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function runAgentSlack(args: string[], opts: RunAgentSlackOptions = {}): Promise<AgentSlackResult> {
|
||||
const resolved = resolveAgentSlackCli(opts.resolve ?? {});
|
||||
if (!resolved) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'not_installed',
|
||||
message: 'agent-slack CLI not found (bundled copy missing and no global install)',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
let stdout: string;
|
||||
try {
|
||||
// process.execPath inside Electron's main process is the Electron
|
||||
// binary, not node — ELECTRON_RUN_AS_NODE makes it behave as plain
|
||||
// node (and is ignored when we already run under real node).
|
||||
const result = await execFileAsync(process.execPath, [resolved.entry, ...args], {
|
||||
timeout,
|
||||
maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
stdout = result.stdout;
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & { killed?: boolean; signal?: string; stderr?: string };
|
||||
const stderr = typeof err.stderr === 'string' ? err.stderr : '';
|
||||
if (err.code === 'ENOENT') {
|
||||
return { ok: false, kind: 'not_installed', message: `agent-slack entry vanished: ${resolved.entry}`, stderr };
|
||||
}
|
||||
if (err.killed || err.signal === 'SIGTERM') {
|
||||
return { ok: false, kind: 'timeout', message: `agent-slack timed out after ${timeout}ms`, stderr };
|
||||
}
|
||||
return { ok: false, kind: 'exec_error', message: err.message ?? 'agent-slack failed', stderr };
|
||||
}
|
||||
|
||||
if (opts.parseJson === false) {
|
||||
return { ok: true, stdout, data: undefined };
|
||||
}
|
||||
const trimmed = stdout.trim();
|
||||
try {
|
||||
return { ok: true, stdout, data: trimmed ? JSON.parse(trimmed) : undefined };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'parse_error',
|
||||
message: `agent-slack returned non-JSON output: ${trimmed.slice(0, 200)}`,
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentSlackCliStatus =
|
||||
| { available: true; version: string; source: AgentSlackSource }
|
||||
| { available: false };
|
||||
|
||||
/** Availability probe backing the slack:cliStatus IPC channel. */
|
||||
export async function getAgentSlackCliStatus(): Promise<AgentSlackCliStatus> {
|
||||
const resolved = resolveAgentSlackCli({ refresh: true });
|
||||
if (!resolved) return { available: false };
|
||||
const result = await runAgentSlack(['--version'], { timeoutMs: 10_000, parseJson: false });
|
||||
if (!result.ok) return { available: false };
|
||||
return { available: true, version: result.stdout.trim(), source: resolved.source };
|
||||
}
|
||||
|
||||
// --- PATH shim for shell consumers (Copilot skill via executeCommand) -------
|
||||
//
|
||||
// The Copilot Slack skill runs literal `agent-slack ...` shell commands. Those
|
||||
// used to rely on the startup `npm install -g` that this module replaced, so
|
||||
// without help they'd only work on machines with a manual global install.
|
||||
// We generate a tiny launcher script that forwards to the resolved CLI entry
|
||||
// and prepend its directory to PATH for executeCommand children.
|
||||
|
||||
let shimmedFor: string | null = null;
|
||||
|
||||
function ensureAgentSlackShim(shimDir: string, entry: string): void {
|
||||
const cacheKey = `${process.execPath} | ||||