@manyoars/oars 0.1.0 → 0.2.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 +95 -0
- package/dist/cli.js +24 -5
- package/dist/commands/deploy-input.d.ts +7 -4
- package/dist/commands/deploy-input.js +58 -15
- package/dist/commands/deploy.js +6 -2
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +2 -4
- package/dist/commands/templates.d.ts +11 -0
- package/dist/commands/templates.js +32 -0
- package/dist/issuer.d.ts +10 -0
- package/dist/issuer.js +38 -0
- package/package.json +2 -2
- package/parity/deploy-plans/paid-api.json +1 -1
- package/parity/deploy-plans/per-task-agent.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @manyoars/oars
|
|
2
|
+
|
|
3
|
+
The `oars` command line for Many Oars: sign in, deploy an agent, set its prices and make a test charge against it.
|
|
4
|
+
|
|
5
|
+
Node 24 or later. ESM only.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --global @manyoars/oars
|
|
11
|
+
oars --help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or run it once without installing:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx @manyoars/oars <command>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
`oars --help` lists the commands below (`oars help <command>` shows one command's flags). `oars --version` prints the version.
|
|
23
|
+
|
|
24
|
+
These flags work on every command:
|
|
25
|
+
|
|
26
|
+
- `--json` prints one JSON document on stdout instead of text (see below).
|
|
27
|
+
- `--api-url <url>` sets the platform API. The default is `OARS_API_URL`, else `https://api.manyoars.com`.
|
|
28
|
+
- `--idempotency-key <key>` is the base of every step's idempotency key (default: a fresh UUID). Rerun a failed command with the same key to resume.
|
|
29
|
+
|
|
30
|
+
### login
|
|
31
|
+
|
|
32
|
+
Signs in with your browser (RFC 8628 device flow) and stores the login.
|
|
33
|
+
|
|
34
|
+
- `--no-browser` prints the link and code without opening a browser.
|
|
35
|
+
- `--issuer-url <url>` is the Keycloak realm URL. The default is `OARS_ISSUER_URL`. With neither, `login` uses the built-in Many Oars issuer once Many Oars has launched, which it learns from `https://manyoars.com/.well-known/oars.json` (`{"launched": true}`). The flag only switches the built-in issuer on; it never supplies a URL. Before launch, with no `--issuer-url` or `OARS_ISSUER_URL`, the command prints "Many Oars has not launched yet: pass --issuer-url or set OARS_ISSUER_URL." and exits 2.
|
|
36
|
+
|
|
37
|
+
### logout
|
|
38
|
+
|
|
39
|
+
Revokes the stored login and deletes it.
|
|
40
|
+
|
|
41
|
+
### templates
|
|
42
|
+
|
|
43
|
+
`oars templates` lists the starter templates `oars deploy --template` takes: each id, name, description, and starter prices. Needs a login.
|
|
44
|
+
|
|
45
|
+
### deploy
|
|
46
|
+
|
|
47
|
+
Registers a workload, creates its prices, and deploys it. Pass exactly one of `--template`, `--image`, or `--manifest`. It stops at the first failed step and exits 9 (see below). A `--template` id the catalog lacks exits 2, and an unreachable catalog exits 8, before anything is registered.
|
|
48
|
+
|
|
49
|
+
- `--image <ref>` deploys an image pinned by digest (`name@sha256:...`). Companions: `--name <name>`, `--port <n>` (default 8080), `--public`, `--cpu-millicores <n>` (default 250), `--memory-mib <n>` (default 256).
|
|
50
|
+
- `--manifest <file>` deploys a JSON workload manifest.
|
|
51
|
+
- `--always-on` keeps the public service running instead of idling. It works with every source.
|
|
52
|
+
- `--template <id>` deploys a starter template from the catalog (`oars templates`): it registers the entry's manifest, creates every starter price, then deploys. `--price <ref>` (repeatable) creates only the named ones; `--no-prices` creates none. `--name`, `--port`, `--public`, `--cpu-millicores`, `--memory-mib`, and `--price-file` do not combine with it.
|
|
53
|
+
- `--price-file <file>` is a JSON array of prices to create (with `--image` or `--manifest`).
|
|
54
|
+
- `--secret <NAME=value>` and `--secret-from-env <NAME>` add a workload secret; both repeat. Prefer `--secret-from-env`, which keeps the value out of your shell history.
|
|
55
|
+
- `--no-start` registers and prices the workload without deploying it.
|
|
56
|
+
|
|
57
|
+
The SDK key is shown once, so save it when `deploy` prints it.
|
|
58
|
+
|
|
59
|
+
### status
|
|
60
|
+
|
|
61
|
+
`oars status <workloadId>` shows a workload and its billing mode.
|
|
62
|
+
|
|
63
|
+
### test-charge
|
|
64
|
+
|
|
65
|
+
`oars test-charge <workloadId>` makes a TEST-mode charge against one of the workload's prices.
|
|
66
|
+
|
|
67
|
+
- `--price <ref>` picks the price. The default is the workload's only active price.
|
|
68
|
+
|
|
69
|
+
## The `--json` contract
|
|
70
|
+
|
|
71
|
+
With `--json`, stdout carries exactly one JSON document and a success exits 0.
|
|
72
|
+
|
|
73
|
+
- Success: `{"ok": true, ...}`. The rest depends on the command: `login` adds `email`; `deploy` adds `workloadId`, `prices`, `started`, `nextSteps` and, when this run minted it, `sdkKey`; `templates` adds `templates`; `status` adds `workload` and `billingMode`; `test-charge` adds `chargeId` and `status`.
|
|
74
|
+
- Failure: `{"ok": false, "step": "...", "problem": {...}}`, where `problem` is the platform's problem document (`title`, `detail`, `code`, `actionUrl` when it sent them). A `deploy` that stopped after registering also carries `workloadId`, `rerun` and, if that run minted it, `sdkKey`.
|
|
75
|
+
|
|
76
|
+
Without `--json`, a failure prints the problem to stderr instead.
|
|
77
|
+
|
|
78
|
+
Exit codes:
|
|
79
|
+
|
|
80
|
+
| Code | Meaning |
|
|
81
|
+
|------|---------|
|
|
82
|
+
| 0 | success |
|
|
83
|
+
| 1 | unexpected error |
|
|
84
|
+
| 2 | usage error |
|
|
85
|
+
| 3 | authentication failed (login expired or denied, SDK key invalid, HTTP 401 or 403) |
|
|
86
|
+
| 4 | the platform rejected the input |
|
|
87
|
+
| 5 | conflict (an idempotency key or workload already used) |
|
|
88
|
+
| 6 | a person must act first (account, payment method, credits, or compute cap); the last stderr line starts "Needs you:" and names the link |
|
|
89
|
+
| 7 | rate limited |
|
|
90
|
+
| 8 | the platform could not be reached |
|
|
91
|
+
| 9 | `deploy` stopped part way; rerun the printed command to resume |
|
|
92
|
+
|
|
93
|
+
## Source
|
|
94
|
+
|
|
95
|
+
[github.com/manyoars/oars](https://github.com/manyoars/oars/tree/main/packages/cli), [Apache-2.0](https://github.com/manyoars/oars/blob/main/LICENSE).
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { deploy } from './commands/deploy.js';
|
|
|
5
5
|
import { login } from './commands/login.js';
|
|
6
6
|
import { logout } from './commands/logout.js';
|
|
7
7
|
import { status } from './commands/status.js';
|
|
8
|
+
import { templates } from './commands/templates.js';
|
|
8
9
|
import { testCharge } from './commands/test-charge.js';
|
|
9
10
|
import { CliError, ExitCode } from './errors.js';
|
|
10
11
|
const flag = (options, name) => options[name] === true;
|
|
@@ -30,6 +31,16 @@ const positiveInteger = (value) => {
|
|
|
30
31
|
const emit = (deps, json, outcome) => {
|
|
31
32
|
deps.io.stdout(`${json ? JSON.stringify(outcome.document) : outcome.text}\n`);
|
|
32
33
|
};
|
|
34
|
+
/** The last line of an exit 6: the one thing a person must do, by link when the platform sent one, then to rerun. Stripe onboarding is the step most runs wait on. */
|
|
35
|
+
const needsYouLine = (code, actionUrl) => {
|
|
36
|
+
const stripe = code === 'ACCOUNT_NOT_READY';
|
|
37
|
+
if (actionUrl !== undefined && actionUrl !== '') {
|
|
38
|
+
return `Needs you: ${stripe ? 'finish Stripe onboarding at' : 'open'} ${actionUrl}, then rerun this command.`;
|
|
39
|
+
}
|
|
40
|
+
return stripe
|
|
41
|
+
? 'Needs you: finish Stripe onboarding (Account, then Payout account, in the Many Oars web app), then rerun this command.'
|
|
42
|
+
: 'Needs you: a step only a person can do. Do it in the Many Oars web app, then rerun this command.';
|
|
43
|
+
};
|
|
33
44
|
/**
|
|
34
45
|
* `--json` prints `{ok: false, step, problem}` on stdout, plus `workloadId`, `sdkKey` and `rerun` after a deploy that stopped;
|
|
35
46
|
* otherwise the problem's title, detail and URL go to stderr, then the rerun line, and the one-time SDK key goes to stdout.
|
|
@@ -42,7 +53,8 @@ const printFailure = (deps, json, error) => {
|
|
|
42
53
|
}
|
|
43
54
|
const { title, detail, actionUrl } = error.problem;
|
|
44
55
|
const resume = partial === undefined ? [] : [`Workload ${partial.workloadId} is registered; the deploy stopped at ${error.step}.`, `Rerun to resume: ${partial.rerun}`];
|
|
45
|
-
|
|
56
|
+
const needsYou = error.exitCode === ExitCode.HUMAN ? [needsYouLine(error.problem.code, actionUrl)] : [];
|
|
57
|
+
deps.io.stderr(`${[title ?? error.message, detail, ...(needsYou.length === 0 ? [actionUrl] : []), ...resume, ...needsYou].filter((line) => line !== undefined && line !== '').join('\n')}\n`);
|
|
46
58
|
if (partial?.sdkKey !== undefined)
|
|
47
59
|
deps.io.stdout(`SDK key (shown once; save it now): ${partial.sdkKey}\n`);
|
|
48
60
|
};
|
|
@@ -59,7 +71,7 @@ const buildProgram = (deps, argv) => {
|
|
|
59
71
|
.command('login')
|
|
60
72
|
.description('sign in with your browser (RFC 8628 device flow)')
|
|
61
73
|
.option('--no-browser', 'print the link and code without opening a browser')
|
|
62
|
-
.option('--issuer-url <url>', 'the Keycloak realm URL (default: OARS_ISSUER_URL)')
|
|
74
|
+
.option('--issuer-url <url>', 'the Keycloak realm URL (default: OARS_ISSUER_URL, else Many Oars once it has launched)')
|
|
63
75
|
.action(async (_options, command) => {
|
|
64
76
|
const options = command.optsWithGlobals();
|
|
65
77
|
const outcome = await login(deps, {
|
|
@@ -76,10 +88,17 @@ const buildProgram = (deps, argv) => {
|
|
|
76
88
|
const options = command.optsWithGlobals();
|
|
77
89
|
emit(deps, flag(options, 'json'), await logout(deps));
|
|
78
90
|
});
|
|
91
|
+
program
|
|
92
|
+
.command('templates')
|
|
93
|
+
.description('list the starter templates `oars deploy --template` takes')
|
|
94
|
+
.action(async (_options, command) => {
|
|
95
|
+
const options = command.optsWithGlobals();
|
|
96
|
+
emit(deps, flag(options, 'json'), await templates(deps, { apiUrl: resolveApiUrl(deps, text(options, 'apiUrl')) }));
|
|
97
|
+
});
|
|
79
98
|
program
|
|
80
99
|
.command('deploy')
|
|
81
100
|
.description('register a workload, create its prices and deploy it; stops at the first failed step (exit 9)')
|
|
82
|
-
.option('--template <id>', 'a starter template
|
|
101
|
+
.option('--template <id>', 'a starter template from the catalog (see: oars templates)')
|
|
83
102
|
.option('--image <ref>', 'an image pinned by digest (name@sha256:...)')
|
|
84
103
|
.option('--manifest <file>', 'a JSON workload manifest')
|
|
85
104
|
.option('--name <name>', 'the workload name (--image)')
|
|
@@ -88,8 +107,8 @@ const buildProgram = (deps, argv) => {
|
|
|
88
107
|
.option('--cpu-millicores <n>', 'CPU in millicores (--image; default 250)', positiveInteger)
|
|
89
108
|
.option('--memory-mib <n>', 'memory in MiB (--image; default 256)', positiveInteger)
|
|
90
109
|
.option('--always-on', 'keep the public service running instead of idling')
|
|
91
|
-
.option('--price <ref>', 'a starter price to create (--template; repeatable)', collect)
|
|
92
|
-
.option('--no-prices',
|
|
110
|
+
.option('--price <ref>', 'a starter price to create instead of all of them (--template; repeatable)', collect)
|
|
111
|
+
.option('--no-prices', "create none of the template's starter prices (--template)")
|
|
93
112
|
.option('--price-file <file>', 'a JSON array of prices to create (--image, --manifest)')
|
|
94
113
|
.option('--secret <NAME=value>', 'a secret for the workload (repeatable); prefer --secret-from-env', collect)
|
|
95
114
|
.option('--secret-from-env <NAME>', 'a secret read from the environment variable NAME (repeatable)', collect)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { components } from '@manyoars/oars-client';
|
|
2
|
+
import type { AgentTemplate } from './templates.js';
|
|
2
3
|
type RegisterWorkloadRequest = components['schemas']['RegisterWorkloadRequest'];
|
|
3
4
|
type CreatePriceInput = components['schemas']['CreatePriceInput'];
|
|
4
5
|
/** What `oars deploy` is told to deploy, as the flags give it. A flag the developer did not pass is undefined. */
|
|
@@ -12,7 +13,7 @@ export interface DeployFlags {
|
|
|
12
13
|
cpuMillicores?: number;
|
|
13
14
|
memoryMib?: number;
|
|
14
15
|
alwaysOn?: boolean;
|
|
15
|
-
/** `--price`: starter prices to pick from a template. */
|
|
16
|
+
/** `--price`: starter prices to pick from a template (all of them by default). */
|
|
16
17
|
prices: string[];
|
|
17
18
|
/** `--no-prices`. */
|
|
18
19
|
noPrices: boolean;
|
|
@@ -27,9 +28,11 @@ export interface DeployInput {
|
|
|
27
28
|
request: RegisterWorkloadRequest;
|
|
28
29
|
prices: CreatePriceInput[];
|
|
29
30
|
}
|
|
31
|
+
/** Reads the catalog once, when a `--template` deploy has passed every check that needs no network. */
|
|
32
|
+
export type TemplateLookup = () => Promise<AgentTemplate[]>;
|
|
30
33
|
/**
|
|
31
|
-
* Turns the flags into the calls' bodies
|
|
32
|
-
*
|
|
34
|
+
* Turns the flags into the calls' bodies. Exactly one of `--template`, `--image`, `--manifest`. Every mistake the flags alone can
|
|
35
|
+
* show is found before any request; only `--template` then reads the catalog (`catalog`), so a typo there costs one GET.
|
|
33
36
|
*/
|
|
34
|
-
export declare const loadDeployInput: (flags: DeployFlags, env: NodeJS.ProcessEnv) => Promise<DeployInput>;
|
|
37
|
+
export declare const loadDeployInput: (flags: DeployFlags, env: NodeJS.ProcessEnv, catalog: TemplateLookup) => Promise<DeployInput>;
|
|
35
38
|
export {};
|
|
@@ -62,10 +62,22 @@ const requestFromFlags = (flags, image) => {
|
|
|
62
62
|
services: [{ name: 'app', image, port: flags.port ?? 8080, public: flags.public ?? false, cpuMillicores: flags.cpuMillicores ?? 250, memoryMib: flags.memoryMib ?? 256 }],
|
|
63
63
|
};
|
|
64
64
|
};
|
|
65
|
-
|
|
65
|
+
/** The register body of an --image or --manifest deploy. */
|
|
66
|
+
const imageOrFileRequest = async (flags) => {
|
|
67
|
+
if (flags.image !== undefined)
|
|
68
|
+
return requestFromFlags(flags, flags.image);
|
|
69
|
+
if (flags.manifestFile !== undefined)
|
|
70
|
+
return requestFromFile(flags, flags.manifestFile);
|
|
71
|
+
throw usage('Pass exactly one of --template, --image, or --manifest.');
|
|
72
|
+
};
|
|
73
|
+
/** The flags that describe an --image deploy; a template or a manifest file holds its own. */
|
|
74
|
+
const refuseImageFlags = (flags, source) => {
|
|
66
75
|
const given = ['name', 'port', 'public', 'cpuMillicores', 'memoryMib'].filter((key) => flags[key] !== undefined);
|
|
67
76
|
if (given.length > 0)
|
|
68
|
-
throw usage(
|
|
77
|
+
throw usage(`--name, --port, --public, --cpu-millicores, and --memory-mib describe an --image deploy; ${source} holds its own.`);
|
|
78
|
+
};
|
|
79
|
+
const requestFromFile = async (flags, path) => {
|
|
80
|
+
refuseImageFlags(flags, 'a --manifest file');
|
|
69
81
|
const parsed = await readJson(path);
|
|
70
82
|
if (!isRegisterRequest(parsed)) {
|
|
71
83
|
throw usage(`${path} is not a workload manifest: it needs a name and services, each with name, image, cpuMillicores and memoryMib.`);
|
|
@@ -81,27 +93,58 @@ const pricesFromFile = async (path) => {
|
|
|
81
93
|
}
|
|
82
94
|
return parsed;
|
|
83
95
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
/** A starter price as the create call takes it. `credits` is left out of a price that has none (the wire may carry null). */
|
|
97
|
+
const priceFromTemplate = (price) => ({
|
|
98
|
+
priceRef: price.priceRef,
|
|
99
|
+
displayName: price.displayName,
|
|
100
|
+
amountUsd: price.amountUsd,
|
|
101
|
+
kind: price.kind,
|
|
102
|
+
...(typeof price.credits === 'number' ? { credits: price.credits } : {}),
|
|
103
|
+
});
|
|
104
|
+
/** `--template`: the entry's manifest, and the starter prices `--price` picked (every one without it, none for `--no-prices`). */
|
|
105
|
+
const fromTemplate = (flags, template) => {
|
|
106
|
+
const wanted = flags.noPrices ? [] : flags.prices;
|
|
107
|
+
const unknown = wanted.filter((ref) => !template.starterPrices.some((price) => price.priceRef === ref));
|
|
108
|
+
if (unknown.length > 0) {
|
|
109
|
+
throw usage(`Template ${template.templateId} has no starter price ${unknown.join(', ')}; its prices are ${template.starterPrices.map((price) => price.priceRef).join(', ')}.`);
|
|
110
|
+
}
|
|
111
|
+
const chosen = flags.noPrices || flags.prices.length > 0 ? template.starterPrices.filter((price) => wanted.includes(price.priceRef)) : template.starterPrices;
|
|
112
|
+
return { request: flags.alwaysOn === true ? { ...template.manifest, alwaysOn: true } : template.manifest, prices: chosen.map(priceFromTemplate) };
|
|
113
|
+
};
|
|
114
|
+
/** A catalog entry's id is what the platform serves, so an id it does not list is exit 2 naming the ids it does. */
|
|
115
|
+
const findTemplate = async (id, resolve) => {
|
|
116
|
+
const catalog = await resolve();
|
|
117
|
+
const found = catalog.find((template) => template.templateId === id);
|
|
118
|
+
if (found === undefined) {
|
|
119
|
+
throw usage(`No template ${id}. ${catalog.length === 0 ? 'The catalog is empty.' : `Available: ${catalog.map((template) => template.templateId).join(', ')}.`} List them with: oars templates`);
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
};
|
|
123
|
+
/** The checks on a `--template` deploy's flags, then its catalog entry; the flags are all read before the one GET. */
|
|
124
|
+
const templateInput = async (flags, id, catalog) => {
|
|
125
|
+
refuseImageFlags(flags, 'the template');
|
|
126
|
+
if (flags.priceFile !== undefined)
|
|
127
|
+
throw usage('--price-file adds prices to an --image or --manifest deploy; a template brings its own: choose among them with --price, or skip them with --no-prices.');
|
|
128
|
+
if (flags.noPrices && flags.prices.length > 0)
|
|
129
|
+
throw usage('--price and --no-prices contradict each other.');
|
|
130
|
+
return fromTemplate(flags, await findTemplate(id, catalog));
|
|
90
131
|
};
|
|
91
132
|
/**
|
|
92
|
-
* Turns the flags into the calls' bodies
|
|
93
|
-
*
|
|
133
|
+
* Turns the flags into the calls' bodies. Exactly one of `--template`, `--image`, `--manifest`. Every mistake the flags alone can
|
|
134
|
+
* show is found before any request; only `--template` then reads the catalog (`catalog`), so a typo there costs one GET.
|
|
94
135
|
*/
|
|
95
|
-
export const loadDeployInput = async (flags, env) => {
|
|
136
|
+
export const loadDeployInput = async (flags, env, catalog) => {
|
|
96
137
|
if ([flags.template, flags.image, flags.manifestFile].filter((source) => source !== undefined).length !== 1) {
|
|
97
|
-
throw usage('Pass exactly one of --template, --image or --manifest.');
|
|
138
|
+
throw usage('Pass exactly one of --template, --image, or --manifest.');
|
|
98
139
|
}
|
|
99
|
-
const
|
|
100
|
-
if (flags.prices.length > 0 || flags.noPrices)
|
|
140
|
+
const picked = flags.template === undefined ? undefined : await templateInput(flags, flags.template, catalog);
|
|
141
|
+
if (picked === undefined && (flags.prices.length > 0 || flags.noPrices)) {
|
|
101
142
|
throw usage("--price and --no-prices choose among a template's starter prices; use --price-file with --image or --manifest.");
|
|
143
|
+
}
|
|
144
|
+
const base = picked?.request ?? (await imageOrFileRequest(flags));
|
|
102
145
|
const secrets = [...(base.secrets ?? []), ...secretsFrom(flags, env)];
|
|
103
146
|
return {
|
|
104
147
|
request: secrets.length === 0 ? base : { ...base, secrets },
|
|
105
|
-
prices: flags.priceFile === undefined ? [] : await pricesFromFile(flags.priceFile),
|
|
148
|
+
prices: picked?.prices ?? (flags.priceFile === undefined ? [] : await pricesFromFile(flags.priceFile)),
|
|
106
149
|
};
|
|
107
150
|
};
|
package/dist/commands/deploy.js
CHANGED
|
@@ -4,6 +4,7 @@ import { CliError, ExitCode } from '../errors.js';
|
|
|
4
4
|
import { stepKey } from '../journal.js';
|
|
5
5
|
import { resolveAuthToken } from '../session.js';
|
|
6
6
|
import { loadDeployInput } from './deploy-input.js';
|
|
7
|
+
import { listTemplates, STEP as TEMPLATES_STEP } from './templates.js';
|
|
7
8
|
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
8
9
|
const quote = (token) => (SHELL_SAFE.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`);
|
|
9
10
|
/**
|
|
@@ -42,8 +43,11 @@ const partialFailure = (error, step, partial) => {
|
|
|
42
43
|
* the sequence (exit 9) with the workload id, the rerun line, and the SDK key when this run minted it.
|
|
43
44
|
*/
|
|
44
45
|
export const deploy = async (deps, options) => {
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// Resolved once, and only when a step needs it: a deploy with a mistake in its flags fails before it asks for a login.
|
|
47
|
+
let pending;
|
|
48
|
+
const authToken = () => (pending ??= resolveAuthToken(deps, options.apiUrl));
|
|
49
|
+
const input = await loadDeployInput(options, deps.env, async () => listTemplates(apiClient(deps, { apiUrl: options.apiUrl, step: TEMPLATES_STEP, token: await authToken() })));
|
|
50
|
+
const token = await authToken();
|
|
47
51
|
const base = options.idempotencyKey ?? randomUUID();
|
|
48
52
|
const clientFor = (step) => apiClient(deps, { apiUrl: options.apiUrl, step, token });
|
|
49
53
|
const prior = await deps.journal.read(base).catch(() => undefined);
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Outcome, RunDeps } from '../cli.js';
|
|
2
2
|
export interface LoginOptions {
|
|
3
3
|
apiUrl: string;
|
|
4
|
-
/** `--issuer-url`; `OARS_ISSUER_URL` when absent
|
|
4
|
+
/** `--issuer-url`; `OARS_ISSUER_URL` when absent, then the built-in production issuer once Many Oars has launched (issuer.ts). */
|
|
5
5
|
issuerUrl?: string;
|
|
6
6
|
/** False for `--no-browser`. */
|
|
7
7
|
browser: boolean;
|
package/dist/commands/login.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { CliError, ExitCode } from '../errors.js';
|
|
2
|
+
import { resolveIssuerUrl } from '../issuer.js';
|
|
2
3
|
import { awaitDeviceTokens, discover, startDeviceAuthorization } from '../oidc.js';
|
|
3
4
|
import { exchangeKeycloakToken } from '../session.js';
|
|
4
5
|
const STEP = 'login';
|
|
5
6
|
/** RFC 8628 device flow on Keycloak, then the exchange; stores the credentials. */
|
|
6
7
|
export const login = async (deps, options) => {
|
|
7
|
-
const issuerUrl = options.issuerUrl
|
|
8
|
-
if (issuerUrl === undefined || issuerUrl === '') {
|
|
9
|
-
throw new CliError('No Keycloak issuer URL: pass --issuer-url or set OARS_ISSUER_URL.', ExitCode.USAGE, 'usage');
|
|
10
|
-
}
|
|
8
|
+
const issuerUrl = await resolveIssuerUrl(deps, options.issuerUrl);
|
|
11
9
|
const config = await discover(issuerUrl, { ...deps, step: STEP });
|
|
12
10
|
const device = await startDeviceAuthorization(config, STEP);
|
|
13
11
|
const url = device.verification_uri_complete ?? device.verification_uri;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { components, OarsClient } from '@manyoars/oars-client';
|
|
2
|
+
import type { Outcome, RunDeps } from '../cli.js';
|
|
3
|
+
export type AgentTemplate = components['schemas']['AgentTemplateResponse'];
|
|
4
|
+
export interface TemplatesOptions {
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const STEP = "templates";
|
|
8
|
+
/** Every entry of `GET /agents/templates`, following the pages until the last one. */
|
|
9
|
+
export declare const listTemplates: (client: OarsClient) => Promise<AgentTemplate[]>;
|
|
10
|
+
/** `oars templates`: the platform's starter-template catalog, the ids `oars deploy --template` takes. */
|
|
11
|
+
export declare const templates: (deps: RunDeps, options: TemplatesOptions) => Promise<Outcome>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { apiClient, unwrap } from '../api.js';
|
|
2
|
+
import { resolveAuthToken } from '../session.js';
|
|
3
|
+
export const STEP = 'templates';
|
|
4
|
+
// The catalog holds a handful of entries; a page this large is the whole of it, and the loop below only covers a catalog that outgrows it.
|
|
5
|
+
const PAGE_SIZE = 100;
|
|
6
|
+
/** Every entry of `GET /agents/templates`, following the pages until the last one. */
|
|
7
|
+
export const listTemplates = async (client) => {
|
|
8
|
+
const templates = [];
|
|
9
|
+
for (let page = 0;; page += 1) {
|
|
10
|
+
const result = unwrap(STEP, await client.GET('/agents/templates', { params: { query: { page, size: PAGE_SIZE } } }));
|
|
11
|
+
templates.push(...result.content);
|
|
12
|
+
if (result.last)
|
|
13
|
+
return templates;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const describePrice = (price) => `${price.priceRef} ($${price.amountUsd}${typeof price.credits === 'number' ? `, ${String(price.credits)} credits` : ''})`;
|
|
17
|
+
/** `oars templates`: the platform's starter-template catalog, the ids `oars deploy --template` takes. */
|
|
18
|
+
export const templates = async (deps, options) => {
|
|
19
|
+
const client = apiClient(deps, { apiUrl: options.apiUrl, step: STEP, token: await resolveAuthToken(deps, options.apiUrl) });
|
|
20
|
+
const catalog = await listTemplates(client);
|
|
21
|
+
const lines = catalog.length === 0
|
|
22
|
+
? ['No starter templates are available.']
|
|
23
|
+
: [
|
|
24
|
+
...catalog.flatMap((template) => [
|
|
25
|
+
`${template.templateId}: ${template.name}`,
|
|
26
|
+
` ${template.description}`,
|
|
27
|
+
...(template.starterPrices.length === 0 ? [] : [` Prices: ${template.starterPrices.map(describePrice).join(', ')}`]),
|
|
28
|
+
]),
|
|
29
|
+
'Deploy one with: oars deploy --template <id>',
|
|
30
|
+
];
|
|
31
|
+
return { document: { ok: true, templates: catalog }, text: lines.join('\n') };
|
|
32
|
+
};
|
package/dist/issuer.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RunDeps } from './cli.js';
|
|
2
|
+
/**
|
|
3
|
+
* The production Keycloak realm (aor-platform/k8s/configmap.yaml: KEYCLOAK_ISSUER_URI). It ships in the CLI, reviewed with it:
|
|
4
|
+
* the launch flag below only switches it on, so a compromised site cannot send logins anywhere else.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PRODUCTION_ISSUER_URL = "https://login.manyoars.com/realms/aor";
|
|
7
|
+
/** The marketing site serves `{"launched": true}` here from launch day. */
|
|
8
|
+
export declare const LAUNCH_FLAG_URL = "https://manyoars.com/.well-known/oars.json";
|
|
9
|
+
/** `--issuer-url`, else `OARS_ISSUER_URL`, else the built-in production issuer once the launch flag is on. */
|
|
10
|
+
export declare const resolveIssuerUrl: (deps: RunDeps, flag?: string) => Promise<string>;
|
package/dist/issuer.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CliError, ExitCode } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* The production Keycloak realm (aor-platform/k8s/configmap.yaml: KEYCLOAK_ISSUER_URI). It ships in the CLI, reviewed with it:
|
|
4
|
+
* the launch flag below only switches it on, so a compromised site cannot send logins anywhere else.
|
|
5
|
+
*/
|
|
6
|
+
export const PRODUCTION_ISSUER_URL = 'https://login.manyoars.com/realms/aor';
|
|
7
|
+
/** The marketing site serves `{"launched": true}` here from launch day. */
|
|
8
|
+
export const LAUNCH_FLAG_URL = 'https://manyoars.com/.well-known/oars.json';
|
|
9
|
+
const FLAG_TIMEOUT_MS = 5000;
|
|
10
|
+
const NOT_LAUNCHED = 'Many Oars has not launched yet: pass --issuer-url or set OARS_ISSUER_URL.';
|
|
11
|
+
/** True only for a 200 answer whose JSON is an object with `launched: true`; every other answer, or none, means not launched. */
|
|
12
|
+
const hasLaunched = async (deps) => {
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(LAUNCH_FLAG_URL, {
|
|
15
|
+
headers: { 'User-Agent': `oars/${deps.version}`, Accept: 'application/json' },
|
|
16
|
+
redirect: 'error',
|
|
17
|
+
signal: AbortSignal.timeout(FLAG_TIMEOUT_MS),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok)
|
|
20
|
+
return false;
|
|
21
|
+
const body = await response.json();
|
|
22
|
+
return typeof body === 'object' && body !== null && Reflect.get(body, 'launched') === true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** `--issuer-url`, else `OARS_ISSUER_URL`, else the built-in production issuer once the launch flag is on. */
|
|
29
|
+
export const resolveIssuerUrl = async (deps, flag) => {
|
|
30
|
+
if (flag !== undefined)
|
|
31
|
+
return flag;
|
|
32
|
+
const fromEnv = deps.env.OARS_ISSUER_URL;
|
|
33
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
34
|
+
return fromEnv;
|
|
35
|
+
if (await hasLaunched(deps))
|
|
36
|
+
return PRODUCTION_ISSUER_URL;
|
|
37
|
+
throw new CliError(NOT_LAUNCHED, ExitCode.USAGE, 'usage');
|
|
38
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manyoars/oars",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The oars CLI: log in to Many Oars, list the starter templates and deploy an agent",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The oars CLI: log in to Many Oars, list the starter templates, and deploy an agent",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"services": [
|
|
8
8
|
{
|
|
9
9
|
"name": "api",
|
|
10
|
-
"image": "ghcr.io/manyoars/paid-api@sha256:
|
|
10
|
+
"image": "ghcr.io/manyoars/paid-api@sha256:c24de301f3b29f912c6235046b1f964a213b8437e496ba4b9f9de362e094b13f",
|
|
11
11
|
"port": 8080,
|
|
12
12
|
"public": true,
|
|
13
13
|
"cpuMillicores": 250,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"services": [
|
|
8
8
|
{
|
|
9
9
|
"name": "agent",
|
|
10
|
-
"image": "ghcr.io/manyoars/per-task-agent@sha256:
|
|
10
|
+
"image": "ghcr.io/manyoars/per-task-agent@sha256:3dfe58ccacfcf8bf136e487e0dd6152092d26e6349f9671a2c46f78a097c07b3",
|
|
11
11
|
"port": 8080,
|
|
12
12
|
"public": true,
|
|
13
13
|
"cpuMillicores": 500,
|