@notis_ai/cli 0.2.0 → 0.2.2

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 (81) hide show
  1. package/README.md +122 -158
  2. package/dist/scaffolds/notes/app/globals.css +3 -0
  3. package/dist/scaffolds/notes/app/layout.tsx +6 -0
  4. package/dist/scaffolds/notes/app/page.tsx +1465 -0
  5. package/dist/scaffolds/notes/components/ui/badge.tsx +28 -0
  6. package/dist/scaffolds/notes/components/ui/button.tsx +53 -0
  7. package/dist/scaffolds/notes/components/ui/card.tsx +56 -0
  8. package/dist/scaffolds/notes/components.json +20 -0
  9. package/dist/scaffolds/notes/lib/utils.ts +6 -0
  10. package/dist/scaffolds/notes/notis.config.ts +31 -0
  11. package/dist/scaffolds/notes/package.json +31 -0
  12. package/dist/scaffolds/notes/postcss.config.mjs +8 -0
  13. package/dist/scaffolds/notes/tailwind.config.ts +58 -0
  14. package/dist/scaffolds/notes/tsconfig.json +22 -0
  15. package/dist/scaffolds/notes/vite.config.ts +10 -0
  16. package/dist/scaffolds.json +13 -0
  17. package/package.json +12 -2
  18. package/skills/notis-apps/SKILL.md +162 -0
  19. package/skills/notis-apps/cli.md +208 -0
  20. package/skills/notis-cli/SKILL.md +258 -0
  21. package/skills/notis-query/cli.md +40 -0
  22. package/src/cli.js +1 -1
  23. package/src/command-specs/apps.js +1029 -59
  24. package/src/command-specs/helpers.js +6 -41
  25. package/src/command-specs/index.js +1 -7
  26. package/src/command-specs/meta.js +7 -6
  27. package/src/command-specs/tools.js +29 -34
  28. package/src/runtime/agent-browser.js +192 -0
  29. package/src/runtime/app-boundary-validator.js +132 -0
  30. package/src/runtime/app-dev-server.js +605 -0
  31. package/src/runtime/app-dev-sessions.js +87 -0
  32. package/src/runtime/app-platform.js +1037 -82
  33. package/src/runtime/cli-mode.generated.js +4 -0
  34. package/src/runtime/cli-mode.js +29 -0
  35. package/src/runtime/output.js +2 -2
  36. package/src/runtime/ports.js +15 -0
  37. package/src/runtime/profiles.js +36 -5
  38. package/src/runtime/transport.js +131 -6
  39. package/template/.harness/index.html.tmpl +211 -0
  40. package/template/app/globals.css +3 -0
  41. package/template/app/layout.tsx +6 -0
  42. package/template/app/page.tsx +90 -0
  43. package/template/components/ui/badge.tsx +28 -0
  44. package/template/components/ui/button.tsx +53 -0
  45. package/template/components/ui/card.tsx +56 -0
  46. package/template/components.json +20 -0
  47. package/template/lib/utils.ts +6 -0
  48. package/template/metadata/cover.png +0 -0
  49. package/template/metadata/screenshot-1.png +0 -0
  50. package/template/metadata/screenshot-2.png +0 -0
  51. package/template/metadata/screenshot-3.png +0 -0
  52. package/template/notis.config.ts +37 -0
  53. package/template/package.json +31 -0
  54. package/template/packages/sdk/package.json +32 -0
  55. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +273 -0
  56. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  57. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  58. package/template/packages/sdk/src/config.ts +71 -0
  59. package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
  60. package/template/packages/sdk/src/hooks/useDatabase.ts +76 -0
  61. package/template/packages/sdk/src/hooks/useMultiSelect.ts +503 -0
  62. package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
  63. package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  64. package/template/packages/sdk/src/hooks/useTool.ts +64 -0
  65. package/template/packages/sdk/src/hooks/useTools.ts +56 -0
  66. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  67. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +50 -0
  68. package/template/packages/sdk/src/index.ts +54 -0
  69. package/template/packages/sdk/src/provider.tsx +43 -0
  70. package/template/packages/sdk/src/runtime.ts +161 -0
  71. package/template/packages/sdk/src/styles.css +38 -0
  72. package/template/packages/sdk/src/ui.ts +15 -0
  73. package/template/packages/sdk/src/vite.ts +56 -0
  74. package/template/packages/sdk/tsconfig.json +15 -0
  75. package/template/postcss.config.mjs +8 -0
  76. package/template/tailwind.config.ts +58 -0
  77. package/template/tsconfig.json +22 -0
  78. package/template/vite.config.ts +10 -0
  79. package/src/command-specs/auth.js +0 -178
  80. package/src/command-specs/db.js +0 -163
  81. package/src/runtime/app-preview-server.js +0 -272
@@ -1,13 +1,6 @@
1
- import { createInterface } from 'node:readline/promises';
2
1
  import { randomUUID } from 'node:crypto';
3
2
  import { CliError, EXIT_CODES, usageError } from '../runtime/errors.js';
4
3
  import { callTool, httpRequest } from '../runtime/transport.js';
5
- import {
6
- DEFAULT_PROFILE,
7
- ensureProfile,
8
- loadConfig,
9
- saveConfig,
10
- } from '../runtime/profiles.js';
11
4
 
12
5
  export function parseJson(value, label) {
13
6
  try {
@@ -41,16 +34,6 @@ export function normalizeToolkits(value) {
41
34
  .filter(Boolean);
42
35
  }
43
36
 
44
- export async function promptForJwt() {
45
- const rl = createInterface({ input: process.stdin, output: process.stdout });
46
- try {
47
- const jwt = await rl.question('Paste your Notis JWT: ');
48
- return jwt.trim();
49
- } finally {
50
- rl.close();
51
- }
52
- }
53
-
54
37
  export function nextIdempotencyKey(globalOptions) {
55
38
  return globalOptions.idempotencyKey || randomUUID();
56
39
  }
@@ -105,33 +88,15 @@ export async function healthCheck(runtime) {
105
88
  });
106
89
  }
107
90
 
108
- export function updateStoredProfile({ profileName, jwt, apiBase, setCurrent = true }) {
109
- let config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
110
- const resolvedName = profileName || DEFAULT_PROFILE;
111
- config.profiles[resolvedName] = {
112
- ...config.profiles[resolvedName],
113
- ...(jwt ? { jwt } : {}),
114
- ...(apiBase ? { api_base: apiBase } : {}),
115
- };
116
- if (setCurrent) {
117
- config.current_profile = resolvedName;
118
- }
119
- saveConfig(config);
120
- return config;
121
- }
122
-
123
- export function clearStoredJwt(profileName) {
124
- const config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
125
- delete config.profiles[profileName || DEFAULT_PROFILE].jwt;
126
- saveConfig(config);
127
- return config;
128
- }
129
-
130
- export async function fetchToolSchema(runtime, toolName) {
91
+ export async function fetchToolSchema(runtime, toolName, rawToolkits) {
92
+ const toolkits = normalizeToolkits(rawToolkits);
131
93
  const result = await runToolCommand({
132
94
  runtime,
133
95
  toolName: 'notis_find_tools',
134
- arguments_: { query: toolName },
96
+ arguments_: {
97
+ query: toolName,
98
+ ...(toolkits.length ? { toolkits } : {}),
99
+ },
135
100
  });
136
101
  const tools = result.payload.tools || [];
137
102
  const match = tools.find((t) => t.name === toolName);
@@ -1,20 +1,14 @@
1
- import { authCommandSpecs } from './auth.js';
2
1
  import { appsCommandSpecs } from './apps.js';
3
- import { dbCommandSpecs } from './db.js';
4
2
  import { toolsCommandSpecs } from './tools.js';
5
3
  import { metaCommandSpecs } from './meta.js';
6
4
 
7
5
  export const GROUP_SUMMARIES = {
8
- auth: 'Authentication and profile management.',
9
- apps: 'Build, preview, and deploy Notis Apps.',
10
- db: 'List, query, and update native Notis Databases.',
6
+ apps: 'Develop, build, and deploy Notis Apps.',
11
7
  tools: 'Discover and execute generic tools exposed through Notis.',
12
8
  };
13
9
 
14
10
  export const COMMAND_SPECS = [
15
- ...authCommandSpecs,
16
11
  ...appsCommandSpecs,
17
- ...dbCommandSpecs,
18
12
  ...toolsCommandSpecs,
19
13
  ...metaCommandSpecs,
20
14
  ];
@@ -29,10 +29,10 @@ async function doctorHandler(ctx) {
29
29
 
30
30
  const hints = [];
31
31
  if (checks.auth === 'missing') {
32
- hints.push({ command: 'notis auth login --jwt <token>', reason: 'Configure credentials' });
32
+ hints.push({ command: 'Open the Notis desktop app and sign in', reason: 'Configure CLI credentials' });
33
33
  }
34
34
  if (checks.health === 'error') {
35
- hints.push({ command: 'notis auth status', reason: 'Check API base URL configuration' });
35
+ hints.push({ command: 'Open the Notis desktop app and sign in again', reason: 'Refresh local CLI configuration' });
36
36
  }
37
37
  if (checks.tool_roundtrip === 'error') {
38
38
  hints.push({ command: 'notis whoami', reason: 'Verify your account and permissions' });
@@ -117,7 +117,7 @@ export const metaCommandSpecs = [
117
117
  output_schema: 'Returns profile, api_base, user_id, toolkit count, and CLI version.',
118
118
  mutates: false,
119
119
  idempotent: true,
120
- related_commands: ['notis auth status --verify', 'notis doctor'],
120
+ related_commands: ['notis doctor'],
121
121
  backend_call: { type: 'tool', name: 'notis_find_toolkits' },
122
122
  handler: whoamiHandler,
123
123
  },
@@ -130,7 +130,8 @@ export const metaCommandSpecs = [
130
130
  output_schema: 'Returns config, auth, health, and roundtrip check statuses.',
131
131
  mutates: false,
132
132
  idempotent: true,
133
- related_commands: ['notis auth status --verify', 'notis tools toolkits'],
133
+ require_auth: false,
134
+ related_commands: ['notis tools toolkits'],
134
135
  backend_call: { type: 'health+tool_roundtrip' },
135
136
  handler: doctorHandler,
136
137
  },
@@ -139,10 +140,10 @@ export const metaCommandSpecs = [
139
140
  summary: 'Describe a first-class CLI command in detail.',
140
141
  when_to_use: 'Use this when an agent or human needs the exact shape, examples, and semantics of a command.',
141
142
  args_schema: {
142
- arguments: [{ token: '<command...>', key: 'commandPath', description: 'Command path to describe, such as "apps push".' }],
143
+ arguments: [{ token: '<command...>', key: 'commandPath', description: 'Command path to describe, such as "apps deploy".' }],
143
144
  options: [],
144
145
  },
145
- examples: ['notis describe apps push', 'notis describe db query'],
146
+ examples: ['notis describe apps deploy', 'notis describe tools exec'],
146
147
  output_schema: 'Returns the command spec metadata for the requested command.',
147
148
  mutates: false,
148
149
  idempotent: true,
@@ -6,6 +6,7 @@ import {
6
6
  nextIdempotencyKey,
7
7
  parseJson,
8
8
  parseMaybeJson,
9
+ normalizeToolkits,
9
10
  resolveSearchToolkits,
10
11
  runToolCommand,
11
12
  validateArguments,
@@ -33,6 +34,22 @@ async function resolveJsonInput(value, label) {
33
34
  return parseJson(value, label);
34
35
  }
35
36
 
37
+ const LONG_RUNNING_TOOL_TIMEOUT_MS = 600000;
38
+ const LONG_RUNNING_TOOL_NAMES = new Set([
39
+ 'notis-default-generate_image_openai',
40
+ 'notis-default-edit_image_openai',
41
+ 'notis-default-generate_image_gemini',
42
+ 'notis-default-edit_image_gemini',
43
+ ]);
44
+
45
+ function ensureLongRunningToolTimeout(ctx, toolNames) {
46
+ const names = Array.isArray(toolNames) ? toolNames : [toolNames];
47
+ if (!names.some((name) => LONG_RUNNING_TOOL_NAMES.has(name))) {
48
+ return;
49
+ }
50
+ ctx.runtime.timeoutMs = Math.max(ctx.runtime.timeoutMs || 0, LONG_RUNNING_TOOL_TIMEOUT_MS);
51
+ }
52
+
36
53
  async function toolsToolkitsHandler(ctx) {
37
54
  const toolkits = await fetchToolkits(ctx.runtime);
38
55
  return ctx.output.emitSuccess({
@@ -105,11 +122,14 @@ async function toolsDescribeHandler(ctx) {
105
122
  }
106
123
 
107
124
  async function toolsExecHandler(ctx) {
125
+ ensureLongRunningToolTimeout(ctx, ctx.args.toolName);
126
+ const toolkits = normalizeToolkits(ctx.options.toolkits);
127
+
108
128
  if (ctx.options.getSchema) {
109
- const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName);
129
+ const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName, ctx.options.toolkits);
110
130
  return ctx.output.emitSuccess({
111
131
  command: ctx.spec.command_path.join(' '),
112
- data: { tool },
132
+ data: { tool, toolkits },
113
133
  humanSummary: `Schema for ${ctx.args.toolName}`,
114
134
  hints: [
115
135
  { command: `notis tools exec ${ctx.args.toolName} --dry-run --arguments '{}'`, reason: 'Validate arguments before executing' },
@@ -124,13 +144,13 @@ async function toolsExecHandler(ctx) {
124
144
  : parseMaybeJson(rawArguments, 'arguments') || {};
125
145
 
126
146
  if (ctx.options.dryRun) {
127
- const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName);
147
+ const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName, ctx.options.toolkits);
128
148
  const errors = validateArguments(tool.parameters, args);
129
149
 
130
150
  if (errors.length) {
131
151
  return ctx.output.emitSuccess({
132
152
  command: ctx.spec.command_path.join(' '),
133
- data: { valid: false, errors, tool: ctx.args.toolName },
153
+ data: { valid: false, errors, tool: ctx.args.toolName, toolkits },
134
154
  humanSummary: `Dry run failed: ${errors.length} validation error(s)`,
135
155
  hints: [
136
156
  { command: `notis tools exec ${ctx.args.toolName} --get-schema`, reason: 'Review the full parameter schema' },
@@ -141,7 +161,7 @@ async function toolsExecHandler(ctx) {
141
161
 
142
162
  return ctx.output.emitSuccess({
143
163
  command: ctx.spec.command_path.join(' '),
144
- data: { valid: true, tool: ctx.args.toolName, arguments: args },
164
+ data: { valid: true, tool: ctx.args.toolName, arguments: args, toolkits },
145
165
  humanSummary: `Dry run passed for ${ctx.args.toolName}`,
146
166
  hints: [
147
167
  { command: `notis tools exec ${ctx.args.toolName} --arguments '${JSON.stringify(args)}'`, reason: 'Execute with these arguments' },
@@ -151,39 +171,13 @@ async function toolsExecHandler(ctx) {
151
171
 
152
172
  const idempotencyKey = nextIdempotencyKey(ctx.globalOptions);
153
173
 
154
- if (ctx.options.watch) {
155
- const intervalSec = Math.max(1, parseInt(ctx.options.watch, 10));
156
- const intervalMs = intervalSec * 1000;
157
- process.on('SIGINT', () => process.exit(0));
158
-
159
- while (true) {
160
- try {
161
- const result = await runToolCommand({
162
- runtime: ctx.runtime,
163
- toolName: 'notis_execute_tool',
164
- arguments_: { tool_name: ctx.args.toolName, arguments: args },
165
- mutating: true,
166
- idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
167
- });
168
- ctx.output.emitSuccess({
169
- command: ctx.spec.command_path.join(' '),
170
- data: result.payload,
171
- humanSummary: `[${new Date().toISOString()}] ${ctx.args.toolName}`,
172
- renderHuman: () => JSON.stringify(result.payload, null, 2),
173
- });
174
- } catch (error) {
175
- process.stderr.write(`[${new Date().toISOString()}] Error: ${error.message}\n`);
176
- }
177
- await new Promise((r) => setTimeout(r, intervalMs));
178
- }
179
- }
180
-
181
174
  const result = await runToolCommand({
182
175
  runtime: ctx.runtime,
183
176
  toolName: 'notis_execute_tool',
184
177
  arguments_: {
185
178
  tool_name: ctx.args.toolName,
186
179
  arguments: args,
180
+ ...(toolkits.length ? { toolkits } : {}),
187
181
  },
188
182
  mutating: true,
189
183
  idempotencyKey,
@@ -209,6 +203,7 @@ async function toolsExecParallelHandler(ctx) {
209
203
  throw usageError(`calls[${i}] missing required "tool_name" string`);
210
204
  }
211
205
  }
206
+ ensureLongRunningToolTimeout(ctx, calls.map((call) => call.tool_name));
212
207
 
213
208
  const data = await Promise.all(
214
209
  calls.map((call) =>
@@ -333,16 +328,16 @@ export const toolsCommandSpecs = [
333
328
  { flags: '--arguments <json>', description: 'JSON object, @file path, or - for stdin.' },
334
329
  { flags: '--get-schema', description: 'Display the tool parameter schema without executing.' },
335
330
  { flags: '--dry-run', description: 'Validate arguments against the tool schema without executing.' },
336
- { flags: '--watch <seconds>', description: 'Re-execute on an interval and stream results.' },
331
+ { flags: '--toolkits <csv-or-json>', description: 'Optional toolkit namespace(s) to use when resolving the tool.' },
337
332
  ],
338
333
  },
339
334
  examples: [
340
335
  'notis tools exec notis-default-query --arguments \'{"database_slug":"tasks","query":{}}\'',
336
+ 'notis tools exec notis-default-get_database --arguments \'{"database_slug":"tasks"}\'',
341
337
  'notis tools exec notis-default-query --get-schema',
342
338
  'notis tools exec notis-default-query --dry-run --arguments \'{"database_slug":"tasks","query":{}}\'',
343
339
  'notis tools exec notis-default-query --arguments @query.json',
344
340
  'notis tools exec notis-default-query --arguments - < query.json',
345
- 'notis tools exec notis-default-query --watch 10 --arguments \'{"database_slug":"tasks","query":{}}\'',
346
341
  ],
347
342
  output_schema: 'Returns the raw tool execution payload.',
348
343
  mutates: true,
@@ -0,0 +1,192 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ function delay(ms) {
6
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
7
+ }
8
+
9
+ function commandError(phase, result) {
10
+ return {
11
+ phase,
12
+ exit_code: result.exitCode,
13
+ stdout: result.stdout.trim(),
14
+ stderr: result.stderr.trim(),
15
+ };
16
+ }
17
+
18
+ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
19
+ return new Promise((resolvePromise) => {
20
+ const child = spawn('agent-browser', args, {
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ env: process.env,
23
+ });
24
+ let stdout = '';
25
+ let stderr = '';
26
+ let timedOut = false;
27
+ const timer = setTimeout(() => {
28
+ timedOut = true;
29
+ child.kill('SIGTERM');
30
+ }, timeoutMs);
31
+
32
+ child.stdout.on('data', (chunk) => {
33
+ stdout += chunk.toString();
34
+ });
35
+ child.stderr.on('data', (chunk) => {
36
+ stderr += chunk.toString();
37
+ });
38
+ child.on('error', (error) => {
39
+ clearTimeout(timer);
40
+ resolvePromise({
41
+ exitCode: 1,
42
+ stdout,
43
+ stderr: stderr || error.message,
44
+ timedOut,
45
+ });
46
+ });
47
+ child.on('close', (code) => {
48
+ clearTimeout(timer);
49
+ resolvePromise({
50
+ exitCode: timedOut ? 124 : (code ?? 1),
51
+ stdout,
52
+ stderr,
53
+ timedOut,
54
+ });
55
+ });
56
+ });
57
+ }
58
+
59
+ function parseAgentBrowserJson(stdout) {
60
+ const trimmed = stdout.trim();
61
+ if (!trimmed) {
62
+ return null;
63
+ }
64
+ return JSON.parse(trimmed);
65
+ }
66
+
67
+ function parseHarnessFromEval(stdout) {
68
+ const payload = parseAgentBrowserJson(stdout);
69
+ const rawResult = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
70
+ if (rawResult == null || rawResult === 'null') {
71
+ return null;
72
+ }
73
+ return typeof rawResult === 'string' ? JSON.parse(rawResult) : rawResult;
74
+ }
75
+
76
+ async function readHarness(sessionName, timeoutMs) {
77
+ const result = await runAgentBrowser(
78
+ [
79
+ '--session',
80
+ sessionName,
81
+ '--json',
82
+ 'eval',
83
+ 'JSON.stringify(window.__harness || null)',
84
+ ],
85
+ { timeoutMs },
86
+ );
87
+ if (result.exitCode !== 0) {
88
+ return { ok: false, toolError: commandError('eval', result) };
89
+ }
90
+
91
+ try {
92
+ return { ok: true, harness: parseHarnessFromEval(result.stdout) };
93
+ } catch (error) {
94
+ return {
95
+ ok: false,
96
+ toolError: {
97
+ phase: 'eval_parse',
98
+ message: error instanceof Error ? error.message : String(error),
99
+ stdout: result.stdout.trim(),
100
+ stderr: result.stderr.trim(),
101
+ },
102
+ };
103
+ }
104
+ }
105
+
106
+ export function isAgentBrowserAvailable() {
107
+ const result = spawnSync('agent-browser', ['--version'], {
108
+ stdio: 'ignore',
109
+ env: process.env,
110
+ });
111
+ return result.status === 0;
112
+ }
113
+
114
+ export async function runHarnessRoute({
115
+ url,
116
+ sessionName,
117
+ timeoutMs = 10_000,
118
+ snapshotPath = null,
119
+ }) {
120
+ const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
121
+ timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
122
+ });
123
+ if (opened.exitCode !== 0) {
124
+ return {
125
+ mounted: false,
126
+ renderStarted: false,
127
+ errors: [],
128
+ runtimeCalls: [],
129
+ snapshotPath: null,
130
+ tool_error: commandError('open', opened),
131
+ };
132
+ }
133
+
134
+ const deadline = Date.now() + timeoutMs;
135
+ while (Date.now() < deadline) {
136
+ const lastRead = await readHarness(sessionName, 5000);
137
+ if (!lastRead.ok) {
138
+ await delay(250);
139
+ continue;
140
+ }
141
+ const harness = lastRead.harness;
142
+ if (harness?.mounted || (Array.isArray(harness?.errors) && harness.errors.length > 0)) {
143
+ await delay(250);
144
+ break;
145
+ }
146
+ await delay(250);
147
+ }
148
+
149
+ const finalRead = await readHarness(sessionName, 5000);
150
+ if (!finalRead.ok) {
151
+ return {
152
+ mounted: false,
153
+ renderStarted: false,
154
+ errors: [],
155
+ runtimeCalls: [],
156
+ snapshotPath: null,
157
+ tool_error: finalRead.toolError,
158
+ };
159
+ }
160
+
161
+ const harness = finalRead.harness || {};
162
+ let savedSnapshotPath = null;
163
+ if (snapshotPath) {
164
+ const snapshot = await runAgentBrowser(['--session', sessionName, 'snapshot', '-i'], {
165
+ timeoutMs: 10_000,
166
+ });
167
+ if (snapshot.exitCode === 0) {
168
+ mkdirSync(dirname(snapshotPath), { recursive: true });
169
+ writeFileSync(snapshotPath, snapshot.stdout);
170
+ savedSnapshotPath = snapshotPath;
171
+ }
172
+ }
173
+
174
+ const timedOut = !harness.mounted && Date.now() >= deadline;
175
+ return {
176
+ mounted: Boolean(harness.mounted),
177
+ renderStarted: Boolean(harness.renderStarted),
178
+ errors: Array.isArray(harness.errors) ? harness.errors : [],
179
+ runtimeCalls: Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [],
180
+ snapshotPath: savedSnapshotPath,
181
+ timed_out: timedOut,
182
+ tool_error: null,
183
+ raw: harness,
184
+ };
185
+ }
186
+
187
+ export async function closeAgentBrowserSession(sessionName) {
188
+ const result = await runAgentBrowser(['--session', sessionName, 'close'], {
189
+ timeoutMs: 5000,
190
+ });
191
+ return result.exitCode === 0;
192
+ }
@@ -0,0 +1,132 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { dirname, extname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { usageError } from './errors.js';
6
+
7
+ const RULES_PATH = resolve(
8
+ dirname(fileURLToPath(import.meta.url)),
9
+ '../../../../server/config/notis_app_boundary_rules.json',
10
+ );
11
+
12
+ const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.pcss']);
13
+ const IGNORED_SOURCE_DIRS = new Set([
14
+ 'node_modules',
15
+ '.git',
16
+ '.next',
17
+ '.notis',
18
+ 'dist',
19
+ 'build',
20
+ 'coverage',
21
+ ]);
22
+
23
+ function loadBoundaryRules() {
24
+ return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));
25
+ }
26
+
27
+ function compileRules(entries) {
28
+ return (entries || []).map((entry) => ({
29
+ regex: new RegExp(entry.pattern, 'm'),
30
+ message: entry.message,
31
+ }));
32
+ }
33
+
34
+ const boundaryRules = loadBoundaryRules();
35
+ const javascriptRules = compileRules(boundaryRules.javascript);
36
+ const cssRules = compileRules(boundaryRules.css);
37
+
38
+ function collectProjectFiles(projectDir, dir, results) {
39
+ if (!existsSync(dir)) {
40
+ return;
41
+ }
42
+
43
+ const entries = readdirSync(dir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ if (entry.name.startsWith('.')) {
46
+ if (entry.name !== '.storybook') {
47
+ continue;
48
+ }
49
+ }
50
+
51
+ const fullPath = join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ if (IGNORED_SOURCE_DIRS.has(entry.name)) {
54
+ continue;
55
+ }
56
+ collectProjectFiles(projectDir, fullPath, results);
57
+ continue;
58
+ }
59
+
60
+ if (!SOURCE_FILE_EXTENSIONS.has(extname(entry.name))) {
61
+ continue;
62
+ }
63
+
64
+ results.push({
65
+ path: fullPath,
66
+ relPath: fullPath.slice(projectDir.length + 1),
67
+ content: readFileSync(fullPath, 'utf-8'),
68
+ });
69
+ }
70
+ }
71
+
72
+ function applyRules(content, rules, relPath) {
73
+ const errors = [];
74
+ for (const rule of rules) {
75
+ if (rule.regex.test(content)) {
76
+ errors.push(`${relPath}: ${rule.message}`);
77
+ }
78
+ }
79
+ return errors;
80
+ }
81
+
82
+ function validateTextFile(relPath, content) {
83
+ const extension = extname(relPath);
84
+ if (extension === '.css' || extension === '.scss' || extension === '.sass' || extension === '.pcss') {
85
+ return applyRules(content, cssRules, relPath);
86
+ }
87
+ if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
88
+ return applyRules(content, javascriptRules, relPath);
89
+ }
90
+ return [];
91
+ }
92
+
93
+ export function collectProjectBoundaryViolations(projectDir) {
94
+ const files = [];
95
+ collectProjectFiles(projectDir, projectDir, files);
96
+ return files.flatMap((file) => validateTextFile(file.relPath, file.content));
97
+ }
98
+
99
+ export function collectArtifactBoundaryViolations(files) {
100
+ return Object.entries(files).flatMap(([relPath, rawContent]) => {
101
+ const extension = extname(relPath);
102
+ if (!['.js', '.css', '.scss', '.sass', '.pcss'].includes(extension)) {
103
+ return [];
104
+ }
105
+
106
+ const content = Buffer.isBuffer(rawContent)
107
+ ? rawContent.toString('utf-8')
108
+ : typeof rawContent === 'string'
109
+ ? rawContent
110
+ : String(rawContent ?? '');
111
+
112
+ return validateTextFile(relPath, content);
113
+ });
114
+ }
115
+
116
+ export function validateProjectBoundary(projectDir) {
117
+ const violations = collectProjectBoundaryViolations(projectDir);
118
+ if (violations.length > 0) {
119
+ throw usageError(
120
+ `Project violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
121
+ );
122
+ }
123
+ }
124
+
125
+ export function validateArtifactBoundary(files) {
126
+ const violations = collectArtifactBoundaryViolations(files);
127
+ if (violations.length > 0) {
128
+ throw usageError(
129
+ `Built app violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
130
+ );
131
+ }
132
+ }