@h-ai/cli 0.1.0-alpha.42 → 0.1.0-alpha.44

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 (49) hide show
  1. package/dist/{chunk-CFCXOKGV.js → chunk-U4PA632G.js} +123 -125
  2. package/dist/chunk-U4PA632G.js.map +1 -0
  3. package/dist/cli-config-templates-GSXKLGU5.js +5 -0
  4. package/dist/{cli-config-templates-5J5VKWWD.js.map → cli-config-templates-GSXKLGU5.js.map} +1 -1
  5. package/dist/index.js +2 -2
  6. package/package.json +2 -2
  7. package/templates/apps/api/apps/{{projectName}}-contract/README.md.hbs +4 -2
  8. package/templates/apps/api/apps/{{projectName}}-contract/src/app-contract.ts.hbs +3 -3
  9. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +0 -7
  10. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +1 -1
  11. package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +18 -1
  12. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +20 -34
  13. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +7 -2
  14. package/templates/apps/api/e2e/api-service.spec.ts.hbs +5 -1
  15. package/templates/apps/api/package.json.hbs +4 -0
  16. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +5 -3
  17. package/templates/apps/fullstack/package.json.hbs +1 -0
  18. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +6 -4
  19. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-contract.ts.hbs +6 -7
  20. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -6
  21. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +0 -1
  22. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +4 -1
  23. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +2 -17
  24. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +3 -3
  25. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +24 -6
  26. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +17 -19
  27. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +8 -5
  28. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +3 -3
  29. package/templates/apps/mobile-app/src/lib/api.ts.hbs +1 -2
  30. package/templates/base/e2e/app.spec.ts.hbs +4 -1
  31. package/templates/base/package.json.hbs +1 -1
  32. package/templates/skills/hai-ai/SKILL.md +11 -4
  33. package/templates/skills/hai-ai/reference.md +2 -2
  34. package/templates/skills/hai-api-contract/SKILL.md +34 -8
  35. package/templates/skills/hai-build/SKILL.md +9 -6
  36. package/templates/skills/hai-cache/SKILL.md +9 -6
  37. package/templates/skills/hai-capacitor/SKILL.md +0 -2
  38. package/templates/skills/hai-core/SKILL.md +15 -9
  39. package/templates/skills/hai-deploy/SKILL.md +3 -0
  40. package/templates/skills/hai-iam/SKILL.md +7 -4
  41. package/templates/skills/hai-payment/SKILL.md +14 -11
  42. package/templates/skills/hai-reach/SKILL.md +3 -0
  43. package/templates/skills/hai-reldb/SKILL.md +9 -6
  44. package/templates/skills/hai-serv/SKILL.md +46 -2
  45. package/templates/skills/hai-storage/SKILL.md +10 -7
  46. package/templates/skills/hai-vecdb/SKILL.md +8 -5
  47. package/dist/chunk-CFCXOKGV.js.map +0 -1
  48. package/dist/cli-config-templates-5J5VKWWD.js +0 -5
  49. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-routes.ts.hbs +0 -27
@@ -13,6 +13,23 @@ http:
13
13
  readyPath: /ready
14
14
  rpc: false
15
15
 
16
+ cors:
17
+ origin: '*'
18
+ nativeOrigins: http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost
19
+ {{#if features.iam}} credentials: true
20
+ {{else}} credentials: false
21
+ {{/if}} allowedHeaders:
22
+ - Authorization
23
+ - Content-Type
24
+ {{#if features.crypto}} - X-Client-Id
25
+ - X-Encrypted
26
+ {{/if}} - X-Hai-Locale
27
+ - X-Request-Id
28
+ - X-Requested-With
29
+ exposedHeaders:
30
+ {{#if features.crypto}} - X-Encrypted
31
+ {{/if}} - X-Request-Id
32
+
16
33
  {{#if features.crypto}}transport:
17
34
  keyExchangePath: /_hai/key-exchange
18
35
  excludePaths:
@@ -23,4 +40,4 @@ http:
23
40
  - /_hai/scalar.js
24
41
  maxClients: 10000
25
42
  {{else}}transport: false
26
- {{/if}}
43
+ {{/if}}
@@ -1,6 +1,6 @@
1
1
  import type { ServConfig } from '@h-ai/serv'
2
2
  import { core } from '@h-ai/core'
3
- {{#if features.crypto}}import { crypto, TRANSPORT_PROTOCOL } from '@h-ai/crypto'
3
+ {{#if features.crypto}}import { crypto } from '@h-ai/crypto'
4
4
  {{/if}}{{#if features.iam}}import { iam } from '@h-ai/iam'
5
5
  {{/if}}import { serv } from '@h-ai/serv'
6
6
  import { {{api.contractExportName}} } from '{{api.contractPackageName}}'
@@ -14,26 +14,18 @@ interface CreateApiServiceAppOptions {
14
14
  refreshCookie?: 'enabled' | 'disabled'
15
15
  }
16
16
 
17
- const LOOPBACK_ORIGIN_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/
18
- const WEBVIEW_ORIGINS = new Set([
19
- 'capacitor://localhost',
20
- 'http://localhost',
21
- 'https://tauri.localhost',
22
- 'tauri://localhost',
23
- ])
24
- const DEFAULT_CORS_ALLOWED_HEADERS = [
25
- 'Authorization',
26
- 'Content-Type',
27
- {{#if features.crypto}} TRANSPORT_PROTOCOL.CLIENT_ID_HEADER,
28
- TRANSPORT_PROTOCOL.ENCRYPTED_HEADER,
29
- {{/if}} 'X-Hai-Locale',
30
- 'X-Request-Id',
31
- 'X-Requested-With',
32
- ]
33
-
17
+ interface AppCoreConfig {
18
+ readonly env?: string
19
+ }
34
20
  /** 创建 API Service HTTP 应用。 */
35
21
  export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptions = {}) {
36
22
  const servConfig = core.config.getOrThrow<ServConfig>('serv')
23
+ const coreConfig = core.config.get<AppCoreConfig>('core')
24
+ const security = serv.createRuntimeSecurityPolicy({
25
+ environment: coreConfig?.env,
26
+ corsOrigin: servConfig.cors.origin,
27
+ nativeOrigins: servConfig.cors.nativeOrigins,
28
+ })
37
29
  {{#if features.crypto}} const transportMode = _options.transport ?? 'config'
38
30
  const transport = transportMode === 'disabled' || servConfig.transport === false
39
31
  ? undefined
@@ -46,7 +38,7 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
46
38
  {{else}} const transport = undefined
47
39
  {{/if}}
48
40
  {{#if features.iam}}
49
- const refreshCookie = _options.refreshCookie === 'disabled' ? undefined : {}
41
+ const refreshCookie = _options.refreshCookie === 'disabled' ? undefined : { secure: security.secureRefreshCookie }
50
42
  {{/if}}
51
43
 
52
44
  const appOptions: Parameters<typeof serv.createApp>[0] = {
@@ -61,17 +53,21 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
61
53
  startedAt,
62
54
  },
63
55
  }),
64
- http: servConfig.http,
56
+ http: {
57
+ ...servConfig.http,
58
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
59
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
60
+ },
65
61
  {{#if features.iam}}
66
62
  iam,
67
63
  {{/if}}
68
64
  middlewares: [
69
65
  {
70
66
  middleware: serv.cors({
71
- origin: origin => resolveCorsOrigin(origin) !== null,
72
- {{#if features.iam}} credentials: true,
73
- {{/if}} allowedHeaders: DEFAULT_CORS_ALLOWED_HEADERS,{{#if features.crypto}}
74
- exposedHeaders: [TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, 'X-Request-Id'],{{/if}}
67
+ origin: security.allowOrigin,
68
+ credentials: servConfig.cors.credentials,
69
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
70
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
75
71
  }),
76
72
  },
77
73
  ],
@@ -86,13 +82,3 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
86
82
 
87
83
  return serv.createApp(appOptions)
88
84
  }
89
-
90
- function resolveCorsOrigin(origin: string | undefined): string | null {
91
- if (!origin)
92
- return null
93
- if (WEBVIEW_ORIGINS.has(origin))
94
- return origin
95
- if (LOOPBACK_ORIGIN_RE.test(origin))
96
- return origin
97
- return null
98
- }
@@ -1,3 +1,4 @@
1
+ import type { ServConfig } from '@h-ai/serv'
1
2
  import type { AddressInfo } from 'node:net'
2
3
  import { mkdtempSync, rmSync } from 'node:fs'
3
4
  import { tmpdir } from 'node:os'
@@ -20,6 +21,7 @@ let tempRoot = ''
20
21
  let baseUrl = ''
21
22
  let client: ReturnType<typeof apiClient.create<typeof {{api.contractExportName}}>>
22
23
  let server: ReturnType<typeof serv.listen>
24
+ let servConfig: ServConfig
23
25
  {{#if features.storage}}let storageRoot = ''
24
26
  {{/if}}{{#if features.vecdb}}let vecdbRoot = ''
25
27
  {{/if}}
@@ -45,6 +47,7 @@ beforeAll(async () => {
45
47
  configDir: './config',
46
48
  logging: { level: 'warn' },
47
49
  })
50
+ servConfig = core.config.getOrThrow<ServConfig>('serv')
48
51
  {{#if features.db}}
49
52
 
50
53
  const dbResult = await reldb.init({ type: 'sqlite', database: ':memory:' })
@@ -93,7 +96,7 @@ beforeAll(async () => {
93
96
 
94
97
  client = apiClient.create({{api.contractExportName}})
95
98
  const initResult = await client.init({
96
- baseUrl: `${baseUrl}/api/v1`,
99
+ baseUrl: `${baseUrl}${servConfig.http.apiPrefix}`,
97
100
  auth: { storage: apiClient.tokenStorage.memory() },
98
101
  })
99
102
  if (!initResult.success)
@@ -114,7 +117,9 @@ afterAll(async () => {
114
117
 
115
118
  describe('{{api.servicePackageName}}', () => {
116
119
  it('returns health status', async () => {
117
- const response = await fetch(`${baseUrl}/health`)
120
+ if (servConfig.http.health === false)
121
+ throw new Error('service test requires health endpoint')
122
+ const response = await fetch(`${baseUrl}${servConfig.http.health.path}`)
118
123
 
119
124
  expect(response.status).toBe(200)
120
125
  await expect(response.json()).resolves.toEqual({ status: 'ok' })
@@ -1,4 +1,8 @@
1
1
  import { expect, test } from '@playwright/test'
2
+ import { apiContract } from '@h-ai/api-contract'
3
+ import { {{api.contractExportName}} } from '{{api.contractPackageName}}'
4
+
5
+ const API_PREFIX = '/api/v1'
2
6
 
3
7
  test('service health endpoint returns ok', async ({ request }) => {
4
8
  const response = await request.get('/health')
@@ -8,7 +12,7 @@ test('service health endpoint returns ok', async ({ request }) => {
8
12
  })
9
13
 
10
14
  test('service app.info endpoint returns HaiResult', async ({ request }) => {
11
- const response = await request.post('/api/v1/app/info')
15
+ const response = await request.post(`${API_PREFIX}${apiContract.pathOf({{api.contractExportName}}.app.info)}`)
12
16
  const body = await response.json()
13
17
 
14
18
  expect(response.status()).toBe(200)
@@ -15,6 +15,10 @@
15
15
  "test:e2e": "pnpm build && playwright test",
16
16
  "clean": "pnpm -r --if-present clean && rimraf node_modules .turbo"
17
17
  },
18
+ "dependencies": {
19
+ "@h-ai/api-contract": "{{haiPackageSpecifier}}",
20
+ "{{api.contractPackageName}}": "workspace:*"
21
+ },
18
22
  "devDependencies": {
19
23
  "@antfu/eslint-config": "{{api.packageSpecifiers.antfuEslintConfig}}",
20
24
  "@playwright/test": "{{api.packageSpecifiers.playwright}}",
@@ -1,17 +1,19 @@
1
1
  import { expect, test } from '@playwright/test'
2
- import { APP_API_ROUTES } from '{{fullstack.contractPackageName}}'
2
+ import { apiContract } from '@h-ai/api-contract'
3
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
4
 
4
5
  const DEFAULT_SERVICE_BASE_URL = 'http://127.0.0.1:3000'
5
6
  const apiBaseUrl = process.env.SERVICE_BASE_URL || DEFAULT_SERVICE_BASE_URL
7
+ const apiPrefix = '/api/v1'
6
8
 
7
9
  test('service health endpoint is ready', async ({ request }) => {
8
- const response = await request.get(apiBaseUrl + APP_API_ROUTES.health)
10
+ const response = await request.get(`${apiBaseUrl}/health`)
9
11
  expect(response.ok()).toBe(true)
10
12
  await expect(response).toBeOK()
11
13
  })
12
14
 
13
15
  test('service echo endpoint returns HaiResult', async ({ request }) => {
14
- const response = await request.post(apiBaseUrl + APP_API_ROUTES.echo, {
16
+ const response = await request.post(`${apiBaseUrl}${apiPrefix}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
15
17
  data: { message: 'hello e2e' },
16
18
  })
17
19
  expect(response.ok()).toBe(true)
@@ -20,6 +20,7 @@
20
20
  "clean": "pnpm -r --if-present clean && rimraf node_modules .turbo"
21
21
  },
22
22
  "dependencies": {
23
+ "@h-ai/api-contract": "{{haiPackageSpecifier}}",
23
24
  "{{fullstack.contractPackageName}}": "workspace:*",
24
25
  "{{fullstack.sharedPackageName}}": "workspace:*"
25
26
  },
@@ -1,14 +1,16 @@
1
1
  # {{fullstack.contractPackageName}}
2
2
 
3
- 共享 API 契约包,包含前后端共用的路径常量、Zod Schema、`apiContract.route()` 路由定义与工程级 contract。
3
+ 共享 API 契约包,包含 Zod Schema、`apiContract.route()` 业务接口定义与工程级 contract。
4
4
 
5
5
  ## 骨架说明
6
6
 
7
- - `src/app-routes.ts`:HTTP 路径常量,供 service、frontend 与 E2E 复用。
8
- - `src/app-schemas.ts`:输入输出 Schema 与类型。
9
- - `src/app-contract.ts`:app 领域 contract 路由定义。
7
+ - `src/app-schemas.ts`:跨接口、跨层复用的输入/数据 Schema 与类型。
8
+ - `src/app-contract.ts`:app 领域 contract;每个业务 path 直接写在对应接口定义中。
10
9
  - `src/{{projectName}}-contract.ts`:工程级 contract,供 `@h-ai/serv` 与 `@h-ai/api-client` 共用。
11
10
 
11
+ 只使用一次的输出 Schema 直接内联到 `.output(...)`;同一 contract 文件内重复使用时才保留私有常量。
12
+ API 前缀、健康检查与跨端 Header 等传输配置位于 serv 包的 `config/_serv.yml`。
13
+
12
14
  ## 开发
13
15
 
14
16
  - `pnpm --filter {{fullstack.contractPackageName}} typecheck`
@@ -1,21 +1,20 @@
1
1
  import { apiContract } from '@h-ai/api-contract'
2
- import { APP_CONTRACT_ROUTES } from './app-routes.js'
3
- import { AppEchoInputSchema, AppEchoOutputSchema, AppInfoOutputSchema } from './app-schemas.js'
2
+ import { AppEchoInputSchema, AppEchoOutputDataSchema, AppInfoOutputDataSchema } from './app-schemas.js'
4
3
 
5
4
  /** app 领域 API contract:路径、方法、输入输出 Schema 的单一来源。 */
6
5
  export const appContract = {
7
6
  info: apiContract.route({
8
7
  method: 'POST',
9
- path: APP_CONTRACT_ROUTES.info,
8
+ path: '/app/info',
10
9
  operationId: 'app.info',
11
10
  tags: ['app'],
12
11
  summary: 'Get application runtime information',
13
- }).output(AppInfoOutputSchema),
12
+ }).output(apiContract.haiResultSchema(AppInfoOutputDataSchema)),
14
13
  echo: apiContract.route({
15
14
  method: 'POST',
16
- path: APP_CONTRACT_ROUTES.echo,
15
+ path: '/app/echo',
17
16
  operationId: 'app.echo',
18
17
  tags: ['app'],
19
18
  summary: 'Echo a message through the generated service route',
20
- }).input(AppEchoInputSchema).output(AppEchoOutputSchema),
21
- }
19
+ }).input(AppEchoInputSchema).output(apiContract.haiResultSchema(AppEchoOutputDataSchema)),
20
+ }
@@ -1,4 +1,4 @@
1
- import { apiContract } from '@h-ai/api-contract'
1
+ import type { HaiResult } from '@h-ai/core'
2
2
  import { z } from 'zod'
3
3
 
4
4
  export const AppFrontendTargetSchema = z.enum(['web', 'app', 'miniapp', 'desktop'])
@@ -23,12 +23,9 @@ export const AppEchoOutputDataSchema = z.object({
23
23
  requestId: z.string(),
24
24
  })
25
25
 
26
- export const AppInfoOutputSchema = apiContract.haiResultSchema(AppInfoOutputDataSchema)
27
- export const AppEchoOutputSchema = apiContract.haiResultSchema(AppEchoOutputDataSchema)
28
-
29
26
  export type AppFrontendTarget = z.infer<typeof AppFrontendTargetSchema>
30
27
  export type AppInfoOutputData = z.infer<typeof AppInfoOutputDataSchema>
31
- export type AppInfoOutput = z.infer<typeof AppInfoOutputSchema>
28
+ export type AppInfoOutput = HaiResult<AppInfoOutputData>
32
29
  export type AppEchoInput = z.infer<typeof AppEchoInputSchema>
33
30
  export type AppEchoOutputData = z.infer<typeof AppEchoOutputDataSchema>
34
- export type AppEchoOutput = z.infer<typeof AppEchoOutputSchema>
31
+ export type AppEchoOutput = HaiResult<AppEchoOutputData>
@@ -1,4 +1,3 @@
1
1
  export * from './app-contract.js'
2
- export * from './app-routes.js'
3
2
  export * from './app-schemas.js'
4
3
  export * from './{{projectName}}-contract.js'
@@ -1,9 +1,12 @@
1
1
  import { apiContract } from '@h-ai/api-contract'
2
2
  import { appContract } from './app-contract.js'
3
3
 
4
+ /** 当前工程启用的前端目标。 */
5
+ export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const
6
+
4
7
  /** 工程级 API contract,前端 typed client 与后端 service 共用。 */
5
8
  export const {{fullstack.contractExportName}} = apiContract.create({
6
9
  app: appContract,
7
10
  })
8
11
 
9
- export type FullstackContract = typeof {{fullstack.contractExportName}}
12
+ export type FullstackContract = typeof {{fullstack.contractExportName}}
@@ -1,10 +1,9 @@
1
+ import { apiContract } from '@h-ai/api-contract'
1
2
  import { describe, expect, it } from 'vitest'
2
3
  import {
3
- APP_API_ROUTES,
4
4
  appContract,
5
5
  AppEchoInputSchema,
6
6
  AppInfoOutputDataSchema,
7
- AppInfoOutputSchema,
8
7
  ENABLED_FRONTENDS,
9
8
  {{fullstack.contractExportName}},
10
9
  } from '../src/index.js'
@@ -13,7 +12,7 @@ describe('{{fullstack.contractPackageName}}', () => {
13
12
  it('defines app contract routes', () => {
14
13
  expect({{fullstack.contractExportName}}.app).toBeDefined()
15
14
  expect(appContract.info).toBeDefined()
16
- expect(APP_API_ROUTES.info).toBe('/api/v1/app/info')
15
+ expect(apiContract.pathOf(appContract.info)).toBe('/app/info')
17
16
  })
18
17
 
19
18
  it('validates app info payload', () => {
@@ -31,18 +30,4 @@ describe('{{fullstack.contractPackageName}}', () => {
31
30
  expect(AppEchoInputSchema.safeParse({ message: 'hello' }).success).toBe(true)
32
31
  expect(AppEchoInputSchema.safeParse({ message: '' }).success).toBe(false)
33
32
  })
34
-
35
- it('validates HaiResult app info response', () => {
36
- const result = AppInfoOutputSchema.safeParse({
37
- success: true,
38
- data: {
39
- name: '{{fullstack.servPackageName}}',
40
- version: '0.1.0',
41
- startedAt: '2026-01-01T00:00:00.000Z',
42
- frontends: [...ENABLED_FRONTENDS],
43
- },
44
- })
45
-
46
- expect(result.success).toBe(true)
47
- })
48
33
  })
@@ -3,12 +3,12 @@
3
3
  # =============================================================================
4
4
  #
5
5
  # 后端服务的应用级配置,由 core.config 在启动时加载。
6
- # 敏感信息请通过环境变量注入。
6
+ # 每个叶子项都可由约定式 HAI 环境变量覆盖,敏感值不要写入文件。
7
7
  # =============================================================================
8
8
 
9
9
  name: {{fullstack.servPackageName}}
10
10
  version: 0.1.0
11
- env: ${HAI_ENV:development}
12
- debug: ${HAI_DEBUG:false}
11
+ env: development
12
+ debug: false
13
13
 
14
14
  defaultLocale: {{fullstack.i18n.baseLocale}}
@@ -2,12 +2,30 @@
2
2
  # {{fullstack.servPackageName}} - Serv / HTTP 入口配置
3
3
  # =============================================================================
4
4
  #
5
- # HTTP 路由路径(apiPrefix / health / openapi / docs)由共享 contract
6
- # 统一定义,作为前后端唯一事实来源,因此不在此重复声明。
7
- # 本文件仅声明服务端运行时设置。
5
+ # 业务接口路径直接写在对应 *-contract.ts;公共 HTTP 传输配置以本文件为准。
8
6
  # =============================================================================
9
7
 
10
- # CORS 允许的来源。
11
- # 开发环境放开为 *,生产环境应收敛为前端域名(如 https://app.example.com)。
8
+ http:
9
+ apiPrefix: /api/v1
10
+ openapi:
11
+ path: /openapi.json
12
+ docs:
13
+ path: /docs
14
+ health:
15
+ path: /health
16
+ readyPath: /ready
17
+ rpc: false
18
+
12
19
  cors:
13
- origin: ${HAI_CORS_ORIGIN:*}
20
+ origin: '*'
21
+ nativeOrigins: http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost
22
+ credentials: false
23
+ allowedHeaders:
24
+ - Authorization
25
+ - Content-Type
26
+ - X-Request-Id
27
+ - X-Requested-With
28
+ exposedHeaders:
29
+ - X-Request-Id
30
+
31
+ transport: false
@@ -1,6 +1,7 @@
1
+ import type { ServConfigInput } from '@h-ai/serv'
1
2
  import { core } from '@h-ai/core'
2
- import { serv } from '@h-ai/serv'
3
- import { APP_HTTP_ROUTES, ENABLED_FRONTENDS, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
+ import { serv, ServConfigSchema } from '@h-ai/serv'
4
+ import { ENABLED_FRONTENDS, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
4
5
  import { createServerProcedures } from './server/procedures/index.js'
5
6
 
6
7
  const STARTED_AT = new Date().toISOString()
@@ -9,11 +10,7 @@ const STARTED_AT = new Date().toISOString()
9
10
  interface AppCoreConfig {
10
11
  name?: string
11
12
  version?: string
12
- }
13
-
14
- /** `_serv.yml` 中的服务端运行时设置。 */
15
- interface AppServConfig {
16
- cors?: { origin?: string }
13
+ env?: string
17
14
  }
18
15
 
19
16
  /** 应用侧最小 HTTP App 抽象;不把底层 Hono 类型暴露给项目代码。 */
@@ -27,10 +24,14 @@ export function createServerApp(): ServerApp {
27
24
  // 配置来自 config/_core.yml 与 config/_serv.yml。
28
25
  // 单元测试可不初始化 core,此时回退到内置默认值。
29
26
  const coreConfig = core.config.get<AppCoreConfig>('core')
30
- const servConfig = core.config.get<AppServConfig>('serv')
27
+ const servConfig = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
31
28
  const appName = coreConfig?.name ?? '{{fullstack.servPackageName}}'
32
29
  const appVersion = coreConfig?.version ?? '0.1.0'
33
- const corsOrigin = servConfig?.cors?.origin ?? '*'
30
+ const security = serv.createRuntimeSecurityPolicy({
31
+ environment: coreConfig?.env,
32
+ corsOrigin: servConfig.cors.origin,
33
+ nativeOrigins: servConfig.cors.nativeOrigins,
34
+ })
34
35
 
35
36
  return serv.createApp({
36
37
  contract: {{fullstack.contractExportName}},
@@ -43,19 +44,16 @@ export function createServerApp(): ServerApp {
43
44
  },
44
45
  }),
45
46
  http: {
46
- apiPrefix: APP_HTTP_ROUTES.apiPrefix,
47
- health: {
48
- path: APP_HTTP_ROUTES.health,
49
- readyPath: APP_HTTP_ROUTES.ready,
50
- },
51
- openapi: { path: APP_HTTP_ROUTES.openapi },
52
- docs: { path: APP_HTTP_ROUTES.docs },
53
- rpc: false,
47
+ ...servConfig.http,
48
+ openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
49
+ docs: security.exposeApiDocs ? servConfig.http.docs : false,
54
50
  },
55
51
  middlewares: [{
56
52
  middleware: serv.cors({
57
- origin: corsOrigin,
58
- allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
53
+ origin: security.allowOrigin,
54
+ credentials: servConfig.cors.credentials,
55
+ allowedHeaders: [...servConfig.cors.allowedHeaders],
56
+ exposedHeaders: [...servConfig.cors.exposedHeaders],
59
57
  }),
60
58
  }],
61
59
  })
@@ -1,11 +1,14 @@
1
- import { APP_API_ROUTES } from '{{fullstack.contractPackageName}}'
1
+ import { apiContract } from '@h-ai/api-contract'
2
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
2
3
  import { describe, expect, it } from 'vitest'
3
4
  import { createServerApp } from '../src/app-server.js'
4
5
 
6
+ const API_PREFIX = '/api/v1'
7
+
5
8
  describe('{{fullstack.servPackageName}}', () => {
6
9
  it('returns health status', async () => {
7
10
  const app = createServerApp()
8
- const response = await app.request(APP_API_ROUTES.health)
11
+ const response = await app.request('/health')
9
12
 
10
13
  expect(response.status).toBe(200)
11
14
  await expect(response.json()).resolves.toEqual({ status: 'ok' })
@@ -13,7 +16,7 @@ describe('{{fullstack.servPackageName}}', () => {
13
16
 
14
17
  it('returns app info as HaiResult', async () => {
15
18
  const app = createServerApp()
16
- const response = await app.request(APP_API_ROUTES.info, { method: 'POST' })
19
+ const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.info)}`, { method: 'POST' })
17
20
  const body = await response.json()
18
21
 
19
22
  expect(response.status).toBe(200)
@@ -23,7 +26,7 @@ describe('{{fullstack.servPackageName}}', () => {
23
26
 
24
27
  it('validates echo input', async () => {
25
28
  const app = createServerApp()
26
- const response = await app.request(APP_API_ROUTES.echo, {
29
+ const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
27
30
  method: 'POST',
28
31
  headers: { 'content-type': 'application/json' },
29
32
  body: JSON.stringify({ message: '' }),
@@ -34,7 +37,7 @@ describe('{{fullstack.servPackageName}}', () => {
34
37
 
35
38
  it('returns echo result as HaiResult', async () => {
36
39
  const app = createServerApp()
37
- const response = await app.request(APP_API_ROUTES.echo, {
40
+ const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
38
41
  method: 'POST',
39
42
  headers: { 'content-type': 'application/json' },
40
43
  body: JSON.stringify({ message: 'hello unit' }),
@@ -1,6 +1,6 @@
1
1
  import type { AppInfoOutputData } from '{{fullstack.contractPackageName}}'
2
2
  import { apiClient } from '@h-ai/api-client'
3
- import { APP_HTTP_ROUTES, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
+ import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
4
4
 
5
5
  export type AppInfo = AppInfoOutputData
6
6
 
@@ -43,9 +43,9 @@ function resolveDefaultApiOrigin(): string {
43
43
 
44
44
  function normalizeApiBaseUrl(value: string): string {
45
45
  const trimmed = trimTrailingSlash(value)
46
- return trimmed.endsWith(APP_HTTP_ROUTES.apiPrefix)
46
+ return trimmed.endsWith('/api/v1')
47
47
  ? trimmed
48
- : `${trimmed}${APP_HTTP_ROUTES.apiPrefix}`
48
+ : `${trimmed}/api/v1`
49
49
  }
50
50
 
51
51
  function trimTrailingSlash(value: string): string {
@@ -33,7 +33,6 @@ export async function initApi(): Promise<void> {
33
33
  baseUrl: resolveApiBase(),
34
34
  auth: {
35
35
  storage: await createTokenStorage(),
36
- refreshPath: '/auth/refresh',
37
36
  },
38
37
  })
39
38
 
@@ -51,4 +50,4 @@ export async function closeApi(): Promise<void> {
51
50
  initialized = false
52
51
  }
53
52
 
54
- export { apiClient }
53
+ export { apiClient }
@@ -2,6 +2,9 @@ import { expect, test } from '@playwright/test'
2
2
 
3
3
  /** 首页从真实浏览器页面发起,覆盖基础渲染链路。 */
4
4
  test('home page renders from browser', async ({ page }) => {
5
+ const pageErrors: string[] = []
6
+ page.on('pageerror', error => pageErrors.push(error.message))
5
7
  await page.goto('/')
8
+ await expect(pageErrors).toEqual([])
6
9
  await expect(page.locator('main, section').first()).toBeVisible()
7
- })
10
+ })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "{{projectName}}",
3
- "version": "0.1.0-alpha.42",
3
+ "version": "0.1.0-alpha.44",
4
4
  "private": true,
5
5
  "description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
6
6
  "keywords": ["hai", {{#unless isSvelteOnlyApp}}"sveltekit", {{/unless}}"svelte5", "{{appType}}"],
@@ -65,9 +65,9 @@ await ai.close()
65
65
 
66
66
  ```yaml
67
67
  llm:
68
- apiKey: ${HAI_AI_LLM_API_KEY:}
69
- baseUrl: ${HAI_AI_LLM_BASE_URL:https://api.openai.com/v1}
70
- model: ${HAI_AI_LLM_MODEL:gpt-4o-mini}
68
+ apiKey: ''
69
+ baseUrl: https://api.openai.com/v1
70
+ model: gpt-4o-mini
71
71
  api: chat # chat(默认)| responses | anthropic——底层协议,对使用方透明
72
72
  timeout: 60000
73
73
  tempModelCacheTtl: 600000 # 临时模型客户端缓存 TTL(毫秒,默认 10 分钟)
@@ -97,6 +97,9 @@ memory:
97
97
  # allowEphemeralFallback: true # 仅明确接受重启丢失数据时开启
98
98
  ```
99
99
 
100
+ 约定环境变量优先于 YAML,例如 `HAI_AI_LLM_APIKEY`、
101
+ `HAI_AI_LLM_BASEURL`、`HAI_AI_LLM_MODEL`。
102
+
100
103
  `ai.config` 是脱敏快照;`apiKey`、`privateKey`、URL 内嵌凭证会被隐藏。
101
104
 
102
105
  ## 常用 API
@@ -279,11 +282,13 @@ for await (const ev of m.chatStream('展开讲讲', { signal: controller.signal
279
282
 
280
283
  ## 语音(Audio)
281
284
 
282
- `ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。
285
+ `ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。LLM 与语音模型确认共用凭据时,可显式设置 `inheritLlmApiKey: true`;该选项默认关闭,避免跨供应商误用密钥。
283
286
 
284
287
  ```yaml
285
288
  # ai.init 配置片段
286
289
  audio:
290
+ # 仅当语音服务与 LLM 确认共用同一密钥时启用
291
+ inheritLlmApiKey: true
287
292
  models:
288
293
  - { id: asr, provider: qwen, model: qwen3-asr-flash-realtime, operations: [transcribe] }
289
294
  - { id: tts, provider: qwen, model: qwen3-tts-flash-realtime, operations: [synthesize] }
@@ -332,6 +337,8 @@ image:
332
337
  generateModel: free
333
338
  ```
334
339
 
340
+ LLM、Audio、Image 的可选密钥统一使用 `OptionalSecretSchema`:配置中的 YAML `null`、空字符串和纯空白字符串均表示未配置。语音密钥优先级为模型条目 → 启用继承后的 LLM 密钥 → 平台环境变量,业务启动代码不应再遍历配置修补 `apiKey`。
341
+
335
342
  ```ts
336
343
  const image = await ai.image.generate({
337
344
  prompt: '水墨风格的未来城市,清晨薄雾',
@@ -179,8 +179,8 @@ const result = await ai.rag.query('核心架构是什么?', {
179
179
 
180
180
  - 音频类型:`AudioContent { data: Uint8Array, format: 'pcm16'|'wav'|'mp3'|'opus', sampleRate?, channels? }`;`pcm16` 等裸音频必须传 `sampleRate`。
181
181
  - 请求可选:识别 `contextHints?: string[]`(热词/提示);合成 `instruction?: string`(自然语言风格指令);均支持 `signal: AbortSignal`。
182
- - 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。
183
- - 凭据环境变量回退:`HAI_AI_AUDIO_<PROVIDER>_API_KEY` 或 `OPENAI_API_KEY` / `MIMO_API_KEY` / `DASHSCOPE_API_KEY` / `VOLC_API_KEY`(豆包旧版控制台额外 `VOLC_APP_KEY` / `VOLC_ACCESS_KEY`)。
182
+ - 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。确认 LLM 与语音模型共用凭据时,可设置 `audio.inheritLlmApiKey: true`,默认关闭。
183
+ - 凭据优先级:模型条目 `apiKey` → 显式启用继承后的 LLM `apiKey` → `HAI_AI_AUDIO_<PROVIDER>_API_KEY` 或 `OPENAI_API_KEY` / `MIMO_API_KEY` / `DASHSCOPE_API_KEY` / `VOLC_API_KEY`(豆包旧版控制台额外 `VOLC_APP_KEY` / `VOLC_ACCESS_KEY`)。可选密钥中的 YAML `null`、空字符串和纯空白字符串都会规范化为未配置。
184
184
  - 资源上限:`audio.maxAudioBytes`(默 10 MiB)、`audio.maxStreamDurationMs`(默 5 分钟);所有请求支持 `signal: AbortSignal` 打断。
185
185
  - Provider 为内部实现,不从根入口导出;不支持的输入方式招 `AUDIO_UNSUPPORTED_INPUT`,不伪装实时。取消 → `AUDIO_CANCELLED`,超时 → `AUDIO_TIMEOUT`,连接失败或 `end` 前异常断连 → `AUDIO_CONNECTION_FAILED`。浏览器客户端(`@h-ai/ai/client`)严格区分正常结束 / 取消 / 异常断连:取消招 `AUDIO_CANCELLED`、`end` 前断连招 `AUDIO_CONNECTION_FAILED`、服务端 error 帧保留其领域错误码,`synthesize` 不把未完成的部分音频当作成功返回。
186
186