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