@frontera-sdk/cli 1.52.43 → 1.52.45
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/package.json +4 -4
- package/src/commands/kit/vendor.ts +62 -48
- package/src/commands/local/check.ts +17 -9
- package/src/commands/local/index-commands.ts +7 -1
- package/src/commands/local/init.ts +22 -2
- package/src/commands/local/snapshot.ts +8 -2
- package/src/commands/local/sync-request.ts +95 -9
- package/src/commands/local/sync.ts +27 -6
- package/src/commands/registry.ts +2 -1
- package/src/flag-help.ts +1 -0
- package/src/local/local-case-evidence.ts +3 -2
- package/src/local/local-db-snapshot.ts +23 -3
- package/src/local/local-dev-references.ts +59 -7
- package/src/local/local-env.ts +21 -0
- package/src/local/local-history-carry.ts +103 -0
- package/src/local/local-key-custodian.ts +64 -0
- package/src/local/local-keys.ts +5 -3
- package/src/local/local-knowledge-uploads.ts +62 -5
- package/src/local/local-scaffold.ts +48 -13
- package/src/local/local-schemas.ts +1 -1
- package/src/local/local-seed.ts +1 -1
- package/src/local/local-studio-server.ts +14 -8
- package/src/local/local-supervisor.ts +13 -0
- package/src/local/local-sync-link.ts +43 -2
- package/src/local/local-sync-models.ts +118 -0
- package/src/local/local-sync-plan.ts +19 -9
- package/src/local/local-sync-portable.ts +269 -0
- package/src/local/local-sync-precheck.ts +94 -0
- package/src/local/local-sync-push.ts +45 -7
- package/src/local/local-sync-remote.ts +39 -8
- package/src/local/local-sync-snapshot.ts +9 -1
- package/src/vendor/kit-assets.json +66 -5
|
@@ -587,6 +587,53 @@ export function ensureProjectBunfig(root: string): boolean {
|
|
|
587
587
|
return true
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
+
/** The example Agent, workflow, Function, evaluation and object type, with the sample dataset they use. */
|
|
591
|
+
function sampleFiles(name: string, agentModel?: string): Record<string, string> {
|
|
592
|
+
return {
|
|
593
|
+
'agents/assistant/agent.json': AGENT(name, agentModel),
|
|
594
|
+
'agents/assistant/operating.json': OPERATING,
|
|
595
|
+
'agents/assistant/bindings.json': BINDINGS,
|
|
596
|
+
'agents/assistant/evals/regression.yaml': EVALS,
|
|
597
|
+
'workflows/hello.yaml': WORKFLOW,
|
|
598
|
+
'workflows/tests/hello.yaml': WORKFLOW_TESTS,
|
|
599
|
+
'functions/word-count.ts': FUNCTION,
|
|
600
|
+
'functions/word-count.test.ts': FUNCTION_TEST,
|
|
601
|
+
'datasets/tickets.json': DATASET,
|
|
602
|
+
'datasets/tickets.csv': DATASET_ROWS,
|
|
603
|
+
'blueprint/object-types/Ticket.yaml': OBJECT_TYPE,
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The sample Agent as compared: its model and the project's name in its description are set per project. */
|
|
608
|
+
function sampleAgent(text: string): unknown {
|
|
609
|
+
try {
|
|
610
|
+
const doc = JSON.parse(text) as { config?: Record<string, unknown>; modelConfigs?: unknown }
|
|
611
|
+
const { description: _description, defaultMode: _defaultMode, ...config } = doc.config ?? {}
|
|
612
|
+
const { modelConfigs: _models, ...rest } = doc
|
|
613
|
+
return { ...rest, config }
|
|
614
|
+
} catch {
|
|
615
|
+
return null
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Whether an item's files are still exactly what setting up the project wrote
|
|
621
|
+
* for its sample: the example, not the project's own work, so a linked
|
|
622
|
+
* project does not propose it by default: the sample Ticket type would
|
|
623
|
+
* otherwise overwrite a workspace's own. Edited, it is the project's.
|
|
624
|
+
*/
|
|
625
|
+
export function isScaffoldSample(files: Record<string, string>): boolean {
|
|
626
|
+
const sample = sampleFiles('')
|
|
627
|
+
const paths = Object.keys(files)
|
|
628
|
+
if (!paths.length) return false
|
|
629
|
+
return paths.every((path) => {
|
|
630
|
+
const expected = sample[path]
|
|
631
|
+
if (expected === undefined) return false
|
|
632
|
+
if (path === 'agents/assistant/agent.json') return JSON.stringify(sampleAgent(files[path]!)) === JSON.stringify(sampleAgent(expected))
|
|
633
|
+
return files[path] === expected
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
590
637
|
/** `samples: false` leaves out the example Agent, workflow, Function and object type: a project started from a workspace gets the workspace's own. */
|
|
591
638
|
export function solutionScaffoldFiles(name: string, opts: { agentModel?: string; samples?: boolean } = {}): Record<string, string> {
|
|
592
639
|
const sdk = vendored.files as Record<string, string>
|
|
@@ -610,19 +657,7 @@ export function solutionScaffoldFiles(name: string, opts: { agentModel?: string;
|
|
|
610
657
|
out['.codex/config.toml'] = CODEX_MCP_TOML
|
|
611
658
|
out['fixtures/README.md'] = FIXTURES_README
|
|
612
659
|
out['bunfig.toml'] = PROJECT_BUNFIG
|
|
613
|
-
if (opts.samples !== false)
|
|
614
|
-
out['agents/assistant/agent.json'] = AGENT(name, opts.agentModel)
|
|
615
|
-
out['agents/assistant/operating.json'] = OPERATING
|
|
616
|
-
out['agents/assistant/bindings.json'] = BINDINGS
|
|
617
|
-
out['agents/assistant/evals/regression.yaml'] = EVALS
|
|
618
|
-
out['workflows/hello.yaml'] = WORKFLOW
|
|
619
|
-
out['workflows/tests/hello.yaml'] = WORKFLOW_TESTS
|
|
620
|
-
out['functions/word-count.ts'] = FUNCTION
|
|
621
|
-
out['functions/word-count.test.ts'] = FUNCTION_TEST
|
|
622
|
-
out['datasets/tickets.json'] = DATASET
|
|
623
|
-
out['datasets/tickets.csv'] = DATASET_ROWS
|
|
624
|
-
out['blueprint/object-types/Ticket.yaml'] = OBJECT_TYPE
|
|
625
|
-
}
|
|
660
|
+
if (opts.samples !== false) Object.assign(out, sampleFiles(name, opts.agentModel))
|
|
626
661
|
out['package.json'] =
|
|
627
662
|
JSON.stringify(
|
|
628
663
|
{
|
|
@@ -134,7 +134,7 @@ const EVALS_SCHEMA: JsonSchema = {
|
|
|
134
134
|
conversation: {
|
|
135
135
|
type: 'array',
|
|
136
136
|
minItems: 1,
|
|
137
|
-
description: 'One turn per item. Only the user turns are sent, in order, each as soon as the reply before it ends; an assistant turn is kept for whoever reads the case. `waitSeconds` on a user turn keeps a gap before it (`- { user: "Still there?", waitSeconds: 60 }` sends it a minute after the reply before).',
|
|
137
|
+
description: 'One turn per item. Only the user turns are sent, in order, each as soon as the reply before it ends; an assistant turn is kept for whoever reads the case. `waitSeconds` on a user turn keeps a gap before it (`- { user: "Still there?", waitSeconds: 60 }` sends it a minute after the reply before). A case of more than one turn runs on this computer only: the workspace\'s evaluation datasets hold single questions, so `frontera local sync push` proposes the file\'s single-question cases and keeps the rest here.',
|
|
138
138
|
items: {
|
|
139
139
|
type: 'object',
|
|
140
140
|
properties: {
|
package/src/local/local-seed.ts
CHANGED
|
@@ -166,7 +166,7 @@ export async function seedLocalRuntime(args: SeedArgs): Promise<LocalCredentials
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/** Provider ids the service stores as organization credentials, each with an `apiKey` field. */
|
|
169
|
-
const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google'])
|
|
169
|
+
const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google', 'openrouter-web-search'])
|
|
170
170
|
|
|
171
171
|
/** Embedding defaults by provider, in preference order; the service requires 1536-wide vectors. */
|
|
172
172
|
const EMBEDDING: Array<{ provider: string; model: string }> = [
|
|
@@ -299,7 +299,7 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
299
299
|
if (!(file instanceof Blob)) return json({ error: 'send the document as the multipart field "file"' }, 400)
|
|
300
300
|
const name = checkUpload((file as File).name ?? '', file.size)
|
|
301
301
|
const replace = url.searchParams.get('replace') === '1'
|
|
302
|
-
// Replacing: the runtime's copy goes first, so the apply uploads the new content
|
|
302
|
+
// Replacing: the runtime's copy goes first, so the apply uploads the new content as a document the runtime lacks.
|
|
303
303
|
if (replace && id && documentInFolder(project.root, folder, name)) {
|
|
304
304
|
const call = serviceCall()
|
|
305
305
|
if (call) {
|
|
@@ -386,14 +386,20 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
386
386
|
if (state?.credentials?.workspaceId && !headers.has('x-workspace-id')) {
|
|
387
387
|
headers.set('x-workspace-id', state.credentials.workspaceId)
|
|
388
388
|
}
|
|
389
|
+
// A page sends the session token it kept (`bearer_token`) itself. After
|
|
390
|
+
// `frontera local reset` that session is gone, so a session the service
|
|
391
|
+
// refuses is answered as the owner; an API key or a signed token is not.
|
|
392
|
+
const kept = headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9]{32})$/)?.[1]
|
|
393
|
+
const fallback = kept && bearer && kept !== bearer ? bearer : null
|
|
394
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
|
|
395
|
+
const body = hasBody && fallback ? await req.arrayBuffer() : hasBody ? req.body : undefined
|
|
389
396
|
try {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
})
|
|
397
|
+
const send = () => fetch(target, { method: req.method, headers, body, redirect: 'manual', duplex: 'half' })
|
|
398
|
+
let res = await send()
|
|
399
|
+
if (res.status === 401 && fallback) {
|
|
400
|
+
headers.set('authorization', `Bearer ${fallback}`)
|
|
401
|
+
res = await send()
|
|
402
|
+
}
|
|
397
403
|
const out = new Headers(res.headers)
|
|
398
404
|
out.delete('content-encoding')
|
|
399
405
|
out.delete('content-length')
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type LocalConfig,
|
|
33
33
|
type LocalSecrets,
|
|
34
34
|
} from './local-env'
|
|
35
|
+
import { runtimeKeyCustodianProblem } from './local-key-custodian'
|
|
35
36
|
import { localEmbeddingsReachable, providerEnv } from './local-keys'
|
|
36
37
|
import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
|
|
37
38
|
import type { PortBlock } from './local-ports'
|
|
@@ -484,6 +485,7 @@ async function startService(
|
|
|
484
485
|
running.children.set('service', child)
|
|
485
486
|
const ok = await waitForHttp(`http://127.0.0.1:${ports.service}/health`, 120_000, () => child.exitCode === null)
|
|
486
487
|
if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
|
|
488
|
+
await refuseBrokenKeyCustodian(ports)
|
|
487
489
|
set('service', { status: 'running', pid: child.pid, startedAt: new Date().toISOString() })
|
|
488
490
|
keepDocs(project, { bundle: customerDocsBundle(config.platformSource) }, log)
|
|
489
491
|
refreshCheckoutDocs(project, config.platformSource, log)
|
|
@@ -508,10 +510,21 @@ async function startService(
|
|
|
508
510
|
followLogs(name, log)
|
|
509
511
|
const ok = await waitForHttp(`http://127.0.0.1:${ports.service}/health`, 180_000)
|
|
510
512
|
if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
|
|
513
|
+
await refuseBrokenKeyCustodian(ports)
|
|
511
514
|
set('service', { status: 'running', container: name, startedAt: new Date().toISOString() })
|
|
512
515
|
keepDocs(project, { fromContainer: (dest) => copyFromContainer(name, IMAGE_DOCS_PATH, dest) }, log)
|
|
513
516
|
}
|
|
514
517
|
|
|
518
|
+
/**
|
|
519
|
+
* A service whose encryption keys can't be read fails every secret and
|
|
520
|
+
* governed Action record later with a 500: the start stops here instead, with
|
|
521
|
+
* the fix (`local-key-custodian.ts`).
|
|
522
|
+
*/
|
|
523
|
+
async function refuseBrokenKeyCustodian(ports: PortBlock): Promise<void> {
|
|
524
|
+
const problem = await runtimeKeyCustodianProblem(`http://127.0.0.1:${ports.service}`)
|
|
525
|
+
if (problem) throw new Error(problem)
|
|
526
|
+
}
|
|
527
|
+
|
|
515
528
|
/** The release's documentation, for the solution's coding agents (`frontera mcp`). */
|
|
516
529
|
function keepDocs(project: LocalProject, from: { bundle?: string | null; fromContainer?: (dest: string) => boolean }, log: string): void {
|
|
517
530
|
if (!from.bundle && !from.fromContainer) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { parseDocument } from 'yaml'
|
|
@@ -28,6 +28,11 @@ export interface SyncLink {
|
|
|
28
28
|
/** Names shown in Forge and `sync status`; refreshed on connect. */
|
|
29
29
|
workspaceName?: string
|
|
30
30
|
organizationName?: string
|
|
31
|
+
/**
|
|
32
|
+
* A model this computer names (a local provider's, by catalog slug) and the
|
|
33
|
+
* workspace's model it goes as when proposed (`local-sync-portable.ts`).
|
|
34
|
+
*/
|
|
35
|
+
models?: Record<string, string>
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export function projectFile(root: string): string {
|
|
@@ -40,6 +45,9 @@ export function readLink(root: string): SyncLink | null {
|
|
|
40
45
|
const doc = parseDocument(readFileSync(file, 'utf8')).toJS() as { sync?: Partial<SyncLink> } | null
|
|
41
46
|
const s = doc?.sync
|
|
42
47
|
if (!s || typeof s.platform !== 'string' || typeof s.workspace !== 'string') return null
|
|
48
|
+
const models = s.models && typeof s.models === 'object' && !Array.isArray(s.models)
|
|
49
|
+
? Object.fromEntries(Object.entries(s.models as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string' && !!e[0] && !!e[1]))
|
|
50
|
+
: {}
|
|
43
51
|
return {
|
|
44
52
|
platform: s.platform,
|
|
45
53
|
workspace: s.workspace,
|
|
@@ -47,9 +55,41 @@ export function readLink(root: string): SyncLink | null {
|
|
|
47
55
|
blueprint: s.blueprint === 'organization' ? 'organization' : 'workspace',
|
|
48
56
|
workspaceName: typeof s.workspaceName === 'string' ? s.workspaceName : undefined,
|
|
49
57
|
organizationName: typeof s.organizationName === 'string' ? s.organizationName : undefined,
|
|
58
|
+
...(Object.keys(models).length ? { models } : {}),
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
const mapped = new Map<string, { mtimeMs: number; models: Record<string, string> }>()
|
|
63
|
+
|
|
64
|
+
/** The project's model mapping (`sync.models`), empty when it has none or no link; read once per change of the file. */
|
|
65
|
+
export function modelMap(root: string): Record<string, string> {
|
|
66
|
+
const file = projectFile(root)
|
|
67
|
+
let mtimeMs = -1
|
|
68
|
+
try {
|
|
69
|
+
mtimeMs = statSync(file).mtimeMs
|
|
70
|
+
} catch {
|
|
71
|
+
return {}
|
|
72
|
+
}
|
|
73
|
+
const known = mapped.get(file)
|
|
74
|
+
if (known && known.mtimeMs === mtimeMs) return known.models
|
|
75
|
+
const models = readLink(root)?.models ?? {}
|
|
76
|
+
mapped.set(file, { mtimeMs, models })
|
|
77
|
+
return models
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Map a local model to a workspace model when proposed, or (`workspace` null) stop mapping it. */
|
|
81
|
+
export function setModelMapping(root: string, local: string, workspace: string | null): SyncLink {
|
|
82
|
+
const link = readLink(root)
|
|
83
|
+
if (!link) throw new Error('the project is not linked to a workspace')
|
|
84
|
+
const models = { ...(link.models ?? {}) }
|
|
85
|
+
if (workspace) models[local] = workspace
|
|
86
|
+
else delete models[local]
|
|
87
|
+
const next: SyncLink = { ...link, models }
|
|
88
|
+
if (!Object.keys(models).length) delete next.models
|
|
89
|
+
writeLink(root, next)
|
|
90
|
+
return next
|
|
91
|
+
}
|
|
92
|
+
|
|
53
93
|
/** Write the link into `frontera.yaml`, keeping the rest of the file and its comments. */
|
|
54
94
|
export function writeLink(root: string, link: SyncLink | null): void {
|
|
55
95
|
const file = projectFile(root)
|
|
@@ -58,11 +98,12 @@ export function writeLink(root: string, link: SyncLink | null): void {
|
|
|
58
98
|
if (!link) {
|
|
59
99
|
doc.delete('sync')
|
|
60
100
|
} else {
|
|
61
|
-
const value: Record<string,
|
|
101
|
+
const value: Record<string, unknown> = { platform: link.platform, workspace: link.workspace }
|
|
62
102
|
if (link.workspaceName) value.workspaceName = link.workspaceName
|
|
63
103
|
if (link.organization) value.organization = link.organization
|
|
64
104
|
if (link.organizationName) value.organizationName = link.organizationName
|
|
65
105
|
value.blueprint = link.blueprint
|
|
106
|
+
if (link.models && Object.keys(link.models).length) value.models = link.models
|
|
66
107
|
const existing = doc.get('sync', true) as { commentBefore?: string } | undefined
|
|
67
108
|
const node = doc.createNode(value) as unknown as { commentBefore?: string }
|
|
68
109
|
node.commentBefore = existing?.commentBefore ?? ' The platform workspace this project pulls from (`frontera local sync`). Keys are in your keychain, not here.'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { modelMap, projectFile } from './local-sync-link'
|
|
5
|
+
import type { ModelMap } from './local-sync-portable'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which model a project's model slug is, on this computer and in the
|
|
9
|
+
* workspace. A catalog slug is each catalog's own name for a model (this
|
|
10
|
+
* computer's provider calls it `deepseek-deepseek-v4-1-flash`, the
|
|
11
|
+
* workspace's `deepseek-v4-1-flash`), so models are matched by what they are:
|
|
12
|
+
* the provider's model id (`deepseek/deepseek-v4.1-flash`).
|
|
13
|
+
*
|
|
14
|
+
* the same model in the workspace the file's slug is written as the workspace's when proposed, and the
|
|
15
|
+
* workspace's as this computer's on pull: nothing to choose
|
|
16
|
+
* no such model in the workspace which model an Agent runs on stays the workspace's choice: the Agent
|
|
17
|
+
* keeps the workspace's model there (its default, for a new one), and the
|
|
18
|
+
* local one is this computer's own, never proposed (`local-sync-portable.ts`)
|
|
19
|
+
* `sync.models` in frontera.yaml an explicit choice, over the match
|
|
20
|
+
*
|
|
21
|
+
* This computer's catalog is kept when a sync command finds the runtime
|
|
22
|
+
* running (`.frontera/local/model-catalog.json`), so status and push match
|
|
23
|
+
* models offline; the workspace's comes with the snapshot.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface CatalogEntry {
|
|
27
|
+
slug: string
|
|
28
|
+
name?: string
|
|
29
|
+
kind?: string
|
|
30
|
+
provider?: string
|
|
31
|
+
modelId?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LOCAL_CATALOG = join('.frontera', 'local', 'model-catalog.json')
|
|
35
|
+
const REMOTE_JSON = join('.frontera', 'sync', 'remote.json')
|
|
36
|
+
|
|
37
|
+
/** Keep this computer's catalog, as the running runtime listed it. */
|
|
38
|
+
export function rememberLocalCatalog(root: string, models: CatalogEntry[]): void {
|
|
39
|
+
const file = join(root, LOCAL_CATALOG)
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
42
|
+
writeFileSync(file, `${JSON.stringify({ version: 1, models: models.map(({ slug, name, kind, provider, modelId }) => ({ slug, name, kind, provider, modelId })) }, null, 2)}\n`)
|
|
43
|
+
} catch {
|
|
44
|
+
// Matching then falls back to the last copy, or to slugs alone.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readJson<T>(file: string): T | null {
|
|
49
|
+
try {
|
|
50
|
+
return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The provider's id as two catalogs write it alike: case and spaces aside. */
|
|
57
|
+
const sameModelId = (id: string | undefined) => (id ?? '').trim().toLowerCase()
|
|
58
|
+
|
|
59
|
+
export interface ProjectModels {
|
|
60
|
+
/** A local slug and the workspace slug it goes as: matched by model, then `sync.models` over it. */
|
|
61
|
+
map: ModelMap
|
|
62
|
+
/** The workspace's model slugs; null when no snapshot lists them (then nothing is judged missing). */
|
|
63
|
+
workspace: Set<string> | null
|
|
64
|
+
/** The workspace's models by slug, for words. */
|
|
65
|
+
names: Record<string, string>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cached = new Map<string, { stamp: string; value: ProjectModels }>()
|
|
69
|
+
|
|
70
|
+
const stampOf = (...files: string[]) =>
|
|
71
|
+
files
|
|
72
|
+
.map((f) => {
|
|
73
|
+
try {
|
|
74
|
+
return String(statSync(f).mtimeMs)
|
|
75
|
+
} catch {
|
|
76
|
+
return '-'
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.join('|')
|
|
80
|
+
|
|
81
|
+
/** The project's models as push and status read them; read again only when one of their files changes. */
|
|
82
|
+
export function projectModels(root: string): ProjectModels {
|
|
83
|
+
const files = [projectFile(root), join(root, LOCAL_CATALOG), join(root, REMOTE_JSON)]
|
|
84
|
+
const stamp = stampOf(...files)
|
|
85
|
+
const known = cached.get(root)
|
|
86
|
+
if (known && known.stamp === stamp) return known.value
|
|
87
|
+
const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> } }>(join(root, REMOTE_JSON))
|
|
88
|
+
const theirs = remote?.models ?? Object.keys(remote?.names?.model ?? {}).map((slug) => ({ slug }) as CatalogEntry)
|
|
89
|
+
const ours = readJson<{ models?: CatalogEntry[] }>(join(root, LOCAL_CATALOG))?.models ?? []
|
|
90
|
+
const value = matchModels(ours, theirs.length ? theirs : null, modelMap(root))
|
|
91
|
+
cached.set(root, { stamp, value })
|
|
92
|
+
return value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The match itself: each local model the workspace has under another slug, by the provider's id; the explicit choices over it. */
|
|
96
|
+
export function matchModels(ours: CatalogEntry[], theirs: CatalogEntry[] | null, explicit: ModelMap = {}): ProjectModels {
|
|
97
|
+
if (!theirs) return { map: { ...explicit }, workspace: null, names: {} }
|
|
98
|
+
const workspace = new Set(theirs.map((m) => m.slug))
|
|
99
|
+
const byId = new Map<string, CatalogEntry[]>()
|
|
100
|
+
for (const m of theirs) {
|
|
101
|
+
if ((m.kind ?? 'text') !== 'text' || !m.modelId) continue
|
|
102
|
+
byId.set(sameModelId(m.modelId), [...(byId.get(sameModelId(m.modelId)) ?? []), m])
|
|
103
|
+
}
|
|
104
|
+
// A platform that lists its models without the provider's id (older than these fields): a slug made from the
|
|
105
|
+
// provider's id, whole or its model part (`deepseek/deepseek-v4.1-flash` → `deepseek-v4-1-flash`), is the same model.
|
|
106
|
+
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
107
|
+
const bySlug = new Map(theirs.filter((t) => !t.modelId).map((t) => [slugify(t.slug), t]))
|
|
108
|
+
const map: ModelMap = {}
|
|
109
|
+
for (const m of ours) {
|
|
110
|
+
if (workspace.has(m.slug) || !m.modelId) continue
|
|
111
|
+
const guessed = [slugify(m.modelId), slugify(m.modelId.split('/').pop() ?? '')].map((s) => bySlug.get(s)).find(Boolean)
|
|
112
|
+
const same = byId.get(sameModelId(m.modelId)) ?? (guessed ? [guessed] : [])
|
|
113
|
+
// One model under two of the workspace's providers: the one named alike, else the first.
|
|
114
|
+
const pick = same.find((w) => w.name && w.name === m.name) ?? same[0]
|
|
115
|
+
if (pick) map[m.slug] = pick.slug
|
|
116
|
+
}
|
|
117
|
+
return { map: { ...map, ...explicit }, workspace, names: Object.fromEntries(theirs.map((m) => [m.slug, m.name ?? m.slug])) }
|
|
118
|
+
}
|
|
@@ -9,6 +9,8 @@ import { conflictsId, deepEqual, mergeItemFiles, type Choice, type Side } from '
|
|
|
9
9
|
import { itemTitle } from './local-sync-names'
|
|
10
10
|
import { toNames, type Directory, type Unresolved } from './local-sync-refs'
|
|
11
11
|
import { comparableText, isPluginFile, withLocalSecrets } from './local-secrets'
|
|
12
|
+
import { projectModels } from './local-sync-models'
|
|
13
|
+
import { comparablePortable, toLocal } from './local-sync-portable'
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* A pull is a plan first. Each item in the workspace (an Agent with its
|
|
@@ -314,17 +316,24 @@ export function statusFor(base: string | null, local: string | null, remote: str
|
|
|
314
316
|
return 'same'
|
|
315
317
|
}
|
|
316
318
|
|
|
317
|
-
/**
|
|
318
|
-
|
|
319
|
+
/**
|
|
320
|
+
* A working file as it compares with the workspace's: what is this
|
|
321
|
+
* computer's own left out (a Plugin's secret values, a model mapped in
|
|
322
|
+
* `sync.models`, multi-turn evaluation cases), and written as the
|
|
323
|
+
* workspace's or the base's text when it then says the same.
|
|
324
|
+
*/
|
|
325
|
+
function readComparable(root: string, rel: string, base?: (path: string) => string | null, remote?: string | null): string | null {
|
|
319
326
|
const text = readLocal(root, rel)
|
|
320
|
-
|
|
327
|
+
if (text === null) return null
|
|
328
|
+
if (isPluginFile(rel)) return comparableText(rel, text, base?.(rel) ?? null)
|
|
329
|
+
return comparablePortable(rel, text, projectModels(root), [remote, base?.(rel)])
|
|
321
330
|
}
|
|
322
331
|
|
|
323
332
|
/** Hash of the working files at these paths, or null when none of them exist. */
|
|
324
|
-
function localHash(root: string, paths: string[], base?: (path: string) => string | null): string | null {
|
|
333
|
+
function localHash(root: string, paths: string[], base?: (path: string) => string | null, remote: Record<string, string> = {}): string | null {
|
|
325
334
|
const files: Record<string, string> = {}
|
|
326
335
|
for (const p of paths) {
|
|
327
|
-
const content = readComparable(root, p, base)
|
|
336
|
+
const content = readComparable(root, p, base, remote[p])
|
|
328
337
|
if (content !== null) files[p] = content
|
|
329
338
|
}
|
|
330
339
|
return Object.keys(files).length ? itemHash(files) : null
|
|
@@ -401,7 +410,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
|
|
|
401
410
|
seen.add(item.ref)
|
|
402
411
|
const entry = lock?.items[item.ref]
|
|
403
412
|
const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
404
|
-
const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base), remote: item.files[path] ?? null }))
|
|
413
|
+
const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base, item.files[path] ?? null), remote: item.files[path] ?? null }))
|
|
405
414
|
// An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
|
|
406
415
|
// `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
|
|
407
416
|
const added = item.kind === 'agent' ? AGENT_FILES.map((f) => `agents/${item.name}/${f}`).filter((p) => !paths.includes(p) && existsSync(join(root, p))) : []
|
|
@@ -409,7 +418,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
|
|
|
409
418
|
// The files as they stand over the base's paths, plus any path only the workspace has that is
|
|
410
419
|
// already in the folder: a file the pull would overwrite counts as a change here.
|
|
411
420
|
const basePaths = entry ? [...Object.keys(entry.files), ...files.filter((f) => !(f.path in entry.files) && f.local !== null).map((f) => f.path), ...added] : Object.keys(item.files)
|
|
412
|
-
const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base), itemHash(item.files), matches) ?? 'same'
|
|
421
|
+
const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base, item.files), itemHash(item.files), matches) ?? 'same'
|
|
413
422
|
// Pulled with conflicts and not resolved: it stays so until `resolve`, whatever the files say.
|
|
414
423
|
const status: ItemStatus = entry?.conflict && found !== 'same' ? 'unresolved' : found
|
|
415
424
|
let merge: PlanItem['merge']
|
|
@@ -579,8 +588,9 @@ export function applyItems(root: string, workspace: string, items: RemoteItem[],
|
|
|
579
588
|
for (const [path, remote] of Object.entries(item.files)) {
|
|
580
589
|
const full = safePath(root, path)
|
|
581
590
|
mkdirSync(dirname(full), { recursive: true })
|
|
582
|
-
// A Plugin's secret values set here stay: they are this computer's configuration.
|
|
583
|
-
|
|
591
|
+
// A Plugin's secret values set here stay: they are this computer's configuration. So do a mapped model and
|
|
592
|
+
// the multi-turn evaluation cases (`local-sync-portable.ts`).
|
|
593
|
+
const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : toLocal(path, remote, readLocal(root, path), projectModels(root), opts.base?.(path) ?? null)
|
|
584
594
|
if (readLocal(root, path) !== content) {
|
|
585
595
|
writeFileSync(full, content)
|
|
586
596
|
written.push(path)
|