Skip to content

Commit d61ba50

Browse files
committed
Harden the stable CLI release
1 parent d114d57 commit d61ba50

9 files changed

Lines changed: 246 additions & 44 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
- Added independently configurable authentication and Comic API domains for local and isolated test environments.
88
- Moved local API credentials into a dedicated mode-0600 credentials file with atomic writes.
99
- Recognized the Comic API `PROCESSED` terminal status when waiting for generations.
10+
- Kept runtime version output in sync with package metadata and fixed execution through npm's symlinked binary.
11+
- Added strict option, range, failure-status, and zero-based page/panel validation against the official Comic SDK contract.
1012
- Added Comic API CLI commands with promptUrl for uploaded Word, PDF, or TXT script files.
1113
- Added Animation API CLI commands.
1214
- Added local config management, CI, tests, and npm packaging metadata.

‎CODEGEN_VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
manual-0.1.0-beta.1
1+
manual-0.1.0

‎README.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ Inspect and continue a generation:
100100
llamagen comic get <generation_id>
101101
llamagen comic continue <generation_id> --prompt "Continue for four panels"
102102
llamagen comic update-panel <generation_id> \
103-
--page 1 \
103+
--page 0 \
104104
--panel 2 \
105105
--prompt "Move the camera closer to the hero"
106106
```
107107

108+
Page and panel indexes are zero-based, matching the Comic API and SDK.
109+
108110
Check Comic API usage:
109111

110112
```bash
@@ -162,10 +164,10 @@ llamagen auth login [--no-browser] [--json]
162164
llamagen auth status [--offline] [--json]
163165
llamagen auth logout [--json]
164166
165-
llamagen comic create --prompt <prompt> [--wait]
166-
llamagen comic get <generation_id>
167-
llamagen comic continue <generation_id> --prompt <prompt>
168-
llamagen comic update-panel <generation_id> --page <n> --panel <n> --prompt <prompt>
167+
llamagen comic create --prompt <prompt> [--fix-panel-num <1-20>] [--wait]
168+
llamagen comic get <generation_id> [--page <n>] [--panel <n>]
169+
llamagen comic continue <generation_id> --prompt <prompt> [--fix-panel-num <1-20>]
170+
llamagen comic update-panel <generation_id> [--page <n>] --panel <n> (--prompt <prompt> | --panel-prompt <prompt>)
169171
llamagen comic usage
170172
171173
llamagen config set <site-url|api-url|api-key> <value>

‎bin/llamagen.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
#!/usr/bin/env node
22

3-
import path from 'node:path';
4-
import { pathToFileURL } from 'node:url';
3+
import fs from 'node:fs';
4+
import { fileURLToPath } from 'node:url';
55

66
import { main } from '../src/cli.js';
77

8-
export { main } from '../src/cli.js';
8+
export { main, VERSION } from '../src/cli.js';
99

1010
const isDirect =
1111
process.argv[1] &&
12-
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
12+
fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
1313

1414
if (isDirect) {
1515
const code = await main();

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
],
1616
"scripts": {
1717
"test": "node --test",
18-
"lint": "node --check bin/llamagen.js && node --check src/auth.js && node --check src/cli.js && node --check src/config.js && node --check src/errors.js && node --check src/http.js",
18+
"lint": "node --check bin/llamagen.js && node --check src/auth.js && node --check src/cli.js && node --check src/config.js && node --check src/errors.js && node --check src/http.js && node --check src/version.js",
1919
"prepack": "npm run lint && npm test"
2020
},
2121
"keywords": [

‎src/auth.js‎

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
} from './config.js';
1414
import { CliError } from './errors.js';
1515
import { requestJson } from './http.js';
16+
import { VERSION } from './version.js';
1617

17-
const VERSION = '0.1.0-beta.1';
1818
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
1919

2020
export async function runAuth(args, globals, env, io, runtime = {}) {
@@ -30,7 +30,7 @@ export async function runAuth(args, globals, env, io, runtime = {}) {
3030
}
3131

3232
async function authLogin(args, globals, env, io, runtime) {
33-
const options = parseAuthOptions(args);
33+
const options = parseAuthOptions(args, new Set(['json', 'noBrowser', 'loginTimeoutMs']));
3434
const config = readConfig(env);
3535
const siteUrl = resolveSiteUrl(globals, env, config);
3636
const verifier = randomBytes(32).toString('base64url');
@@ -55,15 +55,16 @@ async function authLogin(args, globals, env, io, runtime) {
5555
headers: { 'user-agent': `llamagen-cli/${VERSION}` },
5656
timeoutMs: Number(globals.timeoutMs || 30000),
5757
});
58-
if (!start?.authorizeUrl) {
58+
const authorizeUrl = normalizeAuthorizeUrl(start?.authorizeUrl, siteUrl);
59+
if (!authorizeUrl) {
5960
throw new CliError('LlamaGen did not return a browser authorization URL.', 1);
6061
}
6162

6263
const progressOutput = options.json ? io.stderr : io.stdout;
63-
progressOutput.write(`Opening ${start.authorizeUrl}\n`);
64+
progressOutput.write(`Opening ${authorizeUrl}\n`);
6465
if (!options.noBrowser) {
6566
try {
66-
await (runtime.openBrowser || openBrowser)(start.authorizeUrl);
67+
await (runtime.openBrowser || openBrowser)(authorizeUrl);
6768
} catch {
6869
io.stderr.write('Could not open the browser automatically. Open the URL above manually.\n');
6970
}
@@ -139,7 +140,7 @@ async function authLogin(args, globals, env, io, runtime) {
139140
}
140141

141142
async function authStatus(args, globals, env, io) {
142-
const options = parseAuthOptions(args);
143+
const options = parseAuthOptions(args, new Set(['json', 'offline']));
143144
const resolved = resolveCredential(globals, env);
144145
if (!resolved.apiToken) {
145146
const result = { authenticated: false, status: 'not_authenticated', source: 'none' };
@@ -182,7 +183,7 @@ async function authStatus(args, globals, env, io) {
182183
}
183184

184185
function authLogout(args, env, io) {
185-
const options = parseAuthOptions(args);
186+
const options = parseAuthOptions(args, new Set(['json']));
186187
const removed = removeCredential(env);
187188
const environmentStillActive = Boolean(env.LLAMAGEN_API_KEY);
188189
const result = {
@@ -302,19 +303,46 @@ function openBrowser(url) {
302303
});
303304
}
304305

305-
function parseAuthOptions(args) {
306+
function parseAuthOptions(args, allowed) {
306307
const options = {};
307308
for (let index = 0; index < args.length; index += 1) {
308309
const arg = args[index];
309-
if (arg === '--json') options.json = true;
310-
else if (arg === '--offline') options.offline = true;
311-
else if (arg === '--no-browser') options.noBrowser = true;
312-
else if (arg === '--login-timeout-ms') options.loginTimeoutMs = args[++index];
310+
let key;
311+
if (arg === '--json') key = 'json';
312+
else if (arg === '--offline') key = 'offline';
313+
else if (arg === '--no-browser') key = 'noBrowser';
314+
else if (arg === '--login-timeout-ms') key = 'loginTimeoutMs';
313315
else throw new CliError(`Unknown auth option: ${arg}`, 2);
316+
if (!allowed.has(key)) throw new CliError(`Unsupported option for this auth command: ${arg}`, 2);
317+
if (key === 'loginTimeoutMs') {
318+
const value = args[++index];
319+
options[key] = positiveInteger(value, arg);
320+
} else {
321+
options[key] = true;
322+
}
314323
}
315324
return options;
316325
}
317326

327+
function normalizeAuthorizeUrl(value, siteUrl) {
328+
if (typeof value !== 'string') return null;
329+
try {
330+
const authorizeUrl = new URL(value);
331+
if (authorizeUrl.origin !== new URL(siteUrl).origin) return null;
332+
return authorizeUrl.toString();
333+
} catch {
334+
return null;
335+
}
336+
}
337+
338+
function positiveInteger(value, flag) {
339+
const parsed = Number(value);
340+
if (!value || !Number.isSafeInteger(parsed) || parsed <= 0) {
341+
throw new CliError(`${flag} requires a positive integer.`, 2);
342+
}
343+
return parsed;
344+
}
345+
318346
function printStatus(io, result, json) {
319347
if (json) {
320348
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);

‎src/cli.js‎

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,21 @@ import {
1010
} from './config.js';
1111
import { runAuth, requestComicApi } from './auth.js';
1212
import { CliError } from './errors.js';
13+
import { VERSION } from './version.js';
1314

14-
export const VERSION = '0.1.0-beta.1';
15+
export { VERSION } from './version.js';
16+
17+
const SUPPORTED_COMIC_SIZES = new Set([
18+
'1024x1024',
19+
'512x768',
20+
'512x1024',
21+
'576x1024',
22+
'768x1024',
23+
'1024x768',
24+
'768x512',
25+
'1024x576',
26+
'1024x512',
27+
]);
1528

1629
export async function main(
1730
argv = process.argv.slice(2),
@@ -30,10 +43,10 @@ export async function main(
3043
io.stdout.write(`llamagen ${VERSION}\n`);
3144
return 0;
3245
}
33-
if (command === 'auth') return runAuth(args, globals, env, io, runtime);
46+
if (command === 'auth') return await runAuth(args, globals, env, io, runtime);
3447
if (command === 'config') return runConfig(args, env, io);
35-
if (command === 'comic') return runComic(args, globals, env, io);
36-
if (command === 'animation') return runAnimation(args, globals, env, io);
48+
if (command === 'comic') return await runComic(args, globals, env, io);
49+
if (command === 'animation') return await runAnimation(args, globals, env, io);
3750
throw new CliError(`Unknown command: ${command}`, 2);
3851
} catch (error) {
3952
const code = error instanceof CliError ? error.code : 1;
@@ -50,42 +63,77 @@ async function runComic(args, globals, env, io) {
5063
}
5164
if (subcommand === 'create') {
5265
const options = parseOptions(args);
66+
assertAllowedOptions(options, [
67+
'prompt', 'promptUrl', 'size', 'preset', 'style', 'language', 'fixPanelNum', 'wait',
68+
], 'comic create');
5369
requireOption(options, 'prompt');
70+
const size = options.size || '1024x1024';
71+
if (!SUPPORTED_COMIC_SIZES.has(size)) {
72+
throw new CliError(`Unsupported comic size: ${size}`, 2);
73+
}
5474
const body = compact({
5575
prompt: options.prompt,
5676
promptUrl: options.promptUrl,
57-
size: options.size,
58-
preset: options.preset,
77+
size,
78+
preset: options.preset || 'neutral',
5979
style: options.style,
6080
language: options.language,
61-
fixPanelNum: numberOption(options.fixPanelNum),
81+
fixPanelNum: integerOption(options.fixPanelNum, '--fix-panel-num', 1, 20),
6282
});
6383
const job = await apiRequest('POST', '/v1/comics/generations', body, globals, env);
64-
if (options.wait) writeJson(io, await waitForComic(job.id, globals, env));
65-
else writeJson(io, job);
84+
if (options.wait) {
85+
if (typeof job.id !== 'string' || !job.id) {
86+
throw new CliError('Comic API did not return a generation ID.', 1);
87+
}
88+
const completed = await waitForComic(job.id, globals, env);
89+
writeJson(io, completed);
90+
if (isFailureStatus(completed.status)) {
91+
io.stderr.write(`Comic generation ${job.id} ended with status ${completed.status}.\n`);
92+
return 1;
93+
}
94+
} else writeJson(io, job);
6695
return 0;
6796
}
6897
if (subcommand === 'get') {
6998
const id = args.shift();
7099
if (!id) throw new CliError('Usage: llamagen comic get <generation_id>', 2);
71-
writeJson(io, await apiRequest('GET', `/v1/comics/generations/${encodeURIComponent(id)}`, undefined, globals, env));
100+
const options = parseOptions(args);
101+
assertAllowedOptions(options, ['page', 'panel'], 'comic get');
102+
const query = new URLSearchParams();
103+
const page = integerOption(options.page, '--page', 0);
104+
const panel = integerOption(options.panel, '--panel', 0);
105+
if (page !== undefined) query.set('page', String(page));
106+
if (panel !== undefined) query.set('panel', String(panel));
107+
const suffix = query.size ? `?${query.toString()}` : '';
108+
writeJson(io, await apiRequest('GET', `/v1/comics/generations/${encodeURIComponent(id)}${suffix}`, undefined, globals, env));
72109
return 0;
73110
}
74111
if (subcommand === 'continue') {
75112
const id = args.shift();
76113
if (!id) throw new CliError('Usage: llamagen comic continue <generation_id> --prompt <prompt>', 2);
77114
const options = parseOptions(args);
115+
assertAllowedOptions(options, ['prompt', 'fixPanelNum'], 'comic continue');
78116
requireOption(options, 'prompt');
79-
writeJson(io, await apiRequest('PATCH', `/v1/comics/generations/${encodeURIComponent(id)}`, { prompt: options.prompt, action: 'continueWrite' }, globals, env));
117+
writeJson(io, await apiRequest('PATCH', `/v1/comics/generations/${encodeURIComponent(id)}`, compact({
118+
prompt: options.prompt,
119+
fixPanelNum: integerOption(options.fixPanelNum, '--fix-panel-num', 1, 20),
120+
action: 'continueWrite',
121+
}), globals, env));
80122
return 0;
81123
}
82124
if (subcommand === 'update-panel') {
83125
const id = args.shift();
84126
if (!id) throw new CliError('Usage: llamagen comic update-panel <generation_id> --page <n> --panel <n> --prompt <prompt>', 2);
85127
const options = parseOptions(args);
128+
assertAllowedOptions(options, ['page', 'panel', 'prompt', 'panelPrompt'], 'comic update-panel');
129+
const panel = integerOption(options.panel, '--panel', 0);
130+
if (panel === undefined) throw new CliError('Missing required option --panel', 2);
131+
if (!hasNonEmptyString(options.prompt) && !hasNonEmptyString(options.panelPrompt)) {
132+
throw new CliError('Missing required option --prompt or --panel-prompt', 2);
133+
}
86134
const body = compact({
87-
page: numberOption(options.page),
88-
panel: numberOption(options.panel),
135+
page: integerOption(options.page, '--page', 0),
136+
panel,
89137
prompt: options.prompt,
90138
panelPrompt: options.panelPrompt,
91139
action: 'regeneratePanel',
@@ -108,6 +156,7 @@ async function runAnimation(args, globals, env, io) {
108156
}
109157
if (subcommand === 'create') {
110158
const options = parseOptions(args);
159+
assertAllowedOptions(options, ['prompt'], 'animation create');
111160
requireOption(options, 'prompt');
112161
writeJson(io, await apiRequest('POST', '/v1/artworks/generations', { prompt: options.prompt }, globals, env));
113162
return 0;
@@ -222,8 +271,8 @@ function parseGlobals(argv) {
222271
else if (arg === '--site-url') globals.siteUrl = requiredGlobalValue(argv, ++index, arg);
223272
else if (arg === '--api-url') globals.apiUrl = requiredGlobalValue(argv, ++index, arg);
224273
else if (arg === '--base-url') globals.baseUrl = requiredGlobalValue(argv, ++index, arg);
225-
else if (arg === '--timeout-ms') globals.timeoutMs = requiredGlobalValue(argv, ++index, arg);
226-
else if (arg === '--poll-interval-ms') globals.pollIntervalMs = requiredGlobalValue(argv, ++index, arg);
274+
else if (arg === '--timeout-ms') globals.timeoutMs = integerOption(requiredGlobalValue(argv, ++index, arg), arg, 1);
275+
else if (arg === '--poll-interval-ms') globals.pollIntervalMs = integerOption(requiredGlobalValue(argv, ++index, arg), arg, 1);
227276
else args.push(arg);
228277
}
229278
return { args, globals };
@@ -257,13 +306,35 @@ function requireOption(options, key) {
257306
}
258307
}
259308

260-
function numberOption(value) {
261-
if (value === undefined || value === true) return undefined;
309+
function integerOption(value, flag, min, max = Number.MAX_SAFE_INTEGER) {
310+
if (value === undefined) return undefined;
311+
if (value === true) {
312+
throw new CliError(`${flag} requires a value.`, 2);
313+
}
262314
const parsed = Number(value);
263-
if (!Number.isFinite(parsed)) throw new CliError(`Expected numeric value, got ${value}`, 2);
315+
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
316+
const range = max === Number.MAX_SAFE_INTEGER ? `${min} or greater` : `${min} to ${max}`;
317+
throw new CliError(`${flag} must be an integer from ${range}.`, 2);
318+
}
264319
return parsed;
265320
}
266321

322+
function assertAllowedOptions(options, allowed, command) {
323+
const allowedSet = new Set(allowed);
324+
const unknown = Object.keys(options).find((key) => !allowedSet.has(key));
325+
if (unknown) {
326+
throw new CliError(`Unknown option for ${command}: --${kebabCase(unknown)}`, 2);
327+
}
328+
}
329+
330+
function hasNonEmptyString(value) {
331+
return typeof value === 'string' && value.trim().length > 0;
332+
}
333+
334+
function isFailureStatus(status) {
335+
return ['failed', 'canceled', 'cancelled'].includes(String(status).toLowerCase());
336+
}
337+
267338
function compact(value) {
268339
return Object.fromEntries(
269340
Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== false),
@@ -287,7 +358,7 @@ function helpText() {
287358
}
288359

289360
function comicHelpText() {
290-
return `Usage:\n llamagen comic create --prompt <prompt> [--prompt-url <url>] [--size 1024x1024] [--wait]\n llamagen comic get <generation_id>\n llamagen comic continue <generation_id> --prompt <prompt>\n llamagen comic update-panel <generation_id> --page <n> --panel <n> --prompt <prompt>\n llamagen comic usage\n`;
361+
return `Usage:\n llamagen comic create --prompt <prompt> [--prompt-url <url>] [--size 1024x1024] [--fix-panel-num <1-20>] [--wait]\n llamagen comic get <generation_id> [--page <n>] [--panel <n>]\n llamagen comic continue <generation_id> --prompt <prompt> [--fix-panel-num <1-20>]\n llamagen comic update-panel <generation_id> [--page <n>] --panel <n> (--prompt <prompt> | --panel-prompt <prompt>)\n llamagen comic usage\n`;
291362
}
292363

293364
function animationHelpText() {

‎src/version.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import fs from 'node:fs';
2+
3+
const packageJson = JSON.parse(
4+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
5+
);
6+
7+
export const VERSION = packageJson.version;

0 commit comments

Comments
 (0)