@frontera-sdk/cli 1.44.0 → 1.45.0
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 +419 -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 +31 -0
- package/src/vendor/sdk-sources.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { verifyKey } from '../../auth-verify'
|
|
2
|
+
import { ensureMigrated } from '../../config'
|
|
3
|
+
import { credentialStore } from '../../credential-store'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { canPrompt, promptConfirm, promptSecret } from '../../prompt'
|
|
6
|
+
import {
|
|
7
|
+
assertApiUrl,
|
|
8
|
+
assertOriginMatches,
|
|
9
|
+
assertProfileName,
|
|
10
|
+
fingerprint,
|
|
11
|
+
getProfile,
|
|
12
|
+
putProfile,
|
|
13
|
+
} from '../../profiles'
|
|
14
|
+
import { readSecretValue } from '../../secrets'
|
|
15
|
+
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where the key comes from, in the order a caller would expect.
|
|
19
|
+
*
|
|
20
|
+
* There is no `--token`. A secret in a flag lands in process listings and shell
|
|
21
|
+
* history, and a CLI that offers the option will have it used — so the only
|
|
22
|
+
* routes in are stdin, a no-echo prompt on a real terminal, and the
|
|
23
|
+
* environment. An agent's stdin is a pipe or /dev/null, so an agent lands in
|
|
24
|
+
* the stdin branch or the error, never on a prompt that would hang.
|
|
25
|
+
*/
|
|
26
|
+
async function readKey(ctx: CommandContext): Promise<string> {
|
|
27
|
+
const from = flagString(ctx, 'from')
|
|
28
|
+
if (from) return (await readSecretValue(from)).trim()
|
|
29
|
+
|
|
30
|
+
const noInput = flagBool(ctx, 'no-input')
|
|
31
|
+
if (!noInput && canPrompt()) return promptSecret('Frontera API key (sk-ws-… or sk-org-…): ')
|
|
32
|
+
|
|
33
|
+
const fromEnv = (process.env.FRONTERA_TOKEN ?? '').trim()
|
|
34
|
+
if (fromEnv) return fromEnv
|
|
35
|
+
|
|
36
|
+
throw new UsageError(
|
|
37
|
+
'no key supplied',
|
|
38
|
+
'pipe it in with `--from -`, point at a file with `--from ./key.txt`, '
|
|
39
|
+
+ 'or run this in a terminal to be asked for it',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const authAdd: Command = {
|
|
44
|
+
meta: {
|
|
45
|
+
noun: 'auth',
|
|
46
|
+
verb: 'add',
|
|
47
|
+
args: [{ name: 'profile', required: true, description: 'name for this credential, e.g. acme-prod' }],
|
|
48
|
+
flags: { from: 'string', force: 'boolean', 'no-input': 'boolean' },
|
|
49
|
+
summary: 'Verify an API key and store it under a profile name',
|
|
50
|
+
examples: [
|
|
51
|
+
'frontera auth add acme-prod --api-url https://api.frontera.test --from -',
|
|
52
|
+
'frontera --api-url https://api.frontera.test auth add globex-dev --from -',
|
|
53
|
+
'frontera auth add acme-prod --api-url https://api.frontera.test --force',
|
|
54
|
+
],
|
|
55
|
+
// It SUPPLIES a credential, so it must not require one to start.
|
|
56
|
+
offline: true,
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async run(ctx) {
|
|
60
|
+
const [name] = ctx.positional
|
|
61
|
+
if (!name) throw new UsageError('no profile name given', 'run `frontera auth add <profile> --api-url <origin>`')
|
|
62
|
+
assertProfileName(name)
|
|
63
|
+
|
|
64
|
+
await ensureMigrated(process.env)
|
|
65
|
+
|
|
66
|
+
const existing = getProfile(name)
|
|
67
|
+
const supplied = flagString(ctx, 'api-url') ?? process.env.FRONTERA_API_URL
|
|
68
|
+
if (existing && supplied) assertOriginMatches(name, existing.apiUrl, supplied)
|
|
69
|
+
|
|
70
|
+
const apiUrl = supplied ?? existing?.apiUrl
|
|
71
|
+
if (!apiUrl) {
|
|
72
|
+
throw new UsageError(
|
|
73
|
+
'no API origin given',
|
|
74
|
+
'pass --api-url <origin>, or set FRONTERA_API_URL — a key is only valid at the deployment that issued it',
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
assertApiUrl(apiUrl)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Replacing is a decision, not a default.
|
|
81
|
+
*
|
|
82
|
+
* An agent gets a usage error rather than a prompt: it cannot answer one,
|
|
83
|
+
* and silently overwriting a working customer credential because a command
|
|
84
|
+
* was re-run is not recoverable — the previous key is not stored anywhere
|
|
85
|
+
* else.
|
|
86
|
+
*/
|
|
87
|
+
if (existing && !flagBool(ctx, 'force')) {
|
|
88
|
+
const confirmed =
|
|
89
|
+
canPrompt() && !flagBool(ctx, 'no-input')
|
|
90
|
+
? await promptConfirm(`Replace the key stored for ${name} (${existing.fingerprint})?`)
|
|
91
|
+
: false
|
|
92
|
+
if (!confirmed) {
|
|
93
|
+
throw new CliError(`profile "${name}" already exists`, {
|
|
94
|
+
code: 'USAGE',
|
|
95
|
+
hint: `re-run with --force to replace its key (${existing.fingerprint}), or pick another name`,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const token = await readKey(ctx)
|
|
101
|
+
if (!token) {
|
|
102
|
+
throw new UsageError('the key was empty', 'check what you piped in — a key starts with sk-ws- or sk-org-')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const verified = await verifyKey(apiUrl, token)
|
|
106
|
+
|
|
107
|
+
const store = credentialStore()
|
|
108
|
+
await store.set(name, token)
|
|
109
|
+
|
|
110
|
+
const now = new Date().toISOString()
|
|
111
|
+
putProfile(name, {
|
|
112
|
+
apiUrl,
|
|
113
|
+
credentialKind: verified.kind,
|
|
114
|
+
workspaceId: verified.workspaceId,
|
|
115
|
+
orgId: verified.orgId,
|
|
116
|
+
fingerprint: fingerprint(token),
|
|
117
|
+
createdAt: existing?.createdAt ?? now,
|
|
118
|
+
lastVerifiedAt: now,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const data = {
|
|
122
|
+
profile: name,
|
|
123
|
+
apiUrl,
|
|
124
|
+
credentialKind: verified.kind,
|
|
125
|
+
workspaceId: verified.workspaceId,
|
|
126
|
+
orgId: verified.orgId,
|
|
127
|
+
fingerprint: fingerprint(token),
|
|
128
|
+
credentialSource: store.kind,
|
|
129
|
+
replaced: Boolean(existing),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const scope = verified.kind === 'organization'
|
|
133
|
+
? "it authors this organization's Blueprint"
|
|
134
|
+
: `workspace ${verified.workspaceId}`
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
data,
|
|
138
|
+
text: [
|
|
139
|
+
`${existing ? 'Replaced' : 'Stored'} profile ${name} (${data.fingerprint})`,
|
|
140
|
+
` ${apiUrl} — ${scope}`,
|
|
141
|
+
` select it here with \`frontera auth use ${name}\``,
|
|
142
|
+
].join('\n'),
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { credentialStore } from '../../credential-store'
|
|
2
|
+
import { describeSelection } from '../../config'
|
|
3
|
+
import { CliError } from '../../errors'
|
|
4
|
+
import { listProfiles } from '../../profiles'
|
|
5
|
+
import { flagString, type Command } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The preflight every Frontera skill runs first.
|
|
9
|
+
*
|
|
10
|
+
* "Which key is this command about to use" has to be answerable without
|
|
11
|
+
* running a mutation to find out, and answerable as DATA — an agent about to
|
|
12
|
+
* deploy to a customer cannot be asked to parse a sentence. So this reports
|
|
13
|
+
* the resolved profile AND where each part of the resolution came from.
|
|
14
|
+
*
|
|
15
|
+
* It reads no secret bytes. It reports only whether one is present, which is
|
|
16
|
+
* the part that changes what happens next.
|
|
17
|
+
*/
|
|
18
|
+
export const authCurrent: Command = {
|
|
19
|
+
meta: {
|
|
20
|
+
noun: 'auth',
|
|
21
|
+
verb: 'current',
|
|
22
|
+
args: [],
|
|
23
|
+
flags: {},
|
|
24
|
+
summary: 'Show the profile, origin and scope this directory resolves to',
|
|
25
|
+
examples: ['frontera auth current', 'frontera auth current --json'],
|
|
26
|
+
offline: true,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async run(ctx) {
|
|
30
|
+
const { selection, metadata } = await describeSelection(
|
|
31
|
+
{ profile: flagString(ctx, 'profile') },
|
|
32
|
+
{ cwd: ctx.cwd },
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (!selection.profile || !metadata) {
|
|
36
|
+
const known = listProfiles().map((p) => p.name)
|
|
37
|
+
throw new CliError('no Frontera profile is selected for this directory', {
|
|
38
|
+
code: 'PROFILE_NOT_SELECTED',
|
|
39
|
+
hint: known.length
|
|
40
|
+
? `run \`frontera auth use <profile>\` here — you have: ${known.join(', ')}`
|
|
41
|
+
: 'run `frontera auth add <profile> --api-url <origin>`, then `frontera auth use <profile>`',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const store = credentialStore()
|
|
46
|
+
const located = await store.locate(selection.profile)
|
|
47
|
+
|
|
48
|
+
const envToken = Boolean(process.env.FRONTERA_TOKEN?.trim())
|
|
49
|
+
const suppliedOrigin = flagString(ctx, 'api-url') ?? process.env.FRONTERA_API_URL
|
|
50
|
+
|
|
51
|
+
const data = {
|
|
52
|
+
profile: selection.profile,
|
|
53
|
+
profileSource: selection.source,
|
|
54
|
+
...(selection.path ? { profileSourcePath: selection.path } : {}),
|
|
55
|
+
apiUrl: metadata.apiUrl,
|
|
56
|
+
apiUrlSource: suppliedOrigin ? (flagString(ctx, 'api-url') ? 'flag' : 'environment') : 'profile',
|
|
57
|
+
credentialSource: envToken ? 'environment' : (located?.source ?? null),
|
|
58
|
+
credentialKind: metadata.credentialKind,
|
|
59
|
+
workspaceId: metadata.workspaceId,
|
|
60
|
+
orgId: metadata.orgId,
|
|
61
|
+
fingerprint: metadata.fingerprint,
|
|
62
|
+
lastVerifiedAt: metadata.lastVerifiedAt,
|
|
63
|
+
hasSecret: located !== null,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const where = selection.source === 'directory' ? ` (from ${selection.path})` : ` (from ${selection.source})`
|
|
67
|
+
const scope = metadata.credentialKind === 'organization'
|
|
68
|
+
? "organization Blueprint author"
|
|
69
|
+
: `workspace ${metadata.workspaceId ?? 'unknown'}`
|
|
70
|
+
|
|
71
|
+
const lines = [
|
|
72
|
+
`${selection.profile}${where}`,
|
|
73
|
+
` ${metadata.apiUrl} — ${scope}`,
|
|
74
|
+
` key ${data.hasSecret ? metadata.fingerprint : 'MISSING — run `frontera auth add ' + selection.profile + '`'}`,
|
|
75
|
+
]
|
|
76
|
+
if (envToken) {
|
|
77
|
+
lines.push(' FRONTERA_TOKEN is set and takes precedence over this profile’s stored key')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { data, text: lines.join('\n') }
|
|
81
|
+
},
|
|
82
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { authAdd } from './add'
|
|
2
|
+
import { authCurrent } from './current'
|
|
3
|
+
import { authList } from './list'
|
|
4
|
+
import { authRemove } from './remove'
|
|
5
|
+
import { authUse } from './use'
|
|
6
|
+
import { authVerify } from './verify'
|
|
7
|
+
import type { Command } from '../types'
|
|
8
|
+
|
|
9
|
+
export const authCommands: readonly Command[] = [
|
|
10
|
+
authAdd,
|
|
11
|
+
authList,
|
|
12
|
+
authCurrent,
|
|
13
|
+
authUse,
|
|
14
|
+
authVerify,
|
|
15
|
+
authRemove,
|
|
16
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ensureMigrated } from '../../config'
|
|
2
|
+
import { credentialStore } from '../../credential-store'
|
|
3
|
+
import { listProfiles } from '../../profiles'
|
|
4
|
+
import { findContext } from '../../project-context'
|
|
5
|
+
import { table } from '../../table'
|
|
6
|
+
import type { Command } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every profile on this machine, with no key bytes anywhere in the output.
|
|
10
|
+
*
|
|
11
|
+
* The fingerprint is what makes the listing useful without being dangerous: it
|
|
12
|
+
* answers "is this the key I rotated on Tuesday" by eye, and answers nothing
|
|
13
|
+
* else.
|
|
14
|
+
*/
|
|
15
|
+
export const authList: Command = {
|
|
16
|
+
meta: {
|
|
17
|
+
noun: 'auth',
|
|
18
|
+
verb: 'list',
|
|
19
|
+
args: [],
|
|
20
|
+
flags: {},
|
|
21
|
+
summary: 'List the credential profiles stored on this machine',
|
|
22
|
+
examples: ['frontera auth list', 'frontera auth list --json'],
|
|
23
|
+
offline: true,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
await ensureMigrated(process.env)
|
|
28
|
+
|
|
29
|
+
const store = credentialStore()
|
|
30
|
+
const selected = findContext(ctx.cwd)?.profile ?? null
|
|
31
|
+
|
|
32
|
+
const rows = []
|
|
33
|
+
for (const { name, profile } of listProfiles()) {
|
|
34
|
+
rows.push({
|
|
35
|
+
profile: name,
|
|
36
|
+
apiUrl: profile.apiUrl,
|
|
37
|
+
credentialKind: profile.credentialKind,
|
|
38
|
+
workspaceId: profile.workspaceId,
|
|
39
|
+
orgId: profile.orgId,
|
|
40
|
+
fingerprint: profile.fingerprint,
|
|
41
|
+
lastVerifiedAt: profile.lastVerifiedAt,
|
|
42
|
+
// A profile whose secret has been removed from the OS store is the one
|
|
43
|
+
// state that looks fine in metadata and fails on the next command.
|
|
44
|
+
hasSecret: (await store.get(name)) !== null,
|
|
45
|
+
selectedHere: name === selected,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (rows.length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
data: { profiles: [] },
|
|
52
|
+
text: 'No profiles yet.\n frontera auth add <profile> --api-url <origin>',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const text = table(
|
|
57
|
+
['', 'profile', 'origin', 'kind', 'key'],
|
|
58
|
+
rows.map((r) => [
|
|
59
|
+
// The directory's selection, marked where a reader is already looking,
|
|
60
|
+
// rather than in a separate line they have to correlate.
|
|
61
|
+
r.selectedHere ? '*' : ' ',
|
|
62
|
+
r.profile,
|
|
63
|
+
r.apiUrl,
|
|
64
|
+
r.credentialKind,
|
|
65
|
+
r.hasSecret ? r.fingerprint : 'MISSING',
|
|
66
|
+
]),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return { data: { profiles: rows, selectedHere: selected }, text }
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ensureMigrated } from '../../config'
|
|
2
|
+
import { credentialStore } from '../../credential-store'
|
|
3
|
+
import { CliError, UsageError } from '../../errors'
|
|
4
|
+
import { assertProfileName, deleteProfile, requireProfile } from '../../profiles'
|
|
5
|
+
import { canPrompt, promptConfirm } from '../../prompt'
|
|
6
|
+
import { forgetBinding } from '../../binding-registry'
|
|
7
|
+
import { findContext } from '../../project-context'
|
|
8
|
+
import { flagBool, type Command } from '../types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Delete a profile's key and its metadata.
|
|
12
|
+
*
|
|
13
|
+
* Confirmation is required because the key is not stored anywhere else — this
|
|
14
|
+
* is the last copy, and re-adding it means going back to the platform for a new
|
|
15
|
+
* one. `--yes` is the non-interactive form, so an agent has an explicit way to
|
|
16
|
+
* say it meant this.
|
|
17
|
+
*
|
|
18
|
+
* Directory bindings that pointed at it are REPORTED rather than rewritten. A
|
|
19
|
+
* binding is a file in someone's repository; leaving it and naming it is
|
|
20
|
+
* honest, and rewriting files across a filesystem to tidy up after a delete is
|
|
21
|
+
* not a thing a credential command should do.
|
|
22
|
+
*/
|
|
23
|
+
export const authRemove: Command = {
|
|
24
|
+
meta: {
|
|
25
|
+
noun: 'auth',
|
|
26
|
+
verb: 'remove',
|
|
27
|
+
args: [{ name: 'profile', required: true, description: 'profile to delete' }],
|
|
28
|
+
flags: {},
|
|
29
|
+
summary: 'Delete a profile and its stored key',
|
|
30
|
+
examples: ['frontera auth remove globex-dev --yes'],
|
|
31
|
+
offline: true,
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async run(ctx) {
|
|
35
|
+
const [name] = ctx.positional
|
|
36
|
+
if (!name) throw new UsageError('no profile name given', 'run `frontera auth remove <profile> --yes`')
|
|
37
|
+
assertProfileName(name)
|
|
38
|
+
|
|
39
|
+
await ensureMigrated(process.env)
|
|
40
|
+
const metadata = requireProfile(name)
|
|
41
|
+
|
|
42
|
+
if (!flagBool(ctx, 'yes')) {
|
|
43
|
+
const confirmed = canPrompt()
|
|
44
|
+
? await promptConfirm(`Delete ${name} (${metadata.fingerprint}) and its stored key?`)
|
|
45
|
+
: false
|
|
46
|
+
if (!confirmed) {
|
|
47
|
+
throw new CliError(`removing "${name}" deletes the only copy of that key`, {
|
|
48
|
+
code: 'USAGE',
|
|
49
|
+
hint: `re-run with --yes to confirm — the key (${metadata.fingerprint}) cannot be recovered afterwards`,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const store = credentialStore()
|
|
55
|
+
await store.delete(name)
|
|
56
|
+
deleteProfile(name)
|
|
57
|
+
|
|
58
|
+
// Only the ancestry of the current directory is inspectable. Claiming to
|
|
59
|
+
// have found every binding on the machine would be a claim this cannot
|
|
60
|
+
// support, so it reports the one it can see.
|
|
61
|
+
const bound = findContext(ctx.cwd)
|
|
62
|
+
const danglingHere = bound?.profile === name ? bound.path : null
|
|
63
|
+
// The binding named a profile that no longer exists, so the record that
|
|
64
|
+
// made it trusted is now meaningless — drop it rather than leave it to
|
|
65
|
+
// vouch for a future profile that happens to reuse the name.
|
|
66
|
+
if (bound?.profile === name) forgetBinding(bound.root)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
data: {
|
|
70
|
+
profile: name,
|
|
71
|
+
apiUrl: metadata.apiUrl,
|
|
72
|
+
fingerprint: metadata.fingerprint,
|
|
73
|
+
danglingContext: danglingHere,
|
|
74
|
+
},
|
|
75
|
+
text: danglingHere
|
|
76
|
+
? `Removed ${name}\n ${danglingHere} still selects it — run \`frontera auth use <profile>\` there`
|
|
77
|
+
: `Removed ${name}`,
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { relative } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { ensureMigrated } from '../../config'
|
|
4
|
+
import { credentialStore } from '../../credential-store'
|
|
5
|
+
import { CliError, UsageError } from '../../errors'
|
|
6
|
+
import { assertProfileName, requireProfile } from '../../profiles'
|
|
7
|
+
import { ensureGitignored, resolveBindingRoot, writeContext } from '../../project-context'
|
|
8
|
+
import { flagString, type Command } from '../types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bind a directory tree to a profile.
|
|
12
|
+
*
|
|
13
|
+
* No initialization is required and none is created: the binding root is the
|
|
14
|
+
* nearest Frontera project, else the Git root, else the working directory, so
|
|
15
|
+
* this works on the first command in a bare customer folder. `--dir` binds that
|
|
16
|
+
* exact directory and searches no further.
|
|
17
|
+
*
|
|
18
|
+
* It writes no token and makes no request — `auth add` already verified the
|
|
19
|
+
* key, and `auth verify` exists for an explicit freshness check. What it writes
|
|
20
|
+
* is a NAME, which is why a nested customer project can override its parent
|
|
21
|
+
* without either one being able to redirect a key to another deployment.
|
|
22
|
+
*/
|
|
23
|
+
export const authUse: Command = {
|
|
24
|
+
meta: {
|
|
25
|
+
noun: 'auth',
|
|
26
|
+
verb: 'use',
|
|
27
|
+
args: [{ name: 'profile', required: true, description: 'profile to select for this directory tree' }],
|
|
28
|
+
flags: { dir: 'string' },
|
|
29
|
+
summary: 'Select a profile for this directory and everything under it',
|
|
30
|
+
examples: [
|
|
31
|
+
'frontera auth use acme-prod',
|
|
32
|
+
'frontera auth use globex-dev --dir ~/Customers/globex',
|
|
33
|
+
],
|
|
34
|
+
offline: true,
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async run(ctx) {
|
|
38
|
+
const [name] = ctx.positional
|
|
39
|
+
if (!name) throw new UsageError('no profile name given', 'run `frontera auth use <profile>` — see `frontera auth list`')
|
|
40
|
+
assertProfileName(name)
|
|
41
|
+
|
|
42
|
+
await ensureMigrated(process.env)
|
|
43
|
+
const metadata = requireProfile(name)
|
|
44
|
+
|
|
45
|
+
// A selection pointing at a profile with no key is a binding that fails on
|
|
46
|
+
// the next command with a confusing error. Catch it here, where the fix is
|
|
47
|
+
// one command away.
|
|
48
|
+
const store = credentialStore()
|
|
49
|
+
if ((await store.get(name)) === null) {
|
|
50
|
+
throw new CliError(`profile "${name}" has no stored key`, {
|
|
51
|
+
code: 'PROFILE_SECRET_MISSING',
|
|
52
|
+
hint: `re-add it with \`frontera auth add ${name} --api-url ${metadata.apiUrl}\``,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { root, source } = resolveBindingRoot(ctx.cwd, flagString(ctx, 'dir'))
|
|
57
|
+
const path = writeContext(root, name)
|
|
58
|
+
const gitignore = ensureGitignored(root)
|
|
59
|
+
|
|
60
|
+
const scope = metadata.credentialKind === 'organization'
|
|
61
|
+
? "organization Blueprint author"
|
|
62
|
+
: `workspace ${metadata.workspaceId ?? 'unknown'}`
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
data: {
|
|
66
|
+
profile: name,
|
|
67
|
+
root,
|
|
68
|
+
rootSource: source,
|
|
69
|
+
contextPath: path,
|
|
70
|
+
gitignore,
|
|
71
|
+
apiUrl: metadata.apiUrl,
|
|
72
|
+
credentialKind: metadata.credentialKind,
|
|
73
|
+
workspaceId: metadata.workspaceId,
|
|
74
|
+
orgId: metadata.orgId,
|
|
75
|
+
fingerprint: metadata.fingerprint,
|
|
76
|
+
},
|
|
77
|
+
text: [
|
|
78
|
+
`Selected ${name} for ${root}`,
|
|
79
|
+
` ${metadata.apiUrl} — ${scope}`,
|
|
80
|
+
` wrote ${relative(root, path) || path}${gitignore === 'present' ? '' : `, ${gitignore} .gitignore entry`}`,
|
|
81
|
+
].join('\n'),
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { verifyKey } from '../../auth-verify'
|
|
2
|
+
import { describeSelection } from '../../config'
|
|
3
|
+
import { credentialStore } from '../../credential-store'
|
|
4
|
+
import { CliError } from '../../errors'
|
|
5
|
+
import { assertApiUrl, assertOriginMatches, assertProfileName, putProfile, requireProfile } from '../../profiles'
|
|
6
|
+
import { flagString, type Command } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ask the service whether the key still works.
|
|
10
|
+
*
|
|
11
|
+
* Stored scope metadata is descriptive and can go stale — a key is revoked, a
|
|
12
|
+
* workspace is renamed, an organization grant is withdrawn — and nothing about
|
|
13
|
+
* a local file would show it. This is the explicit freshness check, and it
|
|
14
|
+
* refreshes `lastVerifiedAt` and the recorded scope on success.
|
|
15
|
+
*
|
|
16
|
+
* The service remains authoritative regardless: a passing verify is a statement
|
|
17
|
+
* about this moment, not a grant.
|
|
18
|
+
*/
|
|
19
|
+
export const authVerify: Command = {
|
|
20
|
+
meta: {
|
|
21
|
+
noun: 'auth',
|
|
22
|
+
verb: 'verify',
|
|
23
|
+
args: [{ name: 'profile', required: false, description: "profile to check (default: this directory's)" }],
|
|
24
|
+
flags: {},
|
|
25
|
+
summary: 'Check a stored key against its API origin and refresh its scope',
|
|
26
|
+
examples: ['frontera auth verify', 'frontera auth verify acme-prod --json'],
|
|
27
|
+
offline: true,
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async run(ctx) {
|
|
31
|
+
const [named] = ctx.positional
|
|
32
|
+
if (named) assertProfileName(named)
|
|
33
|
+
|
|
34
|
+
const { selection, metadata: selected } = await describeSelection(
|
|
35
|
+
{ profile: named ?? flagString(ctx, 'profile') },
|
|
36
|
+
{ cwd: ctx.cwd },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const name = named ?? selection.profile
|
|
40
|
+
if (!name) {
|
|
41
|
+
throw new CliError('no profile to verify', {
|
|
42
|
+
code: 'PROFILE_NOT_SELECTED',
|
|
43
|
+
hint: 'name one — `frontera auth verify <profile>` — or select one with `frontera auth use <profile>`',
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const metadata = selected ?? requireProfile(name)
|
|
48
|
+
|
|
49
|
+
const supplied = flagString(ctx, 'api-url')
|
|
50
|
+
if (supplied) {
|
|
51
|
+
assertApiUrl(supplied)
|
|
52
|
+
assertOriginMatches(name, metadata.apiUrl, supplied)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const store = credentialStore()
|
|
56
|
+
const token = await store.get(name)
|
|
57
|
+
if (!token) {
|
|
58
|
+
throw new CliError(`profile "${name}" has no stored key`, {
|
|
59
|
+
code: 'PROFILE_SECRET_MISSING',
|
|
60
|
+
hint: `re-add it with \`frontera auth add ${name} --api-url ${metadata.apiUrl}\``,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const verified = await verifyKey(metadata.apiUrl, token)
|
|
65
|
+
const now = new Date().toISOString()
|
|
66
|
+
|
|
67
|
+
putProfile(name, {
|
|
68
|
+
...metadata,
|
|
69
|
+
credentialKind: verified.kind,
|
|
70
|
+
workspaceId: verified.workspaceId ?? metadata.workspaceId,
|
|
71
|
+
orgId: verified.orgId ?? metadata.orgId,
|
|
72
|
+
lastVerifiedAt: now,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const scope = verified.kind === 'organization'
|
|
76
|
+
? "organization Blueprint author"
|
|
77
|
+
: `workspace ${verified.workspaceId}`
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
data: {
|
|
81
|
+
profile: name,
|
|
82
|
+
apiUrl: metadata.apiUrl,
|
|
83
|
+
credentialKind: verified.kind,
|
|
84
|
+
workspaceId: verified.workspaceId ?? metadata.workspaceId,
|
|
85
|
+
orgId: verified.orgId ?? metadata.orgId,
|
|
86
|
+
fingerprint: metadata.fingerprint,
|
|
87
|
+
lastVerifiedAt: now,
|
|
88
|
+
valid: true,
|
|
89
|
+
},
|
|
90
|
+
text: `${name} is valid at ${metadata.apiUrl}\n ${scope}\n key ${metadata.fingerprint}`,
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|
|
@@ -339,17 +339,56 @@ export const automationRuns: Command = {
|
|
|
339
339
|
meta: {
|
|
340
340
|
noun: 'automation',
|
|
341
341
|
verb: 'runs',
|
|
342
|
-
args: [
|
|
342
|
+
args: [
|
|
343
|
+
{ name: 'slug', required: true, description: 'automation slug' },
|
|
344
|
+
{
|
|
345
|
+
name: 'runId',
|
|
346
|
+
required: false,
|
|
347
|
+
description: 'one run to open, with its step trail; omit to list recent runs',
|
|
348
|
+
},
|
|
349
|
+
],
|
|
343
350
|
flags: { dev: 'boolean' },
|
|
344
|
-
summary: 'List recent runs, newest first
|
|
351
|
+
summary: 'List recent runs, newest first — or open one run and its steps',
|
|
345
352
|
examples: [
|
|
346
353
|
'frontera automation runs daily-digest',
|
|
347
354
|
'frontera automation runs daily-digest --dev',
|
|
355
|
+
'frontera automation runs daily-digest <runId> --json',
|
|
348
356
|
],
|
|
349
357
|
},
|
|
350
358
|
|
|
351
359
|
async run(ctx) {
|
|
352
360
|
const slug = requireSlug(ctx)
|
|
361
|
+
|
|
362
|
+
// A run id turns the listing into a detail view rather than needing a
|
|
363
|
+
// second verb. The list is where a failure is noticed and the steps are
|
|
364
|
+
// where it is explained, so the two belong under one name.
|
|
365
|
+
const runId = ctx.positional[1]
|
|
366
|
+
if (runId) {
|
|
367
|
+
const api = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
368
|
+
const [detail, steps] = await Promise.all([
|
|
369
|
+
api.runById(slug, runId),
|
|
370
|
+
// A run that failed before its first step has none, and that is a fact
|
|
371
|
+
// about the run rather than an error — so a failure to read the trail
|
|
372
|
+
// must not lose the run it belongs to.
|
|
373
|
+
api.runSteps(runId).catch(() => [] as Awaited<ReturnType<typeof api.runSteps>>),
|
|
374
|
+
])
|
|
375
|
+
|
|
376
|
+
const trail = steps.length === 0
|
|
377
|
+
? ' (no steps recorded — the run failed before the first one, or made none)'
|
|
378
|
+
: steps
|
|
379
|
+
.map((s) => {
|
|
380
|
+
const duration = s.durationMs === null ? '' : ` ${s.durationMs}ms`
|
|
381
|
+
return ` ${String(s.seq).padStart(3)} ${s.status.padEnd(9)}`
|
|
382
|
+
+ ` ${s.stepName ?? s.label}${duration}`
|
|
383
|
+
})
|
|
384
|
+
.join('\n')
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
data: { run: detail, steps },
|
|
388
|
+
text: `${describeRun(detail)}\n\nSteps\n${trail}`,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
353
392
|
// The two histories are separate rows and the route defaults to live, so
|
|
354
393
|
// without this a dev run is invisible from the CLI — the same blind spot
|
|
355
394
|
// that made `run --dev` wait out its timeout.
|