@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.
- package/README.md +36 -9
- package/package.json +2 -1
- package/src/command-specs/apps.js +780 -42
- package/src/command-specs/helpers.js +28 -3
- package/src/command-specs/index.js +1 -1
- package/src/command-specs/tools.js +33 -6
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +577 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +352 -17
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +34 -3
- package/src/runtime/transport.js +129 -4
- package/template/.harness/index.html.tmpl +260 -0
- package/template/app/layout.tsx +1 -2
- package/template/notis.config.ts +16 -1
- package/template/package.json +1 -2
- package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
- package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/notis-sdk/src/config.ts +24 -1
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +2 -1
- package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +5 -3
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +1 -1
- package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/notis-sdk/src/index.ts +20 -0
- package/template/packages/notis-sdk/src/provider.tsx +23 -24
- package/template/packages/notis-sdk/src/runtime.ts +43 -26
- package/template/packages/notis-sdk/src/styles.css +6 -91
- 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,
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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(
|
|
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,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
|
+
}
|
package/src/runtime/output.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -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
|
|
107
|
-
//
|
|
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,
|