From 27e01c8294ae68f5bc3e399c58147fa5f1c0dd6c Mon Sep 17 00:00:00 2001 From: msmhmorsi Date: Wed, 2 Sep 2026 12:21:58 +0800 Subject: [PATCH] feat: add project creation command --- CHANGELOG.md | 6 +++ README.md | 6 ++- SKILL.md | 8 +++ src/cli.ts | 37 +++++++++++++- src/client.ts | 56 +++++++++++++++++++++ src/index.ts | 3 ++ test/client.test.ts | 117 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 test/client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index febfbb4..7ee879a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added +- `olcli project create ` creates a blank or example Overleaf project from the command line +- `OverleafClient.createProject()` exposes project creation through the package's programmatic API + ## [0.9.1] - 2026-09-01 ### Fixed diff --git a/README.md b/README.md index 16c3896..7229162 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Work with Overleaf projects directly from your command line. Edit locally with y ## Features - 📋 **List** all your Overleaf projects +- ✨ **Create** blank or example projects - ⬇️ **Pull** project files to local directory for offline editing - ⬆️ **Push** local changes back to Overleaf - 🔄 **Sync** bidirectionally with smart conflict detection @@ -136,6 +137,7 @@ All commands auto-detect the project when run from a synced directory (contains | `olcli download [project]` | Download a single file | | `olcli delete [project]` | Delete a remote file or folder (alias: `rm`) | | `olcli rename [project]` | Rename a remote file or folder (alias: `mv`) | +| `olcli project create ` | Create a blank or example project (`--template blank\|example`) | | `olcli project rename [project]` | Rename the project itself (`--dry-run`) | | `olcli project rename-bulk` | Rename many projects by pattern (dry-run unless `--apply`) | | `olcli compile [project]` | Trigger PDF compilation | @@ -303,6 +305,7 @@ import { OverleafClient } from '@aloth/olcli'; const client = await OverleafClient.fromSessionCookie(cookie); +const created = await client.createProject('My Paper'); const projects = await client.listProjects(); const info = await client.getProjectInfo(projectId); const zipBuf = await client.downloadProject(projectId); @@ -319,7 +322,8 @@ const comments = await client.listComments(projectId, { status: 'open' }); import { OverleafClient, // Types - Project, ProjectInfo, FolderEntry, DocEntry, FileEntry, + Project, ProjectInfo, ProjectTemplate, CreateProjectOptions, CreatedProject, + FolderEntry, DocEntry, FileEntry, CommentMessage, ProjectComment, CommentContext, CommentStatus, ListCommentsOptions, AddCommentOptions, Credentials, SessionCookiePair, // Config utilities diff --git a/SKILL.md b/SKILL.md index 999533c..c8fe724 100644 --- a/SKILL.md +++ b/SKILL.md @@ -124,6 +124,13 @@ olcli pull "My Paper" cd My_Paper/ ``` +### Create a project + +```bash +olcli project create "My Paper" +olcli project create "Example Paper" --template example +``` + ### Edit and sync changes ```bash @@ -239,6 +246,7 @@ zip arxiv.zip *.tex main.bbl figures/*.pdf | `olcli logout` | Clear stored credentials | | `olcli check` | Show config paths and credential sources | | `olcli list` | List all projects | +| `olcli project create ` | Create a blank or example project | | `olcli info [project]` | Show project details | | `olcli pull [project] [dir]` | Download project files | | `olcli push [dir]` | Upload local changes | diff --git a/src/cli.ts b/src/cli.ts index 08f1c50..4940d47 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -822,7 +822,42 @@ program const projectCmd = program .command('project') - .description('Operate on projects themselves (rename, bulk rename)'); + .description('Operate on projects themselves (create, rename, bulk rename)'); + +projectCmd + .command('create ') + .description('Create a new project') + .option('-t, --template ', 'Project template: blank or example', 'blank') + .option('--json', 'Output as JSON') + .option('--cookie ', 'Session cookie override') + .action(async (name, options) => { + const template = options.template as string; + if (template !== 'blank' && template !== 'example') { + console.error(chalk.red(`Unsupported project template: ${template}`)); + console.error('Supported templates: blank, example'); + process.exit(1); + } + + const spinner = ora('Creating project...').start(); + try { + const client = await getClient(options.cookie); + const created = await client.createProject(name, { template }); + setLastProject(created.id); + + if (options.json) { + spinner.stop(); + console.log(JSON.stringify(created, null, 2)); + return; + } + + spinner.succeed(`Created project: ${created.name}`); + console.log(` ${chalk.cyan(created.id)}`); + console.log(` ${chalk.cyan(created.url)}`); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } + }); projectCmd .command('rename [project]') diff --git a/src/client.ts b/src/client.ts index c7c6659..9d1edec 100644 --- a/src/client.ts +++ b/src/client.ts @@ -37,6 +37,19 @@ export interface ProjectInfo { rootFolder: FolderEntry[]; } +export type ProjectTemplate = 'blank' | 'example'; + +export interface CreateProjectOptions { + template?: ProjectTemplate; +} + +export interface CreatedProject { + id: string; + name: string; + url: string; + ownerId?: string; +} + export interface FolderEntry { _id: string; name: string; @@ -634,6 +647,49 @@ export class OverleafClient { return projects.find(p => p.id === id); } + /** + * Create a blank or example project. + */ + async createProject(name: string, options: CreateProjectOptions = {}): Promise { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error('Project name must not be empty'); + } + + const template = options.template ?? 'blank'; + if (template !== 'blank' && template !== 'example') { + throw new Error(`Unsupported project template: ${template}`); + } + + const response = await this.httpRequest(`${this.baseUrl}/project/new`, { + method: 'POST', + headers: this.getHeaders(true), + body: JSON.stringify({ + projectName: trimmed, + ...(template === 'example' ? { template } : {}) + }), + expect: 'json' + }); + + if (!response.ok) { + throw new Error(`Failed to create project: ${response.status}`); + } + + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const projectId = response.body?.project_id; + if (typeof projectId !== 'string' || !projectId) { + throw new Error('Failed to create project: response did not include a project ID'); + } + + return { + id: projectId, + name: trimmed, + url: `${this.baseUrl}/project/${projectId}`, + ...(typeof response.body?.owner_ref === 'string' ? { ownerId: response.body.owner_ref } : {}) + }; + } + /** * Get detailed project info including file tree */ diff --git a/src/index.ts b/src/index.ts index 822ddae..cc4183b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,9 @@ export { // Interfaces type Project, type ProjectInfo, + type ProjectTemplate, + type CreateProjectOptions, + type CreatedProject, type FolderEntry, type DocEntry, type FileEntry, diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..09b4fdf --- /dev/null +++ b/test/client.test.ts @@ -0,0 +1,117 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { OverleafClient } from '../src/client.js'; + +interface CapturedRequest { + method?: string; + url?: string; + headers: IncomingMessage['headers']; + body: string; +} + +async function withServer( + response: { status?: number; body: unknown; headers?: Record }, + run: (baseUrl: string, getRequest: () => CapturedRequest | undefined) => Promise +): Promise { + let request: CapturedRequest | undefined; + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + request = { + method: req.method, + url: req.url, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8') + }; + res.writeHead(response.status ?? 200, { + 'Content-Type': 'application/json', + ...response.headers + }); + res.end(JSON.stringify(response.body)); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address === 'object'); + + try { + await run(`http://127.0.0.1:${address.port}`, () => request); + } finally { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } +} + +test('createProject creates a blank project and maps the response', async () => { + await withServer( + { + body: { project_id: '0123456789abcdef01234567', owner_ref: 'owner-id' }, + headers: { 'Set-Cookie': 'refreshed=session; Path=/' } + }, + async (baseUrl, getRequest) => { + const client = new OverleafClient({ + cookies: { session: 'secret' }, + csrf: 'csrf-token', + baseUrl + }); + + const created = await client.createProject(' My Paper '); + + assert.deepEqual(created, { + id: '0123456789abcdef01234567', + name: 'My Paper', + url: `${baseUrl}/project/0123456789abcdef01234567`, + ownerId: 'owner-id' + }); + assert.equal(client.getCookie('refreshed'), 'session'); + assert.deepEqual(JSON.parse(getRequest()?.body ?? ''), { projectName: 'My Paper' }); + assert.equal(getRequest()?.method, 'POST'); + assert.equal(getRequest()?.url, '/project/new'); + assert.equal(getRequest()?.headers['content-type'], 'application/json'); + assert.equal(getRequest()?.headers['x-csrf-token'], 'csrf-token'); + } + ); +}); + +test('createProject requests the example template', async () => { + await withServer( + { body: { project_id: '0123456789abcdef01234567' } }, + async (baseUrl, getRequest) => { + const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token', baseUrl }); + await client.createProject('Example Paper', { template: 'example' }); + assert.deepEqual(JSON.parse(getRequest()?.body ?? ''), { + projectName: 'Example Paper', + template: 'example' + }); + } + ); +}); + +test('createProject rejects invalid input before making a request', async () => { + const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token' }); + await assert.rejects(client.createProject(' '), /Project name must not be empty/); + await assert.rejects( + client.createProject('Paper', { template: 'unsupported' as 'blank' }), + /Unsupported project template: unsupported/ + ); +}); + +test('createProject reports HTTP errors and malformed success responses', async () => { + await withServer( + { status: 403, body: { message: 'forbidden' } }, + async baseUrl => { + const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token', baseUrl }); + await assert.rejects(client.createProject('Paper'), /Failed to create project: 403/); + } + ); + + await withServer( + { body: {} }, + async baseUrl => { + const client = new OverleafClient({ cookies: {}, csrf: 'csrf-token', baseUrl }); + await assert.rejects(client.createProject('Paper'), /response did not include a project ID/); + } + ); +});