akv-manager 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -52,7 +52,7 @@ By default environments are inferred from common keywords (`dev`, `test`, `qa`,
52
52
  convention, map them yourself, e.g.:
53
53
 
54
54
  ```bash
55
- AZBO_ENV_RULES='[["ANDS","Dev"],["ANIS","Int"],["ANQS","QA"],["APPH","Prod"]]' npx akv-manager
55
+ AZBO_ENV_RULES='[["-prd-","Prod"],["-dev-","Dev"],["-tst-","Test"]]' npx akv-manager
56
56
  ```
57
57
 
58
58
  ## Notes on access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akv-manager",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Local web console to browse and manage Azure Key Vaults — secrets, keys, certificates, expiry — using your own az login. No credentials are stored.",
5
5
  "type": "commonjs",
6
6
  "main": "server.js",
package/public/index.html CHANGED
@@ -7,262 +7,34 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
10
- <style>
11
- :root{
12
- --ink:#0A0D14; --ink2:#0C1019;
13
- --panel:#121826; --panel2:#0E141F; --raise:#171F2E;
14
- --line:#222C3E; --line2:#1A2233;
15
- --text:#E6EBF2; --muted:#79859B; --faint:#4C586E;
16
- --azure:#5B9DFF; --azure2:#3F86F0; --azuredim:rgba(91,157,255,.12);
17
- --ok:#46D6B4; --warn:#F2B53D; --alert:#FF5C6C; --bad:#FF5C6C; --good:#46D6B4;
18
- --violet:#A98BFF;
19
- --r:12px; --r-sm:8px;
20
- --shadow:0 18px 40px -22px rgba(0,0,0,.8);
21
- --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
22
- --sans:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,sans-serif;
23
- }
24
- *{box-sizing:border-box}
25
- html,body{margin:0}
26
- body{font-family:var(--sans);font-size:14px;line-height:1.5;color:var(--text);min-height:100vh;
27
- background:
28
- radial-gradient(900px 500px at 88% -8%,rgba(91,157,255,.07),transparent 60%),
29
- linear-gradient(180deg,var(--ink2),var(--ink));
30
- -webkit-font-smoothing:antialiased}
31
- ::selection{background:rgba(91,157,255,.32)}
32
- a{color:var(--azure);text-decoration:none}
33
- :focus-visible{outline:2px solid var(--azure);outline-offset:2px;border-radius:4px}
34
- .mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
35
- .muted{color:var(--muted)} .faint{color:var(--faint)} .right{text-align:right}
36
- .hide{display:none!important}
37
- .eyebrow{font-family:var(--mono);font-size:10.5px;font-weight:500;letter-spacing:.18em;
38
- text-transform:uppercase;color:var(--faint)}
39
-
40
- .app{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
41
-
42
- /* ---------- sidebar ---------- */
43
- aside{background:linear-gradient(180deg,rgba(255,255,255,.015),transparent),var(--panel2);
44
- border-right:1px solid var(--line);padding:22px 14px;display:flex;flex-direction:column;
45
- position:sticky;top:0;height:100vh}
46
- .brand{display:flex;align-items:center;gap:11px;padding:0 6px 20px}
47
- .brand .mark{width:34px;height:34px;flex:none}
48
- .brand .wm{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;color:var(--text);line-height:1.15}
49
- .brand .wm small{display:block;font-size:9.5px;letter-spacing:.22em;color:var(--faint);font-weight:400;margin-top:2px}
50
- .navlabel{font-family:var(--mono);font-size:9.5px;letter-spacing:.2em;color:var(--faint);
51
- text-transform:uppercase;padding:14px 10px 7px}
52
- nav button{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:transparent;
53
- border:0;color:var(--muted);padding:9px 11px;border-radius:var(--r-sm);cursor:pointer;
54
- font-family:var(--sans);font-size:13.5px;font-weight:500;position:relative;transition:.15s}
55
- nav button svg{width:17px;height:17px;flex:none;opacity:.8}
56
- nav button:hover{background:rgba(255,255,255,.03);color:var(--text)}
57
- nav button.active{background:var(--azuredim);color:#fff}
58
- nav button.active svg{opacity:1;color:var(--azure)}
59
- nav button.active::before{content:"";position:absolute;left:-14px;top:7px;bottom:7px;width:3px;
60
- background:var(--azure);border-radius:0 3px 3px 0}
61
- .who{margin-top:auto;font-size:12px;color:var(--muted);border-top:1px solid var(--line2);
62
- padding:14px 8px 4px;line-height:1.6}
63
- .who b{color:var(--text);font-weight:600}
64
- .who .mono{font-size:11px}
65
- .dot{width:7px;height:7px;border-radius:50%;display:inline-block;vertical-align:middle}
66
-
67
- /* ---------- main ---------- */
68
- main{padding:26px 32px 60px;max-width:1560px}
69
- .topbar{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:26px}
70
- h1{font-size:23px;font-weight:600;margin:4px 0 0;letter-spacing:-.01em}
71
- .sub{color:var(--muted);font-size:13px;margin-top:3px;max-width:62ch}
72
- .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
73
-
74
- .btn{font-family:var(--sans);font-weight:500;font-size:13px;border:0;border-radius:var(--r-sm);
75
- padding:9px 15px;cursor:pointer;color:#06101f;background:var(--azure);transition:.15s}
76
- .btn:hover{background:#75AEFF}
77
- .btn:disabled{opacity:.55;cursor:default}
78
- .btn.ghost{background:var(--raise);color:var(--text);border:1px solid var(--line)}
79
- .btn.ghost:hover{border-color:var(--azure);background:var(--raise)}
80
- .btn.sm{padding:5px 11px;font-size:12px}
81
- input.search{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--text);
82
- padding:9px 13px;border-radius:var(--r-sm);min-width:320px;font-size:12.5px}
83
- input.search::placeholder{color:var(--faint)}
84
- input.search:focus{outline:none;border-color:var(--azure);background:var(--panel)}
85
-
86
- /* ---------- KPI cards ---------- */
87
- .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:13px;margin-bottom:24px}
88
- .card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--line);
89
- border-radius:var(--r);padding:15px 17px;position:relative;overflow:hidden}
90
- .card::after{content:"";position:absolute;inset:0 0 auto;height:2px;background:var(--line)}
91
- .card .k{font-family:var(--mono);color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.13em}
92
- .card .v{font-family:var(--mono);font-size:30px;font-weight:600;margin-top:9px;line-height:1;letter-spacing:-.02em}
93
- .card .v small{font-family:var(--sans);font-size:12px;font-weight:400;color:var(--muted);letter-spacing:0}
94
- .card--alert::after{background:var(--alert)} .card--alert .v{color:#FF8893}
95
- .card--warn::after{background:var(--warn)} .card--warn .v{color:#FBC56B}
96
- .card--azure::after{background:var(--azure)} .card--azure .v{color:#8FBEFF}
97
-
98
- /* ---------- chips ---------- */
99
- .chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
100
- .chip{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--muted);
101
- padding:6px 12px;border-radius:7px;cursor:pointer;font-size:11.5px;display:flex;gap:8px;align-items:center;transition:.15s}
102
- .chip:hover{color:var(--text);border-color:var(--line)}
103
- .chip.active{border-color:var(--azure);color:#fff;background:var(--azuredim)}
104
- .chip .n{background:var(--ink);border-radius:5px;padding:1px 7px;font-size:10.5px;color:var(--muted)}
105
- .chip.active .n{color:var(--azure)}
106
-
107
- /* ---------- panel + table ---------- */
108
- .panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
109
- table{width:100%;border-collapse:collapse;font-size:13px}
110
- th,td{padding:11px 16px;text-align:left;border-bottom:1px solid var(--line2);white-space:nowrap}
111
- thead th{position:sticky;top:0;z-index:2;background:var(--panel2);color:var(--muted);font-weight:500;
112
- font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;
113
- cursor:pointer;user-select:none}
114
- thead th:hover{color:var(--text)}
115
- tbody tr{transition:background .12s}
116
- tbody tr:hover{background:rgba(91,157,255,.05)}
117
- tbody tr:last-child td{border-bottom:0}
118
- td.mono,.mono{font-family:var(--mono);font-size:12.5px}
119
- td b{font-weight:600}
120
-
121
- .tag{display:inline-flex;align-items:center;font-family:var(--mono);font-size:10px;font-weight:500;
122
- letter-spacing:.08em;text-transform:uppercase;padding:2px 7px;border-radius:5px;border:1px solid transparent}
123
- .tag.e-dev{color:#7FE0C8;background:rgba(70,214,180,.10);border-color:rgba(70,214,180,.22)}
124
- .tag.e-test{color:#7FD0E0;background:rgba(70,180,214,.10);border-color:rgba(70,180,214,.22)}
125
- .tag.e-qa{color:#93C0FF;background:rgba(91,157,255,.12);border-color:rgba(91,157,255,.24)}
126
- .tag.e-stage{color:#F4C977;background:rgba(242,181,61,.10);border-color:rgba(242,181,61,.22)}
127
- .tag.e-prod{color:#FF9BA4;background:rgba(255,92,108,.10);border-color:rgba(255,92,108,.26)}
128
- .tag.e-sandbox{color:#BCA8FF;background:rgba(169,139,255,.10);border-color:rgba(169,139,255,.22)}
129
- .tag.e-other{color:var(--muted);background:rgba(255,255,255,.04);border-color:var(--line)}
130
-
131
- .pill{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--muted);
132
- border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
133
- .pill.rbac{color:#7FE0C8;border-color:rgba(70,214,180,.3)}
134
- .pill.policy{color:#F4C977;border-color:rgba(242,181,61,.3)}
135
- .sev{font-family:var(--mono);font-size:10px;letter-spacing:.1em;padding:2px 8px;border-radius:5px;border:1px solid}
136
- .sev.expired{color:#FF8893;background:rgba(255,92,108,.12);border-color:rgba(255,92,108,.32)}
137
- .sev.urgent{color:#FBC56B;background:rgba(242,181,61,.12);border-color:rgba(242,181,61,.32)}
138
- .sev.ok{color:var(--muted);border-color:var(--line);background:transparent}
139
-
140
- .iconbtn{font-family:var(--mono);background:var(--raise);border:1px solid var(--line);color:var(--muted);
141
- border-radius:6px;padding:4px 9px;cursor:pointer;font-size:11.5px;transition:.15s}
142
- .iconbtn:hover{color:#fff;border-color:var(--azure)}
143
- .empty{padding:48px 24px;text-align:center;color:var(--muted)}
144
- .scroll{max-height:calc(100vh - 380px);overflow:auto}
145
- .scroll::-webkit-scrollbar{width:10px;height:10px}
146
- .scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px;border:2px solid var(--panel)}
147
- .tablenote{font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:10px}
148
-
149
- /* ---------- signature: expiry horizon ---------- */
150
- .horizon{padding:18px 20px;margin-bottom:22px}
151
- .hz-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px}
152
- .hz-body{display:flex;gap:12px;height:104px}
153
- .hz-expired{position:relative;width:118px;flex:none;border:1px solid rgba(255,92,108,.28);
154
- background:linear-gradient(180deg,rgba(255,92,108,.12),rgba(255,92,108,.02));
155
- border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden}
156
- .hz-expired-n{font-family:var(--mono);font-size:30px;font-weight:600;color:#FF8893;line-height:1}
157
- .hz-expired-l{font-family:var(--mono);font-size:9.5px;letter-spacing:.18em;color:#FF8893;margin-top:3px}
158
- .hz-axis{position:relative;flex:1;border:1px solid var(--line);border-radius:10px;
159
- background:linear-gradient(180deg,rgba(255,255,255,.015),transparent);overflow:hidden}
160
- .hz-grid{position:absolute;top:0;bottom:0;border-left:1px dashed var(--line);}
161
- .hz-grid:first-of-type{border-left-color:rgba(91,157,255,.35)}
162
- .hz-grid span{position:absolute;bottom:7px;left:6px;font-family:var(--mono);font-size:9.5px;color:var(--faint);white-space:nowrap}
163
- .hz-tick{position:absolute;top:12px;bottom:26px;width:2px;border-radius:2px;background:var(--ok);
164
- transform:translateX(-1px);opacity:.9}
165
- .hz-tick.warn{background:var(--warn)} .hz-tick.alert{background:var(--alert)}
166
- .hz-tick:hover{opacity:1;width:3px;box-shadow:0 0 8px currentColor}
167
- .hz-expired .hz-tick{top:auto;bottom:9px;height:16px;opacity:.7}
168
- .hz-legend{display:flex;gap:16px;align-items:center}
169
- .hz-legend span{font-family:var(--mono);font-size:10px;color:var(--muted);display:flex;align-items:center;gap:5px}
170
- .hz-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
171
-
172
- /* ---------- segmented + window ---------- */
173
- .seg{display:inline-flex;gap:4px;background:var(--panel2);border:1px solid var(--line);
174
- border-radius:var(--r-sm);padding:3px}
175
- .seg button{font-family:var(--mono);background:transparent;border:0;color:var(--muted);padding:6px 12px;
176
- border-radius:6px;cursor:pointer;font-size:12px;transition:.15s}
177
- .seg button:hover{color:var(--text)}
178
- .seg button.active{background:var(--raise);color:#fff;box-shadow:0 1px 0 rgba(0,0,0,.4)}
179
-
180
- /* ---------- drawer ---------- */
181
- .overlay{position:fixed;inset:0;background:rgba(5,8,14,.6);backdrop-filter:blur(2px);opacity:0;
182
- pointer-events:none;transition:.25s;z-index:30}
183
- .overlay.show{opacity:1;pointer-events:auto}
184
- .drawer{position:fixed;top:0;right:0;height:100vh;width:100vw;max-width:100vw;background:var(--panel);
185
- border-left:1px solid var(--line);box-shadow:-30px 0 80px -30px rgba(0,0,0,.9);
186
- transform:translateX(100%);transition:transform .28s cubic-bezier(.2,.7,.2,1);z-index:40;
187
- display:flex;flex-direction:column}
188
- .drawer.open{transform:translateX(0)}
189
- .drawer header{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;
190
- justify-content:space-between;align-items:flex-start;gap:14px}
191
- .drawer header .dt{font-family:var(--mono);font-size:16px;font-weight:600;word-break:break-all}
192
- .drawer .body{padding:18px 22px;overflow:auto}
193
- .kvitem{border-bottom:1px solid var(--line2)}
194
- .kvitem:last-child{border-bottom:0}
195
- .kvitem.sel{background:rgba(91,157,255,.06);margin:0 -22px;padding:0 22px}
196
- .kvline{display:flex;align-items:center;gap:10px;padding:10px 0}
197
- .kvline .nm{flex:1 1 auto;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px}
198
- .kvline .meta{display:flex;align-items:center;gap:8px;flex:0 0 auto}
199
- .kvline input[type=checkbox]{flex:0 0 auto}
200
- .revealed{padding:0 0 12px;display:flex;align-items:flex-start;gap:8px}
201
- .revealed code.val{display:block;flex:1;min-width:0;background:var(--ink);border:1px solid var(--line);
202
- border-radius:6px;padding:8px 10px;max-height:120px;overflow:auto}
203
- .cmd{background:var(--ink);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px 12px;
204
- margin:8px 0;font-family:var(--mono);font-size:11.5px;color:#A9D2FF;display:flex;justify-content:space-between;gap:10px;align-items:center}
205
- .secthead{font-family:var(--mono);margin:24px 0 6px;color:var(--faint);font-size:10px;text-transform:uppercase;letter-spacing:.16em}
206
- .loadval{font-family:var(--mono);font-size:11px;color:var(--azure);cursor:pointer;background:none;border:0;text-decoration:underline}
207
- code.val{font-family:var(--mono);user-select:all;color:#A9D2FF;font-size:11.5px;word-break:break-all}
208
- /* drawer edit + batch */
209
- .dtools{display:flex;align-items:center;gap:12px;margin-top:14px}
210
- .dtools .search{min-width:0;flex:1;padding:8px 11px}
211
- .selall{font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap}
212
- input[type=checkbox]{accent-color:var(--azure);width:14px;height:14px;cursor:pointer}
213
- .batchbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px;padding:10px 12px;
214
- background:var(--azuredim);border:1px solid rgba(91,157,255,.4);border-radius:var(--r-sm)}
215
- .batchbar .bn{font-family:var(--mono);font-size:12px;color:#cfe2ff;margin-right:2px}
216
- .batchbar input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);border:1px solid var(--line);
217
- color:var(--text);border-radius:6px;padding:5px 8px;color-scheme:dark}
218
- .editor{margin:0 -22px;padding:14px 22px;background:var(--panel2);border-top:1px solid var(--line2)}
219
- /* create panel */
220
- .createp{margin-top:12px;padding:16px;background:var(--panel2);border:1px solid var(--azure);border-radius:var(--r-sm)}
221
- .createp h4{margin:0 0 12px;font-family:var(--mono);font-size:12px;color:#cfe2ff;font-weight:500;letter-spacing:.04em}
222
- .createp .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
223
- .createp label{font-family:var(--mono);font-size:11px;color:var(--muted);width:72px;flex:none}
224
- .createp input[type=text],.createp input[type=date],.createp textarea{font-family:var(--mono);font-size:12px;
225
- background:var(--ink);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:8px 10px;flex:1;width:100%;color-scheme:dark}
226
- .createp textarea{min-height:120px;resize:vertical;line-height:1.6}
227
- .createp .actions{display:flex;gap:8px;align-items:center;margin-top:6px}
228
- .newbtns{display:flex;gap:6px;margin-left:auto}
229
- .editor .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
230
- .editor label{font-family:var(--mono);font-size:11px;color:var(--muted);width:78px;flex:none}
231
- .editor input[type=text],.editor input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);
232
- border:1px solid var(--line);color:var(--text);border-radius:6px;padding:7px 9px;flex:1;color-scheme:dark}
233
- .editor input[type=date],.createp input[type=date]{flex:0 0 auto;width:200px}
234
- .editor .hint{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
235
- .toggle{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--text);cursor:pointer}
236
-
237
- /* ---------- misc ---------- */
238
- .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
239
- background:var(--raise);border:1px solid var(--azure);color:#fff;padding:10px 18px;
240
- border-radius:var(--r-sm);opacity:0;transition:.25s;z-index:60;font-size:13px}
241
- .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
242
- .banner{background:linear-gradient(180deg,rgba(255,92,108,.14),rgba(255,92,108,.04));
243
- border:1px solid rgba(255,92,108,.4);color:#FFC4C9;padding:13px 16px;border-radius:var(--r);
244
- margin-bottom:18px;display:none;font-size:13px}
245
- .banner.show{display:block}
246
- .banner code{font-family:var(--mono);background:rgba(0,0,0,.3);padding:1px 6px;border-radius:4px;color:#fff}
247
- .spin{width:18px;height:18px;border:2px solid var(--line);border-top-color:var(--azure);border-radius:50%;
248
- display:inline-block;animation:spin .7s linear infinite;vertical-align:-4px}
249
- @keyframes spin{to{transform:rotate(360deg)}}
250
- .progress{width:64%;max-width:380px;height:6px;background:var(--ink);border:1px solid var(--line);
251
- border-radius:999px;margin:16px auto 0;overflow:hidden}
252
- .progress .bar{height:100%;background:linear-gradient(90deg,var(--azure),var(--ok));border-radius:999px;transition:width .4s}
253
-
254
- @media (max-width:900px){
255
- .app{grid-template-columns:1fr}
256
- aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
257
- aside nav{display:flex;gap:4px;flex-wrap:wrap} .navlabel{display:none} .who{display:none}
258
- main{padding:20px}
259
- input.search{min-width:180px}
260
- }
261
- @media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition:none!important}}
262
- </style>
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <script>
12
+ // Tailwind Play CDN, themed to the design tokens. Preflight is OFF so the
13
+ // hand-authored component layer in styles.css stays authoritative.
14
+ tailwind.config = {
15
+ corePlugins: { preflight: false },
16
+ theme: {
17
+ extend: {
18
+ colors: {
19
+ ink: '#0A0D14', 'ink-2': '#0C1019',
20
+ panel: '#121826', 'panel-2': '#0E141F', raise: '#171F2E',
21
+ line: '#222C3E', 'line-2': '#1A2233',
22
+ text: '#E6EBF2', muted: '#79859B', faint: '#4C586E',
23
+ azure: '#5B9DFF', azure2: '#3F86F0',
24
+ ok: '#46D6B4', warn: '#F2B53D', alert: '#FF5C6C', violet: '#A98BFF',
25
+ },
26
+ fontFamily: {
27
+ sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'],
28
+ mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
29
+ },
30
+ },
31
+ },
32
+ };
33
+ </script>
34
+ <link rel="stylesheet" href="/styles.css">
263
35
  </head>
264
36
  <body>
265
- <div class="app">
37
+ <div class="grid min-h-screen grid-cols-[236px_1fr] max-[900px]:grid-cols-1">
266
38
  <aside>
267
39
  <div class="brand">
268
40
  <svg class="mark" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -292,12 +64,13 @@
292
64
  Expiry</button>
293
65
  </nav>
294
66
  <div class="who" id="who"><span class="spin"></span> connecting…</div>
67
+ <div class="ver mono faint" id="ver"></div>
295
68
  </aside>
296
69
 
297
- <main>
70
+ <main class="max-w-[1560px] px-8 pt-[26px] pb-[60px] max-[900px]:p-5">
298
71
  <div class="banner" id="banner"></div>
299
72
 
300
- <div class="topbar">
73
+ <div class="flex items-end justify-between gap-[18px] mb-[26px]">
301
74
  <div>
302
75
  <div class="eyebrow" id="eyebrow">Overview</div>
303
76
  <h1 id="title">Overview</h1>
@@ -442,10 +215,14 @@ function subName(id){ return (SUBMETA[id]&&SUBMETA[id].name)||id; }
442
215
  async function boot(){
443
216
  const cfg = await api('/api/config'); if(cfg.ok){ CFG = {...CFG, ...cfg.data};
444
217
  if(CFG.title){ document.title = CFG.title; const wm=document.querySelector('.brand .wm'); if(wm) wm.firstChild.textContent = CFG.title; } }
218
+ $('#ver').textContent = 'akv-manager v' + (CFG.version || '?');
445
219
  const acc = await api('/api/account');
446
220
  if(!acc.ok){
447
- $('#who').innerHTML = '<span class="dot" style="background:var(--alert)"></span> not signed in';
448
- banner('Azure CLI is not signed in. Run <code>az login</code> in your terminal, then press Refresh.');
221
+ $('#who').innerHTML = '<span class="dot" style="background:var(--alert)"></span> not connected';
222
+ const detail = acc.error ? `<div class="hint" style="margin-top:6px">${acc.error}</div>` : '';
223
+ banner((acc.needLogin
224
+ ? 'Azure CLI is signed out. Run <code>az login</code>, then press Refresh.'
225
+ : 'Could not reach the Azure CLI. Make sure <code>az</code> is installed and on your PATH (on Windows it is <code>az.cmd</code>), then press Refresh.') + detail);
449
226
  return;
450
227
  }
451
228
  STATE.account = acc.data;
@@ -0,0 +1,249 @@
1
+ :root{
2
+ --ink:#0A0D14; --ink2:#0C1019;
3
+ --panel:#121826; --panel2:#0E141F; --raise:#171F2E;
4
+ --line:#222C3E; --line2:#1A2233;
5
+ --text:#E6EBF2; --muted:#79859B; --faint:#4C586E;
6
+ --azure:#5B9DFF; --azure2:#3F86F0; --azuredim:rgba(91,157,255,.12);
7
+ --ok:#46D6B4; --warn:#F2B53D; --alert:#FF5C6C; --bad:#FF5C6C; --good:#46D6B4;
8
+ --violet:#A98BFF;
9
+ --r:12px; --r-sm:8px;
10
+ --shadow:0 18px 40px -22px rgba(0,0,0,.8);
11
+ --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
12
+ --sans:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,sans-serif;
13
+ }
14
+ *{box-sizing:border-box}
15
+ html,body{margin:0}
16
+ body{font-family:var(--sans);font-size:14px;line-height:1.5;color:var(--text);min-height:100vh;
17
+ background:
18
+ radial-gradient(900px 500px at 88% -8%,rgba(91,157,255,.07),transparent 60%),
19
+ linear-gradient(180deg,var(--ink2),var(--ink));
20
+ -webkit-font-smoothing:antialiased}
21
+ ::selection{background:rgba(91,157,255,.32)}
22
+ a{color:var(--azure);text-decoration:none}
23
+ :focus-visible{outline:2px solid var(--azure);outline-offset:2px;border-radius:4px}
24
+ .mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
25
+ .muted{color:var(--muted)} .faint{color:var(--faint)} .right{text-align:right}
26
+ .hide{display:none!important}
27
+ .eyebrow{font-family:var(--mono);font-size:10.5px;font-weight:500;letter-spacing:.18em;
28
+ text-transform:uppercase;color:var(--faint)}
29
+
30
+ /* page skeleton (.app grid, main, .topbar) now uses Tailwind utilities in index.html */
31
+
32
+ /* ---------- sidebar ---------- */
33
+ aside{background:linear-gradient(180deg,rgba(255,255,255,.015),transparent),var(--panel2);
34
+ border-right:1px solid var(--line);padding:22px 14px;display:flex;flex-direction:column;
35
+ position:sticky;top:0;height:100vh}
36
+ .brand{display:flex;align-items:center;gap:11px;padding:0 6px 20px}
37
+ .brand .mark{width:34px;height:34px;flex:none}
38
+ .brand .wm{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;color:var(--text);line-height:1.15}
39
+ .brand .wm small{display:block;font-size:9.5px;letter-spacing:.22em;color:var(--faint);font-weight:400;margin-top:2px}
40
+ .navlabel{font-family:var(--mono);font-size:9.5px;letter-spacing:.2em;color:var(--faint);
41
+ text-transform:uppercase;padding:14px 10px 7px}
42
+ nav button{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:transparent;
43
+ border:0;color:var(--muted);padding:9px 11px;border-radius:var(--r-sm);cursor:pointer;
44
+ font-family:var(--sans);font-size:13.5px;font-weight:500;position:relative;transition:.15s}
45
+ nav button svg{width:17px;height:17px;flex:none;opacity:.8}
46
+ nav button:hover{background:rgba(255,255,255,.03);color:var(--text)}
47
+ nav button.active{background:var(--azuredim);color:#fff}
48
+ nav button.active svg{opacity:1;color:var(--azure)}
49
+ nav button.active::before{content:"";position:absolute;left:-14px;top:7px;bottom:7px;width:3px;
50
+ background:var(--azure);border-radius:0 3px 3px 0}
51
+ .who{margin-top:auto;font-size:12px;color:var(--muted);border-top:1px solid var(--line2);
52
+ padding:14px 8px 4px;line-height:1.6}
53
+ .who b{color:var(--text);font-weight:600}
54
+ .who .mono{font-size:11px}
55
+ .dot{width:7px;height:7px;border-radius:50%;display:inline-block;vertical-align:middle}
56
+ .ver{font-size:10px;letter-spacing:.04em;padding:8px 8px 2px}
57
+
58
+ /* ---------- main ---------- */
59
+ h1{font-size:23px;font-weight:600;margin:4px 0 0;letter-spacing:-.01em}
60
+ .sub{color:var(--muted);font-size:13px;margin-top:3px;max-width:62ch}
61
+ .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
62
+
63
+ .btn{font-family:var(--sans);font-weight:500;font-size:13px;border:0;border-radius:var(--r-sm);
64
+ padding:9px 15px;cursor:pointer;color:#06101f;background:var(--azure);transition:.15s}
65
+ .btn:hover{background:#75AEFF}
66
+ .btn:disabled{opacity:.55;cursor:default}
67
+ .btn.ghost{background:var(--raise);color:var(--text);border:1px solid var(--line)}
68
+ .btn.ghost:hover{border-color:var(--azure);background:var(--raise)}
69
+ .btn.sm{padding:5px 11px;font-size:12px}
70
+ input.search{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--text);
71
+ padding:9px 13px;border-radius:var(--r-sm);min-width:320px;font-size:12.5px}
72
+ input.search::placeholder{color:var(--faint)}
73
+ input.search:focus{outline:none;border-color:var(--azure);background:var(--panel)}
74
+
75
+ /* ---------- KPI cards ---------- */
76
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:13px;margin-bottom:24px}
77
+ .card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--line);
78
+ border-radius:var(--r);padding:15px 17px;position:relative;overflow:hidden}
79
+ .card::after{content:"";position:absolute;inset:0 0 auto;height:2px;background:var(--line)}
80
+ .card .k{font-family:var(--mono);color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.13em}
81
+ .card .v{font-family:var(--mono);font-size:30px;font-weight:600;margin-top:9px;line-height:1;letter-spacing:-.02em}
82
+ .card .v small{font-family:var(--sans);font-size:12px;font-weight:400;color:var(--muted);letter-spacing:0}
83
+ .card--alert::after{background:var(--alert)} .card--alert .v{color:#FF8893}
84
+ .card--warn::after{background:var(--warn)} .card--warn .v{color:#FBC56B}
85
+ .card--azure::after{background:var(--azure)} .card--azure .v{color:#8FBEFF}
86
+
87
+ /* ---------- chips ---------- */
88
+ .chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
89
+ .chip{font-family:var(--mono);background:var(--panel2);border:1px solid var(--line);color:var(--muted);
90
+ padding:6px 12px;border-radius:7px;cursor:pointer;font-size:11.5px;display:flex;gap:8px;align-items:center;transition:.15s}
91
+ .chip:hover{color:var(--text);border-color:var(--line)}
92
+ .chip.active{border-color:var(--azure);color:#fff;background:var(--azuredim)}
93
+ .chip .n{background:var(--ink);border-radius:5px;padding:1px 7px;font-size:10.5px;color:var(--muted)}
94
+ .chip.active .n{color:var(--azure)}
95
+
96
+ /* ---------- panel + table ---------- */
97
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
98
+ table{width:100%;border-collapse:collapse;font-size:13px}
99
+ th,td{padding:11px 16px;text-align:left;border-bottom:1px solid var(--line2);white-space:nowrap}
100
+ thead th{position:sticky;top:0;z-index:2;background:var(--panel2);color:var(--muted);font-weight:500;
101
+ font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;
102
+ cursor:pointer;user-select:none}
103
+ thead th:hover{color:var(--text)}
104
+ tbody tr{transition:background .12s}
105
+ tbody tr:hover{background:rgba(91,157,255,.05)}
106
+ tbody tr:last-child td{border-bottom:0}
107
+ td.mono,.mono{font-family:var(--mono);font-size:12.5px}
108
+ td b{font-weight:600}
109
+
110
+ .tag{display:inline-flex;align-items:center;font-family:var(--mono);font-size:10px;font-weight:500;
111
+ letter-spacing:.08em;text-transform:uppercase;padding:2px 7px;border-radius:5px;border:1px solid transparent}
112
+ .tag.e-dev{color:#7FE0C8;background:rgba(70,214,180,.10);border-color:rgba(70,214,180,.22)}
113
+ .tag.e-test{color:#7FD0E0;background:rgba(70,180,214,.10);border-color:rgba(70,180,214,.22)}
114
+ .tag.e-qa{color:#93C0FF;background:rgba(91,157,255,.12);border-color:rgba(91,157,255,.24)}
115
+ .tag.e-stage{color:#F4C977;background:rgba(242,181,61,.10);border-color:rgba(242,181,61,.22)}
116
+ .tag.e-prod{color:#FF9BA4;background:rgba(255,92,108,.10);border-color:rgba(255,92,108,.26)}
117
+ .tag.e-sandbox{color:#BCA8FF;background:rgba(169,139,255,.10);border-color:rgba(169,139,255,.22)}
118
+ .tag.e-other{color:var(--muted);background:rgba(255,255,255,.04);border-color:var(--line)}
119
+
120
+ .pill{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--muted);
121
+ border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
122
+ .pill.rbac{color:#7FE0C8;border-color:rgba(70,214,180,.3)}
123
+ .pill.policy{color:#F4C977;border-color:rgba(242,181,61,.3)}
124
+ .sev{font-family:var(--mono);font-size:10px;letter-spacing:.1em;padding:2px 8px;border-radius:5px;border:1px solid}
125
+ .sev.expired{color:#FF8893;background:rgba(255,92,108,.12);border-color:rgba(255,92,108,.32)}
126
+ .sev.urgent{color:#FBC56B;background:rgba(242,181,61,.12);border-color:rgba(242,181,61,.32)}
127
+ .sev.ok{color:var(--muted);border-color:var(--line);background:transparent}
128
+
129
+ .iconbtn{font-family:var(--mono);background:var(--raise);border:1px solid var(--line);color:var(--muted);
130
+ border-radius:6px;padding:4px 9px;cursor:pointer;font-size:11.5px;transition:.15s}
131
+ .iconbtn:hover{color:#fff;border-color:var(--azure)}
132
+ .empty{padding:48px 24px;text-align:center;color:var(--muted)}
133
+ .scroll{max-height:calc(100vh - 380px);overflow:auto}
134
+ .scroll::-webkit-scrollbar{width:10px;height:10px}
135
+ .scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px;border:2px solid var(--panel)}
136
+ .tablenote{font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:10px}
137
+
138
+ /* ---------- signature: expiry horizon ---------- */
139
+ .horizon{padding:18px 20px;margin-bottom:22px}
140
+ .hz-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px}
141
+ .hz-body{display:flex;gap:12px;height:104px}
142
+ .hz-expired{position:relative;width:118px;flex:none;border:1px solid rgba(255,92,108,.28);
143
+ background:linear-gradient(180deg,rgba(255,92,108,.12),rgba(255,92,108,.02));
144
+ border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden}
145
+ .hz-expired-n{font-family:var(--mono);font-size:30px;font-weight:600;color:#FF8893;line-height:1}
146
+ .hz-expired-l{font-family:var(--mono);font-size:9.5px;letter-spacing:.18em;color:#FF8893;margin-top:3px}
147
+ .hz-axis{position:relative;flex:1;border:1px solid var(--line);border-radius:10px;
148
+ background:linear-gradient(180deg,rgba(255,255,255,.015),transparent);overflow:hidden}
149
+ .hz-grid{position:absolute;top:0;bottom:0;border-left:1px dashed var(--line);}
150
+ .hz-grid:first-of-type{border-left-color:rgba(91,157,255,.35)}
151
+ .hz-grid span{position:absolute;bottom:7px;left:6px;font-family:var(--mono);font-size:9.5px;color:var(--faint);white-space:nowrap}
152
+ .hz-tick{position:absolute;top:12px;bottom:26px;width:2px;border-radius:2px;background:var(--ok);
153
+ transform:translateX(-1px);opacity:.9}
154
+ .hz-tick.warn{background:var(--warn)} .hz-tick.alert{background:var(--alert)}
155
+ .hz-tick:hover{opacity:1;width:3px;box-shadow:0 0 8px currentColor}
156
+ .hz-expired .hz-tick{top:auto;bottom:9px;height:16px;opacity:.7}
157
+ .hz-legend{display:flex;gap:16px;align-items:center}
158
+ .hz-legend span{font-family:var(--mono);font-size:10px;color:var(--muted);display:flex;align-items:center;gap:5px}
159
+ .hz-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
160
+
161
+ /* ---------- segmented + window ---------- */
162
+ .seg{display:inline-flex;gap:4px;background:var(--panel2);border:1px solid var(--line);
163
+ border-radius:var(--r-sm);padding:3px}
164
+ .seg button{font-family:var(--mono);background:transparent;border:0;color:var(--muted);padding:6px 12px;
165
+ border-radius:6px;cursor:pointer;font-size:12px;transition:.15s}
166
+ .seg button:hover{color:var(--text)}
167
+ .seg button.active{background:var(--raise);color:#fff;box-shadow:0 1px 0 rgba(0,0,0,.4)}
168
+
169
+ /* ---------- drawer ---------- */
170
+ .overlay{position:fixed;inset:0;background:rgba(5,8,14,.6);backdrop-filter:blur(2px);opacity:0;
171
+ pointer-events:none;transition:.25s;z-index:30}
172
+ .overlay.show{opacity:1;pointer-events:auto}
173
+ .drawer{position:fixed;top:0;right:0;height:100vh;width:100vw;max-width:100vw;background:var(--panel);
174
+ border-left:1px solid var(--line);box-shadow:-30px 0 80px -30px rgba(0,0,0,.9);
175
+ transform:translateX(100%);transition:transform .28s cubic-bezier(.2,.7,.2,1);z-index:40;
176
+ display:flex;flex-direction:column}
177
+ .drawer.open{transform:translateX(0)}
178
+ .drawer header{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;
179
+ justify-content:space-between;align-items:flex-start;gap:14px}
180
+ .drawer header .dt{font-family:var(--mono);font-size:16px;font-weight:600;word-break:break-all}
181
+ .drawer .body{padding:18px 22px;overflow:auto}
182
+ .kvitem{border-bottom:1px solid var(--line2)}
183
+ .kvitem:last-child{border-bottom:0}
184
+ .kvitem.sel{background:rgba(91,157,255,.06);margin:0 -22px;padding:0 22px}
185
+ .kvline{display:flex;align-items:center;gap:10px;padding:10px 0}
186
+ .kvline .nm{flex:1 1 auto;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:12.5px}
187
+ .kvline .meta{display:flex;align-items:center;gap:8px;flex:0 0 auto}
188
+ .kvline input[type=checkbox]{flex:0 0 auto}
189
+ .revealed{padding:0 0 12px;display:flex;align-items:flex-start;gap:8px}
190
+ .revealed code.val{display:block;flex:1;min-width:0;background:var(--ink);border:1px solid var(--line);
191
+ border-radius:6px;padding:8px 10px;max-height:120px;overflow:auto}
192
+ .cmd{background:var(--ink);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px 12px;
193
+ margin:8px 0;font-family:var(--mono);font-size:11.5px;color:#A9D2FF;display:flex;justify-content:space-between;gap:10px;align-items:center}
194
+ .secthead{font-family:var(--mono);margin:24px 0 6px;color:var(--faint);font-size:10px;text-transform:uppercase;letter-spacing:.16em}
195
+ .loadval{font-family:var(--mono);font-size:11px;color:var(--azure);cursor:pointer;background:none;border:0;text-decoration:underline}
196
+ code.val{font-family:var(--mono);user-select:all;color:#A9D2FF;font-size:11.5px;word-break:break-all}
197
+ /* drawer edit + batch */
198
+ .dtools{display:flex;align-items:center;gap:12px;margin-top:14px}
199
+ .dtools .search{min-width:0;flex:1;padding:8px 11px}
200
+ .selall{font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap}
201
+ input[type=checkbox]{accent-color:var(--azure);width:14px;height:14px;cursor:pointer}
202
+ .batchbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px;padding:10px 12px;
203
+ background:var(--azuredim);border:1px solid rgba(91,157,255,.4);border-radius:var(--r-sm)}
204
+ .batchbar .bn{font-family:var(--mono);font-size:12px;color:#cfe2ff;margin-right:2px}
205
+ .batchbar input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);border:1px solid var(--line);
206
+ color:var(--text);border-radius:6px;padding:5px 8px;color-scheme:dark}
207
+ .editor{margin:0 -22px;padding:14px 22px;background:var(--panel2);border-top:1px solid var(--line2)}
208
+ /* create panel */
209
+ .createp{margin-top:12px;padding:16px;background:var(--panel2);border:1px solid var(--azure);border-radius:var(--r-sm)}
210
+ .createp h4{margin:0 0 12px;font-family:var(--mono);font-size:12px;color:#cfe2ff;font-weight:500;letter-spacing:.04em}
211
+ .createp .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
212
+ .createp label{font-family:var(--mono);font-size:11px;color:var(--muted);width:72px;flex:none}
213
+ .createp input[type=text],.createp input[type=date],.createp textarea{font-family:var(--mono);font-size:12px;
214
+ background:var(--ink);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:8px 10px;flex:1;width:100%;color-scheme:dark}
215
+ .createp textarea{min-height:120px;resize:vertical;line-height:1.6}
216
+ .createp .actions{display:flex;gap:8px;align-items:center;margin-top:6px}
217
+ .newbtns{display:flex;gap:6px;margin-left:auto}
218
+ .editor .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
219
+ .editor label{font-family:var(--mono);font-size:11px;color:var(--muted);width:78px;flex:none}
220
+ .editor input[type=text],.editor input[type=date]{font-family:var(--mono);font-size:12px;background:var(--ink);
221
+ border:1px solid var(--line);color:var(--text);border-radius:6px;padding:7px 9px;flex:1;color-scheme:dark}
222
+ .editor input[type=date],.createp input[type=date]{flex:0 0 auto;width:200px}
223
+ .editor .hint{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
224
+ .toggle{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--text);cursor:pointer}
225
+
226
+ /* ---------- misc ---------- */
227
+ .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
228
+ background:var(--raise);border:1px solid var(--azure);color:#fff;padding:10px 18px;
229
+ border-radius:var(--r-sm);opacity:0;transition:.25s;z-index:60;font-size:13px}
230
+ .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
231
+ .banner{background:linear-gradient(180deg,rgba(255,92,108,.14),rgba(255,92,108,.04));
232
+ border:1px solid rgba(255,92,108,.4);color:#FFC4C9;padding:13px 16px;border-radius:var(--r);
233
+ margin-bottom:18px;display:none;font-size:13px}
234
+ .banner.show{display:block}
235
+ .banner code{font-family:var(--mono);background:rgba(0,0,0,.3);padding:1px 6px;border-radius:4px;color:#fff}
236
+ .spin{width:18px;height:18px;border:2px solid var(--line);border-top-color:var(--azure);border-radius:50%;
237
+ display:inline-block;animation:spin .7s linear infinite;vertical-align:-4px}
238
+ @keyframes spin{to{transform:rotate(360deg)}}
239
+ .progress{width:64%;max-width:380px;height:6px;background:var(--ink);border:1px solid var(--line);
240
+ border-radius:999px;margin:16px auto 0;overflow:hidden}
241
+ .progress .bar{height:100%;background:linear-gradient(90deg,var(--azure),var(--ok));border-radius:999px;transition:width .4s}
242
+
243
+ @media (max-width:900px){
244
+ /* .app grid + main padding handled by Tailwind max-[900px]: utilities */
245
+ aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
246
+ aside nav{display:flex;gap:4px;flex-wrap:wrap} .navlabel{display:none} .who{display:none}
247
+ input.search{min-width:180px}
248
+ }
249
+ @media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition:none!important}}
package/server.js CHANGED
@@ -31,17 +31,25 @@ const SUB_RE = /^[0-9a-fA-F-]{1,40}$|^[\w .()-]{1,80}$/; // sub id or display na
31
31
 
32
32
  function isValidName(v) { return typeof v === 'string' && NAME_RE.test(v); }
33
33
 
34
- /** Run az with an args array (no shell => no injection). Returns parsed JSON. */
34
+ /** Run az with an args array. Returns parsed JSON.
35
+ * az() only runs trusted, fixed commands (get-access-token / logout) — never user
36
+ * input — so enabling the shell on Windows (required to launch az.cmd) is safe here. */
35
37
  function az(args, { json = true } = {}) {
36
38
  const finalArgs = json ? [...args, '--output', 'json'] : args;
39
+ const isWin = process.platform === 'win32';
37
40
  return new Promise((resolve, reject) => {
38
- execFile(AZ, finalArgs, { timeout: AZ_TIMEOUT, maxBuffer: 64 * 1024 * 1024 },
39
- (err, stdout, stderr) => {
41
+ execFile(AZ, finalArgs, {
42
+ timeout: AZ_TIMEOUT, maxBuffer: 64 * 1024 * 1024,
43
+ shell: isWin, // on Windows the CLI is `az.cmd`; execFile can't run it without a shell
44
+ windowsHide: true,
45
+ }, (err, stdout, stderr) => {
40
46
  if (err) {
41
47
  const msg = (stderr || err.message || '').trim();
42
- return reject(Object.assign(new Error(msg || 'az command failed'), {
43
- azError: true,
44
- needLogin: /AADSTS70043|refresh token|az login|InvalidAuthenticationToken|expired/i.test(msg),
48
+ const notFound = err.code === 'ENOENT' || /not recognized|cannot find the (path|file)|ENOENT/i.test(msg);
49
+ return reject(Object.assign(new Error(
50
+ notFound ? `Azure CLI not found (tried "${AZ}"). Install the Azure CLI, or set AZ_PATH to its full path.` : (msg || 'az command failed')), {
51
+ azError: true, notFound,
52
+ needLogin: /AADSTS70043|refresh token|please run ['"]?az login|InvalidAuthenticationToken|expired|no subscription|not logged in/i.test(msg),
45
53
  forbidden: /Forbidden|does not have secrets|AccessDenied|not authorized|403/i.test(msg),
46
54
  stderr: msg, args: finalArgs,
47
55
  }));
@@ -524,8 +532,9 @@ const ENV_RULES = (() => {
524
532
  try { return process.env.AZBO_ENV_RULES ? JSON.parse(process.env.AZBO_ENV_RULES) : null; }
525
533
  catch { console.warn(' AZBO_ENV_RULES is not valid JSON — ignoring.'); return null; }
526
534
  })();
535
+ const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
527
536
  app.get('/api/config', (req, res) =>
528
- res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null } }));
537
+ res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null, version: PKG_VERSION } }));
529
538
 
530
539
  function openBrowser(url) {
531
540
  if (process.env.AZBO_NO_OPEN) return;