@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,356 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { confirm, askLetter, waitForEnter, EOF } from '../utils/prompt.js';
|
|
3
|
+
import { createSpinner } from '../utils/spinner.js';
|
|
4
|
+
import { applyMigration } from '../api/endpoints.js';
|
|
5
|
+
import {
|
|
6
|
+
formatBriefSummary,
|
|
7
|
+
formatChange,
|
|
8
|
+
changeId,
|
|
9
|
+
kindLabel,
|
|
10
|
+
} from './db-changes.js';
|
|
11
|
+
import { ROOT_FILE } from './project-layout.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Linear walk-through migration. Used by `migrate`. (`push` only prints
|
|
15
|
+
* the brief summary and suggests `tgcloud migrate`.)
|
|
16
|
+
*
|
|
17
|
+
* Flow (interactive):
|
|
18
|
+
* 1. Brief summary (one line per change).
|
|
19
|
+
* 2. "Proceed with migration?" prompt — always asked.
|
|
20
|
+
* 3. Walk through actionable changes with [N/M] step counter:
|
|
21
|
+
* - Safe changes: one grouped step (a single step when there's just one),
|
|
22
|
+
* applied or skipped as a whole. Prompt [y]es/[n]o/[q]uit.
|
|
23
|
+
* - Warning: each as own step, [y]es/[n]o/[q]uit.
|
|
24
|
+
* - Manual / Undocumented: NOT counted as steps. Shown as a tail
|
|
25
|
+
* block after all decisions, with collective explanation +
|
|
26
|
+
* "Press Enter to continue...".
|
|
27
|
+
* 4. Final summary line.
|
|
28
|
+
*
|
|
29
|
+
* Flags:
|
|
30
|
+
* --yes auto-yes everywhere (safe batch + every warning).
|
|
31
|
+
* --safe auto-yes for safe batch, auto-no for every warning.
|
|
32
|
+
* --dry-run show summary + all cards, no prompts, no apply.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} token
|
|
35
|
+
* @param {Array} dbChanges
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @returns {Promise<{applied:number, skipped:number, failed:number}|undefined>}
|
|
38
|
+
* Apply tally (undefined for dry-run / empty). Callers use `failed` to set
|
|
39
|
+
* the process exit code.
|
|
40
|
+
*/
|
|
41
|
+
export async function runMigrationFlow(token, dbChanges, options = {}) {
|
|
42
|
+
if (!dbChanges?.length) return;
|
|
43
|
+
|
|
44
|
+
const good = dbChanges.filter((c) => c.status === 'good');
|
|
45
|
+
const warning = dbChanges.filter((c) => c.status === 'warning');
|
|
46
|
+
const manual = dbChanges.filter((c) => c.status === 'manual');
|
|
47
|
+
const undoc = dbChanges.filter((c) => c.status === 'undocumented');
|
|
48
|
+
|
|
49
|
+
printBriefHeader(dbChanges);
|
|
50
|
+
|
|
51
|
+
if (options.dryRun) return runDryRun(dbChanges);
|
|
52
|
+
if (options.yes || options.safe) return runCI(token, { good, warning, manual, undoc }, options);
|
|
53
|
+
|
|
54
|
+
return runInteractive(token, { good, warning, manual, undoc }, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Brief header ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function printBriefHeader(dbChanges) {
|
|
60
|
+
const n = dbChanges.length;
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(chalk.bold(`Schema out of sync — ${n} change${n === 1 ? '' : 's'}:`));
|
|
63
|
+
console.log(formatBriefSummary(dbChanges));
|
|
64
|
+
console.log('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Dry run ───────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function runDryRun(dbChanges) {
|
|
70
|
+
for (const c of dbChanges) {
|
|
71
|
+
console.log(formatChange(c));
|
|
72
|
+
console.log('');
|
|
73
|
+
}
|
|
74
|
+
console.log(chalk.dim('(dry-run — nothing was applied)'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Interactive walk-through ──────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function runInteractive(token, groups, options) {
|
|
80
|
+
const { good, warning, manual, undoc } = groups;
|
|
81
|
+
|
|
82
|
+
const proceed = await confirm('Proceed with migration?');
|
|
83
|
+
if (!proceed) {
|
|
84
|
+
console.log('\nCancelled.');
|
|
85
|
+
return { applied: 0, skipped: 0, failed: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stats = { applied: 0, skipped: 0, failed: 0 };
|
|
89
|
+
// Total step count:
|
|
90
|
+
// safe-group = 1 step (if any)
|
|
91
|
+
// each warning = 1 step
|
|
92
|
+
// manual = 1 step (all manual cards shown together — no per-card
|
|
93
|
+
// decision, so spreading them across steps is just noise)
|
|
94
|
+
// undoc = 1 step (same reasoning)
|
|
95
|
+
const state = {
|
|
96
|
+
idx: 0,
|
|
97
|
+
total:
|
|
98
|
+
(good.length > 0 ? 1 : 0) +
|
|
99
|
+
warning.length +
|
|
100
|
+
(manual.length > 0 ? 1 : 0) +
|
|
101
|
+
(undoc.length > 0 ? 1 : 0),
|
|
102
|
+
quit: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── Safe ──
|
|
106
|
+
if (good.length === 1) {
|
|
107
|
+
await stepSingle(token, good[0], state, stats, options);
|
|
108
|
+
} else if (good.length > 1) {
|
|
109
|
+
await stepSafeGroup(token, good, state, stats, options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Warning ──
|
|
113
|
+
if (!state.quit) {
|
|
114
|
+
for (let i = 0; i < warning.length; i++) {
|
|
115
|
+
await stepSingle(token, warning[i], state, stats, options);
|
|
116
|
+
if (state.quit) {
|
|
117
|
+
stats.skipped += warning.length - i - 1;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
stats.skipped += warning.length;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Manual & Undoc — each kind is one combined step ──
|
|
126
|
+
if (!state.quit) {
|
|
127
|
+
if (manual.length) await stepInfoGroup(manual, 'manual', state);
|
|
128
|
+
if (undoc.length) await stepInfoGroup(undoc, 'undocumented', state);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
printSummary(stats, { manual: manual.length, undoc: undoc.length, quit: state.quit });
|
|
132
|
+
return stats;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Informational step covering all changes of a single non-actionable kind
|
|
137
|
+
* (manual or undocumented). Prints all cards stacked, then one collective
|
|
138
|
+
* hint, then waits for Enter. No apply.
|
|
139
|
+
*/
|
|
140
|
+
async function stepInfoGroup(changes, kind, state) {
|
|
141
|
+
state.idx++;
|
|
142
|
+
for (const c of changes) {
|
|
143
|
+
console.log(formatChange(c));
|
|
144
|
+
console.log('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const n = changes.length;
|
|
148
|
+
const body = kind === 'manual'
|
|
149
|
+
? (n === 1
|
|
150
|
+
? 'This change must be applied to the database manually.'
|
|
151
|
+
: `These ${n} changes must be applied to the database manually.`)
|
|
152
|
+
: (n === 1
|
|
153
|
+
? `Not in schema. Update ${ROOT_FILE}, or mark as deprecated to remove.`
|
|
154
|
+
: `${n} elements exist in the database but not in the schema.\n Update ${ROOT_FILE} to match, or mark them as deprecated to remove.`);
|
|
155
|
+
console.log(chalk.blue('Note: ') + body);
|
|
156
|
+
console.log('');
|
|
157
|
+
|
|
158
|
+
await waitForEnter(`[${state.idx}/${state.total}] Press Enter to continue...`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Step that's a single change card (safe-1 or any warning). Prints the card
|
|
163
|
+
* and asks Apply? [y]/[n]/[q].
|
|
164
|
+
*/
|
|
165
|
+
async function stepSingle(token, change, state, stats, options) {
|
|
166
|
+
state.idx++;
|
|
167
|
+
console.log(formatChange(change));
|
|
168
|
+
console.log('');
|
|
169
|
+
|
|
170
|
+
const choice = await askLetter(`[${state.idx}/${state.total}] Apply?`, [
|
|
171
|
+
{ key: 'y', label: 'yes' },
|
|
172
|
+
{ key: 'n', label: 'no' },
|
|
173
|
+
{ key: 'q', label: 'quit' },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
if (choice === 'q' || choice === EOF) {
|
|
177
|
+
state.quit = true;
|
|
178
|
+
stats.skipped++;
|
|
179
|
+
console.log(chalk.dim(' ⊘ skipped (quit)'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (choice === 'n') {
|
|
183
|
+
stats.skipped++;
|
|
184
|
+
console.log(chalk.dim(' ⊘ skipped'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// 'y'
|
|
188
|
+
const ok = await applyOne(token, change, options.schema);
|
|
189
|
+
if (ok) stats.applied++;
|
|
190
|
+
else stats.failed++;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Step that's a group of safe changes. Prints all cards, then a single
|
|
195
|
+
* group prompt — applies (or skips) the whole group atomically. To apply only
|
|
196
|
+
* some, cancel and edit the schema.
|
|
197
|
+
*/
|
|
198
|
+
async function stepSafeGroup(token, group, state, stats, options) {
|
|
199
|
+
state.idx++;
|
|
200
|
+
for (const c of group) {
|
|
201
|
+
console.log(formatChange(c));
|
|
202
|
+
console.log('');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const choice = await askLetter(
|
|
206
|
+
`[${state.idx}/${state.total}] Apply these ${group.length} changes?`,
|
|
207
|
+
[
|
|
208
|
+
{ key: 'y', label: 'yes' },
|
|
209
|
+
{ key: 'n', label: 'no' },
|
|
210
|
+
{ key: 'q', label: 'quit' },
|
|
211
|
+
]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (choice === 'q' || choice === EOF) {
|
|
215
|
+
state.quit = true;
|
|
216
|
+
stats.skipped += group.length;
|
|
217
|
+
console.log(chalk.dim(` ⊘ skipped ${group.length} (quit)`));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (choice === 'n') {
|
|
221
|
+
stats.skipped += group.length;
|
|
222
|
+
console.log(chalk.dim(` ⊘ skipped ${group.length}`));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// 'y'
|
|
226
|
+
const ok = await applyBatch(token, group, options.schema);
|
|
227
|
+
if (ok) stats.applied += group.length;
|
|
228
|
+
else stats.failed += group.length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Summary ───────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function printSummary(stats, { manual, undoc, quit }) {
|
|
234
|
+
console.log('');
|
|
235
|
+
|
|
236
|
+
// All counters use `label: N` separated by commas, so the line stays
|
|
237
|
+
// visually consistent (not a mix of `, ` and `: `).
|
|
238
|
+
const parts = [
|
|
239
|
+
`applied: ${stats.applied}`,
|
|
240
|
+
`skipped: ${stats.skipped}`,
|
|
241
|
+
];
|
|
242
|
+
if (stats.failed) parts.push(`failed: ${stats.failed}`);
|
|
243
|
+
if (manual) parts.push(`awaiting manual fix: ${manual}`);
|
|
244
|
+
if (undoc) parts.push(`not in schema: ${undoc}`);
|
|
245
|
+
|
|
246
|
+
// Capitalize first counter so the sentence reads naturally after the prefix.
|
|
247
|
+
parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
248
|
+
|
|
249
|
+
const nothingActed =
|
|
250
|
+
stats.applied === 0 && stats.skipped === 0 && stats.failed === 0;
|
|
251
|
+
const prefix = quit
|
|
252
|
+
? 'Migration interrupted.'
|
|
253
|
+
: nothingActed
|
|
254
|
+
? 'Migration finished.'
|
|
255
|
+
: 'Migration complete.';
|
|
256
|
+
|
|
257
|
+
console.log(chalk.bold(`${prefix} ${parts.join(', ')}.`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── CI mode (--yes / --safe) ──────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
async function runCI(token, groups, options) {
|
|
263
|
+
const { good, warning, manual, undoc } = groups;
|
|
264
|
+
const stats = { applied: 0, skipped: 0, failed: 0 };
|
|
265
|
+
|
|
266
|
+
// Safe — always attempted in CI (both --yes and --safe).
|
|
267
|
+
if (good.length) {
|
|
268
|
+
for (const c of good) {
|
|
269
|
+
console.log(formatChange(c));
|
|
270
|
+
console.log('');
|
|
271
|
+
}
|
|
272
|
+
const ok = await applyBatch(token, good, options.schema);
|
|
273
|
+
if (ok) stats.applied += good.length;
|
|
274
|
+
else stats.failed += good.length;
|
|
275
|
+
console.log('');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Warning — applied only with --yes; --safe shows + skips.
|
|
279
|
+
for (const c of warning) {
|
|
280
|
+
console.log(formatChange(c));
|
|
281
|
+
console.log('');
|
|
282
|
+
if (options.yes) {
|
|
283
|
+
const ok = await applyOne(token, c, options.schema);
|
|
284
|
+
if (ok) stats.applied++;
|
|
285
|
+
else stats.failed++;
|
|
286
|
+
} else {
|
|
287
|
+
console.log(chalk.dim(' ⊘ skipped (--safe)'));
|
|
288
|
+
stats.skipped++;
|
|
289
|
+
}
|
|
290
|
+
console.log('');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Manual / undoc — show each with its hint, no waitForEnter in CI.
|
|
294
|
+
for (const c of manual) {
|
|
295
|
+
console.log(formatChange(c));
|
|
296
|
+
console.log('');
|
|
297
|
+
console.log(chalk.blue('Note: ') + 'This change must be applied to the database manually.');
|
|
298
|
+
console.log('');
|
|
299
|
+
}
|
|
300
|
+
for (const c of undoc) {
|
|
301
|
+
console.log(formatChange(c));
|
|
302
|
+
console.log('');
|
|
303
|
+
console.log(chalk.blue('Note: ') + `Not in schema. Update ${ROOT_FILE}, or mark as deprecated to remove.`);
|
|
304
|
+
console.log('');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
printSummary(stats, { manual: manual.length, undoc: undoc.length, quit: false });
|
|
308
|
+
return stats;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─── API helpers ───────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Apply a batch of changes in one POST. Prints inline ✓ per change on success.
|
|
315
|
+
*/
|
|
316
|
+
async function applyBatch(token, changes, schema) {
|
|
317
|
+
const ids = changes.map(changeId);
|
|
318
|
+
const spinner = createSpinner(`Applying ${ids.length} change${ids.length === 1 ? '' : 's'}...`);
|
|
319
|
+
spinner.start();
|
|
320
|
+
try {
|
|
321
|
+
await applyMigration(token, { apply: ids, schema });
|
|
322
|
+
spinner.stop();
|
|
323
|
+
for (const c of changes) {
|
|
324
|
+
console.log(chalk.green(` ✓ ${headerTargetFor(c)}`));
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
} catch (err) {
|
|
328
|
+
spinner.stop();
|
|
329
|
+
console.error(chalk.red('Error: ') + `Batch failed: ${err.message}`);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function applyOne(token, change, schema) {
|
|
335
|
+
const id = changeId(change);
|
|
336
|
+
const target = headerTargetFor(change);
|
|
337
|
+
const label = kindLabel(change);
|
|
338
|
+
const spinner = createSpinner(`Applying ${label} '${target}'...`);
|
|
339
|
+
spinner.start();
|
|
340
|
+
try {
|
|
341
|
+
await applyMigration(token, { apply: [id], schema });
|
|
342
|
+
spinner.stop();
|
|
343
|
+
console.log(chalk.green(` ✓ ${target}`));
|
|
344
|
+
return true;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
spinner.stop();
|
|
347
|
+
console.error(chalk.red('Error: ') + `${label} '${target}': ${err.message}`);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function headerTargetFor(change) {
|
|
353
|
+
if (change.column) return `${change.table}.${change.column}`;
|
|
354
|
+
if (change.index) return `${change.table}.${change.index}`;
|
|
355
|
+
return change.table;
|
|
356
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { loadCachedCapabilities } from './state.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for the SHAPE of a tgcloud project: the root file(s),
|
|
5
|
+
* the module directories and their rules (nested / runnable / synced), plus the
|
|
6
|
+
* scaffold list and the template contents used by `init`.
|
|
7
|
+
*
|
|
8
|
+
* The shape comes from two layers, merged per-aspect:
|
|
9
|
+
* 1. BASELINE — built into the CLI, used offline and as the fallback.
|
|
10
|
+
* 2. The server descriptor (GET /capabilities), cached in
|
|
11
|
+
* .tgcloud/capabilities.json and refreshed on online commands by
|
|
12
|
+
* core/capabilities.js. When present and valid, its fields are authoritative.
|
|
13
|
+
*
|
|
14
|
+
* Scaffolding is PATH-DRIVEN: `scaffold` is the full list of files/dirs to
|
|
15
|
+
* create (files may be nested paths — `docs/x.md`, `handlers/message.js`), and
|
|
16
|
+
* `templates` is a map keyed by that same relative path → file content. The CLI
|
|
17
|
+
* holds no hard-coded list of which files exist; the platform can add a file
|
|
18
|
+
* (a new starter module or doc) by putting it in `scaffold` + `templates`
|
|
19
|
+
* WITHOUT a CLI release.
|
|
20
|
+
*
|
|
21
|
+
* scanner / run / init / deploy derive their behavior from the accessors below
|
|
22
|
+
* so that discovery, the deploy manifest, the run whitelist, and scaffolding all
|
|
23
|
+
* move together.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Highest project_schema_version this CLI understands. A descriptor with a
|
|
27
|
+
// higher version is still consumed for the parts that parse, but the user is
|
|
28
|
+
// warned (in core/capabilities.js) that newer features may be ignored. Scaffolding
|
|
29
|
+
// is path-driven: `templates` is keyed by relative path and `scaffold` entries may
|
|
30
|
+
// be nested. Template keys that aren't valid relative paths are ignored, and the
|
|
31
|
+
// bundled copy is used instead.
|
|
32
|
+
export const SUPPORTED_SCHEMA_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
export const BASELINE = {
|
|
35
|
+
rootFiles: [
|
|
36
|
+
{ name: 'schema.js', singleton: true },
|
|
37
|
+
],
|
|
38
|
+
directories: [
|
|
39
|
+
{ name: 'lib', nested: true, synced: true, runnable: false },
|
|
40
|
+
{ name: 'handlers', nested: false, synced: true, runnable: true },
|
|
41
|
+
],
|
|
42
|
+
// The files/dirs `init` scaffolds offline, written from the CLI's bundled
|
|
43
|
+
// templates (src/templates/). Online, the server descriptor's `scaffold` +
|
|
44
|
+
// `templates` (keyed by path) take over.
|
|
45
|
+
scaffold: [
|
|
46
|
+
'schema.js',
|
|
47
|
+
'lib/',
|
|
48
|
+
'handlers/',
|
|
49
|
+
'handlers/message.js',
|
|
50
|
+
// Project docs aimed at AI coding assistants: an orientation file at the root
|
|
51
|
+
// and the SDK reference under docs/. init doesn't track .md in init_templates,
|
|
52
|
+
// so they scaffold but never deploy.
|
|
53
|
+
'AGENTS.md',
|
|
54
|
+
'docs/',
|
|
55
|
+
'docs/tgcloud-sdk.md',
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Root file *names* stay baseline-driven (schema.js is fixed); the server may
|
|
60
|
+
// override their *content* via `templates` (keyed by path), but not the names.
|
|
61
|
+
export const ROOT_FILE = BASELINE.rootFiles[0].name;
|
|
62
|
+
export const ROOT_FILES = BASELINE.rootFiles.map((f) => f.name);
|
|
63
|
+
|
|
64
|
+
// Sanitization is the security boundary: the descriptor drives which directories
|
|
65
|
+
// the CLI scans and which files it writes, so names/paths are strictly checked.
|
|
66
|
+
const SAFE_SEGMENT = /^[a-z0-9_-]+$/i;
|
|
67
|
+
const SAFE_FILE = /^[a-z0-9_-]+(\.[a-z0-9]+)+$/i;
|
|
68
|
+
const FORBIDDEN = new Set(['.tgcloud', '.git', 'node_modules', '.', '..']);
|
|
69
|
+
|
|
70
|
+
const MAX_TEMPLATES = 100;
|
|
71
|
+
const MAX_TEMPLATE_BYTES = 64 * 1024;
|
|
72
|
+
const MAX_SCAFFOLD = 200;
|
|
73
|
+
const MAX_PATH_SEGMENTS = 8;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate a relative path from the descriptor (a scaffold entry or a templates
|
|
77
|
+
* key). Returns the normalized path or null if unsafe. This is where the CLI
|
|
78
|
+
* decides what files a server may create on the user's machine, so it is
|
|
79
|
+
* deliberately strict: every segment is checked, and anything absolute, escaping
|
|
80
|
+
* (`..`), hidden (`.x`), backslash-bearing, over-deep, or naming a reserved dir
|
|
81
|
+
* is rejected.
|
|
82
|
+
*
|
|
83
|
+
* opts.dir === true → also accept a trailing-slash directory entry ("lib/").
|
|
84
|
+
*/
|
|
85
|
+
export function sanitizeRelPath(p, opts = {}) {
|
|
86
|
+
if (typeof p !== 'string' || !p) return null;
|
|
87
|
+
if (p.includes('\\')) return null; // no Windows separators / escapes
|
|
88
|
+
if (p.startsWith('/')) return null; // no absolute paths
|
|
89
|
+
const isDir = p.endsWith('/');
|
|
90
|
+
if (isDir && !opts.dir) return null; // templates keys must be files, not dirs
|
|
91
|
+
const body = isDir ? p.slice(0, -1) : p;
|
|
92
|
+
const segments = body.split('/');
|
|
93
|
+
if (!segments.length || segments.length > MAX_PATH_SEGMENTS) return null;
|
|
94
|
+
for (let i = 0; i < segments.length; i++) {
|
|
95
|
+
const seg = segments[i];
|
|
96
|
+
if (!seg) return null; // empty: "", "a//b", leading/trailing slash
|
|
97
|
+
if (/[\x00-\x1f\x7f]/.test(seg)) return null; // control chars incl. a trailing \n the `$` anchor would otherwise allow
|
|
98
|
+
if (FORBIDDEN.has(seg.toLowerCase())) return null; // ".", "..", ".tgcloud", ".git", "node_modules" (case-folded: FS is often case-insensitive)
|
|
99
|
+
const isFilePart = !isDir && i === segments.length - 1;
|
|
100
|
+
if (isFilePart) {
|
|
101
|
+
if (!SAFE_FILE.test(seg)) return null; // final file part: name(.ext)+
|
|
102
|
+
} else if (!SAFE_SEGMENT.test(seg)) {
|
|
103
|
+
return null; // directory segment
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return isDir ? `${body}/` : body;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sanitizeDirectory(d) {
|
|
110
|
+
if (!d || typeof d.name !== 'string') return null;
|
|
111
|
+
if (!SAFE_SEGMENT.test(d.name) || FORBIDDEN.has(d.name.toLowerCase())) return null;
|
|
112
|
+
// Only known flags are honored; unknown flags are ignored (forward-compat).
|
|
113
|
+
const dir = {
|
|
114
|
+
name: d.name,
|
|
115
|
+
nested: d.nested === true,
|
|
116
|
+
synced: d.synced !== false, // default true
|
|
117
|
+
runnable: d.runnable === true,
|
|
118
|
+
};
|
|
119
|
+
// Optional closed vocabulary of module names allowed in this directory (e.g.
|
|
120
|
+
// handlers/ = Bot API update types). Absent → any safe name is allowed.
|
|
121
|
+
if (Array.isArray(d.allowed_names)) {
|
|
122
|
+
const names = d.allowed_names.filter((n) => typeof n === 'string' && SAFE_SEGMENT.test(n));
|
|
123
|
+
if (names.length) dir.allowedNames = names;
|
|
124
|
+
}
|
|
125
|
+
return dir;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sanitizeRootFile(f) {
|
|
129
|
+
if (!f || typeof f.name !== 'string') return null;
|
|
130
|
+
if (!SAFE_FILE.test(f.name) || FORBIDDEN.has(f.name.toLowerCase())) return null;
|
|
131
|
+
return { name: f.name, singleton: f.singleton === true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A scaffold entry is a directory ("lib/") or a file — possibly nested
|
|
135
|
+
// ("docs/guide.md", "handlers/message.js").
|
|
136
|
+
function sanitizeScaffoldEntry(e) {
|
|
137
|
+
return sanitizeRelPath(e, { dir: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// `templates` is a map { key: content }. A key is either a file path (an `init`
|
|
141
|
+
// starter, e.g. "handlers/message.js") or a directory ("handlers/") — the
|
|
142
|
+
// per-directory starter used by `tgcloud add`. Content must be a string within
|
|
143
|
+
// the size cap; the whole map is capped.
|
|
144
|
+
function sanitizeTemplates(raw) {
|
|
145
|
+
if (!raw || typeof raw !== 'object') return {};
|
|
146
|
+
const out = {};
|
|
147
|
+
let count = 0;
|
|
148
|
+
for (const [key, content] of Object.entries(raw)) {
|
|
149
|
+
if (count >= MAX_TEMPLATES) break;
|
|
150
|
+
const path = sanitizeRelPath(key, { dir: true });
|
|
151
|
+
if (!path) continue;
|
|
152
|
+
if (typeof content !== 'string') continue;
|
|
153
|
+
if (Buffer.byteLength(content, 'utf-8') > MAX_TEMPLATE_BYTES) continue;
|
|
154
|
+
out[path] = content;
|
|
155
|
+
count++;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate + sanitize a raw descriptor (from the server or read from cache).
|
|
162
|
+
* Returns a normalized descriptor, or null if it can't be trusted at all — in
|
|
163
|
+
* which case callers fall back to BASELINE.
|
|
164
|
+
*
|
|
165
|
+
* `directories` is the core: a descriptor with no usable directories is rejected
|
|
166
|
+
* wholesale. The optional `root_files`/`templates`/`scaffold` fields are
|
|
167
|
+
* sanitized independently; if any is malformed it is simply dropped and the
|
|
168
|
+
* baseline fills in for that aspect (see effective()). Forward-compatible: a
|
|
169
|
+
* higher project_schema_version still validates.
|
|
170
|
+
*/
|
|
171
|
+
export function validateDescriptor(raw) {
|
|
172
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
173
|
+
if (typeof raw.project_schema_version !== 'number') return null;
|
|
174
|
+
|
|
175
|
+
const directories = Array.isArray(raw.directories)
|
|
176
|
+
? raw.directories.map(sanitizeDirectory).filter(Boolean)
|
|
177
|
+
: [];
|
|
178
|
+
if (!directories.length) return null;
|
|
179
|
+
|
|
180
|
+
const rootFiles = Array.isArray(raw.root_files)
|
|
181
|
+
? raw.root_files.map(sanitizeRootFile).filter(Boolean)
|
|
182
|
+
: [];
|
|
183
|
+
|
|
184
|
+
const scaffold = Array.isArray(raw.scaffold)
|
|
185
|
+
? raw.scaffold.slice(0, MAX_SCAFFOLD).map(sanitizeScaffoldEntry).filter(Boolean)
|
|
186
|
+
: [];
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
project_schema_version: raw.project_schema_version,
|
|
190
|
+
directories,
|
|
191
|
+
rootFiles,
|
|
192
|
+
scaffold,
|
|
193
|
+
templates: sanitizeTemplates(raw.templates),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// What `init` should create when a valid descriptor carries no scaffold list:
|
|
198
|
+
// each root file, then each directory as "name/". (Minimal — the baseline's
|
|
199
|
+
// richer scaffold, with docs/starters, only applies offline.)
|
|
200
|
+
function synthesizeScaffold(rootFiles, directories) {
|
|
201
|
+
return [
|
|
202
|
+
...rootFiles.map((f) => f.name),
|
|
203
|
+
...directories.map((d) => `${d.name}/`),
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Memoized effective layout for this process. core/capabilities.js calls
|
|
208
|
+
// resetLayout() after writing a fresh descriptor so later reads pick it up.
|
|
209
|
+
let _effective;
|
|
210
|
+
|
|
211
|
+
function effective() {
|
|
212
|
+
if (!_effective) {
|
|
213
|
+
const valid = validateDescriptor(loadCachedCapabilities());
|
|
214
|
+
const directories = valid ? valid.directories : BASELINE.directories;
|
|
215
|
+
const rootFiles = valid && valid.rootFiles.length ? valid.rootFiles : BASELINE.rootFiles;
|
|
216
|
+
const templates = valid ? valid.templates : {};
|
|
217
|
+
// Online: use the server's scaffold, or a minimal synthesized one if it
|
|
218
|
+
// omitted scaffold. Offline: the baseline's full scaffold (incl. docs/starters).
|
|
219
|
+
const scaffold = valid
|
|
220
|
+
? (valid.scaffold.length ? valid.scaffold : synthesizeScaffold(rootFiles, directories))
|
|
221
|
+
: BASELINE.scaffold;
|
|
222
|
+
_effective = { directories, rootFiles, templates, scaffold };
|
|
223
|
+
}
|
|
224
|
+
return _effective;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function resetLayout() {
|
|
228
|
+
_effective = undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** All module directory names, in declaration order. */
|
|
232
|
+
export function moduleDirs() {
|
|
233
|
+
return effective().directories.map((d) => d.name);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Directories whose modules belong to the deployed space (the deploy manifest). */
|
|
237
|
+
export function syncedDirs() {
|
|
238
|
+
return effective().directories.filter((d) => d.synced).map((d) => d.name);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Directories that may contain subdirectories (nested module names). */
|
|
242
|
+
export function nestableDirs() {
|
|
243
|
+
return new Set(
|
|
244
|
+
effective().directories.filter((d) => d.nested).map((d) => d.name)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Directories whose modules can be invoked via `tgcloud run`. */
|
|
249
|
+
export function runnableDirs() {
|
|
250
|
+
return effective().directories.filter((d) => d.runnable).map((d) => d.name);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Root-file entries ({ name, singleton }). */
|
|
254
|
+
export function rootFileEntries() {
|
|
255
|
+
return effective().rootFiles;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The effective directory descriptor by name (with flags/allowedNames/template), or null. */
|
|
259
|
+
export function directoryByName(name) {
|
|
260
|
+
return effective().directories.find((d) => d.name === name) || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Map { relativePath: content } of server-provided template contents (may be empty). */
|
|
264
|
+
export function templates() {
|
|
265
|
+
return effective().templates;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Ordered list of files/dirs `init` should scaffold (dirs end with "/"). */
|
|
269
|
+
export function scaffoldEntries() {
|
|
270
|
+
return effective().scaffold;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Given working-dir file paths, return modules that sit in a directory which
|
|
275
|
+
* declares `allowed_names` but whose name isn't in that list. Lets the CLI
|
|
276
|
+
* reject e.g. handlers/start.js locally (with the valid list) instead of only
|
|
277
|
+
* at the deploy round-trip. No-op until the server advertises allowed_names.
|
|
278
|
+
*/
|
|
279
|
+
export function findDisallowedModules(files) {
|
|
280
|
+
const byDir = new Map(
|
|
281
|
+
effective().directories.filter((d) => d.allowedNames).map((d) => [d.name, d.allowedNames])
|
|
282
|
+
);
|
|
283
|
+
if (!byDir.size) return [];
|
|
284
|
+
const out = [];
|
|
285
|
+
for (const f of files) {
|
|
286
|
+
const parts = f.split(/[\\/]/);
|
|
287
|
+
if (parts.length < 2) continue; // root files have no directory
|
|
288
|
+
const allowed = byDir.get(parts[0]);
|
|
289
|
+
if (!allowed) continue;
|
|
290
|
+
const name = parts[parts.length - 1].replace(/\.js$/, '');
|
|
291
|
+
if (!allowed.includes(name)) out.push({ file: f, dir: parts[0], name, allowed });
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const TGCLOUD_DIR = '.tgcloud';
|
|
5
|
+
|
|
6
|
+
function isProjectDir(dir) {
|
|
7
|
+
try {
|
|
8
|
+
return fs.statSync(path.join(dir, TGCLOUD_DIR)).isDirectory();
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Locate the tgcloud project root: the nearest ancestor of `start` (inclusive)
|
|
16
|
+
* that contains a `.tgcloud/` directory — the same way git finds a repo by
|
|
17
|
+
* `.git/`. Returns the absolute directory path, or null if none is found up to
|
|
18
|
+
* the filesystem root.
|
|
19
|
+
*
|
|
20
|
+
* This is what lets a tgcloud project live in a dedicated folder (its own repo,
|
|
21
|
+
* or a subfolder of a larger repo) and be driven from any subdirectory.
|
|
22
|
+
*/
|
|
23
|
+
export function findProjectRoot(start = process.cwd()) {
|
|
24
|
+
let dir = path.resolve(start);
|
|
25
|
+
while (true) {
|
|
26
|
+
if (isProjectDir(dir)) return dir;
|
|
27
|
+
const parent = path.dirname(dir);
|
|
28
|
+
if (parent === dir) return null; // reached the filesystem root
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Change into the project root so all CWD-relative file access (state, snapshot,
|
|
35
|
+
* scanner, working-dir reads/writes) resolves against the project — the single
|
|
36
|
+
* mechanism that makes running from a subdirectory work, without threading a
|
|
37
|
+
* root prefix through every fs call.
|
|
38
|
+
*
|
|
39
|
+
* No-op when no project is found: a not-yet-initialized directory keeps today's
|
|
40
|
+
* CWD-relative behavior, so `init` and the first `fetch`/`deploy` create
|
|
41
|
+
* `.tgcloud/` in the current directory. Returns the root, or null.
|
|
42
|
+
*/
|
|
43
|
+
export function enterProjectRoot() {
|
|
44
|
+
const root = findProjectRoot();
|
|
45
|
+
if (root && root !== process.cwd()) process.chdir(root);
|
|
46
|
+
return root;
|
|
47
|
+
}
|