diff --git a/README.md b/README.md index 0b9ab43d..420428c0 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/package.json b/package.json index f0252030..984044f9 100644 --- a/package.json +++ b/package.json @@ -76,5 +76,6 @@ "files": [ "dist", "src" - ] + ], + "bin": "./dist/cli.js" } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..e3f44c47 --- /dev/null +++ b/src/cli.ts @@ -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 { + 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 { + 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 = {} + + 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 + const existingAuth = + typeof parsedParams.auth === 'object' && + parsedParams.auth != null && + !Array.isArray(parsedParams.auth) + ? (parsedParams.auth as Record) + : {} + + 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 { + 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() diff --git a/test/unit/test-cli.test.ts b/test/unit/test-cli.test.ts new file mode 100644 index 00000000..3af41965 --- /dev/null +++ b/test/unit/test-cli.test.ts @@ -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) + }) +}) diff --git a/yarn.lock b/yarn.lock index c2093dca..41432649 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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