@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.9",
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.9",
43
- "@frontera-sdk/core": "1.50.9",
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.9",
49
+ "@frontera-sdk/forge-contracts": "1.50.11",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -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
- export function workspaceRoot(workspaceSlug: string): string {
145
- const safe = workspaceSlug.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'workspace'
146
- return join(homedir(), 'Frontera', safe)
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
- /** The profile bound to this deployment's API origin, if the CLI has one. */
150
- function profileForOrigin(apiUrl: string | null): string | null {
151
- if (!apiUrl) return null
152
- let origin: string
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
- origin = normalizeOrigin(apiUrl)
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
- const match = listProfiles().find((p) => {
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
- const profile = profileForOrigin(apiUrl)
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)
@@ -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: body.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
- const existing = store.projectByWorkspace(body.workspaceId) ?? store.projectByRoot(detected.root)
267
- if (existing) return ok(store.refreshProject(existing.id, detected))
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)
@@ -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