@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.
- package/CHANGELOG.md +15 -0
- package/dist/chunks/{billing-22az60hy.js → billing-7twygmtj.js} +2 -2
- package/dist/chunks/{config-hsp7ndhj.js → config-mjgbekxj.js} +2 -2
- package/dist/chunks/{index-9wa1f2kv.js → index-7ek4r0ww.js} +1 -1
- package/dist/chunks/{index-qvcafabj.js → index-9za4cmq2.js} +2 -2
- package/dist/chunks/{index-zs5dba3t.js → index-a1gh3d6t.js} +5 -4
- package/dist/chunks/{index-vajzfzgv.js → index-ep1pkyr3.js} +1 -1
- package/dist/chunks/{index-zqsx461z.js → index-gf3kan31.js} +2 -2
- package/dist/chunks/{index-dk5rq1rq.js → index-gtjqy0kd.js} +1 -1
- package/dist/chunks/{index-m12xttw1.js → index-h2mcj7gv.js} +82 -7
- package/dist/chunks/{index-c7h2rxx1.js → index-t0n32g8r.js} +1 -1
- package/dist/chunks/{index-f5jv6q5q.js → index-zgvmrqyc.js} +1 -1
- package/dist/chunks/{open-projects-8qm46m3d.js → open-projects-bj561k5j.js} +1 -1
- package/dist/chunks/{pricing-k9wsjwbz.js → pricing-bkgvzpe9.js} +1 -1
- package/dist/chunks/{pricing-462f8xkm.js → pricing-bn88mhqk.js} +1 -1
- package/dist/chunks/{pricing-vxejj1qa.js → pricing-nt31hf53.js} +1 -1
- package/dist/chunks/{serve-ept6cfbe.js → serve-14cgmx02.js} +14 -8
- package/dist/chunks/{sqlite-store-ht6frztj.js → sqlite-store-8t4anw98.js} +2 -2
- package/dist/chunks/{sqlite-store-13nhr1z0.js → sqlite-store-jfy0e8b6.js} +2 -2
- package/dist/chunks/{sqlite-store-gamn65xp.js → sqlite-store-r219qrjt.js} +2 -2
- package/dist/chunks/{tui-4bh067a3.js → tui-s6tpce3h.js} +4 -4
- package/dist/chunks/{watch-227kxmra.js → watch-cv5va8x6.js} +5 -5
- package/dist/chunks/{webhooks-nw4daw9y.js → webhooks-td46p6gk.js} +2 -2
- package/dist/cli/index.js +32 -28
- package/dist/cost-imports/contract.d.ts +84 -0
- package/dist/cost-imports/contract.d.ts.map +1 -0
- package/dist/cost-imports/decimal.d.ts +6 -0
- package/dist/cost-imports/decimal.d.ts.map +1 -0
- package/dist/cost-imports/providers.d.ts +4 -0
- package/dist/cost-imports/providers.d.ts.map +1 -0
- package/dist/index.js +66 -8
- package/dist/lib/analytics.d.ts +3 -0
- package/dist/lib/analytics.d.ts.map +1 -1
- package/dist/lib/comparison-contract.d.ts +8 -0
- package/dist/lib/comparison-contract.d.ts.map +1 -0
- package/dist/lib/comparison-test-contract.d.ts +3 -0
- package/dist/lib/comparison-test-contract.d.ts.map +1 -0
- package/dist/lib/store/index.d.ts.map +1 -1
- package/dist/mcp/index.js +69 -11
- package/dist/otel/index.js +1 -1
- package/dist/server/index.js +40 -6
- package/dist/server/service-operations.d.ts.map +1 -1
- package/dist/server/service-worker.js +40 -6
- package/docs/comparison-coverage.md +30 -0
- package/native/README.md +70 -2
- package/native/Tools/pair-qr.py +130 -34
- package/native/Tools/pair-qr.swift +19 -1
- package/package.json +1 -1
package/native/Tools/pair-qr.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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.
|
|
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",
|