@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.53.
|
|
3
|
+
"version": "1.53.9",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
42
|
"@faker-js/faker": "10.6.0",
|
|
43
|
-
"@frontera-sdk/core": "1.53.
|
|
44
|
-
"@frontera-sdk/functions": "1.53.
|
|
43
|
+
"@frontera-sdk/core": "1.53.9",
|
|
44
|
+
"@frontera-sdk/functions": "1.53.9",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"axe-core": "4.12.1",
|
|
47
47
|
"gray-matter": "^4.0.3",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.9.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@frontera-sdk/forge-contracts": "1.53.
|
|
52
|
+
"@frontera-sdk/forge-contracts": "1.53.9",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
|
@@ -600,10 +600,12 @@ export class GovernedActionApi {
|
|
|
600
600
|
* A validation refusal's issues (`details.issues: [{ kind?, path, message }]`,
|
|
601
601
|
* `kind` being `invocation_input` or `invocation` for a submission), one line
|
|
602
602
|
* each as `path: message`, and a stale version's `details.currentVersion` as
|
|
603
|
-
* the version to submit instead
|
|
603
|
+
* the version to submit instead, in the record's own version input when the
|
|
604
|
+
* stale record is one the Action changes beside its subject (`details.record`);
|
|
605
|
+
* empty when the details carry neither.
|
|
604
606
|
*/
|
|
605
607
|
export function issueLines(details: unknown): string[] {
|
|
606
|
-
const d = (details && typeof details === 'object' ? details : {}) as { issues?: unknown; currentVersion?: unknown }
|
|
608
|
+
const d = (details && typeof details === 'object' ? details : {}) as { issues?: unknown; currentVersion?: unknown; record?: unknown; versionInput?: unknown }
|
|
607
609
|
const lines: string[] = []
|
|
608
610
|
if (Array.isArray(d.issues)) {
|
|
609
611
|
for (const i of d.issues) {
|
|
@@ -615,7 +617,11 @@ export function issueLines(details: unknown): string[] {
|
|
|
615
617
|
lines.push(where ? `${where}: ${message}` : message)
|
|
616
618
|
}
|
|
617
619
|
}
|
|
618
|
-
if (typeof d.currentVersion === 'number' || typeof d.currentVersion === 'string') {
|
|
620
|
+
if ((typeof d.currentVersion === 'number' || typeof d.currentVersion === 'string') && typeof d.record === 'string') {
|
|
621
|
+
// A record the Action changes beside its subject, checked against its own version input.
|
|
622
|
+
const input = typeof d.versionInput === 'string' ? d.versionInput : 'its version input'
|
|
623
|
+
lines.push(`record ${d.record} is now at version ${String(d.currentVersion)}: read it again and submit with ${input} ${String(d.currentVersion)}`)
|
|
624
|
+
} else if (typeof d.currentVersion === 'number' || typeof d.currentVersion === 'string') {
|
|
619
625
|
lines.push(`the record is now at version ${String(d.currentVersion)}: read it again and submit with expectedVersion ${String(d.currentVersion)}`)
|
|
620
626
|
}
|
|
621
627
|
return [...new Set(lines)].slice(0, 8)
|
|
@@ -272,6 +272,8 @@ interface ObjectTypeFacts {
|
|
|
272
272
|
valueTypes?: ReadonlyMap<string, string>
|
|
273
273
|
/** `backing.editable`: what a governed Action may write; null when the file declares none. */
|
|
274
274
|
editable?: ReadonlySet<string> | null
|
|
275
|
+
/** The properties the type requires (`required: true`), which nothing may set to null. */
|
|
276
|
+
required?: ReadonlySet<string>
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
/** The input kinds that may set a property of each base type. */
|
|
@@ -295,6 +297,7 @@ export function actionRuleProblems(files: AuthoredFile[], inheritedConflict?: In
|
|
|
295
297
|
primaryKey: typeof f.document.primaryKey === 'string' ? f.document.primaryKey : undefined,
|
|
296
298
|
valueTypes: new Map(properties.flatMap((p) => (typeof p.apiName === 'string' && typeof p.valueType === 'string' ? [[p.apiName, p.valueType] as const] : []))),
|
|
297
299
|
editable: Array.isArray(backing.editable) ? new Set(backing.editable.map(String)) : null,
|
|
300
|
+
required: new Set(properties.flatMap((p) => (p.required === true && typeof p.apiName === 'string' ? [p.apiName] : []))),
|
|
298
301
|
})
|
|
299
302
|
}
|
|
300
303
|
const refused: string[] = []
|
|
@@ -324,8 +327,163 @@ export function actionRuleProblems(files: AuthoredFile[], inheritedConflict?: In
|
|
|
324
327
|
return { refused, undeployable, refusedActions, ...(inherited.length ? { inherited, inheritedActions } : {}) }
|
|
325
328
|
}
|
|
326
329
|
|
|
327
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* One Action document: what the draft route refuses (`refused`) and what will not deploy (`undeployable`).
|
|
332
|
+
* An Action with `records` is checked as its subject, and then each record as the subject of a create
|
|
333
|
+
* (`actionFileForRecord`): a created record as the subject of a create, an existing one as the subject
|
|
334
|
+
* of an edit or delete, each addressed by its own key input and, when it exists, its own version input.
|
|
335
|
+
*/
|
|
328
336
|
export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts>, refused: string[], undeployable: string[]): void {
|
|
337
|
+
if (!Array.isArray(doc.records)) {
|
|
338
|
+
checkRecord(doc, types, refused, undeployable)
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
recordProblems(doc, types, refused, undeployable)
|
|
342
|
+
const subjectRefused: string[] = []
|
|
343
|
+
const subjectUndeployable: string[] = []
|
|
344
|
+
checkRecord(actionFileForRecord(doc, null), types, subjectRefused, subjectUndeployable)
|
|
345
|
+
refused.push(...subjectRefused)
|
|
346
|
+
undeployable.push(...subjectUndeployable)
|
|
347
|
+
for (const record of list(doc.records)) {
|
|
348
|
+
if (typeof record.key !== 'string') continue
|
|
349
|
+
const r: string[] = []
|
|
350
|
+
const u: string[] = []
|
|
351
|
+
checkRecord(actionFileForRecord(doc, record.key), types, r, u, {
|
|
352
|
+
keyInput: typeof record.keyInput === 'string' ? record.keyInput : undefined,
|
|
353
|
+
versionInput: typeof record.versionInput === 'string' ? record.versionInput : undefined,
|
|
354
|
+
})
|
|
355
|
+
refused.push(...recordOwnProblems(record.key, subjectRefused, r))
|
|
356
|
+
undeployable.push(...recordOwnProblems(record.key, subjectUndeployable, u))
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** A record's problems under its key. A problem with the file as a whole (an input's own shape) is reported once, by the subject. */
|
|
361
|
+
function recordOwnProblems(key: string, subjectProblems: readonly string[], problems: readonly string[]): string[] {
|
|
362
|
+
return problems.filter((problem) => !subjectProblems.includes(problem)).map((problem) => `records.${key}: ${problem}`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The Action file as one record sees it: `null` is the subject, with only the changes that name no
|
|
367
|
+
* record; a record key is that record as a subject, with only its changes: a create for a record the
|
|
368
|
+
* Action creates, an update or delete under expected_version for one that exists. The service
|
|
369
|
+
* reviews each record the same way (`actionDefinitionForRecord`).
|
|
370
|
+
*/
|
|
371
|
+
export function actionFileForRecord(doc: Doc, key: string | null): Doc {
|
|
372
|
+
const outcomes = list(doc.businessOutcomes)
|
|
373
|
+
const { records: _records, ...rest } = doc
|
|
374
|
+
// Another record's change stays as an empty placeholder, so every problem keeps the file's own path.
|
|
375
|
+
const changesOf = (o: Doc) => list(o.expectedChanges).map((c) => {
|
|
376
|
+
if ((c.record ?? null) !== key) return {}
|
|
377
|
+
const { record: _record, ...change } = c
|
|
378
|
+
return change
|
|
379
|
+
})
|
|
380
|
+
if (key === null) {
|
|
381
|
+
return { ...rest, businessOutcomes: outcomes.map((o) => ({ ...o, expectedChanges: changesOf(o) })) }
|
|
382
|
+
}
|
|
383
|
+
const record = list(doc.records).find((candidate) => candidate.key === key)
|
|
384
|
+
const existing = record?.mode === 'existing'
|
|
385
|
+
const deletes = outcomes.some((o) => o.effect === 'confirmed_applied' && list(o.expectedChanges).some((c) => c.record === key && c.kind === 'object_deleted'))
|
|
386
|
+
return {
|
|
387
|
+
...rest,
|
|
388
|
+
subject: { objectType: record?.objectType, mode: existing ? 'existing' : 'create' },
|
|
389
|
+
effect: { ...(isRecord(doc.effect) ? doc.effect : {}), kind: existing ? (deletes ? 'delete' : 'update') : 'create' },
|
|
390
|
+
concurrency: { strategy: existing ? 'expected_version' : 'append_once' },
|
|
391
|
+
submissionCriteria: [],
|
|
392
|
+
businessOutcomes: outcomes.map((o) => ({ ...o, expectedChanges: changesOf(o), postconditions: [], output: [] })),
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The rules on `records` themselves: each is named once and addressed by its own required, internal key
|
|
398
|
+
* input; a created record is created by this Action, and an existing one is checked against its own
|
|
399
|
+
* required version input and changed or deleted by it. One whose key or version input is the subject's
|
|
400
|
+
* key or version input saves, and its write path refuses it (`deriveOntologyEditPlan`).
|
|
401
|
+
*/
|
|
402
|
+
function recordProblems(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts>, refused: string[], undeployable: string[]): void {
|
|
403
|
+
const records = list(doc.records)
|
|
404
|
+
const inputs = new Map(list(doc.inputs).flatMap((f) => (typeof f.apiName === 'string' ? [[f.apiName, f] as const] : [])))
|
|
405
|
+
const subjectType = isRecord(doc.subject) && typeof doc.subject.objectType === 'string' ? doc.subject.objectType : undefined
|
|
406
|
+
const subjectKey = subjectType ? types.get(subjectType)?.primaryKey : undefined
|
|
407
|
+
if (records.length > 10) refused.push(`records lists ${records.length} records; an Action writes at most 10 beside its subject`)
|
|
408
|
+
const keys = new Set<string>()
|
|
409
|
+
const keyInputs = new Set<string>()
|
|
410
|
+
const own = (where: string, role: string, name: string) => {
|
|
411
|
+
if (keyInputs.has(name)) refused.push(`${where} takes its ${role} from input ${name}, as another record does; give each record its own ${role} input`)
|
|
412
|
+
if (name === subjectKey || name === 'expectedVersion') undeployable.push(`${where} takes its ${role} from input ${name}, which already ${name === 'expectedVersion' ? 'carries the subject’s version' : 'addresses the subject'}; give the record its own ${role} input`)
|
|
413
|
+
keyInputs.add(name)
|
|
414
|
+
}
|
|
415
|
+
records.forEach((record, i) => {
|
|
416
|
+
const where = `records[${i}]`
|
|
417
|
+
if (typeof record.key !== 'string' || !/^[a-z][a-z0-9_]{0,99}$/.test(record.key)) refused.push(`${where} needs key: the record’s name in this Action, lowercase snake_case (key: event)`)
|
|
418
|
+
else if (keys.has(record.key)) refused.push(`${where}: two records are both named ${record.key}; give each its own key`)
|
|
419
|
+
else keys.add(record.key)
|
|
420
|
+
if (record.mode !== 'create' && record.mode !== 'existing') refused.push(`${where} needs mode: create (a record this Action creates) or existing (one it changes or deletes)`)
|
|
421
|
+
if (typeof record.objectType !== 'string') refused.push(`${where} needs objectType: the object type of the record, by apiName`)
|
|
422
|
+
else if (!types.has(record.objectType)) refused.push(`${where} names a ${record.objectType}, which no blueprint/object-types file defines`)
|
|
423
|
+
const keyInput = typeof record.keyInput === 'string' ? record.keyInput : undefined
|
|
424
|
+
if (!keyInput) refused.push(`${where} needs keyInput: the required input that carries the record’s primary key, by apiName`)
|
|
425
|
+
else {
|
|
426
|
+
const input = inputs.get(keyInput)
|
|
427
|
+
if (!input) refused.push(`${where} takes its key from input ${keyInput}, which the Action does not declare; add it under inputs (required: true)`)
|
|
428
|
+
else if (input.required !== true) refused.push(`${where} takes its key from input ${keyInput}, which must be required: true`)
|
|
429
|
+
else if (input.sensitivity !== 'internal') refused.push(`${where} takes its key from input ${keyInput}, which must be sensitivity: internal; a request names the records it writes wherever it is shown`)
|
|
430
|
+
own(where, 'key', keyInput)
|
|
431
|
+
}
|
|
432
|
+
if (record.mode !== 'existing') {
|
|
433
|
+
if (record.versionInput !== undefined) refused.push(`${where} is created by this Action, so it has no version to check; remove versionInput`)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const versionInput = typeof record.versionInput === 'string' ? record.versionInput : undefined
|
|
437
|
+
if (!versionInput) {
|
|
438
|
+
refused.push(`${where} needs versionInput: the required input that carries the version the request was decided on (a query row’s _meta.recordVersion), by apiName`)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
const input = inputs.get(versionInput)
|
|
442
|
+
if (!input) refused.push(`${where} takes its version from input ${versionInput}, which the Action does not declare; add it under inputs ({ kind: number, integer: true, minimum: 0 }, required: true)`)
|
|
443
|
+
else if (input.required !== true) refused.push(`${where} takes its version from input ${versionInput}, which must be required: true`)
|
|
444
|
+
own(where, 'version', versionInput)
|
|
445
|
+
})
|
|
446
|
+
const byKey = new Map(records.flatMap((record) => (typeof record.key === 'string' ? [[record.key, record] as const] : [])))
|
|
447
|
+
const created = new Set<string>()
|
|
448
|
+
const changed = new Set<string>()
|
|
449
|
+
list(doc.businessOutcomes).forEach((o, i) => list(o.expectedChanges).forEach((c, j) => {
|
|
450
|
+
if (c.record === undefined) return
|
|
451
|
+
const where = `businessOutcomes[${i}].expectedChanges[${j}]`
|
|
452
|
+
const record = typeof c.record === 'string' ? byKey.get(c.record) : undefined
|
|
453
|
+
if (!record) {
|
|
454
|
+
refused.push(`${where} names record ${String(c.record)}, which records does not declare`)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
if (c.objectType !== undefined && c.objectType !== record.objectType) refused.push(`${where} changes a ${String(c.objectType)}, but record ${String(c.record)} is a ${String(record.objectType)}`)
|
|
458
|
+
if (c.kind === 'lifecycle_transition') refused.push(`${where}: a lifecycle_transition changes the subject only, so it cannot name record ${String(c.record)}`)
|
|
459
|
+
else if (record.mode === 'existing' && c.kind === 'object_created') refused.push(`${where}: record ${String(c.record)} exists already, so object_created cannot name it; declare it with mode: create to create it`)
|
|
460
|
+
else if (record.mode !== 'existing' && c.kind === 'object_deleted') refused.push(`${where}: record ${String(c.record)} is created by this Action, so object_deleted cannot name it`)
|
|
461
|
+
if (o.effect !== 'confirmed_applied') return
|
|
462
|
+
if (c.kind === 'object_created') created.add(String(c.record))
|
|
463
|
+
else changed.add(String(c.record))
|
|
464
|
+
}))
|
|
465
|
+
for (const key of keys) {
|
|
466
|
+
const record = byKey.get(key)
|
|
467
|
+
const type = String(record?.objectType ?? '<ObjectType>')
|
|
468
|
+
if (record?.mode === 'existing') {
|
|
469
|
+
if (!changed.has(key)) refused.push(`records.${key} exists already and nothing changes it: add { kind: object_deleted, objectType: ${type}, record: ${key} } or a property_set with record: ${key} to its applied outcome`)
|
|
470
|
+
} else if (!created.has(key)) refused.push(`records.${key} is created by this Action: add { kind: object_created, objectType: ${type}, record: ${key} } to its applied outcome`)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function checkRecord(
|
|
475
|
+
doc: Doc,
|
|
476
|
+
types: ReadonlyMap<string, ObjectTypeFacts>,
|
|
477
|
+
refused: string[],
|
|
478
|
+
undeployable: string[],
|
|
479
|
+
/**
|
|
480
|
+
* A related record's key and version inputs; a subject's key input is named like its primary key,
|
|
481
|
+
* and its version input is expectedVersion.
|
|
482
|
+
*/
|
|
483
|
+
record?: { keyInput?: string; versionInput?: string },
|
|
484
|
+
): void {
|
|
485
|
+
const keyInput = record?.keyInput
|
|
486
|
+
const versionName = record?.versionInput ?? 'expectedVersion'
|
|
329
487
|
const subject = isRecord(doc.subject) ? doc.subject : {}
|
|
330
488
|
const effect = isRecord(doc.effect) ? doc.effect : {}
|
|
331
489
|
const concurrency = isRecord(doc.concurrency) ? doc.concurrency : {}
|
|
@@ -403,8 +561,11 @@ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts
|
|
|
403
561
|
const kindOf = (f: Doc | undefined) => (f && isRecord(f.valueType) && typeof f.valueType.kind === 'string' ? f.valueType.kind : undefined)
|
|
404
562
|
|
|
405
563
|
// The key: an edit or delete finds its record by it; a create files the new record under it.
|
|
406
|
-
const
|
|
407
|
-
|
|
564
|
+
const keyName = keyInput ?? pk
|
|
565
|
+
const key = keyName ? byName.get(keyName) : undefined
|
|
566
|
+
if (keyInput !== undefined) {
|
|
567
|
+
if (key && key.required !== true) undeployable.push(`inputs: "${keyInput}" is the record’s key, so it must be required: true`)
|
|
568
|
+
} else if (pk && !key) {
|
|
408
569
|
undeployable.push(
|
|
409
570
|
effectKind === 'create'
|
|
410
571
|
? `a create files the new record under its primary key, so it takes a required "${pk}" input (valueType: { kind: string, minLength: 1 }); add one`
|
|
@@ -417,7 +578,7 @@ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts
|
|
|
417
578
|
// compare and set on). Its value type is not a deploy rule: the version is checked as a whole
|
|
418
579
|
// number from 0 when the Action is submitted, and the platform itself writes `{ kind: number }`
|
|
419
580
|
// with `integer: false` and no minimum on Actions that deploy.
|
|
420
|
-
if (!byName.get('expectedVersion')) undeployable.push('concurrency expected_version needs an "expectedVersion" input: { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 }, required: true } — the record version the change was decided on (a query row’s _meta.recordVersion)')
|
|
581
|
+
if (record === undefined && !byName.get('expectedVersion')) undeployable.push('concurrency expected_version needs an "expectedVersion" input: { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 }, required: true } — the record version the change was decided on (a query row’s _meta.recordVersion)')
|
|
421
582
|
}
|
|
422
583
|
|
|
423
584
|
const changes = outcomes.flatMap((o) => list(o.expectedChanges))
|
|
@@ -446,21 +607,26 @@ export function checkAction(doc: Doc, types: ReadonlyMap<string, ObjectTypeFacts
|
|
|
446
607
|
}
|
|
447
608
|
if (pk && st.property === pk) {
|
|
448
609
|
if (st.value.kind === 'literal') undeployable.push(`${st.where} sets the primary key ${pk} to a constant; the key comes from its "${pk}" input, so remove this change`)
|
|
449
|
-
else if (effectKind !== 'create' || st.value.field ===
|
|
610
|
+
else if (effectKind !== 'create' || st.value.field === keyName) {
|
|
450
611
|
undeployable.push(
|
|
451
612
|
effectKind === 'create'
|
|
452
|
-
? `${st.where} sets the primary key ${pk}; a create already files the record under its "${
|
|
613
|
+
? `${st.where} sets the primary key ${pk}; a create already files the record under its "${keyName}" input, so remove this change`
|
|
453
614
|
: `${st.where} sets the primary key ${pk}, which addresses the record and cannot be changed; remove this change`,
|
|
454
615
|
)
|
|
455
616
|
}
|
|
456
617
|
continue
|
|
457
618
|
}
|
|
458
|
-
if (st.value.kind === 'literal')
|
|
619
|
+
if (st.value.kind === 'literal') {
|
|
620
|
+
if (st.value.value === null && subjectType?.required?.has(st.property)) {
|
|
621
|
+
undeployable.push(`${st.where} sets ${st.property} to null, and ${typeName} requires it; set a value, or make ${st.property} optional (required: false)`)
|
|
622
|
+
}
|
|
623
|
+
continue
|
|
624
|
+
}
|
|
459
625
|
if (st.value.kind !== 'input' || typeof st.value.field !== 'string') continue
|
|
460
626
|
fromInput++
|
|
461
627
|
if (byLiteral.has(st.property)) undeployable.push(`${st.where} sets ${st.property} from input ${st.value.field}, and another change sets it to a constant; keep one`)
|
|
462
|
-
if (st.value.field ===
|
|
463
|
-
undeployable.push(`${st.where} sets ${st.property} from
|
|
628
|
+
if (st.value.field === versionName && strategy === 'expected_version') {
|
|
629
|
+
undeployable.push(`${st.where} sets ${st.property} from ${versionName}, which already carries the record version; give ${st.property} its own input`)
|
|
464
630
|
}
|
|
465
631
|
// The input's kind fits the property's base type.
|
|
466
632
|
const base = subjectType?.valueTypes?.get(st.property)
|
package/src/blueprint/diff.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type DefinitionBundle,
|
|
9
9
|
} from './model'
|
|
10
10
|
import { CliError } from '../errors'
|
|
11
|
+
import type { PropertyIntents, PropertyRefusal } from './property-intents'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The plan: what the files say that the live draft does not, expressed as typed
|
|
@@ -37,6 +38,8 @@ export interface ModelOperation {
|
|
|
37
38
|
* field is not merely wasteful, it fails the run.
|
|
38
39
|
*/
|
|
39
40
|
liveDocument?: Record<string, unknown>
|
|
41
|
+
/** An object type's properties that keep their id and take a new name (`renamedFrom`), made before anything else on it. */
|
|
42
|
+
renames?: Array<{ from: string; to: string }>
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export interface BindingDrift {
|
|
@@ -66,6 +69,8 @@ export interface Plan {
|
|
|
66
69
|
editable: EditableChange[]
|
|
67
70
|
/** Object types whose `backing.dataset` names another Dataset than the draft reads. */
|
|
68
71
|
rebind: RebindChange[]
|
|
72
|
+
/** What the files' `renamedFrom` and `replaces` keys cannot be applied as (`property-intents.ts`). */
|
|
73
|
+
propertyRefusals?: PropertyRefusal[]
|
|
69
74
|
unchanged: number
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -270,13 +275,18 @@ function propertyNames(document: Record<string, unknown>): string[] {
|
|
|
270
275
|
.filter((name): name is string => typeof name === 'string')
|
|
271
276
|
}
|
|
272
277
|
|
|
273
|
-
/** `+1 property (tier)` / `-1 property (legacyCode)`, or a bare field count. */
|
|
274
|
-
function describeUpdate(local: Record<string, unknown>, live: Record<string, unknown
|
|
278
|
+
/** `+1 property (tier)` / `-1 property (legacyCode)`, a rename or a replacement said as one, or a bare field count. */
|
|
279
|
+
function describeUpdate(local: Record<string, unknown>, live: Record<string, unknown>, intents?: Pick<PropertyIntents, 'renames' | 'replacements'>, objectApiName?: string): string {
|
|
275
280
|
const before = new Set(propertyNames(live))
|
|
276
281
|
const after = propertyNames(local)
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
const
|
|
282
|
+
const replacements = (intents?.replacements ?? []).filter((entry) => entry.objectApiName === objectApiName)
|
|
283
|
+
const added = after.filter((name) => !before.has(name) && !replacements.some((entry) => entry.added === name))
|
|
284
|
+
const removed = [...before].filter((name) => !after.includes(name) && !replacements.some((entry) => entry.removed === name))
|
|
285
|
+
const parts: string[] = [
|
|
286
|
+
...(intents?.renames ?? []).filter((entry) => entry.objectApiName === objectApiName)
|
|
287
|
+
.map((entry) => `rename ${entry.from} → ${entry.to} (its values, edits and references stay)`),
|
|
288
|
+
...replacements.map((entry) => `replace ${entry.removed} with ${entry.added} (${entry.removed}'s values are not carried over)`),
|
|
289
|
+
]
|
|
280
290
|
if (added.length) parts.push(`+${added.length} propert${added.length === 1 ? 'y' : 'ies'} (${added.join(', ')})`)
|
|
281
291
|
if (removed.length) parts.push(`-${removed.length} propert${removed.length === 1 ? 'y' : 'ies'} (${removed.join(', ')})`)
|
|
282
292
|
return parts.length ? parts.join(', ') : 'fields changed'
|
|
@@ -351,6 +361,8 @@ export function diff(
|
|
|
351
361
|
revision: number,
|
|
352
362
|
/** Absent when the Datasets were not read: no rebind is planned. */
|
|
353
363
|
datasets?: DatasetIndex,
|
|
364
|
+
/** Read from the files first (`readPropertyIntents`): then `localFiles` and `live` are its own. */
|
|
365
|
+
intents?: Omit<PropertyIntents, 'files' | 'live'>,
|
|
354
366
|
): Plan {
|
|
355
367
|
const liveModel = toFiles(live)
|
|
356
368
|
const liveByKey = new Map(liveModel.files.map((file) => [`${file.kind}:${file.apiName}`, file]))
|
|
@@ -382,7 +394,10 @@ export function diff(
|
|
|
382
394
|
})
|
|
383
395
|
continue
|
|
384
396
|
}
|
|
385
|
-
|
|
397
|
+
const renames = kind === 'object-type'
|
|
398
|
+
? (intents?.renames ?? []).filter((entry) => entry.objectApiName === file.apiName).map(({ from, to }) => ({ from, to }))
|
|
399
|
+
: []
|
|
400
|
+
if (matches(kind, file.document, existing.document) && renames.length === 0) {
|
|
386
401
|
if (kind !== 'object-type' || !editableNames.has(file.apiName)) unchanged += 1
|
|
387
402
|
continue
|
|
388
403
|
}
|
|
@@ -392,7 +407,8 @@ export function diff(
|
|
|
392
407
|
apiName: file.apiName,
|
|
393
408
|
document: file.document,
|
|
394
409
|
liveDocument: existing.document,
|
|
395
|
-
detail: describeUpdate(file.document, existing.document),
|
|
410
|
+
detail: describeUpdate(file.document, existing.document, intents, file.apiName),
|
|
411
|
+
...(renames.length ? { renames } : {}),
|
|
396
412
|
})
|
|
397
413
|
}
|
|
398
414
|
for (const file of liveModel.files.filter((candidate) => candidate.kind === kind)) {
|
|
@@ -406,7 +422,10 @@ export function diff(
|
|
|
406
422
|
}
|
|
407
423
|
}
|
|
408
424
|
|
|
409
|
-
return {
|
|
425
|
+
return {
|
|
426
|
+
revision, operations, prunes, drift: driftOf(live), editable, rebind, unchanged,
|
|
427
|
+
...(intents?.refusals.length ? { propertyRefusals: intents.refusals } : {}),
|
|
428
|
+
}
|
|
410
429
|
}
|
|
411
430
|
|
|
412
431
|
/**
|
|
@@ -25,6 +25,11 @@ export interface PublishedActionDefinition {
|
|
|
25
25
|
businessOutcomes: Array<{
|
|
26
26
|
expectedChanges?: Array<Record<string, unknown>>
|
|
27
27
|
}>
|
|
28
|
+
/** Records the Action writes beside its subject; each becomes a related step of the plan. */
|
|
29
|
+
records?: Array<
|
|
30
|
+
| { key: string; objectTypeId: string; mode: 'create'; keyFieldId: string }
|
|
31
|
+
| { key: string; objectTypeId: string; mode: 'existing'; keyFieldId: string; versionFieldId: string }
|
|
32
|
+
>
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface ObjectTypeShape {
|
|
@@ -63,6 +68,16 @@ export interface OntologyEditPlan {
|
|
|
63
68
|
mediaAttachment?: { propertyApiName?: string; itemIdProperty?: string; mediaSetId: string; uploadParameter: string }
|
|
64
69
|
impactBounds: { maxAffectedRows: number; maxTouchedRows: number }
|
|
65
70
|
capabilities: Record<string, unknown>
|
|
71
|
+
related?: Array<{
|
|
72
|
+
record: string
|
|
73
|
+
objectTypeId: string
|
|
74
|
+
objectTypeApiName: string
|
|
75
|
+
op: 'create' | 'patch' | 'delete'
|
|
76
|
+
set: Record<string, string>
|
|
77
|
+
pkParameter: string
|
|
78
|
+
/** Present exactly when the record exists already. */
|
|
79
|
+
expectedVersionParameter?: string
|
|
80
|
+
}>
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
export class PlanDerivationError extends Error {
|
|
@@ -94,6 +109,46 @@ export function ontologyEditCompatibilityFingerprint(
|
|
|
94
109
|
return `sha256:${createHash('sha256').update(canonical).digest('hex')}`
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The service's `ontologyEditPlanFingerprint`, reproduced: the subject's
|
|
114
|
+
* fingerprint alone, or for a plan with related records, one over the subject's
|
|
115
|
+
* and each record's (canonical JSON, keys in lexicographic order).
|
|
116
|
+
*/
|
|
117
|
+
export function ontologyEditPlanFingerprint(plan: Pick<OntologyEditPlan, 'targetCompatibility' | 'related'>): string {
|
|
118
|
+
const subject = plan.targetCompatibility.expectedFingerprint
|
|
119
|
+
if (!plan.related?.length) return subject
|
|
120
|
+
const related = plan.related
|
|
121
|
+
.map((step) => ({ fingerprint: ontologyEditCompatibilityFingerprint(step.objectTypeId, Object.keys(step.set)), record: step.record }))
|
|
122
|
+
.sort((a, b) => (a.record < b.record ? -1 : a.record > b.record ? 1 : 0))
|
|
123
|
+
return `sha256:${createHash('sha256').update(JSON.stringify({ related, subject })).digest('hex')}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Action as one record sees it: the service's `actionDefinitionForRecord`. */
|
|
127
|
+
function actionForRecord(
|
|
128
|
+
action: PublishedActionDefinition,
|
|
129
|
+
record: NonNullable<PublishedActionDefinition['records']>[number] | null,
|
|
130
|
+
): PublishedActionDefinition {
|
|
131
|
+
const changesOf = (outcome: PublishedActionDefinition['businessOutcomes'][number]) =>
|
|
132
|
+
(outcome.expectedChanges ?? [])
|
|
133
|
+
.filter((change) => change.record === record?.key)
|
|
134
|
+
.map(({ record: _record, ...change }) => change)
|
|
135
|
+
if (record === null) {
|
|
136
|
+
return { ...action, businessOutcomes: action.businessOutcomes.map((outcome) => ({ ...outcome, expectedChanges: changesOf(outcome) })) }
|
|
137
|
+
}
|
|
138
|
+
const businessOutcomes = action.businessOutcomes.map((outcome) => ({ ...outcome, expectedChanges: changesOf(outcome) }))
|
|
139
|
+
if (record.mode === 'create') {
|
|
140
|
+
return { ...action, subject: { objectTypeId: record.objectTypeId, mode: 'create' }, effect: { kind: 'create' }, concurrency: { strategy: 'append_once' }, businessOutcomes }
|
|
141
|
+
}
|
|
142
|
+
const deletes = businessOutcomes.some((outcome) => outcome.expectedChanges.some((change) => change.kind === 'object_deleted'))
|
|
143
|
+
return {
|
|
144
|
+
...action,
|
|
145
|
+
subject: { objectTypeId: record.objectTypeId, mode: 'existing' },
|
|
146
|
+
effect: { kind: deletes ? 'delete' : 'update' },
|
|
147
|
+
concurrency: { strategy: 'expected_version' },
|
|
148
|
+
businessOutcomes,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
97
152
|
const OP_FOR_EFFECT: Readonly<Record<string, OntologyEditPlan['op']>> = {
|
|
98
153
|
update: 'patch',
|
|
99
154
|
create: 'create',
|
|
@@ -104,6 +159,102 @@ export function deriveOntologyEditPlan(
|
|
|
104
159
|
action: PublishedActionDefinition,
|
|
105
160
|
objectType: ObjectTypeShape,
|
|
106
161
|
attachment?: MediaAttachmentChoice,
|
|
162
|
+
/** The object types of the records the Action writes beside its subject. */
|
|
163
|
+
relatedTypes: readonly ObjectTypeShape[] = [],
|
|
164
|
+
): OntologyEditPlan {
|
|
165
|
+
if (!action.records?.length) return deriveStep(action, objectType, attachment)
|
|
166
|
+
const subject = deriveStep(actionForRecord(action, null), objectType, attachment)
|
|
167
|
+
const inputName = (id: string) => action.inputs.find((input) => input.id === id)?.apiName
|
|
168
|
+
// Every version parameter of the edit: none may also be written as a value.
|
|
169
|
+
const versions = [
|
|
170
|
+
...(subject.expectedVersionParameter ? [subject.expectedVersionParameter] : []),
|
|
171
|
+
...action.records.flatMap((record) => (record.mode === 'existing' ? [inputName(record.versionFieldId)].filter((name): name is string => name !== undefined) : [])),
|
|
172
|
+
]
|
|
173
|
+
const related = action.records.map((record) => {
|
|
174
|
+
const type = relatedTypes.find((candidate) => candidate.id === record.objectTypeId)
|
|
175
|
+
if (!type) {
|
|
176
|
+
throw new PlanDerivationError(
|
|
177
|
+
`Action "${action.apiName}" writes record "${record.key}" of an object type this deploy did not find.`,
|
|
178
|
+
'Publish the object type the record names, then deploy again.',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
const keyParameter = action.inputs.find((input) => input.id === record.keyFieldId)?.apiName
|
|
182
|
+
if (!keyParameter) {
|
|
183
|
+
throw new PlanDerivationError(
|
|
184
|
+
`Action "${action.apiName}" files record "${record.key}" under an input it does not declare.`,
|
|
185
|
+
'Name a required input of the Action as the record’s keyInput.',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (keyParameter === subject.pkParameter || keyParameter === subject.expectedVersionParameter) {
|
|
189
|
+
throw new PlanDerivationError(
|
|
190
|
+
`Action "${action.apiName}" files record "${record.key}" under ${keyParameter}, which already ${keyParameter === subject.pkParameter ? 'addresses the subject' : 'carries the subject’s version'}.`,
|
|
191
|
+
'Give the record its own required key input.',
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
const versionParameter = record.mode === 'existing' ? inputName(record.versionFieldId) : undefined
|
|
195
|
+
if (record.mode === 'existing' && !versionParameter) {
|
|
196
|
+
throw new PlanDerivationError(
|
|
197
|
+
`Action "${action.apiName}" checks record "${record.key}" against a version input it does not declare.`,
|
|
198
|
+
'Name a required input of the Action as the record’s versionInput.',
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
if (versionParameter && (versionParameter === subject.pkParameter || versionParameter === subject.expectedVersionParameter)) {
|
|
202
|
+
throw new PlanDerivationError(
|
|
203
|
+
`Action "${action.apiName}" checks record "${record.key}" against ${versionParameter}, which already ${versionParameter === subject.pkParameter ? 'addresses the subject' : 'carries the subject’s version'}.`,
|
|
204
|
+
'Give the record its own required version input.',
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
let step: OntologyEditPlan
|
|
208
|
+
try {
|
|
209
|
+
step = deriveStep(actionForRecord(action, record), type, undefined, keyParameter, versionParameter)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!(error instanceof PlanDerivationError)) throw error
|
|
212
|
+
throw new PlanDerivationError(`Record "${record.key}": ${error.message}`, error.hint)
|
|
213
|
+
}
|
|
214
|
+
const fromVersion = Object.values(step.set).find((parameter) => versions.includes(parameter))
|
|
215
|
+
if (fromVersion) {
|
|
216
|
+
throw new PlanDerivationError(
|
|
217
|
+
`Record "${record.key}" writes a property from ${fromVersion}, which carries a record’s version.`,
|
|
218
|
+
'Give that property its own input: every mapped parameter carries exactly one role.',
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
record: record.key,
|
|
223
|
+
objectTypeId: step.objectTypeId,
|
|
224
|
+
objectTypeApiName: step.objectTypeApiName,
|
|
225
|
+
op: step.op,
|
|
226
|
+
set: step.set,
|
|
227
|
+
pkParameter: step.pkParameter,
|
|
228
|
+
...(step.expectedVersionParameter ? { expectedVersionParameter: step.expectedVersionParameter } : {}),
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
const addresses = related.flatMap((step) => [step.pkParameter, ...(step.expectedVersionParameter ? [step.expectedVersionParameter] : [])])
|
|
232
|
+
const reused = addresses.find((parameter, index) => addresses.indexOf(parameter) !== index)
|
|
233
|
+
if (reused) {
|
|
234
|
+
throw new PlanDerivationError(
|
|
235
|
+
`Action "${action.apiName}" addresses two records with the same input, ${reused}.`,
|
|
236
|
+
'Give each record its own required key input and, when it exists, its own version input.',
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
const rows = 1 + related.length
|
|
240
|
+
return {
|
|
241
|
+
...subject,
|
|
242
|
+
targetCompatibility: { kind: 'ontology_edit', expectedFingerprint: ontologyEditPlanFingerprint({ ...subject, related }) },
|
|
243
|
+
impactBounds: { maxAffectedRows: rows, maxTouchedRows: rows },
|
|
244
|
+
related,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* One record's step: the subject's, or a related record's addressed by `keyParameter` and, when it
|
|
250
|
+
* exists, checked against `versionParameter`.
|
|
251
|
+
*/
|
|
252
|
+
function deriveStep(
|
|
253
|
+
action: PublishedActionDefinition,
|
|
254
|
+
objectType: ObjectTypeShape,
|
|
255
|
+
attachment?: MediaAttachmentChoice,
|
|
256
|
+
keyParameter?: string,
|
|
257
|
+
versionParameter?: string,
|
|
107
258
|
): OntologyEditPlan {
|
|
108
259
|
const op = OP_FOR_EFFECT[action.effect.kind]
|
|
109
260
|
if (!op) {
|
|
@@ -134,7 +285,7 @@ export function deriveOntologyEditPlan(
|
|
|
134
285
|
const constants: string[] = []
|
|
135
286
|
for (const outcome of action.businessOutcomes) {
|
|
136
287
|
for (const change of outcome.expectedChanges ?? []) {
|
|
137
|
-
if (change.kind !== 'property_set') continue
|
|
288
|
+
if (change.kind !== 'property_set' || change.record !== undefined) continue
|
|
138
289
|
const value = change.value as { kind?: string; fieldId?: string } | undefined
|
|
139
290
|
if (value?.kind === 'literal') {
|
|
140
291
|
const property = propertyApiNameById.get(String(change.propertyId))
|
|
@@ -177,7 +328,7 @@ export function deriveOntologyEditPlan(
|
|
|
177
328
|
'Set one with `frontera blueprint update object-type`, then publish.',
|
|
178
329
|
)
|
|
179
330
|
}
|
|
180
|
-
const pkParameter = action.inputs.find((i) => i.apiName === pkApiName)?.apiName
|
|
331
|
+
const pkParameter = keyParameter ?? action.inputs.find((i) => i.apiName === pkApiName)?.apiName
|
|
181
332
|
if (!pkParameter) {
|
|
182
333
|
throw new PlanDerivationError(
|
|
183
334
|
`Action "${action.apiName}" takes no "${pkApiName}" parameter, so an edit has no address.`,
|
|
@@ -213,7 +364,7 @@ export function deriveOntologyEditPlan(
|
|
|
213
364
|
// Only when the Action asked for it. Compare-and-set is the Action's
|
|
214
365
|
// concurrency contract, not a deployment preference.
|
|
215
366
|
const expectedVersionParameter = action.concurrency.strategy === 'expected_version'
|
|
216
|
-
? action.inputs.find((i) => i.apiName === 'expectedVersion')?.apiName
|
|
367
|
+
? versionParameter ?? action.inputs.find((i) => i.apiName === 'expectedVersion')?.apiName
|
|
217
368
|
: undefined
|
|
218
369
|
if (action.concurrency.strategy === 'expected_version' && !expectedVersionParameter) {
|
|
219
370
|
throw new PlanDerivationError(
|
|
@@ -229,7 +380,7 @@ export function deriveOntologyEditPlan(
|
|
|
229
380
|
}
|
|
230
381
|
if (expectedVersionParameter && Object.values(set).includes(expectedVersionParameter)) {
|
|
231
382
|
throw new PlanDerivationError(
|
|
232
|
-
`Action "${action.apiName}" writes a property from
|
|
383
|
+
`Action "${action.apiName}" writes a property from ${expectedVersionParameter}, which carries the record version.`,
|
|
233
384
|
'Give that property its own input: every mapped parameter carries exactly one role.',
|
|
234
385
|
)
|
|
235
386
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { localProject } from '../local/local-project'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { signInCommand } from '../device-login'
|
|
3
|
+
import { unwrap, type SyncClient } from '../local/local-sync-client'
|
|
4
|
+
import { organizationReader, readLink, targetProfileName } from '../local/local-sync-link'
|
|
4
5
|
import type { ArtifactKind } from './model'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -8,9 +9,9 @@ import type { ArtifactKind } from './model'
|
|
|
8
9
|
* (Forge improvement plan X11). A workspace sees only its slice of the
|
|
9
10
|
* organization's Blueprint, so `blueprint new object-type Activity` wrote a
|
|
10
11
|
* file a change request then refused: the organization had an `Activity`
|
|
11
|
-
* outside the slice (P5 A9). Read
|
|
12
|
-
*
|
|
13
|
-
* checked, and says so.
|
|
12
|
+
* outside the slice (P5 A9). Read as the person signed in for the project's
|
|
13
|
+
* workspace, or with its organization key; without a link or either it is
|
|
14
|
+
* not checked, and says so.
|
|
14
15
|
*/
|
|
15
16
|
export type NameCheck =
|
|
16
17
|
| { checked: false; why: string }
|
|
@@ -39,9 +40,10 @@ export async function orgNameCheck(root: string, kind: ArtifactKind, apiName: st
|
|
|
39
40
|
const project = localProject(root)
|
|
40
41
|
const link = readLink(project.root)
|
|
41
42
|
if (!link) return { checked: false, why: 'the project is linked to no workspace' }
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
client
|
|
43
|
+
const reader = await organizationReader(project, link)
|
|
44
|
+
if (reader.notSteward) return { checked: false, why: `${reader.notSteward}, signed in for the project's workspace, is not a Blueprint Steward, who reads the organization's names` }
|
|
45
|
+
if (!reader.client) return { checked: false, why: `no one is signed in for the project's workspace (\`${signInCommand(targetProfileName(project, link))}\`)` }
|
|
46
|
+
client = reader.client
|
|
45
47
|
}
|
|
46
48
|
try {
|
|
47
49
|
const found = await client.find<unknown>('/v1/blueprint/lifecycle')
|