@farthershore/cli 0.3.7 → 0.3.9
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 +160 -0
- package/dist/auth.js +17 -0
- package/dist/build-info.js +10 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +82 -0
- package/dist/commands/apply.js +296 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +99 -0
- package/dist/commands/feature.d.ts +3 -0
- package/dist/commands/feature.js +109 -0
- package/dist/commands/helpers.d.ts +15 -0
- package/dist/commands/helpers.js +93 -0
- package/dist/commands/init.js +43 -0
- package/dist/commands/login.js +144 -0
- package/dist/commands/meter.d.ts +3 -0
- package/dist/commands/meter.js +121 -0
- package/dist/commands/plan-transition.d.ts +40 -0
- package/dist/commands/plan-transition.js +504 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.js +234 -0
- package/dist/commands/product.d.ts +3 -0
- package/dist/commands/product.js +137 -0
- package/dist/commands/transition.d.ts +3 -0
- package/dist/commands/transition.js +80 -0
- package/dist/commands/validate.js +216 -0
- package/dist/config.js +58 -0
- package/dist/index.js +102 -1233
- package/dist/output.js +28 -0
- package/dist/remediation.js +53 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +23 -0
- package/package.json +5 -8
package/README.md
CHANGED
|
@@ -25,6 +25,9 @@ farthershore set-key
|
|
|
25
25
|
# Non-interactive — pass token directly
|
|
26
26
|
farthershore set-key mk_xxx
|
|
27
27
|
|
|
28
|
+
# Agent-friendly alias
|
|
29
|
+
farthershore auth set-key mk_xxx
|
|
30
|
+
|
|
28
31
|
# Environment variable (CI / agents)
|
|
29
32
|
export FARTHERSHORE_TOKEN=mk_xxx
|
|
30
33
|
```
|
|
@@ -54,6 +57,125 @@ The created repo contains:
|
|
|
54
57
|
- `docs/` — developer portal documentation
|
|
55
58
|
- `.skills/` — task-specific instructions
|
|
56
59
|
|
|
60
|
+
### `farthershore product create <name>`
|
|
61
|
+
|
|
62
|
+
Create a product through the API and select it as the active product for
|
|
63
|
+
subsequent CLI commands. This is the fastest path for an agent that wants
|
|
64
|
+
to configure a product without opening the UI.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
farthershore product create weather-api \
|
|
68
|
+
--base-url https://api.example.com \
|
|
69
|
+
--strategy prepaid_credits \
|
|
70
|
+
--format json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### `farthershore product status`
|
|
74
|
+
|
|
75
|
+
Return the latest accepted internal product config metadata, including
|
|
76
|
+
the accepted config hash and GitHub sync state.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
farthershore product status --format json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `farthershore product config`
|
|
83
|
+
|
|
84
|
+
Print the latest accepted product config from FartherShore. GitHub, the
|
|
85
|
+
UI, and the CLI are proposal surfaces; the accepted internal config is the
|
|
86
|
+
valid source of truth.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
farthershore product config --format json
|
|
90
|
+
farthershore product config --format yaml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `farthershore product attempts`
|
|
94
|
+
|
|
95
|
+
List rejected config proposals. Invalid proposals are recorded for
|
|
96
|
+
diagnostics but do not replace the accepted internal config or mutate live
|
|
97
|
+
product state.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
farthershore product attempts --format json
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `farthershore meter`
|
|
104
|
+
|
|
105
|
+
Create and manage usage meters on the accepted product config.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
farthershore meter add ai_tokens \
|
|
109
|
+
--selector model \
|
|
110
|
+
--measure input_tokens \
|
|
111
|
+
--measure output_tokens \
|
|
112
|
+
--rating provider_catalog \
|
|
113
|
+
--catalog llm_models \
|
|
114
|
+
--format json
|
|
115
|
+
|
|
116
|
+
farthershore meter list --format json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `farthershore feature`
|
|
120
|
+
|
|
121
|
+
Create features and bind or unbind them from plans.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
farthershore feature add chat_completions \
|
|
125
|
+
--route POST:/v1/chat/completions \
|
|
126
|
+
--format json
|
|
127
|
+
|
|
128
|
+
farthershore feature bind chat_completions --plan pro --format json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `farthershore plan`
|
|
132
|
+
|
|
133
|
+
Create and update plans in the accepted product config. Server-side
|
|
134
|
+
validation remains authoritative for rules like one free plan, duplicate
|
|
135
|
+
paid prices, and product-level billing strategy consistency.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
farthershore plan add free \
|
|
139
|
+
--free \
|
|
140
|
+
--limit ai_tokens:month:100000:enforce \
|
|
141
|
+
--feature chat_completions \
|
|
142
|
+
--format json
|
|
143
|
+
|
|
144
|
+
farthershore plan add pro \
|
|
145
|
+
--price-monthly 2900 \
|
|
146
|
+
--credits-monthly-micros 500000000 \
|
|
147
|
+
--meter ai_tokens \
|
|
148
|
+
--feature chat_completions \
|
|
149
|
+
--format json
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `farthershore billing`
|
|
153
|
+
|
|
154
|
+
Read or change the product-level billing strategy and subscriber change
|
|
155
|
+
policy.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
farthershore billing strategy get --format json
|
|
159
|
+
|
|
160
|
+
farthershore billing strategy set prepaid_credits \
|
|
161
|
+
--transition preserve_current_period \
|
|
162
|
+
--format json
|
|
163
|
+
|
|
164
|
+
farthershore billing policy set \
|
|
165
|
+
--default preserve_current_period \
|
|
166
|
+
--proration none \
|
|
167
|
+
--format json
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `farthershore transition preview`
|
|
171
|
+
|
|
172
|
+
Preview how billing-affecting config changes apply to existing
|
|
173
|
+
subscribers before applying them.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
farthershore transition preview --format json
|
|
177
|
+
```
|
|
178
|
+
|
|
57
179
|
### `farthershore validate [file]`
|
|
58
180
|
|
|
59
181
|
Validate a local `product.yaml` file without making any API calls. Checks structure, required fields, and launch constraints for env-scoped plans and meters.
|
|
@@ -76,6 +198,9 @@ farthershore apply
|
|
|
76
198
|
|
|
77
199
|
# Or pass the product slug
|
|
78
200
|
farthershore apply my-weather-api
|
|
201
|
+
|
|
202
|
+
# Validate and remote-compile without applying
|
|
203
|
+
farthershore apply --dry-run --format json
|
|
79
204
|
```
|
|
80
205
|
|
|
81
206
|
### `farthershore set-key [token]`
|
|
@@ -120,6 +245,41 @@ farthershore validate
|
|
|
120
245
|
git add -A && git commit -m "Configure product" && git push
|
|
121
246
|
```
|
|
122
247
|
|
|
248
|
+
Agents can also complete the flow entirely through API-backed CLI
|
|
249
|
+
commands:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
farthershore auth set-key "$FARTHERSHORE_TOKEN"
|
|
253
|
+
farthershore product create weather-api \
|
|
254
|
+
--base-url https://api.example.com \
|
|
255
|
+
--strategy prepaid_credits \
|
|
256
|
+
--format json
|
|
257
|
+
farthershore meter add ai_tokens \
|
|
258
|
+
--selector model \
|
|
259
|
+
--measure input_tokens \
|
|
260
|
+
--measure output_tokens \
|
|
261
|
+
--rating provider_catalog \
|
|
262
|
+
--catalog llm_models \
|
|
263
|
+
--format json
|
|
264
|
+
farthershore feature add chat_completions \
|
|
265
|
+
--route POST:/v1/chat/completions \
|
|
266
|
+
--format json
|
|
267
|
+
farthershore plan add free \
|
|
268
|
+
--free \
|
|
269
|
+
--limit ai_tokens:month:100000:enforce \
|
|
270
|
+
--feature chat_completions \
|
|
271
|
+
--format json
|
|
272
|
+
farthershore plan add pro \
|
|
273
|
+
--price-monthly 2900 \
|
|
274
|
+
--credits-monthly-micros 500000000 \
|
|
275
|
+
--meter ai_tokens \
|
|
276
|
+
--feature chat_completions \
|
|
277
|
+
--format json
|
|
278
|
+
farthershore transition preview --format json
|
|
279
|
+
farthershore apply --dry-run --format json
|
|
280
|
+
farthershore product status --format json
|
|
281
|
+
```
|
|
282
|
+
|
|
123
283
|
## Global Flags
|
|
124
284
|
|
|
125
285
|
| Flag | Description |
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Token resolution: env var → credentials file → error
|
|
2
|
+
import { loadCredentials } from "./config.js";
|
|
3
|
+
import { CliError } from "./types.js";
|
|
4
|
+
export function resolveToken(overrideToken) {
|
|
5
|
+
// 1. Explicit override (--token flag)
|
|
6
|
+
if (overrideToken)
|
|
7
|
+
return overrideToken;
|
|
8
|
+
// 2. Environment variable
|
|
9
|
+
const envToken = process.env.FARTHERSHORE_TOKEN;
|
|
10
|
+
if (envToken)
|
|
11
|
+
return envToken;
|
|
12
|
+
// 3. Stored credentials (from `farthershore login`)
|
|
13
|
+
const creds = loadCredentials();
|
|
14
|
+
if (creds?.token)
|
|
15
|
+
return creds.token;
|
|
16
|
+
throw new CliError("Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN environment variable.");
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Build-time constants baked into the CLI binary at publish time.
|
|
2
|
+
//
|
|
3
|
+
// To publish a staging variant:
|
|
4
|
+
// 1. Change BUILD_API_URL below to the staging URL
|
|
5
|
+
// 2. Change the npm package name/tag in package.json
|
|
6
|
+
// 3. Run `npm publish`
|
|
7
|
+
//
|
|
8
|
+
// The CLI uses this as the default API URL — users don't need to set
|
|
9
|
+
// FARTHERSHORE_API_URL or pass --api-url for their target environment.
|
|
10
|
+
export const BUILD_API_URL = "https://core.farthershore.com";
|
package/dist/client.d.ts
CHANGED
|
@@ -23,14 +23,43 @@ export declare function createClient(opts: {
|
|
|
23
23
|
}>;
|
|
24
24
|
}>;
|
|
25
25
|
listProducts: () => Promise<Product[]>;
|
|
26
|
+
createProduct: (data: {
|
|
27
|
+
name: string;
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
displayName?: string;
|
|
31
|
+
billingStrategy?: string;
|
|
32
|
+
subscriberChangePolicy?: unknown;
|
|
33
|
+
}) => Promise<Product>;
|
|
26
34
|
initProduct: (data: {
|
|
27
35
|
name: string;
|
|
28
36
|
baseUrl?: string;
|
|
29
37
|
description?: string;
|
|
30
38
|
displayName?: string;
|
|
39
|
+
billingStrategy?: string;
|
|
40
|
+
subscriberChangePolicy?: unknown;
|
|
31
41
|
}) => Promise<InitProductResponse>;
|
|
42
|
+
updateProduct: (productId: string, data: Record<string, unknown>, opts?: {
|
|
43
|
+
env?: string;
|
|
44
|
+
}) => Promise<Product>;
|
|
45
|
+
createPlan: (productId: string, data: unknown, opts?: {
|
|
46
|
+
env?: string;
|
|
47
|
+
}) => Promise<unknown>;
|
|
48
|
+
updatePlan: (productId: string, planId: string, data: unknown, opts?: {
|
|
49
|
+
env?: string;
|
|
50
|
+
}) => Promise<unknown>;
|
|
51
|
+
deletePlan: (productId: string, planId: string, opts?: {
|
|
52
|
+
env?: string;
|
|
53
|
+
}) => Promise<void>;
|
|
54
|
+
getProductConfig: (productId: string) => Promise<unknown>;
|
|
55
|
+
getProductConfigYaml: (productId: string) => Promise<string>;
|
|
56
|
+
getProductAttempts: (productId: string) => Promise<unknown>;
|
|
57
|
+
updateProductConfig: (productId: string, spec: unknown, opts?: {
|
|
58
|
+
dryRun?: boolean;
|
|
59
|
+
}) => Promise<unknown>;
|
|
32
60
|
compileProduct: (productId: string, opts?: {
|
|
33
61
|
branch?: string;
|
|
62
|
+
dryRun?: boolean;
|
|
34
63
|
}) => Promise<{
|
|
35
64
|
success: boolean;
|
|
36
65
|
errors?: CompileDiagnostic[];
|
|
@@ -38,6 +67,7 @@ export declare function createClient(opts: {
|
|
|
38
67
|
}>;
|
|
39
68
|
managementCompileSelf: (opts?: {
|
|
40
69
|
branch?: string;
|
|
70
|
+
dryRun?: boolean;
|
|
41
71
|
}) => Promise<{
|
|
42
72
|
success: boolean;
|
|
43
73
|
productId: string;
|
|
@@ -47,6 +77,7 @@ export declare function createClient(opts: {
|
|
|
47
77
|
}>;
|
|
48
78
|
managementCompile: (productId: string, opts?: {
|
|
49
79
|
branch?: string;
|
|
80
|
+
dryRun?: boolean;
|
|
50
81
|
}) => Promise<{
|
|
51
82
|
success: boolean;
|
|
52
83
|
errors?: CompileDiagnostic[];
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Typed API client for the FartherShore platform
|
|
2
|
+
import { CliError } from "./types.js";
|
|
3
|
+
export function createClient(opts) {
|
|
4
|
+
async function request(method, path, body) {
|
|
5
|
+
const res = await fetch(`${opts.apiUrl}${path}`, {
|
|
6
|
+
method,
|
|
7
|
+
headers: {
|
|
8
|
+
Authorization: `Bearer ${opts.token}`,
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
},
|
|
11
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
12
|
+
});
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
// 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 });
|
|
32
|
+
}
|
|
33
|
+
if (res.status === 204)
|
|
34
|
+
return undefined;
|
|
35
|
+
return res.json();
|
|
36
|
+
}
|
|
37
|
+
async function requestText(method, path) {
|
|
38
|
+
const res = await fetch(`${opts.apiUrl}${path}`, {
|
|
39
|
+
method,
|
|
40
|
+
headers: { Authorization: `Bearer ${opts.token}` },
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new CliError(res.statusText, res.status);
|
|
44
|
+
return res.text();
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
// --- Auth ---
|
|
48
|
+
bootstrap: () => request("POST", "/builder/context/bootstrap"),
|
|
49
|
+
// --- Products ---
|
|
50
|
+
listProducts: () => request("GET", "/products"),
|
|
51
|
+
createProduct: (data) => request("POST", "/products", data),
|
|
52
|
+
initProduct: (data) => request("POST", "/products/init", data),
|
|
53
|
+
updateProduct: (productId, data, opts) => request("PATCH", `/products/${productId}${opts?.env ? `?env=${encodeURIComponent(opts.env)}` : ""}`, data),
|
|
54
|
+
createPlan: (productId, data, opts) => request("POST", `/products/${productId}/plans${opts?.env ? `?env=${encodeURIComponent(opts.env)}` : ""}`, data),
|
|
55
|
+
updatePlan: (productId, planId, data, opts) => request("PATCH", `/products/${productId}/plans/${planId}${opts?.env ? `?env=${encodeURIComponent(opts.env)}` : ""}`, data),
|
|
56
|
+
deletePlan: (productId, planId, opts) => request("DELETE", `/products/${productId}/plans/${planId}${opts?.env ? `?env=${encodeURIComponent(opts.env)}` : ""}`),
|
|
57
|
+
getProductConfig: (productId) => request("GET", `/products/${productId}/config?format=json`),
|
|
58
|
+
getProductConfigYaml: (productId) => requestText("GET", `/products/${productId}/config?format=yaml`),
|
|
59
|
+
getProductAttempts: (productId) => request("GET", `/products/${productId}/config-attempts`),
|
|
60
|
+
updateProductConfig: (productId, spec, opts) => request("PATCH", `/products/${productId}/config`, {
|
|
61
|
+
spec,
|
|
62
|
+
dryRun: opts?.dryRun === true,
|
|
63
|
+
}),
|
|
64
|
+
// --- Compile ---
|
|
65
|
+
compileProduct: (productId, opts) => request("POST", `/products/${productId}/compile`, compileBody(opts)),
|
|
66
|
+
// --- Management (maker token) ---
|
|
67
|
+
// Compile the product associated with the token — no product ID needed.
|
|
68
|
+
// Pass `branch` to scope compilation to an env branch's plans.
|
|
69
|
+
managementCompileSelf: (opts) => request("POST", "/management/compile", compileBody(opts)),
|
|
70
|
+
managementCompile: (productId, opts) => request("POST", `/management/products/${productId}/compile`, compileBody(opts)),
|
|
71
|
+
managementListProducts: () => request("GET", "/management/products"),
|
|
72
|
+
isMakerToken: () => opts.token.startsWith("mk_"),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function compileBody(opts) {
|
|
76
|
+
const body = {};
|
|
77
|
+
if (opts?.branch)
|
|
78
|
+
body.branch = opts.branch;
|
|
79
|
+
if (opts?.dryRun)
|
|
80
|
+
body.dryRun = true;
|
|
81
|
+
return Object.keys(body).length > 0 ? body : undefined;
|
|
82
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import * as output from "../output.js";
|
|
6
|
+
import { loadProductYaml, validateProductYaml } from "./validate.js";
|
|
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
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the product name/slug from product.yaml in the current directory.
|
|
38
|
+
*/
|
|
39
|
+
function readSlugFromProductYaml() {
|
|
40
|
+
const yamlPath = resolve("product.yaml");
|
|
41
|
+
if (!existsSync(yamlPath))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const spec = YAML.parse(readFileSync(yamlPath, "utf-8"));
|
|
45
|
+
const product = spec.product;
|
|
46
|
+
return product?.name ?? null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function validateLocalProductYamlBeforeRemoteCompile(format) {
|
|
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
|
+
if (format === "json") {
|
|
65
|
+
console.log(output.json({
|
|
66
|
+
ok: false,
|
|
67
|
+
success: false,
|
|
68
|
+
phase: "local_validation",
|
|
69
|
+
errors: [{ message }],
|
|
70
|
+
warnings: [],
|
|
71
|
+
nextActions: ["Fix product.yaml and run apply again"],
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
output.error(message);
|
|
76
|
+
}
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const result = validateProductYaml(loaded.spec);
|
|
81
|
+
if (!result.valid) {
|
|
82
|
+
if (CI) {
|
|
83
|
+
for (const err of result.errors) {
|
|
84
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
85
|
+
}
|
|
86
|
+
for (const warning of result.warnings) {
|
|
87
|
+
console.log(`::warning file=product.yaml::${warning}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (format === "json") {
|
|
91
|
+
console.log(output.json({
|
|
92
|
+
ok: false,
|
|
93
|
+
success: false,
|
|
94
|
+
phase: "local_validation",
|
|
95
|
+
errors: result.errors.map((message) => ({ message })),
|
|
96
|
+
warnings: result.warnings.map((message) => ({ message })),
|
|
97
|
+
nextActions: ["Fix product.yaml and run apply again"],
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
output.error("Local product.yaml failed validation; remote compile was not started.\n");
|
|
102
|
+
for (const err of result.errors) {
|
|
103
|
+
console.log(` • ${err}`);
|
|
104
|
+
}
|
|
105
|
+
if (result.warnings.length > 0) {
|
|
106
|
+
console.log();
|
|
107
|
+
for (const warning of result.warnings) {
|
|
108
|
+
output.warn(warning);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (format !== "json") {
|
|
116
|
+
output.success("Local product.yaml passed validation");
|
|
117
|
+
for (const warning of result.warnings) {
|
|
118
|
+
output.warn(warning);
|
|
119
|
+
}
|
|
120
|
+
output.info("Remote compile checks the pushed branch state; unpushed local edits are not included.");
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function shouldValidateLocalProductYaml(productArg) {
|
|
125
|
+
const filePath = resolve("product.yaml");
|
|
126
|
+
if (!existsSync(filePath))
|
|
127
|
+
return false;
|
|
128
|
+
if (!productArg)
|
|
129
|
+
return true;
|
|
130
|
+
const localSlug = readSlugFromProductYaml();
|
|
131
|
+
return (typeof localSlug === "string" &&
|
|
132
|
+
localSlug.toLowerCase() === productArg.toLowerCase());
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a product identifier to an API product ID.
|
|
136
|
+
* If no arg, reads product.name from product.yaml in the current directory.
|
|
137
|
+
* If arg looks like a UUID, uses it directly. Otherwise treats it as a slug.
|
|
138
|
+
*/
|
|
139
|
+
async function resolveProductId(client, arg) {
|
|
140
|
+
const slug = arg ?? readSlugFromProductYaml();
|
|
141
|
+
if (!slug)
|
|
142
|
+
return null;
|
|
143
|
+
// UUID — use directly
|
|
144
|
+
if (slug.length === 36 &&
|
|
145
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(slug)) {
|
|
146
|
+
return slug;
|
|
147
|
+
}
|
|
148
|
+
// Slug — resolve via product list (use management API for maker tokens)
|
|
149
|
+
try {
|
|
150
|
+
const products = client.isMakerToken()
|
|
151
|
+
? await client.managementListProducts()
|
|
152
|
+
: await client.listProducts();
|
|
153
|
+
const match = products.find((p) => p.name === slug || p.name.toLowerCase() === slug.toLowerCase());
|
|
154
|
+
return match?.id ?? null;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// Don't swallow network errors silently
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
process.stderr.write(`Warning: Failed to resolve product slug: ${msg}\n`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function compileOptions(branch, dryRun) {
|
|
164
|
+
return {
|
|
165
|
+
...(branch ? { branch } : {}),
|
|
166
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Render a diagnostic with an optional plan-key prefix so users can see which
|
|
170
|
+
// plan an error belongs to when the compiler scopes it. `compiledPlanId`
|
|
171
|
+
// itself is an internal pointer — `planKey` is the human-readable label.
|
|
172
|
+
function formatDiag(d) {
|
|
173
|
+
const prefix = d.code ? `[${d.code}] ` : "";
|
|
174
|
+
const planLabel = d.planKey ? `(plan: ${d.planKey}) ` : "";
|
|
175
|
+
return `${prefix}${planLabel}${d.message}`;
|
|
176
|
+
}
|
|
177
|
+
function handleResult(result, format) {
|
|
178
|
+
// GitHub Actions annotations
|
|
179
|
+
if (CI) {
|
|
180
|
+
for (const err of result.errors ?? []) {
|
|
181
|
+
console.log(`::error file=product.yaml::${formatDiag(err)}`);
|
|
182
|
+
}
|
|
183
|
+
for (const w of result.warnings ?? []) {
|
|
184
|
+
console.log(`::warning file=product.yaml::${formatDiag(w)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (format === "json") {
|
|
188
|
+
console.log(output.json({
|
|
189
|
+
ok: result.success,
|
|
190
|
+
success: result.success,
|
|
191
|
+
errors: result.errors ?? [],
|
|
192
|
+
warnings: result.warnings ?? [],
|
|
193
|
+
nextActions: result.success
|
|
194
|
+
? ["Product config is valid for apply"]
|
|
195
|
+
: ["Fix errors and run farthershore apply --dry-run --format json"],
|
|
196
|
+
}));
|
|
197
|
+
if (!result.success)
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (result.success) {
|
|
202
|
+
output.success("Remote compile passed");
|
|
203
|
+
if (result.warnings?.length) {
|
|
204
|
+
console.log();
|
|
205
|
+
for (const w of result.warnings) {
|
|
206
|
+
output.warn(formatDiag(w));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
output.error("Remote compile failed\n");
|
|
212
|
+
for (const err of result.errors ?? []) {
|
|
213
|
+
console.log(` • ${formatDiag(err)}`);
|
|
214
|
+
}
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export function registerApplyCommand(program, getClient) {
|
|
219
|
+
program
|
|
220
|
+
.command("apply [product]")
|
|
221
|
+
.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. " +
|
|
222
|
+
"Unpushed local edits are not included. Pass a product slug, or run inside a product repo to auto-detect from product.yaml. " +
|
|
223
|
+
"Automatically scopes to the current git branch so env branches compile against their own plans.")
|
|
224
|
+
.option("--branch <branch>", "Override the branch used for env-scoped compilation (default: auto-detected)")
|
|
225
|
+
.option("--dry-run", "Run local validation and remote compile without applying product state")
|
|
226
|
+
.addHelpText("after", `
|
|
227
|
+
Agent notes:
|
|
228
|
+
apply first validates local product.yaml when it matches the target product, then asks core to compile the pushed branch state.
|
|
229
|
+
Unpushed local edits are not included in remote compile. Push or use CLI config commands for API-backed changes.
|
|
230
|
+
Use --format json for stable output: ok, success, errors, warnings, nextActions.
|
|
231
|
+
|
|
232
|
+
Examples:
|
|
233
|
+
farthershore apply --dry-run --format json
|
|
234
|
+
farthershore apply my-weather-api --dry-run --format json
|
|
235
|
+
farthershore apply my-weather-api --branch feature/billing-change --dry-run --format json
|
|
236
|
+
`)
|
|
237
|
+
.action(async (productArg, opts) => {
|
|
238
|
+
const client = getClient();
|
|
239
|
+
const globalFormat = program.opts().format;
|
|
240
|
+
const format = globalFormat === "json" ? "json" : "table";
|
|
241
|
+
const branch = detectBranch(opts.branch);
|
|
242
|
+
const compileOpts = compileOptions(branch, opts.dryRun);
|
|
243
|
+
if (shouldValidateLocalProductYaml(productArg) &&
|
|
244
|
+
!validateLocalProductYamlBeforeRemoteCompile(format)) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (branch && CI) {
|
|
248
|
+
console.log(`::notice::Compiling against branch '${branch}'`);
|
|
249
|
+
}
|
|
250
|
+
// Fast path for CI: product-scoped maker token with no argument.
|
|
251
|
+
// Server auto-resolves product from the token — no product.yaml,
|
|
252
|
+
// no slug lookup, single API call.
|
|
253
|
+
if (client.isMakerToken() && !productArg) {
|
|
254
|
+
try {
|
|
255
|
+
const result = await client.managementCompileSelf(compileOpts);
|
|
256
|
+
handleResult(result, format);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
const msg = err instanceof Error ? err.message : "Compilation check failed";
|
|
261
|
+
if (CI)
|
|
262
|
+
console.log(`::error::${msg}`);
|
|
263
|
+
output.error(msg);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const productId = await resolveProductId(client, productArg);
|
|
269
|
+
if (!productId) {
|
|
270
|
+
const hint = productArg
|
|
271
|
+
? `Product "${productArg}" not found. Check the name and try again.`
|
|
272
|
+
: "No product specified and no product.yaml found.\n" +
|
|
273
|
+
" Run from inside a product repo, or pass the slug: farthershore apply my-api";
|
|
274
|
+
output.error(hint);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const result = client.isMakerToken()
|
|
280
|
+
? await client.managementCompile(productId, {
|
|
281
|
+
...compileOpts,
|
|
282
|
+
})
|
|
283
|
+
: await client.compileProduct(productId, {
|
|
284
|
+
...compileOpts,
|
|
285
|
+
});
|
|
286
|
+
handleResult(result, format);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const msg = err instanceof Error ? err.message : "Compilation check failed";
|
|
290
|
+
if (CI)
|
|
291
|
+
console.log(`::error::${msg}`);
|
|
292
|
+
output.error(msg);
|
|
293
|
+
process.exitCode = 1;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|