@notis_ai/cli 0.2.0-beta.102.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 +334 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/dist/scaffolds/notis-database/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-database/app/globals.css +44 -0
- package/dist/scaffolds/notis-database/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-database/app/page.tsx +1091 -0
- package/dist/scaffolds/notis-database/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-database/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-database/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-database/components/ui/table.tsx +120 -0
- package/dist/scaffolds/notis-database/components.json +20 -0
- package/dist/scaffolds/notis-database/index.html +12 -0
- package/dist/scaffolds/notis-database/lib/types.ts +134 -0
- package/dist/scaffolds/notis-database/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/notis.config.ts +26 -0
- package/dist/scaffolds/notis-database/package.json +31 -0
- package/dist/scaffolds/notis-database/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-database/src/dev-main.tsx +23 -0
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +558 -0
- package/dist/scaffolds/notis-database/tailwind.config.ts +59 -0
- package/dist/scaffolds/notis-database/tsconfig.json +23 -0
- package/dist/scaffolds/notis-database/vite.config.ts +22 -0
- package/dist/scaffolds/notis-journal/CHANGELOG.md +25 -0
- package/dist/scaffolds/notis-journal/app/globals.css +37 -0
- package/dist/scaffolds/notis-journal/app/insights/page.tsx +513 -0
- package/dist/scaffolds/notis-journal/app/journal-core.tsx +362 -0
- package/dist/scaffolds/notis-journal/app/journal-ui.tsx +337 -0
- package/dist/scaffolds/notis-journal/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-journal/app/page.tsx +485 -0
- package/dist/scaffolds/notis-journal/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-journal/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-journal/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-journal/components.json +20 -0
- package/dist/scaffolds/notis-journal/index.html +12 -0
- package/dist/scaffolds/notis-journal/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +132 -0
- package/dist/scaffolds/notis-journal/notis.config.ts +93 -0
- package/dist/scaffolds/notis-journal/package.json +34 -0
- package/dist/scaffolds/notis-journal/packages/sdk/package.json +36 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +60 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +145 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +229 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +64 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +56 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +83 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +43 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +220 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +186 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +15 -0
- package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +56 -0
- package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +15 -0
- package/dist/scaffolds/notis-journal/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +120 -0
- package/dist/scaffolds/notis-journal/src/dev-main.tsx +58 -0
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +197 -0
- package/dist/scaffolds/notis-journal/tailwind.config.ts +58 -0
- package/dist/scaffolds/notis-journal/tsconfig.json +23 -0
- package/dist/scaffolds/notis-journal/vite.config.ts +10 -0
- package/dist/scaffolds/notis-notes/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-notes/app/globals.css +3 -0
- package/dist/scaffolds/notis-notes/app/layout.tsx +6 -0
- package/dist/scaffolds/notis-notes/app/page.tsx +1950 -0
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +28 -0
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +53 -0
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +56 -0
- package/dist/scaffolds/notis-notes/components.json +20 -0
- package/dist/scaffolds/notis-notes/lib/utils.ts +6 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/notis.config.ts +35 -0
- package/dist/scaffolds/notis-notes/package.json +31 -0
- package/dist/scaffolds/notis-notes/postcss.config.mjs +8 -0
- package/dist/scaffolds/notis-notes/tailwind.config.ts +58 -0
- package/dist/scaffolds/notis-notes/tsconfig.json +23 -0
- package/dist/scaffolds/notis-notes/vite.config.ts +10 -0
- package/dist/scaffolds/notis-random/CHANGELOG.md +5 -0
- package/dist/scaffolds/notis-random/README.md +33 -0
- package/dist/scaffolds/notis-random/app/globals.css +11 -0
- package/dist/scaffolds/notis-random/app/history/page.tsx +66 -0
- package/dist/scaffolds/notis-random/app/layout.tsx +7 -0
- package/dist/scaffolds/notis-random/app/page.tsx +222 -0
- package/dist/scaffolds/notis-random/index.html +12 -0
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +109 -0
- package/dist/scaffolds/notis-random/lib/rng.ts +42 -0
- package/dist/scaffolds/notis-random/lib/roll-record.ts +102 -0
- package/dist/scaffolds/notis-random/lib/utils.ts +25 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/notis.config.ts +42 -0
- package/dist/scaffolds/notis-random/package.json +32 -0
- package/dist/scaffolds/notis-random/postcss.config.mjs +6 -0
- package/dist/scaffolds/notis-random/src/dev-main.tsx +70 -0
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +129 -0
- package/dist/scaffolds/notis-random/tailwind.config.ts +43 -0
- package/dist/scaffolds/notis-random/tsconfig.json +23 -0
- package/dist/scaffolds/notis-random/vite.config.ts +11 -0
- package/dist/scaffolds.json +47 -0
- package/package.json +45 -0
- package/skills/notis-apps/SKILL.md +219 -0
- package/skills/notis-apps/cli.md +244 -0
- package/skills/notis-cli/SKILL.md +269 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/cli.js +175 -0
- package/src/command-specs/apps.js +1939 -0
- package/src/command-specs/helpers.js +186 -0
- package/src/command-specs/index.js +14 -0
- package/src/command-specs/meta.js +161 -0
- package/src/command-specs/tools.js +499 -0
- package/src/runtime/agent-browser.js +464 -0
- package/src/runtime/app-boundary-validator.js +183 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-dev-server.js +712 -0
- package/src/runtime/app-dev-sessions.js +251 -0
- package/src/runtime/app-platform.js +1500 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/desktop-auth.js +93 -0
- package/src/runtime/errors.js +55 -0
- package/src/runtime/help.js +60 -0
- package/src/runtime/output.js +180 -0
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +261 -0
- package/src/runtime/store-screenshot.js +138 -0
- package/src/runtime/transport.js +347 -0
- package/template/.harness/index.html.tmpl +333 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +60 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
- package/template/notis.config.ts +36 -0
- package/template/package-lock.json +4137 -0
- package/template/package.json +32 -0
- package/template/packages/sdk/package.json +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/config.ts +145 -0
- package/template/packages/sdk/src/documents.ts +229 -0
- package/template/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/template/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/sdk/src/hooks/useTool.ts +64 -0
- package/template/packages/sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +83 -0
- package/template/packages/sdk/src/provider.tsx +43 -0
- package/template/packages/sdk/src/runtime.ts +220 -0
- package/template/packages/sdk/src/styles.css +186 -0
- package/template/packages/sdk/src/ui.ts +15 -0
- package/template/packages/sdk/src/vite.ts +56 -0
- package/template/packages/sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +10 -0
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App platform utilities for the Notis CLI.
|
|
3
|
+
*
|
|
4
|
+
* Handles project scaffolding, validation, building, and linking. This is the
|
|
5
|
+
* CLI-side counterpart to @notis/sdk -- it reads notis.config.ts, runs the
|
|
6
|
+
* Vite build, and packages the bundle for deployment.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { gunzipSync } from 'node:zlib';
|
|
15
|
+
|
|
16
|
+
import { usageError } from './errors.js';
|
|
17
|
+
import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
|
|
18
|
+
import { readAppChangelog } from './app-changelog.js';
|
|
19
|
+
|
|
20
|
+
const NOTIS_DIR = '.notis';
|
|
21
|
+
const STATE_FILE = join(NOTIS_DIR, 'state.json');
|
|
22
|
+
const OUTPUT_DIR = join(NOTIS_DIR, 'output');
|
|
23
|
+
const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
|
|
24
|
+
const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
|
|
25
|
+
const METADATA_DIR = 'metadata';
|
|
26
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
27
|
+
const MONOREPO_APPS_DIR = resolve(CLI_ROOT, '../..', 'apps');
|
|
28
|
+
const DIST_DIR = join(CLI_ROOT, 'dist');
|
|
29
|
+
const SCAFFOLD_CATALOG_FILE = join(DIST_DIR, 'scaffolds.json');
|
|
30
|
+
const SCAFFOLD_SOURCE_DIR = join(DIST_DIR, 'scaffolds');
|
|
31
|
+
const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'template', 'packages', 'sdk');
|
|
32
|
+
export const NOTIS_APP_CATEGORIES = [
|
|
33
|
+
'Productivity',
|
|
34
|
+
'Sales & Marketing',
|
|
35
|
+
'Operations',
|
|
36
|
+
'Product & Engineering',
|
|
37
|
+
'Personal',
|
|
38
|
+
];
|
|
39
|
+
export const MIN_LISTING_SCREENSHOTS = 3;
|
|
40
|
+
const SOURCE_COPY_EXCLUDES = new Set([
|
|
41
|
+
'node_modules',
|
|
42
|
+
'.notis',
|
|
43
|
+
'.git',
|
|
44
|
+
'dist',
|
|
45
|
+
'tsconfig.tsbuildinfo',
|
|
46
|
+
'.DS_Store',
|
|
47
|
+
]);
|
|
48
|
+
const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
49
|
+
...SOURCE_COPY_EXCLUDES,
|
|
50
|
+
'coverage',
|
|
51
|
+
'.next',
|
|
52
|
+
'.turbo',
|
|
53
|
+
]);
|
|
54
|
+
let appConfigImportNonce = 0;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Project directory resolution
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export function resolveProjectDir(inputDir = '.') {
|
|
61
|
+
return resolve(process.cwd(), inputDir);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getBundleDir(projectDir) {
|
|
65
|
+
return join(projectDir, BUNDLE_DIR);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveBuiltBundleDir(projectDir) {
|
|
69
|
+
const bundleDir = getBundleDir(projectDir);
|
|
70
|
+
if (existsSync(join(bundleDir, 'app.js'))) {
|
|
71
|
+
return bundleDir;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeShadowScopedCss(css) {
|
|
77
|
+
return css
|
|
78
|
+
// Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
|
|
79
|
+
// the shadow host itself to carry those defaults.
|
|
80
|
+
.replace(/html\s*,\s*:host\s*\{/g, ':host{')
|
|
81
|
+
.replace(/:root\s*,\s*:host\s*\{/g, ':host{')
|
|
82
|
+
.replace(/:root\s*\{/g, ':host{')
|
|
83
|
+
.replace(/html\s*\{/g, ':host{')
|
|
84
|
+
// Shadow trees do not contain a body element. Route those defaults to the
|
|
85
|
+
// app root contract instead so authors still get the expected reset.
|
|
86
|
+
.replace(/body\s*\{/g, '[data-notis-app-root]{');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeBundleStylesheets(projectDir) {
|
|
90
|
+
const bundleDir = join(projectDir, BUNDLE_DIR);
|
|
91
|
+
if (!existsSync(bundleDir)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const entry of readdirSync(bundleDir)) {
|
|
96
|
+
if (!entry.endsWith('.css')) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const cssPath = join(bundleDir, entry);
|
|
100
|
+
const normalizedCss = normalizeShadowScopedCss(readFileSync(cssPath, 'utf-8'));
|
|
101
|
+
writeFileSync(cssPath, normalizedCss);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Config loading
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load and parse notis.config.ts from a project directory. Uses a simple
|
|
111
|
+
* approach: we read the file, transpile with the project's TypeScript compiler
|
|
112
|
+
* when available, then evaluate as ESM.
|
|
113
|
+
*/
|
|
114
|
+
function stripNotisSdkImports(source) {
|
|
115
|
+
return source
|
|
116
|
+
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
117
|
+
.replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
|
|
118
|
+
.replace(/defineNotisApp\s*\(/g, '(');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function transpileTsConfigSource(source, configPath) {
|
|
122
|
+
// Strip the @notis/sdk/config import unconditionally. The SDK package often
|
|
123
|
+
// points its `exports` at raw .ts source, which Node cannot import from the
|
|
124
|
+
// temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
|
|
125
|
+
// so removing the import and replacing the call with a parens-wrapped
|
|
126
|
+
// expression preserves the config value without ever resolving the SDK.
|
|
127
|
+
const stripped = stripNotisSdkImports(source);
|
|
128
|
+
const requireFromConfig = createRequire(`file://${configPath}`);
|
|
129
|
+
try {
|
|
130
|
+
const ts = requireFromConfig('typescript');
|
|
131
|
+
const transpiled = ts.transpileModule(stripped, {
|
|
132
|
+
compilerOptions: {
|
|
133
|
+
module: ts.ModuleKind.ESNext,
|
|
134
|
+
target: ts.ScriptTarget.ES2020,
|
|
135
|
+
},
|
|
136
|
+
fileName: configPath,
|
|
137
|
+
});
|
|
138
|
+
return transpiled.outputText;
|
|
139
|
+
} catch {
|
|
140
|
+
return stripped;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function loadAppConfig(projectDir) {
|
|
145
|
+
const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
146
|
+
let configPath = null;
|
|
147
|
+
|
|
148
|
+
for (const name of configPaths) {
|
|
149
|
+
const candidate = join(projectDir, name);
|
|
150
|
+
if (existsSync(candidate)) {
|
|
151
|
+
configPath = candidate;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!configPath) {
|
|
157
|
+
throw usageError('No notis.config.ts found in project directory.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// For .js/.mjs files, import directly. For .ts, we need transpilation.
|
|
161
|
+
if (configPath.endsWith('.ts')) {
|
|
162
|
+
const source = readFileSync(configPath, 'utf-8');
|
|
163
|
+
const jsSource = transpileTsConfigSource(source, configPath);
|
|
164
|
+
|
|
165
|
+
const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
|
|
166
|
+
mkdirSync(dirname(tmpPath), { recursive: true });
|
|
167
|
+
writeFileSync(tmpPath, jsSource);
|
|
168
|
+
try {
|
|
169
|
+
const mod = await import(`file://${tmpPath}?v=${appConfigImportNonce++}`);
|
|
170
|
+
return mod.default || mod;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof SyntaxError) {
|
|
173
|
+
throw usageError(
|
|
174
|
+
`Failed to parse ${configPath}. Install project dependencies (including typescript) ` +
|
|
175
|
+
'or convert the config to plain JavaScript.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
} finally {
|
|
180
|
+
try {
|
|
181
|
+
const { unlinkSync } = await import('node:fs');
|
|
182
|
+
unlinkSync(tmpPath);
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const mod = await import(`file://${configPath}`);
|
|
188
|
+
return mod.default || mod;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Project validation
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export function detectProjectProblems(projectDir) {
|
|
196
|
+
const problems = [];
|
|
197
|
+
|
|
198
|
+
if (!existsSync(join(projectDir, 'package.json'))) {
|
|
199
|
+
problems.push('Missing package.json');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
|
|
203
|
+
.some((name) => existsSync(join(projectDir, name)));
|
|
204
|
+
if (!hasViteConfig) {
|
|
205
|
+
problems.push('Missing vite.config file');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!existsSync(join(projectDir, 'app'))) {
|
|
209
|
+
problems.push('Missing app/ directory');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const hasNotisConfig = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs']
|
|
213
|
+
.some((name) => existsSync(join(projectDir, name)));
|
|
214
|
+
if (!hasNotisConfig) {
|
|
215
|
+
problems.push('Missing notis.config.ts');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return problems;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
222
|
+
const warnings = [];
|
|
223
|
+
|
|
224
|
+
if (!existsSync(join(projectDir, 'components.json'))) {
|
|
225
|
+
warnings.push('Missing components.json -- shadcn is not configured.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const hasTailwind = ['tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.cjs']
|
|
229
|
+
.some((name) => existsSync(join(projectDir, name)));
|
|
230
|
+
if (!hasTailwind) {
|
|
231
|
+
warnings.push('Missing Tailwind config.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
|
|
235
|
+
warnings.push('No database references declared in notis.config.ts.');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (appConfig) {
|
|
239
|
+
try {
|
|
240
|
+
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
241
|
+
warnings.push(...listing.errors, ...listing.warnings);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return warnings;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function safeKebab(value) {
|
|
251
|
+
return String(value || '')
|
|
252
|
+
.trim()
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
255
|
+
.replace(/(^-|-$)+/g, '');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function jsStringLiteral(value) {
|
|
259
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeCategories(categories = []) {
|
|
263
|
+
if (!Array.isArray(categories)) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const allowed = new Set(NOTIS_APP_CATEGORIES);
|
|
267
|
+
const normalized = [];
|
|
268
|
+
for (const category of categories) {
|
|
269
|
+
if (typeof category !== 'string' || !category.trim()) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const trimmed = category.trim();
|
|
273
|
+
if (!allowed.has(trimmed)) {
|
|
274
|
+
throw usageError(
|
|
275
|
+
`Invalid Notis app category "${trimmed}". Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (!normalized.includes(trimmed)) {
|
|
279
|
+
normalized.push(trimmed);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return normalized;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pngDimensions(buffer) {
|
|
286
|
+
const signature = '89504e470d0a1a0a';
|
|
287
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.subarray(0, 8).toString('hex') !== signature) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
width: buffer.readUInt32BE(16),
|
|
292
|
+
height: buffer.readUInt32BE(20),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function imageAssetMeta(projectDir, relPath, { maxBytes }) {
|
|
297
|
+
const fullPath = join(projectDir, relPath);
|
|
298
|
+
const content = readFileSync(fullPath);
|
|
299
|
+
const dimensions = pngDimensions(content);
|
|
300
|
+
const errors = [];
|
|
301
|
+
const warnings = [];
|
|
302
|
+
|
|
303
|
+
if (!relPath.toLowerCase().endsWith('.png')) {
|
|
304
|
+
errors.push(`${relPath} must be a PNG file.`);
|
|
305
|
+
}
|
|
306
|
+
if (content.length > maxBytes) {
|
|
307
|
+
errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
|
|
308
|
+
}
|
|
309
|
+
if (!dimensions) {
|
|
310
|
+
errors.push(`${relPath} is not a valid PNG file.`);
|
|
311
|
+
} else if (dimensions.width !== 2000 || dimensions.height !== 1250) {
|
|
312
|
+
errors.push(`${relPath} must be exactly 2000x1250 pixels.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
path: relPath,
|
|
317
|
+
content_type: 'image/png',
|
|
318
|
+
bytes: content.length,
|
|
319
|
+
width: dimensions?.width ?? null,
|
|
320
|
+
height: dimensions?.height ?? null,
|
|
321
|
+
errors,
|
|
322
|
+
warnings,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function discoverMetadataAssets(projectDir) {
|
|
327
|
+
const metadataDir = join(projectDir, METADATA_DIR);
|
|
328
|
+
const screenshots = [];
|
|
329
|
+
|
|
330
|
+
if (existsSync(metadataDir)) {
|
|
331
|
+
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
332
|
+
if (!entry.isFile()) continue;
|
|
333
|
+
const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
|
|
334
|
+
if (!match) continue;
|
|
335
|
+
screenshots.push({
|
|
336
|
+
index: Number.parseInt(match[1], 10),
|
|
337
|
+
...imageAssetMeta(projectDir, join(METADATA_DIR, entry.name), {
|
|
338
|
+
maxBytes: 2 * 1024 * 1024,
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
screenshots.sort((a, b) => a.index - b.index);
|
|
345
|
+
return {
|
|
346
|
+
screenshots: screenshots.slice(0, 6),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function normalizeConfiguredScreenshots(appConfig = null) {
|
|
351
|
+
if (!Array.isArray(appConfig?.screenshots) || appConfig.screenshots.length === 0) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (appConfig.screenshots.length > 6) {
|
|
355
|
+
throw usageError('Configure at most six listing screenshots.');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const seenPaths = new Set();
|
|
359
|
+
return appConfig.screenshots.map((entry, index) => {
|
|
360
|
+
const path = typeof entry?.path === 'string' ? entry.path.trim().replace(/\\/g, '/') : '';
|
|
361
|
+
const alt = typeof entry?.alt === 'string' ? entry.alt.trim() : '';
|
|
362
|
+
const route = typeof entry?.route === 'string' ? entry.route.trim() : '';
|
|
363
|
+
const scenario = typeof entry?.scenario === 'string' ? entry.scenario.trim() : '';
|
|
364
|
+
const focus = typeof entry?.focus === 'string' ? entry.focus.trim() : '';
|
|
365
|
+
const theme = typeof entry?.theme === 'string' ? entry.theme.trim().toLowerCase() : '';
|
|
366
|
+
if (!/^metadata\/screenshot-\d+\.png$/i.test(path)) {
|
|
367
|
+
throw usageError(`screenshots[${index}].path must match metadata/screenshot-N.png.`);
|
|
368
|
+
}
|
|
369
|
+
const expectedPath = `metadata/screenshot-${index + 1}.png`;
|
|
370
|
+
if (path !== expectedPath) {
|
|
371
|
+
throw usageError(`screenshots[${index}].path must be ${expectedPath} so capture order stays stable.`);
|
|
372
|
+
}
|
|
373
|
+
if (seenPaths.has(path)) {
|
|
374
|
+
throw usageError(`Duplicate listing screenshot path: ${path}`);
|
|
375
|
+
}
|
|
376
|
+
if (theme && theme !== 'light' && theme !== 'dark') {
|
|
377
|
+
throw usageError(`screenshots[${index}].theme must be light or dark.`);
|
|
378
|
+
}
|
|
379
|
+
seenPaths.add(path);
|
|
380
|
+
return {
|
|
381
|
+
path,
|
|
382
|
+
alt,
|
|
383
|
+
route: route || null,
|
|
384
|
+
scenario: scenario || null,
|
|
385
|
+
focus: focus || null,
|
|
386
|
+
theme: theme || 'light',
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Resolve listing screenshot files in their configured editorial order. */
|
|
392
|
+
export function resolveListingScreenshots(projectDir, appConfig = null) {
|
|
393
|
+
const discovered = discoverMetadataAssets(projectDir).screenshots;
|
|
394
|
+
const configured = normalizeConfiguredScreenshots(appConfig);
|
|
395
|
+
if (!configured) {
|
|
396
|
+
return discovered.map((asset) => ({
|
|
397
|
+
...asset,
|
|
398
|
+
alt: null,
|
|
399
|
+
route: null,
|
|
400
|
+
scenario: null,
|
|
401
|
+
focus: null,
|
|
402
|
+
theme: 'light',
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const byPath = new Map(discovered.map((asset) => [asset.path.replace(/\\/g, '/'), asset]));
|
|
407
|
+
return configured.map((entry) => {
|
|
408
|
+
const asset = byPath.get(entry.path);
|
|
409
|
+
if (!asset) {
|
|
410
|
+
return {
|
|
411
|
+
...entry,
|
|
412
|
+
content_type: 'image/png',
|
|
413
|
+
bytes: null,
|
|
414
|
+
width: null,
|
|
415
|
+
height: null,
|
|
416
|
+
errors: [`${entry.path} is configured but the file does not exist.`],
|
|
417
|
+
warnings: [],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return { ...asset, ...entry };
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function inspectListingReadiness(projectDir, appConfig = null) {
|
|
425
|
+
const config = appConfig || {};
|
|
426
|
+
const warnings = [];
|
|
427
|
+
const errors = [];
|
|
428
|
+
const screenshots = resolveListingScreenshots(projectDir, config);
|
|
429
|
+
const metadata = { screenshots };
|
|
430
|
+
const categories = normalizeCategories(config.categories || []);
|
|
431
|
+
const changelog = readAppChangelog(projectDir);
|
|
432
|
+
errors.push(...changelog.errors);
|
|
433
|
+
|
|
434
|
+
const packagePath = join(projectDir, 'package.json');
|
|
435
|
+
if (!existsSync(packagePath)) {
|
|
436
|
+
errors.push('package.json is required for Store publication.');
|
|
437
|
+
} else {
|
|
438
|
+
try {
|
|
439
|
+
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
|
440
|
+
const notisAppVersion = String(packageJson.notisAppVersion || '').trim();
|
|
441
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(notisAppVersion)) {
|
|
442
|
+
errors.push('package.json must include a semver `notisAppVersion` (for example, "0.1.0").');
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
errors.push('package.json must contain valid JSON for Store publication.');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!String(config.tagline || '').trim()) {
|
|
450
|
+
errors.push('Listing tagline missing in notis.config.ts.');
|
|
451
|
+
}
|
|
452
|
+
if (categories.length === 0) {
|
|
453
|
+
errors.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
|
|
454
|
+
}
|
|
455
|
+
if (metadata.screenshots.length < MIN_LISTING_SCREENSHOTS) {
|
|
456
|
+
errors.push(`Listing screenshots: ${metadata.screenshots.length}/${MIN_LISTING_SCREENSHOTS} minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
|
|
457
|
+
}
|
|
458
|
+
for (const screenshot of metadata.screenshots) {
|
|
459
|
+
errors.push(...screenshot.errors);
|
|
460
|
+
warnings.push(...screenshot.warnings);
|
|
461
|
+
if (!screenshot.alt) {
|
|
462
|
+
errors.push(`${screenshot.path} is missing descriptive alt text in notis.config.ts -> screenshots.`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
ready:
|
|
468
|
+
errors.length === 0 &&
|
|
469
|
+
Boolean(String(config.tagline || '').trim()) &&
|
|
470
|
+
categories.length > 0 &&
|
|
471
|
+
metadata.screenshots.length >= MIN_LISTING_SCREENSHOTS &&
|
|
472
|
+
metadata.screenshots.every((screenshot) => Boolean(screenshot.alt)) &&
|
|
473
|
+
changelog.entries.length > 0,
|
|
474
|
+
warnings,
|
|
475
|
+
errors,
|
|
476
|
+
metadata,
|
|
477
|
+
categories,
|
|
478
|
+
changelog,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Build
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
export async function runProjectScript({ projectDir, scriptName, env = {}, stdio = 'inherit' }) {
|
|
487
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
488
|
+
const child = spawn('npm', ['run', scriptName], {
|
|
489
|
+
cwd: projectDir,
|
|
490
|
+
stdio,
|
|
491
|
+
env: { ...process.env, ...env },
|
|
492
|
+
});
|
|
493
|
+
child.on('error', rejectPromise);
|
|
494
|
+
child.on('exit', (code) => {
|
|
495
|
+
if (code === 0) {
|
|
496
|
+
resolvePromise();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
rejectPromise(new Error(`npm run ${scriptName} failed with exit code ${code}`));
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Derive an export name from a route path.
|
|
506
|
+
* '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
|
|
507
|
+
*/
|
|
508
|
+
export function exportNameFromPath(routePath) {
|
|
509
|
+
if (routePath === '/') return 'index';
|
|
510
|
+
const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
511
|
+
const identifier = slug
|
|
512
|
+
.split(/[^A-Za-z0-9_$]+/)
|
|
513
|
+
.filter(Boolean)
|
|
514
|
+
.map((part, index) => {
|
|
515
|
+
const lower = part.toLowerCase();
|
|
516
|
+
if (index === 0) return lower;
|
|
517
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
518
|
+
})
|
|
519
|
+
.join('');
|
|
520
|
+
if (!identifier) return 'route';
|
|
521
|
+
return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function slugFromPath(routePath) {
|
|
525
|
+
if (routePath === '/') return 'index';
|
|
526
|
+
return routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Auto-detect routes from the app/ directory by scanning for page.tsx files.
|
|
531
|
+
*/
|
|
532
|
+
function autoDetectRoutes(projectDir) {
|
|
533
|
+
const appDir = join(projectDir, 'app');
|
|
534
|
+
const routes = [];
|
|
535
|
+
|
|
536
|
+
function scan(dir, pathPrefix) {
|
|
537
|
+
if (!existsSync(dir)) return;
|
|
538
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
539
|
+
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
542
|
+
|
|
543
|
+
if (entry.isDirectory()) {
|
|
544
|
+
scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
|
|
545
|
+
} else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
|
|
546
|
+
const routePath = pathPrefix || '/';
|
|
547
|
+
const slug = slugFromPath(routePath);
|
|
548
|
+
routes.push({
|
|
549
|
+
path: routePath,
|
|
550
|
+
slug,
|
|
551
|
+
name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
552
|
+
default: routePath === '/',
|
|
553
|
+
export_name: exportNameFromPath(routePath),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
scan(appDir, '');
|
|
560
|
+
return routes;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function validateConfiguredRoutes(routes) {
|
|
564
|
+
if (routes.length === 0) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const slugSet = new Set();
|
|
569
|
+
let defaultCount = 0;
|
|
570
|
+
|
|
571
|
+
for (const route of routes) {
|
|
572
|
+
if (!route.slug || typeof route.slug !== 'string') {
|
|
573
|
+
throw usageError(`Route "${route.path}" must define a slug.`);
|
|
574
|
+
}
|
|
575
|
+
if (slugSet.has(route.slug)) {
|
|
576
|
+
throw usageError(`Duplicate route slug "${route.slug}".`);
|
|
577
|
+
}
|
|
578
|
+
slugSet.add(route.slug);
|
|
579
|
+
if (route.default) {
|
|
580
|
+
defaultCount += 1;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (defaultCount !== 1) {
|
|
585
|
+
throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const route of routes) {
|
|
589
|
+
if (route.parentSlug && !slugSet.has(route.parentSlug)) {
|
|
590
|
+
throw usageError(
|
|
591
|
+
`Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
const collection = route.collection || null;
|
|
595
|
+
if (
|
|
596
|
+
collection?.sidebar?.mode === 'tree' &&
|
|
597
|
+
(!collection.parentProperty || typeof collection.parentProperty !== 'string')
|
|
598
|
+
) {
|
|
599
|
+
throw usageError(
|
|
600
|
+
`Tree collection route "${route.slug}" must define collection.parentProperty.`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const childrenByParent = new Map();
|
|
606
|
+
for (const route of routes) {
|
|
607
|
+
if (!route.parentSlug) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const children = childrenByParent.get(route.parentSlug) || [];
|
|
611
|
+
children.push(route.slug);
|
|
612
|
+
childrenByParent.set(route.parentSlug, children);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
for (const route of routes) {
|
|
616
|
+
const seen = new Set([route.slug]);
|
|
617
|
+
let current = route.parentSlug || null;
|
|
618
|
+
while (current) {
|
|
619
|
+
if (seen.has(current)) {
|
|
620
|
+
throw usageError(`Route parent cycle detected at "${route.slug}".`);
|
|
621
|
+
}
|
|
622
|
+
seen.add(current);
|
|
623
|
+
current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const route of routes) {
|
|
628
|
+
if (
|
|
629
|
+
route.collection?.sidebar?.mode === 'tree' &&
|
|
630
|
+
(childrenByParent.get(route.slug) || []).length > 0
|
|
631
|
+
) {
|
|
632
|
+
throw usageError(
|
|
633
|
+
`Tree collection route "${route.slug}" cannot also define static child routes.`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function resolveConfiguredRoutes(appConfig, projectDir) {
|
|
640
|
+
const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
641
|
+
validateConfiguredRoutes(configuredRoutes);
|
|
642
|
+
const routes = configuredRoutes.map((route) => ({
|
|
643
|
+
...route,
|
|
644
|
+
slug: route.slug,
|
|
645
|
+
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
646
|
+
}));
|
|
647
|
+
return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Generate the _entry.tsx file that re-exports each route's page component.
|
|
652
|
+
*/
|
|
653
|
+
function generateEntryFile(projectDir, routes) {
|
|
654
|
+
const entryDir = join(projectDir, NOTIS_DIR);
|
|
655
|
+
mkdirSync(entryDir, { recursive: true });
|
|
656
|
+
|
|
657
|
+
// Also re-export the layout if it exists
|
|
658
|
+
const lines = [];
|
|
659
|
+
const layoutPath = join(projectDir, 'app', 'layout.tsx');
|
|
660
|
+
if (existsSync(layoutPath)) {
|
|
661
|
+
lines.push(`export { default as __AppShell } from '../app/layout';`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
for (const route of routes) {
|
|
665
|
+
const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
|
|
666
|
+
lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const entryPath = join(entryDir, '_entry.tsx');
|
|
670
|
+
writeFileSync(entryPath, lines.join('\n') + '\n');
|
|
671
|
+
return entryPath;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export async function prepareArtifactBuild(projectDir) {
|
|
675
|
+
validateProjectBoundary(projectDir);
|
|
676
|
+
const appConfig = await loadAppConfig(projectDir);
|
|
677
|
+
const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
|
|
678
|
+
|
|
679
|
+
generateEntryFile(projectDir, detectedRoutes);
|
|
680
|
+
|
|
681
|
+
const manifest = generateManifest(appConfig, projectDir);
|
|
682
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
683
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
684
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
685
|
+
|
|
686
|
+
return { appConfig, manifest, routes: detectedRoutes };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Generate the manifest from app config and build output.
|
|
691
|
+
*/
|
|
692
|
+
export function generateManifest(appConfig, projectDir) {
|
|
693
|
+
const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
|
|
694
|
+
const entry = {
|
|
695
|
+
path: route.path,
|
|
696
|
+
slug: route.slug,
|
|
697
|
+
name: route.name,
|
|
698
|
+
icon: route.icon || null,
|
|
699
|
+
parentSlug: route.parentSlug || null,
|
|
700
|
+
default: route.default || false,
|
|
701
|
+
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
702
|
+
collection: route.collection || null,
|
|
703
|
+
};
|
|
704
|
+
if (route.tool_access) {
|
|
705
|
+
entry.tool_access = route.tool_access;
|
|
706
|
+
}
|
|
707
|
+
return entry;
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const databases = appConfig.databases || [];
|
|
711
|
+
const categories = normalizeCategories(appConfig.categories || []);
|
|
712
|
+
const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
|
|
713
|
+
const appSlug = safeKebab(appConfig.name);
|
|
714
|
+
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
715
|
+
const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
|
|
716
|
+
key: skill.key,
|
|
717
|
+
path: String(skill.path || '').replace(/^\.\/+/, ''),
|
|
718
|
+
name: skill.name,
|
|
719
|
+
description: skill.description || null,
|
|
720
|
+
}));
|
|
721
|
+
const listingMedia = {
|
|
722
|
+
screenshots: metadata.screenshots.map((screenshot) => ({
|
|
723
|
+
path: screenshot.path,
|
|
724
|
+
content_type: screenshot.content_type,
|
|
725
|
+
width: screenshot.width,
|
|
726
|
+
height: screenshot.height,
|
|
727
|
+
bytes: screenshot.bytes,
|
|
728
|
+
alt: screenshot.alt || null,
|
|
729
|
+
theme: screenshot.theme || 'light',
|
|
730
|
+
})),
|
|
731
|
+
};
|
|
732
|
+
const changelog = readAppChangelog(projectDir);
|
|
733
|
+
if (changelog.exists && changelog.errors.length > 0) {
|
|
734
|
+
throw usageError(changelog.errors.join('\n'));
|
|
735
|
+
}
|
|
736
|
+
const latestChangelogEntry = changelog.entries[0] || null;
|
|
737
|
+
const legacyVersionNotes = latestChangelogEntry?.body
|
|
738
|
+
|| appConfig.versionNotes
|
|
739
|
+
|| appConfig.version_notes
|
|
740
|
+
|| null;
|
|
741
|
+
let releaseVersion = null;
|
|
742
|
+
try {
|
|
743
|
+
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
|
|
744
|
+
releaseVersion = String(packageJson.notisAppVersion || '').trim() || null;
|
|
745
|
+
} catch {
|
|
746
|
+
// Store readiness reports missing or malformed package metadata. Ordinary
|
|
747
|
+
// development builds remain usable without a Store release version.
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
version: 1,
|
|
752
|
+
spec_version: 4,
|
|
753
|
+
app: {
|
|
754
|
+
name: displayTitle,
|
|
755
|
+
slug: appSlug || null,
|
|
756
|
+
description: appConfig.description || null,
|
|
757
|
+
icon: appConfig.icon || null,
|
|
758
|
+
accent: appConfig.accent || null,
|
|
759
|
+
title: displayTitle,
|
|
760
|
+
tagline: appConfig.tagline || null,
|
|
761
|
+
categories,
|
|
762
|
+
author: appConfig.author || null,
|
|
763
|
+
release_version: releaseVersion,
|
|
764
|
+
version_notes: legacyVersionNotes,
|
|
765
|
+
},
|
|
766
|
+
listing: {
|
|
767
|
+
title: displayTitle,
|
|
768
|
+
tagline: appConfig.tagline || null,
|
|
769
|
+
categories,
|
|
770
|
+
author: appConfig.author || null,
|
|
771
|
+
version_notes: legacyVersionNotes,
|
|
772
|
+
...(changelog.exists
|
|
773
|
+
? {
|
|
774
|
+
changelog: {
|
|
775
|
+
source_path: changelog.source_path,
|
|
776
|
+
entries: changelog.entries,
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
: {}),
|
|
780
|
+
media: listingMedia,
|
|
781
|
+
},
|
|
782
|
+
metadata: listingMedia,
|
|
783
|
+
routes,
|
|
784
|
+
bundle: {
|
|
785
|
+
js: 'bundle/app.js',
|
|
786
|
+
css: 'bundle/app.css',
|
|
787
|
+
},
|
|
788
|
+
databases,
|
|
789
|
+
tools: appConfig.tools || [],
|
|
790
|
+
skills,
|
|
791
|
+
onboarding: appConfig.onboarding || null,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
796
|
+
const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
|
|
797
|
+
const projectRoot = resolve(projectDir);
|
|
798
|
+
const seenKeys = new Set();
|
|
799
|
+
|
|
800
|
+
return configured.map((skill, index) => {
|
|
801
|
+
const key = typeof skill?.key === 'string' ? skill.key.trim() : '';
|
|
802
|
+
const sourcePath = typeof skill?.path === 'string' ? skill.path.trim() : '';
|
|
803
|
+
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
|
|
804
|
+
if (!key || !sourcePath || !name) {
|
|
805
|
+
throw usageError(`skills[${index}] must define non-empty key, path, and name values.`);
|
|
806
|
+
}
|
|
807
|
+
if (seenKeys.has(key)) {
|
|
808
|
+
throw usageError(`Duplicate app skill key: ${key}`);
|
|
809
|
+
}
|
|
810
|
+
seenKeys.add(key);
|
|
811
|
+
|
|
812
|
+
const absolutePath = resolve(projectRoot, sourcePath);
|
|
813
|
+
const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
|
|
814
|
+
if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
|
|
815
|
+
throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
|
|
816
|
+
}
|
|
817
|
+
if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) {
|
|
818
|
+
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
key,
|
|
823
|
+
path: relativePath,
|
|
824
|
+
name,
|
|
825
|
+
description: typeof skill.description === 'string' ? skill.description.trim() : null,
|
|
826
|
+
skill_md: readFileSync(absolutePath, 'utf8'),
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
|
|
833
|
+
*/
|
|
834
|
+
export async function buildArtifact(projectDir) {
|
|
835
|
+
await prepareArtifactBuild(projectDir);
|
|
836
|
+
|
|
837
|
+
// Run Vite build
|
|
838
|
+
await runProjectScript({
|
|
839
|
+
projectDir,
|
|
840
|
+
scriptName: 'build',
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Verify the canonical `.notis/output/bundle` packaging contract.
|
|
844
|
+
const builtBundleDir = resolveBuiltBundleDir(projectDir);
|
|
845
|
+
if (!builtBundleDir) {
|
|
846
|
+
throw usageError(
|
|
847
|
+
'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
normalizeBundleStylesheets(projectDir);
|
|
851
|
+
copyMetadataAssets(projectDir);
|
|
852
|
+
|
|
853
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
854
|
+
|
|
855
|
+
const manifest = readManifest(projectDir);
|
|
856
|
+
|
|
857
|
+
return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Read the manifest from a built project.
|
|
862
|
+
*/
|
|
863
|
+
export function readManifest(projectDir) {
|
|
864
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
865
|
+
if (!existsSync(manifestPath)) {
|
|
866
|
+
throw usageError('No manifest found. Run "notis apps build" first.');
|
|
867
|
+
}
|
|
868
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function copyMetadataAssets(projectDir) {
|
|
872
|
+
const metadataDir = join(projectDir, METADATA_DIR);
|
|
873
|
+
const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
|
|
874
|
+
// Vite clears the bundle directory, not sibling listing media. Always make
|
|
875
|
+
// the packaged metadata an exact reflection of the source tree so removed
|
|
876
|
+
// placeholders can never survive into a later deploy.
|
|
877
|
+
rmSync(outputMetadataDir, { recursive: true, force: true });
|
|
878
|
+
if (!existsSync(metadataDir)) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
mkdirSync(outputMetadataDir, { recursive: true });
|
|
882
|
+
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
883
|
+
if (!entry.isFile()) continue;
|
|
884
|
+
if (!/^screenshot-\d+\.png$/i.test(entry.name)) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
// Linking
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
|
|
895
|
+
export function readLinkedState(projectDir) {
|
|
896
|
+
const statePath = join(projectDir, STATE_FILE);
|
|
897
|
+
if (!existsSync(statePath)) return null;
|
|
898
|
+
return JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export function writeLinkedState(projectDir, state) {
|
|
902
|
+
const statePath = join(projectDir, STATE_FILE);
|
|
903
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
904
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
908
|
+
if (explicitAppId) return explicitAppId;
|
|
909
|
+
const state = readLinkedState(projectDir);
|
|
910
|
+
if (state?.app_id) return state.app_id;
|
|
911
|
+
throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// Scaffolding
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Scaffold a new Notis app project from the SDK template.
|
|
920
|
+
*/
|
|
921
|
+
export function loadScaffoldCatalog() {
|
|
922
|
+
if (existsSync(SCAFFOLD_CATALOG_FILE)) {
|
|
923
|
+
const parsed = JSON.parse(readFileSync(SCAFFOLD_CATALOG_FILE, 'utf-8'));
|
|
924
|
+
const scaffolds = Array.isArray(parsed?.scaffolds) ? parsed.scaffolds : parsed;
|
|
925
|
+
return Array.isArray(scaffolds)
|
|
926
|
+
? scaffolds.filter((entry) => entry && typeof entry.slug === 'string')
|
|
927
|
+
: [];
|
|
928
|
+
}
|
|
929
|
+
return loadMonorepoScaffoldCatalog();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export function scaffoldProject({ projectDir, appName, fromSlug = null }) {
|
|
933
|
+
const templateDir = fromSlug ? resolveScaffoldSourceDir(fromSlug) : join(CLI_ROOT, 'template');
|
|
934
|
+
|
|
935
|
+
if (!existsSync(templateDir)) {
|
|
936
|
+
if (fromSlug) {
|
|
937
|
+
const known = loadScaffoldCatalog().map((entry) => entry.slug).join(', ') || 'none';
|
|
938
|
+
throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
|
|
939
|
+
}
|
|
940
|
+
throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
mkdirSync(projectDir, { recursive: true });
|
|
944
|
+
copyScaffoldSource(templateDir, projectDir);
|
|
945
|
+
|
|
946
|
+
// Update package.json with the app name
|
|
947
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
948
|
+
if (existsSync(pkgPath)) {
|
|
949
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
950
|
+
pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
951
|
+
ensureScaffoldLocalSdk(projectDir, pkg);
|
|
952
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Update notis.config.ts with the app name
|
|
956
|
+
const configPath = join(projectDir, 'notis.config.ts');
|
|
957
|
+
if (existsSync(configPath)) {
|
|
958
|
+
let config = readFileSync(configPath, 'utf-8');
|
|
959
|
+
const displayName = jsStringLiteral(appName);
|
|
960
|
+
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
961
|
+
if (fromSlug) {
|
|
962
|
+
if (/name\s*:/.test(config)) {
|
|
963
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
964
|
+
}
|
|
965
|
+
if (/title\s*:/.test(config)) {
|
|
966
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
967
|
+
} else {
|
|
968
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
|
|
969
|
+
}
|
|
970
|
+
} else {
|
|
971
|
+
if (/name\s*:/.test(config)) {
|
|
972
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
973
|
+
}
|
|
974
|
+
if (/title\s*:/.test(config)) {
|
|
975
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
976
|
+
} else {
|
|
977
|
+
config = config.replace(/'My Notis App'/, displayName);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
writeFileSync(configPath, config);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return { projectDir };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
987
|
+
let shouldInstallLocalSdk = false;
|
|
988
|
+
for (const dependencyGroup of ['dependencies', 'devDependencies']) {
|
|
989
|
+
const dependencyValue = pkg[dependencyGroup]?.['@notis/sdk'];
|
|
990
|
+
if (typeof dependencyValue === 'string' && dependencyValue.startsWith('file:')) {
|
|
991
|
+
pkg[dependencyGroup]['@notis/sdk'] = 'file:./packages/sdk';
|
|
992
|
+
shouldInstallLocalSdk = true;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (!shouldInstallLocalSdk) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const localSdkDir = join(projectDir, 'packages', 'sdk');
|
|
1001
|
+
if (existsSync(join(localSdkDir, 'package.json'))) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (!existsSync(join(TEMPLATE_SDK_DIR, 'package.json'))) {
|
|
1005
|
+
throw usageError(`SDK template not found at ${TEMPLATE_SDK_DIR}. Ensure @notis_ai/cli is installed correctly.`);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
mkdirSync(dirname(localSdkDir), { recursive: true });
|
|
1009
|
+
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function resolveScaffoldSourceDir(fromSlug) {
|
|
1013
|
+
const bundledDir = join(SCAFFOLD_SOURCE_DIR, fromSlug);
|
|
1014
|
+
if (existsSync(bundledDir)) {
|
|
1015
|
+
return bundledDir;
|
|
1016
|
+
}
|
|
1017
|
+
const monorepoDir = join(MONOREPO_APPS_DIR, fromSlug);
|
|
1018
|
+
if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
|
|
1019
|
+
return monorepoDir;
|
|
1020
|
+
}
|
|
1021
|
+
return bundledDir;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function loadMonorepoScaffoldCatalog() {
|
|
1025
|
+
if (!existsSync(MONOREPO_APPS_DIR)) {
|
|
1026
|
+
return [];
|
|
1027
|
+
}
|
|
1028
|
+
const scaffolds = [];
|
|
1029
|
+
for (const entry of readdirSync(MONOREPO_APPS_DIR, { withFileTypes: true })) {
|
|
1030
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const configPath = join(MONOREPO_APPS_DIR, entry.name, 'notis.config.ts');
|
|
1034
|
+
if (!existsSync(configPath)) {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const configSource = readFileSync(configPath, 'utf-8');
|
|
1038
|
+
const description = readTsStringProperty(configSource, 'description') || '';
|
|
1039
|
+
scaffolds.push({
|
|
1040
|
+
slug: entry.name,
|
|
1041
|
+
name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
|
|
1042
|
+
description,
|
|
1043
|
+
icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
|
|
1044
|
+
categories: readTsStringArrayProperty(configSource, 'categories'),
|
|
1045
|
+
tagline: readTsStringProperty(configSource, 'tagline') || description,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function readTsStringProperty(source, propertyName) {
|
|
1052
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
|
|
1053
|
+
return match ? match[2] : null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function readTsStringArrayProperty(source, propertyName) {
|
|
1057
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
|
|
1058
|
+
if (!match) {
|
|
1059
|
+
return [];
|
|
1060
|
+
}
|
|
1061
|
+
return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function copyScaffoldSource(sourceDir, targetDir) {
|
|
1065
|
+
function shouldCopy(path) {
|
|
1066
|
+
const name = path.split(/[\\/]/).pop();
|
|
1067
|
+
if (!name) return true;
|
|
1068
|
+
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
1069
|
+
&& !name.startsWith('.env')
|
|
1070
|
+
&& !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function walk(src, dest) {
|
|
1074
|
+
if (!shouldCopy(src)) {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const stat = statSync(src);
|
|
1078
|
+
if (stat.isDirectory()) {
|
|
1079
|
+
mkdirSync(dest, { recursive: true });
|
|
1080
|
+
for (const entry of readdirSync(src)) {
|
|
1081
|
+
walk(join(src, entry), join(dest, entry));
|
|
1082
|
+
}
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (stat.isFile()) {
|
|
1086
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1087
|
+
cpSync(src, dest);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
walk(sourceDir, targetDir);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ---------------------------------------------------------------------------
|
|
1095
|
+
// Deploy helpers
|
|
1096
|
+
// ---------------------------------------------------------------------------
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Collect all files from .notis/output/ as base64-encoded entries for upload.
|
|
1100
|
+
*/
|
|
1101
|
+
export function collectArtifactFiles(projectDir) {
|
|
1102
|
+
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
1103
|
+
if (!existsSync(outputDir)) {
|
|
1104
|
+
throw usageError('No build output found. Run "notis apps build" first.');
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
1108
|
+
|
|
1109
|
+
const files = {};
|
|
1110
|
+
|
|
1111
|
+
function walk(dir, prefix) {
|
|
1112
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1113
|
+
for (const entry of entries) {
|
|
1114
|
+
const fullPath = join(dir, entry.name);
|
|
1115
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1116
|
+
if (entry.isDirectory()) {
|
|
1117
|
+
walk(fullPath, relPath);
|
|
1118
|
+
} else {
|
|
1119
|
+
files[relPath] = readFileSync(fullPath).toString('base64');
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
walk(outputDir, '');
|
|
1125
|
+
return files;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function shouldExcludeSourceEntry(name) {
|
|
1129
|
+
return SOURCE_COPY_EXCLUDES.has(name) || name.startsWith('.env');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function readSourceFiles(projectDir) {
|
|
1133
|
+
const files = {};
|
|
1134
|
+
|
|
1135
|
+
function walk(dir, prefix) {
|
|
1136
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1137
|
+
for (const entry of entries) {
|
|
1138
|
+
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const fullPath = join(dir, entry.name);
|
|
1142
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1143
|
+
if (entry.isDirectory()) {
|
|
1144
|
+
walk(fullPath, relPath);
|
|
1145
|
+
} else if (entry.isFile()) {
|
|
1146
|
+
files[relPath] = readFileSync(fullPath);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
walk(projectDir, '');
|
|
1152
|
+
return files;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export function collectSourceFiles(projectDir) {
|
|
1156
|
+
const files = readSourceFiles(projectDir);
|
|
1157
|
+
const encoded = {};
|
|
1158
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
1159
|
+
encoded[relPath] = content.toString('base64');
|
|
1160
|
+
}
|
|
1161
|
+
return encoded;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function cleanTarPath(name) {
|
|
1165
|
+
const cleaned = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
1166
|
+
const parts = cleaned.split('/').filter(Boolean);
|
|
1167
|
+
if (!parts.length || parts.includes('..')) {
|
|
1168
|
+
throw usageError(`Refusing to extract unsafe path from source archive: ${name}`);
|
|
1169
|
+
}
|
|
1170
|
+
return parts.join('/');
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function extractTarGz(buffer, targetDir) {
|
|
1174
|
+
const tar = gunzipSync(buffer);
|
|
1175
|
+
let offset = 0;
|
|
1176
|
+
while (offset + 512 <= tar.length) {
|
|
1177
|
+
const header = tar.subarray(offset, offset + 512);
|
|
1178
|
+
offset += 512;
|
|
1179
|
+
if (header.every((byte) => byte === 0)) {
|
|
1180
|
+
break;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
|
|
1184
|
+
const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0.*$/, '');
|
|
1185
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
1186
|
+
const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
|
1187
|
+
const size = Number.parseInt(sizeRaw || '0', 8);
|
|
1188
|
+
const typeFlag = header[156];
|
|
1189
|
+
const relPath = cleanTarPath(fullName);
|
|
1190
|
+
const outputPath = join(targetDir, relPath);
|
|
1191
|
+
|
|
1192
|
+
if (typeFlag === 53) {
|
|
1193
|
+
mkdirSync(outputPath, { recursive: true });
|
|
1194
|
+
} else {
|
|
1195
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1196
|
+
writeFileSync(outputPath, tar.subarray(offset, offset + size));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
offset += Math.ceil(size / 512) * 512;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export async function pullAppSource({
|
|
1204
|
+
apiBase,
|
|
1205
|
+
jwt,
|
|
1206
|
+
appId,
|
|
1207
|
+
targetDir,
|
|
1208
|
+
version = 'latest',
|
|
1209
|
+
force = false,
|
|
1210
|
+
}) {
|
|
1211
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
1212
|
+
if (!force) {
|
|
1213
|
+
throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
|
|
1214
|
+
}
|
|
1215
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1218
|
+
|
|
1219
|
+
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
1220
|
+
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
1221
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
1222
|
+
});
|
|
1223
|
+
if (!response.ok) {
|
|
1224
|
+
const data = await response.json().catch(() => ({}));
|
|
1225
|
+
throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
1229
|
+
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
1230
|
+
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
1231
|
+
extractTarGz(Buffer.from(await response.arrayBuffer()), targetDir);
|
|
1232
|
+
const linkedVersion = pulledVersion || (
|
|
1233
|
+
String(version || 'latest') === 'latest'
|
|
1234
|
+
? undefined
|
|
1235
|
+
: Number.parseInt(String(version), 10)
|
|
1236
|
+
);
|
|
1237
|
+
writeLinkedState(targetDir, {
|
|
1238
|
+
app_id: appId,
|
|
1239
|
+
...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
|
|
1240
|
+
linked_at: new Date().toISOString(),
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
return { projectDir: targetDir, version: pulledVersion || version };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function readArtifactFiles(projectDir) {
|
|
1247
|
+
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
1248
|
+
if (!existsSync(outputDir)) {
|
|
1249
|
+
throw usageError('No build output found. Run "notis apps build" first.');
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const files = {};
|
|
1253
|
+
|
|
1254
|
+
function walk(dir, prefix) {
|
|
1255
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1256
|
+
for (const entry of entries) {
|
|
1257
|
+
const fullPath = join(dir, entry.name);
|
|
1258
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1259
|
+
if (entry.isDirectory()) {
|
|
1260
|
+
walk(fullPath, relPath);
|
|
1261
|
+
} else {
|
|
1262
|
+
files[relPath] = readFileSync(fullPath);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
walk(outputDir, '');
|
|
1268
|
+
return files;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// ---------------------------------------------------------------------------
|
|
1272
|
+
// Direct deploy (bypasses backend server)
|
|
1273
|
+
// ---------------------------------------------------------------------------
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Resolve Supabase credentials from the server/.env file in the repo workspace.
|
|
1277
|
+
* Falls back to environment variables.
|
|
1278
|
+
*/
|
|
1279
|
+
function resolveSupabaseCredentials() {
|
|
1280
|
+
const envPaths = [
|
|
1281
|
+
resolve(process.cwd(), 'server/.env'),
|
|
1282
|
+
resolve(process.cwd(), '../server/.env'),
|
|
1283
|
+
resolve(process.cwd(), '../../server/.env'),
|
|
1284
|
+
];
|
|
1285
|
+
|
|
1286
|
+
let supabaseUrl = process.env.SUPABASE_URL;
|
|
1287
|
+
let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
|
|
1288
|
+
let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
1289
|
+
|
|
1290
|
+
for (const envPath of envPaths) {
|
|
1291
|
+
if (existsSync(envPath)) {
|
|
1292
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
1293
|
+
for (const line of content.split('\n')) {
|
|
1294
|
+
const trimmed = line.trim();
|
|
1295
|
+
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
|
1296
|
+
const eqIdx = trimmed.indexOf('=');
|
|
1297
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
1298
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
1299
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
1300
|
+
value = value.slice(1, -1);
|
|
1301
|
+
}
|
|
1302
|
+
if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
|
|
1303
|
+
if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
|
|
1304
|
+
if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
|
|
1305
|
+
}
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Derive URL from subdomain if needed
|
|
1311
|
+
if (!supabaseUrl && supabaseSubdomain) {
|
|
1312
|
+
supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
1316
|
+
throw usageError(
|
|
1317
|
+
'Cannot resolve Supabase credentials for direct deploy. ' +
|
|
1318
|
+
'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
|
|
1319
|
+
'or set them as environment variables.',
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
return { supabaseUrl, supabaseKey };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Upload a file to Supabase Storage with upsert.
|
|
1328
|
+
*/
|
|
1329
|
+
async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
|
|
1330
|
+
const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
|
|
1331
|
+
const response = await fetch(url, {
|
|
1332
|
+
method: 'POST',
|
|
1333
|
+
headers: {
|
|
1334
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1335
|
+
'Content-Type': contentType,
|
|
1336
|
+
'x-upsert': 'true',
|
|
1337
|
+
},
|
|
1338
|
+
body: content,
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
if (!response.ok) {
|
|
1342
|
+
const text = await response.text().catch(() => '');
|
|
1343
|
+
throw new Error(`Storage upload failed (${response.status}): ${text}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
|
|
1348
|
+
const encodedBucket = encodeURIComponent(bucket);
|
|
1349
|
+
const headers = {
|
|
1350
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1351
|
+
'apikey': supabaseKey,
|
|
1352
|
+
};
|
|
1353
|
+
const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
|
|
1354
|
+
headers,
|
|
1355
|
+
});
|
|
1356
|
+
if (inspectResponse.ok) {
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (inspectResponse.status !== 404) {
|
|
1360
|
+
const text = await inspectResponse.text().catch(() => '');
|
|
1361
|
+
throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
|
|
1365
|
+
method: 'POST',
|
|
1366
|
+
headers: {
|
|
1367
|
+
...headers,
|
|
1368
|
+
'Content-Type': 'application/json',
|
|
1369
|
+
},
|
|
1370
|
+
body: JSON.stringify({
|
|
1371
|
+
id: bucket,
|
|
1372
|
+
name: bucket,
|
|
1373
|
+
public: Boolean(options.public),
|
|
1374
|
+
}),
|
|
1375
|
+
});
|
|
1376
|
+
if (createResponse.ok) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const text = await createResponse.text().catch(() => '');
|
|
1381
|
+
if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Get the current app manifest version from the apps table.
|
|
1389
|
+
*/
|
|
1390
|
+
async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
|
|
1391
|
+
const encodedAppId = encodeURIComponent(appId);
|
|
1392
|
+
const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
|
|
1393
|
+
const response = await fetch(url, {
|
|
1394
|
+
headers: {
|
|
1395
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1396
|
+
'apikey': supabaseKey,
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
|
|
1400
|
+
const rows = await response.json();
|
|
1401
|
+
if (!rows.length) throw usageError(`App ${appId} not found.`);
|
|
1402
|
+
const manifest = rows[0].manifest || {};
|
|
1403
|
+
return { currentVersion: manifest.version || 0, manifest };
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Update the app manifest in the apps table.
|
|
1408
|
+
*/
|
|
1409
|
+
async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
|
|
1410
|
+
const encodedAppId = encodeURIComponent(appId);
|
|
1411
|
+
const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
|
|
1412
|
+
const response = await fetch(url, {
|
|
1413
|
+
method: 'PATCH',
|
|
1414
|
+
headers: {
|
|
1415
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
1416
|
+
'apikey': supabaseKey,
|
|
1417
|
+
'Content-Type': 'application/json',
|
|
1418
|
+
'Prefer': 'return=minimal',
|
|
1419
|
+
},
|
|
1420
|
+
body: JSON.stringify({
|
|
1421
|
+
manifest: { ...manifest, version: newVersion },
|
|
1422
|
+
accent: manifest?.app?.accent ?? null,
|
|
1423
|
+
updated_at: new Date().toISOString(),
|
|
1424
|
+
}),
|
|
1425
|
+
});
|
|
1426
|
+
if (!response.ok) {
|
|
1427
|
+
const text = await response.text().catch(() => '');
|
|
1428
|
+
throw new Error(`Failed to update app version: ${response.status} ${text}`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const CONTENT_TYPE_MAP = {
|
|
1433
|
+
'.js': 'application/javascript',
|
|
1434
|
+
'.css': 'text/css',
|
|
1435
|
+
'.json': 'application/json',
|
|
1436
|
+
'.html': 'text/html',
|
|
1437
|
+
'.png': 'image/png',
|
|
1438
|
+
'.jpg': 'image/jpeg',
|
|
1439
|
+
'.jpeg': 'image/jpeg',
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Deploy app bundle directly to Supabase storage, bypassing the backend server.
|
|
1444
|
+
*/
|
|
1445
|
+
export async function directDeploy(projectDir, appId) {
|
|
1446
|
+
const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
|
|
1447
|
+
const manifest = readManifest(projectDir);
|
|
1448
|
+
const artifactFiles = readArtifactFiles(projectDir);
|
|
1449
|
+
const sourceFiles = readSourceFiles(projectDir);
|
|
1450
|
+
validateArtifactBoundary(artifactFiles);
|
|
1451
|
+
|
|
1452
|
+
// Get current version and increment
|
|
1453
|
+
const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
|
|
1454
|
+
const newVersion = currentVersion + 1;
|
|
1455
|
+
|
|
1456
|
+
// Upload all files from the output directory
|
|
1457
|
+
const bucket = 'app-code';
|
|
1458
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
|
|
1459
|
+
await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
|
|
1460
|
+
|
|
1461
|
+
async function uploadDir(dir, prefix) {
|
|
1462
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1463
|
+
for (const entry of entries) {
|
|
1464
|
+
const fullPath = join(dir, entry.name);
|
|
1465
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1466
|
+
if (entry.isDirectory()) {
|
|
1467
|
+
await uploadDir(fullPath, relPath);
|
|
1468
|
+
} else {
|
|
1469
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
1470
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
1471
|
+
const content = readFileSync(fullPath);
|
|
1472
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
1473
|
+
await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
await uploadDir(join(projectDir, OUTPUT_DIR), '');
|
|
1479
|
+
|
|
1480
|
+
for (const [relPath, content] of Object.entries(sourceFiles)) {
|
|
1481
|
+
const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
|
|
1482
|
+
const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
|
1483
|
+
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
1484
|
+
await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Update the app record -- include storage_prefix so the portal can resolve bundle URLs
|
|
1488
|
+
const storagePrefix = `${appId}/v${newVersion}/`;
|
|
1489
|
+
const deployManifest = {
|
|
1490
|
+
...manifest,
|
|
1491
|
+
version: newVersion,
|
|
1492
|
+
storage_bucket: bucket,
|
|
1493
|
+
storage_prefix: storagePrefix,
|
|
1494
|
+
source_storage_bucket: 'app-source',
|
|
1495
|
+
source_storage_prefix: storagePrefix,
|
|
1496
|
+
};
|
|
1497
|
+
await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
|
|
1498
|
+
|
|
1499
|
+
return { version: newVersion };
|
|
1500
|
+
}
|