@frontera-sdk/cli 1.50.9 → 1.50.11
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 -4
- package/src/forge/projects.ts +125 -22
- package/src/forge/server.ts +37 -5
- package/src/forge/store.ts +14 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.11",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
|
-
"@frontera-sdk/functions": "1.50.
|
|
43
|
-
"@frontera-sdk/core": "1.50.
|
|
42
|
+
"@frontera-sdk/functions": "1.50.11",
|
|
43
|
+
"@frontera-sdk/core": "1.50.11",
|
|
44
44
|
"ai": "^6.0.116",
|
|
45
45
|
"gray-matter": "^4.0.3",
|
|
46
46
|
"yaml": "^2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@frontera-sdk/forge-contracts": "1.50.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.50.11",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
package/src/forge/projects.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { findProjectRoot } from '../context'
|
|
|
9
9
|
import { readProject } from '../project'
|
|
10
10
|
import { applyClaudeImport, applyManagedBlock, type BlockMarkers } from '../kit'
|
|
11
11
|
import { ensureGitignored, findContext, writeContext } from '../project-context'
|
|
12
|
-
import { listProfiles, normalizeOrigin } from '../profiles'
|
|
12
|
+
import { listProfiles, normalizeOrigin, type ProfileMetadata } from '../profiles'
|
|
13
13
|
import { badRequest } from './http'
|
|
14
14
|
import type { NewProject } from './store'
|
|
15
15
|
|
|
@@ -141,32 +141,127 @@ function writeSituationFiles(root: string, body: string): void {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/** Where Forge keeps a workspace's folder. Visible on purpose: developers open it in editors. */
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
/**
|
|
145
|
+
* A folder-name segment. Lower-cased: APFS and NTFS fold case, and two rows
|
|
146
|
+
* for `audit-Globex` and `audit-globex` would be one directory. Capped: an
|
|
147
|
+
* organization name is unbounded text, and a segment past the filesystem's
|
|
148
|
+
* limit fails `mkdir` for the whole workspace.
|
|
149
|
+
*/
|
|
150
|
+
const SEGMENT_MAX = 40
|
|
151
|
+
function safeSegment(value: string, fallback: string): string {
|
|
152
|
+
const safe = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '')
|
|
153
|
+
return (safe || fallback).slice(0, SEGMENT_MAX).replace(/[-.]+$/g, '') || fallback
|
|
147
154
|
}
|
|
148
155
|
|
|
149
|
-
/**
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
156
|
+
/** Where managed workspace folders live. Injectable: `os.homedir()` ignores `$HOME` under Bun, so a test cannot redirect it. */
|
|
157
|
+
export function workspacesDir(): string {
|
|
158
|
+
return join(homedir(), 'Frontera')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function workspaceRoot(workspaceSlug: string, discriminator?: string, base: string = workspacesDir()): string {
|
|
162
|
+
const safe = safeSegment(workspaceSlug, 'workspace')
|
|
163
|
+
return join(base, discriminator ? `${safe}-${safeSegment(discriminator, 'x')}` : safe)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The folder for THIS workspace.
|
|
168
|
+
*
|
|
169
|
+
* Workspace slugs are unique per organization, not across them: two
|
|
170
|
+
* organizations can each have `audit`. The folder used to be keyed on the
|
|
171
|
+
* slug alone, so the second organization's `audit` landed in the first's
|
|
172
|
+
* folder — its project row, its sessions, its profile binding — and a person
|
|
173
|
+
* switching organizations saw the other one's Forge history.
|
|
174
|
+
*
|
|
175
|
+
* The plain slug stays the folder for whichever workspace claimed it first
|
|
176
|
+
* (nobody's folder moves). A later workspace with the same slug gets the
|
|
177
|
+
* slug suffixed with its organization's name, or with the workspace id's
|
|
178
|
+
* first eight characters when even that is taken or unknown.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveWorkspaceRoot(
|
|
181
|
+
input: { workspaceId: string; workspaceSlug: string; orgName?: string },
|
|
182
|
+
takenByAnotherWorkspace: (root: string) => boolean,
|
|
183
|
+
base: string = workspacesDir(),
|
|
184
|
+
): string {
|
|
185
|
+
const id8 = input.workspaceId.slice(0, 8)
|
|
186
|
+
const candidates = [
|
|
187
|
+
undefined,
|
|
188
|
+
...(input.orgName ? [input.orgName] : []),
|
|
189
|
+
id8,
|
|
190
|
+
]
|
|
191
|
+
for (const discriminator of candidates) {
|
|
192
|
+
const root = workspaceRoot(input.workspaceSlug, discriminator, base)
|
|
193
|
+
if (!takenByAnotherWorkspace(root)) return root
|
|
194
|
+
}
|
|
195
|
+
// Every readable name is someone else's. Number until one is free — the
|
|
196
|
+
// guarantee is total, whatever ids or names collide.
|
|
197
|
+
for (let n = 2; ; n += 1) {
|
|
198
|
+
const root = workspaceRoot(input.workspaceSlug, `${id8}-${n}`, base)
|
|
199
|
+
if (!takenByAnotherWorkspace(root)) return root
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The workspace a managed folder ON DISK says it belongs to, from the id the
|
|
205
|
+
* situation block writes into its `AGENTS.md`. The store is not the only
|
|
206
|
+
* record: a row can be deleted (`DELETE /projects/:id` removes rows, never
|
|
207
|
+
* files) or the store reset, and the folder — another organization's
|
|
208
|
+
* checkout, its profile binding — is still there. Null when the folder has
|
|
209
|
+
* no managed block.
|
|
210
|
+
*/
|
|
211
|
+
export function workspaceIdInFolder(root: string): string | null {
|
|
153
212
|
try {
|
|
154
|
-
|
|
213
|
+
const text = readFileSync(join(root, 'AGENTS.md'), 'utf8')
|
|
214
|
+
const start = text.indexOf(FORGE_MARKERS.begin)
|
|
215
|
+
const end = text.indexOf(FORGE_MARKERS.end)
|
|
216
|
+
if (start === -1 || end === -1) return null
|
|
217
|
+
const match = /Workspace id `([^`]+)`/.exec(text.slice(start, end))
|
|
218
|
+
return match?.[1] ?? null
|
|
155
219
|
} catch {
|
|
156
220
|
return null
|
|
157
221
|
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The profile bound to this deployment's API origin, if the CLI has one. */
|
|
225
|
+
/**
|
|
226
|
+
* The profile to bind a workspace folder to.
|
|
227
|
+
*
|
|
228
|
+
* Not the first profile for the origin. Two organizations on one deployment
|
|
229
|
+
* are two profiles with the same `apiUrl`, and first-match bound BOTH
|
|
230
|
+
* organizations' folders to whichever profile sorted first — the second
|
|
231
|
+
* organization's sessions then acted on the first organization's key. So:
|
|
232
|
+
* this workspace's own key; else an organization key for THIS organization;
|
|
233
|
+
* else a profile for the origin that names no organization (an older
|
|
234
|
+
* profile, or the organization is unknown here); never a profile that names
|
|
235
|
+
* a different organization.
|
|
236
|
+
*/
|
|
237
|
+
export function profileForWorkspace(
|
|
238
|
+
apiUrl: string | null,
|
|
239
|
+
identity: { workspaceId: string; orgId?: string },
|
|
240
|
+
profiles: Array<{ name: string; profile: ProfileMetadata }>,
|
|
241
|
+
): string | null {
|
|
242
|
+
if (!apiUrl) return null
|
|
243
|
+
let origin: string
|
|
158
244
|
try {
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
return normalizeOrigin(p.profile.apiUrl) === origin
|
|
162
|
-
} catch {
|
|
163
|
-
return false
|
|
164
|
-
}
|
|
165
|
-
})
|
|
166
|
-
return match?.name ?? null
|
|
245
|
+
origin = normalizeOrigin(apiUrl)
|
|
167
246
|
} catch {
|
|
168
247
|
return null
|
|
169
248
|
}
|
|
249
|
+
const atOrigin = profiles.filter((p) => {
|
|
250
|
+
try {
|
|
251
|
+
return normalizeOrigin(p.profile.apiUrl) === origin
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
const own = atOrigin.find((p) => p.profile.workspaceId === identity.workspaceId)
|
|
257
|
+
if (own) return own.name
|
|
258
|
+
const sameOrg = identity.orgId
|
|
259
|
+
? atOrigin.find((p) => p.profile.orgId === identity.orgId && p.profile.credentialKind === 'organization')
|
|
260
|
+
?? atOrigin.find((p) => p.profile.orgId === identity.orgId)
|
|
261
|
+
: undefined
|
|
262
|
+
if (sameOrg) return sameOrg.name
|
|
263
|
+
const unscoped = atOrigin.find((p) => !p.profile.orgId && !p.profile.workspaceId)
|
|
264
|
+
return unscoped?.name ?? null
|
|
170
265
|
}
|
|
171
266
|
|
|
172
267
|
/**
|
|
@@ -177,11 +272,13 @@ function profileForOrigin(apiUrl: string | null): string | null {
|
|
|
177
272
|
* to a profile is left as it is.
|
|
178
273
|
*/
|
|
179
274
|
export function ensureWorkspaceProject(
|
|
180
|
-
input: { workspaceId: string; workspaceSlug: string; workspaceName?: string },
|
|
275
|
+
input: { workspaceId: string; workspaceSlug: string; workspaceName?: string; orgId?: string },
|
|
181
276
|
apiUrl: string | null,
|
|
182
|
-
warn: (message: string) => void
|
|
277
|
+
warn: (message: string) => void,
|
|
278
|
+
/** The folder, resolved by the caller against what it knows — see `resolveWorkspaceRoot`. Required: a default would be the slug-only path this exists to leave behind. */
|
|
279
|
+
root: string,
|
|
280
|
+
deps: { profiles?: () => Array<{ name: string; profile: ProfileMetadata }> } = {},
|
|
183
281
|
): NewProject {
|
|
184
|
-
const root = workspaceRoot(input.workspaceSlug)
|
|
185
282
|
mkdirSync(root, { recursive: true })
|
|
186
283
|
if (!isGitWorkTree(root)) {
|
|
187
284
|
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' })
|
|
@@ -193,9 +290,15 @@ export function ensureWorkspaceProject(
|
|
|
193
290
|
bound = null
|
|
194
291
|
}
|
|
195
292
|
if (!bound) {
|
|
196
|
-
|
|
293
|
+
let profiles: Array<{ name: string; profile: ProfileMetadata }> = []
|
|
294
|
+
try {
|
|
295
|
+
profiles = (deps.profiles ?? listProfiles)()
|
|
296
|
+
} catch (error) {
|
|
297
|
+
warn(`could not read CLI profiles: ${error instanceof Error ? error.message : String(error)}`)
|
|
298
|
+
}
|
|
299
|
+
const profile = profileForWorkspace(apiUrl, { workspaceId: input.workspaceId, ...(input.orgId ? { orgId: input.orgId } : {}) }, profiles)
|
|
197
300
|
if (!profile) {
|
|
198
|
-
warn(`no CLI profile for ${apiUrl ?? '(no api url)'}; ${root} left unbound`)
|
|
301
|
+
warn(`no CLI profile for this workspace or organization at ${apiUrl ?? '(no api url)'}; ${root} left unbound`)
|
|
199
302
|
} else {
|
|
200
303
|
try {
|
|
201
304
|
writeContext(root, profile)
|
package/src/forge/server.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { FORGE_HEADERS, FORGE_WELL_KNOWN_PATH } from './protocol'
|
|
|
29
29
|
import { commandsFor } from './claude-commands'
|
|
30
30
|
import { skillsFor } from './claude-skills'
|
|
31
31
|
import { normalizeTurnText } from './turn-text'
|
|
32
|
-
import { ensureWorkspaceProject, inspectRoot, listFiles, readChanges } from './projects'
|
|
32
|
+
import { ensureWorkspaceProject, inspectRoot, listFiles, readChanges, resolveWorkspaceRoot, workspaceIdInFolder } from './projects'
|
|
33
33
|
import { RunManager } from './runs'
|
|
34
34
|
import { sessionView } from './sessions'
|
|
35
35
|
import { ForgeStore, type StoredThread, type ThreadPatch } from './store'
|
|
@@ -56,6 +56,11 @@ export interface ForgeServerOptions {
|
|
|
56
56
|
storePath: string
|
|
57
57
|
log: Logger
|
|
58
58
|
adapters?: AdapterFactory
|
|
59
|
+
/**
|
|
60
|
+
* Where managed workspace folders are created. Defaults to `~/Frontera`; a
|
|
61
|
+
* test points it at a scratch directory so it never touches the real one.
|
|
62
|
+
*/
|
|
63
|
+
workspacesDir?: string
|
|
59
64
|
/**
|
|
60
65
|
* How host binaries are probed. Defaults to spawning them. A test supplies
|
|
61
66
|
* its own so which harnesses happen to be installed on the machine running
|
|
@@ -251,20 +256,47 @@ export function startForgeServer(opts: ForgeServerOptions): ForgeServer {
|
|
|
251
256
|
|
|
252
257
|
// The managed folder for the workspace the developer is in — no picker.
|
|
253
258
|
route('POST', '/v1/forge/projects/workspace', async (req) => {
|
|
254
|
-
const body = await readJson<{ workspaceId?: unknown; workspaceSlug?: unknown; workspaceName?: unknown }>(req)
|
|
259
|
+
const body = await readJson<{ workspaceId?: unknown; workspaceSlug?: unknown; workspaceName?: unknown; orgId?: unknown; orgName?: unknown }>(req)
|
|
255
260
|
if (typeof body.workspaceId !== 'string' || !body.workspaceId) throw badRequest('workspaceId is required')
|
|
256
261
|
if (typeof body.workspaceSlug !== 'string' || !body.workspaceSlug) throw badRequest('workspaceSlug is required')
|
|
262
|
+
const workspaceId = body.workspaceId
|
|
263
|
+
const orgId = typeof body.orgId === 'string' && body.orgId ? body.orgId : undefined
|
|
264
|
+
const orgName = typeof body.orgName === 'string' && body.orgName ? body.orgName : undefined
|
|
265
|
+
// This workspace's own row decides its folder; only a workspace with no
|
|
266
|
+
// row yet picks one — and never a folder another workspace owns, by the
|
|
267
|
+
// store's row OR by what the folder on disk says it is (the row can be
|
|
268
|
+
// gone; the folder, with its checkout and profile binding, is not).
|
|
269
|
+
// Two organizations can each have a workspace called `audit`.
|
|
270
|
+
const own = store.projectByWorkspace(workspaceId)
|
|
271
|
+
const root = own?.root ?? resolveWorkspaceRoot(
|
|
272
|
+
{ workspaceId, workspaceSlug: body.workspaceSlug, ...(orgName ? { orgName } : {}) },
|
|
273
|
+
(candidate) => {
|
|
274
|
+
const holder = store.projectByRoot(candidate)
|
|
275
|
+
if (holder !== null && holder.workspaceId !== null && holder.workspaceId !== workspaceId) return true
|
|
276
|
+
const onDisk = workspaceIdInFolder(candidate)
|
|
277
|
+
return onDisk !== null && onDisk !== workspaceId
|
|
278
|
+
},
|
|
279
|
+
opts.workspacesDir,
|
|
280
|
+
)
|
|
257
281
|
const detected = ensureWorkspaceProject(
|
|
258
282
|
{
|
|
259
|
-
workspaceId
|
|
283
|
+
workspaceId,
|
|
260
284
|
workspaceSlug: body.workspaceSlug,
|
|
261
285
|
...(typeof body.workspaceName === 'string' ? { workspaceName: body.workspaceName } : {}),
|
|
286
|
+
...(orgId ? { orgId } : {}),
|
|
262
287
|
},
|
|
263
288
|
opts.apiUrl || null,
|
|
264
289
|
(message) => log.warn(message),
|
|
290
|
+
root,
|
|
265
291
|
)
|
|
266
|
-
|
|
267
|
-
|
|
292
|
+
// A row at this root with no workspace (opened by hand before managed
|
|
293
|
+
// folders existed) is adopted AND claimed, so it is this workspace's from
|
|
294
|
+
// now on; one for another workspace was excluded above.
|
|
295
|
+
const existing = own ?? store.projectByRoot(root)
|
|
296
|
+
if (existing) {
|
|
297
|
+
if (existing.workspaceId === null) store.claimProjectWorkspace(existing.id, workspaceId)
|
|
298
|
+
return ok(store.refreshProject(existing.id, detected))
|
|
299
|
+
}
|
|
268
300
|
const project = store.insertProject(detected)
|
|
269
301
|
log.info(`workspace project ${project.id} at ${project.root}`)
|
|
270
302
|
return ok(project)
|
package/src/forge/store.ts
CHANGED
|
@@ -290,10 +290,21 @@ export class ForgeStore {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/** Re-detect the parts that can change underneath us: git, app, profile. */
|
|
293
|
-
refreshProject(id: string, input: Pick<NewProject, 'profile' | 'appSlug' | 'git'>): ForgeProject {
|
|
293
|
+
refreshProject(id: string, input: Pick<NewProject, 'profile' | 'appSlug' | 'git'> & { name?: string }): ForgeProject {
|
|
294
294
|
this.db
|
|
295
|
-
.query('UPDATE projects SET profile = ?, app_slug = ?, git = ?, last_opened_at = ? WHERE id = ?')
|
|
296
|
-
.run(input.profile, input.appSlug, input.git ? 1 : 0, now(), id)
|
|
295
|
+
.query('UPDATE projects SET profile = ?, app_slug = ?, git = ?, name = COALESCE(?, name), last_opened_at = ? WHERE id = ?')
|
|
296
|
+
.run(input.profile, input.appSlug, input.git ? 1 : 0, input.name ?? null, now(), id)
|
|
297
|
+
return this.getProject(id)!
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A row opened by hand (no workspace) that a workspace now adopts takes the
|
|
302
|
+
* workspace's id, so it is found by `projectByWorkspace` from then on and
|
|
303
|
+
* counts as taken for any other workspace — a NULL there let a second
|
|
304
|
+
* organization adopt the same row.
|
|
305
|
+
*/
|
|
306
|
+
claimProjectWorkspace(id: string, workspaceId: string): ForgeProject {
|
|
307
|
+
this.db.query('UPDATE projects SET workspace_id = ? WHERE id = ? AND workspace_id IS NULL').run(workspaceId, id)
|
|
297
308
|
return this.getProject(id)!
|
|
298
309
|
}
|
|
299
310
|
|