@notis_ai/cli 0.2.0 → 0.2.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.
Files changed (58) hide show
  1. package/README.md +39 -10
  2. package/package.json +7 -1
  3. package/src/command-specs/apps.js +852 -47
  4. package/src/command-specs/helpers.js +28 -3
  5. package/src/command-specs/index.js +1 -1
  6. package/src/command-specs/tools.js +33 -6
  7. package/src/runtime/agent-browser.js +192 -0
  8. package/src/runtime/app-boundary-validator.js +132 -0
  9. package/src/runtime/app-dev-server.js +577 -0
  10. package/src/runtime/app-dev-sessions.js +87 -0
  11. package/src/runtime/app-platform.js +646 -71
  12. package/src/runtime/cli-mode.generated.js +4 -0
  13. package/src/runtime/cli-mode.js +29 -0
  14. package/src/runtime/output.js +2 -2
  15. package/src/runtime/ports.js +15 -0
  16. package/src/runtime/profiles.js +34 -3
  17. package/src/runtime/transport.js +129 -4
  18. package/template/.harness/index.html.tmpl +260 -0
  19. package/template/app/globals.css +3 -0
  20. package/template/app/layout.tsx +6 -0
  21. package/template/app/page.tsx +55 -0
  22. package/template/components/ui/badge.tsx +28 -0
  23. package/template/components/ui/button.tsx +53 -0
  24. package/template/components/ui/card.tsx +56 -0
  25. package/template/components.json +20 -0
  26. package/template/lib/utils.ts +6 -0
  27. package/template/notis.config.ts +33 -0
  28. package/template/package.json +31 -0
  29. package/template/packages/notis-sdk/package.json +26 -0
  30. package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
  31. package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  32. package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  33. package/template/packages/notis-sdk/src/config.ts +71 -0
  34. package/template/packages/notis-sdk/src/helpers.ts +131 -0
  35. package/template/packages/notis-sdk/src/hooks/useAppState.ts +50 -0
  36. package/template/packages/notis-sdk/src/hooks/useBackend.ts +41 -0
  37. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +58 -0
  38. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +88 -0
  39. package/template/packages/notis-sdk/src/hooks/useDocument.ts +61 -0
  40. package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
  41. package/template/packages/notis-sdk/src/hooks/useNotis.ts +33 -0
  42. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +49 -0
  43. package/template/packages/notis-sdk/src/hooks/useTool.ts +49 -0
  44. package/template/packages/notis-sdk/src/hooks/useTools.ts +56 -0
  45. package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
  46. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +58 -0
  47. package/template/packages/notis-sdk/src/index.ts +67 -0
  48. package/template/packages/notis-sdk/src/provider.tsx +43 -0
  49. package/template/packages/notis-sdk/src/runtime.ts +182 -0
  50. package/template/packages/notis-sdk/src/styles.css +38 -0
  51. package/template/packages/notis-sdk/src/ui.ts +15 -0
  52. package/template/packages/notis-sdk/src/vite.ts +56 -0
  53. package/template/packages/notis-sdk/tsconfig.json +15 -0
  54. package/template/postcss.config.mjs +8 -0
  55. package/template/tailwind.config.ts +58 -0
  56. package/template/tsconfig.json +22 -0
  57. package/template/vite.config.ts +10 -0
  58. package/src/runtime/app-preview-server.js +0 -272
@@ -105,13 +105,30 @@ export async function healthCheck(runtime) {
105
105
  });
106
106
  }
107
107
 
108
- export function updateStoredProfile({ profileName, jwt, apiBase, setCurrent = true }) {
108
+ export function updateStoredProfile({
109
+ profileName,
110
+ jwt,
111
+ apiBase,
112
+ authMode,
113
+ refreshToken,
114
+ accessExpiresAt,
115
+ refreshExpiresAt,
116
+ setCurrent = true,
117
+ }) {
109
118
  let config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
110
119
  const resolvedName = profileName || DEFAULT_PROFILE;
111
120
  config.profiles[resolvedName] = {
112
121
  ...config.profiles[resolvedName],
113
122
  ...(jwt ? { jwt } : {}),
114
123
  ...(apiBase ? { api_base: apiBase } : {}),
124
+ ...(authMode ? { auth_mode: authMode } : {}),
125
+ ...(refreshToken ? { refresh_token: refreshToken } : {}),
126
+ ...(typeof accessExpiresAt === 'number'
127
+ ? { access_expires_at: accessExpiresAt }
128
+ : {}),
129
+ ...(typeof refreshExpiresAt === 'number'
130
+ ? { refresh_expires_at: refreshExpiresAt }
131
+ : {}),
115
132
  };
116
133
  if (setCurrent) {
117
134
  config.current_profile = resolvedName;
@@ -123,15 +140,23 @@ export function updateStoredProfile({ profileName, jwt, apiBase, setCurrent = tr
123
140
  export function clearStoredJwt(profileName) {
124
141
  const config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
125
142
  delete config.profiles[profileName || DEFAULT_PROFILE].jwt;
143
+ delete config.profiles[profileName || DEFAULT_PROFILE].auth_mode;
144
+ delete config.profiles[profileName || DEFAULT_PROFILE].refresh_token;
145
+ delete config.profiles[profileName || DEFAULT_PROFILE].access_expires_at;
146
+ delete config.profiles[profileName || DEFAULT_PROFILE].refresh_expires_at;
126
147
  saveConfig(config);
127
148
  return config;
128
149
  }
129
150
 
130
- export async function fetchToolSchema(runtime, toolName) {
151
+ export async function fetchToolSchema(runtime, toolName, rawToolkits) {
152
+ const toolkits = normalizeToolkits(rawToolkits);
131
153
  const result = await runToolCommand({
132
154
  runtime,
133
155
  toolName: 'notis_find_tools',
134
- arguments_: { query: toolName },
156
+ arguments_: {
157
+ query: toolName,
158
+ ...(toolkits.length ? { toolkits } : {}),
159
+ },
135
160
  });
136
161
  const tools = result.payload.tools || [];
137
162
  const match = tools.find((t) => t.name === toolName);
@@ -6,7 +6,7 @@ import { metaCommandSpecs } from './meta.js';
6
6
 
7
7
  export const GROUP_SUMMARIES = {
8
8
  auth: 'Authentication and profile management.',
9
- apps: 'Build, preview, and deploy Notis Apps.',
9
+ apps: 'Develop, build, and deploy Notis Apps.',
10
10
  db: 'List, query, and update native Notis Databases.',
11
11
  tools: 'Discover and execute generic tools exposed through Notis.',
12
12
  };
@@ -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' },
@@ -161,7 +181,11 @@ async function toolsExecHandler(ctx) {
161
181
  const result = await runToolCommand({
162
182
  runtime: ctx.runtime,
163
183
  toolName: 'notis_execute_tool',
164
- arguments_: { tool_name: ctx.args.toolName, arguments: args },
184
+ arguments_: {
185
+ tool_name: ctx.args.toolName,
186
+ arguments: args,
187
+ ...(toolkits.length ? { toolkits } : {}),
188
+ },
165
189
  mutating: true,
166
190
  idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
167
191
  });
@@ -184,6 +208,7 @@ async function toolsExecHandler(ctx) {
184
208
  arguments_: {
185
209
  tool_name: ctx.args.toolName,
186
210
  arguments: args,
211
+ ...(toolkits.length ? { toolkits } : {}),
187
212
  },
188
213
  mutating: true,
189
214
  idempotencyKey,
@@ -209,6 +234,7 @@ async function toolsExecParallelHandler(ctx) {
209
234
  throw usageError(`calls[${i}] missing required "tool_name" string`);
210
235
  }
211
236
  }
237
+ ensureLongRunningToolTimeout(ctx, calls.map((call) => call.tool_name));
212
238
 
213
239
  const data = await Promise.all(
214
240
  calls.map((call) =>
@@ -334,6 +360,7 @@ export const toolsCommandSpecs = [
334
360
  { flags: '--get-schema', description: 'Display the tool parameter schema without executing.' },
335
361
  { flags: '--dry-run', description: 'Validate arguments against the tool schema without executing.' },
336
362
  { flags: '--watch <seconds>', description: 'Re-execute on an interval and stream results.' },
363
+ { flags: '--toolkits <csv-or-json>', description: 'Optional toolkit namespace(s) to use when resolving the tool.' },
337
364
  ],
338
365
  },
339
366
  examples: [
@@ -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
+ }