Skip to content

Commit b86cbf8

Browse files
committed
refactor(cli): share the path predicate and other-spellings list
1 parent 2f00eca commit b86cbf8

8 files changed

Lines changed: 73 additions & 70 deletions

File tree

‎apps/sim/lib/workflows/editing/validation.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,7 @@ export function validateValueForSubBlockType(
614614
}
615615

616616
case 'file-upload': {
617-
// A multi-file field stores the editor's list of file objects; removing its last file
618-
// stores null, so an empty list clears it the same way.
617+
// Removing the editor's last file stores null, so an empty list clears the field too.
619618
if (subBlockConfig.multiple && Array.isArray(value)) {
620619
if (value.length === 0) return { valid: true, value: null }
621620
const index = value.findIndex((file) => !isStoredFile(file))
@@ -639,9 +638,7 @@ export function validateValueForSubBlockType(
639638
blockType,
640639
field: fieldName,
641640
value,
642-
error: subBlockConfig.multiple
643-
? `Invalid file-upload value for field "${fieldName}" - expected a list of objects with "name" and "path" properties, or null`
644-
: `Invalid file-upload value for field "${fieldName}" - expected one object with "name" and "path" properties, or null`,
641+
error: `Invalid file-upload value for field "${fieldName}" - expected ${subBlockConfig.multiple ? 'a list of objects' : 'one object'} with "name" and "path" properties, or null`,
645642
},
646643
}
647644
}

‎apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
bulkArchiveWorkspaceFileItems,
88
loadWorkspaceFileOperationContext,
99
} from '@/lib/uploads/contexts/workspace'
10-
import { isWorkspaceFileVfsPath } from '@/lib/vfs/path'
1110
import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case'
12-
import { resolveWorkspaceFileVfsPath } from '@/lib/workspace-files/application/file-context'
11+
import {
12+
namesWorkspaceFileVfsPath,
13+
resolveWorkspaceFileVfsPath,
14+
} from '@/lib/workspace-files/application/file-context'
1315
import { fileOperations } from '@/lib/workspace-files/application/operations'
1416
import {
1517
MAX_WORKSPACE_FILE_BULK_REQUEST_IDS,
@@ -41,10 +43,6 @@ function normalizeSelection(input: ArchiveWorkspaceFileItemsInput) {
4143
}
4244
}
4345

44-
function namesVfsPath(input: ArchiveWorkspaceFileItemsInput, reference: string): boolean {
45-
return input.acceptVfsPath === true && isWorkspaceFileVfsPath(reference)
46-
}
47-
4846
/**
4947
* Turns each path among the selected files into its file's id, all before anything is archived,
5048
* so one path that names no single live file refuses the whole request. Runs only after the
@@ -53,19 +51,19 @@ function namesVfsPath(input: ArchiveWorkspaceFileItemsInput, reference: string):
5351
*/
5452
async function resolveFileReferences(
5553
workspaceId: string,
56-
input: ArchiveWorkspaceFileItemsInput,
54+
acceptVfsPath: boolean | undefined,
5755
references: string[]
5856
): Promise<string[]> {
59-
const paths = references.filter((reference) => namesVfsPath(input, reference))
60-
if (paths.length > MAX_WORKSPACE_FILE_BULK_REQUEST_PATHS) {
57+
const isPath = references.map((reference) => namesWorkspaceFileVfsPath(acceptVfsPath, reference))
58+
if (isPath.filter(Boolean).length > MAX_WORKSPACE_FILE_BULK_REQUEST_PATHS) {
6159
throw new OrchestrationError(
6260
'validation',
6361
`Bulk file operations resolve at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_PATHS} file paths per request; pass file ids for the rest`
6462
)
6563
}
6664
const resolved: string[] = []
67-
for (const reference of references) {
68-
if (!namesVfsPath(input, reference)) {
65+
for (const [index, reference] of references.entries()) {
66+
if (!isPath[index]) {
6967
resolved.push(reference)
7068
continue
7169
}
@@ -102,7 +100,7 @@ async function executeArchiveWorkspaceFileItems({
102100
`Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs`
103101
)
104102
}
105-
const fileIds = await resolveFileReferences(context.workspaceId, input, references)
103+
const fileIds = await resolveFileReferences(context.workspaceId, input.acceptVfsPath, references)
106104

107105
await assertWorkspaceFileItemsBelongToWorkspace({
108106
workspaceId: context.workspaceId,
@@ -134,9 +132,13 @@ async function resolveArchiveContext({ input }: { input: ArchiveWorkspaceFileIte
134132
const context = await loadWorkspaceFileOperationContext(input.workspaceId)
135133
if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
136134
const { fileIds, folderIds } = normalizeSelection(input)
137-
const sole = fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined
138-
// A path is not yet an id here, so it can never match a delegation scoped to one file.
139-
return { ...context, fileId: sole === undefined || namesVfsPath(input, sole) ? undefined : sole }
135+
const [sole] = fileIds
136+
const namesOneFileById =
137+
sole !== undefined &&
138+
fileIds.length === 1 &&
139+
folderIds.length === 0 &&
140+
!namesWorkspaceFileVfsPath(input.acceptVfsPath, sole)
141+
return { ...context, fileId: namesOneFileById ? sole : undefined }
140142
}
141143

142144
export const archiveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({

‎apps/sim/lib/workspace-files/application/file-context.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,16 @@ function requireWorkspaceFileContext<C extends ActiveWorkspaceFileContext>(
8787
return canonical
8888
}
8989

90+
/** Whether a caller that may have opted in with `acceptVfsPath` named a file by its VFS path. */
91+
export function namesWorkspaceFileVfsPath(
92+
acceptVfsPath: boolean | undefined,
93+
reference: string
94+
): boolean {
95+
return acceptVfsPath === true && isWorkspaceFileVfsPath(reference)
96+
}
97+
9098
function namesVfsPath(input: WorkspaceFileTargetInput): boolean {
91-
return input.acceptVfsPath === true && isWorkspaceFileVfsPath(input.fileId)
99+
return namesWorkspaceFileVfsPath(input.acceptVfsPath, input.fileId)
92100
}
93101

94102
/**

‎apps/sim/lib/workspace-files/render-document-for-vision.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ const pageResultSchema = z.object({
1515

1616
function requestedPages(pages?: string): { first: number; last: number } | undefined {
1717
if (pages === undefined) return undefined
18-
const match = /^(\d+)(?:-(\d+))?$/.exec(pages.trim())
19-
if (!match && /^\d+(?:-\d+)?(?:\s*,\s*\d+(?:-\d+)?)+$/.test(pages.trim())) {
20-
throw new OrchestrationError(
21-
'validation',
22-
`"${pages.trim()}" lists several pages; pass one page or one contiguous range per read, such as 5 or 2-5, and read the others separately`
23-
)
24-
}
25-
if (!match)
18+
const trimmed = pages.trim()
19+
const match = /^(\d+)(?:-(\d+))?$/.exec(trimmed)
20+
if (!match) {
21+
if (/^\d+(?:-\d+)?(?:\s*,\s*\d+(?:-\d+)?)+$/.test(trimmed)) {
22+
throw new OrchestrationError(
23+
'validation',
24+
`"${trimmed}" lists several pages; pass one page or one contiguous range per read, such as 5 or 2-5, and read the others separately`
25+
)
26+
}
2627
throw new OrchestrationError('validation', 'Pages must be a page number or range, such as 2-5')
28+
}
2729
const first = Number(match[1])
2830
const last = Number(match[2] ?? match[1])
2931
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last) || first < 1 || last < first) {

‎packages/sim-cli/src/contract/types.ts‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,14 @@ export interface FlagSpec {
4747
* Other names this flag answers to because callers reach for them: the request field's own
4848
* name behind a shortened flag (`--row-ids` for `--row`), or the noun a sibling command uses
4949
* (`--ids`, `--paths` for `--file-ids`). Only for a spelling that can mean nothing but this
50-
* field on this command.
51-
*
52-
* Hidden from help and the generated docs like a {@link renamedFrom} spelling, but silent,
53-
* since nothing was renamed. Combining one with the documented spelling is refused.
50+
* field on this command. Hidden like a {@link renamedFrom} spelling, but silent.
5451
*/
5552
hiddenAliases?: readonly string[]
5653
/**
5754
* The list holds workspace file ids or `files/…` paths, so an inline value written as a JSON
5855
* array (`["wf_a","wf_b"]`) or joined with commas (`wf_a,wf_b`) is read as its entries.
59-
*
60-
* Unambiguous only because of what a file reference can be: an id is `[A-Za-z0-9_-]` and a
61-
* path starts with `files/`, so neither starts with `[`. A comma splits only a value with no
62-
* `/`, which no id contains and no path lacks; a path keeps a comma in a file name.
56+
* Unambiguous because an id is `[A-Za-z0-9_-]` and a path starts with `files/`: neither starts
57+
* with `[`, and a comma splits only a value with no `/`, so a path keeps a comma in its name.
6358
*/
6459
fileReferences?: true
6560
/**

‎packages/sim-cli/src/runtime/execute.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
cursorSlot,
2222
flagNameFor,
2323
isProfileWorkspacePath,
24+
otherSpellings,
2425
PROFILE_INJECTED_FIELD,
2526
trailingListField,
2627
} from './request'
@@ -377,11 +378,7 @@ function foldOtherSpellings(
377378
): void {
378379
for (const [field, flag] of Object.entries(commandSpec.flags ?? {})) {
379380
const current = flagNameFor(operation, field)
380-
const spellings = [
381-
...(flag.renamedFrom ?? []).map((name) => ({ name, renamed: true })),
382-
...(flag.hiddenAliases ?? []).map((name) => ({ name, renamed: false })),
383-
]
384-
for (const { name, renamed } of spellings) {
381+
for (const { name, renamed } of otherSpellings(flag)) {
385382
const supplied = flags[camel(name)]
386383
if (supplied === undefined) continue
387384
if (flags[camel(current)] !== undefined) {

‎packages/sim-cli/src/runtime/options.ts‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { type Command, Option } from 'commander'
2-
import type { CommandSpec, FlagSpec } from '../contract/types'
2+
import type { CommandSpec } from '../contract/types'
33
import type { V2OperationName } from '../generated/v2-api'
44
import {
55
cursorSlot,
66
type FieldSpec,
77
flagNameFor,
88
flagSpecFor,
9+
otherSpellings,
910
PROFILE_INJECTED_FIELD,
1011
pathFlagNameFor,
1112
takesJson,
@@ -122,11 +123,6 @@ function withoutWireVocabulary(documented: string): string {
122123
const NON_PAGINATED_LIMIT_HINT =
123124
' (caps a --filter match only; omit it to act on every match, and note 0 is not accepted)'
124125

125-
/** The undocumented spellings a flag also answers to: former names, then hidden aliases. */
126-
function hiddenSpellings(flag: FlagSpec): readonly string[] {
127-
return [...(flag.renamedFrom ?? []), ...(flag.hiddenAliases ?? [])]
128-
}
129-
130126
function addFieldOption(
131127
command: Command,
132128
operation: V2OperationName,
@@ -201,7 +197,7 @@ function addFieldOption(
201197
if (!flag.boolean || flag.negatable) {
202198
command.option(`--no-${name}`, `Send --${name} as false`)
203199
}
204-
for (const previous of hiddenSpellings(flag)) {
200+
for (const { name: previous } of otherSpellings(flag)) {
205201
command.addOption(new Option(`--${previous}`).hideHelp())
206202
if (!flag.boolean || flag.negatable)
207203
command.addOption(new Option(`--no-${previous}`).hideHelp())
@@ -246,7 +242,7 @@ function addFieldOption(
246242
: ''
247243
}${literalNull ? literalNullHint(documented, name) : ''}`
248244

249-
const otherSpellings = hiddenSpellings(flag)
245+
const hiddenNames = otherSpellings(flag).map(({ name }) => name)
250246
const option = new Option(`${short}--${name} ${placeholder}`, describe)
251247
if (flag.hidden) option.hideHelp()
252248
if (choices && !takesList) option.choices([...choices])
@@ -257,12 +253,12 @@ function addFieldOption(
257253
// spelling or trailing arguments onto the flag, so a required field supplied
258254
// either way would be rejected. The requirement is not lost: `buildRequest`
259255
// raises it once every spelling has had its chance.
260-
if (descriptor.required && otherSpellings.length === 0 && !flag.trailing) {
256+
if (descriptor.required && hiddenNames.length === 0 && !flag.trailing) {
261257
option.makeOptionMandatory()
262258
}
263259
command.addOption(option)
264260

265-
for (const previous of otherSpellings) {
261+
for (const previous of hiddenNames) {
266262
const hidden = new Option(`--${previous} ${placeholder}`).hideHelp()
267263
if (choices && !takesList) hidden.choices([...choices])
268264
command.addOption(hidden)

‎packages/sim-cli/src/runtime/request.ts‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec
6161
return CLI_CONTRACT[operation]?.flags?.[field] ?? {}
6262
}
6363

64+
/** The undocumented spellings a flag also answers to: former names, then hidden aliases. */
65+
export function otherSpellings(flag: FlagSpec): { name: string; renamed: boolean }[] {
66+
return [
67+
...(flag.renamedFrom ?? []).map((name) => ({ name, renamed: true })),
68+
...(flag.hiddenAliases ?? []).map((name) => ({ name, renamed: false })),
69+
]
70+
}
71+
6472
/** The list field whose values may also follow the command as arguments, if any. */
6573
export function trailingListField(commandSpec: CommandSpec): string | undefined {
6674
return Object.entries(commandSpec.flags ?? {}).find(([, flag]) => flag.trailing)?.[0]
@@ -278,25 +286,23 @@ export async function readArgumentSource(
278286
* misread a single id or path.
279287
*/
280288
function fileReferenceEntries(value: string): string[] {
281-
if (value.startsWith('[')) {
282-
let parsed: unknown
283-
try {
284-
parsed = JSON.parse(value)
285-
} catch {
286-
return [value]
287-
}
288-
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
289-
return parsed.map((entry) => entry.trim()).filter(Boolean)
290-
}
291-
return [value]
292-
}
293-
if (value.includes(',') && !value.includes('/')) {
294-
return value
295-
.split(',')
296-
.map((entry) => entry.trim())
297-
.filter(Boolean)
289+
const entries = value.startsWith('[')
290+
? jsonStringArray(value)
291+
: value.includes(',') && !value.includes('/')
292+
? value.split(',')
293+
: undefined
294+
return entries ? entries.map((entry) => entry.trim()).filter(Boolean) : [value]
295+
}
296+
297+
function jsonStringArray(value: string): string[] | undefined {
298+
try {
299+
const parsed: unknown = JSON.parse(value)
300+
return Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')
301+
? parsed
302+
: undefined
303+
} catch {
304+
return undefined
298305
}
299-
return [value]
300306
}
301307

302308
/** A manifest line that carries no value: blank, or a `#` comment. */

0 commit comments

Comments
 (0)