@phnx-labs/agents-cli 1.22.114 → 1.22.115

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.
Files changed (176) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +199 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +2 -7
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/focus.js +1 -1
  19. package/dist/commands/go.d.ts +0 -17
  20. package/dist/commands/go.js +2 -19
  21. package/dist/commands/logs.js +1 -1
  22. package/dist/commands/mcp.js +8 -83
  23. package/dist/commands/memory.js +4 -47
  24. package/dist/commands/message.js +4 -4
  25. package/dist/commands/plugins.js +1 -93
  26. package/dist/commands/repo.js +0 -44
  27. package/dist/commands/resume.js +4 -1
  28. package/dist/commands/secrets-passthrough.js +2 -2
  29. package/dist/commands/send.d.ts +4 -4
  30. package/dist/commands/send.js +6 -51
  31. package/dist/commands/sessions-backup-setup.js +1 -1
  32. package/dist/commands/sessions-resume.js +0 -1
  33. package/dist/commands/sessions-share.d.ts +5 -7
  34. package/dist/commands/sessions-share.js +98 -49
  35. package/dist/commands/sessions.js +1 -8
  36. package/dist/commands/setup-browser.js +18 -2
  37. package/dist/commands/setup-computer.js +20 -6
  38. package/dist/commands/setup-secrets.js +24 -4
  39. package/dist/commands/setup-terminal.d.ts +3 -0
  40. package/dist/commands/setup-terminal.js +22 -0
  41. package/dist/commands/setup.d.ts +1 -1
  42. package/dist/commands/setup.js +20 -10
  43. package/dist/commands/skills.js +0 -8
  44. package/dist/commands/ssh.d.ts +6 -0
  45. package/dist/commands/ssh.js +92 -233
  46. package/dist/commands/sync.js +14 -5
  47. package/dist/commands/teams.js +1 -1
  48. package/dist/commands/traces.js +1 -1
  49. package/dist/lib/accounts/add.d.ts +0 -5
  50. package/dist/lib/accounts/add.js +1 -7
  51. package/dist/lib/artifacts-client.d.ts +20 -0
  52. package/dist/lib/artifacts-client.js +46 -0
  53. package/dist/lib/auth-mint.js +2 -2
  54. package/dist/lib/browser/runtime-state.d.ts +55 -0
  55. package/dist/lib/browser/runtime-state.js +99 -18
  56. package/dist/lib/browser/service.js +21 -1
  57. package/dist/lib/cli-resources.js +3 -1
  58. package/dist/lib/cloud/dispatch.js +1 -1
  59. package/dist/lib/cloudflare/creds.d.ts +10 -0
  60. package/dist/lib/cloudflare/creds.js +46 -0
  61. package/dist/lib/cloudflare/provision.d.ts +35 -0
  62. package/dist/lib/cloudflare/provision.js +144 -0
  63. package/dist/lib/computer/sessions-list.d.ts +55 -0
  64. package/dist/lib/computer/sessions-list.js +168 -1
  65. package/dist/lib/daemon/daemon.js +8 -1
  66. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  67. package/dist/lib/daemon/feed-stream-service.js +40 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/devices/connect.d.ts +49 -5
  71. package/dist/lib/devices/connect.js +169 -21
  72. package/dist/lib/feed/envelope.d.ts +79 -0
  73. package/dist/lib/feed/envelope.js +23 -0
  74. package/dist/lib/feed/events.d.ts +6 -0
  75. package/dist/lib/feed/events.js +8 -0
  76. package/dist/lib/feed/hub-server.d.ts +86 -0
  77. package/dist/lib/feed/hub-server.js +334 -0
  78. package/dist/lib/feed/hub.d.ts +95 -0
  79. package/dist/lib/feed/hub.js +255 -0
  80. package/dist/lib/feed/tool-activity.d.ts +108 -0
  81. package/dist/lib/feed/tool-activity.js +313 -0
  82. package/dist/lib/feed/tools.d.ts +198 -0
  83. package/dist/lib/feed/tools.js +265 -0
  84. package/dist/lib/feed/watch.d.ts +50 -50
  85. package/dist/lib/feed/watch.js +147 -16
  86. package/dist/lib/format.d.ts +1 -1
  87. package/dist/lib/format.js +1 -1
  88. package/dist/lib/git.d.ts +0 -16
  89. package/dist/lib/git.js +0 -58
  90. package/dist/lib/helper-versions.js +1 -1
  91. package/dist/lib/hosts/remote-cmd.d.ts +51 -1
  92. package/dist/lib/hosts/remote-cmd.js +125 -8
  93. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  94. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  95. package/dist/lib/mcp.js +17 -11
  96. package/dist/lib/probe.d.ts +4 -1
  97. package/dist/lib/probe.js +5 -2
  98. package/dist/lib/pwsh.d.ts +33 -0
  99. package/dist/lib/pwsh.js +56 -0
  100. package/dist/lib/redact.d.ts +8 -0
  101. package/dist/lib/redact.js +11 -0
  102. package/dist/lib/refresh.d.ts +6 -2
  103. package/dist/lib/refresh.js +92 -72
  104. package/dist/lib/secrets-cli.d.ts +11 -0
  105. package/dist/lib/secrets-cli.js +30 -0
  106. package/dist/lib/secrets-client.js +3 -2
  107. package/dist/lib/session/detached.d.ts +7 -0
  108. package/dist/lib/session/detached.js +29 -0
  109. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  110. package/dist/lib/session/remote/peer-stream.js +33 -6
  111. package/dist/lib/session/sync/backend.d.ts +3 -3
  112. package/dist/lib/session/sync/backend.js +3 -3
  113. package/dist/lib/session/sync/provision.d.ts +1 -1
  114. package/dist/lib/session/sync/provision.js +2 -2
  115. package/dist/lib/sessions-client.js +0 -3
  116. package/dist/lib/setup-tool-install.d.ts +3 -0
  117. package/dist/lib/setup-tool-install.js +26 -0
  118. package/dist/lib/setup-tool-status.d.ts +22 -0
  119. package/dist/lib/setup-tool-status.js +215 -0
  120. package/dist/lib/share-runtime.d.ts +11 -0
  121. package/dist/lib/share-runtime.js +63 -0
  122. package/dist/lib/smart-launch.d.ts +1 -5
  123. package/dist/lib/smart-launch.js +3 -11
  124. package/dist/lib/ssh-exec.d.ts +44 -0
  125. package/dist/lib/ssh-exec.js +119 -0
  126. package/dist/lib/startup/command-registry.d.ts +6 -4
  127. package/dist/lib/startup/command-registry.js +10 -7
  128. package/dist/lib/state.js +2 -2
  129. package/dist/lib/storage/selection.d.ts +2 -2
  130. package/dist/lib/storage/selection.js +2 -2
  131. package/dist/lib/sync-umbrella.d.ts +5 -0
  132. package/dist/lib/sync-umbrella.js +18 -10
  133. package/dist/lib/traces/backend.d.ts +1 -2
  134. package/dist/lib/traces/backend.js +1 -2
  135. package/dist/lib/traces/provision.d.ts +1 -1
  136. package/dist/lib/traces/provision.js +2 -2
  137. package/dist/lib/types.d.ts +8 -6
  138. package/package.json +2 -3
  139. package/dist/commands/artifacts-setup.d.ts +0 -53
  140. package/dist/commands/artifacts-setup.js +0 -161
  141. package/dist/commands/artifacts.d.ts +0 -18
  142. package/dist/commands/artifacts.js +0 -58
  143. package/dist/commands/attach.d.ts +0 -12
  144. package/dist/commands/attach.js +0 -86
  145. package/dist/commands/auth-mint.d.ts +0 -12
  146. package/dist/commands/auth-mint.js +0 -108
  147. package/dist/commands/reconnect.d.ts +0 -46
  148. package/dist/commands/reconnect.js +0 -115
  149. package/dist/commands/share.d.ts +0 -293
  150. package/dist/commands/share.js +0 -1424
  151. package/dist/lib/share/analytics.d.ts +0 -13
  152. package/dist/lib/share/analytics.js +0 -45
  153. package/dist/lib/share/backend.d.ts +0 -120
  154. package/dist/lib/share/backend.js +0 -176
  155. package/dist/lib/share/capture.d.ts +0 -31
  156. package/dist/lib/share/capture.js +0 -174
  157. package/dist/lib/share/config.d.ts +0 -72
  158. package/dist/lib/share/config.js +0 -211
  159. package/dist/lib/share/delete.d.ts +0 -123
  160. package/dist/lib/share/delete.js +0 -173
  161. package/dist/lib/share/html.d.ts +0 -20
  162. package/dist/lib/share/html.js +0 -88
  163. package/dist/lib/share/http-error.d.ts +0 -53
  164. package/dist/lib/share/http-error.js +0 -65
  165. package/dist/lib/share/og.d.ts +0 -26
  166. package/dist/lib/share/og.js +0 -84
  167. package/dist/lib/share/provision.d.ts +0 -127
  168. package/dist/lib/share/provision.js +0 -285
  169. package/dist/lib/share/publish.d.ts +0 -379
  170. package/dist/lib/share/publish.js +0 -818
  171. package/dist/lib/share/worker-template.d.ts +0 -27
  172. package/dist/lib/share/worker-template.js +0 -2424
  173. package/dist/lib/storage/index.d.ts +0 -14
  174. package/dist/lib/storage/index.js +0 -14
  175. package/dist/lib/storage/visibility.d.ts +0 -82
  176. package/dist/lib/storage/visibility.js +0 -99
@@ -1,818 +0,0 @@
1
- // The publish path for `agents artifacts share <file>` — an authed PUT to the Worker.
2
- // Pure logic (slug, expiry) is exported for tests; the network call is behind a DI seam.
3
- //
4
- // For HTML publishes it also captures a 1200×630 cover (the page's own hero) and
5
- // injects og:image / twitter:card meta, so the link unfurls into a preview card in
6
- // Slack / iMessage / Twitter / Discord. The cover is best-effort: if no headless
7
- // browser is available it's skipped and the plain link still publishes.
8
- import { readFileSync } from 'node:fs';
9
- import { basename } from 'node:path';
10
- import { execFileSync } from 'node:child_process';
11
- import { hostname as osHostname } from 'node:os';
12
- import { createHash, randomBytes } from 'node:crypto';
13
- import { readSession } from '../identity/client.js';
14
- import { readShareConfig } from './config.js';
15
- import { resolveGitHubUsername } from '../git.js';
16
- import { resolveShareBackend, sanitizeShareNamespace } from './backend.js';
17
- import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
18
- import { deriveMeta, injectOgMeta } from './og.js';
19
- import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
20
- import { PUBLISH_VISIBILITY_LEVELS as STORAGE_PUBLISH_VISIBILITY_LEVELS, EDITABLE_VISIBILITY_LEVELS, resolveVisibility, } from '../storage/visibility.js';
21
- import { injectAnalyticsBeacon } from './analytics.js';
22
- import { prepareShareHtml } from './html.js';
23
- /** The visibility levels a publish `--visibility` may select — the Worker's own
24
- * set. `private` (token-gated, PHNX-3654) is settable only at publish time,
25
- * since it mints a viewer token the metadata-edit route can't carry, so it is
26
- * NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
27
- * set). */
28
- export const PUBLISH_VISIBILITY_LEVELS = STORAGE_PUBLISH_VISIBILITY_LEVELS;
29
- /** The visibility levels an ALREADY-published share may be re-scoped to in place
30
- * — the set `share visibility <target> <level>` and the inline owner control
31
- * accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
32
- * token, which only the publish path mints. */
33
- export const SHARE_VISIBILITY_LEVELS = EDITABLE_VISIBILITY_LEVELS;
34
- /**
35
- * `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
36
- * over `--unlisted` when both are set, being the stronger control); `--unlisted`
37
- * maps to `unlisted`; `--visibility private|unlisted|me|org` passes through;
38
- * otherwise `visibility` (default public).
39
- */
40
- export function resolveShareVisibility(opts = {}) {
41
- // Delegates to the shared visibility resolver. The library fallback stays
42
- // `public` so a lib caller that hasn't opted into the managed `me` default
43
- // (e.g. `sessions share --public`, which expresses "public" as the absence of
44
- // --unlisted) is never silently flipped. The PRODUCT default (`me` on managed)
45
- // is applied by the `agents artifacts share` command surface, which knows the
46
- // resolved backend — see `commands/share.ts`.
47
- return resolveVisibility(opts);
48
- }
49
- /** The bytes of viewer-token entropy the `private` mode mints (128-bit; the
50
- * base64url form is ~22 URL-safe chars). Well past the 64-bit floor a guessable
51
- * capability URL would fall to. */
52
- const VIEWER_TOKEN_BYTES = 16;
53
- /** Mint a fresh random viewer token for a `private` (token-gated) publish. The
54
- * raw token rides ONLY in the returned URL's `?k=`; the Worker stores just its
55
- * SHA-256 hash, so the object metadata never carries the secret. */
56
- function generateViewerToken() {
57
- return randomBytes(VIEWER_TOKEN_BYTES).toString('base64url');
58
- }
59
- /** SHA-256 hex of a viewer token — the form the Worker also computes and stores,
60
- * so a CLI-side preview of the stored hash matches. Exported for tests. */
61
- export function hashViewerToken(token) {
62
- return createHash('sha256').update(token).digest('hex');
63
- }
64
- /** A 64-bit random slug tail (16 lowercase hex chars) for a capability-URL
65
- * publish, so the slug can never be derived/guessed from the title (PHNX-3654).
66
- * `unlisted` leans on this for obscurity; `private` uses it as defense-in-depth
67
- * behind the viewer token. */
68
- function randomSlugTail() {
69
- return randomBytes(8).toString('hex');
70
- }
71
- /**
72
- * The loud stderr warning printed on an `unlisted` / `--private` publish
73
- * (PHNX-3654): `unlisted` is obscurity, NOT read-authentication — anyone with
74
- * the URL can read it. Points the user at the real controls (`--protected`,
75
- * `--expire`, `me`/`org`). Lives here beside the visibility logic; `share.ts`
76
- * prints it so the lib layer stays free of `console.*`.
77
- */
78
- export function unlistedNotPrivateWarning() {
79
- return ('unlisted is NOT private — anyone with the URL can read it (a capability link, ' +
80
- 'hidden from the gallery and marked noindex, but not authenticated).\n' +
81
- ' For sensitive content use --protected (a token-gated link that returns 404 ' +
82
- 'without the key), and/or --expire to bound the window; --visibility me|org ' +
83
- 'gates on your Phoenix login.');
84
- }
85
- /**
86
- * Reserved `customMetadata` keys a `--meta key=value` may not target (see
87
- * {@link parseMetaEntries}). Matches the Worker's `RESERVED_METADATA_KEYS`:
88
- * provenance the CLI sets automatically, plus `expires-at` / `visibility` /
89
- * `owner` which the Worker stamps itself.
90
- */
91
- export const RESERVED_META_KEYS = [
92
- 'expires-at',
93
- 'published-at',
94
- 'visibility',
95
- 'viewer-token-hash',
96
- 'owner',
97
- 'org_domain',
98
- 'agent',
99
- 'session',
100
- 'host',
101
- 'repo',
102
- 'date',
103
- 'avatar',
104
- 'label',
105
- 'label-source',
106
- 'og-title',
107
- 'og-description',
108
- 'og-generated',
109
- 'og-source-etag',
110
- ];
111
- const META_KEY_RE = /^[a-z0-9-]{1,64}$/;
112
- /**
113
- * Auto-capture publish provenance from the exec env, git, and the local clock.
114
- * Every field is present only when the environment genuinely carries it — a
115
- * human running the command by hand outside a git repo yields `session`/`agent`/
116
- * `repo` all undefined, never an invented value. `host` is always present
117
- * (`os.hostname()` never fails to return something real about where the publish
118
- * ran, so it isn't "invented" in the same sense).
119
- */
120
- export function resolveShareProvenance(opts = {}) {
121
- const env = opts.env ?? process.env;
122
- return {
123
- session: env.AGENTS_SESSION_ID || env.AGENT_SESSION_ID || undefined,
124
- agent: env.AGENTS_AGENT_NAME || undefined,
125
- host: opts.hostname ?? osHostname(),
126
- repo: gitRepoName(opts.dir ?? process.cwd()),
127
- date: (opts.now ?? new Date()).toISOString().slice(0, 10),
128
- };
129
- }
130
- /**
131
- * The sharer's avatar URL, stamped so the share bar can show a real profile
132
- * picture instead of only the initials circle. A hosted OAuth profile image
133
- * already known to identity (PhoenixSession.avatarUrl — captured at login and
134
- * refreshed from `/api/v1/auth/me`) wins outright; only when none exists do we
135
- * fall back to a Gravatar keyed on the SHA-256 of the signed-in user's
136
- * lowercased email (Gravatar resolves either MD5 or SHA-256), with `d=404` so
137
- * Gravatar returns 404 for a user who has none — the bar's `<img>` onerror
138
- * then falls back to the initials circle. Only the hash lands in public
139
- * metadata, never the raw email. Returns '' when signed out (BYO without a
140
- * Phoenix session), leaving the bar on the initials circle.
141
- *
142
- * `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
143
- * yields ''; `undefined` reads the real persisted session.
144
- */
145
- export function resolveShareAvatar(opts = {}) {
146
- const session = opts.session !== undefined ? opts.session : readSession();
147
- const hosted = session?.avatarUrl?.trim();
148
- if (hosted && /^https:\/\//i.test(hosted))
149
- return hosted;
150
- const email = session?.email?.trim().toLowerCase();
151
- if (!email)
152
- return '';
153
- const hash = createHash('sha256').update(email).digest('hex');
154
- return `https://www.gravatar.com/avatar/${hash}?d=404&s=52`;
155
- }
156
- /**
157
- * Parse repeated `--meta key=value` CLI args into a validated metadata record.
158
- * Keys are lowercase `[a-z0-9-]`, up to 64 characters, and may not collide with
159
- * {@link RESERVED_META_KEYS} (Worker-stamped owner/visibility/expires-at, plus
160
- * the provenance the CLI sets automatically, plus label/label-source) — throws
161
- * naming the offending pair on any violation.
162
- */
163
- export function parseMetaEntries(pairs) {
164
- const meta = {};
165
- for (const pair of pairs) {
166
- const eq = pair.indexOf('=');
167
- if (eq <= 0) {
168
- throw new Error(`Bad --meta '${pair}'. Expected key=value.`);
169
- }
170
- const key = pair.slice(0, eq).trim();
171
- const value = pair.slice(eq + 1);
172
- if (RESERVED_META_KEYS.includes(key)) {
173
- throw new Error(`--meta ${key}=… is reserved (Worker-stamped, or set automatically from your session/git) — pass a different key.`);
174
- }
175
- if (!META_KEY_RE.test(key)) {
176
- throw new Error(`Bad --meta key '${key}'. Keys are lowercase letters, digits, and hyphens, up to 64 characters.`);
177
- }
178
- meta[key] = value;
179
- }
180
- return meta;
181
- }
182
- /** S3's `x-amz-meta` convention caps user metadata around 2KB; R2 publishes no
183
- * hard limit of its own, so stay under that ceiling to keep a share portable to
184
- * an S3-compatible mirror. Checked over the FULL customMetadata payload
185
- * (provenance + label + --meta combined), since that's what actually gets
186
- * written to the object. */
187
- const MAX_METADATA_BYTES = 2048;
188
- /** Throws when the combined `customMetadata` payload would exceed {@link MAX_METADATA_BYTES}. */
189
- export function assertMetadataSize(customMetadata) {
190
- const bytes = Buffer.byteLength(JSON.stringify(customMetadata), 'utf8');
191
- if (bytes > MAX_METADATA_BYTES) {
192
- throw new Error(`Share metadata is ${bytes} bytes, over the ${MAX_METADATA_BYTES}-byte cap ` +
193
- `(provenance + --label + --meta combined). Trim your --meta values.`);
194
- }
195
- }
196
- /**
197
- * Collapse a label to a single line before it goes into the `x-share-label`
198
- * header or public customMetadata. `<title>[^<]{1,200}</title>` matches
199
- * newlines (`[^<]` excludes only `<`), and `.trim()` only strips leading/
200
- * trailing whitespace, not embedded newlines — a multi-line `<title>` (or an
201
- * explicit `--label`/`--title` the caller typed with a literal newline) was
202
- * previously passed straight into `Headers.set()` unsanitized, which throws
203
- * an unhandled `TypeError: Invalid value` and crashes the publish outright.
204
- */
205
- export function sanitizeLabel(text) {
206
- return text.replace(/\s+/g, ' ').trim();
207
- }
208
- /**
209
- * Typographic characters that reach a header constantly — a curly quote from a
210
- * pasted prompt, an em dash from prose, the ellipsis a truncated title ends on —
211
- * each mapped to the ASCII form a reader loses nothing by seeing.
212
- */
213
- const HEADER_TRANSLITERATIONS = [
214
- [/[‘’‚‛]/g, "'"],
215
- [/[“”„‟]/g, '"'],
216
- [/[–—―]/g, '-'],
217
- [/…/g, '...'],
218
- [/[    ]/g, ' '],
219
- [/[•·]/g, '-'],
220
- ];
221
- /**
222
- * Make a free-text value safe to put in an HTTP header.
223
- *
224
- * `fetch` encodes header values as a **ByteString**, so any code point above 255
225
- * throws `TypeError: Cannot convert argument to a ByteString` — an unhandled
226
- * crash with a stack trace, mid-publish, after the body has already been read.
227
- * Reproduced by publishing a session whose title ended in `…` (U+2026), and
228
- * reachable by any emoji, curly quote, accented name, or CJK text in a `--label`,
229
- * a `--meta` value, or a repo name.
230
- *
231
- * The transliterations above cover what actually shows up; anything else outside
232
- * latin1 is dropped, and a value that transliterates to nothing at all (a title
233
- * written entirely in a non-latin script) degrades to a marker rather than an
234
- * empty header. This value is the **latin1-safe floor** every Worker can read: a
235
- * pre-Unicode Worker only ever sees `x-share-<field>`, so it MUST stay folded.
236
- * Full Unicode rides ALONGSIDE it in a percent-encoded companion header
237
- * (`toPercentHeaderValue` / {@link needsUnicodeCompanion}), which a new Worker
238
- * opts into via `x-share-encoding: percent` and an old one ignores — so a
239
- * Japanese/emoji title renders in full on an updated Worker and still folds
240
- * gracefully everywhere else (PHNX-2786).
241
- */
242
- export function toHeaderValue(text) {
243
- let safe = text;
244
- for (const [pattern, replacement] of HEADER_TRANSLITERATIONS)
245
- safe = safe.replace(pattern, replacement);
246
- safe = safe.replace(/[^\x20-\x7E\xA0-\xFF]/g, '').replace(/\s+/g, ' ').trim();
247
- // An input that was empty to begin with stays empty — `--meta note=` means an
248
- // empty note, not an unnamed one. The marker is only for a value that HAD
249
- // content and lost all of it to the latin1 fold.
250
- if (safe)
251
- return safe;
252
- return text.trim() ? '(unnamed)' : '';
253
- }
254
- /**
255
- * Whether text carries a code point above latin1 (U+00FF) — the meaningful
256
- * display content `fetch`'s ByteString cannot hold, which {@link toHeaderValue}
257
- * therefore transliterates or drops. This is the range worth carrying in the
258
- * percent-encoded companion: an em dash, a curly quote, an emoji, or any
259
- * CJK/Arabic/Hindi text. A plain accented latin1 name (`José`, é = U+00E9) is
260
- * NOT lossy and needs no companion.
261
- *
262
- * `toHeaderValue` ALSO strips C0/C1 control characters (below U+0020, and
263
- * U+007F–U+009F), which this deliberately does not flag: a raw ANSI/control
264
- * sequence is not display text and must not be reconstructed into a page's
265
- * rendered metadata, so it stays dropped on both the old and new Worker paths.
266
- */
267
- export function needsUnicodeCompanion(text) {
268
- return /[^\u0000-\u00ff]/.test(text);
269
- }
270
- /**
271
- * Percent-encode a single-line free-text value for the `x-share-<field>-u`
272
- * companion header. Whitespace is collapsed first (matching the folded value's
273
- * single-line shape), then `encodeURIComponent` makes it pure-ASCII and
274
- * header-safe. The Worker recovers the original with `decodeURIComponent`.
275
- */
276
- export function toPercentHeaderValue(text) {
277
- return encodeURIComponent(text.replace(/\s+/g, ' ').trim());
278
- }
279
- /**
280
- * Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
281
- * Markdown frontmatter `title:`, else the filename. Always returns something —
282
- * a headless publish must never hang waiting on a prompt for one.
283
- */
284
- export function deriveLabel(filePath, body) {
285
- const text = body.toString('utf8');
286
- const htmlTitle = /<title[^>]*>([^<]{1,200})<\/title>/i.exec(text);
287
- if (htmlTitle?.[1]?.trim())
288
- return sanitizeLabel(htmlTitle[1]);
289
- const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
290
- if (frontmatter) {
291
- const titleLine = /^title:\s*(.+)$/m.exec(frontmatter[1]);
292
- const cleaned = titleLine?.[1]?.trim().replace(/^["']|["']$/g, '').trim();
293
- if (cleaned)
294
- return sanitizeLabel(cleaned);
295
- }
296
- const base = basename(filePath).replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
297
- return sanitizeLabel(base || basename(filePath));
298
- }
299
- /** Default auto-expire for unflagged publishes — accidental links decay (RUSH-2443). */
300
- export const DEFAULT_SHARE_EXPIRE = '30d';
301
- const UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 };
302
- /** `30d` / `12h` / `2026-08-01` → an absolute ISO timestamp (or undefined). */
303
- export function parseExpire(spec) {
304
- if (!spec)
305
- return undefined;
306
- const rel = /^(\d+)\s*([smhdw])$/i.exec(spec.trim());
307
- if (rel) {
308
- return new Date(Date.now() + parseInt(rel[1], 10) * UNIT_MS[rel[2].toLowerCase()]).toISOString();
309
- }
310
- const d = new Date(spec);
311
- if (!Number.isNaN(d.getTime()))
312
- return d.toISOString();
313
- throw new Error(`Bad --expire '${spec}'. Use e.g. 30d, 12h, an absolute date like 2026-08-01, or 'never' for no expiry.`);
314
- }
315
- /**
316
- * Resolve the publish expiry. Omitted → {@link DEFAULT_SHARE_EXPIRE}. Explicit
317
- * `never` / `none` / `permanent` → no expiry. Anything else → {@link parseExpire}.
318
- */
319
- export function resolveExpire(spec) {
320
- if (spec === undefined)
321
- return parseExpire(DEFAULT_SHARE_EXPIRE);
322
- const trimmed = spec.trim().toLowerCase();
323
- if (trimmed === 'never' || trimmed === 'none' || trimmed === 'permanent')
324
- return undefined;
325
- return parseExpire(spec);
326
- }
327
- /** Email addresses — the RUSH-2428 incident page carried seven of these. */
328
- const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
329
- /**
330
- * Mask every email address in `text`, so a page can be published without tripping
331
- * {@link scanShareContent}.
332
- *
333
- * Lives here, beside the scanner, rather than in `lib/redact.ts`: it exists solely
334
- * to satisfy this gate, and sharing {@link EMAIL_RE} is what makes the masking
335
- * *sufficient* to clear it rather than merely reducing the hit count. Two copies of
336
- * the pattern in two modules would drift, and the failure mode of that drift is a
337
- * refused publish at runtime.
338
- *
339
- * Deliberately NOT part of `redactSecrets`: an email is not a credential, and a
340
- * transcript rendered for a private gist or local review reads better with the real
341
- * author addresses intact. It only becomes a leak once the text is PUBLISHED — a
342
- * world-readable page carrying seven of them is the RUSH-2428 incident.
343
- *
344
- * The whole address goes, domain included — a personal domain identifies its owner
345
- * as surely as the local part does, and keeping it would buy the reader nothing.
346
- */
347
- export function redactEmails(text) {
348
- return text.replace(EMAIL_RE, '[EMAIL]');
349
- }
350
- /**
351
- * Credential-shaped strings that an agent routinely dumps into reports: GitHub
352
- * PATs, OpenAI/Anthropic/etc. API keys, AWS access keys, Slack tokens, and a
353
- * generic long `sk-…` / `Bearer …` form. Keep the set tight — false positives
354
- * force `--force` and train agents to bypass the gate.
355
- */
356
- const CREDENTIAL_PATTERNS = [
357
- { re: /\bghp_[A-Za-z0-9_]{20,}\b/g, label: 'ghp_…' },
358
- { re: /\bgho_[A-Za-z0-9_]{20,}\b/g, label: 'gho_…' },
359
- { re: /\bghu_[A-Za-z0-9_]{20,}\b/g, label: 'ghu_…' },
360
- { re: /\bghs_[A-Za-z0-9_]{20,}\b/g, label: 'ghs_…' },
361
- { re: /\bghr_[A-Za-z0-9_]{20,}\b/g, label: 'ghr_…' },
362
- { re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, label: 'github_pat_…' },
363
- { re: /\bsk-(?:ant|proj|live|test)?[_-]?[A-Za-z0-9]{16,}\b/g, label: 'sk-…' },
364
- { re: /\bAKIA[0-9A-Z]{16}\b/g, label: 'AKIA…' },
365
- { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, label: 'xox…' },
366
- { re: /\bBearer\s+[A-Za-z0-9._\-+/=]{20,}\b/gi, label: 'Bearer …' },
367
- ];
368
- function redactSample(raw, max = 12) {
369
- if (raw.length <= max)
370
- return raw.slice(0, 4) + '…';
371
- return raw.slice(0, Math.min(6, max)) + '…';
372
- }
373
- /**
374
- * Scan a text body for email addresses and credential-shaped strings. Returns
375
- * the first few hits (deduped by kind+sample). Binary / non-text bodies yield
376
- * nothing — the gate is for HTML/text reports, not screenshots.
377
- */
378
- export function scanShareContent(body) {
379
- // Skip clearly-binary content (null bytes in the first 1KB) so a PNG/MP4
380
- // publish never false-positives on binary noise.
381
- if (Buffer.isBuffer(body)) {
382
- const head = body.subarray(0, Math.min(body.length, 1024));
383
- if (head.includes(0))
384
- return [];
385
- }
386
- const text = typeof body === 'string' ? body : body.toString('utf8');
387
- const hits = [];
388
- const seen = new Set();
389
- for (const m of text.matchAll(EMAIL_RE)) {
390
- const sample = redactSample(m[0]);
391
- const key = `email:${sample}`;
392
- if (seen.has(key))
393
- continue;
394
- seen.add(key);
395
- hits.push({ kind: 'email', sample });
396
- if (hits.length >= 5)
397
- return hits;
398
- }
399
- for (const { re, label } of CREDENTIAL_PATTERNS) {
400
- re.lastIndex = 0;
401
- const m = re.exec(text);
402
- if (!m)
403
- continue;
404
- const sample = label;
405
- const key = `credential:${sample}`;
406
- if (seen.has(key))
407
- continue;
408
- seen.add(key);
409
- hits.push({ kind: 'credential', sample });
410
- if (hits.length >= 5)
411
- return hits;
412
- }
413
- return hits;
414
- }
415
- /** Build the refuse message when the pre-publish scan finds sensitive content. */
416
- export function formatSensitiveContentError(hits) {
417
- const kinds = Array.from(new Set(hits.map((h) => h.kind)));
418
- const samples = hits.map((h) => h.sample).join(', ');
419
- const what = kinds.length === 2
420
- ? 'email addresses and credential-shaped strings'
421
- : kinds[0] === 'email'
422
- ? 'email addresses'
423
- : 'credential-shaped strings';
424
- return (`Refusing to publish: found ${what} (${samples}) in the file, label, or metadata. ` +
425
- `Shares are world-readable by URL — pass --force to publish anyway, ` +
426
- `or --unlisted --expire 12h to bound the blast radius.`);
427
- }
428
- /** Normalize artifact title text into a URL-safe slug. */
429
- function normalizeSlug(name) {
430
- return (name.toLowerCase()
431
- .replace(/[^a-z0-9]+/g, '-')
432
- .replace(/^-+|-+$/g, '')
433
- .slice(0, 60) || 'page');
434
- }
435
- /** Derive a URL-safe slug from a filename, stripping its path and extension. */
436
- export function slugify(name) {
437
- return normalizeSlug(basename(name).replace(/\.[^.]+$/, ''));
438
- }
439
- function sanitizeSlugPart(s) {
440
- return sanitizeShareNamespace(s);
441
- }
442
- /** The current repo's name, or undefined outside a git checkout — never a
443
- * fallback guess, since callers that want one (detectProject) supply it themselves. */
444
- function gitRepoName(dir) {
445
- try {
446
- const top = execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
447
- stdio: ['ignore', 'pipe', 'ignore'],
448
- })
449
- .toString()
450
- .trim();
451
- if (top)
452
- return sanitizeSlugPart(basename(top)) || undefined;
453
- }
454
- catch {
455
- // not a git repo
456
- }
457
- return undefined;
458
- }
459
- /** The project the file belongs to — git repo name, else the cwd's basename. */
460
- export function detectProject(dir = process.cwd()) {
461
- return gitRepoName(dir) ?? (sanitizeSlugPart(basename(dir)) || 'share');
462
- }
463
- /**
464
- * Stable default slug for an artifact: prefer its HTML `<title>` or Markdown
465
- * frontmatter `title:`, then fall back to its filename. The same artifact title
466
- * always yields the same slug, so republishing without `--slug` updates the same
467
- * URL. `--slug` remains an exact override.
468
- */
469
- export function defaultSlug(filePath, body) {
470
- return body ? normalizeSlug(deriveLabel(filePath, body)) : slugify(filePath);
471
- }
472
- function guessContentType(filePath) {
473
- if (/\.html?$/i.test(filePath))
474
- return 'text/html; charset=utf-8';
475
- if (/\.css$/i.test(filePath))
476
- return 'text/css; charset=utf-8';
477
- if (/\.js$/i.test(filePath))
478
- return 'text/javascript; charset=utf-8';
479
- if (/\.json$/i.test(filePath))
480
- return 'application/json';
481
- if (/\.svg$/i.test(filePath))
482
- return 'image/svg+xml';
483
- // Raster images + video: agents publish screenshots and screen recordings as PR
484
- // evidence, and GitHub's image proxy (camo) only renders an inline `![](url)` when
485
- // the asset is served with a real image/video content-type — octet-stream is
486
- // refused. Type them so the share URL embeds instead of downloading.
487
- if (/\.png$/i.test(filePath))
488
- return 'image/png';
489
- if (/\.jpe?g$/i.test(filePath))
490
- return 'image/jpeg';
491
- if (/\.gif$/i.test(filePath))
492
- return 'image/gif';
493
- if (/\.webp$/i.test(filePath))
494
- return 'image/webp';
495
- if (/\.avif$/i.test(filePath))
496
- return 'image/avif';
497
- if (/\.ico$/i.test(filePath))
498
- return 'image/x-icon';
499
- if (/\.mp4$/i.test(filePath))
500
- return 'video/mp4';
501
- if (/\.mov$/i.test(filePath))
502
- return 'video/quicktime';
503
- if (/\.webm$/i.test(filePath))
504
- return 'video/webm';
505
- if (/\.pdf$/i.test(filePath))
506
- return 'application/pdf';
507
- if (/\.txt$|\.md$/i.test(filePath))
508
- return 'text/plain; charset=utf-8';
509
- return 'application/octet-stream';
510
- }
511
- /**
512
- * Best-effort OG cover: capture a screenshot, upload it as `<slug>.png`, and return
513
- * the page body with og:image meta injected (+ the cover URL). All IO is injected
514
- * (`put`, `capturer`), so this whole path is unit-testable without config/keychain.
515
- * Any miss — no capturer output, a failed upload — returns the original body and no
516
- * coverUrl, so publishing never fails because a cover couldn't be made.
517
- */
518
- export async function attachOgCover(filePath, body, ctx) {
519
- const png = await ctx.capturer(filePath).catch(() => null);
520
- if (!png)
521
- return { body };
522
- const cr = await ctx.put(ctx.pngUrl, png, ctx.pngHeaders);
523
- if (!cr.ok)
524
- return { body };
525
- const { title, description } = deriveMeta(body.toString('utf8'));
526
- const injected = injectOgMeta(body.toString('utf8'), {
527
- title,
528
- description,
529
- imageUrl: ctx.pngUrl,
530
- pageUrl: ctx.pageUrl,
531
- imageWidth: OG_WIDTH * OG_SCALE,
532
- imageHeight: OG_HEIGHT * OG_SCALE,
533
- });
534
- return { body: Buffer.from(injected, 'utf8'), coverUrl: ctx.pngUrl };
535
- }
536
- /** Resolve the publisher's GitHub username, with an explicit override winning first. */
537
- export async function resolveShareUsername(opts = {}) {
538
- if (opts.githubUser) {
539
- const sanitized = sanitizeSlugPart(opts.githubUser);
540
- if (sanitized)
541
- return sanitized;
542
- }
543
- const resolved = await resolveGitHubUsername();
544
- if (resolved)
545
- return sanitizeSlugPart(resolved);
546
- throw new Error("Could not determine your GitHub username for the share URL namespace. " +
547
- "Authenticate with `gh auth login`, set `git config --global github.user <user>`, " +
548
- "or pass `--github-user <user>`.");
549
- }
550
- /** Build the R2 object key from a namespace username and a slug part. */
551
- export function buildShareKey(username, slugPart) {
552
- const user = sanitizeSlugPart(username);
553
- const part = sanitizeSlugPart(slugPart.replace(/\//g, '-'));
554
- if (!user)
555
- throw new Error('GitHub username is required for the share URL namespace.');
556
- if (!part)
557
- throw new Error('Share slug is empty.');
558
- return `${user}/${part}`;
559
- }
560
- export async function publishFile(filePath, opts = {}) {
561
- const backend = resolveShareBackend(opts);
562
- const managedHandle = backend.kind === 'managed' ? requireManagedHandle(opts.handle) : '';
563
- const username = backend.kind === 'managed'
564
- ? managedHandle || backend.namespace
565
- : await resolveShareUsername({ githubUser: opts.githubUser || backend.namespace || undefined });
566
- const analyticsToken = opts.analyticsToken ?? (backend.kind === 'byo' ? (opts.config ?? readShareConfig())?.analyticsToken : undefined);
567
- return publishToEndpoint(filePath, { baseUrl: backend.baseUrl, token: backend.token }, {
568
- ...opts,
569
- githubUser: username,
570
- analyticsToken,
571
- backendKind: backend.kind,
572
- });
573
- }
574
- /** Sanitize a caller-chosen managed handle to the Worker's namespace shape.
575
- * Returns '' when the result is empty or over the Worker's 63-char cap (the
576
- * caller then falls back to the derived handle / errors). */
577
- export function resolveManagedHandle(handle) {
578
- if (!handle)
579
- return '';
580
- const sanitized = sanitizeShareNamespace(handle);
581
- return sanitized && sanitized.length <= 63 ? sanitized : '';
582
- }
583
- function requireManagedHandle(handle) {
584
- const resolved = resolveManagedHandle(handle);
585
- if (handle && !resolved) {
586
- throw new Error(`Invalid --handle '${handle}': must sanitize to 1-63 [a-z0-9-] characters`);
587
- }
588
- return resolved;
589
- }
590
- export async function publishToEndpoint(filePath, endpoint, opts = {}) {
591
- // A managed publish may carry an explicit --handle: it namespaces the URL and
592
- // rides the x-share-handle header so the Worker binds the claim to it (a
593
- // derived handle is proven by the email; an alternate one must be declared).
594
- const managedHandle = opts.backendKind === 'managed' ? requireManagedHandle(opts.handle) : '';
595
- const username = managedHandle || (await resolveShareUsername(opts));
596
- let body = readFileSync(filePath);
597
- const expiresAt = resolveExpire(opts.expire);
598
- const visibility = resolveShareVisibility(opts);
599
- const unlisted = visibility === 'unlisted';
600
- // A capability-URL publish (unlisted / token-gated private) must not have a
601
- // guessable slug — that was the PHNX-3654 hole. Without an explicit --slug the
602
- // slug always carries a 64-bit random tail (a title-derived prefix may lead it,
603
- // but the random suffix is what makes the whole URL unguessable); an explicit
604
- // --slug is the caller's own choice (e.g. republishing to a known URL) and is
605
- // honored verbatim.
606
- const capabilityUrl = visibility === 'unlisted' || visibility === 'private';
607
- const explicitSlug = typeof opts.slug === 'string' && opts.slug.trim() !== '';
608
- const slugPart = (explicitSlug
609
- ? opts.slug
610
- : capabilityUrl
611
- ? `${defaultSlug(filePath, body)}-${randomSlugTail()}`
612
- : defaultSlug(filePath, body)).replace(/^\/+/, '');
613
- const key = buildShareKey(username, slugPart);
614
- // Token-gated read auth (PHNX-3654): mint a random viewer token for a private
615
- // publish. Only its hash is sent to the Worker; the raw token rides in ?k=.
616
- const viewerToken = visibility === 'private' ? generateViewerToken() : undefined;
617
- const pageUrl = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
618
- const provenance = opts.provenance ?? resolveShareProvenance();
619
- const avatarUrl = opts.avatar ?? resolveShareAvatar({ session: opts.session });
620
- const meta = opts.meta ?? {};
621
- const put = opts.uploader ??
622
- (async (u, b, h) => {
623
- const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
624
- // Read the error body only on failure, so the caller can surface the
625
- // Worker's own `{"error":"…"}` + Retry-After (bounded by extractShareHttpError).
626
- if (res.ok)
627
- return { ok: true, status: res.status, url: u };
628
- const body = await res.text().catch(() => undefined);
629
- return { ok: false, status: res.status, url: u, body, retryAfter: res.headers.get('retry-after') ?? undefined };
630
- });
631
- let coverUrl;
632
- const isHtml = /\.html?$/i.test(filePath);
633
- if (isHtml) {
634
- body = Buffer.from(prepareShareHtml(body.toString('utf8'), filePath), 'utf8');
635
- }
636
- const explicitLabel = opts.label?.trim();
637
- // sanitizeLabel here (not just inside deriveLabel) covers an explicit
638
- // --label/--title the caller typed with an embedded newline — deriveLabel
639
- // is only reached when --label is omitted.
640
- const label = explicitLabel ? sanitizeLabel(explicitLabel) : deriveLabel(filePath, body);
641
- const labelSource = explicitLabel ? 'explicit' : 'derived';
642
- const ogMeta = isHtml ? deriveMeta(body.toString('utf8')) : undefined;
643
- // The managed Worker owns deterministic OG generation. Point crawlers at the
644
- // lazy sibling route without invoking a browser on the publishing machine.
645
- if (isHtml && opts.cover !== false && opts.backendKind === 'managed' && ogMeta) {
646
- coverUrl = `${pageUrl}.png`;
647
- body = Buffer.from(injectOgMeta(body.toString('utf8'), {
648
- ...ogMeta,
649
- imageUrl: coverUrl,
650
- pageUrl,
651
- imageWidth: OG_WIDTH,
652
- imageHeight: OG_HEIGHT,
653
- }), 'utf8');
654
- }
655
- // Pre-publish scan (RUSH-2443/RUSH-2683): refuse emails / credential-shaped
656
- // strings unless --force. Runs on the raw file body AND on every piece of
657
- // free-text metadata that lands in public customMetadata — --label (explicit
658
- // or derived) and every --meta value. Metadata is visible in the gallery,
659
- // `share list --list-json`, and `share revisions` just like the page itself, so a
660
- // credential smuggled in there is exactly as exposed as one in the body; it
661
- // must not have a free pass around this gate. Runs before analytics/cover
662
- // mutation so a beacon injection never triggers a false positive on the body
663
- // scan. Binary media bodies are a no-op for the body scan.
664
- if (opts.force !== true) {
665
- const hits = [
666
- ...scanShareContent(body),
667
- ...scanShareContent(label),
668
- ...Object.values(meta).flatMap((v) => scanShareContent(v)),
669
- ];
670
- if (hits.length > 0) {
671
- throw new Error(formatSensitiveContentError(hits));
672
- }
673
- }
674
- // Validate the FULL customMetadata payload before any network call — fail
675
- // fast, not mid-upload.
676
- const metadataPreview = { ...meta, label, 'label-source': labelSource };
677
- if (ogMeta && opts.backendKind === 'managed') {
678
- metadataPreview['og-title'] = ogMeta.title;
679
- metadataPreview['og-description'] = ogMeta.description;
680
- }
681
- if (provenance.agent)
682
- metadataPreview.agent = provenance.agent;
683
- if (provenance.session)
684
- metadataPreview.session = provenance.session;
685
- if (provenance.host)
686
- metadataPreview.host = provenance.host;
687
- if (provenance.repo)
688
- metadataPreview.repo = provenance.repo;
689
- if (provenance.date)
690
- metadataPreview.date = provenance.date;
691
- if (avatarUrl)
692
- metadataPreview.avatar = avatarUrl;
693
- assertMetadataSize(metadataPreview);
694
- const authHeaders = (contentType) => {
695
- const h = { authorization: `Bearer ${endpoint.token}`, 'content-type': contentType };
696
- if (managedHandle)
697
- h['x-share-handle'] = managedHandle;
698
- if (expiresAt)
699
- h['x-share-expires-at'] = expiresAt;
700
- h['x-share-visibility'] = visibility;
701
- // Token-gated read auth (PHNX-3654): send the RAW viewer token. The Worker
702
- // hashes it (SHA-256) and stores only the hash in customMetadata, so the
703
- // secret never lands in object metadata. Only present for a private publish.
704
- if (viewerToken)
705
- h['x-share-viewer-token'] = viewerToken;
706
- // Two headers per free-text field, backward-compatible by construction
707
- // (PHNX-2786): `x-share-<field>` always carries the latin1-safe folded value
708
- // an already-deployed Worker reads verbatim, and — only when the fold is lossy
709
- // (a curly quote, em dash, emoji, CJK/Arabic/Hindi) — a percent-encoded
710
- // `x-share-<field>-u` companion carries the full Unicode. A new Worker opts
711
- // into the companions via `x-share-encoding: percent`; an old one ignores the
712
- // unknown headers and keeps folding gracefully. The floor also keeps the
713
- // ByteString crash fixed: a non-latin1 code point never reaches a raw header.
714
- let unicodeCompanion = false;
715
- const setText = (name, value) => {
716
- h[name] = toHeaderValue(value);
717
- if (needsUnicodeCompanion(value)) {
718
- h[`${name}-u`] = toPercentHeaderValue(value);
719
- unicodeCompanion = true;
720
- }
721
- };
722
- if (provenance.agent)
723
- setText('x-share-agent', provenance.agent);
724
- if (provenance.session)
725
- setText('x-share-session', provenance.session);
726
- if (provenance.host)
727
- setText('x-share-host', provenance.host);
728
- if (provenance.repo)
729
- setText('x-share-repo', provenance.repo);
730
- if (provenance.date)
731
- setText('x-share-date', provenance.date);
732
- if (avatarUrl)
733
- setText('x-share-avatar', avatarUrl);
734
- setText('x-share-label', label);
735
- h['x-share-label-source'] = labelSource;
736
- if (ogMeta && opts.backendKind === 'managed') {
737
- setText('x-share-og-title', ogMeta.title);
738
- setText('x-share-og-description', ogMeta.description);
739
- }
740
- // Per VALUE, before JSON.stringify — folding the serialized form would rewrite
741
- // a curly quote inside a value into a bare `"`, which is structural in JSON and
742
- // makes the Worker's JSON.parse throw. It swallows that error, so every --meta
743
- // key would silently vanish on a 200. The companion carries the whole raw meta
744
- // object percent-encoded once, so a new Worker recovers full-Unicode keys AND
745
- // values in one JSON.parse rather than per-field.
746
- if (Object.keys(meta).length > 0) {
747
- const headerMeta = Object.fromEntries(Object.entries(meta).map(([k, v]) => [toHeaderValue(k), toHeaderValue(v)]));
748
- h['x-share-meta'] = JSON.stringify(headerMeta);
749
- if (Object.entries(meta).some(([k, v]) => needsUnicodeCompanion(k) || needsUnicodeCompanion(v))) {
750
- h['x-share-meta-u'] = encodeURIComponent(JSON.stringify(meta));
751
- unicodeCompanion = true;
752
- }
753
- }
754
- if (unicodeCompanion)
755
- h['x-share-encoding'] = 'percent';
756
- if (opts.noRevision)
757
- h['x-share-no-revision'] = '1';
758
- return h;
759
- };
760
- // Analytics: cookieless CF Web Analytics beacon, injected for HTML by default.
761
- if (isHtml && opts.analytics !== false && opts.analyticsToken) {
762
- body = Buffer.from(injectAnalyticsBeacon(body.toString('utf8'), opts.analyticsToken), 'utf8');
763
- }
764
- // Cover: screenshot the page's hero → upload <slug>.png → inject og:image meta.
765
- // Unlisted pages still get a cover (the direct URL is the capability), but the
766
- // cover inherits visibility=unlisted so it is also omitted from the gallery.
767
- if (isHtml && opts.cover !== false && opts.backendKind !== 'managed') {
768
- const res = await attachOgCover(filePath, body, {
769
- pngUrl: `${pageUrl}.png`,
770
- pageUrl,
771
- put,
772
- pngHeaders: authHeaders('image/png'),
773
- capturer: opts.capturer ?? captureCover,
774
- });
775
- body = res.body;
776
- coverUrl = res.coverUrl;
777
- }
778
- const r = await put(pageUrl, body, authHeaders(opts.contentType ?? guessContentType(filePath)));
779
- if (!r.ok) {
780
- // 409 has two distinct shapes on the managed Worker: 'handle taken' (the
781
- // caller's namespace belongs to another account) and 'publish conflict'
782
- // (a concurrent write won the republish race — retry, per PHNX-3547).
783
- const httpError = extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter });
784
- if (r.status === 409 && httpError.serverMessage === 'handle taken' && opts.backendKind === 'managed') {
785
- throw new Error(`Handle '${username}' is already claimed by another account. ` +
786
- 'If you signed in again and got a new account id, republishing with the same email re-binds your handle automatically; ' +
787
- 'otherwise pick a different public namespace with --handle <name>.');
788
- }
789
- const detail = formatShareHttpErrorDetail(httpError);
790
- // The write-token/setup advice is only meaningful for an auth failure — a
791
- // 413 (quota/size) or 429 (rate) rejection has nothing to do with the
792
- // token, and 'Check the write token' there is plain wrong (PHNX-3579).
793
- // Managed endpoints carry a Phoenix bearer, so the recovery is re-login.
794
- let advice = '';
795
- if (r.status === 401 || r.status === 403) {
796
- advice =
797
- opts.backendKind === 'managed'
798
- ? ". Check that you're signed in — run 'agents auth login'."
799
- : ". Check the write token, or that 'agents artifacts setup' completed.";
800
- }
801
- throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}${advice}`);
802
- }
803
- // A token-gated page is only reachable WITH its key, so the URL we hand back
804
- // (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
805
- const baseUrl = r.url ?? pageUrl;
806
- const url = viewerToken ? `${baseUrl}?k=${encodeURIComponent(viewerToken)}` : baseUrl;
807
- return {
808
- url,
809
- slug: key.slice(key.indexOf('/') + 1),
810
- expiresAt,
811
- coverUrl,
812
- label,
813
- labelSource,
814
- visibility,
815
- ...(unlisted ? { unlisted: true } : {}),
816
- ...(viewerToken ? { viewerToken } : {}),
817
- };
818
- }