From a55e313de44444d22ef928b1bb91fd96284ea367 Mon Sep 17 00:00:00 2001 From: YurenHao0426 Date: Sun, 13 Sep 2026 04:24:07 +0000 Subject: Add copy command to clone Overleaf projects server-side Adds `lattex-cli copy ` which uses Overleaf's POST /project/{id}/clone endpoint to create a server-side copy of a project. Includes API method, CLI wiring, help text, and tests. Co-Authored-By: Claude Opus 5 --- src/cli/main.ts | 35 +++++++++++++++++++++++++++++++++++ src/cli/overleafApi.ts | 11 +++++++++++ src/cli/test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+) (limited to 'src/cli') diff --git a/src/cli/main.ts b/src/cli/main.ts index 1fa9fce..52573fe 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -186,6 +186,37 @@ async function cmdProjects(flags: Record): Promise): Promise { + const sourceRef = positional[0] + const newName = positional[1] + if (!sourceRef || !newName) { + exitWith(EXIT_USAGE, 'Usage: lattex-cli copy ') + } + + const cookie = await resolveAuth(flags) + const api = new OverleafApi(cookie) + + // Resolve project ID from name if needed + let sourceId = sourceRef + if (!sourceRef.match(/^[0-9a-f]{24}$/)) { + const projects = await api.listProjects() + const match = projects.find(p => p.name === sourceRef) + if (!match) { + exitWith(EXIT_ERROR, `Project not found: ${sourceRef}`) + } + sourceId = match!.id + } + + err(`Copying project ${sourceId} as "${newName}"...`) + const newId = await api.copyProject(sourceId, newName) + + if (jsonMode) { + jsonOut({ ok: true, sourceId, newId, newName }) + } else { + out(`Copied → ${newId} "${newName}"`) + } +} + async function cmdClone(positional: string[], flags: Record): Promise { const projectRef = positional[0] const targetDir = positional[1] @@ -693,6 +724,7 @@ Commands: projects [--json] List Overleaf projects + copy Copy (clone) a project on Overleaf clone Download project to local directory pull Update local dir from Overleaf status Show local changes @@ -726,6 +758,9 @@ async function main(): Promise { case 'projects': await cmdProjects(parsed.flags) break + case 'copy': + await cmdCopy(parsed.positional, parsed.flags) + break case 'clone': await cmdClone(parsed.positional, parsed.flags) break diff --git a/src/cli/overleafApi.ts b/src/cli/overleafApi.ts index 6301638..94bdac2 100644 --- a/src/cli/overleafApi.ts +++ b/src/cli/overleafApi.ts @@ -348,6 +348,17 @@ export class OverleafApi { } } + /** Clone (copy) a project on Overleaf, returning the new project ID */ + async copyProject(sourceProjectId: string, newName: string): Promise { + const result = await this.requestWithCsrf('POST', `/project/${sourceProjectId}/clone`, { + projectName: newName + }) + if (!result.ok || !(result.data as any)?.project_id) { + throw new Error(`Copy project failed: HTTP ${result.status}`) + } + return (result.data as any).project_id + } + /** Flush project (ensure OT changes are saved to database) */ async flushProject(projectId: string): Promise { await this.requestWithCsrf('POST', `/project/${projectId}/flush`) diff --git a/src/cli/test.ts b/src/cli/test.ts index e76182e..f81a89b 100644 --- a/src/cli/test.ts +++ b/src/cli/test.ts @@ -1,5 +1,6 @@ // Tests for CLI arg parsing and diff logic import { parseArgs } from './args' +import { OverleafApi } from './overleafApi' import { walkRootFolder } from './fileTree' import { join } from 'path' import { mkdtemp, writeFile, mkdir, rm } from 'fs/promises' @@ -229,6 +230,28 @@ l.3 \\usepackage{nonexistent}` eq(entries4[0].line, 20, 'overfull line') } +// ── copy command arg parsing test ── + +function testCopyArgs(): void { + console.log('--- copy args ---') + + const r1 = parseArgs(['node', 'cli', 'copy', '6a965e135690947a3be20882', 'My New Project']) + eq(r1.command, 'copy', 'copy command') + eq(r1.positional, ['6a965e135690947a3be20882', 'My New Project'], 'copy positionals') + + const r2 = parseArgs(['node', 'cli', 'copy', 'My Source', 'My Copy', '--json']) + eq(r2.command, 'copy', 'copy command with json') + eq(r2.positional, ['My Source', 'My Copy'], 'copy positionals by name') + eq(r2.flags.json, true, 'json flag with copy') +} + +// ── OverleafApi.copyProject existence test ── + +function testCopyProjectMethod(): void { + console.log('--- copyProject method ---') + assert(typeof OverleafApi.prototype.copyProject === 'function', 'copyProject is a method on OverleafApi') +} + // ── Run all tests ── async function runTests(): Promise { @@ -237,6 +260,8 @@ async function runTests(): Promise { testIsTextFile() await testComputeDiff() testParseCompileLog() + testCopyArgs() + testCopyProjectMethod() console.log(`\n${passed} passed, ${failed} failed`) process.exit(failed > 0 ? 1 : 0) -- cgit v1.2.3