@astrale-os/cli 0.8.1-alpha.7 → 1.0.0-beta.0

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.
Files changed (64) hide show
  1. package/README.md +15 -3
  2. package/dist/astrale.js +86 -35
  3. package/package.json +5 -5
  4. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  5. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  6. package/src/commands/__tests__/install-direct.test.ts +3 -2
  7. package/src/commands/domain/install.ts +78 -15
  8. package/src/connection/__tests__/errors.test.ts +82 -6
  9. package/src/connection/command.ts +9 -1
  10. package/src/connection/errors.ts +19 -8
  11. package/src/connection/index.ts +1 -1
  12. package/src/connection/reasons.ts +26 -12
  13. package/src/program/__tests__/program.test.ts +1 -1
  14. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  15. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  16. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  17. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  18. package/studio/client/dist/index.html +2 -2
  19. package/studio/package.json +1 -1
  20. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  21. package/studio/server/agent/prompts/anchors.ts +85 -15
  22. package/studio/server/agent/prompts/system.test.ts +12 -0
  23. package/studio/server/agent/prompts/system.ts +6 -6
  24. package/studio/server/api.ts +1 -5
  25. package/studio/server/cache.ts +5 -2
  26. package/studio/server/domain.test.ts +67 -0
  27. package/studio/server/domain.ts +29 -6
  28. package/studio/server/index.ts +1 -3
  29. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  30. package/studio/server/introspect/anatomy-extras.ts +340 -8
  31. package/studio/server/introspect/anatomy.test.ts +33 -0
  32. package/studio/server/introspect/anatomy.ts +22 -7
  33. package/studio/server/introspect/bundle.ts +7 -1
  34. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  35. package/studio/server/introspect/canonical-schema.ts +751 -0
  36. package/studio/server/introspect/core-extractor.ts +30 -10
  37. package/studio/server/introspect/core.ts +4 -2
  38. package/studio/server/introspect/diff.test.ts +124 -0
  39. package/studio/server/introspect/diff.ts +252 -23
  40. package/studio/server/introspect/extractor.ts +46 -14
  41. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  42. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  43. package/studio/server/introspect/overlay.test.ts +72 -0
  44. package/studio/server/introspect/overlay.ts +16 -6
  45. package/studio/server/introspect/runtime.test.ts +217 -0
  46. package/studio/server/introspect/runtime.ts +18 -6
  47. package/studio/server/introspect/schema-refs.test.ts +100 -0
  48. package/studio/server/introspect/schema-refs.ts +23 -2
  49. package/studio/server/state/baseline.test.ts +51 -0
  50. package/studio/server/state/baseline.ts +31 -2
  51. package/studio/server/state/create.test.ts +37 -0
  52. package/studio/server/state/create.ts +21 -15
  53. package/studio/server/state/instance.test.ts +63 -0
  54. package/studio/server/state/instance.ts +65 -29
  55. package/studio/server/state/views.test.ts +209 -9
  56. package/studio/server/state/views.ts +176 -69
  57. package/studio/server/watch.test.ts +36 -0
  58. package/studio/server/watch.ts +24 -16
  59. package/studio/server/workspace-watch.ts +8 -3
  60. package/studio/shared/types.ts +164 -33
  61. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  62. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  63. package/studio/server/view-dev-server.test.ts +0 -111
  64. package/studio/server/view-dev-server.ts +0 -372
package/README.md CHANGED
@@ -13,6 +13,12 @@ curl -fsSL https://raw.githubusercontent.com/astrale-os/cli/main/install.sh | sh
13
13
  The installer places a verified standalone binary at
14
14
  `~/.astrale/bin/astrale` by default. No local runtime is installed.
15
15
 
16
+ During the prerelease period, install the current standalone beta explicitly:
17
+
18
+ ```bash
19
+ curl -fsSL https://raw.githubusercontent.com/astrale-os/cli/main/install.sh | ASTRALE_CHANNEL=beta sh
20
+ ```
21
+
16
22
  Optional installer environment:
17
23
 
18
24
  ```bash
@@ -29,6 +35,12 @@ ships the [Domain Studio](studio/README.md) (`astrale studio`):
29
35
  npm install -g @astrale-os/cli
30
36
  ```
31
37
 
38
+ During the prerelease period, install the current beta explicitly:
39
+
40
+ ```bash
41
+ npm install -g @astrale-os/cli@beta
42
+ ```
43
+
32
44
  The npm build bundles all dependencies, so no private-registry access is needed.
33
45
  `astrale studio` launches a local web GUI to author and inspect a domain, with
34
46
  local Claude Code and Codex agent harnesses (`--harness claude|codex`); it runs on
@@ -36,9 +48,9 @@ local Claude Code and Codex agent harnesses (`--harness claude|codex`); it runs
36
48
  (the rest of the CLI needs only Node ≥ 22). The curl-installed standalone binary
37
49
  above does not include the studio — use the npm install for `astrale studio`.
38
50
 
39
- > Pre-1.0, `npm install -g @astrale-os/cli` installs the latest published
40
- > build (currently an alpha). The curl installer's channels (alpha/beta/rc) are
41
- > a binary-only concept.
51
+ > Prereleases use their matching npm dist-tag (`alpha`, `beta`, or `rc`); bare
52
+ > `npm install -g @astrale-os/cli` follows the stable `latest` tag. The curl
53
+ > installer has separate binary channels with the same names.
42
54
 
43
55
  ## Quickstart
44
56
 
package/dist/astrale.js CHANGED
@@ -9738,7 +9738,7 @@ var package_default;
9738
9738
  var init_package = __esm(() => {
9739
9739
  package_default = {
9740
9740
  name: "@astrale-os/cli",
9741
- version: "0.8.1-alpha.7",
9741
+ version: "1.0.0-beta.0",
9742
9742
  description: "Astrale CLI — connect to existing Astrale kernels",
9743
9743
  keywords: [
9744
9744
  "astrale",
@@ -9799,7 +9799,7 @@ var init_package = __esm(() => {
9799
9799
  "lint:fix": "oxlint --fix .",
9800
9800
  format: "pnpm exec oxfmt --write .",
9801
9801
  "format:check": "pnpm exec oxfmt --check .",
9802
- test: "bun test src",
9802
+ test: "bun test src && node --test scripts/*.test.mjs",
9803
9803
  "test:watch": "bun test --watch src"
9804
9804
  },
9805
9805
  dependencies: {
@@ -9814,10 +9814,10 @@ var init_package = __esm(() => {
9814
9814
  zod: "^3.25.0"
9815
9815
  },
9816
9816
  devDependencies: {
9817
- "@astrale-os/kernel-client": "0.6.0-beta.3",
9817
+ "@astrale-os/kernel-client": ">=0.6.0-beta.3 <1.0.0",
9818
9818
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
9819
- "@astrale-os/sdk": "0.5.0-beta.3",
9820
- "@astrale-os/shell": "0.3.8-beta.2",
9819
+ "@astrale-os/sdk": ">=0.5.0-beta.3 <1.0.0",
9820
+ "@astrale-os/shell": ">=0.3.8-beta.2 <1.0.0",
9821
9821
  "@astrale/commitlint-config": "jsr:~2.0.1",
9822
9822
  "@commitlint/cli": "~20.3.1",
9823
9823
  "@commitlint/config-conventional": "~20.3.1",
@@ -87021,16 +87021,20 @@ function schemaUpgradeDetails2(reason) {
87021
87021
  if (!record10(reason) || reason.code !== "SCHEMA_UPGRADE_INCOMPATIBLE")
87022
87022
  return;
87023
87023
  const details = record10(reason.details) ? reason.details : {};
87024
- return {
87025
- ...typeof details.origin === "string" ? { origin: details.origin } : {},
87026
- ...typeof details.issue === "string" ? { issue: details.issue } : {},
87027
- ...typeof details.expected === "string" ? { expected: details.expected } : {},
87028
- ...typeof details.actual === "string" ? { actual: details.actual } : {}
87029
- };
87024
+ const origin = typeof details.origin === "string" ? details.origin : undefined;
87025
+ if (details.issue === "issuer-changed" && origin !== undefined && typeof details.installedIssuer === "string" && typeof details.replacementIssuer === "string") {
87026
+ return {
87027
+ origin,
87028
+ issue: details.issue,
87029
+ installedIssuer: details.installedIssuer,
87030
+ replacementIssuer: details.replacementIssuer
87031
+ };
87032
+ }
87033
+ return origin === undefined ? {} : { origin };
87030
87034
  }
87031
87035
  function schemaUpgradeHint(details) {
87032
87036
  const target2 = details.origin ?? "<origin>";
87033
- const explanation = details.expected !== undefined && details.actual !== undefined ? "A replacement cannot change an installed Domain issuer." : "The replacement changes an immutable part of the installed Domain.";
87037
+ const explanation = details.issue === "issuer-changed" ? "A replacement cannot change an installed Domain issuer." : "The replacement changes an immutable part of the installed Domain.";
87034
87038
  return `${explanation} If this change is intentional, first run ` + `\`astrale domain uninstall ${target2}\`, then install it again. ` + "The Kernel refuses uninstall while dependents or business data remain; uninstall never deletes business data.";
87035
87039
  }
87036
87040
  function reasonWithCode(input, code) {
@@ -87063,7 +87067,7 @@ var init_reasons = __esm(() => {
87063
87067
  });
87064
87068
 
87065
87069
  // src/connection/errors.ts
87066
- async function formatKernelError(error52, isRaw, urlArg = "", debug = false) {
87070
+ async function formatKernelError(error52, isRaw, urlArg = "", debug = false, opts = {}) {
87067
87071
  const url3 = urlArg || (error52 instanceof Error ? error52.url ?? "" : "");
87068
87072
  const localContext = await contextForError(error52);
87069
87073
  if (error52 instanceof AstraleError) {
@@ -87088,7 +87092,7 @@ async function formatKernelError(error52, isRaw, urlArg = "", debug = false) {
87088
87092
  const name = error52.name;
87089
87093
  switch (name) {
87090
87094
  case "TransportError":
87091
- presentTransportError(error52, isRaw, url3, localContext);
87095
+ presentTransportError(error52, isRaw, url3, localContext, opts.recovery);
87092
87096
  break;
87093
87097
  case "ResponseError": {
87094
87098
  const code = error52.code;
@@ -87117,11 +87121,9 @@ async function formatKernelError(error52, isRaw, urlArg = "", debug = false) {
87117
87121
  presentFunctionInputIssues(inputIssues);
87118
87122
  if (queryRepair !== undefined)
87119
87123
  presentQueryInputRepair(queryRepair);
87120
- if (upgrade?.expected !== undefined) {
87121
- log.dim(` installed issuer: ${upgrade.expected}`);
87122
- }
87123
- if (upgrade?.actual !== undefined) {
87124
- log.dim(` replacement issuer: ${upgrade.actual}`);
87124
+ if (upgrade?.issue === "issuer-changed") {
87125
+ log.dim(` installed issuer: ${upgrade.installedIssuer}`);
87126
+ log.dim(` replacement issuer: ${upgrade.replacementIssuer}`);
87125
87127
  }
87126
87128
  if (hint !== undefined)
87127
87129
  log.dim(` ${hint}`);
@@ -87194,7 +87196,7 @@ function mapPublicError(error52) {
87194
87196
  }
87195
87197
  return { code: name && name !== "Error" ? name : "UNKNOWN", message: error52.message };
87196
87198
  }
87197
- function presentTransportError(error52, isRaw, url3, context) {
87199
+ function presentTransportError(error52, isRaw, url3, context, recovery) {
87198
87200
  const phase = transportPhase(error52);
87199
87201
  const delivery = transportDelivery(error52);
87200
87202
  const code = phase === "connect" ? "CONNECTION_ERROR" : phase === "timeout" ? "TIMEOUT" : phase === "closed" ? "DISCONNECTED" : "TRANSPORT_ERROR";
@@ -87205,7 +87207,8 @@ function presentTransportError(error52, isRaw, url3, context) {
87205
87207
  ...url3 === "" ? {} : { url: url3 },
87206
87208
  ...phase === undefined ? {} : { phase },
87207
87209
  ...delivery === undefined ? {} : { delivery },
87208
- ...context === undefined ? {} : { context }
87210
+ ...context === undefined ? {} : { context },
87211
+ ...delivery === "unknown" && recovery !== undefined ? recovery : {}
87209
87212
  });
87210
87213
  return;
87211
87214
  }
@@ -87218,10 +87221,18 @@ function presentTransportError(error52, isRaw, url3, context) {
87218
87221
  log.dim(" Check the target and run `astrale status`.");
87219
87222
  else if (phase === "timeout")
87220
87223
  log.dim(" Try increasing `--timeout`.");
87221
- else if (delivery === "unknown") {
87224
+ else if (delivery === "unknown")
87225
+ printOperationRecovery(recovery);
87226
+ printLocalContext(context);
87227
+ }
87228
+ function printOperationRecovery(recovery) {
87229
+ if (recovery === undefined) {
87222
87230
  log.dim(" Delivery is unknown; do not automatically retry a mutating call.");
87231
+ return;
87223
87232
  }
87224
- printLocalContext(context);
87233
+ log.dim(" Delivery is unknown; retry with the same operation id:");
87234
+ log.dim(` operation: ${recovery.operation}`);
87235
+ log.dim(` ${recovery.retry}`);
87225
87236
  }
87226
87237
  function presentFunctionInputIssues(issues) {
87227
87238
  for (const issue2 of issues) {
@@ -88640,7 +88651,9 @@ async function runKernelCommand(input) {
88640
88651
  } catch (error52) {
88641
88652
  if (!isRaw && spin)
88642
88653
  spin.fail(`${label} failed`);
88643
- await formatKernelError(error52, isRaw, undefined, opts.debug);
88654
+ await formatKernelError(error52, isRaw, undefined, opts.debug, {
88655
+ recovery: input.recovery
88656
+ });
88644
88657
  process.exit(1);
88645
88658
  }
88646
88659
  }
@@ -93593,11 +93606,12 @@ __export(exports_install, {
93593
93606
  default: () => install_default,
93594
93607
  directInstallCallInput: () => directInstallCallInput,
93595
93608
  domainRefFromTarget: () => domainRefFromTarget,
93609
+ installDirect: () => installDirect,
93596
93610
  installViaAdmin: () => installViaAdmin,
93597
93611
  isIdentityOverride: () => isIdentityOverride,
93598
93612
  probeDeclaredOrigin: () => probeDeclaredOrigin
93599
93613
  });
93600
- function directInstallCallInput(url3, token, operation2 = crypto.randomUUID()) {
93614
+ function directInstallCallInput(url3, operation2, token) {
93601
93615
  return Object.freeze({
93602
93616
  operation: operation2,
93603
93617
  domains: [
@@ -93611,6 +93625,15 @@ function directInstallCallInput(url3, token, operation2 = crypto.randomUUID()) {
93611
93625
  ]
93612
93626
  });
93613
93627
  }
93628
+ function acceptOperationId(input) {
93629
+ if (typeof input !== "string" || !OPERATION_ID_PATTERN.test(input)) {
93630
+ throw new AstraleError("INVALID_FLAG", "--operation must be a canonical lowercase UUIDv4.", "Omit --operation for a fresh install; use it only with the exact UUID printed for recovery.");
93631
+ }
93632
+ return input;
93633
+ }
93634
+ function createOperationId() {
93635
+ return acceptOperationId(globalThis.crypto.randomUUID());
93636
+ }
93614
93637
  async function installViaAdmin(target2, opts, dependencies = {}) {
93615
93638
  const admin = { ...defaultAdminInstallDependencies, ...dependencies };
93616
93639
  const interactive = !!process.stdin.isTTY && !(opts.ci || opts.noPrompt || process.env.CI);
@@ -93648,8 +93671,9 @@ async function installViaAdmin(target2, opts, dependencies = {}) {
93648
93671
  log.dim(` origin: ${result.origin}`);
93649
93672
  log.dim(` url: ${result.url}`);
93650
93673
  });
93651
- } catch (e) {
93652
- fatal(e, opts);
93674
+ } catch (error52) {
93675
+ await formatKernelError(error52, isMachine(opts), undefined, opts.debug);
93676
+ process.exit(1);
93653
93677
  }
93654
93678
  }
93655
93679
  function assertInstallTargetReady(instance) {
@@ -93712,40 +93736,49 @@ async function activeSlug() {
93712
93736
  return;
93713
93737
  }
93714
93738
  }
93715
- async function installDirect(target2, opts) {
93739
+ async function installDirect(target2, opts, dependencies = {}) {
93740
+ const direct = { ...defaultDirectInstallDependencies, ...dependencies };
93716
93741
  let host = "";
93717
93742
  let consentedOrigin;
93743
+ let operation2;
93718
93744
  try {
93719
93745
  if (!target2) {
93720
93746
  throw new AstraleError("MISSING_ARG", "--direct requires a domain url.", "e.g. astrale domain install https://crm.acme.dev --direct");
93721
93747
  }
93722
93748
  host = validateInstallUrl(target2);
93749
+ operation2 = opts.operation === undefined ? direct.createOperationId() : direct.acceptOperationId(opts.operation);
93723
93750
  consentedOrigin = await ensureIdentityOverrideConsent(target2, host, opts.allowIdentityOverride ?? false);
93724
93751
  } catch (e) {
93725
- fatal(e);
93752
+ fatal(e, opts);
93726
93753
  }
93727
93754
  const url3 = target2;
93728
- await runKernelCommand({
93755
+ const retry = directInstallRetry(url3, operation2, opts);
93756
+ await direct.runKernelCommand({
93729
93757
  opts,
93730
- label: `Installing domain from ${url3}`,
93731
- fn: async ({ session }) => await session.call(createPathCall(Path.project(exports_syscalls.install.ref).raw, directInstallCallInput(url3, opts.token))),
93758
+ label: `Installing domain from ${url3} (operation ${operation2})`,
93759
+ recovery: { operation: operation2, retry },
93760
+ fn: async ({ session }) => await session.call(createPathCall(Path.project(exports_syscalls.install.ref).raw, directInstallCallInput(url3, operation2, opts.token))),
93732
93761
  format: (result, fmtOpts, isRaw) => {
93733
93762
  if (isRaw) {
93734
93763
  output(result, fmtOpts);
93735
93764
  return;
93736
93765
  }
93737
93766
  const installed = result.transitions[0]?.intent;
93738
- if (installed === undefined) {
93767
+ if (!installed)
93739
93768
  throw new Error("Kernel install returned no committed Domain transition.");
93740
- }
93741
93769
  const revision = installed.target?.schemaRevision ?? result.operation;
93742
93770
  log.success(`Domain installed: ${installed.origin}@${revision}`);
93771
+ log.dim(` operation: ${result.operation}`);
93743
93772
  if (isIdentityOverride(installed.origin, host) && installed.origin !== consentedOrigin) {
93744
93773
  log.warn(`Installed origin "${installed.origin}" differs from the serving host "${host}" ` + `and was not confirmed before install (the worker's /meta did not declare it). ` + `Every ${installed.origin}/* call on this instance now routes to ${host}.`);
93745
93774
  }
93746
93775
  }
93747
93776
  });
93748
93777
  }
93778
+ function directInstallRetry(url3, operation2, opts) {
93779
+ const instance = opts.instance === undefined ? "" : ` -i ${opts.instance}`;
93780
+ return `astrale domain install ${url3} --direct --operation ${operation2}${instance}`;
93781
+ }
93749
93782
  function validateInstallUrl(value2) {
93750
93783
  let url3;
93751
93784
  try {
@@ -93798,12 +93831,13 @@ async function probeDeclaredOrigin(url3) {
93798
93831
  return;
93799
93832
  }
93800
93833
  }
93801
- var install_default, defaultAdminInstallDependencies;
93834
+ var OPERATION_ID_PATTERN, install_default, defaultAdminInstallDependencies, defaultDirectInstallDependencies;
93802
93835
  var init_install3 = __esm(() => {
93803
93836
  init_path5();
93804
93837
  init_kernel2();
93805
93838
  init_source();
93806
93839
  init_connection2();
93840
+ init_errors12();
93807
93841
  init_errors2();
93808
93842
  init_admin_domain();
93809
93843
  init_admin_instance();
@@ -93813,6 +93847,7 @@ var init_install3 = __esm(() => {
93813
93847
  init_output();
93814
93848
  init_prompt();
93815
93849
  init_validation2();
93850
+ OPERATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
93816
93851
  install_default = {
93817
93852
  name: "install",
93818
93853
  description: "Install a domain on an instance (via the admin catalog, or --direct from a url)",
@@ -93831,6 +93866,10 @@ Behavior:
93831
93866
  origin differs from its serving host, it requires explicit consent (an
93832
93867
  interactive DANGER prompt, or --allow-identity-override in scripts).
93833
93868
 
93869
+ A fresh, strong operation id is generated automatically. Use --operation
93870
+ only to retry or recover the exact same direct install after an outcome-unknown
93871
+ timeout or disconnect.
93872
+
93834
93873
  Examples:
93835
93874
  $ astrale domain install crm.acme.dev -i staging # by origin, via admin
93836
93875
  $ astrale domain install https://crm.acme.dev # by url, via admin
@@ -93855,12 +93894,19 @@ Examples:
93855
93894
  flags: "--token <token>",
93856
93895
  description: "Bearer token for private domain install endpoints (--direct only)"
93857
93896
  },
93897
+ {
93898
+ flags: "--operation <uuid>",
93899
+ description: "Reuse an exact direct-install operation id for explicit retry/recovery"
93900
+ },
93858
93901
  {
93859
93902
  flags: "--allow-identity-override",
93860
93903
  description: "Consent to a domain whose origin differs from its serving host (--direct only)"
93861
93904
  }
93862
93905
  ],
93863
93906
  action: async (target2, opts) => {
93907
+ if (opts.operation !== undefined && !opts.direct) {
93908
+ fatal(new AstraleError("INVALID_FLAG", "--operation is valid only with --direct.", "Ordinary direct installs generate a fresh operation id automatically."), opts);
93909
+ }
93864
93910
  if (opts.direct) {
93865
93911
  await installDirect(target2, opts);
93866
93912
  return;
@@ -93873,6 +93919,11 @@ Examples:
93873
93919
  listDomains: listAdminDomainsInContext,
93874
93920
  install: installAdminDomainInContext
93875
93921
  });
93922
+ defaultDirectInstallDependencies = Object.freeze({
93923
+ acceptOperationId,
93924
+ createOperationId,
93925
+ runKernelCommand
93926
+ });
93876
93927
  });
93877
93928
 
93878
93929
  // src/commands/domain/uninstall.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "0.8.1-alpha.7",
3
+ "version": "1.0.0-beta.0",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -62,10 +62,10 @@
62
62
  "zod": "^3.25.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@astrale-os/kernel-client": "0.6.0-beta.3",
65
+ "@astrale-os/kernel-client": ">=0.6.0-beta.3 <1.0.0",
66
66
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
67
- "@astrale-os/sdk": "0.5.0-beta.3",
68
- "@astrale-os/shell": "0.3.8-beta.2",
67
+ "@astrale-os/sdk": ">=0.5.0-beta.3 <1.0.0",
68
+ "@astrale-os/shell": ">=0.3.8-beta.2 <1.0.0",
69
69
  "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
70
70
  "@commitlint/cli": "~20.3.1",
71
71
  "@commitlint/config-conventional": "~20.3.1",
@@ -107,7 +107,7 @@
107
107
  "lint:fix": "oxlint --fix .",
108
108
  "format": "pnpm exec oxfmt --write .",
109
109
  "format:check": "pnpm exec oxfmt --check .",
110
- "test": "bun test src",
110
+ "test": "bun test src && node --test scripts/*.test.mjs",
111
111
  "test:watch": "bun test --watch src"
112
112
  }
113
113
  }
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ import { installDirect } from '../domain/install'
4
+
5
+ const GENERATED = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
6
+ const RETRY = '139137b5-af47-47ce-92b2-b64a2b0c63d7'
7
+
8
+ const originalFetch = globalThis.fetch
9
+
10
+ class ExitError extends Error {}
11
+
12
+ afterEach(() => {
13
+ globalThis.fetch = originalFetch
14
+ })
15
+
16
+ describe('direct domain install operation identity', () => {
17
+ test('generates one operation before transport and exposes it for recovery', async () => {
18
+ globalThis.fetch = mock(async () =>
19
+ Response.json({ domainName: 'crm.test' }),
20
+ ) as unknown as typeof fetch
21
+ const create = mock(() => GENERATED)
22
+ const requests: unknown[] = []
23
+ let recovery: unknown
24
+
25
+ await installDirect(
26
+ 'https://crm.test',
27
+ { direct: true, json: true },
28
+ {
29
+ createOperationId: create,
30
+ runKernelCommand: async (input) => {
31
+ recovery = input.recovery
32
+ requests.push(
33
+ await input.fn({
34
+ session: {
35
+ call: async (call: { input: unknown }) => call.input,
36
+ },
37
+ } as never),
38
+ )
39
+ },
40
+ },
41
+ )
42
+
43
+ expect(create).toHaveBeenCalledTimes(1)
44
+ expect(requests).toEqual([
45
+ {
46
+ operation: GENERATED,
47
+ domains: [{ source: { kind: 'remote', url: 'https://crm.test' } }],
48
+ },
49
+ ])
50
+ expect(recovery).toEqual({
51
+ operation: GENERATED,
52
+ retry: `astrale domain install https://crm.test --direct --operation ${GENERATED}`,
53
+ })
54
+ })
55
+
56
+ test('accepts an explicit operation only as the exact retry identity', async () => {
57
+ globalThis.fetch = mock(async () =>
58
+ Response.json({ domainName: 'crm.test' }),
59
+ ) as unknown as typeof fetch
60
+ const create = mock(() => GENERATED)
61
+ const accepted: string[] = []
62
+ const requests: unknown[] = []
63
+
64
+ await installDirect(
65
+ 'https://crm.test',
66
+ { direct: true, operation: RETRY, json: true },
67
+ {
68
+ createOperationId: create,
69
+ acceptOperationId: (input) => {
70
+ accepted.push(String(input))
71
+ return RETRY
72
+ },
73
+ runKernelCommand: async (input) => {
74
+ requests.push(
75
+ await input.fn({
76
+ session: {
77
+ call: async (call: { input: unknown }) => call.input,
78
+ },
79
+ } as never),
80
+ )
81
+ },
82
+ },
83
+ )
84
+
85
+ expect(create).not.toHaveBeenCalled()
86
+ expect(accepted).toEqual([RETRY])
87
+ expect(requests).toEqual([
88
+ {
89
+ operation: RETRY,
90
+ domains: [{ source: { kind: 'remote', url: 'https://crm.test' } }],
91
+ },
92
+ ])
93
+ })
94
+
95
+ test('rejects a non-UUID operation before metadata or Kernel transport', async () => {
96
+ const originalExit = process.exit
97
+ const originalWrite = process.stderr.write
98
+ const fetch = mock(async () => Response.json({ domainName: 'crm.test' }))
99
+ const run = mock(async () => undefined)
100
+ globalThis.fetch = fetch as unknown as typeof globalThis.fetch
101
+ process.exit = (() => {
102
+ throw new ExitError('exit')
103
+ }) as typeof process.exit
104
+ process.stderr.write = (() => true) as typeof process.stderr.write
105
+ try {
106
+ await expect(
107
+ installDirect(
108
+ 'https://crm.test',
109
+ { direct: true, operation: 'guessable-operation', json: true },
110
+ { runKernelCommand: run },
111
+ ),
112
+ ).rejects.toBeInstanceOf(ExitError)
113
+ } finally {
114
+ process.exit = originalExit
115
+ process.stderr.write = originalWrite
116
+ }
117
+
118
+ expect(fetch).not.toHaveBeenCalled()
119
+ expect(run).not.toHaveBeenCalled()
120
+ })
121
+ })
@@ -1,3 +1,4 @@
1
+ import { ResponseError } from '@astrale-os/kernel-client'
1
2
  import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
3
 
3
4
  import type { OwnedInstanceInfo } from '../../lib/admin-instance'
@@ -118,4 +119,69 @@ describe('admin domain install owner boundary', () => {
118
119
  expect(calls).toEqual([])
119
120
  expect(calls).toEqual([])
120
121
  })
122
+
123
+ test('preserves an incompatible issuer reason returned through Admin', async () => {
124
+ inventory = [
125
+ {
126
+ id: 'owned-id',
127
+ slug: 'owned',
128
+ url: 'https://owned.eu.astrale.ai',
129
+ state: 'ready',
130
+ },
131
+ ]
132
+ const domain = {
133
+ id: 'crm-id',
134
+ origin: 'crm.acme.dev',
135
+ name: 'CRM',
136
+ url: 'https://crm.acme.dev',
137
+ createdAt: '2026-08-20T00:00:00.000Z',
138
+ updatedAt: '2026-08-20T00:00:00.000Z',
139
+ }
140
+ const failure = new ResponseError(5001, 'Schema operation is not supported.', {
141
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
142
+ details: {
143
+ phase: 'upgrade',
144
+ origin: domain.origin,
145
+ issue: 'issuer-changed',
146
+ installedIssuer: 'https://old.example',
147
+ replacementIssuer: 'https://new.example',
148
+ },
149
+ })
150
+ const { installViaAdmin } = await import('../domain/install')
151
+
152
+ await expect(
153
+ installViaAdmin(
154
+ domain.origin,
155
+ {
156
+ instance: 'owned',
157
+ json: true,
158
+ noPrompt: true,
159
+ },
160
+ {
161
+ listInstances: async () => inventory,
162
+ listDomains: async () => [domain],
163
+ install: async () => {
164
+ throw failure
165
+ },
166
+ },
167
+ ),
168
+ ).rejects.toEqual(new ExitError(1))
169
+
170
+ expect(JSON.parse(stderr)).toEqual({
171
+ error: 'RESPONSE_ERROR',
172
+ code: 5001,
173
+ message: 'Schema operation is not supported.',
174
+ reason: {
175
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
176
+ details: {
177
+ phase: 'upgrade',
178
+ origin: 'crm.acme.dev',
179
+ issue: 'issuer-changed',
180
+ installedIssuer: 'https://old.example',
181
+ replacementIssuer: 'https://new.example',
182
+ },
183
+ },
184
+ hint: expect.stringContaining('astrale domain uninstall crm.acme.dev'),
185
+ })
186
+ })
121
187
  })
@@ -4,8 +4,9 @@ import { directInstallCallInput } from '../domain/install'
4
4
 
5
5
  describe('directInstallCallInput', () => {
6
6
  test('sends the current remote install syscall, not a legacy url list', () => {
7
- expect(directInstallCallInput('https://tasks.example.test', 'secret', 'op-1')).toEqual({
8
- operation: 'op-1',
7
+ const operation = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
8
+ expect(directInstallCallInput('https://tasks.example.test', operation, 'secret')).toEqual({
9
+ operation,
9
10
  domains: [
10
11
  {
11
12
  source: {