(
opts: CreateOptions,
deps: ProjectDeps = {},
)
| 129 | } |
| 130 | |
| 131 | export async function runCreate( |
| 132 | opts: CreateOptions, |
| 133 | deps: ProjectDeps = {}, |
| 134 | ): Promise<CliCreateProjectResponse> { |
| 135 | const out = makeOutput(opts.output, deps); |
| 136 | const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); |
| 137 | |
| 138 | // P1-2: validate idempotency key before sending as an HTTP header. |
| 139 | // Non-ASCII chars cause a ByteString TypeError at the transport layer |
| 140 | // (exit 10 UNAVAILABLE) — fail fast with a clear exit 5 instead. |
| 141 | assertIdempotencyKey(opts.idempotencyKey); |
| 142 | |
| 143 | // P1-3: client-side length checks matching server limits. |
| 144 | if (opts.name !== undefined && opts.name.length > 200) { |
| 145 | throw localValidationError('--name must be at most 200 characters'); |
| 146 | } |
| 147 | if (opts.description !== undefined && opts.description.length > 2000) { |
| 148 | throw localValidationError('--description must be at most 2000 characters'); |
| 149 | } |
| 150 | |
| 151 | // P2-7: guard --url against localhost/RFC1918/non-http(s) (same rules as |
| 152 | // `test create --target-url`). Applies to both FE (required) and BE (optional). |
| 153 | if (opts.targetUrl !== undefined) { |
| 154 | assertNotLocal(opts.targetUrl); |
| 155 | } |
| 156 | |
| 157 | if (opts.type === 'frontend' && !opts.targetUrl) { |
| 158 | throw localValidationError('--url is required for --type frontend'); |
| 159 | } |
| 160 | |
| 161 | if (opts.dryRun) { |
| 162 | const idempotencyKey = opts.idempotencyKey ?? `cli-proj-create-${randomUUID()}`; |
| 163 | // P2-6: gate idempotency-key output behind --verbose/--debug/json (matches |
| 164 | // test create convention). Suppress in plain text interactive mode to reduce |
| 165 | // noise; still available for automation and retry flows. |
| 166 | if ( |
| 167 | opts.idempotencyKey === undefined && |
| 168 | (opts.output === 'json' || opts.verbose || opts.debug) |
| 169 | ) { |
| 170 | stderr(`idempotency-key: ${idempotencyKey}`); |
| 171 | } |
| 172 | const sample: CliCreateProjectResponse = { |
| 173 | id: 'p_dryrun_2026', |
| 174 | type: opts.type, |
| 175 | name: opts.name, |
| 176 | targetUrl: opts.targetUrl ?? '', |
| 177 | createdFrom: 'cli', |
| 178 | createdAt: '2026-05-16T00:00:00.000Z', |
| 179 | updatedAt: '2026-05-16T00:00:00.000Z', |
| 180 | } as unknown as CliCreateProjectResponse; |
| 181 | out.print(sample, data => renderProjectText(data as CliProject)); |
| 182 | return sample; |
| 183 | } |
| 184 | |
| 185 | // Resolve password: flag > file > none |
| 186 | let password = opts.password; |
| 187 | if (password === undefined && opts.passwordFile !== undefined) { |
| 188 | password = readFileSync(opts.passwordFile, 'utf8').trim(); |
no test coverage detected