@frontera-sdk/cli 0.1.0 → 1.43.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- package/src/commands/blueprint/reserved.ts +0 -40
|
@@ -7,6 +7,7 @@ import { resolveManifest } from '../../manifest'
|
|
|
7
7
|
import { collectSourceFiles, formatBytes } from '../../packaging'
|
|
8
8
|
import { packDirectory } from '../../pack'
|
|
9
9
|
import { readPackageVersion, writeAppId, writeState } from '../../project'
|
|
10
|
+
import { describeRenderEvidence, readRenderEvidence } from '../../render-evidence'
|
|
10
11
|
import { createTarGz } from '../../tar'
|
|
11
12
|
import { requireProjectFrom } from './shared'
|
|
12
13
|
import { flagBool, flagString, type Command } from '../types'
|
|
@@ -23,7 +24,7 @@ async function publish(
|
|
|
23
24
|
client: AppsApi,
|
|
24
25
|
appId: string,
|
|
25
26
|
args: Parameters<AppsApi['publish']>[1],
|
|
26
|
-
): Promise<
|
|
27
|
+
): Promise<Awaited<ReturnType<AppsApi['publish']>>> {
|
|
27
28
|
try {
|
|
28
29
|
return await client.publish(appId, args)
|
|
29
30
|
} catch (err) {
|
|
@@ -77,6 +78,14 @@ export const appDeploy: Command = {
|
|
|
77
78
|
const reach = [...manifest.connectDomains, ...manifest.resourceDomains]
|
|
78
79
|
if (reach.length > 0) ctx.output.note(` may reach ${reach.join(', ')}`)
|
|
79
80
|
|
|
81
|
+
// Asked BEFORE the upload, so the answer is about the tree that is being
|
|
82
|
+
// packaged rather than about whatever the disk looks like afterwards.
|
|
83
|
+
// Reported whichever way it comes out: "rendered" is worth saying too, and
|
|
84
|
+
// a line that only ever appears when something is wrong is a line people
|
|
85
|
+
// learn to skim past.
|
|
86
|
+
const renderEvidence = readRenderEvidence(project.root)
|
|
87
|
+
ctx.output.note(describeRenderEvidence(renderEvidence))
|
|
88
|
+
|
|
80
89
|
const promote = !flagBool(ctx, 'no-promote')
|
|
81
90
|
const res = await publish(client, app.id, {
|
|
82
91
|
version,
|
|
@@ -85,6 +94,7 @@ export const appDeploy: Command = {
|
|
|
85
94
|
source,
|
|
86
95
|
manifest,
|
|
87
96
|
promote,
|
|
97
|
+
renderEvidence,
|
|
88
98
|
})
|
|
89
99
|
|
|
90
100
|
writeState(project.root, { appId: app.id, parentVersion: res.version })
|
|
@@ -92,14 +102,44 @@ export const appDeploy: Command = {
|
|
|
92
102
|
// the slug is renamed — and any stale slug is dropped.
|
|
93
103
|
writeAppId(project.root, app.id)
|
|
94
104
|
|
|
105
|
+
// The one line that turns "published" into something a person can act on.
|
|
106
|
+
// A version that was published and not promoted used to be described to
|
|
107
|
+
// users as one nobody could see — so the turn ended by asking to make an
|
|
108
|
+
// unreviewed build live for the whole workspace, rather than showing it to
|
|
109
|
+
// the person who asked for it. It was always viewable; only the URL was
|
|
110
|
+
// missing.
|
|
111
|
+
if (res.previewUrl) {
|
|
112
|
+
ctx.output.note(
|
|
113
|
+
res.promoted
|
|
114
|
+
? ` live at ${res.liveUrl ?? ''} · this exact build: ${res.previewUrl}`
|
|
115
|
+
: ` open it: ${res.previewUrl}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
95
119
|
return {
|
|
96
|
-
data: {
|
|
120
|
+
data: {
|
|
121
|
+
app: app.slug,
|
|
122
|
+
appId: app.id,
|
|
123
|
+
version: res.version,
|
|
124
|
+
promoted: res.promoted,
|
|
125
|
+
...(res.previewUrl ? { previewUrl: res.previewUrl } : {}),
|
|
126
|
+
...(res.liveUrl && res.promoted ? { liveUrl: res.liveUrl } : {}),
|
|
127
|
+
// In `data` as well as in the note: an agent reads the JSON, and this
|
|
128
|
+
// is the fact it most needs to carry into what it tells the user.
|
|
129
|
+
rendered: renderEvidence.rendered,
|
|
130
|
+
renderEvidence,
|
|
131
|
+
},
|
|
97
132
|
// The slug as the PLATFORM knows it: `project.slug` is a bootstrap name
|
|
98
133
|
// and goes stale the moment the app is renamed, so printing it would
|
|
99
134
|
// report a deploy to an app that no longer exists.
|
|
100
135
|
text:
|
|
101
136
|
`Published ${app.slug}@${res.version}` +
|
|
102
|
-
(res.promoted ? ' and promoted it' : '
|
|
137
|
+
(res.promoted ? ' and promoted it' : '') +
|
|
138
|
+
(renderEvidence.rendered ? '' : ' — nobody has looked at this version') +
|
|
139
|
+
// Not "use `frontera app promote`". That read as "invisible until you
|
|
140
|
+
// promote", which is false and is what sent an agent asking to publish
|
|
141
|
+
// an unreviewed build to everyone.
|
|
142
|
+
(res.previewUrl ? `\n\nOpen it: ${res.previewUrl}` : ''),
|
|
103
143
|
}
|
|
104
144
|
},
|
|
105
145
|
}
|
package/src/commands/app/init.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { UsageError } from '../../errors'
|
|
4
4
|
import { scaffold, scaffoldFiles } from '../../template'
|
|
5
5
|
import { writeHarnessFiles } from '../../harness'
|
|
6
|
+
import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
|
|
6
7
|
import type { Command } from '../types'
|
|
7
8
|
|
|
8
9
|
export const appInit: Command = {
|
|
@@ -38,8 +39,29 @@ export const appInit: Command = {
|
|
|
38
39
|
).length
|
|
39
40
|
ctx.output.note(` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by alias`)
|
|
40
41
|
|
|
42
|
+
// The dev host reads VITE_FRONTERA_TOKEN and 401s on every platform read
|
|
43
|
+
// without it. The credential is already on this machine; only a scaffolded
|
|
44
|
+
// project never received it, so every app built from scratch rendered
|
|
45
|
+
// empty — and an agent looking at a page with no numbers on it cannot see
|
|
46
|
+
// that one of the numbers is wrong.
|
|
47
|
+
//
|
|
48
|
+
// Best-effort by construction: `offline: true` above means this command
|
|
49
|
+
// must work before anyone has logged in.
|
|
50
|
+
const devEnv = writeDevEnv(target)
|
|
51
|
+
ctx.output.note(
|
|
52
|
+
devEnv.written
|
|
53
|
+
? ` ${DEV_ENV_FILE} written — the dev host will read real data`
|
|
54
|
+
: ` no ${DEV_ENV_FILE} (${devEnv.reason === 'no-credential' ? 'no stored credential' : devEnv.reason}) — the dev host will mount but platform reads will 401`,
|
|
55
|
+
)
|
|
56
|
+
|
|
41
57
|
return {
|
|
42
|
-
data: {
|
|
58
|
+
data: {
|
|
59
|
+
name,
|
|
60
|
+
path: target,
|
|
61
|
+
harnessFiles: harness.written,
|
|
62
|
+
vendoredSdkFiles: vendored,
|
|
63
|
+
devEnvWritten: devEnv.written,
|
|
64
|
+
},
|
|
43
65
|
text: [
|
|
44
66
|
`Scaffolded ${name}`,
|
|
45
67
|
'',
|
package/src/commands/app/pull.ts
CHANGED
|
@@ -1,43 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
3
|
-
import { gunzipSync } from 'node:zlib'
|
|
1
|
+
import { join } from 'node:path'
|
|
4
2
|
|
|
5
3
|
import { AppsApi } from '../../api/apps-api'
|
|
6
4
|
import { CliError, UsageError } from '../../errors'
|
|
7
5
|
import { healProject } from '../../heal'
|
|
8
6
|
import { dirtyFiles, writeState } from '../../project'
|
|
7
|
+
import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
|
|
9
8
|
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
|
-
}
|
|
9
|
+
import { unpackTo } from '../../untar'
|
|
41
10
|
|
|
42
11
|
export const appPull: Command = {
|
|
43
12
|
meta: {
|
|
@@ -134,9 +103,17 @@ export const appPull: Command = {
|
|
|
134
103
|
// leaving the caller to discover it — see heal.ts.
|
|
135
104
|
const { repairs } = healProject(dir)
|
|
136
105
|
|
|
106
|
+
// Same reason as `app init`: the dev host reads VITE_FRONTERA_TOKEN and
|
|
107
|
+
// 401s without it. `.env.local` is never packaged, so a pulled copy never
|
|
108
|
+
// carries the publisher's — every working copy has to be given one here.
|
|
109
|
+
// Never overwrites, so an author who pointed this project somewhere else
|
|
110
|
+
// keeps their setting.
|
|
111
|
+
const devEnv = writeDevEnv(dir)
|
|
112
|
+
if (devEnv.written) ctx.output.note(` ${DEV_ENV_FILE} written — the dev host will read real data`)
|
|
113
|
+
|
|
137
114
|
const pulled = `Pulled ${count} files from ${app.slug}${parentVersion ? `@${parentVersion}` : ' (draft)'}`
|
|
138
115
|
return {
|
|
139
|
-
data: { app: app.slug, appId: app.id, files: count, parentVersion, repairs },
|
|
116
|
+
data: { app: app.slug, appId: app.id, files: count, parentVersion, repairs, devEnvWritten: devEnv.written },
|
|
140
117
|
text: repairs.length
|
|
141
118
|
? `${pulled}\nRepaired legacy packaging:\n${repairs.map((r) => ` ${r}`).join('\n')}`
|
|
142
119
|
: pulled,
|
|
@@ -6,6 +6,11 @@ import { validateManifest } from '@frontera-sdk/automation'
|
|
|
6
6
|
|
|
7
7
|
import { AutomationApi } from '../../api/automation-api'
|
|
8
8
|
import { UsageError } from '../../errors'
|
|
9
|
+
import { packDirectory } from '../../pack'
|
|
10
|
+
import { automationInit } from './init'
|
|
11
|
+
import { automationPull } from './pull'
|
|
12
|
+
import { automationRun, automationRuns } from './run'
|
|
13
|
+
import { findAutomationProjectRoot } from './project-root'
|
|
9
14
|
import { formatBytes } from '../../packaging'
|
|
10
15
|
import { table } from '../../table'
|
|
11
16
|
import { flagBool, type Command, type CommandContext } from '../types'
|
|
@@ -152,10 +157,37 @@ const deploy: Command = {
|
|
|
152
157
|
const bundle = new TextEncoder().encode(code)
|
|
153
158
|
ctx.output.note(` bundle ${formatBytes(bundle.byteLength)}`)
|
|
154
159
|
|
|
160
|
+
// The PROJECT root, not the entry file's directory. Packing `src/` omits
|
|
161
|
+
// package.json and tsconfig.json, so the pulled project cannot build — a
|
|
162
|
+
// first pass did exactly that and stored a one-member archive.
|
|
163
|
+
//
|
|
164
|
+
// `collectSourceFiles` excludes node_modules, build output, and `.env` /
|
|
165
|
+
// `.env.*` at any depth, which is what makes source retention safe to turn
|
|
166
|
+
// on: an FDE's project-local credential must never reach the bucket. That
|
|
167
|
+
// exclusion is deliberately not optional (see packaging.ts).
|
|
168
|
+
const projectRoot = findAutomationProjectRoot(resolve(file))
|
|
169
|
+
let source: Uint8Array | undefined
|
|
170
|
+
if (projectRoot) {
|
|
171
|
+
source = packDirectory(projectRoot, ctx.output, 'source').tgz
|
|
172
|
+
} else {
|
|
173
|
+
// Degrade honestly rather than refuse. Authoring inside this monorepo is
|
|
174
|
+
// still supported and simply cannot retain source — there is no
|
|
175
|
+
// package.json between the file and a workspace root, and packing a
|
|
176
|
+
// workspace root would upload the whole monorepo. Saying so is the point:
|
|
177
|
+
// the alternative is a version that silently cannot be pulled.
|
|
178
|
+
ctx.output.note(
|
|
179
|
+
' warning: no automation project found (no package.json above '
|
|
180
|
+
+ `${file}, or the nearest one is a workspace root) — deploying WITHOUT `
|
|
181
|
+
+ 'source, so `frontera automation pull` will not work for this version. '
|
|
182
|
+
+ 'Run `frontera automation init` to author in a project.',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
155
186
|
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
156
187
|
const result = await client.deploy(slug, {
|
|
157
188
|
manifest,
|
|
158
189
|
bundle,
|
|
190
|
+
source,
|
|
159
191
|
promote: !flagBool(ctx, 'no-promote'),
|
|
160
192
|
})
|
|
161
193
|
|
|
@@ -243,10 +275,15 @@ const versions: Command = {
|
|
|
243
275
|
rows.length === 0
|
|
244
276
|
? `No versions deployed for ${slug}.`
|
|
245
277
|
: table(
|
|
246
|
-
['live', 'version', 'digest', 'size', 'created'],
|
|
278
|
+
['live', 'version', 'state', 'digest', 'size', 'created'],
|
|
247
279
|
rows.map((v) => [
|
|
248
280
|
v.id === liveVersionId ? '*' : '',
|
|
249
281
|
String(v.version),
|
|
282
|
+
// A retired version is still LISTED — the row is the record that
|
|
283
|
+
// the version number was used, and numbers are never reused. But
|
|
284
|
+
// listing it unmarked implies it can be pulled or promoted, and
|
|
285
|
+
// it can be neither: retention deleted its bundle and source.
|
|
286
|
+
v.retiredAt ? 'retired' : '',
|
|
250
287
|
v.contentDigest.slice(0, 12),
|
|
251
288
|
formatBytes(v.totalBytes),
|
|
252
289
|
v.createdAt,
|
|
@@ -322,4 +359,8 @@ export const automationCommands: Command[] = [
|
|
|
322
359
|
promote,
|
|
323
360
|
enabledCommand('enable', 'Resume scheduled execution'),
|
|
324
361
|
enabledCommand('disable', 'Stop scheduled execution immediately — the kill switch'),
|
|
362
|
+
automationRun,
|
|
363
|
+
automationRuns,
|
|
364
|
+
automationPull,
|
|
365
|
+
automationInit,
|
|
325
366
|
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { automationScaffoldFiles, scaffoldAutomation } from '../../automation-template'
|
|
4
|
+
import { UsageError } from '../../errors'
|
|
5
|
+
import type { Command } from '../types'
|
|
6
|
+
|
|
7
|
+
export const automationInit: Command = {
|
|
8
|
+
meta: {
|
|
9
|
+
noun: 'automation',
|
|
10
|
+
verb: 'init',
|
|
11
|
+
args: [{
|
|
12
|
+
name: 'name',
|
|
13
|
+
required: true,
|
|
14
|
+
description: 'directory and automation slug for the new project',
|
|
15
|
+
}],
|
|
16
|
+
flags: { dir: 'string' },
|
|
17
|
+
summary: 'Scaffold a new automation project',
|
|
18
|
+
examples: ['frontera automation init daily-digest'],
|
|
19
|
+
// Scaffolding must work before a credential exists.
|
|
20
|
+
offline: true,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async run(ctx) {
|
|
24
|
+
const name = ctx.positional[0]
|
|
25
|
+
if (!name) throw new UsageError('missing <name>', 'frontera automation init <name>')
|
|
26
|
+
|
|
27
|
+
const base = typeof ctx.flags.dir === 'string' ? ctx.flags.dir : ctx.cwd
|
|
28
|
+
const target = join(base, name)
|
|
29
|
+
scaffoldAutomation(target, name)
|
|
30
|
+
|
|
31
|
+
// Reported because it is the one surprising thing about the tree: an agent or
|
|
32
|
+
// engineer who does not know the SDK is vendored will try to "fix" the
|
|
33
|
+
// imports into a package dependency that resolves nowhere.
|
|
34
|
+
const vendored = Object.keys(automationScaffoldFiles(name))
|
|
35
|
+
.filter((f) => f.startsWith('src/frontera/')).length
|
|
36
|
+
ctx.output.note(
|
|
37
|
+
` SDK vendored into src/frontera/ (${vendored} files) — imports resolve by tsconfig paths`,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
data: { name, path: target, vendoredSdkFiles: vendored },
|
|
42
|
+
text: [
|
|
43
|
+
`Scaffolded ${name}`,
|
|
44
|
+
'',
|
|
45
|
+
` cd ${name}`,
|
|
46
|
+
' bun install',
|
|
47
|
+
' bun run typecheck',
|
|
48
|
+
' frontera automation deploy src/index.ts',
|
|
49
|
+
].join('\n'),
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, parse } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The project directory to pack as an automation's source, or null.
|
|
6
|
+
*
|
|
7
|
+
* Walks up from the entry file to the nearest `package.json`. Two rules, and both
|
|
8
|
+
* exist because the obvious implementations are actively harmful:
|
|
9
|
+
*
|
|
10
|
+
* - NOT the entry file's own directory. `src/index.ts` would pack `src/` and
|
|
11
|
+
* omit `package.json` and `tsconfig.json` — so a pulled project would
|
|
12
|
+
* contain a lone source file and could not build. Measured: a first pass did
|
|
13
|
+
* exactly this and stored a one-member archive.
|
|
14
|
+
*
|
|
15
|
+
* - NOT a workspace root. An automation authored inside this monorepo has no
|
|
16
|
+
* `package.json` between it and the repo root, so an unguarded walk would
|
|
17
|
+
* pack the WHOLE monorepo into a bucket. A `package.json` declaring
|
|
18
|
+
* `workspaces` is therefore refused as a root.
|
|
19
|
+
*
|
|
20
|
+
* Returning null rather than throwing is deliberate: authoring inside the
|
|
21
|
+
* monorepo is still supported, it simply cannot retain source. The caller says
|
|
22
|
+
* so out loud and deploys anyway, and `pull` reports the missing source
|
|
23
|
+
* actionably. Refusing would break a workflow that works.
|
|
24
|
+
*/
|
|
25
|
+
export function findAutomationProjectRoot(entryFile: string): string | null {
|
|
26
|
+
const { root } = parse(entryFile)
|
|
27
|
+
let dir = dirname(entryFile)
|
|
28
|
+
|
|
29
|
+
while (true) {
|
|
30
|
+
const manifest = join(dir, 'package.json')
|
|
31
|
+
if (existsSync(manifest)) {
|
|
32
|
+
let parsed: unknown
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
35
|
+
} catch {
|
|
36
|
+
// Cannot verify this is NOT a workspace root, and the hazard being
|
|
37
|
+
// guarded is uploading an entire monorepo. Packing it and walking past it
|
|
38
|
+
// both risk that — packing directly, walking past by reaching a parent
|
|
39
|
+
// project the author never meant to publish. So refuse: deploy proceeds
|
|
40
|
+
// without source and says so.
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
const isWorkspaceRoot =
|
|
44
|
+
typeof parsed === 'object'
|
|
45
|
+
&& parsed !== null
|
|
46
|
+
&& 'workspaces' in (parsed as Record<string, unknown>)
|
|
47
|
+
// A workspace root is never the answer. Stop rather than continue: every
|
|
48
|
+
// ancestor above it is even less likely to be the project, and walking on
|
|
49
|
+
// risks finding some unrelated package.json further up the filesystem.
|
|
50
|
+
if (isWorkspaceRoot) return null
|
|
51
|
+
return dir
|
|
52
|
+
}
|
|
53
|
+
if (dir === root) return null
|
|
54
|
+
const parent = dirname(dir)
|
|
55
|
+
if (parent === dir) return null
|
|
56
|
+
dir = parent
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { AutomationApi } from '../../api/automation-api'
|
|
5
|
+
import { UsageError } from '../../errors'
|
|
6
|
+
import { unpackTo } from '../../untar'
|
|
7
|
+
import { flagBool, type Command } from '../types'
|
|
8
|
+
|
|
9
|
+
/** Split `slug` or `slug@3` into its parts. Exported for its own tests. */
|
|
10
|
+
export function parseTarget(raw: string): { slug: string; version: number | null } {
|
|
11
|
+
const at = raw.lastIndexOf('@')
|
|
12
|
+
if (at === -1) {
|
|
13
|
+
if (!raw) throw new UsageError('missing slug', 'frontera automation pull <slug>[@version]')
|
|
14
|
+
return { slug: raw, version: null }
|
|
15
|
+
}
|
|
16
|
+
const slug = raw.slice(0, at)
|
|
17
|
+
const rest = raw.slice(at + 1)
|
|
18
|
+
if (!slug) throw new UsageError('missing slug', 'frontera automation pull <slug>[@version]')
|
|
19
|
+
if (!/^\d+$/.test(rest)) {
|
|
20
|
+
// Not silently falling back to latest: handing back a different version than
|
|
21
|
+
// the one named is the failure mode this phase exists to prevent.
|
|
22
|
+
throw new UsageError(
|
|
23
|
+
`version must be a number, got "${rest}"`,
|
|
24
|
+
'frontera automation pull <slug>@3',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
return { slug, version: Number(rest) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where a pull writes, refusing to clobber.
|
|
32
|
+
*
|
|
33
|
+
* There are no server-side drafts for automations, so uncommitted local work a
|
|
34
|
+
* pull overwrites is gone. Refusing by default and requiring `--force` puts that
|
|
35
|
+
* decision in the author's hands. `readdirSync` counts dotfiles, so a directory
|
|
36
|
+
* holding only a `.git` is non-empty — which is exactly the case where clobbering
|
|
37
|
+
* costs the most.
|
|
38
|
+
*
|
|
39
|
+
* `resolve`, not `join`: `join(cwd, '/tmp/x')` yields `<cwd>/tmp/x`, so an
|
|
40
|
+
* absolute `--dir` landed inside the working directory. Measured — a pull with
|
|
41
|
+
* `--dir /tmp/pulled` wrote to `/tmp/pulled/tmp/pulled`. Exported for its own
|
|
42
|
+
* tests.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveTargetDir(
|
|
45
|
+
cwd: string,
|
|
46
|
+
slug: string,
|
|
47
|
+
dirFlag: string | undefined,
|
|
48
|
+
force: boolean,
|
|
49
|
+
): string {
|
|
50
|
+
// `--dir` is a GLOBAL flag: `main.ts` sets `cwd` to it before any command
|
|
51
|
+
// runs. So when it is present the target IS `cwd`, and resolving its VALUE
|
|
52
|
+
// again here appended it twice — the documented `--dir ./recovered` wrote to
|
|
53
|
+
// `./recovered/recovered`. Only its PRESENCE is signal; the value is already
|
|
54
|
+
// accounted for. An absolute flag hid this, because resolving an absolute path
|
|
55
|
+
// a second time is idempotent.
|
|
56
|
+
const target = dirFlag === undefined ? resolve(cwd, slug) : resolve(cwd)
|
|
57
|
+
if (!force && existsSync(target) && readdirSync(target).length > 0) {
|
|
58
|
+
throw new UsageError(
|
|
59
|
+
`${target} is not empty`,
|
|
60
|
+
'pull into a new directory, or pass --force to overwrite',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return target
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const automationPull: Command = {
|
|
67
|
+
meta: {
|
|
68
|
+
noun: 'automation',
|
|
69
|
+
verb: 'pull',
|
|
70
|
+
args: [{
|
|
71
|
+
name: 'slug[@version]',
|
|
72
|
+
required: true,
|
|
73
|
+
description: 'automation slug, optionally pinned to a version',
|
|
74
|
+
}],
|
|
75
|
+
flags: { dir: 'string', force: 'boolean' },
|
|
76
|
+
summary: 'Hydrate an editable project from a deployed version',
|
|
77
|
+
examples: [
|
|
78
|
+
'frontera automation pull daily-digest',
|
|
79
|
+
'frontera automation pull daily-digest@3 --dir ./recovered',
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async run(ctx) {
|
|
84
|
+
const raw = ctx.positional[0]
|
|
85
|
+
if (!raw) throw new UsageError('missing <slug>', 'frontera automation pull <slug>[@version]')
|
|
86
|
+
const { slug, version } = parseTarget(raw)
|
|
87
|
+
|
|
88
|
+
const client = new AutomationApi(ctx.apiUrl, ctx.token)
|
|
89
|
+
|
|
90
|
+
// "Latest" is resolved against the version list rather than by inventing a
|
|
91
|
+
// server endpoint for it — `versions` already exists and is authoritative.
|
|
92
|
+
let resolved = version
|
|
93
|
+
if (resolved === null) {
|
|
94
|
+
const list = await client.versions(slug)
|
|
95
|
+
if (list.length === 0) throw new UsageError(`${slug} has no versions`, 'deploy it first')
|
|
96
|
+
resolved = Math.max(...list.map((v) => v.version))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Refuse BEFORE downloading. Discovering the directory is occupied after a
|
|
100
|
+
// network round trip wastes it, and worse, invites a --force retry that the
|
|
101
|
+
// author has not actually thought about.
|
|
102
|
+
const target = resolveTargetDir(
|
|
103
|
+
ctx.cwd,
|
|
104
|
+
slug,
|
|
105
|
+
typeof ctx.flags.dir === 'string' ? ctx.flags.dir : undefined,
|
|
106
|
+
flagBool(ctx, 'force'),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const gz = await client.downloadSource(slug, resolved)
|
|
110
|
+
mkdirSync(target, { recursive: true })
|
|
111
|
+
const count = unpackTo(target, gz)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
data: { slug, version: resolved, path: target, files: count },
|
|
115
|
+
text: [
|
|
116
|
+
`Pulled ${slug} v${resolved} into ${target} (${count} files)`,
|
|
117
|
+
'',
|
|
118
|
+
` cd ${target}`,
|
|
119
|
+
' bun install',
|
|
120
|
+
' frontera automation deploy src/index.ts',
|
|
121
|
+
].join('\n'),
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}
|