@astrale-os/cli 0.4.0-alpha.13 → 0.6.0-alpha.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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +24396 -9632
  5. package/package.json +24 -22
  6. package/src/command.ts +2 -0
  7. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  8. package/src/commands/__tests__/domain-list.test.ts +6 -2
  9. package/src/commands/__tests__/help-contract.test.ts +27 -14
  10. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  11. package/src/commands/__tests__/ls.test.ts +1 -1
  12. package/src/commands/__tests__/read-commands.test.ts +201 -0
  13. package/src/commands/__tests__/view.test.ts +100 -0
  14. package/src/commands/call.ts +27 -44
  15. package/src/commands/describe.ts +57 -58
  16. package/src/commands/domain/install.ts +9 -9
  17. package/src/commands/domain/list.ts +2 -2
  18. package/src/commands/domain/publish.ts +3 -3
  19. package/src/commands/get.ts +48 -23
  20. package/src/commands/identity/register.ts +27 -33
  21. package/src/commands/instance/active.ts +2 -2
  22. package/src/commands/instance/create.ts +1 -1
  23. package/src/commands/instance/delete.ts +3 -3
  24. package/src/commands/instance/list.ts +8 -3
  25. package/src/commands/instance/status.ts +4 -3
  26. package/src/commands/instance/use.ts +2 -2
  27. package/src/commands/logs.ts +8 -8
  28. package/src/commands/ls.ts +77 -55
  29. package/src/commands/mutate.ts +191 -0
  30. package/src/commands/query.ts +307 -20
  31. package/src/commands/session/analyze.ts +50 -0
  32. package/src/commands/session/list.ts +36 -0
  33. package/src/commands/token.ts +4 -7
  34. package/src/commands/view-serve.ts +26 -0
  35. package/src/commands/view.ts +614 -0
  36. package/src/connect-core.test.ts +42 -0
  37. package/src/connect-core.ts +53 -0
  38. package/src/kernel/__tests__/auth.test.ts +1 -0
  39. package/src/kernel/__tests__/expand.test.ts +123 -0
  40. package/src/kernel/client.ts +33 -37
  41. package/src/kernel/expand.ts +55 -61
  42. package/src/kernel/graph.ts +96 -0
  43. package/src/kernel/index.ts +18 -3
  44. package/src/kernel/options.ts +1 -1
  45. package/src/kernel/run.ts +0 -13
  46. package/src/lib/__tests__/instance-target.test.ts +34 -0
  47. package/src/lib/__tests__/table.test.ts +1 -1
  48. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  49. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  50. package/src/lib/admin-domain.ts +8 -4
  51. package/src/lib/admin-instance.ts +5 -1
  52. package/src/lib/config.ts +1 -0
  53. package/src/lib/domain-identity.ts +1 -1
  54. package/src/lib/instance-target.ts +5 -0
  55. package/src/lib/instance.ts +11 -0
  56. package/src/lib/log.ts +12 -2
  57. package/src/lib/login-flow.ts +41 -4
  58. package/src/lib/provision-instance.ts +2 -2
  59. package/src/lib/self.ts +1 -3
  60. package/src/lib/view/open-intent.ts +97 -0
  61. package/src/lib/view/resolve.ts +104 -0
  62. package/src/lib/view/server.ts +294 -0
  63. package/src/lib/view/session.ts +123 -0
  64. package/src/lib/view/snapshot.ts +101 -0
  65. package/src/program.ts +16 -3
  66. package/src/registry.ts +1 -1
  67. package/src/setup/render.ts +1 -3
  68. package/src/setup/steps/instance.ts +2 -2
  69. package/src/telemetry/__tests__/gate.test.ts +63 -0
  70. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  71. package/src/telemetry/__tests__/redact.test.ts +79 -0
  72. package/src/telemetry/__tests__/session.test.ts +166 -0
  73. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  74. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  75. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  76. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  77. package/src/telemetry/adapters/claude-code.ts +90 -0
  78. package/src/telemetry/adapters/codex.ts +160 -0
  79. package/src/telemetry/adapters/index.ts +45 -0
  80. package/src/telemetry/adapters/types.ts +21 -0
  81. package/src/telemetry/analyze.ts +227 -0
  82. package/src/telemetry/gate.ts +79 -0
  83. package/src/telemetry/recorder.ts +57 -0
  84. package/src/telemetry/redact.ts +53 -0
  85. package/src/telemetry/session.ts +88 -0
  86. package/src/telemetry/settings.ts +26 -0
  87. package/src/telemetry/store.ts +91 -0
  88. package/src/telemetry/trigger.ts +119 -0
  89. package/src/telemetry/types.ts +64 -0
  90. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  91. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  92. package/studio/client/dist/index.html +2 -2
  93. package/studio/server/agent/ask.ts +7 -2
  94. package/studio/server/agent/claude.ts +15 -3
  95. package/studio/server/agent/runner.ts +4 -1
  96. package/studio/server/agent/session-id.ts +13 -0
  97. package/studio/server/api.ts +50 -32
  98. package/studio/server/cache.ts +17 -6
  99. package/studio/server/client-package.test.ts +147 -0
  100. package/studio/server/client-package.ts +242 -0
  101. package/studio/server/index.ts +13 -0
  102. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  103. package/studio/server/introspect/anatomy-extras.ts +48 -49
  104. package/studio/server/introspect/anatomy.ts +7 -7
  105. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  106. package/studio/server/introspect/overlay-tsmorph.ts +230 -72
  107. package/studio/server/state/harness-gateway.ts +12 -3
  108. package/studio/server/state/harness-token.ts +0 -0
  109. package/studio/server/state/views.test.ts +90 -0
  110. package/studio/server/state/views.ts +396 -99
  111. package/studio/server/state/visibility.ts +10 -6
  112. package/studio/server/view-dev-server.test.ts +111 -0
  113. package/studio/server/view-dev-server.ts +372 -0
  114. package/studio/shared/types.ts +57 -10
  115. package/viewer/dist/index.html +93 -0
  116. package/viewer/dist/main.js +71 -0
  117. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  118. package/src/kernel/remote-routing.ts +0 -88
  119. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  120. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  121. package/studio/tsconfig.json +0 -23
@@ -0,0 +1,53 @@
1
+ /**
2
+ * connect-core — the frozen, curated re-export surface the connect-host adapter
3
+ * borrows from the CLI. It is the ONE public seam between `@astrale-os/cli` and
4
+ * `@astrale-os/connect-host`; nothing else in cli/src is contract.
5
+ *
6
+ * Consumed by `@astrale-os/connect-host`; temps-2 seed of a future
7
+ * `@astrale-os/client-core`. Re-export ONLY — no logic lives here.
8
+ */
9
+
10
+ // ── identity registry (~/.astrale/identities.json) ──────────────────────────
11
+ export { readIdentities, getDefault, getIdentity } from './lib/identity'
12
+ export type { Identity, IdentityStore } from './lib/identity'
13
+
14
+ // ── keypairs on disk + self-grant mint ──────────────────────────────────────
15
+ export { signAs, listIdentityKeys } from './lib/keys'
16
+
17
+ // ── instance bookmarks (~/.astrale/instances.json) ──────────────────────────
18
+ export {
19
+ readInstances,
20
+ resetInstancesMemo,
21
+ resolveInstance,
22
+ normalizeInstanceKernelUrl,
23
+ orgIdForAudience,
24
+ } from './lib/instance'
25
+ export type { InstanceEntry, InstanceStore, ResolvedInstance } from './lib/instance'
26
+
27
+ // ── instance-target resolution (bookmark | managed | admin | free url) ──────
28
+ export { resolveInstanceTarget } from './lib/instance-target'
29
+ export type { ResolvedInstanceTarget } from './lib/instance-target'
30
+
31
+ // ── global config (~/.astrale/config.json) ──────────────────────────────────
32
+ export { readConfig } from './lib/config'
33
+ export type { AstraleConfig } from './lib/config'
34
+
35
+ // ── credential resolution (self-grant + IdP) ────────────────────────────────
36
+ export { resolveCredential, IdpRefreshTransientError, IdpOrgMembershipError } from './kernel/auth'
37
+
38
+ // ── IdP login (device flow) — driven by connect-host's `auth.login` op ───────
39
+ export { loginViaIdp, resolveIdpName } from './lib/login-flow'
40
+ export type { LoginFlowOpts, LoginResult, DeviceVerification } from './lib/login-flow'
41
+
42
+ // ── IdP session state (so connect-host can flag "login required" rows) ───────
43
+ export { readIdpSession, isSessionExpired } from './lib/idp'
44
+ export type { IdpSession } from './lib/idp'
45
+
46
+ // ── typed errors ────────────────────────────────────────────────────────────
47
+ export { AuthError } from './errors'
48
+
49
+ // ── custom-CA fetch (private-CA local stacks) ───────────────────────────────
50
+ export { fetchWithCaFile } from './kernel/ca-fetch'
51
+
52
+ // ── path resolution (~/.astrale layout) ─────────────────────────────────────
53
+ export { paths, createPaths } from './lib/env'
@@ -12,6 +12,7 @@ const config: AstraleConfig = {
12
12
  url: 'https://admin.eu.astrale.ai/api',
13
13
  issuer: 'https://admin.eu.astrale.ai/api',
14
14
  },
15
+ telemetry: { enabled: true },
15
16
  }
16
17
 
17
18
  describe('resolveKeyIdentityAuthOptions', () => {
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { SelfResolverContext } from '../../lib/self'
4
+
5
+ import { resolveSelfIdLazy } from '../expand'
6
+
7
+ describe('resolveSelfIdLazy', () => {
8
+ test('refreshes a cached IdP @self registration via whoami', async () => {
9
+ const writes: unknown[][] = []
10
+ const ctx: SelfResolverContext = {
11
+ identity: {
12
+ name: 'bryan',
13
+ subject: 'user_01KC9MW1M6S5J6V9ERSRJ8RDYF',
14
+ createdAt: '2026-06-25T08:00:00.000Z',
15
+ source: 'idp',
16
+ registrations: {
17
+ bryan: {
18
+ iss: 'https://old.example',
19
+ sub: '4ad8e4ce-5cf7-4c2e-ab29-5549023dc8bf',
20
+ registeredAt: '2026-06-25T08:00:00.000Z',
21
+ },
22
+ },
23
+ },
24
+ instanceSlug: 'bryan',
25
+ instanceSigned: false,
26
+ }
27
+
28
+ const id = await resolveSelfIdLazy(
29
+ ctx,
30
+ {},
31
+ {
32
+ whoami: async () => ({
33
+ id: 'f011538e-9edc-4c29-92ce-9b81b6c1b6c7',
34
+ kernelUrl: 'https://bryan.example',
35
+ }),
36
+ setRegistration: async (...args) => {
37
+ writes.push(args)
38
+ },
39
+ now: () => new Date('2026-06-25T08:15:00.000Z'),
40
+ },
41
+ )
42
+
43
+ expect(id).toBe('f011538e-9edc-4c29-92ce-9b81b6c1b6c7')
44
+ expect(writes).toEqual([
45
+ [
46
+ 'bryan',
47
+ 'bryan',
48
+ {
49
+ iss: 'https://bryan.example',
50
+ sub: 'f011538e-9edc-4c29-92ce-9b81b6c1b6c7',
51
+ registeredAt: '2026-06-25T08:15:00.000Z',
52
+ },
53
+ ],
54
+ ])
55
+ })
56
+
57
+ test('keeps the cached IdP id when whoami is temporarily unavailable', async () => {
58
+ const ctx: SelfResolverContext = {
59
+ identity: {
60
+ name: 'bryan',
61
+ subject: 'user_01KC9MW1M6S5J6V9ERSRJ8RDYF',
62
+ createdAt: '2026-06-25T08:00:00.000Z',
63
+ source: 'idp',
64
+ registrations: {
65
+ bryan: {
66
+ iss: 'https://bryan.example',
67
+ sub: 'cached-id',
68
+ registeredAt: '2026-06-25T08:00:00.000Z',
69
+ },
70
+ },
71
+ },
72
+ instanceSlug: 'bryan',
73
+ instanceSigned: false,
74
+ }
75
+
76
+ await expect(
77
+ resolveSelfIdLazy(
78
+ ctx,
79
+ {},
80
+ {
81
+ whoami: async () => {
82
+ throw new Error('network down')
83
+ },
84
+ },
85
+ ),
86
+ ).resolves.toBe('cached-id')
87
+ })
88
+
89
+ test('does not run whoami for key-backed registrations', async () => {
90
+ const ctx: SelfResolverContext = {
91
+ identity: {
92
+ name: 'alice',
93
+ subject: 'alice',
94
+ createdAt: '2026-06-25T08:00:00.000Z',
95
+ source: 'key',
96
+ registrations: {
97
+ bryan: {
98
+ iss: 'https://bryan.example',
99
+ sub: 'key-node-id',
100
+ registeredAt: '2026-06-25T08:00:00.000Z',
101
+ },
102
+ },
103
+ },
104
+ instanceSlug: 'bryan',
105
+ instanceSigned: false,
106
+ }
107
+ let called = false
108
+
109
+ const id = await resolveSelfIdLazy(
110
+ ctx,
111
+ {},
112
+ {
113
+ whoami: async () => {
114
+ called = true
115
+ return { id: 'should-not-be-used', kernelUrl: 'https://bryan.example' }
116
+ },
117
+ },
118
+ )
119
+
120
+ expect(id).toBe('key-node-id')
121
+ expect(called).toBe(false)
122
+ })
123
+ })
@@ -1,16 +1,15 @@
1
1
  import { KernelClient, type FnMap } from '@astrale-os/kernel-client'
2
- import { ClientSession } from '@astrale-os/kernel-client/session'
2
+ import { ClientSession, delegationMintVia } from '@astrale-os/kernel-client/session'
3
3
 
4
4
  import type { AdminTargetCommandOpts } from '../lib/admin-target'
5
5
  import type { KernelCommandOpts } from './types'
6
6
 
7
7
  import { AstraleError } from '../errors'
8
- import { ADMIN_INSTANCE, type InstanceInfo } from '../lib/admin-instance'
8
+ import { adminInstanceMethod, type InstanceInfo } from '../lib/admin-instance'
9
9
  import { readConfig } from '../lib/config'
10
10
  import { resolveInstanceTarget, type ResolvedInstanceTarget } from '../lib/instance-target'
11
11
  import { resolveCredential } from './auth'
12
12
  import { fetchWithCaFile } from './ca-fetch'
13
- import { mintRemoteCredential } from './remote-routing'
14
13
 
15
14
  const DEFAULT_TIMEOUT_MS = 30_000
16
15
 
@@ -22,7 +21,7 @@ export type ClientContext = {
22
21
  config: Awaited<ReturnType<typeof readConfig>>
23
22
  }
24
23
 
25
- type ResolvedKernelTarget = {
24
+ export type ResolvedKernelTarget = {
26
25
  url: string
27
26
  audience: string
28
27
  slug?: string
@@ -30,44 +29,50 @@ type ResolvedKernelTarget = {
30
29
  caFile?: string
31
30
  }
32
31
 
33
- /**
34
- * Connect to a kernel instance, run `fn`, then disconnect.
35
- * The new client is lazy: construction does no I/O. We only need to
36
- * release sockets on the way out.
37
- */
38
- export async function withKernelClient<T>(
32
+ /** Resolve the kernel a command targets (`--url` / `-i` / active instance). */
33
+ export async function resolveKernelTarget(
39
34
  opts: KernelCommandOpts,
40
- fn: (ctx: ClientContext) => Promise<T>,
41
- ): Promise<T> {
42
- const config = await readConfig()
35
+ config: Awaited<ReturnType<typeof readConfig>>,
36
+ ): Promise<ResolvedKernelTarget> {
43
37
  // Ad-hoc `--url` — unknown kernel. Stamp the URL itself as audience,
44
38
  // no slug for per-instance signing.
45
- let target: ResolvedKernelTarget
46
39
  if (opts.url && !opts.instance) {
47
40
  const resolved = await resolveInstanceTarget(
48
41
  { source: 'url', url: opts.url },
49
42
  { config, admin: adminLookupOpts(opts) },
50
43
  )
51
- target = resolvedToKernelTarget(resolved)
52
- } else {
53
- const resolved = await resolveInstanceTarget(
54
- opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
55
- {
56
- config,
57
- admin: adminLookupOpts(opts),
58
- managed: (slug) => lookupManagedInstance(slug, opts),
59
- },
60
- )
61
- target = resolvedToKernelTarget(resolved, opts.url)
44
+ return resolvedToKernelTarget(resolved)
62
45
  }
46
+ const resolved = await resolveInstanceTarget(
47
+ opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
48
+ {
49
+ config,
50
+ admin: adminLookupOpts(opts),
51
+ managed: (slug) => lookupManagedInstance(slug, opts),
52
+ },
53
+ )
54
+ return resolvedToKernelTarget(resolved, opts.url)
55
+ }
63
56
 
57
+ /**
58
+ * Connect to a kernel instance, run `fn`, then disconnect.
59
+ * The new client is lazy: construction does no I/O. We only need to
60
+ * release sockets on the way out.
61
+ */
62
+ export async function withKernelClient<T>(
63
+ opts: KernelCommandOpts,
64
+ fn: (ctx: ClientContext) => Promise<T>,
65
+ ): Promise<T> {
66
+ const config = await readConfig()
67
+ const target = await resolveKernelTarget(opts, config)
64
68
  return withResolvedKernelClient(opts, config, target, fn)
65
69
  }
66
70
 
67
71
  async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
68
72
  return await withAdminKernelClient(
69
73
  adminLookupOpts(opts),
70
- async (ctx) => (await ctx.client.call(`${ADMIN_INSTANCE}/info`, { id: slug })) as InstanceInfo,
74
+ async (ctx) =>
75
+ (await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
71
76
  )
72
77
  }
73
78
 
@@ -131,21 +136,12 @@ async function withResolvedKernelClient<T>(
131
136
  // exponential backoff on ECONNREFUSED / 5xx). The user can re-run.
132
137
  const requestTimeout = resolveTimeoutMs(opts.timeout)
133
138
  const fetchImpl = target.caFile ? fetchWithCaFile(target.caFile) : undefined
134
- // The delegation mint references `client` lazily — it only fires on a cache
135
- // miss during an actual remote call, long after this binding is initialised,
136
- // so the self-reference inside the closure is safe.
137
139
  const client: ClientSession<FnMap> = new ClientSession<FnMap>({
138
140
  default: target.url,
139
141
  identity: credential,
140
- // Remote-bound functions redirect to a worker that verifies `aud` against
141
- // its own identity. The session follows the redirect and mints a worker-
142
- // scoped delegation here, for the audience the kernel carries on the
143
- // redirect (`redirection.iss`, surfaced by the default iss-aware policy).
144
142
  delegation: {
145
- mint: async (audience) => ({
146
- credential: await mintRemoteCredential(client, audience, credential),
147
- ttl: 3600,
148
- }),
143
+ // Resolve lazily: the session must exist before the cache mints.
144
+ mint: delegationMintVia(() => client, credential),
149
145
  ttl: 3600,
150
146
  },
151
147
  pool: {
@@ -1,14 +1,8 @@
1
1
  import type { InstanceInfo } from '../lib/admin-instance'
2
2
  import type { KernelCommandOpts } from './types'
3
3
 
4
- import { ADMIN_INSTANCE } from '../lib/admin-instance'
5
- /**
6
- * Bridges `lib/self.ts` to CLI command sites: builds a `SelfResolverContext`
7
- * from CLI opts, resolves a nodeId via
8
- * `resolveOrThrow` (throwing `SelfRefusalError` on refusal), and wraps async
9
- * calls with `withSelfHint` so `NotFoundError`s carry expansion metadata for
10
- * the stale-registration hint emitted by `formatKernelError`.
11
- */
4
+ import { adminInstanceMethod } from '../lib/admin-instance'
5
+ /** CLI bridge for @self resolution and stale-registration error hints. */
12
6
  import { readConfig } from '../lib/config'
13
7
  import { getDefault, getIdentity, setRegistration } from '../lib/identity'
14
8
  import { decodeTokenClaims, readIdpSession } from '../lib/idp'
@@ -34,13 +28,7 @@ export type SelfExpansionMeta = {
34
28
  slug?: string
35
29
  }
36
30
 
37
- /**
38
- * Build a `SelfResolverContext` from CLI opts. Mirrors the target +
39
- * signing-mode logic in `withKernelClient` / `resolveCredential`.
40
- *
41
- * Cheap enough to call eagerly; commands skip the call entirely when
42
- * `containsSelfRef` returns false on every input.
43
- */
31
+ /** Build the same target/signing context `withKernelClient` will use. */
44
32
  export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfResolverContext> {
45
33
  const config = await readConfig()
46
34
  // Mirror withKernelClient's slug logic: --url without -i ⇒ no slug.
@@ -70,11 +58,7 @@ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfRes
70
58
  instanceSigned = await fileExists(privatePath)
71
59
  }
72
60
 
73
- // Identity that will sign (only relevant when not instance-signed and not --creds).
74
- // Failures here (corrupt identities.json, missing --as identity) are
75
- // re-thrown rather than swallowed — swallowing produces a useless
76
- // refusal naming `identityName: '(unknown)'`. The fatal-error UX is
77
- // honest about the actual problem.
61
+ // Identity lookup failures should surface as real CLI errors, not @self refusals.
78
62
  let identity: SelfResolverContext['identity']
79
63
  if (!opts.creds) {
80
64
  const identityName = opts.as ?? defaultIdentity
@@ -111,7 +95,8 @@ function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
111
95
  async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
112
96
  return await withAdminKernelClient(
113
97
  adminLookupOpts(opts),
114
- async (ctx) => (await ctx.client.call(`${ADMIN_INSTANCE}/info`, { id: slug })) as InstanceInfo,
98
+ async (ctx) =>
99
+ (await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
115
100
  )
116
101
  }
117
102
 
@@ -142,59 +127,68 @@ export function resolveOrThrow(selfCtx: SelfResolverContext): string {
142
127
  return r.id
143
128
  }
144
129
 
145
- // The kernel's whoami — returns the AUTHENTICATED principal's graph node.
146
- // Static interface method, so the colon form (slash form is rejected).
147
- const WHOAMI_PATH = '/:kernel.astrale.ai:interface.Identity:whoami'
130
+ export type ResolveSelfIdLazyDeps = {
131
+ whoami?: (opts: KernelCommandOpts) => Promise<{ id?: unknown; kernelUrl: string }>
132
+ setRegistration?: typeof setRegistration
133
+ now?: () => Date
134
+ }
148
135
 
149
136
  /**
150
- * Resolve `@self`, falling back to ONE kernel `whoami` round-trip when an
151
- * IdP identity merely lacks a cached registration on this instance (the
152
- * normal `astrale auth login` flow — the IdP subject is never a node id).
153
- * The resolved id is persisted as a registration so subsequent expansions
154
- * are local again. Every other refusal (manager, instance-signed, …) and a
155
- * failed whoami throw the typed refusal unchanged.
137
+ * Resolve @self for IdP identities through one whoami refresh, then cache the
138
+ * current node id. Non-IdP refusals stay typed and local.
156
139
  */
157
140
  export async function resolveSelfIdLazy(
158
141
  selfCtx: SelfResolverContext,
159
142
  opts: KernelCommandOpts,
143
+ deps: ResolveSelfIdLazyDeps = {},
160
144
  ): Promise<string> {
161
145
  const r: SelfResolution = resolveSelfNodeId(selfCtx)
162
- if (!('reason' in r)) return r.id
163
- if (r.reason !== 'idp-no-sub' || !selfCtx.instanceSlug || !selfCtx.identity) {
164
- throw selfRefusalError(r)
146
+ const cachedId = 'reason' in r ? undefined : r.id
147
+ const isIdp = (selfCtx.identity?.source ?? 'key') === 'idp'
148
+
149
+ if (isIdp && selfCtx.instanceSlug && selfCtx.identity) {
150
+ let lookup: { id?: unknown; kernelUrl: string }
151
+ try {
152
+ lookup = await (deps.whoami ?? whoamiSelfId)(opts)
153
+ } catch {
154
+ if (cachedId) return cachedId
155
+ if ('reason' in r) throw selfRefusalError(r)
156
+ throw selfRefusalError({ reason: 'idp-no-sub', identityName: selfCtx.identity.name })
157
+ }
158
+
159
+ const resolvedId =
160
+ typeof lookup.id === 'string' && lookup.id.trim().length > 0 ? lookup.id : undefined
161
+ if (!resolvedId) {
162
+ if (cachedId) return cachedId
163
+ if ('reason' in r) throw selfRefusalError(r)
164
+ throw selfRefusalError({ reason: 'idp-no-sub', identityName: selfCtx.identity.name })
165
+ }
166
+
167
+ const cached = selfCtx.identity.registrations?.[selfCtx.instanceSlug]
168
+ if (cached?.sub !== resolvedId || cached?.iss !== lookup.kernelUrl) {
169
+ await (deps.setRegistration ?? setRegistration)(selfCtx.identity.name, selfCtx.instanceSlug, {
170
+ iss: lookup.kernelUrl,
171
+ sub: resolvedId,
172
+ registeredAt: (deps.now ?? (() => new Date()))().toISOString(),
173
+ })
174
+ }
175
+ return resolvedId
165
176
  }
166
- let me: { id?: unknown } | null
177
+
178
+ if (!('reason' in r)) return r.id
179
+ throw selfRefusalError(r)
180
+ }
181
+
182
+ async function whoamiSelfId(opts: KernelCommandOpts): Promise<{ id?: unknown; kernelUrl: string }> {
167
183
  let kernelUrl = ''
168
- try {
169
- me = (await withKernelClient(opts, (ctx) => {
170
- kernelUrl = ctx.url
171
- return ctx.client.call(WHOAMI_PATH as never, {} as never)
172
- })) as { id?: unknown } | null
173
- } catch {
174
- // Network/auth failure — surface the original recipe, not a stack.
175
- throw selfRefusalError(r)
176
- }
177
- const id = typeof me?.id === 'string' && me.id.trim().length > 0 ? me.id : undefined
178
- if (!id) throw selfRefusalError(r)
179
- await setRegistration(selfCtx.identity.name, selfCtx.instanceSlug, {
180
- iss: kernelUrl,
181
- sub: id,
182
- registeredAt: new Date().toISOString(),
184
+ const me = await withKernelClient(opts, (ctx) => {
185
+ kernelUrl = ctx.url
186
+ return ctx.client.as(ctx.credential).auth.whoami()
183
187
  })
184
- return id
188
+ return { id: me.id, kernelUrl }
185
189
  }
186
190
 
187
- /**
188
- * Expand `@self` in a single path string for the common command shape
189
- * (`get`, `ls`, `describe`). Returns the expanded path AND the metadata
190
- * needed by `withSelfHint` to attach the stale-registration hint to a
191
- * downstream `NotFoundError`.
192
- *
193
- * No-op (returns the input unchanged with `meta: undefined`) when the path
194
- * contains no `@self` — avoids the I/O of `buildSelfContext`.
195
- *
196
- * Throws `SelfRefusalError` when `@self` is present but unresolvable.
197
- */
191
+ /** Expand @self in path-like commands and return metadata for NotFound hints. */
198
192
  export async function expandSelfInPath(
199
193
  path: string,
200
194
  opts: KernelCommandOpts,
@@ -0,0 +1,96 @@
1
+ import type {
2
+ GetResultWire,
3
+ GraphApi,
4
+ GraphNodeWire,
5
+ MutationResultWire,
6
+ PatchInput,
7
+ QueryASTInput,
8
+ } from '@astrale-os/kernel-client/graph'
9
+
10
+ import { createGraph, rawOf } from '@astrale-os/kernel-client/graph'
11
+
12
+ import type { ClientContext } from './client'
13
+
14
+ export type {
15
+ GetResultWire,
16
+ GraphApi,
17
+ GraphNodeWire,
18
+ MutationResultWire,
19
+ PatchInput,
20
+ QueryASTInput,
21
+ }
22
+
23
+ /** A node row as it crosses the graph doors — the shared item shape for `get`/`ls`/`describe`. */
24
+ export type GraphNode = GraphNodeWire
25
+
26
+ /**
27
+ * Bind the graph sugar (`function.get` / `function.mutate` + the read/write
28
+ * helpers) over a CLI client session. The sugar is pure over a call capability;
29
+ * we hand it `ClientSession.call` — the same surface every command already uses.
30
+ */
31
+ export function bindGraph(ctx: ClientContext): GraphApi {
32
+ return createGraph((method, params) => ctx.client.call(method, params))
33
+ }
34
+
35
+ /**
36
+ * Split a `function.get` page into addressed root and descendants. Root matches
37
+ * path/id first, then falls back to the shallowest node in the page.
38
+ */
39
+ export function splitRoot(
40
+ nodes: readonly GraphNode[],
41
+ rootPath: string,
42
+ ): { root: GraphNode | undefined; children: GraphNode[] } {
43
+ const raw = rawOf(rootPath)
44
+ const idForm = rootPath.startsWith('@') ? rootPath.slice(1) : undefined
45
+
46
+ let idx = nodes.findIndex(
47
+ (n) => rawOf(n.path) === raw || (idForm !== undefined && n.id === idForm),
48
+ )
49
+ if (idx < 0 && nodes.length > 0) {
50
+ let min = Number.POSITIVE_INFINITY
51
+ nodes.forEach((n, i) => {
52
+ const depth = rawOf(n.path).split('/').length
53
+ if (depth < min) {
54
+ min = depth
55
+ idx = i
56
+ }
57
+ })
58
+ }
59
+
60
+ const root = idx >= 0 ? nodes[idx] : undefined
61
+ const children = idx >= 0 ? nodes.filter((_, i) => i !== idx) : [...nodes]
62
+ return { root, children }
63
+ }
64
+
65
+ /** The children cursor of a single-root `function.get` result, if the page overflowed. */
66
+ export function childrenCursor(result: GetResultWire): string | undefined {
67
+ const next = result.next
68
+ if (!next) return undefined
69
+ return Object.values(next)[0]?.children
70
+ }
71
+
72
+ /**
73
+ * Strip a fully-qualified prop key to its leaf name. Node props travel under
74
+ * qualified keys (`<domain>:class.X.property.name` / `<domain>:interface.I.property.name`);
75
+ * this recovers `name`.
76
+ */
77
+ export function unqualifyKey(key: string): string {
78
+ const dot = key.lastIndexOf('.property.')
79
+ if (dot >= 0) return key.slice(dot + '.property.'.length)
80
+ const slash = key.lastIndexOf('/')
81
+ return slash >= 0 ? key.slice(slash + 1) : key
82
+ }
83
+
84
+ /** Read a node prop by its unqualified leaf name (exact key wins, else a qualified match). */
85
+ export function nodeProp(
86
+ node: { props: Record<string, unknown> } | null | undefined,
87
+ name: string,
88
+ ): unknown {
89
+ const props = node?.props
90
+ if (!props) return undefined
91
+ if (name in props) return props[name]
92
+ for (const [k, v] of Object.entries(props)) {
93
+ if (unqualifyKey(k) === name) return v
94
+ }
95
+ return undefined
96
+ }
@@ -1,4 +1,10 @@
1
- export { withKernelClient, withAdminKernelClient, type ClientContext } from './client'
1
+ export {
2
+ withKernelClient,
3
+ withAdminKernelClient,
4
+ resolveKernelTarget,
5
+ type ClientContext,
6
+ type ResolvedKernelTarget,
7
+ } from './client'
2
8
  export { resolveCredential } from './auth'
3
9
  export { formatKernelError } from './errors'
4
10
  export {
@@ -9,6 +15,15 @@ export {
9
15
  resolveSelfIdLazy,
10
16
  type SelfExpansionMeta,
11
17
  } from './expand'
12
- export { mintRemoteCredential } from './remote-routing'
13
- export { runKernelCommand, extractItems } from './run'
18
+ export { runKernelCommand } from './run'
19
+ export { bindGraph, splitRoot, childrenCursor, nodeProp, unqualifyKey } from './graph'
20
+ export type {
21
+ GetResultWire,
22
+ GraphApi,
23
+ GraphNode,
24
+ GraphNodeWire,
25
+ MutationResultWire,
26
+ PatchInput,
27
+ QueryASTInput,
28
+ } from './graph'
14
29
  export type { KernelCommandOpts, CallCommandOpts } from './types'
@@ -15,7 +15,7 @@ export const KERNEL_PASSTHROUGH_OPTIONS: CommandOption[] = [
15
15
  flags: '-i, --instance <name>',
16
16
  description: 'Target a specific instance (overrides active)',
17
17
  },
18
- { flags: '--timeout <ms>', description: 'Request timeout in ms', default: '30000' },
18
+ { flags: '--timeout <ms>', description: 'Request timeout in ms (default: 30000)' },
19
19
  { flags: '--as <identity>', description: 'Call as a specific identity' },
20
20
  { flags: '--creds <token>', description: 'Use a pre-signed credential (e.g. delegation token)' },
21
21
  { flags: '--debug', description: 'Print full error diagnostics on failure' },
package/src/kernel/run.ts CHANGED
@@ -48,16 +48,3 @@ export async function runKernelCommand<T>(run: RunOpts<T>): Promise<void> {
48
48
  process.exit(1)
49
49
  }
50
50
  }
51
-
52
- /**
53
- * Normalize a `listChildren` response into an array of items.
54
- * The kernel may return a bare array or `{ items: [...] }`.
55
- */
56
- export function extractItems<T = Record<string, unknown>>(result: unknown): T[] {
57
- if (Array.isArray(result)) return result as T[]
58
- if (result && typeof result === 'object') {
59
- const items = (result as { items?: unknown }).items
60
- if (Array.isArray(items)) return items as T[]
61
- }
62
- return []
63
- }
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
3
3
  import type { AstraleConfig } from '../config'
4
4
  import type { InstanceStore } from '../instance'
5
5
 
6
+ import { AstraleError } from '../../errors'
6
7
  import { DEFAULT_CONFIG } from '../config'
7
8
  import {
8
9
  couldBeConfiguredAdminInstance,
@@ -181,3 +182,36 @@ describe('instance target helpers', () => {
181
182
  expect(isManagedInstanceNotFound(auth)).toBe(false)
182
183
  })
183
184
  })
185
+
186
+ describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
187
+ test('NOT_FOUND-prefixed InternalKernelError is an instance miss', () => {
188
+ const e = new Error('NOT_FOUND: no instance node for "knowledge"')
189
+ e.name = 'InternalKernelError'
190
+ expect(isManagedInstanceNotFound(e)).toBe(true)
191
+ })
192
+ test('other InternalKernelError messages are not swallowed', () => {
193
+ const e = new Error('DISPATCH_FAILED: handler crashed')
194
+ e.name = 'InternalKernelError'
195
+ expect(isManagedInstanceNotFound(e)).toBe(false)
196
+ })
197
+ test('resolveNamedInstanceTarget maps the wrap to typed INSTANCE_NOT_FOUND', async () => {
198
+ const managed = async () => {
199
+ const e = new Error('NOT_FOUND: no instance node for "ghost"')
200
+ e.name = 'InternalKernelError'
201
+ throw e
202
+ }
203
+ const opts = {
204
+ config: DEFAULT_CONFIG,
205
+ instances: { instances: {} },
206
+ managed,
207
+ } as unknown as Parameters<typeof resolveInstanceTarget>[1]
208
+ let caught: unknown
209
+ try {
210
+ await resolveInstanceTarget({ source: 'name', name: 'ghost' }, opts)
211
+ } catch (e) {
212
+ caught = e
213
+ }
214
+ expect(caught).toBeInstanceOf(AstraleError)
215
+ expect((caught as AstraleError).code).toBe('INSTANCE_NOT_FOUND')
216
+ })
217
+ })