@gajae-gateway/cli 0.1.3 → 0.1.5
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 +2120 -72
- 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 +33 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/list.ts +35 -10
- package/src/main.ts +101 -12
- 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 +199 -0
package/dist/main.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
// src/main.ts
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
6
|
-
import { copyFile, lstat, readFile as
|
|
7
|
-
import { isAbsolute, join as
|
|
6
|
+
import { copyFile as copyFile2, lstat as lstat2, readFile as readFile8, stat as stat3 } from "fs/promises";
|
|
7
|
+
import { isAbsolute, join as join10 } from "path";
|
|
8
8
|
import { LOOPBACK_ORIGIN, originKey as originKey2 } from "@gajae-gateway/protocol";
|
|
9
9
|
import { GajaewayClient } from "@gajae-gateway/sdk";
|
|
10
10
|
|
|
@@ -98,12 +98,29 @@ function triggerSummary(monitor) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
var MONITOR_COLUMNS = [
|
|
101
|
-
{ name: "id", value: (
|
|
102
|
-
{ name: "name", value: (
|
|
103
|
-
{ name: "schedule", value: triggerSummary },
|
|
104
|
-
{ name: "
|
|
105
|
-
{
|
|
106
|
-
|
|
101
|
+
{ name: "id", value: (row) => row.monitor.monitorId.slice(0, 8) },
|
|
102
|
+
{ name: "name", value: (row) => row.monitor.name },
|
|
103
|
+
{ name: "schedule", value: (row) => triggerSummary(row.monitor) },
|
|
104
|
+
{ name: "timezone", value: (row) => row.schedule?.effectiveTimezone ?? null },
|
|
105
|
+
{
|
|
106
|
+
name: "nextFire",
|
|
107
|
+
value: (row) => {
|
|
108
|
+
const { monitor, schedule } = row;
|
|
109
|
+
if (monitor.trigger.kind !== "cron")
|
|
110
|
+
return null;
|
|
111
|
+
if (!monitor.enabled)
|
|
112
|
+
return "paused";
|
|
113
|
+
if (!schedule)
|
|
114
|
+
return null;
|
|
115
|
+
if (!schedule.nextFireAt)
|
|
116
|
+
return "never";
|
|
117
|
+
const utc = schedule.nextFireAt.utc.replace(/:\d{2}\.\d{3}Z$/, "Z");
|
|
118
|
+
return `${schedule.nextFireAt.local} / ${utc}`;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{ name: "events", value: (row) => [...row.monitor.eventTypes] },
|
|
122
|
+
{ name: "target", value: (row) => row.monitor.channelTarget ? originKey(row.monitor.channelTarget.origin) : null },
|
|
123
|
+
{ name: "enabled", value: (row) => row.monitor.enabled }
|
|
107
124
|
];
|
|
108
125
|
var SESSION_COLUMNS = [
|
|
109
126
|
{ name: "index", value: (_session, index) => index },
|
|
@@ -123,20 +140,1020 @@ function renderList(columns, rows, options, envelope) {
|
|
|
123
140
|
return renderTable(selected, entries);
|
|
124
141
|
if (!options.fields && options.limit === undefined && options.offset === 0)
|
|
125
142
|
return [JSON.stringify(envelope.result)];
|
|
126
|
-
const projected = options.fields ? projectRows(selected, entries) : entries.map((entry) => entry.row);
|
|
143
|
+
const projected = options.fields ? projectRows(selected, entries) : entries.map((entry) => envelope.serializeRow ? envelope.serializeRow(entry.row) : entry.row);
|
|
127
144
|
return [JSON.stringify({ ...envelope.result, [envelope.key]: projected })];
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
// src/migrate.ts
|
|
148
|
+
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
149
|
+
import { join as join4 } from "path";
|
|
150
|
+
|
|
151
|
+
// src/migrate/hermes.ts
|
|
152
|
+
import { join as join2 } from "path";
|
|
153
|
+
|
|
154
|
+
// src/migrate/plan.ts
|
|
155
|
+
import { eventTypeOrigin, validateOriginRef } from "@gajae-gateway/protocol";
|
|
156
|
+
function emptyExtract(source, root) {
|
|
157
|
+
return {
|
|
158
|
+
source,
|
|
159
|
+
root,
|
|
160
|
+
inventory: { persona: [], memory: [], channels: [], schedules: [], skills: [] },
|
|
161
|
+
files: [],
|
|
162
|
+
merges: [],
|
|
163
|
+
monitors: [],
|
|
164
|
+
unmapped: [],
|
|
165
|
+
warnings: []
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
var CRON_FIELD = /^(\*|\d+(-\d+)?)(\/\d+)?(,(\*|\d+(-\d+)?)(\/\d+)?)*$/;
|
|
169
|
+
var CRON_BOUNDS = [
|
|
170
|
+
[0, 59],
|
|
171
|
+
[0, 23],
|
|
172
|
+
[1, 31],
|
|
173
|
+
[1, 12],
|
|
174
|
+
[0, 7]
|
|
175
|
+
];
|
|
176
|
+
function gatewayCron(expr) {
|
|
177
|
+
const fields = expr.trim().split(/\s+/);
|
|
178
|
+
if (fields.length !== 5)
|
|
179
|
+
return;
|
|
180
|
+
for (const [index, field] of fields.entries()) {
|
|
181
|
+
if (!CRON_FIELD.test(field))
|
|
182
|
+
return;
|
|
183
|
+
const [min, max] = CRON_BOUNDS[index];
|
|
184
|
+
for (const number of field.match(/\d+/g) ?? []) {
|
|
185
|
+
const value = Number(number);
|
|
186
|
+
if (field.includes(`/${number}`))
|
|
187
|
+
continue;
|
|
188
|
+
if (value < min || value > max)
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return fields.join(" ");
|
|
193
|
+
}
|
|
194
|
+
function intervalCron(minutes) {
|
|
195
|
+
if (!Number.isInteger(minutes) || minutes < 1)
|
|
196
|
+
return;
|
|
197
|
+
if (minutes === 1)
|
|
198
|
+
return "* * * * *";
|
|
199
|
+
if (minutes < 60 && 60 % minutes === 0)
|
|
200
|
+
return `*/${minutes} * * * *`;
|
|
201
|
+
if (minutes === 60)
|
|
202
|
+
return "0 * * * *";
|
|
203
|
+
if (minutes % 60 === 0 && minutes < 1440 && 24 % (minutes / 60) === 0)
|
|
204
|
+
return `0 */${minutes / 60} * * *`;
|
|
205
|
+
if (minutes === 1440)
|
|
206
|
+
return "0 0 * * *";
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
function slug(value) {
|
|
210
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/, "") || "job";
|
|
211
|
+
}
|
|
212
|
+
var MONITOR_INSTRUCTION_MAX = 4000;
|
|
213
|
+
function cronMonitor(input) {
|
|
214
|
+
const instruction = input.instruction.trim();
|
|
215
|
+
if (!instruction)
|
|
216
|
+
return "job has no prompt text to use as the monitor instruction";
|
|
217
|
+
if (instruction.length > MONITOR_INSTRUCTION_MAX)
|
|
218
|
+
return `prompt is ${instruction.length} characters; monitor instructions are limited to ${MONITOR_INSTRUCTION_MAX}`;
|
|
219
|
+
const base = `${input.source}-${slug(input.name)}`;
|
|
220
|
+
let name = base;
|
|
221
|
+
for (let n = 2;input.taken.has(name); n++)
|
|
222
|
+
name = `${base}-${n}`;
|
|
223
|
+
const eventType = `migrated.${name}`;
|
|
224
|
+
validateOriginRef(eventTypeOrigin(eventType));
|
|
225
|
+
if (input.target)
|
|
226
|
+
validateOriginRef(input.target);
|
|
227
|
+
input.taken.add(name);
|
|
228
|
+
return {
|
|
229
|
+
name,
|
|
230
|
+
trigger: { kind: "cron", schedule: input.schedule },
|
|
231
|
+
eventTypes: [eventType],
|
|
232
|
+
burstPolicy: "dedupe",
|
|
233
|
+
instruction,
|
|
234
|
+
...input.target ? { channelTarget: { origin: input.target } } : {},
|
|
235
|
+
enabled: input.enabled
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function deliveryOrigin(platform, rawTarget) {
|
|
239
|
+
const target = rawTarget.trim();
|
|
240
|
+
if (platform === "telegram") {
|
|
241
|
+
const chat = target.replace(/^(channel|group|chat):/, "").split(":")[0] ?? "";
|
|
242
|
+
if (!/^-?\d+$/.test(chat))
|
|
243
|
+
return `telegram target ${JSON.stringify(target)} is not a numeric chat id`;
|
|
244
|
+
return chat.startsWith("-") ? { platform: "telegram", kind: "channel", conversationId: chat } : { platform: "telegram", kind: "dm", conversationId: chat, peerId: chat };
|
|
245
|
+
}
|
|
246
|
+
if (platform === "discord" || platform === "slack") {
|
|
247
|
+
if (target.startsWith("user:"))
|
|
248
|
+
return `${platform} user target ${JSON.stringify(target)} needs a DM conversation id the source does not record`;
|
|
249
|
+
const id = target.replace(/^channel:/, "");
|
|
250
|
+
const valid = platform === "discord" ? /^\d+$/.test(id) : /^[CG][A-Z0-9]+$/.test(id);
|
|
251
|
+
if (!valid)
|
|
252
|
+
return `${platform} target ${JSON.stringify(target)} is not a channel id`;
|
|
253
|
+
return { platform, kind: "channel", conversationId: id };
|
|
254
|
+
}
|
|
255
|
+
return `delivery platform ${JSON.stringify(platform)} has no gajae-way adapter`;
|
|
256
|
+
}
|
|
257
|
+
var SECRET_FILES = {
|
|
258
|
+
discord: "secrets/discord-token",
|
|
259
|
+
telegram: "secrets/telegram-token",
|
|
260
|
+
"slack-bot": "secrets/slack-bot-token",
|
|
261
|
+
"slack-app": "secrets/slack-app-token"
|
|
262
|
+
};
|
|
263
|
+
function emptyChannelPlan() {
|
|
264
|
+
return { credentials: {}, channels: {}, allowlist: new Set };
|
|
265
|
+
}
|
|
266
|
+
function emitChannels(extract, plan, home, configSource) {
|
|
267
|
+
const credentialSources = Object.values(plan.credentials).map((entry) => entry.source);
|
|
268
|
+
for (const [name, entry] of Object.entries(plan.credentials)) {
|
|
269
|
+
extract.files.push({
|
|
270
|
+
category: "credentials",
|
|
271
|
+
sources: [entry.source],
|
|
272
|
+
target: SECRET_FILES[name],
|
|
273
|
+
content: `${entry.value}
|
|
274
|
+
`,
|
|
275
|
+
secret: true
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const has = (name) => plan.credentials[name] !== undefined;
|
|
279
|
+
const byPlatform = (platform) => Object.fromEntries(Object.entries(plan.channels).filter(([key]) => key.startsWith(`${platform}:`)).map(([key, policy]) => [key.slice(platform.length + 1), policy]));
|
|
280
|
+
const credentialRefs = {};
|
|
281
|
+
if (has("discord"))
|
|
282
|
+
credentialRefs.discord = { credentialFile: `${home}/${SECRET_FILES.discord}` };
|
|
283
|
+
if (has("telegram"))
|
|
284
|
+
credentialRefs.telegram = { credentialFile: `${home}/${SECRET_FILES.telegram}` };
|
|
285
|
+
if (has("slack-bot"))
|
|
286
|
+
credentialRefs.slackBot = { credentialFile: `${home}/${SECRET_FILES["slack-bot"]}` };
|
|
287
|
+
if (has("slack-app"))
|
|
288
|
+
credentialRefs.slackApp = { credentialFile: `${home}/${SECRET_FILES["slack-app"]}` };
|
|
289
|
+
const allowlist = [...plan.allowlist].sort();
|
|
290
|
+
extract.merges.push({
|
|
291
|
+
category: "channels",
|
|
292
|
+
sources: [configSource, ...credentialSources],
|
|
293
|
+
target: "config.json",
|
|
294
|
+
merge(existing) {
|
|
295
|
+
const base = existing ?? { schemaVersion: 1 };
|
|
296
|
+
const credentials = { ...base.credentials };
|
|
297
|
+
for (const [name, reference] of Object.entries(credentialRefs))
|
|
298
|
+
credentials[name] ??= reference;
|
|
299
|
+
const channels = { ...base.channels };
|
|
300
|
+
for (const [key, policy] of Object.entries(plan.channels))
|
|
301
|
+
channels[key] ??= policy;
|
|
302
|
+
const existingAllow = Array.isArray(base.mentionAllowlist) ? base.mentionAllowlist : [];
|
|
303
|
+
const mergedAllow = [...new Set([...existingAllow, ...allowlist])];
|
|
304
|
+
return {
|
|
305
|
+
...base,
|
|
306
|
+
...Object.keys(credentials).length ? { credentials } : {},
|
|
307
|
+
...Object.keys(channels).length ? { channels } : {},
|
|
308
|
+
...mergedAllow.length ? { mentionAllowlist: mergedAllow } : {},
|
|
309
|
+
...base.dmPolicy === undefined && plan.dmPolicy ? { dmPolicy: plan.dmPolicy } : {}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
const adapter = (target, required, channelKey, platform) => {
|
|
314
|
+
const channels = byPlatform(platform);
|
|
315
|
+
const adapterChannels = platform === "telegram" ? Object.fromEntries(Object.entries(channels).filter(([, policy]) => policy.engagement === "open")) : channels;
|
|
316
|
+
extract.merges.push({
|
|
317
|
+
category: "channels",
|
|
318
|
+
sources: [configSource],
|
|
319
|
+
target,
|
|
320
|
+
merge(existing) {
|
|
321
|
+
const base = { ...existing };
|
|
322
|
+
for (const [field, value] of Object.entries(required))
|
|
323
|
+
base[field] ??= value;
|
|
324
|
+
const merged = { ...base[channelKey] };
|
|
325
|
+
for (const [id, policy] of Object.entries(adapterChannels))
|
|
326
|
+
merged[id] ??= policy;
|
|
327
|
+
return { ...base, ...Object.keys(merged).length ? { [channelKey]: merged } : {} };
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
if (has("discord"))
|
|
332
|
+
adapter("adapter-discord.json", { tokenFile: SECRET_FILES.discord }, "channels", "discord");
|
|
333
|
+
if (has("telegram"))
|
|
334
|
+
adapter("adapter-telegram.json", { tokenFile: SECRET_FILES.telegram }, "chats", "telegram");
|
|
335
|
+
if (has("slack-bot") && has("slack-app"))
|
|
336
|
+
adapter("adapter-slack.json", { botTokenFile: SECRET_FILES["slack-bot"], appTokenFile: SECRET_FILES["slack-app"] }, "channels", "slack");
|
|
337
|
+
else if (has("slack-bot") !== has("slack-app"))
|
|
338
|
+
extract.warnings.push("Slack needs both a bot token (xoxb-) and a Socket Mode app token (xapp-); only one was found, so adapter-slack.json was not written");
|
|
339
|
+
for (const [name, prefix] of [
|
|
340
|
+
["slack-bot", "xoxb-"],
|
|
341
|
+
["slack-app", "xapp-"]
|
|
342
|
+
]) {
|
|
343
|
+
const value = plan.credentials[name]?.value;
|
|
344
|
+
if (value !== undefined && !value.startsWith(prefix))
|
|
345
|
+
extract.warnings.push(`${SECRET_FILES[name]} does not start with ${prefix}; the Slack adapter will refuse it`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function emitMonitors(extract) {
|
|
349
|
+
if (extract.monitors.length === 0)
|
|
350
|
+
return;
|
|
351
|
+
const specs = extract.monitors.map((entry) => entry.spec);
|
|
352
|
+
extract.merges.push({
|
|
353
|
+
category: "monitors",
|
|
354
|
+
sources: [...new Set(extract.monitors.map((entry) => entry.source))],
|
|
355
|
+
target: MIGRATED_MONITORS_FILE,
|
|
356
|
+
merge(existing) {
|
|
357
|
+
const current = Array.isArray(existing?.monitors) ? existing.monitors : [];
|
|
358
|
+
const names = new Set(current.map((spec) => spec.name));
|
|
359
|
+
return { version: 1, monitors: [...current, ...specs.filter((spec) => !names.has(spec.name))] };
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
var MIGRATED_MONITORS_FILE = "migration/monitors.json";
|
|
364
|
+
var IMPORTED_AXIS = {
|
|
365
|
+
id: "imported",
|
|
366
|
+
displayName: "Imported memory",
|
|
367
|
+
root: "imported",
|
|
368
|
+
nesting: "nested",
|
|
369
|
+
index: "tree",
|
|
370
|
+
layout: "free",
|
|
371
|
+
retrievalPriority: 50
|
|
372
|
+
};
|
|
373
|
+
function emitImportedAxis(extract) {
|
|
374
|
+
extract.merges.push({
|
|
375
|
+
category: "memory",
|
|
376
|
+
sources: [],
|
|
377
|
+
target: "memory/axes.json",
|
|
378
|
+
merge(existing) {
|
|
379
|
+
const axes = Array.isArray(existing?.axes) ? existing.axes : [];
|
|
380
|
+
if (axes.some((axis) => axis.id === IMPORTED_AXIS.id))
|
|
381
|
+
return { version: 1, ...existing, axes };
|
|
382
|
+
return { version: 1, ...existing, axes: [...axes, IMPORTED_AXIS] };
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/migrate/source-fs.ts
|
|
388
|
+
import { lstat, readdir, readFile } from "fs/promises";
|
|
389
|
+
import { homedir } from "os";
|
|
390
|
+
import { join, relative } from "path";
|
|
391
|
+
|
|
392
|
+
class SourceTree {
|
|
393
|
+
root;
|
|
394
|
+
#consumed = new Set;
|
|
395
|
+
constructor(root) {
|
|
396
|
+
this.root = root;
|
|
397
|
+
}
|
|
398
|
+
rel(path) {
|
|
399
|
+
const inside = relative(this.root, path);
|
|
400
|
+
return inside.startsWith("..") ? path : inside || ".";
|
|
401
|
+
}
|
|
402
|
+
consume(path) {
|
|
403
|
+
this.#consumed.add(path);
|
|
404
|
+
}
|
|
405
|
+
isConsumed(path) {
|
|
406
|
+
for (const consumed of this.#consumed)
|
|
407
|
+
if (path === consumed || path.startsWith(`${consumed}/`) || consumed.startsWith(`${path}/`))
|
|
408
|
+
return true;
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
isFullyConsumed(path) {
|
|
412
|
+
for (const consumed of this.#consumed)
|
|
413
|
+
if (path === consumed || path.startsWith(`${consumed}/`))
|
|
414
|
+
return true;
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
async text(path) {
|
|
418
|
+
const info = await regularFile(path);
|
|
419
|
+
if (!info)
|
|
420
|
+
return;
|
|
421
|
+
this.consume(path);
|
|
422
|
+
return readFile(path, "utf8");
|
|
423
|
+
}
|
|
424
|
+
async bytes(path) {
|
|
425
|
+
const info = await regularFile(path);
|
|
426
|
+
if (!info)
|
|
427
|
+
return;
|
|
428
|
+
this.consume(path);
|
|
429
|
+
return new Uint8Array(await readFile(path));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function regularFile(path) {
|
|
433
|
+
try {
|
|
434
|
+
const info = await lstat(path);
|
|
435
|
+
return info.isFile() ? { mtime: info.mtime } : undefined;
|
|
436
|
+
} catch {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async function isDirectory(path) {
|
|
441
|
+
try {
|
|
442
|
+
return (await lstat(path)).isDirectory();
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async function entries(path) {
|
|
448
|
+
try {
|
|
449
|
+
return (await readdir(path)).sort();
|
|
450
|
+
} catch {
|
|
451
|
+
return [];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function walkFiles(dir, prefix = "") {
|
|
455
|
+
const out = [];
|
|
456
|
+
for (const name of await entries(dir)) {
|
|
457
|
+
const full = join(dir, name);
|
|
458
|
+
const info = await lstat(full).catch(() => {
|
|
459
|
+
return;
|
|
460
|
+
});
|
|
461
|
+
if (!info)
|
|
462
|
+
continue;
|
|
463
|
+
if (info.isDirectory())
|
|
464
|
+
out.push(...await walkFiles(full, join(prefix, name)));
|
|
465
|
+
else if (info.isFile())
|
|
466
|
+
out.push(join(prefix, name));
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
function parseDotenv(text) {
|
|
471
|
+
const values = new Map;
|
|
472
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
473
|
+
const line = rawLine.trim();
|
|
474
|
+
if (!line || line.startsWith("#"))
|
|
475
|
+
continue;
|
|
476
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
477
|
+
if (!match)
|
|
478
|
+
continue;
|
|
479
|
+
let value = match[2] ?? "";
|
|
480
|
+
const quote = value[0];
|
|
481
|
+
if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0)
|
|
482
|
+
value = value.slice(1, value.lastIndexOf(quote));
|
|
483
|
+
else
|
|
484
|
+
value = value.replace(/\s+#.*$/, "").trim();
|
|
485
|
+
values.set(match[1], value);
|
|
486
|
+
}
|
|
487
|
+
return values;
|
|
488
|
+
}
|
|
489
|
+
function expandHome(path) {
|
|
490
|
+
return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
491
|
+
}
|
|
492
|
+
function isRecord(value) {
|
|
493
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
494
|
+
}
|
|
495
|
+
function stringList(value) {
|
|
496
|
+
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
497
|
+
return items.map((item) => String(item).trim()).filter(Boolean);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/migrate/hermes.ts
|
|
501
|
+
var HERMES_ENTRY_DELIMITER = `
|
|
502
|
+
\xA7
|
|
503
|
+
`;
|
|
504
|
+
async function detectHermes(root) {
|
|
505
|
+
return await regularFile(join2(root, "config.yaml")) !== undefined || await isDirectory(join2(root, "memories")) || await regularFile(join2(root, "cron", "jobs.json")) !== undefined;
|
|
506
|
+
}
|
|
507
|
+
var TOKEN_KEYS = {
|
|
508
|
+
DISCORD_BOT_TOKEN: "discord",
|
|
509
|
+
TELEGRAM_BOT_TOKEN: "telegram",
|
|
510
|
+
SLACK_BOT_TOKEN: "slack-bot",
|
|
511
|
+
SLACK_APP_TOKEN: "slack-app"
|
|
512
|
+
};
|
|
513
|
+
var ALLOWLIST_KEYS = ["DISCORD_ALLOWED_USERS", "TELEGRAM_ALLOWED_USERS", "SLACK_ALLOWED_USERS"];
|
|
514
|
+
var HOME_CHANNEL_KEYS = {
|
|
515
|
+
discord: "DISCORD_HOME_CHANNEL",
|
|
516
|
+
telegram: "TELEGRAM_HOME_CHANNEL",
|
|
517
|
+
slack: "SLACK_HOME_CHANNEL"
|
|
518
|
+
};
|
|
519
|
+
async function readHermes(tree, home) {
|
|
520
|
+
const root = tree.root;
|
|
521
|
+
const extract = emptyExtract("hermes", root);
|
|
522
|
+
for (const name of ["SOUL.md", "AGENTS.md"]) {
|
|
523
|
+
const path = join2(root, name);
|
|
524
|
+
const text = await tree.text(path);
|
|
525
|
+
if (text === undefined)
|
|
526
|
+
continue;
|
|
527
|
+
const info = await regularFile(path);
|
|
528
|
+
extract.inventory.persona.push(tree.rel(path));
|
|
529
|
+
extract.files.push({
|
|
530
|
+
category: "persona",
|
|
531
|
+
sources: [tree.rel(path)],
|
|
532
|
+
target: `workspace/${name}`,
|
|
533
|
+
content: text,
|
|
534
|
+
mtime: info?.mtime
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const memoryDir = join2(root, "memories");
|
|
538
|
+
const userPath = join2(memoryDir, "USER.md");
|
|
539
|
+
const userText = await tree.text(userPath);
|
|
540
|
+
if (userText !== undefined) {
|
|
541
|
+
const info = await regularFile(userPath);
|
|
542
|
+
extract.inventory.persona.push(tree.rel(userPath));
|
|
543
|
+
extract.files.push({
|
|
544
|
+
category: "persona",
|
|
545
|
+
sources: [tree.rel(userPath)],
|
|
546
|
+
target: "workspace/USER.md",
|
|
547
|
+
content: entriesToMarkdown("User profile (imported from Hermes)", userText),
|
|
548
|
+
mtime: info?.mtime
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
const memoryPath = join2(memoryDir, "MEMORY.md");
|
|
552
|
+
const memoryText = await tree.text(memoryPath);
|
|
553
|
+
if (memoryText !== undefined) {
|
|
554
|
+
const info = await regularFile(memoryPath);
|
|
555
|
+
extract.inventory.memory.push(tree.rel(memoryPath));
|
|
556
|
+
extract.files.push({
|
|
557
|
+
category: "memory",
|
|
558
|
+
sources: [tree.rel(memoryPath)],
|
|
559
|
+
target: "memory/imported/hermes/MEMORY.md",
|
|
560
|
+
content: entriesToMarkdown("Long-term memory (imported from Hermes)", memoryText),
|
|
561
|
+
mtime: info?.mtime
|
|
562
|
+
});
|
|
563
|
+
emitImportedAxis(extract);
|
|
564
|
+
}
|
|
565
|
+
const envPath = join2(root, ".env");
|
|
566
|
+
const envText = await tree.text(envPath);
|
|
567
|
+
const env = envText === undefined ? new Map : parseDotenv(envText);
|
|
568
|
+
const consumedEnv = new Set;
|
|
569
|
+
const configPath = join2(root, "config.yaml");
|
|
570
|
+
const configText = await tree.text(configPath);
|
|
571
|
+
let config = {};
|
|
572
|
+
if (configText !== undefined) {
|
|
573
|
+
try {
|
|
574
|
+
const parsed = Bun.YAML.parse(configText);
|
|
575
|
+
if (isRecord(parsed))
|
|
576
|
+
config = parsed;
|
|
577
|
+
} catch (error) {
|
|
578
|
+
extract.warnings.push(`config.yaml could not be parsed (${error instanceof Error ? error.message : String(error)}); channel policies were not read`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const consumedConfig = new Set;
|
|
582
|
+
const channels = emptyChannelPlan();
|
|
583
|
+
for (const [key, credential] of Object.entries(TOKEN_KEYS)) {
|
|
584
|
+
const value = env.get(key);
|
|
585
|
+
if (!value)
|
|
586
|
+
continue;
|
|
587
|
+
consumedEnv.add(key);
|
|
588
|
+
extract.inventory.channels.push(`.env ${key}`);
|
|
589
|
+
if (value.includes(",")) {
|
|
590
|
+
extract.unmapped.push({
|
|
591
|
+
category: "credentials",
|
|
592
|
+
source: `.env ${key}`,
|
|
593
|
+
reason: "holds several comma-separated tokens (multi-workspace); gajae-way runs one bot per adapter, so none was imported"
|
|
594
|
+
});
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
channels.credentials[credential] = { value, source: `.env ${key}` };
|
|
598
|
+
}
|
|
599
|
+
for (const key of ALLOWLIST_KEYS) {
|
|
600
|
+
const value = env.get(key);
|
|
601
|
+
if (value === undefined)
|
|
602
|
+
continue;
|
|
603
|
+
consumedEnv.add(key);
|
|
604
|
+
for (const id of stringList(value))
|
|
605
|
+
channels.allowlist.add(id);
|
|
606
|
+
}
|
|
607
|
+
readHermesChannelPolicy(config, env, channels, consumedConfig, consumedEnv, extract);
|
|
608
|
+
if (Object.keys(channels.credentials).length > 0 || Object.keys(channels.channels).length > 0)
|
|
609
|
+
emitChannels(extract, channels, home, ".env / config.yaml");
|
|
610
|
+
await readHermesCron(tree, env, consumedEnv, extract);
|
|
611
|
+
emitMonitors(extract);
|
|
612
|
+
await readHermesSkills(tree, extract);
|
|
613
|
+
for (const key of env.keys())
|
|
614
|
+
if (!consumedEnv.has(key))
|
|
615
|
+
extract.unmapped.push({
|
|
616
|
+
category: "credentials",
|
|
617
|
+
source: `.env ${key}`,
|
|
618
|
+
reason: /(_KEY|_TOKEN|_SECRET|PASSWORD)$/.test(key) ? "provider/tool credential; gajae-way sessions use the shared gjc agent profile, so set it there if still needed" : "setting has no gajae-way equivalent"
|
|
619
|
+
});
|
|
620
|
+
for (const key of Object.keys(config))
|
|
621
|
+
if (!consumedConfig.has(key))
|
|
622
|
+
extract.unmapped.push({
|
|
623
|
+
category: "other",
|
|
624
|
+
source: `config.yaml ${key}`,
|
|
625
|
+
reason: "setting has no gajae-way equivalent"
|
|
626
|
+
});
|
|
627
|
+
return extract;
|
|
628
|
+
}
|
|
629
|
+
function entriesToMarkdown(title, raw) {
|
|
630
|
+
const items = raw.split(HERMES_ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
|
|
631
|
+
return `# ${title}
|
|
632
|
+
|
|
633
|
+
${items.map((entry) => `- ${entry.replace(/\n/g, `
|
|
634
|
+
`)}`).join(`
|
|
635
|
+
`)}
|
|
636
|
+
`;
|
|
637
|
+
}
|
|
638
|
+
function readHermesChannelPolicy(config, env, channels, consumedConfig, consumedEnv, extract) {
|
|
639
|
+
const discord = isRecord(config.discord) ? config.discord : undefined;
|
|
640
|
+
if (discord) {
|
|
641
|
+
consumedConfig.add("discord");
|
|
642
|
+
const free = stringList(discord.free_response_channels);
|
|
643
|
+
for (const id of free) {
|
|
644
|
+
if (id === "*") {
|
|
645
|
+
extract.unmapped.push({
|
|
646
|
+
category: "channels",
|
|
647
|
+
source: "config.yaml discord.free_response_channels",
|
|
648
|
+
reason: '"*" (every channel) has no per-channel equivalent; list channels explicitly'
|
|
649
|
+
});
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
channels.channels[`discord:${id}`] = { engagement: "open" };
|
|
653
|
+
extract.inventory.channels.push(`config.yaml discord.free_response_channels ${id}`);
|
|
654
|
+
}
|
|
655
|
+
for (const key of Object.keys(discord))
|
|
656
|
+
if (key !== "free_response_channels" && key !== "require_mention")
|
|
657
|
+
extract.unmapped.push({
|
|
658
|
+
category: "channels",
|
|
659
|
+
source: `config.yaml discord.${key}`,
|
|
660
|
+
reason: "Discord adapter setting has no gajae-way equivalent"
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const slackChannels = env.get("SLACK_ALLOWED_CHANNELS");
|
|
664
|
+
if (slackChannels !== undefined) {
|
|
665
|
+
consumedEnv.add("SLACK_ALLOWED_CHANNELS");
|
|
666
|
+
for (const id of stringList(slackChannels))
|
|
667
|
+
channels.channels[`slack:${id}`] = { engagement: "mention-open" };
|
|
668
|
+
}
|
|
669
|
+
const telegram = isRecord(config.telegram) ? config.telegram : undefined;
|
|
670
|
+
if (telegram)
|
|
671
|
+
consumedConfig.add("telegram");
|
|
672
|
+
const telegramGroups = env.get("TELEGRAM_GROUP_ALLOWED_CHATS");
|
|
673
|
+
if (telegramGroups !== undefined) {
|
|
674
|
+
consumedEnv.add("TELEGRAM_GROUP_ALLOWED_CHATS");
|
|
675
|
+
const engagement = telegram?.require_mention === true ? "mention-open" : "open";
|
|
676
|
+
for (const id of stringList(telegramGroups))
|
|
677
|
+
channels.channels[`telegram:${id}`] = { engagement };
|
|
678
|
+
}
|
|
679
|
+
for (const key of [
|
|
680
|
+
"DISCORD_ALLOW_ALL_USERS",
|
|
681
|
+
"TELEGRAM_ALLOW_ALL_USERS",
|
|
682
|
+
"SLACK_ALLOW_ALL_USERS",
|
|
683
|
+
"GATEWAY_ALLOW_ALL_USERS"
|
|
684
|
+
]) {
|
|
685
|
+
if (!env.has(key))
|
|
686
|
+
continue;
|
|
687
|
+
consumedEnv.add(key);
|
|
688
|
+
extract.unmapped.push({
|
|
689
|
+
category: "channels",
|
|
690
|
+
source: `.env ${key}`,
|
|
691
|
+
reason: 'allow-everyone is not carried over; gajae-way DMs default to the allowlist. Set dmPolicy "open" in config.json deliberately if wanted'
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
async function readHermesCron(tree, env, consumedEnv, extract) {
|
|
696
|
+
const path = join2(tree.root, "cron", "jobs.json");
|
|
697
|
+
const text = await tree.text(path);
|
|
698
|
+
if (text === undefined)
|
|
699
|
+
return;
|
|
700
|
+
let jobs = [];
|
|
701
|
+
try {
|
|
702
|
+
const parsed = JSON.parse(text);
|
|
703
|
+
jobs = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [];
|
|
704
|
+
} catch {
|
|
705
|
+
extract.unmapped.push({ category: "monitors", source: tree.rel(path), reason: "is not valid JSON" });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const taken = new Set;
|
|
709
|
+
for (const job of jobs) {
|
|
710
|
+
if (!isRecord(job))
|
|
711
|
+
continue;
|
|
712
|
+
const label = String(job.name ?? job.id ?? "job");
|
|
713
|
+
const source = `${tree.rel(path)} ${JSON.stringify(label)}`;
|
|
714
|
+
extract.inventory.schedules.push(source);
|
|
715
|
+
const skip = (reason) => extract.unmapped.push({ category: "monitors", source, reason });
|
|
716
|
+
if (job.no_agent === true || typeof job.script === "string" && job.script) {
|
|
717
|
+
skip("script job: gajae-way script monitors run a command under scriptRoot, recreate it with a script trigger");
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (typeof job.monitor_script === "string" || typeof job.monitor_url === "string") {
|
|
721
|
+
skip("change-detection pre-check (monitor_script/monitor_url) has no equivalent");
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const schedule = isRecord(job.schedule) ? job.schedule : {};
|
|
725
|
+
let cron;
|
|
726
|
+
if (schedule.kind === "cron" && typeof schedule.expr === "string") {
|
|
727
|
+
cron = gatewayCron(schedule.expr);
|
|
728
|
+
if (!cron) {
|
|
729
|
+
skip(`cron expression ${JSON.stringify(schedule.expr)} uses syntax the gateway cron trigger does not evaluate`);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
} else if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
|
|
733
|
+
cron = intervalCron(schedule.minutes);
|
|
734
|
+
if (!cron) {
|
|
735
|
+
skip(`every ${schedule.minutes}m does not divide an hour or a day, so it has no cron form`);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
} else {
|
|
739
|
+
skip(`schedule kind ${JSON.stringify(schedule.kind)} (one-shot) has no recurring monitor equivalent`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const skills = stringList(job.skills);
|
|
743
|
+
const prompt = typeof job.prompt === "string" ? job.prompt : "";
|
|
744
|
+
const instruction = skills.length ? `Use the skill(s): ${skills.join(", ")}.
|
|
745
|
+
|
|
746
|
+
${prompt}` : prompt;
|
|
747
|
+
const target = hermesTarget(job, env, consumedEnv, extract, source);
|
|
748
|
+
const repeat = isRecord(job.repeat) ? job.repeat.times : null;
|
|
749
|
+
if (typeof repeat === "number")
|
|
750
|
+
extract.warnings.push(`${source}: repeat limit ${repeat} dropped; the monitor fires until removed`);
|
|
751
|
+
const spec = cronMonitor({
|
|
752
|
+
source: "hermes",
|
|
753
|
+
name: label,
|
|
754
|
+
schedule: cron,
|
|
755
|
+
instruction,
|
|
756
|
+
enabled: job.enabled !== false && job.state !== "paused",
|
|
757
|
+
target,
|
|
758
|
+
taken
|
|
759
|
+
});
|
|
760
|
+
if (typeof spec === "string")
|
|
761
|
+
skip(spec);
|
|
762
|
+
else
|
|
763
|
+
extract.monitors.push({ source, spec });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function hermesTarget(job, env, consumedEnv, extract, source) {
|
|
767
|
+
const targets = stringList(job.deliver ?? "local").filter((target) => target !== "local");
|
|
768
|
+
if (targets.length === 0)
|
|
769
|
+
return;
|
|
770
|
+
const resolved = [];
|
|
771
|
+
for (const target of targets) {
|
|
772
|
+
let result;
|
|
773
|
+
if (target === "origin" || target === "origin_fallback") {
|
|
774
|
+
const origin = isRecord(job.origin) ? job.origin : undefined;
|
|
775
|
+
result = origin && typeof origin.platform === "string" && origin.chat_id !== undefined ? deliveryOrigin(origin.platform, String(origin.chat_id)) : "origin delivery recorded no origin chat";
|
|
776
|
+
} else {
|
|
777
|
+
const [platform = "", ...rest] = target.split(":");
|
|
778
|
+
let chat = rest.join(":");
|
|
779
|
+
if (!chat && HOME_CHANNEL_KEYS[platform]) {
|
|
780
|
+
chat = env.get(HOME_CHANNEL_KEYS[platform]) ?? "";
|
|
781
|
+
consumedEnv.add(HOME_CHANNEL_KEYS[platform]);
|
|
782
|
+
consumedEnv.add(`${HOME_CHANNEL_KEYS[platform]}_NAME`);
|
|
783
|
+
}
|
|
784
|
+
result = chat ? deliveryOrigin(platform, chat) : `delivery target ${JSON.stringify(target)} has no chat id or home channel`;
|
|
785
|
+
}
|
|
786
|
+
if (typeof result === "string")
|
|
787
|
+
extract.warnings.push(`${source}: ${result}; delivered to the owner target instead`);
|
|
788
|
+
else
|
|
789
|
+
resolved.push(result);
|
|
790
|
+
}
|
|
791
|
+
if (resolved.length > 1)
|
|
792
|
+
extract.warnings.push(`${source}: a monitor has one channel target; kept the first of ${resolved.length}`);
|
|
793
|
+
return resolved[0];
|
|
794
|
+
}
|
|
795
|
+
async function readHermesSkills(tree, extract) {
|
|
796
|
+
const skillsDir = join2(tree.root, "skills");
|
|
797
|
+
if (!await isDirectory(skillsDir))
|
|
798
|
+
return;
|
|
799
|
+
tree.consume(skillsDir);
|
|
800
|
+
const bundledText = await tree.text(join2(skillsDir, ".bundled_manifest"));
|
|
801
|
+
const bundled = new Set((bundledText ?? "").split(/\r?\n/).map((line) => line.split(":")[0]?.trim() ?? "").filter(Boolean));
|
|
802
|
+
const files = (await walkFiles(skillsDir)).filter((file) => !file.split("/").some((part) => part.startsWith(".")));
|
|
803
|
+
const skillRoots = files.filter((file) => file.endsWith("/SKILL.md") || file === "SKILL.md").map((file) => file.slice(0, -"/SKILL.md".length));
|
|
804
|
+
const seen = new Set;
|
|
805
|
+
for (const skillRoot of skillRoots) {
|
|
806
|
+
const name = skillRoot.split("/").at(-1) ?? skillRoot;
|
|
807
|
+
if (bundled.has(name))
|
|
808
|
+
continue;
|
|
809
|
+
await copySkill(tree, join2(skillsDir, skillRoot), name, extract, seen);
|
|
810
|
+
}
|
|
811
|
+
if (bundled.size)
|
|
812
|
+
extract.warnings.push(`${bundled.size} Hermes-bundled skill(s) skipped; they ship with Hermes, not with your data`);
|
|
813
|
+
}
|
|
814
|
+
async function copySkill(tree, dir, name, extract, seen) {
|
|
815
|
+
const source = tree.rel(dir);
|
|
816
|
+
extract.inventory.skills.push(source);
|
|
817
|
+
if (name === "self-ops" || seen.has(name)) {
|
|
818
|
+
extract.unmapped.push({
|
|
819
|
+
category: "skills",
|
|
820
|
+
source,
|
|
821
|
+
reason: `a skill named ${name} is already imported or built in`
|
|
822
|
+
});
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
seen.add(name);
|
|
826
|
+
for (const file of await walkFiles(dir)) {
|
|
827
|
+
const path = join2(dir, file);
|
|
828
|
+
const content = await tree.bytes(path);
|
|
829
|
+
if (content === undefined)
|
|
830
|
+
continue;
|
|
831
|
+
extract.files.push({
|
|
832
|
+
category: "skills",
|
|
833
|
+
sources: [tree.rel(path)],
|
|
834
|
+
target: `workspace/.gjc/skills/${name}/${file}`,
|
|
835
|
+
content,
|
|
836
|
+
mtime: (await regularFile(path))?.mtime
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/migrate/openclaw.ts
|
|
842
|
+
import { join as join3 } from "path";
|
|
843
|
+
var OPENCLAW_CONFIG = "config.json";
|
|
844
|
+
async function detectOpenClaw(root) {
|
|
845
|
+
const configPath = join3(root, OPENCLAW_CONFIG);
|
|
846
|
+
const stat = await regularFile(configPath);
|
|
847
|
+
return stat !== undefined;
|
|
848
|
+
}
|
|
849
|
+
async function readOpenClaw(tree, _home) {
|
|
850
|
+
const extract = emptyExtract("openclaw", tree.root);
|
|
851
|
+
const configPath = join3(tree.root, OPENCLAW_CONFIG);
|
|
852
|
+
const envPath = join3(tree.root, ".env");
|
|
853
|
+
let config = {};
|
|
854
|
+
const configText = await tree.text(configPath);
|
|
855
|
+
if (configText) {
|
|
856
|
+
try {
|
|
857
|
+
config = JSON.parse(configText);
|
|
858
|
+
} catch (e) {
|
|
859
|
+
extract.warnings.push(`Invalid config.json: ${String(e)}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const env = new Map;
|
|
863
|
+
const envText = await tree.text(envPath);
|
|
864
|
+
if (envText) {
|
|
865
|
+
const parsed = parseDotenv(envText);
|
|
866
|
+
for (const [key, val] of parsed.entries()) {
|
|
867
|
+
env.set(key, val);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const consumedConfig = new Set;
|
|
871
|
+
const consumedEnv = new Set;
|
|
872
|
+
if (config.persona?.name) {
|
|
873
|
+
extract.files.push({
|
|
874
|
+
category: "persona",
|
|
875
|
+
sources: ["config.persona.name"],
|
|
876
|
+
target: "persona.json",
|
|
877
|
+
content: JSON.stringify({ name: config.persona.name })
|
|
878
|
+
});
|
|
879
|
+
consumedConfig.add("persona");
|
|
880
|
+
}
|
|
881
|
+
const channels = emptyChannelPlan();
|
|
882
|
+
if (isRecord(config.channels)) {
|
|
883
|
+
readOpenClawChannels(config.channels, env, channels, consumedConfig, consumedEnv);
|
|
884
|
+
}
|
|
885
|
+
if (isRecord(config.monitors)) {
|
|
886
|
+
for (const [name, spec] of Object.entries(config.monitors)) {
|
|
887
|
+
if (isRecord(spec)) {
|
|
888
|
+
const monitorResult = buildOpenClawMonitor(name, spec);
|
|
889
|
+
if (typeof monitorResult === "object") {
|
|
890
|
+
extract.monitors.push({ source: `config.monitors.${name}`, spec: monitorResult });
|
|
891
|
+
} else {
|
|
892
|
+
extract.unmapped.push({
|
|
893
|
+
source: `config.monitors.${name}`,
|
|
894
|
+
reason: monitorResult,
|
|
895
|
+
category: "monitors"
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
consumedConfig.add("monitors");
|
|
901
|
+
}
|
|
902
|
+
if (isRecord(config.skills)) {
|
|
903
|
+
await readOpenClawSkills(tree, config.skills, extract);
|
|
904
|
+
consumedConfig.add("skills");
|
|
905
|
+
}
|
|
906
|
+
if (isRecord(config.memory)) {
|
|
907
|
+
await readOpenClawMemory(tree, config.memory, extract);
|
|
908
|
+
consumedConfig.add("memory");
|
|
909
|
+
}
|
|
910
|
+
for (const [key] of Object.entries(config)) {
|
|
911
|
+
if (!consumedConfig.has(key)) {
|
|
912
|
+
extract.unmapped.push({
|
|
913
|
+
source: `config.${key}`,
|
|
914
|
+
reason: "not mapped by migration",
|
|
915
|
+
category: "other"
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
for (const [key] of env.entries()) {
|
|
920
|
+
if (!consumedEnv.has(key)) {
|
|
921
|
+
extract.unmapped.push({
|
|
922
|
+
source: `.env.${key}`,
|
|
923
|
+
reason: "not mapped by migration",
|
|
924
|
+
category: "credentials"
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return extract;
|
|
929
|
+
}
|
|
930
|
+
function readOpenClawChannels(config, env, channels, consumedConfig, consumedEnv) {
|
|
931
|
+
for (const [platform, platformConfig] of Object.entries(config)) {
|
|
932
|
+
if (!isRecord(platformConfig))
|
|
933
|
+
continue;
|
|
934
|
+
if (platform === "discord" || platform === "telegram" || platform === "slack") {
|
|
935
|
+
const credKey = `${platform.toUpperCase()}_TOKEN`;
|
|
936
|
+
const token = env.get(credKey);
|
|
937
|
+
if (token) {
|
|
938
|
+
consumedEnv.add(credKey);
|
|
939
|
+
channels.credentials[platform === "slack" ? "slack-bot" : platform] = {
|
|
940
|
+
value: token,
|
|
941
|
+
source: `.env ${credKey}`
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
if (isRecord(platformConfig.channels)) {
|
|
945
|
+
for (const [channelId, channelPolicy] of Object.entries(platformConfig.channels)) {
|
|
946
|
+
if (isRecord(channelPolicy)) {
|
|
947
|
+
const engagement = typeof channelPolicy.engagement === "string" ? channelPolicy.engagement : "open";
|
|
948
|
+
channels.channels[`${platform}:${channelId}`] = {
|
|
949
|
+
engagement
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const allowlistKey = `${platform.toUpperCase()}_ALLOWED_USERS`;
|
|
955
|
+
const allowlist = env.get(allowlistKey);
|
|
956
|
+
if (allowlist) {
|
|
957
|
+
consumedEnv.add(allowlistKey);
|
|
958
|
+
for (const user of stringList(allowlist)) {
|
|
959
|
+
channels.allowlist.add(`${platform}:${user}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
consumedConfig.add("channels");
|
|
965
|
+
}
|
|
966
|
+
function buildOpenClawMonitor(name, spec) {
|
|
967
|
+
const schedule = typeof spec.schedule === "string" ? spec.schedule : undefined;
|
|
968
|
+
const enabled = typeof spec.enabled === "boolean" ? spec.enabled : true;
|
|
969
|
+
const instruction = typeof spec.instruction === "string" ? spec.instruction : "";
|
|
970
|
+
const target = typeof spec.target === "string" ? spec.target : undefined;
|
|
971
|
+
if (!schedule || instruction.length === 0) {
|
|
972
|
+
return "Missing schedule or instruction";
|
|
973
|
+
}
|
|
974
|
+
if (instruction.length > MONITOR_INSTRUCTION_MAX) {
|
|
975
|
+
return `Instruction exceeds ${MONITOR_INSTRUCTION_MAX} characters`;
|
|
976
|
+
}
|
|
977
|
+
const cronExpr = schedule.includes("/") ? schedule : intervalCron(parseInt(schedule, 10));
|
|
978
|
+
if (!cronExpr) {
|
|
979
|
+
return "Invalid schedule";
|
|
980
|
+
}
|
|
981
|
+
let deliveryTarget;
|
|
982
|
+
if (target) {
|
|
983
|
+
const [platform, ...rest] = target.split(":");
|
|
984
|
+
const rawTarget = rest.join(":");
|
|
985
|
+
const origin = deliveryOrigin(platform, rawTarget);
|
|
986
|
+
if (typeof origin === "object") {
|
|
987
|
+
deliveryTarget = origin;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const taken = new Set;
|
|
991
|
+
return cronMonitor({
|
|
992
|
+
source: "openclaw",
|
|
993
|
+
name: slug(name),
|
|
994
|
+
instruction,
|
|
995
|
+
schedule: cronExpr,
|
|
996
|
+
enabled,
|
|
997
|
+
target: deliveryTarget,
|
|
998
|
+
taken
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
async function readOpenClawSkills(tree, skillsConfig, extract) {
|
|
1002
|
+
const skillsDir = join3(tree.root, "skills");
|
|
1003
|
+
const hasSkillsDir = await isDirectory(skillsDir);
|
|
1004
|
+
for (const [skillName] of Object.entries(skillsConfig)) {
|
|
1005
|
+
if (!hasSkillsDir)
|
|
1006
|
+
continue;
|
|
1007
|
+
const skillPath = join3(skillsDir, `${skillName}.md`);
|
|
1008
|
+
const skillContent = await tree.text(skillPath);
|
|
1009
|
+
if (skillContent) {
|
|
1010
|
+
extract.files.push({
|
|
1011
|
+
category: "skills",
|
|
1012
|
+
sources: [skillPath],
|
|
1013
|
+
target: join3(".gjc", "skills", `${slug(skillName)}.md`),
|
|
1014
|
+
content: skillContent
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
async function readOpenClawMemory(tree, _memoryConfig, extract) {
|
|
1020
|
+
const memoryDir = join3(tree.root, "memory");
|
|
1021
|
+
const hasMemoryDir = await isDirectory(memoryDir);
|
|
1022
|
+
if (!hasMemoryDir) {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const files = await walkFiles(memoryDir);
|
|
1026
|
+
for (const file of files) {
|
|
1027
|
+
if (file.endsWith(".md")) {
|
|
1028
|
+
const fullPath = join3(memoryDir, file);
|
|
1029
|
+
const content = await tree.text(fullPath);
|
|
1030
|
+
if (content) {
|
|
1031
|
+
extract.files.push({
|
|
1032
|
+
category: "memory",
|
|
1033
|
+
sources: [fullPath],
|
|
1034
|
+
target: join3("memory", "imported.md"),
|
|
1035
|
+
content
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// src/migrate.ts
|
|
1043
|
+
async function migrate(options) {
|
|
1044
|
+
const sourceRoot = expandHome(options.source ?? ".");
|
|
1045
|
+
const targetHome = expandHome(options.target ?? `${process.env.HOME ?? "~"}/.gajaeway`);
|
|
1046
|
+
let sourceType;
|
|
1047
|
+
if (await detectHermes(sourceRoot)) {
|
|
1048
|
+
sourceType = "hermes";
|
|
1049
|
+
} else if (await detectOpenClaw(sourceRoot)) {
|
|
1050
|
+
sourceType = "openclaw";
|
|
1051
|
+
} else {
|
|
1052
|
+
throw new Error(`No Hermes or OpenClaw configuration detected at ${sourceRoot}. Expected .hermes.config.json or config.json`);
|
|
1053
|
+
}
|
|
1054
|
+
console.log(`Detected ${sourceType} configuration at ${sourceRoot}`);
|
|
1055
|
+
const tree = new SourceTree(sourceRoot);
|
|
1056
|
+
let extract;
|
|
1057
|
+
if (sourceType === "hermes") {
|
|
1058
|
+
extract = await readHermes(tree, targetHome);
|
|
1059
|
+
} else {
|
|
1060
|
+
extract = await readOpenClaw(tree, targetHome);
|
|
1061
|
+
}
|
|
1062
|
+
for (const warning of extract.warnings) {
|
|
1063
|
+
console.warn(`Warning: ${warning}`);
|
|
1064
|
+
}
|
|
1065
|
+
if (extract.unmapped.length > 0) {
|
|
1066
|
+
console.log(`
|
|
1067
|
+
Unmapped entries (these will not be migrated):`);
|
|
1068
|
+
const byCategory = new Map;
|
|
1069
|
+
for (const entry of extract.unmapped) {
|
|
1070
|
+
if (!byCategory.has(entry.category)) {
|
|
1071
|
+
byCategory.set(entry.category, []);
|
|
1072
|
+
}
|
|
1073
|
+
const cat = byCategory.get(entry.category);
|
|
1074
|
+
if (cat) {
|
|
1075
|
+
cat.push(entry);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
for (const [category, entries2] of byCategory) {
|
|
1079
|
+
console.log(` ${category}:`);
|
|
1080
|
+
for (const entry of entries2) {
|
|
1081
|
+
console.log(` - ${entry.source}: ${entry.reason}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (extract.inventory.persona.length > 0 || extract.inventory.schedules.length > 0) {
|
|
1086
|
+
console.log(`
|
|
1087
|
+
Migration summary:`);
|
|
1088
|
+
console.log(` Personas: ${extract.inventory.persona.length}`);
|
|
1089
|
+
console.log(` Schedules: ${extract.inventory.schedules.length}`);
|
|
1090
|
+
console.log(` Memory entries: ${extract.inventory.memory.length}`);
|
|
1091
|
+
console.log(` Skills: ${extract.inventory.skills.length}`);
|
|
1092
|
+
console.log(` Channels: ${extract.inventory.channels.length}`);
|
|
1093
|
+
}
|
|
1094
|
+
if (options.dryRun) {
|
|
1095
|
+
console.log(`
|
|
1096
|
+
[DRY RUN] Output would be written to:`);
|
|
1097
|
+
for (const file of extract.files) {
|
|
1098
|
+
console.log(` ${file.target} (${file.category})`);
|
|
1099
|
+
}
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
await mkdir(targetHome, { recursive: true });
|
|
1103
|
+
const channels = emptyChannelPlan();
|
|
1104
|
+
emitChannels(extract, channels, targetHome, sourceType);
|
|
1105
|
+
emitMonitors(extract);
|
|
1106
|
+
emitImportedAxis(extract);
|
|
1107
|
+
for (const file of extract.files) {
|
|
1108
|
+
const filePath = join4(targetHome, file.target);
|
|
1109
|
+
await mkdir(join4(filePath, ".."), { recursive: true });
|
|
1110
|
+
await writeFile(filePath, file.content);
|
|
1111
|
+
}
|
|
1112
|
+
for (const merge of extract.merges) {
|
|
1113
|
+
const filePath = join4(targetHome, merge.target);
|
|
1114
|
+
await mkdir(join4(filePath, ".."), { recursive: true });
|
|
1115
|
+
let existing;
|
|
1116
|
+
try {
|
|
1117
|
+
const text = await readFile2(filePath, "utf-8");
|
|
1118
|
+
existing = JSON.parse(text);
|
|
1119
|
+
} catch {}
|
|
1120
|
+
const merged = merge.merge(existing);
|
|
1121
|
+
await writeFile(filePath, JSON.stringify(merged, null, 2));
|
|
1122
|
+
}
|
|
1123
|
+
console.log(`
|
|
1124
|
+
Migration complete. Output written to ${targetHome}`);
|
|
1125
|
+
}
|
|
1126
|
+
function parseMigrateArgs(args) {
|
|
1127
|
+
const options = {};
|
|
1128
|
+
for (let i = 0;i < args.length; i++) {
|
|
1129
|
+
const arg = args[i];
|
|
1130
|
+
if (arg === "--source" && args[i + 1]) {
|
|
1131
|
+
options.source = args[++i];
|
|
1132
|
+
} else if (arg === "--target" && args[i + 1]) {
|
|
1133
|
+
options.target = args[++i];
|
|
1134
|
+
} else if (arg === "--dry-run") {
|
|
1135
|
+
options.dryRun = true;
|
|
1136
|
+
} else if (arg.startsWith("--")) {
|
|
1137
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1138
|
+
} else if (!options.source) {
|
|
1139
|
+
options.source = arg;
|
|
1140
|
+
} else {
|
|
1141
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return options;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
130
1147
|
// src/restart-stack.ts
|
|
131
1148
|
import { randomUUID } from "crypto";
|
|
132
|
-
import { access, mkdir as
|
|
133
|
-
import { homedir as
|
|
134
|
-
import { join as
|
|
1149
|
+
import { access, mkdir as mkdir3, readFile as readFile4, rename, stat, writeFile as writeFile3 } from "fs/promises";
|
|
1150
|
+
import { homedir as homedir3 } from "os";
|
|
1151
|
+
import { join as join6 } from "path";
|
|
135
1152
|
|
|
136
1153
|
// src/services.ts
|
|
137
|
-
import { chmod, mkdir, readFile, writeFile } from "fs/promises";
|
|
138
|
-
import { homedir } from "os";
|
|
139
|
-
import { join } from "path";
|
|
1154
|
+
import { chmod, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
1155
|
+
import { homedir as homedir2 } from "os";
|
|
1156
|
+
import { join as join5 } from "path";
|
|
140
1157
|
import { parseRuntimeConfig, RuntimeConfigError } from "@gajae-gateway/protocol";
|
|
141
1158
|
var CONFIG_SCHEMA_VERSION = 1;
|
|
142
1159
|
var FALLBACK_PATH_DIRS = ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
@@ -170,18 +1187,18 @@ function serviceSpecs() {
|
|
|
170
1187
|
return SERVICE_SPECS;
|
|
171
1188
|
}
|
|
172
1189
|
function homeForEnvironment(env) {
|
|
173
|
-
return env.GAJAEWAY_HOME ||
|
|
1190
|
+
return env.GAJAEWAY_HOME || join5(env.HOME || homedir2(), ".gajaeway");
|
|
174
1191
|
}
|
|
175
|
-
function
|
|
176
|
-
return value.startsWith("~/") ?
|
|
1192
|
+
function expandHome2(value, home) {
|
|
1193
|
+
return value.startsWith("~/") ? join5(home, value.slice(2)) : value;
|
|
177
1194
|
}
|
|
178
|
-
function normalizedEntries(
|
|
1195
|
+
function normalizedEntries(entries2, home) {
|
|
179
1196
|
const seen = new Set;
|
|
180
1197
|
const result = [];
|
|
181
|
-
for (const raw of
|
|
1198
|
+
for (const raw of entries2) {
|
|
182
1199
|
if (raw.length === 0)
|
|
183
1200
|
continue;
|
|
184
|
-
const entry =
|
|
1201
|
+
const entry = expandHome2(raw, home);
|
|
185
1202
|
if (seen.has(entry))
|
|
186
1203
|
continue;
|
|
187
1204
|
seen.add(entry);
|
|
@@ -191,10 +1208,10 @@ function normalizedEntries(entries, home) {
|
|
|
191
1208
|
}
|
|
192
1209
|
function mergePathEntries(binDir, userHome, loginPath) {
|
|
193
1210
|
return normalizedEntries([
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
1211
|
+
expandHome2(binDir, userHome),
|
|
1212
|
+
join5(userHome, "bin"),
|
|
1213
|
+
join5(userHome, ".local", "bin"),
|
|
1214
|
+
join5(userHome, ".bun", "bin"),
|
|
198
1215
|
...loginPath.replace(/(?:\r\n|\n)+$/, "").split(":"),
|
|
199
1216
|
...FALLBACK_PATH_DIRS
|
|
200
1217
|
], userHome);
|
|
@@ -237,7 +1254,7 @@ function plistString(value) {
|
|
|
237
1254
|
return `<string>${escapeXml(value)}</string>`;
|
|
238
1255
|
}
|
|
239
1256
|
function renderLaunchAgent(spec, binDir, home, path) {
|
|
240
|
-
const program =
|
|
1257
|
+
const program = join5(binDir, spec.binary);
|
|
241
1258
|
const argumentsXml = [program, ...spec.args].map(plistString).join("");
|
|
242
1259
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
243
1260
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -251,8 +1268,8 @@ function renderLaunchAgent(spec, binDir, home, path) {
|
|
|
251
1268
|
</dict>
|
|
252
1269
|
<key>RunAtLoad</key><true/>
|
|
253
1270
|
<key>KeepAlive</key><true/>
|
|
254
|
-
<key>StandardOutPath</key>${plistString(
|
|
255
|
-
<key>StandardErrorPath</key>${plistString(
|
|
1271
|
+
<key>StandardOutPath</key>${plistString(join5(home, `${spec.id}.stdout.log`))}
|
|
1272
|
+
<key>StandardErrorPath</key>${plistString(join5(home, `${spec.id}.stderr.log`))}
|
|
256
1273
|
</dict></plist>
|
|
257
1274
|
`;
|
|
258
1275
|
}
|
|
@@ -263,7 +1280,7 @@ function systemdUnitName(spec) {
|
|
|
263
1280
|
return `gajaeway-${spec.id}.service`;
|
|
264
1281
|
}
|
|
265
1282
|
function renderSystemdUnit(spec, binDir, home, path) {
|
|
266
|
-
const execStart = [
|
|
1283
|
+
const execStart = [join5(binDir, spec.binary), ...spec.args].join(" ");
|
|
267
1284
|
const unit = [
|
|
268
1285
|
"[Unit]",
|
|
269
1286
|
`Description=gajaeway ${spec.id}`,
|
|
@@ -276,7 +1293,7 @@ function renderSystemdUnit(spec, binDir, home, path) {
|
|
|
276
1293
|
unitEnvironment("PATH", path),
|
|
277
1294
|
"Restart=always",
|
|
278
1295
|
"RestartSec=2",
|
|
279
|
-
...spec.dependsOnGateway ? [] : ["KillMode=
|
|
1296
|
+
...spec.dependsOnGateway ? [] : ["KillMode=mixed", "TimeoutStopSec=30s"],
|
|
280
1297
|
"",
|
|
281
1298
|
"[Install]",
|
|
282
1299
|
`WantedBy=${spec.dependsOnGateway ? GATEWAY_UNIT : "default.target"}`,
|
|
@@ -286,14 +1303,14 @@ function renderSystemdUnit(spec, binDir, home, path) {
|
|
|
286
1303
|
`);
|
|
287
1304
|
}
|
|
288
1305
|
function systemdUnitDir(env, userHome) {
|
|
289
|
-
const base = env.XDG_CONFIG_HOME ||
|
|
290
|
-
return
|
|
1306
|
+
const base = env.XDG_CONFIG_HOME || join5(userHome, ".config");
|
|
1307
|
+
return join5(base, "systemd", "user");
|
|
291
1308
|
}
|
|
292
1309
|
function launchAgentPlistPath(spec, launchAgentsDir) {
|
|
293
|
-
return
|
|
1310
|
+
return join5(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`);
|
|
294
1311
|
}
|
|
295
1312
|
function systemdUnitPath(spec, unitDir) {
|
|
296
|
-
return
|
|
1313
|
+
return join5(unitDir, systemdUnitName(spec));
|
|
297
1314
|
}
|
|
298
1315
|
function restartStackCommands(platform, uid) {
|
|
299
1316
|
if (platform === "linux")
|
|
@@ -321,10 +1338,10 @@ function currentPlatform() {
|
|
|
321
1338
|
throw new Error(`unsupported service platform: ${process.platform}`);
|
|
322
1339
|
}
|
|
323
1340
|
async function readRuntime(home) {
|
|
324
|
-
const configPath =
|
|
1341
|
+
const configPath = join5(home, "config.json");
|
|
325
1342
|
let raw;
|
|
326
1343
|
try {
|
|
327
|
-
raw = await
|
|
1344
|
+
raw = await readFile3(configPath, "utf8");
|
|
328
1345
|
} catch (error) {
|
|
329
1346
|
throw new Error(`could not read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
330
1347
|
}
|
|
@@ -351,10 +1368,10 @@ async function installServices(options) {
|
|
|
351
1368
|
throw new Error("services requires a non-empty --bin-dir DIR");
|
|
352
1369
|
const env = options.env ?? process.env;
|
|
353
1370
|
const platform = options.platform === undefined ? currentPlatform() : requirePlatform(options.platform);
|
|
354
|
-
const userHome = env.HOME ||
|
|
1371
|
+
const userHome = env.HOME || homedir2();
|
|
355
1372
|
const home = homeForEnvironment(env);
|
|
356
|
-
const binDir =
|
|
357
|
-
const targetDir = platform === "darwin" ?
|
|
1373
|
+
const binDir = expandHome2(options.binDir, userHome);
|
|
1374
|
+
const targetDir = platform === "darwin" ? expandHome2(options.launchAgentsDir ?? join5(userHome, "Library", "LaunchAgents"), userHome) : expandHome2(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
|
|
358
1375
|
const runtime = await readRuntime(home);
|
|
359
1376
|
const path = await resolvePath({
|
|
360
1377
|
binDir,
|
|
@@ -363,17 +1380,17 @@ async function installServices(options) {
|
|
|
363
1380
|
env,
|
|
364
1381
|
loginPathRunner: options.loginPathRunner
|
|
365
1382
|
});
|
|
366
|
-
await
|
|
1383
|
+
await mkdir2(targetDir, { recursive: true, mode: 448 });
|
|
367
1384
|
const definitions = SERVICE_SPECS.map((spec) => platform === "darwin" ? {
|
|
368
|
-
path:
|
|
1385
|
+
path: join5(targetDir, `dev.gajaeway.${spec.id}.plist`),
|
|
369
1386
|
contents: renderLaunchAgent(spec, binDir, home, path)
|
|
370
|
-
} : { path:
|
|
1387
|
+
} : { path: join5(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) });
|
|
371
1388
|
const write = options.writeFile;
|
|
372
1389
|
for (const definition of definitions) {
|
|
373
1390
|
if (write)
|
|
374
1391
|
await write(definition.path, definition.contents);
|
|
375
1392
|
else {
|
|
376
|
-
await
|
|
1393
|
+
await writeFile2(definition.path, definition.contents, { encoding: "utf8", mode: 384 });
|
|
377
1394
|
await chmod(definition.path, 384);
|
|
378
1395
|
}
|
|
379
1396
|
}
|
|
@@ -388,12 +1405,12 @@ var RECEIPT_FILE = "restart-stack.json";
|
|
|
388
1405
|
var VERIFY_TIMEOUT_MS = 90000;
|
|
389
1406
|
var POLL_MS = 1000;
|
|
390
1407
|
function restartReceiptPath(home) {
|
|
391
|
-
return
|
|
1408
|
+
return join6(home, RECEIPT_FILE);
|
|
392
1409
|
}
|
|
393
1410
|
async function readRestartReceipt(home) {
|
|
394
1411
|
let raw;
|
|
395
1412
|
try {
|
|
396
|
-
raw = await
|
|
1413
|
+
raw = await readFile4(restartReceiptPath(home), "utf8");
|
|
397
1414
|
} catch (error) {
|
|
398
1415
|
if (error.code === "ENOENT")
|
|
399
1416
|
return;
|
|
@@ -402,10 +1419,10 @@ async function readRestartReceipt(home) {
|
|
|
402
1419
|
return JSON.parse(raw);
|
|
403
1420
|
}
|
|
404
1421
|
async function writeReceipt(home, receipt) {
|
|
405
|
-
await
|
|
1422
|
+
await mkdir3(home, { recursive: true });
|
|
406
1423
|
const path = restartReceiptPath(home);
|
|
407
1424
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
408
|
-
await
|
|
1425
|
+
await writeFile3(temporary, `${JSON.stringify(receipt, null, 2)}
|
|
409
1426
|
`, { mode: 384 });
|
|
410
1427
|
await rename(temporary, path);
|
|
411
1428
|
}
|
|
@@ -475,8 +1492,8 @@ function defaultIsServiceInstalled(options) {
|
|
|
475
1492
|
if (spec === undefined)
|
|
476
1493
|
return false;
|
|
477
1494
|
if (options.platform === "darwin") {
|
|
478
|
-
const userHome2 = options.env.HOME ||
|
|
479
|
-
const agentsDir = options.launchAgentsDir ||
|
|
1495
|
+
const userHome2 = options.env.HOME || homedir3();
|
|
1496
|
+
const agentsDir = options.launchAgentsDir || join6(userHome2, "Library", "LaunchAgents");
|
|
480
1497
|
const plistPath = launchAgentPlistPath(spec, agentsDir);
|
|
481
1498
|
try {
|
|
482
1499
|
await access(plistPath);
|
|
@@ -485,7 +1502,7 @@ function defaultIsServiceInstalled(options) {
|
|
|
485
1502
|
return false;
|
|
486
1503
|
}
|
|
487
1504
|
}
|
|
488
|
-
const userHome = options.env.HOME ||
|
|
1505
|
+
const userHome = options.env.HOME || homedir3();
|
|
489
1506
|
const unitDirPath = options.unitDir || systemdUnitDir(options.env, userHome);
|
|
490
1507
|
const unitPath = systemdUnitPath(spec, unitDirPath);
|
|
491
1508
|
try {
|
|
@@ -496,9 +1513,10 @@ function defaultIsServiceInstalled(options) {
|
|
|
496
1513
|
}
|
|
497
1514
|
};
|
|
498
1515
|
}
|
|
499
|
-
function
|
|
500
|
-
return Math.floor(ms / 1000) * 1000;
|
|
1516
|
+
function psStartBound(ms) {
|
|
1517
|
+
return Math.floor(ms / 1000) * 1000 - PS_START_SKEW_MS;
|
|
501
1518
|
}
|
|
1519
|
+
var PS_START_SKEW_MS = 1000;
|
|
502
1520
|
async function runRestartStack(options) {
|
|
503
1521
|
const platform = options.platform ?? currentPlatform();
|
|
504
1522
|
const runner = options.runner ?? defaultCommandRunner;
|
|
@@ -569,7 +1587,7 @@ async function runRestartStack(options) {
|
|
|
569
1587
|
step.processStartedAt = new Date(running.startedAt).toISOString();
|
|
570
1588
|
step.binary = running.binary;
|
|
571
1589
|
step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
|
|
572
|
-
if (running.startedAt >=
|
|
1590
|
+
if (running.startedAt >= psStartBound(Math.max(binaryModifiedAt, since))) {
|
|
573
1591
|
step.result = "ok";
|
|
574
1592
|
delete step.detail;
|
|
575
1593
|
return;
|
|
@@ -657,6 +1675,956 @@ function renderRestartReceipt(receipt) {
|
|
|
657
1675
|
return lines;
|
|
658
1676
|
}
|
|
659
1677
|
|
|
1678
|
+
// src/setup.ts
|
|
1679
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1680
|
+
import { chmod as chmod2, mkdir as mkdir4, readFile as readFile5, rename as rename2, rm, writeFile as writeFile4 } from "fs/promises";
|
|
1681
|
+
import { join as join7 } from "path";
|
|
1682
|
+
var SETUP_PLATFORMS = ["discord", "slack", "telegram"];
|
|
1683
|
+
var SETUP_ENV = {
|
|
1684
|
+
discordToken: "GAJAEWAY_DISCORD_TOKEN",
|
|
1685
|
+
discordAppId: "GAJAEWAY_DISCORD_APP_ID",
|
|
1686
|
+
slackBotToken: "GAJAEWAY_SLACK_BOT_TOKEN",
|
|
1687
|
+
slackAppToken: "GAJAEWAY_SLACK_APP_TOKEN",
|
|
1688
|
+
telegramToken: "GAJAEWAY_TELEGRAM_TOKEN",
|
|
1689
|
+
ownerIds: "GAJAEWAY_OWNER_IDS"
|
|
1690
|
+
};
|
|
1691
|
+
var SETUP_USAGE = "usage: gajaeway setup [--from-env] [--adapters discord,slack,telegram] [--owner ID[,ID...]] [--discord-app-id ID] | setup --status";
|
|
1692
|
+
var SECRET_FILES2 = {
|
|
1693
|
+
discord: "discord-token",
|
|
1694
|
+
slackBot: "slack-bot-token",
|
|
1695
|
+
slackApp: "slack-app-token",
|
|
1696
|
+
telegram: "telegram-token"
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
class SetupError extends Error {
|
|
1700
|
+
constructor(message) {
|
|
1701
|
+
super(message);
|
|
1702
|
+
this.name = "SetupError";
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
var REDACTED = "[redacted]";
|
|
1706
|
+
function redact(text, secrets) {
|
|
1707
|
+
let out = text;
|
|
1708
|
+
for (const secret of secrets)
|
|
1709
|
+
if (secret.length >= 8)
|
|
1710
|
+
out = out.split(secret).join(REDACTED);
|
|
1711
|
+
return out;
|
|
1712
|
+
}
|
|
1713
|
+
function parseSetupArgs(args) {
|
|
1714
|
+
let fromEnv = false;
|
|
1715
|
+
let status = false;
|
|
1716
|
+
let adapters;
|
|
1717
|
+
const owners = [];
|
|
1718
|
+
let discordAppId;
|
|
1719
|
+
for (let i = 0;i < args.length; i++) {
|
|
1720
|
+
const flag = args[i];
|
|
1721
|
+
if (flag === "--from-env")
|
|
1722
|
+
fromEnv = true;
|
|
1723
|
+
else if (flag === "--status")
|
|
1724
|
+
status = true;
|
|
1725
|
+
else if (flag === "--adapters" || flag === "--owner" || flag === "--discord-app-id") {
|
|
1726
|
+
const value = args[++i];
|
|
1727
|
+
if (value === undefined || value.trim() === "" || value.startsWith("--"))
|
|
1728
|
+
throw new SetupError(`${flag} expects a value
|
|
1729
|
+
${SETUP_USAGE}`);
|
|
1730
|
+
if (flag === "--adapters")
|
|
1731
|
+
adapters = parseAdapterList(value);
|
|
1732
|
+
else if (flag === "--owner")
|
|
1733
|
+
owners.push(...splitList(value));
|
|
1734
|
+
else
|
|
1735
|
+
discordAppId = value.trim();
|
|
1736
|
+
} else if (/token/i.test(flag)) {
|
|
1737
|
+
throw new SetupError(`${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`);
|
|
1738
|
+
} else
|
|
1739
|
+
throw new SetupError(`unknown option: ${flag.split("=")[0]}
|
|
1740
|
+
${SETUP_USAGE}`);
|
|
1741
|
+
}
|
|
1742
|
+
if (status && (fromEnv || adapters || owners.length > 0 || discordAppId))
|
|
1743
|
+
throw new SetupError(`--status takes no other options
|
|
1744
|
+
${SETUP_USAGE}`);
|
|
1745
|
+
return {
|
|
1746
|
+
mode: status ? "status" : "setup",
|
|
1747
|
+
fromEnv,
|
|
1748
|
+
...adapters ? { adapters } : {},
|
|
1749
|
+
owners,
|
|
1750
|
+
...discordAppId ? { discordAppId } : {}
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
function splitList(value) {
|
|
1754
|
+
return value.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
|
|
1755
|
+
}
|
|
1756
|
+
function parseAdapterList(value) {
|
|
1757
|
+
const list = splitList(value);
|
|
1758
|
+
if (list.length === 0)
|
|
1759
|
+
throw new SetupError("--adapters expects at least one of discord, slack, telegram");
|
|
1760
|
+
for (const name of list)
|
|
1761
|
+
if (!SETUP_PLATFORMS.includes(name))
|
|
1762
|
+
throw new SetupError(`unknown adapter: ${name} (expected discord, slack, or telegram)`);
|
|
1763
|
+
return [...new Set(list)];
|
|
1764
|
+
}
|
|
1765
|
+
function nonEmpty(value) {
|
|
1766
|
+
const trimmed = value?.trim();
|
|
1767
|
+
return trimmed ? trimmed : undefined;
|
|
1768
|
+
}
|
|
1769
|
+
function tokensFromEnv(env, adapters, discordAppId) {
|
|
1770
|
+
const discord = nonEmpty(env[SETUP_ENV.discordToken]);
|
|
1771
|
+
const slackBot = nonEmpty(env[SETUP_ENV.slackBotToken]);
|
|
1772
|
+
const slackApp = nonEmpty(env[SETUP_ENV.slackAppToken]);
|
|
1773
|
+
const telegram = nonEmpty(env[SETUP_ENV.telegramToken]);
|
|
1774
|
+
const appId = discordAppId ?? nonEmpty(env[SETUP_ENV.discordAppId]);
|
|
1775
|
+
const wanted = adapters ?? SETUP_PLATFORMS.filter((platform) => presentFor(platform));
|
|
1776
|
+
function presentFor(platform) {
|
|
1777
|
+
if (platform === "discord")
|
|
1778
|
+
return discord !== undefined;
|
|
1779
|
+
if (platform === "telegram")
|
|
1780
|
+
return telegram !== undefined;
|
|
1781
|
+
return slackBot !== undefined || slackApp !== undefined;
|
|
1782
|
+
}
|
|
1783
|
+
if (wanted.length === 0)
|
|
1784
|
+
throw new SetupError(`--from-env found no adapter tokens; set ${SETUP_ENV.discordToken}, ${SETUP_ENV.slackBotToken} and ${SETUP_ENV.slackAppToken}, or ${SETUP_ENV.telegramToken}`);
|
|
1785
|
+
const tokens = {};
|
|
1786
|
+
for (const platform of wanted) {
|
|
1787
|
+
if (platform === "discord") {
|
|
1788
|
+
if (!discord)
|
|
1789
|
+
throw new SetupError(`discord: ${SETUP_ENV.discordToken} is not set`);
|
|
1790
|
+
tokens.discord = { token: discord, ...appId ? { appId } : {} };
|
|
1791
|
+
} else if (platform === "slack") {
|
|
1792
|
+
if (!slackBot)
|
|
1793
|
+
throw new SetupError(`slack: ${SETUP_ENV.slackBotToken} is not set`);
|
|
1794
|
+
if (!slackApp)
|
|
1795
|
+
throw new SetupError(`slack: ${SETUP_ENV.slackAppToken} is not set (Socket Mode app-level token)`);
|
|
1796
|
+
tokens.slack = { botToken: slackBot, appToken: slackApp };
|
|
1797
|
+
} else {
|
|
1798
|
+
if (!telegram)
|
|
1799
|
+
throw new SetupError(`telegram: ${SETUP_ENV.telegramToken} is not set`);
|
|
1800
|
+
tokens.telegram = { token: telegram };
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return tokens;
|
|
1804
|
+
}
|
|
1805
|
+
function secretsOf(tokens) {
|
|
1806
|
+
return [tokens.discord?.token, tokens.slack?.botToken, tokens.slack?.appToken, tokens.telegram?.token].filter((value) => value !== undefined);
|
|
1807
|
+
}
|
|
1808
|
+
async function requestJson(fetcher, platform, url, init, secrets) {
|
|
1809
|
+
let response;
|
|
1810
|
+
try {
|
|
1811
|
+
response = await fetcher(url, { ...init, signal: AbortSignal.timeout(15000) });
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1814
|
+
throw new SetupError(`${platform}: could not reach the platform API (${redact(message, secrets)})`);
|
|
1815
|
+
}
|
|
1816
|
+
let body;
|
|
1817
|
+
try {
|
|
1818
|
+
body = await response.json();
|
|
1819
|
+
} catch {
|
|
1820
|
+
body = {};
|
|
1821
|
+
}
|
|
1822
|
+
return {
|
|
1823
|
+
status: response.status,
|
|
1824
|
+
body: typeof body === "object" && body !== null && !Array.isArray(body) ? body : {}
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
async function validateDiscord(credentials, fetcher) {
|
|
1828
|
+
const secrets = [credentials.token];
|
|
1829
|
+
const headers = { authorization: `Bot ${credentials.token}` };
|
|
1830
|
+
const me = await requestJson(fetcher, "discord", "https://discord.com/api/v10/users/@me", { headers }, secrets);
|
|
1831
|
+
if (me.status === 401)
|
|
1832
|
+
throw new SetupError("discord: the platform rejected the bot token (HTTP 401); copy a fresh token from the Bot page");
|
|
1833
|
+
if (me.status !== 200)
|
|
1834
|
+
throw new SetupError(`discord: token check failed with HTTP ${me.status}`);
|
|
1835
|
+
if (me.body.bot !== true)
|
|
1836
|
+
throw new SetupError("discord: the token authenticates a user account, not a bot; use the Bot page token");
|
|
1837
|
+
const app = await requestJson(fetcher, "discord", "https://discord.com/api/v10/applications/@me", { headers }, secrets);
|
|
1838
|
+
if (app.status !== 200)
|
|
1839
|
+
throw new SetupError(`discord: application lookup failed with HTTP ${app.status}`);
|
|
1840
|
+
const appId = String(app.body.id ?? "");
|
|
1841
|
+
if (credentials.appId !== undefined && appId !== credentials.appId)
|
|
1842
|
+
throw new SetupError(`discord: the token belongs to application ${appId}, not ${credentials.appId}`);
|
|
1843
|
+
const owner = app.body.owner;
|
|
1844
|
+
const hasTeam = app.body.team !== null && app.body.team !== undefined;
|
|
1845
|
+
const ownerId = !hasTeam && typeof owner?.id === "string" ? owner.id : undefined;
|
|
1846
|
+
return {
|
|
1847
|
+
platform: "discord",
|
|
1848
|
+
summary: `bot ${String(me.body.username ?? "?")} (${String(me.body.id ?? "?")}) of application ${appId}`,
|
|
1849
|
+
...ownerId ? { ownerId } : {}
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
async function validateSlack(credentials, fetcher) {
|
|
1853
|
+
if (!credentials.botToken.startsWith("xoxb-"))
|
|
1854
|
+
throw new SetupError("slack: the bot token must start with xoxb- (OAuth & Permissions > Bot User OAuth Token)");
|
|
1855
|
+
if (!credentials.appToken.startsWith("xapp-"))
|
|
1856
|
+
throw new SetupError("slack: the app-level token must start with xapp- (Basic Information > App-Level Tokens, scope connections:write)");
|
|
1857
|
+
const secrets = [credentials.botToken, credentials.appToken];
|
|
1858
|
+
const call = (method, token) => requestJson(fetcher, "slack", `https://slack.com/api/${method}`, { method: "POST", headers: { authorization: `Bearer ${token}` } }, secrets);
|
|
1859
|
+
const auth = await call("auth.test", credentials.botToken);
|
|
1860
|
+
if (auth.body.ok !== true)
|
|
1861
|
+
throw new SetupError(`slack: the platform rejected the bot token (${slackError(auth.body, auth.status)})`);
|
|
1862
|
+
if (typeof auth.body.bot_id !== "string")
|
|
1863
|
+
throw new SetupError("slack: the bot token does not authenticate a bot user");
|
|
1864
|
+
const socket = await call("apps.connections.open", credentials.appToken);
|
|
1865
|
+
if (socket.body.ok !== true)
|
|
1866
|
+
throw new SetupError(`slack: the app-level token cannot open a Socket Mode connection (${slackError(socket.body, socket.status)}); enable Socket Mode and grant connections:write`);
|
|
1867
|
+
return {
|
|
1868
|
+
platform: "slack",
|
|
1869
|
+
summary: `bot ${String(auth.body.user ?? "?")} (${String(auth.body.user_id ?? "?")}) in workspace ${String(auth.body.team ?? "?")}`
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
function slackError(body, status) {
|
|
1873
|
+
const code = typeof body.error === "string" && /^[a-z_]{1,64}$/.test(body.error) ? body.error : `HTTP ${status}`;
|
|
1874
|
+
return code;
|
|
1875
|
+
}
|
|
1876
|
+
async function validateTelegram(credentials, fetcher) {
|
|
1877
|
+
const me = await requestJson(fetcher, "telegram", `https://api.telegram.org/bot${credentials.token}/getMe`, {}, [
|
|
1878
|
+
credentials.token
|
|
1879
|
+
]);
|
|
1880
|
+
const result = me.body.result;
|
|
1881
|
+
if (me.body.ok !== true || !result)
|
|
1882
|
+
throw new SetupError(`telegram: the platform rejected the bot token (HTTP ${me.status})`);
|
|
1883
|
+
return { platform: "telegram", summary: `bot @${String(result.username ?? "?")} (${String(result.id ?? "?")})` };
|
|
1884
|
+
}
|
|
1885
|
+
async function validateAll(tokens, fetcher) {
|
|
1886
|
+
const secrets = secretsOf(tokens);
|
|
1887
|
+
const identities = [];
|
|
1888
|
+
try {
|
|
1889
|
+
if (tokens.discord)
|
|
1890
|
+
identities.push(await validateDiscord(tokens.discord, fetcher));
|
|
1891
|
+
if (tokens.slack)
|
|
1892
|
+
identities.push(await validateSlack(tokens.slack, fetcher));
|
|
1893
|
+
if (tokens.telegram)
|
|
1894
|
+
identities.push(await validateTelegram(tokens.telegram, fetcher));
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1897
|
+
throw new SetupError(redact(message, secrets));
|
|
1898
|
+
}
|
|
1899
|
+
return identities;
|
|
1900
|
+
}
|
|
1901
|
+
async function writePrivateFile(path, contents) {
|
|
1902
|
+
const temporary = `${path}.${randomUUID2()}.tmp`;
|
|
1903
|
+
try {
|
|
1904
|
+
await writeFile4(temporary, contents, { flag: "wx", mode: 384 });
|
|
1905
|
+
await chmod2(temporary, 384);
|
|
1906
|
+
await rename2(temporary, path);
|
|
1907
|
+
} finally {
|
|
1908
|
+
await rm(temporary, { force: true });
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
async function readJsonObject(path) {
|
|
1912
|
+
let text;
|
|
1913
|
+
try {
|
|
1914
|
+
text = await readFile5(path, "utf8");
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
if (error.code === "ENOENT")
|
|
1917
|
+
return;
|
|
1918
|
+
throw new SetupError(`cannot read ${path}: ${error.code ?? "unreadable"}`);
|
|
1919
|
+
}
|
|
1920
|
+
let parsed;
|
|
1921
|
+
try {
|
|
1922
|
+
parsed = JSON.parse(text);
|
|
1923
|
+
} catch {
|
|
1924
|
+
throw new SetupError(`${path} is not valid JSON; fix or remove it, then run setup again`);
|
|
1925
|
+
}
|
|
1926
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1927
|
+
throw new SetupError(`${path} must contain a JSON object`);
|
|
1928
|
+
return parsed;
|
|
1929
|
+
}
|
|
1930
|
+
async function applySetup(options) {
|
|
1931
|
+
const { home, tokens } = options;
|
|
1932
|
+
const secretsDir = join7(home, "secrets");
|
|
1933
|
+
const configPath = join7(home, "config.json");
|
|
1934
|
+
const adapterPath = (platform) => join7(home, `adapter-${platform}.json`);
|
|
1935
|
+
const existingConfig = await readJsonObject(configPath);
|
|
1936
|
+
const existingAdapters = new Map;
|
|
1937
|
+
for (const platform of SETUP_PLATFORMS)
|
|
1938
|
+
if (tokens[platform])
|
|
1939
|
+
existingAdapters.set(platform, await readJsonObject(adapterPath(platform)));
|
|
1940
|
+
await mkdir4(home, { recursive: true, mode: 448 });
|
|
1941
|
+
await chmod2(home, 448);
|
|
1942
|
+
await mkdir4(secretsDir, { recursive: true, mode: 448 });
|
|
1943
|
+
await chmod2(secretsDir, 448);
|
|
1944
|
+
const written = [];
|
|
1945
|
+
const secret = async (name, value) => {
|
|
1946
|
+
const path = join7(secretsDir, name);
|
|
1947
|
+
await writePrivateFile(path, `${value}
|
|
1948
|
+
`);
|
|
1949
|
+
written.push(path);
|
|
1950
|
+
return path;
|
|
1951
|
+
};
|
|
1952
|
+
const credentials = {};
|
|
1953
|
+
const adapterConfigs = [];
|
|
1954
|
+
if (tokens.discord) {
|
|
1955
|
+
credentials.discord = { credentialFile: await secret(SECRET_FILES2.discord, tokens.discord.token) };
|
|
1956
|
+
adapterConfigs.push(["discord", { tokenFile: `secrets/${SECRET_FILES2.discord}` }]);
|
|
1957
|
+
}
|
|
1958
|
+
if (tokens.slack) {
|
|
1959
|
+
credentials["slack-bot"] = { credentialFile: await secret(SECRET_FILES2.slackBot, tokens.slack.botToken) };
|
|
1960
|
+
credentials["slack-app"] = { credentialFile: await secret(SECRET_FILES2.slackApp, tokens.slack.appToken) };
|
|
1961
|
+
adapterConfigs.push([
|
|
1962
|
+
"slack",
|
|
1963
|
+
{ botTokenFile: `secrets/${SECRET_FILES2.slackBot}`, appTokenFile: `secrets/${SECRET_FILES2.slackApp}` }
|
|
1964
|
+
]);
|
|
1965
|
+
}
|
|
1966
|
+
if (tokens.telegram) {
|
|
1967
|
+
credentials.telegram = { credentialFile: await secret(SECRET_FILES2.telegram, tokens.telegram.token) };
|
|
1968
|
+
adapterConfigs.push(["telegram", { tokenFile: `secrets/${SECRET_FILES2.telegram}` }]);
|
|
1969
|
+
}
|
|
1970
|
+
for (const [platform, references] of adapterConfigs) {
|
|
1971
|
+
const path = adapterPath(platform);
|
|
1972
|
+
await writePrivateFile(path, `${JSON.stringify({ ...existingAdapters.get(platform), ...references }, null, "\t")}
|
|
1973
|
+
`);
|
|
1974
|
+
written.push(path);
|
|
1975
|
+
}
|
|
1976
|
+
const base = existingConfig ?? { schemaVersion: 1 };
|
|
1977
|
+
const previousCredentials = typeof base.credentials === "object" && base.credentials !== null && !Array.isArray(base.credentials) ? base.credentials : {};
|
|
1978
|
+
const previousAllowlist = Array.isArray(base.mentionAllowlist) ? base.mentionAllowlist.filter((id) => typeof id === "string") : [];
|
|
1979
|
+
const owners = [...new Set([...previousAllowlist, ...options.owners])];
|
|
1980
|
+
const config = {
|
|
1981
|
+
...base,
|
|
1982
|
+
credentials: { ...previousCredentials, ...credentials },
|
|
1983
|
+
...owners.length > 0 ? { mentionAllowlist: owners } : {}
|
|
1984
|
+
};
|
|
1985
|
+
await writePrivateFile(configPath, `${JSON.stringify(config, null, "\t")}
|
|
1986
|
+
`);
|
|
1987
|
+
written.push(configPath);
|
|
1988
|
+
return { written, owners };
|
|
1989
|
+
}
|
|
1990
|
+
async function enabledAdapters(home) {
|
|
1991
|
+
const enabled = [];
|
|
1992
|
+
for (const platform of SETUP_PLATFORMS) {
|
|
1993
|
+
if (await Bun.file(join7(home, `adapter-${platform}.json`)).exists())
|
|
1994
|
+
enabled.push(platform);
|
|
1995
|
+
}
|
|
1996
|
+
return enabled;
|
|
1997
|
+
}
|
|
1998
|
+
async function askYes(prompter, question) {
|
|
1999
|
+
for (;; ) {
|
|
2000
|
+
const answer = (await prompter.ask(`${question} [y/N] `)).trim().toLowerCase();
|
|
2001
|
+
if (answer === "" || answer === "n" || answer === "no")
|
|
2002
|
+
return false;
|
|
2003
|
+
if (answer === "y" || answer === "yes")
|
|
2004
|
+
return true;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
async function askRequiredSecret(prompter, question) {
|
|
2008
|
+
for (;; ) {
|
|
2009
|
+
const answer = (await prompter.askSecret(question)).trim();
|
|
2010
|
+
if (answer)
|
|
2011
|
+
return answer;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
async function tokensFromPrompts(prompter, parsed) {
|
|
2015
|
+
const wanted = parsed.adapters ?? [];
|
|
2016
|
+
const enable = async (platform, label) => parsed.adapters ? wanted.includes(platform) : await askYes(prompter, `Enable ${label}?`);
|
|
2017
|
+
const tokens = {};
|
|
2018
|
+
if (await enable("discord", "Discord")) {
|
|
2019
|
+
const token = await askRequiredSecret(prompter, "Discord bot token (input hidden): ");
|
|
2020
|
+
const appId = parsed.discordAppId ?? (await prompter.ask("Discord application id (optional): ")).trim();
|
|
2021
|
+
tokens.discord = { token, ...appId ? { appId } : {} };
|
|
2022
|
+
}
|
|
2023
|
+
if (await enable("slack", "Slack (Socket Mode)")) {
|
|
2024
|
+
const botToken = await askRequiredSecret(prompter, "Slack bot token xoxb-\u2026 (input hidden): ");
|
|
2025
|
+
const appToken = await askRequiredSecret(prompter, "Slack app-level token xapp-\u2026 (input hidden): ");
|
|
2026
|
+
tokens.slack = { botToken, appToken };
|
|
2027
|
+
}
|
|
2028
|
+
if (await enable("telegram", "Telegram")) {
|
|
2029
|
+
tokens.telegram = { token: await askRequiredSecret(prompter, "Telegram bot token (input hidden): ") };
|
|
2030
|
+
}
|
|
2031
|
+
if (!tokens.discord && !tokens.slack && !tokens.telegram)
|
|
2032
|
+
throw new SetupError("no adapter selected; nothing to do");
|
|
2033
|
+
const owners = parsed.owners.length > 0 ? [...parsed.owners] : splitList(await prompter.ask("Your platform user id(s), comma-separated, allowed to DM the bot (blank: Discord app owner only): "));
|
|
2034
|
+
return { tokens, owners };
|
|
2035
|
+
}
|
|
2036
|
+
function terminalPrompter() {
|
|
2037
|
+
const stdin = process.stdin;
|
|
2038
|
+
if (!stdin.isTTY)
|
|
2039
|
+
throw new SetupError("interactive setup needs a terminal; run it with a TTY (docker compose run -it \u2026) or use --from-env");
|
|
2040
|
+
const readLine = (question, echo) => new Promise((resolve, reject) => {
|
|
2041
|
+
process.stdout.write(question);
|
|
2042
|
+
let line = "";
|
|
2043
|
+
stdin.setRawMode(true);
|
|
2044
|
+
stdin.resume();
|
|
2045
|
+
const onData = (chunk) => {
|
|
2046
|
+
for (const char of chunk.toString("utf8")) {
|
|
2047
|
+
if (char === "\x03") {
|
|
2048
|
+
finish();
|
|
2049
|
+
process.stdout.write(`
|
|
2050
|
+
`);
|
|
2051
|
+
reject(new SetupError("setup cancelled"));
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (char === "\r" || char === `
|
|
2055
|
+
`) {
|
|
2056
|
+
finish();
|
|
2057
|
+
process.stdout.write(`
|
|
2058
|
+
`);
|
|
2059
|
+
resolve(line);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (char === "\x7F" || char === "\b") {
|
|
2063
|
+
if (line.length > 0) {
|
|
2064
|
+
line = line.slice(0, -1);
|
|
2065
|
+
if (echo)
|
|
2066
|
+
process.stdout.write("\b \b");
|
|
2067
|
+
}
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
line += char;
|
|
2071
|
+
if (echo)
|
|
2072
|
+
process.stdout.write(char);
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
const finish = () => {
|
|
2076
|
+
stdin.off("data", onData);
|
|
2077
|
+
stdin.setRawMode(false);
|
|
2078
|
+
stdin.pause();
|
|
2079
|
+
};
|
|
2080
|
+
stdin.on("data", onData);
|
|
2081
|
+
});
|
|
2082
|
+
return {
|
|
2083
|
+
ask: (question) => readLine(question, true),
|
|
2084
|
+
askSecret: (question) => readLine(question, false),
|
|
2085
|
+
close: () => stdin.pause()
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
async function runSetup(args, options) {
|
|
2089
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
2090
|
+
const parsed = parseSetupArgs(args);
|
|
2091
|
+
if (parsed.mode === "status") {
|
|
2092
|
+
log((await enabledAdapters(options.home)).join(","));
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
let tokens;
|
|
2096
|
+
let owners;
|
|
2097
|
+
if (parsed.fromEnv) {
|
|
2098
|
+
tokens = tokensFromEnv(options.env ?? process.env, parsed.adapters, parsed.discordAppId);
|
|
2099
|
+
owners = [...parsed.owners, ...splitList((options.env ?? process.env)[SETUP_ENV.ownerIds] ?? "")];
|
|
2100
|
+
} else {
|
|
2101
|
+
const prompter = (options.prompter ?? terminalPrompter)();
|
|
2102
|
+
try {
|
|
2103
|
+
({ tokens, owners } = await tokensFromPrompts(prompter, parsed));
|
|
2104
|
+
} finally {
|
|
2105
|
+
prompter.close();
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
const secrets = secretsOf(tokens);
|
|
2109
|
+
try {
|
|
2110
|
+
const identities = await validateAll(tokens, options.fetch ?? ((input, init) => fetch(input, init)));
|
|
2111
|
+
for (const identity of identities)
|
|
2112
|
+
log(`${identity.platform}: token valid \u2014 ${identity.summary}`);
|
|
2113
|
+
for (const identity of identities)
|
|
2114
|
+
if (identity.ownerId && owners.length === 0) {
|
|
2115
|
+
owners.push(identity.ownerId);
|
|
2116
|
+
log(`${identity.platform}: allowing DMs from the application owner ${identity.ownerId}`);
|
|
2117
|
+
}
|
|
2118
|
+
const applied = await applySetup({ home: options.home, tokens, owners });
|
|
2119
|
+
for (const path of applied.written)
|
|
2120
|
+
log(`wrote ${path}`);
|
|
2121
|
+
if (applied.owners.length === 0)
|
|
2122
|
+
log("warning: no owner id configured; the gateway declines every DM until config.json mentionAllowlist names you (rerun setup with --owner ID)");
|
|
2123
|
+
log(`enabled adapters: ${(await enabledAdapters(options.home)).join(",")}`);
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2126
|
+
throw new SetupError(redact(message, secrets));
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// src/update.ts
|
|
2131
|
+
import {
|
|
2132
|
+
chmod as chmod3,
|
|
2133
|
+
copyFile,
|
|
2134
|
+
mkdir as mkdir5,
|
|
2135
|
+
mkdtemp,
|
|
2136
|
+
open,
|
|
2137
|
+
readdir as readdir2,
|
|
2138
|
+
readFile as readFile6,
|
|
2139
|
+
realpath,
|
|
2140
|
+
rename as rename3,
|
|
2141
|
+
rm as rm2,
|
|
2142
|
+
stat as stat2,
|
|
2143
|
+
writeFile as writeFile5
|
|
2144
|
+
} from "fs/promises";
|
|
2145
|
+
import { tmpdir } from "os";
|
|
2146
|
+
import { basename, dirname, join as join8 } from "path";
|
|
2147
|
+
// package.json
|
|
2148
|
+
var package_default = {
|
|
2149
|
+
name: "@gajae-gateway/cli",
|
|
2150
|
+
version: "0.1.5",
|
|
2151
|
+
type: "module",
|
|
2152
|
+
description: "Owner CLI for the gajaeway gateway: status, sessions, chat, monitors, and worker lanes over the gateway socket. Requires the Bun runtime (uses Bun.file/Bun.spawn).",
|
|
2153
|
+
license: "AGPL-3.0-only",
|
|
2154
|
+
author: "Yeachan Heo",
|
|
2155
|
+
repository: {
|
|
2156
|
+
type: "git",
|
|
2157
|
+
url: "git+https://github.com/Yeachan-Heo/gajae-way.git",
|
|
2158
|
+
directory: "packages/cli"
|
|
2159
|
+
},
|
|
2160
|
+
homepage: "https://github.com/Yeachan-Heo/gajae-way/tree/main/packages/cli#readme",
|
|
2161
|
+
bugs: "https://github.com/Yeachan-Heo/gajae-way/issues",
|
|
2162
|
+
keywords: [
|
|
2163
|
+
"gajaeway",
|
|
2164
|
+
"cli",
|
|
2165
|
+
"gateway"
|
|
2166
|
+
],
|
|
2167
|
+
files: [
|
|
2168
|
+
"dist",
|
|
2169
|
+
"src"
|
|
2170
|
+
],
|
|
2171
|
+
main: "./dist/main.js",
|
|
2172
|
+
types: "./src/main.ts",
|
|
2173
|
+
exports: {
|
|
2174
|
+
".": {
|
|
2175
|
+
bun: "./src/main.ts",
|
|
2176
|
+
types: "./src/main.ts",
|
|
2177
|
+
default: "./dist/main.js"
|
|
2178
|
+
}
|
|
2179
|
+
},
|
|
2180
|
+
bin: {
|
|
2181
|
+
gajaeway: "./dist/main.js"
|
|
2182
|
+
},
|
|
2183
|
+
publishConfig: {
|
|
2184
|
+
access: "public"
|
|
2185
|
+
},
|
|
2186
|
+
scripts: {
|
|
2187
|
+
build: "rm -rf dist && bun build src/main.ts --outdir dist --target bun --format esm --external @gajae-gateway/protocol --external @gajae-gateway/sdk && tsc -p tsconfig.build.json",
|
|
2188
|
+
prepublishOnly: "bun run build"
|
|
2189
|
+
},
|
|
2190
|
+
dependencies: {
|
|
2191
|
+
"@gajae-gateway/sdk": "workspace:*",
|
|
2192
|
+
"@gajae-gateway/protocol": "workspace:*"
|
|
2193
|
+
},
|
|
2194
|
+
engines: {
|
|
2195
|
+
bun: ">=1.4.0"
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
// src/update.ts
|
|
2200
|
+
var UPDATE_USAGE = "usage: gajaeway update [--check] [--force] [--bin-dir DIR] [--no-restart]";
|
|
2201
|
+
var DEFAULT_RELEASE_REPO = "Yeachan-Heo/gajae-way";
|
|
2202
|
+
var RELEASE_REPO_OVERRIDE_ENV = "GAJAEWAY_UPDATE_REPO";
|
|
2203
|
+
function releaseRepo(env = process.env) {
|
|
2204
|
+
const override = env[RELEASE_REPO_OVERRIDE_ENV]?.trim();
|
|
2205
|
+
return override ? override : declaredReleaseRepo();
|
|
2206
|
+
}
|
|
2207
|
+
function declaredReleaseRepo() {
|
|
2208
|
+
const url = package_default.repository?.url;
|
|
2209
|
+
const match = typeof url === "string" ? /^git\+https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)\.git$/.exec(url) : undefined;
|
|
2210
|
+
return match ? match[1] : DEFAULT_RELEASE_REPO;
|
|
2211
|
+
}
|
|
2212
|
+
function parseUpdateArgs(args) {
|
|
2213
|
+
let check = false;
|
|
2214
|
+
let force = false;
|
|
2215
|
+
let noRestart = false;
|
|
2216
|
+
let binDir;
|
|
2217
|
+
for (let i = 0;i < args.length; i++) {
|
|
2218
|
+
const flag = args[i];
|
|
2219
|
+
if (flag === "--check")
|
|
2220
|
+
check = true;
|
|
2221
|
+
else if (flag === "--force")
|
|
2222
|
+
force = true;
|
|
2223
|
+
else if (flag === "--no-restart")
|
|
2224
|
+
noRestart = true;
|
|
2225
|
+
else if (flag === "--bin-dir") {
|
|
2226
|
+
const value = args[++i];
|
|
2227
|
+
if (value === undefined || value.length === 0 || value.startsWith("--"))
|
|
2228
|
+
throw new Error(`${flag} expects a non-empty DIR`);
|
|
2229
|
+
binDir = value;
|
|
2230
|
+
} else
|
|
2231
|
+
throw new Error(`${UPDATE_USAGE} (unknown option: ${flag})`);
|
|
2232
|
+
}
|
|
2233
|
+
return {
|
|
2234
|
+
check,
|
|
2235
|
+
force,
|
|
2236
|
+
noRestart,
|
|
2237
|
+
...binDir === undefined ? {} : { binDir }
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
function releaseVersion(tag) {
|
|
2241
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(tag.trim());
|
|
2242
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}` : undefined;
|
|
2243
|
+
}
|
|
2244
|
+
function releaseAssetName(platform = process.platform, arch = process.arch) {
|
|
2245
|
+
return `gajaeway-${platform}-${arch}.tar.gz`;
|
|
2246
|
+
}
|
|
2247
|
+
function selectReleaseAsset(release, name) {
|
|
2248
|
+
return release.assets.find((asset) => asset.name === name);
|
|
2249
|
+
}
|
|
2250
|
+
function isNewer(current, latest) {
|
|
2251
|
+
return Bun.semver.order(latest, current) > 0;
|
|
2252
|
+
}
|
|
2253
|
+
var KNOWN_BINARIES = [
|
|
2254
|
+
"gajaeway",
|
|
2255
|
+
"gajaeway-gateway",
|
|
2256
|
+
"gajaeway-admin",
|
|
2257
|
+
"gajaeway-slack",
|
|
2258
|
+
"gajaeway-discord",
|
|
2259
|
+
"gajaeway-telegram"
|
|
2260
|
+
];
|
|
2261
|
+
var CORE_BINARIES = ["gajaeway", "gajaeway-gateway"];
|
|
2262
|
+
var MIN_BINARY_BYTES = 1048576;
|
|
2263
|
+
var MAX_ASSET_BYTES = 512 * 1024 * 1024;
|
|
2264
|
+
var RELEASE_MARKER = ".gajaeway-release";
|
|
2265
|
+
var LOCK_FILE = ".gajaeway-update.lock";
|
|
2266
|
+
function releaseMarkerPath(binDir) {
|
|
2267
|
+
return join8(binDir, RELEASE_MARKER);
|
|
2268
|
+
}
|
|
2269
|
+
async function readInstalledRelease(binDir) {
|
|
2270
|
+
try {
|
|
2271
|
+
const version = releaseVersion(await readFile6(releaseMarkerPath(binDir), "utf8"));
|
|
2272
|
+
return version;
|
|
2273
|
+
} catch {
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
async function resolveBinDir(flag, execPath = process.execPath) {
|
|
2278
|
+
if (flag !== undefined)
|
|
2279
|
+
return flag;
|
|
2280
|
+
const real = await realpath(execPath).catch(() => {
|
|
2281
|
+
return;
|
|
2282
|
+
});
|
|
2283
|
+
if (real === undefined)
|
|
2284
|
+
return;
|
|
2285
|
+
return basename(real).startsWith("gajaeway") ? dirname(real) : undefined;
|
|
2286
|
+
}
|
|
2287
|
+
function renderUpdate(result) {
|
|
2288
|
+
const lines = [];
|
|
2289
|
+
if (result.upToDate) {
|
|
2290
|
+
lines.push(result.checkedOnly ? `up to date: ${result.current} (latest release ${result.tag})` : `already up to date: ${result.current} (latest release ${result.tag}); use --force to reinstall`);
|
|
2291
|
+
return lines;
|
|
2292
|
+
}
|
|
2293
|
+
if (result.checkedOnly) {
|
|
2294
|
+
lines.push(`update available: ${result.current} \u2192 ${result.latest} (release ${result.tag})`);
|
|
2295
|
+
return lines;
|
|
2296
|
+
}
|
|
2297
|
+
lines.push(`updated ${result.current} \u2192 ${result.latest} from ${result.repo} (${result.tag})`);
|
|
2298
|
+
lines.push(`installed: ${result.installed.join(", ")}`);
|
|
2299
|
+
if (result.backupDir !== undefined)
|
|
2300
|
+
lines.push(`backup: ${result.backupDir}`);
|
|
2301
|
+
if (result.restartQueued !== undefined)
|
|
2302
|
+
lines.push(`restart: queued ${result.restartQueued} \u2014 check with: gajaeway ops restart-stack --status`);
|
|
2303
|
+
else if (result.restartSkipped)
|
|
2304
|
+
lines.push("restart: skipped (--no-restart)");
|
|
2305
|
+
return lines;
|
|
2306
|
+
}
|
|
2307
|
+
async function runUpdate(options) {
|
|
2308
|
+
const deps = options.deps ?? {};
|
|
2309
|
+
const platform = deps.platform ?? process.platform;
|
|
2310
|
+
if (platform !== "darwin" && platform !== "linux")
|
|
2311
|
+
throw new Error(`update supports darwin and linux release assets, not ${platform}`);
|
|
2312
|
+
const repo = releaseRepo(deps.env ?? process.env);
|
|
2313
|
+
const release = await (deps.fetchLatestRelease ?? defaultFetchLatestRelease)(repo);
|
|
2314
|
+
const latest = releaseVersion(release.tag);
|
|
2315
|
+
if (latest === undefined)
|
|
2316
|
+
throw new Error(`latest release tag is not a version: ${release.tag}`);
|
|
2317
|
+
const binDir = await resolveBinDir(options.args.binDir, deps.execPath ?? process.execPath);
|
|
2318
|
+
const current = (binDir === undefined ? undefined : await readInstalledRelease(binDir)) ?? package_default.version;
|
|
2319
|
+
const upToDate = !isNewer(current, latest);
|
|
2320
|
+
if (upToDate && !options.args.force)
|
|
2321
|
+
return { current, latest, tag: release.tag, repo, checkedOnly: options.args.check, upToDate: true, installed: [] };
|
|
2322
|
+
const asset = selectReleaseAsset(release, releaseAssetName(platform, deps.arch ?? process.arch));
|
|
2323
|
+
if (asset === undefined)
|
|
2324
|
+
throw new Error(`release ${release.tag} has no asset ${releaseAssetName(platform, deps.arch ?? process.arch)} (assets: ${release.assets.map((candidate) => candidate.name).join(", ") || "none"})`);
|
|
2325
|
+
if (options.args.check)
|
|
2326
|
+
return { current, latest, tag: release.tag, repo, checkedOnly: true, upToDate: false, installed: [] };
|
|
2327
|
+
if (binDir === undefined)
|
|
2328
|
+
throw new Error("run the update from a compiled gajaeway binary, or pass --bin-dir DIR");
|
|
2329
|
+
const unlock = await acquireUpdateLock(binDir);
|
|
2330
|
+
try {
|
|
2331
|
+
const staged = await stageRelease(asset, deps);
|
|
2332
|
+
const installedNames = await knownBinariesIn(binDir);
|
|
2333
|
+
const toInstall = binariesToInstall(staged.names, installedNames);
|
|
2334
|
+
const missingCore = CORE_BINARIES.filter((name) => !toInstall.includes(name));
|
|
2335
|
+
if (missingCore.length > 0)
|
|
2336
|
+
throw new Error(`release tarball is missing core binaries: ${missingCore.join(", ")}`);
|
|
2337
|
+
await assertStagedBinariesUsable(staged, toInstall);
|
|
2338
|
+
const stamp = new Date((deps.now ?? Date.now)()).toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
2339
|
+
const backupDir = join8(binDir, `backup-${current}-${stamp}Z`);
|
|
2340
|
+
await mkdir5(backupDir, { recursive: false });
|
|
2341
|
+
for (const name of toInstall) {
|
|
2342
|
+
const target = join8(binDir, name);
|
|
2343
|
+
if (await exists(target))
|
|
2344
|
+
await copyFile(target, join8(backupDir, name));
|
|
2345
|
+
}
|
|
2346
|
+
for (const name of toInstall) {
|
|
2347
|
+
const stagedPath = join8(staged.dir, name);
|
|
2348
|
+
const tempPath = join8(binDir, `.update-${name}`);
|
|
2349
|
+
await copyFile(stagedPath, tempPath);
|
|
2350
|
+
await chmod3(tempPath, 493);
|
|
2351
|
+
await rename3(tempPath, join8(binDir, name));
|
|
2352
|
+
}
|
|
2353
|
+
await writeFile5(releaseMarkerPath(binDir), `${latest}
|
|
2354
|
+
`, "utf8");
|
|
2355
|
+
await rm2(staged.dir, { recursive: true, force: true });
|
|
2356
|
+
if (options.args.noRestart)
|
|
2357
|
+
return {
|
|
2358
|
+
current,
|
|
2359
|
+
latest,
|
|
2360
|
+
tag: release.tag,
|
|
2361
|
+
repo,
|
|
2362
|
+
checkedOnly: false,
|
|
2363
|
+
upToDate: false,
|
|
2364
|
+
installed: toInstall,
|
|
2365
|
+
backupDir,
|
|
2366
|
+
restartSkipped: true
|
|
2367
|
+
};
|
|
2368
|
+
const receipt = await (deps.queueRestart ?? defaultQueueRestart)(options.home);
|
|
2369
|
+
return {
|
|
2370
|
+
current,
|
|
2371
|
+
latest,
|
|
2372
|
+
tag: release.tag,
|
|
2373
|
+
repo,
|
|
2374
|
+
checkedOnly: false,
|
|
2375
|
+
upToDate: false,
|
|
2376
|
+
installed: toInstall,
|
|
2377
|
+
backupDir,
|
|
2378
|
+
restartQueued: receipt.receiptId
|
|
2379
|
+
};
|
|
2380
|
+
} finally {
|
|
2381
|
+
await unlock();
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
function binariesToInstall(stagedNames, installedNames) {
|
|
2385
|
+
const installed = new Set(installedNames);
|
|
2386
|
+
return KNOWN_BINARIES.filter((name) => stagedNames.includes(name) && (installed.has(name) || CORE_BINARIES.includes(name)));
|
|
2387
|
+
}
|
|
2388
|
+
async function knownBinariesIn(dir) {
|
|
2389
|
+
const entries2 = await readdir2(dir).catch(() => []);
|
|
2390
|
+
const names = new Set(entries2);
|
|
2391
|
+
return KNOWN_BINARIES.filter((name) => names.has(name));
|
|
2392
|
+
}
|
|
2393
|
+
async function assertStagedBinariesUsable(staged, toInstall) {
|
|
2394
|
+
for (const name of toInstall) {
|
|
2395
|
+
const info = await stat2(join8(staged.dir, name));
|
|
2396
|
+
if (!info.isFile())
|
|
2397
|
+
throw new Error(`staged ${name} is not a regular file`);
|
|
2398
|
+
if (info.size < MIN_BINARY_BYTES)
|
|
2399
|
+
throw new Error(`staged ${name} is only ${info.size} bytes; refusing a broken extraction`);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
async function exists(path) {
|
|
2403
|
+
try {
|
|
2404
|
+
await stat2(path);
|
|
2405
|
+
return true;
|
|
2406
|
+
} catch {
|
|
2407
|
+
return false;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
async function stageRelease(asset, deps) {
|
|
2411
|
+
const dir = await mkdtemp(join8(tmpdir(), "gajaeway-update-"));
|
|
2412
|
+
const tarball = await (deps.downloadAsset ?? defaultDownloadAsset)(asset);
|
|
2413
|
+
await (deps.extractTarball ?? defaultExtractTarball)(tarball, dir);
|
|
2414
|
+
const names = (await readdir2(dir)).filter((name) => KNOWN_BINARIES.includes(name));
|
|
2415
|
+
return { dir, names };
|
|
2416
|
+
}
|
|
2417
|
+
async function defaultFetchLatestRelease(repo) {
|
|
2418
|
+
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
|
2419
|
+
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }
|
|
2420
|
+
});
|
|
2421
|
+
if (!response.ok)
|
|
2422
|
+
throw new Error(`release lookup failed: HTTP ${response.status} for ${repo}/releases/latest`);
|
|
2423
|
+
const body = await response.json();
|
|
2424
|
+
const tag = typeof body.tag_name === "string" ? body.tag_name : undefined;
|
|
2425
|
+
if (tag === undefined)
|
|
2426
|
+
throw new Error(`latest release of ${repo} has no tag_name`);
|
|
2427
|
+
const assets = (body.assets ?? []).flatMap((asset) => typeof asset.name === "string" && typeof asset.browser_download_url === "string" ? [{ name: asset.name, url: asset.browser_download_url }] : []);
|
|
2428
|
+
return { tag, assets };
|
|
2429
|
+
}
|
|
2430
|
+
async function defaultDownloadAsset(asset) {
|
|
2431
|
+
const response = await fetch(asset.url);
|
|
2432
|
+
if (!response.ok)
|
|
2433
|
+
throw new Error(`download failed: HTTP ${response.status} for ${asset.name}`);
|
|
2434
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
2435
|
+
if (bytes.byteLength > MAX_ASSET_BYTES)
|
|
2436
|
+
throw new Error(`${asset.name} is ${bytes.byteLength} bytes; refusing anything over ${MAX_ASSET_BYTES}`);
|
|
2437
|
+
return bytes;
|
|
2438
|
+
}
|
|
2439
|
+
async function defaultExtractTarball(tarball, intoDir) {
|
|
2440
|
+
const tarballPath = join8(intoDir, "release.tar.gz");
|
|
2441
|
+
await writeFile5(tarballPath, tarball);
|
|
2442
|
+
const process2 = Bun.spawn(["tar", "-xzf", tarballPath, "-C", intoDir], {
|
|
2443
|
+
stdout: "ignore",
|
|
2444
|
+
stderr: "pipe"
|
|
2445
|
+
});
|
|
2446
|
+
const exitCode = await process2.exited;
|
|
2447
|
+
if (exitCode !== 0) {
|
|
2448
|
+
const detail = await new Response(process2.stderr).text();
|
|
2449
|
+
throw new Error(`extracting ${basename(tarballPath)} failed (tar exit ${exitCode}): ${detail.trim()}`);
|
|
2450
|
+
}
|
|
2451
|
+
await rm2(tarballPath, { force: true });
|
|
2452
|
+
}
|
|
2453
|
+
async function defaultQueueRestart(home) {
|
|
2454
|
+
const { receipt, supervisorPid } = await launchRestartStack({ home });
|
|
2455
|
+
return { receiptId: receipt.id, supervisorPid };
|
|
2456
|
+
}
|
|
2457
|
+
async function acquireUpdateLock(binDir) {
|
|
2458
|
+
const lockPath = join8(binDir, LOCK_FILE);
|
|
2459
|
+
for (let attempt = 0;attempt < 2; attempt++) {
|
|
2460
|
+
try {
|
|
2461
|
+
const handle = await open(lockPath, "wx");
|
|
2462
|
+
try {
|
|
2463
|
+
await handle.write(`${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}
|
|
2464
|
+
`);
|
|
2465
|
+
} finally {
|
|
2466
|
+
await handle.close();
|
|
2467
|
+
}
|
|
2468
|
+
return async () => {
|
|
2469
|
+
await rm2(lockPath, { force: true });
|
|
2470
|
+
};
|
|
2471
|
+
} catch (error) {
|
|
2472
|
+
if (!isFileExistsError(error))
|
|
2473
|
+
throw error;
|
|
2474
|
+
const holder = await readLockHolder(lockPath);
|
|
2475
|
+
if (holder !== undefined && isProcessAlive(holder)) {
|
|
2476
|
+
throw new Error(`another gajaeway update is running (pid ${holder}); refusing to run concurrently`);
|
|
2477
|
+
}
|
|
2478
|
+
await rm2(lockPath, { force: true });
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
throw new Error(`could not acquire the update lock at ${lockPath}`);
|
|
2482
|
+
}
|
|
2483
|
+
function isFileExistsError(error) {
|
|
2484
|
+
return error !== null && typeof error === "object" && error.code === "EEXIST";
|
|
2485
|
+
}
|
|
2486
|
+
async function readLockHolder(lockPath) {
|
|
2487
|
+
try {
|
|
2488
|
+
const parsed = JSON.parse(await readFile6(lockPath, "utf8"));
|
|
2489
|
+
return typeof parsed.pid === "number" ? parsed.pid : undefined;
|
|
2490
|
+
} catch {
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
function isProcessAlive(pid) {
|
|
2495
|
+
try {
|
|
2496
|
+
process.kill(pid, 0);
|
|
2497
|
+
return true;
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
return error.code === "EPERM";
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// src/upgrade.ts
|
|
2504
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
2505
|
+
import { join as join9 } from "path";
|
|
2506
|
+
async function readGatewayPinnedGjcVersion(home) {
|
|
2507
|
+
try {
|
|
2508
|
+
const packageJsonPath = join9(home, "..", "..", "packages", "gateway", "package.json");
|
|
2509
|
+
const content = await readFile7(packageJsonPath, "utf-8");
|
|
2510
|
+
const pkg = JSON.parse(content);
|
|
2511
|
+
const version = pkg.gjc?.version;
|
|
2512
|
+
if (!version || typeof version !== "string") {
|
|
2513
|
+
throw new Error("gjc.version not found in gateway package.json");
|
|
2514
|
+
}
|
|
2515
|
+
return version;
|
|
2516
|
+
} catch (error) {
|
|
2517
|
+
throw new Error(`Failed to read pinned GJC version: ${error instanceof Error ? error.message : String(error)}`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
async function checkLatestCiStatus(repo) {
|
|
2521
|
+
try {
|
|
2522
|
+
const branchProc = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
2523
|
+
stdout: "pipe",
|
|
2524
|
+
stderr: "pipe"
|
|
2525
|
+
});
|
|
2526
|
+
if (branchProc.exitCode !== 0) {
|
|
2527
|
+
throw new Error("Could not determine current git branch");
|
|
2528
|
+
}
|
|
2529
|
+
const branch = branchProc.stdout?.toString().trim();
|
|
2530
|
+
if (!branch) {
|
|
2531
|
+
throw new Error("Empty branch name");
|
|
2532
|
+
}
|
|
2533
|
+
const runProc = Bun.spawnSync(["gh", "run", "list", "-R", repo, "--branch", branch, "--limit", "1", "--json", "conclusion,status"], {
|
|
2534
|
+
stdout: "pipe",
|
|
2535
|
+
stderr: "pipe"
|
|
2536
|
+
});
|
|
2537
|
+
if (runProc.exitCode !== 0) {
|
|
2538
|
+
throw new Error(`gh run list failed: ${runProc.stderr?.toString()}`);
|
|
2539
|
+
}
|
|
2540
|
+
const output = runProc.stdout?.toString() || "[]";
|
|
2541
|
+
const runs = JSON.parse(output);
|
|
2542
|
+
if (!Array.isArray(runs) || runs.length === 0) {
|
|
2543
|
+
throw new Error("No CI runs found for this branch");
|
|
2544
|
+
}
|
|
2545
|
+
const latestRun = runs[0];
|
|
2546
|
+
if (!latestRun.conclusion || !latestRun.status) {
|
|
2547
|
+
throw new Error(`Latest CI run status is incomplete: ${JSON.stringify(latestRun)}`);
|
|
2548
|
+
}
|
|
2549
|
+
if (latestRun.status === "in_progress") {
|
|
2550
|
+
throw new Error("CI is currently running; wait for completion");
|
|
2551
|
+
}
|
|
2552
|
+
return latestRun.conclusion === "success";
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
throw new Error(`Failed to check CI status: ${error instanceof Error ? error.message : String(error)}`);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
async function performUpgrade(options) {
|
|
2558
|
+
const readVersion = options.readPinnedVersion || readGatewayPinnedGjcVersion;
|
|
2559
|
+
const targetVersion = options.gjcVersion ?? await readVersion(options.home);
|
|
2560
|
+
try {
|
|
2561
|
+
const checkCi = options.checkCiStatus || checkLatestCiStatus;
|
|
2562
|
+
let ciPassed = false;
|
|
2563
|
+
try {
|
|
2564
|
+
ciPassed = await checkCi("Yeachan-Heo/gajae-way");
|
|
2565
|
+
} catch (ciError) {
|
|
2566
|
+
const ciDetail = ciError instanceof Error ? ciError.message : String(ciError);
|
|
2567
|
+
return { status: "failed", detail: `CI status check failed: ${ciDetail}` };
|
|
2568
|
+
}
|
|
2569
|
+
if (!ciPassed) {
|
|
2570
|
+
return {
|
|
2571
|
+
status: "failed",
|
|
2572
|
+
detail: `Cannot upgrade: latest CI run did not pass. Fix failures and try again.`
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
const gjcProc = Bun.spawnSync(["gjc", "--version"], {
|
|
2576
|
+
stdout: "pipe",
|
|
2577
|
+
stderr: "pipe"
|
|
2578
|
+
});
|
|
2579
|
+
if (gjcProc.exitCode !== 0) {
|
|
2580
|
+
return { status: "failed", detail: "Failed to get current gjc version" };
|
|
2581
|
+
}
|
|
2582
|
+
const currentVersionMatch = ((gjcProc.stdout?.toString() || "") + (gjcProc.stderr?.toString() || "")).match(/(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/);
|
|
2583
|
+
if (!currentVersionMatch) {
|
|
2584
|
+
return { status: "failed", detail: "Could not parse current gjc version" };
|
|
2585
|
+
}
|
|
2586
|
+
const currentVersion = currentVersionMatch.slice(1, 4).join(".");
|
|
2587
|
+
if (currentVersion === targetVersion) {
|
|
2588
|
+
return { status: "ok", detail: `Already running gjc ${targetVersion}` };
|
|
2589
|
+
}
|
|
2590
|
+
const installProc = Bun.spawnSync(["bun", "add", "-g", `gajae-code@${targetVersion}`], {
|
|
2591
|
+
stdout: "pipe",
|
|
2592
|
+
stderr: "pipe",
|
|
2593
|
+
cwd: join9(options.home, "workspace")
|
|
2594
|
+
});
|
|
2595
|
+
if (installProc.exitCode !== 0) {
|
|
2596
|
+
return {
|
|
2597
|
+
status: "failed",
|
|
2598
|
+
detail: `Failed to install gjc ${targetVersion}: ${installProc.stderr?.toString()}`
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
const verifyProc = Bun.spawnSync(["gjc", "--version"], {
|
|
2602
|
+
stdout: "pipe",
|
|
2603
|
+
stderr: "pipe"
|
|
2604
|
+
});
|
|
2605
|
+
if (verifyProc.exitCode !== 0) {
|
|
2606
|
+
return { status: "failed", detail: "Failed to verify gjc installation" };
|
|
2607
|
+
}
|
|
2608
|
+
const verifiedVersionMatch = ((verifyProc.stdout?.toString() || "") + (verifyProc.stderr?.toString() || "")).match(/(?:gjc\/)?(\d+)\.(\d+)\.(\d+)/);
|
|
2609
|
+
if (!verifiedVersionMatch) {
|
|
2610
|
+
return { status: "failed", detail: "Could not verify installed gjc version" };
|
|
2611
|
+
}
|
|
2612
|
+
const verifiedVersion = verifiedVersionMatch.slice(1, 4).join(".");
|
|
2613
|
+
if (verifiedVersion !== targetVersion) {
|
|
2614
|
+
return {
|
|
2615
|
+
status: "failed",
|
|
2616
|
+
detail: `Verification failed: expected ${targetVersion}, got ${verifiedVersion}`
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
return { status: "ok", detail: `Successfully upgraded gjc from ${currentVersion} to ${targetVersion}` };
|
|
2620
|
+
} catch (error) {
|
|
2621
|
+
return {
|
|
2622
|
+
status: "failed",
|
|
2623
|
+
detail: `Upgrade failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
|
|
660
2628
|
// src/main.ts
|
|
661
2629
|
function socketPath(home = process.env.GAJAEWAY_HOME) {
|
|
662
2630
|
return `${home ?? `${process.env.HOME ?? "~"}/.gajaeway`}/gateway.sock`;
|
|
@@ -671,9 +2639,12 @@ var COMMANDS = [
|
|
|
671
2639
|
"memory",
|
|
672
2640
|
"monitors",
|
|
673
2641
|
"work",
|
|
674
|
-
"services"
|
|
2642
|
+
"services",
|
|
2643
|
+
"migrate",
|
|
2644
|
+
"update",
|
|
2645
|
+
"setup"
|
|
675
2646
|
];
|
|
676
|
-
var CLI_USAGE = "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] <text>|work status <name>|work steer <name> <text>|work retire <name>|work jobs|ops backup <path>|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restore <backupPath>|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux] (work run waits for a response; caller timeout does not end the attempt)";
|
|
2647
|
+
var CLI_USAGE = "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] <text>|work status <name>|work steer <name> <text>|work retire [--force] <name>|work retire --all-dead|work jobs|ops backup <path>|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restore <backupPath>|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]|migrate [--source PATH] [--target PATH] [--dry-run]|update [--check] [--force] [--bin-dir DIR] [--no-restart]|setup [--from-env] [--adapters discord,slack,telegram] [--owner ID] [--discord-app-id ID]|setup --status (work run waits for a response; caller timeout does not end the attempt); cron timezone is an IANA zone and defaults to the gateway host's local timezone";
|
|
677
2648
|
var USAGE_EXIT_CODE = 2;
|
|
678
2649
|
function usageFor(command) {
|
|
679
2650
|
return command !== undefined && COMMANDS.includes(command) ? undefined : CLI_USAGE;
|
|
@@ -922,18 +2893,18 @@ function verifyBackupIntegrity(backupPath) {
|
|
|
922
2893
|
throw new Error(`Backup failed SQLite integrity_check: ${backupPath} (${detail})`);
|
|
923
2894
|
}
|
|
924
2895
|
async function restoreTargetPath(home = gatewayHome()) {
|
|
925
|
-
const configPath =
|
|
2896
|
+
const configPath = join10(home, "config.json");
|
|
926
2897
|
let raw;
|
|
927
2898
|
try {
|
|
928
|
-
raw = await
|
|
2899
|
+
raw = await readFile8(configPath, "utf8");
|
|
929
2900
|
} catch (error) {
|
|
930
2901
|
const code = error.code;
|
|
931
|
-
const entryExists = await
|
|
2902
|
+
const entryExists = await lstat2(configPath).then(() => true, () => false);
|
|
932
2903
|
if (code !== "ENOENT" || entryExists)
|
|
933
2904
|
throw new Error(`Cannot read ${configPath}; refusing restore.`);
|
|
934
2905
|
}
|
|
935
2906
|
if (raw === undefined)
|
|
936
|
-
return
|
|
2907
|
+
return join10(home, "gateway.db");
|
|
937
2908
|
let parsed;
|
|
938
2909
|
try {
|
|
939
2910
|
parsed = JSON.parse(raw);
|
|
@@ -944,7 +2915,7 @@ async function restoreTargetPath(home = gatewayHome()) {
|
|
|
944
2915
|
throw new Error(`Invalid configuration ${configPath}: config must be an object; refusing restore.`);
|
|
945
2916
|
const dbPath = parsed.dbPath;
|
|
946
2917
|
if (dbPath === undefined)
|
|
947
|
-
return
|
|
2918
|
+
return join10(home, "gateway.db");
|
|
948
2919
|
if (typeof dbPath !== "string" || dbPath.length === 0)
|
|
949
2920
|
throw new Error(`Invalid configuration ${configPath}: dbPath must be a non-empty string; refusing restore.`);
|
|
950
2921
|
return dbPath;
|
|
@@ -953,7 +2924,7 @@ async function restoreDatabase(socket, backupPath) {
|
|
|
953
2924
|
if (!isAbsolute(backupPath))
|
|
954
2925
|
throw new Error("ops restore requires an absolute backup path");
|
|
955
2926
|
try {
|
|
956
|
-
await
|
|
2927
|
+
await stat3(socket);
|
|
957
2928
|
throw new Error(`Refusing restore: gateway socket ${socket} exists. Stop the daemon before restoring.`);
|
|
958
2929
|
} catch (error) {
|
|
959
2930
|
if (error instanceof Error && error.message.startsWith("Refusing restore:"))
|
|
@@ -964,8 +2935,8 @@ async function restoreDatabase(socket, backupPath) {
|
|
|
964
2935
|
verifyBackupIntegrity(backupPath);
|
|
965
2936
|
const databasePath = await restoreTargetPath();
|
|
966
2937
|
const preservedPath = `${databasePath}.pre-restore-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
967
|
-
await
|
|
968
|
-
await
|
|
2938
|
+
await copyFile2(databasePath, preservedPath);
|
|
2939
|
+
await copyFile2(backupPath, databasePath);
|
|
969
2940
|
console.log(`Validated SQLite backup: ${backupPath}`);
|
|
970
2941
|
console.log(`Copied current database to: ${preservedPath}`);
|
|
971
2942
|
console.log(`Restored backup to: ${databasePath}`);
|
|
@@ -1102,7 +3073,26 @@ async function main(args = process.argv.slice(2), options = {}) {
|
|
|
1102
3073
|
break;
|
|
1103
3074
|
}
|
|
1104
3075
|
case "ops": {
|
|
1105
|
-
const [command, path] = parsed.rest;
|
|
3076
|
+
const [command, path, ...extraArgs] = parsed.rest;
|
|
3077
|
+
if (command === "upgrade") {
|
|
3078
|
+
const home = gatewayHome();
|
|
3079
|
+
let gjcVersion;
|
|
3080
|
+
if (path === "--gjc" && extraArgs[0]) {
|
|
3081
|
+
gjcVersion = extraArgs[0];
|
|
3082
|
+
} else if (path && path !== "--gjc") {
|
|
3083
|
+
throw new Error("usage: gajaeway ops upgrade [--gjc X.Y.Z]");
|
|
3084
|
+
}
|
|
3085
|
+
const result = await performUpgrade({ home, gjcVersion });
|
|
3086
|
+
console.log(result.detail);
|
|
3087
|
+
if (result.status !== "ok") {
|
|
3088
|
+
process.exitCode = 1;
|
|
3089
|
+
break;
|
|
3090
|
+
}
|
|
3091
|
+
const { receipt, supervisorPid } = await launchRestartStack({ home });
|
|
3092
|
+
console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
|
|
3093
|
+
console.log("read the outcome with: gajaeway ops restart-stack --status");
|
|
3094
|
+
break;
|
|
3095
|
+
}
|
|
1106
3096
|
if (command === "restore" && path) {
|
|
1107
3097
|
await restoreDatabase(parsed.socket, path);
|
|
1108
3098
|
break;
|
|
@@ -1165,7 +3155,7 @@ async function main(args = process.argv.slice(2), options = {}) {
|
|
|
1165
3155
|
console.log(line);
|
|
1166
3156
|
process.exitCode = cycleExitCode(cycle);
|
|
1167
3157
|
} else
|
|
1168
|
-
throw new Error("usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|restart-stack [--status]");
|
|
3158
|
+
throw new Error("usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|upgrade [--gjc X.Y.Z]|restart-stack [--status]");
|
|
1169
3159
|
} finally {
|
|
1170
3160
|
await client.close();
|
|
1171
3161
|
}
|
|
@@ -1207,9 +3197,14 @@ async function main(args = process.argv.slice(2), options = {}) {
|
|
|
1207
3197
|
else if (command === "list") {
|
|
1208
3198
|
const options2 = listOptions;
|
|
1209
3199
|
const result = await client.request("monitor.list");
|
|
1210
|
-
|
|
3200
|
+
const rows = result.monitors.map((monitor) => ({
|
|
3201
|
+
monitor,
|
|
3202
|
+
schedule: result.schedules[monitor.monitorId] ?? null
|
|
3203
|
+
}));
|
|
3204
|
+
for (const line of renderList(MONITOR_COLUMNS, rows, options2, {
|
|
1211
3205
|
key: "monitors",
|
|
1212
|
-
result
|
|
3206
|
+
result,
|
|
3207
|
+
serializeRow: (row) => row.monitor
|
|
1213
3208
|
}))
|
|
1214
3209
|
console.log(line);
|
|
1215
3210
|
} else if (command === "inspect" && args2[0])
|
|
@@ -1259,20 +3254,55 @@ async function main(args = process.argv.slice(2), options = {}) {
|
|
|
1259
3254
|
break;
|
|
1260
3255
|
}
|
|
1261
3256
|
if (command === "retire" || command === "jobs") {
|
|
1262
|
-
|
|
3257
|
+
let force = false;
|
|
3258
|
+
let allDead = false;
|
|
3259
|
+
let name2;
|
|
3260
|
+
if (command === "retire") {
|
|
3261
|
+
for (let i = 0;i < args2.length; i++) {
|
|
3262
|
+
const arg = args2[i];
|
|
3263
|
+
if (arg === "--force") {
|
|
3264
|
+
if (force)
|
|
3265
|
+
throw new Error(usage2);
|
|
3266
|
+
force = true;
|
|
3267
|
+
} else if (arg === "--all-dead") {
|
|
3268
|
+
if (allDead)
|
|
3269
|
+
throw new Error(usage2);
|
|
3270
|
+
allDead = true;
|
|
3271
|
+
} else if (!arg.startsWith("--")) {
|
|
3272
|
+
if (name2)
|
|
3273
|
+
throw new Error(usage2);
|
|
3274
|
+
name2 = arg;
|
|
3275
|
+
} else
|
|
3276
|
+
throw new Error(usage2);
|
|
3277
|
+
}
|
|
3278
|
+
if (!allDead && !name2)
|
|
3279
|
+
throw new Error(usage2);
|
|
3280
|
+
if (allDead && name2)
|
|
3281
|
+
throw new Error(usage2);
|
|
3282
|
+
} else if (args2.length !== 0) {
|
|
1263
3283
|
throw new Error(usage2);
|
|
3284
|
+
}
|
|
1264
3285
|
const client2 = await GajaewayClient.connectSocket(parsed.socket);
|
|
1265
3286
|
try {
|
|
1266
3287
|
if (command === "retire") {
|
|
1267
|
-
const result = await client2.request("work.retire", {
|
|
1268
|
-
|
|
3288
|
+
const result = await client2.request("work.retire", {
|
|
3289
|
+
...name2 !== undefined ? { name: name2 } : {},
|
|
3290
|
+
...force ? { force } : {},
|
|
3291
|
+
...allDead ? { allDead } : {}
|
|
3292
|
+
});
|
|
3293
|
+
if (allDead) {
|
|
3294
|
+
console.log(`Retired all dead lanes`);
|
|
3295
|
+
} else {
|
|
3296
|
+
const forced = result.retired && result.forced ? " (forced)" : "";
|
|
3297
|
+
console.log(result.retired ? `retired: ${result.sessionKey} session=${result.sessionId} closed=${result.closed}${forced}` : `not retired: ${result.reason}`);
|
|
3298
|
+
}
|
|
1269
3299
|
} else {
|
|
1270
3300
|
const result = await client2.request("work.jobs");
|
|
1271
3301
|
for (const job of result.jobs) {
|
|
1272
|
-
const
|
|
3302
|
+
const name3 = job.lane_key.startsWith("work-") ? job.lane_key.slice("work-".length) : job.lane_key;
|
|
1273
3303
|
const state = job.quarantined ? `HELD: quarantined reason=${job.reason} historical_state=${job.state}` : job.state;
|
|
1274
3304
|
const head = job.last_commit ? `${job.last_commit.sha.slice(0, 7)}@${job.last_commit.committed_at} ${JSON.stringify(job.last_commit.subject)}` : "-";
|
|
1275
|
-
console.log(`${
|
|
3305
|
+
console.log(`${name3} ${state} session=${job.session_id || "-"} accepted=${job.accepted_at || "-"} op=${job.attempt?.op_ref || "-"} last=${job.last_activity_at || "-"} head=${head} ${job.worktree_path}${job.reports && (job.reports.pending !== 0 || job.reports.claimed !== 0 || job.reports.held !== 0 || job.reports.undeliverable !== 0) ? ` reports=p:${job.reports.pending} c:${job.reports.claimed} h:${job.reports.held} u:${job.reports.undeliverable}` : ""}`);
|
|
1276
3306
|
}
|
|
1277
3307
|
}
|
|
1278
3308
|
} finally {
|
|
@@ -1364,6 +3394,24 @@ job: ${result.jobId} state: ${result.state}`);
|
|
|
1364
3394
|
console.log(definition);
|
|
1365
3395
|
break;
|
|
1366
3396
|
}
|
|
3397
|
+
case "migrate":
|
|
3398
|
+
await migrate(parseMigrateArgs(parsed.rest));
|
|
3399
|
+
break;
|
|
3400
|
+
case "update": {
|
|
3401
|
+
const update = parseUpdateArgs(parsed.rest);
|
|
3402
|
+
const result = await runUpdate({
|
|
3403
|
+
args: update,
|
|
3404
|
+
home: gatewayHome(),
|
|
3405
|
+
...options.update === undefined ? {} : { deps: options.update }
|
|
3406
|
+
});
|
|
3407
|
+
for (const line of renderUpdate(result))
|
|
3408
|
+
console.log(line);
|
|
3409
|
+
break;
|
|
3410
|
+
}
|
|
3411
|
+
case "setup": {
|
|
3412
|
+
await runSetup(parsed.rest, { home: gatewayHome(), ...options.setup });
|
|
3413
|
+
break;
|
|
3414
|
+
}
|
|
1367
3415
|
default:
|
|
1368
3416
|
throw new Error(CLI_USAGE);
|
|
1369
3417
|
}
|