Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,17 @@ Calculates a signature for the given `params` JSON object. If the `params` objec

This function returns an object with the key `signature` (containing the calculated signature string) and a key `params`, which contains the stringified version of the passed `params` object (including the set expires and authKey keys).

#### CLI smart_sig

Generate a signature from the command line without writing any JavaScript. The CLI reads a JSON object from stdin, injects credentials from `TRANSLOADIT_KEY`/`TRANSLOADIT_SECRET`, and prints the payload returned by `calcSignature()`.

```sh
TRANSLOADIT_KEY=... TRANSLOADIT_SECRET=... \
printf '{"assembly_id":"12345"}' | npx transloadit smart_sig
```

You can also use `TRANSLOADIT_AUTH_KEY`/`TRANSLOADIT_AUTH_SECRET` as aliases for the environment variables.

#### getSignedSmartCDNUrl(params)

Constructs a signed Smart CDN URL, as defined in the [API documentation](https://transloadit.com/docs/topics/signature-authentication/#smart-cdn). `params` must be an object with the following properties:
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@
"files": [
"dist",
"src"
]
],
"bin": "./dist/cli.js"
}
158 changes: 158 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env node

import { realpathSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import type { ZodIssue } from 'zod'
import {
assemblyAuthInstructionsSchema,
assemblyInstructionsSchema,
} from './alphalib/types/template.ts'
import type { OptionalAuthParams } from './apiTypes.ts'
import { Transloadit } from './Transloadit.ts'

export async function readStdin(): Promise<string> {
if (process.stdin.isTTY) return ''

process.stdin.setEncoding('utf8')
let data = ''

for await (const chunk of process.stdin) {
data += chunk
}

return data
}

function fail(message: string): void {
console.error(message)
process.exitCode = 1
}

const cliParamsSchema = assemblyInstructionsSchema
.extend({ auth: assemblyAuthInstructionsSchema.partial().optional() })
.partial()
.passthrough()

function formatIssues(issues: ZodIssue[]): string {
return issues
.map((issue) => {
const path = issue.path.join('.') || '(root)'
return `${path}: ${issue.message}`
})
.join('; ')
}

export async function runSmartSig(providedInput?: string): Promise<void> {
const authKey = process.env.TRANSLOADIT_KEY || process.env.TRANSLOADIT_AUTH_KEY
const authSecret = process.env.TRANSLOADIT_SECRET || process.env.TRANSLOADIT_AUTH_SECRET

if (!authKey || !authSecret) {
fail(
'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.',
)
return
}

const rawInput = providedInput ?? (await readStdin())
const input = rawInput.trim()
let params: Record<string, unknown> = {}

if (input !== '') {
try {
const parsed = JSON.parse(input)
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
fail('Invalid params provided via stdin. Expected a JSON object.')
return
}

const parsedResult = cliParamsSchema.safeParse(parsed)
if (!parsedResult.success) {
fail(`Invalid params: ${formatIssues(parsedResult.error.issues)}`)
return
}

const parsedParams = parsedResult.data as Record<string, unknown>
const existingAuth =
typeof parsedParams.auth === 'object' &&
parsedParams.auth != null &&
!Array.isArray(parsedParams.auth)
? (parsedParams.auth as Record<string, unknown>)
: {}

params = {
...parsedParams,
auth: {
...existingAuth,
key: authKey,
},
}
} catch (error: unknown) {
fail(`Failed to parse JSON from stdin: ${(error as Error).message}`)
return
}
}

const client = new Transloadit({ authKey, authSecret })
const signature = client.calcSignature(params as OptionalAuthParams)
process.stdout.write(`${JSON.stringify(signature)}\n`)
}

export async function main(args = process.argv.slice(2)): Promise<void> {
const [command] = args

switch (command) {
case 'smart_sig': {
await runSmartSig()
break
}

case '-h':
case '--help':
case undefined: {
process.stdout.write(
[
'Usage:',
' npx transloadit smart_sig Read params JSON from stdin and output signed payload.',
'',
'Environment variables:',
' TRANSLOADIT_KEY, TRANSLOADIT_SECRET',
].join('\n'),
)
if (command === undefined) process.exitCode = 1
break
}

default: {
fail(`Unknown command: ${command}`)
}
}
}

const currentFile = realpathSync(fileURLToPath(import.meta.url))

function resolveInvokedPath(invoked?: string): string | null {
if (invoked == null) return null
try {
return realpathSync(invoked)
} catch {
return path.resolve(invoked)
}
}

export function shouldRunCli(invoked?: string): boolean {
const resolved = resolveInvokedPath(invoked)
if (resolved == null) return false
return resolved === currentFile
}

export function runCliWhenExecuted(): void {
if (!shouldRunCli(process.argv[1])) return

void main().catch((error) => {
fail((error as Error).message)
})
}

runCliWhenExecuted()
141 changes: 141 additions & 0 deletions test/unit/test-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { main, runSmartSig, shouldRunCli } from '../../src/cli.ts'
import { Transloadit } from '../../src/Transloadit.ts'

const mockedExpiresDate = '2025-01-01T00:00:00.000Z'
const mockExpires = () =>
vi
.spyOn(Transloadit.prototype as unknown as { _getExpiresDate: () => string }, '_getExpiresDate')
.mockReturnValue(mockedExpiresDate)

const resetExitCode = () => {
process.exitCode = undefined
}

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
resetExitCode()
})

describe('cli smart_sig', () => {
it('recognizes symlinked invocation paths', () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'transloadit-cli-'))
const symlinkTarget = fileURLToPath(new URL('../../src/cli.ts', import.meta.url))
const symlinkPath = path.join(tmpDir, 'transloadit')

symlinkSync(symlinkTarget, symlinkPath)
try {
expect(shouldRunCli(symlinkPath)).toBe(true)
} finally {
rmSync(symlinkPath)
rmSync(tmpDir, { recursive: true, force: true })
}
})

it('overwrites auth key with env credentials', async () => {
mockExpires()
vi.stubEnv('TRANSLOADIT_KEY', 'key')
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const expires = '2025-01-03T00:00:00.000Z'
await runSmartSig(JSON.stringify({ auth: { key: 'other', expires } }))

expect(stderrSpy).not.toHaveBeenCalled()
expect(stdoutSpy).toHaveBeenCalledTimes(1)
const output = JSON.parse(`${stdoutSpy.mock.calls[0]?.[0]}`.trim())
const params = JSON.parse(output.params)

expect(params.auth?.key).toBe('key')
expect(params.auth?.expires).toBe(expires)
})

it('prints signature JSON built from stdin params', async () => {
mockExpires()
vi.stubEnv('TRANSLOADIT_KEY', 'key')
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const params = { auth: { expires: '2025-01-02T00:00:00.000Z' } }
await runSmartSig(JSON.stringify(params))

expect(stderrSpy).not.toHaveBeenCalled()
expect(stdoutSpy).toHaveBeenCalledTimes(1)
const output = stdoutSpy.mock.calls[0]?.[0]
const parsed = JSON.parse(`${output}`.trim())

const client = new Transloadit({ authKey: 'key', authSecret: 'secret' })
const expected = client.calcSignature({ auth: { expires: '2025-01-02T00:00:00.000Z' } })
expect(parsed).toEqual(expected)
expect(process.exitCode).toBeUndefined()
})

it('fails when credentials are missing', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

await runSmartSig('{}')

expect(stdoutSpy).not.toHaveBeenCalled()
expect(stderrSpy).toHaveBeenCalledWith(
'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.',
)
expect(process.exitCode).toBe(1)
})

it('fails when stdin is not valid JSON', async () => {
mockExpires()
vi.stubEnv('TRANSLOADIT_KEY', 'key')
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

await runSmartSig('this is not json')

expect(stdoutSpy).not.toHaveBeenCalled()
expect(stderrSpy).toHaveBeenCalled()
expect(stderrSpy.mock.calls[0]?.[0]).toContain('Failed to parse JSON from stdin')
expect(process.exitCode).toBe(1)
})

it('fails when params are not an object', async () => {
mockExpires()
vi.stubEnv('TRANSLOADIT_KEY', 'key')
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

await runSmartSig('[]')

expect(stdoutSpy).not.toHaveBeenCalled()
expect(stderrSpy).toHaveBeenCalledWith(
'Invalid params provided via stdin. Expected a JSON object.',
)
expect(process.exitCode).toBe(1)
})

it('prints usage when no command is provided', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

await main([])

expect(stderrSpy).not.toHaveBeenCalled()
expect(stdoutSpy).toHaveBeenCalled()
const message = `${stdoutSpy.mock.calls[0]?.[0]}`
expect(message).toContain('Usage:')
expect(message).toContain('npx transloadit smart_sig')
expect(process.exitCode).toBe(1)
})
})
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5255,6 +5255,8 @@ __metadata:
typescript: "npm:5.9.2"
vitest: "npm:^3.2.4"
zod: "npm:3.25.76"
bin:
transloadit: ./dist/cli.js
languageName: unknown
linkType: soft

Expand Down