@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { localDiff } from '../core/local-diff.js';
|
|
4
|
+
import { readSnapshotFile, snapshotExists } from '../core/snapshot.js';
|
|
5
|
+
import { confirm, closePrompts } from '../utils/prompt.js';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { done, info, fatal } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reset working-directory files to match the local snapshot.
|
|
11
|
+
* - With a file argument: reset that file (no confirmation).
|
|
12
|
+
* - Without: reset all changed files, with a single confirmation prompt.
|
|
13
|
+
*
|
|
14
|
+
* Semantics:
|
|
15
|
+
* modified → rewrite with snapshot content
|
|
16
|
+
* newLocal → delete (file was added locally, not in snapshot)
|
|
17
|
+
* remoteOnly → restore (file was deleted locally, still in snapshot)
|
|
18
|
+
*/
|
|
19
|
+
export function registerReset(program) {
|
|
20
|
+
program
|
|
21
|
+
.command('reset [file]')
|
|
22
|
+
.description('Discard local changes by copying from the local snapshot (offline)')
|
|
23
|
+
.action(async (targetFile) => {
|
|
24
|
+
try {
|
|
25
|
+
await runReset(targetFile);
|
|
26
|
+
} finally {
|
|
27
|
+
closePrompts();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runReset(targetFile) {
|
|
33
|
+
if (!snapshotExists()) {
|
|
34
|
+
fatal('No local snapshot. Run "tgcloud pull" or "tgcloud fetch" first.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const diff = localDiff();
|
|
38
|
+
const changes = [
|
|
39
|
+
...diff.modified.map((f) => ({ ...f, kind: 'modified' })),
|
|
40
|
+
...diff.newLocal.map((f) => ({ ...f, kind: 'new' })),
|
|
41
|
+
...diff.remoteOnly.map((f) => ({ ...f, kind: 'deleted' })),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const filtered = targetFile
|
|
45
|
+
? changes.filter((c) => c.path === targetFile)
|
|
46
|
+
: changes;
|
|
47
|
+
|
|
48
|
+
if (!filtered.length) {
|
|
49
|
+
info(targetFile
|
|
50
|
+
? `${targetFile}: no local changes to reset.`
|
|
51
|
+
: 'Working directory clean. Nothing to reset.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Without a file argument — confirm the full reset.
|
|
56
|
+
if (!targetFile) {
|
|
57
|
+
console.log(`Will reset ${filtered.length} file${filtered.length === 1 ? '' : 's'}:`);
|
|
58
|
+
for (const c of filtered) {
|
|
59
|
+
console.log(' ' + chalk.dim(`${labelFor(c.kind).padEnd(7)} →`) + ` ${c.path}`);
|
|
60
|
+
}
|
|
61
|
+
const ok = await confirm('Reset all local changes?');
|
|
62
|
+
if (!ok) {
|
|
63
|
+
info('Cancelled.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const c of filtered) {
|
|
69
|
+
if (c.kind === 'modified' || c.kind === 'deleted') {
|
|
70
|
+
const content = readSnapshotFile(c.path);
|
|
71
|
+
if (content == null) {
|
|
72
|
+
fatal(`Snapshot missing for ${c.path} (unexpected).`);
|
|
73
|
+
}
|
|
74
|
+
const dir = path.dirname(c.path);
|
|
75
|
+
if (dir !== '.' && !fs.existsSync(dir)) {
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
fs.writeFileSync(c.path, content);
|
|
79
|
+
done(c.path, 'restored');
|
|
80
|
+
} else if (c.kind === 'new') {
|
|
81
|
+
if (fs.existsSync(c.path)) fs.unlinkSync(c.path);
|
|
82
|
+
done(c.path, 'removed');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Bare verb; the caller pads it so the → arrows line up (longest is "restore").
|
|
88
|
+
function labelFor(kind) {
|
|
89
|
+
switch (kind) {
|
|
90
|
+
case 'modified': return 'modify';
|
|
91
|
+
case 'new': return 'remove';
|
|
92
|
+
case 'deleted': return 'restore';
|
|
93
|
+
default: return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
import { resolveToken, retryOnAuthError } from '../core/credentials.js';
|
|
6
|
+
import { syncCapabilities } from '../core/capabilities.js';
|
|
7
|
+
import { scanFiles } from '../core/scanner.js';
|
|
8
|
+
import { runnableDirs } from '../core/project-layout.js';
|
|
9
|
+
import { pathToModule } from '../core/snapshot.js';
|
|
10
|
+
import { runFunction, describeServerError } from '../api/endpoints.js';
|
|
11
|
+
import { createSpinner } from '../utils/spinner.js';
|
|
12
|
+
import { fatal } from '../utils/logger.js';
|
|
13
|
+
import { formatRunHeader, formatRunLogs, formatRunReturn } from '../core/run-output.js';
|
|
14
|
+
|
|
15
|
+
function parseJson5Inline(str, errorMsg) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON5.parse(str);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
fatal(`${errorMsg}: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRunnable(filePath, runnable) {
|
|
24
|
+
return runnable.some((dir) =>
|
|
25
|
+
filePath.startsWith(dir + path.sep) || filePath.startsWith(dir + '/')
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveFilePath(input) {
|
|
30
|
+
const runnable = runnableDirs();
|
|
31
|
+
const allowed = new Set(scanFiles().filter((p) => isRunnable(p, runnable)));
|
|
32
|
+
const candidates = [];
|
|
33
|
+
|
|
34
|
+
const withExt = input.endsWith('.js') ? input : `${input}.js`;
|
|
35
|
+
|
|
36
|
+
if (input.includes('/') || input.includes(path.sep)) {
|
|
37
|
+
candidates.push(withExt);
|
|
38
|
+
} else {
|
|
39
|
+
for (const dir of runnable) {
|
|
40
|
+
candidates.push(path.join(dir, withExt));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
const normalized = path.normalize(candidate);
|
|
46
|
+
if (allowed.has(normalized) && fs.existsSync(normalized)) {
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
const normalized = path.normalize(candidate);
|
|
53
|
+
if (fs.existsSync(normalized) && !isRunnable(normalized, runnable)) {
|
|
54
|
+
fatal(
|
|
55
|
+
`${normalized} is not runnable. Only modules in ${runnable.map((d) => d + '/').join(' and ')} can be run.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (candidates.length === 1) {
|
|
61
|
+
fatal(`Module not found: ${candidates[0]}. Run "tgcloud status" to see your modules.`);
|
|
62
|
+
} else {
|
|
63
|
+
fatal(`Module not found in any of: ${candidates.join(', ')}. Run "tgcloud status" to see your modules.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerRun(program) {
|
|
68
|
+
program
|
|
69
|
+
.command('run <module> [args]')
|
|
70
|
+
.description(
|
|
71
|
+
'Run a module in the cloud without deploying. <module> can be a bare ' +
|
|
72
|
+
'name (searches handlers/) or a relative path inside that directory. ' +
|
|
73
|
+
'[args] is an optional JSON5 expression with ' +
|
|
74
|
+
'arguments to pass to the function. ' +
|
|
75
|
+
'Read args from a file: tgcloud run module "$(cat args.json5)".'
|
|
76
|
+
)
|
|
77
|
+
.option('--ctx <json5>', 'JSON5 context')
|
|
78
|
+
.action(async (moduleArg, positionalArgs, options) => {
|
|
79
|
+
// Refresh the layout so the runnable-directory whitelist reflects any
|
|
80
|
+
// directories the platform has added since this CLI was released.
|
|
81
|
+
await syncCapabilities();
|
|
82
|
+
|
|
83
|
+
const filePath = resolveFilePath(moduleArg);
|
|
84
|
+
const moduleName = pathToModule(filePath);
|
|
85
|
+
|
|
86
|
+
const args = positionalArgs !== undefined
|
|
87
|
+
? parseJson5Inline(positionalArgs, 'Invalid JSON5 in args')
|
|
88
|
+
: undefined;
|
|
89
|
+
|
|
90
|
+
const ctx = options.ctx !== undefined
|
|
91
|
+
? parseJson5Inline(options.ctx, 'Invalid JSON5 in --ctx')
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
// Build the full module space from the local working directory.
|
|
95
|
+
// The server resolves all imports against this map alone — its deployed
|
|
96
|
+
// state is not consulted, so we send EVERY local module so locally
|
|
97
|
+
// modified `lib/*` (and others) are picked up. Syntax is validated
|
|
98
|
+
// server-side (it compiles only the imported graph and reports
|
|
99
|
+
// `Module error: … [module:line:col]`); we don't pre-check locally.
|
|
100
|
+
const allFiles = scanFiles();
|
|
101
|
+
const sources = {};
|
|
102
|
+
for (const f of allFiles) {
|
|
103
|
+
sources[pathToModule(f)] = fs.readFileSync(f, 'utf-8');
|
|
104
|
+
}
|
|
105
|
+
if (!(moduleName in sources)) {
|
|
106
|
+
fatal(`Internal: ${moduleName} not in collected sources.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await retryOnAuthError(async () => {
|
|
111
|
+
const token = await resolveToken();
|
|
112
|
+
|
|
113
|
+
// Print the invocation header BEFORE starting the spinner, so the
|
|
114
|
+
// spinner animation appears underneath it: `>` line stays as the
|
|
115
|
+
// "input echo", spinner is the "in flight" indicator below.
|
|
116
|
+
console.log(formatRunHeader(moduleName, args));
|
|
117
|
+
const spinner = createSpinner('Running...');
|
|
118
|
+
spinner.start();
|
|
119
|
+
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
result = await runFunction(token, moduleName, sources, args, ctx);
|
|
123
|
+
spinner.stop();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Header already printed. Mirror the success path: any logs the
|
|
126
|
+
// server emitted, then a SINGLE error line (with timing when the
|
|
127
|
+
// server reported it). Don't use spinner.fail() here — it would
|
|
128
|
+
// print the (often multi-line) message a second time.
|
|
129
|
+
spinner.stop();
|
|
130
|
+
const params = err.parameters || {};
|
|
131
|
+
for (const line of formatRunLogs(params.log)) console.log(line);
|
|
132
|
+
const ms = params.time != null ? Math.max(0, Math.round(params.time * 1000)) : null;
|
|
133
|
+
const suffix = ms != null ? chalk.dim(` ${ms}ms`) : '';
|
|
134
|
+
console.log(chalk.red(`✗ ${describeServerError(err)}`) + suffix);
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const line of formatRunLogs(result.log)) console.log(line);
|
|
139
|
+
console.log(formatRunReturn(result.result, result.time));
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { localDiff } from '../core/local-diff.js';
|
|
3
|
+
import { warnStrayFiles } from '../core/scanner.js';
|
|
4
|
+
import { snapshotExists } from '../core/snapshot.js';
|
|
5
|
+
import { loadState } from '../core/state.js';
|
|
6
|
+
import { loadCredentials, maskToken } from '../core/credentials.js';
|
|
7
|
+
import { webhookStatusLine } from './webhook.js';
|
|
8
|
+
|
|
9
|
+
export function registerStatus(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('status')
|
|
12
|
+
.description('Show working-directory status relative to the local snapshot (offline)')
|
|
13
|
+
.action(async () => {
|
|
14
|
+
// Linked-to info (best-effort, offline)
|
|
15
|
+
const creds = loadCredentials();
|
|
16
|
+
const state = loadState();
|
|
17
|
+
|
|
18
|
+
// Mirror resolveToken()'s priority (env first, then saved creds) so the
|
|
19
|
+
// shown app-id is the one commands actually use, and label both sources.
|
|
20
|
+
if (process.env.TGCLOUD_TOKEN) {
|
|
21
|
+
console.log(chalk.dim(`Linked to ${maskToken(process.env.TGCLOUD_TOKEN)} (from env)`));
|
|
22
|
+
} else if (creds?.token) {
|
|
23
|
+
console.log(chalk.dim(`Linked to ${maskToken(creds.token)} (saved)`));
|
|
24
|
+
} else {
|
|
25
|
+
console.log(chalk.dim('Not linked to a bot yet.'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (state.last_known_revision != null) {
|
|
29
|
+
console.log(chalk.dim(`Local revision: ${state.last_known_revision}`));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Best-effort webhook health. status is otherwise offline; this line is the
|
|
33
|
+
// sole networked call, silent on any failure (see webhookStatusLine).
|
|
34
|
+
const whLine = await webhookStatusLine();
|
|
35
|
+
if (whLine) console.log(whLine);
|
|
36
|
+
|
|
37
|
+
if (!snapshotExists()) {
|
|
38
|
+
console.log('\n' + chalk.yellow('Warning: ') +
|
|
39
|
+
'No local snapshot. Run "tgcloud pull" or "tgcloud fetch" first.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const diff = localDiff();
|
|
43
|
+
|
|
44
|
+
if (diff.modified.length) {
|
|
45
|
+
console.log(chalk.yellow('\nModified:'));
|
|
46
|
+
for (const f of diff.modified) console.log(` ${f.path}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (diff.newLocal.length) {
|
|
50
|
+
console.log(chalk.green('\nNew (not yet deployed):'));
|
|
51
|
+
for (const f of diff.newLocal) console.log(` ${f.path}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (diff.remoteOnly.length) {
|
|
55
|
+
console.log(chalk.red('\nDeleted locally (still in snapshot):'));
|
|
56
|
+
for (const f of diff.remoteOnly) console.log(` ${f.path}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const anyChanges = diff.modified.length + diff.newLocal.length + diff.remoteOnly.length;
|
|
60
|
+
|
|
61
|
+
if (!anyChanges && diff.matched.length) {
|
|
62
|
+
console.log(chalk.green('\nWorking directory clean.'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Nudge about misplaced modules at the root (independent of tracked diff).
|
|
66
|
+
warnStrayFiles();
|
|
67
|
+
|
|
68
|
+
if (!anyChanges && !diff.matched.length) {
|
|
69
|
+
console.log('\nNo files found. Run "tgcloud init" to scaffold a project.');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { resolveToken, retryOnAuthError, peekToken } from '../core/credentials.js';
|
|
3
|
+
import { getWebhook, syncWebhook, describeServerError } from '../api/endpoints.js';
|
|
4
|
+
import { stripControl } from '../api/client.js';
|
|
5
|
+
import { createSpinner } from '../utils/spinner.js';
|
|
6
|
+
import { info, success } from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
// Set difference for the allowed_updates diff shown on a mismatch.
|
|
9
|
+
function diffSets(actual = [], expected = []) {
|
|
10
|
+
const a = new Set(actual);
|
|
11
|
+
const e = new Set(expected);
|
|
12
|
+
return {
|
|
13
|
+
missing: expected.filter((x) => !a.has(x)), // expected but not delivered
|
|
14
|
+
extra: actual.filter((x) => !e.has(x)), // delivered but not expected
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Render a full webhook status block. `wh` is the server payload; the server
|
|
19
|
+
// computes `in_sync` and we just display it.
|
|
20
|
+
function renderWebhook(wh) {
|
|
21
|
+
// One value column for the whole block: 2-space indent + label padded to 18,
|
|
22
|
+
// so values line up at column 20 regardless of label length.
|
|
23
|
+
const row = (label, value) => console.log(' ' + `${label}:`.padEnd(18) + value);
|
|
24
|
+
|
|
25
|
+
console.log(chalk.dim('Webhook:'));
|
|
26
|
+
row('url', wh.url || chalk.dim('(none set)'));
|
|
27
|
+
row('allowed updates', (wh.allowed_updates || []).join(', ') || chalk.dim('(none)'));
|
|
28
|
+
row('pending updates', wh.pending_update_count ?? 0);
|
|
29
|
+
if (wh.last_error_message) {
|
|
30
|
+
// Plain text — a last error may be a one-off, not necessarily the current
|
|
31
|
+
// state. Timestamp trimmed to "YYYY-MM-DD HH:MM UTC" for readability. Guard
|
|
32
|
+
// the date: a NaN/out-of-range last_error_date would make toISOString() throw
|
|
33
|
+
// (RangeError) and kill the whole command with no message.
|
|
34
|
+
let when = '';
|
|
35
|
+
const ts = Number(wh.last_error_date);
|
|
36
|
+
if (Number.isFinite(ts)) {
|
|
37
|
+
const d = new Date(ts * 1000);
|
|
38
|
+
if (!Number.isNaN(d.getTime())) {
|
|
39
|
+
when = ` (${d.toISOString().slice(0, 16).replace('T', ' ')} UTC)`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
row('last error', `${stripControl(wh.last_error_message)}${when}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (wh.in_sync) {
|
|
46
|
+
console.log(chalk.green(' ✓ in sync with your deployed handlers'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(chalk.red(' ✗ OUT OF SYNC with your deployed handlers'));
|
|
51
|
+
const expected = wh.expected || {};
|
|
52
|
+
// Detail rows share the settings block's value column — the red status line
|
|
53
|
+
// already sets them apart, so no extra indent or color. Instead of a
|
|
54
|
+
// missing/stale diff, just show what the webhook should be (compare with the
|
|
55
|
+
// live "allowed updates" above).
|
|
56
|
+
if (expected.url && expected.url !== wh.url) {
|
|
57
|
+
row('expected url', expected.url);
|
|
58
|
+
}
|
|
59
|
+
const { missing, extra } = diffSets(wh.allowed_updates, expected.allowed_updates);
|
|
60
|
+
if (missing.length || extra.length) {
|
|
61
|
+
row('expected updates', (expected.allowed_updates || []).join(', '));
|
|
62
|
+
}
|
|
63
|
+
console.log('Run "tgcloud webhook sync" to fix.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Best-effort one-liner for `tgcloud status`. Returns a string or null; never
|
|
67
|
+
// throws — status is an offline command, so any failure (offline, no token,
|
|
68
|
+
// endpoint absent) just omits the line. Guarded by a short timeout so a slow or
|
|
69
|
+
// unreachable server can't stall status (the manage `request` has no timeout).
|
|
70
|
+
export async function webhookStatusLine({ timeoutMs = 2500 } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
const token = peekToken();
|
|
73
|
+
if (!token) return null; // offline / not linked — status stays fully offline
|
|
74
|
+
const wh = await Promise.race([
|
|
75
|
+
getWebhook(token),
|
|
76
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
|
|
77
|
+
]);
|
|
78
|
+
if (typeof wh?.in_sync !== 'boolean') return null;
|
|
79
|
+
if (wh.in_sync) return chalk.dim('Webhook: in sync');
|
|
80
|
+
return chalk.dim('Webhook: OUT OF SYNC') + '\n' +
|
|
81
|
+
chalk.yellow('Warning: ') + 'Webhook doesn\'t match your handlers. Run "tgcloud webhook sync" to fix.';
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function registerWebhook(program) {
|
|
88
|
+
const cmd = program
|
|
89
|
+
.command('webhook')
|
|
90
|
+
.description('Show the bot webhook the cloud manages, and whether it is in sync')
|
|
91
|
+
.action(async () => {
|
|
92
|
+
try {
|
|
93
|
+
await retryOnAuthError(async () => {
|
|
94
|
+
const token = await resolveToken();
|
|
95
|
+
const spinner = createSpinner('Checking webhook...');
|
|
96
|
+
spinner.start();
|
|
97
|
+
let wh;
|
|
98
|
+
try {
|
|
99
|
+
wh = await getWebhook(token);
|
|
100
|
+
spinner.stop();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
spinner.stop();
|
|
103
|
+
console.error(chalk.red('Error: ') + describeServerError(err));
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
renderWebhook(wh);
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
cmd
|
|
114
|
+
.command('sync')
|
|
115
|
+
.description('Re-point the webhook at the cloud and refresh allowed_updates from your handlers')
|
|
116
|
+
.option('--drop-pending', 'Discard updates queued at Telegram before the sync')
|
|
117
|
+
.action(async (opts) => {
|
|
118
|
+
try {
|
|
119
|
+
await retryOnAuthError(async () => {
|
|
120
|
+
const token = await resolveToken();
|
|
121
|
+
const spinner = createSpinner('Syncing webhook...');
|
|
122
|
+
spinner.start();
|
|
123
|
+
let wh;
|
|
124
|
+
try {
|
|
125
|
+
wh = await syncWebhook(token, { drop_pending: !!opts.dropPending });
|
|
126
|
+
spinner.stop();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
spinner.stop();
|
|
129
|
+
console.error(chalk.red('Error: ') + describeServerError(err));
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
success('Webhook synced.');
|
|
133
|
+
renderWebhook(wh);
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { fetchCapabilities } from '../api/endpoints.js';
|
|
5
|
+
import { saveCachedCapabilities } from './state.js';
|
|
6
|
+
import {
|
|
7
|
+
validateDescriptor,
|
|
8
|
+
resetLayout,
|
|
9
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
10
|
+
} from './project-layout.js';
|
|
11
|
+
import { warn } from '../utils/logger.js';
|
|
12
|
+
import { stripControl } from '../api/client.js';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const CLI_VERSION = readCliVersion();
|
|
16
|
+
|
|
17
|
+
// Single source of truth for the CLI version — read from package.json, reused by
|
|
18
|
+
// both the capabilities handshake and `tgcloud --version` (cli.js).
|
|
19
|
+
export function readCliVersion() {
|
|
20
|
+
try {
|
|
21
|
+
const pkg = JSON.parse(
|
|
22
|
+
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8')
|
|
23
|
+
);
|
|
24
|
+
return pkg.version || '0.0.0';
|
|
25
|
+
} catch {
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Compare dotted numeric versions. Returns true if `a` is strictly older.
|
|
31
|
+
function isOlder(a, b) {
|
|
32
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
33
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
34
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
35
|
+
const x = pa[i] || 0;
|
|
36
|
+
const y = pb[i] || 0;
|
|
37
|
+
if (x !== y) return x < y;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Refresh the project-layout descriptor from the server (GET /capabilities),
|
|
44
|
+
* cache it, and make it the effective layout for the rest of this run.
|
|
45
|
+
*
|
|
46
|
+
* Best-effort and silent on failure: if the platform has no /capabilities
|
|
47
|
+
* endpoint yet, we're offline, or the response is unusable, we keep whatever is
|
|
48
|
+
* already cached (or the built-in BASELINE). This is what keeps the CLI working
|
|
49
|
+
* against an older server and offline.
|
|
50
|
+
*
|
|
51
|
+
* Online commands call this early (before scanning), so discovery, the deploy
|
|
52
|
+
* manifest, and the run whitelist reflect the latest layout.
|
|
53
|
+
*/
|
|
54
|
+
export async function syncCapabilities() {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = await fetchCapabilities();
|
|
58
|
+
} catch {
|
|
59
|
+
return; // no endpoint / offline / server error → keep cache + baseline
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const valid = validateDescriptor(raw);
|
|
63
|
+
if (!valid) return; // unusable descriptor → ignore, don't clobber the cache
|
|
64
|
+
|
|
65
|
+
// Version handshake: warn (but still consume what we can) when the server is
|
|
66
|
+
// ahead of this CLI. The user keeps working on the subset we understand.
|
|
67
|
+
if (raw.project_schema_version > SUPPORTED_SCHEMA_VERSION) {
|
|
68
|
+
warn(
|
|
69
|
+
`This project's layout is newer than your tgcloud (schema v${raw.project_schema_version} ` +
|
|
70
|
+
`> v${SUPPORTED_SCHEMA_VERSION}); newer directories may be ignored. ` +
|
|
71
|
+
`Upgrade: npm i -D @tgcloud/cli@latest`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (raw.min_cli_version && isOlder(CLI_VERSION, raw.min_cli_version)) {
|
|
75
|
+
warn(
|
|
76
|
+
`This project requires tgcloud ≥ ${stripControl(raw.min_cli_version)} (you have ${CLI_VERSION}). ` +
|
|
77
|
+
`Upgrade: npm i -D @tgcloud/cli@latest`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
saveCachedCapabilities(raw);
|
|
82
|
+
resetLayout();
|
|
83
|
+
}
|