@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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/bin/tgcloud.js +2 -0
- package/package.json +36 -0
- package/src/api/client.js +136 -0
- package/src/api/endpoints.js +124 -0
- package/src/cli.js +51 -0
- package/src/commands/add.js +133 -0
- package/src/commands/completion.js +298 -0
- package/src/commands/deploy.js +319 -0
- package/src/commands/diff.js +57 -0
- package/src/commands/fetch.js +23 -0
- package/src/commands/init.js +226 -0
- package/src/commands/login.js +56 -0
- package/src/commands/migrate.js +142 -0
- package/src/commands/pull.js +156 -0
- package/src/commands/reset.js +95 -0
- package/src/commands/run.js +145 -0
- package/src/commands/status.js +72 -0
- package/src/commands/webhook.js +139 -0
- package/src/core/capabilities.js +83 -0
- package/src/core/credentials.js +229 -0
- package/src/core/db-changes.js +221 -0
- package/src/core/file-diff.js +11 -0
- package/src/core/hasher.js +11 -0
- package/src/core/local-diff.js +57 -0
- package/src/core/migration-flow.js +356 -0
- package/src/core/project-layout.js +294 -0
- package/src/core/project-root.js +47 -0
- package/src/core/run-output.js +134 -0
- package/src/core/scanner.js +115 -0
- package/src/core/snapshot.js +132 -0
- package/src/core/state.js +92 -0
- package/src/core/sync.js +50 -0
- package/src/templates/AGENTS.md +95 -0
- package/src/templates/docs/tgcloud-sdk.md +347 -0
- package/src/templates/gitignore +2 -0
- package/src/templates/handlers/_default_.js +6 -0
- package/src/templates/handlers/message.js +13 -0
- package/src/templates/lib/_default_.js +2 -0
- package/src/templates/schema.js +10 -0
- package/src/utils/logger.js +54 -0
- package/src/utils/pager.js +76 -0
- package/src/utils/prompt.js +220 -0
- 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
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
|
+
}
|