@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,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
+ }