@frontera-sdk/cli 1.53.8 → 1.53.9
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/api/governed-action-api.ts +9 -3
- package/src/blueprint/action-rules.ts +175 -9
- package/src/blueprint/diff.ts +27 -8
- package/src/blueprint/ontology-edit-plan.ts +155 -4
- package/src/blueprint/org-name-check.ts +10 -8
- package/src/blueprint/projection.ts +13 -3
- package/src/blueprint/property-intents.ts +248 -0
- package/src/blueprint/publish-plan.ts +40 -4
- package/src/blueprint/render.ts +4 -0
- package/src/blueprint/tree.ts +22 -0
- package/src/commands/action/deploy.ts +31 -24
- package/src/commands/action/retire.ts +9 -1
- package/src/commands/action/submit.ts +21 -5
- package/src/commands/auth/login.ts +2 -2
- package/src/commands/blueprint/authoring.ts +22 -4
- package/src/commands/blueprint/declarative.ts +140 -32
- package/src/commands/knowledge/index-commands.ts +1 -1
- package/src/commands/local/check.ts +6 -4
- package/src/commands/local/index-commands.ts +8 -2
- package/src/commands/local/sync-request.ts +252 -66
- package/src/commands/local/sync.ts +29 -24
- package/src/device-login.ts +6 -1
- package/src/local/local-actions.ts +13 -1
- package/src/local/local-applied.ts +1 -1
- package/src/local/local-blueprint-schemas.ts +40 -6
- package/src/local/local-dev-references.ts +4 -3
- package/src/local/local-jobs.ts +28 -0
- package/src/local/local-portability.ts +1 -1
- package/src/local/local-sync-link.ts +62 -4
- package/src/local/local-sync-merge.ts +37 -1
- package/src/local/local-sync-plan.ts +44 -6
- package/src/local/local-sync-precheck.ts +2 -1
- package/src/local/local-sync-push.ts +22 -11
- package/src/local/local-sync-remote.ts +13 -8
- package/src/local/local-synthetic-data.ts +2 -2
- package/src/main.ts +7 -2
- package/src/vendor/sdk-sources.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { signInStep } from '../../device-login'
|
|
4
|
+
import { signInCommand, signInStep } from '../../device-login'
|
|
5
5
|
import { CliError, UsageError } from '../../errors'
|
|
6
6
|
import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
|
|
7
7
|
import { assetFilename } from '../skill/bundle-commands'
|
|
@@ -13,7 +13,7 @@ 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, type SyncLink } from '../../local/local-sync-link'
|
|
16
|
+
import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetOf, targetProfileName, targetSession, type SyncLink } from '../../local/local-sync-link'
|
|
17
17
|
import { itemTitle } from '../../local/local-sync-names'
|
|
18
18
|
import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
|
|
19
19
|
import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
|
|
@@ -72,9 +72,10 @@ import { fetchFor, planFor, sourceFor } from './sync'
|
|
|
72
72
|
* push --release desk=1.4.0 also release a Custom App: Apply promotes that deployment, last
|
|
73
73
|
*
|
|
74
74
|
* The request's id is kept in `.frontera/sync/requests.json` (not
|
|
75
|
-
* committed).
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* committed). The calls act as the person signed in for the project's
|
|
76
|
+
* target profile (`frontera auth login --browser --profile <target>`), or
|
|
77
|
+
* else with the project's keys from the keychain, as for `sync fetch`: the
|
|
78
|
+
* workspace key, and the organization key beside it when one is stored.
|
|
78
79
|
*/
|
|
79
80
|
|
|
80
81
|
const ORGANIZATION_KEY_HEADER = 'x-frontera-organization-key'
|
|
@@ -91,7 +92,18 @@ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
|
91
92
|
|
|
92
93
|
type RequestBody = NonNullable<RequestInit['body']>
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Who a command's change-request calls act as: the person signed in for the
|
|
97
|
+
* target, or else the project's keys. One for the whole command: a person
|
|
98
|
+
* sends no key with their session, so a Blueprint change applied by a person
|
|
99
|
+
* is that person's alone.
|
|
100
|
+
*/
|
|
101
|
+
type Acting =
|
|
102
|
+
| { as: 'person'; profile: string; token: string; email: string | null; organizationKeyStored: boolean }
|
|
103
|
+
| { as: 'keys'; profile: string; workspaceKey: string; organizationKey: string | null }
|
|
104
|
+
|
|
94
105
|
interface Platform {
|
|
106
|
+
acting: Acting
|
|
95
107
|
call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts?: { organizationKey?: string | null; timeoutMs?: number }): Promise<T>
|
|
96
108
|
/** A body sent as it is (a document's bytes, a multipart upload), not as JSON. */
|
|
97
109
|
send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts?: { timeoutMs?: number }): Promise<T>
|
|
@@ -100,21 +112,24 @@ interface Platform {
|
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
/**
|
|
103
|
-
* The change-request routes
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* read.
|
|
115
|
+
* The change-request routes as `acting`: a person's session names the
|
|
116
|
+
* linked workspace, a workspace key carries it. A refusal by the blocking
|
|
117
|
+
* checks keeps them (`details.checks`), so the caller can say which item
|
|
118
|
+
* each names; Apply may take minutes, so it waits longer than a read.
|
|
107
119
|
*/
|
|
108
|
-
function platform(link: SyncLink,
|
|
120
|
+
function platform(link: SyncLink, acting: Acting): Platform {
|
|
109
121
|
const origin = normalizeOrigin(link.platform)
|
|
122
|
+
const credential: Record<string, string> = acting.as === 'person' ? { authorization: `Bearer ${acting.token}`, 'x-workspace-id': link.workspace } : { authorization: `Bearer ${acting.workspaceKey}` }
|
|
123
|
+
const answer = <T>(res: Response) => answerAs<T>(res, acting)
|
|
110
124
|
const request = async (method: string, path: string, headers: Record<string, string>, body: RequestBody | undefined, timeoutMs: number): Promise<Response> => {
|
|
111
125
|
try {
|
|
112
|
-
return await fetch(new URL(path, origin), { method, headers: {
|
|
126
|
+
return await fetch(new URL(path, origin), { method, headers: { ...credential, accept: 'application/json', ...headers }, body, signal: AbortSignal.timeout(timeoutMs) })
|
|
113
127
|
} catch (err) {
|
|
114
128
|
throw new CliError(`${origin} did not answer: ${(err as Error).message}`, { code: 'UNAVAILABLE', hint: 'check the address and your network, then run the command again' })
|
|
115
129
|
}
|
|
116
130
|
}
|
|
117
131
|
return {
|
|
132
|
+
acting,
|
|
118
133
|
async call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts: { organizationKey?: string | null; timeoutMs?: number } = {}): Promise<T> {
|
|
119
134
|
const res = await request(method, path, {
|
|
120
135
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
@@ -134,7 +149,7 @@ function platform(link: SyncLink, key: string): Platform {
|
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
/** A platform answer: its data, or a refusal in words (the checks it names kept in `details`). */
|
|
137
|
-
async function
|
|
152
|
+
async function answerAs<T>(res: Response, acting: Acting): Promise<T> {
|
|
138
153
|
const text = await res.text().catch(() => '')
|
|
139
154
|
let parsed: Record<string, unknown> | null = null
|
|
140
155
|
try {
|
|
@@ -150,18 +165,153 @@ async function answer<T>(res: Response): Promise<T> {
|
|
|
150
165
|
if (details?.code === 'GATE_FAILED' && details.gate) {
|
|
151
166
|
throw new CliError(message, { code: 'SYNC_APPLY_GATED', hint: 'make the checks pass (`frontera local sync request evidence` attaches evals and renders), or a workspace admin applies with `--force --reason "<why>"`', details: { gate: details.gate } })
|
|
152
167
|
}
|
|
168
|
+
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)}\`` })
|
|
153
169
|
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; `frontera local sync connect` stores a new one' })
|
|
154
|
-
if (res.status === 403) throw new CliError(message, { code: 'FORBIDDEN', hint: 'the key’s role decides what it may propose and apply; ask a workspace admin' })
|
|
170
|
+
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' })
|
|
155
171
|
if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint: 'the change request is gone from the workspace; propose again with `frontera local sync push`' })
|
|
156
172
|
if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint: 'see where it stands with `frontera local sync request`' })
|
|
157
173
|
if (res.status === 400 || res.status === 422) throw new CliError(message, { code: 'VALIDATION_ERROR', hint: 'see where it stands with `frontera local sync request`' })
|
|
158
174
|
throw new CliError(`the platform answered ${res.status}: ${message}`, { code: 'UNAVAILABLE', hint: 'check the platform is reachable, then run the command again' })
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
177
|
+
/** The person signed in for the target when they are a member of the linked workspace, else the project's keys. */
|
|
178
|
+
async function platformFor(project: LocalProject, link: SyncLink): Promise<Platform> {
|
|
179
|
+
const person = await targetSession(project, link)
|
|
180
|
+
if (!person) return keysFor(project, link)
|
|
181
|
+
const profile = targetProfileName(project, link)
|
|
182
|
+
const api = platform(link, { as: 'person', profile, token: person.token, email: person.email ?? null, organizationKeyStored: !!getSyncKey(project, 'organization') })
|
|
183
|
+
// The platform drops the workspace from a session that isn't a member's, and every change-request call then fails.
|
|
184
|
+
const me = await api.call<{ workspaceId?: string | null }>('GET', '/v1/whoami')
|
|
185
|
+
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 ${link.workspaceName ?? link.workspace}`, { 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
|
+
}
|
|
188
|
+
return api
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function keysFor(project: LocalProject, link: SyncLink): Platform {
|
|
192
|
+
const workspaceKey = getSyncKey(project, 'workspace')
|
|
193
|
+
if (!workspaceKey) throw new CliError('no workspace key for this project on this machine', { code: 'SYNC_NO_KEY', hint: 'run `frontera local sync connect` to store one (keys are per machine)' })
|
|
194
|
+
return platform(link, { as: 'keys', profile: targetProfileName(project, link), workspaceKey, organizationKey: getSyncKey(project, 'organization') })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A request takes pushes, evidence, close and Apply from the one who proposed
|
|
199
|
+
* it (a workspace admin aside), so a request is another's when `acting` did
|
|
200
|
+
* not propose it: `key` when the project's workspace key did and a person
|
|
201
|
+
* acts now, `person` when a person did and the keys act now. Null when it is
|
|
202
|
+
* `acting`'s, or the view does not say.
|
|
203
|
+
*/
|
|
204
|
+
function proposedByOther(acting: Acting, view: View): 'key' | 'person' | null {
|
|
205
|
+
const author = view.request.authorId
|
|
206
|
+
if (typeof author !== 'string') return null
|
|
207
|
+
if (acting.as === 'person') return author.startsWith('wskey:') ? 'key' : null
|
|
208
|
+
return /^(wskey|orgkey|apikey):/.test(author) ? null : 'person'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The refusal, with the way out, for a command on a request `acting` did not propose; null when it did. */
|
|
212
|
+
function notProposer(acting: Acting, view: View, doing: 'apply' | 'push to' | 'close' | 'attach evidence to'): CliError | null {
|
|
213
|
+
const by = proposedByOther(acting, view)
|
|
214
|
+
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` })
|
|
216
|
+
if (by !== 'key' || acting.as !== 'person') return null
|
|
217
|
+
const closeIt = 'close it (`frontera local sync request close` closes it with that key)'
|
|
218
|
+
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}`, {
|
|
220
|
+
code: 'SYNC_PROPOSED_WITH_KEY',
|
|
221
|
+
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` : ''}`
|
|
223
|
+
: `${closeIt}, then push again to propose the changes as yourself`,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Who proposed the change request a step is for: nobody yet, the person signed in, or the project's workspace key. */
|
|
228
|
+
type ProposedBy = 'nobody' | 'you' | 'key'
|
|
229
|
+
|
|
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 ['frontera local sync push', ...refs, ...(releases ? [`--release ${releases}`] : []), `-t "${title ?? '<title>'}"`].join(' ')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 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 {
|
|
237
|
+
const release = (i: ViewItem) => (i.kind === 'app' ? releasedDeployment(i) : null)
|
|
238
|
+
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(','))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* How a person signed in goes with the organization key stored instead. The
|
|
244
|
+
* keys apply only a request they proposed, so the person's own request is
|
|
245
|
+
* closed first and pushed again (`push`, the command that proposes its items);
|
|
246
|
+
* the key's is applied as it is.
|
|
247
|
+
*/
|
|
248
|
+
function withStoredKey(acting: Extract<Acting, { as: 'person' }>, push: string, proposed: ProposedBy): string {
|
|
249
|
+
const signOut = `sign out (\`frontera auth logout --profile ${acting.profile}\`)`
|
|
250
|
+
const apply = 'apply (`frontera local sync request apply`)'
|
|
251
|
+
if (proposed === 'key') return `${signOut}, then ${apply}`
|
|
252
|
+
const pushIt = `push${proposed === 'you' ? ' it again' : ''} (\`${push}\`) and ${apply}`
|
|
253
|
+
return proposed === 'you' ? `close it (\`frontera local sync request close\`), ${signOut}, then ${pushIt}` : `${signOut}, then ${pushIt}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A refusal of the person's role, with the way to act with the organization key stored instead. */
|
|
257
|
+
function orStoredKey(err: unknown, acting: Acting, push: string, proposed: ProposedBy): unknown {
|
|
258
|
+
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` })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether Apply would stop at a step of these items that a Blueprint Steward
|
|
264
|
+
* makes and the checks' principal can't. The plan's `blueprint` step stands
|
|
265
|
+
* for every Blueprint file but a saved object set, whose own step an
|
|
266
|
+
* organization admin takes too. A new Agent's grants wait until after Apply.
|
|
267
|
+
*/
|
|
268
|
+
function stewardStepUnmet(person: ReturnType<typeof needsAPerson>, items: Array<{ ref: string; kind: string }>): boolean {
|
|
269
|
+
return person.some((p) => (p.code === 'blueprint_needs_steward' || p.code === 'grant_needs_steward') && !p.after && items.some((i) => i.ref === p.ref || (p.ref === 'blueprint' && i.kind === 'blueprint' && !i.ref.startsWith('blueprint/object-sets/'))))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The refusal of Blueprint changes and grants the person signed in could not
|
|
274
|
+
* apply (`unmet`): with an organization key stored, the keys propose and
|
|
275
|
+
* apply them instead.
|
|
276
|
+
*/
|
|
277
|
+
function notSteward(acting: Acting, unmet: boolean, push: string, proposed: ProposedBy): CliError | null {
|
|
278
|
+
if (acting.as !== 'person' || !acting.organizationKeyStored || !unmet) return null
|
|
279
|
+
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
|
+
code: 'SYNC_NOT_A_STEWARD',
|
|
281
|
+
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`,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The organization key a command sends beside the workspace key: none when a person acts. */
|
|
288
|
+
const organizationKeyOf = (acting: Acting) => (acting.as === 'keys' ? acting.organizationKey : null)
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* What Forge reads about who applies Blueprint changes: an organization key
|
|
292
|
+
* is needed only when no person is signed in, and `signIn` is the step that
|
|
293
|
+
* makes it unneeded.
|
|
294
|
+
*/
|
|
295
|
+
function stewardData(acting: Acting, blueprint: boolean, stored: boolean) {
|
|
296
|
+
return {
|
|
297
|
+
organizationKey: { needed: blueprint && acting.as === 'keys', stored },
|
|
298
|
+
signIn: blueprint && acting.as === 'keys' && !acting.organizationKey ? signInCommand(acting.profile) : null,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Who applies the request's Blueprint changes, in words, for the preview
|
|
304
|
+
* (`proposed` false: nothing is proposed yet), the push and the request. A
|
|
305
|
+
* request the keys propose stays the keys', so the sign-in comes before the
|
|
306
|
+
* push.
|
|
307
|
+
*/
|
|
308
|
+
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 'Blueprint changes are applied with the organization key stored for this project.'
|
|
311
|
+
const signIn = `\`${signInCommand(acting.profile)}\``
|
|
312
|
+
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 (\`frontera local sync request close\`) and push again.`
|
|
314
|
+
: `Blueprint changes are applied by a Blueprint Steward, signed in: sign in before you push, so the change request is yours to apply (${signIn}).`
|
|
165
315
|
}
|
|
166
316
|
|
|
167
317
|
// ── The platform's view of a request ────────────────────────────────────────
|
|
@@ -422,11 +572,11 @@ async function platformDocuments(api: Platform, names: string[], remote: Array<{
|
|
|
422
572
|
* own in-use check before anything is proposed, so a deletion names its
|
|
423
573
|
* dependents first. An older platform without the read says nothing.
|
|
424
574
|
*/
|
|
425
|
-
async function deletionUsers(
|
|
575
|
+
async function deletionUsers(api: Platform, items: PushItem[]): Promise<Map<string, string[]>> {
|
|
426
576
|
const refs = items.filter((i) => i.change === 'deleted' && i.proposable && i.kind !== 'blueprint').map((i) => i.ref)
|
|
427
577
|
if (!refs.length) return new Map()
|
|
428
578
|
try {
|
|
429
|
-
const answer = await
|
|
579
|
+
const answer = await api.call<{ items: Array<{ ref: string; usedBy: string[] }> }>('POST', '/v1/change-requests/usage', { refs })
|
|
430
580
|
return new Map((answer.items ?? []).filter((u) => u.usedBy.length).map((u) => [u.ref, u.usedBy]))
|
|
431
581
|
} catch {
|
|
432
582
|
return new Map()
|
|
@@ -535,7 +685,7 @@ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown
|
|
|
535
685
|
return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
|
|
536
686
|
}
|
|
537
687
|
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`)
|
|
538
|
-
const offered = await workspaceModels(platformFor(project, link))
|
|
688
|
+
const offered = await workspaceModels(await platformFor(project, link))
|
|
539
689
|
if (offered.length && !offered.some((m) => m.slug === workspace)) {
|
|
540
690
|
throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
|
|
541
691
|
}
|
|
@@ -559,15 +709,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
559
709
|
const documentNames = asked.includes('*') ? knowledgeHere : asked
|
|
560
710
|
const notHere = documentNames.filter((n) => !knowledgeHere.includes(n))
|
|
561
711
|
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(project, link)
|
|
562
713
|
const documents = documentNames.length
|
|
563
|
-
? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(
|
|
714
|
+
? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(api, documentNames, remote.items) }
|
|
564
715
|
: undefined
|
|
565
716
|
const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
|
|
566
717
|
// Before anything is sent: models only this computer has, then the workspace's own checks.
|
|
567
718
|
const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
|
|
568
719
|
const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
|
|
569
|
-
const
|
|
570
|
-
const checked = available ? await precheck(api, all, getSyncKey(project, 'organization')) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
|
|
720
|
+
const checked = available ? await precheck(api, all, organizationKeyOf(api.acting)) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
|
|
571
721
|
const person = needsAPerson(checked.plan)
|
|
572
722
|
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
573
723
|
const updating = kept && isOpen(kept.state) ? kept : null
|
|
@@ -587,17 +737,33 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
587
737
|
} else chosen = all
|
|
588
738
|
// Samples from setting up the project go only when named.
|
|
589
739
|
const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
|
|
590
|
-
const
|
|
740
|
+
const title = flagString(ctx, 'title')?.trim()
|
|
741
|
+
// The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
|
|
742
|
+
let target = updating
|
|
743
|
+
let stop: CliError | null = null
|
|
744
|
+
if (target) {
|
|
745
|
+
const live = await api.call<View>('GET', `/v1/change-requests/${target.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
|
|
746
|
+
if (!live || !isOpen(live.request.state)) {
|
|
747
|
+
if (live) recordRequest(project.root, link.workspace, live)
|
|
748
|
+
target = null
|
|
749
|
+
} else stop = notProposer(api.acting, live, 'push to')
|
|
750
|
+
}
|
|
751
|
+
// Following a refusal proposes what was asked: the items named (none: every change) and the releases.
|
|
752
|
+
const again = pushCommand(target?.title ?? title ?? null, named, flagString(ctx, 'release'))
|
|
753
|
+
stop ??= notSteward(api.acting, stewardStepUnmet(person, chosen), again, target ? 'you' : 'nobody')
|
|
754
|
+
const orgNote = needsOrganizationKey(chosen) ? stewardNote(api.acting, false, pushCommand(title ?? null, named, flagString(ctx, 'release'))) : null
|
|
591
755
|
|
|
592
756
|
if (preview) {
|
|
593
|
-
const usedBy = available ? await deletionUsers(
|
|
757
|
+
const usedBy = available ? await deletionUsers(api, chosen) : new Map<string, string[]>()
|
|
594
758
|
return {
|
|
595
759
|
data: {
|
|
596
760
|
link,
|
|
597
761
|
available,
|
|
598
|
-
request:
|
|
762
|
+
request: target ? { id: target.id, title: target.title, state: target.state, items: target.refs.length, refs: target.refs } : null,
|
|
599
763
|
items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
|
|
600
764
|
counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
|
|
765
|
+
// A push now would be refused before anything is sent: why, and the way out.
|
|
766
|
+
refused: stop ? { code: stop.code, message: stop.message, hint: stop.hint } : null,
|
|
601
767
|
// Whether the workspace's checks ran before proposing; a platform without them checks at Propose.
|
|
602
768
|
checked: checked.checked,
|
|
603
769
|
...(('problem' in checked && checked.problem) ? { checkProblem: checked.problem } : {}),
|
|
@@ -606,14 +772,14 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
606
772
|
workspace: missing.size ? await workspaceModels(api) : [],
|
|
607
773
|
mapped: link.models ?? {},
|
|
608
774
|
},
|
|
609
|
-
|
|
775
|
+
...stewardData(api.acting, needsOrganizationKey(chosen), orgStored),
|
|
610
776
|
// Apply's steps in order, and the ones the project's credentials can't take (N3).
|
|
611
777
|
applyPlan: checked.plan,
|
|
612
778
|
needsPerson: person,
|
|
613
779
|
notCarried,
|
|
614
780
|
fetchedAt: snapshot.fetchedAt,
|
|
615
781
|
},
|
|
616
|
-
text: previewText(link, chosen,
|
|
782
|
+
text: previewText(link, chosen, target, stop ? `A push now would be refused: ${stop.message}; ${stop.hint}.` : orgNote, available, usedBy, person, mcp?.line),
|
|
617
783
|
}
|
|
618
784
|
}
|
|
619
785
|
|
|
@@ -645,17 +811,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
645
811
|
hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
|
|
646
812
|
})
|
|
647
813
|
}
|
|
648
|
-
const title = flagString(ctx, 'title')?.trim()
|
|
649
814
|
const description = flagString(ctx, 'description')
|
|
650
|
-
|
|
651
|
-
let target = updating
|
|
652
|
-
if (target) {
|
|
653
|
-
const live = await api.call<View>('GET', `/v1/change-requests/${target.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
|
|
654
|
-
if (!live || !isOpen(live.request.state)) {
|
|
655
|
-
if (live) recordRequest(project.root, link.workspace, live)
|
|
656
|
-
target = null
|
|
657
|
-
}
|
|
658
|
-
}
|
|
815
|
+
if (stop) throw stop
|
|
659
816
|
if (!target && !title) throw new UsageError('give the change request a title', 'frontera local sync push -t "Tighten the refund procedure"')
|
|
660
817
|
|
|
661
818
|
// Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
|
|
@@ -677,7 +834,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
677
834
|
details: { checks },
|
|
678
835
|
})
|
|
679
836
|
}
|
|
680
|
-
throw err
|
|
837
|
+
throw orStoredKey(err, api.acting, again, target ? 'you' : 'nobody')
|
|
681
838
|
}
|
|
682
839
|
// A knowledge base's documents follow the request, one at a time (each up to the platform's limit).
|
|
683
840
|
const staged = await stageDocuments(project, api, view)
|
|
@@ -695,7 +852,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
695
852
|
releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
|
|
696
853
|
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
|
|
697
854
|
samples: leftSamples.map((i) => i.ref),
|
|
698
|
-
|
|
855
|
+
...stewardData(api.acting, needsOrganizationKey(view.items), orgStored),
|
|
699
856
|
notCarried,
|
|
700
857
|
},
|
|
701
858
|
text: [
|
|
@@ -704,7 +861,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
704
861
|
...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
|
|
705
862
|
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
|
|
706
863
|
...(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`] : []),
|
|
707
|
-
...(
|
|
864
|
+
...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(view))] : []),
|
|
708
865
|
...(mcp ? ['', mcp.line] : []),
|
|
709
866
|
'',
|
|
710
867
|
'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
|
|
@@ -745,7 +902,7 @@ async function basesFor(project: LocalProject, link: SyncLink, view: View): Prom
|
|
|
745
902
|
// Earlier versions the platform keeps: workflows, Functions and the Blueprint by revision.
|
|
746
903
|
if (item.kind === 'agent' || !HISTORY_KINDS.includes(item.kind) || !snapshot?.capabilities.includes('item-revision')) continue
|
|
747
904
|
try {
|
|
748
|
-
const source = sourceFor(project, link)
|
|
905
|
+
const source = await sourceFor(project, link)
|
|
749
906
|
const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
|
|
750
907
|
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)}`)
|
|
751
908
|
if (answer) {
|
|
@@ -835,12 +992,13 @@ function activityOf(view: View, titles: Record<string, string>) {
|
|
|
835
992
|
})
|
|
836
993
|
}
|
|
837
994
|
|
|
838
|
-
async function currentView(project: LocalProject, link: SyncLink): Promise<{ view: View; titles: Record<string, string
|
|
995
|
+
async function currentView(project: LocalProject, link: SyncLink): Promise<{ view: View; titles: Record<string, string>; api: Platform } | null> {
|
|
839
996
|
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
840
997
|
if (!kept) return null
|
|
841
|
-
const
|
|
998
|
+
const api = await platformFor(project, link)
|
|
999
|
+
const view = await api.call<View>('GET', `/v1/change-requests/${kept.id}`)
|
|
842
1000
|
recordRequest(project.root, link.workspace, view)
|
|
843
|
-
return { view, titles: kept.titles }
|
|
1001
|
+
return { view, titles: kept.titles, api }
|
|
844
1002
|
}
|
|
845
1003
|
|
|
846
1004
|
/**
|
|
@@ -903,6 +1061,8 @@ async function show(ctx: CommandContext) {
|
|
|
903
1061
|
const activity = activityOf(view, allTitles)
|
|
904
1062
|
const orgStored = !!getSyncKey(project, 'organization')
|
|
905
1063
|
const r = view.request
|
|
1064
|
+
const { acting } = current.api
|
|
1065
|
+
const other = isOpen(r.state) ? proposedByOther(acting, view) : null
|
|
906
1066
|
return {
|
|
907
1067
|
data: {
|
|
908
1068
|
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 },
|
|
@@ -910,7 +1070,7 @@ async function show(ctx: CommandContext) {
|
|
|
910
1070
|
items,
|
|
911
1071
|
activity,
|
|
912
1072
|
checks: checks.filter((c) => !items.some((i) => i.ref === c.ref)).map((c) => ({ ref: c.ref, code: c.code, message: c.message })),
|
|
913
|
-
|
|
1073
|
+
...stewardData(acting, needsOrganizationKey(view.items), orgStored),
|
|
914
1074
|
gate: view.gate ?? null,
|
|
915
1075
|
evidence: view.evidence ?? [],
|
|
916
1076
|
},
|
|
@@ -930,7 +1090,10 @@ async function show(ctx: CommandContext) {
|
|
|
930
1090
|
'',
|
|
931
1091
|
'Activity:',
|
|
932
1092
|
...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
|
|
933
|
-
...(isOpen(r.state) ? ['',
|
|
1093
|
+
...(isOpen(r.state) && !other && needsOrganizationKey(view.items) ? ['', stewardNote(acting, true, pushAgain(view))] : []),
|
|
1094
|
+
...(other === 'key' ? ['', 'Proposed with the project’s workspace key before you signed in: close it (`frontera local sync request close`) and push again to apply it as yourself.'] : []),
|
|
1095
|
+
...(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 ? ['', 'Apply it with `frontera local sync request apply`, or close it with `request close`.'] : []),
|
|
934
1097
|
].join('\n'),
|
|
935
1098
|
}
|
|
936
1099
|
}
|
|
@@ -940,15 +1103,27 @@ async function apply(ctx: CommandContext) {
|
|
|
940
1103
|
const link = needLink(project)
|
|
941
1104
|
const current = await currentView(project, link)
|
|
942
1105
|
if (!current) throw new UsageError('no change request to apply', 'propose one with `frontera local sync push -t "<title>"`')
|
|
943
|
-
const { view, titles } = current
|
|
1106
|
+
const { view, titles, api } = current
|
|
1107
|
+
const { acting } = api
|
|
944
1108
|
if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: 'propose new changes with `frontera local sync push -t "<title>"`' })
|
|
945
1109
|
if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: 'propose the changes again with `frontera local sync push -t "<title>"`' })
|
|
1110
|
+
const proposed: ProposedBy = proposedByOther(acting, view) === 'key' ? 'key' : 'you'
|
|
1111
|
+
if (acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')) {
|
|
1112
|
+
// The platform refuses a person who is no Steward only once Apply runs, so ask its checks, with every item so references resolve.
|
|
1113
|
+
const items = view.items.map((i) => ({ ref: i.ref, files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
|
|
1114
|
+
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)
|
|
1116
|
+
if (stop) throw stop
|
|
1117
|
+
}
|
|
1118
|
+
const refused = notProposer(acting, view, 'apply')
|
|
1119
|
+
// A workspace admin signed in may apply another's request once every item is approved: the platform decides.
|
|
1120
|
+
if (refused && !(acting.as === 'person' && view.request.state === 'approved')) throw refused
|
|
946
1121
|
const allTitles = Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
|
|
947
1122
|
const blueprint = view.items.some((i) => i.kind === 'blueprint')
|
|
948
|
-
//
|
|
949
|
-
const organizationKey =
|
|
950
|
-
if (blueprint && !organizationKey) {
|
|
951
|
-
throw new CliError('this change request changes the Blueprint,
|
|
1123
|
+
// 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
|
+
const organizationKey = organizationKeyOf(acting)
|
|
1125
|
+
if (blueprint && acting.as === 'keys' && !organizationKey) {
|
|
1126
|
+
throw new CliError('this change request changes the Blueprint, which a Blueprint Steward applies as themself, and no one is signed in for this project’s workspace on this computer', { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in with \`${signInCommand(acting.profile)}\`, then close this change request (\`frontera local sync request close\`) and push it again, so it is yours to apply` })
|
|
952
1127
|
}
|
|
953
1128
|
const migration = flagString(ctx, 'migration')?.trim()
|
|
954
1129
|
const force = flagBool(ctx, 'force')
|
|
@@ -957,7 +1132,7 @@ async function apply(ctx: CommandContext) {
|
|
|
957
1132
|
let answer: View
|
|
958
1133
|
try {
|
|
959
1134
|
// The key in the body: the platform's edge strips `x-frontera-*` headers a client sends. The header stays for an older platform.
|
|
960
|
-
answer = await
|
|
1135
|
+
answer = await api.call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}), ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
|
|
961
1136
|
} catch (err) {
|
|
962
1137
|
if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
|
|
963
1138
|
const gate = (err.details as { gate?: Gate } | undefined)?.gate
|
|
@@ -973,7 +1148,8 @@ async function apply(ctx: CommandContext) {
|
|
|
973
1148
|
details: { checks },
|
|
974
1149
|
})
|
|
975
1150
|
}
|
|
976
|
-
throw
|
|
1151
|
+
if (refused && err instanceof CliError && err.code === 'FORBIDDEN') throw refused
|
|
1152
|
+
throw orStoredKey(err, acting, pushAgain(view), proposed)
|
|
977
1153
|
}
|
|
978
1154
|
const outcome = parseOutcome(answer.outcome)
|
|
979
1155
|
recordRequest(project.root, link.workspace, answer, { applied: { ok: outcome.ok } })
|
|
@@ -981,9 +1157,14 @@ async function apply(ctx: CommandContext) {
|
|
|
981
1157
|
// Fetch, so the project sees the workspace as it was put back. The request itself applies again as it is:
|
|
982
1158
|
// the platform takes each put-back item's new revision as its base.
|
|
983
1159
|
await fetchFor(project, link).catch(() => null)
|
|
1160
|
+
// When the checks could not be read before sending, the Steward refusal comes back as the failed step.
|
|
1161
|
+
const stewardFailed = /Blueprint Steward assignment required/.test(outcome.failure?.message ?? '')
|
|
1162
|
+
const steward = notSteward(acting, stewardFailed, pushAgain(view), proposed)
|
|
1163
|
+
// With no organization key stored, applying again fails the same way.
|
|
1164
|
+
const noKey = stewardFailed ? 'ask a Blueprint Steward who is a workspace admin to approve it and apply it; or store the project’s organization key (`frontera local sync connect --workspace-key - --org-key -`), then apply again (`frontera local sync request apply`) and follow the steps it prints' : null
|
|
984
1165
|
throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
|
|
985
1166
|
code: 'SYNC_APPLY_FAILED',
|
|
986
|
-
hint: /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 (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)',
|
|
1167
|
+
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 (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)'),
|
|
987
1168
|
details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: failedName(outcome, allTitles), message: outcome.failure.message } : null },
|
|
988
1169
|
})
|
|
989
1170
|
}
|
|
@@ -1003,7 +1184,7 @@ async function apply(ctx: CommandContext) {
|
|
|
1003
1184
|
fetched.error = (err as Error).message
|
|
1004
1185
|
}
|
|
1005
1186
|
// Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
|
|
1006
|
-
await recordAppliedDocuments(project, link, answer).catch(() => null)
|
|
1187
|
+
await recordAppliedDocuments(project, link, api, answer).catch(() => null)
|
|
1007
1188
|
// The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
|
|
1008
1189
|
recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
|
|
1009
1190
|
// What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
|
|
@@ -1015,16 +1196,16 @@ async function apply(ctx: CommandContext) {
|
|
|
1015
1196
|
const pending = recordAfterApply(project.root, answer.request.title, after, target ? { profile: target, target: targetOf(link) } : null)
|
|
1016
1197
|
const signIn = target && pending.length ? await signInStep({ profile: target, apiUrl: link.platform, named: true }) : null
|
|
1017
1198
|
return {
|
|
1018
|
-
data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
|
|
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 },
|
|
1019
1200
|
text: [
|
|
1020
|
-
`Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}.`,
|
|
1201
|
+
`Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}${acting.as === 'person' ? ` as ${acting.email ?? 'you'} (signed in)` : ''}.`,
|
|
1021
1202
|
...describeOutcome(outcome, allTitles),
|
|
1022
1203
|
'',
|
|
1023
1204
|
fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
|
|
1024
1205
|
...(pending.length
|
|
1025
1206
|
? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
|
|
1026
1207
|
target
|
|
1027
|
-
? `They run against ${new URL(link.platform).host} with \`--profile ${target}\` (this project's keys), not the local runtime${signIn ? `; a step a signed-in person takes needs \`${signIn}\` first` : ''}.`
|
|
1208
|
+
? `They run against ${new URL(link.platform).host} with \`--profile ${target}\` (${getSyncKey(project, 'organization') ? "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` : ''}.`
|
|
1028
1209
|
: `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${targetError ? ` (${targetError})` : ''}. Add \`--profile <a profile for that platform>\` when you run them.`,
|
|
1029
1210
|
'Forge lists them on the project’s home; `frontera local steps` here.']
|
|
1030
1211
|
: []),
|
|
@@ -1037,12 +1218,11 @@ async function apply(ctx: CommandContext) {
|
|
|
1037
1218
|
* workspace's new copy (the newest document of that name), and each taken off
|
|
1038
1219
|
* is forgotten, so the next push sees them as unchanged.
|
|
1039
1220
|
*/
|
|
1040
|
-
async function recordAppliedDocuments(project: LocalProject, link: SyncLink, view: View): Promise<void> {
|
|
1221
|
+
async function recordAppliedDocuments(project: LocalProject, link: SyncLink, api: Platform, view: View): Promise<void> {
|
|
1041
1222
|
const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
|
|
1042
1223
|
if (!items.length) return
|
|
1043
1224
|
const snapshot = readSnapshot(project.root)
|
|
1044
1225
|
const remote = snapshot ? snapshotItems(project.root, snapshot) : []
|
|
1045
|
-
const api = platformFor(project, link)
|
|
1046
1226
|
for (const item of items) {
|
|
1047
1227
|
const id = remote.find((r) => r.ref === item.ref)?.id
|
|
1048
1228
|
if (!id) continue
|
|
@@ -1071,9 +1251,14 @@ async function close(ctx: CommandContext) {
|
|
|
1071
1251
|
const link = needLink(project)
|
|
1072
1252
|
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
1073
1253
|
if (!kept || !isOpen(kept.state)) throw new UsageError('no open change request to close', 'see where it stands with `frontera local sync request`')
|
|
1074
|
-
const
|
|
1254
|
+
const current = (await currentView(project, link))!
|
|
1255
|
+
// Closing writes nothing to the workspace, so a request the project's key proposed is closed with that key.
|
|
1256
|
+
const byKey = proposedByOther(current.api.acting, current.view) === 'key'
|
|
1257
|
+
const refused = byKey ? null : notProposer(current.api.acting, current.view, 'close')
|
|
1258
|
+
if (refused) throw refused
|
|
1259
|
+
const view = await (byKey ? keysFor(project, link) : current.api).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
|
|
1075
1260
|
recordRequest(project.root, link.workspace, view)
|
|
1076
|
-
return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}
|
|
1261
|
+
return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”${byKey ? ' with the project’s workspace key that proposed it' : ''}. The project’s files are unchanged; the next push opens a new change request.` }
|
|
1077
1262
|
}
|
|
1078
1263
|
|
|
1079
1264
|
/**
|
|
@@ -1089,13 +1274,14 @@ async function evidence(ctx: CommandContext) {
|
|
|
1089
1274
|
const link = needLink(project)
|
|
1090
1275
|
const current = await currentView(project, link)
|
|
1091
1276
|
if (!current) throw new UsageError('no change request to attach evidence to', 'propose one with `frontera local sync push -t "<title>"`')
|
|
1092
|
-
const { view, titles } = current
|
|
1277
|
+
const { view, titles, api } = current
|
|
1093
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: 'propose new changes with `frontera local sync push -t "<title>"`' })
|
|
1279
|
+
const refused = notProposer(api.acting, view, 'attach evidence to')
|
|
1280
|
+
if (refused) throw refused
|
|
1094
1281
|
const session = flagString(ctx, 'screenshots') ?? process.env.FORGE_SESSION ?? null
|
|
1095
1282
|
const trace = flagString(ctx, 'trace')
|
|
1096
1283
|
const any = flagBool(ctx, 'evals') || flagBool(ctx, 'renders') || flagString(ctx, 'screenshots') !== undefined || trace !== undefined
|
|
1097
1284
|
const want = { evals: any ? flagBool(ctx, 'evals') : true, renders: any ? flagBool(ctx, 'renders') : true, screenshots: any ? flagString(ctx, 'screenshots') !== undefined : !!session }
|
|
1098
|
-
const api = platformFor(project, link)
|
|
1099
1285
|
const attach = (body: Record<string, unknown>) => api.call<View>('POST', `/v1/change-requests/${view.request.id}/evidence`, body, { timeoutMs: 120_000 })
|
|
1100
1286
|
const lines: string[] = []
|
|
1101
1287
|
const attached: Array<{ kind: string; ref: string | null; title: string }> = []
|
|
@@ -1207,7 +1393,7 @@ async function evidenceImage(ctx: CommandContext) {
|
|
|
1207
1393
|
const path = join(dir, `${id}.${ext}`)
|
|
1208
1394
|
if (existsSync(path)) return { data: { path, mime: Object.entries(PICTURE_EXT).find(([, e]) => e === ext)![0] }, text: path }
|
|
1209
1395
|
}
|
|
1210
|
-
const { bytes, mime } = await platformFor(project, link).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
|
|
1396
|
+
const { bytes, mime } = await (await platformFor(project, link)).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
|
|
1211
1397
|
const ext = PICTURE_EXT[mime.split(';')[0]!.trim()]
|
|
1212
1398
|
if (!ext) throw new CliError('the platform did not answer with a picture', { code: 'UNAVAILABLE', hint: 'run the command again' })
|
|
1213
1399
|
mkdirSync(dir, { recursive: true })
|