@nomac/cli 0.2.4 → 0.3.0-beta.1
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/bin/nomac.mjs +4 -1
- package/bin/rsync-ssh.mjs +13 -0
- package/package.json +6 -3
- package/skill/SKILL.md +54 -5
- package/src/api.mjs +10 -4
- package/src/cli.mjs +57 -2
- package/src/hosted-sessions.mjs +171 -0
- package/src/lifecycle-schemas.json +416 -0
- package/src/mac-credits.mjs +10 -0
- package/src/mac-tools.mjs +18 -0
- package/src/mac-transport.mjs +118 -0
- package/src/mcp.mjs +54 -2
- package/src/session-ssh.mjs +135 -0
- package/src/session-store.mjs +45 -0
- package/src/session-transfer.mjs +215 -0
- package/src/sessions.mjs +144 -0
- package/src/store-upload.mjs +127 -0
- package/src/transport-command.mjs +91 -0
- package/src/upload.mjs +1 -1
|
@@ -1,4 +1,420 @@
|
|
|
1
1
|
{
|
|
2
|
+
"get_store_capabilities": {
|
|
3
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"category": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"maxLength": 80
|
|
9
|
+
},
|
|
10
|
+
"operation": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"maxLength": 160
|
|
13
|
+
},
|
|
14
|
+
"search": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"maxLength": 100
|
|
17
|
+
},
|
|
18
|
+
"offset": {
|
|
19
|
+
"type": "integer",
|
|
20
|
+
"minimum": 0,
|
|
21
|
+
"maximum": 1000
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
},
|
|
26
|
+
"read_store": {
|
|
27
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"operation": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"minLength": 1,
|
|
33
|
+
"maxLength": 160
|
|
34
|
+
},
|
|
35
|
+
"scope": {
|
|
36
|
+
"maxItems": 8,
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"relationship": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"maxLength": 100,
|
|
44
|
+
"pattern": "^[A-Za-z][A-Za-z0-9]*$"
|
|
45
|
+
},
|
|
46
|
+
"id": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"minLength": 1,
|
|
49
|
+
"maxLength": 512
|
|
50
|
+
},
|
|
51
|
+
"query": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"propertyNames": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"maxLength": 120
|
|
56
|
+
},
|
|
57
|
+
"additionalProperties": {
|
|
58
|
+
"anyOf": [
|
|
59
|
+
{
|
|
60
|
+
"type": "string",
|
|
61
|
+
"maxLength": 4096
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"type": "number"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"type": "boolean"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"maxItems": 200,
|
|
71
|
+
"type": "array",
|
|
72
|
+
"items": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"maxLength": 1024
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"required": [
|
|
82
|
+
"relationship",
|
|
83
|
+
"id"
|
|
84
|
+
],
|
|
85
|
+
"additionalProperties": false
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"query": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"propertyNames": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"maxLength": 120
|
|
93
|
+
},
|
|
94
|
+
"additionalProperties": {
|
|
95
|
+
"anyOf": [
|
|
96
|
+
{
|
|
97
|
+
"type": "string",
|
|
98
|
+
"maxLength": 4096
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"type": "number"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"type": "boolean"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"maxItems": 200,
|
|
108
|
+
"type": "array",
|
|
109
|
+
"items": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"maxLength": 1024
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"cursor": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"maxLength": 4096
|
|
120
|
+
},
|
|
121
|
+
"resource_id": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"minLength": 1,
|
|
124
|
+
"maxLength": 512
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"required": [
|
|
128
|
+
"operation"
|
|
129
|
+
],
|
|
130
|
+
"additionalProperties": false
|
|
131
|
+
},
|
|
132
|
+
"update_store": {
|
|
133
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
134
|
+
"type": "object",
|
|
135
|
+
"properties": {
|
|
136
|
+
"operation": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"minLength": 1,
|
|
139
|
+
"maxLength": 160
|
|
140
|
+
},
|
|
141
|
+
"scope": {
|
|
142
|
+
"maxItems": 8,
|
|
143
|
+
"type": "array",
|
|
144
|
+
"items": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"properties": {
|
|
147
|
+
"relationship": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"maxLength": 100,
|
|
150
|
+
"pattern": "^[A-Za-z][A-Za-z0-9]*$"
|
|
151
|
+
},
|
|
152
|
+
"id": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"minLength": 1,
|
|
155
|
+
"maxLength": 512
|
|
156
|
+
},
|
|
157
|
+
"query": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"propertyNames": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"maxLength": 120
|
|
162
|
+
},
|
|
163
|
+
"additionalProperties": {
|
|
164
|
+
"anyOf": [
|
|
165
|
+
{
|
|
166
|
+
"type": "string",
|
|
167
|
+
"maxLength": 4096
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"type": "number"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"type": "boolean"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"maxItems": 200,
|
|
177
|
+
"type": "array",
|
|
178
|
+
"items": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"maxLength": 1024
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"required": [
|
|
188
|
+
"relationship",
|
|
189
|
+
"id"
|
|
190
|
+
],
|
|
191
|
+
"additionalProperties": false
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
"references": {
|
|
195
|
+
"maxItems": 30,
|
|
196
|
+
"type": "array",
|
|
197
|
+
"items": {
|
|
198
|
+
"maxItems": 8,
|
|
199
|
+
"type": "array",
|
|
200
|
+
"items": {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"properties": {
|
|
203
|
+
"relationship": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"maxLength": 100,
|
|
206
|
+
"pattern": "^[A-Za-z][A-Za-z0-9]*$"
|
|
207
|
+
},
|
|
208
|
+
"id": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"minLength": 1,
|
|
211
|
+
"maxLength": 512
|
|
212
|
+
},
|
|
213
|
+
"query": {
|
|
214
|
+
"type": "object",
|
|
215
|
+
"propertyNames": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"maxLength": 120
|
|
218
|
+
},
|
|
219
|
+
"additionalProperties": {
|
|
220
|
+
"anyOf": [
|
|
221
|
+
{
|
|
222
|
+
"type": "string",
|
|
223
|
+
"maxLength": 4096
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"type": "number"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"type": "boolean"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"maxItems": 200,
|
|
233
|
+
"type": "array",
|
|
234
|
+
"items": {
|
|
235
|
+
"type": "string",
|
|
236
|
+
"maxLength": 1024
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
]
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
"required": [
|
|
244
|
+
"relationship",
|
|
245
|
+
"id"
|
|
246
|
+
],
|
|
247
|
+
"additionalProperties": false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
"body": {
|
|
252
|
+
"type": "object",
|
|
253
|
+
"propertyNames": {
|
|
254
|
+
"type": "string"
|
|
255
|
+
},
|
|
256
|
+
"additionalProperties": {}
|
|
257
|
+
},
|
|
258
|
+
"confirm": {
|
|
259
|
+
"type": "boolean"
|
|
260
|
+
},
|
|
261
|
+
"asset_sha256": {
|
|
262
|
+
"description": "Set by upload_store_asset for exact file-reservation recovery.",
|
|
263
|
+
"type": "string",
|
|
264
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
"required": [
|
|
268
|
+
"operation"
|
|
269
|
+
],
|
|
270
|
+
"additionalProperties": false
|
|
271
|
+
},
|
|
272
|
+
"upload_store_asset": {
|
|
273
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
274
|
+
"type": "object",
|
|
275
|
+
"properties": {
|
|
276
|
+
"operation": {
|
|
277
|
+
"type": "string",
|
|
278
|
+
"minLength": 1,
|
|
279
|
+
"maxLength": 160
|
|
280
|
+
},
|
|
281
|
+
"scope": {
|
|
282
|
+
"maxItems": 8,
|
|
283
|
+
"type": "array",
|
|
284
|
+
"items": {
|
|
285
|
+
"type": "object",
|
|
286
|
+
"properties": {
|
|
287
|
+
"relationship": {
|
|
288
|
+
"type": "string",
|
|
289
|
+
"maxLength": 100,
|
|
290
|
+
"pattern": "^[A-Za-z][A-Za-z0-9]*$"
|
|
291
|
+
},
|
|
292
|
+
"id": {
|
|
293
|
+
"type": "string",
|
|
294
|
+
"minLength": 1,
|
|
295
|
+
"maxLength": 512
|
|
296
|
+
},
|
|
297
|
+
"query": {
|
|
298
|
+
"type": "object",
|
|
299
|
+
"propertyNames": {
|
|
300
|
+
"type": "string",
|
|
301
|
+
"maxLength": 120
|
|
302
|
+
},
|
|
303
|
+
"additionalProperties": {
|
|
304
|
+
"anyOf": [
|
|
305
|
+
{
|
|
306
|
+
"type": "string",
|
|
307
|
+
"maxLength": 4096
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"type": "number"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"type": "boolean"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
"maxItems": 200,
|
|
317
|
+
"type": "array",
|
|
318
|
+
"items": {
|
|
319
|
+
"type": "string",
|
|
320
|
+
"maxLength": 1024
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
"required": [
|
|
328
|
+
"relationship",
|
|
329
|
+
"id"
|
|
330
|
+
],
|
|
331
|
+
"additionalProperties": false
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
"references": {
|
|
335
|
+
"maxItems": 30,
|
|
336
|
+
"type": "array",
|
|
337
|
+
"items": {
|
|
338
|
+
"maxItems": 8,
|
|
339
|
+
"type": "array",
|
|
340
|
+
"items": {
|
|
341
|
+
"type": "object",
|
|
342
|
+
"properties": {
|
|
343
|
+
"relationship": {
|
|
344
|
+
"type": "string",
|
|
345
|
+
"maxLength": 100,
|
|
346
|
+
"pattern": "^[A-Za-z][A-Za-z0-9]*$"
|
|
347
|
+
},
|
|
348
|
+
"id": {
|
|
349
|
+
"type": "string",
|
|
350
|
+
"minLength": 1,
|
|
351
|
+
"maxLength": 512
|
|
352
|
+
},
|
|
353
|
+
"query": {
|
|
354
|
+
"type": "object",
|
|
355
|
+
"propertyNames": {
|
|
356
|
+
"type": "string",
|
|
357
|
+
"maxLength": 120
|
|
358
|
+
},
|
|
359
|
+
"additionalProperties": {
|
|
360
|
+
"anyOf": [
|
|
361
|
+
{
|
|
362
|
+
"type": "string",
|
|
363
|
+
"maxLength": 4096
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"type": "number"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"type": "boolean"
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
"maxItems": 200,
|
|
373
|
+
"type": "array",
|
|
374
|
+
"items": {
|
|
375
|
+
"type": "string",
|
|
376
|
+
"maxLength": 1024
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
"required": [
|
|
384
|
+
"relationship",
|
|
385
|
+
"id"
|
|
386
|
+
],
|
|
387
|
+
"additionalProperties": false
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
"body": {
|
|
392
|
+
"type": "object",
|
|
393
|
+
"propertyNames": {
|
|
394
|
+
"type": "string"
|
|
395
|
+
},
|
|
396
|
+
"additionalProperties": {}
|
|
397
|
+
},
|
|
398
|
+
"confirm": {
|
|
399
|
+
"type": "boolean"
|
|
400
|
+
},
|
|
401
|
+
"asset_sha256": {
|
|
402
|
+
"description": "Set by upload_store_asset for exact file-reservation recovery.",
|
|
403
|
+
"type": "string",
|
|
404
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
405
|
+
},
|
|
406
|
+
"data_base64": {
|
|
407
|
+
"type": "string",
|
|
408
|
+
"minLength": 1,
|
|
409
|
+
"maxLength": 3145728
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
"required": [
|
|
413
|
+
"operation",
|
|
414
|
+
"data_base64"
|
|
415
|
+
],
|
|
416
|
+
"additionalProperties": false
|
|
417
|
+
},
|
|
2
418
|
"update_review": {
|
|
3
419
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
420
|
"type": "object",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { api, CliError } from "./api.mjs";
|
|
3
|
+
export async function creditsCommand(argv, { request = api, print = console.log } = {}) {
|
|
4
|
+
const { values, positionals } = parseArgs({ args: argv, options: { plan: { type: "string" }, "request-key": { type: "string" }, json: { type: "boolean" }, help: { type: "boolean" } }, allowPositionals: true });
|
|
5
|
+
if (values.help) return print("nomac credits [--json]\nnomac credits --plan five|twenty_five --request-key STABLE_KEY\n$5/week for 5 hours or $20/week for 25 hours. Unused hours expire weekly.\nReturns a subscription checkout link; the human reviews and pays. Reuse the key after a lost response.");
|
|
6
|
+
if (positionals.length) throw new CliError("Unexpected credits argument.");
|
|
7
|
+
if (!values.plan) return print(JSON.stringify(await request("GET", "/api/v1/mac/credits"), null, 2));
|
|
8
|
+
if (!["five", "twenty_five"].includes(values.plan) || !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"] ?? "")) throw new CliError("Choose --plan five or twenty_five and supply a stable --request-key.");
|
|
9
|
+
return print(JSON.stringify(await request("POST", "/api/v1/mac/credits/checkout", { idempotencyKey: values["request-key"], body: { plan: values.plan } }), null, 2));
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { api } from "./api.mjs";
|
|
2
|
+
const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
3
|
+
const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
|
|
4
|
+
const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
|
|
5
|
+
const key = { type: "string", pattern: "^[A-Za-z0-9_.:-]{8,128}$" };
|
|
6
|
+
const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
7
|
+
const base = args => `/api/v1/mac/sessions/${encodeURIComponent(args.session_id)}`;
|
|
8
|
+
export const MAC_TOOLS = [
|
|
9
|
+
{ name: "get_mac_checkout", description: "Return a weekly subscription checkout: five is $5/week for 5 hours; twenty_five is $20/week for 25 hours. Renews until canceled; no rollover or overage. The human reviews and subscribes. Reuse request_key after a lost response.", inputSchema: schema({ plan: { type: "string", enum: ["five", "twenty_five"] }, request_key: key }, ["plan", "request_key"]), handler: async ({ plan, request_key }) => text(await api("POST", "/api/v1/mac/credits/checkout", { body: { plan }, idempotencyKey: request_key })) },
|
|
10
|
+
{ name: "get_mac_credits", description: "Read weekly Mac hours, expiry, reservations and concurrency, separate from legacy build subscriptions.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
|
|
11
|
+
{ name: "start_mac", description: "Allocate a full cloud Mac VM. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 86400 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
|
|
12
|
+
{ name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
|
|
13
|
+
{ name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
|
|
14
|
+
{ name: "stop_mac", description: "Stop billing by shutting down the VM and deleting its files. Retrieve files first. Poll get_mac_session until cleanup_confirmed; stopping only acknowledges the request.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("DELETE", base(args))) },
|
|
15
|
+
{ name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Returns a durable job ID; poll get_mac_job and read_mac_output. Reuse request_key and identical arguments after lost responses; never submit a new key blindly. Use bash -lc explicitly for shell syntax.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, ...body }) => text(await api("POST", `${base({ session_id })}/jobs`, { body, idempotencyKey: request_key })) },
|
|
16
|
+
{ name: "get_mac_job", description: "Inspect the existing command without rerunning it. Retain its job ID until completion and retrieve output before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}`)) },
|
|
17
|
+
{ name: "read_mac_output", description: "Read a bounded stdout/stderr chunk by byte cursor. data_base64 preserves binary output. Resume from next_cursor; retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
|
|
18
|
+
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
4
|
+
import { getProxyForUrl } from "proxy-from-env";
|
|
5
|
+
import WebSocket, { createWebSocketStream } from "ws";
|
|
6
|
+
import { CliError } from "./api.mjs";
|
|
7
|
+
|
|
8
|
+
export function sessionId(value) {
|
|
9
|
+
const id = String(value ?? "").replace(/^nomac-/, "");
|
|
10
|
+
if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(id)) throw new CliError("Invalid Mac session ID.", undefined, undefined, "invalid_session");
|
|
11
|
+
return id;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function transportOptions(options = {}) {
|
|
15
|
+
let url;
|
|
16
|
+
try { url = new URL(options.gateway ?? process.env.NOMAC_GATEWAY_URL); }
|
|
17
|
+
catch { throw new CliError("Set NOMAC_GATEWAY_URL to the HTTPS gateway origin.", undefined, undefined, "gateway_required"); }
|
|
18
|
+
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
19
|
+
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
20
|
+
throw new CliError("The gateway must be an HTTPS origin; HTTP is allowed only for a loopback probe.", undefined, undefined, "invalid_gateway");
|
|
21
|
+
}
|
|
22
|
+
const token = options.token ?? process.env.NOMAC_SESSION_TOKEN;
|
|
23
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{32,2048}$/.test(token)) throw new CliError("Set NOMAC_SESSION_TOKEN to a session-scoped access token.", undefined, undefined, "token_required");
|
|
24
|
+
const proxy = options.proxy ?? getProxyForUrl(url.href);
|
|
25
|
+
return { url, token, agent: proxy ? new HttpsProxyAgent(proxy) : undefined, ca: options.ca };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bounded requests deliberately do not require streaming responses or retry mutations. */
|
|
29
|
+
export function macRequest(method, path, { body, raw, requestKey, ...options } = {}) {
|
|
30
|
+
const { url, token, agent, ca } = transportOptions(options);
|
|
31
|
+
if (!path.startsWith("/api/v1/mac/") || path.includes("#")) throw new CliError("Invalid Mac API path.");
|
|
32
|
+
const target = new URL(path, url);
|
|
33
|
+
if (target.origin !== url.origin) throw new CliError("Invalid Mac API origin.");
|
|
34
|
+
const payload = raw ?? (body === undefined ? undefined : Buffer.from(JSON.stringify(body)));
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let settled = false;
|
|
37
|
+
const fail = (code, message, status) => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
reject(new CliError(message, undefined, status, code, requestKey ? { request_key: requestKey } : undefined));
|
|
41
|
+
};
|
|
42
|
+
const request = (target.protocol === "https:" ? https : http).request(target, {
|
|
43
|
+
method, agent, ca, headers: {
|
|
44
|
+
authorization: `Bearer ${token}`,
|
|
45
|
+
...(payload ? { "content-type": raw ? "application/octet-stream" : "application/json", "content-length": payload.length } : {}),
|
|
46
|
+
...(requestKey ? { "idempotency-key": requestKey } : {}),
|
|
47
|
+
},
|
|
48
|
+
}, response => {
|
|
49
|
+
const chunks = []; let bytes = 0;
|
|
50
|
+
response.on("data", chunk => {
|
|
51
|
+
bytes += chunk.length;
|
|
52
|
+
if (bytes > 768 * 1024) {
|
|
53
|
+
fail("response_too_large", "The gateway response exceeded its limit.");
|
|
54
|
+
response.destroy();
|
|
55
|
+
} else chunks.push(chunk);
|
|
56
|
+
});
|
|
57
|
+
response.on("error", () => fail("outcome_unknown", "Response lost. Inspect the job or retry with the same request key."));
|
|
58
|
+
response.on("end", () => {
|
|
59
|
+
if (settled) return;
|
|
60
|
+
let data;
|
|
61
|
+
try { data = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
62
|
+
catch { return fail("bad_response", "The gateway returned an unreadable response. Reconcile before retrying.", response.statusCode); }
|
|
63
|
+
if (response.statusCode < 200 || response.statusCode >= 300) return fail(data.error?.code ?? "gateway_error", data.error?.message ?? "The Mac request failed.", response.statusCode);
|
|
64
|
+
settled = true;
|
|
65
|
+
resolve(data);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
const deadline = setTimeout(() => request.destroy(new Error("deadline")), 15_000);
|
|
69
|
+
request.on("close", () => clearTimeout(deadline));
|
|
70
|
+
request.on("error", () => fail("outcome_unknown", "Connection lost. Inspect the job or retry with the same request key."));
|
|
71
|
+
request.end(payload);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A ProxyCommand: stdout contains only SSH bytes, never status or cost messages. */
|
|
76
|
+
export function tunnel(value, { input = process.stdin, output = process.stdout, ...options } = {}) {
|
|
77
|
+
const id = sessionId(value);
|
|
78
|
+
const { url, token, agent, ca } = transportOptions(options);
|
|
79
|
+
url.pathname = `/api/v1/mac/sessions/${id}/ssh`;
|
|
80
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const ws = new WebSocket(url, {
|
|
83
|
+
agent, ca, headers: { authorization: `Bearer ${token}` },
|
|
84
|
+
handshakeTimeout: 15_000, perMessageDeflate: false, maxPayload: 1024 * 1024,
|
|
85
|
+
followRedirects: false,
|
|
86
|
+
});
|
|
87
|
+
const stream = createWebSocketStream(ws, { highWaterMark: 64 * 1024 });
|
|
88
|
+
let failure, closedCode, settled = false;
|
|
89
|
+
const finish = () => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
input.unpipe(stream); stream.unpipe(output); input.pause();
|
|
93
|
+
input.removeListener("error", fail); output.removeListener("error", fail);
|
|
94
|
+
if (failure || closedCode !== 1000) reject(failure ?? new CliError("SSH tunnel closed unexpectedly.", undefined, undefined, "tunnel_disconnected"));
|
|
95
|
+
else resolve();
|
|
96
|
+
};
|
|
97
|
+
const fail = () => { failure ??= new CliError("SSH tunnel disconnected. Reconnect explicitly; an existing SSH connection cannot be resumed.", undefined, undefined, "tunnel_disconnected"); stream.destroy(); finish(); };
|
|
98
|
+
stream.on("error", fail);
|
|
99
|
+
input.on("error", fail);
|
|
100
|
+
output.on("error", fail);
|
|
101
|
+
ws.on("unexpected-response", (_req, response) => {
|
|
102
|
+
failure = new CliError(`SSH tunnel refused (HTTP ${response.statusCode}).`, undefined, response.statusCode, "tunnel_refused");
|
|
103
|
+
response.resume();
|
|
104
|
+
ws.terminate();
|
|
105
|
+
});
|
|
106
|
+
ws.on("error", fail);
|
|
107
|
+
ws.on("close", code => {
|
|
108
|
+
closedCode = code;
|
|
109
|
+
input.unpipe(stream); input.pause();
|
|
110
|
+
if (code !== 1000 || failure) { stream.destroy(); finish(); }
|
|
111
|
+
});
|
|
112
|
+
// A close frame can arrive while the consumer is backpressured. Drain all
|
|
113
|
+
// buffered SSH bytes and the last output write before resolving.
|
|
114
|
+
stream.on("end", () => output.write(Buffer.alloc(0), error => { if (error) fail(); else finish(); }));
|
|
115
|
+
stream.pipe(output, { end: false });
|
|
116
|
+
input.pipe(stream);
|
|
117
|
+
});
|
|
118
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { api } from "./api.mjs";
|
|
|
10
10
|
import { loadProjectState, saveProjectState } from "./config.mjs";
|
|
11
11
|
import { packTarball } from "./pack.mjs";
|
|
12
12
|
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
13
|
+
import { uploadStoreAsset } from "./store-upload.mjs";
|
|
14
|
+
import { MAC_TOOLS } from "./mac-tools.mjs";
|
|
13
15
|
|
|
14
16
|
const PROTOCOL_FALLBACK = "2025-06-18";
|
|
15
17
|
|
|
@@ -26,6 +28,55 @@ async function resolveProjectId(args) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const TOOLS = [
|
|
31
|
+
...MAC_TOOLS,
|
|
32
|
+
{
|
|
33
|
+
name: "check_project", description: "Free source-readiness check of the latest uploaded snapshot. No Apple credentials or paid compute. Run push_project first or after code changes; this does not prove compilation or TestFlight delivery.",
|
|
34
|
+
inputSchema: { type: "object", properties: { project_id: { type: "string" } }, additionalProperties: false },
|
|
35
|
+
handler: async args => text(await api("POST", "/api/v1/check-project", { body: { project_id: await resolveProjectId(args) } })),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "get_billing_status", description: "Read confirmed payment, contracted prices, quota/reset, renewal and first-purchase refund eligibility. After payment, resume a blocked build once with a fresh request_key only if the earlier response said build_started:false. Reconcile unknown outcomes first.",
|
|
39
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
40
|
+
handler: async () => text(await api("GET", "/api/v1/billing/status")),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "get_checkout_link", description: "Return an org-bound checkout link; the human chooses a plan and pays explicitly. This tool does not charge them. Reuses pending checkout; existing subscribers get billing management instead.",
|
|
44
|
+
inputSchema: { type: "object", properties: { plan: { type: "string", enum: ["starter", "pro"] } }, additionalProperties: false },
|
|
45
|
+
handler: async args => text(await api("POST", "/api/v1/billing/checkout", { body: args })),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "request_refund", description: "Preview the first-purchase refund amount and subscription cancellation. Default confirm:false. Present the quote and effect to the customer; only after explicit authorization for both actions call with confirm:true and its request_id. A build failure alone does not authorize a refund. Reason is optional.",
|
|
49
|
+
inputSchema: { type: "object", properties: { confirm: { type: "boolean", default: false }, request_id: { type: "string" }, reason: { type: "string", maxLength: 1000 } }, additionalProperties: false },
|
|
50
|
+
handler: async args => text(await api("POST", "/api/v1/billing/refund", { body: args })),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "get_refund_status", description: "Read the saved refund and cancellation progress. Does not submit a refund or cancel anything.",
|
|
54
|
+
inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
|
|
55
|
+
handler: async args => text(await api("GET", `/api/v1/billing/refund?request_id=${encodeURIComponent(args.request_id)}`)),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "upload_store_asset",
|
|
59
|
+
description: "Upload a new Apple screenshot, preview, purchase image, supplemental reviewer attachment or encryption document from file_path (up to 500 MiB, subject to Apple limits) or data_base64 (2 MiB). Discover the createInstance operation/body and parent scope with get_store_capabilities/read_store. Preview by default; confirm:true reserves, uploads and commits. Preserve request_key and identical bytes/body to resume. ready:true requires Apple processing COMPLETE. Message attachments require App Store Connect.",
|
|
60
|
+
inputSchema: { ...lifecycleSchemas.upload_store_asset, required: ["operation"], properties: { ...lifecycleSchemas.upload_store_asset.properties, project_id: { type: "string" }, file_path: { type: "string" }, request_key: { type: "string" } } },
|
|
61
|
+
handler: async args => text(await uploadStoreAsset({ ...args, project_id: await resolveProjectId(args) }, api)),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "get_store_capabilities",
|
|
65
|
+
description: "Discover useful App Store Connect operations: listing, paid pricing/availability, IAPs/subscriptions/offers, public customer-review replies, phased releases, marketing, files, analytics and webhooks. Pass category/search to list; pass operation for the exact Apple JSON:API request schema. Reviewer correspondence is a separate browser workflow.",
|
|
66
|
+
inputSchema: lifecycleSchemas.get_store_capabilities,
|
|
67
|
+
handler: async args => text(await api("GET", `/api/v1/store-capabilities?${new URLSearchParams(Object.entries(args ?? {}).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]))}`)),
|
|
68
|
+
},
|
|
69
|
+
...[
|
|
70
|
+
["read_store", "read", "Read a discovered Apple GET operation. Start with an apps_* relationship and scope:[]; reuse the returned resource scopes. Paginate with next_cursor and unchanged query. Raw IDs are accepted only for global lookups. Reads live Apple state, file upload operations and analytics report URLs."],
|
|
71
|
+
["update_store", "write", "Execute a discovered Apple write using its exact JSON:API body. Copy scope and relationship references from read_store. Preview by default; confirm:true changes real Apple resources. Use owner-authorized prices and attestations. On pending, retry identical arguments with request_key; get_store_operation recovers lost keys. Use publish/update_review/manage_testflight for their dedicated lifecycle actions."],
|
|
72
|
+
].map(([name, route, description]) => ({
|
|
73
|
+
name, description,
|
|
74
|
+
inputSchema: { ...lifecycleSchemas[name], properties: { project_id: { type: "string" }, ...lifecycleSchemas[name].properties, ...(route === "write" ? { request_key: { type: "string" } } : {}) } },
|
|
75
|
+
handler: async args => {
|
|
76
|
+
const { project_id: _project, request_key, ...body } = args;
|
|
77
|
+
return text(await api("POST", `/api/v1/projects/${encodeURIComponent(await resolveProjectId(args))}/store/${route}`, { body, ...(route === "write" ? { idempotencyKey: request_key ?? `mcp-store-${randomUUID()}` } : {}) }));
|
|
78
|
+
},
|
|
79
|
+
})),
|
|
29
80
|
{
|
|
30
81
|
name: "connect_status",
|
|
31
82
|
description:
|
|
@@ -90,6 +141,7 @@ export const TOOLS = [
|
|
|
90
141
|
properties: {
|
|
91
142
|
project_id: { type: "string" },
|
|
92
143
|
workflow: { type: "string", enum: ["release", "smoke"], default: "release" },
|
|
144
|
+
request_key: { type: "string", description: "Reuse this key to reconcile an unknown response. Use a fresh key after a confirmed build_started:false and payment/setup completion." },
|
|
93
145
|
},
|
|
94
146
|
additionalProperties: false,
|
|
95
147
|
},
|
|
@@ -97,7 +149,7 @@ export const TOOLS = [
|
|
|
97
149
|
const project_id = await resolveProjectId(args);
|
|
98
150
|
const res = await api("POST", "/api/v1/builds", {
|
|
99
151
|
body: { project_id, workflow: args?.workflow ?? "release" },
|
|
100
|
-
idempotencyKey: `mcp-${
|
|
152
|
+
idempotencyKey: args?.request_key ?? `mcp-${randomUUID()}`,
|
|
101
153
|
});
|
|
102
154
|
let warning;
|
|
103
155
|
try {
|
|
@@ -376,7 +428,7 @@ export async function runMcpServer() {
|
|
|
376
428
|
reply(await tool.handler(params?.arguments ?? {}));
|
|
377
429
|
} catch (err) {
|
|
378
430
|
reply({
|
|
379
|
-
content: [{ type: "text", text: err?.friendly ?? err?.message ?? String(err) }],
|
|
431
|
+
content: [{ type: "text", text: err?.details ? JSON.stringify({ error: { ...err.details, message: err.friendly ?? err.message } }, null, 2) : err?.friendly ?? err?.message ?? String(err) }],
|
|
380
432
|
isError: true,
|
|
381
433
|
});
|
|
382
434
|
}
|