@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/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>');
@@ -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
- return url.toString();
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
  }
@@ -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 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.
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
- 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}`
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
- /** `vX.Y.Z` (GITHUB_REF_NAME) → `X.Y.Z`. */
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
- return { abs, filename: rel.split(/[/\\]/).pop() };
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
- return await res.json();
126
+ try {
127
+ return (await res.json()) ?? {};
128
+ } catch {
129
+ return {};
130
+ }
107
131
  }