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