yesilcam-studios 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,37 @@
1
1
  # yesilcam-studios
2
2
 
3
- Yeşilçam Studios from your terminal (`yesilcam`) and from Claude (`yesilcam-mcp`). Same account, same token wallet as https://yesilcamstudios.com/app/.
3
+ Every Yeşilçam Studios studio from your terminal (`yesilcam`) and from AI assistants over MCP (`yesilcam-mcp`): Image Studio, Film Studio, Animation Studio (25 styles, Ink Landscape, Inkmation, Custom), Cinema Studio, Design Studio, Website Studio, Website Promo, Assemble / Disassemble, Fashion Try-On, Maket and AI Influencer Studio (long videos, automations, Discover). Same account and token wallet as https://yesilcamstudios.com/app/.
4
4
 
5
5
  ```bash
6
6
  npm install -g yesilcam-studios
7
- yesilcam login # email + password, token saved to ~/.config/yesilcam/config.json (0600)
8
- yesilcam balance
9
- yesilcam image "a lighthouse at dusk, 35mm film" --tier standard --out lighthouse.png
10
- yesilcam video --from 1234 --duration 5 --wait
11
- yesilcam tryon --garment 88 --person 77 --out look.png
12
- yesilcam maket analyze plan.dxf # local, free
13
- yesilcam maket save plan.dxf --title "Villa"
14
- yesilcam pricing
7
+ yesilcam login # paste an API key from https://yesilcamstudios.com/app/account/developer
8
+ yesilcam account show
9
+ yesilcam image "a lighthouse at dusk, 35mm film" --tier draft --quote # price only
10
+ yesilcam image "a lighthouse at dusk, 35mm film" --tier draft --yes --out lighthouse.png
11
+ yesilcam influencer video --mode talk --influencer-id 70 --text "Hello!" --yes --wait
12
+ yesilcam longvideo plan --influencer-id 70 --topic "3 tips for phone photos" --seconds 60 --yes --wait
13
+ yesilcam maket analyze plan.dxf # local, free
14
+ yesilcam --help # all studios; yesilcam <studio> --help for actions
15
15
  ```
16
16
 
17
- Claude (MCP): `yesilcam mcp` prints the config snippet. Claude Code: `claude mcp add yesilcam -e YESILCAM_TOKEN=<token> -- yesilcam-mcp`.
18
- Get a token with `yesilcam login --json`, or set `YESILCAM_TOKEN` in the environment.
17
+ Anything that spends tokens shows a quote first: interactive prompt, `--yes` to approve in scripts, `--max-tokens` as a ceiling, `--quote` to only price. `--json` for machine output, `--wait` / `--out` for long jobs. Exit codes: 3 quote not approved, 2 not signed in.
18
+
19
+ ## MCP (Claude Code, Claude Desktop, Cursor, VS Code, Windsurf …)
20
+
21
+ ```bash
22
+ claude mcp add yesilcam -- yesilcam-mcp
23
+ ```
24
+
25
+ ```json
26
+ { "mcpServers": { "yesilcam": { "command": "yesilcam-mcp", "args": [] } } }
27
+ ```
28
+
29
+ No secret in the config: the server uses the key saved by `yesilcam login` (or `YESILCAM_API_KEY`). `--studios image,film,influencer` loads fewer tools, `--read-only` only reading tools. Tools that spend tokens return a quote and charge nothing until called again with `confirm=true` after the user approves. `yesilcam mcp` prints the snippets.
30
+
31
+ ## Security
32
+
33
+ - API keys: read-only or read-and-create, optional expiry, revocable in the app; they cannot delete the account, change password/email, pay, create keys or connect social accounts. Only a hash is stored on the server.
34
+ - Credentials live in `~/.config/yesilcam/config.json` (0600) and are never printed. `yesilcam logout` removes them.
35
+ - Legacy sign-in with email and password: `yesilcam login --password`.
36
+
37
+ Docs: https://yesilcamstudios.com/developers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yesilcam-studios",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Yeşilçam Studios CLI and MCP server: AI film, fashion try-on and Maket from your terminal or from Claude.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -1,120 +1,212 @@
1
- import { Command } from 'commander'
2
- import { readFileSync, writeFileSync } from 'node:fs'
3
- import { basename, extname } from 'node:path'
4
- import { createInterface } from 'node:readline/promises'
5
- import { stdin as input, stdout as output } from 'node:process'
6
- import { login, makeClient, ApiError } from './client.js'
7
- import { readConfig, writeConfig, clearConfig, getUserToken, CONFIG_FILE } from './config.js'
8
- import { imageJob, videoJob, tryonJob, waitForJob, describeJob, TIERS, ASPECTS } from './jobs.js'
9
- import { analyzePlanText } from './maket/analyze.js'
10
- import { SITE_URL } from './constants.js'
1
+ // yesilcam CLI: `yesilcam <stüdyo> <eylem>`; komutlar işlem kataloğundan türetilir (MCP araçlarıyla birebir).
2
+ import { Command, Option } from 'commander'
3
+ import { readFileSync } from 'node:fs'
4
+ import { createInterface } from 'node:readline'
5
+ import { z } from 'zod'
6
+ import { login, makeClient, exchangeKey, isApiKey, ApiError, friendlyError } from './client.js'
7
+ import { readConfig, writeConfig, clearConfig, getCredentials, permissionWarning, configFile } from './config.js'
8
+ import { OPS } from './registry.js'
9
+ import { execute } from './run.js'
10
+ import { checkOnce } from './wait.js'
11
+ import { VERSION, KEYS_URL, SITE_URL } from './constants.js'
11
12
 
12
- const out = (program, data, human) => { if (program.opts().json) console.log(JSON.stringify(data, null, 2)); else console.log(human ?? JSON.stringify(data, null, 2)) }
13
- const fmtInt = (n) => Number(n).toLocaleString('en-US')
14
- const client = () => { const t = getUserToken(); if (!t) { console.error('Not logged in. Run: yesilcam login'); process.exit(2) } return makeClient(t) }
15
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }
13
+ export const GROUPS = {
14
+ account: 'Account, plan, balance, prices', jobs: 'Generation jobs', library: 'Your library: upload, download, share', wait: 'Wait for any job or project',
15
+ image: 'Image Studio', video: 'Film Studio videos (text, image, restyle, characters, lip sync)', characters: 'Characters and Character Bible', voices: 'Voices', prompt: 'Prompt help from Sam',
16
+ film: 'Film Studio: Story to Film and Flow', animation: 'Animation Studio (25 styles, Ink Landscape, Custom)', inkmation: 'Inkmation', cinema: 'Cinema Studio (video editor)',
17
+ design: 'Design Studio', website: 'Website Studio', promo: 'Website Promo', assemble: 'Assemble / Disassemble product videos', fashion: 'Fashion Try-On', maket: 'Maket (floor plan to 3D)',
18
+ influencer: 'AI Influencer Studio', discover: 'Discover ready-made influencers', longvideo: 'Long videos (15 s to 10 min)', automation: 'Influencer automations', social: 'Social media publishing',
19
+ }
20
+ const DEFAULT_ACTION = { wait: 'until', image: 'create', video: 'create', characters: 'list', jobs: 'list', library: 'list', voices: 'list' }
21
+
22
+ // ── zod alan çözümleme
23
+ function unwrap(s) { let t = s; let optional = false; let desc = s.description; while (['optional', 'default', 'nullable'].includes(t?._zod?.def?.type)) { optional = true; t = t._zod.def.innerType; desc = desc || t.description } return { t, optional, desc: desc || t.description } }
24
+ export function fieldInfo(schema) {
25
+ const { t, optional, desc } = unwrap(schema)
26
+ const type = t._zod.def.type
27
+ const info = { optional, desc, type }
28
+ if (type === 'enum') info.choices = Object.values(t._zod.def.entries)
29
+ if (type === 'union') { const opts = t._zod.def.options.map((o) => o._zod.def); info.literals = opts.every((o) => o.type === 'literal') ? opts.flatMap((o) => o.values) : null; info.hasNumber = opts.some((o) => o.type === 'number'); info.hasString = opts.some((o) => o.type === 'string'); info.hasLiteral = opts.some((o) => o.type === 'literal'); info.numeric = opts.some((o) => o.type === 'number') || (info.literals || []).every((v) => typeof v === 'number') }
30
+ if (type === 'array') { const el = t._zod.def.element._zod.def; info.element = el.type === 'union' ? 'id' : el.type; if (el.type === 'enum') info.choices = Object.values(el.entries) }
31
+ return info
32
+ }
33
+ const kebab = (s) => s.replace(/_/g, '-')
34
+ const camel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
35
+
36
+ /** Komut satırı değeri → alan türü. JSON alanlar: '{...}' / '[...]' ya da @dosya.json */
37
+ export function coerce(info, raw) {
38
+ if (raw === undefined) return undefined
39
+ const json = (v) => { const s = String(v); const txt = s.startsWith('@') ? readFileSync(s.slice(1), 'utf8') : s; try { return JSON.parse(txt) } catch { throw new Error(`invalid JSON: ${s.slice(0, 40)}`) } }
40
+ switch (info.type) {
41
+ case 'number': return Number(raw)
42
+ case 'boolean': return Boolean(raw)
43
+ case 'union': { const isNum = /^-?\d+(\.\d+)?$/.test(String(raw)); if (!isNum) return raw; if (info.literals) return info.literals.includes(Number(raw)) ? Number(raw) : raw; return info.hasNumber && !info.hasString ? Number(raw) : (info.hasNumber && info.hasLiteral ? Number(raw) : raw) }
44
+ case 'array':
45
+ if (['object', 'record'].includes(info.element) || String(raw).trim().startsWith('[') || String(raw).startsWith('@')) return json(raw)
46
+ return String(raw).split(',').map((x) => x.trim()).filter(Boolean).map((x) => (info.element === 'number' ? Number(x) : x))
47
+ case 'object': case 'record': return json(raw)
48
+ default: return raw
49
+ }
50
+ }
16
51
 
17
- async function download(url, file) {
18
- const res = await fetch(url); if (!res.ok) throw new Error(`download failed (${res.status})`)
19
- writeFileSync(file, Buffer.from(await res.arrayBuffer())); return file
52
+ // ── çıktı
53
+ const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : n)
54
+ const MONEY = /(token|balance|total|price|cost|charged|need|spent|cap|amount|per_run|per_month)/i
55
+ const fmtKV = (k, v) => (typeof v === 'number' && MONEY.test(k) ? fmt(v) : v)
56
+ const LINKY = /(url|thumb|poster|cover|preview|avatar|image)$/i
57
+ export function human(v, indent = '') {
58
+ if (v == null) return `${indent}-`
59
+ if (Array.isArray(v)) {
60
+ if (!v.length) return `${indent}(none)`
61
+ return v.map((x) => (x && typeof x === 'object' ? `${indent}- ${Object.entries(x).filter(([k, y]) => y != null && typeof y !== 'object' && !LINKY.test(k)).slice(0, 7).map(([k, y]) => `${k}: ${fmtKV(k, y)}`).join(' ')}` : `${indent}- ${x}`)).join('\n')
62
+ }
63
+ if (typeof v === 'object') return Object.entries(v).filter(([, y]) => y != null).map(([k, y]) => (y && typeof y === 'object' ? `${indent}${k}:\n${human(y, indent + ' ')}` : `${indent}${k}: ${fmtKV(k, y)}`)).join('\n')
64
+ return `${indent}${v}`
65
+ }
66
+ export function quoteText(q) {
67
+ const lines = (q.items || []).map((i) => ` ${String(i.item).padEnd(44)} ${fmt(i.tokens).padStart(12)}`)
68
+ return [`Quote${q.approximate ? ' (estimate)' : ''}:`, ...lines, ` ${'Total'.padEnd(44)} ${fmt(q.total).padStart(12)} tokens`, q.balance != null ? ` Balance: ${fmt(q.balance)} tokens` : null, q.notes ? ` ${q.notes}` : null].filter(Boolean).join('\n')
20
69
  }
21
70
 
22
- async function runJob(program, c, payload, opts) {
23
- const r = await c.createJob(payload)
24
- const jobId = r?.job_id || r?.id || r?.job?.id
25
- if (!jobId) { out(program, r); return }
26
- if (!opts.wait) { out(program, { job_id: String(jobId), status: 'queued' }, `Job ${jobId} queued. Check: yesilcam jobs status ${jobId} --wait`); return }
27
- const job = await waitForJob(c, jobId, { onTick: (j) => { if (!program.opts().json && j) process.stderr.write(`\r${j.status}${j.render_progress ? ` ${j.render_progress}%` : ''} `) } })
28
- if (!program.opts().json) process.stderr.write('\n')
29
- const d = describeJob(job)
30
- if (opts.out && d?.result?.url) { await download(d.result.url, opts.out); d.saved = opts.out }
31
- out(program, d, d.result ? `Done: ${d.result.url}${d.saved ? `\nSaved: ${d.saved}` : ''}` : `Job ${d.id}: ${d.status}${d.error ? ` (${d.error})` : ''}`)
71
+ function ask(question, { hidden = false } = {}) {
72
+ return new Promise((resolve) => {
73
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true })
74
+ if (hidden) { rl._writeToOutput = (s) => { if (s.includes(question)) rl.output.write(s) } }
75
+ rl.question(question, (ans) => { rl.close(); if (hidden) process.stderr.write('\n'); resolve(ans) })
76
+ })
77
+ }
78
+ const readStdin = async () => { let d = ''; for await (const ch of process.stdin) d += ch; return d.trim() }
79
+
80
+ export function getClient() {
81
+ const cred = getCredentials()
82
+ if (!cred) throw new ApiError('not_logged_in')
83
+ const w = permissionWarning(); if (w) process.stderr.write(`${w}\n`)
84
+ return makeClient(cred)
85
+ }
86
+
87
+ function progressLine(st) {
88
+ if (!process.stderr.isTTY || !st) return
89
+ const pct = st.progress != null ? ` ${st.progress}%` : ''
90
+ process.stderr.write(`\r${String(st.status || '').padEnd(12)}${pct}${st.stage ? ` · ${st.stage}` : ''}`.padEnd(70).slice(0, 78))
91
+ }
92
+
93
+ /** Bir işlemi commander komutuna bağlar. */
94
+ export function attachOp(cmd, op, program) {
95
+ const entries = Object.entries(op.input)
96
+ const posList = op.positional ? [].concat(op.positional) : []
97
+ for (const p of posList) { const info = fieldInfo(op.input[p]); cmd.argument(info.optional ? `[${p}]` : `<${p}>`, info.desc || p) }
98
+ for (const [name, schema] of entries) {
99
+ if (posList.includes(name)) continue
100
+ const info = fieldInfo(schema)
101
+ const flag = `--${kebab(name)}`
102
+ const hint = [info.desc, info.choices ? `(${info.choices.join('|')})` : null, info.literals ? `(${info.literals.join('|')})` : null, ['object', 'record'].includes(info.type) || ['object', 'record'].includes(info.element) ? '(JSON or @file.json)' : null, info.type === 'array' && !['object', 'record'].includes(info.element) ? '(comma-separated)' : null].filter(Boolean).join(' ')
103
+ if (info.type === 'boolean') { cmd.option(flag, hint); cmd.addOption(new Option(`--no-${kebab(name)}`).hideHelp()) }
104
+ else cmd.option(`${flag} <value>`, `${hint}${info.optional ? '' : ' (required)'}`)
105
+ }
106
+ if (op.kind === 'spend') cmd.option('-y, --yes', 'approve the quoted token cost without asking').option('--max-tokens <n>', 'refuse if the quote is above this').option('--quote', 'only show the price')
107
+ if (op.wait) cmd.option('-w, --wait', 'wait for the result').option('-o, --out <path>', 'download the result to a file or folder (implies --wait)').option('--timeout <seconds>', 'max wait (default 900)')
108
+ cmd.action(async (...argv) => {
109
+ const opts = argv[argv.length - 2] || {}
110
+ const json = program.opts().json
111
+ const args = {}
112
+ posList.forEach((p, i) => { if (argv[i] !== undefined && i < argv.length - 2) args[p] = coerce(fieldInfo(op.input[p]), argv[i]) })
113
+ for (const [name, schema] of entries) {
114
+ if (posList.includes(name)) continue
115
+ const v = opts[camel(name)]
116
+ if (v !== undefined) args[name] = coerce(fieldInfo(schema), v)
117
+ }
118
+ const parsed = z.object(op.input).safeParse(args)
119
+ if (!parsed.success) { const i = parsed.error.issues[0]; throw new Error(`--${kebab(String(i.path[0] || ''))}: ${i.message}`) }
120
+ const shared = { max_tokens: opts.maxTokens != null ? Number(opts.maxTokens) : undefined, wait: Boolean(opts.wait || opts.out), save_to: opts.out, max_wait_seconds: opts.timeout ? Number(opts.timeout) : undefined }
121
+ const c = op.kind === 'local' ? null : getClient()
122
+ const askConfirm = async (q) => {
123
+ if (opts.quote) return false
124
+ if (!process.stdin.isTTY || json) { return false }
125
+ process.stderr.write(`${quoteText(q)}\n`)
126
+ const ans = await ask(`Spend about ${fmt(q.total)} tokens? [y/N] `)
127
+ return /^y(es)?$/i.test(String(ans).trim())
128
+ }
129
+ const r = await execute(op, c, { ...parsed.data, ...shared }, { confirmed: Boolean(opts.yes) && !opts.quote, askConfirm, onProgress: json ? undefined : progressLine })
130
+ if (process.stderr.isTTY && shared.wait && !json) process.stderr.write('\n')
131
+ if (r.status === 'quote') {
132
+ if (json) console.log(JSON.stringify({ status: 'quote', quote: r.quote }, null, 2))
133
+ else { console.log(quoteText(r.quote)); console.log(opts.quote ? 'Nothing was charged.' : 'Nothing was charged. Re-run with --yes to approve.') }
134
+ process.exitCode = opts.quote ? 0 : 3; return
135
+ }
136
+ if (r.status !== 'done') { if (json) console.log(JSON.stringify(r, null, 2)); else { if (r.quote) console.log(quoteText(r.quote)); console.log(r.message || r.quote?.notes || r.status) } process.exitCode = 4; return }
137
+ if (json) { console.log(JSON.stringify({ result: r.result, ...(r.final ? { final: r.final } : {}), ...(r.track ? { track: r.track } : {}) }, null, 2)); return }
138
+ console.log(human(r.result))
139
+ if (r.final) { console.log(`\n${human({ status: r.final.status, url: r.final.url, saved: r.final.saved, error: r.final.error, next: r.final.needs_action || r.final.hint })}`) }
140
+ else if (r.track) console.log(`\nTrack: yesilcam wait ${r.track.kind} ${r.track.id} (or add --wait next time)`)
141
+ })
142
+ return cmd
32
143
  }
33
144
 
34
145
  export function buildProgram() {
35
146
  const program = new Command()
36
- program.name('yesilcam').description('Yeşilçam Studios: AI film, fashion try-on and Maket from your terminal.').version('0.1.0')
37
- .option('--json', 'machine-readable output')
147
+ program.name('yesilcam').description(`Yeşilçam Studios ${VERSION}: AI film, image, animation, design, website, fashion, Maket and AI Influencer studios from your terminal.\nPrices are shown before anything is charged. Docs: ${SITE_URL}/developers`)
148
+ .version(VERSION).option('--json', 'machine-readable JSON output').showHelpAfterError()
38
149
 
39
- program.command('login').description('Sign in with your Yeşilçam Studios account')
40
- .option('--email <email>').option('--password <password>').option('--token <userToken>', 'use an existing user token')
150
+ // ── giriş
151
+ program.command('login').description(`Sign in. Default: paste an API key (create one at ${KEYS_URL}). Stored in ${configFile()} (owner-only), never printed.`)
152
+ .option('--key-stdin', 'read the API key from stdin (CI)').option('--password', 'sign in with email + password instead (legacy session)').option('--email <email>')
41
153
  .action(async (o) => {
42
- if (o.token) { writeConfig({ ...readConfig(), token: o.token }); out(program, { ok: true }, `Token saved to ${CONFIG_FILE}`); return }
43
- let { email, password } = o
44
- if (!email || !password) {
45
- const rl = createInterface({ input, output })
46
- email = email || await rl.question('Email: ')
47
- password = password || await rl.question('Password: ')
48
- rl.close()
154
+ const json = program.opts().json
155
+ if (o.password) {
156
+ const email = o.email || await ask('Email: ')
157
+ const pw = process.env.YESILCAM_PASSWORD || await ask('Password: ', { hidden: true })
158
+ const r = await login(String(email).trim(), pw)
159
+ writeConfig({ token: r.token, user: r.user, method: 'password' })
160
+ const msg = `Signed in as ${r.user.name} ${r.user.lastname} <${r.user.email}> (session saved to ${configFile()}).`
161
+ console.log(json ? JSON.stringify({ ok: true, user: r.user, method: 'password' }) : msg); return
49
162
  }
50
- const r = await login(email.trim(), password)
51
- writeConfig({ token: r.token, user: r.user })
52
- out(program, { ok: true, user: r.user }, `Signed in as ${r.user.name} ${r.user.lastname} <${r.user.email}>. Token saved to ${CONFIG_FILE}`)
163
+ const key = (o.keyStdin ? await readStdin() : (process.env.YESILCAM_API_KEY || await ask(`API key (from ${KEYS_URL}): `, { hidden: true }))).trim()
164
+ if (!isApiKey(key)) throw new ApiError('api_key_invalid')
165
+ const s = await exchangeKey(key)
166
+ writeConfig({ apiKey: key, user: s.user, key: s.key, scopes: s.scopes, method: 'api_key' })
167
+ const msg = `Signed in as ${s.user.name} ${s.user.lastname} <${s.user.email}> with key "${s.key.name}" (${s.key.prefix}…, scopes: ${s.scopes.join(', ')}). Saved to ${configFile()}.`
168
+ console.log(json ? JSON.stringify({ ok: true, user: s.user, key: s.key, scopes: s.scopes, method: 'api_key' }) : msg)
53
169
  })
54
- program.command('logout').description('Remove the saved token').action(() => { clearConfig(); out(program, { ok: true }, 'Signed out.') })
55
- program.command('whoami').description('Account and plan').action(async () => { const r = await client().me(); out(program, r, `${r.name} ${r.lastname} · plan: ${r.subscriptionPackageType || 'social-hive'}`) })
56
- program.command('balance').description('Token balance').action(async () => { const r = await client().balance(); out(program, r, `${fmtInt(r.balance)} tokens`) })
57
- program.command('pricing').description('Production costs (tokens) and token packs').action(async () => {
58
- const c = client(); const [ops, packs] = await Promise.all([c.modulePricing(), c.tokenPackages()])
59
- const rows = ops.filter((r) => ['ai-studio', 'fashion-tryon'].includes(r.module_key) && String(r.status ?? '1') === '1').map((r) => ({ module: r.module_key, operation: r.operation_key, tokens: Number(r.base_cost_tokens), free_per_month: Number(r.monthly_free_limit || 0) })).sort((a, b) => a.tokens - b.tokens)
60
- const pk = packs.map((p) => ({ id: p.id, title: p.title, tokens: Number(p.token_amount), bonus_percent: Number(p.bonus_percentage), usd: Number(p.price_usd), usd_without_plan: p.price_usd_standard != null ? Number(p.price_usd_standard) : null, try: Number(p.price_tr), try_without_plan: p.price_tr_standard != null ? Number(p.price_tr_standard) : null }))
61
- out(program, { operations: rows, packs: pk }, [...rows.map((r) => `${r.module.padEnd(14)} ${r.operation.padEnd(24)} ${fmtInt(r.tokens).padStart(10)} tokens${r.free_per_month ? ` (${r.free_per_month} free/month)` : ''}`), '', ...pk.map((p) => `${p.title.padEnd(14)} ${fmtInt(p.tokens).padStart(10)} tokens $${p.usd} with plan / $${p.usd_without_plan ?? p.usd} without${p.bonus_percent ? ` +${p.bonus_percent}% bonus` : ''}`)].join('\n'))
170
+ program.command('logout').description('Remove the saved credentials from this computer').action(() => {
171
+ const had = readConfig(); clearConfig()
172
+ console.log(program.opts().json ? JSON.stringify({ ok: true }) : `Signed out.${had.apiKey ? ` The API key still works until you revoke it at ${KEYS_URL}` : ''}`)
62
173
  })
174
+ program.command('mcp').description('Show how to connect the MCP server to Claude Code, Claude Desktop, Cursor and others').action(() => {
175
+ console.log(`Sign in once with "yesilcam login"; the MCP server reads the same credentials (no secret in the config below).
63
176
 
64
- const assets = program.command('assets').description('Your library (images, videos)')
65
- assets.command('list').option('--kind <kind>', 'image|video').option('--limit <n>', '', '40').action(async (o) => {
66
- const r = await client().assets({ kind: o.kind, limit: Number(o.limit) })
67
- const list = r?.assets || r?.items || r || []
68
- out(program, list, list.map((a) => `${String(a.id).padEnd(8)} ${String(a.kind).padEnd(6)} ${a.label || ''} ${a.spaces_url || ''}`).join('\n') || 'No assets.')
69
- })
70
- assets.command('upload <file>').option('--kind <kind>', 'image|video|audio').option('--label <label>').action(async (file, o) => {
71
- const ext = extname(file).toLowerCase(); const mime = MIME[ext] || 'application/octet-stream'
72
- const kind = o.kind || (mime.startsWith('video') ? 'video' : mime.startsWith('audio') ? 'audio' : 'image')
73
- const b64 = `data:${mime};base64,${readFileSync(file).toString('base64')}`
74
- const r = await client().uploadAsset(b64, kind, o.label || basename(file))
75
- out(program, r, `Uploaded asset ${r?.asset_id || r?.id || ''} ${r?.url || r?.spaces_url || ''}`)
76
- })
77
-
78
- const jobs = program.command('jobs').description('Generation jobs')
79
- jobs.command('list').option('--limit <n>', '', '20').action(async (o) => { const r = await client().jobs({ limit: Number(o.limit) }); const list = r?.jobs || []; out(program, list, list.map((j) => `${String(j.id).padEnd(8)} ${String(j.job_type).padEnd(16)} ${String(j.status).padEnd(11)} ${j.quality_tier || ''}`).join('\n') || 'No jobs.') })
80
- jobs.command('status <id>').option('--wait').option('--out <file>').action(async (id, o) => {
81
- const c = client(); const job = o.wait ? await waitForJob(c, id) : (await c.jobStatus(Number(id))).job
82
- const d = describeJob(job); if (o.out && d?.result?.url) { await download(d.result.url, o.out); d.saved = o.out }
83
- out(program, d, `${d.id} ${d.type} ${d.status}${d.result ? ` ${d.result.url}` : ''}${d.saved ? `\nSaved: ${d.saved}` : ''}`)
84
- })
177
+ Claude Code:
178
+ claude mcp add yesilcam -- yesilcam-mcp
85
179
 
86
- program.command('image <prompt>').description('Generate an image (text2image)')
87
- .option('--tier <tier>', TIERS.join('|'), 'draft').option('--aspect <ratio>', ASPECTS.join('|'), '16:9').option('--framing <f>', 'waist_up|waist_down|full_body')
88
- .option('--wait', 'wait for the result').option('--out <file>', 'download result (implies --wait)')
89
- .action(async (prompt, o) => runJob(program, client(), imageJob({ prompt, tier: o.tier, aspect: o.aspect, framing: o.framing }), { wait: o.wait || Boolean(o.out), out: o.out }))
90
- program.command('video [prompt]').description('Generate a video (text2video, or image2video with --from)')
91
- .option('--from <assetId>', 'source image asset id').option('--tier <tier>', TIERS.join('|'), 'draft').option('--aspect <ratio>', '', '16:9').option('--duration <s>').option('--resolution <r>', '480p|720p|1080p').option('--audio', 'generate audio')
92
- .option('--wait').option('--out <file>')
93
- .action(async (prompt = '', o) => runJob(program, client(), videoJob({ prompt, tier: o.tier, aspect: o.aspect, sourceAssetId: o.from, duration: o.duration, resolution: o.resolution, generateAudio: o.audio }), { wait: o.wait || Boolean(o.out), out: o.out }))
94
- program.command('tryon').description('Fashion try-on: garment on a person or character')
95
- .requiredOption('--garment <assetId>').option('--person <assetId>').option('--character <id>').option('--prompt <text>', 'style note').option('--wait').option('--out <file>')
96
- .action(async (o) => { if (!o.person && !o.character) { console.error('--person <assetId> or --character <id> is required'); process.exit(2) } return runJob(program, client(), tryonJob({ garmentAssetId: o.garment, personAssetId: o.person, characterId: o.character, prompt: o.prompt }), { wait: o.wait || Boolean(o.out), out: o.out }) })
97
- program.command('characters').description('List your characters').action(async () => { const r = await client().characters(); const list = r?.characters || r || []; out(program, list, list.map((c) => `${String(c.id).padEnd(8)} ${c.name || ''}`).join('\n') || 'No characters.') })
180
+ Claude Desktop (claude_desktop_config.json), Cursor (~/.cursor/mcp.json), Windsurf, VS Code and others:
181
+ ${JSON.stringify({ mcpServers: { yesilcam: { command: 'yesilcam-mcp', args: [] } } }, null, 2)}
98
182
 
99
- const maket = program.command('maket').description('Floor plan → 3D (Maket)')
100
- maket.command('analyze <file>').description('Analyze a DXF/JSON plan locally (free)').action((file) => { const a = analyzePlanText(readFileSync(file, 'utf8'), basename(file)); out(program, a, `${a.name}: ${a.stats.wallCount} walls, ${a.stats.doorCount} doors, ${a.stats.windowCount} windows, ${a.stats.roomCount} rooms, gross ${a.stats.grossArea?.toFixed?.(1)} m² (${a.units.name}${a.units.guessed ? ', guessed' : ''})\nlayers: ${a.layers.map((l) => `${l.name}=${l.role}`).join(', ')}`) })
101
- maket.command('list').description('Saved Maket projects').action(async () => { const r = await client().maketList(); out(program, r.items, r.items.map((p) => `${String(p.id).padEnd(6)} ${p.title} ${p.source_kind} ${new Date(p.updated_date_time * 1000).toISOString().slice(0, 10)}`).join('\n') || 'No projects.') })
102
- maket.command('save <file>').description('Save a plan as a Maket project').option('--title <title>').action(async (file, o) => {
103
- const text = readFileSync(file, 'utf8'); const a = analyzePlanText(text, basename(file))
104
- const r = await client().maketCreate({ title: o.title || basename(file, extname(file)), source_kind: a.source_kind, source_text: text, settings: { overrides: {}, wallHeight: 2.9 }, stats: a.stats })
105
- out(program, r, `Saved project ${r.id}. Open it at ${SITE_URL}/app/maket`)
183
+ Without a global install: "command": "npx", "args": ["-y", "yesilcam-studios", "yesilcam-mcp"]
184
+ Fewer tools: "args": ["--studios", "image,film,influencer"] · Read-only: "args": ["--read-only"]
185
+ On a server or CI, set YESILCAM_API_KEY in the environment instead of signing in.`)
106
186
  })
107
- maket.command('get <id>').description('Download a project’s plan').option('--out <file>').action(async (id, o) => { const r = await client().maketGet(Number(id)); const p = r.project; const file = o.out || `${p.title || 'maket'}.${p.source_kind === 'json' ? 'json' : 'dxf'}`; writeFileSync(file, p.source_text); out(program, { id: p.id, title: p.title, saved: file }, `Saved: ${file}`) })
108
- maket.command('delete <id>').action(async (id) => { await client().maketDelete(Number(id)); out(program, { ok: true }, 'Deleted.') })
109
187
 
110
- program.command('mcp').description('Print the Claude MCP configuration snippet').action(() => {
111
- console.log(JSON.stringify({ mcpServers: { yesilcam: { command: 'yesilcam-mcp', env: { YESILCAM_TOKEN: '<your token: yesilcam login --json>' } } } }, null, 2))
112
- })
188
+ // ── katalogdan komutlar
189
+ const groups = new Map()
190
+ for (const op of OPS) {
191
+ if (!groups.has(op.group)) { const g = program.command(op.group).description(GROUPS[op.group] || op.group); groups.set(op.group, g); if (op.group === 'library') g.alias('assets') }
192
+ const g = groups.get(op.group)
193
+ const sub = g.command(op.action, { isDefault: DEFAULT_ACTION[op.group] === op.action }).description(`${op.summary}${op.kind === 'spend' ? ' [tokens]' : ''}`)
194
+ attachOp(sub, op, program)
195
+ }
196
+ // kısayollar (0.1 / 0.2 uyumu)
197
+ const byName = Object.fromEntries(OPS.map((o) => [o.name, o]))
198
+ for (const [alias, name] of [['balance', 'balance'], ['pricing', 'pricing'], ['whoami', 'account'], ['tryon', 'tryon']]) attachOp(program.command(alias).description(byName[name].summary), byName[name], program)
113
199
  return program
114
200
  }
115
201
 
116
202
  export async function main(argv) {
117
203
  const program = buildProgram()
118
204
  try { await program.parseAsync(argv) }
119
- catch (e) { if (e instanceof ApiError || e?.message) { console.error(`Error: ${e.message}`); process.exit(1) } throw e }
205
+ catch (e) {
206
+ const msg = e instanceof ApiError ? friendlyError(e) : (e?.message || String(e))
207
+ if (program.opts().json) console.log(JSON.stringify({ error: e?.code || 'error', message: msg }))
208
+ else console.error(`Error: ${msg}`)
209
+ process.exitCode = e?.code === 'not_logged_in' ? 2 : 1
210
+ }
120
211
  }
212
+ export { checkOnce }
package/src/client.js CHANGED
@@ -1,16 +1,26 @@
1
- import { API_URL, APP_KEY, APP_SECRET, REGISTRATION_SOURCE } from './constants.js'
1
+ // Apiollon istemcisi: POST <API>/flow/<uç>, JSON gövde, Bearer belirteç; yanıt { status, result } (hata da HTTP 200 + status:false).
2
+ // Kimlik: (1) API anahtarı (ysk_…) → ys-apikey-exchange ile 1 saatlik oturum (bellekte; süresi dolunca yenilenir),
3
+ // (2) eski usul kullanıcı oturum belirteci (e-posta/şifre girişi). Gizli bilgiler hiçbir zaman yazdırılmaz.
4
+ import { API_URL, APP_KEY, APP_SECRET } from './constants.js'
2
5
 
3
6
  const base = API_URL.replace(/\/+$/, '') + '/'
4
7
  let appTokenCache = null
5
8
 
6
- export class ApiError extends Error { constructor(message, data) { super(message); this.data = data } }
9
+ export class ApiError extends Error {
10
+ constructor(message, data) { super(message); this.code = String(message || ''); this.data = data }
11
+ }
7
12
 
8
13
  async function post(path, body, bearer, fetchImpl = globalThis.fetch) {
9
- const headers = { 'Content-Type': 'application/json' }
14
+ const headers = { 'Content-Type': 'application/json', 'User-Agent': 'yesilcam-studios-cli' }
10
15
  if (bearer) headers.Authorization = `Bearer ${bearer}`
11
- const res = await fetchImpl(base + path, { method: 'POST', headers, body: JSON.stringify(body || {}) })
16
+ let res
17
+ try { res = await fetchImpl(base + path, { method: 'POST', headers, body: JSON.stringify(body || {}) }) }
18
+ catch (e) { throw new ApiError('network_error', { cause: String(e?.message || e) }) }
12
19
  const data = await res.json().catch(() => null)
13
- if (!data || data.status !== true) throw new ApiError((data && (data.message || data?.result?.error)) || `request_failed (${res.status})`, data)
20
+ if (!data || data.status !== true) {
21
+ const msg = (data && (data.message || data?.result?.error)) || `request_failed (${res.status})`
22
+ throw new ApiError(String(msg).split(' — ')[0], data)
23
+ }
14
24
  return data.result
15
25
  }
16
26
 
@@ -21,8 +31,9 @@ export async function getAppToken(fetchImpl) {
21
31
  appTokenCache = r.token
22
32
  return appTokenCache
23
33
  }
34
+ export function _resetAppToken() { appTokenCache = null }
24
35
 
25
- /** E-posta + şifre → kullanıcı token'ı ve profil. */
36
+ /** E-posta + şifre → kullanıcı oturum belirteci ve profil (eski yöntem; API anahtarı önerilir). */
26
37
  export async function login(email, password, fetchImpl) {
27
38
  const app = await getAppToken(fetchImpl)
28
39
  const r = await post('flow/user-login', { credential: email, password }, app, fetchImpl)
@@ -30,25 +41,84 @@ export async function login(email, password, fetchImpl) {
30
41
  return { token: r.token, user: { id: String(r.id), name: r.name || '', lastname: r.lastname || '', email: r.email || '' } }
31
42
  }
32
43
 
33
- export function makeClient(userToken, fetchImpl) {
34
- if (!userToken) throw new ApiError('not_logged_in')
35
- const flow = (name, body) => post(`flow/${name}`, body, userToken, fetchImpl)
44
+ /** API anahtarı → kısa ömürlü oturum ({ token, expires_at, scopes, key, user }). */
45
+ export async function exchangeKey(apiKey, fetchImpl) {
46
+ const app = await getAppToken(fetchImpl)
47
+ try { return await post('flow/ys-apikey-exchange', { key: apiKey }, app, fetchImpl) }
48
+ catch (e) {
49
+ if (/invalid method/i.test(e.message)) throw new ApiError('api_keys_not_enabled', e.data)
50
+ throw e
51
+ }
52
+ }
53
+
54
+ export const isApiKey = (s) => /^ysk_[a-f0-9]{48}$/.test(String(s || '').trim())
55
+
56
+ const SESSION_ERRORS = /^(api_session_expired|api_session_invalid|invalid token)$/i
57
+
58
+ /**
59
+ * makeClient({ apiKey } | { token }) ya da makeClient('<token>') (geriye uyum).
60
+ * c.call(uç, gövde) → result. c.session() → anahtar oturum bilgisi (kapsamlar, kullanıcı).
61
+ */
62
+ export function makeClient(cred, fetchImpl) {
63
+ const creds = typeof cred === 'string' ? (isApiKey(cred) ? { apiKey: cred } : { token: cred }) : (cred || {})
64
+ if (!creds.apiKey && !creds.token) throw new ApiError('not_logged_in')
65
+ let session = null
66
+ const bearer = async (force = false) => {
67
+ if (!creds.apiKey) return creds.token
68
+ if (force || !session || session.expires_at * 1000 - Date.now() < 60_000) session = await exchangeKey(creds.apiKey, fetchImpl)
69
+ return session.token
70
+ }
71
+ const call = async (name, body = {}) => {
72
+ try { return await post(`flow/${name}`, body, await bearer(), fetchImpl) }
73
+ catch (e) {
74
+ if (creds.apiKey && SESSION_ERRORS.test(e.message)) return post(`flow/${name}`, body, await bearer(true), fetchImpl)
75
+ throw e
76
+ }
77
+ }
36
78
  return {
37
- flow,
38
- balance: () => flow('get-token-balance'),
39
- me: () => flow('get-user-subscription-infos'),
40
- modulePricing: () => flow('get-all-module-pricing'),
41
- tokenPackages: () => flow('get-token-packages'),
42
- routes: () => flow('aistudio-list-routes'),
43
- assets: (p = {}) => flow('aistudio-asset-list', p),
44
- uploadAsset: (fileBase64, kind = 'image', label = '') => flow('aistudio-upload-asset', { file_base64: fileBase64, kind, label }),
45
- createJob: (p) => flow('aistudio-create-job', p),
46
- jobStatus: (jobId) => flow('aistudio-job-status', { job_id: jobId }),
47
- jobs: (p = {}) => flow('aistudio-job-list', p),
48
- characters: () => flow('aistudio-character-list'),
49
- maketList: () => flow('maket-list-projects'),
50
- maketGet: (projectId) => flow('maket-get-project', { projectId }),
51
- maketCreate: (p) => flow('maket-create-project', p),
52
- maketDelete: (projectId) => flow('maket-delete-project', { projectId }),
79
+ call,
80
+ flow: call,
81
+ kind: creds.apiKey ? 'api_key' : 'token',
82
+ session: async () => { if (creds.apiKey) await bearer(); return session },
83
+ // geriye uyum kısayolları (0.2)
84
+ balance: () => call('get-token-balance'),
85
+ me: () => call('get-user-subscription-infos'),
86
+ jobStatus: (jobId) => call('aistudio-job-status', { job_id: Number(jobId) }),
53
87
  }
54
88
  }
89
+
90
+ /** Sunucu hata kodu → sade İngilizce açıklama (bilinmeyen kod olduğu gibi döner). */
91
+ const FRIENDLY = {
92
+ 'Insufficient tokens': 'Not enough tokens. Top up or upgrade at https://yesilcamstudios.com/app/billing',
93
+ insufficient_tokens: 'Not enough tokens. Top up or upgrade at https://yesilcamstudios.com/app/billing',
94
+ subscription_required: 'This needs a Basic or Pro plan: https://yesilcamstudios.com/app/billing',
95
+ pro_required: 'This needs the Pro plan: https://yesilcamstudios.com/app/billing',
96
+ too_many_running: 'Too many jobs are running at once. Wait for one to finish and try again.',
97
+ minor_text: 'The text seems to describe a minor. Characters must be adults.',
98
+ public_figure_text: 'Real public figures cannot be used.',
99
+ unsafe_content: 'The request was blocked by the safety check.',
100
+ safety_filter: 'The request was blocked by the safety check.',
101
+ consent_required: 'Explicit consent is required for this upload (pass consent / --consent).',
102
+ not_logged_in: 'Not signed in. Run: yesilcam login',
103
+ api_keys_not_enabled: 'API keys are not enabled on the server yet. Use: yesilcam login --password',
104
+ api_key_invalid: 'The API key is not valid. Create a new one at https://yesilcamstudios.com/app/account/developer',
105
+ api_key_revoked: 'This API key was revoked. Create a new one at https://yesilcamstudios.com/app/account/developer',
106
+ api_key_expired: 'This API key has expired. Create a new one at https://yesilcamstudios.com/app/account/developer',
107
+ api_key_scope: 'This API key is read-only. Create a key with the "write" scope to create, edit or spend tokens.',
108
+ api_key_forbidden: 'API keys cannot do this (account, payment and key management stay in the web app).',
109
+ social_unavailable: 'Publishing to social media is coming soon.',
110
+ edit_unavailable: 'Long videos are not available on this account yet.',
111
+ auto_unavailable: 'Automations are not available on this account yet.',
112
+ network_error: 'Could not reach the Yeşilçam Studios API. Check your connection.',
113
+ quote_changed: 'The price changed since the quote. Get a fresh quote and confirm again.',
114
+ voice_not_set: 'Set a voice for this character first (influencer voice-set).',
115
+ edit_busy: 'The project is busy (planning or producing). Wait and try again.',
116
+ still_running: 'It is still running. Wait for it to finish first.',
117
+ 'Invalid method': 'This feature is not available on the server yet.',
118
+ }
119
+ export function friendlyError(e) {
120
+ const code = String(e?.code || e?.message || e || '')
121
+ const extra = e?.data && typeof e.data === 'object' ? ['need', 'total', 'reason'].filter((k) => e.data[k] != null).map((k) => `${k}: ${e.data[k]}`).join(', ') : ''
122
+ const text = FRIENDLY[code] || code || 'Unknown error'
123
+ return extra ? `${text} (${extra})` : text
124
+ }
package/src/config.js CHANGED
@@ -1,18 +1,40 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
 
5
- export const CONFIG_DIR = process.env.YESILCAM_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'yesilcam')
6
- export const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
5
+ // Yol çağrı anında çözülür (YESILCAM_CONFIG_DIR sonradan değişebilir: testler, birden çok hesap)
6
+ export const configDir = () => process.env.YESILCAM_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'yesilcam')
7
+ export const configFile = () => join(configDir(), 'config.json')
8
+ export const CONFIG_DIR = configDir()
9
+ export const CONFIG_FILE = configFile()
7
10
 
8
11
  export function readConfig() {
9
- try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) } catch { return {} }
12
+ try { return JSON.parse(readFileSync(configFile(), 'utf8')) } catch { return {} }
10
13
  }
14
+ /** Yapılandırma dosyası yalnız sahibine açık (dizin 0700, dosya 0600). */
11
15
  export function writeConfig(cfg) {
12
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
13
- writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
14
- try { chmodSync(CONFIG_FILE, 0o600) } catch {}
16
+ const dir = configDir(); const file = configFile()
17
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
18
+ try { chmodSync(dir, 0o700) } catch {}
19
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
20
+ try { chmodSync(file, 0o600) } catch {}
15
21
  }
16
- export function clearConfig() { if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE) }
17
- /** Öncelik: YESILCAM_TOKEN env > config dosyası. */
18
- export function getUserToken() { return process.env.YESILCAM_TOKEN || readConfig().token || '' }
22
+ export function clearConfig() { if (existsSync(configFile())) unlinkSync(configFile()) }
23
+ /** Dosya başkalarınca okunabiliyorsa uyarı metni (Windows'ta izin bitleri anlamsız → null). */
24
+ export function permissionWarning() {
25
+ const file = configFile()
26
+ if (process.platform === 'win32' || !existsSync(file)) return null
27
+ try { const m = statSync(file).mode & 0o077; return m ? `Warning: ${file} is readable by other users. Run: chmod 600 ${file}` : null } catch { return null }
28
+ }
29
+
30
+ /** Öncelik: YESILCAM_API_KEY > YESILCAM_TOKEN > yapılandırma dosyası (apiKey, sonra token). */
31
+ export function getCredentials() {
32
+ if (process.env.YESILCAM_API_KEY) return { apiKey: process.env.YESILCAM_API_KEY.trim(), source: 'env' }
33
+ if (process.env.YESILCAM_TOKEN) return { token: process.env.YESILCAM_TOKEN.trim(), source: 'env' }
34
+ const c = readConfig()
35
+ if (c.apiKey) return { apiKey: c.apiKey, source: 'file' }
36
+ if (c.token) return { token: c.token, source: 'file' }
37
+ return null
38
+ }
39
+ /** Geriye uyum (0.2). */
40
+ export function getUserToken() { const c = getCredentials(); return c ? (c.apiKey || c.token) : '' }
package/src/constants.js CHANGED
@@ -1,6 +1,9 @@
1
- // Uygulama anahtarı: web uygulamasıyla aynı (get-token). Kullanıcı token'ı ayrı alınır ve diske yazılır.
1
+ // Uygulama anahtarı web uygulamasıyla aynıdır (herkese açık istemci kimliği; get-token). Kullanıcı kimliği ayrıca alınır.
2
2
  export const API_URL = process.env.YESILCAM_API_URL || 'https://prod.apiollon.com/'
3
3
  export const APP_KEY = process.env.YESILCAM_APP_KEY || 'F0RRDrlPigAYTmOSweyhBmo6aYZOLpTA8qGCGQe2wkmDHJYBF9'
4
4
  export const APP_SECRET = process.env.YESILCAM_APP_SECRET || 'anHHiSHfgOZ5zJKxWGJPO2PPNdIgL2HwdgUchEuC7bh5BLU'
5
5
  export const REGISTRATION_SOURCE = 'yesilcam-web'
6
6
  export const SITE_URL = 'https://yesilcamstudios.com'
7
+ export const APP_URL = `${SITE_URL}/app`
8
+ export const KEYS_URL = `${APP_URL}/account/developer`
9
+ export const VERSION = '0.3.0'