@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read-only access to a source tree. Every read is recorded, so whatever the
|
|
7
|
+
* readers did not consume can be reported as unmapped instead of vanishing.
|
|
8
|
+
* Nothing here opens a file for writing.
|
|
9
|
+
*/
|
|
10
|
+
export class SourceTree {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly #consumed = new Set<string>();
|
|
13
|
+
|
|
14
|
+
constructor(root: string) {
|
|
15
|
+
this.root = root;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Relative display path for a source file, `~`-free and stable across hosts. */
|
|
19
|
+
rel(path: string): string {
|
|
20
|
+
const inside = relative(this.root, path);
|
|
21
|
+
return inside.startsWith("..") ? path : inside || ".";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
consume(path: string): void {
|
|
25
|
+
this.#consumed.add(path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
isConsumed(path: string): boolean {
|
|
29
|
+
for (const consumed of this.#consumed)
|
|
30
|
+
if (path === consumed || path.startsWith(`${consumed}/`) || consumed.startsWith(`${path}/`)) return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Consumed exactly, or entirely beneath a consumed directory. */
|
|
35
|
+
isFullyConsumed(path: string): boolean {
|
|
36
|
+
for (const consumed of this.#consumed) if (path === consumed || path.startsWith(`${consumed}/`)) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async text(path: string): Promise<string | undefined> {
|
|
41
|
+
const info = await regularFile(path);
|
|
42
|
+
if (!info) return undefined;
|
|
43
|
+
this.consume(path);
|
|
44
|
+
return readFile(path, "utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async bytes(path: string): Promise<Uint8Array | undefined> {
|
|
48
|
+
const info = await regularFile(path);
|
|
49
|
+
if (!info) return undefined;
|
|
50
|
+
this.consume(path);
|
|
51
|
+
return new Uint8Array(await readFile(path));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stat without following symlinks; only a plain regular file qualifies. */
|
|
56
|
+
export async function regularFile(path: string): Promise<{ mtime: Date } | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const info = await lstat(path);
|
|
59
|
+
return info.isFile() ? { mtime: info.mtime } : undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function isDirectory(path: string): Promise<boolean> {
|
|
66
|
+
try {
|
|
67
|
+
return (await lstat(path)).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function entries(path: string): Promise<string[]> {
|
|
74
|
+
try {
|
|
75
|
+
return (await readdir(path)).sort();
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every regular file under `dir`, relative to it, skipping symlinks (they may point anywhere). */
|
|
82
|
+
export async function walkFiles(dir: string, prefix = ""): Promise<string[]> {
|
|
83
|
+
const out: string[] = [];
|
|
84
|
+
for (const name of await entries(dir)) {
|
|
85
|
+
const full = join(dir, name);
|
|
86
|
+
const info = await lstat(full).catch(() => undefined);
|
|
87
|
+
if (!info) continue;
|
|
88
|
+
if (info.isDirectory()) out.push(...(await walkFiles(full, join(prefix, name))));
|
|
89
|
+
else if (info.isFile()) out.push(join(prefix, name));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Minimal dotenv reader: KEY=VALUE lines, optional `export`, single/double quotes, `#` comments. */
|
|
95
|
+
export function parseDotenv(text: string): Map<string, string> {
|
|
96
|
+
const values = new Map<string, string>();
|
|
97
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
98
|
+
const line = rawLine.trim();
|
|
99
|
+
if (!line || line.startsWith("#")) continue;
|
|
100
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
101
|
+
if (!match) continue;
|
|
102
|
+
let value = match[2] ?? "";
|
|
103
|
+
const quote = value[0];
|
|
104
|
+
if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0)
|
|
105
|
+
value = value.slice(1, value.lastIndexOf(quote));
|
|
106
|
+
else value = value.replace(/\s+#.*$/, "").trim();
|
|
107
|
+
values.set(match[1] as string, value);
|
|
108
|
+
}
|
|
109
|
+
return values;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function expandHome(path: string): string {
|
|
113
|
+
return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
117
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A comma-separated string or a list, as both Hermes and OpenClaw accept. */
|
|
121
|
+
export function stringList(value: unknown): string[] {
|
|
122
|
+
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
123
|
+
return items.map((item) => String(item).trim()).filter(Boolean);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const DATED_FILE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { detectHermes, readHermes } from "./migrate/hermes";
|
|
4
|
+
import { detectOpenClaw, readOpenClaw } from "./migrate/openclaw";
|
|
5
|
+
import {
|
|
6
|
+
emitChannels,
|
|
7
|
+
emitImportedAxis,
|
|
8
|
+
emitMonitors,
|
|
9
|
+
emptyChannelPlan,
|
|
10
|
+
type MigrationSource,
|
|
11
|
+
type SourceExtract,
|
|
12
|
+
} from "./migrate/plan";
|
|
13
|
+
import { expandHome, SourceTree } from "./migrate/source-fs";
|
|
14
|
+
|
|
15
|
+
export interface MigrateOptions {
|
|
16
|
+
source?: string;
|
|
17
|
+
target?: string;
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Main migration entry point. Detects the source type (Hermes or OpenClaw),
|
|
23
|
+
* reads its configuration, and writes the gajae-way-compatible output.
|
|
24
|
+
*/
|
|
25
|
+
export async function migrate(options: MigrateOptions): Promise<void> {
|
|
26
|
+
const sourceRoot = expandHome(options.source ?? ".");
|
|
27
|
+
const targetHome = expandHome(options.target ?? `${process.env.HOME ?? "~"}/.gajaeway`);
|
|
28
|
+
|
|
29
|
+
// Detect source type
|
|
30
|
+
let sourceType: MigrationSource | undefined;
|
|
31
|
+
if (await detectHermes(sourceRoot)) {
|
|
32
|
+
sourceType = "hermes";
|
|
33
|
+
} else if (await detectOpenClaw(sourceRoot)) {
|
|
34
|
+
sourceType = "openclaw";
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`No Hermes or OpenClaw configuration detected at ${sourceRoot}. Expected .hermes.config.json or config.json`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`Detected ${sourceType} configuration at ${sourceRoot}`);
|
|
42
|
+
|
|
43
|
+
// Create source tree for tracking consumed files
|
|
44
|
+
const tree = new SourceTree(sourceRoot);
|
|
45
|
+
|
|
46
|
+
// Read source configuration
|
|
47
|
+
let extract: SourceExtract;
|
|
48
|
+
if (sourceType === "hermes") {
|
|
49
|
+
extract = await readHermes(tree, targetHome);
|
|
50
|
+
} else {
|
|
51
|
+
extract = await readOpenClaw(tree, targetHome);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Report warnings
|
|
55
|
+
for (const warning of extract.warnings) {
|
|
56
|
+
console.warn(`Warning: ${warning}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Report unmapped entries
|
|
60
|
+
if (extract.unmapped.length > 0) {
|
|
61
|
+
console.log("\nUnmapped entries (these will not be migrated):");
|
|
62
|
+
const byCategory = new Map<string, typeof extract.unmapped>();
|
|
63
|
+
for (const entry of extract.unmapped) {
|
|
64
|
+
if (!byCategory.has(entry.category)) {
|
|
65
|
+
byCategory.set(entry.category, []);
|
|
66
|
+
}
|
|
67
|
+
const cat = byCategory.get(entry.category);
|
|
68
|
+
if (cat) {
|
|
69
|
+
cat.push(entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [category, entries] of byCategory) {
|
|
74
|
+
console.log(` ${category}:`);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
console.log(` - ${entry.source}: ${entry.reason}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Report inventory
|
|
82
|
+
if (extract.inventory.persona.length > 0 || extract.inventory.schedules.length > 0) {
|
|
83
|
+
console.log("\nMigration summary:");
|
|
84
|
+
console.log(` Personas: ${extract.inventory.persona.length}`);
|
|
85
|
+
console.log(` Schedules: ${extract.inventory.schedules.length}`);
|
|
86
|
+
console.log(` Memory entries: ${extract.inventory.memory.length}`);
|
|
87
|
+
console.log(` Skills: ${extract.inventory.skills.length}`);
|
|
88
|
+
console.log(` Channels: ${extract.inventory.channels.length}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.dryRun) {
|
|
92
|
+
console.log("\n[DRY RUN] Output would be written to:");
|
|
93
|
+
for (const file of extract.files) {
|
|
94
|
+
console.log(` ${file.target} (${file.category})`);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Write files
|
|
100
|
+
await mkdir(targetHome, { recursive: true });
|
|
101
|
+
|
|
102
|
+
// Create channel plan and emit configurations
|
|
103
|
+
const channels = emptyChannelPlan();
|
|
104
|
+
emitChannels(extract, channels, targetHome, sourceType);
|
|
105
|
+
emitMonitors(extract);
|
|
106
|
+
emitImportedAxis(extract);
|
|
107
|
+
|
|
108
|
+
// Write source files
|
|
109
|
+
for (const file of extract.files) {
|
|
110
|
+
const filePath = join(targetHome, file.target);
|
|
111
|
+
await mkdir(join(filePath, ".."), { recursive: true });
|
|
112
|
+
await writeFile(filePath, file.content);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write merged JSON files
|
|
116
|
+
for (const merge of extract.merges) {
|
|
117
|
+
const filePath = join(targetHome, merge.target);
|
|
118
|
+
await mkdir(join(filePath, ".."), { recursive: true });
|
|
119
|
+
let existing: Record<string, unknown> | undefined;
|
|
120
|
+
try {
|
|
121
|
+
const text = await readFile(filePath, "utf-8");
|
|
122
|
+
existing = JSON.parse(text) as Record<string, unknown>;
|
|
123
|
+
} catch {
|
|
124
|
+
// File doesn't exist or is not valid JSON, start fresh
|
|
125
|
+
}
|
|
126
|
+
const merged = merge.merge(existing);
|
|
127
|
+
await writeFile(filePath, JSON.stringify(merged, null, 2));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`\nMigration complete. Output written to ${targetHome}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse command-line arguments for the migrate command.
|
|
135
|
+
*/
|
|
136
|
+
export function parseMigrateArgs(args: readonly string[]): MigrateOptions {
|
|
137
|
+
const options: MigrateOptions = {};
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < args.length; i++) {
|
|
140
|
+
const arg = args[i];
|
|
141
|
+
|
|
142
|
+
if (arg === "--source" && args[i + 1]) {
|
|
143
|
+
options.source = args[++i];
|
|
144
|
+
} else if (arg === "--target" && args[i + 1]) {
|
|
145
|
+
options.target = args[++i];
|
|
146
|
+
} else if (arg === "--dry-run") {
|
|
147
|
+
options.dryRun = true;
|
|
148
|
+
} else if (arg.startsWith("--")) {
|
|
149
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
150
|
+
} else if (!options.source) {
|
|
151
|
+
options.source = arg;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return options;
|
|
158
|
+
}
|
package/src/restart-stack.ts
CHANGED
|
@@ -215,9 +215,17 @@ function defaultIsServiceInstalled(options: {
|
|
|
215
215
|
};
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
/**
|
|
219
|
+
* `ps -o lstart` is whole seconds, and on Linux it is derived from the boot
|
|
220
|
+
* time plus clock ticks, so it can read up to one second EARLY. Measured on
|
|
221
|
+
* gaebal-gajae 2026-09-30: systemd logged `Started` at 10:55:50.170 while
|
|
222
|
+
* `ps` reported 10:55:49, and the freshly restarted gateway was judged stale,
|
|
223
|
+
* aborting the rest of the stack. The bound is floored and allowed that second.
|
|
224
|
+
*/
|
|
225
|
+
function psStartBound(ms: number): number {
|
|
226
|
+
return Math.floor(ms / 1_000) * 1_000 - PS_START_SKEW_MS;
|
|
220
227
|
}
|
|
228
|
+
const PS_START_SKEW_MS = 1_000;
|
|
221
229
|
|
|
222
230
|
/**
|
|
223
231
|
* Runs the ordered restart, verifying each service before the next command,
|
|
@@ -302,8 +310,7 @@ export async function runRestartStack(options: RunRestartOptions): Promise<Resta
|
|
|
302
310
|
step.processStartedAt = new Date(running.startedAt).toISOString();
|
|
303
311
|
step.binary = running.binary;
|
|
304
312
|
step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
|
|
305
|
-
|
|
306
|
-
if (running.startedAt >= floorSecond(Math.max(binaryModifiedAt, since))) {
|
|
313
|
+
if (running.startedAt >= psStartBound(Math.max(binaryModifiedAt, since))) {
|
|
307
314
|
step.result = "ok";
|
|
308
315
|
delete step.detail;
|
|
309
316
|
return;
|
package/src/services.ts
CHANGED
|
@@ -220,11 +220,14 @@ export function renderSystemdUnit(spec: ServiceSpec, binDir: string, home: strin
|
|
|
220
220
|
unitEnvironment("PATH", path),
|
|
221
221
|
"Restart=always",
|
|
222
222
|
"RestartSec=2",
|
|
223
|
-
// Only the gateway:
|
|
224
|
-
//
|
|
225
|
-
// The gateway exits on its own
|
|
226
|
-
// gateway main); the unit's stop
|
|
227
|
-
|
|
223
|
+
// Only the gateway: SIGTERM its main process for an ordered shutdown, then
|
|
224
|
+
// kill whatever is left in its cgroup so no child outlives the unit and
|
|
225
|
+
// gets re-adopted by the next start (#183). The gateway exits on its own
|
|
226
|
+
// inside 25s (SHUTDOWN_DEADLINE_MS in the gateway main); the unit's stop
|
|
227
|
+
// window is pinned so that ceiling always fits (#225). The gateway moves a GJC broker
|
|
228
|
+
// it autostarted into a scope of its own, so this never reaches the shared
|
|
229
|
+
// broker or its session hosts.
|
|
230
|
+
...(spec.dependsOnGateway ? [] : ["KillMode=mixed", "TimeoutStopSec=30s"]),
|
|
228
231
|
"",
|
|
229
232
|
"[Install]",
|
|
230
233
|
// Enabling the gateway pulls the whole stack in; a dependent is never
|