@gajae-gateway/cli 0.1.4 → 0.1.6
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/dist/list.d.ts +8 -2
- package/dist/list.d.ts.map +1 -1
- package/dist/main.d.ts +8 -2
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +2080 -67
- package/dist/migrate/hermes.d.ts +24 -0
- package/dist/migrate/hermes.d.ts.map +1 -0
- package/dist/migrate/openclaw.d.ts +10 -0
- package/dist/migrate/openclaw.d.ts.map +1 -0
- package/dist/migrate/plan.d.ts +117 -0
- package/dist/migrate/plan.d.ts.map +1 -0
- package/dist/migrate/source-fs.d.ts +34 -0
- package/dist/migrate/source-fs.d.ts.map +1 -0
- package/dist/migrate.d.ts +15 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/restart-stack.d.ts.map +1 -1
- package/dist/services.d.ts.map +1 -1
- package/dist/setup.d.ts +130 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/update.d.ts +95 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/upgrade.d.ts +40 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/list.ts +35 -10
- package/src/main.ts +65 -5
- package/src/migrate/hermes.ts +436 -0
- package/src/migrate/openclaw.ts +284 -0
- package/src/migrate/plan.ts +343 -0
- package/src/migrate/source-fs.ts +126 -0
- package/src/migrate.ts +158 -0
- package/src/restart-stack.ts +11 -4
- package/src/services.ts +8 -5
- package/src/setup.ts +606 -0
- package/src/update.ts +450 -0
- package/src/upgrade.ts +205 -0
package/src/setup.ts
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `gajaeway setup`: guided credential setup for the platform adapters.
|
|
7
|
+
*
|
|
8
|
+
* Invariants, each pinned by tests:
|
|
9
|
+
* - a token is validated against its platform API before anything is written;
|
|
10
|
+
* one rejected token writes nothing at all, so a half-configured home never
|
|
11
|
+
* exists;
|
|
12
|
+
* - tokens are only ever read from the environment or a no-echo prompt, never
|
|
13
|
+
* from argv (argv is visible in `ps` and shell history);
|
|
14
|
+
* - secret files are written 0600 inside a 0700 `secrets/` directory, atomically;
|
|
15
|
+
* - no message this module produces contains a token: every error passes
|
|
16
|
+
* through `redact` with every secret collected so far.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const SETUP_PLATFORMS = ["discord", "slack", "telegram"] as const;
|
|
20
|
+
export type SetupPlatform = (typeof SETUP_PLATFORMS)[number];
|
|
21
|
+
|
|
22
|
+
/** Environment variables `--from-env` reads. Tokens have no flag spelling on purpose. */
|
|
23
|
+
export const SETUP_ENV = {
|
|
24
|
+
discordToken: "GAJAEWAY_DISCORD_TOKEN",
|
|
25
|
+
discordAppId: "GAJAEWAY_DISCORD_APP_ID",
|
|
26
|
+
slackBotToken: "GAJAEWAY_SLACK_BOT_TOKEN",
|
|
27
|
+
slackAppToken: "GAJAEWAY_SLACK_APP_TOKEN",
|
|
28
|
+
telegramToken: "GAJAEWAY_TELEGRAM_TOKEN",
|
|
29
|
+
ownerIds: "GAJAEWAY_OWNER_IDS",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export const SETUP_USAGE =
|
|
33
|
+
"usage: gajaeway setup [--from-env] [--adapters discord,slack,telegram] [--owner ID[,ID...]] [--discord-app-id ID] | setup --status";
|
|
34
|
+
|
|
35
|
+
/** Secret file names under `$GAJAEWAY_HOME/secrets`, matching docs/deployment.md. */
|
|
36
|
+
export const SECRET_FILES = {
|
|
37
|
+
discord: "discord-token",
|
|
38
|
+
slackBot: "slack-bot-token",
|
|
39
|
+
slackApp: "slack-app-token",
|
|
40
|
+
telegram: "telegram-token",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
44
|
+
|
|
45
|
+
export class SetupError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "SetupError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const REDACTED = "[redacted]";
|
|
53
|
+
|
|
54
|
+
/** Replaces every occurrence of every secret. Short strings are skipped so a stray "a" cannot shred output. */
|
|
55
|
+
export function redact(text: string, secrets: Iterable<string>): string {
|
|
56
|
+
let out = text;
|
|
57
|
+
for (const secret of secrets) if (secret.length >= 8) out = out.split(secret).join(REDACTED);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ParsedSetupArgs {
|
|
62
|
+
readonly mode: "setup" | "status";
|
|
63
|
+
readonly fromEnv: boolean;
|
|
64
|
+
readonly adapters?: readonly SetupPlatform[];
|
|
65
|
+
readonly owners: readonly string[];
|
|
66
|
+
readonly discordAppId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseSetupArgs(args: readonly string[]): ParsedSetupArgs {
|
|
70
|
+
let fromEnv = false;
|
|
71
|
+
let status = false;
|
|
72
|
+
let adapters: SetupPlatform[] | undefined;
|
|
73
|
+
const owners: string[] = [];
|
|
74
|
+
let discordAppId: string | undefined;
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
const flag = args[i] as string;
|
|
77
|
+
if (flag === "--from-env") fromEnv = true;
|
|
78
|
+
else if (flag === "--status") status = true;
|
|
79
|
+
else if (flag === "--adapters" || flag === "--owner" || flag === "--discord-app-id") {
|
|
80
|
+
const value = args[++i];
|
|
81
|
+
if (value === undefined || value.trim() === "" || value.startsWith("--"))
|
|
82
|
+
throw new SetupError(`${flag} expects a value\n${SETUP_USAGE}`);
|
|
83
|
+
if (flag === "--adapters") adapters = parseAdapterList(value);
|
|
84
|
+
else if (flag === "--owner") owners.push(...splitList(value));
|
|
85
|
+
else discordAppId = value.trim();
|
|
86
|
+
} else if (/token/i.test(flag)) {
|
|
87
|
+
throw new SetupError(
|
|
88
|
+
`${flag.split("=")[0]}: tokens are never accepted as arguments (argv is visible to every local user); export ${SETUP_ENV.discordToken}/${SETUP_ENV.slackBotToken}/${SETUP_ENV.slackAppToken}/${SETUP_ENV.telegramToken} and pass --from-env, or run setup interactively`,
|
|
89
|
+
);
|
|
90
|
+
} else throw new SetupError(`unknown option: ${flag.split("=")[0]}\n${SETUP_USAGE}`);
|
|
91
|
+
}
|
|
92
|
+
if (status && (fromEnv || adapters || owners.length > 0 || discordAppId))
|
|
93
|
+
throw new SetupError(`--status takes no other options\n${SETUP_USAGE}`);
|
|
94
|
+
return {
|
|
95
|
+
mode: status ? "status" : "setup",
|
|
96
|
+
fromEnv,
|
|
97
|
+
...(adapters ? { adapters } : {}),
|
|
98
|
+
owners,
|
|
99
|
+
...(discordAppId ? { discordAppId } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function splitList(value: string): string[] {
|
|
104
|
+
return value
|
|
105
|
+
.split(",")
|
|
106
|
+
.map((part) => part.trim())
|
|
107
|
+
.filter((part) => part.length > 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseAdapterList(value: string): SetupPlatform[] {
|
|
111
|
+
const list = splitList(value);
|
|
112
|
+
if (list.length === 0) throw new SetupError("--adapters expects at least one of discord, slack, telegram");
|
|
113
|
+
for (const name of list)
|
|
114
|
+
if (!(SETUP_PLATFORMS as readonly string[]).includes(name))
|
|
115
|
+
throw new SetupError(`unknown adapter: ${name} (expected discord, slack, or telegram)`);
|
|
116
|
+
return [...new Set(list)] as SetupPlatform[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Tokens collected for the adapters being (re)configured. */
|
|
120
|
+
export interface SetupTokens {
|
|
121
|
+
readonly discord?: { readonly token: string; readonly appId?: string };
|
|
122
|
+
readonly slack?: { readonly botToken: string; readonly appToken: string };
|
|
123
|
+
readonly telegram?: { readonly token: string };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
127
|
+
const trimmed = value?.trim();
|
|
128
|
+
return trimmed ? trimmed : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The `--from-env` token set. With `--adapters`, every named adapter must be
|
|
133
|
+
* fully present; without it, every adapter whose variables are present is
|
|
134
|
+
* enabled, and a half-present Slack pair is an error rather than a skip.
|
|
135
|
+
*/
|
|
136
|
+
export function tokensFromEnv(
|
|
137
|
+
env: NodeJS.ProcessEnv,
|
|
138
|
+
adapters: readonly SetupPlatform[] | undefined,
|
|
139
|
+
discordAppId: string | undefined,
|
|
140
|
+
): SetupTokens {
|
|
141
|
+
const discord = nonEmpty(env[SETUP_ENV.discordToken]);
|
|
142
|
+
const slackBot = nonEmpty(env[SETUP_ENV.slackBotToken]);
|
|
143
|
+
const slackApp = nonEmpty(env[SETUP_ENV.slackAppToken]);
|
|
144
|
+
const telegram = nonEmpty(env[SETUP_ENV.telegramToken]);
|
|
145
|
+
const appId = discordAppId ?? nonEmpty(env[SETUP_ENV.discordAppId]);
|
|
146
|
+
const wanted = adapters ?? SETUP_PLATFORMS.filter((platform) => presentFor(platform));
|
|
147
|
+
function presentFor(platform: SetupPlatform): boolean {
|
|
148
|
+
if (platform === "discord") return discord !== undefined;
|
|
149
|
+
if (platform === "telegram") return telegram !== undefined;
|
|
150
|
+
return slackBot !== undefined || slackApp !== undefined;
|
|
151
|
+
}
|
|
152
|
+
if (wanted.length === 0)
|
|
153
|
+
throw new SetupError(
|
|
154
|
+
`--from-env found no adapter tokens; set ${SETUP_ENV.discordToken}, ${SETUP_ENV.slackBotToken} and ${SETUP_ENV.slackAppToken}, or ${SETUP_ENV.telegramToken}`,
|
|
155
|
+
);
|
|
156
|
+
const tokens: { -readonly [K in keyof SetupTokens]: SetupTokens[K] } = {};
|
|
157
|
+
for (const platform of wanted) {
|
|
158
|
+
if (platform === "discord") {
|
|
159
|
+
if (!discord) throw new SetupError(`discord: ${SETUP_ENV.discordToken} is not set`);
|
|
160
|
+
tokens.discord = { token: discord, ...(appId ? { appId } : {}) };
|
|
161
|
+
} else if (platform === "slack") {
|
|
162
|
+
if (!slackBot) throw new SetupError(`slack: ${SETUP_ENV.slackBotToken} is not set`);
|
|
163
|
+
if (!slackApp) throw new SetupError(`slack: ${SETUP_ENV.slackAppToken} is not set (Socket Mode app-level token)`);
|
|
164
|
+
tokens.slack = { botToken: slackBot, appToken: slackApp };
|
|
165
|
+
} else {
|
|
166
|
+
if (!telegram) throw new SetupError(`telegram: ${SETUP_ENV.telegramToken} is not set`);
|
|
167
|
+
tokens.telegram = { token: telegram };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return tokens;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function secretsOf(tokens: SetupTokens): string[] {
|
|
174
|
+
return [tokens.discord?.token, tokens.slack?.botToken, tokens.slack?.appToken, tokens.telegram?.token].filter(
|
|
175
|
+
(value): value is string => value !== undefined,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What each platform said about the credentials; never contains a token. */
|
|
180
|
+
export interface ValidatedIdentity {
|
|
181
|
+
readonly platform: SetupPlatform;
|
|
182
|
+
readonly summary: string;
|
|
183
|
+
/** A user id the platform names as the app owner, when it names one. */
|
|
184
|
+
readonly ownerId?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function requestJson(
|
|
188
|
+
fetcher: FetchLike,
|
|
189
|
+
platform: string,
|
|
190
|
+
url: string,
|
|
191
|
+
init: RequestInit,
|
|
192
|
+
secrets: readonly string[],
|
|
193
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
194
|
+
let response: Response;
|
|
195
|
+
try {
|
|
196
|
+
response = await fetcher(url, { ...init, signal: AbortSignal.timeout(15_000) });
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
199
|
+
throw new SetupError(`${platform}: could not reach the platform API (${redact(message, secrets)})`);
|
|
200
|
+
}
|
|
201
|
+
let body: unknown;
|
|
202
|
+
try {
|
|
203
|
+
body = await response.json();
|
|
204
|
+
} catch {
|
|
205
|
+
body = {};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
status: response.status,
|
|
209
|
+
body: typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : {},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Discord: the bot token must authenticate `/users/@me` as a bot and, when given, belong to the expected application. */
|
|
214
|
+
export async function validateDiscord(
|
|
215
|
+
credentials: NonNullable<SetupTokens["discord"]>,
|
|
216
|
+
fetcher: FetchLike,
|
|
217
|
+
): Promise<ValidatedIdentity> {
|
|
218
|
+
const secrets = [credentials.token];
|
|
219
|
+
const headers = { authorization: `Bot ${credentials.token}` };
|
|
220
|
+
const me = await requestJson(fetcher, "discord", "https://discord.com/api/v10/users/@me", { headers }, secrets);
|
|
221
|
+
if (me.status === 401)
|
|
222
|
+
throw new SetupError(
|
|
223
|
+
"discord: the platform rejected the bot token (HTTP 401); copy a fresh token from the Bot page",
|
|
224
|
+
);
|
|
225
|
+
if (me.status !== 200) throw new SetupError(`discord: token check failed with HTTP ${me.status}`);
|
|
226
|
+
if (me.body.bot !== true)
|
|
227
|
+
throw new SetupError("discord: the token authenticates a user account, not a bot; use the Bot page token");
|
|
228
|
+
const app = await requestJson(
|
|
229
|
+
fetcher,
|
|
230
|
+
"discord",
|
|
231
|
+
"https://discord.com/api/v10/applications/@me",
|
|
232
|
+
{ headers },
|
|
233
|
+
secrets,
|
|
234
|
+
);
|
|
235
|
+
if (app.status !== 200) throw new SetupError(`discord: application lookup failed with HTTP ${app.status}`);
|
|
236
|
+
const appId = String(app.body.id ?? "");
|
|
237
|
+
if (credentials.appId !== undefined && appId !== credentials.appId)
|
|
238
|
+
throw new SetupError(`discord: the token belongs to application ${appId}, not ${credentials.appId}`);
|
|
239
|
+
const owner = app.body.owner as { id?: unknown } | undefined;
|
|
240
|
+
const hasTeam = app.body.team !== null && app.body.team !== undefined;
|
|
241
|
+
const ownerId = !hasTeam && typeof owner?.id === "string" ? owner.id : undefined;
|
|
242
|
+
return {
|
|
243
|
+
platform: "discord",
|
|
244
|
+
summary: `bot ${String(me.body.username ?? "?")} (${String(me.body.id ?? "?")}) of application ${appId}`,
|
|
245
|
+
...(ownerId ? { ownerId } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Slack: the bot token must pass `auth.test` as a bot, and the app-level token
|
|
251
|
+
* must be able to open a Socket Mode connection (`apps.connections.open`) —
|
|
252
|
+
* the exact call the adapter makes at startup. The returned URL is discarded.
|
|
253
|
+
*/
|
|
254
|
+
export async function validateSlack(
|
|
255
|
+
credentials: NonNullable<SetupTokens["slack"]>,
|
|
256
|
+
fetcher: FetchLike,
|
|
257
|
+
): Promise<ValidatedIdentity> {
|
|
258
|
+
if (!credentials.botToken.startsWith("xoxb-"))
|
|
259
|
+
throw new SetupError("slack: the bot token must start with xoxb- (OAuth & Permissions > Bot User OAuth Token)");
|
|
260
|
+
if (!credentials.appToken.startsWith("xapp-"))
|
|
261
|
+
throw new SetupError(
|
|
262
|
+
"slack: the app-level token must start with xapp- (Basic Information > App-Level Tokens, scope connections:write)",
|
|
263
|
+
);
|
|
264
|
+
const secrets = [credentials.botToken, credentials.appToken];
|
|
265
|
+
const call = (method: string, token: string) =>
|
|
266
|
+
requestJson(
|
|
267
|
+
fetcher,
|
|
268
|
+
"slack",
|
|
269
|
+
`https://slack.com/api/${method}`,
|
|
270
|
+
{ method: "POST", headers: { authorization: `Bearer ${token}` } },
|
|
271
|
+
secrets,
|
|
272
|
+
);
|
|
273
|
+
const auth = await call("auth.test", credentials.botToken);
|
|
274
|
+
if (auth.body.ok !== true)
|
|
275
|
+
throw new SetupError(`slack: the platform rejected the bot token (${slackError(auth.body, auth.status)})`);
|
|
276
|
+
if (typeof auth.body.bot_id !== "string")
|
|
277
|
+
throw new SetupError("slack: the bot token does not authenticate a bot user");
|
|
278
|
+
const socket = await call("apps.connections.open", credentials.appToken);
|
|
279
|
+
if (socket.body.ok !== true)
|
|
280
|
+
throw new SetupError(
|
|
281
|
+
`slack: the app-level token cannot open a Socket Mode connection (${slackError(socket.body, socket.status)}); enable Socket Mode and grant connections:write`,
|
|
282
|
+
);
|
|
283
|
+
return {
|
|
284
|
+
platform: "slack",
|
|
285
|
+
summary: `bot ${String(auth.body.user ?? "?")} (${String(auth.body.user_id ?? "?")}) in workspace ${String(auth.body.team ?? "?")}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function slackError(body: Record<string, unknown>, status: number): string {
|
|
290
|
+
// Slack error codes are fixed identifiers such as invalid_auth; still bounded to that shape.
|
|
291
|
+
const code = typeof body.error === "string" && /^[a-z_]{1,64}$/.test(body.error) ? body.error : `HTTP ${status}`;
|
|
292
|
+
return code;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Telegram: `getMe` with the token. The token is part of the URL, so fetch errors are redacted. */
|
|
296
|
+
export async function validateTelegram(
|
|
297
|
+
credentials: NonNullable<SetupTokens["telegram"]>,
|
|
298
|
+
fetcher: FetchLike,
|
|
299
|
+
): Promise<ValidatedIdentity> {
|
|
300
|
+
const me = await requestJson(fetcher, "telegram", `https://api.telegram.org/bot${credentials.token}/getMe`, {}, [
|
|
301
|
+
credentials.token,
|
|
302
|
+
]);
|
|
303
|
+
const result = me.body.result as { username?: unknown; id?: unknown } | undefined;
|
|
304
|
+
if (me.body.ok !== true || !result)
|
|
305
|
+
throw new SetupError(`telegram: the platform rejected the bot token (HTTP ${me.status})`);
|
|
306
|
+
return { platform: "telegram", summary: `bot @${String(result.username ?? "?")} (${String(result.id ?? "?")})` };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function validateAll(tokens: SetupTokens, fetcher: FetchLike): Promise<ValidatedIdentity[]> {
|
|
310
|
+
const secrets = secretsOf(tokens);
|
|
311
|
+
const identities: ValidatedIdentity[] = [];
|
|
312
|
+
try {
|
|
313
|
+
if (tokens.discord) identities.push(await validateDiscord(tokens.discord, fetcher));
|
|
314
|
+
if (tokens.slack) identities.push(await validateSlack(tokens.slack, fetcher));
|
|
315
|
+
if (tokens.telegram) identities.push(await validateTelegram(tokens.telegram, fetcher));
|
|
316
|
+
} catch (error) {
|
|
317
|
+
// Last line of defence: whatever escaped a validator is scrubbed of every token.
|
|
318
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
319
|
+
throw new SetupError(redact(message, secrets));
|
|
320
|
+
}
|
|
321
|
+
return identities;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Atomic 0600 write: a temp file created 0600, then renamed over the target. */
|
|
325
|
+
export async function writePrivateFile(path: string, contents: string): Promise<void> {
|
|
326
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
327
|
+
try {
|
|
328
|
+
await writeFile(temporary, contents, { flag: "wx", mode: 0o600 });
|
|
329
|
+
await chmod(temporary, 0o600);
|
|
330
|
+
await rename(temporary, path);
|
|
331
|
+
} finally {
|
|
332
|
+
await rm(temporary, { force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function readJsonObject(path: string): Promise<Record<string, unknown> | undefined> {
|
|
337
|
+
let text: string;
|
|
338
|
+
try {
|
|
339
|
+
text = await readFile(path, "utf8");
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
342
|
+
throw new SetupError(`cannot read ${path}: ${(error as NodeJS.ErrnoException).code ?? "unreadable"}`);
|
|
343
|
+
}
|
|
344
|
+
let parsed: unknown;
|
|
345
|
+
try {
|
|
346
|
+
parsed = JSON.parse(text);
|
|
347
|
+
} catch {
|
|
348
|
+
throw new SetupError(`${path} is not valid JSON; fix or remove it, then run setup again`);
|
|
349
|
+
}
|
|
350
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
351
|
+
throw new SetupError(`${path} must contain a JSON object`);
|
|
352
|
+
return parsed as Record<string, unknown>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface ApplySetupOptions {
|
|
356
|
+
readonly home: string;
|
|
357
|
+
readonly tokens: SetupTokens;
|
|
358
|
+
readonly owners: readonly string[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface AppliedSetup {
|
|
362
|
+
readonly written: readonly string[];
|
|
363
|
+
readonly owners: readonly string[];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Writes secrets, adapter configs, and the gateway config. Existing files are
|
|
368
|
+
* merged, not replaced: re-running setup for one adapter (token rotation)
|
|
369
|
+
* leaves the other adapters and every unrelated config field untouched.
|
|
370
|
+
* All inputs are read and parsed before the first write.
|
|
371
|
+
*/
|
|
372
|
+
export async function applySetup(options: ApplySetupOptions): Promise<AppliedSetup> {
|
|
373
|
+
const { home, tokens } = options;
|
|
374
|
+
const secretsDir = join(home, "secrets");
|
|
375
|
+
const configPath = join(home, "config.json");
|
|
376
|
+
const adapterPath = (platform: SetupPlatform) => join(home, `adapter-${platform}.json`);
|
|
377
|
+
const existingConfig = await readJsonObject(configPath);
|
|
378
|
+
const existingAdapters = new Map<SetupPlatform, Record<string, unknown> | undefined>();
|
|
379
|
+
for (const platform of SETUP_PLATFORMS)
|
|
380
|
+
if (tokens[platform]) existingAdapters.set(platform, await readJsonObject(adapterPath(platform)));
|
|
381
|
+
|
|
382
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
383
|
+
await chmod(home, 0o700);
|
|
384
|
+
await mkdir(secretsDir, { recursive: true, mode: 0o700 });
|
|
385
|
+
await chmod(secretsDir, 0o700);
|
|
386
|
+
|
|
387
|
+
const written: string[] = [];
|
|
388
|
+
const secret = async (name: string, value: string) => {
|
|
389
|
+
const path = join(secretsDir, name);
|
|
390
|
+
await writePrivateFile(path, `${value}\n`);
|
|
391
|
+
written.push(path);
|
|
392
|
+
return path;
|
|
393
|
+
};
|
|
394
|
+
const credentials: Record<string, { credentialFile: string }> = {};
|
|
395
|
+
const adapterConfigs: [SetupPlatform, Record<string, unknown>][] = [];
|
|
396
|
+
if (tokens.discord) {
|
|
397
|
+
credentials.discord = { credentialFile: await secret(SECRET_FILES.discord, tokens.discord.token) };
|
|
398
|
+
adapterConfigs.push(["discord", { tokenFile: `secrets/${SECRET_FILES.discord}` }]);
|
|
399
|
+
}
|
|
400
|
+
if (tokens.slack) {
|
|
401
|
+
credentials["slack-bot"] = { credentialFile: await secret(SECRET_FILES.slackBot, tokens.slack.botToken) };
|
|
402
|
+
credentials["slack-app"] = { credentialFile: await secret(SECRET_FILES.slackApp, tokens.slack.appToken) };
|
|
403
|
+
adapterConfigs.push([
|
|
404
|
+
"slack",
|
|
405
|
+
{ botTokenFile: `secrets/${SECRET_FILES.slackBot}`, appTokenFile: `secrets/${SECRET_FILES.slackApp}` },
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
if (tokens.telegram) {
|
|
409
|
+
credentials.telegram = { credentialFile: await secret(SECRET_FILES.telegram, tokens.telegram.token) };
|
|
410
|
+
adapterConfigs.push(["telegram", { tokenFile: `secrets/${SECRET_FILES.telegram}` }]);
|
|
411
|
+
}
|
|
412
|
+
for (const [platform, references] of adapterConfigs) {
|
|
413
|
+
const path = adapterPath(platform);
|
|
414
|
+
await writePrivateFile(
|
|
415
|
+
path,
|
|
416
|
+
`${JSON.stringify({ ...existingAdapters.get(platform), ...references }, null, "\t")}\n`,
|
|
417
|
+
);
|
|
418
|
+
written.push(path);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const base = existingConfig ?? { schemaVersion: 1 };
|
|
422
|
+
const previousCredentials =
|
|
423
|
+
typeof base.credentials === "object" && base.credentials !== null && !Array.isArray(base.credentials)
|
|
424
|
+
? (base.credentials as Record<string, unknown>)
|
|
425
|
+
: {};
|
|
426
|
+
const previousAllowlist = Array.isArray(base.mentionAllowlist)
|
|
427
|
+
? base.mentionAllowlist.filter((id): id is string => typeof id === "string")
|
|
428
|
+
: [];
|
|
429
|
+
const owners = [...new Set([...previousAllowlist, ...options.owners])];
|
|
430
|
+
const config = {
|
|
431
|
+
...base,
|
|
432
|
+
credentials: { ...previousCredentials, ...credentials },
|
|
433
|
+
...(owners.length > 0 ? { mentionAllowlist: owners } : {}),
|
|
434
|
+
};
|
|
435
|
+
await writePrivateFile(configPath, `${JSON.stringify(config, null, "\t")}\n`);
|
|
436
|
+
written.push(configPath);
|
|
437
|
+
return { written, owners };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Adapters with a config file in `home`, in canonical order: the compose profiles to run. */
|
|
441
|
+
export async function enabledAdapters(home: string): Promise<SetupPlatform[]> {
|
|
442
|
+
const enabled: SetupPlatform[] = [];
|
|
443
|
+
for (const platform of SETUP_PLATFORMS) {
|
|
444
|
+
if (await Bun.file(join(home, `adapter-${platform}.json`)).exists()) enabled.push(platform);
|
|
445
|
+
}
|
|
446
|
+
return enabled;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface Prompter {
|
|
450
|
+
ask(question: string): Promise<string>;
|
|
451
|
+
/** Reads a line without echoing it. */
|
|
452
|
+
askSecret(question: string): Promise<string>;
|
|
453
|
+
close(): void;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function askYes(prompter: Prompter, question: string): Promise<boolean> {
|
|
457
|
+
for (;;) {
|
|
458
|
+
const answer = (await prompter.ask(`${question} [y/N] `)).trim().toLowerCase();
|
|
459
|
+
if (answer === "" || answer === "n" || answer === "no") return false;
|
|
460
|
+
if (answer === "y" || answer === "yes") return true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function askRequiredSecret(prompter: Prompter, question: string): Promise<string> {
|
|
465
|
+
for (;;) {
|
|
466
|
+
const answer = (await prompter.askSecret(question)).trim();
|
|
467
|
+
if (answer) return answer;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Interactive collection. Tokens are read without echo and never repeated back. */
|
|
472
|
+
export async function tokensFromPrompts(
|
|
473
|
+
prompter: Prompter,
|
|
474
|
+
parsed: ParsedSetupArgs,
|
|
475
|
+
): Promise<{ tokens: SetupTokens; owners: string[] }> {
|
|
476
|
+
const wanted = parsed.adapters ?? [];
|
|
477
|
+
const enable = async (platform: SetupPlatform, label: string) =>
|
|
478
|
+
parsed.adapters ? wanted.includes(platform) : await askYes(prompter, `Enable ${label}?`);
|
|
479
|
+
const tokens: { -readonly [K in keyof SetupTokens]: SetupTokens[K] } = {};
|
|
480
|
+
if (await enable("discord", "Discord")) {
|
|
481
|
+
const token = await askRequiredSecret(prompter, "Discord bot token (input hidden): ");
|
|
482
|
+
const appId = parsed.discordAppId ?? (await prompter.ask("Discord application id (optional): ")).trim();
|
|
483
|
+
tokens.discord = { token, ...(appId ? { appId } : {}) };
|
|
484
|
+
}
|
|
485
|
+
if (await enable("slack", "Slack (Socket Mode)")) {
|
|
486
|
+
const botToken = await askRequiredSecret(prompter, "Slack bot token xoxb-… (input hidden): ");
|
|
487
|
+
const appToken = await askRequiredSecret(prompter, "Slack app-level token xapp-… (input hidden): ");
|
|
488
|
+
tokens.slack = { botToken, appToken };
|
|
489
|
+
}
|
|
490
|
+
if (await enable("telegram", "Telegram")) {
|
|
491
|
+
tokens.telegram = { token: await askRequiredSecret(prompter, "Telegram bot token (input hidden): ") };
|
|
492
|
+
}
|
|
493
|
+
if (!tokens.discord && !tokens.slack && !tokens.telegram) throw new SetupError("no adapter selected; nothing to do");
|
|
494
|
+
const owners =
|
|
495
|
+
parsed.owners.length > 0
|
|
496
|
+
? [...parsed.owners]
|
|
497
|
+
: splitList(
|
|
498
|
+
await prompter.ask(
|
|
499
|
+
"Your platform user id(s), comma-separated, allowed to DM the bot (blank: Discord app owner only): ",
|
|
500
|
+
),
|
|
501
|
+
);
|
|
502
|
+
return { tokens, owners };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Terminal prompter: raw-mode, no-echo secret entry. Requires a TTY. */
|
|
506
|
+
export function terminalPrompter(): Prompter {
|
|
507
|
+
const stdin = process.stdin;
|
|
508
|
+
if (!stdin.isTTY)
|
|
509
|
+
throw new SetupError(
|
|
510
|
+
"interactive setup needs a terminal; run it with a TTY (docker compose run -it …) or use --from-env",
|
|
511
|
+
);
|
|
512
|
+
const readLine = (question: string, echo: boolean): Promise<string> =>
|
|
513
|
+
new Promise((resolve, reject) => {
|
|
514
|
+
process.stdout.write(question);
|
|
515
|
+
let line = "";
|
|
516
|
+
stdin.setRawMode(true);
|
|
517
|
+
stdin.resume();
|
|
518
|
+
const onData = (chunk: Buffer) => {
|
|
519
|
+
for (const char of chunk.toString("utf8")) {
|
|
520
|
+
if (char === "\u0003") {
|
|
521
|
+
finish();
|
|
522
|
+
process.stdout.write("\n");
|
|
523
|
+
reject(new SetupError("setup cancelled"));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (char === "\r" || char === "\n") {
|
|
527
|
+
finish();
|
|
528
|
+
process.stdout.write("\n");
|
|
529
|
+
resolve(line);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (char === "\u007f" || char === "\b") {
|
|
533
|
+
if (line.length > 0) {
|
|
534
|
+
line = line.slice(0, -1);
|
|
535
|
+
if (echo) process.stdout.write("\b \b");
|
|
536
|
+
}
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
line += char;
|
|
540
|
+
if (echo) process.stdout.write(char);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const finish = () => {
|
|
544
|
+
stdin.off("data", onData);
|
|
545
|
+
stdin.setRawMode(false);
|
|
546
|
+
stdin.pause();
|
|
547
|
+
};
|
|
548
|
+
stdin.on("data", onData);
|
|
549
|
+
});
|
|
550
|
+
return {
|
|
551
|
+
ask: (question) => readLine(question, true),
|
|
552
|
+
askSecret: (question) => readLine(question, false),
|
|
553
|
+
close: () => stdin.pause(),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export interface RunSetupOptions {
|
|
558
|
+
readonly home: string;
|
|
559
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
560
|
+
readonly fetch?: FetchLike;
|
|
561
|
+
readonly prompter?: () => Prompter;
|
|
562
|
+
readonly log?: (line: string) => void;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The whole command. Returns the lines it printed so tests can assert nothing secret escaped. */
|
|
566
|
+
export async function runSetup(args: readonly string[], options: RunSetupOptions): Promise<void> {
|
|
567
|
+
const log = options.log ?? ((line: string) => console.log(line));
|
|
568
|
+
const parsed = parseSetupArgs(args);
|
|
569
|
+
if (parsed.mode === "status") {
|
|
570
|
+
log((await enabledAdapters(options.home)).join(","));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
let tokens: SetupTokens;
|
|
574
|
+
let owners: string[];
|
|
575
|
+
if (parsed.fromEnv) {
|
|
576
|
+
tokens = tokensFromEnv(options.env ?? process.env, parsed.adapters, parsed.discordAppId);
|
|
577
|
+
owners = [...parsed.owners, ...splitList((options.env ?? process.env)[SETUP_ENV.ownerIds] ?? "")];
|
|
578
|
+
} else {
|
|
579
|
+
const prompter = (options.prompter ?? terminalPrompter)();
|
|
580
|
+
try {
|
|
581
|
+
({ tokens, owners } = await tokensFromPrompts(prompter, parsed));
|
|
582
|
+
} finally {
|
|
583
|
+
prompter.close();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const secrets = secretsOf(tokens);
|
|
587
|
+
try {
|
|
588
|
+
const identities = await validateAll(tokens, options.fetch ?? ((input, init) => fetch(input, init)));
|
|
589
|
+
for (const identity of identities) log(`${identity.platform}: token valid — ${identity.summary}`);
|
|
590
|
+
for (const identity of identities)
|
|
591
|
+
if (identity.ownerId && owners.length === 0) {
|
|
592
|
+
owners.push(identity.ownerId);
|
|
593
|
+
log(`${identity.platform}: allowing DMs from the application owner ${identity.ownerId}`);
|
|
594
|
+
}
|
|
595
|
+
const applied = await applySetup({ home: options.home, tokens, owners });
|
|
596
|
+
for (const path of applied.written) log(`wrote ${path}`);
|
|
597
|
+
if (applied.owners.length === 0)
|
|
598
|
+
log(
|
|
599
|
+
"warning: no owner id configured; the gateway declines every DM until config.json mentionAllowlist names you (rerun setup with --owner ID)",
|
|
600
|
+
);
|
|
601
|
+
log(`enabled adapters: ${(await enabledAdapters(options.home)).join(",")}`);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
604
|
+
throw new SetupError(redact(message, secrets));
|
|
605
|
+
}
|
|
606
|
+
}
|