@sqaoss/flowy 1.6.1 → 1.8.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.
@@ -1,5 +1,12 @@
1
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
- import { homedir } from 'node:os'
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ readFileSync,
5
+ rmSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { homedir, platform } from 'node:os'
3
10
  import { resolve } from 'node:path'
4
11
  import {
5
12
  afterAll,
@@ -12,6 +19,8 @@ import {
12
19
  vi,
13
20
  } from 'vitest'
14
21
 
22
+ const isWindows = platform() === 'win32'
23
+
15
24
  const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
16
25
 
17
26
  describe('config', () => {
@@ -43,7 +52,9 @@ describe('config', () => {
43
52
  test('loadConfig returns defaults when no config file exists', async () => {
44
53
  const { loadConfig } = await import('./config.ts')
45
54
  const config = loadConfig()
46
- 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')
47
58
  expect(config.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
48
59
  expect(config.apiKey).toBe('')
49
60
  expect(config.client.name).toBe('')
@@ -70,6 +81,45 @@ describe('config', () => {
70
81
  expect(reloaded.client.name).toBe('Test Client')
71
82
  })
72
83
 
84
+ test.skipIf(isWindows)(
85
+ 'saveConfig writes config with 0600 mode',
86
+ async () => {
87
+ const { saveConfig, loadConfig } = await import('./config.ts')
88
+ saveConfig(loadConfig())
89
+ const mode = statSync(CONFIG_PATH).mode & 0o777
90
+ expect(mode).toBe(0o600)
91
+ },
92
+ )
93
+
94
+ test.skipIf(isWindows)(
95
+ 'saveConfig corrects a pre-existing 0644 config to 0600',
96
+ async () => {
97
+ const { saveConfig, loadConfig } = await import('./config.ts')
98
+ // Simulate a config written by an older CLI (world-readable).
99
+ writeFileSync(CONFIG_PATH, JSON.stringify(loadConfig(), null, 2))
100
+ chmodSync(CONFIG_PATH, 0o644)
101
+ expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o644)
102
+
103
+ saveConfig(loadConfig())
104
+ expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o600)
105
+ },
106
+ )
107
+
108
+ test('fingerprintKey is deterministic and non-reversible', async () => {
109
+ const { fingerprintKey } = await import('./config.ts')
110
+ const key = 'flowy_secret_abcdef0123456789'
111
+ const fp = fingerprintKey(key)
112
+ expect(fingerprintKey(key)).toBe(fp)
113
+ expect(fp).not.toContain(key)
114
+ expect(fp).not.toContain('abcdef0123456789')
115
+ expect(fp).toMatch(/sha256:[0-9a-f]{12}/)
116
+ })
117
+
118
+ test('fingerprintKey returns a placeholder for an empty key', async () => {
119
+ const { fingerprintKey } = await import('./config.ts')
120
+ expect(fingerprintKey('')).toBe('(none)')
121
+ })
122
+
73
123
  test('resolveProject returns null when no project configured', async () => {
74
124
  const { resolveProject } = await import('./config.ts')
75
125
  expect(resolveProject()).toBeNull()
@@ -148,4 +198,169 @@ describe('config', () => {
148
198
  (updated.projects[cwd] as { activeFeature?: string }).activeFeature,
149
199
  ).toBe('feat_999')
150
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
+ })
151
366
  })
@@ -1,27 +1,28 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { homedir } from 'node:os'
1
+ import { createHash } from 'node:crypto'
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { homedir, platform } from 'node:os'
3
10
  import { resolve } from 'node:path'
4
11
 
5
12
  const CONFIG_DIR = resolve(homedir(), '.config', 'flowy')
6
13
  const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json')
7
14
 
8
- export function loadConfig() {
9
- if (!existsSync(CONFIG_PATH)) {
10
- return {
11
- mode: 'saas' as const,
12
- apiUrl: 'https://flowy-ai.fly.dev/graphql',
13
- apiKey: '',
14
- client: { name: '' },
15
- projects: {} as Record<string, unknown>,
16
- }
17
- }
18
- return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
19
- }
15
+ // Owner-only modes for the config dir/file, which hold the FLOWY_API_KEY.
16
+ // POSIX-only; chmod is a no-op on Windows so we skip it to avoid surprises.
17
+ const DIR_MODE = 0o700
18
+ const FILE_MODE = 0o600
19
+ const isWindows = platform() === 'win32'
20
20
 
21
- export function saveConfig(config: ReturnType<typeof loadConfig>): void {
22
- mkdirSync(CONFIG_DIR, { recursive: true })
23
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
24
- }
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'
25
26
 
26
27
  export interface ProjectConfig {
27
28
  id: string
@@ -29,6 +30,208 @@ export interface ProjectConfig {
29
30
  activeFeature?: string
30
31
  }
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
+
47
+ /**
48
+ * Non-reversible fingerprint of an API key, safe to print to stdout/logs.
49
+ * A short SHA-256 prefix lets a human confirm *which* key is configured
50
+ * without exposing the secret itself (F35).
51
+ */
52
+ export function fingerprintKey(apiKey: string): string {
53
+ if (!apiKey) return '(none)'
54
+ const digest = createHash('sha256').update(apiKey).digest('hex')
55
+ return `sha256:${digest.slice(0, 12)}`
56
+ }
57
+
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
+ }
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>
138
+ }
139
+ return stored
140
+ }
141
+
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
+ }
204
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE })
205
+ // `mode` on writeFileSync only applies to *newly created* files and is
206
+ // masked by umask, so an explicit chmod afterward both corrects a
207
+ // pre-existing world-readable (0644) config and survives a tight umask.
208
+ writeFileSync(CONFIG_PATH, JSON.stringify(stored, null, 2), {
209
+ mode: FILE_MODE,
210
+ })
211
+ if (!isWindows) {
212
+ chmodSync(CONFIG_DIR, DIR_MODE)
213
+ chmodSync(CONFIG_PATH, FILE_MODE)
214
+ }
215
+ }
216
+
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
+ }
233
+ }
234
+
32
235
  export function resolveProject(): ProjectConfig | null {
33
236
  const envProject = process.env.FLOWY_PROJECT
34
237
  const config = loadConfig()
@@ -36,7 +239,7 @@ export function resolveProject(): ProjectConfig | null {
36
239
  if (envProject) {
37
240
  return (
38
241
  (Object.values(config.projects).find(
39
- (p) => (p as ProjectConfig).name === envProject,
242
+ (p) => p.name === envProject,
40
243
  ) as ProjectConfig) ?? null
41
244
  )
42
245
  }
@@ -50,7 +253,7 @@ export function resolveProject(): ProjectConfig | null {
50
253
  (cwd === path || cwd.startsWith(`${path}/`)) &&
51
254
  path.length > bestLength
52
255
  ) {
53
- bestMatch = project as ProjectConfig
256
+ bestMatch = project
54
257
  bestLength = path.length
55
258
  }
56
259
  }
@@ -93,7 +296,7 @@ export function updateProjectConfig(
93
296
 
94
297
  for (const [path, project] of Object.entries(config.projects)) {
95
298
  if (cwd === path || cwd.startsWith(`${path}/`)) {
96
- updater(project as ProjectConfig)
299
+ updater(project)
97
300
  saveConfig(config)
98
301
  return
99
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
+ })