applaunchflow 0.3.1
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/README.md +110 -0
- package/build/catalog.js +5 -0
- package/build/catalog.test.js +13 -0
- package/build/cli-core.js +20 -0
- package/build/cli-core.test.js +24 -0
- package/build/cli.js +71 -0
- package/build/client/api.js +341 -0
- package/build/http.js +178 -0
- package/build/http.test.js +111 -0
- package/build/index.js +127 -0
- package/build/prompts/register.js +66 -0
- package/build/read-receipt.test.js +36 -0
- package/build/resources/data.js +723 -0
- package/build/resources/register.js +115 -0
- package/build/social-template-previews.js +84 -0
- package/build/template-previews.js +83 -0
- package/build/tool-metadata.js +112 -0
- package/build/tool-metadata.test.js +32 -0
- package/build/tools/assets.js +311 -0
- package/build/tools/graphics.js +475 -0
- package/build/tools/keywords.js +132 -0
- package/build/tools/layouts.js +283 -0
- package/build/tools/localization.js +59 -0
- package/build/tools/mockups.js +324 -0
- package/build/tools/projects.js +113 -0
- package/build/tools/promovideo.js +199 -0
- package/build/tools/screenshots.js +307 -0
- package/build/tools/templates.js +195 -0
- package/build/tools/utils.js +212 -0
- package/build/tools/variants.js +71 -0
- package/package.json +33 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import { promises as dns } from "node:dns";
|
|
3
|
+
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { fail, ok } from "./utils.js";
|
|
7
|
+
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
8
|
+
const MAX_REMOTE_REDIRECTS = 3;
|
|
9
|
+
const blockedNetworks = new BlockList();
|
|
10
|
+
for (const [network, prefix] of [
|
|
11
|
+
["0.0.0.0", 8],
|
|
12
|
+
["10.0.0.0", 8],
|
|
13
|
+
["100.64.0.0", 10],
|
|
14
|
+
["127.0.0.0", 8],
|
|
15
|
+
["169.254.0.0", 16],
|
|
16
|
+
["172.16.0.0", 12],
|
|
17
|
+
["192.0.0.0", 24],
|
|
18
|
+
["192.0.2.0", 24],
|
|
19
|
+
["192.168.0.0", 16],
|
|
20
|
+
["198.18.0.0", 15],
|
|
21
|
+
["198.51.100.0", 24],
|
|
22
|
+
["203.0.113.0", 24],
|
|
23
|
+
["224.0.0.0", 4],
|
|
24
|
+
["240.0.0.0", 4],
|
|
25
|
+
]) {
|
|
26
|
+
blockedNetworks.addSubnet(network, prefix, "ipv4");
|
|
27
|
+
}
|
|
28
|
+
blockedNetworks.addAddress("::", "ipv6");
|
|
29
|
+
blockedNetworks.addAddress("::1", "ipv6");
|
|
30
|
+
for (const [network, prefix] of [
|
|
31
|
+
["64:ff9b:1::", 48],
|
|
32
|
+
["100::", 64],
|
|
33
|
+
["2001:db8::", 32],
|
|
34
|
+
["fc00::", 7],
|
|
35
|
+
["fe80::", 10],
|
|
36
|
+
["ff00::", 8],
|
|
37
|
+
]) {
|
|
38
|
+
blockedNetworks.addSubnet(network, prefix, "ipv6");
|
|
39
|
+
}
|
|
40
|
+
export function isPrivateOrReservedIp(address) {
|
|
41
|
+
const normalized = address.toLowerCase().split("%")[0];
|
|
42
|
+
const family = isIP(normalized);
|
|
43
|
+
if (family === 0)
|
|
44
|
+
return true;
|
|
45
|
+
return blockedNetworks.check(normalized, family === 4 ? "ipv4" : "ipv6");
|
|
46
|
+
}
|
|
47
|
+
async function assertSafeRemoteUrl(value) {
|
|
48
|
+
const url = new URL(value);
|
|
49
|
+
if (url.protocol !== "https:") {
|
|
50
|
+
throw new Error("Hosted connectors only fetch assets over HTTPS");
|
|
51
|
+
}
|
|
52
|
+
if (url.username || url.password) {
|
|
53
|
+
throw new Error("Asset URLs must not contain embedded credentials");
|
|
54
|
+
}
|
|
55
|
+
const addresses = await dns.lookup(url.hostname, { all: true, verbatim: true });
|
|
56
|
+
if (addresses.length === 0 ||
|
|
57
|
+
addresses.some(({ address }) => isPrivateOrReservedIp(address))) {
|
|
58
|
+
throw new Error("Asset URL resolves to a private or reserved network");
|
|
59
|
+
}
|
|
60
|
+
return url;
|
|
61
|
+
}
|
|
62
|
+
async function readResponseWithLimit(response) {
|
|
63
|
+
const declaredLength = Number(response.headers.get("content-length") || 0);
|
|
64
|
+
if (declaredLength > MAX_UPLOAD_BYTES) {
|
|
65
|
+
throw new Error("Asset exceeds the 25 MB upload limit");
|
|
66
|
+
}
|
|
67
|
+
if (!response.body)
|
|
68
|
+
return Buffer.alloc(0);
|
|
69
|
+
const reader = response.body.getReader();
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let total = 0;
|
|
72
|
+
while (true) {
|
|
73
|
+
const { value, done } = await reader.read();
|
|
74
|
+
if (done)
|
|
75
|
+
break;
|
|
76
|
+
total += value.byteLength;
|
|
77
|
+
if (total > MAX_UPLOAD_BYTES) {
|
|
78
|
+
await reader.cancel();
|
|
79
|
+
throw new Error("Asset exceeds the 25 MB upload limit");
|
|
80
|
+
}
|
|
81
|
+
chunks.push(value);
|
|
82
|
+
}
|
|
83
|
+
return Buffer.concat(chunks);
|
|
84
|
+
}
|
|
85
|
+
async function fetchRemoteAsset(value) {
|
|
86
|
+
let current = await assertSafeRemoteUrl(value);
|
|
87
|
+
for (let redirectCount = 0; redirectCount <= MAX_REMOTE_REDIRECTS; redirectCount += 1) {
|
|
88
|
+
const response = await fetch(current, {
|
|
89
|
+
redirect: "manual",
|
|
90
|
+
signal: AbortSignal.timeout(20_000),
|
|
91
|
+
headers: { accept: "image/*,font/*;q=0.8" },
|
|
92
|
+
});
|
|
93
|
+
if (response.status < 300 || response.status >= 400) {
|
|
94
|
+
return { response, finalUrl: current };
|
|
95
|
+
}
|
|
96
|
+
const location = response.headers.get("location");
|
|
97
|
+
await response.body?.cancel();
|
|
98
|
+
if (!location || redirectCount === MAX_REMOTE_REDIRECTS) {
|
|
99
|
+
throw new Error("Asset URL redirected too many times");
|
|
100
|
+
}
|
|
101
|
+
current = await assertSafeRemoteUrl(new URL(location, current).toString());
|
|
102
|
+
}
|
|
103
|
+
throw new Error("Asset URL could not be fetched");
|
|
104
|
+
}
|
|
105
|
+
const uploadSourceSchema = z
|
|
106
|
+
.object({
|
|
107
|
+
path: z.string().optional(),
|
|
108
|
+
url: z.string().url().optional(),
|
|
109
|
+
base64: z.string().optional(),
|
|
110
|
+
filename: z.string().optional(),
|
|
111
|
+
})
|
|
112
|
+
.refine((value) => !!value.path || !!value.url || !!value.base64, {
|
|
113
|
+
message: "Provide path, url, or base64",
|
|
114
|
+
});
|
|
115
|
+
function inferMimeType(filename) {
|
|
116
|
+
const extension = path.extname(filename).toLowerCase();
|
|
117
|
+
switch (extension) {
|
|
118
|
+
case ".png":
|
|
119
|
+
return "image/png";
|
|
120
|
+
case ".jpg":
|
|
121
|
+
case ".jpeg":
|
|
122
|
+
return "image/jpeg";
|
|
123
|
+
case ".webp":
|
|
124
|
+
return "image/webp";
|
|
125
|
+
case ".gif":
|
|
126
|
+
return "image/gif";
|
|
127
|
+
case ".svg":
|
|
128
|
+
return "image/svg+xml";
|
|
129
|
+
case ".ttf":
|
|
130
|
+
return "font/ttf";
|
|
131
|
+
case ".otf":
|
|
132
|
+
return "font/otf";
|
|
133
|
+
case ".woff":
|
|
134
|
+
return "font/woff";
|
|
135
|
+
case ".woff2":
|
|
136
|
+
return "font/woff2";
|
|
137
|
+
default:
|
|
138
|
+
return "application/octet-stream";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function expandPathSource(sourcePath) {
|
|
142
|
+
const stat = await fs.stat(sourcePath);
|
|
143
|
+
if (!stat.isDirectory()) {
|
|
144
|
+
return [sourcePath];
|
|
145
|
+
}
|
|
146
|
+
const entries = await fs.readdir(sourcePath);
|
|
147
|
+
return entries
|
|
148
|
+
.filter((entry) => [".png", ".jpg", ".jpeg", ".webp", ".gif"].includes(path.extname(entry).toLowerCase()))
|
|
149
|
+
.map((entry) => path.join(sourcePath, entry))
|
|
150
|
+
.sort();
|
|
151
|
+
}
|
|
152
|
+
async function resolveUploadPayload(source) {
|
|
153
|
+
if (source.path) {
|
|
154
|
+
if (process.env.APPLAUNCHFLOW_MCP_REMOTE === "1") {
|
|
155
|
+
throw new Error("Local file paths are unavailable to hosted connectors; use an HTTPS URL or base64 data");
|
|
156
|
+
}
|
|
157
|
+
const expanded = await expandPathSource(source.path);
|
|
158
|
+
return Promise.all(expanded.map(async (filePath) => {
|
|
159
|
+
const buffer = await fs.readFile(filePath);
|
|
160
|
+
return {
|
|
161
|
+
buffer,
|
|
162
|
+
filename: source.filename || path.basename(filePath),
|
|
163
|
+
contentType: inferMimeType(source.filename || filePath),
|
|
164
|
+
};
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
if (source.url) {
|
|
168
|
+
const { response, finalUrl } = await fetchRemoteAsset(source.url);
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
throw new Error(`Failed to fetch ${source.url}: ${response.status}`);
|
|
171
|
+
}
|
|
172
|
+
const contentType = response.headers.get("content-type") || "";
|
|
173
|
+
if (!/^(image|font)\//i.test(contentType)) {
|
|
174
|
+
throw new Error(`Asset URL returned unsupported content type: ${contentType || "unknown"}`);
|
|
175
|
+
}
|
|
176
|
+
const buffer = await readResponseWithLimit(response);
|
|
177
|
+
const filename = source.filename || finalUrl.pathname.split("/").pop() || "upload.png";
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
buffer,
|
|
181
|
+
filename,
|
|
182
|
+
contentType: contentType || inferMimeType(filename),
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
const filename = source.filename || "upload.png";
|
|
187
|
+
const normalizedBase64 = source.base64.replace(/^data:[^;]+;base64,/, "");
|
|
188
|
+
const buffer = Buffer.from(normalizedBase64, "base64");
|
|
189
|
+
if (buffer.byteLength > MAX_UPLOAD_BYTES) {
|
|
190
|
+
throw new Error("Asset exceeds the 25 MB upload limit");
|
|
191
|
+
}
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
buffer,
|
|
195
|
+
filename,
|
|
196
|
+
contentType: inferMimeType(filename),
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
export function registerAssetTools(server, client) {
|
|
201
|
+
server.registerTool("upload_screenshots", {
|
|
202
|
+
title: "Upload Screenshots",
|
|
203
|
+
description: "Upload screenshot images for screenshot generation workflows. Hosted connectors must use HTTPS URLs or base64 data; local file paths are available only to the npm/stdio connector.",
|
|
204
|
+
inputSchema: {
|
|
205
|
+
projectId: z.string().uuid(),
|
|
206
|
+
deviceType: z.enum(["mobile", "tablet", "desktop", "watch"]),
|
|
207
|
+
platform: z.enum(["ios", "android"]),
|
|
208
|
+
sources: z.array(uploadSourceSchema).min(1),
|
|
209
|
+
},
|
|
210
|
+
}, async ({ projectId, deviceType, platform, sources }) => {
|
|
211
|
+
try {
|
|
212
|
+
const uploads = [];
|
|
213
|
+
for (const source of sources) {
|
|
214
|
+
const payloads = await resolveUploadPayload(source);
|
|
215
|
+
for (const payload of payloads) {
|
|
216
|
+
const signed = await client.createSignedUpload({
|
|
217
|
+
projectId,
|
|
218
|
+
filename: payload.filename,
|
|
219
|
+
contentType: payload.contentType,
|
|
220
|
+
deviceType,
|
|
221
|
+
platform,
|
|
222
|
+
});
|
|
223
|
+
await client.uploadBinary(signed.uploadUrl, payload.buffer, payload.contentType);
|
|
224
|
+
uploads.push({
|
|
225
|
+
filename: signed.filename,
|
|
226
|
+
path: signed.path,
|
|
227
|
+
fullPath: signed.fullPath,
|
|
228
|
+
subfolder: signed.subfolder,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return ok({ uploads }, "Uploaded assets");
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
return fail(error);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
server.registerTool("list_illustrations", {
|
|
239
|
+
title: "List Illustrations",
|
|
240
|
+
description: "List available illustrations. Use 'shared' source to browse the shared library (icons, stickers, etc.). " +
|
|
241
|
+
"Use 'project' source to list illustrations uploaded to a specific project. " +
|
|
242
|
+
"When the user wants to add an illustration, list available options FIRST so they can pick one or choose to upload.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
source: z
|
|
245
|
+
.enum(["shared", "project"])
|
|
246
|
+
.describe("'shared' for the shared library, 'project' for project-uploaded illustrations."),
|
|
247
|
+
projectId: z
|
|
248
|
+
.string()
|
|
249
|
+
.uuid()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe("Required when source is 'project'."),
|
|
252
|
+
category: z
|
|
253
|
+
.string()
|
|
254
|
+
.optional()
|
|
255
|
+
.describe("Filter shared library by category (e.g. 'Icons', 'Sticker', 'Illustrations')."),
|
|
256
|
+
search: z
|
|
257
|
+
.string()
|
|
258
|
+
.optional()
|
|
259
|
+
.describe("Search term to filter by name."),
|
|
260
|
+
},
|
|
261
|
+
}, async ({ source, projectId, category, search }) => {
|
|
262
|
+
try {
|
|
263
|
+
if (source === "project") {
|
|
264
|
+
if (!projectId) {
|
|
265
|
+
throw new Error("projectId is required when source is 'project'");
|
|
266
|
+
}
|
|
267
|
+
return ok(await client.listProjectIllustrations(projectId), "Fetched project illustrations");
|
|
268
|
+
}
|
|
269
|
+
return ok(await client.listSharedIllustrations({ category, search, limit: 50 }), "Fetched shared illustrations");
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return fail(error);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
server.registerTool("upload_asset", {
|
|
276
|
+
title: "Upload Asset",
|
|
277
|
+
description: "Upload an image asset (panorama background, illustration, logo, or background image). Hosted connectors must use an HTTPS URL or base64 data; local paths are available only to the npm/stdio connector. " +
|
|
278
|
+
"Returns the stored path which can then be used in transform_layout operations " +
|
|
279
|
+
"(e.g. set panoramaBackground.imageUrl or illustration imageUrl to the returned path). " +
|
|
280
|
+
"For illustrations: list_illustrations first to show existing options, then upload only if the user wants a custom image. " +
|
|
281
|
+
"For panoramas: ask the user to provide a local image path to upload.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
projectId: z.string().uuid(),
|
|
284
|
+
fileType: z
|
|
285
|
+
.enum(["illustration", "logo", "panorama", "background"])
|
|
286
|
+
.describe("Type of asset: 'panorama' for panorama backgrounds, 'illustration' for decorative images/stickers, 'logo' for app logo, 'background' for per-screen background images."),
|
|
287
|
+
source: uploadSourceSchema.describe("The image source — provide a local file path, a URL, or base64 data."),
|
|
288
|
+
},
|
|
289
|
+
}, async ({ projectId, fileType, source }) => {
|
|
290
|
+
try {
|
|
291
|
+
const payloads = await resolveUploadPayload(source);
|
|
292
|
+
const payload = payloads[0];
|
|
293
|
+
const signed = await client.createSignedUpload({
|
|
294
|
+
projectId,
|
|
295
|
+
filename: payload.filename,
|
|
296
|
+
contentType: payload.contentType,
|
|
297
|
+
fileType,
|
|
298
|
+
});
|
|
299
|
+
await client.uploadBinary(signed.uploadUrl, payload.buffer, payload.contentType);
|
|
300
|
+
return ok({
|
|
301
|
+
filename: signed.filename,
|
|
302
|
+
path: signed.path,
|
|
303
|
+
fullPath: signed.fullPath,
|
|
304
|
+
subfolder: signed.subfolder,
|
|
305
|
+
}, `Uploaded ${fileType} asset`);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
return fail(error);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|