@tthr/cli 0.0.1 → 0.0.3

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.
Files changed (2) hide show
  1. package/dist/index.js +821 -167
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import ora from "ora";
9
9
  import prompts from "prompts";
10
10
  import fs2 from "fs-extra";
11
11
  import path2 from "path";
12
+ import { execSync } from "child_process";
12
13
 
13
14
  // src/utils/auth.ts
14
15
  import chalk from "chalk";
@@ -32,6 +33,14 @@ async function requireAuth() {
32
33
  console.log(chalk.dim("Run `tthr login` to authenticate\n"));
33
34
  process.exit(1);
34
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
+ }
35
44
  return credentials;
36
45
  }
37
46
  async function saveCredentials(credentials) {
@@ -84,14 +93,14 @@ async function initCommand(name, options) {
84
93
  name: "template",
85
94
  message: "Select a framework:",
86
95
  choices: [
87
- { title: "Vue / Nuxt", value: "vue" },
88
- { title: "Svelte / SvelteKit", value: "svelte" },
89
- { title: "React / Next.js", value: "react" },
90
- { title: "Vanilla JavaScript", value: "vanilla" }
96
+ { title: "Nuxt (Vue)", value: "nuxt", description: "Full-stack Vue framework with SSR" },
97
+ { title: "Next.js (React)", value: "next", description: "Full-stack React framework with SSR" },
98
+ { title: "SvelteKit (Svelte)", value: "sveltekit", description: "Full-stack Svelte framework with SSR" },
99
+ { title: "Vanilla TypeScript", value: "vanilla", description: "Minimal setup for scripts and APIs" }
91
100
  ],
92
101
  initial: 0
93
102
  });
94
- template = response.template || "vue";
103
+ template = response.template || "nuxt";
95
104
  }
96
105
  const projectPath = path2.resolve(process.cwd(), projectName);
97
106
  if (await fs2.pathExists(projectPath)) {
@@ -126,14 +135,184 @@ async function initCommand(name, options) {
126
135
  const data = await response.json();
127
136
  projectId = data.project.id;
128
137
  apiKey = data.apiKey;
129
- spinner.text = "Creating project structure...";
130
- await fs2.ensureDir(projectPath);
131
- await fs2.ensureDir(path2.join(projectPath, "tether"));
132
- await fs2.ensureDir(path2.join(projectPath, "tether", "functions"));
133
- await fs2.ensureDir(path2.join(projectPath, "_generated"));
134
- await fs2.writeFile(
135
- path2.join(projectPath, "tether.config.ts"),
136
- `import { defineConfig } from '@tthr/cli';
138
+ spinner.text = `Scaffolding ${template} project...`;
139
+ if (template === "nuxt") {
140
+ await scaffoldNuxtProject(projectName, projectPath, spinner);
141
+ } else if (template === "next") {
142
+ await scaffoldNextProject(projectName, projectPath, spinner);
143
+ } else if (template === "sveltekit") {
144
+ await scaffoldSvelteKitProject(projectName, projectPath, spinner);
145
+ } else {
146
+ await scaffoldVanillaProject(projectName, projectPath, spinner);
147
+ }
148
+ spinner.text = "Adding Tether configuration...";
149
+ await addTetherFiles(projectPath, projectId, apiKey, template);
150
+ spinner.text = "Configuring framework integration...";
151
+ await configureFramework(projectPath, template);
152
+ spinner.text = "Installing Tether packages...";
153
+ await installTetherPackages(projectPath, template);
154
+ spinner.text = "Creating demo page...";
155
+ await createDemoPage(projectPath, template);
156
+ spinner.text = "Running initial migration...";
157
+ await runInitialMigration(projectPath, projectId, apiKey);
158
+ spinner.succeed("Project created successfully!");
159
+ console.log("\n" + chalk2.green("\u2713") + " Project created successfully!\n");
160
+ console.log("Next steps:\n");
161
+ console.log(chalk2.cyan(` cd ${projectName}`));
162
+ if (template === "vanilla") {
163
+ console.log(chalk2.cyan(" npm install"));
164
+ }
165
+ console.log(chalk2.cyan(" npm run dev"));
166
+ console.log("\n" + chalk2.dim("For more information, visit https://tthr.io/docs\n"));
167
+ } catch (error) {
168
+ spinner.fail("Failed to create project");
169
+ console.error(chalk2.red(error instanceof Error ? error.message : "Unknown error"));
170
+ process.exit(1);
171
+ }
172
+ }
173
+ async function scaffoldNuxtProject(projectName, projectPath, spinner) {
174
+ const parentDir = path2.dirname(projectPath);
175
+ spinner.stop();
176
+ console.log(chalk2.dim("\nRunning nuxi init...\n"));
177
+ try {
178
+ execSync(`npx nuxi@latest init ${projectName} --packageManager npm --gitInit false`, {
179
+ cwd: parentDir,
180
+ stdio: "inherit"
181
+ });
182
+ if (!await fs2.pathExists(path2.join(projectPath, "package.json"))) {
183
+ throw new Error("Nuxt project was not created successfully - package.json missing");
184
+ }
185
+ spinner.start();
186
+ } catch (error) {
187
+ spinner.start();
188
+ if (error instanceof Error && error.message.includes("package.json missing")) {
189
+ throw error;
190
+ }
191
+ throw new Error("Failed to create Nuxt project. Make sure you have npx installed.");
192
+ }
193
+ }
194
+ async function scaffoldNextProject(projectName, projectPath, spinner) {
195
+ const parentDir = path2.dirname(projectPath);
196
+ spinner.stop();
197
+ console.log(chalk2.dim("\nRunning create-next-app...\n"));
198
+ try {
199
+ execSync(`npx create-next-app@latest ${projectName} --typescript --eslint --tailwind --src-dir --app --import-alias "@/*" --use-npm`, {
200
+ cwd: parentDir,
201
+ stdio: "inherit"
202
+ });
203
+ if (!await fs2.pathExists(path2.join(projectPath, "package.json"))) {
204
+ throw new Error("Next.js project was not created successfully - package.json missing");
205
+ }
206
+ spinner.start();
207
+ } catch (error) {
208
+ spinner.start();
209
+ if (error instanceof Error && error.message.includes("package.json missing")) {
210
+ throw error;
211
+ }
212
+ throw new Error("Failed to create Next.js project. Make sure you have npx installed.");
213
+ }
214
+ }
215
+ async function scaffoldSvelteKitProject(projectName, projectPath, spinner) {
216
+ const parentDir = path2.dirname(projectPath);
217
+ spinner.stop();
218
+ console.log(chalk2.dim("\nRunning sv create...\n"));
219
+ try {
220
+ execSync(`npx sv create ${projectName} --template minimal --types ts --no-add-ons --no-install`, {
221
+ cwd: parentDir,
222
+ stdio: "inherit"
223
+ });
224
+ if (!await fs2.pathExists(path2.join(projectPath, "package.json"))) {
225
+ throw new Error("SvelteKit project was not created successfully - package.json missing");
226
+ }
227
+ spinner.start();
228
+ } catch (error) {
229
+ spinner.start();
230
+ if (error instanceof Error && error.message.includes("package.json missing")) {
231
+ throw error;
232
+ }
233
+ throw new Error("Failed to create SvelteKit project. Make sure you have npx installed.");
234
+ }
235
+ }
236
+ async function scaffoldVanillaProject(projectName, projectPath, spinner) {
237
+ spinner.text = "Creating vanilla TypeScript project...";
238
+ await fs2.ensureDir(projectPath);
239
+ await fs2.ensureDir(path2.join(projectPath, "src"));
240
+ const packageJson = {
241
+ name: projectName,
242
+ version: "0.0.1",
243
+ private: true,
244
+ type: "module",
245
+ scripts: {
246
+ "dev": "tsx watch src/index.ts",
247
+ "build": "tsc",
248
+ "start": "node dist/index.js"
249
+ },
250
+ devDependencies: {
251
+ "typescript": "latest",
252
+ "tsx": "latest",
253
+ "@types/node": "latest"
254
+ }
255
+ };
256
+ await fs2.writeJSON(path2.join(projectPath, "package.json"), packageJson, { spaces: 2 });
257
+ await fs2.writeJSON(
258
+ path2.join(projectPath, "tsconfig.json"),
259
+ {
260
+ compilerOptions: {
261
+ target: "ES2022",
262
+ module: "ESNext",
263
+ moduleResolution: "bundler",
264
+ strict: true,
265
+ esModuleInterop: true,
266
+ skipLibCheck: true,
267
+ forceConsistentCasingInFileNames: true,
268
+ outDir: "./dist",
269
+ rootDir: "./src",
270
+ paths: {
271
+ "@/*": ["./src/*"]
272
+ }
273
+ },
274
+ include: ["src/**/*.ts"],
275
+ exclude: ["node_modules", "dist"]
276
+ },
277
+ { spaces: 2 }
278
+ );
279
+ await fs2.writeFile(
280
+ path2.join(projectPath, "src", "index.ts"),
281
+ `import { createClient } from '@tthr/client';
282
+
283
+ const tether = createClient({
284
+ projectId: process.env.TETHER_PROJECT_ID!,
285
+ url: process.env.TETHER_URL || 'https://tthr.io',
286
+ });
287
+
288
+ async function main() {
289
+ console.log('Hello from Tether!');
290
+
291
+ // Example: List all posts
292
+ // const posts = await tether.query('posts.list', { limit: 10 });
293
+ // console.log(posts);
294
+ }
295
+
296
+ main().catch(console.error);
297
+ `
298
+ );
299
+ await fs2.writeFile(
300
+ path2.join(projectPath, ".gitignore"),
301
+ `node_modules/
302
+ dist/
303
+ .env
304
+ .env.local
305
+ .DS_Store
306
+ `
307
+ );
308
+ }
309
+ async function addTetherFiles(projectPath, projectId, apiKey, template) {
310
+ await fs2.ensureDir(path2.join(projectPath, "tether"));
311
+ await fs2.ensureDir(path2.join(projectPath, "tether", "functions"));
312
+ const configPackage = template === "nuxt" ? "@tthr/vue" : template === "next" ? "@tthr/react" : template === "sveltekit" ? "@tthr/svelte" : "@tthr/client";
313
+ await fs2.writeFile(
314
+ path2.join(projectPath, "tether.config.ts"),
315
+ `import { defineConfig } from '${configPackage}';
137
316
 
138
317
  export default defineConfig({
139
318
  // Project configuration
@@ -152,10 +331,10 @@ export default defineConfig({
152
331
  output: './_generated',
153
332
  });
154
333
  `
155
- );
156
- await fs2.writeFile(
157
- path2.join(projectPath, "tether", "schema.ts"),
158
- `import { defineSchema, text, integer, timestamp } from '@tthr/schema';
334
+ );
335
+ await fs2.writeFile(
336
+ path2.join(projectPath, "tether", "schema.ts"),
337
+ `import { defineSchema, text, integer, timestamp } from '@tthr/schema';
159
338
 
160
339
  export default defineSchema({
161
340
  // Example: posts table
@@ -178,18 +357,17 @@ export default defineSchema({
178
357
  },
179
358
  });
180
359
  `
181
- );
182
- await fs2.writeFile(
183
- path2.join(projectPath, "tether", "functions", "posts.ts"),
184
- `import { query, mutation, z } from '@tthr/server';
185
- import { db } from '../../_generated/db';
360
+ );
361
+ await fs2.writeFile(
362
+ path2.join(projectPath, "tether", "functions", "posts.ts"),
363
+ `import { query, mutation, z } from '@tthr/server';
186
364
 
187
365
  // List all posts
188
366
  export const list = query({
189
367
  args: z.object({
190
368
  limit: z.number().optional().default(20),
191
369
  }),
192
- handler: async ({ args }) => {
370
+ handler: async ({ args, db }) => {
193
371
  return db.posts.findMany({
194
372
  orderBy: { createdAt: 'desc' },
195
373
  limit: args.limit,
@@ -202,7 +380,7 @@ export const get = query({
202
380
  args: z.object({
203
381
  id: z.string(),
204
382
  }),
205
- handler: async ({ args }) => {
383
+ handler: async ({ args, db }) => {
206
384
  return db.posts.findUnique({
207
385
  where: { id: args.id },
208
386
  });
@@ -215,7 +393,7 @@ export const create = mutation({
215
393
  title: z.string().min(1),
216
394
  content: z.string().optional(),
217
395
  }),
218
- handler: async ({ args, ctx }) => {
396
+ handler: async ({ args, ctx, db }) => {
219
397
  const id = crypto.randomUUID();
220
398
  const now = new Date().toISOString();
221
399
 
@@ -241,7 +419,7 @@ export const update = mutation({
241
419
  title: z.string().min(1).optional(),
242
420
  content: z.string().optional(),
243
421
  }),
244
- handler: async ({ args }) => {
422
+ handler: async ({ args, db }) => {
245
423
  await db.posts.update({
246
424
  where: { id: args.id },
247
425
  data: {
@@ -258,170 +436,646 @@ export const remove = mutation({
258
436
  args: z.object({
259
437
  id: z.string(),
260
438
  }),
261
- handler: async ({ args }) => {
439
+ handler: async ({ args, db }) => {
262
440
  await db.posts.delete({
263
441
  where: { id: args.id },
264
442
  });
265
443
  },
266
444
  });
267
445
  `
268
- );
446
+ );
447
+ const envContent = `# Tether Configuration
448
+ TETHER_PROJECT_ID=${projectId}
449
+ TETHER_URL=${isDev ? "http://localhost:3001" : "https://tthr.io"}
450
+ TETHER_API_KEY=${apiKey}
451
+ `;
452
+ const envPath = path2.join(projectPath, ".env");
453
+ if (await fs2.pathExists(envPath)) {
454
+ const existing = await fs2.readFile(envPath, "utf-8");
455
+ await fs2.writeFile(envPath, existing + "\n" + envContent);
456
+ } else {
457
+ await fs2.writeFile(envPath, envContent);
458
+ }
459
+ const gitignorePath = path2.join(projectPath, ".gitignore");
460
+ const tetherGitignore = `
461
+ # Tether
462
+ _generated/
463
+ .env
464
+ .env.local
465
+ `;
466
+ if (await fs2.pathExists(gitignorePath)) {
467
+ const existing = await fs2.readFile(gitignorePath, "utf-8");
468
+ if (!existing.includes("_generated/")) {
469
+ await fs2.writeFile(gitignorePath, existing + tetherGitignore);
470
+ }
471
+ } else {
472
+ await fs2.writeFile(gitignorePath, tetherGitignore.trim());
473
+ }
474
+ }
475
+ async function configureFramework(projectPath, template) {
476
+ if (template === "nuxt") {
477
+ await configureNuxt(projectPath);
478
+ } else if (template === "next") {
479
+ await configureNext(projectPath);
480
+ } else if (template === "sveltekit") {
481
+ await configureSvelteKit(projectPath);
482
+ }
483
+ }
484
+ async function configureNuxt(projectPath) {
485
+ const configPath = path2.join(projectPath, "nuxt.config.ts");
486
+ if (!await fs2.pathExists(configPath)) {
269
487
  await fs2.writeFile(
270
- path2.join(projectPath, "tether", "seed.ts"),
271
- `import { db } from '../_generated/db';
272
-
273
- export async function seed() {
274
- console.log('Seeding database...');
275
-
276
- // Add your seed data here
277
- const now = new Date().toISOString();
278
-
279
- await db.posts.create({
280
- data: {
281
- id: 'post-1',
282
- title: 'Welcome to Tether',
283
- content: 'This is your first post. Edit or delete it to get started!',
284
- authorId: 'system',
285
- createdAt: now,
286
- updatedAt: now,
287
- },
288
- });
488
+ configPath,
489
+ `// https://nuxt.com/docs/api/configuration/nuxt-config
490
+ export default defineNuxtConfig({
491
+ compatibilityDate: '2024-11-01',
492
+ devtools: { enabled: true },
493
+
494
+ modules: ['@tthr/vue/nuxt'],
495
+
496
+ tether: {
497
+ projectId: process.env.TETHER_PROJECT_ID,
498
+ url: process.env.TETHER_URL || 'https://tthr.io',
499
+ },
500
+ });
501
+ `
502
+ );
503
+ return;
504
+ }
505
+ let config = await fs2.readFile(configPath, "utf-8");
506
+ if (config.includes("modules:")) {
507
+ config = config.replace(
508
+ /modules:\s*\[/,
509
+ "modules: ['@tthr/vue/nuxt', "
510
+ );
511
+ } else {
512
+ config = config.replace(
513
+ /defineNuxtConfig\(\{/,
514
+ `defineNuxtConfig({
515
+ modules: ['@tthr/vue/nuxt'],
516
+ `
517
+ );
518
+ }
519
+ if (!config.includes("tether:")) {
520
+ config = config.replace(
521
+ /(\}|\]|'|"|true|false|\d)\s*\n(\s*}\);?\s*)$/,
522
+ `$1,
289
523
 
290
- console.log('Seeding complete!');
524
+ tether: {
525
+ projectId: process.env.TETHER_PROJECT_ID,
526
+ url: process.env.TETHER_URL || 'https://tthr.io',
527
+ },
528
+ });
529
+ `
530
+ );
531
+ }
532
+ await fs2.writeFile(configPath, config);
533
+ }
534
+ async function configureNext(projectPath) {
535
+ const providersPath = path2.join(projectPath, "src", "app", "providers.tsx");
536
+ await fs2.writeFile(
537
+ providersPath,
538
+ `'use client';
539
+
540
+ import { TetherProvider } from '@tthr/react';
541
+
542
+ export function Providers({ children }: { children: React.ReactNode }) {
543
+ return (
544
+ <TetherProvider
545
+ projectId={process.env.NEXT_PUBLIC_TETHER_PROJECT_ID!}
546
+ url={process.env.NEXT_PUBLIC_TETHER_URL || 'https://tthr.io'}
547
+ >
548
+ {children}
549
+ </TetherProvider>
550
+ );
291
551
  }
552
+ `
553
+ );
554
+ const layoutPath = path2.join(projectPath, "src", "app", "layout.tsx");
555
+ if (await fs2.pathExists(layoutPath)) {
556
+ let layout = await fs2.readFile(layoutPath, "utf-8");
557
+ if (!layout.includes("import { Providers }")) {
558
+ layout = layout.replace(
559
+ /^(import.*\n)+/m,
560
+ (match) => match + "import { Providers } from './providers';\n"
561
+ );
562
+ }
563
+ if (!layout.includes("<Providers>")) {
564
+ layout = layout.replace(
565
+ /(<body[^>]*>)([\s\S]*?)(<\/body>)/,
566
+ "$1<Providers>$2</Providers>$3"
567
+ );
568
+ }
569
+ await fs2.writeFile(layoutPath, layout);
570
+ }
571
+ const envLocalPath = path2.join(projectPath, ".env.local");
572
+ const nextEnvContent = `# Tether Configuration (client-side)
573
+ NEXT_PUBLIC_TETHER_PROJECT_ID=\${TETHER_PROJECT_ID}
574
+ NEXT_PUBLIC_TETHER_URL=\${TETHER_URL}
575
+ `;
576
+ if (await fs2.pathExists(envLocalPath)) {
577
+ const existing = await fs2.readFile(envLocalPath, "utf-8");
578
+ await fs2.writeFile(envLocalPath, existing + "\n" + nextEnvContent);
579
+ } else {
580
+ await fs2.writeFile(envLocalPath, nextEnvContent);
581
+ }
582
+ }
583
+ async function configureSvelteKit(projectPath) {
584
+ const libPath = path2.join(projectPath, "src", "lib");
585
+ await fs2.ensureDir(libPath);
586
+ await fs2.writeFile(
587
+ path2.join(libPath, "tether.ts"),
588
+ `import { createClient } from '@tthr/svelte';
589
+ import { PUBLIC_TETHER_PROJECT_ID, PUBLIC_TETHER_URL } from '$env/static/public';
590
+
591
+ export const tether = createClient({
592
+ projectId: PUBLIC_TETHER_PROJECT_ID,
593
+ url: PUBLIC_TETHER_URL || 'https://tthr.io',
594
+ });
595
+ `
596
+ );
597
+ const envPath = path2.join(projectPath, ".env");
598
+ const svelteEnvContent = `# Tether Configuration (public)
599
+ PUBLIC_TETHER_PROJECT_ID=\${TETHER_PROJECT_ID}
600
+ PUBLIC_TETHER_URL=\${TETHER_URL}
601
+ `;
602
+ if (await fs2.pathExists(envPath)) {
603
+ const existing = await fs2.readFile(envPath, "utf-8");
604
+ if (!existing.includes("PUBLIC_TETHER_")) {
605
+ await fs2.writeFile(envPath, existing + "\n" + svelteEnvContent);
606
+ }
607
+ }
608
+ }
609
+ async function installTetherPackages(projectPath, template) {
610
+ const [
611
+ tthrClientVersion,
612
+ tthrSchemaVersion,
613
+ tthrServerVersion,
614
+ tthrCliVersion
615
+ ] = await Promise.all([
616
+ getLatestVersion("@tthr/client"),
617
+ getLatestVersion("@tthr/schema"),
618
+ getLatestVersion("@tthr/server"),
619
+ getLatestVersion("@tthr/cli")
620
+ ]);
621
+ const packages = [
622
+ `@tthr/client@${tthrClientVersion}`,
623
+ `@tthr/schema@${tthrSchemaVersion}`,
624
+ `@tthr/server@${tthrServerVersion}`
625
+ ];
626
+ const devPackages = [
627
+ `@tthr/cli@${tthrCliVersion}`
628
+ ];
629
+ if (template === "nuxt") {
630
+ const tthrVueVersion = await getLatestVersion("@tthr/vue");
631
+ packages.push(`@tthr/vue@${tthrVueVersion}`);
632
+ } else if (template === "next") {
633
+ const tthrReactVersion = await getLatestVersion("@tthr/react");
634
+ packages.push(`@tthr/react@${tthrReactVersion}`);
635
+ } else if (template === "sveltekit") {
636
+ const tthrSvelteVersion = await getLatestVersion("@tthr/svelte");
637
+ packages.push(`@tthr/svelte@${tthrSvelteVersion}`);
638
+ }
639
+ try {
640
+ execSync(`npm install ${packages.join(" ")}`, {
641
+ cwd: projectPath,
642
+ stdio: "pipe"
643
+ });
644
+ execSync(`npm install -D ${devPackages.join(" ")}`, {
645
+ cwd: projectPath,
646
+ stdio: "pipe"
647
+ });
648
+ } catch (error) {
649
+ console.warn(chalk2.yellow("\nNote: Some Tether packages may not be published yet."));
650
+ }
651
+ }
652
+ async function createDemoPage(projectPath, template) {
653
+ if (template === "nuxt") {
654
+ const nuxt4AppVuePath = path2.join(projectPath, "app", "app.vue");
655
+ const nuxt3AppVuePath = path2.join(projectPath, "app.vue");
656
+ const appVuePath = await fs2.pathExists(path2.join(projectPath, "app")) ? nuxt4AppVuePath : nuxt3AppVuePath;
657
+ await fs2.writeFile(
658
+ appVuePath,
659
+ `<template>
660
+ <TetherWelcome />
661
+ </template>
292
662
  `
293
663
  );
664
+ } else if (template === "next") {
665
+ const pagePath = path2.join(projectPath, "src", "app", "page.tsx");
294
666
  await fs2.writeFile(
295
- path2.join(projectPath, ".env"),
296
- `# Tether Configuration
297
- TETHER_PROJECT_ID=${projectId}
298
- TETHER_URL=${isDev ? "http://localhost:3001" : "https://tthr.io"}
299
- TETHER_API_KEY=${apiKey}
667
+ pagePath,
668
+ `'use client';
669
+
670
+ import { useState } from 'react';
671
+ import { useQuery, useMutation } from '@tthr/react';
672
+
673
+ interface Post {
674
+ id: string;
675
+ title: string;
676
+ createdAt: string;
677
+ }
678
+
679
+ export default function Home() {
680
+ const [newPostTitle, setNewPostTitle] = useState('');
681
+ const { data: posts, isLoading, refetch } = useQuery<Post[]>('posts.list');
682
+ const { mutate: createPost, isPending } = useMutation('posts.create');
683
+ const { mutate: deletePost } = useMutation('posts.remove');
684
+
685
+ const handleCreatePost = async (e: React.FormEvent) => {
686
+ e.preventDefault();
687
+ if (!newPostTitle.trim()) return;
688
+
689
+ await createPost({ title: newPostTitle.trim(), content: '' });
690
+ setNewPostTitle('');
691
+ refetch();
692
+ };
693
+
694
+ const handleDeletePost = async (id: string) => {
695
+ await deletePost({ id });
696
+ refetch();
697
+ };
698
+
699
+ return (
700
+ <main className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 text-white p-8">
701
+ <div className="max-w-2xl mx-auto space-y-8">
702
+ <header className="text-center space-y-4">
703
+ <h1 className="text-4xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
704
+ Welcome to Tether
705
+ </h1>
706
+ <p className="text-gray-400">Realtime SQLite for modern apps</p>
707
+ </header>
708
+
709
+ <section className="bg-white/5 border border-white/10 rounded-xl p-6 space-y-4">
710
+ <h2 className="text-xl font-semibold">Try it out</h2>
711
+ <p className="text-gray-400 text-sm">Create posts in realtime. Open in multiple tabs to see live updates!</p>
712
+
713
+ <form onSubmit={handleCreatePost} className="flex gap-3">
714
+ <input
715
+ type="text"
716
+ value={newPostTitle}
717
+ onChange={(e) => setNewPostTitle(e.target.value)}
718
+ placeholder="Enter a post title..."
719
+ 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"
720
+ disabled={isPending}
721
+ />
722
+ <button
723
+ type="submit"
724
+ disabled={!newPostTitle.trim() || isPending}
725
+ 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"
726
+ >
727
+ {isPending ? 'Creating...' : 'Create Post'}
728
+ </button>
729
+ </form>
730
+
731
+ <div className="space-y-2 min-h-[100px]">
732
+ {isLoading ? (
733
+ <p className="text-gray-500 text-center py-8">Loading posts...</p>
734
+ ) : posts?.length ? (
735
+ posts.map((post) => (
736
+ <div
737
+ key={post.id}
738
+ className="flex items-center justify-between p-3 bg-black/20 border border-white/5 rounded-lg hover:bg-black/30 transition"
739
+ >
740
+ <div>
741
+ <h3 className="font-medium">{post.title}</h3>
742
+ <time className="text-xs text-gray-500">
743
+ {new Date(post.createdAt).toLocaleDateString('en-GB', {
744
+ day: 'numeric',
745
+ month: 'short',
746
+ year: 'numeric',
747
+ })}
748
+ </time>
749
+ </div>
750
+ <button
751
+ onClick={() => handleDeletePost(post.id)}
752
+ className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-400/10 rounded transition"
753
+ >
754
+ <svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
755
+ <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" />
756
+ </svg>
757
+ </button>
758
+ </div>
759
+ ))
760
+ ) : (
761
+ <p className="text-gray-500 text-center py-8">No posts yet. Create your first one above!</p>
762
+ )}
763
+ </div>
764
+ </section>
765
+
766
+ <footer className="text-center text-gray-600 text-sm border-t border-white/5 pt-6">
767
+ Built with Tether by <a href="https://strands.gg" className="text-indigo-400 hover:underline">Strands Services</a>
768
+ </footer>
769
+ </div>
770
+ </main>
771
+ );
772
+ }
300
773
  `
301
774
  );
775
+ } else if (template === "sveltekit") {
776
+ const pagePath = path2.join(projectPath, "src", "routes", "+page.svelte");
302
777
  await fs2.writeFile(
303
- path2.join(projectPath, ".gitignore"),
304
- `# Dependencies
305
- node_modules/
778
+ pagePath,
779
+ `<script lang="ts">
780
+ import { onMount } from 'svelte';
781
+ import { tether } from '$lib/tether';
306
782
 
307
- # Build output
308
- dist/
309
- .output/
783
+ interface Post {
784
+ id: string;
785
+ title: string;
786
+ createdAt: string;
787
+ }
310
788
 
311
- # Generated files
312
- _generated/
789
+ let posts: Post[] = [];
790
+ let isLoading = true;
791
+ let newPostTitle = '';
792
+ let isPending = false;
313
793
 
314
- # Environment
315
- .env
316
- .env.local
317
- .env.*.local
794
+ onMount(async () => {
795
+ await loadPosts();
796
+ });
318
797
 
319
- # IDE
320
- .idea/
321
- .vscode/
322
- *.swp
323
- *.swo
798
+ async function loadPosts() {
799
+ isLoading = true;
800
+ try {
801
+ posts = await tether.query('posts.list');
802
+ } catch (e) {
803
+ console.error('Failed to load posts:', e);
804
+ } finally {
805
+ isLoading = false;
806
+ }
807
+ }
324
808
 
325
- # OS
326
- .DS_Store
327
- Thumbs.db
809
+ async function handleCreatePost() {
810
+ if (!newPostTitle.trim()) return;
811
+ isPending = true;
812
+ try {
813
+ await tether.mutation('posts.create', { title: newPostTitle.trim(), content: '' });
814
+ newPostTitle = '';
815
+ await loadPosts();
816
+ } catch (e) {
817
+ console.error('Failed to create post:', e);
818
+ } finally {
819
+ isPending = false;
820
+ }
821
+ }
822
+
823
+ async function handleDeletePost(id: string) {
824
+ try {
825
+ await tether.mutation('posts.remove', { id });
826
+ await loadPosts();
827
+ } catch (e) {
828
+ console.error('Failed to delete post:', e);
829
+ }
830
+ }
831
+
832
+ function formatDate(dateString: string): string {
833
+ return new Date(dateString).toLocaleDateString('en-GB', {
834
+ day: 'numeric',
835
+ month: 'short',
836
+ year: 'numeric',
837
+ });
838
+ }
839
+ </script>
840
+
841
+ <main>
842
+ <div class="container">
843
+ <header>
844
+ <h1>Welcome to Tether</h1>
845
+ <p>Realtime SQLite for modern apps</p>
846
+ </header>
847
+
848
+ <section class="demo">
849
+ <h2>Try it out</h2>
850
+ <p class="intro">Create posts in realtime. Open in multiple tabs to see live updates!</p>
851
+
852
+ <form on:submit|preventDefault={handleCreatePost}>
853
+ <input
854
+ type="text"
855
+ bind:value={newPostTitle}
856
+ placeholder="Enter a post title..."
857
+ disabled={isPending}
858
+ />
859
+ <button type="submit" disabled={!newPostTitle.trim() || isPending}>
860
+ {isPending ? 'Creating...' : 'Create Post'}
861
+ </button>
862
+ </form>
863
+
864
+ <div class="posts">
865
+ {#if isLoading}
866
+ <p class="empty">Loading posts...</p>
867
+ {:else if posts.length}
868
+ {#each posts as post (post.id)}
869
+ <article>
870
+ <div class="content">
871
+ <h3>{post.title}</h3>
872
+ <time>{formatDate(post.createdAt)}</time>
873
+ </div>
874
+ <button class="delete" on:click={() => handleDeletePost(post.id)}>
875
+ Delete
876
+ </button>
877
+ </article>
878
+ {/each}
879
+ {:else}
880
+ <p class="empty">No posts yet. Create your first one above!</p>
881
+ {/if}
882
+ </div>
883
+ </section>
884
+
885
+ <footer>
886
+ Built with Tether by <a href="https://strands.gg">Strands Services</a>
887
+ </footer>
888
+ </div>
889
+ </main>
890
+
891
+ <style>
892
+ main {
893
+ min-height: 100vh;
894
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
895
+ color: #e4e4e7;
896
+ font-family: system-ui, sans-serif;
897
+ padding: 2rem;
898
+ }
899
+
900
+ .container {
901
+ max-width: 640px;
902
+ margin: 0 auto;
903
+ display: flex;
904
+ flex-direction: column;
905
+ gap: 2rem;
906
+ }
907
+
908
+ header {
909
+ text-align: center;
910
+ }
911
+
912
+ h1 {
913
+ font-size: 2.5rem;
914
+ background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
915
+ -webkit-background-clip: text;
916
+ -webkit-text-fill-color: transparent;
917
+ margin: 0 0 0.5rem;
918
+ }
919
+
920
+ header p {
921
+ color: #a1a1aa;
922
+ margin: 0;
923
+ }
924
+
925
+ .demo {
926
+ background: rgba(255, 255, 255, 0.03);
927
+ border: 1px solid rgba(255, 255, 255, 0.1);
928
+ border-radius: 1rem;
929
+ padding: 1.5rem;
930
+ }
931
+
932
+ .demo h2 {
933
+ margin: 0 0 0.5rem;
934
+ font-size: 1.25rem;
935
+ }
936
+
937
+ .intro {
938
+ color: #a1a1aa;
939
+ font-size: 0.875rem;
940
+ margin: 0 0 1rem;
941
+ }
942
+
943
+ form {
944
+ display: flex;
945
+ gap: 0.75rem;
946
+ margin-bottom: 1rem;
947
+ }
948
+
949
+ input {
950
+ flex: 1;
951
+ padding: 0.75rem 1rem;
952
+ background: rgba(0, 0, 0, 0.3);
953
+ border: 1px solid rgba(255, 255, 255, 0.1);
954
+ border-radius: 0.5rem;
955
+ color: #e4e4e7;
956
+ font-size: 0.875rem;
957
+ }
958
+
959
+ input:focus {
960
+ outline: none;
961
+ border-color: #6366f1;
962
+ }
963
+
964
+ button {
965
+ padding: 0.75rem 1.5rem;
966
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
967
+ border: none;
968
+ border-radius: 0.5rem;
969
+ color: white;
970
+ font-weight: 500;
971
+ cursor: pointer;
972
+ }
973
+
974
+ button:disabled {
975
+ opacity: 0.5;
976
+ cursor: not-allowed;
977
+ }
978
+
979
+ .posts {
980
+ display: flex;
981
+ flex-direction: column;
982
+ gap: 0.5rem;
983
+ min-height: 100px;
984
+ }
985
+
986
+ .empty {
987
+ color: #71717a;
988
+ text-align: center;
989
+ padding: 2rem;
990
+ }
991
+
992
+ article {
993
+ display: flex;
994
+ align-items: center;
995
+ justify-content: space-between;
996
+ padding: 0.875rem 1rem;
997
+ background: rgba(0, 0, 0, 0.2);
998
+ border: 1px solid rgba(255, 255, 255, 0.05);
999
+ border-radius: 0.5rem;
1000
+ }
1001
+
1002
+ .content h3 {
1003
+ margin: 0 0 0.25rem;
1004
+ font-size: 0.9375rem;
1005
+ }
1006
+
1007
+ .content time {
1008
+ font-size: 0.75rem;
1009
+ color: #71717a;
1010
+ }
1011
+
1012
+ .delete {
1013
+ padding: 0.5rem 1rem;
1014
+ background: transparent;
1015
+ border: 1px solid rgba(239, 68, 68, 0.3);
1016
+ color: #fca5a5;
1017
+ font-size: 0.75rem;
1018
+ }
1019
+
1020
+ .delete:hover {
1021
+ background: rgba(239, 68, 68, 0.1);
1022
+ }
1023
+
1024
+ footer {
1025
+ text-align: center;
1026
+ color: #52525b;
1027
+ font-size: 0.8125rem;
1028
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
1029
+ padding-top: 1rem;
1030
+ }
1031
+
1032
+ footer a {
1033
+ color: #6366f1;
1034
+ text-decoration: none;
1035
+ }
1036
+ </style>
328
1037
  `
329
1038
  );
330
- spinner.text = "Fetching latest package versions...";
331
- const [
332
- tthrClientVersion,
333
- tthrSchemaVersion,
334
- tthrServerVersion,
335
- tthrCliVersion,
336
- typescriptVersion,
337
- tsxVersion
338
- ] = await Promise.all([
339
- getLatestVersion("@tthr/client"),
340
- getLatestVersion("@tthr/schema"),
341
- getLatestVersion("@tthr/server"),
342
- getLatestVersion("@tthr/cli"),
343
- getLatestVersion("typescript"),
344
- getLatestVersion("tsx")
345
- ]);
346
- const dependencies = {
347
- "@tthr/client": tthrClientVersion,
348
- "@tthr/schema": tthrSchemaVersion,
349
- "@tthr/server": tthrServerVersion
350
- };
351
- if (template === "vue") {
352
- const [tthrVueVersion, vueVersion] = await Promise.all([
353
- getLatestVersion("@tthr/vue"),
354
- getLatestVersion("vue")
355
- ]);
356
- dependencies["@tthr/vue"] = tthrVueVersion;
357
- dependencies["vue"] = vueVersion;
358
- } else if (template === "react") {
359
- const [tthrReactVersion, reactVersion, reactDomVersion] = await Promise.all([
360
- getLatestVersion("@tthr/react"),
361
- getLatestVersion("react"),
362
- getLatestVersion("react-dom")
363
- ]);
364
- dependencies["@tthr/react"] = tthrReactVersion;
365
- dependencies["react"] = reactVersion;
366
- dependencies["react-dom"] = reactDomVersion;
367
- } else if (template === "svelte") {
368
- const [tthrSvelteVersion, svelteVersion] = await Promise.all([
369
- getLatestVersion("@tthr/svelte"),
370
- getLatestVersion("svelte")
371
- ]);
372
- dependencies["@tthr/svelte"] = tthrSvelteVersion;
373
- dependencies["svelte"] = svelteVersion;
374
- }
375
- const packageJson = {
376
- name: projectName,
377
- version: "0.0.1",
378
- private: true,
379
- type: "module",
380
- scripts: {
381
- "dev": "tthr dev",
382
- "generate": "tthr generate",
383
- "migrate": "tthr migrate",
384
- "seed": "tsx tether/seed.ts"
385
- },
386
- dependencies,
387
- devDependencies: {
388
- "@tthr/cli": tthrCliVersion,
389
- "typescript": typescriptVersion,
390
- "tsx": tsxVersion
391
- }
392
- };
393
- await fs2.writeJSON(path2.join(projectPath, "package.json"), packageJson, { spaces: 2 });
394
- await fs2.writeJSON(
395
- path2.join(projectPath, "tsconfig.json"),
396
- {
397
- compilerOptions: {
398
- target: "ES2022",
399
- module: "ESNext",
400
- moduleResolution: "bundler",
401
- strict: true,
402
- esModuleInterop: true,
403
- skipLibCheck: true,
404
- forceConsistentCasingInFileNames: true,
405
- paths: {
406
- "@/*": ["./*"]
407
- }
408
- },
409
- include: ["**/*.ts"],
410
- exclude: ["node_modules", "dist"]
411
- },
412
- { spaces: 2 }
1039
+ }
1040
+ }
1041
+ async function runInitialMigration(projectPath, projectId, apiKey) {
1042
+ const migrationSql = `
1043
+ CREATE TABLE IF NOT EXISTS posts (
1044
+ id TEXT PRIMARY KEY,
1045
+ title TEXT NOT NULL,
1046
+ content TEXT,
1047
+ authorId TEXT NOT NULL,
1048
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
1049
+ updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
413
1050
  );
414
- spinner.succeed("Project structure created");
415
- console.log("\n" + chalk2.green("\u2713") + " Project created successfully!\n");
416
- console.log("Next steps:\n");
417
- console.log(chalk2.cyan(` cd ${projectName}`));
418
- console.log(chalk2.cyan(" npm install"));
419
- console.log(chalk2.cyan(" npm run dev"));
420
- console.log("\n" + chalk2.dim("For more information, visit https://tthr.io/docs\n"));
421
- } catch (error) {
422
- spinner.fail("Failed to create project");
423
- console.error(chalk2.red(error instanceof Error ? error.message : "Unknown error"));
424
- process.exit(1);
1051
+
1052
+ CREATE TABLE IF NOT EXISTS comments (
1053
+ id TEXT PRIMARY KEY,
1054
+ postId TEXT NOT NULL REFERENCES posts(id),
1055
+ content TEXT NOT NULL,
1056
+ authorId TEXT NOT NULL,
1057
+ createdAt TEXT NOT NULL DEFAULT (datetime('now'))
1058
+ );
1059
+
1060
+ CREATE INDEX IF NOT EXISTS idx_posts_authorId ON posts(authorId);
1061
+ CREATE INDEX IF NOT EXISTS idx_posts_createdAt ON posts(createdAt);
1062
+ CREATE INDEX IF NOT EXISTS idx_comments_postId ON comments(postId);
1063
+ `;
1064
+ try {
1065
+ const response = await fetch(`${API_URL}/${projectId}/mutation`, {
1066
+ method: "POST",
1067
+ headers: {
1068
+ "Content-Type": "application/json",
1069
+ "Authorization": `Bearer ${apiKey}`
1070
+ },
1071
+ body: JSON.stringify({
1072
+ function: "_migrate",
1073
+ args: { sql: migrationSql }
1074
+ })
1075
+ });
1076
+ if (!response.ok) {
1077
+ }
1078
+ } catch {
425
1079
  }
426
1080
  }
427
1081
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tthr/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Tether CLI - project scaffolding, migrations, and deployment",
5
5
  "type": "module",
6
6
  "bin": {