@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
@@ -3,22 +3,34 @@
3
3
  *
4
4
  * Handles project scaffolding, validation, building, and linking. This is the
5
5
  * CLI-side counterpart to @notis/sdk -- it reads notis.config.ts, runs the
6
- * Next.js build, and packages the artifact for deployment.
6
+ * Vite build, and packages the bundle for deployment.
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from 'node:fs';
10
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
11
12
  import { dirname, join, resolve } from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
- import { createHash } from 'node:crypto';
14
+ import { gunzipSync } from 'node:zlib';
14
15
 
15
16
  import { usageError } from './errors.js';
17
+ import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
16
18
 
17
19
  const NOTIS_DIR = '.notis';
18
20
  const STATE_FILE = join(NOTIS_DIR, 'state.json');
19
21
  const OUTPUT_DIR = join(NOTIS_DIR, 'output');
20
- const SITE_DIR = join(OUTPUT_DIR, 'site');
22
+ const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
21
23
  const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
24
+ const SOURCE_COPY_EXCLUDES = new Set([
25
+ 'node_modules',
26
+ '.notis',
27
+ '.git',
28
+ 'dist',
29
+ 'package-lock.json',
30
+ 'tsconfig.tsbuildinfo',
31
+ '.DS_Store',
32
+ ]);
33
+ let appConfigImportNonce = 0;
22
34
 
23
35
  // ---------------------------------------------------------------------------
24
36
  // Project directory resolution
@@ -28,15 +40,86 @@ export function resolveProjectDir(inputDir = '.') {
28
40
  return resolve(process.cwd(), inputDir);
29
41
  }
30
42
 
43
+ export function getBundleDir(projectDir) {
44
+ return join(projectDir, BUNDLE_DIR);
45
+ }
46
+
47
+ export function resolveBuiltBundleDir(projectDir) {
48
+ const bundleDir = getBundleDir(projectDir);
49
+ if (existsSync(join(bundleDir, 'app.js'))) {
50
+ return bundleDir;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function normalizeShadowScopedCss(css) {
56
+ return css
57
+ // Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
58
+ // the shadow host itself to carry those defaults.
59
+ .replace(/html\s*,\s*:host\s*\{/g, ':host{')
60
+ .replace(/:root\s*,\s*:host\s*\{/g, ':host{')
61
+ .replace(/:root\s*\{/g, ':host{')
62
+ .replace(/html\s*\{/g, ':host{')
63
+ // Shadow trees do not contain a body element. Route those defaults to the
64
+ // app root contract instead so authors still get the expected reset.
65
+ .replace(/body\s*\{/g, '[data-notis-app-root]{');
66
+ }
67
+
68
+ function normalizeBundleStylesheets(projectDir) {
69
+ const bundleDir = join(projectDir, BUNDLE_DIR);
70
+ if (!existsSync(bundleDir)) {
71
+ return;
72
+ }
73
+
74
+ for (const entry of readdirSync(bundleDir)) {
75
+ if (!entry.endsWith('.css')) {
76
+ continue;
77
+ }
78
+ const cssPath = join(bundleDir, entry);
79
+ const normalizedCss = normalizeShadowScopedCss(readFileSync(cssPath, 'utf-8'));
80
+ writeFileSync(cssPath, normalizedCss);
81
+ }
82
+ }
83
+
31
84
  // ---------------------------------------------------------------------------
32
85
  // Config loading
33
86
  // ---------------------------------------------------------------------------
34
87
 
35
88
  /**
36
89
  * Load and parse notis.config.ts from a project directory. Uses a simple
37
- * approach: we read the file, transpile with a basic regex to strip types,
38
- * and evaluate. For production use this should use tsx or similar.
90
+ * approach: we read the file, transpile with the project's TypeScript compiler
91
+ * when available, then evaluate as ESM.
39
92
  */
93
+ function stripNotisSdkImports(source) {
94
+ return source
95
+ .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
96
+ .replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
97
+ .replace(/defineNotisApp\s*\(/g, '(');
98
+ }
99
+
100
+ function transpileTsConfigSource(source, configPath) {
101
+ // Strip the @notis/sdk/config import unconditionally. The SDK package often
102
+ // points its `exports` at raw .ts source, which Node cannot import from the
103
+ // temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
104
+ // so removing the import and replacing the call with a parens-wrapped
105
+ // expression preserves the config value without ever resolving the SDK.
106
+ const stripped = stripNotisSdkImports(source);
107
+ const requireFromConfig = createRequire(`file://${configPath}`);
108
+ try {
109
+ const ts = requireFromConfig('typescript');
110
+ const transpiled = ts.transpileModule(stripped, {
111
+ compilerOptions: {
112
+ module: ts.ModuleKind.ESNext,
113
+ target: ts.ScriptTarget.ES2020,
114
+ },
115
+ fileName: configPath,
116
+ });
117
+ return transpiled.outputText;
118
+ } catch {
119
+ return stripped;
120
+ }
121
+ }
122
+
40
123
  export async function loadAppConfig(projectDir) {
41
124
  const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
42
125
  let configPath = null;
@@ -54,24 +137,24 @@ export async function loadAppConfig(projectDir) {
54
137
  }
55
138
 
56
139
  // For .js/.mjs files, import directly. For .ts, we need transpilation.
57
- // In the CLI context, we use a simple approach: read the file, strip TS
58
- // syntax, and eval. This avoids requiring tsx as a dependency.
59
140
  if (configPath.endsWith('.ts')) {
60
141
  const source = readFileSync(configPath, 'utf-8');
61
- // Strip import type annotations and type declarations
62
- const jsSource = source
63
- .replace(/import\s+type\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
64
- .replace(/import\s+{[^}]*}\s+from\s+['"][^'"]*['"]\s*;?/g, '')
65
- .replace(/export\s+default\s+/, 'export default ')
66
- .replace(/defineNotisApp\s*\(/, '(');
67
-
68
- // Write a temporary .mjs file and import it
69
- const tmpPath = join(projectDir, '.notis', '_config_tmp.mjs');
142
+ const jsSource = transpileTsConfigSource(source, configPath);
143
+
144
+ const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
70
145
  mkdirSync(dirname(tmpPath), { recursive: true });
71
146
  writeFileSync(tmpPath, jsSource);
72
147
  try {
73
- const mod = await import(`file://${tmpPath}`);
148
+ const mod = await import(`file://${tmpPath}?v=${appConfigImportNonce++}`);
74
149
  return mod.default || mod;
150
+ } catch (error) {
151
+ if (error instanceof SyntaxError) {
152
+ throw usageError(
153
+ `Failed to parse ${configPath}. Install project dependencies (including typescript) ` +
154
+ 'or convert the config to plain JavaScript.',
155
+ );
156
+ }
157
+ throw error;
75
158
  } finally {
76
159
  try {
77
160
  const { unlinkSync } = await import('node:fs');
@@ -95,10 +178,10 @@ export function detectProjectProblems(projectDir) {
95
178
  problems.push('Missing package.json');
96
179
  }
97
180
 
98
- const hasNextConfig = ['next.config.ts', 'next.config.mjs', 'next.config.js']
181
+ const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
99
182
  .some((name) => existsSync(join(projectDir, name)));
100
- if (!hasNextConfig) {
101
- problems.push('Missing next.config file');
183
+ if (!hasViteConfig) {
184
+ problems.push('Missing vite.config file');
102
185
  }
103
186
 
104
187
  if (!existsSync(join(projectDir, 'app'))) {
@@ -128,7 +211,7 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
128
211
  }
129
212
 
130
213
  if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
131
- warnings.push('No databases declared in notis.config.ts.');
214
+ warnings.push('No database references declared in notis.config.ts.');
132
215
  }
133
216
 
134
217
  return warnings;
@@ -156,6 +239,31 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
156
239
  });
157
240
  }
158
241
 
242
+ /**
243
+ * Derive an export name from a route path.
244
+ * '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
245
+ */
246
+ export function exportNameFromPath(routePath) {
247
+ if (routePath === '/') return 'index';
248
+ const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
249
+ const identifier = slug
250
+ .split(/[^A-Za-z0-9_$]+/)
251
+ .filter(Boolean)
252
+ .map((part, index) => {
253
+ const lower = part.toLowerCase();
254
+ if (index === 0) return lower;
255
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
256
+ })
257
+ .join('');
258
+ if (!identifier) return 'route';
259
+ return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
260
+ }
261
+
262
+ function slugFromPath(routePath) {
263
+ if (routePath === '/') return 'index';
264
+ return routePath.replace(/^\//, '').replace(/\//g, '-');
265
+ }
266
+
159
267
  /**
160
268
  * Auto-detect routes from the app/ directory by scanning for page.tsx files.
161
269
  */
@@ -174,13 +282,13 @@ function autoDetectRoutes(projectDir) {
174
282
  scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
175
283
  } else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
176
284
  const routePath = pathPrefix || '/';
177
- const slug = routePath === '/' ? 'index' : routePath.replace(/^\//, '').replace(/\//g, '-');
285
+ const slug = slugFromPath(routePath);
178
286
  routes.push({
179
287
  path: routePath,
180
288
  slug,
181
289
  name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
182
290
  default: routePath === '/',
183
- entry_html: routePath === '/' ? 'index.html' : `${routePath.replace(/^\//, '')}/index.html`,
291
+ export_name: exportNameFromPath(routePath),
184
292
  });
185
293
  }
186
294
  }
@@ -190,89 +298,192 @@ function autoDetectRoutes(projectDir) {
190
298
  return routes;
191
299
  }
192
300
 
193
- function slugFromPath(routePath) {
194
- if (routePath === '/') return 'index';
195
- return routePath.replace(/^\//, '').replace(/\//g, '-');
301
+ function validateConfiguredRoutes(routes) {
302
+ if (routes.length === 0) {
303
+ return;
304
+ }
305
+
306
+ const slugSet = new Set();
307
+ let defaultCount = 0;
308
+
309
+ for (const route of routes) {
310
+ if (!route.slug || typeof route.slug !== 'string') {
311
+ throw usageError(`Route "${route.path}" must define a slug.`);
312
+ }
313
+ if (slugSet.has(route.slug)) {
314
+ throw usageError(`Duplicate route slug "${route.slug}".`);
315
+ }
316
+ slugSet.add(route.slug);
317
+ if (route.default) {
318
+ defaultCount += 1;
319
+ }
320
+ }
321
+
322
+ if (defaultCount !== 1) {
323
+ throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
324
+ }
325
+
326
+ for (const route of routes) {
327
+ if (route.parentSlug && !slugSet.has(route.parentSlug)) {
328
+ throw usageError(
329
+ `Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
330
+ );
331
+ }
332
+ const collection = route.collection || null;
333
+ if (
334
+ collection?.sidebar?.mode === 'tree' &&
335
+ (!collection.parentProperty || typeof collection.parentProperty !== 'string')
336
+ ) {
337
+ throw usageError(
338
+ `Tree collection route "${route.slug}" must define collection.parentProperty.`,
339
+ );
340
+ }
341
+ }
342
+
343
+ const childrenByParent = new Map();
344
+ for (const route of routes) {
345
+ if (!route.parentSlug) {
346
+ continue;
347
+ }
348
+ const children = childrenByParent.get(route.parentSlug) || [];
349
+ children.push(route.slug);
350
+ childrenByParent.set(route.parentSlug, children);
351
+ }
352
+
353
+ for (const route of routes) {
354
+ const seen = new Set([route.slug]);
355
+ let current = route.parentSlug || null;
356
+ while (current) {
357
+ if (seen.has(current)) {
358
+ throw usageError(`Route parent cycle detected at "${route.slug}".`);
359
+ }
360
+ seen.add(current);
361
+ current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
362
+ }
363
+ }
364
+
365
+ for (const route of routes) {
366
+ if (
367
+ route.collection?.sidebar?.mode === 'tree' &&
368
+ (childrenByParent.get(route.slug) || []).length > 0
369
+ ) {
370
+ throw usageError(
371
+ `Tree collection route "${route.slug}" cannot also define static child routes.`,
372
+ );
373
+ }
374
+ }
375
+ }
376
+
377
+ function resolveConfiguredRoutes(appConfig, projectDir) {
378
+ const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
379
+ validateConfiguredRoutes(configuredRoutes);
380
+ const routes = configuredRoutes.map((route) => ({
381
+ ...route,
382
+ slug: route.slug,
383
+ export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
384
+ }));
385
+ return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
196
386
  }
197
387
 
198
- function entryHtmlFromPath(routePath) {
199
- if (routePath === '/') return 'index.html';
200
- return `${routePath.replace(/^\//, '')}/index.html`;
388
+ /**
389
+ * Generate the _entry.tsx file that re-exports each route's page component.
390
+ */
391
+ function generateEntryFile(projectDir, routes) {
392
+ const entryDir = join(projectDir, NOTIS_DIR);
393
+ mkdirSync(entryDir, { recursive: true });
394
+
395
+ // Also re-export the layout if it exists
396
+ const lines = [];
397
+ const layoutPath = join(projectDir, 'app', 'layout.tsx');
398
+ if (existsSync(layoutPath)) {
399
+ lines.push(`export { default as __AppShell } from '../app/layout';`);
400
+ }
401
+
402
+ for (const route of routes) {
403
+ const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
404
+ lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
405
+ }
406
+
407
+ const entryPath = join(entryDir, '_entry.tsx');
408
+ writeFileSync(entryPath, lines.join('\n') + '\n');
409
+ return entryPath;
201
410
  }
202
411
 
203
412
  /**
204
413
  * Generate the manifest from app config and build output.
205
414
  */
206
- function generateManifest(appConfig, projectDir) {
207
- const routes = (appConfig.routes || autoDetectRoutes(projectDir)).map((route) => ({
208
- path: route.path,
209
- slug: route.slug || slugFromPath(route.path),
210
- name: route.name,
211
- icon: route.icon || null,
212
- default: route.default || false,
213
- entry_html: route.entry_html || entryHtmlFromPath(route.path),
214
- collection: route.collection || null,
215
- }));
415
+ export function generateManifest(appConfig, projectDir) {
416
+ const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
417
+ const entry = {
418
+ path: route.path,
419
+ slug: route.slug,
420
+ name: route.name,
421
+ icon: route.icon || null,
422
+ parentSlug: route.parentSlug || null,
423
+ default: route.default || false,
424
+ export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
425
+ collection: route.collection || null,
426
+ };
427
+ if (route.tool_access) {
428
+ entry.tool_access = route.tool_access;
429
+ }
430
+ return entry;
431
+ });
216
432
 
217
- const databases = (appConfig.databases || []).map((db) => ({
218
- slug: db.slug,
219
- title: db.title,
220
- description: db.description || null,
221
- icon: db.icon || null,
222
- properties: (db.properties || []).map((prop) => ({
223
- name: prop.name,
224
- type: prop.type,
225
- description: prop.description || null,
226
- options: prop.options
227
- ? prop.options.map((opt) => typeof opt === 'string' ? { name: opt } : opt)
228
- : null,
229
- })),
230
- }));
433
+ const databases = appConfig.databases || [];
231
434
 
232
435
  return {
233
436
  version: 1,
234
- spec_version: 2,
437
+ spec_version: 3,
235
438
  app: {
236
439
  name: appConfig.name,
237
440
  description: appConfig.description || null,
238
441
  icon: appConfig.icon || null,
239
442
  },
240
443
  routes,
444
+ bundle: {
445
+ js: 'bundle/app.js',
446
+ css: 'bundle/app.css',
447
+ },
241
448
  databases,
242
449
  tools: appConfig.tools || [],
243
450
  };
244
451
  }
245
452
 
246
453
  /**
247
- * Build the app artifact: run `next build`, then package into .notis/output/.
454
+ * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
248
455
  */
249
456
  export async function buildArtifact(projectDir) {
457
+ validateProjectBoundary(projectDir);
250
458
  const appConfig = await loadAppConfig(projectDir);
251
459
 
252
- // Run the Next.js build
460
+ // Auto-detect or use configured routes
461
+ const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
462
+
463
+ // Generate the entry file that re-exports all route page components
464
+ generateEntryFile(projectDir, detectedRoutes);
465
+
466
+ // Run Vite build
253
467
  await runProjectScript({
254
468
  projectDir,
255
469
  scriptName: 'build',
256
- env: {
257
- NOTIS_BUILD: '1',
258
- NOTIS_APP_BASE_PATH: '/__NOTIS_APP_BASE__',
259
- NOTIS_ASSET_PREFIX: '/__NOTIS_ASSET_BASE__',
260
- },
261
470
  });
262
471
 
263
- // Copy the static export into .notis/output/site/
264
- const nextOutDir = join(projectDir, 'out');
265
- if (!existsSync(nextOutDir)) {
266
- throw usageError('Next.js build did not produce an `out/` directory. Ensure `output: "export"` is set (withNotis does this automatically).');
472
+ // Verify the canonical `.notis/output/bundle` packaging contract.
473
+ const builtBundleDir = resolveBuiltBundleDir(projectDir);
474
+ if (!builtBundleDir) {
475
+ throw usageError(
476
+ 'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
477
+ );
267
478
  }
479
+ normalizeBundleStylesheets(projectDir);
268
480
 
269
- const outputSiteDir = join(projectDir, SITE_DIR);
270
- mkdirSync(outputSiteDir, { recursive: true });
271
- cpSync(nextOutDir, outputSiteDir, { recursive: true });
481
+ validateArtifactBoundary(readArtifactFiles(projectDir));
272
482
 
273
483
  // Generate manifest
274
484
  const manifest = generateManifest(appConfig, projectDir);
275
485
  const manifestPath = join(projectDir, MANIFEST_FILE);
486
+ mkdirSync(dirname(manifestPath), { recursive: true });
276
487
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
277
488
 
278
489
  return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
@@ -322,11 +533,11 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
322
533
  export function scaffoldProject({ projectDir, appName }) {
323
534
  const templateDir = resolve(
324
535
  dirname(fileURLToPath(import.meta.url)),
325
- '../../../../packages/notis-sdk/template',
536
+ '../../template',
326
537
  );
327
538
 
328
539
  if (!existsSync(templateDir)) {
329
- throw usageError(`SDK template not found at ${templateDir}. Ensure @notis/sdk is installed.`);
540
+ throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
330
541
  }
331
542
 
332
543
  mkdirSync(projectDir, { recursive: true });
@@ -364,6 +575,8 @@ export function collectArtifactFiles(projectDir) {
364
575
  throw usageError('No build output found. Run "notis apps build" first.');
365
576
  }
366
577
 
578
+ validateArtifactBoundary(readArtifactFiles(projectDir));
579
+
367
580
  const files = {};
368
581
 
369
582
  function walk(dir, prefix) {
@@ -382,3 +595,365 @@ export function collectArtifactFiles(projectDir) {
382
595
  walk(outputDir, '');
383
596
  return files;
384
597
  }
598
+
599
+ function shouldExcludeSourceEntry(name) {
600
+ return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
601
+ }
602
+
603
+ function readSourceFiles(projectDir) {
604
+ const files = {};
605
+
606
+ function walk(dir, prefix) {
607
+ const entries = readdirSync(dir, { withFileTypes: true });
608
+ for (const entry of entries) {
609
+ if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
610
+ continue;
611
+ }
612
+ const fullPath = join(dir, entry.name);
613
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
614
+ if (entry.isDirectory()) {
615
+ walk(fullPath, relPath);
616
+ } else if (entry.isFile()) {
617
+ files[relPath] = readFileSync(fullPath);
618
+ }
619
+ }
620
+ }
621
+
622
+ walk(projectDir, '');
623
+ return files;
624
+ }
625
+
626
+ export function collectSourceFiles(projectDir) {
627
+ const files = readSourceFiles(projectDir);
628
+ const encoded = {};
629
+ for (const [relPath, content] of Object.entries(files)) {
630
+ encoded[relPath] = content.toString('base64');
631
+ }
632
+ return encoded;
633
+ }
634
+
635
+ function cleanTarPath(name) {
636
+ const cleaned = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
637
+ const parts = cleaned.split('/').filter(Boolean);
638
+ if (!parts.length || parts.includes('..')) {
639
+ throw usageError(`Refusing to extract unsafe path from source archive: ${name}`);
640
+ }
641
+ return parts.join('/');
642
+ }
643
+
644
+ function extractTarGz(buffer, targetDir) {
645
+ const tar = gunzipSync(buffer);
646
+ let offset = 0;
647
+ while (offset + 512 <= tar.length) {
648
+ const header = tar.subarray(offset, offset + 512);
649
+ offset += 512;
650
+ if (header.every((byte) => byte === 0)) {
651
+ break;
652
+ }
653
+
654
+ const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
655
+ const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0.*$/, '');
656
+ const fullName = prefix ? `${prefix}/${name}` : name;
657
+ const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
658
+ const size = Number.parseInt(sizeRaw || '0', 8);
659
+ const typeFlag = header[156];
660
+ const relPath = cleanTarPath(fullName);
661
+ const outputPath = join(targetDir, relPath);
662
+
663
+ if (typeFlag === 53) {
664
+ mkdirSync(outputPath, { recursive: true });
665
+ } else {
666
+ mkdirSync(dirname(outputPath), { recursive: true });
667
+ writeFileSync(outputPath, tar.subarray(offset, offset + size));
668
+ }
669
+
670
+ offset += Math.ceil(size / 512) * 512;
671
+ }
672
+ }
673
+
674
+ export async function pullAppSource({
675
+ apiBase,
676
+ jwt,
677
+ appId,
678
+ targetDir,
679
+ version = 'latest',
680
+ force = false,
681
+ }) {
682
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
683
+ if (!force) {
684
+ throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
685
+ }
686
+ rmSync(targetDir, { recursive: true, force: true });
687
+ }
688
+ mkdirSync(targetDir, { recursive: true });
689
+
690
+ const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
691
+ const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
692
+ headers: { Authorization: `Bearer ${jwt}` },
693
+ });
694
+ if (!response.ok) {
695
+ const data = await response.json().catch(() => ({}));
696
+ throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
697
+ }
698
+
699
+ const contentDisposition = response.headers.get('content-disposition') || '';
700
+ const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
701
+ const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
702
+ extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
703
+ writeLinkedState(targetDir, {
704
+ app_id: appId,
705
+ linked_at: new Date().toISOString(),
706
+ });
707
+
708
+ return { projectDir: targetDir, version: pulledVersion || version };
709
+ }
710
+
711
+ function readArtifactFiles(projectDir) {
712
+ const outputDir = join(projectDir, OUTPUT_DIR);
713
+ if (!existsSync(outputDir)) {
714
+ throw usageError('No build output found. Run "notis apps build" first.');
715
+ }
716
+
717
+ const files = {};
718
+
719
+ function walk(dir, prefix) {
720
+ const entries = readdirSync(dir, { withFileTypes: true });
721
+ for (const entry of entries) {
722
+ const fullPath = join(dir, entry.name);
723
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
724
+ if (entry.isDirectory()) {
725
+ walk(fullPath, relPath);
726
+ } else {
727
+ files[relPath] = readFileSync(fullPath);
728
+ }
729
+ }
730
+ }
731
+
732
+ walk(outputDir, '');
733
+ return files;
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Direct deploy (bypasses backend server)
738
+ // ---------------------------------------------------------------------------
739
+
740
+ /**
741
+ * Resolve Supabase credentials from the server/.env file in the repo workspace.
742
+ * Falls back to environment variables.
743
+ */
744
+ function resolveSupabaseCredentials() {
745
+ const envPaths = [
746
+ resolve(process.cwd(), 'server/.env'),
747
+ resolve(process.cwd(), '../server/.env'),
748
+ resolve(process.cwd(), '../../server/.env'),
749
+ ];
750
+
751
+ let supabaseUrl = process.env.SUPABASE_URL;
752
+ let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
753
+ let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
754
+
755
+ for (const envPath of envPaths) {
756
+ if (existsSync(envPath)) {
757
+ const content = readFileSync(envPath, 'utf-8');
758
+ for (const line of content.split('\n')) {
759
+ const trimmed = line.trim();
760
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
761
+ const eqIdx = trimmed.indexOf('=');
762
+ const key = trimmed.slice(0, eqIdx).trim();
763
+ let value = trimmed.slice(eqIdx + 1).trim();
764
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
765
+ value = value.slice(1, -1);
766
+ }
767
+ if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
768
+ if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
769
+ if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
770
+ }
771
+ break;
772
+ }
773
+ }
774
+
775
+ // Derive URL from subdomain if needed
776
+ if (!supabaseUrl && supabaseSubdomain) {
777
+ supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
778
+ }
779
+
780
+ if (!supabaseUrl || !supabaseKey) {
781
+ throw usageError(
782
+ 'Cannot resolve Supabase credentials for direct deploy. ' +
783
+ 'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
784
+ 'or set them as environment variables.',
785
+ );
786
+ }
787
+
788
+ return { supabaseUrl, supabaseKey };
789
+ }
790
+
791
+ /**
792
+ * Upload a file to Supabase Storage with upsert.
793
+ */
794
+ async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
795
+ const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
796
+ const response = await fetch(url, {
797
+ method: 'POST',
798
+ headers: {
799
+ 'Authorization': `Bearer ${supabaseKey}`,
800
+ 'Content-Type': contentType,
801
+ 'x-upsert': 'true',
802
+ },
803
+ body: content,
804
+ });
805
+
806
+ if (!response.ok) {
807
+ const text = await response.text().catch(() => '');
808
+ throw new Error(`Storage upload failed (${response.status}): ${text}`);
809
+ }
810
+ }
811
+
812
+ async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
813
+ const encodedBucket = encodeURIComponent(bucket);
814
+ const headers = {
815
+ 'Authorization': `Bearer ${supabaseKey}`,
816
+ 'apikey': supabaseKey,
817
+ };
818
+ const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
819
+ headers,
820
+ });
821
+ if (inspectResponse.ok) {
822
+ return;
823
+ }
824
+ if (inspectResponse.status !== 404) {
825
+ const text = await inspectResponse.text().catch(() => '');
826
+ throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
827
+ }
828
+
829
+ const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
830
+ method: 'POST',
831
+ headers: {
832
+ ...headers,
833
+ 'Content-Type': 'application/json',
834
+ },
835
+ body: JSON.stringify({
836
+ id: bucket,
837
+ name: bucket,
838
+ public: Boolean(options.public),
839
+ }),
840
+ });
841
+ if (createResponse.ok) {
842
+ return;
843
+ }
844
+
845
+ const text = await createResponse.text().catch(() => '');
846
+ if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
847
+ return;
848
+ }
849
+ throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
850
+ }
851
+
852
+ /**
853
+ * Get the current app manifest version from the apps table.
854
+ */
855
+ async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
856
+ const encodedAppId = encodeURIComponent(appId);
857
+ const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
858
+ const response = await fetch(url, {
859
+ headers: {
860
+ 'Authorization': `Bearer ${supabaseKey}`,
861
+ 'apikey': supabaseKey,
862
+ },
863
+ });
864
+ if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
865
+ const rows = await response.json();
866
+ if (!rows.length) throw usageError(`App ${appId} not found.`);
867
+ const manifest = rows[0].manifest || {};
868
+ return { currentVersion: manifest.version || 0, manifest };
869
+ }
870
+
871
+ /**
872
+ * Update the app manifest in the apps table.
873
+ */
874
+ async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
875
+ const encodedAppId = encodeURIComponent(appId);
876
+ const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
877
+ const response = await fetch(url, {
878
+ method: 'PATCH',
879
+ headers: {
880
+ 'Authorization': `Bearer ${supabaseKey}`,
881
+ 'apikey': supabaseKey,
882
+ 'Content-Type': 'application/json',
883
+ 'Prefer': 'return=minimal',
884
+ },
885
+ body: JSON.stringify({
886
+ manifest: { ...manifest, version: newVersion },
887
+ updated_at: new Date().toISOString(),
888
+ }),
889
+ });
890
+ if (!response.ok) {
891
+ const text = await response.text().catch(() => '');
892
+ throw new Error(`Failed to update app version: ${response.status} ${text}`);
893
+ }
894
+ }
895
+
896
+ const CONTENT_TYPE_MAP = {
897
+ '.js': 'application/javascript',
898
+ '.css': 'text/css',
899
+ '.json': 'application/json',
900
+ '.html': 'text/html',
901
+ };
902
+
903
+ /**
904
+ * Deploy app bundle directly to Supabase storage, bypassing the backend server.
905
+ */
906
+ export async function directDeploy(projectDir, appId) {
907
+ const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
908
+ const manifest = readManifest(projectDir);
909
+ const artifactFiles = readArtifactFiles(projectDir);
910
+ const sourceFiles = readSourceFiles(projectDir);
911
+ validateArtifactBoundary(artifactFiles);
912
+
913
+ // Get current version and increment
914
+ const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
915
+ const newVersion = currentVersion + 1;
916
+
917
+ // Upload all files from the output directory
918
+ const bucket = 'app-code';
919
+ await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
920
+ await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
921
+
922
+ async function uploadDir(dir, prefix) {
923
+ const entries = readdirSync(dir, { withFileTypes: true });
924
+ for (const entry of entries) {
925
+ const fullPath = join(dir, entry.name);
926
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
927
+ if (entry.isDirectory()) {
928
+ await uploadDir(fullPath, relPath);
929
+ } else {
930
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
931
+ const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
932
+ const content = readFileSync(fullPath);
933
+ const storagePath = `${appId}/v${newVersion}/${relPath}`;
934
+ await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
935
+ }
936
+ }
937
+ }
938
+
939
+ await uploadDir(join(projectDir, OUTPUT_DIR), '');
940
+
941
+ for (const [relPath, content] of Object.entries(sourceFiles)) {
942
+ const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
943
+ const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
944
+ const storagePath = `${appId}/v${newVersion}/${relPath}`;
945
+ await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
946
+ }
947
+
948
+ // Update the app record -- include storage_prefix so the portal can resolve bundle URLs
949
+ const storagePrefix = `${appId}/v${newVersion}/`;
950
+ const deployManifest = {
951
+ ...manifest,
952
+ version: newVersion,
953
+ storage_bucket: bucket,
954
+ storage_prefix: storagePrefix,
955
+ };
956
+ await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
957
+
958
+ return { version: newVersion };
959
+ }