create-ts-saas 0.1.0 → 0.1.2
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 +67 -0
- package/dist/cli.mjs +790 -3
- package/dist/index.d.mts +0 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +792 -2
- package/dist/index.mjs.map +1 -0
- package/package.json +6 -5
- package/dist/cli.d.mts +0 -1
- package/dist/cli.mjs.map +0 -1
- package/dist/src-D4guDFsb.mjs +0 -794
- package/dist/src-D4guDFsb.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,793 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
import { os } from "@orpc/server";
|
|
2
|
+
import { createCli } from "trpc-cli";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { consola } from "consola";
|
|
5
|
+
import { $ } from "execa";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import semver from "semver";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "fs-extra";
|
|
10
|
+
import os$1 from "node:os";
|
|
11
|
+
import { confirm, intro, isCancel, log, note, select, spinner, text } from "@clack/prompts";
|
|
12
|
+
import open from "open";
|
|
13
|
+
import { createWriteStream } from "node:fs";
|
|
14
|
+
import { Readable } from "node:stream";
|
|
15
|
+
import { pipeline } from "node:stream/promises";
|
|
16
|
+
import unzipper from "unzipper";
|
|
17
|
+
//#region src/lib/version.ts
|
|
18
|
+
function getCliVersion() {
|
|
19
|
+
return "0.1.2";
|
|
20
|
+
}
|
|
21
|
+
function getCliPlatform() {
|
|
22
|
+
return `${process.platform}-${process.arch}`;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/lib/api.ts
|
|
26
|
+
var ApiError = class extends Error {
|
|
27
|
+
constructor(code, message, retryable = false, status, minCliVersion) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.retryable = retryable;
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.minCliVersion = minCliVersion;
|
|
33
|
+
this.name = "ApiError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function parseApiErrorBody(text) {
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(text);
|
|
39
|
+
if (typeof data.code === "string" && typeof data.message === "string") return {
|
|
40
|
+
code: data.code,
|
|
41
|
+
message: data.message,
|
|
42
|
+
retryable: Boolean(data.retryable)
|
|
43
|
+
};
|
|
44
|
+
const nested = data.error;
|
|
45
|
+
if (nested && typeof nested.code === "string" && typeof nested.message === "string") return {
|
|
46
|
+
code: nested.code,
|
|
47
|
+
message: nested.message,
|
|
48
|
+
retryable: Boolean(data.retryable)
|
|
49
|
+
};
|
|
50
|
+
} catch {}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function createApiClient(options) {
|
|
54
|
+
const { config, accessToken, deviceId } = options;
|
|
55
|
+
const baseUrl = config.apiBaseUrl.replace(/\/$/, "");
|
|
56
|
+
const cliVersion = getCliVersion();
|
|
57
|
+
const cliPlatform = getCliPlatform();
|
|
58
|
+
async function request(path, init = {}) {
|
|
59
|
+
const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
60
|
+
const headers = new Headers(init.headers);
|
|
61
|
+
headers.set("Accept", "application/json");
|
|
62
|
+
headers.set("x-cli-version", cliVersion);
|
|
63
|
+
headers.set("x-cli-platform", cliPlatform);
|
|
64
|
+
if (deviceId) headers.set("x-cli-device-id", deviceId);
|
|
65
|
+
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(url, {
|
|
69
|
+
...init,
|
|
70
|
+
headers
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
throw new ApiError("NETWORK_ERROR", "Network error — could not connect to the server.", true);
|
|
74
|
+
}
|
|
75
|
+
const minCli = res.headers.get("x-min-supported-cli");
|
|
76
|
+
if (minCli && semver.valid(cliVersion) && semver.valid(minCli) && semver.lt(cliVersion, minCli)) throw new ApiError("UPGRADE_REQUIRED", `CLI version ${cliVersion} is below minimum supported ${minCli}. Please upgrade.`, false, res.status, minCli);
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const body = parseApiErrorBody(text);
|
|
80
|
+
throw new ApiError(body?.code ?? (res.status === 401 ? "AUTH_INVALID" : "REQUEST_FAILED"), body?.message ?? res.statusText, body?.retryable ?? false, res.status, minCli ?? void 0);
|
|
81
|
+
}
|
|
82
|
+
if (!text) return {};
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
async function stream(url) {
|
|
86
|
+
const headers = new Headers();
|
|
87
|
+
headers.set("x-cli-version", cliVersion);
|
|
88
|
+
headers.set("x-cli-platform", cliPlatform);
|
|
89
|
+
if (deviceId) headers.set("x-cli-device-id", deviceId);
|
|
90
|
+
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(url, { headers });
|
|
94
|
+
} catch {
|
|
95
|
+
throw new ApiError("NETWORK_ERROR", "Network error — could not connect to the server.", true);
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const body = parseApiErrorBody(await res.text());
|
|
99
|
+
throw new ApiError(body?.code ?? "DOWNLOAD_FAILED", body?.message ?? "Template download failed", false, res.status);
|
|
100
|
+
}
|
|
101
|
+
return res;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
baseUrl,
|
|
105
|
+
request,
|
|
106
|
+
stream,
|
|
107
|
+
deviceInit: () => request("/cli/device/init", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({})
|
|
111
|
+
}),
|
|
112
|
+
devicePoll: (deviceCode) => request("/cli/device/poll", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ deviceCode })
|
|
116
|
+
}),
|
|
117
|
+
refresh: (refreshToken) => request("/cli/auth/refresh", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ refreshToken })
|
|
121
|
+
}),
|
|
122
|
+
logoutDevice: (refreshToken) => request("/cli/device/logout", {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify({ refreshToken })
|
|
126
|
+
}),
|
|
127
|
+
me: () => request("/cli/me"),
|
|
128
|
+
getTemplates: () => request("/cli/templates"),
|
|
129
|
+
authorizeInit: (input) => request("/cli/init/authorize", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
body: JSON.stringify(input)
|
|
133
|
+
}),
|
|
134
|
+
getLatestVersion: () => request("/cli/latest-version")
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/constants.ts
|
|
139
|
+
const DEFAULT_API_BASE_URL = "https://api.ts-saas.com/api/v1";
|
|
140
|
+
const CONFIG_DIR_NAME = "ts-saas-cli";
|
|
141
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
142
|
+
const KEYTAR_SERVICE_NAME = "create-ts-saas";
|
|
143
|
+
const KEYTAR_ACCOUNT_NAME = "default";
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/lib/config.ts
|
|
146
|
+
function getConfigDir() {
|
|
147
|
+
const home = os$1.homedir();
|
|
148
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
149
|
+
return path.join(configHome, CONFIG_DIR_NAME);
|
|
150
|
+
}
|
|
151
|
+
function getConfigPath() {
|
|
152
|
+
return path.join(getConfigDir(), CONFIG_FILE_NAME);
|
|
153
|
+
}
|
|
154
|
+
async function loadConfig() {
|
|
155
|
+
const fromEnv = {
|
|
156
|
+
apiBaseUrl: DEFAULT_API_BASE_URL,
|
|
157
|
+
disableTelemetry: process.env.TS_SAAS_CLI_DISABLE_TELEMETRY === "1" || process.env.TS_SAAS_CLI_DISABLE_TELEMETRY === "true"
|
|
158
|
+
};
|
|
159
|
+
const configPath = getConfigPath();
|
|
160
|
+
try {
|
|
161
|
+
if (await fs.pathExists(configPath)) {
|
|
162
|
+
const data = await fs.readJson(configPath);
|
|
163
|
+
return {
|
|
164
|
+
apiBaseUrl: data.apiBaseUrl ?? fromEnv.apiBaseUrl ?? "https://api.ts-saas.com/api/v1",
|
|
165
|
+
disableTelemetry: data.disableTelemetry ?? fromEnv.disableTelemetry ?? false
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
return {
|
|
170
|
+
apiBaseUrl: fromEnv.apiBaseUrl ?? "https://api.ts-saas.com/api/v1",
|
|
171
|
+
disableTelemetry: fromEnv.disableTelemetry ?? false
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/lib/storage.ts
|
|
176
|
+
const FALLBACK_FILE = "session.json";
|
|
177
|
+
let cachedKeytar;
|
|
178
|
+
function getFallbackSessionPath() {
|
|
179
|
+
return path.join(path.dirname(getConfigPath()), FALLBACK_FILE);
|
|
180
|
+
}
|
|
181
|
+
async function getStoredSession() {
|
|
182
|
+
const keytar = await getKeytar();
|
|
183
|
+
try {
|
|
184
|
+
const value = keytar ? await keytar.getPassword(KEYTAR_SERVICE_NAME, KEYTAR_ACCOUNT_NAME) : null;
|
|
185
|
+
if (!value) return await getFallbackSession();
|
|
186
|
+
const parsed = JSON.parse(value);
|
|
187
|
+
if (typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string" || typeof parsed.deviceId !== "string" || typeof parsed.expiresAt !== "number") return null;
|
|
188
|
+
return parsed;
|
|
189
|
+
} catch {
|
|
190
|
+
return await getFallbackSession();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function setStoredSession(session) {
|
|
194
|
+
const serialized = JSON.stringify(session);
|
|
195
|
+
const keytar = await getKeytar();
|
|
196
|
+
try {
|
|
197
|
+
if (keytar) {
|
|
198
|
+
await keytar.setPassword(KEYTAR_SERVICE_NAME, KEYTAR_ACCOUNT_NAME, serialized);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await setFallbackSession(session);
|
|
202
|
+
} catch {
|
|
203
|
+
await setFallbackSession(session);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function clearStoredSession() {
|
|
207
|
+
const keytar = await getKeytar();
|
|
208
|
+
try {
|
|
209
|
+
if (keytar) await keytar.deletePassword(KEYTAR_SERVICE_NAME, KEYTAR_ACCOUNT_NAME);
|
|
210
|
+
} catch {}
|
|
211
|
+
try {
|
|
212
|
+
await fs.remove(getFallbackSessionPath());
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
async function getFallbackSession() {
|
|
216
|
+
const filePath = getFallbackSessionPath();
|
|
217
|
+
try {
|
|
218
|
+
if (!await fs.pathExists(filePath)) return null;
|
|
219
|
+
const value = await fs.readJson(filePath);
|
|
220
|
+
if (typeof value.accessToken !== "string" || typeof value.refreshToken !== "string" || typeof value.deviceId !== "string" || typeof value.expiresAt !== "number") return null;
|
|
221
|
+
return value;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function setFallbackSession(session) {
|
|
227
|
+
await fs.ensureDir(path.dirname(getFallbackSessionPath()));
|
|
228
|
+
await fs.writeJson(getFallbackSessionPath(), session, { spaces: 0 });
|
|
229
|
+
try {
|
|
230
|
+
await fs.chmod(getFallbackSessionPath(), 384);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
async function getKeytar() {
|
|
234
|
+
if (cachedKeytar !== void 0) return cachedKeytar;
|
|
235
|
+
try {
|
|
236
|
+
const imported = await import("keytar");
|
|
237
|
+
cachedKeytar = imported.default ?? imported;
|
|
238
|
+
return cachedKeytar;
|
|
239
|
+
} catch {
|
|
240
|
+
cachedKeytar = null;
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/commands/doctor.ts
|
|
246
|
+
async function doctorCommand() {
|
|
247
|
+
consola.log(pc.bold("Environment checks\n"));
|
|
248
|
+
const checks = [];
|
|
249
|
+
checks.push({
|
|
250
|
+
name: "Node.js",
|
|
251
|
+
status: Number(process.versions.node.split(".")[0]) >= 20 ? "ok" : "fail",
|
|
252
|
+
detail: process.version
|
|
253
|
+
});
|
|
254
|
+
try {
|
|
255
|
+
const result = await $`pnpm --version`;
|
|
256
|
+
checks.push({
|
|
257
|
+
name: "pnpm",
|
|
258
|
+
status: "ok",
|
|
259
|
+
detail: result.stdout.trim()
|
|
260
|
+
});
|
|
261
|
+
} catch {
|
|
262
|
+
checks.push({
|
|
263
|
+
name: "pnpm",
|
|
264
|
+
status: "fail",
|
|
265
|
+
detail: "not found"
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await $`git --version`;
|
|
270
|
+
checks.push({
|
|
271
|
+
name: "git",
|
|
272
|
+
status: "ok",
|
|
273
|
+
detail: "installed"
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
checks.push({
|
|
277
|
+
name: "git",
|
|
278
|
+
status: "fail",
|
|
279
|
+
detail: "not found"
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const config = await loadConfig();
|
|
283
|
+
const session = await getStoredSession();
|
|
284
|
+
checks.push({
|
|
285
|
+
name: "session",
|
|
286
|
+
status: session ? "ok" : "fail",
|
|
287
|
+
detail: session ? "present" : "missing"
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
await createApiClient({
|
|
291
|
+
config,
|
|
292
|
+
accessToken: session?.accessToken ?? null,
|
|
293
|
+
deviceId: session?.deviceId ?? null
|
|
294
|
+
}).getLatestVersion();
|
|
295
|
+
checks.push({
|
|
296
|
+
name: "api",
|
|
297
|
+
status: "ok",
|
|
298
|
+
detail: config.apiBaseUrl
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
checks.push({
|
|
302
|
+
name: "api",
|
|
303
|
+
status: "fail",
|
|
304
|
+
detail: "unreachable"
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
for (const check of checks) {
|
|
308
|
+
const icon = check.status === "ok" ? pc.green("✓") : pc.red("✗");
|
|
309
|
+
consola.log(`${icon} ${check.name}: ${check.detail}`);
|
|
310
|
+
}
|
|
311
|
+
consola.log(`\nCLI version: ${getCliVersion()}`);
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/utils/open-url.ts
|
|
315
|
+
async function openUrl(url) {
|
|
316
|
+
try {
|
|
317
|
+
await open(url);
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/lib/auth.ts
|
|
322
|
+
async function getValidSession() {
|
|
323
|
+
const session = await getStoredSession();
|
|
324
|
+
if (!session) return null;
|
|
325
|
+
if (session.expiresAt <= Date.now() + 6e4) return null;
|
|
326
|
+
return session;
|
|
327
|
+
}
|
|
328
|
+
async function ensureSession(apiFactory) {
|
|
329
|
+
const valid = await getValidSession();
|
|
330
|
+
if (valid) return valid;
|
|
331
|
+
const current = await getStoredSession();
|
|
332
|
+
if (current?.refreshToken) try {
|
|
333
|
+
const refreshed = await apiFactory(current).refresh(current.refreshToken);
|
|
334
|
+
const next = {
|
|
335
|
+
accessToken: refreshed.accessToken,
|
|
336
|
+
refreshToken: refreshed.refreshToken,
|
|
337
|
+
deviceId: current.deviceId,
|
|
338
|
+
expiresAt: Date.now() + refreshed.expiresIn * 1e3
|
|
339
|
+
};
|
|
340
|
+
await setStoredSession(next);
|
|
341
|
+
return next;
|
|
342
|
+
} catch {
|
|
343
|
+
await clearStoredSession();
|
|
344
|
+
}
|
|
345
|
+
return await loginWithDeviceFlow(apiFactory());
|
|
346
|
+
}
|
|
347
|
+
async function loginWithDeviceFlow(api) {
|
|
348
|
+
const init = await api.deviceInit();
|
|
349
|
+
const verificationUrlWithCode = `${init.verificationUrl}${init.verificationUrl.includes("?") ? "&" : "?"}code=${encodeURIComponent(init.userCode)}`;
|
|
350
|
+
note(`Open ${verificationUrlWithCode} and confirm code ${init.userCode}\nCode expires in ${Math.floor(init.expiresIn / 60)} minutes.`, "Device Login");
|
|
351
|
+
openUrl(verificationUrlWithCode);
|
|
352
|
+
const deadline = Date.now() + init.expiresIn * 1e3;
|
|
353
|
+
while (Date.now() < deadline) {
|
|
354
|
+
await new Promise((resolve) => {
|
|
355
|
+
setTimeout(resolve, Math.max(init.interval, 3) * 1e3);
|
|
356
|
+
});
|
|
357
|
+
try {
|
|
358
|
+
const token = await api.devicePoll(init.deviceCode);
|
|
359
|
+
const session = {
|
|
360
|
+
accessToken: token.accessToken,
|
|
361
|
+
refreshToken: token.refreshToken,
|
|
362
|
+
deviceId: token.deviceId,
|
|
363
|
+
expiresAt: Date.now() + token.expiresIn * 1e3
|
|
364
|
+
};
|
|
365
|
+
await setStoredSession(session);
|
|
366
|
+
return session;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
const e = error;
|
|
369
|
+
if (e.code === "AUTH_PENDING" || e.retryable) continue;
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
throw new Error("Device code expired. Run login again.");
|
|
374
|
+
}
|
|
375
|
+
async function logout(apiFactory) {
|
|
376
|
+
const session = await getStoredSession();
|
|
377
|
+
if (session?.refreshToken) try {
|
|
378
|
+
await apiFactory(session).logoutDevice(session.refreshToken);
|
|
379
|
+
} catch {}
|
|
380
|
+
await clearStoredSession();
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/lib/scaffolder.ts
|
|
384
|
+
const IGNORED_ROOT_ENTRIES = new Set(["__MACOSX"]);
|
|
385
|
+
async function streamAndExtractTemplate(downloadResponse, projectDir) {
|
|
386
|
+
if (!downloadResponse.body) throw new Error("Template response body is empty.");
|
|
387
|
+
await fs.ensureDir(projectDir);
|
|
388
|
+
const tempDir = path.join(projectDir, "..", `.create-ts-saas-tmp-${Date.now()}`);
|
|
389
|
+
const extractDir = path.join(tempDir, "extracted");
|
|
390
|
+
await fs.ensureDir(tempDir);
|
|
391
|
+
await fs.ensureDir(extractDir);
|
|
392
|
+
const zipPath = path.join(tempDir, "template.zip");
|
|
393
|
+
const writable = createWriteStream(zipPath);
|
|
394
|
+
await pipeline(Readable.fromWeb(downloadResponse.body), writable);
|
|
395
|
+
await (await unzipper.Open.file(zipPath)).extract({ path: extractDir });
|
|
396
|
+
const meaningfulEntries = (await fs.readdir(extractDir)).filter((name) => !IGNORED_ROOT_ENTRIES.has(name));
|
|
397
|
+
if (meaningfulEntries.length === 1 && meaningfulEntries[0]) {
|
|
398
|
+
const singleRoot = path.join(extractDir, meaningfulEntries[0]);
|
|
399
|
+
if ((await fs.stat(singleRoot)).isDirectory()) {
|
|
400
|
+
const children = await fs.readdir(singleRoot);
|
|
401
|
+
for (const child of children) await fs.move(path.join(singleRoot, child), path.join(projectDir, child), { overwrite: true });
|
|
402
|
+
} else await fs.move(singleRoot, path.join(projectDir, meaningfulEntries[0]), { overwrite: true });
|
|
403
|
+
} else for (const entry of meaningfulEntries) await fs.move(path.join(extractDir, entry), path.join(projectDir, entry), { overwrite: true });
|
|
404
|
+
await fs.remove(tempDir).catch(() => {});
|
|
405
|
+
}
|
|
406
|
+
async function writeWatermark(projectDir, payload) {
|
|
407
|
+
const pkgJsonPath = path.join(projectDir, "package.json");
|
|
408
|
+
if (await fs.pathExists(pkgJsonPath)) {
|
|
409
|
+
const pkgJson = await fs.readJson(pkgJsonPath);
|
|
410
|
+
pkgJson["create-ts-saas"] = { wId: payload.watermarkId ?? payload.issuanceId };
|
|
411
|
+
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/prompts/install.ts
|
|
416
|
+
async function promptInstall(defaultValue = true) {
|
|
417
|
+
const value = await confirm({
|
|
418
|
+
message: "Install dependencies now?",
|
|
419
|
+
initialValue: defaultValue
|
|
420
|
+
});
|
|
421
|
+
if (isCancel(value)) throw new Error("Operation cancelled.");
|
|
422
|
+
return value;
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/prompts/package-manager.ts
|
|
426
|
+
async function promptPackageManager(initialValue) {
|
|
427
|
+
const value = await select({
|
|
428
|
+
message: "Select package manager",
|
|
429
|
+
options: [
|
|
430
|
+
{
|
|
431
|
+
value: "pnpm",
|
|
432
|
+
label: "pnpm"
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
value: "npm",
|
|
436
|
+
label: "npm"
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
value: "bun",
|
|
440
|
+
label: "bun"
|
|
441
|
+
}
|
|
442
|
+
],
|
|
443
|
+
initialValue
|
|
444
|
+
});
|
|
445
|
+
if (isCancel(value)) throw new Error("Operation cancelled.");
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/prompts/project-name.ts
|
|
450
|
+
async function promptProjectName(initialValue = "my-saas-app") {
|
|
451
|
+
const value = await text({
|
|
452
|
+
message: "Project name",
|
|
453
|
+
initialValue,
|
|
454
|
+
validate: (input) => input?.trim() ? void 0 : "Project name is required"
|
|
455
|
+
});
|
|
456
|
+
if (isCancel(value) || typeof value !== "string") throw new Error("Operation cancelled.");
|
|
457
|
+
return value.trim();
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/prompts/template.ts
|
|
461
|
+
async function promptTemplate(templates) {
|
|
462
|
+
const value = await select({
|
|
463
|
+
message: "Select template",
|
|
464
|
+
options: templates.map((t) => ({
|
|
465
|
+
value: t.slug,
|
|
466
|
+
label: `${t.name} (${t.slug})`,
|
|
467
|
+
hint: `latest ${t.latestVersion}`
|
|
468
|
+
}))
|
|
469
|
+
});
|
|
470
|
+
if (isCancel(value) || typeof value !== "string") throw new Error("Operation cancelled.");
|
|
471
|
+
const chosen = templates.find((t) => t.slug === value);
|
|
472
|
+
if (!chosen) throw new Error(`Unknown template "${value}".`);
|
|
473
|
+
return chosen;
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/prompts/variant.ts
|
|
477
|
+
async function promptVariant(template) {
|
|
478
|
+
const first = template.variants[0];
|
|
479
|
+
if (!first) throw new Error(`Template "${template.slug}" has no variants.`);
|
|
480
|
+
const value = await select({
|
|
481
|
+
message: "Select variant",
|
|
482
|
+
options: template.variants.map((v) => ({
|
|
483
|
+
value: v.slug,
|
|
484
|
+
label: v.name,
|
|
485
|
+
hint: v.slug
|
|
486
|
+
})),
|
|
487
|
+
initialValue: first.slug
|
|
488
|
+
});
|
|
489
|
+
if (isCancel(value) || typeof value !== "string") throw new Error("Operation cancelled.");
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/utils/get-package-manager.ts
|
|
494
|
+
function getUserPackageManager() {
|
|
495
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
496
|
+
if (userAgent?.startsWith("pnpm")) return "pnpm";
|
|
497
|
+
if (userAgent?.startsWith("bun")) return "bun";
|
|
498
|
+
return "npm";
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/utils/handle-cli-error.ts
|
|
502
|
+
const DEBUG_FLAG = process.env.TS_SAAS_CLI_DEBUG === "1" || process.env.TS_SAAS_CLI_DEBUG === "true";
|
|
503
|
+
function isNetworkError(error) {
|
|
504
|
+
if (!(error instanceof Error)) return false;
|
|
505
|
+
const cause = error.cause;
|
|
506
|
+
if (cause instanceof Error) {
|
|
507
|
+
const code = cause.code;
|
|
508
|
+
if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT" || code === "ECONNRESET") return true;
|
|
509
|
+
}
|
|
510
|
+
return error.message === "fetch failed";
|
|
511
|
+
}
|
|
512
|
+
function handleCliError(error) {
|
|
513
|
+
if (isNetworkError(error)) {
|
|
514
|
+
log.error(pc.red("Network error — could not connect to the server."));
|
|
515
|
+
if (DEBUG_FLAG) console.error(error);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
if (error instanceof ApiError) {
|
|
519
|
+
log.error(pc.red(error.message));
|
|
520
|
+
if (DEBUG_FLAG) console.error(error);
|
|
521
|
+
process.exit(getExitCode(error));
|
|
522
|
+
}
|
|
523
|
+
if (error instanceof Error) {
|
|
524
|
+
log.error(pc.red(error.message || "Something went wrong"));
|
|
525
|
+
if (DEBUG_FLAG) console.error(error);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
log.error(pc.red("Something went wrong"));
|
|
529
|
+
if (DEBUG_FLAG) console.error(error);
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
function getExitCode(error) {
|
|
533
|
+
switch (error.code) {
|
|
534
|
+
case "AUTH_EXPIRED":
|
|
535
|
+
case "AUTH_INVALID": return 2;
|
|
536
|
+
case "PLAN_FORBIDDEN": return 3;
|
|
537
|
+
case "UPGRADE_REQUIRED": return 4;
|
|
538
|
+
default: return error.status && error.status >= 500 ? 1 : 1;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/utils/project-directory.ts
|
|
543
|
+
async function resolveProjectDirectory(initialInput) {
|
|
544
|
+
const current = initialInput;
|
|
545
|
+
while (true) {
|
|
546
|
+
const fullPath = path.resolve(process.cwd(), current);
|
|
547
|
+
if (!(await fs.pathExists(fullPath) && (await fs.readdir(fullPath)).length > 0)) {
|
|
548
|
+
await fs.ensureDir(fullPath);
|
|
549
|
+
return {
|
|
550
|
+
projectDir: fullPath,
|
|
551
|
+
projectName: path.basename(fullPath)
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
log.warn(`Directory "${pc.yellow(current)}" already exists and is not empty.`);
|
|
555
|
+
const action = await select({
|
|
556
|
+
message: "Choose how to proceed",
|
|
557
|
+
options: [
|
|
558
|
+
{
|
|
559
|
+
value: "merge",
|
|
560
|
+
label: "Merge into existing directory"
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
value: "overwrite",
|
|
564
|
+
label: "Overwrite directory contents"
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
value: "cancel",
|
|
568
|
+
label: "Cancel"
|
|
569
|
+
}
|
|
570
|
+
],
|
|
571
|
+
initialValue: "merge"
|
|
572
|
+
});
|
|
573
|
+
if (isCancel(action) || action === "cancel") throw new Error("Operation cancelled.");
|
|
574
|
+
if (action === "merge") return {
|
|
575
|
+
projectDir: fullPath,
|
|
576
|
+
projectName: path.basename(fullPath)
|
|
577
|
+
};
|
|
578
|
+
await fs.emptyDir(fullPath);
|
|
579
|
+
return {
|
|
580
|
+
projectDir: fullPath,
|
|
581
|
+
projectName: path.basename(fullPath)
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/commands/init.ts
|
|
587
|
+
async function initCommand(options) {
|
|
588
|
+
intro(pc.magenta("Initialize your saas..."));
|
|
589
|
+
try {
|
|
590
|
+
const config = await loadConfig();
|
|
591
|
+
const session = await ensureSession((current) => createApiClient({
|
|
592
|
+
config,
|
|
593
|
+
accessToken: current?.accessToken ?? null,
|
|
594
|
+
deviceId: current?.deviceId ?? null
|
|
595
|
+
}));
|
|
596
|
+
const api = createApiClient({
|
|
597
|
+
config,
|
|
598
|
+
accessToken: session.accessToken,
|
|
599
|
+
deviceId: session.deviceId
|
|
600
|
+
});
|
|
601
|
+
const { projectDir, projectName } = await resolveProjectDirectory(options.projectName || options.dir || await promptProjectName());
|
|
602
|
+
const templates = (await api.getTemplates()).templates;
|
|
603
|
+
if (!templates.length) throw new Error("No templates available for this account.");
|
|
604
|
+
const selectedTemplate = options.template && templates.find((template) => template.slug === options.template) ? templates.find((template) => template.slug === options.template) : await promptTemplate(templates);
|
|
605
|
+
if (!selectedTemplate) {
|
|
606
|
+
log.error(pc.red("Selected template not found."));
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
const selectedVariant = options.variant && selectedTemplate.variants.some((variant) => variant.slug === options.variant) ? options.variant : await promptVariant(selectedTemplate);
|
|
610
|
+
const s = spinner();
|
|
611
|
+
s.start("Authorizing template download...");
|
|
612
|
+
try {
|
|
613
|
+
const authz = await api.authorizeInit({
|
|
614
|
+
templateSlug: selectedTemplate.slug,
|
|
615
|
+
variantSlug: selectedVariant
|
|
616
|
+
});
|
|
617
|
+
s.message("Downloading template...");
|
|
618
|
+
await streamAndExtractTemplate(await api.stream(authz.downloadUrl), projectDir);
|
|
619
|
+
await writeWatermark(projectDir, authz.watermark);
|
|
620
|
+
s.stop("Template ready.");
|
|
621
|
+
} catch (error) {
|
|
622
|
+
s.stop(pc.red("Failed to scaffold template."));
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
const packageManager = options.packageManager ?? await promptPackageManager(getUserPackageManager());
|
|
626
|
+
if (options.install ?? (options.yes ? true : await promptInstall(true))) {
|
|
627
|
+
s.start(`Running ${packageManager} install...`);
|
|
628
|
+
await $({
|
|
629
|
+
cwd: projectDir,
|
|
630
|
+
stderr: "inherit"
|
|
631
|
+
})`${packageManager} install`;
|
|
632
|
+
s.stop("Dependencies installed.");
|
|
633
|
+
}
|
|
634
|
+
if (options.git ?? (options.yes ? true : await confirm({
|
|
635
|
+
message: "Initialize git?",
|
|
636
|
+
initialValue: true
|
|
637
|
+
}))) try {
|
|
638
|
+
await $({
|
|
639
|
+
cwd: projectDir,
|
|
640
|
+
stderr: "ignore",
|
|
641
|
+
stdout: "ignore"
|
|
642
|
+
})`git init`;
|
|
643
|
+
} catch {
|
|
644
|
+
log.warn("Git init failed. Continuing without repository setup.");
|
|
645
|
+
}
|
|
646
|
+
log.success(pc.green(`Project created at ${projectDir}`));
|
|
647
|
+
note([
|
|
648
|
+
`cd ${path.basename(projectDir)}`,
|
|
649
|
+
``,
|
|
650
|
+
`Copy ${pc.cyan(".env.example")} → ${pc.cyan(".env")} and fill in your values`,
|
|
651
|
+
`Run ${pc.cyan(`${packageManager} db:migrate`)} to set up the database`,
|
|
652
|
+
`Run ${pc.cyan(`${packageManager} dev`)} to start`
|
|
653
|
+
].join("\n"), `Next steps (${projectName})`);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
handleCliError(error);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/commands/login.ts
|
|
660
|
+
async function loginCommand() {
|
|
661
|
+
intro(pc.magenta("Login to ts-saas"));
|
|
662
|
+
try {
|
|
663
|
+
await loginWithDeviceFlow(createApiClient({ config: await loadConfig() }));
|
|
664
|
+
log.success(pc.green("Logged in successfully."));
|
|
665
|
+
} catch (error) {
|
|
666
|
+
handleCliError(error);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/commands/logout.ts
|
|
671
|
+
async function logoutCommand() {
|
|
672
|
+
intro(pc.magenta("Logout from create-ts-saas"));
|
|
673
|
+
const config = await loadConfig();
|
|
674
|
+
await logout((session) => createApiClient({
|
|
675
|
+
config,
|
|
676
|
+
accessToken: session?.accessToken ?? null,
|
|
677
|
+
deviceId: session?.deviceId ?? null
|
|
678
|
+
}));
|
|
679
|
+
log.success(pc.green("Logged out and local session cleared."));
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/commands/upgrade.ts
|
|
683
|
+
async function upgradeCommand() {
|
|
684
|
+
intro(pc.magenta("Checking for updates"));
|
|
685
|
+
const latest = await createApiClient({ config: await loadConfig() }).getLatestVersion();
|
|
686
|
+
const current = getCliVersion();
|
|
687
|
+
if (latest.version !== current) {
|
|
688
|
+
log.warn(pc.yellow(`Update available: current ${current}, latest ${latest.version}. Run: npx create-ts-saas@latest`));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
log.success(pc.green(`You are on latest version (${current}).`));
|
|
692
|
+
}
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/commands/whoami.ts
|
|
695
|
+
async function whoamiCommand() {
|
|
696
|
+
intro(pc.magenta("Current CLI account"));
|
|
697
|
+
try {
|
|
698
|
+
const config = await loadConfig();
|
|
699
|
+
const session = await ensureSession((current) => createApiClient({
|
|
700
|
+
config,
|
|
701
|
+
accessToken: current?.accessToken ?? null,
|
|
702
|
+
deviceId: current?.deviceId ?? null
|
|
703
|
+
}));
|
|
704
|
+
const data = await createApiClient({
|
|
705
|
+
config,
|
|
706
|
+
accessToken: session.accessToken,
|
|
707
|
+
deviceId: session.deviceId
|
|
708
|
+
}).me();
|
|
709
|
+
note(`Email: ${data.email}\nPlan: ${data.plan}\nActive devices: ${data.activeDevices}\nTemplates: ${data.templates.join(", ") || "none"}`, "Account");
|
|
710
|
+
log.success(pc.green("Account lookup completed."));
|
|
711
|
+
} catch (error) {
|
|
712
|
+
handleCliError(error);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/index.ts
|
|
717
|
+
const router = os.router({
|
|
718
|
+
create: os.meta({
|
|
719
|
+
description: "Create a new project from your paid SaaS template",
|
|
720
|
+
default: true,
|
|
721
|
+
negateBooleans: true
|
|
722
|
+
}).input(z.tuple([z.string().optional(), z.object({
|
|
723
|
+
dir: z.string().optional(),
|
|
724
|
+
yes: z.boolean().optional().default(false),
|
|
725
|
+
template: z.string().optional(),
|
|
726
|
+
variant: z.string().optional(),
|
|
727
|
+
packageManager: z.enum([
|
|
728
|
+
"npm",
|
|
729
|
+
"pnpm",
|
|
730
|
+
"bun"
|
|
731
|
+
]).optional(),
|
|
732
|
+
install: z.boolean().optional(),
|
|
733
|
+
git: z.boolean().optional()
|
|
734
|
+
})])).handler(async ({ input }) => {
|
|
735
|
+
const [projectName, options] = input;
|
|
736
|
+
await initCommand({ ...projectName ? {
|
|
737
|
+
...options,
|
|
738
|
+
projectName
|
|
739
|
+
} : options });
|
|
740
|
+
}),
|
|
741
|
+
init: os.meta({ description: "Alias for create command" }).input(z.tuple([z.string().optional(), z.object({
|
|
742
|
+
dir: z.string().optional(),
|
|
743
|
+
yes: z.boolean().optional().default(false),
|
|
744
|
+
template: z.string().optional(),
|
|
745
|
+
variant: z.string().optional(),
|
|
746
|
+
packageManager: z.enum([
|
|
747
|
+
"npm",
|
|
748
|
+
"pnpm",
|
|
749
|
+
"bun"
|
|
750
|
+
]).optional(),
|
|
751
|
+
install: z.boolean().optional(),
|
|
752
|
+
git: z.boolean().optional()
|
|
753
|
+
})])).handler(async ({ input }) => {
|
|
754
|
+
const [projectName, options] = input;
|
|
755
|
+
await initCommand({ ...projectName ? {
|
|
756
|
+
...options,
|
|
757
|
+
projectName
|
|
758
|
+
} : options });
|
|
759
|
+
}),
|
|
760
|
+
login: os.meta({ description: "Login with device flow" }).handler(async () => {
|
|
761
|
+
await loginCommand();
|
|
762
|
+
}),
|
|
763
|
+
logout: os.meta({ description: "Logout and clear local auth session" }).handler(async () => {
|
|
764
|
+
await logoutCommand();
|
|
765
|
+
}),
|
|
766
|
+
doctor: os.meta({ description: "Run local environment checks" }).handler(async () => {
|
|
767
|
+
await doctorCommand();
|
|
768
|
+
}),
|
|
769
|
+
whoami: os.meta({ description: "Show authenticated account details" }).handler(async () => {
|
|
770
|
+
await whoamiCommand();
|
|
771
|
+
}),
|
|
772
|
+
upgrade: os.meta({ description: "Check CLI version status" }).handler(async () => {
|
|
773
|
+
await upgradeCommand();
|
|
774
|
+
})
|
|
775
|
+
});
|
|
776
|
+
function createCliApp() {
|
|
777
|
+
return createCli({
|
|
778
|
+
router,
|
|
779
|
+
name: "create-ts-saas",
|
|
780
|
+
version: getCliVersion()
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
async function runCli() {
|
|
784
|
+
try {
|
|
785
|
+
await createCliApp().run();
|
|
786
|
+
} catch (error) {
|
|
787
|
+
handleCliError(error);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
3
791
|
export { router, runCli };
|
|
792
|
+
|
|
793
|
+
//# sourceMappingURL=index.mjs.map
|