@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.
@@ -8,7 +8,7 @@ let mockSpawnSync: ReturnType<typeof vi.fn>
8
8
 
9
9
  beforeEach(() => {
10
10
  mockLoadConfig = vi.fn(() => ({
11
- mode: 'saas',
11
+ mode: 'remote',
12
12
  apiUrl: 'https://flowy-ai.fly.dev/graphql',
13
13
  apiKey: '',
14
14
  client: { name: '' },
@@ -315,6 +315,53 @@ describe('setup command', () => {
315
315
  errSpy.mockRestore()
316
316
  })
317
317
 
318
+ test('setup remote persists the API key before any later step (save-after-register)', async () => {
319
+ const mockGraphql = vi.fn().mockResolvedValue({
320
+ register: {
321
+ user: {
322
+ id: 'user_1',
323
+ email: 'a@b.com',
324
+ tier: null,
325
+ createdAt: '2026-06-13T00:00:00Z',
326
+ graceEndsAt: null,
327
+ },
328
+ apiKey: 'flowy_key_persisted',
329
+ checkoutUrl: null,
330
+ },
331
+ })
332
+ vi.doMock('../util/client.ts', () => ({ graphql: mockGraphql }))
333
+
334
+ // The skill install (a step AFTER the key is obtained) blows up hard.
335
+ // The key must already be on disk by then so the user is never stranded.
336
+ let keySavedBeforeSkillInstall = false
337
+ mockSaveConfig.mockImplementation((cfg: { apiKey?: string }) => {
338
+ if (cfg.apiKey === 'flowy_key_persisted')
339
+ keySavedBeforeSkillInstall = true
340
+ })
341
+ mockSpawnSync.mockImplementation((cmd: string) => {
342
+ if (cmd === 'npx') {
343
+ // By the time the (post-register) skill install runs, the key is saved.
344
+ expect(keySavedBeforeSkillInstall).toBe(true)
345
+ throw new Error('npx exploded')
346
+ }
347
+ return { status: 0, stdout: Buffer.from('') }
348
+ })
349
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
350
+
351
+ const { setupCommand } = await import('./setup.ts')
352
+ await setupCommand.parseAsync(['remote', '--email', 'a@b.com'], {
353
+ from: 'user',
354
+ })
355
+
356
+ // The key was saved at least once with the real value before the failure.
357
+ expect(
358
+ mockSaveConfig.mock.calls.some(
359
+ (c) => (c[0] as { apiKey?: string }).apiKey === 'flowy_key_persisted',
360
+ ),
361
+ ).toBe(true)
362
+ errSpy.mockRestore()
363
+ })
364
+
318
365
  test('setup remote warns when the skill install fails', async () => {
319
366
  const mockGraphql = vi.fn().mockResolvedValue({
320
367
  register: {
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
2
2
  import { Command, Option } from 'commander'
3
3
  import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
4
4
  import { output, outputError } from '../util/format.ts'
5
+ import { REGISTER } from '../util/operations.ts'
5
6
  import { pinnedInstallSpec } from './serve.ts'
6
7
 
7
8
  export const setupCommand = new Command('setup').description(
@@ -104,16 +105,7 @@ setupCommand
104
105
  apiKey: string
105
106
  checkoutUrl: string
106
107
  }
107
- }>(
108
- `mutation Register($email: String!, $tier: String) {
109
- register(email: $email, tier: $tier) {
110
- user { id email tier createdAt graceEndsAt }
111
- apiKey
112
- checkoutUrl
113
- }
114
- }`,
115
- { email: opts.email, tier: opts.tier },
116
- )
108
+ }>(REGISTER, { email: opts.email, tier: opts.tier })
117
109
 
118
110
  config.apiKey = data.register.apiKey
119
111
  saveConfig(config)
@@ -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 { UPDATE_STATUS } from '../util/operations.ts'
4
5
 
5
6
  export const statusCommand = new Command('status')
6
7
  .description('Update a node status (shorthand)')
@@ -11,14 +12,10 @@ export const statusCommand = new Command('status')
11
12
  )
12
13
  .action(async (id: string, status: string) => {
13
14
  try {
14
- const data = await graphql<{ updateNode: unknown }>(
15
- `mutation UpdateStatus($id: String!, $status: String) {
16
- updateNode(id: $id, status: $status) {
17
- id type title status updatedAt
18
- }
19
- }`,
20
- { id, status },
21
- )
15
+ const data = await graphql<{ updateNode: unknown }>(UPDATE_STATUS, {
16
+ id,
17
+ status,
18
+ })
22
19
  output(data.updateNode)
23
20
  } catch (error) {
24
21
  outputError(error)
@@ -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,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
2
 
3
3
  describe('tree command', () => {
4
4
  test('exports a flat command with id argument and depth option', async () => {
@@ -6,4 +6,92 @@ describe('tree command', () => {
6
6
  expect(treeCommand.name()).toBe('tree')
7
7
  expect(treeCommand.commands).toHaveLength(0)
8
8
  })
9
+
10
+ test('exposes a --relation option defaulting to part_of', async () => {
11
+ const { treeCommand } = await import('./tree.ts')
12
+ const relationOpt = treeCommand.options.find((o) => o.long === '--relation')
13
+ expect(relationOpt).toBeDefined()
14
+ expect(relationOpt?.defaultValue).toBe('part_of')
15
+ })
16
+
17
+ describe('action', () => {
18
+ beforeEach(() => {
19
+ vi.resetModules()
20
+ })
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks()
24
+ vi.unstubAllEnvs()
25
+ })
26
+
27
+ test('sends the SUBTREE op with the relation filter and renders depth/relation', async () => {
28
+ const calls: Array<{ query: string; variables: unknown }> = []
29
+ vi.doMock('../util/client.ts', () => ({
30
+ graphql: vi.fn(async (query: string, variables: unknown) => {
31
+ calls.push({ query, variables })
32
+ return {
33
+ subtree: [
34
+ {
35
+ id: 'feat_1',
36
+ type: 'feature',
37
+ title: 'F1',
38
+ status: 'draft',
39
+ parentId: 'proj_1',
40
+ depth: 1,
41
+ relation: 'part_of',
42
+ },
43
+ ],
44
+ }
45
+ }),
46
+ }))
47
+ const outputs: unknown[] = []
48
+ vi.doMock('../util/format.ts', () => ({
49
+ output: vi.fn((data: unknown) => outputs.push(data)),
50
+ outputError: vi.fn(),
51
+ }))
52
+
53
+ const { SUBTREE } = await import('../util/operations.ts')
54
+ const { treeCommand } = await import('./tree.ts')
55
+ await treeCommand.parseAsync(['proj_1'], { from: 'user' })
56
+
57
+ expect(calls).toHaveLength(1)
58
+ expect(calls[0]!.query).toBe(SUBTREE)
59
+ expect(calls[0]!.variables).toMatchObject({
60
+ nodeId: 'proj_1',
61
+ relation: 'part_of',
62
+ })
63
+
64
+ const rendered = outputs[0] as Array<Record<string, unknown>>
65
+ expect(rendered[0]).toMatchObject({
66
+ id: 'feat_1',
67
+ parentId: 'proj_1',
68
+ depth: 1,
69
+ relation: 'part_of',
70
+ })
71
+ })
72
+
73
+ test('passes an overridden relation through to the op', async () => {
74
+ const calls: Array<{ variables: unknown }> = []
75
+ vi.doMock('../util/client.ts', () => ({
76
+ graphql: vi.fn(async (_query: string, variables: unknown) => {
77
+ calls.push({ variables })
78
+ return { subtree: [] }
79
+ }),
80
+ }))
81
+ vi.doMock('../util/format.ts', () => ({
82
+ output: vi.fn(),
83
+ outputError: vi.fn(),
84
+ }))
85
+
86
+ const { treeCommand } = await import('./tree.ts')
87
+ await treeCommand.parseAsync(['task_1', '--relation', 'blocks'], {
88
+ from: 'user',
89
+ })
90
+
91
+ expect(calls[0]!.variables).toMatchObject({
92
+ nodeId: 'task_1',
93
+ relation: 'blocks',
94
+ })
95
+ })
96
+ })
9
97
  })
@@ -1,21 +1,24 @@
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')
7
8
  .argument('<id>', 'Root node ID')
8
9
  .option('--depth <n>', 'Max depth', '10')
10
+ .option(
11
+ '--relation <relation>',
12
+ 'Edge relation to follow (e.g. part_of, blocks)',
13
+ 'part_of',
14
+ )
9
15
  .action(async (id: string, opts) => {
10
16
  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
- )
17
+ const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
18
+ nodeId: id,
19
+ relation: opts.relation,
20
+ maxDepth: Number.parseInt(opts.depth, 10),
21
+ })
19
22
  output(data.subtree)
20
23
  } catch (error) {
21
24
  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({