trackops 1.0.0 → 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.
Files changed (72) hide show
  1. package/README.md +341 -232
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +518 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +146 -55
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +523 -0
  10. package/lib/opera.js +319 -170
  11. package/lib/registry.js +27 -13
  12. package/lib/release.js +56 -0
  13. package/lib/resources.js +42 -0
  14. package/lib/server.js +912 -418
  15. package/lib/skills.js +148 -124
  16. package/lib/workspace.js +260 -0
  17. package/locales/en.json +331 -139
  18. package/locales/es.json +331 -139
  19. package/package.json +14 -3
  20. package/scripts/skills-marketplace-smoke.js +124 -0
  21. package/scripts/smoke-tests.js +445 -0
  22. package/scripts/sync-skill-version.js +21 -0
  23. package/scripts/validate-skill.js +88 -0
  24. package/skills/trackops/SKILL.md +64 -0
  25. package/skills/trackops/agents/openai.yaml +3 -0
  26. package/skills/trackops/references/activation.md +39 -0
  27. package/skills/trackops/references/troubleshooting.md +34 -0
  28. package/skills/trackops/references/workflow.md +20 -0
  29. package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
  30. package/skills/trackops/skill.json +29 -0
  31. package/templates/etapa/agent.md +2 -2
  32. package/templates/etapa/references/etapa-cycle.md +1 -1
  33. package/templates/opera/agent.md +1 -1
  34. package/templates/opera/en/agent.md +26 -0
  35. package/templates/opera/en/genesis.md +79 -0
  36. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  37. package/templates/opera/en/references/opera-cycle.md +62 -0
  38. package/templates/opera/en/registry.md +28 -0
  39. package/templates/opera/en/router.md +39 -0
  40. package/templates/opera/genesis.md +79 -94
  41. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  42. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  43. package/templates/skills/project-starter-skill/SKILL.md +5 -3
  44. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  45. package/ui/css/base.css +266 -0
  46. package/ui/css/charts.css +327 -0
  47. package/ui/css/components.css +570 -0
  48. package/ui/css/panels.css +956 -0
  49. package/ui/css/tokens.css +227 -0
  50. package/ui/favicon.svg +5 -0
  51. package/ui/index.html +91 -351
  52. package/ui/js/api.js +220 -0
  53. package/ui/js/app.js +200 -0
  54. package/ui/js/console-logger.js +172 -0
  55. package/ui/js/i18n.js +14 -0
  56. package/ui/js/icons.js +104 -0
  57. package/ui/js/onboarding.js +439 -0
  58. package/ui/js/router.js +125 -0
  59. package/ui/js/state.js +130 -0
  60. package/ui/js/theme.js +100 -0
  61. package/ui/js/time-tracker.js +248 -0
  62. package/ui/js/utils.js +175 -0
  63. package/ui/js/views/board.js +255 -0
  64. package/ui/js/views/execution.js +256 -0
  65. package/ui/js/views/flash.js +47 -0
  66. package/ui/js/views/insights.js +340 -0
  67. package/ui/js/views/overview.js +365 -0
  68. package/ui/js/views/settings.js +381 -0
  69. package/ui/js/views/sidebar.js +131 -0
  70. package/ui/js/views/skills.js +163 -0
  71. package/ui/js/views/tasks.js +406 -0
  72. package/ui/js/views/topbar.js +239 -0
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ const assert = require("assert");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { spawnSync } = require("child_process");
8
+
9
+ const ROOT = path.resolve(__dirname, "..");
10
+ const SKILL_DIR = path.join(ROOT, "skills", "trackops");
11
+ const SKILL_CONFIG = JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "skill.json"), "utf8"));
12
+
13
+ function run(command, args, cwd, envOverrides = {}) {
14
+ const shell = process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
15
+ return spawnSync(command, args, {
16
+ cwd,
17
+ encoding: "utf8",
18
+ env: { ...process.env, ...envOverrides },
19
+ shell,
20
+ });
21
+ }
22
+
23
+ function getNpxCommand() {
24
+ return process.platform === "win32" ? "npx.cmd" : "npx";
25
+ }
26
+
27
+ function ensureOk(result, context) {
28
+ const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
29
+ assert.strictEqual(result.status, 0, output || context);
30
+ return output;
31
+ }
32
+
33
+ function initGitRepo(repo) {
34
+ ensureOk(run("git", ["init"], repo), "git init failed");
35
+ ensureOk(run("git", ["config", "user.email", "skills-smoke@example.com"], repo), "git config email failed");
36
+ ensureOk(run("git", ["config", "user.name", "Skills Smoke"], repo), "git config name failed");
37
+ ensureOk(run("git", ["add", "."], repo), "git add failed");
38
+ ensureOk(run("git", ["commit", "-m", "skills smoke fixture"], repo), "git commit failed");
39
+ }
40
+
41
+ function buildIsolatedEnv(homeRoot) {
42
+ const env = {
43
+ HOME: homeRoot,
44
+ USERPROFILE: homeRoot,
45
+ APPDATA: path.join(homeRoot, "AppData", "Roaming"),
46
+ LOCALAPPDATA: path.join(homeRoot, "AppData", "Local"),
47
+ XDG_CONFIG_HOME: path.join(homeRoot, ".config"),
48
+ };
49
+ for (const value of Object.values(env)) {
50
+ fs.mkdirSync(value, { recursive: true });
51
+ }
52
+ return env;
53
+ }
54
+
55
+ function findInstalledSkill(rootDir, skillName) {
56
+ const matches = [];
57
+
58
+ function walk(dir) {
59
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ for (const entry of entries) {
61
+ const fullPath = path.join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ walk(fullPath);
64
+ continue;
65
+ }
66
+ if (entry.isFile() && entry.name === "SKILL.md" && path.basename(path.dirname(fullPath)) === skillName) {
67
+ matches.push(path.dirname(fullPath));
68
+ }
69
+ }
70
+ }
71
+
72
+ walk(rootDir);
73
+ return matches;
74
+ }
75
+
76
+ function main() {
77
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trackops-skills-smoke-"));
78
+ const sourceRepo = path.join(tempRoot, "source");
79
+ const sourceSkillDir = path.join(sourceRepo, "skills", "trackops");
80
+ const homeRoot = path.join(tempRoot, "home");
81
+
82
+ fs.mkdirSync(path.dirname(sourceSkillDir), { recursive: true });
83
+ fs.cpSync(SKILL_DIR, sourceSkillDir, { recursive: true });
84
+ initGitRepo(sourceRepo);
85
+
86
+ const env = buildIsolatedEnv(homeRoot);
87
+ const distribution = SKILL_CONFIG.distribution || {};
88
+ const skillName = distribution.skill || SKILL_CONFIG.name;
89
+
90
+ const listResult = run(
91
+ getNpxCommand(),
92
+ ["--yes", "skills", "add", sourceRepo, "--list", "--skill", skillName, "--full-depth", "-y"],
93
+ ROOT,
94
+ env,
95
+ );
96
+ const listOutput = ensureOk(listResult, "skills list failed");
97
+ assert.match(listOutput, /\btrackops\b/i, listOutput);
98
+
99
+ const installResult = run(
100
+ getNpxCommand(),
101
+ ["--yes", "skills", "add", sourceRepo, "--skill", skillName, "--full-depth", "--global", "--agent", "codex", "--copy", "-y"],
102
+ ROOT,
103
+ env,
104
+ );
105
+ ensureOk(installResult, "skills install failed");
106
+
107
+ const installed = findInstalledSkill(homeRoot, skillName);
108
+ assert.ok(installed.length >= 1, `trackops skill was not installed under ${homeRoot}`);
109
+
110
+ const installedSkillDir = installed[0];
111
+ assert.ok(fs.existsSync(path.join(installedSkillDir, "scripts", "bootstrap-trackops.js")));
112
+ assert.ok(fs.existsSync(path.join(installedSkillDir, "references", "activation.md")));
113
+ assert.ok(fs.existsSync(path.join(installedSkillDir, "skill.json")));
114
+
115
+ fs.rmSync(tempRoot, { recursive: true, force: true });
116
+ console.log("skills marketplace smoke OK");
117
+ }
118
+
119
+ try {
120
+ main();
121
+ } catch (error) {
122
+ console.error(error.message);
123
+ process.exit(1);
124
+ }
@@ -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();