Skip to content

Commit 72996b9

Browse files
authored
npx smart_sig (#261)
* npx smart_sig * implement feedback * lock * implement feedback * fix formatting
1 parent 982d972 commit 72996b9

File tree

5 files changed

+314
-1
lines changed

5 files changed

+314
-1
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,17 @@ Calculates a signature for the given `params` JSON object. If the `params` objec
385385

386386
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).
387387

388+
#### CLI smart_sig
389+
390+
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()`.
391+
392+
```sh
393+
TRANSLOADIT_KEY=... TRANSLOADIT_SECRET=... \
394+
printf '{"assembly_id":"12345"}' | npx transloadit smart_sig
395+
```
396+
397+
You can also use `TRANSLOADIT_AUTH_KEY`/`TRANSLOADIT_AUTH_SECRET` as aliases for the environment variables.
398+
388399
#### getSignedSmartCDNUrl(params)
389400

390401
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:

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,6 @@
7676
"files": [
7777
"dist",
7878
"src"
79-
]
79+
],
80+
"bin": "./dist/cli.js"
8081
}

src/cli.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env node
2+
3+
import { realpathSync } from 'node:fs'
4+
import path from 'node:path'
5+
import process from 'node:process'
6+
import { fileURLToPath } from 'node:url'
7+
import type { ZodIssue } from 'zod'
8+
import {
9+
assemblyAuthInstructionsSchema,
10+
assemblyInstructionsSchema,
11+
} from './alphalib/types/template.ts'
12+
import type { OptionalAuthParams } from './apiTypes.ts'
13+
import { Transloadit } from './Transloadit.ts'
14+
15+
export async function readStdin(): Promise<string> {
16+
if (process.stdin.isTTY) return ''
17+
18+
process.stdin.setEncoding('utf8')
19+
let data = ''
20+
21+
for await (const chunk of process.stdin) {
22+
data += chunk
23+
}
24+
25+
return data
26+
}
27+
28+
function fail(message: string): void {
29+
console.error(message)
30+
process.exitCode = 1
31+
}
32+
33+
const cliParamsSchema = assemblyInstructionsSchema
34+
.extend({ auth: assemblyAuthInstructionsSchema.partial().optional() })
35+
.partial()
36+
.passthrough()
37+
38+
function formatIssues(issues: ZodIssue[]): string {
39+
return issues
40+
.map((issue) => {
41+
const path = issue.path.join('.') || '(root)'
42+
return `${path}: ${issue.message}`
43+
})
44+
.join('; ')
45+
}
46+
47+
export async function runSmartSig(providedInput?: string): Promise<void> {
48+
const authKey = process.env.TRANSLOADIT_KEY || process.env.TRANSLOADIT_AUTH_KEY
49+
const authSecret = process.env.TRANSLOADIT_SECRET || process.env.TRANSLOADIT_AUTH_SECRET
50+
51+
if (!authKey || !authSecret) {
52+
fail(
53+
'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.',
54+
)
55+
return
56+
}
57+
58+
const rawInput = providedInput ?? (await readStdin())
59+
const input = rawInput.trim()
60+
let params: Record<string, unknown> = {}
61+
62+
if (input !== '') {
63+
try {
64+
const parsed = JSON.parse(input)
65+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
66+
fail('Invalid params provided via stdin. Expected a JSON object.')
67+
return
68+
}
69+
70+
const parsedResult = cliParamsSchema.safeParse(parsed)
71+
if (!parsedResult.success) {
72+
fail(`Invalid params: ${formatIssues(parsedResult.error.issues)}`)
73+
return
74+
}
75+
76+
const parsedParams = parsedResult.data as Record<string, unknown>
77+
const existingAuth =
78+
typeof parsedParams.auth === 'object' &&
79+
parsedParams.auth != null &&
80+
!Array.isArray(parsedParams.auth)
81+
? (parsedParams.auth as Record<string, unknown>)
82+
: {}
83+
84+
params = {
85+
...parsedParams,
86+
auth: {
87+
...existingAuth,
88+
key: authKey,
89+
},
90+
}
91+
} catch (error: unknown) {
92+
fail(`Failed to parse JSON from stdin: ${(error as Error).message}`)
93+
return
94+
}
95+
}
96+
97+
const client = new Transloadit({ authKey, authSecret })
98+
const signature = client.calcSignature(params as OptionalAuthParams)
99+
process.stdout.write(`${JSON.stringify(signature)}\n`)
100+
}
101+
102+
export async function main(args = process.argv.slice(2)): Promise<void> {
103+
const [command] = args
104+
105+
switch (command) {
106+
case 'smart_sig': {
107+
await runSmartSig()
108+
break
109+
}
110+
111+
case '-h':
112+
case '--help':
113+
case undefined: {
114+
process.stdout.write(
115+
[
116+
'Usage:',
117+
' npx transloadit smart_sig Read params JSON from stdin and output signed payload.',
118+
'',
119+
'Environment variables:',
120+
' TRANSLOADIT_KEY, TRANSLOADIT_SECRET',
121+
].join('\n'),
122+
)
123+
if (command === undefined) process.exitCode = 1
124+
break
125+
}
126+
127+
default: {
128+
fail(`Unknown command: ${command}`)
129+
}
130+
}
131+
}
132+
133+
const currentFile = realpathSync(fileURLToPath(import.meta.url))
134+
135+
function resolveInvokedPath(invoked?: string): string | null {
136+
if (invoked == null) return null
137+
try {
138+
return realpathSync(invoked)
139+
} catch {
140+
return path.resolve(invoked)
141+
}
142+
}
143+
144+
export function shouldRunCli(invoked?: string): boolean {
145+
const resolved = resolveInvokedPath(invoked)
146+
if (resolved == null) return false
147+
return resolved === currentFile
148+
}
149+
150+
export function runCliWhenExecuted(): void {
151+
if (!shouldRunCli(process.argv[1])) return
152+
153+
void main().catch((error) => {
154+
fail((error as Error).message)
155+
})
156+
}
157+
158+
runCliWhenExecuted()

test/unit/test-cli.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { main, runSmartSig, shouldRunCli } from '../../src/cli.ts'
7+
import { Transloadit } from '../../src/Transloadit.ts'
8+
9+
const mockedExpiresDate = '2025-01-01T00:00:00.000Z'
10+
const mockExpires = () =>
11+
vi
12+
.spyOn(Transloadit.prototype as unknown as { _getExpiresDate: () => string }, '_getExpiresDate')
13+
.mockReturnValue(mockedExpiresDate)
14+
15+
const resetExitCode = () => {
16+
process.exitCode = undefined
17+
}
18+
19+
afterEach(() => {
20+
vi.restoreAllMocks()
21+
vi.unstubAllEnvs()
22+
resetExitCode()
23+
})
24+
25+
describe('cli smart_sig', () => {
26+
it('recognizes symlinked invocation paths', () => {
27+
const tmpDir = mkdtempSync(path.join(tmpdir(), 'transloadit-cli-'))
28+
const symlinkTarget = fileURLToPath(new URL('../../src/cli.ts', import.meta.url))
29+
const symlinkPath = path.join(tmpDir, 'transloadit')
30+
31+
symlinkSync(symlinkTarget, symlinkPath)
32+
try {
33+
expect(shouldRunCli(symlinkPath)).toBe(true)
34+
} finally {
35+
rmSync(symlinkPath)
36+
rmSync(tmpDir, { recursive: true, force: true })
37+
}
38+
})
39+
40+
it('overwrites auth key with env credentials', async () => {
41+
mockExpires()
42+
vi.stubEnv('TRANSLOADIT_KEY', 'key')
43+
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')
44+
45+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
46+
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
47+
48+
const expires = '2025-01-03T00:00:00.000Z'
49+
await runSmartSig(JSON.stringify({ auth: { key: 'other', expires } }))
50+
51+
expect(stderrSpy).not.toHaveBeenCalled()
52+
expect(stdoutSpy).toHaveBeenCalledTimes(1)
53+
const output = JSON.parse(`${stdoutSpy.mock.calls[0]?.[0]}`.trim())
54+
const params = JSON.parse(output.params)
55+
56+
expect(params.auth?.key).toBe('key')
57+
expect(params.auth?.expires).toBe(expires)
58+
})
59+
60+
it('prints signature JSON built from stdin params', async () => {
61+
mockExpires()
62+
vi.stubEnv('TRANSLOADIT_KEY', 'key')
63+
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')
64+
65+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
66+
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
67+
68+
const params = { auth: { expires: '2025-01-02T00:00:00.000Z' } }
69+
await runSmartSig(JSON.stringify(params))
70+
71+
expect(stderrSpy).not.toHaveBeenCalled()
72+
expect(stdoutSpy).toHaveBeenCalledTimes(1)
73+
const output = stdoutSpy.mock.calls[0]?.[0]
74+
const parsed = JSON.parse(`${output}`.trim())
75+
76+
const client = new Transloadit({ authKey: 'key', authSecret: 'secret' })
77+
const expected = client.calcSignature({ auth: { expires: '2025-01-02T00:00:00.000Z' } })
78+
expect(parsed).toEqual(expected)
79+
expect(process.exitCode).toBeUndefined()
80+
})
81+
82+
it('fails when credentials are missing', async () => {
83+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
84+
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
85+
86+
await runSmartSig('{}')
87+
88+
expect(stdoutSpy).not.toHaveBeenCalled()
89+
expect(stderrSpy).toHaveBeenCalledWith(
90+
'Missing credentials. Please set TRANSLOADIT_KEY and TRANSLOADIT_SECRET environment variables.',
91+
)
92+
expect(process.exitCode).toBe(1)
93+
})
94+
95+
it('fails when stdin is not valid JSON', async () => {
96+
mockExpires()
97+
vi.stubEnv('TRANSLOADIT_KEY', 'key')
98+
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')
99+
100+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
101+
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
102+
103+
await runSmartSig('this is not json')
104+
105+
expect(stdoutSpy).not.toHaveBeenCalled()
106+
expect(stderrSpy).toHaveBeenCalled()
107+
expect(stderrSpy.mock.calls[0]?.[0]).toContain('Failed to parse JSON from stdin')
108+
expect(process.exitCode).toBe(1)
109+
})
110+
111+
it('fails when params are not an object', async () => {
112+
mockExpires()
113+
vi.stubEnv('TRANSLOADIT_KEY', 'key')
114+
vi.stubEnv('TRANSLOADIT_SECRET', 'secret')
115+
116+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
117+
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
118+
119+
await runSmartSig('[]')
120+
121+
expect(stdoutSpy).not.toHaveBeenCalled()
122+
expect(stderrSpy).toHaveBeenCalledWith(
123+
'Invalid params provided via stdin. Expected a JSON object.',
124+
)
125+
expect(process.exitCode).toBe(1)
126+
})
127+
128+
it('prints usage when no command is provided', async () => {
129+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
130+
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
131+
132+
await main([])
133+
134+
expect(stderrSpy).not.toHaveBeenCalled()
135+
expect(stdoutSpy).toHaveBeenCalled()
136+
const message = `${stdoutSpy.mock.calls[0]?.[0]}`
137+
expect(message).toContain('Usage:')
138+
expect(message).toContain('npx transloadit smart_sig')
139+
expect(process.exitCode).toBe(1)
140+
})
141+
})

yarn.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5255,6 +5255,8 @@ __metadata:
52555255
typescript: "npm:5.9.2"
52565256
vitest: "npm:^3.2.4"
52575257
zod: "npm:3.25.76"
5258+
bin:
5259+
transloadit: ./dist/cli.js
52585260
languageName: unknown
52595261
linkType: soft
52605262

0 commit comments

Comments
 (0)