diff --git a/lib/config.js b/lib/config.js index c5b69d8..8045b65 100644 --- a/lib/config.js +++ b/lib/config.js @@ -128,6 +128,15 @@ function loadConfig() { return config; } +function getConfigBaseDir(config) { + const configPath = config && (config.__configPath || config.configPath); + if (configPath) { + return path.dirname(configPath); + } + + return process.cwd(); +} + function resolveOutputPath(outputDir, filename) { if (!path.isAbsolute(outputDir)) { outputDir = path.resolve(process.cwd(), outputDir); @@ -203,6 +212,8 @@ function validateConfig(config) { // Ensure service account credential paths are unique when using serviceAccounts if (hasSvcAccounts) { + const baseDir = getConfigBaseDir(config); + for (const item of config.serviceAccounts) { if (typeof item === 'string') { if (!item.trim()) { @@ -231,7 +242,7 @@ function validateConfig(config) { if (typeof item === 'string') p = item.trim(); else if (item && typeof item === 'object') p = (item.path || item.file || item.credentialsPath || '').toString().trim(); if (!p) return ''; - const full = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); + const full = path.isAbsolute(p) ? p : path.resolve(baseDir, p); if (!fs.existsSync(full)) { throw new Error(`Service account file not found: ${p}`); } diff --git a/lib/onboarding.js b/lib/onboarding.js index 53b4fed..f7298f1 100644 --- a/lib/onboarding.js +++ b/lib/onboarding.js @@ -1,3 +1,5 @@ +const fs = require('fs'); +const path = require('path'); const readline = require('readline'); const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config'); const {getFirstServiceAccountAuthClient} = require('./auth'); @@ -78,6 +80,52 @@ async function askOptionalPositiveInteger(ask, question, defaultValue) { } } +function getServiceAccountIdentity(inputPath, baseDir) { + const value = (inputPath || '').trim(); + if (!value) { + return ''; + } + + const fullPath = path.isAbsolute(value) ? value : path.resolve(baseDir || process.cwd(), value); + if (!fs.existsSync(fullPath)) { + return value; + } + + const raw = fs.readFileSync(fullPath, 'utf8').trim(); + try { + const parsed = JSON.parse(raw); + const canonicalize = obj => { + if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); + if (Array.isArray(obj)) return '[' + obj.map(canonicalize).join(',') + ']'; + const keys = Object.keys(obj).sort(); + return '{' + keys.map(key => JSON.stringify(key) + ':' + canonicalize(obj[key])).join(',') + '}'; + }; + return canonicalize(parsed); + } catch (_) { + return raw; + } +} + +function getServiceAccountsFolderPath(configPath) { + return path.join(path.dirname(configPath), 'serviceAccounts'); +} + +function listServiceAccountFiles(folderPath) { + if (!fs.existsSync(folderPath)) { + return []; + } + + return fs.readdirSync(folderPath) + .map(fileName => path.join(folderPath, fileName)) + .filter(filePath => { + try { + return fs.statSync(filePath).isFile(); + } catch (_) { + return false; + } + }); +} + function extractSpreadsheetId(input) { const value = (input || '').trim(); if (!value) { @@ -164,11 +212,6 @@ async function runOnboarding() { console.log(''); } - const credentialCount = await askPositiveInteger( - ask, - `How many ${authMode === 'apiKeys' ? 'API keys' : 'service accounts'} do you want to add? ` - ); - const config = { apiKeys: [], serviceAccounts: [], @@ -182,26 +225,69 @@ async function runOnboarding() { }); if (authMode === 'apiKeys') { + const credentialCount = await askPositiveInteger( + ask, + 'How many API keys do you want to add? ' + ); + + const seenKeys = new Set(); + for (let index = 0; index < credentialCount; index += 1) { - const key = await askNonEmpty(ask, `Enter API key ${index + 1}: `); + let key; + while (true) { + key = await askNonEmpty(ask, `Enter API key ${index + 1}: `); + if (seenKeys.has(key)) { + console.log('That API key was already entered. Please enter a unique API key.'); + continue; + } + break; + } + const rateLimitPerMinute = await askOptionalPositiveInteger( ask, `Rate limit per minute for API key ${index + 1} (press Enter for 50): `, 50 ); + seenKeys.add(key); config.apiKeys.push({key, rateLimitPerMinute}); } } else { - for (let index = 0; index < credentialCount; index += 1) { - const pathValue = await askNonEmpty(ask, `Enter service account path ${index + 1}: `); + const serviceAccountsFolder = getServiceAccountsFolderPath(configPath); + if (!fs.existsSync(serviceAccountsFolder)) { + fs.mkdirSync(serviceAccountsFolder, {recursive: true}); + } + + console.log(`A serviceAccounts folder has been created at ${serviceAccountsFolder}.`); + console.log('Paste your service account files into that folder, then press Enter to continue.'); + await pauseForEnter(ask); + + const serviceAccountFiles = listServiceAccountFiles(serviceAccountsFolder); + console.log(`Found ${serviceAccountFiles.length} service account${serviceAccountFiles.length === 1 ? '' : 's'}.`); + + if (serviceAccountFiles.length === 0) { + throw new Error(`No service account files were found in ${serviceAccountsFolder}`); + } + + const seenServiceAccounts = new Set(); + + for (const filePath of serviceAccountFiles) { + const relativePath = path.relative(path.dirname(configPath), filePath).split(path.sep).join('/'); + const serviceAccountIdentity = getServiceAccountIdentity(relativePath, path.dirname(configPath)); + + if (seenServiceAccounts.has(serviceAccountIdentity)) { + console.log(`Skipping duplicate service account file: ${path.basename(filePath)}`); + continue; + } + + seenServiceAccounts.add(serviceAccountIdentity); const rateLimitPerMinute = await askOptionalPositiveInteger( ask, - `Rate limit per minute for service account ${index + 1} (press Enter for 50): `, + `Rate limit per minute for ${path.basename(filePath)} (press Enter for 50): `, 50 ); - config.serviceAccounts.push({path: pathValue, rateLimitPerMinute}); + config.serviceAccounts.push({path: relativePath, rateLimitPerMinute}); } } diff --git a/package.json b/package.json index 9fd54ab..2581fa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-sheets-rate-assistant", - "version": "2.1.5", + "version": "2.1.6", "description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.", "main": "index.js", "bin": "index.js",