slack-term 1.37.0 → 1.38.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/ts/escapes.ts CHANGED
@@ -46,3 +46,14 @@ export function unescapeArg(s: string): string {
46
46
  export function hasEscapes(s: string): boolean {
47
47
  return unescapeArg(s) !== s;
48
48
  }
49
+
50
+ /** The inverse of `unescapeArg`, for printing a command that will be run again:
51
+ * `unescapeArg(escapeArg(x)) === x` for every `x`. Each lead is doubled first
52
+ * (so a literal `\\n` survives), then real newlines/tabs become `\\n`/`\\t` so
53
+ * the printed command stays on one line. */
54
+ export function escapeArg(s: string): string {
55
+ return s
56
+ .replace(new RegExp(`[${ESCAPE_LEAD}]`, "g"), (c) => `\\${c}`)
57
+ .replace(/\n/g, "\\n")
58
+ .replace(/\t/g, "\\t");
59
+ }
package/ts/pinlog.ts ADDED
@@ -0,0 +1,215 @@
1
+ // Pinlog — a pinned status board in a Slack channel.
2
+ //
3
+ // - The pinned TOP-LEVEL message is the current state (HEAD). It is edited in
4
+ // place, so it is always the whole truth and never needs scrolling.
5
+ // - Every change ALSO gets one short thread reply under it: the log.
6
+ // - Edits are silent; replies notify. So people are pinged once per real
7
+ // change, and the board itself never adds noise.
8
+ //
9
+ // This module is the part that needs no Slack: the footer marker that makes a
10
+ // message recognisable as a pinlog HEAD, and the JST stamp in it. Stateless by
11
+ // design: a board IS its message — addressed by permalink / C…:ts, found by
12
+ // its footer — so nothing about boards is kept on this machine. The commands themselves live in cli.ts beside send/edit,
13
+ // because they share its confirm gate.
14
+
15
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ /** The words every HEAD ends with. `list` finds boards by this, and `update`
20
+ * refuses a message without it — editing an arbitrary message into a "board"
21
+ * by passing the wrong ts is the mistake this guards against. */
22
+ export const PINLOG_MARKER = "Pinlog · 最終更新";
23
+
24
+ /** `2026-10-08 15:10 JST`. Always JST: the board is read by one team in Japan,
25
+ * and a stamp whose zone depends on whichever machine last updated it would
26
+ * make two consecutive updates disagree about what time it is. */
27
+ export function formatJst(now: Date): string {
28
+ const parts = new Intl.DateTimeFormat("en-GB", {
29
+ timeZone: "Asia/Tokyo",
30
+ hour12: false,
31
+ year: "numeric",
32
+ month: "2-digit",
33
+ day: "2-digit",
34
+ hour: "2-digit",
35
+ minute: "2-digit",
36
+ }).formatToParts(now);
37
+ const p = (t: string): string => parts.find((x) => x.type === t)?.value ?? "??";
38
+ // en-GB renders midnight as "24" in some engines; normalise to 00.
39
+ const hour = p("hour") === "24" ? "00" : p("hour");
40
+ return `${p("year")}-${p("month")}-${p("day")} ${hour}:${p("minute")} JST`;
41
+ }
42
+
43
+ export function pinlogFooter(now: Date): string {
44
+ return `_${PINLOG_MARKER} ${formatJst(now)} · 更新はスレッドに_`;
45
+ }
46
+
47
+ // The footer is the whole LAST line, exactly as `pinlogFooter` writes it — the
48
+ // underscores optional, since a human hand-editing the board in the Slack
49
+ // client may drop them. Anything looser (no line start, no timestamp) matches
50
+ // ordinary text that merely mentions the marker, and `stripPinlogFooter` would
51
+ // then cut that text off mid-sentence.
52
+ const FOOTER_RE = new RegExp(
53
+ `(^|\\n+)_?${PINLOG_MARKER} \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} JST · 更新はスレッドに_?[ \\t]*\\n*$`,
54
+ );
55
+
56
+ /** True when `text` is a pinlog HEAD (carries the footer marker). */
57
+ export function isPinlogHead(text: string): boolean {
58
+ return FOOTER_RE.test(text);
59
+ }
60
+
61
+ /** The state without its footer — what the owner wrote. */
62
+ export function stripPinlogFooter(text: string): string {
63
+ return text.replace(FOOTER_RE, "");
64
+ }
65
+
66
+ /** The full HEAD text: the state, a blank line, the footer. A state that
67
+ * already carries a footer (copied from `show`) has it replaced, not doubled. */
68
+ export function composeHead(state: string, now: Date): string {
69
+ return `${stripPinlogFooter(state).trimEnd()}\n\n${pinlogFooter(now)}`;
70
+ }
71
+
72
+ /** The "last updated" stamp in a HEAD's (trailing) footer, or null when there is
73
+ * none — not the first stamp anywhere: a state may quote an older footer. */
74
+ export function headUpdatedAt(text: string): string | null {
75
+ const footer = text.match(FOOTER_RE)?.[0];
76
+ const m = footer?.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2} JST)/);
77
+ return m ? m[1]! : null;
78
+ }
79
+
80
+ // --- lock: one writer per board on this machine -------------------------------
81
+
82
+ export class LockBusyError extends Error {
83
+ constructor(public holder: string) {
84
+ super(`locked by ${holder}`);
85
+ this.name = "LockBusyError";
86
+ }
87
+ }
88
+
89
+ export function lockDir(): string {
90
+ return join(process.env.HOME || homedir(), ".config", "slack-cli", "locks");
91
+ }
92
+
93
+ /** Is the process that wrote a lock still running? Unknown → assume yes. */
94
+ function holderAlive(content: string): boolean {
95
+ const m = content.match(/^pid=(\d+)/);
96
+ if (!m) return true;
97
+ try {
98
+ process.kill(Number(m[1]), 0);
99
+ return true;
100
+ } catch (e: unknown) {
101
+ return (e as NodeJS.ErrnoException).code === "EPERM"; // exists, not ours
102
+ }
103
+ }
104
+
105
+ /** Remove `path` only if it still holds `deadContent`. Read-compare-unlink is not
106
+ * atomic, so two breakers could both judge the same dead lock, and the slower
107
+ * one's unlink would then delete the lock the faster one just took. Every
108
+ * non-owner unlink therefore happens under a second O_EXCL file (`.break`):
109
+ * the re-read and the unlink are serialised, and a breaker that comes second
110
+ * re-reads the NEW owner's content and leaves it alone. (Owners unlink their
111
+ * own lock without it: nobody breaks a live owner's lock.)
112
+ *
113
+ * The mutex is owned like a lock: it names its pid, is released only by its
114
+ * owner (content check), and is cleared by others only when that pid is
115
+ * provably DEAD — never by age. Clearing by age let a live breaker that paused
116
+ * between its compare and its unlink lose the mutex, and then delete the live
117
+ * lock of whoever broke the dead one in the meantime. The one exception is a
118
+ * mutex with no parsable owner (a breaker that died between creating and
119
+ * writing it — microseconds), cleared after 10 s so it cannot wedge the lock. */
120
+ export function breakStaleLock(path: string, deadContent: string): void {
121
+ const mutex = `${path}.break`;
122
+ const mine = `pid=${process.pid} nonce=${Math.random().toString(36).slice(2)}${Date.now()}\n`;
123
+ let fd: number;
124
+ try {
125
+ fd = openSync(mutex, "wx");
126
+ writeFileSync(fd, mine);
127
+ } catch (e: unknown) {
128
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
129
+ // Another breaker holds it — leave it, unless its owner is gone.
130
+ try {
131
+ const held = readFileSync(mutex, "utf8");
132
+ const owned = /^pid=\d+/.test(held);
133
+ const stale = owned ? !holderAlive(held) : Date.now() - statSync(mutex).mtimeMs > 10_000;
134
+ if (stale && readFileSync(mutex, "utf8") === held) unlinkSync(mutex);
135
+ } catch { /* gone */ }
136
+ return;
137
+ }
138
+ try {
139
+ if (readFileSync(path, "utf8") === deadContent) unlinkSync(path);
140
+ } catch { /* already gone */ } finally {
141
+ closeSync(fd);
142
+ try { if (readFileSync(mutex, "utf8") === mine) unlinkSync(mutex); } catch { /* gone */ }
143
+ }
144
+ }
145
+
146
+ /** Take an exclusive lock (O_EXCL file) or throw LockBusyError.
147
+ *
148
+ * A lock is broken only when its holder is provably DEAD (no such pid) — age
149
+ * alone is not enough: a slow but live holder whose lock is broken would then
150
+ * run concurrently with the breaker, which is the race this exists to stop. A
151
+ * lock whose content cannot be parsed is broken after `staleMs`.
152
+ *
153
+ * Each lock carries a unique nonce, and release unlinks only while the file
154
+ * still carries OURS — so a holder whose lock was (wrongly) broken can never
155
+ * delete its successor's lock on the way out. Release is idempotent and also
156
+ * runs on process exit, because the confirm gate leaves via `process.exit`.
157
+ *
158
+ * Slack has no compare-and-swap on chat.update, so this is the only thing that
159
+ * keeps two confirmed updates from interleaving fetch → edit → log. It covers
160
+ * writers on THIS machine (the fleet runs on one); writers elsewhere are not
161
+ * serialised. */
162
+ export function acquireLock(key: string, opts: { dir?: string; staleMs?: number } = {}): () => void {
163
+ const dir = opts.dir ?? lockDir();
164
+ const staleMs = opts.staleMs ?? 120_000;
165
+ mkdirSync(dir, { recursive: true });
166
+ const path = join(dir, `${key.replace(/[^A-Za-z0-9._-]/g, "_")}.lock`);
167
+ const content = `pid=${process.pid} since=${new Date().toISOString()} nonce=${Math.random().toString(36).slice(2)}${Date.now()}\n`;
168
+ for (let attempt = 0; attempt < 3; attempt++) {
169
+ try {
170
+ const fd = openSync(path, "wx");
171
+ writeFileSync(fd, content);
172
+ closeSync(fd);
173
+ let held = true;
174
+ const release = (): void => {
175
+ if (!held) return;
176
+ held = false;
177
+ try {
178
+ if (readFileSync(path, "utf8") === content) unlinkSync(path);
179
+ } catch { /* already gone */ }
180
+ };
181
+ process.once("exit", release);
182
+ return release;
183
+ } catch (e: unknown) {
184
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
185
+ let existing: string;
186
+ let age: number;
187
+ try {
188
+ existing = readFileSync(path, "utf8");
189
+ age = Date.now() - statSync(path).mtimeMs;
190
+ } catch {
191
+ continue; // vanished between open and read — just retry
192
+ }
193
+ const parsable = /^pid=\d+/.test(existing);
194
+ const dead = parsable ? !holderAlive(existing) : age > staleMs;
195
+ if (dead) {
196
+ breakStaleLock(path, existing);
197
+ continue;
198
+ }
199
+ throw new LockBusyError(existing.trim().replace(/ nonce=\S+/, "") || "another process");
200
+ }
201
+ }
202
+ throw new LockBusyError("another process");
203
+ }
204
+
205
+ /** The id `create` prints and every other subcommand accepts. */
206
+ export function pinlogId(channel: string, ts: string): string {
207
+ return `${channel}:${ts}`;
208
+ }
209
+
210
+ /** `C00000001:1700000000.000100` → parts; anything else → null (the caller then
211
+ * tries #chan:ts / permalink). */
212
+ export function parsePinlogId(s: string): { channel: string; ts: string } | null {
213
+ const m = s.match(/^([CDG][A-Z0-9]{8,}):(\d{10}\.\d{6})$/);
214
+ return m ? { channel: m[1]!, ts: m[2]! } : null;
215
+ }
package/ts/profiles.ts CHANGED
@@ -268,10 +268,16 @@ export function resolveToken(workspaceFlag?: string): string {
268
268
  // Bot tokens in shell env or .env files must not shadow workspace profiles.
269
269
  const legacyEnvToken = process.env.SLACK_BOT_TOKEN ?? process.env.SLACK_MCP_XOXP_TOKEN;
270
270
  if (legacyEnvToken && names.length === 0) return legacyEnvToken;
271
- if (legacyEnvToken && names.length > 0) {
271
+ // A bot token (xoxb-) belongs in SLACK_BOT_TOKEN: it is how `slack ask` / --as-bot send, and
272
+ // ~/.config/slack-cli/.env is its documented home. It never stands in for the user token, so it is
273
+ // no conflict; warn only about a *user* token parked in the legacy variables.
274
+ const legacyUserToken = [process.env.SLACK_BOT_TOKEN, process.env.SLACK_MCP_XOXP_TOKEN].find(
275
+ (t) => t && !t.startsWith("xoxb-"),
276
+ );
277
+ if (legacyUserToken && names.length > 0) {
272
278
  // Warn only when the token differs from what's in .env.local (same value = not a real conflict)
273
279
  const envFileToken = walkDirEnv(process.cwd(), ["SLACK_BOT_TOKEN", "SLACK_MCP_XOXP_TOKEN"]);
274
- if (legacyEnvToken !== envFileToken) {
280
+ if (legacyUserToken !== envFileToken) {
275
281
  if (!(globalThis as Record<string, unknown>).__slackEnvWarnShown) {
276
282
  (globalThis as Record<string, unknown>).__slackEnvWarnShown = true;
277
283
  console.error(
package/ts/slack.ts CHANGED
@@ -460,6 +460,28 @@ export async function deleteMessage(
460
460
  await post(token, "chat.delete", { channel, ts }, cookie);
461
461
  }
462
462
 
463
+ /** One page of a thread, with Slack's cursor — for callers that must see the
464
+ * WHOLE thread (or say that they did not). */
465
+ export async function repliesCursor(
466
+ token: string,
467
+ channel: string,
468
+ ts: string,
469
+ cursor?: string,
470
+ limit = 200,
471
+ cookie?: string,
472
+ ): Promise<Json> {
473
+ const params: Record<string, string> = { channel, ts, limit: String(limit) };
474
+ if (cursor !== undefined) params.cursor = cursor;
475
+ return get(token, "conversations.replies", params, cookie);
476
+ }
477
+
478
+ /** Pin a message to its channel (pins.add, scope pins:write). Slack answers
479
+ * `already_pinned` when it already is — surfaced as an error like any other;
480
+ * the caller decides whether that counts. */
481
+ export async function pinsAdd(token: string, channel: string, ts: string, cookie?: string): Promise<void> {
482
+ await post(token, "pins.add", { channel, timestamp: ts }, cookie);
483
+ }
484
+
463
485
  // Add or remove an emoji reaction on a message. `name` is the emoji shortcode
464
486
  // without colons (e.g. "eyes", "white_check_mark"). Slack's reactions.add
465
487
  // returns already_reacted when the reaction exists; reactions.remove returns