@entrinsik/vite-plugin-informer 2.6.0-beta.2 → 2.7.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/ci.js +157 -0
- package/bin/publish.js +32 -103
- package/package.json +2 -1
- package/src/assemble.js +14 -3
- package/src/github-oidc.js +58 -0
- package/src/marketplaces.js +61 -0
- package/src/openapi-local.js +283 -0
- package/src/publish-payload.js +125 -0
- package/src/publish.js +8 -4
- package/src/server-routes.js +18 -4
package/bin/ci.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* informer-ci — publish one build to every configured marketplace, using
|
|
5
|
+
* GitHub Actions OIDC instead of a stored publish key.
|
|
6
|
+
*
|
|
7
|
+
* The workflow step is deliberately inert:
|
|
8
|
+
*
|
|
9
|
+
* permissions:
|
|
10
|
+
* id-token: write
|
|
11
|
+
* ...
|
|
12
|
+
* - run: npx informer-ci
|
|
13
|
+
* env:
|
|
14
|
+
* INFORMER_MARKETPLACES: ${{ vars.INFORMER_MARKETPLACES }}
|
|
15
|
+
*
|
|
16
|
+
* Everything interesting lives here rather than in YAML, so fixing it is an
|
|
17
|
+
* npm release rather than a pull request into every customer's repo.
|
|
18
|
+
*
|
|
19
|
+
* For a single marketplace with a `lmpub_` key — locally, or on CI that is not
|
|
20
|
+
* GitHub Actions — use `informer-publish` instead. This command needs the OIDC
|
|
21
|
+
* endpoint only GitHub's runner provides.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { appendFile } from 'node:fs/promises';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import { publish } from '../src/publish.js';
|
|
27
|
+
import { preparePublish, tagVersion } from '../src/publish-payload.js';
|
|
28
|
+
import { parseMarketplaces, normalizeMarketplaceUrl } from '../src/marketplaces.js';
|
|
29
|
+
import { canMintIdToken, mintIdToken } from '../src/github-oidc.js';
|
|
30
|
+
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
31
|
+
|
|
32
|
+
const mode = parseModeArg(process.argv);
|
|
33
|
+
loadEnv({ mode });
|
|
34
|
+
|
|
35
|
+
const projectRoot = resolve('.');
|
|
36
|
+
|
|
37
|
+
// --- Targets: explicit flags replace the configured list entirely, so a
|
|
38
|
+
// workflow_dispatch can re-publish to a subset without editing config. ---
|
|
39
|
+
let targets;
|
|
40
|
+
try {
|
|
41
|
+
const explicit = argValues('--marketplace').map(normalizeMarketplaceUrl);
|
|
42
|
+
targets = explicit.length ? [...new Set(explicit)] : parseMarketplaces(process.env.INFORMER_MARKETPLACES);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
fail(err.message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!targets.length) {
|
|
48
|
+
fail(
|
|
49
|
+
'No marketplaces configured. Set the INFORMER_MARKETPLACES repo variable to one cloud-api URL per line, e.g.\n' +
|
|
50
|
+
' https://lm.example.com/cloud-api\n' +
|
|
51
|
+
' or pass --marketplace <url>.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!canMintIdToken()) {
|
|
56
|
+
fail(
|
|
57
|
+
'informer-ci needs GitHub Actions OIDC, which is not available here.\n' +
|
|
58
|
+
' In a workflow, give the job `permissions: id-token: write`.\n' +
|
|
59
|
+
' Outside GitHub Actions, publish with a key instead: informer-publish.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Build the payload ONCE. Every marketplace gets identical bytes. ---
|
|
64
|
+
let payload;
|
|
65
|
+
try {
|
|
66
|
+
payload = await preparePublish({
|
|
67
|
+
projectRoot,
|
|
68
|
+
version: argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME)
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
fail(err.message);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const w of payload.warnings || []) console.warn(` warning: ${w}`);
|
|
75
|
+
|
|
76
|
+
const { name, slug, version, channel, files, icon, screenshots } = payload;
|
|
77
|
+
console.log(
|
|
78
|
+
`Publishing ${name} v${version} (${channel}) to ${targets.length} marketplace${targets.length === 1 ? '' : 's'} — ` +
|
|
79
|
+
`${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// --- Fan out. One target failing must not stop the others: a marketplace that
|
|
83
|
+
// is down should not withhold the release from the ones that are up. ---
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const marketplaceUrl of targets) {
|
|
86
|
+
const label = hostOf(marketplaceUrl);
|
|
87
|
+
try {
|
|
88
|
+
// Audience is the marketplace itself, so a token minted for one
|
|
89
|
+
// License Manager is not replayable at another.
|
|
90
|
+
const token = await mintIdToken(marketplaceUrl);
|
|
91
|
+
const result = await publish({ marketplaceUrl, token, files, icon, screenshots, fields: payload.fields });
|
|
92
|
+
for (const w of result.warnings || []) console.warn(` warning (${label}): ${w}`);
|
|
93
|
+
console.log(` ✓ ${label} — published ${slug} v${version} (${result.channel || channel})`);
|
|
94
|
+
results.push({ label, ok: true });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(` ✗ ${label} — ${err.message}`);
|
|
97
|
+
results.push({ label, ok: false, error: err.message });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await writeSummary(results);
|
|
102
|
+
|
|
103
|
+
const failed = results.filter(r => !r.ok);
|
|
104
|
+
if (failed.length) {
|
|
105
|
+
console.error(`\n${failed.length} of ${results.length} marketplaces failed.`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- helpers ---
|
|
110
|
+
|
|
111
|
+
function argValue(flag) {
|
|
112
|
+
const i = process.argv.indexOf(flag);
|
|
113
|
+
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every occurrence of a repeatable flag, e.g. --marketplace a --marketplace b. */
|
|
117
|
+
function argValues(flag) {
|
|
118
|
+
const out = [];
|
|
119
|
+
process.argv.forEach((arg, i) => {
|
|
120
|
+
if (arg === flag && process.argv[i + 1]) out.push(process.argv[i + 1]);
|
|
121
|
+
});
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hostOf(url) {
|
|
126
|
+
try {
|
|
127
|
+
return new URL(url).host;
|
|
128
|
+
} catch {
|
|
129
|
+
return url;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A per-target line in the job summary. Without this the Actions UI shows one
|
|
135
|
+
* opaque step for what is really N independent publishes — the observability
|
|
136
|
+
* the old matrix-of-jobs gave up when fan-out moved in here.
|
|
137
|
+
*/
|
|
138
|
+
async function writeSummary(rows) {
|
|
139
|
+
const path = process.env.GITHUB_STEP_SUMMARY;
|
|
140
|
+
if (!path || !rows.length) return;
|
|
141
|
+
const body = [
|
|
142
|
+
`### ${name} v${version} (${channel})`,
|
|
143
|
+
'',
|
|
144
|
+
'| Marketplace | Result |',
|
|
145
|
+
'| --- | --- |',
|
|
146
|
+
...rows.map(r => `| ${r.label} | ${r.ok ? 'published' : `failed — ${r.error.replace(/\|/g, '\\|')}`} |`),
|
|
147
|
+
''
|
|
148
|
+
].join('\n');
|
|
149
|
+
await appendFile(path, body).catch(() => {
|
|
150
|
+
// A summary is a nicety; never fail a good publish over it.
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fail(message) {
|
|
155
|
+
console.error(message);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
package/bin/publish.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* informer-publish — publish this app to ONE marketplace using a vendor
|
|
5
|
+
* publish key. Works anywhere: locally, GitHub Actions, or any other CI.
|
|
6
|
+
*
|
|
7
|
+
* For fanning one build out to several marketplaces from GitHub Actions
|
|
8
|
+
* without storing a key, see `informer-ci` (OIDC trusted publishing).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve } from 'node:path';
|
|
7
12
|
import { publish } from '../src/publish.js';
|
|
13
|
+
import { preparePublish, tagVersion } from '../src/publish-payload.js';
|
|
8
14
|
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
9
15
|
|
|
10
16
|
const mode = parseModeArg(process.argv);
|
|
@@ -19,80 +25,44 @@ if (!marketplaceUrl || !token) {
|
|
|
19
25
|
console.error('Missing required environment variables.');
|
|
20
26
|
console.error(' INFORMER_MARKETPLACE_URL=https://<license-manager-host>/cloud-api');
|
|
21
27
|
console.error(' INFORMER_PUBLISH_TOKEN=lmpub_<vendor publish key>');
|
|
28
|
+
if (process.env.INFORMER_MARKETPLACES) {
|
|
29
|
+
console.error('\nINFORMER_MARKETPLACES is set — for a multi-marketplace CI publish, run `informer-ci` instead.');
|
|
30
|
+
}
|
|
22
31
|
process.exit(1);
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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);
|
|
34
|
+
// A repo mid-migration to trusted publishing can have both configured. Publish
|
|
35
|
+
// what it was asked to publish, but say that only one target is being used.
|
|
36
|
+
if (process.env.INFORMER_MARKETPLACES && process.env.GITHUB_ACTIONS) {
|
|
37
|
+
console.warn('Note: INFORMER_MARKETPLACES is set but ignored here — `informer-ci` is the multi-marketplace command.');
|
|
45
38
|
}
|
|
46
39
|
|
|
47
|
-
|
|
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;
|
|
40
|
+
const projectRoot = resolve('.');
|
|
56
41
|
|
|
57
|
-
|
|
58
|
-
let files;
|
|
42
|
+
let payload;
|
|
59
43
|
try {
|
|
60
|
-
|
|
44
|
+
payload = await preparePublish({
|
|
45
|
+
// Prefer an explicit --version, then the git tag (CI: GITHUB_REF_NAME=vX.Y.Z),
|
|
46
|
+
// then package.json.
|
|
47
|
+
version: argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME),
|
|
48
|
+
projectRoot
|
|
49
|
+
});
|
|
61
50
|
} catch (err) {
|
|
62
51
|
console.error(err.message);
|
|
63
52
|
process.exit(1);
|
|
64
53
|
}
|
|
65
54
|
|
|
66
|
-
const
|
|
67
|
-
const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
|
|
68
|
-
const channel = version.includes('-') ? 'beta' : 'stable';
|
|
55
|
+
for (const w of payload.warnings || []) console.warn(` warning: ${w}`);
|
|
69
56
|
|
|
70
|
-
|
|
57
|
+
const { name, slug, version, channel, files, icon, screenshots, fields } = payload;
|
|
58
|
+
|
|
59
|
+
console.log(`Publishing ${name} v${version} (${channel}) — ${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}${fields.changeNotes ? '' : ' — no release notes found'}`);
|
|
71
60
|
|
|
72
61
|
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
|
-
});
|
|
62
|
+
const result = await publish({ marketplaceUrl, token, files, icon, screenshots, fields });
|
|
95
63
|
console.log(`Published ${slug} v${version} (${result.channel || channel}).`);
|
|
64
|
+
// Server-side advisories (e.g. "public API changed without a major bump").
|
|
65
|
+
for (const w of result.warnings || []) console.warn(` warning: ${w}`);
|
|
96
66
|
} catch (err) {
|
|
97
67
|
console.error(err.message);
|
|
98
68
|
process.exit(1);
|
|
@@ -104,44 +74,3 @@ function argValue(flag) {
|
|
|
104
74
|
const i = process.argv.indexOf(flag);
|
|
105
75
|
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
|
|
106
76
|
}
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0-beta.0",
|
|
4
4
|
"description": "Vite plugin and deploy tool for Informer App development",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"bin": {
|
|
31
31
|
"informer-deploy": "./bin/deploy.js",
|
|
32
32
|
"informer-publish": "./bin/publish.js",
|
|
33
|
+
"informer-ci": "./bin/ci.js",
|
|
33
34
|
"informer-init": "./bin/init.js",
|
|
34
35
|
"informer-workspace": "./bin/workspace.js",
|
|
35
36
|
"create-magic-report": "./bin/init.js"
|
package/src/assemble.js
CHANGED
|
@@ -14,7 +14,14 @@ import { join, relative, posix } from 'node:path';
|
|
|
14
14
|
* (index.html, assets/…), while the source trees keep their directory prefix
|
|
15
15
|
* (server/…, tools/…) — matching how an app's library is structured in Informer.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
// API.md is the author-written integration guide: deployed to the library root
|
|
18
|
+
// (the live openapi.json route folds it into info.description) and tarred into
|
|
19
|
+
// the published archive for the marketplace listing's Integrate surface.
|
|
20
|
+
// README.md is the docs fallback the gallery/app-menu Documentation surfaces
|
|
21
|
+
// already honor server-side (docs.html is preferred — ship that via public/ so
|
|
22
|
+
// the Vite build lands it at the library root). Without README.md here, a
|
|
23
|
+
// CLI-deployed app could never light those surfaces up via its README.
|
|
24
|
+
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml', 'API.md', 'README.md'];
|
|
18
25
|
const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'lib', 'shared'];
|
|
19
26
|
|
|
20
27
|
// Entries never worth shipping in an app's server-side library: OS/editor
|
|
@@ -29,8 +36,12 @@ async function exists(path) {
|
|
|
29
36
|
try {
|
|
30
37
|
await access(path);
|
|
31
38
|
return true;
|
|
32
|
-
} catch {
|
|
33
|
-
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Only genuine absence is a quiet skip. Anything else (EACCES, EIO)
|
|
41
|
+
// means the file is there but unreadable — silently dropping it would
|
|
42
|
+
// publish an incomplete archive with no signal, so fail the build.
|
|
43
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
44
|
+
throw new Error(`Cannot read "${path}" (${err.code}) — fix permissions or remove the file.`);
|
|
34
45
|
}
|
|
35
46
|
}
|
|
36
47
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions OIDC — "trusted publishing".
|
|
3
|
+
*
|
|
4
|
+
* The runner exposes a token-minting endpoint to jobs that declare
|
|
5
|
+
* `permissions: id-token: write`. The token it returns asserts which repository
|
|
6
|
+
* and workflow asked for it, signed by GitHub; the License Manager verifies it
|
|
7
|
+
* and maps the `repository` claim to a vendor account. Nothing long-lived is
|
|
8
|
+
* stored in the customer's repo, which is the whole point.
|
|
9
|
+
*
|
|
10
|
+
* The audience is requested PER MARKETPLACE, so a token minted for one License
|
|
11
|
+
* Manager cannot be replayed against another.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const REQUEST_URL = 'ACTIONS_ID_TOKEN_REQUEST_URL';
|
|
15
|
+
const REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN';
|
|
16
|
+
|
|
17
|
+
/** True when the two runner-injected OIDC variables are present. */
|
|
18
|
+
export function canMintIdToken() {
|
|
19
|
+
return Boolean(process.env[REQUEST_URL] && process.env[REQUEST_TOKEN]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mint an OIDC token for one marketplace.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} audience the marketplace URL this token may be spent at
|
|
26
|
+
* @returns {Promise<string>} the JWT
|
|
27
|
+
*/
|
|
28
|
+
export async function mintIdToken(audience) {
|
|
29
|
+
const base = process.env[REQUEST_URL];
|
|
30
|
+
const requestToken = process.env[REQUEST_TOKEN];
|
|
31
|
+
|
|
32
|
+
if (!base || !requestToken) {
|
|
33
|
+
// Nearly always a missing permissions block: the variables are injected
|
|
34
|
+
// only for jobs that asked for the capability, so name the fix.
|
|
35
|
+
throw new Error(
|
|
36
|
+
'No GitHub OIDC token endpoint available. Add to the job:\n' +
|
|
37
|
+
' permissions:\n' +
|
|
38
|
+
' id-token: write\n' +
|
|
39
|
+
' (or publish with a key using `informer-publish`).'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const url = `${base}&audience=${encodeURIComponent(audience)}`;
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(url, { headers: { Authorization: `Bearer ${requestToken}` } });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Could not reach the GitHub OIDC token endpoint: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(`GitHub refused to mint an OIDC token (${res.status} ${res.statusText}).`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const body = await res.json().catch(() => ({}));
|
|
56
|
+
if (!body.value) throw new Error('GitHub OIDC token response contained no token.');
|
|
57
|
+
return body.value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list of marketplaces a CI publish fans out to.
|
|
3
|
+
*
|
|
4
|
+
* Fan-out lives in the CLI rather than the workflow, so the value never has to
|
|
5
|
+
* survive a YAML `fromJSON()` — which means it does not have to BE json. People
|
|
6
|
+
* edit this by hand in a repo-variable textarea, so accept the forms they
|
|
7
|
+
* actually type: a json array, or one URL per line, or comma-separated.
|
|
8
|
+
*
|
|
9
|
+
* Bare URLs on purpose, no labels. Nothing that writes this list (Studio, a
|
|
10
|
+
* human) knows whether an instance is dev, qa, or prod — only the person
|
|
11
|
+
* reading the hostname does.
|
|
12
|
+
*/
|
|
13
|
+
export function parseMarketplaces(raw) {
|
|
14
|
+
if (!raw) return [];
|
|
15
|
+
|
|
16
|
+
const trimmed = String(raw).trim();
|
|
17
|
+
let values;
|
|
18
|
+
|
|
19
|
+
if (trimmed.startsWith('[')) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(trimmed);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(`INFORMER_MARKETPLACES looks like JSON but does not parse: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(parsed)) throw new Error('INFORMER_MARKETPLACES must be an array of marketplace URLs.');
|
|
27
|
+
values = parsed;
|
|
28
|
+
} else {
|
|
29
|
+
values = trimmed.split(/[\s,]+/);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const urls = values.map(v => String(v).trim()).filter(Boolean).map(normalizeMarketplaceUrl);
|
|
33
|
+
|
|
34
|
+
// A duplicate would publish the same version twice to one marketplace, and
|
|
35
|
+
// the second attempt 409s — turning a working config into a red build.
|
|
36
|
+
return [...new Set(urls)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Canonical form for comparison and display.
|
|
41
|
+
*
|
|
42
|
+
* A trailing slash makes two spellings of one marketplace look like two
|
|
43
|
+
* marketplaces — which matters most to whatever is checking "am I already in
|
|
44
|
+
* this list?" before appending.
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeMarketplaceUrl(value) {
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(String(value).trim());
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`Not a valid marketplace URL: ${value}`);
|
|
52
|
+
}
|
|
53
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
54
|
+
throw new Error(`Marketplace URL must be http(s): ${value}`);
|
|
55
|
+
}
|
|
56
|
+
url.hostname = url.hostname.toLowerCase();
|
|
57
|
+
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
58
|
+
url.hash = '';
|
|
59
|
+
url.search = '';
|
|
60
|
+
return url.toString();
|
|
61
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { filePathToRoute, walkJsFiles } from './server-routes.js';
|
|
5
|
+
|
|
6
|
+
// Build the app's OpenAPI 3.0.3 document from the LOCAL project at publish
|
|
7
|
+
// time — the frozen, per-version contract the marketplace stores alongside the
|
|
8
|
+
// changelog. CI often has no access to the Informer host an app was developed
|
|
9
|
+
// against, so this cannot fetch the live /apps/{id}/openapi.json; instead the
|
|
10
|
+
// handlers are import()ed (this runs in the author's own project, where
|
|
11
|
+
// executing their code is exactly what vite does anyway) and their real
|
|
12
|
+
// exports — methods, config, schema, description — are read directly.
|
|
13
|
+
//
|
|
14
|
+
// The document-shaping half below is a deliberate port of
|
|
15
|
+
// informer-server/modules/app/lib/openapi-emitter.js (the deploy-time emitter):
|
|
16
|
+
// the frozen doc must match what a live instance serves for the same source.
|
|
17
|
+
// Keep the two in sync when either changes. This package publishes to npm on
|
|
18
|
+
// its own, so importing across the monorepo is not an option.
|
|
19
|
+
|
|
20
|
+
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
21
|
+
const HTTP_METHOD_KEYS = new Set(VALID_METHODS);
|
|
22
|
+
|
|
23
|
+
// Fallback when a handler cannot be import()ed (a missing optional dep, a
|
|
24
|
+
// top-level env access that only resolves in dev): the file still contributes
|
|
25
|
+
// skeleton routes (method + path) so the frozen doc doesn't silently lose them.
|
|
26
|
+
const METHOD_EXPORT_RE = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b|export\s+(?:const|let|var)\s+(GET|POST|PUT|PATCH|DELETE)\b/g;
|
|
27
|
+
|
|
28
|
+
function routePathToOpenApi(path) {
|
|
29
|
+
return path.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pathParamNames(path) {
|
|
33
|
+
const names = new Set();
|
|
34
|
+
const re = /:([A-Za-z0-9_]+)/g;
|
|
35
|
+
let m;
|
|
36
|
+
while ((m = re.exec(path)) !== null) names.add(m[1]);
|
|
37
|
+
return [...names];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function operationId(method, path) {
|
|
41
|
+
const slug = path.split('/').filter(Boolean)
|
|
42
|
+
.map(seg => (seg.startsWith(':') ? `by_${seg.slice(1)}` : seg))
|
|
43
|
+
.join('_') || 'root';
|
|
44
|
+
return `${method.toLowerCase()}_${slug}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// OpenAPI 3.0.3 Schema Object keywords, by required value type (port of the
|
|
48
|
+
// server emitter's sanitizeSchema — see file header).
|
|
49
|
+
const SCHEMA_STRING_KEYS = new Set(['type', 'format', 'title', 'description', 'pattern']);
|
|
50
|
+
const SCHEMA_NUMBER_KEYS = new Set(['multipleOf', 'maximum', 'minimum', 'maxLength', 'minLength', 'maxItems', 'minItems', 'maxProperties', 'minProperties']);
|
|
51
|
+
const SCHEMA_BOOLEAN_KEYS = new Set(['exclusiveMaximum', 'exclusiveMinimum', 'uniqueItems', 'nullable', 'readOnly', 'writeOnly', 'deprecated']);
|
|
52
|
+
const SCHEMA_ANY_KEYS = new Set(['default', 'example']);
|
|
53
|
+
const SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object']);
|
|
54
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
55
|
+
|
|
56
|
+
function sanitizeSchema(schema, depth = 0) {
|
|
57
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null;
|
|
58
|
+
if (depth >= MAX_SCHEMA_DEPTH) return null;
|
|
59
|
+
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
62
|
+
if (SCHEMA_STRING_KEYS.has(key)) {
|
|
63
|
+
if (typeof value !== 'string') continue;
|
|
64
|
+
if (key === 'type' && !SCHEMA_TYPES.has(value)) continue;
|
|
65
|
+
out[key] = value;
|
|
66
|
+
} else if (SCHEMA_NUMBER_KEYS.has(key)) {
|
|
67
|
+
if (typeof value === 'number') out[key] = value;
|
|
68
|
+
} else if (SCHEMA_BOOLEAN_KEYS.has(key)) {
|
|
69
|
+
if (typeof value === 'boolean') out[key] = value;
|
|
70
|
+
} else if (SCHEMA_ANY_KEYS.has(key)) {
|
|
71
|
+
out[key] = value;
|
|
72
|
+
} else if (key === 'enum') {
|
|
73
|
+
if (Array.isArray(value) && value.length) out.enum = value;
|
|
74
|
+
} else if (key === 'required') {
|
|
75
|
+
const names = Array.isArray(value) ? value.filter(v => typeof v === 'string') : [];
|
|
76
|
+
if (names.length) out.required = names;
|
|
77
|
+
} else if (key === 'properties') {
|
|
78
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
79
|
+
const props = {};
|
|
80
|
+
for (const [name, sub] of Object.entries(value)) {
|
|
81
|
+
const clean = sanitizeSchema(sub, depth + 1);
|
|
82
|
+
props[name] = clean || {};
|
|
83
|
+
}
|
|
84
|
+
out.properties = props;
|
|
85
|
+
} else if (key === 'items' || key === 'not') {
|
|
86
|
+
const clean = sanitizeSchema(value, depth + 1);
|
|
87
|
+
if (clean) out[key] = clean;
|
|
88
|
+
} else if (key === 'allOf' || key === 'anyOf' || key === 'oneOf') {
|
|
89
|
+
const clean = (Array.isArray(value) ? value : []).map(v => sanitizeSchema(v, depth + 1)).filter(Boolean);
|
|
90
|
+
if (clean.length) out[key] = clean;
|
|
91
|
+
} else if (key === 'additionalProperties') {
|
|
92
|
+
if (typeof value === 'boolean') out.additionalProperties = value;
|
|
93
|
+
else {
|
|
94
|
+
const clean = sanitizeSchema(value, depth + 1);
|
|
95
|
+
if (clean) out.additionalProperties = clean;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// anything else (including $ref) is dropped
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (out.type === 'array' && !out.items) out.items = {};
|
|
102
|
+
|
|
103
|
+
return Object.keys(out).length ? out : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A method-keyed `export const schema` returns its per-method slice; a flat
|
|
107
|
+
// schema applies to every method (mirror of the server scanner's sliceSchema).
|
|
108
|
+
function sliceSchema(schema, method) {
|
|
109
|
+
if (!schema || typeof schema !== 'object') return null;
|
|
110
|
+
const methodKeyed = Object.keys(schema).some(k => HTTP_METHOD_KEYS.has(k));
|
|
111
|
+
if (!methodKeyed) return schema;
|
|
112
|
+
return schema[method] || null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildOperation(row) {
|
|
116
|
+
const cfg = row.config || {};
|
|
117
|
+
const s = (row.schema && typeof row.schema === 'object') ? row.schema : {};
|
|
118
|
+
const successDesc = cfg.responseDescription ? String(cfg.responseDescription) : 'Success';
|
|
119
|
+
|
|
120
|
+
const op = {
|
|
121
|
+
operationId: operationId(row.method, row.path),
|
|
122
|
+
responses: {}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (cfg.summary) op.summary = String(cfg.summary);
|
|
126
|
+
const desc = cfg.description || row.description;
|
|
127
|
+
if (desc) op.description = String(desc);
|
|
128
|
+
if (cfg.deprecated) op.deprecated = true;
|
|
129
|
+
if (Array.isArray(cfg.tags) && cfg.tags.length) op.tags = cfg.tags.map(String);
|
|
130
|
+
|
|
131
|
+
const params = pathParamNames(row.path).map(name => ({
|
|
132
|
+
name,
|
|
133
|
+
in: 'path',
|
|
134
|
+
required: true,
|
|
135
|
+
schema: { type: 'string' }
|
|
136
|
+
}));
|
|
137
|
+
if (s.query && s.query.properties && typeof s.query.properties === 'object') {
|
|
138
|
+
const required = new Set(Array.isArray(s.query.required) ? s.query.required : []);
|
|
139
|
+
for (const [name, propSchema] of Object.entries(s.query.properties)) {
|
|
140
|
+
params.push({
|
|
141
|
+
name,
|
|
142
|
+
in: 'query',
|
|
143
|
+
required: required.has(name),
|
|
144
|
+
schema: sanitizeSchema(propSchema) || {}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (params.length) op.parameters = params;
|
|
149
|
+
|
|
150
|
+
const body = sanitizeSchema(s.body);
|
|
151
|
+
if (body) {
|
|
152
|
+
op.requestBody = { content: { 'application/json': { schema: body } } };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const response = sanitizeSchema(s.response);
|
|
156
|
+
if (response) {
|
|
157
|
+
op.responses['200'] = {
|
|
158
|
+
description: successDesc,
|
|
159
|
+
content: { 'application/json': { schema: response } }
|
|
160
|
+
};
|
|
161
|
+
} else {
|
|
162
|
+
op.responses['200'] = { description: successDesc };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (Array.isArray(cfg.roles) && cfg.roles.length) {
|
|
166
|
+
op['x-informer-roles'] = cfg.roles.map(String);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Curated public surface — see the server emitter for semantics.
|
|
170
|
+
if (cfg.api === 'public') {
|
|
171
|
+
op['x-informer-public'] = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return op;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Import every server/ handler and read its real exports into emitter rows.
|
|
179
|
+
* Returns { rows, warnings }: a file that fails to import degrades to skeleton
|
|
180
|
+
* routes (regex-detected methods) with a warning, never a lost route.
|
|
181
|
+
*/
|
|
182
|
+
async function collectLocalRoutes(projectRoot) {
|
|
183
|
+
const files = await walkJsFiles(join(projectRoot, 'server'), 'server');
|
|
184
|
+
const rows = [];
|
|
185
|
+
const warnings = [];
|
|
186
|
+
|
|
187
|
+
for (const { relPath, absPath } of files) {
|
|
188
|
+
const routePath = filePathToRoute(relPath);
|
|
189
|
+
let mod = null;
|
|
190
|
+
try {
|
|
191
|
+
mod = await import(pathToFileURL(absPath).href);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
warnings.push(`${relPath}: could not be imported (${err.message}) — frozen doc keeps its routes at skeleton level`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (mod) {
|
|
197
|
+
for (const method of VALID_METHODS) {
|
|
198
|
+
if (typeof mod[method] !== 'function') continue;
|
|
199
|
+
rows.push({
|
|
200
|
+
method,
|
|
201
|
+
path: routePath,
|
|
202
|
+
handlerPath: relPath,
|
|
203
|
+
config: (mod.config && typeof mod.config === 'object') ? mod.config : {},
|
|
204
|
+
description: typeof mod.description === 'string' ? mod.description : null,
|
|
205
|
+
schema: sliceSchema((mod.schema && typeof mod.schema === 'object') ? mod.schema : null, method)
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const source = await readFile(absPath, 'utf8');
|
|
210
|
+
const methods = new Set();
|
|
211
|
+
let m;
|
|
212
|
+
while ((m = METHOD_EXPORT_RE.exec(source)) !== null) methods.add(m[1] || m[2]);
|
|
213
|
+
for (const method of methods) {
|
|
214
|
+
rows.push({ method, path: routePath, handlerPath: relPath, config: {}, description: null, schema: null });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Deterministic doc: same ordering the live endpoint uses.
|
|
220
|
+
rows.sort((a, b) => (a.path === b.path ? a.method.localeCompare(b.method) : a.path.localeCompare(b.path)));
|
|
221
|
+
return { rows, warnings };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build the frozen OpenAPI document for a publish.
|
|
226
|
+
*
|
|
227
|
+
* The installing tenant's naturalId ({owner}:{slug}) is unknowable at publish
|
|
228
|
+
* time, so the dispatch base is emitted as an OpenAPI server variable with the
|
|
229
|
+
* slug as its default.
|
|
230
|
+
*
|
|
231
|
+
* @param {Object} opts
|
|
232
|
+
* @param {string} opts.projectRoot
|
|
233
|
+
* @param {string} opts.name - listing name (info.title)
|
|
234
|
+
* @param {string} opts.slug - marketplace slug
|
|
235
|
+
* @param {string} opts.version - the version being published (info.version)
|
|
236
|
+
* @returns {Promise<{ spec: Object|null, warnings: string[] }>} spec is null
|
|
237
|
+
* when the app has no server/ routes at all (nothing to freeze).
|
|
238
|
+
*/
|
|
239
|
+
export async function buildLocalOpenApi({ projectRoot, name, slug, version }) {
|
|
240
|
+
const { rows, warnings } = await collectLocalRoutes(projectRoot);
|
|
241
|
+
if (!rows.length) return { spec: null, warnings };
|
|
242
|
+
|
|
243
|
+
const paths = {};
|
|
244
|
+
const usedIds = new Set();
|
|
245
|
+
for (const row of rows) {
|
|
246
|
+
const oaPath = routePathToOpenApi(row.path);
|
|
247
|
+
paths[oaPath] = paths[oaPath] || {};
|
|
248
|
+
const op = buildOperation(row);
|
|
249
|
+
while (usedIds.has(op.operationId)) {
|
|
250
|
+
const m = op.operationId.match(/^(.*?)(?:_(\d+))?$/);
|
|
251
|
+
op.operationId = `${m[1]}_${(parseInt(m[2], 10) || 1) + 1}`;
|
|
252
|
+
}
|
|
253
|
+
usedIds.add(op.operationId);
|
|
254
|
+
paths[oaPath][row.method.toLowerCase()] = op;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let description;
|
|
258
|
+
try {
|
|
259
|
+
description = await readFile(join(projectRoot, 'API.md'), 'utf8');
|
|
260
|
+
} catch {
|
|
261
|
+
// no API.md — fall through to the generated blurb
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const spec = {
|
|
265
|
+
openapi: '3.0.3',
|
|
266
|
+
info: {
|
|
267
|
+
title: name || slug,
|
|
268
|
+
version: String(version),
|
|
269
|
+
description: description || (
|
|
270
|
+
`Auto-generated from the server/ routes of "${name || slug}". ` +
|
|
271
|
+
'Invoke a route through a cross-app dependency: context.<slot>.request({ method, url }).'
|
|
272
|
+
)
|
|
273
|
+
},
|
|
274
|
+
servers: [{
|
|
275
|
+
url: '/api/apps/{app}/view/_',
|
|
276
|
+
description: 'App server-route dispatch base ({app} is the installed naturalId, owner:name)',
|
|
277
|
+
variables: { app: { default: slug } }
|
|
278
|
+
}],
|
|
279
|
+
paths
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return { spec, warnings };
|
|
283
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFile, access, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { collectAppFiles } from './assemble.js';
|
|
4
|
+
import { extractNotes } from './changelog.js';
|
|
5
|
+
import { buildLocalOpenApi } from './openapi-local.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything a publish needs, assembled once.
|
|
9
|
+
*
|
|
10
|
+
* Split out of bin/publish.js so `informer-ci` can build the payload a single
|
|
11
|
+
* time and hand the SAME files to several marketplaces — a version has to be
|
|
12
|
+
* byte-identical everywhere it lands, and re-assembling per target would only
|
|
13
|
+
* create opportunities for it not to be.
|
|
14
|
+
*
|
|
15
|
+
* Throws on anything that should stop a publish; the bins print and exit.
|
|
16
|
+
*/
|
|
17
|
+
export async function preparePublish({ projectRoot = resolve('.'), version: versionOverride } = {}) {
|
|
18
|
+
let pkg;
|
|
19
|
+
try {
|
|
20
|
+
pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error('Could not read package.json in current directory.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const inf = pkg.informer || {};
|
|
26
|
+
const name = inf.name || pkg.name;
|
|
27
|
+
const slug = inf.slug || defaultSlug(name);
|
|
28
|
+
const version = versionOverride || pkg.version;
|
|
29
|
+
|
|
30
|
+
if (!name || !slug || !version) {
|
|
31
|
+
throw new Error('Need a name, slug, and version. Set package.json "informer.name"/"informer.slug" and tag a release (vX.Y.Z) or pass --version.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Release notes: the section for this version, else the cumulative Unreleased block.
|
|
35
|
+
const changeNotes = await extractNotes(join(projectRoot, 'CHANGELOG.md'), version);
|
|
36
|
+
|
|
37
|
+
// Assemble the same file set a deploy produces.
|
|
38
|
+
const files = await collectAppFiles({ distDir: join(projectRoot, 'dist'), projectRoot });
|
|
39
|
+
|
|
40
|
+
// Freeze the API contract with this version: build the OpenAPI doc from the
|
|
41
|
+
// local handlers and ship it in the archive as root openapi.json — the
|
|
42
|
+
// License Manager extracts it, stores it per version, and diffs the public
|
|
43
|
+
// surface against the previous version.
|
|
44
|
+
const { spec: apiSpec, warnings } = await buildLocalOpenApi({ projectRoot, name, slug, version });
|
|
45
|
+
if (apiSpec) files.push({ rel: 'openapi.json', content: JSON.stringify(apiSpec, null, 2) });
|
|
46
|
+
|
|
47
|
+
const icon = await resolveIcon(projectRoot, inf.icon);
|
|
48
|
+
const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
|
|
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
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
slug,
|
|
60
|
+
version,
|
|
61
|
+
// The server derives the channel too; this copy is for local reporting.
|
|
62
|
+
channel: version.includes('-') ? 'beta' : 'stable',
|
|
63
|
+
files,
|
|
64
|
+
icon,
|
|
65
|
+
screenshots,
|
|
66
|
+
warnings,
|
|
67
|
+
fields: {
|
|
68
|
+
name,
|
|
69
|
+
slug,
|
|
70
|
+
version,
|
|
71
|
+
shortDescription: inf.shortDescription,
|
|
72
|
+
description: inf.description || pkg.description,
|
|
73
|
+
categories: inf.categories,
|
|
74
|
+
documentationUrl: inf.documentationUrl,
|
|
75
|
+
requires: inf.requires,
|
|
76
|
+
metadata: inf.metadata,
|
|
77
|
+
changeNotes,
|
|
78
|
+
sourceRepo,
|
|
79
|
+
sourceCommit,
|
|
80
|
+
ciRun
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `vX.Y.Z` (GITHUB_REF_NAME) → `X.Y.Z`. */
|
|
86
|
+
export function tagVersion(ref) {
|
|
87
|
+
return ref ? ref.replace(/^v/, '') : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function defaultSlug(value) {
|
|
91
|
+
if (!value) return undefined;
|
|
92
|
+
const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
|
|
93
|
+
return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveIcon(root, configured) {
|
|
97
|
+
const candidates = [configured, 'favicon.svg', join('public', 'favicon.svg'), join('dist', 'favicon.svg')].filter(Boolean);
|
|
98
|
+
for (const rel of candidates) {
|
|
99
|
+
const abs = resolve(root, rel);
|
|
100
|
+
try {
|
|
101
|
+
await access(abs);
|
|
102
|
+
return { abs, filename: rel.split(/[/\\]/).pop() };
|
|
103
|
+
} catch {
|
|
104
|
+
// try next candidate
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Collect listing screenshots from a `screenshots/` directory (override via
|
|
111
|
+
// package.json informer.screenshots), ordered by filename. Returns
|
|
112
|
+
// [{abs, filename}] for the publisher to upload; empty when none exist.
|
|
113
|
+
async function resolveScreenshots(root, configuredDir) {
|
|
114
|
+
const dir = resolve(root, configuredDir || 'screenshots');
|
|
115
|
+
let names;
|
|
116
|
+
try {
|
|
117
|
+
names = await readdir(dir);
|
|
118
|
+
} catch {
|
|
119
|
+
return []; // no screenshots directory — nothing to upload
|
|
120
|
+
}
|
|
121
|
+
return names
|
|
122
|
+
.filter(n => /\.(png|jpe?g|webp|gif)$/i.test(n))
|
|
123
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
124
|
+
.map(n => ({ abs: join(dir, n), filename: n }));
|
|
125
|
+
}
|
package/src/publish.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtemp, mkdir, copyFile, rm, readFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdtemp, mkdir, copyFile, rm, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
@@ -24,17 +24,21 @@ export { PublishError };
|
|
|
24
24
|
* the server's node-tar expects; COPYFILE_DISABLE suppresses macOS AppleDouble
|
|
25
25
|
* (`._*`) junk from leaking into the archive.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* Entries carry either `abs` (copied from disk) or `content` (generated at
|
|
28
|
+
* publish time, e.g. the frozen openapi.json).
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<{abs?: string, rel: string, content?: string}>} files
|
|
28
31
|
* @returns {Promise<Buffer>}
|
|
29
32
|
*/
|
|
30
33
|
async function buildArchive(files) {
|
|
31
34
|
const stage = await mkdtemp(join(tmpdir(), 'informer-publish-'));
|
|
32
35
|
const archivePath = `${stage}.tgz`;
|
|
33
36
|
try {
|
|
34
|
-
for (const { abs, rel } of files) {
|
|
37
|
+
for (const { abs, rel, content } of files) {
|
|
35
38
|
const dest = join(stage, rel);
|
|
36
39
|
await mkdir(dirname(dest), { recursive: true });
|
|
37
|
-
await
|
|
40
|
+
if (content !== undefined) await writeFile(dest, content);
|
|
41
|
+
else await copyFile(abs, dest);
|
|
38
42
|
}
|
|
39
43
|
await execFileP('tar', ['-czf', archivePath, '-C', stage, '.'], {
|
|
40
44
|
env: { ...process.env, COPYFILE_DISABLE: '1' }
|
package/src/server-routes.js
CHANGED
|
@@ -9,9 +9,21 @@ const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
|
9
9
|
// hanging the handler.
|
|
10
10
|
const FETCH_TIMEOUT_MS = 30000;
|
|
11
11
|
|
|
12
|
-
// Strict base64 — see view-api.js for the rationale. Mirror kept identical
|
|
13
|
-
//
|
|
14
|
-
|
|
12
|
+
// Strict base64 — see view-api.js for the rationale. Mirror kept identical to
|
|
13
|
+
// keep dev and prod behavior aligned, down to the scan: the quantified
|
|
14
|
+
// /^[A-Za-z0-9+/]+={0,2}$/ throws "RangeError: Maximum call stack size
|
|
15
|
+
// exceeded" instead of answering once a body passes ~4M characters, so dev
|
|
16
|
+
// would 500 on exactly the large images and PDFs prod serves fine.
|
|
17
|
+
const NON_BASE64 = /[^A-Za-z0-9+/]/; // unquantified: never backtracks
|
|
18
|
+
|
|
19
|
+
function isBase64(s) {
|
|
20
|
+
if (typeof s !== 'string') return false;
|
|
21
|
+
let end = s.length;
|
|
22
|
+
while (end > 0 && s.charCodeAt(end - 1) === 0x3d /* '=' */) end--;
|
|
23
|
+
if (s.length - end > 2) return false;
|
|
24
|
+
if (end === 0) return true; // '', '=', '==' → zero bytes
|
|
25
|
+
return !NON_BASE64.test(end === s.length ? s : s.slice(0, end));
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
/**
|
|
17
29
|
* Convert a file path under server/ to a route path.
|
|
@@ -120,6 +132,8 @@ async function scanRoutes(serverDir) {
|
|
|
120
132
|
}));
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
export { filePathToRoute, walkJsFiles };
|
|
136
|
+
|
|
123
137
|
async function walkJsFiles(dir, basePath) {
|
|
124
138
|
const results = [];
|
|
125
139
|
let items;
|
|
@@ -398,7 +412,7 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
398
412
|
if (responseBody === null) {
|
|
399
413
|
res.end();
|
|
400
414
|
} else if (encoding === 'base64' && typeof responseBody === 'string') {
|
|
401
|
-
if (!
|
|
415
|
+
if (!isBase64(responseBody)) {
|
|
402
416
|
throw new Error('Handler returned malformed base64 body');
|
|
403
417
|
}
|
|
404
418
|
res.end(Buffer.from(responseBody, 'base64'));
|