@frontera-sdk/cli 0.1.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/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- package/src/vendor/sdk-sources.json +25 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import { projectApp, requireProjectFrom } from './shared'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
|
|
6
|
+
export const appPromote: Command = {
|
|
7
|
+
meta: {
|
|
8
|
+
noun: 'app',
|
|
9
|
+
verb: 'promote',
|
|
10
|
+
args: [{ name: 'version', required: true, description: 'version to make live' }],
|
|
11
|
+
flags: {},
|
|
12
|
+
summary: 'Move the live pointer to an already-published version',
|
|
13
|
+
examples: ['frontera app promote 1.4.0'],
|
|
14
|
+
needsProject: true,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async run(ctx) {
|
|
18
|
+
const version = ctx.positional[0]
|
|
19
|
+
if (!version) throw new UsageError('missing <version>', 'frontera app promote <version>')
|
|
20
|
+
|
|
21
|
+
const project = requireProjectFrom(ctx)
|
|
22
|
+
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
23
|
+
const app = await projectApp(client, project)
|
|
24
|
+
const res = await client.deploy(app.id, version)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
data: { app: app.slug, appId: app.id, deployedVersion: res.deployedVersion },
|
|
28
|
+
text: `Promoted ${app.slug}@${res.deployedVersion}`,
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { gunzipSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { AppsApi } from '../../api/apps-api'
|
|
6
|
+
import { CliError, UsageError } from '../../errors'
|
|
7
|
+
import { healProject } from '../../heal'
|
|
8
|
+
import { dirtyFiles, writeState } from '../../project'
|
|
9
|
+
import { flagBool, type Command } from '../types'
|
|
10
|
+
|
|
11
|
+
/** Unpack a gzipped ustar archive onto disk. */
|
|
12
|
+
function unpackTo(dir: string, gz: Uint8Array): number {
|
|
13
|
+
const buf = new Uint8Array(gunzipSync(Buffer.from(gz)))
|
|
14
|
+
const dec = new TextDecoder()
|
|
15
|
+
const readStr = (start: number, len: number) => {
|
|
16
|
+
let end = start
|
|
17
|
+
while (end < start + len && buf[end] !== 0) end++
|
|
18
|
+
return dec.decode(buf.subarray(start, end))
|
|
19
|
+
}
|
|
20
|
+
let off = 0
|
|
21
|
+
let count = 0
|
|
22
|
+
while (off + 512 <= buf.length) {
|
|
23
|
+
const name = readStr(off, 100)
|
|
24
|
+
if (name === '') break
|
|
25
|
+
const prefix = readStr(off + 345, 155)
|
|
26
|
+
const size = parseInt(readStr(off + 124, 12).trim() || '0', 8)
|
|
27
|
+
const dataStart = off + 512
|
|
28
|
+
if (buf[off + 156] === 0x30 || buf[off + 156] === 0) {
|
|
29
|
+
const rel = (prefix ? `${prefix}/${name}` : name).replace(/^\.?\//, '')
|
|
30
|
+
if (rel && !rel.split('/').includes('..')) {
|
|
31
|
+
const full = join(dir, rel)
|
|
32
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
33
|
+
writeFileSync(full, buf.subarray(dataStart, dataStart + size))
|
|
34
|
+
count++
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
off = dataStart + Math.ceil(size / 512) * 512
|
|
38
|
+
}
|
|
39
|
+
return count
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const appPull: Command = {
|
|
43
|
+
meta: {
|
|
44
|
+
noun: 'app',
|
|
45
|
+
verb: 'pull',
|
|
46
|
+
args: [{ name: 'app[@version]', required: true, description: 'app slug or id, optionally pinned to a version' }],
|
|
47
|
+
flags: { draft: 'boolean', force: 'boolean' },
|
|
48
|
+
summary:
|
|
49
|
+
'Hydrate a working tree from a published version or your saved draft, into the current directory or --dir',
|
|
50
|
+
examples: [
|
|
51
|
+
'frontera app pull shipments-console --dir ~/apps/shipments-console',
|
|
52
|
+
'frontera app pull shipments-console',
|
|
53
|
+
'frontera app pull shipments-console@1.4.0',
|
|
54
|
+
'frontera app pull shipments-console --draft',
|
|
55
|
+
],
|
|
56
|
+
// Pull hydrates a directory that is usually empty, so it must not require
|
|
57
|
+
// an existing project — but it is the command that most needs `--dir`.
|
|
58
|
+
//
|
|
59
|
+
// It unpacks INTO that directory and never creates a `<dir>/<slug>`
|
|
60
|
+
// beneath it. The summary says so because two other texts once assumed
|
|
61
|
+
// otherwise and sent an agent hunting for a subdirectory that was never
|
|
62
|
+
// made; `--dir` is first in the examples for the same reason.
|
|
63
|
+
optionalProject: true,
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async run(ctx) {
|
|
67
|
+
const target = ctx.positional[0]
|
|
68
|
+
if (!target) throw new UsageError('missing <app>', 'frontera app pull <app>[@version]')
|
|
69
|
+
const [slug, pinned] = target.split('@')
|
|
70
|
+
|
|
71
|
+
const dir = typeof ctx.flags.dir === 'string' ? ctx.flags.dir : ctx.cwd
|
|
72
|
+
|
|
73
|
+
// Refuse before destroying. Inside a Computer sandbox there is often no
|
|
74
|
+
// git to recover from, so erring toward refusal costs one --force and
|
|
75
|
+
// erring the other way costs someone's afternoon.
|
|
76
|
+
const dirty = dirtyFiles(dir)
|
|
77
|
+
if (dirty.length > 0 && !flagBool(ctx, 'force')) {
|
|
78
|
+
const shown = dirty.slice(0, 10).map((f) => ` ${f}`).join('\n')
|
|
79
|
+
throw new CliError(
|
|
80
|
+
`refusing to pull: ${dirty.length} file(s) in the working tree would be overwritten\n${shown}` +
|
|
81
|
+
(dirty.length > 10 ? `\n …and ${dirty.length - 10} more` : ''),
|
|
82
|
+
{ code: 'CONFLICT', hint: 'commit or stash them, or re-run with --force' },
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
87
|
+
// Resolve, never ensure: pulling an unknown or renamed slug must fail,
|
|
88
|
+
// not quietly create an empty app to pull nothing from.
|
|
89
|
+
const app = await client.resolveApp(slug!)
|
|
90
|
+
|
|
91
|
+
let bytes: Uint8Array
|
|
92
|
+
let parentVersion: string | null
|
|
93
|
+
|
|
94
|
+
if (flagBool(ctx, 'draft')) {
|
|
95
|
+
const draft = await client.downloadDraft(app.id).catch(() => null)
|
|
96
|
+
if (!draft) {
|
|
97
|
+
// The generic NOT_FOUND hint ("list the resource first") is wrong here:
|
|
98
|
+
// the app resolved fine, it simply has no source stored. That happens
|
|
99
|
+
// for an app created in the console and never deployed from the CLI.
|
|
100
|
+
throw new CliError(`no source stored for ${app.slug}`, {
|
|
101
|
+
code: 'NOT_FOUND',
|
|
102
|
+
hint: 'this app has no saved draft and no deployed version — start from `frontera app init <name>`',
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
bytes = draft.bytes
|
|
106
|
+
parentVersion = draft.parentVersion
|
|
107
|
+
} else {
|
|
108
|
+
const versions = await client.listVersions(app.id)
|
|
109
|
+
const chosen = pinned
|
|
110
|
+
? versions.find((v) => v.version === pinned)
|
|
111
|
+
: versions.find((v) => v.deployed) ?? versions[0]
|
|
112
|
+
if (!chosen) {
|
|
113
|
+
// NOT `app versions`: that needs a project, and the directory being
|
|
114
|
+
// pulled into is empty by definition — the hint would send the caller
|
|
115
|
+
// to a command that cannot run where they are.
|
|
116
|
+
throw new CliError(`no ${pinned ?? 'deployed'} version to pull for ${slug}`, {
|
|
117
|
+
code: 'NOT_FOUND',
|
|
118
|
+
hint: pinned
|
|
119
|
+
? `run \`frontera app list\` to see apps, or omit @${pinned} to take the live one`
|
|
120
|
+
: `this app has never been deployed — pull the saved draft with \`frontera app pull ${slug} --draft\``,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
bytes = await client.downloadSource(app.id, chosen.version)
|
|
124
|
+
parentVersion = chosen.version
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const count = unpackTo(dir, bytes)
|
|
128
|
+
writeState(dir, { appId: app.id, parentVersion })
|
|
129
|
+
|
|
130
|
+
// Pull's contract is "give me a working copy", and a copy that cannot
|
|
131
|
+
// `bun install` is not one. Apps stored by an older toolchain carry
|
|
132
|
+
// `@frontera-sdk/*` dependencies pinned to absolute paths on the machine that
|
|
133
|
+
// published them, and no dev host. Repair on the way out rather than
|
|
134
|
+
// leaving the caller to discover it — see heal.ts.
|
|
135
|
+
const { repairs } = healProject(dir)
|
|
136
|
+
|
|
137
|
+
const pulled = `Pulled ${count} files from ${app.slug}${parentVersion ? `@${parentVersion}` : ' (draft)'}`
|
|
138
|
+
return {
|
|
139
|
+
data: { app: app.slug, appId: app.id, files: count, parentVersion, repairs },
|
|
140
|
+
text: repairs.length
|
|
141
|
+
? `${pulled}\nRepaired legacy packaging:\n${repairs.map((r) => ` ${r}`).join('\n')}`
|
|
142
|
+
: pulled,
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { packDirectory } from '../../pack'
|
|
3
|
+
import { writeAppId, writeState } from '../../project'
|
|
4
|
+
import { requireProjectFrom } from './shared'
|
|
5
|
+
import type { Command } from '../types'
|
|
6
|
+
|
|
7
|
+
export const appSave: Command = {
|
|
8
|
+
meta: {
|
|
9
|
+
noun: 'app',
|
|
10
|
+
verb: 'save',
|
|
11
|
+
args: [],
|
|
12
|
+
flags: {},
|
|
13
|
+
summary: 'Store the working tree as your draft, without building',
|
|
14
|
+
examples: ['frontera app save'],
|
|
15
|
+
needsProject: true,
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
async run(ctx) {
|
|
19
|
+
const project = requireProjectFrom(ctx)
|
|
20
|
+
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
21
|
+
|
|
22
|
+
// save and deploy are the only verbs that may bring an app into
|
|
23
|
+
// existence; every read verb resolves instead.
|
|
24
|
+
const app = await client.ensureApp(project.slug, project.displayName, project.appId)
|
|
25
|
+
const { tgz } = packDirectory(project.root, ctx.output, 'packaged')
|
|
26
|
+
const res = await client.saveDraft(app.id, tgz, project.parentVersion)
|
|
27
|
+
|
|
28
|
+
writeState(project.root, { appId: app.id })
|
|
29
|
+
writeAppId(project.root, app.id)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
data: { app: app.slug, appId: app.id, fileCount: res.fileCount },
|
|
33
|
+
text: `Saved draft for ${app.slug} (${res.fileCount} files)`,
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import type { AppProject } from '../../context'
|
|
4
|
+
import type { CommandContext } from '../types'
|
|
5
|
+
|
|
6
|
+
export function requireProjectFrom(ctx: CommandContext): AppProject {
|
|
7
|
+
if (!ctx.project) {
|
|
8
|
+
throw new UsageError(
|
|
9
|
+
'not in a Frontera app directory',
|
|
10
|
+
'cd into an app project, or run `frontera app init <name>`',
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
return ctx.project
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The app this project belongs to, without creating one.
|
|
18
|
+
*
|
|
19
|
+
* Only `save` and `deploy` may bring an app into existence. A read-only verb
|
|
20
|
+
* that ensured instead would turn a typo, or a slug the platform had since
|
|
21
|
+
* renamed, into a brand new empty app.
|
|
22
|
+
*/
|
|
23
|
+
export function projectApp(client: AppsApi, project: AppProject) {
|
|
24
|
+
return client.resolveApp(project.appId ?? project.slug)
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { formatBytes } from '../../packaging'
|
|
3
|
+
import { projectApp, requireProjectFrom } from './shared'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
|
|
6
|
+
export const appVersions: Command = {
|
|
7
|
+
meta: {
|
|
8
|
+
noun: 'app',
|
|
9
|
+
verb: 'versions',
|
|
10
|
+
args: [],
|
|
11
|
+
flags: {},
|
|
12
|
+
summary: 'List published versions, newest first; * marks the live one',
|
|
13
|
+
examples: ['frontera app versions', 'frontera app versions --json'],
|
|
14
|
+
needsProject: true,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async run(ctx) {
|
|
18
|
+
const project = requireProjectFrom(ctx)
|
|
19
|
+
const client = new AppsApi(ctx.apiUrl, ctx.token)
|
|
20
|
+
const app = await projectApp(client, project)
|
|
21
|
+
const versions = await client.listVersions(app.id)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
data: versions,
|
|
25
|
+
text:
|
|
26
|
+
versions.length === 0
|
|
27
|
+
? 'No versions published yet.'
|
|
28
|
+
: versions
|
|
29
|
+
.map(
|
|
30
|
+
(v) =>
|
|
31
|
+
`${v.deployed ? '*' : ' '} ${v.version.padEnd(12)} ` +
|
|
32
|
+
`parent=${(v.parentVersion ?? '-').padEnd(10)} ` +
|
|
33
|
+
`${v.fileCount} files ${formatBytes(v.totalBytes)}`,
|
|
34
|
+
)
|
|
35
|
+
.join('\n'),
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { validateManifest } from '@frontera-sdk/automation'
|
|
6
|
+
|
|
7
|
+
import { AutomationApi } from '../../api/automation-api'
|
|
8
|
+
import { UsageError } from '../../errors'
|
|
9
|
+
import { formatBytes } from '../../packaging'
|
|
10
|
+
import { table } from '../../table'
|
|
11
|
+
import { flagBool, type Command, type CommandContext } from '../types'
|
|
12
|
+
|
|
13
|
+
/** The one required argument every verb but `list` takes. */
|
|
14
|
+
function requireSlug(ctx: CommandContext): string {
|
|
15
|
+
const slug = ctx.positional[0]
|
|
16
|
+
if (!slug) {
|
|
17
|
+
throw new UsageError(
|
|
18
|
+
'missing <slug>',
|
|
19
|
+
'run `frontera automation list` — then pass the slug of the one you mean',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return slug
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the entry file, then read the manifest off the BUILT module.
|
|
27
|
+
*
|
|
28
|
+
* The artifact is the thing the runner will execute, so it is the thing worth
|
|
29
|
+
* inspecting: a manifest read from the source could differ from the one that
|
|
30
|
+
* ships. The cost is that importing it evaluates the module here, so anything
|
|
31
|
+
* an author does at import time runs on this machine — calling `automation()`
|
|
32
|
+
* and nothing else is the contract.
|
|
33
|
+
*/
|
|
34
|
+
export async function buildAndExtract(file: string): Promise<{
|
|
35
|
+
manifest: Record<string, unknown>
|
|
36
|
+
code: string
|
|
37
|
+
}> {
|
|
38
|
+
const entry = resolve(file)
|
|
39
|
+
if (!existsSync(entry)) {
|
|
40
|
+
throw new UsageError(`no such file: ${file}`, 'pass the path to the automation entry file')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `throw: false`. The default raises an AggregateError whose message is the
|
|
44
|
+
// bare string "Bundle failed" — no file, no reason — and it escapes as an
|
|
45
|
+
// INTERNAL_ERROR, telling the caller to RETRY a build that will never
|
|
46
|
+
// succeed. A build error is something the caller fixes, so it exits 2.
|
|
47
|
+
const built = await Bun.build({
|
|
48
|
+
entrypoints: [entry],
|
|
49
|
+
target: 'bun',
|
|
50
|
+
minify: false,
|
|
51
|
+
throw: false,
|
|
52
|
+
})
|
|
53
|
+
if (!built.success) {
|
|
54
|
+
throw new UsageError(
|
|
55
|
+
`could not build ${file}: ${built.logs.map((l) => l.message).join('; ')}`,
|
|
56
|
+
'fix the errors above, then run the command again',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const code = await built.outputs[0]!.text()
|
|
61
|
+
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
|
|
63
|
+
try {
|
|
64
|
+
const bundle = join(dir, 'bundle.js')
|
|
65
|
+
await Bun.write(bundle, code)
|
|
66
|
+
const mod = (await import(bundle)) as { default?: { manifest?: unknown } }
|
|
67
|
+
const manifest = mod.default?.manifest
|
|
68
|
+
|
|
69
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
70
|
+
throw new UsageError(
|
|
71
|
+
`${file} has no automation as its default export`,
|
|
72
|
+
'export the result of `automation({ … }, handler)` as the default export',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return { manifest: manifest as Record<string, unknown>, code }
|
|
76
|
+
} finally {
|
|
77
|
+
rmSync(dir, { recursive: true, force: true })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** How stale a registry poll may be before a deploy is worth warning about.
|
|
82
|
+
* The runner's default interval is 15s, so a minute is several missed polls —
|
|
83
|
+
* long enough not to fire on a restart, short enough to catch a dead runner. */
|
|
84
|
+
const RUNNER_STALE_MS = 60_000
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The warning to print for a deploy, or null when a runner is clearly alive.
|
|
88
|
+
*
|
|
89
|
+
* Two distinct states, because the remedies differ: nothing has EVER polled
|
|
90
|
+
* (no runner is deployed in this environment at all) versus one polled but has
|
|
91
|
+
* since stopped (it is deployed and unhealthy).
|
|
92
|
+
*/
|
|
93
|
+
export function runnerWarningFor(
|
|
94
|
+
lastSeen: string | null | undefined,
|
|
95
|
+
now: number = Date.now(),
|
|
96
|
+
): string | null {
|
|
97
|
+
// `undefined` and `null` are NOT the same thing here. `undefined` means the
|
|
98
|
+
// service did not send the field — an older build — and inferring "no runner"
|
|
99
|
+
// from silence would warn falsely against every environment predating it.
|
|
100
|
+
// `null` is the service explicitly saying nothing has ever polled.
|
|
101
|
+
if (lastSeen === undefined) return null
|
|
102
|
+
if (lastSeen === null) {
|
|
103
|
+
return 'no automation runner has ever polled this environment — the automation is stored and live, but nothing will execute it'
|
|
104
|
+
}
|
|
105
|
+
const age = now - new Date(lastSeen).getTime()
|
|
106
|
+
if (Number.isNaN(age) || age <= RUNNER_STALE_MS) return null
|
|
107
|
+
return `no automation runner has polled for ${Math.round(age / 1000)}s — the automation may not execute`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const deploy: Command = {
|
|
111
|
+
meta: {
|
|
112
|
+
noun: 'automation',
|
|
113
|
+
verb: 'deploy',
|
|
114
|
+
args: [
|
|
115
|
+
{
|
|
116
|
+
name: 'file',
|
|
117
|
+
required: true,
|
|
118
|
+
description: 'entry file whose default export is an `automation(…)`',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
flags: { 'no-promote': 'boolean' },
|
|
122
|
+
summary: 'Build and deploy an automation, promoting it live by default',
|
|
123
|
+
examples: [
|
|
124
|
+
'frontera automation deploy src/daily-digest.ts',
|
|
125
|
+
'frontera automation deploy src/daily-digest.ts --no-promote',
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async run(ctx) {
|
|
130
|
+
const file = ctx.positional[0]
|
|
131
|
+
if (!file) {
|
|
132
|
+
throw new UsageError('missing <file>', 'frontera automation deploy <file>')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { manifest, code } = await buildAndExtract(file)
|
|
136
|
+
|
|
137
|
+
// A courtesy check — the service validates again and is authoritative. It
|
|
138
|
+
// earns its place by failing before the upload, and by printing the
|
|
139
|
+
// warnings the service discards: an unknown key is not an error, so
|
|
140
|
+
// `concurrancy: 100` would otherwise deploy "successfully" and run at the
|
|
141
|
+
// default of 1 with nothing on screen to say so.
|
|
142
|
+
const check = validateManifest(manifest)
|
|
143
|
+
for (const warning of check.warnings) ctx.output.note(` warning: ${warning}`)
|
|
144
|
+
if (!check.valid) {
|
|
145
|
+
throw new UsageError(
|
|
146
|
+
`invalid manifest: ${check.errors.join('; ')}`,
|
|
147
|
+
'fix the manifest passed to `automation()`, then deploy again',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const slug = manifest.name as string
|
|
152
|
+
const bundle = new TextEncoder().encode(code)
|
|
153
|
+
ctx.output.note(` bundle ${formatBytes(bundle.byteLength)}`)
|
|
154
|
+
|
|
155
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
156
|
+
const result = await client.deploy(slug, {
|
|
157
|
+
manifest,
|
|
158
|
+
bundle,
|
|
159
|
+
promote: !flagBool(ctx, 'no-promote'),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// A deploy into an environment with no runner succeeds at every layer —
|
|
163
|
+
// the bundle is stored, the version is live, `list` says ENABLED — and
|
|
164
|
+
// nothing ever fires, because nothing polls the registry. `runnerLastSeenAt`
|
|
165
|
+
// is the only signal that distinguishes "scheduled" from "scheduled and
|
|
166
|
+
// will actually run", so say it out loud rather than reporting success.
|
|
167
|
+
const runnerWarning = runnerWarningFor(result.runnerLastSeenAt)
|
|
168
|
+
if (runnerWarning) ctx.output.note(` warning: ${runnerWarning}`)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
data: { slug, ...result },
|
|
172
|
+
// `result.promoted` rather than the flag: the service decides, and
|
|
173
|
+
// reporting the request instead of the outcome is how a CLI comes to
|
|
174
|
+
// claim something is live when it is not.
|
|
175
|
+
text:
|
|
176
|
+
`Deployed ${slug} v${result.version}` +
|
|
177
|
+
(result.promoted
|
|
178
|
+
? ' and promoted it'
|
|
179
|
+
: ` (not promoted — use \`frontera automation promote ${slug} ${result.version}\`)`),
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const list: Command = {
|
|
185
|
+
meta: {
|
|
186
|
+
noun: 'automation',
|
|
187
|
+
verb: 'list',
|
|
188
|
+
args: [],
|
|
189
|
+
flags: {},
|
|
190
|
+
summary: 'List automations in this workspace',
|
|
191
|
+
examples: ['frontera automation list', 'frontera automation list --json'],
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async run(ctx) {
|
|
195
|
+
const rows = await new AutomationApi(ctx.apiUrl, ctx.token).list()
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
data: rows,
|
|
199
|
+
text:
|
|
200
|
+
rows.length === 0
|
|
201
|
+
? 'No automations in this workspace.'
|
|
202
|
+
: // LIVE is its own column because it is a separate fact from
|
|
203
|
+
// ENABLED: a deploy with --no-promote leaves an automation enabled
|
|
204
|
+
// with nothing live, and nothing runs. One column would hide that.
|
|
205
|
+
table(
|
|
206
|
+
['slug', 'enabled', 'live', 'description'],
|
|
207
|
+
rows.map((a) => [
|
|
208
|
+
a.slug,
|
|
209
|
+
String(a.enabled),
|
|
210
|
+
a.liveVersionId ? 'yes' : 'no',
|
|
211
|
+
a.description ?? '',
|
|
212
|
+
]),
|
|
213
|
+
[undefined, undefined, undefined, 60],
|
|
214
|
+
),
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const versions: Command = {
|
|
220
|
+
meta: {
|
|
221
|
+
noun: 'automation',
|
|
222
|
+
verb: 'versions',
|
|
223
|
+
args: [{ name: 'slug', required: true, description: 'automation slug, from `frontera automation list`' }],
|
|
224
|
+
flags: {},
|
|
225
|
+
summary: 'List deployed versions, newest first; * marks the live one',
|
|
226
|
+
examples: ['frontera automation versions daily-digest'],
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async run(ctx) {
|
|
230
|
+
const slug = requireSlug(ctx)
|
|
231
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
232
|
+
|
|
233
|
+
// Which version is LIVE is on the automation row, not on any version, so
|
|
234
|
+
// it takes a second read. Worth it: "which one is running" is the question
|
|
235
|
+
// this command exists to answer, and the alternative is making the caller
|
|
236
|
+
// run `list` and match a uuid by eye.
|
|
237
|
+
const [rows, all] = await Promise.all([client.versions(slug), client.list()])
|
|
238
|
+
const liveVersionId = all.find((a) => a.slug === slug)?.liveVersionId ?? null
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
data: rows.map((v) => ({ ...v, live: v.id === liveVersionId })),
|
|
242
|
+
text:
|
|
243
|
+
rows.length === 0
|
|
244
|
+
? `No versions deployed for ${slug}.`
|
|
245
|
+
: table(
|
|
246
|
+
['live', 'version', 'digest', 'size', 'created'],
|
|
247
|
+
rows.map((v) => [
|
|
248
|
+
v.id === liveVersionId ? '*' : '',
|
|
249
|
+
String(v.version),
|
|
250
|
+
v.contentDigest.slice(0, 12),
|
|
251
|
+
formatBytes(v.totalBytes),
|
|
252
|
+
v.createdAt,
|
|
253
|
+
]),
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const promote: Command = {
|
|
260
|
+
meta: {
|
|
261
|
+
noun: 'automation',
|
|
262
|
+
verb: 'promote',
|
|
263
|
+
args: [
|
|
264
|
+
{ name: 'slug', required: true, description: 'automation slug' },
|
|
265
|
+
{ name: 'version', required: true, description: 'version number, from `frontera automation versions`' },
|
|
266
|
+
],
|
|
267
|
+
flags: {},
|
|
268
|
+
summary: 'Move the live pointer to an already-deployed version',
|
|
269
|
+
examples: ['frontera automation promote daily-digest 3'],
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
async run(ctx) {
|
|
273
|
+
const slug = requireSlug(ctx)
|
|
274
|
+
const raw = ctx.positional[1]
|
|
275
|
+
const version = Number(raw)
|
|
276
|
+
// Caught here rather than by the service so a typo costs no round trip,
|
|
277
|
+
// and so the message names the command that lists the real ones.
|
|
278
|
+
if (raw === undefined || !Number.isInteger(version)) {
|
|
279
|
+
throw new UsageError(
|
|
280
|
+
`version must be an integer, got ${raw === undefined ? '(none)' : `"${raw}"`}`,
|
|
281
|
+
`run \`frontera automation versions ${slug}\` to see the versions that exist`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = await new AutomationApi(ctx.apiUrl, ctx.token).promote(slug, version)
|
|
286
|
+
return { data: result, text: `Promoted ${slug} to v${result.version}` }
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `disable` is the kill switch, so it is its own verb rather than
|
|
292
|
+
* `enable --false`: stopping a misbehaving automation is done under pressure,
|
|
293
|
+
* and a flag is one more thing to get wrong.
|
|
294
|
+
*/
|
|
295
|
+
function enabledCommand(verb: 'enable' | 'disable', summary: string): Command {
|
|
296
|
+
const enabled = verb === 'enable'
|
|
297
|
+
return {
|
|
298
|
+
meta: {
|
|
299
|
+
noun: 'automation',
|
|
300
|
+
verb,
|
|
301
|
+
args: [{ name: 'slug', required: true, description: 'automation slug' }],
|
|
302
|
+
flags: {},
|
|
303
|
+
summary,
|
|
304
|
+
examples: [`frontera automation ${verb} daily-digest`],
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async run(ctx) {
|
|
308
|
+
const slug = requireSlug(ctx)
|
|
309
|
+
const result = await new AutomationApi(ctx.apiUrl, ctx.token).setEnabled(slug, enabled)
|
|
310
|
+
return {
|
|
311
|
+
data: result,
|
|
312
|
+
text: `${enabled ? 'Enabled' : 'Disabled'} ${slug}`,
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const automationCommands: Command[] = [
|
|
319
|
+
deploy,
|
|
320
|
+
list,
|
|
321
|
+
versions,
|
|
322
|
+
promote,
|
|
323
|
+
enabledCommand('enable', 'Resume scheduled execution'),
|
|
324
|
+
enabledCommand('disable', 'Stop scheduled execution immediately — the kill switch'),
|
|
325
|
+
]
|