7 Commits
Author SHA1 Message Date
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
lzstealth c1154a58d5 fix: correct github release mirroring 2026-07-19 10:02:21 +01:00
lzstealth 727df78b53 feat: mirror gitea releases to github 2026-07-19 09:58:21 +01:00
lzstealth ba656eb679 Ignore serviceAccounts folder 2026-07-18 13:03:34 +01:00
lzstealth 00d0db8255 Add folder-based service account onboarding 2026-07-18 12:38:51 +01:00
7 changed files with 214 additions and 26 deletions
+102
View File
@@ -37,3 +37,105 @@ jobs:
tag_name: ${{ github.ref_name }}
name: gSheets-Rate-Assistant ${{ github.ref_name }}
files: dist/*.exe
- name: Mirror release to GitHub
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}
RELEASE_GITHUB_REPO: ${{ secrets.RELEASE_GITHUB_REPO }}
run: |
python - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
token = os.environ.get("GITHUB_TOKEN", "")
repository = os.environ.get("RELEASE_GITHUB_REPO", "")
if not token or not repository:
print("Skipping GitHub mirror because RELEASE_GITHUB_REPO or RELEASE_GITHUB_TOKEN is missing.")
raise SystemExit(0)
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.json
credentials-*.json
serviceAccounts/
config.json
config-*.json
.env
+12 -1
View File
@@ -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}`);
}
+96 -10
View File
@@ -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});
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "google-sheets-rate-assistant",
"version": "2.1.0",
"version": "2.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "google-sheets-rate-assistant",
"version": "2.1.0",
"version": "2.1.9",
"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.5",
"version": "2.1.9",
"description": "Pull Google Sheets data and export to CSV with configurable schedules and rate limiting.",
"main": "index.js",
"bin": "index.js",
-12
View File
@@ -63,18 +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.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.
Gitea now builds only on version tags like `v1.1.1` through the workflow in `.gitea/workflows/build.yml`.
## 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.