@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
package/bin/nomac.mjs
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { main } from "../src/cli.mjs";
|
|
3
3
|
|
|
4
4
|
main(process.argv.slice(2)).catch((err) => {
|
|
5
|
-
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
if (["start", "sync", "pull", "stop", "session", "transport"].includes(args[0]) && args.includes("--json")) {
|
|
7
|
+
console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
|
|
8
|
+
} else console.error(err?.friendly ?? err?.message ?? String(err));
|
|
6
9
|
process.exit(err?.exitCode ?? 1);
|
|
7
10
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Private rsync transport. stdout is the binary rsync protocol, never CLI logging.
|
|
3
|
+
import { rsyncServerCommand, runProcess, sshArgs } from "../src/session-ssh.mjs";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const session = JSON.parse(process.env.NOMAC_TRANSFER_SESSION ?? "null");
|
|
7
|
+
if (!session) throw new Error("This helper is started by nomac sync/pull.");
|
|
8
|
+
const command = rsyncServerCommand(session, process.argv.slice(2), session.remoteDirectory);
|
|
9
|
+
await runProcess("ssh", [...sshArgs(session), session.target, command], { inherit: true });
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.error(error.friendly ?? error.message);
|
|
12
|
+
process.exitCode = error.exitCode ?? 1;
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
4
|
+
"description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"ignore": "^7.0.0",
|
|
23
|
-
"tar": "^7.4.0"
|
|
23
|
+
"tar": "^7.4.0",
|
|
24
|
+
"ws": "^8.21.3",
|
|
25
|
+
"https-proxy-agent": "^9.1.0",
|
|
26
|
+
"proxy-from-env": "^2.1.0"
|
|
24
27
|
},
|
|
25
28
|
"devDependencies": {
|
|
26
29
|
"@types/node": "^26.1.1",
|
package/skill/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ description: >
|
|
|
12
12
|
# nomac — ship iOS without a Mac
|
|
13
13
|
|
|
14
14
|
nomac is the hands; you are the brain. It builds, signs, uploads, lints for
|
|
15
|
-
review-readiness, writes store metadata
|
|
15
|
+
review-readiness, writes store metadata, manages pricing/products and submits for review —
|
|
16
16
|
via MCP tools (preferred) or the `nomac` CLI.
|
|
17
17
|
|
|
18
18
|
## Setup check
|
|
@@ -81,8 +81,9 @@ too, and use `get_screenshot_upload` to find or poll the existing operation.
|
|
|
81
81
|
Fix issues before marking items resolved. Every rejected item must be resolved
|
|
82
82
|
or removed before resubmitting. Removed items cannot be added back to that submission.
|
|
83
83
|
- Poll `get_review`; approval and release are separate. Release requires an
|
|
84
|
-
app-version item in Pending Developer Release.
|
|
85
|
-
and
|
|
84
|
+
app-version item in Pending Developer Release. Edit subscription/IAP products,
|
|
85
|
+
events and custom pages through the store catalog below. Apple/account/legal
|
|
86
|
+
decisions still require the relevant Apple workflow.
|
|
86
87
|
|
|
87
88
|
## External TestFlight
|
|
88
89
|
|
|
@@ -103,8 +104,56 @@ requires its own review and distribution:
|
|
|
103
104
|
5. All actions preview without confirm and apply with confirm:true. Pending
|
|
104
105
|
requests use their original key/body; get_store_operation recovers them.
|
|
105
106
|
6. Relay an accurate human-provided uses_non_exempt_encryption with action=encryption
|
|
106
|
-
when appropriate. Never guess.
|
|
107
|
-
|
|
107
|
+
when appropriate. Never guess. Encryption declarations/documents use the
|
|
108
|
+
store catalog; rejected beta messages require App Store Connect. Fix the cause
|
|
109
|
+
before resubmitting a rejected binary.
|
|
110
|
+
|
|
111
|
+
## Pricing, products, customer reviews and the wider store API
|
|
112
|
+
|
|
113
|
+
1. `get_store_capabilities` lists operations by category/search with next_offset.
|
|
114
|
+
Pass `operation` to get that operation's exact Apple request/query schema.
|
|
115
|
+
Categories: listing, releases, pricing, purchases, subscriptions, reviews,
|
|
116
|
+
marketing, assets, testflight, analytics, compliance, webhooks.
|
|
117
|
+
2. `read_store` executes listed GET operations. Begin with an `apps_*` relationship
|
|
118
|
+
and `scope:[]` for the linked app. Every child has a reusable `scope`; copy it
|
|
119
|
+
into the next read/write. Keep the query when following `next_cursor`.
|
|
120
|
+
3. `update_store` takes the operation, scope and exact JSON:API `body`. For creation,
|
|
121
|
+
scope the parent. Every existing relationship ID needs its own verified scope
|
|
122
|
+
in `references`; the target and its ancestors are already verified. Territories
|
|
123
|
+
and categories are global. Inline creates use local IDs such as `${price1}`.
|
|
124
|
+
4. Preview without confirm. `confirm:true` applies real paid prices, availability,
|
|
125
|
+
products/offers, public responses, release settings or event subscriptions.
|
|
126
|
+
Honor the human's existing authorization; do not ask again for authorized work.
|
|
127
|
+
Do not invent prices, legal answers or factual claims in public replies.
|
|
128
|
+
5. `customerReviewResponses_createInstance` replies to a public customer review.
|
|
129
|
+
It does not send a message to Apple reviewers. Read their actual messages in
|
|
130
|
+
App Store Connect; `prepare_review_reply` only drafts the response.
|
|
131
|
+
6. Product management covers subscriptions/IAP versions, localization, prices,
|
|
132
|
+
availability, grace periods and offers. First IAP approval may require the
|
|
133
|
+
app version submission. Draft review submission items can attach products;
|
|
134
|
+
use `publish` / `update_review` to submit, resolve, resubmit and release.
|
|
135
|
+
7. Events, custom pages, experiments, accessibility and phased releases use their
|
|
136
|
+
catalog operations. Apple still validates account permissions and valid states.
|
|
137
|
+
Analytics report segments supply download URLs; offer-code CSV exports return
|
|
138
|
+
bounded base64 downloads. Webhooks send app events to an owner-selected receiver;
|
|
139
|
+
that receiver is responsible for running an agent when events arrive.
|
|
140
|
+
|
|
141
|
+
`upload_store_asset` handles new Apple file resources: previews, screenshot files,
|
|
142
|
+
purchase images, supplemental review attachments and encryption documents. Choose
|
|
143
|
+
the asset create operation, provide its parent relationship/body, and pass a local
|
|
144
|
+
`file_path` (CLI, up to 500 MiB) or `data_base64` (hosted, up to 2 MiB). Apple also
|
|
145
|
+
has asset-specific limits. The tool reserves, uploads each requested byte range,
|
|
146
|
+
then commits its checksum. Keep the same request_key and original bytes to resume.
|
|
147
|
+
`uploaded:true` is not processing success: poll `read_store` until
|
|
148
|
+
`assetDeliveryState.state` is `COMPLETE`. FAILED requires addressing Apple's errors.
|
|
149
|
+
This adds a new file; use `upload_screenshots` for managed screenshot replacement.
|
|
150
|
+
|
|
151
|
+
Keep pending write keys and unchanged bodies. `get_store_operation` exposes captured
|
|
152
|
+
targets and receipts. Unknown Apple outcomes remain pending and block other writes
|
|
153
|
+
to the app until reconciled; never use a fresh key to bypass one. Corrected inputs
|
|
154
|
+
or a new intentional action require a fresh key. Correspondence, appeals, App Privacy
|
|
155
|
+
questionnaire answers and account-wide financial/admin APIs are not exposed.
|
|
156
|
+
This catalog does not run simulators or Xcode Cloud jobs.
|
|
108
157
|
|
|
109
158
|
## When stuck
|
|
110
159
|
|
package/src/api.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { loadApiKey, loadApiUrl } from "./config.mjs";
|
|
2
2
|
|
|
3
3
|
export class CliError extends Error {
|
|
4
|
-
constructor(message, friendly, status, code) {
|
|
4
|
+
constructor(message, friendly, status, code, details) {
|
|
5
5
|
super(message);
|
|
6
6
|
this.friendly = friendly ?? message;
|
|
7
7
|
this.exitCode = 1;
|
|
8
8
|
this.status = status;
|
|
9
9
|
this.code = code;
|
|
10
|
+
this.details = details;
|
|
10
11
|
}
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -33,7 +34,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
33
34
|
if (!key) {
|
|
34
35
|
throw new CliError(
|
|
35
36
|
"not logged in",
|
|
36
|
-
"Not logged in. Run `nomac login
|
|
37
|
+
"Not logged in. Run `nomac login` and approve access in your browser, or set NOMAC_API_KEY.",
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
const url = `${loadApiUrl()}${path}`;
|
|
@@ -49,13 +50,17 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
49
50
|
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
|
|
50
51
|
},
|
|
51
52
|
body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined),
|
|
53
|
+
}).catch(() => {
|
|
54
|
+
throw new CliError("Request outcome unknown", "The response was lost. Reconcile the previous operation before retrying; reuse its request_key.", undefined, "outcome_unknown", { code: "outcome_unknown", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
|
|
55
|
+
});
|
|
56
|
+
const text = await res.text().catch(() => {
|
|
57
|
+
throw new CliError("Response body was lost", "Reconcile the previous operation before retrying; reuse its request_key.", res.status, "outcome_unknown", { code: "outcome_unknown", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
|
|
52
58
|
});
|
|
53
|
-
const text = await res.text();
|
|
54
59
|
let json;
|
|
55
60
|
try {
|
|
56
61
|
json = text ? JSON.parse(text) : {};
|
|
57
62
|
} catch {
|
|
58
|
-
throw new CliError(`API returned non-JSON (HTTP ${res.status})
|
|
63
|
+
throw new CliError(`API returned non-JSON (HTTP ${res.status})`, "The response could not be read. Reconcile the operation before retrying; reuse its request_key.", res.status, "bad_response", { code: "bad_response", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
|
|
59
64
|
}
|
|
60
65
|
if (!res.ok) {
|
|
61
66
|
const err = json?.error ?? {};
|
|
@@ -64,6 +69,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
64
69
|
err.message ?? `API error (HTTP ${res.status})`,
|
|
65
70
|
res.status,
|
|
66
71
|
err.code,
|
|
72
|
+
{ ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
|
|
67
73
|
);
|
|
68
74
|
}
|
|
69
75
|
return json;
|
package/src/cli.mjs
CHANGED
|
@@ -4,9 +4,30 @@ import { loadProjectState, saveApiKey, saveProjectState } from "./config.mjs";
|
|
|
4
4
|
import { packTarball } from "./pack.mjs";
|
|
5
5
|
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
6
6
|
|
|
7
|
-
const HELP = `nomac —
|
|
7
|
+
const HELP = `nomac — Mac workspaces for agents / iOS app publishing
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Nomac 2.0 cloud Macs:
|
|
10
|
+
nomac credits weekly hours and plan checkout
|
|
11
|
+
nomac start allocate or reconnect to your Mac
|
|
12
|
+
nomac exec --request-key KEY -- command args
|
|
13
|
+
nomac job JOB_ID inspect a submitted command
|
|
14
|
+
nomac output JOB_ID read resumable command output
|
|
15
|
+
nomac start --help session limits and usage
|
|
16
|
+
|
|
17
|
+
Existing Mac workspaces:
|
|
18
|
+
nomac start --host user@mac connect a dedicated Mac you control
|
|
19
|
+
nomac sync . upload changed project files
|
|
20
|
+
nomac ssh open the full Mac shell
|
|
21
|
+
nomac pull ./output retrieve a remote directory
|
|
22
|
+
nomac session status --json show workspace and connection information
|
|
23
|
+
nomac stop detach locally (does not end a rental)
|
|
24
|
+
nomac start --help setup, exclusions and preview limitations
|
|
25
|
+
|
|
26
|
+
Nomac 2.0 transport probe:
|
|
27
|
+
nomac tunnel <session> SSH through the configured port-443 gateway
|
|
28
|
+
nomac transport --help HTTPS jobs, resumable output and binary probes
|
|
29
|
+
|
|
30
|
+
Existing publishing commands:
|
|
10
31
|
nomac login connect this device (approve in the browser)
|
|
11
32
|
nomac login <api-key> store an nmk_ key directly (CI / power users)
|
|
12
33
|
nomac push pack this directory and upload a snapshot
|
|
@@ -22,6 +43,40 @@ Environment: NOMAC_API_KEY, NOMAC_API_URL`;
|
|
|
22
43
|
export async function main(argv) {
|
|
23
44
|
const [cmd, ...rest] = argv;
|
|
24
45
|
switch (cmd) {
|
|
46
|
+
case "credits": {
|
|
47
|
+
const { creditsCommand } = await import("./mac-credits.mjs");
|
|
48
|
+
return creditsCommand(rest);
|
|
49
|
+
}
|
|
50
|
+
case "tunnel": {
|
|
51
|
+
if (rest.length !== 1) throw new CliError("Usage: nomac tunnel <session-id>");
|
|
52
|
+
const { tunnel } = await import("./mac-transport.mjs");
|
|
53
|
+
if (process.env.NOMAC_SESSION_TOKEN) return tunnel(rest[0]);
|
|
54
|
+
const { sessionAccess } = await import("./hosted-sessions.mjs");
|
|
55
|
+
const access = await sessionAccess(rest[0].replace(/^nomac-/, ""));
|
|
56
|
+
return tunnel(rest[0], { gateway: access.gateway, token: access.token });
|
|
57
|
+
}
|
|
58
|
+
case "transport": {
|
|
59
|
+
const { transportCommand } = await import("./transport-command.mjs");
|
|
60
|
+
return transportCommand(rest);
|
|
61
|
+
}
|
|
62
|
+
case "start":
|
|
63
|
+
case "exec":
|
|
64
|
+
case "job":
|
|
65
|
+
case "output":
|
|
66
|
+
case "sync":
|
|
67
|
+
case "ssh":
|
|
68
|
+
case "pull":
|
|
69
|
+
case "stop":
|
|
70
|
+
case "session": {
|
|
71
|
+
const { sessionStore } = await import("./session-store.mjs");
|
|
72
|
+
const store = sessionStore();
|
|
73
|
+
if (!rest.includes("--host") && !rest.some(value => value.startsWith("--host=")) && store.load()?.mode !== "external_ssh") {
|
|
74
|
+
const { hostedSessionCommand } = await import("./hosted-sessions.mjs");
|
|
75
|
+
return hostedSessionCommand(cmd, rest, { store });
|
|
76
|
+
}
|
|
77
|
+
const { sessionCommand } = await import("./sessions.mjs");
|
|
78
|
+
return sessionCommand(cmd, rest);
|
|
79
|
+
}
|
|
25
80
|
case "login": {
|
|
26
81
|
const key = rest[0];
|
|
27
82
|
if (key) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { api, CliError } from "./api.mjs";
|
|
6
|
+
import { sessionStore } from "./session-store.mjs";
|
|
7
|
+
import { forwardingArg, runProcess, sshArgs, workspaceCommand } from "./session-ssh.mjs";
|
|
8
|
+
import { pullProject, syncProject } from "./session-transfer.mjs";
|
|
9
|
+
import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
|
|
10
|
+
|
|
11
|
+
const HELP = `Nomac cloud Mac sessions
|
|
12
|
+
nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
|
|
13
|
+
nomac sync . [--dry-run] upload changed files
|
|
14
|
+
nomac ssh [--forward 8080:3000] full Mac shell and port forwarding
|
|
15
|
+
nomac ssh -- command args run any command over SSH
|
|
16
|
+
nomac exec --request-key KEY -- command args
|
|
17
|
+
nomac job JOB_ID reconcile a submitted command
|
|
18
|
+
nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
|
|
19
|
+
nomac pull ./output [--from PATH] retrieve files before stopping
|
|
20
|
+
nomac session status --json live state, cost and cleanup status
|
|
21
|
+
nomac stop [--wait 60] --json request shutdown and wait for confirmation
|
|
22
|
+
|
|
23
|
+
Login once with nomac login. The CLI creates a local SSH key; it sends only the
|
|
24
|
+
public key. Sessions use the port-443 gateway and verified guest host keys.
|
|
25
|
+
Time continues while the VM is ready, including between commands. Inspect the
|
|
26
|
+
returned offer and limits. Stop removes the VM and its files; pull first.
|
|
27
|
+
A wait timeout leaves the session attached: inspect status or run stop.
|
|
28
|
+
Use nomac start --host user@mac to connect an existing Mac instead.`;
|
|
29
|
+
const options = {
|
|
30
|
+
help: { type: "boolean" }, json: { type: "boolean" }, wait: { type: "string" },
|
|
31
|
+
"max-duration": { type: "string" }, "idle-timeout": { type: "string" },
|
|
32
|
+
"max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
|
|
33
|
+
overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
|
|
34
|
+
forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
|
|
35
|
+
};
|
|
36
|
+
function budget(value = "5") {
|
|
37
|
+
if (!/^(?:0|[1-9][0-9]{0,5})(?:\.[0-9]{1,6})?$/.test(value)) throw new CliError("--max-cost must be a positive USD amount, for example 5 or 2.50.");
|
|
38
|
+
const [whole, fraction = ""] = value.split(".");
|
|
39
|
+
const result = BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, "0"));
|
|
40
|
+
if (result <= 0n) throw new CliError("--max-cost must be positive.");
|
|
41
|
+
return result.toString();
|
|
42
|
+
}
|
|
43
|
+
function integer(value, fallback, min, max) {
|
|
44
|
+
if (value === undefined) return fallback;
|
|
45
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max) throw new CliError("Invalid numeric option.");
|
|
46
|
+
return Number(value);
|
|
47
|
+
}
|
|
48
|
+
export async function sessionAccess(id, request = api) {
|
|
49
|
+
sessionId(id);
|
|
50
|
+
const access = await request("POST", `/api/v1/mac/sessions/${id}/access`);
|
|
51
|
+
if (access.session_id !== id || !Number.isSafeInteger(access.generation) || access.generation < 1 || access.ssh?.username !== "agent" || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}(?: [^\r\n\x00]*)?$/.test(access.ssh?.host_key ?? "")) throw new CliError("Invalid session access response.");
|
|
52
|
+
// Validates the gateway origin and token without saving bearer credentials to disk.
|
|
53
|
+
transportOptions({ gateway: access.gateway, token: access.token });
|
|
54
|
+
return access;
|
|
55
|
+
}
|
|
56
|
+
export async function hostedRequest(id, method, path, params = {}, { request = api, transport = macRequest } = {}) {
|
|
57
|
+
const access = await sessionAccess(id, request);
|
|
58
|
+
return transport(method, path, { ...params, gateway: access.gateway, token: access.token });
|
|
59
|
+
}
|
|
60
|
+
export async function hostedSessionCommand(command, argv, { store = sessionStore(), request = api, transport = macRequest, run = runProcess, print = console.log, sleep = ms => new Promise(r => setTimeout(r, ms)), now = Date.now } = {}) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try { parsed = parseArgs({ args: argv, options, allowPositionals: true }); }
|
|
63
|
+
catch (error) { throw new CliError(error.message); }
|
|
64
|
+
const { values, positionals } = parsed;
|
|
65
|
+
if (values.help) return print(HELP);
|
|
66
|
+
const output = result => {
|
|
67
|
+
const billing = result.session?.billing;
|
|
68
|
+
const usage = billing?.mode === "weekly_included"
|
|
69
|
+
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
70
|
+
: `Cost: ${JSON.stringify(result.session?.cost)}`;
|
|
71
|
+
print(values.json || !["start", "stop"].includes(command) ? JSON.stringify(result, null, 2) : `${result.session.id}: ${result.session.state}\n${result.session.state === "ready" ? "Next: nomac sync . → nomac ssh" : "Check: nomac session status --json"}\n${usage}`);
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
const saveView = (local, view) => {
|
|
75
|
+
if (view.id !== local.id && local.id) throw new CliError("Session response mismatch.");
|
|
76
|
+
sessionId(view.id);
|
|
77
|
+
Object.assign(local, { id: view.id, state: view.state, snapshot: view }); store.save(local);
|
|
78
|
+
return view;
|
|
79
|
+
};
|
|
80
|
+
const refresh = async local => saveView(local, (await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
|
|
81
|
+
async function ready(local) {
|
|
82
|
+
await refresh(local);
|
|
83
|
+
if (local.state !== "ready") throw new CliError("Mac is not ready.", `Session ${local.id} is ${local.state}. Run nomac session status --json.`, undefined, "session_not_ready");
|
|
84
|
+
const access = await sessionAccess(local.id, request);
|
|
85
|
+
if (local.generation && (local.generation !== access.generation || local.hostKey !== access.ssh.host_key)) throw new CliError("The guest identity changed. Stop this session and start a fresh one.", undefined, undefined, "host_key_changed");
|
|
86
|
+
Object.assign(local, { generation: access.generation, hostKey: access.ssh.host_key, target: `agent@nomac-${local.id}`, workspace: "/Users/agent/workspace", knownHosts: join(store.directory, "known_hosts") });
|
|
87
|
+
writeFileSync(local.knownHosts, `nomac-${local.id} ${access.ssh.host_key}\n`, { mode: 0o600 }); chmodSync(local.knownHosts, 0o600); store.save(local);
|
|
88
|
+
return local;
|
|
89
|
+
}
|
|
90
|
+
const localSession = () => {
|
|
91
|
+
const local = store.load();
|
|
92
|
+
if (local?.mode !== "hosted" || !local.id) throw new CliError("No attached cloud session.", "Run nomac start to allocate or reconcile your Mac.");
|
|
93
|
+
return local;
|
|
94
|
+
};
|
|
95
|
+
if (command === "session") {
|
|
96
|
+
if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac session status --json");
|
|
97
|
+
const local = localSession(); return output({ session: await refresh(local) });
|
|
98
|
+
}
|
|
99
|
+
if (["exec", "job", "output"].includes(command)) {
|
|
100
|
+
const local = localSession(), base = `/api/v1/mac/sessions/${sessionId(local.id)}`;
|
|
101
|
+
let path, method = "GET", params = {};
|
|
102
|
+
if (command === "exec") {
|
|
103
|
+
if (!positionals.length || !argv.includes("--") || !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"] ?? "")) throw new CliError("Use nomac exec --request-key STABLE_KEY -- command args");
|
|
104
|
+
method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"], body: { argv: positionals, timeout_seconds: integer(values.timeout, 60, 1, 600) } };
|
|
105
|
+
} else {
|
|
106
|
+
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
107
|
+
path = `${base}/jobs/${positionals[0]}`;
|
|
108
|
+
if (command === "output") {
|
|
109
|
+
const cursor = integer(values.cursor, 0, 0, Number.MAX_SAFE_INTEGER), stream = values.stream ?? "stdout";
|
|
110
|
+
if (!["stdout", "stderr"].includes(stream)) throw new CliError("Use stdout or stderr.");
|
|
111
|
+
path += `/output?cursor=${cursor}&stream=${stream}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return output(await hostedRequest(local.id, method, path, params, { request, transport }));
|
|
115
|
+
}
|
|
116
|
+
if (command === "ssh") {
|
|
117
|
+
if (values.json) throw new CliError("SSH streams bytes. Use session status --json for metadata.");
|
|
118
|
+
const local = await ready(localSession());
|
|
119
|
+
return run("ssh", [...sshArgs(local, { batch: false }), ...(!positionals.length && process.stdin.isTTY ? ["-t"] : []), ...(values.forward ?? []).flatMap(v => ["-L", forwardingArg(v)]), "-o", "ExitOnForwardFailure=yes", local.target, workspaceCommand(local, positionals)], { inherit: true });
|
|
120
|
+
}
|
|
121
|
+
return store.locked(async () => {
|
|
122
|
+
if (command === "start") {
|
|
123
|
+
if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
|
|
124
|
+
let local = store.load();
|
|
125
|
+
if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
|
|
126
|
+
const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60, 86400), idle_timeout_seconds: integer(values["idle-timeout"], 600, 60, 3600), spend_cap_microunits: budget(values["max-cost"]) };
|
|
127
|
+
if (local && !["stopped", "failed"].includes(local.state)) {
|
|
128
|
+
for (const [flag, field] of [["max-duration", "max_duration_seconds"], ["idle-timeout", "idle_timeout_seconds"], ["max-cost", "spend_cap_microunits"]]) {
|
|
129
|
+
if (values[flag] !== undefined && limits[field] !== local.request.limits[field]) throw new CliError("This session already has different limits. Stop it and start a fresh session to change them.");
|
|
130
|
+
}
|
|
131
|
+
if (values["request-key"] !== undefined && values["request-key"] !== local.requestKey) throw new CliError("A session is already attached under a different request key. Inspect it or stop it before starting another.");
|
|
132
|
+
}
|
|
133
|
+
if (!local || ["stopped", "failed"].includes(local.state)) {
|
|
134
|
+
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
135
|
+
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
136
|
+
local = { mode: "hosted", state: "requesting", identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
|
|
137
|
+
store.save(local); store.saveBaseline({});
|
|
138
|
+
}
|
|
139
|
+
if (!local.id) {
|
|
140
|
+
const result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request });
|
|
141
|
+
saveView(local, result.session);
|
|
142
|
+
}
|
|
143
|
+
const deadline = now() + integer(values.wait, 300, 0, 600) * 1000;
|
|
144
|
+
for (;;) {
|
|
145
|
+
await refresh(local);
|
|
146
|
+
if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
|
|
147
|
+
await sleep(2000);
|
|
148
|
+
}
|
|
149
|
+
if (local.state === "ready") await ready(local);
|
|
150
|
+
return output({ session: local.snapshot, request_key: local.requestKey });
|
|
151
|
+
}
|
|
152
|
+
const local = localSession();
|
|
153
|
+
if (command === "stop") {
|
|
154
|
+
if (positionals.length) throw new CliError("Usage: nomac stop [--wait SECONDS] [--json]");
|
|
155
|
+
saveView(local, (await request("DELETE", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
|
|
156
|
+
const deadline = now() + integer(values.wait, 60, 0, 300) * 1000;
|
|
157
|
+
while (!["stopped", "failed"].includes(local.state) && now() < deadline) { await sleep(1000); await refresh(local); }
|
|
158
|
+
return output({ session: local.snapshot });
|
|
159
|
+
}
|
|
160
|
+
await ready(local);
|
|
161
|
+
if (command === "sync") {
|
|
162
|
+
if (positionals.length > 1 || (positionals[0] && resolve(positionals[0]) !== store.project)) throw new CliError("Run nomac sync . from the attached project.");
|
|
163
|
+
return output(await syncProject(local, store, { overwrite: values.overwrite, dryRun: values["dry-run"], run }));
|
|
164
|
+
}
|
|
165
|
+
if (command === "pull") {
|
|
166
|
+
if (positionals.length !== 1) throw new CliError("Usage: nomac pull LOCAL_PATH [--from REMOTE_PATH]");
|
|
167
|
+
return output(await pullProject(local, store, positionals[0], { from: values.from ?? positionals[0], overwrite: values.overwrite, dryRun: values["dry-run"], run }));
|
|
168
|
+
}
|
|
169
|
+
throw new CliError("Unknown cloud session command.");
|
|
170
|
+
});
|
|
171
|
+
}
|