@astrale-os/cli 1.0.0-beta.21 → 1.0.0-beta.23

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 (31) hide show
  1. package/dist/astrale.js +670 -622
  2. package/dist/public/connect-core.js +4 -4
  3. package/dist/types/admin/contract.d.ts +2 -2
  4. package/package.json +4 -4
  5. package/src/admin/contract.ts +3 -2
  6. package/src/commands/__tests__/read-commands.test.ts +56 -0
  7. package/src/commands/__tests__/view.test.ts +1 -2
  8. package/src/commands/introspect.ts +8 -9
  9. package/src/ui/.spec/architecture.md +8 -6
  10. package/src/ui/.spec/laws.ts +8 -1
  11. package/src/ui/__tests__/ui.test.ts +166 -14
  12. package/src/ui/operations.ts +117 -14
  13. package/src/ui/release.ts +9 -2
  14. package/studio/client/dist/assets/{index-7YHdeCxp.js → index-OeJK1TjG.js} +2 -2
  15. package/studio/client/dist/assets/{schema-studio-CABpTfvH.js → schema-studio-DRdfu_cQ.js} +2 -2
  16. package/studio/client/dist/index.html +1 -1
  17. package/studio/package.json +3 -3
  18. package/studio/server/cli-consumers.test.ts +0 -1
  19. package/studio/server/introspect/anatomy/views.ts +1 -2
  20. package/studio/server/introspect/anatomy-extras.test.ts +3 -14
  21. package/studio/server/introspect/canonical-schema.test.ts +1 -1
  22. package/studio/server/introspect/canonical-schema.ts +0 -4
  23. package/studio/server/introspect/diff.test.ts +10 -2
  24. package/studio/server/introspect/diff.ts +1 -9
  25. package/studio/server/introspect/runtime.test.ts +1 -1
  26. package/studio/server/introspect/schema-ir-json.test.ts +12 -1
  27. package/studio/server/introspect/schema-ir-json.ts +1 -1
  28. package/studio/server/views/target.test.ts +1 -2
  29. package/studio/shared/contracts/schema.ts +1 -3
  30. package/studio/tsconfig.json +2 -1
  31. package/viewer/dist/main.js +13 -13
@@ -8242,14 +8242,14 @@ function isPidAlive(pid) {
8242
8242
  function sleep(milliseconds) {
8243
8243
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
8244
8244
  }
8245
- // node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.12/node_modules/@astrale-os/kernel-dsl/dist/value/limits.js
8245
+ // node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.13/node_modules/@astrale-os/kernel-dsl/dist/value/limits.js
8246
8246
  var defaultLimits = Object.freeze({
8247
8247
  maxBytes: 16 * 1024 * 1024,
8248
8248
  maxDepth: 64,
8249
8249
  maxMembers: 1e5,
8250
8250
  maxStringBytes: 1024 * 1024
8251
8251
  });
8252
- // node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.12/node_modules/@astrale-os/kernel-dsl/dist/value/canonical.js
8252
+ // node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.13/node_modules/@astrale-os/kernel-dsl/dist/value/canonical.js
8253
8253
  var encoder2 = new TextEncoder;
8254
8254
  // node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
8255
8255
  var CharacterCodes;
@@ -8429,7 +8429,7 @@ var ParseErrorCode;
8429
8429
  ParseErrorCode2[ParseErrorCode2["InvalidCharacter"] = 16] = "InvalidCharacter";
8430
8430
  })(ParseErrorCode || (ParseErrorCode = {}));
8431
8431
 
8432
- // node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.12/node_modules/@astrale-os/kernel-dsl/dist/value/decode.js
8432
+ // node_modules/.pnpm/@astrale-os+kernel-dsl@0.2.0-beta.13/node_modules/@astrale-os/kernel-dsl/dist/value/decode.js
8433
8433
  var decoder2 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
8434
8434
  // node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
8435
8435
  var exports_external = {};
@@ -23659,7 +23659,7 @@ var log = {
23659
23659
  dim: (msg) => console.log(source_default.dim(msg))
23660
23660
  };
23661
23661
  var IS_CI = !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION || process.env.NO_SPINNER);
23662
- // node_modules/.pnpm/@astrale-os+kernel-core@0.9.0-beta.15/node_modules/@astrale-os/kernel-core/dist/dns-label.js
23662
+ // node_modules/.pnpm/@astrale-os+kernel-core@0.9.0-beta.16/node_modules/@astrale-os/kernel-core/dist/dns-label.js
23663
23663
  var DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
23664
23664
  function isDnsLabel(value) {
23665
23665
  return DNS_LABEL_RE.test(value);
@@ -1,5 +1,5 @@
1
1
  import type { Input } from '@astrale-os/sdk/client';
2
- import type { ClientSession } from '@astrale-os/sdk/client/session';
2
+ import type { ClientSession, SessionRequestOptions } from '@astrale-os/sdk/client/session';
3
3
  import type { ClassRef } from '@astrale-os/sdk/schema';
4
4
  import { Path } from '@astrale-os/sdk/graph/path';
5
5
  export declare const AdminContract: Readonly<{
@@ -23,4 +23,4 @@ export declare const AdminContract: Readonly<{
23
23
  }>;
24
24
  }>;
25
25
  /** Invoke one stable Admin instance Method without schema discovery or reflection. */
26
- export declare function callAdminMethod(session: ClientSession, receiver: Path, method: string, input: Input): Promise<unknown>;
26
+ export declare function callAdminMethod(session: ClientSession, receiver: Path, method: string, input: Input, options?: SessionRequestOptions): Promise<unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.21",
3
+ "version": "1.0.0-beta.23",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -62,8 +62,8 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@astrale-os/ox": ">=0.1.3 <1.0.0",
65
- "@astrale-os/sdk": "0.5.0-beta.50",
66
- "@astrale-os/shell": "0.4.2-beta.5",
65
+ "@astrale-os/sdk": "0.5.0-beta.51",
66
+ "@astrale-os/shell": "0.4.2-beta.6",
67
67
  "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
68
68
  "@commitlint/cli": "21.2.2",
69
69
  "@commitlint/config-conventional": "21.2.2",
@@ -98,7 +98,7 @@
98
98
  "scripts": {
99
99
  "build": "bun scripts/build.ts",
100
100
  "package:check": "pnpm run typecheck && pnpm run test && pnpm run build && node scripts/verify-public-exports.mjs && node scripts/verify-public-dependency-boundary.mjs",
101
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json && tsc --noEmit -p viewer && pnpm --dir studio run typecheck",
101
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p viewer && pnpm --dir studio run typecheck",
102
102
  "lint": "oxlint .",
103
103
  "lint:fix": "oxlint --fix .",
104
104
  "format": "pnpm exec oxfmt --write .",
@@ -1,5 +1,5 @@
1
1
  import type { Input } from '@astrale-os/sdk/client'
2
- import type { ClientSession } from '@astrale-os/sdk/client/session'
2
+ import type { ClientSession, SessionRequestOptions } from '@astrale-os/sdk/client/session'
3
3
  import type { ClassRef } from '@astrale-os/sdk/schema'
4
4
 
5
5
  import { call } from '@astrale-os/sdk/client'
@@ -41,6 +41,7 @@ export function callAdminMethod(
41
41
  receiver: Path,
42
42
  method: string,
43
43
  input: Input,
44
+ options?: SessionRequestOptions,
44
45
  ): Promise<unknown> {
45
- return session.call(call(Path.instanceMethod(receiver, method), input))
46
+ return session.call(call(Path.instanceMethod(receiver, method), input), options)
46
47
  }
@@ -6,6 +6,12 @@ type GraphContext = {
6
6
  getOrThrow: typeof getOrThrowMock
7
7
  mutate: typeof mutateMock
8
8
  }
9
+ session: {
10
+ schema: {
11
+ inspect: typeof inspectMock
12
+ bundle: typeof bundleMock
13
+ }
14
+ }
9
15
  }
10
16
 
11
17
  type RunOpts = {
@@ -36,6 +42,8 @@ let mutations: unknown[] = []
36
42
  let queryResult: unknown
37
43
  let getResult: unknown
38
44
  let mutationResult: unknown
45
+ let inspectResult: unknown
46
+ let bundleResult: unknown
39
47
  let selfFailure: unknown
40
48
  let selfContexts: unknown[] = []
41
49
 
@@ -51,6 +59,8 @@ const mutateMock = mock(async (mutation: unknown) => {
51
59
  mutations.push(mutation)
52
60
  return mutationResult
53
61
  })
62
+ const inspectMock = mock(async (_origin: string) => inspectResult)
63
+ const bundleMock = mock(async (_origin: string) => bundleResult)
54
64
  const expandSelfInPathMock = mock(async (path: string, context: unknown) => {
55
65
  selfContexts.push(context)
56
66
  if (selfFailure !== undefined) throw selfFailure
@@ -65,6 +75,7 @@ const expandSelfInPathMock = mock(async (path: string, context: unknown) => {
65
75
  const withSelfHintMock = mock(async (action: () => Promise<unknown>) => action())
66
76
  const commandContext = {
67
77
  graph: { query: queryMock, getOrThrow: getOrThrowMock, mutate: mutateMock },
78
+ session: { schema: { inspect: inspectMock, bundle: bundleMock } },
68
79
  }
69
80
  const runKernelCommandMock = mock(async (run: RunOpts) => {
70
81
  const result = await run.fn(commandContext)
@@ -93,11 +104,22 @@ beforeEach(() => {
93
104
  }
94
105
  getResult = undefined
95
106
  mutationResult = { createdNodes: {} }
107
+ inspectResult = {
108
+ origin: 'notes.example.dev',
109
+ revision: 'notes-v1',
110
+ generation: 'sha256:generation',
111
+ }
112
+ bundleResult = {
113
+ domain: inspectResult,
114
+ bundle: { origin: 'notes.example.dev', revision: 'notes-v1' },
115
+ }
96
116
  selfFailure = undefined
97
117
  selfContexts = []
98
118
  queryMock.mockClear()
99
119
  getOrThrowMock.mockClear()
100
120
  mutateMock.mockClear()
121
+ inspectMock.mockClear()
122
+ bundleMock.mockClear()
101
123
  expandSelfInPathMock.mockClear()
102
124
  withSelfHintMock.mockClear()
103
125
  runKernelCommandMock.mockClear()
@@ -280,6 +302,40 @@ describe('get command', () => {
280
302
  })
281
303
  })
282
304
 
305
+ describe('introspect command', () => {
306
+ test('uses the split SDK Schema API without recreating the retired introspection request', async () => {
307
+ const { introspectCommand } = await import('../introspect')
308
+
309
+ await introspectCommand(
310
+ 'notes.example.dev',
311
+ { json: true },
312
+ {
313
+ runKernelCommand: runKernelCommandMock as never,
314
+ },
315
+ )
316
+
317
+ expect(inspectMock).toHaveBeenCalledWith('notes.example.dev')
318
+ expect(bundleMock).not.toHaveBeenCalled()
319
+ expect(JSON.parse(stdout)).toEqual(inspectResult)
320
+ })
321
+
322
+ test('requests the immutable bundle only when explicitly selected', async () => {
323
+ const { introspectCommand } = await import('../introspect')
324
+
325
+ await introspectCommand(
326
+ 'notes.example.dev',
327
+ { json: true, bundle: true },
328
+ {
329
+ runKernelCommand: runKernelCommandMock as never,
330
+ },
331
+ )
332
+
333
+ expect(bundleMock).toHaveBeenCalledWith('notes.example.dev')
334
+ expect(inspectMock).not.toHaveBeenCalled()
335
+ expect(JSON.parse(stdout)).toEqual(bundleResult)
336
+ })
337
+ })
338
+
283
339
  describe('mutate command', () => {
284
340
  /** @evidence TEST-CLI-MUTATE-DISPATCHES-CANONICAL-V3 */
285
341
  test('admits authoring input and dispatches one canonical Mutation V3 document', async () => {
@@ -52,7 +52,6 @@ const resolved = [
52
52
  kind: 'definition' as const,
53
53
  definitions: [{ origin: 'ai-gateway.astrale.ai', kind: 'class' as const, name: 'Model' }],
54
54
  },
55
- auth: 'required' as const,
56
55
  },
57
56
  }),
58
57
  route({
@@ -62,7 +61,7 @@ const resolved = [
62
61
  issuer: 'https://ai-gateway.astrale.ai',
63
62
  etag: `sha256:${'c'.repeat(64)}`,
64
63
  revision: `sha256:${'d'.repeat(64)}`,
65
- declaration: { target: { kind: 'domain' as const }, auth: 'public' as const },
64
+ declaration: { target: { kind: 'domain' as const } },
66
65
  }),
67
66
  ]
68
67
 
@@ -38,14 +38,13 @@ export async function introspectCommand(
38
38
  opts,
39
39
  label: `Introspect ${origin}`,
40
40
  fn: async ({ session }) => {
41
- const includeBundle = wantsCallable || opts.bundle === true
42
- const result = await readInstalledDomain(session.schema, origin, includeBundle)
43
41
  if (wantsCallable) {
44
- const described = describeCallableFromBundle(path, (result as DomainBundle).bundle)
42
+ const result = await readInstalledDomain(session.schema, origin, true)
43
+ const described = describeCallableFromBundle(path, result.bundle)
45
44
  if (described === undefined) throw missingCallableDescription(path.raw)
46
45
  return described
47
46
  }
48
- return result
47
+ return readInstalledDomain(session.schema, origin, opts.bundle === true)
49
48
  },
50
49
  format: (value, format) => output(value, format),
51
50
  })
@@ -110,11 +109,11 @@ export default {
110
109
  description: 'Read installed Domain schema from the Kernel Schema syscall',
111
110
  afterHelpText: `
112
111
  Behavior:
113
- Calls the public Kernel introspect syscall for one installed Domain.
114
- A bare origin (kernel.astrale.ai or /:kernel.astrale.ai) prints installation
115
- state, target, source, readiness, and capabilities. --bundle includes the
116
- schema bundle. A method or Function Path projects that callable's
117
- input/output from the installed bundle.
112
+ Calls the public SDK Schema API for one installed Domain. A bare origin
113
+ (kernel.astrale.ai or /:kernel.astrale.ai) prints its exact revision,
114
+ generation, publication, readiness, capabilities, and bindings. --bundle
115
+ includes the schema bundle. A method or Function Path projects that
116
+ callable's input/output from the installed bundle.
118
117
 
119
118
  Examples:
120
119
  $ astrale introspect kernel.astrale.ai
@@ -4,9 +4,11 @@ This private CLI owner manages an existing local React project. It does not conn
4
4
  does not add React, Base UI, Tailwind, shadcn, or the UI runtime to the installed CLI dependency
5
5
  graph.
6
6
 
7
- One registry operation resolves one immutable UI commit. Package compatibility and registry reads
8
- use that commit, and the detected package manager invokes the release-qualified shadcn CLI on
9
- demand. A local playground-exported theme is instead admitted and copied without network or
10
- shadcn. Both paths install consumer-owned source, activate themes through one project-relative CSS
11
- import, and complete application writes before the lock advances. Dry-run, list, and doctor do not
12
- claim a file mutation.
7
+ One registry operation resolves one immutable UI commit. During the V1 prerelease, operations
8
+ without an explicit version resolve the npm `beta` dist-tag; the legacy `latest` channel is not a V1
9
+ source. Package compatibility and registry reads use that commit. Patterns and blocks invoke the
10
+ release-qualified shadcn CLI on demand through the detected package manager. Released themes copy
11
+ the exact admitted embedded CSS directly, while a local playground export is admitted and copied
12
+ without registry or shadcn. Both theme paths install consumer-owned source and activate it through
13
+ one project-relative CSS import, and complete application writes before the lock advances. Dry-run,
14
+ list, and doctor do not claim a file mutation.
@@ -1,5 +1,12 @@
1
1
  import { defineLaw } from '@astrale-os/spec/authoring'
2
2
 
3
+ export const CLI_UI_BETA_DEFAULT = defineLaw({
4
+ id: 'CLI-UI-BETA-DEFAULT',
5
+ statement:
6
+ 'Until the UI V1 channel is promoted, an operation without an explicit version resolves the npm beta dist-tag and never the legacy latest dist-tag.',
7
+ tests: [{ file: '../__tests__/ui.test.ts', id: 'TEST-CLI-UI-BETA-DEFAULT' }],
8
+ })
9
+
3
10
  export const CLI_UI_ONE_SNAPSHOT = defineLaw({
4
11
  id: 'CLI-UI-ONE-SNAPSHOT',
5
12
  statement:
@@ -24,7 +31,7 @@ export const CLI_UI_BOUNDED_REMOTE_DOCUMENTS = defineLaw({
24
31
  export const CLI_UI_EXACT_ITEM_SOURCE = defineLaw({
25
32
  id: 'CLI-UI-EXACT-ITEM-SOURCE',
26
33
  statement:
27
- 'An add operation admits the exact built item document against the release index and records a content-bearing source digest before invoking project tooling.',
34
+ 'An add operation admits the exact built item document against the release index and records its content-bearing source digest before any consumer mutation; only patterns and blocks invoke project tooling.',
28
35
  tests: [{ file: '../__tests__/ui.test.ts', id: 'TEST-CLI-UI-EXACT-ITEM-SOURCE' }],
29
36
  })
30
37
 
@@ -28,6 +28,30 @@ const registry: UiRegistry = {
28
28
  },
29
29
  ],
30
30
  }
31
+ const componentRegistry: UiRegistry = {
32
+ name: 'astrale-ui',
33
+ items: [
34
+ {
35
+ name: 'component-sidebar',
36
+ type: 'registry:component',
37
+ title: 'Component · sidebar',
38
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
39
+ files: [
40
+ {
41
+ path: 'sidebar.tsx',
42
+ type: 'registry:component',
43
+ target: 'components/astrale/component/sidebar/sidebar.tsx',
44
+ },
45
+ {
46
+ path: 'use-mobile.ts',
47
+ type: 'registry:hook',
48
+ target: 'components/astrale/component/sidebar/use-mobile.ts',
49
+ },
50
+ ],
51
+ meta: { canonicalAddress: 'component/sidebar', ownership: 'consumer-source' },
52
+ },
53
+ ],
54
+ }
31
55
  const themeCss = `/* Generated from observatory.astrale-theme.json. Consumer-owned after installation. */
32
56
  :root,
33
57
  [data-ui-theme='observatory'] {
@@ -183,21 +207,25 @@ function themeFetch(seen: string[] = []): typeof fetch {
183
207
  }
184
208
  if (url.endsWith('/registry/themes/registry.json')) return Response.json(themeRegistry)
185
209
  if (url.endsWith('/registry/public/r/theme-observatory.json')) {
186
- return Response.json({
187
- ...themeRegistry.items[0],
188
- files: [
189
- {
190
- ...themeRegistry.items[0]!.files[0],
191
- path: 'registry/themes/observatory.css',
192
- content: themeCss,
193
- },
194
- ],
195
- })
210
+ return Response.json(builtThemeItem())
196
211
  }
197
212
  return new Response('not found', { status: 404 })
198
213
  }) as typeof fetch
199
214
  }
200
215
 
216
+ function builtThemeItem() {
217
+ return {
218
+ ...themeRegistry.items[0],
219
+ files: [
220
+ {
221
+ ...themeRegistry.items[0]!.files[0],
222
+ path: 'registry/themes/observatory.css',
223
+ content: themeCss,
224
+ },
225
+ ],
226
+ }
227
+ }
228
+
201
229
  function builtItem(item: UiRegistry['items'][number]) {
202
230
  return {
203
231
  ...item,
@@ -253,15 +281,17 @@ function lock(): UiLock {
253
281
  }
254
282
 
255
283
  describe('UI release and runner contracts', () => {
284
+ /** @evidence TEST-CLI-UI-BETA-DEFAULT */
256
285
  test('resolves the default release from the public beta channel', async () => {
257
286
  const seen: string[] = []
287
+ const fallback = mockFetch()
258
288
  const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
259
289
  const url = String(input)
290
+ seen.push(url)
260
291
  if (url.endsWith('/@astrale-os/ui/beta')) {
261
- seen.push(url)
262
292
  return Response.json({ version: '0.3.0-beta.1' })
263
293
  }
264
- return mockFetch(seen)(input, init)
294
+ return fallback(input, init)
265
295
  }) as typeof fetch
266
296
 
267
297
  const release = await resolveUiRelease(undefined, fetcher)
@@ -318,6 +348,36 @@ describe('UI release and runner contracts', () => {
318
348
  ])
319
349
  })
320
350
 
351
+ test('admits an exact consumer-owned component with item-local dependencies and files', async () => {
352
+ const release = await resolveUiRelease('0.3.0-beta.0', mockFetch([], componentRegistry))
353
+ expect(release.registry.items).toEqual([
354
+ expect.objectContaining({
355
+ name: 'component-sidebar',
356
+ type: 'registry:component',
357
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
358
+ meta: expect.objectContaining({ canonicalAddress: 'component/sidebar' }),
359
+ files: [
360
+ expect.objectContaining({
361
+ type: 'registry:component',
362
+ target: 'components/astrale/component/sidebar/sidebar.tsx',
363
+ }),
364
+ expect.objectContaining({
365
+ type: 'registry:hook',
366
+ target: 'components/astrale/component/sidebar/use-mobile.ts',
367
+ }),
368
+ ],
369
+ }),
370
+ ])
371
+ })
372
+
373
+ test('rejects a component item that targets another registry ownership category', async () => {
374
+ const invalid = structuredClone(componentRegistry)
375
+ invalid.items[0]!.files[0]!.target = 'components/astrale/pattern/sidebar/sidebar.tsx'
376
+ await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], invalid))).rejects.toMatchObject({
377
+ code: 'UI_REGISTRY_UNAVAILABLE',
378
+ })
379
+ })
380
+
321
381
  test('lists release themes through the public type filter', async () => {
322
382
  const themes = await listUi(
323
383
  undefined,
@@ -791,8 +851,60 @@ describe('UI initialization transaction', () => {
791
851
  )
792
852
  })
793
853
 
854
+ test('preserves an existing development-only UI dependency during initialization', async () => {
855
+ const root = await fixture()
856
+ const manifestPath = path.join(root, 'package.json')
857
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
858
+ manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
859
+ await writeFile(manifestPath, JSON.stringify(manifest))
860
+
861
+ await initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() })
862
+
863
+ const written = JSON.parse(await readFile(manifestPath, 'utf8'))
864
+ expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
865
+ expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
866
+ })
867
+
868
+ test('doctor accepts one exact development dependency and rejects drift or duplication', async () => {
869
+ const root = await lockedFixture()
870
+ const manifestPath = path.join(root, 'package.json')
871
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
872
+ delete manifest.dependencies['@astrale-os/ui']
873
+ manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
874
+ await writeFile(manifestPath, JSON.stringify(manifest))
875
+ await writeFile(
876
+ path.join(root, 'components.json'),
877
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
878
+ )
879
+ await writeFile(
880
+ path.join(root, 'src/index.css'),
881
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
882
+ )
883
+
884
+ expect((await doctorUi(root)).healthy).toBe(true)
885
+
886
+ manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.1'
887
+ await writeFile(manifestPath, JSON.stringify(manifest))
888
+ expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
889
+ ok: false,
890
+ detail: 'devDependencies:0.3.0-beta.1',
891
+ })
892
+
893
+ manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.0'
894
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
895
+ await writeFile(manifestPath, JSON.stringify(manifest))
896
+ expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
897
+ ok: false,
898
+ detail: 'declared in dependencies and devDependencies',
899
+ })
900
+ })
901
+
794
902
  test('repeated exact init performs no release fetch while requested drift rejects', async () => {
795
903
  const root = await fixture()
904
+ const manifestPath = path.join(root, 'package.json')
905
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
906
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
907
+ await writeFile(manifestPath, JSON.stringify(manifest))
796
908
  await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
797
909
  await writeFile(
798
910
  path.join(root, 'components.json'),
@@ -817,6 +929,11 @@ describe('UI initialization transaction', () => {
817
929
  await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
818
930
  code: 'UI_ITEM_CONFLICT',
819
931
  })
932
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.1'
933
+ await writeFile(manifestPath, JSON.stringify(manifest))
934
+ await expect(initUi({ path: root, preset: 'astrale' })).rejects.toMatchObject({
935
+ code: 'UI_ITEM_CONFLICT',
936
+ })
820
937
  })
821
938
  })
822
939
 
@@ -887,26 +1004,29 @@ describe('UI source operations', () => {
887
1004
  "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
888
1005
  )
889
1006
  const target = path.join(root, 'components/astrale/theme/observatory.css')
1007
+ let invoked = false
890
1008
  const result = await addUi(
891
1009
  ['theme/observatory'],
892
1010
  { project: root, yes: true },
893
1011
  {
894
1012
  fetcher: themeFetch(),
895
1013
  runner: async () => {
896
- await mkdir(path.dirname(target), { recursive: true })
897
- await writeFile(target, themeCss)
1014
+ invoked = true
898
1015
  return { code: 0, stdout: '', stderr: '' }
899
1016
  },
900
1017
  },
901
1018
  )
902
1019
 
1020
+ expect(invoked).toBe(false)
903
1021
  expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
1022
+ expect(await readFile(target, 'utf8')).toBe(themeCss)
904
1023
  expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
905
1024
  "@import '../components/astrale/theme/observatory.css';",
906
1025
  )
907
1026
  const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
908
1027
  expect(written.items['theme/observatory']).toMatchObject({
909
1028
  address: 'theme/observatory',
1029
+ sourceDigest: digest(JSON.stringify(builtThemeItem())),
910
1030
  files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
911
1031
  })
912
1032
  expect((await doctorUi(root)).healthy).toBe(true)
@@ -1125,6 +1245,38 @@ describe('UI source operations', () => {
1125
1245
  ).toBe('0.3.0-beta.0')
1126
1246
  })
1127
1247
 
1248
+ test('restores a development-only UI dependency without retaining shadcn duplication', async () => {
1249
+ const root = await lockedFixture()
1250
+ const manifestPath = path.join(root, 'package.json')
1251
+ const initial = JSON.parse(await readFile(manifestPath, 'utf8'))
1252
+ delete initial.dependencies['@astrale-os/ui']
1253
+ initial.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
1254
+ await writeFile(manifestPath, JSON.stringify(initial))
1255
+ const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1256
+
1257
+ await addUi(
1258
+ ['pattern/chart/line/basic'],
1259
+ { project: root, yes: true },
1260
+ {
1261
+ fetcher: mockFetch(),
1262
+ runner: async (_file, args) => {
1263
+ if (args[0] === 'dlx') {
1264
+ await mkdir(path.dirname(installed), { recursive: true })
1265
+ await writeFile(installed, 'export const Chart = true\n')
1266
+ const changed = JSON.parse(await readFile(manifestPath, 'utf8'))
1267
+ changed.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
1268
+ await writeFile(manifestPath, JSON.stringify(changed))
1269
+ }
1270
+ return { code: 0, stdout: '', stderr: '' }
1271
+ },
1272
+ },
1273
+ )
1274
+
1275
+ const written = JSON.parse(await readFile(manifestPath, 'utf8'))
1276
+ expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
1277
+ expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
1278
+ })
1279
+
1128
1280
  test('rolls back item and package state when restoring the locked dependency fails', async () => {
1129
1281
  const root = await lockedFixture()
1130
1282
  const manifestPath = path.join(root, 'package.json')