From 0bd2d55886753fd70c110021622f7f6885f9e87d Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Wed, 4 Mar 2026 11:19:32 -0500 Subject: [PATCH 1/5] gia --- README.md | 10 + lib/providers/base.js | 169 ++++++++++++++ lib/providers/claude.js | 38 +++- lib/providers/codex.js | 19 +- public/index.html | 25 ++- public/js/explorer/file-viewer-content.js | 5 +- public/js/explorer/files-core.js | 3 +- public/js/file-panel/data.js | 94 +++++++- public/js/ui.js | 4 +- public/js/ui/theme.js | 4 +- public/js/utils.js | 14 +- public/js/websocket.js | 4 +- public/sw.js | 90 ++++---- scripts/gia-env.sh | 255 ++++++++++++++++++++++ 14 files changed, 661 insertions(+), 73 deletions(-) create mode 100755 scripts/gia-env.sh diff --git a/README.md b/README.md index b4f361d..e21b757 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ No port forwarding, no exposing to the internet, and you get valid HTTPS certs. | `OLLAMA_HOST` | `http://localhost:11434` | Ollama API endpoint | | `GOOGLE_APPLICATION_CREDENTIALS` | _(unset)_ | Optional path to a service-account JSON for BigQuery ADC | | `BIGQUERY_EXPORT_MAX_ROWS` | `250000` | Max rows allowed for full BigQuery save/download exports (0 disables limit) | +| `CONCIERGE_CLAUDE_CMD` | _(unset)_ | Override Claude CLI command (default: `claude` on PATH) | +| `CONCIERGE_CLAUDE_ARGS` | _(unset)_ | Extra args prepended to Claude CLI command (JSON array or quoted string) | +| `CONCIERGE_CODEX_CMD` | _(unset)_ | Override Codex CLI command (default: `codex` on PATH) | +| `CONCIERGE_CODEX_ARGS` | _(unset)_ | Extra args prepended to Codex CLI command (JSON array or quoted string) | +| `CONCIERGE_CLI_SKILLS_DIRS` | _(unset)_ | Colon/semicolon-delimited skill dirs added via `--add-dir` for all CLI providers | +| `CONCIERGE_CLAUDE_SKILLS_DIRS` | _(unset)_ | Additional skill dirs for Claude only | +| `CONCIERGE_CODEX_SKILLS_DIRS` | _(unset)_ | Additional skill dirs for Codex only | +| `CONCIERGE_CLI_ENV_FILE` | _(unset)_ | JSON file of env vars to merge into CLI process environment | +| `CONCIERGE_CLAUDE_ENV_FILE` | _(unset)_ | JSON env overrides for Claude only | +| `CONCIERGE_CODEX_ENV_FILE` | _(unset)_ | JSON env overrides for Codex only | BigQuery uses Google Application Default Credentials (ADC). Typical local setup: diff --git a/lib/providers/base.js b/lib/providers/base.js index 4042c2e..18a44ee 100644 --- a/lib/providers/base.js +++ b/lib/providers/base.js @@ -4,6 +4,172 @@ */ const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); + +const CLI_ENV_OVERRIDES = { + claude: { + cmd: 'CONCIERGE_CLAUDE_CMD', + args: 'CONCIERGE_CLAUDE_ARGS', + envFile: 'CONCIERGE_CLAUDE_ENV_FILE', + skills: 'CONCIERGE_CLAUDE_SKILLS_DIRS', + }, + codex: { + cmd: 'CONCIERGE_CODEX_CMD', + args: 'CONCIERGE_CODEX_ARGS', + envFile: 'CONCIERGE_CODEX_ENV_FILE', + skills: 'CONCIERGE_CODEX_SKILLS_DIRS', + }, +}; + +const GLOBAL_SKILLS_ENV = 'CONCIERGE_CLI_SKILLS_DIRS'; +const GLOBAL_ENV_FILE = 'CONCIERGE_CLI_ENV_FILE'; +const GLOBAL_ENV_ALLOWLIST = 'CONCIERGE_CLI_ENV_ALLOWLIST'; +const GLOBAL_RUNTIME_DIR = 'CONCIERGE_CLI_RUNTIME_DIR'; +const GLOBAL_PREPEND_SKILL_BINS = 'CONCIERGE_CLI_PREPEND_SKILL_BINS'; +const CLAUDE_DISABLE_BETAS = 'CONCIERGE_CLAUDE_DISABLE_EXPERIMENTAL_BETAS'; +const CLAUDE_DISABLE_AUTO_UPDATE = 'CONCIERGE_CLAUDE_DISABLE_AUTO_UPDATE'; +const CLAUDE_DISABLE_UPDATE_NAG = 'CONCIERGE_CLAUDE_DISABLE_UPDATE_NAG'; + +function parseArgString(value) { + if (!value) return []; + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { /* fall back to tokenizing */ } + } + return trimmed + .match(/(?:[^\s"]+|"[^"]*")+/g) + ?.map((token) => token.replace(/^"|"$/g, '')) || []; +} + +function parseSkillDirs(value) { + if (!value) return []; + return value + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parseAllowlist(value) { + if (!value) return null; + const items = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + return items.length > 0 ? items : null; +} + +function loadEnvFile(filePath) { + if (!filePath) return null; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + if (data && typeof data === 'object' && !Array.isArray(data)) { + return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])); + } + } catch { /* ignore invalid env files */ } + return null; +} + +function filterEnv(env, allowlist) { + if (!allowlist) return env; + const filtered = {}; + for (const key of allowlist) { + if (Object.prototype.hasOwnProperty.call(env, key)) { + filtered[key] = env[key]; + } + } + return filtered; +} + +function resolveSpawn(cliName) { + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const cmd = process.env[overrides.cmd]; + if (cmd) { + const args = parseArgString(process.env[overrides.args]); + return { command: cmd, prefixArgs: args }; + } + return { command: cliName, prefixArgs: [] }; +} + +function cliEnv(cliName) { + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const env = { ...process.env }; + + const allowlist = parseAllowlist(process.env[GLOBAL_ENV_ALLOWLIST]); + const globalEnv = loadEnvFile(process.env[GLOBAL_ENV_FILE]); + if (globalEnv) Object.assign(env, filterEnv(globalEnv, allowlist)); + + const providerEnv = loadEnvFile(process.env[overrides.envFile]); + if (providerEnv) Object.assign(env, filterEnv(providerEnv, allowlist)); + + const runtimeDir = process.env[GLOBAL_RUNTIME_DIR]; + if (runtimeDir) { + const nodeBin = path.join(runtimeDir, 'node', 'bin'); + env.PATH = nodeBin + (env.PATH ? path.delimiter + env.PATH : ''); + env.NODE_PATH = path.join(runtimeDir, 'clis', 'node_modules'); + const libDir = path.join(runtimeDir, 'lib'); + env.LD_LIBRARY_PATH = libDir + (env.LD_LIBRARY_PATH ? path.delimiter + env.LD_LIBRARY_PATH : ''); + } + + if (cliName === 'claude') { + if (process.env[CLAUDE_DISABLE_BETAS] === '1') { + env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1'; + } + if (process.env[CLAUDE_DISABLE_AUTO_UPDATE] === '1') { + env.CLAUDE_CODE_DISABLE_AUTO_UPDATE = '1'; + } + if (process.env[CLAUDE_DISABLE_UPDATE_NAG] === '1') { + env.CLAUDE_CODE_DISABLE_UPDATE_NAG = '1'; + } + } + + prependSkillBins(env, cliName); + + return env; +} + +function skillAddDirArgs(cliName) { + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const dirs = [ + ...parseSkillDirs(process.env[GLOBAL_SKILLS_ENV]), + ...parseSkillDirs(process.env[overrides.skills]), + ].filter((dir) => fs.existsSync(dir)); + + const args = []; + for (const dir of dirs) { + args.push('--add-dir', dir); + } + return args; +} + +function prependSkillBins(env, cliName) { + if (process.env[GLOBAL_PREPEND_SKILL_BINS] !== '1') return; + const overrides = CLI_ENV_OVERRIDES[cliName] || {}; + const dirs = [ + ...parseSkillDirs(process.env[GLOBAL_SKILLS_ENV]), + ...parseSkillDirs(process.env[overrides.skills]), + ].filter((dir) => fs.existsSync(dir)); + + const binDirs = []; + for (const dir of dirs) { + try { + const entries = fs.readdirSync(dir); + for (const name of entries) { + const binDir = path.join(dir, name, 'bin'); + if (fs.existsSync(binDir)) binDirs.push(binDir); + } + } catch { /* ignore */ } + } + + if (binDirs.length > 0) { + env.PATH = binDirs.join(path.delimiter) + (env.PATH ? path.delimiter + env.PATH : ''); + } +} /** * Safe WebSocket send - checks connection state before sending @@ -86,4 +252,7 @@ class LLMProvider { module.exports = { LLMProvider, safeSend, + cliEnv, + resolveSpawn, + skillAddDirArgs, }; diff --git a/lib/providers/claude.js b/lib/providers/claude.js index b9d7545..cdfd6d3 100644 --- a/lib/providers/claude.js +++ b/lib/providers/claude.js @@ -6,7 +6,7 @@ const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { LLMProvider, safeSend } = require('./base'); +const { LLMProvider, safeSend, cliEnv, resolveSpawn, skillAddDirArgs } = require('./base'); const { embedConversation, hasEmbedding } = require('../embeddings'); const { resolveConversationExecutionMode, modeAllowsWrites } = require('../workflow/execution-mode'); @@ -596,13 +596,30 @@ class ClaudeProvider extends LLMProvider { `Edit(/${conv.cwd}/**)`, `Write(/${conv.cwd}/**)`, ] : []; + const extraAllow = (process.env.CONCIERGE_CLAUDE_SANDBOX_ALLOW || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + if (extraAllow.length > 0) { + allow.push(...extraAllow); + } + const extraDomains = (process.env.CONCIERGE_CLAUDE_SANDBOX_ALLOWED_DOMAINS || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); const sandboxSettings = { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, network: { - allowedDomains: ['github.com', '*.npmjs.org', 'registry.yarnpkg.com', 'api.github.com'] + allowedDomains: [ + 'github.com', + '*.npmjs.org', + 'registry.yarnpkg.com', + 'api.github.com', + ...extraDomains, + ] } }, permissions: { @@ -650,6 +667,9 @@ Please continue the conversation naturally, using the above context as backgroun args.push('--add-dir', path.join(uploadDir, conversationId)); } + // Inject --add-dir for configured skill directories + args.push(...skillAddDirArgs('claude')); + // Append attachment file paths to the prompt if (attachments && attachments.length > 0) { const imageAtts = attachments.filter(a => a.path && /\.(png|jpg|jpeg|gif|webp)$/i.test(a.path)); @@ -665,6 +685,7 @@ Please continue the conversation naturally, using the above context as backgroun } // Debug sandbox settings + const { command: spawnCmd, prefixArgs } = resolveSpawn('claude'); debug('SPAWN', { cwd: conv.cwd, sandboxed: conv.sandboxed, @@ -672,12 +693,14 @@ Please continue the conversation naturally, using the above context as backgroun executionMode, hasSessionId: !!conv.claudeSessionId, hasNativeForkSession: useNativeForkSession, - args: args, + command: spawnCmd, + args: [...prefixArgs, ...args], }, { truncate: 0 }); - const proc = spawn('claude', args, { + const env = cliEnv('claude'); + const proc = spawn(spawnCmd, [...prefixArgs, ...args], { cwd: conv.cwd, - env: { ...process.env }, + env, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -879,13 +902,14 @@ ${conversationText} Write your summary:`; return new Promise((resolve, reject) => { - const proc = spawn('claude', [ + const { command: sumCmd, prefixArgs: sumPrefix } = resolveSpawn('claude'); + const proc = spawn(sumCmd, [...sumPrefix, '-p', prompt, '--model', model, '--output-format', 'text', ], { cwd, - env: { ...process.env }, + env: cliEnv('claude'), stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/lib/providers/codex.js b/lib/providers/codex.js index a1963f4..6081de2 100644 --- a/lib/providers/codex.js +++ b/lib/providers/codex.js @@ -5,7 +5,7 @@ const { spawn } = require('child_process'); const path = require('path'); -const { LLMProvider, safeSend } = require('./base'); +const { LLMProvider, safeSend, cliEnv, resolveSpawn, skillAddDirArgs } = require('./base'); const { resolveConversationExecutionMode, modeAllowsWrites } = require('../workflow/execution-mode'); // Debug logging - enabled via DEBUG_CODEX=1 environment variable @@ -701,14 +701,20 @@ class CodexProvider extends LLMProvider { conv._typedInputTokens = estimateTypedInputTokens(text); conv._codexDisplayInputTokens = conv._typedInputTokens; + // Inject --add-dir for configured skill directories (before prompt) + if (!isResume) { + args.push(...skillAddDirArgs('codex')); + } + // Prompt must be last args.push(prompt); - debug('SPAWN', { cwd: conv.cwd, executionMode, args }, { truncate: 0 }); + const { command: spawnCmd, prefixArgs } = resolveSpawn('codex'); + debug('SPAWN', { cwd: conv.cwd, executionMode, command: spawnCmd, args: [...prefixArgs, ...args] }, { truncate: 0 }); - const proc = spawn('codex', args, { + const proc = spawn(spawnCmd, [...prefixArgs, ...args], { cwd: conv.cwd, - env: { ...process.env }, + env: cliEnv('codex'), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -924,7 +930,8 @@ ${conversationText} Write your summary:`; return new Promise((resolve, reject) => { - const proc = spawn('codex', [ + const { command: sumCmd, prefixArgs: sumPrefix } = resolveSpawn('codex'); + const proc = spawn(sumCmd, [...sumPrefix, 'exec', '--json', '--skip-git-repo-check', @@ -932,7 +939,7 @@ Write your summary:`; prompt, ], { cwd, - env: { ...process.env }, + env: cliEnv('codex'), stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/public/index.html b/public/index.html index 5129de5..0f7ddc4 100644 --- a/public/index.html +++ b/public/index.html @@ -2,6 +2,17 @@ + @@ -12,17 +23,17 @@ - - - + + + - + @@ -843,7 +854,7 @@

Commands & Skills

- - + + diff --git a/public/js/explorer/file-viewer-content.js b/public/js/explorer/file-viewer-content.js index 8bb5d4e..070362d 100644 --- a/public/js/explorer/file-viewer-content.js +++ b/public/js/explorer/file-viewer-content.js @@ -11,6 +11,7 @@ import { selectGeoFeature, fitGeoPreviewToBounds, } from './geo-preview.js'; +import { basePath } from '../utils.js'; const DEFAULT_PREVIEWABLE_EXTS = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']); const GEO_PREVIEW_EXTS = new Set(['geojson', 'json', 'topojson', 'jsonl', 'ndjson']); @@ -527,8 +528,8 @@ export function renderFileViewerContent({ unmountGeoPreview(container); const ext = toSafeString(data.ext).toLowerCase(); - const fileUrl = context.getFileDownloadUrl(filePath, { inline: true }); - const downloadUrl = context.getFileDownloadUrl(filePath); + const fileUrl = basePath(context.getFileDownloadUrl(filePath, { inline: true })); + const downloadUrl = basePath(context.getFileDownloadUrl(filePath)); const fileLikeIcon = icons.document || icons.file || ''; const openExternalIcon = icons.openExternal || DEFAULT_OPEN_EXTERNAL_ICON; const attachIcon = icons.attach || DEFAULT_ATTACH_ICON; diff --git a/public/js/explorer/files-core.js b/public/js/explorer/files-core.js index d235583..3b3a106 100644 --- a/public/js/explorer/files-core.js +++ b/public/js/explorer/files-core.js @@ -1,4 +1,5 @@ import { buildDeleteFileUrl } from './context.js'; +import { basePath } from '../utils.js'; export function sortEntries(entries) { return [...entries].sort((a, b) => { @@ -13,7 +14,7 @@ export function getViewableFiles(entries) { } export function getInlineDownloadUrl(context, filePath) { - return context.getFileDownloadUrl(filePath, { inline: true }); + return basePath(context.getFileDownloadUrl(filePath, { inline: true })); } export async function fetchDirectoryData(context, targetPath, apiFetch, options = {}) { diff --git a/public/js/file-panel/data.js b/public/js/file-panel/data.js index bb62ded..32c13d3 100644 --- a/public/js/file-panel/data.js +++ b/public/js/file-panel/data.js @@ -1,6 +1,6 @@ // --- Data Tab (DuckDB + BigQuery SQL Analysis) --- import { escapeHtml } from '../markdown.js'; -import { haptic, showToast, apiFetch, showDialog } from '../utils.js'; +import { haptic, showToast, apiFetch, showDialog, basePath } from '../utils.js'; import * as state from '../state.js'; import { showSaveLocationPicker } from '../ui/save-location-picker.js'; @@ -1201,6 +1201,96 @@ function renderResults(data, { source = DATA_SOURCE_DUCKDB } = {}) { }); } +/** + * Export query results + * @param {string} format - export target format + */ +async function exportResults(format) { + if (!lastQueryResults) { + showToast('No results to export'); + return; + } + + const defaultName = 'query-results'; + const filename = await promptForFilename(defaultName, 'Download'); + if (!filename) return; + + const { columns, rows } = lastQueryResults; + + if (format === 'parquet') { + if (lastQuerySource !== DATA_SOURCE_DUCKDB || !lastQuerySQL) { + showToast('Parquet export is only available for DuckDB queries'); + return; + } + + showToast('Exporting...', { duration: 1000 }); + + try { + const res = await fetch(basePath('/api/duckdb/export'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sql: lastQuerySQL, + format: 'parquet', + filename, + }), + }); + + if (!res.ok) { + const err = await res.json(); + showToast(err.error || 'Export failed'); + return; + } + + const blob = await res.blob(); + const rowCount = res.headers.get('X-Row-Count') || rows.length; + triggerBlobDownload(blob, `${filename}.parquet`); + + showToast(`Exported ${rowCount} rows as Parquet`); + } catch { + showToast('Export failed'); + } + return; + } + + let content; + let mimeType; + let extension; + + if (format === 'json') { + const data = rows.map((row) => { + const obj = {}; + columns.forEach((col, i) => { + obj[col.name] = row[i]; + }); + return obj; + }); + content = JSON.stringify(data, null, 2); + mimeType = 'application/json'; + extension = 'json'; + } else { + const escapeCSV = (val) => { + if (val === null || val === undefined) return ''; + const str = String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; + }; + + const header = columns.map((col) => escapeCSV(col.name)).join(','); + const dataRows = rows.map((row) => row.map(escapeCSV).join(',')); + content = [header, ...dataRows].join('\n'); + mimeType = 'text/csv'; + extension = 'csv'; + } + + const blob = new Blob([content], { type: mimeType }); + triggerBlobDownload(blob, `${filename}.${extension}`); + + showToast(`Exported ${rows.length} rows as ${extension.toUpperCase()}`); +} + async function downloadBigQueryResults(format) { if (lastQuerySource !== DATA_SOURCE_BIGQUERY) { showToast('No BigQuery result to download'); @@ -1229,7 +1319,7 @@ async function downloadBigQueryResults(format) { if (!filename) return; try { - const res = await fetch('/api/bigquery/query/download', { + const res = await fetch(basePath('/api/bigquery/query/download'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/public/js/ui.js b/public/js/ui.js index 82b9e82..003c291 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,6 +1,6 @@ // --- UI interactions (core message/input handling) --- import { escapeHtml } from './markdown.js'; -import { formatTime, haptic, showToast, showDialog, getDialogOverlay, getDialogCancel, apiFetch, setupLongPressHandler } from './utils.js'; +import { formatTime, haptic, showToast, showDialog, getDialogOverlay, getDialogCancel, apiFetch, setupLongPressHandler, basePath } from './utils.js'; import { HEADER_COMPACT_ENTER, HEADER_COMPACT_EXIT, MESSAGE_INPUT_MAX_HEIGHT } from './constants.js'; import { getWS } from './websocket.js'; import { loadConversations, deleteConversation, forkConversation, showListView, triggerSearch, hideActionPopup, renameConversation } from './conversations.js'; @@ -152,7 +152,7 @@ function _getCurrentConversationCwd() { async function postWorkflowLock(endpoint, payload) { try { - const res = await fetch(`/api/workflow/lock/${endpoint}`, { + const res = await fetch(basePath(`/api/workflow/lock/${endpoint}`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), diff --git a/public/js/ui/theme.js b/public/js/ui/theme.js index 7e6f37f..96eea25 100644 --- a/public/js/ui/theme.js +++ b/public/js/ui/theme.js @@ -1,5 +1,5 @@ // --- Theme management (light/dark/auto and color themes) --- -import { haptic, showToast } from '../utils.js'; +import { haptic, showToast, basePath } from '../utils.js'; import * as state from '../state.js'; import { THEME_TRANSITION_DURATION } from '../constants.js'; @@ -204,7 +204,7 @@ export function applyColorTheme(animate = false) { setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), THEME_TRANSITION_DURATION); } - colorThemeLink.href = `/css/themes/${theme}.css`; + colorThemeLink.href = basePath(`/css/themes/${theme}.css`); // Update status bar color after CSS loads const meta = document.querySelector('meta[name="theme-color"]'); diff --git a/public/js/utils.js b/public/js/utils.js index 4994b64..94e3022 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,6 +1,18 @@ // --- Utility functions --- import { HAPTIC_LIGHT, HAPTIC_MEDIUM, TOAST_DURATION_DEFAULT, LONG_PRESS_DURATION } from './constants.js'; +// Derive base path for reverse-proxy setups (e.g. VSCode port forwarding, JupyterHub proxy). +const _bp = (() => { + const p = window.location.pathname; + return p.endsWith('/') ? p : p.substring(0, p.lastIndexOf('/') + 1); +})(); + +/** Resolve an absolute path (e.g. '/api/foo') relative to the proxy base path. */ +export function basePath(url) { + if (!url.startsWith('/')) return url; + return _bp + url.substring(1); +} + export function haptic(ms = HAPTIC_LIGHT) { navigator.vibrate?.(ms); } @@ -279,7 +291,7 @@ export function setupLongPressHandler(element, handlers) { export async function apiFetch(url, options = {}) { const { silent = false, ...fetchOptions } = options; try { - const res = await fetch(url, fetchOptions); + const res = await fetch(basePath(url), fetchOptions); if (!res.ok) { const data = await res.json().catch(() => ({})); const msg = data.error || `Request failed (${res.status})`; diff --git a/public/js/websocket.js b/public/js/websocket.js index 1e10139..8b39886 100644 --- a/public/js/websocket.js +++ b/public/js/websocket.js @@ -1,5 +1,5 @@ // --- WebSocket connection management --- -import { showToast } from './utils.js'; +import { showToast, basePath } from './utils.js'; import { appendDelta, finalizeMessage, renderMessages } from './render.js'; import { loadConversations } from './conversations.js'; import * as state from './state.js'; @@ -34,7 +34,7 @@ export function connectWS() { } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${proto}//${location.host}`); + ws = new WebSocket(`${proto}//${location.host}${basePath('/')}`); ws.onopen = () => { clearTimeout(reconnectTimer); diff --git a/public/sw.js b/public/sw.js index ed95f48..24d35ea 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,45 +1,49 @@ -const CACHE_NAME = 'concierge-v127'; -const STATIC_ASSETS = [ - '/', - '/index.html', - '/style.css', - '/css/themes/darjeeling.css', - '/css/themes/budapest.css', - '/css/themes/moonrise.css', - '/css/themes/aquatic.css', - '/css/themes/solarized.css', - '/css/themes/catppuccin.css', - '/css/themes/fjord.css', - '/css/themes/monokai.css', - '/css/themes/paper.css', - '/css/base.css', - '/css/layout.css', - '/css/components.css', - '/css/messages.css', - '/css/list.css', - '/css/file-panel.css', - '/css/branches.css', - '/js/app.js', - '/js/utils.js', - '/js/state.js', - '/js/websocket.js', - '/js/render.js', - '/js/markdown.js', - '/js/conversations.js', - '/js/ui.js', - '/js/file-panel.js', - '/js/branches.js', - '/manifest.json', - '/lib/highlight.min.js', - '/icons/icon-192.png', - '/icons/icon-512.png', - '/icons/icon-maskable-512.png', - '/icons/screenshot-narrow.png', - '/icons/screenshot-wide.png', +const CACHE_NAME = 'concierge-v128'; +const STATIC_ASSET_PATHS = [ + '', + 'index.html', + 'style.css', + 'css/themes/darjeeling.css', + 'css/themes/budapest.css', + 'css/themes/moonrise.css', + 'css/themes/aquatic.css', + 'css/themes/solarized.css', + 'css/themes/catppuccin.css', + 'css/themes/fjord.css', + 'css/themes/monokai.css', + 'css/themes/paper.css', + 'css/base.css', + 'css/layout.css', + 'css/components.css', + 'css/messages.css', + 'css/list.css', + 'css/file-panel.css', + 'css/branches.css', + 'js/app.js', + 'js/utils.js', + 'js/state.js', + 'js/websocket.js', + 'js/render.js', + 'js/markdown.js', + 'js/conversations.js', + 'js/ui.js', + 'js/file-panel.js', + 'js/branches.js', + 'manifest.json', + 'lib/highlight.min.js', + 'icons/icon-192.png', + 'icons/icon-512.png', + 'icons/icon-maskable-512.png', + 'icons/screenshot-narrow.png', + 'icons/screenshot-wide.png', ]; +// Resolve asset paths relative to SW scope (handles proxy prefixes). +const SCOPE = self.registration?.scope || self.location.href.replace(/sw\.js.*$/, ''); +const STATIC_ASSETS = STATIC_ASSET_PATHS.map((p) => new URL(p, SCOPE).href); + // API routes to cache for offline use -const CACHED_API_ROUTES = ['/api/conversations']; +const CACHED_API_ROUTES = ['api/conversations']; // Install: cache static assets self.addEventListener('install', (event) => { @@ -67,7 +71,11 @@ self.addEventListener('fetch', (event) => { if (event.request.headers.get('upgrade') === 'websocket') return; // Cacheable API routes: network-first, fall back to cache - if (CACHED_API_ROUTES.some(route => url.pathname === route || url.pathname.startsWith(route + '?'))) { + const scopePath = new URL(SCOPE).pathname; + if (CACHED_API_ROUTES.some(route => { + const full = scopePath + route; + return url.pathname === full || url.pathname.startsWith(full + '?'); + })) { event.respondWith( fetch(event.request).then((response) => { if (response.ok) { @@ -81,7 +89,7 @@ self.addEventListener('fetch', (event) => { } // Skip other API requests - if (url.pathname.startsWith('/api/')) return; + if (url.pathname.startsWith(scopePath + 'api/')) return; // Static assets: cache-first event.respondWith( diff --git a/scripts/gia-env.sh b/scripts/gia-env.sh new file mode 100755 index 0000000..ebfbf22 --- /dev/null +++ b/scripts/gia-env.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat <<'EOF' +Usage: source scripts/gia-env.sh + eval "$(scripts/gia-env.sh)" + scripts/gia-env.sh + +Prints export statements to configure Concierge to use GIA's bundled CLIs +and skills directories. When sourced, it will export variables directly. + +Examples: + source scripts/gia-env.sh + eval "$(scripts/gia-env.sh)" +EOF + exit 0 +fi + +cache_root="${XDG_CACHE_HOME:-$HOME/.cache}" +runtime_base="${cache_root}/gia/runtime" +runtime_dir="" + +if [[ -d "${runtime_base}" ]]; then + while IFS= read -r -d '' marker; do + runtime_dir="$(dirname "$marker")" + break + done < <(find "${runtime_base}" -maxdepth 2 -type f -name .runtime-ready -print0 2>/dev/null | sort -z) +fi + +if [[ -z "${runtime_dir}" ]]; then + echo "No GIA runtime found under ${runtime_base} (missing .runtime-ready)." >&2 + exit 1 +fi + +node_bin="${runtime_dir}/node/bin/node" +cli_root="${runtime_dir}/clis" + +if [[ ! -x "${node_bin}" ]]; then + echo "GIA node runtime not found at ${node_bin}." >&2 + exit 1 +fi + +resolve_entry() { + local package_name="$1" + local bin_hint="$2" + python - "$cli_root" "$package_name" "$bin_hint" <<'PY' +import json +import os +import sys + +cli_root, package_name, bin_hint = sys.argv[1:] +pkg_dir = os.path.join(cli_root, "node_modules", *package_name.split("/")) +pkg_path = os.path.join(pkg_dir, "package.json") +if not os.path.exists(pkg_path): + sys.exit(1) +with open(pkg_path, "r", encoding="utf-8") as f: + pkg = json.load(f) +bin_entry = pkg.get("bin") +entry = None +if isinstance(bin_entry, str): + entry = os.path.join(pkg_dir, bin_entry) +elif isinstance(bin_entry, dict): + entry = bin_entry.get(bin_hint) + if entry: + entry = os.path.join(pkg_dir, entry) +if not entry or not os.path.exists(entry): + sys.exit(2) +print(entry) +PY +} + +json_array() { + python - "$1" <<'PY' +import json +import sys +print(json.dumps([sys.argv[1]])) +PY +} + +emit_exports() { + local name="$1" + local cmd_env="$2" + local args_env="$3" + local entrypoint="$4" + + local args_json + args_json="$(json_array "$entrypoint")" + + printf 'export %s="%s"\n' "${cmd_env}" "${node_bin}" + printf 'export %s=%q\n' "${args_env}" "${args_json}" +} + +claude_entry="$(resolve_entry "@anthropic-ai/claude-code" "claude" || true)" +codex_entry="$(resolve_entry "@openai/codex" "codex" || true)" + +exports=() + +if [[ -n "${claude_entry}" ]]; then + exports+=("CONCIERGE_CLAUDE_CMD=${node_bin}") + exports+=("CONCIERGE_CLAUDE_ARGS=$(json_array "${claude_entry}")") +fi + +if [[ -n "${codex_entry}" ]]; then + exports+=("CONCIERGE_CODEX_CMD=${node_bin}") + exports+=("CONCIERGE_CODEX_ARGS=$(json_array "${codex_entry}")") +fi + +exports+=("CONCIERGE_CLI_RUNTIME_DIR=${runtime_dir}") + +env_json_path="/tmp/concierge-gia-env.json" +python - "$env_json_path" <<'PY' +import json +import os +import sys + +out_path = sys.argv[1] +env = {} + +def load_json(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + +def load_text(path): + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception: + return None + +genai = load_json(os.path.expanduser("~/genai.json")) +if isinstance(genai, dict): + token = genai.get("token") + base_url = genai.get("base_url") + if token: + env["ANTHROPIC_AUTH_TOKEN"] = token + env["OPENAI_API_KEY"] = token + if base_url: + base = str(base_url).rstrip("/") + env["ANTHROPIC_BASE_URL"] = base + env["OPENAI_BASE_URL"] = base + +jira = load_json(os.path.expanduser("~/jira.json")) +if isinstance(jira, dict): + email = jira.get("email") + token = jira.get("token") + base_url = jira.get("base_url") + if email: + env["JIRA_EMAIL"] = email + if token: + env["JIRA_API_TOKEN"] = token + if base_url: + env["JIRA_BASE_URL"] = base_url + +gitlab_pat = load_text(os.path.expanduser("~/gitlab-pat.txt")) +if gitlab_pat: + env["GITLAB_TOKEN"] = gitlab_pat + +if env: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(env, f) +else: + # Ensure we don't point at a stale env file + try: + os.remove(out_path) + except FileNotFoundError: + pass +PY + +if [[ -f "${env_json_path}" ]]; then + exports+=("CONCIERGE_CLI_ENV_FILE=${env_json_path}") + exports+=("CONCIERGE_CLI_ENV_ALLOWLIST=OPENAI_API_KEY,OPENAI_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_BASE_URL,JIRA_EMAIL,JIRA_API_TOKEN,JIRA_BASE_URL,GITLAB_TOKEN") +fi + +exports+=("CONCIERGE_CLI_PREPEND_SKILL_BINS=1") +exports+=("CONCIERGE_CLAUDE_DISABLE_EXPERIMENTAL_BETAS=1") +exports+=("CONCIERGE_CLAUDE_DISABLE_AUTO_UPDATE=1") +exports+=("CONCIERGE_CLAUDE_DISABLE_UPDATE_NAG=1") +exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOW=Read(${HOME}/.config/gcloud/**),Read(${HOME}/gw.json),Read(${HOME}/genai.json),Read(${HOME}/jira.json),Read(${HOME}/gitlab-pat.txt),Read(${HOME}/myg-cli.json)") + +# Skills directories (MySkills as global, AI-specific as per-provider). +global_skill_dirs=() +[[ -d "${HOME}/MySkills" ]] && global_skill_dirs+=("${HOME}/MySkills") + +if [[ -d "${HOME}/.claude/skills" ]]; then + exports+=("CONCIERGE_CLAUDE_SKILLS_DIRS=${HOME}/.claude/skills") +fi + +if [[ -d "${HOME}/.codex/skills" ]]; then + exports+=("CONCIERGE_CODEX_SKILLS_DIRS=${HOME}/.codex/skills") +fi + +if [[ ${#global_skill_dirs[@]} -gt 0 ]]; then + (IFS=':'; exports+=("CONCIERGE_CLI_SKILLS_DIRS=${global_skill_dirs[*]}")) +fi + +# Dynamic sandbox domain allowlist (based on installed skill dirs). +domains=() +skill_base="${HOME}/.claude/skills" +if [[ -d "${skill_base}" ]]; then + [[ -d "${skill_base}/cli-jira" ]] && domains+=("geotab.atlassian.net" "id.atlassian.com") + [[ -d "${skill_base}/cli-gitlab" ]] && domains+=("git.geotab.com") + [[ -d "${skill_base}/cli-superset" ]] && domains+=("superset.geotab.com") + [[ -d "${skill_base}/cli-bigquery" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com" "bigquery.googleapis.com") + [[ -d "${skill_base}/cli-workspace" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com" "docs.google.com" "drive.google.com") + [[ -d "${skill_base}/util-auth" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com") + [[ -d "${skill_base}/cli-mygeotab" ]] && domains+=("my.geotab.com" "my.geotab.com/") + [[ -d "${skill_base}/cli-figma" ]] && domains+=("api.figma.com") +fi + +domains+=("genai-us.geotab.com") + +if [[ ${#domains[@]} -gt 0 ]]; then + # uniq + uniq_domains=() + for d in "${domains[@]}"; do + skip=0 + for u in "${uniq_domains[@]}"; do + if [[ "$u" == "$d" ]]; then + skip=1 + break + fi + done + [[ "$skip" -eq 0 ]] && uniq_domains+=("$d") + done + (IFS=','; exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOWED_DOMAINS=${uniq_domains[*]}")) +fi + +is_sourced=0 +if [[ -n "${ZSH_EVAL_CONTEXT-}" ]]; then + case "${ZSH_EVAL_CONTEXT}" in + *:file) is_sourced=1 ;; + esac +elif [[ -n "${BASH_VERSION-}" ]]; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + is_sourced=1 + fi +else + case "$0" in + *bash|*zsh|*sh) is_sourced=1 ;; + esac +fi + +if [[ "${is_sourced}" -eq 1 ]]; then + for entry in "${exports[@]}"; do + export "${entry}" + done +else + for entry in "${exports[@]}"; do + printf 'export %s\n' "${entry}" + done +fi From 2ed19280d00dc24324dabf9c6f756b9e51aa3697 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Wed, 4 Mar 2026 12:22:52 -0500 Subject: [PATCH 2/5] bundled script --- .../{gia-env.sh => example-bundled-env.sh} | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) rename scripts/{gia-env.sh => example-bundled-env.sh} (70%) diff --git a/scripts/gia-env.sh b/scripts/example-bundled-env.sh similarity index 70% rename from scripts/gia-env.sh rename to scripts/example-bundled-env.sh index ebfbf22..e6ed624 100755 --- a/scripts/gia-env.sh +++ b/scripts/example-bundled-env.sh @@ -1,24 +1,34 @@ #!/usr/bin/env bash +# Example: Configure Concierge to use a bundled Node.js runtime and CLIs. +# +# This script demonstrates how to set CONCIERGE_* environment variables +# for running Claude Code and Codex from a self-contained runtime directory +# (e.g., installed by an enterprise deployment tool). +# +# Adapt the paths and credential file locations to your environment. set -euo pipefail if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then cat <<'EOF' -Usage: source scripts/gia-env.sh - eval "$(scripts/gia-env.sh)" - scripts/gia-env.sh +Usage: source scripts/example-bundled-env.sh + eval "$(scripts/example-bundled-env.sh)" -Prints export statements to configure Concierge to use GIA's bundled CLIs -and skills directories. When sourced, it will export variables directly. +Example script showing how to configure Concierge to use a bundled CLI runtime +and skills directories. Adapt paths and credential locations to your setup. -Examples: - source scripts/gia-env.sh - eval "$(scripts/gia-env.sh)" +When sourced or eval'd, exports CONCIERGE_* variables for: + - Custom CLI command/args (CONCIERGE_CLAUDE_CMD, CONCIERGE_CLAUDE_ARGS, etc.) + - Environment injection from JSON (CONCIERGE_CLI_ENV_FILE) + - Skill directories (CONCIERGE_CLI_SKILLS_DIRS) + - Sandbox permissions (CONCIERGE_CLAUDE_SANDBOX_ALLOW, etc.) EOF exit 0 fi +# --- CUSTOMIZE: Set runtime_base to your bundled runtime location --- +# This example looks for a runtime directory containing a .runtime-ready marker. cache_root="${XDG_CACHE_HOME:-$HOME/.cache}" -runtime_base="${cache_root}/gia/runtime" +runtime_base="${cache_root}/gia/runtime" # Change this path for your setup runtime_dir="" if [[ -d "${runtime_base}" ]]; then @@ -29,7 +39,7 @@ if [[ -d "${runtime_base}" ]]; then fi if [[ -z "${runtime_dir}" ]]; then - echo "No GIA runtime found under ${runtime_base} (missing .runtime-ready)." >&2 + echo "No bundled runtime found under ${runtime_base} (missing .runtime-ready marker)." >&2 exit 1 fi @@ -37,7 +47,7 @@ node_bin="${runtime_dir}/node/bin/node" cli_root="${runtime_dir}/clis" if [[ ! -x "${node_bin}" ]]; then - echo "GIA node runtime not found at ${node_bin}." >&2 + echo "Node.js runtime not found at ${node_bin}." >&2 exit 1 fi @@ -108,7 +118,10 @@ fi exports+=("CONCIERGE_CLI_RUNTIME_DIR=${runtime_dir}") -env_json_path="/tmp/concierge-gia-env.json" +# --- CUSTOMIZE: Credential file locations --- +# This example reads API tokens from JSON/text files and injects them as env vars. +# Adapt the file paths and env var names to your credential storage. +env_json_path="/tmp/concierge-bundled-env.json" python - "$env_json_path" <<'PY' import json import os @@ -179,7 +192,10 @@ exports+=("CONCIERGE_CLI_PREPEND_SKILL_BINS=1") exports+=("CONCIERGE_CLAUDE_DISABLE_EXPERIMENTAL_BETAS=1") exports+=("CONCIERGE_CLAUDE_DISABLE_AUTO_UPDATE=1") exports+=("CONCIERGE_CLAUDE_DISABLE_UPDATE_NAG=1") -exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOW=Read(${HOME}/.config/gcloud/**),Read(${HOME}/gw.json),Read(${HOME}/genai.json),Read(${HOME}/jira.json),Read(${HOME}/gitlab-pat.txt),Read(${HOME}/myg-cli.json)") + +# --- CUSTOMIZE: Extra sandbox file read permissions --- +# Allow Claude to read credential files needed by your skills. +exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOW=Read(${HOME}/.config/gcloud/**)") # Skills directories (MySkills as global, AI-specific as per-provider). global_skill_dirs=() @@ -197,35 +213,38 @@ if [[ ${#global_skill_dirs[@]} -gt 0 ]]; then (IFS=':'; exports+=("CONCIERGE_CLI_SKILLS_DIRS=${global_skill_dirs[*]}")) fi -# Dynamic sandbox domain allowlist (based on installed skill dirs). +# --- CUSTOMIZE: Dynamic sandbox domain allowlist --- +# Add domains your skills need network access to. This example detects installed +# skills and adds their required domains conditionally. domains=() skill_base="${HOME}/.claude/skills" if [[ -d "${skill_base}" ]]; then - [[ -d "${skill_base}/cli-jira" ]] && domains+=("geotab.atlassian.net" "id.atlassian.com") - [[ -d "${skill_base}/cli-gitlab" ]] && domains+=("git.geotab.com") - [[ -d "${skill_base}/cli-superset" ]] && domains+=("superset.geotab.com") + # Example: Jira skill needs Atlassian domains + [[ -d "${skill_base}/cli-jira" ]] && domains+=("your-org.atlassian.net" "id.atlassian.com") + # Example: GitLab skill + [[ -d "${skill_base}/cli-gitlab" ]] && domains+=("gitlab.your-org.com") + # Example: Google APIs for BigQuery/Workspace [[ -d "${skill_base}/cli-bigquery" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com" "bigquery.googleapis.com") [[ -d "${skill_base}/cli-workspace" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com" "docs.google.com" "drive.google.com") [[ -d "${skill_base}/util-auth" ]] && domains+=("accounts.google.com" "oauth2.googleapis.com" "www.googleapis.com") - [[ -d "${skill_base}/cli-mygeotab" ]] && domains+=("my.geotab.com" "my.geotab.com/") + # Example: Figma API [[ -d "${skill_base}/cli-figma" ]] && domains+=("api.figma.com") fi -domains+=("genai-us.geotab.com") - -if [[ ${#domains[@]} -gt 0 ]]; then - # uniq - uniq_domains=() - for d in "${domains[@]}"; do - skip=0 - for u in "${uniq_domains[@]}"; do - if [[ "$u" == "$d" ]]; then - skip=1 - break - fi - done - [[ "$skip" -eq 0 ]] && uniq_domains+=("$d") - done +# Add any always-required domains (e.g., your API proxy) +# domains+=("api-proxy.your-org.com") + +# Deduplicate domains using associative array +declare -A seen_domains +uniq_domains=() +for d in ${domains[@]+"${domains[@]}"}; do + if [[ -z "${seen_domains[$d]:-}" ]]; then + seen_domains[$d]=1 + uniq_domains+=("$d") + fi +done + +if [[ ${#uniq_domains[@]} -gt 0 ]]; then (IFS=','; exports+=("CONCIERGE_CLAUDE_SANDBOX_ALLOWED_DOMAINS=${uniq_domains[*]}")) fi From 8a9b6e100a11b14c1048aaf68e9b93fa821c2c47 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Wed, 4 Mar 2026 14:43:37 -0500 Subject: [PATCH 3/5] remove redundant fct --- public/js/file-panel/data.js | 92 +----------------------------------- 1 file changed, 1 insertion(+), 91 deletions(-) diff --git a/public/js/file-panel/data.js b/public/js/file-panel/data.js index 32c13d3..c1a6ffb 100644 --- a/public/js/file-panel/data.js +++ b/public/js/file-panel/data.js @@ -1201,96 +1201,6 @@ function renderResults(data, { source = DATA_SOURCE_DUCKDB } = {}) { }); } -/** - * Export query results - * @param {string} format - export target format - */ -async function exportResults(format) { - if (!lastQueryResults) { - showToast('No results to export'); - return; - } - - const defaultName = 'query-results'; - const filename = await promptForFilename(defaultName, 'Download'); - if (!filename) return; - - const { columns, rows } = lastQueryResults; - - if (format === 'parquet') { - if (lastQuerySource !== DATA_SOURCE_DUCKDB || !lastQuerySQL) { - showToast('Parquet export is only available for DuckDB queries'); - return; - } - - showToast('Exporting...', { duration: 1000 }); - - try { - const res = await fetch(basePath('/api/duckdb/export'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sql: lastQuerySQL, - format: 'parquet', - filename, - }), - }); - - if (!res.ok) { - const err = await res.json(); - showToast(err.error || 'Export failed'); - return; - } - - const blob = await res.blob(); - const rowCount = res.headers.get('X-Row-Count') || rows.length; - triggerBlobDownload(blob, `${filename}.parquet`); - - showToast(`Exported ${rowCount} rows as Parquet`); - } catch { - showToast('Export failed'); - } - return; - } - - let content; - let mimeType; - let extension; - - if (format === 'json') { - const data = rows.map((row) => { - const obj = {}; - columns.forEach((col, i) => { - obj[col.name] = row[i]; - }); - return obj; - }); - content = JSON.stringify(data, null, 2); - mimeType = 'application/json'; - extension = 'json'; - } else { - const escapeCSV = (val) => { - if (val === null || val === undefined) return ''; - const str = String(val); - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return '"' + str.replace(/"/g, '""') + '"'; - } - return str; - }; - - const header = columns.map((col) => escapeCSV(col.name)).join(','); - const dataRows = rows.map((row) => row.map(escapeCSV).join(',')); - content = [header, ...dataRows].join('\n'); - mimeType = 'text/csv'; - extension = 'csv'; - } - - const blob = new Blob([content], { type: mimeType }); - triggerBlobDownload(blob, `${filename}.${extension}`); - - showToast(`Exported ${rows.length} rows as ${extension.toUpperCase()}`); -} - async function downloadBigQueryResults(format) { if (lastQuerySource !== DATA_SOURCE_BIGQUERY) { showToast('No BigQuery result to download'); @@ -1432,7 +1342,7 @@ async function downloadDuckDbResults(format) { showToast('Exporting...', { duration: 1000 }); try { - const res = await fetch('/api/duckdb/export', { + const res = await fetch(basePath('/api/duckdb/export'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ From 8e9f691d92242465b6307e32b508ba2946f63206 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Wed, 4 Mar 2026 15:03:51 -0500 Subject: [PATCH 4/5] fix: add window.location mock for basePath in tests The basePath() function accesses window.location.pathname which wasn't mocked in test files, causing CI failures. Co-Authored-By: Claude Opus 4.6 --- test/context-bar.test.js | 1 + test/render-compression.test.js | 1 + 2 files changed, 2 insertions(+) diff --git a/test/context-bar.test.js b/test/context-bar.test.js index 95a55dd..d3847da 100644 --- a/test/context-bar.test.js +++ b/test/context-bar.test.js @@ -21,6 +21,7 @@ describe('context bar token calculation', async () => { }; globalThis.window = { dispatchEvent() {}, + location: { pathname: '/' }, }; after(() => { diff --git a/test/render-compression.test.js b/test/render-compression.test.js index e84312c..91dc881 100644 --- a/test/render-compression.test.js +++ b/test/render-compression.test.js @@ -25,6 +25,7 @@ describe('render compressed message UI', async () => { globalThis.window = { dispatchEvent() {}, speechSynthesis: null, + location: { pathname: '/' }, }; globalThis.navigator = {}; From 50c974ca35d29475c7e0db0110577b06b2cb8725 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Wed, 4 Mar 2026 15:58:33 -0500 Subject: [PATCH 5/5] fix: add window mock to explorer test files Move window mock before module import to prevent ReferenceError in Node test environment. Co-Authored-By: Claude Opus 4.6 --- test/explorer-files-core.test.js | 13 ++++++++++++- test/explorer-shell.test.js | 8 ++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/test/explorer-files-core.test.js b/test/explorer-files-core.test.js index 77ac326..88289bd 100644 --- a/test/explorer-files-core.test.js +++ b/test/explorer-files-core.test.js @@ -1,4 +1,4 @@ -const { describe, it } = require('node:test'); +const { describe, it, after } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -6,6 +6,17 @@ const { pathToFileURL } = require('node:url'); const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'public', 'js', 'explorer', 'files-core.js')).href; describe('explorer files core', async () => { + const originalWindow = globalThis.window; + globalThis.window = { + location: { pathname: '/' }, + dispatchEvent() {}, + }; + + after(() => { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }); + const { sortEntries, getViewableFiles, diff --git a/test/explorer-shell.test.js b/test/explorer-shell.test.js index aa64f6e..f51431c 100644 --- a/test/explorer-shell.test.js +++ b/test/explorer-shell.test.js @@ -69,9 +69,13 @@ function makeViewer() { } describe('explorer shell refresh behavior', async () => { - const { createExplorerShell } = await import(moduleUrl); const previousWindow = globalThis.window; - globalThis.window = {}; + globalThis.window = { + location: { pathname: '/' }, + dispatchEvent() {}, + }; + + const { createExplorerShell } = await import(moduleUrl); after(() => { if (previousWindow === undefined) {