From 8bf0e4a186d391f9ff475aecdd48f78d3d80d797 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 16 Oct 2025 13:24:57 +0200 Subject: [PATCH 1/5] npx smart_sig --- README.md | 11 ++++ package.json | 5 +- src/cli.ts | 100 ++++++++++++++++++++++++++++++++++ test/unit/test-cli.test.ts | 106 +++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/cli.ts create mode 100644 test/unit/test-cli.test.ts 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..e03b8516 100644 --- a/package.json +++ b/package.json @@ -76,5 +76,8 @@ "files": [ "dist", "src" - ] + ], + "bin": { + "transloadit": "./dist/cli.js" + } } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..1cc7031b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { Transloadit } from './Transloadit.ts' +import type { OptionalAuthParams } from './apiTypes.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 +} + +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: OptionalAuthParams = {} + + 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 + } + + params = parsed as OptionalAuthParams + } 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) + 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 = path.resolve(fileURLToPath(import.meta.url)) +const invokedFile = typeof process.argv[1] === 'string' ? path.resolve(process.argv[1]) : '' + +if (currentFile === invokedFile) { + void main().catch((error) => { + fail((error as Error).message) + }) +} diff --git a/test/unit/test-cli.test.ts b/test/unit/test-cli.test.ts new file mode 100644 index 00000000..116ca88f --- /dev/null +++ b/test/unit/test-cli.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { main, runSmartSig } 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('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 = { template_id: '123' } + 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({ template_id: '123' }) + 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) + + }) +}) From 83fcee9b3283f15827b6bf6bf740e4d87d74ec71 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 16 Oct 2025 13:33:34 +0200 Subject: [PATCH 2/5] implement feedback --- src/cli.ts | 47 ++++++++++++++++++++++++++++++++++---- test/unit/test-cli.test.ts | 33 +++++++++++++++++++------- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 1cc7031b..63ad6546 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,13 @@ import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { Transloadit } from './Transloadit.ts' +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 '' @@ -24,6 +29,20 @@ function fail(message: string): void { 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 @@ -37,7 +56,7 @@ export async function runSmartSig(providedInput?: string): Promise { const rawInput = providedInput ?? (await readStdin()) const input = rawInput.trim() - let params: OptionalAuthParams = {} + let params: Record = {} if (input !== '') { try { @@ -47,7 +66,27 @@ export async function runSmartSig(providedInput?: string): Promise { return } - params = parsed as OptionalAuthParams + 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 @@ -55,7 +94,7 @@ export async function runSmartSig(providedInput?: string): Promise { } const client = new Transloadit({ authKey, authSecret }) - const signature = client.calcSignature(params) + const signature = client.calcSignature(params as OptionalAuthParams) process.stdout.write(`${JSON.stringify(signature)}\n`) } diff --git a/test/unit/test-cli.test.ts b/test/unit/test-cli.test.ts index 116ca88f..13d38e88 100644 --- a/test/unit/test-cli.test.ts +++ b/test/unit/test-cli.test.ts @@ -19,6 +19,26 @@ afterEach(() => { }) describe('cli smart_sig', () => { + 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') @@ -27,7 +47,7 @@ describe('cli smart_sig', () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const params = { template_id: '123' } + const params = { auth: { expires: '2025-01-02T00:00:00.000Z' } } await runSmartSig(JSON.stringify(params)) expect(stderrSpy).not.toHaveBeenCalled() @@ -36,10 +56,9 @@ describe('cli smart_sig', () => { const parsed = JSON.parse(`${output}`.trim()) const client = new Transloadit({ authKey: 'key', authSecret: 'secret' }) - const expected = client.calcSignature({ template_id: '123' }) + 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 () => { @@ -53,7 +72,6 @@ describe('cli smart_sig', () => { '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 () => { @@ -70,7 +88,6 @@ describe('cli smart_sig', () => { 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 () => { @@ -84,9 +101,10 @@ describe('cli smart_sig', () => { await runSmartSig('[]') expect(stdoutSpy).not.toHaveBeenCalled() - expect(stderrSpy).toHaveBeenCalledWith('Invalid params provided via stdin. Expected a JSON object.') + 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 () => { @@ -101,6 +119,5 @@ describe('cli smart_sig', () => { expect(message).toContain('Usage:') expect(message).toContain('npx transloadit smart_sig') expect(process.exitCode).toBe(1) - }) }) From 9d07267165fb17b65a9465621b79c4db92788fc8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 16 Oct 2025 13:34:23 +0200 Subject: [PATCH 3/5] lock --- package.json | 4 +--- yarn.lock | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e03b8516..984044f9 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,5 @@ "dist", "src" ], - "bin": { - "transloadit": "./dist/cli.js" - } + "bin": "./dist/cli.js" } 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 From 8ee9ec452d7e86e43b6aa661d044c5280f9917e5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 16 Oct 2025 13:51:43 +0200 Subject: [PATCH 4/5] implement feedback --- src/cli.ts | 25 ++++++++++++++++++++++--- test/unit/test-cli.test.ts | 20 +++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 63ad6546..e3f44c47 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { realpathSync } from 'node:fs' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' @@ -129,11 +130,29 @@ export async function main(args = process.argv.slice(2)): Promise { } } -const currentFile = path.resolve(fileURLToPath(import.meta.url)) -const invokedFile = typeof process.argv[1] === 'string' ? path.resolve(process.argv[1]) : '' +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 -if (currentFile === invokedFile) { 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 index 13d38e88..584313e6 100644 --- a/test/unit/test-cli.test.ts +++ b/test/unit/test-cli.test.ts @@ -1,5 +1,9 @@ +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import path from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { main, runSmartSig } from '../../src/cli.ts' +import { main, runSmartSig, shouldRunCli } from '../../src/cli.ts' import { Transloadit } from '../../src/Transloadit.ts' const mockedExpiresDate = '2025-01-01T00:00:00.000Z' @@ -19,6 +23,20 @@ afterEach(() => { }) 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') From e08dba2002a1148426f54f10b45f192208f7e78f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 16 Oct 2025 13:52:45 +0200 Subject: [PATCH 5/5] fix formatting --- test/unit/test-cli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/test-cli.test.ts b/test/unit/test-cli.test.ts index 584313e6..3af41965 100644 --- a/test/unit/test-cli.test.ts +++ b/test/unit/test-cli.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' -import path from 'node:path' 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'