tthr 0.0.6
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/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1766 -0
- package/dist/open-OC46UHP7.js +344 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import chalk3 from "chalk";
|
|
8
|
+
import ora2 from "ora";
|
|
9
|
+
import prompts from "prompts";
|
|
10
|
+
import fs3 from "fs-extra";
|
|
11
|
+
import path3 from "path";
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
|
|
14
|
+
// src/utils/auth.ts
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import fs from "fs-extra";
|
|
17
|
+
import path from "path";
|
|
18
|
+
var CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "", ".tether");
|
|
19
|
+
var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
20
|
+
async function getCredentials() {
|
|
21
|
+
try {
|
|
22
|
+
if (await fs.pathExists(CREDENTIALS_FILE)) {
|
|
23
|
+
return await fs.readJSON(CREDENTIALS_FILE);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
async function requireAuth() {
|
|
30
|
+
const credentials = await getCredentials();
|
|
31
|
+
if (!credentials) {
|
|
32
|
+
console.error(chalk.red("\n\u2717 Not logged in\n"));
|
|
33
|
+
console.log(chalk.dim("Run `tthr login` to authenticate\n"));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
if (credentials.expiresAt) {
|
|
37
|
+
const expiresAt = new Date(credentials.expiresAt);
|
|
38
|
+
if (/* @__PURE__ */ new Date() > expiresAt) {
|
|
39
|
+
console.error(chalk.red("\n\u2717 Session expired\n"));
|
|
40
|
+
console.log(chalk.dim("Run `tthr login` to authenticate\n"));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return credentials;
|
|
45
|
+
}
|
|
46
|
+
async function saveCredentials(credentials) {
|
|
47
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
48
|
+
await fs.writeJSON(CREDENTIALS_FILE, credentials, { spaces: 2, mode: 384 });
|
|
49
|
+
}
|
|
50
|
+
async function clearCredentials() {
|
|
51
|
+
try {
|
|
52
|
+
await fs.remove(CREDENTIALS_FILE);
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/commands/deploy.ts
|
|
58
|
+
import chalk2 from "chalk";
|
|
59
|
+
import ora from "ora";
|
|
60
|
+
import fs2 from "fs-extra";
|
|
61
|
+
import path2 from "path";
|
|
62
|
+
var isDev = process.env.NODE_ENV === "development" || process.env.TETHER_DEV === "true";
|
|
63
|
+
var API_URL = isDev ? "http://localhost:3001/api/v1" : "https://tthr.io/api/v1";
|
|
64
|
+
async function deployCommand(options) {
|
|
65
|
+
const credentials = await requireAuth();
|
|
66
|
+
const configPath = path2.resolve(process.cwd(), "tether.config.ts");
|
|
67
|
+
if (!await fs2.pathExists(configPath)) {
|
|
68
|
+
console.log(chalk2.red("\nError: Not a Tether project"));
|
|
69
|
+
console.log(chalk2.dim("Run `tthr init` to create a new project\n"));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const envPath = path2.resolve(process.cwd(), ".env");
|
|
73
|
+
let projectId;
|
|
74
|
+
if (await fs2.pathExists(envPath)) {
|
|
75
|
+
const envContent = await fs2.readFile(envPath, "utf-8");
|
|
76
|
+
const match = envContent.match(/TETHER_PROJECT_ID=(.+)/);
|
|
77
|
+
projectId = match?.[1]?.trim();
|
|
78
|
+
}
|
|
79
|
+
if (!projectId) {
|
|
80
|
+
console.log(chalk2.red("\nError: Project ID not found"));
|
|
81
|
+
console.log(chalk2.dim("Make sure TETHER_PROJECT_ID is set in your .env file\n"));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
console.log(chalk2.bold("\n\u26A1 Deploying to Tether\n"));
|
|
85
|
+
console.log(chalk2.dim(` Project: ${projectId}`));
|
|
86
|
+
console.log(chalk2.dim(` API: ${API_URL}
|
|
87
|
+
`));
|
|
88
|
+
const deploySchema = options.schema || !options.schema && !options.functions;
|
|
89
|
+
const deployFunctions = options.functions || !options.schema && !options.functions;
|
|
90
|
+
if (deploySchema) {
|
|
91
|
+
await deploySchemaToServer(projectId, credentials.accessToken, options.dryRun);
|
|
92
|
+
}
|
|
93
|
+
if (deployFunctions) {
|
|
94
|
+
await deployFunctionsToServer(projectId, credentials.accessToken, options.dryRun);
|
|
95
|
+
}
|
|
96
|
+
console.log(chalk2.green("\n\u2713 Deployment complete\n"));
|
|
97
|
+
}
|
|
98
|
+
async function deploySchemaToServer(projectId, token, dryRun) {
|
|
99
|
+
const spinner = ora("Reading schema...").start();
|
|
100
|
+
try {
|
|
101
|
+
const schemaPath = path2.resolve(process.cwd(), "tether", "schema.ts");
|
|
102
|
+
if (!await fs2.pathExists(schemaPath)) {
|
|
103
|
+
spinner.warn("No schema file found");
|
|
104
|
+
console.log(chalk2.dim(" Create tether/schema.ts to define your database schema\n"));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const schemaSource = await fs2.readFile(schemaPath, "utf-8");
|
|
108
|
+
const tables = parseSchema(schemaSource);
|
|
109
|
+
spinner.text = `Found ${tables.length} table(s)`;
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
spinner.info("Dry run - would deploy schema:");
|
|
112
|
+
for (const table of tables) {
|
|
113
|
+
console.log(chalk2.dim(` - ${table.name}`));
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const sql = generateSchemaSQL(tables);
|
|
118
|
+
spinner.text = "Deploying schema...";
|
|
119
|
+
const response = await fetch(`${API_URL}/${projectId}/deploy/schema`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"Authorization": `Bearer ${token}`
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
sql,
|
|
127
|
+
tables: tables.map((t) => t.name)
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
|
132
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
133
|
+
}
|
|
134
|
+
spinner.succeed(`Schema deployed (${tables.length} table(s))`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
spinner.fail("Failed to deploy schema");
|
|
137
|
+
console.error(chalk2.red(error instanceof Error ? error.message : "Unknown error"));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function deployFunctionsToServer(projectId, token, dryRun) {
|
|
141
|
+
const spinner = ora("Reading functions...").start();
|
|
142
|
+
try {
|
|
143
|
+
const functionsDir = path2.resolve(process.cwd(), "tether", "functions");
|
|
144
|
+
if (!await fs2.pathExists(functionsDir)) {
|
|
145
|
+
spinner.warn("No functions directory found");
|
|
146
|
+
console.log(chalk2.dim(" Create tether/functions/ to define your API functions\n"));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const files = await fs2.readdir(functionsDir);
|
|
150
|
+
const tsFiles = files.filter((f) => f.endsWith(".ts"));
|
|
151
|
+
if (tsFiles.length === 0) {
|
|
152
|
+
spinner.info("No function files found");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const functions = [];
|
|
156
|
+
for (const file of tsFiles) {
|
|
157
|
+
const filePath = path2.join(functionsDir, file);
|
|
158
|
+
const source = await fs2.readFile(filePath, "utf-8");
|
|
159
|
+
const moduleName = file.replace(".ts", "");
|
|
160
|
+
const parsedFunctions = parseFunctions(moduleName, source);
|
|
161
|
+
functions.push(...parsedFunctions);
|
|
162
|
+
}
|
|
163
|
+
spinner.text = `Found ${functions.length} function(s)`;
|
|
164
|
+
if (dryRun) {
|
|
165
|
+
spinner.info("Dry run - would deploy functions:");
|
|
166
|
+
for (const fn of functions) {
|
|
167
|
+
const icon = fn.type === "query" ? "\u{1F50D}" : fn.type === "mutation" ? "\u270F\uFE0F" : "\u26A1";
|
|
168
|
+
console.log(chalk2.dim(` ${icon} ${fn.name} (${fn.type})`));
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
spinner.text = "Deploying functions...";
|
|
173
|
+
const response = await fetch(`${API_URL}/${projectId}/deploy/functions`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"Authorization": `Bearer ${token}`
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
functions: functions.map((fn) => ({
|
|
181
|
+
name: fn.name,
|
|
182
|
+
type: fn.type,
|
|
183
|
+
source: fn.source
|
|
184
|
+
}))
|
|
185
|
+
})
|
|
186
|
+
});
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
|
189
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
190
|
+
}
|
|
191
|
+
spinner.succeed(`Functions deployed (${functions.length} function(s))`);
|
|
192
|
+
const queries = functions.filter((f) => f.type === "query");
|
|
193
|
+
const mutations = functions.filter((f) => f.type === "mutation");
|
|
194
|
+
const actions = functions.filter((f) => f.type === "action");
|
|
195
|
+
if (queries.length > 0) {
|
|
196
|
+
console.log(chalk2.dim(`
|
|
197
|
+
Queries:`));
|
|
198
|
+
for (const fn of queries) {
|
|
199
|
+
console.log(chalk2.dim(` - ${fn.name}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (mutations.length > 0) {
|
|
203
|
+
console.log(chalk2.dim(`
|
|
204
|
+
Mutations:`));
|
|
205
|
+
for (const fn of mutations) {
|
|
206
|
+
console.log(chalk2.dim(` - ${fn.name}`));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (actions.length > 0) {
|
|
210
|
+
console.log(chalk2.dim(`
|
|
211
|
+
Actions:`));
|
|
212
|
+
for (const fn of actions) {
|
|
213
|
+
console.log(chalk2.dim(` - ${fn.name}`));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
spinner.fail("Failed to deploy functions");
|
|
218
|
+
console.error(chalk2.red(error instanceof Error ? error.message : "Unknown error"));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function parseSchema(source) {
|
|
222
|
+
const tables = [];
|
|
223
|
+
const schemaMatch = source.match(/defineSchema\s*\(\s*\{([\s\S]*)\}\s*\)/);
|
|
224
|
+
if (!schemaMatch) return tables;
|
|
225
|
+
const schemaContent = schemaMatch[1];
|
|
226
|
+
const tableRegex = /(\w+)\s*:\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
227
|
+
let match;
|
|
228
|
+
while ((match = tableRegex.exec(schemaContent)) !== null) {
|
|
229
|
+
const tableName = match[1];
|
|
230
|
+
const columnsContent = match[2];
|
|
231
|
+
const columns = {};
|
|
232
|
+
const columnRegex = /(\w+)\s*:\s*(\w+)\s*\(\s*\)([^,\n]*)/g;
|
|
233
|
+
let colMatch;
|
|
234
|
+
while ((colMatch = columnRegex.exec(columnsContent)) !== null) {
|
|
235
|
+
const colName = colMatch[1];
|
|
236
|
+
const colType = colMatch[2];
|
|
237
|
+
const modifiers = colMatch[3];
|
|
238
|
+
columns[colName] = {
|
|
239
|
+
type: colType,
|
|
240
|
+
primaryKey: modifiers.includes(".primaryKey()"),
|
|
241
|
+
notNull: modifiers.includes(".notNull()"),
|
|
242
|
+
unique: modifiers.includes(".unique()"),
|
|
243
|
+
hasDefault: modifiers.includes(".default("),
|
|
244
|
+
references: modifiers.match(/\.references\s*\(\s*['"]([^'"]+)['"]\s*\)/)?.[1]
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
tables.push({ name: tableName, columns });
|
|
248
|
+
}
|
|
249
|
+
return tables;
|
|
250
|
+
}
|
|
251
|
+
function generateSchemaSQL(tables) {
|
|
252
|
+
const statements = [];
|
|
253
|
+
for (const table of tables) {
|
|
254
|
+
const columnDefs = [];
|
|
255
|
+
for (const [colName, colDef] of Object.entries(table.columns)) {
|
|
256
|
+
const def = colDef;
|
|
257
|
+
let sqlType = "TEXT";
|
|
258
|
+
switch (def.type) {
|
|
259
|
+
case "text":
|
|
260
|
+
sqlType = "TEXT";
|
|
261
|
+
break;
|
|
262
|
+
case "integer":
|
|
263
|
+
sqlType = "INTEGER";
|
|
264
|
+
break;
|
|
265
|
+
case "real":
|
|
266
|
+
sqlType = "REAL";
|
|
267
|
+
break;
|
|
268
|
+
case "blob":
|
|
269
|
+
sqlType = "BLOB";
|
|
270
|
+
break;
|
|
271
|
+
case "timestamp":
|
|
272
|
+
sqlType = "TEXT";
|
|
273
|
+
break;
|
|
274
|
+
case "boolean":
|
|
275
|
+
sqlType = "INTEGER";
|
|
276
|
+
break;
|
|
277
|
+
case "json":
|
|
278
|
+
sqlType = "TEXT";
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
let colSql = `${colName} ${sqlType}`;
|
|
282
|
+
if (def.primaryKey) colSql += " PRIMARY KEY";
|
|
283
|
+
if (def.notNull) colSql += " NOT NULL";
|
|
284
|
+
if (def.unique) colSql += " UNIQUE";
|
|
285
|
+
if (def.hasDefault && def.type === "timestamp") {
|
|
286
|
+
colSql += " DEFAULT (datetime('now'))";
|
|
287
|
+
}
|
|
288
|
+
if (def.references) {
|
|
289
|
+
const [refTable, refCol] = def.references.split(".");
|
|
290
|
+
colSql += ` REFERENCES ${refTable}(${refCol})`;
|
|
291
|
+
}
|
|
292
|
+
columnDefs.push(colSql);
|
|
293
|
+
}
|
|
294
|
+
statements.push(
|
|
295
|
+
`CREATE TABLE IF NOT EXISTS ${table.name} (
|
|
296
|
+
${columnDefs.join(",\n ")}
|
|
297
|
+
);`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return statements.join("\n\n");
|
|
301
|
+
}
|
|
302
|
+
function parseFunctions(moduleName, source) {
|
|
303
|
+
const functions = [];
|
|
304
|
+
const exportRegex = /export\s+const\s+(\w+)\s*=\s*(query|mutation|action)\s*\(/g;
|
|
305
|
+
let match;
|
|
306
|
+
while ((match = exportRegex.exec(source)) !== null) {
|
|
307
|
+
const fnName = match[1];
|
|
308
|
+
const fnType = match[2];
|
|
309
|
+
const startIndex = match.index + match[0].length;
|
|
310
|
+
let depth = 1;
|
|
311
|
+
let endIndex = startIndex;
|
|
312
|
+
for (let i = startIndex; i < source.length && depth > 0; i++) {
|
|
313
|
+
const char = source[i];
|
|
314
|
+
if (char === "(" || char === "{") {
|
|
315
|
+
depth++;
|
|
316
|
+
} else if (char === ")" || char === "}") {
|
|
317
|
+
depth--;
|
|
318
|
+
}
|
|
319
|
+
endIndex = i;
|
|
320
|
+
}
|
|
321
|
+
const fnBody = source.slice(startIndex, endIndex);
|
|
322
|
+
functions.push({
|
|
323
|
+
name: `${moduleName}.${fnName}`,
|
|
324
|
+
type: fnType,
|
|
325
|
+
file: `${moduleName}.ts`,
|
|
326
|
+
source: `${fnType}(${fnBody})`
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return functions;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/commands/init.ts
|
|
333
|
+
var isDev2 = process.env.NODE_ENV === "development" || process.env.TETHER_DEV === "true";
|
|
334
|
+
var API_URL2 = isDev2 ? "http://localhost:3001/api/v1" : "https://tthr.io/api/v1";
|
|
335
|
+
async function getLatestVersion(packageName) {
|
|
336
|
+
try {
|
|
337
|
+
const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
338
|
+
if (!response.ok) {
|
|
339
|
+
return "latest";
|
|
340
|
+
}
|
|
341
|
+
const data = await response.json();
|
|
342
|
+
return `^${data.version}`;
|
|
343
|
+
} catch {
|
|
344
|
+
return "latest";
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function initCommand(name, options) {
|
|
348
|
+
const credentials = await requireAuth();
|
|
349
|
+
console.log(chalk3.bold("\n\u26A1 Create a new Tether project\n"));
|
|
350
|
+
let projectName = name;
|
|
351
|
+
if (!projectName) {
|
|
352
|
+
const response = await prompts({
|
|
353
|
+
type: "text",
|
|
354
|
+
name: "name",
|
|
355
|
+
message: "Project name:",
|
|
356
|
+
initial: "my-tether-app"
|
|
357
|
+
});
|
|
358
|
+
projectName = response.name;
|
|
359
|
+
if (!projectName) {
|
|
360
|
+
console.log(chalk3.red("Project name is required"));
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
let template = options.template;
|
|
365
|
+
if (options.template === "vue") {
|
|
366
|
+
const response = await prompts({
|
|
367
|
+
type: "select",
|
|
368
|
+
name: "template",
|
|
369
|
+
message: "Select a framework:",
|
|
370
|
+
choices: [
|
|
371
|
+
{ title: "Nuxt (Vue)", value: "nuxt", description: "Full-stack Vue framework with SSR" },
|
|
372
|
+
{ title: "Next.js (React)", value: "next", description: "Full-stack React framework with SSR" },
|
|
373
|
+
{ title: "SvelteKit (Svelte)", value: "sveltekit", description: "Full-stack Svelte framework with SSR" },
|
|
374
|
+
{ title: "Vanilla TypeScript", value: "vanilla", description: "Minimal setup for scripts and APIs" }
|
|
375
|
+
],
|
|
376
|
+
initial: 0
|
|
377
|
+
});
|
|
378
|
+
template = response.template || "nuxt";
|
|
379
|
+
}
|
|
380
|
+
const projectPath = path3.resolve(process.cwd(), projectName);
|
|
381
|
+
if (await fs3.pathExists(projectPath)) {
|
|
382
|
+
const { overwrite } = await prompts({
|
|
383
|
+
type: "confirm",
|
|
384
|
+
name: "overwrite",
|
|
385
|
+
message: `Directory ${projectName} already exists. Overwrite?`,
|
|
386
|
+
initial: false
|
|
387
|
+
});
|
|
388
|
+
if (!overwrite) {
|
|
389
|
+
console.log(chalk3.yellow("Cancelled"));
|
|
390
|
+
process.exit(0);
|
|
391
|
+
}
|
|
392
|
+
await fs3.remove(projectPath);
|
|
393
|
+
}
|
|
394
|
+
const spinner = ora2("Creating project on Tether...").start();
|
|
395
|
+
let projectId;
|
|
396
|
+
let apiKey;
|
|
397
|
+
try {
|
|
398
|
+
const response = await fetch(`${API_URL2}/projects`, {
|
|
399
|
+
method: "POST",
|
|
400
|
+
headers: {
|
|
401
|
+
"Content-Type": "application/json",
|
|
402
|
+
"Authorization": `Bearer ${credentials.accessToken}`
|
|
403
|
+
},
|
|
404
|
+
body: JSON.stringify({ name: projectName, source: "cli" })
|
|
405
|
+
});
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
const error = await response.json().catch(() => ({}));
|
|
408
|
+
throw new Error(error.error || "Failed to create project on server");
|
|
409
|
+
}
|
|
410
|
+
const data = await response.json();
|
|
411
|
+
projectId = data.project.id;
|
|
412
|
+
apiKey = data.apiKey;
|
|
413
|
+
spinner.text = `Scaffolding ${template} project...`;
|
|
414
|
+
if (template === "nuxt") {
|
|
415
|
+
await scaffoldNuxtProject(projectName, projectPath, spinner);
|
|
416
|
+
} else if (template === "next") {
|
|
417
|
+
await scaffoldNextProject(projectName, projectPath, spinner);
|
|
418
|
+
} else if (template === "sveltekit") {
|
|
419
|
+
await scaffoldSvelteKitProject(projectName, projectPath, spinner);
|
|
420
|
+
} else {
|
|
421
|
+
await scaffoldVanillaProject(projectName, projectPath, spinner);
|
|
422
|
+
}
|
|
423
|
+
spinner.text = "Adding Tether configuration...";
|
|
424
|
+
await addTetherFiles(projectPath, projectId, apiKey, template);
|
|
425
|
+
spinner.text = "Configuring framework integration...";
|
|
426
|
+
await configureFramework(projectPath, template);
|
|
427
|
+
spinner.text = "Installing Tether packages...";
|
|
428
|
+
await installTetherPackages(projectPath, template);
|
|
429
|
+
spinner.text = "Creating demo page...";
|
|
430
|
+
await createDemoPage(projectPath, template);
|
|
431
|
+
spinner.succeed("Project created successfully!");
|
|
432
|
+
console.log("");
|
|
433
|
+
const originalCwd = process.cwd();
|
|
434
|
+
process.chdir(projectPath);
|
|
435
|
+
try {
|
|
436
|
+
await deployCommand({});
|
|
437
|
+
} finally {
|
|
438
|
+
process.chdir(originalCwd);
|
|
439
|
+
}
|
|
440
|
+
console.log("\n" + chalk3.green("\u2713") + " Project created successfully!\n");
|
|
441
|
+
console.log("Next steps:\n");
|
|
442
|
+
console.log(chalk3.cyan(` cd ${projectName}`));
|
|
443
|
+
if (template === "vanilla") {
|
|
444
|
+
console.log(chalk3.cyan(" npm install"));
|
|
445
|
+
}
|
|
446
|
+
console.log(chalk3.cyan(" npm run dev"));
|
|
447
|
+
console.log("\n" + chalk3.dim("For more information, visit https://tthr.io/docs\n"));
|
|
448
|
+
} catch (error) {
|
|
449
|
+
spinner.fail("Failed to create project");
|
|
450
|
+
console.error(chalk3.red(error instanceof Error ? error.message : "Unknown error"));
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function scaffoldNuxtProject(projectName, projectPath, spinner) {
|
|
455
|
+
const parentDir = path3.dirname(projectPath);
|
|
456
|
+
spinner.stop();
|
|
457
|
+
console.log(chalk3.dim("\nRunning nuxi init...\n"));
|
|
458
|
+
try {
|
|
459
|
+
execSync(`npx nuxi@latest init ${projectName} --packageManager npm --gitInit false`, {
|
|
460
|
+
cwd: parentDir,
|
|
461
|
+
stdio: "inherit"
|
|
462
|
+
});
|
|
463
|
+
if (!await fs3.pathExists(path3.join(projectPath, "package.json"))) {
|
|
464
|
+
throw new Error("Nuxt project was not created successfully - package.json missing");
|
|
465
|
+
}
|
|
466
|
+
spinner.start();
|
|
467
|
+
} catch (error) {
|
|
468
|
+
spinner.start();
|
|
469
|
+
if (error instanceof Error && error.message.includes("package.json missing")) {
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
throw new Error("Failed to create Nuxt project. Make sure you have npx installed.");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function scaffoldNextProject(projectName, projectPath, spinner) {
|
|
476
|
+
const parentDir = path3.dirname(projectPath);
|
|
477
|
+
spinner.stop();
|
|
478
|
+
console.log(chalk3.dim("\nRunning create-next-app...\n"));
|
|
479
|
+
try {
|
|
480
|
+
execSync(`npx create-next-app@latest ${projectName} --typescript --eslint --tailwind --src-dir --app --import-alias "@/*" --use-npm`, {
|
|
481
|
+
cwd: parentDir,
|
|
482
|
+
stdio: "inherit"
|
|
483
|
+
});
|
|
484
|
+
if (!await fs3.pathExists(path3.join(projectPath, "package.json"))) {
|
|
485
|
+
throw new Error("Next.js project was not created successfully - package.json missing");
|
|
486
|
+
}
|
|
487
|
+
spinner.start();
|
|
488
|
+
} catch (error) {
|
|
489
|
+
spinner.start();
|
|
490
|
+
if (error instanceof Error && error.message.includes("package.json missing")) {
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
throw new Error("Failed to create Next.js project. Make sure you have npx installed.");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function scaffoldSvelteKitProject(projectName, projectPath, spinner) {
|
|
497
|
+
const parentDir = path3.dirname(projectPath);
|
|
498
|
+
spinner.stop();
|
|
499
|
+
console.log(chalk3.dim("\nRunning sv create...\n"));
|
|
500
|
+
try {
|
|
501
|
+
execSync(`npx sv create ${projectName} --template minimal --types ts --no-add-ons --no-install`, {
|
|
502
|
+
cwd: parentDir,
|
|
503
|
+
stdio: "inherit"
|
|
504
|
+
});
|
|
505
|
+
if (!await fs3.pathExists(path3.join(projectPath, "package.json"))) {
|
|
506
|
+
throw new Error("SvelteKit project was not created successfully - package.json missing");
|
|
507
|
+
}
|
|
508
|
+
spinner.start();
|
|
509
|
+
} catch (error) {
|
|
510
|
+
spinner.start();
|
|
511
|
+
if (error instanceof Error && error.message.includes("package.json missing")) {
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
throw new Error("Failed to create SvelteKit project. Make sure you have npx installed.");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function scaffoldVanillaProject(projectName, projectPath, spinner) {
|
|
518
|
+
spinner.text = "Creating vanilla TypeScript project...";
|
|
519
|
+
await fs3.ensureDir(projectPath);
|
|
520
|
+
await fs3.ensureDir(path3.join(projectPath, "src"));
|
|
521
|
+
const packageJson = {
|
|
522
|
+
name: projectName,
|
|
523
|
+
version: "0.0.1",
|
|
524
|
+
private: true,
|
|
525
|
+
type: "module",
|
|
526
|
+
scripts: {
|
|
527
|
+
"dev": "tsx watch src/index.ts",
|
|
528
|
+
"build": "tsc",
|
|
529
|
+
"start": "node dist/index.js"
|
|
530
|
+
},
|
|
531
|
+
devDependencies: {
|
|
532
|
+
"typescript": "latest",
|
|
533
|
+
"tsx": "latest",
|
|
534
|
+
"@types/node": "latest"
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
await fs3.writeJSON(path3.join(projectPath, "package.json"), packageJson, { spaces: 2 });
|
|
538
|
+
await fs3.writeJSON(
|
|
539
|
+
path3.join(projectPath, "tsconfig.json"),
|
|
540
|
+
{
|
|
541
|
+
compilerOptions: {
|
|
542
|
+
target: "ES2022",
|
|
543
|
+
module: "ESNext",
|
|
544
|
+
moduleResolution: "bundler",
|
|
545
|
+
strict: true,
|
|
546
|
+
esModuleInterop: true,
|
|
547
|
+
skipLibCheck: true,
|
|
548
|
+
forceConsistentCasingInFileNames: true,
|
|
549
|
+
outDir: "./dist",
|
|
550
|
+
rootDir: "./src",
|
|
551
|
+
paths: {
|
|
552
|
+
"@/*": ["./src/*"]
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
include: ["src/**/*.ts"],
|
|
556
|
+
exclude: ["node_modules", "dist"]
|
|
557
|
+
},
|
|
558
|
+
{ spaces: 2 }
|
|
559
|
+
);
|
|
560
|
+
await fs3.writeFile(
|
|
561
|
+
path3.join(projectPath, "src", "index.ts"),
|
|
562
|
+
`import { createClient } from '@tthr/client';
|
|
563
|
+
|
|
564
|
+
const tether = createClient({
|
|
565
|
+
projectId: process.env.TETHER_PROJECT_ID!,
|
|
566
|
+
url: process.env.TETHER_URL || 'https://tthr.io',
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
async function main() {
|
|
570
|
+
console.log('Hello from Tether!');
|
|
571
|
+
|
|
572
|
+
// Example: List all posts
|
|
573
|
+
// const posts = await tether.query('posts.list', { limit: 10 });
|
|
574
|
+
// console.log(posts);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
main().catch(console.error);
|
|
578
|
+
`
|
|
579
|
+
);
|
|
580
|
+
await fs3.writeFile(
|
|
581
|
+
path3.join(projectPath, ".gitignore"),
|
|
582
|
+
`node_modules/
|
|
583
|
+
dist/
|
|
584
|
+
.env
|
|
585
|
+
.env.local
|
|
586
|
+
.DS_Store
|
|
587
|
+
`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
async function addTetherFiles(projectPath, projectId, apiKey, template) {
|
|
591
|
+
await fs3.ensureDir(path3.join(projectPath, "tether"));
|
|
592
|
+
await fs3.ensureDir(path3.join(projectPath, "tether", "functions"));
|
|
593
|
+
const configPackage = template === "nuxt" ? "@tthr/vue" : template === "next" ? "@tthr/react" : template === "sveltekit" ? "@tthr/svelte" : "@tthr/client";
|
|
594
|
+
await fs3.writeFile(
|
|
595
|
+
path3.join(projectPath, "tether.config.ts"),
|
|
596
|
+
`import { defineConfig } from '${configPackage}';
|
|
597
|
+
|
|
598
|
+
export default defineConfig({
|
|
599
|
+
// Project configuration
|
|
600
|
+
projectId: process.env.TETHER_PROJECT_ID,
|
|
601
|
+
|
|
602
|
+
// API endpoint (defaults to Tether Cloud)
|
|
603
|
+
url: process.env.TETHER_URL || 'https://tthr.io',
|
|
604
|
+
|
|
605
|
+
// Schema file location
|
|
606
|
+
schema: './tether/schema.ts',
|
|
607
|
+
|
|
608
|
+
// Functions directory
|
|
609
|
+
functions: './tether/functions',
|
|
610
|
+
|
|
611
|
+
// Generated types output
|
|
612
|
+
output: './_generated',
|
|
613
|
+
});
|
|
614
|
+
`
|
|
615
|
+
);
|
|
616
|
+
await fs3.writeFile(
|
|
617
|
+
path3.join(projectPath, "tether", "schema.ts"),
|
|
618
|
+
`import { defineSchema, text, integer, timestamp } from '@tthr/schema';
|
|
619
|
+
|
|
620
|
+
export default defineSchema({
|
|
621
|
+
// Example: posts table
|
|
622
|
+
posts: {
|
|
623
|
+
id: text().primaryKey(),
|
|
624
|
+
title: text().notNull(),
|
|
625
|
+
content: text(),
|
|
626
|
+
authorId: text().notNull(),
|
|
627
|
+
createdAt: timestamp().notNull().default('now'),
|
|
628
|
+
updatedAt: timestamp().notNull().default('now'),
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
// Example: comments table
|
|
632
|
+
comments: {
|
|
633
|
+
id: text().primaryKey(),
|
|
634
|
+
postId: text().notNull().references('posts.id'),
|
|
635
|
+
content: text().notNull(),
|
|
636
|
+
authorId: text().notNull(),
|
|
637
|
+
createdAt: timestamp().notNull().default('now'),
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
`
|
|
641
|
+
);
|
|
642
|
+
await fs3.writeFile(
|
|
643
|
+
path3.join(projectPath, "tether", "functions", "posts.ts"),
|
|
644
|
+
`import { query, mutation, z } from '@tthr/server';
|
|
645
|
+
|
|
646
|
+
// List all posts
|
|
647
|
+
export const list = query({
|
|
648
|
+
args: z.object({
|
|
649
|
+
limit: z.number().optional().default(20),
|
|
650
|
+
}),
|
|
651
|
+
handler: async ({ args, db }) => {
|
|
652
|
+
return db.posts.findMany({
|
|
653
|
+
orderBy: { createdAt: 'desc' },
|
|
654
|
+
limit: args.limit,
|
|
655
|
+
});
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Get a single post
|
|
660
|
+
export const get = query({
|
|
661
|
+
args: z.object({
|
|
662
|
+
id: z.string(),
|
|
663
|
+
}),
|
|
664
|
+
handler: async ({ args, db }) => {
|
|
665
|
+
return db.posts.findUnique({
|
|
666
|
+
where: { id: args.id },
|
|
667
|
+
});
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Create a new post
|
|
672
|
+
export const create = mutation({
|
|
673
|
+
args: z.object({
|
|
674
|
+
title: z.string().min(1),
|
|
675
|
+
content: z.string().optional(),
|
|
676
|
+
}),
|
|
677
|
+
handler: async ({ args, ctx, db }) => {
|
|
678
|
+
const id = crypto.randomUUID();
|
|
679
|
+
const now = new Date().toISOString();
|
|
680
|
+
|
|
681
|
+
await db.posts.create({
|
|
682
|
+
data: {
|
|
683
|
+
id,
|
|
684
|
+
title: args.title,
|
|
685
|
+
content: args.content ?? '',
|
|
686
|
+
authorId: ctx.userId,
|
|
687
|
+
createdAt: now,
|
|
688
|
+
updatedAt: now,
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
return { id };
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Update a post
|
|
697
|
+
export const update = mutation({
|
|
698
|
+
args: z.object({
|
|
699
|
+
id: z.string(),
|
|
700
|
+
title: z.string().min(1).optional(),
|
|
701
|
+
content: z.string().optional(),
|
|
702
|
+
}),
|
|
703
|
+
handler: async ({ args, db }) => {
|
|
704
|
+
await db.posts.update({
|
|
705
|
+
where: { id: args.id },
|
|
706
|
+
data: {
|
|
707
|
+
...(args.title && { title: args.title }),
|
|
708
|
+
...(args.content !== undefined && { content: args.content }),
|
|
709
|
+
updatedAt: new Date().toISOString(),
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Delete a post
|
|
716
|
+
export const remove = mutation({
|
|
717
|
+
args: z.object({
|
|
718
|
+
id: z.string(),
|
|
719
|
+
}),
|
|
720
|
+
handler: async ({ args, db }) => {
|
|
721
|
+
await db.posts.delete({
|
|
722
|
+
where: { id: args.id },
|
|
723
|
+
});
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
`
|
|
727
|
+
);
|
|
728
|
+
const envContent = `# Tether Configuration
|
|
729
|
+
TETHER_PROJECT_ID=${projectId}
|
|
730
|
+
TETHER_URL=${isDev2 ? "http://localhost:3001" : "https://tthr.io"}
|
|
731
|
+
TETHER_API_KEY=${apiKey}
|
|
732
|
+
`;
|
|
733
|
+
const envPath = path3.join(projectPath, ".env");
|
|
734
|
+
if (await fs3.pathExists(envPath)) {
|
|
735
|
+
const existing = await fs3.readFile(envPath, "utf-8");
|
|
736
|
+
await fs3.writeFile(envPath, existing + "\n" + envContent);
|
|
737
|
+
} else {
|
|
738
|
+
await fs3.writeFile(envPath, envContent);
|
|
739
|
+
}
|
|
740
|
+
const gitignorePath = path3.join(projectPath, ".gitignore");
|
|
741
|
+
const tetherGitignore = `
|
|
742
|
+
# Tether
|
|
743
|
+
_generated/
|
|
744
|
+
.env
|
|
745
|
+
.env.local
|
|
746
|
+
`;
|
|
747
|
+
if (await fs3.pathExists(gitignorePath)) {
|
|
748
|
+
const existing = await fs3.readFile(gitignorePath, "utf-8");
|
|
749
|
+
if (!existing.includes("_generated/")) {
|
|
750
|
+
await fs3.writeFile(gitignorePath, existing + tetherGitignore);
|
|
751
|
+
}
|
|
752
|
+
} else {
|
|
753
|
+
await fs3.writeFile(gitignorePath, tetherGitignore.trim());
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async function configureFramework(projectPath, template) {
|
|
757
|
+
if (template === "nuxt") {
|
|
758
|
+
await configureNuxt(projectPath);
|
|
759
|
+
} else if (template === "next") {
|
|
760
|
+
await configureNext(projectPath);
|
|
761
|
+
} else if (template === "sveltekit") {
|
|
762
|
+
await configureSvelteKit(projectPath);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async function configureNuxt(projectPath) {
|
|
766
|
+
const configPath = path3.join(projectPath, "nuxt.config.ts");
|
|
767
|
+
if (!await fs3.pathExists(configPath)) {
|
|
768
|
+
await fs3.writeFile(
|
|
769
|
+
configPath,
|
|
770
|
+
`// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
771
|
+
export default defineNuxtConfig({
|
|
772
|
+
compatibilityDate: '2024-11-01',
|
|
773
|
+
devtools: { enabled: true },
|
|
774
|
+
|
|
775
|
+
modules: ['@tthr/vue/nuxt'],
|
|
776
|
+
|
|
777
|
+
tether: {
|
|
778
|
+
projectId: process.env.TETHER_PROJECT_ID,
|
|
779
|
+
url: process.env.TETHER_URL || 'https://tthr.io',
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
`
|
|
783
|
+
);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
let config = await fs3.readFile(configPath, "utf-8");
|
|
787
|
+
if (config.includes("modules:")) {
|
|
788
|
+
config = config.replace(
|
|
789
|
+
/modules:\s*\[/,
|
|
790
|
+
"modules: ['@tthr/vue/nuxt', "
|
|
791
|
+
);
|
|
792
|
+
} else {
|
|
793
|
+
config = config.replace(
|
|
794
|
+
/defineNuxtConfig\(\{/,
|
|
795
|
+
`defineNuxtConfig({
|
|
796
|
+
modules: ['@tthr/vue/nuxt'],
|
|
797
|
+
`
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
if (!config.includes("tether:")) {
|
|
801
|
+
config = config.replace(
|
|
802
|
+
/(\}|\]|'|"|true|false|\d)\s*\n(\s*}\);?\s*)$/,
|
|
803
|
+
`$1,
|
|
804
|
+
|
|
805
|
+
tether: {
|
|
806
|
+
projectId: process.env.TETHER_PROJECT_ID,
|
|
807
|
+
url: process.env.TETHER_URL || 'https://tthr.io',
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
await fs3.writeFile(configPath, config);
|
|
814
|
+
}
|
|
815
|
+
async function configureNext(projectPath) {
|
|
816
|
+
const providersPath = path3.join(projectPath, "src", "app", "providers.tsx");
|
|
817
|
+
await fs3.writeFile(
|
|
818
|
+
providersPath,
|
|
819
|
+
`'use client';
|
|
820
|
+
|
|
821
|
+
import { TetherProvider } from '@tthr/react';
|
|
822
|
+
|
|
823
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
824
|
+
return (
|
|
825
|
+
<TetherProvider
|
|
826
|
+
projectId={process.env.NEXT_PUBLIC_TETHER_PROJECT_ID!}
|
|
827
|
+
url={process.env.NEXT_PUBLIC_TETHER_URL || 'https://tthr.io'}
|
|
828
|
+
>
|
|
829
|
+
{children}
|
|
830
|
+
</TetherProvider>
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
`
|
|
834
|
+
);
|
|
835
|
+
const layoutPath = path3.join(projectPath, "src", "app", "layout.tsx");
|
|
836
|
+
if (await fs3.pathExists(layoutPath)) {
|
|
837
|
+
let layout = await fs3.readFile(layoutPath, "utf-8");
|
|
838
|
+
if (!layout.includes("import { Providers }")) {
|
|
839
|
+
layout = layout.replace(
|
|
840
|
+
/^(import.*\n)+/m,
|
|
841
|
+
(match) => match + "import { Providers } from './providers';\n"
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
if (!layout.includes("<Providers>")) {
|
|
845
|
+
layout = layout.replace(
|
|
846
|
+
/(<body[^>]*>)([\s\S]*?)(<\/body>)/,
|
|
847
|
+
"$1<Providers>$2</Providers>$3"
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
await fs3.writeFile(layoutPath, layout);
|
|
851
|
+
}
|
|
852
|
+
const envLocalPath = path3.join(projectPath, ".env.local");
|
|
853
|
+
const nextEnvContent = `# Tether Configuration (client-side)
|
|
854
|
+
NEXT_PUBLIC_TETHER_PROJECT_ID=\${TETHER_PROJECT_ID}
|
|
855
|
+
NEXT_PUBLIC_TETHER_URL=\${TETHER_URL}
|
|
856
|
+
`;
|
|
857
|
+
if (await fs3.pathExists(envLocalPath)) {
|
|
858
|
+
const existing = await fs3.readFile(envLocalPath, "utf-8");
|
|
859
|
+
await fs3.writeFile(envLocalPath, existing + "\n" + nextEnvContent);
|
|
860
|
+
} else {
|
|
861
|
+
await fs3.writeFile(envLocalPath, nextEnvContent);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
async function configureSvelteKit(projectPath) {
|
|
865
|
+
const libPath = path3.join(projectPath, "src", "lib");
|
|
866
|
+
await fs3.ensureDir(libPath);
|
|
867
|
+
await fs3.writeFile(
|
|
868
|
+
path3.join(libPath, "tether.ts"),
|
|
869
|
+
`import { createClient } from '@tthr/svelte';
|
|
870
|
+
import { PUBLIC_TETHER_PROJECT_ID, PUBLIC_TETHER_URL } from '$env/static/public';
|
|
871
|
+
|
|
872
|
+
export const tether = createClient({
|
|
873
|
+
projectId: PUBLIC_TETHER_PROJECT_ID,
|
|
874
|
+
url: PUBLIC_TETHER_URL || 'https://tthr.io',
|
|
875
|
+
});
|
|
876
|
+
`
|
|
877
|
+
);
|
|
878
|
+
const envPath = path3.join(projectPath, ".env");
|
|
879
|
+
const svelteEnvContent = `# Tether Configuration (public)
|
|
880
|
+
PUBLIC_TETHER_PROJECT_ID=\${TETHER_PROJECT_ID}
|
|
881
|
+
PUBLIC_TETHER_URL=\${TETHER_URL}
|
|
882
|
+
`;
|
|
883
|
+
if (await fs3.pathExists(envPath)) {
|
|
884
|
+
const existing = await fs3.readFile(envPath, "utf-8");
|
|
885
|
+
if (!existing.includes("PUBLIC_TETHER_")) {
|
|
886
|
+
await fs3.writeFile(envPath, existing + "\n" + svelteEnvContent);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async function installTetherPackages(projectPath, template) {
|
|
891
|
+
const [
|
|
892
|
+
tthrClientVersion,
|
|
893
|
+
tthrSchemaVersion,
|
|
894
|
+
tthrServerVersion,
|
|
895
|
+
tthrCliVersion
|
|
896
|
+
] = await Promise.all([
|
|
897
|
+
getLatestVersion("@tthr/client"),
|
|
898
|
+
getLatestVersion("@tthr/schema"),
|
|
899
|
+
getLatestVersion("@tthr/server"),
|
|
900
|
+
getLatestVersion("@tthr/cli")
|
|
901
|
+
]);
|
|
902
|
+
const packages = [
|
|
903
|
+
`@tthr/client@${tthrClientVersion}`,
|
|
904
|
+
`@tthr/schema@${tthrSchemaVersion}`,
|
|
905
|
+
`@tthr/server@${tthrServerVersion}`
|
|
906
|
+
];
|
|
907
|
+
const devPackages = [
|
|
908
|
+
`@tthr/cli@${tthrCliVersion}`
|
|
909
|
+
];
|
|
910
|
+
if (template === "nuxt") {
|
|
911
|
+
const tthrVueVersion = await getLatestVersion("@tthr/vue");
|
|
912
|
+
packages.push(`@tthr/vue@${tthrVueVersion}`);
|
|
913
|
+
} else if (template === "next") {
|
|
914
|
+
const tthrReactVersion = await getLatestVersion("@tthr/react");
|
|
915
|
+
packages.push(`@tthr/react@${tthrReactVersion}`);
|
|
916
|
+
} else if (template === "sveltekit") {
|
|
917
|
+
const tthrSvelteVersion = await getLatestVersion("@tthr/svelte");
|
|
918
|
+
packages.push(`@tthr/svelte@${tthrSvelteVersion}`);
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
execSync(`npm install ${packages.join(" ")}`, {
|
|
922
|
+
cwd: projectPath,
|
|
923
|
+
stdio: "pipe"
|
|
924
|
+
});
|
|
925
|
+
execSync(`npm install -D ${devPackages.join(" ")}`, {
|
|
926
|
+
cwd: projectPath,
|
|
927
|
+
stdio: "pipe"
|
|
928
|
+
});
|
|
929
|
+
} catch (error) {
|
|
930
|
+
console.warn(chalk3.yellow("\nNote: Some Tether packages may not be published yet."));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async function createDemoPage(projectPath, template) {
|
|
934
|
+
if (template === "nuxt") {
|
|
935
|
+
const nuxt4AppVuePath = path3.join(projectPath, "app", "app.vue");
|
|
936
|
+
const nuxt3AppVuePath = path3.join(projectPath, "app.vue");
|
|
937
|
+
const appVuePath = await fs3.pathExists(path3.join(projectPath, "app")) ? nuxt4AppVuePath : nuxt3AppVuePath;
|
|
938
|
+
await fs3.writeFile(
|
|
939
|
+
appVuePath,
|
|
940
|
+
`<template>
|
|
941
|
+
<TetherWelcome />
|
|
942
|
+
</template>
|
|
943
|
+
`
|
|
944
|
+
);
|
|
945
|
+
} else if (template === "next") {
|
|
946
|
+
const pagePath = path3.join(projectPath, "src", "app", "page.tsx");
|
|
947
|
+
await fs3.writeFile(
|
|
948
|
+
pagePath,
|
|
949
|
+
`'use client';
|
|
950
|
+
|
|
951
|
+
import { useState } from 'react';
|
|
952
|
+
import { useQuery, useMutation } from '@tthr/react';
|
|
953
|
+
|
|
954
|
+
interface Post {
|
|
955
|
+
id: string;
|
|
956
|
+
title: string;
|
|
957
|
+
createdAt: string;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export default function Home() {
|
|
961
|
+
const [newPostTitle, setNewPostTitle] = useState('');
|
|
962
|
+
const { data: posts, isLoading, refetch } = useQuery<Post[]>('posts.list');
|
|
963
|
+
const { mutate: createPost, isPending } = useMutation('posts.create');
|
|
964
|
+
const { mutate: deletePost } = useMutation('posts.remove');
|
|
965
|
+
|
|
966
|
+
const handleCreatePost = async (e: React.FormEvent) => {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
if (!newPostTitle.trim()) return;
|
|
969
|
+
|
|
970
|
+
await createPost({ title: newPostTitle.trim(), content: '' });
|
|
971
|
+
setNewPostTitle('');
|
|
972
|
+
refetch();
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const handleDeletePost = async (id: string) => {
|
|
976
|
+
await deletePost({ id });
|
|
977
|
+
refetch();
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
return (
|
|
981
|
+
<main className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 text-white p-8">
|
|
982
|
+
<div className="max-w-2xl mx-auto space-y-8">
|
|
983
|
+
<header className="text-center space-y-4">
|
|
984
|
+
<h1 className="text-4xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
|
|
985
|
+
Welcome to Tether
|
|
986
|
+
</h1>
|
|
987
|
+
<p className="text-gray-400">Realtime SQLite for modern apps</p>
|
|
988
|
+
</header>
|
|
989
|
+
|
|
990
|
+
<section className="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4">
|
|
991
|
+
<h2 className="text-xl font-semibold">Try it out</h2>
|
|
992
|
+
<p className="text-gray-400 text-sm">Create posts in realtime. Open in multiple tabs to see live updates!</p>
|
|
993
|
+
|
|
994
|
+
<form onSubmit={handleCreatePost} className="flex gap-3">
|
|
995
|
+
<input
|
|
996
|
+
type="text"
|
|
997
|
+
value={newPostTitle}
|
|
998
|
+
onChange={(e) => setNewPostTitle(e.target.value)}
|
|
999
|
+
placeholder="Enter a post title..."
|
|
1000
|
+
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
|
|
1001
|
+
disabled={isPending}
|
|
1002
|
+
/>
|
|
1003
|
+
<button
|
|
1004
|
+
type="submit"
|
|
1005
|
+
disabled={!newPostTitle.trim() || isPending}
|
|
1006
|
+
className="px-6 py-2 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90 transition"
|
|
1007
|
+
>
|
|
1008
|
+
{isPending ? 'Creating...' : 'Create Post'}
|
|
1009
|
+
</button>
|
|
1010
|
+
</form>
|
|
1011
|
+
|
|
1012
|
+
<div className="space-y-2 min-h-[100px]">
|
|
1013
|
+
{isLoading ? (
|
|
1014
|
+
<p className="text-gray-500 text-center py-8">Loading posts...</p>
|
|
1015
|
+
) : posts?.length ? (
|
|
1016
|
+
posts.map((post) => (
|
|
1017
|
+
<div
|
|
1018
|
+
key={post.id}
|
|
1019
|
+
className="flex items-center justify-between p-3 bg-black/20 border border-white/5 rounded-lg hover:bg-black/30 transition"
|
|
1020
|
+
>
|
|
1021
|
+
<div>
|
|
1022
|
+
<h3 className="font-medium">{post.title}</h3>
|
|
1023
|
+
<time className="text-xs text-gray-500">
|
|
1024
|
+
{new Date(post.createdAt).toLocaleDateString('en-GB', {
|
|
1025
|
+
day: 'numeric',
|
|
1026
|
+
month: 'short',
|
|
1027
|
+
year: 'numeric',
|
|
1028
|
+
})}
|
|
1029
|
+
</time>
|
|
1030
|
+
</div>
|
|
1031
|
+
<button
|
|
1032
|
+
onClick={() => handleDeletePost(post.id)}
|
|
1033
|
+
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-400/10 rounded transition"
|
|
1034
|
+
>
|
|
1035
|
+
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
|
1036
|
+
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
1037
|
+
</svg>
|
|
1038
|
+
</button>
|
|
1039
|
+
</div>
|
|
1040
|
+
))
|
|
1041
|
+
) : (
|
|
1042
|
+
<p className="text-gray-500 text-center py-8">No posts yet. Create your first one above!</p>
|
|
1043
|
+
)}
|
|
1044
|
+
</div>
|
|
1045
|
+
</section>
|
|
1046
|
+
|
|
1047
|
+
<footer className="text-center text-gray-600 text-sm border-t border-white/5 pt-6">
|
|
1048
|
+
Built with Tether by <a href="https://strands.gg" className="text-indigo-400 hover:underline">Strands Services</a>
|
|
1049
|
+
</footer>
|
|
1050
|
+
</div>
|
|
1051
|
+
</main>
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
`
|
|
1055
|
+
);
|
|
1056
|
+
} else if (template === "sveltekit") {
|
|
1057
|
+
const pagePath = path3.join(projectPath, "src", "routes", "+page.svelte");
|
|
1058
|
+
await fs3.writeFile(
|
|
1059
|
+
pagePath,
|
|
1060
|
+
`<script lang="ts">
|
|
1061
|
+
import { onMount } from 'svelte';
|
|
1062
|
+
import { tether } from '$lib/tether';
|
|
1063
|
+
|
|
1064
|
+
interface Post {
|
|
1065
|
+
id: string;
|
|
1066
|
+
title: string;
|
|
1067
|
+
createdAt: string;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
let posts: Post[] = [];
|
|
1071
|
+
let isLoading = true;
|
|
1072
|
+
let newPostTitle = '';
|
|
1073
|
+
let isPending = false;
|
|
1074
|
+
|
|
1075
|
+
onMount(async () => {
|
|
1076
|
+
await loadPosts();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
async function loadPosts() {
|
|
1080
|
+
isLoading = true;
|
|
1081
|
+
try {
|
|
1082
|
+
posts = await tether.query('posts.list');
|
|
1083
|
+
} catch (e) {
|
|
1084
|
+
console.error('Failed to load posts:', e);
|
|
1085
|
+
} finally {
|
|
1086
|
+
isLoading = false;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function handleCreatePost() {
|
|
1091
|
+
if (!newPostTitle.trim()) return;
|
|
1092
|
+
isPending = true;
|
|
1093
|
+
try {
|
|
1094
|
+
await tether.mutation('posts.create', { title: newPostTitle.trim(), content: '' });
|
|
1095
|
+
newPostTitle = '';
|
|
1096
|
+
await loadPosts();
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
console.error('Failed to create post:', e);
|
|
1099
|
+
} finally {
|
|
1100
|
+
isPending = false;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function handleDeletePost(id: string) {
|
|
1105
|
+
try {
|
|
1106
|
+
await tether.mutation('posts.remove', { id });
|
|
1107
|
+
await loadPosts();
|
|
1108
|
+
} catch (e) {
|
|
1109
|
+
console.error('Failed to delete post:', e);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function formatDate(dateString: string): string {
|
|
1114
|
+
return new Date(dateString).toLocaleDateString('en-GB', {
|
|
1115
|
+
day: 'numeric',
|
|
1116
|
+
month: 'short',
|
|
1117
|
+
year: 'numeric',
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
</script>
|
|
1121
|
+
|
|
1122
|
+
<main>
|
|
1123
|
+
<div class="container">
|
|
1124
|
+
<header>
|
|
1125
|
+
<h1>Welcome to Tether</h1>
|
|
1126
|
+
<p>Realtime SQLite for modern apps</p>
|
|
1127
|
+
</header>
|
|
1128
|
+
|
|
1129
|
+
<section class="demo">
|
|
1130
|
+
<h2>Try it out</h2>
|
|
1131
|
+
<p class="intro">Create posts in realtime. Open in multiple tabs to see live updates!</p>
|
|
1132
|
+
|
|
1133
|
+
<form on:submit|preventDefault={handleCreatePost}>
|
|
1134
|
+
<input
|
|
1135
|
+
type="text"
|
|
1136
|
+
bind:value={newPostTitle}
|
|
1137
|
+
placeholder="Enter a post title..."
|
|
1138
|
+
disabled={isPending}
|
|
1139
|
+
/>
|
|
1140
|
+
<button type="submit" disabled={!newPostTitle.trim() || isPending}>
|
|
1141
|
+
{isPending ? 'Creating...' : 'Create Post'}
|
|
1142
|
+
</button>
|
|
1143
|
+
</form>
|
|
1144
|
+
|
|
1145
|
+
<div class="posts">
|
|
1146
|
+
{#if isLoading}
|
|
1147
|
+
<p class="empty">Loading posts...</p>
|
|
1148
|
+
{:else if posts.length}
|
|
1149
|
+
{#each posts as post (post.id)}
|
|
1150
|
+
<article>
|
|
1151
|
+
<div class="content">
|
|
1152
|
+
<h3>{post.title}</h3>
|
|
1153
|
+
<time>{formatDate(post.createdAt)}</time>
|
|
1154
|
+
</div>
|
|
1155
|
+
<button class="delete" on:click={() => handleDeletePost(post.id)}>
|
|
1156
|
+
Delete
|
|
1157
|
+
</button>
|
|
1158
|
+
</article>
|
|
1159
|
+
{/each}
|
|
1160
|
+
{:else}
|
|
1161
|
+
<p class="empty">No posts yet. Create your first one above!</p>
|
|
1162
|
+
{/if}
|
|
1163
|
+
</div>
|
|
1164
|
+
</section>
|
|
1165
|
+
|
|
1166
|
+
<footer>
|
|
1167
|
+
Built with Tether by <a href="https://strands.gg">Strands Services</a>
|
|
1168
|
+
</footer>
|
|
1169
|
+
</div>
|
|
1170
|
+
</main>
|
|
1171
|
+
|
|
1172
|
+
<style>
|
|
1173
|
+
main {
|
|
1174
|
+
min-height: 100vh;
|
|
1175
|
+
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
|
|
1176
|
+
color: #e4e4e7;
|
|
1177
|
+
font-family: system-ui, sans-serif;
|
|
1178
|
+
padding: 2rem;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
.container {
|
|
1182
|
+
max-width: 640px;
|
|
1183
|
+
margin: 0 auto;
|
|
1184
|
+
display: flex;
|
|
1185
|
+
flex-direction: column;
|
|
1186
|
+
gap: 2rem;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
header {
|
|
1190
|
+
text-align: center;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
h1 {
|
|
1194
|
+
font-size: 2.5rem;
|
|
1195
|
+
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
|
1196
|
+
-webkit-background-clip: text;
|
|
1197
|
+
-webkit-text-fill-color: transparent;
|
|
1198
|
+
margin: 0 0 0.5rem;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
header p {
|
|
1202
|
+
color: #a1a1aa;
|
|
1203
|
+
margin: 0;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
.demo {
|
|
1207
|
+
background: rgba(255, 255, 255, 0.03);
|
|
1208
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1209
|
+
border-radius: 1rem;
|
|
1210
|
+
padding: 1.5rem;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.demo h2 {
|
|
1214
|
+
margin: 0 0 0.5rem;
|
|
1215
|
+
font-size: 1.25rem;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
.intro {
|
|
1219
|
+
color: #a1a1aa;
|
|
1220
|
+
font-size: 0.875rem;
|
|
1221
|
+
margin: 0 0 1rem;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
form {
|
|
1225
|
+
display: flex;
|
|
1226
|
+
gap: 0.75rem;
|
|
1227
|
+
margin-bottom: 1rem;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
input {
|
|
1231
|
+
flex: 1;
|
|
1232
|
+
padding: 0.75rem 1rem;
|
|
1233
|
+
background: rgba(0, 0, 0, 0.3);
|
|
1234
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1235
|
+
border-radius: 0.5rem;
|
|
1236
|
+
color: #e4e4e7;
|
|
1237
|
+
font-size: 0.875rem;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
input:focus {
|
|
1241
|
+
outline: none;
|
|
1242
|
+
border-color: #6366f1;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
button {
|
|
1246
|
+
padding: 0.75rem 1.5rem;
|
|
1247
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
1248
|
+
border: none;
|
|
1249
|
+
border-radius: 0.5rem;
|
|
1250
|
+
color: white;
|
|
1251
|
+
font-weight: 500;
|
|
1252
|
+
cursor: pointer;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
button:disabled {
|
|
1256
|
+
opacity: 0.5;
|
|
1257
|
+
cursor: not-allowed;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
.posts {
|
|
1261
|
+
display: flex;
|
|
1262
|
+
flex-direction: column;
|
|
1263
|
+
gap: 0.5rem;
|
|
1264
|
+
min-height: 100px;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
.empty {
|
|
1268
|
+
color: #71717a;
|
|
1269
|
+
text-align: center;
|
|
1270
|
+
padding: 2rem;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
article {
|
|
1274
|
+
display: flex;
|
|
1275
|
+
align-items: center;
|
|
1276
|
+
justify-content: space-between;
|
|
1277
|
+
padding: 0.875rem 1rem;
|
|
1278
|
+
background: rgba(0, 0, 0, 0.2);
|
|
1279
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
1280
|
+
border-radius: 0.5rem;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
.content h3 {
|
|
1284
|
+
margin: 0 0 0.25rem;
|
|
1285
|
+
font-size: 0.9375rem;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.content time {
|
|
1289
|
+
font-size: 0.75rem;
|
|
1290
|
+
color: #71717a;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
.delete {
|
|
1294
|
+
padding: 0.5rem 1rem;
|
|
1295
|
+
background: transparent;
|
|
1296
|
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
1297
|
+
color: #fca5a5;
|
|
1298
|
+
font-size: 0.75rem;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
.delete:hover {
|
|
1302
|
+
background: rgba(239, 68, 68, 0.1);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
footer {
|
|
1306
|
+
text-align: center;
|
|
1307
|
+
color: #52525b;
|
|
1308
|
+
font-size: 0.8125rem;
|
|
1309
|
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
|
1310
|
+
padding-top: 1rem;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
footer a {
|
|
1314
|
+
color: #6366f1;
|
|
1315
|
+
text-decoration: none;
|
|
1316
|
+
}
|
|
1317
|
+
</style>
|
|
1318
|
+
`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/commands/dev.ts
|
|
1324
|
+
import chalk4 from "chalk";
|
|
1325
|
+
import ora3 from "ora";
|
|
1326
|
+
import fs4 from "fs-extra";
|
|
1327
|
+
import path4 from "path";
|
|
1328
|
+
async function devCommand(options) {
|
|
1329
|
+
await requireAuth();
|
|
1330
|
+
const configPath = path4.resolve(process.cwd(), "tether.config.ts");
|
|
1331
|
+
if (!await fs4.pathExists(configPath)) {
|
|
1332
|
+
console.log(chalk4.red("\nError: Not a Tether project"));
|
|
1333
|
+
console.log(chalk4.dim("Run `tthr init` to create a new project\n"));
|
|
1334
|
+
process.exit(1);
|
|
1335
|
+
}
|
|
1336
|
+
console.log(chalk4.bold("\n\u26A1 Starting Tether development server\n"));
|
|
1337
|
+
const spinner = ora3("Starting server...").start();
|
|
1338
|
+
try {
|
|
1339
|
+
spinner.succeed(`Development server running on port ${options.port}`);
|
|
1340
|
+
console.log("\n" + chalk4.cyan(` Local: http://localhost:${options.port}`));
|
|
1341
|
+
console.log(chalk4.cyan(` WebSocket: ws://localhost:${options.port}/ws
|
|
1342
|
+
`));
|
|
1343
|
+
console.log(chalk4.dim(" Press Ctrl+C to stop\n"));
|
|
1344
|
+
process.on("SIGINT", () => {
|
|
1345
|
+
console.log(chalk4.yellow("\n\nShutting down...\n"));
|
|
1346
|
+
process.exit(0);
|
|
1347
|
+
});
|
|
1348
|
+
await new Promise(() => {
|
|
1349
|
+
});
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
spinner.fail("Failed to start server");
|
|
1352
|
+
console.error(chalk4.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1353
|
+
process.exit(1);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// src/commands/generate.ts
|
|
1358
|
+
import chalk5 from "chalk";
|
|
1359
|
+
import ora4 from "ora";
|
|
1360
|
+
import fs5 from "fs-extra";
|
|
1361
|
+
import path5 from "path";
|
|
1362
|
+
async function generateCommand() {
|
|
1363
|
+
await requireAuth();
|
|
1364
|
+
const configPath = path5.resolve(process.cwd(), "tether.config.ts");
|
|
1365
|
+
if (!await fs5.pathExists(configPath)) {
|
|
1366
|
+
console.log(chalk5.red("\nError: Not a Tether project"));
|
|
1367
|
+
console.log(chalk5.dim("Run `tthr init` to create a new project\n"));
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
}
|
|
1370
|
+
console.log(chalk5.bold("\n\u26A1 Generating types from schema\n"));
|
|
1371
|
+
const spinner = ora4("Reading schema...").start();
|
|
1372
|
+
try {
|
|
1373
|
+
const schemaPath = path5.resolve(process.cwd(), "tether", "schema.ts");
|
|
1374
|
+
const outputDir = path5.resolve(process.cwd(), "_generated");
|
|
1375
|
+
if (!await fs5.pathExists(schemaPath)) {
|
|
1376
|
+
spinner.fail("Schema file not found");
|
|
1377
|
+
console.log(chalk5.dim(`Expected: ${schemaPath}
|
|
1378
|
+
`));
|
|
1379
|
+
process.exit(1);
|
|
1380
|
+
}
|
|
1381
|
+
spinner.text = "Generating types...";
|
|
1382
|
+
await fs5.ensureDir(outputDir);
|
|
1383
|
+
await fs5.writeFile(
|
|
1384
|
+
path5.join(outputDir, "db.ts"),
|
|
1385
|
+
`// Auto-generated by Tether CLI - do not edit manually
|
|
1386
|
+
// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1387
|
+
|
|
1388
|
+
import type { TetherDatabase } from '@tthr/client';
|
|
1389
|
+
|
|
1390
|
+
export interface Post {
|
|
1391
|
+
id: string;
|
|
1392
|
+
title: string;
|
|
1393
|
+
content: string | null;
|
|
1394
|
+
authorId: string;
|
|
1395
|
+
createdAt: string;
|
|
1396
|
+
updatedAt: string;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
export interface Comment {
|
|
1400
|
+
id: string;
|
|
1401
|
+
postId: string;
|
|
1402
|
+
content: string;
|
|
1403
|
+
authorId: string;
|
|
1404
|
+
createdAt: string;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
export interface Schema {
|
|
1408
|
+
posts: Post;
|
|
1409
|
+
comments: Comment;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Database client with typed tables
|
|
1413
|
+
export declare const db: TetherDatabase<Schema>;
|
|
1414
|
+
`
|
|
1415
|
+
);
|
|
1416
|
+
await fs5.writeFile(
|
|
1417
|
+
path5.join(outputDir, "api.ts"),
|
|
1418
|
+
`// Auto-generated by Tether CLI - do not edit manually
|
|
1419
|
+
// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1420
|
+
|
|
1421
|
+
import type { TetherClient } from '@tthr/client';
|
|
1422
|
+
import type { Schema } from './db';
|
|
1423
|
+
|
|
1424
|
+
export interface PostsApi {
|
|
1425
|
+
list: (args?: { limit?: number }) => Promise<Schema['posts'][]>;
|
|
1426
|
+
get: (args: { id: string }) => Promise<Schema['posts'] | null>;
|
|
1427
|
+
create: (args: { title: string; content?: string }) => Promise<{ id: string }>;
|
|
1428
|
+
update: (args: { id: string; title?: string; content?: string }) => Promise<void>;
|
|
1429
|
+
remove: (args: { id: string }) => Promise<void>;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
export interface Api {
|
|
1433
|
+
posts: PostsApi;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Typed Tether client
|
|
1437
|
+
export declare const tether: TetherClient<Api>;
|
|
1438
|
+
`
|
|
1439
|
+
);
|
|
1440
|
+
await fs5.writeFile(
|
|
1441
|
+
path5.join(outputDir, "index.ts"),
|
|
1442
|
+
`// Auto-generated by Tether CLI - do not edit manually
|
|
1443
|
+
export * from './db';
|
|
1444
|
+
export * from './api';
|
|
1445
|
+
`
|
|
1446
|
+
);
|
|
1447
|
+
spinner.succeed("Types generated");
|
|
1448
|
+
console.log("\n" + chalk5.green("\u2713") + " Generated files:");
|
|
1449
|
+
console.log(chalk5.dim(" _generated/db.ts"));
|
|
1450
|
+
console.log(chalk5.dim(" _generated/api.ts"));
|
|
1451
|
+
console.log(chalk5.dim(" _generated/index.ts\n"));
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
spinner.fail("Failed to generate types");
|
|
1454
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1455
|
+
process.exit(1);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// src/commands/migrate.ts
|
|
1460
|
+
import chalk6 from "chalk";
|
|
1461
|
+
import ora5 from "ora";
|
|
1462
|
+
import fs6 from "fs-extra";
|
|
1463
|
+
import path6 from "path";
|
|
1464
|
+
import prompts2 from "prompts";
|
|
1465
|
+
async function migrateCommand(action, options) {
|
|
1466
|
+
await requireAuth();
|
|
1467
|
+
const configPath = path6.resolve(process.cwd(), "tether.config.ts");
|
|
1468
|
+
if (!await fs6.pathExists(configPath)) {
|
|
1469
|
+
console.log(chalk6.red("\nError: Not a Tether project"));
|
|
1470
|
+
console.log(chalk6.dim("Run `tthr init` to create a new project\n"));
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
const migrationsDir = path6.resolve(process.cwd(), "tether", "migrations");
|
|
1474
|
+
switch (action) {
|
|
1475
|
+
case "create":
|
|
1476
|
+
await createMigration(migrationsDir, options.name);
|
|
1477
|
+
break;
|
|
1478
|
+
case "up":
|
|
1479
|
+
await runMigrations(migrationsDir, "up");
|
|
1480
|
+
break;
|
|
1481
|
+
case "down":
|
|
1482
|
+
await runMigrations(migrationsDir, "down");
|
|
1483
|
+
break;
|
|
1484
|
+
case "status":
|
|
1485
|
+
default:
|
|
1486
|
+
await showStatus(migrationsDir);
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
async function createMigration(migrationsDir, name) {
|
|
1491
|
+
console.log(chalk6.bold("\n\u26A1 Create migration\n"));
|
|
1492
|
+
let migrationName = name;
|
|
1493
|
+
if (!migrationName) {
|
|
1494
|
+
const response = await prompts2({
|
|
1495
|
+
type: "text",
|
|
1496
|
+
name: "name",
|
|
1497
|
+
message: "Migration name:",
|
|
1498
|
+
initial: "update_schema"
|
|
1499
|
+
});
|
|
1500
|
+
migrationName = response.name;
|
|
1501
|
+
if (!migrationName) {
|
|
1502
|
+
console.log(chalk6.red("Migration name is required"));
|
|
1503
|
+
process.exit(1);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
const spinner = ora5("Creating migration...").start();
|
|
1507
|
+
try {
|
|
1508
|
+
await fs6.ensureDir(migrationsDir);
|
|
1509
|
+
const timestamp = Date.now();
|
|
1510
|
+
const filename = `${timestamp}_${migrationName}.sql`;
|
|
1511
|
+
const filepath = path6.join(migrationsDir, filename);
|
|
1512
|
+
await fs6.writeFile(
|
|
1513
|
+
filepath,
|
|
1514
|
+
`-- Migration: ${migrationName}
|
|
1515
|
+
-- Created at: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1516
|
+
|
|
1517
|
+
-- Up migration
|
|
1518
|
+
-- Add your schema changes here
|
|
1519
|
+
|
|
1520
|
+
-- Example:
|
|
1521
|
+
-- CREATE TABLE IF NOT EXISTS users (
|
|
1522
|
+
-- id TEXT PRIMARY KEY,
|
|
1523
|
+
-- email TEXT NOT NULL UNIQUE,
|
|
1524
|
+
-- name TEXT,
|
|
1525
|
+
-- created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1526
|
+
-- );
|
|
1527
|
+
|
|
1528
|
+
-- Down migration (for rollback)
|
|
1529
|
+
-- DROP TABLE IF EXISTS users;
|
|
1530
|
+
`
|
|
1531
|
+
);
|
|
1532
|
+
spinner.succeed("Migration created");
|
|
1533
|
+
console.log(chalk6.dim(`
|
|
1534
|
+
${filepath}
|
|
1535
|
+
`));
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
spinner.fail("Failed to create migration");
|
|
1538
|
+
console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1539
|
+
process.exit(1);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
async function runMigrations(migrationsDir, direction) {
|
|
1543
|
+
console.log(chalk6.bold(`
|
|
1544
|
+
\u26A1 Running migrations (${direction})
|
|
1545
|
+
`));
|
|
1546
|
+
const spinner = ora5("Checking migrations...").start();
|
|
1547
|
+
try {
|
|
1548
|
+
if (!await fs6.pathExists(migrationsDir)) {
|
|
1549
|
+
spinner.info("No migrations directory found");
|
|
1550
|
+
console.log(chalk6.dim("\nRun `tthr migrate create` to create your first migration\n"));
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const files = await fs6.readdir(migrationsDir);
|
|
1554
|
+
const migrations = files.filter((f) => f.endsWith(".sql")).sort((a, b) => direction === "up" ? a.localeCompare(b) : b.localeCompare(a));
|
|
1555
|
+
if (migrations.length === 0) {
|
|
1556
|
+
spinner.info("No migrations found");
|
|
1557
|
+
console.log(chalk6.dim("\nRun `tthr migrate create` to create your first migration\n"));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
spinner.text = `Found ${migrations.length} migration(s)`;
|
|
1561
|
+
spinner.succeed(`Would run ${migrations.length} migration(s)`);
|
|
1562
|
+
console.log("\nMigrations to run:");
|
|
1563
|
+
for (const migration of migrations) {
|
|
1564
|
+
console.log(chalk6.dim(` ${migration}`));
|
|
1565
|
+
}
|
|
1566
|
+
console.log(chalk6.yellow("\nNote: Migration execution not yet implemented\n"));
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
spinner.fail("Failed to run migrations");
|
|
1569
|
+
console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1570
|
+
process.exit(1);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
async function showStatus(migrationsDir) {
|
|
1574
|
+
console.log(chalk6.bold("\n\u26A1 Migration status\n"));
|
|
1575
|
+
try {
|
|
1576
|
+
if (!await fs6.pathExists(migrationsDir)) {
|
|
1577
|
+
console.log(chalk6.dim("No migrations directory found"));
|
|
1578
|
+
console.log(chalk6.dim("\nRun `tthr migrate create` to create your first migration\n"));
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
const files = await fs6.readdir(migrationsDir);
|
|
1582
|
+
const migrations = files.filter((f) => f.endsWith(".sql")).sort();
|
|
1583
|
+
if (migrations.length === 0) {
|
|
1584
|
+
console.log(chalk6.dim("No migrations found"));
|
|
1585
|
+
console.log(chalk6.dim("\nRun `tthr migrate create` to create your first migration\n"));
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
console.log(`Found ${migrations.length} migration(s):
|
|
1589
|
+
`);
|
|
1590
|
+
for (const migration of migrations) {
|
|
1591
|
+
console.log(chalk6.yellow(" \u25CB") + ` ${migration} ${chalk6.dim("(pending)")}`);
|
|
1592
|
+
}
|
|
1593
|
+
console.log();
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
console.error(chalk6.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1596
|
+
process.exit(1);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/commands/login.ts
|
|
1601
|
+
import chalk7 from "chalk";
|
|
1602
|
+
import ora6 from "ora";
|
|
1603
|
+
var isDev3 = process.env.NODE_ENV === "development" || process.env.TETHER_DEV === "true";
|
|
1604
|
+
var API_URL3 = isDev3 ? "http://localhost:3001/api/v1" : "https://tthr.io/api/v1";
|
|
1605
|
+
var AUTH_URL = isDev3 ? "http://localhost:3000/cli" : "https://tthr.io/cli";
|
|
1606
|
+
async function loginCommand() {
|
|
1607
|
+
console.log(chalk7.bold("\u26A1 Login to Tether\n"));
|
|
1608
|
+
const existing = await getCredentials();
|
|
1609
|
+
if (existing) {
|
|
1610
|
+
console.log(chalk7.green("\u2713") + ` Already logged in as ${chalk7.cyan(existing.email)}`);
|
|
1611
|
+
console.log(chalk7.dim("\nRun `tthr logout` to sign out\n"));
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const spinner = ora6("Generating authentication code...").start();
|
|
1615
|
+
try {
|
|
1616
|
+
const deviceCode = await requestDeviceCode();
|
|
1617
|
+
spinner.stop();
|
|
1618
|
+
const authUrl = `${AUTH_URL}/${deviceCode.userCode}`;
|
|
1619
|
+
console.log(chalk7.dim("Open this URL in your browser to authenticate:\n"));
|
|
1620
|
+
console.log(chalk7.cyan.bold(` ${authUrl}
|
|
1621
|
+
`));
|
|
1622
|
+
console.log(chalk7.dim(`Your code: ${chalk7.white.bold(deviceCode.userCode)}
|
|
1623
|
+
`));
|
|
1624
|
+
const credentials = await pollForApproval(deviceCode.deviceCode, deviceCode.interval, deviceCode.expiresIn);
|
|
1625
|
+
await saveCredentials(credentials);
|
|
1626
|
+
console.log(chalk7.green("\u2713") + ` Logged in as ${chalk7.cyan(credentials.email)}`);
|
|
1627
|
+
console.log();
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
spinner.fail("Authentication failed");
|
|
1630
|
+
console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1631
|
+
console.log(chalk7.dim("\nTry again or visit https://tthr.io/docs/cli for help\n"));
|
|
1632
|
+
process.exit(1);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
async function logoutCommand() {
|
|
1636
|
+
console.log(chalk7.bold("\n\u26A1 Logout from Tether\n"));
|
|
1637
|
+
const credentials = await getCredentials();
|
|
1638
|
+
if (!credentials) {
|
|
1639
|
+
console.log(chalk7.dim("Not logged in\n"));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const spinner = ora6("Logging out...").start();
|
|
1643
|
+
try {
|
|
1644
|
+
await clearCredentials();
|
|
1645
|
+
spinner.succeed(`Logged out from ${chalk7.cyan(credentials.email)}`);
|
|
1646
|
+
console.log();
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
spinner.fail("Logout failed");
|
|
1649
|
+
console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1650
|
+
process.exit(1);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
async function whoamiCommand() {
|
|
1654
|
+
const credentials = await getCredentials();
|
|
1655
|
+
if (!credentials) {
|
|
1656
|
+
console.log(chalk7.dim("\nNot logged in"));
|
|
1657
|
+
console.log(chalk7.dim("Run `tthr login` to authenticate\n"));
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
console.log(chalk7.bold("\n\u26A1 Current user\n"));
|
|
1661
|
+
console.log(` Email: ${chalk7.cyan(credentials.email)}`);
|
|
1662
|
+
console.log(` User ID: ${chalk7.dim(credentials.userId)}`);
|
|
1663
|
+
if (credentials.expiresAt) {
|
|
1664
|
+
const expiresAt = new Date(credentials.expiresAt);
|
|
1665
|
+
const now = /* @__PURE__ */ new Date();
|
|
1666
|
+
if (expiresAt > now) {
|
|
1667
|
+
console.log(` Session: ${chalk7.green("Active")}`);
|
|
1668
|
+
} else {
|
|
1669
|
+
console.log(` Session: ${chalk7.yellow("Expired")}`);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
console.log();
|
|
1673
|
+
}
|
|
1674
|
+
function generateUserCode() {
|
|
1675
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
1676
|
+
const nums = "23456789";
|
|
1677
|
+
let code = "";
|
|
1678
|
+
for (let i = 0; i < 4; i++) {
|
|
1679
|
+
code += chars[Math.floor(Math.random() * chars.length)];
|
|
1680
|
+
}
|
|
1681
|
+
code += "-";
|
|
1682
|
+
for (let i = 0; i < 4; i++) {
|
|
1683
|
+
code += nums[Math.floor(Math.random() * nums.length)];
|
|
1684
|
+
}
|
|
1685
|
+
return code;
|
|
1686
|
+
}
|
|
1687
|
+
async function requestDeviceCode() {
|
|
1688
|
+
const userCode = generateUserCode();
|
|
1689
|
+
const deviceCode = crypto.randomUUID();
|
|
1690
|
+
const response = await fetch(`${API_URL3}/auth/device`, {
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
headers: { "Content-Type": "application/json" },
|
|
1693
|
+
body: JSON.stringify({ userCode, deviceCode })
|
|
1694
|
+
}).catch(() => null);
|
|
1695
|
+
if (response?.ok) {
|
|
1696
|
+
return await response.json();
|
|
1697
|
+
}
|
|
1698
|
+
return {
|
|
1699
|
+
deviceCode,
|
|
1700
|
+
userCode,
|
|
1701
|
+
expiresIn: 120,
|
|
1702
|
+
// 2 minutes
|
|
1703
|
+
interval: 5
|
|
1704
|
+
// Poll every 5 seconds
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
async function pollForApproval(deviceCode, interval, expiresIn) {
|
|
1708
|
+
const startTime = Date.now();
|
|
1709
|
+
const expiresAt = startTime + expiresIn * 1e3;
|
|
1710
|
+
const spinner = ora6("").start();
|
|
1711
|
+
const updateCountdown = () => {
|
|
1712
|
+
const remaining = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1e3));
|
|
1713
|
+
const mins = Math.floor(remaining / 60);
|
|
1714
|
+
const secs = remaining % 60;
|
|
1715
|
+
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, "0")}` : `${secs}s`;
|
|
1716
|
+
spinner.text = `Waiting for approval... ${chalk7.dim(`(${timeStr} remaining)`)}`;
|
|
1717
|
+
};
|
|
1718
|
+
updateCountdown();
|
|
1719
|
+
const countdownInterval = setInterval(updateCountdown, 1e3);
|
|
1720
|
+
try {
|
|
1721
|
+
while (Date.now() < expiresAt) {
|
|
1722
|
+
await sleep(interval * 1e3);
|
|
1723
|
+
spinner.text = `Checking... ${chalk7.dim(`(${Math.ceil((expiresAt - Date.now()) / 1e3)}s remaining)`)}`;
|
|
1724
|
+
const response = await fetch(`${API_URL3}/auth/device/${deviceCode}`, {
|
|
1725
|
+
method: "GET"
|
|
1726
|
+
}).catch(() => null);
|
|
1727
|
+
updateCountdown();
|
|
1728
|
+
if (response?.ok) {
|
|
1729
|
+
const data = await response.json();
|
|
1730
|
+
if (data.status === "approved" && data.credentials) {
|
|
1731
|
+
spinner.stop();
|
|
1732
|
+
return data.credentials;
|
|
1733
|
+
}
|
|
1734
|
+
if (data.status === "denied") {
|
|
1735
|
+
spinner.fail("Authentication denied");
|
|
1736
|
+
throw new Error("Authentication request was denied");
|
|
1737
|
+
}
|
|
1738
|
+
if (data.status === "expired") {
|
|
1739
|
+
spinner.fail("Authentication expired");
|
|
1740
|
+
throw new Error("Authentication request expired");
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
spinner.fail("Authentication timed out");
|
|
1745
|
+
throw new Error("Authentication timed out");
|
|
1746
|
+
} finally {
|
|
1747
|
+
clearInterval(countdownInterval);
|
|
1748
|
+
spinner.stop();
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
function sleep(ms) {
|
|
1752
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/index.ts
|
|
1756
|
+
var program = new Command();
|
|
1757
|
+
program.name("tthr").description("Tether CLI - Realtime SQLite for modern applications").version("0.0.1");
|
|
1758
|
+
program.command("init [name]").description("Create a new Tether project").option("-t, --template <template>", "Project template (vue, svelte, react, vanilla)", "vue").action(initCommand);
|
|
1759
|
+
program.command("dev").description("Start local development server with hot reload").option("-p, --port <port>", "Port to run on", "3001").action(devCommand);
|
|
1760
|
+
program.command("generate").alias("gen").description("Generate types from schema").action(generateCommand);
|
|
1761
|
+
program.command("migrate").description("Database migrations").argument("[action]", "Migration action: create, up, down, status", "status").option("-n, --name <name>", "Migration name (for create)").action(migrateCommand);
|
|
1762
|
+
program.command("deploy").description("Deploy schema and functions to Tether").option("-s, --schema", "Deploy schema only").option("-f, --functions", "Deploy functions only").option("--dry-run", "Show what would be deployed without deploying").action(deployCommand);
|
|
1763
|
+
program.command("login").description("Authenticate with Tether").action(loginCommand);
|
|
1764
|
+
program.command("logout").description("Sign out of Tether").action(logoutCommand);
|
|
1765
|
+
program.command("whoami").description("Show current authenticated user").action(whoamiCommand);
|
|
1766
|
+
program.parse();
|