@sqaoss/flowy 1.7.0 → 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.
@@ -3,6 +3,19 @@ import { graphql } from '../util/client.ts'
3
3
  import { requireFeature, resolveProject } from '../util/config.ts'
4
4
  import { resolveDescription } from '../util/description.ts'
5
5
  import { output, outputError } from '../util/format.ts'
6
+ import {
7
+ ALL_TASKS,
8
+ BLOCK_TASK,
9
+ CREATE_TASK,
10
+ DELETE_NODE,
11
+ LINK_TASK,
12
+ LIST_TASKS,
13
+ READY_TASKS,
14
+ SHOW_TASK,
15
+ TASK_DEPS,
16
+ UNBLOCK_TASK,
17
+ UPDATE_NODE,
18
+ } from '../util/operations.ts'
6
19
 
7
20
  export const taskCommand = new Command('task').description(
8
21
  'Manage tasks in the active feature',
@@ -27,23 +40,17 @@ taskCommand
27
40
  description: opts.description,
28
41
  descriptionFile: opts.descriptionFile,
29
42
  })
30
- const data = await graphql<{ createNode: { id: string } }>(
31
- `mutation CreateTask($type: String!, $title: String!, $description: String) {
32
- createNode(type: $type, title: $title, description: $description) {
33
- id type title description status createdAt
34
- }
35
- }`,
36
- { type: 'task', title: opts.title, description },
37
- )
43
+ const data = await graphql<{ createNode: { id: string } }>(CREATE_TASK, {
44
+ type: 'task',
45
+ title: opts.title,
46
+ description,
47
+ })
38
48
  const taskId = data.createNode.id
39
- await graphql(
40
- `mutation LinkTask($sourceId: String!, $targetId: String!, $relation: String!) {
41
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
42
- sourceId targetId relation
43
- }
44
- }`,
45
- { sourceId: taskId, targetId: featureId, relation: 'part_of' },
46
- )
49
+ await graphql(LINK_TASK, {
50
+ sourceId: taskId,
51
+ targetId: featureId,
52
+ relation: 'part_of',
53
+ })
47
54
  output(data.createNode)
48
55
  } catch (error) {
49
56
  outputError(error)
@@ -67,40 +74,27 @@ taskCommand
67
74
  if (opts.ready) {
68
75
  const projectId =
69
76
  opts.project ?? (opts.all ? undefined : resolveProject()?.id)
70
- const data = await graphql<{ readyTasks: unknown[] }>(
71
- `query ReadyTasks($projectId: String) {
72
- readyTasks(projectId: $projectId) {
73
- id type title status createdAt
74
- }
75
- }`,
76
- { projectId: projectId ?? null },
77
- )
77
+ const data = await graphql<{ readyTasks: unknown[] }>(READY_TASKS, {
78
+ projectId: projectId ?? null,
79
+ })
78
80
  output(data.readyTasks)
79
81
  return
80
82
  }
81
83
 
82
84
  if (opts.all) {
83
- const data = await graphql<{ nodes: unknown[] }>(
84
- `query AllTasks($type: String!) {
85
- nodes(type: $type) {
86
- id type title status createdAt
87
- }
88
- }`,
89
- { type: 'task' },
90
- )
85
+ const data = await graphql<{ nodes: unknown[] }>(ALL_TASKS, {
86
+ type: 'task',
87
+ })
91
88
  output(data.nodes)
92
89
  return
93
90
  }
94
91
 
95
92
  const featureId = requireFeature()
96
- const data = await graphql<{ descendants: unknown[] }>(
97
- `query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
98
- descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
99
- id type title status createdAt
100
- }
101
- }`,
102
- { nodeId: featureId, relation: 'part_of', maxDepth: 1 },
103
- )
93
+ const data = await graphql<{ descendants: unknown[] }>(LIST_TASKS, {
94
+ nodeId: featureId,
95
+ relation: 'part_of',
96
+ maxDepth: 1,
97
+ })
104
98
  const tasks = data.descendants.filter(
105
99
  (n: unknown) => (n as { type: string }).type === 'task',
106
100
  )
@@ -120,20 +114,7 @@ taskCommand
120
114
  node: Record<string, unknown>
121
115
  blockedBy: unknown[]
122
116
  blocks: unknown[]
123
- }>(
124
- `query ShowTask($id: String!) {
125
- node(id: $id) {
126
- id type title description status metadata createdAt updatedAt
127
- }
128
- blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
129
- id type title status
130
- }
131
- blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
132
- id type title status
133
- }
134
- }`,
135
- { id },
136
- )
117
+ }>(SHOW_TASK, { id })
137
118
  output({
138
119
  ...data.node,
139
120
  blockedBy: data.blockedBy,
@@ -170,11 +151,7 @@ taskCommand
170
151
  }
171
152
  if (opts.metadata != null) variables.metadata = opts.metadata
172
153
  const data = await graphql<{ updateNode: unknown }>(
173
- `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
174
- updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
175
- id type title description status metadata createdAt updatedAt
176
- }
177
- }`,
154
+ UPDATE_NODE,
178
155
  variables,
179
156
  )
180
157
  output(data.updateNode)
@@ -189,12 +166,7 @@ taskCommand
189
166
  .argument('<id>', 'Task ID')
190
167
  .action(async (id: string) => {
191
168
  try {
192
- const data = await graphql<{ deleteNode: boolean }>(
193
- `mutation DeleteNode($id: String!) {
194
- deleteNode(id: $id)
195
- }`,
196
- { id },
197
- )
169
+ const data = await graphql<{ deleteNode: boolean }>(DELETE_NODE, { id })
198
170
  output({ deleted: data.deleteNode })
199
171
  } catch (error) {
200
172
  outputError(error)
@@ -208,14 +180,11 @@ taskCommand
208
180
  .argument('<id2>', 'Blocked task ID')
209
181
  .action(async (id1: string, id2: string) => {
210
182
  try {
211
- const data = await graphql<{ createEdge: unknown }>(
212
- `mutation BlockTask($sourceId: String!, $targetId: String!, $relation: String!) {
213
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
214
- sourceId targetId relation createdAt
215
- }
216
- }`,
217
- { sourceId: id1, targetId: id2, relation: 'blocks' },
218
- )
183
+ const data = await graphql<{ createEdge: unknown }>(BLOCK_TASK, {
184
+ sourceId: id1,
185
+ targetId: id2,
186
+ relation: 'blocks',
187
+ })
219
188
  output(data.createEdge)
220
189
  } catch (error) {
221
190
  outputError(error)
@@ -229,12 +198,11 @@ taskCommand
229
198
  .argument('<id2>', 'Blocked task ID')
230
199
  .action(async (id1: string, id2: string) => {
231
200
  try {
232
- const data = await graphql<{ removeEdge: boolean }>(
233
- `mutation UnblockTask($sourceId: String!, $targetId: String!, $relation: String!) {
234
- removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
235
- }`,
236
- { sourceId: id1, targetId: id2, relation: 'blocks' },
237
- )
201
+ const data = await graphql<{ removeEdge: boolean }>(UNBLOCK_TASK, {
202
+ sourceId: id1,
203
+ targetId: id2,
204
+ relation: 'blocks',
205
+ })
238
206
  output({ removed: data.removeEdge })
239
207
  } catch (error) {
240
208
  outputError(error)
@@ -248,14 +216,7 @@ taskCommand
248
216
  .action(async (id: string) => {
249
217
  try {
250
218
  const data = await graphql<{ blockedBy: unknown[]; blocks: unknown[] }>(
251
- `query TaskDeps($id: String!) {
252
- blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
253
- id type title status
254
- }
255
- blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
256
- id type title status
257
- }
258
- }`,
219
+ TASK_DEPS,
259
220
  { id },
260
221
  )
261
222
  output({ id, blockedBy: data.blockedBy, blocks: data.blocks })
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
3
  import { output, outputError } from '../util/format.ts'
4
+ import { SUBTREE } from '../util/operations.ts'
4
5
 
5
6
  export const treeCommand = new Command('tree')
6
7
  .description('Show subtree from any entity')
@@ -8,14 +9,10 @@ export const treeCommand = new Command('tree')
8
9
  .option('--depth <n>', 'Max depth', '10')
9
10
  .action(async (id: string, opts) => {
10
11
  try {
11
- const data = await graphql<{ subtree: unknown[] }>(
12
- `query Subtree($nodeId: String!, $maxDepth: Int) {
13
- subtree(nodeId: $nodeId, maxDepth: $maxDepth) {
14
- id type title status
15
- }
16
- }`,
17
- { nodeId: id, maxDepth: Number.parseInt(opts.depth, 10) },
18
- )
12
+ const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
13
+ nodeId: id,
14
+ maxDepth: Number.parseInt(opts.depth, 10),
15
+ })
19
16
  output(data.subtree)
20
17
  } catch (error) {
21
18
  outputError(error)
@@ -9,7 +9,7 @@ beforeEach(() => {
9
9
  mockOutput = vi.fn()
10
10
  mockOutputError = vi.fn()
11
11
  mockLoadConfig = vi.fn(() => ({
12
- mode: 'saas',
12
+ mode: 'remote',
13
13
  apiUrl: 'https://flowy-ai.fly.dev/graphql',
14
14
  apiKey: 'flowy_secret_abcdef0123456789',
15
15
  client: { name: '' },
@@ -42,6 +42,16 @@ beforeEach(() => {
42
42
  return {
43
43
  loadConfig: mockLoadConfig,
44
44
  fingerprintKey: actual.fingerprintKey,
45
+ requireRemoteMode: (commandName: string) => {
46
+ const cfg = (mockLoadConfig as unknown as () => { mode: string })()
47
+ if (cfg.mode === 'local') {
48
+ const err = new Error(
49
+ `"flowy ${commandName}" is only available in remote mode. The active mode is local mode.`,
50
+ ) as Error & { code?: string }
51
+ err.code = 'LOCAL_MODE'
52
+ throw err
53
+ }
54
+ },
45
55
  }
46
56
  })
47
57
  })
@@ -100,4 +110,27 @@ describe('whoami command', () => {
100
110
  const query = mockGraphql.mock.calls[0]?.[0] as string
101
111
  expect(query).toContain('graceEndsAt')
102
112
  })
113
+
114
+ test('whoami errors cleanly in local mode without hitting the server', async () => {
115
+ mockLoadConfig.mockReturnValue({
116
+ mode: 'local',
117
+ apiUrl: 'http://localhost:4000/graphql',
118
+ apiKey: '',
119
+ client: { name: '' },
120
+ projects: {},
121
+ })
122
+
123
+ const { whoamiCommand } = await import('./whoami.ts')
124
+ await whoamiCommand.parseAsync([], { from: 'user' })
125
+
126
+ // No GraphQL call against the local server.
127
+ expect(mockGraphql).not.toHaveBeenCalled()
128
+ expect(mockOutput).not.toHaveBeenCalled()
129
+ expect(mockOutputError).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ message: expect.stringMatching(/local mode/i),
132
+ code: 'LOCAL_MODE',
133
+ }),
134
+ )
135
+ })
103
136
  })
@@ -1,19 +1,19 @@
1
1
  import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
- import { fingerprintKey, loadConfig } from '../util/config.ts'
3
+ import {
4
+ fingerprintKey,
5
+ loadConfig,
6
+ requireRemoteMode,
7
+ } from '../util/config.ts'
4
8
  import { output, outputError } from '../util/format.ts'
9
+ import { WHOAMI } from '../util/operations.ts'
5
10
 
6
11
  export const whoamiCommand = new Command('whoami')
7
12
  .description('Show current user info')
8
13
  .action(async () => {
9
14
  try {
10
- const data = await graphql<{ whoami: Record<string, unknown> }>(
11
- `query Whoami {
12
- whoami {
13
- id email tier createdAt graceEndsAt
14
- }
15
- }`,
16
- )
15
+ requireRemoteMode('whoami')
16
+ const data = await graphql<{ whoami: Record<string, unknown> }>(WHOAMI)
17
17
  // Surface a non-reversible fingerprint of the configured key so a human
18
18
  // can confirm *which* credential is active without exposing it (F35).
19
19
  output({
@@ -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
  })