@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,134 @@
1
+ import chalk from 'chalk';
2
+ import JSON5 from 'json5';
3
+
4
+ /**
5
+ * Render server-side runtime output of a function: console.log/debug/info/warn/error
6
+ * entries plus the return value or error.
7
+ *
8
+ * Each log entry:
9
+ * {
10
+ * _: "log" | "dbg" | "inf" | "wrn" | "err",
11
+ * t: <seconds-since-start>,
12
+ * m: "<full message>", // or…
13
+ * M: "<truncated message>", // …if the server truncated it
14
+ * o: "<module>:<line>" // optional origin
15
+ * }
16
+ *
17
+ * Total `time` (passed to formatRunReturn) is in seconds.
18
+ *
19
+ * Output format: single-char prefix per entry, message text, and time delta on
20
+ * the right. Args are pre-formatted by the server — the CLI only colorizes by
21
+ * severity.
22
+ */
23
+
24
+ /**
25
+ * `> name(args)` — input invocation line. Shows only the module's basename
26
+ * (no directory) since that matches how a JS function is normally referenced.
27
+ * Plain weight so the trailing `< value` return line stands out via bold.
28
+ */
29
+ export function formatRunHeader(moduleName, args) {
30
+ const baseName = moduleName.split('/').pop();
31
+ let argsText = '';
32
+ if (args !== undefined) {
33
+ try { argsText = formatValue(args); }
34
+ catch { argsText = String(args); }
35
+ }
36
+ return chalk.cyan('> ') + `${baseName}(${argsText})`;
37
+ }
38
+
39
+ export function formatRunLogs(logs) {
40
+ if (!Array.isArray(logs) || logs.length === 0) return [];
41
+
42
+ const lines = [];
43
+ let prevMs = 0;
44
+
45
+ for (const entry of logs) {
46
+ const tMs = (entry.t || 0) * 1000;
47
+ const deltaMs = Math.max(0, tMs - prevMs);
48
+ prevMs = tMs;
49
+
50
+ // `m` = full message; `M` = truncated. Mutually exclusive.
51
+ const truncated = entry.M != null;
52
+ const rawMessage = truncated ? entry.M : (entry.m ?? '');
53
+
54
+ const prefix = typePrefix(entry._);
55
+ const message = colorize(entry._, rawMessage);
56
+ const truncSuffix = truncated ? chalk.dim(' …[truncated]') : '';
57
+ const time = chalk.dim(`+${formatMs(deltaMs)}`);
58
+ const origin = formatOrigin(entry.o);
59
+ const originText = origin ? ' ' + chalk.dim(`[${origin}]`) : '';
60
+
61
+ lines.push(`${prefix} ${message}${truncSuffix} ${time}${originText}`);
62
+ }
63
+
64
+ return lines;
65
+ }
66
+
67
+ /**
68
+ * Tint message text by severity. wrn/err get a coloured wash so the line
69
+ * stands out beyond the prefix chip; log/dbg/inf stay default.
70
+ */
71
+ function colorize(type, text) {
72
+ switch (type) {
73
+ case 'wrn': return chalk.yellow(text);
74
+ case 'err': return chalk.red(text);
75
+ default: return text;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Render the `o` (origin) field from a log entry. Accepts either a string
81
+ * (`"handlers/message:6"`) or an object (`{ m, l }` / `{ module, line }`).
82
+ */
83
+ function formatOrigin(o) {
84
+ if (!o) return '';
85
+ if (typeof o === 'string') return o;
86
+ if (typeof o === 'object') {
87
+ const mod = o.m ?? o.module;
88
+ const line = o.l ?? o.line;
89
+ if (mod != null && line != null) return `${mod}:${line}`;
90
+ if (mod != null) return String(mod);
91
+ }
92
+ return '';
93
+ }
94
+
95
+ /**
96
+ * One-line representation of the return value: cyan `<` + bold value + dim total time.
97
+ * Only the value itself is bold so it stands out from the surrounding log stream
98
+ * and the plain-weight header above.
99
+ */
100
+ export function formatRunReturn(value, totalSeconds) {
101
+ const valueText = chalk.bold(formatValue(value));
102
+ const timeText = totalSeconds != null
103
+ ? chalk.dim(` ${formatMs(totalSeconds * 1000)}`)
104
+ : '';
105
+ return chalk.cyan('< ') + valueText + timeText;
106
+ }
107
+
108
+ function typePrefix(type) {
109
+ switch (type) {
110
+ case 'inf': return chalk.blue('i');
111
+ case 'wrn': return chalk.yellow('!');
112
+ case 'err': return chalk.red('x');
113
+ case 'dbg':
114
+ case 'log':
115
+ default: return ' ';
116
+ }
117
+ }
118
+
119
+ export function formatValue(v) {
120
+ if (v === undefined) return chalk.dim('undefined');
121
+ if (typeof v === 'string') return v;
122
+ if (v && typeof v === 'object' && v.__error) {
123
+ return chalk.red(`Error: ${v.message || ''}`);
124
+ }
125
+ try {
126
+ return JSON5.stringify(v);
127
+ } catch {
128
+ return String(v);
129
+ }
130
+ }
131
+
132
+ function formatMs(ms) {
133
+ return Math.max(0, Math.round(ms)) + 'ms';
134
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fatal, warn, info } from '../utils/logger.js';
4
+ import { ROOT_FILES, moduleDirs, nestableDirs } from './project-layout.js';
5
+
6
+ /**
7
+ * Return working-directory-relative paths of all project files, with `.js`.
8
+ *
9
+ * Includes:
10
+ * - each root file (e.g. `schema.js`) at project root, if it exists
11
+ * - Every `*.js` file under each module directory. `lib/` allows nesting
12
+ * (`lib/internal/util.js`); `handlers/` must be flat.
13
+ *
14
+ * The layout (which root files / directories exist, and which directories may
15
+ * nest) comes from core/project-layout.js — the single source of truth.
16
+ *
17
+ * Bails out with a fatal error if a non-nestable directory contains
18
+ * subdirectories — surfacing the issue locally before any deploy/run round-trip.
19
+ *
20
+ * Note: anything OUTSIDE the root files and module directories is not scanned.
21
+ * A `.js` inside a non-module directory is silently excluded (intentional — it
22
+ * may be a build script). A stray `.js` at the *project root* is now flagged
23
+ * separately via findStrayFiles()/warnStrayFiles(), which the dedicated-folder
24
+ * layout makes safe.
25
+ */
26
+ export function scanFiles() {
27
+ const files = [];
28
+ const nestable = nestableDirs();
29
+
30
+ for (const rootFile of ROOT_FILES) {
31
+ if (fs.existsSync(rootFile)) {
32
+ files.push(rootFile);
33
+ }
34
+ }
35
+
36
+ for (const dir of moduleDirs()) {
37
+ if (!fs.existsSync(dir)) continue;
38
+ walkJs(dir, dir, files, nestable);
39
+ }
40
+
41
+ // POSIX-normalize so paths match the snapshot/wire side on Windows (where
42
+ // path.join yields backslashes); no-op on POSIX. Keeps local-diff, the deploy
43
+ // manifest, and pull's orphan check comparing like-for-like.
44
+ return files.map((f) => f.split(/[\\/]/).join('/'));
45
+ }
46
+
47
+ function walkJs(currentDir, baseDir, out, nestable) {
48
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
49
+ const full = path.join(currentDir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ if (!nestable.has(baseDir)) {
52
+ fatal(
53
+ `${baseDir}/ must be flat — found subdirectory ${full}. ` +
54
+ `Only ${[...nestable].map((d) => d + '/').join(', ')} allow nested modules.`
55
+ );
56
+ }
57
+ walkJs(full, baseDir, out, nestable);
58
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
59
+ out.push(full);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Config files legitimately live at a project root; don't mistake them for
65
+ // misplaced modules. Covers dotfiles (`.eslintrc.js`) and the `*.config.js`
66
+ // convention (`vite.config.js`, `eslint.config.js`, …).
67
+ function isConfigFile(name) {
68
+ return name.startsWith('.') || /\.config\.js$/.test(name);
69
+ }
70
+
71
+ /**
72
+ * `*.js` files sitting directly at the project root that are neither a known
73
+ * root file (e.g. `schema.js`) nor a recognized config — i.e. modules the
74
+ * developer most likely misplaced (they would silently deploy to nothing).
75
+ *
76
+ * This is the loud counterpart to scanFiles()'s silent scoping, made safe by
77
+ * the dedicated-folder layout: the project root now holds only tgcloud content,
78
+ * so a bare `.js` there is unambiguous. Root-level
79
+ * only — `.js` inside a non-module directory is still intentionally ignored.
80
+ */
81
+ export function findStrayFiles() {
82
+ const rootFiles = new Set(ROOT_FILES);
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync('.', { withFileTypes: true });
86
+ } catch {
87
+ return [];
88
+ }
89
+ const out = [];
90
+ for (const entry of entries) {
91
+ if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
92
+ if (rootFiles.has(entry.name) || isConfigFile(entry.name)) continue;
93
+ out.push(entry.name);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * Print a non-blocking warning for stray root-level `.js` (see findStrayFiles).
100
+ * Used by `status` and by a full `deploy`; a targeted `deploy` of a stray file
101
+ * is a hard error instead (handled in the deploy command). Returns the strays.
102
+ */
103
+ export function warnStrayFiles() {
104
+ const strays = findStrayFiles();
105
+ if (!strays.length) return strays;
106
+ info('');
107
+ warn(
108
+ `${strays.length} stray .js file${strays.length === 1 ? '' : 's'} at the ` +
109
+ `project root will not be deployed:`
110
+ );
111
+ for (const f of strays) info(` ${f}`);
112
+ const dirs = moduleDirs().map((d) => d + '/').join(', ');
113
+ info(`Move ${strays.length === 1 ? 'it' : 'them'} into a module directory (${dirs}) to deploy.`);
114
+ return strays;
115
+ }
@@ -0,0 +1,132 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { ensureTgcloudDir } from './state.js';
4
+
5
+ const SNAPSHOT_DIR = path.join('.tgcloud', 'remote');
6
+
7
+ // Normalize OS separators to the POSIX '/' used by the wire protocol and by
8
+ // every snapshot-vs-server comparison. No-op on POSIX; on Windows it stops
9
+ // backslash module names from breaking the deploy manifest and the pull
10
+ // orphan check (which compares against server names that are always '/').
11
+ function toPosix(p) {
12
+ return p.split(/[\\/]/).join('/');
13
+ }
14
+
15
+ /**
16
+ * Convert a file path (working-dir style, with .js) to a module name
17
+ * (API style, without .js). e.g. "handlers/message.js" → "handlers/message".
18
+ */
19
+ export function pathToModule(filePath) {
20
+ const p = toPosix(filePath);
21
+ return p.endsWith('.js') ? p.slice(0, -3) : p;
22
+ }
23
+
24
+ /**
25
+ * Inverse of pathToModule. "handlers/message" → "handlers/message.js".
26
+ */
27
+ export function moduleToPath(moduleName) {
28
+ const p = toPosix(moduleName);
29
+ return p.endsWith('.js') ? p : p + '.js';
30
+ }
31
+
32
+ /**
33
+ * True if .tgcloud/remote/ exists.
34
+ */
35
+ export function snapshotExists() {
36
+ return fs.existsSync(SNAPSHOT_DIR);
37
+ }
38
+
39
+ /**
40
+ * Read a file from the snapshot. Returns string or null if missing.
41
+ * relPath is a working-dir-style path, e.g. "handlers/message.js".
42
+ */
43
+ export function readSnapshotFile(relPath) {
44
+ const full = path.join(SNAPSHOT_DIR, relPath);
45
+ if (!fs.existsSync(full)) return null;
46
+ return fs.readFileSync(full, 'utf-8');
47
+ }
48
+
49
+ /**
50
+ * Write a file into the snapshot (creating parent directories as needed).
51
+ * The content is stored byte-identical to what was passed.
52
+ */
53
+ export function writeSnapshotFile(relPath, content) {
54
+ ensureTgcloudDir();
55
+ const full = path.join(SNAPSHOT_DIR, relPath);
56
+ const dir = path.dirname(full);
57
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
58
+ fs.writeFileSync(full, content);
59
+ }
60
+
61
+ /**
62
+ * Remove a file from the snapshot. No-op if missing.
63
+ */
64
+ export function deleteSnapshotFile(relPath) {
65
+ const full = path.join(SNAPSHOT_DIR, relPath);
66
+ if (fs.existsSync(full)) fs.unlinkSync(full);
67
+ }
68
+
69
+ /**
70
+ * Walk .tgcloud/remote/ and return all file paths relative to it
71
+ * (e.g. "schema.js", "handlers/message.js").
72
+ */
73
+ export function listSnapshotPaths() {
74
+ if (!fs.existsSync(SNAPSHOT_DIR)) return [];
75
+ const result = [];
76
+ walk(SNAPSHOT_DIR, '', result);
77
+ return result.map(toPosix); // POSIX-normalized so keys match server/scanner
78
+ }
79
+
80
+ function walk(root, rel, out) {
81
+ const abs = rel ? path.join(root, rel) : root;
82
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
83
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
84
+ if (entry.isDirectory()) {
85
+ walk(root, childRel, out);
86
+ } else if (entry.isFile()) {
87
+ out.push(childRel);
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Apply a canonical_modules map from a server response: write each module
94
+ * to the snapshot. Keys are module names (no .js); values are full content.
95
+ *
96
+ * @param {Object<string, string>} canonicalModules
97
+ */
98
+ export function applyCanonicalModules(canonicalModules) {
99
+ if (!canonicalModules) return;
100
+ for (const [moduleName, content] of Object.entries(canonicalModules)) {
101
+ writeSnapshotFile(moduleToPath(moduleName), content);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Replace the entire snapshot with the given canonical_modules. Any files
107
+ * in the snapshot not present in the new map are removed. Used by `pull`
108
+ * and `fetch` to bring local snapshot into full sync with server state.
109
+ */
110
+ export function replaceSnapshot(canonicalModules) {
111
+ // Ensure the snapshot dir exists even when the server returns no modules, so
112
+ // `status`/`diff` see a (correctly empty) snapshot rather than "no snapshot".
113
+ ensureTgcloudDir();
114
+ if (!fs.existsSync(SNAPSHOT_DIR)) fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
115
+
116
+ const existing = new Set(listSnapshotPaths());
117
+ const newPaths = new Set();
118
+
119
+ if (canonicalModules) {
120
+ for (const [moduleName, content] of Object.entries(canonicalModules)) {
121
+ const p = moduleToPath(moduleName);
122
+ writeSnapshotFile(p, content);
123
+ newPaths.add(p);
124
+ }
125
+ }
126
+
127
+ for (const existingPath of existing) {
128
+ if (!newPaths.has(existingPath)) {
129
+ deleteSnapshotFile(existingPath);
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,92 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const TGCLOUD_DIR = '.tgcloud';
5
+ const STATE_FILE = path.join(TGCLOUD_DIR, 'state.json');
6
+ const CAPABILITIES_FILE = path.join(TGCLOUD_DIR, 'capabilities.json');
7
+
8
+ /**
9
+ * Default empty state. Used when state.json does not exist yet.
10
+ */
11
+ function emptyState() {
12
+ return {
13
+ last_known_revision: null,
14
+ init_templates: {},
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Load state from .tgcloud/state.json.
20
+ * Returns an object with defaults for missing fields.
21
+ * If the file doesn't exist, returns an empty state.
22
+ */
23
+ export function loadState() {
24
+ if (!fs.existsSync(STATE_FILE)) {
25
+ return emptyState();
26
+ }
27
+ try {
28
+ const raw = fs.readFileSync(STATE_FILE, 'utf-8');
29
+ const parsed = JSON.parse(raw);
30
+ return { ...emptyState(), ...parsed };
31
+ } catch {
32
+ return emptyState();
33
+ }
34
+ }
35
+
36
+ // Write atomically: stage to a temp sibling, then rename over the target.
37
+ // rename is atomic on one filesystem, so a crash/Ctrl-C mid-write can't leave a
38
+ // truncated file — which loadState()/loadCachedCapabilities() would silently
39
+ // swallow as "empty", dropping last_known_revision and init_templates.
40
+ function atomicWrite(file, content) {
41
+ const tmp = `${file}.${process.pid}.tmp`;
42
+ fs.writeFileSync(tmp, content);
43
+ try {
44
+ fs.renameSync(tmp, file);
45
+ } catch {
46
+ // Windows rename can reject an existing target — replace then retry.
47
+ try { fs.rmSync(file, { force: true }); fs.renameSync(tmp, file); }
48
+ finally { fs.rmSync(tmp, { force: true }); }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Save state to .tgcloud/state.json.
54
+ * Creates .tgcloud/ if it doesn't exist.
55
+ */
56
+ export function saveState(state) {
57
+ ensureTgcloudDir();
58
+ atomicWrite(STATE_FILE, JSON.stringify(state, null, 2) + '\n');
59
+ }
60
+
61
+ /**
62
+ * Ensure .tgcloud/ directory exists.
63
+ */
64
+ export function ensureTgcloudDir() {
65
+ if (!fs.existsSync(TGCLOUD_DIR)) {
66
+ fs.mkdirSync(TGCLOUD_DIR, { recursive: true });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Load the cached project-layout descriptor from .tgcloud/capabilities.json.
72
+ * Returns the raw object, or null if absent/unreadable. The caller validates
73
+ * (core/project-layout.js → validateDescriptor) before trusting it; offline
74
+ * commands read the layout from this cache.
75
+ */
76
+ export function loadCachedCapabilities() {
77
+ if (!fs.existsSync(CAPABILITIES_FILE)) return null;
78
+ try {
79
+ return JSON.parse(fs.readFileSync(CAPABILITIES_FILE, 'utf-8'));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Persist the latest project-layout descriptor to .tgcloud/capabilities.json.
87
+ * Stored verbatim as received from the server (after the caller validated it).
88
+ */
89
+ export function saveCachedCapabilities(descriptor) {
90
+ ensureTgcloudDir();
91
+ atomicWrite(CAPABILITIES_FILE, JSON.stringify(descriptor, null, 2) + '\n');
92
+ }
@@ -0,0 +1,50 @@
1
+ import chalk from 'chalk';
2
+ import { resolveToken, retryOnAuthError } from './credentials.js';
3
+ import { syncCapabilities } from './capabilities.js';
4
+ import { getFiles } from '../api/endpoints.js';
5
+ import { describeServerError } from '../api/client.js';
6
+ import { replaceSnapshot } from './snapshot.js';
7
+ import { loadState, saveState } from './state.js';
8
+ import { createSpinner } from '../utils/spinner.js';
9
+
10
+ /**
11
+ * Download current server state into the local snapshot (.tgcloud/remote/) and
12
+ * update last_known_revision. The working directory is NOT touched.
13
+ *
14
+ * Shared by `tgcloud fetch` and `tgcloud login` (which syncs on bind so a first
15
+ * push conflict-checks against the bot's real state instead of force-overwriting
16
+ * it). Refreshes the layout descriptor first, then pulls the module
17
+ * space. Wrapped in retryOnAuthError so a stale token re-links cleanly.
18
+ *
19
+ * @returns {Promise<{ count: number, revision: number|null }>}
20
+ */
21
+ export async function syncSnapshot({ spinnerText = 'Fetching snapshot...' } = {}) {
22
+ await syncCapabilities();
23
+ return await retryOnAuthError(async () => {
24
+ const token = await resolveToken();
25
+
26
+ const spinner = createSpinner(spinnerText);
27
+ spinner.start();
28
+
29
+ let response;
30
+ try {
31
+ response = await getFiles(token);
32
+ spinner.stop();
33
+ } catch (err) {
34
+ spinner.stop();
35
+ console.error(chalk.red('Error: ') + describeServerError(err));
36
+ throw err;
37
+ }
38
+
39
+ const canonical = response.canonical_modules || {};
40
+ replaceSnapshot(canonical);
41
+
42
+ if (response.revision != null) {
43
+ const s = loadState();
44
+ s.last_known_revision = response.revision;
45
+ saveState(s);
46
+ }
47
+
48
+ return { count: Object.keys(canonical).length, revision: response.revision ?? null };
49
+ });
50
+ }
@@ -0,0 +1,95 @@
1
+ # AGENTS.md
2
+
3
+ Orientation for AI coding assistants (and humans) working in this project.
4
+ This file is auto-loaded by Claude Code, Cursor, and similar tools — keep it short
5
+ and true. For the full SDK reference (db, Bot API, fetch), see
6
+ [docs/tgcloud-sdk.md](docs/tgcloud-sdk.md).
7
+
8
+ ## What this project is
9
+
10
+ A **Telegram Mini App bot** running on Telegram's serverless platform. You write
11
+ JavaScript modules (database schema, shared library code, update handlers); the
12
+ platform runs them in a V8 isolate. The `tgcloud` CLI syncs this local project
13
+ with the bot's cloud environment — think `wrangler`/`vercel` + `drizzle-kit`.
14
+
15
+ There is no server to run locally and no `node_modules` to import from at runtime:
16
+ the only things available inside a module are the platform SDK and other modules
17
+ in this project.
18
+
19
+ ## Layout
20
+
21
+ | Path | What it is |
22
+ |-----------------|-------------------------------------------------------------------|
23
+ | `schema.js` | Database schema — tables as **named exports**. One file, at root. |
24
+ | `lib/` | Shared modules. Subdirectories allowed (`lib/internal/util.js`). |
25
+ | `handlers/` | Update handlers, **one level only**. Names match Telegram Bot API update types (`message`, `callback_query`, …). |
26
+ | `docs/` | Reference docs (this project's, for you). Not deployed. |
27
+ | `.tgcloud/` | CLI state (credentials, snapshot, cached layout). **Never edit or read from here** — it's gitignored machine state. |
28
+
29
+ Only `.js` files in `schema.js`, `lib/`, and `handlers/` are deployed. Everything
30
+ else (Markdown, config, `.tgcloud/`) is local-only.
31
+
32
+ ## Module system — the rules that bite
33
+
34
+ - **Import by bare module name, never a relative path or file extension.**
35
+ The platform resolves modules by their name in the module space, not by the
36
+ filesystem.
37
+ - ✅ `import { users } from 'schema'`
38
+ - ✅ `import { addItem } from 'lib/cart'`
39
+ - ✅ `import { db, api, fetch } from 'sdk'` / `import { eq, sql } from 'sdk/db'`
40
+ - ❌ `import { users } from './schema'` or `'../schema'` → **won't compile**
41
+ - ❌ `import x from 'lib/cart.js'` → drop the `.js`
42
+ - **No filesystem, no npm packages** at runtime. Only `sdk` (and its submodules
43
+ like `sdk/db`) and your own project modules exist.
44
+ - A handler module's `export default` is what the platform invokes, with the
45
+ update's **payload** as the first argument — for `handlers/message` that's the
46
+ `Message` (i.e. `update.message`), for `handlers/callback_query` the
47
+ `CallbackQuery`, and so on. The full `Update` (with `update_id`) is on the
48
+ second argument: `ctx.update`.
49
+
50
+ ## Platform SDK (`import … from 'sdk'`)
51
+
52
+ - **`db`** — the database (query builder + schema DSL). Full API: [docs/tgcloud-sdk.md](docs/tgcloud-sdk.md).
53
+ - **`api`** — the Telegram Bot API. `api.<method>({...})` (e.g. `api.sendMessage`,
54
+ `api.getMe`) returns the **unwrapped** result and **throws `BotApiError`** on
55
+ failure (`import { BotApiError } from 'sdk'`; it has `.code`/`.description`/`.parameters`).
56
+ - **`fetch`** — outbound HTTP, web-`fetch`-like (`res.status/ok`, `res.json()`,
57
+ `res.text()`, streaming via `for await`, redirects followed).
58
+
59
+ ## Database — the rules that bite
60
+
61
+ Full API in [docs/tgcloud-sdk.md](docs/tgcloud-sdk.md). The non-obvious parts:
62
+
63
+ - **Every DB call is async — always `await`.** `.all()`, `.get()`, `.values()`,
64
+ `.run()`, `db.$count()` and the raw `db.run/all/get` all return Promises.
65
+ - **No foreign keys.** `.references()` and `foreignKey()` **throw at declaration**
66
+ — the runtime runs with FKs off, so they'd be silently inert. Enforce integrity
67
+ in application code (delete children before parents, etc.).
68
+ - **Drops happen only via `.deprecated('reason')`** on a column/table/index.
69
+ Deleting the declaration does *not* drop anything.
70
+ - **Type changes aren't automatic** — do them by hand with `db.run(...)`.
71
+
72
+ ## Deploy & migrate workflow
73
+
74
+ **Deploying never touches the database.** Schema sync is a separate, explicit step.
75
+
76
+ The CLI is a local dev-dependency, so run it with `npx tgcloud <command>` (or use
77
+ the `npm run` scripts in package.json — e.g. `npm run deploy`):
78
+
79
+ ```
80
+ npx tgcloud status # what changed locally vs the cloud
81
+ npx tgcloud push # deploy modules to the cloud
82
+ npx tgcloud migrate # apply schema.js changes to the database (interactive)
83
+ npx tgcloud run <module> [args] # execute a handler server-side
84
+ npx tgcloud pull # bring the local project in line with the cloud
85
+ npx tgcloud login # link this project to a bot
86
+ npx tgcloud webhook # show the bot's webhook and whether it matches your handlers
87
+ ```
88
+
89
+ After you change `schema.js`, `push` reports what the DB *would* change but applies
90
+ nothing — run `npx tgcloud migrate` to actually apply it.
91
+
92
+ The platform manages the bot's webhook for you, derived from your deployed
93
+ `handlers/*`, and refreshes it on `push`. If it ever drifts — e.g. someone called
94
+ `setWebhook` with the raw bot token — `npx tgcloud webhook` shows the mismatch and
95
+ `npx tgcloud webhook sync` repairs it.