@h-ai/cli 0.1.0-alpha.64 → 0.1.0-alpha.66
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.en.md +31 -9
- package/README.ja.md +30 -8
- package/README.md +38 -16
- package/dist/{chunk-X3RSYNBG.js → chunk-FWN5B54L.js} +94 -4
- package/dist/chunk-FWN5B54L.js.map +1 -0
- package/dist/chunk-QYX6V7H2.js +5613 -0
- package/dist/chunk-QYX6V7H2.js.map +1 -0
- package/dist/cli-config-templates-3XSFIXBP.js +5 -0
- package/dist/{cli-config-templates-QRF6B5T4.js.map → cli-config-templates-3XSFIXBP.js.map} +1 -1
- package/dist/{cli-skill-templates-EZFW7D43.js → cli-skill-templates-TSYEQTCY.js} +3 -3
- package/dist/{cli-skill-templates-EZFW7D43.js.map → cli-skill-templates-TSYEQTCY.js.map} +1 -1
- package/dist/index.js +733 -210
- package/dist/index.js.map +1 -1
- package/dist/{src-DUGLX4YB.js → src-RDQ2EIBT.js} +1054 -660
- package/dist/{src-DUGLX4YB.js.map → src-RDQ2EIBT.js.map} +1 -1
- package/package.json +9 -7
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +4 -2
- package/templates/apps/api/.dockerignore.hbs +4 -0
- package/templates/apps/api/.gitignore.hbs +6 -5
- package/templates/apps/api/Dockerfile.hbs +5 -8
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +2 -2
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +2 -2
- package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +4 -4
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +8 -2
- package/templates/apps/api/hai.build.json.hbs +1 -0
- package/templates/apps/api/package.json.hbs +13 -7
- package/templates/apps/fullstack/.dockerignore.hbs +4 -3
- package/templates/apps/fullstack/.gitignore.hbs +24 -10
- package/templates/apps/fullstack/Dockerfile.hbs +6 -8
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +0 -3
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +5 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +8 -4
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -6
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +6 -2
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/src/lib.rs.hbs +73 -2
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +8 -4
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -6
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +5 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +8 -4
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +24 -1
- package/templates/apps/fullstack/hai.build.json.hbs +7 -0
- package/templates/apps/fullstack/package.json.hbs +16 -11
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +8 -14
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +8 -6
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +8 -8
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +3 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +8 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +4 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +4 -2
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +14 -12
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +17 -3
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +15 -5
- package/templates/apps/fullstack/playwright.config.ts.hbs +6 -1
- package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +0 -1
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +13 -13
- package/templates/apps/mobile-app/index.html.hbs +1 -0
- package/templates/apps/mobile-app/public/runtime-config.js +1 -0
- package/templates/apps/mobile-app/src/App.svelte.hbs +46 -117
- package/templates/apps/mobile-app/src/lib/api.ts.hbs +2 -1
- package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +3 -2
- package/templates/apps/mobile-app/src/lib/pages/home.svelte.hbs +58 -0
- package/templates/apps/mobile-app/src/lib/pages/profile.svelte.hbs +13 -0
- package/templates/apps/mobile-app/src/lib/pages/tasks.svelte.hbs +16 -0
- package/templates/base/.dockerignore.hbs +4 -0
- package/templates/base/.gitignore.hbs +20 -5
- package/templates/base/Dockerfile.hbs +10 -11
- package/templates/base/docker/nginx.conf.hbs +6 -0
- package/templates/base/e2e/app.spec.ts.hbs +14 -0
- package/templates/base/hai.build.json.hbs +1 -0
- package/templates/base/package.json.hbs +25 -17
- package/templates/base/svelte.config.js.hbs +2 -0
- package/templates/base/tests/scaffold.test.ts.hbs +9 -3
- package/templates/base/vite.config.ts.hbs +11 -5
- package/templates/shared/.gitattributes +5 -0
- package/templates/shared/container-runtime-config.sh +30 -0
- package/templates/shared/hash-router.svelte.ts +83 -0
- package/templates/shared/run-e2e.mjs +65 -0
- package/templates/shared/vite-runtime-config.ts +54 -0
- package/templates/skills/AGENTS.md +15 -1
- package/templates/skills/bridges/admin/AGENTS.md +17 -1
- package/templates/skills/bridges/api/AGENTS.md +17 -3
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +35 -11
- package/templates/skills/bridges/h5/AGENTS.md +17 -1
- package/templates/skills/bridges/mobile-app/AGENTS.md +19 -3
- package/templates/skills/bridges/website/AGENTS.md +17 -1
- package/templates/skills/hai-ai/reference.md +2 -2
- package/templates/skills/hai-app-create/SKILL.md +5 -3
- package/templates/skills/hai-app-create/development.md +128 -2
- package/templates/skills/hai-app-create/reference.md +4 -4
- package/templates/skills/hai-app-review/SKILL.md +2 -2
- package/templates/skills/hai-app-tests/SKILL.md +2 -1
- package/templates/skills/hai-app-tests/reference.md +1 -1
- package/templates/skills/hai-build/SKILL.md +16 -1
- package/templates/skills/hai-core/SKILL.md +2 -1
- package/templates/skills/hai-deploy/SKILL.md +52 -252
- package/templates/skills/hai-deploy/middleware.md +88 -0
- package/templates/skills/hai-framework-sync/SKILL.md +1 -1
- package/templates/skills/hai-framework-sync/sync.md +1 -1
- package/templates/skills/hai-reach/reference.md +8 -8
- package/templates/skills/hai-spec-implement/SKILL.md +12 -9
- package/templates/skills/hai-spec-implement/changes.md +67 -0
- package/templates/skills/hai-spec-implement/templates.md +25 -3
- package/templates/skills/hai-spec-implement/workflow.md +7 -6
- package/templates/skills/hai-spec-review/SKILL.md +14 -5
- package/templates/skills/hai-spec-review/checklist.md +4 -3
- package/templates/skills/hai-storage/SKILL.md +2 -0
- package/templates/skills/hai-ui/reference.md +2 -0
- package/dist/chunk-WW2MIO6T.js +0 -992
- package/dist/chunk-WW2MIO6T.js.map +0 -1
- package/dist/chunk-X3RSYNBG.js.map +0 -1
- package/dist/cli-config-templates-QRF6B5T4.js +0 -5
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/AuditManagement.svelte.hbs → pages/audit.svelte.hbs} +0 -0
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/RoleManagement.svelte.hbs → pages/roles.svelte.hbs} +0 -0
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/ProfileSettings.svelte.hbs → pages/settings.svelte.hbs} +0 -0
- /package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/{components/UserManagement.svelte.hbs → pages/users.svelte.hbs} +0 -0
package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { HashRouter } from '../src/lib/navigation/hash-router.svelte.js'
|
|
2
3
|
import * as m from '../src/lib/paraglide/messages.js'
|
|
3
4
|
import { ManagementStore } from '../src/lib/stores/management-store.svelte.js'
|
|
4
5
|
|
|
@@ -35,15 +36,24 @@ function deferred<T>() {
|
|
|
35
36
|
|
|
36
37
|
beforeEach(() => {
|
|
37
38
|
vi.resetAllMocks()
|
|
39
|
+
vi.stubGlobal('location', { hash: '#/users' })
|
|
40
|
+
vi.stubGlobal('history', { replaceState: vi.fn(), pushState: vi.fn() })
|
|
38
41
|
api.iam.auth.logout.mockResolvedValue({ success: true })
|
|
39
42
|
api.iam.auth.currentUser.mockResolvedValue({ success: true, data: member })
|
|
40
43
|
})
|
|
41
44
|
|
|
45
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
46
|
+
|
|
47
|
+
/** 会话测试使用真实路由状态,浏览器历史由 E2E 验证。 */
|
|
48
|
+
function createStore() {
|
|
49
|
+
return new ManagementStore(new HashRouter(['../pages/users.svelte', '../pages/settings.svelte'], '/users'))
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
describe('management session and request ownership', () => {
|
|
43
53
|
it('ignores a previous administrator failure after logout and another user login', async () => {
|
|
44
54
|
const oldRequest = deferred<typeof forbidden>()
|
|
45
55
|
api.admin.users.list.mockReturnValueOnce(oldRequest.promise)
|
|
46
|
-
const store =
|
|
56
|
+
const store = createStore()
|
|
47
57
|
store.user = administrator
|
|
48
58
|
const loading = store.loadUsers()
|
|
49
59
|
await store.logout()
|
|
@@ -60,7 +70,7 @@ describe('management session and request ownership', () => {
|
|
|
60
70
|
it('ignores an obsolete page failure after opening personal settings', async () => {
|
|
61
71
|
const oldRequest = deferred<typeof forbidden>()
|
|
62
72
|
api.admin.users.list.mockReturnValueOnce(oldRequest.promise)
|
|
63
|
-
const store =
|
|
73
|
+
const store = createStore()
|
|
64
74
|
store.user = administrator
|
|
65
75
|
const loading = store.loadUsers()
|
|
66
76
|
await store.navigate('settings')
|
|
@@ -75,7 +85,7 @@ describe('management session and request ownership', () => {
|
|
|
75
85
|
|
|
76
86
|
it('refreshes revoked roles once and clears administration data', async () => {
|
|
77
87
|
api.admin.users.list.mockResolvedValueOnce(forbidden)
|
|
78
|
-
const store =
|
|
88
|
+
const store = createStore()
|
|
79
89
|
store.user = administrator
|
|
80
90
|
store.roles = [{ id: 'role-id', code: 'admin', name: 'Administrator', userCount: 1 }]
|
|
81
91
|
await store.loadUsers()
|
|
@@ -90,7 +100,7 @@ describe('management session and request ownership', () => {
|
|
|
90
100
|
|
|
91
101
|
it('reports a connection failure when loading the authenticated user', async () => {
|
|
92
102
|
api.iam.auth.currentUser.mockRejectedValueOnce(new Error('Connection unavailable'))
|
|
93
|
-
const store =
|
|
103
|
+
const store = createStore()
|
|
94
104
|
await store.authenticated()
|
|
95
105
|
expect(store.error).toBe(m.network_error())
|
|
96
106
|
expect(store.error).not.toBe(m.login_invalid())
|
|
@@ -5,12 +5,17 @@ const defaultBaseURL = '{{#if fullstack.e2eFrontend}}http://127.0.0.1:{{fullstac
|
|
|
5
5
|
const defaultServiceBaseURL = 'http://127.0.0.1:3000'
|
|
6
6
|
const baseURL = process.env.BASE_URL || defaultBaseURL
|
|
7
7
|
const primaryFrontend = '{{fullstack.e2eFrontend.target}}'
|
|
8
|
+
const frontendUrls = {
|
|
9
|
+
web: process.env.HAI_E2E_WEB_URL,
|
|
10
|
+
app: process.env.HAI_E2E_APP_URL,
|
|
11
|
+
desktop: process.env.HAI_E2E_DESKTOP_URL,
|
|
12
|
+
}
|
|
8
13
|
const frontends: { target: string, name: string, url: string }[] = [
|
|
9
14
|
{{#each fullstack.frontendApps}}
|
|
10
15
|
{
|
|
11
16
|
target: '{{target}}',
|
|
12
17
|
name: '{{packageName}}',
|
|
13
|
-
url:
|
|
18
|
+
url: frontendUrls.{{target}} || 'http://127.0.0.1:{{port}}',
|
|
14
19
|
},
|
|
15
20
|
{{/each}}
|
|
16
21
|
]
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
{{#if hasI18n}}
|
|
15
15
|
import * as m from '$lib/paraglide/messages'
|
|
16
16
|
import { getLocale } from '$lib/paraglide/runtime.js'
|
|
17
|
-
import { BottomNav, setGlobalLocale } from '@h-ai/ui'
|
|
17
|
+
import { AppBar, BottomNav, setGlobalLocale } from '@h-ai/ui'
|
|
18
18
|
{{else}}
|
|
19
|
-
import { BottomNav } from '@h-ai/ui'
|
|
19
|
+
import { AppBar, BottomNav } from '@h-ai/ui'
|
|
20
20
|
{{/if}}
|
|
21
21
|
import '../app.css'
|
|
22
22
|
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
]
|
|
40
40
|
|
|
41
41
|
const activeNav = $derived(navItems.find(item => item.path === page.url.pathname)?.id)
|
|
42
|
-
//
|
|
42
|
+
// 所有页面共用页头页脚;仅业务页面显示底部导航。
|
|
43
43
|
const isAuthPage = $derived(page.url.pathname.startsWith('/auth'))
|
|
44
44
|
|
|
45
45
|
function handleNavigation(id: string) {
|
|
@@ -60,15 +60,15 @@
|
|
|
60
60
|
<title>{page.data.appConfig.name}</title>
|
|
61
61
|
</svelte:head>
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
<div class='hai-mobile-viewport'>
|
|
64
|
+
<div class='hai-mobile-shell max-w-lg'>
|
|
65
|
+
<AppBar title={page.data.appConfig.name} safeArea fixed={false} />
|
|
66
|
+
<main class='hai-mobile-main'>{@render children()}</main>
|
|
67
|
+
<footer class='border-t border-base-300 bg-base-100 p-2 text-center text-xs text-base-content/60'>
|
|
68
|
+
{page.data.appConfig.name} · {page.data.appConfig.version} · {page.data.appConfig.env}
|
|
69
|
+
</footer>
|
|
70
|
+
{#if !isAuthPage}
|
|
71
71
|
<BottomNav items={navItems} active={activeNav} onchange={handleNavigation} centered maxWidth='lg' safeArea />
|
|
72
|
-
|
|
72
|
+
{/if}
|
|
73
73
|
</div>
|
|
74
|
-
|
|
74
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
globalThis.__HAI_PUBLIC_CONFIG__ ??= {}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script lang='ts'>
|
|
2
2
|
{{#if features.iam}}
|
|
3
3
|
import type { IamPublicConfig } from '@h-ai/api-contract'
|
|
4
|
+
{{/if}}
|
|
5
|
+
import type { Component } from 'svelte'
|
|
6
|
+
{{#if features.iam}}
|
|
4
7
|
import { apiClient } from '@h-ai/api-client'
|
|
5
8
|
{{/if}}
|
|
6
9
|
import { applyTheme, getSavedTheme, setGlobalLocale } from '@h-ai/ui'
|
|
@@ -8,9 +11,11 @@
|
|
|
8
11
|
import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
|
|
9
12
|
{{/if}}
|
|
10
13
|
import { onMount } from 'svelte'
|
|
11
|
-
|
|
14
|
+
|
|
15
|
+
import { closeApi, initApi, usesNativeTokenStorage } from './lib/api.js'
|
|
12
16
|
import { appConfig, initClientCore } from './lib/app-config.js'
|
|
13
17
|
import { initCapacitor, isNativeApp } from './lib/capacitor.js'
|
|
18
|
+
import { HashRouter } from './lib/navigation/hash-router.svelte.js'
|
|
14
19
|
import * as m from './lib/paraglide/messages.js'
|
|
15
20
|
import { getLocale } from './lib/paraglide/runtime.js'
|
|
16
21
|
|
|
@@ -31,11 +36,12 @@
|
|
|
31
36
|
}
|
|
32
37
|
{{/if}}
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
const pages = import.meta.glob<{ default: Component<{ nativeRuntime: boolean, nativeTokenStorage: boolean }> }>('./lib/pages/**/*.svelte')
|
|
40
|
+
const router = new HashRouter(Object.keys(pages), '/home')
|
|
41
|
+
const pageComponent = $derived(pages[router.file]())
|
|
35
42
|
|
|
36
43
|
let booting = $state(true)
|
|
37
44
|
let bootError = $state<string | null>(null)
|
|
38
|
-
let activeTab = $state<TabId>('home')
|
|
39
45
|
let nativeRuntime = $state(false)
|
|
40
46
|
let nativeTokenStorage = $state(false)
|
|
41
47
|
|
|
@@ -74,136 +80,59 @@
|
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
/** 切换底部导航;例如 handleTabChange('tasks') 会写入 #/tasks。 */
|
|
77
84
|
function handleTabChange(id: string): void {
|
|
78
|
-
|
|
79
|
-
activeTab = id
|
|
85
|
+
router.navigate(`/${id}`)
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
onMount(() => {
|
|
89
|
+
const stopRouting = router.start()
|
|
83
90
|
{{#if features.iam}}
|
|
84
91
|
resetToken = new URL(location.href).searchParams.get('token') ?? ''
|
|
85
92
|
{{/if}}
|
|
86
93
|
void bootstrap()
|
|
87
94
|
|
|
88
95
|
return () => {
|
|
96
|
+
stopRouting()
|
|
89
97
|
void closeApi()
|
|
90
98
|
}
|
|
91
99
|
})
|
|
92
100
|
</script>
|
|
93
101
|
|
|
94
|
-
|
|
95
|
-
<div class='hai-mobile-
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
<span class='icon-[tabler--alert-triangle] text-error text-4xl'></span>
|
|
107
|
-
<h1 class='text-lg font-bold'>{m.boot_failed_title()}</h1>
|
|
108
|
-
<p class='text-base-content/60 text-sm'>{bootError}</p>
|
|
109
|
-
<Button variant='primary' size='sm' onclick={() => location.reload()}>{m.common_retry()}</Button>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
102
|
+
<div class='hai-mobile-viewport'>
|
|
103
|
+
<div class='hai-mobile-shell max-w-lg'>
|
|
104
|
+
<AppBar title={appConfig.name} safeArea fixed={false} class='bg-base-100/95 backdrop-blur border-base-content/8' />
|
|
105
|
+
<main class='hai-mobile-main'>
|
|
106
|
+
{#if booting}
|
|
107
|
+
<div class='grid min-h-64 place-items-center'><span class='loading loading-spinner loading-lg'></span></div>
|
|
108
|
+
{:else if bootError}
|
|
109
|
+
<section class='flex min-h-64 flex-col items-center justify-center gap-3 p-6 text-center'>
|
|
110
|
+
<h1 class='text-lg font-bold'>{m.boot_failed_title()}</h1>
|
|
111
|
+
<p class='text-base-content/60 text-sm'>{bootError}</p>
|
|
112
|
+
<Button variant='primary' size='sm' onclick={() => location.reload()}>{m.common_retry()}</Button>
|
|
113
|
+
</section>
|
|
113
114
|
{{#if features.iam}}
|
|
114
|
-
{:else if !authenticated}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
{:else if !authenticated}
|
|
116
|
+
<AuthPanel config={authConfig} actions={authActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} onauthenticated={handleAuthenticated} />
|
|
117
|
+
{{/if}}
|
|
118
|
+
{:else}
|
|
119
|
+
{#await pageComponent}
|
|
120
|
+
<div class='grid min-h-64 place-items-center'><span class='loading loading-spinner'></span></div>
|
|
121
|
+
{:then { default: Page }}
|
|
122
|
+
<Page {nativeRuntime} {nativeTokenStorage} />
|
|
123
|
+
{/await}
|
|
124
|
+
{/if}
|
|
125
|
+
</main>
|
|
126
|
+
<footer class='border-t border-base-content/8 bg-base-100 p-2 text-center text-xs text-base-content/50'>
|
|
127
|
+
{appConfig.name} · {appConfig.version} · {appConfig.env}
|
|
128
|
+
</footer>
|
|
129
|
+
{{#if features.iam}}
|
|
130
|
+
{#if authenticated && !booting && !bootError}
|
|
131
|
+
{{/if}}
|
|
132
|
+
<BottomNav items={navItems} active={router.path.slice(1)} onchange={handleTabChange} safeArea centered maxWidth='lg' />
|
|
133
|
+
{{#if features.iam}}
|
|
134
|
+
{/if}
|
|
118
135
|
{{/if}}
|
|
119
|
-
{:else}
|
|
120
|
-
<div class='hai-mobile-viewport'>
|
|
121
|
-
<div class='hai-mobile-shell max-w-lg'>
|
|
122
|
-
<AppBar title={appConfig.name} safeArea fixed={false} class='bg-base-100/95 backdrop-blur border-base-content/8' />
|
|
123
|
-
|
|
124
|
-
<main class='hai-mobile-main'>
|
|
125
|
-
{#if activeTab === 'home'}
|
|
126
|
-
<section class='space-y-4 p-4 pb-6'>
|
|
127
|
-
<div class='bg-primary text-primary-content rounded-2xl p-5 shadow-sm'>
|
|
128
|
-
<p class='text-sm opacity-75'>{m.hero_eyebrow()}</p>
|
|
129
|
-
<h1 class='mt-1 text-2xl font-bold'>{m.hero_title()}</h1>
|
|
130
|
-
<p class='mt-2 text-sm opacity-80'>{m.hero_subtitle()}</p>
|
|
131
|
-
</div>
|
|
132
|
-
|
|
133
|
-
<div class='grid grid-cols-2 gap-3'>
|
|
134
|
-
<Card padding='sm' shadow='sm'>
|
|
135
|
-
<div class='flex flex-col gap-2'>
|
|
136
|
-
<span class='icon-[tabler--device-mobile-code] text-primary text-2xl'></span>
|
|
137
|
-
<span class='text-sm font-medium'>{m.feature_native_title()}</span>
|
|
138
|
-
<span class='text-base-content/60 text-xs'>{m.feature_native_desc()}</span>
|
|
139
|
-
</div>
|
|
140
|
-
</Card>
|
|
141
|
-
<Card padding='sm' shadow='sm'>
|
|
142
|
-
<div class='flex flex-col gap-2'>
|
|
143
|
-
<span class='icon-[tabler--api-app] text-primary text-2xl'></span>
|
|
144
|
-
<span class='text-sm font-medium'>{m.feature_api_title()}</span>
|
|
145
|
-
<span class='text-base-content/60 text-xs'>{m.feature_api_desc()}</span>
|
|
146
|
-
</div>
|
|
147
|
-
</Card>
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
<Card title={m.status_title()} shadow='sm'>
|
|
151
|
-
<p class='mb-3 text-xs text-base-content/50'>{appConfig.name} · {appConfig.version} · {appConfig.env}</p>
|
|
152
|
-
<div class='grid grid-cols-2 gap-3 text-sm'>
|
|
153
|
-
<div>
|
|
154
|
-
<p class='text-base-content/50'>{m.status_api_label()}</p>
|
|
155
|
-
<p class='font-medium break-all'>{resolveApiBase()}</p>
|
|
156
|
-
</div>
|
|
157
|
-
<div>
|
|
158
|
-
<p class='text-base-content/50'>{m.status_runtime_label()}</p>
|
|
159
|
-
<Badge variant={nativeRuntime ? 'success' : 'warning'} size='sm'>
|
|
160
|
-
{nativeRuntime ? m.status_runtime_native() : m.status_runtime_web()}
|
|
161
|
-
</Badge>
|
|
162
|
-
</div>
|
|
163
|
-
<div class='col-span-2'>
|
|
164
|
-
<p class='text-base-content/50'>{m.status_token_label()}</p>
|
|
165
|
-
<p class='font-medium'>{nativeTokenStorage ? m.status_token_native() : m.status_token_memory()}</p>
|
|
166
|
-
</div>
|
|
167
|
-
</div>
|
|
168
|
-
</Card>
|
|
169
|
-
</section>
|
|
170
|
-
{:else if activeTab === 'tasks'}
|
|
171
|
-
<section class='space-y-4 p-4 pb-6'>
|
|
172
|
-
<Card title={m.tasks_title()} shadow='sm'>
|
|
173
|
-
<div class='space-y-3'>
|
|
174
|
-
{#each [m.tasks_sync(), m.tasks_polish(), m.tasks_release()] as task (task)}
|
|
175
|
-
<div class='bg-base-200 rounded-xl flex items-center gap-3 p-3'>
|
|
176
|
-
<span class='icon-[tabler--circle-check] text-success text-xl'></span>
|
|
177
|
-
<span class='text-sm font-medium'>{task}</span>
|
|
178
|
-
</div>
|
|
179
|
-
{/each}
|
|
180
|
-
</div>
|
|
181
|
-
</Card>
|
|
182
|
-
</section>
|
|
183
|
-
{:else}
|
|
184
|
-
<section class='space-y-4 p-4 pb-6'>
|
|
185
|
-
<Card title={m.profile_title()} shadow='sm'>
|
|
186
|
-
<div class='space-y-3 text-sm'>
|
|
187
|
-
<div class='bg-base-200 rounded-xl p-3'>{m.profile_language()}</div>
|
|
188
|
-
<div class='bg-base-200 rounded-xl p-3'>{m.profile_theme()}</div>
|
|
189
|
-
<div class='bg-base-200 rounded-xl p-3'>{m.profile_secure()}</div>
|
|
190
|
-
</div>
|
|
191
|
-
</Card>
|
|
192
|
-
</section>
|
|
193
|
-
{/if}
|
|
194
|
-
</main>
|
|
195
|
-
|
|
196
|
-
<BottomNav
|
|
197
|
-
items={navItems}
|
|
198
|
-
active={activeTab}
|
|
199
|
-
onchange={handleTabChange}
|
|
200
|
-
safeArea
|
|
201
|
-
centered
|
|
202
|
-
maxWidth='lg'
|
|
203
|
-
class='border-base-content/8 shadow-[0_-12px_32px_rgba(15,23,42,0.08)]'
|
|
204
|
-
/>
|
|
205
|
-
</div>
|
|
206
136
|
</div>
|
|
207
|
-
|
|
208
|
-
|
|
137
|
+
</div>
|
|
209
138
|
<ToastContainer />
|
|
@@ -10,7 +10,8 @@ const DEFAULT_API_BASE = 'http://localhost:3000/api/v1'
|
|
|
10
10
|
let initialized = false
|
|
11
11
|
|
|
12
12
|
export function resolveApiBase(): string {
|
|
13
|
-
|
|
13
|
+
const runtime = (globalThis as typeof globalThis & { __HAI_PUBLIC_CONFIG__?: { apiBase?: string } }).__HAI_PUBLIC_CONFIG__
|
|
14
|
+
return runtime?.apiBase || import.meta.env.PUBLIC_API_BASE || DEFAULT_API_BASE
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export async function usesNativeTokenStorage(): Promise<boolean> {
|
|
@@ -5,8 +5,9 @@ declare const __APP_CORE_CONFIG__: Pick<CoreConfig, 'name' | 'version' | 'env' |
|
|
|
5
5
|
logging?: Pick<NonNullable<CoreConfig['logging']>, 'level' | 'format'>
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/** 构建包含公开默认值;预览或容器启动时的公开配置覆盖默认值,不重建静态资源。 */
|
|
9
|
+
const runtime = (globalThis as typeof globalThis & { __HAI_PUBLIC_CONFIG__?: { core?: Partial<typeof __APP_CORE_CONFIG__> } }).__HAI_PUBLIC_CONFIG__?.core
|
|
10
|
+
export const appConfig = { ...__APP_CORE_CONFIG__, ...runtime, logging: { ...__APP_CORE_CONFIG__.logging, ...runtime?.logging } }
|
|
10
11
|
|
|
11
12
|
export function initClientCore(): void {
|
|
12
13
|
core.init({ logging: appConfig.logging })
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script lang='ts'>
|
|
2
|
+
import { resolveApiBase } from '../api.js'
|
|
3
|
+
import { appConfig } from '../app-config.js'
|
|
4
|
+
import * as m from '../paraglide/messages.js'
|
|
5
|
+
/** 首页展示的平台运行状态;例如 { nativeRuntime: true, nativeTokenStorage: true }。 */
|
|
6
|
+
interface Props {
|
|
7
|
+
/** 是否运行于原生壳。 */
|
|
8
|
+
nativeRuntime: boolean
|
|
9
|
+
/** 是否使用原生安全凭据存储。 */
|
|
10
|
+
nativeTokenStorage: boolean
|
|
11
|
+
}
|
|
12
|
+
const { nativeRuntime, nativeTokenStorage }: Props = $props()
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<section class='space-y-4 p-4 pb-6'>
|
|
16
|
+
<div class='bg-primary text-primary-content rounded-2xl p-5 shadow-sm'>
|
|
17
|
+
<p class='text-sm opacity-75'>{m.hero_eyebrow()}</p>
|
|
18
|
+
<h1 class='mt-1 text-2xl font-bold'>{m.hero_title()}</h1>
|
|
19
|
+
<p class='mt-2 text-sm opacity-80'>{m.hero_subtitle()}</p>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class='grid grid-cols-2 gap-3'>
|
|
23
|
+
<Card padding='sm' shadow='sm'>
|
|
24
|
+
<div class='flex flex-col gap-2'>
|
|
25
|
+
<span class='icon-[tabler--device-mobile-code] text-primary text-2xl'></span>
|
|
26
|
+
<span class='text-sm font-medium'>{m.feature_native_title()}</span>
|
|
27
|
+
<span class='text-base-content/60 text-xs'>{m.feature_native_desc()}</span>
|
|
28
|
+
</div>
|
|
29
|
+
</Card>
|
|
30
|
+
<Card padding='sm' shadow='sm'>
|
|
31
|
+
<div class='flex flex-col gap-2'>
|
|
32
|
+
<span class='icon-[tabler--api-app] text-primary text-2xl'></span>
|
|
33
|
+
<span class='text-sm font-medium'>{m.feature_api_title()}</span>
|
|
34
|
+
<span class='text-base-content/60 text-xs'>{m.feature_api_desc()}</span>
|
|
35
|
+
</div>
|
|
36
|
+
</Card>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<Card title={m.status_title()} shadow='sm'>
|
|
40
|
+
<p class='mb-3 text-xs text-base-content/50'>{appConfig.name} · {appConfig.version} · {appConfig.env}</p>
|
|
41
|
+
<div class='grid grid-cols-2 gap-3 text-sm'>
|
|
42
|
+
<div>
|
|
43
|
+
<p class='text-base-content/50'>{m.status_api_label()}</p>
|
|
44
|
+
<p class='font-medium break-all'>{resolveApiBase()}</p>
|
|
45
|
+
</div>
|
|
46
|
+
<div>
|
|
47
|
+
<p class='text-base-content/50'>{m.status_runtime_label()}</p>
|
|
48
|
+
<Badge variant={nativeRuntime ? 'success' : 'warning'} size='sm'>
|
|
49
|
+
{nativeRuntime ? m.status_runtime_native() : m.status_runtime_web()}
|
|
50
|
+
</Badge>
|
|
51
|
+
</div>
|
|
52
|
+
<div class='col-span-2'>
|
|
53
|
+
<p class='text-base-content/50'>{m.status_token_label()}</p>
|
|
54
|
+
<p class='font-medium'>{nativeTokenStorage ? m.status_token_native() : m.status_token_memory()}</p>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</Card>
|
|
58
|
+
</section>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang='ts'>
|
|
2
|
+
import * as m from '../paraglide/messages.js'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<section class='space-y-4 p-4 pb-6'>
|
|
6
|
+
<Card title={m.profile_title()} shadow='sm'>
|
|
7
|
+
<div class='space-y-3 text-sm'>
|
|
8
|
+
<div class='bg-base-200 rounded-xl p-3'>{m.profile_language()}</div>
|
|
9
|
+
<div class='bg-base-200 rounded-xl p-3'>{m.profile_theme()}</div>
|
|
10
|
+
<div class='bg-base-200 rounded-xl p-3'>{m.profile_secure()}</div>
|
|
11
|
+
</div>
|
|
12
|
+
</Card>
|
|
13
|
+
</section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang='ts'>
|
|
2
|
+
import * as m from '../paraglide/messages.js'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<section class='space-y-4 p-4 pb-6'>
|
|
6
|
+
<Card title={m.tasks_title()} shadow='sm'>
|
|
7
|
+
<div class='space-y-3'>
|
|
8
|
+
{#each [m.tasks_sync(), m.tasks_polish(), m.tasks_release()] as task (task)}
|
|
9
|
+
<div class='bg-base-200 rounded-xl flex items-center gap-3 p-3'>
|
|
10
|
+
<span class='icon-[tabler--circle-check] text-success text-xl'></span>
|
|
11
|
+
<span class='text-sm font-medium'>{task}</span>
|
|
12
|
+
</div>
|
|
13
|
+
{/each}
|
|
14
|
+
</div>
|
|
15
|
+
</Card>
|
|
16
|
+
</section>
|
|
@@ -1,19 +1,34 @@
|
|
|
1
|
+
# 构建产物目录
|
|
1
2
|
node_modules
|
|
2
3
|
{{#unless isSvelteOnlyApp}}
|
|
3
4
|
.svelte-kit
|
|
4
5
|
{{/unless}}
|
|
5
6
|
build
|
|
6
7
|
dist
|
|
8
|
+
.turbo
|
|
7
9
|
android
|
|
8
10
|
ios
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
**/src/lib/paraglide
|
|
12
|
+
**/src-tauri/gen/
|
|
13
|
+
**/src-tauri/target/
|
|
14
|
+
**/hash-core/target/
|
|
15
|
+
|
|
16
|
+
# 日志测试目录
|
|
12
17
|
*.log
|
|
13
|
-
.DS_Store
|
|
14
18
|
test-results/
|
|
15
19
|
playwright-report/
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# 系统文件
|
|
23
|
+
.DS_Store
|
|
24
|
+
|
|
25
|
+
# 本地数据目录
|
|
16
26
|
**/data/
|
|
17
27
|
|
|
18
|
-
#
|
|
28
|
+
# 构建与部署产物(镜像 / Compose tar / 私有运行配置)
|
|
19
29
|
.hai/
|
|
30
|
+
*.tar
|
|
31
|
+
|
|
32
|
+
# 本机私有配置;公共 .env 与 dev/test/prod 环境文件纳入版本控制。
|
|
33
|
+
.env.local
|
|
34
|
+
.env.*.local
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1.7
|
|
2
2
|
{{#if isSvelteOnlyApp}}
|
|
3
3
|
FROM node:24-bookworm-slim AS build
|
|
4
|
+
ARG SQLITE_PREBUILD_MIRROR
|
|
5
|
+
ENV npm_config_better_sqlite3_binary_host_mirror=$SQLITE_PREBUILD_MIRROR
|
|
4
6
|
|
|
5
7
|
ENV PNPM_HOME=/pnpm
|
|
6
8
|
ENV PATH=$PNPM_HOME:$PATH
|
|
@@ -8,14 +10,14 @@ ENV CI=true
|
|
|
8
10
|
WORKDIR /workspace
|
|
9
11
|
|
|
10
12
|
RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
|
|
11
|
-
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
12
|
-
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
|
|
13
13
|
COPY . .
|
|
14
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
|
|
14
15
|
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
15
|
-
RUN pnpm
|
|
16
|
+
RUN pnpm compile
|
|
16
17
|
|
|
17
18
|
FROM nginxinc/nginx-unprivileged:1.29-alpine AS runtime
|
|
18
19
|
|
|
20
|
+
COPY --chmod=755 docker/19-runtime-config.sh /docker-entrypoint.d/19-hai-runtime-config.sh
|
|
19
21
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
|
20
22
|
COPY --from=build /workspace/dist /usr/share/nginx/html
|
|
21
23
|
|
|
@@ -24,6 +26,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
|
24
26
|
CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
|
|
25
27
|
{{else}}
|
|
26
28
|
FROM node:24-bookworm-slim AS build
|
|
29
|
+
ARG SQLITE_PREBUILD_MIRROR
|
|
30
|
+
ENV npm_config_better_sqlite3_binary_host_mirror=$SQLITE_PREBUILD_MIRROR
|
|
27
31
|
|
|
28
32
|
ENV PNPM_HOME=/pnpm
|
|
29
33
|
ENV PATH=$PNPM_HOME:$PATH
|
|
@@ -31,17 +35,16 @@ ENV CI=true
|
|
|
31
35
|
WORKDIR /workspace
|
|
32
36
|
|
|
33
37
|
RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
|
|
34
|
-
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
35
|
-
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
|
|
36
38
|
COPY . .
|
|
39
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch
|
|
37
40
|
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
38
|
-
RUN pnpm
|
|
41
|
+
RUN pnpm compile
|
|
42
|
+
RUN if [ -d node_modules/better-sqlite3 ]; then cd node_modules/better-sqlite3; for attempt in 1 2 3; do node -e "require('node:child_process').execFileSync(process.execPath,[require.resolve('prebuild-install/bin.js'),'--verbose'],{stdio:'inherit'})" && break; [ "$attempt" = 3 ] && exit 1; sleep 3; done; fi
|
|
39
43
|
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm pm deploy --filter {{projectName}} --prod /prod/app
|
|
40
44
|
|
|
41
45
|
FROM node:24-bookworm-slim AS runtime-base
|
|
42
46
|
|
|
43
47
|
ENV NODE_ENV=production
|
|
44
|
-
ENV HAI_CORE_ENV=production
|
|
45
48
|
ENV HOST=0.0.0.0
|
|
46
49
|
ENV PORT=3000
|
|
47
50
|
WORKDIR /app
|
|
@@ -55,9 +58,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
|
55
58
|
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
56
59
|
CMD ["node", "build"]
|
|
57
60
|
|
|
58
|
-
FROM runtime-base AS runtime-with-data
|
|
59
|
-
|
|
60
|
-
COPY --from=embedded-data --chown=node:node / /app/data/
|
|
61
|
-
|
|
62
61
|
FROM runtime-base AS runtime
|
|
63
62
|
{{/if}}
|
|
@@ -9,6 +9,12 @@ server {
|
|
|
9
9
|
try_files $uri $uri/ /index.html;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
location = /runtime-config.js {
|
|
13
|
+
alias /tmp/hai-runtime-config/runtime-config.js;
|
|
14
|
+
add_header Cache-Control "no-store";
|
|
15
|
+
default_type application/javascript;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
location = /health {
|
|
13
19
|
access_log off;
|
|
14
20
|
add_header Content-Type text/plain;
|
|
@@ -21,14 +21,26 @@ test('home page renders from browser', async ({ page }) => {
|
|
|
21
21
|
await expect(page).toHaveTitle(process.env.HAI_CORE_NAME ?? '{{projectName}}')
|
|
22
22
|
await expect(pageErrors).toEqual([])
|
|
23
23
|
await expect(page.locator('main, section').first()).toBeVisible()
|
|
24
|
+
await expect(page.locator('header').first()).toBeVisible()
|
|
25
|
+
await expect(page.locator('footer').first()).toBeAttached()
|
|
24
26
|
{{#if_eq appType 'h5'}}
|
|
25
27
|
// 在手机宽度验证导航真正切换路由,避免样式失效但首页渲染测试仍通过。
|
|
26
28
|
const nav = page.locator('nav')
|
|
27
29
|
await nav.getByRole('button', { name: /发现|Discover/i, exact: true }).click()
|
|
28
30
|
await expect(page).toHaveURL(/\/discover$/)
|
|
31
|
+
await page.reload()
|
|
32
|
+
await expect(page).toHaveURL(/\/discover$/)
|
|
33
|
+
await expect(page.getByRole('heading', { name: /发现|Discover/i })).toBeVisible()
|
|
29
34
|
await nav.getByRole('button', { name: /首页|Home/i, exact: true }).click()
|
|
30
35
|
await expect(page).toHaveURL(/\/$/)
|
|
31
36
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
|
37
|
+
{{/if_eq}}
|
|
38
|
+
{{#if_eq appType 'website'}}
|
|
39
|
+
await page.goto('/about')
|
|
40
|
+
await page.reload()
|
|
41
|
+
await expect(page).toHaveURL(/\/about$/)
|
|
42
|
+
await expect(page.locator('header')).toBeVisible()
|
|
43
|
+
await expect(page.locator('footer')).toBeVisible()
|
|
32
44
|
{{/if_eq}}
|
|
33
45
|
const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
|
|
34
46
|
await mkdir(evidence, { recursive: true })
|
|
@@ -50,6 +62,8 @@ test('configured IAM registration and password login', async ({ page, context })
|
|
|
50
62
|
const authUrl = '{{#if isCapacitorApp}}/{{else}}/auth/login{{/if}}'
|
|
51
63
|
await page.goto(authUrl)
|
|
52
64
|
await expect(page.locator('#login-username')).toBeVisible()
|
|
65
|
+
await expect(page.locator('header').first()).toBeVisible()
|
|
66
|
+
await expect(page.locator('footer')).toBeVisible()
|
|
53
67
|
await page.screenshot({ path: path.join(evidence, '{{projectName}}-login.png'), fullPage: true })
|
|
54
68
|
await page.getByRole('{{#if isCapacitorApp}}button{{else}}link{{/if}}', { name: /立即注册|Register now/i }).click()
|
|
55
69
|
await expect(page.locator('input[name="username"]')).toBeVisible()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "images": [{ "name": "app", "target": "runtime", "containerPort": {{#if isSvelteOnlyApp}}8080{{else}}3000{{/if}} }] }
|