@notis_ai/cli 0.2.0-beta.20.1 → 0.2.0-beta.32.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 (35) hide show
  1. package/README.md +36 -9
  2. package/package.json +2 -1
  3. package/src/command-specs/apps.js +780 -42
  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 +352 -17
  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/layout.tsx +1 -2
  20. package/template/notis.config.ts +16 -1
  21. package/template/package.json +1 -2
  22. package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
  23. package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  24. package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  25. package/template/packages/notis-sdk/src/config.ts +24 -1
  26. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +2 -1
  27. package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
  28. package/template/packages/notis-sdk/src/hooks/useNotis.ts +5 -3
  29. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +1 -1
  30. package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
  31. package/template/packages/notis-sdk/src/index.ts +20 -0
  32. package/template/packages/notis-sdk/src/provider.tsx +23 -24
  33. package/template/packages/notis-sdk/src/runtime.ts +43 -26
  34. package/template/packages/notis-sdk/src/styles.css +6 -91
  35. package/src/runtime/app-preview-server.js +0 -336
@@ -7,18 +7,29 @@
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
11
  import { createRequire } from 'node:module';
12
12
  import { dirname, join, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
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
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
+ ]);
22
33
  let appConfigImportNonce = 0;
23
34
 
24
35
  // ---------------------------------------------------------------------------
@@ -29,6 +40,47 @@ export function resolveProjectDir(inputDir = '.') {
29
40
  return resolve(process.cwd(), inputDir);
30
41
  }
31
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
+
32
84
  // ---------------------------------------------------------------------------
33
85
  // Config loading
34
86
  // ---------------------------------------------------------------------------
@@ -38,11 +90,24 @@ export function resolveProjectDir(inputDir = '.') {
38
90
  * approach: we read the file, transpile with the project's TypeScript compiler
39
91
  * when available, then evaluate as ESM.
40
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
+
41
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);
42
107
  const requireFromConfig = createRequire(`file://${configPath}`);
43
108
  try {
44
109
  const ts = requireFromConfig('typescript');
45
- const transpiled = ts.transpileModule(source, {
110
+ const transpiled = ts.transpileModule(stripped, {
46
111
  compilerOptions: {
47
112
  module: ts.ModuleKind.ESNext,
48
113
  target: ts.ScriptTarget.ES2020,
@@ -51,10 +116,7 @@ function transpileTsConfigSource(source, configPath) {
51
116
  });
52
117
  return transpiled.outputText;
53
118
  } catch {
54
- return source
55
- .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
56
- .replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
57
- .replace(/defineNotisApp\s*\(/, '(');
119
+ return stripped;
58
120
  }
59
121
  }
60
122
 
@@ -181,7 +243,7 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
181
243
  * Derive an export name from a route path.
182
244
  * '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
183
245
  */
184
- function exportNameFromPath(routePath) {
246
+ export function exportNameFromPath(routePath) {
185
247
  if (routePath === '/') return 'index';
186
248
  const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
187
249
  const identifier = slug
@@ -236,11 +298,88 @@ function autoDetectRoutes(projectDir) {
236
298
  return routes;
237
299
  }
238
300
 
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
+
239
377
  function resolveConfiguredRoutes(appConfig, projectDir) {
240
378
  const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
379
+ validateConfiguredRoutes(configuredRoutes);
241
380
  const routes = configuredRoutes.map((route) => ({
242
381
  ...route,
243
- slug: route.slug || slugFromPath(route.path),
382
+ slug: route.slug,
244
383
  export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
245
384
  }));
246
385
  return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
@@ -273,13 +412,14 @@ function generateEntryFile(projectDir, routes) {
273
412
  /**
274
413
  * Generate the manifest from app config and build output.
275
414
  */
276
- function generateManifest(appConfig, projectDir) {
415
+ export function generateManifest(appConfig, projectDir) {
277
416
  const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
278
417
  const entry = {
279
418
  path: route.path,
280
- slug: route.slug || slugFromPath(route.path),
419
+ slug: route.slug,
281
420
  name: route.name,
282
421
  icon: route.icon || null,
422
+ parentSlug: route.parentSlug || null,
283
423
  default: route.default || false,
284
424
  export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
285
425
  collection: route.collection || null,
@@ -314,6 +454,7 @@ function generateManifest(appConfig, projectDir) {
314
454
  * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
315
455
  */
316
456
  export async function buildArtifact(projectDir) {
457
+ validateProjectBoundary(projectDir);
317
458
  const appConfig = await loadAppConfig(projectDir);
318
459
 
319
460
  // Auto-detect or use configured routes
@@ -328,12 +469,16 @@ export async function buildArtifact(projectDir) {
328
469
  scriptName: 'build',
329
470
  });
330
471
 
331
- // Verify output
332
- const bundleDir = join(projectDir, BUNDLE_DIR);
333
- const jsPath = join(bundleDir, 'app.js');
334
- if (!existsSync(jsPath)) {
335
- throw usageError('Vite build did not produce .notis/output/bundle/app.js. Check your vite.config.ts.');
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
+ );
336
478
  }
479
+ normalizeBundleStylesheets(projectDir);
480
+
481
+ validateArtifactBoundary(readArtifactFiles(projectDir));
337
482
 
338
483
  // Generate manifest
339
484
  const manifest = generateManifest(appConfig, projectDir);
@@ -430,6 +575,8 @@ export function collectArtifactFiles(projectDir) {
430
575
  throw usageError('No build output found. Run "notis apps build" first.');
431
576
  }
432
577
 
578
+ validateArtifactBoundary(readArtifactFiles(projectDir));
579
+
433
580
  const files = {};
434
581
 
435
582
  function walk(dir, prefix) {
@@ -449,6 +596,143 @@ export function collectArtifactFiles(projectDir) {
449
596
  return files;
450
597
  }
451
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
+
452
736
  // ---------------------------------------------------------------------------
453
737
  // Direct deploy (bypasses backend server)
454
738
  // ---------------------------------------------------------------------------
@@ -525,6 +809,46 @@ async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, co
525
809
  }
526
810
  }
527
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
+
528
852
  /**
529
853
  * Get the current app manifest version from the apps table.
530
854
  */
@@ -582,14 +906,18 @@ const CONTENT_TYPE_MAP = {
582
906
  export async function directDeploy(projectDir, appId) {
583
907
  const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
584
908
  const manifest = readManifest(projectDir);
909
+ const artifactFiles = readArtifactFiles(projectDir);
910
+ const sourceFiles = readSourceFiles(projectDir);
911
+ validateArtifactBoundary(artifactFiles);
585
912
 
586
913
  // Get current version and increment
587
914
  const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
588
915
  const newVersion = currentVersion + 1;
589
916
 
590
917
  // Upload all files from the output directory
591
- const outputDir = join(projectDir, OUTPUT_DIR);
592
918
  const bucket = 'app-code';
919
+ await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
920
+ await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
593
921
 
594
922
  async function uploadDir(dir, prefix) {
595
923
  const entries = readdirSync(dir, { withFileTypes: true });
@@ -608,7 +936,14 @@ export async function directDeploy(projectDir, appId) {
608
936
  }
609
937
  }
610
938
 
611
- await uploadDir(outputDir, '');
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
+ }
612
947
 
613
948
  // Update the app record -- include storage_prefix so the portal can resolve bundle URLs
614
949
  const storagePrefix = `${appId}/v${newVersion}/`;
@@ -0,0 +1,4 @@
1
+ // Auto-generated on npm publish.
2
+ // Committed value is 'local' so in-repo runs target localhost.
3
+ // scripts/set-cli-mode.js rewrites this to 'published' before the npm publish step.
4
+ export const MODE = 'local';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * CLI mode detection for `notis apps dev`.
3
+ *
4
+ * The repo-local CLI package targets localhost; the published npm CLI package
5
+ * targets app.notis.ai. Mode is baked into `cli-mode.generated.js` at publish
6
+ * time — no runtime flag is needed (and none is exposed to end users, so a
7
+ * crafted env var can't trick a published CLI into opening a localhost portal
8
+ * that isn't running).
9
+ *
10
+ * The `NOTIS_CLI_MODE` env var is honored for internal testing only.
11
+ */
12
+
13
+ import { MODE as BAKED_MODE } from './cli-mode.generated.js';
14
+
15
+ export function getCliMode() {
16
+ const override = process.env.NOTIS_CLI_MODE;
17
+ if (override === 'local' || override === 'published') {
18
+ return override;
19
+ }
20
+ return BAKED_MODE === 'published' ? 'published' : 'local';
21
+ }
22
+
23
+ export function getDefaultApiBase(mode = getCliMode()) {
24
+ return mode === 'local' ? 'http://localhost:3001' : 'https://api.notis.ai';
25
+ }
26
+
27
+ export function getDefaultPortalOrigin(mode = getCliMode()) {
28
+ return mode === 'local' ? 'http://localhost:3000' : 'https://app.notis.ai';
29
+ }
@@ -95,6 +95,7 @@ export class OutputManager {
95
95
  }
96
96
 
97
97
  emitSuccess({
98
+ ok = true,
98
99
  command,
99
100
  data = {},
100
101
  humanSummary,
@@ -105,7 +106,7 @@ export class OutputManager {
105
106
  renderHuman,
106
107
  }) {
107
108
  const envelope = {
108
- ok: true,
109
+ ok: Boolean(ok),
109
110
  command,
110
111
  data,
111
112
  human_summary: humanSummary,
@@ -177,4 +178,3 @@ export class OutputManager {
177
178
  }
178
179
 
179
180
  export { formatTable };
180
-
@@ -0,0 +1,15 @@
1
+ import { createServer } from 'node:http';
2
+
3
+ export async function getAvailablePort() {
4
+ const server = createServer();
5
+ await new Promise((resolvePromise, rejectPromise) => {
6
+ server.once('error', rejectPromise);
7
+ server.listen(0, '127.0.0.1', () => {
8
+ server.off('error', rejectPromise);
9
+ resolvePromise();
10
+ });
11
+ });
12
+ const { port } = server.address();
13
+ await new Promise((resolvePromise) => server.close(resolvePromise));
14
+ return port;
15
+ }
@@ -12,6 +12,7 @@ const LOCAL_DEFAULT_API_BASES = new Set([
12
12
  'http://localhost:3001',
13
13
  'http://127.0.0.1:3001',
14
14
  ]);
15
+ const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
15
16
 
16
17
  function clone(value) {
17
18
  return JSON.parse(JSON.stringify(value));
@@ -29,6 +30,13 @@ export function normalizeConfig(rawConfig = {}) {
29
30
  profiles[name] = {
30
31
  jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
31
32
  api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
33
+ auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
34
+ refresh_token:
35
+ typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
36
+ access_expires_at:
37
+ typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
38
+ refresh_expires_at:
39
+ typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
32
40
  };
33
41
  }
34
42
 
@@ -51,6 +59,12 @@ export function normalizeConfig(rawConfig = {}) {
51
59
  [DEFAULT_PROFILE]: {
52
60
  jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
53
61
  api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
62
+ auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
63
+ refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
64
+ access_expires_at:
65
+ typeof raw.access_expires_at === 'number' ? raw.access_expires_at : undefined,
66
+ refresh_expires_at:
67
+ typeof raw.refresh_expires_at === 'number' ? raw.refresh_expires_at : undefined,
54
68
  },
55
69
  },
56
70
  };
@@ -90,6 +104,18 @@ export function ensureProfile(config, profileName) {
90
104
  return normalized;
91
105
  }
92
106
 
107
+ function isLocalApiBase(value) {
108
+ if (typeof value !== 'string' || !value) {
109
+ return false;
110
+ }
111
+ try {
112
+ const parsed = new URL(value);
113
+ return ['http:', 'https:'].includes(parsed.protocol) && LOCAL_API_HOSTS.has(parsed.hostname);
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
93
119
  export function getApiBase(config, profileName, override) {
94
120
  if (override) {
95
121
  return override;
@@ -103,12 +129,12 @@ export function getApiBase(config, profileName, override) {
103
129
  const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
104
130
 
105
131
  // Conductor assigns dynamic local ports. When a workspace is running under
106
- // Conductor, treat the legacy localhost:3001 profile value as stale and
107
- // transparently retarget the CLI at the active backend port.
132
+ // Conductor, treat saved localhost profile values as stale and transparently
133
+ // retarget the CLI at the active backend port.
108
134
  if (
109
135
  Number.isInteger(conductorPort) &&
110
136
  conductorPort > 0 &&
111
- (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase))
137
+ (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
112
138
  ) {
113
139
  return `http://localhost:${conductorPort + 1}`;
114
140
  }
@@ -166,6 +192,7 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
166
192
  const profileName = getCurrentProfileName(config, globalOptions.profile);
167
193
  const apiBase = getApiBase(config, profileName, globalOptions.apiBase);
168
194
  const jwt = getJwt(config, profileName);
195
+ const profile = getProfile(config, profileName);
169
196
  const agentMode = isAgentMode(globalOptions);
170
197
  const nonInteractive = isNonInteractive(globalOptions);
171
198
  const outputMode = resolveOutputMode(globalOptions);
@@ -190,6 +217,10 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
190
217
  profileName,
191
218
  apiBase,
192
219
  jwt,
220
+ authMode: profile.auth_mode,
221
+ refreshToken: profile.refresh_token,
222
+ accessExpiresAt: profile.access_expires_at,
223
+ refreshExpiresAt: profile.refresh_expires_at,
193
224
  agentMode,
194
225
  nonInteractive,
195
226
  outputMode,