@frontera-sdk/cli 1.44.1 → 1.45.1
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/README.md +65 -1
- package/package.json +4 -3
- package/src/api/automation-api.ts +15 -0
- package/src/api/dataset-api.ts +99 -0
- package/src/api/governed-action-api.ts +80 -0
- package/src/api/platform-api.ts +293 -0
- package/src/auth-verify.ts +105 -0
- package/src/binding-registry.ts +87 -0
- package/src/commands/action/deploy.ts +1 -0
- package/src/commands/action/grant.ts +1 -0
- package/src/commands/action/index-commands.ts +8 -0
- package/src/commands/action/prepare.ts +1 -0
- package/src/commands/action/requests.ts +111 -0
- package/src/commands/action/review.ts +1 -0
- package/src/commands/agent/index-commands.ts +189 -7
- package/src/commands/app/init.ts +1 -1
- package/src/commands/app/pull.ts +1 -1
- package/src/commands/auth/add.ts +145 -0
- package/src/commands/auth/current.ts +82 -0
- package/src/commands/auth/index-commands.ts +16 -0
- package/src/commands/auth/list.ts +71 -0
- package/src/commands/auth/remove.ts +80 -0
- package/src/commands/auth/use.ts +84 -0
- package/src/commands/auth/verify.ts +93 -0
- package/src/commands/automation/run.ts +41 -2
- package/src/commands/blueprint/query.ts +294 -0
- package/src/commands/capability/index-commands.ts +334 -0
- package/src/commands/dataset/index-commands.ts +103 -14
- package/src/commands/kit/doctor.ts +101 -0
- package/src/commands/kit/index-commands.ts +7 -0
- package/src/commands/kit/shared.ts +52 -0
- package/src/commands/kit/status.ts +92 -0
- package/src/commands/kit/sync.ts +106 -0
- package/src/commands/kit/vendor.ts +120 -0
- package/src/commands/knowledge/index-commands.ts +165 -0
- package/src/commands/login.ts +64 -84
- package/src/commands/plugin/index-commands.ts +284 -21
- package/src/commands/registry.ts +104 -1
- package/src/commands/setup.ts +248 -0
- package/src/commands/source/index-commands.ts +446 -0
- package/src/commands/types.ts +14 -0
- package/src/config.ts +197 -100
- package/src/credential-store.ts +273 -0
- package/src/dev-env.ts +3 -3
- package/src/exit.ts +29 -2
- package/src/flag-help.ts +65 -3
- package/src/fs-atomic.ts +44 -0
- package/src/harness.ts +155 -4
- package/src/kit.ts +431 -0
- package/src/main.ts +13 -1
- package/src/paths.ts +43 -0
- package/src/profile-migration.ts +101 -0
- package/src/profiles.ts +240 -0
- package/src/project-context.ts +178 -0
- package/src/prompt.ts +23 -0
- package/src/templates/next-app-files.ts +4 -1
- package/src/vendor/kit-assets.json +60 -0
- package/src/vendor/sdk-sources.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { CliError, UsageError } from '../errors'
|
|
5
|
+
import { KIT, materializedVersion, materializeMarketplace } from '../kit'
|
|
6
|
+
import { dataDir } from '../paths'
|
|
7
|
+
import { flagBool, type Command } from './types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Install the Frontera plugin into a coding host.
|
|
11
|
+
*
|
|
12
|
+
* The distribution problem this solves: both Codex and Claude Code install
|
|
13
|
+
* plugins from a marketplace, and every documented marketplace is a git
|
|
14
|
+
* repository or a public directory listing. For a tool that only works against
|
|
15
|
+
* a customer's own deployment, publishing one is the wrong shape — it puts an
|
|
16
|
+
* internal harness in a public index to solve a local installation problem.
|
|
17
|
+
*
|
|
18
|
+
* So the CLI is the marketplace. It already carries every byte of the kit,
|
|
19
|
+
* pinned at build time, and both hosts accept a local DIRECTORY as a
|
|
20
|
+
* marketplace source. `frontera setup` writes one under the user's data
|
|
21
|
+
* directory and points the host at it: nothing published, nothing hosted,
|
|
22
|
+
* nothing leaving the machine, and no network required.
|
|
23
|
+
*
|
|
24
|
+
* Registration goes through each host's OWN CLI rather than by editing its
|
|
25
|
+
* settings file. Writing another tool's configuration is a thing that works
|
|
26
|
+
* until their next release; asking the tool to do it is a thing that keeps
|
|
27
|
+
* working.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type HostId = 'codex' | 'claude'
|
|
31
|
+
|
|
32
|
+
interface Host {
|
|
33
|
+
id: HostId
|
|
34
|
+
label: string
|
|
35
|
+
bin: string
|
|
36
|
+
/** Verbs differ: Codex `plugin add`, Claude `plugin install`. */
|
|
37
|
+
install: string[]
|
|
38
|
+
marketplaceList: string[]
|
|
39
|
+
installHint: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HOSTS: Record<HostId, Host> = {
|
|
43
|
+
codex: {
|
|
44
|
+
id: 'codex',
|
|
45
|
+
label: 'Codex',
|
|
46
|
+
bin: 'codex',
|
|
47
|
+
install: ['plugin', 'add', 'frontera@frontera'],
|
|
48
|
+
marketplaceList: ['plugin', 'marketplace', 'list'],
|
|
49
|
+
installHint: 'install Codex first — https://developers.openai.com/codex',
|
|
50
|
+
},
|
|
51
|
+
claude: {
|
|
52
|
+
id: 'claude',
|
|
53
|
+
label: 'Claude Code',
|
|
54
|
+
bin: 'claude',
|
|
55
|
+
install: ['plugin', 'install', 'frontera@frontera'],
|
|
56
|
+
marketplaceList: ['plugin', 'marketplace', 'list'],
|
|
57
|
+
installHint: 'install Claude Code first — https://claude.com/claude-code',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ALIASES: Record<string, HostId> = {
|
|
62
|
+
codex: 'codex',
|
|
63
|
+
claude: 'claude',
|
|
64
|
+
'claude-code': 'claude',
|
|
65
|
+
claudecode: 'claude',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function run(bin: string, args: string[]): { ok: boolean; out: string; missing: boolean } {
|
|
69
|
+
const res = spawnSync(bin, args, { encoding: 'utf8' })
|
|
70
|
+
// ENOENT is "the host is not installed", which is a different answer from
|
|
71
|
+
// "the host refused" and gets a different message.
|
|
72
|
+
if (res.error && (res.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
73
|
+
return { ok: false, out: '', missing: true }
|
|
74
|
+
}
|
|
75
|
+
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.trim()
|
|
76
|
+
return { ok: res.status === 0, out, missing: false }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isInstalled(host: Host): boolean {
|
|
80
|
+
return !run(host.bin, ['--version']).missing
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Where a marketplace named `frontera` currently points, if one is registered.
|
|
85
|
+
*
|
|
86
|
+
* Worth asking, because `plugin marketplace add` on an existing NAME silently
|
|
87
|
+
* repoints it — verified against Claude Code, which replaced a checkout path
|
|
88
|
+
* with this one without a word. Someone developing the plugin from a checkout
|
|
89
|
+
* would find their edits stop being read and nothing would have told them.
|
|
90
|
+
*/
|
|
91
|
+
function registeredMarketplace(host: Host): string | null {
|
|
92
|
+
if (host.id === 'claude') {
|
|
93
|
+
const listed = run(host.bin, ['plugin', 'marketplace', 'list', '--json'])
|
|
94
|
+
if (!listed.ok) return null
|
|
95
|
+
try {
|
|
96
|
+
const rows = JSON.parse(listed.out) as Array<{ name?: string; path?: string; repo?: string }>
|
|
97
|
+
const row = rows.find((r) => r.name === 'frontera')
|
|
98
|
+
return row?.path ?? row?.repo ?? null
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Codex prints the manifest path on the line after the marketplace header.
|
|
105
|
+
const listed = run(host.bin, host.marketplaceList)
|
|
106
|
+
if (!listed.ok) return null
|
|
107
|
+
const lines = listed.out.split('\n')
|
|
108
|
+
const header = lines.findIndex((l) => l.includes('Marketplace `frontera`'))
|
|
109
|
+
if (header === -1) return null
|
|
110
|
+
const manifest = lines[header + 1]?.trim()
|
|
111
|
+
if (!manifest) return null
|
|
112
|
+
// Back out of `.agents/plugins/marketplace.json` to the marketplace root.
|
|
113
|
+
return manifest.replace(/\/(\.agents\/plugins|\.claude-plugin)\/marketplace\.json$/, '')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const setupCommand: Command = {
|
|
117
|
+
meta: {
|
|
118
|
+
noun: 'setup',
|
|
119
|
+
verb: '',
|
|
120
|
+
args: [
|
|
121
|
+
{
|
|
122
|
+
name: 'host',
|
|
123
|
+
required: false,
|
|
124
|
+
description: 'coding host to set up: codex or claude (default: every one installed)',
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
flags: { force: 'boolean' },
|
|
128
|
+
summary: 'Install the Frontera plugin into Codex or Claude Code',
|
|
129
|
+
examples: ['frontera setup', 'frontera setup codex', 'frontera setup claude --force'],
|
|
130
|
+
// It configures a coding host. No credential is involved, and requiring one
|
|
131
|
+
// would make this impossible to run before `auth add`.
|
|
132
|
+
offline: true,
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async run(ctx) {
|
|
136
|
+
const [named] = ctx.positional
|
|
137
|
+
if (named && !ALIASES[named.toLowerCase()]) {
|
|
138
|
+
throw new UsageError(
|
|
139
|
+
`unknown host: ${named}`,
|
|
140
|
+
'pass `codex` or `claude`, or run `frontera setup` on its own to set up every host installed here',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const requested: HostId[] = named
|
|
145
|
+
? [ALIASES[named.toLowerCase()]!]
|
|
146
|
+
: (Object.keys(HOSTS) as HostId[]).filter((id) => isInstalled(HOSTS[id]))
|
|
147
|
+
|
|
148
|
+
if (requested.length === 0) {
|
|
149
|
+
throw new CliError('no supported coding host is installed here', {
|
|
150
|
+
code: 'NOT_FOUND',
|
|
151
|
+
hint: 'install Codex or Claude Code, then run `frontera setup` again',
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const root = join(dataDir(), 'harness')
|
|
156
|
+
const before = materializedVersion(root)
|
|
157
|
+
const stale = before !== KIT.kitVersion
|
|
158
|
+
if (stale || flagBool(ctx, 'force')) materializeMarketplace(root)
|
|
159
|
+
|
|
160
|
+
const results: Array<{
|
|
161
|
+
host: HostId
|
|
162
|
+
installed: boolean
|
|
163
|
+
marketplace: string
|
|
164
|
+
error?: string
|
|
165
|
+
}> = []
|
|
166
|
+
|
|
167
|
+
for (const id of requested) {
|
|
168
|
+
const host = HOSTS[id]
|
|
169
|
+
|
|
170
|
+
if (!isInstalled(host)) {
|
|
171
|
+
// Named explicitly, so silence would be wrong — the person asked for
|
|
172
|
+
// this host by name.
|
|
173
|
+
throw new CliError(`${host.label} is not installed`, {
|
|
174
|
+
code: 'NOT_FOUND',
|
|
175
|
+
hint: host.installHint,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Refuse to take a name someone else is using.
|
|
181
|
+
*
|
|
182
|
+
* The host would repoint it silently, which is the wrong default when the
|
|
183
|
+
* other source is almost always a deliberate checkout of the authoring
|
|
184
|
+
* repository. `--force` is how you say you meant it.
|
|
185
|
+
*/
|
|
186
|
+
const existing = registeredMarketplace(host)
|
|
187
|
+
if (existing && existing !== root && !flagBool(ctx, 'force')) {
|
|
188
|
+
throw new CliError(
|
|
189
|
+
`${host.label} already has a \`frontera\` marketplace, pointing at ${existing}`,
|
|
190
|
+
{
|
|
191
|
+
code: 'USAGE',
|
|
192
|
+
hint:
|
|
193
|
+
`re-run with --force to point it at ${root}, `
|
|
194
|
+
+ `or keep the existing one and skip this — it already provides the plugin`,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const added = run(host.bin, ['plugin', 'marketplace', 'add', root])
|
|
200
|
+
if (!added.ok) {
|
|
201
|
+
results.push({ host: id, installed: false, marketplace: root, error: added.out })
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Refresh, because `add` on an already-registered marketplace is a no-op
|
|
206
|
+
// and the tree underneath it may have just been rewritten by a CLI
|
|
207
|
+
// upgrade. Not every host has this verb; a failure here is not fatal.
|
|
208
|
+
run(host.bin, ['plugin', 'marketplace', 'update', 'frontera'])
|
|
209
|
+
|
|
210
|
+
const installed = run(host.bin, host.install)
|
|
211
|
+
results.push({
|
|
212
|
+
host: id,
|
|
213
|
+
installed: installed.ok,
|
|
214
|
+
marketplace: root,
|
|
215
|
+
...(installed.ok ? {} : { error: installed.out }),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const failed = results.filter((r) => !r.installed)
|
|
220
|
+
const data = {
|
|
221
|
+
kitVersion: KIT.kitVersion,
|
|
222
|
+
marketplace: root,
|
|
223
|
+
rematerialized: stale || flagBool(ctx, 'force'),
|
|
224
|
+
previousKitVersion: before,
|
|
225
|
+
hosts: results,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (failed.length === requested.length) {
|
|
229
|
+
throw new CliError(`could not install the plugin into ${failed.map((f) => HOSTS[f.host].label).join(' or ')}`, {
|
|
230
|
+
code: 'INTERNAL_ERROR',
|
|
231
|
+
hint: failed[0]?.error?.split('\n')[0] ?? 'run the host’s own `plugin install` to see why',
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const lines = [
|
|
236
|
+
`Frontera plugin ${KIT.kitVersion} — ${Object.keys(KIT.assets).length} skill files`,
|
|
237
|
+
` marketplace ${root}`,
|
|
238
|
+
...results.map((r) =>
|
|
239
|
+
r.installed
|
|
240
|
+
? ` + ${HOSTS[r.host].label}`
|
|
241
|
+
: ` · ${HOSTS[r.host].label} failed — ${r.error?.split('\n')[0] ?? 'unknown'}`,
|
|
242
|
+
),
|
|
243
|
+
' restart the host to pick it up',
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
return { data, text: lines.join('\n') }
|
|
247
|
+
},
|
|
248
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { DatasetApi } from '../../api/dataset-api'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { table } from '../../table'
|
|
6
|
+
import { flagString, type Command } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Connected Sources — where a Dataset's rows come from.
|
|
10
|
+
*
|
|
11
|
+
* `dataset create --from-source` names one, and until now nothing could make
|
|
12
|
+
* one, so a scripted organization stand-up always broke at the same step: open
|
|
13
|
+
* the Console, provision the Source by hand, come back. `dataset sources` and
|
|
14
|
+
* `dataset test-source` still work and still list and test; everything that
|
|
15
|
+
* WRITES a Source lives here.
|
|
16
|
+
*
|
|
17
|
+
* Two rules the service imposes and this noun surfaces rather than hides:
|
|
18
|
+
*
|
|
19
|
+
* The password never travels in the definition file. It is read from stdin
|
|
20
|
+
* with `--password-from -`, for the reason `secret set` gives: a credential
|
|
21
|
+
* written into a committed file is a credential in the repository. The
|
|
22
|
+
* definition file carries everything else.
|
|
23
|
+
*
|
|
24
|
+
* Writes are optimistically concurrent. `update`, `revise` and `disable` all
|
|
25
|
+
* take a token from `source get` and are refused if the Source moved, which
|
|
26
|
+
* exits 3 — re-read, reapply, retry. Never force; there is no force.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
interface SourceFile {
|
|
30
|
+
displayName?: unknown
|
|
31
|
+
description?: unknown
|
|
32
|
+
config?: unknown
|
|
33
|
+
datasets?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The definition file, minus the one field it is not allowed to carry. */
|
|
37
|
+
function readSourceFile(path: string): SourceFile {
|
|
38
|
+
const raw = path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8')
|
|
39
|
+
let parsed: unknown
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw)
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new CliError(`${path === '-' ? 'stdin' : path} is not valid JSON: ${(e as Error).message}`, {
|
|
44
|
+
code: 'USAGE',
|
|
45
|
+
hint: 'the file holds { displayName, config, datasets } — see `frontera source get <id> --json`',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
const doc = parsed as SourceFile
|
|
49
|
+
const config = doc.config as Record<string, unknown> | undefined
|
|
50
|
+
|
|
51
|
+
// Refused, not stripped. Silently dropping it would leave a caller believing
|
|
52
|
+
// the file is the whole story, and the next person to read the repo would
|
|
53
|
+
// find a password sitting in it.
|
|
54
|
+
if (config && 'password' in config) {
|
|
55
|
+
throw new CliError('the definition file carries `config.password`', {
|
|
56
|
+
code: 'USAGE',
|
|
57
|
+
hint: 'remove it and pass the value with --password-from - (stdin) or --password-from ./file',
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
return doc
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Both the definition and the password can come from stdin — but not at once.
|
|
65
|
+
*
|
|
66
|
+
* There is one stdin. `--file - --password-from -` reads the whole stream into
|
|
67
|
+
* the definition and leaves the password read with nothing, which surfaces as
|
|
68
|
+
* "the password is empty" and sends the caller looking at their password
|
|
69
|
+
* instead of at the two dashes.
|
|
70
|
+
*/
|
|
71
|
+
function refuseSharedStdin(file: string | undefined, passwordFrom: string): void {
|
|
72
|
+
if (file === '-' && passwordFrom === '-') {
|
|
73
|
+
throw new CliError('--file and --password-from cannot both read stdin', {
|
|
74
|
+
code: 'USAGE',
|
|
75
|
+
hint: 'keep --password-from - and pass the definition as a path, or the other way round',
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** stdin or a file. Never an inline value — same rule as `secret set`. */
|
|
81
|
+
function readPassword(from: string): string {
|
|
82
|
+
const raw = from === '-' ? readFileSync(0, 'utf8') : readFileSync(from, 'utf8')
|
|
83
|
+
// Trailing newline is what `echo` adds and what nobody means to include.
|
|
84
|
+
const value = raw.replace(/\r?\n$/, '')
|
|
85
|
+
if (!value) {
|
|
86
|
+
throw new CliError('the password is empty', {
|
|
87
|
+
code: 'USAGE',
|
|
88
|
+
hint: from === '-' ? 'pipe it in: `printf %s "$PW" | frontera source create …`' : `${from} has no content`,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
return value
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check the dataset definitions before sending them.
|
|
96
|
+
*
|
|
97
|
+
* The service's refusal for this body is actively misleading. `strictJsonBody`
|
|
98
|
+
* validates first and, on failure, substitutes a SENTINEL body — which omits
|
|
99
|
+
* `mode` — and Elysia then validates the sentinel, so a definition missing
|
|
100
|
+
* `nullable` on a column comes back as "Expected 'virtual' at
|
|
101
|
+
* /datasets/0/mode": a field the caller set correctly, named as the fault.
|
|
102
|
+
*
|
|
103
|
+
* So these are checked here, where the message can name the real field. Same
|
|
104
|
+
* reason `dataset create` validates its own columns rather than relying on the
|
|
105
|
+
* round trip.
|
|
106
|
+
*/
|
|
107
|
+
export function validateDatasets(datasets: unknown[], path: string): void {
|
|
108
|
+
datasets.forEach((entry, index) => {
|
|
109
|
+
const where = `${path}: datasets[${index}]`
|
|
110
|
+
const dataset = entry as Record<string, unknown>
|
|
111
|
+
if (!dataset || typeof dataset !== 'object') {
|
|
112
|
+
throw new CliError(`${where} is not a mapping.`, {
|
|
113
|
+
code: 'USAGE',
|
|
114
|
+
hint: '{ apiName, displayName, mode, definition, deterministicKeyConfirmed }',
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
if (dataset.mode !== 'virtual') {
|
|
118
|
+
throw new CliError(`${where} has mode ${JSON.stringify(dataset.mode)}.`, {
|
|
119
|
+
code: 'USAGE',
|
|
120
|
+
// `managed` exists in the domain but cannot be asked for — the service
|
|
121
|
+
// reserves it for a later ingestion slice.
|
|
122
|
+
hint: 'mode must be "virtual"; `managed` is not creatable',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
if (typeof dataset.deterministicKeyConfirmed !== 'boolean') {
|
|
126
|
+
throw new CliError(`${where} does not set \`deterministicKeyConfirmed\`.`, {
|
|
127
|
+
code: 'USAGE',
|
|
128
|
+
hint: 'true asserts the primary key uniquely identifies a row; it is not defaulted',
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
const definition = dataset.definition as Record<string, unknown> | undefined
|
|
132
|
+
const columns = definition?.columns
|
|
133
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
134
|
+
throw new CliError(`${where} declares no \`definition.columns\`.`, {
|
|
135
|
+
code: 'USAGE',
|
|
136
|
+
hint: 'columns: [{ name, ordinal, databaseType, nullable }] — at least one',
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
columns.forEach((raw, position) => {
|
|
140
|
+
const column = raw as Record<string, unknown>
|
|
141
|
+
const at = `${where}: definition.columns[${position}]`
|
|
142
|
+
for (const [field, ok] of [
|
|
143
|
+
['name', typeof column?.name === 'string' && column.name.length > 0],
|
|
144
|
+
['ordinal', Number.isInteger(column?.ordinal) && (column.ordinal as number) >= 1],
|
|
145
|
+
['databaseType', typeof column?.databaseType === 'string' && column.databaseType.length > 0],
|
|
146
|
+
// The one that produced the misleading error, and the easiest to omit:
|
|
147
|
+
// it is a boolean with no sensible default, and a Blueprint `required`
|
|
148
|
+
// property may not read a nullable column.
|
|
149
|
+
['nullable', typeof column?.nullable === 'boolean'],
|
|
150
|
+
] as const) {
|
|
151
|
+
if (!ok) {
|
|
152
|
+
throw new CliError(`${at} has no usable \`${field}\`.`, {
|
|
153
|
+
code: 'USAGE',
|
|
154
|
+
hint: 'each column needs { name, ordinal (1-based), databaseType, nullable }',
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
if (!Array.isArray(definition?.primaryKey) || definition.primaryKey.length === 0) {
|
|
160
|
+
throw new CliError(`${where} declares no \`definition.primaryKey\`.`, {
|
|
161
|
+
code: 'USAGE',
|
|
162
|
+
hint: 'primaryKey: ["<column>"] — at least one column name',
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function requireConfig(doc: SourceFile, path: string): Record<string, unknown> {
|
|
169
|
+
if (!doc.config || typeof doc.config !== 'object') {
|
|
170
|
+
throw new CliError(`${path} declares no \`config\`.`, {
|
|
171
|
+
code: 'USAGE',
|
|
172
|
+
hint: 'config: { kind: "postgres", host, port, database, username, sslMode, connectTimeoutMs }',
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return doc.config as Record<string, unknown>
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const list: Command = {
|
|
179
|
+
meta: {
|
|
180
|
+
noun: 'source',
|
|
181
|
+
verb: 'list',
|
|
182
|
+
args: [],
|
|
183
|
+
flags: {},
|
|
184
|
+
summary: 'List the connected Sources in this organization',
|
|
185
|
+
examples: ['frontera source list', 'frontera source list --json'],
|
|
186
|
+
},
|
|
187
|
+
async run(ctx) {
|
|
188
|
+
const rows = await new DatasetApi(ctx.apiUrl, ctx.token).listSources()
|
|
189
|
+
return {
|
|
190
|
+
data: rows,
|
|
191
|
+
text: rows.length === 0
|
|
192
|
+
? 'No connected Sources.\n `frontera source create --file ./source.json --password-from -`'
|
|
193
|
+
: table(
|
|
194
|
+
['displayName', 'connector', 'status', 'id'],
|
|
195
|
+
rows.map((s) => [
|
|
196
|
+
s.displayName ?? '?',
|
|
197
|
+
s.connectorType ?? '',
|
|
198
|
+
s.status ?? '',
|
|
199
|
+
s.id ?? '',
|
|
200
|
+
]),
|
|
201
|
+
[30, 14, 14, undefined],
|
|
202
|
+
),
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const get: Command = {
|
|
208
|
+
meta: {
|
|
209
|
+
noun: 'source',
|
|
210
|
+
verb: 'get',
|
|
211
|
+
args: [{ name: 'source', required: true, description: 'Source id, from `frontera source list`' }],
|
|
212
|
+
flags: {},
|
|
213
|
+
summary: 'Show one Source, with the tokens a write to it needs',
|
|
214
|
+
examples: ['frontera source get <sourceId>', 'frontera source get <sourceId> --json'],
|
|
215
|
+
},
|
|
216
|
+
async run(ctx) {
|
|
217
|
+
const sourceId = ctx.positional[0]
|
|
218
|
+
if (!sourceId) throw new UsageError('missing <source>', 'frontera source list')
|
|
219
|
+
|
|
220
|
+
const source = await new DatasetApi(ctx.apiUrl, ctx.token).getSource(sourceId)
|
|
221
|
+
return {
|
|
222
|
+
data: source,
|
|
223
|
+
// `updatedAt` and the current revision are printed with everything else
|
|
224
|
+
// because they are the inputs to `update`, `revise` and `disable` — a
|
|
225
|
+
// caller reads them here or guesses them nowhere.
|
|
226
|
+
text: table(
|
|
227
|
+
['field', 'value'],
|
|
228
|
+
Object.entries(source).map(([k, v]) => [
|
|
229
|
+
k,
|
|
230
|
+
v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
|
|
231
|
+
]),
|
|
232
|
+
[undefined, 70],
|
|
233
|
+
),
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const create: Command = {
|
|
239
|
+
meta: {
|
|
240
|
+
noun: 'source',
|
|
241
|
+
verb: 'create',
|
|
242
|
+
args: [],
|
|
243
|
+
flags: { file: 'string', 'password-from': 'string' },
|
|
244
|
+
aliases: { f: 'file' },
|
|
245
|
+
summary: 'Provision a Source and the Datasets it carries',
|
|
246
|
+
examples: [
|
|
247
|
+
'frontera source create --file ./source.json --password-from -',
|
|
248
|
+
'frontera source create -f ./source.json --password-from ./pw.txt',
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
async run(ctx) {
|
|
252
|
+
const file = flagString(ctx, 'file')
|
|
253
|
+
if (!file) {
|
|
254
|
+
throw new UsageError(
|
|
255
|
+
'missing --file',
|
|
256
|
+
'the definition holds { displayName, config, datasets }; the password comes from --password-from',
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
const passwordFrom = flagString(ctx, 'password-from')
|
|
260
|
+
if (!passwordFrom) {
|
|
261
|
+
throw new UsageError(
|
|
262
|
+
'missing --password-from',
|
|
263
|
+
'pass `-` to read the password from stdin, or a file path. Never an inline value',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
refuseSharedStdin(file, passwordFrom)
|
|
268
|
+
const doc = readSourceFile(file)
|
|
269
|
+
if (typeof doc.displayName !== 'string' || !doc.displayName) {
|
|
270
|
+
throw new CliError(`${file} declares no \`displayName\`.`, {
|
|
271
|
+
code: 'USAGE',
|
|
272
|
+
hint: 'displayName is what `frontera source list` shows',
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
if (!Array.isArray(doc.datasets) || doc.datasets.length === 0) {
|
|
276
|
+
throw new CliError(`${file} declares no \`datasets\`.`, {
|
|
277
|
+
code: 'USAGE',
|
|
278
|
+
// Not optional at the service either — a Source with no Dataset reads
|
|
279
|
+
// nothing, so provisioning one would create an object with no purpose.
|
|
280
|
+
hint: 'at least one: { apiName, displayName, mode, definition, deterministicKeyConfirmed }',
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
validateDatasets(doc.datasets, file)
|
|
285
|
+
|
|
286
|
+
const config = requireConfig(doc, file)
|
|
287
|
+
const result = await new DatasetApi(ctx.apiUrl, ctx.token).provisionSource({
|
|
288
|
+
displayName: doc.displayName,
|
|
289
|
+
...(typeof doc.description === 'string' ? { description: doc.description } : {}),
|
|
290
|
+
connectorType: 'postgres',
|
|
291
|
+
config: { ...config, password: readPassword(passwordFrom) },
|
|
292
|
+
datasets: doc.datasets,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const id = (result as { id?: string; source?: { id?: string } }).source?.id
|
|
296
|
+
?? (result as { id?: string }).id
|
|
297
|
+
return {
|
|
298
|
+
data: result,
|
|
299
|
+
text:
|
|
300
|
+
`Provisioned ${doc.displayName}${id ? ` ${id}` : ''}.\n`
|
|
301
|
+
+ ` ${doc.datasets.length} dataset(s) created with it — \`frontera dataset list\`.\n`
|
|
302
|
+
+ ` \`frontera source get ${id ?? '<sourceId>'}\` — confirm it connected.`,
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const update: Command = {
|
|
308
|
+
meta: {
|
|
309
|
+
noun: 'source',
|
|
310
|
+
verb: 'update',
|
|
311
|
+
args: [{ name: 'source', required: true, description: 'Source id, from `frontera source list`' }],
|
|
312
|
+
flags: { file: 'string', 'password-from': 'string' },
|
|
313
|
+
aliases: { f: 'file' },
|
|
314
|
+
summary: 'Replace a Source’s connection, and add Datasets to it',
|
|
315
|
+
examples: ['frontera source update <sourceId> --file ./source.json --password-from -'],
|
|
316
|
+
},
|
|
317
|
+
async run(ctx) {
|
|
318
|
+
const sourceId = ctx.positional[0]
|
|
319
|
+
if (!sourceId) throw new UsageError('missing <source>', 'frontera source list')
|
|
320
|
+
const file = flagString(ctx, 'file')
|
|
321
|
+
if (!file) throw new UsageError('missing --file', `frontera source get ${sourceId} --json > source.json`)
|
|
322
|
+
const passwordFrom = flagString(ctx, 'password-from')
|
|
323
|
+
if (!passwordFrom) {
|
|
324
|
+
throw new UsageError('missing --password-from', 'pass `-` for stdin, or a file path')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
refuseSharedStdin(file, passwordFrom)
|
|
328
|
+
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
329
|
+
const doc = readSourceFile(file)
|
|
330
|
+
|
|
331
|
+
// Read immediately before writing rather than asking the caller for the
|
|
332
|
+
// tokens. A hand-copied `expectedUpdatedAt` is stale as often as not, and
|
|
333
|
+
// the resulting 409 reads as a platform fault rather than a race.
|
|
334
|
+
const current = await api.getSource(sourceId)
|
|
335
|
+
const expectedUpdatedAt = String(current.updatedAt ?? '')
|
|
336
|
+
const expectedRevision = Number(
|
|
337
|
+
(current as { currentRevision?: { revision?: number } }).currentRevision?.revision
|
|
338
|
+
?? (current as { revision?: number }).revision,
|
|
339
|
+
)
|
|
340
|
+
if (!expectedUpdatedAt || !Number.isFinite(expectedRevision)) {
|
|
341
|
+
throw new CliError('cannot read the Source’s current revision', {
|
|
342
|
+
code: 'FAILURE',
|
|
343
|
+
hint: `frontera source get ${sourceId} --json — and report what it shows`,
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = await api.updateSource(sourceId, {
|
|
348
|
+
expectedUpdatedAt,
|
|
349
|
+
expectedRevision,
|
|
350
|
+
displayName: typeof doc.displayName === 'string' ? doc.displayName : String(current.displayName ?? ''),
|
|
351
|
+
...(typeof doc.description === 'string' ? { description: doc.description } : {}),
|
|
352
|
+
config: { ...requireConfig(doc, file), password: readPassword(passwordFrom) },
|
|
353
|
+
...(Array.isArray(doc.datasets) && doc.datasets.length > 0 ? { datasets: doc.datasets } : {}),
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
data: result,
|
|
358
|
+
text:
|
|
359
|
+
`Updated ${sourceId}.\n`
|
|
360
|
+
+ ' Datasets in the file were ADDED; nothing was removed — this route has no removal channel.',
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const revise: Command = {
|
|
366
|
+
meta: {
|
|
367
|
+
noun: 'source',
|
|
368
|
+
verb: 'revise',
|
|
369
|
+
args: [{ name: 'source', required: true, description: 'Source id, from `frontera source list`' }],
|
|
370
|
+
flags: { file: 'string', 'password-from': 'string' },
|
|
371
|
+
aliases: { f: 'file' },
|
|
372
|
+
summary: 'Publish a new connection revision, leaving Datasets alone',
|
|
373
|
+
examples: ['frontera source revise <sourceId> --file ./source.json --password-from -'],
|
|
374
|
+
},
|
|
375
|
+
async run(ctx) {
|
|
376
|
+
const sourceId = ctx.positional[0]
|
|
377
|
+
if (!sourceId) throw new UsageError('missing <source>', 'frontera source list')
|
|
378
|
+
const file = flagString(ctx, 'file')
|
|
379
|
+
if (!file) throw new UsageError('missing --file', 'the file holds the `config` block only')
|
|
380
|
+
const passwordFrom = flagString(ctx, 'password-from')
|
|
381
|
+
if (!passwordFrom) throw new UsageError('missing --password-from', 'pass `-` for stdin, or a file path')
|
|
382
|
+
|
|
383
|
+
refuseSharedStdin(file, passwordFrom)
|
|
384
|
+
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
385
|
+
const doc = readSourceFile(file)
|
|
386
|
+
const current = await api.getSource(sourceId)
|
|
387
|
+
const expectedRevision = Number(
|
|
388
|
+
(current as { currentRevision?: { revision?: number } }).currentRevision?.revision
|
|
389
|
+
?? (current as { revision?: number }).revision,
|
|
390
|
+
)
|
|
391
|
+
if (!Number.isFinite(expectedRevision)) {
|
|
392
|
+
throw new CliError('cannot read the Source’s current revision', {
|
|
393
|
+
code: 'FAILURE',
|
|
394
|
+
hint: `frontera source get ${sourceId} --json`,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const result = await api.reviseSource(sourceId, {
|
|
399
|
+
expectedRevision,
|
|
400
|
+
config: { ...requireConfig(doc, file), password: readPassword(passwordFrom) },
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
data: result,
|
|
405
|
+
text:
|
|
406
|
+
`Revised ${sourceId} to a new connection revision.\n`
|
|
407
|
+
+ ` \`frontera dataset test-source ${sourceId}\` — confirm it still reaches the database.`,
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const disable: Command = {
|
|
413
|
+
meta: {
|
|
414
|
+
noun: 'source',
|
|
415
|
+
verb: 'disable',
|
|
416
|
+
args: [{ name: 'source', required: true, description: 'Source id, from `frontera source list`' }],
|
|
417
|
+
flags: {},
|
|
418
|
+
summary: 'Stop reads through a Source, without deleting it',
|
|
419
|
+
examples: ['frontera source disable <sourceId>'],
|
|
420
|
+
},
|
|
421
|
+
async run(ctx) {
|
|
422
|
+
const sourceId = ctx.positional[0]
|
|
423
|
+
if (!sourceId) throw new UsageError('missing <source>', 'frontera source list')
|
|
424
|
+
|
|
425
|
+
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
426
|
+
const current = await api.getSource(sourceId)
|
|
427
|
+
const expectedUpdatedAt = String(current.updatedAt ?? '')
|
|
428
|
+
if (!expectedUpdatedAt) {
|
|
429
|
+
throw new CliError('cannot read the Source’s updatedAt', {
|
|
430
|
+
code: 'FAILURE',
|
|
431
|
+
hint: `frontera source get ${sourceId} --json`,
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const result = await api.disableSource(sourceId, { expectedUpdatedAt })
|
|
436
|
+
return {
|
|
437
|
+
data: result,
|
|
438
|
+
text:
|
|
439
|
+
`Disabled ${sourceId}.\n`
|
|
440
|
+
+ ' The Source is marked disabled; already-published dataset revisions are immutable\n'
|
|
441
|
+
+ ` and are not rewritten. Check what still reads it with \`frontera dataset list\`.`,
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export const sourceCommands: Command[] = [list, get, create, update, revise, disable]
|