@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,298 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import {
4
+ ROOT_FILES,
5
+ moduleDirs,
6
+ nestableDirs,
7
+ runnableDirs,
8
+ directoryByName,
9
+ } from '../core/project-layout.js';
10
+
11
+ // Shell completion, cobra/npm-style: a `completion <shell>` command prints a
12
+ // thin shim, and a hidden `__complete <words...>` command computes candidates.
13
+ // The shim, invoked by the shell on <Tab>, calls back into `__complete` so the
14
+ // suggestions are dynamic — real handler update-types, locally present modules,
15
+ // current command/flag set — without the shell knowing anything about tgcloud.
16
+ //
17
+ // Wire format of `__complete`: one candidate per line, then a final directive
18
+ // line `:<bitmask>` (always printed, even with zero candidates). bit 1 (value
19
+ // 4, matching the shim's `& 4`) means "no trailing space" — used when the
20
+ // candidate is a directory prefix the user keeps typing into (e.g. `handlers/`).
21
+
22
+ const NOSPACE = 4;
23
+
24
+ // --- candidate sources ------------------------------------------------------
25
+ // All of these are best-effort and never throw: completion must stay silent on
26
+ // a malformed or absent project rather than break the user's shell. We also
27
+ // never hit the network here — `directoryByName` reads the cached descriptor.
28
+
29
+ function listJs(dir) {
30
+ try {
31
+ return fs.readdirSync(dir, { withFileTypes: true })
32
+ .filter((e) => e.isFile() && e.name.endsWith('.js'))
33
+ .map((e) => e.name.replace(/\.js$/, ''));
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ // Working-dir module files (relative, with `.js`), tolerant of any fs error and
40
+ // of a flat directory that wrongly contains subdirs (scanFiles() would fatal).
41
+ function moduleFiles() {
42
+ const out = [];
43
+ const nestable = nestableDirs();
44
+ for (const rf of ROOT_FILES) {
45
+ try { if (fs.existsSync(rf)) out.push(rf); } catch { /* ignore */ }
46
+ }
47
+ const walk = (dir, base) => {
48
+ let entries;
49
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
50
+ for (const e of entries) {
51
+ const full = path.join(dir, e.name);
52
+ if (e.isDirectory()) { if (nestable.has(base)) walk(full, base); }
53
+ else if (e.isFile() && e.name.endsWith('.js')) out.push(full);
54
+ }
55
+ };
56
+ for (const dir of moduleDirs()) {
57
+ try { if (fs.existsSync(dir)) walk(dir, dir); } catch { /* ignore */ }
58
+ }
59
+ return out.map((f) => f.split(path.sep).join('/'));
60
+ }
61
+
62
+ // `tgcloud add <target>`: first a directory (`handlers/`, `lib/`), then — for a
63
+ // directory with a closed vocabulary (handlers/ = update types) — the names not
64
+ // already present — the same available-names set `add` lists when given a bare
65
+ // directory (commands/add.js's requireLeaf), from the same source.
66
+ function completeAdd(cur) {
67
+ if (!cur.includes('/')) {
68
+ return { items: moduleDirs().map((d) => `${d}/`), nospace: true };
69
+ }
70
+ const slash = cur.indexOf('/');
71
+ const dir = cur.slice(0, slash);
72
+ const desc = directoryByName(dir);
73
+ if (!desc || !desc.allowedNames) return { items: [], nospace: false };
74
+ const existing = new Set(listJs(dir));
75
+ return {
76
+ items: desc.allowedNames.filter((n) => !existing.has(n)).map((n) => `${dir}/${n}`),
77
+ nospace: false,
78
+ };
79
+ }
80
+
81
+ // `tgcloud run <module>`: modules that actually exist locally in a runnable
82
+ // directory. With a single runnable dir (v1: handlers/), offer the bare leaf
83
+ // name — what users type; otherwise the full `dir/name` path.
84
+ function completeRun() {
85
+ const runnable = runnableDirs();
86
+ const rset = new Set(runnable);
87
+ const bare = runnable.length === 1;
88
+ const items = [];
89
+ for (const rel of moduleFiles()) {
90
+ const top = rel.split('/')[0];
91
+ if (!rset.has(top)) continue;
92
+ const mod = rel.replace(/\.js$/, '');
93
+ items.push(bare ? mod.slice(top.length + 1) : mod);
94
+ }
95
+ return { items: [...new Set(items)], nospace: false };
96
+ }
97
+
98
+ // Targeted `push`/`reset`/`diff`: the deployable file paths.
99
+ function completeFiles() {
100
+ return { items: moduleFiles(), nospace: false };
101
+ }
102
+
103
+ function longFlags(cmd) {
104
+ return cmd ? cmd.options.map((o) => o.long).filter(Boolean) : [];
105
+ }
106
+
107
+ // A command's subcommand names (aliases included), minus the hidden __complete.
108
+ function subNames(cmd) {
109
+ return cmd.commands
110
+ .filter((c) => c.name() !== '__complete')
111
+ .flatMap((c) => [c.name(), ...c.aliases()]);
112
+ }
113
+
114
+ // Walk the already-typed words down the command tree. Returns the deepest
115
+ // matched command (`node`) and the positional words consumed under it (flags,
116
+ // and — best effort — words that match a subcommand, are not positionals).
117
+ // Descending into a subcommand resets the positional count.
118
+ function resolvePath(program, prior) {
119
+ let node = program;
120
+ const positionals = [];
121
+ for (const w of prior) {
122
+ if (w.startsWith('-')) continue; // a flag (its value, if any, is ignored)
123
+ const child = node.commands.find(
124
+ (c) => c.name() === w || (c.aliases && c.aliases().includes(w))
125
+ );
126
+ if (child) { node = child; positionals.length = 0; }
127
+ else positionals.push(w);
128
+ }
129
+ return { node, positionals };
130
+ }
131
+
132
+ // Positional completion for a leaf command (no subcommands of its own).
133
+ function completeLeaf(name, first, cur) {
134
+ const empty = { items: [], nospace: false };
135
+ switch (name) {
136
+ case 'add': return first ? completeAdd(cur) : empty;
137
+ case 'run': return first ? completeRun() : empty;
138
+ case 'push': return completeFiles();
139
+ case 'reset': case 'diff': return first ? completeFiles() : empty;
140
+ case 'completion': return first ? { items: ['bash', 'zsh', 'fish'], nospace: false } : empty;
141
+ default: return empty;
142
+ }
143
+ }
144
+
145
+ // Core dispatcher: given the words typed after `tgcloud` (last = current partial
146
+ // word, possibly ''), return { items, nospace }. Every item is already filtered
147
+ // to the current prefix.
148
+ function computeCandidates(program, words) {
149
+ const cur = words.length ? words[words.length - 1] : '';
150
+ const prior = words.slice(0, -1);
151
+ const empty = { items: [], nospace: false };
152
+
153
+ let result;
154
+ // `help` is commander's implicit command (not in program.commands): it takes a
155
+ // command name as its argument. Handle it before the tree walk.
156
+ if (prior[0] === 'help') {
157
+ const rest = prior.slice(1).filter((w) => !w.startsWith('-'));
158
+ result = (rest.length === 0 && !cur.startsWith('-'))
159
+ ? { items: subNames(program), nospace: false }
160
+ : empty;
161
+ } else {
162
+ const { node, positionals } = resolvePath(program, prior);
163
+ const atTop = node === program;
164
+ const first = positionals.length === 0;
165
+
166
+ if (cur.startsWith('-')) {
167
+ // This command's own flags. At the top level also the global flags (+
168
+ // implicit --help/--version); commander rejects globals after a subcommand.
169
+ result = atTop
170
+ ? { items: [...longFlags(program), '--help', '--version'], nospace: false }
171
+ : { items: [...longFlags(node), '--help'], nospace: false };
172
+ } else if (node.commands.some((c) => c.name() !== '__complete') && first) {
173
+ // The command has subcommands and none is chosen yet → offer them.
174
+ // `help` is a valid top-level target too.
175
+ const names = subNames(node);
176
+ if (atTop) names.push('help');
177
+ result = { items: names, nospace: false };
178
+ } else {
179
+ result = completeLeaf(node.name(), first, cur);
180
+ }
181
+ }
182
+
183
+ const seen = new Set();
184
+ const items = result.items.filter((it) => {
185
+ if (typeof it !== 'string' || !it.startsWith(cur) || seen.has(it)) return false;
186
+ seen.add(it);
187
+ return true;
188
+ });
189
+ return { items, nospace: result.nospace };
190
+ }
191
+
192
+ // --- shim scripts -----------------------------------------------------------
193
+ // Emitted by `completion <shell>`. Each collects the words up to the cursor,
194
+ // calls `tgcloud __complete -- …`, and feeds the candidates back, honoring the
195
+ // no-space directive. `-- ` guards words that begin with `-` from being parsed
196
+ // as options by the __complete invocation.
197
+
198
+ const BASH = `# tgcloud bash completion. Enable with: eval "$(tgcloud completion bash)"
199
+ _tgcloud_complete() {
200
+ local out cands directive
201
+ out="$(tgcloud __complete -- "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)"
202
+ [ -z "$out" ] && return 0
203
+ directive="\${out##*$'\\n'}"
204
+ if [ "$out" = "$directive" ]; then cands=""; else cands="\${out%$'\\n'*}"; fi
205
+ COMPREPLY=()
206
+ local line
207
+ while IFS= read -r line; do [ -n "$line" ] && COMPREPLY+=("$line"); done <<< "$cands"
208
+ case "$directive" in
209
+ :*) [ $(( \${directive#:} & ${NOSPACE} )) -ne 0 ] && compopt -o nospace 2>/dev/null ;;
210
+ esac
211
+ }
212
+ complete -F _tgcloud_complete tgcloud
213
+ `;
214
+
215
+ const ZSH = `#compdef tgcloud
216
+ # tgcloud zsh completion. Enable with: eval "$(tgcloud completion zsh)"
217
+ # or: tgcloud completion zsh > "\${fpath[1]}/_tgcloud"
218
+ _tgcloud() {
219
+ local out directive d
220
+ local -a lines cands
221
+ out="$(tgcloud __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)"
222
+ [[ -z "$out" ]] && return 0
223
+ lines=("\${(@f)out}")
224
+ directive="\${lines[-1]}"
225
+ cands=("\${(@)lines[1,-2]}")
226
+ (( \${#cands} )) || return 0
227
+ # Write -S '' literally: an empty element in an unquoted array expansion is
228
+ # dropped by zsh, which would make -S swallow the following '--' as its suffix.
229
+ d="\${directive#:}"
230
+ if [[ "$d" == <-> ]] && (( (d & ${NOSPACE}) != 0 )); then
231
+ compadd -S '' -- $cands
232
+ else
233
+ compadd -- $cands
234
+ fi
235
+ }
236
+ if [ "\${funcstack[1]}" = "_tgcloud" ]; then
237
+ _tgcloud "$@"
238
+ else
239
+ compdef _tgcloud tgcloud
240
+ fi
241
+ `;
242
+
243
+ const FISH = `# tgcloud fish completion. Enable with:
244
+ # tgcloud completion fish > ~/.config/fish/completions/tgcloud.fish
245
+ function __tgcloud_complete
246
+ set -l tokens (commandline -opc) (commandline -ct)
247
+ set -e tokens[1]
248
+ tgcloud __complete -- $tokens 2>/dev/null | string match -rv '^:'
249
+ end
250
+ complete -c tgcloud -f -a '(__tgcloud_complete)'
251
+ `;
252
+
253
+ const SHIMS = { bash: BASH, zsh: ZSH, fish: FISH };
254
+
255
+ const INSTRUCTIONS = `# tgcloud shell completion
256
+ #
257
+ # Run one of these, then restart your shell:
258
+ #
259
+ # bash: echo 'eval "$(tgcloud completion bash)"' >> ~/.bashrc
260
+ # (requires the bash-completion package)
261
+ # zsh: echo 'eval "$(tgcloud completion zsh)"' >> ~/.zshrc
262
+ # (ensure 'autoload -U compinit && compinit' runs in ~/.zshrc)
263
+ # fish: tgcloud completion fish > ~/.config/fish/completions/tgcloud.fish
264
+ #
265
+ # Usage: tgcloud completion <bash|zsh|fish>
266
+ `;
267
+
268
+ export function registerCompletion(program) {
269
+ program
270
+ .command('completion [shell]')
271
+ .description('Print a shell completion script (bash, zsh, or fish)')
272
+ .action((shell) => {
273
+ const s = (shell || '').toLowerCase();
274
+ if (SHIMS[s]) {
275
+ process.stdout.write(SHIMS[s]);
276
+ return;
277
+ }
278
+ // No/unknown shell: print human instructions as comments, so an accidental
279
+ // `eval "$(tgcloud completion)"` is a harmless no-op rather than an error.
280
+ if (s) process.stdout.write(`# Unknown shell "${s}".\n`);
281
+ process.stdout.write(INSTRUCTIONS);
282
+ });
283
+
284
+ program
285
+ .command('__complete [words...]', { hidden: true })
286
+ .description('Internal: emit shell-completion candidates.')
287
+ .action((words = [], _opts, cmd) => {
288
+ let out = '';
289
+ try {
290
+ const { items, nospace } = computeCandidates(cmd.parent, words);
291
+ for (const it of items) out += `${it}\n`;
292
+ out += `:${nospace ? NOSPACE : 0}\n`;
293
+ } catch {
294
+ out = ':0\n';
295
+ }
296
+ process.stdout.write(out);
297
+ });
298
+ }
@@ -0,0 +1,319 @@
1
+ import fs from 'fs';
2
+ import chalk from 'chalk';
3
+ import { resolveToken, retryOnAuthError } from '../core/credentials.js';
4
+ import { syncCapabilities } from '../core/capabilities.js';
5
+ import { scanFiles, warnStrayFiles } from '../core/scanner.js';
6
+ import { syncedDirs, ROOT_FILES, findDisallowedModules } from '../core/project-layout.js';
7
+ import { localDiff } from '../core/local-diff.js';
8
+ import { batchDeploy, getFiles } from '../api/endpoints.js';
9
+ import { describeServerError } from '../api/client.js';
10
+ import { pathToModule, replaceSnapshot } from '../core/snapshot.js';
11
+ import { loadState, saveState } from '../core/state.js';
12
+ import { formatBriefSummary } from '../core/db-changes.js';
13
+ import { confirm, closePrompts } from '../utils/prompt.js';
14
+ import {
15
+ done,
16
+ modified,
17
+ newFile,
18
+ deleted,
19
+ info,
20
+ fatal,
21
+ } from '../utils/logger.js';
22
+ import { createSpinner } from '../utils/spinner.js';
23
+
24
+ const SCHEMA_FILE = 'schema.js';
25
+
26
+ export function registerDeploy(program) {
27
+ program
28
+ .command('push [files...]')
29
+ .description('Deploy changed modules to the cloud (batch). Optional files/dirs narrow what is sent.')
30
+ .option('--force', 'Skip concurrency check — overwrite whatever is in the cloud')
31
+ .action(async (targetFiles, options) => {
32
+ // Refresh the project layout from the server before scanning, so the
33
+ // manifest reflects any directories the platform has added since release.
34
+ await syncCapabilities();
35
+
36
+ const scanned = scanFiles();
37
+
38
+ // Resolve explicit targets (specific files, shell-expanded globs, or a
39
+ // directory like `handlers/`) to a set of deployable paths. A named target
40
+ // that isn't deployable — missing, or a stray .js outside a module dir —
41
+ // is a hard error: you asked for it by name, so we don't silently skip it.
42
+ let targeted = false;
43
+ let targetSet = null;
44
+ if (targetFiles && targetFiles.length) {
45
+ targeted = true;
46
+ targetSet = resolveTargets(targetFiles, scanned);
47
+ } else {
48
+ // Full deploy: nudge about stray root-level .js (non-blocking).
49
+ warnStrayFiles();
50
+ }
51
+
52
+ // Reject module names a directory doesn't allow (e.g. handlers/ accepts
53
+ // only Bot API update types) locally, before the deploy round-trip.
54
+ const disallowed = findDisallowedModules(scanned);
55
+ if (disallowed.length) {
56
+ const d = disallowed[0];
57
+ fatal(
58
+ `${d.file}: "${d.name}" is not a valid module name in ${d.dir}/. ` +
59
+ `Allowed: ${d.allowed.join(', ')}.`
60
+ );
61
+ }
62
+
63
+ // 1. Compute what changed locally (offline).
64
+ const diff = localDiff();
65
+
66
+ console.log('\nChecking changes...');
67
+ const changedPaths = []; // modified + newLocal, will be validated and sent
68
+ const deletedPaths = []; // remoteOnly, will be omitted from manifest
69
+
70
+ for (const f of diff.modified) {
71
+ if (targeted && !targetSet.has(f.path)) continue;
72
+ modified(f.path);
73
+ changedPaths.push(f.path);
74
+ }
75
+ for (const f of diff.newLocal) {
76
+ if (targeted && !targetSet.has(f.path)) continue;
77
+ newFile(f.path);
78
+ changedPaths.push(f.path);
79
+ }
80
+ for (const f of diff.remoteOnly) {
81
+ if (targeted && !targetSet.has(f.path)) continue;
82
+ deleted(f.path);
83
+ deletedPaths.push(f.path);
84
+ }
85
+ // Unchanged (matched) files are intentionally not listed — only changes
86
+ // are worth showing. If everything matched, the no-changes path below fires.
87
+
88
+ if (!changedPaths.length && !deletedPaths.length) {
89
+ info('\nNo changes to deploy.');
90
+ return;
91
+ }
92
+
93
+ // 2. Build the batch payload.
94
+ // Manifest = the complete set of modules that should exist on the
95
+ // server after this deploy. Anything on the server not in the manifest
96
+ // is implicitly deleted.
97
+ // Only files in `synced` directories (plus root files) belong to the
98
+ // deployed module space. Today every directory is synced, so this filter
99
+ // is a no-op; it honors the descriptor's `synced` flag for future
100
+ // local-only dirs.
101
+ const synced = new Set(syncedDirs());
102
+ const inDeployedSpace = (p) => {
103
+ if (ROOT_FILES.includes(p)) return true;
104
+ return synced.has(p.split(/[\\/]/)[0]);
105
+ };
106
+
107
+ // A NEW local-only module (in the working dir, not yet on the server) may
108
+ // only enter the manifest if we also send its source. A targeted deploy
109
+ // narrows `changed_sources` to the target, so any untargeted new module
110
+ // would land in the manifest WITHOUT a source — and the server rejects it
111
+ // with MODULE_NOT_FOUND (it has no copy to keep). Exclude those. Modules
112
+ // already on the server (matched/modified) stay regardless of the target,
113
+ // so a targeted deploy doesn't prune them.
114
+ const newLocalSet = new Set(diff.newLocal.map((f) => f.path));
115
+ const manifestPaths = scanned.filter((p) => {
116
+ if (!inDeployedSpace(p)) return false;
117
+ if (targeted && newLocalSet.has(p) && !targetSet.has(p)) return false;
118
+ return true;
119
+ });
120
+ // Targeted deploy (`push <file>`) must touch only the requested file: it
121
+ // must NOT prune modules that exist on the server but are absent from the
122
+ // working dir. Those `remoteOnly` paths aren't scanned (not on disk), so
123
+ // we add them back to the manifest to keep the server from deleting them.
124
+ // A full deploy (no targets) omits them on purpose, which is how
125
+ // deletions are synced.
126
+ if (targeted) {
127
+ for (const f of diff.remoteOnly) {
128
+ if (inDeployedSpace(f.path)) manifestPaths.push(f.path);
129
+ }
130
+ }
131
+
132
+ const manifest = manifestPaths.map(pathToModule);
133
+
134
+ const changed_sources = {};
135
+ for (const p of changedPaths) {
136
+ if (!inDeployedSpace(p)) continue;
137
+ changed_sources[pathToModule(p)] = fs.readFileSync(p, 'utf-8');
138
+ }
139
+
140
+ const state = loadState();
141
+ let expected_revision = options.force ? null : state.last_known_revision;
142
+
143
+ // Never-synced guard. A null last_known_revision sends
144
+ // expected_revision:null, which the server treats as force — so a first
145
+ // push from a freshly-bound project would silently overwrite (and prune)
146
+ // a bot that already has deployed content, with no conflict prompt. Before
147
+ // that, check the server: if it already has modules, refuse unless --force.
148
+ // (`login` normally syncs on bind so this won't fire; it's the safety net
149
+ // for env-token-only use or a manually cleared snapshot.)
150
+ if (expected_revision == null && !options.force) {
151
+ let remote;
152
+ try {
153
+ remote = await retryOnAuthError(async () => getFiles(await resolveToken()));
154
+ } catch (err) {
155
+ fatal(describeServerError(err));
156
+ }
157
+ const remoteCount = Object.keys(remote?.canonical_modules || {}).length;
158
+ if (remoteCount > 0) {
159
+ fatal(
160
+ `This bot already has ${remoteCount} deployed module${remoteCount === 1 ? '' : 's'}` +
161
+ (remote.revision != null ? ` (revision ${remote.revision})` : '') +
162
+ `, but this project has never synced with it.\n` +
163
+ ` Run "tgcloud fetch" to review the difference, then push,\n` +
164
+ ` or "tgcloud push --force" to overwrite the bot with your local project.`
165
+ );
166
+ }
167
+ // Empty bot: safe to proceed. Adopt its revision so the deploy is a
168
+ // normal conditional create (409 if content appears before we send).
169
+ if (remote?.revision != null) expected_revision = remote.revision;
170
+ }
171
+
172
+ if (deletedPaths.length) {
173
+ const n = deletedPaths.length;
174
+ const plural = n === 1 ? '' : 's';
175
+ const isTTY = process.stdout.isTTY || process.env.TGCLOUD_ASSUME_TTY === '1';
176
+ // A full push prunes server modules that are absent locally (the documented
177
+ // deletion model). In an interactive terminal, confirm first — a stray cd or
178
+ // a partial checkout could otherwise wipe modules from a single command.
179
+ // Targeted pushes never prune (remoteOnly is re-added above); CI (non-TTY)
180
+ // and --force proceed without a prompt so scripted deploys aren't blocked.
181
+ if (!targeted && !options.force && isTTY) {
182
+ const ok = await confirm(
183
+ `\n${n} module${plural} in the cloud ${n === 1 ? 'is' : 'are'} absent locally and will be DELETED. Continue?`
184
+ );
185
+ closePrompts();
186
+ if (!ok) { info('Cancelled.'); return; }
187
+ } else {
188
+ info(`\n${n} module${plural} will be deleted in the cloud (absent locally).`);
189
+ }
190
+ }
191
+
192
+ // 4. Send the batch (with automatic re-auth on 401/403).
193
+ const startTime = Date.now();
194
+ let response;
195
+ try {
196
+ response = await retryOnAuthError(async () => {
197
+ const token = await resolveToken();
198
+ const spinner = createSpinner(
199
+ `Deploying ${changedPaths.length} change${changedPaths.length === 1 ? '' : 's'}...`
200
+ );
201
+ spinner.start();
202
+ try {
203
+ const r = await batchDeploy(token, { expected_revision, manifest, changed_sources });
204
+ spinner.stop();
205
+ return r;
206
+ } catch (err) {
207
+ spinner.stop();
208
+ throw err;
209
+ }
210
+ });
211
+ } catch (err) {
212
+ // A revision mismatch gets its own actionable help block; any other
213
+ // server failure prints one Error: line (no ora ✖ glyph).
214
+ if (isRevisionMismatch(err)) {
215
+ showRevisionMismatchHelp(err);
216
+ } else {
217
+ console.error(chalk.red('Error: ') + describeServerError(err));
218
+ }
219
+ process.exit(1);
220
+ }
221
+
222
+ for (const p of changedPaths) done(p);
223
+ for (const p of deletedPaths) done(p, 'deleted');
224
+
225
+ // 5. Update local snapshot and state from server's canonical response.
226
+ if (response?.canonical_modules) {
227
+ replaceSnapshot(response.canonical_modules);
228
+ }
229
+ if (response?.revision != null) {
230
+ const s = loadState();
231
+ s.last_known_revision = response.revision;
232
+ saveState(s);
233
+ }
234
+
235
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
236
+ info(`\nDone in ${elapsed}s`);
237
+
238
+ // 6. If schema.js was deployed and the server reported pending db_changes,
239
+ // print them as a heads-up and suggest `tgcloud migrate`. push/deploy
240
+ // never applies DB migrations itself — that's a separate, explicit command.
241
+ const schemaDeployed = changedPaths.includes(SCHEMA_FILE);
242
+ if (schemaDeployed && response?.db_changes?.length) {
243
+ const n = response.db_changes.length;
244
+ console.log('');
245
+ console.log(chalk.bold(`Schema out of sync — ${n} change${n === 1 ? '' : 's'}:`));
246
+ console.log(formatBriefSummary(response.db_changes));
247
+ console.log('');
248
+ info('Run "tgcloud migrate" to apply schema changes to the database.');
249
+ }
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Turn raw `deploy` targets into a set of deployable module paths. Each target
255
+ * is either an exact scanned file, or a directory (`handlers/`) expanded to the
256
+ * scanned files under it. Anything else — a missing path, or a stray .js outside
257
+ * a module directory — aborts with a per-target reason (you named it explicitly,
258
+ * so silently dropping it would be wrong).
259
+ */
260
+ function resolveTargets(rawTargets, scanned) {
261
+ const scannedSet = new Set(scanned);
262
+ const targetSet = new Set();
263
+ const bad = [];
264
+ for (const raw of rawTargets) {
265
+ if (scannedSet.has(raw)) {
266
+ targetSet.add(raw);
267
+ continue;
268
+ }
269
+ // Accept a bare module name without the `.js` extension, so a target reads
270
+ // the same as `tgcloud run` (run handlers/message ≡ push handlers/message).
271
+ const withExt = raw.endsWith('.js') ? raw : `${raw}.js`;
272
+ if (scannedSet.has(withExt)) {
273
+ targetSet.add(withExt);
274
+ continue;
275
+ }
276
+ const dir = raw.replace(/[\\/]+$/, '');
277
+ const under = scanned.filter((p) => p.startsWith(dir + '/'));
278
+ if (under.length) {
279
+ under.forEach((p) => targetSet.add(p));
280
+ continue;
281
+ }
282
+ bad.push(raw);
283
+ }
284
+ if (bad.length) {
285
+ const w = Math.max(...bad.map((t) => t.length));
286
+ const lines = bad.map(
287
+ (t) => ` ${t.padEnd(w)} — ${fs.existsSync(t) ? 'not in a deployed module directory' : 'not found'}`
288
+ );
289
+ fatal(
290
+ `Cannot deploy:\n${lines.join('\n')}\n` +
291
+ `Run "tgcloud status" to see deployable files.`
292
+ );
293
+ }
294
+ return targetSet;
295
+ }
296
+
297
+ function isRevisionMismatch(err) {
298
+ const p = err.parameters || {};
299
+ return typeof p.current_revision === 'number' || err.status === 409;
300
+ }
301
+
302
+ function showRevisionMismatchHelp(err) {
303
+ const p = err.parameters || {};
304
+ console.error(chalk.yellow(
305
+ '\nServer has new revisions since your last sync.'
306
+ ));
307
+ if (p.current_revision != null) {
308
+ console.error(chalk.dim(` Server revision: ${p.current_revision}`));
309
+ }
310
+ if (p.expected_revision != null) {
311
+ console.error(chalk.dim(` Your revision: ${p.expected_revision}`));
312
+ }
313
+ console.error(
314
+ '\nOptions:\n' +
315
+ ' tgcloud fetch — pull latest into snapshot, then re-check diff\n' +
316
+ ' tgcloud pull — pull latest into both snapshot and working dir\n' +
317
+ ' tgcloud push --force — overwrite server state (dangerous)'
318
+ );
319
+ }
@@ -0,0 +1,57 @@
1
+ import chalk from 'chalk';
2
+ import { localDiff } from '../core/local-diff.js';
3
+ import { computeDiff } from '../core/file-diff.js';
4
+ import { createPager } from '../utils/pager.js';
5
+
6
+ export function registerDiff(program) {
7
+ program
8
+ .command('diff [file]')
9
+ .description('Show line-by-line diff between working directory and local snapshot (offline)')
10
+ .action(async (targetFile) => {
11
+ const diff = localDiff();
12
+
13
+ const allChanged = [
14
+ ...diff.modified.map((f) => ({ ...f, kind: 'modified' })),
15
+ ...diff.newLocal.map((f) => ({ ...f, kind: 'new', remoteContent: '' })),
16
+ ...diff.remoteOnly.map((f) => ({ ...f, kind: 'deleted', localContent: '' })),
17
+ ];
18
+
19
+ const filtered = targetFile
20
+ ? allChanged.filter((f) => f.path === targetFile)
21
+ : allChanged;
22
+
23
+ // Collect patches first so we don't even launch the pager for empty output.
24
+ const chunks = [];
25
+ for (const f of filtered) {
26
+ const patch = computeDiff(f.remoteContent || '', f.localContent || '', f.path);
27
+ if (patch) chunks.push(formatPatch(patch));
28
+ }
29
+
30
+ if (!chunks.length) {
31
+ console.log('No changes.');
32
+ return;
33
+ }
34
+
35
+ const pager = createPager();
36
+ try {
37
+ for (const chunk of chunks) {
38
+ pager.write(chunk);
39
+ if (!chunk.endsWith('\n')) pager.write('\n');
40
+ }
41
+ } finally {
42
+ await pager.close();
43
+ }
44
+ });
45
+ }
46
+
47
+ function formatPatch(patch) {
48
+ return patch
49
+ .split('\n')
50
+ .map((line) => {
51
+ if (line.startsWith('+') && !line.startsWith('+++')) return chalk.green(line);
52
+ if (line.startsWith('-') && !line.startsWith('---')) return chalk.red(line);
53
+ if (line.startsWith('@@')) return chalk.cyan(line);
54
+ return line;
55
+ })
56
+ .join('\n');
57
+ }