@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Telegram Messenger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @tgcloud/cli
2
+
3
+ The command-line tool for [Telegram Serverless](https://core.telegram.org/bots/serverless) —
4
+ run your bot's backend on Telegram's own infrastructure, with no server to host or scale.
5
+ Write it in JavaScript, deploy with a single command, and manage your project, database,
6
+ and bot right from the terminal.
7
+
8
+ ## Install
9
+
10
+ The CLI is normally a project-local dev-dependency. The fastest way to start a
11
+ new project — which installs it for you — is:
12
+
13
+ ```sh
14
+ npm create @tgcloud/bot example_bot
15
+ ```
16
+
17
+ To add it to an existing project:
18
+
19
+ ```sh
20
+ npm install -D @tgcloud/cli
21
+ ```
22
+
23
+ Inside a project you run it with `npx tgcloud <command>`, or through the `npm run`
24
+ scripts the scaffold sets up.
25
+
26
+ Requires Node.js 18 or newer.
27
+
28
+ ## Quick start
29
+
30
+ ```sh
31
+ npx tgcloud init # scaffold a project
32
+ npx tgcloud login # link a bot
33
+ npx tgcloud push # deploy
34
+ ```
35
+
36
+ Run `npx tgcloud --help` to see every command.
37
+
38
+ ## Global install
39
+
40
+ You can also install the CLI globally if you'd rather run a bare `tgcloud` from any
41
+ folder instead of `npx tgcloud`:
42
+
43
+ ```sh
44
+ npm install -g @tgcloud/cli
45
+ ```
46
+
47
+ ## Documentation
48
+
49
+ Full documentation: https://core.telegram.org/bots/serverless
50
+
51
+ ## License
52
+
53
+ MIT
package/bin/tgcloud.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@tgcloud/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for the Telegram serverless platform",
5
+ "keywords": [
6
+ "telegram",
7
+ "telegram-bot",
8
+ "bot",
9
+ "serverless",
10
+ "cli",
11
+ "deploy",
12
+ "tgcloud"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "bin": {
17
+ "tgcloud": "bin/tgcloud.js"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18.0.0"
25
+ },
26
+ "dependencies": {
27
+ "chalk": "^5.3.0",
28
+ "commander": "^12.0.0",
29
+ "diff": "^9.0.0",
30
+ "json5": "^2.2.3",
31
+ "ora": "^8.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,136 @@
1
+ const PROD_URL = 'https://cloud.telegram.org';
2
+ // TG_CLOUD_API_URL overrides the base. TGCLOUD_BETA=1
3
+ // targets the beta backend by appending /beta to whichever base is in effect.
4
+ const API_BASE = process.env.TG_CLOUD_API_URL || PROD_URL;
5
+ const BASE_URL =
6
+ process.env.TGCLOUD_BETA === '1' ? `${API_BASE.replace(/\/+$/, '')}/beta` : API_BASE;
7
+
8
+ function isDebug() {
9
+ return process.env.TGCLOUD_DEBUG === '1' || process.env.TGCLOUD_DEBUG === 'true';
10
+ }
11
+
12
+ /**
13
+ * Extract app_id from token.
14
+ * Token format: "app1234:feiugjritgjr" → app_id = "app1234"
15
+ */
16
+ function extractAppId(token) {
17
+ const parts = token.split(':');
18
+ if (parts.length !== 2 || !/^app\d+$/.test(parts[0])) {
19
+ throw new Error('Invalid token format. Expected "app<id>:<secret>".');
20
+ }
21
+ return parts[0];
22
+ }
23
+
24
+ export async function request(method, path, token, body) {
25
+ const appId = extractAppId(token);
26
+ const url = `${BASE_URL}/${appId}/manage${path}`;
27
+ const headers = {
28
+ 'Authorization': `Bearer ${token}`,
29
+ 'Content-Type': 'application/json',
30
+ };
31
+
32
+ if (isDebug()) {
33
+ // Bearer token is redacted. App-id (public part) is shown.
34
+ console.error(`\n[debug] → ${method} ${url}`);
35
+ console.error(`[debug] Authorization: Bearer ${appId}:***`);
36
+ if (body !== undefined) {
37
+ console.error('[debug] body:', JSON.stringify(body, null, 2));
38
+ }
39
+ }
40
+
41
+ const res = await fetch(url, {
42
+ method,
43
+ headers,
44
+ body: body ? JSON.stringify(body) : undefined,
45
+ });
46
+
47
+ return parseEnvelope(res);
48
+ }
49
+
50
+ /**
51
+ * Request a platform-global, unauthenticated endpoint (no app-id, no token) —
52
+ * e.g. GET /capabilities, which describes the project layout and is the same
53
+ * for every bot. Same `{ ok, result }` envelope as `request`.
54
+ */
55
+ export async function requestPublic(method, path) {
56
+ const url = `${BASE_URL}${path}`;
57
+
58
+ if (isDebug()) {
59
+ console.error(`\n[debug] → ${method} ${url} (public)`);
60
+ }
61
+
62
+ const res = await fetch(url, {
63
+ method,
64
+ headers: { 'Content-Type': 'application/json' },
65
+ });
66
+
67
+ return parseEnvelope(res);
68
+ }
69
+
70
+ async function parseEnvelope(res) {
71
+ const rawText = await res.text();
72
+
73
+ if (isDebug()) {
74
+ console.error(`[debug] ← HTTP ${res.status} ${res.statusText}`);
75
+ console.error(`[debug] body: ${rawText.length ? rawText : '(empty)'}`);
76
+ }
77
+
78
+ let data;
79
+ try {
80
+ data = JSON.parse(rawText);
81
+ } catch {
82
+ throw new Error(`Invalid response from server (not JSON). HTTP ${res.status}`);
83
+ }
84
+
85
+ if (typeof data !== 'object' || data === null || typeof data.ok === 'undefined') {
86
+ throw new Error('Invalid response format: expected { ok, result }.');
87
+ }
88
+
89
+ if (!data.ok) {
90
+ // Error envelope: { ok:false, description, error_code, parameters? }. The
91
+ // server sends a human `description`; `data.error` is an alternate string form
92
+ // used as a fallback.
93
+ const err = new Error(data.description || data.error || `Server error (HTTP ${res.status})`);
94
+ err.description = data.description; // human text
95
+ err.code = data.error_code ?? data.error; // numeric error_code, or string form
96
+ err.status = res.status;
97
+ err.parameters = data.parameters || {};
98
+ throw err;
99
+ }
100
+
101
+ return data.result;
102
+ }
103
+
104
+ /**
105
+ * Human-facing one-liner for a server error: prefer the server's `description`,
106
+ * fall back to the code/message, and append the offending module when the
107
+ * server identifies one (e.g. MODULE_NAME_INVALID → which module).
108
+ */
109
+ export function describeServerError(err) {
110
+ // Network/transport failure: fetch() rejects with a TypeError and no HTTP
111
+ // status (server errors always set err.status via parseEnvelope). The raw
112
+ // message is just "fetch failed", which isn't actionable — give the user
113
+ // something they can do something about.
114
+ if (
115
+ err &&
116
+ err.status == null &&
117
+ (err.name === 'TypeError' || /fetch failed/i.test(err.message || ''))
118
+ ) {
119
+ return (
120
+ `Could not reach the tgcloud server at ${BASE_URL}. ` +
121
+ 'Check your connection (or the TG_CLOUD_API_URL setting).'
122
+ );
123
+ }
124
+
125
+ let msg = err?.description || err?.message || 'Server error';
126
+ const mod = err?.parameters?.module;
127
+ if (mod && !msg.includes(mod)) msg += ` (module: ${mod})`;
128
+ return stripControl(msg);
129
+ }
130
+
131
+ // Server-provided strings (error descriptions, version hints) get printed to the
132
+ // user's terminal — strip control/escape bytes so a hostile or buggy server
133
+ // can't inject ANSI sequences (screen clears, cursor moves, misleading output).
134
+ export function stripControl(s) {
135
+ return String(s).replace(/[\x00-\x1f\x7f]/g, '');
136
+ }
@@ -0,0 +1,124 @@
1
+ import { request, requestPublic, describeServerError } from './client.js';
2
+ import { pathToModule, moduleToPath } from '../core/snapshot.js';
3
+
4
+ /**
5
+ * Fetch the platform's project-layout descriptor (GET /capabilities).
6
+ * Global and unauthenticated — describes the shape of a project (root files,
7
+ * module directories and their flags), identical for every bot.
8
+ *
9
+ * Returns the raw descriptor object; validation/sanitization is the caller's
10
+ * job (core/project-layout.js → validateDescriptor).
11
+ */
12
+ export async function fetchCapabilities() {
13
+ return requestPublic('GET', '/capabilities');
14
+ }
15
+
16
+ /**
17
+ * Batch deploy: push changed modules, implicitly delete modules not in the manifest.
18
+ *
19
+ * Concurrency control:
20
+ * - If `expected_revision` is provided, server must match it atomically; otherwise
21
+ * it returns a revision-mismatch error with the current revision.
22
+ * - If `expected_revision` is null/omitted, the deploy is unconditional ("force").
23
+ * Caller is responsible for any extra safety (e.g. --force flag).
24
+ *
25
+ * @param {string} token
26
+ * @param {object} payload
27
+ * @param {number|null} [payload.expected_revision] — client's last_known_revision
28
+ * @param {string[]} payload.manifest — full list of module names (without .js)
29
+ * @param {Object<string,string>} payload.changed_sources — map moduleName → content
30
+ *
31
+ * @returns {Promise<{ revision: number, canonical_modules: Object<string,string>, db_changes?: any[] }>}
32
+ */
33
+ export async function batchDeploy(token, { expected_revision, manifest, changed_sources }) {
34
+ const body = { manifest, changed_sources };
35
+ if (expected_revision != null) body.expected_revision = expected_revision;
36
+ return request('POST', '/deploy', token, body);
37
+ }
38
+
39
+ /**
40
+ * Run a module on the server without deploying.
41
+ *
42
+ * `moduleName` is the full module path without `.js`, e.g. "handlers/message".
43
+ * The server only accepts modules from `handlers/`.
44
+ *
45
+ * `sources` is the FULL module space for this invocation: { moduleName: code }.
46
+ * Server resolves all imports against `sources` only — its deployed state
47
+ * is not consulted. The target `moduleName` must be a key in `sources`.
48
+ */
49
+ export async function runFunction(token, moduleName, sources, args, ctx) {
50
+ return request('POST', '/run', token, {
51
+ module: moduleName,
52
+ sources,
53
+ args,
54
+ ctx: ctx || {},
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Full server state for pull/fetch.
60
+ * Returns { revision, canonical_modules: { moduleName: content } }.
61
+ */
62
+ export async function getFiles(token) {
63
+ return request('GET', '/get', token);
64
+ }
65
+
66
+ /**
67
+ * Webhook status the platform manages (GET /webhook).
68
+ *
69
+ * The platform owns the bot's webhook (SDK Bot API `getWebhookInfo`/`setWebhook`
70
+ * are restricted), so this is a manage-plane read. Returns the live webhook plus
71
+ * `expected` (what the platform would set for the deployed handler set) and a
72
+ * server-computed `in_sync` verdict.
73
+ *
74
+ * @returns {Promise<{ url:string, allowed_updates:string[], pending_update_count:number,
75
+ * last_error_date?:number, last_error_message?:string,
76
+ * expected:{ url:string, allowed_updates:string[] }, in_sync:boolean }>}
77
+ */
78
+ export async function getWebhook(token) {
79
+ return request('GET', '/webhook', token);
80
+ }
81
+
82
+ /**
83
+ * Re-assert the platform webhook + allowed_updates from the deployed handler set
84
+ * (POST /webhook/sync). Repairs a desync (e.g. someone called setWebhook with the
85
+ * raw bot token). Returns the same shape as getWebhook, now in_sync.
86
+ */
87
+ export async function syncWebhook(token, { drop_pending = false } = {}) {
88
+ const body = {};
89
+ if (drop_pending) body.drop_pending_updates = true;
90
+ return request('POST', '/webhook/sync', token, body);
91
+ }
92
+
93
+ /**
94
+ * Get the current DB migration status without applying anything.
95
+ * Computes diff between DEPLOYED schema and DB.
96
+ * Returns { db_changes: [...] }.
97
+ */
98
+ export async function getMigrationStatus(token) {
99
+ return request('GET', '/migrate', token);
100
+ }
101
+
102
+ /**
103
+ * Compute/apply migration via POST /migrate.
104
+ *
105
+ * apply: list of change id's to apply (see changeId() in db-changes.js).
106
+ * Each id identifies one entry from the current db_changes list.
107
+ * For safe changes the caller typically sends all ids in one call;
108
+ * for warning changes — one id per call (per-change confirmation).
109
+ * schema: if present, server uses this schema string (instead of its
110
+ * currently-deployed schema.js) for diff calculation. Server's
111
+ * stored schema.js is NOT updated — caller must `push` afterward.
112
+ * dry_run: if true, server only returns db_changes, no changes are applied.
113
+ * `apply` is ignored when `dry_run: true`.
114
+ */
115
+ export async function applyMigration(token, { apply, schema, dry_run = false } = {}) {
116
+ const body = {};
117
+ if (apply != null) body.apply = apply;
118
+ if (schema != null) body.schema = schema;
119
+ if (dry_run) body.dry_run = true;
120
+ return request('POST', '/migrate', token, body);
121
+ }
122
+
123
+ // Re-export for convenience
124
+ export { pathToModule, moduleToPath, describeServerError };
package/src/cli.js ADDED
@@ -0,0 +1,51 @@
1
+ import { program } from 'commander';
2
+ import { enterProjectRoot } from './core/project-root.js';
3
+ import { readCliVersion } from './core/capabilities.js';
4
+ import { registerLogin } from './commands/login.js';
5
+ import { registerInit } from './commands/init.js';
6
+ import { registerAdd } from './commands/add.js';
7
+ import { registerDeploy } from './commands/deploy.js';
8
+ import { registerStatus } from './commands/status.js';
9
+ import { registerDiff } from './commands/diff.js';
10
+ import { registerRun } from './commands/run.js';
11
+ import { registerPull } from './commands/pull.js';
12
+ import { registerMigrate } from './commands/migrate.js';
13
+ import { registerFetch } from './commands/fetch.js';
14
+ import { registerReset } from './commands/reset.js';
15
+ import { registerWebhook } from './commands/webhook.js';
16
+ import { registerCompletion } from './commands/completion.js';
17
+
18
+ program
19
+ .name('tgcloud')
20
+ .description('CLI for the Telegram serverless platform')
21
+ .version(readCliVersion())
22
+ .option('--debug', 'Log HTTP requests and responses')
23
+ .option('--no-pager', 'Disable the pager; write output straight to stdout')
24
+ .hook('preAction', (thisCommand, actionCommand) => {
25
+ const opts = thisCommand.opts();
26
+ if (opts.debug) process.env.TGCLOUD_DEBUG = '1';
27
+ // commander sets `pager: false` when --no-pager is given; default is `true`.
28
+ if (opts.pager === false) process.env.TGCLOUD_NO_PAGER = '1';
29
+
30
+ // Resolve the project root (nearest ancestor with .tgcloud/) and operate
31
+ // from there, so commands work from any subdirectory. `init` is exempt: it
32
+ // creates a new project in the current directory and guards against nesting
33
+ // itself.
34
+ if (actionCommand.name() !== 'init') enterProjectRoot();
35
+ });
36
+
37
+ registerLogin(program);
38
+ registerInit(program);
39
+ registerAdd(program);
40
+ registerDeploy(program);
41
+ registerStatus(program);
42
+ registerDiff(program);
43
+ registerRun(program);
44
+ registerPull(program);
45
+ registerFetch(program);
46
+ registerReset(program);
47
+ registerMigrate(program);
48
+ registerWebhook(program);
49
+ registerCompletion(program);
50
+
51
+ program.parse();
@@ -0,0 +1,133 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { fileURLToPath } from 'url';
5
+ import { syncCapabilities } from '../core/capabilities.js';
6
+ import {
7
+ moduleDirs,
8
+ nestableDirs,
9
+ directoryByName,
10
+ templates,
11
+ sanitizeRelPath,
12
+ } from '../core/project-layout.js';
13
+ import { success, info, fatal } from '../utils/logger.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const templatesDir = path.join(__dirname, '..', 'templates');
17
+
18
+ // The reserved per-directory starter (on disk) that `add` scaffolds from — the
19
+ // server serves the same template under the directory key ("handlers/").
20
+ // `$$BASENAME$$` in it is replaced with the new module's name.
21
+ const ADD_TEMPLATE = '_default_.js';
22
+ // `$$BASENAME$$` → the leaf name (the update type for handlers/, the function
23
+ // parameter). `$$MODULE$$` → the full module name incl. any nested lib/ path
24
+ // (e.g. `lib/internal/util`), which is how the module is imported.
25
+ const NAME_TOKEN = /\$\$BASENAME\$\$/g;
26
+ const MODULE_TOKEN = /\$\$MODULE\$\$/g;
27
+
28
+ // Split a raw target into (dir, leaf). Accepts a trailing `.js`, a trailing
29
+ // slash, or no leaf at all (→ error). "handlers/message" →
30
+ // { dir: 'handlers', leaf: 'message' }; "handlers" or "handlers/" →
31
+ // { dir: 'handlers', leaf: null }.
32
+ function parseTarget(raw) {
33
+ const s = String(raw).replace(/\\/g, '/').replace(/\.js$/i, '').replace(/\/+$/, '');
34
+ const segments = s.split('/').filter(Boolean);
35
+ if (!segments.length) return null;
36
+ const [dir, ...rest] = segments;
37
+ return { dir, leaf: rest.length ? rest.join('/') : null };
38
+ }
39
+
40
+ // Content for a new module: the directory's starter template — the server's
41
+ // copy (from GET /capabilities `templates`, keyed by the directory itself, e.g.
42
+ // `"handlers/"`), else the CLI's bundled `<dir>/dir-default.js`, else empty —
43
+ // with `$$BASENAME$$` replaced by the module name.
44
+ //
45
+ // This directory-level template is deliberately distinct from the exact-path
46
+ // `init` starters (e.g. the onboarding handlers/message.js): `init` seeds a
47
+ // fresh project with worked examples; `add` gives a clean per-directory stub.
48
+ // Sharing them would leak the onboarding echo into every added handler.
49
+ function moduleContent(dir, moduleName, basename) {
50
+ const serverTemplates = templates();
51
+ let raw = serverTemplates[`${dir}/`];
52
+ if (raw == null) {
53
+ const bundled = path.join(templatesDir, dir, ADD_TEMPLATE);
54
+ raw = fs.existsSync(bundled) ? fs.readFileSync(bundled, 'utf-8') : '';
55
+ }
56
+ return raw.replace(MODULE_TOKEN, moduleName).replace(NAME_TOKEN, basename);
57
+ }
58
+
59
+ // A bare directory ("tgcloud add handlers") is an error — you must name the
60
+ // module. When the directory advertises a closed vocabulary (handlers/ = update
61
+ // types), the error lists the names still available so it's actionable without a
62
+ // separate lookup. (Tab-completion offers the same set; see commands/completion.js.)
63
+ function requireLeaf(dir, desc) {
64
+ const allowed = desc && desc.allowedNames;
65
+ if (allowed && allowed.length) {
66
+ const available = allowed.filter((n) => !fs.existsSync(path.join(dir, `${n}.js`)));
67
+ if (!available.length) fatal(`Every ${dir}/ module already exists.`);
68
+ fatal(
69
+ `Specify a name, e.g. "${dir}/${available[0]}". ` +
70
+ `Available ${dir}/ types: ${available.join(', ')}.`
71
+ );
72
+ }
73
+ fatal(`Specify a name, e.g. "${dir}/<name>".`);
74
+ }
75
+
76
+ export function registerAdd(program) {
77
+ program
78
+ .command('add <target>')
79
+ .description(
80
+ 'Scaffold a new module — e.g. "tgcloud add handlers/message" or ' +
81
+ '"tgcloud add lib/cart". The name is required; give just the directory ' +
82
+ '("tgcloud add handlers") to see the valid names.'
83
+ )
84
+ .action(async (target) => {
85
+ // Refresh the layout so allowed names and directory templates reflect the
86
+ // latest server descriptor (best-effort; offline falls back to baseline).
87
+ await syncCapabilities();
88
+
89
+ const parsed = parseTarget(target);
90
+ if (!parsed) fatal(`Invalid target: ${target}`);
91
+ const { dir } = parsed;
92
+
93
+ const dirs = moduleDirs();
94
+ if (!dirs.includes(dir)) {
95
+ fatal(`Unknown directory "${dir}". Add modules under: ${dirs.map((d) => d + '/').join(', ')}.`);
96
+ }
97
+
98
+ const desc = directoryByName(dir);
99
+ const leaf = parsed.leaf;
100
+ if (!leaf) requireLeaf(dir, desc); // fatal — never returns
101
+
102
+ // A flat directory (e.g. handlers/) takes a single-segment name only.
103
+ if (leaf.includes('/') && !nestableDirs().has(dir)) {
104
+ fatal(`${dir}/ is flat — use a single name, not a nested path.`);
105
+ }
106
+
107
+ const relPath = `${dir}/${leaf}.js`;
108
+ if (!sanitizeRelPath(relPath)) {
109
+ fatal(`Invalid module name: ${leaf}`);
110
+ }
111
+
112
+ // Enforce a closed name set when the directory advertises one.
113
+ const finalName = leaf.split('/').pop();
114
+ if (desc && desc.allowedNames && !desc.allowedNames.includes(finalName)) {
115
+ fatal(
116
+ `"${finalName}" is not a valid ${dir}/ module name. ` +
117
+ `Allowed: ${desc.allowedNames.join(', ')}.`
118
+ );
119
+ }
120
+
121
+ if (fs.existsSync(relPath)) {
122
+ fatal(`${relPath} already exists.`);
123
+ }
124
+
125
+ const parentDir = path.dirname(relPath);
126
+ if (parentDir && parentDir !== '.') fs.mkdirSync(parentDir, { recursive: true });
127
+ fs.writeFileSync(relPath, moduleContent(dir, `${dir}/${leaf}`, finalName));
128
+
129
+ info(`Adding a new ${dir}/ module...`);
130
+ success(relPath);
131
+ info(`Edit it, then deploy with ${chalk.cyan('tgcloud push')}.`);
132
+ });
133
+ }