@frontera-sdk/cli 1.53.5 → 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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
@@ -1,4 +1,4 @@
1
- import type { CredentialStore } from './credential-store'
1
+ import { credentialStore, type CredentialStore } from './credential-store'
2
2
  import { CliError } from './errors'
3
3
 
4
4
  /**
@@ -128,6 +128,24 @@ export async function readDeviceSession(store: CredentialStore, profile: string,
128
128
  }
129
129
  }
130
130
 
131
+ /** How long a stored session must still last for a printed sign-in step to be left out: the steps after it take a few minutes. */
132
+ const SIGN_IN_STEP_MARGIN_MS = 5 * 60_000
133
+
134
+ /**
135
+ * The sign-in step a list of next steps starts with: `frontera auth login --browser`, with
136
+ * `--profile` when the steps name one. Null when that profile already holds a browser
137
+ * session for this platform that lasts past the next few minutes, so a person who signed
138
+ * in is not asked again at every printed step.
139
+ */
140
+ export async function signInStep(
141
+ at: { profile?: string; apiUrl: string; named?: boolean },
142
+ store: CredentialStore = credentialStore(),
143
+ now = Date.now(),
144
+ ): Promise<string | null> {
145
+ if (at.profile && (await readDeviceSession(store, at.profile, at.apiUrl, now + SIGN_IN_STEP_MARGIN_MS))) return null
146
+ return `frontera auth login --browser${at.named && at.profile ? ` --profile ${at.profile}` : ''}`
147
+ }
148
+
131
149
  /** Revoke the session on the platform (best effort) and forget it here. */
132
150
  export async function forgetDeviceSession(store: CredentialStore, profile: string, fetchFn: Fetch = fetch): Promise<boolean> {
133
151
  const raw = await store.get(sessionSlot(profile)).catch(() => null)
package/src/exit.ts CHANGED
@@ -74,6 +74,8 @@ const USAGE_CODES = new Set([
74
74
  // An Action that isn't there to submit, or a submit without the key it needs.
75
75
  'ACTION_NOT_FOUND',
76
76
  'ACTION_IDEMPOTENCY_KEY_REQUIRED',
77
+ // `blueprint publish` without the --instruction its changes need: fixed in the command.
78
+ 'MIGRATION_INSTRUCTION_REQUIRED',
77
79
  ])
78
80
  /**
79
81
  * Someone changed it first: re-read, then decide or submit again. Each of
@@ -115,6 +117,9 @@ const CONFLICT_CODES = new Set([
115
117
  'AUTOMATION_PUBLISH_UNCERTAIN',
116
118
  // The dataset's rows are written somewhere else now.
117
119
  'DATASET_AUTHORITY_MOVED',
120
+ // A release the draft's state refuses (an Action without a deployment decision, a
121
+ // missing column): the same publish is refused until that is cleared.
122
+ 'RELEASE_BLOCKED',
118
123
  ])
119
124
  /**
120
125
  * Refusals the service sends under another code with a `reason` that is a
package/src/flag-help.ts CHANGED
@@ -127,6 +127,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
127
127
  framework: 'App scaffold to create: next (default) or react (legacy Vite)',
128
128
  port: 'loopback port to listen on — the App development server, or the Forge daemon (0: any free port)',
129
129
  'no-open': 'start local development without opening a browser window',
130
+ detach: 'start the App development server in its own session and return once it answers: it keeps running after this shell ends',
130
131
  'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
131
132
  'no-git': 'scaffold without creating a repository and an initial commit',
132
133
  'no-components': 'scaffold without fetching the baseline shadcn components',
@@ -283,7 +284,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
283
284
  title: 'the change request’s title; a push that opens a new one needs it',
284
285
  why: 'a person-step: why it is needed now, in a sentence',
285
286
  changes: 'a person-step: what it changes, in the workspace’s words',
286
- credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person)',
287
+ credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person, registry-owner)',
287
288
  delivery: 'a person-step: the delivery it belongs to; steps with the same one show as one card',
288
289
  all: 'list the settled ones too',
289
290
  browser: 'sign in by approving a code in the browser, as yourself',
@@ -113,6 +113,8 @@ export const FRONTERA_PERSON_COMMANDS: readonly string[] = [
113
113
  'app rollback',
114
114
  'agent publish',
115
115
  'chat-app publish',
116
+ // Live in the workspace at once, its tools run as whoever connects; a change request does not carry it.
117
+ 'mcp-server save',
116
118
  'function promote',
117
119
  'workflow promote',
118
120
  'local sync request apply',
@@ -21,8 +21,8 @@ import type { LocalCredentials } from './local-state'
21
21
  * after the publish an Action that writes only the Blueprint's records gets its write
22
22
  * path (an ontology edit derived from the Action), built and validated
23
23
  * by the organization key, then reviewed and activated by the owner's
24
- * session (the service refuses a reviewer who built it); the owner role
25
- * may then invoke it
24
+ * session (an ontology edit, so its builder may review it); the owner
25
+ * role may then invoke it
26
26
  *
27
27
  * An Action whose effect would reach another system (a transition, a command)
28
28
  * keeps its switched-off decision: it is published, workflows that name it
@@ -170,8 +170,9 @@ export async function unpublishedActions(call: Call, apiNames: string[]): Promis
170
170
  * Actions whose contract moved after their decision was taken here (an edit
171
171
  * to the Action, or to a field of its object type), which blocks the publish:
172
172
  * a decision cannot follow a new contract. The draft's validation names them.
173
- * On the platform the cure is authoring the Action again under a new id; on
174
- * the local runtime `local dev` does that itself (see `dev.ts`).
173
+ * On the platform a person retires the old deployment (`action retire`), then
174
+ * publishes and deploys again; on the local runtime `local dev` authors the
175
+ * Action again under a new id itself (see `dev.ts`).
175
176
  */
176
177
  export async function staleActions(call: Call, apiNames: string[]): Promise<{ stale: string[]; consumed: string[] }> {
177
178
  const validated = await call('org', 'POST', '/v1/blueprint/lifecycle/validate', {})
@@ -295,7 +296,7 @@ async function deployOne(call: Call, published: Deployable, attachment?: LocalAc
295
296
  if (findings.length) return { reason: `its write path did not validate (${findings.length} failed check${findings.length === 1 ? '' : 's'})`, findings }
296
297
  return `its write path did not validate: ${validation.status >= 300 ? message(validation) : failedChecks(report)}`
297
298
  }
298
- // A different principal from the key that built it: the service insists.
299
+ // Review and activation take a person's session; the key only builds.
299
300
  const reviewed = await call('session', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/review`, { validationReportId: report.id, expectedStateGeneration: 2 })
300
301
  if (reviewed.status >= 300) return `its review was refused: ${message(reviewed)}`
301
302
  const revision = data<{ stateId?: string; stateGeneration?: number; actionDefinitionId?: string }>(await call('session', 'GET', `${ADMIN}/binding-revisions/${bindingRevisionId}`))
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
 
3
- import { readState } from '../project'
3
+ import { appBinding, readState } from '../project'
4
4
  import { customAppFolders } from './local-apps'
5
5
  import type { LocalCredentials } from './local-state'
6
6
 
@@ -42,16 +42,18 @@ export function workspaceGet(serviceUrl: string, creds: Pick<LocalCredentials, '
42
42
  * holds it. Null when the runtime could not be asked; a folder the runtime
43
43
  * could not answer for is left out (the folder's record speaks for it).
44
44
  */
45
- export async function liveAppDeployments(root: string, get: Get | null): Promise<Map<string, LiveApp> | null> {
45
+ export async function liveAppDeployments(root: string, get: Get | null, target: string): Promise<Map<string, LiveApp> | null> {
46
46
  if (!get) return null
47
47
  const out = new Map<string, LiveApp>()
48
48
  for (const dir of customAppFolders(root)) {
49
49
  const state = readState(join(root, dir))
50
50
  const deployment = typeof state.parentVersion === 'string' && state.parentVersion ? state.parentVersion : null
51
- if (!deployment || !state.appId) continue
51
+ // The folder's App on this runtime (`appTarget`); an older CLI's single id otherwise.
52
+ const appId = appBinding(join(root, dir), target).id ?? state.appId
53
+ if (!deployment || !appId) continue
52
54
  const name = dir.split('/').pop()!
53
55
  try {
54
- const res = await get(`/v1/platform-apps/${encodeURIComponent(state.appId)}/deployments`)
56
+ const res = await get(`/v1/platform-apps/${encodeURIComponent(appId)}/deployments`)
55
57
  if (res.status === 404) {
56
58
  out.set(name, { app: false, deployment: null })
57
59
  continue
@@ -113,7 +113,7 @@ const OBJECT_TYPE: JsonSchema = {
113
113
  type: 'object',
114
114
  description: 'Where the records come from: the dataset by NAME (its revision is resolved when applied), and one column per property. There is no name-match fallback.',
115
115
  properties: {
116
- dataset: text('The dataset’s apiName (datasets/<apiName>.json).'),
116
+ dataset: text('The dataset’s apiName (datasets/<apiName>.json). Naming another dataset moves the type to it on `frontera blueprint apply` and in a change request’s Apply; each property keeps its column. A change request creates a new type bound to it, in the same release.'),
117
117
  mapping: {
118
118
  type: 'object',
119
119
  description: 'property apiName → its column, or { column, field } for a field inside a column.',
@@ -124,7 +124,7 @@ const OBJECT_TYPE: JsonSchema = {
124
124
  ],
125
125
  },
126
126
  },
127
- editable: { type: 'array', items: { type: 'string' }, description: 'The properties a governed Action may write.' },
127
+ editable: { type: 'array', items: { type: 'string' }, description: 'The properties a governed Action may write. `frontera blueprint apply` and a change request’s Apply declare this list, for a type they create too; leave the key out to keep what the draft holds, or write [] for none.' },
128
128
  },
129
129
  required: ['dataset', 'mapping'],
130
130
  },
@@ -1,4 +1,5 @@
1
1
  import { liveActionStates, localActionCalls, type LiveAction } from './local-actions'
2
+ import { appTarget } from '../project'
2
3
  import { liveAppDeployments, workspaceGet, type LiveApp } from './local-app-runtime'
3
4
  import { projectBuildRows, projectBuildSummary, type BuildRow, type ProjectBuildSummary } from './local-build-summary'
4
5
  import { localSecretPresence, type SecretPresence } from './local-secrets'
@@ -26,7 +27,7 @@ export async function runtimeBuildInputs(state: LocalState | null, root?: string
26
27
  const [live, secrets, apps] = await Promise.all([
27
28
  creds.orgKey ? Promise.resolve().then(() => liveActionStates(localActionCalls(serviceUrl, creds))).catch(() => null) : Promise.resolve(null),
28
29
  localSecretPresence(serviceUrl, creds).catch(() => null),
29
- root ? liveAppDeployments(root, workspaceGet(serviceUrl, creds)).catch(() => null) : Promise.resolve(null),
30
+ root ? liveAppDeployments(root, workspaceGet(serviceUrl, creds), appTarget(serviceUrl, creds.workspaceId)).catch(() => null) : Promise.resolve(null),
30
31
  ])
31
32
  return { live, secrets, apps }
32
33
  }
@@ -1,9 +1,7 @@
1
- import { createHash } from 'node:crypto'
2
1
  import { readFileSync, rmSync, writeFileSync } from 'node:fs'
3
2
  import { join } from 'node:path'
4
3
 
5
4
  import { writeFileAtomic } from '../fs-atomic'
6
- import { loadHashes } from './local-applied'
7
5
  import { appDeployments, appsLostByRestore, appsLostWords, markAppsLostByRestore, type AppLostByRestore } from './local-app-restore'
8
6
  import { execInContainer, queryInContainer } from './local-docker'
9
7
  import { dumpHistory, historyWords, loadHistory, type HistoryCarried } from './local-history-carry'
@@ -20,12 +18,19 @@ import { containerName } from './local-supervisor'
20
18
  * `realActions` or a `decision`) is rolled back afterwards instead of growing
21
19
  * the data on every `local check`.
22
20
  *
23
- * The baseline is keyed by the applied build: a digest of what `local dev`
24
- * recorded as applied (`applied-hashes.json`). It is taken again only when
25
- * that changed. A copy of the hash and build records is kept with it, so a
26
- * restore puts the CLI's record of what is applied back with the database:
27
- * anything applied after the baseline counts as not applied, and the next
28
- * `local dev` applies it again.
21
+ * Every save takes a new copy: the baseline is the database just before what
22
+ * is to be rolled back (`local check` saves it after its apply, right before
23
+ * its tests), so a restore takes away only what was written since. A copy kept
24
+ * from an earlier save would also take away what was written between the two,
25
+ * such as a person's sign-up or an App's records. A copy of the hash and build
26
+ * records is kept with it, so a restore puts the CLI's record of what is
27
+ * applied back with the database: anything applied after the baseline counts
28
+ * as not applied, and the next `local dev` applies it again.
29
+ *
30
+ * A check's baseline is marked from its save until its restore, so a check
31
+ * cut off in between (Ctrl+C, an agent's time limit, a crash), which left
32
+ * its tests' writes, is put right by the next check before it applies
33
+ * (`restoreCutOffCheck`).
29
34
  *
30
35
  * Only the platform database is copied. The managed datasets' catalog
31
36
  * database, object storage and local traces are left as they are.
@@ -52,6 +57,8 @@ import { containerName } from './local-supervisor'
52
57
  */
53
58
 
54
59
  export const BASELINE_DB = `${POSTGRES_DB}_baseline`
60
+ /** What took a baseline: `local check`, `local snapshot save`, `local data load`, or an Agent conversation with rollback (MCP `agent_chat`). */
61
+ export type BaselineSaver = 'check' | 'snapshot' | 'data' | 'conversation'
55
62
  /** The database psql connects to while it copies, drops and creates the platform's. */
56
63
  const MAINTENANCE_DB = 'postgres'
57
64
 
@@ -60,8 +67,6 @@ export interface BaselineRecord {
60
67
  organizationId: string
61
68
  database: string
62
69
  baseline: string
63
- /** The applied build the baseline is of (`buildHash`). */
64
- buildHash: string
65
70
  savedAt: string
66
71
  saveMs: number
67
72
  /** `applied-hashes.json` and `build.json` as they were when the baseline was taken; null when absent. */
@@ -74,6 +79,10 @@ export interface BaselineRecord {
74
79
  * Absent on a baseline taken before it was kept.
75
80
  */
76
81
  apps?: Record<string, string | null>
82
+ /** Absent on a baseline an older CLI took. */
83
+ savedBy?: BaselineSaver
84
+ /** A `local check` took it and has not restored it yet: still running, or cut off before its rollback. */
85
+ checkInProgress?: boolean
77
86
  restoredAt?: string
78
87
  restoreMs?: number
79
88
  }
@@ -102,8 +111,6 @@ export interface SnapshotOutcome {
102
111
  /** What happened, in a line. */
103
112
  note: string
104
113
  ms: number
105
- /** save: false when the stored baseline is already of this build. */
106
- took?: boolean
107
114
  /** restore: items applied after the baseline, now counted as not applied (Custom Apps deployed after it included). */
108
115
  reapply?: number
109
116
  /** restore: the Custom Apps deployed after the baseline, which the restored database does not have. */
@@ -136,26 +143,11 @@ function readText(path: string): string | null {
136
143
  }
137
144
  }
138
145
 
139
- /**
140
- * What is applied to this runtime, as one digest: the hash record, in a stable
141
- * order, and each Custom App's deployment. An App deployed since the baseline
142
- * changes it, so `local check` takes the baseline again rather than rolling the
143
- * App back out of the runtime after its tests.
144
- */
145
- export function buildHash(project: LocalProject, organizationId: string): string {
146
- const entries = [...loadHashes(project, organizationId)].sort(([a], [b]) => a.localeCompare(b))
147
- const apps = Object.entries(appDeployments(project.root)).filter(([, d]) => d).sort(([a], [b]) => a.localeCompare(b))
148
- const hash = createHash('sha256').update(organizationId).update('\0').update(JSON.stringify(entries))
149
- // Only when there is one, so a project without Apps keeps the digest its baseline was taken under.
150
- if (apps.length) hash.update('\0apps\0').update(JSON.stringify(apps))
151
- return hash.digest('hex')
152
- }
153
-
154
146
  /** The stored baseline's record, for this organization (after a reset the runtime is another one). */
155
147
  export function readBaseline(project: LocalProject, organizationId: string): BaselineRecord | null {
156
148
  try {
157
149
  const saved = JSON.parse(readFileSync(recordPath(project), 'utf8')) as Partial<BaselineRecord>
158
- return saved.version === 1 && saved.organizationId === organizationId && typeof saved.buildHash === 'string' ? (saved as BaselineRecord) : null
150
+ return saved.version === 1 && saved.organizationId === organizationId ? (saved as BaselineRecord) : null
159
151
  } catch {
160
152
  return null
161
153
  }
@@ -225,24 +217,15 @@ function withLock<T>(project: LocalProject, run: () => Promise<T>): Promise<T> {
225
217
  return run().finally(() => rmSync(lockPath(project), { force: true }))
226
218
  }
227
219
 
228
- /**
229
- * Take the baseline of the database as it is now, unless the stored one is of
230
- * this build already (`force` takes it regardless).
231
- */
232
- export async function saveBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, force = false): Promise<SnapshotOutcome> {
220
+ /** Take the baseline of the database as it is now, replacing the one before. */
221
+ export async function saveBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, by?: BaselineSaver): Promise<SnapshotOutcome> {
233
222
  const exec = deps.exec ?? execInContainer
234
223
  const now = deps.now ?? Date.now
235
224
  const psql = psqlFor(project, exec)
236
- const hash = buildHash(project, organizationId)
237
- const stored = readBaseline(project, organizationId)
238
225
  const started = now()
239
- if (!force && stored?.buildHash === hash && psql(exists(BASELINE_DB))) {
240
- return { ok: true, took: false, ms: 0, note: `the stored baseline is of this build (taken ${stored.savedAt})` }
241
- }
242
226
  return withLock(project, async () => {
243
- // The copy before last is dropped first: a template is only ever this one build.
244
227
  if (!psql(`DROP DATABASE IF EXISTS ${BASELINE_DB}`)) {
245
- return { ok: false, took: false, ms: now() - started, note: `couldn’t drop the previous baseline (${BASELINE_DB}); see \`frontera local logs postgres\`` }
228
+ return { ok: false, ms: now() - started, note: `couldn’t drop the previous baseline (${BASELINE_DB}); see \`frontera local logs postgres\`` }
246
229
  }
247
230
  psql(refuse(POSTGRES_DB, false))
248
231
  let copied = false
@@ -252,7 +235,7 @@ export async function saveBaseline(project: LocalProject, organizationId: string
252
235
  psql(refuse(POSTGRES_DB, true))
253
236
  }
254
237
  const ms = now() - started
255
- if (!copied) return { ok: false, took: false, ms, note: `couldn’t copy the database to ${BASELINE_DB}: something kept a connection open; see \`frontera local logs postgres\`` }
238
+ if (!copied) return { ok: false, ms, note: `couldn’t copy the database to ${BASELINE_DB}: something kept a connection open; see \`frontera local logs postgres\`` }
256
239
  // Nothing connects to the baseline: it is only ever a template.
257
240
  psql(refuse(BASELINE_DB, false))
258
241
  await settle(deps)
@@ -262,15 +245,16 @@ export async function saveBaseline(project: LocalProject, organizationId: string
262
245
  organizationId,
263
246
  database: POSTGRES_DB,
264
247
  baseline: BASELINE_DB,
265
- buildHash: hash,
266
248
  savedAt: new Date(now()).toISOString(),
267
249
  saveMs: ms,
268
250
  applied: readText(hashesFile(project)),
269
251
  build: readText(buildFile(project)),
270
252
  apps: appDeployments(project.root),
253
+ ...(by ? { savedBy: by } : {}),
254
+ ...(by === 'check' ? { checkInProgress: true } : {}),
271
255
  })
272
256
  const late = answering ? '' : '; the service isn’t answering since (see `frontera local logs service`)'
273
- return { ok: true, took: true, ms, note: `took a baseline of the applied database (${ms} ms)${late}` }
257
+ return { ok: true, ms, note: `took a baseline of the database as it is now (${ms} ms)${late}` }
274
258
  })
275
259
  }
276
260
 
@@ -352,17 +336,49 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
352
336
  const answering = deps.serviceAnswers ? await deps.serviceAnswers() : true
353
337
  const ms = now() - started
354
338
  // Written last: a watching `local dev` reads it to take the record above.
355
- writeBaseline(project, { ...stored, restoredAt, restoreMs: ms })
339
+ writeBaseline(project, { ...stored, checkInProgress: undefined, restoredAt, restoreMs: ms })
356
340
  const kept = secretsWords(secrets)
357
341
  const ran = historyWords(history)
358
342
  const lostApps = appsLostWords(appsLost)
359
343
  const tail = `${reapply ? `; ${reapply} item${reapply === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`` : ''}${lostApps ? `; ${lostApps}` : ''}${kept ? `; ${kept}` : ''}${ran ? `; ${ran}` : ''}`
360
344
  const withSecrets = { ...(secrets ? { secrets } : {}), ...(history ? { history } : {}) }
361
345
  const counted = { reapply: reapply + appsLost.length, ...(appsLost.length ? { appsLost } : {}) }
362
- if (!answering) return { ok: false, ms, ...counted, ...withSecrets, note: `restored the database (${ms} ms), but the service isn’t answering since: see \`frontera local logs service\`${tail}` }
363
- return { ok: true, ms, ...counted, ...withSecrets, note: `restored the database as applied (${ms} ms)${tail}` }
346
+ const back = `restored the database to the baseline of ${stored.savedAt} (${ms} ms), so anything written since then is gone`
347
+ if (!answering) return { ok: false, ms, ...counted, ...withSecrets, note: `${back}, but the service isn’t answering since: see \`frontera local logs service\`${tail}` }
348
+ return { ok: true, ms, ...counted, ...withSecrets, note: `${back}${tail}` }
364
349
  })
365
350
  }
366
351
 
352
+ /** How long after its save a cut-off check's baseline is put back by the next check: later, what was written since is more likely the person's own than a test's. */
353
+ const CUT_OFF_RESTORE_MS = 60 * 60_000
354
+
355
+ /**
356
+ * The baseline a `local check` cut off before its rollback took, put back:
357
+ * its tests' writes, and anything written since, are gone. When it was taken
358
+ * more than an hour ago, the data is left as it is instead and the mark
359
+ * cleared (`left`: when it was taken), so the next check goes on with a new
360
+ * baseline. Null when the last check finished (or the baseline was saved
361
+ * again since). The runtime lock keeps a second check from running, so a
362
+ * marked baseline found by a check is never a running check's.
363
+ */
364
+ export async function restoreCutOffCheck(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}): Promise<(SnapshotOutcome & { left?: string }) | null> {
365
+ const stored = readBaseline(project, organizationId)
366
+ if (!stored?.checkInProgress) return null
367
+ if ((deps.now ?? Date.now)() - Date.parse(stored.savedAt) <= CUT_OFF_RESTORE_MS) return restoreBaseline(project, organizationId, deps)
368
+ writeBaseline(project, { ...stored, checkInProgress: undefined })
369
+ return {
370
+ ok: true,
371
+ ms: 0,
372
+ left: stored.savedAt,
373
+ note: `the last \`frontera local check\` was cut off at ${stored.savedAt}, more than an hour ago, before it rolled back its tests’ writes. They are still in the data: rolling back now would also take away everything written since. \`frontera local snapshot restore\` puts that baseline back by hand until the next save of one, which a check makes before its tests`,
374
+ }
375
+ }
376
+
377
+ /** A check whose tests did not run after its save: nothing of theirs for a later check to put back. */
378
+ export function endCheckBaseline(project: LocalProject, organizationId: string): void {
379
+ const stored = readBaseline(project, organizationId)
380
+ if (stored?.checkInProgress) writeBaseline(project, { ...stored, checkInProgress: undefined })
381
+ }
382
+
367
383
  /** The record file's path relative to the project, which `local dev` watches for a restore. */
368
384
  export const BASELINE_RECORD = join('.frontera', 'local', 'db-baseline.json')
@@ -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
@@ -1,5 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process'
2
2
  import { openSync, readFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
3
5
 
4
6
  /**
5
7
  * The container runtime, through its CLI.
@@ -67,6 +69,80 @@ export function imagePresent(image: string): boolean {
67
69
  return docker(['image', 'inspect', image, '--format', '{{.Id}}']).ok
68
70
  }
69
71
 
72
+ /** The registry an image comes from: its first part when that names a host, else Docker Hub. */
73
+ export function registryHost(image: string): string {
74
+ const first = image.split('/')[0]!
75
+ return image.includes('/') && /[.:]|^localhost$/.test(first) ? first : 'docker.io'
76
+ }
77
+
78
+ /** Docker Hub answers to three names, and its sign-in is kept under `https://index.docker.io/v1/`. */
79
+ const DOCKER_HUB = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io'])
80
+ const sameRegistry = (host: string) => (DOCKER_HUB.has(host) ? 'docker.io' : host)
81
+
82
+ /**
83
+ * Whether Docker holds a sign-in for a registry host, read as Docker reads it:
84
+ * `config.json` in `$DOCKER_CONFIG` (else `~/.docker`), then the host's
85
+ * credential helper or the default store, asked for its `list` (addresses and
86
+ * user names, never a secret), else the file's own `auths`. False when none
87
+ * is found or it can't be read; nothing here prompts.
88
+ */
89
+ export function dockerSignedIn(host: string): boolean {
90
+ let config: { auths?: Record<string, { auth?: string; identitytoken?: string } | null>; credHelpers?: Record<string, string>; credsStore?: string }
91
+ try {
92
+ config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), '.docker'), 'config.json'), 'utf8'))
93
+ } catch {
94
+ return false
95
+ }
96
+ const isHost = (address: string) => sameRegistry(address.replace(/^https?:\/\//, '').split('/')[0]!) === sameRegistry(host)
97
+ const helper = Object.entries(config.credHelpers ?? {}).find(([address]) => isHost(address))?.[1] ?? config.credsStore
98
+ if (!helper) return Object.entries(config.auths ?? {}).some(([address, entry]) => isHost(address) && !!(entry?.auth || entry?.identitytoken))
99
+ // A name with a slash would run a program by path (`../evil` from the folder), not a helper on PATH.
100
+ if (!/^[A-Za-z0-9._-]+$/.test(helper)) return false
101
+ const listed = spawnSync(`docker-credential-${helper}`, ['list'], { input: 'unused', encoding: 'utf8', timeout: 5_000 })
102
+ if (listed.status !== 0) return false
103
+ try {
104
+ return Object.keys(JSON.parse(listed.stdout) as Record<string, string>).some(isHost)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Why a pull failed, decided once from its output and Docker's sign-in for
112
+ * the registry. `signed-out`: refused, with no sign-in Docker shows, so
113
+ * signing in is the fix. `no-access`: refused although Docker is signed in,
114
+ * so only someone who grants access can clear it. `missing`: the image or
115
+ * tag is not there. `other`: anything else, in the log.
116
+ */
117
+ export type PullFailure = 'signed-out' | 'no-access' | 'missing' | 'other'
118
+
119
+ export class PullError extends DockerError {
120
+ constructor(
121
+ readonly failure: PullFailure,
122
+ message: string,
123
+ ) {
124
+ super(message)
125
+ }
126
+ }
127
+
128
+ function pullFailureMessage(failure: PullFailure, image: string, code: number | null, logFile: string): string {
129
+ const host = registryHost(image)
130
+ switch (failure) {
131
+ case 'signed-out':
132
+ return host === 'ghcr.io'
133
+ ? `the registry refused access to ${image}: run \`gh auth refresh -s read:packages\`, then \`gh auth token | docker login ghcr.io -u <github-user> --password-stdin\`, or run from a platform checkout without --image`
134
+ : `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
135
+ case 'no-access':
136
+ return host === 'ghcr.io'
137
+ ? `the registry refused ${image}, though Docker is signed in to ghcr.io. The GitHub account it signed in with has no read access to the organization's packages, or its sign-in no longer works. Ask an organization owner to grant that account read access, or run from a platform checkout without --image`
138
+ : `the registry refused ${image}, though Docker is signed in to ${host}. The account it signed in with may not pull it, or its sign-in no longer works. Ask an owner of ${host} to grant that account pull access, or run from a platform checkout without --image`
139
+ case 'missing':
140
+ return `${image} is not in the registry: check --registry and --version, or push that release there first`
141
+ case 'other':
142
+ return `docker pull ${image} failed (exit ${code}); see ${logFile}`
143
+ }
144
+ }
145
+
70
146
  /** Pull an image, writing progress to a log file instead of the terminal. */
71
147
  export function pullImage(image: string, logFile: string): Promise<void> {
72
148
  return new Promise((resolvePromise, reject) => {
@@ -75,23 +151,21 @@ export function pullImage(image: string, logFile: string): Promise<void> {
75
151
  child.on('error', reject)
76
152
  child.on('exit', (code) => {
77
153
  if (code === 0) return resolvePromise()
78
- // The private release images are the usual failure: say what grants access.
79
154
  let tail = ''
80
155
  try {
81
156
  tail = readFileSync(logFile, 'utf8').slice(-2000)
82
157
  } catch {
83
158
  // The log is a convenience; the exit code is the fact.
84
159
  }
85
- const denied = /unauthorized|denied|authentication required/i.test(tail)
86
- const host = image.split('/')[0]
87
- const reason = denied
88
- ? image.startsWith('ghcr.io/')
89
- ? `the registry refused access to ${image}: run \`gh auth refresh -s read:packages\`, then \`gh auth token | docker login ghcr.io -u <github-user> --password-stdin\`, or run from a platform checkout without --image`
90
- : `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
160
+ // The words don't tell no sign-in from a sign-in without access (ghcr.io answers `denied` to a token that no longer works); Docker's own sign-in does.
161
+ const failure: PullFailure = /unauthorized|denied|authentication required/i.test(tail)
162
+ ? dockerSignedIn(registryHost(image))
163
+ ? 'no-access'
164
+ : 'signed-out'
91
165
  : /manifest unknown|not found/i.test(tail)
92
- ? `${image} is not in the registry: check --registry and --version, or push that release there first`
93
- : `docker pull ${image} failed (exit ${code}); see ${logFile}`
94
- reject(new DockerError(reason))
166
+ ? 'missing'
167
+ : 'other'
168
+ reject(new PullError(failure, pullFailureMessage(failure, image, code, logFile)))
95
169
  })
96
170
  })
97
171
  }