@sqaoss/flowy 1.6.1 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,10 +17,17 @@ beforeEach(() => {
17
17
  mockOutput = vi.fn()
18
18
  mockOutputError = vi.fn()
19
19
 
20
- vi.doMock('../util/config.ts', () => ({
21
- loadConfig: mockLoadConfig,
22
- saveConfig: mockSaveConfig,
23
- }))
20
+ vi.doMock('../util/config.ts', async () => {
21
+ const actual =
22
+ await vi.importActual<typeof import('../util/config.ts')>(
23
+ '../util/config.ts',
24
+ )
25
+ return {
26
+ loadConfig: mockLoadConfig,
27
+ saveConfig: mockSaveConfig,
28
+ fingerprintKey: actual.fingerprintKey,
29
+ }
30
+ })
24
31
 
25
32
  vi.doMock('../util/format.ts', () => ({
26
33
  output: mockOutput,
@@ -46,7 +53,7 @@ describe('key command', () => {
46
53
  expect(keyCommand.commands).toHaveLength(1)
47
54
  })
48
55
 
49
- test('rotate calls rotateApiKey mutation, saves new key to config, and outputs result', async () => {
56
+ test('rotate calls rotateApiKey mutation, saves new key to config, and outputs a fingerprint (not the secret)', async () => {
50
57
  const mockGraphql = vi.fn().mockResolvedValue({
51
58
  rotateApiKey: {
52
59
  user: {
@@ -74,12 +81,43 @@ describe('key command', () => {
74
81
  apiKey: 'flowy_new_key_456',
75
82
  }),
76
83
  )
77
- expect(mockOutput).toHaveBeenCalledWith(
84
+
85
+ // Default output must NOT leak the full secret.
86
+ const outputArg = mockOutput.mock.calls[0]![0]
87
+ expect(JSON.stringify(outputArg)).not.toContain('flowy_new_key_456')
88
+ expect(outputArg).toEqual(
78
89
  expect.objectContaining({
79
90
  user: expect.objectContaining({ email: 'test@example.com' }),
80
- apiKey: 'flowy_new_key_456',
91
+ keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
81
92
  }),
82
93
  )
94
+ expect(outputArg).not.toHaveProperty('apiKey')
95
+ })
96
+
97
+ test('rotate --show-key reveals the full secret', async () => {
98
+ const mockGraphql = vi.fn().mockResolvedValue({
99
+ rotateApiKey: {
100
+ user: {
101
+ id: 'user_1',
102
+ email: 'test@example.com',
103
+ tier: 'free',
104
+ createdAt: '2025-01-01T00:00:00Z',
105
+ graceEndsAt: null,
106
+ },
107
+ apiKey: 'flowy_new_key_456',
108
+ },
109
+ })
110
+ vi.doMock('../util/client.ts', () => ({
111
+ graphql: mockGraphql,
112
+ }))
113
+
114
+ const { keyCommand } = await import('./key.ts')
115
+ await keyCommand.parseAsync(['rotate', '--show-key'], { from: 'user' })
116
+
117
+ const outputArg = mockOutput.mock.calls[0]![0]
118
+ expect(outputArg).toEqual(
119
+ expect.objectContaining({ apiKey: 'flowy_new_key_456' }),
120
+ )
83
121
  })
84
122
 
85
123
  test('rotate saves the exact new apiKey to config', async () => {
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
- import { loadConfig, saveConfig } from '../util/config.ts'
3
+ import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
4
4
  import { output, outputError } from '../util/format.ts'
5
5
 
6
6
  export const keyCommand = new Command('key').description('API key management')
@@ -8,7 +8,11 @@ export const keyCommand = new Command('key').description('API key management')
8
8
  keyCommand
9
9
  .command('rotate')
10
10
  .description('Rotate API key')
11
- .action(async () => {
11
+ .option(
12
+ '--show-key',
13
+ 'Print the full API key instead of a non-reversible fingerprint',
14
+ )
15
+ .action(async (opts) => {
12
16
  try {
13
17
  const data = await graphql<{
14
18
  rotateApiKey: {
@@ -30,11 +34,17 @@ keyCommand
30
34
  }`,
31
35
  )
32
36
 
37
+ const { user, apiKey } = data.rotateApiKey
33
38
  const config = loadConfig()
34
- config.apiKey = data.rotateApiKey.apiKey
39
+ config.apiKey = apiKey
35
40
  saveConfig(config)
36
41
 
37
- output(data.rotateApiKey)
42
+ // Default output never leaks the secret; --show-key opts in (F35).
43
+ output(
44
+ opts.showKey
45
+ ? { user, apiKey }
46
+ : { user, keyFingerprint: fingerprintKey(apiKey) },
47
+ )
38
48
  } catch (error) {
39
49
  outputError(error)
40
50
  }
@@ -19,10 +19,17 @@ beforeEach(() => {
19
19
  mockOutputError = vi.fn()
20
20
  mockSpawnSync = vi.fn()
21
21
 
22
- vi.doMock('../util/config.ts', () => ({
23
- loadConfig: mockLoadConfig,
24
- saveConfig: mockSaveConfig,
25
- }))
22
+ vi.doMock('../util/config.ts', async () => {
23
+ const actual =
24
+ await vi.importActual<typeof import('../util/config.ts')>(
25
+ '../util/config.ts',
26
+ )
27
+ return {
28
+ loadConfig: mockLoadConfig,
29
+ saveConfig: mockSaveConfig,
30
+ fingerprintKey: actual.fingerprintKey,
31
+ }
32
+ })
26
33
 
27
34
  vi.doMock('../util/format.ts', () => ({
28
35
  output: mockOutput,
@@ -230,17 +237,51 @@ describe('setup command', () => {
230
237
  apiKey: 'flowy_test_key_123',
231
238
  }),
232
239
  )
233
- expect(mockOutput).toHaveBeenCalledWith(
240
+ // Default output surfaces a fingerprint, never the raw secret (F35).
241
+ const outputArg = mockOutput.mock.calls[0]![0]
242
+ expect(JSON.stringify(outputArg)).not.toContain('flowy_test_key_123')
243
+ expect(outputArg).toEqual(
234
244
  expect.objectContaining({
235
245
  user: expect.objectContaining({
236
246
  email: 'test@example.com',
237
247
  tier: 'explorer',
238
248
  graceEndsAt: '2026-04-13T00:00:00Z',
239
249
  }),
240
- apiKey: 'flowy_test_key_123',
250
+ keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
241
251
  checkoutUrl: 'https://checkout.stripe.com/session_123',
242
252
  }),
243
253
  )
254
+ expect(outputArg).not.toHaveProperty('apiKey')
255
+ })
256
+
257
+ test('setup remote --show-key reveals the full API key', async () => {
258
+ const mockGraphql = vi.fn().mockResolvedValue({
259
+ register: {
260
+ user: {
261
+ id: 'user_1',
262
+ email: 'test@example.com',
263
+ tier: 'explorer',
264
+ createdAt: '2026-03-30T00:00:00Z',
265
+ graceEndsAt: null,
266
+ },
267
+ apiKey: 'flowy_test_key_123',
268
+ checkoutUrl: null,
269
+ },
270
+ })
271
+ vi.doMock('../util/client.ts', () => ({
272
+ graphql: mockGraphql,
273
+ }))
274
+ mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from('') })
275
+
276
+ const { setupCommand } = await import('./setup.ts')
277
+ await setupCommand.parseAsync(
278
+ ['remote', '--email', 'test@example.com', '--show-key'],
279
+ { from: 'user' },
280
+ )
281
+
282
+ expect(mockOutput).toHaveBeenCalledWith(
283
+ expect.objectContaining({ apiKey: 'flowy_test_key_123' }),
284
+ )
244
285
  })
245
286
 
246
287
  test('setup local warns when the skill install fails', async () => {
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { Command, Option } from 'commander'
3
- import { loadConfig, saveConfig } from '../util/config.ts'
3
+ import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
4
4
  import { output, outputError } from '../util/format.ts'
5
5
  import { pinnedInstallSpec } from './serve.ts'
6
6
 
@@ -69,6 +69,10 @@ setupCommand
69
69
  .command('remote')
70
70
  .description('Connect to the hosted Flowy service')
71
71
  .option('--email <email>', 'Email address for registration')
72
+ .option(
73
+ '--show-key',
74
+ 'Print the full API key instead of a non-reversible fingerprint',
75
+ )
72
76
  .addOption(
73
77
  new Option(
74
78
  '--tier <tier>',
@@ -116,7 +120,13 @@ setupCommand
116
120
 
117
121
  installSkill()
118
122
 
119
- output(data.register)
123
+ const { user, apiKey, checkoutUrl } = data.register
124
+ // Default output never leaks the secret; --show-key opts in (F35).
125
+ output(
126
+ opts.showKey
127
+ ? { user, apiKey, checkoutUrl }
128
+ : { user, keyFingerprint: fingerprintKey(apiKey), checkoutUrl },
129
+ )
120
130
  } catch (error) {
121
131
  outputError(error)
122
132
  }
@@ -3,10 +3,18 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3
3
  let mockGraphql: ReturnType<typeof vi.fn>
4
4
  let mockOutput: ReturnType<typeof vi.fn>
5
5
  let mockOutputError: ReturnType<typeof vi.fn>
6
+ let mockLoadConfig: ReturnType<typeof vi.fn>
6
7
 
7
8
  beforeEach(() => {
8
9
  mockOutput = vi.fn()
9
10
  mockOutputError = vi.fn()
11
+ mockLoadConfig = vi.fn(() => ({
12
+ mode: 'saas',
13
+ apiUrl: 'https://flowy-ai.fly.dev/graphql',
14
+ apiKey: 'flowy_secret_abcdef0123456789',
15
+ client: { name: '' },
16
+ projects: {},
17
+ }))
10
18
  mockGraphql = vi.fn().mockResolvedValue({
11
19
  whoami: {
12
20
  id: 'user_1',
@@ -25,6 +33,17 @@ beforeEach(() => {
25
33
  vi.doMock('../util/client.ts', () => ({
26
34
  graphql: mockGraphql,
27
35
  }))
36
+
37
+ vi.doMock('../util/config.ts', async () => {
38
+ const actual =
39
+ await vi.importActual<typeof import('../util/config.ts')>(
40
+ '../util/config.ts',
41
+ )
42
+ return {
43
+ loadConfig: mockLoadConfig,
44
+ fingerprintKey: actual.fingerprintKey,
45
+ }
46
+ })
28
47
  })
29
48
 
30
49
  afterEach(() => {
@@ -33,7 +52,7 @@ afterEach(() => {
33
52
  })
34
53
 
35
54
  describe('whoami command', () => {
36
- test('whoami outputs user data from query', async () => {
55
+ test('whoami outputs user data plus a non-reversible key fingerprint', async () => {
37
56
  const userData = {
38
57
  id: '1',
39
58
  email: 'a@b.com',
@@ -46,7 +65,17 @@ describe('whoami command', () => {
46
65
  const { whoamiCommand } = await import('./whoami.ts')
47
66
  await whoamiCommand.parseAsync([], { from: 'user' })
48
67
 
49
- expect(mockOutput).toHaveBeenCalledWith(userData)
68
+ const outputArg = mockOutput.mock.calls[0]![0]
69
+ expect(outputArg).toEqual(
70
+ expect.objectContaining({
71
+ ...userData,
72
+ keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
73
+ }),
74
+ )
75
+ // Fingerprint must not leak the configured secret.
76
+ expect(JSON.stringify(outputArg)).not.toContain(
77
+ 'flowy_secret_abcdef0123456789',
78
+ )
50
79
  })
51
80
 
52
81
  test('whoami outputs error when query fails', async () => {
@@ -1,19 +1,25 @@
1
1
  import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
+ import { fingerprintKey, loadConfig } from '../util/config.ts'
3
4
  import { output, outputError } from '../util/format.ts'
4
5
 
5
6
  export const whoamiCommand = new Command('whoami')
6
7
  .description('Show current user info')
7
8
  .action(async () => {
8
9
  try {
9
- const data = await graphql<{ whoami: unknown }>(
10
+ const data = await graphql<{ whoami: Record<string, unknown> }>(
10
11
  `query Whoami {
11
12
  whoami {
12
13
  id email tier createdAt graceEndsAt
13
14
  }
14
15
  }`,
15
16
  )
16
- output(data.whoami)
17
+ // Surface a non-reversible fingerprint of the configured key so a human
18
+ // can confirm *which* credential is active without exposing it (F35).
19
+ output({
20
+ ...data.whoami,
21
+ keyFingerprint: fingerprintKey(loadConfig().apiKey),
22
+ })
17
23
  } catch (error) {
18
24
  outputError(error)
19
25
  }
@@ -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', () => {
@@ -70,6 +79,45 @@ describe('config', () => {
70
79
  expect(reloaded.client.name).toBe('Test Client')
71
80
  })
72
81
 
82
+ test.skipIf(isWindows)(
83
+ 'saveConfig writes config with 0600 mode',
84
+ async () => {
85
+ const { saveConfig, loadConfig } = await import('./config.ts')
86
+ saveConfig(loadConfig())
87
+ const mode = statSync(CONFIG_PATH).mode & 0o777
88
+ expect(mode).toBe(0o600)
89
+ },
90
+ )
91
+
92
+ test.skipIf(isWindows)(
93
+ 'saveConfig corrects a pre-existing 0644 config to 0600',
94
+ async () => {
95
+ const { saveConfig, loadConfig } = await import('./config.ts')
96
+ // Simulate a config written by an older CLI (world-readable).
97
+ writeFileSync(CONFIG_PATH, JSON.stringify(loadConfig(), null, 2))
98
+ chmodSync(CONFIG_PATH, 0o644)
99
+ expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o644)
100
+
101
+ saveConfig(loadConfig())
102
+ expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o600)
103
+ },
104
+ )
105
+
106
+ test('fingerprintKey is deterministic and non-reversible', async () => {
107
+ const { fingerprintKey } = await import('./config.ts')
108
+ const key = 'flowy_secret_abcdef0123456789'
109
+ const fp = fingerprintKey(key)
110
+ expect(fingerprintKey(key)).toBe(fp)
111
+ expect(fp).not.toContain(key)
112
+ expect(fp).not.toContain('abcdef0123456789')
113
+ expect(fp).toMatch(/sha256:[0-9a-f]{12}/)
114
+ })
115
+
116
+ test('fingerprintKey returns a placeholder for an empty key', async () => {
117
+ const { fingerprintKey } = await import('./config.ts')
118
+ expect(fingerprintKey('')).toBe('(none)')
119
+ })
120
+
73
121
  test('resolveProject returns null when no project configured', async () => {
74
122
  const { resolveProject } = await import('./config.ts')
75
123
  expect(resolveProject()).toBeNull()
@@ -1,10 +1,34 @@
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
 
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
+
21
+ /**
22
+ * Non-reversible fingerprint of an API key, safe to print to stdout/logs.
23
+ * A short SHA-256 prefix lets a human confirm *which* key is configured
24
+ * without exposing the secret itself (F35).
25
+ */
26
+ export function fingerprintKey(apiKey: string): string {
27
+ if (!apiKey) return '(none)'
28
+ const digest = createHash('sha256').update(apiKey).digest('hex')
29
+ return `sha256:${digest.slice(0, 12)}`
30
+ }
31
+
8
32
  export function loadConfig() {
9
33
  if (!existsSync(CONFIG_PATH)) {
10
34
  return {
@@ -19,8 +43,17 @@ export function loadConfig() {
19
43
  }
20
44
 
21
45
  export function saveConfig(config: ReturnType<typeof loadConfig>): void {
22
- mkdirSync(CONFIG_DIR, { recursive: true })
23
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
46
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE })
47
+ // `mode` on writeFileSync only applies to *newly created* files and is
48
+ // masked by umask, so an explicit chmod afterward both corrects a
49
+ // pre-existing world-readable (0644) config and survives a tight umask.
50
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
51
+ mode: FILE_MODE,
52
+ })
53
+ if (!isWindows) {
54
+ chmodSync(CONFIG_DIR, DIR_MODE)
55
+ chmodSync(CONFIG_PATH, FILE_MODE)
56
+ }
24
57
  }
25
58
 
26
59
  export interface ProjectConfig {