@manyoars/oars 0.1.1 → 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 CHANGED
@@ -32,20 +32,24 @@ These flags work on every command:
32
32
  Signs in with your browser (RFC 8628 device flow) and stores the login.
33
33
 
34
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, the command exits 2.
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
36
 
37
37
  ### logout
38
38
 
39
39
  Revokes the stored login and deletes it.
40
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
+
41
45
  ### deploy
42
46
 
43
- 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).
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.
44
48
 
45
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).
46
50
  - `--manifest <file>` deploys a JSON workload manifest.
47
- - `--always-on` keeps the public service running instead of idling. It works with `--image` and `--manifest`.
48
- - `--template <id>` names a starter template. It exits 2 for now, because the public API does not list templates yet; companions `--price <ref>` (repeatable) and `--no-prices` wait with it.
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.
49
53
  - `--price-file <file>` is a JSON array of prices to create (with `--image` or `--manifest`).
50
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.
51
55
  - `--no-start` registers and prices the workload without deploying it.
@@ -66,7 +70,7 @@ The SDK key is shown once, so save it when `deploy` prints it.
66
70
 
67
71
  With `--json`, stdout carries exactly one JSON document and a success exits 0.
68
72
 
69
- - 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`; `status` adds `workload` and `billingMode`; `test-charge` adds `chargeId` and `status`.
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`.
70
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`.
71
75
 
72
76
  Without `--json`, a failure prints the problem to stderr instead.
@@ -81,7 +85,7 @@ Exit codes:
81
85
  | 3 | authentication failed (login expired or denied, SDK key invalid, HTTP 401 or 403) |
82
86
  | 4 | the platform rejected the input |
83
87
  | 5 | conflict (an idempotency key or workload already used) |
84
- | 6 | a person must act first (account, payment method, credits or compute cap) |
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 |
85
89
  | 7 | rate limited |
86
90
  | 8 | the platform could not be reached |
87
91
  | 9 | `deploy` stopped part way; rerun the printed command to resume |
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
- deps.io.stderr(`${[title ?? error.message, detail, actionUrl, ...resume].filter((line) => line !== undefined && line !== '').join('\n')}\n`);
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 (not available until the public API lists them)')
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', 'create none of the starter prices (--template)')
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 without touching the network, so a mistake costs nothing. Exactly one of
32
- * `--template`, `--image`, `--manifest`; `--template` waits for the public template catalog (the `oars templates` brief).
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
- const requestFromFile = async (flags, path) => {
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('--name, --port, --public, --cpu-millicores and --memory-mib describe an --image deploy; a --manifest file holds its own.');
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
- const baseRequest = async (flags) => {
85
- if (flags.image !== undefined)
86
- return requestFromFlags(flags, flags.image);
87
- if (flags.manifestFile !== undefined)
88
- return requestFromFile(flags, flags.manifestFile);
89
- throw usage('--template is not available yet: the public API does not publish the template catalog. Deploy with --image <name@sha256:...> --name <name>, or with --manifest <file>.');
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 without touching the network, so a mistake costs nothing. Exactly one of
93
- * `--template`, `--image`, `--manifest`; `--template` waits for the public template catalog (the `oars templates` brief).
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 base = await baseRequest(flags);
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
  };
@@ -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
- const input = await loadDeployInput(options, deps.env);
46
- const token = await resolveAuthToken(deps, options.apiUrl);
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);
@@ -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. There is no production default until one is ruled. */
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;
@@ -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 ?? deps.env.OARS_ISSUER_URL;
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
+ };
@@ -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.1.1",
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:bc18de1da5b0a0fbdc18d6d68aa9da4f89a0ab118a2c18921a1247b09d9d4146",
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:b88bb11504236ad97349f6a4ad820a98c16eef670e9aa2f93c157019406aecd6",
10
+ "image": "ghcr.io/manyoars/per-task-agent@sha256:3dfe58ccacfcf8bf136e487e0dd6152092d26e6349f9671a2c46f78a097c07b3",
11
11
  "port": 8080,
12
12
  "public": true,
13
13
  "cpuMillicores": 500,