frontpage-host 0.1.8 → 0.1.10
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 +6 -4
- package/dist/argv.d.ts +12 -0
- package/dist/argv.js +48 -0
- package/dist/bin.js +5 -0
- package/dist/commands/help.js +1 -1
- package/dist/commands/media.js +25 -14
- package/dist/commands/open.d.ts +2 -0
- package/dist/commands/open.js +40 -6
- 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/commands/search.js +14 -18
- package/dist/commands/seo.js +28 -13
- package/dist/commands/version.d.ts +1 -0
- package/dist/commands/version.js +13 -0
- package/dist/flags.d.ts +3 -1
- package/dist/flags.js +7 -2
- 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 +141 -0
- package/dist/registry.js +51 -15
- package/dist/version.d.ts +7 -0
- package/dist/version.js +15 -0
- package/llms.txt +5 -2
- package/package.json +4 -1
package/dist/flags.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Strip global flags from argv. Safe to call before command dispatch.
|
|
3
|
-
* Recognizes --json, --quiet, --yes, and help aliases anywhere in the list.
|
|
3
|
+
* Recognizes --json, --quiet, --yes, --version/-V, and help aliases anywhere in the list.
|
|
4
4
|
*/
|
|
5
5
|
export function parseGlobalFlags(argv) {
|
|
6
6
|
const args = [];
|
|
@@ -8,6 +8,7 @@ export function parseGlobalFlags(argv) {
|
|
|
8
8
|
let quiet = false;
|
|
9
9
|
let yes = false;
|
|
10
10
|
let wantsHelp = false;
|
|
11
|
+
let wantsVersion = false;
|
|
11
12
|
for (const a of argv) {
|
|
12
13
|
if (a === "--json") {
|
|
13
14
|
json = true;
|
|
@@ -25,7 +26,11 @@ export function parseGlobalFlags(argv) {
|
|
|
25
26
|
wantsHelp = true;
|
|
26
27
|
continue;
|
|
27
28
|
}
|
|
29
|
+
if (a === "--version" || a === "-V") {
|
|
30
|
+
wantsVersion = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
28
33
|
args.push(a);
|
|
29
34
|
}
|
|
30
|
-
return { json, quiet, yes, wantsHelp, args };
|
|
35
|
+
return { json, quiet, yes, wantsHelp, wantsVersion, args };
|
|
31
36
|
}
|
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,141 @@
|
|
|
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
|
+
// Local validation only — omit httpStatus (status 0) and never invent requestId (R10).
|
|
128
|
+
throw new CliHttpError(decision.message, EXIT_USAGE, 0, "bad_request");
|
|
129
|
+
}
|
|
130
|
+
if (decision.transport === "client") {
|
|
131
|
+
return uploadViaClient({
|
|
132
|
+
slug: args.slug,
|
|
133
|
+
bytes,
|
|
134
|
+
filename,
|
|
135
|
+
ext: decision.ext,
|
|
136
|
+
contentType: decision.contentType,
|
|
137
|
+
kind: decision.kind,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return uploadViaProxy({ slug: args.slug, bytes, filename });
|
|
141
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -26,6 +26,13 @@ export const COMMANDS = [
|
|
|
26
26
|
"frontpage help --json",
|
|
27
27
|
],
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
name: "version",
|
|
31
|
+
summary: "Print frontpage-host package version",
|
|
32
|
+
usage: "frontpage version | frontpage --version | frontpage -V",
|
|
33
|
+
examples: ["frontpage --version", "frontpage version --json"],
|
|
34
|
+
flags: ["Also available as global --version / -V"],
|
|
35
|
+
},
|
|
29
36
|
{
|
|
30
37
|
name: "login",
|
|
31
38
|
summary: "Save an API key to the OS keychain",
|
|
@@ -133,15 +140,21 @@ export const COMMANDS = [
|
|
|
133
140
|
{
|
|
134
141
|
name: "search",
|
|
135
142
|
summary: "Search remote text files",
|
|
136
|
-
usage: "frontpage search <pattern> [path] [--regex] [--case-sensitive] [--whole-word] [--files-only] [--context-lines N] [--max-results N] [--site <slug>] [--json]",
|
|
143
|
+
usage: "frontpage search <pattern> [path|--path <path>] [--regex] [--case-sensitive] [--whole-word] [--files-only] [--context-lines N] [--max-results N] [--site <slug>] [--json]",
|
|
137
144
|
topics: ["sync"],
|
|
145
|
+
examples: [
|
|
146
|
+
"frontpage search Subscribe src/pages",
|
|
147
|
+
"frontpage search Subscribe --path src/pages",
|
|
148
|
+
],
|
|
138
149
|
flags: [
|
|
150
|
+
"--path <prefix> (alias for positional path filter; flag wins if both present)",
|
|
139
151
|
"--regex",
|
|
140
152
|
"--case-sensitive",
|
|
141
153
|
"--whole-word",
|
|
142
154
|
"--files-only",
|
|
143
155
|
"--context-lines <n>",
|
|
144
156
|
"--max-results <n>",
|
|
157
|
+
"Unknown leftover flags fail closed (exit 2)",
|
|
145
158
|
],
|
|
146
159
|
},
|
|
147
160
|
{
|
|
@@ -184,9 +197,13 @@ export const COMMANDS = [
|
|
|
184
197
|
{
|
|
185
198
|
name: "open",
|
|
186
199
|
summary: "Open the site subdomain in a browser",
|
|
187
|
-
usage: "frontpage open [--site <slug>] [--prod]",
|
|
200
|
+
usage: "frontpage open [/path|--path /path] [--site <slug>] [--prod]",
|
|
188
201
|
topics: ["sync"],
|
|
189
|
-
|
|
202
|
+
examples: ["frontpage open", "frontpage open /updates", "frontpage open --path /pricing"],
|
|
203
|
+
flags: [
|
|
204
|
+
"Optional site-relative path appended to https://<slug>.frontpage.host",
|
|
205
|
+
"--prod is a no-op stub today — still opens the *.frontpage.host subdomain",
|
|
206
|
+
],
|
|
190
207
|
},
|
|
191
208
|
{
|
|
192
209
|
name: "prompt",
|
|
@@ -204,7 +221,7 @@ export const COMMANDS = [
|
|
|
204
221
|
"--path must start with /",
|
|
205
222
|
"--attach: local file (uploads via media), /__media/… site path, or https Blob URL; repeatable",
|
|
206
223
|
"--asset-url: https image URL (legacy); repeatable",
|
|
207
|
-
"Local attach uses
|
|
224
|
+
"Local attach uses frontpage media upload (images/PDF ≤25 MB, video ≤50 MB, SVG/text ≤4 MB proxy)",
|
|
208
225
|
"stdout is bare runId (or JSON with --json)",
|
|
209
226
|
],
|
|
210
227
|
seeAlso: ["runs", "deploy", "media"],
|
|
@@ -316,9 +333,21 @@ export const COMMANDS = [
|
|
|
316
333
|
},
|
|
317
334
|
{
|
|
318
335
|
name: "media",
|
|
319
|
-
summary: "List or upload site media
|
|
320
|
-
usage: "frontpage media list|upload <file> [--site <slug>]",
|
|
336
|
+
summary: "List or upload site media (images/PDF ≤25 MB, video ≤50 MB)",
|
|
337
|
+
usage: "frontpage media list|upload <file> [--site <slug>] [--json]",
|
|
321
338
|
topics: ["content"],
|
|
339
|
+
examples: [
|
|
340
|
+
"frontpage media list",
|
|
341
|
+
"frontpage media upload ./hero.webp",
|
|
342
|
+
"frontpage media upload ./clip.mp4",
|
|
343
|
+
],
|
|
344
|
+
flags: [
|
|
345
|
+
"Auto-selects proxy (≤4 MB) vs direct-to-Blob for large rasters/PDFs and all video",
|
|
346
|
+
"Human success: sitePath first (markup-safe), then url: (blob URL for debugging)",
|
|
347
|
+
"Use sitePath (/__media/uploads/<slug>/…) in markup — never raw blob hosts in pages",
|
|
348
|
+
"SVG stays on the sanitizing proxy path (≤4 MB)",
|
|
349
|
+
],
|
|
350
|
+
seeAlso: ["prompt", "help content", "help site-building"],
|
|
322
351
|
},
|
|
323
352
|
{
|
|
324
353
|
name: "inbox",
|
|
@@ -330,8 +359,14 @@ export const COMMANDS = [
|
|
|
330
359
|
{
|
|
331
360
|
name: "seo",
|
|
332
361
|
summary: "Get or set page SEO",
|
|
333
|
-
usage: "frontpage seo get|set --path / [--title …] [--description …] [--site <slug>]",
|
|
362
|
+
usage: "frontpage seo get [/path|--path /path] | seo set --path / [--title …] [--description …] [--site <slug>]",
|
|
334
363
|
topics: ["content"],
|
|
364
|
+
examples: [
|
|
365
|
+
"frontpage seo get /",
|
|
366
|
+
"frontpage seo get --path /updates",
|
|
367
|
+
"frontpage seo set --path / --title \"Home\"",
|
|
368
|
+
],
|
|
369
|
+
flags: ["get accepts positional path or --path (flag wins); unknown leftover flags fail closed"],
|
|
335
370
|
},
|
|
336
371
|
{
|
|
337
372
|
name: "articles",
|
|
@@ -436,8 +471,8 @@ export const TOPICS = {
|
|
|
436
471
|
frontpage push [--message …] [--wait] # uploads allowlisted files; CAS + active-run guard
|
|
437
472
|
frontpage ls [prefix]
|
|
438
473
|
frontpage cat <path>
|
|
439
|
-
frontpage search <pattern> [path] [--regex] [--case-sensitive] [--whole-word] [--files-only]
|
|
440
|
-
frontpage open
|
|
474
|
+
frontpage search <pattern> [path|--path <path>] [--regex] [--case-sensitive] [--whole-word] [--files-only]
|
|
475
|
+
frontpage open [/path] # opens https://<slug>.frontpage.host[/path] (--prod is a no-op stub)
|
|
441
476
|
|
|
442
477
|
Local state: .frontpage/state.json { slug, baseCommitSha }
|
|
443
478
|
Pull BEFORE EDITING when tip may have moved (UI agent / another checkout) — check tipDrift.
|
|
@@ -485,7 +520,7 @@ export const TOPICS = {
|
|
|
485
520
|
Modules & articles (short):
|
|
486
521
|
- Forms: <form data-fp-form="slug"> with named inputs; no action/onsubmit/honeypot.
|
|
487
522
|
- Articles: frontpage articles …; migrate folders via frontpage prompt (don't invent mass delete+write).
|
|
488
|
-
- Media
|
|
523
|
+
- 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
524
|
|
|
490
525
|
Full guide: https://frontpage.host/developers/site-building
|
|
491
526
|
See also: frontpage help sync | content | AGENTS.md`,
|
|
@@ -508,15 +543,15 @@ export const TOPICS = {
|
|
|
508
543
|
frontpage modules request-setup <key> --reason "…"
|
|
509
544
|
frontpage extensions
|
|
510
545
|
|
|
511
|
-
--attach uploads
|
|
512
|
-
API errors include requestId in --json
|
|
546
|
+
--attach uploads via media (images/PDF/video/text) or accepts /__media/… / https URLs.
|
|
547
|
+
API errors include requestId in --json envelopes when the server returned one; local validation errors omit requestId and httpStatus.
|
|
513
548
|
Mid-task continuation is automatic — there is no runs continue.
|
|
514
549
|
Do not file-sync while an agent run is active (see help sync for push --wait).
|
|
515
550
|
Default after work: review https://<subdomain>.frontpage.host; do not publish.`,
|
|
516
551
|
content: `Content
|
|
517
|
-
frontpage media list|upload <file>
|
|
552
|
+
frontpage media list|upload <file> # images/PDF ≤25 MB, video ≤50 MB; human prints sitePath first
|
|
518
553
|
frontpage inbox [leads|subscribers|orders|…] [--mark-read] [--csv]
|
|
519
|
-
frontpage seo get|set --path /
|
|
554
|
+
frontpage seo get [/path|--path /path] | seo set --path /
|
|
520
555
|
frontpage articles list|content|create-section|create-article|set-draft|…
|
|
521
556
|
frontpage fonts [list|search] [--slot …] [--query …]
|
|
522
557
|
frontpage tokens set --group … --key … --value …
|
|
@@ -562,7 +597,8 @@ export const TOPICS = {
|
|
|
562
597
|
{ "ok": true, "command": "…", "data": {…} }
|
|
563
598
|
{ "ok": false, "command": "…", "error": { "message", "code?", "httpStatus?", "exitCode", "requestId?" }, "data"? }
|
|
564
599
|
Failures with --json still use the process exit code; envelope is on stdout.
|
|
565
|
-
|
|
600
|
+
requestId is present only when the API returned one — local CLI validation (e.g. media size/type reject) omits requestId and httpStatus.
|
|
601
|
+
Prefer requestId from API error envelopes when filing support issues.`,
|
|
566
602
|
};
|
|
567
603
|
export function findCommand(name) {
|
|
568
604
|
return COMMANDS.find((c) => c.name === name);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Read package identity from frontpage-host package.json (works from dist/). */
|
|
2
|
+
export declare function packageIdentity(): {
|
|
3
|
+
name: string;
|
|
4
|
+
version: string;
|
|
5
|
+
};
|
|
6
|
+
/** Human one-liner: `frontpage-host 0.1.10`. */
|
|
7
|
+
export declare function versionLine(): string;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
/** Read package identity from frontpage-host package.json (works from dist/). */
|
|
4
|
+
export function packageIdentity() {
|
|
5
|
+
const pkg = require("../package.json");
|
|
6
|
+
return {
|
|
7
|
+
name: pkg.name ?? "frontpage-host",
|
|
8
|
+
version: pkg.version ?? "0.0.0",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Human one-liner: `frontpage-host 0.1.10`. */
|
|
12
|
+
export function versionLine() {
|
|
13
|
+
const { name, version } = packageIdentity();
|
|
14
|
+
return `${name} ${version}`;
|
|
15
|
+
}
|