@sqaoss/flowy 1.4.0 → 1.6.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,6 +1,6 @@
1
1
  import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
- import { requireFeature } from '../util/config.ts'
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
6
 
@@ -53,8 +53,45 @@ taskCommand
53
53
  taskCommand
54
54
  .command('list')
55
55
  .description('List tasks in the active feature')
56
- .action(async () => {
56
+ .option(
57
+ '--ready',
58
+ 'Only actionable tasks: not done/cancelled and with zero unfinished blockers',
59
+ )
60
+ .option('--all', 'List every task across the whole backlog')
61
+ .option(
62
+ '--project <id>',
63
+ 'Scope --ready/--all to a project (defaults to the active project)',
64
+ )
65
+ .action(async (opts) => {
57
66
  try {
67
+ if (opts.ready) {
68
+ const projectId =
69
+ 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
+ )
78
+ output(data.readyTasks)
79
+ return
80
+ }
81
+
82
+ 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
+ )
91
+ output(data.nodes)
92
+ return
93
+ }
94
+
58
95
  const featureId = requireFeature()
59
96
  const data = await graphql<{ descendants: unknown[] }>(
60
97
  `query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
@@ -75,19 +112,33 @@ taskCommand
75
112
 
76
113
  taskCommand
77
114
  .command('show')
78
- .description('Show task details')
115
+ .description('Show task details, including its blockedBy/blocks dependencies')
79
116
  .argument('<id>', 'Task ID')
80
117
  .action(async (id: string) => {
81
118
  try {
82
- const data = await graphql<{ node: unknown }>(
119
+ const data = await graphql<{
120
+ node: Record<string, unknown>
121
+ blockedBy: unknown[]
122
+ blocks: unknown[]
123
+ }>(
83
124
  `query ShowTask($id: String!) {
84
125
  node(id: $id) {
85
126
  id type title description status metadata createdAt updatedAt
86
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
+ }
87
134
  }`,
88
135
  { id },
89
136
  )
90
- output(data.node)
137
+ output({
138
+ ...data.node,
139
+ blockedBy: data.blockedBy,
140
+ blocks: data.blocks,
141
+ })
91
142
  } catch (error) {
92
143
  outputError(error)
93
144
  }
@@ -189,3 +240,26 @@ taskCommand
189
240
  outputError(error)
190
241
  }
191
242
  })
243
+
244
+ taskCommand
245
+ .command('deps')
246
+ .description('List a task’s dependencies: what blocks it and what it blocks')
247
+ .argument('<id>', 'Task ID')
248
+ .action(async (id: string) => {
249
+ try {
250
+ 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
+ }`,
259
+ { id },
260
+ )
261
+ output({ id, blockedBy: data.blockedBy, blocks: data.blocks })
262
+ } catch (error) {
263
+ outputError(error)
264
+ }
265
+ })
package/src/index.test.ts CHANGED
@@ -42,6 +42,12 @@ vi.mock('./commands/key.ts', () => ({
42
42
  vi.mock('./commands/serve.ts', () => ({
43
43
  serveCommand: { name: () => 'serve' },
44
44
  }))
45
+ vi.mock('./commands/import.ts', () => ({
46
+ importCommand: { name: () => 'import' },
47
+ }))
48
+ vi.mock('./commands/export.ts', () => ({
49
+ exportCommand: { name: () => 'export' },
50
+ }))
45
51
 
46
52
  describe('index.ts command registration', () => {
47
53
  test('registers billing and key commands', async () => {
@@ -73,4 +79,21 @@ describe('index.ts command registration', () => {
73
79
  )
74
80
  expect(indexSource).toContain('program.addCommand(serveCommand)')
75
81
  })
82
+
83
+ test('registers the import and export commands', async () => {
84
+ const { readFileSync } = await import('node:fs')
85
+ const indexSource = readFileSync(
86
+ new URL('./index.ts', import.meta.url).pathname,
87
+ 'utf-8',
88
+ )
89
+
90
+ expect(indexSource).toContain(
91
+ "import { importCommand } from './commands/import.ts'",
92
+ )
93
+ expect(indexSource).toContain(
94
+ "import { exportCommand } from './commands/export.ts'",
95
+ )
96
+ expect(indexSource).toContain('program.addCommand(importCommand)')
97
+ expect(indexSource).toContain('program.addCommand(exportCommand)')
98
+ })
76
99
  })
package/src/index.ts CHANGED
@@ -14,7 +14,9 @@ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
14
14
  import { approveCommand } from './commands/approve.ts'
15
15
  import { billingCommand } from './commands/billing.ts'
16
16
  import { clientCommand } from './commands/client.ts'
17
+ import { exportCommand } from './commands/export.ts'
17
18
  import { featureCommand } from './commands/feature.ts'
19
+ import { importCommand } from './commands/import.ts'
18
20
  import { initCommand } from './commands/init.ts'
19
21
  import { keyCommand } from './commands/key.ts'
20
22
  import { projectCommand } from './commands/project.ts'
@@ -45,5 +47,7 @@ program.addCommand(keyCommand)
45
47
  program.addCommand(searchCommand)
46
48
  program.addCommand(treeCommand)
47
49
  program.addCommand(whoamiCommand)
50
+ program.addCommand(importCommand)
51
+ program.addCommand(exportCommand)
48
52
 
49
53
  program.parse()
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import {
3
+ buildNodeMetadata,
4
+ FLOWY_KEY_FIELD,
5
+ type Manifest,
6
+ parseManifest,
7
+ readClientKey,
8
+ serializeManifest,
9
+ stripClientKey,
10
+ } from './manifest.ts'
11
+
12
+ const VALID: Manifest = {
13
+ version: 1,
14
+ nodes: [
15
+ { key: 'proj', type: 'project', title: 'Demo' },
16
+ { key: 'feat-1', type: 'feature', title: 'Auth', parent: 'proj' },
17
+ {
18
+ key: 'task-1',
19
+ type: 'task',
20
+ title: 'Login',
21
+ parent: 'feat-1',
22
+ status: 'draft',
23
+ metadata: { priority: 'high' },
24
+ },
25
+ { key: 'task-2', type: 'task', title: 'Logout', parent: 'feat-1' },
26
+ ],
27
+ edges: [{ source: 'task-2', target: 'task-1', relation: 'blocks' }],
28
+ }
29
+
30
+ describe('parseManifest', () => {
31
+ test('parses a well-formed JSON manifest', () => {
32
+ const m = parseManifest(JSON.stringify(VALID))
33
+ expect(m.version).toBe(1)
34
+ expect(m.nodes).toHaveLength(4)
35
+ expect(m.edges).toHaveLength(1)
36
+ expect(m.nodes[0]).toMatchObject({ key: 'proj', type: 'project' })
37
+ })
38
+
39
+ test('defaults edges to an empty array when omitted', () => {
40
+ const m = parseManifest(
41
+ JSON.stringify({
42
+ version: 1,
43
+ nodes: [{ key: 'proj', type: 'project', title: 'Demo' }],
44
+ }),
45
+ )
46
+ expect(m.edges).toEqual([])
47
+ })
48
+
49
+ test('throws on invalid JSON', () => {
50
+ expect(() => parseManifest('{not json')).toThrow(/invalid json/i)
51
+ })
52
+
53
+ test('throws when nodes is missing', () => {
54
+ expect(() => parseManifest(JSON.stringify({ version: 1 }))).toThrow(
55
+ /nodes/i,
56
+ )
57
+ })
58
+
59
+ test('throws when a node lacks a key', () => {
60
+ expect(() =>
61
+ parseManifest(
62
+ JSON.stringify({
63
+ version: 1,
64
+ nodes: [{ type: 'project', title: 'Demo' }],
65
+ }),
66
+ ),
67
+ ).toThrow(/key/i)
68
+ })
69
+
70
+ test('throws when a node lacks a type', () => {
71
+ expect(() =>
72
+ parseManifest(
73
+ JSON.stringify({
74
+ version: 1,
75
+ nodes: [{ key: 'x', title: 'Demo' }],
76
+ }),
77
+ ),
78
+ ).toThrow(/type/i)
79
+ })
80
+
81
+ test('throws on a duplicate client-key', () => {
82
+ expect(() =>
83
+ parseManifest(
84
+ JSON.stringify({
85
+ version: 1,
86
+ nodes: [
87
+ { key: 'dup', type: 'project', title: 'A' },
88
+ { key: 'dup', type: 'feature', title: 'B' },
89
+ ],
90
+ }),
91
+ ),
92
+ ).toThrow(/duplicate/i)
93
+ })
94
+
95
+ test('throws when an edge references an unknown key', () => {
96
+ expect(() =>
97
+ parseManifest(
98
+ JSON.stringify({
99
+ version: 1,
100
+ nodes: [{ key: 'a', type: 'project', title: 'A' }],
101
+ edges: [{ source: 'a', target: 'ghost', relation: 'blocks' }],
102
+ }),
103
+ ),
104
+ ).toThrow(/ghost/i)
105
+ })
106
+ })
107
+
108
+ describe('serializeManifest', () => {
109
+ test('round-trips through parse', () => {
110
+ const text = serializeManifest(VALID)
111
+ expect(parseManifest(text)).toEqual(VALID)
112
+ })
113
+
114
+ test('emits pretty-printed JSON', () => {
115
+ const text = serializeManifest(VALID)
116
+ expect(text).toContain('\n')
117
+ expect(text.endsWith('\n')).toBe(true)
118
+ })
119
+ })
120
+
121
+ describe('node metadata client-key', () => {
122
+ test('the reserved field is a single scalar key, not an edge namespace', () => {
123
+ expect(FLOWY_KEY_FIELD).toBe('__flowyKey')
124
+ })
125
+
126
+ test('buildNodeMetadata stamps the client-key alongside user metadata', () => {
127
+ const meta = JSON.parse(buildNodeMetadata('task-1', { priority: 'high' }))
128
+ expect(meta).toEqual({ priority: 'high', __flowyKey: 'task-1' })
129
+ // No edge data is ever stored in metadata.
130
+ expect(meta.__flowy).toBeUndefined()
131
+ expect(meta.edges).toBeUndefined()
132
+ })
133
+
134
+ test('buildNodeMetadata works with no user metadata', () => {
135
+ expect(JSON.parse(buildNodeMetadata('proj'))).toEqual({
136
+ __flowyKey: 'proj',
137
+ })
138
+ })
139
+
140
+ test('readClientKey extracts the key', () => {
141
+ expect(readClientKey(buildNodeMetadata('feat-1', { a: 1 }))).toBe('feat-1')
142
+ })
143
+
144
+ test('readClientKey returns null for absent/invalid metadata', () => {
145
+ expect(readClientKey(null)).toBeNull()
146
+ expect(readClientKey('{not json')).toBeNull()
147
+ expect(readClientKey(JSON.stringify({ a: 1 }))).toBeNull()
148
+ })
149
+
150
+ test('stripClientKey returns only user metadata', () => {
151
+ expect(
152
+ stripClientKey(buildNodeMetadata('x', { priority: 'high' })),
153
+ ).toEqual({ priority: 'high' })
154
+ })
155
+
156
+ test('stripClientKey returns undefined when only the key was stored', () => {
157
+ expect(stripClientKey(buildNodeMetadata('x'))).toBeUndefined()
158
+ })
159
+ })
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Flowy import/export manifest format.
3
+ *
4
+ * The manifest is the migration unit for a backlog: projects, features and
5
+ * tasks (`nodes`) plus their dependency `edges`, all addressed by a stable
6
+ * **client-key** rather than a server id. Import upserts by client-key
7
+ * (idempotent); export reconstructs the same shape from the server. Keeping
8
+ * all format knowledge in this one module means the on-disk format (JSON
9
+ * today — see roadmap §G, an open owner decision) can change without touching
10
+ * the import/export command logic.
11
+ *
12
+ * Edges live in the real edge model (`createEdge` / `Query.edges`), not in
13
+ * node metadata. The only thing import stamps into metadata is the node's
14
+ * client-key (for idempotent node upsert); export reads edges back via
15
+ * `Query.edges`, so it captures edges created by any path (e.g. `task block`).
16
+ */
17
+
18
+ export interface ManifestNode {
19
+ /** Stable client-key — the idempotency anchor. */
20
+ key: string
21
+ /** One of: project, feature, task. */
22
+ type: string
23
+ title: string
24
+ description?: string
25
+ status?: string
26
+ /** Client-key of the parent node; drives the implicit `part_of` edge. */
27
+ parent?: string
28
+ /** Arbitrary user metadata (the reserved `__flowyKey` field is stripped). */
29
+ metadata?: Record<string, unknown>
30
+ }
31
+
32
+ export interface ManifestEdge {
33
+ /** Client-key of the source node. */
34
+ source: string
35
+ /** Client-key of the target node. */
36
+ target: string
37
+ relation: string
38
+ }
39
+
40
+ export interface Manifest {
41
+ version: number
42
+ nodes: ManifestNode[]
43
+ edges: ManifestEdge[]
44
+ }
45
+
46
+ /** The current manifest schema version. */
47
+ export const MANIFEST_VERSION = 1
48
+
49
+ /**
50
+ * Reserved metadata field holding a node's client-key — the only thing import
51
+ * writes into metadata, purely so a re-import can find the node by its stable
52
+ * key and update in place rather than duplicating. User metadata lives
53
+ * alongside it at the top level and is preserved untouched; export strips this
54
+ * one field back out. Edges are NOT stored here (see the module header).
55
+ */
56
+ export const FLOWY_KEY_FIELD = '__flowyKey'
57
+
58
+ /** Read the reserved client-key field from a server node's metadata string. */
59
+ export function readClientKey(
60
+ metadata: string | null | undefined,
61
+ ): string | null {
62
+ if (!metadata) return null
63
+ let parsed: unknown
64
+ try {
65
+ parsed = JSON.parse(metadata)
66
+ } catch {
67
+ return null
68
+ }
69
+ if (!isObject(parsed)) return null
70
+ const key = parsed[FLOWY_KEY_FIELD]
71
+ return typeof key === 'string' ? key : null
72
+ }
73
+
74
+ /** Strip the reserved client-key field, returning only user metadata (or undefined). */
75
+ export function stripClientKey(
76
+ metadata: string | null | undefined,
77
+ ): Record<string, unknown> | undefined {
78
+ if (!metadata) return undefined
79
+ let parsed: unknown
80
+ try {
81
+ parsed = JSON.parse(metadata)
82
+ } catch {
83
+ return undefined
84
+ }
85
+ if (!isObject(parsed)) return undefined
86
+ const { [FLOWY_KEY_FIELD]: _key, ...rest } = parsed
87
+ return Object.keys(rest).length > 0 ? rest : undefined
88
+ }
89
+
90
+ /** Build the metadata JSON string for a node: user metadata plus the client-key. */
91
+ export function buildNodeMetadata(
92
+ key: string,
93
+ userMetadata?: Record<string, unknown>,
94
+ ): string {
95
+ return JSON.stringify({ ...(userMetadata ?? {}), [FLOWY_KEY_FIELD]: key })
96
+ }
97
+
98
+ function fail(message: string): never {
99
+ throw new Error(message)
100
+ }
101
+
102
+ function isObject(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
104
+ }
105
+
106
+ /** Parse and validate a manifest from its serialized form. */
107
+ export function parseManifest(text: string): Manifest {
108
+ let raw: unknown
109
+ try {
110
+ raw = JSON.parse(text)
111
+ } catch {
112
+ fail('Invalid JSON: manifest is not valid JSON.')
113
+ }
114
+
115
+ if (!isObject(raw)) fail('Invalid manifest: expected a JSON object.')
116
+ if (!Array.isArray(raw.nodes)) {
117
+ fail('Invalid manifest: "nodes" must be an array.')
118
+ }
119
+
120
+ const seen = new Set<string>()
121
+ const nodes: ManifestNode[] = raw.nodes.map((entry, i) => {
122
+ if (!isObject(entry))
123
+ fail(`Invalid manifest: nodes[${i}] is not an object.`)
124
+ if (typeof entry.key !== 'string' || entry.key.length === 0) {
125
+ fail(`Invalid manifest: nodes[${i}] is missing a string "key".`)
126
+ }
127
+ if (typeof entry.type !== 'string' || entry.type.length === 0) {
128
+ fail(`Invalid manifest: node "${entry.key}" is missing a "type".`)
129
+ }
130
+ if (typeof entry.title !== 'string') {
131
+ fail(`Invalid manifest: node "${entry.key}" is missing a "title".`)
132
+ }
133
+ if (seen.has(entry.key)) {
134
+ fail(`Invalid manifest: duplicate client-key "${entry.key}".`)
135
+ }
136
+ seen.add(entry.key)
137
+ if (entry.parent != null && typeof entry.parent !== 'string') {
138
+ fail(`Invalid manifest: node "${entry.key}" has a non-string "parent".`)
139
+ }
140
+ if (entry.metadata != null && !isObject(entry.metadata)) {
141
+ fail(`Invalid manifest: node "${entry.key}" has non-object "metadata".`)
142
+ }
143
+ const node: ManifestNode = {
144
+ key: entry.key,
145
+ type: entry.type,
146
+ title: entry.title,
147
+ }
148
+ if (typeof entry.description === 'string')
149
+ node.description = entry.description
150
+ if (typeof entry.status === 'string') node.status = entry.status
151
+ if (typeof entry.parent === 'string') node.parent = entry.parent
152
+ if (isObject(entry.metadata)) node.metadata = entry.metadata
153
+ return node
154
+ })
155
+
156
+ for (const node of nodes) {
157
+ if (node.parent != null && !seen.has(node.parent)) {
158
+ fail(
159
+ `Invalid manifest: node "${node.key}" references unknown parent "${node.parent}".`,
160
+ )
161
+ }
162
+ }
163
+
164
+ const rawEdges = Array.isArray(raw.edges) ? raw.edges : []
165
+ const edges: ManifestEdge[] = rawEdges.map((entry, i) => {
166
+ if (!isObject(entry))
167
+ fail(`Invalid manifest: edges[${i}] is not an object.`)
168
+ const { source, target, relation } = entry
169
+ if (typeof source !== 'string' || typeof target !== 'string') {
170
+ fail(`Invalid manifest: edges[${i}] needs string "source" and "target".`)
171
+ }
172
+ if (typeof relation !== 'string' || relation.length === 0) {
173
+ fail(`Invalid manifest: edges[${i}] is missing a "relation".`)
174
+ }
175
+ if (!seen.has(source)) {
176
+ fail(`Invalid manifest: edge source "${source}" is not a known node key.`)
177
+ }
178
+ if (!seen.has(target)) {
179
+ fail(`Invalid manifest: edge target "${target}" is not a known node key.`)
180
+ }
181
+ return { source, target, relation }
182
+ })
183
+
184
+ const version =
185
+ typeof raw.version === 'number' ? raw.version : MANIFEST_VERSION
186
+ return { version, nodes, edges }
187
+ }
188
+
189
+ /** Serialize a manifest to its on-disk form (pretty-printed JSON, trailing newline). */
190
+ export function serializeManifest(manifest: Manifest): string {
191
+ return `${JSON.stringify(manifest, null, 2)}\n`
192
+ }