@entrinsik/vite-plugin-informer 2.7.0-beta.0 → 2.10.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/README.md +129 -0
- package/bin/ci.js +52 -51
- package/bin/publish.js +11 -4
- package/package.json +1 -1
- package/src/agent-dev.js +26 -12
- package/src/assemble.js +5 -1
- package/src/compat.js +252 -0
- package/src/deploy.js +116 -43
- package/src/dev-channel-shim.js +150 -0
- package/src/dev-channels.js +191 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +41 -0
- package/src/dev-streams.js +660 -0
- package/src/fan-out.js +129 -0
- package/src/index.js +131 -16
- package/src/marketplaces.js +28 -2
- package/src/publish-payload.js +55 -7
- package/src/publish.js +25 -1
- package/src/server-routes.js +79 -21
- package/src/streams-client.js +200 -0
package/src/fan-out.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { normalizeMarketplaceUrl, parseMarketplaces } from './marketplaces.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The informer-ci fan-out, extracted from the bin so it can be tested. A bin
|
|
5
|
+
* script with top-level await and process.exit is unreachable to a test
|
|
6
|
+
* runner, and the contracts here are exactly the ones a refactor would break
|
|
7
|
+
* silently — see fanOut's doc comment.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which marketplaces this run publishes to. Explicit flags replace the
|
|
12
|
+
* configured list entirely — a workflow_dispatch re-publishing to one target
|
|
13
|
+
* must not fan back out to everything — and both forms are normalized and
|
|
14
|
+
* deduped the same way.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveTargets({ explicit = [], configured } = {}) {
|
|
17
|
+
const flags = explicit.map(normalizeMarketplaceUrl);
|
|
18
|
+
return flags.length ? [...new Set(flags)] : parseMarketplaces(configured);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Display label for a target: the host, or the raw value when it won't parse. */
|
|
22
|
+
export function targetLabel(url) {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(url).host;
|
|
25
|
+
} catch {
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Publish one prepared payload to every target. Returns per-target rows:
|
|
32
|
+
* `{ label, ok }` or `{ label, ok: false, error }`.
|
|
33
|
+
*
|
|
34
|
+
* Three load-bearing contracts:
|
|
35
|
+
*
|
|
36
|
+
* - One target failing must not stop the others — a marketplace that is down
|
|
37
|
+
* should not withhold the release from the ones that are up. Any failed row
|
|
38
|
+
* still fails the run afterwards (the bin exits 1 on any `ok: false`).
|
|
39
|
+
* - `mint` runs INSIDE the loop, once per target, with that target's URL as
|
|
40
|
+
* the token audience — a token minted for one License Manager must not be
|
|
41
|
+
* replayable at another. Hoisting the mint out as loop-invariant would
|
|
42
|
+
* break that property without failing a single publish against a lax
|
|
43
|
+
* verifier, which is why the test suite pins it.
|
|
44
|
+
* - Reporting cannot unland a publish. The release exists the moment
|
|
45
|
+
* `publishOne` resolves; everything after that is narration. `onSuccess`
|
|
46
|
+
* therefore gets its own try/catch, and its failure is recorded as
|
|
47
|
+
* `reportError` on a row that stays `ok: true`. Letting it reach the outer
|
|
48
|
+
* catch would print `✗ <host>` and exit 1 for a version the marketplace had
|
|
49
|
+
* already stored — and the re-run would 409 "version already exists",
|
|
50
|
+
* sending someone off to debug a publish that succeeded.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} targets - normalized marketplace URLs
|
|
53
|
+
* @param {(url: string) => Promise<string>} mint - audience-scoped token minter
|
|
54
|
+
* @param {(opts: { marketplaceUrl: string, token: string }) => Promise<Object>} publishOne
|
|
55
|
+
* @param {(label: string, result: Object) => void|Promise<void>} [onSuccess]
|
|
56
|
+
* @param {(label: string, message: string) => void} [onFailure]
|
|
57
|
+
*/
|
|
58
|
+
export async function fanOut({ targets, mint, publishOne, onSuccess = () => {}, onFailure = () => {} }) {
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const marketplaceUrl of targets) {
|
|
61
|
+
const label = targetLabel(marketplaceUrl);
|
|
62
|
+
try {
|
|
63
|
+
const token = await mint(marketplaceUrl);
|
|
64
|
+
const result = await publishOne({ marketplaceUrl, token });
|
|
65
|
+
// Past this line the publish has landed, so no path may produce an
|
|
66
|
+
// `ok: false` row for this target. Awaited so an async reporter's
|
|
67
|
+
// rejection lands here too rather than escaping unhandled.
|
|
68
|
+
let reportError;
|
|
69
|
+
try {
|
|
70
|
+
await onSuccess(label, result);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
reportError = String((err && err.message) ?? err);
|
|
73
|
+
}
|
|
74
|
+
results.push(reportError ? { label, ok: true, reportError } : { label, ok: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// String() rather than err.message: a non-Error thrown per-target
|
|
77
|
+
// must land in the results row, not crash the summary writer
|
|
78
|
+
// after the fact and mask the real publish failure.
|
|
79
|
+
const message = String((err && err.message) ?? err);
|
|
80
|
+
onFailure(label, message);
|
|
81
|
+
results.push({ label, ok: false, error: message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The GitHub job summary table. Without it the Actions UI shows one opaque
|
|
89
|
+
* step for what is really N independent publishes.
|
|
90
|
+
*
|
|
91
|
+
* Failure text is flattened to one line and truncated: PublishError messages
|
|
92
|
+
* embed multi-line JSON response bodies, and a newline inside a Markdown
|
|
93
|
+
* table cell breaks the row on exactly the runs where the summary matters.
|
|
94
|
+
*/
|
|
95
|
+
export function summaryTable({ title, rows }) {
|
|
96
|
+
const cell = s => {
|
|
97
|
+
const flat = String(s).replace(/\s+/g, ' ').trim();
|
|
98
|
+
// The ellipsis marks the cut — a message truncated without one reads
|
|
99
|
+
// as if it were complete.
|
|
100
|
+
return (flat.length > 300 ? `${flat.slice(0, 300)}…` : flat).replace(/\|/g, '\\|');
|
|
101
|
+
};
|
|
102
|
+
return [
|
|
103
|
+
`### ${title}`,
|
|
104
|
+
'',
|
|
105
|
+
'| Marketplace | Result |',
|
|
106
|
+
'| --- | --- |',
|
|
107
|
+
...rows.map(r => `| ${r.label} | ${resultCell(r, cell)} |`),
|
|
108
|
+
''
|
|
109
|
+
].join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One row's Result cell. A reporting failure annotates a publish, never negates it. */
|
|
113
|
+
function resultCell(row, cell) {
|
|
114
|
+
if (!row.ok) return `failed — ${cell(row.error)}`;
|
|
115
|
+
return row.reportError ? `published (reporting failed — ${cell(row.reportError)})` : 'published';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether the run failed, i.e. whether the bin exits 1. This is the whole CI
|
|
120
|
+
* contract — a green run means every target has the version — so it lives here
|
|
121
|
+
* as a predicate the suite can pin rather than as an inline `.filter` in a bin
|
|
122
|
+
* that no test runner can reach.
|
|
123
|
+
*
|
|
124
|
+
* A row that published but could not be reported is NOT a failure: the release
|
|
125
|
+
* is live, and exiting 1 would invite a re-run that 409s.
|
|
126
|
+
*/
|
|
127
|
+
export function failedTargets(rows) {
|
|
128
|
+
return rows.filter(r => !r.ok);
|
|
129
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,70 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
3
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
import { createClient } from './client.js';
|
|
5
|
-
import { loadDependencies, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
|
+
import { loadDependencies, loadChannels, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
7
|
import { buildDeclarations } from './openapi-to-dts.js';
|
|
7
8
|
import { loadEnv, envWritePath } from './env.js';
|
|
8
9
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
9
10
|
import { createAgentMiddleware } from './agent-dev.js';
|
|
11
|
+
import { createDevChannels, validateChannels, BROADCAST_EVENT, DEV_CHANNEL_EVENT } from './dev-channels.js';
|
|
12
|
+
import { renderDevChannelScript } from './dev-channel-shim.js';
|
|
13
|
+
import { createStreamStore, createUploadsMiddleware, createDownloadsMiddleware } from './dev-streams.js';
|
|
14
|
+
import { streamsClientSource } from './streams-client.js';
|
|
10
15
|
import { init, migrate } from './workspace.js';
|
|
16
|
+
import { devPlatform } from './dev-platform.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The dev `window.__INFORMER__` context: defaults overlaid with the plugin's
|
|
20
|
+
* `mock` option. Shared by the page injection and the dev channels hub (whose
|
|
21
|
+
* frames carry `report.id` as the app id).
|
|
22
|
+
*/
|
|
23
|
+
function buildMock(options) {
|
|
24
|
+
return {
|
|
25
|
+
report: {
|
|
26
|
+
id: 'dev-local',
|
|
27
|
+
name: 'Local Development'
|
|
28
|
+
},
|
|
29
|
+
theme: 'light',
|
|
30
|
+
roles: [],
|
|
31
|
+
// The viewer identity the server injects (window.__INFORMER__.user);
|
|
32
|
+
// override with mock.user to test @user/<username> channels as someone else.
|
|
33
|
+
user: { username: 'dev', displayName: 'Local Developer' },
|
|
34
|
+
...options.mock,
|
|
35
|
+
// What the platform offers, as the server injects it; a mock
|
|
36
|
+
// override merges into the dev defaults (see dev-platform.js).
|
|
37
|
+
platform: devPlatform(options.mock && options.mock.platform)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Occupied-spot check for the dev /api mount: does informer.yaml's
|
|
43
|
+
* access.apis declare this method+path? Mirrors the server's matchWhitelist
|
|
44
|
+
* semantics — segment-aware, `*` matches exactly one segment.
|
|
45
|
+
*/
|
|
46
|
+
function manifestOccupies (projectRoot, req) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
const raw = parseYaml(readFileSync(resolve(projectRoot, 'informer.yaml'), 'utf8')) || {};
|
|
50
|
+
entries = (raw.access && Array.isArray(raw.access.apis)) ? raw.access.apis : [];
|
|
51
|
+
} catch {
|
|
52
|
+
return false; // no/invalid manifest: nothing is occupied
|
|
53
|
+
}
|
|
54
|
+
// Connect strips the mount prefix, so re-add it for whole-path matching.
|
|
55
|
+
const path = `/api${(req.url || '').split('?')[0]}`;
|
|
56
|
+
const segments = path.split('/').filter(Boolean);
|
|
57
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
58
|
+
return entries.some(entry => {
|
|
59
|
+
const spec = typeof entry === 'string'
|
|
60
|
+
? entry
|
|
61
|
+
: `${(entry && entry.method) || 'GET'} ${(entry && (entry.url || entry.path)) || ''}`;
|
|
62
|
+
const parsed = /^([A-Za-z]+)\s+(\S+)$/.exec(String(spec).trim());
|
|
63
|
+
if (!parsed || parsed[1].toUpperCase() !== method) return false;
|
|
64
|
+
const want = parsed[2].split('/').filter(Boolean);
|
|
65
|
+
return want.length === segments.length && want.every((s, i) => s === '*' || s === segments[i]);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
11
68
|
|
|
12
69
|
/**
|
|
13
70
|
* Write generated app-dependency type declarations under .informer/ and keep the
|
|
@@ -39,7 +96,8 @@ async function writeAppDepTypes (projectRoot, dts) {
|
|
|
39
96
|
*
|
|
40
97
|
* - Proxies /api requests to the Informer server with Basic auth
|
|
41
98
|
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
42
|
-
* - Injects window.__INFORMER__ context mock in dev mode
|
|
99
|
+
* - Injects window.__INFORMER__ context mock in dev mode, including a
|
|
100
|
+
* `channel()` mock fed by handler broadcast() over Vite's dev websocket
|
|
43
101
|
* - Sets base to './' so built assets use relative paths
|
|
44
102
|
*
|
|
45
103
|
* @param {Object} [options]
|
|
@@ -101,7 +159,12 @@ export default function informer(options = {}) {
|
|
|
101
159
|
Authorization: authHeader
|
|
102
160
|
},
|
|
103
161
|
...options.proxy
|
|
104
|
-
}
|
|
162
|
+
},
|
|
163
|
+
// NOTE: no /informer-api mount. The server
|
|
164
|
+
// retired that spelling — /api is ONE namespace
|
|
165
|
+
// with manifest-driven precedence (a whitelisted
|
|
166
|
+
// access.apis path OCCUPIES its spot; everything
|
|
167
|
+
// else is the app's own routes), mirrored below.
|
|
105
168
|
}
|
|
106
169
|
};
|
|
107
170
|
}
|
|
@@ -185,6 +248,27 @@ export default function informer(options = {}) {
|
|
|
185
248
|
for (const message of validateDevBindings(deps, options.devBindings || {})) {
|
|
186
249
|
console.error(`[informer] vite.config.js: ${message}`);
|
|
187
250
|
}
|
|
251
|
+
// Same for the `channels:` relay block — the deploy 400s on a bad
|
|
252
|
+
// channel or event name; say so now instead of dropping relays.
|
|
253
|
+
try {
|
|
254
|
+
for (const message of validateChannels(await loadChannels(projectRoot))) {
|
|
255
|
+
console.error(`[informer] informer.yaml: ${message}`);
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.warn(`[informer] Could not read informer.yaml channels: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// App Channels in dev. Handlers' broadcast() (and the `channels:`
|
|
262
|
+
// relay behind emit()) publish frames on this hub; each frame goes
|
|
263
|
+
// to the page as a custom event on Vite's own dev websocket, which
|
|
264
|
+
// the injected `__INFORMER__.channel` mock listens to via
|
|
265
|
+
// import.meta.hot. Riding the HMR socket means no second websocket,
|
|
266
|
+
// no `ws: true` on the /api proxy and no socket credential to mint
|
|
267
|
+
// — the dev server already owns a live connection to every page.
|
|
268
|
+
const channels = createDevChannels({ appId: buildMock(options).report.id });
|
|
269
|
+
channels.emitter.on(BROADCAST_EVENT, (frame) => {
|
|
270
|
+
server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
|
|
271
|
+
});
|
|
188
272
|
|
|
189
273
|
// Generate .d.ts types for bound `target: app` / `target: pack`
|
|
190
274
|
// deps from their published OpenAPI docs, so server/ handlers get
|
|
@@ -249,6 +333,16 @@ export default function informer(options = {}) {
|
|
|
249
333
|
console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
|
|
250
334
|
}
|
|
251
335
|
|
|
336
|
+
// App streams (I5-12979): the dev origin's /_uploads and /_downloads
|
|
337
|
+
// — the same protocol the deployed app speaks on its own origin —
|
|
338
|
+
// backed by one in-memory store the server routes' `uploads` /
|
|
339
|
+
// `downloads` services share. Mounted regardless of server/: the
|
|
340
|
+
// client helper is always on __INFORMER__, so a page may stage an
|
|
341
|
+
// upload before any handler exists to consume it.
|
|
342
|
+
const streamStore = createStreamStore();
|
|
343
|
+
server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
|
|
344
|
+
server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
|
|
345
|
+
|
|
252
346
|
// Mount server-side route handlers if a server/ directory exists
|
|
253
347
|
const serverDir = resolve(projectRoot, 'server');
|
|
254
348
|
|
|
@@ -259,14 +353,31 @@ export default function informer(options = {}) {
|
|
|
259
353
|
devWorkspaceId,
|
|
260
354
|
projectRoot,
|
|
261
355
|
roles: (options.mock && options.mock.roles) || [],
|
|
356
|
+
user: buildMock(options).user,
|
|
262
357
|
// Dev-only bindings for `target: app` / `target: pack`
|
|
263
358
|
// slots (app: overrides the manifest defaultBinding; pack:
|
|
264
359
|
// names the locally-installed pack app). Shape:
|
|
265
360
|
// devBindings: { kanban: { app: 'admin:kanban' } }
|
|
266
361
|
devBindings: options.devBindings || {},
|
|
267
|
-
appToken
|
|
362
|
+
appToken,
|
|
363
|
+
channels,
|
|
364
|
+
streamStore
|
|
268
365
|
});
|
|
269
366
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
367
|
+
// Occupied-spot precedence (App API v2, matches production
|
|
368
|
+
// dispatch in view-api.js): a platform API declared in
|
|
369
|
+
// informer.yaml's access.apis OCCUPIES its /api path — those
|
|
370
|
+
// requests skip the local handlers (next() falls through to
|
|
371
|
+
// the /api proxy above); every unclaimed path dispatches the
|
|
372
|
+
// app's own routes. The manifest is re-read per request so
|
|
373
|
+
// edits apply without a restart. Dev limitation: only raw
|
|
374
|
+
// access.apis entries are consulted (dependency-derived
|
|
375
|
+
// grants proxy through their typed context slots anyway).
|
|
376
|
+
// /api/_server stays as the legacy exclusive spelling.
|
|
377
|
+
server.middlewares.use('/api', (req, res, next) => {
|
|
378
|
+
if (manifestOccupies(projectRoot, req)) return next();
|
|
379
|
+
return serverRoutes(req, res, next);
|
|
380
|
+
});
|
|
270
381
|
}
|
|
271
382
|
|
|
272
383
|
// Mount agent dev middleware if tools/, mcp/, or informer.yaml agents exist
|
|
@@ -281,7 +392,8 @@ export default function informer(options = {}) {
|
|
|
281
392
|
devWorkspaceId,
|
|
282
393
|
projectRoot,
|
|
283
394
|
devBindings: options.devBindings || {},
|
|
284
|
-
appToken
|
|
395
|
+
appToken,
|
|
396
|
+
channels
|
|
285
397
|
});
|
|
286
398
|
server.middlewares.use('/api/_agent', agentDev);
|
|
287
399
|
}
|
|
@@ -293,22 +405,25 @@ export default function informer(options = {}) {
|
|
|
293
405
|
handler(html) {
|
|
294
406
|
if (!isDev) return html;
|
|
295
407
|
|
|
296
|
-
const mock =
|
|
297
|
-
report: {
|
|
298
|
-
id: 'dev-local',
|
|
299
|
-
name: 'Local Development'
|
|
300
|
-
},
|
|
301
|
-
theme: 'light',
|
|
302
|
-
roles: [],
|
|
303
|
-
...options.mock
|
|
304
|
-
};
|
|
408
|
+
const mock = buildMock(options);
|
|
305
409
|
|
|
410
|
+
// The context is a classic script (synchronous, so it exists
|
|
411
|
+
// before anything else runs). The streams client (upload /
|
|
412
|
+
// downloadUrl) rides inside it: the one piece of the mock that is
|
|
413
|
+
// code, not data, the same helper the deployed page gets,
|
|
414
|
+
// speaking to the /_uploads and /_downloads mounted in
|
|
415
|
+
// configureServer. The channel mock follows as a module script
|
|
416
|
+
// because only a Vite-processed module gets import.meta.hot.
|
|
306
417
|
const script = `<script>
|
|
307
418
|
(function() {
|
|
308
419
|
'use strict';
|
|
309
420
|
window.__INFORMER__ = ${JSON.stringify(mock)};
|
|
421
|
+
var __streams = ${streamsClientSource()};
|
|
422
|
+
window.__INFORMER__.upload = __streams.upload;
|
|
423
|
+
window.__INFORMER__.downloadUrl = __streams.downloadUrl;
|
|
310
424
|
})();
|
|
311
|
-
</script
|
|
425
|
+
</script>
|
|
426
|
+
${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;
|
|
312
427
|
|
|
313
428
|
// Insert after <head> tag, matching server behavior
|
|
314
429
|
const headIdx = html.indexOf('<head>');
|
package/src/marketplaces.js
CHANGED
|
@@ -53,9 +53,35 @@ export function normalizeMarketplaceUrl(value) {
|
|
|
53
53
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
54
54
|
throw new Error(`Marketplace URL must be http(s): ${value}`);
|
|
55
55
|
}
|
|
56
|
+
// This URL is both the OIDC audience and the endpoint that receives
|
|
57
|
+
// `Authorization: Bearer <jwt>`. The audience binding stops a token minted
|
|
58
|
+
// for one marketplace being replayed at another, but does nothing about an
|
|
59
|
+
// on-path observer at the target itself — over http: a vendor-publishing
|
|
60
|
+
// token crosses the wire in cleartext. Loopback is exempt so an on-prem or
|
|
61
|
+
// local LM can still be developed against.
|
|
62
|
+
if (url.protocol === 'http:' && !isLoopback(url.hostname)) {
|
|
63
|
+
throw new Error(`Marketplace URL must be https (a publish token is sent to it): ${value}`);
|
|
64
|
+
}
|
|
56
65
|
url.hostname = url.hostname.toLowerCase();
|
|
57
|
-
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
58
66
|
url.hash = '';
|
|
59
67
|
url.search = '';
|
|
60
|
-
|
|
68
|
+
// Strip trailing slashes on the serialized string, NOT via the pathname
|
|
69
|
+
// setter — WHATWG re-normalizes an empty path back to '/', so a root-path
|
|
70
|
+
// URL like https://api.informer.cloud would keep a slash that the publish
|
|
71
|
+
// URL strips. This string is also the OIDC audience, and the License
|
|
72
|
+
// Manager compares `aud` as an exact string: one character of drift fails
|
|
73
|
+
// verification on any LM that enforces its audience.
|
|
74
|
+
return url.toString().replace(/\/+$/, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a hostname is this machine. WHATWG keeps IPv6 hosts bracketed
|
|
79
|
+
* (`[::1]`), and 127.0.0.0/8 is loopback in full, not just 127.0.0.1.
|
|
80
|
+
*/
|
|
81
|
+
function isLoopback(hostname) {
|
|
82
|
+
const host = String(hostname).toLowerCase();
|
|
83
|
+
return host === 'localhost'
|
|
84
|
+
|| host === '[::1]'
|
|
85
|
+
|| host === '::1'
|
|
86
|
+
|| /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
61
87
|
}
|
package/src/publish-payload.js
CHANGED
|
@@ -8,9 +8,11 @@ import { buildLocalOpenApi } from './openapi-local.js';
|
|
|
8
8
|
* Everything a publish needs, assembled once.
|
|
9
9
|
*
|
|
10
10
|
* Split out of bin/publish.js so `informer-ci` can build the payload a single
|
|
11
|
-
* time and hand the SAME
|
|
12
|
-
*
|
|
13
|
-
* create opportunities for
|
|
11
|
+
* time and hand the SAME file set to several marketplaces — a version's
|
|
12
|
+
* contents have to be identical everywhere it lands, and re-assembling per
|
|
13
|
+
* target would only create opportunities for them not to be. (The upload
|
|
14
|
+
* archive itself is still built per target inside publish(), so archive
|
|
15
|
+
* bytes — tar mtimes — differ; the files inside do not.)
|
|
14
16
|
*
|
|
15
17
|
* Throws on anything that should stop a publish; the bins print and exit.
|
|
16
18
|
*/
|
|
@@ -44,14 +46,32 @@ export async function preparePublish({ projectRoot = resolve('.'), version: vers
|
|
|
44
46
|
const { spec: apiSpec, warnings } = await buildLocalOpenApi({ projectRoot, name, slug, version });
|
|
45
47
|
if (apiSpec) files.push({ rel: 'openapi.json', content: JSON.stringify(apiSpec, null, 2) });
|
|
46
48
|
|
|
49
|
+
// An explicitly configured icon or screenshots dir that doesn't resolve
|
|
50
|
+
// must not degrade silently: the publish "succeeds", the listing ships
|
|
51
|
+
// visually wrong to every marketplace in a fan-out, and a human notices
|
|
52
|
+
// weeks later. Speculative defaults may miss quietly; config may not.
|
|
47
53
|
const icon = await resolveIcon(projectRoot, inf.icon);
|
|
54
|
+
if (inf.icon && (!icon || icon.abs !== resolve(projectRoot, inf.icon))) {
|
|
55
|
+
warnings.push(
|
|
56
|
+
`informer.icon "${inf.icon}" was not found — ` +
|
|
57
|
+
(icon ? `falling back to ${icon.rel}.` : 'publishing without a listing icon.')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
48
60
|
const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
|
|
61
|
+
if (inf.screenshots && !screenshots.length) {
|
|
62
|
+
warnings.push(`informer.screenshots "${inf.screenshots}" has no readable screenshots — the listing will have none.`);
|
|
63
|
+
}
|
|
49
64
|
|
|
50
65
|
// Provenance (GitHub Actions). All optional — best-effort audit trail.
|
|
51
66
|
const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
|
|
52
67
|
const sourceCommit = process.env.GITHUB_SHA || undefined;
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
// Both halves must be present: the URL interpolates the repo as well as the
|
|
69
|
+
// run id, so guarding only the run id records
|
|
70
|
+
// `https://github.com/undefined/actions/runs/123` as the audit trail — a
|
|
71
|
+
// link that resolves nowhere, in the one field whose entire job is to be
|
|
72
|
+
// followable later.
|
|
73
|
+
const ciRun = sourceRepo && process.env.GITHUB_RUN_ID
|
|
74
|
+
? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${sourceRepo}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
55
75
|
: undefined;
|
|
56
76
|
|
|
57
77
|
return {
|
|
@@ -82,11 +102,36 @@ export async function preparePublish({ projectRoot = resolve('.'), version: vers
|
|
|
82
102
|
};
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* `vX.Y.Z` (GITHUB_REF_NAME) → `X.Y.Z`.
|
|
107
|
+
*
|
|
108
|
+
* A non-tag ref passes through unchanged — `main` stays `main` — so a caller
|
|
109
|
+
* feeding this from CI must gate on isVersionTag() before publishing with the
|
|
110
|
+
* result (informer-ci refuses outright; informer-publish falls back to the
|
|
111
|
+
* package.json version).
|
|
112
|
+
*/
|
|
86
113
|
export function tagVersion(ref) {
|
|
87
114
|
return ref ? ref.replace(/^v/, '') : undefined;
|
|
88
115
|
}
|
|
89
116
|
|
|
117
|
+
/** Semver-ish: x.y.z with optional prerelease/build. What the LM will accept. */
|
|
118
|
+
export function looksLikeVersion(value) {
|
|
119
|
+
return typeof value === 'string' && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Does this CI ref name a publishable version? Only a tag-shaped ref counts:
|
|
124
|
+
* GITHUB_REF_TYPE must not say otherwise, and the version the ref yields must
|
|
125
|
+
* look like a version. Without this gate, a workflow_dispatch against a
|
|
126
|
+
* branch has GITHUB_REF_NAME=main — publishing a version literally called
|
|
127
|
+
* "main", on the STABLE channel (no prerelease dash). Both bins share this
|
|
128
|
+
* predicate so they can't drift.
|
|
129
|
+
*/
|
|
130
|
+
export function isVersionTag(refType, refName) {
|
|
131
|
+
if (refType && refType !== 'tag') return false;
|
|
132
|
+
return looksLikeVersion(tagVersion(refName));
|
|
133
|
+
}
|
|
134
|
+
|
|
90
135
|
export function defaultSlug(value) {
|
|
91
136
|
if (!value) return undefined;
|
|
92
137
|
const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
|
|
@@ -99,7 +144,10 @@ async function resolveIcon(root, configured) {
|
|
|
99
144
|
const abs = resolve(root, rel);
|
|
100
145
|
try {
|
|
101
146
|
await access(abs);
|
|
102
|
-
|
|
147
|
+
// `rel` rides along for warnings: every default candidate shares
|
|
148
|
+
// the basename favicon.svg, so filename alone can't say WHICH
|
|
149
|
+
// file was picked.
|
|
150
|
+
return { abs, rel, filename: rel.split(/[/\\]/).pop() };
|
|
103
151
|
} catch {
|
|
104
152
|
// try next candidate
|
|
105
153
|
}
|
package/src/publish.js
CHANGED
|
@@ -93,6 +93,26 @@ export async function publish({ marketplaceUrl, token, files, fields, icon, scre
|
|
|
93
93
|
body: form
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
return await readPublishResponse(res, url);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Turn the `/packs/publish` response into a result object, or throw
|
|
101
|
+
* PublishError. Exported so the parse contract is testable without staging an
|
|
102
|
+
* archive and stubbing fetch.
|
|
103
|
+
*
|
|
104
|
+
* Both directions tolerate a body they can't read, and for the same reason:
|
|
105
|
+
* whether the version landed is decided by the status, never by our ability to
|
|
106
|
+
* parse what came back. An unguarded `res.json()` here meant a 204 or an empty
|
|
107
|
+
* 2xx threw SyntaxError *after* the marketplace had stored the version, which
|
|
108
|
+
* the caller could only read as a publish failure — CI exits 1, the re-run
|
|
109
|
+
* 409s "version already exists", and someone debugs a publish that succeeded.
|
|
110
|
+
*
|
|
111
|
+
* `{}` (rather than null/undefined) keeps the shape callers destructure —
|
|
112
|
+
* `result.warnings`, `result.channel` — which is also why a literal `null`
|
|
113
|
+
* body, valid JSON that parses to null, is normalized away.
|
|
114
|
+
*/
|
|
115
|
+
export async function readPublishResponse(res, url) {
|
|
96
116
|
if (!res.ok) {
|
|
97
117
|
let body = '';
|
|
98
118
|
try {
|
|
@@ -103,5 +123,9 @@ export async function publish({ marketplaceUrl, token, files, fields, icon, scre
|
|
|
103
123
|
throw new PublishError(res.status, body, url);
|
|
104
124
|
}
|
|
105
125
|
|
|
106
|
-
|
|
126
|
+
try {
|
|
127
|
+
return (await res.json()) ?? {};
|
|
128
|
+
} catch {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
107
131
|
}
|