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:
Siddharth Ganesan
2026-07-29 16:39:38 -07:00
committed by GitHub
parent 4f776b7a13
commit 1a23438bb7
10 changed files with 429 additions and 15 deletions
+1 -1
View File
@@ -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 .",
+82
View File
@@ -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
}
+124
View File
@@ -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')
})
})
+56
View File
@@ -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')
+63 -8
View File
@@ -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' })
+20 -2
View File
@@ -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