mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
fix(desktop): release script (#6060)
* fix(desktop): fix release * fix * Updates * chore(copilot): sync generated mothership contract mirrors (user_table select descriptions from mothership #373) * fix(desktop): canonicalize the apex origin in setOrigin, not just on load
This commit is contained in:
committed by
GitHub
parent
4f776b7a13
commit
1a23438bb7
@@ -18,7 +18,7 @@
|
||||
"start": "electron .",
|
||||
"package:dir": "bun run build && electron-builder --mac dir --publish never",
|
||||
"package:mac": "bun run build && electron-builder --mac --publish never",
|
||||
"package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none",
|
||||
"package:share": "bun run scripts/package-share.ts",
|
||||
"install:local": "bun run scripts/install-local.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Build-time channel identity, derived from the origin a build is baked to.
|
||||
*
|
||||
* Must stay in sync with APP_NAME_FOR_CHANNEL / channelForOrigin in
|
||||
* src/main/config.ts. That pair is the RUNTIME source of truth — the app calls
|
||||
* app.setName() from its baked origin at startup, which decides the userData
|
||||
* directory and single-instance lock — and this is what the packager stamps
|
||||
* into the bundle. When the two disagree, the bundle on disk and the running
|
||||
* app disagree about which app they are: a dev-pointed build packaged as
|
||||
* "Sim.app" with the production bundle id installs over a real production
|
||||
* install while naming itself "Sim Dev" at runtime.
|
||||
*/
|
||||
|
||||
/** Canonical no-redirect origins per environment. */
|
||||
export const PROD_ORIGIN = 'https://www.sim.ai'
|
||||
export const STAGING_ORIGIN = 'https://www.staging.sim.ai'
|
||||
export const DEV_ORIGIN = 'https://www.dev.sim.ai'
|
||||
export const LOCAL_ORIGIN = 'http://localhost:3000'
|
||||
|
||||
export interface ChannelIdentity {
|
||||
/** Display + bundle name; also the userData directory name. */
|
||||
name: string
|
||||
appId: string
|
||||
/** Baked default origin + persisted settings origin. */
|
||||
origin: string
|
||||
/**
|
||||
* Artifact filename stem, and the per-channel scratch directory name.
|
||||
* Space-free for the same reason electron-builder.yml's artifactName is:
|
||||
* GitHub rewrites asset names containing spaces, which desyncs the
|
||||
* electron-updater manifest from the uploaded files.
|
||||
*/
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const PROD: ChannelIdentity = {
|
||||
name: 'Sim',
|
||||
appId: 'ai.sim.desktop',
|
||||
origin: PROD_ORIGIN,
|
||||
slug: 'sim',
|
||||
}
|
||||
export const STAGING: ChannelIdentity = {
|
||||
name: 'Sim Staging',
|
||||
appId: 'ai.sim.desktop.staging',
|
||||
origin: STAGING_ORIGIN,
|
||||
slug: 'sim-staging',
|
||||
}
|
||||
export const DEV: ChannelIdentity = {
|
||||
name: 'Sim Dev',
|
||||
appId: 'ai.sim.desktop.dev',
|
||||
origin: DEV_ORIGIN,
|
||||
slug: 'sim-dev',
|
||||
}
|
||||
export const LOCAL: ChannelIdentity = {
|
||||
name: 'Sim Local',
|
||||
appId: 'ai.sim.desktop.local',
|
||||
origin: LOCAL_ORIGIN,
|
||||
slug: 'sim-local',
|
||||
}
|
||||
|
||||
/** Every channel, in the order a full run builds them. */
|
||||
export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL]
|
||||
|
||||
/**
|
||||
* Resolves the identity a build baked to `origin` must carry. Mirrors
|
||||
* channelForOrigin in src/main/config.ts, including its fall-through to
|
||||
* production for unrecognized hosts (self-hosted origins are production
|
||||
* builds pointed elsewhere). An empty origin means "unset", which the app
|
||||
* resolves to DEFAULT_ORIGIN — production.
|
||||
*/
|
||||
export function identityForOrigin(origin: string): ChannelIdentity {
|
||||
if (!origin) return PROD
|
||||
let host: string
|
||||
try {
|
||||
host = new URL(origin).hostname.toLowerCase()
|
||||
} catch {
|
||||
return PROD
|
||||
}
|
||||
if (host === 'localhost' || host === '127.0.0.1') return LOCAL
|
||||
if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return DEV
|
||||
if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return STAGING
|
||||
return PROD
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Share build: packages distributable .dmg/.zip artifacts from the current
|
||||
* checkout, signed with whatever identity is on the machine (no notarization,
|
||||
* no trusted timestamps) — the "send someone a build to try" loop.
|
||||
*
|
||||
* bun run package:share # production only
|
||||
* bun run package:share --all # all four channels
|
||||
* bun run package:share --staging --dev # just these two
|
||||
* bun run package:share --dir # skip dmg/zip, package the .app only (fast)
|
||||
* SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.acme.example bun run package:share
|
||||
*
|
||||
* Each channel lands in release/<slug>/ with artifacts named for it — sim,
|
||||
* sim-staging, sim-dev, sim-local. electron-builder.yml's artifactName is a
|
||||
* single literal ("Sim-${version}-${arch}"), so without the override every
|
||||
* channel writes the same filename and the last build silently wins. Only this
|
||||
* path overrides it: the release workflow publishes one channel per GitHub
|
||||
* release, where the flat name is what electron-updater expects.
|
||||
*
|
||||
* The baked origin decides the bundle identity, exactly as it decides the
|
||||
* runtime one. This used to shell straight into electron-builder, which took
|
||||
* productName/appId from electron-builder.yml — always the production pair — so
|
||||
* a dev-pointed share packaged as "Sim.app" with the production bundle id while
|
||||
* naming itself "Sim Dev" at runtime.
|
||||
*
|
||||
* Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle
|
||||
* to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so
|
||||
* concurrent channels would overwrite each other's bundle mid-package and ship
|
||||
* a dmg whose baked origin belongs to a different environment — invisible until
|
||||
* someone signs in. Giving each channel its own bundle directory is what would
|
||||
* make concurrency safe, and the flag that redirects the app entry point
|
||||
* (-c.extraMetadata.main) rewrites this package.json IN THE SOURCE TREE,
|
||||
* stripping scripts and devDependencies. If parallelism is worth it later, the
|
||||
* way to get it is a per-channel project directory (electron-builder --project)
|
||||
* — not extraMetadata.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels'
|
||||
|
||||
const FLAG_TO_CHANNEL: Record<string, ChannelIdentity> = Object.fromEntries(
|
||||
ALL_CHANNELS.map((channel) => [
|
||||
`--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`,
|
||||
channel,
|
||||
])
|
||||
)
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dirOnly = args.includes('--dir')
|
||||
const bakedOriginOverride = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''
|
||||
|
||||
function selectedChannels(): ChannelIdentity[] {
|
||||
if (args.includes('--all')) return [...ALL_CHANNELS]
|
||||
const picked = args.filter((arg) => arg in FLAG_TO_CHANNEL).map((arg) => FLAG_TO_CHANNEL[arg])
|
||||
if (picked.length > 0) return picked
|
||||
// No channel flags: honour an explicit origin (self-hosted shares resolve to
|
||||
// the production identity), otherwise plain production.
|
||||
return [identityForOrigin(bakedOriginOverride)]
|
||||
}
|
||||
|
||||
const channels = selectedChannels()
|
||||
// An explicit origin only makes sense for a single-channel run; with several
|
||||
// channels each one supplies its own.
|
||||
const originFor = (channel: ChannelIdentity): string =>
|
||||
channels.length === 1 && bakedOriginOverride ? bakedOriginOverride : channel.origin
|
||||
|
||||
function run(command: string, commandArgs: string[], env?: Record<string, string>): void {
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
stdio: 'inherit',
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
console.error(`\n✖ ${command} ${commandArgs.join(' ')} failed`)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannel(channel: ChannelIdentity): void {
|
||||
const origin = originFor(channel)
|
||||
console.log(`\n• ${channel.slug}: ${channel.name} (${channel.appId}) → ${origin}`)
|
||||
|
||||
// electron-builder only writes the output dir for the CURRENT target/arch, so
|
||||
// an app left by an earlier run with different settings would survive
|
||||
// alongside the new one.
|
||||
for (const dir of ['mac-universal', 'mac-arm64', 'mac']) {
|
||||
rmSync(`release/${channel.slug}/${dir}`, { recursive: true, force: true })
|
||||
}
|
||||
// electron-builder's `files: dist/**` packages whatever is in dist/, not just
|
||||
// what this build wrote. Anything left there by an earlier run — another
|
||||
// channel's bundle, scratch from an experiment — rides along inside the dmg,
|
||||
// so a production artifact can end up carrying a dev-pointed bundle. Dead
|
||||
// weight rather than a live risk (the app boots package.json's `main`), but
|
||||
// not something to hand to anyone. build.ts rewrites the directory on the
|
||||
// next line.
|
||||
rmSync('dist', { recursive: true, force: true })
|
||||
|
||||
run('bun', ['run', 'scripts/build.ts'], { SIM_DESKTOP_DEFAULT_ORIGIN: origin })
|
||||
run('bunx', [
|
||||
'electron-builder',
|
||||
'--mac',
|
||||
...(dirOnly ? ['dir'] : []),
|
||||
'--publish',
|
||||
'never',
|
||||
// Trusted timestamps make codesign do a network round trip to Apple per
|
||||
// file (hundreds inside the Electron framework). Distribution builds need
|
||||
// them; a share build does not, and it turns signing into a long stall.
|
||||
'-c.mac.timestamp=none',
|
||||
`-c.productName=${channel.name}`,
|
||||
`-c.appId=${channel.appId}`,
|
||||
`-c.directories.output=release/${channel.slug}`,
|
||||
// The ${...} placeholders are electron-builder's own templating, expanded
|
||||
// by it at packaging time — escaped here so JS leaves them alone.
|
||||
`-c.artifactName=${channel.slug}-\${version}-\${arch}.\${ext}`,
|
||||
])
|
||||
console.log(`✔ ${channel.slug}: release/${channel.slug}/`)
|
||||
}
|
||||
|
||||
// Shared node_modules state, and the one step every channel has in common.
|
||||
run('bun', ['run', 'scripts/ensure-pty-prebuilds.ts'])
|
||||
console.log(
|
||||
`• Building ${channels.length} channel(s)${dirOnly ? ' (dir only)' : ''}: ${channels.map((c) => c.slug).join(', ')}`
|
||||
)
|
||||
for (const channel of channels) {
|
||||
buildChannel(channel)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_NAME_FOR_CHANNEL, channelForOrigin, DEFAULT_ORIGIN } from '@/main/config'
|
||||
import { classifyNavigation } from '@/main/navigation'
|
||||
import { DEV, identityForOrigin, LOCAL, PROD, STAGING } from '../../scripts/channels'
|
||||
|
||||
/**
|
||||
* The packager stamps a bundle name/id from scripts/channels.ts while the app
|
||||
* names itself at runtime from config.ts. Nothing linked the two, so they were
|
||||
* free to drift — which is how the share build ended up packaging a
|
||||
* dev-pointed app under the production identity.
|
||||
*/
|
||||
describe('channel identity', () => {
|
||||
it('packages every channel under the name it gives itself at runtime', () => {
|
||||
for (const identity of [PROD, STAGING, DEV, LOCAL]) {
|
||||
expect(APP_NAME_FOR_CHANNEL[channelForOrigin(identity.origin)]).toBe(identity.name)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves an unset baked origin to the same channel as the app default', () => {
|
||||
expect(identityForOrigin('')).toBe(PROD)
|
||||
expect(PROD.origin).toBe(DEFAULT_ORIGIN)
|
||||
expect(channelForOrigin(DEFAULT_ORIGIN)).toBe('prod')
|
||||
})
|
||||
|
||||
it('treats an unrecognized (self-hosted) origin as production', () => {
|
||||
expect(identityForOrigin('https://sim.acme-corp.example')).toBe(PROD)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The shipped default must be an origin the environment SERVES. When it was
|
||||
* the apex (which 301s to www) the window sat one origin away from appOrigin,
|
||||
* so `fromAuthSurface` was never true and social login was misread as an
|
||||
* integration connect — the "finish connecting in your browser" dialog
|
||||
* instead of the login handoff.
|
||||
*/
|
||||
describe('social login from the shipped default origin', () => {
|
||||
it('hands off to the system browser instead of offering a connect', () => {
|
||||
expect(
|
||||
classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', {
|
||||
appOrigin: DEFAULT_ORIGIN,
|
||||
currentUrl: `${DEFAULT_ORIGIN}/login`,
|
||||
})
|
||||
).toBe('idp-system-login')
|
||||
})
|
||||
|
||||
it('still offers the connect dialog for an integration connect mid-workspace', () => {
|
||||
expect(
|
||||
classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', {
|
||||
appOrigin: DEFAULT_ORIGIN,
|
||||
currentUrl: `${DEFAULT_ORIGIN}/workspace/ws1/w/wf1`,
|
||||
})
|
||||
).toBe('idp-system-connect')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
APP_NAME_FOR_CHANNEL,
|
||||
canonicalOrigin,
|
||||
channelForOrigin,
|
||||
createConfigStore,
|
||||
DEFAULT_ORIGIN,
|
||||
@@ -58,6 +59,27 @@ describe('partitionForOrigin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalOrigin', () => {
|
||||
it('rewrites the pre-www production origin', () => {
|
||||
expect(canonicalOrigin('https://sim.ai')).toBe('https://www.sim.ai')
|
||||
})
|
||||
|
||||
it('leaves every other origin alone', () => {
|
||||
expect(canonicalOrigin('https://www.sim.ai')).toBe('https://www.sim.ai')
|
||||
expect(canonicalOrigin('https://www.staging.sim.ai')).toBe('https://www.staging.sim.ai')
|
||||
expect(canonicalOrigin('https://sim.acme-corp.example')).toBe('https://sim.acme-corp.example')
|
||||
expect(canonicalOrigin('http://localhost:3000')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('keeps a rewritten install on its existing cookie partition', () => {
|
||||
// The whole point of rewriting rather than leaving the apex in place: the
|
||||
// apex no longer equals DEFAULT_ORIGIN, so an un-rewritten install would
|
||||
// be moved to a fresh empty jar and silently signed out on update.
|
||||
expect(partitionForOrigin(canonicalOrigin('https://sim.ai'))).toBe('persist:sim')
|
||||
expect(partitionForOrigin('https://sim.ai')).not.toBe('persist:sim')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSafeInternalPath', () => {
|
||||
it('accepts absolute same-origin paths with query', () => {
|
||||
expect(isSafeInternalPath('/workspace/ws1?tab=logs')).toBe(true)
|
||||
@@ -76,6 +98,26 @@ describe('isSafeInternalPath', () => {
|
||||
})
|
||||
|
||||
describe('createConfigStore', () => {
|
||||
it('rewrites a stored pre-www production origin and persists it immediately', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.ai', zoomLevel: 1.5 }))
|
||||
|
||||
const store = createConfigStore(filePath, {})
|
||||
|
||||
expect(store.getOrigin()).toBe('https://www.sim.ai')
|
||||
expect(store.get('zoomLevel')).toBe(1.5)
|
||||
// Written without waiting for the debounce, so a crash cannot leave the
|
||||
// file pointing somewhere the running app is not.
|
||||
expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://www.sim.ai')
|
||||
})
|
||||
|
||||
it('leaves a deliberately configured origin untouched', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.acme-corp.example' }))
|
||||
|
||||
expect(createConfigStore(filePath, {}).getOrigin()).toBe('https://sim.acme-corp.example')
|
||||
})
|
||||
|
||||
it('round-trips settings through disk', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
const store = createConfigStore(filePath, {})
|
||||
@@ -103,6 +145,20 @@ describe('createConfigStore', () => {
|
||||
expect(reloaded.getOrigin()).toBe('https://self-hosted.example')
|
||||
})
|
||||
|
||||
it('canonicalizes the apex production origin on setOrigin, not just on load', () => {
|
||||
// Entering https://sim.ai mid-session must not persist the apex: the
|
||||
// running session would use the wrong cookie partition and misclassify
|
||||
// social login until the next launch's load-time rewrite repaired it.
|
||||
const filePath = tempSettingsPath()
|
||||
const store = createConfigStore(filePath, {})
|
||||
const result = store.setOrigin('https://sim.ai')
|
||||
expect(result).toEqual({ ok: true, origin: 'https://www.sim.ai' })
|
||||
expect(store.getOrigin()).toBe('https://www.sim.ai')
|
||||
|
||||
const reloaded = createConfigStore(filePath, {})
|
||||
expect(reloaded.getOrigin()).toBe('https://www.sim.ai')
|
||||
})
|
||||
|
||||
it('recovers from a corrupted settings file', () => {
|
||||
const filePath = tempSettingsPath()
|
||||
writeFileSync(filePath, '{not json')
|
||||
|
||||
@@ -22,6 +22,9 @@ const logger = createLogger('DesktopConfig')
|
||||
* origins for exact equality — so an apex origin here silently leaves every
|
||||
* page off-origin, which reclassifies social login as an integration connect
|
||||
* and strands the sign-in in the browser with no way back.
|
||||
*
|
||||
* Changing this does NOT reach installs that already persisted the old value —
|
||||
* see {@link canonicalOrigin}.
|
||||
*/
|
||||
function isValidBakedOrigin(origin: string | undefined): origin is string {
|
||||
if (!origin) return false
|
||||
@@ -149,6 +152,33 @@ export function validateOriginInput(raw: string): OriginValidation {
|
||||
return { ok: false, error: 'Server URL must use HTTPS (HTTP is allowed for localhost only)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored origins that must be rewritten on load, because the app can no
|
||||
* longer work correctly while pointed at them.
|
||||
*
|
||||
* Every install that shipped before DEFAULT_ORIGIN moved to www persisted the
|
||||
* apex on first launch, and a build-time change alone never reaches them —
|
||||
* the stored value wins over the default. Two things break if they keep it:
|
||||
* social login is misclassified as an integration connect (see DEFAULT_ORIGIN),
|
||||
* and, because the apex no longer equals DEFAULT_ORIGIN, partitionForOrigin
|
||||
* would move them off `persist:sim` onto a fresh, empty jar — signing out
|
||||
* every existing user on update. Rewriting to the canonical origin fixes the
|
||||
* first and avoids the second: www.sim.ai IS the new DEFAULT_ORIGIN, so the
|
||||
* partition stays `persist:sim` and the session survives.
|
||||
*
|
||||
* Keyed on the exact stored string, not a host pattern: this rewrites what a
|
||||
* previous BUILD wrote, never a deliberate operator choice like a self-hosted
|
||||
* origin that happens to redirect.
|
||||
*/
|
||||
const ORIGIN_REWRITES: Readonly<Record<string, string>> = {
|
||||
'https://sim.ai': 'https://www.sim.ai',
|
||||
}
|
||||
|
||||
/** Applies ORIGIN_REWRITES to a validated origin. */
|
||||
export function canonicalOrigin(origin: string): string {
|
||||
return ORIGIN_REWRITES[origin] ?? origin
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a server origin to its cookie/storage partition. Each origin gets an
|
||||
* isolated persistent partition so sessions never leak across instances.
|
||||
@@ -229,11 +259,20 @@ export function createConfigStore(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): ConfigStore {
|
||||
let settings: DesktopSettings = { ...DEFAULT_SETTINGS }
|
||||
let rewroteOrigin = false
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<DesktopSettings>
|
||||
settings = { ...DEFAULT_SETTINGS, ...parsed }
|
||||
const validated = validateOriginInput(settings.origin)
|
||||
settings.origin = validated.ok ? validated.origin : DEFAULT_ORIGIN
|
||||
const loaded = validated.ok ? validated.origin : DEFAULT_ORIGIN
|
||||
settings.origin = canonicalOrigin(loaded)
|
||||
rewroteOrigin = settings.origin !== loaded
|
||||
if (rewroteOrigin) {
|
||||
logger.info('Rewrote stored server origin to its canonical form', {
|
||||
from: loaded,
|
||||
to: settings.origin,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
settings = { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
@@ -271,6 +310,14 @@ export function createConfigStore(
|
||||
saveTimer.unref?.()
|
||||
}
|
||||
|
||||
// Persist a rewrite immediately rather than waiting for the next debounced
|
||||
// write: the partition and every navigation check already use the canonical
|
||||
// value from here on, so a crash before the next save must not leave the
|
||||
// file claiming an origin the running app is no longer using.
|
||||
if (rewroteOrigin) {
|
||||
writeNow()
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
getOrigin() {
|
||||
@@ -281,14 +328,22 @@ export function createConfigStore(
|
||||
},
|
||||
setOrigin(raw: string) {
|
||||
const validated = validateOriginInput(raw)
|
||||
if (validated.ok) {
|
||||
settings.origin = validated.origin
|
||||
// Not debounced: changing the origin tears the session down and
|
||||
// reloads, so a pending write could be lost on the way out — and this
|
||||
// is the one setting whose loss strands the app on the wrong server.
|
||||
writeNow()
|
||||
if (!validated.ok) {
|
||||
return validated
|
||||
}
|
||||
return validated
|
||||
// Same canonicalization the load path applies (ORIGIN_REWRITES).
|
||||
// Without it, entering the apex origin mid-session persists it and
|
||||
// immediately drives the wrong cookie partition and social-login
|
||||
// classification for the rest of the session — the load-time rewrite
|
||||
// only repairs it on the next launch. The canonical origin is also
|
||||
// returned so the caller sees what was actually stored.
|
||||
const origin = canonicalOrigin(validated.origin)
|
||||
settings.origin = origin
|
||||
// Not debounced: changing the origin tears the session down and
|
||||
// reloads, so a pending write could be lost on the way out — and this
|
||||
// is the one setting whose loss strands the app on the wrong server.
|
||||
writeNow()
|
||||
return { ok: true, origin }
|
||||
},
|
||||
get(key) {
|
||||
return settings[key]
|
||||
|
||||
@@ -4735,7 +4735,7 @@ export const UserTable: ToolCatalogEntry = {
|
||||
newType: {
|
||||
type: 'string',
|
||||
description:
|
||||
"New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn't match one of them.",
|
||||
'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
@@ -4744,7 +4744,7 @@ export const UserTable: ToolCatalogEntry = {
|
||||
options: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list: options kept by name keep their cells, and cells holding a removed option are cleared. Max 100.',
|
||||
'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
outputColumnNames: {
|
||||
|
||||
@@ -4386,7 +4386,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
newType: {
|
||||
type: 'string',
|
||||
description:
|
||||
"New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn't match one of them.",
|
||||
'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
@@ -4395,7 +4395,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
options: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list: options kept by name keep their cells, and cells holding a removed option are cleared. Max 100.',
|
||||
'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
|
||||
@@ -67,6 +67,30 @@ describe('grep', () => {
|
||||
expect(matches[1]).toMatchObject({ path: 'a.txt', line: 3, content: 'hello' })
|
||||
})
|
||||
|
||||
it('truncates oversized matched lines so a minified single-line file cannot return whole', () => {
|
||||
const giantLine = `{"status":"error"}${'x'.repeat(50_000)}`
|
||||
const files = vfsFromEntries([['internal/tool-results/run.json', giantLine]])
|
||||
const matches = grep(files, 'status', undefined, { outputMode: 'content' }) as Array<{
|
||||
content: string
|
||||
}>
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0].content.length).toBeLessThan(2_200)
|
||||
expect(matches[0].content).toContain('[line truncated: 50018 chars total]')
|
||||
})
|
||||
|
||||
it('truncates oversized context lines around a match', () => {
|
||||
const files = vfsFromEntries([['a.txt', `before${'y'.repeat(10_000)}\nneedle\nafter`]])
|
||||
const matches = grep(files, 'needle', undefined, {
|
||||
outputMode: 'content',
|
||||
context: 1,
|
||||
}) as Array<{
|
||||
content: string
|
||||
}>
|
||||
expect(matches).toHaveLength(3)
|
||||
expect(matches[0].content.length).toBeLessThan(2_200)
|
||||
expect(matches[1].content).toBe('needle')
|
||||
})
|
||||
|
||||
it('strips CR before end-of-line matching on CRLF content', () => {
|
||||
const files = vfsFromEntries([['x.txt', 'foo\r\n']])
|
||||
const matches = grep(files, 'foo$', undefined, { outputMode: 'content' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import micromatch from 'micromatch'
|
||||
import {
|
||||
compileLinearRegex,
|
||||
@@ -9,6 +10,23 @@ import {
|
||||
|
||||
const logger = createLogger('VfsOperations')
|
||||
|
||||
/**
|
||||
* Maximum characters returned for one matched (or context) line in grep
|
||||
* `content` mode. Minified single-line files (workflow JSON, persisted tool
|
||||
* results) make one match the entire file otherwise — a single grep can then
|
||||
* blow through the inline tool-result budget and the caller's context window.
|
||||
*/
|
||||
const GREP_MATCH_MAX_CHARS = 2_000
|
||||
|
||||
/**
|
||||
* Truncates one grep match line to {@link GREP_MATCH_MAX_CHARS}, noting the
|
||||
* original length so the caller knows the line continues.
|
||||
*/
|
||||
function capGrepMatchContent(line: string): string {
|
||||
if (line.length <= GREP_MATCH_MAX_CHARS) return line
|
||||
return truncate(line, GREP_MATCH_MAX_CHARS, ` … [line truncated: ${line.length} chars total]`)
|
||||
}
|
||||
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
line: number
|
||||
@@ -221,14 +239,14 @@ export function grep(
|
||||
matches.push({
|
||||
path: filePath,
|
||||
line: showLineNumbers ? j + 1 : 0,
|
||||
content: lines[j],
|
||||
content: capGrepMatchContent(lines[j]),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
matches.push({
|
||||
path: filePath,
|
||||
line: showLineNumbers ? i + 1 : 0,
|
||||
content: lines[i],
|
||||
content: capGrepMatchContent(lines[i]),
|
||||
})
|
||||
}
|
||||
if (matches.length >= maxResults) return matches
|
||||
|
||||
Reference in New Issue
Block a user