@tgcloud/cli 0.1.0

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/bin/tgcloud.js +2 -0
  4. package/package.json +36 -0
  5. package/src/api/client.js +136 -0
  6. package/src/api/endpoints.js +124 -0
  7. package/src/cli.js +51 -0
  8. package/src/commands/add.js +133 -0
  9. package/src/commands/completion.js +298 -0
  10. package/src/commands/deploy.js +319 -0
  11. package/src/commands/diff.js +57 -0
  12. package/src/commands/fetch.js +23 -0
  13. package/src/commands/init.js +226 -0
  14. package/src/commands/login.js +56 -0
  15. package/src/commands/migrate.js +142 -0
  16. package/src/commands/pull.js +156 -0
  17. package/src/commands/reset.js +95 -0
  18. package/src/commands/run.js +145 -0
  19. package/src/commands/status.js +72 -0
  20. package/src/commands/webhook.js +139 -0
  21. package/src/core/capabilities.js +83 -0
  22. package/src/core/credentials.js +229 -0
  23. package/src/core/db-changes.js +221 -0
  24. package/src/core/file-diff.js +11 -0
  25. package/src/core/hasher.js +11 -0
  26. package/src/core/local-diff.js +57 -0
  27. package/src/core/migration-flow.js +356 -0
  28. package/src/core/project-layout.js +294 -0
  29. package/src/core/project-root.js +47 -0
  30. package/src/core/run-output.js +134 -0
  31. package/src/core/scanner.js +115 -0
  32. package/src/core/snapshot.js +132 -0
  33. package/src/core/state.js +92 -0
  34. package/src/core/sync.js +50 -0
  35. package/src/templates/AGENTS.md +95 -0
  36. package/src/templates/docs/tgcloud-sdk.md +347 -0
  37. package/src/templates/gitignore +2 -0
  38. package/src/templates/handlers/_default_.js +6 -0
  39. package/src/templates/handlers/message.js +13 -0
  40. package/src/templates/lib/_default_.js +2 -0
  41. package/src/templates/schema.js +10 -0
  42. package/src/utils/logger.js +54 -0
  43. package/src/utils/pager.js +76 -0
  44. package/src/utils/prompt.js +220 -0
  45. package/src/utils/spinner.js +5 -0
@@ -0,0 +1,229 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { askPassword } from '../utils/prompt.js';
5
+ import { ensureTgcloudDir } from './state.js';
6
+ import { request } from '../api/client.js';
7
+ import { error, warn, fatal } from '../utils/logger.js';
8
+
9
+ const TGCLOUD_DIR = '.tgcloud';
10
+ const CREDENTIALS_FILE = path.join(TGCLOUD_DIR, 'credentials');
11
+ const ENV_VAR = 'TGCLOUD_TOKEN';
12
+
13
+ const TOKEN_FORMAT = /^app\d+:[A-Za-z0-9_-]+$/;
14
+
15
+ /**
16
+ * Read token from .tgcloud/credentials. Returns null if missing or malformed.
17
+ */
18
+ export function loadCredentials() {
19
+ if (!fs.existsSync(CREDENTIALS_FILE)) return null;
20
+ try {
21
+ const raw = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed && typeof parsed.token === 'string') {
24
+ return { token: parsed.token };
25
+ }
26
+ } catch {
27
+ // fall through
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Save token to .tgcloud/credentials as a JSON object.
34
+ */
35
+ export function saveCredentials(token) {
36
+ ensureTgcloudDir();
37
+ // The file holds the bot secret — keep it owner-only. `mode` only applies when
38
+ // the file is created, so chmod an already-existing file down as well.
39
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ token }, null, 2) + '\n', { mode: 0o600 });
40
+ try { fs.chmodSync(CREDENTIALS_FILE, 0o600); } catch { /* best-effort; e.g. Windows */ }
41
+ }
42
+
43
+ /**
44
+ * Remove .tgcloud/credentials. No-op if missing.
45
+ */
46
+ export function deleteCredentials() {
47
+ if (fs.existsSync(CREDENTIALS_FILE)) fs.unlinkSync(CREDENTIALS_FILE);
48
+ }
49
+
50
+ /**
51
+ * Classify an error thrown by api/client.js as an authentication failure.
52
+ */
53
+ export function isAuthError(err) {
54
+ return err?.status === 401 || err?.status === 403;
55
+ }
56
+
57
+ /**
58
+ * Run a network-bound command body, turning an auth failure into a clear,
59
+ * non-interactive error.
60
+ *
61
+ * On 401/403 we never prompt mid-command (that would surprise scripts/CI):
62
+ * - env-provided token → report it is invalid and exit.
63
+ * - saved-file token → delete the stale file, tell the user to re-link with
64
+ * `tgcloud login`, and exit.
65
+ *
66
+ * Re-authentication is an explicit step (`tgcloud login`), not a side effect of
67
+ * another command.
68
+ */
69
+ export async function retryOnAuthError(fn) {
70
+ try {
71
+ return await fn();
72
+ } catch (err) {
73
+ if (!isAuthError(err)) throw err;
74
+
75
+ // Prefer the server's human reason (e.g. "Cloud is not enabled for this
76
+ // bot") over the generic fallback.
77
+ const reason = err.description || 'token is invalid or expired';
78
+
79
+ if (process.env[ENV_VAR]) {
80
+ console.error('');
81
+ fatal(`${ENV_VAR} (${maskToken(process.env[ENV_VAR])}): ${reason}.`);
82
+ }
83
+
84
+ const creds = loadCredentials();
85
+ const who = creds?.token ? ` for ${maskToken(creds.token)}` : '';
86
+ deleteCredentials();
87
+ console.error('');
88
+ fatal(`Saved token${who} rejected: ${reason}.\n Run "tgcloud login" to re-link this project.`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Return the public-safe representation of a token (the app-id part before ':').
94
+ * Used in any output where the token might otherwise leak.
95
+ */
96
+ export function maskToken(token) {
97
+ if (!token) return '';
98
+ const i = token.indexOf(':');
99
+ if (i <= 0) {
100
+ // Fallback: last 4 chars
101
+ return '...' + token.slice(-4);
102
+ }
103
+ return token.slice(0, i);
104
+ }
105
+
106
+ /**
107
+ * Validate a token against the server by calling a known endpoint.
108
+ * Returns { ok: boolean, networkError: boolean, statusText?: string }.
109
+ */
110
+ export async function validateToken(token) {
111
+ try {
112
+ await request('GET', '/get', token);
113
+ return { ok: true, networkError: false };
114
+ } catch (err) {
115
+ if (err.status === 401 || err.status === 403) {
116
+ return { ok: false, networkError: false, statusText: err.message };
117
+ }
118
+ // Treat missing status as network error; other server errors (500) we
119
+ // optimistically treat as "token probably fine, server is having issues"
120
+ // so we don't force the user to re-enter.
121
+ if (!err.status) {
122
+ return { ok: false, networkError: true, statusText: err.message };
123
+ }
124
+ return { ok: true, networkError: false };
125
+ }
126
+ }
127
+
128
+ function isValidFormat(token) {
129
+ return TOKEN_FORMAT.test(token);
130
+ }
131
+
132
+ /**
133
+ * Interactive login: prompt for a token (hidden input), validate its format,
134
+ * validate it against the server, and save it to `.tgcloud/credentials` on
135
+ * success. This is the ONLY place the CLI prompts for a token — it is reached
136
+ * via the explicit `tgcloud login` command, never mid-command.
137
+ *
138
+ * Loops on invalid input until the user provides a working token or aborts (Ctrl-C).
139
+ */
140
+ export async function loginInteractive() {
141
+ console.log('Link this project to a bot.');
142
+ console.log('Get your CLI access token from @BotFather: your bot → Serverless → CLI Access → Access token.');
143
+
144
+ while (true) {
145
+ const token = await askPassword('? Paste token (hidden): ');
146
+
147
+ if (token === null) {
148
+ // stdin closed with no input (piped/CI). Stop instead of looping on EOF,
149
+ // and signal failure so the command exits non-zero.
150
+ console.error('\n' + chalk.red('Error: ') + 'No token provided (input closed).');
151
+ console.error(' For CI, set the TGCLOUD_TOKEN environment variable instead.');
152
+ return null;
153
+ }
154
+
155
+ if (!token) {
156
+ error('Token is required.');
157
+ continue;
158
+ }
159
+
160
+ if (!isValidFormat(token)) {
161
+ error('Token format looks wrong. Expected "app<id>:<secret>".');
162
+ continue;
163
+ }
164
+
165
+ const result = await validateToken(token);
166
+
167
+ if (result.ok) {
168
+ saveCredentials(token);
169
+ console.log(`Linked to ${maskToken(token)}.`);
170
+ return token;
171
+ }
172
+
173
+ if (result.networkError) {
174
+ // Save anyway, will revalidate on next command.
175
+ saveCredentials(token);
176
+ warn(
177
+ 'Could not reach tgcloud servers. Token saved but not verified.\n' +
178
+ ' Will retry on next command.'
179
+ );
180
+ return token;
181
+ }
182
+
183
+ // 401/403 — invalid token
184
+ error('Token invalid or expired.');
185
+ console.log(
186
+ ' Get a fresh CLI access token from @BotFather:\n' +
187
+ ' your bot → Serverless → CLI Access → Access token'
188
+ );
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolve a usable token for the current command. Priority:
194
+ * 1. TGCLOUD_TOKEN environment variable (CI/CD)
195
+ * 2. .tgcloud/credentials (project-local, written by `tgcloud login`)
196
+ *
197
+ * If neither is present this exits with a clear error pointing at `tgcloud
198
+ * login`. It deliberately does NOT prompt — a surprise interactive prompt in
199
+ * the middle of an arbitrary command would hang scripts and CI. Linking is an
200
+ * explicit step.
201
+ */
202
+ export async function resolveToken() {
203
+ const envToken = process.env[ENV_VAR];
204
+ if (envToken) {
205
+ if (!isValidFormat(envToken)) {
206
+ fatal(`${ENV_VAR} has invalid format. Expected "app<id>:<secret>".`);
207
+ }
208
+ return envToken;
209
+ }
210
+
211
+ const creds = loadCredentials();
212
+ if (creds?.token) {
213
+ return creds.token;
214
+ }
215
+
216
+ fatal('No CLI access token found for this project.\n Run "tgcloud login" to link a bot, or set TGCLOUD_TOKEN (for CI).');
217
+ }
218
+
219
+ /**
220
+ * Non-exiting token lookup for best-effort/offline callers (e.g. the webhook
221
+ * health line in `status`). Returns the usable token or null — never prints,
222
+ * never exits. Unlike resolveToken(), a missing/malformed token here is not an
223
+ * error: the caller simply skips its networked bit.
224
+ */
225
+ export function peekToken() {
226
+ const envToken = process.env[ENV_VAR];
227
+ if (envToken) return isValidFormat(envToken) ? envToken : null;
228
+ return loadCredentials()?.token || null;
229
+ }
@@ -0,0 +1,221 @@
1
+ import chalk from 'chalk';
2
+
3
+ /**
4
+ * Deterministic identifier for a change. Computed purely from structural
5
+ * fields — the CLI and server compute the same string without any shared
6
+ * state. `kind:entity` where entity depends on the kind.
7
+ */
8
+ export function changeId(change) {
9
+ const { kind, table, column, index } = change;
10
+ if (column) return `${kind}:${table}.${column}`;
11
+ if (index) return `${kind}:${table}.${index}`;
12
+ return `${kind}:${table}`;
13
+ }
14
+
15
+ /**
16
+ * Human-readable label generated from `kind`. Used in headers so we don't
17
+ * rely on a `description` field from the server (which would duplicate the
18
+ * target name we already have in `table` / `column` / `index`).
19
+ *
20
+ * Regular 3×4 grid: every level (table / column / index) has create / drop
21
+ * / change / undocumented. Server classifies each via `status`:
22
+ * create_* → good (or warning if large/blocking)
23
+ * drop_* → warning
24
+ * change_* → manual (always; needs human action)
25
+ * undocumented_* → undocumented
26
+ */
27
+ const LABELS = {
28
+ create_table: 'new table',
29
+ drop_table: 'drop table',
30
+ change_table: 'change table',
31
+ undocumented_table: 'not in schema',
32
+
33
+ create_column: 'new column',
34
+ drop_column: 'drop column',
35
+ change_column: 'change column',
36
+ undocumented_column: 'not in schema',
37
+
38
+ create_index: 'new index',
39
+ drop_index: 'drop index',
40
+ change_index: 'change index',
41
+ undocumented_index: 'not in schema',
42
+ };
43
+
44
+ export function kindLabel(change) {
45
+ return LABELS[change.kind] || change.description || change.kind;
46
+ }
47
+
48
+ /**
49
+ * Operation-kind → marker char + chalk color, used in the brief summary.
50
+ * The marker reflects what the change does (add / delete / modify / found),
51
+ * not the severity status — severity surfaces in the per-card warning text.
52
+ */
53
+ const KIND_STYLE = {
54
+ create: { mark: '+', color: chalk.green },
55
+ drop: { mark: '✗', color: chalk.red },
56
+ change: { mark: '!', color: chalk.yellow },
57
+ undocumented: { mark: '?', color: chalk.dim },
58
+ };
59
+
60
+ function styleFor(change) {
61
+ // kind shape: "<verb>_<level>" (e.g. create_table, drop_column, change_index,
62
+ // undocumented_table). First underscored token = verb.
63
+ const verb = (change.kind || '').split('_')[0];
64
+ return KIND_STYLE[verb] || { mark: '·', color: (s) => s };
65
+ }
66
+
67
+ /**
68
+ * Compact one-line-per-change summary. Used in `push` (after schema deploy)
69
+ * and at the top of `migrate` before the walk-through.
70
+ *
71
+ * Format:
72
+ * + users.score add column
73
+ * ! old_sessions drop table
74
+ * ✗ users change table
75
+ * ? legacy_logs not in schema
76
+ *
77
+ * Whole line is colored by status. No extra annotations — the marker carries
78
+ * the severity.
79
+ */
80
+ export function formatBriefSummary(dbChanges) {
81
+ if (!dbChanges?.length) return '';
82
+
83
+ const rows = dbChanges.map((change) => ({
84
+ style: styleFor(change),
85
+ target: headerTarget(change),
86
+ label: kindLabel(change),
87
+ }));
88
+
89
+ const targetWidth = Math.max(...rows.map((r) => r.target.length));
90
+
91
+ return rows
92
+ .map((r) => r.style.color(` ${r.style.mark} ${r.target.padEnd(targetWidth)} ${r.label}`))
93
+ .join('\n');
94
+ }
95
+
96
+ /**
97
+ * Render a single change as a card (header + body). Used in the migration
98
+ * walk-through where each change is shown individually.
99
+ */
100
+ export function formatChange(change) {
101
+ return formatBlock(change);
102
+ }
103
+
104
+ function formatBlock(change) {
105
+ const lines = [];
106
+ lines.push(formatHeader(change));
107
+
108
+ const body = formatBody(change);
109
+ if (body.length) {
110
+ lines.push(...body);
111
+ }
112
+
113
+ // Server-provided explanation, rendered for ANY kind — not just change_*.
114
+ // A normally-"good" kind can be classified manual/warning with a reason/action
115
+ // (e.g. a UNIQUE column can't be added via ALTER TABLE, so create_column comes
116
+ // back manual). formatBody only prints the SQL for create_*/drop_*, so the
117
+ // reason must surface here or the user never sees why it's manual.
118
+ if (change.reason || change.action) {
119
+ lines.push('');
120
+ if (change.reason) lines.push(` Reason: ${change.reason}`);
121
+ if (change.action) lines.push(` Action: ${change.action}`);
122
+ }
123
+
124
+ // Warning text (status==='warning') — e.g. the data-loss warning on drops.
125
+ // Warning: convention — only the label is colored, the message stays default.
126
+ if (change.status === 'warning' && change.warning) {
127
+ lines.push('');
128
+ lines.push(' ' + chalk.yellow('Warning: ') + change.warning);
129
+ }
130
+
131
+ return lines.join('\n');
132
+ }
133
+
134
+ function formatHeader(change) {
135
+ const target = headerTarget(change);
136
+ const label = kindLabel(change);
137
+ return chalk.cyan(`@@ ${target}: ${label} @@`);
138
+ }
139
+
140
+ function headerTarget(change) {
141
+ if (change.column) return `${change.table}.${change.column}`;
142
+ if (change.index) return `${change.table}.${change.index}`;
143
+ return change.table;
144
+ }
145
+
146
+ function formatBody(change) {
147
+ const lines = [];
148
+
149
+ switch (change.kind) {
150
+ // ── Create: SQL that will be executed (no diff prefix) ─────────────────
151
+ case 'create_table':
152
+ for (const line of splitLines(change.sql)) {
153
+ lines.push(chalk.green(' ' + line));
154
+ }
155
+ break;
156
+
157
+ case 'create_column':
158
+ case 'create_index':
159
+ if (change.sql) lines.push(chalk.green(' ' + change.sql));
160
+ break;
161
+
162
+ // ── Drop: SQL that will be executed (no diff prefix) ───────────────────
163
+ case 'drop_table':
164
+ case 'drop_column':
165
+ case 'drop_index':
166
+ if (change.sql) lines.push(chalk.red(' ' + change.sql));
167
+ break;
168
+
169
+ // ── Change: real diff — keep -/+ prefixes ──────────────────────────────
170
+ case 'change_table':
171
+ case 'change_column':
172
+ case 'change_index':
173
+ if (change.diff) {
174
+ lines.push(chalk.red('-' + change.diff.current));
175
+ lines.push(chalk.green('+' + change.diff.expected));
176
+ }
177
+ // reason/action rendered by formatBlock (applies to all kinds)
178
+ break;
179
+
180
+ // ── Undocumented: show what's in the DB as context ─────────────────────
181
+ case 'undocumented_table':
182
+ for (const line of splitLines(change.existing_schema)) {
183
+ lines.push(' ' + line);
184
+ }
185
+ if (typeof change.row_count === 'number') {
186
+ lines.push(` Contents: ${change.row_count} row${change.row_count === 1 ? '' : 's'}`);
187
+ }
188
+ break;
189
+
190
+ case 'undocumented_column':
191
+ if (change.diff?.current) {
192
+ lines.push(' ' + change.diff.current);
193
+ }
194
+ break;
195
+
196
+ case 'undocumented_index':
197
+ for (const line of splitLines(change.existing_schema)) {
198
+ lines.push(' ' + line);
199
+ }
200
+ break;
201
+
202
+ // ── Forward-compat fallback for unknown kinds ──────────────────────────
203
+ default:
204
+ if (change.diff) {
205
+ lines.push(chalk.red('-' + change.diff.current));
206
+ lines.push(chalk.green('+' + change.diff.expected));
207
+ }
208
+ // reason/action rendered by formatBlock (applies to all kinds)
209
+ if (change.sql) {
210
+ lines.push(chalk.dim(' ' + change.sql));
211
+ }
212
+ }
213
+
214
+ return lines;
215
+ }
216
+
217
+ function splitLines(sql) {
218
+ if (!sql) return [];
219
+ const trimmed = sql.endsWith('\n') ? sql.slice(0, -1) : sql;
220
+ return trimmed.split('\n');
221
+ }
@@ -0,0 +1,11 @@
1
+ import { createTwoFilesPatch } from 'diff';
2
+
3
+ export function computeDiff(oldContent, newContent, filename) {
4
+ if (oldContent === newContent) return null;
5
+ return createTwoFilesPatch(
6
+ `a/${filename}`,
7
+ `b/${filename}`,
8
+ oldContent || '',
9
+ newContent || '',
10
+ );
11
+ }
@@ -0,0 +1,11 @@
1
+ import { createHash } from 'crypto';
2
+ import fs from 'fs';
3
+
4
+ export function hashFile(filePath) {
5
+ const content = fs.readFileSync(filePath, 'utf-8');
6
+ return hashContent(content);
7
+ }
8
+
9
+ export function hashContent(content) {
10
+ return createHash('md5').update(content).digest('hex');
11
+ }
@@ -0,0 +1,57 @@
1
+ import fs from 'fs';
2
+ import { scanFiles } from './scanner.js';
3
+ import { listSnapshotPaths, readSnapshotFile } from './snapshot.js';
4
+
5
+ /**
6
+ * Compare working directory files against the local snapshot (.tgcloud/remote/).
7
+ *
8
+ * Returns:
9
+ * {
10
+ * modified: [{ path, localContent, remoteContent }],
11
+ * newLocal: [{ path, localContent }], // exists locally, not in snapshot
12
+ * remoteOnly: [{ path, remoteContent }], // exists in snapshot, not locally
13
+ * matched: [{ path, localContent }], // identical
14
+ * }
15
+ *
16
+ * Comparison is byte-level on file content.
17
+ */
18
+ export function localDiff() {
19
+ const localFiles = scanFiles();
20
+ const snapshotFiles = listSnapshotPaths();
21
+
22
+ const localSet = new Set(localFiles);
23
+ const snapshotSet = new Set(snapshotFiles);
24
+ const allPaths = new Set([...localFiles, ...snapshotFiles]);
25
+
26
+ const modified = [];
27
+ const newLocal = [];
28
+ const remoteOnly = [];
29
+ const matched = [];
30
+
31
+ for (const p of allPaths) {
32
+ const hasLocal = localSet.has(p);
33
+ const hasRemote = snapshotSet.has(p);
34
+
35
+ if (hasLocal && !hasRemote) {
36
+ newLocal.push({
37
+ path: p,
38
+ localContent: fs.readFileSync(p, 'utf-8'),
39
+ });
40
+ } else if (!hasLocal && hasRemote) {
41
+ remoteOnly.push({
42
+ path: p,
43
+ remoteContent: readSnapshotFile(p) ?? '',
44
+ });
45
+ } else {
46
+ const localContent = fs.readFileSync(p, 'utf-8');
47
+ const remoteContent = readSnapshotFile(p) ?? '';
48
+ if (localContent === remoteContent) {
49
+ matched.push({ path: p, localContent });
50
+ } else {
51
+ modified.push({ path: p, localContent, remoteContent });
52
+ }
53
+ }
54
+ }
55
+
56
+ return { modified, newLocal, remoteOnly, matched };
57
+ }