@sqaoss/flowy 1.7.0 → 1.9.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.
@@ -52,7 +52,9 @@ describe('config', () => {
52
52
  test('loadConfig returns defaults when no config file exists', async () => {
53
53
  const { loadConfig } = await import('./config.ts')
54
54
  const config = loadConfig()
55
- expect(config.mode).toBe('saas')
55
+ // Canonical default mode is "remote" (was "saas" — kept as a back-compat
56
+ // alias on read only).
57
+ expect(config.mode).toBe('remote')
56
58
  expect(config.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
57
59
  expect(config.apiKey).toBe('')
58
60
  expect(config.client.name).toBe('')
@@ -196,4 +198,169 @@ describe('config', () => {
196
198
  (updated.projects[cwd] as { activeFeature?: string }).activeFeature,
197
199
  ).toBe('feat_999')
198
200
  })
201
+
202
+ describe('per-mode profiles (F25)', () => {
203
+ test('default config canonicalizes mode to "remote"', async () => {
204
+ const { loadConfig } = await import('./config.ts')
205
+ const config = loadConfig()
206
+ // Canonical vocab is "remote"; "saas" is only a back-compat alias.
207
+ expect(config.mode).toBe('remote')
208
+ })
209
+
210
+ test('local apiKey/projects do not bleed into remote mode', async () => {
211
+ const { saveConfig, loadConfig } = await import('./config.ts')
212
+
213
+ // Configure the local profile with a key + a project mapping.
214
+ const local = loadConfig()
215
+ local.mode = 'local'
216
+ local.apiKey = 'local-secret'
217
+ local.apiUrl = 'http://localhost:4000/graphql'
218
+ local.projects['/work/local'] = { id: 'proj_local', name: 'LocalProj' }
219
+ saveConfig(local)
220
+
221
+ // Switch to remote: the local key/projects must NOT be visible.
222
+ const remote = loadConfig()
223
+ remote.mode = 'remote'
224
+ expect(remote.apiKey).toBe('')
225
+ expect(remote.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
226
+ expect(remote.projects['/work/local']).toBeUndefined()
227
+
228
+ // Set a different key/project in remote mode and persist.
229
+ remote.apiKey = 'remote-secret'
230
+ remote.projects['/work/remote'] = {
231
+ id: 'proj_remote',
232
+ name: 'RemoteProj',
233
+ }
234
+ saveConfig(remote)
235
+
236
+ // Back to local: local data intact, remote data not visible.
237
+ const reloadLocal = loadConfig()
238
+ reloadLocal.mode = 'local'
239
+ expect(reloadLocal.apiKey).toBe('local-secret')
240
+ expect(reloadLocal.projects['/work/local']?.name).toBe('LocalProj')
241
+ expect(reloadLocal.projects['/work/remote']).toBeUndefined()
242
+ })
243
+
244
+ test('getConfig reads from the active mode profile, not the other', async () => {
245
+ const { saveConfig, loadConfig, getConfig } = await import('./config.ts')
246
+
247
+ const local = loadConfig()
248
+ local.mode = 'local'
249
+ local.apiKey = 'local-secret'
250
+ local.apiUrl = 'http://localhost:4000/graphql'
251
+ saveConfig(local)
252
+
253
+ const remote = loadConfig()
254
+ remote.mode = 'remote'
255
+ remote.apiKey = 'remote-secret'
256
+ saveConfig(remote)
257
+
258
+ // Active mode is now remote (last saved). getConfig sees remote creds.
259
+ const cfg = getConfig()
260
+ expect(cfg.apiKey).toBe('remote-secret')
261
+ expect(cfg.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
262
+ })
263
+
264
+ test('client name is shared across modes', async () => {
265
+ const { saveConfig, loadConfig } = await import('./config.ts')
266
+ const config = loadConfig()
267
+ config.client.name = 'Acme'
268
+ saveConfig(config)
269
+ const reloaded = loadConfig()
270
+ reloaded.mode = reloaded.mode === 'local' ? 'remote' : 'local'
271
+ expect(reloaded.client.name).toBe('Acme')
272
+ })
273
+
274
+ test('migrates a legacy flat config into the active-mode profile', async () => {
275
+ const { loadConfig } = await import('./config.ts')
276
+ // Legacy shape written by an older CLI: flat apiKey/apiUrl/projects,
277
+ // mode="saas" (the old vocab).
278
+ writeFileSync(
279
+ CONFIG_PATH,
280
+ JSON.stringify({
281
+ mode: 'saas',
282
+ apiUrl: 'https://flowy-ai.fly.dev/graphql',
283
+ apiKey: 'legacy-key',
284
+ client: { name: 'Legacy Co' },
285
+ projects: { '/legacy/path': { id: 'p1', name: 'Legacy' } },
286
+ }),
287
+ )
288
+
289
+ const config = loadConfig()
290
+ // "saas" canonicalizes to "remote".
291
+ expect(config.mode).toBe('remote')
292
+ // Flat fields land in the (remote) active profile.
293
+ expect(config.apiKey).toBe('legacy-key')
294
+ expect(config.projects['/legacy/path']?.name).toBe('Legacy')
295
+ expect(config.client.name).toBe('Legacy Co')
296
+ })
297
+
298
+ test('resolveProject/resolveFeature use the active mode profile only', async () => {
299
+ const { saveConfig, loadConfig, resolveProject, resolveFeature } =
300
+ await import('./config.ts')
301
+ const cwd = process.cwd()
302
+
303
+ const local = loadConfig()
304
+ local.mode = 'local'
305
+ local.projects[cwd] = {
306
+ id: 'proj_local',
307
+ name: 'Local',
308
+ activeFeature: 'feat_local',
309
+ }
310
+ saveConfig(local)
311
+
312
+ const remote = loadConfig()
313
+ remote.mode = 'remote'
314
+ remote.projects[cwd] = {
315
+ id: 'proj_remote',
316
+ name: 'Remote',
317
+ activeFeature: 'feat_remote',
318
+ }
319
+ saveConfig(remote)
320
+
321
+ // Active mode is remote → resolution returns the remote project.
322
+ expect(resolveProject()?.id).toBe('proj_remote')
323
+ expect(resolveFeature()).toBe('feat_remote')
324
+ })
325
+
326
+ test('requireRemoteMode throws a coded error in local mode', async () => {
327
+ const { saveConfig, loadConfig, requireRemoteMode } = await import(
328
+ './config.ts'
329
+ )
330
+ const config = loadConfig()
331
+ config.mode = 'local'
332
+ saveConfig(config)
333
+
334
+ expect(() => requireRemoteMode('whoami')).toThrow(/local mode/i)
335
+ try {
336
+ requireRemoteMode('whoami')
337
+ } catch (error) {
338
+ expect((error as { code?: string }).code).toBe('LOCAL_MODE')
339
+ }
340
+ })
341
+
342
+ test('requireRemoteMode is a no-op in remote mode', async () => {
343
+ const { saveConfig, loadConfig, requireRemoteMode } = await import(
344
+ './config.ts'
345
+ )
346
+ const config = loadConfig()
347
+ config.mode = 'remote'
348
+ saveConfig(config)
349
+ expect(() => requireRemoteMode('whoami')).not.toThrow()
350
+ })
351
+
352
+ test('a half-written config (mode set, key not yet) still loads cleanly', async () => {
353
+ const { saveConfig, loadConfig } = await import('./config.ts')
354
+ // Simulate save-after-mode-switch but before the key arrives.
355
+ const config = loadConfig()
356
+ config.mode = 'remote'
357
+ saveConfig(config)
358
+
359
+ const reloaded = loadConfig()
360
+ expect(reloaded.mode).toBe('remote')
361
+ expect(reloaded.apiKey).toBe('')
362
+ expect(reloaded.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
363
+ expect(reloaded.projects).toEqual({})
364
+ })
365
+ })
199
366
  })
@@ -18,6 +18,32 @@ const DIR_MODE = 0o700
18
18
  const FILE_MODE = 0o600
19
19
  const isWindows = platform() === 'win32'
20
20
 
21
+ /** The two configuration profiles. `saas` is a back-compat alias for `remote`. */
22
+ export type Mode = 'local' | 'remote'
23
+
24
+ const LOCAL_API_URL = 'http://localhost:4000/graphql'
25
+ const REMOTE_API_URL = 'https://flowy-ai.fly.dev/graphql'
26
+
27
+ export interface ProjectConfig {
28
+ id: string
29
+ name: string
30
+ activeFeature?: string
31
+ }
32
+
33
+ /** Per-mode settings — isolated so local creds never bleed into remote. */
34
+ interface Profile {
35
+ apiUrl: string
36
+ apiKey: string
37
+ projects: Record<string, ProjectConfig>
38
+ }
39
+
40
+ /** On-disk shape (current). */
41
+ interface StoredConfig {
42
+ mode: Mode
43
+ client: { name: string }
44
+ profiles: Record<Mode, Profile>
45
+ }
46
+
21
47
  /**
22
48
  * Non-reversible fingerprint of an API key, safe to print to stdout/logs.
23
49
  * A short SHA-256 prefix lets a human confirm *which* key is configured
@@ -29,25 +55,157 @@ export function fingerprintKey(apiKey: string): string {
29
55
  return `sha256:${digest.slice(0, 12)}`
30
56
  }
31
57
 
32
- export function loadConfig() {
33
- if (!existsSync(CONFIG_PATH)) {
34
- return {
35
- mode: 'saas' as const,
36
- apiUrl: 'https://flowy-ai.fly.dev/graphql',
37
- apiKey: '',
38
- client: { name: '' },
39
- projects: {} as Record<string, unknown>,
58
+ /** Map any historical mode token onto the canonical vocabulary (F25). */
59
+ function canonicalMode(raw: unknown): Mode {
60
+ // "saas" was the old name for the hosted service; "remote" is canonical now.
61
+ return raw === 'local' ? 'local' : 'remote'
62
+ }
63
+
64
+ function defaultProfile(mode: Mode): Profile {
65
+ return {
66
+ apiUrl: mode === 'local' ? LOCAL_API_URL : REMOTE_API_URL,
67
+ apiKey: '',
68
+ projects: {},
69
+ }
70
+ }
71
+
72
+ function emptyStored(mode: Mode = 'remote'): StoredConfig {
73
+ return {
74
+ mode,
75
+ client: { name: '' },
76
+ profiles: {
77
+ local: defaultProfile('local'),
78
+ remote: defaultProfile('remote'),
79
+ },
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Read raw JSON off disk and normalize it into the current {mode, client,
85
+ * profiles} shape. Handles three eras gracefully:
86
+ * - missing file → defaults (remote mode, empty profiles)
87
+ * - legacy flat config → {mode, apiUrl, apiKey, projects} migrated into
88
+ * the active-mode profile
89
+ * - current profiled config
90
+ * The migration is non-destructive: an old user's key/projects land in the
91
+ * profile matching their (canonicalized) mode, so nothing is lost.
92
+ */
93
+ function readStored(): StoredConfig {
94
+ if (!existsSync(CONFIG_PATH)) return emptyStored()
95
+
96
+ let raw: Record<string, unknown>
97
+ try {
98
+ raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
99
+ } catch {
100
+ return emptyStored()
101
+ }
102
+
103
+ const mode = canonicalMode(raw.mode)
104
+ const clientName =
105
+ typeof (raw.client as { name?: unknown })?.name === 'string'
106
+ ? (raw.client as { name: string }).name
107
+ : ''
108
+
109
+ const stored = emptyStored(mode)
110
+ stored.client.name = clientName
111
+
112
+ if (raw.profiles && typeof raw.profiles === 'object') {
113
+ // Current profiled shape — merge each known profile over the defaults.
114
+ const profiles = raw.profiles as Partial<Record<string, Partial<Profile>>>
115
+ // Fold a legacy "saas" profile onto "remote" if one is ever present.
116
+ for (const key of ['local', 'remote', 'saas'] as const) {
117
+ const p = profiles[key]
118
+ if (!p) continue
119
+ const target: Mode = key === 'saas' ? 'remote' : key
120
+ stored.profiles[target] = {
121
+ apiUrl:
122
+ typeof p.apiUrl === 'string'
123
+ ? p.apiUrl
124
+ : defaultProfile(target).apiUrl,
125
+ apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
126
+ projects: (p.projects as Record<string, ProjectConfig>) ?? {},
127
+ }
40
128
  }
129
+ return stored
130
+ }
131
+
132
+ // Legacy flat config — migrate apiUrl/apiKey/projects into the active profile.
133
+ const profile = stored.profiles[mode]
134
+ if (typeof raw.apiUrl === 'string') profile.apiUrl = raw.apiUrl
135
+ if (typeof raw.apiKey === 'string') profile.apiKey = raw.apiKey
136
+ if (raw.projects && typeof raw.projects === 'object') {
137
+ profile.projects = raw.projects as Record<string, ProjectConfig>
41
138
  }
42
- return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
139
+ return stored
43
140
  }
44
141
 
45
- export function saveConfig(config: ReturnType<typeof loadConfig>): void {
142
+ /**
143
+ * A live, backward-compatible view over the stored config. Reading/writing
144
+ * `apiUrl`/`apiKey`/`projects` transparently targets the *active* mode's
145
+ * profile, so every existing command keeps working unchanged. Reassigning
146
+ * `mode` re-points those accessors at the other profile — the mechanism that
147
+ * keeps local and remote credentials from cross-contaminating (F25).
148
+ */
149
+ export interface Config {
150
+ mode: Mode
151
+ client: { name: string }
152
+ apiUrl: string
153
+ apiKey: string
154
+ projects: Record<string, ProjectConfig>
155
+ /** @internal — the underlying per-mode storage, persisted by saveConfig. */
156
+ readonly profiles: Record<Mode, Profile>
157
+ }
158
+
159
+ function makeConfig(stored: StoredConfig): Config {
160
+ let mode = stored.mode
161
+ const view = {
162
+ client: stored.client,
163
+ get profiles() {
164
+ return stored.profiles
165
+ },
166
+ get mode() {
167
+ return mode
168
+ },
169
+ set mode(next: Mode) {
170
+ mode = canonicalMode(next)
171
+ },
172
+ get apiUrl() {
173
+ return stored.profiles[mode].apiUrl
174
+ },
175
+ set apiUrl(value: string) {
176
+ stored.profiles[mode].apiUrl = value
177
+ },
178
+ get apiKey() {
179
+ return stored.profiles[mode].apiKey
180
+ },
181
+ set apiKey(value: string) {
182
+ stored.profiles[mode].apiKey = value
183
+ },
184
+ get projects() {
185
+ return stored.profiles[mode].projects
186
+ },
187
+ set projects(value: Record<string, ProjectConfig>) {
188
+ stored.profiles[mode].projects = value
189
+ },
190
+ }
191
+ return view as Config
192
+ }
193
+
194
+ export function loadConfig(): Config {
195
+ return makeConfig(readStored())
196
+ }
197
+
198
+ export function saveConfig(config: Config): void {
199
+ const stored: StoredConfig = {
200
+ mode: canonicalMode(config.mode),
201
+ client: { name: config.client.name },
202
+ profiles: config.profiles,
203
+ }
46
204
  mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE })
47
205
  // `mode` on writeFileSync only applies to *newly created* files and is
48
206
  // masked by umask, so an explicit chmod afterward both corrects a
49
207
  // pre-existing world-readable (0644) config and survives a tight umask.
50
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
208
+ writeFileSync(CONFIG_PATH, JSON.stringify(stored, null, 2), {
51
209
  mode: FILE_MODE,
52
210
  })
53
211
  if (!isWindows) {
@@ -56,10 +214,22 @@ export function saveConfig(config: ReturnType<typeof loadConfig>): void {
56
214
  }
57
215
  }
58
216
 
59
- export interface ProjectConfig {
60
- id: string
61
- name: string
62
- activeFeature?: string
217
+ /**
218
+ * Guard SaaS-only commands. The bundled local server has no whoami/billing/key
219
+ * endpoints, so running them in local mode used to fail with an obscure
220
+ * GraphQL error. Fail fast with a clear message + a distinct code (F25).
221
+ */
222
+ export function requireRemoteMode(commandName: string): void {
223
+ const { mode } = loadConfig()
224
+ if (mode === 'local') {
225
+ const err = new Error(
226
+ `"flowy ${commandName}" is only available in remote mode. ` +
227
+ `The active mode is local mode — run "flowy setup remote" to connect ` +
228
+ `to the hosted Flowy service.`,
229
+ ) as Error & { code?: string }
230
+ err.code = 'LOCAL_MODE'
231
+ throw err
232
+ }
63
233
  }
64
234
 
65
235
  export function resolveProject(): ProjectConfig | null {
@@ -69,7 +239,7 @@ export function resolveProject(): ProjectConfig | null {
69
239
  if (envProject) {
70
240
  return (
71
241
  (Object.values(config.projects).find(
72
- (p) => (p as ProjectConfig).name === envProject,
242
+ (p) => p.name === envProject,
73
243
  ) as ProjectConfig) ?? null
74
244
  )
75
245
  }
@@ -83,7 +253,7 @@ export function resolveProject(): ProjectConfig | null {
83
253
  (cwd === path || cwd.startsWith(`${path}/`)) &&
84
254
  path.length > bestLength
85
255
  ) {
86
- bestMatch = project as ProjectConfig
256
+ bestMatch = project
87
257
  bestLength = path.length
88
258
  }
89
259
  }
@@ -126,7 +296,7 @@ export function updateProjectConfig(
126
296
 
127
297
  for (const [path, project] of Object.entries(config.projects)) {
128
298
  if (cwd === path || cwd.startsWith(`${path}/`)) {
129
- updater(project as ProjectConfig)
299
+ updater(project)
130
300
  saveConfig(config)
131
301
  return
132
302
  }
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ LOCAL_CONTRACT_OPERATIONS,
6
+ SAAS_ONLY_OPERATIONS,
7
+ } from './operations.ts'
8
+
9
+ /**
10
+ * Root-suite half of the CLI/local-server contract guard (P1-1).
11
+ *
12
+ * The executable half lives in `server/src/contract.test.ts` (runs the ops
13
+ * against a live local server). This half runs under the root `bun run test`
14
+ * and guards the *single source of truth*: every command must send a canonical
15
+ * operation from `operations.ts`, and the two op-sets must stay disjoint and
16
+ * well-formed. If a command re-inlines a query string (bypassing the contract),
17
+ * this fails.
18
+ */
19
+
20
+ const allOps = {
21
+ ...LOCAL_CONTRACT_OPERATIONS,
22
+ ...SAAS_ONLY_OPERATIONS,
23
+ } as Record<string, string>
24
+
25
+ describe('operations module', () => {
26
+ it('every operation is a non-empty named query or mutation', () => {
27
+ for (const [name, op] of Object.entries(allOps)) {
28
+ expect(op.trim().length, name).toBeGreaterThan(0)
29
+ expect(op, name).toMatch(/^\s*(query|mutation)\s/)
30
+ }
31
+ })
32
+
33
+ it('local and SaaS-only operation sets are disjoint', () => {
34
+ const local = new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))
35
+ const saas = Object.keys(SAAS_ONLY_OPERATIONS)
36
+ for (const name of saas) expect(local.has(name), name).toBe(false)
37
+ })
38
+
39
+ it('SaaS-only set is exactly the documented divergences', () => {
40
+ // These are deliberately NOT served by the bundled local server.
41
+ expect(new Set(Object.keys(SAAS_ONLY_OPERATIONS))).toEqual(
42
+ new Set(['REGISTER', 'WHOAMI', 'ROTATE_API_KEY', 'CREATE_CHECKOUT']),
43
+ )
44
+ })
45
+ })
46
+
47
+ describe('commands send canonical operations (no re-inlined queries)', () => {
48
+ // command file -> the operation constants it must import from operations.ts
49
+ const expectations: Record<string, string[]> = {
50
+ 'project.ts': [
51
+ 'CREATE_PROJECT',
52
+ 'LIST_PROJECTS_FOR_SET',
53
+ 'LIST_PROJECTS',
54
+ 'GET_PROJECT',
55
+ 'UPDATE_NODE',
56
+ 'DELETE_NODE',
57
+ ],
58
+ 'feature.ts': [
59
+ 'CREATE_NODE',
60
+ 'CREATE_EDGE',
61
+ 'DESCENDANTS',
62
+ 'DESCENDANTS_BRIEF',
63
+ 'UPDATE_NODE',
64
+ 'DELETE_NODE',
65
+ 'GET_NODE',
66
+ ],
67
+ 'task.ts': [
68
+ 'CREATE_TASK',
69
+ 'LINK_TASK',
70
+ 'READY_TASKS',
71
+ 'ALL_TASKS',
72
+ 'LIST_TASKS',
73
+ 'SHOW_TASK',
74
+ 'UPDATE_NODE',
75
+ 'DELETE_NODE',
76
+ 'BLOCK_TASK',
77
+ 'UNBLOCK_TASK',
78
+ 'TASK_DEPS',
79
+ ],
80
+ 'status.ts': ['UPDATE_STATUS'],
81
+ 'approve.ts': ['APPROVE_NODE'],
82
+ 'search.ts': ['SEARCH'],
83
+ 'tree.ts': ['SUBTREE'],
84
+ 'whoami.ts': ['WHOAMI'],
85
+ 'billing.ts': ['CREATE_CHECKOUT'],
86
+ 'key.ts': ['ROTATE_API_KEY'],
87
+ 'init.ts': ['CREATE_PROJECT'],
88
+ 'setup.ts': ['REGISTER'],
89
+ 'import.ts': [
90
+ 'IMPORT_EXISTING',
91
+ 'IMPORT_EDGES',
92
+ 'IMPORT_CREATE',
93
+ 'IMPORT_UPDATE',
94
+ 'IMPORT_EDGE',
95
+ ],
96
+ 'export.ts': ['EXPORT_PROJECT', 'EXPORT_DESCENDANTS', 'EXPORT_EDGES'],
97
+ }
98
+
99
+ for (const [file, ops] of Object.entries(expectations)) {
100
+ it(`${file} imports its operations from operations.ts`, () => {
101
+ const path = fileURLToPath(
102
+ new URL(`../commands/${file}`, import.meta.url),
103
+ )
104
+ const source = readFileSync(path, 'utf-8')
105
+ expect(source).toContain("from '../util/operations.ts'")
106
+ for (const op of ops) expect(source, `${file} -> ${op}`).toContain(op)
107
+ // A command file must not inline GraphQL operation text anymore: the
108
+ // backtick-wrapped query/mutation lives only in operations.ts.
109
+ expect(source, `${file} re-inlines a GraphQL operation`).not.toMatch(
110
+ /`\s*(query|mutation)\s/,
111
+ )
112
+ })
113
+ }
114
+ })