@aloth/olcli 0.8.0 → 0.9.1
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/README.md +3 -1
- package/dist/cli.js +247 -10
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +23 -0
- package/dist/client.js.map +1 -1
- package/dist/mcp.js +49 -0
- package/dist/mcp.js.map +1 -1
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +21 -0
- package/dist/paths.js.map +1 -1
- package/dist/rename-plan.d.ts +46 -0
- package/dist/rename-plan.d.ts.map +1 -0
- package/dist/rename-plan.js +81 -0
- package/dist/rename-plan.js.map +1 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -130,12 +130,14 @@ All commands auto-detect the project when run from a synced directory (contains
|
|
|
130
130
|
| `olcli list` | List all projects |
|
|
131
131
|
| `olcli info [project]` | Show project details and file list |
|
|
132
132
|
| `olcli pull [project] [dir]` | Download project files to local directory |
|
|
133
|
-
| `olcli push [dir]` | Upload local changes to Overleaf |
|
|
133
|
+
| `olcli push [dir]` | Upload local changes to Overleaf (`--delete` also removes files deleted locally) |
|
|
134
134
|
| `olcli sync [dir]` | Bidirectional sync (pull + push) |
|
|
135
135
|
| `olcli upload <file> [project]` | Upload a single file (`--to <path>` sets the remote destination) |
|
|
136
136
|
| `olcli download <file> [project]` | Download a single file |
|
|
137
137
|
| `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
|
|
138
138
|
| `olcli rename <old> <new> [project]` | Rename a remote file or folder (alias: `mv`) |
|
|
139
|
+
| `olcli project rename <new> [project]` | Rename the project itself (`--dry-run`) |
|
|
140
|
+
| `olcli project rename-bulk` | Rename many projects by pattern (dry-run unless `--apply`) |
|
|
139
141
|
| `olcli compile [project]` | Trigger PDF compilation |
|
|
140
142
|
| `olcli pdf [project]` | Compile and download PDF |
|
|
141
143
|
| `olcli output [type]` | Download compile output files |
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,8 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
12
12
|
import { join, dirname, basename } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { OverleafClient } from './client.js';
|
|
15
|
-
import { resolveRemotePath } from './paths.js';
|
|
15
|
+
import { resolveRemotePath, resolveWithin } from './paths.js';
|
|
16
|
+
import { planProjectRenames } from './rename-plan.js';
|
|
16
17
|
import { loadIgnore, shouldIgnore, buildTexSiblingSet, } from './ignore.js';
|
|
17
18
|
// Read version from package.json
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -709,6 +710,140 @@ program
|
|
|
709
710
|
}
|
|
710
711
|
});
|
|
711
712
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
713
|
+
// PROJECT COMMANDS (rename the project itself, not files inside it)
|
|
714
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
715
|
+
const projectCmd = program
|
|
716
|
+
.command('project')
|
|
717
|
+
.description('Operate on projects themselves (rename, bulk rename)');
|
|
718
|
+
projectCmd
|
|
719
|
+
.command('rename <newname> [project]')
|
|
720
|
+
.description('Rename a project')
|
|
721
|
+
.option('--dry-run', 'Show what would change without applying')
|
|
722
|
+
.option('--cookie <session>', 'Session cookie override')
|
|
723
|
+
.action(async (newname, project, options) => {
|
|
724
|
+
const spinner = ora('Renaming project...').start();
|
|
725
|
+
try {
|
|
726
|
+
const client = await getClient(options.cookie);
|
|
727
|
+
const proj = await resolveProject(client, project);
|
|
728
|
+
if (proj.name === newname) {
|
|
729
|
+
spinner.info(`Project is already named "${newname}"`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (options.dryRun) {
|
|
733
|
+
spinner.stop();
|
|
734
|
+
console.log(chalk.bold('Would rename project:'));
|
|
735
|
+
console.log(` ${chalk.cyan(proj.name)} \u2192 ${chalk.cyan(newname)} ${chalk.dim(`(${proj.id})`)}`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
await client.renameProject(proj.id, newname);
|
|
739
|
+
spinner.succeed(`Renamed project: ${proj.name} \u2192 ${newname}`);
|
|
740
|
+
setLastProject(proj.id);
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
spinner.fail(`Failed: ${error.message}`);
|
|
744
|
+
process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
projectCmd
|
|
748
|
+
.command('rename-bulk')
|
|
749
|
+
.description('Rename many projects at once by pattern (dry-run unless --apply)')
|
|
750
|
+
.option('--match <regex>', 'Only consider projects whose name matches this regex')
|
|
751
|
+
.option('--search <text>', 'Literal substring to replace in the name')
|
|
752
|
+
.option('--replace <text>', 'Replacement for --search or --match (supports $1, $2 backrefs)')
|
|
753
|
+
.option('--prefix <text>', 'Prepend this to the name')
|
|
754
|
+
.option('--suffix <text>', 'Append this to the name')
|
|
755
|
+
.option('--apply', 'Actually rename. Without this flag nothing is changed.')
|
|
756
|
+
.option('--max <n>', 'Refuse to apply if more than n projects would change', parseInt)
|
|
757
|
+
.option('--cookie <session>', 'Session cookie override')
|
|
758
|
+
.action(async (options) => {
|
|
759
|
+
// Inverted default on purpose: for a single project a dry-run flag is a
|
|
760
|
+
// convenience, but a bulk rename that fires on a typo is unrecoverable
|
|
761
|
+
// (Overleaf keeps no project-name history). So doing nothing is the
|
|
762
|
+
// default and --apply is the deliberate act.
|
|
763
|
+
const spinner = ora('Fetching projects...').start();
|
|
764
|
+
try {
|
|
765
|
+
const client = await getClient(options.cookie);
|
|
766
|
+
const projects = await client.listProjects();
|
|
767
|
+
let plan;
|
|
768
|
+
try {
|
|
769
|
+
plan = planProjectRenames(projects, {
|
|
770
|
+
match: options.match,
|
|
771
|
+
search: options.search,
|
|
772
|
+
replace: options.replace,
|
|
773
|
+
prefix: options.prefix,
|
|
774
|
+
suffix: options.suffix,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
spinner.fail(err.message);
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
const { planned, skipped, collisions } = plan;
|
|
782
|
+
spinner.stop();
|
|
783
|
+
if (planned.length === 0) {
|
|
784
|
+
console.log(chalk.yellow('No project names would change.'));
|
|
785
|
+
for (const s of skipped) {
|
|
786
|
+
console.log(chalk.dim(` skipped ${s.name}: ${s.reason}`));
|
|
787
|
+
}
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
console.log(chalk.bold(`${planned.length} project(s) would be renamed:`));
|
|
791
|
+
for (const p of planned) {
|
|
792
|
+
console.log(` ${chalk.cyan(p.from)} ${chalk.dim('\u2192')} ${chalk.cyan(p.to)} ${chalk.dim(`(${p.id})`)}`);
|
|
793
|
+
}
|
|
794
|
+
for (const s of skipped) {
|
|
795
|
+
console.log(chalk.dim(` skipped ${s.name}: ${s.reason}`));
|
|
796
|
+
}
|
|
797
|
+
if (collisions.length > 0) {
|
|
798
|
+
console.log();
|
|
799
|
+
console.log(chalk.red(`Name collisions (${collisions.length}):`));
|
|
800
|
+
for (const c of collisions)
|
|
801
|
+
console.log(chalk.red(` ${c}`));
|
|
802
|
+
console.log(chalk.red('Refusing to apply. Overleaf allows duplicate names, so this would'));
|
|
803
|
+
console.log(chalk.red('succeed silently and leave projects you cannot tell apart.'));
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
if (options.max !== undefined && planned.length > options.max) {
|
|
807
|
+
console.log();
|
|
808
|
+
console.log(chalk.red(`Refusing to apply: ${planned.length} changes exceed --max ${options.max}.`));
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
if (!options.apply) {
|
|
812
|
+
console.log();
|
|
813
|
+
console.log(chalk.yellow('Dry run. Nothing was changed. Re-run with --apply to rename.'));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const applySpinner = ora(`Renaming ${planned.length} project(s)...`).start();
|
|
817
|
+
let renamed = 0;
|
|
818
|
+
const failures = [];
|
|
819
|
+
for (const p of planned) {
|
|
820
|
+
try {
|
|
821
|
+
await client.renameProject(p.id, p.to);
|
|
822
|
+
renamed++;
|
|
823
|
+
applySpinner.text = `Renaming... (${renamed}/${planned.length})`;
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
// Keep going: a partial rename is recoverable by re-running, while
|
|
827
|
+
// aborting midway leaves the same partial state plus no report.
|
|
828
|
+
failures.push({ from: p.from, reason: error.message || String(error) });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (failures.length > 0) {
|
|
832
|
+
applySpinner.warn(`Renamed ${renamed} project(s), ${failures.length} failed`);
|
|
833
|
+
for (const f of failures) {
|
|
834
|
+
console.log(chalk.yellow(` ${f.from}: ${f.reason}`));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
applySpinner.succeed(`Renamed ${renamed} project(s)`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
spinner.fail(`Failed: ${error.message}`);
|
|
843
|
+
process.exit(1);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
712
847
|
// COMPILE COMMAND
|
|
713
848
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
714
849
|
program
|
|
@@ -799,9 +934,15 @@ program
|
|
|
799
934
|
let fileCount = 0;
|
|
800
935
|
let skippedCount = 0;
|
|
801
936
|
const skippedFiles = [];
|
|
937
|
+
const unsafeEntries = [];
|
|
802
938
|
for (const entry of entries) {
|
|
803
939
|
if (!entry.isDirectory) {
|
|
804
|
-
const filePath =
|
|
940
|
+
const filePath = resolveWithin(targetDir, entry.entryName);
|
|
941
|
+
if (!filePath) {
|
|
942
|
+
// Entry would escape the target directory (zip-slip) - never extract
|
|
943
|
+
unsafeEntries.push(entry.entryName);
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
805
946
|
const fileDir = dirname(filePath);
|
|
806
947
|
// Check if local file exists and is newer than last pull
|
|
807
948
|
if (!options.force && existsSync(filePath) && lastPull) {
|
|
@@ -825,11 +966,18 @@ program
|
|
|
825
966
|
fileCount++;
|
|
826
967
|
}
|
|
827
968
|
}
|
|
969
|
+
if (unsafeEntries.length > 0) {
|
|
970
|
+
console.log(chalk.yellow(` Skipped ${unsafeEntries.length} unsafe archive entr${unsafeEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory):`));
|
|
971
|
+
for (const name of unsafeEntries.slice(0, 5)) {
|
|
972
|
+
console.log(chalk.dim(` ${name}`));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
828
975
|
// Save project metadata (with manifest of remote files for sync deletion tracking)
|
|
829
976
|
const remoteManifest = [];
|
|
830
977
|
for (const e of entries) {
|
|
831
|
-
if (!e.isDirectory)
|
|
978
|
+
if (!e.isDirectory && resolveWithin(targetDir, e.entryName)) {
|
|
832
979
|
remoteManifest.push(e.entryName);
|
|
980
|
+
}
|
|
833
981
|
}
|
|
834
982
|
writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({
|
|
835
983
|
projectId,
|
|
@@ -863,6 +1011,7 @@ program
|
|
|
863
1011
|
.description('Upload local changes to Overleaf project')
|
|
864
1012
|
.option('--project <name>', 'Project name or ID (overrides .olcli.json)')
|
|
865
1013
|
.option('--all', 'Upload all files (not just changed)')
|
|
1014
|
+
.option('--delete', 'Propagate local deletions to the remote (opt-in; see docs)')
|
|
866
1015
|
.option('--dry-run', 'Show what would be uploaded without uploading')
|
|
867
1016
|
.option('--probe-folder', 'Probe for correct folder ID (use if uploads fail with folder_not_found)')
|
|
868
1017
|
.option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
|
|
@@ -877,12 +1026,21 @@ program
|
|
|
877
1026
|
let projectName;
|
|
878
1027
|
let lastPull;
|
|
879
1028
|
let rootFolderId;
|
|
1029
|
+
let previousPushManifest = [];
|
|
880
1030
|
if (existsSync(metaPath)) {
|
|
881
1031
|
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
882
1032
|
projectId = meta.projectId;
|
|
883
1033
|
projectName = meta.projectName;
|
|
884
1034
|
lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined;
|
|
885
1035
|
rootFolderId = meta.rootFolderId;
|
|
1036
|
+
// Written by a previous push (pushManifest) or pull (remoteManifest).
|
|
1037
|
+
// Either is a valid baseline for "what did this directory last put there".
|
|
1038
|
+
if (Array.isArray(meta.pushManifest)) {
|
|
1039
|
+
previousPushManifest = meta.pushManifest;
|
|
1040
|
+
}
|
|
1041
|
+
else if (Array.isArray(meta.remoteManifest)) {
|
|
1042
|
+
previousPushManifest = meta.remoteManifest;
|
|
1043
|
+
}
|
|
886
1044
|
}
|
|
887
1045
|
if (options.project) {
|
|
888
1046
|
// Override with command line option
|
|
@@ -920,6 +1078,10 @@ program
|
|
|
920
1078
|
const { readdirSync, statSync } = await import('node:fs');
|
|
921
1079
|
const filesToUpload = [];
|
|
922
1080
|
const filesIgnored = [];
|
|
1081
|
+
// Every local file that survives ignore filtering, regardless of mtime.
|
|
1082
|
+
// filesToUpload is mtime-filtered and therefore useless as a deletion
|
|
1083
|
+
// baseline: an unchanged file would look "absent" and get deleted.
|
|
1084
|
+
const allLocalPaths = new Set();
|
|
923
1085
|
function scanDir(currentDir, relativeBase = '') {
|
|
924
1086
|
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
925
1087
|
// Pre-compute sibling .tex set for the PDF special rule.
|
|
@@ -943,6 +1105,7 @@ program
|
|
|
943
1105
|
filesIgnored.push(relativePath);
|
|
944
1106
|
continue;
|
|
945
1107
|
}
|
|
1108
|
+
allLocalPaths.add(relativePath);
|
|
946
1109
|
// Check if file is newer than last pull (unless --all)
|
|
947
1110
|
if (options.all || !lastPull) {
|
|
948
1111
|
filesToUpload.push({ path: fullPath, relativePath });
|
|
@@ -965,15 +1128,46 @@ program
|
|
|
965
1128
|
}
|
|
966
1129
|
spinner.start('Scanning files...');
|
|
967
1130
|
}
|
|
968
|
-
|
|
1131
|
+
// Deletion candidates: tracked by a previous push/pull from THIS directory,
|
|
1132
|
+
// gone locally now. Never derived from the remote listing - that would also
|
|
1133
|
+
// sweep away files someone else uploaded through the Overleaf editor.
|
|
1134
|
+
//
|
|
1135
|
+
// Skipped entirely without a baseline manifest: on a first push we cannot
|
|
1136
|
+
// tell "deleted locally" from "never existed here", and guessing wrong
|
|
1137
|
+
// destroys remote work.
|
|
1138
|
+
const filesToDelete = [];
|
|
1139
|
+
if (options.delete && previousPushManifest.length > 0) {
|
|
1140
|
+
for (const path of previousPushManifest) {
|
|
1141
|
+
if (path === 'output.pdf' || path.endsWith('/output.pdf'))
|
|
1142
|
+
continue;
|
|
1143
|
+
if (!allLocalPaths.has(path))
|
|
1144
|
+
filesToDelete.push(path);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const noBaseline = options.delete === true && previousPushManifest.length === 0;
|
|
1148
|
+
if (filesToUpload.length === 0 && filesToDelete.length === 0) {
|
|
969
1149
|
spinner.info('No files to upload');
|
|
1150
|
+
if (noBaseline) {
|
|
1151
|
+
console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
|
|
1152
|
+
}
|
|
970
1153
|
return;
|
|
971
1154
|
}
|
|
972
1155
|
if (options.dryRun) {
|
|
973
1156
|
spinner.stop();
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1157
|
+
if (filesToUpload.length > 0) {
|
|
1158
|
+
console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`));
|
|
1159
|
+
for (const f of filesToUpload) {
|
|
1160
|
+
console.log(` ${chalk.cyan(f.relativePath)}`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (filesToDelete.length > 0) {
|
|
1164
|
+
console.log(chalk.bold(`Would delete ${filesToDelete.length} file(s) on "${projectName}":`));
|
|
1165
|
+
for (const p of filesToDelete) {
|
|
1166
|
+
console.log(` ${chalk.red(p)}`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (noBaseline) {
|
|
1170
|
+
console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
|
|
977
1171
|
}
|
|
978
1172
|
return;
|
|
979
1173
|
}
|
|
@@ -1024,10 +1218,29 @@ program
|
|
|
1024
1218
|
}
|
|
1025
1219
|
}
|
|
1026
1220
|
}
|
|
1027
|
-
//
|
|
1221
|
+
// Deletions run after uploads: a rename arrives as add+remove, and doing
|
|
1222
|
+
// it in this order never leaves the remote without the file.
|
|
1223
|
+
let deleted = 0;
|
|
1224
|
+
const deleteSkipped = [];
|
|
1225
|
+
if (filesToDelete.length > 0) {
|
|
1226
|
+
spinner.text = `Deleting ${filesToDelete.length} remote file(s)...`;
|
|
1227
|
+
for (const path of filesToDelete) {
|
|
1228
|
+
try {
|
|
1229
|
+
await client.deleteByPath(projectId, path);
|
|
1230
|
+
deleted++;
|
|
1231
|
+
}
|
|
1232
|
+
catch (error) {
|
|
1233
|
+
// Already gone remotely is the common case and not an error worth
|
|
1234
|
+
// failing the push over.
|
|
1235
|
+
deleteSkipped.push({ path, reason: error.message || String(error) });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
// Update last push time and the manifest that the next --delete reads.
|
|
1028
1240
|
if (existsSync(metaPath)) {
|
|
1029
1241
|
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
1030
1242
|
meta.lastPush = new Date().toISOString();
|
|
1243
|
+
meta.pushManifest = Array.from(allLocalPaths).sort();
|
|
1031
1244
|
writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
1032
1245
|
}
|
|
1033
1246
|
if (failed > 0) {
|
|
@@ -1039,6 +1252,18 @@ program
|
|
|
1039
1252
|
else {
|
|
1040
1253
|
spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`);
|
|
1041
1254
|
}
|
|
1255
|
+
if (deleted > 0) {
|
|
1256
|
+
console.log(chalk.dim(` ✖ ${deleted} deleted on remote`));
|
|
1257
|
+
}
|
|
1258
|
+
if (deleteSkipped.length > 0) {
|
|
1259
|
+
console.log(chalk.yellow(` ${deleteSkipped.length} deletion(s) skipped:`));
|
|
1260
|
+
for (const s of deleteSkipped) {
|
|
1261
|
+
console.log(chalk.dim(` ${s.path}: ${s.reason}`));
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (noBaseline) {
|
|
1265
|
+
console.log(chalk.dim(' --delete skipped: no manifest yet; next push has a baseline'));
|
|
1266
|
+
}
|
|
1042
1267
|
setLastProject(projectId);
|
|
1043
1268
|
}
|
|
1044
1269
|
catch (error) {
|
|
@@ -1153,13 +1378,23 @@ program
|
|
|
1153
1378
|
}
|
|
1154
1379
|
spinner.start();
|
|
1155
1380
|
}
|
|
1156
|
-
// Extract remote files
|
|
1381
|
+
// Extract remote files (skipping entries that would escape the target
|
|
1382
|
+
// directory - zip-slip protection)
|
|
1157
1383
|
const remoteFiles = new Map();
|
|
1384
|
+
const unsafeRemoteEntries = [];
|
|
1158
1385
|
for (const entry of zip.getEntries()) {
|
|
1159
1386
|
if (!entry.isDirectory) {
|
|
1387
|
+
if (!resolveWithin(targetDir, entry.entryName)) {
|
|
1388
|
+
unsafeRemoteEntries.push(entry.entryName);
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1160
1391
|
remoteFiles.set(entry.entryName, entry.getData());
|
|
1161
1392
|
}
|
|
1162
1393
|
}
|
|
1394
|
+
if (unsafeRemoteEntries.length > 0) {
|
|
1395
|
+
spinner.warn(`Skipped ${unsafeRemoteEntries.length} unsafe archive entr${unsafeRemoteEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory)`);
|
|
1396
|
+
spinner.start();
|
|
1397
|
+
}
|
|
1163
1398
|
// Merge: local changes take precedence for files modified after last pull
|
|
1164
1399
|
let lastPull;
|
|
1165
1400
|
let previousManifest = [];
|
|
@@ -1215,7 +1450,9 @@ program
|
|
|
1215
1450
|
spinner.text = 'Comparing files...';
|
|
1216
1451
|
// Write remote files, but preserve local modifications
|
|
1217
1452
|
for (const [path, remoteContent] of remoteFiles) {
|
|
1218
|
-
const filePath =
|
|
1453
|
+
const filePath = resolveWithin(targetDir, path);
|
|
1454
|
+
if (!filePath)
|
|
1455
|
+
continue; // already filtered above; defense in depth
|
|
1219
1456
|
const fileDir = dirname(filePath);
|
|
1220
1457
|
if (!existsSync(fileDir)) {
|
|
1221
1458
|
mkdirSync(fileDir, { recursive: true });
|