@entrinsik/vite-plugin-informer 2.5.0 → 2.6.0-beta.0

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/package.json CHANGED
@@ -1,15 +1,12 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.5.0",
3
+ "version": "2.6.0-beta.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/entrinsik-org/i5.git",
8
8
  "directory": "packages/vite-plugin-informer"
9
9
  },
10
- "publishConfig": {
11
- "registry": "https://docker.entrinsik.com/repository/entNPM/"
12
- },
13
10
  "author": "Entrinsik Inc.",
14
11
  "license": "UNLICENSED",
15
12
  "type": "module",
@@ -24,10 +21,12 @@
24
21
  "default": "./src/index.js"
25
22
  },
26
23
  "./deploy": "./src/deploy.js",
24
+ "./publish": "./src/publish.js",
27
25
  "./workspace": "./src/workspace.js"
28
26
  },
29
27
  "bin": {
30
28
  "informer-deploy": "./bin/deploy.js",
29
+ "informer-publish": "./bin/publish.js",
31
30
  "informer-init": "./bin/init.js",
32
31
  "informer-workspace": "./bin/workspace.js",
33
32
  "create-magic-report": "./bin/init.js"
@@ -0,0 +1,87 @@
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'];
19
+
20
+ async function exists(path) {
21
+ try {
22
+ await access(path);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** Recursively walk a directory, returning absolute file paths (not dirs). */
30
+ async function walkDir(dir) {
31
+ const results = [];
32
+ const items = await readdir(dir);
33
+ for (const item of items) {
34
+ const full = join(dir, item);
35
+ const s = await stat(full);
36
+ if (s.isDirectory()) {
37
+ results.push(...await walkDir(full));
38
+ } else {
39
+ results.push(full);
40
+ }
41
+ }
42
+ return results;
43
+ }
44
+
45
+ /** Normalise a path to forward slashes for use as a library/tar entry name. */
46
+ function toLibraryPath(p) {
47
+ return posix.normalize(p.split('\\').join('/'));
48
+ }
49
+
50
+ /**
51
+ * Collect every file that belongs in the app library, each as { abs, rel },
52
+ * where `rel` is the path it should occupy in the library (and in the tar).
53
+ *
54
+ * @param {{ distDir: string, projectRoot: string }} opts
55
+ * @returns {Promise<Array<{ abs: string, rel: string }>>}
56
+ */
57
+ export async function collectAppFiles({ distDir, projectRoot }) {
58
+ if (!await exists(distDir)) {
59
+ throw new Error(`Build output not found at "${distDir}" — run the build first.`);
60
+ }
61
+
62
+ const files = [];
63
+
64
+ // dist/** -> library root
65
+ for (const abs of await walkDir(distDir)) {
66
+ files.push({ abs, rel: toLibraryPath(relative(distDir, abs)) });
67
+ }
68
+
69
+ // root config files -> library root
70
+ for (const name of ROOT_CONFIG_FILES) {
71
+ const abs = join(projectRoot, name);
72
+ if (await exists(abs)) files.push({ abs, rel: name });
73
+ }
74
+
75
+ // source trees -> keep their prefix (server/…, migrations/…, tools/…, webhooks/…)
76
+ for (const dir of SOURCE_DIRS) {
77
+ const root = join(projectRoot, dir);
78
+ if (!await exists(root)) continue;
79
+ for (const abs of await walkDir(root)) {
80
+ files.push({ abs, rel: toLibraryPath(relative(projectRoot, abs)) });
81
+ }
82
+ }
83
+
84
+ return files;
85
+ }
86
+
87
+ 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`;
package/src/publish.js ADDED
@@ -0,0 +1,103 @@
1
+ import { mkdtemp, mkdir, copyFile, rm, readFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, dirname } from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileP = promisify(execFile);
8
+
9
+ class PublishError extends Error {
10
+ constructor(status, body, url) {
11
+ super(`Marketplace publish failed: ${status}${body ? ` — ${body}` : ''}${url ? ` (${url})` : ''}`);
12
+ this.name = 'PublishError';
13
+ this.status = status;
14
+ this.body = body;
15
+ this.url = url;
16
+ }
17
+ }
18
+
19
+ export { PublishError };
20
+
21
+ /**
22
+ * Stage the assembled files into a temp dir (preserving their library-relative
23
+ * layout) and tar.gz them. System `tar` keeps the archive format exactly what
24
+ * the server's node-tar expects; COPYFILE_DISABLE suppresses macOS AppleDouble
25
+ * (`._*`) junk from leaking into the archive.
26
+ *
27
+ * @param {Array<{abs: string, rel: string}>} files
28
+ * @returns {Promise<Buffer>}
29
+ */
30
+ async function buildArchive(files) {
31
+ const stage = await mkdtemp(join(tmpdir(), 'informer-publish-'));
32
+ const archivePath = `${stage}.tgz`;
33
+ try {
34
+ for (const { abs, rel } of files) {
35
+ const dest = join(stage, rel);
36
+ await mkdir(dirname(dest), { recursive: true });
37
+ await copyFile(abs, dest);
38
+ }
39
+ await execFileP('tar', ['-czf', archivePath, '-C', stage, '.'], {
40
+ env: { ...process.env, COPYFILE_DISABLE: '1' }
41
+ });
42
+ return await readFile(archivePath);
43
+ } finally {
44
+ await rm(stage, { recursive: true, force: true });
45
+ await rm(archivePath, { force: true });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Publish an assembled app to the marketplace (License Manager `/packs/publish`).
51
+ *
52
+ * @param {Object} opts
53
+ * @param {string} opts.marketplaceUrl - base URL fronting the cloud-api
54
+ * @param {string} opts.token - vendor publish token (sent as Bearer)
55
+ * @param {Array<{abs: string, rel: string}>} opts.files - assembled app files
56
+ * @param {Object} opts.fields - publish metadata; non-string values are JSON-encoded
57
+ * (the endpoint parses categories/requires/metadata as JSON strings)
58
+ * @param {{abs: string, filename: string}} [opts.icon] - optional listing icon
59
+ * @returns {Promise<Object>} the publish response
60
+ */
61
+ export async function publish({ marketplaceUrl, token, files, fields, icon, screenshots = [] }) {
62
+ const archive = await buildArchive(files);
63
+
64
+ const form = new FormData();
65
+ for (const [key, value] of Object.entries(fields)) {
66
+ if (value === undefined || value === null || value === '') continue;
67
+ form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
68
+ }
69
+ form.append(
70
+ 'archive',
71
+ new Blob([archive], { type: 'application/gzip' }),
72
+ `${fields.slug}-${fields.version}.tgz`
73
+ );
74
+ if (icon) {
75
+ const iconBuffer = await readFile(icon.abs);
76
+ form.append('icon', new Blob([iconBuffer]), icon.filename);
77
+ }
78
+ // Listing screenshots — appended in order; the server records sortOrder by
79
+ // the order parts arrive.
80
+ for (const shot of screenshots) {
81
+ const buffer = await readFile(shot.abs);
82
+ form.append('screenshots', new Blob([buffer]), shot.filename);
83
+ }
84
+
85
+ const url = `${marketplaceUrl.replace(/\/+$/, '')}/packs/publish`;
86
+ const res = await fetch(url, {
87
+ method: 'POST',
88
+ headers: { Authorization: `Bearer ${token}` },
89
+ body: form
90
+ });
91
+
92
+ if (!res.ok) {
93
+ let body = '';
94
+ try {
95
+ body = await res.text();
96
+ } catch {
97
+ body = '';
98
+ }
99
+ throw new PublishError(res.status, body, url);
100
+ }
101
+
102
+ return await res.json();
103
+ }