@cat-factory/app 0.317.1 → 0.318.0
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/app/components/auth/LoginScreen.vue +15 -16
- package/app/components/github/AddServiceFromRepoModal.vue +16 -1
- package/app/components/settings/ApiTokensPanel.vue +11 -0
- package/app/composables/useAddRepoDeepLink.ts +1 -1
- package/app/composables/useNewApiKeyDeepLink.spec.ts +53 -0
- package/app/composables/useNewApiKeyDeepLink.ts +87 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/ui/modals.ts +7 -1
- package/app/utils/postSignIn.spec.ts +28 -1
- package/app/utils/postSignIn.ts +20 -0
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
|
|
|
5
5
|
import type { VcsProvider } from '~/types/domain'
|
|
6
6
|
import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
|
|
7
7
|
import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
|
|
8
|
-
import {
|
|
8
|
+
import { signInThenReload } from '~/utils/postSignIn'
|
|
9
9
|
|
|
10
10
|
const auth = useAuthStore()
|
|
11
11
|
const { t } = useI18n()
|
|
@@ -62,8 +62,7 @@ async function submitPat(provider: PatProvider) {
|
|
|
62
62
|
patError.value = null
|
|
63
63
|
patBusy.value = true
|
|
64
64
|
try {
|
|
65
|
-
await auth.patLogin({ provider })
|
|
66
|
-
if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
|
|
65
|
+
await signInThenReload(() => auth.patLogin({ provider }))
|
|
67
66
|
} catch (e) {
|
|
68
67
|
patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
|
|
69
68
|
} finally {
|
|
@@ -95,18 +94,17 @@ async function submitPassword() {
|
|
|
95
94
|
error.value = null
|
|
96
95
|
busy.value = true
|
|
97
96
|
try {
|
|
98
|
-
if (mode.value === 'signup') {
|
|
99
|
-
await auth.signup({
|
|
100
|
-
email: email.value,
|
|
101
|
-
password: password.value,
|
|
102
|
-
name: name.value || undefined,
|
|
103
|
-
invite: invite.value,
|
|
104
|
-
})
|
|
105
|
-
} else {
|
|
106
|
-
await auth.passwordLogin({ email: email.value, password: password.value })
|
|
107
|
-
}
|
|
108
97
|
// Reload so the app boots with the new session.
|
|
109
|
-
|
|
98
|
+
await signInThenReload(() =>
|
|
99
|
+
mode.value === 'signup'
|
|
100
|
+
? auth.signup({
|
|
101
|
+
email: email.value,
|
|
102
|
+
password: password.value,
|
|
103
|
+
name: name.value || undefined,
|
|
104
|
+
invite: invite.value,
|
|
105
|
+
})
|
|
106
|
+
: auth.passwordLogin({ email: email.value, password: password.value }),
|
|
107
|
+
)
|
|
110
108
|
} catch (e) {
|
|
111
109
|
error.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
|
|
112
110
|
} finally {
|
|
@@ -177,8 +175,9 @@ async function submitRemotePat() {
|
|
|
177
175
|
remotePatError.value = null
|
|
178
176
|
remotePatBusy.value = true
|
|
179
177
|
try {
|
|
180
|
-
await
|
|
181
|
-
|
|
178
|
+
await signInThenReload(() =>
|
|
179
|
+
auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() }),
|
|
180
|
+
)
|
|
182
181
|
} catch (e) {
|
|
183
182
|
remotePatError.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
|
|
184
183
|
} finally {
|
|
@@ -306,11 +306,25 @@ watch(
|
|
|
306
306
|
if (!isOpen) return
|
|
307
307
|
resetSelection()
|
|
308
308
|
void loadRepos()
|
|
309
|
-
if (ui.addServicePrefill) repoSearch.value = ui.addServicePrefill.repo
|
|
310
309
|
},
|
|
311
310
|
{ immediate: true },
|
|
312
311
|
)
|
|
313
312
|
|
|
313
|
+
// A deep link's repository is typed into the search once the picker's menu has MOUNTED, and after
|
|
314
|
+
// the tick it mounted in: Nuxt UI's InputMenu clears its own search term on that tick, so a term set
|
|
315
|
+
// on open is wiped (the picker mounts with the window, or later behind the connect prompt). The
|
|
316
|
+
// prefill is spent once its repository is selected below, so nothing is typed after that.
|
|
317
|
+
const repoMenu = ref<unknown>(null)
|
|
318
|
+
watch(
|
|
319
|
+
repoMenu,
|
|
320
|
+
async (menu) => {
|
|
321
|
+
if (menu === null || !ui.addServicePrefill) return
|
|
322
|
+
await nextTick()
|
|
323
|
+
if (ui.addServicePrefill) repoSearch.value = ui.addServicePrefill.repo
|
|
324
|
+
},
|
|
325
|
+
{ flush: 'post' },
|
|
326
|
+
)
|
|
327
|
+
|
|
314
328
|
// A deep link names one repository: select it as soon as the search lists it, once. A repository
|
|
315
329
|
// the connection cannot reach never lists, which leaves the search's own empty state (and its
|
|
316
330
|
// grant-access remedy) in front of the person.
|
|
@@ -497,6 +511,7 @@ function done() {
|
|
|
497
511
|
to forward the attribute to. -->
|
|
498
512
|
<div class="space-y-1.5" data-testid="add-service-repo-search">
|
|
499
513
|
<UInputMenu
|
|
514
|
+
ref="repoMenu"
|
|
500
515
|
v-model="selectedRepoId"
|
|
501
516
|
v-model:search-term="repoSearch"
|
|
502
517
|
:items="repoMenuItems"
|
|
@@ -172,6 +172,7 @@ watch(
|
|
|
172
172
|
newSecret.value = null
|
|
173
173
|
return
|
|
174
174
|
}
|
|
175
|
+
applyPrefill()
|
|
175
176
|
try {
|
|
176
177
|
await store.ensureLoaded()
|
|
177
178
|
} catch (e) {
|
|
@@ -181,6 +182,16 @@ watch(
|
|
|
181
182
|
{ immediate: true },
|
|
182
183
|
)
|
|
183
184
|
|
|
185
|
+
/** Fill the create form from a new-API-key deep link (`useNewApiKeyDeepLink`), once. */
|
|
186
|
+
function applyPrefill() {
|
|
187
|
+
const prefill = ui.apiTokensPrefill
|
|
188
|
+
if (!prefill) return
|
|
189
|
+
if (prefill.label) label.value = prefill.label
|
|
190
|
+
if (prefill.scope) scope.value = prefill.scope
|
|
191
|
+
if (prefill.runsAsSelf && canBindSelf.value) identity.value = 'self'
|
|
192
|
+
ui.apiTokensPrefill = null
|
|
193
|
+
}
|
|
194
|
+
|
|
184
195
|
async function createToken() {
|
|
185
196
|
const trimmed = label.value.trim()
|
|
186
197
|
if (!trimmed) return
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* /api/v1/deployment`; the format is published at
|
|
11
11
|
* https://catfactory.ai/extend/public-api.html#sending-a-person-to-add-a-repository
|
|
12
12
|
*
|
|
13
|
-
* Signing in first keeps the query (`
|
|
13
|
+
* Signing in first keeps the query (`signInThenReload`), so a signed-out person lands here too.
|
|
14
14
|
*/
|
|
15
15
|
import { watch } from 'vue'
|
|
16
16
|
import type { GitHubAvailableRepo } from '~/types/domain'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { captureNewApiKeyDeepLink } from './useNewApiKeyDeepLink'
|
|
4
|
+
|
|
5
|
+
function setUrl(search: string): void {
|
|
6
|
+
history.replaceState(null, '', `/${search}`)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('captureNewApiKeyDeepLink', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
setActivePinia(createPinia())
|
|
12
|
+
setUrl('')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('parses the link an integration builds, and strips only its own params', () => {
|
|
16
|
+
setUrl('?newApiKey=1&label=sainte-beuve&scope=decide&runsAsSelf=1&ws=ws_1&keep=yes')
|
|
17
|
+
expect(captureNewApiKeyDeepLink()).toEqual({
|
|
18
|
+
label: 'sainte-beuve',
|
|
19
|
+
scope: 'decide',
|
|
20
|
+
runsAsSelf: true,
|
|
21
|
+
workspaceId: 'ws_1',
|
|
22
|
+
})
|
|
23
|
+
expect(window.location.search).toBe('?keep=yes')
|
|
24
|
+
expect(captureNewApiKeyDeepLink()).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('opens the form at its defaults when the link names nothing else', () => {
|
|
28
|
+
setUrl('?newApiKey=1')
|
|
29
|
+
expect(captureNewApiKeyDeepLink()).toEqual({
|
|
30
|
+
label: null,
|
|
31
|
+
scope: null,
|
|
32
|
+
runsAsSelf: false,
|
|
33
|
+
workspaceId: null,
|
|
34
|
+
})
|
|
35
|
+
expect(window.location.search).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('drops a scope this build does not know rather than guessing one', () => {
|
|
39
|
+
setUrl('?newApiKey=1&scope=superuser')
|
|
40
|
+
expect(captureNewApiKeyDeepLink()).toMatchObject({ scope: null })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('cuts a label to what the form accepts', () => {
|
|
44
|
+
setUrl(`?newApiKey=1&label=${'x'.repeat(200)}`)
|
|
45
|
+
expect(captureNewApiKeyDeepLink()?.label).toHaveLength(120)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('ignores a URL without the link, and leaves its query alone', () => {
|
|
49
|
+
setUrl('?label=other&scope=admin')
|
|
50
|
+
expect(captureNewApiKeyDeepLink()).toBeNull()
|
|
51
|
+
expect(window.location.search).toBe('?label=other&scope=admin')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time replay of a NEW API KEY deep link:
|
|
3
|
+
* `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&ws=<id>`.
|
|
4
|
+
*
|
|
5
|
+
* An integration that needs a person's OWN key (one bound to them, which is what lets a run use
|
|
6
|
+
* their personal model subscription) cannot mint it for them: a bound key can only be minted by
|
|
7
|
+
* its owner, signed in. It sends the person here instead, and the API tokens window opens with the
|
|
8
|
+
* create form filled in. Nothing is minted until the person clicks create.
|
|
9
|
+
*
|
|
10
|
+
* Every parameter but `newApiKey` is optional. A scope this build does not know is dropped rather
|
|
11
|
+
* than guessed at, and `runsAsSelf` only takes effect where there is a signed-in person to bind
|
|
12
|
+
* (`ApiTokensPanel` withholds the choice otherwise). Signing in first keeps the query
|
|
13
|
+
* (`postSignInUrl`). The format is published next to `?addRepo=` in the public API docs.
|
|
14
|
+
*/
|
|
15
|
+
import { PUBLIC_API_SCOPES } from '@cat-factory/contracts'
|
|
16
|
+
import { watch } from 'vue'
|
|
17
|
+
import type { PublicApiScope } from '~/types/publicApiKeys'
|
|
18
|
+
import { useUiStore } from '../stores/ui'
|
|
19
|
+
import { useWorkspaceStore } from '../stores/workspace'
|
|
20
|
+
|
|
21
|
+
/** What the API tokens window's create form is opened with. Absent fields keep their defaults. */
|
|
22
|
+
export interface NewApiKeyPrefill {
|
|
23
|
+
label: string | null
|
|
24
|
+
scope: PublicApiScope | null
|
|
25
|
+
runsAsSelf: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PendingNewApiKeyLink extends NewApiKeyPrefill {
|
|
29
|
+
workspaceId: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NEW_API_KEY_LINK_PARAMS = ['newApiKey', 'label', 'scope', 'runsAsSelf', 'ws'] as const
|
|
33
|
+
|
|
34
|
+
/** The form's own label limit, so a prefill never starts out invalid. */
|
|
35
|
+
const LABEL_MAX_LENGTH = 120
|
|
36
|
+
|
|
37
|
+
function scopeOf(value: string | null): PublicApiScope | null {
|
|
38
|
+
return (PUBLIC_API_SCOPES as readonly string[]).includes(value ?? '')
|
|
39
|
+
? (value as PublicApiScope)
|
|
40
|
+
: null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read (and strip) a new-API-key link from the current URL, or null when there is none. Called
|
|
45
|
+
* before `workspace.init()` so `ws` can pin the board.
|
|
46
|
+
*/
|
|
47
|
+
export function captureNewApiKeyDeepLink(): PendingNewApiKeyLink | null {
|
|
48
|
+
if (typeof window === 'undefined') return null
|
|
49
|
+
const params = new URLSearchParams(window.location.search)
|
|
50
|
+
if (params.get('newApiKey') !== '1') return null
|
|
51
|
+
const label = params.get('label')?.trim().slice(0, LABEL_MAX_LENGTH) || null
|
|
52
|
+
const link: PendingNewApiKeyLink = {
|
|
53
|
+
label,
|
|
54
|
+
scope: scopeOf(params.get('scope')),
|
|
55
|
+
runsAsSelf: params.get('runsAsSelf') === '1',
|
|
56
|
+
workspaceId: params.get('ws'),
|
|
57
|
+
}
|
|
58
|
+
for (const key of NEW_API_KEY_LINK_PARAMS) params.delete(key)
|
|
59
|
+
const qs = params.toString()
|
|
60
|
+
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
|
61
|
+
return link
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Capture a new-API-key link and, once the board has hydrated, open the window with it. */
|
|
65
|
+
export function useNewApiKeyDeepLink(): void {
|
|
66
|
+
const link = captureNewApiKeyDeepLink()
|
|
67
|
+
if (!link) return
|
|
68
|
+
|
|
69
|
+
const workspace = useWorkspaceStore()
|
|
70
|
+
const ui = useUiStore()
|
|
71
|
+
if (link.workspaceId) workspace.workspaceId = link.workspaceId
|
|
72
|
+
|
|
73
|
+
// One-shot, as `useAddRepoDeepLink` is: a later refresh must not reopen a window the person
|
|
74
|
+
// closed, and `immediate` runs the callback before `stop` is bound.
|
|
75
|
+
let applied = false
|
|
76
|
+
let stop: (() => void) | undefined
|
|
77
|
+
stop = watch(
|
|
78
|
+
() => workspace.ready,
|
|
79
|
+
(ready) => {
|
|
80
|
+
if (!ready || applied) return
|
|
81
|
+
applied = true
|
|
82
|
+
ui.openApiTokens({ label: link.label, scope: link.scope, runsAsSelf: link.runsAsSelf })
|
|
83
|
+
stop?.()
|
|
84
|
+
},
|
|
85
|
+
{ immediate: true },
|
|
86
|
+
)
|
|
87
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -185,6 +185,8 @@ onMounted(() => {
|
|
|
185
185
|
useRunDeepLink()
|
|
186
186
|
// `?addRepo=<owner>/<name>`: an integration sending a person to add a service for a repository.
|
|
187
187
|
useAddRepoDeepLink()
|
|
188
|
+
// `?newApiKey=1`: an integration sending a person to mint a key bound to them.
|
|
189
|
+
useNewApiKeyDeepLink()
|
|
188
190
|
// `?view=queue` / `?view=board`: a link that opens one of the board's two views.
|
|
189
191
|
views.consumeDeepLink()
|
|
190
192
|
void workspace.init()
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ProviderConnectionKind,
|
|
12
12
|
} from '~/types/providerConnections'
|
|
13
13
|
import type { AddServicePrefill } from '~/composables/useAddRepoDeepLink'
|
|
14
|
+
import type { NewApiKeyPrefill } from '~/composables/useNewApiKeyDeepLink'
|
|
14
15
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
15
16
|
import {
|
|
16
17
|
infraSetupDismissalKey,
|
|
@@ -667,6 +668,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
667
668
|
// API access tokens: the workspace's inbound public-API keys external systems present to
|
|
668
669
|
// the `/api/v1` surface. Opened from the Integrations hub.
|
|
669
670
|
const apiTokensOpen = ref(false)
|
|
671
|
+
// The create form a new-API-key deep link opened the window with, consumed on open.
|
|
672
|
+
const apiTokensPrefill = ref<NewApiKeyPrefill | null>(null)
|
|
670
673
|
// Opened and closed by `createBuilderModelConfigHandoff`, which hands the pipeline builder over.
|
|
671
674
|
const modelConfigOpen = ref(false)
|
|
672
675
|
// LLM-vendor subscription credentials (the token pool powering the Claude Code
|
|
@@ -722,12 +725,14 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
722
725
|
function closeReports() {
|
|
723
726
|
reportsOpen.value = false
|
|
724
727
|
}
|
|
725
|
-
function openApiTokens() {
|
|
728
|
+
function openApiTokens(prefill: NewApiKeyPrefill | null = null) {
|
|
726
729
|
resetHubReturn()
|
|
730
|
+
apiTokensPrefill.value = prefill
|
|
727
731
|
apiTokensOpen.value = true
|
|
728
732
|
}
|
|
729
733
|
function closeApiTokens() {
|
|
730
734
|
apiTokensOpen.value = false
|
|
735
|
+
apiTokensPrefill.value = null
|
|
731
736
|
}
|
|
732
737
|
function openVendorCredentials(tab = 'pool') {
|
|
733
738
|
resetHubReturn()
|
|
@@ -770,6 +775,7 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
770
775
|
operatorDashboardOpen,
|
|
771
776
|
reportsOpen,
|
|
772
777
|
apiTokensOpen,
|
|
778
|
+
apiTokensPrefill,
|
|
773
779
|
modelConfigOpen,
|
|
774
780
|
vendorCredentialsOpen,
|
|
775
781
|
vendorCredentialsTab,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { postSignInUrl } from './postSignIn'
|
|
2
|
+
import { postSignInUrl, signInThenReload } from './postSignIn'
|
|
3
3
|
|
|
4
4
|
// The regression this exists for: every sign-in path reloaded to `location.pathname`, so a flow
|
|
5
5
|
// whose subject rides the query string lost it the moment a person signed in. The MCP consent
|
|
@@ -27,3 +27,30 @@ describe('postSignInUrl', () => {
|
|
|
27
27
|
expect(postSignInUrl({ pathname: '/boards', search: '?a=1' })).toBe('/boards?a=1')
|
|
28
28
|
})
|
|
29
29
|
})
|
|
30
|
+
|
|
31
|
+
describe('signInThenReload', () => {
|
|
32
|
+
function location(search: string) {
|
|
33
|
+
const assigned: string[] = []
|
|
34
|
+
const at = { pathname: '/', search, assign: (url: string) => void assigned.push(url) }
|
|
35
|
+
return { at, assigned }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('reloads to the URL as it was BEFORE the sign-in, query included', async () => {
|
|
39
|
+
// What the board does as the session lands: it consumes `?addRepo=` and strips it from the URL.
|
|
40
|
+
const { at, assigned } = location('?addRepo=octo%2Fdemo&provider=github')
|
|
41
|
+
await signInThenReload(async () => {
|
|
42
|
+
at.search = ''
|
|
43
|
+
}, at)
|
|
44
|
+
expect(assigned).toEqual(['/?addRepo=octo%2Fdemo&provider=github'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not reload when the sign-in is refused', async () => {
|
|
48
|
+
const { at, assigned } = location('?addRepo=octo%2Fdemo')
|
|
49
|
+
await expect(
|
|
50
|
+
signInThenReload(async () => {
|
|
51
|
+
throw new Error('refused')
|
|
52
|
+
}, at),
|
|
53
|
+
).rejects.toThrow('refused')
|
|
54
|
+
expect(assigned).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
})
|
package/app/utils/postSignIn.ts
CHANGED
|
@@ -27,3 +27,23 @@ export function postSignInUrl(location: { pathname: string; search: string }): s
|
|
|
27
27
|
const query = params.toString()
|
|
28
28
|
return query ? `${location.pathname}?${query}` : location.pathname
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** The part of `window.location` a sign-in reads and reloads through. */
|
|
32
|
+
type SignInLocation = Pick<Location, 'pathname' | 'search' | 'assign'>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run a sign-in, then reload to where the person was.
|
|
36
|
+
*
|
|
37
|
+
* The destination is read BEFORE the call. A sign-in that succeeds mounts the board in place, and
|
|
38
|
+
* the board's deep-link handlers strip the query they act on (`?addRepo=` among them) as they
|
|
39
|
+
* consume it, so a destination read after the call is the stripped URL and the reload lands on a
|
|
40
|
+
* board that no longer knows what the link asked for.
|
|
41
|
+
*/
|
|
42
|
+
export async function signInThenReload(
|
|
43
|
+
signIn: () => Promise<unknown>,
|
|
44
|
+
location: SignInLocation | null = typeof window === 'undefined' ? null : window.location,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const destination = location === null ? null : postSignInUrl(location)
|
|
47
|
+
await signIn()
|
|
48
|
+
if (location !== null && destination !== null) location.assign(destination)
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.318.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|