@elyracode/grove 0.9.21
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/CHANGELOG.md +6 -0
- package/README.md +34 -0
- package/extensions/index.ts +574 -0
- package/package.json +34 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.9.21] - 2026-07-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release: Grove integration for Elyra. Reads Grove's config, service state, pid files, and logs directly (no CLI required) and exposes `grove_status`, `grove_sites`, `grove_logs`, and `grove_env_sync` as agent tools, plus `/grove`, `/grove-logs`, and `/grove-sync` commands.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @elyracode/grove
|
|
2
|
+
|
|
3
|
+
Grove integration for Elyra. Gives the agent eyes into your Grove local development environment: daemon state, PHP/Node runtimes, services, sites, logs, and `.env` sync checks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
elyra install npm:@elyracode/grove
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
Grove has no CLI — it is driven by a daemon (`groved`) and a `config.toml`. This extension reads Grove's own files directly (config, service state, pid files, logs), so everything works without shelling out.
|
|
14
|
+
|
|
15
|
+
### Tools
|
|
16
|
+
|
|
17
|
+
| Tool | Description |
|
|
18
|
+
|------|-------------|
|
|
19
|
+
| `grove_status` | Daemon state, installed PHP/Node runtimes (and which php-fpm is running), services with ports, TLS/domain settings, and which Grove site the current project maps to |
|
|
20
|
+
| `grove_sites` | All registered sites with domains, per-site PHP/Node overrides and TLS, plus parked paths |
|
|
21
|
+
| `grove_logs` | Recent entries from MySQL, Redis, php-fpm, per-site dev logs (vite, queue), and the project's Laravel log |
|
|
22
|
+
| `grove_env_sync` | Checks `.env` against the actual Grove environment: DB host/port (from Grove's service state), Redis host, and `APP_URL` against the site's real Grove domain and TLS setting |
|
|
23
|
+
|
|
24
|
+
### Commands
|
|
25
|
+
|
|
26
|
+
| Command | Description |
|
|
27
|
+
|---------|-------------|
|
|
28
|
+
| `/grove` | Grove dashboard — daemon, runtimes, services, current site |
|
|
29
|
+
| `/grove-logs` | Recent Grove logs for this project |
|
|
30
|
+
| `/grove-sync` | Check `.env` against the Grove environment |
|
|
31
|
+
|
|
32
|
+
## Grove home discovery
|
|
33
|
+
|
|
34
|
+
The extension looks for Grove at `~/Library/Application Support/Grove` (macOS) or `~/.config/grove`. Set `GROVE_HOME` to override.
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grove integration for Elyra.
|
|
3
|
+
*
|
|
4
|
+
* Grove is a local development environment manager (PHP + Node runtimes,
|
|
5
|
+
* MySQL/Redis services, TLS, .test domains) driven by a daemon (groved) and
|
|
6
|
+
* a config.toml. There is no CLI to shell out to, so this extension reads
|
|
7
|
+
* Grove's own files directly: config.toml for sites/runtimes, services
|
|
8
|
+
* state.json for ports and autostart, pid files for liveness, and the logs
|
|
9
|
+
* directory for service and per-site dev logs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import type { ExtensionAPI } from "@elyracode/coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
// ── Grove home discovery ────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function getGroveHome(): string | undefined {
|
|
21
|
+
const envHome = process.env.GROVE_HOME;
|
|
22
|
+
if (envHome && existsSync(envHome)) return envHome;
|
|
23
|
+
const candidates = [
|
|
24
|
+
join(homedir(), "Library", "Application Support", "Grove"),
|
|
25
|
+
join(homedir(), ".config", "grove"),
|
|
26
|
+
];
|
|
27
|
+
return candidates.find((p) => existsSync(p));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Minimal TOML subset parser ──────────────────────────────────────────────
|
|
31
|
+
// Grove's config.toml is machine-written and uses a small, predictable
|
|
32
|
+
// subset: scalar keys, [tables], [[arrays of tables]], and string arrays.
|
|
33
|
+
// Parsing it by hand avoids a dependency that would otherwise exist only
|
|
34
|
+
// for this file.
|
|
35
|
+
|
|
36
|
+
type TomlScalar = string | number | boolean;
|
|
37
|
+
type TomlValue = TomlScalar | TomlScalar[];
|
|
38
|
+
type TomlTable = Record<string, TomlValue>;
|
|
39
|
+
|
|
40
|
+
interface ParsedToml {
|
|
41
|
+
root: TomlTable;
|
|
42
|
+
tables: Record<string, TomlTable>;
|
|
43
|
+
arrays: Record<string, TomlTable[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseScalar(raw: string): TomlScalar {
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
if (trimmed === "true") return true;
|
|
49
|
+
if (trimmed === "false") return false;
|
|
50
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
51
|
+
return trimmed.replace(/^["']/, "").replace(/["']$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseTomlSubset(text: string): ParsedToml {
|
|
55
|
+
const result: ParsedToml = { root: {}, tables: {}, arrays: {} };
|
|
56
|
+
let current: TomlTable = result.root;
|
|
57
|
+
let pendingArrayKey: string | undefined;
|
|
58
|
+
let pendingArrayItems: TomlScalar[] = [];
|
|
59
|
+
|
|
60
|
+
for (const rawLine of text.split("\n")) {
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
if (!line || line.startsWith("#")) continue;
|
|
63
|
+
|
|
64
|
+
// Continuation of a multi-line array
|
|
65
|
+
if (pendingArrayKey !== undefined) {
|
|
66
|
+
if (line.startsWith("]")) {
|
|
67
|
+
current[pendingArrayKey] = pendingArrayItems;
|
|
68
|
+
pendingArrayKey = undefined;
|
|
69
|
+
pendingArrayItems = [];
|
|
70
|
+
} else {
|
|
71
|
+
const item = line.replace(/,\s*$/, "");
|
|
72
|
+
if (item) pendingArrayItems.push(parseScalar(item));
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const arrayHeader = /^\[\[([^\]]+)\]\]$/.exec(line);
|
|
78
|
+
if (arrayHeader) {
|
|
79
|
+
const name = arrayHeader[1].trim();
|
|
80
|
+
const table: TomlTable = {};
|
|
81
|
+
result.arrays[name] = result.arrays[name] ?? [];
|
|
82
|
+
result.arrays[name].push(table);
|
|
83
|
+
current = table;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tableHeader = /^\[([^\]]+)\]$/.exec(line);
|
|
88
|
+
if (tableHeader) {
|
|
89
|
+
const name = tableHeader[1].trim();
|
|
90
|
+
result.tables[name] = result.tables[name] ?? {};
|
|
91
|
+
current = result.tables[name];
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const kv = /^([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line);
|
|
96
|
+
if (!kv) continue;
|
|
97
|
+
const key = kv[1];
|
|
98
|
+
const value = kv[2].trim();
|
|
99
|
+
|
|
100
|
+
if (value === "[") {
|
|
101
|
+
pendingArrayKey = key;
|
|
102
|
+
pendingArrayItems = [];
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
106
|
+
const inner = value.slice(1, -1).trim();
|
|
107
|
+
current[key] = inner ? inner.split(",").map((v) => parseScalar(v)) : [];
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
current[key] = parseScalar(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (pendingArrayKey !== undefined) current[pendingArrayKey] = pendingArrayItems;
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Grove model ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
interface GroveSite {
|
|
120
|
+
name: string;
|
|
121
|
+
path: string;
|
|
122
|
+
php?: string;
|
|
123
|
+
node?: string;
|
|
124
|
+
secure: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface GroveConfig {
|
|
128
|
+
tld: string;
|
|
129
|
+
defaultPhp?: string;
|
|
130
|
+
httpPort?: number;
|
|
131
|
+
httpsPort?: number;
|
|
132
|
+
docker?: boolean;
|
|
133
|
+
xdebug?: boolean;
|
|
134
|
+
parked: string[];
|
|
135
|
+
sites: GroveSite[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function expandTilde(p: string): string {
|
|
139
|
+
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function str(v: TomlValue | undefined): string | undefined {
|
|
143
|
+
return typeof v === "string" ? v : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readGroveConfig(groveHome: string): GroveConfig | undefined {
|
|
147
|
+
const configPath = join(groveHome, "config.toml");
|
|
148
|
+
if (!existsSync(configPath)) return undefined;
|
|
149
|
+
let parsed: ParsedToml;
|
|
150
|
+
try {
|
|
151
|
+
parsed = parseTomlSubset(readFileSync(configPath, "utf-8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const general = parsed.tables.general ?? {};
|
|
157
|
+
const sites: GroveSite[] = (parsed.arrays.sites ?? [])
|
|
158
|
+
.filter((s) => typeof s.name === "string" && typeof s.path === "string")
|
|
159
|
+
.map((s) => ({
|
|
160
|
+
name: s.name as string,
|
|
161
|
+
path: expandTilde(s.path as string),
|
|
162
|
+
php: str(s.php),
|
|
163
|
+
node: str(s.node),
|
|
164
|
+
secure: s.secure === true,
|
|
165
|
+
}));
|
|
166
|
+
const parked = (parsed.arrays.parked ?? [])
|
|
167
|
+
.map((p) => str(p.path))
|
|
168
|
+
.filter((p): p is string => !!p)
|
|
169
|
+
.map(expandTilde);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
tld: str(general.tld) ?? "test",
|
|
173
|
+
defaultPhp: str(general.default_php),
|
|
174
|
+
httpPort: typeof general.http_port === "number" ? general.http_port : undefined,
|
|
175
|
+
httpsPort: typeof general.https_port === "number" ? general.https_port : undefined,
|
|
176
|
+
docker: general.docker === true,
|
|
177
|
+
xdebug: general.xdebug === true,
|
|
178
|
+
parked,
|
|
179
|
+
sites,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ServicesState {
|
|
184
|
+
autostart: Record<string, boolean>;
|
|
185
|
+
ports: Record<string, number>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readServicesState(groveHome: string): ServicesState {
|
|
189
|
+
const statePath = join(groveHome, "services", "state.json");
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf-8")) as Partial<ServicesState>;
|
|
192
|
+
return {
|
|
193
|
+
autostart: parsed.autostart ?? {},
|
|
194
|
+
ports: parsed.ports ?? {},
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
return { autostart: {}, ports: {} };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pidAlive(pidFile: string): boolean {
|
|
202
|
+
try {
|
|
203
|
+
const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
204
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
205
|
+
process.kill(pid, 0);
|
|
206
|
+
return true;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function listRuntimes(groveHome: string): { php: string[]; node: string[] } {
|
|
213
|
+
const runtimesDir = join(groveHome, "runtimes");
|
|
214
|
+
const php: string[] = [];
|
|
215
|
+
let node: string[] = [];
|
|
216
|
+
try {
|
|
217
|
+
for (const entry of readdirSync(runtimesDir)) {
|
|
218
|
+
if (/^\d+\.\d+$/.test(entry)) php.push(entry);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// no runtimes dir
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
node = readdirSync(join(runtimesDir, "node")).filter((e) => /^\d+/.test(e));
|
|
225
|
+
} catch {
|
|
226
|
+
// no node runtimes
|
|
227
|
+
}
|
|
228
|
+
return { php: php.sort(), node: node.sort() };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Resolve which Grove site the given directory belongs to, if any. */
|
|
232
|
+
function resolveSite(config: GroveConfig, cwd: string): GroveSite | undefined {
|
|
233
|
+
const explicit = config.sites.find((s) => s.path === cwd);
|
|
234
|
+
if (explicit) return explicit;
|
|
235
|
+
const parkedParent = config.parked.find((p) => cwd.startsWith(`${p}/`) && cwd !== p);
|
|
236
|
+
if (parkedParent) {
|
|
237
|
+
return { name: basename(cwd), path: cwd, secure: false };
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function siteUrl(config: GroveConfig, site: GroveSite): string {
|
|
243
|
+
const scheme = site.secure ? "https" : "http";
|
|
244
|
+
return `${scheme}://${site.name}.${config.tld}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function readLogTail(path: string, lineCount: number): string | undefined {
|
|
248
|
+
try {
|
|
249
|
+
const content = readFileSync(path, "utf-8");
|
|
250
|
+
const lines = content.split("\n").filter((l) => l.length > 0);
|
|
251
|
+
if (lines.length === 0) return undefined;
|
|
252
|
+
return lines.slice(-lineCount).join("\n");
|
|
253
|
+
} catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseEnv(content: string): Record<string, string> {
|
|
259
|
+
const env: Record<string, string> = {};
|
|
260
|
+
for (const line of content.split("\n")) {
|
|
261
|
+
const match = /^([A-Z0-9_]+)=("?)(.*)\2$/.exec(line.trim());
|
|
262
|
+
if (match) env[match[1]] = match[3];
|
|
263
|
+
}
|
|
264
|
+
return env;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const NOT_INSTALLED =
|
|
268
|
+
"Grove does not appear to be installed (no Grove directory found; set GROVE_HOME to override).";
|
|
269
|
+
|
|
270
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
export default function (elyra: ExtensionAPI): void {
|
|
273
|
+
// ── Tool: grove_status ──
|
|
274
|
+
elyra.registerTool({
|
|
275
|
+
name: "grove_status",
|
|
276
|
+
label: "Grove Status",
|
|
277
|
+
description:
|
|
278
|
+
"Show the Grove local development environment status: daemon state, installed PHP and Node " +
|
|
279
|
+
"runtimes, configured services (MySQL, Redis) with ports, TLS/domain settings, and which " +
|
|
280
|
+
"Grove site the current project maps to. Use this to understand the local dev environment.",
|
|
281
|
+
parameters: Type.Object({}),
|
|
282
|
+
execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
|
|
283
|
+
const groveHome = getGroveHome();
|
|
284
|
+
if (!groveHome) {
|
|
285
|
+
return { content: [{ type: "text", text: NOT_INSTALLED }], details: {} };
|
|
286
|
+
}
|
|
287
|
+
const config = readGroveConfig(groveHome);
|
|
288
|
+
if (!config) {
|
|
289
|
+
return {
|
|
290
|
+
content: [{ type: "text", text: `Grove directory found (${groveHome}) but config.toml is missing or unreadable.` }],
|
|
291
|
+
details: {},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const state = readServicesState(groveHome);
|
|
296
|
+
const runtimes = listRuntimes(groveHome);
|
|
297
|
+
const daemonRunning = pidAlive(join(groveHome, "run", "groved.pid"));
|
|
298
|
+
|
|
299
|
+
const lines: string[] = ["# Grove Status", ""];
|
|
300
|
+
lines.push(`**Daemon (groved)**: ${daemonRunning ? "running" : "not running"}`);
|
|
301
|
+
lines.push(`**TLD**: .${config.tld}`);
|
|
302
|
+
if (config.httpPort || config.httpsPort) {
|
|
303
|
+
lines.push(`**Ports**: http ${config.httpPort ?? "-"}, https ${config.httpsPort ?? "-"}`);
|
|
304
|
+
}
|
|
305
|
+
lines.push(`**Docker integration**: ${config.docker ? "on" : "off"}`);
|
|
306
|
+
lines.push(`**Xdebug**: ${config.xdebug ? "on" : "off"}`);
|
|
307
|
+
lines.push("");
|
|
308
|
+
|
|
309
|
+
lines.push("## Runtimes");
|
|
310
|
+
lines.push(
|
|
311
|
+
`- **PHP**: ${runtimes.php.length > 0 ? runtimes.php.join(", ") : "none"}${config.defaultPhp ? ` (default: ${config.defaultPhp})` : ""}`,
|
|
312
|
+
);
|
|
313
|
+
for (const version of runtimes.php) {
|
|
314
|
+
const running = pidAlive(join(groveHome, "run", `php-fpm-${version.replace(".", "_")}.pid`));
|
|
315
|
+
if (running) lines.push(` - php-fpm ${version}: running`);
|
|
316
|
+
}
|
|
317
|
+
lines.push(`- **Node**: ${runtimes.node.length > 0 ? runtimes.node.join(", ") : "none"}`);
|
|
318
|
+
lines.push("");
|
|
319
|
+
|
|
320
|
+
lines.push("## Services");
|
|
321
|
+
const serviceNames = new Set([...Object.keys(state.autostart), ...Object.keys(state.ports)]);
|
|
322
|
+
if (serviceNames.size === 0) {
|
|
323
|
+
lines.push("No services configured.");
|
|
324
|
+
}
|
|
325
|
+
for (const name of [...serviceNames].sort()) {
|
|
326
|
+
const port = state.ports[name];
|
|
327
|
+
const autostart = state.autostart[name] === true;
|
|
328
|
+
lines.push(`- **${name}**: ${autostart ? "autostart" : "manual"}${port ? `, port ${port}` : ""}`);
|
|
329
|
+
}
|
|
330
|
+
lines.push("");
|
|
331
|
+
|
|
332
|
+
lines.push("## Current Project");
|
|
333
|
+
const site = resolveSite(config, ctx.cwd);
|
|
334
|
+
if (site) {
|
|
335
|
+
lines.push(`- **Site**: ${site.name} -> ${siteUrl(config, site)}`);
|
|
336
|
+
if (site.php) lines.push(`- **PHP override**: ${site.php}`);
|
|
337
|
+
if (site.node) lines.push(`- **Node override**: ${site.node}`);
|
|
338
|
+
} else {
|
|
339
|
+
lines.push(`- Not a Grove site (not in a parked path and not registered in config.toml).`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ── Tool: grove_sites ──
|
|
347
|
+
elyra.registerTool({
|
|
348
|
+
name: "grove_sites",
|
|
349
|
+
label: "Grove Sites",
|
|
350
|
+
description:
|
|
351
|
+
"List all sites Grove serves: registered sites with their domains, per-site PHP/Node " +
|
|
352
|
+
"overrides and TLS status, plus parked directories where every subfolder becomes a site.",
|
|
353
|
+
parameters: Type.Object({}),
|
|
354
|
+
execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
|
|
355
|
+
const groveHome = getGroveHome();
|
|
356
|
+
if (!groveHome) {
|
|
357
|
+
return { content: [{ type: "text", text: NOT_INSTALLED }], details: {} };
|
|
358
|
+
}
|
|
359
|
+
const config = readGroveConfig(groveHome);
|
|
360
|
+
if (!config) {
|
|
361
|
+
return { content: [{ type: "text", text: "Grove config.toml is missing or unreadable." }], details: {} };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lines: string[] = ["# Grove Sites", ""];
|
|
365
|
+
lines.push("## Registered Sites");
|
|
366
|
+
if (config.sites.length === 0) {
|
|
367
|
+
lines.push("None.");
|
|
368
|
+
}
|
|
369
|
+
for (const site of config.sites) {
|
|
370
|
+
const overrides = [
|
|
371
|
+
site.php ? `php ${site.php}` : undefined,
|
|
372
|
+
site.node ? `node ${site.node}` : undefined,
|
|
373
|
+
].filter((o): o is string => !!o);
|
|
374
|
+
const marker = site.path === ctx.cwd ? " (current project)" : "";
|
|
375
|
+
lines.push(
|
|
376
|
+
`- **${site.name}** -> ${siteUrl(config, site)}${overrides.length > 0 ? ` [${overrides.join(", ")}]` : ""}${marker}`,
|
|
377
|
+
);
|
|
378
|
+
lines.push(` ${site.path}`);
|
|
379
|
+
}
|
|
380
|
+
lines.push("");
|
|
381
|
+
lines.push("## Parked Paths");
|
|
382
|
+
if (config.parked.length === 0) {
|
|
383
|
+
lines.push("None.");
|
|
384
|
+
}
|
|
385
|
+
for (const parked of config.parked) {
|
|
386
|
+
lines.push(`- ${parked} (every subfolder is served as <name>.${config.tld})`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ── Tool: grove_logs ──
|
|
394
|
+
elyra.registerTool({
|
|
395
|
+
name: "grove_logs",
|
|
396
|
+
label: "Grove Logs",
|
|
397
|
+
description:
|
|
398
|
+
"Read recent log entries from Grove: MySQL, Redis, php-fpm, the per-site dev logs " +
|
|
399
|
+
"(vite, queue), and the current project's Laravel log. Use this to debug local " +
|
|
400
|
+
"development issues.",
|
|
401
|
+
parameters: Type.Object({
|
|
402
|
+
source: Type.Optional(
|
|
403
|
+
Type.Union(
|
|
404
|
+
[
|
|
405
|
+
Type.Literal("mysql"),
|
|
406
|
+
Type.Literal("redis"),
|
|
407
|
+
Type.Literal("php"),
|
|
408
|
+
Type.Literal("site"),
|
|
409
|
+
Type.Literal("laravel"),
|
|
410
|
+
Type.Literal("all"),
|
|
411
|
+
],
|
|
412
|
+
{ description: "Log source to read (default: all). 'site' reads the current site's vite/queue dev logs." },
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
lines: Type.Optional(Type.Number({ description: "Number of lines to read per log (default: 50)" })),
|
|
416
|
+
}),
|
|
417
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
418
|
+
const groveHome = getGroveHome();
|
|
419
|
+
if (!groveHome) {
|
|
420
|
+
return { content: [{ type: "text", text: NOT_INSTALLED }], details: {} };
|
|
421
|
+
}
|
|
422
|
+
const source = params.source ?? "all";
|
|
423
|
+
const lineCount = params.lines ?? 50;
|
|
424
|
+
const logsDir = join(groveHome, "logs");
|
|
425
|
+
const lines: string[] = ["# Grove Logs", ""];
|
|
426
|
+
|
|
427
|
+
const addSection = (title: string, path: string) => {
|
|
428
|
+
const tail = readLogTail(path, lineCount);
|
|
429
|
+
lines.push(`## ${title}`);
|
|
430
|
+
lines.push(tail ?? "No entries (or log not found).");
|
|
431
|
+
lines.push("");
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
if (source === "mysql" || source === "all") addSection("MySQL", join(logsDir, "mysql.log"));
|
|
435
|
+
if (source === "redis" || source === "all") addSection("Redis", join(logsDir, "redis.log"));
|
|
436
|
+
if (source === "php" || source === "all") {
|
|
437
|
+
try {
|
|
438
|
+
for (const entry of readdirSync(logsDir)) {
|
|
439
|
+
if (/^php-fpm-.*\.log$/.test(entry)) addSection(entry.replace(".log", ""), join(logsDir, entry));
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
// no logs dir
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (source === "site" || source === "all") {
|
|
446
|
+
const config = readGroveConfig(groveHome);
|
|
447
|
+
const site = config ? resolveSite(config, ctx.cwd) : undefined;
|
|
448
|
+
if (site) {
|
|
449
|
+
try {
|
|
450
|
+
for (const entry of readdirSync(logsDir)) {
|
|
451
|
+
if (entry.startsWith(`dev-${site.name}-`) && entry.endsWith(".log")) {
|
|
452
|
+
addSection(entry.replace(".log", ""), join(logsDir, entry));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
// no logs dir
|
|
457
|
+
}
|
|
458
|
+
} else if (source === "site") {
|
|
459
|
+
lines.push("Current directory is not a Grove site; no site dev logs.");
|
|
460
|
+
lines.push("");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (source === "laravel" || source === "all") {
|
|
464
|
+
addSection("Laravel Application Log", join(ctx.cwd, "storage", "logs", "laravel.log"));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ── Tool: grove_env_sync ──
|
|
472
|
+
elyra.registerTool({
|
|
473
|
+
name: "grove_env_sync",
|
|
474
|
+
label: "Grove .env Sync Check",
|
|
475
|
+
description:
|
|
476
|
+
"Check whether the project's .env matches the Grove environment: database host and port " +
|
|
477
|
+
"(from Grove's service state), Redis host, and APP_URL against the site's Grove domain " +
|
|
478
|
+
"and TLS setting. Reports mismatches with concrete fixes.",
|
|
479
|
+
parameters: Type.Object({}),
|
|
480
|
+
execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
|
|
481
|
+
const groveHome = getGroveHome();
|
|
482
|
+
if (!groveHome) {
|
|
483
|
+
return { content: [{ type: "text", text: NOT_INSTALLED }], details: {} };
|
|
484
|
+
}
|
|
485
|
+
const envPath = join(ctx.cwd, ".env");
|
|
486
|
+
if (!existsSync(envPath)) {
|
|
487
|
+
return { content: [{ type: "text", text: "No .env file found in the current project." }], details: {} };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const config = readGroveConfig(groveHome);
|
|
491
|
+
const state = readServicesState(groveHome);
|
|
492
|
+
const env = parseEnv(readFileSync(envPath, "utf-8"));
|
|
493
|
+
const issues: string[] = [];
|
|
494
|
+
const ok: string[] = [];
|
|
495
|
+
|
|
496
|
+
if (env.DB_CONNECTION === "mysql") {
|
|
497
|
+
const host = env.DB_HOST ?? "";
|
|
498
|
+
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
499
|
+
issues.push(`DB_HOST is '${host}' -- Grove MySQL runs on 127.0.0.1. Set DB_HOST=127.0.0.1`);
|
|
500
|
+
} else {
|
|
501
|
+
ok.push("DB_HOST matches Grove MySQL");
|
|
502
|
+
}
|
|
503
|
+
const expectedPort = String(state.ports.mysql ?? 3306);
|
|
504
|
+
const port = env.DB_PORT ?? "3306";
|
|
505
|
+
if (port !== expectedPort) {
|
|
506
|
+
issues.push(`DB_PORT is '${port}' -- Grove MySQL listens on ${expectedPort}. Set DB_PORT=${expectedPort}`);
|
|
507
|
+
} else {
|
|
508
|
+
ok.push(`DB_PORT matches Grove MySQL (${expectedPort})`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (env.REDIS_HOST) {
|
|
513
|
+
if (env.REDIS_HOST !== "127.0.0.1" && env.REDIS_HOST !== "localhost") {
|
|
514
|
+
issues.push(`REDIS_HOST is '${env.REDIS_HOST}' -- Grove Redis runs on 127.0.0.1`);
|
|
515
|
+
} else {
|
|
516
|
+
ok.push("REDIS_HOST matches Grove");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (config && env.APP_URL) {
|
|
521
|
+
const site = resolveSite(config, ctx.cwd);
|
|
522
|
+
if (site) {
|
|
523
|
+
const expected = siteUrl(config, site);
|
|
524
|
+
if (env.APP_URL !== expected) {
|
|
525
|
+
issues.push(`APP_URL is '${env.APP_URL}' -- Grove serves this site at ${expected}`);
|
|
526
|
+
} else {
|
|
527
|
+
ok.push(`APP_URL matches Grove (${expected})`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const lines: string[] = ["# Grove .env Sync Check", ""];
|
|
533
|
+
if (issues.length === 0) {
|
|
534
|
+
lines.push("All checked .env values match the Grove environment.");
|
|
535
|
+
} else {
|
|
536
|
+
lines.push(`Found ${issues.length} issue${issues.length > 1 ? "s" : ""}:`, "");
|
|
537
|
+
for (const issue of issues) lines.push(`- ${issue}`);
|
|
538
|
+
}
|
|
539
|
+
if (ok.length > 0) {
|
|
540
|
+
lines.push("", "Matching:");
|
|
541
|
+
for (const item of ok) lines.push(`- ${item}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
546
|
+
details: { issues: issues.length, ok: ok.length },
|
|
547
|
+
};
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ── Commands ──
|
|
552
|
+
elyra.registerCommand("grove", {
|
|
553
|
+
description: "Show Grove dashboard -- daemon, runtimes, services, current site",
|
|
554
|
+
handler: async (_args, _ctx) => {
|
|
555
|
+
elyra.sendUserMessage(
|
|
556
|
+
"Show me the Grove status: daemon state, installed runtimes, services with ports, and which Grove site this project maps to.",
|
|
557
|
+
);
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
elyra.registerCommand("grove-logs", {
|
|
562
|
+
description: "Show recent Grove logs (services, php-fpm, site dev logs, Laravel)",
|
|
563
|
+
handler: async (_args, _ctx) => {
|
|
564
|
+
elyra.sendUserMessage("Show me the recent Grove logs for this project -- services, php-fpm, site dev logs, and the Laravel log.");
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
elyra.registerCommand("grove-sync", {
|
|
569
|
+
description: "Check if .env matches the Grove environment",
|
|
570
|
+
handler: async (_args, _ctx) => {
|
|
571
|
+
elyra.sendUserMessage("Check if my .env file matches the Grove environment and report any mismatches with fixes.");
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyracode/grove",
|
|
3
|
+
"version": "0.9.21",
|
|
4
|
+
"description": "Grove integration for Elyra — local dev environment status, sites, services, runtimes, logs, and .env sync",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"elyra-package",
|
|
8
|
+
"grove",
|
|
9
|
+
"php",
|
|
10
|
+
"node",
|
|
11
|
+
"local-development"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Knut W. Horne",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/kwhorne/elyra.git",
|
|
18
|
+
"directory": "packages/grove"
|
|
19
|
+
},
|
|
20
|
+
"elyra": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./extensions/index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@elyracode/coding-agent": "*",
|
|
27
|
+
"typebox": "*"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"clean": "echo 'nothing to clean'",
|
|
31
|
+
"build": "echo 'nothing to build'",
|
|
32
|
+
"check": "echo 'nothing to check'"
|
|
33
|
+
}
|
|
34
|
+
}
|