Skip to content

Commit 7ccc86c

Browse files
committed
fix(sim-cli): version reads lazily with an embedded-host fallback
version.ts read package.json at import time; hosts that bundle the CLI into a different layout (the server's embedded CLI, Trigger.dev workers) have no manifest at the relative path, and the import-time throw took the whole Trigger.dev task graph down (dev deploy failure). Reads are now lazy and fall back to a sentinel; the published binary still reports its real version (smoke-tested in the publish lane). Also: workflow lint as an agent-cli command — the Go copilot's virtual lint.json, served by the same engine both graph writes publish. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent f5eec9a commit 7ccc86c

11 files changed

Lines changed: 136 additions & 32 deletions

File tree

apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
const { buildWorkflowLintReport } = vi.hoisted(() => ({
7+
buildWorkflowLintReport: vi.fn().mockResolvedValue({
8+
sources: ['block-1'],
9+
sinks: ['block-2'],
10+
orphanBlocks: [],
11+
emptyOutgoingPorts: [],
12+
invalidBranchPorts: [],
13+
invalidConnectionTargets: [],
14+
fieldIssues: [
15+
{
16+
blockId: 'block-2',
17+
blockName: 'Summarize emails',
18+
missingRequiredFields: ['model'],
19+
inactiveModeValues: [],
20+
},
21+
],
22+
unresolvedReferences: [],
23+
}),
24+
}))
25+
26+
vi.mock('@/lib/workflows/editing/lint-report', () => ({ buildWorkflowLintReport }))
27+
528
import {
629
agentCliHelpSection,
730
executeAgentCliCommand,
@@ -22,6 +45,7 @@ const WORKFLOW_STATE = {
2245
function runtimeWith(responses: Record<string, unknown>): AgentCliRuntime {
2346
return {
2447
workspaceId: 'ws-1',
48+
userId: 'user-1',
2549
client: {
2650
request: async <T>(path: string): Promise<T> => {
2751
const hit = responses[path]
@@ -133,6 +157,24 @@ describe('workflow grep', () => {
133157
expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails')
134158
})
135159

160+
it('lints a workflow through the shared engine with the caller scoped as subject', async () => {
161+
const match = matchAgentCliCommand(['workflow', 'lint', 'wf-1'])
162+
const result = await executeAgentCliCommand(
163+
match!,
164+
runtimeWith({ [EXPORT_PATH]: exportResponse })
165+
)
166+
expect(result.stderr).toBe('')
167+
expect(result.exitCode).toBe(0)
168+
const report = JSON.parse(result.stdout)
169+
expect(report.fieldIssues).toHaveLength(1)
170+
expect(report.summary.length).toBeGreaterThan(0)
171+
expect(buildWorkflowLintReport).toHaveBeenCalledWith(expect.anything(), {
172+
workflowId: 'wf-1',
173+
workspaceId: 'ws-1',
174+
subjectUserId: 'user-1',
175+
})
176+
})
177+
136178
it('surfaces execution errors as a failed result, never a throw', async () => {
137179
const match = matchAgentCliCommand(['workflow', 'grep', 'wf-missing', 'x'])
138180
const result = await executeAgentCliCommand(match!, runtimeWith({}))
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { WorkflowState } from '@sim/workflow-types/workflow'
2+
import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views'
3+
import {
4+
type AgentCliCommand,
5+
agentCliFail,
6+
agentCliOk,
7+
} from '@/lib/mothership/tools/handlers/agent-cli/types'
8+
import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint'
9+
import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report'
10+
11+
/**
12+
* The Go copilot served this as the virtual `workflows/{path}/lint.json` VFS
13+
* file; here it is a command. Authorization rides the v2 export fetch (a user
14+
* who cannot read the workflow gets the 403 there); the report itself is the
15+
* same engine both graph writes publish, so a lint here can never disagree
16+
* with what an edit would have reported.
17+
*/
18+
export const workflowLintCommand: AgentCliCommand = {
19+
path: ['workflow', 'lint'],
20+
summary: 'Validate one workflow: orphans, unwired ports, missing fields, unresolved references',
21+
usage: 'workflow lint <workflowId>',
22+
async execute(rest, runtime) {
23+
const workflowId = rest[0]
24+
if (!workflowId) return agentCliFail('Usage: sim workflow lint <workflowId>')
25+
const state = await fetchWorkflowState(runtime, workflowId)
26+
// double-cast-allowed: the v2 export's `state` is the serialized WorkflowState;
27+
// the lint engine reads it structurally (blocks/edges only)
28+
const graph = state as unknown as Pick<WorkflowState, 'blocks' | 'edges'>
29+
const report = await buildWorkflowLintReport(graph, {
30+
workflowId,
31+
workspaceId: runtime.workspaceId,
32+
subjectUserId: runtime.userId,
33+
})
34+
const summary = hasWorkflowLintIssues(report)
35+
? formatWorkflowLintMessage(report)
36+
: 'No lint issues found.'
37+
return agentCliOk(JSON.stringify({ summary, ...report }, null, 2))
38+
},
39+
}

apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
workflowGrepCommand,
33
workflowsGrepCommand,
44
} from '@/lib/mothership/tools/handlers/agent-cli/commands/grep'
5+
import { workflowLintCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/lint'
56
import {
67
workflowBlocksCommand,
78
workflowEdgesCommand,
@@ -22,6 +23,7 @@ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [
2223
workflowBlocksCommand,
2324
workflowEdgesCommand,
2425
workflowGrepCommand,
26+
workflowLintCommand,
2527
workflowsGrepCommand,
2628
]
2729

apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface AgentCliClient {
1818
export interface AgentCliRuntime {
1919
client: AgentCliClient
2020
workspaceId: string
21+
/** The human the command acts as — reference resolution and grants scope to them. */
22+
userId: string
2123
}
2224

2325
export interface AgentCliResult {

apps/sim/lib/mothership/tools/handlers/sim-cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export async function executeSimCli(
5656
? await executeAgentCliCommand(agentMatch, {
5757
client: createEmbeddedClient(identity),
5858
workspaceId: context.workspaceId,
59+
userId: context.userId,
5960
})
6061
: await runEmbeddedCli(args, identity)
6162
if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) {

packages/sim-cli/src/auth/device-flow.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHash, randomBytes, randomInt } from 'node:crypto'
22
import { sleep } from '../helpers'
33
import { buildUrl, REDIRECT_STATUSES, redirectEndpoint, SimApiError } from '../http/client'
4-
import { USER_AGENT } from '../version'
4+
import { userAgent } from '../version'
55

66
/**
77
* The terminal half of the CLI key handoff.
@@ -193,7 +193,7 @@ export async function pollForKey(
193193
headers: {
194194
'content-type': 'application/json',
195195
accept: 'application/json',
196-
'user-agent': USER_AGENT,
196+
'user-agent': userAgent(),
197197
},
198198
body: JSON.stringify({ request: auth.request, verifier: auth.pollSecret }),
199199
signal,

packages/sim-cli/src/http/client.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
22
import { CLI_CONTRACT } from '../contract/commands'
33
import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api'
44
import { sleep } from '../helpers'
5-
import { USER_AGENT } from '../version'
5+
import { userAgent } from '../version'
66
import {
77
formatApiErrorDetails,
88
redirectEndpoint,
@@ -496,10 +496,10 @@ describe('request identity', () => {
496496
await client().request('/api/v2/workflows')
497497

498498
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>
499-
expect(headers['user-agent']).toBe(USER_AGENT)
500-
expect(USER_AGENT).toMatch(/^sim-cli\/\d+\.\d+\.\d+/)
501-
expect(USER_AGENT).toContain(`node/${process.versions.node}`)
502-
expect(USER_AGENT).toContain(process.platform)
499+
expect(headers['user-agent']).toBe(userAgent())
500+
expect(userAgent()).toMatch(/^sim-cli\/\d+\.\d+\.\d+/)
501+
expect(userAgent()).toContain(`node/${process.versions.node}`)
502+
expect(userAgent()).toContain(process.platform)
503503
})
504504
})
505505

packages/sim-cli/src/http/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk'
22
import type { ResolvedProfile } from '../config/index'
3-
import { USER_AGENT } from '../version'
3+
import { userAgent } from '../version'
44
import { warnIfKeyOverCleartext, warnIfProxyIgnored } from './environment'
55

66
/**
@@ -511,7 +511,7 @@ export class SimClient {
511511
headers: {
512512
...(apiKey ? { 'x-api-key': apiKey } : {}),
513513
accept: 'application/json',
514-
'user-agent': USER_AGENT,
514+
'user-agent': userAgent(),
515515
...(hasBody ? { 'content-type': 'application/json' } : {}),
516516
...options.headers,
517517
},

packages/sim-cli/src/program.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import type { Command } from 'commander'
55
import { describe, expect, it } from 'vitest'
66
import { buildProgram } from './program'
7-
import { CLI_VERSION } from './version'
7+
import { cliVersion } from './version'
88

99
/** Parses argv against a program whose output and exits are captured, not taken. */
1010
async function parse(argv: string[]): Promise<{ out: string; code: string | null }> {
@@ -34,7 +34,7 @@ describe('the root version flag', () => {
3434
it('still reports the version on its own', async () => {
3535
const { out, code } = await parse(['--version'])
3636

37-
expect(out.trim()).toBe(CLI_VERSION)
37+
expect(out.trim()).toBe(cliVersion())
3838
expect(code).toBe('commander.version')
3939
})
4040

@@ -47,14 +47,14 @@ describe('the root version flag', () => {
4747
it('refuses a value instead of answering for a subcommand', async () => {
4848
const { out, code } = await parse(['workflows', 'rollback', 'wf_1', '--version', '1'])
4949

50-
expect(out).not.toContain(CLI_VERSION)
50+
expect(out).not.toContain(cliVersion())
5151
expect(code).toBe('commander.error')
5252
})
5353

5454
it('refuses the same value written with an equals sign', async () => {
5555
const { out, code } = await parse(['workflows', 'rollback', 'wf_1', '--version=1'])
5656

57-
expect(out).not.toContain(CLI_VERSION)
57+
expect(out).not.toContain(cliVersion())
5858
expect(code).toBe('commander.error')
5959
})
6060

@@ -65,15 +65,15 @@ describe('the root version flag', () => {
6565
it('refuses the bare flag typed against a subcommand', async () => {
6666
const { out, code } = await parse(['workflows', 'rollback', 'wf_1', '--version'])
6767

68-
expect(out).not.toContain(CLI_VERSION)
68+
expect(out).not.toContain(cliVersion())
6969
expect(code).toBe('commander.error')
7070
})
7171

7272
/** A root option's value is not the command name, even when it looks like one. */
7373
it('still reports the version after a root option', async () => {
7474
const { out, code } = await parse(['--profile', 'workflows', '--version'])
7575

76-
expect(out.trim()).toBe(CLI_VERSION)
76+
expect(out.trim()).toBe(cliVersion())
7777
expect(code).toBe('commander.version')
7878
})
7979

packages/sim-cli/src/program.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
buildGeneratedCommands,
1111
refuseHelpAfterUnknownCommand,
1212
} from './runtime/build'
13-
import { CLI_VERSION } from './version'
13+
import { cliVersion } from './version'
1414

1515
/** Root program description, shared by `--help` and the generated docs. */
1616
export const PROGRAM_DESCRIPTION = 'Talk to the Sim API from your terminal'
@@ -105,7 +105,11 @@ function addVersionOption(program: Command): void {
105105
'error: --version reports the Sim CLI version and takes no value. A command that acts on a deployment version reads it from --to-version.'
106106
)
107107
})
108-
program.version(CLI_VERSION, '-V, --version [none]', 'output the version number (takes no value)')
108+
program.version(
109+
cliVersion(),
110+
'-V, --version [none]',
111+
'output the version number (takes no value)'
112+
)
109113
}
110114

111115
/**

0 commit comments

Comments
 (0)