@rigour-labs/cli 5.5.4 → 6.1.0-beta.1

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.
@@ -4,8 +4,11 @@ import os from 'os';
4
4
  import chalk from 'chalk';
5
5
  import { execa } from 'execa';
6
6
  import fs from 'fs-extra';
7
+ import { createReadStream, promises as nativeFs } from 'fs';
8
+ import readline from 'readline';
7
9
  import http from 'http';
8
10
  import { randomUUID } from 'crypto';
11
+ import { normalizeAgentSession } from './studio-contracts.js';
9
12
  function sendJson(res, status, body) {
10
13
  res.writeHead(status, { 'Content-Type': 'application/json' });
11
14
  res.end(JSON.stringify(body));
@@ -20,6 +23,44 @@ async function readJsonIfExists(filePath) {
20
23
  return null;
21
24
  }
22
25
  }
26
+ async function readRecentLines(filePath, limit) {
27
+ const stat = await nativeFs.stat(filePath);
28
+ const bytes = Math.min(stat.size, 512 * 1024);
29
+ const handle = await nativeFs.open(filePath, 'r');
30
+ try {
31
+ const buffer = Buffer.alloc(bytes);
32
+ await handle.read(buffer, 0, bytes, stat.size - bytes);
33
+ return buffer.toString('utf8').split('\n').filter(line => line.trim()).slice(-limit);
34
+ }
35
+ finally {
36
+ await handle.close();
37
+ }
38
+ }
39
+ async function readEventPage(filePath, limit, before) {
40
+ if (!(await fs.pathExists(filePath)))
41
+ return { events: [], hasMore: false };
42
+ const ring = [];
43
+ let matching = 0;
44
+ const lines = readline.createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
45
+ for await (const line of lines) {
46
+ if (!line.trim())
47
+ continue;
48
+ try {
49
+ const event = JSON.parse(line);
50
+ const timestamp = Date.parse(event.timestamp ?? event.createdAt ?? 0);
51
+ if (before && (!Number.isFinite(timestamp) || timestamp >= before))
52
+ continue;
53
+ matching++;
54
+ ring.push(event);
55
+ if (ring.length > limit)
56
+ ring.shift();
57
+ }
58
+ catch {
59
+ // Malformed historical lines are ignored without hiding the remaining ledger.
60
+ }
61
+ }
62
+ return { events: ring.reverse(), hasMore: matching > limit };
63
+ }
23
64
  async function mergeMemoryStores(cwd) {
24
65
  const sources = [];
25
66
  const memories = {};
@@ -72,7 +113,7 @@ async function synthesizeAgents(cwd, checkpoints) {
72
113
  const sessionPath = path.join(cwd, '.rigour/agent-session.json');
73
114
  const session = await readJsonIfExists(sessionPath);
74
115
  if (session?.agents?.length) {
75
- return session;
116
+ return normalizeAgentSession(session);
76
117
  }
77
118
  const byAgent = new Map();
78
119
  for (const cp of checkpoints) {
@@ -94,13 +135,13 @@ async function synthesizeAgents(cwd, checkpoints) {
94
135
  }
95
136
  }
96
137
  const agents = [...byAgent.values()];
97
- return {
138
+ return normalizeAgentSession({
98
139
  sessionId: agents.length ? 'derived-from-checkpoints' : 'inactive',
99
140
  agents,
100
141
  status: agents.length ? 'completed' : 'inactive',
101
142
  createdAt: agents[0]?.registeredAt || new Date().toISOString(),
102
143
  derived: true,
103
- };
144
+ });
104
145
  }
105
146
  async function handleApiRequest(req, res, url, ctx) {
106
147
  if (!url.pathname.startsWith('/api'))
@@ -112,6 +153,7 @@ async function handleApiRequest(req, res, url, ctx) {
112
153
  }
113
154
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS, POST, DELETE');
114
155
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
156
+ res.setHeader('X-Rigour-Api-Version', '1');
115
157
  if (req.method === 'OPTIONS') {
116
158
  res.writeHead(204);
117
159
  res.end();
@@ -124,30 +166,35 @@ async function handleApiRequest(req, res, url, ctx) {
124
166
  'Cache-Control': 'no-cache',
125
167
  Connection: 'keep-alive',
126
168
  });
169
+ res.write(': connected\n\n');
127
170
  if (await fs.pathExists(eventsPath)) {
128
- const content = await fs.readFile(eventsPath, 'utf8');
129
- const lines = content.split('\n').filter((l) => l.trim());
130
- // Send recent history only — full 77MB dumps freeze the UI
131
- for (const line of lines.slice(-200)) {
171
+ for (const line of await readRecentLines(eventsPath, 200)) {
132
172
  res.write(`data: ${line}\n\n`);
133
173
  }
134
174
  }
135
175
  await fs.ensureDir(path.dirname(eventsPath));
136
- const watcher = fs.watch(path.dirname(eventsPath), async (_eventType, filename) => {
137
- if (filename === 'events.jsonl') {
138
- try {
139
- const content = await fs.readFile(eventsPath, 'utf8');
140
- const lines = content.split('\n').filter((l) => l.trim());
141
- const lastLine = lines[lines.length - 1];
142
- if (lastLine)
143
- res.write(`data: ${lastLine}\n\n`);
144
- }
145
- catch {
146
- // ignore transient reads
147
- }
176
+ let lastModified = (await fs.pathExists(eventsPath)) ? (await fs.stat(eventsPath)).mtimeMs : 0;
177
+ let ticks = 0;
178
+ const poller = setInterval(async () => {
179
+ try {
180
+ ticks++;
181
+ if (ticks % 15 === 0)
182
+ res.write(': heartbeat\n\n');
183
+ if (!(await fs.pathExists(eventsPath)))
184
+ return;
185
+ const stat = await fs.stat(eventsPath);
186
+ if (stat.mtimeMs <= lastModified)
187
+ return;
188
+ lastModified = stat.mtimeMs;
189
+ const lastLine = (await readRecentLines(eventsPath, 1)).at(-1);
190
+ if (lastLine)
191
+ res.write(`data: ${lastLine}\n\n`);
148
192
  }
149
- });
150
- req.on('close', () => watcher.close());
193
+ catch {
194
+ // A transient read failure must not terminate the event stream.
195
+ }
196
+ }, 1_000);
197
+ req.on('close', () => clearInterval(poller));
151
198
  return true;
152
199
  }
153
200
  if (url.pathname === '/api/file') {
@@ -158,13 +205,21 @@ async function handleApiRequest(req, res, url, ctx) {
158
205
  return true;
159
206
  }
160
207
  const absolutePath = path.resolve(cwd, filePath);
161
- if (!absolutePath.startsWith(cwd)) {
208
+ const relativePath = path.relative(path.resolve(cwd), absolutePath);
209
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
162
210
  res.writeHead(403);
163
211
  res.end('Forbidden');
164
212
  return true;
165
213
  }
166
214
  try {
167
- const content = await fs.readFile(absolutePath, 'utf8');
215
+ const [realRoot, realFile] = await Promise.all([fs.realpath(cwd), fs.realpath(absolutePath)]);
216
+ const realRelative = path.relative(realRoot, realFile);
217
+ if (!realRelative || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
218
+ res.writeHead(403);
219
+ res.end('Forbidden');
220
+ return true;
221
+ }
222
+ const content = await fs.readFile(realFile, 'utf8');
168
223
  res.writeHead(200, { 'Content-Type': 'text/plain' });
169
224
  res.end(content);
170
225
  }
@@ -267,6 +322,109 @@ async function handleApiRequest(req, res, url, ctx) {
267
322
  }
268
323
  return true;
269
324
  }
325
+ if (url.pathname === '/api/health') {
326
+ try {
327
+ const { getSystemHealth } = await import('@rigour-labs/core');
328
+ sendJson(res, 200, await getSystemHealth(cwd));
329
+ }
330
+ catch (e) {
331
+ sendJson(res, 503, { schemaVersion: 1, generatedAt: new Date().toISOString(), error: e.message });
332
+ }
333
+ return true;
334
+ }
335
+ if (url.pathname === '/api/lessons' && req.method === 'GET') {
336
+ try {
337
+ const { listLessons, loadTeamConfiguration } = await import('@rigour-labs/core');
338
+ const [lessons, teamConfiguration] = await Promise.all([
339
+ listLessons(cwd),
340
+ loadTeamConfiguration(),
341
+ ]);
342
+ sendJson(res, 200, { schemaVersion: 1, lessons, teamConfigured: Boolean(teamConfiguration) });
343
+ }
344
+ catch (e) {
345
+ sendJson(res, 500, { schemaVersion: 1, lessons: [], teamConfigured: false, error: e.message });
346
+ }
347
+ return true;
348
+ }
349
+ if (url.pathname === '/api/lessons' && req.method === 'POST') {
350
+ let body = '';
351
+ req.on('data', (chunk) => (body += chunk));
352
+ req.on('end', async () => {
353
+ try {
354
+ const payload = JSON.parse(body || '{}');
355
+ const allowedStates = new Set(['validated', 'promoted', 'rejected', 'superseded']);
356
+ if (typeof payload.id !== 'string' || !allowedStates.has(payload.state)) {
357
+ sendJson(res, 400, { error: 'A lesson id and valid target state are required.' });
358
+ return;
359
+ }
360
+ const { transitionLesson } = await import('@rigour-labs/core');
361
+ const publishing = payload.state === 'promoted';
362
+ const changed = await transitionLesson(payload.id, payload.state, {
363
+ visibility: publishing ? 'team' : undefined,
364
+ queueSync: publishing,
365
+ });
366
+ sendJson(res, changed ? 200 : 404, { success: changed });
367
+ }
368
+ catch (e) {
369
+ sendJson(res, 400, { error: e.message });
370
+ }
371
+ });
372
+ return true;
373
+ }
374
+ if (url.pathname === '/api/agent-history') {
375
+ try {
376
+ const requested = Number(url.searchParams.get('limit') ?? 250);
377
+ const limit = Math.max(1, Math.min(500, Number.isFinite(requested) ? requested : 250));
378
+ const beforeValue = Number(url.searchParams.get('before'));
379
+ const before = Number.isFinite(beforeValue) && beforeValue > 0 ? beforeValue : undefined;
380
+ const page = await readEventPage(eventsPath, limit, before);
381
+ const { buildAgentRuns, normalizeAgentEvents } = await import('@rigour-labs/core');
382
+ const events = normalizeAgentEvents(page.events);
383
+ sendJson(res, 200, {
384
+ schemaVersion: 1,
385
+ events,
386
+ runs: buildAgentRuns(events),
387
+ hasMore: page.hasMore,
388
+ nextBefore: events.length ? Date.parse(events.at(-1).timestamp) : null,
389
+ });
390
+ }
391
+ catch (e) {
392
+ sendJson(res, 500, { schemaVersion: 1, events: [], runs: [], hasMore: false, error: e.message });
393
+ }
394
+ return true;
395
+ }
396
+ if (url.pathname === '/api/knowledge-graph') {
397
+ try {
398
+ const { buildAgentRuns, buildEngineeringKnowledgeGraph, getRepositoryId, listKnowledgeLessons, normalizeAgentEvents } = await import('@rigour-labs/core');
399
+ const [page, dependencyGraph, lessons, repositoryId, patternIndex, memory] = await Promise.all([
400
+ readEventPage(eventsPath, 2_000),
401
+ readJsonIfExists(path.join(cwd, '.rigour/dependency-graph.json')),
402
+ listKnowledgeLessons(cwd).catch(() => []),
403
+ getRepositoryId(cwd),
404
+ readJsonIfExists(path.join(cwd, '.rigour/patterns.json')),
405
+ mergeMemoryStores(cwd),
406
+ ]);
407
+ const events = normalizeAgentEvents(page.events);
408
+ sendJson(res, 200, buildEngineeringKnowledgeGraph({
409
+ repository: { id: repositoryId, name: path.basename(cwd) },
410
+ dependencyGraph,
411
+ events,
412
+ runs: buildAgentRuns(events),
413
+ lessons,
414
+ patterns: Array.isArray(patternIndex?.patterns) ? patternIndex.patterns : [],
415
+ memories: Object.entries(memory.memories).map(([id, value]) => ({
416
+ id,
417
+ label: id,
418
+ source: value?.source,
419
+ detail: value?.type || value?.category || 'retained memory',
420
+ })),
421
+ }));
422
+ }
423
+ catch (e) {
424
+ sendJson(res, 500, { schemaVersion: 1, nodes: [], edges: [], counts: {}, truncated: false, error: e.message });
425
+ }
426
+ return true;
427
+ }
270
428
  if (url.pathname === '/api/index-stats') {
271
429
  try {
272
430
  const indexPath = path.join(cwd, '.rigour/patterns.json');
@@ -405,7 +563,7 @@ async function handleApiRequest(req, res, url, ctx) {
405
563
  if (url.pathname === '/api/drift') {
406
564
  try {
407
565
  const { generateTemporalDriftReport } = await import('@rigour-labs/core');
408
- const report = generateTemporalDriftReport(cwd);
566
+ const report = await generateTemporalDriftReport(cwd);
409
567
  sendJson(res, 200, report || { totalScans: 0 });
410
568
  }
411
569
  catch {
@@ -719,12 +877,97 @@ async function handleApiRequest(req, res, url, ctx) {
719
877
  });
720
878
  return true;
721
879
  }
880
+ if (url.pathname === '/api/firewall') {
881
+ try {
882
+ const { loadCurrentTransaction, listTransactions, loadLatestAttestation, verifyAttestation, } = await import('@rigour-labs/core');
883
+ const current = await loadCurrentTransaction(cwd);
884
+ const transactions = await listTransactions(cwd);
885
+ const attestation = await loadLatestAttestation(cwd);
886
+ const attestationValid = attestation ? await verifyAttestation(cwd, attestation) : false;
887
+ const advPath = path.join(cwd, '.rigour/adversarial-report.json');
888
+ const adversarial = await fs.pathExists(advPath) ? await fs.readJson(advPath) : null;
889
+ const decisionsPath = path.join(cwd, '.rigour/firewall-decisions.jsonl');
890
+ let decisions = [];
891
+ if (await fs.pathExists(decisionsPath)) {
892
+ const content = await fs.readFile(decisionsPath, 'utf8');
893
+ decisions = content
894
+ .split('\n')
895
+ .filter((l) => l.trim())
896
+ .slice(-100)
897
+ .map((l) => {
898
+ try {
899
+ return JSON.parse(l);
900
+ }
901
+ catch {
902
+ return null;
903
+ }
904
+ })
905
+ .filter(Boolean)
906
+ .reverse();
907
+ }
908
+ let recentDenies = [];
909
+ if (await fs.pathExists(eventsPath)) {
910
+ const content = await fs.readFile(eventsPath, 'utf8');
911
+ recentDenies = content
912
+ .split('\n')
913
+ .filter((l) => l.trim())
914
+ .map((l) => {
915
+ try {
916
+ return JSON.parse(l);
917
+ }
918
+ catch {
919
+ return null;
920
+ }
921
+ })
922
+ .filter((e) => e && (e.type === 'firewall_deny' || e.decision === 'timeout-deny' || e.decision === 'deny'))
923
+ .slice(-50)
924
+ .reverse();
925
+ }
926
+ const hooksPresent = (await fs.pathExists(path.join(cwd, '.cursor/hooks.json'))) ||
927
+ (await fs.pathExists(path.join(cwd, '.claude/settings.json'))) ||
928
+ (await fs.pathExists(path.join(cwd, '.clinerules'))) ||
929
+ (await fs.pathExists(path.join(cwd, '.windsurf/hooks.json')));
930
+ const agentScopesPath = path.join(cwd, '.rigour/agent-session.json');
931
+ const agentSession = await fs.pathExists(agentScopesPath) ? await fs.readJson(agentScopesPath) : null;
932
+ const scopeActive = Array.isArray(agentSession?.agents) && agentSession.agents.length > 0;
933
+ const typedSeen = recentDenies.some((e) => e.ruleId?.startsWith?.('shell.') || e.tool === 'rigour_run');
934
+ const gatewayWired = false; // McpGateway not yet the MCP proxy path
935
+ sendJson(res, 200, {
936
+ current,
937
+ transactions: transactions.slice(0, 20),
938
+ attestation,
939
+ attestationValid,
940
+ adversarial,
941
+ decisions,
942
+ recentDenies,
943
+ failClosed: true,
944
+ mediation: {
945
+ status: gatewayWired && hooksPresent ? 'full' : 'partial',
946
+ typedCommands: typedSeen || hooksPresent ? 'rigour_run_only' : 'not_observed',
947
+ scopeEnforcement: scopeActive ? 'requires_agent_id' : 'inactive',
948
+ arbitration: 'fail-closed',
949
+ hooksInstalled: hooksPresent,
950
+ mcpGateway: gatewayWired,
951
+ },
952
+ });
953
+ }
954
+ catch (e) {
955
+ sendJson(res, 500, { error: e.message });
956
+ }
957
+ return true;
958
+ }
722
959
  if (url.pathname === '/api/arbitrate' && req.method === 'POST') {
723
960
  let body = '';
724
961
  req.on('data', (chunk) => (body += chunk));
725
962
  req.on('end', async () => {
726
963
  try {
727
964
  const decision = JSON.parse(body);
965
+ const { consumeArbitrationToken } = await import('@rigour-labs/core');
966
+ const ok = await consumeArbitrationToken(cwd, decision.requestId, decision.token);
967
+ if (!ok) {
968
+ sendJson(res, 403, { error: 'Invalid or missing arbitration token (one-time, fail-closed)' });
969
+ return;
970
+ }
728
971
  const logEntry = JSON.stringify({
729
972
  id: randomUUID(),
730
973
  timestamp: new Date().toISOString(),
@@ -801,6 +1044,18 @@ export const studioCommand = new Command('studio')
801
1044
  `http://127.0.0.1:${studioPort}`,
802
1045
  ]);
803
1046
  const ctx = { cwd, eventsPath, allowedOrigins };
1047
+ const { ensureAutomaticIndex, loadTeamConfiguration, syncTeamOutbox } = await import('@rigour-labs/core');
1048
+ void ensureAutomaticIndex(cwd).catch((error) => {
1049
+ console.warn(chalk.yellow(`Structural index is degraded: ${error instanceof Error ? error.message : String(error)}`));
1050
+ });
1051
+ const syncTimer = setInterval(() => {
1052
+ void loadTeamConfiguration().then((config) => {
1053
+ if (config)
1054
+ return syncTeamOutbox().catch(() => undefined);
1055
+ return undefined;
1056
+ });
1057
+ }, 30_000);
1058
+ syncTimer.unref();
804
1059
  console.log(chalk.bold.cyan('\nšŸ›”ļø Launching Rigour Studio...'));
805
1060
  console.log(chalk.gray(`Project Root: ${cwd}`));
806
1061
  const configPath = path.join(cwd, 'rigour.yml');
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const teamCommand: Command;
@@ -0,0 +1,78 @@
1
+ import chalk from 'chalk';
2
+ import { Command } from 'commander';
3
+ import { backfillTeamEmbeddings, doctorTeamConnection, initializeTeamSchema, saveTeamConfiguration, searchTeamKnowledge, syncTeamOutbox, } from '@rigour-labs/core';
4
+ export const teamCommand = new Command('team').description('Configure and diagnose PostgreSQL team learning');
5
+ teamCommand
6
+ .command('init-schema')
7
+ .description('Initialize the PostgreSQL team schema with an administrator connection')
8
+ .requiredOption('--database-url <url>', 'Administrator PostgreSQL URL; remote databases must require TLS')
9
+ .option('--pgvector', 'Enable the optional pgvector semantic knowledge index')
10
+ .action(async (options) => {
11
+ await initializeTeamSchema(options.databaseUrl, { pgvector: Boolean(options.pgvector) });
12
+ console.log(chalk.green(`Rigour team schema initialized${options.pgvector ? ' with pgvector' : ''}.`));
13
+ });
14
+ teamCommand
15
+ .command('configure')
16
+ .description('Configure a provisioned PostgreSQL team database')
17
+ .requiredOption('--database-url <url>', 'PostgreSQL URL; remote databases must require TLS')
18
+ .requiredOption('--organization <id>', 'Organization identifier')
19
+ .requiredOption('--team <id>', 'Team identifier')
20
+ .requiredOption('--actor <id>', 'Actor identifier provisioned for the database role')
21
+ .option('--initialize-schema', 'Create or update the Rigour schema (administrator only)')
22
+ .option('--pgvector', 'Use pgvector to rank team knowledge semantically')
23
+ .action(async (options) => {
24
+ if (options.initializeSchema)
25
+ await initializeTeamSchema(options.databaseUrl, { pgvector: Boolean(options.pgvector) });
26
+ const config = {
27
+ databaseUrl: options.databaseUrl,
28
+ organizationId: options.organization,
29
+ teamId: options.team,
30
+ actorId: options.actor,
31
+ semantic: options.pgvector ? {
32
+ provider: 'pgvector',
33
+ model: 'Xenova/all-MiniLM-L6-v2',
34
+ dimensions: 384,
35
+ } : undefined,
36
+ };
37
+ const result = await doctorTeamConnection(config);
38
+ await saveTeamConfiguration(config);
39
+ console.log(chalk.green(result.message));
40
+ });
41
+ teamCommand
42
+ .command('doctor')
43
+ .description('Verify team database TLS, schema, role, and membership')
44
+ .action(async () => {
45
+ const result = await doctorTeamConnection();
46
+ console.log(JSON.stringify(result, null, 2));
47
+ if (result.connectivity === 'offline')
48
+ process.exitCode = 1;
49
+ });
50
+ teamCommand
51
+ .command('semantic-backfill')
52
+ .description('Embed existing validated knowledge owned by the configured actor')
53
+ .option('--limit <count>', 'Maximum lessons to embed in this run', '200')
54
+ .action(async (options) => {
55
+ const limit = Number.parseInt(options.limit, 10);
56
+ if (!Number.isInteger(limit) || limit < 1)
57
+ throw new Error('--limit must be a positive integer.');
58
+ console.log(JSON.stringify(await backfillTeamEmbeddings(limit), null, 2));
59
+ });
60
+ teamCommand
61
+ .command('semantic-search')
62
+ .description('Diagnose advisory cross-repository knowledge recall')
63
+ .argument('<query>', 'Natural-language engineering question')
64
+ .option('--limit <count>', 'Maximum candidates to return', '8')
65
+ .action(async (query, options) => {
66
+ const limit = Number.parseInt(options.limit, 10);
67
+ if (!Number.isInteger(limit) || limit < 1 || limit > 25)
68
+ throw new Error('--limit must be between 1 and 25.');
69
+ console.log(JSON.stringify(await searchTeamKnowledge(query, limit), null, 2));
70
+ });
71
+ teamCommand
72
+ .command('sync')
73
+ .description('Synchronize approved team lessons from the offline outbox')
74
+ .option('--dry-run', 'Report pending records without sending them')
75
+ .action(async (options) => {
76
+ const result = await syncTeamOutbox({ dryRun: Boolean(options.dryRun) });
77
+ console.log(JSON.stringify(result, null, 2));
78
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/cli",
3
- "version": "5.5.4",
3
+ "version": "6.1.0-beta.1",
4
4
  "description": "AI-native quality gates with local LLM analysis. Forces AI agents (Claude, Cursor, Copilot, Cline, Windsurf) to meet engineering standards. Bayesian Brain learns your codebase. Zero config: npx rigour-scan.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -53,12 +53,13 @@
53
53
  "inquirer": "9.2.16",
54
54
  "ora": "^8.0.1",
55
55
  "yaml": "^2.8.2",
56
- "@rigour-labs/core": "5.5.4"
56
+ "@rigour-labs/core": "6.1.0-beta.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/fs-extra": "^11.0.4",
60
60
  "@types/inquirer": "9.0.7",
61
- "@types/node": "^25.0.3"
61
+ "@types/node": "^25.0.3",
62
+ "@rigour-labs/studio": "6.1.0-beta.1"
62
63
  },
63
64
  "scripts": {
64
65
  "build": "tsc && pnpm bundle-studio",