@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,23 @@
|
|
|
1
|
+
import { syncSnapshot } from '../core/sync.js';
|
|
2
|
+
import { info } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Download current server state into .tgcloud/remote/ ONLY.
|
|
6
|
+
* Working directory is not modified.
|
|
7
|
+
*/
|
|
8
|
+
export function registerFetch(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('fetch')
|
|
11
|
+
.description('Update local snapshot from the cloud without touching working directory')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
try {
|
|
14
|
+
const { count, revision } = await syncSnapshot();
|
|
15
|
+
info(
|
|
16
|
+
`Snapshot updated — ${count} module${count === 1 ? '' : 's'}` +
|
|
17
|
+
(revision != null ? ` (revision ${revision})` : '')
|
|
18
|
+
);
|
|
19
|
+
} catch {
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { success, info, fatal } from '../utils/logger.js';
|
|
8
|
+
import { loadState, saveState } from '../core/state.js';
|
|
9
|
+
import { findProjectRoot } from '../core/project-root.js';
|
|
10
|
+
import {
|
|
11
|
+
scaffoldEntries,
|
|
12
|
+
templates,
|
|
13
|
+
} from '../core/project-layout.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const templatesDir = path.join(__dirname, '..', 'templates');
|
|
17
|
+
|
|
18
|
+
function sha256Hex(buffer) {
|
|
19
|
+
return 'sha256:' + createHash('sha256').update(buffer).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Content for a scaffolded file, by its relative path: prefer the server-provided
|
|
23
|
+
// template (from GET /capabilities, keyed by path), else the CLI's bundled copy.
|
|
24
|
+
// Returns null when neither exists — the file is skipped.
|
|
25
|
+
function templateContent(relPath, serverTemplates) {
|
|
26
|
+
if (serverTemplates[relPath] != null) return serverTemplates[relPath];
|
|
27
|
+
const bundled = path.join(templatesDir, relPath);
|
|
28
|
+
return fs.existsSync(bundled) ? fs.readFileSync(bundled, 'utf-8') : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Ensure a valid Node manifest exists — `init` owns the canonical package.json
|
|
32
|
+
// shape (create-bot writes only a frozen `{ name, private }` stub before it can
|
|
33
|
+
// run `npm install`, so the evolving fields live here in the CLI, not there).
|
|
34
|
+
//
|
|
35
|
+
// Additive merge: fill only MISSING top-level keys, never overwrite existing
|
|
36
|
+
// ones — critical for `devDependencies` that `npm install` wrote in the
|
|
37
|
+
// create-bot flow, and for a user's own fields when `init` runs standalone.
|
|
38
|
+
// `scripts` is all-or-nothing: added only when the key is absent entirely (if
|
|
39
|
+
// the user already has scripts, we don't inject into their object). The script
|
|
40
|
+
// bodies are bare `tgcloud …` on purpose: npm puts node_modules/.bin on PATH
|
|
41
|
+
// when running a script, so bare resolves there. Returns 'created' | 'updated' |
|
|
42
|
+
// null (unchanged, or a malformed file we refuse to clobber).
|
|
43
|
+
function ensurePackageJson() {
|
|
44
|
+
const defaults = {
|
|
45
|
+
name: path.basename(process.cwd()),
|
|
46
|
+
version: '0.1.0',
|
|
47
|
+
private: true,
|
|
48
|
+
type: 'module',
|
|
49
|
+
};
|
|
50
|
+
const existed = fs.existsSync('package.json');
|
|
51
|
+
let pkg = {};
|
|
52
|
+
if (existed) {
|
|
53
|
+
try {
|
|
54
|
+
pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null; // malformed — leave the user's file untouched
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let changed = false;
|
|
60
|
+
for (const [k, v] of Object.entries(defaults)) {
|
|
61
|
+
if (!(k in pkg)) { pkg[k] = v; changed = true; }
|
|
62
|
+
}
|
|
63
|
+
if (!('scripts' in pkg)) {
|
|
64
|
+
pkg.scripts = { deploy: 'tgcloud push', status: 'tgcloud status', run: 'tgcloud run' };
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
if (!existed || changed) {
|
|
68
|
+
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
69
|
+
return existed ? 'updated' : 'created';
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Print the created dirs/files as a flat, alphabetical ✓ checklist — same shape
|
|
75
|
+
// as `push`'s deploy list: files by full path, plus any created dir that stayed
|
|
76
|
+
// empty (so an otherwise-invisible starter folder still shows). Dot-entries
|
|
77
|
+
// (.gitignore, .tgcloud/) sort to the top.
|
|
78
|
+
function printCreated(dirs, files) {
|
|
79
|
+
const emptyDirs = dirs
|
|
80
|
+
.filter((d) => !files.some((f) => f === d || f.startsWith(d + '/')))
|
|
81
|
+
.map((d) => `${d}/`);
|
|
82
|
+
const entries = [...files, ...emptyDirs].sort((a, b) => a.localeCompare(b));
|
|
83
|
+
for (const e of entries) success(e);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Best-effort git setup for a freshly scaffolded project.
|
|
87
|
+
//
|
|
88
|
+
// Skip entirely when we're already inside a git work tree (a nested backend/
|
|
89
|
+
// folder): a second `git init` there creates an embedded repo the outer one can
|
|
90
|
+
// only track as a bare gitlink, so the files silently vanish on clone.
|
|
91
|
+
//
|
|
92
|
+
// Otherwise init + stage + commit as ONE all-or-nothing step, so we never leave
|
|
93
|
+
// a half-initialized repo (empty, or inited-but-uncommitted — the odd middle
|
|
94
|
+
// ground). The commit uses the user's own git
|
|
95
|
+
// identity; if that's unset (or git is missing) the commit fails and we roll the
|
|
96
|
+
// just-created .git back rather than leave a repo with no first commit.
|
|
97
|
+
//
|
|
98
|
+
// Prints a single plain-text line (not a ✓ file entry) so git status reads as a
|
|
99
|
+
// note about the repo, not as one of the scaffolded files.
|
|
100
|
+
function setupGit() {
|
|
101
|
+
try {
|
|
102
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
|
|
103
|
+
info('Using the existing git repository.');
|
|
104
|
+
return;
|
|
105
|
+
} catch {
|
|
106
|
+
// not inside a work tree — create a fresh repo below
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
execSync('git init', { stdio: 'ignore' });
|
|
111
|
+
} catch {
|
|
112
|
+
return; // git not installed — nothing to roll back
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
execSync('git add -A', { stdio: 'ignore' });
|
|
117
|
+
execSync('git commit -m "Initial commit"', { stdio: 'ignore' });
|
|
118
|
+
info('Initialized a git repository with an initial commit.');
|
|
119
|
+
} catch {
|
|
120
|
+
fs.rmSync('.git', { recursive: true, force: true });
|
|
121
|
+
info('Skipped git — set your git user.name and user.email, then run "git init" yourself.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function registerInit(program) {
|
|
126
|
+
program
|
|
127
|
+
.command('init')
|
|
128
|
+
.description('Initialize a new tgcloud project (local scaffolding only)')
|
|
129
|
+
.option('--no-git', 'Skip git repository initialization and the initial commit')
|
|
130
|
+
.action(async (options) => {
|
|
131
|
+
// Refuse to create a project nested inside another one. Re-running `init`
|
|
132
|
+
// in the same directory is fine (idempotent scaffolding); only an ancestor
|
|
133
|
+
// that is already a project is an error — that would shadow the outer one.
|
|
134
|
+
const existing = findProjectRoot();
|
|
135
|
+
if (existing && path.resolve(existing) !== path.resolve(process.cwd())) {
|
|
136
|
+
fatal(
|
|
137
|
+
`Already inside a tgcloud project at ${existing}.\n` +
|
|
138
|
+
`Run "tgcloud init" in a separate directory, or cd to that project to work on it.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Track the scaffolded project files so we can hash them for init_templates.
|
|
143
|
+
const scaffoldedFiles = [];
|
|
144
|
+
// Collect what we create and print it sorted at the end (folders first,
|
|
145
|
+
// then files, each alphabetical) rather than in descriptor order.
|
|
146
|
+
const createdDirs = [];
|
|
147
|
+
const createdFiles = [];
|
|
148
|
+
|
|
149
|
+
// Walk the layout's scaffold list (path-driven: server-provided when
|
|
150
|
+
// available, baseline otherwise). Directories end with "/"; files — which
|
|
151
|
+
// may be nested paths like docs/guide.md or handlers/message.js — get
|
|
152
|
+
// their content from the templates map (by path), else the bundled copy.
|
|
153
|
+
const serverTemplates = templates();
|
|
154
|
+
|
|
155
|
+
for (const entry of scaffoldEntries()) {
|
|
156
|
+
if (entry.endsWith('/')) {
|
|
157
|
+
const dir = entry.slice(0, -1);
|
|
158
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
159
|
+
createdDirs.push(dir);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (fs.existsSync(entry)) continue;
|
|
163
|
+
const content = templateContent(entry, serverTemplates);
|
|
164
|
+
if (content == null) continue; // no template for this file — skip
|
|
165
|
+
const dir = path.dirname(entry);
|
|
166
|
+
if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
|
|
167
|
+
fs.writeFileSync(entry, content);
|
|
168
|
+
createdFiles.push(entry);
|
|
169
|
+
// Track deployable modules (.js) for init_templates; docs (.md, etc.)
|
|
170
|
+
// don't deploy, so a change to them isn't a skeleton modification.
|
|
171
|
+
if (entry.endsWith('.js')) scaffoldedFiles.push(entry);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// .gitignore — ensure .tgcloud/ is ignored.
|
|
175
|
+
const gitignoreContent = fs.readFileSync(path.join(templatesDir, 'gitignore'), 'utf-8');
|
|
176
|
+
if (fs.existsSync('.gitignore')) {
|
|
177
|
+
const existing = fs.readFileSync('.gitignore', 'utf-8');
|
|
178
|
+
const linesToAdd = gitignoreContent.split('\n').filter(
|
|
179
|
+
(line) => line && !existing.split('\n').includes(line)
|
|
180
|
+
);
|
|
181
|
+
if (linesToAdd.length) {
|
|
182
|
+
const suffix = existing.endsWith('\n') ? '' : '\n';
|
|
183
|
+
fs.appendFileSync('.gitignore', suffix + linesToAdd.join('\n') + '\n');
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
fs.writeFileSync('.gitignore', gitignoreContent);
|
|
187
|
+
}
|
|
188
|
+
createdFiles.push('.gitignore');
|
|
189
|
+
|
|
190
|
+
// package.json — create the canonical manifest, or fill missing fields in
|
|
191
|
+
// one already written by create-bot (with npm's devDependencies) / the user.
|
|
192
|
+
if (ensurePackageJson()) createdFiles.push('package.json');
|
|
193
|
+
|
|
194
|
+
// Record init_templates in state.json — hashes of the scaffolded files,
|
|
195
|
+
// used to detect whether the user modified the skeleton before the first deploy.
|
|
196
|
+
//
|
|
197
|
+
// On re-run we preserve existing init_templates and only add hashes for
|
|
198
|
+
// files we actually scaffolded this time. This avoids wiping the marker
|
|
199
|
+
// if someone runs `init` again in an already-initialized project.
|
|
200
|
+
const state = loadState();
|
|
201
|
+
for (const file of scaffoldedFiles) {
|
|
202
|
+
state.init_templates[file] = sha256Hex(fs.readFileSync(file));
|
|
203
|
+
}
|
|
204
|
+
saveState(state);
|
|
205
|
+
createdFiles.push('.tgcloud/state.json');
|
|
206
|
+
|
|
207
|
+
// --- output: intro, then a flat ✓ checklist of what we created ---
|
|
208
|
+
info('Creating a new tgcloud project...');
|
|
209
|
+
printCreated(createdDirs, createdFiles);
|
|
210
|
+
|
|
211
|
+
// Version control — last, so the initial commit captures the full scaffold.
|
|
212
|
+
if (options.git) setupGit();
|
|
213
|
+
|
|
214
|
+
// "Project ready" + the files note is always init's — it owns the scaffold,
|
|
215
|
+
// so the description stays here (generic, no hardcoded names, survives new
|
|
216
|
+
// directories the platform may add). Only the numbered "Next steps" is suppressed
|
|
217
|
+
// when a bootstrap (create-bot) delegates and sets TGCLOUD_INIT_QUIET_NEXT:
|
|
218
|
+
// it prints its own, leading with `cd` and using npx.
|
|
219
|
+
info('\nProject ready. The scaffolded files are a starting point — edit or delete as you like.');
|
|
220
|
+
if (!process.env.TGCLOUD_INIT_QUIET_NEXT) {
|
|
221
|
+
info('\nNext steps:');
|
|
222
|
+
info(` ${chalk.cyan('tgcloud login')} ${chalk.dim('# link this project to a bot')}`);
|
|
223
|
+
info(` ${chalk.cyan('tgcloud push')} ${chalk.dim('# deploy to the cloud')}`);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { loginInteractive } from '../core/credentials.js';
|
|
2
|
+
import { syncSnapshot } from '../core/sync.js';
|
|
3
|
+
import { closePrompts } from '../utils/prompt.js';
|
|
4
|
+
import { info, warn, fatal } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Explicit, interactive authentication. The only command that prompts for a
|
|
8
|
+
* token; every other command reads it from .tgcloud/credentials or TGCLOUD_TOKEN
|
|
9
|
+
* and errors if absent (see core/credentials.js → resolveToken).
|
|
10
|
+
*/
|
|
11
|
+
export function registerLogin(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('login')
|
|
14
|
+
.description('Link this project to a bot by saving its token (interactive)')
|
|
15
|
+
.action(async () => {
|
|
16
|
+
// Requires a real terminal — refuse in scripts/CI rather than hang on a
|
|
17
|
+
// hidden prompt. TGCLOUD_ASSUME_TTY=1 overrides the check for non-TTY stdio.
|
|
18
|
+
const tty = process.stdout.isTTY || process.env.TGCLOUD_ASSUME_TTY === '1';
|
|
19
|
+
if (!tty) {
|
|
20
|
+
fatal('"tgcloud login" requires an interactive terminal.\n For CI, set the TGCLOUD_TOKEN environment variable instead.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (process.env.TGCLOUD_TOKEN) {
|
|
24
|
+
warn('TGCLOUD_TOKEN is set and takes priority; the saved token is used only when it is unset.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// closePrompts() releases the shared stdin reader (used by askPassword's
|
|
28
|
+
// non-interactive path) so the process can exit. A null result means no
|
|
29
|
+
// token was provided (e.g. closed stdin) — exit non-zero so scripts don't
|
|
30
|
+
// mistake a no-op for a successful link.
|
|
31
|
+
let token;
|
|
32
|
+
try {
|
|
33
|
+
token = await loginInteractive();
|
|
34
|
+
} finally {
|
|
35
|
+
closePrompts();
|
|
36
|
+
}
|
|
37
|
+
if (!token) process.exit(1);
|
|
38
|
+
|
|
39
|
+
// Sync the snapshot on bind: this records the bot's current
|
|
40
|
+
// revision + modules, so a subsequent first `push` conflict-checks against
|
|
41
|
+
// the bot's real state instead of silently force-overwriting it (a null
|
|
42
|
+
// last_known_revision is treated as force by the server). The working dir
|
|
43
|
+
// is untouched, so any local files stay and `status` shows the diff.
|
|
44
|
+
// Best-effort: a failed or offline sync must not fail the login itself.
|
|
45
|
+
try {
|
|
46
|
+
const { count, revision } = await syncSnapshot({ spinnerText: 'Syncing bot state...' });
|
|
47
|
+
info(
|
|
48
|
+
`Synced — ${count} module${count === 1 ? '' : 's'} on the bot` +
|
|
49
|
+
(revision != null ? ` (revision ${revision})` : '') +
|
|
50
|
+
(count > 0 ? '. Run "tgcloud status" to compare with your local files.' : '.')
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
// Token is saved; the next online command will sync. Don't block login.
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { resolveToken, retryOnAuthError } from '../core/credentials.js';
|
|
4
|
+
import { getMigrationStatus, applyMigration, describeServerError } from '../api/endpoints.js';
|
|
5
|
+
import { runMigrationFlow } from '../core/migration-flow.js';
|
|
6
|
+
import { closePrompts } from '../utils/prompt.js';
|
|
7
|
+
import { createSpinner } from '../utils/spinner.js';
|
|
8
|
+
import { fatal } from '../utils/logger.js';
|
|
9
|
+
import { ROOT_FILE } from '../core/project-layout.js';
|
|
10
|
+
import { readSnapshotFile } from '../core/snapshot.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compare local schema file with the snapshot (= last-deployed version).
|
|
14
|
+
* Returns a short label describing the discrepancy, or null if they match.
|
|
15
|
+
*/
|
|
16
|
+
function localSchemaDivergence() {
|
|
17
|
+
const localExists = fs.existsSync(ROOT_FILE);
|
|
18
|
+
const remoteContent = readSnapshotFile(ROOT_FILE);
|
|
19
|
+
|
|
20
|
+
if (!localExists && remoteContent == null) return null;
|
|
21
|
+
if (!localExists && remoteContent != null) return 'deleted_locally';
|
|
22
|
+
if (localExists && remoteContent == null) return 'new_locally';
|
|
23
|
+
|
|
24
|
+
const localContent = fs.readFileSync(ROOT_FILE, 'utf-8');
|
|
25
|
+
return localContent === remoteContent ? null : 'modified_locally';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printSchemaDivergenceWarning(kind) {
|
|
29
|
+
const base = '\n' + chalk.yellow('Warning: ') + `Your local ${ROOT_FILE} differs from what's deployed.`;
|
|
30
|
+
const detail =
|
|
31
|
+
` The pending changes below are the diff between the DEPLOYED schema and the DB —\n` +
|
|
32
|
+
` they do NOT include your local edits. Run "tgcloud push" first if you want\n` +
|
|
33
|
+
` those edits to be part of this migration, or use "--local" to diff against\n` +
|
|
34
|
+
` your local schema instead.`;
|
|
35
|
+
console.log(base);
|
|
36
|
+
console.log(detail);
|
|
37
|
+
|
|
38
|
+
if (kind === 'new_locally') {
|
|
39
|
+
console.log(` (Note: ${ROOT_FILE} is new locally — never deployed.)`);
|
|
40
|
+
} else if (kind === 'deleted_locally') {
|
|
41
|
+
console.log(` (Note: ${ROOT_FILE} was deleted locally but still exists in the cloud.)`);
|
|
42
|
+
}
|
|
43
|
+
console.log('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerMigrate(program) {
|
|
47
|
+
program
|
|
48
|
+
.command('migrate')
|
|
49
|
+
.description('Apply pending database schema changes')
|
|
50
|
+
.option('--yes', 'Apply all safe + all warning changes without prompting (for CI)')
|
|
51
|
+
.option('--safe', 'Apply only safe changes, skip warning and manual')
|
|
52
|
+
.option('--local', `Compute diff against your local ${ROOT_FILE} instead of the deployed version`)
|
|
53
|
+
.option('--dry-run', 'Show pending changes without applying anything')
|
|
54
|
+
.action(async (options) => {
|
|
55
|
+
if (options.yes && options.safe) {
|
|
56
|
+
fatal('--yes and --safe are mutually exclusive.');
|
|
57
|
+
}
|
|
58
|
+
if (options.dryRun && (options.yes || options.safe)) {
|
|
59
|
+
fatal('--dry-run cannot be combined with --yes/--safe (nothing to apply in dry-run mode).');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Non-interactive session + no explicit mode → ambiguous, bail out.
|
|
63
|
+
// TGCLOUD_ASSUME_TTY=1 overrides the check, treating piped stdio as interactive.
|
|
64
|
+
const tty = process.stdout.isTTY || process.env.TGCLOUD_ASSUME_TTY === '1';
|
|
65
|
+
if (!tty && !options.dryRun && !options.safe && !options.yes) {
|
|
66
|
+
fatal('non-interactive session — specify --safe, --yes, or --dry-run.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Load local schema if --local is requested.
|
|
70
|
+
let localSchema = null;
|
|
71
|
+
if (options.local) {
|
|
72
|
+
if (!fs.existsSync(ROOT_FILE)) {
|
|
73
|
+
fatal(`--local requires a local ${ROOT_FILE}, none found.`);
|
|
74
|
+
}
|
|
75
|
+
localSchema = fs.readFileSync(ROOT_FILE, 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let migrateFailed = false;
|
|
79
|
+
try {
|
|
80
|
+
await retryOnAuthError(async () => {
|
|
81
|
+
const token = await resolveToken();
|
|
82
|
+
|
|
83
|
+
const spinner = createSpinner('Checking schema...');
|
|
84
|
+
spinner.start();
|
|
85
|
+
|
|
86
|
+
let status;
|
|
87
|
+
try {
|
|
88
|
+
if (localSchema != null) {
|
|
89
|
+
status = await applyMigration(token, { schema: localSchema, dry_run: true });
|
|
90
|
+
} else {
|
|
91
|
+
status = await getMigrationStatus(token);
|
|
92
|
+
}
|
|
93
|
+
spinner.stop();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
spinner.stop();
|
|
96
|
+
console.error(chalk.red('Error: ') + describeServerError(err));
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const dbChanges = status?.db_changes || [];
|
|
101
|
+
|
|
102
|
+
if (options.local) {
|
|
103
|
+
console.log(chalk.dim(`(diff computed against your local ${ROOT_FILE})`));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!dbChanges.length) {
|
|
107
|
+
const target = options.local
|
|
108
|
+
? `your local ${ROOT_FILE}`
|
|
109
|
+
: `the deployed ${ROOT_FILE}`;
|
|
110
|
+
console.log(`No schema changes. Database is in sync with ${target}.`);
|
|
111
|
+
if (!options.local) {
|
|
112
|
+
const divergence = localSchemaDivergence();
|
|
113
|
+
if (divergence) printSchemaDivergenceWarning(divergence);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!options.local) {
|
|
119
|
+
const divergence = localSchemaDivergence();
|
|
120
|
+
if (divergence) printSchemaDivergenceWarning(divergence);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = await runMigrationFlow(token, dbChanges, {
|
|
124
|
+
yes: options.yes,
|
|
125
|
+
safe: options.safe,
|
|
126
|
+
dryRun: options.dryRun,
|
|
127
|
+
schema: localSchema,
|
|
128
|
+
});
|
|
129
|
+
if (result?.failed > 0) migrateFailed = true;
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
process.exit(1);
|
|
133
|
+
} finally {
|
|
134
|
+
closePrompts();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Some changes were attempted but rejected by the server — surface a
|
|
138
|
+
// non-zero exit so CI/scripts can detect a failed migration. Manual/undoc
|
|
139
|
+
// changes (never applied by design) and user-skipped changes do NOT count.
|
|
140
|
+
if (migrateFailed) process.exit(1);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { resolveToken, retryOnAuthError } from '../core/credentials.js';
|
|
5
|
+
import { syncCapabilities } from '../core/capabilities.js';
|
|
6
|
+
import { getFiles, describeServerError } from '../api/endpoints.js';
|
|
7
|
+
import { replaceSnapshot, moduleToPath, listSnapshotPaths } from '../core/snapshot.js';
|
|
8
|
+
import { loadState, saveState } from '../core/state.js';
|
|
9
|
+
import { computeDiff } from '../core/file-diff.js';
|
|
10
|
+
import { done, skipped, info } from '../utils/logger.js';
|
|
11
|
+
import { confirm, closePrompts } from '../utils/prompt.js';
|
|
12
|
+
import { createSpinner } from '../utils/spinner.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Download full server state into working directory AND local snapshot.
|
|
16
|
+
* After a successful pull, working dir, snapshot, and server are all aligned.
|
|
17
|
+
*
|
|
18
|
+
* Throws on network/auth errors — caller decides how to handle.
|
|
19
|
+
*/
|
|
20
|
+
export async function executePull(options = {}) {
|
|
21
|
+
// Refresh the layout first: a project may contain directories this CLI build
|
|
22
|
+
// doesn't know about natively. Without this, those files would be written to
|
|
23
|
+
// the working dir but then ignored by the scanner (and dropped on next deploy).
|
|
24
|
+
await syncCapabilities();
|
|
25
|
+
const token = await resolveToken();
|
|
26
|
+
|
|
27
|
+
const spinner = createSpinner('Pulling files from the cloud...');
|
|
28
|
+
spinner.start();
|
|
29
|
+
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await getFiles(token);
|
|
33
|
+
spinner.stop();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
spinner.stop();
|
|
36
|
+
console.error(chalk.red('Error: ') + describeServerError(err));
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const canonical = response.canonical_modules || {};
|
|
41
|
+
const moduleNames = Object.keys(canonical);
|
|
42
|
+
const serverPaths = new Set(moduleNames.map(moduleToPath));
|
|
43
|
+
|
|
44
|
+
// Orphans: files previously synced from the server (present in the snapshot)
|
|
45
|
+
// that the server no longer has, and that still exist in the working dir.
|
|
46
|
+
// Without removing these, `pull` would leave the working dir out of sync with
|
|
47
|
+
// the server — the orphan shows up as a "new" file that the next deploy
|
|
48
|
+
// resurrects. New local files the user created (never synced,
|
|
49
|
+
// so not in the snapshot) are NOT in this set and are left untouched.
|
|
50
|
+
// Computed before replaceSnapshot(), which prunes the snapshot.
|
|
51
|
+
const orphans = listSnapshotPaths().filter(
|
|
52
|
+
(p) => !serverPaths.has(p) && fs.existsSync(p)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (!moduleNames.length && !orphans.length) {
|
|
56
|
+
info('No files in the cloud.');
|
|
57
|
+
replaceSnapshot(canonical);
|
|
58
|
+
if (response.revision != null) {
|
|
59
|
+
const s = loadState();
|
|
60
|
+
s.last_known_revision = response.revision;
|
|
61
|
+
saveState(s);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let pulled = 0;
|
|
67
|
+
|
|
68
|
+
for (const moduleName of moduleNames) {
|
|
69
|
+
const filePath = moduleToPath(moduleName);
|
|
70
|
+
const remoteContent = canonical[moduleName];
|
|
71
|
+
const dir = path.dirname(filePath);
|
|
72
|
+
|
|
73
|
+
if (dir !== '.' && !fs.existsSync(dir)) {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (fs.existsSync(filePath)) {
|
|
78
|
+
const localContent = fs.readFileSync(filePath, 'utf-8');
|
|
79
|
+
|
|
80
|
+
if (localContent === remoteContent) {
|
|
81
|
+
done(filePath, 'up to date');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!options.force) {
|
|
86
|
+
console.log(formatPatch(computeDiff(localContent, remoteContent, filePath)));
|
|
87
|
+
const overwrite = await confirm(` Overwrite ${filePath}?`);
|
|
88
|
+
if (!overwrite) {
|
|
89
|
+
skipped(filePath, 'skipped');
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fs.writeFileSync(filePath, remoteContent);
|
|
96
|
+
done(filePath, 'saved');
|
|
97
|
+
pulled++;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Remove orphans (modules deleted on the server). Confirmed per-file unless
|
|
101
|
+
// --force, mirroring the overwrite confirmation above.
|
|
102
|
+
let removed = 0;
|
|
103
|
+
for (const filePath of orphans) {
|
|
104
|
+
if (!options.force) {
|
|
105
|
+
console.log(' ' + chalk.dim('delete →') + ` ${filePath}` + chalk.dim(' (removed in the cloud)'));
|
|
106
|
+
const ok = await confirm(` Delete ${filePath}?`);
|
|
107
|
+
if (!ok) {
|
|
108
|
+
skipped(filePath, 'kept');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
fs.unlinkSync(filePath);
|
|
113
|
+
done(filePath, 'deleted');
|
|
114
|
+
removed++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
replaceSnapshot(canonical);
|
|
118
|
+
|
|
119
|
+
if (response.revision != null) {
|
|
120
|
+
const s = loadState();
|
|
121
|
+
s.last_known_revision = response.revision;
|
|
122
|
+
saveState(s);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const removedNote = removed ? `, deleted ${removed}` : '';
|
|
126
|
+
info(`\nDone. Pulled ${pulled} file${pulled !== 1 ? 's' : ''}${removedNote}.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatPatch(patch) {
|
|
130
|
+
if (!patch) return '';
|
|
131
|
+
return patch
|
|
132
|
+
.split('\n')
|
|
133
|
+
.map((line) => {
|
|
134
|
+
if (line.startsWith('+') && !line.startsWith('+++')) return chalk.green(line);
|
|
135
|
+
if (line.startsWith('-') && !line.startsWith('---')) return chalk.red(line);
|
|
136
|
+
if (line.startsWith('@@')) return chalk.cyan(line);
|
|
137
|
+
return line;
|
|
138
|
+
})
|
|
139
|
+
.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function registerPull(program) {
|
|
143
|
+
program
|
|
144
|
+
.command('pull')
|
|
145
|
+
.description('Download cloud state into working directory and local snapshot')
|
|
146
|
+
.option('--force', 'Overwrite local files without confirmation')
|
|
147
|
+
.action(async (options) => {
|
|
148
|
+
try {
|
|
149
|
+
await retryOnAuthError(() => executePull(options));
|
|
150
|
+
} catch {
|
|
151
|
+
process.exit(1);
|
|
152
|
+
} finally {
|
|
153
|
+
closePrompts();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|