@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.
Files changed (58) hide show
  1. package/README.md +65 -1
  2. package/package.json +4 -3
  3. package/src/api/automation-api.ts +15 -0
  4. package/src/api/dataset-api.ts +99 -0
  5. package/src/api/governed-action-api.ts +80 -0
  6. package/src/api/platform-api.ts +293 -0
  7. package/src/auth-verify.ts +105 -0
  8. package/src/binding-registry.ts +87 -0
  9. package/src/commands/action/deploy.ts +1 -0
  10. package/src/commands/action/grant.ts +1 -0
  11. package/src/commands/action/index-commands.ts +8 -0
  12. package/src/commands/action/prepare.ts +1 -0
  13. package/src/commands/action/requests.ts +111 -0
  14. package/src/commands/action/review.ts +1 -0
  15. package/src/commands/agent/index-commands.ts +189 -7
  16. package/src/commands/app/init.ts +1 -1
  17. package/src/commands/app/pull.ts +1 -1
  18. package/src/commands/auth/add.ts +145 -0
  19. package/src/commands/auth/current.ts +82 -0
  20. package/src/commands/auth/index-commands.ts +16 -0
  21. package/src/commands/auth/list.ts +71 -0
  22. package/src/commands/auth/remove.ts +80 -0
  23. package/src/commands/auth/use.ts +84 -0
  24. package/src/commands/auth/verify.ts +93 -0
  25. package/src/commands/automation/run.ts +41 -2
  26. package/src/commands/blueprint/query.ts +294 -0
  27. package/src/commands/capability/index-commands.ts +334 -0
  28. package/src/commands/dataset/index-commands.ts +103 -14
  29. package/src/commands/kit/doctor.ts +101 -0
  30. package/src/commands/kit/index-commands.ts +7 -0
  31. package/src/commands/kit/shared.ts +52 -0
  32. package/src/commands/kit/status.ts +92 -0
  33. package/src/commands/kit/sync.ts +106 -0
  34. package/src/commands/kit/vendor.ts +120 -0
  35. package/src/commands/knowledge/index-commands.ts +165 -0
  36. package/src/commands/login.ts +64 -84
  37. package/src/commands/plugin/index-commands.ts +284 -21
  38. package/src/commands/registry.ts +104 -1
  39. package/src/commands/setup.ts +248 -0
  40. package/src/commands/source/index-commands.ts +446 -0
  41. package/src/commands/types.ts +14 -0
  42. package/src/config.ts +197 -100
  43. package/src/credential-store.ts +273 -0
  44. package/src/dev-env.ts +3 -3
  45. package/src/exit.ts +29 -2
  46. package/src/flag-help.ts +65 -3
  47. package/src/fs-atomic.ts +44 -0
  48. package/src/harness.ts +155 -4
  49. package/src/kit.ts +431 -0
  50. package/src/main.ts +13 -1
  51. package/src/paths.ts +43 -0
  52. package/src/profile-migration.ts +101 -0
  53. package/src/profiles.ts +240 -0
  54. package/src/project-context.ts +178 -0
  55. package/src/prompt.ts +23 -0
  56. package/src/templates/next-app-files.ts +4 -1
  57. package/src/vendor/kit-assets.json +60 -0
  58. 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]