slack-term 1.34.0 → 1.36.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/README.md +35 -3
- package/SKILL.md +27 -9
- package/dist/cli.js +184 -138
- package/package.json +1 -1
- package/ts/agent.ts +148 -0
- package/ts/ask.ts +14 -3
- package/ts/auth.ts +185 -16
- package/ts/cli.ts +216 -38
- package/ts/profiles.ts +11 -5
- package/ts/sentlog.ts +356 -0
- package/ts/slack-app.ts +180 -102
- package/ts/slack.ts +56 -5
package/package.json
CHANGED
package/ts/agent.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { constants } from "node:os";
|
|
3
|
+
import type { Argv } from "yargs";
|
|
4
|
+
import { resolveBotToken } from "./profiles.ts";
|
|
5
|
+
import { renameAgentSession, resolveChannel, setAgentStatus, type AgentStatus, type AgentThread } from "./slack.ts";
|
|
6
|
+
|
|
7
|
+
const statuses: AgentStatus[] = ["processing", "active", "suspended", "closed"];
|
|
8
|
+
type ParseTarget = (target: string) => { ref: string; threadTs?: string };
|
|
9
|
+
|
|
10
|
+
function botToken(envName?: string): string {
|
|
11
|
+
if (envName !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) {
|
|
12
|
+
throw new Error("--bot-token-env must name an environment variable, not contain a token.");
|
|
13
|
+
}
|
|
14
|
+
const token = envName === undefined ? resolveBotToken() : process.env[envName];
|
|
15
|
+
if (!token?.startsWith("xoxb-")) {
|
|
16
|
+
throw new Error("Agent commands require a bot token (xoxb). Set SLACK_BOT_TOKEN or --bot-token-env NAME; user tokens are refused.");
|
|
17
|
+
}
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateTitle(title?: string): void {
|
|
22
|
+
if (title !== undefined && (!title.trim() || [...title].length > 200)) {
|
|
23
|
+
throw new Error("--title must contain 1–200 characters.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function threadTarget(token: string, target: string, parse: ParseTarget): Promise<AgentThread> {
|
|
28
|
+
const { ref, threadTs } = parse(target);
|
|
29
|
+
if (!threadTs || !/^\d{10}\.\d{6}$/.test(threadTs)) {
|
|
30
|
+
throw new Error("Target must include a thread root: C00000001:1700000000.000100, #channel:ts, or a message permalink.");
|
|
31
|
+
}
|
|
32
|
+
return { channel_id: await resolveChannel(token, ref), thread_ts: threadTs };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function report(context: string, error: unknown): void {
|
|
36
|
+
console.error(`agent: ${context}: ${error instanceof Error ? error.message : "request failed"}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Own the command's lifetime, and serialize the last refresh before final cleanup. */
|
|
40
|
+
export async function runAgent(
|
|
41
|
+
token: string,
|
|
42
|
+
thread: AgentThread,
|
|
43
|
+
command: string[],
|
|
44
|
+
opts: { every: number; title?: string; close: boolean },
|
|
45
|
+
): Promise<number> {
|
|
46
|
+
let child: ChildProcess | undefined;
|
|
47
|
+
let signal: NodeJS.Signals | undefined;
|
|
48
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
50
|
+
let refreshing: Promise<void> | undefined;
|
|
51
|
+
let stopping = false;
|
|
52
|
+
let attempted = false;
|
|
53
|
+
let code = 1;
|
|
54
|
+
|
|
55
|
+
const stop = (received: NodeJS.Signals) => {
|
|
56
|
+
if (signal) return; // repeated interrupts must not bypass the final write
|
|
57
|
+
signal = received;
|
|
58
|
+
stopping = true;
|
|
59
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
60
|
+
child.kill(received);
|
|
61
|
+
killTimer = setTimeout(() => child?.kill("SIGKILL"), 5000);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const onInt = () => stop("SIGINT");
|
|
65
|
+
const onTerm = () => stop("SIGTERM");
|
|
66
|
+
process.on("SIGINT", onInt);
|
|
67
|
+
process.on("SIGTERM", onTerm);
|
|
68
|
+
try {
|
|
69
|
+
attempted = true; // even a lost response may have set processing remotely
|
|
70
|
+
await setAgentStatus(token, thread, "processing");
|
|
71
|
+
if (!stopping && opts.title !== undefined) await renameAgentSession(token, thread, opts.title);
|
|
72
|
+
if (!stopping) {
|
|
73
|
+
child = spawn(command[0]!, command.slice(1), { stdio: "inherit" });
|
|
74
|
+
const done = new Promise<number>((resolve) => {
|
|
75
|
+
child!.once("error", () => {
|
|
76
|
+
console.error("agent: could not start command");
|
|
77
|
+
resolve(127);
|
|
78
|
+
});
|
|
79
|
+
child!.once("exit", (exitCode, exitSignal) => {
|
|
80
|
+
resolve(exitCode ?? (exitSignal ? 128 + constants.signals[exitSignal] : 1));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
timer = setInterval(() => {
|
|
84
|
+
if (stopping || refreshing) return;
|
|
85
|
+
refreshing = setAgentStatus(token, thread, "processing")
|
|
86
|
+
.then(() => {})
|
|
87
|
+
.catch((error: unknown) => report("heartbeat failed; retrying on next tick", error))
|
|
88
|
+
.finally(() => { refreshing = undefined; });
|
|
89
|
+
}, opts.every * 1000);
|
|
90
|
+
code = await done;
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
report("could not start agent session", error);
|
|
94
|
+
} finally {
|
|
95
|
+
stopping = true;
|
|
96
|
+
clearInterval(timer);
|
|
97
|
+
clearTimeout(killTimer);
|
|
98
|
+
// No late processing write may race with (and undo) the final active write.
|
|
99
|
+
await refreshing;
|
|
100
|
+
if (attempted) {
|
|
101
|
+
try {
|
|
102
|
+
await setAgentStatus(token, thread, opts.close ? "closed" : "active");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
report("final status clear failed", error);
|
|
105
|
+
if (code === 0) code = 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
process.removeListener("SIGINT", onInt);
|
|
109
|
+
process.removeListener("SIGTERM", onTerm);
|
|
110
|
+
}
|
|
111
|
+
return signal ? 128 + constants.signals[signal] : code;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function agentCommands(y: Argv, parse: ParseTarget): Argv {
|
|
115
|
+
const common = (args: Argv) => args
|
|
116
|
+
.positional("target", { type: "string", demandOption: true, describe: "Thread root: raw channel ID:ts (avoids listing), #channel:ts, or permalink" })
|
|
117
|
+
.option("title", { type: "string", describe: "Rename the session (1–200 characters)" })
|
|
118
|
+
.option("bot-token-env", { type: "string", describe: "Environment variable holding an agent-enabled app's xoxb token (default SLACK_BOT_TOKEN); requires chat:write and channel membership" });
|
|
119
|
+
return y
|
|
120
|
+
.command("status <target> <status>", "Set a bot agent session's status", (args) => common(args)
|
|
121
|
+
.positional("status", { type: "string", choices: statuses, demandOption: true }), async (args) => {
|
|
122
|
+
validateTitle(args.title);
|
|
123
|
+
const token = botToken(args["bot-token-env"]);
|
|
124
|
+
const thread = await threadTarget(token, args.target, parse);
|
|
125
|
+
await setAgentStatus(token, thread, args.status as AgentStatus);
|
|
126
|
+
if (args.title !== undefined) await renameAgentSession(token, thread, args.title);
|
|
127
|
+
})
|
|
128
|
+
.command("run <target>", "Keep a thread working while a command runs: run <target> -- <command...>", (args) => common(args)
|
|
129
|
+
.parserConfiguration({ "populate--": true })
|
|
130
|
+
.option("every", { type: "number", default: 300, describe: "Heartbeat seconds (>0 and <3600); processing expires after one hour" })
|
|
131
|
+
.option("close", { type: "boolean", default: false, describe: "Finish with closed instead of active (also on failure or interrupt)" }), async (args) => {
|
|
132
|
+
validateTitle(args.title);
|
|
133
|
+
if (!Number.isFinite(args.every) || args.every <= 0 || args.every >= 3600) {
|
|
134
|
+
throw new Error("--every must be greater than 0 and less than 3600 seconds.");
|
|
135
|
+
}
|
|
136
|
+
// Preserve the exact argv, including numeric strings and the child's own --.
|
|
137
|
+
const separator = process.argv.indexOf("--");
|
|
138
|
+
const command = separator < 0 ? [] : process.argv.slice(separator + 1);
|
|
139
|
+
if (!command[0]) throw new Error("agent run requires -- <command...>");
|
|
140
|
+
const token = botToken(args["bot-token-env"]);
|
|
141
|
+
const thread = await threadTarget(token, args.target, parse);
|
|
142
|
+
process.exitCode = await runAgent(token, thread, command, {
|
|
143
|
+
every: args.every, close: args.close,
|
|
144
|
+
...(args.title !== undefined ? { title: args.title } : {}),
|
|
145
|
+
});
|
|
146
|
+
})
|
|
147
|
+
.demandCommand(1, "Choose agent status or agent run.");
|
|
148
|
+
}
|
package/ts/ask.ts
CHANGED
|
@@ -179,6 +179,12 @@ export function askBuildResolvedText(question: string, found: AskFound, who: str
|
|
|
179
179
|
return `${ASK_RESOLVED_PREFIX}*${question}*\n_${found.how}で回答済み${who ? ` (${who})` : ""}_\n\n${quoted}`;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Undo Slack's storage escaping. Only these three are ever escaped, and `&`
|
|
183
|
+
* goes LAST so an answer that literally contains `<` is not decoded twice. */
|
|
184
|
+
function askDecodeEntities(s: string): string {
|
|
185
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
186
|
+
}
|
|
187
|
+
|
|
182
188
|
export type AskParsed =
|
|
183
189
|
| { kind: "open"; question: string; reactable: string[]; overflow: string[]; threadOnly: boolean }
|
|
184
190
|
| { kind: "resolved"; question: string; answer: string }
|
|
@@ -222,10 +228,15 @@ export function askParseMessage(text: string): AskParsed {
|
|
|
222
228
|
// would re-poll a question that already has its answer.
|
|
223
229
|
if (text.startsWith(ASK_RESOLVED_PREFIX)) {
|
|
224
230
|
const head = lines[0]!.slice(ASK_RESOLVED_PREFIX.length);
|
|
225
|
-
const question = head.replace(/^\*/, "").replace(/\*$/, "");
|
|
231
|
+
const question = askDecodeEntities(head.replace(/^\*/, "").replace(/\*$/, ""));
|
|
232
|
+
// `> `, not only `> `: Slack HTML-escapes `&`, `<` and `>` in the text it
|
|
233
|
+
// hands back, so the quote we wrote as `> ` is STORED as `> `. Matching
|
|
234
|
+
// only the raw form made every collected question unreadable — re-running
|
|
235
|
+
// `--waitFor` on one exited 3 (measured 2026-09-27: 40 of 71 asks).
|
|
226
236
|
const answer = lines
|
|
227
|
-
.
|
|
228
|
-
.
|
|
237
|
+
.map((l) => (l.startsWith("> ") ? l.slice(2) : l.startsWith("> ") ? l.slice(5) : null))
|
|
238
|
+
.filter((l): l is string => l !== null)
|
|
239
|
+
.map(askDecodeEntities)
|
|
229
240
|
.join("\n");
|
|
230
241
|
// A ✅-stamped body with no quoted answer is not a settled question we can
|
|
231
242
|
// report; treat it as unknown rather than answer with an empty string.
|
package/ts/auth.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Interactive auth setup: slack auth login / slack login
|
|
2
2
|
import { createInterface, type Interface } from "node:readline/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { addProfile, listProfiles, setCookie, saveToEnvFile } from "./profiles.ts";
|
|
5
|
+
import { addProfile, listProfiles, setCookie, saveToEnvFile, resolveToken, resolveCookie, resolveBotToken } from "./profiles.ts";
|
|
6
6
|
import { authTest } from "./slack.ts";
|
|
7
|
-
import { extractSessions, discoverChromeCookies, discoverFirefoxCookies } from "./slack-app.ts";
|
|
7
|
+
import { extractSessions, extractChromeSessions, discoverChromeCookies, discoverFirefoxCookies } from "./slack-app.ts";
|
|
8
8
|
|
|
9
9
|
const USER_SCOPES = [
|
|
10
10
|
"search:read",
|
|
@@ -53,9 +53,16 @@ async function ask(rl: Interface, q: string): Promise<string> {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
async function allowBrowserProfileRead(rl: Interface | undefined, yes: boolean): Promise<boolean> {
|
|
57
|
+
if (yes) return true;
|
|
58
|
+
if (!rl) return false;
|
|
59
|
+
const answer = await ask(rl, "Read local browser profiles and Slack session cookies? [y/N]: ");
|
|
60
|
+
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
async function saveToken(rl: Interface | null, token: string, nameOverride?: string, cookie?: string): Promise<string> {
|
|
57
64
|
console.error("Verifying token...");
|
|
58
|
-
const info = await authTest(token);
|
|
65
|
+
const info = await authTest(token, cookie);
|
|
59
66
|
const defaultName = slugify(info.team);
|
|
60
67
|
let name: string;
|
|
61
68
|
if (nameOverride) {
|
|
@@ -106,16 +113,53 @@ async function saveToken(rl: Interface | null, token: string, nameOverride?: str
|
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
/** Shared logic for importing sessions from the Slack desktop app. */
|
|
109
|
-
export async function importFromDesktop(
|
|
116
|
+
export async function importFromDesktop(
|
|
117
|
+
rl?: Interface, yes = false, browser: "auto" | "all" | "chrome" | "firefox" | "none" = "auto",
|
|
118
|
+
saveProfileOnly = false,
|
|
119
|
+
): Promise<void> {
|
|
110
120
|
console.error("Scanning Slack desktop app...");
|
|
111
121
|
const sessions = await extractSessions();
|
|
112
122
|
if (sessions.length === 0) {
|
|
113
123
|
console.error("No sessions found. Make sure Slack is installed and you have signed in at least once.");
|
|
114
124
|
process.exit(1);
|
|
115
125
|
}
|
|
126
|
+
if (process.platform === "linux" && browser !== "none" && sessions.some((s) => !s.cookie) && await allowBrowserProfileRead(rl, yes)) {
|
|
127
|
+
const found: { source: string; profileName: string; cookie: string }[] = [];
|
|
128
|
+
if (browser !== "chrome") {
|
|
129
|
+
for (const candidate of discoverFirefoxCookies()) {
|
|
130
|
+
found.push({ source: "Firefox", profileName: candidate.profileName, cookie: candidate.cookie });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (browser === "chrome" || browser === "all" || (browser === "auto" && found.length === 0)) {
|
|
134
|
+
try {
|
|
135
|
+
for (const candidate of discoverChromeCookies().candidates) {
|
|
136
|
+
found.push({ source: "Chrome", profileName: candidate.profileName, cookie: candidate.cookie });
|
|
137
|
+
}
|
|
138
|
+
} catch (e: unknown) {
|
|
139
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (found.length === 1) {
|
|
143
|
+
for (const session of sessions) session.cookie ??= found[0]!.cookie;
|
|
144
|
+
console.log(`Found Slack session cookie in ${found[0]!.source} profile: ${found[0]!.profileName}`);
|
|
145
|
+
} else if (found.length > 1) {
|
|
146
|
+
console.log("Multiple browser sessions found. Choose one later with slack auth chrome or slack auth firefox.");
|
|
147
|
+
}
|
|
148
|
+
if (sessions.some((s) => !s.cookie)) {
|
|
149
|
+
console.log("Desktop session needs an xoxd cookie on Linux. Run: slack auth chrome or slack auth firefox");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await saveImportedSessions(sessions, rl, saveProfileOnly);
|
|
154
|
+
}
|
|
116
155
|
|
|
117
|
-
|
|
118
|
-
|
|
156
|
+
async function saveImportedSessions(
|
|
157
|
+
sessions: import("./slack-app.ts").SlackAppSession[], rl?: Interface, saveProfileOnly = false,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
// A session token without a cookie cannot pass auth.test yet. Save its
|
|
160
|
+
// LevelDB metadata as a profile so auth firefox can attach the cookie later.
|
|
161
|
+
// Single workspace + interactive + cookie: offer save-destination choice
|
|
162
|
+
if (sessions.length === 1 && rl && sessions[0]?.cookie && !saveProfileOnly) {
|
|
119
163
|
const s = sessions[0]!;
|
|
120
164
|
const teamLabel = s.teamName ?? s.teamId;
|
|
121
165
|
console.log(`Found workspace: ${teamLabel}${s.cookie ? " (+ xoxd cookie)" : ""}`);
|
|
@@ -220,9 +264,9 @@ async function loginNewApp(rl: Interface, mode: "user" | "bot"): Promise<void> {
|
|
|
220
264
|
* When run interactively, macOS will show a system dialog asking for the login password
|
|
221
265
|
* to grant access to the "Chrome Safe Storage" keychain item — click Allow.
|
|
222
266
|
*/
|
|
223
|
-
export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<void> {
|
|
224
|
-
if (process.platform !== "darwin") {
|
|
225
|
-
console.error("Chrome cookie extraction is
|
|
267
|
+
export async function cmdAuthChrome(opts: { workspace?: string; yes?: boolean } = {}): Promise<void> {
|
|
268
|
+
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
269
|
+
console.error("Chrome cookie extraction is supported on macOS and Linux.");
|
|
226
270
|
process.exit(1);
|
|
227
271
|
}
|
|
228
272
|
|
|
@@ -286,8 +330,21 @@ export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<
|
|
|
286
330
|
process.exit(1);
|
|
287
331
|
}
|
|
288
332
|
|
|
333
|
+
if (!opts.yes && !process.stdin.isTTY) {
|
|
334
|
+
console.error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
const confirmRl = opts.yes ? undefined : createInterface({ input: process.stdin, output: process.stdout });
|
|
338
|
+
try {
|
|
339
|
+
if (!await allowBrowserProfileRead(confirmRl, opts.yes ?? false)) {
|
|
340
|
+
console.log("Browser profile scan cancelled.");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
confirmRl?.close();
|
|
345
|
+
}
|
|
289
346
|
console.log("Scanning Chrome profiles for Slack session...");
|
|
290
|
-
console.log("macOS may show a dialog asking for your login password — click Allow.");
|
|
347
|
+
if (process.platform === "darwin") console.log("macOS may show a dialog asking for your login password — click Allow.");
|
|
291
348
|
|
|
292
349
|
let candidates: import("./slack-app.ts").ChromeCookieCandidate[];
|
|
293
350
|
let totalProfiles: number;
|
|
@@ -300,7 +357,7 @@ export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<
|
|
|
300
357
|
|
|
301
358
|
if (candidates.length === 0) {
|
|
302
359
|
console.error(`No Slack session found in Chrome (scanned ${totalProfiles} profile${totalProfiles !== 1 ? "s" : ""}). Possible reasons:`);
|
|
303
|
-
console.error(" - You denied the keychain dialog (try running again and click Allow)");
|
|
360
|
+
if (process.platform === "darwin") console.error(" - You denied the keychain dialog (try running again and click Allow)");
|
|
304
361
|
console.error(" - Chrome is not installed or has no Slack session");
|
|
305
362
|
console.error(" - You're not logged in to Slack in Chrome");
|
|
306
363
|
process.exit(1);
|
|
@@ -339,7 +396,7 @@ export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<
|
|
|
339
396
|
* Attach the xoxd session cookie from Firefox browser to an existing workspace profile.
|
|
340
397
|
* Firefox stores cookies in plaintext — no keychain access needed.
|
|
341
398
|
*/
|
|
342
|
-
export async function cmdAuthFirefox(opts: { workspace?: string } = {}): Promise<void> {
|
|
399
|
+
export async function cmdAuthFirefox(opts: { workspace?: string; yes?: boolean } = {}): Promise<void> {
|
|
343
400
|
const profiles = listProfiles();
|
|
344
401
|
if (profiles.length === 0) {
|
|
345
402
|
console.error("No workspaces configured. Run: slack auth login");
|
|
@@ -376,6 +433,19 @@ export async function cmdAuthFirefox(opts: { workspace?: string } = {}): Promise
|
|
|
376
433
|
}
|
|
377
434
|
}
|
|
378
435
|
|
|
436
|
+
if (!opts.yes && !process.stdin.isTTY) {
|
|
437
|
+
console.error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
const confirmRl = opts.yes ? undefined : createInterface({ input: process.stdin, output: process.stdout });
|
|
441
|
+
try {
|
|
442
|
+
if (!await allowBrowserProfileRead(confirmRl, opts.yes ?? false)) {
|
|
443
|
+
console.log("Browser profile scan cancelled.");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
} finally {
|
|
447
|
+
confirmRl?.close();
|
|
448
|
+
}
|
|
379
449
|
console.log("Scanning Firefox profiles for Slack session...");
|
|
380
450
|
const candidates = discoverFirefoxCookies();
|
|
381
451
|
|
|
@@ -450,7 +520,44 @@ export async function cmdAuthApp(opts: { bot?: boolean } = {}): Promise<void> {
|
|
|
450
520
|
}
|
|
451
521
|
}
|
|
452
522
|
|
|
453
|
-
|
|
523
|
+
/** Print the currently resolved credentials as dotenv assignments. */
|
|
524
|
+
export function cmdAuthTokens(opts: { workspace?: string } = {}): void {
|
|
525
|
+
const format = (name: string, value: string): string => {
|
|
526
|
+
if (/[\r\n]/.test(value)) throw new Error(`Invalid newline in ${name}.`);
|
|
527
|
+
return `${name}=${/^[A-Za-z0-9._=-]+$/.test(value) ? value : JSON.stringify(value)}`;
|
|
528
|
+
};
|
|
529
|
+
const lines = [format("SLACK_TOKEN", resolveToken(opts.workspace))];
|
|
530
|
+
const cookie = resolveCookie(opts.workspace);
|
|
531
|
+
if (cookie) lines.push(format("SLACK_COOKIE", cookie));
|
|
532
|
+
if (!opts.workspace) {
|
|
533
|
+
const bot = resolveBotToken();
|
|
534
|
+
if (bot) lines.push(format("SLACK_BOT_TOKEN", bot));
|
|
535
|
+
}
|
|
536
|
+
console.log(lines.join("\n"));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Export a selected profile for env-file based CLI use. */
|
|
540
|
+
export function cmdAuthSave(opts: { envfile: string; workspace?: string }): void {
|
|
541
|
+
const profiles = listProfiles();
|
|
542
|
+
const selected = opts.workspace
|
|
543
|
+
? profiles.find((entry) => entry.name === opts.workspace)
|
|
544
|
+
: profiles.find((entry) => entry.current) ?? (profiles.length === 1 ? profiles[0] : undefined);
|
|
545
|
+
if (!selected) {
|
|
546
|
+
throw new Error(opts.workspace
|
|
547
|
+
? `Workspace "${opts.workspace}" not found. Run: slack auth ls`
|
|
548
|
+
: "Select a workspace with slack auth use <name>, or pass --workspace <name>.");
|
|
549
|
+
}
|
|
550
|
+
if (!selected.profile.cookie) {
|
|
551
|
+
throw new Error(`Workspace "${selected.name}" has no session cookie. Run: slack auth chrome -w ${selected.name} or slack auth firefox -w ${selected.name}`);
|
|
552
|
+
}
|
|
553
|
+
const filePath = resolve(opts.envfile);
|
|
554
|
+
saveToEnvFile(filePath, { SLACK_TOKEN: selected.profile.token, SLACK_COOKIE: selected.profile.cookie });
|
|
555
|
+
console.log(`Saved token and cookie for workspace "${selected.name}" to ${filePath}`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function cmdAuthLogin(opts: {
|
|
559
|
+
token?: string; name?: string; yes?: boolean; fromDesktop?: boolean; fromChrome?: boolean; fromFirefox?: boolean; fromAll?: boolean;
|
|
560
|
+
} = {}): Promise<void> {
|
|
454
561
|
// Show existing profiles if any
|
|
455
562
|
const existing = listProfiles();
|
|
456
563
|
if (existing.length > 0) {
|
|
@@ -462,6 +569,68 @@ export async function cmdAuthLogin(opts: { token?: string; name?: string } = {})
|
|
|
462
569
|
console.log("");
|
|
463
570
|
}
|
|
464
571
|
|
|
572
|
+
if ([opts.fromChrome, opts.fromFirefox, opts.fromAll].filter(Boolean).length > 1) {
|
|
573
|
+
throw new Error("Choose only one browser source: --from-chrome, --from-firefox, or --from-all.");
|
|
574
|
+
}
|
|
575
|
+
if (opts.fromChrome && !opts.fromDesktop) {
|
|
576
|
+
if (!opts.yes && !process.stdin.isTTY) {
|
|
577
|
+
throw new Error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
|
|
578
|
+
}
|
|
579
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
580
|
+
try {
|
|
581
|
+
if (!await allowBrowserProfileRead(rl, opts.yes ?? false)) {
|
|
582
|
+
console.log("Browser profile scan cancelled.");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const sessions = await extractChromeSessions();
|
|
586
|
+
if (sessions.length === 0) throw new Error("No Slack session token found in Chrome profiles.");
|
|
587
|
+
await saveImportedSessions(sessions, rl, true);
|
|
588
|
+
} finally {
|
|
589
|
+
rl?.close();
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (opts.fromAll && !opts.fromDesktop) {
|
|
594
|
+
if (!opts.yes && !process.stdin.isTTY) {
|
|
595
|
+
throw new Error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
|
|
596
|
+
}
|
|
597
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
598
|
+
try {
|
|
599
|
+
try {
|
|
600
|
+
await importFromDesktop(rl, opts.yes ?? false, "all", true);
|
|
601
|
+
} catch (e: unknown) {
|
|
602
|
+
if (!(e instanceof Error) || !e.message.startsWith("Slack desktop app LevelDB not found")) throw e;
|
|
603
|
+
if (!await allowBrowserProfileRead(rl, opts.yes ?? false)) {
|
|
604
|
+
console.log("Browser profile scan cancelled.");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const sessions = await extractChromeSessions();
|
|
608
|
+
if (sessions.length === 0) throw new Error("No Slack session token found in Chrome profiles.");
|
|
609
|
+
if (sessions.every((s) => !s.cookie)) {
|
|
610
|
+
const firefox = discoverFirefoxCookies();
|
|
611
|
+
if (firefox.length === 1) for (const session of sessions) session.cookie = firefox[0]!.cookie;
|
|
612
|
+
}
|
|
613
|
+
await saveImportedSessions(sessions, rl, true);
|
|
614
|
+
}
|
|
615
|
+
} finally {
|
|
616
|
+
rl?.close();
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (opts.fromDesktop || opts.fromChrome || opts.fromFirefox || opts.fromAll) {
|
|
621
|
+
const browser = opts.fromChrome ? "chrome" : opts.fromFirefox ? "firefox" : opts.fromAll ? "all" : "none";
|
|
622
|
+
if (browser !== "none" && !opts.yes && !process.stdin.isTTY) {
|
|
623
|
+
throw new Error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
|
|
624
|
+
}
|
|
625
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
626
|
+
try {
|
|
627
|
+
await importFromDesktop(rl, opts.yes ?? false, browser, true);
|
|
628
|
+
} finally {
|
|
629
|
+
rl?.close();
|
|
630
|
+
}
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
465
634
|
// Non-interactive: token passed via --token flag or piped via stdin
|
|
466
635
|
if (opts.token) {
|
|
467
636
|
await saveToken(null, opts.token, opts.name);
|
|
@@ -485,7 +654,7 @@ export async function cmdAuthLogin(opts: { token?: string; name?: string } = {})
|
|
|
485
654
|
console.log("");
|
|
486
655
|
console.log(" 1) Slack desktop app - import session token");
|
|
487
656
|
console.log(" Reads the xoxc- token directly from the installed app.");
|
|
488
|
-
console.log(" Token: all platforms | xoxd cookie: macOS
|
|
657
|
+
console.log(" Token: all platforms | xoxd cookie: macOS, or Chrome/Firefox on Linux");
|
|
489
658
|
console.log("");
|
|
490
659
|
console.log(" 2) Connect existing Slack app [recommended if you have one]");
|
|
491
660
|
console.log(" Paste a token from an app you already created.");
|
|
@@ -503,7 +672,7 @@ export async function cmdAuthLogin(opts: { token?: string; name?: string } = {})
|
|
|
503
672
|
console.log("");
|
|
504
673
|
|
|
505
674
|
if (choice === "1") {
|
|
506
|
-
await importFromDesktop(rl);
|
|
675
|
+
await importFromDesktop(rl, opts.yes ?? false);
|
|
507
676
|
} else if (choice === "2") {
|
|
508
677
|
await loginExisting(rl);
|
|
509
678
|
} else if (choice === "3") {
|