@gajae-gateway/cli 0.1.0
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 +55 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/main.d.ts +50 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +792 -0
- package/dist/services.d.ts +40 -0
- package/dist/services.d.ts.map +1 -0
- package/package.json +50 -0
- package/src/list.ts +187 -0
- package/src/main.ts +576 -0
- package/src/services.ts +224 -0
package/src/services.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseRuntimeConfig, type RuntimeConfig, RuntimeConfigError } from "@gajae-gateway/protocol";
|
|
5
|
+
|
|
6
|
+
const CONFIG_SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export const FALLBACK_PATH_DIRS = ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] as const;
|
|
9
|
+
const LOGIN_PATH_COMMAND = 'printf %s "$PATH"';
|
|
10
|
+
|
|
11
|
+
export type LoginPathRunner = (command: readonly string[]) => string | PromiseLike<string>;
|
|
12
|
+
export type PlistWriter = (path: string, contents: string) => void | PromiseLike<void>;
|
|
13
|
+
|
|
14
|
+
export interface ResolvePathOptions {
|
|
15
|
+
readonly binDir: string;
|
|
16
|
+
/** The operator home used for managed PATH entries and `~/` expansion. */
|
|
17
|
+
readonly home: string;
|
|
18
|
+
readonly runtime?: RuntimeConfig;
|
|
19
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
20
|
+
/** A supplied PATH bypasses shell discovery and is useful for pure tests. */
|
|
21
|
+
readonly loginPath?: string;
|
|
22
|
+
/** Test seam for the `${SHELL} -lc` invocation used by the default path. */
|
|
23
|
+
readonly loginPathRunner?: LoginPathRunner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InstallServicesOptions {
|
|
27
|
+
readonly binDir: string;
|
|
28
|
+
readonly launchAgentsDir?: string;
|
|
29
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** Test seam for the `${SHELL} -lc` invocation used by the default path. */
|
|
31
|
+
readonly loginPathRunner?: LoginPathRunner;
|
|
32
|
+
/** Test seam for counting or inspecting the plist writes. */
|
|
33
|
+
readonly writeFile?: PlistWriter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ServiceSpec {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly binary: string;
|
|
40
|
+
readonly args: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SERVICE_SPECS: readonly ServiceSpec[] = [
|
|
44
|
+
{ id: "gateway", label: "dev.gajaeway.gateway", binary: "gajaeway-gateway", args: ["daemon"] },
|
|
45
|
+
{
|
|
46
|
+
id: "adapter-discord",
|
|
47
|
+
label: "dev.gajaeway.adapter-discord",
|
|
48
|
+
binary: "gajaeway-discord",
|
|
49
|
+
args: [],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "adapter-slack",
|
|
53
|
+
label: "dev.gajaeway.adapter-slack",
|
|
54
|
+
binary: "gajaeway-slack",
|
|
55
|
+
args: [],
|
|
56
|
+
},
|
|
57
|
+
{ id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"] },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function homeForEnvironment(env: NodeJS.ProcessEnv): string {
|
|
61
|
+
return env.GAJAEWAY_HOME || join(env.HOME || homedir(), ".gajaeway");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function expandHome(value: string, home: string): string {
|
|
65
|
+
return value.startsWith("~/") ? join(home, value.slice(2)) : value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizedEntries(entries: readonly string[], home: string): string[] {
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
const result: string[] = [];
|
|
71
|
+
for (const raw of entries) {
|
|
72
|
+
if (raw.length === 0) continue;
|
|
73
|
+
const entry = expandHome(raw, home);
|
|
74
|
+
if (seen.has(entry)) continue;
|
|
75
|
+
seen.add(entry);
|
|
76
|
+
result.push(entry);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mergePathEntries(binDir: string, userHome: string, loginPath: string): string[] {
|
|
82
|
+
return normalizedEntries(
|
|
83
|
+
[
|
|
84
|
+
expandHome(binDir, userHome),
|
|
85
|
+
join(userHome, "bin"),
|
|
86
|
+
join(userHome, ".local", "bin"),
|
|
87
|
+
join(userHome, ".bun", "bin"),
|
|
88
|
+
...loginPath.replace(/(?:\r\n|\n)+$/, "").split(":"),
|
|
89
|
+
...FALLBACK_PATH_DIRS,
|
|
90
|
+
],
|
|
91
|
+
userHome,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function defaultLoginPathRunner(command: readonly string[]): Promise<string> {
|
|
96
|
+
let child: ReturnType<typeof Bun.spawn>;
|
|
97
|
+
try {
|
|
98
|
+
child = Bun.spawn([...command], { stdout: "pipe", stderr: "pipe" });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(`could not run login shell: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
if (!(child.stdout instanceof ReadableStream)) throw new Error("login shell stdout was not piped");
|
|
103
|
+
const stdout = await new Response(child.stdout).text();
|
|
104
|
+
const exitCode = await child.exited;
|
|
105
|
+
if (exitCode !== 0) throw new Error(`login shell exited with status ${exitCode}`);
|
|
106
|
+
return stdout;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function discoverLoginPath(
|
|
110
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
111
|
+
runner: LoginPathRunner = defaultLoginPathRunner,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
const shell = env.SHELL || "/bin/zsh";
|
|
114
|
+
return await runner([shell, "-lc", LOGIN_PATH_COMMAND]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function resolvePath(options: ResolvePathOptions): Promise<string> {
|
|
118
|
+
const runtime = options.runtime;
|
|
119
|
+
const env = options.env;
|
|
120
|
+
const shellEnv = env ?? process.env;
|
|
121
|
+
const userHome = options.home;
|
|
122
|
+
let loginPath = "";
|
|
123
|
+
if (runtime?.path !== undefined) loginPath = runtime.path.join(":");
|
|
124
|
+
else if (runtime?.inheritLoginPath !== false)
|
|
125
|
+
loginPath =
|
|
126
|
+
options.loginPath ?? (await discoverLoginPath(shellEnv, options.loginPathRunner ?? defaultLoginPathRunner));
|
|
127
|
+
return mergePathEntries(options.binDir, userHome, loginPath).join(":");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function escapeXml(value: string): string {
|
|
131
|
+
return value
|
|
132
|
+
.replaceAll("&", "&")
|
|
133
|
+
.replaceAll("<", "<")
|
|
134
|
+
.replaceAll(">", ">")
|
|
135
|
+
.replaceAll('"', """)
|
|
136
|
+
.replaceAll("'", "'");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function plistString(value: string): string {
|
|
140
|
+
return `<string>${escapeXml(value)}</string>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function renderLaunchAgent(spec: ServiceSpec, binDir: string, home: string, path: string): string {
|
|
144
|
+
const program = join(binDir, spec.binary);
|
|
145
|
+
const argumentsXml = [program, ...spec.args].map(plistString).join("");
|
|
146
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
147
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
148
|
+
<plist version="1.0"><dict>
|
|
149
|
+
<key>Label</key>${plistString(spec.label)}
|
|
150
|
+
<key>ProgramArguments</key><array>${argumentsXml}</array>
|
|
151
|
+
<key>WorkingDirectory</key>${plistString(binDir)}
|
|
152
|
+
<key>EnvironmentVariables</key><dict>
|
|
153
|
+
<key>GAJAEWAY_HOME</key>${plistString(home)}
|
|
154
|
+
<key>PATH</key>${plistString(path)}
|
|
155
|
+
</dict>
|
|
156
|
+
<key>RunAtLoad</key><true/>
|
|
157
|
+
<key>KeepAlive</key><true/>
|
|
158
|
+
<key>StandardOutPath</key>${plistString(join(home, `${spec.id}.stdout.log`))}
|
|
159
|
+
<key>StandardErrorPath</key>${plistString(join(home, `${spec.id}.stderr.log`))}
|
|
160
|
+
</dict></plist>
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
|
|
165
|
+
const configPath = join(home, "config.json");
|
|
166
|
+
let raw: string;
|
|
167
|
+
try {
|
|
168
|
+
raw = await readFile(configPath, "utf8");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new Error(`could not read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
171
|
+
}
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(raw);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(`could not parse ${configPath}: malformed JSON`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
179
|
+
throw new Error(`invalid configuration ${configPath}: config must be an object`);
|
|
180
|
+
const input = parsed as Record<string, unknown>;
|
|
181
|
+
if (input.schemaVersion !== CONFIG_SCHEMA_VERSION)
|
|
182
|
+
throw new Error(`invalid configuration ${configPath}: schemaVersion must be ${CONFIG_SCHEMA_VERSION}`);
|
|
183
|
+
try {
|
|
184
|
+
return parseRuntimeConfig(input.runtime);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const message = error instanceof RuntimeConfigError ? error.message : String(error);
|
|
187
|
+
throw new Error(`invalid runtime configuration ${configPath}: ${message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function installServices(options: InstallServicesOptions): Promise<readonly string[]> {
|
|
192
|
+
if (options.binDir.length === 0) throw new Error("services requires a non-empty --bin-dir DIR");
|
|
193
|
+
const env = options.env ?? process.env;
|
|
194
|
+
const userHome = env.HOME || homedir();
|
|
195
|
+
const home = homeForEnvironment(env);
|
|
196
|
+
const binDir = expandHome(options.binDir, userHome);
|
|
197
|
+
const launchAgentsDir = expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome);
|
|
198
|
+
const runtime = await readRuntime(home);
|
|
199
|
+
const path = await resolvePath({
|
|
200
|
+
binDir,
|
|
201
|
+
home: userHome,
|
|
202
|
+
runtime,
|
|
203
|
+
env,
|
|
204
|
+
loginPathRunner: options.loginPathRunner,
|
|
205
|
+
});
|
|
206
|
+
await mkdir(launchAgentsDir, { recursive: true, mode: 0o700 });
|
|
207
|
+
const plists = SERVICE_SPECS.map((spec) => ({
|
|
208
|
+
path: join(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`),
|
|
209
|
+
contents: renderLaunchAgent(spec, binDir, home, path),
|
|
210
|
+
}));
|
|
211
|
+
const write = options.writeFile;
|
|
212
|
+
for (const plist of plists) {
|
|
213
|
+
if (write) await write(plist.path, plist.contents);
|
|
214
|
+
else {
|
|
215
|
+
await writeFile(plist.path, plist.contents, { encoding: "utf8", mode: 0o600 });
|
|
216
|
+
await chmod(plist.path, 0o600);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return plists.map((plist) => plist.path);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function serviceUsage(): string {
|
|
223
|
+
return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR]";
|
|
224
|
+
}
|