@phnx-labs/agents-cli 1.20.65 → 1.20.67
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 +45 -0
- package/README.md +119 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.d.ts +48 -0
- package/dist/commands/exec.js +71 -0
- package/dist/commands/monitors.js +8 -0
- package/dist/commands/plugins.js +28 -7
- package/dist/commands/sessions-browser.d.ts +82 -0
- package/dist/commands/sessions-browser.js +320 -0
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +121 -4
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +150 -0
- package/dist/commands/ssh.js +9 -1
- package/dist/commands/teams.js +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +28 -0
- package/dist/lib/agents.js +76 -0
- package/dist/lib/devices/connect.d.ts +18 -1
- package/dist/lib/devices/connect.js +10 -2
- package/dist/lib/devices/known-hosts.d.ts +62 -0
- package/dist/lib/devices/known-hosts.js +137 -0
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +31 -6
- package/dist/lib/hosts/dispatch.js +20 -2
- package/dist/lib/hosts/remote-cmd.js +8 -4
- package/dist/lib/monitors/engine.js +4 -0
- package/dist/lib/monitors/sources/device.js +13 -3
- package/dist/lib/picker.d.ts +53 -0
- package/dist/lib/picker.js +214 -1
- package/dist/lib/plugins.d.ts +31 -1
- package/dist/lib/plugins.js +74 -13
- package/dist/lib/runner.js +6 -7
- package/dist/lib/secrets/agent.d.ts +35 -0
- package/dist/lib/secrets/agent.js +114 -55
- package/dist/lib/secrets/filestore.d.ts +9 -0
- package/dist/lib/secrets/filestore.js +21 -8
- package/dist/lib/secrets/index.d.ts +7 -0
- package/dist/lib/secrets/index.js +26 -6
- package/dist/lib/share/capture.d.ts +29 -0
- package/dist/lib/share/capture.js +140 -0
- package/dist/lib/share/config.d.ts +35 -0
- package/dist/lib/share/config.js +100 -0
- package/dist/lib/share/og.d.ts +25 -0
- package/dist/lib/share/og.js +84 -0
- package/dist/lib/share/provision.d.ts +10 -0
- package/dist/lib/share/provision.js +91 -0
- package/dist/lib/share/publish.d.ts +57 -0
- package/dist/lib/share/publish.js +145 -0
- package/dist/lib/share/worker-template.d.ts +2 -0
- package/dist/lib/share/worker-template.js +82 -0
- package/dist/lib/shims.d.ts +13 -0
- package/dist/lib/shims.js +42 -2
- package/dist/lib/ssh-exec.d.ts +24 -0
- package/dist/lib/ssh-exec.js +15 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Config + credential glue for `agents share`.
|
|
2
|
+
//
|
|
3
|
+
// - The endpoint config (base URL, account, worker/bucket names) lives in
|
|
4
|
+
// `agents.yaml` under `share:` (Meta.share) so it syncs fleet-wide via
|
|
5
|
+
// `agents repo push/pull`.
|
|
6
|
+
// - The raw write token lives in the `share` secrets bundle (keychain-backed,
|
|
7
|
+
// fleet-injectable) — never on disk in plaintext.
|
|
8
|
+
// - The Cloudflare API token (for provisioning) is read from the user's existing
|
|
9
|
+
// `cloudflare.com` bundle.
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { readMeta, updateMeta } from '../state.js';
|
|
12
|
+
import { bundleItemStore, bundlePolicy, isHeadlessSecretsContext, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
|
|
13
|
+
import { secretsKeychainItem } from '../secrets/index.js';
|
|
14
|
+
export const SHARE_BUNDLE = 'share';
|
|
15
|
+
export const SHARE_TOKEN_KEY = 'SHARE_WRITE_TOKEN';
|
|
16
|
+
export const DEFAULT_CF_BUNDLE = 'cloudflare.com';
|
|
17
|
+
export const DEFAULT_WORKER_NAME = 'agents-share';
|
|
18
|
+
export const DEFAULT_BUCKET_NAME = 'agents-share';
|
|
19
|
+
/** Read the persisted endpoint config, or null if `agents share setup`/`join` never ran. */
|
|
20
|
+
export function readShareConfig() {
|
|
21
|
+
const s = readMeta().share;
|
|
22
|
+
if (!s?.baseUrl || !s.accountId || !s.workerName || !s.bucketName)
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
baseUrl: s.baseUrl.replace(/\/+$/, ''),
|
|
26
|
+
accountId: s.accountId,
|
|
27
|
+
workerName: s.workerName,
|
|
28
|
+
bucketName: s.bucketName,
|
|
29
|
+
domain: s.domain,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Persist the endpoint config to `agents.yaml` (syncs across the fleet). */
|
|
33
|
+
export function writeShareConfig(cfg) {
|
|
34
|
+
updateMeta((meta) => ({ ...meta, share: { ...meta.share, ...cfg } }));
|
|
35
|
+
}
|
|
36
|
+
/** A fresh 32-byte hex write token. */
|
|
37
|
+
export function generateWriteToken() {
|
|
38
|
+
return randomBytes(32).toString('hex');
|
|
39
|
+
}
|
|
40
|
+
/** Persist the raw write token into the `share` secrets bundle (keychain-backed,
|
|
41
|
+
* fleet-injectable). Mirrors the add-key sequence in `commands/secrets.ts`. */
|
|
42
|
+
export function storeWriteToken(token) {
|
|
43
|
+
let bundle;
|
|
44
|
+
try {
|
|
45
|
+
bundle = readBundle(SHARE_BUNDLE);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
bundle = {
|
|
49
|
+
name: SHARE_BUNDLE,
|
|
50
|
+
description: 'agents share — write token for the R2 share endpoint',
|
|
51
|
+
vars: {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
|
|
55
|
+
store.set(secretsKeychainItem(bundle.name, SHARE_TOKEN_KEY), token);
|
|
56
|
+
bundle.vars[SHARE_TOKEN_KEY] = keychainRef(SHARE_TOKEN_KEY);
|
|
57
|
+
writeBundle(bundle);
|
|
58
|
+
}
|
|
59
|
+
/** Read the raw write token from the `share` secrets bundle. Throws with an
|
|
60
|
+
* actionable message if absent (run setup/join first). */
|
|
61
|
+
export function readWriteToken() {
|
|
62
|
+
const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
|
|
63
|
+
caller: 'share',
|
|
64
|
+
agentOnly: isHeadlessSecretsContext(),
|
|
65
|
+
});
|
|
66
|
+
const token = env[SHARE_TOKEN_KEY];
|
|
67
|
+
if (!token) {
|
|
68
|
+
throw new Error(`No ${SHARE_TOKEN_KEY} in the '${SHARE_BUNDLE}' secrets bundle. ` +
|
|
69
|
+
`Run 'agents share setup' (to provision your own endpoint) or 'agents share join' (to use an existing one).`);
|
|
70
|
+
}
|
|
71
|
+
return token;
|
|
72
|
+
}
|
|
73
|
+
/** Cloudflare API credentials for provisioning, read from `cloudflare.com` (or a
|
|
74
|
+
* user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
|
|
75
|
+
export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
|
|
76
|
+
// Explicit --token/--account bypass the bundle entirely (robust escape hatch).
|
|
77
|
+
if (override?.apiToken) {
|
|
78
|
+
return { apiToken: override.apiToken, accountId: override.accountId ?? '' };
|
|
79
|
+
}
|
|
80
|
+
const { env } = readAndResolveBundleEnv(bundle, {
|
|
81
|
+
caller: 'share',
|
|
82
|
+
agentOnly: isHeadlessSecretsContext(),
|
|
83
|
+
});
|
|
84
|
+
const find = (re) => {
|
|
85
|
+
for (const [k, v] of Object.entries(env))
|
|
86
|
+
if (re.test(k) && v)
|
|
87
|
+
return v;
|
|
88
|
+
return '';
|
|
89
|
+
};
|
|
90
|
+
const apiToken = env.CLOUDFLARE_API_TOKEN || env.CF_API_TOKEN || find(/API[_-]?TOKEN|(?:^|_)TOKEN$/i);
|
|
91
|
+
const accountId = override?.accountId || env.CLOUDFLARE_ACCOUNT_ID || env.CF_ACCOUNT_ID || find(/ACCOUNT[_-]?ID/i);
|
|
92
|
+
if (!apiToken) {
|
|
93
|
+
const keys = Object.keys(env);
|
|
94
|
+
throw new Error(`No Cloudflare API token in the '${bundle}' bundle ` +
|
|
95
|
+
`(keys present: ${keys.length ? keys.join(', ') : 'none'}). ` +
|
|
96
|
+
`Pass it directly with --token <t> [--account <id>], or add it: ` +
|
|
97
|
+
`agents secrets add ${bundle} CLOUDFLARE_API_TOKEN`);
|
|
98
|
+
}
|
|
99
|
+
return { apiToken, accountId };
|
|
100
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Graph / Twitter card meta for a shared HTML page.
|
|
3
|
+
*
|
|
4
|
+
* `deriveMeta` pulls a title + description out of the document (its `<title>`/`<h1>`
|
|
5
|
+
* and first real paragraph or existing `<meta name=description>`); `injectOgMeta`
|
|
6
|
+
* splices the social tags into `<head>` (idempotent — it strips any tags we
|
|
7
|
+
* previously added before re-inserting). Pure string work so it's unit-testable
|
|
8
|
+
* without a browser or network.
|
|
9
|
+
*/
|
|
10
|
+
export interface OgFields {
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
imageUrl: string;
|
|
14
|
+
pageUrl: string;
|
|
15
|
+
/** Actual pixel dimensions of the served image (defaults to the 1200×630 OG card). */
|
|
16
|
+
imageWidth?: number;
|
|
17
|
+
imageHeight?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Pull a sensible title + description out of a plan/document's HTML. */
|
|
20
|
+
export declare function deriveMeta(html: string, fallbackTitle?: string): {
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
/** Splice OG + Twitter tags into `<head>` (or prepend if there's no head). Idempotent. */
|
|
25
|
+
export declare function injectOgMeta(html: string, f: OgFields): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Graph / Twitter card meta for a shared HTML page.
|
|
3
|
+
*
|
|
4
|
+
* `deriveMeta` pulls a title + description out of the document (its `<title>`/`<h1>`
|
|
5
|
+
* and first real paragraph or existing `<meta name=description>`); `injectOgMeta`
|
|
6
|
+
* splices the social tags into `<head>` (idempotent — it strips any tags we
|
|
7
|
+
* previously added before re-inserting). Pure string work so it's unit-testable
|
|
8
|
+
* without a browser or network.
|
|
9
|
+
*/
|
|
10
|
+
const OG_MARK_OPEN = '<!-- agents-share:og -->';
|
|
11
|
+
const OG_MARK_CLOSE = '<!-- /agents-share:og -->';
|
|
12
|
+
function escapeAttr(s) {
|
|
13
|
+
return s
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>');
|
|
18
|
+
}
|
|
19
|
+
function stripTags(s) {
|
|
20
|
+
return s
|
|
21
|
+
.replace(/<[^>]+>/g, ' ')
|
|
22
|
+
.replace(/ /g, ' ')
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
/** Pull a sensible title + description out of a plan/document's HTML. */
|
|
27
|
+
export function deriveMeta(html, fallbackTitle = 'agents-cli') {
|
|
28
|
+
const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
29
|
+
const h1Match = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html);
|
|
30
|
+
const title = stripTags(titleMatch?.[1] ?? h1Match?.[1] ?? '') || fallbackTitle;
|
|
31
|
+
let description = '';
|
|
32
|
+
const metaDesc = /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i.exec(html);
|
|
33
|
+
if (metaDesc) {
|
|
34
|
+
description = metaDesc[1].trim();
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
for (const m of html.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
|
|
38
|
+
const t = stripTags(m[1]);
|
|
39
|
+
if (t.length > 40) {
|
|
40
|
+
description = t;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (description.length > 198)
|
|
46
|
+
description = description.slice(0, 197).trimEnd() + '…';
|
|
47
|
+
return { title, description };
|
|
48
|
+
}
|
|
49
|
+
/** Remove a previously-injected block so re-publishing doesn't stack duplicate tags. */
|
|
50
|
+
function stripPrevious(html) {
|
|
51
|
+
const re = new RegExp(`${OG_MARK_OPEN}[\\s\\S]*?${OG_MARK_CLOSE}\\n?`, 'g');
|
|
52
|
+
return html.replace(re, '');
|
|
53
|
+
}
|
|
54
|
+
/** Splice OG + Twitter tags into `<head>` (or prepend if there's no head). Idempotent. */
|
|
55
|
+
export function injectOgMeta(html, f) {
|
|
56
|
+
const cleaned = stripPrevious(html);
|
|
57
|
+
const t = escapeAttr(f.title);
|
|
58
|
+
const d = escapeAttr(f.description);
|
|
59
|
+
const w = f.imageWidth ?? 1200;
|
|
60
|
+
const h = f.imageHeight ?? 630;
|
|
61
|
+
const block = `${OG_MARK_OPEN}\n` +
|
|
62
|
+
`<meta property="og:type" content="website">\n` +
|
|
63
|
+
`<meta property="og:site_name" content="agents-cli">\n` +
|
|
64
|
+
`<meta property="og:title" content="${t}">\n` +
|
|
65
|
+
`<meta property="og:description" content="${d}">\n` +
|
|
66
|
+
`<meta property="og:url" content="${escapeAttr(f.pageUrl)}">\n` +
|
|
67
|
+
`<meta property="og:image" content="${escapeAttr(f.imageUrl)}">\n` +
|
|
68
|
+
`<meta property="og:image:width" content="${w}">\n` +
|
|
69
|
+
`<meta property="og:image:height" content="${h}">\n` +
|
|
70
|
+
`<meta name="twitter:card" content="summary_large_image">\n` +
|
|
71
|
+
`<meta name="twitter:title" content="${t}">\n` +
|
|
72
|
+
`<meta name="twitter:description" content="${d}">\n` +
|
|
73
|
+
`<meta name="twitter:image" content="${escapeAttr(f.imageUrl)}">\n` +
|
|
74
|
+
`${OG_MARK_CLOSE}\n`;
|
|
75
|
+
const headClose = cleaned.search(/<\/head>/i);
|
|
76
|
+
if (headClose !== -1)
|
|
77
|
+
return cleaned.slice(0, headClose) + block + cleaned.slice(headClose);
|
|
78
|
+
const headOpen = /<head[^>]*>/i.exec(cleaned);
|
|
79
|
+
if (headOpen) {
|
|
80
|
+
const at = headOpen.index + headOpen[0].length;
|
|
81
|
+
return cleaned.slice(0, at) + '\n' + block + cleaned.slice(at);
|
|
82
|
+
}
|
|
83
|
+
return block + cleaned;
|
|
84
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Create the R2 bucket (idempotent). */
|
|
2
|
+
export declare function createBucket(apiToken: string, accountId: string, name: string): Promise<void>;
|
|
3
|
+
/** Upload the module Worker with an R2 binding (`BUCKET`) + inline `WRITE_TOKEN` secret. */
|
|
4
|
+
export declare function deployWorker(apiToken: string, accountId: string, workerName: string, script: string, bucketName: string, writeToken: string): Promise<void>;
|
|
5
|
+
/** Enable the free `*.workers.dev` route for the script, and return the account subdomain. */
|
|
6
|
+
export declare function enableWorkersDev(apiToken: string, accountId: string, workerName: string): Promise<string>;
|
|
7
|
+
/** Resolve a zone id for a domain the token can see, or null if not owned/visible. */
|
|
8
|
+
export declare function findZoneId(apiToken: string, domain: string): Promise<string | null>;
|
|
9
|
+
/** Map a custom hostname (e.g. `share.agents-cli.sh`) to the Worker via Workers Custom Domains. */
|
|
10
|
+
export declare function addCustomDomain(apiToken: string, accountId: string, workerName: string, zoneId: string, hostname: string): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Cloudflare provisioning for `agents share setup` — plain `fetch` against the CF
|
|
2
|
+
// REST API (the repo has no CF wrapper). Creates the R2 bucket, uploads the Worker
|
|
3
|
+
// (with an R2 binding + the WRITE_TOKEN as an inline secret), enables the free
|
|
4
|
+
// `*.workers.dev` subdomain, and — when the token owns the zone — maps a custom domain.
|
|
5
|
+
const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
6
|
+
async function cf(apiToken, method, pathname, body, form) {
|
|
7
|
+
const headers = { authorization: `Bearer ${apiToken}` };
|
|
8
|
+
let payload;
|
|
9
|
+
if (form) {
|
|
10
|
+
payload = form; // fetch sets multipart boundary
|
|
11
|
+
}
|
|
12
|
+
else if (body !== undefined) {
|
|
13
|
+
headers['content-type'] = 'application/json';
|
|
14
|
+
payload = JSON.stringify(body);
|
|
15
|
+
}
|
|
16
|
+
const res = await fetch(`${CF_API}${pathname}`, { method, headers, body: payload });
|
|
17
|
+
const json = (await res.json().catch(() => ({})));
|
|
18
|
+
if (!res.ok || json.success === false) {
|
|
19
|
+
const msg = (json.errors ?? []).map((e) => `${e.code ?? ''} ${e.message ?? ''}`.trim()).join('; ') ||
|
|
20
|
+
res.statusText;
|
|
21
|
+
throw new Error(`Cloudflare ${method} ${pathname} failed (${res.status}): ${msg}`);
|
|
22
|
+
}
|
|
23
|
+
return json.result;
|
|
24
|
+
}
|
|
25
|
+
/** True if the CF error looks like "the thing already exists" (idempotent create). */
|
|
26
|
+
function isAlreadyExists(e) {
|
|
27
|
+
return /already exists|duplicate|10004|10014/i.test(String(e));
|
|
28
|
+
}
|
|
29
|
+
/** Create the R2 bucket (idempotent). */
|
|
30
|
+
export async function createBucket(apiToken, accountId, name) {
|
|
31
|
+
try {
|
|
32
|
+
await cf(apiToken, 'POST', `/accounts/${accountId}/r2/buckets`, { name });
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (!isAlreadyExists(e))
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Upload the module Worker with an R2 binding (`BUCKET`) + inline `WRITE_TOKEN` secret. */
|
|
40
|
+
export async function deployWorker(apiToken, accountId, workerName, script, bucketName, writeToken) {
|
|
41
|
+
const metadata = {
|
|
42
|
+
main_module: 'worker.js',
|
|
43
|
+
compatibility_date: '2024-11-06',
|
|
44
|
+
bindings: [
|
|
45
|
+
{ type: 'r2_bucket', name: 'BUCKET', bucket_name: bucketName },
|
|
46
|
+
{ type: 'secret_text', name: 'WRITE_TOKEN', text: writeToken },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.set('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
|
51
|
+
form.set('worker.js', new Blob([script], { type: 'application/javascript+module' }), 'worker.js');
|
|
52
|
+
await cf(apiToken, 'PUT', `/accounts/${accountId}/workers/scripts/${workerName}`, undefined, form);
|
|
53
|
+
}
|
|
54
|
+
/** Enable the free `*.workers.dev` route for the script, and return the account subdomain. */
|
|
55
|
+
export async function enableWorkersDev(apiToken, accountId, workerName) {
|
|
56
|
+
await cf(apiToken, 'POST', `/accounts/${accountId}/workers/scripts/${workerName}/subdomain`, {
|
|
57
|
+
enabled: true,
|
|
58
|
+
previews_enabled: false,
|
|
59
|
+
});
|
|
60
|
+
const sub = await cf(apiToken, 'GET', `/accounts/${accountId}/workers/subdomain`);
|
|
61
|
+
if (!sub?.subdomain) {
|
|
62
|
+
throw new Error('No workers.dev subdomain on this account yet — register one at dash.cloudflare.com → Workers → Subdomain, then re-run.');
|
|
63
|
+
}
|
|
64
|
+
return sub.subdomain;
|
|
65
|
+
}
|
|
66
|
+
/** Resolve a zone id for a domain the token can see, or null if not owned/visible. */
|
|
67
|
+
export async function findZoneId(apiToken, domain) {
|
|
68
|
+
// Try the exact name, then the registrable parent (share.agents-cli.sh -> agents-cli.sh).
|
|
69
|
+
const candidates = [domain, domain.split('.').slice(-2).join('.')];
|
|
70
|
+
for (const name of candidates) {
|
|
71
|
+
const zones = await cf(apiToken, 'GET', `/zones?name=${encodeURIComponent(name)}`).catch(() => []);
|
|
72
|
+
if (zones?.length)
|
|
73
|
+
return zones[0].id;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/** Map a custom hostname (e.g. `share.agents-cli.sh`) to the Worker via Workers Custom Domains. */
|
|
78
|
+
export async function addCustomDomain(apiToken, accountId, workerName, zoneId, hostname) {
|
|
79
|
+
try {
|
|
80
|
+
await cf(apiToken, 'PUT', `/accounts/${accountId}/workers/domains`, {
|
|
81
|
+
zone_id: zoneId,
|
|
82
|
+
hostname,
|
|
83
|
+
service: workerName,
|
|
84
|
+
environment: 'production',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (!isAlreadyExists(e))
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<{
|
|
2
|
+
ok: boolean;
|
|
3
|
+
status: number;
|
|
4
|
+
url?: string;
|
|
5
|
+
}>;
|
|
6
|
+
export interface PublishOptions {
|
|
7
|
+
slug?: string;
|
|
8
|
+
/** e.g. `30d`, `12h`, or an absolute date like `2026-08-01`. */
|
|
9
|
+
expire?: string;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
/** Generate + attach an OG cover for HTML pages (default true). */
|
|
12
|
+
cover?: boolean;
|
|
13
|
+
/** DI seam for tests — override the real HTTP PUT. */
|
|
14
|
+
uploader?: (url: string, body: Buffer, headers: Record<string, string>) => Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
status: number;
|
|
17
|
+
url?: string;
|
|
18
|
+
}>;
|
|
19
|
+
/** DI seam for tests — override cover capture (returns a PNG buffer or null). */
|
|
20
|
+
capturer?: (htmlPath: string) => Promise<Buffer | null>;
|
|
21
|
+
}
|
|
22
|
+
/** `30d` / `12h` / `2026-08-01` → an absolute ISO timestamp (or undefined). */
|
|
23
|
+
export declare function parseExpire(spec: string | undefined): string | undefined;
|
|
24
|
+
/** Derive a URL-safe slug from a filename (or pass one through). */
|
|
25
|
+
export declare function slugify(name: string): string;
|
|
26
|
+
/** The project the file belongs to — git repo name, else the cwd's basename. */
|
|
27
|
+
export declare function detectProject(dir?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Notion-style default slug: `<project>-<feature>-<6hex>`. Project scopes the link
|
|
30
|
+
* to the repo the agent is in; the random tail keeps it unguessable + collision-free.
|
|
31
|
+
* A leading `plan-` on the filename is dropped (it's redundant under the project).
|
|
32
|
+
*/
|
|
33
|
+
export declare function defaultSlug(filePath: string, dir?: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort OG cover: capture a screenshot, upload it as `<slug>.png`, and return
|
|
36
|
+
* the page body with og:image meta injected (+ the cover URL). All IO is injected
|
|
37
|
+
* (`put`, `capturer`), so this whole path is unit-testable without config/keychain.
|
|
38
|
+
* Any miss — no capturer output, a failed upload — returns the original body and no
|
|
39
|
+
* coverUrl, so publishing never fails because a cover couldn't be made.
|
|
40
|
+
*/
|
|
41
|
+
export declare function attachOgCover(filePath: string, body: Buffer, ctx: {
|
|
42
|
+
/** Absolute URL to PUT the cover to, `${pageUrl}.png`. Doubles as the cover URL. */
|
|
43
|
+
pngUrl: string;
|
|
44
|
+
pageUrl: string;
|
|
45
|
+
put: PutFn;
|
|
46
|
+
pngHeaders: Record<string, string>;
|
|
47
|
+
capturer: (p: string) => Promise<Buffer | null>;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
body: Buffer;
|
|
50
|
+
coverUrl?: string;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function publishFile(filePath: string, opts?: PublishOptions): Promise<{
|
|
53
|
+
url: string;
|
|
54
|
+
expiresAt?: string;
|
|
55
|
+
coverUrl?: string;
|
|
56
|
+
}>;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The publish path for `agents 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 { randomBytes } from 'node:crypto';
|
|
12
|
+
import { readShareConfig, readWriteToken } from './config.js';
|
|
13
|
+
import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
|
|
14
|
+
import { deriveMeta, injectOgMeta } from './og.js';
|
|
15
|
+
const UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 };
|
|
16
|
+
/** `30d` / `12h` / `2026-08-01` → an absolute ISO timestamp (or undefined). */
|
|
17
|
+
export function parseExpire(spec) {
|
|
18
|
+
if (!spec)
|
|
19
|
+
return undefined;
|
|
20
|
+
const rel = /^(\d+)\s*([smhdw])$/i.exec(spec.trim());
|
|
21
|
+
if (rel) {
|
|
22
|
+
return new Date(Date.now() + parseInt(rel[1], 10) * UNIT_MS[rel[2].toLowerCase()]).toISOString();
|
|
23
|
+
}
|
|
24
|
+
const d = new Date(spec);
|
|
25
|
+
if (!Number.isNaN(d.getTime()))
|
|
26
|
+
return d.toISOString();
|
|
27
|
+
throw new Error(`Bad --expire '${spec}'. Use e.g. 30d, 12h, or an absolute date like 2026-08-01.`);
|
|
28
|
+
}
|
|
29
|
+
/** Derive a URL-safe slug from a filename (or pass one through). */
|
|
30
|
+
export function slugify(name) {
|
|
31
|
+
return (basename(name)
|
|
32
|
+
.replace(/\.[^.]+$/, '')
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
35
|
+
.replace(/^-+|-+$/g, '')
|
|
36
|
+
.slice(0, 60) || 'page');
|
|
37
|
+
}
|
|
38
|
+
function sanitizeSlugPart(s) {
|
|
39
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
40
|
+
}
|
|
41
|
+
/** The project the file belongs to — git repo name, else the cwd's basename. */
|
|
42
|
+
export function detectProject(dir = process.cwd()) {
|
|
43
|
+
try {
|
|
44
|
+
const top = execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
|
|
45
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
46
|
+
})
|
|
47
|
+
.toString()
|
|
48
|
+
.trim();
|
|
49
|
+
if (top)
|
|
50
|
+
return sanitizeSlugPart(basename(top)) || 'share';
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// not a git repo — fall through to cwd basename
|
|
54
|
+
}
|
|
55
|
+
return sanitizeSlugPart(basename(dir)) || 'share';
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Notion-style default slug: `<project>-<feature>-<6hex>`. Project scopes the link
|
|
59
|
+
* to the repo the agent is in; the random tail keeps it unguessable + collision-free.
|
|
60
|
+
* A leading `plan-` on the filename is dropped (it's redundant under the project).
|
|
61
|
+
*/
|
|
62
|
+
export function defaultSlug(filePath, dir) {
|
|
63
|
+
const feature = slugify(filePath).replace(/^plan-/, '') || 'page';
|
|
64
|
+
return `${detectProject(dir)}-${feature}-${randomBytes(3).toString('hex')}`;
|
|
65
|
+
}
|
|
66
|
+
function guessContentType(filePath) {
|
|
67
|
+
if (/\.html?$/i.test(filePath))
|
|
68
|
+
return 'text/html; charset=utf-8';
|
|
69
|
+
if (/\.css$/i.test(filePath))
|
|
70
|
+
return 'text/css; charset=utf-8';
|
|
71
|
+
if (/\.js$/i.test(filePath))
|
|
72
|
+
return 'text/javascript; charset=utf-8';
|
|
73
|
+
if (/\.json$/i.test(filePath))
|
|
74
|
+
return 'application/json';
|
|
75
|
+
if (/\.svg$/i.test(filePath))
|
|
76
|
+
return 'image/svg+xml';
|
|
77
|
+
if (/\.txt$|\.md$/i.test(filePath))
|
|
78
|
+
return 'text/plain; charset=utf-8';
|
|
79
|
+
return 'application/octet-stream';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort OG cover: capture a screenshot, upload it as `<slug>.png`, and return
|
|
83
|
+
* the page body with og:image meta injected (+ the cover URL). All IO is injected
|
|
84
|
+
* (`put`, `capturer`), so this whole path is unit-testable without config/keychain.
|
|
85
|
+
* Any miss — no capturer output, a failed upload — returns the original body and no
|
|
86
|
+
* coverUrl, so publishing never fails because a cover couldn't be made.
|
|
87
|
+
*/
|
|
88
|
+
export async function attachOgCover(filePath, body, ctx) {
|
|
89
|
+
const png = await ctx.capturer(filePath).catch(() => null);
|
|
90
|
+
if (!png)
|
|
91
|
+
return { body };
|
|
92
|
+
const cr = await ctx.put(ctx.pngUrl, png, ctx.pngHeaders);
|
|
93
|
+
if (!cr.ok)
|
|
94
|
+
return { body };
|
|
95
|
+
const { title, description } = deriveMeta(body.toString('utf8'));
|
|
96
|
+
const injected = injectOgMeta(body.toString('utf8'), {
|
|
97
|
+
title,
|
|
98
|
+
description,
|
|
99
|
+
imageUrl: ctx.pngUrl,
|
|
100
|
+
pageUrl: ctx.pageUrl,
|
|
101
|
+
imageWidth: OG_WIDTH * OG_SCALE,
|
|
102
|
+
imageHeight: OG_HEIGHT * OG_SCALE,
|
|
103
|
+
});
|
|
104
|
+
return { body: Buffer.from(injected, 'utf8'), coverUrl: ctx.pngUrl };
|
|
105
|
+
}
|
|
106
|
+
export async function publishFile(filePath, opts = {}) {
|
|
107
|
+
const cfg = readShareConfig();
|
|
108
|
+
if (!cfg) {
|
|
109
|
+
throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
|
|
110
|
+
}
|
|
111
|
+
const token = readWriteToken();
|
|
112
|
+
const slug = (opts.slug ?? defaultSlug(filePath)).replace(/^\/+/, '');
|
|
113
|
+
const expiresAt = parseExpire(opts.expire);
|
|
114
|
+
const pageUrl = `${cfg.baseUrl}/${slug}`;
|
|
115
|
+
const put = opts.uploader ??
|
|
116
|
+
(async (u, b, h) => {
|
|
117
|
+
const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
|
|
118
|
+
return { ok: res.ok, status: res.status, url: u };
|
|
119
|
+
});
|
|
120
|
+
const authHeaders = (contentType) => {
|
|
121
|
+
const h = { authorization: `Bearer ${token}`, 'content-type': contentType };
|
|
122
|
+
if (expiresAt)
|
|
123
|
+
h['x-share-expires-at'] = expiresAt;
|
|
124
|
+
return h;
|
|
125
|
+
};
|
|
126
|
+
let body = readFileSync(filePath);
|
|
127
|
+
let coverUrl;
|
|
128
|
+
// Cover: screenshot the page's hero → upload <slug>.png → inject og:image meta.
|
|
129
|
+
if (/\.html?$/i.test(filePath) && opts.cover !== false) {
|
|
130
|
+
const res = await attachOgCover(filePath, body, {
|
|
131
|
+
pngUrl: `${pageUrl}.png`,
|
|
132
|
+
pageUrl,
|
|
133
|
+
put,
|
|
134
|
+
pngHeaders: authHeaders('image/png'),
|
|
135
|
+
capturer: opts.capturer ?? captureCover,
|
|
136
|
+
});
|
|
137
|
+
body = res.body;
|
|
138
|
+
coverUrl = res.coverUrl;
|
|
139
|
+
}
|
|
140
|
+
const r = await put(pageUrl, body, authHeaders(opts.contentType ?? guessContentType(filePath)));
|
|
141
|
+
if (!r.ok) {
|
|
142
|
+
throw new Error(`Publish failed (${r.status}) for ${pageUrl}. Check the write token, or that 'agents share setup' completed.`);
|
|
143
|
+
}
|
|
144
|
+
return { url: r.url ?? pageUrl, expiresAt, coverUrl };
|
|
145
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The Cloudflare Worker that fronts the R2 share bucket.
|
|
2
|
+
//
|
|
3
|
+
// One tiny Worker does both sides:
|
|
4
|
+
// - PUT /<slug> — bearer-gated (WRITE_TOKEN secret); writes the body to R2 via
|
|
5
|
+
// the BUCKET binding, storing an optional expiry in object metadata.
|
|
6
|
+
// - GET /<slug> — public; streams the object from R2, 410s (and lazily deletes)
|
|
7
|
+
// once its expiry has passed. A bucket lifecycle rule is the
|
|
8
|
+
// durable sweeper; this is the immediate gate.
|
|
9
|
+
//
|
|
10
|
+
// Emitted as a string (mirrors src/lib/serve/page.ts `renderPage()`), so it compiles
|
|
11
|
+
// into `dist/**` and ships with no package.json#files change. `provision.ts` uploads
|
|
12
|
+
// this verbatim as an ES-module Worker with a BUCKET (R2) binding + a WRITE_TOKEN secret.
|
|
13
|
+
/** Render the Worker source. Pure — the R2 binding + token are wired at deploy time. */
|
|
14
|
+
export function renderWorkerScript() {
|
|
15
|
+
return `// GENERATED by agents-cli \`agents share setup\` — do not edit here; edit
|
|
16
|
+
// src/lib/share/worker-template.ts and re-run setup.
|
|
17
|
+
export default {
|
|
18
|
+
async fetch(request, env) {
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const key = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
|
|
21
|
+
|
|
22
|
+
if (!key) {
|
|
23
|
+
return new Response('agents share — POST is not it; PUT /<slug> to publish, GET /<slug> to view.', {
|
|
24
|
+
status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (request.method === 'PUT') {
|
|
29
|
+
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
30
|
+
if (!env.WRITE_TOKEN || !safeEqual(presented, env.WRITE_TOKEN)) {
|
|
31
|
+
return json({ error: 'unauthorized' }, 401);
|
|
32
|
+
}
|
|
33
|
+
const expiresAt = request.headers.get('x-share-expires-at') || '';
|
|
34
|
+
const contentType = request.headers.get('content-type') || 'text/html; charset=utf-8';
|
|
35
|
+
await env.BUCKET.put(key, request.body, {
|
|
36
|
+
httpMetadata: { contentType },
|
|
37
|
+
customMetadata: expiresAt ? { expiresAt } : {},
|
|
38
|
+
});
|
|
39
|
+
return json({ ok: true, url: url.origin + '/' + key, expiresAt: expiresAt || null }, 200);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
43
|
+
const obj = await env.BUCKET.get(key);
|
|
44
|
+
if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
45
|
+
const expiresAt = obj.customMetadata && obj.customMetadata.expiresAt;
|
|
46
|
+
if (expiresAt && Date.now() > Date.parse(expiresAt)) {
|
|
47
|
+
await env.BUCKET.delete(key);
|
|
48
|
+
return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
|
|
49
|
+
}
|
|
50
|
+
const headers = new Headers();
|
|
51
|
+
obj.writeHttpMetadata(headers);
|
|
52
|
+
headers.set('etag', obj.httpEtag);
|
|
53
|
+
if (!headers.has('content-type')) headers.set('content-type', 'text/html; charset=utf-8');
|
|
54
|
+
headers.set('cache-control', 'public, max-age=60');
|
|
55
|
+
return new Response(request.method === 'HEAD' ? null : obj.body, { status: 200, headers });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (request.method === 'DELETE') {
|
|
59
|
+
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
60
|
+
if (!env.WRITE_TOKEN || !safeEqual(presented, env.WRITE_TOKEN)) return json({ error: 'unauthorized' }, 401);
|
|
61
|
+
await env.BUCKET.delete(key);
|
|
62
|
+
return json({ ok: true, deleted: key }, 200);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Response('method not allowed', { status: 405 });
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function json(body, status) {
|
|
70
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Length-independent constant-time-ish compare (Workers has no timingSafeEqual).
|
|
74
|
+
function safeEqual(a, b) {
|
|
75
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
76
|
+
const la = a.length, lb = b.length;
|
|
77
|
+
let out = la ^ lb;
|
|
78
|
+
for (let i = 0; i < la; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i % lb || 0);
|
|
79
|
+
return out === 0 && la === lb;
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
}
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -247,6 +247,19 @@ export declare function readCodexConfiguredModel(): string | undefined;
|
|
|
247
247
|
* preserved so the "freshest" comparison stays stable and switches don't
|
|
248
248
|
* ping-pong. Best-effort: a failed copy just means the user re-logs in.
|
|
249
249
|
*/
|
|
250
|
+
/**
|
|
251
|
+
* Best-effort account identity for the credential *directory* of a file-auth
|
|
252
|
+
* agent (droid / kimi / antigravity), or null when the directory holds no
|
|
253
|
+
* decodable account claim. Delegates to readAuthAccountIdentity, which decrypts
|
|
254
|
+
* / decodes each agent's REAL on-disk format (droid AES-256-GCM + WorkOS JWT,
|
|
255
|
+
* kimi access-token JWT, antigravity refresh-token) — the earlier plaintext
|
|
256
|
+
* top-level-key scan matched NO real credential file, so the guard below never
|
|
257
|
+
* engaged. Two dirs for the SAME account compare equal; DIFFERENT accounts
|
|
258
|
+
* compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
|
|
259
|
+
* account's login with a credential that belongs to a DIFFERENT account
|
|
260
|
+
* (RUSH-1764).
|
|
261
|
+
*/
|
|
262
|
+
export declare function readAuthFileIdentity(agent: AgentId, configDir: string): string | null;
|
|
250
263
|
export declare function carryForwardAuthFiles(agent: AgentId, toConfigDir: string): void;
|
|
251
264
|
export declare function switchConfigSymlink(agent: AgentId, version: string): Promise<{
|
|
252
265
|
success: boolean;
|