@hasna/economy 0.5.6 → 0.5.8

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 (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/chunks/{billing-22az60hy.js → billing-7twygmtj.js} +2 -2
  3. package/dist/chunks/{config-hsp7ndhj.js → config-mjgbekxj.js} +2 -2
  4. package/dist/chunks/{index-9wa1f2kv.js → index-7ek4r0ww.js} +1 -1
  5. package/dist/chunks/{index-qvcafabj.js → index-9za4cmq2.js} +2 -2
  6. package/dist/chunks/{index-zs5dba3t.js → index-a1gh3d6t.js} +5 -4
  7. package/dist/chunks/{index-vajzfzgv.js → index-ep1pkyr3.js} +1 -1
  8. package/dist/chunks/{index-zqsx461z.js → index-gf3kan31.js} +2 -2
  9. package/dist/chunks/{index-dk5rq1rq.js → index-gtjqy0kd.js} +1 -1
  10. package/dist/chunks/{index-m12xttw1.js → index-h2mcj7gv.js} +82 -7
  11. package/dist/chunks/{index-c7h2rxx1.js → index-t0n32g8r.js} +1 -1
  12. package/dist/chunks/{index-f5jv6q5q.js → index-zgvmrqyc.js} +1 -1
  13. package/dist/chunks/{open-projects-8qm46m3d.js → open-projects-bj561k5j.js} +1 -1
  14. package/dist/chunks/{pricing-k9wsjwbz.js → pricing-bkgvzpe9.js} +1 -1
  15. package/dist/chunks/{pricing-462f8xkm.js → pricing-bn88mhqk.js} +1 -1
  16. package/dist/chunks/{pricing-vxejj1qa.js → pricing-nt31hf53.js} +1 -1
  17. package/dist/chunks/{serve-ept6cfbe.js → serve-14cgmx02.js} +14 -8
  18. package/dist/chunks/{sqlite-store-ht6frztj.js → sqlite-store-8t4anw98.js} +2 -2
  19. package/dist/chunks/{sqlite-store-13nhr1z0.js → sqlite-store-jfy0e8b6.js} +2 -2
  20. package/dist/chunks/{sqlite-store-gamn65xp.js → sqlite-store-r219qrjt.js} +2 -2
  21. package/dist/chunks/{tui-4bh067a3.js → tui-s6tpce3h.js} +4 -4
  22. package/dist/chunks/{watch-227kxmra.js → watch-cv5va8x6.js} +5 -5
  23. package/dist/chunks/{webhooks-nw4daw9y.js → webhooks-td46p6gk.js} +2 -2
  24. package/dist/cli/index.js +32 -28
  25. package/dist/cost-imports/contract.d.ts +84 -0
  26. package/dist/cost-imports/contract.d.ts.map +1 -0
  27. package/dist/cost-imports/decimal.d.ts +6 -0
  28. package/dist/cost-imports/decimal.d.ts.map +1 -0
  29. package/dist/cost-imports/providers.d.ts +4 -0
  30. package/dist/cost-imports/providers.d.ts.map +1 -0
  31. package/dist/index.js +66 -8
  32. package/dist/lib/analytics.d.ts +3 -0
  33. package/dist/lib/analytics.d.ts.map +1 -1
  34. package/dist/lib/comparison-contract.d.ts +8 -0
  35. package/dist/lib/comparison-contract.d.ts.map +1 -0
  36. package/dist/lib/comparison-test-contract.d.ts +3 -0
  37. package/dist/lib/comparison-test-contract.d.ts.map +1 -0
  38. package/dist/lib/store/index.d.ts.map +1 -1
  39. package/dist/mcp/index.js +69 -11
  40. package/dist/otel/index.js +1 -1
  41. package/dist/server/index.js +40 -6
  42. package/dist/server/service-operations.d.ts.map +1 -1
  43. package/dist/server/service-worker.js +40 -6
  44. package/docs/comparison-coverage.md +30 -0
  45. package/native/README.md +70 -2
  46. package/native/Tools/pair-qr.py +130 -34
  47. package/native/Tools/pair-qr.swift +19 -1
  48. package/package.json +1 -1
@@ -6,14 +6,17 @@ Pass an existing authorized human Identities session's Secrets reference using
6
6
  verifies the session and selected tenant; no station agent key works.
7
7
  """
8
8
  import argparse
9
+ import getpass
9
10
  import json
10
11
  import os
11
12
  from pathlib import Path
12
13
  import re
13
14
  import shlex
15
+ import signal
14
16
  import subprocess
15
17
  import sys
16
18
  import urllib.request
19
+ import warnings
17
20
 
18
21
 
19
22
  class NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -47,7 +50,12 @@ def owner_terminal():
47
50
  parent, executable = int(fields[0]), fields[1]
48
51
  if executable == '/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal':
49
52
  return True
50
- if Path(executable).name not in ['Python', 'python3', 'bash', 'zsh', 'login', 'bun', 'secrets']:
53
+ process_name = Path(executable).name
54
+ # macOS Terminal reports login shells with a leading hyphen in comm.
55
+ # Normalize only the shell names already allowed by this boundary.
56
+ if process_name in ['-bash', '-zsh']:
57
+ process_name = process_name[1:]
58
+ if process_name not in ['Python', 'python3', 'bash', 'zsh', 'login', 'bun', 'secrets']:
51
59
  return False
52
60
  if parent <= 1 or parent == pid:
53
61
  return False
@@ -55,14 +63,20 @@ def owner_terminal():
55
63
  return False
56
64
 
57
65
 
58
- def launch_owner_terminal(reference, tenant):
59
- if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]{0,254}', reference):
66
+ def launch_owner_terminal(reference, tenant, email=None, create_account=False):
67
+ if reference is not None and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]{0,254}', reference):
60
68
  print('Invalid Secrets reference.', file=sys.stderr)
61
69
  return 2
62
70
  # AppleScript receives references and paths only. Its returned text never
63
71
  # contains Terminal output. No QR or credential is returned to this process.
64
- command = shlex.join(['secrets', 'exec', reference, '--as', 'ECONOMY_HUMAN_SESSION', '--',
65
- '/usr/bin/python3', str(Path(__file__).resolve()), '--owner-terminal', '--tenant', tenant])
72
+ arguments = ['/usr/bin/python3', str(Path(__file__).resolve()), '--owner-terminal', '--tenant', tenant]
73
+ if email is not None:
74
+ arguments += ['--login-email', email]
75
+ if create_account:
76
+ arguments.append('--create-account')
77
+ else:
78
+ arguments = ['secrets', 'exec', reference, '--as', 'ECONOMY_HUMAN_SESSION', '--', *arguments]
79
+ command = shlex.join(arguments)
66
80
  script = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(command) + '\nend tell\n'
67
81
  try:
68
82
  result = subprocess.run(['/usr/bin/osascript', '-'], input=script, text=True, capture_output=True, timeout=30)
@@ -72,15 +86,68 @@ def launch_owner_terminal(reference, tenant):
72
86
  if result.returncode:
73
87
  print('Could not open the owner Terminal window. No code was displayed by this process.', file=sys.stderr)
74
88
  return 1
75
- print('Opened the Economy pairing window on this Mac. Scan it with Economy on your iPhone.')
89
+ print('Opened Economy setup in Terminal on this Mac. Complete the steps there, then scan its QR code.')
76
90
  return 0
77
91
 
78
92
 
93
+ def identity_request(action, body, bearer=None):
94
+ if action not in ['signup', 'login', 'verify', 'logout', 'economy-pairing/issue']:
95
+ raise ValueError('Unsupported identity operation')
96
+ headers = {'Content-Type': 'application/json', 'Accept': 'application/json',
97
+ 'User-Agent': 'Hasna-Economy-Pairing/1.0'}
98
+ if bearer is not None:
99
+ headers['Authorization'] = 'Bearer ' + bearer
100
+ request = urllib.request.Request('https://api.hasna.com/identities/v1/auth/' + action,
101
+ data=json.dumps(body).encode(), headers=headers, method='POST')
102
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=15) as response:
103
+ raw = response.read(16385)
104
+ if response.status != (201 if action == 'signup' else 200) or len(raw) > 16384:
105
+ raise ValueError('Invalid identity response')
106
+ value = json.loads(raw)
107
+ if not isinstance(value, dict):
108
+ raise ValueError('Invalid identity response')
109
+ return value
110
+
111
+
112
+ def login_in_owner_terminal(email, create_account):
113
+ action = 'Create your Hasna sign-in and send one verification email to' if create_account else 'Send one sign-in email to'
114
+ if input(action + ' ' + email + '? Type yes to continue: ').strip().lower() != 'yes':
115
+ print('Cancelled. No email was requested.')
116
+ return None
117
+ challenge = identity_request('signup' if create_account else 'login', {'email': email})
118
+ if challenge.get('challenge') is not True or challenge.get('email_sent') is False:
119
+ raise ValueError('Email challenge unavailable')
120
+ with warnings.catch_warnings():
121
+ warnings.simplefilter('error', getpass.GetPassWarning)
122
+ code = getpass.getpass('Verification code (hidden; enter only in this Terminal): ')
123
+ if not re.fullmatch(r'[0-9]{6}', code):
124
+ raise ValueError('Invalid verification code')
125
+ try:
126
+ return identity_request('verify', {'email': email, 'code': code})
127
+ finally:
128
+ code = None
129
+
130
+
131
+ def render_pairing_code(human, tenant, renderer):
132
+ value = identity_request('economy-pairing/issue', {'tenant_id': tenant, 'device_name': 'Your iPhone'}, human)
133
+ if not re.fullmatch(r'hec_[A-Za-z0-9_-]{43}', value.get('code', '')) or not 1 <= value.get('expires_in', 0) <= 300:
134
+ raise ValueError('Invalid pairing response')
135
+ environment = {k: v for k, v in os.environ.items() if k in
136
+ ['PATH', 'HOME', 'TMPDIR', 'LANG', 'TERM', 'DEVELOPER_DIR', 'CLANG_MODULE_CACHE_PATH']}
137
+ environment['ECONOMY_PAIR_CODE'] = value['code']
138
+ environment['ECONOMY_PAIR_SERVER'] = 'https://api.hasna.com/economy'
139
+ environment['ECONOMY_PAIR_TTL'] = str(value['expires_in'])
140
+ return subprocess.run(['/usr/bin/swift', str(renderer)], env=environment).returncode
141
+
142
+
79
143
  def main():
80
144
  parser = argparse.ArgumentParser(description=__doc__)
81
145
  parser.add_argument('--tenant', help='Exact workspace ID, resolved through Identities')
82
146
  parser.add_argument('--self-test', action='store_true')
83
- parser.add_argument('--session-ref', help='Authorized human-session reference in Secrets; never a value')
147
+ source = parser.add_mutually_exclusive_group()
148
+ source.add_argument('--session-ref', help='Authorized human-session reference in Secrets; never a value')
149
+ source.add_argument('--login-email', help='Sign in privately in the owner Terminal before displaying the QR code')
150
+ parser.add_argument('--create-account', action='store_true', help='Offer first-account signup in the owner Terminal; requires --login-email and direct confirmation')
84
151
  parser.add_argument('--owner-terminal', action='store_true', help=argparse.SUPPRESS)
85
152
  args = parser.parse_args()
86
153
  human = os.environ.pop('ECONOMY_HUMAN_SESSION', None)
@@ -90,41 +157,70 @@ def main():
90
157
  if not args.tenant or not re.fullmatch(r'[A-Za-z0-9_-]{1,255}', args.tenant):
91
158
  print('Provide the exact authorized workspace with --tenant.', file=sys.stderr)
92
159
  return 2
160
+ if args.create_account and not args.login_email:
161
+ print('--create-account requires --login-email.', file=sys.stderr)
162
+ return 2
163
+ if args.login_email is not None and (len(args.login_email) > 320 or not re.fullmatch(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', args.login_email)):
164
+ print('Provide a valid sign-in email address.', file=sys.stderr)
165
+ return 2
93
166
  if not args.owner_terminal:
94
- if not args.session_ref:
95
- print('Use --session-ref to open the owner Terminal. Credential values are not accepted here.', file=sys.stderr)
167
+ if not args.session_ref and not args.login_email:
168
+ print('Use --session-ref or --login-email to open the owner Terminal. Credential values are not accepted here.', file=sys.stderr)
96
169
  return 3
97
- return launch_owner_terminal(args.session_ref, args.tenant)
170
+ return launch_owner_terminal(args.session_ref, args.tenant, args.login_email, args.create_account)
98
171
  if args.session_ref or not owner_terminal():
99
172
  print('Refusing to show a connection code outside the direct owner Terminal path.', file=sys.stderr)
100
173
  return 3
101
- if not human or not re.fullmatch(r'hst_[A-Za-z0-9_-]{43}', human):
174
+ if args.login_email and human:
175
+ print('Choose either a saved session or interactive sign-in.', file=sys.stderr)
176
+ return 2
177
+ if not args.login_email and (not human or not re.fullmatch(r'hst_[A-Za-z0-9_-]{43}', human)):
102
178
  print('An existing human Identities session is required via ECONOMY_HUMAN_SESSION. Station keys are refused.', file=sys.stderr)
103
179
  return 2
104
- request = urllib.request.Request('https://api.hasna.com/identities/v1/auth/economy-pairing/issue',
105
- data=json.dumps({'tenant_id': args.tenant, 'device_name': 'Your iPhone'}).encode(),
106
- headers={'Authorization': 'Bearer ' + human, 'Content-Type': 'application/json',
107
- 'Accept': 'application/json', 'User-Agent': 'Hasna-Economy-Pairing/1.0'}, method='POST')
180
+ created_session = None
181
+ result = 1
182
+ previous_handlers = {}
183
+ cleaning_up = False
184
+ def shutdown(signum, frame):
185
+ if not cleaning_up:
186
+ raise KeyboardInterrupt
108
187
  try:
109
- with urllib.request.build_opener(NoRedirect).open(request, timeout=15) as response:
110
- raw = response.read(8193)
111
- if response.status != 200 or len(raw) > 8192:
112
- raise ValueError('response')
113
- value = json.loads(raw)
114
- if not re.fullmatch(r'hec_[A-Za-z0-9_-]{43}', value.get('code', '')) or not 1 <= value.get('expires_in', 0) <= 300:
115
- raise ValueError('response')
116
- except Exception:
117
- print('Unable to issue the connection code. Check the human session, workspace and hosted pairing service.', file=sys.stderr)
118
- return 1
119
- human = None
120
- # Only the temporary code goes to the renderer. The human session was
121
- # removed from the environment before any child was started.
122
- environment = {k: v for k, v in os.environ.items() if k in
123
- ['PATH', 'HOME', 'TMPDIR', 'LANG', 'TERM', 'DEVELOPER_DIR', 'CLANG_MODULE_CACHE_PATH']}
124
- environment['ECONOMY_PAIR_CODE'] = value['code']
125
- environment['ECONOMY_PAIR_SERVER'] = 'https://api.hasna.com/economy'
126
- environment['ECONOMY_PAIR_TTL'] = str(value['expires_in'])
127
- return subprocess.run(['/usr/bin/swift', str(renderer)], env=environment).returncode
188
+ for sig in [signal.SIGHUP, signal.SIGTERM, signal.SIGINT]:
189
+ previous_handlers[sig] = signal.signal(sig, shutdown)
190
+ if args.login_email:
191
+ identity = login_in_owner_terminal(args.login_email, args.create_account)
192
+ if identity is None:
193
+ return 0
194
+ human = identity.get('session')
195
+ if not isinstance(human, str) or not re.fullmatch(r'hst_[A-Za-z0-9_-]{43}', human):
196
+ raise ValueError('Invalid human session')
197
+ created_session = human
198
+ if identity.get('principal', {}).get('kind') != 'human' or not any(
199
+ isinstance(row, dict) and row.get('tenant_id') == args.tenant for row in identity.get('tenants', [])):
200
+ raise ValueError('Human or workspace mismatch')
201
+ identity = None
202
+ print('Scan this code before closing its window. Setup will then try to sign out its temporary session.')
203
+ result = render_pairing_code(human, args.tenant, renderer)
204
+ except (Exception, KeyboardInterrupt):
205
+ print('Unable to complete setup. No automatic email or sign-in retry was made. Check your account and workspace before trying again.', file=sys.stderr)
206
+ finally:
207
+ # Catchable Terminal shutdown unwinds into cleanup. Further catchable
208
+ # signals cannot interrupt the single timeout-limited logout attempt.
209
+ cleaning_up = True
210
+ try:
211
+ if created_session:
212
+ try:
213
+ if identity_request('logout', {}, created_session).get('signed_out') is not True:
214
+ raise ValueError('Sign-out unconfirmed')
215
+ except Exception:
216
+ print('Setup session sign-out could not be confirmed. Check your account sessions before retrying.', file=sys.stderr)
217
+ result = 1
218
+ finally:
219
+ human = None
220
+ created_session = None
221
+ for sig, handler in previous_handlers.items():
222
+ signal.signal(sig, handler)
223
+ return result
128
224
 
129
225
 
130
226
  if __name__ == '__main__':
@@ -116,8 +116,11 @@ func ownerTerminal(environment: [String: String], tty: Bool, parent: Int32,
116
116
  for _ in 0..<12 {
117
117
  guard let (next, executable) = lookup(pid) else { return false }
118
118
  if executable == "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal" { return true }
119
+ var processName = URL(fileURLWithPath: executable).lastPathComponent
120
+ // Match the Python launcher: macOS prefixes login-shell names with '-'.
121
+ if ["-bash", "-zsh"].contains(processName) { processName.removeFirst() }
119
122
  guard ["Python", "python3", "bash", "zsh", "login", "bun", "secrets", "swift", "swift-frontend"]
120
- .contains(URL(fileURLWithPath: executable).lastPathComponent), next > 1, next != pid else { return false }
123
+ .contains(processName), next > 1, next != pid else { return false }
121
124
  pid = next
122
125
  }
123
126
  return false
@@ -142,6 +145,21 @@ func parentProcess(_ pid: Int32) -> (Int32, String)? {
142
145
 
143
146
  func selfTest() {
144
147
  let terminal = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"
148
+ for shell in ["-zsh", "-/bin/zsh", "-bash"] {
149
+ guard ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { pid in
150
+ switch pid {
151
+ case 100: (200, "/usr/bin/python3")
152
+ case 200: (300, shell)
153
+ case 300: (400, "login")
154
+ default: (1, terminal)
155
+ }
156
+ }) else { fail("pair-qr self-test: FAIL (owner login shell)", 1) }
157
+ }
158
+ for harness in ["-codex", "-claude", "-opencode"] {
159
+ guard !ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { pid in
160
+ pid == 100 ? (200, harness) : (1, terminal)
161
+ }) else { fail("pair-qr self-test: FAIL (captured login process)", 1) }
162
+ }
145
163
  guard !ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { _ in (200, "/runtime/codex") }),
146
164
  !ownerTerminal(environment: [:], tty: false, parent: 100, lookup: { _ in (200, terminal) }),
147
165
  !ownerTerminal(environment: ["CODEX_THREAD_ID": "fixture"], tty: true, parent: 100, lookup: { _ in (200, terminal) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5
5
  "repository": {
6
6
  "type": "git",