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