@farthershore/product 0.1.0 → 0.2.1

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
@@ -1,9 +1,12 @@
1
1
  # @farthershore/product
2
2
 
3
- Product-as-Code SDK for Farther Shore. Builder repos use this package in
4
- `product.config.ts` to declare product contracts in TypeScript. Builders author
5
- and export a `Business`; the Farther Shore GitHub bot checks and publishes those
6
- declarations when repo changes land.
3
+ Product-as-Code SDK for Farther Shore. Builder repos use this package from
4
+ `product/product.config.ts` to declare product contracts in TypeScript. Builders
5
+ author and export a `Business`; Farther Shore compiles that program to
6
+ backend-owned IR, validates it, and applies it through Core. Every product is
7
+ created with a GitHub repo that contains the editable `frontend/` starter and
8
+ the Product SDK entrypoint; connecting GitHub is a product-creation
9
+ precondition.
7
10
 
8
11
  ## Install
9
12
 
@@ -12,12 +15,37 @@ pnpm add @farthershore/product
12
15
  ```
13
16
 
14
17
  Builder repos generated by Farther Shore already pin this dependency in
15
- `package.json`.
18
+ `product/package.json`.
19
+
20
+ ## Repo Layout
21
+
22
+ ```text
23
+ product/
24
+ package.json
25
+ product.config.ts
26
+ meters.ts
27
+ plans/
28
+ free.ts
29
+ pro.ts
30
+ routes/
31
+ api.ts
32
+ frontend/
33
+ ...
34
+ ```
35
+
36
+ `product/` is authored Product SDK code and may be split into any imported files
37
+ the builder wants. `frontend/` is the generated editable starter app and is built
38
+ by the frontend pipeline only. Runtime state, accepted specs, compilation
39
+ records, deployment runs, and compiled IR are not checked into the builder repo;
40
+ Farther Shore stores them in Core.
16
41
 
17
42
  ## Example
18
43
 
19
44
  ```ts
20
45
  import { fs } from "@farthershore/product";
46
+ import { configureMeters } from "./meters.js";
47
+ import { configureCronRoutes } from "./routes/cron.js";
48
+ import { configurePlans } from "./plans/index.js";
21
49
 
22
50
  const business = fs.business("croncloud", {
23
51
  baseUrl: "https://api.example.com",
@@ -25,71 +53,65 @@ const business = fs.business("croncloud", {
25
53
  description: "Managed cron jobs",
26
54
  });
27
55
 
28
- business.meter("requests", { display: "Requests" });
56
+ business.use(configureMeters, configureCronRoutes, configurePlans);
29
57
 
30
- business.resource("cron_jobs", {
31
- display: "Cron jobs",
32
- countSource: "action_inferred",
33
- });
34
-
35
- const cron = business.capability("managed-cron", {
36
- title: "Managed Cron Jobs",
37
- });
38
-
39
- const jobs = business.feature("cron-jobs", {
40
- description: "Cron job CRUD",
41
- capabilities: [cron],
42
- routes: [{ match: "GET /v1/cron-jobs", meters: ["requests"] }],
43
- });
44
-
45
- const createJob = jobs.action("cron-job.create", {
46
- kind: "mutation",
47
- title: "Create cron job",
48
- resource: { resource: "cron_jobs", effect: "create" },
49
- });
50
-
51
- jobs.route("POST /v1/cron-jobs", {
52
- meters: ["requests"],
53
- action: createJob,
54
- });
58
+ export default business;
59
+ ```
55
60
 
56
- business.plan("starter", {
57
- name: "Starter",
58
- price: fs.price.monthly(29),
59
- grants: [cron.enable({ limits: { cron_jobs: 10 } })],
60
- limits: [
61
- {
62
- dimension: "requests",
63
- window: { type: "named", name: "minute" },
64
- capacity: 600,
65
- enforcement: "enforce",
66
- },
67
- ],
68
- });
61
+ Modules are plain synchronous functions:
69
62
 
70
- export default business;
63
+ ```ts
64
+ import { fs, type ProductModule } from "@farthershore/product";
65
+
66
+ export const configureMeters: ProductModule = (product) => {
67
+ product.meter("requests", { display: "Requests" });
68
+ };
69
+
70
+ export const configurePlans: ProductModule = (product) => {
71
+ product.plan("starter", {
72
+ name: "Starter",
73
+ price: fs.price.monthly(29),
74
+ limits: [
75
+ {
76
+ dimension: "requests",
77
+ window: { type: "named", name: "minute" },
78
+ capacity: 600,
79
+ enforcement: "enforce",
80
+ },
81
+ ],
82
+ });
83
+ };
71
84
  ```
72
85
 
73
- ## Bot build
86
+ ## Lifecycle apply paths
74
87
 
75
- Normal product repos do not call platform release or IR APIs. The GitHub bot
76
- owns that workflow:
88
+ Generated product repos use GitHub as the required automation and frontend
89
+ workspace:
77
90
 
78
- 1. Loads `product.config.ts`.
91
+ 1. Loads `product/product.config.ts`.
79
92
  2. Requires the default export to be the `Business` returned by
80
93
  `fs.business(...)` or `fs.product(...)`.
81
- 3. Compiles the business into deterministic Manifest IR.
94
+ 3. Executes imported modules and compiles the business into deterministic
95
+ Manifest IR.
82
96
  4. Validates the result against the deployed platform contract.
83
97
  5. Publishes the accepted release through Core so edge artifacts propagate.
84
98
 
85
- The bundled `farthershore-manifest-build` binary is bot/build-runner plumbing.
86
- Run it locally only when debugging the automation itself; it is not part of the
87
- normal builder workflow.
99
+ The same IR can also be applied to an already repo-backed product through
100
+ trusted Core APIs, for example `farthershore build --apply <product>`. That path
101
+ is useful for local validation/apply loops, but it is not a replacement for the
102
+ product repo. The repo remains the required frontend customization workspace
103
+ because `frontend/` is where the starter UI and all custom React code live.
104
+
105
+ The bundled `farthershore-manifest-build` binary is shared by the bot,
106
+ build-runner, and CLI. It emits the deterministic Manifest IR envelope that Core
107
+ accepts; Core, not the user repo, remains the lifecycle authority.
88
108
 
89
109
  ## Public API
90
110
 
91
111
  - `fs.business(name, options)` / `fs.product(name, options)` — create the
92
112
  product builder.
113
+ - `business.use(...modules)` — compose Product SDK modules from any files under
114
+ `product/`.
93
115
  - `business.meter(...)` — declare billable or enforceable dimensions.
94
116
  - `business.resource(...)` — declare counted resources for resource-count
95
117
  constraints.
@@ -99,16 +121,16 @@ normal builder workflow.
99
121
  - `business.policy(...)` — declare policy files in code.
100
122
  - `business.plan(...)` — declare plan pricing, limits, grants, and lifecycle
101
123
  behavior.
102
- - `business.frontend.*` — declare generated portal navigation and gates.
103
124
  - `business.lifecycle.*` — declare migrations.
104
125
  - `business.raw.*` — escape hatches for platform-schema JSON when the typed SDK
105
126
  does not yet have sugar.
106
127
 
107
128
  ## Determinism
108
129
 
109
- `product.config.ts` must be deterministic: no dates, randomness, network calls,
110
- or process state. Sorted collections produce stable output, while route order
111
- remains semantic because the gateway matcher is first-match-wins.
130
+ `product/product.config.ts` and everything it imports must be deterministic: no
131
+ dates, randomness, network calls, or process state. Sorted collections produce
132
+ stable output, while route order remains semantic because the gateway matcher is
133
+ first-match-wins.
112
134
 
113
135
  The GitHub bot runs the build twice and rejects the push if the two generated
114
136
  hashes differ.
package/dist/bin.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { createRequire as __createRequire } from "node:module";const require=__createRequire(import.meta.url);
3
3
 
4
4
  // src/bin.ts
5
- import { writeFile } from "node:fs/promises";
5
+ import { access, writeFile } from "node:fs/promises";
6
+ import { constants } from "node:fs";
6
7
  import { resolve } from "node:path";
7
8
  import { pathToFileURL } from "node:url";
8
9
  import { tsImport } from "tsx/esm/api";
@@ -1398,7 +1399,7 @@ var productCustomerContextSchema = z13.object({
1398
1399
  identity_requirement: customerIdentityRequirementSchema.optional(),
1399
1400
  /**
1400
1401
  * Enables signed customer-context tokens (`fsc_*`) without putting the
1401
- * runtime signing secret in product.config.ts. Core generates/preserves the
1402
+ * runtime signing secret in product/product.config.ts. Core generates/preserves the
1402
1403
  * secret when this is true and clears it when explicitly false.
1403
1404
  */
1404
1405
  context_tokens: z13.object({
@@ -1447,10 +1448,9 @@ var productSpecSchema = z13.object({
1447
1448
  policies: productOperatorPoliciesSchema,
1448
1449
  customer_context: productCustomerContextSchema.optional(),
1449
1450
  /**
1450
- * Track B4 — Declarative frontend surface. Product code can declare
1451
- * template-owned nav/pages composed from known portal components. The
1452
- * template renders these for non-reserved paths; contractual dashboard
1453
- * sections remain template-owned.
1451
+ * Legacy/internal declarative frontend surface. New product repos customize
1452
+ * the generated `frontend/` project directly; this remains for existing IR
1453
+ * and internal template metadata.
1454
1454
  */
1455
1455
  frontend: frontendManifestSchema.optional(),
1456
1456
  migrations: migrationDeclsSchema.optional(),
@@ -1574,7 +1574,7 @@ var productSpecSchema = z13.object({
1574
1574
  *
1575
1575
  * Once a product has compiled with a `webhooks` block, API mutations
1576
1576
  * on those endpoints fail with `409 MANAGED_BY_CODE` — see
1577
- * `core/src/routes/management-webhooks.ts`. Tie-breaker: product.config.ts
1577
+ * `core/src/routes/management-webhooks.ts`. Tie-breaker: product/product.config.ts
1578
1578
  * wins over API state if an endpoint id appears in both.
1579
1579
  */
1580
1580
  webhooks: webhooksBlockSchema.optional(),
@@ -2360,7 +2360,7 @@ function hashIr(ir) {
2360
2360
  }
2361
2361
 
2362
2362
  // src/version.ts
2363
- var SDK_VERSION = true ? "0.1.0" : "0.0.0-dev";
2363
+ var SDK_VERSION = true ? "0.2.1" : "0.0.0-dev";
2364
2364
 
2365
2365
  // src/business.ts
2366
2366
  var BUSINESS_BRAND = Symbol.for("farthershore.product.business");
@@ -2726,6 +2726,12 @@ var Business = class {
2726
2726
  this.graph.register("plan", key, spec, this.planDependsOn(spec));
2727
2727
  return { kind: "plan", key };
2728
2728
  }
2729
+ use(...modules) {
2730
+ for (const module of modules) {
2731
+ module(this);
2732
+ }
2733
+ return this;
2734
+ }
2729
2735
  /** Inspect the SDK-local declaration graph used to emit the Manifest IR. */
2730
2736
  resourceGraph() {
2731
2737
  return this.graph.snapshot();
@@ -3009,9 +3015,13 @@ function describeResourceUrn(urn) {
3009
3015
  }
3010
3016
 
3011
3017
  // src/bin.ts
3018
+ var DEFAULT_ENTRY_CANDIDATES = [
3019
+ "product/product.config.ts",
3020
+ "product.config.ts"
3021
+ ];
3012
3022
  function parseArgs(argv) {
3013
3023
  const args = {
3014
- entry: "product.config.ts",
3024
+ entry: null,
3015
3025
  out: "manifest-ir.json",
3016
3026
  diagnosticsOut: "manifest-diagnostics.json"
3017
3027
  };
@@ -3029,13 +3039,35 @@ function parseArgs(argv) {
3029
3039
  i++;
3030
3040
  } else if (flag === "--help" || flag === "-h") {
3031
3041
  process.stdout.write(
3032
- "Usage: farthershore-manifest-build [--entry product.config.ts] [--out manifest-ir.json] [--diagnostics-out manifest-diagnostics.json]\n"
3042
+ "Usage: farthershore-manifest-build [--entry product/product.config.ts] [--out manifest-ir.json] [--diagnostics-out manifest-diagnostics.json]\n"
3033
3043
  );
3034
3044
  process.exit(0);
3035
3045
  }
3036
3046
  }
3037
3047
  return args;
3038
3048
  }
3049
+ async function fileExists(path) {
3050
+ try {
3051
+ await access(path, constants.R_OK);
3052
+ return true;
3053
+ } catch {
3054
+ return false;
3055
+ }
3056
+ }
3057
+ async function resolveEntry(args) {
3058
+ if (args.entry) {
3059
+ return { entry: args.entry, entryPath: resolve(process.cwd(), args.entry) };
3060
+ }
3061
+ for (const candidate of DEFAULT_ENTRY_CANDIDATES) {
3062
+ const entryPath = resolve(process.cwd(), candidate);
3063
+ if (await fileExists(entryPath)) {
3064
+ return { entry: candidate, entryPath };
3065
+ }
3066
+ }
3067
+ throw new Error(
3068
+ `no Product SDK entry found; expected ${DEFAULT_ENTRY_CANDIDATES.join(" or ")}`
3069
+ );
3070
+ }
3039
3071
  function validationIssues(error) {
3040
3072
  if (error instanceof ManifestValidationError) return error.issues;
3041
3073
  if (typeof error === "object" && error !== null && error.name === "ManifestValidationError" && Array.isArray(error.issues)) {
@@ -3048,16 +3080,25 @@ function errorMessage(error) {
3048
3080
  }
3049
3081
  async function main() {
3050
3082
  const args = parseArgs(process.argv.slice(2));
3051
- const entryPath = resolve(process.cwd(), args.entry);
3083
+ let resolvedEntry;
3084
+ try {
3085
+ resolvedEntry = await resolveEntry(args);
3086
+ } catch (error) {
3087
+ process.stderr.write(
3088
+ `farthershore-manifest-build: ${errorMessage(error)}
3089
+ `
3090
+ );
3091
+ process.exit(1);
3092
+ }
3052
3093
  let mod;
3053
3094
  try {
3054
3095
  mod = await tsImport(
3055
- pathToFileURL(entryPath).href,
3096
+ pathToFileURL(resolvedEntry.entryPath).href,
3056
3097
  import.meta.url
3057
3098
  );
3058
3099
  } catch (error) {
3059
3100
  process.stderr.write(
3060
- `farthershore-manifest-build: failed to load ${args.entry}: ${error instanceof Error ? error.message : String(error)}
3101
+ `farthershore-manifest-build: failed to load ${resolvedEntry.entry}: ${error instanceof Error ? error.message : String(error)}
3061
3102
  `
3062
3103
  );
3063
3104
  process.exit(1);
@@ -3067,7 +3108,7 @@ async function main() {
3067
3108
  const candidate = isBusiness(direct) ? direct : interop;
3068
3109
  if (!isBusiness(candidate)) {
3069
3110
  process.stderr.write(
3070
- `farthershore-manifest-build: ${args.entry} must \`export default\` the Business returned by fs.business(\u2026)
3111
+ `farthershore-manifest-build: ${resolvedEntry.entry} must \`export default\` the Business returned by fs.business(\u2026)
3071
3112
  `
3072
3113
  );
3073
3114
  process.exit(1);
package/dist/index.js CHANGED
@@ -1391,7 +1391,7 @@ var productCustomerContextSchema = z13.object({
1391
1391
  identity_requirement: customerIdentityRequirementSchema.optional(),
1392
1392
  /**
1393
1393
  * Enables signed customer-context tokens (`fsc_*`) without putting the
1394
- * runtime signing secret in product.config.ts. Core generates/preserves the
1394
+ * runtime signing secret in product/product.config.ts. Core generates/preserves the
1395
1395
  * secret when this is true and clears it when explicitly false.
1396
1396
  */
1397
1397
  context_tokens: z13.object({
@@ -1440,10 +1440,9 @@ var productSpecSchema = z13.object({
1440
1440
  policies: productOperatorPoliciesSchema,
1441
1441
  customer_context: productCustomerContextSchema.optional(),
1442
1442
  /**
1443
- * Track B4 — Declarative frontend surface. Product code can declare
1444
- * template-owned nav/pages composed from known portal components. The
1445
- * template renders these for non-reserved paths; contractual dashboard
1446
- * sections remain template-owned.
1443
+ * Legacy/internal declarative frontend surface. New product repos customize
1444
+ * the generated `frontend/` project directly; this remains for existing IR
1445
+ * and internal template metadata.
1447
1446
  */
1448
1447
  frontend: frontendManifestSchema.optional(),
1449
1448
  migrations: migrationDeclsSchema.optional(),
@@ -1567,7 +1566,7 @@ var productSpecSchema = z13.object({
1567
1566
  *
1568
1567
  * Once a product has compiled with a `webhooks` block, API mutations
1569
1568
  * on those endpoints fail with `409 MANAGED_BY_CODE` — see
1570
- * `core/src/routes/management-webhooks.ts`. Tie-breaker: product.config.ts
1569
+ * `core/src/routes/management-webhooks.ts`. Tie-breaker: product/product.config.ts
1571
1570
  * wins over API state if an endpoint id appears in both.
1572
1571
  */
1573
1572
  webhooks: webhooksBlockSchema.optional(),
@@ -2356,7 +2355,7 @@ function canonicalIrJson(ir) {
2356
2355
  }
2357
2356
 
2358
2357
  // src/version.ts
2359
- var SDK_VERSION = true ? "0.1.0" : "0.0.0-dev";
2358
+ var SDK_VERSION = true ? "0.2.1" : "0.0.0-dev";
2360
2359
 
2361
2360
  // src/business.ts
2362
2361
  var BUSINESS_BRAND = Symbol.for("farthershore.product.business");
@@ -2722,6 +2721,12 @@ var Business = class {
2722
2721
  this.graph.register("plan", key, spec, this.planDependsOn(spec));
2723
2722
  return { kind: "plan", key };
2724
2723
  }
2724
+ use(...modules) {
2725
+ for (const module of modules) {
2726
+ module(this);
2727
+ }
2728
+ return this;
2729
+ }
2725
2730
  /** Inspect the SDK-local declaration graph used to emit the Manifest IR. */
2726
2731
  resourceGraph() {
2727
2732
  return this.graph.snapshot();
@@ -2975,8 +2980,10 @@ var Business = class {
2975
2980
  function isBusiness(value) {
2976
2981
  return typeof value === "object" && value !== null && value[BUSINESS_BRAND] === true;
2977
2982
  }
2978
- function business(name, options) {
2979
- return new Business(name, options);
2983
+ function business(name, options, configure) {
2984
+ const product2 = new Business(name, options);
2985
+ if (configure) product2.use(configure);
2986
+ return product2;
2980
2987
  }
2981
2988
  function isPlainObject(value) {
2982
2989
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -191,6 +191,11 @@ export type PlanCapabilityGrant = {
191
191
  readonly capability: string;
192
192
  readonly limits?: Record<string, number | boolean>;
193
193
  };
194
+ /**
195
+ * A synchronous Product SDK module. Use this to split a product across files:
196
+ * `product.use(configureMeters, configureRoutes, configurePlans)`.
197
+ */
198
+ export type ProductModule = (product: Business) => void;
194
199
  export declare class Business {
195
200
  readonly [BUSINESS_BRAND] = true;
196
201
  readonly name: string;
@@ -231,6 +236,7 @@ export declare class Business {
231
236
  feature(key: string, options?: FeatureOptions): FeatureRef;
232
237
  policy(name: string, options: PolicyOptions): PolicyRef;
233
238
  plan(key: string, options: PlanOptions): PlanRef;
239
+ use(...modules: ProductModule[]): Business;
234
240
  /** Inspect the SDK-local declaration graph used to emit the Manifest IR. */
235
241
  resourceGraph(): ManifestResourceGraphSnapshot;
236
242
  /** Assemble + validate the Manifest IR. Throws ManifestValidationError
@@ -260,4 +266,4 @@ export declare class Business {
260
266
  private existingDependenciesFor;
261
267
  }
262
268
  export declare function isBusiness(value: unknown): value is Business;
263
- export declare function business(name: string, options: BusinessOptions): Business;
269
+ export declare function business(name: string, options: BusinessOptions, configure?: ProductModule): Business;
@@ -14,7 +14,7 @@ export { price } from "./price.js";
14
14
  export { validateManifestIr, hashIr, canonicalIrJson } from "./validate.js";
15
15
  export { ManifestValidationError, ManifestBuilderError } from "./errors.js";
16
16
  export { SDK_VERSION } from "./version.js";
17
- export type { BusinessOptions, MeterOptions, ResourceOptions, CapabilityOptions, ActionOptions, FrontendNavItemOptions, FrontendPageOptions, FrontendComponentOptions, MigrationOptions, FeatureOptions, RouteOptions, PolicyOptions, PlanOptions, MeterRef, ResourceRef, ActionRef, PolicyRef, PlanRef, FeatureRef, CapabilityRef, PlanCapabilityGrant, } from "./business.js";
17
+ export type { BusinessOptions, MeterOptions, ResourceOptions, CapabilityOptions, ActionOptions, FrontendNavItemOptions, FrontendPageOptions, FrontendComponentOptions, MigrationOptions, FeatureOptions, RouteOptions, PolicyOptions, PlanOptions, MeterRef, ResourceRef, ActionRef, PolicyRef, PlanRef, FeatureRef, CapabilityRef, PlanCapabilityGrant, ProductModule, } from "./business.js";
18
18
  export type { ManifestResourceGraphSnapshot, ManifestResourceKind, ManifestResourceUrn, } from "./resource-graph.js";
19
19
  export type { PriceSpec } from "./price.js";
20
20
  export type { ManifestIssue } from "./errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/product",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Farther Shore product-as-code SDK — declare your business in TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",