@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.
- package/CHANGELOG.md +110 -0
- package/README.md +22 -102
- package/dist/browser.js +0 -0
- package/dist/cli/command-registry.js +0 -7
- package/dist/commands/accounts.d.ts +3 -77
- package/dist/commands/accounts.js +13 -366
- package/dist/commands/audit.js +1 -1
- package/dist/commands/auth.js +0 -2
- package/dist/commands/browser.d.ts +53 -0
- package/dist/commands/browser.js +199 -9
- package/dist/commands/doctor.d.ts +2 -3
- package/dist/commands/doctor.js +0 -11
- package/dist/commands/events.d.ts +1 -3
- package/dist/commands/events.js +4 -7
- package/dist/commands/exec.d.ts +0 -1
- package/dist/commands/exec.js +2 -7
- package/dist/commands/feed-watch.js +52 -8
- package/dist/commands/focus.js +1 -1
- package/dist/commands/go.d.ts +0 -17
- package/dist/commands/go.js +2 -19
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mcp.js +8 -83
- package/dist/commands/memory.js +4 -47
- package/dist/commands/message.js +4 -4
- package/dist/commands/plugins.js +1 -93
- package/dist/commands/repo.js +0 -44
- package/dist/commands/resume.js +4 -1
- package/dist/commands/secrets-passthrough.js +2 -2
- package/dist/commands/send.d.ts +4 -4
- package/dist/commands/send.js +6 -51
- package/dist/commands/sessions-backup-setup.js +1 -1
- package/dist/commands/sessions-resume.js +0 -1
- package/dist/commands/sessions-share.d.ts +5 -7
- package/dist/commands/sessions-share.js +98 -49
- package/dist/commands/sessions.js +1 -8
- package/dist/commands/setup-browser.js +18 -2
- package/dist/commands/setup-computer.js +20 -6
- package/dist/commands/setup-secrets.js +24 -4
- package/dist/commands/setup-terminal.d.ts +3 -0
- package/dist/commands/setup-terminal.js +22 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +20 -10
- package/dist/commands/skills.js +0 -8
- package/dist/commands/ssh.d.ts +6 -0
- package/dist/commands/ssh.js +92 -233
- package/dist/commands/sync.js +14 -5
- package/dist/commands/teams.js +1 -1
- package/dist/commands/traces.js +1 -1
- package/dist/lib/accounts/add.d.ts +0 -5
- package/dist/lib/accounts/add.js +1 -7
- package/dist/lib/artifacts-client.d.ts +20 -0
- package/dist/lib/artifacts-client.js +46 -0
- package/dist/lib/auth-mint.js +2 -2
- package/dist/lib/browser/runtime-state.d.ts +55 -0
- package/dist/lib/browser/runtime-state.js +99 -18
- package/dist/lib/browser/service.js +21 -1
- package/dist/lib/cli-resources.js +3 -1
- package/dist/lib/cloud/dispatch.js +1 -1
- package/dist/lib/cloudflare/creds.d.ts +10 -0
- package/dist/lib/cloudflare/creds.js +46 -0
- package/dist/lib/cloudflare/provision.d.ts +35 -0
- package/dist/lib/cloudflare/provision.js +144 -0
- package/dist/lib/computer/sessions-list.d.ts +55 -0
- package/dist/lib/computer/sessions-list.js +168 -1
- package/dist/lib/daemon/daemon.js +8 -1
- package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
- package/dist/lib/daemon/feed-stream-service.js +40 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/devices/connect.d.ts +49 -5
- package/dist/lib/devices/connect.js +169 -21
- package/dist/lib/feed/envelope.d.ts +79 -0
- package/dist/lib/feed/envelope.js +23 -0
- package/dist/lib/feed/events.d.ts +6 -0
- package/dist/lib/feed/events.js +8 -0
- package/dist/lib/feed/hub-server.d.ts +86 -0
- package/dist/lib/feed/hub-server.js +334 -0
- package/dist/lib/feed/hub.d.ts +95 -0
- package/dist/lib/feed/hub.js +255 -0
- package/dist/lib/feed/tool-activity.d.ts +108 -0
- package/dist/lib/feed/tool-activity.js +313 -0
- package/dist/lib/feed/tools.d.ts +198 -0
- package/dist/lib/feed/tools.js +265 -0
- package/dist/lib/feed/watch.d.ts +50 -50
- package/dist/lib/feed/watch.js +147 -16
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/git.d.ts +0 -16
- package/dist/lib/git.js +0 -58
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hosts/remote-cmd.d.ts +51 -1
- package/dist/lib/hosts/remote-cmd.js +125 -8
- package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
- package/dist/lib/mcp.js +17 -11
- package/dist/lib/probe.d.ts +4 -1
- package/dist/lib/probe.js +5 -2
- package/dist/lib/pwsh.d.ts +33 -0
- package/dist/lib/pwsh.js +56 -0
- package/dist/lib/redact.d.ts +8 -0
- package/dist/lib/redact.js +11 -0
- package/dist/lib/refresh.d.ts +6 -2
- package/dist/lib/refresh.js +92 -72
- package/dist/lib/secrets-cli.d.ts +11 -0
- package/dist/lib/secrets-cli.js +30 -0
- package/dist/lib/secrets-client.js +3 -2
- package/dist/lib/session/detached.d.ts +7 -0
- package/dist/lib/session/detached.js +29 -0
- package/dist/lib/session/remote/peer-stream.d.ts +24 -2
- package/dist/lib/session/remote/peer-stream.js +33 -6
- package/dist/lib/session/sync/backend.d.ts +3 -3
- package/dist/lib/session/sync/backend.js +3 -3
- package/dist/lib/session/sync/provision.d.ts +1 -1
- package/dist/lib/session/sync/provision.js +2 -2
- package/dist/lib/sessions-client.js +0 -3
- package/dist/lib/setup-tool-install.d.ts +3 -0
- package/dist/lib/setup-tool-install.js +26 -0
- package/dist/lib/setup-tool-status.d.ts +22 -0
- package/dist/lib/setup-tool-status.js +215 -0
- package/dist/lib/share-runtime.d.ts +11 -0
- package/dist/lib/share-runtime.js +63 -0
- package/dist/lib/smart-launch.d.ts +1 -5
- package/dist/lib/smart-launch.js +3 -11
- package/dist/lib/ssh-exec.d.ts +44 -0
- package/dist/lib/ssh-exec.js +119 -0
- package/dist/lib/startup/command-registry.d.ts +6 -4
- package/dist/lib/startup/command-registry.js +10 -7
- package/dist/lib/state.js +2 -2
- package/dist/lib/storage/selection.d.ts +2 -2
- package/dist/lib/storage/selection.js +2 -2
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +18 -10
- package/dist/lib/traces/backend.d.ts +1 -2
- package/dist/lib/traces/backend.js +1 -2
- package/dist/lib/traces/provision.d.ts +1 -1
- package/dist/lib/traces/provision.js +2 -2
- package/dist/lib/types.d.ts +8 -6
- package/package.json +2 -3
- package/dist/commands/artifacts-setup.d.ts +0 -53
- package/dist/commands/artifacts-setup.js +0 -161
- package/dist/commands/artifacts.d.ts +0 -18
- package/dist/commands/artifacts.js +0 -58
- package/dist/commands/attach.d.ts +0 -12
- package/dist/commands/attach.js +0 -86
- package/dist/commands/auth-mint.d.ts +0 -12
- package/dist/commands/auth-mint.js +0 -108
- package/dist/commands/reconnect.d.ts +0 -46
- package/dist/commands/reconnect.js +0 -115
- package/dist/commands/share.d.ts +0 -293
- package/dist/commands/share.js +0 -1424
- package/dist/lib/share/analytics.d.ts +0 -13
- package/dist/lib/share/analytics.js +0 -45
- package/dist/lib/share/backend.d.ts +0 -120
- package/dist/lib/share/backend.js +0 -176
- package/dist/lib/share/capture.d.ts +0 -31
- package/dist/lib/share/capture.js +0 -174
- package/dist/lib/share/config.d.ts +0 -72
- package/dist/lib/share/config.js +0 -211
- package/dist/lib/share/delete.d.ts +0 -123
- package/dist/lib/share/delete.js +0 -173
- package/dist/lib/share/html.d.ts +0 -20
- package/dist/lib/share/html.js +0 -88
- package/dist/lib/share/http-error.d.ts +0 -53
- package/dist/lib/share/http-error.js +0 -65
- package/dist/lib/share/og.d.ts +0 -26
- package/dist/lib/share/og.js +0 -84
- package/dist/lib/share/provision.d.ts +0 -127
- package/dist/lib/share/provision.js +0 -285
- package/dist/lib/share/publish.d.ts +0 -379
- package/dist/lib/share/publish.js +0 -818
- package/dist/lib/share/worker-template.d.ts +0 -27
- package/dist/lib/share/worker-template.js +0 -2424
- package/dist/lib/storage/index.d.ts +0 -14
- package/dist/lib/storage/index.js +0 -14
- package/dist/lib/storage/visibility.d.ts +0 -82
- 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 `` 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
|
-
}
|