@frontera-sdk/cli 1.53.7 → 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.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +3 -4
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
@@ -5,7 +5,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path'
5
5
  import { parse as parseYaml } from 'yaml'
6
6
 
7
7
  import type { Portability } from './local-portability'
8
- import { conflictsId, deepEqual, mergeItemFiles, type Choice, type Side } from './local-sync-merge'
8
+ import { conflictsId, deepEqual, mergeItemFiles, sameContent, type Choice, type Side } from './local-sync-merge'
9
9
  import { itemTitle } from './local-sync-names'
10
10
  import { toNames, type Directory, type Unresolved } from './local-sync-refs'
11
11
  import { comparableText, isPluginFile, withLocalSecrets } from './local-secrets'
@@ -358,6 +358,32 @@ export function baseFiles(entry: LockEntry | undefined, read: ((path: string) =>
358
358
  return out
359
359
  }
360
360
 
361
+ /** Whether two sets of an item's files say the same, file by file: a missing file only matches a missing one. */
362
+ function sameFiles(a: Record<string, string | null>, b: Record<string, string | null>): boolean {
363
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((path) => {
364
+ const x = a[path] ?? null
365
+ const y = b[path] ?? null
366
+ return x === y || (x !== null && y !== null && sameContent(path, x, y))
367
+ })
368
+ }
369
+
370
+ /** Every base file is here, as the last pull wrote it: without one, a deletion on one side reads as an addition on the other. */
371
+ const wholeBase = (base: Record<string, string | null>) => Object.keys(base).length > 0 && Object.values(base).every((v) => v !== null)
372
+
373
+ /**
374
+ * The status again, from what the files say rather than their exact text,
375
+ * when every base file is here to compare with: a side whose files
376
+ * differ from the base only in key order or formatting (the platform writes
377
+ * a document's keys its own way from one read to the next) has not moved.
378
+ */
379
+ function settledStatus(found: ItemStatus, base: Record<string, string | null>, local: Record<string, string | null>, remote: Record<string, string | null>): ItemStatus {
380
+ if (found !== 'conflict' && found !== 'edited' && found !== 'changed') return found
381
+ if (!wholeBase(base)) return found
382
+ const localMoved = !sameFiles(base, local)
383
+ const remoteMoved = !sameFiles(base, remote)
384
+ return remoteMoved ? (localMoved ? 'conflict' : 'changed') : localMoved ? 'edited' : 'same'
385
+ }
386
+
361
387
  /** The working files at these paths, null where absent. */
362
388
  export function localFiles(root: string, paths: string[]): Record<string, string | null> {
363
389
  return Object.fromEntries(paths.map((p) => [p, readLocal(root, p)]))
@@ -414,18 +440,30 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
414
440
  // An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
415
441
  // `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
416
442
  const added = item.kind === 'agent' ? AGENT_FILES.map((f) => `agents/${item.name}/${f}`).filter((p) => !paths.includes(p) && existsSync(join(root, p))) : []
417
- const matches = files.every((f) => f.local === f.remote) && !added.length
443
+ // Saying the same as the workspace is up to date, whatever order the platform wrote the keys in.
444
+ const matches = files.every((f) => f.local === f.remote || (f.local !== null && f.remote !== null && sameContent(f.path, f.local, f.remote))) && !added.length
418
445
  // The files as they stand over the base's paths, plus any path only the workspace has that is
419
446
  // already in the folder: a file the pull would overwrite counts as a change here.
420
447
  const basePaths = entry ? [...Object.keys(entry.files), ...files.filter((f) => !(f.path in entry.files) && f.local !== null).map((f) => f.path), ...added] : Object.keys(item.files)
421
- const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base, item.files), itemHash(item.files), matches) ?? 'same'
448
+ const hashed = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base, item.files), itemHash(item.files), matches) ?? 'same'
449
+ const base = baseFiles(entry, opts.base)
450
+ let found = entry && !entry.conflict && !added.length ? settledStatus(hashed, base, Object.fromEntries(files.map((f) => [f.path, f.local])), Object.fromEntries(files.map((f) => [f.path, f.remote]))) : hashed
422
451
  // Pulled with conflicts and not resolved: it stays so until `resolve`, whatever the files say.
423
- const status: ItemStatus = entry?.conflict && found !== 'same' ? 'unresolved' : found
424
452
  let merge: PlanItem['merge']
425
- if (status === 'conflict' || status === 'unresolved') {
453
+ if (found === 'conflict' || (entry?.conflict && found !== 'same')) {
426
454
  const local = localFiles(root, paths)
427
- merge = { conflicts: mergeItemFiles(baseFiles(entry, opts.base), local, item.files).conflicts }
455
+ const result = mergeItemFiles(base, local, item.files)
456
+ merge = { conflicts: result.conflicts }
457
+ // A clean merge that leaves every file as it is here: what the workspace changed is already in the files
458
+ // (proposed from here, or a set only reordered there). Nothing comes in; the change is this side's alone.
459
+ // Only against the whole base: without it the merge keeps what the workspace deleted, and the item stays a
460
+ // conflict, so the base is looked for again and a push waits for the pull.
461
+ if (found === 'conflict' && entry && !entry.conflict && wholeBase(base) && !result.conflicts && result.files.every((f) => f.merged === (local[f.path] ?? null))) {
462
+ found = 'edited'
463
+ merge = undefined
464
+ }
428
465
  }
466
+ const status: ItemStatus = entry?.conflict && found !== 'same' ? 'unresolved' : found
429
467
  const namesOnly = status === 'changed' && opts.directory ? differsOnlyInNames(localFiles(root, paths), item.files, opts.directory) : false
430
468
  plan.push({
431
469
  ref: item.ref,
@@ -65,7 +65,8 @@ export function fixFor(check: Check): string | null {
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
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
- if (check.code === 'blueprint_draft') return 'publish or discard the Blueprint’s draft on the platform, then propose'
68
+ if (check.code === 'blueprint_draft') return 'publish the organization’s Blueprint on the platform first, then propose'
69
+ if (check.code === 'blueprint_draft_conflict') return 'publish or discard that change in the Blueprint on the platform, or leave this item out (`frontera local sync push <kind/name>…` proposes only the items it names), then propose'
69
70
  const model = /the model (\S+) is not in this workspace/.exec(check.message)
70
71
  if (model) return `choose the workspace model it goes as (\`frontera local sync map-model ${model[1]} <workspace model>\`)`
71
72
  if (check.code === 'unresolved') return 'propose what it names too, or create that in the workspace first'
@@ -17,7 +17,7 @@ import { importedFiles } from './local-function-shared'
17
17
  import { isIgnoredName } from './local-dev-sync'
18
18
  import { comparableText, withoutSecrets } from './local-secrets'
19
19
  import { projectModels } from './local-sync-models'
20
- import { deepEqual } from './local-sync-merge'
20
+ import { deepEqual, sameContent } from './local-sync-merge'
21
21
  import { portableNotes, sameDocumentText, toLocal, toWorkspace } from './local-sync-portable'
22
22
  import { itemTitle } from './local-sync-names'
23
23
  import { isScaffoldSample } from './local-scaffold'
@@ -443,12 +443,18 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
443
443
  // A Plugin whose only difference is this computer's secrets (a header set here) is the same, as status reads it.
444
444
  const pluginSame = kind === 'plugin' && !!theirs && Object.keys(files).length > 0 && Object.keys(files).every((p) => theirs.files[p] !== undefined && comparableText(p, readLocal(root, p) ?? '', theirs.files[p]!) === theirs.files[p])
445
445
  const textSame = !entry || itemHash(files) === entry.remote || !theirs || itemHash(theirs.files) !== entry.remote || pluginSame
446
- if (status === 'same' && textSame && !extra) continue
446
+ // Up to date as status reads it: the files say what the workspace's say, keys in another order or not.
447
+ const saysSame = status === 'same' && !!theirs && Object.keys(files).length === Object.keys(theirs.files).length && Object.entries(files).every(([path, text]) => theirs.files[path] !== undefined && sameContent(path, text, theirs.files[path]!))
448
+ if (status === 'same' && (textSame || saysSame) && !extra) continue
447
449
  if (status === 'same' && Object.keys(files).length === 0) continue
448
450
  const isNew = !entry && !theirs
449
- const baseFiles: Record<string, string> = !isNew && theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.files : {}
451
+ // The workspace has not moved since the last pull (the plan reads what its files say, key order aside), or what it
452
+ // changed is already in the files here: its version now is the base.
453
+ const unmoved = !!theirs && !!entry && (itemHash(theirs.files) === entry.remote || p?.status === 'edited')
454
+ const baseFiles: Record<string, string> = !isNew && unmoved ? theirs!.files : {}
450
455
  const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
451
- const diff: PushFile[] = diffPaths.map((path) => ({ path, base: baseFiles[path] ?? null, local: files[path] ?? null })).filter((f) => f.base !== f.local)
456
+ // A file that says the same as the base, keys in another order, is no change to show.
457
+ const diff: PushFile[] = diffPaths.map((path) => ({ path, base: baseFiles[path] ?? null, local: files[path] ?? null })).filter((f) => f.base !== f.local && !(f.base !== null && f.local !== null && sameContent(f.path, f.base, f.local)))
452
458
  const item: PushItem = {
453
459
  ref,
454
460
  kind,
@@ -513,7 +519,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
513
519
  } else if (item.proposable && !isNew) {
514
520
  // The base is what the workspace holds now (the plan says only this side moved): its current
515
521
  // revision, which for a Blueprint file is the Blueprint's.
516
- item.baseRevision = (kind === 'blueprint' || kind === 'evaluation' ? snapshot?.groups[kind] : undefined) ?? (theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.revision : undefined) ?? entry?.revision ?? null
522
+ item.baseRevision = (kind === 'blueprint' || kind === 'evaluation' ? snapshot?.groups[kind] : undefined) ?? (unmoved ? theirs!.revision : undefined) ?? entry?.revision ?? null
517
523
  } else if (item.proposable && kind === 'blueprint') {
518
524
  item.baseRevision = snapshot?.groups.blueprint ?? null
519
525
  }
@@ -805,12 +811,10 @@ export function sameDocument(path: string, a: string, b: string): boolean {
805
811
  if (/^agents\/[^/]+\/evals\/[^/]+\.ya?ml$/.test(path)) return deepEqual(evaluationCases(a), evaluationCases(b))
806
812
  // A knowledge base's quality checks are a set: the platform lists them in its own order.
807
813
  if (path.endsWith(`/${QUALITY_CHECKS_FILE}`)) return deepEqual(checkSet(a), checkSet(b))
808
- if (/\.json$/i.test(path)) return deepEqual(JSON.parse(a), JSON.parse(b))
809
- if (/\.ya?ml$/i.test(path)) return deepEqual(parseYaml(a), parseYaml(b))
810
814
  } catch {
811
815
  return false
812
816
  }
813
- return false
817
+ return sameContent(path, a, b)
814
818
  }
815
819
 
816
820
  /** Quality checks as a set: by question, each one's lists in order of their words. */
@@ -1017,7 +1021,8 @@ export interface ApplyOutcome {
1017
1021
  ok: boolean
1018
1022
  steps: ApplyStep[]
1019
1023
  failure?: { key: string; ref: string; itemIds: string[]; message: string }
1020
- compensation: Array<{ label: string; type: string; status: 'put-back' | 'failed' | 'irreversible'; error?: string }>
1024
+ /** `note`: what a put-back left other than as it was. */
1025
+ compensation: Array<{ label: string; type: string; status: 'put-back' | 'failed' | 'irreversible'; error?: string; note?: string }>
1021
1026
  }
1022
1027
 
1023
1028
  /** Apply's outcome as the service sent it, read defensively: a field it lacks reads as empty. */
@@ -1042,7 +1047,13 @@ export function parseOutcome(raw: unknown): ApplyOutcome {
1042
1047
  if (!c || typeof c !== 'object') return []
1043
1048
  const r = c as Record<string, unknown>
1044
1049
  const status = r.status === 'failed' || r.status === 'irreversible' ? r.status : 'put-back'
1045
- return [{ label: String(r.label ?? ''), type: String(r.type ?? ''), status: status as 'put-back' | 'failed' | 'irreversible', ...(typeof r.error === 'string' ? { error: r.error } : {}) }]
1050
+ return [{
1051
+ label: String(r.label ?? ''),
1052
+ type: String(r.type ?? ''),
1053
+ status: status as 'put-back' | 'failed' | 'irreversible',
1054
+ ...(typeof r.error === 'string' ? { error: r.error } : {}),
1055
+ ...(typeof r.note === 'string' ? { note: r.note } : {}),
1056
+ }]
1046
1057
  })
1047
1058
  return {
1048
1059
  ok: o.ok === true,
@@ -1074,7 +1085,7 @@ export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, st
1074
1085
  `Not applied: ${failed ? `${failedName(outcome, titles)} failed: ${failed.message}` : 'a step failed'}`,
1075
1086
  ...lines,
1076
1087
  ...(outcome.compensation.length
1077
- ? ['', 'What was put back:', ...outcome.compensation.map((c) => ` ${c.label}: ${BACK_WORD[c.status]}${c.error ? ` (${c.error})` : ''}`)]
1088
+ ? ['', 'What was put back:', ...outcome.compensation.map((c) => ` ${c.label}: ${BACK_WORD[c.status]}${c.error ? ` (${c.error})` : ''}${c.note ? `. ${c.note}` : ''}`)]
1078
1089
  : ['', 'Nothing had changed yet, so nothing needed putting back.']),
1079
1090
  ]
1080
1091
  }
@@ -42,9 +42,11 @@ import { governedActionIds, invert, itemRef, referencesIn, refName as refNameOf,
42
42
  export interface SyncSource {
43
43
  /** The workspace key's client. */
44
44
  workspace: SyncClient
45
- /** The organization key's client, when one was given. */
45
+ /** The organization's reader, when there is one: the person signed in, or the organization key. */
46
46
  organization?: SyncClient
47
47
  workspaceId: string
48
+ /** The person signed in, when they read nothing at organization grain (no Blueprint Steward) and no key reads for them. */
49
+ notSteward?: string
48
50
  }
49
51
 
50
52
  /** Envelopes come single (`{data}`) or double (`{data:{data}}`); lists are what is inside. */
@@ -1268,7 +1270,8 @@ export function sliceDraft(draft: Bundle, granted: ReadonlySet<string>): Bundle
1268
1270
  const ids = new Set(objects.map((o) => o.id))
1269
1271
  const actions = (def.actions ?? []).filter((a) => {
1270
1272
  const changes = (Array.isArray(a.businessOutcomes) ? a.businessOutcomes : []).flatMap((o: Record<string, any>) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : [])) as Array<Record<string, any>>
1271
- return [a.subject?.objectTypeId, ...changes.map((c) => c?.objectTypeId)].filter((v): v is string => typeof v === 'string').every((id) => ids.has(id))
1273
+ const records = (Array.isArray(a.records) ? a.records : []) as Array<Record<string, any>>
1274
+ return [a.subject?.objectTypeId, ...records.map((r) => r?.objectTypeId), ...changes.map((c) => c?.objectTypeId)].filter((v): v is string => typeof v === 'string').every((id) => ids.has(id))
1272
1275
  })
1273
1276
  return {
1274
1277
  ...def,
@@ -1312,7 +1315,7 @@ async function readBlueprint(
1312
1315
  notes.push('This platform cannot cut the Blueprint to the workspace yet; the organization key read the whole model')
1313
1316
  return readBlueprint(source, 'organization', warnings, notes, sets, opts)
1314
1317
  } else {
1315
- warnings.push('Blueprint: this platform cannot export a workspace’s Blueprint yet; connect an organization key to pull it')
1318
+ warnings.push('Blueprint: this platform cannot export a workspace’s Blueprint yet; sign in for the project’s workspace to pull it')
1316
1319
  return []
1317
1320
  }
1318
1321
  if (opts.draft && !source.organization) notes.push('Reading the Blueprint’s unpublished draft takes an organization key; its published release was read')
@@ -1384,7 +1387,8 @@ async function withActions(org: SyncClient, sliced: Bundle, warnings: string[],
1384
1387
  const outside: string[] = []
1385
1388
  for (const action of published) {
1386
1389
  const changes = (Array.isArray(action.businessOutcomes) ? action.businessOutcomes : []).flatMap((o: Record<string, any>) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : [])) as Array<Record<string, any>>
1387
- const touched = [action.subject?.objectTypeId, ...changes.map((c) => c?.objectTypeId)].filter((v): v is string => typeof v === 'string')
1390
+ const records = (Array.isArray(action.records) ? action.records : []) as Array<Record<string, any>>
1391
+ const touched = [action.subject?.objectTypeId, ...records.map((r) => r?.objectTypeId), ...changes.map((c) => c?.objectTypeId)].filter((v): v is string => typeof v === 'string')
1388
1392
  if (touched.every((id) => ids.has(id))) kept.push(action)
1389
1393
  else outside.push(String(action.apiName))
1390
1394
  }
@@ -1742,9 +1746,10 @@ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}):
1742
1746
  it.notes.push(`Hard-codes the workspace’s object type ids, which the local runtime doesn’t share: ${hits.map((h) => `${h.file}:${h.line} (${h.objectType})`).join(', ')}. \`local dev\` deploys it here with this runtime’s ids in their place and leaves the files as they are; take the id from the query result (\`subjectOf\`, or \`result.objectTypeId\`) so the code holds on any runtime`)
1743
1747
  }
1744
1748
  if (!source.organization && items.some((i) => i.unresolved.length)) {
1745
- notes.push(directory.model?.size
1746
- ? 'Governed Actions are named only with an organization key (they are organization-wide); an Action step shows as an id until you connect one'
1747
- : 'Models and governed Actions are named only with an organization key (both are organization-wide); an LLM step’s model or an Action step shows as an id until you connect one')
1749
+ const [what, shown] = directory.model?.size ? ['Governed Actions are', 'an Action step'] : ['Models and governed Actions are', 'an LLM step’s model or an Action step']
1750
+ notes.push(source.notSteward
1751
+ ? `${what} named only for a Blueprint Steward signed in for the project’s workspace (organization-wide); ${source.notSteward}, signed in here, is not one, so ${shown} shows as an id`
1752
+ : `${what} named only while you’re signed in for the project’s workspace (organization-wide); ${shown} shows as an id until you sign in`)
1748
1753
  }
1749
1754
  return { items, warnings, notes, directory, models: lists.models ?? [] }
1750
1755
  }
@@ -1817,7 +1822,7 @@ export function noteActions(items: RemoteItem[], directory?: Directory): void {
1817
1822
  continue
1818
1823
  }
1819
1824
  const what = many ? `${missing.length} governed Actions` : `governed Action${missing.length === 1 ? '' : 's'} ${list}`
1820
- it.notes.push(`${subject.charAt(0).toUpperCase()}${subject.slice(1)} runs ${what}: add an organization key to bring ${missing.length === 1 ? 'it' : 'them'} with the Blueprint${many ? `: ${list}` : ''}`)
1825
+ it.notes.push(`${subject.charAt(0).toUpperCase()}${subject.slice(1)} runs ${what}: sign in for the project’s workspace to bring ${missing.length === 1 ? 'it' : 'them'} with the Blueprint${many ? `: ${list}` : ''}`)
1821
1826
  }
1822
1827
  }
1823
1828
 
@@ -112,7 +112,7 @@ interface ActionFile {
112
112
  apiName: string
113
113
  subject?: { objectType?: string }
114
114
  inputs?: Array<{ apiName?: string; valueType?: { kind?: string; enum?: unknown[] } }>
115
- businessOutcomes?: Array<{ expectedChanges?: Array<{ kind?: string; property?: string; value?: { kind?: string; field?: string; value?: unknown } }> }>
115
+ businessOutcomes?: Array<{ expectedChanges?: Array<{ kind?: string; objectType?: string; property?: string; value?: { kind?: string; field?: string; value?: unknown } }> }>
116
116
  }
117
117
 
118
118
  /**
@@ -612,7 +612,7 @@ export function actionEnums(actions: ActionFile[]): Map<string, string[]> {
612
612
  for (const outcome of a.businessOutcomes ?? []) {
613
613
  for (const change of outcome.expectedChanges ?? []) {
614
614
  if (change.kind !== 'property_set' || !change.property) continue
615
- const key = `${type}.${change.property}`
615
+ const key = `${change.objectType ?? type}.${change.property}`
616
616
  const values = change.value?.kind === 'input' && change.value.field ? asStrings(inputs.get(change.value.field)?.valueType?.enum) : change.value?.kind === 'literal' && change.value.value !== undefined ? [String(change.value.value)] : null
617
617
  if (values) out.set(key, [...new Set([...(out.get(key) ?? []), ...values])])
618
618
  }
package/src/main.ts CHANGED
@@ -245,8 +245,13 @@ async function main(): Promise<number> {
245
245
  { cwd, ...(piped ? { env: { ...process.env, FRONTERA_TOKEN: piped } } : {}) },
246
246
  )
247
247
  // A command that acts as a person takes the browser sign-in (`auth login --browser`) over the
248
- // profile's key, which it would refuse; nothing else does. A raw FRONTERA_TOKEN still wins.
249
- if (command.meta.authLane === 'session' && credential.token.startsWith('sk-') && !process.env.FRONTERA_TOKEN?.trim() && 'profile' in credential && credential.profile) {
248
+ // profile's key, which it would refuse; so does an organization command over a workspace key
249
+ // (a project's target profile with no organization key), which it would refuse too. Nothing
250
+ // else does. A raw FRONTERA_TOKEN still wins.
251
+ const keyRefused = command.meta.authLane === 'session'
252
+ ? credential.token.startsWith('sk-')
253
+ : credential.token.startsWith('sk-ws-') && commandScope(command.meta) === 'organization' && command.meta.noun !== 'auth' && command.meta.noun !== 'kit'
254
+ if (keyRefused && !process.env.FRONTERA_TOKEN?.trim() && 'profile' in credential && credential.profile) {
250
255
  const signedIn = await readDeviceSession(credentialStore(), credential.profile, credential.apiUrl)
251
256
  if (signedIn) {
252
257
  credential = { ...credential, token: signedIn.token }