Skip to content

Commit 9984a7a

Browse files
fix(setup): isolate standalone compose installs
1 parent 084bb54 commit 9984a7a

8 files changed

Lines changed: 219 additions & 44 deletions

File tree

.github/workflows/publish-sim-setup.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ permissions:
1616

1717
concurrency:
1818
group: publish-sim-setup-${{ github.ref }}
19-
cancel-in-progress: true
19+
cancel-in-progress: false
2020

2121
jobs:
2222
publish-npm:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { legacyComposeProjectName, standaloneComposeProjectName } from './compose-project'
3+
4+
describe('standaloneComposeProjectName', () => {
5+
it('is stable for one installation directory', () => {
6+
expect(standaloneComposeProjectName('/srv/one/sim')).toBe(
7+
standaloneComposeProjectName('/srv/one/sim')
8+
)
9+
})
10+
11+
it('isolates installations that share the same directory basename', () => {
12+
expect(standaloneComposeProjectName('/srv/one/sim')).not.toBe(
13+
standaloneComposeProjectName('/srv/two/sim')
14+
)
15+
})
16+
17+
it('produces a valid Compose project name without exposing the path', () => {
18+
expect(standaloneComposeProjectName('/Users/example/Customer Project/sim')).toMatch(
19+
/^sim-[0-9a-f]{12}$/
20+
)
21+
})
22+
23+
it('retains the directory-derived name for legacy installations', () => {
24+
expect(legacyComposeProjectName('/srv/Sim.Demo')).toBe('simdemo')
25+
expect(() => legacyComposeProjectName('/srv/---')).toThrow(/Cannot derive/)
26+
})
27+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createHash } from 'node:crypto'
2+
import path from 'node:path'
3+
4+
/** Returns the stable Docker Compose project name for a new standalone installation. */
5+
export function standaloneComposeProjectName(root: string): string {
6+
const digest = createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 12)
7+
return `sim-${digest}`
8+
}
9+
10+
/** Reproduces Compose's directory-derived identity for installs created before names were stored. */
11+
export function legacyComposeProjectName(root: string): string {
12+
const name = path
13+
.basename(path.resolve(root))
14+
.toLowerCase()
15+
.replace(/[^a-z0-9_-]/g, '')
16+
if (!/^[a-z0-9]/.test(name)) {
17+
throw new Error(
18+
`Cannot derive the existing Compose project name from ${root}; set COMPOSE_PROJECT_NAME in its .env file.`
19+
)
20+
}
21+
return name
22+
}

packages/sim-setup/src/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function isStandaloneInstall(candidate: string): boolean {
5353
return readFileSync(composeFile, 'utf8').includes(SIM_COMPOSE_MARKER)
5454
}
5555

56-
function directoryOverride(args: readonly string[]): string | null {
56+
export function directoryOverride(args: readonly string[]): string | null {
5757
const equalsArg = args.find((arg) => arg.startsWith('--dir='))
5858
if (equalsArg) {
5959
const value = equalsArg.slice('--dir='.length)

packages/sim-setup/src/index.ts

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,18 @@ const SETUP_FEATURES =
1010
'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | llm | integration <slug>'
1111

1212
const USAGE = `Usage:
13-
npx @sim/setup run the setup wizard
14-
npx @sim/setup [--quick] [--dir <path>] create a Compose installation
15-
npx @sim/setup config show configured capabilities and integrations
16-
npx @sim/setup add <feature> configure ${SETUP_FEATURES}
17-
npx @sim/setup doctor [--fix] [--json] check your setup
18-
npx @sim/setup start | stop | restart bring your install up / down / cycle
19-
npx @sim/setup update pull and apply Compose images
20-
npx @sim/setup status show what's installed and healthy
21-
npx @sim/setup logs follow logs
22-
npx @sim/setup down remove containers (data kept)
23-
npx @sim/setup reset archive .env + wipe managed data
13+
sim-setup [--quick] [--dir <path>] [--mode compose|dev|k8s]
14+
sim-setup config show configured capabilities and integrations
15+
sim-setup add <feature> configure ${SETUP_FEATURES}
16+
sim-setup doctor [--fix] [--json] check your setup
17+
sim-setup start | stop | restart bring your install up / down / cycle
18+
sim-setup update pull/rebuild and apply Compose images
19+
sim-setup status show what's installed and healthy
20+
sim-setup logs follow logs
21+
sim-setup down remove containers (data kept)
22+
sim-setup reset archive .env + wipe managed data
2423
25-
Inside a Sim source checkout, use the repository command:
26-
bun run sim-setup [--quick] [--mode compose|dev|k8s]
27-
bun run sim-setup config show configured capabilities and integrations
28-
bun run sim-setup add <feature> configure ${SETUP_FEATURES}
29-
bun run sim-setup doctor [--fix] [--json] check your setup
30-
bun run sim-setup start | stop | restart bring your install up / down / cycle
31-
bun run sim-setup update pull/rebuild and apply Compose images
32-
bun run sim-setup status what's installed and healthy
33-
bun run sim-setup logs follow logs
34-
bun run sim-setup down remove containers (data kept)
35-
bun run sim-setup reset archive .env + wipe managed data`
24+
Note: dev and k8s modes require a Sim source checkout.`
3625

3726
async function main(): Promise<void> {
3827
const invocation = parseSetupArguments(process.argv.slice(2))

packages/sim-setup/src/lifecycle.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { tmpdir } from 'node:os'
44
import path from 'node:path'
55
import { describe, expect, it } from 'vitest'
66
import { ensureProductionComposeFile } from './compose-asset'
7-
import { getComposeUpdateMode, isLifecycleCommand, refreshComposeFileForUpdate } from './lifecycle'
7+
import {
8+
composeInstallFromDirectory,
9+
getComposeUpdateMode,
10+
isLifecycleCommand,
11+
refreshComposeFileForUpdate,
12+
} from './lifecycle'
813

914
describe('setup lifecycle', () => {
1015
it('recognizes update as a lifecycle command', () => {
@@ -38,4 +43,33 @@ describe('setup lifecycle', () => {
3843
rmSync(root, { recursive: true, force: true })
3944
}
4045
})
46+
47+
it('restores a downed standalone install from its persisted project name', () => {
48+
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-lifecycle-'))
49+
try {
50+
ensureProductionComposeFile({ kind: 'standalone', root, existing: false })
51+
writeFileSync(path.join(root, '.env'), 'COMPOSE_PROJECT_NAME=sim-a1b2c3d4e5f6\n')
52+
53+
expect(composeInstallFromDirectory(root, [])).toEqual({
54+
kind: 'compose',
55+
file: path.join(root, 'docker-compose.prod.yml'),
56+
dir: root,
57+
project: 'sim-a1b2c3d4e5f6',
58+
})
59+
} finally {
60+
rmSync(root, { recursive: true, force: true })
61+
}
62+
})
63+
64+
it('does not duplicate a running install restored from its directory', () => {
65+
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-lifecycle-'))
66+
try {
67+
const file = ensureProductionComposeFile({ kind: 'standalone', root, existing: false })
68+
const active = [{ kind: 'compose', file, dir: root, project: 'sim-live' }] as const
69+
70+
expect(composeInstallFromDirectory(root, active)).toBeNull()
71+
} finally {
72+
rmSync(root, { recursive: true, force: true })
73+
}
74+
})
4175
})

packages/sim-setup/src/lifecycle.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { spawnSync } from 'node:child_process'
2-
import { readFileSync } from 'node:fs'
2+
import { existsSync, readFileSync } from 'node:fs'
33
import path from 'node:path'
44
import { ensureProductionComposeFile } from './compose-asset'
5-
import { resolveSetupContextAtRoot } from './context'
5+
import { legacyComposeProjectName } from './compose-project'
6+
import { directoryOverride, resolveSetupContextAtRoot, SETUP_CONTEXT } from './context'
67
import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect'
7-
import { archiveEnvFile, archiveFile, ROOT } from './env-files'
8+
import { archiveEnvFile, archiveFile, parseEnv, ROOT } from './env-files'
89
import { SetupError } from './errors'
910
import { forwardCommands, isLocalKubeContext } from './modes/k8s'
1011
import { httpHealth } from './probes'
@@ -67,7 +68,7 @@ function dockerRun(args: string[], failMessage: string, cwd: string = ROOT): voi
6768
}
6869
}
6970

70-
interface ComposeInstall {
71+
export interface ComposeInstall {
7172
kind: 'compose'
7273
/** Absolute path to the compose file Docker recorded for the project. */
7374
file: string
@@ -158,6 +159,28 @@ function composeInstalls(): ComposeInstall[] {
158159
return installs
159160
}
160161

162+
/** Restores a setup-managed Compose target from disk even when `down` removed every container. */
163+
export function composeInstallFromDirectory(
164+
root: string,
165+
activeInstalls: readonly ComposeInstall[]
166+
): ComposeInstall | null {
167+
const file = path.join(root, 'docker-compose.prod.yml')
168+
if (!isSimComposeFile(file)) return null
169+
if (activeInstalls.some((install) => path.resolve(install.file) === path.resolve(file)))
170+
return null
171+
172+
const envFile = path.join(root, '.env')
173+
const configuredProject = existsSync(envFile)
174+
? parseEnv(readFileSync(envFile, 'utf8')).get('COMPOSE_PROJECT_NAME')
175+
: undefined
176+
return {
177+
kind: 'compose',
178+
file,
179+
dir: root,
180+
project: configuredProject || legacyComposeProjectName(root),
181+
}
182+
}
183+
161184
/**
162185
* Compose args for an op on a detected install. `-p` is not optional: without it
163186
* Compose re-derives the project from the working directory, and that name is
@@ -203,14 +226,29 @@ function k8sInstall(detection: Detection): K8sInstall | null {
203226
}
204227

205228
function detectInstalls(detection: Detection): Install[] {
206-
const installs: Install[] = [...composeInstalls()]
229+
const compose = composeInstalls()
230+
if (SETUP_CONTEXT.kind === 'standalone' && SETUP_CONTEXT.existing) {
231+
const fromDirectory = composeInstallFromDirectory(SETUP_CONTEXT.root, compose)
232+
if (fromDirectory) compose.push(fromDirectory)
233+
}
234+
const installs: Install[] = [...compose]
207235
const dev = devInstall(detection)
208236
if (dev) installs.push(dev)
209237
const k8s = k8sInstall(detection)
210238
if (k8s) installs.push(k8s)
211239
return installs
212240
}
213241

242+
/** Limits an explicit standalone `--dir` command to that installation. */
243+
function scopeInstallsToInvocation(installs: Install[]): Install[] {
244+
if (SETUP_CONTEXT.kind !== 'standalone' || directoryOverride(process.argv.slice(2)) === null) {
245+
return installs
246+
}
247+
return installs.filter(
248+
(install) => install.kind === 'compose' && path.resolve(install.dir) === SETUP_CONTEXT.root
249+
)
250+
}
251+
214252
function describeInstall(install: Install): string {
215253
if (install.kind === 'compose')
216254
return `Docker Compose (project ${install.project} in ${install.dir})`
@@ -501,7 +539,7 @@ async function reset(install: Install | null): Promise<void> {
501539

502540
async function status(): Promise<void> {
503541
const detection = await runDetection()
504-
const installs = detectInstalls(detection)
542+
const installs = scopeInstallsToInvocation(detectInstalls(detection))
505543
const docker = dockerReachable()
506544
console.log(`\n${theme.heading('◆ Sim status')}\n`)
507545
// Every container probe goes through Docker, so when the daemon is down the
@@ -540,7 +578,7 @@ async function status(): Promise<void> {
540578
export async function runLifecycle(command: LifecycleCommand): Promise<void> {
541579
if (command === 'status') return status()
542580

543-
const installs = detectInstalls(await runDetection())
581+
const installs = scopeInstallsToInvocation(detectInstalls(await runDetection()))
544582

545583
// Reset stays useful with nothing running — it still archives stray .env files.
546584
if (command === 'reset') return reset(await resolveInstall(installs))

0 commit comments

Comments
 (0)