@farthershore/cli 0.3.5 → 0.3.7
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 +30 -4
- package/dist/client.d.ts +17 -29
- package/dist/commands/validate.d.ts +26 -0
- package/dist/config.d.ts +0 -3
- package/dist/index.js +1237 -46
- package/dist/output.d.ts +1 -4
- package/dist/remediation.d.ts +6 -0
- package/dist/types.d.ts +36 -11
- package/package.json +14 -5
- package/dist/auth.js +0 -17
- package/dist/build-info.js +0 -10
- package/dist/client.js +0 -36
- package/dist/commands/apply.js +0 -127
- package/dist/commands/init.js +0 -41
- package/dist/commands/login.js +0 -95
- package/dist/commands/validate.js +0 -190
- package/dist/config.js +0 -76
- package/dist/output.js +0 -47
- package/dist/types.js +0 -9
package/dist/output.d.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
export declare function table(headers: string[], rows: string[][]): string;
|
|
2
1
|
export declare function json(data: unknown): string;
|
|
3
2
|
export declare function success(msg: string): void;
|
|
4
3
|
export declare function error(msg: string): void;
|
|
5
4
|
export declare function warn(msg: string): void;
|
|
6
5
|
export declare function info(msg: string): void;
|
|
7
6
|
export declare function heading(msg: string): void;
|
|
8
|
-
export declare function formatPrice(cents: number): string;
|
|
9
|
-
export declare function formatDate(iso: string): string;
|
|
10
7
|
export declare function isTTY(): boolean;
|
|
11
|
-
export declare function outputFormat(flagFormat: string | undefined): "table" | "json"
|
|
8
|
+
export declare function outputFormat(flagFormat: string | undefined): "table" | "json";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Look up a one-line remediation hint for a server error code. Returns
|
|
3
|
+
* `undefined` if no hint is registered — callers should treat that as
|
|
4
|
+
* "show no hint" rather than a fallback string.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getRemediation(code: string | undefined): string | undefined;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import type { BuilderPlan } from "@farther-shore/shared-types/plans";
|
|
2
|
+
/**
|
|
3
|
+
* Diagnostic emitted by core's /compile endpoints. The `compiledPlanId` and
|
|
4
|
+
* `planKey` fields (added in core v0.14.0) scope a diagnostic to a specific
|
|
5
|
+
* plan; both stay optional so diagnostics from older core builds still
|
|
6
|
+
* typecheck while the new shape rolls out.
|
|
7
|
+
*/
|
|
8
|
+
export type CompileDiagnostic = {
|
|
9
|
+
code?: string;
|
|
10
|
+
message: string;
|
|
11
|
+
compiledPlanId?: string;
|
|
12
|
+
planKey?: string;
|
|
13
|
+
};
|
|
1
14
|
export type Product = {
|
|
2
15
|
id: string;
|
|
3
16
|
name: string;
|
|
@@ -10,16 +23,13 @@ export type Product = {
|
|
|
10
23
|
createdAt: string;
|
|
11
24
|
plans: Plan[];
|
|
12
25
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
isActive: boolean;
|
|
21
|
-
selfServeEnabled: boolean;
|
|
22
|
-
};
|
|
26
|
+
/**
|
|
27
|
+
* `Plan` here is the canonical shared-types `BuilderPlan` projection: a
|
|
28
|
+
* `PlanSpec` extended with server-owned fields (id, productId, environmentId,
|
|
29
|
+
* isActive, createdAt). Adding a field to `planSpecSchema` automatically
|
|
30
|
+
* surfaces it on this type — drift between CLI and server is a compile error.
|
|
31
|
+
*/
|
|
32
|
+
export type Plan = BuilderPlan;
|
|
23
33
|
export type CliConfig = {
|
|
24
34
|
apiUrl: string;
|
|
25
35
|
activeOrg?: string;
|
|
@@ -42,7 +52,22 @@ export type InitProductResponse = {
|
|
|
42
52
|
agentsMdUrl: string | null;
|
|
43
53
|
};
|
|
44
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* Error thrown by the CLI for any non-success path. Carries the canonical
|
|
57
|
+
* `code` from core's `{ error: { code, message, details? } }` envelope so
|
|
58
|
+
* the global error handler can print the code alongside the message and
|
|
59
|
+
* dispatch a remediation hint when one is registered for that code.
|
|
60
|
+
*
|
|
61
|
+
* `code`/`details` are optional because the same class also gets thrown for
|
|
62
|
+
* local (non-API) failures — e.g. "no token configured" — where there's no
|
|
63
|
+
* server-side code to surface.
|
|
64
|
+
*/
|
|
45
65
|
export declare class CliError extends Error {
|
|
46
66
|
status?: number | undefined;
|
|
47
|
-
|
|
67
|
+
readonly code?: string;
|
|
68
|
+
readonly details?: Record<string, unknown>;
|
|
69
|
+
constructor(message: string, status?: number | undefined, extra?: {
|
|
70
|
+
code?: string;
|
|
71
|
+
details?: Record<string, unknown>;
|
|
72
|
+
});
|
|
48
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "FartherShore CLI — create and configure API products",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
13
|
+
"node": ">=22"
|
|
14
14
|
},
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"access": "public"
|
|
@@ -21,10 +21,13 @@
|
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"dev": "tsx src/index.ts",
|
|
24
|
-
"build": "
|
|
24
|
+
"build": "rm -rf dist && npm run build:types && npm run build:bundle",
|
|
25
|
+
"build:types": "tsc --emitDeclarationOnly",
|
|
26
|
+
"build:bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/index.js --external:chalk --external:commander --external:yaml --external:zod && chmod +x dist/index.js",
|
|
25
27
|
"prepublishOnly": "npm run build",
|
|
26
28
|
"test": "vitest run",
|
|
27
|
-
"lint": "prettier --check \"src/**/*.ts\"",
|
|
29
|
+
"lint": "prettier --check \"src/**/*.ts\" && eslint .",
|
|
30
|
+
"lint:fix": "eslint . --fix",
|
|
28
31
|
"typecheck": "tsc --noEmit"
|
|
29
32
|
},
|
|
30
33
|
"keywords": [
|
|
@@ -40,13 +43,19 @@
|
|
|
40
43
|
"dependencies": {
|
|
41
44
|
"chalk": "^5.4.1",
|
|
42
45
|
"commander": "^13.1.0",
|
|
43
|
-
"yaml": "^2.8.3"
|
|
46
|
+
"yaml": "^2.8.3",
|
|
47
|
+
"zod": "^4.3.6"
|
|
44
48
|
},
|
|
45
49
|
"devDependencies": {
|
|
50
|
+
"@farther-shore/shared-types": "^0.28.2",
|
|
46
51
|
"@types/node": "^22.19.17",
|
|
52
|
+
"esbuild": "^0.28.0",
|
|
53
|
+
"eslint": "^9.39.4",
|
|
54
|
+
"eslint-plugin-sonarjs": "^4.0.3",
|
|
47
55
|
"prettier": "^3.8.1",
|
|
48
56
|
"tsx": "^4.21.0",
|
|
49
57
|
"typescript": "^6.0.2",
|
|
58
|
+
"typescript-eslint": "^8.59.0",
|
|
50
59
|
"vitest": "^3.2.4"
|
|
51
60
|
}
|
|
52
61
|
}
|
package/dist/auth.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
// Token resolution: env var → credentials file → error
|
|
2
|
-
import { loadCredentials } from "./config.js";
|
|
3
|
-
import { CliError } from "./types.js";
|
|
4
|
-
export function resolveToken(overrideToken) {
|
|
5
|
-
// 1. Explicit override (--token flag)
|
|
6
|
-
if (overrideToken)
|
|
7
|
-
return overrideToken;
|
|
8
|
-
// 2. Environment variable
|
|
9
|
-
const envToken = process.env.FARTHERSHORE_TOKEN;
|
|
10
|
-
if (envToken)
|
|
11
|
-
return envToken;
|
|
12
|
-
// 3. Stored credentials (from `farthershore login`)
|
|
13
|
-
const creds = loadCredentials();
|
|
14
|
-
if (creds?.token)
|
|
15
|
-
return creds.token;
|
|
16
|
-
throw new CliError("Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN environment variable.");
|
|
17
|
-
}
|
package/dist/build-info.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// Build-time constants baked into the CLI binary at publish time.
|
|
2
|
-
//
|
|
3
|
-
// To publish a staging variant:
|
|
4
|
-
// 1. Change BUILD_API_URL below to the staging URL
|
|
5
|
-
// 2. Change the npm package name/tag in package.json
|
|
6
|
-
// 3. Run `npm publish`
|
|
7
|
-
//
|
|
8
|
-
// The CLI uses this as the default API URL — users don't need to set
|
|
9
|
-
// FARTHERSHORE_API_URL or pass --api-url for their target environment.
|
|
10
|
-
export const BUILD_API_URL = "https://core.farthershore.com";
|
package/dist/client.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// Typed API client for the FartherShore platform
|
|
2
|
-
import { CliError } from "./types.js";
|
|
3
|
-
export function createClient(opts) {
|
|
4
|
-
async function request(method, path, body) {
|
|
5
|
-
const res = await fetch(`${opts.apiUrl}${path}`, {
|
|
6
|
-
method,
|
|
7
|
-
headers: {
|
|
8
|
-
Authorization: `Bearer ${opts.token}`,
|
|
9
|
-
"Content-Type": "application/json",
|
|
10
|
-
},
|
|
11
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
12
|
-
});
|
|
13
|
-
if (!res.ok) {
|
|
14
|
-
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
15
|
-
throw new CliError(err.error ?? `API error: ${res.status}`, res.status);
|
|
16
|
-
}
|
|
17
|
-
if (res.status === 204)
|
|
18
|
-
return undefined;
|
|
19
|
-
return res.json();
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
// --- Auth ---
|
|
23
|
-
bootstrap: () => request("POST", "/builder/context/bootstrap"),
|
|
24
|
-
// --- Products ---
|
|
25
|
-
listProducts: () => request("GET", "/products"),
|
|
26
|
-
initProduct: (data) => request("POST", "/products/init", data),
|
|
27
|
-
// --- Compile ---
|
|
28
|
-
compileProduct: (productId) => request("POST", `/products/${productId}/compile`),
|
|
29
|
-
// --- Management (maker token) ---
|
|
30
|
-
// Compile the product associated with the token — no product ID needed.
|
|
31
|
-
managementCompileSelf: () => request("POST", "/management/compile"),
|
|
32
|
-
managementCompile: (productId) => request("POST", `/management/products/${productId}/compile`),
|
|
33
|
-
managementListProducts: () => request("GET", "/management/products"),
|
|
34
|
-
isMakerToken: () => opts.token.startsWith("mk_"),
|
|
35
|
-
};
|
|
36
|
-
}
|
package/dist/commands/apply.js
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import YAML from "yaml";
|
|
4
|
-
import * as output from "../output.js";
|
|
5
|
-
const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
6
|
-
/**
|
|
7
|
-
* Read the product name/slug from product.yaml in the current directory.
|
|
8
|
-
*/
|
|
9
|
-
function readSlugFromProductYaml() {
|
|
10
|
-
const yamlPath = resolve("product.yaml");
|
|
11
|
-
if (!existsSync(yamlPath))
|
|
12
|
-
return null;
|
|
13
|
-
try {
|
|
14
|
-
const spec = YAML.parse(readFileSync(yamlPath, "utf-8"));
|
|
15
|
-
const product = spec.product;
|
|
16
|
-
return product?.name ?? null;
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Resolve a product identifier to an API product ID.
|
|
24
|
-
* If no arg, reads product.name from product.yaml in the current directory.
|
|
25
|
-
* If arg looks like a UUID, uses it directly. Otherwise treats it as a slug.
|
|
26
|
-
*/
|
|
27
|
-
async function resolveProductId(client, arg) {
|
|
28
|
-
const slug = arg ?? readSlugFromProductYaml();
|
|
29
|
-
if (!slug)
|
|
30
|
-
return null;
|
|
31
|
-
// UUID — use directly
|
|
32
|
-
if (slug.length === 36 &&
|
|
33
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(slug)) {
|
|
34
|
-
return slug;
|
|
35
|
-
}
|
|
36
|
-
// Slug — resolve via product list (use management API for maker tokens)
|
|
37
|
-
try {
|
|
38
|
-
const products = client.isMakerToken()
|
|
39
|
-
? await client.managementListProducts()
|
|
40
|
-
: await client.listProducts();
|
|
41
|
-
const match = products.find((p) => p.name === slug || p.name.toLowerCase() === slug.toLowerCase());
|
|
42
|
-
return match?.id ?? null;
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
// Don't swallow network errors silently
|
|
46
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
47
|
-
process.stderr.write(`Warning: Failed to resolve product slug: ${msg}\n`);
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function handleResult(result) {
|
|
52
|
-
// GitHub Actions annotations
|
|
53
|
-
if (CI) {
|
|
54
|
-
for (const err of result.errors ?? []) {
|
|
55
|
-
console.log(`::error file=product.yaml::${err.code ? `[${err.code}] ` : ""}${err.message}`);
|
|
56
|
-
}
|
|
57
|
-
for (const w of result.warnings ?? []) {
|
|
58
|
-
console.log(`::warning file=product.yaml::${w.code ? `[${w.code}] ` : ""}${w.message}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
if (result.success) {
|
|
62
|
-
output.success("Compilation passed");
|
|
63
|
-
if (result.warnings?.length) {
|
|
64
|
-
console.log();
|
|
65
|
-
for (const w of result.warnings) {
|
|
66
|
-
output.warn(`${w.code ? `[${w.code}] ` : ""}${w.message}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
output.error("Compilation failed\n");
|
|
72
|
-
for (const err of result.errors ?? []) {
|
|
73
|
-
console.log(` • ${err.code ? `[${err.code}] ` : ""}${err.message}`);
|
|
74
|
-
}
|
|
75
|
-
process.exitCode = 1;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
export function registerApplyCommand(program, getClient) {
|
|
79
|
-
program
|
|
80
|
-
.command("apply [product]")
|
|
81
|
-
.description("Run the server-side compiler to check if the current config is valid. " +
|
|
82
|
-
"Pass a product slug, or run inside a product repo to auto-detect from product.yaml.")
|
|
83
|
-
.action(async (productArg) => {
|
|
84
|
-
const client = getClient();
|
|
85
|
-
// Fast path for CI: product-scoped maker token with no argument.
|
|
86
|
-
// Server auto-resolves product from the token — no product.yaml,
|
|
87
|
-
// no slug lookup, single API call.
|
|
88
|
-
if (client.isMakerToken() && !productArg) {
|
|
89
|
-
try {
|
|
90
|
-
const result = await client.managementCompileSelf();
|
|
91
|
-
handleResult(result);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
const msg = err instanceof Error ? err.message : "Compilation check failed";
|
|
96
|
-
if (CI)
|
|
97
|
-
console.log(`::error::${msg}`);
|
|
98
|
-
output.error(msg);
|
|
99
|
-
process.exitCode = 1;
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const productId = await resolveProductId(client, productArg);
|
|
104
|
-
if (!productId) {
|
|
105
|
-
const hint = productArg
|
|
106
|
-
? `Product "${productArg}" not found. Check the name and try again.`
|
|
107
|
-
: "No product specified and no product.yaml found.\n" +
|
|
108
|
-
" Run from inside a product repo, or pass the slug: farthershore apply my-api";
|
|
109
|
-
output.error(hint);
|
|
110
|
-
process.exitCode = 1;
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
const result = client.isMakerToken()
|
|
115
|
-
? await client.managementCompile(productId)
|
|
116
|
-
: await client.compileProduct(productId);
|
|
117
|
-
handleResult(result);
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
const msg = err instanceof Error ? err.message : "Compilation check failed";
|
|
121
|
-
if (CI)
|
|
122
|
-
console.log(`::error::${msg}`);
|
|
123
|
-
output.error(msg);
|
|
124
|
-
process.exitCode = 1;
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
package/dist/commands/init.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import * as output from "../output.js";
|
|
2
|
-
export function registerInitCommand(program, getClient) {
|
|
3
|
-
program
|
|
4
|
-
.command("init <name>")
|
|
5
|
-
.description("Create a new product with a GitHub repo for agent-first configuration")
|
|
6
|
-
.option("--base-url <url>", "Backend API base URL")
|
|
7
|
-
.option("--description <desc>", "Product description")
|
|
8
|
-
.option("--display-name <name>", "Display name")
|
|
9
|
-
.option("--billing <strategy>", "Billing strategy: subscription|usage_based|hybrid", "subscription")
|
|
10
|
-
.action(async (name, opts) => {
|
|
11
|
-
const client = getClient();
|
|
12
|
-
const result = await client.initProduct({
|
|
13
|
-
name,
|
|
14
|
-
baseUrl: opts.baseUrl,
|
|
15
|
-
description: opts.description,
|
|
16
|
-
displayName: opts.displayName,
|
|
17
|
-
billingStrategy: opts.billing,
|
|
18
|
-
});
|
|
19
|
-
const format = output.outputFormat(program.opts().format);
|
|
20
|
-
if (format === "json") {
|
|
21
|
-
console.log(output.json(result));
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
output.success(`Created product "${result.product.name}" (DRAFT)`);
|
|
25
|
-
console.log();
|
|
26
|
-
if (result.repo) {
|
|
27
|
-
console.log(` Repository: ${result.repo.htmlUrl}`);
|
|
28
|
-
console.log(` Clone: git clone ${result.repo.cloneUrl}`);
|
|
29
|
-
console.log();
|
|
30
|
-
}
|
|
31
|
-
console.log(" Next steps:");
|
|
32
|
-
console.log(" 1. Clone the repository");
|
|
33
|
-
console.log(" 2. Read AGENTS.md for the full configuration reference");
|
|
34
|
-
console.log(" 3. Edit product.yaml — add your base URL, plans, and meters");
|
|
35
|
-
console.log(" 4. Push to main — a valid config goes live automatically");
|
|
36
|
-
console.log();
|
|
37
|
-
if (result.agent.agentsMdUrl) {
|
|
38
|
-
console.log(` Docs: ${result.agent.agentsMdUrl}`);
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
}
|
package/dist/commands/login.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import * as readline from "node:readline/promises";
|
|
2
|
-
import { createClient } from "../client.js";
|
|
3
|
-
import { loadConfig, saveConfig, saveCredentials, clearCredentials, } from "../config.js";
|
|
4
|
-
import { resolveToken } from "../auth.js";
|
|
5
|
-
import * as output from "../output.js";
|
|
6
|
-
export function registerAuthCommands(program) {
|
|
7
|
-
program
|
|
8
|
-
.command("set-key [token]")
|
|
9
|
-
.description("Set your API token (interactive or pass as argument)")
|
|
10
|
-
.action(async (tokenArg) => {
|
|
11
|
-
let token = tokenArg?.trim();
|
|
12
|
-
if (!token) {
|
|
13
|
-
// Interactive mode
|
|
14
|
-
if (!process.stdin.isTTY) {
|
|
15
|
-
output.error("No token provided. Pass it as an argument: farthershore set-key <token>");
|
|
16
|
-
process.exitCode = 1;
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
console.log("Set your FartherShore API token\n");
|
|
20
|
-
console.log(" Create a token at https://farthershore.com/settings/tokens\n");
|
|
21
|
-
const rl = readline.createInterface({
|
|
22
|
-
input: process.stdin,
|
|
23
|
-
output: process.stdout,
|
|
24
|
-
});
|
|
25
|
-
token = (await rl.question("Token: ")).trim();
|
|
26
|
-
rl.close();
|
|
27
|
-
}
|
|
28
|
-
if (!token) {
|
|
29
|
-
output.error("No token provided.");
|
|
30
|
-
process.exitCode = 1;
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const config = loadConfig();
|
|
34
|
-
try {
|
|
35
|
-
const client = createClient({ apiUrl: config.apiUrl, token });
|
|
36
|
-
const ctx = await client.bootstrap();
|
|
37
|
-
saveCredentials({
|
|
38
|
-
token,
|
|
39
|
-
orgId: ctx.activeOrganization.id,
|
|
40
|
-
userId: ctx.user.id,
|
|
41
|
-
});
|
|
42
|
-
output.success("Authenticated");
|
|
43
|
-
console.log(` Organization: ${ctx.activeOrganization.name ?? ctx.activeOrganization.id}`);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
output.error("Invalid token. Check it and try again.");
|
|
47
|
-
process.exitCode = 1;
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
program
|
|
51
|
-
.command("logout")
|
|
52
|
-
.description("Clear stored credentials")
|
|
53
|
-
.action(() => {
|
|
54
|
-
clearCredentials();
|
|
55
|
-
output.success("Credentials cleared.");
|
|
56
|
-
});
|
|
57
|
-
program
|
|
58
|
-
.command("whoami")
|
|
59
|
-
.description("Show current authentication context")
|
|
60
|
-
.action(async () => {
|
|
61
|
-
const config = loadConfig();
|
|
62
|
-
try {
|
|
63
|
-
const token = resolveToken();
|
|
64
|
-
const client = createClient({ apiUrl: config.apiUrl, token });
|
|
65
|
-
const ctx = await client.bootstrap();
|
|
66
|
-
output.heading("Current Context");
|
|
67
|
-
console.log(` Organization: ${ctx.activeOrganization.name ?? ctx.activeOrganization.id}`);
|
|
68
|
-
console.log(` API URL: ${config.apiUrl}`);
|
|
69
|
-
if (process.env.FARTHERSHORE_TOKEN) {
|
|
70
|
-
output.info(" Auth: FARTHERSHORE_TOKEN env var");
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
output.info(" Auth: ~/.farthershore/credentials.json");
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
output.error("Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN.");
|
|
78
|
-
process.exitCode = 1;
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
program
|
|
82
|
-
.command("set-url <url>")
|
|
83
|
-
.description("Override the API base URL (for staging/testing)")
|
|
84
|
-
.action((url) => {
|
|
85
|
-
saveConfig({ apiUrl: url });
|
|
86
|
-
output.success(`API URL set to ${url}`);
|
|
87
|
-
});
|
|
88
|
-
program
|
|
89
|
-
.command("reset-url")
|
|
90
|
-
.description("Reset the API URL to production default")
|
|
91
|
-
.action(() => {
|
|
92
|
-
saveConfig({ apiUrl: "https://core.farthershore.com" });
|
|
93
|
-
output.success("API URL reset to https://core.farthershore.com");
|
|
94
|
-
});
|
|
95
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { execSync } from "node:child_process";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import YAML from "yaml";
|
|
5
|
-
import * as output from "../output.js";
|
|
6
|
-
const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
7
|
-
/**
|
|
8
|
-
* Read product.yaml from the main branch using git, without checking it out.
|
|
9
|
-
* Returns null if not in a git repo or main doesn't have the file.
|
|
10
|
-
*/
|
|
11
|
-
function readMainBranchProductName() {
|
|
12
|
-
for (const branch of ["main", "master"]) {
|
|
13
|
-
try {
|
|
14
|
-
const yaml = execSync(`git show ${branch}:product.yaml 2>/dev/null`, {
|
|
15
|
-
encoding: "utf-8",
|
|
16
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
17
|
-
});
|
|
18
|
-
const spec = YAML.parse(yaml);
|
|
19
|
-
const product = spec.product;
|
|
20
|
-
return product?.name ?? null;
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
// Inline a lightweight validation since we can't import the core schema directly.
|
|
29
|
-
// Checks structure, required fields, and strategy/model alignment.
|
|
30
|
-
function validateProductYaml(spec) {
|
|
31
|
-
const errors = [];
|
|
32
|
-
const warnings = [];
|
|
33
|
-
// Check top-level sections
|
|
34
|
-
const product = spec.product;
|
|
35
|
-
if (!product)
|
|
36
|
-
errors.push("Missing 'product' section");
|
|
37
|
-
else {
|
|
38
|
-
if (!product.name)
|
|
39
|
-
errors.push("product.name is required");
|
|
40
|
-
if (!product.baseUrl || product.baseUrl === "https://api.example.com")
|
|
41
|
-
errors.push("product.baseUrl must be set to your real API endpoint");
|
|
42
|
-
if (product.description && product.description.length > 2000)
|
|
43
|
-
errors.push("product.description exceeds 2000 character limit");
|
|
44
|
-
if (product.displayName && product.displayName.length > 200)
|
|
45
|
-
errors.push("product.displayName exceeds 200 character limit");
|
|
46
|
-
}
|
|
47
|
-
const billing = spec.billing;
|
|
48
|
-
if (!billing)
|
|
49
|
-
errors.push("Missing 'billing' section");
|
|
50
|
-
else if (!billing.strategy)
|
|
51
|
-
errors.push("billing.strategy is required");
|
|
52
|
-
const metering = spec.metering;
|
|
53
|
-
if (!metering)
|
|
54
|
-
errors.push("Missing 'metering' section");
|
|
55
|
-
else {
|
|
56
|
-
const rawMeters = metering.meters ?? [];
|
|
57
|
-
if (!Array.isArray(rawMeters)) {
|
|
58
|
-
errors.push("'metering.meters' must be an array");
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
const meters = rawMeters;
|
|
62
|
-
if (meters.length === 0)
|
|
63
|
-
errors.push("At least one meter is required in metering.meters");
|
|
64
|
-
if (meters.length > 10)
|
|
65
|
-
errors.push("Maximum 10 meters allowed");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const rawPlans = spec.plans ?? [];
|
|
69
|
-
const plans = Array.isArray(rawPlans)
|
|
70
|
-
? rawPlans
|
|
71
|
-
: [];
|
|
72
|
-
if (spec.plans != null && !Array.isArray(spec.plans)) {
|
|
73
|
-
errors.push("'plans' must be an array");
|
|
74
|
-
}
|
|
75
|
-
if (plans.length === 0)
|
|
76
|
-
errors.push("At least one plan is required to go live");
|
|
77
|
-
if (plans.length > 4)
|
|
78
|
-
errors.push("Maximum 4 plans allowed");
|
|
79
|
-
// Validate strategy/model alignment
|
|
80
|
-
const strategy = billing?.strategy;
|
|
81
|
-
const modelMap = {
|
|
82
|
-
subscription: "flat_rate",
|
|
83
|
-
usage_based: "pay_as_you_go",
|
|
84
|
-
hybrid: "included_usage",
|
|
85
|
-
};
|
|
86
|
-
for (const plan of plans) {
|
|
87
|
-
const pricing = plan.pricing;
|
|
88
|
-
if (!pricing?.model) {
|
|
89
|
-
errors.push(`Plan "${plan.key ?? plan.name ?? "?"}": pricing.model is required`);
|
|
90
|
-
}
|
|
91
|
-
else if (strategy &&
|
|
92
|
-
modelMap[strategy] &&
|
|
93
|
-
pricing.model !== modelMap[strategy]) {
|
|
94
|
-
errors.push(`Plan "${plan.key ?? "?"}": pricing.model "${pricing.model}" doesn't match billing.strategy "${strategy}" (expected "${modelMap[strategy]}")`);
|
|
95
|
-
}
|
|
96
|
-
if (!plan.key)
|
|
97
|
-
errors.push(`Plan "${plan.name ?? "?"}": key is required`);
|
|
98
|
-
if (!plan.name)
|
|
99
|
-
errors.push(`Plan "${plan.key ?? "?"}": name is required`);
|
|
100
|
-
const limits = (plan.limits ?? []);
|
|
101
|
-
if (limits.length === 0) {
|
|
102
|
-
errors.push(`Plan "${plan.key ?? "?"}": at least one limit is required`);
|
|
103
|
-
}
|
|
104
|
-
if (limits.length > 20) {
|
|
105
|
-
errors.push(`Plan "${plan.key ?? "?"}": maximum 20 limits per plan`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
// Check for duplicate plan keys
|
|
109
|
-
const keys = plans.map((p) => p.key).filter(Boolean);
|
|
110
|
-
const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
111
|
-
if (dupes.length > 0)
|
|
112
|
-
errors.push(`Duplicate plan keys: ${dupes.join(", ")}`);
|
|
113
|
-
// Warnings (non-blocking)
|
|
114
|
-
if (plans.length > 0 &&
|
|
115
|
-
!plans.some((p) => {
|
|
116
|
-
const pricing = p.pricing;
|
|
117
|
-
return pricing?.monthlyPriceCents === 0;
|
|
118
|
-
})) {
|
|
119
|
-
warnings.push("No free plan — consider adding one for developer adoption");
|
|
120
|
-
}
|
|
121
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
122
|
-
}
|
|
123
|
-
export function registerValidateCommand(program) {
|
|
124
|
-
program
|
|
125
|
-
.command("validate [file]")
|
|
126
|
-
.description("Validate a local product.yaml file")
|
|
127
|
-
.action(async (file) => {
|
|
128
|
-
const filePath = resolve(file ?? "product.yaml");
|
|
129
|
-
if (!existsSync(filePath)) {
|
|
130
|
-
if (CI)
|
|
131
|
-
console.log(`::error file=product.yaml::File not found: ${filePath}`);
|
|
132
|
-
output.error(`File not found: ${filePath}`);
|
|
133
|
-
process.exitCode = 1;
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
let spec;
|
|
137
|
-
try {
|
|
138
|
-
const content = readFileSync(filePath, "utf-8");
|
|
139
|
-
spec = YAML.parse(content);
|
|
140
|
-
}
|
|
141
|
-
catch (err) {
|
|
142
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
143
|
-
if (CI)
|
|
144
|
-
console.log(`::error file=product.yaml::YAML parse error: ${msg}`);
|
|
145
|
-
output.error(`Failed to parse YAML: ${msg}`);
|
|
146
|
-
process.exitCode = 1;
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const result = validateProductYaml(spec);
|
|
150
|
-
// Check product.name consistency against main branch
|
|
151
|
-
const currentName = spec.product
|
|
152
|
-
?.name;
|
|
153
|
-
if (currentName) {
|
|
154
|
-
const mainName = readMainBranchProductName();
|
|
155
|
-
if (mainName && mainName !== currentName) {
|
|
156
|
-
result.errors.push(`product.name "${currentName}" differs from main branch "${mainName}" — product name must not change`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
// GitHub Actions annotations
|
|
160
|
-
if (CI) {
|
|
161
|
-
for (const err of result.errors) {
|
|
162
|
-
console.log(`::error file=product.yaml::${err}`);
|
|
163
|
-
}
|
|
164
|
-
for (const w of result.warnings) {
|
|
165
|
-
console.log(`::warning file=product.yaml::${w}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (result.errors.length === 0) {
|
|
169
|
-
output.success("product.yaml is valid");
|
|
170
|
-
if (result.warnings.length > 0) {
|
|
171
|
-
for (const w of result.warnings) {
|
|
172
|
-
output.warn(w);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
output.error(`product.yaml has ${result.errors.length} error(s):\n`);
|
|
178
|
-
for (const err of result.errors) {
|
|
179
|
-
console.log(` • ${err}`);
|
|
180
|
-
}
|
|
181
|
-
if (result.warnings.length > 0) {
|
|
182
|
-
console.log();
|
|
183
|
-
for (const w of result.warnings) {
|
|
184
|
-
output.warn(w);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
process.exitCode = 1;
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|