@cotal-ai/connector-hermes 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/bin/install.mjs +146 -35
  2. package/package.json +1 -1
package/bin/install.mjs CHANGED
@@ -2,44 +2,143 @@
2
2
  /**
3
3
  * `npx @cotal-ai/connector-hermes install`
4
4
  *
5
- * Installs the Cotal plugin into an existing Hermes so the gateway can join a Cotal mesh.
6
- * Copies this package's `plugin/cotal` (including the bundled `_sidecar/standalone.cjs`) into
7
- * `<HERMES_HOME>/plugins/cotal` and enables it.
5
+ * Guided install of the Cotal plugin into an existing Hermes so its gateway can join a Cotal mesh.
6
+ * Copies this package's `plugin/cotal` (incl. the bundled `_sidecar/standalone.cjs`) into
7
+ * `<HERMES_HOME>/plugins/cotal`, enables it, optionally writes your mesh config to
8
+ * `<HERMES_HOME>/.env`, and checks the mesh is reachable.
8
9
  *
9
- * House rule: no fallbacks. If Hermes isn't installed we fail loudly and write NOTHING — no
10
- * orphaned `~/.hermes/plugins/`. We resolve the Hermes home honestly (HERMES_HOME first), never
11
- * assuming a fixed path.
10
+ * Interactive when run in a TTY; non-interactive with `--yes` (or no TTY), taking config from
11
+ * `--link/--space/--name/--server` flags or the matching `COTAL_*` env vars.
12
+ *
13
+ * House rule: no fallbacks. If Hermes isn't installed we fail loudly and write NOTHING.
12
14
  */
13
15
  import { execFileSync } from "node:child_process";
14
- import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
16
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { createConnection } from "node:net";
15
18
  import { homedir } from "node:os";
16
19
  import { join, sep } from "node:path";
20
+ import { createInterface } from "node:readline/promises";
17
21
  import { fileURLToPath } from "node:url";
18
22
 
19
23
  const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
20
24
  const PLUGIN_SRC = join(PKG_ROOT, "plugin", "cotal");
21
25
  const SIDECAR = join(PLUGIN_SRC, "_sidecar", "standalone.cjs");
22
26
 
23
- function die(msg) {
27
+ const DEFAULTS = { space: "demo", name: "my-hermes", server: "nats://127.0.0.1:4222" };
28
+
29
+ const die = (msg) => {
24
30
  process.stderr.write(`✗ ${msg}\n`);
25
31
  process.exit(1);
32
+ };
33
+ const info = (msg) => process.stdout.write(`${msg}\n`);
34
+
35
+ function usage() {
36
+ info("usage: npx @cotal-ai/connector-hermes install [--yes] [--link <url> | --space <s> --name <n> --server <url>]");
26
37
  }
27
- function info(msg) {
28
- process.stdout.write(`${msg}\n`);
38
+
39
+ // ---- arg parsing ------------------------------------------------------------
40
+ function parseFlags(argv) {
41
+ const f = {};
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === "--yes" || a === "-y") f.yes = true;
45
+ else if (a.startsWith("--")) {
46
+ const [k, inline] = a.slice(2).split("=");
47
+ f[k] = inline ?? (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true);
48
+ }
49
+ }
50
+ return f;
29
51
  }
30
52
 
31
- function usage() {
32
- info("usage: npx @cotal-ai/connector-hermes install");
33
- info(" Installs the Cotal plugin into your Hermes (HERMES_HOME or ~/.hermes).");
53
+ // ---- mesh config gathering --------------------------------------------------
54
+ async function promptConfig() {
55
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
56
+ try {
57
+ info("\nCotal mesh — paste a join link (cotal://token@host/space), or press Enter to set it manually:");
58
+ const link = (await rl.question(" link: ")).trim();
59
+ if (link) {
60
+ const name = (await rl.question(` name [${DEFAULTS.name}]: `)).trim() || DEFAULTS.name;
61
+ return { COTAL_LINK: link, COTAL_NAME: name };
62
+ }
63
+ const space = (await rl.question(` space [${DEFAULTS.space}]: `)).trim() || DEFAULTS.space;
64
+ const name = (await rl.question(` name [${DEFAULTS.name}]: `)).trim() || DEFAULTS.name;
65
+ const server = (await rl.question(` server [${DEFAULTS.server}]: `)).trim() || DEFAULTS.server;
66
+ return { COTAL_SPACE: space, COTAL_NAME: name, COTAL_SERVERS: server };
67
+ } finally {
68
+ rl.close();
69
+ }
70
+ }
71
+
72
+ function configFromFlags(flags) {
73
+ const link = flags.link || process.env.COTAL_LINK;
74
+ if (link) return { COTAL_LINK: link, COTAL_NAME: flags.name || process.env.COTAL_NAME || DEFAULTS.name };
75
+ const space = flags.space || process.env.COTAL_SPACE;
76
+ const server = flags.server || process.env.COTAL_SERVERS;
77
+ if (!space && !server) return {}; // nothing supplied → skip .env, print manual next steps
78
+ return {
79
+ COTAL_SPACE: space || DEFAULTS.space,
80
+ COTAL_NAME: flags.name || process.env.COTAL_NAME || DEFAULTS.name,
81
+ COTAL_SERVERS: server || DEFAULTS.server,
82
+ };
83
+ }
84
+
85
+ // ---- ~/.hermes/.env editing (idempotent) ------------------------------------
86
+ function writeEnv(file, set) {
87
+ // Link mode and manual mode are mutually exclusive; comment out the other mode's keys so a
88
+ // re-run never leaves a stale COTAL_SPACE overriding a new COTAL_LINK (individual vars win in
89
+ // configFromEnv).
90
+ const drop = set.COTAL_LINK ? ["COTAL_SPACE", "COTAL_SERVERS"] : ["COTAL_LINK"];
91
+ const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
92
+ const keyOf = (l) => l.replace(/^#\s*/, "").split("=")[0].trim();
93
+ for (const [k, v] of Object.entries(set)) {
94
+ const line = `${k}=${v}`;
95
+ const i = lines.findIndex((l) => keyOf(l) === k);
96
+ if (i >= 0) lines[i] = line;
97
+ else lines.push(line);
98
+ }
99
+ for (let i = 0; i < lines.length; i++) {
100
+ if (drop.includes(keyOf(lines[i])) && !lines[i].trimStart().startsWith("#")) lines[i] = `# ${lines[i]}`;
101
+ }
102
+ writeFileSync(file, lines.join("\n").replace(/\n*$/, "\n"));
103
+ }
104
+
105
+ // ---- mesh reachability ------------------------------------------------------
106
+ function hostPort(server) {
107
+ try {
108
+ const u = new URL(server.replace(/^(cotals?|nats):\/\//, "http://"));
109
+ return { host: u.hostname, port: Number(u.port || 4222) };
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ function reachable(server) {
115
+ const hp = hostPort(server);
116
+ if (!hp) return Promise.resolve(false);
117
+ return new Promise((res) => {
118
+ const s = createConnection({ host: hp.host, port: hp.port });
119
+ const done = (ok) => {
120
+ try {
121
+ s.destroy();
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ res(ok);
126
+ };
127
+ s.setTimeout(2000);
128
+ s.once("connect", () => done(true));
129
+ s.once("timeout", () => done(false));
130
+ s.once("error", () => done(false));
131
+ });
34
132
  }
35
133
 
36
- const cmd = process.argv[2];
37
- if (cmd !== "install") {
134
+ // ---- main -------------------------------------------------------------------
135
+ const args = process.argv.slice(2);
136
+ if (args[0] !== "install") {
38
137
  usage();
39
- process.exit(cmd ? 1 : 0);
138
+ process.exit(args[0] ? 1 : 0);
40
139
  }
140
+ const flags = parseFlags(args.slice(1));
41
141
 
42
- // 1. Require a working Hermes — refuse (writing nothing) if absent.
43
142
  function hermesVersion() {
44
143
  try {
45
144
  return execFileSync("hermes", ["--version"], { encoding: "utf8" }).trim();
@@ -47,6 +146,7 @@ function hermesVersion() {
47
146
  return undefined;
48
147
  }
49
148
  }
149
+
50
150
  const version = hermesVersion();
51
151
  if (!version) {
52
152
  die(
@@ -55,45 +155,56 @@ if (!version) {
55
155
  " then re-run: npx @cotal-ai/connector-hermes install",
56
156
  );
57
157
  }
58
-
59
- // 2. The bundled sidecar must be present (it ships in the npm package; a raw source checkout
60
- // needs `pnpm --filter @cotal-ai/connector-hermes build` first). No silent half-install.
61
158
  if (!existsSync(SIDECAR)) {
62
159
  die(
63
160
  `bundled sidecar missing at ${SIDECAR}\n` +
64
161
  " (running from source? build it first: pnpm --filter @cotal-ai/connector-hermes build)",
65
162
  );
66
163
  }
164
+ info(`✓ Hermes detected — ${version.split("·")[0].trim()}`);
67
165
 
68
- // 3. Resolve the Hermes home honestly: HERMES_HOME wins, else ~/.hermes.
69
166
  const hermesHome = process.env.HERMES_HOME?.trim() || join(homedir(), ".hermes");
167
+ const interactive = Boolean(process.stdin.isTTY) && !flags.yes;
168
+ const meshEnv = interactive ? await promptConfig() : configFromFlags(flags);
169
+
170
+ // Write mesh config to <home>/.env (if any was gathered).
171
+ if (Object.keys(meshEnv).length) {
172
+ const envFile = join(hermesHome, ".env");
173
+ mkdirSync(hermesHome, { recursive: true });
174
+ writeEnv(envFile, meshEnv);
175
+ info(`✓ Wrote ${Object.keys(meshEnv).join(", ")} to ${envFile}`);
176
+
177
+ // Best-effort reachability check.
178
+ const server = meshEnv.COTAL_SERVERS || meshEnv.COTAL_LINK;
179
+ if (server) {
180
+ const ok = await reachable(server);
181
+ info(ok ? `✓ Mesh reachable` : `✗ Mesh not reachable at ${hostPort(server)?.host}:${hostPort(server)?.port} — start it (e.g. \`nats-server -js\`) before \`hermes gateway run\``);
182
+ }
183
+ }
184
+
185
+ // Copy the plugin + enable it.
70
186
  const pluginsDir = join(hermesHome, "plugins");
71
187
  const dest = join(pluginsDir, "cotal");
72
-
73
- info(`Hermes: ${version}`);
74
- info(`Installing the Cotal plugin into ${dest} ...`);
75
188
  mkdirSync(pluginsDir, { recursive: true });
76
- rmSync(dest, { recursive: true, force: true }); // clean reinstall (overwrite stale code)
189
+ rmSync(dest, { recursive: true, force: true });
77
190
  cpSync(PLUGIN_SRC, dest, {
78
191
  recursive: true,
79
192
  filter: (src) => !src.includes(`${sep}__pycache__`) && !src.endsWith(".pyc"),
80
193
  });
81
-
82
- // 4. Enable it via Hermes' own command (it's discovered now that it's in plugins/).
83
194
  try {
84
195
  execFileSync("hermes", ["plugins", "enable", "cotal"], { stdio: "inherit" });
85
196
  } catch {
86
197
  die(
87
198
  "copied the plugin but `hermes plugins enable cotal` failed.\n" +
88
- " Enable it manually: `hermes plugins enable cotal` (or add `cotal` to plugins.enabled in config.yaml).",
199
+ " Enable it manually: `hermes plugins enable cotal`.",
89
200
  );
90
201
  }
91
202
 
92
203
  info("\n✓ Cotal plugin installed + enabled.\n");
93
- info("Next: point it at a mesh, then run the gateway:");
94
- info(" # add to ~/.hermes/.env (or the matching HERMES_HOME)");
95
- info(" COTAL_SPACE=demo");
96
- info(" COTAL_NAME=my-hermes");
97
- info(" COTAL_SERVERS=nats://127.0.0.1:4222");
98
- info("");
99
- info(" hermes gateway run");
204
+ if (Object.keys(meshEnv).length) {
205
+ info("Run it on the mesh:");
206
+ info(" hermes gateway run");
207
+ } else {
208
+ info("Next: set your mesh in ~/.hermes/.env (COTAL_LINK, or COTAL_SPACE/COTAL_NAME/COTAL_SERVERS),");
209
+ info(" then: hermes gateway run");
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cotal-ai/connector-hermes",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",