4 Commits
6 changed files with 213 additions and 12 deletions
+98
View File
@@ -37,3 +37,101 @@ jobs:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: gSheets-Rate-Assistant ${{ github.ref_name }} name: gSheets-Rate-Assistant ${{ github.ref_name }}
files: dist/*.exe files: dist/*.exe
- name: Mirror release to GitHub
if: ${{ env.GITHUB_RELEASE_REPO != '' && env.GITHUB_TOKEN != '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_RELEASE_REPO: ${{ secrets.GITHUB_RELEASE_REPO }}
run: |
python - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
token = os.environ["GITHUB_TOKEN"]
repository = os.environ["GITHUB_RELEASE_REPO"]
tag = os.environ["BUILD_VERSION"]
release_name = f"gSheets-Rate-Assistant {tag}"
asset_path = next((os.path.join("dist", name) for name in os.listdir("dist") if name.endswith(".exe")), None)
if asset_path is None:
raise SystemExit("No Windows executable found in dist/")
def request_json(url, method="GET", payload=None, headers=None):
request_headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
if headers:
request_headers.update(headers)
data = None if payload is None else json.dumps(payload).encode("utf-8")
if data is not None:
request_headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
try:
with urllib.request.urlopen(request) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as error:
message = error.read().decode("utf-8", errors="replace")
print(message, file=sys.stderr)
raise
release_url = f"https://api.github.com/repos/{repository}/releases/tags/{urllib.parse.quote(tag)}"
release = None
try:
release = request_json(release_url)
except urllib.error.HTTPError as error:
if error.code != 404:
raise
payload = {
"tag_name": tag,
"name": release_name,
"draft": False,
"prerelease": False,
"generate_release_notes": False,
}
if release is None:
release = request_json(f"https://api.github.com/repos/{repository}/releases", method="POST", payload=payload)
else:
release = request_json(release["url"], method="PATCH", payload=payload)
asset_name = os.path.basename(asset_path)
for asset in release.get("assets", []):
if asset.get("name") == asset_name:
request_json(asset["url"], method="DELETE")
upload_url = release["upload_url"].split("{", 1)[0]
with open(asset_path, "rb") as asset_file:
asset_data = asset_file.read()
upload_request = urllib.request.Request(
f"{upload_url}?name={urllib.parse.quote(asset_name)}",
data=asset_data,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/octet-stream",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(upload_request) as response:
response.read()
except urllib.error.HTTPError as error:
message = error.read().decode("utf-8", errors="replace")
print(message, file=sys.stderr)
raise
PY
+1
View File
@@ -9,6 +9,7 @@ dist/
# Credentials and config (do NOT commit secrets) # Credentials and config (do NOT commit secrets)
credentials.json credentials.json
credentials-*.json credentials-*.json
serviceAccounts/
config.json config.json
config-*.json config-*.json
.env .env
+12 -1
View File
@@ -128,6 +128,15 @@ function loadConfig() {
return config; 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) { function resolveOutputPath(outputDir, filename) {
if (!path.isAbsolute(outputDir)) { if (!path.isAbsolute(outputDir)) {
outputDir = path.resolve(process.cwd(), outputDir); outputDir = path.resolve(process.cwd(), outputDir);
@@ -203,6 +212,8 @@ function validateConfig(config) {
// Ensure service account credential paths are unique when using serviceAccounts // Ensure service account credential paths are unique when using serviceAccounts
if (hasSvcAccounts) { if (hasSvcAccounts) {
const baseDir = getConfigBaseDir(config);
for (const item of config.serviceAccounts) { for (const item of config.serviceAccounts) {
if (typeof item === 'string') { if (typeof item === 'string') {
if (!item.trim()) { if (!item.trim()) {
@@ -231,7 +242,7 @@ function validateConfig(config) {
if (typeof item === 'string') p = item.trim(); if (typeof item === 'string') p = item.trim();
else if (item && typeof item === 'object') p = (item.path || item.file || item.credentialsPath || '').toString().trim(); else if (item && typeof item === 'object') p = (item.path || item.file || item.credentialsPath || '').toString().trim();
if (!p) return ''; 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)) { if (!fs.existsSync(full)) {
throw new Error(`Service account file not found: ${p}`); throw new Error(`Service account file not found: ${p}`);
} }
+100 -9
View File
@@ -1,3 +1,5 @@
const fs = require('fs');
const path = require('path');
const readline = require('readline'); const readline = require('readline');
const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config'); const {createStarterConfig, getDefaultConfigPath, writeConfigFile} = require('./config');
const {getFirstServiceAccountAuthClient} = require('./auth'); 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) { function extractSpreadsheetId(input) {
const value = (input || '').trim(); const value = (input || '').trim();
if (!value) { if (!value) {
@@ -159,10 +207,10 @@ async function runOnboarding() {
] ]
); );
const credentialCount = await askPositiveInteger( if (authMode === 'apiKeys') {
ask, console.log('Warning: when using API keys, the spreadsheet must be publicly accessible.');
`How many ${authMode === 'apiKeys' ? 'API keys' : 'service accounts'} do you want to add? ` console.log('');
); }
const config = { const config = {
apiKeys: [], apiKeys: [],
@@ -177,26 +225,69 @@ async function runOnboarding() {
}); });
if (authMode === 'apiKeys') { 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) { 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( const rateLimitPerMinute = await askOptionalPositiveInteger(
ask, ask,
`Rate limit per minute for API key ${index + 1} (press Enter for 50): `, `Rate limit per minute for API key ${index + 1} (press Enter for 50): `,
50 50
); );
seenKeys.add(key);
config.apiKeys.push({key, rateLimitPerMinute}); config.apiKeys.push({key, rateLimitPerMinute});
} }
} else { } else {
for (let index = 0; index < credentialCount; index += 1) { const serviceAccountsFolder = getServiceAccountsFolderPath(configPath);
const pathValue = await askNonEmpty(ask, `Enter service account path ${index + 1}: `); 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( const rateLimitPerMinute = await askOptionalPositiveInteger(
ask, 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 50
); );
config.serviceAccounts.push({path: pathValue, rateLimitPerMinute}); config.serviceAccounts.push({path: relativePath, rateLimitPerMinute});
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "google-sheets-rate-assistant", "name": "google-sheets-rate-assistant",
"version": "2.1.3", "version": "2.1.7",
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.", "description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
"main": "index.js", "main": "index.js",
"bin": "index.js", "bin": "index.js",
+1 -1
View File
@@ -71,7 +71,7 @@ To build a Windows executable from source, run:
npm run build:win npm run build:win
``` ```
That creates `dist/gSheets-Rate-Assistant-v2.0.0.exe` using the current version in the filename. For distribution, ship the exe alongside a `config.json` and any service account credential files, or set `GSA_CONFIG` to point at a different config file. That creates `dist/gSheets-Rate-Assistant-v2.1.7.exe` using the current version in the filename. For distribution, ship the exe alongside a `config.json` and any service account credential files, or set `GSA_CONFIG` to point at a different config file.
Gitea now builds only on version tags like `v1.1.1` through the workflow in `.gitea/workflows/build.yml`. Gitea now builds only on version tags like `v1.1.1` through the workflow in `.gitea/workflows/build.yml`.