@frontera-sdk/cli 1.53.10 → 1.53.12
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 +5 -5
- package/src/api/platform-api.ts +43 -0
- package/src/blueprint/compile.ts +35 -2
- package/src/blueprint/dataset-revision.ts +4 -3
- package/src/blueprint/diff.ts +4 -1
- package/src/commands/automation/dev.ts +1 -1
- package/src/commands/blueprint/declarative.ts +106 -44
- package/src/commands/knowledge/index-commands.ts +1 -1
- package/src/commands/local/steps.ts +4 -4
- package/src/commands/local/sync-request.ts +229 -152
- package/src/commands/local/sync.ts +308 -44
- package/src/commands/registry.ts +5 -2
- package/src/commands/voice/index-commands.ts +363 -0
- package/src/flag-help.ts +19 -0
- package/src/forge/touches.ts +2 -0
- package/src/local/local-blueprint-schemas.ts +6 -2
- package/src/local/local-person-steps.ts +17 -16
- package/src/local/local-portability.ts +5 -3
- package/src/local/local-schemas.ts +41 -0
- package/src/local/local-sync-link.ts +255 -55
- package/src/local/local-sync-plan.ts +1 -1
- package/src/local/local-sync-precheck.ts +16 -2
- package/src/local/local-synthetic-data.ts +12 -7
- package/src/mcp/tools/project-tools.ts +9 -2
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -13,9 +13,9 @@ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
|
|
|
13
13
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
14
14
|
import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
15
15
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
16
|
-
import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetOf, targetProfileName, targetSession, type SyncLink } from '../../local/local-sync-link'
|
|
16
|
+
import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetLabel, targetOf, targetProfileName, targetSession, targetWords, type SyncLink, type SyncTarget } from '../../local/local-sync-link'
|
|
17
17
|
import { itemTitle } from '../../local/local-sync-names'
|
|
18
|
-
import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
18
|
+
import { checkBody, markMissingModels, markRefused, movedOnTarget, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
19
19
|
import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
|
|
20
20
|
import { rows } from '../../local/local-sync-remote'
|
|
21
21
|
import { evalsDetail, evalsLine, lastRender, sessionScreenshots, standInInstalls } from '../../local/local-request-evidence'
|
|
@@ -54,7 +54,7 @@ import { directoryOf, readSnapshot, snapshotItems } from '../../local/local-sync
|
|
|
54
54
|
import { buildAndExtract } from '../automation/build-entry'
|
|
55
55
|
import { findAutomationProjectRoot } from '../automation/project-root'
|
|
56
56
|
import { flagBool, flagString, type CommandContext } from '../types'
|
|
57
|
-
import {
|
|
57
|
+
import { chooseTarget, fetchTarget, planFor, sourceFor, targetPlan } from './sync'
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* `frontera local sync push` and `frontera local sync request`: change
|
|
@@ -94,13 +94,16 @@ type RequestBody = NonNullable<RequestInit['body']>
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Who a command's change-request calls act as: the person signed in for the
|
|
97
|
-
* target, or else the
|
|
97
|
+
* target, or else the target's keys. One for the whole command: a person
|
|
98
98
|
* sends no key with their session, so a Blueprint change applied by a person
|
|
99
|
-
* is that person's alone.
|
|
99
|
+
* is that person's alone. `target` and `profile` say where: the named target
|
|
100
|
+
* (null for the project's link) and its profile, so every step a hint prints
|
|
101
|
+
* names the same target.
|
|
100
102
|
*/
|
|
101
|
-
type Acting =
|
|
102
|
-
| { as: 'person';
|
|
103
|
-
| { as: 'keys';
|
|
103
|
+
type Acting = { target: string | null; profile: string } & (
|
|
104
|
+
| { as: 'person'; token: string; email: string | null; organizationKeyStored: boolean }
|
|
105
|
+
| { as: 'keys'; workspaceKey: string; organizationKey: string | null }
|
|
106
|
+
)
|
|
104
107
|
|
|
105
108
|
interface Platform {
|
|
106
109
|
acting: Acting
|
|
@@ -163,37 +166,45 @@ async function answerAs<T>(res: Response, acting: Acting): Promise<T> {
|
|
|
163
166
|
if (checks.length) throw new CliError(message, { code: 'SYNC_CHECKS', hint: 'fix the items the checks name, then push again', details: { checks } })
|
|
164
167
|
const details = (parsed?.details ?? null) as { code?: string; gate?: Gate } | null
|
|
165
168
|
if (details?.code === 'GATE_FAILED' && details.gate) {
|
|
166
|
-
throw new CliError(message, { code: 'SYNC_APPLY_GATED', hint:
|
|
169
|
+
throw new CliError(message, { code: 'SYNC_APPLY_GATED', hint: `make the checks pass (\`${syncCommand(acting, 'request evidence')}\` attaches evals and renders), or a workspace admin applies with \`--force --reason "<why>"\``, details: { gate: details.gate } })
|
|
167
170
|
}
|
|
168
171
|
if (res.status === 401 && acting.as === 'person') throw new CliError(`the platform no longer takes the browser sign-in for ${acting.profile}`, { code: 'UNAUTHORIZED', hint: `sign in again with \`${signInCommand(acting.profile)}\`` })
|
|
169
|
-
if (res.status === 401) throw new CliError('the platform refused the key', { code: 'UNAUTHORIZED', hint:
|
|
172
|
+
if (res.status === 401) throw new CliError('the platform refused the key', { code: 'UNAUTHORIZED', hint: `check it is a current workspace key (sk-ws-…) for this platform; \`${syncCommand(acting, 'connect')}\` stores a new one` })
|
|
170
173
|
if (res.status === 403) throw new CliError(message, { code: 'FORBIDDEN', hint: acting.as === 'keys' ? 'the key’s role decides what it may propose and apply; ask a workspace admin' : 'your role decides what you may propose and apply; ask a workspace admin' })
|
|
171
|
-
if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint:
|
|
172
|
-
if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint:
|
|
173
|
-
if (res.status === 400 || res.status === 422) throw new CliError(message, { code: 'VALIDATION_ERROR', hint:
|
|
174
|
+
if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint: `the change request is gone from the workspace; propose again with \`${syncCommand(acting, 'push')}\`` })
|
|
175
|
+
if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint: `see where it stands with \`${syncCommand(acting, 'request')}\`` })
|
|
176
|
+
if (res.status === 400 || res.status === 422) throw new CliError(message, { code: 'VALIDATION_ERROR', hint: `see where it stands with \`${syncCommand(acting, 'request')}\`` })
|
|
174
177
|
throw new CliError(`the platform answered ${res.status}: ${message}`, { code: 'UNAVAILABLE', hint: 'check the platform is reachable, then run the command again' })
|
|
175
178
|
}
|
|
176
179
|
|
|
177
|
-
/** The person signed in for the target when they are a member of
|
|
178
|
-
async function platformFor(
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
180
|
+
/** The person signed in for the target when they are a member of its workspace, else the target's own keys: never another target's. */
|
|
181
|
+
async function platformFor(target: Pick<SyncTarget, 'name' | 'owner' | 'link'>): Promise<Platform> {
|
|
182
|
+
const { link } = target
|
|
183
|
+
const person = await targetSession(target.owner, link)
|
|
184
|
+
if (!person) return keysFor(target)
|
|
185
|
+
const profile = targetProfileName(target.owner, link)
|
|
186
|
+
const api = platform(link, { as: 'person', target: target.name, profile, token: person.token, email: person.email ?? null, organizationKeyStored: !!getSyncKey(target.owner, 'organization') })
|
|
183
187
|
// The platform drops the workspace from a session that isn't a member's, and every change-request call then fails.
|
|
184
188
|
const me = await api.call<{ workspaceId?: string | null }>('GET', '/v1/whoami')
|
|
185
189
|
if (me.workspaceId?.toLowerCase() !== link.workspace.toLowerCase()) {
|
|
186
|
-
throw new CliError(`signed in as ${person.email ?? 'someone'} for ${profile}, who is not a member of ${
|
|
190
|
+
throw new CliError(`signed in as ${person.email ?? 'someone'} for ${profile}, who is not a member of ${targetLabel(target)}`, { code: 'SYNC_NOT_A_MEMBER', hint: `sign out with \`frontera auth logout --profile ${profile}\` and sign in as a member (\`${signInCommand(profile)}\`), or ask a workspace admin to add you` })
|
|
187
191
|
}
|
|
188
192
|
return api
|
|
189
193
|
}
|
|
190
194
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
/** The change-request routes of a target's platform with that target's own keys. */
|
|
196
|
+
function keysFor(target: Pick<SyncTarget, 'name' | 'owner' | 'link'>): Platform {
|
|
197
|
+
const workspaceKey = getSyncKey(target.owner, 'workspace')
|
|
198
|
+
if (!workspaceKey) throw new CliError(`no workspace key for ${target.name ? `target ${target.name}` : 'this project'} on this machine`, { code: 'SYNC_NO_KEY', hint: `run \`${targetWords(target, 'frontera local sync connect')}\` to store one (keys are per machine)` })
|
|
199
|
+
return platform(target.link, { as: 'keys', target: target.name, profile: targetProfileName(target.owner, target.link), workspaceKey, organizationKey: getSyncKey(target.owner, 'organization') })
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
/** A `frontera local sync` command for where `acting` acts: `--target <name>` follows it for a named target. */
|
|
203
|
+
const syncCommand = (acting: Pick<Acting, 'target'>, words: string) => targetWords({ name: acting.target }, `frontera local sync ${words}`)
|
|
204
|
+
|
|
205
|
+
/** How a hint names whose keys and workspace `acting` uses: the link's words (`link`), or the named target. */
|
|
206
|
+
const ownerWords = (acting: Pick<Acting, 'target'>, link: string) => (acting.target ? `target ${acting.target}` : link)
|
|
207
|
+
|
|
197
208
|
/**
|
|
198
209
|
* A request takes pushes, evidence, close and Apply from the one who proposed
|
|
199
210
|
* it (a workspace admin aside), so a request is another's when `acting` did
|
|
@@ -212,14 +223,14 @@ function proposedByOther(acting: Acting, view: View): 'key' | 'person' | null {
|
|
|
212
223
|
function notProposer(acting: Acting, view: View, doing: 'apply' | 'push to' | 'close' | 'attach evidence to'): CliError | null {
|
|
213
224
|
const by = proposedByOther(acting, view)
|
|
214
225
|
const title = view.request.title
|
|
215
|
-
if (by === 'person') return new CliError(`“${title}” was proposed by a person signed in for this project’s workspace, and no one is signed in on this computer now`, { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in again with \`${signInCommand(acting.profile)}\`, then run the command again` })
|
|
226
|
+
if (by === 'person') return new CliError(`“${title}” was proposed by a person signed in for ${ownerWords(acting, 'this project')}’s workspace, and no one is signed in on this computer now`, { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in again with \`${signInCommand(acting.profile)}\`, then run the command again` })
|
|
216
227
|
if (by !== 'key' || acting.as !== 'person') return null
|
|
217
|
-
const closeIt =
|
|
228
|
+
const closeIt = `close it (\`${syncCommand(acting, 'request close')}\` closes it with that key)`
|
|
218
229
|
const withKeys = doing === 'apply' && (acting.organizationKeyStored || !view.items.some((i) => i.kind === 'blueprint'))
|
|
219
|
-
return new CliError(`“${title}” was proposed with this project’s workspace key before you signed in, so it isn’t yours to ${doing}`, {
|
|
230
|
+
return new CliError(`“${title}” was proposed with ${ownerWords(acting, 'this project')}’s workspace key before you signed in, so it isn’t yours to ${doing}`, {
|
|
220
231
|
code: 'SYNC_PROPOSED_WITH_KEY',
|
|
221
232
|
hint: doing === 'apply'
|
|
222
|
-
? `${closeIt}, then push the changes again as yourself (\`${pushAgain(view)}\`) and apply${withKeys ? `; or sign out (\`frontera auth logout --profile ${acting.profile}\`) to apply it with the project’s keys` : ''}`
|
|
233
|
+
? `${closeIt}, then push the changes again as yourself (\`${pushAgain(acting, view)}\`) and apply${withKeys ? `; or sign out (\`frontera auth logout --profile ${acting.profile}\`) to apply it with ${ownerWords(acting, 'the project')}’s keys` : ''}`
|
|
223
234
|
: `${closeIt}, then push again to propose the changes as yourself`,
|
|
224
235
|
})
|
|
225
236
|
}
|
|
@@ -227,16 +238,16 @@ function notProposer(acting: Acting, view: View, doing: 'apply' | 'push to' | 'c
|
|
|
227
238
|
/** Who proposed the change request a step is for: nobody yet, the person signed in, or the project's workspace key. */
|
|
228
239
|
type ProposedBy = 'nobody' | 'you' | 'key'
|
|
229
240
|
|
|
230
|
-
/** The push that proposes these refs and App releases (`--release`'s value), with the title. */
|
|
231
|
-
function pushCommand(title: string | null, refs: string[], releases?: string): string {
|
|
232
|
-
return ['
|
|
241
|
+
/** The push that proposes these refs and App releases (`--release`'s value), with the title, where `acting` acts. */
|
|
242
|
+
function pushCommand(acting: Pick<Acting, 'target'>, title: string | null, refs: string[], releases?: string): string {
|
|
243
|
+
return syncCommand(acting, ['push', ...refs, ...(releases ? [`--release ${releases}`] : []), `-t "${title ?? '<title>'}"`].join(' '))
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
/** The push that proposes a request's items again: a Custom App's release by `--release`, anything else by its ref. */
|
|
236
|
-
function pushAgain(view: View): string {
|
|
247
|
+
function pushAgain(acting: Pick<Acting, 'target'>, view: View): string {
|
|
237
248
|
const release = (i: ViewItem) => (i.kind === 'app' ? releasedDeployment(i) : null)
|
|
238
249
|
const refs = view.items.filter((i) => !release(i) || Object.keys(i.files).length > 1).map((i) => i.ref)
|
|
239
|
-
return pushCommand(view.request.title, refs, view.items.flatMap((i) => (release(i) ? [`${i.name}=${release(i)}`] : [])).join(','))
|
|
250
|
+
return pushCommand(acting, view.request.title, refs, view.items.flatMap((i) => (release(i) ? [`${i.name}=${release(i)}`] : [])).join(','))
|
|
240
251
|
}
|
|
241
252
|
|
|
242
253
|
/**
|
|
@@ -247,16 +258,16 @@ function pushAgain(view: View): string {
|
|
|
247
258
|
*/
|
|
248
259
|
function withStoredKey(acting: Extract<Acting, { as: 'person' }>, push: string, proposed: ProposedBy): string {
|
|
249
260
|
const signOut = `sign out (\`frontera auth logout --profile ${acting.profile}\`)`
|
|
250
|
-
const apply =
|
|
261
|
+
const apply = `apply (\`${syncCommand(acting, 'request apply')}\`)`
|
|
251
262
|
if (proposed === 'key') return `${signOut}, then ${apply}`
|
|
252
263
|
const pushIt = `push${proposed === 'you' ? ' it again' : ''} (\`${push}\`) and ${apply}`
|
|
253
|
-
return proposed === 'you' ? `close it (
|
|
264
|
+
return proposed === 'you' ? `close it (\`${syncCommand(acting, 'request close')}\`), ${signOut}, then ${pushIt}` : `${signOut}, then ${pushIt}`
|
|
254
265
|
}
|
|
255
266
|
|
|
256
267
|
/** A refusal of the person's role, with the way to act with the organization key stored instead. */
|
|
257
268
|
function orStoredKey(err: unknown, acting: Acting, push: string, proposed: ProposedBy): unknown {
|
|
258
269
|
if (!(err instanceof CliError) || err.code !== 'FORBIDDEN' || acting.as !== 'person' || !acting.organizationKeyStored) return err
|
|
259
|
-
return new CliError(err.message, { code: err.code, hint: `your role decides what you may propose and apply. To use the organization key stored for this project instead, ${withStoredKey(acting, push, proposed)}; or ask a workspace admin` })
|
|
270
|
+
return new CliError(err.message, { code: err.code, hint: `your role decides what you may propose and apply. To use the organization key stored for ${ownerWords(acting, 'this project')} instead, ${withStoredKey(acting, push, proposed)}; or ask a workspace admin` })
|
|
260
271
|
}
|
|
261
272
|
|
|
262
273
|
/**
|
|
@@ -279,8 +290,8 @@ function notSteward(acting: Acting, unmet: boolean, push: string, proposed: Prop
|
|
|
279
290
|
return new CliError(`you are signed in as ${acting.email ?? 'someone'}, who is not a Blueprint Steward, so the Blueprint changes and grants in this change request could not be applied as you`, {
|
|
280
291
|
code: 'SYNC_NOT_A_STEWARD',
|
|
281
292
|
hint: proposed === 'key'
|
|
282
|
-
? `${withStoredKey(acting, push, proposed)}: the project’s keys proposed it, and Apply uses the organization key stored for this project`
|
|
283
|
-
: `${withStoredKey(acting, push, proposed)}: the project’s keys propose it, and Apply uses the organization key stored for this project; or ask a Blueprint Steward to sign in for this project and push it`,
|
|
293
|
+
? `${withStoredKey(acting, push, proposed)}: ${ownerWords(acting, 'the project')}’s keys proposed it, and Apply uses the organization key stored for ${ownerWords(acting, 'this project')}`
|
|
294
|
+
: `${withStoredKey(acting, push, proposed)}: ${ownerWords(acting, 'the project')}’s keys propose it, and Apply uses the organization key stored for ${ownerWords(acting, 'this project')}; or ask a Blueprint Steward to sign in for ${ownerWords(acting, 'this project')} and push it`,
|
|
284
295
|
})
|
|
285
296
|
}
|
|
286
297
|
|
|
@@ -306,11 +317,11 @@ function stewardData(acting: Acting, blueprint: boolean, stored: boolean) {
|
|
|
306
317
|
* push.
|
|
307
318
|
*/
|
|
308
319
|
function stewardNote(acting: Acting, proposed: boolean, push: string): string {
|
|
309
|
-
if (acting.as === 'person') return `Blueprint changes are applied as you (${acting.email ?? 'signed in'})${acting.organizationKeyStored ? `; the organization key stored for this project is not used while you’re signed in: to apply ${proposed ? 'this change request ' : ''}with it instead, ${withStoredKey(acting, push, proposed ? 'you' : 'nobody')}` : ''}.`
|
|
310
|
-
if (acting.organizationKey) return
|
|
320
|
+
if (acting.as === 'person') return `Blueprint changes are applied as you (${acting.email ?? 'signed in'})${acting.organizationKeyStored ? `; the organization key stored for ${ownerWords(acting, 'this project')} is not used while you’re signed in: to apply ${proposed ? 'this change request ' : ''}with it instead, ${withStoredKey(acting, push, proposed ? 'you' : 'nobody')}` : ''}.`
|
|
321
|
+
if (acting.organizationKey) return `Blueprint changes are applied with the organization key stored for ${ownerWords(acting, 'this project')}.`
|
|
311
322
|
const signIn = `\`${signInCommand(acting.profile)}\``
|
|
312
323
|
return proposed
|
|
313
|
-
? `Blueprint changes are applied by a Blueprint Steward, signed in, and this change request was proposed with the project’s workspace key: to apply it as yourself, sign in (${signIn}), close it (
|
|
324
|
+
? `Blueprint changes are applied by a Blueprint Steward, signed in, and this change request was proposed with ${ownerWords(acting, 'the project')}’s workspace key: to apply it as yourself, sign in (${signIn}), close it (\`${syncCommand(acting, 'request close')}\`) and push again.`
|
|
314
325
|
: `Blueprint changes are applied by a Blueprint Steward, signed in: sign in before you push, so the change request is yours to apply (${signIn}).`
|
|
315
326
|
}
|
|
316
327
|
|
|
@@ -589,17 +600,17 @@ async function deletionUsers(api: Platform, items: PushItem[]): Promise<Map<stri
|
|
|
589
600
|
* with its reason and fix. A platform without the check leaves them to
|
|
590
601
|
* Propose: `checked` says which.
|
|
591
602
|
*/
|
|
592
|
-
async function precheck(api: Platform, items: PushItem[], organizationKey: string | null): Promise<{ checked: boolean; refused: string[]; plan: ApplyStep[]; problem?: string }> {
|
|
603
|
+
async function precheck(api: Platform, items: PushItem[], organizationKey: string | null, target: SyncTarget): Promise<{ checked: boolean; refused: string[]; plan: ApplyStep[]; problem?: string }> {
|
|
593
604
|
const body = checkBody(items)
|
|
594
605
|
if (!body.length) return { checked: true, refused: [], plan: [] }
|
|
595
606
|
try {
|
|
596
607
|
// The organization key stored for the project, so the plan says whether it can make a Steward's steps.
|
|
597
608
|
const answer = await api.call<{ checks?: unknown[]; plan?: unknown }>('POST', '/v1/change-requests/check', { items: body, ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 120_000 })
|
|
598
|
-
return { checked: true, refused: markRefused(items, checksOf(answer)), plan: planOf(answer) }
|
|
609
|
+
return { checked: true, refused: markRefused(items, checksOf(answer), target), plan: planOf(answer) }
|
|
599
610
|
} catch (err) {
|
|
600
611
|
if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
|
|
601
612
|
// An older platform has no check; a refusal of the check itself names the items as the push would.
|
|
602
|
-
if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)), plan: [] }
|
|
613
|
+
if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details), target), plan: [] }
|
|
603
614
|
return { checked: false, refused: [], plan: [] }
|
|
604
615
|
}
|
|
605
616
|
return { checked: false, refused: [], plan: [], problem: (err as Error).message.split('\n')[0] }
|
|
@@ -616,11 +627,25 @@ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
|
|
|
616
627
|
}
|
|
617
628
|
}
|
|
618
629
|
|
|
619
|
-
|
|
630
|
+
/**
|
|
631
|
+
* What a named target changed since this project last delivered it, and the
|
|
632
|
+
* project did not: not proposed, so the target keeps its version, with the
|
|
633
|
+
* command that puts the project's back over it.
|
|
634
|
+
*/
|
|
635
|
+
function changedThereLines(target: SyncTarget, changed: Array<{ ref: string; kind: SyncKind; title: string }>): string[] {
|
|
636
|
+
if (!changed.length) return []
|
|
637
|
+
return [
|
|
638
|
+
'',
|
|
639
|
+
`Changed in ${targetLabel(target)} since this project last delivered it, so not proposed:`,
|
|
640
|
+
...changed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: to put the project’s version back over it, \`${targetWords(target, `frontera local sync resolve ${i.ref} --keep mine`)}\``),
|
|
641
|
+
]
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function previewText(target: SyncTarget, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = [], notCarried: string | null = null, changedThere: Array<{ ref: string; kind: SyncKind; title: string }> = []): string {
|
|
620
645
|
const ok = items.filter((i) => i.proposable && !i.sample)
|
|
621
646
|
const samples = items.filter((i) => i.proposable && i.sample)
|
|
622
647
|
const not = items.filter((i) => !i.proposable)
|
|
623
|
-
const name =
|
|
648
|
+
const name = targetLabel(target)
|
|
624
649
|
const lines = [
|
|
625
650
|
ok.length
|
|
626
651
|
? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:`
|
|
@@ -630,6 +655,7 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
630
655
|
]
|
|
631
656
|
if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
|
|
632
657
|
if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
|
|
658
|
+
lines.push(...changedThereLines(target, changedThere))
|
|
633
659
|
const before = person.filter((p) => !p.after)
|
|
634
660
|
const after = person.filter((p) => p.after)
|
|
635
661
|
if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
|
|
@@ -637,7 +663,7 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
637
663
|
if (orgNote) lines.push('', orgNote)
|
|
638
664
|
if (notCarried) lines.push('', notCarried)
|
|
639
665
|
if (!available) lines.push('', 'This platform does not take change requests yet.')
|
|
640
|
-
if (ok.length && available) lines.push('',
|
|
666
|
+
if (ok.length && available) lines.push('', `Propose with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\`, or name items to propose only those.`)
|
|
641
667
|
return lines.join('\n')
|
|
642
668
|
}
|
|
643
669
|
|
|
@@ -685,7 +711,7 @@ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown
|
|
|
685
711
|
return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
|
|
686
712
|
}
|
|
687
713
|
if (!workspace) throw new UsageError('name the workspace model it goes as', `frontera local sync map-model ${local} <workspace model>; \`push --preview\` lists ${name}’s models`)
|
|
688
|
-
const offered = await workspaceModels(await platformFor(project, link))
|
|
714
|
+
const offered = await workspaceModels(await platformFor({ name: null, owner: project, link }))
|
|
689
715
|
if (offered.length && !offered.some((m) => m.slug === workspace)) {
|
|
690
716
|
throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
|
|
691
717
|
}
|
|
@@ -696,11 +722,14 @@ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown
|
|
|
696
722
|
|
|
697
723
|
export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
698
724
|
const project = localProject(ctx.cwd)
|
|
699
|
-
const
|
|
725
|
+
const target = chooseTarget(project, ctx)
|
|
726
|
+
const { link } = target
|
|
700
727
|
const named = ctx.positional.slice(1)
|
|
701
728
|
const preview = flagBool(ctx, 'preview')
|
|
729
|
+
const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
|
|
702
730
|
// Fetch first, as git does before a push: an item the workspace moved past is pulled and merged first.
|
|
703
|
-
|
|
731
|
+
// A named target is never pulled from: it is compared with what this project last delivered to it (`targetPlan`).
|
|
732
|
+
const { plan, lock, snapshot, remote, delivered } = target.name ? await targetPlan(project, target) : { ...(await planFor(project, link, { fetch: 'always', repair: 'online' })), delivered: null }
|
|
704
733
|
if (!plan || !snapshot) throw new CliError('could not fetch the workspace', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch` and look at what it says' })
|
|
705
734
|
const available = snapshot.capabilities.includes('change-requests')
|
|
706
735
|
// Knowledge bases whose documents come too: opt-in, as for a pull (`*` for every one the project has).
|
|
@@ -709,19 +738,36 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
709
738
|
const documentNames = asked.includes('*') ? knowledgeHere : asked
|
|
710
739
|
const notHere = documentNames.filter((n) => !knowledgeHere.includes(n))
|
|
711
740
|
if (notHere.length) throw new UsageError(`no knowledge base ${notHere.join(', ')} in this project`, '--documents takes knowledge base names (knowledge/<name>), or * for every one')
|
|
712
|
-
const api = await platformFor(
|
|
741
|
+
const api = await platformFor(target)
|
|
713
742
|
const documents = documentNames.length
|
|
714
|
-
? { names: new Set(documentNames), pulled: readPulledDocuments(
|
|
743
|
+
? { names: new Set(documentNames), pulled: readPulledDocuments(target.state, link.workspace).bases, platform: await platformDocuments(api, documentNames, remote.items) }
|
|
715
744
|
: undefined
|
|
716
745
|
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
|
|
746
|
+
// A named target is never pulled from: what it changed since this project delivered it has its own words.
|
|
747
|
+
const changedThere = target.name ? plan.filter((p) => p.status === 'changed').map((p) => ({ ref: p.ref, kind: p.kind, title: p.title })) : []
|
|
748
|
+
if (target.name) {
|
|
749
|
+
const bothChanged = new Set(plan.filter((p) => p.status === 'conflict').map((p) => p.ref))
|
|
750
|
+
for (const i of all) {
|
|
751
|
+
// With no delivery to compare with, a change made on the target can't be told from one it hasn't had yet.
|
|
752
|
+
if (i.proposable && i.change !== 'new' && !delivered?.has(i.ref)) i.notes.push(`No delivery of it from this project is recorded on this computer: proposed over what ${targetLabel(target)} holds now, so a change made there is replaced`)
|
|
753
|
+
if (i.blocked === 'workspace-deleted') {
|
|
754
|
+
i.reason = `Deleted in ${targetLabel(target)} since this project last delivered it`
|
|
755
|
+
i.fix = `to deliver it again, \`${command(`resolve ${i.ref} --keep mine`)}\``
|
|
756
|
+
continue
|
|
757
|
+
}
|
|
758
|
+
if (i.blocked !== 'moved' || !bothChanged.has(i.ref)) continue
|
|
759
|
+
i.reason = `Changed in ${targetLabel(target)} too since this project last delivered it`
|
|
760
|
+
i.fix = `see what changed there with \`${command(`diff ${i.ref}`)}\`, then keep the project’s version with \`${command(`resolve ${i.ref} --keep mine`)}\`, or carry that change into the files here first`
|
|
761
|
+
}
|
|
762
|
+
}
|
|
717
763
|
// Before anything is sent: models only this computer has, then the workspace's own checks.
|
|
718
764
|
const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
|
|
719
765
|
const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
|
|
720
|
-
const checked = available ? await precheck(api, all, organizationKeyOf(api.acting)) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
|
|
766
|
+
const checked = available ? await precheck(api, all, organizationKeyOf(api.acting), target) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
|
|
721
767
|
const person = needsAPerson(checked.plan)
|
|
722
|
-
const kept = currentRequest(readRequests(
|
|
768
|
+
const kept = currentRequest(readRequests(target.state, link.workspace))
|
|
723
769
|
const updating = kept && isOpen(kept.state) ? kept : null
|
|
724
|
-
const orgStored = !!getSyncKey(
|
|
770
|
+
const orgStored = !!getSyncKey(target.owner, 'organization')
|
|
725
771
|
const titles = Object.fromEntries(all.map((i) => [i.ref, i.title]))
|
|
726
772
|
const mcp = mcpServersNotCarried(project.root)
|
|
727
773
|
const notCarried = mcp ? { mcpServers: mcp.files, save: mcp.command } : null
|
|
@@ -732,34 +778,35 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
732
778
|
const unknown = named.filter((r) => !byRef.has(r))
|
|
733
779
|
const servers = unknown.filter((r) => r.startsWith('mcp-server'))
|
|
734
780
|
if (mcp && servers.length) throw new UsageError(`a change request does not carry ${servers.join(', ')}`, mcp.line)
|
|
735
|
-
if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`,
|
|
781
|
+
if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, `list what can be proposed with \`${command('push --preview')}\``)
|
|
736
782
|
chosen = named.map((r) => byRef.get(r)!)
|
|
737
783
|
} else chosen = all
|
|
738
784
|
// Samples from setting up the project go only when named.
|
|
739
785
|
const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
|
|
740
786
|
const title = flagString(ctx, 'title')?.trim()
|
|
741
787
|
// The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
|
|
742
|
-
let
|
|
788
|
+
let open = updating
|
|
743
789
|
let stop: CliError | null = null
|
|
744
|
-
if (
|
|
745
|
-
const live = await api.call<View>('GET', `/v1/change-requests/${
|
|
790
|
+
if (open) {
|
|
791
|
+
const live = await api.call<View>('GET', `/v1/change-requests/${open.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
|
|
746
792
|
if (!live || !isOpen(live.request.state)) {
|
|
747
|
-
if (live) recordRequest(
|
|
748
|
-
|
|
793
|
+
if (live) recordRequest(target.state, link.workspace, live)
|
|
794
|
+
open = null
|
|
749
795
|
} else stop = notProposer(api.acting, live, 'push to')
|
|
750
796
|
}
|
|
751
797
|
// Following a refusal proposes what was asked: the items named (none: every change) and the releases.
|
|
752
|
-
const again = pushCommand(
|
|
753
|
-
stop ??= notSteward(api.acting, stewardStepUnmet(person, chosen), again,
|
|
754
|
-
const orgNote = needsOrganizationKey(chosen) ? stewardNote(api.acting, false, pushCommand(title ?? null, named, flagString(ctx, 'release'))) : null
|
|
798
|
+
const again = pushCommand(api.acting, open?.title ?? title ?? null, named, flagString(ctx, 'release'))
|
|
799
|
+
stop ??= notSteward(api.acting, stewardStepUnmet(person, chosen), again, open ? 'you' : 'nobody')
|
|
800
|
+
const orgNote = needsOrganizationKey(chosen) ? stewardNote(api.acting, false, pushCommand(api.acting, title ?? null, named, flagString(ctx, 'release'))) : null
|
|
755
801
|
|
|
756
802
|
if (preview) {
|
|
757
803
|
const usedBy = available ? await deletionUsers(api, chosen) : new Map<string, string[]>()
|
|
758
804
|
return {
|
|
759
805
|
data: {
|
|
760
806
|
link,
|
|
807
|
+
target: target.name,
|
|
761
808
|
available,
|
|
762
|
-
request:
|
|
809
|
+
request: open ? { id: open.id, title: open.title, state: open.state, items: open.refs.length, refs: open.refs } : null,
|
|
763
810
|
items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
|
|
764
811
|
counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
|
|
765
812
|
// A push now would be refused before anything is sent: why, and the way out.
|
|
@@ -777,20 +824,25 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
777
824
|
applyPlan: checked.plan,
|
|
778
825
|
needsPerson: person,
|
|
779
826
|
notCarried,
|
|
827
|
+
...(target.name ? { changedThere: changedThere.map((i) => i.ref) } : {}),
|
|
780
828
|
fetchedAt: snapshot.fetchedAt,
|
|
781
829
|
},
|
|
782
|
-
text: previewText(
|
|
830
|
+
text: previewText(target, chosen, open, stop ? `A push now would be refused: ${stop.message}; ${stop.hint}.` : orgNote, available, usedBy, person, mcp?.line, changedThere),
|
|
783
831
|
}
|
|
784
832
|
}
|
|
785
833
|
|
|
786
|
-
if (!available) throw new CliError(`${
|
|
834
|
+
if (!available) throw new CliError(`${targetLabel(target)}’s platform does not take change requests yet`, { code: 'SYNC_NO_CHANGE_REQUESTS', hint: 'make the change on the platform, or ask your administrator to update it' })
|
|
787
835
|
// Named items must each be proposable; with none named, an item the workspace moved past stops the push.
|
|
788
836
|
const stopping = chosen.filter((i) => !i.proposable && (named.length > 0 || i.blocked === 'moved' || i.blocked === 'conflicts'))
|
|
789
837
|
if (stopping.length) {
|
|
790
838
|
const lines = stopping.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)
|
|
791
839
|
throw new CliError(`not proposed: ${plural(stopping.length, 'item')} cannot go yet\n${lines.join('\n')}`, {
|
|
792
840
|
code: 'SYNC_PUSH_BLOCKED',
|
|
793
|
-
hint: stopping.some((i) => i.blocked === 'moved'
|
|
841
|
+
hint: target.name && stopping.some((i) => i.blocked === 'moved')
|
|
842
|
+
? `see what changed there with \`${command('diff <ref>')}\`, then keep the project’s version with \`${command('resolve <ref> --keep mine')}\`, or name only the items to propose`
|
|
843
|
+
: target.name && stopping.some((i) => i.blocked === 'conflicts')
|
|
844
|
+
? 'resolve the conflicts from the link’s pull first (`frontera local sync resolve <ref>`), or name only the items to propose'
|
|
845
|
+
: stopping.some((i) => i.blocked === 'moved' || i.blocked === 'conflicts') ? 'pull and merge them first (`frontera local sync pull <ref>`), or name the items to propose' : `name only items that can be proposed; \`${command('push --preview')}\` lists them`,
|
|
794
846
|
details: { blocked: stopping.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })) },
|
|
795
847
|
})
|
|
796
848
|
}
|
|
@@ -802,10 +854,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
802
854
|
if (held.length) {
|
|
803
855
|
throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
|
|
804
856
|
code: 'SYNC_PUSH_BLOCKED',
|
|
805
|
-
hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with
|
|
857
|
+
hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`${command('push --preview')}\``,
|
|
806
858
|
details: { blocked: held.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })) },
|
|
807
859
|
})
|
|
808
860
|
}
|
|
861
|
+
if (target.name) {
|
|
862
|
+
throw new CliError([`nothing to propose to ${targetLabel(target)}: nothing here differs from what it holds${changedThere.length ? ', other than what changed there' : ''}`, ...changedThereLines(target, changedThere)].join('\n'), {
|
|
863
|
+
code: 'SYNC_NOTHING_TO_PUSH',
|
|
864
|
+
hint: changedThere.length ? `to put the project’s version back over a change made there, \`${command('resolve <ref> --keep mine')}\`` : `change an item in the project first; \`${command('push --preview')}\` lists what would go`,
|
|
865
|
+
})
|
|
866
|
+
}
|
|
809
867
|
throw new CliError(`nothing to propose: nothing ${mcp ? 'a change request carries ' : ''}changed here since the last pull`, {
|
|
810
868
|
code: 'SYNC_NOTHING_TO_PUSH',
|
|
811
869
|
hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
|
|
@@ -813,40 +871,45 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
813
871
|
}
|
|
814
872
|
const description = flagString(ctx, 'description')
|
|
815
873
|
if (stop) throw stop
|
|
816
|
-
if (!
|
|
874
|
+
if (!open && !title) throw new UsageError('give the change request a title', command('push -t "Tighten the refund procedure"'))
|
|
817
875
|
|
|
818
876
|
// Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
|
|
819
877
|
const releases = await releaseItems(api, releaseAsks)
|
|
820
|
-
const keptReleases =
|
|
878
|
+
const keptReleases = open ? (await api.call<View>('GET', `/v1/change-requests/${open.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref) && releasedDeployment(i)).map((i) => releaseItem(i.name, releasedDeployment(i)!)) : []
|
|
821
879
|
// An App's settings pushed now and its release go as one item (X6).
|
|
822
880
|
const items = mergeAppItems([...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases])
|
|
823
881
|
let view: View
|
|
824
882
|
try {
|
|
825
|
-
view =
|
|
826
|
-
? await api.call<View>('PUT', `/v1/change-requests/${
|
|
883
|
+
view = open
|
|
884
|
+
? await api.call<View>('PUT', `/v1/change-requests/${open.id}`, { ...(title ? { title } : {}), ...(description !== undefined ? { description: description || null } : {}), items, replace: true }, { timeoutMs: 120_000 })
|
|
827
885
|
: await api.call<View>('POST', '/v1/change-requests', { title, ...(description ? { description } : {}), items }, { timeoutMs: 120_000 })
|
|
828
886
|
} catch (err) {
|
|
829
887
|
if (err instanceof CliError && err.code === 'SYNC_CHECKS') {
|
|
830
888
|
const checks = checksOf(err.details)
|
|
831
|
-
|
|
889
|
+
const moved = checks.map((c) => movedOnTarget(c, target))
|
|
890
|
+
const there = moved.find((m) => m !== null)
|
|
891
|
+
throw new CliError(`not proposed: the workspace refused ${plural(checks.length, 'check')}\n${checkLines(checks.map((c, n) => ({ ...c, message: moved[n]?.reason ?? c.message })), titles).join('\n')}`, {
|
|
832
892
|
code: 'SYNC_PUSH_REFUSED',
|
|
833
|
-
hint:
|
|
893
|
+
hint: there
|
|
894
|
+
? `${there.fix}, then push again`
|
|
895
|
+
: checks.some((c) => c.code === 'stale_base' || c.code === 'exists' || c.code === 'gone') ? 'fetch and pull the items named, merge them, then push again' : 'fix the items named, then push again',
|
|
834
896
|
details: { checks },
|
|
835
897
|
})
|
|
836
898
|
}
|
|
837
|
-
throw orStoredKey(err, api.acting, again,
|
|
899
|
+
throw orStoredKey(err, api.acting, again, open ? 'you' : 'nobody')
|
|
838
900
|
}
|
|
839
901
|
// A knowledge base's documents follow the request, one at a time (each up to the platform's limit).
|
|
840
902
|
const staged = await stageDocuments(project, api, view)
|
|
841
903
|
if (staged) view = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
|
|
842
904
|
const allTitles = { ...(kept?.titles ?? {}), ...titles }
|
|
843
|
-
recordRequest(
|
|
844
|
-
writeRequestBase(
|
|
905
|
+
recordRequest(target.state, link.workspace, view, { titles: allTitles, pushed: true })
|
|
906
|
+
writeRequestBase(target.state, view.request.id, going)
|
|
845
907
|
const notProposed = chosen.filter((i) => !i.proposable)
|
|
846
908
|
return {
|
|
847
909
|
data: {
|
|
848
910
|
proposed: true,
|
|
849
|
-
|
|
911
|
+
target: target.name,
|
|
912
|
+
created: !open,
|
|
850
913
|
request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
|
|
851
914
|
pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
|
|
852
915
|
releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
|
|
@@ -856,15 +919,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
856
919
|
notCarried,
|
|
857
920
|
},
|
|
858
921
|
text: [
|
|
859
|
-
`${
|
|
922
|
+
`${open ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${targetLabel(target)}: ${plural(view.items.length, 'item')}.`,
|
|
860
923
|
...going.map((i) => ` ${pushItemLine(i)}`),
|
|
861
924
|
...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
|
|
862
925
|
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
|
|
926
|
+
...changedThereLines(target, changedThere),
|
|
863
927
|
...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
|
|
864
|
-
...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(view))] : []),
|
|
928
|
+
...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(api.acting, view))] : []),
|
|
865
929
|
...(mcp ? ['', mcp.line] : []),
|
|
866
930
|
'',
|
|
867
|
-
|
|
931
|
+
`Apply it with \`${command('request apply')}\`; see it with \`${command('request')}\`.`,
|
|
868
932
|
].join('\n'),
|
|
869
933
|
}
|
|
870
934
|
}
|
|
@@ -872,10 +936,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
872
936
|
// ── request ─────────────────────────────────────────────────────────────────
|
|
873
937
|
|
|
874
938
|
/** The base each item was changed from: the copy kept at push, the fetched snapshot, or the platform at that revision. */
|
|
875
|
-
async function basesFor(
|
|
876
|
-
const
|
|
877
|
-
const
|
|
878
|
-
const
|
|
939
|
+
async function basesFor(target: SyncTarget, view: View): Promise<Record<string, { files: Record<string, string>; known: boolean }>> {
|
|
940
|
+
const { link } = target
|
|
941
|
+
const kept = readRequestBase(target.state, view.request.id)
|
|
942
|
+
const snapshot = readSnapshot(target.state)
|
|
943
|
+
const remote = snapshot && snapshot.workspace === link.workspace ? new Map(snapshotItems(target.state, snapshot).map((i) => [i.ref, i])) : new Map()
|
|
879
944
|
const out: Record<string, { files: Record<string, string>; known: boolean }> = {}
|
|
880
945
|
for (const item of view.items) {
|
|
881
946
|
if (!item.baseRevision) {
|
|
@@ -902,7 +967,7 @@ async function basesFor(project: LocalProject, link: SyncLink, view: View): Prom
|
|
|
902
967
|
// Earlier versions the platform keeps: workflows, Functions and the Blueprint by revision.
|
|
903
968
|
if (item.kind === 'agent' || !HISTORY_KINDS.includes(item.kind) || !snapshot?.capabilities.includes('item-revision')) continue
|
|
904
969
|
try {
|
|
905
|
-
const source = await sourceFor(
|
|
970
|
+
const source = await sourceFor(target.owner, link, target.name)
|
|
906
971
|
const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
|
|
907
972
|
const answer = await source.workspace.find<{ ref: string; kind: string; revision: string; content: Record<string, unknown> }>(`/v1/sync/item?ref=${encodeURIComponent(asked)}&revision=${encodeURIComponent(item.baseRevision)}`)
|
|
908
973
|
if (answer) {
|
|
@@ -992,12 +1057,12 @@ function activityOf(view: View, titles: Record<string, string>) {
|
|
|
992
1057
|
})
|
|
993
1058
|
}
|
|
994
1059
|
|
|
995
|
-
async function currentView(
|
|
996
|
-
const kept = currentRequest(readRequests(
|
|
1060
|
+
async function currentView(target: SyncTarget): Promise<{ view: View; titles: Record<string, string>; api: Platform } | null> {
|
|
1061
|
+
const kept = currentRequest(readRequests(target.state, target.link.workspace))
|
|
997
1062
|
if (!kept) return null
|
|
998
|
-
const api = await platformFor(
|
|
1063
|
+
const api = await platformFor(target)
|
|
999
1064
|
const view = await api.call<View>('GET', `/v1/change-requests/${kept.id}`)
|
|
1000
|
-
recordRequest(
|
|
1065
|
+
recordRequest(target.state, target.link.workspace, view)
|
|
1001
1066
|
return { view, titles: kept.titles, api }
|
|
1002
1067
|
}
|
|
1003
1068
|
|
|
@@ -1014,13 +1079,15 @@ function titleOf(item: ViewItem, titles: Record<string, string>): string {
|
|
|
1014
1079
|
|
|
1015
1080
|
async function show(ctx: CommandContext) {
|
|
1016
1081
|
const project = localProject(ctx.cwd)
|
|
1017
|
-
const
|
|
1018
|
-
const
|
|
1082
|
+
const target = chooseTarget(project, ctx)
|
|
1083
|
+
const { link } = target
|
|
1084
|
+
const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
|
|
1085
|
+
const current = await currentView(target)
|
|
1019
1086
|
if (!current) {
|
|
1020
|
-
return { data: { request: null }, text:
|
|
1087
|
+
return { data: { request: null, target: target.name }, text: `No change request yet. Propose what changed here with \`${command('push -t "<title>"')}\`.` }
|
|
1021
1088
|
}
|
|
1022
1089
|
const { view, titles } = current
|
|
1023
|
-
const bases = await basesFor(
|
|
1090
|
+
const bases = await basesFor(target, view)
|
|
1024
1091
|
const checks = lastChecks(view)
|
|
1025
1092
|
const items = view.items.map((item) => {
|
|
1026
1093
|
const base = bases[item.ref]!
|
|
@@ -1059,12 +1126,13 @@ async function show(ctx: CommandContext) {
|
|
|
1059
1126
|
})
|
|
1060
1127
|
const allTitles = Object.fromEntries(items.map((i) => [i.ref, i.title]))
|
|
1061
1128
|
const activity = activityOf(view, allTitles)
|
|
1062
|
-
const orgStored = !!getSyncKey(
|
|
1129
|
+
const orgStored = !!getSyncKey(target.owner, 'organization')
|
|
1063
1130
|
const r = view.request
|
|
1064
1131
|
const { acting } = current.api
|
|
1065
1132
|
const other = isOpen(r.state) ? proposedByOther(acting, view) : null
|
|
1066
1133
|
return {
|
|
1067
1134
|
data: {
|
|
1135
|
+
target: target.name,
|
|
1068
1136
|
request: { id: r.id, title: r.title, description: r.description, state: r.state, stateLabel: STATE_WORD[r.state] ?? r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, mergedAt: r.mergedAt },
|
|
1069
1137
|
workspace: { name: link.workspaceName ?? null, organizationName: link.organizationName ?? null },
|
|
1070
1138
|
items,
|
|
@@ -1075,7 +1143,7 @@ async function show(ctx: CommandContext) {
|
|
|
1075
1143
|
evidence: view.evidence ?? [],
|
|
1076
1144
|
},
|
|
1077
1145
|
text: [
|
|
1078
|
-
`${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${
|
|
1146
|
+
`${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${targetLabel(target)}`,
|
|
1079
1147
|
...(r.description ? [r.description] : []),
|
|
1080
1148
|
'',
|
|
1081
1149
|
...items.flatMap((i, n) => [
|
|
@@ -1090,29 +1158,31 @@ async function show(ctx: CommandContext) {
|
|
|
1090
1158
|
'',
|
|
1091
1159
|
'Activity:',
|
|
1092
1160
|
...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
|
|
1093
|
-
...(isOpen(r.state) && !other && needsOrganizationKey(view.items) ? ['', stewardNote(acting, true, pushAgain(view))] : []),
|
|
1094
|
-
...(other === 'key' ? ['',
|
|
1161
|
+
...(isOpen(r.state) && !other && needsOrganizationKey(view.items) ? ['', stewardNote(acting, true, pushAgain(acting, view))] : []),
|
|
1162
|
+
...(other === 'key' ? ['', `Proposed with ${ownerWords(acting, 'the project')}’s workspace key before you signed in: close it (\`${command('request close')}\`) and push again to apply it as yourself.`] : []),
|
|
1095
1163
|
...(other === 'person' ? ['', `Proposed by a person signed in, and no one is signed in on this computer now: sign in again with \`${signInCommand(acting.profile)}\` to apply it.`] : []),
|
|
1096
|
-
...(isOpen(r.state) && !other ? ['',
|
|
1164
|
+
...(isOpen(r.state) && !other ? ['', `Apply it with \`${command('request apply')}\`, or close it with \`${targetWords(target, 'request close')}\`.`] : []),
|
|
1097
1165
|
].join('\n'),
|
|
1098
1166
|
}
|
|
1099
1167
|
}
|
|
1100
1168
|
|
|
1101
1169
|
async function apply(ctx: CommandContext) {
|
|
1102
1170
|
const project = localProject(ctx.cwd)
|
|
1103
|
-
const
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1171
|
+
const target = chooseTarget(project, ctx)
|
|
1172
|
+
const { link } = target
|
|
1173
|
+
const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
|
|
1174
|
+
const current = await currentView(target)
|
|
1175
|
+
if (!current) throw new UsageError(`no change request to apply${target.name ? ` to target ${target.name}` : ''}`, `propose one with \`${command('push -t "<title>"')}\``)
|
|
1106
1176
|
const { view, titles, api } = current
|
|
1107
1177
|
const { acting } = api
|
|
1108
|
-
if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint:
|
|
1109
|
-
if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint:
|
|
1178
|
+
if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: `propose new changes with \`${command('push -t "<title>"')}\`` })
|
|
1179
|
+
if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: `propose the changes again with \`${command('push -t "<title>"')}\`` })
|
|
1110
1180
|
const proposed: ProposedBy = proposedByOther(acting, view) === 'key' ? 'key' : 'you'
|
|
1111
1181
|
if (acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')) {
|
|
1112
1182
|
// The platform refuses a person who is no Steward only once Apply runs, so ask its checks, with every item so references resolve.
|
|
1113
1183
|
const items = view.items.map((i) => ({ ref: i.ref, files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
|
|
1114
1184
|
const plan = await api.call<unknown>('POST', '/v1/change-requests/check', { items }, { timeoutMs: 120_000 }).then(planOf, () => [])
|
|
1115
|
-
const stop = notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(view), proposed)
|
|
1185
|
+
const stop = notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(acting, view), proposed)
|
|
1116
1186
|
if (stop) throw stop
|
|
1117
1187
|
}
|
|
1118
1188
|
const refused = notProposer(acting, view, 'apply')
|
|
@@ -1123,7 +1193,7 @@ async function apply(ctx: CommandContext) {
|
|
|
1123
1193
|
// With the keys, sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
|
|
1124
1194
|
const organizationKey = organizationKeyOf(acting)
|
|
1125
1195
|
if (blueprint && acting.as === 'keys' && !organizationKey) {
|
|
1126
|
-
throw new CliError(
|
|
1196
|
+
throw new CliError(`this change request changes the Blueprint, which a Blueprint Steward applies as themself, and no one is signed in for ${ownerWords(acting, 'this project')}’s workspace on this computer`, { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in with \`${signInCommand(acting.profile)}\`, then close this change request (\`${command('request close')}\`) and push it again, so it is yours to apply` })
|
|
1127
1197
|
}
|
|
1128
1198
|
const migration = flagString(ctx, 'migration')?.trim()
|
|
1129
1199
|
const force = flagBool(ctx, 'force')
|
|
@@ -1149,22 +1219,22 @@ async function apply(ctx: CommandContext) {
|
|
|
1149
1219
|
})
|
|
1150
1220
|
}
|
|
1151
1221
|
if (refused && err instanceof CliError && err.code === 'FORBIDDEN') throw refused
|
|
1152
|
-
throw orStoredKey(err, acting, pushAgain(view), proposed)
|
|
1222
|
+
throw orStoredKey(err, acting, pushAgain(acting, view), proposed)
|
|
1153
1223
|
}
|
|
1154
1224
|
const outcome = parseOutcome(answer.outcome)
|
|
1155
|
-
recordRequest(
|
|
1225
|
+
recordRequest(target.state, link.workspace, answer, { applied: { ok: outcome.ok } })
|
|
1156
1226
|
if (!outcome.ok) {
|
|
1157
1227
|
// Fetch, so the project sees the workspace as it was put back. The request itself applies again as it is:
|
|
1158
1228
|
// the platform takes each put-back item's new revision as its base.
|
|
1159
|
-
await
|
|
1229
|
+
await fetchTarget(project, target).catch(() => null)
|
|
1160
1230
|
// When the checks could not be read before sending, the Steward refusal comes back as the failed step.
|
|
1161
1231
|
const stewardFailed = /Blueprint Steward assignment required/.test(outcome.failure?.message ?? '')
|
|
1162
|
-
const steward = notSteward(acting, stewardFailed, pushAgain(view), proposed)
|
|
1232
|
+
const steward = notSteward(acting, stewardFailed, pushAgain(acting, view), proposed)
|
|
1163
1233
|
// With no organization key stored, applying again fails the same way.
|
|
1164
|
-
const noKey = stewardFailed ?
|
|
1234
|
+
const noKey = stewardFailed ? `ask a Blueprint Steward who is a workspace admin to approve it and apply it; or store ${ownerWords(acting, 'the project')}’s organization key (\`${command('connect --workspace-key - --org-key -')}\`), then apply again (\`${command('request apply')}\`) and follow the steps it prints` : null
|
|
1165
1235
|
throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
|
|
1166
1236
|
code: 'SYNC_APPLY_FAILED',
|
|
1167
|
-
hint: steward?.hint ?? noKey ?? (/migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' :
|
|
1237
|
+
hint: steward?.hint ?? noKey ?? (/migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' : `once what failed is fixed, apply again (\`${command('request apply')}\`); a fix in the project’s files is pushed first (\`${command('push')}\`)`),
|
|
1168
1238
|
details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: failedName(outcome, allTitles), message: outcome.failure.message } : null },
|
|
1169
1239
|
})
|
|
1170
1240
|
}
|
|
@@ -1173,40 +1243,45 @@ async function apply(ctx: CommandContext) {
|
|
|
1173
1243
|
try {
|
|
1174
1244
|
// A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
|
|
1175
1245
|
const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
|
|
1176
|
-
const result = await
|
|
1246
|
+
const result = await fetchTarget(project, target, { full: deleted.length > 0 })
|
|
1177
1247
|
// A Custom App release moved a pointer; the App's own sync item (its description) is not the project's to adopt.
|
|
1178
1248
|
const adoptable = answer.items.filter((i) => i.kind !== 'app').map((i) => i.ref)
|
|
1179
|
-
|
|
1249
|
+
// A named target's base is its own (`target.state`), so the next push to it reads what changed there since.
|
|
1250
|
+
const { adopted } = adoptApplied(target.state, link.workspace, adoptable, snapshotItems(target.state, result.snapshot), readLock(target.state), new Date(), { deleted })
|
|
1180
1251
|
// An Agent's lessons were proposed for review: not proposed again; the next pull brings what reviewers approve.
|
|
1181
|
-
adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
|
|
1252
|
+
if (!target.name) adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
|
|
1182
1253
|
fetched = { changed: result.changed.length, adopted, error: null }
|
|
1183
1254
|
} catch (err) {
|
|
1184
1255
|
fetched.error = (err as Error).message
|
|
1185
1256
|
}
|
|
1186
1257
|
// Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
|
|
1187
|
-
await recordAppliedDocuments(
|
|
1258
|
+
await recordAppliedDocuments(target, api, answer).catch(() => null)
|
|
1188
1259
|
// The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
|
|
1189
1260
|
recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
|
|
1190
1261
|
// What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
|
|
1191
1262
|
const after = afterApplyOf(answer.outcome)
|
|
1192
1263
|
// The steps change the workspace the request went to, so they name it: the folder is bound to its local runtime.
|
|
1193
|
-
const {
|
|
1194
|
-
? await registerTargetProfile(
|
|
1195
|
-
: {
|
|
1196
|
-
|
|
1197
|
-
const
|
|
1264
|
+
const { profile, profileError } = after.length
|
|
1265
|
+
? await registerTargetProfile(target.owner, link).then((name) => ({ profile: name, profileError: null }), (err: Error) => ({ profile: null, profileError: err.message }))
|
|
1266
|
+
: { profile: null, profileError: null }
|
|
1267
|
+
// A named target's steps are a delivery of their own: the same request title applied to the link keeps its steps.
|
|
1268
|
+
const delivery = target.name ? `${answer.request.title} · ${target.name}` : answer.request.title
|
|
1269
|
+
const pending = recordAfterApply(project.root, delivery, after, profile ? { profile, target: targetOf(link) } : null)
|
|
1270
|
+
const signIn = profile && pending.length ? await signInStep({ profile, apiUrl: link.platform, named: true }) : null
|
|
1198
1271
|
return {
|
|
1199
|
-
data: { applied: true, as: { signedIn: acting.as === 'person', email: acting.as === 'person' ? acting.email : null, profile: acting.profile }, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
|
|
1272
|
+
data: { applied: true, target: target.name, as: { signedIn: acting.as === 'person', email: acting.as === 'person' ? acting.email : null, profile: acting.profile }, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
|
|
1200
1273
|
text: [
|
|
1201
|
-
`Applied “${answer.request.title}” to ${
|
|
1274
|
+
`Applied “${answer.request.title}” to ${targetLabel(target)}${acting.as === 'person' ? ` as ${acting.email ?? 'you'} (signed in)` : ''}.`,
|
|
1202
1275
|
...describeOutcome(outcome, allTitles),
|
|
1203
1276
|
'',
|
|
1204
|
-
fetched.error
|
|
1277
|
+
fetched.error
|
|
1278
|
+
? `Could not fetch afterwards: ${fetched.error}.${target.name ? '' : ' Run `frontera local sync fetch`.'}`
|
|
1279
|
+
: target.name ? `Fetched ${targetLabel(target)}: the next push to it compares with what this Apply left there.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
|
|
1205
1280
|
...(pending.length
|
|
1206
1281
|
? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
|
|
1207
|
-
|
|
1208
|
-
? `They run against ${new URL(link.platform).host} with \`--profile ${
|
|
1209
|
-
: `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${
|
|
1282
|
+
profile
|
|
1283
|
+
? `They run against ${new URL(link.platform).host} with \`--profile ${profile}\` (${getSyncKey(target.owner, 'organization') ? (target.name ? `target ${target.name}’s keys` : "this project's keys") : 'as the person signed in on it'}), not the local runtime${signIn ? `; a step a signed-in person takes needs \`${signIn}\` first` : ''}.`
|
|
1284
|
+
: `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${profileError ? ` (${profileError})` : ''}. Add \`--profile <a profile for that platform>\` when you run them.`,
|
|
1210
1285
|
'Forge lists them on the project’s home; `frontera local steps` here.']
|
|
1211
1286
|
: []),
|
|
1212
1287
|
].join('\n'),
|
|
@@ -1218,11 +1293,12 @@ async function apply(ctx: CommandContext) {
|
|
|
1218
1293
|
* workspace's new copy (the newest document of that name), and each taken off
|
|
1219
1294
|
* is forgotten, so the next push sees them as unchanged.
|
|
1220
1295
|
*/
|
|
1221
|
-
async function recordAppliedDocuments(
|
|
1296
|
+
async function recordAppliedDocuments(target: SyncTarget, api: Platform, view: View): Promise<void> {
|
|
1297
|
+
const { link } = target
|
|
1222
1298
|
const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
|
|
1223
1299
|
if (!items.length) return
|
|
1224
|
-
const snapshot = readSnapshot(
|
|
1225
|
-
const remote = snapshot ? snapshotItems(
|
|
1300
|
+
const snapshot = readSnapshot(target.state)
|
|
1301
|
+
const remote = snapshot ? snapshotItems(target.state, snapshot) : []
|
|
1226
1302
|
for (const item of items) {
|
|
1227
1303
|
const id = remote.find((r) => r.ref === item.ref)?.id
|
|
1228
1304
|
if (!id) continue
|
|
@@ -1237,28 +1313,29 @@ async function recordAppliedDocuments(project: LocalProject, link: SyncLink, api
|
|
|
1237
1313
|
if (newest?.id) entries[path] = { sourceId: newest.id, sha256: d.sha256, bytes: d.bytes, at: new Date().toISOString() }
|
|
1238
1314
|
}
|
|
1239
1315
|
// A document taken off is forgotten by the copy it was, wherever it was filed.
|
|
1240
|
-
const pulled = readPulledDocuments(
|
|
1316
|
+
const pulled = readPulledDocuments(target.state, link.workspace).bases[item.name] ?? {}
|
|
1241
1317
|
for (const d of item.documents!.remove) {
|
|
1242
1318
|
const at = Object.entries(pulled).find(([, was]) => was.sourceId === d.sourceId)?.[0]
|
|
1243
1319
|
entries[at ?? d.fileName] = null
|
|
1244
1320
|
}
|
|
1245
|
-
recordPulledDocuments(
|
|
1321
|
+
recordPulledDocuments(target.state, link.workspace, item.name, entries)
|
|
1246
1322
|
}
|
|
1247
1323
|
}
|
|
1248
1324
|
|
|
1249
1325
|
async function close(ctx: CommandContext) {
|
|
1250
1326
|
const project = localProject(ctx.cwd)
|
|
1251
|
-
const
|
|
1252
|
-
const kept = currentRequest(readRequests(
|
|
1253
|
-
if (!kept || !isOpen(kept.state)) throw new UsageError(
|
|
1254
|
-
const current = (await currentView(
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
const
|
|
1327
|
+
const target = chooseTarget(project, ctx)
|
|
1328
|
+
const kept = currentRequest(readRequests(target.state, target.link.workspace))
|
|
1329
|
+
if (!kept || !isOpen(kept.state)) throw new UsageError(`no open change request to close${target.name ? ` on target ${target.name}` : ''}`, `see where it stands with \`${targetWords(target, 'frontera local sync request')}\``)
|
|
1330
|
+
const current = (await currentView(target))!
|
|
1331
|
+
const { acting } = current.api
|
|
1332
|
+
// Closing writes nothing to the workspace, so a request the key proposed is closed with that key.
|
|
1333
|
+
const byKey = proposedByOther(acting, current.view) === 'key'
|
|
1334
|
+
const refused = byKey ? null : notProposer(acting, current.view, 'close')
|
|
1258
1335
|
if (refused) throw refused
|
|
1259
|
-
const view = await (byKey ? keysFor(
|
|
1260
|
-
recordRequest(
|
|
1261
|
-
return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”${
|
|
1336
|
+
const view = await (byKey ? keysFor(target) : current.api).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
|
|
1337
|
+
recordRequest(target.state, target.link.workspace, view)
|
|
1338
|
+
return { data: { closed: true, target: target.name, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”${target.name ? ` on ${targetLabel(target)}` : ''}${byKey ? ` with ${ownerWords(acting, 'the project')}’s workspace key that proposed it` : ''}. The project’s files are unchanged; the next push opens a new change request.` }
|
|
1262
1339
|
}
|
|
1263
1340
|
|
|
1264
1341
|
/**
|
|
@@ -1271,11 +1348,11 @@ async function close(ctx: CommandContext) {
|
|
|
1271
1348
|
*/
|
|
1272
1349
|
async function evidence(ctx: CommandContext) {
|
|
1273
1350
|
const project = localProject(ctx.cwd)
|
|
1274
|
-
const
|
|
1275
|
-
const current = await currentView(
|
|
1276
|
-
if (!current) throw new UsageError('no change request to attach evidence to',
|
|
1351
|
+
const target = chooseTarget(project, ctx)
|
|
1352
|
+
const current = await currentView(target)
|
|
1353
|
+
if (!current) throw new UsageError('no change request to attach evidence to', `propose one with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\``)
|
|
1277
1354
|
const { view, titles, api } = current
|
|
1278
|
-
if (!isOpen(view.request.state)) throw new CliError(`“${view.request.title}” is ${STATE_WORD[view.request.state] ?? view.request.state}; it takes no evidence`, { code: 'CONFLICT', hint:
|
|
1355
|
+
if (!isOpen(view.request.state)) throw new CliError(`“${view.request.title}” is ${STATE_WORD[view.request.state] ?? view.request.state}; it takes no evidence`, { code: 'CONFLICT', hint: `propose new changes with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\`` })
|
|
1279
1356
|
const refused = notProposer(api.acting, view, 'attach evidence to')
|
|
1280
1357
|
if (refused) throw refused
|
|
1281
1358
|
const session = flagString(ctx, 'screenshots') ?? process.env.FORGE_SESSION ?? null
|
|
@@ -1366,7 +1443,7 @@ async function evidence(ctx: CommandContext) {
|
|
|
1366
1443
|
}
|
|
1367
1444
|
|
|
1368
1445
|
const after = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
|
|
1369
|
-
recordRequest(
|
|
1446
|
+
recordRequest(target.state, target.link.workspace, after)
|
|
1370
1447
|
return {
|
|
1371
1448
|
data: { attached, skipped, gate: after.gate ?? null },
|
|
1372
1449
|
text: [
|
|
@@ -1383,17 +1460,17 @@ const PICTURE_EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg':
|
|
|
1383
1460
|
/** One evidence picture, kept on this machine (in the runtime's state folder) for Forge to show. */
|
|
1384
1461
|
async function evidenceImage(ctx: CommandContext) {
|
|
1385
1462
|
const project = localProject(ctx.cwd)
|
|
1386
|
-
const
|
|
1463
|
+
const target = chooseTarget(project, ctx)
|
|
1387
1464
|
const id = ctx.positional[2]
|
|
1388
1465
|
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw new UsageError('say which evidence picture', 'frontera local sync request evidence-image <evidence id>')
|
|
1389
|
-
const kept = currentRequest(readRequests(
|
|
1390
|
-
if (!kept) throw new UsageError('no change request',
|
|
1466
|
+
const kept = currentRequest(readRequests(target.state, target.link.workspace))
|
|
1467
|
+
if (!kept) throw new UsageError('no change request', `propose one with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\``)
|
|
1391
1468
|
const dir = join(project.stateDir, 'evidence', kept.id)
|
|
1392
1469
|
for (const ext of Object.values(PICTURE_EXT)) {
|
|
1393
1470
|
const path = join(dir, `${id}.${ext}`)
|
|
1394
1471
|
if (existsSync(path)) return { data: { path, mime: Object.entries(PICTURE_EXT).find(([, e]) => e === ext)![0] }, text: path }
|
|
1395
1472
|
}
|
|
1396
|
-
const { bytes, mime } = await (await platformFor(
|
|
1473
|
+
const { bytes, mime } = await (await platformFor(target)).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
|
|
1397
1474
|
const ext = PICTURE_EXT[mime.split(';')[0]!.trim()]
|
|
1398
1475
|
if (!ext) throw new CliError('the platform did not answer with a picture', { code: 'UNAVAILABLE', hint: 'run the command again' })
|
|
1399
1476
|
mkdirSync(dir, { recursive: true })
|