@sqaoss/flowy 0.1.1 → 1.1.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.
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ test,
12
+ vi,
13
+ } from 'vitest'
14
+
15
+ const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
16
+
17
+ describe('config', () => {
18
+ let originalConfig: string | null = null
19
+
20
+ beforeAll(() => {
21
+ originalConfig = existsSync(CONFIG_PATH)
22
+ ? readFileSync(CONFIG_PATH, 'utf-8')
23
+ : null
24
+ })
25
+
26
+ afterAll(() => {
27
+ if (originalConfig !== null) {
28
+ writeFileSync(CONFIG_PATH, originalConfig)
29
+ } else if (existsSync(CONFIG_PATH)) {
30
+ rmSync(CONFIG_PATH)
31
+ }
32
+ })
33
+
34
+ beforeEach(() => {
35
+ if (existsSync(CONFIG_PATH)) rmSync(CONFIG_PATH)
36
+ })
37
+
38
+ afterEach(() => {
39
+ delete process.env.FLOWY_PROJECT
40
+ delete process.env.FLOWY_FEATURE
41
+ vi.resetModules()
42
+ })
43
+ test('loadConfig returns defaults when no config file exists', async () => {
44
+ const { loadConfig } = await import('./config.ts')
45
+ const config = loadConfig()
46
+ expect(config.mode).toBe('saas')
47
+ expect(config.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
48
+ expect(config.apiKey).toBe('')
49
+ expect(config.client.name).toBe('')
50
+ expect(config.projects).toEqual({})
51
+ })
52
+
53
+ test('getConfig uses env var overrides', async () => {
54
+ process.env.FLOWY_API_URL = 'http://localhost:4000/graphql'
55
+ process.env.FLOWY_API_KEY = 'test-key'
56
+ const { getConfig } = await import('./config.ts')
57
+ const { apiUrl, apiKey } = getConfig()
58
+ expect(apiUrl).toBe('http://localhost:4000/graphql')
59
+ expect(apiKey).toBe('test-key')
60
+ delete process.env.FLOWY_API_URL
61
+ delete process.env.FLOWY_API_KEY
62
+ })
63
+
64
+ test('saveConfig writes and loadConfig reads from disk', async () => {
65
+ const { saveConfig, loadConfig } = await import('./config.ts')
66
+ const config = loadConfig()
67
+ config.client.name = 'Test Client'
68
+ saveConfig(config)
69
+ const reloaded = loadConfig()
70
+ expect(reloaded.client.name).toBe('Test Client')
71
+ })
72
+
73
+ test('resolveProject returns null when no project configured', async () => {
74
+ const { resolveProject } = await import('./config.ts')
75
+ expect(resolveProject()).toBeNull()
76
+ })
77
+
78
+ test('resolveProject matches cwd against project paths', async () => {
79
+ const { saveConfig, loadConfig, resolveProject } = await import(
80
+ './config.ts'
81
+ )
82
+ const config = loadConfig()
83
+ const cwd = process.cwd()
84
+ config.projects[cwd] = { id: 'proj_1', name: 'Test Project' }
85
+ saveConfig(config)
86
+ const project = resolveProject()
87
+ expect(project).not.toBeNull()
88
+ expect(project?.id).toBe('proj_1')
89
+ expect(project?.name).toBe('Test Project')
90
+ })
91
+
92
+ test('resolveProject uses FLOWY_PROJECT env var', async () => {
93
+ const { saveConfig, loadConfig, resolveProject } = await import(
94
+ './config.ts'
95
+ )
96
+ const config = loadConfig()
97
+ config.projects['/other/path'] = { id: 'proj_2', name: 'Env Project' }
98
+ saveConfig(config)
99
+ process.env.FLOWY_PROJECT = 'Env Project'
100
+ const project = resolveProject()
101
+ expect(project?.name).toBe('Env Project')
102
+ })
103
+
104
+ test('requireProject throws when no project configured', async () => {
105
+ const { requireProject } = await import('./config.ts')
106
+ expect(() => requireProject()).toThrow('No active project')
107
+ })
108
+
109
+ test('resolveFeature returns FLOWY_FEATURE env var', async () => {
110
+ process.env.FLOWY_FEATURE = 'feat_123'
111
+ const { resolveFeature } = await import('./config.ts')
112
+ expect(resolveFeature()).toBe('feat_123')
113
+ })
114
+
115
+ test('resolveFeature falls back to activeFeature from project config', async () => {
116
+ const { saveConfig, loadConfig, resolveFeature } = await import(
117
+ './config.ts'
118
+ )
119
+ const config = loadConfig()
120
+ const cwd = process.cwd()
121
+ config.projects[cwd] = {
122
+ id: 'proj_1',
123
+ name: 'Test',
124
+ activeFeature: 'feat_abc',
125
+ }
126
+ saveConfig(config)
127
+ expect(resolveFeature()).toBe('feat_abc')
128
+ })
129
+
130
+ test('requireFeature throws when no feature set', async () => {
131
+ const { requireFeature } = await import('./config.ts')
132
+ expect(() => requireFeature()).toThrow('No active feature')
133
+ })
134
+
135
+ test('updateProjectConfig modifies project entry for cwd', async () => {
136
+ const { saveConfig, loadConfig, updateProjectConfig } = await import(
137
+ './config.ts'
138
+ )
139
+ const config = loadConfig()
140
+ const cwd = process.cwd()
141
+ config.projects[cwd] = { id: 'proj_1', name: 'Test' }
142
+ saveConfig(config)
143
+ updateProjectConfig((p) => {
144
+ p.activeFeature = 'feat_999'
145
+ })
146
+ const updated = loadConfig()
147
+ expect(
148
+ (updated.projects[cwd] as { activeFeature?: string }).activeFeature,
149
+ ).toBe('feat_999')
150
+ })
151
+ })
@@ -1,5 +1,110 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { resolve } from 'node:path'
4
+
5
+ const CONFIG_DIR = resolve(homedir(), '.config', 'flowy')
6
+ const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json')
7
+
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
+ }
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
+ }
25
+
26
+ export interface ProjectConfig {
27
+ id: string
28
+ name: string
29
+ activeFeature?: string
30
+ }
31
+
32
+ export function resolveProject(): ProjectConfig | null {
33
+ const envProject = process.env.FLOWY_PROJECT
34
+ const config = loadConfig()
35
+
36
+ if (envProject) {
37
+ return (
38
+ (Object.values(config.projects).find(
39
+ (p) => (p as ProjectConfig).name === envProject,
40
+ ) as ProjectConfig) ?? null
41
+ )
42
+ }
43
+
44
+ const cwd = process.cwd()
45
+ let bestMatch: ProjectConfig | null = null
46
+ let bestLength = 0
47
+
48
+ for (const [path, project] of Object.entries(config.projects)) {
49
+ if (
50
+ (cwd === path || cwd.startsWith(`${path}/`)) &&
51
+ path.length > bestLength
52
+ ) {
53
+ bestMatch = project as ProjectConfig
54
+ bestLength = path.length
55
+ }
56
+ }
57
+
58
+ return bestMatch
59
+ }
60
+
61
+ export function resolveFeature(): string | null {
62
+ const envFeature = process.env.FLOWY_FEATURE
63
+ if (envFeature) return envFeature
64
+ const project = resolveProject()
65
+ return project?.activeFeature ?? null
66
+ }
67
+
68
+ export function requireFeature(): string {
69
+ const feature = resolveFeature()
70
+ if (!feature) {
71
+ throw new Error(
72
+ 'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
73
+ )
74
+ }
75
+ return feature
76
+ }
77
+
78
+ export function requireProject(): ProjectConfig {
79
+ const project = resolveProject()
80
+ if (!project) {
81
+ throw new Error(
82
+ 'No active project. Run "flowy project set <name>" or set FLOWY_PROJECT.',
83
+ )
84
+ }
85
+ return project
86
+ }
87
+
88
+ export function updateProjectConfig(
89
+ updater: (project: ProjectConfig) => void,
90
+ ): void {
91
+ const config = loadConfig()
92
+ const cwd = process.cwd()
93
+
94
+ for (const [path, project] of Object.entries(config.projects)) {
95
+ if (cwd === path || cwd.startsWith(`${path}/`)) {
96
+ updater(project as ProjectConfig)
97
+ saveConfig(config)
98
+ return
99
+ }
100
+ }
101
+
102
+ throw new Error('No directory mapping. Run "flowy project set <name>" first.')
103
+ }
104
+
1
105
  export function getConfig() {
2
- const apiUrl = process.env.FLOWY_API_URL ?? 'https://flowy-ai.fly.dev/graphql'
3
- const apiKey = process.env.FLOWY_API_KEY ?? ''
106
+ const config = loadConfig()
107
+ const apiUrl = process.env.FLOWY_API_URL ?? config.apiUrl
108
+ const apiKey = process.env.FLOWY_API_KEY ?? config.apiKey
4
109
  return { apiUrl, apiKey }
5
110
  }
@@ -0,0 +1,29 @@
1
+ import { rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { afterEach, describe, expect, test } from 'vitest'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+
8
+ describe('resolveDescription', () => {
9
+ const TEST_FILE = resolve(__dirname, '../../.test-desc.md')
10
+
11
+ afterEach(() => {
12
+ try {
13
+ rmSync(TEST_FILE)
14
+ } catch {}
15
+ })
16
+
17
+ test('returns file content when path exists', async () => {
18
+ writeFileSync(TEST_FILE, '# Test\nSome content')
19
+ const { resolveDescription } = await import('./description.ts')
20
+ const result = await resolveDescription(TEST_FILE)
21
+ expect(result).toBe('# Test\nSome content')
22
+ })
23
+
24
+ test('returns value as-is when path does not exist', async () => {
25
+ const { resolveDescription } = await import('./description.ts')
26
+ const result = await resolveDescription('Just a plain description')
27
+ expect(result).toBe('Just a plain description')
28
+ })
29
+ })
@@ -0,0 +1,8 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+
3
+ export async function resolveDescription(value: string): Promise<string> {
4
+ if (existsSync(value)) {
5
+ return readFileSync(value, 'utf-8')
6
+ }
7
+ return value
8
+ }
@@ -1,84 +0,0 @@
1
- import { Command } from 'commander'
2
- import { graphql } from '../util/client.ts'
3
- import { output, outputError } from '../util/format.ts'
4
-
5
- export const edgeCommand = new Command('edge').description(
6
- 'Manage edges between nodes',
7
- )
8
-
9
- edgeCommand
10
- .command('create')
11
- .description('Create an edge between two nodes')
12
- .requiredOption('--source <id>', 'Source node ID')
13
- .requiredOption('--target <id>', 'Target node ID')
14
- .requiredOption(
15
- '--relation <rel>',
16
- 'Relation type (part_of, depends_on, blocks, informs)',
17
- )
18
- .action(async (opts) => {
19
- try {
20
- const data = await graphql<{ createEdge: unknown }>(
21
- `mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
22
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
23
- sourceId targetId relation createdAt
24
- }
25
- }`,
26
- {
27
- sourceId: opts.source,
28
- targetId: opts.target,
29
- relation: opts.relation,
30
- },
31
- )
32
- output(data.createEdge)
33
- } catch (error) {
34
- outputError(error)
35
- }
36
- })
37
-
38
- edgeCommand
39
- .command('remove')
40
- .description('Remove an edge')
41
- .requiredOption('--source <id>', 'Source node ID')
42
- .requiredOption('--target <id>', 'Target node ID')
43
- .requiredOption('--relation <rel>', 'Relation type')
44
- .action(async (opts) => {
45
- try {
46
- const data = await graphql<{ removeEdge: boolean }>(
47
- `mutation RemoveEdge($sourceId: String!, $targetId: String!, $relation: String!) {
48
- removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
49
- }`,
50
- {
51
- sourceId: opts.source,
52
- targetId: opts.target,
53
- relation: opts.relation,
54
- },
55
- )
56
- output({ removed: data.removeEdge })
57
- } catch (error) {
58
- outputError(error)
59
- }
60
- })
61
-
62
- edgeCommand
63
- .command('list')
64
- .description('List edges')
65
- .option('--node <id>', 'Filter by node ID')
66
- .option('--relation <rel>', 'Filter by relation type')
67
- .action(async (opts) => {
68
- try {
69
- const data = await graphql<{ edges: unknown[] }>(
70
- `query ListEdges($nodeId: String, $relation: String) {
71
- edges(nodeId: $nodeId, relation: $relation) {
72
- sourceId targetId relation createdAt
73
- }
74
- }`,
75
- {
76
- nodeId: opts.node,
77
- relation: opts.relation,
78
- },
79
- )
80
- output(data.edges)
81
- } catch (error) {
82
- outputError(error)
83
- }
84
- })
@@ -1,134 +0,0 @@
1
- import { Command } from 'commander'
2
- import { graphql } from '../util/client.ts'
3
- import { output, outputError } from '../util/format.ts'
4
-
5
- export const nodeCommand = new Command('node').description('Manage work nodes')
6
-
7
- nodeCommand
8
- .command('create')
9
- .description('Create a new node')
10
- .requiredOption(
11
- '--type <type>',
12
- 'Node type (client, project, feature, epic, task)',
13
- )
14
- .requiredOption('--title <title>', 'Node title')
15
- .option('--description <desc>', 'Node description')
16
- .option('--status <status>', 'Initial status')
17
- .option('--metadata <json>', 'Metadata as JSON string')
18
- .action(async (opts) => {
19
- try {
20
- const data = await graphql<{ createNode: unknown }>(
21
- `mutation CreateNode($type: String!, $title: String!, $description: String, $status: String, $metadata: String) {
22
- createNode(type: $type, title: $title, description: $description, status: $status, metadata: $metadata) {
23
- id type title description status metadata createdAt updatedAt
24
- }
25
- }`,
26
- {
27
- type: opts.type,
28
- title: opts.title,
29
- description: opts.description,
30
- status: opts.status,
31
- metadata: opts.metadata,
32
- },
33
- )
34
- output(data.createNode)
35
- } catch (error) {
36
- outputError(error)
37
- }
38
- })
39
-
40
- nodeCommand
41
- .command('get')
42
- .description('Get a node by ID')
43
- .requiredOption('--id <id>', 'Node ID')
44
- .action(async (opts) => {
45
- try {
46
- const data = await graphql<{ node: unknown }>(
47
- `query GetNode($id: String!) {
48
- node(id: $id) {
49
- id type title description status metadata createdAt updatedAt
50
- }
51
- }`,
52
- { id: opts.id },
53
- )
54
- output(data.node)
55
- } catch (error) {
56
- outputError(error)
57
- }
58
- })
59
-
60
- nodeCommand
61
- .command('list')
62
- .description('List nodes')
63
- .option('--type <type>', 'Filter by type')
64
- .option('--status <status>', 'Filter by status')
65
- .option('--limit <n>', 'Limit results', '50')
66
- .option('--offset <n>', 'Offset results', '0')
67
- .action(async (opts) => {
68
- try {
69
- const data = await graphql<{ nodes: unknown[] }>(
70
- `query ListNodes($type: String, $status: String, $limit: Int, $offset: Int) {
71
- nodes(type: $type, status: $status, limit: $limit, offset: $offset) {
72
- id type title description status metadata createdAt updatedAt
73
- }
74
- }`,
75
- {
76
- type: opts.type,
77
- status: opts.status,
78
- limit: Number.parseInt(opts.limit, 10),
79
- offset: Number.parseInt(opts.offset, 10),
80
- },
81
- )
82
- output(data.nodes)
83
- } catch (error) {
84
- outputError(error)
85
- }
86
- })
87
-
88
- nodeCommand
89
- .command('update')
90
- .description('Update a node')
91
- .requiredOption('--id <id>', 'Node ID')
92
- .option('--title <title>', 'New title')
93
- .option('--description <desc>', 'New description')
94
- .option('--status <status>', 'New status')
95
- .option('--metadata <json>', 'New metadata as JSON string')
96
- .action(async (opts) => {
97
- try {
98
- const data = await graphql<{ updateNode: unknown }>(
99
- `mutation UpdateNode($id: String!, $title: String, $description: String, $status: String, $metadata: String) {
100
- updateNode(id: $id, title: $title, description: $description, status: $status, metadata: $metadata) {
101
- id type title description status metadata createdAt updatedAt
102
- }
103
- }`,
104
- {
105
- id: opts.id,
106
- title: opts.title,
107
- description: opts.description,
108
- status: opts.status,
109
- metadata: opts.metadata,
110
- },
111
- )
112
- output(data.updateNode)
113
- } catch (error) {
114
- outputError(error)
115
- }
116
- })
117
-
118
- nodeCommand
119
- .command('delete')
120
- .description('Delete a node')
121
- .requiredOption('--id <id>', 'Node ID')
122
- .action(async (opts) => {
123
- try {
124
- const data = await graphql<{ deleteNode: boolean }>(
125
- `mutation DeleteNode($id: String!) {
126
- deleteNode(id: $id)
127
- }`,
128
- { id: opts.id },
129
- )
130
- output({ deleted: data.deleteNode })
131
- } catch (error) {
132
- outputError(error)
133
- }
134
- })
@@ -1,25 +0,0 @@
1
- import { Command } from 'commander'
2
- import { graphql } from '../util/client.ts'
3
- import { output, outputError } from '../util/format.ts'
4
-
5
- export const registerCommand = new Command('register')
6
- .description('Register a new account')
7
- .requiredOption('--email <email>', 'Your email address')
8
- .action(async (opts) => {
9
- try {
10
- const data = await graphql<{
11
- register: { user: { id: string; email: string }; apiKey: string }
12
- }>(
13
- `mutation Register($email: String!) {
14
- register(email: $email) {
15
- user { id email tier createdAt }
16
- apiKey
17
- }
18
- }`,
19
- { email: opts.email },
20
- )
21
- output(data.register)
22
- } catch (error) {
23
- outputError(error)
24
- }
25
- })