12 Commits
Author SHA1 Message Date
lzstealth 54a0be4836 chore: fix release workflow node version
build-windows-exe / build (push) Successful in 47s
2026-08-15 14:56:47 +01:00
lzstealth ea283dd490 Release v2.1.11
build-windows-exe / build (push) Failing after 9s
2026-08-15 14:55:06 +01:00
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
9 changed files with 719 additions and 1593 deletions
+49 -1
View File
@@ -15,11 +15,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '26'
cache: npm
- name: Install dependencies
@@ -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,
+2 -1
View File
@@ -10,6 +10,7 @@ const stringifyModule = require('./node_modules/csv-stringify/dist/cjs/sync.cjs'
const stringify = typeof stringifyModule === 'function'
? stringifyModule
: stringifyModule.default || stringifyModule.stringify || stringifyModule;
const {version: packageVersion} = require('./package.json');
const {loadConfig, resolveOutputPath} = require('./lib/config');
const {buildThrottle, applyRateLimitBuffer, getTotalRateLimitPerMinute} = require('./lib/rate');
const {runOnboarding} = require('./lib/onboarding');
@@ -336,7 +337,7 @@ async function scheduleRuns(config) {
throw new Error('No sheets configured to pull in config.json');
}
console.log('Google Sheets Rate Assistant');
console.log(`Google Sheets Rate Assistant v${packageVersion}`);
const firstApiKey = getFirstApiKey(config);
const titleMap = {};
+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,
};
+10 -2
View File
@@ -1,6 +1,14 @@
const fs = require('fs');
const path = require('path');
let isSingleExecutableApp = false;
try {
const sea = require('node:sea');
isSingleExecutableApp = typeof sea.isSea === 'function' && sea.isSea();
} catch {
isSingleExecutableApp = false;
}
const CONFIG_FILE_NAME = 'config.json';
const DEFAULT_CONFIG_TEMPLATE = JSON.stringify({
apiKeys: [
@@ -56,12 +64,12 @@ const DEFAULT_CONFIG_TEMPLATE = JSON.stringify({
}, null, 2) + '\n';
function getDefaultConfigPath() {
const packagedApp = Boolean(process.pkg);
const packagedApp = isSingleExecutableApp;
return path.resolve(packagedApp ? path.dirname(process.execPath) : process.cwd(), CONFIG_FILE_NAME);
}
function getConfigPathCandidates() {
const packagedApp = Boolean(process.pkg);
const packagedApp = isSingleExecutableApp;
const externalDefault = getDefaultConfigPath();
return [
+13 -11
View File
@@ -45,13 +45,16 @@ function extractErrorMessage(data) {
function requestJson(urlString, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const request = https.request(
url,
{
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 => {
};
const request = https.request(requestOptions, response => {
let body = '';
response.setEncoding('utf8');
@@ -72,8 +75,7 @@ function requestJson(urlString, headers = {}) {
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 || {});
}
}
+459 -1522
View File
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -1,6 +1,6 @@
{
"name": "google-sheets-rate-assistant",
"version": "2.1.9",
"version": "2.1.11",
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
"main": "index.js",
"bin": "index.js",
@@ -14,11 +14,6 @@
"p-throttle": "^8.1.0"
},
"devDependencies": {
"pkg": "^5.8.1"
},
"pkg": {
"assets": [
"node_modules/csv-stringify/dist/cjs/sync.cjs"
]
"esbuild": "^0.25.9"
}
}
+5 -1
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,6 +63,10 @@ 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.
## Build Note
The Windows packaging script in `scripts/build-win.js` now builds a single executable using Node 26's SEA workflow.
## 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.
+28 -2
View File
@@ -1,6 +1,8 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const {exec} = require('pkg');
const {execFileSync} = require('child_process');
const esbuild = require('esbuild');
const {version: packageVersion} = require('../package.json');
function normalizeBuildVersion(value) {
@@ -14,9 +16,33 @@ async function main() {
fs.mkdirSync(distDir, {recursive: true});
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsa-sea-'));
const bundlePath = path.join(tempDir, 'index.cjs');
const seaBlobPath = path.join(tempDir, 'sea-prep.blob');
const seaConfigPath = path.join(tempDir, 'sea-config.json');
const buildVersion = normalizeBuildVersion(process.env.BUILD_VERSION);
const outputFile = path.join(distDir, `gSheets-Rate-Assistant-${buildVersion}.exe`);
await exec(['.', '--targets', 'latest-win-x64', '--output', outputFile]);
await esbuild.build({
entryPoints: [path.resolve(__dirname, '..', 'index.js')],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node26',
outfile: bundlePath,
logLevel: 'info',
});
fs.writeFileSync(seaConfigPath, JSON.stringify({
main: bundlePath,
output: outputFile,
mainFormat: 'commonjs',
disableExperimentalSEAWarning: true,
useCodeCache: false,
useSnapshot: false,
}, null, 2));
execFileSync(process.execPath, ['--build-sea', seaConfigPath], {stdio: 'inherit'});
}
main().catch(error => {