@farthershore/cli 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { saveConfig } from "../config.js";
2
+ import * as output from "../output.js";
3
+ import { commandFormat, printResult, resolveProductId } from "./helpers.js";
4
+ export function registerProductCommands(program, getClient) {
5
+ const product = program.command("product").description("Manage products");
6
+ product
7
+ .command("create <name>")
8
+ .description("Create a product and select it for subsequent CLI commands")
9
+ .requiredOption("--base-url <url>", "Backend API base URL")
10
+ .option("--strategy <strategy>", "Product billing strategy", "flat_subscription")
11
+ .option("--description <desc>", "Product description")
12
+ .option("--display-name <name>", "Display name")
13
+ .option("--dry-run", "Print the create request without mutating")
14
+ .action(async (name, opts) => {
15
+ if (opts.dryRun) {
16
+ printResult(program, "Product create dry run", {
17
+ ok: true,
18
+ dryRun: true,
19
+ proposal: {
20
+ name,
21
+ baseUrl: opts.baseUrl,
22
+ description: opts.description,
23
+ displayName: opts.displayName,
24
+ billingStrategy: opts.strategy,
25
+ subscriberChangePolicy: {
26
+ default: "preserve_current_period",
27
+ proration: "none",
28
+ },
29
+ },
30
+ nextActions: ["Run without --dry-run to create the product"],
31
+ });
32
+ return;
33
+ }
34
+ const client = getClient();
35
+ const result = await client.initProduct({
36
+ name,
37
+ baseUrl: opts.baseUrl,
38
+ description: opts.description,
39
+ displayName: opts.displayName,
40
+ billingStrategy: opts.strategy,
41
+ subscriberChangePolicy: {
42
+ default: "preserve_current_period",
43
+ proration: "none",
44
+ },
45
+ });
46
+ saveConfig({
47
+ activeProductId: result.product.id,
48
+ activeProductName: result.product.name,
49
+ });
50
+ printResult(program, `Created product "${result.product.name}"`, {
51
+ ok: true,
52
+ productId: result.product.id,
53
+ product: result.product,
54
+ repo: result.repo,
55
+ githubSyncStatus: result.product
56
+ .acceptedProductSpecGithubSyncStatus ?? null,
57
+ nextActions: [
58
+ "Add meters, features, and plans",
59
+ "Run farthershore apply --dry-run --format json",
60
+ ],
61
+ });
62
+ });
63
+ product
64
+ .command("status")
65
+ .description("Show selected product status")
66
+ .option("--product <product>", "Product id or name")
67
+ .action(async (opts) => {
68
+ const client = getClient();
69
+ const productId = await resolveProductId(client, opts.product);
70
+ const config = await client.getProductConfig(productId);
71
+ printResult(program, "Product status loaded", {
72
+ ok: true,
73
+ productId,
74
+ ...(typeof config === "object" && config ? config : {}),
75
+ });
76
+ });
77
+ product
78
+ .command("config")
79
+ .description("Show latest accepted internal product config")
80
+ .option("--product <product>", "Product id or name")
81
+ .option("--format <format>", "Output format: json or yaml")
82
+ .action(async (opts) => {
83
+ const client = getClient();
84
+ const productId = await resolveProductId(client, opts.product);
85
+ const format = opts.format ?? commandFormat(program);
86
+ if (format === "yaml") {
87
+ console.log(await client.getProductConfigYaml(productId));
88
+ return;
89
+ }
90
+ console.log(output.json(await client.getProductConfig(productId)));
91
+ });
92
+ product
93
+ .command("attempts")
94
+ .description("Show rejected product config attempts")
95
+ .option("--product <product>", "Product id or name")
96
+ .action(async (opts) => {
97
+ const client = getClient();
98
+ const productId = await resolveProductId(client, opts.product);
99
+ console.log(output.json(await client.getProductAttempts(productId)));
100
+ });
101
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { ApiClient } from "../client.js";
3
+ export declare function registerTransitionCommands(program: Command, getClient: () => ApiClient): void;
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import YAML from "yaml";
4
+ import * as output from "../output.js";
5
+ import { loadAcceptedSpec, resolveProductId } from "./helpers.js";
6
+ import { analyzePlanTransition } from "./plan-transition.js";
7
+ export function registerTransitionCommands(program, getClient) {
8
+ const transition = program
9
+ .command("transition")
10
+ .description("Preview subscriber impact for product config changes");
11
+ transition
12
+ .command("preview")
13
+ .description("Compare the latest accepted config to a proposed config and show subscriber transition impact")
14
+ .option("--product <product>", "Product id or name")
15
+ .option("--to <file>", "Proposed product YAML file")
16
+ .option("--format <format>", "Output format: table or json")
17
+ .action(async (opts) => {
18
+ const client = getClient();
19
+ const productId = await resolveProductId(client, opts.product);
20
+ const accepted = await loadAcceptedSpec(client, productId);
21
+ const proposed = loadProposedSpec(opts.to) ?? accepted;
22
+ const analysis = analyzePlanTransition({
23
+ fromSpec: accepted,
24
+ toSpec: proposed,
25
+ fromLabel: "acceptedProductSpec",
26
+ toLabel: opts.to ?? "acceptedProductSpec",
27
+ });
28
+ const globalOpts = program.opts();
29
+ const format = opts.format ?? output.outputFormat(globalOpts.format);
30
+ if (format === "json") {
31
+ console.log(output.json({
32
+ ok: analysis.valid,
33
+ productId,
34
+ transitionPreview: analysis,
35
+ errors: analysis.errors,
36
+ warnings: analysis.warnings,
37
+ nextActions: analysis.agentHints,
38
+ }));
39
+ if (!analysis.valid)
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ if (analysis.valid)
44
+ output.success("Transition preview passed");
45
+ else
46
+ output.error("Transition preview failed");
47
+ for (const change of analysis.changes) {
48
+ console.log(` • ${change.message} -> ${change.subscriberAction}`);
49
+ }
50
+ for (const warning of analysis.warnings)
51
+ output.warn(warning);
52
+ for (const error of analysis.errors)
53
+ output.error(error);
54
+ if (!analysis.valid)
55
+ process.exitCode = 1;
56
+ });
57
+ }
58
+ function loadProposedSpec(file) {
59
+ const path = resolve(file ?? "product.yaml");
60
+ if (!existsSync(path))
61
+ return null;
62
+ return YAML.parse(readFileSync(path, "utf-8"));
63
+ }
@@ -0,0 +1,206 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+ import YAML from "yaml";
5
+ import { productSpecSchema, } from "@farther-shore/shared-types/plans";
6
+ import * as output from "../output.js";
7
+ const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
8
+ /**
9
+ * Read product.yaml from the main branch using git, without checking it out.
10
+ * Returns null if not in a git repo or main doesn't have the file.
11
+ */
12
+ function readMainBranchProductName() {
13
+ for (const branch of ["main", "master"]) {
14
+ try {
15
+ const yaml = execSync(`git show ${branch}:product.yaml 2>/dev/null`, {
16
+ encoding: "utf-8",
17
+ stdio: ["pipe", "pipe", "pipe"],
18
+ });
19
+ const spec = YAML.parse(yaml);
20
+ const product = spec.product;
21
+ return product?.name ?? null;
22
+ }
23
+ catch {
24
+ continue;
25
+ }
26
+ }
27
+ return null;
28
+ }
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) {
79
+ const warnings = [];
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");
85
+ }
86
+ else {
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);
109
+ }
110
+ else {
111
+ seen.add(plan.key);
112
+ }
113
+ }
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) };
128
+ }
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}`);
146
+ }
147
+ for (const w of result.warnings) {
148
+ console.log(`::warning file=product.yaml::${w}`);
149
+ }
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);
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}`);
161
+ }
162
+ if (result.warnings.length > 0) {
163
+ console.log();
164
+ for (const w of result.warnings) {
165
+ output.warn(w);
166
+ }
167
+ }
168
+ process.exitCode = 1;
169
+ }
170
+ export function registerValidateCommand(program) {
171
+ program
172
+ .command("validate [file]")
173
+ .description("Validate a local product.yaml file")
174
+ // Action body is sync; commander only requires a thenable on
175
+ // .action when await is used. Drop `async` keyword.
176
+ .action((file) => {
177
+ const filePath = resolve(file ?? "product.yaml");
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
+ }
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+ const result = validateProductYaml(loaded.spec);
194
+ // Check product.name consistency against main branch
195
+ const currentName = loaded.spec
196
+ ?.product?.name;
197
+ if (currentName) {
198
+ const mainName = readMainBranchProductName();
199
+ if (mainName && mainName !== currentName) {
200
+ result.errors.push(`product.name "${currentName}" differs from main branch "${mainName}" — product name must not change`);
201
+ result.valid = false;
202
+ }
203
+ }
204
+ reportValidationResult(result);
205
+ });
206
+ }
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ // ~/.farthershore/ config management
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { BUILD_API_URL } from "./build-info.js";
6
+ const CONFIG_DIR = join(homedir(), ".farthershore");
7
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
8
+ const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
9
+ function ensureDir(dir) {
10
+ if (!existsSync(dir))
11
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
12
+ }
13
+ // --- Config ---
14
+ const DEFAULT_CONFIG = {
15
+ apiUrl: BUILD_API_URL,
16
+ defaultFormat: "table",
17
+ };
18
+ export function loadConfig() {
19
+ ensureDir(CONFIG_DIR);
20
+ if (!existsSync(CONFIG_FILE))
21
+ return DEFAULT_CONFIG;
22
+ try {
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 };
27
+ }
28
+ catch {
29
+ return DEFAULT_CONFIG;
30
+ }
31
+ }
32
+ export function saveConfig(config) {
33
+ ensureDir(CONFIG_DIR);
34
+ const current = loadConfig();
35
+ writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2) + "\n");
36
+ }
37
+ // --- Credentials ---
38
+ export function loadCredentials() {
39
+ if (!existsSync(CREDENTIALS_FILE))
40
+ return null;
41
+ try {
42
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8"));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export function saveCredentials(creds) {
49
+ ensureDir(CONFIG_DIR);
50
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2) + "\n", {
51
+ mode: 0o600,
52
+ });
53
+ }
54
+ export function clearCredentials() {
55
+ if (existsSync(CREDENTIALS_FILE)) {
56
+ writeFileSync(CREDENTIALS_FILE, "{}");
57
+ }
58
+ }