@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.
- package/package.json +4 -4
- package/src/api/apps-api.ts +2 -2
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/api/platform-api.ts +20 -0
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/app/checks.ts +1 -1
- package/src/commands/app/deploy.ts +9 -9
- package/src/commands/app/deployments.ts +1 -1
- package/src/commands/app/dev.ts +79 -14
- package/src/commands/app/promote.ts +1 -1
- package/src/commands/app/pull.ts +4 -2
- package/src/commands/app/rollback.ts +1 -1
- package/src/commands/app/save.ts +3 -7
- package/src/commands/app/shared.ts +59 -2
- package/src/commands/app/unpin.ts +1 -1
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +15 -2
- package/src/commands/local/check.ts +21 -13
- package/src/commands/local/data.ts +1 -1
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +45 -8
- package/src/commands/local/sync.ts +23 -4
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +121 -19
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +7 -1
- package/src/mcp/mcp-apps.ts +96 -20
- package/src/mcp/tools/agent-tools.ts +29 -7
- package/src/mcp/tools/app-tools.ts +3 -3
- package/src/mcp/tools/project-tools.ts +2 -1
- package/src/project.ts +47 -11
- package/src/remote-guard.ts +4 -1
- package/src/templates/next-app-files.ts +4 -1
- package/src/templates/next-skills.ts +9 -5
- package/src/vendor/kit-assets.json +8 -8
- package/src/vendor/sdk-sources.json +17 -17
package/src/device-login.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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 (
|
|
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
|
|
174
|
-
* the local runtime `local dev`
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
363
|
-
return { ok:
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
?
|
|
93
|
-
:
|
|
94
|
-
reject(new
|
|
166
|
+
? 'missing'
|
|
167
|
+
: 'other'
|
|
168
|
+
reject(new PullError(failure, pullFailureMessage(failure, image, code, logFile)))
|
|
95
169
|
})
|
|
96
170
|
})
|
|
97
171
|
}
|