tthr 0.0.34 → 0.0.35

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.
@@ -0,0 +1,418 @@
1
+ // src/commands/generate.ts
2
+ import chalk2 from "chalk";
3
+ import ora from "ora";
4
+ import fs3 from "fs-extra";
5
+ import path3 from "path";
6
+
7
+ // src/utils/auth.ts
8
+ import chalk from "chalk";
9
+ import fs from "fs-extra";
10
+ import path from "path";
11
+ var CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "", ".tether");
12
+ var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
13
+ async function getCredentials() {
14
+ try {
15
+ if (await fs.pathExists(CREDENTIALS_FILE)) {
16
+ return await fs.readJSON(CREDENTIALS_FILE);
17
+ }
18
+ } catch {
19
+ }
20
+ return null;
21
+ }
22
+ async function requireAuth() {
23
+ const credentials = await getCredentials();
24
+ if (!credentials) {
25
+ console.error(chalk.red("\n\u2717 Not logged in\n"));
26
+ console.log(chalk.dim("Run `tthr login` to authenticate\n"));
27
+ process.exit(1);
28
+ }
29
+ if (credentials.expiresAt) {
30
+ const expiresAt = new Date(credentials.expiresAt);
31
+ if (/* @__PURE__ */ new Date() > expiresAt) {
32
+ console.error(chalk.red("\n\u2717 Session expired\n"));
33
+ console.log(chalk.dim("Run `tthr login` to authenticate\n"));
34
+ process.exit(1);
35
+ }
36
+ }
37
+ return credentials;
38
+ }
39
+ async function saveCredentials(credentials) {
40
+ await fs.ensureDir(CONFIG_DIR);
41
+ await fs.writeJSON(CREDENTIALS_FILE, credentials, { spaces: 2, mode: 384 });
42
+ }
43
+ async function clearCredentials() {
44
+ try {
45
+ await fs.remove(CREDENTIALS_FILE);
46
+ } catch {
47
+ }
48
+ }
49
+
50
+ // src/utils/config.ts
51
+ import fs2 from "fs-extra";
52
+ import path2 from "path";
53
+ var DEFAULT_CONFIG = {
54
+ schema: "./tether/schema.ts",
55
+ functions: "./tether/functions",
56
+ output: "./tether/_generated",
57
+ dev: {
58
+ port: 3001,
59
+ host: "localhost"
60
+ },
61
+ database: {
62
+ walMode: true
63
+ }
64
+ };
65
+ async function loadConfig(cwd = process.cwd()) {
66
+ const configPath = path2.resolve(cwd, "tether.config.ts");
67
+ if (!await fs2.pathExists(configPath)) {
68
+ return DEFAULT_CONFIG;
69
+ }
70
+ const configSource = await fs2.readFile(configPath, "utf-8");
71
+ const config = {};
72
+ const schemaMatch = configSource.match(/schema\s*:\s*['"]([^'"]+)['"]/);
73
+ if (schemaMatch) {
74
+ config.schema = schemaMatch[1];
75
+ }
76
+ const functionsMatch = configSource.match(/functions\s*:\s*['"]([^'"]+)['"]/);
77
+ if (functionsMatch) {
78
+ config.functions = functionsMatch[1];
79
+ }
80
+ const outputMatch = configSource.match(/output\s*:\s*['"]([^'"]+)['"]/);
81
+ if (outputMatch) {
82
+ config.output = outputMatch[1];
83
+ }
84
+ const envMatch = configSource.match(/environment\s*:\s*['"]([^'"]+)['"]/);
85
+ if (envMatch) {
86
+ config.environment = envMatch[1];
87
+ }
88
+ const portMatch = configSource.match(/port\s*:\s*(\d+)/);
89
+ if (portMatch) {
90
+ config.dev = { ...config.dev, port: parseInt(portMatch[1], 10) };
91
+ }
92
+ const hostMatch = configSource.match(/host\s*:\s*['"]([^'"]+)['"]/);
93
+ if (hostMatch) {
94
+ config.dev = { ...config.dev, host: hostMatch[1] };
95
+ }
96
+ return {
97
+ ...DEFAULT_CONFIG,
98
+ ...config,
99
+ dev: { ...DEFAULT_CONFIG.dev, ...config.dev },
100
+ database: { ...DEFAULT_CONFIG.database, ...config.database }
101
+ };
102
+ }
103
+ function resolvePath(configPath, cwd = process.cwd()) {
104
+ const normalised = configPath.replace(/^\.\//, "");
105
+ return path2.resolve(cwd, normalised);
106
+ }
107
+ async function detectFramework(cwd = process.cwd()) {
108
+ const packageJsonPath = path2.resolve(cwd, "package.json");
109
+ if (!await fs2.pathExists(packageJsonPath)) {
110
+ return "unknown";
111
+ }
112
+ try {
113
+ const packageJson = await fs2.readJson(packageJsonPath);
114
+ const deps = {
115
+ ...packageJson.dependencies,
116
+ ...packageJson.devDependencies
117
+ };
118
+ if (deps.nuxt || deps["@nuxt/kit"]) {
119
+ return "nuxt";
120
+ }
121
+ if (deps.next) {
122
+ return "next";
123
+ }
124
+ if (deps["@sveltejs/kit"]) {
125
+ return "sveltekit";
126
+ }
127
+ if (deps.vite && !deps.nuxt && !deps.next && !deps["@sveltejs/kit"]) {
128
+ return "vite";
129
+ }
130
+ return "vanilla";
131
+ } catch {
132
+ return "unknown";
133
+ }
134
+ }
135
+ function getFrameworkDevCommand(framework) {
136
+ switch (framework) {
137
+ case "nuxt":
138
+ return "nuxt dev";
139
+ case "next":
140
+ return "next dev";
141
+ case "sveltekit":
142
+ return "vite dev";
143
+ case "vite":
144
+ return "vite dev";
145
+ case "vanilla":
146
+ return null;
147
+ // No default dev server for vanilla
148
+ default:
149
+ return null;
150
+ }
151
+ }
152
+
153
+ // src/commands/generate.ts
154
+ function parseSchemaFile(source) {
155
+ const tables = [];
156
+ const schemaMatch = source.match(/defineSchema\s*\(\s*\{([\s\S]*)\}\s*\)/);
157
+ if (!schemaMatch) return tables;
158
+ const schemaContent = schemaMatch[1];
159
+ const tableStartRegex = /(\w+)\s*:\s*\{/g;
160
+ let match;
161
+ while ((match = tableStartRegex.exec(schemaContent)) !== null) {
162
+ const tableName = match[1];
163
+ const startOffset = match.index + match[0].length;
164
+ let braceCount = 1;
165
+ let endOffset = startOffset;
166
+ for (let i = startOffset; i < schemaContent.length && braceCount > 0; i++) {
167
+ const char = schemaContent[i];
168
+ if (char === "{") braceCount++;
169
+ else if (char === "}") braceCount--;
170
+ endOffset = i;
171
+ }
172
+ const columnsContent = schemaContent.slice(startOffset, endOffset);
173
+ const columns = parseColumns(columnsContent);
174
+ tables.push({ name: tableName, columns });
175
+ }
176
+ return tables;
177
+ }
178
+ function parseColumns(content) {
179
+ const columns = [];
180
+ const columnRegex = /(\w+)\s*:\s*(\w+)\s*\(\s*\)([^,\n}]*)/g;
181
+ let match;
182
+ while ((match = columnRegex.exec(content)) !== null) {
183
+ const name = match[1];
184
+ const schemaType = match[2];
185
+ const modifiers = match[3] || "";
186
+ const nullable = !modifiers.includes(".notNull()") && !modifiers.includes(".primaryKey()");
187
+ const primaryKey = modifiers.includes(".primaryKey()");
188
+ columns.push({
189
+ name,
190
+ type: schemaTypeToTS(schemaType),
191
+ nullable,
192
+ primaryKey
193
+ });
194
+ }
195
+ return columns;
196
+ }
197
+ function schemaTypeToTS(schemaType) {
198
+ switch (schemaType) {
199
+ case "text":
200
+ return "string";
201
+ case "integer":
202
+ return "number";
203
+ case "real":
204
+ return "number";
205
+ case "boolean":
206
+ return "boolean";
207
+ case "timestamp":
208
+ return "string";
209
+ case "json":
210
+ return "unknown";
211
+ case "blob":
212
+ return "Uint8Array";
213
+ default:
214
+ return "unknown";
215
+ }
216
+ }
217
+ function tableNameToInterface(tableName) {
218
+ let name = tableName;
219
+ if (name.endsWith("ies")) {
220
+ name = name.slice(0, -3) + "y";
221
+ } else if (name.endsWith("s") && !name.endsWith("ss")) {
222
+ name = name.slice(0, -1);
223
+ }
224
+ return name.charAt(0).toUpperCase() + name.slice(1);
225
+ }
226
+ function generateDbFile(tables) {
227
+ const lines = [
228
+ "// Auto-generated by Tether CLI - do not edit manually",
229
+ `// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}`,
230
+ "",
231
+ "import { createDatabaseProxy, type TetherDatabase } from '@tthr/client';",
232
+ ""
233
+ ];
234
+ for (const table of tables) {
235
+ const interfaceName = tableNameToInterface(table.name);
236
+ lines.push(`export interface ${interfaceName} {`);
237
+ for (const col of table.columns) {
238
+ const typeStr = col.nullable ? `${col.type} | null` : col.type;
239
+ lines.push(` ${col.name}: ${typeStr};`);
240
+ }
241
+ lines.push("}");
242
+ lines.push("");
243
+ }
244
+ lines.push("export interface Schema {");
245
+ for (const table of tables) {
246
+ const interfaceName = tableNameToInterface(table.name);
247
+ lines.push(` ${table.name}: ${interfaceName};`);
248
+ }
249
+ lines.push("}");
250
+ lines.push("");
251
+ lines.push("// Database client with typed tables");
252
+ lines.push("// This is a proxy that will be populated by the Tether runtime");
253
+ lines.push("export const db: TetherDatabase<Schema> = createDatabaseProxy<Schema>();");
254
+ lines.push("");
255
+ return lines.join("\n");
256
+ }
257
+ async function parseFunctionsDir(functionsDir) {
258
+ const functions = [];
259
+ if (!await fs3.pathExists(functionsDir)) {
260
+ return functions;
261
+ }
262
+ const files = await fs3.readdir(functionsDir);
263
+ for (const file of files) {
264
+ if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
265
+ const filePath = path3.join(functionsDir, file);
266
+ const stat = await fs3.stat(filePath);
267
+ if (!stat.isFile()) continue;
268
+ const moduleName = file.replace(/\.(ts|js)$/, "");
269
+ const source = await fs3.readFile(filePath, "utf-8");
270
+ const exportRegex = /export\s+const\s+(\w+)\s*=\s*(query|mutation|action)\s*\(/g;
271
+ let match;
272
+ while ((match = exportRegex.exec(source)) !== null) {
273
+ functions.push({
274
+ name: match[1],
275
+ moduleName
276
+ });
277
+ }
278
+ }
279
+ return functions;
280
+ }
281
+ async function generateApiFile(functionsDir) {
282
+ const functions = await parseFunctionsDir(functionsDir);
283
+ const moduleMap = /* @__PURE__ */ new Map();
284
+ for (const fn of functions) {
285
+ if (!moduleMap.has(fn.moduleName)) {
286
+ moduleMap.set(fn.moduleName, []);
287
+ }
288
+ moduleMap.get(fn.moduleName).push(fn.name);
289
+ }
290
+ const lines = [
291
+ "// Auto-generated by Tether CLI - do not edit manually",
292
+ `// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}`,
293
+ "",
294
+ "import { createApiProxy } from '@tthr/client';",
295
+ "",
296
+ "/**",
297
+ " * API function reference type for useQuery/useMutation.",
298
+ ' * The _name property contains the function path (e.g., "users.list").',
299
+ " */",
300
+ "export interface ApiFunction<TArgs = unknown, TResult = unknown> {",
301
+ " _name: string;",
302
+ " _args?: TArgs;",
303
+ " _result?: TResult;",
304
+ "}",
305
+ ""
306
+ ];
307
+ if (moduleMap.size > 0) {
308
+ for (const [moduleName, fnNames] of moduleMap) {
309
+ const interfaceName = moduleName.charAt(0).toUpperCase() + moduleName.slice(1) + "Api";
310
+ lines.push(`export interface ${interfaceName} {`);
311
+ for (const fnName of fnNames) {
312
+ lines.push(` ${fnName}: ApiFunction;`);
313
+ }
314
+ lines.push("}");
315
+ lines.push("");
316
+ }
317
+ lines.push("export interface Api {");
318
+ for (const moduleName of moduleMap.keys()) {
319
+ const interfaceName = moduleName.charAt(0).toUpperCase() + moduleName.slice(1) + "Api";
320
+ lines.push(` ${moduleName}: ${interfaceName};`);
321
+ }
322
+ lines.push("}");
323
+ } else {
324
+ lines.push("/**");
325
+ lines.push(" * Flexible API type that allows access to any function path.");
326
+ lines.push(" * Functions are accessed as api.moduleName.functionName");
327
+ lines.push(" * The actual types depend on your function definitions.");
328
+ lines.push(" */");
329
+ lines.push("export type Api = {");
330
+ lines.push(" [module: string]: {");
331
+ lines.push(" [fn: string]: ApiFunction;");
332
+ lines.push(" };");
333
+ lines.push("};");
334
+ }
335
+ lines.push("");
336
+ lines.push("// API client proxy - provides typed access to your functions");
337
+ lines.push("// On the client: returns { _name } references for useQuery/useMutation");
338
+ lines.push("// In Tether functions: calls the actual function implementation");
339
+ lines.push("export const api = createApiProxy<Api>();");
340
+ lines.push("");
341
+ return lines.join("\n");
342
+ }
343
+ async function generateTypes(options = {}) {
344
+ const config = await loadConfig();
345
+ const schemaPath = resolvePath(config.schema);
346
+ const outputDir = resolvePath(config.output);
347
+ const functionsDir = resolvePath(config.functions);
348
+ if (!await fs3.pathExists(schemaPath)) {
349
+ throw new Error(`Schema file not found: ${schemaPath}`);
350
+ }
351
+ const schemaSource = await fs3.readFile(schemaPath, "utf-8");
352
+ const tables = parseSchemaFile(schemaSource);
353
+ await fs3.ensureDir(outputDir);
354
+ await fs3.writeFile(
355
+ path3.join(outputDir, "db.ts"),
356
+ generateDbFile(tables)
357
+ );
358
+ await fs3.writeFile(
359
+ path3.join(outputDir, "api.ts"),
360
+ await generateApiFile(functionsDir)
361
+ );
362
+ await fs3.writeFile(
363
+ path3.join(outputDir, "index.ts"),
364
+ `// Auto-generated by Tether CLI - do not edit manually
365
+ export * from './db';
366
+ export * from './api';
367
+ `
368
+ );
369
+ return { tables, outputDir };
370
+ }
371
+ async function generateCommand() {
372
+ await requireAuth();
373
+ const configPath = path3.resolve(process.cwd(), "tether.config.ts");
374
+ if (!await fs3.pathExists(configPath)) {
375
+ console.log(chalk2.red("\nError: Not a Tether project"));
376
+ console.log(chalk2.dim("Run `tthr init` to create a new project\n"));
377
+ process.exit(1);
378
+ }
379
+ console.log(chalk2.bold("\n\u26A1 Generating types from schema\n"));
380
+ const spinner = ora("Reading schema...").start();
381
+ try {
382
+ spinner.text = "Generating types...";
383
+ const { tables, outputDir } = await generateTypes();
384
+ if (tables.length === 0) {
385
+ spinner.warn("No tables found in schema");
386
+ console.log(chalk2.dim(" Make sure your schema uses defineSchema({ ... })\n"));
387
+ return;
388
+ }
389
+ spinner.succeed(`Types generated for ${tables.length} table(s)`);
390
+ console.log("\n" + chalk2.green("\u2713") + " Tables:");
391
+ for (const table of tables) {
392
+ console.log(chalk2.dim(` - ${table.name} (${table.columns.length} columns)`));
393
+ }
394
+ const relativeOutput = path3.relative(process.cwd(), outputDir);
395
+ console.log("\n" + chalk2.green("\u2713") + " Generated files:");
396
+ console.log(chalk2.dim(` ${relativeOutput}/db.ts`));
397
+ console.log(chalk2.dim(` ${relativeOutput}/api.ts`));
398
+ console.log(chalk2.dim(` ${relativeOutput}/index.ts
399
+ `));
400
+ } catch (error) {
401
+ spinner.fail("Failed to generate types");
402
+ console.error(chalk2.red(error instanceof Error ? error.message : "Unknown error"));
403
+ process.exit(1);
404
+ }
405
+ }
406
+
407
+ export {
408
+ getCredentials,
409
+ requireAuth,
410
+ saveCredentials,
411
+ clearCredentials,
412
+ loadConfig,
413
+ resolvePath,
414
+ detectFramework,
415
+ getFrameworkDevCommand,
416
+ generateTypes,
417
+ generateCommand
418
+ };
@@ -0,0 +1,8 @@
1
+ import {
2
+ generateCommand,
3
+ generateTypes
4
+ } from "./chunk-LXODXQ5V.js";
5
+ export {
6
+ generateCommand,
7
+ generateTypes
8
+ };
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  requireAuth,
10
10
  resolvePath,
11
11
  saveCredentials
12
- } from "./chunk-ZWLVHKNL.js";
12
+ } from "./chunk-LXODXQ5V.js";
13
13
 
14
14
  // src/index.ts
15
15
  import { Command } from "commander";
@@ -49,8 +49,10 @@ async function deployCommand(options) {
49
49
  console.log(chalk.dim("Make sure TETHER_PROJECT_ID is set in your .env file\n"));
50
50
  process.exit(1);
51
51
  }
52
+ const environment = options.env || "development";
52
53
  console.log(chalk.bold("\n\u26A1 Deploying to Tether\n"));
53
54
  console.log(chalk.dim(` Project: ${projectId}`));
55
+ console.log(chalk.dim(` Environment: ${environment}`));
54
56
  console.log(chalk.dim(` API: ${API_URL}
55
57
  `));
56
58
  const config = await loadConfig();
@@ -58,15 +60,15 @@ async function deployCommand(options) {
58
60
  const deployFunctions = options.functions || !options.schema && !options.functions;
59
61
  if (deploySchema) {
60
62
  const schemaPath = resolvePath(config.schema);
61
- await deploySchemaToServer(projectId, credentials.accessToken, schemaPath, options.dryRun);
63
+ await deploySchemaToServer(projectId, credentials.accessToken, schemaPath, environment, options.dryRun);
62
64
  }
63
65
  if (deployFunctions) {
64
66
  const functionsDir = resolvePath(config.functions);
65
- await deployFunctionsToServer(projectId, credentials.accessToken, functionsDir, options.dryRun);
67
+ await deployFunctionsToServer(projectId, credentials.accessToken, functionsDir, environment, options.dryRun);
66
68
  }
67
69
  console.log(chalk.green("\n\u2713 Deployment complete\n"));
68
70
  }
69
- async function deploySchemaToServer(projectId, token, schemaPath, dryRun) {
71
+ async function deploySchemaToServer(projectId, token, schemaPath, environment, dryRun) {
70
72
  const spinner = ora("Reading schema...").start();
71
73
  try {
72
74
  if (!await fs.pathExists(schemaPath)) {
@@ -90,7 +92,8 @@ async function deploySchemaToServer(projectId, token, schemaPath, dryRun) {
90
92
  return;
91
93
  }
92
94
  spinner.text = "Deploying schema...";
93
- const response = await fetch(`${API_URL}/projects/${projectId}/deploy/schema`, {
95
+ const envPath = environment !== "production" ? `/env/${environment}` : "";
96
+ const response = await fetch(`${API_URL}/projects/${projectId}${envPath}/deploy/schema`, {
94
97
  method: "POST",
95
98
  headers: {
96
99
  "Content-Type": "application/json",
@@ -115,7 +118,7 @@ async function deploySchemaToServer(projectId, token, schemaPath, dryRun) {
115
118
  console.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
116
119
  }
117
120
  }
118
- async function deployFunctionsToServer(projectId, token, functionsDir, dryRun) {
121
+ async function deployFunctionsToServer(projectId, token, functionsDir, environment, dryRun) {
119
122
  const spinner = ora("Reading functions...").start();
120
123
  try {
121
124
  if (!await fs.pathExists(functionsDir)) {
@@ -149,7 +152,8 @@ async function deployFunctionsToServer(projectId, token, functionsDir, dryRun) {
149
152
  return;
150
153
  }
151
154
  spinner.text = "Deploying functions...";
152
- const response = await fetch(`${API_URL}/projects/${projectId}/deploy/functions`, {
155
+ const envPath = environment !== "production" ? `/env/${environment}` : "";
156
+ const response = await fetch(`${API_URL}/projects/${projectId}${envPath}/deploy/functions`, {
153
157
  method: "POST",
154
158
  headers: {
155
159
  "Content-Type": "application/json",
@@ -235,15 +239,42 @@ function parseSchema(source) {
235
239
  const colName = colMatch[1];
236
240
  const colType = colMatch[2];
237
241
  const modifiers = colMatch[3];
242
+ if (colName === "_id" || colName === "_createdAt") {
243
+ console.warn(`Warning: Column '${colName}' is a reserved system column and will be ignored.`);
244
+ continue;
245
+ }
246
+ const userWantsPrimaryKey = modifiers.includes(".primaryKey()");
247
+ if (userWantsPrimaryKey) {
248
+ console.warn(`Warning: Column '${colName}' has .primaryKey() which will be ignored. Tether uses '_id' as the primary key.`);
249
+ }
238
250
  columns[colName] = {
239
251
  type: colType,
240
- primaryKey: modifiers.includes(".primaryKey()"),
252
+ primaryKey: false,
253
+ // _id is always the primary key, ignore user-defined primary keys
241
254
  notNull: modifiers.includes(".notNull()"),
242
- unique: modifiers.includes(".unique()"),
255
+ unique: modifiers.includes(".unique()") || userWantsPrimaryKey,
256
+ // Make it unique if they wanted PK
243
257
  hasDefault: modifiers.includes(".default("),
244
258
  references: modifiers.match(/\.references\s*\(\s*['"]([^'"]+)['"]\s*\)/)?.[1]
245
259
  };
246
260
  }
261
+ columns["_id"] = {
262
+ type: "text",
263
+ primaryKey: true,
264
+ notNull: true,
265
+ unique: true,
266
+ // Also set unique so ALTER TABLE adds UNIQUE constraint
267
+ hasDefault: false,
268
+ isSystemColumn: true
269
+ };
270
+ columns["_createdAt"] = {
271
+ type: "timestamp",
272
+ primaryKey: false,
273
+ notNull: true,
274
+ unique: false,
275
+ hasDefault: true,
276
+ isSystemColumn: true
277
+ };
247
278
  tables.push({ name: tableName, columns, source: tableSource });
248
279
  }
249
280
  return tables;
@@ -271,9 +302,10 @@ function getColumnSqlType(type) {
271
302
  function buildColumnSql(colName, def, forAlterTable = false) {
272
303
  const sqlType = getColumnSqlType(def.type);
273
304
  let colSql = `${colName} ${sqlType}`;
274
- if (def.primaryKey) colSql += " PRIMARY KEY";
305
+ const addingPrimaryKey = def.primaryKey && !forAlterTable;
306
+ if (addingPrimaryKey) colSql += " PRIMARY KEY";
275
307
  if (def.notNull && !forAlterTable) colSql += " NOT NULL";
276
- if (def.unique) colSql += " UNIQUE";
308
+ if (def.unique && !addingPrimaryKey) colSql += " UNIQUE";
277
309
  if (def.hasDefault && def.type === "timestamp") {
278
310
  colSql += " DEFAULT (datetime('now'))";
279
311
  }
@@ -298,7 +330,6 @@ function generateSchemaSQL(tables) {
298
330
  );
299
331
  for (const [colName, colDef] of Object.entries(table.columns)) {
300
332
  const def = colDef;
301
- if (def.primaryKey) continue;
302
333
  const alterColSql = buildColumnSql(colName, def, true);
303
334
  statements.push(
304
335
  `-- Add column if missing (will error if exists, which is OK)
@@ -1020,8 +1051,8 @@ PUBLIC_TETHER_PROJECT_ID=\${TETHER_PROJECT_ID}
1020
1051
  }
1021
1052
  }
1022
1053
  }
1023
- function getInstallCommand(pm, isDev5 = false) {
1024
- const devFlag = isDev5 ? pm === "npm" ? "-D" : pm === "yarn" ? "-D" : pm === "pnpm" ? "-D" : "-d" : "";
1054
+ function getInstallCommand(pm, isDev6 = false) {
1055
+ const devFlag = isDev6 ? pm === "npm" ? "-D" : pm === "yarn" ? "-D" : pm === "pnpm" ? "-D" : "-d" : "";
1025
1056
  return `${pm} ${pm === "npm" ? "install" : "add"} ${devFlag}`.trim();
1026
1057
  }
1027
1058
  async function installTetherPackages(projectPath, template, packageManager) {
@@ -1478,7 +1509,7 @@ async function runGenerate(spinner) {
1478
1509
  }
1479
1510
  isGenerating = true;
1480
1511
  try {
1481
- const { generateTypes } = await import("./generate-26HERX3T.js");
1512
+ const { generateTypes } = await import("./generate-YQ4QRPXF.js");
1482
1513
  spinner.text = "Regenerating types...";
1483
1514
  await generateTypes({ silent: true });
1484
1515
  spinner.succeed("Types regenerated");
@@ -1618,14 +1649,17 @@ async function devCommand(options) {
1618
1649
  const functionsDir = resolvePath(config.functions);
1619
1650
  const outputDir = resolvePath(config.output);
1620
1651
  const framework = config.framework || await detectFramework();
1621
- const devCommand2 = config.dev?.command || getFrameworkDevCommand(framework);
1652
+ const devCmd = config.dev?.command || getFrameworkDevCommand(framework);
1653
+ const environment = options.env || config.environment || "development";
1654
+ const isLocal = options.local ?? false;
1622
1655
  console.log(chalk3.bold("\n\u26A1 Starting Tether development server\n"));
1623
1656
  if (framework !== "unknown") {
1624
1657
  console.log(chalk3.dim(` Framework: ${framework}`));
1625
1658
  }
1626
- if (devCommand2) {
1627
- console.log(chalk3.dim(` Dev command: ${devCommand2}`));
1659
+ if (devCmd) {
1660
+ console.log(chalk3.dim(` Dev command: ${devCmd}`));
1628
1661
  }
1662
+ console.log(chalk3.dim(` Environment: ${environment}${isLocal ? " (local)" : " (cloud)"}`));
1629
1663
  console.log(chalk3.dim(` Schema: ${path3.relative(process.cwd(), schemaPath)}`));
1630
1664
  console.log(chalk3.dim(` Functions: ${path3.relative(process.cwd(), functionsDir)}`));
1631
1665
  console.log(chalk3.dim(` Output: ${path3.relative(process.cwd(), outputDir)}`));
@@ -1656,7 +1690,7 @@ async function devCommand(options) {
1656
1690
  }
1657
1691
  }
1658
1692
  spinner.text = "Generating types...";
1659
- const { generateTypes } = await import("./generate-26HERX3T.js");
1693
+ const { generateTypes } = await import("./generate-YQ4QRPXF.js");
1660
1694
  await generateTypes({ silent: true });
1661
1695
  spinner.succeed("Types generated");
1662
1696
  spinner.start("Setting up file watchers...");
@@ -1700,9 +1734,9 @@ async function devCommand(options) {
1700
1734
  });
1701
1735
  }
1702
1736
  spinner.succeed("File watchers ready");
1703
- if (devCommand2 && !options.skipFramework) {
1737
+ if (devCmd && !options.skipFramework) {
1704
1738
  spinner.start(`Starting ${framework} dev server...`);
1705
- frameworkProcess = startFrameworkDev(devCommand2, port, spinner);
1739
+ frameworkProcess = startFrameworkDev(devCmd, port, spinner);
1706
1740
  } else {
1707
1741
  spinner.start("Watching for changes...");
1708
1742
  console.log("\n" + chalk3.cyan(` Tether dev ready`));
@@ -1904,18 +1938,18 @@ function detectPackageManager() {
1904
1938
  }
1905
1939
  return "npm";
1906
1940
  }
1907
- function getInstallCommand2(pm, packages, isDev5) {
1941
+ function getInstallCommand2(pm, packages, isDev6) {
1908
1942
  const packagesStr = packages.join(" ");
1909
1943
  switch (pm) {
1910
1944
  case "bun":
1911
- return isDev5 ? `bun add -d ${packagesStr}` : `bun add ${packagesStr}`;
1945
+ return isDev6 ? `bun add -d ${packagesStr}` : `bun add ${packagesStr}`;
1912
1946
  case "pnpm":
1913
- return isDev5 ? `pnpm add -D ${packagesStr}` : `pnpm add ${packagesStr}`;
1947
+ return isDev6 ? `pnpm add -D ${packagesStr}` : `pnpm add ${packagesStr}`;
1914
1948
  case "yarn":
1915
- return isDev5 ? `yarn add -D ${packagesStr}` : `yarn add ${packagesStr}`;
1949
+ return isDev6 ? `yarn add -D ${packagesStr}` : `yarn add ${packagesStr}`;
1916
1950
  case "npm":
1917
1951
  default:
1918
- return isDev5 ? `npm install -D ${packagesStr}` : `npm install ${packagesStr}`;
1952
+ return isDev6 ? `npm install -D ${packagesStr}` : `npm install ${packagesStr}`;
1919
1953
  }
1920
1954
  }
1921
1955
  async function getLatestVersion2(packageName) {
@@ -2088,16 +2122,182 @@ async function execCommand(sql) {
2088
2122
  }
2089
2123
  }
2090
2124
 
2125
+ // src/commands/env.ts
2126
+ import chalk7 from "chalk";
2127
+ import ora7 from "ora";
2128
+ import fs6 from "fs-extra";
2129
+ import path6 from "path";
2130
+ var isDev5 = process.env.NODE_ENV === "development" || process.env.TETHER_DEV === "true";
2131
+ var API_URL5 = isDev5 ? "http://localhost:3001/api/v1" : "https://tether-api.strands.gg/api/v1";
2132
+ async function getProjectId() {
2133
+ const envPath = path6.resolve(process.cwd(), ".env");
2134
+ let projectId;
2135
+ if (await fs6.pathExists(envPath)) {
2136
+ const envContent = await fs6.readFile(envPath, "utf-8");
2137
+ const match = envContent.match(/TETHER_PROJECT_ID=(.+)/);
2138
+ projectId = match?.[1]?.trim();
2139
+ }
2140
+ if (!projectId) {
2141
+ console.log(chalk7.red("\nError: Project ID not found"));
2142
+ console.log(chalk7.dim("Make sure TETHER_PROJECT_ID is set in your .env file\n"));
2143
+ process.exit(1);
2144
+ }
2145
+ return projectId;
2146
+ }
2147
+ async function envListCommand() {
2148
+ const credentials = await requireAuth();
2149
+ const projectId = await getProjectId();
2150
+ const spinner = ora7("Fetching environments...").start();
2151
+ try {
2152
+ const response = await fetch(`${API_URL5}/projects/${projectId}/environments`, {
2153
+ headers: {
2154
+ "Authorization": `Bearer ${credentials.accessToken}`
2155
+ }
2156
+ });
2157
+ if (!response.ok) {
2158
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
2159
+ throw new Error(error.error || `HTTP ${response.status}`);
2160
+ }
2161
+ const data = await response.json();
2162
+ spinner.succeed(`Found ${data.environments.length} environment(s)`);
2163
+ console.log();
2164
+ for (const env2 of data.environments) {
2165
+ const isDefault = env2.name === data.defaultEnvironment;
2166
+ const colour = getEnvColour(env2.name);
2167
+ const defaultBadge = isDefault ? chalk7.yellow(" \u2605 default") : "";
2168
+ console.log(` ${chalk7.hex(colour)("\u25CF")} ${chalk7.bold(env2.name)}${defaultBadge}`);
2169
+ console.log(chalk7.dim(` API Key: ${maskApiKey(env2.apiKey)}`));
2170
+ console.log(chalk7.dim(` Created: ${new Date(env2.createdAt).toLocaleDateString()}`));
2171
+ console.log();
2172
+ }
2173
+ } catch (error) {
2174
+ spinner.fail("Failed to fetch environments");
2175
+ console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
2176
+ process.exit(1);
2177
+ }
2178
+ }
2179
+ async function envCreateCommand(name, options) {
2180
+ const credentials = await requireAuth();
2181
+ const projectId = await getProjectId();
2182
+ const normalizedName = name.toLowerCase();
2183
+ if (!/^[a-z][a-z0-9-_]*$/.test(normalizedName)) {
2184
+ console.log(chalk7.red("\nError: Invalid environment name"));
2185
+ console.log(chalk7.dim("Name must start with a letter and contain only letters, numbers, hyphens, and underscores\n"));
2186
+ process.exit(1);
2187
+ }
2188
+ const spinner = ora7(`Creating environment '${normalizedName}'...`).start();
2189
+ try {
2190
+ const body = { name: normalizedName };
2191
+ if (options.from) {
2192
+ body.cloneFrom = options.from;
2193
+ spinner.text = `Creating environment '${normalizedName}' from '${options.from}'...`;
2194
+ }
2195
+ const response = await fetch(`${API_URL5}/projects/${projectId}/environments`, {
2196
+ method: "POST",
2197
+ headers: {
2198
+ "Content-Type": "application/json",
2199
+ "Authorization": `Bearer ${credentials.accessToken}`
2200
+ },
2201
+ body: JSON.stringify(body)
2202
+ });
2203
+ if (!response.ok) {
2204
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
2205
+ throw new Error(error.error || `HTTP ${response.status}`);
2206
+ }
2207
+ const env2 = await response.json();
2208
+ spinner.succeed(`Environment '${normalizedName}' created`);
2209
+ console.log();
2210
+ console.log(chalk7.dim(` API Key: ${env2.apiKey}`));
2211
+ console.log();
2212
+ console.log(chalk7.dim("Deploy to this environment with:"));
2213
+ console.log(chalk7.cyan(` tthr deploy --env ${normalizedName}`));
2214
+ console.log();
2215
+ } catch (error) {
2216
+ spinner.fail("Failed to create environment");
2217
+ console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
2218
+ process.exit(1);
2219
+ }
2220
+ }
2221
+ async function envDeleteCommand(name) {
2222
+ const credentials = await requireAuth();
2223
+ const projectId = await getProjectId();
2224
+ const spinner = ora7(`Deleting environment '${name}'...`).start();
2225
+ try {
2226
+ const response = await fetch(`${API_URL5}/projects/${projectId}/environments/${name}`, {
2227
+ method: "DELETE",
2228
+ headers: {
2229
+ "Authorization": `Bearer ${credentials.accessToken}`
2230
+ }
2231
+ });
2232
+ if (!response.ok) {
2233
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
2234
+ throw new Error(error.error || `HTTP ${response.status}`);
2235
+ }
2236
+ spinner.succeed(`Environment '${name}' deleted`);
2237
+ } catch (error) {
2238
+ spinner.fail("Failed to delete environment");
2239
+ console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
2240
+ process.exit(1);
2241
+ }
2242
+ }
2243
+ async function envDefaultCommand(name) {
2244
+ const credentials = await requireAuth();
2245
+ const projectId = await getProjectId();
2246
+ const spinner = ora7(`Setting '${name}' as default environment...`).start();
2247
+ try {
2248
+ const response = await fetch(`${API_URL5}/projects/${projectId}/environments/default`, {
2249
+ method: "PATCH",
2250
+ headers: {
2251
+ "Content-Type": "application/json",
2252
+ "Authorization": `Bearer ${credentials.accessToken}`
2253
+ },
2254
+ body: JSON.stringify({ name })
2255
+ });
2256
+ if (!response.ok) {
2257
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
2258
+ throw new Error(error.error || `HTTP ${response.status}`);
2259
+ }
2260
+ spinner.succeed(`'${name}' is now the default environment`);
2261
+ } catch (error) {
2262
+ spinner.fail("Failed to set default environment");
2263
+ console.error(chalk7.red(error instanceof Error ? error.message : "Unknown error"));
2264
+ process.exit(1);
2265
+ }
2266
+ }
2267
+ function getEnvColour(envName) {
2268
+ switch (envName) {
2269
+ case "production":
2270
+ return "#10b981";
2271
+ // green
2272
+ case "development":
2273
+ return "#f59e0b";
2274
+ // amber
2275
+ default:
2276
+ return "#3b82f6";
2277
+ }
2278
+ }
2279
+ function maskApiKey(key) {
2280
+ if (!key || key.length < 15) return key;
2281
+ const visibleStart = key.substring(0, 10);
2282
+ const visibleEnd = key.substring(key.length - 4);
2283
+ return `${visibleStart}...${visibleEnd}`;
2284
+ }
2285
+
2091
2286
  // src/index.ts
2092
2287
  var program = new Command();
2093
2288
  program.name("tthr").description("Tether CLI - Realtime SQLite for modern applications").version("0.0.1");
2094
2289
  program.command("init [name]").description("Create a new Tether project").option("-t, --template <template>", "Project template (vue, svelte, react, vanilla)", "vue").action(initCommand);
2095
- program.command("dev").description("Start local development server with hot reload").option("-p, --port <port>", "Port to run on").option("--skip-framework", "Only run Tether watchers, skip framework dev server").action(devCommand);
2290
+ program.command("dev").description("Start local development server with hot reload").option("-p, --port <port>", "Port to run on").option("-e, --env <environment>", "Target environment (default: development)").option("--local", "Run fully local (do not connect to cloud)").option("--skip-framework", "Only run Tether watchers, skip framework dev server").action(devCommand);
2096
2291
  program.command("generate").alias("gen").description("Generate types from schema").action(generateCommand);
2097
- 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);
2292
+ program.command("deploy").description("Deploy schema and functions to Tether").option("-s, --schema", "Deploy schema only").option("-f, --functions", "Deploy functions only").option("-e, --env <environment>", "Target environment (default: development)").option("--dry-run", "Show what would be deployed without deploying").action(deployCommand);
2098
2293
  program.command("login").description("Authenticate with Tether").action(loginCommand);
2099
2294
  program.command("logout").description("Sign out of Tether").action(logoutCommand);
2100
2295
  program.command("whoami").description("Show current authenticated user").action(whoamiCommand);
2101
2296
  program.command("update").description("Update all Tether packages to the latest version").option("--dry-run", "Show what would be updated without updating").action(updateCommand);
2102
2297
  program.command("exec <sql>").description("Execute a SQL query against the project database").action(execCommand);
2298
+ var env = program.command("env").description("Manage project environments");
2299
+ env.command("list").description("List all environments").action(envListCommand);
2300
+ env.command("create <name>").description("Create a new environment").option("--from <env>", "Clone schema and functions from an existing environment").action(envCreateCommand);
2301
+ env.command("delete <name>").description("Delete an environment").action(envDeleteCommand);
2302
+ env.command("default <name>").description("Set the default environment").action(envDefaultCommand);
2103
2303
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tthr",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "description": "Tether CLI - project scaffolding and deployment",
5
5
  "type": "module",
6
6
  "bin": {