@farthershore/cli 0.3.4 → 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 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 strategy/model alignment.
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
- Run the server-side compiler against the current product configuration. Returns compilation errors and warnings. Exits with code 1 on failure — suitable for CI checks before merging.
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, Plan, ApplyResponse, MakerToken, OrgMember, 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;
@@ -22,95 +22,37 @@ export declare function createClient(opts: {
22
22
  role: string;
23
23
  }>;
24
24
  }>;
25
- validateToken: () => Promise<{
26
- valid: boolean;
27
- type: string;
28
- orgId: string;
29
- productId: string | null;
30
- permissions: string[];
31
- }>;
32
25
  listProducts: () => Promise<Product[]>;
33
- getProduct: (id: string) => Promise<Product>;
34
- createProduct: (data: {
35
- name: string;
36
- baseUrl: string;
37
- description?: string;
38
- }) => Promise<Product>;
39
- updateProduct: (id: string, data: Record<string, unknown>) => Promise<Product>;
40
- deleteProduct: (id: string) => Promise<void>;
41
- publishProduct: (id: string) => Promise<Product>;
42
26
  initProduct: (data: {
43
27
  name: string;
44
28
  baseUrl?: string;
45
29
  description?: string;
46
30
  displayName?: string;
47
- billingStrategy?: string;
48
31
  }) => Promise<InitProductResponse>;
49
- listPlans: (productId: string) => Promise<Plan[]>;
50
- createPlan: (productId: string, data: Record<string, unknown>) => Promise<Plan>;
51
- updatePlan: (productId: string, planId: string, data: Record<string, unknown>) => Promise<Plan>;
52
- deletePlan: (productId: string, planId: string) => Promise<void>;
53
- exportPlanConfig: (productId: string) => Promise<string>;
54
- applyPlanConfig: (productId: string, config: string, opts?: {
55
- configHash?: string;
56
- force?: boolean;
57
- validateOnly?: boolean;
58
- }) => Promise<ApplyResponse>;
59
- listTokens: () => Promise<MakerToken[]>;
60
- revokeToken: (id: string) => Promise<void>;
61
- listMembers: (orgId: string) => Promise<OrgMember[]>;
62
- inviteMember: (orgId: string, email: string, role: string) => Promise<unknown>;
63
- removeMember: (orgId: string, userId: string) => Promise<void>;
64
- getStripeStatus: () => Promise<{
65
- connected: boolean;
66
- chargesEnabled?: boolean;
67
- accountId?: string;
68
- }>;
69
- startStripeOnboard: () => Promise<{
70
- url: string;
71
- }>;
72
- getUsage: (productId: string) => Promise<Record<string, unknown>>;
73
- compileProduct: (productId: string) => Promise<{
32
+ compileProduct: (productId: string, opts?: {
33
+ branch?: string;
34
+ }) => Promise<{
74
35
  success: boolean;
75
- errors?: Array<{
76
- code?: string;
77
- message: string;
78
- }>;
79
- warnings?: Array<{
80
- code?: string;
81
- message: string;
82
- }>;
36
+ errors?: CompileDiagnostic[];
37
+ warnings?: CompileDiagnostic[];
83
38
  }>;
84
- managementCompileSelf: () => Promise<{
39
+ managementCompileSelf: (opts?: {
40
+ branch?: string;
41
+ }) => Promise<{
85
42
  success: boolean;
86
43
  productId: string;
87
- errors?: Array<{
88
- code?: string;
89
- message: string;
90
- }>;
91
- warnings?: Array<{
92
- code?: string;
93
- message: string;
94
- }>;
44
+ branch?: string | null;
45
+ errors?: CompileDiagnostic[];
46
+ warnings?: CompileDiagnostic[];
95
47
  }>;
96
- managementCompile: (productId: string) => Promise<{
48
+ managementCompile: (productId: string, opts?: {
49
+ branch?: string;
50
+ }) => Promise<{
97
51
  success: boolean;
98
- errors?: Array<{
99
- code?: string;
100
- message: string;
101
- }>;
102
- warnings?: Array<{
103
- code?: string;
104
- message: string;
105
- }>;
52
+ errors?: CompileDiagnostic[];
53
+ warnings?: CompileDiagnostic[];
106
54
  }>;
107
55
  managementListProducts: () => Promise<Product[]>;
108
- whoami: () => Promise<{
109
- tokenId: string;
110
- organizationId: string;
111
- productId: string | null;
112
- scopes: string[];
113
- }>;
114
56
  isMakerToken: () => boolean;
115
57
  };
116
58
  export type ApiClient = ReturnType<typeof createClient>;
package/dist/client.js CHANGED
@@ -11,69 +11,43 @@ export function createClient(opts) {
11
11
  body: body ? JSON.stringify(body) : undefined,
12
12
  });
13
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);
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;
19
35
  return res.json();
20
36
  }
21
- async function rawText(method, path) {
22
- const res = await fetch(`${opts.apiUrl}${path}`, {
23
- method,
24
- headers: { Authorization: `Bearer ${opts.token}` },
25
- });
26
- if (!res.ok) {
27
- throw new CliError(`API error: ${res.status}`, res.status);
28
- }
29
- return res.text();
30
- }
31
37
  return {
32
38
  // --- Auth ---
33
39
  bootstrap: () => request("POST", "/builder/context/bootstrap"),
34
- validateToken: () => request("POST", "/auth/validate-token"),
35
40
  // --- Products ---
36
41
  listProducts: () => request("GET", "/products"),
37
- getProduct: (id) => request("GET", `/products/${id}`),
38
- createProduct: (data) => request("POST", "/products", data),
39
- updateProduct: (id, data) => request("PATCH", `/products/${id}`, data),
40
- deleteProduct: (id) => request("DELETE", `/products/${id}`),
41
- publishProduct: (id) => request("POST", `/products/${id}/publish`),
42
42
  initProduct: (data) => request("POST", "/products/init", data),
43
- // --- Plans ---
44
- listPlans: (productId) => request("GET", `/products/${productId}/plans`),
45
- createPlan: (productId, data) => request("POST", `/products/${productId}/plans`, data),
46
- updatePlan: (productId, planId, data) => request("PATCH", `/products/${productId}/plans/${planId}`, data),
47
- deletePlan: (productId, planId) => request("DELETE", `/products/${productId}/plans/${planId}`),
48
- // --- Plan config (declarative) ---
49
- exportPlanConfig: (productId) => rawText("GET", `/products/${productId}/plans/export`),
50
- applyPlanConfig: (productId, config, opts) => {
51
- const params = opts?.validateOnly ? "?validate_only=true" : "";
52
- return request("POST", `/products/${productId}/plans/apply${params}`, { config, configHash: opts?.configHash, force: opts?.force });
53
- },
54
- // --- Tokens ---
55
- listTokens: () => request("GET", "/maker-tokens"),
56
- revokeToken: (id) => request("DELETE", `/maker-tokens/${id}`),
57
- // --- Team ---
58
- listMembers: (orgId) => request("GET", `/builder/organizations/${orgId}/members`),
59
- inviteMember: (orgId, email, role) => request("POST", `/builder/organizations/${orgId}/invitations`, {
60
- email,
61
- role,
62
- }),
63
- removeMember: (orgId, userId) => request("DELETE", `/builder/organizations/${orgId}/members/${userId}`),
64
- // --- Billing ---
65
- getStripeStatus: () => request("GET", "/builder/stripe/status"),
66
- startStripeOnboard: () => request("POST", "/builder/stripe/onboard"),
67
- // --- Usage ---
68
- getUsage: (productId) => request("GET", `/products/${productId}/usage`),
69
43
  // --- Compile ---
70
- compileProduct: (productId) => request("POST", `/products/${productId}/compile`),
44
+ compileProduct: (productId, opts) => request("POST", `/products/${productId}/compile`, opts?.branch ? { branch: opts.branch } : undefined),
71
45
  // --- Management (maker token) ---
72
46
  // Compile the product associated with the token — no product ID needed.
73
- managementCompileSelf: () => request("POST", "/management/compile"),
74
- managementCompile: (productId) => request("POST", `/management/products/${productId}/compile`),
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),
75
50
  managementListProducts: () => request("GET", "/management/products"),
76
- whoami: () => request("GET", "/management/whoami"),
77
51
  isMakerToken: () => opts.token.startsWith("mk_"),
78
52
  };
79
53
  }
@@ -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.code ? `[${err.code}] ` : ""}${err.message}`);
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.code ? `[${w.code}] ` : ""}${w.message}`);
152
+ console.log(`::warning file=product.yaml::${formatDiag(w)}`);
59
153
  }
60
154
  }
61
155
  if (result.success) {
62
- output.success("Compilation passed");
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(`${w.code ? `[${w.code}] ` : ""}${w.message}`);
160
+ output.warn(formatDiag(w));
67
161
  }
68
162
  }
69
163
  }
70
164
  else {
71
- output.error("Compilation failed\n");
165
+ output.error("Remote compile failed\n");
72
166
  for (const err of result.errors ?? []) {
73
- console.log(` • ${err.code ? `[${err.code}] ` : ""}${err.message}`);
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("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) => {
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) {
@@ -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
- const format = output.outputFormat(program.opts().format);
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;
@@ -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 (process.env.FARTHERSHORE_TOKEN) {
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
- output.error("Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN.");
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
- // 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 = [];
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
- // 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");
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 rawMeters = metering.meters ?? [];
57
- if (!Array.isArray(rawMeters)) {
58
- errors.push("'metering.meters' must be an array");
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
- 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");
111
+ seen.add(plan.key);
66
112
  }
67
113
  }
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");
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
- 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]}")`);
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
- 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`);
147
+ for (const w of result.warnings) {
148
+ console.log(`::warning file=product.yaml::${w}`);
103
149
  }
104
- if (limits.length > 20) {
105
- errors.push(`Plan "${plan.key ?? "?"}": maximum 20 limits per plan`);
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
- // 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");
162
+ if (result.warnings.length > 0) {
163
+ console.log();
164
+ for (const w of result.warnings) {
165
+ output.warn(w);
166
+ }
120
167
  }
121
- return { valid: errors.length === 0, errors, warnings };
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
- .action(async (file) => {
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
- 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}`);
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.product
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
- // 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
- }
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
- return {
25
- ...DEFAULT_CONFIG,
26
- ...JSON.parse(readFileSync(CONFIG_FILE, "utf-8")),
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
- process.stderr.write(`Error: ${err.message}\n`);
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
- main();
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" | "yaml";
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 === "yaml" || flagFormat === "table")
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,54 +23,13 @@ export type Product = {
10
23
  createdAt: string;
11
24
  plans: Plan[];
12
25
  };
13
- export type Plan = {
14
- id: string;
15
- key: string;
16
- name: string;
17
- description: string | null;
18
- type: string;
19
- monthlyPriceCents: number;
20
- isActive: boolean;
21
- selfServeEnabled: boolean;
22
- };
23
- export type ApplyResponse = {
24
- valid: boolean;
25
- errors: Array<{
26
- line?: number;
27
- message: string;
28
- }>;
29
- warnings: Array<{
30
- message: string;
31
- }>;
32
- changes: Array<{
33
- action: "create" | "update" | "deactivate";
34
- key: string;
35
- name: string;
36
- details: string;
37
- }>;
38
- applied: boolean;
39
- results?: Array<{
40
- action: string;
41
- key: string;
42
- id: string;
43
- }>;
44
- };
45
- export type MakerToken = {
46
- id: string;
47
- label: string;
48
- lastFour: string;
49
- productId: string | null;
50
- permissions: string[];
51
- createdAt: string;
52
- };
53
- export type OrgMember = {
54
- userId: string;
55
- role: string;
56
- user?: {
57
- email?: string;
58
- name?: string;
59
- };
60
- };
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;
61
33
  export type CliConfig = {
62
34
  apiUrl: string;
63
35
  activeOrg?: string;
@@ -80,7 +52,22 @@ export type InitProductResponse = {
80
52
  agentsMdUrl: string | null;
81
53
  };
82
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
+ */
83
65
  export declare class CliError extends Error {
84
66
  status?: number | undefined;
85
- constructor(message: string, status?: number | undefined);
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
+ });
86
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
- constructor(message, status) {
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.4",
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": ">=20"
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
  }