@astrale-os/sdk 0.1.10 → 0.3.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 (186) hide show
  1. package/LICENSE +202 -0
  2. package/dist/auth/check.d.ts +1 -2
  3. package/dist/auth/check.d.ts.map +1 -1
  4. package/dist/auth/check.js +2 -6
  5. package/dist/auth/check.js.map +1 -1
  6. package/dist/auth/index.d.ts +3 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +2 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/auth/issuer-mint.d.ts +53 -0
  11. package/dist/auth/issuer-mint.d.ts.map +1 -0
  12. package/dist/auth/issuer-mint.js +35 -0
  13. package/dist/auth/issuer-mint.js.map +1 -0
  14. package/dist/auth/kernel-client.d.ts +8 -2
  15. package/dist/auth/kernel-client.d.ts.map +1 -1
  16. package/dist/auth/kernel-client.js +22 -46
  17. package/dist/auth/kernel-client.js.map +1 -1
  18. package/dist/cli/dotenv.d.ts +8 -0
  19. package/dist/cli/dotenv.d.ts.map +1 -1
  20. package/dist/cli/dotenv.js +18 -0
  21. package/dist/cli/dotenv.js.map +1 -1
  22. package/dist/cli/index.d.ts +1 -1
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +1 -1
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/cli/log.d.ts +11 -0
  27. package/dist/cli/log.d.ts.map +1 -0
  28. package/dist/cli/log.js +17 -0
  29. package/dist/cli/log.js.map +1 -0
  30. package/dist/cli/publish.d.ts +35 -0
  31. package/dist/cli/publish.d.ts.map +1 -0
  32. package/dist/cli/publish.js +271 -0
  33. package/dist/cli/publish.js.map +1 -0
  34. package/dist/cli/run.d.ts +17 -2
  35. package/dist/cli/run.d.ts.map +1 -1
  36. package/dist/cli/run.js +102 -111
  37. package/dist/cli/run.js.map +1 -1
  38. package/dist/cli/spec.d.ts.map +1 -1
  39. package/dist/cli/spec.js +1 -0
  40. package/dist/cli/spec.js.map +1 -1
  41. package/dist/config/adapter.d.ts +12 -11
  42. package/dist/config/adapter.d.ts.map +1 -1
  43. package/dist/config/adapter.js.map +1 -1
  44. package/dist/config/define-domain.d.ts +88 -27
  45. package/dist/config/define-domain.d.ts.map +1 -1
  46. package/dist/config/define-domain.js +79 -33
  47. package/dist/config/define-domain.js.map +1 -1
  48. package/dist/config/deploy.d.ts +1 -1
  49. package/dist/config/deploy.js +1 -1
  50. package/dist/config/index.d.ts +1 -1
  51. package/dist/config/index.d.ts.map +1 -1
  52. package/dist/define/remote-function.d.ts +32 -17
  53. package/dist/define/remote-function.d.ts.map +1 -1
  54. package/dist/define/remote-function.js +10 -9
  55. package/dist/define/remote-function.js.map +1 -1
  56. package/dist/define/view.d.ts +8 -6
  57. package/dist/define/view.d.ts.map +1 -1
  58. package/dist/define/view.js +8 -6
  59. package/dist/define/view.js.map +1 -1
  60. package/dist/deploy/check.d.ts +6 -6
  61. package/dist/deploy/check.d.ts.map +1 -1
  62. package/dist/deploy/check.js +1 -1
  63. package/dist/deploy/check.js.map +1 -1
  64. package/dist/deploy/index.d.ts +0 -1
  65. package/dist/deploy/index.d.ts.map +1 -1
  66. package/dist/deploy/index.js +0 -1
  67. package/dist/deploy/index.js.map +1 -1
  68. package/dist/deploy/meta.d.ts +11 -0
  69. package/dist/deploy/meta.d.ts.map +1 -1
  70. package/dist/deploy/meta.js +23 -0
  71. package/dist/deploy/meta.js.map +1 -1
  72. package/dist/dispatch/dispatcher.d.ts +17 -4
  73. package/dist/dispatch/dispatcher.d.ts.map +1 -1
  74. package/dist/dispatch/dispatcher.js +15 -3
  75. package/dist/dispatch/dispatcher.js.map +1 -1
  76. package/dist/dispatch/execute.d.ts +6 -2
  77. package/dist/dispatch/execute.d.ts.map +1 -1
  78. package/dist/dispatch/execute.js +1 -0
  79. package/dist/dispatch/execute.js.map +1 -1
  80. package/dist/dispatch/identity.d.ts +12 -10
  81. package/dist/dispatch/identity.d.ts.map +1 -1
  82. package/dist/dispatch/identity.js +10 -15
  83. package/dist/dispatch/identity.js.map +1 -1
  84. package/dist/dispatch/index.d.ts +1 -1
  85. package/dist/dispatch/index.d.ts.map +1 -1
  86. package/dist/dispatch/index.js +1 -1
  87. package/dist/dispatch/index.js.map +1 -1
  88. package/dist/dispatch/self.d.ts +25 -37
  89. package/dist/dispatch/self.d.ts.map +1 -1
  90. package/dist/dispatch/self.js +60 -30
  91. package/dist/dispatch/self.js.map +1 -1
  92. package/dist/domain/binding.d.ts +18 -0
  93. package/dist/domain/binding.d.ts.map +1 -0
  94. package/dist/domain/binding.js +29 -0
  95. package/dist/domain/binding.js.map +1 -0
  96. package/dist/domain/build-spec.d.ts.map +1 -1
  97. package/dist/domain/build-spec.js +22 -7
  98. package/dist/domain/build-spec.js.map +1 -1
  99. package/dist/domain/define.d.ts +28 -43
  100. package/dist/domain/define.d.ts.map +1 -1
  101. package/dist/domain/define.js +23 -60
  102. package/dist/domain/define.js.map +1 -1
  103. package/dist/domain/extend-functions.d.ts +48 -0
  104. package/dist/domain/extend-functions.d.ts.map +1 -0
  105. package/dist/domain/extend-functions.js +84 -0
  106. package/dist/domain/extend-functions.js.map +1 -0
  107. package/dist/domain/extend-views.d.ts +45 -0
  108. package/dist/domain/extend-views.d.ts.map +1 -0
  109. package/dist/domain/extend-views.js +116 -0
  110. package/dist/domain/extend-views.js.map +1 -0
  111. package/dist/index.d.ts +8 -4
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +13 -5
  114. package/dist/index.js.map +1 -1
  115. package/dist/method/class.d.ts +2 -2
  116. package/dist/method/class.d.ts.map +1 -1
  117. package/dist/method/class.js.map +1 -1
  118. package/dist/method/context.d.ts +18 -3
  119. package/dist/method/context.d.ts.map +1 -1
  120. package/dist/method/index.d.ts +2 -2
  121. package/dist/method/index.d.ts.map +1 -1
  122. package/dist/method/index.js.map +1 -1
  123. package/dist/method/single.d.ts +25 -38
  124. package/dist/method/single.d.ts.map +1 -1
  125. package/dist/method/single.js +1 -11
  126. package/dist/method/single.js.map +1 -1
  127. package/dist/server/auxiliary-routes.d.ts.map +1 -1
  128. package/dist/server/auxiliary-routes.js +14 -3
  129. package/dist/server/auxiliary-routes.js.map +1 -1
  130. package/dist/server/create.d.ts +2 -15
  131. package/dist/server/create.d.ts.map +1 -1
  132. package/dist/server/create.js +15 -51
  133. package/dist/server/create.js.map +1 -1
  134. package/dist/server/domain-entry.d.ts +10 -0
  135. package/dist/server/domain-entry.d.ts.map +1 -1
  136. package/dist/server/domain-entry.js +2 -0
  137. package/dist/server/domain-entry.js.map +1 -1
  138. package/dist/server/index.d.ts +0 -1
  139. package/dist/server/index.d.ts.map +1 -1
  140. package/dist/server/index.js +0 -1
  141. package/dist/server/index.js.map +1 -1
  142. package/dist/server/worker-entry.d.ts +3 -3
  143. package/dist/server/worker-entry.js +3 -3
  144. package/package.json +10 -11
  145. package/src/auth/check.ts +2 -6
  146. package/src/auth/index.ts +3 -1
  147. package/src/auth/issuer-mint.ts +75 -0
  148. package/src/auth/kernel-client.ts +31 -63
  149. package/src/cli/dotenv.ts +20 -0
  150. package/src/cli/index.ts +1 -1
  151. package/src/cli/log.ts +18 -0
  152. package/src/cli/publish.ts +341 -0
  153. package/src/cli/run.ts +109 -125
  154. package/src/cli/spec.ts +1 -0
  155. package/src/config/adapter.ts +9 -11
  156. package/src/config/define-domain.ts +184 -53
  157. package/src/config/deploy.ts +1 -1
  158. package/src/config/index.ts +1 -1
  159. package/src/define/remote-function.ts +56 -21
  160. package/src/define/view.ts +8 -6
  161. package/src/deploy/check.ts +7 -7
  162. package/src/deploy/index.ts +0 -1
  163. package/src/deploy/meta.ts +25 -0
  164. package/src/dispatch/dispatcher.ts +32 -9
  165. package/src/dispatch/execute.ts +7 -2
  166. package/src/dispatch/identity.ts +17 -22
  167. package/src/dispatch/index.ts +1 -1
  168. package/src/dispatch/self.ts +117 -41
  169. package/src/domain/binding.ts +37 -0
  170. package/src/domain/build-spec.ts +32 -7
  171. package/src/domain/define.ts +71 -78
  172. package/src/domain/extend-functions.ts +106 -0
  173. package/src/domain/extend-views.ts +151 -0
  174. package/src/index.ts +36 -7
  175. package/src/method/class.ts +11 -7
  176. package/src/method/context.ts +19 -3
  177. package/src/method/index.ts +8 -2
  178. package/src/method/single.ts +35 -42
  179. package/src/server/auxiliary-routes.ts +14 -3
  180. package/src/server/create.ts +15 -51
  181. package/src/server/domain-entry.ts +12 -0
  182. package/src/server/index.ts +0 -1
  183. package/src/server/worker-entry.ts +3 -3
  184. package/src/deploy/hash-spec.ts +0 -31
  185. package/src/domain/extend-core.ts +0 -301
  186. package/src/server/worker-meta.ts +0 -25
@@ -20,7 +20,12 @@ import type { UnresolvedIdentityExpr } from '@astrale-os/kernel-core'
20
20
 
21
21
  import { KernelClient, SchemaRegistry, type FnMap } from '@astrale-os/kernel-client'
22
22
  import { ClientPool } from '@astrale-os/kernel-client/pool'
23
- import { ClientSession, type BoundClientSessionView } from '@astrale-os/kernel-client/session'
23
+ import {
24
+ ClientSession,
25
+ allowAllDelegations,
26
+ delegationMintVia,
27
+ type BoundClientSessionView,
28
+ } from '@astrale-os/kernel-client/session'
24
29
 
25
30
  import type { RemoteIdentityConfig } from './identity'
26
31
 
@@ -29,11 +34,6 @@ import { signCredential } from './sign'
29
34
 
30
35
  const DELEGATION_TTL_SECONDS = 3600
31
36
 
32
- // The kernel's whoami — returns the AUTHENTICATED principal's graph node, so
33
- // the resolved id satisfies the mint syscall's `self.id === auth.principal`
34
- // invariant by construction (same seam the shell uses to resolve self).
35
- const WHOAMI_PATH = '/:kernel.astrale.ai:interface.Identity:whoami'
36
-
37
37
  // Shared per kernel URL — the expensive, identity-agnostic state. Sessions are
38
38
  // NOT shared (each binds a subject-specific delegation mint), but the pool
39
39
  // (connections) and registry (learned schemas) are reused across them.
@@ -63,8 +63,7 @@ function getPool(url: string): ClientPool<FnMap> {
63
63
  /**
64
64
  * Build a `BoundClientSessionView` that signs outbound calls as the composed
65
65
  * identity (the caller's delegation unioned with this function's own
66
- * identity). Remote-bound calls auto-follow the kernel's redirect and mint a
67
- * worker-scoped delegation via `@<subject>::mintDelegationCredential`.
66
+ * identity). Remote-bound redirects mint worker-scoped delegations via AuthApi.
68
67
  */
69
68
  export async function bindKernel(
70
69
  delegation: Delegation,
@@ -117,38 +116,37 @@ async function bindSession(
117
116
  },
118
117
  )
119
118
 
120
- // Self-reference in the mint closure is lazy it only fires on a delegation
121
- // cache miss while following a redirect, long after construction.
119
+ // The standard mint resolves the function identity through `auth.whoami()`.
122
120
  const session: ClientSession<FnMap> = new ClientSession<FnMap>({
123
121
  default: kernelUrl,
124
122
  schema: getRegistry(kernelUrl),
125
123
  pool: getPool(kernelUrl),
126
124
  delegation: {
127
- // NEXT-HOP mint: each hop mints AS ITSELF. The anchor is this function's
128
- // identity NODE id (resolved via whoami — `@` only accepts node ids, not
129
- // paths), so `self.id === auth.principal` holds by construction. The
130
- // minted delegation carries the session's authority expression (composed
131
- // for delegated sessions, self-only for selfKernel): the next worker
132
- // sees WHO called it while inheriting exactly that authority.
133
- // `skipDelegation` keeps the whoami + mint from re-entering this
134
- // closure — both target the kernel (same origin), so no delegation is
135
- // needed.
136
- mint: async (audience) => {
137
- const selfId = await resolveSelfId(session, credential, kernelUrl, config)
138
- const envelope = await session.call(
139
- `@${selfId}::mintDelegationCredential`,
140
- { audience, delegation: nextHopDelegation(), ttl: DELEGATION_TTL_SECONDS },
141
- { credential, skipDelegation: true },
142
- )
143
- if (typeof envelope !== 'string') {
144
- throw new Error(
145
- `mintDelegationCredential returned ${typeof envelope}, expected a credential string`,
146
- )
147
- }
148
- return { credential: envelope, ttl: DELEGATION_TTL_SECONDS }
149
- },
125
+ mint: delegationMintVia(() => session, credential),
150
126
  ttl: DELEGATION_TTL_SECONDS,
151
127
  },
128
+ delegationPolicy: {
129
+ ...allowAllDelegations({ default: kernelUrl }),
130
+ delegationFor: () => nextHopDelegation(),
131
+ },
132
+ })
133
+ return session.as(credential)
134
+ }
135
+
136
+ /**
137
+ * Bind a session to a PRE-SIGNED credential — no delegation mint, no composed
138
+ * grant; the credential IS the authority. The seam behind
139
+ * `DomainIssuer.sessionFor` (a worker acting as an identity it issues). Pool +
140
+ * schema registry are the same per-URL caches the composed sessions use.
141
+ */
142
+ export function bindCredentialSession(
143
+ kernelUrl: string,
144
+ credential: string,
145
+ ): BoundClientSessionView<FnMap> {
146
+ const session = new ClientSession<FnMap>({
147
+ default: kernelUrl,
148
+ schema: getRegistry(kernelUrl),
149
+ pool: getPool(kernelUrl),
152
150
  })
153
151
  return session.as(credential)
154
152
  }
@@ -178,33 +176,3 @@ export function makeSelfKernel(
178
176
  return bindSelfKernel(url, identity)
179
177
  }
180
178
  }
181
-
182
- // Function-identity node ids, cached per (kernel, issuer, subject). Sessions
183
- // are per-request, but a function's identity node is stable for the worker's
184
- // lifetime — one whoami per function, not one per redirect.
185
- const selfIds = new Map<string, string>()
186
-
187
- /**
188
- * Resolve THIS function's identity node id on the parent kernel via whoami,
189
- * authenticated with the composed credential (whose principal IS the function).
190
- */
191
- async function resolveSelfId(
192
- session: ClientSession<FnMap>,
193
- credential: string,
194
- kernelUrl: string,
195
- config: RemoteIdentityConfig,
196
- ): Promise<string> {
197
- const key = `${kernelUrl}|${config.issuer}|${config.subject}`
198
- const cached = selfIds.get(key)
199
- if (cached) return cached
200
- const me = (await session.call(WHOAMI_PATH, {}, { credential, skipDelegation: true })) as {
201
- id?: string
202
- } | null
203
- if (!me?.id) {
204
- throw new Error(
205
- `whoami returned no identity node for ${config.subject} — cannot anchor the next-hop delegation mint`,
206
- )
207
- }
208
- selfIds.set(key, me.id)
209
- return me.id
210
- }
package/src/cli/dotenv.ts CHANGED
@@ -43,3 +43,23 @@ export function loadDotenvFile(path: string): Record<string, string> {
43
43
  }
44
44
  return parseDotenv(contents)
45
45
  }
46
+
47
+ /**
48
+ * Read + parse a dotenv file the preset explicitly DECLARED (`secrets:`).
49
+ * Declared-but-absent is a wiring error (the gitignored file was never created
50
+ * on this machine), so it throws instead of loading `{}` — silently forwarding
51
+ * nothing surfaces much later as an empty credential at the provider. A file
52
+ * that exists but holds no keys is legitimate (a domain with no secrets yet).
53
+ */
54
+ export function loadDeclaredSecrets(path: string, declaration: string): Record<string, string> {
55
+ let contents: string
56
+ try {
57
+ contents = readFileSync(path, 'utf-8')
58
+ } catch {
59
+ throw new Error(
60
+ `Secrets file '${declaration}' is declared in the preset but missing at ${path}. ` +
61
+ `Create it (it may be empty) or remove 'secrets:' from the preset.`,
62
+ )
63
+ }
64
+ return parseDotenv(contents)
65
+ }
package/src/cli/index.ts CHANGED
@@ -12,4 +12,4 @@
12
12
  */
13
13
 
14
14
  export { run, parseArgs } from './run'
15
- export { parseDotenv, loadDotenvFile } from './dotenv'
15
+ export { parseDotenv, loadDotenvFile, loadDeclaredSecrets } from './dotenv'
package/src/cli/log.ts ADDED
@@ -0,0 +1,18 @@
1
+ /** CLI output helpers + ANSI colours, shared by run.ts and publish.ts. */
2
+
3
+ export const GREEN = '\x1b[32m'
4
+ export const DIM = '\x1b[2m'
5
+ export const BOLD = '\x1b[1m'
6
+ export const YELLOW = '\x1b[33m'
7
+ export const RED = '\x1b[31m'
8
+ export const RESET = '\x1b[0m'
9
+
10
+ export function info(msg: string): void {
11
+ process.stdout.write(`${DIM}›${RESET} ${msg}\n`)
12
+ }
13
+ export function warn(msg: string): void {
14
+ process.stderr.write(`${YELLOW}!${RESET} ${msg}\n`)
15
+ }
16
+ export function error(msg: string): void {
17
+ process.stderr.write(`${RED}✗${RESET} ${msg}\n`)
18
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * All `publish` logic — the schema-publish step, the admin-catalog register, and
3
+ * the release orchestration tying them together. `run.ts` only parses args and
4
+ * dispatches here.
5
+ *
6
+ * A release has two halves that must stay in sync: the CONTRACT (the `./schema`
7
+ * package on npm, so other domains can `imports`/`implements`/`extends` it) and
8
+ * the DEPLOYMENT (the worker URL in the admin catalog). They run npm-FIRST — npm
9
+ * publish is irreversible, so the catalog is registered only on schema-publish
10
+ * success, never pointing at an unpublished contract.
11
+ *
12
+ * Why `pnpm pack` then `npm publish <tarball>` (not a plain `npm publish`): a
13
+ * schema package ships only `dist` and uses `publishConfig` to repoint
14
+ * main/exports from source `.ts` to `dist` — a field override only PNPM applies,
15
+ * so npm would otherwise publish an entry pointing at unshipped source.
16
+ */
17
+ import { spawn } from 'node:child_process'
18
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+
22
+ import { BOLD, DIM, error, GREEN, info, RESET, warn } from './log'
23
+
24
+ type SchemaPkg = {
25
+ name?: string
26
+ version?: string
27
+ private?: boolean
28
+ files?: readonly string[]
29
+ peerDependencies?: Record<string, string>
30
+ dependencies?: Record<string, string>
31
+ optionalDependencies?: Record<string, string>
32
+ publishConfig?: { access?: string }
33
+ }
34
+
35
+ // ── release orchestration (schema THEN catalog) ──────────────────────────────
36
+
37
+ /** `publish` (no deploy): publish the schema, then register the deployed URL. */
38
+ export async function runRelease(opts: {
39
+ projectDir: string
40
+ origin: string
41
+ name?: string
42
+ publicUrl?: string
43
+ skipSchema: boolean
44
+ dryRun: boolean
45
+ otp?: string
46
+ installByDefault?: boolean
47
+ }): Promise<number> {
48
+ const schemaCode = await publishSchemaBeforeCatalog(opts.projectDir, opts)
49
+ if (schemaCode !== 0) return schemaCode
50
+
51
+ const url = opts.publicUrl ?? `https://${opts.origin}`
52
+ if (opts.dryRun) {
53
+ info(`dry-run: would register ${opts.origin} → ${url} in the admin catalog (not registered).`)
54
+ return 0
55
+ }
56
+ info(`registering ${opts.origin} → ${url} in the admin catalog (no deploy)`)
57
+ return await registerCatalog(opts.projectDir, opts, url)
58
+ }
59
+
60
+ /** The `deploy --publish` tail: publish the schema, then register the just-deployed URL. */
61
+ export async function runReleaseAfterDeploy(opts: {
62
+ projectDir: string
63
+ origin: string
64
+ deployedUrl: string
65
+ name?: string
66
+ skipSchema: boolean
67
+ otp?: string
68
+ installByDefault?: boolean
69
+ }): Promise<number> {
70
+ const schemaCode = await publishSchemaBeforeCatalog(opts.projectDir, { ...opts, dryRun: false })
71
+ if (schemaCode !== 0) return schemaCode
72
+ return await registerCatalog(opts.projectDir, opts, opts.deployedUrl)
73
+ }
74
+
75
+ /**
76
+ * The schema half of a release. npm publish is irreversible, so it runs FIRST:
77
+ * returns 0 to proceed (published | skipped | no `./schema` package), non-zero to
78
+ * ABORT before the catalog touch — so the catalog never points at an unpublished
79
+ * contract.
80
+ */
81
+ async function publishSchemaBeforeCatalog(
82
+ projectDir: string,
83
+ opts: { skipSchema: boolean; dryRun: boolean; otp?: string },
84
+ ): Promise<number> {
85
+ if (opts.skipSchema) return 0
86
+ if (!existsSync(join(projectDir, 'schema', 'package.json'))) {
87
+ info('no ./schema package — registering the catalog only.')
88
+ return 0
89
+ }
90
+ const code = await publishSchema({
91
+ projectDir,
92
+ schemaDir: 'schema',
93
+ dryRun: opts.dryRun,
94
+ ...(opts.otp !== undefined ? { otp: opts.otp } : {}),
95
+ })
96
+ if (code !== 0)
97
+ error(
98
+ 'schema publish failed — NOT registering the catalog (a catalog entry must never point ' +
99
+ 'at an unpublished contract). Fix the schema package and re-run, or pass --skip-schema.',
100
+ )
101
+ return code
102
+ }
103
+
104
+ /**
105
+ * Register a domain's public URL in the admin catalog by shelling out to the
106
+ * operator CLI (`astrale domain publish`). Deliberately a subprocess: all
107
+ * credential/admin-target resolution lives in `@astrale-os/cli`, kept out of
108
+ * this build tool. A missing `astrale` is reported with the exact line to run by
109
+ * hand. The catalog `name` is a short slug (the origin's first label by default),
110
+ * distinct from the FQDN origin and from any `@scope/…` npm package name.
111
+ */
112
+ async function registerCatalog(
113
+ projectDir: string,
114
+ opts: { origin: string; name?: string; installByDefault?: boolean },
115
+ url: string,
116
+ ): Promise<number> {
117
+ const { description } = readPackageMeta(projectDir)
118
+ const name = opts.name ?? opts.origin.split('.')[0] ?? opts.origin
119
+ // `--public-url`, not `--url`: the operator CLI reserves `--url` for kernel targeting.
120
+ const cliArgs = [
121
+ 'domain',
122
+ 'publish',
123
+ '--origin',
124
+ opts.origin,
125
+ '--name',
126
+ name,
127
+ '--public-url',
128
+ url,
129
+ ]
130
+ if (description) cliArgs.push('--description', description)
131
+ if (opts.installByDefault) cliArgs.push('--install-by-default')
132
+
133
+ info(`publishing to the admin catalog → astrale ${cliArgs.join(' ')}`)
134
+ return await new Promise<number>((resolve) => {
135
+ const child = spawn('astrale', cliArgs, { stdio: 'inherit' })
136
+ child.on('error', (err) => {
137
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT')
138
+ error(
139
+ `\`astrale\` CLI not found on PATH. Register it by hand:\n astrale ${cliArgs.join(' ')}`,
140
+ )
141
+ else error(`failed to run \`astrale domain publish\`: ${err.message}`)
142
+ resolve(1)
143
+ })
144
+ child.on('exit', (code) => resolve(code ?? 1))
145
+ })
146
+ }
147
+
148
+ function readPackageMeta(projectDir: string): { name?: string; description?: string } {
149
+ try {
150
+ return JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as {
151
+ name?: string
152
+ description?: string
153
+ }
154
+ } catch {
155
+ return {}
156
+ }
157
+ }
158
+
159
+ // ── schema publish ───────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Build a domain's thin schema package and publish it to npm. Operates on the
163
+ * schema PACKAGE (its own package.json), not astrale.config.ts, so it needs no
164
+ * adapter — `publish --schema` runs it config-free.
165
+ */
166
+ export async function publishSchema(args: {
167
+ projectDir: string
168
+ /** Schema-package dir. Default: `<projectDir>/schema`, else `<projectDir>`. */
169
+ schemaDir?: string
170
+ dryRun: boolean
171
+ /** 2FA one-time password forwarded to `npm publish`. */
172
+ otp?: string
173
+ }): Promise<number> {
174
+ const dir = resolveSchemaDir(args.projectDir, args.schemaDir)
175
+ if (!dir) {
176
+ error(
177
+ `No schema package found (no package.json at ./schema or the current dir). A schema\n` +
178
+ ` package declares peerDependencies (@astrale-os/kernel-core + kernel-dsl + zod) and a\n` +
179
+ ` publishConfig that repoints main/exports to ./dist — see domains/workspace/schema.`,
180
+ )
181
+ return 1
182
+ }
183
+
184
+ const pkgPath = join(dir, 'package.json')
185
+ let pkg: SchemaPkg
186
+ try {
187
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as SchemaPkg
188
+ } catch (err) {
189
+ error(`Could not read ${pkgPath}: ${(err as Error).message}`)
190
+ return 1
191
+ }
192
+
193
+ const problems = preflight(pkg)
194
+ if (problems.length > 0) {
195
+ error(`Schema package is not ready to publish (${pkgPath}):`)
196
+ for (const p of problems) process.stderr.write(` • ${p}\n`)
197
+ return 1
198
+ }
199
+ const name = pkg.name as string
200
+ const version = pkg.version as string
201
+
202
+ // Idempotent in BOTH modes: a dry-run must skip too, since `npm publish
203
+ // --dry-run` still validates against the registry and errors on an existing version.
204
+ if (await isOnRegistry(name, version)) {
205
+ info(
206
+ `${BOLD}${name}@${version}${RESET} is already published — ` +
207
+ (args.dryRun ? `a real run would skip it.` : `bump the version to ship again.`),
208
+ )
209
+ return 0
210
+ }
211
+
212
+ const dest = mkdtempSync(join(tmpdir(), 'astrale-schema-pack-'))
213
+ try {
214
+ info(`packing ${BOLD}${name}@${version}${RESET} from ${rel(args.projectDir, dir)}`)
215
+ if ((await runInherit('pnpm', ['pack', '--pack-destination', dest], dir)) !== 0) {
216
+ error(`pnpm pack failed — see the build output above.`)
217
+ return 1
218
+ }
219
+ const tarball = readdirSync(dest)
220
+ .filter((f) => f.endsWith('.tgz'))
221
+ .map((f) => join(dest, f))[0]
222
+ if (!tarball) {
223
+ error(`pnpm pack produced no .tgz in ${dest}.`)
224
+ return 1
225
+ }
226
+
227
+ // `--access` mirrors the manifest's baked publishConfig.access; npm's 2FA
228
+ // prompt passes through `stdio: 'inherit'` (or `--otp` for non-interactive).
229
+ const access = pkg.publishConfig?.access ?? 'public'
230
+ const publishArgs = [
231
+ 'publish',
232
+ tarball,
233
+ '--access',
234
+ access,
235
+ ...(args.dryRun ? ['--dry-run'] : []),
236
+ ...(args.otp ? ['--otp', args.otp] : []),
237
+ ]
238
+ info(
239
+ args.dryRun
240
+ ? `dry-run: ${DIM}npm ${publishArgs.join(' ')}${RESET}`
241
+ : `publishing ${DIM}(a browser / OTP prompt may appear for 2FA)${RESET}`,
242
+ )
243
+ const code = await runInherit('npm', publishArgs)
244
+ if (code !== 0) {
245
+ error(
246
+ `npm publish exited ${code}. An auth error (E401/E404) usually means the session ` +
247
+ `expired — \`npm login\` and retry.`,
248
+ )
249
+ return code
250
+ }
251
+ if (args.dryRun) info(`dry-run complete — nothing was published.`)
252
+ else printPublished(name, version)
253
+ return 0
254
+ } finally {
255
+ try {
256
+ rmSync(dest, { recursive: true, force: true })
257
+ } catch {
258
+ warn(`(non-fatal) could not clean the temp pack dir ${dest}`)
259
+ }
260
+ }
261
+ }
262
+
263
+ /** Locate the schema package: an explicit path, else `<projectDir>/schema`, else `<projectDir>`. */
264
+ function resolveSchemaDir(projectDir: string, explicit?: string): string | undefined {
265
+ const candidates = explicit
266
+ ? [join(projectDir, explicit)]
267
+ : [join(projectDir, 'schema'), projectDir]
268
+ return candidates.find((c) => existsSync(join(c, 'package.json')))
269
+ }
270
+
271
+ /** Static guards against the classic schema-publish footguns, before any network call. */
272
+ function preflight(pkg: SchemaPkg): string[] {
273
+ const problems: string[] = []
274
+ if (!pkg.name) problems.push('package.json has no "name".')
275
+ if (!pkg.version) problems.push('package.json has no "version".')
276
+ else if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(pkg.version))
277
+ problems.push(`"version" ("${pkg.version}") must be an exact semver, not a range.`)
278
+ if (pkg.private) problems.push('"private": true — remove it to publish.')
279
+ // Peers, not deps: bundling kernel-core/dsl/zod gives the consumer a 2nd copy
280
+ // and breaks the def `__brand` identity at compile time.
281
+ if (!pkg.peerDependencies || Object.keys(pkg.peerDependencies).length === 0)
282
+ problems.push(
283
+ 'no "peerDependencies" — peer-depend on @astrale-os/kernel-core (+ kernel-dsl, zod).',
284
+ )
285
+ if (!pkg.files?.includes('dist'))
286
+ problems.push('"files" must include "dist" (ship only the built output).')
287
+ if (!pkg.publishConfig)
288
+ problems.push('no "publishConfig" — main/types/exports won\'t repoint to ./dist at pack time.')
289
+ for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) {
290
+ for (const [dep, spec] of Object.entries(pkg[field] ?? {})) {
291
+ if (/^(link:|file:|workspace:)/.test(spec))
292
+ problems.push(
293
+ `${field}["${dep}"] is "${spec}" — npm can't publish that; use a semver range.`,
294
+ )
295
+ }
296
+ }
297
+ return problems
298
+ }
299
+
300
+ /** True if `<name>@<version>` already exists on the registry. */
301
+ async function isOnRegistry(name: string, version: string): Promise<boolean> {
302
+ return await new Promise<boolean>((resolve) => {
303
+ let out = ''
304
+ const child = spawn('npm', ['view', `${name}@${version}`, 'version'], {
305
+ stdio: ['ignore', 'pipe', 'ignore'],
306
+ })
307
+ child.stdout?.on('data', (d) => (out += String(d)))
308
+ child.on('error', () => resolve(false)) // npm missing / not-found / offline → let publish surface it
309
+ child.on('exit', () => resolve(out.trim().length > 0))
310
+ })
311
+ }
312
+
313
+ async function runInherit(cmd: string, cmdArgs: readonly string[], cwd?: string): Promise<number> {
314
+ return await new Promise<number>((resolve) => {
315
+ const child = spawn(cmd, cmdArgs as string[], { stdio: 'inherit', ...(cwd ? { cwd } : {}) })
316
+ child.on('error', (err) => {
317
+ const c = (err as NodeJS.ErrnoException).code
318
+ error(
319
+ c === 'ENOENT'
320
+ ? `\`${cmd}\` not found on PATH.`
321
+ : `failed to run \`${cmd}\`: ${err.message}`,
322
+ )
323
+ resolve(1)
324
+ })
325
+ child.on('exit', (code) => resolve(code ?? 1))
326
+ })
327
+ }
328
+
329
+ function printPublished(name: string, version: string): void {
330
+ process.stdout.write(
331
+ `\n${GREEN}✓${RESET} published ${BOLD}${name}@${version}${RESET}\n` +
332
+ ` ${DIM}consume it:${RESET} ${BOLD}import { schema as S } from '${name}'${RESET}` +
333
+ `${DIM} → imports: [S], implements: [S.interfaces.X]${RESET}\n` +
334
+ ` ${DIM}and add ${BOLD}requires: ['<this-origin>']${RESET}${DIM} on the consumer ` +
335
+ `(producer installs first).${RESET}\n\n`,
336
+ )
337
+ }
338
+
339
+ function rel(from: string, to: string): string {
340
+ return to.startsWith(from) ? `.${to.slice(from.length) || '/'}` : to
341
+ }