@sun-asterisk/sungen 3.2.3 → 3.2.5

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.
Files changed (184) hide show
  1. package/dist/cli/commands/dashboard.js +2 -2
  2. package/dist/cli/commands/dashboard.js.map +1 -1
  3. package/dist/cli/commands/delivery.d.ts.map +1 -1
  4. package/dist/cli/commands/delivery.js +22 -5
  5. package/dist/cli/commands/delivery.js.map +1 -1
  6. package/dist/cli/commands/feedback.d.ts +11 -0
  7. package/dist/cli/commands/feedback.d.ts.map +1 -1
  8. package/dist/cli/commands/feedback.js +154 -57
  9. package/dist/cli/commands/feedback.js.map +1 -1
  10. package/dist/cli/commands/login.d.ts +19 -0
  11. package/dist/cli/commands/login.d.ts.map +1 -0
  12. package/dist/cli/commands/login.js +76 -0
  13. package/dist/cli/commands/login.js.map +1 -0
  14. package/dist/cli/commands/qa-feedback.d.ts +14 -0
  15. package/dist/cli/commands/qa-feedback.d.ts.map +1 -0
  16. package/dist/cli/commands/qa-feedback.js +83 -0
  17. package/dist/cli/commands/qa-feedback.js.map +1 -0
  18. package/dist/cli/index.js +4 -0
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/dashboard/snapshot-builder.d.ts +3 -0
  21. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  22. package/dist/dashboard/snapshot-builder.js +155 -52
  23. package/dist/dashboard/snapshot-builder.js.map +1 -1
  24. package/dist/dashboard/templates/index.html +23 -23
  25. package/dist/dashboard/types.d.ts +8 -0
  26. package/dist/dashboard/types.d.ts.map +1 -1
  27. package/dist/exporters/api-testcase-formatter.d.ts +55 -0
  28. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -0
  29. package/dist/exporters/api-testcase-formatter.js +304 -0
  30. package/dist/exporters/api-testcase-formatter.js.map +1 -0
  31. package/dist/exporters/csv-exporter.d.ts +13 -1
  32. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  33. package/dist/exporters/csv-exporter.js +35 -6
  34. package/dist/exporters/csv-exporter.js.map +1 -1
  35. package/dist/exporters/feature-parser.d.ts +4 -0
  36. package/dist/exporters/feature-parser.d.ts.map +1 -1
  37. package/dist/exporters/feature-parser.js +7 -2
  38. package/dist/exporters/feature-parser.js.map +1 -1
  39. package/dist/exporters/json-exporter.d.ts.map +1 -1
  40. package/dist/exporters/json-exporter.js +11 -3
  41. package/dist/exporters/json-exporter.js.map +1 -1
  42. package/dist/exporters/playwright-report-parser.d.ts +21 -2
  43. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  44. package/dist/exporters/playwright-report-parser.js +52 -12
  45. package/dist/exporters/playwright-report-parser.js.map +1 -1
  46. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  47. package/dist/exporters/scenario-merger.js +50 -2
  48. package/dist/exporters/scenario-merger.js.map +1 -1
  49. package/dist/exporters/spec-parser.js +1 -1
  50. package/dist/exporters/spec-parser.js.map +1 -1
  51. package/dist/exporters/test-data-resolver.d.ts +7 -0
  52. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  53. package/dist/exporters/test-data-resolver.js +13 -0
  54. package/dist/exporters/test-data-resolver.js.map +1 -1
  55. package/dist/exporters/types.d.ts +13 -0
  56. package/dist/exporters/types.d.ts.map +1 -1
  57. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  58. package/dist/exporters/xlsx-exporter.js +131 -42
  59. package/dist/exporters/xlsx-exporter.js.map +1 -1
  60. package/dist/generators/gherkin-parser/index.d.ts +15 -0
  61. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  62. package/dist/generators/gherkin-parser/index.js +33 -1
  63. package/dist/generators/gherkin-parser/index.js.map +1 -1
  64. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  65. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  66. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  67. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  68. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  69. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  70. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  71. package/dist/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  72. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  73. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  74. package/dist/generators/test-generator/patterns/index.d.ts +12 -0
  75. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  76. package/dist/generators/test-generator/patterns/index.js +21 -1
  77. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  78. package/dist/harness/script-check.d.ts.map +1 -1
  79. package/dist/harness/script-check.js +10 -5
  80. package/dist/harness/script-check.js.map +1 -1
  81. package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
  82. package/dist/orchestrator/ai-skills/writer.js +46 -4
  83. package/dist/orchestrator/ai-skills/writer.js.map +1 -1
  84. package/dist/orchestrator/project-initializer.d.ts +14 -0
  85. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  86. package/dist/orchestrator/project-initializer.js +58 -0
  87. package/dist/orchestrator/project-initializer.js.map +1 -1
  88. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  89. package/dist/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  90. package/dist/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  91. package/dist/orchestrator/templates/ai-src/commands/locale.md +26 -4
  92. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  93. package/dist/orchestrator/templates/wdio.conf.ts +39 -3
  94. package/dist/tools/sungen-api/edit-in-editor.d.ts +15 -0
  95. package/dist/tools/sungen-api/edit-in-editor.d.ts.map +1 -0
  96. package/dist/tools/sungen-api/edit-in-editor.js +85 -0
  97. package/dist/tools/sungen-api/edit-in-editor.js.map +1 -0
  98. package/dist/tools/sungen-api/feedback-client.d.ts +24 -0
  99. package/dist/tools/sungen-api/feedback-client.d.ts.map +1 -0
  100. package/dist/tools/sungen-api/feedback-client.js +53 -0
  101. package/dist/tools/sungen-api/feedback-client.js.map +1 -0
  102. package/dist/tools/sungen-api/read-stdin.d.ts +6 -0
  103. package/dist/tools/sungen-api/read-stdin.d.ts.map +1 -0
  104. package/dist/tools/sungen-api/read-stdin.js +19 -0
  105. package/dist/tools/sungen-api/read-stdin.js.map +1 -0
  106. package/dist/tools/sungen-auth/api-base-url.d.ts +14 -0
  107. package/dist/tools/sungen-auth/api-base-url.d.ts.map +1 -0
  108. package/dist/tools/sungen-auth/api-base-url.js +28 -0
  109. package/dist/tools/sungen-auth/api-base-url.js.map +1 -0
  110. package/dist/tools/sungen-auth/credentials-store.d.ts +19 -0
  111. package/dist/tools/sungen-auth/credentials-store.d.ts.map +1 -0
  112. package/dist/tools/sungen-auth/credentials-store.js +94 -0
  113. package/dist/tools/sungen-auth/credentials-store.js.map +1 -0
  114. package/dist/tools/sungen-auth/login-flow.d.ts +33 -0
  115. package/dist/tools/sungen-auth/login-flow.d.ts.map +1 -0
  116. package/dist/tools/sungen-auth/login-flow.js +107 -0
  117. package/dist/tools/sungen-auth/login-flow.js.map +1 -0
  118. package/dist/tools/sungen-auth/loopback-server.d.ts +20 -0
  119. package/dist/tools/sungen-auth/loopback-server.d.ts.map +1 -0
  120. package/dist/tools/sungen-auth/loopback-server.js +132 -0
  121. package/dist/tools/sungen-auth/loopback-server.js.map +1 -0
  122. package/dist/tools/sungen-auth/open-browser.d.ts +10 -0
  123. package/dist/tools/sungen-auth/open-browser.d.ts.map +1 -0
  124. package/dist/tools/sungen-auth/open-browser.js +43 -0
  125. package/dist/tools/sungen-auth/open-browser.js.map +1 -0
  126. package/dist/tools/sungen-auth/pkce.d.ts +13 -0
  127. package/dist/tools/sungen-auth/pkce.d.ts.map +1 -0
  128. package/dist/tools/sungen-auth/pkce.js +27 -0
  129. package/dist/tools/sungen-auth/pkce.js.map +1 -0
  130. package/dist/tools/sungen-auth/sungen-auth-types.d.ts +41 -0
  131. package/dist/tools/sungen-auth/sungen-auth-types.d.ts.map +1 -0
  132. package/dist/tools/sungen-auth/sungen-auth-types.js +7 -0
  133. package/dist/tools/sungen-auth/sungen-auth-types.js.map +1 -0
  134. package/package.json +4 -3
  135. package/src/cli/commands/dashboard.ts +2 -2
  136. package/src/cli/commands/delivery.ts +24 -6
  137. package/src/cli/commands/feedback.ts +123 -51
  138. package/src/cli/commands/login.ts +75 -0
  139. package/src/cli/commands/qa-feedback.ts +77 -0
  140. package/src/cli/index.ts +4 -0
  141. package/src/dashboard/snapshot-builder.ts +146 -46
  142. package/src/dashboard/templates/index.html +23 -23
  143. package/src/dashboard/types.ts +10 -2
  144. package/src/exporters/api-testcase-formatter.ts +325 -0
  145. package/src/exporters/csv-exporter.ts +46 -7
  146. package/src/exporters/feature-parser.ts +7 -2
  147. package/src/exporters/json-exporter.ts +11 -3
  148. package/src/exporters/playwright-report-parser.ts +50 -11
  149. package/src/exporters/scenario-merger.ts +49 -3
  150. package/src/exporters/spec-parser.ts +1 -1
  151. package/src/exporters/test-data-resolver.ts +13 -0
  152. package/src/exporters/types.ts +13 -0
  153. package/src/exporters/xlsx-exporter.ts +155 -49
  154. package/src/generators/gherkin-parser/index.ts +44 -1
  155. package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +29 -8
  156. package/src/generators/test-generator/adapters/appium/templates/steps/actions/scroll-action.hbs +21 -17
  157. package/src/generators/test-generator/adapters/appium/templates/steps/actions/toggle-action.hbs +3 -0
  158. package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +26 -6
  159. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/is-hidden-assertion.hbs +3 -1
  160. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-assertion.hbs +14 -0
  161. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/tap-top-action.hbs +3 -0
  162. package/src/generators/test-generator/adapters/appium/templates/steps/partials/locator.hbs +9 -2
  163. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +115 -0
  164. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +1 -1
  165. package/src/generators/test-generator/patterns/index.ts +21 -1
  166. package/src/harness/script-check.ts +10 -5
  167. package/src/orchestrator/ai-skills/writer.ts +39 -4
  168. package/src/orchestrator/project-initializer.ts +59 -0
  169. package/src/orchestrator/templates/ai-src/commands/add-flow.md +52 -5
  170. package/src/orchestrator/templates/ai-src/commands/create-test.md +7 -0
  171. package/src/orchestrator/templates/ai-src/commands/feedback.md +6 -6
  172. package/src/orchestrator/templates/ai-src/commands/locale.md +26 -4
  173. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -4
  174. package/src/orchestrator/templates/wdio.conf.ts +39 -3
  175. package/src/tools/sungen-api/edit-in-editor.ts +48 -0
  176. package/src/tools/sungen-api/feedback-client.ts +62 -0
  177. package/src/tools/sungen-api/read-stdin.ts +15 -0
  178. package/src/tools/sungen-auth/api-base-url.ts +23 -0
  179. package/src/tools/sungen-auth/credentials-store.ts +57 -0
  180. package/src/tools/sungen-auth/login-flow.ts +135 -0
  181. package/src/tools/sungen-auth/loopback-server.ts +113 -0
  182. package/src/tools/sungen-auth/open-browser.ts +42 -0
  183. package/src/tools/sungen-auth/pkce.ts +27 -0
  184. package/src/tools/sungen-auth/sungen-auth-types.ts +45 -0
@@ -25,6 +25,7 @@ import dotenv from 'dotenv';
25
25
  dotenv.config({ path: '.env.appium' });
26
26
  import * as fs from 'node:fs';
27
27
  import * as path from 'node:path';
28
+ import { execSync } from 'node:child_process';
28
29
  import PwShapeReporter from './specs/reporters/pw-shape-reporter';
29
30
 
30
31
  /**
@@ -104,13 +105,34 @@ const RUN_ANDROID = MOBILE_PLATFORM === 'android' || MOBILE_PLATFORM === 'both';
104
105
  const RUN_IOS = MOBILE_PLATFORM === 'ios' || MOBILE_PLATFORM === 'both';
105
106
  const IOS_APP = process.env.IOS_APP;
106
107
  const IOS_BUNDLE_ID = process.env.IOS_BUNDLE_ID || '__IOS_BUNDLE_ID__';
107
- const IOS_DEVICE = process.env.IOS_DEVICE || '__IOS_DEVICE__';
108
- const IOS_VERSION = process.env.IOS_VERSION || '__IOS_VERSION__';
108
+ const IOS_UDID = process.env.IOS_UDID;
109
+ // When a specific Simulator is targeted by IOS_UDID, XCUITest uses THAT device and IGNORES the
110
+ // deviceName/platformVersion caps — so a stale IOS_DEVICE/IOS_VERSION would MISLABEL the deliverable's
111
+ // "Test Environment". Resolve the real device name + iOS runtime from the UDID via simctl so the caps
112
+ // (and the reporter's label) reflect reality. Explicit IOS_DEVICE/IOS_VERSION still win; on a real
113
+ // device / non-mac, simctl fails → fall back to the provided/scaffold values.
114
+ function resolveSimDevice(udid: string): { name?: string; version?: string } {
115
+ try {
116
+ const data = JSON.parse(execSync('xcrun simctl list devices --json', { encoding: 'utf-8' })) as {
117
+ devices?: Record<string, Array<{ udid: string; name: string }>>;
118
+ };
119
+ for (const [runtime, devs] of Object.entries(data.devices || {})) {
120
+ const hit = devs.find((d) => d.udid === udid);
121
+ if (hit) {
122
+ const vm = /iOS-(\d+)-(\d+)/.exec(runtime); // com.apple.CoreSimulator.SimRuntime.iOS-26-3
123
+ return { name: hit.name, version: vm ? `${vm[1]}.${vm[2]}` : undefined };
124
+ }
125
+ }
126
+ } catch { /* simctl unavailable (real device / non-mac) → keep provided values */ }
127
+ return {};
128
+ }
129
+ const IOS_SIM = IOS_UDID ? resolveSimDevice(IOS_UDID) : {};
130
+ const IOS_DEVICE = process.env.IOS_DEVICE || IOS_SIM.name || '__IOS_DEVICE__';
131
+ const IOS_VERSION = process.env.IOS_VERSION || IOS_SIM.version || '__IOS_VERSION__';
109
132
  // platformVersion is OPTIONAL on a real device: with IOS_UDID set, XCUITest reads the iOS version off
110
133
  // the connected device. Only pass the cap when IOS_VERSION is a real value (not the unfilled scaffold
111
134
  // placeholder). On a Simulator set it (or IOS_DEVICE) so Appium picks a matching runtime.
112
135
  const HAS_REAL_IOS_VERSION = !!IOS_VERSION && IOS_VERSION !== '__IOS_VERSION__';
113
- const IOS_UDID = process.env.IOS_UDID;
114
136
  // Real iPhone (device) — WebDriverAgent must be code-signed onto it. Set IOS_TEAM_ID (your Apple
115
137
  // Team ID) to switch the iOS cap into real-device mode; the Simulator ignores these (they're only
116
138
  // emitted when IOS_TEAM_ID is present, so Simulator runs are unchanged). For a real device also set
@@ -208,6 +230,13 @@ export const config: WebdriverIO.Config = {
208
230
  {
209
231
  platformName: 'iOS',
210
232
  'appium:automationName': 'XCUITest',
233
+ // Flutter apps NEVER report "idle" to XCUITest, so WDA always waits the FULL
234
+ // waitForIdleTimeout before EVERY interaction. The default (~10s) makes gesture
235
+ // loops blow the mocha timeout; 0 removes the wait entirely but then taps fire
236
+ // mid-transition/mid-launch and randomly miss (measured: ~2 flaky scenarios per
237
+ // run). 2s is the working balance — a universal settle that covers Flutter
238
+ // transitions while keeping bounded scroll/error paths well inside the budget.
239
+ 'appium:waitForIdleTimeout': 2,
211
240
  'appium:deviceName': IOS_DEVICE,
212
241
  // Omit when unknown (real device, no IOS_VERSION) → XCUITest reads it from the device by udid.
213
242
  ...(HAS_REAL_IOS_VERSION ? { 'appium:platformVersion': IOS_VERSION } : {}),
@@ -253,6 +282,13 @@ export const config: WebdriverIO.Config = {
253
282
  mochaOpts: {
254
283
  ui: 'bdd',
255
284
  timeout: 120000,
285
+ // One retry per test: the relaunch-per-scenario pattern on a Flutter sim has a residual
286
+ // launch race (~10%/scenario — the first interaction can fire before the app routes
287
+ // touches), with a RANDOM victim each run. A retry re-runs the full beforeEach (fresh
288
+ // relaunch), so race victims recover on attempt 2 while real failures (wrong expected
289
+ // value, missing element) fail both attempts and still report Failed. The reporter and
290
+ // delivery already take the LAST attempt.
291
+ retries: 1,
256
292
  },
257
293
 
258
294
  // iOS + locale: XCUITest's `noReset:false` does NOT actually clear the app's data container
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Open the user's editor to compose feedback text, à la `git commit`.
3
+ *
4
+ * The feedback body is MARKDOWN, so the editor buffer is left completely
5
+ * untouched — no comment/instruction lines are injected or stripped (that would
6
+ * clobber markdown `#` headings). Guidance is printed to the terminal before the
7
+ * editor launches instead. Returns the trimmed body, or null when it is empty
8
+ * (caller treats that as "abort").
9
+ */
10
+
11
+ import { spawnSync } from 'child_process';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+
16
+ /** Resolve the editor command, mirroring git's precedence. */
17
+ function resolveEditor(): string {
18
+ return (
19
+ process.env.VISUAL ||
20
+ process.env.EDITOR ||
21
+ (process.platform === 'win32' ? 'notepad' : 'vi')
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Open the editor and return the composed feedback (verbatim, trimmed), or null
27
+ * if empty/aborted. Throws if the editor cannot be launched or exits non-zero.
28
+ */
29
+ export function composeInEditor(): string | null {
30
+ const file = path.join(os.tmpdir(), `sungen-feedback-${process.pid}-${Date.now()}.md`);
31
+ fs.writeFileSync(file, '', 'utf8');
32
+ try {
33
+ const editor = resolveEditor();
34
+ console.error('Composing feedback in your editor (markdown supported). Save & close to submit; leave empty to abort.');
35
+ // shell:true so editors with args work (e.g. VISUAL="code --wait").
36
+ const result = spawnSync(`${editor} "${file}"`, { shell: true, stdio: 'inherit' });
37
+ if (result.error) throw new Error(`Could not launch editor "${editor}": ${result.error.message}`);
38
+ if (typeof result.status === 'number' && result.status !== 0) {
39
+ throw new Error(`Editor "${editor}" exited with code ${result.status}.`);
40
+ }
41
+ // Verbatim — only trim outer whitespace so markdown (headings, lists, code
42
+ // fences, HTML comments) is preserved exactly as written.
43
+ const body = fs.readFileSync(file, 'utf8').trim();
44
+ return body.length > 0 ? body : null;
45
+ } finally {
46
+ try { fs.unlinkSync(file); } catch { /* best effort */ }
47
+ }
48
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Thin client for submitting product feedback to the FastSchema backend.
3
+ * Raw fetch (the fastschema npm SDK is browser/cookie-oriented and has no
4
+ * bearer-token content client). author is NEVER sent — the server stamps it.
5
+ */
6
+
7
+ /** Thrown on 401 so the caller can prompt a re-login and retry once. */
8
+ export class AuthExpiredError extends Error {
9
+ constructor(message = 'Authentication expired.') {
10
+ super(message);
11
+ this.name = 'AuthExpiredError';
12
+ }
13
+ }
14
+
15
+ export interface FeedbackPayload {
16
+ content: string;
17
+ category: string;
18
+ sungen_version?: string;
19
+ os?: string;
20
+ source: string;
21
+ }
22
+
23
+ function normalizeBase(url: string): string {
24
+ return url.replace(/\/+$/, '');
25
+ }
26
+
27
+ /**
28
+ * POST one feedback record. Returns the created row id.
29
+ * @throws AuthExpiredError on 401; Error (scrubbed) on any other non-2xx.
30
+ */
31
+ export async function submitFeedback(
32
+ apiBaseUrl: string,
33
+ token: string,
34
+ payload: FeedbackPayload,
35
+ ): Promise<{ id: number | string }> {
36
+ let res: Response;
37
+ try {
38
+ res = await fetch(`${normalizeBase(apiBaseUrl)}/api/content/feedback`, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ Authorization: `Bearer ${token}`,
43
+ },
44
+ body: JSON.stringify(payload),
45
+ });
46
+ } catch (err: any) {
47
+ throw new Error(`Network error reaching the feedback backend: ${err?.message ?? 'unknown'}`);
48
+ }
49
+
50
+ if (res.status === 401) throw new AuthExpiredError();
51
+ if (!res.ok) {
52
+ // Scrub the body — never echo a raw server error that might carry internals.
53
+ throw new Error(`Feedback submission failed (HTTP ${res.status}).`);
54
+ }
55
+
56
+ const body = (await res.json().catch(() => ({}))) as any;
57
+ // FastSchema content create returns the row (id at top level or under .data).
58
+ // Only accept an actual id value — never fall back to the whole object (would
59
+ // print "[object Object]").
60
+ const id = body?.id ?? body?.data?.id ?? 'unknown';
61
+ return { id };
62
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Read piped stdin to a string. Returns null when stdin is a TTY (interactive),
3
+ * so callers can distinguish "no piped input" from "empty pipe".
4
+ */
5
+
6
+ export function readStdin(): Promise<string | null> {
7
+ if (process.stdin.isTTY) return Promise.resolve(null);
8
+ return new Promise((resolve, reject) => {
9
+ let data = '';
10
+ process.stdin.setEncoding('utf8');
11
+ process.stdin.on('data', (chunk) => { data += chunk; });
12
+ process.stdin.on('end', () => resolve(data));
13
+ process.stdin.on('error', reject);
14
+ });
15
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolves the feedback backend base URL with a fixed precedence so `login` and
3
+ * `feedback` agree on where to talk.
4
+ *
5
+ * Precedence: explicit flag > SUNGEN_API_BASE_URL env > stored credential's
6
+ * api_base_url > DEFAULT_API_BASE_URL.
7
+ *
8
+ * DEFAULT_API_BASE_URL is the deployed production host, so the common case needs
9
+ * no config; override via --api-base-url / SUNGEN_API_BASE_URL for dev/staging.
10
+ */
11
+
12
+ import { loadCredentials } from './credentials-store';
13
+
14
+ /** Deployed feedback backend (production). Override with SUNGEN_API_BASE_URL locally. */
15
+ export const DEFAULT_API_BASE_URL = 'https://sungen.sun-asterisk.vn';
16
+
17
+ export function resolveApiBaseUrl(flagValue?: string): string {
18
+ if (flagValue) return flagValue;
19
+ if (process.env.SUNGEN_API_BASE_URL) return process.env.SUNGEN_API_BASE_URL;
20
+ const stored = loadCredentials();
21
+ if (stored?.api_base_url) return stored.api_base_url;
22
+ return DEFAULT_API_BASE_URL;
23
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * User-level credential storage at ~/.sungen/credentials.json.
3
+ *
4
+ * Deliberately user-scoped (not project-local like the Figma PAT in .env): the
5
+ * login token is tied to the person and works across every sungen project.
6
+ * File mode 0600, parent dir 0700 — the token is a bearer secret.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as os from 'os';
11
+ import * as path from 'path';
12
+ import type { Credentials } from './sungen-auth-types';
13
+
14
+ const DIR_NAME = '.sungen';
15
+ const FILE_NAME = 'credentials.json';
16
+
17
+ /** Absolute path to the credentials file (~/.sungen/credentials.json). */
18
+ export function credentialsPath(): string {
19
+ return path.join(os.homedir(), DIR_NAME, FILE_NAME);
20
+ }
21
+
22
+ /** Load stored credentials, or null when absent / unreadable / malformed. */
23
+ export function loadCredentials(): Credentials | null {
24
+ try {
25
+ const raw = fs.readFileSync(credentialsPath(), 'utf8');
26
+ const parsed = JSON.parse(raw) as Credentials;
27
+ if (!parsed || typeof parsed.token !== 'string') return null;
28
+ return parsed;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Persist credentials with restrictive permissions (dir 0700, file 0600). */
35
+ export function saveCredentials(creds: Credentials): void {
36
+ const dir = path.join(os.homedir(), DIR_NAME);
37
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ const fp = credentialsPath();
39
+ // Write then chmod so an existing looser-mode file is tightened too.
40
+ fs.writeFileSync(fp, JSON.stringify(creds, null, 2), { mode: 0o600 });
41
+ fs.chmodSync(fp, 0o600);
42
+ }
43
+
44
+ /** Remove the credentials file; no-op when absent. */
45
+ export function clearCredentials(): void {
46
+ try {
47
+ fs.unlinkSync(credentialsPath());
48
+ } catch {
49
+ /* already gone */
50
+ }
51
+ }
52
+
53
+ /** True when the credential carries an expiry that is already past (no refresh token — prompt re-login on expiry). */
54
+ export function isExpired(creds: Credentials): boolean {
55
+ if (typeof creds.expires_at !== 'number') return false;
56
+ return creds.expires_at * 1000 <= Date.now();
57
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The 6-call RFC 8252 CLI login flow against FastSchema v0.13.0.
3
+ *
4
+ * Base path is /api/auth (verified live against the running v0.13.0 server;
5
+ * the earlier /api/user/auth contract was wrong). PKCE-S256 + single-use
6
+ * one-time code; the JWT is minted server-side
7
+ * and only delivered via the server-to-server `exchange` — it never appears in
8
+ * the browser. This module does NO disk I/O so it stays unit-testable.
9
+ */
10
+
11
+ import type {
12
+ ExchangeResponse,
13
+ InitiateResponse,
14
+ MeResponse,
15
+ PkceMaterial,
16
+ } from './sungen-auth-types';
17
+
18
+ export interface LoginFlowDeps {
19
+ apiBaseUrl: string;
20
+ pkce: PkceMaterial;
21
+ correlation: string;
22
+ redirectUri: string;
23
+ /** Open the authorize URL; return false to trigger the manual-paste fallback. */
24
+ openBrowser: (url: string) => boolean;
25
+ /** Resolve with the one-time code once the loopback callback fires. */
26
+ waitForCode: (correlation: string, timeoutMs?: number) => Promise<string>;
27
+ /** Optional sink for user-facing progress lines (defaults to console.log). */
28
+ log?: (msg: string) => void;
29
+ }
30
+
31
+ export interface LoginResult {
32
+ token: string;
33
+ email: string;
34
+ expires_at?: number;
35
+ }
36
+
37
+ /** Strip a trailing slash so path joins never double up. */
38
+ function normalizeBase(url: string): string {
39
+ return url.replace(/\/+$/, '');
40
+ }
41
+
42
+ async function postJson<T>(url: string, body: unknown): Promise<{ ok: boolean; status: number; data: T | null }> {
43
+ let res: Response;
44
+ try {
45
+ res = await fetch(url, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(body),
49
+ });
50
+ } catch (err: any) {
51
+ throw new Error(`Network error reaching ${url}: ${err?.message ?? 'unknown'}`);
52
+ }
53
+ let data: T | null = null;
54
+ try {
55
+ const json: any = await res.json();
56
+ // FastSchema wraps success payloads in { data: ... } and errors in { error: ... }.
57
+ data = (json && json.data !== undefined ? json.data : json) as T;
58
+ } catch {
59
+ /* non-JSON body */
60
+ }
61
+ return { ok: res.ok, status: res.status, data };
62
+ }
63
+
64
+ /**
65
+ * Run the full login flow. Throws with a clear, distinct message for each
66
+ * failure mode (feature-off, missing challenge, exchange-401, etc.).
67
+ */
68
+ export async function runLoginFlow(deps: LoginFlowDeps): Promise<LoginResult> {
69
+ const base = normalizeBase(deps.apiBaseUrl);
70
+ const log = deps.log ?? ((m: string) => console.log(m));
71
+
72
+ // 1. initiate — exchange PKCE challenge + loopback redirect for a carrier.
73
+ const init = await postJson<InitiateResponse>(`${base}/api/auth/cli/initiate`, {
74
+ redirect_uri: deps.redirectUri,
75
+ correlation: deps.correlation,
76
+ code_challenge: deps.pkce.challenge,
77
+ });
78
+ if (init.status === 403) {
79
+ throw new Error('CLI login is disabled on the server (AUTH_CLI_LOGIN_ENABLED=false).');
80
+ }
81
+ if (init.status === 400) {
82
+ throw new Error('Server rejected the login request (missing/invalid PKCE challenge or redirect).');
83
+ }
84
+ if (!init.ok || !init.data?.authorize_url) {
85
+ throw new Error(`Login initiate failed (HTTP ${init.status}).`);
86
+ }
87
+
88
+ // 2. open the dashboard login page (authorize_url is a path → join with base).
89
+ const authorizeUrl = `${base}${init.data.authorize_url}`;
90
+ // Always print the URL: openBrowser reports success even when the launcher
91
+ // fails asynchronously (e.g. headless box with no xdg-open), so the paste
92
+ // path must stay visible regardless.
93
+ const opened = deps.openBrowser(authorizeUrl);
94
+ if (opened) {
95
+ log(`Opened your browser to complete login. Waiting…\nIf it didn't open, visit:\n ${authorizeUrl}`);
96
+ } else {
97
+ log(`Open this URL in your browser to continue login:\n ${authorizeUrl}`);
98
+ }
99
+
100
+ // 3. wait for the loopback callback (asserts state===correlation inside).
101
+ const code = await deps.waitForCode(deps.correlation);
102
+
103
+ // 4. exchange the one-time code + verifier for the JWT.
104
+ const exch = await postJson<ExchangeResponse>(`${base}/api/auth/exchange`, {
105
+ code,
106
+ code_verifier: deps.pkce.verifier,
107
+ });
108
+ if (exch.status === 401) {
109
+ throw new Error('Token exchange rejected (code expired or verifier mismatch). Try `sungen login` again.');
110
+ }
111
+ if (!exch.ok || !exch.data?.token) {
112
+ throw new Error(`Token exchange failed (HTTP ${exch.status}).`);
113
+ }
114
+ const token = exch.data.token;
115
+ // Backend reports expiry as an ISO `expires` string; accept a numeric
116
+ // `expires_at` epoch too. Absent → rely on 401-on-expiry (single token, no refresh).
117
+ const expires_at =
118
+ exch.data.expires_at ??
119
+ (exch.data.expires ? Math.floor(Date.parse(exch.data.expires) / 1000) : undefined);
120
+
121
+ // 5. confirm identity (Bearer) and capture the email.
122
+ let me: Response;
123
+ try {
124
+ me = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
125
+ } catch (err: any) {
126
+ throw new Error(`Network error confirming identity: ${err?.message ?? 'unknown'}`);
127
+ }
128
+ if (!me.ok) {
129
+ throw new Error(`Logged in but identity check failed (HTTP ${me.status}).`);
130
+ }
131
+ const meJson: any = await me.json();
132
+ const meBody = (meJson && meJson.data !== undefined ? meJson.data : meJson) as MeResponse;
133
+
134
+ return { token, email: meBody.email, expires_at };
135
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Single-shot loopback HTTP listener for the CLI login redirect.
3
+ *
4
+ * Binds 127.0.0.1 on an ephemeral port, waits for exactly one
5
+ * GET /callback?code=<otc>&state=<correlation>, asserts state===correlation
6
+ * (CSRF guard), shows a "you may close this tab" page, then closes.
7
+ */
8
+
9
+ import * as http from 'http';
10
+ import { AddressInfo, Socket } from 'net';
11
+
12
+ export interface Loopback {
13
+ /** The ephemeral port the listener bound to. */
14
+ port: number;
15
+ /** Full redirect_uri to hand to the backend (http://127.0.0.1:<port>/callback). */
16
+ redirectUri: string;
17
+ /** Resolve with the one-time code once the browser hits /callback; reject on timeout/mismatch. */
18
+ waitForCode(correlation: string, timeoutMs?: number): Promise<string>;
19
+ /** Force-close the listener (idempotent). */
20
+ close(): void;
21
+ }
22
+
23
+ const CLOSE_PAGE =
24
+ '<!doctype html><meta charset="utf-8"><title>sungen login</title>' +
25
+ '<body style="font-family:system-ui;padding:3rem;text-align:center">' +
26
+ '<h2>Login complete</h2><p>You may close this tab and return to the terminal.</p></body>';
27
+
28
+ const DEFAULT_TIMEOUT_MS = 180_000;
29
+
30
+ /** Start the loopback listener. Resolves once it is bound and ready. */
31
+ export function startLoopback(): Promise<Loopback> {
32
+ return new Promise((resolve, reject) => {
33
+ let resolveCode: ((code: string) => void) | null = null;
34
+ let rejectCode: ((err: Error) => void) | null = null;
35
+
36
+ const server = http.createServer((req, res) => {
37
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
38
+ if (url.pathname !== '/callback') {
39
+ res.writeHead(404, { Connection: 'close' }).end('not found');
40
+ return;
41
+ }
42
+ // Connection: close so the browser doesn't hold a keep-alive socket open —
43
+ // otherwise server.close() would never fire and the CLI would hang after login.
44
+ res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' }).end(CLOSE_PAGE);
45
+ const code = url.searchParams.get('code');
46
+ const state = url.searchParams.get('state');
47
+ // Hand the parsed values to the pending waitForCode promise.
48
+ pending = { code, state };
49
+ drain();
50
+ });
51
+
52
+ // Track live sockets so shutdown() can destroy any keep-alive stragglers.
53
+ const sockets = new Set<Socket>();
54
+ server.on('connection', (s) => {
55
+ sockets.add(s);
56
+ s.on('close', () => sockets.delete(s));
57
+ });
58
+
59
+ /** Close the server AND destroy lingering sockets so the event loop can drain. */
60
+ function shutdown(): void {
61
+ if (server.listening) server.close();
62
+ for (const s of sockets) s.destroy();
63
+ sockets.clear();
64
+ }
65
+
66
+ // Buffer the callback result until a waiter (with its correlation) arrives.
67
+ let pending: { code: string | null; state: string | null } | null = null;
68
+ let expectedCorrelation: string | null = null;
69
+
70
+ function drain(): void {
71
+ if (!pending || !resolveCode || !rejectCode || expectedCorrelation == null) return;
72
+ const { code, state } = pending;
73
+ if (state !== expectedCorrelation) {
74
+ rejectCode(new Error('Login state mismatch — possible CSRF; aborting.'));
75
+ } else if (!code) {
76
+ rejectCode(new Error('Login callback missing authorization code.'));
77
+ } else {
78
+ resolveCode(code);
79
+ }
80
+ // Let the response flush first, then tear everything down.
81
+ setImmediate(shutdown);
82
+ }
83
+
84
+ server.on('error', reject);
85
+ server.listen(0, '127.0.0.1', () => {
86
+ const port = (server.address() as AddressInfo).port;
87
+ resolve({
88
+ port,
89
+ redirectUri: `http://127.0.0.1:${port}/callback`,
90
+ waitForCode(correlation, timeoutMs = DEFAULT_TIMEOUT_MS) {
91
+ expectedCorrelation = correlation;
92
+ return new Promise<string>((res, rej) => {
93
+ resolveCode = res;
94
+ rejectCode = rej;
95
+ const timer = setTimeout(() => {
96
+ shutdown();
97
+ rej(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for browser login.`));
98
+ }, timeoutMs);
99
+ timer.unref?.();
100
+ // Wrap to always clear the timer.
101
+ const origRes = resolveCode, origRej = rejectCode;
102
+ resolveCode = (c) => { clearTimeout(timer); origRes!(c); };
103
+ rejectCode = (e) => { clearTimeout(timer); origRej!(e); };
104
+ drain();
105
+ });
106
+ },
107
+ close() {
108
+ shutdown();
109
+ },
110
+ });
111
+ });
112
+ });
113
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Cross-platform "open this URL in the default browser" helper.
3
+ *
4
+ * Deliberately dependency-free — a small platform switch instead of pulling in
5
+ * the `open` npm package. Returns false on failure so the caller can fall back
6
+ * to printing the URL for manual paste.
7
+ */
8
+
9
+ import { spawn } from 'child_process';
10
+
11
+ /** Attempt to open `url` in the system browser. Returns true if a launcher was spawned. */
12
+ export function openBrowser(url: string): boolean {
13
+ let command: string;
14
+ let args: string[];
15
+
16
+ switch (process.platform) {
17
+ case 'darwin':
18
+ command = 'open';
19
+ args = [url];
20
+ break;
21
+ case 'win32':
22
+ // `start` is a cmd builtin; the empty "" is the window-title placeholder.
23
+ command = 'cmd';
24
+ args = ['/c', 'start', '', url];
25
+ break;
26
+ default:
27
+ command = 'xdg-open';
28
+ args = [url];
29
+ break;
30
+ }
31
+
32
+ try {
33
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
34
+ // A missing launcher fails asynchronously here, after we've already returned
35
+ // true; that's why callers also print the URL rather than relying on this.
36
+ child.on('error', () => { /* best-effort launch */ });
37
+ child.unref();
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * PKCE-S256 + correlation generation for the CLI login flow.
3
+ *
4
+ * The backend mandates S256 (plain is rejected). The verifier never leaves the
5
+ * process except inside the server-to-server `exchange` POST; the challenge is
6
+ * what travels to the browser side.
7
+ */
8
+
9
+ import { randomBytes, createHash } from 'crypto';
10
+ import type { PkceMaterial } from './sungen-auth-types';
11
+
12
+ /** base64url with padding stripped, per RFC 7636 §A. */
13
+ function base64url(buf: Buffer): string {
14
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
15
+ }
16
+
17
+ /** Generate a fresh PKCE verifier + S256 challenge pair. */
18
+ export function generatePkce(): PkceMaterial {
19
+ const verifier = base64url(randomBytes(32));
20
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
21
+ return { verifier, challenge };
22
+ }
23
+
24
+ /** Random opaque value used to bind the loopback callback to this attempt (CSRF guard). */
25
+ export function randomCorrelation(): string {
26
+ return randomBytes(16).toString('hex');
27
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared types for the sungen CLI login flow (RFC 8252 native-app login against
3
+ * a FastSchema v0.13.0 backend: PKCE-S256 + loopback redirect + one-time code).
4
+ */
5
+
6
+ /** Persisted, user-level credentials at ~/.sungen/credentials.json (mode 0600). */
7
+ export interface Credentials {
8
+ /** Bearer JWT minted by the backend exchange step. */
9
+ token: string;
10
+ /** Authenticated account email (from /api/auth/me). */
11
+ email: string;
12
+ /** Backend base URL the token was issued against (so `feedback` knows where to POST). */
13
+ api_base_url: string;
14
+ /** Unix epoch seconds when the token expires, if the backend reports it (single token, no refresh). */
15
+ expires_at?: number;
16
+ }
17
+
18
+ /** Response of POST /api/auth/cli/initiate (unwrapped from the {data} envelope). */
19
+ export interface InitiateResponse {
20
+ /** Signed carrier the dashboard login page consumes. */
21
+ carrier: string;
22
+ /** Path (not absolute) to open in the browser, e.g. /dash/login?cli=<carrier>. */
23
+ authorize_url: string;
24
+ }
25
+
26
+ /** Response of POST /api/auth/exchange (unwrapped from the {data} envelope). */
27
+ export interface ExchangeResponse {
28
+ /** Bearer JWT. */
29
+ token: string;
30
+ /** ISO expiry string the backend returns (e.g. "2026-07-08T06:42:30Z"). */
31
+ expires?: string;
32
+ /** Numeric epoch-seconds expiry, if a build reports it instead. */
33
+ expires_at?: number;
34
+ }
35
+
36
+ /** Response of GET /api/auth/me (subset we rely on, unwrapped from {data}). */
37
+ export interface MeResponse {
38
+ email: string;
39
+ }
40
+
41
+ /** PKCE material generated per login attempt; never persisted to disk. */
42
+ export interface PkceMaterial {
43
+ verifier: string;
44
+ challenge: string;
45
+ }