@farthershore/cli 0.3.5 → 0.3.6
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/client.js +22 -5
- package/dist/commands/apply.js +116 -12
- package/dist/commands/init.js +5 -3
- package/dist/commands/login.js +27 -2
- package/dist/commands/validate.d.ts +26 -0
- package/dist/commands/validate.js +148 -132
- package/dist/config.d.ts +0 -3
- package/dist/config.js +4 -22
- package/dist/index.js +21 -2
- package/dist/output.d.ts +1 -4
- package/dist/output.js +1 -20
- package/dist/remediation.d.ts +6 -0
- package/dist/remediation.js +53 -0
- package/dist/types.d.ts +36 -11
- package/dist/types.js +15 -1
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -45,8 +45,7 @@ Options:
|
|
|
45
45
|
|
|
46
46
|
- `--base-url <url>` — Backend API base URL
|
|
47
47
|
- `--description <text>` — Product description
|
|
48
|
-
- `--display-name <name>` — Display name for the portal
|
|
49
|
-
- `--billing <strategy>` — `subscription` (default), `usage_based`, or `hybrid`
|
|
48
|
+
- `--display-name <name>` — Display name for the product portal
|
|
50
49
|
|
|
51
50
|
The created repo contains:
|
|
52
51
|
|
|
@@ -57,7 +56,7 @@ The created repo contains:
|
|
|
57
56
|
|
|
58
57
|
### `farthershore validate [file]`
|
|
59
58
|
|
|
60
|
-
Validate a local `product.yaml` file without making any API calls. Checks structure, required fields, and
|
|
59
|
+
Validate a local `product.yaml` file without making any API calls. Checks structure, required fields, and launch constraints for env-scoped plans and meters.
|
|
61
60
|
|
|
62
61
|
```bash
|
|
63
62
|
# Validate product.yaml in current directory
|
|
@@ -69,7 +68,7 @@ farthershore validate path/to/product.yaml
|
|
|
69
68
|
|
|
70
69
|
### `farthershore apply [product]`
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
Validate the current repo's `product.yaml` first when applying that repo's product, then run the server-side compiler against the pushed branch state for the product. Unpushed local edits are not part of the remote compile. Returns compilation errors and warnings. Exits with code 1 on failure — suitable for CI checks before merging.
|
|
73
72
|
|
|
74
73
|
```bash
|
|
75
74
|
# Inside a product repo (auto-detects from product.yaml)
|
|
@@ -137,3 +136,30 @@ git add -A && git commit -m "Configure product" && git push
|
|
|
137
136
|
| ---------------------- | ------------------------------------------------------- |
|
|
138
137
|
| `FARTHERSHORE_TOKEN` | API token (overrides stored credentials) |
|
|
139
138
|
| `FARTHERSHORE_API_URL` | API base URL (default: `https://core.farthershore.com`) |
|
|
139
|
+
|
|
140
|
+
## Errors and exit codes
|
|
141
|
+
|
|
142
|
+
The CLI exits `0` on success and `1` on any failure (including
|
|
143
|
+
`commander`-level argument errors). When the API returns a 4XX/5XX, the
|
|
144
|
+
CLI prints the canonical error code in brackets and a one-line
|
|
145
|
+
remediation hint when one is registered:
|
|
146
|
+
|
|
147
|
+
```text
|
|
148
|
+
Error [STRIPE_NOT_CONFIGURED]: connect Stripe before running billing operations
|
|
149
|
+
Hint: Stripe isn't connected on this product. Connect it in the dashboard before running billing operations.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The bracketed code is stable across releases — quote it in support
|
|
153
|
+
threads.
|
|
154
|
+
|
|
155
|
+
`farthershore whoami --format json` emits a stable JSON shape suitable
|
|
156
|
+
for CI scripts. On the not-authenticated path it emits
|
|
157
|
+
`{ "authenticated": false }` with exit code `1`.
|
|
158
|
+
|
|
159
|
+
## CI / agent usage
|
|
160
|
+
|
|
161
|
+
The CLI is non-interactive whenever a token is available via the
|
|
162
|
+
`--token` flag or `FARTHERSHORE_TOKEN` env var — `set-key` only prompts
|
|
163
|
+
when stdin is a TTY and no token argument was passed, so it's safe to
|
|
164
|
+
script. `validate` and `apply` emit GitHub Actions-formatted annotations
|
|
165
|
+
when run under `CI` / `GITHUB_ACTIONS`.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Product, InitProductResponse } from "./types.js";
|
|
1
|
+
import type { Product, InitProductResponse, CompileDiagnostic } from "./types.js";
|
|
2
2
|
export declare function createClient(opts: {
|
|
3
3
|
apiUrl: string;
|
|
4
4
|
token: string;
|
|
@@ -28,41 +28,29 @@ export declare function createClient(opts: {
|
|
|
28
28
|
baseUrl?: string;
|
|
29
29
|
description?: string;
|
|
30
30
|
displayName?: string;
|
|
31
|
-
billingStrategy?: string;
|
|
32
31
|
}) => Promise<InitProductResponse>;
|
|
33
|
-
compileProduct: (productId: string
|
|
32
|
+
compileProduct: (productId: string, opts?: {
|
|
33
|
+
branch?: string;
|
|
34
|
+
}) => Promise<{
|
|
34
35
|
success: boolean;
|
|
35
|
-
errors?:
|
|
36
|
-
|
|
37
|
-
message: string;
|
|
38
|
-
}>;
|
|
39
|
-
warnings?: Array<{
|
|
40
|
-
code?: string;
|
|
41
|
-
message: string;
|
|
42
|
-
}>;
|
|
36
|
+
errors?: CompileDiagnostic[];
|
|
37
|
+
warnings?: CompileDiagnostic[];
|
|
43
38
|
}>;
|
|
44
|
-
managementCompileSelf: (
|
|
39
|
+
managementCompileSelf: (opts?: {
|
|
40
|
+
branch?: string;
|
|
41
|
+
}) => Promise<{
|
|
45
42
|
success: boolean;
|
|
46
43
|
productId: string;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}>;
|
|
51
|
-
warnings?: Array<{
|
|
52
|
-
code?: string;
|
|
53
|
-
message: string;
|
|
54
|
-
}>;
|
|
44
|
+
branch?: string | null;
|
|
45
|
+
errors?: CompileDiagnostic[];
|
|
46
|
+
warnings?: CompileDiagnostic[];
|
|
55
47
|
}>;
|
|
56
|
-
managementCompile: (productId: string
|
|
48
|
+
managementCompile: (productId: string, opts?: {
|
|
49
|
+
branch?: string;
|
|
50
|
+
}) => Promise<{
|
|
57
51
|
success: boolean;
|
|
58
|
-
errors?:
|
|
59
|
-
|
|
60
|
-
message: string;
|
|
61
|
-
}>;
|
|
62
|
-
warnings?: Array<{
|
|
63
|
-
code?: string;
|
|
64
|
-
message: string;
|
|
65
|
-
}>;
|
|
52
|
+
errors?: CompileDiagnostic[];
|
|
53
|
+
warnings?: CompileDiagnostic[];
|
|
66
54
|
}>;
|
|
67
55
|
managementListProducts: () => Promise<Product[]>;
|
|
68
56
|
isMakerToken: () => boolean;
|
package/dist/client.js
CHANGED
|
@@ -11,8 +11,24 @@ export function createClient(opts) {
|
|
|
11
11
|
body: body ? JSON.stringify(body) : undefined,
|
|
12
12
|
});
|
|
13
13
|
if (!res.ok) {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Try the canonical Wave-4 envelope first; fall back to a string
|
|
15
|
+
// body or the bare statusText so the error path is robust to old
|
|
16
|
+
// server builds and HTML/text responses (e.g. Cloudflare 502s).
|
|
17
|
+
const parsed = (await res.json().catch(() => null));
|
|
18
|
+
const errEnvelope = parsed && typeof parsed === "object" && parsed.error;
|
|
19
|
+
let message = res.statusText;
|
|
20
|
+
let code;
|
|
21
|
+
let details;
|
|
22
|
+
if (typeof errEnvelope === "string") {
|
|
23
|
+
// Legacy { error: "..." } shape.
|
|
24
|
+
message = errEnvelope;
|
|
25
|
+
}
|
|
26
|
+
else if (errEnvelope && typeof errEnvelope === "object") {
|
|
27
|
+
message = errEnvelope.message ?? message;
|
|
28
|
+
code = errEnvelope.code;
|
|
29
|
+
details = errEnvelope.details;
|
|
30
|
+
}
|
|
31
|
+
throw new CliError(message, res.status, { code, details });
|
|
16
32
|
}
|
|
17
33
|
if (res.status === 204)
|
|
18
34
|
return undefined;
|
|
@@ -25,11 +41,12 @@ export function createClient(opts) {
|
|
|
25
41
|
listProducts: () => request("GET", "/products"),
|
|
26
42
|
initProduct: (data) => request("POST", "/products/init", data),
|
|
27
43
|
// --- Compile ---
|
|
28
|
-
compileProduct: (productId) => request("POST", `/products/${productId}/compile
|
|
44
|
+
compileProduct: (productId, opts) => request("POST", `/products/${productId}/compile`, opts?.branch ? { branch: opts.branch } : undefined),
|
|
29
45
|
// --- Management (maker token) ---
|
|
30
46
|
// Compile the product associated with the token — no product ID needed.
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
// Pass `branch` to scope compilation to an env branch's plans.
|
|
48
|
+
managementCompileSelf: (opts) => request("POST", "/management/compile", opts?.branch ? { branch: opts.branch } : undefined),
|
|
49
|
+
managementCompile: (productId, opts) => request("POST", `/management/products/${productId}/compile`, opts?.branch ? { branch: opts.branch } : undefined),
|
|
33
50
|
managementListProducts: () => request("GET", "/management/products"),
|
|
34
51
|
isMakerToken: () => opts.token.startsWith("mk_"),
|
|
35
52
|
};
|
package/dist/commands/apply.js
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
1
2
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import YAML from "yaml";
|
|
4
5
|
import * as output from "../output.js";
|
|
6
|
+
import { loadProductYaml, validateProductYaml } from "./validate.js";
|
|
5
7
|
const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
8
|
+
/**
|
|
9
|
+
* Detect the git branch the compiler should scope to. Order of preference:
|
|
10
|
+
* 1. --branch CLI flag (explicit override)
|
|
11
|
+
* 2. GITHUB_HEAD_REF — pull_request source branch (the branch being proposed)
|
|
12
|
+
* 3. GITHUB_REF_NAME — push event branch
|
|
13
|
+
* 4. `git rev-parse --abbrev-ref HEAD` — local dev
|
|
14
|
+
* Returns undefined when nothing is detectable.
|
|
15
|
+
*/
|
|
16
|
+
function detectBranch(explicit) {
|
|
17
|
+
if (explicit)
|
|
18
|
+
return explicit;
|
|
19
|
+
const ghHead = process.env.GITHUB_HEAD_REF;
|
|
20
|
+
if (ghHead)
|
|
21
|
+
return ghHead;
|
|
22
|
+
const ghRef = process.env.GITHUB_REF_NAME;
|
|
23
|
+
if (ghRef)
|
|
24
|
+
return ghRef;
|
|
25
|
+
try {
|
|
26
|
+
const local = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
27
|
+
encoding: "utf-8",
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
}).trim();
|
|
30
|
+
return local && local !== "HEAD" ? local : undefined;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
6
36
|
/**
|
|
7
37
|
* Read the product name/slug from product.yaml in the current directory.
|
|
8
38
|
*/
|
|
@@ -19,6 +49,62 @@ function readSlugFromProductYaml() {
|
|
|
19
49
|
return null;
|
|
20
50
|
}
|
|
21
51
|
}
|
|
52
|
+
function validateLocalProductYamlBeforeRemoteCompile() {
|
|
53
|
+
const filePath = resolve("product.yaml");
|
|
54
|
+
if (!existsSync(filePath))
|
|
55
|
+
return true;
|
|
56
|
+
const loaded = loadProductYaml(filePath);
|
|
57
|
+
if (!loaded.ok) {
|
|
58
|
+
const message = loaded.reason === "parse"
|
|
59
|
+
? `Local product.yaml failed to parse; remote compile was not started.\n${loaded.message}`
|
|
60
|
+
: `Local product.yaml could not be read; remote compile was not started.\n${loaded.message}`;
|
|
61
|
+
if (CI) {
|
|
62
|
+
console.log(`::error file=product.yaml::${message}`);
|
|
63
|
+
}
|
|
64
|
+
output.error(message);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const result = validateProductYaml(loaded.spec);
|
|
69
|
+
if (!result.valid) {
|
|
70
|
+
if (CI) {
|
|
71
|
+
for (const err of result.errors) {
|
|
72
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
73
|
+
}
|
|
74
|
+
for (const warning of result.warnings) {
|
|
75
|
+
console.log(`::warning file=product.yaml::${warning}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
output.error("Local product.yaml failed validation; remote compile was not started.\n");
|
|
79
|
+
for (const err of result.errors) {
|
|
80
|
+
console.log(` • ${err}`);
|
|
81
|
+
}
|
|
82
|
+
if (result.warnings.length > 0) {
|
|
83
|
+
console.log();
|
|
84
|
+
for (const warning of result.warnings) {
|
|
85
|
+
output.warn(warning);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
output.success("Local product.yaml passed validation");
|
|
92
|
+
for (const warning of result.warnings) {
|
|
93
|
+
output.warn(warning);
|
|
94
|
+
}
|
|
95
|
+
output.info("Remote compile checks the pushed branch state; unpushed local edits are not included.");
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
function shouldValidateLocalProductYaml(productArg) {
|
|
99
|
+
const filePath = resolve("product.yaml");
|
|
100
|
+
if (!existsSync(filePath))
|
|
101
|
+
return false;
|
|
102
|
+
if (!productArg)
|
|
103
|
+
return true;
|
|
104
|
+
const localSlug = readSlugFromProductYaml();
|
|
105
|
+
return (typeof localSlug === "string" &&
|
|
106
|
+
localSlug.toLowerCase() === productArg.toLowerCase());
|
|
107
|
+
}
|
|
22
108
|
/**
|
|
23
109
|
* Resolve a product identifier to an API product ID.
|
|
24
110
|
* If no arg, reads product.name from product.yaml in the current directory.
|
|
@@ -48,29 +134,37 @@ async function resolveProductId(client, arg) {
|
|
|
48
134
|
return null;
|
|
49
135
|
}
|
|
50
136
|
}
|
|
137
|
+
// Render a diagnostic with an optional plan-key prefix so users can see which
|
|
138
|
+
// plan an error belongs to when the compiler scopes it. `compiledPlanId`
|
|
139
|
+
// itself is an internal pointer — `planKey` is the human-readable label.
|
|
140
|
+
function formatDiag(d) {
|
|
141
|
+
const prefix = d.code ? `[${d.code}] ` : "";
|
|
142
|
+
const planLabel = d.planKey ? `(plan: ${d.planKey}) ` : "";
|
|
143
|
+
return `${prefix}${planLabel}${d.message}`;
|
|
144
|
+
}
|
|
51
145
|
function handleResult(result) {
|
|
52
146
|
// GitHub Actions annotations
|
|
53
147
|
if (CI) {
|
|
54
148
|
for (const err of result.errors ?? []) {
|
|
55
|
-
console.log(`::error file=product.yaml::${err
|
|
149
|
+
console.log(`::error file=product.yaml::${formatDiag(err)}`);
|
|
56
150
|
}
|
|
57
151
|
for (const w of result.warnings ?? []) {
|
|
58
|
-
console.log(`::warning file=product.yaml::${w
|
|
152
|
+
console.log(`::warning file=product.yaml::${formatDiag(w)}`);
|
|
59
153
|
}
|
|
60
154
|
}
|
|
61
155
|
if (result.success) {
|
|
62
|
-
output.success("
|
|
156
|
+
output.success("Remote compile passed");
|
|
63
157
|
if (result.warnings?.length) {
|
|
64
158
|
console.log();
|
|
65
159
|
for (const w of result.warnings) {
|
|
66
|
-
output.warn(
|
|
160
|
+
output.warn(formatDiag(w));
|
|
67
161
|
}
|
|
68
162
|
}
|
|
69
163
|
}
|
|
70
164
|
else {
|
|
71
|
-
output.error("
|
|
165
|
+
output.error("Remote compile failed\n");
|
|
72
166
|
for (const err of result.errors ?? []) {
|
|
73
|
-
console.log(` • ${err
|
|
167
|
+
console.log(` • ${formatDiag(err)}`);
|
|
74
168
|
}
|
|
75
169
|
process.exitCode = 1;
|
|
76
170
|
}
|
|
@@ -78,16 +172,26 @@ function handleResult(result) {
|
|
|
78
172
|
export function registerApplyCommand(program, getClient) {
|
|
79
173
|
program
|
|
80
174
|
.command("apply [product]")
|
|
81
|
-
.description("
|
|
82
|
-
"Pass a product slug, or run inside a product repo to auto-detect from product.yaml."
|
|
83
|
-
|
|
175
|
+
.description("Validate the current repo's product.yaml before remote compile when applying that repo's product, then run the server-side compiler against the pushed branch state for this product. " +
|
|
176
|
+
"Unpushed local edits are not included. Pass a product slug, or run inside a product repo to auto-detect from product.yaml. " +
|
|
177
|
+
"Automatically scopes to the current git branch so env branches compile against their own plans.")
|
|
178
|
+
.option("--branch <branch>", "Override the branch used for env-scoped compilation (default: auto-detected)")
|
|
179
|
+
.action(async (productArg, opts) => {
|
|
84
180
|
const client = getClient();
|
|
181
|
+
const branch = detectBranch(opts.branch);
|
|
182
|
+
if (shouldValidateLocalProductYaml(productArg) &&
|
|
183
|
+
!validateLocalProductYamlBeforeRemoteCompile()) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (branch && CI) {
|
|
187
|
+
console.log(`::notice::Compiling against branch '${branch}'`);
|
|
188
|
+
}
|
|
85
189
|
// Fast path for CI: product-scoped maker token with no argument.
|
|
86
190
|
// Server auto-resolves product from the token — no product.yaml,
|
|
87
191
|
// no slug lookup, single API call.
|
|
88
192
|
if (client.isMakerToken() && !productArg) {
|
|
89
193
|
try {
|
|
90
|
-
const result = await client.managementCompileSelf();
|
|
194
|
+
const result = await client.managementCompileSelf({ branch });
|
|
91
195
|
handleResult(result);
|
|
92
196
|
return;
|
|
93
197
|
}
|
|
@@ -112,8 +216,8 @@ export function registerApplyCommand(program, getClient) {
|
|
|
112
216
|
}
|
|
113
217
|
try {
|
|
114
218
|
const result = client.isMakerToken()
|
|
115
|
-
? await client.managementCompile(productId)
|
|
116
|
-
: await client.compileProduct(productId);
|
|
219
|
+
? await client.managementCompile(productId, { branch })
|
|
220
|
+
: await client.compileProduct(productId, { branch });
|
|
117
221
|
handleResult(result);
|
|
118
222
|
}
|
|
119
223
|
catch (err) {
|
package/dist/commands/init.js
CHANGED
|
@@ -6,7 +6,6 @@ export function registerInitCommand(program, getClient) {
|
|
|
6
6
|
.option("--base-url <url>", "Backend API base URL")
|
|
7
7
|
.option("--description <desc>", "Product description")
|
|
8
8
|
.option("--display-name <name>", "Display name")
|
|
9
|
-
.option("--billing <strategy>", "Billing strategy: subscription|usage_based|hybrid", "subscription")
|
|
10
9
|
.action(async (name, opts) => {
|
|
11
10
|
const client = getClient();
|
|
12
11
|
const result = await client.initProduct({
|
|
@@ -14,9 +13,12 @@ export function registerInitCommand(program, getClient) {
|
|
|
14
13
|
baseUrl: opts.baseUrl,
|
|
15
14
|
description: opts.description,
|
|
16
15
|
displayName: opts.displayName,
|
|
17
|
-
billingStrategy: opts.billing,
|
|
18
16
|
});
|
|
19
|
-
|
|
17
|
+
// `program.opts()` is typed `OptionValues` (string-indexed any).
|
|
18
|
+
// Narrow at the boundary so `outputFormat`'s `string | undefined`
|
|
19
|
+
// parameter type is satisfied without an unsafe-arg lint.
|
|
20
|
+
const formatOpt = program.opts().format;
|
|
21
|
+
const format = output.outputFormat(formatOpt);
|
|
20
22
|
if (format === "json") {
|
|
21
23
|
console.log(output.json(result));
|
|
22
24
|
return;
|
package/dist/commands/login.js
CHANGED
|
@@ -59,14 +59,34 @@ export function registerAuthCommands(program) {
|
|
|
59
59
|
.description("Show current authentication context")
|
|
60
60
|
.action(async () => {
|
|
61
61
|
const config = loadConfig();
|
|
62
|
+
const formatOpt = program.opts().format;
|
|
63
|
+
const format = output.outputFormat(formatOpt);
|
|
62
64
|
try {
|
|
63
65
|
const token = resolveToken();
|
|
64
66
|
const client = createClient({ apiUrl: config.apiUrl, token });
|
|
65
67
|
const ctx = await client.bootstrap();
|
|
68
|
+
const authSource = process.env.FARTHERSHORE_TOKEN
|
|
69
|
+
? "env:FARTHERSHORE_TOKEN"
|
|
70
|
+
: "credentials-file";
|
|
71
|
+
if (format === "json") {
|
|
72
|
+
// Machine-readable shape — stable contract for CI scripts
|
|
73
|
+
// ("am I logged in?"). Don't include the token itself.
|
|
74
|
+
console.log(output.json({
|
|
75
|
+
organization: {
|
|
76
|
+
id: ctx.activeOrganization.id,
|
|
77
|
+
name: ctx.activeOrganization.name ?? null,
|
|
78
|
+
slug: ctx.activeOrganization.slug ?? null,
|
|
79
|
+
},
|
|
80
|
+
user: { id: ctx.user.id },
|
|
81
|
+
apiUrl: config.apiUrl,
|
|
82
|
+
authSource,
|
|
83
|
+
}));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
66
86
|
output.heading("Current Context");
|
|
67
87
|
console.log(` Organization: ${ctx.activeOrganization.name ?? ctx.activeOrganization.id}`);
|
|
68
88
|
console.log(` API URL: ${config.apiUrl}`);
|
|
69
|
-
if (
|
|
89
|
+
if (authSource === "env:FARTHERSHORE_TOKEN") {
|
|
70
90
|
output.info(" Auth: FARTHERSHORE_TOKEN env var");
|
|
71
91
|
}
|
|
72
92
|
else {
|
|
@@ -74,7 +94,12 @@ export function registerAuthCommands(program) {
|
|
|
74
94
|
}
|
|
75
95
|
}
|
|
76
96
|
catch {
|
|
77
|
-
|
|
97
|
+
if (format === "json") {
|
|
98
|
+
console.log(output.json({ authenticated: false }));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
output.error("Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN.");
|
|
102
|
+
}
|
|
78
103
|
process.exitCode = 1;
|
|
79
104
|
}
|
|
80
105
|
});
|
|
@@ -1,2 +1,28 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* Validate a parsed YAML object against the canonical Zod schema.
|
|
4
|
+
*
|
|
5
|
+
* Errors are returned in the same `Plan "key": message` shape the previous
|
|
6
|
+
* hand-rolled checks produced, so existing CI output stays grep-friendly.
|
|
7
|
+
* Warnings cover product-quality nudges (no free plan, missing display name)
|
|
8
|
+
* that don't fail the schema parse but are worth flagging.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateProductYaml(spec: unknown): {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
errors: string[];
|
|
13
|
+
warnings: string[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Read + parse a product.yaml file. Returns either the parsed spec or
|
|
17
|
+
* a structured error result. Extracted from the validate command so
|
|
18
|
+
* the action handler stays under the cognitive-complexity threshold.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadProductYaml(filePath: string): {
|
|
21
|
+
ok: true;
|
|
22
|
+
spec: unknown;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: "missing" | "parse";
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
2
28
|
export declare function registerValidateCommand(program: Command): void;
|
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import YAML from "yaml";
|
|
5
|
+
import { productSpecSchema, } from "@farther-shore/shared-types/plans";
|
|
5
6
|
import * as output from "../output.js";
|
|
6
7
|
const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
7
8
|
/**
|
|
@@ -25,166 +26,181 @@ function readMainBranchProductName() {
|
|
|
25
26
|
}
|
|
26
27
|
return null;
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Validate a parsed YAML object against the canonical Zod schema.
|
|
31
|
+
*
|
|
32
|
+
* Errors are returned in the same `Plan "key": message` shape the previous
|
|
33
|
+
* hand-rolled checks produced, so existing CI output stays grep-friendly.
|
|
34
|
+
* Warnings cover product-quality nudges (no free plan, missing display name)
|
|
35
|
+
* that don't fail the schema parse but are worth flagging.
|
|
36
|
+
*/
|
|
37
|
+
export function validateProductYaml(spec) {
|
|
38
|
+
const result = productSpecSchema.safeParse(spec);
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
return {
|
|
41
|
+
valid: false,
|
|
42
|
+
errors: result.error.issues.map((issue) => formatIssue(issue, spec)),
|
|
43
|
+
warnings: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const warnings = deriveWarnings(result.data);
|
|
47
|
+
// Schema doesn't enforce duplicate plan keys (it would conflict with the
|
|
48
|
+
// discriminated-union design). Detect here as a separate check so the CLI
|
|
49
|
+
// surfaces this footgun before the server rejects the apply.
|
|
50
|
+
const dupeKeys = findDuplicatePlanKeys(result.data);
|
|
51
|
+
if (dupeKeys.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
errors: [`Duplicate plan keys: ${dupeKeys.join(", ")}`],
|
|
55
|
+
warnings,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { valid: true, errors: [], warnings };
|
|
59
|
+
}
|
|
60
|
+
function formatIssue(issue, spec) {
|
|
61
|
+
const path = issue.path;
|
|
62
|
+
// Plans are addressed positionally in path (e.g. ["plans", 0, "key"]). Look
|
|
63
|
+
// up the plan key from the spec so the error message reads
|
|
64
|
+
// `Plan "starter": pricing is required` instead of `plans.0.pricing: ...`.
|
|
65
|
+
if (path.length >= 2 && path[0] === "plans" && typeof path[1] === "number") {
|
|
66
|
+
const plans = spec?.plans ?? [];
|
|
67
|
+
const plan = plans[path[1]];
|
|
68
|
+
const planLabel = plan?.key ?? plan?.name ?? `#${path[1]}`;
|
|
69
|
+
const fieldPath = path.slice(2).join(".");
|
|
70
|
+
if (fieldPath) {
|
|
71
|
+
return `Plan "${planLabel}": ${fieldPath} — ${issue.message}`;
|
|
72
|
+
}
|
|
73
|
+
return `Plan "${planLabel}": ${issue.message}`;
|
|
74
|
+
}
|
|
75
|
+
const dotted = path.length > 0 ? path.join(".") : "(root)";
|
|
76
|
+
return `${dotted}: ${issue.message}`;
|
|
77
|
+
}
|
|
78
|
+
function deriveWarnings(parsed) {
|
|
32
79
|
const warnings = [];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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");
|
|
80
|
+
if (parsed.plans.length === 0) {
|
|
81
|
+
// Schema accepts an empty plan list (default), but a product with no
|
|
82
|
+
// plans can't go live — surface as a warning so the builder sees the
|
|
83
|
+
// requirement before pushing.
|
|
84
|
+
warnings.push("No plans declared — product cannot accept signups yet");
|
|
46
85
|
}
|
|
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
86
|
else {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
const hasFree = parsed.plans.some((plan) => {
|
|
88
|
+
const monthly = "monthlyPriceCents" in plan.pricing
|
|
89
|
+
? plan.pricing.monthlyPriceCents
|
|
90
|
+
: undefined;
|
|
91
|
+
return monthly === 0;
|
|
92
|
+
});
|
|
93
|
+
if (!hasFree) {
|
|
94
|
+
warnings.push("No free plan — consider adding one for developer adoption");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!parsed.product.displayName ||
|
|
98
|
+
parsed.product.displayName === parsed.product.name) {
|
|
99
|
+
warnings.push("product.displayName not set — using product.name on the pricing page");
|
|
100
|
+
}
|
|
101
|
+
return warnings;
|
|
102
|
+
}
|
|
103
|
+
function findDuplicatePlanKeys(parsed) {
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const dupes = new Set();
|
|
106
|
+
for (const plan of parsed.plans) {
|
|
107
|
+
if (seen.has(plan.key)) {
|
|
108
|
+
dupes.add(plan.key);
|
|
59
109
|
}
|
|
60
110
|
else {
|
|
61
|
-
|
|
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");
|
|
111
|
+
seen.add(plan.key);
|
|
66
112
|
}
|
|
67
113
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
return [...dupes];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read + parse a product.yaml file. Returns either the parsed spec or
|
|
118
|
+
* a structured error result. Extracted from the validate command so
|
|
119
|
+
* the action handler stays under the cognitive-complexity threshold.
|
|
120
|
+
*/
|
|
121
|
+
export function loadProductYaml(filePath) {
|
|
122
|
+
if (!existsSync(filePath)) {
|
|
123
|
+
return { ok: false, reason: "missing", message: filePath };
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const content = readFileSync(filePath, "utf-8");
|
|
127
|
+
return { ok: true, spec: YAML.parse(content) };
|
|
74
128
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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]}")`);
|
|
129
|
+
catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: "parse",
|
|
133
|
+
message: err instanceof Error ? err.message : String(err),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Print a ValidationResult to stdout, optionally emitting GitHub
|
|
139
|
+
* Actions annotations. Sets `process.exitCode = 1` on validation
|
|
140
|
+
* failure so the CLI exits non-zero.
|
|
141
|
+
*/
|
|
142
|
+
function reportValidationResult(result) {
|
|
143
|
+
if (CI) {
|
|
144
|
+
for (const err of result.errors) {
|
|
145
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
95
146
|
}
|
|
96
|
-
|
|
97
|
-
|
|
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`);
|
|
147
|
+
for (const w of result.warnings) {
|
|
148
|
+
console.log(`::warning file=product.yaml::${w}`);
|
|
103
149
|
}
|
|
104
|
-
|
|
105
|
-
|
|
150
|
+
}
|
|
151
|
+
if (result.errors.length === 0) {
|
|
152
|
+
output.success("product.yaml is valid");
|
|
153
|
+
for (const w of result.warnings) {
|
|
154
|
+
output.warn(w);
|
|
106
155
|
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
output.error(`product.yaml has ${result.errors.length} error(s):\n`);
|
|
159
|
+
for (const err of result.errors) {
|
|
160
|
+
console.log(` • ${err}`);
|
|
107
161
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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");
|
|
162
|
+
if (result.warnings.length > 0) {
|
|
163
|
+
console.log();
|
|
164
|
+
for (const w of result.warnings) {
|
|
165
|
+
output.warn(w);
|
|
166
|
+
}
|
|
120
167
|
}
|
|
121
|
-
|
|
168
|
+
process.exitCode = 1;
|
|
122
169
|
}
|
|
123
170
|
export function registerValidateCommand(program) {
|
|
124
171
|
program
|
|
125
172
|
.command("validate [file]")
|
|
126
173
|
.description("Validate a local product.yaml file")
|
|
127
|
-
|
|
174
|
+
// Action body is sync; commander only requires a thenable on
|
|
175
|
+
// .action when await is used. Drop `async` keyword.
|
|
176
|
+
.action((file) => {
|
|
128
177
|
const filePath = resolve(file ?? "product.yaml");
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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}`);
|
|
178
|
+
const loaded = loadProductYaml(filePath);
|
|
179
|
+
if (!loaded.ok) {
|
|
180
|
+
if (loaded.reason === "missing") {
|
|
181
|
+
if (CI)
|
|
182
|
+
console.log(`::error file=product.yaml::File not found: ${loaded.message}`);
|
|
183
|
+
output.error(`File not found: ${loaded.message}`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
if (CI)
|
|
187
|
+
console.log(`::error file=product.yaml::YAML parse error: ${loaded.message}`);
|
|
188
|
+
output.error(`Failed to parse YAML: ${loaded.message}`);
|
|
189
|
+
}
|
|
146
190
|
process.exitCode = 1;
|
|
147
191
|
return;
|
|
148
192
|
}
|
|
149
|
-
const result = validateProductYaml(spec);
|
|
193
|
+
const result = validateProductYaml(loaded.spec);
|
|
150
194
|
// Check product.name consistency against main branch
|
|
151
|
-
const currentName = spec
|
|
152
|
-
?.name;
|
|
195
|
+
const currentName = loaded.spec
|
|
196
|
+
?.product?.name;
|
|
153
197
|
if (currentName) {
|
|
154
198
|
const mainName = readMainBranchProductName();
|
|
155
199
|
if (mainName && mainName !== currentName) {
|
|
156
200
|
result.errors.push(`product.name "${currentName}" differs from main branch "${mainName}" — product name must not change`);
|
|
201
|
+
result.valid = false;
|
|
157
202
|
}
|
|
158
203
|
}
|
|
159
|
-
|
|
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
|
-
}
|
|
204
|
+
reportValidationResult(result);
|
|
189
205
|
});
|
|
190
206
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -4,6 +4,3 @@ export declare function saveConfig(config: Partial<CliConfig>): void;
|
|
|
4
4
|
export declare function loadCredentials(): Credentials | null;
|
|
5
5
|
export declare function saveCredentials(creds: Credentials): void;
|
|
6
6
|
export declare function clearCredentials(): void;
|
|
7
|
-
export declare function getConfigPath(productSlug: string): string;
|
|
8
|
-
export declare function writeConfigFile(productSlug: string, content: string): string;
|
|
9
|
-
export declare function readConfigFile(productSlug: string): string | null;
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,6 @@ import { BUILD_API_URL } from "./build-info.js";
|
|
|
6
6
|
const CONFIG_DIR = join(homedir(), ".farthershore");
|
|
7
7
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
8
8
|
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
9
|
-
const CONFIGS_DIR = join(CONFIG_DIR, "configs");
|
|
10
9
|
function ensureDir(dir) {
|
|
11
10
|
if (!existsSync(dir))
|
|
12
11
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -21,10 +20,10 @@ export function loadConfig() {
|
|
|
21
20
|
if (!existsSync(CONFIG_FILE))
|
|
22
21
|
return DEFAULT_CONFIG;
|
|
23
22
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
};
|
|
23
|
+
// JSON.parse → `any`; cast to a partial of the typed shape so the
|
|
24
|
+
// spread doesn't propagate `any` into the returned union.
|
|
25
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
26
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
28
27
|
}
|
|
29
28
|
catch {
|
|
30
29
|
return DEFAULT_CONFIG;
|
|
@@ -57,20 +56,3 @@ export function clearCredentials() {
|
|
|
57
56
|
writeFileSync(CREDENTIALS_FILE, "{}");
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
|
-
// --- Plan configs ---
|
|
61
|
-
export function getConfigPath(productSlug) {
|
|
62
|
-
const dir = join(CONFIGS_DIR, productSlug);
|
|
63
|
-
ensureDir(dir);
|
|
64
|
-
return join(dir, "plans.yaml");
|
|
65
|
-
}
|
|
66
|
-
export function writeConfigFile(productSlug, content) {
|
|
67
|
-
const path = getConfigPath(productSlug);
|
|
68
|
-
writeFileSync(path, content);
|
|
69
|
-
return path;
|
|
70
|
-
}
|
|
71
|
-
export function readConfigFile(productSlug) {
|
|
72
|
-
const path = getConfigPath(productSlug);
|
|
73
|
-
if (!existsSync(path))
|
|
74
|
-
return null;
|
|
75
|
-
return readFileSync(path, "utf-8");
|
|
76
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { registerInitCommand } from "./commands/init.js";
|
|
|
11
11
|
import { registerValidateCommand } from "./commands/validate.js";
|
|
12
12
|
import { registerApplyCommand } from "./commands/apply.js";
|
|
13
13
|
import { CliError } from "./types.js";
|
|
14
|
+
import { getRemediation } from "./remediation.js";
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf-8"));
|
|
16
17
|
const program = new Command();
|
|
@@ -36,13 +37,27 @@ registerValidateCommand(program);
|
|
|
36
37
|
registerApplyCommand(program, getClient);
|
|
37
38
|
// Global error handler
|
|
38
39
|
program.exitOverride();
|
|
40
|
+
/**
|
|
41
|
+
* Pretty-print a CliError to stderr, surfacing the canonical `code` (when
|
|
42
|
+
* present) and a remediation hint registered in `./remediation.ts`. The
|
|
43
|
+
* code is shown in brackets so support engineers can ask "what was the
|
|
44
|
+
* code?" without parsing the message.
|
|
45
|
+
*/
|
|
46
|
+
function reportCliError(err) {
|
|
47
|
+
const codeSuffix = err.code ? ` [${err.code}]` : "";
|
|
48
|
+
process.stderr.write(`Error${codeSuffix}: ${err.message}\n`);
|
|
49
|
+
const hint = getRemediation(err.code);
|
|
50
|
+
if (hint) {
|
|
51
|
+
process.stderr.write(`Hint: ${hint}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
39
54
|
async function main() {
|
|
40
55
|
try {
|
|
41
56
|
await program.parseAsync(process.argv);
|
|
42
57
|
}
|
|
43
58
|
catch (err) {
|
|
44
59
|
if (err instanceof CliError) {
|
|
45
|
-
|
|
60
|
+
reportCliError(err);
|
|
46
61
|
process.exitCode = 1;
|
|
47
62
|
}
|
|
48
63
|
else if (err instanceof Error) {
|
|
@@ -57,4 +72,8 @@ async function main() {
|
|
|
57
72
|
}
|
|
58
73
|
}
|
|
59
74
|
}
|
|
60
|
-
|
|
75
|
+
// Top-level invocation; CLI entry-point. `void` discards the returned
|
|
76
|
+
// promise explicitly — `main` already handles its own errors and sets
|
|
77
|
+
// `process.exitCode`, so a bare floating call is safe but the marker
|
|
78
|
+
// silences the lint and documents intent.
|
|
79
|
+
void main();
|
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";
|
package/dist/output.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
// Output formatting: tables, JSON, colors
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
export function table(headers, rows) {
|
|
4
|
-
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
5
|
-
const header = headers.map((h, i) => h.padEnd(widths[i])).join(" ");
|
|
6
|
-
const separator = widths.map((w) => "─".repeat(w)).join("──");
|
|
7
|
-
const body = rows
|
|
8
|
-
.map((row) => row.map((cell, i) => (cell ?? "").padEnd(widths[i])).join(" "))
|
|
9
|
-
.join("\n");
|
|
10
|
-
return `${chalk.bold(header)}\n${chalk.dim(separator)}\n${body}`;
|
|
11
|
-
}
|
|
12
3
|
export function json(data) {
|
|
13
4
|
return JSON.stringify(data, null, 2);
|
|
14
5
|
}
|
|
@@ -27,21 +18,11 @@ export function info(msg) {
|
|
|
27
18
|
export function heading(msg) {
|
|
28
19
|
console.log(chalk.bold(msg));
|
|
29
20
|
}
|
|
30
|
-
export function formatPrice(cents) {
|
|
31
|
-
return `$${(cents / 100).toFixed(2)}`;
|
|
32
|
-
}
|
|
33
|
-
export function formatDate(iso) {
|
|
34
|
-
return new Date(iso).toLocaleDateString("en-US", {
|
|
35
|
-
year: "numeric",
|
|
36
|
-
month: "short",
|
|
37
|
-
day: "numeric",
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
21
|
export function isTTY() {
|
|
41
22
|
return process.stdout.isTTY === true;
|
|
42
23
|
}
|
|
43
24
|
export function outputFormat(flagFormat) {
|
|
44
|
-
if (flagFormat === "json" || flagFormat === "
|
|
25
|
+
if (flagFormat === "json" || flagFormat === "table")
|
|
45
26
|
return flagFormat;
|
|
46
27
|
return isTTY() ? "table" : "json";
|
|
47
28
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Per-error-code remediation hints.
|
|
3
|
+
//
|
|
4
|
+
// When `core` returns a 4XX/5XX with a known `error.code`, the CLI prints
|
|
5
|
+
// the code alongside the message AND a 1-line "next step" so users (and
|
|
6
|
+
// agents) don't have to grep docs to figure out what to do.
|
|
7
|
+
//
|
|
8
|
+
// Codes are kept as raw strings — the CLI doesn't depend on the SDK, and
|
|
9
|
+
// pulling in `@farther-shore/sdk` just for a string union would be more
|
|
10
|
+
// coupling than this is worth. If a code is missing here, no remediation
|
|
11
|
+
// is shown — fail-safe, never wrong.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const REMEDIATIONS = {
|
|
14
|
+
// --- Auth ---
|
|
15
|
+
UNAUTHORIZED: "Token is invalid or revoked. Run `farthershore set-key` to update it.",
|
|
16
|
+
FORBIDDEN: "Your token doesn't have access to this resource. Check the org / product scope.",
|
|
17
|
+
INVALID_ACCESS_KEY: "Token format is wrong. Generate a new one at https://farthershore.com/settings/tokens.",
|
|
18
|
+
MAKER_TOKEN_REVOKED: "This maker token was revoked. Mint a new one in the product settings.",
|
|
19
|
+
MAKER_TOKEN_NO_PRODUCT: "Maker token is not bound to a product. Re-create it from the product page.",
|
|
20
|
+
// --- Stripe ---
|
|
21
|
+
STRIPE_NOT_CONFIGURED: "Stripe isn't connected on this product. Connect it in the dashboard before running billing operations.",
|
|
22
|
+
STRIPE_BALANCE_OUTSTANDING: "Customer has an outstanding balance. Resolve the invoice in Stripe before retrying.",
|
|
23
|
+
CHECKOUT_SESSION_FAILED: "Stripe rejected the checkout request. Check that the plan exists and Stripe credentials are valid.",
|
|
24
|
+
// --- Product / config ---
|
|
25
|
+
PRODUCT_NOT_FOUND: "Check the product slug. Run `farthershore` (no args) for a list of products you can see.",
|
|
26
|
+
PRODUCT_REPO_NOT_LINKED: "Link a GitHub repo to this product before running `apply`.",
|
|
27
|
+
PRODUCT_YAML_NOT_FOUND: "No product.yaml on the target branch. Push a config file before running `apply`.",
|
|
28
|
+
YAML_PARSE_ERROR: "product.yaml is not valid YAML. Run `farthershore validate` locally to see the parse error.",
|
|
29
|
+
GITHUB_NOT_CONNECTED: "Connect GitHub on the org page before running `init` (it provisions the repo).",
|
|
30
|
+
BRANCH_NO_MATCHING_ENV: "The current branch isn't mapped to an environment. Add a branch rule in product settings or pass --branch explicitly.",
|
|
31
|
+
// --- Plans / pricing ---
|
|
32
|
+
PLAN_NOT_FOUND: "Plan key doesn't exist on this product. Check `plans:` in product.yaml.",
|
|
33
|
+
PLAN_HAS_ACTIVE_SUBSCRIPTIONS: "Plan has active subscribers and can't be deleted. Migrate them to another plan first.",
|
|
34
|
+
PLAN_SLUG_CONFLICT: "Another plan already uses this key. Pick a unique key in product.yaml.",
|
|
35
|
+
SLUG_CONFLICT: "This product slug is taken. Pick a different name.",
|
|
36
|
+
SLUG_BLOCKED: "This slug is reserved or blocked. Pick a different name.",
|
|
37
|
+
SLUG_RESERVED: "This slug is reserved by Farther Shore. Pick a different name.",
|
|
38
|
+
SLUG_INVALID_FORMAT: "Slug must be lowercase letters, digits, and hyphens (no leading/trailing hyphen).",
|
|
39
|
+
// --- Generic ---
|
|
40
|
+
RATE_LIMIT_EXCEEDED: "You've hit the rate limit. Wait a moment and retry.",
|
|
41
|
+
VALIDATION_ERROR: "Request is malformed. The `details` field has the field-level errors.",
|
|
42
|
+
CONFLICT: "The resource is in a state that conflicts with the request. Inspect `details` to learn more.",
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Look up a one-line remediation hint for a server error code. Returns
|
|
46
|
+
* `undefined` if no hint is registered — callers should treat that as
|
|
47
|
+
* "show no hint" rather than a fallback string.
|
|
48
|
+
*/
|
|
49
|
+
export function getRemediation(code) {
|
|
50
|
+
if (!code)
|
|
51
|
+
return undefined;
|
|
52
|
+
return REMEDIATIONS[code];
|
|
53
|
+
}
|
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/dist/types.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
// Shared types for the FartherShore CLI
|
|
2
|
+
/**
|
|
3
|
+
* Error thrown by the CLI for any non-success path. Carries the canonical
|
|
4
|
+
* `code` from core's `{ error: { code, message, details? } }` envelope so
|
|
5
|
+
* the global error handler can print the code alongside the message and
|
|
6
|
+
* dispatch a remediation hint when one is registered for that code.
|
|
7
|
+
*
|
|
8
|
+
* `code`/`details` are optional because the same class also gets thrown for
|
|
9
|
+
* local (non-API) failures — e.g. "no token configured" — where there's no
|
|
10
|
+
* server-side code to surface.
|
|
11
|
+
*/
|
|
2
12
|
export class CliError extends Error {
|
|
3
13
|
status;
|
|
4
|
-
|
|
14
|
+
code;
|
|
15
|
+
details;
|
|
16
|
+
constructor(message, status, extra) {
|
|
5
17
|
super(message);
|
|
6
18
|
this.status = status;
|
|
7
19
|
this.name = "CliError";
|
|
20
|
+
this.code = extra?.code;
|
|
21
|
+
this.details = extra?.details;
|
|
8
22
|
}
|
|
9
23
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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"
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"build": "tsc",
|
|
25
25
|
"prepublishOnly": "npm run build",
|
|
26
26
|
"test": "vitest run",
|
|
27
|
-
"lint": "prettier --check \"src/**/*.ts\"",
|
|
27
|
+
"lint": "prettier --check \"src/**/*.ts\" && eslint .",
|
|
28
|
+
"lint:fix": "eslint . --fix",
|
|
28
29
|
"typecheck": "tsc --noEmit"
|
|
29
30
|
},
|
|
30
31
|
"keywords": [
|
|
@@ -38,15 +39,20 @@
|
|
|
38
39
|
"author": "Farther Shore",
|
|
39
40
|
"license": "MIT",
|
|
40
41
|
"dependencies": {
|
|
42
|
+
"@farther-shore/shared-types": "^0.28.2",
|
|
41
43
|
"chalk": "^5.4.1",
|
|
42
44
|
"commander": "^13.1.0",
|
|
43
|
-
"yaml": "^2.8.3"
|
|
45
|
+
"yaml": "^2.8.3",
|
|
46
|
+
"zod": "^4.3.6"
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@types/node": "^22.19.17",
|
|
50
|
+
"eslint": "^9.39.4",
|
|
51
|
+
"eslint-plugin-sonarjs": "^4.0.3",
|
|
47
52
|
"prettier": "^3.8.1",
|
|
48
53
|
"tsx": "^4.21.0",
|
|
49
54
|
"typescript": "^6.0.2",
|
|
55
|
+
"typescript-eslint": "^8.59.0",
|
|
50
56
|
"vitest": "^3.2.4"
|
|
51
57
|
}
|
|
52
58
|
}
|