@entrinsik/vite-plugin-informer 2.5.0 → 2.6.0-beta.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/bin/publish.js ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, access, readdir } from 'node:fs/promises';
4
+ import { resolve, join } from 'node:path';
5
+ import { collectAppFiles } from '../src/assemble.js';
6
+ import { extractNotes } from '../src/changelog.js';
7
+ import { publish } from '../src/publish.js';
8
+ import { loadEnv, parseModeArg } from '../src/env.js';
9
+
10
+ const mode = parseModeArg(process.argv);
11
+ loadEnv({ mode });
12
+
13
+ // --- Target + auth. You already have API keys sorted — just point these at the
14
+ // License Manager that fronts the marketplace cloud-api. ---
15
+ const marketplaceUrl = process.env.INFORMER_MARKETPLACE_URL;
16
+ const token = process.env.INFORMER_PUBLISH_TOKEN || process.env.INFORMER_API_KEY;
17
+
18
+ if (!marketplaceUrl || !token) {
19
+ console.error('Missing required environment variables.');
20
+ console.error(' INFORMER_MARKETPLACE_URL=https://<license-manager-host>/cloud-api');
21
+ console.error(' INFORMER_PUBLISH_TOKEN=lmpub_<vendor publish key>');
22
+ process.exit(1);
23
+ }
24
+
25
+ // --- App + listing metadata from package.json "informer" block ---
26
+ const projectRoot = resolve('.');
27
+ let pkg;
28
+ try {
29
+ pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
30
+ } catch {
31
+ console.error('Could not read package.json in current directory.');
32
+ process.exit(1);
33
+ }
34
+
35
+ const inf = pkg.informer || {};
36
+ const name = inf.name || pkg.name;
37
+ const slug = inf.slug || defaultSlug(name);
38
+
39
+ // Version: prefer the git tag (CI: GITHUB_REF_NAME=vX.Y.Z), then --version, then package.json.
40
+ const version = argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME) || pkg.version;
41
+
42
+ if (!name || !slug || !version) {
43
+ console.error('Need a name, slug, and version. Set package.json "informer.name"/"informer.slug" and tag a release (vX.Y.Z) or pass --version.');
44
+ process.exit(1);
45
+ }
46
+
47
+ // Release notes: the section for this version, else the cumulative Unreleased block.
48
+ const changeNotes = await extractNotes(join(projectRoot, 'CHANGELOG.md'), version);
49
+
50
+ // Provenance (GitHub Actions). All optional — best-effort audit trail.
51
+ const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
52
+ const sourceCommit = process.env.GITHUB_SHA || undefined;
53
+ const ciRun = process.env.GITHUB_RUN_ID
54
+ ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
55
+ : undefined;
56
+
57
+ // Assemble the same file set a deploy produces.
58
+ let files;
59
+ try {
60
+ files = await collectAppFiles({ distDir: join(projectRoot, 'dist'), projectRoot });
61
+ } catch (err) {
62
+ console.error(err.message);
63
+ process.exit(1);
64
+ }
65
+
66
+ const icon = await resolveIcon(projectRoot, inf.icon);
67
+ const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
68
+ const channel = version.includes('-') ? 'beta' : 'stable';
69
+
70
+ console.log(`Publishing ${name} v${version} (${channel}) — ${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}${changeNotes ? '' : ' — no release notes found'}`);
71
+
72
+ try {
73
+ const result = await publish({
74
+ marketplaceUrl,
75
+ token,
76
+ files,
77
+ icon,
78
+ screenshots,
79
+ fields: {
80
+ name,
81
+ slug,
82
+ version,
83
+ shortDescription: inf.shortDescription,
84
+ description: inf.description || pkg.description,
85
+ categories: inf.categories,
86
+ documentationUrl: inf.documentationUrl,
87
+ requires: inf.requires,
88
+ metadata: inf.metadata,
89
+ changeNotes,
90
+ sourceRepo,
91
+ sourceCommit,
92
+ ciRun
93
+ }
94
+ });
95
+ console.log(`Published ${slug} v${version} (${result.channel || channel}).`);
96
+ } catch (err) {
97
+ console.error(err.message);
98
+ process.exit(1);
99
+ }
100
+
101
+ // --- helpers ---
102
+
103
+ function argValue(flag) {
104
+ const i = process.argv.indexOf(flag);
105
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
106
+ }
107
+
108
+ function tagVersion(ref) {
109
+ return ref ? ref.replace(/^v/, '') : undefined;
110
+ }
111
+
112
+ function defaultSlug(value) {
113
+ if (!value) return undefined;
114
+ const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
115
+ return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
116
+ }
117
+
118
+ async function resolveIcon(root, configured) {
119
+ const candidates = [configured, 'favicon.svg', join('public', 'favicon.svg'), join('dist', 'favicon.svg')].filter(Boolean);
120
+ for (const rel of candidates) {
121
+ const abs = resolve(root, rel);
122
+ try {
123
+ await access(abs);
124
+ return { abs, filename: rel.split(/[/\\]/).pop() };
125
+ } catch {
126
+ // try next candidate
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ // Collect listing screenshots from a `screenshots/` directory (override via
133
+ // package.json informer.screenshots), ordered by filename. Returns
134
+ // [{abs, filename}] for the publisher to upload; empty when none exist.
135
+ async function resolveScreenshots(root, configuredDir) {
136
+ const dir = resolve(root, configuredDir || 'screenshots');
137
+ let names;
138
+ try {
139
+ names = await readdir(dir);
140
+ } catch {
141
+ return []; // no screenshots directory — nothing to upload
142
+ }
143
+ return names
144
+ .filter(n => /\.(png|jpe?g|webp|gif)$/i.test(n))
145
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
146
+ .map(n => ({ abs: join(dir, n), filename: n }));
147
+ }
package/index.d.ts CHANGED
@@ -1,2 +1,26 @@
1
1
  import type { Plugin } from 'vite';
2
- export default function informer(): Plugin;
2
+
3
+ /**
4
+ * Dev-only binding for a `target: app` dependency slot. Points `request()` at
5
+ * the target app in dev; overrides the manifest `defaultBinding` when both are
6
+ * present.
7
+ *
8
+ * - `app` — the target app (`owner:slug` or UUID). Powers `request()`.
9
+ *
10
+ * A bare string is shorthand for `{ app }`.
11
+ */
12
+ export interface AppDevBinding {
13
+ app?: string;
14
+ }
15
+
16
+ export interface InformerPluginOptions {
17
+ mock?: {
18
+ report?: { id?: string; name?: string };
19
+ theme?: 'light' | 'dark';
20
+ roles?: string[];
21
+ };
22
+ devBindings?: Record<string, string | AppDevBinding>;
23
+ proxy?: Record<string, unknown>;
24
+ }
25
+
26
+ export default function informer(options?: InformerPluginOptions): Plugin;
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.5.0",
3
+ "version": "2.6.0-beta.1",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
5
8
  "repository": {
6
9
  "type": "git",
7
10
  "url": "https://github.com/entrinsik-org/i5.git",
8
11
  "directory": "packages/vite-plugin-informer"
9
12
  },
10
- "publishConfig": {
11
- "registry": "https://docker.entrinsik.com/repository/entNPM/"
12
- },
13
13
  "author": "Entrinsik Inc.",
14
14
  "license": "UNLICENSED",
15
15
  "type": "module",
@@ -24,10 +24,12 @@
24
24
  "default": "./src/index.js"
25
25
  },
26
26
  "./deploy": "./src/deploy.js",
27
+ "./publish": "./src/publish.js",
27
28
  "./workspace": "./src/workspace.js"
28
29
  },
29
30
  "bin": {
30
31
  "informer-deploy": "./bin/deploy.js",
32
+ "informer-publish": "./bin/publish.js",
31
33
  "informer-init": "./bin/init.js",
32
34
  "informer-workspace": "./bin/workspace.js",
33
35
  "create-magic-report": "./bin/init.js"
package/src/agent-dev.js CHANGED
@@ -7,6 +7,10 @@ const parseYaml = yaml.parse;
7
7
 
8
8
  const MAX_STEPS = 20;
9
9
 
10
+ // Cap dev proxy calls at 30s so a hung upstream fails loudly. (The AI _chat
11
+ // stream below uses its own fetch.)
12
+ const FETCH_TIMEOUT_MS = 30000;
13
+
10
14
  /**
11
15
  * Read and parse informer.yaml from the project root.
12
16
  *
@@ -145,7 +149,7 @@ async function readSSE(response) {
145
149
  * @param {Object} opts - Configuration
146
150
  * @returns {Function} Connect middleware
147
151
  */
148
- export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot }) {
152
+ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken }) {
149
153
 
150
154
  // query() — proxies to the workspace _sql endpoint (same as server-routes.js)
151
155
  async function query(sql, params) {
@@ -169,29 +173,57 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
169
173
  }
170
174
 
171
175
  // fetch() — proxies API calls to the Informer server (same as server-routes.js)
172
- async function apiFetch(path, opts = {}) {
176
+ async function fetchAs(auth, path, opts = {}) {
173
177
  const method = (opts.method || 'GET').toUpperCase();
174
178
  // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
175
179
  const apiPath = normalizeFetchPath(path);
176
180
  if (!apiPath) {
177
- return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
181
+ return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
178
182
  }
179
183
  const url = `${serverOrigin}${apiPath}`;
180
184
  const fetchOpts = {
181
185
  method,
182
- headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
186
+ headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
183
187
  };
184
188
 
185
- if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
189
+ if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
186
190
  fetchOpts.body = JSON.stringify(opts.body);
187
191
  }
188
192
 
189
- const resp = await globalThis.fetch(url, fetchOpts);
193
+ // Read the stream once — `.json()` consumes it, so a `.text()` fallback
194
+ // would throw "Body is unusable" on any non-JSON response. Transport
195
+ // failure/timeout → synthetic 502 so the dep layer names it. See server-routes.js.
196
+ let status, contentType, text;
197
+ try {
198
+ const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
199
+ status = resp.status;
200
+ contentType = resp.headers.get('content-type') || '';
201
+ text = await resp.text();
202
+ } catch (err) {
203
+ const reason = (err.cause && err.cause.message) || err.message;
204
+ return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
205
+ }
190
206
  let body;
191
- try { body = await resp.json(); } catch { body = await resp.text(); }
192
- return { status: resp.status, body };
207
+ try { body = JSON.parse(text); } catch { body = text; }
208
+ return { status, body, contentType };
193
209
  }
194
210
 
211
+ async function apiFetch(path, opts = {}) {
212
+ return await fetchAs(authHeader, path, opts);
213
+ }
214
+
215
+ // Cross-app request() hits the target's /view/_/ dispatch, which accepts only
216
+ // the token/session strategies — not basic auth. In API-key mode the
217
+ // INFORMER_API_KEY Bearer is reused; under basic auth INFORMER_APP_TOKEN is
218
+ // required. appFetch stamps x-informer-app-depth:1 for the one-hop guard.
219
+ // See server-routes.js.
220
+ const appAuth = appToken
221
+ ? `Bearer ${appToken}`
222
+ : (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
223
+ const appFetch = appAuth
224
+ ? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
225
+ : null;
226
+
195
227
  // emit() — no-op in dev mode (logs to console)
196
228
  function emit(event, payload) {
197
229
  console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
@@ -287,7 +319,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
287
319
  // `context.<slot>.<method>(...)` and `env` work locally and match
288
320
  // the prod sandbox bag.
289
321
  const deps = await loadDependencies(projectRoot);
290
- const context = buildDevContext({ deps, apiFetch });
322
+ const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
291
323
  const env = await loadAppEnv(projectRoot);
292
324
 
293
325
  // Load tool handlers via ssrLoadModule
@@ -0,0 +1,96 @@
1
+ import { readdir, stat, access } from 'node:fs/promises';
2
+ import { join, relative, posix } from 'node:path';
3
+
4
+ /**
5
+ * The canonical set of files that make up a deployable Informer app library.
6
+ *
7
+ * This is the SINGLE SOURCE OF TRUTH for what an app "is" on disk, shared by
8
+ * both `informer-deploy` (uploads each file to a live instance) and
9
+ * `informer-publish` (tars the same set for the marketplace). Keeping the
10
+ * enumeration in one place is what guarantees a published artifact is
11
+ * byte-identical to what a normal deploy produces.
12
+ *
13
+ * Layout rule: the Vite build output (`dist/`) lands at the library ROOT
14
+ * (index.html, assets/…), while the source trees keep their directory prefix
15
+ * (server/…, tools/…) — matching how an app's library is structured in Informer.
16
+ */
17
+ const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
18
+ const SOURCE_DIRS = ['migrations', 'tools', 'server', 'webhooks', 'lib', 'shared'];
19
+
20
+ // Entries never worth shipping in an app's server-side library: OS/editor
21
+ // dotfiles (.DS_Store, .env), nested dependency trees, and test files. Applied
22
+ // only to the source-tree dirs (lib/, shared/, etc.) — which use generic names
23
+ // and are walked raw from the project root — not to the Vite dist/ output,
24
+ // where a dot-directory (e.g. .well-known/) can be a real asset.
25
+ const isExcludedSourceEntry = (name) =>
26
+ name === 'node_modules' || name.startsWith('.') || name.endsWith('.test.js');
27
+
28
+ async function exists(path) {
29
+ try {
30
+ await access(path);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Recursively walk a directory, returning absolute file paths (not dirs). */
38
+ async function walkDir(dir, exclude) {
39
+ const results = [];
40
+ const items = await readdir(dir);
41
+ for (const item of items) {
42
+ if (exclude && exclude(item)) continue;
43
+ const full = join(dir, item);
44
+ const s = await stat(full);
45
+ if (s.isDirectory()) {
46
+ results.push(...await walkDir(full, exclude));
47
+ } else {
48
+ results.push(full);
49
+ }
50
+ }
51
+ return results;
52
+ }
53
+
54
+ /** Normalise a path to forward slashes for use as a library/tar entry name. */
55
+ function toLibraryPath(p) {
56
+ return posix.normalize(p.split('\\').join('/'));
57
+ }
58
+
59
+ /**
60
+ * Collect every file that belongs in the app library, each as { abs, rel },
61
+ * where `rel` is the path it should occupy in the library (and in the tar).
62
+ *
63
+ * @param {{ distDir: string, projectRoot: string }} opts
64
+ * @returns {Promise<Array<{ abs: string, rel: string }>>}
65
+ */
66
+ export async function collectAppFiles({ distDir, projectRoot }) {
67
+ if (!await exists(distDir)) {
68
+ throw new Error(`Build output not found at "${distDir}" — run the build first.`);
69
+ }
70
+
71
+ const files = [];
72
+
73
+ // dist/** -> library root
74
+ for (const abs of await walkDir(distDir)) {
75
+ files.push({ abs, rel: toLibraryPath(relative(distDir, abs)) });
76
+ }
77
+
78
+ // root config files -> library root
79
+ for (const name of ROOT_CONFIG_FILES) {
80
+ const abs = join(projectRoot, name);
81
+ if (await exists(abs)) files.push({ abs, rel: name });
82
+ }
83
+
84
+ // source trees -> keep their prefix (server/…, migrations/…, lib/…, shared/…)
85
+ for (const dir of SOURCE_DIRS) {
86
+ const root = join(projectRoot, dir);
87
+ if (!await exists(root)) continue;
88
+ for (const abs of await walkDir(root, isExcludedSourceEntry)) {
89
+ files.push({ abs, rel: toLibraryPath(relative(projectRoot, abs)) });
90
+ }
91
+ }
92
+
93
+ return files;
94
+ }
95
+
96
+ export { ROOT_CONFIG_FILES, SOURCE_DIRS };
@@ -0,0 +1,39 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ /**
4
+ * Extract release notes for a version from a Keep-a-Changelog style CHANGELOG.md.
5
+ *
6
+ * Looks for the section header whose name matches `version` (e.g. `## [1.4.0]`,
7
+ * `## 1.4.0`, `## v1.4.0`); if absent, falls back to `## [Unreleased]`. Returns
8
+ * the section body trimmed, or '' if nothing matches / the file is missing.
9
+ *
10
+ * This is what makes the dual notes flow work: betas ship the cumulative
11
+ * `Unreleased` block, while a stable release promotes `Unreleased` to a
12
+ * versioned heading before tagging so its exact section is matched.
13
+ *
14
+ * @param {string} changelogPath
15
+ * @param {string} version
16
+ * @returns {Promise<string>}
17
+ */
18
+ export async function extractNotes(changelogPath, version) {
19
+ let text;
20
+ try {
21
+ text = await readFile(changelogPath, 'utf8');
22
+ } catch {
23
+ return '';
24
+ }
25
+ return sectionBody(text, version) ?? sectionBody(text, 'Unreleased') ?? '';
26
+ }
27
+
28
+ /** Body of the first `## [name]` / `## name` section, up to the next `## `. */
29
+ function sectionBody(text, name) {
30
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ const header = new RegExp(`^##\\s+\\[?v?${escaped}\\]?.*$`, 'im');
32
+ const match = header.exec(text);
33
+ if (!match) return null;
34
+
35
+ const after = text.slice(match.index + match[0].length);
36
+ const next = after.search(/^##\s/m);
37
+ const body = (next === -1 ? after : after.slice(0, next)).trim();
38
+ return body || null;
39
+ }
package/src/deploy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createClient } from './client.js';
2
- import { readdir, readFile, stat, access } from 'node:fs/promises';
3
- import { join, relative, posix, basename, dirname } from 'node:path';
2
+ import { collectAppFiles } from './assemble.js';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { basename, dirname } from 'node:path';
4
5
 
5
6
  const TEXT_EXTENSIONS = new Set([
6
7
  '.html', '.css', '.js', '.mjs', '.json', '.svg',
@@ -10,9 +11,6 @@ const TEXT_EXTENSIONS = new Set([
10
11
  // Files above this size use chunked upload via Flow.js protocol
11
12
  const CHUNK_THRESHOLD = 512 * 1024; // 512KB
12
13
 
13
- // Config files to upload from project root (if they exist)
14
- const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
15
-
16
14
  /**
17
15
  * Deploy a built Vite project to Informer as an App.
18
16
  *
@@ -106,144 +104,38 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
106
104
  console.log('Clearing existing files...');
107
105
  await api.post(`${entityPath}/files/_clear`);
108
106
 
109
- // 6. Upload dist/ contents
110
- console.log(`Uploading files from ${distDir}...`);
111
- const entries = await walkDir(distDir);
107
+ // 6. Upload the app-library file set: dist output at the library root, plus
108
+ // informer.yaml / data-access.yaml and the server/tools/migrations/webhooks
109
+ // source trees. Sourced from the shared collectAppFiles() so a deploy and a
110
+ // marketplace publish package byte-identical contents.
111
+ console.log('Uploading files...');
112
+ const files = await collectAppFiles({ distDir, projectRoot: dirname(distDir) });
112
113
 
113
- for (const filePath of entries) {
114
- const relPath = posix.normalize(relative(distDir, filePath).split('\\').join('/'));
115
- const content = await readFile(filePath);
114
+ for (const { abs, rel } of files) {
115
+ const content = await readFile(abs);
116
116
 
117
117
  if (content.length > CHUNK_THRESHOLD) {
118
118
  // Large file: chunked upload via Flow.js protocol
119
119
  await api.uploadChunked({
120
120
  entityPath,
121
- path: relPath,
121
+ path: rel,
122
122
  buffer: content,
123
- filename: basename(filePath)
123
+ filename: basename(abs)
124
124
  });
125
- console.log(` ${relPath} (${formatSize(content.length)}, chunked)`);
125
+ console.log(` ${rel} (${formatSize(content.length)}, chunked)`);
126
126
  } else {
127
127
  // Small file: direct JSON upload
128
- const ext = '.' + relPath.split('.').pop();
128
+ const ext = '.' + rel.split('.').pop();
129
129
  const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
130
130
  const payload = isText
131
131
  ? { content: content.toString('utf8'), encoding: 'utf8' }
132
132
  : { content: content.toString('base64'), encoding: 'base64' };
133
133
 
134
- await api.put(`${entityPath}/contents/${relPath}`, payload);
135
- console.log(` ${relPath}`);
136
- }
137
- }
138
-
139
- // 7. Upload config files from project root (informer.yaml, data-access.yaml)
140
- const projectRoot = dirname(distDir);
141
- let configCount = 0;
142
- for (const configFile of ROOT_CONFIG_FILES) {
143
- const configPath = join(projectRoot, configFile);
144
- try {
145
- await access(configPath);
146
- const content = await readFile(configPath, 'utf8');
147
- await api.put(`${entityPath}/contents/${configFile}`, {
148
- content,
149
- encoding: 'utf8'
150
- });
151
- console.log(` ${configFile} (from project root)`);
152
- configCount++;
153
- } catch {
154
- // File doesn't exist, skip
134
+ await api.put(`${entityPath}/contents/${rel}`, payload);
135
+ console.log(` ${rel}`);
155
136
  }
156
137
  }
157
138
 
158
- // 8. Upload migrations/ directory from project root (if it exists)
159
- const migrationsDir = join(projectRoot, 'migrations');
160
- let migrationsCount = 0;
161
- try {
162
- await access(migrationsDir);
163
- const migrationFiles = await walkDir(migrationsDir);
164
- for (const filePath of migrationFiles) {
165
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
166
- const content = await readFile(filePath, 'utf8');
167
- await api.put(`${entityPath}/contents/${relPath}`, {
168
- content,
169
- encoding: 'utf8'
170
- });
171
- console.log(` ${relPath} (from project root)`);
172
- migrationsCount++;
173
- }
174
- } catch {
175
- // No migrations directory, skip
176
- }
177
-
178
- // 9. Upload tools/ directory from project root (if it exists)
179
- const toolsDir = join(projectRoot, 'tools');
180
- let toolsCount = 0;
181
- try {
182
- await access(toolsDir);
183
- const toolFiles = await walkDir(toolsDir);
184
- for (const filePath of toolFiles) {
185
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
186
- const content = await readFile(filePath);
187
- const ext = '.' + relPath.split('.').pop();
188
- const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
189
- const payload = isText
190
- ? { content: content.toString('utf8'), encoding: 'utf8' }
191
- : { content: content.toString('base64'), encoding: 'base64' };
192
-
193
- await api.put(`${entityPath}/contents/${relPath}`, payload);
194
- console.log(` ${relPath} (from project root)`);
195
- toolsCount++;
196
- }
197
- } catch {
198
- // No tools directory, skip
199
- }
200
-
201
- // 10. Upload server/ directory from project root (if it exists)
202
- const serverDir = join(projectRoot, 'server');
203
- let serverCount = 0;
204
- try {
205
- await access(serverDir);
206
- const serverFiles = await walkDir(serverDir);
207
- for (const filePath of serverFiles) {
208
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
209
- const content = await readFile(filePath);
210
- const ext = '.' + relPath.split('.').pop();
211
- const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
212
- const payload = isText
213
- ? { content: content.toString('utf8'), encoding: 'utf8' }
214
- : { content: content.toString('base64'), encoding: 'base64' };
215
-
216
- await api.put(`${entityPath}/contents/${relPath}`, payload);
217
- console.log(` ${relPath} (from project root)`);
218
- serverCount++;
219
- }
220
- } catch {
221
- // No server directory, skip
222
- }
223
-
224
- // 11. Upload webhooks/ directory from project root (if it exists)
225
- const webhooksDir = join(projectRoot, 'webhooks');
226
- let webhooksCount = 0;
227
- try {
228
- await access(webhooksDir);
229
- const webhookFiles = await walkDir(webhooksDir);
230
- for (const filePath of webhookFiles) {
231
- const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
232
- const content = await readFile(filePath);
233
- const ext = '.' + relPath.split('.').pop();
234
- const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
235
- const payload = isText
236
- ? { content: content.toString('utf8'), encoding: 'utf8' }
237
- : { content: content.toString('base64'), encoding: 'base64' };
238
-
239
- await api.put(`${entityPath}/contents/${relPath}`, payload);
240
- console.log(` ${relPath} (from project root)`);
241
- webhooksCount++;
242
- }
243
- } catch {
244
- // No webhooks directory, skip
245
- }
246
-
247
139
  // 12. Deploy: run migrations + scan/bundle server routes + webhooks + tools + agents
248
140
  if (apiPrefix === 'apps') {
249
141
  try {
@@ -283,7 +175,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
283
175
  }
284
176
 
285
177
  // 13. Print URL and return UUID for saving
286
- const totalFiles = entries.length + configCount + migrationsCount + toolsCount + serverCount + webhooksCount;
178
+ const totalFiles = files.length;
287
179
  const base = baseUrl.replace(/\/+$/, '');
288
180
  const entityUrl = apiPrefix === 'apps'
289
181
  ? `${base}/api/apps/${naturalId}/view`
@@ -293,24 +185,6 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
293
185
  return { id: entity.id, url: entityUrl };
294
186
  }
295
187
 
296
- /**
297
- * Recursively walk a directory, returning file paths (not directories).
298
- */
299
- async function walkDir(dir) {
300
- const results = [];
301
- const items = await readdir(dir);
302
- for (const item of items) {
303
- const full = join(dir, item);
304
- const s = await stat(full);
305
- if (s.isDirectory()) {
306
- results.push(...await walkDir(full));
307
- } else {
308
- results.push(full);
309
- }
310
- }
311
- return results;
312
- }
313
-
314
188
  function formatSize(bytes) {
315
189
  if (bytes < 1024) return `${bytes} B`;
316
190
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;