workar 0.1.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 +58 -0
- package/dist/api.d.ts +21 -0
- package/dist/api.js +77 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +69 -0
- package/dist/commands/auth.d.ts +5 -0
- package/dist/commands/auth.js +20 -0
- package/dist/commands/get.d.ts +7 -0
- package/dist/commands/get.js +16 -0
- package/dist/commands/register.d.ts +4 -0
- package/dist/commands/register.js +15 -0
- package/dist/commands/submit.d.ts +8 -0
- package/dist/commands/submit.js +33 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +27 -0
- package/dist/output.d.ts +6 -0
- package/dist/output.js +25 -0
- package/package.json +19 -0
- package/src/api.ts +87 -0
- package/src/cli.ts +89 -0
- package/src/commands/auth.ts +27 -0
- package/src/commands/get.ts +24 -0
- package/src/commands/register.ts +20 -0
- package/src/commands/submit.ts +44 -0
- package/src/config.ts +42 -0
- package/src/output.ts +35 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# workar
|
|
2
|
+
|
|
3
|
+
End-user CLI for the distributed work system.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### Register a user
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
workar register --username alice [--server http://localhost:8787]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Writes `~/.workar/config.json` with `username`, `apiKey`, and `serverUrl`.
|
|
21
|
+
|
|
22
|
+
### Authenticate (get a JWT)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
workar auth [--username alice] [--api-key <key>]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Writes the JWT to `~/.workar/config.json`.
|
|
29
|
+
|
|
30
|
+
### Submit work
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
workar submit --type image-gen --wait [--out-dir ./output] -- prompt="a red panda" model=sdxl-lightning
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Key-value pairs after `--` are passed as work fields. `--wait` polls for the
|
|
37
|
+
result and saves it as `work-<workId>.<ext>` in `--out-dir` (default: CWD).
|
|
38
|
+
|
|
39
|
+
### Retrieve a result
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
workar get [--work-id <id>] [--wait] [--out-dir ./output]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Config file
|
|
46
|
+
|
|
47
|
+
`~/.workar/config.json` (chmod 600):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"serverUrl": "https://...",
|
|
52
|
+
"username": "alice",
|
|
53
|
+
"apiKey": "...",
|
|
54
|
+
"jwt": "..."
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Flags `--server`, `--username`, `--api-key` override the file values.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare class ClientApi {
|
|
2
|
+
private readonly serverUrl;
|
|
3
|
+
private readonly authHeaders;
|
|
4
|
+
constructor(serverUrl: string, authHeaders: Record<string, string>);
|
|
5
|
+
register(username: string): Promise<{
|
|
6
|
+
username: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
}>;
|
|
9
|
+
auth(username: string, apiKey: string): Promise<{
|
|
10
|
+
jwt: string;
|
|
11
|
+
}>;
|
|
12
|
+
submitWork(body: Record<string, unknown>): Promise<{
|
|
13
|
+
workId: string;
|
|
14
|
+
}>;
|
|
15
|
+
getWork(poll: boolean, workId?: string): Promise<{
|
|
16
|
+
workId: string;
|
|
17
|
+
contentType: string;
|
|
18
|
+
bytes: Uint8Array;
|
|
19
|
+
isError: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export class ClientApi {
|
|
2
|
+
serverUrl;
|
|
3
|
+
authHeaders;
|
|
4
|
+
constructor(serverUrl, authHeaders) {
|
|
5
|
+
this.serverUrl = serverUrl;
|
|
6
|
+
this.authHeaders = authHeaders;
|
|
7
|
+
}
|
|
8
|
+
async register(username) {
|
|
9
|
+
const res = await fetch(`${this.serverUrl}/api/users`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'content-type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ username }),
|
|
13
|
+
});
|
|
14
|
+
const data = (await res.json());
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(data['error'] ?? `HTTP ${res.status}`);
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
async auth(username, apiKey) {
|
|
20
|
+
const res = await fetch(`${this.serverUrl}/api/auth`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ username, apiKey }),
|
|
24
|
+
});
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
throw new Error(data['error'] ?? `HTTP ${res.status}`);
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
async submitWork(body) {
|
|
31
|
+
const res = await fetch(`${this.serverUrl}/api/work`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json', ...this.authHeaders },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
const data = (await res.json());
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new Error(data['error'] ?? `HTTP ${res.status}`);
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
async getWork(poll, workId) {
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
if (workId)
|
|
44
|
+
params.set('workId', workId);
|
|
45
|
+
const qs = params.toString();
|
|
46
|
+
const url = `${this.serverUrl}/api/work${qs ? `?${qs}` : ''}`;
|
|
47
|
+
const maxWaitMs = 30 * 60 * 1000; // 30 minutes
|
|
48
|
+
const retryIntervalMs = 10 * 1000; // 10 seconds between retries
|
|
49
|
+
const startTime = Date.now();
|
|
50
|
+
let attempt = 0;
|
|
51
|
+
while (true) {
|
|
52
|
+
attempt++;
|
|
53
|
+
const res = await fetch(url, { headers: this.authHeaders });
|
|
54
|
+
if (res.status === 404) {
|
|
55
|
+
if (!poll || Date.now() - startTime >= maxWaitMs) {
|
|
56
|
+
throw new Error('No work result available (404)');
|
|
57
|
+
}
|
|
58
|
+
if (attempt === 1)
|
|
59
|
+
process.stdout.write('Waiting for result');
|
|
60
|
+
process.stdout.write('.');
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (attempt > 1)
|
|
65
|
+
process.stdout.write('\n');
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const data = (await res.json().catch(() => ({})));
|
|
68
|
+
throw new Error(data['error'] ?? `HTTP ${res.status}`);
|
|
69
|
+
}
|
|
70
|
+
const isError = res.headers.get('x-work-error') === '1';
|
|
71
|
+
const retWorkId = res.headers.get('x-work-id') ?? '';
|
|
72
|
+
const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
|
|
73
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
74
|
+
return { workId: retWorkId, contentType, bytes, isError };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { cmdRegister } from './commands/register.js';
|
|
4
|
+
import { cmdAuth } from './commands/auth.js';
|
|
5
|
+
import { cmdSubmit } from './commands/submit.js';
|
|
6
|
+
import { cmdGet } from './commands/get.js';
|
|
7
|
+
const [subcommand, ...restArgs] = process.argv.slice(2);
|
|
8
|
+
switch (subcommand) {
|
|
9
|
+
case 'register': {
|
|
10
|
+
const { values } = parseArgs({
|
|
11
|
+
args: restArgs,
|
|
12
|
+
options: {
|
|
13
|
+
username: { type: 'string' },
|
|
14
|
+
server: { type: 'string' },
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
await cmdRegister(values);
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
case 'auth': {
|
|
21
|
+
const { values } = parseArgs({
|
|
22
|
+
args: restArgs,
|
|
23
|
+
options: {
|
|
24
|
+
username: { type: 'string' },
|
|
25
|
+
'api-key': { type: 'string' },
|
|
26
|
+
server: { type: 'string' },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
await cmdAuth(values);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case 'submit': {
|
|
33
|
+
const { values, positionals } = parseArgs({
|
|
34
|
+
args: restArgs,
|
|
35
|
+
options: {
|
|
36
|
+
type: { type: 'string' },
|
|
37
|
+
wait: { type: 'boolean', default: false },
|
|
38
|
+
'out-dir': { type: 'string' },
|
|
39
|
+
server: { type: 'string' },
|
|
40
|
+
'api-key': { type: 'string' },
|
|
41
|
+
},
|
|
42
|
+
allowPositionals: true,
|
|
43
|
+
});
|
|
44
|
+
await cmdSubmit({
|
|
45
|
+
...values,
|
|
46
|
+
positionals,
|
|
47
|
+
});
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'get': {
|
|
51
|
+
const { values } = parseArgs({
|
|
52
|
+
args: restArgs,
|
|
53
|
+
options: {
|
|
54
|
+
'work-id': { type: 'string' },
|
|
55
|
+
wait: { type: 'boolean', default: false },
|
|
56
|
+
'out-dir': { type: 'string' },
|
|
57
|
+
server: { type: 'string' },
|
|
58
|
+
'api-key': { type: 'string' },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
await cmdGet(values);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
default: {
|
|
65
|
+
console.error(`Unknown subcommand: ${subcommand ?? '(none)'}`);
|
|
66
|
+
console.error('Usage: tarsk <register|auth|submit|get> [options]');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readConfig, writeConfig, getServerUrl } from '../config.js';
|
|
2
|
+
import { ClientApi } from '../api.js';
|
|
3
|
+
export async function cmdAuth(args) {
|
|
4
|
+
const config = await readConfig();
|
|
5
|
+
const serverUrl = getServerUrl(config, args);
|
|
6
|
+
const username = args.username ?? config.username;
|
|
7
|
+
const apiKey = args['api-key'] ?? config.apiKey;
|
|
8
|
+
if (!username) {
|
|
9
|
+
console.error('Error: --username is required (or run `tarsk register` first)');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
if (!apiKey) {
|
|
13
|
+
console.error('Error: --api-key is required (or run `tarsk register` first)');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const api = new ClientApi(serverUrl, {});
|
|
17
|
+
const result = await api.auth(username, apiKey);
|
|
18
|
+
await writeConfig({ ...config, jwt: result.jwt });
|
|
19
|
+
console.log('Authenticated successfully');
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readConfig, getServerUrl, buildAuthHeaders } from '../config.js';
|
|
3
|
+
import { ClientApi } from '../api.js';
|
|
4
|
+
import { saveResult } from '../output.js';
|
|
5
|
+
export async function cmdGet(args) {
|
|
6
|
+
const config = await readConfig();
|
|
7
|
+
const serverUrl = getServerUrl(config, args);
|
|
8
|
+
const authHeaders = buildAuthHeaders(config, { 'api-key': args['api-key'] });
|
|
9
|
+
const api = new ClientApi(serverUrl, authHeaders);
|
|
10
|
+
const outDir = args['out-dir'] ?? process.cwd();
|
|
11
|
+
await mkdir(outDir, { recursive: true });
|
|
12
|
+
const result = await api.getWork(args.wait ?? false, args['work-id']);
|
|
13
|
+
await saveResult(result, outDir);
|
|
14
|
+
if (result.isError)
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readConfig, writeConfig, getServerUrl } from '../config.js';
|
|
2
|
+
import { ClientApi } from '../api.js';
|
|
3
|
+
export async function cmdRegister(args) {
|
|
4
|
+
if (!args.username) {
|
|
5
|
+
console.error('Error: --username is required');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
const config = await readConfig();
|
|
9
|
+
const serverUrl = getServerUrl(config, args);
|
|
10
|
+
const api = new ClientApi(serverUrl, {});
|
|
11
|
+
const result = await api.register(args.username);
|
|
12
|
+
await writeConfig({ ...config, serverUrl, username: result.username, apiKey: result.apiKey });
|
|
13
|
+
console.log(`Registered as ${result.username}`);
|
|
14
|
+
console.log(`API key: ${result.apiKey}`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readConfig, getServerUrl, buildAuthHeaders } from '../config.js';
|
|
3
|
+
import { ClientApi } from '../api.js';
|
|
4
|
+
import { saveResult } from '../output.js';
|
|
5
|
+
export async function cmdSubmit(args) {
|
|
6
|
+
if (!args.type) {
|
|
7
|
+
console.error('Error: --type is required');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
const config = await readConfig();
|
|
11
|
+
const serverUrl = getServerUrl(config, args);
|
|
12
|
+
const authHeaders = buildAuthHeaders(config, { 'api-key': args['api-key'] });
|
|
13
|
+
const api = new ClientApi(serverUrl, authHeaders);
|
|
14
|
+
const kv = {};
|
|
15
|
+
for (const pair of args.positionals ?? []) {
|
|
16
|
+
const eq = pair.indexOf('=');
|
|
17
|
+
if (eq === -1) {
|
|
18
|
+
console.error(`Invalid key=value pair: ${pair}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
kv[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
22
|
+
}
|
|
23
|
+
const { workId } = await api.submitWork({ type: args.type, ...kv });
|
|
24
|
+
console.log(`Submitted work ${workId}`);
|
|
25
|
+
if (args.wait) {
|
|
26
|
+
const outDir = args['out-dir'] ?? process.cwd();
|
|
27
|
+
await mkdir(outDir, { recursive: true });
|
|
28
|
+
const result = await api.getWork(true, workId);
|
|
29
|
+
await saveResult(result, outDir);
|
|
30
|
+
if (result.isError)
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ClientConfig {
|
|
2
|
+
serverUrl?: string;
|
|
3
|
+
username?: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
jwt?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function readConfig(): Promise<ClientConfig>;
|
|
8
|
+
export declare function writeConfig(config: ClientConfig): Promise<void>;
|
|
9
|
+
export declare function getServerUrl(config: ClientConfig, overrides: {
|
|
10
|
+
server?: string;
|
|
11
|
+
}): string;
|
|
12
|
+
export declare function buildAuthHeaders(config: ClientConfig, overrides: {
|
|
13
|
+
'api-key'?: string;
|
|
14
|
+
}): Record<string, string>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const CONFIG_DIR = path.join(homedir(), '.tarsk-work');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
export async function readConfig() {
|
|
8
|
+
if (!existsSync(CONFIG_FILE))
|
|
9
|
+
return {};
|
|
10
|
+
const raw = await readFile(CONFIG_FILE, 'utf-8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
export async function writeConfig(config) {
|
|
14
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
15
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
16
|
+
}
|
|
17
|
+
export function getServerUrl(config, overrides) {
|
|
18
|
+
return overrides.server ?? config.serverUrl ?? 'https://work.tarsk.io';
|
|
19
|
+
}
|
|
20
|
+
export function buildAuthHeaders(config, overrides) {
|
|
21
|
+
const apiKey = overrides['api-key'] ?? config.apiKey;
|
|
22
|
+
if (apiKey)
|
|
23
|
+
return { 'x-api-key': apiKey };
|
|
24
|
+
if (config.jwt)
|
|
25
|
+
return { authorization: `Bearer ${config.jwt}` };
|
|
26
|
+
throw new Error('No authentication available. Run `tarsk register` then `tarsk auth`.');
|
|
27
|
+
}
|
package/dist/output.d.ts
ADDED
package/dist/output.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const EXT_MAP = {
|
|
4
|
+
'image/png': 'png',
|
|
5
|
+
'image/jpeg': 'jpg',
|
|
6
|
+
'image/webp': 'webp',
|
|
7
|
+
'application/json': 'json',
|
|
8
|
+
'text/plain': 'txt',
|
|
9
|
+
};
|
|
10
|
+
function extFor(contentType) {
|
|
11
|
+
const base = contentType.split(';')[0].trim();
|
|
12
|
+
return EXT_MAP[base] ?? 'bin';
|
|
13
|
+
}
|
|
14
|
+
export async function saveResult(result, outDir) {
|
|
15
|
+
const ext = extFor(result.contentType);
|
|
16
|
+
const filename = `work-${result.workId}.${ext}`;
|
|
17
|
+
const filePath = path.join(outDir, filename);
|
|
18
|
+
await writeFile(filePath, result.bytes);
|
|
19
|
+
console.log(`I saved the result to "${filePath}"`);
|
|
20
|
+
if (result.isError) {
|
|
21
|
+
const errData = JSON.parse(new TextDecoder().decode(result.bytes));
|
|
22
|
+
console.error(`Error: ${errData.message ?? 'unknown error'}`);
|
|
23
|
+
}
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"workar": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc && node -e \"const fs=require('fs');const f='dist/cli.js';fs.writeFileSync(f,'#!/usr/bin/env node\\n'+fs.readFileSync(f,'utf8'))\"",
|
|
10
|
+
"start": "node dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.0.0",
|
|
17
|
+
"typescript": "^5.7.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export class ClientApi {
|
|
2
|
+
constructor(
|
|
3
|
+
private readonly serverUrl: string,
|
|
4
|
+
private readonly authHeaders: Record<string, string>,
|
|
5
|
+
) {}
|
|
6
|
+
|
|
7
|
+
async register(username: string): Promise<{ username: string; apiKey: string }> {
|
|
8
|
+
const res = await fetch(`${this.serverUrl}/api/users`, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
body: JSON.stringify({ username }),
|
|
12
|
+
});
|
|
13
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
14
|
+
if (!res.ok) throw new Error((data['error'] as string) ?? `HTTP ${res.status}`);
|
|
15
|
+
return data as { username: string; apiKey: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async auth(username: string, apiKey: string): Promise<{ jwt: string }> {
|
|
19
|
+
const res = await fetch(`${this.serverUrl}/api/auth`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ username, apiKey }),
|
|
23
|
+
});
|
|
24
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
25
|
+
if (!res.ok) throw new Error((data['error'] as string) ?? `HTTP ${res.status}`);
|
|
26
|
+
return data as { jwt: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async submitWork(body: Record<string, unknown>): Promise<{ workId: string }> {
|
|
30
|
+
const res = await fetch(`${this.serverUrl}/api/work`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'content-type': 'application/json', ...this.authHeaders },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
36
|
+
if (!res.ok) throw new Error((data['error'] as string) ?? `HTTP ${res.status}`);
|
|
37
|
+
return data as { workId: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getWork(
|
|
41
|
+
poll: boolean,
|
|
42
|
+
workId?: string,
|
|
43
|
+
): Promise<{
|
|
44
|
+
workId: string;
|
|
45
|
+
contentType: string;
|
|
46
|
+
bytes: Uint8Array;
|
|
47
|
+
isError: boolean;
|
|
48
|
+
}> {
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
if (workId) params.set('workId', workId);
|
|
51
|
+
const qs = params.toString();
|
|
52
|
+
const url = `${this.serverUrl}/api/work${qs ? `?${qs}` : ''}`;
|
|
53
|
+
|
|
54
|
+
const maxWaitMs = 30 * 60 * 1000; // 30 minutes
|
|
55
|
+
const retryIntervalMs = 10 * 1000; // 10 seconds between retries
|
|
56
|
+
const startTime = Date.now();
|
|
57
|
+
let attempt = 0;
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
attempt++;
|
|
61
|
+
const res = await fetch(url, { headers: this.authHeaders });
|
|
62
|
+
|
|
63
|
+
if (res.status === 404) {
|
|
64
|
+
if (!poll || Date.now() - startTime >= maxWaitMs) {
|
|
65
|
+
throw new Error('No work result available (404)');
|
|
66
|
+
}
|
|
67
|
+
if (attempt === 1) process.stdout.write('Waiting for result');
|
|
68
|
+
process.stdout.write('.');
|
|
69
|
+
await new Promise<void>((resolve) => setTimeout(resolve, retryIntervalMs));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (attempt > 1) process.stdout.write('\n');
|
|
74
|
+
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
77
|
+
throw new Error((data['error'] as string) ?? `HTTP ${res.status}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isError = res.headers.get('x-work-error') === '1';
|
|
81
|
+
const retWorkId = res.headers.get('x-work-id') ?? '';
|
|
82
|
+
const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
|
|
83
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
84
|
+
return { workId: retWorkId, contentType, bytes, isError };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { cmdRegister } from './commands/register.js';
|
|
4
|
+
import { cmdAuth } from './commands/auth.js';
|
|
5
|
+
import { cmdSubmit } from './commands/submit.js';
|
|
6
|
+
import { cmdGet } from './commands/get.js';
|
|
7
|
+
|
|
8
|
+
const [subcommand, ...restArgs] = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
switch (subcommand) {
|
|
11
|
+
case 'register': {
|
|
12
|
+
const { values } = parseArgs({
|
|
13
|
+
args: restArgs,
|
|
14
|
+
options: {
|
|
15
|
+
username: { type: 'string' },
|
|
16
|
+
server: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
await cmdRegister(values as { username?: string; server?: string });
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case 'auth': {
|
|
24
|
+
const { values } = parseArgs({
|
|
25
|
+
args: restArgs,
|
|
26
|
+
options: {
|
|
27
|
+
username: { type: 'string' },
|
|
28
|
+
'api-key': { type: 'string' },
|
|
29
|
+
server: { type: 'string' },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
await cmdAuth(values as { username?: string; 'api-key'?: string; server?: string });
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
case 'submit': {
|
|
37
|
+
const { values, positionals } = parseArgs({
|
|
38
|
+
args: restArgs,
|
|
39
|
+
options: {
|
|
40
|
+
type: { type: 'string' },
|
|
41
|
+
wait: { type: 'boolean', default: false },
|
|
42
|
+
'out-dir': { type: 'string' },
|
|
43
|
+
server: { type: 'string' },
|
|
44
|
+
'api-key': { type: 'string' },
|
|
45
|
+
},
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
});
|
|
48
|
+
await cmdSubmit({
|
|
49
|
+
...(values as {
|
|
50
|
+
type?: string;
|
|
51
|
+
wait?: boolean;
|
|
52
|
+
'out-dir'?: string;
|
|
53
|
+
server?: string;
|
|
54
|
+
'api-key'?: string;
|
|
55
|
+
}),
|
|
56
|
+
positionals,
|
|
57
|
+
});
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
case 'get': {
|
|
62
|
+
const { values } = parseArgs({
|
|
63
|
+
args: restArgs,
|
|
64
|
+
options: {
|
|
65
|
+
'work-id': { type: 'string' },
|
|
66
|
+
wait: { type: 'boolean', default: false },
|
|
67
|
+
'out-dir': { type: 'string' },
|
|
68
|
+
server: { type: 'string' },
|
|
69
|
+
'api-key': { type: 'string' },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
await cmdGet(
|
|
73
|
+
values as {
|
|
74
|
+
'work-id'?: string;
|
|
75
|
+
wait?: boolean;
|
|
76
|
+
'out-dir'?: string;
|
|
77
|
+
server?: string;
|
|
78
|
+
'api-key'?: string;
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
default: {
|
|
85
|
+
console.error(`Unknown subcommand: ${subcommand ?? '(none)'}`);
|
|
86
|
+
console.error('Usage: tarsk <register|auth|submit|get> [options]');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readConfig, writeConfig, getServerUrl } from '../config.js';
|
|
2
|
+
import { ClientApi } from '../api.js';
|
|
3
|
+
|
|
4
|
+
export async function cmdAuth(args: {
|
|
5
|
+
username?: string;
|
|
6
|
+
'api-key'?: string;
|
|
7
|
+
server?: string;
|
|
8
|
+
}): Promise<void> {
|
|
9
|
+
const config = await readConfig();
|
|
10
|
+
const serverUrl = getServerUrl(config, args);
|
|
11
|
+
const username = args.username ?? config.username;
|
|
12
|
+
const apiKey = args['api-key'] ?? config.apiKey;
|
|
13
|
+
|
|
14
|
+
if (!username) {
|
|
15
|
+
console.error('Error: --username is required (or run `workar register` first)');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
console.error('Error: --api-key is required (or run `workar register` first)');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const api = new ClientApi(serverUrl, {});
|
|
24
|
+
const result = await api.auth(username, apiKey);
|
|
25
|
+
await writeConfig({ ...config, jwt: result.jwt });
|
|
26
|
+
console.log('Authenticated successfully');
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readConfig, getServerUrl, buildAuthHeaders } from '../config.js';
|
|
3
|
+
import { ClientApi } from '../api.js';
|
|
4
|
+
import { saveResult } from '../output.js';
|
|
5
|
+
|
|
6
|
+
export async function cmdGet(args: {
|
|
7
|
+
'work-id'?: string;
|
|
8
|
+
wait?: boolean;
|
|
9
|
+
'out-dir'?: string;
|
|
10
|
+
server?: string;
|
|
11
|
+
'api-key'?: string;
|
|
12
|
+
}): Promise<void> {
|
|
13
|
+
const config = await readConfig();
|
|
14
|
+
const serverUrl = getServerUrl(config, args);
|
|
15
|
+
const authHeaders = buildAuthHeaders(config, { 'api-key': args['api-key'] });
|
|
16
|
+
const api = new ClientApi(serverUrl, authHeaders);
|
|
17
|
+
|
|
18
|
+
const outDir = args['out-dir'] ?? process.cwd();
|
|
19
|
+
await mkdir(outDir, { recursive: true });
|
|
20
|
+
|
|
21
|
+
const result = await api.getWork(args.wait ?? false, args['work-id']);
|
|
22
|
+
await saveResult(result, outDir);
|
|
23
|
+
if (result.isError) process.exit(1);
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readConfig, writeConfig, getServerUrl } from '../config.js';
|
|
2
|
+
import { ClientApi } from '../api.js';
|
|
3
|
+
|
|
4
|
+
export async function cmdRegister(args: {
|
|
5
|
+
username?: string;
|
|
6
|
+
server?: string;
|
|
7
|
+
}): Promise<void> {
|
|
8
|
+
if (!args.username) {
|
|
9
|
+
console.error('Error: --username is required');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const config = await readConfig();
|
|
14
|
+
const serverUrl = getServerUrl(config, args);
|
|
15
|
+
const api = new ClientApi(serverUrl, {});
|
|
16
|
+
const result = await api.register(args.username);
|
|
17
|
+
await writeConfig({ ...config, serverUrl, username: result.username, apiKey: result.apiKey });
|
|
18
|
+
console.log(`Registered as ${result.username}`);
|
|
19
|
+
console.log(`API key: ${result.apiKey}`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readConfig, getServerUrl, buildAuthHeaders } from '../config.js';
|
|
3
|
+
import { ClientApi } from '../api.js';
|
|
4
|
+
import { saveResult } from '../output.js';
|
|
5
|
+
|
|
6
|
+
export async function cmdSubmit(args: {
|
|
7
|
+
type?: string;
|
|
8
|
+
wait?: boolean;
|
|
9
|
+
'out-dir'?: string;
|
|
10
|
+
server?: string;
|
|
11
|
+
'api-key'?: string;
|
|
12
|
+
positionals?: string[];
|
|
13
|
+
}): Promise<void> {
|
|
14
|
+
if (!args.type) {
|
|
15
|
+
console.error('Error: --type is required');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config = await readConfig();
|
|
20
|
+
const serverUrl = getServerUrl(config, args);
|
|
21
|
+
const authHeaders = buildAuthHeaders(config, { 'api-key': args['api-key'] });
|
|
22
|
+
const api = new ClientApi(serverUrl, authHeaders);
|
|
23
|
+
|
|
24
|
+
const kv: Record<string, string> = {};
|
|
25
|
+
for (const pair of args.positionals ?? []) {
|
|
26
|
+
const eq = pair.indexOf('=');
|
|
27
|
+
if (eq === -1) {
|
|
28
|
+
console.error(`Invalid key=value pair: ${pair}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
kv[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { workId } = await api.submitWork({ type: args.type, ...kv });
|
|
35
|
+
console.log(`Submitted work ${workId}`);
|
|
36
|
+
|
|
37
|
+
if (args.wait) {
|
|
38
|
+
const outDir = args['out-dir'] ?? process.cwd();
|
|
39
|
+
await mkdir(outDir, { recursive: true });
|
|
40
|
+
const result = await api.getWork(true, workId);
|
|
41
|
+
await saveResult(result, outDir);
|
|
42
|
+
if (result.isError) process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export interface ClientConfig {
|
|
7
|
+
serverUrl?: string;
|
|
8
|
+
username?: string;
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
jwt?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = path.join(homedir(), '.workar');
|
|
14
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
15
|
+
|
|
16
|
+
export async function readConfig(): Promise<ClientConfig> {
|
|
17
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
18
|
+
const raw = await readFile(CONFIG_FILE, 'utf-8');
|
|
19
|
+
return JSON.parse(raw) as ClientConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeConfig(config: ClientConfig): Promise<void> {
|
|
23
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
24
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getServerUrl(
|
|
28
|
+
config: ClientConfig,
|
|
29
|
+
overrides: { server?: string },
|
|
30
|
+
): string {
|
|
31
|
+
return overrides.server ?? config.serverUrl ?? 'https://workar.tarsk.io';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildAuthHeaders(
|
|
35
|
+
config: ClientConfig,
|
|
36
|
+
overrides: { 'api-key'?: string },
|
|
37
|
+
): Record<string, string> {
|
|
38
|
+
const apiKey = overrides['api-key'] ?? config.apiKey;
|
|
39
|
+
if (apiKey) return { 'x-api-key': apiKey };
|
|
40
|
+
if (config.jwt) return { authorization: `Bearer ${config.jwt}` };
|
|
41
|
+
throw new Error('No authentication available. Run `workar register` then `workar auth`.');
|
|
42
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const EXT_MAP: Record<string, string> = {
|
|
5
|
+
'image/png': 'png',
|
|
6
|
+
'image/jpeg': 'jpg',
|
|
7
|
+
'image/webp': 'webp',
|
|
8
|
+
'application/json': 'json',
|
|
9
|
+
'text/plain': 'txt',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function extFor(contentType: string): string {
|
|
13
|
+
const base = contentType.split(';')[0].trim();
|
|
14
|
+
return EXT_MAP[base] ?? 'bin';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function saveResult(
|
|
18
|
+
result: { workId: string; contentType: string; bytes: Uint8Array; isError: boolean },
|
|
19
|
+
outDir: string,
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const ext = extFor(result.contentType);
|
|
22
|
+
const filename = `work-${result.workId}.${ext}`;
|
|
23
|
+
const filePath = path.join(outDir, filename);
|
|
24
|
+
await writeFile(filePath, result.bytes);
|
|
25
|
+
console.log(`I saved the result to "${filePath}"`);
|
|
26
|
+
|
|
27
|
+
if (result.isError) {
|
|
28
|
+
const errData = JSON.parse(new TextDecoder().decode(result.bytes)) as {
|
|
29
|
+
message?: string;
|
|
30
|
+
};
|
|
31
|
+
console.error(`Error: ${errData.message ?? 'unknown error'}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return filePath;
|
|
35
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|