13 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
lzstealth 3a5cc191fe chore: bump version to 2.1.9 2026-07-22 16:57:54 +01:00
lzstealth 817af0e9ba no message 2026-07-22 16:20:08 +01:00
lzstealth 3a4f1eed68 Removed Build Instructions 2026-07-19 10:10:46 +01:00
6 changed files with 215 additions and 73 deletions
+48
View File
@@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -28,6 +30,48 @@ jobs:
- name: Clean dist
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
run: npm run build:win
@@ -36,6 +80,7 @@ jobs:
with:
tag_name: ${{ github.ref_name }}
name: gSheets-Rate-Assistant ${{ github.ref_name }}
body_path: release-notes.md
files: dist/*.exe
- name: Mirror release to GitHub
@@ -61,6 +106,8 @@ jobs:
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)
with open("release-notes.md", "r", encoding="utf-8") as notes_file:
release_notes = notes_file.read()
if asset_path is None:
raise SystemExit("No Windows executable found in dist/")
@@ -100,6 +147,7 @@ jobs:
payload = {
"tag_name": tag,
"name": release_name,
"body": release_notes,
"draft": False,
"prerelease": False,
"generate_release_notes": False,
+132 -27
View File
@@ -1,5 +1,7 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const crypto = require('crypto');
const {applyRateLimitBuffer} = require('./rate');
const {fetchSpreadsheetValuesBatch} = require('./sheets');
@@ -24,6 +26,128 @@ function resolveConfigPath(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) {
const apiKeys = [];
const defaultRateLimit = config.rateLimitPerMinute || 50;
@@ -85,21 +209,8 @@ function createServiceAccountBatchFetcher(config) {
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Service account credentials not found: ${resolvedPath}`);
}
let 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 credentials = readServiceAccountCredentials(resolvedPath);
const authClient = createServiceAccountAuthClient(credentials);
const bufferedRateLimit = applyRateLimitBuffer(rateLimitPerMinute);
const interval = Math.ceil(60000 / bufferedRateLimit);
@@ -222,11 +333,8 @@ function getFirstServiceAccountAuthClient(config) {
if (!fs.existsSync(resolvedPath)) return null;
try {
const raw = fs.readFileSync(resolvedPath, 'utf8');
const credentials = JSON.parse(raw);
const authClient = google.auth.fromJSON(credentials);
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
return authClient;
const credentials = readServiceAccountCredentials(resolvedPath);
return createServiceAccountAuthClient(credentials);
} catch (e) {
console.error(`Failed to load first service account from ${resolvedPath}: ${e.message}`);
return null;
@@ -242,11 +350,8 @@ function loadAuth(config) {
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Credentials file not found: ${resolvedPath}`);
}
const raw = fs.readFileSync(resolvedPath, 'utf8');
const credentials = JSON.parse(raw);
const authClient = google.auth.fromJSON(credentials);
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
return authClient;
const credentials = readServiceAccountCredentials(resolvedPath);
return createServiceAccountAuthClient(credentials);
}
return getFirstServiceAccountAuthClient(config);
@@ -258,6 +363,6 @@ module.exports = {
createServiceAccountBatchFetcher,
createApiKeyBatchFetcher,
getFirstApiKey,
getFirstServiceAccountAuthClient
, loadAuth
getFirstServiceAccountAuthClient,
loadAuth,
};
+31 -29
View File
@@ -45,35 +45,37 @@ function extractErrorMessage(data) {
function requestJson(urlString, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const request = https.request(
url,
{
method: 'GET',
headers: Object.assign({Accept: 'application/json'}, headers),
},
response => {
let body = '';
response.setEncoding('utf8');
const requestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
method: 'GET',
path: `${url.pathname}${url.search}`,
headers: Object.assign({Accept: 'application/json'}, headers),
};
response.on('data', chunk => {
body += chunk;
});
const request = https.request(requestOptions, response => {
let body = '';
response.setEncoding('utf8');
response.on('end', () => {
const data = parseResponseBody(body);
response.on('data', chunk => {
body += chunk;
});
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve({status: response.statusCode, headers: response.headers, data});
return;
}
response.on('end', () => {
const data = parseResponseBody(body);
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);
});
}
);
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve({status: response.statusCode, headers: response.headers, data});
return;
}
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.end();
@@ -96,15 +98,15 @@ async function requestSheetsJson(pathname, context, queryParams = {}) {
const headers = {};
if (context && context.authClient) {
if (typeof context.authClient.getRequestHeaders === 'function') {
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
Object.assign(headers, authHeaders || {});
} else if (typeof context.authClient.getAccessToken === 'function') {
if (typeof context.authClient.getAccessToken === 'function') {
const token = await context.authClient.getAccessToken();
const accessToken = token && typeof token === 'object' ? token.token || token.access_token : token;
if (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",
"version": "2.1.0",
"version": "2.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "google-sheets-rate-assistant",
"version": "2.1.0",
"version": "2.1.10",
"dependencies": {
"csv-stringify": "^6.0.0",
"googleapis": "^173.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "google-sheets-rate-assistant",
"version": "2.1.8",
"version": "2.1.10",
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
"main": "index.js",
"bin": "index.js",
+1 -14
View File
@@ -4,7 +4,7 @@ This Node.js application pulls configured Google Sheets documents and exports th
## 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.
2. If you choose the config-file path instead of the wizard, configure authentication in `config.json` — choose one of:
@@ -63,19 +63,6 @@ If `config.json` is missing, the app creates a starter file next to the executab
If you choose the wizard, it will ask for the auth method, credential count, document count, and sheet names. It will not ask for output directories or file names.
## Windows Packaging
To build a Windows executable from source, run:
```bash
npm run build:win
```
That creates `dist/gSheets-Rate-Assistant-v2.1.8.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`.
If you also want the same tag to create a GitHub release, add `RELEASE_GITHUB_TOKEN` and `RELEASE_GITHUB_REPO` as secrets in Gitea so the workflow can mirror the release and upload the Windows asset to GitHub.
## AI Disclosure
A large chunk of the project was coded using AI, this is the first pass at integrating some AI assistance into my projects. However all code written has been verified and checked before submission. AI use has only been included since the v2 rewrite.