frontpage-host 0.1.8 → 0.1.9
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/AGENTS.md +4 -3
- package/dist/commands/media.js +24 -14
- package/dist/commands/prompt-attach.d.ts +12 -10
- package/dist/commands/prompt-attach.js +55 -22
- package/dist/commands/prompt.js +3 -1
- package/dist/http/client.d.ts +2 -0
- package/dist/http/client.js +8 -0
- package/dist/media/limits.d.ts +18 -0
- package/dist/media/limits.js +101 -0
- package/dist/media/select-transport.d.ts +27 -0
- package/dist/media/select-transport.js +134 -0
- package/dist/media/upload.d.ts +16 -0
- package/dist/media/upload.js +140 -0
- package/dist/registry.js +17 -6
- package/llms.txt +2 -1
- package/package.json +4 -1
package/AGENTS.md
CHANGED
|
@@ -112,7 +112,7 @@ Sites are Astro 6 + Tailwind v4. More detail: `frontpage help site-building` or
|
|
|
112
112
|
- Articles: `frontpage articles …` for lifecycle; bodies via sync under `content/` or `articles content get|set`. Section landing is an owned `src/pages/<section>/index.astro`.
|
|
113
113
|
- Migrate a folder into Articles: prefer `frontpage prompt "migrate … into articles"` — do not invent a mass delete+write migration.
|
|
114
114
|
- Extensions: `frontpage extensions`; if LOCKED on plan, do not fake charts/etc.
|
|
115
|
-
- Media
|
|
115
|
+
- Media: `frontpage media upload <file>` — images/PDF ≤25 MB, video (MP4/WebM/MOV) ≤50 MB, SVG/text ≤4 MB (sanitizing proxy). Use returned `sitePath` (`/__media/uploads/<slug>/…`) in markup — never raw blob hosts. Optional `?w=` on rasters only.
|
|
116
116
|
- Analytics: bare `frontpage analytics` = dashboard; also `summary|query|funnel|timeseries|compare|sql` for structured reads (SQL is guard + RLS site-scoped).
|
|
117
117
|
|
|
118
118
|
## Agent prompt loop
|
|
@@ -122,13 +122,14 @@ frontpage prompt "make the hero shorter"
|
|
|
122
122
|
frontpage prompt "Use this logo in the header" --attach ./logo.png
|
|
123
123
|
frontpage prompt "Match this brand PDF" --attach ./guide.pdf --path /
|
|
124
124
|
frontpage prompt "Reuse uploaded art" --attach /__media/uploads/<slug>/<file>
|
|
125
|
+
frontpage media upload ./clip.mp4
|
|
125
126
|
frontpage runs wait # wait for the agent run to finish (failed/canceled → exit 2)
|
|
126
127
|
# Review https://<subdomain>.frontpage.host — do not publish unless asked.
|
|
127
128
|
```
|
|
128
129
|
|
|
129
|
-
- `--attach` accepts a local file (
|
|
130
|
+
- `--attach` accepts a local file (same transport as `frontpage media upload`), an existing `/__media/…` site path, or an `https://` Blob URL. Repeatable.
|
|
130
131
|
- `--asset-url <https://…>` adds a legacy image URL. Repeatable.
|
|
131
|
-
- Caps
|
|
132
|
+
- Caps: images/PDF ≤25 MB, video ≤50 MB, SVG/text via proxy ≤4 MB (large text/data Blob upload is editor-only).
|
|
132
133
|
- Prompt text is still required (API). Attachments ride on the same enqueue as the editor.
|
|
133
134
|
- `runs wait` with `--json`: failures use `ok: false` and include `data.run` (with `run.error` when present).
|
|
134
135
|
- `deploy wait` is fail-closed: exit 2 if the waited run (or latest, when already idle) is `failed`/`canceled`.
|
package/dist/commands/media.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
1
|
import { resolveSlug } from "../site.js";
|
|
4
|
-
import { EXIT_OK, EXIT_USAGE, exitCodeForError
|
|
2
|
+
import { EXIT_OK, EXIT_USAGE, exitCodeForError } from "../http/client.js";
|
|
3
|
+
import { emitErr, emitOk, isJson } from "../runtime.js";
|
|
4
|
+
import { uploadLocalMedia } from "../media/upload.js";
|
|
5
|
+
import { v1Get } from "../http/client.js";
|
|
5
6
|
export async function runMedia(args) {
|
|
6
7
|
try {
|
|
7
8
|
const { slug, rest } = await resolveSlug(args);
|
|
8
9
|
const sub = rest[0] ?? "list";
|
|
9
10
|
if (sub === "list" || sub === "ls") {
|
|
10
11
|
const data = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/media`);
|
|
12
|
+
if (isJson()) {
|
|
13
|
+
emitOk("media", { slug, ...data });
|
|
14
|
+
return EXIT_OK;
|
|
15
|
+
}
|
|
11
16
|
for (const a of data.assets) {
|
|
12
17
|
console.log(`${a.sitePath.padEnd(48)} ${String(a.sizeBytes).padStart(8)} ${a.name}`);
|
|
13
18
|
}
|
|
@@ -17,27 +22,32 @@ export async function runMedia(args) {
|
|
|
17
22
|
if (sub === "upload") {
|
|
18
23
|
const filePath = rest[1];
|
|
19
24
|
if (!filePath) {
|
|
20
|
-
console.error("Usage: frontpage media upload <file> [--site <slug>]");
|
|
25
|
+
console.error("Usage: frontpage media upload <file> [--site <slug>] [--json]");
|
|
21
26
|
return EXIT_USAGE;
|
|
22
27
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const data = await uploadLocalMedia({ slug, filePath });
|
|
29
|
+
if (isJson()) {
|
|
30
|
+
emitOk("media", { slug, ...data });
|
|
31
|
+
return EXIT_OK;
|
|
32
|
+
}
|
|
27
33
|
if (data.kind === "text") {
|
|
28
34
|
console.log(`Inline text (${data.filename}): ${data.sizeBytes ?? 0} bytes — not stored.`);
|
|
35
|
+
return EXIT_OK;
|
|
29
36
|
}
|
|
30
|
-
|
|
31
|
-
console.log(data.url
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
if (data.url)
|
|
38
|
+
console.log(data.url);
|
|
39
|
+
if (data.sitePath)
|
|
40
|
+
console.log(`sitePath: ${data.sitePath}`);
|
|
41
|
+
else if (data.key)
|
|
42
|
+
console.log(`sitePath: /__media/${data.key}`);
|
|
35
43
|
return EXIT_OK;
|
|
36
44
|
}
|
|
37
|
-
console.error("Usage: frontpage media list|upload <file> [--site <slug>]");
|
|
45
|
+
console.error("Usage: frontpage media list|upload <file> [--site <slug>] [--json]");
|
|
38
46
|
return EXIT_USAGE;
|
|
39
47
|
}
|
|
40
48
|
catch (err) {
|
|
49
|
+
if (isJson())
|
|
50
|
+
return emitErr("media", err);
|
|
41
51
|
console.error(err instanceof Error ? err.message : String(err));
|
|
42
52
|
return exitCodeForError(err);
|
|
43
53
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import { type UploadedMedia } from "../media/upload.js";
|
|
2
|
+
/** Mirror ATTACH_LIMITS in src/lib/agent/attachments.ts. */
|
|
2
3
|
export declare const CLI_ATTACH_LIMITS: {
|
|
3
4
|
readonly maxImages: 6;
|
|
4
5
|
readonly maxDocuments: 4;
|
|
5
6
|
readonly maxTextFiles: 6;
|
|
7
|
+
readonly maxVideos: 3;
|
|
6
8
|
};
|
|
7
9
|
export type PromptAttachmentsPayload = {
|
|
8
10
|
images: Array<{
|
|
@@ -17,19 +19,19 @@ export type PromptAttachmentsPayload = {
|
|
|
17
19
|
name: string;
|
|
18
20
|
content: string;
|
|
19
21
|
}>;
|
|
22
|
+
videos: Array<{
|
|
23
|
+
url: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
sizeBytes?: number;
|
|
26
|
+
}>;
|
|
20
27
|
};
|
|
21
|
-
export type MediaUploadResult =
|
|
22
|
-
kind: string;
|
|
23
|
-
url?: string;
|
|
24
|
-
key?: string;
|
|
25
|
-
filename?: string;
|
|
26
|
-
sizeBytes?: number;
|
|
27
|
-
content?: string;
|
|
28
|
-
};
|
|
28
|
+
export type MediaUploadResult = UploadedMedia;
|
|
29
29
|
export declare function emptyAttachments(): PromptAttachmentsPayload;
|
|
30
30
|
export declare function isHttpsUrl(s: string): boolean;
|
|
31
31
|
export declare function isMediaSitePath(s: string): boolean;
|
|
32
|
-
export declare function classifyRemoteByName(nameOrUrl: string): "image" | "document";
|
|
32
|
+
export declare function classifyRemoteByName(nameOrUrl: string): "image" | "document" | "video";
|
|
33
|
+
/** Agent normalizeAttachments only keeps videos on our Blob host under uploads/. */
|
|
34
|
+
export declare function isFrontpageBlobUploadUrl(url: string): boolean;
|
|
33
35
|
export declare function addHttpsRef(out: PromptAttachmentsPayload, url: string, opts?: {
|
|
34
36
|
name?: string;
|
|
35
37
|
asImageOnly?: boolean;
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* §CLI Phase C — resolve --attach / --asset-url into the agent attachments payload.
|
|
3
|
-
* Local files
|
|
2
|
+
* §CLI Phase C (+ E) — resolve --attach / --asset-url into the agent attachments payload.
|
|
3
|
+
* Local files use the same transport matrix as `frontpage media upload`.
|
|
4
4
|
*/
|
|
5
|
-
import { access
|
|
6
|
-
import { basename } from "node:path";
|
|
5
|
+
import { access } from "node:fs/promises";
|
|
7
6
|
import { constants as fsConstants } from "node:fs";
|
|
8
|
-
import { v1Get,
|
|
9
|
-
|
|
7
|
+
import { v1Get, CliHttpError, EXIT_USAGE, EXIT_UNEXPECTED } from "../http/client.js";
|
|
8
|
+
import { uploadLocalMedia } from "../media/upload.js";
|
|
9
|
+
import { VIDEO_UPLOAD_EXT, extOf } from "../media/limits.js";
|
|
10
|
+
/** Mirror ATTACH_LIMITS in src/lib/agent/attachments.ts. */
|
|
10
11
|
export const CLI_ATTACH_LIMITS = {
|
|
11
12
|
maxImages: 6,
|
|
12
13
|
maxDocuments: 4,
|
|
13
14
|
maxTextFiles: 6,
|
|
15
|
+
maxVideos: 3,
|
|
14
16
|
};
|
|
15
17
|
export function emptyAttachments() {
|
|
16
|
-
return { images: [], documents: [], textFiles: [] };
|
|
18
|
+
return { images: [], documents: [], textFiles: [], videos: [] };
|
|
17
19
|
}
|
|
18
20
|
export function isHttpsUrl(s) {
|
|
19
21
|
return /^https:\/\/[^\s]+$/i.test(s) && s.length <= 2048;
|
|
@@ -23,7 +25,9 @@ export function isMediaSitePath(s) {
|
|
|
23
25
|
}
|
|
24
26
|
export function classifyRemoteByName(nameOrUrl) {
|
|
25
27
|
const base = nameOrUrl.split(/[?#]/)[0] ?? nameOrUrl;
|
|
26
|
-
const ext =
|
|
28
|
+
const ext = extOf(base);
|
|
29
|
+
if (VIDEO_UPLOAD_EXT.has(ext))
|
|
30
|
+
return "video";
|
|
27
31
|
if (ext === "pdf")
|
|
28
32
|
return "document";
|
|
29
33
|
return "image";
|
|
@@ -42,6 +46,19 @@ function pushText(list, name, content) {
|
|
|
42
46
|
}
|
|
43
47
|
list.push({ name, content });
|
|
44
48
|
}
|
|
49
|
+
/** Agent normalizeAttachments only keeps videos on our Blob host under uploads/. */
|
|
50
|
+
export function isFrontpageBlobUploadUrl(url) {
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(url);
|
|
53
|
+
if (!u.hostname.endsWith(".blob.vercel-storage.com"))
|
|
54
|
+
return false;
|
|
55
|
+
const pathname = u.pathname.replace(/^\/+/, "");
|
|
56
|
+
return pathname.startsWith("uploads/");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
45
62
|
export function addHttpsRef(out, url, opts) {
|
|
46
63
|
if (!isHttpsUrl(url)) {
|
|
47
64
|
throw new CliHttpError(`Not an https URL: ${url}`, EXIT_USAGE, 400, "bad_request");
|
|
@@ -49,10 +66,16 @@ export function addHttpsRef(out, url, opts) {
|
|
|
49
66
|
const kind = opts?.asImageOnly ? "image" : classifyRemoteByName(opts?.name ?? url);
|
|
50
67
|
if (kind === "document") {
|
|
51
68
|
pushUniqueUrl(out.documents, CLI_ATTACH_LIMITS.maxDocuments, url, opts?.name, "document");
|
|
69
|
+
return;
|
|
52
70
|
}
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
if (kind === "video") {
|
|
72
|
+
if (!isFrontpageBlobUploadUrl(url)) {
|
|
73
|
+
throw new CliHttpError("Video --attach URLs must be Frontpage Blob uploads (…blob.vercel-storage.com/uploads/…). Upload with frontpage media upload first, or pass a local .mp4/.webm/.mov file.", EXIT_USAGE, 400, "bad_request");
|
|
74
|
+
}
|
|
75
|
+
pushUniqueUrl(out.videos, CLI_ATTACH_LIMITS.maxVideos, url, opts?.name, "video");
|
|
76
|
+
return;
|
|
55
77
|
}
|
|
78
|
+
pushUniqueUrl(out.images, CLI_ATTACH_LIMITS.maxImages, url, opts?.name, "image");
|
|
56
79
|
}
|
|
57
80
|
export function addUploadResult(out, data) {
|
|
58
81
|
const name = data.filename?.trim() || undefined;
|
|
@@ -63,19 +86,31 @@ export function addUploadResult(out, data) {
|
|
|
63
86
|
pushText(out.textFiles, name ?? "snippet.txt", data.content);
|
|
64
87
|
return;
|
|
65
88
|
}
|
|
66
|
-
if (data.kind === "image" || data.kind === "document") {
|
|
89
|
+
if (data.kind === "image" || data.kind === "document" || data.kind === "video") {
|
|
67
90
|
if (!data.url || !isHttpsUrl(data.url)) {
|
|
68
91
|
throw new CliHttpError(`Upload of ${name ?? "file"} did not return a Blob URL.`, EXIT_UNEXPECTED, 500, "internal");
|
|
69
92
|
}
|
|
70
93
|
if (data.kind === "document") {
|
|
71
94
|
pushUniqueUrl(out.documents, CLI_ATTACH_LIMITS.maxDocuments, data.url, name, "document");
|
|
95
|
+
return;
|
|
72
96
|
}
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
if (data.kind === "video") {
|
|
98
|
+
if (out.videos.length >= CLI_ATTACH_LIMITS.maxVideos) {
|
|
99
|
+
throw new CliHttpError(`Too many video attachments (max ${CLI_ATTACH_LIMITS.maxVideos}).`, EXIT_USAGE, 400, "bad_request");
|
|
100
|
+
}
|
|
101
|
+
if (!out.videos.some((x) => x.url === data.url)) {
|
|
102
|
+
out.videos.push({
|
|
103
|
+
url: data.url,
|
|
104
|
+
name,
|
|
105
|
+
sizeBytes: data.sizeBytes,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
75
109
|
}
|
|
110
|
+
pushUniqueUrl(out.images, CLI_ATTACH_LIMITS.maxImages, data.url, name, "image");
|
|
76
111
|
return;
|
|
77
112
|
}
|
|
78
|
-
throw new CliHttpError(`Unsupported upload kind "${data.kind}" for prompt attach. Use images, PDFs, or text/code files
|
|
113
|
+
throw new CliHttpError(`Unsupported upload kind "${data.kind}" for prompt attach. Use images, PDFs, videos, or text/code files.`, EXIT_USAGE, 400, "bad_request");
|
|
79
114
|
}
|
|
80
115
|
export async function uploadLocalForAttach(slug, filePath) {
|
|
81
116
|
try {
|
|
@@ -84,13 +119,7 @@ export async function uploadLocalForAttach(slug, filePath) {
|
|
|
84
119
|
catch {
|
|
85
120
|
throw new CliHttpError(`Cannot read file for --attach: ${filePath}`, EXIT_USAGE, 400, "bad_request");
|
|
86
121
|
}
|
|
87
|
-
|
|
88
|
-
if (bytes.length === 0) {
|
|
89
|
-
throw new CliHttpError(`File is empty: ${filePath}`, EXIT_USAGE, 400, "bad_request");
|
|
90
|
-
}
|
|
91
|
-
const form = new FormData();
|
|
92
|
-
form.append("file", new Blob([Uint8Array.from(bytes)]), basename(filePath));
|
|
93
|
-
return v1PostMultipart(`/api/v1/sites/${encodeURIComponent(slug)}/media`, form);
|
|
122
|
+
return uploadLocalMedia({ slug, filePath });
|
|
94
123
|
}
|
|
95
124
|
export async function resolveSitePathUrl(slug, sitePath) {
|
|
96
125
|
const data = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/media`);
|
|
@@ -123,6 +152,9 @@ export async function resolveAttachRef(slug, ref, out) {
|
|
|
123
152
|
if (kind === "document") {
|
|
124
153
|
pushUniqueUrl(out.documents, CLI_ATTACH_LIMITS.maxDocuments, asset.url, asset.name, "document");
|
|
125
154
|
}
|
|
155
|
+
else if (kind === "video") {
|
|
156
|
+
pushUniqueUrl(out.videos, CLI_ATTACH_LIMITS.maxVideos, asset.url, asset.name, "video");
|
|
157
|
+
}
|
|
126
158
|
else {
|
|
127
159
|
pushUniqueUrl(out.images, CLI_ATTACH_LIMITS.maxImages, asset.url, asset.name, "image");
|
|
128
160
|
}
|
|
@@ -143,7 +175,8 @@ export async function buildPromptAttachments(args) {
|
|
|
143
175
|
}
|
|
144
176
|
if (out.images.length === 0 &&
|
|
145
177
|
out.documents.length === 0 &&
|
|
146
|
-
out.textFiles.length === 0
|
|
178
|
+
out.textFiles.length === 0 &&
|
|
179
|
+
out.videos.length === 0) {
|
|
147
180
|
throw new CliHttpError("No attachments could be resolved from --attach / --asset-url.", EXIT_USAGE, 400, "bad_request");
|
|
148
181
|
}
|
|
149
182
|
return out;
|
package/dist/commands/prompt.js
CHANGED
|
@@ -29,6 +29,7 @@ export async function runPrompt(args) {
|
|
|
29
29
|
images: attachments.images.length,
|
|
30
30
|
documents: attachments.documents.length,
|
|
31
31
|
textFiles: attachments.textFiles.length,
|
|
32
|
+
videos: attachments.videos.length,
|
|
32
33
|
}
|
|
33
34
|
: undefined,
|
|
34
35
|
});
|
|
@@ -40,7 +41,8 @@ export async function runPrompt(args) {
|
|
|
40
41
|
if (attachments) {
|
|
41
42
|
const n = attachments.images.length +
|
|
42
43
|
attachments.documents.length +
|
|
43
|
-
attachments.textFiles.length
|
|
44
|
+
attachments.textFiles.length +
|
|
45
|
+
attachments.videos.length;
|
|
44
46
|
console.error(`(attached ${n} file${n === 1 ? "" : "s"})`);
|
|
45
47
|
}
|
|
46
48
|
}
|
package/dist/http/client.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export declare class CliHttpError extends Error {
|
|
|
21
21
|
/** Map a non-OK v1 response into CliHttpError (shared by JSON + multipart clients). */
|
|
22
22
|
export declare function httpErrorFromV1(status: number, parsed: V1Envelope, fallbackMessage?: string): CliHttpError;
|
|
23
23
|
export declare function apiBase(): string;
|
|
24
|
+
/** Bearer header for raw fetch (e.g. Blob client-token exchange). */
|
|
25
|
+
export declare function getBearerAuthHeader(): Promise<Record<string, string>>;
|
|
24
26
|
export declare function v1Get<T>(path: string): Promise<T>;
|
|
25
27
|
export declare function v1Post<T>(path: string, body?: unknown): Promise<T>;
|
|
26
28
|
export declare function v1Put<T>(path: string, body?: unknown): Promise<T>;
|
package/dist/http/client.js
CHANGED
|
@@ -61,6 +61,14 @@ export function apiBase() {
|
|
|
61
61
|
return fromEnv;
|
|
62
62
|
return "https://app.frontpage.host";
|
|
63
63
|
}
|
|
64
|
+
/** Bearer header for raw fetch (e.g. Blob client-token exchange). */
|
|
65
|
+
export async function getBearerAuthHeader() {
|
|
66
|
+
const key = await getApiKey();
|
|
67
|
+
if (!key) {
|
|
68
|
+
throw new CliHttpError("Not logged in. Run: frontpage login", EXIT_AUTH, 401);
|
|
69
|
+
}
|
|
70
|
+
return { Authorization: `Bearer ${key}` };
|
|
71
|
+
}
|
|
64
72
|
async function v1Request(method, path, body) {
|
|
65
73
|
const key = await getApiKey();
|
|
66
74
|
if (!key) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of app `src/lib/upload/limits.ts` + video ext set (CLI cannot import app code).
|
|
3
|
+
* Keep values in lockstep with the platform.
|
|
4
|
+
*/
|
|
5
|
+
export declare const MAX_UPLOAD_BYTES: number;
|
|
6
|
+
export declare const MAX_VIDEO_UPLOAD_BYTES: number;
|
|
7
|
+
export declare const MAX_PROXY_UPLOAD_BYTES: number;
|
|
8
|
+
export declare const MAX_INLINE_TEXT_BYTES: number;
|
|
9
|
+
export declare const VIDEO_UPLOAD_EXT: Set<string>;
|
|
10
|
+
export declare const RASTER_EXT: Set<string>;
|
|
11
|
+
export declare const PDF_EXT: Set<string>;
|
|
12
|
+
export declare const SVG_EXT: Set<string>;
|
|
13
|
+
/** Common text/code extensions (proxy/inline only via CLI — no FU.9 client path). */
|
|
14
|
+
export declare const TEXT_EXT: Set<string>;
|
|
15
|
+
export declare function formatBytes(n: number): string;
|
|
16
|
+
export declare function extOf(filename: string): string;
|
|
17
|
+
export declare function contentTypeForExt(ext: string): string | undefined;
|
|
18
|
+
export declare function sitePathFromKey(key: string): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of app `src/lib/upload/limits.ts` + video ext set (CLI cannot import app code).
|
|
3
|
+
* Keep values in lockstep with the platform.
|
|
4
|
+
*/
|
|
5
|
+
export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
6
|
+
export const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
export const MAX_PROXY_UPLOAD_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
export const MAX_INLINE_TEXT_BYTES = 256 * 1024;
|
|
9
|
+
export const VIDEO_UPLOAD_EXT = new Set(["mp4", "webm", "mov", "m4v"]);
|
|
10
|
+
export const RASTER_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
|
|
11
|
+
export const PDF_EXT = new Set(["pdf"]);
|
|
12
|
+
export const SVG_EXT = new Set(["svg"]);
|
|
13
|
+
/** Common text/code extensions (proxy/inline only via CLI — no FU.9 client path). */
|
|
14
|
+
export const TEXT_EXT = new Set([
|
|
15
|
+
"txt",
|
|
16
|
+
"md",
|
|
17
|
+
"markdown",
|
|
18
|
+
"csv",
|
|
19
|
+
"tsv",
|
|
20
|
+
"json",
|
|
21
|
+
"json5",
|
|
22
|
+
"jsonc",
|
|
23
|
+
"html",
|
|
24
|
+
"htm",
|
|
25
|
+
"css",
|
|
26
|
+
"scss",
|
|
27
|
+
"sass",
|
|
28
|
+
"less",
|
|
29
|
+
"js",
|
|
30
|
+
"mjs",
|
|
31
|
+
"cjs",
|
|
32
|
+
"jsx",
|
|
33
|
+
"ts",
|
|
34
|
+
"tsx",
|
|
35
|
+
"py",
|
|
36
|
+
"rb",
|
|
37
|
+
"php",
|
|
38
|
+
"go",
|
|
39
|
+
"rs",
|
|
40
|
+
"java",
|
|
41
|
+
"kt",
|
|
42
|
+
"swift",
|
|
43
|
+
"c",
|
|
44
|
+
"h",
|
|
45
|
+
"cpp",
|
|
46
|
+
"cc",
|
|
47
|
+
"yaml",
|
|
48
|
+
"yml",
|
|
49
|
+
"toml",
|
|
50
|
+
"ini",
|
|
51
|
+
"env",
|
|
52
|
+
"xml",
|
|
53
|
+
"sql",
|
|
54
|
+
"sh",
|
|
55
|
+
"graphql",
|
|
56
|
+
"gql",
|
|
57
|
+
]);
|
|
58
|
+
export function formatBytes(n) {
|
|
59
|
+
if (!Number.isFinite(n) || n < 0)
|
|
60
|
+
return "0 bytes";
|
|
61
|
+
if (n < 1024)
|
|
62
|
+
return `${Math.round(n)} bytes`;
|
|
63
|
+
if (n < 1024 * 1024)
|
|
64
|
+
return `${(n / 1024).toFixed(0)} KB`;
|
|
65
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
66
|
+
}
|
|
67
|
+
export function extOf(filename) {
|
|
68
|
+
const base = (filename || "").split(/[/\\]/).pop() ?? "";
|
|
69
|
+
return base.includes(".") ? (base.split(".").pop() ?? "").toLowerCase() : "";
|
|
70
|
+
}
|
|
71
|
+
export function contentTypeForExt(ext) {
|
|
72
|
+
switch (ext) {
|
|
73
|
+
case "png":
|
|
74
|
+
return "image/png";
|
|
75
|
+
case "jpg":
|
|
76
|
+
case "jpeg":
|
|
77
|
+
return "image/jpeg";
|
|
78
|
+
case "gif":
|
|
79
|
+
return "image/gif";
|
|
80
|
+
case "webp":
|
|
81
|
+
return "image/webp";
|
|
82
|
+
case "avif":
|
|
83
|
+
return "image/avif";
|
|
84
|
+
case "pdf":
|
|
85
|
+
return "application/pdf";
|
|
86
|
+
case "mp4":
|
|
87
|
+
return "video/mp4";
|
|
88
|
+
case "webm":
|
|
89
|
+
return "video/webm";
|
|
90
|
+
case "mov":
|
|
91
|
+
return "video/quicktime";
|
|
92
|
+
case "m4v":
|
|
93
|
+
return "video/x-m4v";
|
|
94
|
+
default:
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function sitePathFromKey(key) {
|
|
99
|
+
const k = key.replace(/^\//, "");
|
|
100
|
+
return k.startsWith("__media/") ? `/${k}` : `/__media/${k}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type MediaKind = "image" | "document" | "video" | "text";
|
|
2
|
+
export type TransportDecision = {
|
|
3
|
+
transport: "proxy";
|
|
4
|
+
kind: MediaKind;
|
|
5
|
+
} | {
|
|
6
|
+
transport: "client";
|
|
7
|
+
kind: Exclude<MediaKind, "text">;
|
|
8
|
+
ext: string;
|
|
9
|
+
contentType: string;
|
|
10
|
+
} | {
|
|
11
|
+
transport: "reject";
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Minimal video sniff for files without a recognized extension.
|
|
16
|
+
* Mirrors server `sniffType` enough to avoid classifying AVIF/HEIC as video.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sniffVideoExt(head: Uint8Array): "mp4" | "webm" | null;
|
|
19
|
+
/**
|
|
20
|
+
* Decide proxy vs direct-to-Blob vs reject for a local file.
|
|
21
|
+
* `headBytes` (optional) enables video detection when the extension is missing/wrong.
|
|
22
|
+
*/
|
|
23
|
+
export declare function selectMediaTransport(input: {
|
|
24
|
+
filename: string;
|
|
25
|
+
sizeBytes: number;
|
|
26
|
+
headBytes?: Uint8Array;
|
|
27
|
+
}): TransportDecision;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase E — pure transport matrix for `frontpage media upload`.
|
|
3
|
+
*/
|
|
4
|
+
import { extOf, formatBytes, MAX_PROXY_UPLOAD_BYTES, MAX_UPLOAD_BYTES, MAX_VIDEO_UPLOAD_BYTES, PDF_EXT, RASTER_EXT, SVG_EXT, TEXT_EXT, VIDEO_UPLOAD_EXT, } from "./limits.js";
|
|
5
|
+
function asciiAt(bytes, offset, s) {
|
|
6
|
+
if (bytes.length < offset + s.length)
|
|
7
|
+
return false;
|
|
8
|
+
for (let i = 0; i < s.length; i++)
|
|
9
|
+
if (bytes[offset + i] !== s.charCodeAt(i))
|
|
10
|
+
return false;
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
/** Brands that share ISOBMFF/`ftyp` but are NOT video (must not route to client-as-mp4). */
|
|
14
|
+
const NON_VIDEO_FTYP_BRANDS = new Set(["avif", "avis", "heic", "heif", "mif1", "msf1"]);
|
|
15
|
+
function ftypBrand(head) {
|
|
16
|
+
if (!asciiAt(head, 4, "ftyp") || head.length < 12)
|
|
17
|
+
return null;
|
|
18
|
+
return String.fromCharCode(head[8], head[9], head[10], head[11]).toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Minimal video sniff for files without a recognized extension.
|
|
22
|
+
* Mirrors server `sniffType` enough to avoid classifying AVIF/HEIC as video.
|
|
23
|
+
*/
|
|
24
|
+
export function sniffVideoExt(head) {
|
|
25
|
+
const brand = ftypBrand(head);
|
|
26
|
+
if (brand && NON_VIDEO_FTYP_BRANDS.has(brand))
|
|
27
|
+
return null;
|
|
28
|
+
if (asciiAt(head, 4, "ftyp") ||
|
|
29
|
+
asciiAt(head, 4, "moov") ||
|
|
30
|
+
asciiAt(head, 4, "mdat") ||
|
|
31
|
+
asciiAt(head, 4, "wide") ||
|
|
32
|
+
asciiAt(head, 4, "free") ||
|
|
33
|
+
asciiAt(head, 4, "skip")) {
|
|
34
|
+
return "mp4";
|
|
35
|
+
}
|
|
36
|
+
if (head.length >= 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) {
|
|
37
|
+
return "webm";
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function clientDecision(kind, ext, contentType) {
|
|
42
|
+
return { transport: "client", kind, ext, contentType };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decide proxy vs direct-to-Blob vs reject for a local file.
|
|
46
|
+
* `headBytes` (optional) enables video detection when the extension is missing/wrong.
|
|
47
|
+
*/
|
|
48
|
+
export function selectMediaTransport(input) {
|
|
49
|
+
const { filename, sizeBytes } = input;
|
|
50
|
+
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
51
|
+
return { transport: "reject", message: "That file is empty — nothing to upload." };
|
|
52
|
+
}
|
|
53
|
+
let ext = extOf(filename);
|
|
54
|
+
const hasKnownNonVideoExt = RASTER_EXT.has(ext) || PDF_EXT.has(ext) || SVG_EXT.has(ext) || TEXT_EXT.has(ext);
|
|
55
|
+
// Only sniff for video when the extension isn't already a known non-video kind
|
|
56
|
+
// (AVIF is ISOBMFF/`ftyp` — sniffing before the raster branch would mis-route it).
|
|
57
|
+
const sniffedVideo = !hasKnownNonVideoExt && input.headBytes ? sniffVideoExt(input.headBytes) : null;
|
|
58
|
+
const isVideo = VIDEO_UPLOAD_EXT.has(ext) || sniffedVideo !== null;
|
|
59
|
+
if (isVideo && !VIDEO_UPLOAD_EXT.has(ext) && sniffedVideo) {
|
|
60
|
+
ext = sniffedVideo;
|
|
61
|
+
}
|
|
62
|
+
if (isVideo) {
|
|
63
|
+
if (sizeBytes > MAX_VIDEO_UPLOAD_BYTES) {
|
|
64
|
+
return {
|
|
65
|
+
transport: "reject",
|
|
66
|
+
message: `This video is ${formatBytes(sizeBytes)}, over the ${MAX_VIDEO_UPLOAD_BYTES / (1024 * 1024)} MB video limit. Please compress it (a web-ready MP4 around 1080p) and try again.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const useExt = VIDEO_UPLOAD_EXT.has(ext) ? ext : sniffedVideo ?? "mp4";
|
|
70
|
+
const contentType = useExt === "webm"
|
|
71
|
+
? "video/webm"
|
|
72
|
+
: useExt === "mov"
|
|
73
|
+
? "video/quicktime"
|
|
74
|
+
: useExt === "m4v"
|
|
75
|
+
? "video/x-m4v"
|
|
76
|
+
: "video/mp4";
|
|
77
|
+
return clientDecision("video", useExt, contentType);
|
|
78
|
+
}
|
|
79
|
+
if (SVG_EXT.has(ext)) {
|
|
80
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
81
|
+
return {
|
|
82
|
+
transport: "reject",
|
|
83
|
+
message: `This SVG is ${formatBytes(sizeBytes)} — SVGs must be under ${MAX_PROXY_UPLOAD_BYTES / (1024 * 1024)} MB.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { transport: "proxy", kind: "image" };
|
|
87
|
+
}
|
|
88
|
+
if (RASTER_EXT.has(ext)) {
|
|
89
|
+
if (sizeBytes > MAX_UPLOAD_BYTES) {
|
|
90
|
+
return {
|
|
91
|
+
transport: "reject",
|
|
92
|
+
message: `This image is ${formatBytes(sizeBytes)}, over the ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB upload limit. Please use a smaller or lower-resolution image.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
96
|
+
const contentType = ext === "jpg" || ext === "jpeg"
|
|
97
|
+
? "image/jpeg"
|
|
98
|
+
: ext === "png"
|
|
99
|
+
? "image/png"
|
|
100
|
+
: ext === "gif"
|
|
101
|
+
? "image/gif"
|
|
102
|
+
: ext === "webp"
|
|
103
|
+
? "image/webp"
|
|
104
|
+
: "image/avif";
|
|
105
|
+
return clientDecision("image", ext === "jpeg" ? "jpg" : ext, contentType);
|
|
106
|
+
}
|
|
107
|
+
return { transport: "proxy", kind: "image" };
|
|
108
|
+
}
|
|
109
|
+
if (PDF_EXT.has(ext)) {
|
|
110
|
+
if (sizeBytes > MAX_UPLOAD_BYTES) {
|
|
111
|
+
return {
|
|
112
|
+
transport: "reject",
|
|
113
|
+
message: `This PDF is ${formatBytes(sizeBytes)}, over the ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB upload limit. Please attach a smaller file.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
117
|
+
return clientDecision("document", "pdf", "application/pdf");
|
|
118
|
+
}
|
|
119
|
+
return { transport: "proxy", kind: "document" };
|
|
120
|
+
}
|
|
121
|
+
if (TEXT_EXT.has(ext)) {
|
|
122
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
123
|
+
return {
|
|
124
|
+
transport: "reject",
|
|
125
|
+
message: `This text/data file is ${formatBytes(sizeBytes)}. Large text/data Blob uploads aren't supported via the CLI yet — keep files under ${MAX_PROXY_UPLOAD_BYTES / (1024 * 1024)} MB or use the editor.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { transport: "proxy", kind: "text" };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
transport: "reject",
|
|
132
|
+
message: "That file type isn't supported. Use an image (PNG, JPG, GIF, WebP, AVIF, SVG), a PDF, a video (MP4, WebM, MOV), or a text/code file.",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type UploadedMedia = {
|
|
2
|
+
kind: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
key?: string;
|
|
5
|
+
sitePath?: string;
|
|
6
|
+
filename?: string;
|
|
7
|
+
sizeBytes?: number;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
transport?: "proxy" | "client";
|
|
11
|
+
};
|
|
12
|
+
/** Upload a local file using the Phase E transport matrix. */
|
|
13
|
+
export declare function uploadLocalMedia(args: {
|
|
14
|
+
slug: string;
|
|
15
|
+
filePath: string;
|
|
16
|
+
}): Promise<UploadedMedia>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared local-file upload used by `frontpage media upload` and `prompt --attach`.
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { put } from "@vercel/blob/client";
|
|
8
|
+
import { apiBase, CliHttpError, EXIT_AUTH, EXIT_UNEXPECTED, EXIT_USAGE, getBearerAuthHeader, httpErrorFromV1, v1PostMultipart, } from "../http/client.js";
|
|
9
|
+
import { contentTypeForExt, sitePathFromKey } from "./limits.js";
|
|
10
|
+
import { selectMediaTransport } from "./select-transport.js";
|
|
11
|
+
async function requestClientToken(args) {
|
|
12
|
+
const auth = await getBearerAuthHeader();
|
|
13
|
+
const url = `${apiBase()}/api/v1/sites/${encodeURIComponent(args.slug)}/media/client`;
|
|
14
|
+
let res;
|
|
15
|
+
try {
|
|
16
|
+
res = await fetch(url, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
...auth,
|
|
20
|
+
Accept: "application/json",
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
type: "blob.generate-client-token",
|
|
25
|
+
payload: {
|
|
26
|
+
pathname: args.pathname,
|
|
27
|
+
clientPayload: JSON.stringify({ declaredSize: args.declaredSize }),
|
|
28
|
+
multipart: args.multipart,
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new CliHttpError(`Network error: ${err instanceof Error ? err.message : String(err)}`, EXIT_UNEXPECTED, 0);
|
|
35
|
+
}
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
let parsed = {};
|
|
38
|
+
try {
|
|
39
|
+
parsed = text ? JSON.parse(text) : {};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
parsed = {};
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const msg = typeof parsed.error === "string"
|
|
46
|
+
? parsed.error
|
|
47
|
+
: typeof parsed.error === "object" && parsed.error?.message
|
|
48
|
+
? parsed.error.message
|
|
49
|
+
: `Upload authorization failed (${res.status})`;
|
|
50
|
+
// Auth / rate-limit use v1 envelopes; policy denials use { error: string }.
|
|
51
|
+
if (typeof parsed.error === "object" && parsed.error) {
|
|
52
|
+
throw httpErrorFromV1(res.status, parsed, msg);
|
|
53
|
+
}
|
|
54
|
+
if (res.status === 401 || res.status === 403) {
|
|
55
|
+
throw new CliHttpError(msg, EXIT_AUTH, res.status);
|
|
56
|
+
}
|
|
57
|
+
if (res.status === 402 || res.status === 413 || res.status === 429 || res.status === 400) {
|
|
58
|
+
throw new CliHttpError(msg, EXIT_USAGE, res.status);
|
|
59
|
+
}
|
|
60
|
+
throw new CliHttpError(msg, EXIT_UNEXPECTED, res.status);
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed.clientToken !== "string" || !parsed.clientToken) {
|
|
63
|
+
throw new CliHttpError("Upload authorization failed (no client token).", EXIT_UNEXPECTED, res.status);
|
|
64
|
+
}
|
|
65
|
+
return parsed.clientToken;
|
|
66
|
+
}
|
|
67
|
+
async function uploadViaClient(args) {
|
|
68
|
+
const pathname = `uploads/${args.slug}/${randomUUID()}.${args.ext}`;
|
|
69
|
+
const multipart = args.bytes.length > 8 * 1024 * 1024;
|
|
70
|
+
const clientToken = await requestClientToken({
|
|
71
|
+
slug: args.slug,
|
|
72
|
+
pathname,
|
|
73
|
+
declaredSize: args.bytes.length,
|
|
74
|
+
multipart,
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const blob = await put(pathname, args.bytes, {
|
|
78
|
+
access: "public",
|
|
79
|
+
token: clientToken,
|
|
80
|
+
contentType: args.contentType,
|
|
81
|
+
multipart,
|
|
82
|
+
});
|
|
83
|
+
const key = blob.pathname.replace(/^\//, "");
|
|
84
|
+
return {
|
|
85
|
+
kind: args.kind,
|
|
86
|
+
url: blob.url,
|
|
87
|
+
key,
|
|
88
|
+
sitePath: sitePathFromKey(key),
|
|
89
|
+
filename: args.filename,
|
|
90
|
+
sizeBytes: args.bytes.length,
|
|
91
|
+
contentType: args.contentType || contentTypeForExt(args.ext),
|
|
92
|
+
transport: "client",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
97
|
+
throw new CliHttpError(`Upload failed: ${msg}`, EXIT_UNEXPECTED, 500);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function uploadViaProxy(args) {
|
|
101
|
+
const form = new FormData();
|
|
102
|
+
form.append("file", new Blob([Uint8Array.from(args.bytes)]), args.filename);
|
|
103
|
+
const data = await v1PostMultipart(`/api/v1/sites/${encodeURIComponent(args.slug)}/media`, form);
|
|
104
|
+
const key = data.key?.replace(/^\//, "");
|
|
105
|
+
return {
|
|
106
|
+
kind: data.kind,
|
|
107
|
+
url: data.url,
|
|
108
|
+
key,
|
|
109
|
+
sitePath: data.sitePath ?? (key ? sitePathFromKey(key) : undefined),
|
|
110
|
+
filename: data.filename ?? args.filename,
|
|
111
|
+
sizeBytes: data.sizeBytes ?? args.bytes.length,
|
|
112
|
+
contentType: data.contentType,
|
|
113
|
+
content: data.content,
|
|
114
|
+
transport: "proxy",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Upload a local file using the Phase E transport matrix. */
|
|
118
|
+
export async function uploadLocalMedia(args) {
|
|
119
|
+
const bytes = await readFile(args.filePath);
|
|
120
|
+
const filename = basename(args.filePath);
|
|
121
|
+
const decision = selectMediaTransport({
|
|
122
|
+
filename,
|
|
123
|
+
sizeBytes: bytes.length,
|
|
124
|
+
headBytes: bytes.subarray(0, 4096),
|
|
125
|
+
});
|
|
126
|
+
if (decision.transport === "reject") {
|
|
127
|
+
throw new CliHttpError(decision.message, EXIT_USAGE, 400, "bad_request");
|
|
128
|
+
}
|
|
129
|
+
if (decision.transport === "client") {
|
|
130
|
+
return uploadViaClient({
|
|
131
|
+
slug: args.slug,
|
|
132
|
+
bytes,
|
|
133
|
+
filename,
|
|
134
|
+
ext: decision.ext,
|
|
135
|
+
contentType: decision.contentType,
|
|
136
|
+
kind: decision.kind,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return uploadViaProxy({ slug: args.slug, bytes, filename });
|
|
140
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -204,7 +204,7 @@ export const COMMANDS = [
|
|
|
204
204
|
"--path must start with /",
|
|
205
205
|
"--attach: local file (uploads via media), /__media/… site path, or https Blob URL; repeatable",
|
|
206
206
|
"--asset-url: https image URL (legacy); repeatable",
|
|
207
|
-
"Local attach uses
|
|
207
|
+
"Local attach uses frontpage media upload (images/PDF ≤25 MB, video ≤50 MB, SVG/text ≤4 MB proxy)",
|
|
208
208
|
"stdout is bare runId (or JSON with --json)",
|
|
209
209
|
],
|
|
210
210
|
seeAlso: ["runs", "deploy", "media"],
|
|
@@ -316,9 +316,20 @@ export const COMMANDS = [
|
|
|
316
316
|
},
|
|
317
317
|
{
|
|
318
318
|
name: "media",
|
|
319
|
-
summary: "List or upload site media
|
|
320
|
-
usage: "frontpage media list|upload <file> [--site <slug>]",
|
|
319
|
+
summary: "List or upload site media (images/PDF ≤25 MB, video ≤50 MB)",
|
|
320
|
+
usage: "frontpage media list|upload <file> [--site <slug>] [--json]",
|
|
321
321
|
topics: ["content"],
|
|
322
|
+
examples: [
|
|
323
|
+
"frontpage media list",
|
|
324
|
+
"frontpage media upload ./hero.webp",
|
|
325
|
+
"frontpage media upload ./clip.mp4",
|
|
326
|
+
],
|
|
327
|
+
flags: [
|
|
328
|
+
"Auto-selects proxy (≤4 MB) vs direct-to-Blob for large rasters/PDFs and all video",
|
|
329
|
+
"Prints sitePath (/__media/uploads/<slug>/…) for markup — never raw blob hosts in pages",
|
|
330
|
+
"SVG stays on the sanitizing proxy path (≤4 MB)",
|
|
331
|
+
],
|
|
332
|
+
seeAlso: ["prompt", "help content", "help site-building"],
|
|
322
333
|
},
|
|
323
334
|
{
|
|
324
335
|
name: "inbox",
|
|
@@ -485,7 +496,7 @@ export const TOPICS = {
|
|
|
485
496
|
Modules & articles (short):
|
|
486
497
|
- Forms: <form data-fp-form="slug"> with named inputs; no action/onsubmit/honeypot.
|
|
487
498
|
- Articles: frontpage articles …; migrate folders via frontpage prompt (don't invent mass delete+write).
|
|
488
|
-
- Media
|
|
499
|
+
- Media: frontpage media upload (images/PDF ≤25 MB, video ≤50 MB). Use sitePath /__media/uploads/<slug>/… in markup (not raw blob URLs). Optional ?w= on rasters only.
|
|
489
500
|
|
|
490
501
|
Full guide: https://frontpage.host/developers/site-building
|
|
491
502
|
See also: frontpage help sync | content | AGENTS.md`,
|
|
@@ -508,13 +519,13 @@ export const TOPICS = {
|
|
|
508
519
|
frontpage modules request-setup <key> --reason "…"
|
|
509
520
|
frontpage extensions
|
|
510
521
|
|
|
511
|
-
--attach uploads
|
|
522
|
+
--attach uploads via media (images/PDF/video/text) or accepts /__media/… / https URLs.
|
|
512
523
|
API errors include requestId in --json error envelopes (and stderr when not --json).
|
|
513
524
|
Mid-task continuation is automatic — there is no runs continue.
|
|
514
525
|
Do not file-sync while an agent run is active (see help sync for push --wait).
|
|
515
526
|
Default after work: review https://<subdomain>.frontpage.host; do not publish.`,
|
|
516
527
|
content: `Content
|
|
517
|
-
frontpage media list|upload <file>
|
|
528
|
+
frontpage media list|upload <file> # images/PDF ≤25 MB, video ≤50 MB; sitePath for markup
|
|
518
529
|
frontpage inbox [leads|subscribers|orders|…] [--mark-read] [--csv]
|
|
519
530
|
frontpage seo get|set --path /
|
|
520
531
|
frontpage articles list|content|create-section|create-article|set-draft|…
|
package/llms.txt
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
- Search flags: --regex --case-sensitive --whole-word --files-only --context-lines --max-results
|
|
11
11
|
- Modules setup: frontpage modules request-setup <key> --reason "…" (owner status bar; CLI cannot show it)
|
|
12
12
|
- Analytics ops: frontpage analytics summary|query|funnel|timeseries|compare|sql (bare analytics = dashboard)
|
|
13
|
-
-
|
|
13
|
+
- Media: frontpage media upload <file> (images/PDF ≤25 MB, video ≤50 MB, SVG/text ≤4 MB); use sitePath /__media/uploads/<slug>/… in markup
|
|
14
|
+
- Prompt attachments: frontpage prompt "…" --attach <file|/__media/…|https://…> [--asset-url https://…] (same media caps as media upload)
|
|
14
15
|
- Errors: --json error envelopes include requestId; runs wait failed/canceled → ok:false + run.error (exit 2); deploy wait fail-closed; push --wait
|
|
15
16
|
- Auth: mint fp_live_ key in Frontpage UI (Developers); frontpage login KEY or FRONTPAGE_API_KEY
|
|
16
17
|
- Site id: subdomain only (frontpage use <subdomain>). Not custom domains.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frontpage-host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Frontpage CLI — manage your site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
"cli",
|
|
35
35
|
"landing-page"
|
|
36
36
|
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@vercel/blob": "^2.4.0"
|
|
39
|
+
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@types/node": "^22.13.10",
|
|
39
42
|
"tsx": "^4.19.3",
|