@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 +147 -0
- package/package.json +3 -4
- package/src/assemble.js +87 -0
- package/src/changelog.js +39 -0
- package/src/deploy.js +18 -144
- package/src/publish.js +103 -0
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.
|
|
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"
|
package/src/assemble.js
ADDED
|
@@ -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 };
|
package/src/changelog.js
ADDED
|
@@ -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 {
|
|
3
|
-
import {
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
114
|
-
const
|
|
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:
|
|
121
|
+
path: rel,
|
|
122
122
|
buffer: content,
|
|
123
|
-
filename: basename(
|
|
123
|
+
filename: basename(abs)
|
|
124
124
|
});
|
|
125
|
-
console.log(` ${
|
|
125
|
+
console.log(` ${rel} (${formatSize(content.length)}, chunked)`);
|
|
126
126
|
} else {
|
|
127
127
|
// Small file: direct JSON upload
|
|
128
|
-
const ext = '.' +
|
|
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/${
|
|
135
|
-
console.log(` ${
|
|
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 =
|
|
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
|
+
}
|