@astrale-os/cli 0.8.1-alpha.5 → 0.8.1-alpha.7

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 (88) hide show
  1. package/README.md +4 -3
  2. package/dist/astrale.js +3391 -3163
  3. package/dist/public/connect-core.js +2041 -3066
  4. package/dist/public/keys/index.js +1854 -2895
  5. package/dist/public/paths/index.js +1833 -2882
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance-target.d.ts +2 -0
  8. package/dist/types/lib/instance.d.ts +10 -0
  9. package/dist/types/lib/invocation.d.ts +3 -0
  10. package/dist/types/lib/log.d.ts +6 -5
  11. package/dist/types/lib/output.d.ts +6 -2
  12. package/package.json +8 -8
  13. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  14. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  15. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  16. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  17. package/src/commands/__tests__/instance-use.test.ts +67 -0
  18. package/src/commands/__tests__/introspect-parse.test.ts +21 -0
  19. package/src/commands/__tests__/logs.test.ts +5 -0
  20. package/src/commands/__tests__/read-commands.test.ts +11 -1
  21. package/src/commands/__tests__/token-ttl.test.ts +21 -0
  22. package/src/commands/__tests__/view-build.test.ts +58 -0
  23. package/src/commands/auth/token.ts +19 -7
  24. package/src/commands/call.ts +18 -43
  25. package/src/commands/domain/install.ts +6 -5
  26. package/src/commands/domain/uninstall.ts +128 -0
  27. package/src/commands/get.ts +23 -5
  28. package/src/commands/identity/create.ts +11 -2
  29. package/src/commands/identity/delete.ts +8 -2
  30. package/src/commands/identity/export.ts +8 -2
  31. package/src/commands/identity/import.ts +11 -2
  32. package/src/commands/identity/sync.ts +8 -2
  33. package/src/commands/identity/unsync.ts +12 -2
  34. package/src/commands/identity/use.ts +8 -2
  35. package/src/commands/identity/whoami.ts +1 -1
  36. package/src/commands/instance/active.ts +13 -1
  37. package/src/commands/instance/bookmark.ts +26 -3
  38. package/src/commands/instance/list.ts +18 -4
  39. package/src/commands/instance/use.ts +54 -7
  40. package/src/commands/introspect.ts +117 -0
  41. package/src/commands/logs.ts +50 -6
  42. package/src/commands/mutate.ts +2 -3
  43. package/src/commands/query.ts +3 -5
  44. package/src/commands/token.ts +35 -8
  45. package/src/commands/update.ts +30 -9
  46. package/src/commands/view.ts +33 -19
  47. package/src/connection/.spec/architecture.md +5 -0
  48. package/src/connection/.spec/laws/connection.ts +20 -0
  49. package/src/connection/.spec/layout.ts +1 -0
  50. package/src/connection/__tests__/auth.test.ts +27 -1
  51. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  52. package/src/connection/__tests__/credential.test.ts +10 -0
  53. package/src/connection/__tests__/errors.test.ts +430 -36
  54. package/src/connection/__tests__/exchange.test.ts +46 -5
  55. package/src/connection/__tests__/reasons.test.ts +78 -0
  56. package/src/connection/auth.ts +11 -9
  57. package/src/connection/command.ts +1 -1
  58. package/src/connection/credential.ts +3 -0
  59. package/src/connection/errors.ts +195 -154
  60. package/src/connection/exchange.ts +14 -2
  61. package/src/connection/reasons.ts +179 -0
  62. package/src/connection/session.ts +9 -0
  63. package/src/connection/target.ts +1 -0
  64. package/src/graph/__tests__/mutation.test.ts +6 -0
  65. package/src/graph/mutation.ts +13 -0
  66. package/src/identity/__tests__/registry.test.ts +1 -1
  67. package/src/identity/registry.ts +12 -5
  68. package/src/lib/__tests__/command-dx.test.ts +35 -20
  69. package/src/lib/__tests__/instance-target.test.ts +17 -0
  70. package/src/lib/__tests__/instance.test.ts +51 -1
  71. package/src/lib/__tests__/output.test.ts +12 -0
  72. package/src/lib/__tests__/view-assets.test.ts +33 -1
  73. package/src/lib/__tests__/view-server.test.ts +68 -0
  74. package/src/lib/ca-fetch.ts +9 -3
  75. package/src/lib/command-dx.ts +40 -23
  76. package/src/lib/instance-target.ts +18 -2
  77. package/src/lib/instance.ts +31 -0
  78. package/src/lib/invocation.ts +11 -0
  79. package/src/lib/log.ts +17 -9
  80. package/src/lib/output.ts +20 -4
  81. package/src/lib/view/assets.ts +16 -2
  82. package/src/program/__tests__/program.test.ts +4 -1
  83. package/src/program/build.ts +5 -9
  84. package/src/program/options.ts +2 -1
  85. package/src/state/__tests__/identities.test.ts +2 -2
  86. package/src/state/identities.ts +3 -10
  87. package/studio/package.json +7 -8
  88. package/viewer/dist/main.js +57 -57
@@ -1,5 +1,7 @@
1
1
  import type { AstraleConfig } from '../lib/config';
2
2
  import { type Identity } from '../identity/registry';
3
+ import { IdpAudienceMismatchError } from '../lib/idp';
4
+ import { IdpSessionNoRefreshTokenError } from '../lib/idp-session';
3
5
  export type KeyIdentityAuthOptions = {
4
6
  issuer: string;
5
7
  subject?: string;
@@ -23,6 +25,7 @@ export declare function resolveCredential(opts: {
23
25
  defaultIdentity?: string;
24
26
  }, config: AstraleConfig, audience?: string, registrationKey?: string): Promise<string>;
25
27
  export declare function resolveKeyIdentityAuthOptions(identity: Identity, config: AstraleConfig, audience?: string, registrationKey?: string): KeyIdentityAuthOptions;
28
+ export declare function classifyNoRefreshTokenError(requestedAudience: string, sourceAudience: string | undefined, error: IdpSessionNoRefreshTokenError): IdpSessionNoRefreshTokenError | IdpAudienceMismatchError;
26
29
  /** A refresh attempt failed for a reason that re-login will NOT fix. */
27
30
  export declare class IdpRefreshTransientError extends Error {
28
31
  constructor(message: string);
@@ -33,4 +33,6 @@ export type ResolveInstanceTargetOpts = {
33
33
  export declare function resolveInstanceTarget(request: InstanceTargetRequest, opts: ResolveInstanceTargetOpts): Promise<ResolvedInstanceTarget>;
34
34
  export declare function couldBeConfiguredAdminInstance(identifier: string, config: AstraleConfig): boolean;
35
35
  export declare function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInstanceTarget;
36
+ /** Admin lookup failed before it could say whether the slug exists. */
37
+ export declare function isAdminDiscoveryFailure(error: unknown): boolean;
36
38
  export declare function isManagedInstanceNotFound(error: unknown): boolean;
@@ -136,6 +136,10 @@ export type ResolvedInstance = {
136
136
  mode?: RegistryMode;
137
137
  status?: string;
138
138
  };
139
+ export type BookmarkTrustConflict = {
140
+ readonly name: string;
141
+ readonly caFile: string | null;
142
+ };
139
143
  export declare function sanitizeStore(store: InstanceStore): {
140
144
  store: InstanceStore;
141
145
  changed: boolean;
@@ -152,6 +156,12 @@ export declare function readInstances(_config?: AstraleConfig, opts?: {
152
156
  export declare function writeInstances(store: InstanceStore): Promise<void>;
153
157
  export declare function assertNoCollision(store: InstanceStore, identifiers: string[], ignoreKey?: string): void;
154
158
  export declare function resolveInstanceKey(store: InstanceStore, identifier: string): string | null;
159
+ /**
160
+ * Find other bookmarks for the same normalized Kernel URL whose TLS trust
161
+ * configuration differs. System trust (`undefined`) is a configuration too:
162
+ * mixing it with a custom CA is exactly as significant as mixing two CA files.
163
+ */
164
+ export declare function findBookmarkTrustConflicts(store: InstanceStore, name: string, url: string, caFile?: string): BookmarkTrustConflict[];
155
165
  export declare function addInstance(key: string, opts?: AddInstanceOpts): Promise<InstanceEntry>;
156
166
  export declare function upsertInstance(key: string, opts?: AddInstanceOpts): Promise<{
157
167
  entry: InstanceEntry;
@@ -0,0 +1,3 @@
1
+ /** Process-wide machine mode from argv (`--json` / `--raw` / `--ci`). */
2
+ export declare function configureInvocation(argv: readonly string[]): void;
3
+ export declare function invocationWantsMachine(): boolean;
@@ -1,5 +1,5 @@
1
1
  import { type Ora } from 'ora';
2
- import { type RawOutputOpts } from './output';
2
+ import { type MachineOpts } from './output';
3
3
  export declare const log: {
4
4
  info: (msg: string) => void;
5
5
  success: (msg: string) => void;
@@ -8,10 +8,11 @@ export declare const log: {
8
8
  step: (msg: string) => void;
9
9
  dim: (msg: string) => void;
10
10
  };
11
- /** Report an error with hint (when present) and exit. Commands that carry
12
- * RawOutputOpts should pass them so machine consumers (--json/--raw/piped)
13
- * get one structured JSON line on stderr instead of the pretty ✖ view. */
14
- export declare function fatal(e: unknown, opts?: RawOutputOpts): never;
11
+ /** Report an error with hint (when present) and exit. `--json` / `--ci` / a
12
+ * non-TTY stdout always get one structured JSON line on stderr. */
13
+ export declare function fatal(e: unknown, opts?: MachineOpts): never;
14
+ /** Admit expected invalid input and exit through {@link fatal}. */
15
+ export declare function failClosed(error: unknown, opts?: MachineOpts): never;
15
16
  /** Shortcut for stub commands that aren't wired in v1 (§15). */
16
17
  export declare function fatalNotImplemented(feature: string, hint?: string): never;
17
18
  /**
@@ -6,6 +6,9 @@ export type OutputOpts = {
6
6
  format?: 'yaml' | 'json';
7
7
  };
8
8
  export type RawOutputOpts = Pick<OutputOpts, 'raw' | 'json'>;
9
+ export type MachineOpts = RawOutputOpts & {
10
+ readonly ci?: boolean;
11
+ };
9
12
  export declare const RAW_OUTPUT_OPTIONS: readonly [{
10
13
  readonly flags: '--json';
11
14
  readonly description: 'Always-valid JSON (for jq)';
@@ -15,9 +18,10 @@ export declare const RAW_OUTPUT_OPTIONS: readonly [{
15
18
  }];
16
19
  /**
17
20
  * Is the consumer a machine (emit structured data, not a pretty view)?
18
- * True for `--json`, `--raw`, or any non-TTY stdout (pipe, redirect, CI, agent).
21
+ * True for `--json`, `--raw`, `--ci`, a process-wide `--ci/--json/--raw` on argv,
22
+ * or any non-TTY stdout (pipe, redirect, agent).
19
23
  */
20
- export declare function isMachine(opts?: RawOutputOpts): boolean;
24
+ export declare function isMachine(opts?: MachineOpts): boolean;
21
25
  /**
22
26
  * `--raw` = the *unwrapped* value (bare scalar, raw bytes). The raw-vs-json
23
27
  * distinction only manifests for scalars and binary; objects/arrays fall back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "0.8.1-alpha.5",
3
+ "version": "0.8.1-alpha.7",
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.1",
65
+ "@astrale-os/kernel-client": "0.6.0-beta.3",
66
66
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
67
- "@astrale-os/sdk": "0.5.0-beta.1",
68
- "@astrale-os/shell": "0.3.8-beta.1",
67
+ "@astrale-os/sdk": "0.5.0-beta.3",
68
+ "@astrale-os/shell": "0.3.8-beta.2",
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",
@@ -82,16 +82,16 @@
82
82
  "lint-staged": {
83
83
  "*.{js,cjs,mjs,ts,tsx}": [
84
84
  "oxlint --fix --no-error-on-unmatched-pattern",
85
- "oxfmt --write"
85
+ "oxfmt --write --no-error-on-unmatched-pattern"
86
86
  ],
87
87
  "*.{json,yml,yaml}": [
88
- "oxfmt --write"
88
+ "oxfmt --write --no-error-on-unmatched-pattern"
89
89
  ]
90
90
  },
91
91
  "devEngines": {
92
92
  "runtime": {
93
93
  "name": "node",
94
- "version": ">=24 <25",
94
+ "version": ">=24 <25 || >=26 <27",
95
95
  "onFail": "error"
96
96
  }
97
97
  },
@@ -101,7 +101,7 @@
101
101
  "scripts": {
102
102
  "preinstall": "node .check-workspace.cjs",
103
103
  "build": "bun scripts/build.ts",
104
- "package:check": "pnpm run typecheck && pnpm run test && pnpm run build && node scripts/verify-public-exports.mjs",
104
+ "package:check": "pnpm run typecheck && pnpm run test && pnpm run build && node scripts/verify-public-exports.mjs && node scripts/verify-private-ports-boundary.mjs",
105
105
  "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.tests.json && tsgo --noEmit -p viewer && tsgo --noEmit -p studio",
106
106
  "lint": "oxlint .",
107
107
  "lint:fix": "oxlint --fix .",
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { join } from 'node:path'
3
+
4
+ import { uninstallCallInput } from '../domain/uninstall'
5
+
6
+ const cliRoot = join(import.meta.dir, '../../..')
7
+
8
+ describe('domain uninstall', () => {
9
+ test('sends the public Kernel uninstall request', () => {
10
+ expect(uninstallCallInput('grc.example', 'op-1')).toEqual({
11
+ operation: 'op-1',
12
+ origin: 'grc.example',
13
+ })
14
+ })
15
+
16
+ test('requires --yes outside a TTY before connecting to a Kernel', async () => {
17
+ const proc = Bun.spawn({
18
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'domain', 'uninstall', 'grc.example', '--json'],
19
+ stdout: 'pipe',
20
+ stderr: 'pipe',
21
+ })
22
+ const [stdout, stderr, exitCode] = await Promise.all([
23
+ new Response(proc.stdout).text(),
24
+ new Response(proc.stderr).text(),
25
+ proc.exited,
26
+ ])
27
+
28
+ expect(exitCode).toBe(1)
29
+ expect(stdout).toBe('')
30
+ expect(JSON.parse(stderr)).toMatchObject({
31
+ error: 'CONFIRMATION_REQUIRED',
32
+ message: 'Uninstalling Domain "grc.example" requires explicit confirmation.',
33
+ })
34
+ })
35
+
36
+ test('states that uninstall never deletes business data', async () => {
37
+ const proc = Bun.spawn({
38
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'domain', 'uninstall', '--help'],
39
+ stdout: 'pipe',
40
+ stderr: 'pipe',
41
+ })
42
+ const [stdout, stderr, exitCode] = await Promise.all([
43
+ new Response(proc.stdout).text(),
44
+ new Response(proc.stderr).text(),
45
+ proc.exited,
46
+ ])
47
+
48
+ expect(exitCode).toBe(0)
49
+ expect(stderr).toBe('')
50
+ expect(stdout).toContain('Uninstall never deletes business data.')
51
+ expect(stdout).toContain('business data still uses its schema')
52
+ })
53
+ })
@@ -41,16 +41,27 @@ describe('declared-origin probe (/meta)', () => {
41
41
  return `http://localhost:${server.port}`
42
42
  }
43
43
 
44
- test('reads domainName from a well-formed /meta', async () => {
44
+ test('reads origin from a well-formed /meta', async () => {
45
+ // The exact shape SDK workers serve (adapter-cloudflare worker `/meta`).
45
46
  const url = serveMeta((req) =>
46
47
  new URL(req.url).pathname === '/meta'
47
- ? Response.json({ iss: 'https://x', domainName: 'crm.acme.dev' })
48
+ ? Response.json({
49
+ origin: 'crm.acme.dev',
50
+ issuer: 'https://crm.acme.dev',
51
+ schemaRevision: 'sha256:abc',
52
+ deploymentVersion: 'v1',
53
+ })
48
54
  : new Response('nope', { status: 404 }),
49
55
  )
50
56
  expect(await probeDeclaredOrigin(url)).toBe('crm.acme.dev')
51
57
  })
52
58
 
53
- test('degrades to undefined on missing domainName, non-200, bad JSON, or dead host', async () => {
59
+ test('falls back to the pre-Kernel-V2 domainName field', async () => {
60
+ const legacy = serveMeta(() => Response.json({ iss: 'https://x', domainName: 'crm.acme.dev' }))
61
+ expect(await probeDeclaredOrigin(legacy)).toBe('crm.acme.dev')
62
+ })
63
+
64
+ test('degrades to undefined on missing origin, non-200, bad JSON, or dead host', async () => {
54
65
  const noName = serveMeta(() => Response.json({ iss: 'https://x' }))
55
66
  expect(await probeDeclaredOrigin(noName)).toBeUndefined()
56
67
 
@@ -72,6 +72,63 @@ describe('instance bookmark command', () => {
72
72
  expect(store.instances.testmarc.caFile).toBe('/tmp/ca.pem')
73
73
  expect(store.instances.testmarc.createdAt).toBe('2026-06-10T00:00:00.000Z')
74
74
  })
75
+
76
+ test('warns when another bookmark uses different TLS trust for the same URL', async () => {
77
+ await writeFile(
78
+ join(tmp, 'instances.json'),
79
+ JSON.stringify({
80
+ active: 'stable',
81
+ instances: {
82
+ stable: {
83
+ url: 'https://local.example/kernel',
84
+ caFile: '/certs/stable.pem',
85
+ },
86
+ },
87
+ }),
88
+ )
89
+
90
+ const result = await runBookmark(
91
+ 'stale',
92
+ '--url',
93
+ 'https://local.example/kernel/',
94
+ '--ca',
95
+ '/certs/old.pem',
96
+ '--skip-probe',
97
+ )
98
+
99
+ expect(result.exitCode).toBe(0)
100
+ expect(result.stderr).toContain('TLS trust differs for the same Kernel URL')
101
+ expect(result.stderr).toContain('"stable" uses CA /certs/stable.pem')
102
+ })
103
+
104
+ test('active --json exposes the TLS and identity configuration', async () => {
105
+ await writeFile(
106
+ join(tmp, 'instances.json'),
107
+ JSON.stringify({
108
+ active: 'stable',
109
+ instances: {
110
+ stable: {
111
+ url: 'https://local.example/kernel',
112
+ issuer: 'https://issuer.example',
113
+ defaultIdentity: 'marc',
114
+ caFile: '/certs/stable.pem',
115
+ createdAt: '2026-08-20T00:00:00.000Z',
116
+ },
117
+ },
118
+ }),
119
+ )
120
+
121
+ const result = await runCli('instance', 'active', '--json')
122
+ expect(result.exitCode).toBe(0)
123
+ expect(JSON.parse(result.stdout)).toEqual({
124
+ name: 'stable',
125
+ url: 'https://local.example/kernel',
126
+ issuer: 'https://issuer.example',
127
+ defaultIdentity: 'marc',
128
+ caFile: '/certs/stable.pem',
129
+ createdAt: '2026-08-20T00:00:00.000Z',
130
+ })
131
+ })
75
132
  })
76
133
 
77
134
  async function readInstances(): Promise<{
@@ -85,9 +142,17 @@ async function runBookmark(...args: string[]): Promise<{
85
142
  exitCode: number
86
143
  stdout: string
87
144
  stderr: string
145
+ }> {
146
+ return runCli('instance', 'bookmark', ...args)
147
+ }
148
+
149
+ async function runCli(...args: string[]): Promise<{
150
+ exitCode: number
151
+ stdout: string
152
+ stderr: string
88
153
  }> {
89
154
  const proc = Bun.spawn({
90
- cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'instance', 'bookmark', ...args],
155
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), ...args],
91
156
  env: { ...process.env, ASTRALE_HOME: tmp },
92
157
  stdout: 'pipe',
93
158
  stderr: 'pipe',
@@ -15,6 +15,7 @@ function bookmark(overrides: Partial<Bookmark> = {}): Bookmark {
15
15
  issuer: null,
16
16
  active: false,
17
17
  defaultIdentity: null,
18
+ caFile: null,
18
19
  createdAt: null,
19
20
  ...overrides,
20
21
  }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { IssuerUnreachableError } from '../../errors'
4
+ import { InstanceStoreSchema, type ResolvedInstance } from '../../lib/instance'
5
+ import { probeBookmark } from '../instance/use'
6
+
7
+ const resolved: ResolvedInstance = {
8
+ name: 'stable',
9
+ kind: 'bookmark',
10
+ url: 'https://local.example/kernel',
11
+ issuer: 'https://issuer.example',
12
+ caFile: '/certs/stable.pem',
13
+ }
14
+
15
+ describe('instance use bookmark probe', () => {
16
+ test('uses the CA stored on the selected bookmark', async () => {
17
+ const scopedFetch = globalThis.fetch
18
+
19
+ await expect(
20
+ probeBookmark(resolved, {
21
+ readInstances: async () =>
22
+ InstanceStoreSchema.parse({
23
+ active: 'stable',
24
+ instances: {
25
+ stable: { url: resolved.url, caFile: resolved.caFile },
26
+ },
27
+ }),
28
+ fetchWithCaFile: (path) => {
29
+ expect(path).toBe('/certs/stable.pem')
30
+ return scopedFetch
31
+ },
32
+ checkIssuerReachability: async (url, issuer, fetchImpl) => {
33
+ expect(url).toBe(resolved.url)
34
+ expect(issuer).toBe(resolved.issuer)
35
+ expect(fetchImpl).toBe(scopedFetch)
36
+ return { issuer: resolved.issuer!, keys: [{ kid: 'key-1' }] }
37
+ },
38
+ }),
39
+ ).resolves.toBeUndefined()
40
+ })
41
+
42
+ test('names conflicting bookmark CAs in a failed TLS probe', async () => {
43
+ const failure = probeBookmark(resolved, {
44
+ readInstances: async () =>
45
+ InstanceStoreSchema.parse({
46
+ active: 'stable',
47
+ instances: {
48
+ stable: { url: resolved.url, caFile: resolved.caFile },
49
+ stale: { url: resolved.url, caFile: '/certs/old.pem' },
50
+ },
51
+ }),
52
+ fetchWithCaFile: () => fetch,
53
+ checkIssuerReachability: async () => {
54
+ throw new IssuerUnreachableError(resolved.url, 'certificate verify failed')
55
+ },
56
+ })
57
+
58
+ await expect(failure).rejects.toMatchObject({
59
+ code: 'ISSUER_UNREACHABLE',
60
+ message: `Issuer/JWKS probe failed for bookmark "stable" at ${resolved.url}.`,
61
+ })
62
+ await expect(failure).rejects.toHaveProperty(
63
+ 'hint',
64
+ expect.stringContaining('"stale" (CA /certs/old.pem)'),
65
+ )
66
+ })
67
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { parseIntrospectTarget } from '../introspect'
4
+
5
+ describe('parseIntrospectTarget', () => {
6
+ test('accepts a bare origin', () => {
7
+ const parsed = parseIntrospectTarget('host.astrale.ai')
8
+ expect(parsed.origin).toBe('host.astrale.ai')
9
+ expect(parsed.path.ast.steps).toEqual([])
10
+ })
11
+
12
+ test('accepts a Domain-rooted method Path', () => {
13
+ const parsed = parseIntrospectTarget('/:host.astrale.ai:class.Manager:createInstance')
14
+ expect(parsed.origin).toBe('host.astrale.ai')
15
+ expect(parsed.path.ast.steps.at(-1)?.kind).toBe('method')
16
+ })
17
+
18
+ test('rejects an @id', () => {
19
+ expect(() => parseIntrospectTarget('@abc')).toThrow('not an @id')
20
+ })
21
+ })
@@ -29,6 +29,11 @@ describe('buildJournalInput', () => {
29
29
  expect(buildJournalInput({})).toEqual({ limit: 200 })
30
30
  expect(() => buildJournalInput({ limit: '0' })).toThrow('--limit')
31
31
  expect(() => buildJournalInput({ limit: 'all' })).toThrow('--limit')
32
+ expect(() => buildJournalInput({ since: 'not-a-date' })).toThrow('--since')
33
+ expect(() =>
34
+ buildJournalInput({ since: '2026-01-01T00:00:00Z', until: '1999-01-01T00:00:00Z' }),
35
+ ).toThrow('--since')
36
+ expect(() => buildJournalInput({ cursor: 'junk' })).toThrow('--cursor')
32
37
  })
33
38
  })
34
39
 
@@ -27,6 +27,7 @@ class ExitError extends Error {
27
27
  let stdout = ''
28
28
  let errors: string[] = []
29
29
  let originalStdoutWrite: typeof process.stdout.write
30
+ let originalStderrWrite: typeof process.stderr.write
30
31
  let originalConsoleError: typeof console.error
31
32
  let originalExit: typeof process.exit
32
33
  let queryCalls: Array<{ ast: unknown; options: unknown }> = []
@@ -95,12 +96,17 @@ beforeEach(() => {
95
96
  runKernelCommandMock.mockClear()
96
97
 
97
98
  originalStdoutWrite = process.stdout.write
99
+ originalStderrWrite = process.stderr.write
98
100
  originalConsoleError = console.error
99
101
  originalExit = process.exit
100
102
  process.stdout.write = ((chunk: string | Uint8Array) => {
101
103
  stdout += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
102
104
  return true
103
105
  }) as typeof process.stdout.write
106
+ process.stderr.write = ((chunk: string | Uint8Array) => {
107
+ errors.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
108
+ return true
109
+ }) as typeof process.stderr.write
104
110
  console.error = ((...values: unknown[]) => {
105
111
  errors.push(values.map(String).join(' '))
106
112
  }) as typeof console.error
@@ -111,6 +117,7 @@ beforeEach(() => {
111
117
 
112
118
  afterEach(() => {
113
119
  process.stdout.write = originalStdoutWrite
120
+ process.stderr.write = originalStderrWrite
114
121
  console.error = originalConsoleError
115
122
  process.exit = originalExit
116
123
  })
@@ -195,7 +202,10 @@ describe('query command', () => {
195
202
  ).rejects.toEqual(new ExitError(1))
196
203
 
197
204
  expect(runKernelCommandMock).not.toHaveBeenCalled()
198
- expect(errors.join('\n')).toContain('expected "astrale.graph.query" at /format')
205
+ expect(JSON.parse(errors.join('\n'))).toMatchObject({
206
+ error: 'INVALID_INPUT',
207
+ message: 'Invalid input: expected "astrale.graph.query" at /format.',
208
+ })
199
209
  })
200
210
  })
201
211
 
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { AstraleError } from '../../errors'
4
+ import { parseTtl } from '../token'
5
+
6
+ describe('parseTtl', () => {
7
+ test('defaults to 3600 seconds', () => {
8
+ expect(parseTtl(undefined)).toBe(3600)
9
+ })
10
+
11
+ test('rejects non-positive and non-integer values', () => {
12
+ expect(() => parseTtl('abc')).toThrow(AstraleError)
13
+ expect(() => parseTtl('0')).toThrow(AstraleError)
14
+ expect(() => parseTtl('-5')).toThrow(AstraleError)
15
+ expect(() => parseTtl('1.5')).toThrow(AstraleError)
16
+ })
17
+
18
+ test('admits a positive integer', () => {
19
+ expect(parseTtl('90')).toBe(90)
20
+ })
21
+ })
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { devDistIsStale } from '../view'
7
+
8
+ const temporaryDirectories: string[] = []
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(
12
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
13
+ )
14
+ })
15
+
16
+ describe('view development runtime build', () => {
17
+ test('reuses a current official build and detects every runtime build input', async () => {
18
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-build-'))
19
+ temporaryDirectories.push(root)
20
+ const entry = join(root, 'bin', 'astrale.ts')
21
+ const source = join(root, 'src', 'command.ts')
22
+ const vendor = join(root, 'vendor', 'dependency.tgz')
23
+ const buildScript = join(root, 'scripts', 'build.ts')
24
+ const packageJson = join(root, 'package.json')
25
+ const lockfile = join(root, 'pnpm-lock.yaml')
26
+ const dist = join(root, 'dist', 'astrale.js')
27
+
28
+ await Promise.all(
29
+ ['bin', 'src', 'vendor', 'scripts', 'dist'].map((directory) =>
30
+ mkdir(join(root, directory), { recursive: true }),
31
+ ),
32
+ )
33
+ const inputs = [entry, source, vendor, buildScript, packageJson, lockfile]
34
+ await Promise.all(inputs.map((file) => writeFile(file, 'input')))
35
+ await writeFile(dist, 'built')
36
+ const past = new Date(Date.now() - 2_000)
37
+ const present = new Date()
38
+ await Promise.all(inputs.map((file) => utimes(file, past, past)))
39
+ await utimes(dist, present, present)
40
+
41
+ expect(await devDistIsStale(entry, dist)).toBe(false)
42
+
43
+ for (const input of inputs) {
44
+ await utimes(input, new Date(Date.now() + 2_000), new Date(Date.now() + 2_000))
45
+ expect(await devDistIsStale(entry, dist)).toBe(true)
46
+ await utimes(input, past, past)
47
+ }
48
+ })
49
+
50
+ test('requires a build when dist/astrale.js is missing', async () => {
51
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-build-missing-'))
52
+ temporaryDirectories.push(root)
53
+
54
+ expect(
55
+ await devDistIsStale(join(root, 'bin', 'astrale.ts'), join(root, 'dist', 'astrale.js')),
56
+ ).toBe(true)
57
+ })
58
+ })
@@ -1,9 +1,10 @@
1
1
  import type { CommandDefinition } from '../../program/index'
2
2
 
3
+ import { AstraleError } from '../../errors'
3
4
  import { getDefault, getIdentity, readIdentities } from '../../identity/index'
4
5
  import { isSessionExpired, readIdpSession, type IdpSession } from '../../lib/idp'
5
6
  import { ensureFreshSession } from '../../lib/idp-session'
6
- import { log } from '../../lib/log'
7
+ import { fatal, log } from '../../lib/log'
7
8
  import { output } from '../../lib/output'
8
9
 
9
10
  type AuthTokenType = 'access' | 'id'
@@ -66,7 +67,12 @@ Examples:
66
67
  $ astrale auth token --name alice --type id --json
67
68
  `,
68
69
  action: async (opts: AuthTokenOpts) => {
69
- const result = await resolveAuthToken(opts)
70
+ let result: AuthTokenResult
71
+ try {
72
+ result = await resolveAuthToken(opts)
73
+ } catch (error) {
74
+ fatal(error, opts)
75
+ }
70
76
 
71
77
  if (opts.raw) {
72
78
  process.stdout.write(result.token + '\n')
@@ -118,10 +124,11 @@ async function resolveIdentityName(opts: AuthTokenOpts): Promise<string> {
118
124
  if (opts.name) {
119
125
  const identity = await getIdentity(opts.name)
120
126
  if ((identity.source ?? 'key') !== 'idp') {
121
- throw new Error(`Identity "${opts.name}" is not IdP-backed`)
127
+ throw new AstraleError('AUTH_ERROR', `Identity "${opts.name}" is not IdP-backed`)
122
128
  }
123
129
  if (opts.idp && identity.idp !== opts.idp) {
124
- throw new Error(
130
+ throw new AstraleError(
131
+ 'AUTH_ERROR',
125
132
  `Identity "${opts.name}" is backed by IdP "${identity.idp ?? '?'}", not "${opts.idp}"`,
126
133
  )
127
134
  }
@@ -135,20 +142,25 @@ async function resolveIdentityName(opts: AuthTokenOpts): Promise<string> {
135
142
  .map(([name]) => name)
136
143
 
137
144
  if (matches.length === 0) {
138
- throw new Error(
145
+ throw new AstraleError(
146
+ 'AUTH_ERROR',
139
147
  `No IdP-backed identities found for IdP "${opts.idp}". Run: astrale auth login --idp ${opts.idp}`,
140
148
  )
141
149
  }
142
150
  if (matches.includes(store.default)) return store.default
143
151
  if (matches.length === 1) return matches[0]
144
- throw new Error(
152
+ throw new AstraleError(
153
+ 'AUTH_ERROR',
145
154
  `Multiple IdP-backed identities found for IdP "${opts.idp}": ${matches.join(', ')}. Pass --name.`,
146
155
  )
147
156
  }
148
157
 
149
158
  const identity = await getDefault()
150
159
  if ((identity.source ?? 'key') !== 'idp') {
151
- throw new Error('Default identity is not IdP-backed. Pass --name or --idp.')
160
+ throw new AstraleError(
161
+ 'AUTH_ERROR',
162
+ 'Default identity is not IdP-backed. Pass --name or --idp.',
163
+ )
152
164
  }
153
165
  return identity.name
154
166
  }