@splinterzzz/ouro 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/LICENSE +21 -0
- package/dashboard-dist/assets/index-ETSLKv6w.css +1 -0
- package/dashboard-dist/assets/index-UHUWnWxM.js +63 -0
- package/dashboard-dist/favicon.svg +35 -0
- package/dashboard-dist/index.html +18 -0
- package/package.json +54 -0
- package/src/commands/dashboard.js +50 -0
- package/src/commands/init.js +75 -0
- package/src/commands/listen.js +164 -0
- package/src/commands/logs.js +91 -0
- package/src/commands/start.js +158 -0
- package/src/commands/status.js +69 -0
- package/src/commands/stop.js +39 -0
- package/src/index.js +81 -0
- package/src/lib/agentBackend.js +41 -0
- package/src/lib/agents.js +251 -0
- package/src/lib/claudeCodeExec.js +213 -0
- package/src/lib/codexExec.js +204 -0
- package/src/lib/config.js +91 -0
- package/src/lib/daemon.js +235 -0
- package/src/lib/env.js +135 -0
- package/src/lib/frontmatter.js +114 -0
- package/src/lib/github.js +60 -0
- package/src/lib/intake.js +151 -0
- package/src/lib/paths.js +57 -0
- package/src/lib/runs.js +55 -0
- package/src/lib/ship.js +114 -0
- package/src/lib/store.js +188 -0
- package/src/lib/telegram.js +60 -0
- package/src/lib/worktree.js +93 -0
- package/src/server/index.js +457 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
|
+
<meta name="color-scheme" content="dark" />
|
|
8
|
+
<!-- Paints the browser's own surfaces to match --bg, so the window doesn't
|
|
9
|
+
flash white around a near-black app on load. -->
|
|
10
|
+
<meta name="theme-color" content="#0b0a12" />
|
|
11
|
+
<title>ouro</title>
|
|
12
|
+
<script type="module" crossorigin src="/assets/index-UHUWnWxM.js"></script>
|
|
13
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ETSLKv6w.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="root"></div>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@splinterzzz/ouro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Loop engineering CLI: repo-rooted kanban + agent loop, powered by Claude Code / Codex headless mode (no API key required).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"agent",
|
|
10
|
+
"kanban",
|
|
11
|
+
"cli",
|
|
12
|
+
"code-agent",
|
|
13
|
+
"git-worktree",
|
|
14
|
+
"dashboard"
|
|
15
|
+
],
|
|
16
|
+
"author": "Alex Galedo",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"homepage": "https://github.com/AlexGaledo/Ouro-CLI#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/AlexGaledo/Ouro-CLI.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/AlexGaledo/Ouro-CLI/issues"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"ouro": "src/index.js"
|
|
32
|
+
},
|
|
33
|
+
"main": "./src/index.js",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"dashboard-dist"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"prepublishOnly": "npm --prefix ../dashboard run build && node ../../scripts/bundle-dashboard.js"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"commander": "^12.1.0",
|
|
46
|
+
"express": "^4.19.2",
|
|
47
|
+
"ws": "^8.18.0",
|
|
48
|
+
"chalk": "^5.3.0",
|
|
49
|
+
"nanoid": "^5.0.7",
|
|
50
|
+
"open": "^10.1.0",
|
|
51
|
+
"node-telegram-bot-api": "^0.66.0",
|
|
52
|
+
"simple-git": "^3.25.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import { createServer } from "../server/index.js";
|
|
4
|
+
import { isInitialized } from "../lib/paths.js";
|
|
5
|
+
import { seedDefaultAgents } from "../lib/agents.js";
|
|
6
|
+
import { store } from "../lib/store.js";
|
|
7
|
+
import * as runs from "../lib/runs.js";
|
|
8
|
+
|
|
9
|
+
export async function dashboardCommand(opts) {
|
|
10
|
+
if (!isInitialized()) {
|
|
11
|
+
console.log(chalk.yellow("This repo isn't initialized yet. Run ") + chalk.cyan("ouro init") + chalk.yellow(" first."));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A repo initialized before agents existed has no .ouro/agents/, and a board
|
|
16
|
+
// with no agent to run as is a dead board. Cheap to top up on every boot.
|
|
17
|
+
seedDefaultAgents();
|
|
18
|
+
|
|
19
|
+
const port = Number(opts.port) || 4747;
|
|
20
|
+
const server = createServer();
|
|
21
|
+
|
|
22
|
+
server.on("error", (err) => {
|
|
23
|
+
if (err.code === "EADDRINUSE") {
|
|
24
|
+
console.log(chalk.red(`Port ${port} is already in use.`) + ` Try ${chalk.cyan(`ouro dashboard --port ${port + 1}`)}.`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
server.listen(port, () => {
|
|
31
|
+
const url = `http://localhost:${port}`;
|
|
32
|
+
console.log(chalk.green("✔ ouro dashboard running at ") + chalk.cyan(url));
|
|
33
|
+
if (opts.open !== false) open(url);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Ctrl-C with a run in flight would otherwise orphan the child CLI process
|
|
37
|
+
// and drop whatever the debounced store write hadn't flushed yet.
|
|
38
|
+
let closing = false;
|
|
39
|
+
const shutdown = () => {
|
|
40
|
+
if (closing) return;
|
|
41
|
+
closing = true;
|
|
42
|
+
console.log(chalk.gray("\nShutting down — stopping agent runs…"));
|
|
43
|
+
runs.cancelAll();
|
|
44
|
+
store.flush();
|
|
45
|
+
server.close(() => process.exit(0));
|
|
46
|
+
setTimeout(() => process.exit(0), 2000).unref(); // don't hang on a stuck socket
|
|
47
|
+
};
|
|
48
|
+
process.on("SIGINT", shutdown);
|
|
49
|
+
process.on("SIGTERM", shutdown);
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureOuroDir, isInitialized, agentsDir, ouroDir } from "../lib/paths.js";
|
|
5
|
+
import { writeConfig } from "../lib/config.js";
|
|
6
|
+
import { seedDefaultAgents } from "../lib/agents.js";
|
|
7
|
+
|
|
8
|
+
// What inside .ouro/ is machine state vs. yours.
|
|
9
|
+
//
|
|
10
|
+
// agents/*.md and config.json are meant to be committed — that's the whole
|
|
11
|
+
// point of agents-as-markdown. Everything here is either a secret (.env holds
|
|
12
|
+
// a bot token), a scratch checkout, or per-machine runtime noise.
|
|
13
|
+
const GITIGNORE = `# ouro runtime state — not yours to commit.
|
|
14
|
+
# agents/*.md and config.json are deliberately NOT ignored: they're the
|
|
15
|
+
# reviewable config this tool exists to give you.
|
|
16
|
+
.env
|
|
17
|
+
run/
|
|
18
|
+
logs/
|
|
19
|
+
worktrees/
|
|
20
|
+
tickets.json
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
function writeGitignore() {
|
|
24
|
+
const file = path.join(ouroDir(), ".gitignore");
|
|
25
|
+
if (fs.existsSync(file)) return false;
|
|
26
|
+
fs.writeFileSync(file, GITIGNORE);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function initCommand(opts) {
|
|
31
|
+
if (isInitialized()) {
|
|
32
|
+
console.log(chalk.yellow("ouro is already initialized in this repo (.ouro/config.json exists)."));
|
|
33
|
+
// Still top up agents and the gitignore — an install that predates either
|
|
34
|
+
// feature has a config but neither, and would otherwise stay broken.
|
|
35
|
+
const seeded = seedDefaultAgents();
|
|
36
|
+
if (seeded) console.log(chalk.green(`✔ Added ${seeded} default agents in ${agentsDir()}`));
|
|
37
|
+
if (writeGitignore()) console.log(chalk.green("✔ Added .ouro/.gitignore"));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ensureOuroDir();
|
|
42
|
+
writeGitignore();
|
|
43
|
+
|
|
44
|
+
const backend = opts?.backend === "codex" ? "codex" : "claude-code";
|
|
45
|
+
|
|
46
|
+
writeConfig({
|
|
47
|
+
version: 1,
|
|
48
|
+
backend, // "claude-code" | "codex" — switchable anytime from the dashboard header
|
|
49
|
+
telegram: {
|
|
50
|
+
botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
|
|
51
|
+
chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
|
|
52
|
+
},
|
|
53
|
+
defaultMode: "human", // "agent" | "human" — safe default is human-in-the-loop
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const seeded = seedDefaultAgents();
|
|
57
|
+
|
|
58
|
+
console.log(chalk.green("✔ ouro initialized.") + ` Created .ouro/config.json (backend: ${backend})`);
|
|
59
|
+
console.log(chalk.green(`✔ Seeded ${seeded} agents`) + ` in .ouro/agents/ — plain markdown, edit them in your editor.`);
|
|
60
|
+
console.log("");
|
|
61
|
+
console.log("Next steps:");
|
|
62
|
+
console.log(" 1. Run " + chalk.cyan("ouro start") + " — dashboard + intake agent, in the background.");
|
|
63
|
+
console.log(
|
|
64
|
+
" 2. For Telegram intake, paste your @BotFather token into the dashboard's " +
|
|
65
|
+
chalk.cyan("Settings") +
|
|
66
|
+
" screen."
|
|
67
|
+
);
|
|
68
|
+
console.log(
|
|
69
|
+
chalk.gray(" It's checked against Telegram, written to ") +
|
|
70
|
+
chalk.cyan(".ouro/.env") +
|
|
71
|
+
chalk.gray(" (gitignored), and the bot starts.")
|
|
72
|
+
);
|
|
73
|
+
console.log(chalk.gray(" Prefer the terminal? ") + chalk.cyan("echo 'OURO_TELEGRAM_BOT_TOKEN=<token>' >> .ouro/.env") + chalk.gray(" then ") + chalk.cyan("ouro restart"));
|
|
74
|
+
console.log(" 3. Check on them anytime with " + chalk.cyan("ouro status") + " or " + chalk.cyan("ouro logs -f") + ".");
|
|
75
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
3
|
+
import { isInitialized } from "../lib/paths.js";
|
|
4
|
+
import { telegramTokenVar } from "../lib/config.js";
|
|
5
|
+
import * as intake from "../lib/intake.js";
|
|
6
|
+
|
|
7
|
+
// Runs as a separate process from `ouro dashboard` by design — it only talks
|
|
8
|
+
// to the dashboard over its local HTTP API (POST /api/tickets), so the
|
|
9
|
+
// dashboard's in-memory store/WS broadcast stays the single source of truth
|
|
10
|
+
// and this process can be restarted independently without losing state.
|
|
11
|
+
//
|
|
12
|
+
// The bot is a customer-facing intake agent, not a webhook: it interviews the
|
|
13
|
+
// reporter (lib/intake.js), shows them the drafted ticket, and only posts to
|
|
14
|
+
// the board once they confirm. Nothing reaches the dashboard un-clarified.
|
|
15
|
+
|
|
16
|
+
const HELP = [
|
|
17
|
+
"I'm the ouro intake agent. Tell me what's broken or what you need built,",
|
|
18
|
+
"and I'll ask a couple of questions before writing it up as a ticket.",
|
|
19
|
+
"",
|
|
20
|
+
"/new — start over",
|
|
21
|
+
"/cancel — drop the current conversation",
|
|
22
|
+
].join("\n");
|
|
23
|
+
|
|
24
|
+
export async function listenCommand() {
|
|
25
|
+
if (!isInitialized()) {
|
|
26
|
+
console.log(chalk.yellow("Run ") + chalk.cyan("ouro init") + chalk.yellow(" first."));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { name: tokenVar, error: tokenVarError } = telegramTokenVar();
|
|
31
|
+
if (tokenVarError) for (const line of tokenVarError.split("\n")) console.error(chalk.yellow(line));
|
|
32
|
+
|
|
33
|
+
const token = process.env[tokenVar];
|
|
34
|
+
const dashboardUrl = process.env.OURO_DASHBOARD_URL || "http://localhost:4747";
|
|
35
|
+
|
|
36
|
+
if (!token) {
|
|
37
|
+
console.log(
|
|
38
|
+
chalk.red(`Missing ${tokenVar}. `) +
|
|
39
|
+
"Paste your token into the dashboard's Settings screen, or set the env var in .ouro/.env, then re-run."
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate the token before polling. A bad token doesn't crash
|
|
45
|
+
// node-telegram-bot-api — it retries 401 forever, so the process looks
|
|
46
|
+
// perfectly healthy to `ouro status` while silently receiving nothing. For a
|
|
47
|
+
// service meant to run unattended for days that's the worst failure mode
|
|
48
|
+
// available, so we fail loudly at startup instead.
|
|
49
|
+
const bot = new TelegramBot(token, { polling: false });
|
|
50
|
+
let me;
|
|
51
|
+
try {
|
|
52
|
+
me = await bot.getMe();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(chalk.red("Telegram rejected the bot token: ") + err.message);
|
|
55
|
+
console.error(chalk.gray(`Check ${tokenVar} in .ouro/.env — get a fresh one from @BotFather.`));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await bot.startPolling();
|
|
60
|
+
console.log(
|
|
61
|
+
chalk.green(`✔ ouro intake agent listening as @${me.username}.`) + ` Tickets post to ${dashboardUrl}`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const send = (chatId, text, opts) => bot.sendMessage(chatId, text, opts).catch((err) => {
|
|
65
|
+
console.error(chalk.red("[telegram] send failed:"), err.message);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function createTicket(chatId, draft) {
|
|
69
|
+
const res = await fetch(`${dashboardUrl}/api/tickets`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
title: draft.title,
|
|
74
|
+
body: draft.body,
|
|
75
|
+
summary: draft.summary,
|
|
76
|
+
priority: draft.priority,
|
|
77
|
+
source: "telegram",
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) throw new Error(`dashboard responded ${res.status}`);
|
|
81
|
+
return res.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
bot.on("message", async (msg) => {
|
|
85
|
+
const chatId = msg.chat.id;
|
|
86
|
+
const text = msg.text?.trim();
|
|
87
|
+
if (!text) return;
|
|
88
|
+
|
|
89
|
+
console.log(chalk.gray(`[telegram] ${text}`));
|
|
90
|
+
|
|
91
|
+
if (text === "/start" || text === "/help") {
|
|
92
|
+
intake.reset(chatId);
|
|
93
|
+
return send(chatId, HELP);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (text === "/new" || text === "/cancel") {
|
|
97
|
+
const had = intake.hasSession(chatId);
|
|
98
|
+
intake.reset(chatId);
|
|
99
|
+
return send(chatId, had ? "Dropped that. What do you need?" : "Nothing in progress. What do you need?");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A pending draft means the last thing we sent was "create this ticket?",
|
|
103
|
+
// so this message is the answer — not a new interview turn.
|
|
104
|
+
const pending = intake.getDraft(chatId);
|
|
105
|
+
if (pending) {
|
|
106
|
+
const answer = text.toLowerCase().replace(/[.!]$/, "");
|
|
107
|
+
|
|
108
|
+
if (intake.AFFIRMATIVE.has(answer)) {
|
|
109
|
+
try {
|
|
110
|
+
const ticket = await createTicket(chatId, pending);
|
|
111
|
+
intake.reset(chatId);
|
|
112
|
+
return send(chatId, `Ticket created — ${ticket.title} (#${ticket.id}). It's on the board now.`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(chalk.red("Failed to reach dashboard:"), err.message);
|
|
115
|
+
return send(chatId, "Couldn't reach the ouro dashboard — is it running? Say 'yes' to retry.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (intake.NEGATIVE.has(answer)) {
|
|
120
|
+
intake.reset(chatId);
|
|
121
|
+
return send(chatId, "Dropped it. Tell me what you'd like instead.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Anything else is a correction ("no, it's only on mobile") — feed it
|
|
125
|
+
// back into the interview rather than treating it as yes/no.
|
|
126
|
+
intake.reset(chatId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
bot.sendChatAction(chatId, "typing").catch(() => {});
|
|
131
|
+
const result = await intake.next(chatId, text);
|
|
132
|
+
|
|
133
|
+
if (result.action === "ask") {
|
|
134
|
+
return send(chatId, result.question);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const preamble = result.draft.degraded
|
|
138
|
+
? "I couldn't reach the model to interview you properly, so here's the raw version:"
|
|
139
|
+
: "Here's what I've got:";
|
|
140
|
+
|
|
141
|
+
return send(chatId, `${preamble}\n\n${intake.renderDraft(result.draft)}\n\nCreate this ticket? (yes / no)`, {
|
|
142
|
+
parse_mode: "Markdown",
|
|
143
|
+
});
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error(chalk.red("[intake] failed:"), err.message);
|
|
146
|
+
return send(chatId, "Something went wrong on my side. Try again, or /new to start over.");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// A token revoked while we're running lands here, not at startup. 401/404
|
|
151
|
+
// never recovers by retrying, so exiting is the honest move — it makes
|
|
152
|
+
// `ouro status` show the service as down instead of spinning on a hot loop
|
|
153
|
+
// that will never receive a message.
|
|
154
|
+
bot.on("polling_error", (err) => {
|
|
155
|
+
const fatal = /\b(401|404)\b/.test(err.message ?? "");
|
|
156
|
+
if (fatal) {
|
|
157
|
+
console.error(chalk.red("[telegram] token rejected mid-run: ") + err.message);
|
|
158
|
+
console.error(chalk.gray("Stopping — fix the token in .ouro/.env, then: ouro restart"));
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
// Transient (network drop, Telegram 5xx): the library retries on its own.
|
|
162
|
+
console.error(chalk.yellow("[telegram] polling error:"), err.message);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { SERVICES, logFile, tailLog } from "../lib/daemon.js";
|
|
4
|
+
|
|
5
|
+
// `ouro logs [service] [-f]` — a background service's stdout has to land
|
|
6
|
+
// somewhere reachable, or the daemon is a black box.
|
|
7
|
+
|
|
8
|
+
const COLORS = { dashboard: chalk.cyan, listen: chalk.magenta };
|
|
9
|
+
|
|
10
|
+
function print(name, line, tagged) {
|
|
11
|
+
const color = COLORS[name] ?? chalk.gray;
|
|
12
|
+
console.log(tagged ? color(`[${name}] `) + line : line);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function logsCommand(service, opts) {
|
|
16
|
+
const names = service ? [service] : SERVICES;
|
|
17
|
+
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
if (!SERVICES.includes(name)) {
|
|
20
|
+
console.log(chalk.red(`Unknown service "${name}".`) + chalk.gray(` Try: ${SERVICES.join(", ")}`));
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const tagged = names.length > 1;
|
|
27
|
+
const count = Number(opts.lines) || 40;
|
|
28
|
+
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const lines = tailLog(name, count);
|
|
31
|
+
if (lines.length === 0) {
|
|
32
|
+
print(name, chalk.gray("(no output yet)"), tagged);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const line of lines) print(name, line, tagged);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!opts.follow) return;
|
|
39
|
+
|
|
40
|
+
console.log(chalk.gray(`\n— following ${names.join(", ")}; Ctrl-C to stop —\n`));
|
|
41
|
+
|
|
42
|
+
// Poll by size rather than fs.watch: watch is unreliable across platforms
|
|
43
|
+
// for append-only writes (and on Windows can miss them entirely), and a
|
|
44
|
+
// 400ms poll on a log file costs nothing.
|
|
45
|
+
const offsets = new Map(
|
|
46
|
+
names.map((name) => {
|
|
47
|
+
try {
|
|
48
|
+
return [name, fs.statSync(logFile(name)).size];
|
|
49
|
+
} catch {
|
|
50
|
+
return [name, 0];
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Runs until the process is interrupted — see the note below.
|
|
56
|
+
setInterval(() => {
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const file = logFile(name);
|
|
59
|
+
let size;
|
|
60
|
+
try {
|
|
61
|
+
size = fs.statSync(file).size;
|
|
62
|
+
} catch {
|
|
63
|
+
continue; // not created yet
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const from = offsets.get(name) ?? 0;
|
|
67
|
+
// Shrank — the log rotated. Start from the top of the new file.
|
|
68
|
+
if (size < from) {
|
|
69
|
+
offsets.set(name, 0);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (size === from) continue;
|
|
73
|
+
|
|
74
|
+
const fd = fs.openSync(file, "r");
|
|
75
|
+
try {
|
|
76
|
+
const buf = Buffer.alloc(size - from);
|
|
77
|
+
fs.readSync(fd, buf, 0, buf.length, from);
|
|
78
|
+
for (const line of buf.toString("utf-8").split(/\r?\n/).filter(Boolean)) print(name, line, tagged);
|
|
79
|
+
} finally {
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
offsets.set(name, size);
|
|
83
|
+
}
|
|
84
|
+
}, 400);
|
|
85
|
+
|
|
86
|
+
// Follow until interrupted. No SIGINT handler on purpose: registering one
|
|
87
|
+
// *overrides* Node's default "die on Ctrl-C", so a handler that only clears
|
|
88
|
+
// a timer can leave the process alive and unkillable by Ctrl-C. Letting the
|
|
89
|
+
// default stand is both simpler and the behaviour every tail tool has.
|
|
90
|
+
await new Promise(() => {});
|
|
91
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { isInitialized } from "../lib/paths.js";
|
|
3
|
+
import { telegramTokenVar } from "../lib/config.js";
|
|
4
|
+
import { seedDefaultAgents } from "../lib/agents.js";
|
|
5
|
+
import { startService, serviceStatus, stopService, tailLog, isAlive, logFile, updateRecord } from "../lib/daemon.js";
|
|
6
|
+
|
|
7
|
+
// `ouro start` — both services, detached, surviving this terminal.
|
|
8
|
+
//
|
|
9
|
+
// The hard rule here: never print "started" for something that isn't up. A
|
|
10
|
+
// daemon you can't see is only trustworthy if its start command actually
|
|
11
|
+
// verifies, so the dashboard is probed over HTTP and the listener is checked
|
|
12
|
+
// for still being alive a beat later. A service that died gets its log tail
|
|
13
|
+
// printed inline rather than leaving you to go find it.
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Waits for the dashboard we just spawned to answer on `port`.
|
|
19
|
+
*
|
|
20
|
+
* Checks the pid the server reports, not just that *something* replied: if
|
|
21
|
+
* another ouro already owns the port, our child dies of EADDRINUSE while the
|
|
22
|
+
* incumbent happily answers the probe — which would otherwise be reported as
|
|
23
|
+
* a successful start against a process that no longer exists.
|
|
24
|
+
*/
|
|
25
|
+
async function waitForDashboard(port, pid, timeoutMs = 15000) {
|
|
26
|
+
const deadline = Date.now() + timeoutMs;
|
|
27
|
+
|
|
28
|
+
while (Date.now() < deadline) {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`http://localhost:${port}/api/config`, { signal: AbortSignal.timeout(1000) });
|
|
31
|
+
if (res.ok) {
|
|
32
|
+
const body = await res.json().catch(() => ({}));
|
|
33
|
+
if (body.pid === pid) return { ok: true };
|
|
34
|
+
// Someone else is on this port. Our child is doomed (or already dead).
|
|
35
|
+
if (body.pid) return { ok: false, reason: "port-taken", byPid: body.pid };
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// not up yet
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Died before binding — the log has the reason (usually the port).
|
|
42
|
+
if (!isAlive(pid)) return { ok: false, reason: "exited" };
|
|
43
|
+
await sleep(250);
|
|
44
|
+
}
|
|
45
|
+
return { ok: false, reason: "timeout" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function startCommand(opts) {
|
|
49
|
+
if (!isInitialized()) {
|
|
50
|
+
console.log(chalk.yellow("This repo isn't initialized yet. Run ") + chalk.cyan("ouro init") + chalk.yellow(" first."));
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
seedDefaultAgents();
|
|
56
|
+
|
|
57
|
+
const port = Number(opts.port) || 4747;
|
|
58
|
+
const { name: tokenVar, error: tokenVarError } = telegramTokenVar();
|
|
59
|
+
const wantListen = opts.listen !== false;
|
|
60
|
+
|
|
61
|
+
// --- dashboard ---
|
|
62
|
+
|
|
63
|
+
const existing = serviceStatus("dashboard");
|
|
64
|
+
if (existing.running) {
|
|
65
|
+
console.log(chalk.gray(`• dashboard already running (pid ${existing.pid}, port ${existing.port ?? "?"})`));
|
|
66
|
+
} else {
|
|
67
|
+
const record = startService("dashboard", ["--port", String(port), "--no-open"]);
|
|
68
|
+
process.stdout.write(chalk.gray("• starting dashboard… "));
|
|
69
|
+
|
|
70
|
+
const result = await waitForDashboard(port, record.pid);
|
|
71
|
+
|
|
72
|
+
if (result.ok) {
|
|
73
|
+
// Record the port so status/logs can report it without re-deriving it.
|
|
74
|
+
updateRecord("dashboard", { port });
|
|
75
|
+
console.log(chalk.green("ok"));
|
|
76
|
+
} else {
|
|
77
|
+
console.log(chalk.red("failed"));
|
|
78
|
+
|
|
79
|
+
if (result.reason === "port-taken") {
|
|
80
|
+
console.log(chalk.red(` Port ${port} is already served by another ouro dashboard (pid ${result.byPid}).`));
|
|
81
|
+
console.log(chalk.gray(" That one wasn't started by ") + chalk.cyan("ouro start") + chalk.gray(", so ouro stop won't manage it."));
|
|
82
|
+
console.log(chalk.gray(" Either use it as-is, stop it yourself, or pick another port:"));
|
|
83
|
+
console.log(chalk.cyan(` ouro start --port ${port + 1}`));
|
|
84
|
+
} else if (result.reason === "exited") {
|
|
85
|
+
console.log(chalk.red(" The dashboard process exited during startup:"));
|
|
86
|
+
} else {
|
|
87
|
+
console.log(chalk.red(` The dashboard didn't answer on port ${port} within 15s.`));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Only this run's output — see startService's logOffset.
|
|
91
|
+
for (const line of tailLog("dashboard", 12, record.logOffset)) console.log(chalk.gray(" │ " + line));
|
|
92
|
+
// Don't leave a dead process's pid file behind for stop/status to trust.
|
|
93
|
+
await stopService("dashboard");
|
|
94
|
+
console.log(chalk.gray(` Full log: ${logFile("dashboard")}`));
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- telegram listener ---
|
|
101
|
+
|
|
102
|
+
let listenUp = false;
|
|
103
|
+
|
|
104
|
+
// A token sitting in config.json is a token on its way into git, whether or
|
|
105
|
+
// not the listener can start without it — so this is said every time, not
|
|
106
|
+
// only on the path where the start fails.
|
|
107
|
+
if (tokenVarError) {
|
|
108
|
+
console.log(chalk.red("• Telegram config problem:"));
|
|
109
|
+
for (const line of tokenVarError.split("\n")) console.log(chalk.yellow(` ${line}`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!wantListen) {
|
|
113
|
+
console.log(chalk.gray("• listener skipped (--no-listen)"));
|
|
114
|
+
} else if (!process.env[tokenVar]) {
|
|
115
|
+
// Spawning it anyway would produce a process that exits instantly and a
|
|
116
|
+
// "started" line that's a lie. Say what's missing and how to fix it.
|
|
117
|
+
console.log(chalk.yellow(`• listener skipped — ${tokenVar} is not set.`));
|
|
118
|
+
console.log(chalk.gray(` Paste your @BotFather token into Settings at `) + chalk.cyan(`http://localhost:${port}`) + chalk.gray(` — it starts the bot for you.`));
|
|
119
|
+
console.log(chalk.gray(` Or, from here (the daemon can't read your shell's exports after you close it):`));
|
|
120
|
+
console.log(chalk.cyan(` echo '${tokenVar}=<token>' >> .ouro/.env`) + chalk.gray(" # gitignored"));
|
|
121
|
+
console.log(chalk.gray(` Then: `) + chalk.cyan("ouro restart"));
|
|
122
|
+
} else {
|
|
123
|
+
const existingListen = serviceStatus("listen");
|
|
124
|
+
if (existingListen.running) {
|
|
125
|
+
console.log(chalk.gray(`• listener already running (pid ${existingListen.pid})`));
|
|
126
|
+
listenUp = true;
|
|
127
|
+
} else {
|
|
128
|
+
const record = startService("listen");
|
|
129
|
+
process.stdout.write(chalk.gray("• starting Telegram intake agent… "));
|
|
130
|
+
// No endpoint to probe. listen.js validates the token via getMe() and
|
|
131
|
+
// exits non-zero if Telegram rejects it, so "still alive a beat later"
|
|
132
|
+
// is a real signal rather than just "the process spawned".
|
|
133
|
+
await sleep(3000);
|
|
134
|
+
|
|
135
|
+
if (isAlive(record.pid)) {
|
|
136
|
+
console.log(chalk.green("ok"));
|
|
137
|
+
listenUp = true;
|
|
138
|
+
} else {
|
|
139
|
+
console.log(chalk.red("failed"));
|
|
140
|
+
for (const line of tailLog("listen", 8, record.logOffset)) console.log(chalk.gray(" │ " + line));
|
|
141
|
+
console.log(chalk.gray(` Full log: ${logFile("listen")}`));
|
|
142
|
+
await stopService("listen");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log("");
|
|
148
|
+
// Say what's actually up. The dashboard is running by this point, but
|
|
149
|
+
// claiming "ouro is running" flat out would paper over a dead listener.
|
|
150
|
+
const headline = listenUp
|
|
151
|
+
? "ouro is running in the background."
|
|
152
|
+
: "Dashboard is running in the background — Telegram intake is not.";
|
|
153
|
+
console.log(chalk.green(headline) + chalk.gray(" Closing this terminal won't stop it."));
|
|
154
|
+
console.log(chalk.gray(" dashboard ") + chalk.cyan(`http://localhost:${port}`));
|
|
155
|
+
console.log(chalk.gray(" status ") + chalk.cyan("ouro status"));
|
|
156
|
+
console.log(chalk.gray(" logs ") + chalk.cyan("ouro logs -f"));
|
|
157
|
+
console.log(chalk.gray(" stop ") + chalk.cyan("ouro stop"));
|
|
158
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { statusAll, uptime, logFile } from "../lib/daemon.js";
|
|
3
|
+
import { readConfig } from "../lib/config.js";
|
|
4
|
+
import { hasEnvFile } from "../lib/env.js";
|
|
5
|
+
|
|
6
|
+
// `ouro status` — the only window into a process you can't see.
|
|
7
|
+
// Reports what's actually true, including the awkward cases: a dashboard whose
|
|
8
|
+
// pid is alive but whose port stopped answering, and a listener with no token.
|
|
9
|
+
|
|
10
|
+
async function probe(port) {
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(`http://localhost:${port}/api/config`, { signal: AbortSignal.timeout(1500) });
|
|
13
|
+
return res.ok ? await res.json() : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function statusCommand() {
|
|
20
|
+
const services = statusAll();
|
|
21
|
+
const dashboard = services.find((s) => s.name === "dashboard");
|
|
22
|
+
const listen = services.find((s) => s.name === "listen");
|
|
23
|
+
|
|
24
|
+
const health = dashboard?.running && dashboard.port ? await probe(dashboard.port) : null;
|
|
25
|
+
|
|
26
|
+
console.log("");
|
|
27
|
+
for (const svc of services) {
|
|
28
|
+
if (!svc.running) {
|
|
29
|
+
console.log(chalk.gray(` ○ ${svc.name.padEnd(10)} stopped${svc.stale ? " (cleaned up a stale pid file)" : ""}`));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const bits = [`pid ${svc.pid}`, `up ${uptime(svc.startedAt)}`];
|
|
33
|
+
if (svc.port) bits.push(`port ${svc.port}`);
|
|
34
|
+
console.log(chalk.green(` ● ${svc.name.padEnd(10)} running `) + chalk.gray(bits.join(" · ")));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A live pid whose port doesn't answer is the failure mode a pid check alone
|
|
38
|
+
// would miss — the process is up but wedged. Worth calling out loudly.
|
|
39
|
+
if (dashboard?.running && dashboard.port && !health) {
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log(chalk.yellow(` ! dashboard pid is alive but port ${dashboard.port} isn't answering.`));
|
|
42
|
+
console.log(chalk.gray(` Try: `) + chalk.cyan("ouro restart") + chalk.gray(` Log: ${logFile("dashboard")}`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The port answers, but not from the process we're tracking — another ouro
|
|
46
|
+
// owns it. Reporting its backend/mode as ours would be describing someone
|
|
47
|
+
// else's server.
|
|
48
|
+
if (health && dashboard?.running && health.pid && health.pid !== dashboard.pid) {
|
|
49
|
+
console.log("");
|
|
50
|
+
console.log(chalk.yellow(` ! port ${dashboard.port} is served by a different ouro (pid ${health.pid}), not ours (pid ${dashboard.pid}).`));
|
|
51
|
+
console.log(chalk.gray(" The details below describe that other instance."));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (health) {
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log(chalk.gray(" backend ") + health.backend);
|
|
57
|
+
console.log(chalk.gray(" default mode ") + health.defaultMode);
|
|
58
|
+
console.log(chalk.gray(" url ") + chalk.cyan(`http://localhost:${dashboard.port}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!listen?.running) {
|
|
62
|
+
const tokenVar = readConfig().telegram?.botTokenEnvVar ?? "OURO_TELEGRAM_BOT_TOKEN";
|
|
63
|
+
if (!process.env[tokenVar]) {
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log(chalk.gray(` Telegram intake is off — ${tokenVar} is not set`) + (hasEnvFile() ? chalk.gray(" (not in .ouro/.env either)") : ""));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
console.log("");
|
|
69
|
+
}
|