@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Shared by the hosted upload endpoint and the published CLI (copied by the
|
|
2
|
+
// schema generator). Provider upload URLs come only from an authenticated,
|
|
3
|
+
// app-scoped read_store response; Nomac credentials never go to those URLs.
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { open } from 'node:fs/promises';
|
|
6
|
+
import { basename } from 'node:path';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
|
|
9
|
+
function unwrap(schema, components) {
|
|
10
|
+
if (schema?.$ref) return unwrap(components.schemas[schema.$ref.split('/').at(-1)], components);
|
|
11
|
+
if (schema?.anyOf) return unwrap(schema.anyOf.find(s => s.type !== 'null'), components);
|
|
12
|
+
return schema;
|
|
13
|
+
}
|
|
14
|
+
function fields(detail) {
|
|
15
|
+
const root = unwrap(detail.operation.body, detail.operation.components);
|
|
16
|
+
return unwrap(unwrap(root.properties.data, detail.operation.components).properties.attributes, detail.operation.components)?.properties ?? {};
|
|
17
|
+
}
|
|
18
|
+
const digest = (name, bytes) => createHash(name).update(bytes).digest('hex');
|
|
19
|
+
|
|
20
|
+
export async function uploadStoreAsset(args, call) {
|
|
21
|
+
const requestKey = args.request_key ?? `store-upload-${randomUUID()}`;
|
|
22
|
+
let handle, current, source, md5, sha256, size, filename, reservation;
|
|
23
|
+
let stage = 'prepare';
|
|
24
|
+
try {
|
|
25
|
+
if (Boolean(args.file_path) === Boolean(args.data_base64)) throw new Error('Supply exactly one local file_path (CLI) or data_base64 (hosted).');
|
|
26
|
+
if (!args.operation?.endsWith('_createInstance')) throw new Error('Choose an asset createInstance operation from get_store_capabilities.');
|
|
27
|
+
if (args.file_path) {
|
|
28
|
+
handle = await open(args.file_path, 'r');
|
|
29
|
+
current = await handle.stat({ bigint: true });
|
|
30
|
+
if (!current.isFile() || current.size < 1n || current.size > 500n * 1024n * 1024n) throw new Error('Upload a regular file between 1 byte and 500 MiB. Apple may impose a smaller asset-specific limit.');
|
|
31
|
+
size = Number(current.size); filename = basename(args.file_path);
|
|
32
|
+
const range = async function* (offset, length) {
|
|
33
|
+
for (let position = offset; position < offset + length;) {
|
|
34
|
+
const buffer = Buffer.allocUnsafe(Math.min(1024 * 1024, offset + length - position));
|
|
35
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
36
|
+
if (!bytesRead) throw new Error('The source file was truncated during upload.');
|
|
37
|
+
position += bytesRead;
|
|
38
|
+
yield buffer.subarray(0, bytesRead);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const sha = createHash('sha256'), checksum = createHash('md5');
|
|
42
|
+
for await (const bytes of range(0, size)) { sha.update(bytes); checksum.update(bytes); }
|
|
43
|
+
sha256 = sha.digest('hex'); md5 = checksum.digest('hex');
|
|
44
|
+
source = async (offset, length) => {
|
|
45
|
+
const latest = await handle.stat({ bigint: true });
|
|
46
|
+
if (['size', 'mtimeNs', 'ctimeNs', 'ino'].some(k => latest[k] !== current[k])) throw new Error('The source file changed. Resume with the original bytes or start a new upload.');
|
|
47
|
+
return Readable.from(range(offset, length));
|
|
48
|
+
};
|
|
49
|
+
} else {
|
|
50
|
+
if (typeof args.data_base64 !== 'string' || args.data_base64.length > 3 * 1024 * 1024 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(args.data_base64)) throw new Error('Provide canonical base64 for a file up to 2 MiB; use the CLI for larger files.');
|
|
51
|
+
const bytes = Buffer.from(args.data_base64, 'base64');
|
|
52
|
+
size = bytes.length;
|
|
53
|
+
if (!size || size > 2 * 1024 * 1024) throw new Error('Hosted inline uploads support files between 1 byte and 2 MiB. Use the CLI for larger files.');
|
|
54
|
+
md5 = digest('md5', bytes); sha256 = digest('sha256', bytes); filename = 'asset.bin';
|
|
55
|
+
source = async (offset, length) => bytes.subarray(offset, offset + length);
|
|
56
|
+
}
|
|
57
|
+
const detail = await call('GET', `/api/v1/store-capabilities?operation=${encodeURIComponent(args.operation)}`);
|
|
58
|
+
const createFields = fields(detail);
|
|
59
|
+
if (!createFields.fileName || !createFields.fileSize) throw new Error('This operation does not create an uploadable Apple file.');
|
|
60
|
+
const commitOperation = args.operation.replace(/_createInstance$/, '_updateInstance');
|
|
61
|
+
const commitDetail = await call('GET', `/api/v1/store-capabilities?operation=${encodeURIComponent(commitOperation)}`);
|
|
62
|
+
const commitFields = fields(commitDetail);
|
|
63
|
+
if (!commitFields.uploaded) throw new Error('This asset does not support the standard Apple upload commit operation.');
|
|
64
|
+
const body = structuredClone(args.body ?? {});
|
|
65
|
+
if (!body.data || !body.data.relationships) throw new Error('Supply the asset JSON:API body and its parent relationship from get_store_capabilities.');
|
|
66
|
+
body.data.attributes ??= {};
|
|
67
|
+
if (body.data.attributes.fileSize !== undefined && body.data.attributes.fileSize !== size) throw new Error('The declared fileSize does not match the source file.');
|
|
68
|
+
filename = basename(body.data.attributes.fileName || filename).normalize('NFC').replace(/[\r\n]/g, '_');
|
|
69
|
+
const tag = digest('sha256', `${args.project_id}\n${requestKey}\n${args.operation}\n${sha256}`).slice(0, 32);
|
|
70
|
+
body.data.attributes.fileName = `nomac-${tag}-${filename}`;
|
|
71
|
+
body.data.attributes.fileSize = size;
|
|
72
|
+
const base = `/api/v1/projects/${encodeURIComponent(args.project_id)}/store`;
|
|
73
|
+
const write = (payload, key) => call('POST', `${base}/write`, { body: payload, idempotencyKey: key });
|
|
74
|
+
stage = 'reserve';
|
|
75
|
+
reservation = await write({ operation: args.operation, scope: args.scope, references: args.references, body, ...(args.confirm ? { asset_sha256: sha256 } : {}), confirm: Boolean(args.confirm) }, args.confirm ? requestKey : `${requestKey}:preview`);
|
|
76
|
+
if (!args.confirm || reservation.state === 'pending') return { ...reservation, request_key: requestKey, stage: 'reserve', file_size: size, sha256 };
|
|
77
|
+
if (!reservation.resource?.id || !reservation.scope) throw new Error('Apple has not returned an app-owned file reservation. Resume this request key; do not create another file.');
|
|
78
|
+
const read = async () => call('POST', `${base}/read`, { body: { operation: args.operation.replace(/_createInstance$/, '_getInstance'), scope: reservation.scope } });
|
|
79
|
+
let state = await read();
|
|
80
|
+
let attributes = state.data?.attributes;
|
|
81
|
+
if (!attributes || attributes.fileSize !== size || attributes.fileName !== body.data.attributes.fileName) throw new Error('The reserved Apple file does not match this upload.');
|
|
82
|
+
stage = 'upload';
|
|
83
|
+
const completed = ['UPLOAD_COMPLETE', 'COMPLETE', 'FAILED'].includes(attributes.assetDeliveryState?.state);
|
|
84
|
+
if (!completed) {
|
|
85
|
+
const operations = attributes.uploadOperations;
|
|
86
|
+
if (!Array.isArray(operations) || !operations.length) throw new Error('Apple has not exposed upload operations yet. Retry this same upload.');
|
|
87
|
+
let end = 0;
|
|
88
|
+
for (const op of [...operations].sort((a, b) => a.offset - b.offset)) {
|
|
89
|
+
if (op.method !== 'PUT' || !Number.isSafeInteger(op.offset) || !Number.isSafeInteger(op.length) || op.length < 1 || op.offset !== end || op.offset + op.length > size) throw new Error('Apple returned invalid or incomplete upload byte ranges.');
|
|
90
|
+
const url = new URL(op.url);
|
|
91
|
+
if (url.protocol !== 'https:' || url.username || url.password || !['apple.com', 'icloud.com', 'mzstatic.com', 'amazonaws.com'].some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`))) throw new Error('Apple returned an unexpected upload destination.');
|
|
92
|
+
end += op.length;
|
|
93
|
+
}
|
|
94
|
+
if (end !== size) throw new Error('Apple upload operations do not cover the complete source file.');
|
|
95
|
+
for (const op of operations) {
|
|
96
|
+
const headers = new Headers();
|
|
97
|
+
for (const header of op.requestHeaders ?? []) {
|
|
98
|
+
if (typeof header.name !== 'string' || typeof header.value !== 'string') throw new Error('Apple returned invalid upload headers.');
|
|
99
|
+
headers.set(header.name, header.value);
|
|
100
|
+
}
|
|
101
|
+
if (headers.has('content-length') && headers.get('content-length') !== String(op.length)) throw new Error('Apple returned a Content-Length that does not match the upload byte range.');
|
|
102
|
+
// A Readable body otherwise uses chunked transfer encoding, which
|
|
103
|
+
// Apple's fixed-length upload destinations may reject.
|
|
104
|
+
headers.set('content-length', String(op.length));
|
|
105
|
+
const part = await source(op.offset, op.length);
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(op.url, { method: 'PUT', headers, body: part, duplex: 'half', redirect: 'error', signal: AbortSignal.timeout(120_000) });
|
|
108
|
+
if (!response.ok) throw new Error(`Apple asset upload returned HTTP ${response.status}. Retry with this same request key to refresh its upload URLs.`);
|
|
109
|
+
await response.body?.cancel();
|
|
110
|
+
} finally { if (typeof part.destroy === 'function') part.destroy(); }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (handle) {
|
|
114
|
+
const latest = await handle.stat({ bigint: true });
|
|
115
|
+
if (['size', 'mtimeNs', 'ctimeNs', 'ino'].some(k => latest[k] !== current[k])) throw new Error('The source file changed during upload; it was not committed.');
|
|
116
|
+
}
|
|
117
|
+
stage = 'commit';
|
|
118
|
+
const committed = await write({ operation: commitOperation, scope: reservation.scope, body: { data: { type: reservation.resource.type, id: reservation.resource.id, attributes: { uploaded: true, ...(commitFields.sourceFileChecksum ? { sourceFileChecksum: md5 } : {}) } } }, confirm: true }, `${requestKey}:commit`);
|
|
119
|
+
if (committed.state === 'pending') return { ...committed, request_key: requestKey, commit_request_key: `${requestKey}:commit`, stage, resource: reservation.resource, scope: reservation.scope };
|
|
120
|
+
stage = 'processing';
|
|
121
|
+
state = await read(); attributes = state.data?.attributes;
|
|
122
|
+
const deliveryState = attributes?.assetDeliveryState?.state;
|
|
123
|
+
return { request_key: requestKey, resource: reservation.resource, scope: reservation.scope, file_size: size, sha256, uploaded: true, ready: deliveryState === 'COMPLETE', state: deliveryState === 'COMPLETE' ? 'complete' : deliveryState === 'FAILED' ? 'failed' : 'processing', asset_delivery_state: attributes?.assetDeliveryState, next_steps: 'Poll read_store with this scope until assetDeliveryState is COMPLETE. A committed upload may still be processing or rejected by Apple. This uploaded a new file; existing assets were not replaced.' };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { state: reservation?.resource ? 'needs_attention' : 'failed', stage, request_key: requestKey, ...(reservation?.resource ? { resource: reservation.resource, scope: reservation.scope } : {}), error: { code: error.code ?? 'store_upload_failed', message: error.friendly ?? error.message }, next_steps: 'Keep the original file and request_key to resume. Completed reservation/commit requests are replayed; upload PUTs may repeat the identical byte ranges. Do not start another reservation for an unconfirmed request.' };
|
|
126
|
+
} finally { await handle?.close(); }
|
|
127
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync, writeFileSync, linkSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { resolve, dirname, join } from "node:path";
|
|
4
|
+
import { CliError } from "./api.mjs";
|
|
5
|
+
import { macRequest, sessionId } from "./mac-transport.mjs";
|
|
6
|
+
|
|
7
|
+
const HELP = `Nomac 2.0 transport probe (an operator-configured SSH target; no allocation or billing)
|
|
8
|
+
|
|
9
|
+
Set NOMAC_GATEWAY_URL and NOMAC_SESSION_TOKEN outside the project.
|
|
10
|
+
nomac tunnel <session> SSH ProxyCommand byte stream
|
|
11
|
+
nomac transport status <session> connection capabilities
|
|
12
|
+
nomac transport exec <session> --request-key KEY [--timeout SECONDS] -- command args...
|
|
13
|
+
nomac transport job <session> <job> inspect the existing job
|
|
14
|
+
nomac transport output <session> <job> [--cursor N] [--stream stdout|stderr]
|
|
15
|
+
nomac transport put <session> <file> upload a checksummed probe blob (up to 8 MiB)
|
|
16
|
+
nomac transport get <session> <sha256> <file> download a probe blob without overwriting
|
|
17
|
+
|
|
18
|
+
Results are JSON. Output chunks are base64 with byte cursors, so binary output is lossless.
|
|
19
|
+
Reuse the same request key after a lost response. Commands are never automatically rerun.
|
|
20
|
+
Probe blobs do not yet implement project sync; ordinary SSH/rsync remains available.`;
|
|
21
|
+
|
|
22
|
+
function parsed(args, allowed) {
|
|
23
|
+
const values = {};
|
|
24
|
+
for (let index = 0; index < args.length; index++) {
|
|
25
|
+
const flag = args[index];
|
|
26
|
+
if (flag === "--json") continue;
|
|
27
|
+
if (!allowed.includes(flag) || index + 1 >= args.length || values[flag] !== undefined) throw new CliError(`Unexpected transport option: ${flag}`);
|
|
28
|
+
values[flag] = args[++index];
|
|
29
|
+
}
|
|
30
|
+
return values;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function transportCommand(args, { request = macRequest, print = console.log } = {}) {
|
|
34
|
+
if (!args.length || ["help", "--help"].includes(args[0])) return print(HELP);
|
|
35
|
+
const [command, value, ...rest] = args;
|
|
36
|
+
const id = sessionId(value);
|
|
37
|
+
const base = `/api/v1/mac/sessions/${id}`;
|
|
38
|
+
let result;
|
|
39
|
+
if (command === "status") {
|
|
40
|
+
parsed(rest, []); result = await request("GET", base);
|
|
41
|
+
} else if (command === "exec") {
|
|
42
|
+
const separator = rest.indexOf("--");
|
|
43
|
+
if (separator === -1 || separator === rest.length - 1) throw new CliError("Use -- before the command and its arguments.");
|
|
44
|
+
const options = parsed(rest.slice(0, separator), ["--request-key", "--timeout"]);
|
|
45
|
+
if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(options["--request-key"] ?? "")) throw new CliError("Supply a stable --request-key of 8 to 128 safe characters.");
|
|
46
|
+
const timeout = Number(options["--timeout"] ?? 60);
|
|
47
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 600) throw new CliError("--timeout must be 1 to 600 seconds.");
|
|
48
|
+
result = await request("POST", `${base}/jobs`, { requestKey: options["--request-key"], body: { argv: rest.slice(separator + 1), timeout_seconds: timeout } });
|
|
49
|
+
} else if (command === "job" || command === "output") {
|
|
50
|
+
const [job, ...flags] = rest;
|
|
51
|
+
if (!/^job_[a-f0-9]{40}$/.test(job ?? "")) throw new CliError("Invalid job ID.");
|
|
52
|
+
const options = parsed(flags, command === "output" ? ["--cursor", "--stream"] : []);
|
|
53
|
+
const cursor = options["--cursor"] ?? "0", stream = options["--stream"] ?? "stdout";
|
|
54
|
+
if (!/^\d{1,16}$/.test(cursor) || !Number.isSafeInteger(Number(cursor)) || !["stdout", "stderr"].includes(stream)) throw new CliError("Invalid output stream or cursor.");
|
|
55
|
+
result = await request("GET", `${base}/jobs/${job}${command === "output" ? `/output?cursor=${cursor}&stream=${stream}` : ""}`);
|
|
56
|
+
} else if (command === "put") {
|
|
57
|
+
const [file, ...flags] = rest; parsed(flags, []);
|
|
58
|
+
if (!file) throw new CliError("Supply the file to upload.");
|
|
59
|
+
const info = statSync(file);
|
|
60
|
+
if (!info.isFile() || info.size > 8 * 1024 * 1024) throw new CliError("The probe accepts regular files up to 8 MiB.");
|
|
61
|
+
const data = readFileSync(file);
|
|
62
|
+
if (data.length > 8 * 1024 * 1024) throw new CliError("The file exceeded the probe limit.");
|
|
63
|
+
const digest = createHash("sha256").update(data).digest("hex");
|
|
64
|
+
let offset = 0;
|
|
65
|
+
do {
|
|
66
|
+
result = await request("PUT", `${base}/blobs/${digest}?size=${data.length}&offset=${offset}`, { raw: data.subarray(offset, offset + 256 * 1024) });
|
|
67
|
+
offset += 256 * 1024;
|
|
68
|
+
} while (offset < data.length && !result.complete);
|
|
69
|
+
if (!result.complete || result.id !== digest) throw new CliError("The upload was not confirmed. Retry the same file to resume.", undefined, undefined, "transfer_unconfirmed");
|
|
70
|
+
} else if (command === "get") {
|
|
71
|
+
const [digest, file, ...flags] = rest; parsed(flags, []);
|
|
72
|
+
if (!/^[a-f0-9]{64}$/.test(digest ?? "") || !file) throw new CliError("Supply the blob SHA-256 and destination file.");
|
|
73
|
+
const chunks = []; let cursor = 0, size;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const chunk = await request("GET", `${base}/blobs/${digest}?cursor=${cursor}`);
|
|
76
|
+
const data = Buffer.from(chunk.data_base64 ?? "", "base64");
|
|
77
|
+
size ??= chunk.size;
|
|
78
|
+
if (chunk.id !== digest || !Number.isSafeInteger(size) || size < 0 || size > 8 * 1024 * 1024 || chunk.size !== size || chunk.cursor !== cursor || chunk.next_cursor !== cursor + data.length || chunk.next_cursor > size || (!data.length && !chunk.eof)) throw new CliError("Invalid file transfer response.", undefined, undefined, "transfer_unconfirmed");
|
|
79
|
+
chunks.push(data); cursor = chunk.next_cursor;
|
|
80
|
+
if (chunk.eof) break;
|
|
81
|
+
}
|
|
82
|
+
const data = Buffer.concat(chunks);
|
|
83
|
+
if (data.length !== size || createHash("sha256").update(data).digest("hex") !== digest) throw new CliError("Download checksum mismatch. No destination file was written.", undefined, undefined, "checksum_mismatch");
|
|
84
|
+
const destination = resolve(file), temporary = join(dirname(destination), `.nomac-download-${randomBytes(12).toString("hex")}`);
|
|
85
|
+
writeFileSync(temporary, data, { flag: "wx", mode: 0o600 });
|
|
86
|
+
try { linkSync(temporary, destination); } finally { unlinkSync(temporary); }
|
|
87
|
+
result = { id: digest, size: data.length, file: destination, complete: true };
|
|
88
|
+
} else throw new CliError(`Unknown transport command: ${command}`);
|
|
89
|
+
print(JSON.stringify(result));
|
|
90
|
+
return result;
|
|
91
|
+
}
|
package/src/upload.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export async function uploadSourceSnapshot(projectId, tarball) {
|
|
|
9
9
|
} catch (error) {
|
|
10
10
|
// Older servers have no grant route. Outages and authorization failures
|
|
11
11
|
// should remain actionable, not silently retry through another transport.
|
|
12
|
-
if (!(error instanceof CliError) || error.status !== 404 || error.code) throw error;
|
|
12
|
+
if (!(error instanceof CliError) || error.status !== 404 || (error.code && error.code !== "bad_response")) throw error;
|
|
13
13
|
if (tarball.length > 4 * 1024 * 1024) {
|
|
14
14
|
throw new CliError("direct uploads unavailable", "This server does not support direct source uploads. Update the server or reduce the compressed archive to 4 MiB.");
|
|
15
15
|
}
|