@granular-software/sdk 0.4.13 → 0.4.14

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/dist/cli/index.js CHANGED
@@ -3440,7 +3440,7 @@ var require_main = __commonJS({
3440
3440
  var os2 = __require("os");
3441
3441
  var crypto = __require("crypto");
3442
3442
  var packageJson = require_package();
3443
- var version = packageJson.version;
3443
+ var version2 = packageJson.version;
3444
3444
  var LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg;
3445
3445
  function parse(src) {
3446
3446
  const obj = {};
@@ -3489,13 +3489,13 @@ var require_main = __commonJS({
3489
3489
  return DotenvModule.parse(decrypted);
3490
3490
  }
3491
3491
  function _warn(message) {
3492
- console.log(`[dotenv@${version}][WARN] ${message}`);
3492
+ console.log(`[dotenv@${version2}][WARN] ${message}`);
3493
3493
  }
3494
3494
  function _debug(message) {
3495
- console.log(`[dotenv@${version}][DEBUG] ${message}`);
3495
+ console.log(`[dotenv@${version2}][DEBUG] ${message}`);
3496
3496
  }
3497
3497
  function _log(message) {
3498
- console.log(`[dotenv@${version}] ${message}`);
3498
+ console.log(`[dotenv@${version2}] ${message}`);
3499
3499
  }
3500
3500
  function _dotenvKey(options) {
3501
3501
  if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) {
@@ -6530,7 +6530,8 @@ async function main() {
6530
6530
 
6531
6531
  // Connect as one app user so the SDK can create an environment for the seed run.
6532
6532
  const env = await granular.connect({
6533
- sandbox: SANDBOX_ID,
6533
+ ontology: SANDBOX_ID,
6534
+ environment: 'dev',
6534
6535
  userId: '${template.seedUser.userId}',
6535
6536
  name: '${template.seedUser.name}',
6536
6537
  email: '${template.seedUser.email}',
@@ -6866,15 +6867,72 @@ var ApiClient = class {
6866
6867
  );
6867
6868
  return result.items;
6868
6869
  }
6870
+ async listVersions(sandboxId) {
6871
+ const result = await this.request(
6872
+ `/control/sandboxes/${sandboxId}/versions`
6873
+ );
6874
+ return result.items;
6875
+ }
6869
6876
  async getBuild(buildId) {
6870
6877
  return this.request(`/control/builds/${buildId}`);
6871
6878
  }
6879
+ async getVersion(versionId) {
6880
+ return this.request(`/control/versions/${versionId}`);
6881
+ }
6872
6882
  async triggerBuild(sandboxId, manifestId) {
6873
6883
  return this.request(`/control/sandboxes/${sandboxId}/builds`, {
6874
6884
  method: "POST",
6875
6885
  body: JSON.stringify({ manifestId })
6876
6886
  });
6877
6887
  }
6888
+ async createVersion(sandboxId, manifestId) {
6889
+ return this.triggerBuild(sandboxId, manifestId);
6890
+ }
6891
+ async listTags(sandboxId) {
6892
+ const result = await this.request(
6893
+ `/control/sandboxes/${sandboxId}/tags`
6894
+ );
6895
+ return result.items.map((tag2) => ({
6896
+ ...tag2,
6897
+ targetVersionId: tag2.targetVersionId ?? tag2.targetBuildId ?? null
6898
+ }));
6899
+ }
6900
+ async moveTag(tagId, targetBuildId) {
6901
+ const moved = await this.request(`/control/tags/${tagId}/move`, {
6902
+ method: "POST",
6903
+ body: JSON.stringify({ targetBuildId })
6904
+ });
6905
+ return {
6906
+ ...moved,
6907
+ targetVersionId: moved.targetVersionId ?? moved.targetBuildId ?? null
6908
+ };
6909
+ }
6910
+ async moveVersionTag(tagId, targetVersionId) {
6911
+ return this.moveTag(tagId, targetVersionId);
6912
+ }
6913
+ async promoteToProd(buildId) {
6914
+ return this.request(`/control/builds/${buildId}/promote`, {
6915
+ method: "POST"
6916
+ });
6917
+ }
6918
+ async promoteVersionToProd(versionId) {
6919
+ return this.request(`/control/versions/${versionId}/promote`, {
6920
+ method: "POST"
6921
+ });
6922
+ }
6923
+ async getBuildDiff(buildId, againstBuildId) {
6924
+ const query = againstBuildId ? `?against=${encodeURIComponent(againstBuildId)}` : "";
6925
+ return this.request(`/control/builds/${buildId}/diff${query}`);
6926
+ }
6927
+ async getVersionDiff(versionId, againstVersionId) {
6928
+ const query = againstVersionId ? `?against=${encodeURIComponent(againstVersionId)}` : "";
6929
+ const result = await this.request(`/control/versions/${versionId}/diff${query}`);
6930
+ return {
6931
+ versionId: result.buildId,
6932
+ againstVersionId: result.againstBuildId,
6933
+ diff: result.diff
6934
+ };
6935
+ }
6878
6936
  async waitForBuild(buildId, onStatus, timeoutMs = 12e4) {
6879
6937
  const start = Date.now();
6880
6938
  while (Date.now() - start < timeoutMs) {
@@ -6891,6 +6949,9 @@ var ApiClient = class {
6891
6949
  }
6892
6950
  throw new Error(`Build timed out after ${Math.round(timeoutMs / 1e3)}s`);
6893
6951
  }
6952
+ async waitForVersionBuild(versionId, onStatus, timeoutMs = 12e4) {
6953
+ return this.waitForBuild(versionId, onStatus, timeoutMs);
6954
+ }
6894
6955
  // ── Permission Profiles ──
6895
6956
  async createPermissionProfile(sandboxId, data) {
6896
6957
  return this.request(
@@ -7542,10 +7603,10 @@ function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
7542
7603
  return 3;
7543
7604
  }
7544
7605
  if ("TERM_PROGRAM" in env) {
7545
- const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
7606
+ const version2 = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
7546
7607
  switch (env.TERM_PROGRAM) {
7547
7608
  case "iTerm.app": {
7548
- return version >= 3 ? 3 : 2;
7609
+ return version2 >= 3 ? 3 : 2;
7549
7610
  }
7550
7611
  case "Apple_Terminal": {
7551
7612
  return 2;
@@ -8595,7 +8656,7 @@ var brand = {
8595
8656
  function printHeader() {
8596
8657
  console.log();
8597
8658
  console.log(brand.primary.bold(" \u25A0 Granular"));
8598
- console.log(brand.muted(" Build AI sandboxes from code"));
8659
+ console.log(brand.muted(" Version and run ontologies from code"));
8599
8660
  console.log();
8600
8661
  }
8601
8662
  function success(message) {
@@ -8777,10 +8838,11 @@ The rest of this guide assumes these terms.
8777
8838
  | Term | What it means |
8778
8839
  |------|----------------|
8779
8840
  | **Sandbox** | A **workspace** on Granular (\`sbx_\u2026\`). It stores the **compiled domain** and **your records** for one project/environment. Not a browser sandbox \u2014 think \u201Ctenant + schema + data\u201D for this app. |
8780
- | **Domain** | The **ontology**: **classes**, **relationships**, and **effects**. Declared in \`granular.json\`, installed on the sandbox by **build**. Same idea as *schema* / *model*. |
8781
- | **Manifest** | \`granular.json\` (definition under \`manifest\`). **Source** in the repo until you **build**. |
8782
- | **Build** | \`granular build\` \u2014 uploads and compiles the manifest. Primary **validation**; fixes errors before relying on types. |
8783
- | **Environment** | Return value of \`granular.connect({ sandbox, \u2026 })\`. Your **server-side** handle for \`recordObject\`, \`submitJob\`, \`graphql\`, etc., bound to one sandbox. |
8841
+ | **Domain** | The **ontology**: **classes**, **relationships**, and **effects**. Declared in \`granular.json\`, installed on the sandbox by creating a **version**. Same idea as *schema* / *model*. |
8842
+ | **Manifest** | \`granular.json\` (definition under \`manifest\`). **Source** in the repo until you create a new ontology **version** from it. |
8843
+ | **Version** | Immutable ontology state derived from one manifest revision. Tags like \`dev\` and \`prod\` point to versions. |
8844
+ | **Build run** | The CI-style compilation process. \`granular build\` creates or reuses the ontology version, then runs a build for it. |
8845
+ | **Environment** | Return value of \`granular.connect({ ontology, environment, \u2026 })\`. It is your **server-side** handle for \`recordObject\`, \`submitJob\`, \`graphql\`, etc., bound to one ontology environment slot. |
8784
8846
  | **Session** | SDK base type; \`Environment\` **extends** \`Session\`. Job/prompt APIs live on \`Session\`; you still use the **\`Environment\`** instance from \`connect()\` in normal apps. |
8785
8847
  | **Job** | Code string passed to \`environment.submitJob(code)\`. Runs in Granular\u2019s **sandbox runtime** with access to \`./sandbox-tools\` (generated classes + effect entrypoints). |
8786
8848
  | **Effect** | Declared with \`withEffect\`; **handler** registered with \`registerEffects\` in **your** process. Jobs invoke effects; handlers do IO outside Granular. |
@@ -8801,9 +8863,9 @@ function manifestGuideEndToEndSection() {
8801
8863
  | Step | Action | Outcome |
8802
8864
  |------|--------|---------|
8803
8865
  | 1 | Edit \`granular.json\` (\`manifest\` \u2192 \`volumes\` \u2192 \`operations\`) | Domain **source** in the repo |
8804
- | 2 | \`granular build\` | Manifest **uploaded**; domain **compiled** for the configured sandbox; errors surface here |
8866
+ | 2 | \`granular build\` | Manifest **uploaded**; ontology version created or reused; build run compiles it; errors surface here |
8805
8867
  | 3 | (Optional) Run your **effects host** \u2014 e.g. \`npx tsx granular-effects.ts\` \u2014 calling \`registerEffects(sandboxId, \u2026)\` | Effect **handlers** attached in **your** process |
8806
- | 4 | \`new Granular({ apiKey })\` then \`connect({ sandbox, userId, permissions })\` | **\`Environment\`** for that sandbox |
8868
+ | 4 | \`new Granular({ apiKey })\` then \`connect({ ontology, environment, userId, permissions })\` | **\`Environment\`** for that ontology environment slot |
8807
8869
  | 5 | \`recordObject\` / \`recordObjects\` | **Records** stored with correct fields and relationship keys |
8808
8870
  | 6 | \`submitJob(\`\u2026\`)\` with imports from \`./sandbox-tools\` | **Jobs** run; may call **effects** \u2192 your handlers execute and return results |
8809
8871
 
@@ -8815,8 +8877,10 @@ function sandboxDocMainConceptsSection() {
8815
8877
  | Term | One line |
8816
8878
  |------|----------|
8817
8879
  | **Sandbox** | Workspace \`sbx_\u2026\` \u2014 **domain** + **data** for this project. |
8818
- | **Domain / ontology** | Classes + relationships + effects \u2014 **built** from \`granular.json\`. |
8880
+ | **Domain / ontology** | Classes + relationships + effects \u2014 materialized from versioned manifests. |
8819
8881
  | **Manifest** | \`granular.json\` \u2014 **source**; edit here, then \`granular build\`. |
8882
+ | **Version** | Immutable ontology state created from one manifest revision. |
8883
+ | **Build run** | Compilation run that validates and materializes a version. |
8820
8884
  | **Environment** | \`connect()\` result \u2014 **record**, **submitJob**, **graphql** for this sandbox. |
8821
8885
  | **Session** | Base type; \`Environment\` extends it (job APIs). |
8822
8886
  | **Job** | \`submitJob\` code using \`./sandbox-tools\`. |
@@ -9103,7 +9167,7 @@ Example:
9103
9167
  | \`isStatic\` | \`true\` \u2192 static method; \`false\` or omit \u2192 **instance** method (handler receives the object **id** first). |
9104
9168
  | \`inputSchema\` / \`outputSchema\` | JSON Schema; used for codegen and validation. |
9105
9169
 
9106
- **Lifecycle:** declare in manifest \u2192 \`granular build\` \u2192 implement handlers \u2192 \`granular.registerEffects(sandboxId, [...])\` \u2192 jobs call generated methods \u2192 your handler runs and returns the result to the job.
9170
+ **Lifecycle:** declare in manifest \u2192 \`granular build\` creates or reuses a version and runs the build \u2192 implement handlers \u2192 \`granular.registerEffects(sandboxId, [...])\` \u2192 jobs call generated methods \u2192 your handler runs and returns the result to the job.
9107
9171
 
9108
9172
  **Permissions:** \`connect({ permissions: [...] })\` assigns profiles (e.g. \`default\`) that can allow or deny which effects a user may call.
9109
9173
 
@@ -9172,7 +9236,7 @@ ${effectMetamodelTable}
9172
9236
  |-----|---------|
9173
9237
  | \`new Granular({ apiKey, apiUrl?, endpointMode?, token?, tokenProvider?, \u2026 })\` | Auth. Env: \`GRANULAR_API_KEY\`, \`GRANULAR_API_URL\`, \`GRANULAR_ENDPOINT_MODE\`. |
9174
9238
  | \`recordUser({ userId, name?, email?, permissions? })\` | Upsert user for later \`connect\`. |
9175
- | \`connect({ sandbox, userId?, granularId?, user?, permissions?, clientId?, initialHeap? })\` | Opens a session \u2192 **\`Environment\`**. |
9239
+ | \`connect({ ontology, environment, tagName?, userId?, granularId?, user?, permissions?, clientId?, initialHeap? })\` | Opens a session \u2192 **\`Environment\`**. \`tagName\` is an advanced override. |
9176
9240
  | \`registerEffects(sandboxId, effects)\` / \`registerEffect\` | Register handlers for manifest \`withEffect\` declarations. |
9177
9241
  | \`unregisterEffect\` / \`unregisterAllEffects\` / \`disconnectEffects\` | Stop effect handlers for a sandbox. |
9178
9242
  | \`granular.sandboxes\` | \`.list()\`, \`.get\`, \`.create\`, \`.update\`, \`.delete\` |
@@ -9214,14 +9278,17 @@ Use \`environment.graphql(query, variables?)\` when you need **query/mutation ac
9214
9278
 
9215
9279
  | Command | Purpose |
9216
9280
  |---------|---------|
9217
- | \`granular build\` | Upload \`granular.json\` and compile (primary **validation**). |
9218
- | \`granular deploy\` | Build + deploy current production. |
9281
+ | \`granular build\` | Upload \`granular.json\`, create or reuse the matching ontology version, and run the build (**validation**). |
9282
+ | \`granular deploy\` | Push the current revision, build it, and update \`dev\`. |
9283
+ | \`granular deploy --prod\` | If the current version is already on \`dev\`, also point \`prod\` to it. Otherwise build it, then point both \`dev\` and \`prod\` to it. |
9284
+ | \`granular version diff\` | Show the semantic diff between ontology versions. |
9285
+ | \`granular tag move\` | Move \`dev\`, \`prod\`, or another tag to a chosen version. |
9219
9286
  | \`granular dev\` | Watch manifest, rebuild on change. |
9220
9287
  | \`granular document\` | Regenerate \`GRANULAR_SANDBOX.md\`. |
9221
9288
  | \`granular init\` | Scaffold project; optional \`--agent-docs\`. |
9222
9289
  | \`granular add class|field|relation\` | Mutate local manifest. |
9223
9290
  | \`granular simulate\` | Open simulator in browser. |
9224
- | \`granular pull\` | Pull manifest from API. |
9291
+ | \`granular pull <version>\` | Pull the manifest for a specific ontology version. |
9225
9292
  | \`granular login\`, \`granular whoami\`, \`granular status\` | Auth / project info. |
9226
9293
 
9227
9294
  ---
@@ -9230,7 +9297,7 @@ Use \`environment.graphql(query, variables?)\` when you need **query/mutation ac
9230
9297
 
9231
9298
  1. \`granular build\` (or \`granular dev\`).
9232
9299
  2. Align **effect handlers** with every \`withEffect\` name + schemas.
9233
- 3. \`granular document\` to refresh \`GRANULAR_SANDBOX.md\` without a full build if needed (build still records manifest/build ids).
9300
+ 3. \`granular document\` to refresh \`GRANULAR_SANDBOX.md\` without a full build if needed (the last successful version/build metadata still comes from \`granular build\` or \`granular deploy\`).
9234
9301
 
9235
9302
  ---
9236
9303
 
@@ -9315,9 +9382,9 @@ function generateSandboxAgentDoc(manifest, meta) {
9315
9382
  lines.push(`| Sandbox id | \`${meta.sandboxId}\` |`);
9316
9383
  if (meta.apiUrl) lines.push(`| API / WS base | \`${meta.apiUrl}\` |`);
9317
9384
  if (meta.manifestId) lines.push(`| Last uploaded manifest id | \`${meta.manifestId}\` |`);
9318
- if (meta.buildId) lines.push(`| Last build id | \`${meta.buildId}\` |`);
9385
+ if (meta.buildId) lines.push(`| Last version id | \`${meta.buildId}\` |`);
9319
9386
  if (meta.buildPending) {
9320
- lines.push("| Build status | *No successful build recorded in this doc run \u2014 run `granular build`.* |");
9387
+ lines.push("| Version status | *No successful version/build recorded in this doc run \u2014 run `granular build`.* |");
9321
9388
  }
9322
9389
  lines.push("");
9323
9390
  lines.push("Set `GRANULAR_API_KEY` (e.g. in `.env.local`). Optional: `GRANULAR_API_URL` overrides the WebSocket URL.");
@@ -9334,7 +9401,9 @@ function generateSandboxAgentDoc(manifest, meta) {
9334
9401
  lines.push("");
9335
9402
  lines.push("| Command | Purpose |");
9336
9403
  lines.push("|---------|---------|");
9337
- lines.push("| `granular build` | Upload manifest and build |");
9404
+ lines.push("| `granular build` | Create or rebuild the current ontology version |");
9405
+ lines.push("| `granular version diff <version> [base]` | Show the semantic diff between versions |");
9406
+ lines.push("| `granular tag move <tag> <version>` | Move a release tag such as `prod` |");
9338
9407
  lines.push("| `granular document` | Refresh this file from `granular.json` |");
9339
9408
  lines.push("| `npx tsx " + seed + "` | Seed sample records (if present) |");
9340
9409
  lines.push("| `npx tsx " + eff + "` | Run effect handler host (if present) |");
@@ -9350,7 +9419,8 @@ function generateSandboxAgentDoc(manifest, meta) {
9350
9419
  lines.push(`});`);
9351
9420
  lines.push("");
9352
9421
  lines.push(`const env = await granular.connect({`);
9353
- lines.push(` sandbox: '${meta.sandboxId}',`);
9422
+ lines.push(` ontology: '${meta.sandboxId}',`);
9423
+ lines.push(` environment: 'dev',`);
9354
9424
  lines.push(` userId: 'your_app_user_id',`);
9355
9425
  lines.push(` permissions: ['default'],`);
9356
9426
  lines.push(`});`);
@@ -9959,7 +10029,7 @@ async function loginCommand(options = {}) {
9959
10029
  }
9960
10030
 
9961
10031
  // src/cli/commands/pull.ts
9962
- async function pullCommand() {
10032
+ async function pullCommand(versionId) {
9963
10033
  printHeader();
9964
10034
  const config = resolveConfig({ requireApiKey: true });
9965
10035
  if (!config.sandboxId) {
@@ -9967,17 +10037,13 @@ async function pullCommand() {
9967
10037
  process.exit(1);
9968
10038
  }
9969
10039
  const api = new ApiClient(config.apiKey, config.apiUrl);
9970
- const spin = spinner("Fetching manifests...");
10040
+ const spin = spinner(`Fetching version ${versionId}...`);
9971
10041
  try {
9972
- const manifests = await api.listManifests(config.sandboxId);
9973
- if (manifests.length === 0) {
9974
- spin.fail(" No manifests found for this sandbox.");
9975
- hint("granular build", "Upload and build your local manifest");
9976
- return;
9977
- }
9978
- const latest = manifests[0];
10042
+ const version2 = await api.getVersion(versionId);
10043
+ const targetManifestId = version2.manifestId;
10044
+ const targetVersionLabel = version2.versionNumber != null ? `v${version2.versionNumber}` : versionId;
9979
10045
  spin.text = " Downloading manifest content...";
9980
- const full = await api.getManifest(latest.manifestId);
10046
+ const full = await api.getManifest(targetManifestId);
9981
10047
  if (!full.content) {
9982
10048
  spin.fail(" Manifest has no content.");
9983
10049
  return;
@@ -9989,8 +10055,9 @@ async function pullCommand() {
9989
10055
  spin.succeed(" Manifest pulled successfully.");
9990
10056
  console.log();
9991
10057
  keyValue({
9992
- "Manifest ID": latest.manifestId,
9993
- "Version": latest.version || "N/A",
10058
+ "Manifest ID": full.manifestId,
10059
+ "Source Version": versionId,
10060
+ "Version": targetVersionLabel,
9994
10061
  "Written to": "granular.json"
9995
10062
  });
9996
10063
  console.log();
@@ -10019,17 +10086,23 @@ async function buildCommand() {
10019
10086
  uploading.fail(` Failed to upload manifest: ${err.message}`);
10020
10087
  process.exit(1);
10021
10088
  }
10022
- const building = spinner("Triggering build...");
10089
+ const building = spinner("Creating or reusing ontology version from manifest...");
10023
10090
  try {
10024
- const build = await api.triggerBuild(config.sandboxId, uploadedManifest.manifestId);
10025
- building.text = ` Building... ${brand.muted(build.buildId)}`;
10091
+ const version2 = await api.createVersion(config.sandboxId, uploadedManifest.manifestId);
10092
+ building.text = ` Running build for version... ${brand.muted(version2.buildId)}`;
10026
10093
  const startTime = Date.now();
10027
- const completed = await api.waitForBuild(build.buildId, (status) => {
10094
+ const completed = await api.waitForVersionBuild(version2.buildId, (status) => {
10028
10095
  const elapsed = Math.round((Date.now() - startTime) / 1e3);
10029
- building.text = ` Building... ${brand.muted(`${status} (${elapsed}s)`)}`;
10096
+ building.text = ` Running build for version... ${brand.muted(`${status} (${elapsed}s)`)}`;
10030
10097
  });
10031
10098
  const totalTime = Math.round((Date.now() - startTime) / 1e3);
10032
- building.succeed(` Build completed in ${totalTime}s`);
10099
+ building.succeed(` Version ready in ${totalTime}s`);
10100
+ const [tags, diff] = await Promise.all([
10101
+ api.listTags(config.sandboxId),
10102
+ api.getVersionDiff(completed.buildId).catch(() => null)
10103
+ ]);
10104
+ const devTag = tags.find((tag2) => tag2.name === "dev");
10105
+ const prodTag = tags.find((tag2) => tag2.name === "prod");
10033
10106
  writeSandboxAgentDocFile(getProjectRoot(), manifest, {
10034
10107
  sandboxId: config.sandboxId,
10035
10108
  apiUrl: config.apiUrl,
@@ -10038,12 +10111,27 @@ async function buildCommand() {
10038
10111
  });
10039
10112
  console.log();
10040
10113
  keyValue({
10041
- "Build ID": completed.buildId,
10114
+ "Version ID": completed.buildId,
10115
+ "Version Number": completed.versionNumber != null ? String(completed.versionNumber) : "N/A",
10116
+ "Build Run": completed.buildRunId || completed.latestBuildRunId || "N/A",
10042
10117
  "Manifest": uploadedManifest.manifestId,
10043
- "Status": "completed",
10118
+ "Result": completed.createdNewVersion ? "new version created" : "existing version reused, new build run recorded",
10119
+ "Dev Tag": devTag?.targetVersionId === completed.buildId || devTag?.targetBuildId === completed.buildId ? "updated to this version" : devTag?.targetVersionId || devTag?.targetBuildId || "not set",
10120
+ "Prod Tag": prodTag?.targetVersionId || prodTag?.targetBuildId || "not set",
10044
10121
  "Duration": `${totalTime}s`,
10045
10122
  "Agent doc": "GRANULAR_SANDBOX.md"
10046
10123
  });
10124
+ if (diff?.diff?.summary) {
10125
+ console.log();
10126
+ keyValue({
10127
+ "Diff Added": String(diff.diff.summary.added ?? 0),
10128
+ "Diff Changed": String(diff.diff.summary.changed ?? 0),
10129
+ "Diff Removed": String(diff.diff.summary.removed ?? 0),
10130
+ "Transition Safety": diff.diff.summary.onlyAdditiveChanges ? "additive" : "breaking changes detected"
10131
+ });
10132
+ }
10133
+ console.log();
10134
+ info("Common path: keep working in your dev environment while dev follows the newest successful version. Prod only changes when you promote it.");
10047
10135
  console.log();
10048
10136
  } catch (err) {
10049
10137
  building.fail(` Build failed: ${err.message}`);
@@ -10052,7 +10140,7 @@ async function buildCommand() {
10052
10140
  }
10053
10141
 
10054
10142
  // src/cli/commands/deploy.ts
10055
- async function deployCommand() {
10143
+ async function deployCommand(options = {}) {
10056
10144
  printHeader();
10057
10145
  const config = resolveConfig({ requireApiKey: true, requireProject: true });
10058
10146
  if (!config.sandboxId) {
@@ -10060,7 +10148,7 @@ async function deployCommand() {
10060
10148
  process.exit(1);
10061
10149
  }
10062
10150
  const api = new ApiClient(config.apiKey, config.apiUrl);
10063
- step("Deploy", `Sandbox ${config.sandboxId}`);
10151
+ step(options.prod ? "Deploy to dev and prod" : "Deploy to dev", `Sandbox ${config.sandboxId}`);
10064
10152
  console.log();
10065
10153
  const manifest = config.project.manifest;
10066
10154
  const uploading = spinner(`Uploading manifest "${manifest.name}"...`);
@@ -10072,16 +10160,45 @@ async function deployCommand() {
10072
10160
  uploading.fail(` Failed to upload manifest: ${err.message}`);
10073
10161
  process.exit(1);
10074
10162
  }
10075
- const building = spinner("Building...");
10163
+ const building = spinner("Resolving ontology version from manifest...");
10076
10164
  try {
10077
- const build = await api.triggerBuild(config.sandboxId, uploadedManifest.manifestId);
10078
- const startTime = Date.now();
10079
- const completed = await api.waitForBuild(build.buildId, (status) => {
10080
- const elapsed = Math.round((Date.now() - startTime) / 1e3);
10081
- building.text = ` Building... ${brand.muted(`${status} (${elapsed}s)`)}`;
10082
- });
10083
- const totalTime = Math.round((Date.now() - startTime) / 1e3);
10084
- building.succeed(` Build completed in ${totalTime}s`);
10165
+ const [versions, tags] = await Promise.all([
10166
+ api.listVersions(config.sandboxId),
10167
+ api.listTags(config.sandboxId).catch(() => [])
10168
+ ]);
10169
+ const devTag = tags.find((tag2) => tag2.name === "dev");
10170
+ const prodTag = tags.find((tag2) => tag2.name === "prod");
10171
+ const existingVersion = versions.find((version2) => version2.manifestDigest === uploadedManifest.digest) || null;
10172
+ const existingVersionId = existingVersion?.buildId || null;
10173
+ const devVersionId = devTag?.targetVersionId || devTag?.targetBuildId || null;
10174
+ const reuseDevPromotion = Boolean(
10175
+ options.prod && existingVersion && existingVersion.status === "completed" && existingVersionId && devVersionId === existingVersionId
10176
+ );
10177
+ let completed = existingVersion;
10178
+ let totalTime = 0;
10179
+ let deployedToDev = false;
10180
+ if (reuseDevPromotion) {
10181
+ building.succeed(` Current manifest already matches ${brand.secondary(existingVersionId)} on dev.`);
10182
+ } else {
10183
+ const version2 = await api.createVersion(config.sandboxId, uploadedManifest.manifestId);
10184
+ const startTime = Date.now();
10185
+ building.text = ` Running build for version... ${brand.muted(version2.buildId)}`;
10186
+ completed = await api.waitForVersionBuild(version2.buildId, (status) => {
10187
+ const elapsed = Math.round((Date.now() - startTime) / 1e3);
10188
+ building.text = ` Running build for version... ${brand.muted(`${status} (${elapsed}s)`)}`;
10189
+ });
10190
+ totalTime = Math.round((Date.now() - startTime) / 1e3);
10191
+ deployedToDev = true;
10192
+ building.succeed(` Dev now points to ${brand.secondary(completed.buildId)} (${totalTime}s)`);
10193
+ }
10194
+ if (!completed) {
10195
+ throw new Error("Could not resolve the current ontology version.");
10196
+ }
10197
+ if (options.prod) {
10198
+ const promoting = spinner("Promoting prod...");
10199
+ await api.promoteVersionToProd(completed.buildId);
10200
+ promoting.succeed(` Prod now points to ${brand.secondary(completed.buildId)}`);
10201
+ }
10085
10202
  writeSandboxAgentDocFile(getProjectRoot(), manifest, {
10086
10203
  sandboxId: config.sandboxId,
10087
10204
  apiUrl: config.apiUrl,
@@ -10092,12 +10209,20 @@ async function deployCommand() {
10092
10209
  success(`Deployed ${brand.bold(manifest.name)} successfully!`);
10093
10210
  console.log();
10094
10211
  keyValue({
10095
- "Build": completed.buildId,
10212
+ "Version": completed.buildId,
10213
+ "Version Number": completed.versionNumber != null ? String(completed.versionNumber) : "N/A",
10214
+ "Build Run": completed.buildRunId || completed.latestBuildRunId || "N/A",
10096
10215
  "Manifest": uploadedManifest.manifestId,
10097
- "Status": "live",
10216
+ "Dev Tag": completed.buildId,
10217
+ "Prod Tag": options.prod ? completed.buildId : prodTag?.targetVersionId || prodTag?.targetBuildId || "unchanged",
10218
+ "Release": options.prod ? deployedToDev ? "dev and prod now point to this version" : "prod promoted to the version already on dev" : "dev updated to this version",
10098
10219
  "Agent doc": "GRANULAR_SANDBOX.md"
10099
10220
  });
10100
10221
  console.log();
10222
+ if (options.prod) {
10223
+ info("Existing prod environments keep their current version until they are transitioned.");
10224
+ console.log();
10225
+ }
10101
10226
  } catch (err) {
10102
10227
  building.fail(` Deploy failed: ${err.message}`);
10103
10228
  process.exit(1);
@@ -10125,21 +10250,30 @@ async function statusCommand() {
10125
10250
  "Created": sandbox.createdAt
10126
10251
  });
10127
10252
  console.log();
10128
- const builds = await api.listBuilds(config.sandboxId);
10253
+ const builds = await api.listVersions(config.sandboxId);
10254
+ const tags = await api.listTags(config.sandboxId).catch(() => []);
10255
+ const devTag = tags.find((tag2) => tag2.name === "dev");
10256
+ const prodTag = tags.find((tag2) => tag2.name === "prod");
10257
+ keyValue({
10258
+ "Dev Version": devTag?.targetVersionId || devTag?.targetBuildId || "not set",
10259
+ "Prod Version": prodTag?.targetVersionId || prodTag?.targetBuildId || "not set"
10260
+ });
10261
+ console.log();
10129
10262
  if (builds.length > 0) {
10130
- step("Recent Builds");
10263
+ step("Recent Versions");
10131
10264
  console.log();
10132
10265
  const recentBuilds = builds.slice(0, 5);
10133
10266
  table(
10134
- ["Build ID", "Status", "Created"],
10267
+ ["Version", "Status", "Users/Envs", "Created"],
10135
10268
  recentBuilds.map((b) => [
10136
10269
  b.buildId,
10137
10270
  buildStatus(b.status),
10271
+ `${b.environmentCount ?? 0}/${b.sessionCount ?? 0}`,
10138
10272
  b.createdAt ? String(b.createdAt) : "N/A"
10139
10273
  ])
10140
10274
  );
10141
10275
  } else {
10142
- dim("No builds yet. Run `granular build` to create one.");
10276
+ dim("No versions yet. Run `granular build` to create one.");
10143
10277
  }
10144
10278
  console.log();
10145
10279
  const manifests = await api.listManifests(config.sandboxId);
@@ -10481,18 +10615,145 @@ async function simulateCommand(sandboxIdArg, options) {
10481
10615
  openUrl(url);
10482
10616
  }
10483
10617
 
10618
+ // src/cli/commands/version.ts
10619
+ function formatVersionLabel(versionId, versionNumber) {
10620
+ return versionNumber != null ? `v${versionNumber}` : versionId;
10621
+ }
10622
+ async function versionDiffCommand(versionId, againstVersionId) {
10623
+ printHeader();
10624
+ const config = resolveConfig({ requireApiKey: true });
10625
+ const api = new ApiClient(config.apiKey, config.apiUrl);
10626
+ const spin = spinner(
10627
+ againstVersionId ? `Computing diff ${versionId} \u2190 ${againstVersionId}...` : `Computing diff for ${versionId}...`
10628
+ );
10629
+ try {
10630
+ const [version2, diffResult] = await Promise.all([
10631
+ api.getVersion(versionId),
10632
+ api.getVersionDiff(versionId, againstVersionId)
10633
+ ]);
10634
+ const baseVersion = diffResult.againstVersionId ? await api.getVersion(diffResult.againstVersionId).catch(() => null) : null;
10635
+ const summary = diffResult.diff.summary;
10636
+ spin.succeed(" Version diff ready.");
10637
+ console.log();
10638
+ keyValue({
10639
+ "Version": formatVersionLabel(version2.buildId, version2.versionNumber),
10640
+ "Compared against": baseVersion ? formatVersionLabel(baseVersion.buildId, baseVersion.versionNumber) : diffResult.againstVersionId || "previous version",
10641
+ "Added operations": summary.added,
10642
+ "Changed operations": summary.changed,
10643
+ "Removed operations": summary.removed,
10644
+ "Transition safety": summary.onlyAdditiveChanges ? "additive only" : "breaking changes detected"
10645
+ });
10646
+ if (diffResult.diff.entries.length === 0) {
10647
+ console.log();
10648
+ info("No ontology changes between these versions.");
10649
+ console.log();
10650
+ return;
10651
+ }
10652
+ console.log();
10653
+ step("Semantic diff");
10654
+ console.log();
10655
+ table(
10656
+ ["Change", "Kind", "Operation", "Safety"],
10657
+ diffResult.diff.entries.map((entry) => [
10658
+ entry.changeType,
10659
+ entry.kind,
10660
+ entry.label,
10661
+ entry.additive ? "safe-add" : "breaking"
10662
+ ])
10663
+ );
10664
+ console.log();
10665
+ } catch (err) {
10666
+ spin.fail(` Failed to compute diff: ${err.message}`);
10667
+ process.exit(1);
10668
+ }
10669
+ }
10670
+
10671
+ // src/cli/commands/tag.ts
10672
+ function renderVersionTarget(versionId) {
10673
+ if (!versionId) return "not set";
10674
+ return versionId;
10675
+ }
10676
+ async function tagListCommand() {
10677
+ printHeader();
10678
+ const config = resolveConfig({ requireApiKey: true });
10679
+ if (!config.sandboxId) {
10680
+ error("No ontology configured. Run `granular init` first.");
10681
+ process.exit(1);
10682
+ }
10683
+ const api = new ApiClient(config.apiKey, config.apiUrl);
10684
+ const spin = spinner("Loading tags...");
10685
+ try {
10686
+ const tags = await api.listTags(config.sandboxId);
10687
+ spin.succeed(" Tags loaded.");
10688
+ console.log();
10689
+ if (tags.length === 0) {
10690
+ dim("No tags defined for this ontology yet.");
10691
+ console.log();
10692
+ return;
10693
+ }
10694
+ table(
10695
+ ["Tag", "Target version", "Kind", "Protected"],
10696
+ tags.map((tag2) => [
10697
+ tag2.name,
10698
+ renderVersionTarget(tag2.targetVersionId || tag2.targetBuildId),
10699
+ tag2.kind,
10700
+ tag2.protected ? "yes" : "no"
10701
+ ])
10702
+ );
10703
+ console.log();
10704
+ } catch (err) {
10705
+ spin.fail(` Failed to load tags: ${err.message}`);
10706
+ process.exit(1);
10707
+ }
10708
+ }
10709
+ async function tagMoveCommand(tagName, versionId) {
10710
+ printHeader();
10711
+ const config = resolveConfig({ requireApiKey: true });
10712
+ if (!config.sandboxId) {
10713
+ error("No ontology configured. Run `granular init` first.");
10714
+ process.exit(1);
10715
+ }
10716
+ const api = new ApiClient(config.apiKey, config.apiUrl);
10717
+ const spin = spinner(`Moving ${tagName} to version ${versionId}...`);
10718
+ try {
10719
+ const tags = await api.listTags(config.sandboxId);
10720
+ const tag2 = tags.find((candidate) => candidate.name === tagName);
10721
+ if (!tag2) {
10722
+ spin.fail(` Tag "${tagName}" was not found for this ontology.`);
10723
+ process.exit(1);
10724
+ }
10725
+ const previousVersionId = tag2.targetVersionId || tag2.targetBuildId || null;
10726
+ const moved = await api.moveVersionTag(tag2.tagId, versionId);
10727
+ spin.succeed(` ${tagName} now points to ${versionId}.`);
10728
+ console.log();
10729
+ keyValue({
10730
+ "Tag": moved.name,
10731
+ "Previous version": previousVersionId || "not set",
10732
+ "Current version": moved.targetVersionId || moved.targetBuildId || "not set",
10733
+ "Protected": moved.protected ? "yes" : "no"
10734
+ });
10735
+ console.log();
10736
+ if (moved.name === "prod") {
10737
+ info("Existing prod environments keep their current version until they are transitioned.");
10738
+ console.log();
10739
+ } else if (moved.name === "dev") {
10740
+ info("dev is usually automatic. You only need to move it manually for exceptional release workflows.");
10741
+ console.log();
10742
+ }
10743
+ } catch (err) {
10744
+ spin.fail(` Failed to move tag: ${err.message}`);
10745
+ process.exit(1);
10746
+ }
10747
+ }
10748
+
10484
10749
  // src/cli/index.ts
10485
10750
  var VERSION = "0.2.0";
10486
10751
  var program2 = new Command();
10487
- program2.name("granular").description("Build and deploy AI sandboxes from code").version(VERSION, "-v, --version");
10488
- program2.option("--local", "Use local endpoints (localhost)").option("--prod", "Use production endpoints").option("--env <target>", "Endpoint target: local|production");
10752
+ program2.name("granular").description("Version and run Granular ontologies from code").version(VERSION, "-v, --version");
10753
+ program2.option("--local", "Use local endpoints (localhost)").option("--env <target>", "Endpoint target: local|production");
10489
10754
  program2.hook("preAction", () => {
10490
10755
  const opts = program2.opts();
10491
10756
  const normalizedEnv = opts.env?.trim().toLowerCase();
10492
- if (opts.local && opts.prod) {
10493
- error("Cannot use --local and --prod at the same time.");
10494
- process.exit(1);
10495
- }
10496
10757
  if (normalizedEnv && !["local", "production", "prod"].includes(normalizedEnv)) {
10497
10758
  error('Invalid --env value. Use "local" or "production".');
10498
10759
  process.exit(1);
@@ -10500,7 +10761,7 @@ program2.hook("preAction", () => {
10500
10761
  let mode;
10501
10762
  if (opts.local || normalizedEnv === "local") {
10502
10763
  mode = "local";
10503
- } else if (opts.prod || normalizedEnv === "production" || normalizedEnv === "prod") {
10764
+ } else if (normalizedEnv === "production" || normalizedEnv === "prod") {
10504
10765
  mode = "production";
10505
10766
  }
10506
10767
  if (mode) {
@@ -10535,15 +10796,15 @@ program2.command("login").description("Authenticate with Granular (browser by de
10535
10796
  process.exit(1);
10536
10797
  }
10537
10798
  });
10538
- program2.command("pull").description("Pull the latest manifest from the API").action(async () => {
10799
+ program2.command("pull <version-id>").description("Pull the manifest for a specific ontology version").action(async (versionId) => {
10539
10800
  try {
10540
- await pullCommand();
10801
+ await pullCommand(versionId);
10541
10802
  } catch (err) {
10542
10803
  error(err.message);
10543
10804
  process.exit(1);
10544
10805
  }
10545
10806
  });
10546
- program2.command("build").description("Upload manifest and trigger a build").action(async () => {
10807
+ program2.command("build").description("Create or reuse the ontology version for your current manifest, then run its build").action(async () => {
10547
10808
  try {
10548
10809
  await buildCommand();
10549
10810
  } catch (err) {
@@ -10551,15 +10812,15 @@ program2.command("build").description("Upload manifest and trigger a build").act
10551
10812
  process.exit(1);
10552
10813
  }
10553
10814
  });
10554
- program2.command("deploy").description("Build and deploy as the current production version").action(async () => {
10815
+ program2.command("deploy").description("Push the current revision to dev; use --prod to move prod there too").option("--prod", "Also point prod to the current version").action(async (opts) => {
10555
10816
  try {
10556
- await deployCommand();
10817
+ await deployCommand({ prod: opts.prod });
10557
10818
  } catch (err) {
10558
10819
  error(err.message);
10559
10820
  process.exit(1);
10560
10821
  }
10561
10822
  });
10562
- program2.command("status").description("Show sandbox and build status").action(async () => {
10823
+ program2.command("status").description("Show ontology versions, dev/prod, and manifest status").action(async () => {
10563
10824
  try {
10564
10825
  await statusCommand();
10565
10826
  } catch (err) {
@@ -10567,6 +10828,32 @@ program2.command("status").description("Show sandbox and build status").action(a
10567
10828
  process.exit(1);
10568
10829
  }
10569
10830
  });
10831
+ var version = program2.command("version").description("Inspect ontology versions");
10832
+ version.command("diff <version-id> [against-version-id]").description("Show the semantic diff between one version and another").action(async (versionId, againstVersionId) => {
10833
+ try {
10834
+ await versionDiffCommand(versionId, againstVersionId);
10835
+ } catch (err) {
10836
+ error(err.message);
10837
+ process.exit(1);
10838
+ }
10839
+ });
10840
+ var tag = program2.command("tag").description("Inspect and move release tags such as dev and prod");
10841
+ tag.command("list").description("List tags for the current ontology").action(async () => {
10842
+ try {
10843
+ await tagListCommand();
10844
+ } catch (err) {
10845
+ error(err.message);
10846
+ process.exit(1);
10847
+ }
10848
+ });
10849
+ tag.command("move <tag-name> <version-id>").description("Move a tag to a specific ontology version").action(async (tagName, versionId) => {
10850
+ try {
10851
+ await tagMoveCommand(tagName, versionId);
10852
+ } catch (err) {
10853
+ error(err.message);
10854
+ process.exit(1);
10855
+ }
10856
+ });
10570
10857
  var add = program2.command("add").description("Add schema elements to the local manifest");
10571
10858
  add.command("class <name>").description("Add a new class to the schema").action(async (name) => {
10572
10859
  try {