cue-ai 0.3.0 → 0.4.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/README.md +309 -203
- package/package.json +1 -1
- package/profiles/_types.ts +1 -1
- package/profiles/core/profile.yaml +3 -0
- package/profiles/full/profile.yaml +0 -2
- package/profiles/{readme-writer-svg → readme-writer}/profile.yaml +3 -2
- package/profiles/threejs/profile.yaml +19 -0
- package/resources/mcps/configs/claude.sanitized.json +3 -0
- package/resources/mcps/configs/claude_runtime.sanitized.json +3 -0
- package/resources/mcps/configs/codex.sanitized.json +3 -0
- package/resources/mcps/cue-tty-watch/README.md +80 -0
- package/resources/mcps/cue-tty-watch/bin/cue-tty-watch +18 -0
- package/resources/mcps/cue-tty-watch/bun.lock +198 -0
- package/resources/mcps/cue-tty-watch/package.json +17 -0
- package/resources/mcps/cue-tty-watch/server.ts +181 -0
- package/resources/skills/skills/design/headless-gif-demo/SKILL.md +168 -0
- package/resources/skills/skills/meta/kiro-powers/SKILL.md +152 -0
- package/resources/skills/skills/research/find-skills/SKILL.md +127 -102
- package/src/commands/_index.ts +8 -0
- package/src/commands/launch.ts +11 -0
- package/src/commands/list.ts +15 -5
- package/src/commands/materialize.ts +135 -0
- package/src/commands/security.ts +35 -8
- package/src/commands/share.ts +230 -0
- package/src/commands/status.ts +5 -7
- package/src/commands/tree.ts +17 -2
- package/src/lib/agent-adapters.ts +302 -0
- package/src/lib/kitty-image.ts +12 -9
- package/src/lib/runtime-materializer.ts +27 -3
- package/bin/medusa-dev +0 -240
- package/bin/soul +0 -4
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent adapters — materialize skills + MCPs for any AI coding agent.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter knows how to write skills and MCP configs in the format
|
|
5
|
+
* that a specific agent expects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, symlinkSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Interface
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface AgentAdapter {
|
|
18
|
+
/** Agent identifier (used in profile.yaml agents field) */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Human-readable name */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Where this agent reads its config from */
|
|
23
|
+
configDir(): string;
|
|
24
|
+
/** Write skills as the agent's rules/instructions file */
|
|
25
|
+
writeSkills(skills: { id: string; content: string }[], targetDir: string): void;
|
|
26
|
+
/** Write MCP server configs in the agent's format */
|
|
27
|
+
writeMcps(mcps: Record<string, unknown>, targetDir: string): void;
|
|
28
|
+
/** Try to find the agent binary on PATH */
|
|
29
|
+
detectBinary(): string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function findBinary(name: string): string | null {
|
|
37
|
+
const res = spawnSync("which", [name], { encoding: "utf8" });
|
|
38
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function concatSkills(skills: { id: string; content: string }[]): string {
|
|
42
|
+
return skills.map(s => `<!-- skill: ${s.id} -->\n${s.content}`).join("\n\n---\n\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Claude Code adapter (existing behavior)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const claudeCode: AgentAdapter = {
|
|
50
|
+
id: "claude-code",
|
|
51
|
+
name: "Claude Code",
|
|
52
|
+
configDir: () => process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"),
|
|
53
|
+
writeSkills(skills, targetDir) {
|
|
54
|
+
const skillsDir = join(targetDir, "skills");
|
|
55
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
56
|
+
// Skills are symlinked individually (handled by materializer)
|
|
57
|
+
},
|
|
58
|
+
writeMcps(mcps, targetDir) {
|
|
59
|
+
const settingsPath = join(targetDir, "settings.json");
|
|
60
|
+
let settings: Record<string, unknown> = {};
|
|
61
|
+
if (existsSync(settingsPath)) {
|
|
62
|
+
try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch {}
|
|
63
|
+
}
|
|
64
|
+
settings.mcpServers = { ...(settings.mcpServers as Record<string, unknown> ?? {}), ...mcps };
|
|
65
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
66
|
+
},
|
|
67
|
+
detectBinary: () => findBinary("claude"),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Codex adapter (existing behavior)
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export const codex: AgentAdapter = {
|
|
75
|
+
id: "codex",
|
|
76
|
+
name: "Codex",
|
|
77
|
+
configDir: () => process.env.CODEX_HOME ?? join(homedir(), ".codex"),
|
|
78
|
+
writeSkills(skills, targetDir) {
|
|
79
|
+
const skillsDir = join(targetDir, "skills");
|
|
80
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
81
|
+
},
|
|
82
|
+
writeMcps(mcps, targetDir) {
|
|
83
|
+
// Codex uses config.toml
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
for (const [id, val] of Object.entries(mcps)) {
|
|
86
|
+
lines.push(`[mcp_servers.${id}]`);
|
|
87
|
+
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
|
88
|
+
lines.push(`${k} = ${JSON.stringify(v)}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
writeFileSync(join(targetDir, "config.toml"), lines.join("\n"));
|
|
93
|
+
},
|
|
94
|
+
detectBinary: () => findBinary("codex"),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Cursor adapter
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export const cursor: AgentAdapter = {
|
|
102
|
+
id: "cursor",
|
|
103
|
+
name: "Cursor",
|
|
104
|
+
configDir: () => process.cwd(), // project-local
|
|
105
|
+
writeSkills(skills, targetDir) {
|
|
106
|
+
// Cursor reads .cursorrules in project root
|
|
107
|
+
const content = concatSkills(skills);
|
|
108
|
+
writeFileSync(join(targetDir, ".cursorrules"), content);
|
|
109
|
+
},
|
|
110
|
+
writeMcps(mcps, targetDir) {
|
|
111
|
+
// Cursor reads .cursor/mcp.json
|
|
112
|
+
const cursorDir = join(targetDir, ".cursor");
|
|
113
|
+
mkdirSync(cursorDir, { recursive: true });
|
|
114
|
+
writeFileSync(join(cursorDir, "mcp.json"), JSON.stringify({ mcpServers: mcps }, null, 2));
|
|
115
|
+
},
|
|
116
|
+
detectBinary: () => findBinary("cursor"),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Cline adapter
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export const cline: AgentAdapter = {
|
|
124
|
+
id: "cline",
|
|
125
|
+
name: "Cline",
|
|
126
|
+
configDir: () => process.cwd(),
|
|
127
|
+
writeSkills(skills, targetDir) {
|
|
128
|
+
// Cline reads .clinerules in project root
|
|
129
|
+
const content = concatSkills(skills);
|
|
130
|
+
writeFileSync(join(targetDir, ".clinerules"), content);
|
|
131
|
+
},
|
|
132
|
+
writeMcps(mcps, targetDir) {
|
|
133
|
+
// Cline reads cline_mcp_settings.json in project root
|
|
134
|
+
writeFileSync(join(targetDir, "cline_mcp_settings.json"), JSON.stringify({ mcpServers: mcps }, null, 2));
|
|
135
|
+
},
|
|
136
|
+
detectBinary: () => null, // VS Code extension, no binary
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Windsurf adapter
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export const windsurf: AgentAdapter = {
|
|
144
|
+
id: "windsurf",
|
|
145
|
+
name: "Windsurf",
|
|
146
|
+
configDir: () => process.cwd(),
|
|
147
|
+
writeSkills(skills, targetDir) {
|
|
148
|
+
const content = concatSkills(skills);
|
|
149
|
+
writeFileSync(join(targetDir, ".windsurfrules"), content);
|
|
150
|
+
},
|
|
151
|
+
writeMcps(mcps, targetDir) {
|
|
152
|
+
// Windsurf uses same format as Cursor
|
|
153
|
+
const dir = join(targetDir, ".windsurf");
|
|
154
|
+
mkdirSync(dir, { recursive: true });
|
|
155
|
+
writeFileSync(join(dir, "mcp.json"), JSON.stringify({ mcpServers: mcps }, null, 2));
|
|
156
|
+
},
|
|
157
|
+
detectBinary: () => findBinary("windsurf"),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Gemini CLI adapter
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export const gemini: AgentAdapter = {
|
|
165
|
+
id: "gemini",
|
|
166
|
+
name: "Gemini CLI",
|
|
167
|
+
configDir: () => join(homedir(), ".gemini"),
|
|
168
|
+
writeSkills(skills, targetDir) {
|
|
169
|
+
// Gemini reads skills from ~/.gemini/skills/ as individual files
|
|
170
|
+
const skillsDir = join(targetDir, "skills");
|
|
171
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
172
|
+
for (const s of skills) {
|
|
173
|
+
const slug = s.id.split("/").pop() ?? s.id;
|
|
174
|
+
writeFileSync(join(skillsDir, `${slug}.md`), s.content);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
writeMcps(mcps, targetDir) {
|
|
178
|
+
// Gemini uses settings.json with mcpServers
|
|
179
|
+
const settingsPath = join(targetDir, "settings.json");
|
|
180
|
+
let settings: Record<string, unknown> = {};
|
|
181
|
+
if (existsSync(settingsPath)) {
|
|
182
|
+
try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch {}
|
|
183
|
+
}
|
|
184
|
+
settings.mcpServers = mcps;
|
|
185
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
186
|
+
},
|
|
187
|
+
detectBinary: () => findBinary("gemini"),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// GitHub Copilot adapter
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export const copilot: AgentAdapter = {
|
|
195
|
+
id: "copilot",
|
|
196
|
+
name: "GitHub Copilot",
|
|
197
|
+
configDir: () => process.cwd(),
|
|
198
|
+
writeSkills(skills, targetDir) {
|
|
199
|
+
// Copilot reads .github/copilot-instructions.md
|
|
200
|
+
const ghDir = join(targetDir, ".github");
|
|
201
|
+
mkdirSync(ghDir, { recursive: true });
|
|
202
|
+
const content = concatSkills(skills);
|
|
203
|
+
writeFileSync(join(ghDir, "copilot-instructions.md"), content);
|
|
204
|
+
},
|
|
205
|
+
writeMcps(mcps, targetDir) {
|
|
206
|
+
// Copilot uses .vscode/mcp.json or .github/mcp.json
|
|
207
|
+
const ghDir = join(targetDir, ".github");
|
|
208
|
+
mkdirSync(ghDir, { recursive: true });
|
|
209
|
+
writeFileSync(join(ghDir, "mcp.json"), JSON.stringify({ servers: mcps }, null, 2));
|
|
210
|
+
},
|
|
211
|
+
detectBinary: () => null, // VS Code extension
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Roo Code adapter
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export const roo: AgentAdapter = {
|
|
219
|
+
id: "roo",
|
|
220
|
+
name: "Roo Code",
|
|
221
|
+
configDir: () => process.cwd(),
|
|
222
|
+
writeSkills(skills, targetDir) {
|
|
223
|
+
// Roo reads .roo/rules/
|
|
224
|
+
const rulesDir = join(targetDir, ".roo", "rules");
|
|
225
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
226
|
+
for (const s of skills) {
|
|
227
|
+
const slug = s.id.split("/").pop() ?? s.id;
|
|
228
|
+
writeFileSync(join(rulesDir, `${slug}.md`), s.content);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
writeMcps(mcps, targetDir) {
|
|
232
|
+
// Roo uses .roo/mcp.json
|
|
233
|
+
const rooDir = join(targetDir, ".roo");
|
|
234
|
+
mkdirSync(rooDir, { recursive: true });
|
|
235
|
+
writeFileSync(join(rooDir, "mcp.json"), JSON.stringify({ mcpServers: mcps }, null, 2));
|
|
236
|
+
},
|
|
237
|
+
detectBinary: () => null, // VS Code extension
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Amp adapter
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
export const amp: AgentAdapter = {
|
|
245
|
+
id: "amp",
|
|
246
|
+
name: "Amp",
|
|
247
|
+
configDir: () => process.cwd(),
|
|
248
|
+
writeSkills(skills, targetDir) {
|
|
249
|
+
// Amp reads AGENTS.md in project root
|
|
250
|
+
const content = concatSkills(skills);
|
|
251
|
+
writeFileSync(join(targetDir, "AGENTS.md"), content);
|
|
252
|
+
},
|
|
253
|
+
writeMcps(mcps, targetDir) {
|
|
254
|
+
// Amp uses .amp/mcp.json
|
|
255
|
+
const ampDir = join(targetDir, ".amp");
|
|
256
|
+
mkdirSync(ampDir, { recursive: true });
|
|
257
|
+
writeFileSync(join(ampDir, "mcp.json"), JSON.stringify({ mcpServers: mcps }, null, 2));
|
|
258
|
+
},
|
|
259
|
+
detectBinary: () => findBinary("amp"),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Aider adapter
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export const aider: AgentAdapter = {
|
|
267
|
+
id: "aider",
|
|
268
|
+
name: "Aider",
|
|
269
|
+
configDir: () => process.cwd(),
|
|
270
|
+
writeSkills(skills, targetDir) {
|
|
271
|
+
// Aider reads .aider.conf.yml conventions
|
|
272
|
+
const content = concatSkills(skills);
|
|
273
|
+
writeFileSync(join(targetDir, ".aider.conventions.md"), content);
|
|
274
|
+
},
|
|
275
|
+
writeMcps(_mcps, _targetDir) {
|
|
276
|
+
// Aider doesn't support MCP
|
|
277
|
+
},
|
|
278
|
+
detectBinary: () => findBinary("aider"),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Registry
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
export const ADAPTERS: Record<string, AgentAdapter> = {
|
|
286
|
+
"claude-code": claudeCode,
|
|
287
|
+
"codex": codex,
|
|
288
|
+
"cursor": cursor,
|
|
289
|
+
"cline": cline,
|
|
290
|
+
"windsurf": windsurf,
|
|
291
|
+
"gemini": gemini,
|
|
292
|
+
"copilot": copilot,
|
|
293
|
+
"roo": roo,
|
|
294
|
+
"amp": amp,
|
|
295
|
+
"aider": aider,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export const AGENT_IDS = Object.keys(ADAPTERS);
|
|
299
|
+
|
|
300
|
+
export function getAdapter(id: string): AgentAdapter | null {
|
|
301
|
+
return ADAPTERS[id] ?? null;
|
|
302
|
+
}
|
package/src/lib/kitty-image.ts
CHANGED
|
@@ -339,22 +339,25 @@ export function probeKittyTerminal(timeoutMs = 100): Promise<boolean> {
|
|
|
339
339
|
* Use this from the launch hot path; `isKittyTerminal()` is the env-only
|
|
340
340
|
* sync version kept around for callers that can't await.
|
|
341
341
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
342
|
+
* Trust strong env signals (KITTY_WINDOW_ID, TERM=xterm-kitty) directly —
|
|
343
|
+
* these are set by Kitty itself and reliable. Only probe when signals are
|
|
344
|
+
* ambiguous (e.g. inside tmux with no Kitty env vars).
|
|
345
345
|
*/
|
|
346
346
|
export async function detectKittyTerminal(timeoutMs = 100): Promise<boolean> {
|
|
347
347
|
if (process.env.CUE_DISABLE_KITTY_IMAGES === "1") return false;
|
|
348
|
+
if (process.env.CUE_KITTY === "1") return true;
|
|
349
|
+
|
|
350
|
+
// Strong signals — trust directly (Kitty sets these for child processes)
|
|
351
|
+
if (process.env.TERM === "xterm-kitty") return true;
|
|
352
|
+
if (process.env.KITTY_WINDOW_ID) return true;
|
|
353
|
+
if (process.env.KITTY_PID) return true;
|
|
348
354
|
|
|
349
|
-
//
|
|
350
|
-
// Env vars like KITTY_WINDOW_ID leak through tmux/child terminals.
|
|
355
|
+
// Weak/ambiguous — probe the terminal
|
|
351
356
|
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
352
357
|
return probeKittyTerminal(timeoutMs);
|
|
353
358
|
}
|
|
354
359
|
|
|
355
|
-
// Non-TTY fallback
|
|
356
|
-
if (process.env.
|
|
357
|
-
if (process.env.TERM === "xterm-kitty") return true;
|
|
358
|
-
if (process.env.KITTY_WINDOW_ID) return true;
|
|
360
|
+
// Non-TTY fallback
|
|
361
|
+
if (process.env.TERM_PROGRAM === "kitty") return true;
|
|
359
362
|
return false;
|
|
360
363
|
}
|
|
@@ -195,6 +195,19 @@ export async function materializeRuntime(input: MaterializeInput): Promise<Mater
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
// 6. Atomic swap: rm -rf old, rename tmp.
|
|
198
|
+
// Preserve .claude.json and backups/ — Claude Code writes session state here
|
|
199
|
+
// and resume depends on it surviving across rematerializations.
|
|
200
|
+
const preserveFiles = [".claude.json", "backups"];
|
|
201
|
+
for (const name of preserveFiles) {
|
|
202
|
+
const oldPath = join(runtimeDir, name);
|
|
203
|
+
const newPath = join(tmpDir, name);
|
|
204
|
+
try {
|
|
205
|
+
const st = await lstat(oldPath);
|
|
206
|
+
if (st.isFile() || st.isDirectory()) {
|
|
207
|
+
await rename(oldPath, newPath);
|
|
208
|
+
}
|
|
209
|
+
} catch { /* doesn't exist — skip */ }
|
|
210
|
+
}
|
|
198
211
|
await rm(runtimeDir, { recursive: true, force: true });
|
|
199
212
|
await rename(tmpDir, runtimeDir);
|
|
200
213
|
|
|
@@ -255,9 +268,20 @@ async function overlaySourceState(targetDir: string, sourceDir: string): Promise
|
|
|
255
268
|
await rm(targetPath, { force: true });
|
|
256
269
|
} catch { continue; }
|
|
257
270
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
271
|
+
|
|
272
|
+
// .credentials.json must be COPIED (not symlinked) because Claude Code
|
|
273
|
+
// refreshes tokens via atomic write (write tmp → rename), which replaces
|
|
274
|
+
// symlinks with regular files, leaving the source stale.
|
|
275
|
+
if (name === ".credentials.json") {
|
|
276
|
+
const { copyFile } = await import("node:fs/promises");
|
|
277
|
+
try {
|
|
278
|
+
await copyFile(sourcePath, targetPath);
|
|
279
|
+
} catch { /* skip */ }
|
|
280
|
+
} else {
|
|
281
|
+
try {
|
|
282
|
+
await symlink(sourcePath, targetPath);
|
|
283
|
+
} catch { /* race or permission — skip silently */ }
|
|
284
|
+
}
|
|
261
285
|
}
|
|
262
286
|
}
|
|
263
287
|
|
package/bin/medusa-dev
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# medusa-dev — run multiple Medusa shops side-by-side without port collisions.
|
|
3
|
-
#
|
|
4
|
-
# Reads ~/Documents/medusa-shops/.dev-ports.yaml as the source of truth.
|
|
5
|
-
# Each shop gets a deterministic backend port + storefront port from that file.
|
|
6
|
-
#
|
|
7
|
-
# Usage:
|
|
8
|
-
# medusa-dev list # show registry + which ports are live now
|
|
9
|
-
# medusa-dev start <shop> [back|front|both] # default: both
|
|
10
|
-
# medusa-dev stop <shop> [back|front|both]
|
|
11
|
-
# medusa-dev tail <shop> [back|front] # tail the log
|
|
12
|
-
# medusa-dev status # what's running across all shops
|
|
13
|
-
# medusa-dev cors-fix <shop> # set STORE_CORS/AUTH_CORS to a regex
|
|
14
|
-
# # that allows any http://localhost:PORT
|
|
15
|
-
#
|
|
16
|
-
# Logs go to ~/.cache/medusa-dev/<shop>.<back|front>.log
|
|
17
|
-
# Pidfiles to ~/.cache/medusa-dev/<shop>.<back|front>.pid
|
|
18
|
-
|
|
19
|
-
set -euo pipefail
|
|
20
|
-
|
|
21
|
-
REGISTRY="${MEDUSA_DEV_REGISTRY:-$HOME/Documents/medusa-shops/.dev-ports.yaml}"
|
|
22
|
-
WORKSPACE="${MEDUSA_DEV_WORKSPACE:-$HOME/Documents}"
|
|
23
|
-
CACHE="$HOME/.cache/medusa-dev"
|
|
24
|
-
mkdir -p "$CACHE"
|
|
25
|
-
|
|
26
|
-
err() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
|
|
27
|
-
info() { printf '\033[36m%s\033[0m\n' "$*" >&2; }
|
|
28
|
-
ok() { printf '\033[32m%s\033[0m\n' "$*" >&2; }
|
|
29
|
-
|
|
30
|
-
# Parse the YAML using inline python3 (always available on the user's machine).
|
|
31
|
-
# Returns a TSV: shop<TAB>path<TAB>backend_port<TAB>storefront_port<TAB>framework<TAB>notes
|
|
32
|
-
registry_tsv() {
|
|
33
|
-
python3 - "$REGISTRY" <<'PY'
|
|
34
|
-
import sys, re
|
|
35
|
-
path = sys.argv[1]
|
|
36
|
-
with open(path) as f: txt = f.read()
|
|
37
|
-
|
|
38
|
-
# Tiny YAML walker — no PyYAML dependency. Handles the flat 2-level shape
|
|
39
|
-
# only: `shops:` then `<name>:` with indented `key: value` children.
|
|
40
|
-
out, cur = [], None
|
|
41
|
-
def flush():
|
|
42
|
-
if cur is not None:
|
|
43
|
-
out.append((cur["name"], cur.get("path",""), cur.get("backend",""),
|
|
44
|
-
cur.get("storefront",""), cur.get("framework",""),
|
|
45
|
-
cur.get("notes","").strip('"')))
|
|
46
|
-
for raw in txt.splitlines():
|
|
47
|
-
line = raw.rstrip()
|
|
48
|
-
if not line.strip() or line.lstrip().startswith("#"): continue
|
|
49
|
-
if line == "shops:": continue
|
|
50
|
-
if re.match(r"^ [A-Za-z0-9_.-]+:\s*$", line):
|
|
51
|
-
flush()
|
|
52
|
-
cur = {"name": line.strip().rstrip(":")}
|
|
53
|
-
elif cur is not None and re.match(r"^ [a-z_]+:", line):
|
|
54
|
-
k,_,v = line.strip().partition(":")
|
|
55
|
-
cur[k.strip()] = v.strip()
|
|
56
|
-
flush()
|
|
57
|
-
for row in out:
|
|
58
|
-
print("\t".join(str(c) for c in row))
|
|
59
|
-
PY
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
# Look up a single shop. Echoes a TSV line, or returns nonzero if not found.
|
|
63
|
-
shop_row() {
|
|
64
|
-
local name="$1"
|
|
65
|
-
registry_tsv | awk -F'\t' -v n="$name" '$1==n {print; found=1} END{exit !found}'
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
# Process status — is the pidfile alive?
|
|
69
|
-
is_alive() {
|
|
70
|
-
local pidfile="$1"
|
|
71
|
-
[[ -f "$pidfile" ]] || return 1
|
|
72
|
-
local pid; pid="$(cat "$pidfile" 2>/dev/null || true)"
|
|
73
|
-
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
# Is a port currently held by *any* process?
|
|
77
|
-
port_held() {
|
|
78
|
-
ss -lntH "sport = :$1" 2>/dev/null | grep -q LISTEN
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
cmd_list() {
|
|
82
|
-
printf "%-16s %-7s %-7s %-7s %-7s %s\n" SHOP BACKEND STATE FRONT STATE PATH
|
|
83
|
-
while IFS=$'\t' read -r name path back front fw notes; do
|
|
84
|
-
[[ -z "${name:-}" ]] && continue
|
|
85
|
-
local bstate fstate
|
|
86
|
-
if is_alive "$CACHE/$name.back.pid"; then bstate="up"
|
|
87
|
-
elif port_held "$back"; then bstate="busy"
|
|
88
|
-
else bstate="-"; fi
|
|
89
|
-
if is_alive "$CACHE/$name.front.pid"; then fstate="up"
|
|
90
|
-
elif port_held "$front"; then fstate="busy"
|
|
91
|
-
else fstate="-"; fi
|
|
92
|
-
printf "%-16s %-7s %-7s %-7s %-7s %s\n" "$name" "$back" "$bstate" "$front" "$fstate" "$path"
|
|
93
|
-
done < <(registry_tsv)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
cmd_status() { cmd_list; }
|
|
97
|
-
|
|
98
|
-
# Start one role of one shop.
|
|
99
|
-
start_one() {
|
|
100
|
-
local shop="$1" role="$2" path="$3" port="$4"
|
|
101
|
-
local cwd="$WORKSPACE/$path"
|
|
102
|
-
local pidfile="$CACHE/$shop.$role.pid"
|
|
103
|
-
local logfile="$CACHE/$shop.$role.log"
|
|
104
|
-
|
|
105
|
-
if is_alive "$pidfile"; then
|
|
106
|
-
info "$shop.$role already running (pid $(cat "$pidfile"))."
|
|
107
|
-
return 0
|
|
108
|
-
fi
|
|
109
|
-
if port_held "$port"; then
|
|
110
|
-
local owner
|
|
111
|
-
owner=$(ss -lntpH "sport = :$port" 2>/dev/null | awk -F'"' '{print $2}' | head -1)
|
|
112
|
-
err "Port $port already in use by '$owner' — won't collide. Stop it or change the registry."
|
|
113
|
-
return 1
|
|
114
|
-
fi
|
|
115
|
-
if [[ ! -d "$cwd" ]]; then
|
|
116
|
-
err "Path missing: $cwd"
|
|
117
|
-
return 1
|
|
118
|
-
fi
|
|
119
|
-
|
|
120
|
-
local script
|
|
121
|
-
case "$role" in
|
|
122
|
-
back) script="--filter backend dev" ;;
|
|
123
|
-
front) script="--filter storefront dev" ;;
|
|
124
|
-
*) err "unknown role: $role"; return 1 ;;
|
|
125
|
-
esac
|
|
126
|
-
|
|
127
|
-
info "Starting $shop.$role on port $port → $cwd"
|
|
128
|
-
( cd "$cwd" && PORT="$port" nohup pnpm $script > "$logfile" 2>&1 & echo $! > "$pidfile" )
|
|
129
|
-
ok "$shop.$role started (pid $(cat "$pidfile"), log: $logfile)"
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
cmd_start() {
|
|
133
|
-
local shop="${1:?shop name required}" which="${2:-both}"
|
|
134
|
-
local row; row="$(shop_row "$shop")" || { err "unknown shop: $shop"; exit 1; }
|
|
135
|
-
IFS=$'\t' read -r name path back front _fw _notes <<<"$row"
|
|
136
|
-
case "$which" in
|
|
137
|
-
back) start_one "$shop" back "$path" "$back" ;;
|
|
138
|
-
front) start_one "$shop" front "$path" "$front" ;;
|
|
139
|
-
both) start_one "$shop" back "$path" "$back" || true
|
|
140
|
-
start_one "$shop" front "$path" "$front" || true ;;
|
|
141
|
-
*) err "expected back|front|both, got: $which"; exit 1 ;;
|
|
142
|
-
esac
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
stop_one() {
|
|
146
|
-
local shop="$1" role="$2"
|
|
147
|
-
local pidfile="$CACHE/$shop.$role.pid"
|
|
148
|
-
if is_alive "$pidfile"; then
|
|
149
|
-
local pid; pid="$(cat "$pidfile")"
|
|
150
|
-
info "Stopping $shop.$role (pid $pid)"
|
|
151
|
-
kill "$pid" 2>/dev/null || true
|
|
152
|
-
# Give it 5s for graceful, then SIGKILL the whole process group.
|
|
153
|
-
for _ in 1 2 3 4 5; do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
|
|
154
|
-
if kill -0 "$pid" 2>/dev/null; then
|
|
155
|
-
kill -9 -- "-$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true
|
|
156
|
-
fi
|
|
157
|
-
rm -f "$pidfile"
|
|
158
|
-
ok "$shop.$role stopped"
|
|
159
|
-
else
|
|
160
|
-
info "$shop.$role not running"
|
|
161
|
-
rm -f "$pidfile" 2>/dev/null || true
|
|
162
|
-
fi
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
cmd_stop() {
|
|
166
|
-
local shop="${1:?shop name required}" which="${2:-both}"
|
|
167
|
-
shop_row "$shop" >/dev/null || { err "unknown shop: $shop"; exit 1; }
|
|
168
|
-
case "$which" in
|
|
169
|
-
back) stop_one "$shop" back ;;
|
|
170
|
-
front) stop_one "$shop" front ;;
|
|
171
|
-
both) stop_one "$shop" back; stop_one "$shop" front ;;
|
|
172
|
-
*) err "expected back|front|both, got: $which"; exit 1 ;;
|
|
173
|
-
esac
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
cmd_tail() {
|
|
177
|
-
local shop="${1:?shop name required}" role="${2:-back}"
|
|
178
|
-
local logfile="$CACHE/$shop.$role.log"
|
|
179
|
-
[[ -f "$logfile" ]] || { err "no log at $logfile"; exit 1; }
|
|
180
|
-
exec tail -f "$logfile"
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
# Loosen CORS in a shop's backend .env so any localhost port is accepted.
|
|
184
|
-
cmd_cors_fix() {
|
|
185
|
-
local shop="${1:?shop name required}"
|
|
186
|
-
local row; row="$(shop_row "$shop")" || { err "unknown shop: $shop"; exit 1; }
|
|
187
|
-
IFS=$'\t' read -r name path _back _front _fw _notes <<<"$row"
|
|
188
|
-
local envfile="$WORKSPACE/$path/apps/backend/.env"
|
|
189
|
-
[[ -f "$envfile" ]] || { err "no backend .env at $envfile"; exit 1; }
|
|
190
|
-
cp "$envfile" "$envfile.bak.$(date +%s)"
|
|
191
|
-
python3 - "$envfile" <<'PY'
|
|
192
|
-
import sys, re
|
|
193
|
-
p = sys.argv[1]
|
|
194
|
-
regex = "^http://localhost:\\d+$"
|
|
195
|
-
with open(p) as f: lines = f.readlines()
|
|
196
|
-
keys = ("STORE_CORS","AUTH_CORS","ADMIN_CORS")
|
|
197
|
-
seen = {k: False for k in keys}
|
|
198
|
-
out = []
|
|
199
|
-
for ln in lines:
|
|
200
|
-
m = re.match(r"^(STORE_CORS|AUTH_CORS|ADMIN_CORS)=", ln)
|
|
201
|
-
if m:
|
|
202
|
-
out.append(f"{m.group(1)}={regex}\n")
|
|
203
|
-
seen[m.group(1)] = True
|
|
204
|
-
else:
|
|
205
|
-
out.append(ln)
|
|
206
|
-
for k, ok in seen.items():
|
|
207
|
-
if not ok:
|
|
208
|
-
out.append(f"{k}={regex}\n")
|
|
209
|
-
with open(p, "w") as f: f.writelines(out)
|
|
210
|
-
print("Updated:", ", ".join(seen.keys()))
|
|
211
|
-
PY
|
|
212
|
-
ok "Backup saved alongside $envfile.bak.<ts>"
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
usage() {
|
|
216
|
-
cat <<EOF
|
|
217
|
-
medusa-dev — run multiple Medusa shops side-by-side on stable per-shop ports.
|
|
218
|
-
|
|
219
|
-
medusa-dev list # show registry + live state
|
|
220
|
-
medusa-dev status # alias for list
|
|
221
|
-
medusa-dev start <shop> [back|front|both]
|
|
222
|
-
medusa-dev stop <shop> [back|front|both]
|
|
223
|
-
medusa-dev tail <shop> [back|front]
|
|
224
|
-
medusa-dev cors-fix <shop> # set CORS keys to localhost regex
|
|
225
|
-
|
|
226
|
-
Registry: $REGISTRY
|
|
227
|
-
Cache: $CACHE
|
|
228
|
-
EOF
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
case "${1:-list}" in
|
|
232
|
-
list|ls) shift; cmd_list "$@" ;;
|
|
233
|
-
status) shift; cmd_status "$@" ;;
|
|
234
|
-
start|up) shift; cmd_start "$@" ;;
|
|
235
|
-
stop|down|kill) shift; cmd_stop "$@" ;;
|
|
236
|
-
tail|log|logs) shift; cmd_tail "$@" ;;
|
|
237
|
-
cors-fix) shift; cmd_cors_fix "$@" ;;
|
|
238
|
-
-h|--help|help) usage ;;
|
|
239
|
-
*) err "unknown command: $1"; usage; exit 1 ;;
|
|
240
|
-
esac
|
package/bin/soul
DELETED