@frontera-sdk/cli 1.53.6 → 1.53.7

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.
@@ -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
- return Array.isArray(parsed.steps) ? parsed.steps.filter((s): s is PersonStep => !!s && typeof s.id === 'string' && (typeof s.command === 'string' || s.command === null)) : []
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, a
279
- * settled one stays settled, so applying a request again doesn't bring back
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 item of items) {
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
- Object.assign(existing, { command, why: item.why, changes: item.label, credential, ...(item.check ? { check: item.check } : {}) })
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 'leave it out: a dataset binding is set on the platform'
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 ? `${name} failed: ${failed.message}` : 'a step 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
@@ -382,6 +382,7 @@ async function main(): Promise<number> {
382
382
  cwd,
383
383
  apiUrl: credential.apiUrl,
384
384
  token: credential.token,
385
+ ...('profile' in credential && credential.profile ? { profile: credential.profile } : {}),
385
386
  positional,
386
387
  flags,
387
388
  repeated,