Skip to content

Commit 2869feb

Browse files
mishushakovclaude
andauthored
feat(cli): add config override flags to template migrate (#1494)
Adds override flags to `e2b template migrate` so the generated SDK files don't have to inherit everything from `e2b.toml`: `--name`/`-n` (template name), `--cmd`/`-c` (start command), `--ready-cmd` (ready command), `--cpu-count`, and `--memory-mb`. Each flag falls back to the corresponding config value when omitted, and `--memory-mb` is validated to be even. Includes tests covering the overrides and the odd-memory rejection, plus a changeset for `@e2b/cli`. ## Usage ```bash e2b template migrate \ --language typescript \ --name my-custom-name \ --cmd "node server.js" \ --ready-cmd "curl localhost:3000" \ --cpu-count 4 \ --memory-mb 2048 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f160f08 commit 2869feb

8 files changed

Lines changed: 241 additions & 40 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@e2b/cli": patch
3+
---
4+
5+
Add override options to `e2b template migrate`: `--name`/`-n` to override the generated template name, `--cmd`/`-c` for the start command, `--ready-cmd` for the ready command, and `--cpu-count` / `--memory-mb` for sandbox resources. Each falls back to the value from the config file (`e2b.toml`) when not provided. `--cpu-count` and `--memory-mb` (on both `migrate` and `create`) now reject non-numeric values at parse time instead of silently becoming `NaN`. Template name validation is now shared across `init`, `migrate` and `create` and matches the server-side rule: names are trimmed and lowercased, must contain only letters, numbers, dashes and underscores, and are capped at 128 characters. `init`/`migrate` no longer wrongly reject names with leading/trailing dashes or underscores.

packages/cli/src/commands/template/create.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
defaultDockerfileName,
77
fallbackDockerfileName,
88
} from 'src/docker/constants'
9-
import { pathOption } from 'src/options'
9+
import { parsePositiveInt, pathOption } from 'src/options'
10+
import { validateTemplateName } from 'src/utils/templateName'
1011
import { getRoot } from 'src/utils/filesystem'
1112
import {
1213
asFormattedSandboxTemplate,
@@ -45,12 +46,12 @@ export const createCommand = new commander.Command('create')
4546
.option(
4647
'--cpu-count <cpu-count>',
4748
'specify the number of CPUs that will be used to run the sandbox. The default value is 2.',
48-
parseInt
49+
parsePositiveInt('CPU count')
4950
)
5051
.option(
5152
'--memory-mb <memory-mb>',
5253
'specify the amount of memory in megabytes that will be used to run the sandbox. Must be an even number. The default value is 512.',
53-
parseInt
54+
parsePositiveInt('Memory in megabytes')
5455
)
5556
.option('--no-cache', 'skip cache when building the template.')
5657
.alias('ct')
@@ -70,12 +71,14 @@ export const createCommand = new commander.Command('create')
7071
try {
7172
process.stdout.write('\n')
7273

73-
// Validate template name
74-
if (!/^[a-z0-9-_]+$/.test(templateName)) {
74+
// Validate and normalize template name
75+
try {
76+
templateName = validateTemplateName(templateName)
77+
} catch (err) {
7578
console.error(
76-
`Template name ${asLocal(
77-
templateName
78-
)} is not valid. Template name can only contain lowercase letters, numbers, dashes and underscores.`
79+
`Template name ${asLocal(templateName)} is not valid. ${
80+
err instanceof Error ? err.message : String(err)
81+
}`
7982
)
8083
process.exit(1)
8184
}

packages/cli/src/commands/template/init.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as path from 'path'
77
import { pathOption } from 'src/options'
88
import { getRoot } from 'src/utils/filesystem'
99
import { asPrimary } from 'src/utils/format'
10+
import { validateTemplateName } from 'src/utils/templateName'
1011
import {
1112
generateAndWriteTemplateFiles,
1213
GeneratedFiles,
@@ -137,37 +138,17 @@ async function addPackageJsonScripts(
137138
}
138139
}
139140

140-
/**
141-
* Check template name format
142-
*/
143-
function templateNameRegex(name: string): boolean {
144-
return /^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)
145-
}
146-
147-
function validateTemplateName(name: string) {
148-
if (!name || name.trim().length === 0) {
149-
throw new Error('Template name cannot be empty')
150-
}
151-
if (!templateNameRegex(name.trim())) {
152-
throw new Error(
153-
'Template name must contain only lowercase letters, numbers, hyphens, and underscores, and cannot start or end with a hyphen or underscore'
154-
)
155-
}
156-
}
157-
158141
export const initCommand = new commander.Command('init')
159142
.description('initialize a new sandbox template using the SDK')
160143
.addOption(pathOption)
161144
.option('-n, --name <name>', 'template name', (value) => {
162145
try {
163-
validateTemplateName(value)
146+
return validateTemplateName(value)
164147
} catch (err) {
165148
throw new commander.InvalidArgumentError(
166149
err instanceof Error ? err.message : String(err)
167150
)
168151
}
169-
170-
return value
171152
})
172153
.option(
173154
'-l, --language <language>',
@@ -210,7 +191,7 @@ export const initCommand = new commander.Command('init')
210191
},
211192
})
212193
}
213-
templateName = templateName.trim()
194+
templateName = validateTemplateName(templateName)
214195
console.log(`Using template name: ${templateName}`)
215196

216197
// Step 2: Get language (from CLI or prompt)

packages/cli/src/commands/template/migrate.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import * as fs from 'fs'
55
import * as path from 'path'
66
import { E2BConfig, getConfigPath, loadConfig } from '../../config'
77
import { defaultDockerfileName } from '../../docker/constants'
8-
import { configOption, pathOption } from '../../options'
8+
import { configOption, parsePositiveInt, pathOption } from '../../options'
99
import { getRoot } from '../../utils/filesystem'
1010
import { asLocal, asLocalRelative, asPrimary } from '../../utils/format'
1111
import { getDockerfile } from './dockerfile'
12+
import { validateTemplateName } from '../../utils/templateName'
1213
import {
1314
generateAndWriteTemplateFiles,
1415
Language,
@@ -22,7 +23,8 @@ async function migrateToLanguage(
2223
root: string,
2324
config: E2BConfig,
2425
dockerfileContent: string,
25-
language: Language
26+
language: Language,
27+
nameOverride?: string
2628
): Promise<void> {
2729
// Initialize template with file context
2830
const template = Template({
@@ -66,7 +68,7 @@ async function migrateToLanguage(
6668
parsedTemplate = baseTemplate.setReadyCmd(config.ready_cmd)
6769
}
6870

69-
const name = config.template_name || config.template_id
71+
const name = nameOverride || config.template_name || config.template_id
7072
if (!name) {
7173
throw new Error('Template name or ID is required')
7274
}
@@ -93,6 +95,37 @@ export const migrateCommand = new commander.Command('migrate')
9395
`specify path to Dockerfile. Defaults to ${asLocal('e2b.Dockerfile')}`
9496
)
9597
.addOption(configOption)
98+
.option(
99+
'-n, --name <name>',
100+
'override the template name used in the generated files. Defaults to the template name or ID from the config file.',
101+
(value) => {
102+
try {
103+
return validateTemplateName(value)
104+
} catch (err) {
105+
throw new commander.InvalidArgumentError(
106+
err instanceof Error ? err.message : String(err)
107+
)
108+
}
109+
}
110+
)
111+
.option(
112+
'-c, --cmd <start-command>',
113+
'override the command that will be executed when the sandbox is started.'
114+
)
115+
.option(
116+
'--ready-cmd <ready-command>',
117+
'override the command that will need to exit 0 for the template to be ready.'
118+
)
119+
.option(
120+
'--cpu-count <cpu-count>',
121+
'override the number of CPUs that will be used to run the sandbox.',
122+
parsePositiveInt('CPU count')
123+
)
124+
.option(
125+
'--memory-mb <memory-mb>',
126+
'override the amount of memory in megabytes that will be used to run the sandbox. Must be an even number.',
127+
parsePositiveInt('Memory in megabytes')
128+
)
96129
.option(
97130
'-l, --language <language>',
98131
`specify target language: ${Object.values(Language).join(', ')}`,
@@ -114,11 +147,25 @@ export const migrateCommand = new commander.Command('migrate')
114147
config?: string
115148
path?: string
116149
language?: Language
150+
name?: string
151+
cmd?: string
152+
readyCmd?: string
153+
cpuCount?: number
154+
memoryMb?: number
117155
}) => {
118156
let success = false
119157
try {
120158
console.log('\n🔄 Migrating template configuration to SDK format...\n')
121159

160+
// Validate memory override
161+
if (opts.memoryMb && opts.memoryMb % 2 !== 0) {
162+
throw new Error(
163+
`The memory in megabytes must be an even number. You provided ${asLocal(
164+
opts.memoryMb.toFixed(0)
165+
)}.`
166+
)
167+
}
168+
122169
const root = getRoot(opts.path)
123170
const configPath = getConfigPath(root, opts.config)
124171

@@ -141,6 +188,20 @@ export const migrateCommand = new commander.Command('migrate')
141188
)
142189
}
143190

191+
// Apply command-line overrides on top of the loaded config
192+
if (opts.cmd !== undefined) {
193+
config.start_cmd = opts.cmd
194+
}
195+
if (opts.readyCmd !== undefined) {
196+
config.ready_cmd = opts.readyCmd
197+
}
198+
if (opts.cpuCount !== undefined) {
199+
config.cpu_count = opts.cpuCount
200+
}
201+
if (opts.memoryMb !== undefined) {
202+
config.memory_mb = opts.memoryMb
203+
}
204+
144205
// Determine target language
145206
let language: Language
146207
if (opts.language) {
@@ -173,7 +234,13 @@ export const migrateCommand = new commander.Command('migrate')
173234
}
174235

175236
// Perform migration
176-
await migrateToLanguage(root, config, dockerfileContent, language)
237+
await migrateToLanguage(
238+
root,
239+
config,
240+
dockerfileContent,
241+
language,
242+
opts.name
243+
)
177244

178245
// Rename old files to .old extensions
179246
const oldFilesRenamed: { oldPath: string; newPath: string }[] = []

packages/cli/src/options.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import * as commander from 'commander'
22

3-
import { asBold } from './utils/format'
3+
import { asBold, asLocal } from './utils/format'
4+
5+
/**
6+
* Parse a CLI option as a positive integer, rejecting non-numeric values so
7+
* they don't silently become NaN.
8+
*/
9+
export function parsePositiveInt(label: string): (value: string) => number {
10+
return (value) => {
11+
const parsed = Number(value)
12+
if (!Number.isInteger(parsed) || parsed < 1) {
13+
throw new commander.InvalidArgumentError(
14+
`${label} must be a positive integer. You provided ${asLocal(value)}.`
15+
)
16+
}
17+
return parsed
18+
}
19+
}
420

521
export const pathOption = new commander.Option(
622
'-p, --path <path>',
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Allowed template name (alias) format, matching the server-side validation in
3+
* e2b-dev/infra (`id.identifierRegex`): the name is trimmed and lowercased,
4+
* then must contain only lowercase letters, numbers, dashes and underscores.
5+
*/
6+
const templateNameRegex = /^[a-z0-9-_]+$/
7+
8+
const MAX_TEMPLATE_NAME_LENGTH = 128
9+
10+
/**
11+
* Validates a template name and returns its normalized form (trimmed and
12+
* lowercased), matching how the server normalizes it.
13+
*/
14+
export function validateTemplateName(name: string): string {
15+
const cleaned = name?.trim().toLowerCase()
16+
if (!cleaned) {
17+
throw new Error('Template name cannot be empty')
18+
}
19+
if (!templateNameRegex.test(cleaned)) {
20+
throw new Error(
21+
'Template name must contain only letters, numbers, dashes and underscores'
22+
)
23+
}
24+
if (cleaned.length > MAX_TEMPLATE_NAME_LENGTH) {
25+
throw new Error(
26+
`Template name must be at most ${MAX_TEMPLATE_NAME_LENGTH} characters long`
27+
)
28+
}
29+
return cleaned
30+
}

packages/cli/tests/commands/template/init.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,13 @@ describe('Template Init', () => {
5858
})
5959

6060
test('should validate template name format', async () => {
61+
// Matches the server-side rule in e2b-dev/infra (id.identifierRegex):
62+
// only lowercase letters, numbers, dashes and underscores are allowed.
6163
const invalidNames = [
62-
'My-Template', // uppercase
63-
'-invalid-start', // starts with hyphen
64-
'invalid-end-', // ends with hyphen
65-
'_invalid-start', // starts with underscore
66-
'invalid-end_', // ends with underscore
6764
'invalid space', // contains space
65+
'invalid.dot', // contains a dot
6866
'', // empty
67+
'a'.repeat(129), // exceeds the 128 character limit
6968
]
7069

7170
for (const invalidName of invalidNames) {

0 commit comments

Comments
 (0)