@manyoars/oars 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,107 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { CliError, ExitCode } from '../errors.js';
3
+ const usage = (message) => new CliError(message, ExitCode.USAGE, 'usage');
4
+ const DIGEST = /@sha256:[0-9a-f]{64}$/;
5
+ const PRICE_KINDS = ['ONE_TIME', 'RECURRING_MONTHLY', 'CREDIT_PACK'];
6
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const has = (value, key, type) => typeof Reflect.get(value, key) === type;
8
+ const isService = (value) => isObject(value) && has(value, 'name', 'string') && has(value, 'image', 'string') && has(value, 'cpuMillicores', 'number') && has(value, 'memoryMib', 'number');
9
+ const isRegisterRequest = (value) => {
10
+ if (!isObject(value) || !has(value, 'name', 'string'))
11
+ return false;
12
+ const services = Reflect.get(value, 'services');
13
+ return Array.isArray(services) && services.length > 0 && services.every(isService);
14
+ };
15
+ const isPriceKind = (value) => PRICE_KINDS.some((kind) => kind === value);
16
+ const isPrice = (value) => isObject(value) && has(value, 'priceRef', 'string') && has(value, 'displayName', 'string') && has(value, 'amountUsd', 'string') && isPriceKind(Reflect.get(value, 'kind'));
17
+ const isPrices = (value) => Array.isArray(value) && value.every(isPrice);
18
+ /** A JSON file's parsed content; a file that cannot be read or parsed is exit 2 naming it. */
19
+ const readJson = async (path) => {
20
+ let text;
21
+ try {
22
+ text = await readFile(path, 'utf8');
23
+ }
24
+ catch (error) {
25
+ throw usage(`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ try {
28
+ return JSON.parse(text);
29
+ }
30
+ catch (error) {
31
+ throw usage(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ };
34
+ /** The platform deploys what a digest names; a tag can move, so a service without one is refused before any request. */
35
+ const requireDigest = (image) => {
36
+ if (!DIGEST.test(image)) {
37
+ throw usage(`${image} is not pinned by digest. Print one with: docker buildx imagetools inspect ${image}\nthen deploy <name>@sha256:<64 hex digits>.`);
38
+ }
39
+ };
40
+ const secretsFrom = (flags, env) => [
41
+ ...flags.secrets.map((entry) => {
42
+ const at = entry.indexOf('=');
43
+ // The entry may be the secret itself, so the message does not echo it.
44
+ if (at < 1)
45
+ throw usage('--secret takes NAME=value; use --secret-from-env NAME to read the value from the environment.');
46
+ return { name: entry.slice(0, at), value: entry.slice(at + 1) };
47
+ }),
48
+ ...flags.secretsFromEnv.map((name) => {
49
+ const value = env[name];
50
+ if (value === undefined || value === '')
51
+ throw usage(`--secret-from-env ${name}: the environment variable ${name} is not set.`);
52
+ return { name, value };
53
+ }),
54
+ ];
55
+ const requestFromFlags = (flags, image) => {
56
+ if (flags.name === undefined)
57
+ throw usage('--image needs --name <name>.');
58
+ requireDigest(image);
59
+ return {
60
+ name: flags.name,
61
+ ...(flags.alwaysOn === true ? { alwaysOn: true } : {}),
62
+ services: [{ name: 'app', image, port: flags.port ?? 8080, public: flags.public ?? false, cpuMillicores: flags.cpuMillicores ?? 250, memoryMib: flags.memoryMib ?? 256 }],
63
+ };
64
+ };
65
+ const requestFromFile = async (flags, path) => {
66
+ const given = ['name', 'port', 'public', 'cpuMillicores', 'memoryMib'].filter((key) => flags[key] !== undefined);
67
+ 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.');
69
+ const parsed = await readJson(path);
70
+ if (!isRegisterRequest(parsed)) {
71
+ throw usage(`${path} is not a workload manifest: it needs a name and services, each with name, image, cpuMillicores and memoryMib.`);
72
+ }
73
+ for (const service of parsed.services)
74
+ requireDigest(service.image);
75
+ return flags.alwaysOn === true ? { ...parsed, alwaysOn: true } : parsed;
76
+ };
77
+ const pricesFromFile = async (path) => {
78
+ const parsed = await readJson(path);
79
+ if (!isPrices(parsed)) {
80
+ throw usage(`${path} must be a JSON array of prices, each with priceRef, displayName, amountUsd (a decimal string) and kind (ONE_TIME, RECURRING_MONTHLY or CREDIT_PACK).`);
81
+ }
82
+ return parsed;
83
+ };
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>.');
90
+ };
91
+ /**
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).
94
+ */
95
+ export const loadDeployInput = async (flags, env) => {
96
+ if ([flags.template, flags.image, flags.manifestFile].filter((source) => source !== undefined).length !== 1) {
97
+ throw usage('Pass exactly one of --template, --image or --manifest.');
98
+ }
99
+ const base = await baseRequest(flags);
100
+ if (flags.prices.length > 0 || flags.noPrices)
101
+ throw usage("--price and --no-prices choose among a template's starter prices; use --price-file with --image or --manifest.");
102
+ const secrets = [...(base.secrets ?? []), ...secretsFrom(flags, env)];
103
+ return {
104
+ request: secrets.length === 0 ? base : { ...base, secrets },
105
+ prices: flags.priceFile === undefined ? [] : await pricesFromFile(flags.priceFile),
106
+ };
107
+ };
@@ -0,0 +1,22 @@
1
+ import { type DeployFlags } from './deploy-input.js';
2
+ import type { Outcome, RunDeps } from '../cli.js';
3
+ export interface DeployOptions extends DeployFlags {
4
+ apiUrl: string;
5
+ /** The command line as typed (without the node and script entries), for the rerun line. */
6
+ argv: string[];
7
+ /** False for `--no-start`: register and price, but make no deploy call. */
8
+ start: boolean;
9
+ /** `--idempotency-key`: the base of every step's key. A fresh UUID when absent. */
10
+ idempotencyKey?: string;
11
+ }
12
+ /**
13
+ * The command that resumes a failed deploy: the same arguments plus `--idempotency-key <base>`. Secret values are never
14
+ * printed, so `--secret NAME=value` becomes `--secret-from-env NAME` (the developer exports NAME before the rerun).
15
+ */
16
+ export declare const rerunCommand: (argv: string[], base: string) => string;
17
+ /**
18
+ * The web deploy page's order: register, each price, deploy (design §3.1). Each call carries its own idempotency key
19
+ * (`stepKey(base, step)`), so a rerun with the same base replays what finished and carries on. The first failed step stops
20
+ * the sequence (exit 9) with the workload id, the rerun line, and the SDK key when this run minted it.
21
+ */
22
+ export declare const deploy: (deps: RunDeps, options: DeployOptions) => Promise<Outcome>;
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { apiClient, unwrap } from '../api.js';
3
+ import { CliError, ExitCode } from '../errors.js';
4
+ import { stepKey } from '../journal.js';
5
+ import { resolveAuthToken } from '../session.js';
6
+ import { loadDeployInput } from './deploy-input.js';
7
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
8
+ const quote = (token) => (SHELL_SAFE.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`);
9
+ /**
10
+ * The command that resumes a failed deploy: the same arguments plus `--idempotency-key <base>`. Secret values are never
11
+ * printed, so `--secret NAME=value` becomes `--secret-from-env NAME` (the developer exports NAME before the rerun).
12
+ */
13
+ export const rerunCommand = (argv, base) => {
14
+ const secretName = (entry) => entry.split('=')[0] ?? '';
15
+ const tokens = [];
16
+ for (let at = 0; at < argv.length; at += 1) {
17
+ const arg = argv[at] ?? '';
18
+ if (arg === '--idempotency-key') {
19
+ at += 1;
20
+ }
21
+ else if (arg === '--secret') {
22
+ at += 1;
23
+ tokens.push('--secret-from-env', secretName(argv[at] ?? ''));
24
+ }
25
+ else if (arg.startsWith('--secret=')) {
26
+ tokens.push('--secret-from-env', secretName(arg.slice('--secret='.length)));
27
+ }
28
+ else if (!arg.startsWith('--idempotency-key=')) {
29
+ tokens.push(arg);
30
+ }
31
+ }
32
+ return ['oars', ...tokens, '--idempotency-key', base].map(quote).join(' ');
33
+ };
34
+ /** A failure after register is exit 9 whatever its own code: the workload exists, so the developer's next move is the rerun. */
35
+ const partialFailure = (error, step, partial) => {
36
+ const cause = error instanceof CliError ? error : new CliError(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`, ExitCode.UNEXPECTED, step);
37
+ return new CliError(cause.message, ExitCode.PARTIAL, step, cause.problem, partial);
38
+ };
39
+ /**
40
+ * The web deploy page's order: register, each price, deploy (design §3.1). Each call carries its own idempotency key
41
+ * (`stepKey(base, step)`), so a rerun with the same base replays what finished and carries on. The first failed step stops
42
+ * the sequence (exit 9) with the workload id, the rerun line, and the SDK key when this run minted it.
43
+ */
44
+ export const deploy = async (deps, options) => {
45
+ const input = await loadDeployInput(options, deps.env);
46
+ const token = await resolveAuthToken(deps, options.apiUrl);
47
+ const base = options.idempotencyKey ?? randomUUID();
48
+ const clientFor = (step) => apiClient(deps, { apiUrl: options.apiUrl, step, token });
49
+ const prior = await deps.journal.read(base).catch(() => undefined);
50
+ const progress = { base, steps: prior?.steps ?? [] };
51
+ if (prior !== undefined) {
52
+ deps.io.stderr(`Resuming deploy ${base}: ${prior.steps.map((entry) => entry.step).join(', ') || 'nothing finished yet'} already done.\n`);
53
+ }
54
+ // The journal only tells the developer where the last run got to; the platform's replay is what makes the rerun correct, so
55
+ // a journal that cannot be written must not lose the one-time SDK key.
56
+ const note = async (step, status) => {
57
+ progress.steps = [...progress.steps.filter((entry) => entry.step !== step), { step, status }];
58
+ await deps.journal.write(progress).catch((error) => {
59
+ deps.io.stderr(`Could not record progress in the journal: ${error instanceof Error ? error.message : String(error)}\n`);
60
+ });
61
+ };
62
+ const registerResult = await clientFor('register').POST('/agents/workloads', {
63
+ params: { header: { 'Idempotency-Key': stepKey(base, 'register') } },
64
+ body: input.request,
65
+ });
66
+ const workload = unwrap('register', registerResult);
67
+ await note('register', registerResult.response.status);
68
+ // A replayed registration carries no key: only the run that created the workload can show it.
69
+ const partial = { workloadId: workload.id, ...(workload.sdkKey === undefined ? {} : { sdkKey: workload.sdkKey }), rerun: rerunCommand(options.argv, base) };
70
+ const path = { workloadId: workload.id };
71
+ const stages = [
72
+ ...input.prices.map((price) => {
73
+ const step = `price:${price.priceRef}`;
74
+ return {
75
+ step,
76
+ send: async () => {
77
+ const result = await clientFor(step).POST('/billing/agents/{workloadId}/prices', { params: { path }, headers: { 'Idempotency-Key': stepKey(base, step) }, body: price });
78
+ unwrap(step, result);
79
+ return result.response.status;
80
+ },
81
+ };
82
+ }),
83
+ ...(options.start
84
+ ? [
85
+ {
86
+ step: 'deploy',
87
+ send: async () => {
88
+ const result = await clientFor('deploy').POST('/agents/workloads/{workloadId}/deploy', { params: { path }, headers: { 'Idempotency-Key': stepKey(base, 'deploy') } });
89
+ unwrap('deploy', result);
90
+ return result.response.status;
91
+ },
92
+ },
93
+ ]
94
+ : []),
95
+ ];
96
+ for (const stage of stages) {
97
+ let status;
98
+ try {
99
+ status = await stage.send();
100
+ }
101
+ catch (error) {
102
+ throw partialFailure(error, stage.step, partial);
103
+ }
104
+ await note(stage.step, status);
105
+ }
106
+ await deps.journal.delete(base).catch(() => undefined);
107
+ const prices = input.prices.map((price) => price.priceRef);
108
+ const nextSteps = [`oars status ${workload.id}`, `oars test-charge ${workload.id}${prices[0] === undefined ? '' : ` --price ${prices[0]}`}`];
109
+ const lines = [
110
+ `${options.start ? 'Deployed' : 'Registered'} ${workload.name} (workload ${workload.id})`,
111
+ ...(workload.sdkKey === undefined ? [] : [`SDK key (shown once; save it now): ${workload.sdkKey}`]),
112
+ ...(prices.length === 0 ? [] : [`Prices: ${prices.join(', ')}`]),
113
+ 'Next:',
114
+ ...nextSteps.map((step) => ` ${step}`),
115
+ ];
116
+ return {
117
+ document: { ok: true, workloadId: workload.id, ...(workload.sdkKey === undefined ? {} : { sdkKey: workload.sdkKey }), prices, started: options.start, nextSteps },
118
+ text: lines.join('\n'),
119
+ };
120
+ };
@@ -0,0 +1,10 @@
1
+ import type { Outcome, RunDeps } from '../cli.js';
2
+ export interface LoginOptions {
3
+ apiUrl: string;
4
+ /** `--issuer-url`; `OARS_ISSUER_URL` when absent. There is no production default until one is ruled. */
5
+ issuerUrl?: string;
6
+ /** False for `--no-browser`. */
7
+ browser: boolean;
8
+ }
9
+ /** RFC 8628 device flow on Keycloak, then the exchange; stores the credentials. */
10
+ export declare const login: (deps: RunDeps, options: LoginOptions) => Promise<Outcome>;
@@ -0,0 +1,35 @@
1
+ import { CliError, ExitCode } from '../errors.js';
2
+ import { awaitDeviceTokens, discover, startDeviceAuthorization } from '../oidc.js';
3
+ import { exchangeKeycloakToken } from '../session.js';
4
+ const STEP = 'login';
5
+ /** RFC 8628 device flow on Keycloak, then the exchange; stores the credentials. */
6
+ 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
+ }
11
+ const config = await discover(issuerUrl, { ...deps, step: STEP });
12
+ const device = await startDeviceAuthorization(config, STEP);
13
+ const url = device.verification_uri_complete ?? device.verification_uri;
14
+ deps.io.stderr(`To sign in, open ${url}\nand confirm the code ${device.user_code}\n`);
15
+ if (options.browser) {
16
+ await deps.openBrowser(url).catch(() => {
17
+ deps.io.stderr('Could not open a browser; open the link above yourself.\n');
18
+ });
19
+ }
20
+ const tokens = await awaitDeviceTokens(config, device, STEP);
21
+ if (tokens.refreshToken === undefined) {
22
+ throw new CliError('Keycloak returned no refresh token, so the login could not be kept.', ExitCode.UNEXPECTED, STEP);
23
+ }
24
+ const session = await exchangeKeycloakToken(deps, options.apiUrl, tokens.accessToken, STEP);
25
+ const email = session.user.email;
26
+ await deps.store.write({
27
+ issuerUrl,
28
+ apiUrl: options.apiUrl,
29
+ email,
30
+ refreshToken: tokens.refreshToken,
31
+ accessToken: session.accessToken,
32
+ expiresAt: session.expiresAt,
33
+ });
34
+ return { document: { ok: true, email }, text: `Logged in as ${email}` };
35
+ };
@@ -0,0 +1,3 @@
1
+ import type { Outcome, RunDeps } from '../cli.js';
2
+ /** Revokes the stored refresh token at Keycloak, then deletes the credentials. A failed revocation still deletes them. */
3
+ export declare const logout: (deps: RunDeps) => Promise<Outcome>;
@@ -0,0 +1,17 @@
1
+ import { discover, revokeRefreshToken } from '../oidc.js';
2
+ const STEP = 'logout';
3
+ /** Revokes the stored refresh token at Keycloak, then deletes the credentials. A failed revocation still deletes them. */
4
+ export const logout = async (deps) => {
5
+ const stored = await deps.store.read();
6
+ if (stored !== undefined) {
7
+ try {
8
+ await revokeRefreshToken(await discover(stored.issuerUrl, { ...deps, step: STEP }), stored.refreshToken, STEP);
9
+ }
10
+ catch (error) {
11
+ const reason = error instanceof Error ? error.message : String(error);
12
+ deps.io.stderr(`The refresh token could not be revoked (${reason}); it stops working by itself when idle.\n`);
13
+ }
14
+ }
15
+ await deps.store.delete();
16
+ return { document: { ok: true }, text: 'Logged out' };
17
+ };
@@ -0,0 +1,7 @@
1
+ import type { Outcome, RunDeps } from '../cli.js';
2
+ export interface StatusOptions {
3
+ apiUrl: string;
4
+ workloadId: string;
5
+ }
6
+ /** The workload (`GET /agents/workloads/{id}`) and its billing mode (`GET /billing/agents/{id}/mode`, TEST until the creator goes live). */
7
+ export declare const status: (deps: RunDeps, options: StatusOptions) => Promise<Outcome>;
@@ -0,0 +1,18 @@
1
+ import { apiClient, unwrap } from '../api.js';
2
+ import { resolveAuthToken } from '../session.js';
3
+ const STEP = 'status';
4
+ /** The workload (`GET /agents/workloads/{id}`) and its billing mode (`GET /billing/agents/{id}/mode`, TEST until the creator goes live). */
5
+ export const status = async (deps, options) => {
6
+ const client = apiClient(deps, { apiUrl: options.apiUrl, step: STEP, token: await resolveAuthToken(deps, options.apiUrl) });
7
+ const params = { path: { workloadId: options.workloadId } };
8
+ const workload = unwrap(STEP, await client.GET('/agents/workloads/{workloadId}', { params }));
9
+ const { billingMode } = unwrap(STEP, await client.GET('/billing/agents/{workloadId}/mode', { params }));
10
+ const { deployment } = workload;
11
+ const lines = [
12
+ `${workload.name} (${workload.id})`,
13
+ `Workload: ${workload.status}${workload.suspended ? ' (suspended)' : ''}`,
14
+ `Deployment: ${deployment.status} (${deployment.health}), desired ${deployment.desiredState}`,
15
+ `Billing mode: ${billingMode}`,
16
+ ];
17
+ return { document: { ok: true, workload, billingMode }, text: lines.join('\n') };
18
+ };
@@ -0,0 +1,11 @@
1
+ import type { Outcome, RunDeps } from '../cli.js';
2
+ export interface TestChargeOptions {
3
+ apiUrl: string;
4
+ workloadId: string;
5
+ /** `--price`; absent means the workload's one active price. */
6
+ priceRef?: string;
7
+ /** The base of the step key, so a retry of this command replays the charge instead of making a second one. */
8
+ idempotencyKey: string;
9
+ }
10
+ /** `POST /billing/agents/{id}/test-charges`: a TEST-mode charge against one of the workload's prices (no money moves). */
11
+ export declare const testCharge: (deps: RunDeps, options: TestChargeOptions) => Promise<Outcome>;
@@ -0,0 +1,34 @@
1
+ import { apiClient, unwrap } from '../api.js';
2
+ import { CliError, ExitCode } from '../errors.js';
3
+ import { stepKey } from '../journal.js';
4
+ import { resolveAuthToken } from '../session.js';
5
+ const STEP = 'test-charge';
6
+ /** The price a charge without `--price` uses: the workload's only active one, else exit 2 saying which to pick from. */
7
+ const onlyActivePrice = async (client, workloadId) => {
8
+ const page = unwrap(STEP, await client.GET('/billing/agents/{workloadId}/prices', { params: { path: { workloadId }, query: { size: 100 } } }));
9
+ const active = page.content.filter((price) => price.active).map((price) => price.priceRef);
10
+ if (!page.last) {
11
+ throw new CliError(`${workloadId} has more prices than one request lists; pass --price <priceRef>.`, ExitCode.USAGE, 'usage');
12
+ }
13
+ const [only, ...others] = active;
14
+ if (only === undefined) {
15
+ throw new CliError(`${workloadId} has no active price; create one, or pass --price <priceRef>.`, ExitCode.USAGE, 'usage');
16
+ }
17
+ if (others.length > 0) {
18
+ throw new CliError(`${workloadId} has several active prices (${active.join(', ')}); pass --price <priceRef>.`, ExitCode.USAGE, 'usage');
19
+ }
20
+ return only;
21
+ };
22
+ /** `POST /billing/agents/{id}/test-charges`: a TEST-mode charge against one of the workload's prices (no money moves). */
23
+ export const testCharge = async (deps, options) => {
24
+ const client = apiClient(deps, { apiUrl: options.apiUrl, step: STEP, token: await resolveAuthToken(deps, options.apiUrl) });
25
+ const priceRef = options.priceRef ?? (await onlyActivePrice(client, options.workloadId));
26
+ const charge = unwrap(STEP, await client.POST('/billing/agents/{workloadId}/test-charges', {
27
+ params: { path: { workloadId: options.workloadId }, header: { 'Idempotency-Key': stepKey(options.idempotencyKey, STEP) } },
28
+ body: { priceRef },
29
+ }));
30
+ return {
31
+ document: { ok: true, chargeId: charge.chargeId, status: charge.status },
32
+ text: `Test charge ${charge.chargeId} for ${priceRef}: ${charge.status}`,
33
+ };
34
+ };
@@ -0,0 +1,28 @@
1
+ /** What `oars login` keeps: the Keycloak refresh token and the platform access token the exchange returned for it. */
2
+ export interface StoredCredentials {
3
+ issuerUrl: string;
4
+ apiUrl: string;
5
+ email: string;
6
+ refreshToken: string;
7
+ accessToken: string;
8
+ /** When `accessToken` stops working (ISO 8601). */
9
+ expiresAt: string;
10
+ }
11
+ export interface CredentialStore {
12
+ /** Undefined when nobody is logged in, including when the file is unreadable as credentials. */
13
+ read: () => Promise<StoredCredentials | undefined>;
14
+ /** Replaces the file atomically, owner-only. */
15
+ write: (credentials: StoredCredentials) => Promise<void>;
16
+ delete: () => Promise<void>;
17
+ }
18
+ /**
19
+ * `%APPDATA%` on Windows (the user-profile ACL keeps it private), `$XDG_CONFIG_HOME` or `~/.config` elsewhere. An empty or
20
+ * relative value counts as unset, as the XDG spec has it: a relative one would put the credentials under the cwd, which
21
+ * could be a git checkout.
22
+ */
23
+ export declare const defaultConfigDir: (env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string) => string;
24
+ /**
25
+ * Keeps the credentials in `<dir>/oars/credentials.json`, mode 0600 (design §3.4). A write goes to a fresh sibling file, is
26
+ * synced, then renamed over the old one, so a crash leaves either the old credentials or the new, never half of either.
27
+ */
28
+ export declare const fileCredentialStore: (dir: string) => CredentialStore;
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
+ import { join, posix, win32 } from 'node:path';
4
+ const hasString = (value, key) => key in value && typeof Reflect.get(value, key) === 'string';
5
+ const isStoredCredentials = (value) => typeof value === 'object' &&
6
+ value !== null &&
7
+ hasString(value, 'issuerUrl') &&
8
+ hasString(value, 'apiUrl') &&
9
+ hasString(value, 'email') &&
10
+ hasString(value, 'refreshToken') &&
11
+ hasString(value, 'accessToken') &&
12
+ hasString(value, 'expiresAt');
13
+ const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
14
+ /**
15
+ * `%APPDATA%` on Windows (the user-profile ACL keeps it private), `$XDG_CONFIG_HOME` or `~/.config` elsewhere. An empty or
16
+ * relative value counts as unset, as the XDG spec has it: a relative one would put the credentials under the cwd, which
17
+ * could be a git checkout.
18
+ */
19
+ export const defaultConfigDir = (env, platform, home) => {
20
+ const windows = platform === 'win32';
21
+ const configured = windows ? env.APPDATA : env.XDG_CONFIG_HOME;
22
+ if (configured !== undefined && (windows ? win32 : posix).isAbsolute(configured))
23
+ return configured;
24
+ return windows ? join(home, 'AppData', 'Roaming') : join(home, '.config');
25
+ };
26
+ /**
27
+ * Keeps the credentials in `<dir>/oars/credentials.json`, mode 0600 (design §3.4). A write goes to a fresh sibling file, is
28
+ * synced, then renamed over the old one, so a crash leaves either the old credentials or the new, never half of either.
29
+ */
30
+ export const fileCredentialStore = (dir) => {
31
+ const folder = join(dir, 'oars');
32
+ const file = join(folder, 'credentials.json');
33
+ return {
34
+ read: async () => {
35
+ let text;
36
+ try {
37
+ text = await readFile(file, 'utf8');
38
+ }
39
+ catch (error) {
40
+ if (isMissing(error))
41
+ return undefined;
42
+ throw error;
43
+ }
44
+ try {
45
+ const parsed = JSON.parse(text);
46
+ return isStoredCredentials(parsed) ? parsed : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ },
52
+ write: async (credentials) => {
53
+ await mkdir(folder, { recursive: true, mode: 0o700 });
54
+ const temp = join(folder, `credentials.${randomUUID()}.tmp`);
55
+ try {
56
+ const handle = await open(temp, 'wx', 0o600);
57
+ try {
58
+ await handle.writeFile(`${JSON.stringify(credentials, null, 2)}\n`);
59
+ await handle.sync();
60
+ }
61
+ finally {
62
+ await handle.close();
63
+ }
64
+ await rename(temp, file);
65
+ }
66
+ catch (error) {
67
+ await rm(temp, { force: true });
68
+ throw error;
69
+ }
70
+ },
71
+ delete: () => rm(file, { force: true }),
72
+ };
73
+ };
@@ -0,0 +1,39 @@
1
+ import type { components } from '@manyoars/oars-client';
2
+ export type ProblemDetail = components['schemas']['ProblemDetail'];
3
+ /** Design §3.2's exit-code table. */
4
+ export declare const ExitCode: {
5
+ readonly OK: 0;
6
+ readonly UNEXPECTED: 1;
7
+ readonly USAGE: 2;
8
+ readonly AUTH: 3;
9
+ readonly INVALID: 4;
10
+ readonly CONFLICT: 5;
11
+ readonly HUMAN: 6;
12
+ readonly RATE_LIMITED: 7;
13
+ readonly UNREACHABLE: 8;
14
+ readonly PARTIAL: 9;
15
+ };
16
+ export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
17
+ /** The exit code for a failed response. The problem's code (then its type) decides; the status covers what has none. Text is never read. */
18
+ export declare const exitCodeForProblem: (status: number, problem?: ProblemDetail) => ExitCode;
19
+ /** What a deploy that stopped after registering leaves behind: the workload, the key shown only once, and the command that resumes. */
20
+ export interface PartialDeploy {
21
+ workloadId: string;
22
+ /** Only when the run that failed minted it: no rerun can show it again. */
23
+ sdkKey?: string;
24
+ rerun: string;
25
+ }
26
+ /** A failure the CLI reports and exits on. `step` names where it happened in `--json` documents. */
27
+ export declare class CliError extends Error {
28
+ readonly exitCode: ExitCode;
29
+ readonly step: string;
30
+ readonly problem: ProblemDetail;
31
+ readonly partial?: PartialDeploy | undefined;
32
+ constructor(message: string, exitCode: ExitCode, step: string, problem?: ProblemDetail, partial?: PartialDeploy | undefined);
33
+ }
34
+ /** Keeps the known string fields of a response body that should be a ProblemDetail; anything else (HTML from a proxy) is no problem. */
35
+ export declare const toProblem: (body: unknown) => ProblemDetail | undefined;
36
+ /** A CliError for a non-2xx response; its message is the problem's title, else detail, else the status. */
37
+ export declare const problemError: (step: string, status: number, body: unknown) => CliError;
38
+ /** A request that never got an answer (after the retries). */
39
+ export declare const unreachableError: (step: string, cause: unknown) => CliError;
package/dist/errors.js ADDED
@@ -0,0 +1,73 @@
1
+ /** Design §3.2's exit-code table. */
2
+ export const ExitCode = {
3
+ OK: 0,
4
+ UNEXPECTED: 1,
5
+ USAGE: 2,
6
+ AUTH: 3,
7
+ INVALID: 4,
8
+ CONFLICT: 5,
9
+ HUMAN: 6,
10
+ RATE_LIMITED: 7,
11
+ UNREACHABLE: 8,
12
+ PARTIAL: 9,
13
+ };
14
+ const codes = (exit, ...names) => names.map((name) => [name, exit]);
15
+ // Problem codes (or types) from spec 1 §13, T87 and T92. EXPIRED_TOKEN and ACCESS_DENIED are the device flow's own, raised locally.
16
+ const BY_CODE = new Map([
17
+ ...codes(ExitCode.AUTH, 'SDK_KEY_INVALID', 'EXPIRED_TOKEN', 'ACCESS_DENIED'),
18
+ ...codes(ExitCode.INVALID, 'MANIFEST_INVALID', 'MULTI_SERVICE_DISABLED', 'PRICE_NOT_FOUND', 'PRICE_INACTIVE', 'FEE_POLICY_INVALID', 'LIVE_MODE_REQUIRED', 'TEST_MODE_ONLY'),
19
+ ...codes(ExitCode.CONFLICT, 'IDEMPOTENCY_CONFLICT', 'WORKLOAD_FINGERPRINT_EXISTS'),
20
+ ...codes(ExitCode.HUMAN, 'ACCOUNT_NOT_READY', 'PAYMENT_METHOD_REQUIRED', 'INSUFFICIENT_CREDITS', 'COMPUTE_CAP_REACHED'),
21
+ ...codes(ExitCode.RATE_LIMITED, 'RATE_LIMITED'),
22
+ ]);
23
+ /** The exit code for a failed response. The problem's code (then its type) decides; the status covers what has none. Text is never read. */
24
+ export const exitCodeForProblem = (status, problem) => {
25
+ const key = problem?.code ?? problem?.type;
26
+ const byCode = key === undefined ? undefined : BY_CODE.get(key);
27
+ if (byCode !== undefined)
28
+ return byCode;
29
+ if (status === 401 || status === 403)
30
+ return ExitCode.AUTH;
31
+ if (status === 400 || status === 422)
32
+ return ExitCode.INVALID;
33
+ if (status === 429)
34
+ return ExitCode.RATE_LIMITED;
35
+ if (status === 502 || status === 503 || status === 504)
36
+ return ExitCode.UNREACHABLE;
37
+ return ExitCode.UNEXPECTED;
38
+ };
39
+ /** A failure the CLI reports and exits on. `step` names where it happened in `--json` documents. */
40
+ export class CliError extends Error {
41
+ exitCode;
42
+ step;
43
+ problem;
44
+ partial;
45
+ constructor(message, exitCode, step, problem = { title: message }, partial) {
46
+ super(message);
47
+ this.exitCode = exitCode;
48
+ this.step = step;
49
+ this.problem = problem;
50
+ this.partial = partial;
51
+ this.name = 'CliError';
52
+ }
53
+ }
54
+ /** Keeps the known string fields of a response body that should be a ProblemDetail; anything else (HTML from a proxy) is no problem. */
55
+ export const toProblem = (body) => {
56
+ if (typeof body !== 'object' || body === null)
57
+ return undefined;
58
+ const problem = {};
59
+ for (const key of ['actionUrl', 'code', 'detail', 'title', 'type']) {
60
+ const value = Reflect.get(body, key);
61
+ if (typeof value === 'string')
62
+ problem[key] = value;
63
+ }
64
+ return problem;
65
+ };
66
+ /** A CliError for a non-2xx response; its message is the problem's title, else detail, else the status. */
67
+ export const problemError = (step, status, body) => {
68
+ const problem = toProblem(body);
69
+ const message = problem?.title ?? problem?.detail ?? `The platform answered HTTP ${String(status)}`;
70
+ return new CliError(message, exitCodeForProblem(status, problem), step, { ...problem, title: message, status });
71
+ };
72
+ /** A request that never got an answer (after the retries). */
73
+ export const unreachableError = (step, cause) => new CliError(`Cannot reach the server: ${cause instanceof Error ? cause.message : String(cause)}`, ExitCode.UNREACHABLE, step);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where a command got to, for the rerun that follows a failure (design §3.2). It holds step names and the HTTP status each
3
+ * answered with, never a secret: the SDK key is shown once and the platform replays every finished step by its idempotency key.
4
+ */
5
+ export interface JournalRecord {
6
+ /** The human base key (`--idempotency-key` or the UUID the first run made). */
7
+ base: string;
8
+ steps: Array<{
9
+ step: string;
10
+ status: number;
11
+ }>;
12
+ }
13
+ export interface JournalStore {
14
+ /** Undefined when no run with this base key has left a record, including when the file is unreadable as one. */
15
+ read: (base: string) => Promise<JournalRecord | undefined>;
16
+ write: (record: JournalRecord) => Promise<void>;
17
+ delete: (base: string) => Promise<void>;
18
+ }
19
+ /** One step's `Idempotency-Key`: the lowercase SHA-256 hex of `<base>:<step>`, the 64-hex form every aor-common interceptor accepts. */
20
+ export declare const stepKey: (base: string, step: string) => string;
21
+ /**
22
+ * Keeps one file per base key in `<dir>/oars/journal/`, named by the key's hash because the key is whatever the developer typed.
23
+ * A write goes to a sibling file and is renamed over the old one; a torn file reads as no record, and the platform's replay
24
+ * makes the rerun correct either way.
25
+ */
26
+ export declare const fileJournalStore: (dir: string) => JournalStore;