@frontera-sdk/cli 1.53.6 → 1.53.8
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/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +2 -3
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +28 -5
- package/src/commands/local/sync.ts +19 -3
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-person-steps.ts +92 -8
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/main.ts +1 -0
- package/src/vendor/kit-assets.json +6 -6
|
@@ -735,29 +735,6 @@ export function pointMediaTypesAt(project: LocalProject, blueprint: ResolvedBlue
|
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
-
/**
|
|
739
|
-
* The properties each object type lets a governed Action write
|
|
740
|
-
* (`backing.editable`), for the types those Actions act on. `blueprint apply`
|
|
741
|
-
* leaves backings to `bind`, so `local dev` declares them itself
|
|
742
|
-
* (`blueprint editable`) before an Action's write path is built.
|
|
743
|
-
*/
|
|
744
|
-
export function editableSets(dir: string, subjects: ReadonlySet<string> | null): Map<string, string[]> {
|
|
745
|
-
const out = new Map<string, string[]>()
|
|
746
|
-
const at = join(dir, 'object-types')
|
|
747
|
-
if (!existsSync(at)) return out
|
|
748
|
-
for (const name of readdirSync(at)) {
|
|
749
|
-
if (!/\.ya?ml$/.test(name) || name.startsWith('.')) continue
|
|
750
|
-
try {
|
|
751
|
-
const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as { apiName?: unknown; backing?: { editable?: unknown } } | null
|
|
752
|
-
const editable = doc?.backing?.editable
|
|
753
|
-
if (typeof doc?.apiName === 'string' && (!subjects || subjects.has(doc.apiName)) && Array.isArray(editable) && editable.length) out.set(doc.apiName, editable.map(String).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)))
|
|
754
|
-
} catch {
|
|
755
|
-
// A file that does not parse is refused by the apply itself.
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
return out
|
|
759
|
-
}
|
|
760
|
-
|
|
761
738
|
/**
|
|
762
739
|
* A copy of a Blueprint folder without some governed Actions, under
|
|
763
740
|
* `.frontera/local/resolved/blueprint-without`: applied with `--prune`, it
|
|
@@ -2,6 +2,9 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
import { getProfile } from '../profiles'
|
|
6
|
+
import { localProject } from './local-project'
|
|
7
|
+
import { readLink, targetOf, targetProfileName } from './local-sync-link'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Person-steps (Forge improvement plan T4, N4): a step only the person may
|
|
@@ -74,6 +77,10 @@ export interface PersonStep {
|
|
|
74
77
|
key?: string
|
|
75
78
|
check?: StepCheck
|
|
76
79
|
lastCheckedAt?: string
|
|
80
|
+
/** The workspace a change request's step acts on (`targetOf`): stale once the folder links elsewhere. */
|
|
81
|
+
target?: string
|
|
82
|
+
/** The profile its command and check name (`targetProfileName`): stale when it is gone or no longer the folder's. */
|
|
83
|
+
profile?: string
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
interface StepsFile {
|
|
@@ -119,12 +126,35 @@ function filePath(root: string): string {
|
|
|
119
126
|
export function readPersonSteps(root: string): PersonStep[] {
|
|
120
127
|
const path = filePath(root)
|
|
121
128
|
if (!existsSync(path)) return []
|
|
129
|
+
let steps: PersonStep[]
|
|
122
130
|
try {
|
|
123
131
|
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<StepsFile>
|
|
124
|
-
|
|
132
|
+
steps = Array.isArray(parsed.steps) ? parsed.steps.filter((s): s is PersonStep => !!s && typeof s.id === 'string' && (typeof s.command === 'string' || s.command === null)) : []
|
|
125
133
|
} catch {
|
|
126
134
|
return []
|
|
127
135
|
}
|
|
136
|
+
const current = steps.some((s) => s.profile) ? currentTarget(root) : null
|
|
137
|
+
return current ? steps.map((s) => aimedAt(s, current)) : steps
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A change request's step recorded for the folder's target under another
|
|
142
|
+
* profile, aimed at the folder's profile for it. The profile name follows the
|
|
143
|
+
* folder's path, so it changes when the folder is moved or renamed, while the
|
|
144
|
+
* steps file moves with the folder.
|
|
145
|
+
*/
|
|
146
|
+
function aimedAt(step: PersonStep, current: StepTarget): PersonStep {
|
|
147
|
+
const was = step.profile
|
|
148
|
+
if (!was || step.target !== current.target || was === current.profile) return step
|
|
149
|
+
const swap = (command: string) => command.split(' ')
|
|
150
|
+
.map((word, i, words) => (word === was && words[i - 1] === '--profile' ? current.profile : word === `--profile=${was}` ? `--profile=${current.profile}` : word))
|
|
151
|
+
.join(' ')
|
|
152
|
+
return {
|
|
153
|
+
...step,
|
|
154
|
+
profile: current.profile,
|
|
155
|
+
...(step.command ? { command: swap(step.command) } : {}),
|
|
156
|
+
...(step.check ? { check: { ...step.check, command: swap(step.check.command) } } : {}),
|
|
157
|
+
}
|
|
128
158
|
}
|
|
129
159
|
|
|
130
160
|
function writeSteps(root: string, steps: PersonStep[]): void {
|
|
@@ -273,21 +303,73 @@ export function checkProblem(check: StepCheck, readOnly: readonly string[]): str
|
|
|
273
303
|
return null
|
|
274
304
|
}
|
|
275
305
|
|
|
306
|
+
/** Where a folder's link points (`targetOf`), and the profile that reaches it (`targetProfileName`). */
|
|
307
|
+
export interface StepTarget {
|
|
308
|
+
target: string
|
|
309
|
+
profile: string
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The folder's current target, or null when it links to no workspace. */
|
|
313
|
+
export function currentTarget(root: string): StepTarget | null {
|
|
314
|
+
const link = readLink(root)
|
|
315
|
+
return link ? { target: targetOf(link), profile: targetProfileName(localProject(root), link) } : null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Why a change request's step must not run, or null when it may: it was
|
|
320
|
+
* recorded for another target than the folder links to now, it names a profile
|
|
321
|
+
* that is not the folder's and no target to aim it again (a hand-written
|
|
322
|
+
* step), or its profile is gone. Each says how to go on: connect the folder,
|
|
323
|
+
* or dismiss the step. The request is applied already, so applying it again
|
|
324
|
+
* is refused; adding a profile by hand would aim the step at a profile
|
|
325
|
+
* nothing keeps in step with the link.
|
|
326
|
+
*/
|
|
327
|
+
export function staleProblem(step: PersonStep, current: StepTarget | null): string | null {
|
|
328
|
+
if (step.target && step.target !== current?.target) {
|
|
329
|
+
return `it was recorded for ${step.target}, and this folder now links to ${current?.target ?? 'no workspace'}: link this folder back to it with \`frontera local sync connect --relink\`, or dismiss this step`
|
|
330
|
+
}
|
|
331
|
+
if (!step.profile) return null
|
|
332
|
+
if (step.profile !== current?.profile) {
|
|
333
|
+
return `it names the profile ${step.profile}, which is not this folder's for its link, and it records no target to aim it at this folder's: dismiss this step`
|
|
334
|
+
}
|
|
335
|
+
if (!getProfile(step.profile)) {
|
|
336
|
+
return `its profile ${step.profile} is not on this computer: connect this folder again with \`frontera local sync connect\`, which records it, or dismiss this step`
|
|
337
|
+
}
|
|
338
|
+
return null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The command aimed at a profile: `--profile <profile>` added unless it names one already. */
|
|
342
|
+
export function atProfile(command: string, profile: string | null): string {
|
|
343
|
+
return !profile || /(?:^|\s)--profile(?:\s|=|$)/.test(command) ? command : `${command} --profile ${profile}`
|
|
344
|
+
}
|
|
345
|
+
|
|
276
346
|
/**
|
|
277
347
|
* Record what a request left for a person, as person-steps of its delivery
|
|
278
|
-
* (the request's title). An item already listed (by its key) is updated
|
|
279
|
-
* settled one stays settled
|
|
280
|
-
* what was done
|
|
348
|
+
* (the request's title). An item already listed (by its key) is updated. A
|
|
349
|
+
* settled one stays settled when the request is applied again to the same
|
|
350
|
+
* target, so that doesn't bring back what was done; applied to another
|
|
351
|
+
* target, it is pending again there. `at` is the request's target: its profile
|
|
352
|
+
* (`targetProfileName`), which each command and check names, since the folder
|
|
353
|
+
* is bound to its local runtime and the steps change the workspace the request
|
|
354
|
+
* was applied to, and the target itself, recorded on each step.
|
|
281
355
|
*/
|
|
282
|
-
export function recordAfterApply(root: string, delivery: string, items: AfterApplyItem[], now = new Date()): PersonStep[] {
|
|
356
|
+
export function recordAfterApply(root: string, delivery: string, items: AfterApplyItem[], at: { profile: string; target: string } | null = null, now = new Date()): PersonStep[] {
|
|
357
|
+
const profile = at?.profile ?? null
|
|
283
358
|
const steps = readPersonSteps(root)
|
|
284
359
|
const out: PersonStep[] = []
|
|
285
|
-
for (const
|
|
360
|
+
for (const raw of items) {
|
|
361
|
+
const item = { ...raw, ...(raw.command ? { command: atProfile(raw.command, profile) } : {}), ...(raw.check ? { check: { ...raw.check, command: atProfile(raw.check.command, profile) } } : {}) }
|
|
286
362
|
const credential = ((CREDENTIALS as readonly string[]).includes(item.credential) ? item.credential : 'person') as Credential
|
|
287
363
|
const command = item.command && !commandProblem(item.command) ? item.command : null
|
|
288
364
|
const existing = steps.find((s) => s.key === item.key && s.delivery === delivery)
|
|
289
365
|
if (existing) {
|
|
290
|
-
|
|
366
|
+
if (at && existing.target && existing.target !== at.target) {
|
|
367
|
+
existing.status = 'pending'
|
|
368
|
+
delete existing.doneAt
|
|
369
|
+
delete existing.doneBy
|
|
370
|
+
delete existing.lastCheckedAt
|
|
371
|
+
}
|
|
372
|
+
Object.assign(existing, { command, why: item.why, changes: item.label, credential, ...(item.check ? { check: item.check } : {}), ...(at ? { target: at.target, profile: at.profile } : {}) })
|
|
291
373
|
out.push(existing)
|
|
292
374
|
continue
|
|
293
375
|
}
|
|
@@ -303,6 +385,7 @@ export function recordAfterApply(root: string, delivery: string, items: AfterApp
|
|
|
303
385
|
source: 'change-request',
|
|
304
386
|
key: item.key,
|
|
305
387
|
...(item.check ? { check: item.check } : {}),
|
|
388
|
+
...(at ? { target: at.target, profile: at.profile } : {}),
|
|
306
389
|
}
|
|
307
390
|
steps.push(step)
|
|
308
391
|
out.push(step)
|
|
@@ -332,12 +415,13 @@ export async function runStepChecks(
|
|
|
332
415
|
now = new Date(),
|
|
333
416
|
): Promise<{ checked: number; done: PersonStep[]; problems: Array<{ id: string; problem: string }> }> {
|
|
334
417
|
const steps = readPersonSteps(root)
|
|
418
|
+
const current = currentTarget(root)
|
|
335
419
|
const done: PersonStep[] = []
|
|
336
420
|
const problems: Array<{ id: string; problem: string }> = []
|
|
337
421
|
let checked = 0
|
|
338
422
|
for (const step of steps) {
|
|
339
423
|
if (step.status !== 'pending' || !step.check) continue
|
|
340
|
-
const problem = checkProblem(step.check, readOnly)
|
|
424
|
+
const problem = staleProblem(step, current) ?? checkProblem(step.check, readOnly)
|
|
341
425
|
if (problem) {
|
|
342
426
|
problems.push({ id: step.id, problem })
|
|
343
427
|
continue
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
|
|
5
6
|
import { parseDocument } from 'yaml'
|
|
6
7
|
|
|
8
|
+
import { credentialStore } from '../credential-store'
|
|
9
|
+
import { forgetDeviceSession } from '../device-login'
|
|
7
10
|
import { writeFileAtomic } from '../fs-atomic'
|
|
8
11
|
import { configDir } from '../paths'
|
|
12
|
+
import { deleteProfile, fingerprint, getProfile, putProfile } from '../profiles'
|
|
9
13
|
import { PROJECT_FILES, type LocalProject } from './local-project'
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -161,3 +165,75 @@ export function removeSyncKey(project: LocalProject, role: KeyRole): void {
|
|
|
161
165
|
delete keys[account(project, role)]
|
|
162
166
|
writeFileAtomic(keysFile(), `${JSON.stringify(keys, null, 2)}\n`, 0o600)
|
|
163
167
|
}
|
|
168
|
+
|
|
169
|
+
// ── The link's target as a CLI profile ──────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The CLI profile that reaches the linked workspace's platform with the
|
|
173
|
+
* project's own keys, one per folder and target: its name comes from the
|
|
174
|
+
* project (its folder), the platform and the workspace. The organization is
|
|
175
|
+
* left out, since a workspace already fixes it, so adding the organization
|
|
176
|
+
* key keeps the name. The project folder is bound to its local runtime, so a step
|
|
177
|
+
* that must change the workspace (a change request's after-apply steps, the
|
|
178
|
+
* read that checks them) names this profile with `--profile`; the remote guard
|
|
179
|
+
* lets a command that names its target through.
|
|
180
|
+
*/
|
|
181
|
+
export function targetProfileName(project: LocalProject, link: SyncLink): string {
|
|
182
|
+
const target = [project.id, link.platform, link.workspace].join('\n')
|
|
183
|
+
return `sync-${createHash('sha256').update(target).digest('hex').slice(0, 16)}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Where a recorded step acts, `<host>/<workspace>`: a step whose target is not the folder's link is stale. */
|
|
187
|
+
export const targetOf = (link: SyncLink) => `${new URL(link.platform).host}/${link.workspace}`
|
|
188
|
+
|
|
189
|
+
/** The fingerprints of the keys stored for the project now. */
|
|
190
|
+
export function projectKeyFingerprints(project: LocalProject): string[] {
|
|
191
|
+
return (['organization', 'workspace'] as const).flatMap((role) => {
|
|
192
|
+
const key = getSyncKey(project, role)
|
|
193
|
+
return key ? [fingerprint(key)] : []
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Record the target profile from the keys stored for the project: the
|
|
199
|
+
* organization key when there is one (every step a change request leaves is
|
|
200
|
+
* at organization grain), else the workspace key. Null when no key is stored.
|
|
201
|
+
* The profile is the project's when it holds one of the project's keys, now
|
|
202
|
+
* or before `connect` replaced them (`previous`), and then takes the current
|
|
203
|
+
* key; one holding another key is left alone. The key is stored as every
|
|
204
|
+
* profile's is, so a machine without a secure store refuses it unless its
|
|
205
|
+
* person chose the 0600 file.
|
|
206
|
+
*/
|
|
207
|
+
export async function registerTargetProfile(project: LocalProject, link: SyncLink, previous: readonly string[] = []): Promise<string | null> {
|
|
208
|
+
const organizationKey = getSyncKey(project, 'organization')
|
|
209
|
+
const key = organizationKey ?? getSyncKey(project, 'workspace')
|
|
210
|
+
if (!key) return null
|
|
211
|
+
const name = targetProfileName(project, link)
|
|
212
|
+
const existing = getProfile(name)
|
|
213
|
+
if (existing && ![...previous, ...projectKeyFingerprints(project)].includes(existing.fingerprint)) {
|
|
214
|
+
throw new Error(`the profile ${name} holds a key that isn't this project's, so it is left as it is; \`frontera auth remove ${name}\` frees the name`)
|
|
215
|
+
}
|
|
216
|
+
await credentialStore().set(name, key)
|
|
217
|
+
const now = new Date().toISOString()
|
|
218
|
+
putProfile(name, {
|
|
219
|
+
apiUrl: link.platform,
|
|
220
|
+
credentialKind: organizationKey ? 'organization' : 'workspace',
|
|
221
|
+
workspaceId: organizationKey ? null : link.workspace,
|
|
222
|
+
orgId: link.organization ?? null,
|
|
223
|
+
fingerprint: fingerprint(key),
|
|
224
|
+
createdAt: existing?.createdAt ?? now,
|
|
225
|
+
lastVerifiedAt: now,
|
|
226
|
+
})
|
|
227
|
+
return name
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Remove this folder's target profile with its key and its browser sign-in, unless the profile holds a key that isn't the project's. */
|
|
231
|
+
export async function removeTargetProfile(project: LocalProject, link: SyncLink): Promise<void> {
|
|
232
|
+
const name = targetProfileName(project, link)
|
|
233
|
+
const existing = getProfile(name)
|
|
234
|
+
if (existing && !projectKeyFingerprints(project).includes(existing.fingerprint)) return
|
|
235
|
+
if (existing) deleteProfile(name)
|
|
236
|
+
const store = credentialStore()
|
|
237
|
+
await store.delete(name)
|
|
238
|
+
await forgetDeviceSession(store, name, (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(5_000) }))
|
|
239
|
+
}
|
|
@@ -64,7 +64,7 @@ export function fixFor(check: Check): string | null {
|
|
|
64
64
|
const ref = check.ref
|
|
65
65
|
if (check.code === 'stale_base' || check.code === 'exists' || check.code === 'gone') return `pull it first (\`frontera local sync pull ${ref}\`), then propose`
|
|
66
66
|
if (check.code === 'unpublished_draft') return 'publish or discard its draft on the platform, then fetch and propose'
|
|
67
|
-
if (check.code === 'blueprint_backing') return '
|
|
67
|
+
if (check.code === 'blueprint_backing') return 'make the type’s backing name a dataset the organization has, or add its datasets/<name>.json to this request, and map each field to one of its columns'
|
|
68
68
|
if (check.code === 'blueprint_draft') return 'publish or discard the Blueprint’s draft on the platform, then propose'
|
|
69
69
|
const model = /the model (\S+) is not in this workspace/.exec(check.message)
|
|
70
70
|
if (model) return `choose the workspace model it goes as (\`frontera local sync map-model ${model[1]} <workspace model>\`)`
|
|
@@ -1055,6 +1055,13 @@ export function parseOutcome(raw: unknown): ApplyOutcome {
|
|
|
1055
1055
|
const STEP_MARK: Record<StepStatus, string> = { done: '✓', failed: '✗', 'not-run': '·' }
|
|
1056
1056
|
const BACK_WORD = { 'put-back': 'put back', failed: 'could not be put back', irreversible: 'cannot be withdrawn' } as const
|
|
1057
1057
|
|
|
1058
|
+
/** What failed: an item's title when the step is one item's, otherwise the step's own words ("Grant the workspace its new object types"). */
|
|
1059
|
+
export function failedName(outcome: ApplyOutcome, titles: Record<string, string> = {}): string {
|
|
1060
|
+
const failed = outcome.failure
|
|
1061
|
+
if (!failed) return 'a step'
|
|
1062
|
+
return titles[failed.ref] ?? outcome.steps.find((s) => s.key === failed.key)?.label ?? failed.ref
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1058
1065
|
/** Apply's steps, and after a failure what was put back, as `request apply` prints them. */
|
|
1059
1066
|
export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, string> = {}): string[] {
|
|
1060
1067
|
const lines = outcome.steps.flatMap((s) => [
|
|
@@ -1063,9 +1070,8 @@ export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, st
|
|
|
1063
1070
|
])
|
|
1064
1071
|
if (outcome.ok) return [`Applied ${outcome.steps.length} step${outcome.steps.length === 1 ? '' : 's'}:`, ...lines]
|
|
1065
1072
|
const failed = outcome.failure
|
|
1066
|
-
const name = failed ? titles[failed.ref] ?? failed.ref : 'a step'
|
|
1067
1073
|
return [
|
|
1068
|
-
`Not applied: ${failed ? `${
|
|
1074
|
+
`Not applied: ${failed ? `${failedName(outcome, titles)} failed: ${failed.message}` : 'a step failed'}`,
|
|
1069
1075
|
...lines,
|
|
1070
1076
|
...(outcome.compensation.length
|
|
1071
1077
|
? ['', 'What was put back:', ...outcome.compensation.map((c) => ` ${c.label}: ${BACK_WORD[c.status]}${c.error ? ` (${c.error})` : ''}`)]
|
package/src/main.ts
CHANGED