@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.
@@ -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
+ }
@@ -215,9 +215,17 @@ function defaultIsServiceInstalled(options: {
215
215
  };
216
216
  }
217
217
 
218
- function floorSecond(ms: number): number {
219
- return Math.floor(ms / 1_000) * 1_000;
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
- // `ps` has one-second resolution, so the bound is floored to match.
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: GJC daemon and session hosts share the gateway cgroup,
224
- // so the default control-group kill would take unrelated sessions with it.
225
- // The gateway exits on its own inside 25s (SHUTDOWN_DEADLINE_MS in the
226
- // gateway main); the unit's stop window is pinned so that ceiling always fits.
227
- ...(spec.dependsOnGateway ? [] : ["KillMode=process", "TimeoutStopSec=30s"]),
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