withframe 0.0.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 +93 -0
- package/dist/index.mjs +1756 -0
- package/package.json +69 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { InjectionMode, asClass, asFunction, asValue, createContainer } from "awilix";
|
|
5
|
+
import open from "open";
|
|
6
|
+
import { access, chmod, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { input, select } from "@inquirer/prompts";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import figlet from "figlet";
|
|
14
|
+
//#region src/constants/index.ts
|
|
15
|
+
const CLI_NAME = "withframe";
|
|
16
|
+
const CLI_VERSION = "1.0.0";
|
|
17
|
+
const REGISTRY_URL = "https://withfra.me";
|
|
18
|
+
const WITHFRAME_DIR = ".withframe";
|
|
19
|
+
const AUTH_FILE_NAME = "auth.json";
|
|
20
|
+
const CONFIG_FILE_NAME = "withframe.config.json";
|
|
21
|
+
const SEPARATOR = " ───────────────────────────────────────";
|
|
22
|
+
const COLORS = {
|
|
23
|
+
PRIMARY_50: "#edf8ff",
|
|
24
|
+
PRIMARY_100: "#d7edff",
|
|
25
|
+
PRIMARY_200: "#b9e0ff",
|
|
26
|
+
PRIMARY_300: "#88cfff",
|
|
27
|
+
PRIMARY_400: "#50b3ff",
|
|
28
|
+
PRIMARY_500: "#2890ff",
|
|
29
|
+
PRIMARY_600: "#0469ff",
|
|
30
|
+
PRIMARY_700: "#0a59eb",
|
|
31
|
+
PRIMARY_800: "#0f48be",
|
|
32
|
+
PRIMARY_900: "#134195",
|
|
33
|
+
PRIMARY_950: "#11295a"
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/utils/delay.ts
|
|
37
|
+
const delay = (ms) => new Promise((resolve) => {
|
|
38
|
+
setTimeout(resolve, ms);
|
|
39
|
+
});
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/api/auth-service.ts
|
|
42
|
+
var AuthService = class {
|
|
43
|
+
constructor(registryClient) {
|
|
44
|
+
this.registryClient = registryClient;
|
|
45
|
+
}
|
|
46
|
+
async login({ openBrowser = true }) {
|
|
47
|
+
const session = await this.registryClient.startDeviceFlow();
|
|
48
|
+
if (openBrowser) await open(session.verificationUriComplete).catch(() => {});
|
|
49
|
+
const deadline = Date.now() + session.expiresIn * 1e3;
|
|
50
|
+
let intervalSeconds = Math.max(1, session.interval);
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
await delay(intervalSeconds * 1e3);
|
|
53
|
+
const poll = await this.registryClient.pollDeviceFlow({ deviceCode: session.deviceCode });
|
|
54
|
+
if (poll.status === "pending") {
|
|
55
|
+
intervalSeconds = Math.max(1, poll.interval ?? intervalSeconds);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (poll.status === "authorized") return {
|
|
59
|
+
userCode: session.userCode,
|
|
60
|
+
verificationUri: session.verificationUri,
|
|
61
|
+
accessToken: poll.accessToken
|
|
62
|
+
};
|
|
63
|
+
throw new Error(poll.message);
|
|
64
|
+
}
|
|
65
|
+
throw new Error("Device login timed out. Please run `withframe login` again.");
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/lib/normalize.ts
|
|
70
|
+
const normalizeText = (value) => {
|
|
71
|
+
if (typeof value !== "string") return;
|
|
72
|
+
return value.trim() || void 0;
|
|
73
|
+
};
|
|
74
|
+
const normalizeProjectTarget = (value) => {
|
|
75
|
+
const normalized = normalizeText(value);
|
|
76
|
+
if (!normalized) return;
|
|
77
|
+
if (normalized === "react_native" || normalized === "expo") return normalized;
|
|
78
|
+
throw new Error("Invalid target value. Use 'react_native' or 'expo'.");
|
|
79
|
+
};
|
|
80
|
+
const normalizeOptions = (opts) => {
|
|
81
|
+
return {
|
|
82
|
+
cwd: normalizeText(opts.cwd),
|
|
83
|
+
target: normalizeProjectTarget(opts.target),
|
|
84
|
+
variant: normalizeText(opts.variant) ?? "default",
|
|
85
|
+
yes: Boolean(opts.yes)
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/lib/type-guards.ts
|
|
90
|
+
const isString = (value) => {
|
|
91
|
+
return typeof value === "string";
|
|
92
|
+
};
|
|
93
|
+
const isFunction = (value) => {
|
|
94
|
+
return typeof value === "function";
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/lib/project.ts
|
|
98
|
+
const PACKAGE_NAME_ALIASES = { "@react-native-async-storage": "@react-native-async-storage/async-storage" };
|
|
99
|
+
const hasFile = async (filePath) => {
|
|
100
|
+
try {
|
|
101
|
+
await access(filePath);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const hasDirectory = async (directoryPath) => {
|
|
108
|
+
try {
|
|
109
|
+
return (await stat(directoryPath)).isDirectory();
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const readPackageJson = async (projectRoot) => {
|
|
115
|
+
const packageJsonPath = path.join(projectRoot, "package.json");
|
|
116
|
+
const raw = await readFile(packageJsonPath, "utf8").catch(() => null);
|
|
117
|
+
if (!raw) throw new Error(`Could not find package.json in ${projectRoot}`);
|
|
118
|
+
let data;
|
|
119
|
+
try {
|
|
120
|
+
data = JSON.parse(raw);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Failed to parse package.json: ${error instanceof Error ? error.message : "Invalid JSON"}`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
path: packageJsonPath,
|
|
126
|
+
data
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
const normalizeDependencyVersion = (version) => {
|
|
130
|
+
const normalized = version.trim();
|
|
131
|
+
if (!normalized || normalized === "*") return "";
|
|
132
|
+
return normalized;
|
|
133
|
+
};
|
|
134
|
+
const normalizePackageName = (name) => {
|
|
135
|
+
const normalized = PACKAGE_NAME_ALIASES[name.trim()] ?? name.trim();
|
|
136
|
+
if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(normalized)) throw new Error(`Invalid package name in manifest: ${name}`);
|
|
137
|
+
return normalized;
|
|
138
|
+
};
|
|
139
|
+
const toDependencySpecifier = (name, version) => {
|
|
140
|
+
const normalizedVersion = normalizeDependencyVersion(version);
|
|
141
|
+
return normalizedVersion ? `${name}@${normalizedVersion}` : name;
|
|
142
|
+
};
|
|
143
|
+
const collectProjectDependencies = (data) => ({
|
|
144
|
+
...data.dependencies ?? {},
|
|
145
|
+
...data.devDependencies ?? {},
|
|
146
|
+
...data.peerDependencies ?? {}
|
|
147
|
+
});
|
|
148
|
+
const detectTargetFromDependencies = (data) => {
|
|
149
|
+
const dependencies = collectProjectDependencies(data);
|
|
150
|
+
if (isString(dependencies.expo)) return "expo";
|
|
151
|
+
if (isString(dependencies["react-native"])) return "react_native";
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
const resolveProjectRoot = (cwdOption) => {
|
|
155
|
+
return !cwdOption ? process.cwd() : path.resolve(cwdOption);
|
|
156
|
+
};
|
|
157
|
+
const detectProjectTarget = async ({ projectRoot, optionTarget, config }) => {
|
|
158
|
+
if (optionTarget) return optionTarget;
|
|
159
|
+
if (config.target === "react_native" || config.target === "expo") return config.target;
|
|
160
|
+
const { data } = await readPackageJson(projectRoot);
|
|
161
|
+
const detectedTarget = detectTargetFromDependencies(data);
|
|
162
|
+
if (!detectedTarget) throw new Error("Could not detect project target. Install 'expo' or 'react-native', or set target in withframe.config.json.");
|
|
163
|
+
return detectedTarget;
|
|
164
|
+
};
|
|
165
|
+
const shouldInstallManifestDependencies = async (projectRoot) => {
|
|
166
|
+
if (!await hasFile(path.join(projectRoot, "package.json"))) return false;
|
|
167
|
+
const { data } = await readPackageJson(projectRoot);
|
|
168
|
+
return Boolean(detectTargetFromDependencies(data));
|
|
169
|
+
};
|
|
170
|
+
const resolveOutputDirectory = async ({ projectRoot, config }) => {
|
|
171
|
+
if (config.outputDir) return config.outputDir;
|
|
172
|
+
if (await hasDirectory(path.join(projectRoot, "app", "components"))) return path.join("app", "components", "withframe");
|
|
173
|
+
return path.join("src", "components", "withframe");
|
|
174
|
+
};
|
|
175
|
+
const detectPackageManager = async (projectRoot) => {
|
|
176
|
+
if (await hasFile(path.join(projectRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
177
|
+
if (await hasFile(path.join(projectRoot, "yarn.lock"))) return "yarn";
|
|
178
|
+
if (await hasFile(path.join(projectRoot, "bun.lockb")) || await hasFile(path.join(projectRoot, "bun.lock"))) return "bun";
|
|
179
|
+
return "npm";
|
|
180
|
+
};
|
|
181
|
+
const isPathInsideRoot = (projectRoot, targetPath) => {
|
|
182
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
183
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
184
|
+
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
|
|
185
|
+
};
|
|
186
|
+
const writeManifestFiles = async ({ projectRoot, outputDir, files, yes, confirmOverwrite }) => {
|
|
187
|
+
const created = [];
|
|
188
|
+
const overwritten = [];
|
|
189
|
+
const skipped = [];
|
|
190
|
+
for (const file of files) {
|
|
191
|
+
const relativeFilePath = file.path.replace(/^\/+/, "");
|
|
192
|
+
const destination = path.resolve(projectRoot, outputDir, relativeFilePath);
|
|
193
|
+
if (!isPathInsideRoot(projectRoot, destination)) throw new Error(`Refusing to write outside project root: ${file.path}`);
|
|
194
|
+
const exists = await hasFile(destination);
|
|
195
|
+
if (exists) {
|
|
196
|
+
let shouldOverwrite = yes;
|
|
197
|
+
if (!shouldOverwrite) shouldOverwrite = await confirmOverwrite(destination);
|
|
198
|
+
if (!shouldOverwrite) {
|
|
199
|
+
skipped.push(destination);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
204
|
+
await writeFile(destination, file.content, "utf8");
|
|
205
|
+
if (exists) overwritten.push(destination);
|
|
206
|
+
else created.push(destination);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
created,
|
|
210
|
+
overwritten,
|
|
211
|
+
skipped
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
const mergeManifestDependencies = async ({ projectRoot, manifest }) => {
|
|
215
|
+
const content = (await readPackageJson(projectRoot)).data;
|
|
216
|
+
content.dependencies = content.dependencies ?? {};
|
|
217
|
+
content.devDependencies = content.devDependencies ?? {};
|
|
218
|
+
content.peerDependencies = content.peerDependencies ?? {};
|
|
219
|
+
const hasAnyDependency = (name) => Boolean(content.dependencies?.[name] ?? content.devDependencies?.[name] ?? content.peerDependencies?.[name]);
|
|
220
|
+
const runtimeToInstall = [];
|
|
221
|
+
const devToInstall = [];
|
|
222
|
+
for (const [rawName, version] of Object.entries(manifest.dependencies ?? {})) {
|
|
223
|
+
const name = normalizePackageName(rawName);
|
|
224
|
+
if (hasAnyDependency(name)) continue;
|
|
225
|
+
runtimeToInstall.push(toDependencySpecifier(name, version));
|
|
226
|
+
}
|
|
227
|
+
for (const [rawName, version] of Object.entries(manifest.peerDependencies ?? {})) {
|
|
228
|
+
const name = normalizePackageName(rawName);
|
|
229
|
+
if (hasAnyDependency(name)) continue;
|
|
230
|
+
runtimeToInstall.push(toDependencySpecifier(name, version));
|
|
231
|
+
}
|
|
232
|
+
for (const [rawName, version] of Object.entries(manifest.devDependencies ?? {})) {
|
|
233
|
+
const name = normalizePackageName(rawName);
|
|
234
|
+
if (hasAnyDependency(name)) continue;
|
|
235
|
+
devToInstall.push(toDependencySpecifier(name, version));
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
runtimeToInstall,
|
|
239
|
+
devToInstall
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
const runPackageCommand = async ({ projectRoot, command, args }) => {
|
|
243
|
+
await new Promise((resolve, reject) => {
|
|
244
|
+
const child = spawn(command, args, {
|
|
245
|
+
cwd: projectRoot,
|
|
246
|
+
stdio: "inherit"
|
|
247
|
+
});
|
|
248
|
+
child.on("error", (error) => {
|
|
249
|
+
reject(error);
|
|
250
|
+
});
|
|
251
|
+
child.on("close", (code) => {
|
|
252
|
+
if (code === 0) {
|
|
253
|
+
resolve();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
reject(/* @__PURE__ */ new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
const installDependencies = async ({ projectRoot, runtimeDependencies, devDependencies }) => {
|
|
261
|
+
if (!runtimeDependencies.length && !devDependencies.length) return [];
|
|
262
|
+
const manager = await detectPackageManager(projectRoot);
|
|
263
|
+
if (runtimeDependencies.length) {
|
|
264
|
+
if (manager === "npm") await runPackageCommand({
|
|
265
|
+
projectRoot,
|
|
266
|
+
command: "npm",
|
|
267
|
+
args: [
|
|
268
|
+
"install",
|
|
269
|
+
"--no-progress",
|
|
270
|
+
...runtimeDependencies
|
|
271
|
+
]
|
|
272
|
+
});
|
|
273
|
+
if (manager === "pnpm") await runPackageCommand({
|
|
274
|
+
projectRoot,
|
|
275
|
+
command: "pnpm",
|
|
276
|
+
args: ["add", ...runtimeDependencies]
|
|
277
|
+
});
|
|
278
|
+
if (manager === "yarn") await runPackageCommand({
|
|
279
|
+
projectRoot,
|
|
280
|
+
command: "yarn",
|
|
281
|
+
args: ["add", ...runtimeDependencies]
|
|
282
|
+
});
|
|
283
|
+
if (manager === "bun") await runPackageCommand({
|
|
284
|
+
projectRoot,
|
|
285
|
+
command: "bun",
|
|
286
|
+
args: ["add", ...runtimeDependencies]
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (devDependencies.length) {
|
|
290
|
+
if (manager === "npm") await runPackageCommand({
|
|
291
|
+
projectRoot,
|
|
292
|
+
command: "npm",
|
|
293
|
+
args: [
|
|
294
|
+
"install",
|
|
295
|
+
"--no-progress",
|
|
296
|
+
"--save-dev",
|
|
297
|
+
...devDependencies
|
|
298
|
+
]
|
|
299
|
+
});
|
|
300
|
+
if (manager === "pnpm") await runPackageCommand({
|
|
301
|
+
projectRoot,
|
|
302
|
+
command: "pnpm",
|
|
303
|
+
args: [
|
|
304
|
+
"add",
|
|
305
|
+
"--save-dev",
|
|
306
|
+
...devDependencies
|
|
307
|
+
]
|
|
308
|
+
});
|
|
309
|
+
if (manager === "yarn") await runPackageCommand({
|
|
310
|
+
projectRoot,
|
|
311
|
+
command: "yarn",
|
|
312
|
+
args: [
|
|
313
|
+
"add",
|
|
314
|
+
"--dev",
|
|
315
|
+
...devDependencies
|
|
316
|
+
]
|
|
317
|
+
});
|
|
318
|
+
if (manager === "bun") await runPackageCommand({
|
|
319
|
+
projectRoot,
|
|
320
|
+
command: "bun",
|
|
321
|
+
args: [
|
|
322
|
+
"add",
|
|
323
|
+
"--dev",
|
|
324
|
+
...devDependencies
|
|
325
|
+
]
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return [...runtimeDependencies, ...devDependencies];
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region src/lib/config.ts
|
|
332
|
+
const getConfigFilePath = (cwd) => path.join(cwd, CONFIG_FILE_NAME);
|
|
333
|
+
const loadWithFrameConfig = async (cwd) => {
|
|
334
|
+
const configPath = getConfigFilePath(cwd);
|
|
335
|
+
try {
|
|
336
|
+
await access(configPath);
|
|
337
|
+
} catch {
|
|
338
|
+
return {};
|
|
339
|
+
}
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
const raw = await readFile(configPath, "utf8");
|
|
343
|
+
parsed = JSON.parse(raw);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(`Failed to parse ${CONFIG_FILE_NAME}: ${error instanceof Error ? error.message : "Invalid JSON"}`);
|
|
346
|
+
}
|
|
347
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`${CONFIG_FILE_NAME} must be a JSON object.`);
|
|
348
|
+
const safe = parsed;
|
|
349
|
+
return {
|
|
350
|
+
outputDir: normalizeText(safe.outputDir),
|
|
351
|
+
target: normalizeProjectTarget(safe.target)
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/lib/prompt.ts
|
|
356
|
+
const confirm = async (question) => {
|
|
357
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
process.stdout.write(question);
|
|
360
|
+
const handleData = (data) => {
|
|
361
|
+
const char = data.toString().toLowerCase();
|
|
362
|
+
if (char === "") process.exit();
|
|
363
|
+
if (char === "y") {
|
|
364
|
+
cleanup();
|
|
365
|
+
resolve(true);
|
|
366
|
+
} else if (char === "n") {
|
|
367
|
+
cleanup();
|
|
368
|
+
resolve(false);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const cleanup = () => {
|
|
372
|
+
process.stdin.setRawMode(false);
|
|
373
|
+
process.stdin.pause();
|
|
374
|
+
process.stdin.removeListener("data", handleData);
|
|
375
|
+
process.stdout.write("\n");
|
|
376
|
+
};
|
|
377
|
+
process.stdin.setRawMode(true);
|
|
378
|
+
process.stdin.resume();
|
|
379
|
+
process.stdin.on("data", handleData);
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/api/component-service.ts
|
|
384
|
+
var ComponentService = class {
|
|
385
|
+
constructor(tokenStore, registryClient) {
|
|
386
|
+
this.tokenStore = tokenStore;
|
|
387
|
+
this.registryClient = registryClient;
|
|
388
|
+
}
|
|
389
|
+
async addComponent(componentSlug, opts, hooks = {}) {
|
|
390
|
+
const slug = normalizeText(componentSlug);
|
|
391
|
+
if (!slug) throw new Error("Component slug is required.");
|
|
392
|
+
const options = normalizeOptions(opts);
|
|
393
|
+
const context = await this.resolveContext(options);
|
|
394
|
+
const tokenResult = await this.tokenStore.resolveAccessToken();
|
|
395
|
+
const response = await this.registryClient.fetchComponent({
|
|
396
|
+
slug,
|
|
397
|
+
target: context.target,
|
|
398
|
+
variant: options.variant || "default",
|
|
399
|
+
token: tokenResult.token
|
|
400
|
+
});
|
|
401
|
+
const overwriteDecisions = await this.collectOverwriteDecisions({
|
|
402
|
+
projectRoot: context.projectRoot,
|
|
403
|
+
outputDir: context.outputDir,
|
|
404
|
+
files: response.manifest.files,
|
|
405
|
+
yes: Boolean(options.yes),
|
|
406
|
+
hooks
|
|
407
|
+
});
|
|
408
|
+
hooks.onApplyStart?.();
|
|
409
|
+
const installation = await this.applyManifest({
|
|
410
|
+
context,
|
|
411
|
+
manifest: response.manifest,
|
|
412
|
+
yes: Boolean(options.yes),
|
|
413
|
+
hooks,
|
|
414
|
+
overwriteDecisions
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
component: response.component,
|
|
418
|
+
target: context.target,
|
|
419
|
+
outputDir: context.outputDir,
|
|
420
|
+
...installation
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
async resolveContext(options) {
|
|
424
|
+
const projectRoot = resolveProjectRoot(options.cwd);
|
|
425
|
+
const config = await loadWithFrameConfig(projectRoot);
|
|
426
|
+
return {
|
|
427
|
+
projectRoot,
|
|
428
|
+
target: await detectProjectTarget({
|
|
429
|
+
projectRoot,
|
|
430
|
+
optionTarget: options.target,
|
|
431
|
+
config
|
|
432
|
+
}),
|
|
433
|
+
outputDir: await resolveOutputDirectory({
|
|
434
|
+
projectRoot,
|
|
435
|
+
config
|
|
436
|
+
})
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
async collectOverwriteDecisions({ projectRoot, outputDir, files, yes, hooks }) {
|
|
440
|
+
const overwriteDecisions = /* @__PURE__ */ new Map();
|
|
441
|
+
if (yes) return overwriteDecisions;
|
|
442
|
+
for (const file of files) {
|
|
443
|
+
const relativeFilePath = file.path.replace(/^\/+/, "");
|
|
444
|
+
const destination = path.resolve(projectRoot, outputDir, relativeFilePath);
|
|
445
|
+
if (!await hasFile(destination)) continue;
|
|
446
|
+
overwriteDecisions.set(destination, await this.confirmOverwrite({
|
|
447
|
+
projectRoot,
|
|
448
|
+
absolutePath: destination,
|
|
449
|
+
hooks
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
return overwriteDecisions;
|
|
453
|
+
}
|
|
454
|
+
async applyManifest({ context, manifest, yes, hooks, overwriteDecisions }) {
|
|
455
|
+
const fileResult = await writeManifestFiles({
|
|
456
|
+
projectRoot: context.projectRoot,
|
|
457
|
+
outputDir: context.outputDir,
|
|
458
|
+
files: manifest.files,
|
|
459
|
+
yes,
|
|
460
|
+
confirmOverwrite: async (absolutePath) => {
|
|
461
|
+
const existingDecision = overwriteDecisions.get(absolutePath);
|
|
462
|
+
if (typeof existingDecision === "boolean") return existingDecision;
|
|
463
|
+
return this.confirmOverwrite({
|
|
464
|
+
projectRoot: context.projectRoot,
|
|
465
|
+
absolutePath,
|
|
466
|
+
hooks
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
let installedDependencies = [];
|
|
471
|
+
if (await shouldInstallManifestDependencies(context.projectRoot)) {
|
|
472
|
+
const dependencies = await mergeManifestDependencies({
|
|
473
|
+
projectRoot: context.projectRoot,
|
|
474
|
+
manifest
|
|
475
|
+
});
|
|
476
|
+
installedDependencies = await installDependencies({
|
|
477
|
+
projectRoot: context.projectRoot,
|
|
478
|
+
runtimeDependencies: dependencies.runtimeToInstall,
|
|
479
|
+
devDependencies: dependencies.devToInstall
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
createdFiles: fileResult.created,
|
|
484
|
+
overwrittenFiles: fileResult.overwritten,
|
|
485
|
+
skippedFiles: fileResult.skipped,
|
|
486
|
+
installedDependencies
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async confirmOverwrite({ projectRoot, absolutePath, hooks }) {
|
|
490
|
+
const relativePath = path.relative(projectRoot, absolutePath);
|
|
491
|
+
if (hooks.confirmOverwrite) return hooks.confirmOverwrite(relativePath);
|
|
492
|
+
return confirm(`File ${relativePath} exists. Overwrite? [y/N] `);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/api/init-service.ts
|
|
497
|
+
const detectTargetIfPossible = async (projectRoot, preferredTarget) => {
|
|
498
|
+
if (preferredTarget) return preferredTarget;
|
|
499
|
+
try {
|
|
500
|
+
return await detectProjectTarget({
|
|
501
|
+
projectRoot,
|
|
502
|
+
optionTarget: void 0,
|
|
503
|
+
config: {}
|
|
504
|
+
});
|
|
505
|
+
} catch {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
var InitService = class {
|
|
510
|
+
async createConfig(options, hooks = {}) {
|
|
511
|
+
const projectRoot = resolveProjectRoot(options.cwd);
|
|
512
|
+
const configPath = getConfigFilePath(projectRoot);
|
|
513
|
+
const alreadyExists = await hasFile(configPath);
|
|
514
|
+
if (alreadyExists && !options.force) throw new Error(`${CONFIG_FILE_NAME} already exists at ${configPath}. Use --force to overwrite.`);
|
|
515
|
+
const target = await detectTargetIfPossible(projectRoot, await this.resolveTarget(options.target));
|
|
516
|
+
const config = {
|
|
517
|
+
outputDir: options.outputDir ?? await resolveOutputDirectory({
|
|
518
|
+
projectRoot,
|
|
519
|
+
config: {}
|
|
520
|
+
}),
|
|
521
|
+
...target ? { target } : {}
|
|
522
|
+
};
|
|
523
|
+
hooks.onInitializeStart?.();
|
|
524
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
525
|
+
return {
|
|
526
|
+
configPath,
|
|
527
|
+
config,
|
|
528
|
+
overwritten: alreadyExists
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
async resolveTarget(optionTarget) {
|
|
532
|
+
const normalizedOption = normalizeProjectTarget(optionTarget);
|
|
533
|
+
if (normalizedOption) return normalizedOption;
|
|
534
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive target selection requires a TTY. Pass --target react_native|expo.");
|
|
535
|
+
return select({
|
|
536
|
+
message: "Pick your project target:",
|
|
537
|
+
default: "expo",
|
|
538
|
+
choices: [{
|
|
539
|
+
name: "Expo",
|
|
540
|
+
value: "expo",
|
|
541
|
+
description: "Use for Expo-based React Native apps"
|
|
542
|
+
}, {
|
|
543
|
+
name: "React Native",
|
|
544
|
+
value: "react_native",
|
|
545
|
+
description: "Use for plain React Native projects"
|
|
546
|
+
}]
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/lib/env.ts
|
|
552
|
+
const ENV = { WITHFRAME_TOKEN: "WITHFRAME_TOKEN" };
|
|
553
|
+
const PROJECT_ENV_FILES = [
|
|
554
|
+
".env.local",
|
|
555
|
+
".env",
|
|
556
|
+
".env.development"
|
|
557
|
+
];
|
|
558
|
+
const getEnvVariableName = (key) => ENV[key];
|
|
559
|
+
const formatEnvString = (value) => {
|
|
560
|
+
if (typeof value !== "string") return;
|
|
561
|
+
return value.trim() || void 0;
|
|
562
|
+
};
|
|
563
|
+
const unquote = (value) => {
|
|
564
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
|
565
|
+
return value;
|
|
566
|
+
};
|
|
567
|
+
const getValueFromProjectEnvFiles = (envName) => {
|
|
568
|
+
const cwd = process.cwd();
|
|
569
|
+
for (const fileName of PROJECT_ENV_FILES) {
|
|
570
|
+
const filePath = path.join(cwd, fileName);
|
|
571
|
+
let content;
|
|
572
|
+
try {
|
|
573
|
+
content = readFileSync(filePath, "utf8");
|
|
574
|
+
} catch {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
for (const rawLine of content.split(/\r?\n/g)) {
|
|
578
|
+
const line = rawLine.trim();
|
|
579
|
+
if (!line || line.startsWith("#")) continue;
|
|
580
|
+
const withoutExport = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
581
|
+
const separatorIndex = withoutExport.indexOf("=");
|
|
582
|
+
if (separatorIndex <= 0) continue;
|
|
583
|
+
if (withoutExport.slice(0, separatorIndex).trim() !== envName) continue;
|
|
584
|
+
const value = formatEnvString(unquote(withoutExport.slice(separatorIndex + 1).trim()));
|
|
585
|
+
if (value) return value;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
const getEnvValue = (key) => {
|
|
590
|
+
const envName = getEnvVariableName(key);
|
|
591
|
+
const direct = formatEnvString(process.env[envName]);
|
|
592
|
+
if (direct) return direct;
|
|
593
|
+
return getValueFromProjectEnvFiles(envName);
|
|
594
|
+
};
|
|
595
|
+
const getRequiredEnvValue = (key) => {
|
|
596
|
+
const value = getEnvValue(key);
|
|
597
|
+
if (value) return value;
|
|
598
|
+
const envName = getEnvVariableName(key);
|
|
599
|
+
throw new Error(`Missing required environment variable: ${envName}.`);
|
|
600
|
+
};
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/core/base-command.ts
|
|
603
|
+
var BaseCommand = class {
|
|
604
|
+
requiredEnvVariables() {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
configure(command) {
|
|
608
|
+
return command;
|
|
609
|
+
}
|
|
610
|
+
async runTask(task, options) {
|
|
611
|
+
const spinner = ora(options.spinner);
|
|
612
|
+
let started = false;
|
|
613
|
+
const start = () => {
|
|
614
|
+
if (!started) {
|
|
615
|
+
spinner.start();
|
|
616
|
+
started = true;
|
|
617
|
+
}
|
|
618
|
+
return spinner;
|
|
619
|
+
};
|
|
620
|
+
if (options.startMode !== "manual") start();
|
|
621
|
+
try {
|
|
622
|
+
const result = await task({
|
|
623
|
+
spinner,
|
|
624
|
+
start,
|
|
625
|
+
isStarted: () => started
|
|
626
|
+
});
|
|
627
|
+
const successText = isFunction(options.successText) ? options.successText(result) : options.successText;
|
|
628
|
+
if (started) spinner.succeed(successText);
|
|
629
|
+
else console.log(successText);
|
|
630
|
+
return result;
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (started) spinner.fail(options.failureText);
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
register(program) {
|
|
637
|
+
this.configure(program.command(this.name).description(this.description)).action(async (...args) => {
|
|
638
|
+
try {
|
|
639
|
+
this.requiredEnvVariables().forEach((key) => getRequiredEnvValue(key));
|
|
640
|
+
await this.execute(...args);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
643
|
+
console.error(chalk.red(`\n${message}\n`));
|
|
644
|
+
process.exitCode = 1;
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/utils/output.ts
|
|
651
|
+
const printList = ({ title, items, titleColor, prefix }) => {
|
|
652
|
+
if (!items.length) return;
|
|
653
|
+
console.log(titleColor(`\n ${title}:`));
|
|
654
|
+
items.forEach((item) => {
|
|
655
|
+
console.log(chalk.white(` ${prefix} ${item}`));
|
|
656
|
+
});
|
|
657
|
+
};
|
|
658
|
+
const printQuickStart = () => {
|
|
659
|
+
console.log(chalk.dim(SEPARATOR));
|
|
660
|
+
console.log(chalk.cyan("\n 🎯 Quick Start:\n"));
|
|
661
|
+
console.log(chalk.white(" $ withframe init ") + chalk.gray("→ Create local config"));
|
|
662
|
+
console.log(chalk.white(" $ withframe login ") + chalk.gray("→ Authenticate"));
|
|
663
|
+
console.log(chalk.white(" $ withframe add button ") + chalk.gray("→ Add component code\n"));
|
|
664
|
+
console.log(chalk.white(" $ withframe logout ") + chalk.gray("→ Clear local token\n"));
|
|
665
|
+
};
|
|
666
|
+
const printAddResult = (result) => {
|
|
667
|
+
console.log("");
|
|
668
|
+
console.log(chalk.gray(` Target: ${result.target}`));
|
|
669
|
+
console.log(chalk.gray(` Output dir: ${result.outputDir}`));
|
|
670
|
+
printList({
|
|
671
|
+
title: "Created files",
|
|
672
|
+
items: result.createdFiles,
|
|
673
|
+
titleColor: chalk.cyan,
|
|
674
|
+
prefix: "+"
|
|
675
|
+
});
|
|
676
|
+
printList({
|
|
677
|
+
title: "Overwritten files",
|
|
678
|
+
items: result.overwrittenFiles,
|
|
679
|
+
titleColor: chalk.yellow,
|
|
680
|
+
prefix: "~"
|
|
681
|
+
});
|
|
682
|
+
printList({
|
|
683
|
+
title: "Skipped files",
|
|
684
|
+
items: result.skippedFiles,
|
|
685
|
+
titleColor: chalk.yellow,
|
|
686
|
+
prefix: "-"
|
|
687
|
+
});
|
|
688
|
+
printList({
|
|
689
|
+
title: "Installed dependencies",
|
|
690
|
+
items: result.installedDependencies,
|
|
691
|
+
titleColor: chalk.cyan,
|
|
692
|
+
prefix: "•"
|
|
693
|
+
});
|
|
694
|
+
console.log("");
|
|
695
|
+
};
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/commands/add.ts
|
|
698
|
+
var AddCommand = class extends BaseCommand {
|
|
699
|
+
constructor(componentService) {
|
|
700
|
+
super();
|
|
701
|
+
this.componentService = componentService;
|
|
702
|
+
this.name = "add <component>";
|
|
703
|
+
this.description = chalk.magenta("Add a component from WithFrame registry");
|
|
704
|
+
}
|
|
705
|
+
requiredEnvVariables() {
|
|
706
|
+
return ["WITHFRAME_TOKEN"];
|
|
707
|
+
}
|
|
708
|
+
configure(command) {
|
|
709
|
+
return command.alias("install").option("-v, --variant <variant>", "Install specific variant id", "default").option("-t, --target <target>", "Project target: react_native or expo").option("--cwd <path>", "Project root directory").option("-y, --yes", "Overwrite files without confirmation");
|
|
710
|
+
}
|
|
711
|
+
async execute(comp, opts) {
|
|
712
|
+
const displayComponent = comp.trim() || "component";
|
|
713
|
+
printAddResult(await this.runTask(async ({ start }) => {
|
|
714
|
+
return this.componentService.addComponent(comp, opts, {
|
|
715
|
+
onApplyStart: () => {
|
|
716
|
+
start();
|
|
717
|
+
},
|
|
718
|
+
confirmOverwrite: async (relativePath) => {
|
|
719
|
+
return confirm(`File ${relativePath} exists. Overwrite? [y/N] `);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}, {
|
|
723
|
+
spinner: {
|
|
724
|
+
text: `📦 Adding ${chalk.bold(displayComponent)}...`,
|
|
725
|
+
spinner: "dots",
|
|
726
|
+
color: "cyan"
|
|
727
|
+
},
|
|
728
|
+
successText: (value) => chalk.green.bold(`${value.component.slug} added successfully`),
|
|
729
|
+
failureText: chalk.red.bold("Failed to add component"),
|
|
730
|
+
startMode: "manual"
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/commands/init.ts
|
|
736
|
+
var InitCommand = class extends BaseCommand {
|
|
737
|
+
constructor(initService) {
|
|
738
|
+
super();
|
|
739
|
+
this.initService = initService;
|
|
740
|
+
this.name = "init";
|
|
741
|
+
this.description = chalk.blue("Create withframe.config.json in your project");
|
|
742
|
+
}
|
|
743
|
+
configure(command) {
|
|
744
|
+
return command.option("--cwd <path>", "Project root directory").option("-o, --output-dir <path>", "Set output directory in config").option("-t, --target <target>", "Set target: react_native or expo").option("-f, --force", "Overwrite existing withframe.config.json");
|
|
745
|
+
}
|
|
746
|
+
async execute(options) {
|
|
747
|
+
const cwd = normalizeText(options.cwd);
|
|
748
|
+
const outputDir = normalizeText(options.outputDir);
|
|
749
|
+
const result = await this.runTask(async ({ start }) => {
|
|
750
|
+
return this.initService.createConfig({
|
|
751
|
+
cwd,
|
|
752
|
+
outputDir,
|
|
753
|
+
target: options.target,
|
|
754
|
+
force: Boolean(options.force)
|
|
755
|
+
}, { onInitializeStart: () => {
|
|
756
|
+
start();
|
|
757
|
+
} });
|
|
758
|
+
}, {
|
|
759
|
+
spinner: {
|
|
760
|
+
text: chalk.cyan("Initializing withframe.config.json..."),
|
|
761
|
+
spinner: "dots",
|
|
762
|
+
color: "cyan"
|
|
763
|
+
},
|
|
764
|
+
successText: (value) => value.overwritten ? chalk.green.bold("withframe.config.json updated") : chalk.green.bold("withframe.config.json created"),
|
|
765
|
+
failureText: chalk.red.bold("Failed to initialize config"),
|
|
766
|
+
startMode: "manual"
|
|
767
|
+
});
|
|
768
|
+
console.log("");
|
|
769
|
+
console.log(chalk.gray(` Path: ${result.configPath}`));
|
|
770
|
+
console.log(chalk.gray(` Output dir: ${result.config.outputDir ?? "not set"}`));
|
|
771
|
+
console.log(chalk.gray(` Target: ${result.config.target ?? "auto-detect on add"}`));
|
|
772
|
+
console.log("");
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/commands/login.ts
|
|
777
|
+
var LoginCommand = class extends BaseCommand {
|
|
778
|
+
constructor(authService) {
|
|
779
|
+
super();
|
|
780
|
+
this.authService = authService;
|
|
781
|
+
this.name = "login";
|
|
782
|
+
this.description = chalk.green("Authenticate and output WITHFRAME_TOKEN export command");
|
|
783
|
+
}
|
|
784
|
+
configure(command) {
|
|
785
|
+
return command.option("--no-open", "Do not open browser automatically").option("--cwd <path>", "Resolve config from directory");
|
|
786
|
+
}
|
|
787
|
+
async execute(options) {
|
|
788
|
+
await loadWithFrameConfig(options.cwd?.trim() || process.cwd());
|
|
789
|
+
const authResult = await this.runTask(async ({ spinner }) => {
|
|
790
|
+
spinner.text = chalk.cyan("Waiting for browser approval...");
|
|
791
|
+
return this.authService.login({ openBrowser: options.open });
|
|
792
|
+
}, {
|
|
793
|
+
spinner: {
|
|
794
|
+
text: chalk.cyan("🔐 Starting device login..."),
|
|
795
|
+
spinner: "dots",
|
|
796
|
+
color: "cyan"
|
|
797
|
+
},
|
|
798
|
+
successText: chalk.green.bold("Logged in successfully"),
|
|
799
|
+
failureText: chalk.red.bold("Login failed")
|
|
800
|
+
});
|
|
801
|
+
console.log("");
|
|
802
|
+
console.log(chalk.cyan(" Use this token in one of two ways:"));
|
|
803
|
+
console.log(chalk.cyan(" export in your shell"));
|
|
804
|
+
console.log(chalk.white(` export WITHFRAME_TOKEN="${authResult.accessToken}"`));
|
|
805
|
+
console.log(chalk.gray(" ─────────────── OR ───────────────"));
|
|
806
|
+
console.log(chalk.cyan(" save in your project .env or .env.local"));
|
|
807
|
+
console.log(chalk.white(` WITHFRAME_TOKEN="${authResult.accessToken}"`));
|
|
808
|
+
console.log("");
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/commands/logout.ts
|
|
813
|
+
var LogoutCommand = class extends BaseCommand {
|
|
814
|
+
constructor(tokenStore) {
|
|
815
|
+
super();
|
|
816
|
+
this.tokenStore = tokenStore;
|
|
817
|
+
this.name = "logout";
|
|
818
|
+
this.description = chalk.red("Clear local WithFrame auth token");
|
|
819
|
+
}
|
|
820
|
+
async execute() {
|
|
821
|
+
const authFilePath = await this.runTask(async () => {
|
|
822
|
+
await this.tokenStore.clearToken();
|
|
823
|
+
return this.tokenStore.getAuthFilePath();
|
|
824
|
+
}, {
|
|
825
|
+
spinner: {
|
|
826
|
+
text: chalk.cyan("Clearing local session..."),
|
|
827
|
+
spinner: "dots",
|
|
828
|
+
color: "yellow"
|
|
829
|
+
},
|
|
830
|
+
successText: chalk.yellow.bold("Logged out successfully"),
|
|
831
|
+
failureText: chalk.red.bold("Failed to clear local session")
|
|
832
|
+
});
|
|
833
|
+
console.log(chalk.dim(`\n Token cache cleared: ${authFilePath}\n`));
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/commands/shot.ts
|
|
838
|
+
var ShotCommand = class extends BaseCommand {
|
|
839
|
+
constructor(shotService) {
|
|
840
|
+
super();
|
|
841
|
+
this.shotService = shotService;
|
|
842
|
+
this.name = "shot";
|
|
843
|
+
this.description = chalk.cyan("Upload a screenshot and attach it to a collection");
|
|
844
|
+
}
|
|
845
|
+
requiredEnvVariables() {
|
|
846
|
+
return ["WITHFRAME_TOKEN"];
|
|
847
|
+
}
|
|
848
|
+
configure(command) {
|
|
849
|
+
return command.requiredOption("--file <path>", "Screenshot file path").option("--device <id>", "Preferred device id when dimensions match multiple models");
|
|
850
|
+
}
|
|
851
|
+
async execute(opts) {
|
|
852
|
+
const result = await this.runTask(async ({ start }) => {
|
|
853
|
+
return this.shotService.uploadShot(opts, { onUploadStart: () => {
|
|
854
|
+
start();
|
|
855
|
+
} });
|
|
856
|
+
}, {
|
|
857
|
+
spinner: {
|
|
858
|
+
text: "Uploading screenshot ...",
|
|
859
|
+
spinner: "dots",
|
|
860
|
+
color: "cyan"
|
|
861
|
+
},
|
|
862
|
+
successText: () => chalk.green.bold("Screenshot uploaded successfully"),
|
|
863
|
+
failureText: chalk.red.bold("Failed to upload screenshot"),
|
|
864
|
+
startMode: "manual"
|
|
865
|
+
});
|
|
866
|
+
console.log(result.url);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/commands/upload.ts
|
|
871
|
+
var UploadCommand = class extends BaseCommand {
|
|
872
|
+
constructor(uploadService) {
|
|
873
|
+
super();
|
|
874
|
+
this.uploadService = uploadService;
|
|
875
|
+
this.name = "upload";
|
|
876
|
+
this.description = chalk.yellow("Upload a component to WithFrame registry");
|
|
877
|
+
}
|
|
878
|
+
requiredEnvVariables() {
|
|
879
|
+
return ["WITHFRAME_TOKEN"];
|
|
880
|
+
}
|
|
881
|
+
configure(command) {
|
|
882
|
+
return command.option("--path <path>", "Component entry file path").option("--no-open", "Do not open browser automatically");
|
|
883
|
+
}
|
|
884
|
+
async execute(opts) {
|
|
885
|
+
const result = await this.runTask(async () => {
|
|
886
|
+
return this.uploadService.upload(opts);
|
|
887
|
+
}, {
|
|
888
|
+
spinner: {
|
|
889
|
+
text: `📦 Uploading ...`,
|
|
890
|
+
spinner: "dots",
|
|
891
|
+
color: "cyan"
|
|
892
|
+
},
|
|
893
|
+
successText: () => chalk.green.bold(`Component uploaded successfully`),
|
|
894
|
+
failureText: chalk.red.bold("Failed to upload component"),
|
|
895
|
+
startMode: "manual"
|
|
896
|
+
});
|
|
897
|
+
if (opts.open !== false) await open(result.editUrl).catch(() => {});
|
|
898
|
+
console.log("");
|
|
899
|
+
console.log(chalk.gray(` Draft: ${result.componentId}`));
|
|
900
|
+
console.log(chalk.gray(` Target: ${result.target}`));
|
|
901
|
+
console.log(chalk.gray(` Edit URL: ${result.editUrl}`));
|
|
902
|
+
console.log("");
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/core/cli-app.ts
|
|
907
|
+
var CliApp = class {
|
|
908
|
+
constructor(program, commandRegistry) {
|
|
909
|
+
this.program = program;
|
|
910
|
+
this.commandRegistry = commandRegistry;
|
|
911
|
+
}
|
|
912
|
+
run(argv) {
|
|
913
|
+
this.commandRegistry.registerAll(this.program);
|
|
914
|
+
this.program.parse(argv);
|
|
915
|
+
if (!argv.slice(2).length) {
|
|
916
|
+
this.program.outputHelp();
|
|
917
|
+
printQuickStart();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/core/command-registry.ts
|
|
923
|
+
var CommandRegistry = class {
|
|
924
|
+
constructor(commands) {
|
|
925
|
+
this.commands = commands;
|
|
926
|
+
}
|
|
927
|
+
registerAll(program) {
|
|
928
|
+
this.commands.forEach((command) => {
|
|
929
|
+
command.register(program);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
//#endregion
|
|
934
|
+
//#region src/lib/token-store.ts
|
|
935
|
+
var TokenStore = class {
|
|
936
|
+
constructor() {
|
|
937
|
+
this.authDirectoryPath = path.join(os.homedir(), WITHFRAME_DIR);
|
|
938
|
+
this.authFilePath = path.join(this.authDirectoryPath, AUTH_FILE_NAME);
|
|
939
|
+
}
|
|
940
|
+
getAuthFilePath() {
|
|
941
|
+
return this.authFilePath;
|
|
942
|
+
}
|
|
943
|
+
async resolveAccessToken() {
|
|
944
|
+
return {
|
|
945
|
+
token: getEnvValue("WITHFRAME_TOKEN"),
|
|
946
|
+
source: "env"
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
async saveToken(accessToken, expiresInSeconds) {
|
|
950
|
+
const now = /* @__PURE__ */ new Date();
|
|
951
|
+
const expiresAt = new Date(now.getTime() + Math.max(0, expiresInSeconds) * 1e3);
|
|
952
|
+
const payload = {
|
|
953
|
+
accessToken,
|
|
954
|
+
createdAt: now.toISOString(),
|
|
955
|
+
expiresAt: expiresAt.toISOString()
|
|
956
|
+
};
|
|
957
|
+
await mkdir(this.authDirectoryPath, {
|
|
958
|
+
recursive: true,
|
|
959
|
+
mode: 448
|
|
960
|
+
});
|
|
961
|
+
await writeFile(this.authFilePath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
962
|
+
mode: 384,
|
|
963
|
+
encoding: "utf8"
|
|
964
|
+
});
|
|
965
|
+
await chmod(this.authFilePath, 384).catch(() => void 0);
|
|
966
|
+
}
|
|
967
|
+
async clearToken() {
|
|
968
|
+
await unlink(this.authFilePath).catch(() => void 0);
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region src/lib/errors.ts
|
|
973
|
+
var CliHttpError = class extends Error {
|
|
974
|
+
constructor(message, statusCode, payload) {
|
|
975
|
+
super(message);
|
|
976
|
+
this.statusCode = statusCode;
|
|
977
|
+
this.payload = payload;
|
|
978
|
+
this.name = "CliHttpError";
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region src/lib/http.ts
|
|
983
|
+
const parseResponsePayload = async (response) => {
|
|
984
|
+
const text = await response.text();
|
|
985
|
+
if (!text) return {};
|
|
986
|
+
try {
|
|
987
|
+
return JSON.parse(text);
|
|
988
|
+
} catch {
|
|
989
|
+
return text;
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
const resolveErrorMessage = (payload, fallback) => {
|
|
993
|
+
if (!payload || typeof payload !== "object") return fallback;
|
|
994
|
+
const message = payload.message;
|
|
995
|
+
return isString(message) && message.trim() ? message : fallback;
|
|
996
|
+
};
|
|
997
|
+
const requestJson = async (url, init) => {
|
|
998
|
+
const response = await fetch(url, {
|
|
999
|
+
...init,
|
|
1000
|
+
headers: {
|
|
1001
|
+
Accept: "application/json",
|
|
1002
|
+
...init?.headers ?? {}
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
const payload = await parseResponsePayload(response);
|
|
1006
|
+
if (!response.ok) throw new CliHttpError(resolveErrorMessage(payload, `Request failed with status ${response.status}`), response.status, payload);
|
|
1007
|
+
return payload;
|
|
1008
|
+
};
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/api/registry-client.ts
|
|
1011
|
+
const sanitizeBaseUrl = (baseUrl) => baseUrl.replace(/\/+$/, "");
|
|
1012
|
+
const CALLBACK_URL_TYPE = {
|
|
1013
|
+
DEV: "DEV",
|
|
1014
|
+
STAGE: "STAGE",
|
|
1015
|
+
PROD: "PROD"
|
|
1016
|
+
};
|
|
1017
|
+
const isLocalHost = (hostname) => {
|
|
1018
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0";
|
|
1019
|
+
};
|
|
1020
|
+
const parseUrl = (value) => {
|
|
1021
|
+
try {
|
|
1022
|
+
return new URL(value);
|
|
1023
|
+
} catch {
|
|
1024
|
+
try {
|
|
1025
|
+
return new URL(`http://${value}`);
|
|
1026
|
+
} catch {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
const formatCallback = (url) => {
|
|
1032
|
+
const parsed = parseUrl(url.trim());
|
|
1033
|
+
if (!parsed) return CALLBACK_URL_TYPE.PROD;
|
|
1034
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1035
|
+
if (hostname === "stage.withfra.me") return CALLBACK_URL_TYPE.STAGE;
|
|
1036
|
+
if (hostname === "withfra.me") return CALLBACK_URL_TYPE.PROD;
|
|
1037
|
+
if (isLocalHost(hostname)) return CALLBACK_URL_TYPE.DEV;
|
|
1038
|
+
return CALLBACK_URL_TYPE.PROD;
|
|
1039
|
+
};
|
|
1040
|
+
var RegistryClient = class {
|
|
1041
|
+
getRegistryBaseUrl() {
|
|
1042
|
+
return REGISTRY_URL;
|
|
1043
|
+
}
|
|
1044
|
+
getCallbackUrlType() {
|
|
1045
|
+
return formatCallback(this.getRegistryBaseUrl());
|
|
1046
|
+
}
|
|
1047
|
+
toUrl(endpoint) {
|
|
1048
|
+
return `${sanitizeBaseUrl(this.getRegistryBaseUrl())}${endpoint}`;
|
|
1049
|
+
}
|
|
1050
|
+
startDeviceFlow({ clientName = "withframe-cli" } = {}) {
|
|
1051
|
+
return requestJson(this.toUrl("/api/cli/device/start"), {
|
|
1052
|
+
method: "POST",
|
|
1053
|
+
headers: { "Content-Type": "application/json" },
|
|
1054
|
+
body: JSON.stringify({ clientName })
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
pollDeviceFlow({ deviceCode }) {
|
|
1058
|
+
return requestJson(this.toUrl("/api/cli/device/poll"), {
|
|
1059
|
+
method: "POST",
|
|
1060
|
+
headers: { "Content-Type": "application/json" },
|
|
1061
|
+
body: JSON.stringify({ deviceCode })
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
fetchComponent({ slug, target, variant, token }) {
|
|
1065
|
+
const params = new URLSearchParams({
|
|
1066
|
+
target,
|
|
1067
|
+
variant
|
|
1068
|
+
});
|
|
1069
|
+
return requestJson(this.toUrl(`/api/cli/registry/components/${encodeURIComponent(slug)}?${params.toString()}`), {
|
|
1070
|
+
method: "GET",
|
|
1071
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
uploadComponent({ content, fileName, token }) {
|
|
1075
|
+
const callback = this.getCallbackUrlType();
|
|
1076
|
+
return requestJson(this.toUrl("/api/cli/registry/components/upload"), {
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
headers: {
|
|
1079
|
+
"Content-Type": "application/json",
|
|
1080
|
+
Authorization: `Bearer ${token}`
|
|
1081
|
+
},
|
|
1082
|
+
body: JSON.stringify({
|
|
1083
|
+
content,
|
|
1084
|
+
fileName,
|
|
1085
|
+
callback
|
|
1086
|
+
})
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
fetchShotCollections({ token, offset = 0, limit = 40 }) {
|
|
1090
|
+
const params = new URLSearchParams({
|
|
1091
|
+
offset: String(offset),
|
|
1092
|
+
limit: String(limit)
|
|
1093
|
+
});
|
|
1094
|
+
return requestJson(this.toUrl(`/api/cli/shots/collections?${params.toString()}`), {
|
|
1095
|
+
method: "GET",
|
|
1096
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
uploadShot({ token, fileName, mimeType, content, collectionId, collectionTitle, createNewCollection, color }) {
|
|
1100
|
+
const callback = this.getCallbackUrlType();
|
|
1101
|
+
const formData = new FormData();
|
|
1102
|
+
const fileContent = Uint8Array.from(content);
|
|
1103
|
+
formData.append("file", new Blob([fileContent], { type: mimeType }), fileName);
|
|
1104
|
+
formData.append("callback", callback);
|
|
1105
|
+
if (collectionId) formData.append("collectionId", collectionId);
|
|
1106
|
+
if (collectionTitle) formData.append("collectionTitle", collectionTitle);
|
|
1107
|
+
if (createNewCollection) formData.append("createNewCollection", "true");
|
|
1108
|
+
if (color) formData.append("color", color);
|
|
1109
|
+
return requestJson(this.toUrl("/api/cli/shots/upload"), {
|
|
1110
|
+
method: "POST",
|
|
1111
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1112
|
+
body: formData
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
//#endregion
|
|
1117
|
+
//#region src/api/upload-service.ts
|
|
1118
|
+
var UploadService = class {
|
|
1119
|
+
constructor(tokenStore, registryClient) {
|
|
1120
|
+
this.tokenStore = tokenStore;
|
|
1121
|
+
this.registryClient = registryClient;
|
|
1122
|
+
}
|
|
1123
|
+
async upload(opts) {
|
|
1124
|
+
const compPath = await this.resolveComponentPath(opts.path);
|
|
1125
|
+
const tokenResult = await this.tokenStore.resolveAccessToken();
|
|
1126
|
+
try {
|
|
1127
|
+
await access(compPath);
|
|
1128
|
+
const content = await readFile(compPath, "utf-8");
|
|
1129
|
+
const fileName = path.basename(compPath);
|
|
1130
|
+
return this.registryClient.uploadComponent({
|
|
1131
|
+
content,
|
|
1132
|
+
fileName,
|
|
1133
|
+
token: tokenResult.token
|
|
1134
|
+
});
|
|
1135
|
+
} catch {
|
|
1136
|
+
throw new Error("File read error. Please check the file path and permissions.");
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
async resolveComponentPath(compPath) {
|
|
1140
|
+
const cleanedPath = normalizeText(compPath);
|
|
1141
|
+
if (!cleanedPath) throw new Error("Component path is required.");
|
|
1142
|
+
const absolutePath = path.resolve(process.cwd(), cleanedPath);
|
|
1143
|
+
if (!await hasFile(absolutePath)) throw new Error(`File not found at path: ${absolutePath}`);
|
|
1144
|
+
return absolutePath;
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/lib/shot-devices.ts
|
|
1149
|
+
const supportedDevices = [
|
|
1150
|
+
{
|
|
1151
|
+
id: "iphone.8",
|
|
1152
|
+
name: "iPhone 8",
|
|
1153
|
+
physical: {
|
|
1154
|
+
width: 750,
|
|
1155
|
+
height: 1334
|
|
1156
|
+
},
|
|
1157
|
+
colors: [
|
|
1158
|
+
{
|
|
1159
|
+
id: "silver",
|
|
1160
|
+
name: "Silver",
|
|
1161
|
+
hex: "#E4E4E2",
|
|
1162
|
+
fileSize: 346
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
id: "space_gray",
|
|
1166
|
+
name: "Space Gray",
|
|
1167
|
+
hex: "#25282A",
|
|
1168
|
+
fileSize: 335
|
|
1169
|
+
},
|
|
1170
|
+
{
|
|
1171
|
+
id: "gold",
|
|
1172
|
+
name: "Gold",
|
|
1173
|
+
hex: "#F5DDC5",
|
|
1174
|
+
fileSize: 430
|
|
1175
|
+
}
|
|
1176
|
+
]
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
id: "iphone.8.plus",
|
|
1180
|
+
name: "iPhone 8 Plus",
|
|
1181
|
+
physical: {
|
|
1182
|
+
width: 1080,
|
|
1183
|
+
height: 1920
|
|
1184
|
+
},
|
|
1185
|
+
colors: [
|
|
1186
|
+
{
|
|
1187
|
+
id: "silver",
|
|
1188
|
+
name: "Silver",
|
|
1189
|
+
hex: "#E4E4E2",
|
|
1190
|
+
fileSize: 466
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
id: "space_gray",
|
|
1194
|
+
name: "Space Gray",
|
|
1195
|
+
hex: "#25282A",
|
|
1196
|
+
fileSize: 397
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
id: "gold",
|
|
1200
|
+
name: "Gold",
|
|
1201
|
+
hex: "#F5DDC5",
|
|
1202
|
+
fileSize: 458
|
|
1203
|
+
}
|
|
1204
|
+
]
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
id: "iphone.13",
|
|
1208
|
+
name: "iPhone 13",
|
|
1209
|
+
physical: {
|
|
1210
|
+
width: 1170,
|
|
1211
|
+
height: 2532
|
|
1212
|
+
},
|
|
1213
|
+
colors: [
|
|
1214
|
+
{
|
|
1215
|
+
id: "red",
|
|
1216
|
+
name: "Red",
|
|
1217
|
+
hex: "#A50011",
|
|
1218
|
+
fileSize: 81
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
id: "starlight",
|
|
1222
|
+
name: "Starlight",
|
|
1223
|
+
hex: "#F9F3EE",
|
|
1224
|
+
fileSize: 80
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
id: "midnight",
|
|
1228
|
+
name: "Midnight",
|
|
1229
|
+
hex: "#171E27",
|
|
1230
|
+
fileSize: 69
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
id: "blue",
|
|
1234
|
+
name: "Blue",
|
|
1235
|
+
hex: "#215E7C",
|
|
1236
|
+
fileSize: 84
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
id: "pink",
|
|
1240
|
+
name: "Pink",
|
|
1241
|
+
hex: "#FAE0D8",
|
|
1242
|
+
fileSize: 80
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
id: "green",
|
|
1246
|
+
name: "Green",
|
|
1247
|
+
hex: "#364935",
|
|
1248
|
+
unavailable: true,
|
|
1249
|
+
fileSize: 0
|
|
1250
|
+
}
|
|
1251
|
+
]
|
|
1252
|
+
},
|
|
1253
|
+
{
|
|
1254
|
+
id: "iphone.13.mini",
|
|
1255
|
+
name: "iPhone 13 Mini",
|
|
1256
|
+
physical: {
|
|
1257
|
+
width: 1080,
|
|
1258
|
+
height: 2340
|
|
1259
|
+
},
|
|
1260
|
+
colors: [
|
|
1261
|
+
{
|
|
1262
|
+
id: "red",
|
|
1263
|
+
name: "Red",
|
|
1264
|
+
hex: "#A50011",
|
|
1265
|
+
fileSize: 197
|
|
1266
|
+
},
|
|
1267
|
+
{
|
|
1268
|
+
id: "starlight",
|
|
1269
|
+
name: "Starlight",
|
|
1270
|
+
hex: "#F9F3EE",
|
|
1271
|
+
fileSize: 194
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
id: "midnight",
|
|
1275
|
+
name: "Midnight",
|
|
1276
|
+
hex: "#171E27",
|
|
1277
|
+
fileSize: 173
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
id: "blue",
|
|
1281
|
+
name: "Blue",
|
|
1282
|
+
hex: "#215E7C",
|
|
1283
|
+
fileSize: 203
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
id: "pink",
|
|
1287
|
+
name: "Pink",
|
|
1288
|
+
hex: "#FAE0D8",
|
|
1289
|
+
fileSize: 194
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
id: "green",
|
|
1293
|
+
name: "Green",
|
|
1294
|
+
hex: "#364935",
|
|
1295
|
+
unavailable: true,
|
|
1296
|
+
fileSize: 0
|
|
1297
|
+
}
|
|
1298
|
+
]
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
id: "iphone.13.pro.max",
|
|
1302
|
+
name: "iPhone 13 Pro Max",
|
|
1303
|
+
physical: {
|
|
1304
|
+
width: 1284,
|
|
1305
|
+
height: 2778
|
|
1306
|
+
},
|
|
1307
|
+
colors: [
|
|
1308
|
+
{
|
|
1309
|
+
id: "sierra_blue",
|
|
1310
|
+
name: "Sierra Blue",
|
|
1311
|
+
hex: "#9BB5CE",
|
|
1312
|
+
fileSize: 423
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
id: "graphite",
|
|
1316
|
+
name: "Graphite",
|
|
1317
|
+
hex: "#5C5B57",
|
|
1318
|
+
fileSize: 321
|
|
1319
|
+
},
|
|
1320
|
+
{
|
|
1321
|
+
id: "gold",
|
|
1322
|
+
name: "Gold",
|
|
1323
|
+
hex: "#F9E5C9",
|
|
1324
|
+
fileSize: 435
|
|
1325
|
+
},
|
|
1326
|
+
{
|
|
1327
|
+
id: "silver",
|
|
1328
|
+
name: "Silver",
|
|
1329
|
+
hex: "#F5F5F0",
|
|
1330
|
+
fileSize: 435
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
id: "alpine_green",
|
|
1334
|
+
name: "Alpine Green",
|
|
1335
|
+
hex: "#505F4E",
|
|
1336
|
+
unavailable: true,
|
|
1337
|
+
fileSize: 0
|
|
1338
|
+
}
|
|
1339
|
+
]
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
id: "iphone.14.pro",
|
|
1343
|
+
name: "iPhone 14 Pro",
|
|
1344
|
+
physical: {
|
|
1345
|
+
width: 1179,
|
|
1346
|
+
height: 2556
|
|
1347
|
+
},
|
|
1348
|
+
colors: [
|
|
1349
|
+
{
|
|
1350
|
+
id: "space_black",
|
|
1351
|
+
name: "Space Black",
|
|
1352
|
+
hex: "#4b4845",
|
|
1353
|
+
fileSize: 177
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
id: "silver",
|
|
1357
|
+
name: "Silver",
|
|
1358
|
+
hex: "#e2e4e1",
|
|
1359
|
+
fileSize: 177
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
id: "gold",
|
|
1363
|
+
name: "Gold",
|
|
1364
|
+
hex: "#d4c9b1",
|
|
1365
|
+
fileSize: 177
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
id: "deep_purple",
|
|
1369
|
+
name: "Deep Purple",
|
|
1370
|
+
hex: "#5e5566",
|
|
1371
|
+
fileSize: 202
|
|
1372
|
+
}
|
|
1373
|
+
]
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
id: "iphone.14.pro.max",
|
|
1377
|
+
name: "iPhone 14 Pro Max",
|
|
1378
|
+
physical: {
|
|
1379
|
+
width: 1290,
|
|
1380
|
+
height: 2796
|
|
1381
|
+
},
|
|
1382
|
+
colors: [
|
|
1383
|
+
{
|
|
1384
|
+
id: "space_black",
|
|
1385
|
+
name: "Space Black",
|
|
1386
|
+
hex: "#4b4845",
|
|
1387
|
+
fileSize: 177
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
id: "silver",
|
|
1391
|
+
name: "Silver",
|
|
1392
|
+
hex: "#e2e4e1",
|
|
1393
|
+
fileSize: 177
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
id: "gold",
|
|
1397
|
+
name: "Gold",
|
|
1398
|
+
hex: "#d4c9b1",
|
|
1399
|
+
fileSize: 177
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
id: "deep_purple",
|
|
1403
|
+
name: "Deep Purple",
|
|
1404
|
+
hex: "#5e5566",
|
|
1405
|
+
fileSize: 202
|
|
1406
|
+
}
|
|
1407
|
+
]
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
id: "iphone.15.pro",
|
|
1411
|
+
name: "iPhone 15 Pro",
|
|
1412
|
+
physical: {
|
|
1413
|
+
width: 1179,
|
|
1414
|
+
height: 2556
|
|
1415
|
+
},
|
|
1416
|
+
colors: [
|
|
1417
|
+
{
|
|
1418
|
+
id: "black",
|
|
1419
|
+
name: "Black Titanium",
|
|
1420
|
+
hex: "#1b1b1b",
|
|
1421
|
+
fileSize: 177
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
id: "white",
|
|
1425
|
+
name: "White Titanium",
|
|
1426
|
+
hex: "#dddddd",
|
|
1427
|
+
fileSize: 177
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
id: "natural",
|
|
1431
|
+
name: "Natural Titanium",
|
|
1432
|
+
hex: "#837F7D",
|
|
1433
|
+
fileSize: 177
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
id: "blue",
|
|
1437
|
+
name: "Blue Titanium",
|
|
1438
|
+
hex: "#2F4452",
|
|
1439
|
+
fileSize: 202
|
|
1440
|
+
}
|
|
1441
|
+
]
|
|
1442
|
+
},
|
|
1443
|
+
{
|
|
1444
|
+
id: "iphone.15.pro.max",
|
|
1445
|
+
name: "iPhone 15 Pro Max",
|
|
1446
|
+
physical: {
|
|
1447
|
+
width: 1290,
|
|
1448
|
+
height: 2796
|
|
1449
|
+
},
|
|
1450
|
+
colors: [
|
|
1451
|
+
{
|
|
1452
|
+
id: "black",
|
|
1453
|
+
name: "Black Titanium",
|
|
1454
|
+
hex: "#1b1b1b",
|
|
1455
|
+
fileSize: 177
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
id: "white",
|
|
1459
|
+
name: "White Titanium",
|
|
1460
|
+
hex: "#dddddd",
|
|
1461
|
+
fileSize: 177
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
id: "natural",
|
|
1465
|
+
name: "Natural Titanium",
|
|
1466
|
+
hex: "#837F7D",
|
|
1467
|
+
fileSize: 177
|
|
1468
|
+
},
|
|
1469
|
+
{
|
|
1470
|
+
id: "blue",
|
|
1471
|
+
name: "Blue Titanium",
|
|
1472
|
+
hex: "#2F4452",
|
|
1473
|
+
fileSize: 202
|
|
1474
|
+
}
|
|
1475
|
+
]
|
|
1476
|
+
},
|
|
1477
|
+
{
|
|
1478
|
+
id: "iphone.16.pro",
|
|
1479
|
+
name: "iPhone 16 Pro",
|
|
1480
|
+
physical: {
|
|
1481
|
+
width: 1206,
|
|
1482
|
+
height: 2622
|
|
1483
|
+
},
|
|
1484
|
+
colors: [
|
|
1485
|
+
{
|
|
1486
|
+
id: "black",
|
|
1487
|
+
name: "Black Titanium",
|
|
1488
|
+
hex: "#3C3C3D",
|
|
1489
|
+
fileSize: 177
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
id: "white",
|
|
1493
|
+
name: "White Titanium",
|
|
1494
|
+
hex: "#F2F1ED",
|
|
1495
|
+
fileSize: 177
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
id: "natural",
|
|
1499
|
+
name: "Natural Titanium",
|
|
1500
|
+
hex: "#C2BCB2",
|
|
1501
|
+
fileSize: 177
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
id: "desert",
|
|
1505
|
+
name: "Desert Titanium",
|
|
1506
|
+
hex: "#BFA48F",
|
|
1507
|
+
fileSize: 202
|
|
1508
|
+
}
|
|
1509
|
+
]
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
id: "iphone.16.plus",
|
|
1513
|
+
name: "iPhone 16 Plus",
|
|
1514
|
+
physical: {
|
|
1515
|
+
width: 1290,
|
|
1516
|
+
height: 2796
|
|
1517
|
+
},
|
|
1518
|
+
colors: [
|
|
1519
|
+
{
|
|
1520
|
+
id: "black",
|
|
1521
|
+
name: "Black",
|
|
1522
|
+
hex: "#3C4042",
|
|
1523
|
+
fileSize: 177
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
id: "white",
|
|
1527
|
+
name: "White",
|
|
1528
|
+
hex: "#FAFAFA",
|
|
1529
|
+
fileSize: 177
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
id: "teal",
|
|
1533
|
+
name: "Teal",
|
|
1534
|
+
hex: "#B0D4D2",
|
|
1535
|
+
fileSize: 177
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
id: "ultramarine",
|
|
1539
|
+
name: "Ultramarine",
|
|
1540
|
+
hex: "#9AADF6",
|
|
1541
|
+
fileSize: 202
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
id: "pink",
|
|
1545
|
+
name: "Pink",
|
|
1546
|
+
hex: "#F2ADDA",
|
|
1547
|
+
fileSize: 202
|
|
1548
|
+
}
|
|
1549
|
+
]
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
id: "iphone.16.pro.max",
|
|
1553
|
+
name: "iPhone 16 Pro Max",
|
|
1554
|
+
physical: {
|
|
1555
|
+
width: 1320,
|
|
1556
|
+
height: 2868
|
|
1557
|
+
},
|
|
1558
|
+
colors: [
|
|
1559
|
+
{
|
|
1560
|
+
id: "black",
|
|
1561
|
+
name: "Black Titanium",
|
|
1562
|
+
hex: "#3C3C3D",
|
|
1563
|
+
fileSize: 177
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
id: "white",
|
|
1567
|
+
name: "White Titanium",
|
|
1568
|
+
hex: "#F2F1ED",
|
|
1569
|
+
fileSize: 177
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
id: "natural",
|
|
1573
|
+
name: "Natural Titanium",
|
|
1574
|
+
hex: "#C2BCB2",
|
|
1575
|
+
fileSize: 177
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
id: "desert",
|
|
1579
|
+
name: "Desert Titanium",
|
|
1580
|
+
hex: "#BFA48F",
|
|
1581
|
+
fileSize: 202
|
|
1582
|
+
}
|
|
1583
|
+
]
|
|
1584
|
+
}
|
|
1585
|
+
];
|
|
1586
|
+
const detectDevice = (bounds, deviceId) => {
|
|
1587
|
+
const found = supportedDevices.filter((device) => device.physical.width === bounds.width && device.physical.height === bounds.height);
|
|
1588
|
+
if (found.length === 1) return found[0];
|
|
1589
|
+
if (found.length > 1) return found.find((device) => device.id === deviceId) ?? found[0];
|
|
1590
|
+
throw new Error(`We could not detect the device for your screenshot dimensions (${bounds.width}x${bounds.height}). Supported devices: ${supportedDevices.map((device) => `"${device.id}"`).join(", ")}.`);
|
|
1591
|
+
};
|
|
1592
|
+
//#endregion
|
|
1593
|
+
//#region src/api/shot-service.ts
|
|
1594
|
+
const CREATE_NEW_COLLECTION = "__create_new_collection__";
|
|
1595
|
+
const SUPPORTED_SHOT_MIME_TYPES = {
|
|
1596
|
+
".png": "image/png",
|
|
1597
|
+
".jpg": "image/jpg",
|
|
1598
|
+
".jpeg": "image/jpeg"
|
|
1599
|
+
};
|
|
1600
|
+
var ShotService = class {
|
|
1601
|
+
constructor(tokenStore, registryClient) {
|
|
1602
|
+
this.tokenStore = tokenStore;
|
|
1603
|
+
this.registryClient = registryClient;
|
|
1604
|
+
}
|
|
1605
|
+
async uploadShot(opts, hooks = {}) {
|
|
1606
|
+
const filePath = await this.resolveFilePath(opts.file);
|
|
1607
|
+
const mimeType = this.resolveMimeType(filePath);
|
|
1608
|
+
const tokenResult = await this.tokenStore.resolveAccessToken();
|
|
1609
|
+
const content = await this.readFileContent(filePath);
|
|
1610
|
+
const { Jimp } = await import("jimp");
|
|
1611
|
+
const device = detectDevice((await Jimp.read(content)).bitmap, opts.device);
|
|
1612
|
+
const selectedColor = await this.selectDeviceColor(device);
|
|
1613
|
+
const collections = await this.registryClient.fetchShotCollections({
|
|
1614
|
+
token: tokenResult.token,
|
|
1615
|
+
offset: 0,
|
|
1616
|
+
limit: 40
|
|
1617
|
+
});
|
|
1618
|
+
const selectedCollection = await this.selectCollection(collections.items);
|
|
1619
|
+
hooks.onUploadStart?.();
|
|
1620
|
+
return this.registryClient.uploadShot({
|
|
1621
|
+
token: tokenResult.token,
|
|
1622
|
+
fileName: path.basename(filePath),
|
|
1623
|
+
mimeType,
|
|
1624
|
+
content,
|
|
1625
|
+
collectionId: selectedCollection.collectionId || void 0,
|
|
1626
|
+
collectionTitle: selectedCollection.collectionTitle || void 0,
|
|
1627
|
+
createNewCollection: selectedCollection.createNewCollection,
|
|
1628
|
+
color: selectedColor
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
async selectDeviceColor(device) {
|
|
1632
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive color selection requires a TTY.");
|
|
1633
|
+
const availableColors = device.colors.filter((color) => !color.unavailable);
|
|
1634
|
+
const fallbackColors = device.colors;
|
|
1635
|
+
const colorChoices = availableColors.length > 0 ? availableColors : fallbackColors;
|
|
1636
|
+
if (!colorChoices.length) throw new Error(`No frame colors available for device "${device.id}".`);
|
|
1637
|
+
if (colorChoices.length === 1) return colorChoices[0].id;
|
|
1638
|
+
return select({
|
|
1639
|
+
message: `Select device color for ${device.name}:`,
|
|
1640
|
+
default: colorChoices[0].id,
|
|
1641
|
+
choices: colorChoices.map((color) => ({
|
|
1642
|
+
name: `${color.name} (${color.id})`,
|
|
1643
|
+
value: color.id,
|
|
1644
|
+
description: color.hex
|
|
1645
|
+
}))
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
async resolveFilePath(filePath) {
|
|
1649
|
+
const normalizedPath = normalizeText(filePath);
|
|
1650
|
+
if (!normalizedPath) throw new Error("Screenshot file path is required.");
|
|
1651
|
+
const absolutePath = path.resolve(process.cwd(), normalizedPath);
|
|
1652
|
+
if (!await hasFile(absolutePath)) throw new Error(`File not found at path: ${absolutePath}`);
|
|
1653
|
+
return absolutePath;
|
|
1654
|
+
}
|
|
1655
|
+
async readFileContent(filePath) {
|
|
1656
|
+
try {
|
|
1657
|
+
await access(filePath);
|
|
1658
|
+
return await readFile(filePath);
|
|
1659
|
+
} catch {
|
|
1660
|
+
throw new Error("File read error. Please check the file path and permissions.");
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
resolveMimeType(filePath) {
|
|
1664
|
+
const mimeType = SUPPORTED_SHOT_MIME_TYPES[path.extname(filePath).toLowerCase()];
|
|
1665
|
+
if (!mimeType) throw new Error("Unsupported screenshot file type. Supported formats: .png, .jpg, .jpeg");
|
|
1666
|
+
return mimeType;
|
|
1667
|
+
}
|
|
1668
|
+
async selectCollection(items) {
|
|
1669
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Interactive collection selection requires a TTY.");
|
|
1670
|
+
const selected = await select({
|
|
1671
|
+
message: "Select collection for screenshot upload:",
|
|
1672
|
+
default: CREATE_NEW_COLLECTION,
|
|
1673
|
+
choices: [{
|
|
1674
|
+
name: "Create new collection",
|
|
1675
|
+
value: CREATE_NEW_COLLECTION,
|
|
1676
|
+
description: "Upload into a new screenshot collection"
|
|
1677
|
+
}, ...items.map((item) => ({
|
|
1678
|
+
name: `${item.title} (${item.collectionId.slice(0, 8)}...)`,
|
|
1679
|
+
value: item.collectionId,
|
|
1680
|
+
description: `${item.screenshotsCount} screenshot(s)`
|
|
1681
|
+
}))]
|
|
1682
|
+
});
|
|
1683
|
+
if (selected !== CREATE_NEW_COLLECTION) return { collectionId: selected };
|
|
1684
|
+
return {
|
|
1685
|
+
collectionId: null,
|
|
1686
|
+
createNewCollection: true,
|
|
1687
|
+
collectionTitle: normalizeText(await input({
|
|
1688
|
+
message: "Collection title (optional, leave empty for auto-name):",
|
|
1689
|
+
default: ""
|
|
1690
|
+
})) || void 0
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
//#endregion
|
|
1695
|
+
//#region src/container.ts
|
|
1696
|
+
const createAppContainer = (program) => {
|
|
1697
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC });
|
|
1698
|
+
container.register({
|
|
1699
|
+
program: asValue(program),
|
|
1700
|
+
tokenStore: asClass(TokenStore).singleton(),
|
|
1701
|
+
registryClient: asClass(RegistryClient).singleton(),
|
|
1702
|
+
authService: asClass(AuthService).singleton(),
|
|
1703
|
+
initService: asClass(InitService).singleton(),
|
|
1704
|
+
componentService: asClass(ComponentService).singleton(),
|
|
1705
|
+
uploadService: asClass(UploadService).singleton(),
|
|
1706
|
+
shotService: asClass(ShotService).singleton(),
|
|
1707
|
+
initCommand: asClass(InitCommand).singleton(),
|
|
1708
|
+
loginCommand: asClass(LoginCommand).singleton(),
|
|
1709
|
+
logoutCommand: asClass(LogoutCommand).singleton(),
|
|
1710
|
+
uploadCommand: asClass(UploadCommand).singleton(),
|
|
1711
|
+
shotCommand: asClass(ShotCommand).singleton(),
|
|
1712
|
+
addCommand: asClass(AddCommand).singleton(),
|
|
1713
|
+
commandRegistry: asFunction((initCommand, loginCommand, logoutCommand, addCommand, uploadCommand, shotCommand) => new CommandRegistry([
|
|
1714
|
+
initCommand,
|
|
1715
|
+
loginCommand,
|
|
1716
|
+
logoutCommand,
|
|
1717
|
+
addCommand,
|
|
1718
|
+
uploadCommand,
|
|
1719
|
+
shotCommand
|
|
1720
|
+
])).singleton(),
|
|
1721
|
+
cliApp: asClass(CliApp).singleton()
|
|
1722
|
+
});
|
|
1723
|
+
return container;
|
|
1724
|
+
};
|
|
1725
|
+
//#endregion
|
|
1726
|
+
//#region src/setup.ts
|
|
1727
|
+
const createApp = () => {
|
|
1728
|
+
const program = new Command();
|
|
1729
|
+
program.version(CLI_VERSION).name(CLI_NAME).usage(chalk.yellow("<command> [options]")).description(chalk.hex(COLORS.PRIMARY_300)("🚀 Add unlocked WithFrame components into your project")).helpOption("-h, --help", chalk.gray("Display help information")).addHelpText("before", chalk.cyan("\n✨ WithFrame CLI - Your Component Assistant\n")).addHelpText("after", chalk.dim("\n📖 Example:\n $ export WITHFRAME_TOKEN=\"<token>\"\n $ withframe add button\n"));
|
|
1730
|
+
const container = createAppContainer(program);
|
|
1731
|
+
program.on("command:*", () => {
|
|
1732
|
+
console.error(chalk.red("\n❌ Invalid command: %s\n"), chalk.bold(program.args.join(" ")));
|
|
1733
|
+
console.log(chalk.yellow("See --help for a list of available commands.\n"));
|
|
1734
|
+
process.exit(1);
|
|
1735
|
+
});
|
|
1736
|
+
return container.resolve("cliApp");
|
|
1737
|
+
};
|
|
1738
|
+
//#endregion
|
|
1739
|
+
//#region src/utils/banner.ts
|
|
1740
|
+
const showBanner = () => {
|
|
1741
|
+
const banner = figlet.textSync("Withframe", {
|
|
1742
|
+
font: "Standard",
|
|
1743
|
+
horizontalLayout: "default",
|
|
1744
|
+
verticalLayout: "default",
|
|
1745
|
+
width: 80,
|
|
1746
|
+
whitespaceBreak: true
|
|
1747
|
+
});
|
|
1748
|
+
const coloredBanner = chalk.hex(COLORS.PRIMARY_600)(banner);
|
|
1749
|
+
console.log(coloredBanner);
|
|
1750
|
+
};
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region src/index.ts
|
|
1753
|
+
if (process.argv.length === 2) showBanner();
|
|
1754
|
+
createApp().run(process.argv);
|
|
1755
|
+
//#endregion
|
|
1756
|
+
export {};
|