@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.
- package/README.md +4 -3
- package/dist/astrale.js +3391 -3163
- package/dist/public/connect-core.js +2041 -3066
- package/dist/public/keys/index.js +1854 -2895
- package/dist/public/paths/index.js +1833 -2882
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/lib/instance-target.d.ts +2 -0
- package/dist/types/lib/instance.d.ts +10 -0
- package/dist/types/lib/invocation.d.ts +3 -0
- package/dist/types/lib/log.d.ts +6 -5
- package/dist/types/lib/output.d.ts +6 -2
- package/package.json +8 -8
- package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
- package/src/commands/__tests__/install-identity-override.test.ts +14 -3
- package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
- package/src/commands/__tests__/instance-use.test.ts +67 -0
- package/src/commands/__tests__/introspect-parse.test.ts +21 -0
- package/src/commands/__tests__/logs.test.ts +5 -0
- package/src/commands/__tests__/read-commands.test.ts +11 -1
- package/src/commands/__tests__/token-ttl.test.ts +21 -0
- package/src/commands/__tests__/view-build.test.ts +58 -0
- package/src/commands/auth/token.ts +19 -7
- package/src/commands/call.ts +18 -43
- package/src/commands/domain/install.ts +6 -5
- package/src/commands/domain/uninstall.ts +128 -0
- package/src/commands/get.ts +23 -5
- package/src/commands/identity/create.ts +11 -2
- package/src/commands/identity/delete.ts +8 -2
- package/src/commands/identity/export.ts +8 -2
- package/src/commands/identity/import.ts +11 -2
- package/src/commands/identity/sync.ts +8 -2
- package/src/commands/identity/unsync.ts +12 -2
- package/src/commands/identity/use.ts +8 -2
- package/src/commands/identity/whoami.ts +1 -1
- package/src/commands/instance/active.ts +13 -1
- package/src/commands/instance/bookmark.ts +26 -3
- package/src/commands/instance/list.ts +18 -4
- package/src/commands/instance/use.ts +54 -7
- package/src/commands/introspect.ts +117 -0
- package/src/commands/logs.ts +50 -6
- package/src/commands/mutate.ts +2 -3
- package/src/commands/query.ts +3 -5
- package/src/commands/token.ts +35 -8
- package/src/commands/update.ts +30 -9
- package/src/commands/view.ts +33 -19
- package/src/connection/.spec/architecture.md +5 -0
- package/src/connection/.spec/laws/connection.ts +20 -0
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +27 -1
- package/src/connection/__tests__/ca-fetch.test.ts +8 -1
- package/src/connection/__tests__/credential.test.ts +10 -0
- package/src/connection/__tests__/errors.test.ts +430 -36
- package/src/connection/__tests__/exchange.test.ts +46 -5
- package/src/connection/__tests__/reasons.test.ts +78 -0
- package/src/connection/auth.ts +11 -9
- package/src/connection/command.ts +1 -1
- package/src/connection/credential.ts +3 -0
- package/src/connection/errors.ts +195 -154
- package/src/connection/exchange.ts +14 -2
- package/src/connection/reasons.ts +179 -0
- package/src/connection/session.ts +9 -0
- package/src/connection/target.ts +1 -0
- package/src/graph/__tests__/mutation.test.ts +6 -0
- package/src/graph/mutation.ts +13 -0
- package/src/identity/__tests__/registry.test.ts +1 -1
- package/src/identity/registry.ts +12 -5
- package/src/lib/__tests__/command-dx.test.ts +35 -20
- package/src/lib/__tests__/instance-target.test.ts +17 -0
- package/src/lib/__tests__/instance.test.ts +51 -1
- package/src/lib/__tests__/output.test.ts +12 -0
- package/src/lib/__tests__/view-assets.test.ts +33 -1
- package/src/lib/__tests__/view-server.test.ts +68 -0
- package/src/lib/ca-fetch.ts +9 -3
- package/src/lib/command-dx.ts +40 -23
- package/src/lib/instance-target.ts +18 -2
- package/src/lib/instance.ts +31 -0
- package/src/lib/invocation.ts +11 -0
- package/src/lib/log.ts +17 -9
- package/src/lib/output.ts +20 -4
- package/src/lib/view/assets.ts +16 -2
- package/src/program/__tests__/program.test.ts +4 -1
- package/src/program/build.ts +5 -9
- package/src/program/options.ts +2 -1
- package/src/state/__tests__/identities.test.ts +2 -2
- package/src/state/identities.ts +3 -10
- package/studio/package.json +7 -8
- 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;
|
package/dist/types/lib/log.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Ora } from 'ora';
|
|
2
|
-
import { type
|
|
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.
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
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`,
|
|
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?:
|
|
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.
|
|
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.
|
|
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.
|
|
68
|
-
"@astrale-os/shell": "0.3.8-beta.
|
|
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
|
|
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({
|
|
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('
|
|
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'),
|
|
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',
|
|
@@ -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')).
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|