10 Commits
Author SHA1 Message Date
lzstealth 0d67a4eb25 Update readme.md
build-windows-exe / build (push) Successful in 45s
2026-07-22 22:03:05 +01:00
lzstealth f93065a377 Update readme.md 2026-07-22 22:02:08 +01:00
lzstealth 91f6cd1aa2 Update readme.md 2026-07-22 21:58:19 +01:00
lzstealth e3f585222a Update readme.md 2026-07-22 21:49:24 +01:00
lzstealth e8e771aaf2 Update readme.md 2026-07-22 21:48:08 +01:00
lzstealth 1a89020243 Update readme.md 2026-07-22 21:47:32 +01:00
lzstealth e92a3d364f Fix README releases link 2026-07-22 21:42:14 +01:00
lzstealth 1a714f6deb ci: add release notes to gitea build 2026-07-22 17:40:33 +01:00
lzstealth c4c470c073 fix: improve auth and request handling 2026-07-22 17:34:13 +01:00
lzstealth 694cc55eaa chore: bump version to 2.1.10 2026-07-22 17:33:33 +01:00
6 changed files with 215 additions and 60 deletions
+48
View File
@@ -15,6 +15,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -28,6 +30,48 @@ jobs:
- name: Clean dist - name: Clean dist
run: rm -f dist/*.exe run: rm -f dist/*.exe
- name: Generate release notes
run: |
python - <<'PY'
import os
import pathlib
import subprocess
tag = os.environ["BUILD_VERSION"]
try:
previous_tag = subprocess.check_output(
["git", "describe", "--tags", "--abbrev=0", f"{tag}^"],
text=True,
).strip()
except subprocess.CalledProcessError:
previous_tag = ""
log_range = f"{previous_tag}..{tag}" if previous_tag else tag
subjects = subprocess.check_output(
["git", "log", "--pretty=format:%s", log_range],
text=True,
).splitlines()
notes = []
for subject in subjects:
subject = subject.strip()
if not subject:
continue
if subject.startswith("chore: bump version"):
continue
if subject.lower() == "no message":
continue
notes.append(f"- {subject}")
if not notes:
notes = ["- Release build and version bump."]
body = "## What\'s new\n\n" + "\n".join(notes) + "\n"
pathlib.Path("release-notes.md").write_text(body, encoding="utf-8")
print(body)
PY
- name: Build Windows executable - name: Build Windows executable
run: npm run build:win run: npm run build:win
@@ -36,6 +80,7 @@ jobs:
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: gSheets-Rate-Assistant ${{ github.ref_name }} name: gSheets-Rate-Assistant ${{ github.ref_name }}
body_path: release-notes.md
files: dist/*.exe files: dist/*.exe
- name: Mirror release to GitHub - name: Mirror release to GitHub
@@ -61,6 +106,8 @@ jobs:
tag = os.environ["BUILD_VERSION"] tag = os.environ["BUILD_VERSION"]
release_name = f"gSheets-Rate-Assistant {tag}" 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) asset_path = next((os.path.join("dist", name) for name in os.listdir("dist") if name.endswith(".exe")), None)
with open("release-notes.md", "r", encoding="utf-8") as notes_file:
release_notes = notes_file.read()
if asset_path is None: if asset_path is None:
raise SystemExit("No Windows executable found in dist/") raise SystemExit("No Windows executable found in dist/")
@@ -100,6 +147,7 @@ jobs:
payload = { payload = {
"tag_name": tag, "tag_name": tag,
"name": release_name, "name": release_name,
"body": release_notes,
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
"generate_release_notes": False, "generate_release_notes": False,
+132 -27
View File
@@ -1,5 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const https = require('https');
const crypto = require('crypto');
const {applyRateLimitBuffer} = require('./rate'); const {applyRateLimitBuffer} = require('./rate');
const {fetchSpreadsheetValuesBatch} = require('./sheets'); const {fetchSpreadsheetValuesBatch} = require('./sheets');
@@ -24,6 +26,128 @@ function resolveConfigPath(config, candidatePath) {
return path.resolve(getConfigBaseDir(config), candidatePath); return path.resolve(getConfigBaseDir(config), candidatePath);
} }
function base64UrlEncode(value) {
return Buffer.from(value)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function createServiceAccountAuthClient(credentials) {
const scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
const tokenUri = credentials.token_uri || 'https://oauth2.googleapis.com/token';
const tokenCache = {accessToken: '', expiryDate: 0};
async function requestAccessToken() {
if (!credentials.client_email || !credentials.private_key) {
throw new Error('Service account credentials must include client_email and private_key');
}
const now = Math.floor(Date.now() / 1000);
const header = {alg: 'RS256', typ: 'JWT'};
const payload = {
iss: credentials.client_email,
scope: scopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
};
const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
const signer = crypto.createSign('RSA-SHA256');
signer.update(unsignedToken);
signer.end();
const signature = signer.sign(credentials.private_key);
const assertion = `${unsignedToken}.${base64UrlEncode(signature)}`;
const body = `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${encodeURIComponent(assertion)}`;
const tokenUrl = new URL(tokenUri);
const requestOptions = {
protocol: tokenUrl.protocol,
hostname: tokenUrl.hostname,
port: tokenUrl.port || 443,
method: 'POST',
path: `${tokenUrl.pathname}${tokenUrl.search}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
};
const responseBody = await new Promise((resolve, reject) => {
const request = https.request(requestOptions, response => {
let responseText = '';
response.setEncoding('utf8');
response.on('data', chunk => {
responseText += chunk;
});
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(responseText);
return;
}
let message = `Token request failed with status ${response.statusCode}`;
try {
const parsed = JSON.parse(responseText);
if (parsed && parsed.error && parsed.error_description) {
message = `${parsed.error}: ${parsed.error_description}`;
} else if (parsed && parsed.error && parsed.error.message) {
message = parsed.error.message;
}
} catch (_) {
if (responseText) {
message = responseText;
}
}
reject(new Error(message));
});
});
request.on('error', reject);
request.write(body);
request.end();
});
const tokenData = JSON.parse(responseBody);
if (!tokenData.access_token) {
throw new Error('Token response did not include access_token');
}
tokenCache.accessToken = tokenData.access_token;
tokenCache.expiryDate = Date.now() + Math.max(0, (tokenData.expires_in || 3600) - 60) * 1000;
return tokenData.access_token;
}
return {
scopes,
async getAccessToken() {
if (tokenCache.accessToken && Date.now() < tokenCache.expiryDate) {
return {token: tokenCache.accessToken, expiry_date: tokenCache.expiryDate};
}
const accessToken = await requestAccessToken();
return {token: accessToken, expiry_date: tokenCache.expiryDate};
},
async getRequestHeaders() {
const token = await this.getAccessToken();
return {Authorization: `Bearer ${token.token}`};
},
};
}
function readServiceAccountCredentials(resolvedPath) {
try {
const raw = fs.readFileSync(resolvedPath, 'utf8');
return JSON.parse(raw);
} catch (e) {
throw new Error(`Failed to read/parse service account credentials at ${resolvedPath}: ${e.message}`);
}
}
function loadApiKeys(config) { function loadApiKeys(config) {
const apiKeys = []; const apiKeys = [];
const defaultRateLimit = config.rateLimitPerMinute || 50; const defaultRateLimit = config.rateLimitPerMinute || 50;
@@ -85,21 +209,8 @@ function createServiceAccountBatchFetcher(config) {
if (!fs.existsSync(resolvedPath)) { if (!fs.existsSync(resolvedPath)) {
throw new Error(`Service account credentials not found: ${resolvedPath}`); throw new Error(`Service account credentials not found: ${resolvedPath}`);
} }
const credentials = readServiceAccountCredentials(resolvedPath);
let credentials; const authClient = createServiceAccountAuthClient(credentials);
try {
credentials = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
} catch (e) {
throw new Error(`Failed to read/parse service account credentials at ${resolvedPath}: ${e.message}`);
}
let authClient;
try {
authClient = google.auth.fromJSON(credentials);
} catch (e) {
throw new Error(`Failed to initialize auth client from credentials at ${resolvedPath}: ${e.message}`);
}
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
const bufferedRateLimit = applyRateLimitBuffer(rateLimitPerMinute); const bufferedRateLimit = applyRateLimitBuffer(rateLimitPerMinute);
const interval = Math.ceil(60000 / bufferedRateLimit); const interval = Math.ceil(60000 / bufferedRateLimit);
@@ -222,11 +333,8 @@ function getFirstServiceAccountAuthClient(config) {
if (!fs.existsSync(resolvedPath)) return null; if (!fs.existsSync(resolvedPath)) return null;
try { try {
const raw = fs.readFileSync(resolvedPath, 'utf8'); const credentials = readServiceAccountCredentials(resolvedPath);
const credentials = JSON.parse(raw); return createServiceAccountAuthClient(credentials);
const authClient = google.auth.fromJSON(credentials);
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
return authClient;
} catch (e) { } catch (e) {
console.error(`Failed to load first service account from ${resolvedPath}: ${e.message}`); console.error(`Failed to load first service account from ${resolvedPath}: ${e.message}`);
return null; return null;
@@ -242,11 +350,8 @@ function loadAuth(config) {
if (!fs.existsSync(resolvedPath)) { if (!fs.existsSync(resolvedPath)) {
throw new Error(`Credentials file not found: ${resolvedPath}`); throw new Error(`Credentials file not found: ${resolvedPath}`);
} }
const raw = fs.readFileSync(resolvedPath, 'utf8'); const credentials = readServiceAccountCredentials(resolvedPath);
const credentials = JSON.parse(raw); return createServiceAccountAuthClient(credentials);
const authClient = google.auth.fromJSON(credentials);
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
return authClient;
} }
return getFirstServiceAccountAuthClient(config); return getFirstServiceAccountAuthClient(config);
@@ -258,6 +363,6 @@ module.exports = {
createServiceAccountBatchFetcher, createServiceAccountBatchFetcher,
createApiKeyBatchFetcher, createApiKeyBatchFetcher,
getFirstApiKey, getFirstApiKey,
getFirstServiceAccountAuthClient getFirstServiceAccountAuthClient,
, loadAuth loadAuth,
}; };
+31 -29
View File
@@ -45,35 +45,37 @@ function extractErrorMessage(data) {
function requestJson(urlString, headers = {}) { function requestJson(urlString, headers = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(urlString); const url = new URL(urlString);
const request = https.request( const requestOptions = {
url, protocol: url.protocol,
{ hostname: url.hostname,
method: 'GET', port: url.port || 443,
headers: Object.assign({Accept: 'application/json'}, headers), method: 'GET',
}, path: `${url.pathname}${url.search}`,
response => { headers: Object.assign({Accept: 'application/json'}, headers),
let body = ''; };
response.setEncoding('utf8');
response.on('data', chunk => { const request = https.request(requestOptions, response => {
body += chunk; let body = '';
}); response.setEncoding('utf8');
response.on('end', () => { response.on('data', chunk => {
const data = parseResponseBody(body); body += chunk;
});
if (response.statusCode >= 200 && response.statusCode < 300) { response.on('end', () => {
resolve({status: response.statusCode, headers: response.headers, data}); const data = parseResponseBody(body);
return;
}
const message = extractErrorMessage(data) || `Request failed with status ${response.statusCode}`; if (response.statusCode >= 200 && response.statusCode < 300) {
const error = new Error(message); resolve({status: response.statusCode, headers: response.headers, data});
error.response = {status: response.statusCode, headers: response.headers, data}; return;
reject(error); }
});
} const message = extractErrorMessage(data) || `Request failed with status ${response.statusCode}`;
); const error = new Error(message);
error.response = {status: response.statusCode, headers: response.headers, data};
reject(error);
});
});
request.on('error', reject); request.on('error', reject);
request.end(); request.end();
@@ -96,15 +98,15 @@ async function requestSheetsJson(pathname, context, queryParams = {}) {
const headers = {}; const headers = {};
if (context && context.authClient) { if (context && context.authClient) {
if (typeof context.authClient.getRequestHeaders === 'function') { if (typeof context.authClient.getAccessToken === 'function') {
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
Object.assign(headers, authHeaders || {});
} else if (typeof context.authClient.getAccessToken === 'function') {
const token = await context.authClient.getAccessToken(); const token = await context.authClient.getAccessToken();
const accessToken = token && typeof token === 'object' ? token.token || token.access_token : token; const accessToken = token && typeof token === 'object' ? token.token || token.access_token : token;
if (accessToken) { if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`; headers.Authorization = `Bearer ${accessToken}`;
} }
} else if (typeof context.authClient.getRequestHeaders === 'function') {
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
Object.assign(headers, authHeaders || {});
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "google-sheets-rate-assistant", "name": "google-sheets-rate-assistant",
"version": "2.1.9", "version": "2.1.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "google-sheets-rate-assistant", "name": "google-sheets-rate-assistant",
"version": "2.1.9", "version": "2.1.10",
"dependencies": { "dependencies": {
"csv-stringify": "^6.0.0", "csv-stringify": "^6.0.0",
"googleapis": "^173.0.0", "googleapis": "^173.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "google-sheets-rate-assistant", "name": "google-sheets-rate-assistant",
"version": "2.1.9", "version": "2.1.10",
"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
@@ -4,7 +4,7 @@ This Node.js application pulls configured Google Sheets documents and exports th
## Setup ## Setup
Download the latest version from the [Releases tab](./releases) and run it. Download the latest version from the Releases and run it.
1. If `config.json` does not exist, the app will ask whether you want a wizard or a starter config file. 1. If `config.json` does not exist, the app will ask whether you want a wizard or a starter config file.
2. If you choose the config-file path instead of the wizard, configure authentication in `config.json` — choose one of: 2. If you choose the config-file path instead of the wizard, configure authentication in `config.json` — choose one of: