op-tasks-cli 1.0.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.
package/bin/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../dist/index.js');
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerInitCommand = registerInitCommand;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const fileSync_1 = require("../utils/fileSync");
9
+ function registerInitCommand(program) {
10
+ program
11
+ .command('init')
12
+ .description('Inicializar o diretório de tarefas (.tasksOP) no diretório atual')
13
+ .action(() => {
14
+ const created = (0, fileSync_1.initTasksDir)();
15
+ if (created) {
16
+ console.log(chalk_1.default.green('✔ Diretório .tasksOP criado com sucesso!'));
17
+ }
18
+ else {
19
+ console.log(chalk_1.default.yellow('⚠ Diretório .tasksOP já existe neste local.'));
20
+ }
21
+ });
22
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerLoginCommand = registerLoginCommand;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const config_1 = require("../utils/config");
9
+ function registerLoginCommand(program) {
10
+ program
11
+ .command('login')
12
+ .description('Configurar a URL e o Token do Open Project')
13
+ .argument('<url>', 'A URL base do seu servidor Open Project (ex: https://openproject.empresa.com)')
14
+ .argument('<token>', 'O token de API (apikey) gerado no Open Project')
15
+ .action((url, token) => {
16
+ (0, config_1.saveConfig)({ url, token });
17
+ console.log(chalk_1.default.green('✔ Configuração salva com sucesso!'));
18
+ console.log(chalk_1.default.gray(`URL: ${url}`));
19
+ console.log(chalk_1.default.gray('Token salvo de forma segura.'));
20
+ });
21
+ }
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerSyncCommand = registerSyncCommand;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const ora_1 = __importDefault(require("ora"));
9
+ const config_1 = require("../utils/config");
10
+ const fileSync_1 = require("../utils/fileSync");
11
+ const openProject_1 = require("../services/openProject");
12
+ function registerSyncCommand(program) {
13
+ program
14
+ .command('sync')
15
+ .description('Sincroniza tarefas do Open Project com a pasta local .tasksOP')
16
+ .action(async () => {
17
+ const config = (0, config_1.getConfig)();
18
+ if (!config) {
19
+ console.log(chalk_1.default.red('✖ Nenhuma configuração encontrada. Execute "op-tasks login <url> <token>" primeiro.'));
20
+ return;
21
+ }
22
+ if (!(0, fileSync_1.checkTasksDir)()) {
23
+ console.log(chalk_1.default.yellow('⚠ Diretório .tasksOP não encontrado. Execute "op-tasks init" primeiro.'));
24
+ return;
25
+ }
26
+ const spinner = (0, ora_1.default)('Iniciando sincronização...').start();
27
+ const opService = new openProject_1.OpenProjectService(config);
28
+ try {
29
+ // 1. Check local tasks for updates (e.g. status changes)
30
+ spinner.text = 'Verificando tarefas locais...';
31
+ const localTasks = (0, fileSync_1.getLocalTasks)();
32
+ const localTasksMap = new Map(localTasks.map(t => [t.id, t]));
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
+ // Sync logic:
38
+ // Se uma tarefa local tem um status diferente da remota, a local vence se estivermos atualizando o OP.
39
+ // O caso de uso especifica: "marcar como concluida as que foram dadas como concluidas no open project"
40
+ // Wait, "as que foram dadas como concluidas no open project" - if the user meant:
41
+ // "marcar como concluidas (no OP) as que foram dadas como concluidas (localmente)" - that's upload.
42
+ // "ou marcar como concluidas (localmente) as que foram dadas como concluidas no open project" - that's download.
43
+ // We'll update the Open Project status if the local status changed and the local updatedAt is newer?
44
+ // Actually, markdown doesn't track local updatedAt automatically unless we use fs.stat.
45
+ // 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.
46
+ // Let's update OP if local status is "Concluída", "Closed", "Fechada", "Done" and remote is not.
47
+ const completedStatuses = ['closed', 'concluída', 'concluida', 'fechada', 'done'];
48
+ let updatedRemoteCount = 0;
49
+ for (const localTask of localTasks) {
50
+ const remoteTask = remoteTasksMap.get(localTask.id);
51
+ if (remoteTask) {
52
+ const localIsCompleted = completedStatuses.includes(localTask.status.toLowerCase());
53
+ const remoteIsCompleted = completedStatuses.includes(remoteTask.status.toLowerCase());
54
+ if (localIsCompleted && !remoteIsCompleted) {
55
+ spinner.text = `Atualizando status da tarefa #${localTask.id} no Open Project...`;
56
+ await opService.updateWorkPackageStatus(localTask.id, remoteTask.lockVersion, localTask.status);
57
+ updatedRemoteCount++;
58
+ // After updating, update our local copy of remoteTask to have the new status so we don't overwrite it locally in the next step
59
+ remoteTask.status = localTask.status;
60
+ }
61
+ else if (localTask.status !== remoteTask.status) {
62
+ // If there's another difference, we can choose to push local status or pull remote status.
63
+ // 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.
64
+ spinner.text = `Atualizando status da tarefa #${localTask.id} no Open Project...`;
65
+ try {
66
+ await opService.updateWorkPackageStatus(localTask.id, remoteTask.lockVersion, localTask.status);
67
+ updatedRemoteCount++;
68
+ remoteTask.status = localTask.status;
69
+ }
70
+ catch (e) {
71
+ // Ignore if status is not valid
72
+ }
73
+ }
74
+ }
75
+ }
76
+ // 2. Download remote tasks to local (Creates new ones and updates existing ones)
77
+ spinner.text = 'Sincronizando tarefas para o diretório local...';
78
+ let updatedLocalCount = 0;
79
+ let createdLocalCount = 0;
80
+ for (const remoteTask of remoteTasks) {
81
+ const localTask = localTasksMap.get(remoteTask.id);
82
+ if (!localTask) {
83
+ (0, fileSync_1.saveTaskLocally)(remoteTask);
84
+ createdLocalCount++;
85
+ }
86
+ else {
87
+ // Se a tarefa remota for mais nova, ou se a gente acabou de atualizar o remoteTask, salvamos.
88
+ (0, fileSync_1.saveTaskLocally)(remoteTask);
89
+ updatedLocalCount++;
90
+ }
91
+ }
92
+ spinner.succeed(chalk_1.default.green('Sincronização concluída com sucesso!'));
93
+ console.log(chalk_1.default.gray(`Tarefas baixadas/atualizadas localmente: ${createdLocalCount + updatedLocalCount}`));
94
+ console.log(chalk_1.default.gray(`Tarefas atualizadas no Open Project: ${updatedRemoteCount}`));
95
+ }
96
+ catch (error) {
97
+ spinner.fail(chalk_1.default.red('Erro durante a sincronização.'));
98
+ console.error(chalk_1.default.red(error.message || error));
99
+ if (error.response) {
100
+ console.error(chalk_1.default.gray(`Status: ${error.response.status}`));
101
+ console.error(chalk_1.default.gray(JSON.stringify(error.response.data)));
102
+ }
103
+ }
104
+ });
105
+ }
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const commander_1 = require("commander");
4
+ const login_1 = require("./commands/login");
5
+ const init_1 = require("./commands/init");
6
+ const sync_1 = require("./commands/sync");
7
+ const program = new commander_1.Command();
8
+ program
9
+ .name('op-tasks')
10
+ .description('CLI para sincronizar tarefas do Open Project localmente')
11
+ .version('1.0.0');
12
+ (0, login_1.registerLoginCommand)(program);
13
+ (0, init_1.registerInitCommand)(program);
14
+ (0, sync_1.registerSyncCommand)(program);
15
+ program.parse();
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.OpenProjectService = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ class OpenProjectService {
9
+ constructor(config) {
10
+ this.client = axios_1.default.create({
11
+ baseURL: config.url.endsWith('/') ? config.url.slice(0, -1) : config.url,
12
+ headers: {
13
+ Authorization: `Basic ${Buffer.from(`apikey:${config.token}`).toString('base64')}`,
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ });
17
+ }
18
+ async getCurrentUserId() {
19
+ const response = await this.client.get('/api/v3/users/me');
20
+ return response.data.id;
21
+ }
22
+ async getMyWorkPackages(userId) {
23
+ const filters = encodeURIComponent(JSON.stringify([{ assignee: { operator: '=', values: [String(userId)] } }]));
24
+ // Fetch more per page if needed, default is usually 20, let's set pageSize=100
25
+ const response = await this.client.get(`/api/v3/work_packages?pageSize=100&filters=${filters}`);
26
+ return response.data._embedded.elements.map((item) => {
27
+ var _a;
28
+ return ({
29
+ id: item.id,
30
+ subject: item.subject,
31
+ description: ((_a = item.description) === null || _a === void 0 ? void 0 : _a.raw) || '',
32
+ status: item._links.status.title,
33
+ lockVersion: item.lockVersion,
34
+ updatedAt: item.updatedAt,
35
+ });
36
+ });
37
+ }
38
+ async updateWorkPackageStatus(id, lockVersion, statusName) {
39
+ // First, we need to find the status ID for the given status name
40
+ // A more robust way is to fetch all statuses and find the id
41
+ const statusesResponse = await this.client.get('/api/v3/statuses');
42
+ const statuses = statusesResponse.data._embedded.elements;
43
+ const targetStatus = statuses.find((s) => s.name.toLowerCase() === statusName.toLowerCase());
44
+ if (!targetStatus) {
45
+ throw new Error(`Status "${statusName}" not found in OpenProject.`);
46
+ }
47
+ await this.client.patch(`/api/v3/work_packages/${id}`, {
48
+ lockVersion,
49
+ _links: {
50
+ status: {
51
+ href: targetStatus._links.self.href,
52
+ },
53
+ },
54
+ });
55
+ }
56
+ }
57
+ exports.OpenProjectService = OpenProjectService;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getConfig = getConfig;
7
+ exports.saveConfig = saveConfig;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const configFilePath = path_1.default.join(os_1.default.homedir(), '.op-tasks-config.json');
12
+ function getConfig() {
13
+ if (!fs_1.default.existsSync(configFilePath)) {
14
+ return null;
15
+ }
16
+ try {
17
+ const data = fs_1.default.readFileSync(configFilePath, 'utf8');
18
+ return JSON.parse(data);
19
+ }
20
+ catch (error) {
21
+ return null;
22
+ }
23
+ }
24
+ function saveConfig(config) {
25
+ fs_1.default.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8');
26
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.initTasksDir = initTasksDir;
7
+ exports.checkTasksDir = checkTasksDir;
8
+ exports.saveTaskLocally = saveTaskLocally;
9
+ exports.getLocalTasks = getLocalTasks;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const gray_matter_1 = __importDefault(require("gray-matter"));
13
+ const TASKS_DIR = path_1.default.join(process.cwd(), '.tasksOP');
14
+ function initTasksDir() {
15
+ if (!fs_1.default.existsSync(TASKS_DIR)) {
16
+ fs_1.default.mkdirSync(TASKS_DIR, { recursive: true });
17
+ return true; // Created
18
+ }
19
+ return false; // Already exists
20
+ }
21
+ function checkTasksDir() {
22
+ return fs_1.default.existsSync(TASKS_DIR);
23
+ }
24
+ function sanitizeFileName(name) {
25
+ return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
26
+ }
27
+ function saveTaskLocally(task) {
28
+ initTasksDir();
29
+ const fileName = `${task.id}-${sanitizeFileName(task.subject)}.md`;
30
+ const filePath = path_1.default.join(TASKS_DIR, fileName);
31
+ const content = gray_matter_1.default.stringify(task.description || 'Nenhuma descrição fornecida.', {
32
+ id: task.id,
33
+ subject: task.subject,
34
+ status: task.status,
35
+ lockVersion: task.lockVersion,
36
+ updatedAt: task.updatedAt,
37
+ });
38
+ fs_1.default.writeFileSync(filePath, content, 'utf8');
39
+ }
40
+ function getLocalTasks() {
41
+ if (!fs_1.default.existsSync(TASKS_DIR)) {
42
+ return [];
43
+ }
44
+ const files = fs_1.default.readdirSync(TASKS_DIR).filter((file) => file.endsWith('.md'));
45
+ const tasks = [];
46
+ for (const file of files) {
47
+ const filePath = path_1.default.join(TASKS_DIR, file);
48
+ const content = fs_1.default.readFileSync(filePath, 'utf8');
49
+ const parsed = (0, gray_matter_1.default)(content);
50
+ if (parsed.data.id) {
51
+ tasks.push({
52
+ id: parsed.data.id,
53
+ subject: parsed.data.subject,
54
+ status: parsed.data.status,
55
+ lockVersion: parsed.data.lockVersion,
56
+ updatedAt: parsed.data.updatedAt,
57
+ description: parsed.content.trim(),
58
+ filePath,
59
+ });
60
+ }
61
+ }
62
+ return tasks;
63
+ }
@@ -0,0 +1,5 @@
1
+ 18c3852e3b2bd514a6de09f91598374f4a0bc53fb7dbba87549a8101e568405e
2
+ 23fc81570c930586fb139d4541548c5c843266429ab6ff1a72e891e50d9a8c1d
3
+ 751f6fef546c9638e840f3d2cb59f7a25ebd597d06ee4d832cf4e90e76080dea
4
+ b2c4b475f52800defe158a21c20a40b28a733ef407229f556f1e76149b2c4715
5
+ fe8a4329e0cf7ca2531de556381a3f64b873fa7653853c832a1c9a7ac4b3cd18
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "op-tasks-cli",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "op-tasks": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node ./bin/cli.js"
12
+ },
13
+ "keywords": [],
14
+ "author": "",
15
+ "license": "ISC",
16
+ "type": "commonjs",
17
+ "dependencies": {
18
+ "axios": "^1.16.1",
19
+ "chalk": "^4.1.2",
20
+ "commander": "^14.0.3",
21
+ "gray-matter": "^4.0.3",
22
+ "ora": "^5.4.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^25.8.0",
26
+ "rimraf": "^6.1.3",
27
+ "ts-node": "^10.9.2",
28
+ "typescript": "^6.0.3"
29
+ }
30
+ }
@@ -0,0 +1,17 @@
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
+ }
@@ -0,0 +1,17 @@
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
+ }
@@ -0,0 +1,109 @@
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 ADDED
@@ -0,0 +1,17 @@
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();
@@ -0,0 +1,68 @@
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
+ }
@@ -0,0 +1,26 @@
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
+ }
@@ -0,0 +1,71 @@
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 ADDED
@@ -0,0 +1,16 @@
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
+ }