@tmlmobilidade/export-data 20251229.1536.41 → 20251229.1636.59

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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { promptAccessKey } from './prompts/access-key.js';
2
3
  import { promptExportTypes } from './prompts/export-types.js';
3
4
  import { promptFilterByAgencyIds } from './prompts/filter-agency-ids.js';
4
5
  import { promptFilterByDates } from './prompts/filter-dates.js';
@@ -32,6 +33,9 @@ import { ASCII_CM_SHORT } from '@tmlmobilidade/consts';
32
33
  log.info(`O ID desta exportação é: ${context._id}`);
33
34
  log.info(`Todos os resultados serão guardados aqui: ${context.output}`);
34
35
  //
36
+ // Prompt for the access key
37
+ await promptAccessKey();
38
+ //
35
39
  // Request the export types and which filters to apply
36
40
  const exportTypes = await promptExportTypes();
37
41
  const filterTypes = await promptFilterTypes();
@@ -0,0 +1 @@
1
+ export declare function promptAccessKey(): Promise<void>;
@@ -0,0 +1,68 @@
1
+ /* * */
2
+ import { deleteCredential, getCredential, setCredential } from '../utils/credential-storage.js';
3
+ import { cancel, isCancel, note, spinner, text } from '@clack/prompts';
4
+ /* * */
5
+ const CREDENTIAL_KEY = 'access-key';
6
+ /* * */
7
+ export async function promptAccessKey() {
8
+ //
9
+ //
10
+ // Check if there is an access key already saved in the computer
11
+ const s = spinner();
12
+ s.start('A verificar credenciais guardadas...');
13
+ const existingKey = await getCredential(CREDENTIAL_KEY);
14
+ //
15
+ // If --delete-credentials flag was passed, delete existing credentials
16
+ if (process.argv.includes('--delete-credentials') && existingKey) {
17
+ s.stop('Flag --delete-credentials detectada.');
18
+ s.start('A remover credenciais guardadas...');
19
+ await deleteCredential(CREDENTIAL_KEY);
20
+ s.stop('Credenciais removidas. A começar de novo...');
21
+ }
22
+ else if (existingKey) {
23
+ s.stop('Chave de acesso encontrada no armazenamento seguro do sistema.');
24
+ process.env.DATABASE_URI = existingKey;
25
+ return;
26
+ }
27
+ else {
28
+ s.stop('Nenhuma chave de acesso encontrada.');
29
+ }
30
+ //
31
+ // If no key exists, ask the user to enter the access key
32
+ note('A chave de acesso será guardada de forma segura:\n'
33
+ + ' • macOS: Keychain\n'
34
+ + ' • Windows: Credential Manager\n'
35
+ + ' • Linux: Secret Service (ou ficheiro encriptado)');
36
+ const accessKey = await text({
37
+ message: 'Por favor introduz a chave de acesso:',
38
+ placeholder: 'Chave de acesso...',
39
+ validate: (value) => {
40
+ if (!value || value.trim().length === 0) {
41
+ return 'A chave de acesso não pode estar vazia.';
42
+ }
43
+ },
44
+ });
45
+ //
46
+ // Verify if the user cancelled the operation
47
+ if (isCancel(accessKey)) {
48
+ cancel('Operação cancelada pelo utilizador.');
49
+ process.exit(0);
50
+ }
51
+ if (!accessKey || typeof accessKey !== 'string') {
52
+ cancel('Chave de acesso inválida.');
53
+ process.exit(1);
54
+ }
55
+ //
56
+ // Save the access key to the computer's secure storage
57
+ s.start('A guardar chave de acesso...');
58
+ try {
59
+ await setCredential(CREDENTIAL_KEY, accessKey);
60
+ s.stop('Chave de acesso guardada com sucesso no armazenamento seguro do sistema.');
61
+ }
62
+ catch (error) {
63
+ s.stop('Erro ao guardar a chave de acesso.');
64
+ cancel(`Erro: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
65
+ process.exit(1);
66
+ }
67
+ process.env.DATABASE_URI = accessKey;
68
+ }
@@ -1,2 +1,2 @@
1
1
  import { type ExportType } from '../types.js';
2
- export declare function promptExportTypes(): Promise<(ExportType)[]>;
2
+ export declare function promptExportTypes(): Promise<ExportType[]>;
@@ -1,11 +1,11 @@
1
1
  /* * */
2
- import { log, text } from '@clack/prompts';
2
+ import { note, text } from '@clack/prompts';
3
3
  /* * */
4
4
  export async function promptFilterByAgencyIds() {
5
5
  //
6
- log.step('FILTRAR POR AGENCY ID:');
7
- log.message('- Introduz os Agency IDs separados por vírgulas. Exemplo: 41,42,etc...');
8
- log.message('- Se não introduzires nenhum Agency ID, este filtro não será aplicado.');
6
+ note('FILTRAR POR AGENCY ID:\n'
7
+ + ' Introduz os Agency IDs separados por vírgulas. Exemplo: 41,42,etc...\n'
8
+ + ' Se não introduzires nenhum Agency ID, este filtro não será aplicado.');
9
9
  const value = await text({
10
10
  message: 'Agency IDs:',
11
11
  placeholder: '41,42,etc...',
@@ -1,13 +1,13 @@
1
1
  /* * */
2
- import { cancel, isCancel, log, text } from '@clack/prompts';
2
+ import { cancel, isCancel, note, text } from '@clack/prompts';
3
3
  import { validateOperationalDate } from '@tmlmobilidade/types';
4
4
  /* * */
5
5
  export async function promptFilterByDates() {
6
6
  //
7
- log.step('FILTRAR POR DATAS:');
8
- log.message('- Introduz as datas operacionais no formato ano-mês-dia. Exemplo: 20250101 ou 2025-01-01');
9
- log.message('- Devido ao enorme volume de dados, filtrar por datas é obrigatório.');
10
- log.message('- A data de início não pode ser anterior a 1 Jan. 2024 (20240101).');
7
+ note('FILTRAR POR DATAS:\n'
8
+ + ' Introduz as datas operacionais no formato ano-mês-dia. Exemplo: 20250101 ou 2025-01-01\n'
9
+ + ' Devido ao enorme volume de dados, filtrar por datas é obrigatório.\n'
10
+ + ' A data de início não pode ser anterior a 1 Jan. 2024 (20240101).');
11
11
  const startDate = await text({
12
12
  initialValue: '20250101',
13
13
  message: 'Data de Início:',
@@ -1,11 +1,11 @@
1
1
  /* * */
2
- import { log, text } from '@clack/prompts';
2
+ import { note, text } from '@clack/prompts';
3
3
  /* * */
4
4
  export async function promptFilterByLineIds() {
5
5
  //
6
- log.step('FILTRAR POR LINE ID:');
7
- log.message('- Introduz os Line IDs separados por vírgulas. Exemplo: 1001,1002,etc...');
8
- log.message('- Se não introduzires nenhum Line ID, este filtro não será aplicado.');
6
+ note('FILTRAR POR LINE ID:\n'
7
+ + ' Introduz os Line IDs separados por vírgulas. Exemplo: 1001,1002,etc...\n'
8
+ + ' Se não introduzires nenhum Line ID, este filtro não será aplicado.');
9
9
  const value = await text({
10
10
  message: 'Line IDs:',
11
11
  placeholder: '1001,1002,etc...',
@@ -1,11 +1,11 @@
1
1
  /* * */
2
- import { log, text } from '@clack/prompts';
2
+ import { note, text } from '@clack/prompts';
3
3
  /* * */
4
4
  export async function promptFilterByPatternIds() {
5
5
  //
6
- log.step('FILTRAR POR PATTERN ID:');
7
- log.message('- Introduz os Pattern IDs separados por vírgulas. Exemplo: 1001_0_1,1001_0_2,etc...');
8
- log.message('- Se não introduzires nenhum Pattern ID, este filtro não será aplicado.');
6
+ note('FILTRAR POR PATTERN ID:\n'
7
+ + ' Introduz os Pattern IDs separados por vírgulas. Exemplo: 1001_0_1,1001_0_2,etc...\n'
8
+ + ' Se não introduzires nenhum Pattern ID, este filtro não será aplicado.');
9
9
  const value = await text({
10
10
  message: 'Pattern IDs:',
11
11
  placeholder: '1001_0_1,1001_0_2,etc...',
@@ -1,12 +1,12 @@
1
1
  /* * */
2
- import { log, text } from '@clack/prompts';
2
+ import { note, text } from '@clack/prompts';
3
3
  /* * */
4
4
  export async function promptFilterByStopIds() {
5
5
  //
6
- log.step('FILTRAR POR STOP ID:');
7
- log.message('- Introduz os Stop IDs separados por vírgulas. Exemplo: 010101,020202,etc...');
8
- log.message('- Não te esqueças do zero à esquerda.');
9
- log.message('- Se não introduzires nenhum Stop ID, este filtro não será aplicado.');
6
+ note('FILTRAR POR STOP ID:\n'
7
+ + ' Introduz os Stop IDs separados por vírgulas. Exemplo: 010101,020202,etc...\n'
8
+ + ' Não te esqueças do zero à esquerda.\n'
9
+ + ' Se não introduzires nenhum Stop ID, este filtro não será aplicado.');
10
10
  const value = await text({
11
11
  message: 'Stop IDs:',
12
12
  placeholder: '010101,020202,etc...',
@@ -1,11 +1,11 @@
1
1
  /* * */
2
- import { log, text } from '@clack/prompts';
2
+ import { note, text } from '@clack/prompts';
3
3
  /* * */
4
4
  export async function promptFilterByVehicleIds() {
5
5
  //
6
- log.step('FILTRAR POR VEHICLE ID:');
7
- log.message('- Introduz os Vehicle IDs separados por vírgulas. Exemplo: 1234,9876,etc...');
8
- log.message('- Se não introduzires nenhum Vehicle ID, este filtro não será aplicado.');
6
+ note('FILTRAR POR VEHICLE ID:\n'
7
+ + ' Introduz os Vehicle IDs separados por vírgulas. Exemplo: 1234,9876,etc...\n'
8
+ + ' Se não introduzires nenhum Vehicle ID, este filtro não será aplicado.');
9
9
  const value = await text({
10
10
  message: 'Vehicle IDs:',
11
11
  placeholder: '1234,9876,etc...',
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Get a credential from the OS-native secure storage
3
+ * - macOS: Uses Keychain via `security` command
4
+ * - Windows: Uses Credential Manager via PowerShell
5
+ * - Linux: Uses secret-tool or fallback to encrypted file
6
+ */
7
+ export declare function getCredential(key: string): Promise<null | string>;
8
+ /**
9
+ * Store a credential in the OS-native secure storage
10
+ * - macOS: Uses Keychain via `security` command
11
+ * - Windows: Uses Credential Manager via PowerShell
12
+ * - Linux: Uses secret-tool or fallback to encrypted file
13
+ */
14
+ export declare function setCredential(key: string, value: string): Promise<void>;
15
+ /**
16
+ * Delete a credential from the OS-native secure storage
17
+ */
18
+ export declare function deleteCredential(key: string): Promise<void>;
@@ -0,0 +1,293 @@
1
+ /* * */
2
+ import { exec } from 'node:child_process';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ /* * */
9
+ const execAsync = promisify(exec);
10
+ /* * */
11
+ const SERVICE_NAME = 'TMLMobilidadeGO';
12
+ const ACCOUNT_NAME = 'export-data-access-key';
13
+ /**
14
+ * Get a credential from the OS-native secure storage
15
+ * - macOS: Uses Keychain via `security` command
16
+ * - Windows: Uses Credential Manager via PowerShell
17
+ * - Linux: Uses secret-tool or fallback to encrypted file
18
+ */
19
+ export async function getCredential(key) {
20
+ const platform = os.platform();
21
+ try {
22
+ if (platform === 'darwin') {
23
+ // macOS Keychain
24
+ return await getMacOSCredential(key);
25
+ }
26
+ else if (platform === 'win32') {
27
+ // Windows Credential Manager
28
+ return await getWindowsCredential(key);
29
+ }
30
+ else {
31
+ // Linux (and other Unix-like systems)
32
+ return await getLinuxCredential(key);
33
+ }
34
+ }
35
+ catch {
36
+ // If credential doesn't exist or there's an error, return null
37
+ return null;
38
+ }
39
+ }
40
+ /**
41
+ * Store a credential in the OS-native secure storage
42
+ * - macOS: Uses Keychain via `security` command
43
+ * - Windows: Uses Credential Manager via PowerShell
44
+ * - Linux: Uses secret-tool or fallback to encrypted file
45
+ */
46
+ export async function setCredential(key, value) {
47
+ const platform = os.platform();
48
+ if (platform === 'darwin') {
49
+ // macOS Keychain
50
+ await setMacOSCredential(key, value);
51
+ }
52
+ else if (platform === 'win32') {
53
+ // Windows Credential Manager
54
+ await setWindowsCredential(key, value);
55
+ }
56
+ else {
57
+ // Linux (and other Unix-like systems)
58
+ await setLinuxCredential(key, value);
59
+ }
60
+ }
61
+ /**
62
+ * Delete a credential from the OS-native secure storage
63
+ */
64
+ export async function deleteCredential(key) {
65
+ try {
66
+ const platform = os.platform();
67
+ if (platform === 'darwin') {
68
+ await execAsync(`security delete-generic-password -s "${SERVICE_NAME}" -a "${key}"`);
69
+ }
70
+ else if (platform === 'win32') {
71
+ const targetName = `${SERVICE_NAME}:${key}`;
72
+ await execAsync(`powershell -Command "cmdkey /delete:'${targetName}'"`);
73
+ }
74
+ else {
75
+ await execAsync(`secret-tool clear service "${SERVICE_NAME}" account "${key}"`);
76
+ }
77
+ }
78
+ catch {
79
+ // Credential might not exist, which is fine
80
+ }
81
+ }
82
+ /**
83
+ * Get credential from macOS Keychain.
84
+ * @param key The credential key.
85
+ * @returns The credential value or null if not found.
86
+ */
87
+ async function getMacOSCredential(key) {
88
+ try {
89
+ const { stdout } = await execAsync(`security find-generic-password -s "${SERVICE_NAME}" -a "${key}" -w`);
90
+ return stdout.trim();
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Store credential in macOS Keychain.
98
+ * @param key The credential key.
99
+ * @param value The credential value.
100
+ */
101
+ async function setMacOSCredential(key, value) {
102
+ // First, try to delete existing credential to avoid duplicates
103
+ await deleteCredential(key);
104
+ // Add new credential
105
+ await execAsync(`security add-generic-password -s "${SERVICE_NAME}" -a "${key}" -w "${value}" -U`);
106
+ }
107
+ /**
108
+ * Get credential from Windows Credential Manager.
109
+ * @param key The credential key.
110
+ * @returns The credential value or null if not found.
111
+ */
112
+ async function getWindowsCredential(key) {
113
+ try {
114
+ const targetName = `${SERVICE_NAME}:${key}`;
115
+ const { stdout } = await execAsync(`powershell -Command "$cred = cmdkey /list:'${targetName}' 2>$null; if($cred) { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String((Get-StoredCredential -Target '${targetName}').Password)) }"`);
116
+ // If the above doesn't work, use a simpler approach with a custom PowerShell script
117
+ if (!stdout || stdout.trim() === '') {
118
+ // Fallback: Use Windows Data Protection API (DPAPI) with a file
119
+ return await getWindowsCredentialFallback(key);
120
+ }
121
+ return stdout.trim();
122
+ }
123
+ catch {
124
+ // Try fallback method
125
+ return await getWindowsCredentialFallback(key);
126
+ }
127
+ }
128
+ /**
129
+ * Store credential in Windows Credential Manager.
130
+ * @param key The credential key.
131
+ * @param value The credential value.
132
+ */
133
+ async function setWindowsCredential(key, value) {
134
+ try {
135
+ // Use PowerShell to store the credential
136
+ const targetName = `${SERVICE_NAME}:${key}`;
137
+ // Create a PowerShell script to store the credential
138
+ const script = `
139
+ $password = ConvertTo-SecureString "${value}" -AsPlainText -Force
140
+ $credential = New-Object System.Management.Automation.PSCredential("${ACCOUNT_NAME}", $password)
141
+ cmdkey /generic:"${targetName}" /user:"${ACCOUNT_NAME}" /pass:"${value}"
142
+ `;
143
+ // Execute the PowerShell script
144
+ await execAsync(`powershell -Command "${script.replace(/\n/g, ' ')}"`);
145
+ }
146
+ catch {
147
+ // Fallback to file-based storage with DPAPI
148
+ await setWindowsCredentialFallback(key, value);
149
+ }
150
+ }
151
+ /**
152
+ * Fallback method to get credential from encrypted file on Windows.
153
+ * @param key The credential key.
154
+ * @returns The credential value or null if not found.
155
+ */
156
+ async function getWindowsCredentialFallback(key) {
157
+ try {
158
+ const credPath = getCredentialFilePath(key);
159
+ const encrypted = await fs.readFile(credPath, 'utf8');
160
+ return decrypt(encrypted);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * Fallback method to set credential in encrypted file on Windows.
168
+ * @param key The credential key.
169
+ * @param value The credential value.
170
+ */
171
+ async function setWindowsCredentialFallback(key, value) {
172
+ const credPath = getCredentialFilePath(key);
173
+ const encrypted = encrypt(value);
174
+ // Ensure directory exists
175
+ await fs.mkdir(path.dirname(credPath), { recursive: true });
176
+ // Write with restricted permissions (owner only)
177
+ await fs.writeFile(credPath, encrypted, { mode: 0o600 });
178
+ }
179
+ /**
180
+ * Get credential from Linux secure storage (secret-tool) or fallback to encrypted file.
181
+ * @param key The credential key.
182
+ * @returns The credential value or null if not found.
183
+ */
184
+ async function getLinuxCredential(key) {
185
+ try {
186
+ const { stdout } = await execAsync(`secret-tool lookup service "${SERVICE_NAME}" account "${key}"`);
187
+ return stdout.trim();
188
+ }
189
+ catch {
190
+ // Fallback to encrypted file if secret-tool is not available
191
+ return await getLinuxCredentialFallback(key);
192
+ }
193
+ }
194
+ /**
195
+ * Store credential in Linux secure storage (secret-tool) or fallback to encrypted file.
196
+ * @param key The credential key.
197
+ * @param value The credential value.
198
+ */
199
+ async function setLinuxCredential(key, value) {
200
+ try {
201
+ // Try using secret-tool first
202
+ // Note: secret-tool reads password from stdin, so we use echo with pipe
203
+ await execAsync(`echo "${value}" | secret-tool store --label="${SERVICE_NAME} - ${key}" service "${SERVICE_NAME}" account "${key}"`);
204
+ }
205
+ catch {
206
+ // Fallback to encrypted file
207
+ await setLinuxCredentialFallback(key, value);
208
+ }
209
+ }
210
+ /**
211
+ * Fallback method to get credential from encrypted file on Linux.
212
+ * @param key The credential key.
213
+ * @returns The credential value or null if not found.
214
+ */
215
+ async function getLinuxCredentialFallback(key) {
216
+ try {
217
+ const credPath = getCredentialFilePath(key);
218
+ const encrypted = await fs.readFile(credPath, 'utf8');
219
+ return decrypt(encrypted);
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ /**
226
+ * Fallback method to set credential in encrypted file on Linux.
227
+ * @param key The credential key.
228
+ * @param value The credential value.
229
+ */
230
+ async function setLinuxCredentialFallback(key, value) {
231
+ const credPath = getCredentialFilePath(key);
232
+ const encrypted = encrypt(value);
233
+ // Ensure directory exists
234
+ await fs.mkdir(path.dirname(credPath), { recursive: true });
235
+ // Write with restricted permissions (owner only)
236
+ await fs.writeFile(credPath, encrypted, { mode: 0o600 });
237
+ }
238
+ /**
239
+ * Get the file path for storing encrypted credentials.
240
+ * @param key The credential key.
241
+ * @returns The file path.
242
+ */
243
+ function getCredentialFilePath(key) {
244
+ const homeDir = os.homedir();
245
+ const configDir = process.platform === 'win32'
246
+ ? path.join(homeDir, 'AppData', 'Local', 'TMLMobilidadeGO')
247
+ : path.join(homeDir, '.config', 'tmlmobilidade-go');
248
+ return path.join(configDir, `${key}.enc`);
249
+ }
250
+ /**
251
+ * Generate a machine-specific encryption key.
252
+ * @returns The encryption key as a Buffer.
253
+ */
254
+ function getEncryptionKey() {
255
+ // Use machine-specific identifier as part of the encryption key
256
+ // This provides basic obfuscation (not true security, but better than plaintext)
257
+ const machineId = os.hostname() + os.userInfo().username;
258
+ return crypto.createHash('sha256').update(machineId).digest();
259
+ }
260
+ /**
261
+ * Encrypt text using AES-256-GCM.
262
+ * @param text The plaintext to encrypt.
263
+ * @returns The encrypted text in hex format.
264
+ */
265
+ function encrypt(text) {
266
+ const algorithm = 'aes-256-gcm';
267
+ const key = getEncryptionKey();
268
+ const iv = crypto.randomBytes(16);
269
+ const cipher = crypto.createCipheriv(algorithm, key, iv);
270
+ let encrypted = cipher.update(text, 'utf8', 'hex');
271
+ encrypted += cipher.final('hex');
272
+ const authTag = cipher.getAuthTag();
273
+ // Return IV + authTag + encrypted data
274
+ return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
275
+ }
276
+ /**
277
+ * Decrypt text using AES-256-GCM.
278
+ * @param encryptedText The encrypted text in hex format.
279
+ * @returns The decrypted plaintext.
280
+ */
281
+ function decrypt(encryptedText) {
282
+ const algorithm = 'aes-256-gcm';
283
+ const key = getEncryptionKey();
284
+ const parts = encryptedText.split(':');
285
+ const iv = Buffer.from(parts[0], 'hex');
286
+ const authTag = Buffer.from(parts[1], 'hex');
287
+ const encrypted = parts[2];
288
+ const decipher = crypto.createDecipheriv(algorithm, key, iv);
289
+ decipher.setAuthTag(authTag);
290
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
291
+ decrypted += decipher.final('utf8');
292
+ return decrypted;
293
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tmlmobilidade/export-data",
3
3
  "description": "CLI tool to export data from GO.",
4
- "version": "20251229.1536.41",
4
+ "version": "20251229.1636.59",
5
5
  "author": {
6
6
  "email": "iso@tmlmobilidade.pt",
7
7
  "name": "TML-ISO"
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc && resolve-tspaths",
34
- "dev": "dotenv-run -f ./.env -- tsx src/index.ts",
34
+ "dev": "tsx src/index.ts",
35
35
  "lint": "eslint .",
36
36
  "lint:fix": "eslint . --fix"
37
37
  },