trackops 1.0.1 → 1.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/README.md +326 -270
- package/bin/trackops.js +102 -70
- package/lib/config.js +260 -35
- package/lib/control.js +517 -475
- package/lib/env.js +227 -0
- package/lib/i18n.js +61 -53
- package/lib/init.js +135 -46
- package/lib/locale.js +63 -0
- package/lib/opera-bootstrap.js +523 -0
- package/lib/opera.js +319 -170
- package/lib/registry.js +27 -13
- package/lib/release.js +56 -0
- package/lib/resources.js +42 -0
- package/lib/server.js +907 -554
- package/lib/skills.js +148 -124
- package/lib/workspace.js +260 -0
- package/locales/en.json +331 -139
- package/locales/es.json +331 -139
- package/package.json +7 -9
- package/scripts/skills-marketplace-smoke.js +124 -0
- package/scripts/smoke-tests.js +445 -0
- package/scripts/sync-skill-version.js +21 -0
- package/scripts/validate-skill.js +88 -0
- package/skills/trackops/SKILL.md +64 -0
- package/skills/trackops/agents/openai.yaml +3 -0
- package/skills/trackops/references/activation.md +39 -0
- package/skills/trackops/references/troubleshooting.md +34 -0
- package/skills/trackops/references/workflow.md +20 -0
- package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
- package/skills/trackops/skill.json +29 -0
- package/templates/opera/en/agent.md +26 -0
- package/templates/opera/en/genesis.md +79 -0
- package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
- package/templates/opera/en/references/opera-cycle.md +62 -0
- package/templates/opera/en/registry.md +28 -0
- package/templates/opera/en/router.md +39 -0
- package/templates/opera/genesis.md +79 -94
- package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
- package/templates/skills/commiter/locales/en/SKILL.md +11 -0
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
- package/ui/css/panels.css +956 -953
- package/ui/index.html +1 -1
- package/ui/js/api.js +211 -194
- package/ui/js/app.js +200 -199
- package/ui/js/i18n.js +14 -0
- package/ui/js/onboarding.js +439 -437
- package/ui/js/state.js +130 -129
- package/ui/js/utils.js +175 -172
- package/ui/js/views/board.js +255 -254
- package/ui/js/views/execution.js +256 -256
- package/ui/js/views/insights.js +340 -339
- package/ui/js/views/overview.js +365 -364
- package/ui/js/views/settings.js +340 -202
- package/ui/js/views/sidebar.js +131 -132
- package/ui/js/views/skills.js +163 -162
- package/ui/js/views/tasks.js +406 -405
- package/ui/js/views/topbar.js +239 -183
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const net = require("net");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { spawn, spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
12
|
+
const BIN = path.join(ROOT, "bin", "trackops.js");
|
|
13
|
+
const SKILL_VALIDATE = path.join(ROOT, "scripts", "validate-skill.js");
|
|
14
|
+
const SKILL_BOOTSTRAP = path.join(ROOT, "skills", "trackops", "scripts", "bootstrap-trackops.js");
|
|
15
|
+
|
|
16
|
+
function getNpmCommand() {
|
|
17
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function runNode(args, cwd, envOverrides = {}) {
|
|
21
|
+
const result = spawnSync(process.execPath, args, {
|
|
22
|
+
cwd,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
env: { ...process.env, ...envOverrides },
|
|
25
|
+
});
|
|
26
|
+
assert.strictEqual(result.status, 0, result.stderr || result.stdout || `fallo ejecutando ${args.join(" ")}`);
|
|
27
|
+
return result.stdout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runNodeResult(args, cwd, envOverrides = {}) {
|
|
31
|
+
return spawnSync(process.execPath, args, {
|
|
32
|
+
cwd,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
env: { ...process.env, ...envOverrides },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runCommand(command, args, cwd, envOverrides = {}) {
|
|
39
|
+
return spawnSync(command, args, {
|
|
40
|
+
cwd,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
env: { ...process.env, ...envOverrides },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runNpm(args, cwd, envOverrides = {}) {
|
|
47
|
+
return spawnSync(getNpmCommand(), args, {
|
|
48
|
+
cwd,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
env: { ...process.env, ...envOverrides },
|
|
51
|
+
shell: process.platform === "win32",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function git(args, cwd) {
|
|
56
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
57
|
+
assert.strictEqual(result.status, 0, result.stderr || result.stdout || `git ${args.join(" ")} fallo`);
|
|
58
|
+
return result.stdout.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function wait(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function get(port, pathname, host = "127.0.0.1") {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const req = http.get({ host, port, path: pathname }, (res) => {
|
|
68
|
+
let body = "";
|
|
69
|
+
res.setEncoding("utf8");
|
|
70
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
71
|
+
res.on("end", () => resolve({ status: res.statusCode, body }));
|
|
72
|
+
});
|
|
73
|
+
req.on("error", reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractLocalPort(output) {
|
|
78
|
+
const match = String(output || "").match(/- Local:\s+http:\/\/[^\s:]+:(\d+)/);
|
|
79
|
+
return match ? Number(match[1]) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasExternalIpv4() {
|
|
83
|
+
return Object.values(os.networkInterfaces()).some((entries) => (
|
|
84
|
+
(entries || []).some((entry) => entry && entry.family === "IPv4" && !entry.internal)
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPortFree(port, host = "127.0.0.1") {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const server = net.createServer();
|
|
91
|
+
server.once("error", () => resolve(false));
|
|
92
|
+
server.once("listening", () => {
|
|
93
|
+
server.close(() => resolve(true));
|
|
94
|
+
});
|
|
95
|
+
server.listen(port, host);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findFreePort(host = "127.0.0.1") {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const server = net.createServer();
|
|
102
|
+
server.once("error", reject);
|
|
103
|
+
server.listen(0, host, () => {
|
|
104
|
+
const address = server.address();
|
|
105
|
+
const port = typeof address === "object" && address ? address.port : null;
|
|
106
|
+
server.close((closeError) => {
|
|
107
|
+
if (closeError) reject(closeError);
|
|
108
|
+
else resolve(port);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function occupyPort(port, host = "127.0.0.1") {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const server = http.createServer((_req, res) => {
|
|
117
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
118
|
+
res.end("busy");
|
|
119
|
+
});
|
|
120
|
+
server.once("error", reject);
|
|
121
|
+
server.listen(port, host, () => resolve(server));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function startDashboard(cwd, args = [], envOverrides = {}) {
|
|
126
|
+
const env = { ...process.env, ...envOverrides };
|
|
127
|
+
const child = spawn(process.execPath, [BIN, "dashboard", ...args], {
|
|
128
|
+
cwd,
|
|
129
|
+
env,
|
|
130
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let output = "";
|
|
134
|
+
child.stdout.on("data", (chunk) => { output += chunk.toString("utf8"); });
|
|
135
|
+
child.stderr.on("data", (chunk) => { output += chunk.toString("utf8"); });
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
child,
|
|
139
|
+
output: () => output,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function waitForDashboard(session) {
|
|
144
|
+
const startedAt = Date.now();
|
|
145
|
+
|
|
146
|
+
while (Date.now() - startedAt < 15000) {
|
|
147
|
+
if (session.child.exitCode != null) {
|
|
148
|
+
throw new Error(`el dashboard termino antes de iniciar:\n${session.output()}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const port = extractLocalPort(session.output());
|
|
152
|
+
if (port) {
|
|
153
|
+
try {
|
|
154
|
+
const response = await get(port, "/api/state");
|
|
155
|
+
if (response.status === 200) {
|
|
156
|
+
return { port, output: session.output() };
|
|
157
|
+
}
|
|
158
|
+
} catch (_error) {
|
|
159
|
+
// sigue esperando
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await wait(250);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throw new Error(`el dashboard no respondio a tiempo:\n${session.output()}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function stopDashboard(session) {
|
|
170
|
+
if (!session || !session.child || session.child.exitCode != null) return;
|
|
171
|
+
session.child.kill("SIGTERM");
|
|
172
|
+
await wait(500);
|
|
173
|
+
if (session.child.exitCode == null) {
|
|
174
|
+
session.child.kill("SIGKILL");
|
|
175
|
+
await wait(250);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeJson(filePath, data) {
|
|
180
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
181
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function packCurrentPackage(tempRoot) {
|
|
185
|
+
const result = runNpm(["pack", ROOT], tempRoot);
|
|
186
|
+
assert.strictEqual(result.status, 0, result.stderr || result.stdout || "npm pack fallo");
|
|
187
|
+
const tarballName = String(result.stdout || "").trim().split(/\r?\n/).pop();
|
|
188
|
+
const tarballPath = path.join(tempRoot, tarballName);
|
|
189
|
+
assert.ok(fs.existsSync(tarballPath), `no se encontro el tarball ${tarballPath}`);
|
|
190
|
+
return tarballPath;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readJson(filePath) {
|
|
194
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function initGitRepo(repo) {
|
|
198
|
+
git(["init"], repo);
|
|
199
|
+
git(["config", "user.email", "smoke@example.com"], repo);
|
|
200
|
+
git(["config", "user.name", "Smoke Runner"], repo);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function commitAll(repo, message) {
|
|
204
|
+
git(["add", "."], repo);
|
|
205
|
+
git(["commit", "-m", message], repo);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function main() {
|
|
209
|
+
runNode([SKILL_VALIDATE], ROOT);
|
|
210
|
+
|
|
211
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trackops-smoke-"));
|
|
212
|
+
const tarballPath = packCurrentPackage(tempRoot);
|
|
213
|
+
const packageVersion = readJson(path.join(ROOT, "package.json")).version;
|
|
214
|
+
|
|
215
|
+
const bootstrapHome = path.join(tempRoot, "bootstrap-home");
|
|
216
|
+
const bootstrapPrefix = path.join(tempRoot, "bootstrap-prefix");
|
|
217
|
+
fs.mkdirSync(bootstrapHome, { recursive: true });
|
|
218
|
+
fs.mkdirSync(bootstrapPrefix, { recursive: true });
|
|
219
|
+
|
|
220
|
+
const bootstrapEnv = {
|
|
221
|
+
TRACKOPS_BOOTSTRAP_HOME: bootstrapHome,
|
|
222
|
+
TRACKOPS_BOOTSTRAP_PREFIX: bootstrapPrefix,
|
|
223
|
+
TRACKOPS_BOOTSTRAP_INSTALL_SOURCE: tarballPath,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const firstBootstrap = runCommand(process.execPath, [SKILL_BOOTSTRAP], tempRoot, bootstrapEnv);
|
|
227
|
+
assert.strictEqual(firstBootstrap.status, 0, firstBootstrap.stderr || firstBootstrap.stdout || "bootstrap skill fallo");
|
|
228
|
+
assert.match(firstBootstrap.stdout, /TrackOps runtime .* is ready/i);
|
|
229
|
+
|
|
230
|
+
const runtimeStamp = readJson(path.join(bootstrapHome, ".trackops", "runtime.json"));
|
|
231
|
+
assert.strictEqual(runtimeStamp.runtimeVersion, packageVersion);
|
|
232
|
+
assert.strictEqual(runtimeStamp.skill, "trackops");
|
|
233
|
+
assert.strictEqual(runtimeStamp.bootstrapPolicy, "first_use");
|
|
234
|
+
|
|
235
|
+
const installedCli = path.join(bootstrapPrefix, "node_modules", "trackops", "bin", "trackops.js");
|
|
236
|
+
assert.ok(fs.existsSync(installedCli), "el runtime instalado debe existir dentro del prefijo aislado");
|
|
237
|
+
const installedVersion = runNode([installedCli, "--version"], tempRoot);
|
|
238
|
+
assert.strictEqual(installedVersion.trim(), packageVersion);
|
|
239
|
+
|
|
240
|
+
const secondBootstrap = runCommand(process.execPath, [SKILL_BOOTSTRAP], tempRoot, bootstrapEnv);
|
|
241
|
+
assert.strictEqual(secondBootstrap.status, 0, secondBootstrap.stderr || secondBootstrap.stdout || "bootstrap idempotente fallo");
|
|
242
|
+
assert.match(secondBootstrap.stdout, /already ready/i);
|
|
243
|
+
|
|
244
|
+
const untouchedRepo = path.join(tempRoot, "untouched-repo");
|
|
245
|
+
fs.mkdirSync(untouchedRepo, { recursive: true });
|
|
246
|
+
const bootstrapNoRepoMutation = runCommand(process.execPath, [SKILL_BOOTSTRAP], untouchedRepo, bootstrapEnv);
|
|
247
|
+
assert.strictEqual(bootstrapNoRepoMutation.status, 0, bootstrapNoRepoMutation.stderr || bootstrapNoRepoMutation.stdout || "bootstrap repetido fallo");
|
|
248
|
+
assert.ok(!fs.existsSync(path.join(untouchedRepo, "project_control.json")), "la skill global no debe crear artefactos de proyecto por si sola");
|
|
249
|
+
|
|
250
|
+
const helpOutput = runNode([BIN, "help"], ROOT);
|
|
251
|
+
assert.doesNotMatch(helpOutput, /\btrackops agent\b/i);
|
|
252
|
+
assert.match(helpOutput, /workspace status\|migrate/i);
|
|
253
|
+
assert.match(helpOutput, /env status\|sync/i);
|
|
254
|
+
assert.match(helpOutput, /release \[--push\]/i);
|
|
255
|
+
|
|
256
|
+
const versionOutput = runNode([BIN, "--version"], ROOT);
|
|
257
|
+
assert.strictEqual(versionOutput.trim(), packageVersion);
|
|
258
|
+
|
|
259
|
+
const splitProject = path.join(tempRoot, "split-demo");
|
|
260
|
+
fs.mkdirSync(splitProject, { recursive: true });
|
|
261
|
+
runNode([BIN, "init"], splitProject);
|
|
262
|
+
|
|
263
|
+
assert.ok(fs.existsSync(path.join(splitProject, ".trackops-workspace.json")));
|
|
264
|
+
assert.ok(fs.existsSync(path.join(splitProject, ".env")));
|
|
265
|
+
assert.ok(fs.existsSync(path.join(splitProject, ".env.example")));
|
|
266
|
+
assert.ok(fs.existsSync(path.join(splitProject, "app")));
|
|
267
|
+
assert.ok(fs.existsSync(path.join(splitProject, "ops", "project_control.json")));
|
|
268
|
+
assert.ok(fs.existsSync(path.join(splitProject, "app", ".env")));
|
|
269
|
+
assert.ok(fs.existsSync(path.join(splitProject, "app", ".env.example")));
|
|
270
|
+
assert.ok(!fs.existsSync(path.join(splitProject, "project_control.json")));
|
|
271
|
+
assert.ok(!fs.existsSync(path.join(splitProject, "task_plan.md")));
|
|
272
|
+
|
|
273
|
+
const splitControl = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
274
|
+
assert.strictEqual(splitControl.meta.workspace.layout, "split");
|
|
275
|
+
assert.strictEqual(splitControl.meta.environment.rootEnvFile, ".env");
|
|
276
|
+
|
|
277
|
+
const statusFromWorkspace = runNode([BIN, "status"], splitProject);
|
|
278
|
+
const statusFromApp = runNode([BIN, "status"], path.join(splitProject, "app"));
|
|
279
|
+
const statusFromOps = runNode([BIN, "status"], path.join(splitProject, "ops"));
|
|
280
|
+
assert.match(statusFromWorkspace, /Layout: split/);
|
|
281
|
+
assert.match(statusFromApp, /Layout: split/);
|
|
282
|
+
assert.match(statusFromOps, /Layout: split/);
|
|
283
|
+
|
|
284
|
+
const nextOutput = runNode([BIN, "next"], splitProject);
|
|
285
|
+
assert.match(nextOutput, /ops-bootstrap/);
|
|
286
|
+
|
|
287
|
+
runNode([BIN, "sync"], splitProject);
|
|
288
|
+
for (const file of ["task_plan.md", "progress.md", "findings.md"]) {
|
|
289
|
+
assert.ok(fs.existsSync(path.join(splitProject, "ops", file)), `${file} no fue generado en ops/`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const envStatus = runNode([BIN, "env", "status"], splitProject);
|
|
293
|
+
assert.match(envStatus, /Root \.env:/);
|
|
294
|
+
assert.match(envStatus, /App bridge:/);
|
|
295
|
+
|
|
296
|
+
const nonEmptyProject = path.join(tempRoot, "non-empty");
|
|
297
|
+
fs.mkdirSync(nonEmptyProject, { recursive: true });
|
|
298
|
+
writeJson(path.join(nonEmptyProject, "package.json"), { name: "existing-app", version: "1.0.0" });
|
|
299
|
+
const initNonEmpty = runNodeResult([BIN, "init"], nonEmptyProject);
|
|
300
|
+
assert.notStrictEqual(initNonEmpty.status, 0, "init debe abortar en directorios no vacios");
|
|
301
|
+
assert.match(`${initNonEmpty.stdout}\n${initNonEmpty.stderr}`, /workspace migrate/i);
|
|
302
|
+
|
|
303
|
+
writeJson(path.join(splitProject, "app", "package.json"), {
|
|
304
|
+
name: "split-demo",
|
|
305
|
+
version: "1.0.0",
|
|
306
|
+
dependencies: { openai: "^4.0.0" },
|
|
307
|
+
scripts: { test: "echo ok" },
|
|
308
|
+
});
|
|
309
|
+
runNode([BIN, "opera", "install", "--locale", "en", "--non-interactive"], splitProject);
|
|
310
|
+
|
|
311
|
+
assert.ok(fs.existsSync(path.join(splitProject, "ops", "genesis.md")));
|
|
312
|
+
assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agent", "hub", "agent.md")));
|
|
313
|
+
assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agents", "skills", "_registry.md")));
|
|
314
|
+
assert.ok(!fs.existsSync(path.join(splitProject, "genesis.md")));
|
|
315
|
+
|
|
316
|
+
const operaControl = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
317
|
+
assert.strictEqual(operaControl.meta.locale, "en");
|
|
318
|
+
assert.strictEqual(operaControl.meta.opera.installed, true);
|
|
319
|
+
assert.ok(operaControl.meta.environment.requiredKeys.includes("OPENAI_API_KEY"));
|
|
320
|
+
const envRootText = fs.readFileSync(path.join(splitProject, ".env"), "utf8");
|
|
321
|
+
assert.match(envRootText, /OPENAI_API_KEY=/);
|
|
322
|
+
|
|
323
|
+
const defaultPortFree = await isPortFree(4173);
|
|
324
|
+
const defaultDashboard = startDashboard(splitProject);
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const ready = await waitForDashboard(defaultDashboard);
|
|
328
|
+
const state = await get(ready.port, "/api/state");
|
|
329
|
+
const envPayload = await get(ready.port, "/api/env");
|
|
330
|
+
const localSkills = await get(ready.port, "/api/skills/local");
|
|
331
|
+
const discoverSkills = await get(ready.port, "/api/skills/discover");
|
|
332
|
+
|
|
333
|
+
assert.strictEqual(state.status, 200);
|
|
334
|
+
assert.strictEqual(envPayload.status, 200);
|
|
335
|
+
assert.strictEqual(localSkills.status, 200);
|
|
336
|
+
assert.strictEqual(discoverSkills.status, 200);
|
|
337
|
+
|
|
338
|
+
const statePayload = JSON.parse(state.body);
|
|
339
|
+
assert.strictEqual(statePayload.project.layout, "split");
|
|
340
|
+
assert.strictEqual(statePayload.project.workspaceRoot, splitProject);
|
|
341
|
+
assert.strictEqual(statePayload.project.appRoot, path.join(splitProject, "app"));
|
|
342
|
+
assert.strictEqual(statePayload.project.opsRoot, path.join(splitProject, "ops"));
|
|
343
|
+
assert.ok(Array.isArray(statePayload.env.requiredKeys));
|
|
344
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(statePayload.env, "values"));
|
|
345
|
+
|
|
346
|
+
const envState = JSON.parse(envPayload.body);
|
|
347
|
+
assert.ok(envState.requiredKeys.includes("OPENAI_API_KEY"));
|
|
348
|
+
assert.ok(envState.missingKeys.includes("OPENAI_API_KEY"));
|
|
349
|
+
|
|
350
|
+
if (defaultPortFree) {
|
|
351
|
+
assert.strictEqual(ready.port, 4173, `se esperaba usar 4173 cuando estaba libre:\n${ready.output}`);
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
await stopDashboard(defaultDashboard);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const blocked4173 = await isPortFree(4173) ? await occupyPort(4173) : null;
|
|
358
|
+
const fallbackDashboard = startDashboard(splitProject);
|
|
359
|
+
try {
|
|
360
|
+
const ready = await waitForDashboard(fallbackDashboard);
|
|
361
|
+
assert.notStrictEqual(ready.port, 4173, `deberia haber evitado 4173 ocupado:\n${ready.output}`);
|
|
362
|
+
assert.match(ready.output, /Local:/);
|
|
363
|
+
} finally {
|
|
364
|
+
await stopDashboard(fallbackDashboard);
|
|
365
|
+
if (blocked4173) blocked4173.close();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const strictPort = await findFreePort();
|
|
369
|
+
const occupiedStrictPort = await occupyPort(strictPort);
|
|
370
|
+
try {
|
|
371
|
+
const strictResult = runNodeResult([BIN, "dashboard", "--strict-port", "--port", String(strictPort)], splitProject);
|
|
372
|
+
assert.notStrictEqual(strictResult.status, 0, "el dashboard deberia fallar con --strict-port si el puerto esta ocupado");
|
|
373
|
+
assert.match(`${strictResult.stdout}\n${strictResult.stderr}`, /already in use|esta en uso/i);
|
|
374
|
+
} finally {
|
|
375
|
+
occupiedStrictPort.close();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const publicPort = await findFreePort("0.0.0.0");
|
|
379
|
+
const publicDashboard = startDashboard(splitProject, ["--public", "--port", String(publicPort)]);
|
|
380
|
+
try {
|
|
381
|
+
const ready = await waitForDashboard(publicDashboard);
|
|
382
|
+
if (hasExternalIpv4()) {
|
|
383
|
+
assert.match(ready.output, /- Network:/);
|
|
384
|
+
}
|
|
385
|
+
} finally {
|
|
386
|
+
await stopDashboard(publicDashboard);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const noClipboardPort = await findFreePort();
|
|
390
|
+
const noClipboardDashboard = startDashboard(splitProject, ["--port", String(noClipboardPort)], {
|
|
391
|
+
PATH: "",
|
|
392
|
+
Path: "",
|
|
393
|
+
});
|
|
394
|
+
try {
|
|
395
|
+
const ready = await waitForDashboard(noClipboardDashboard);
|
|
396
|
+
const state = await get(ready.port, "/api/state");
|
|
397
|
+
assert.strictEqual(state.status, 200);
|
|
398
|
+
} finally {
|
|
399
|
+
await stopDashboard(noClipboardDashboard);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const legacyProject = path.join(tempRoot, "legacy-demo");
|
|
403
|
+
fs.mkdirSync(legacyProject, { recursive: true });
|
|
404
|
+
initGitRepo(legacyProject);
|
|
405
|
+
writeJson(path.join(legacyProject, "package.json"), { name: "legacy-demo", version: "1.0.0" });
|
|
406
|
+
runNode([BIN, "init", "--legacy-layout"], legacyProject);
|
|
407
|
+
runNode([BIN, "opera", "install", "--locale", "en", "--no-bootstrap"], legacyProject);
|
|
408
|
+
fs.writeFileSync(path.join(legacyProject, ".env"), "OPENAI_API_KEY=\n", "utf8");
|
|
409
|
+
fs.writeFileSync(path.join(legacyProject, ".env.example"), "OPENAI_API_KEY=\n", "utf8");
|
|
410
|
+
commitAll(legacyProject, "legacy fixture");
|
|
411
|
+
|
|
412
|
+
runNode([BIN, "workspace", "migrate"], legacyProject);
|
|
413
|
+
assert.ok(fs.existsSync(path.join(legacyProject, ".trackops-workspace.json")));
|
|
414
|
+
assert.ok(fs.existsSync(path.join(legacyProject, "app", "package.json")));
|
|
415
|
+
assert.ok(fs.existsSync(path.join(legacyProject, "ops", "project_control.json")));
|
|
416
|
+
assert.ok(fs.existsSync(path.join(legacyProject, ".env")));
|
|
417
|
+
assert.ok(fs.existsSync(path.join(legacyProject, "app", ".env")));
|
|
418
|
+
assert.ok(!fs.existsSync(path.join(legacyProject, "project_control.json")));
|
|
419
|
+
const migratedPackage = readJson(path.join(legacyProject, "app", "package.json"));
|
|
420
|
+
assert.ok(!migratedPackage.scripts || !migratedPackage.scripts["ops:status"]);
|
|
421
|
+
assert.match(git(["branch", "--list"], legacyProject), /backup\/trackops-workspace-/);
|
|
422
|
+
|
|
423
|
+
const releaseProject = path.join(tempRoot, "release-demo");
|
|
424
|
+
fs.mkdirSync(releaseProject, { recursive: true });
|
|
425
|
+
initGitRepo(releaseProject);
|
|
426
|
+
runNode([BIN, "init"], releaseProject);
|
|
427
|
+
writeJson(path.join(releaseProject, "app", "package.json"), { name: "release-demo", version: "1.0.0" });
|
|
428
|
+
fs.writeFileSync(path.join(releaseProject, "app", "index.js"), "console.log('release');\n", "utf8");
|
|
429
|
+
commitAll(releaseProject, "split fixture");
|
|
430
|
+
git(["checkout", "-b", "develop"], releaseProject);
|
|
431
|
+
runNode([BIN, "release"], releaseProject);
|
|
432
|
+
const publishFiles = git(["ls-tree", "--name-only", "master"], releaseProject).split(/\r?\n/).filter(Boolean);
|
|
433
|
+
assert.ok(publishFiles.includes("package.json"));
|
|
434
|
+
assert.ok(publishFiles.includes(".env.example"));
|
|
435
|
+
assert.ok(!publishFiles.includes("ops"));
|
|
436
|
+
assert.ok(!publishFiles.includes(".trackops-workspace.json"));
|
|
437
|
+
|
|
438
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
439
|
+
console.log("Smoke tests OK");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
main().catch((error) => {
|
|
443
|
+
console.error(error.message);
|
|
444
|
+
process.exit(1);
|
|
445
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
7
|
+
const PACKAGE_FILE = path.join(ROOT, "package.json");
|
|
8
|
+
const SKILL_FILE = path.join(ROOT, "skills", "trackops", "skill.json");
|
|
9
|
+
|
|
10
|
+
function main() {
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(PACKAGE_FILE, "utf8"));
|
|
12
|
+
const skill = JSON.parse(fs.readFileSync(SKILL_FILE, "utf8"));
|
|
13
|
+
|
|
14
|
+
skill.skillVersion = pkg.version;
|
|
15
|
+
skill.trackopsVersion = pkg.version;
|
|
16
|
+
|
|
17
|
+
fs.writeFileSync(SKILL_FILE, `${JSON.stringify(skill, null, 2)}\n`, "utf8");
|
|
18
|
+
console.log(`Synced skills/trackops/skill.json to version ${pkg.version}.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
main();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
7
|
+
const PACKAGE_FILE = path.join(ROOT, "package.json");
|
|
8
|
+
const SKILL_DIR = path.join(ROOT, "skills", "trackops");
|
|
9
|
+
const SKILL_FILE = path.join(SKILL_DIR, "skill.json");
|
|
10
|
+
const REQUIRED_FILES = [
|
|
11
|
+
path.join(SKILL_DIR, "SKILL.md"),
|
|
12
|
+
path.join(SKILL_DIR, "skill.json"),
|
|
13
|
+
path.join(SKILL_DIR, "scripts", "bootstrap-trackops.js"),
|
|
14
|
+
path.join(SKILL_DIR, "references", "activation.md"),
|
|
15
|
+
path.join(SKILL_DIR, "references", "workflow.md"),
|
|
16
|
+
path.join(SKILL_DIR, "references", "troubleshooting.md"),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function fail(message) {
|
|
20
|
+
console.error(message);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function main() {
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(PACKAGE_FILE, "utf8"));
|
|
26
|
+
const skill = JSON.parse(fs.readFileSync(SKILL_FILE, "utf8"));
|
|
27
|
+
const skillMd = fs.readFileSync(path.join(SKILL_DIR, "SKILL.md"), "utf8");
|
|
28
|
+
|
|
29
|
+
for (const file of REQUIRED_FILES) {
|
|
30
|
+
if (!fs.existsSync(file)) {
|
|
31
|
+
fail(`Missing required skill file: ${path.relative(ROOT, file)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Array.isArray(pkg.files) || !pkg.files.includes("skills/")) {
|
|
36
|
+
fail("package.json must publish the skills/ directory.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (skill.name !== "trackops") {
|
|
40
|
+
fail("skills/trackops/skill.json must declare name 'trackops'.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (skill.skillVersion !== pkg.version || skill.trackopsVersion !== pkg.version) {
|
|
44
|
+
fail(`skills/trackops/skill.json must be synced to package version ${pkg.version}.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (skill.npmPackage !== pkg.name) {
|
|
48
|
+
fail(`skills/trackops/skill.json must target npm package '${pkg.name}'.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (skill.bootstrapPolicy !== "first_use") {
|
|
52
|
+
fail("skills/trackops/skill.json must use bootstrapPolicy 'first_use'.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const supportedAgents = Array.isArray(skill.supportedAgentsV1) ? skill.supportedAgentsV1 : [];
|
|
56
|
+
for (const agent of ["antigravity", "claude-code", "codex", "cursor", "gemini-cli", "github-copilot", "kiro-cli"]) {
|
|
57
|
+
if (!supportedAgents.includes(agent)) {
|
|
58
|
+
fail(`skills/trackops/skill.json must include supported agent '${agent}'.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!skill.distribution || skill.distribution.source !== "Baxahaun/trackops") {
|
|
63
|
+
fail("skills/trackops/skill.json must declare distribution.source 'Baxahaun/trackops'.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (skill.distribution.skill !== "trackops") {
|
|
67
|
+
fail("skills/trackops/skill.json must declare distribution.skill 'trackops'.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (skill.distribution.fullDepth !== true) {
|
|
71
|
+
fail("skills/trackops/skill.json must declare distribution.fullDepth true.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const requiredPhrase of [
|
|
75
|
+
"npx skills add Baxahaun/trackops --skill trackops --full-depth",
|
|
76
|
+
"node scripts/bootstrap-trackops.js",
|
|
77
|
+
"trackops init",
|
|
78
|
+
"trackops opera install",
|
|
79
|
+
]) {
|
|
80
|
+
if (!skillMd.includes(requiredPhrase)) {
|
|
81
|
+
fail(`skills/trackops/SKILL.md must mention '${requiredPhrase}'.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log("skills/trackops validated successfully.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
main();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "trackops"
|
|
3
|
+
description: "Global TrackOps skill that prepares your agent for local project orchestration and operational automation, ensures the runtime on first use, and guides per-project activation with optional OPERA."
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.1.0"
|
|
6
|
+
type: "global"
|
|
7
|
+
triggers:
|
|
8
|
+
- "install trackops"
|
|
9
|
+
- "skills.sh"
|
|
10
|
+
- "bootstrap trackops"
|
|
11
|
+
- "trackops init"
|
|
12
|
+
- "trackops opera install"
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# TrackOps
|
|
16
|
+
|
|
17
|
+
Use this skill in two layers:
|
|
18
|
+
|
|
19
|
+
1. Global skill layer
|
|
20
|
+
Install it with:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx skills add Baxahaun/trackops --skill trackops --full-depth --global --agent codex -y
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Replace `codex` with any supported target: `antigravity`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `github-copilot`, or `kiro-cli`.
|
|
27
|
+
|
|
28
|
+
Before relying on the CLI, run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node scripts/bootstrap-trackops.js
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. Local project layer
|
|
35
|
+
Activate TrackOps inside the current repository with:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
trackops init
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Add OPERA only when explicitly requested:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
trackops opera install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Core rules:
|
|
48
|
+
|
|
49
|
+
- Treat the global skill install as non-invasive.
|
|
50
|
+
- In split workspaces, use `ops/project_control.json` as the operational source of truth.
|
|
51
|
+
- In legacy repos, use `project_control.json` at the repository root.
|
|
52
|
+
- Prefer `trackops status`, `trackops next`, and `trackops sync` over hand-editing generated docs.
|
|
53
|
+
- Treat `trackops init --with-opera` as a shortcut, not as the primary mental model.
|
|
54
|
+
- TrackOps manages `/.env` and `/.env.example` at workspace root. Do not print or persist secret values.
|
|
55
|
+
- Remember that skills installs from committed Git state.
|
|
56
|
+
|
|
57
|
+
Read references only when needed:
|
|
58
|
+
|
|
59
|
+
- `references/activation.md`
|
|
60
|
+
for install and activation flow
|
|
61
|
+
- `references/workflow.md`
|
|
62
|
+
for day-to-day repo operation
|
|
63
|
+
- `references/troubleshooting.md`
|
|
64
|
+
for bootstrap or environment issues
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Activation
|
|
2
|
+
|
|
3
|
+
## Global install
|
|
4
|
+
|
|
5
|
+
The marketplace skill prepares TrackOps globally for the agent. It must not create repo files by itself.
|
|
6
|
+
|
|
7
|
+
Install it with:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx skills add Baxahaun/trackops --skill trackops --full-depth --global --agent codex -y
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Replace `codex` with any supported target: `antigravity`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `github-copilot`, or `kiro-cli`.
|
|
14
|
+
|
|
15
|
+
Before using TrackOps commands, run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node scripts/bootstrap-trackops.js
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That bootstrap validates prerequisites, installs or updates the TrackOps runtime, and records state in `~/.trackops/runtime.json`.
|
|
22
|
+
|
|
23
|
+
## Local activation
|
|
24
|
+
|
|
25
|
+
Inside a repository, the normal flow is:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
trackops init
|
|
29
|
+
trackops opera install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
|
|
34
|
+
- Use `trackops init` when the repo is not yet managed by TrackOps.
|
|
35
|
+
- By default, `trackops init` creates a split workspace with `app/`, `ops/`, `/.env`, `/.env.example`, and `.trackops-workspace.json`.
|
|
36
|
+
- Use `trackops opera install` only after `trackops init` and only when the user wants OPERA.
|
|
37
|
+
- Use `trackops init --with-opera` only as an explicit shortcut.
|
|
38
|
+
- Use `trackops init --legacy-layout` only for compatibility with the old single-root layout.
|
|
39
|
+
- Never assume that a global skill install authorizes local repo mutations by default.
|