op-tasks-cli 1.0.0 → 1.0.2

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 ADDED
@@ -0,0 +1,60 @@
1
+ # Open Project Tasks CLI (op-tasks)
2
+
3
+ Uma interface de linha de comando (CLI) simples para baixar e sincronizar suas tarefas do [Open Project](https://www.openproject.org/) com arquivos Markdown (MD) locais.
4
+
5
+ ## Instalação e Execução
6
+
7
+ Você não precisa instalar o pacote de forma permanente, basta usar o `npx` para baixar e executar a versão mais recente em qualquer diretório:
8
+
9
+ ```bash
10
+ npx op-tasks-cli [comando]
11
+ ```
12
+
13
+ ## Como Usar
14
+
15
+ ### 1. Login
16
+ O primeiro passo é fazer o login para salvar sua URL do servidor e seu Token de API localmente na sua máquina. O Token é salvo de forma segura no seu diretório `home`.
17
+
18
+ Para gerar um Token, vá no seu Open Project > "Minha Conta" (My Account) > "Tokens de Acesso" (Access tokens) > API.
19
+
20
+ ```bash
21
+ npx op-tasks-cli login <sua-url-do-open-project> <seu-token-api>
22
+ ```
23
+ *Exemplo:* `npx op-tasks-cli login https://projetos.minhaempresa.com.br abc123def456`
24
+
25
+ ### 2. Inicializar um Diretório
26
+ Vá até a pasta do seu computador onde você deseja salvar e gerenciar suas tarefas e inicialize a estrutura:
27
+
28
+ ```bash
29
+ npx op-tasks-cli init
30
+ ```
31
+ Isso criará uma pasta oculta chamada `.tasksOP` no diretório atual. É aqui que todos os seus arquivos Markdown serão armazenados.
32
+
33
+ ### 3. Sincronizar (Download & Upload)
34
+ Para baixar novas tarefas ou enviar atualizações para o Open Project, use:
35
+
36
+ ```bash
37
+ npx op-tasks-cli sync
38
+ ```
39
+
40
+ **Como a sincronização funciona:**
41
+ - **Download:** Se houver novas tarefas atribuídas a você no Open Project, a CLI fará o download e criará os arquivos Markdown correspondentes na pasta `.tasksOP`.
42
+ - **Upload:** Se você alterar o status de uma tarefa no arquivo local para um status de conclusão (ex: `Closed`, `Concluída`, `Done`), a CLI avisará o Open Project e fechará a tarefa lá também!
43
+
44
+ ## Formato dos Arquivos
45
+
46
+ Cada tarefa é salva como um arquivo Markdown com metadados (_Frontmatter_) no topo. Exemplo:
47
+
48
+ ```yaml
49
+ ---
50
+ id: 1234
51
+ subject: Corrigir bug no formulário de contato
52
+ status: In progress
53
+ lockVersion: 3
54
+ updatedAt: 2026-05-17T02:00:00Z
55
+ ---
56
+ Descrição da tarefa que veio do Open Project.
57
+ Você pode fazer anotações aqui.
58
+ ```
59
+
60
+ Se quiser marcar a tarefa como concluída no sistema, basta alterar a linha `status:` para `Concluída` e rodar `npx op-tasks-cli sync`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "op-tasks-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,5 +0,0 @@
1
- 18c3852e3b2bd514a6de09f91598374f4a0bc53fb7dbba87549a8101e568405e
2
- 23fc81570c930586fb139d4541548c5c843266429ab6ff1a72e891e50d9a8c1d
3
- 751f6fef546c9638e840f3d2cb59f7a25ebd597d06ee4d832cf4e90e76080dea
4
- b2c4b475f52800defe158a21c20a40b28a733ef407229f556f1e76149b2c4715
5
- fe8a4329e0cf7ca2531de556381a3f64b873fa7653853c832a1c9a7ac4b3cd18
@@ -1,17 +0,0 @@
1
- import { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import { initTasksDir } from '../utils/fileSync';
4
-
5
- export function registerInitCommand(program: Command) {
6
- program
7
- .command('init')
8
- .description('Inicializar o diretório de tarefas (.tasksOP) no diretório atual')
9
- .action(() => {
10
- const created = initTasksDir();
11
- if (created) {
12
- console.log(chalk.green('✔ Diretório .tasksOP criado com sucesso!'));
13
- } else {
14
- console.log(chalk.yellow('⚠ Diretório .tasksOP já existe neste local.'));
15
- }
16
- });
17
- }
@@ -1,17 +0,0 @@
1
- import { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import { saveConfig } from '../utils/config';
4
-
5
- export function registerLoginCommand(program: Command) {
6
- program
7
- .command('login')
8
- .description('Configurar a URL e o Token do Open Project')
9
- .argument('<url>', 'A URL base do seu servidor Open Project (ex: https://openproject.empresa.com)')
10
- .argument('<token>', 'O token de API (apikey) gerado no Open Project')
11
- .action((url: string, token: string) => {
12
- saveConfig({ url, token });
13
- console.log(chalk.green('✔ Configuração salva com sucesso!'));
14
- console.log(chalk.gray(`URL: ${url}`));
15
- console.log(chalk.gray('Token salvo de forma segura.'));
16
- });
17
- }
@@ -1,109 +0,0 @@
1
- import { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { getConfig } from '../utils/config';
5
- import { checkTasksDir, getLocalTasks, saveTaskLocally } from '../utils/fileSync';
6
- import { OpenProjectService, WorkPackage } from '../services/openProject';
7
-
8
- export function registerSyncCommand(program: Command) {
9
- program
10
- .command('sync')
11
- .description('Sincroniza tarefas do Open Project com a pasta local .tasksOP')
12
- .action(async () => {
13
- const config = getConfig();
14
- if (!config) {
15
- console.log(chalk.red('✖ Nenhuma configuração encontrada. Execute "op-tasks login <url> <token>" primeiro.'));
16
- return;
17
- }
18
-
19
- if (!checkTasksDir()) {
20
- console.log(chalk.yellow('⚠ Diretório .tasksOP não encontrado. Execute "op-tasks init" primeiro.'));
21
- return;
22
- }
23
-
24
- const spinner = ora('Iniciando sincronização...').start();
25
- const opService = new OpenProjectService(config);
26
-
27
- try {
28
- // 1. Check local tasks for updates (e.g. status changes)
29
- spinner.text = 'Verificando tarefas locais...';
30
- const localTasks = getLocalTasks();
31
- const localTasksMap = new Map(localTasks.map(t => [t.id, t]));
32
-
33
- spinner.text = 'Buscando tarefas do Open Project...';
34
- const userId = await opService.getCurrentUserId();
35
- const remoteTasks = await opService.getMyWorkPackages(userId);
36
- const remoteTasksMap = new Map(remoteTasks.map(t => [t.id, t]));
37
-
38
- // Sync logic:
39
- // Se uma tarefa local tem um status diferente da remota, a local vence se estivermos atualizando o OP.
40
- // O caso de uso especifica: "marcar como concluida as que foram dadas como concluidas no open project"
41
- // Wait, "as que foram dadas como concluidas no open project" - if the user meant:
42
- // "marcar como concluidas (no OP) as que foram dadas como concluidas (localmente)" - that's upload.
43
- // "ou marcar como concluidas (localmente) as que foram dadas como concluidas no open project" - that's download.
44
- // We'll update the Open Project status if the local status changed and the local updatedAt is newer?
45
- // Actually, markdown doesn't track local updatedAt automatically unless we use fs.stat.
46
- // Let's do a simple approach: if local status != remote status, we update OP if we assume local changes have priority during sync, OR we just download remote changes.
47
- // Let's update OP if local status is "Concluída", "Closed", "Fechada", "Done" and remote is not.
48
-
49
- const completedStatuses = ['closed', 'concluída', 'concluida', 'fechada', 'done'];
50
-
51
- let updatedRemoteCount = 0;
52
- for (const localTask of localTasks) {
53
- const remoteTask = remoteTasksMap.get(localTask.id);
54
- if (remoteTask) {
55
- const localIsCompleted = completedStatuses.includes(localTask.status.toLowerCase());
56
- const remoteIsCompleted = completedStatuses.includes(remoteTask.status.toLowerCase());
57
-
58
- if (localIsCompleted && !remoteIsCompleted) {
59
- spinner.text = `Atualizando status da tarefa #${localTask.id} no Open Project...`;
60
- await opService.updateWorkPackageStatus(localTask.id, remoteTask.lockVersion, localTask.status);
61
- updatedRemoteCount++;
62
- // After updating, update our local copy of remoteTask to have the new status so we don't overwrite it locally in the next step
63
- remoteTask.status = localTask.status;
64
- } else if (localTask.status !== remoteTask.status) {
65
- // If there's another difference, we can choose to push local status or pull remote status.
66
- // To be safe, we'll push local status to OP if they differ and we want local to be the source of truth for status.
67
- spinner.text = `Atualizando status da tarefa #${localTask.id} no Open Project...`;
68
- try {
69
- await opService.updateWorkPackageStatus(localTask.id, remoteTask.lockVersion, localTask.status);
70
- updatedRemoteCount++;
71
- remoteTask.status = localTask.status;
72
- } catch (e: any) {
73
- // Ignore if status is not valid
74
- }
75
- }
76
- }
77
- }
78
-
79
- // 2. Download remote tasks to local (Creates new ones and updates existing ones)
80
- spinner.text = 'Sincronizando tarefas para o diretório local...';
81
- let updatedLocalCount = 0;
82
- let createdLocalCount = 0;
83
-
84
- for (const remoteTask of remoteTasks) {
85
- const localTask = localTasksMap.get(remoteTask.id);
86
- if (!localTask) {
87
- saveTaskLocally(remoteTask);
88
- createdLocalCount++;
89
- } else {
90
- // Se a tarefa remota for mais nova, ou se a gente acabou de atualizar o remoteTask, salvamos.
91
- saveTaskLocally(remoteTask);
92
- updatedLocalCount++;
93
- }
94
- }
95
-
96
- spinner.succeed(chalk.green('Sincronização concluída com sucesso!'));
97
- console.log(chalk.gray(`Tarefas baixadas/atualizadas localmente: ${createdLocalCount + updatedLocalCount}`));
98
- console.log(chalk.gray(`Tarefas atualizadas no Open Project: ${updatedRemoteCount}`));
99
-
100
- } catch (error: any) {
101
- spinner.fail(chalk.red('Erro durante a sincronização.'));
102
- console.error(chalk.red(error.message || error));
103
- if (error.response) {
104
- console.error(chalk.gray(`Status: ${error.response.status}`));
105
- console.error(chalk.gray(JSON.stringify(error.response.data)));
106
- }
107
- }
108
- });
109
- }
package/src/index.ts DELETED
@@ -1,17 +0,0 @@
1
- import { Command } from 'commander';
2
- import { registerLoginCommand } from './commands/login';
3
- import { registerInitCommand } from './commands/init';
4
- import { registerSyncCommand } from './commands/sync';
5
-
6
- const program = new Command();
7
-
8
- program
9
- .name('op-tasks')
10
- .description('CLI para sincronizar tarefas do Open Project localmente')
11
- .version('1.0.0');
12
-
13
- registerLoginCommand(program);
14
- registerInitCommand(program);
15
- registerSyncCommand(program);
16
-
17
- program.parse();
@@ -1,68 +0,0 @@
1
- import axios, { AxiosInstance } from 'axios';
2
- import { Config } from '../utils/config';
3
-
4
- export interface WorkPackage {
5
- id: number;
6
- subject: string;
7
- description: string;
8
- status: string;
9
- lockVersion: number;
10
- updatedAt: string;
11
- }
12
-
13
- export class OpenProjectService {
14
- private client: AxiosInstance;
15
-
16
- constructor(config: Config) {
17
- this.client = axios.create({
18
- baseURL: config.url.endsWith('/') ? config.url.slice(0, -1) : config.url,
19
- headers: {
20
- Authorization: `Basic ${Buffer.from(`apikey:${config.token}`).toString('base64')}`,
21
- 'Content-Type': 'application/json',
22
- },
23
- });
24
- }
25
-
26
- async getCurrentUserId(): Promise<number> {
27
- const response = await this.client.get('/api/v3/users/me');
28
- return response.data.id;
29
- }
30
-
31
- async getMyWorkPackages(userId: number): Promise<WorkPackage[]> {
32
- const filters = encodeURIComponent(
33
- JSON.stringify([{ assignee: { operator: '=', values: [String(userId)] } }])
34
- );
35
- // Fetch more per page if needed, default is usually 20, let's set pageSize=100
36
- const response = await this.client.get(`/api/v3/work_packages?pageSize=100&filters=${filters}`);
37
-
38
- return response.data._embedded.elements.map((item: any) => ({
39
- id: item.id,
40
- subject: item.subject,
41
- description: item.description?.raw || '',
42
- status: item._links.status.title,
43
- lockVersion: item.lockVersion,
44
- updatedAt: item.updatedAt,
45
- }));
46
- }
47
-
48
- async updateWorkPackageStatus(id: number, lockVersion: number, statusName: string): Promise<void> {
49
- // First, we need to find the status ID for the given status name
50
- // A more robust way is to fetch all statuses and find the id
51
- const statusesResponse = await this.client.get('/api/v3/statuses');
52
- const statuses = statusesResponse.data._embedded.elements;
53
- const targetStatus = statuses.find((s: any) => s.name.toLowerCase() === statusName.toLowerCase());
54
-
55
- if (!targetStatus) {
56
- throw new Error(`Status "${statusName}" not found in OpenProject.`);
57
- }
58
-
59
- await this.client.patch(`/api/v3/work_packages/${id}`, {
60
- lockVersion,
61
- _links: {
62
- status: {
63
- href: targetStatus._links.self.href,
64
- },
65
- },
66
- });
67
- }
68
- }
@@ -1,26 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
-
5
- const configFilePath = path.join(os.homedir(), '.op-tasks-config.json');
6
-
7
- export interface Config {
8
- url: string;
9
- token: string;
10
- }
11
-
12
- export function getConfig(): Config | null {
13
- if (!fs.existsSync(configFilePath)) {
14
- return null;
15
- }
16
- try {
17
- const data = fs.readFileSync(configFilePath, 'utf8');
18
- return JSON.parse(data) as Config;
19
- } catch (error) {
20
- return null;
21
- }
22
- }
23
-
24
- export function saveConfig(config: Config): void {
25
- fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8');
26
- }
@@ -1,71 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import matter from 'gray-matter';
4
- import { WorkPackage } from '../services/openProject';
5
-
6
- const TASKS_DIR = path.join(process.cwd(), '.tasksOP');
7
-
8
- export interface LocalTask extends WorkPackage {
9
- filePath: string;
10
- }
11
-
12
- export function initTasksDir(): boolean {
13
- if (!fs.existsSync(TASKS_DIR)) {
14
- fs.mkdirSync(TASKS_DIR, { recursive: true });
15
- return true; // Created
16
- }
17
- return false; // Already exists
18
- }
19
-
20
- export function checkTasksDir(): boolean {
21
- return fs.existsSync(TASKS_DIR);
22
- }
23
-
24
- function sanitizeFileName(name: string): string {
25
- return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
26
- }
27
-
28
- export function saveTaskLocally(task: WorkPackage): void {
29
- initTasksDir();
30
- const fileName = `${task.id}-${sanitizeFileName(task.subject)}.md`;
31
- const filePath = path.join(TASKS_DIR, fileName);
32
-
33
- const content = matter.stringify(task.description || 'Nenhuma descrição fornecida.', {
34
- id: task.id,
35
- subject: task.subject,
36
- status: task.status,
37
- lockVersion: task.lockVersion,
38
- updatedAt: task.updatedAt,
39
- });
40
-
41
- fs.writeFileSync(filePath, content, 'utf8');
42
- }
43
-
44
- export function getLocalTasks(): LocalTask[] {
45
- if (!fs.existsSync(TASKS_DIR)) {
46
- return [];
47
- }
48
-
49
- const files = fs.readdirSync(TASKS_DIR).filter((file: string) => file.endsWith('.md'));
50
- const tasks: LocalTask[] = [];
51
-
52
- for (const file of files) {
53
- const filePath = path.join(TASKS_DIR, file);
54
- const content = fs.readFileSync(filePath, 'utf8');
55
- const parsed = matter(content);
56
-
57
- if (parsed.data.id) {
58
- tasks.push({
59
- id: parsed.data.id,
60
- subject: parsed.data.subject,
61
- status: parsed.data.status,
62
- lockVersion: parsed.data.lockVersion,
63
- updatedAt: parsed.data.updatedAt,
64
- description: parsed.content.trim(),
65
- filePath,
66
- });
67
- }
68
- }
69
-
70
- return tasks;
71
- }
package/tsconfig.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "es2018",
4
- "module": "commonjs",
5
- "moduleResolution": "node",
6
- "rootDir": "./src",
7
- "outDir": "./dist",
8
- "esModuleInterop": true,
9
- "forceConsistentCasingInFileNames": true,
10
- "strict": true,
11
- "skipLibCheck": true,
12
- "types": ["node"],
13
- "ignoreDeprecations": "6.0"
14
- },
15
- "include": ["src/**/*"]
16
- }