@nomac/cli 0.2.1 → 0.2.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/package.json +1 -1
- package/skill/SKILL.md +16 -3
- package/src/api.mjs +7 -3
- package/src/cli.mjs +9 -30
- package/src/mcp.mjs +66 -15
- package/src/pack.mjs +22 -12
- package/src/upload.mjs +36 -0
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -47,12 +47,25 @@ status # poll ~30s until ready | failed
|
|
|
47
47
|
2. `set_metadata` — you write the copy; check `get_metadata_schema` for
|
|
48
48
|
limits. Age rating + data usage answers are legal attestations: ask the
|
|
49
49
|
human, relay their answers. `upload_screenshots` needs exact dimensions
|
|
50
|
-
(in the schema).
|
|
50
|
+
(in the schema). It runs in the background: poll `get_screenshot_upload`
|
|
51
|
+
until complete before publishing. A rolled-back or paused replacement
|
|
52
|
+
needs the returned recovery instructions before starting another upload.
|
|
51
53
|
3. `publish` (no confirm) → staged dry-run; fix any Apple blockers it returns.
|
|
52
54
|
4. Ask the human explicitly before `publish confirm=true` — it's irreversible.
|
|
53
55
|
|
|
56
|
+
If metadata or publish returns pending, keep its `request_key` and repeat
|
|
57
|
+
the original arguments with that key. `get_store_operation` recovers the key
|
|
58
|
+
and progress after a lost response. A new key starts a separate operation;
|
|
59
|
+
do not use one to bypass an unconfirmed Apple request. Staging and confirmation
|
|
60
|
+
remain separate invocations. For screenshots, keep the original `request_key`
|
|
61
|
+
too, and use `get_screenshot_upload` to find or poll the existing operation.
|
|
62
|
+
|
|
54
63
|
## When stuck
|
|
55
64
|
|
|
56
65
|
`report_issue` with the failing `ref_id` files a support ticket with full
|
|
57
|
-
context auto-attached (deduped, so safe to call).
|
|
58
|
-
|
|
66
|
+
context auto-attached (deduped, so safe to call). Its optional `email` requests
|
|
67
|
+
a direct resolution update; without it, read replies with the same tool. If the
|
|
68
|
+
tool returns a Private Relay warning, explain that Apple relay addresses may
|
|
69
|
+
reject later mail and offer a non-relay address. Never show that warning for a
|
|
70
|
+
normal address such as `@icloud.com`. Never retry-loop an identical failure
|
|
71
|
+
more than twice.
|
package/src/api.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { loadApiKey, loadApiUrl } from "./config.mjs";
|
|
2
2
|
|
|
3
3
|
export class CliError extends Error {
|
|
4
|
-
constructor(message, friendly) {
|
|
4
|
+
constructor(message, friendly, status, code) {
|
|
5
5
|
super(message);
|
|
6
6
|
this.friendly = friendly ?? message;
|
|
7
7
|
this.exitCode = 1;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -21,7 +23,7 @@ export async function apiPublic(method, path, body) {
|
|
|
21
23
|
try {
|
|
22
24
|
json = text ? JSON.parse(text) : {};
|
|
23
25
|
} catch {
|
|
24
|
-
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}
|
|
26
|
+
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
|
|
25
27
|
}
|
|
26
28
|
return { status: res.status, ok: res.ok, json };
|
|
27
29
|
}
|
|
@@ -53,13 +55,15 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
|
|
|
53
55
|
try {
|
|
54
56
|
json = text ? JSON.parse(text) : {};
|
|
55
57
|
} catch {
|
|
56
|
-
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}
|
|
58
|
+
throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
|
|
57
59
|
}
|
|
58
60
|
if (!res.ok) {
|
|
59
61
|
const err = json?.error ?? {};
|
|
60
62
|
throw new CliError(
|
|
61
63
|
`HTTP ${res.status} ${err.code ?? ""}`,
|
|
62
64
|
err.message ?? `API error (HTTP ${res.status})`,
|
|
65
|
+
res.status,
|
|
66
|
+
err.code,
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
69
|
return json;
|
package/src/cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import { api, CliError } from "./api.mjs";
|
|
3
3
|
import { loadProjectState, saveApiKey, saveProjectState } from "./config.mjs";
|
|
4
4
|
import { packTarball } from "./pack.mjs";
|
|
5
|
+
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
5
6
|
|
|
6
7
|
const HELP = `nomac — ship iOS apps without a Mac (agent-first)
|
|
7
8
|
|
|
@@ -85,36 +86,7 @@ async function push() {
|
|
|
85
86
|
const { tarball, files } = await packTarball(process.cwd());
|
|
86
87
|
console.log(`${files.length} files, ${(tarball.length / 1024).toFixed(0)} KB compressed`);
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
// Servers without the uploads route get the legacy inline body instead.
|
|
90
|
-
let grant = null;
|
|
91
|
-
try {
|
|
92
|
-
grant = await api("POST", `/api/v1/projects/${state.project_id}/snapshots/uploads`);
|
|
93
|
-
} catch {
|
|
94
|
-
grant = null;
|
|
95
|
-
}
|
|
96
|
-
let res;
|
|
97
|
-
if (grant) {
|
|
98
|
-
if (tarball.length > grant.max_bytes) {
|
|
99
|
-
throw new CliError(
|
|
100
|
-
"tarball too large",
|
|
101
|
-
`Packed source is ${(tarball.length / 1048576).toFixed(1)} MB; the limit is ${(grant.max_bytes / 1048576).toFixed(0)} MB. Check .gitignore: build artifacts are recreated on the build VM.`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const up = await fetch(grant.upload_url, {
|
|
105
|
-
method: "PUT",
|
|
106
|
-
headers: { "content-type": "application/gzip" },
|
|
107
|
-
body: tarball,
|
|
108
|
-
});
|
|
109
|
-
if (!up.ok) throw new CliError(`upload failed (HTTP ${up.status})`);
|
|
110
|
-
res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
|
|
111
|
-
body: { upload_key: grant.upload_key },
|
|
112
|
-
});
|
|
113
|
-
} else {
|
|
114
|
-
res = await api("POST", `/api/v1/projects/${state.project_id}/snapshots`, {
|
|
115
|
-
raw: tarball,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
89
|
+
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
118
90
|
console.log(`✅ snapshot ${res.snapshot_id} (commit ${res.commit.slice(0, 8)})`);
|
|
119
91
|
const d = res.detected ?? {};
|
|
120
92
|
const framework = d.project_kind
|
|
@@ -169,6 +141,7 @@ const SPINNER_STATES = {
|
|
|
169
141
|
async function waitForBuild(id) {
|
|
170
142
|
const started = Date.now();
|
|
171
143
|
let last = "";
|
|
144
|
+
let lastNotice = "";
|
|
172
145
|
for (;;) {
|
|
173
146
|
const b = await api("GET", `/api/v1/builds/${id}`);
|
|
174
147
|
if (b.state !== last) {
|
|
@@ -176,6 +149,12 @@ async function waitForBuild(id) {
|
|
|
176
149
|
console.log(` [${t}s] ${SPINNER_STATES[b.state] ?? b.state}`);
|
|
177
150
|
last = b.state;
|
|
178
151
|
}
|
|
152
|
+
const notice = b.error?.stage === "testflight" ? b.error.raw : "";
|
|
153
|
+
if (notice && notice !== lastNotice) {
|
|
154
|
+
console.log(` ${notice}`);
|
|
155
|
+
if (b.error.url) console.log(` ${b.error.url}`);
|
|
156
|
+
}
|
|
157
|
+
lastNotice = notice;
|
|
179
158
|
if (b.state === "ready") {
|
|
180
159
|
console.log(
|
|
181
160
|
b.workflow === "smoke"
|
package/src/mcp.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { createInterface } from "node:readline";
|
|
|
7
7
|
import { api } from "./api.mjs";
|
|
8
8
|
import { loadProjectState, saveProjectState } from "./config.mjs";
|
|
9
9
|
import { packTarball } from "./pack.mjs";
|
|
10
|
+
import { uploadSourceSnapshot } from "./upload.mjs";
|
|
10
11
|
|
|
11
12
|
const PROTOCOL_FALLBACK = "2025-06-18";
|
|
12
13
|
|
|
@@ -43,6 +44,15 @@ export const TOOLS = [
|
|
|
43
44
|
return text({ org: me.org, connection: detail });
|
|
44
45
|
},
|
|
45
46
|
},
|
|
47
|
+
{
|
|
48
|
+
name: "set_project_connection",
|
|
49
|
+
description: "Choose an Apple connection for a project, including after key rotation. Verifies the app's bundle ID. Active release builds must finish first.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object", properties: { project_id: { type: "string" }, connection_id: { type: "string" } },
|
|
52
|
+
required: ["connection_id"], additionalProperties: false,
|
|
53
|
+
},
|
|
54
|
+
handler: async args => text(await api("PATCH", `/api/v1/projects/${await resolveProjectId(args)}`, { body: { connection_id: args.connection_id } })),
|
|
55
|
+
},
|
|
46
56
|
{
|
|
47
57
|
name: "push_project",
|
|
48
58
|
description:
|
|
@@ -64,9 +74,7 @@ export const TOOLS = [
|
|
|
64
74
|
saveProjectState(state, dir);
|
|
65
75
|
}
|
|
66
76
|
const { tarball, files } = await packTarball(dir);
|
|
67
|
-
const res = await
|
|
68
|
-
raw: tarball,
|
|
69
|
-
});
|
|
77
|
+
const res = await uploadSourceSnapshot(state.project_id, tarball);
|
|
70
78
|
saveProjectState({ ...state, last_snapshot: res.snapshot_id }, dir);
|
|
71
79
|
return text({ project_id: state.project_id, files_packed: files.length, ...res });
|
|
72
80
|
},
|
|
@@ -89,8 +97,22 @@ export const TOOLS = [
|
|
|
89
97
|
body: { project_id, workflow: args?.workflow ?? "release" },
|
|
90
98
|
idempotencyKey: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
91
99
|
});
|
|
100
|
+
let warning;
|
|
101
|
+
try {
|
|
102
|
+
const state = loadProjectState();
|
|
103
|
+
if (state?.project_id === project_id) {
|
|
104
|
+
saveProjectState({ ...state, last_build: res.id });
|
|
105
|
+
} else {
|
|
106
|
+
warning = "Build started for a project outside this directory's saved project. Pass build_id to status.";
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// The paid attempt already exists. Return its ID even when the local
|
|
110
|
+
// directory is read-only, so the agent can poll without starting again.
|
|
111
|
+
warning = "Build started, but its ID could not be saved locally. Pass build_id to status.";
|
|
112
|
+
}
|
|
92
113
|
return text({
|
|
93
114
|
...res,
|
|
115
|
+
...(warning ? { warning } : {}),
|
|
94
116
|
note: "poll `status` every ~30s; a release build reaching `ready` means the phone can install it from TestFlight",
|
|
95
117
|
});
|
|
96
118
|
},
|
|
@@ -141,24 +163,26 @@ export const TOOLS = [
|
|
|
141
163
|
},
|
|
142
164
|
{
|
|
143
165
|
name: "get_metadata",
|
|
144
|
-
description: "Current App Store metadata (app +
|
|
166
|
+
description: "Current App Store metadata (app + selected iOS version localizations) from App Store Connect.",
|
|
145
167
|
inputSchema: {
|
|
146
168
|
type: "object",
|
|
147
|
-
properties: { project_id: { type: "string" } },
|
|
169
|
+
properties: { project_id: { type: "string" }, version_string: { type: "string", description: "Defaults to the pushed source marketing version" } },
|
|
148
170
|
additionalProperties: false,
|
|
149
171
|
},
|
|
150
172
|
handler: async (args) =>
|
|
151
|
-
text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata`)),
|
|
173
|
+
text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/metadata${args?.version_string ? `?version_string=${encodeURIComponent(args.version_string)}` : ""}`)),
|
|
152
174
|
},
|
|
153
175
|
{
|
|
154
176
|
name: "set_metadata",
|
|
155
177
|
description:
|
|
156
|
-
"Write App Store metadata YOU authored (validated against limits before any Apple call). fields: description, keywords, whats_new, support_url, marketing_url, promotional_text, name, subtitle, privacy_policy_url. Also: primary_category (e.g. UTILITIES), age_rating (attestations from your human), content_rights, copyright, review_contact{...,demo_account}, price:'FREE'.",
|
|
178
|
+
"Write App Store metadata YOU authored (validated against limits before any Apple call). fields: description, keywords, whats_new, support_url, marketing_url, promotional_text, name, subtitle, privacy_policy_url. Also: primary_category (e.g. UTILITIES), age_rating (attestations from your human), content_rights, copyright, review_contact{...,demo_account}, price:'FREE'. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
157
179
|
inputSchema: {
|
|
158
180
|
type: "object",
|
|
159
181
|
properties: {
|
|
160
182
|
project_id: { type: "string" },
|
|
161
183
|
locale: { type: "string", default: "en-US" },
|
|
184
|
+
version_string: { type: "string", description: "Defaults to the pushed source marketing version; select a live version explicitly for promotional text" },
|
|
185
|
+
request_key: { type: "string", description: "Reuse the returned key to resume pending work with the original unchanged arguments" },
|
|
162
186
|
fields: { type: "object" },
|
|
163
187
|
primary_category: { type: "string" },
|
|
164
188
|
age_rating: { type: "object" },
|
|
@@ -170,21 +194,23 @@ export const TOOLS = [
|
|
|
170
194
|
additionalProperties: false,
|
|
171
195
|
},
|
|
172
196
|
handler: async (args) => {
|
|
173
|
-
const { project_id: _p, ...rest } = args ?? {};
|
|
197
|
+
const { project_id: _p, request_key, ...rest } = args ?? {};
|
|
174
198
|
const project_id = await resolveProjectId(args);
|
|
175
|
-
return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest }));
|
|
199
|
+
return text(await api("PUT", `/api/v1/projects/${project_id}/metadata`, { body: rest, idempotencyKey: request_key }));
|
|
176
200
|
},
|
|
177
201
|
},
|
|
178
202
|
{
|
|
179
203
|
name: "upload_screenshots",
|
|
180
204
|
description:
|
|
181
|
-
"
|
|
205
|
+
"Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, data: base64 PNG}].",
|
|
182
206
|
inputSchema: {
|
|
183
207
|
type: "object",
|
|
184
208
|
properties: {
|
|
185
209
|
project_id: { type: "string" },
|
|
186
210
|
display_type: { type: "string" },
|
|
211
|
+
request_key: { type: "string", description: "Unique per replacement; reuse this key and the same images on a retry" },
|
|
187
212
|
locale: { type: "string", default: "en-US" },
|
|
213
|
+
version_string: { type: "string", description: "Defaults to the pushed source marketing version" },
|
|
188
214
|
images: { type: "array", items: { type: "object" } },
|
|
189
215
|
},
|
|
190
216
|
required: ["display_type", "images"],
|
|
@@ -192,19 +218,38 @@ export const TOOLS = [
|
|
|
192
218
|
},
|
|
193
219
|
handler: async (args) => {
|
|
194
220
|
const project_id = await resolveProjectId(args);
|
|
195
|
-
const { project_id: _p, ...rest } = args;
|
|
196
|
-
return text(await api("POST", `/api/v1/projects/${project_id}/screenshots`, { body: rest }));
|
|
221
|
+
const { project_id: _p, request_key, ...rest } = args;
|
|
222
|
+
return text(await api("POST", `/api/v1/projects/${project_id}/screenshots`, { body: rest, idempotencyKey: request_key }));
|
|
197
223
|
},
|
|
198
224
|
},
|
|
225
|
+
{
|
|
226
|
+
name: "get_screenshot_upload",
|
|
227
|
+
description: "Check screenshot replacement/restoration. Omit operation_id to list recent operations after a lost upload response. complete confirms processed images; rolled_back means previous images were retained/restored; needs_attention requires support reconciliation.",
|
|
228
|
+
inputSchema: {
|
|
229
|
+
type: "object", properties: { project_id: { type: "string" }, operation_id: { type: "string" } },
|
|
230
|
+
additionalProperties: false,
|
|
231
|
+
},
|
|
232
|
+
handler: async args => {
|
|
233
|
+
const project_id = await resolveProjectId(args);
|
|
234
|
+
return text(await api("GET", `/api/v1/projects/${project_id}/screenshots${args.operation_id ? `?operation_id=${encodeURIComponent(args.operation_id)}` : ""}`));
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "get_store_operation",
|
|
239
|
+
description: "Read saved publish/metadata requests and recover a lost response or request_key. Resume pending work by repeating the original publish/set_metadata arguments with its returned request_key. This status call performs no Apple writes.",
|
|
240
|
+
inputSchema: { type: "object", properties: { project_id: { type: "string" }, request_key: { type: "string" } }, additionalProperties: false },
|
|
241
|
+
handler: async args => text(await api("GET", `/api/v1/projects/${await resolveProjectId(args)}/operations${args?.request_key ? `?request_key=${encodeURIComponent(args.request_key)}` : ""}`)),
|
|
242
|
+
},
|
|
199
243
|
{
|
|
200
244
|
name: "publish",
|
|
201
245
|
description:
|
|
202
|
-
"Submit for App Store review (3-step reviewSubmissions). IRREVERSIBLE once confirmed — requires confirm:true; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days.",
|
|
246
|
+
"Submit for App Store review (3-step reviewSubmissions). IRREVERSIBLE once confirmed — requires confirm:true; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
|
|
203
247
|
inputSchema: {
|
|
204
248
|
type: "object",
|
|
205
249
|
properties: {
|
|
206
250
|
project_id: { type: "string" },
|
|
207
251
|
confirm: { type: "boolean", default: false },
|
|
252
|
+
request_key: { type: "string", description: "Only supply to resume an interrupted invocation with its original unchanged arguments" },
|
|
208
253
|
force: { type: "boolean", default: false, description: "override a red lint gate (not recommended)" },
|
|
209
254
|
},
|
|
210
255
|
additionalProperties: false,
|
|
@@ -216,7 +261,7 @@ export const TOOLS = [
|
|
|
216
261
|
// this project forever. A later invocation must re-run mutable lint and
|
|
217
262
|
// App Store preflight checks; retries inside this invocation reuse this
|
|
218
263
|
// single key.
|
|
219
|
-
const idempotencyKey = `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
|
|
264
|
+
const idempotencyKey = args?.request_key ?? `mcp-pub-${project_id}-${mode}-${randomUUID()}`;
|
|
220
265
|
return text(
|
|
221
266
|
await api("POST", "/api/v1/publish", {
|
|
222
267
|
body: { project_id, confirm: args?.confirm ?? false, force: args?.force ?? false },
|
|
@@ -240,12 +285,18 @@ export const TOOLS = [
|
|
|
240
285
|
{
|
|
241
286
|
name: "report_issue",
|
|
242
287
|
description:
|
|
243
|
-
"File an unknown/unfixable error with nomac support. Auto-attaches build error, logs (redacted) and lint state when you pass ref_id (a bld_… id). Deduped by error fingerprint.
|
|
288
|
+
"File an unknown/unfixable error with nomac support. Auto-attaches build error, logs (redacted) and lint state when you pass ref_id (a bld_… id). Deduped by error fingerprint. An optional email requests a direct resolution update; replies are always available by calling with no description.",
|
|
244
289
|
inputSchema: {
|
|
245
290
|
type: "object",
|
|
246
291
|
properties: {
|
|
247
292
|
description: { type: "string", description: "what went wrong + what you tried (≥10 chars). Omit to read existing tickets/replies." },
|
|
248
293
|
ref_id: { type: "string" },
|
|
294
|
+
email: {
|
|
295
|
+
type: "string",
|
|
296
|
+
format: "email",
|
|
297
|
+
maxLength: 320,
|
|
298
|
+
description: "optional follow-up email for a direct resolution update",
|
|
299
|
+
},
|
|
249
300
|
},
|
|
250
301
|
additionalProperties: false,
|
|
251
302
|
},
|
package/src/pack.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join, relative } from "node:path";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
3
|
import ignoreFactory from "ignore";
|
|
4
4
|
import * as tar from "tar";
|
|
5
5
|
|
|
@@ -24,21 +24,31 @@ const ALWAYS_IGNORE = [
|
|
|
24
24
|
];
|
|
25
25
|
|
|
26
26
|
export function collectFiles(dir) {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
ig.add(readFileSync(join(dir, ".gitignore"), "utf8"));
|
|
30
|
-
} catch {
|
|
31
|
-
// no .gitignore — fine
|
|
32
|
-
}
|
|
33
|
-
|
|
27
|
+
const denied = ignoreFactory().add(ALWAYS_IGNORE);
|
|
28
|
+
const relativePath = (base, path) => relative(base, path).split(sep).join("/");
|
|
34
29
|
const files = [];
|
|
35
|
-
const walk = (current) => {
|
|
30
|
+
const walk = (current, inherited = []) => {
|
|
31
|
+
let scopes = inherited;
|
|
32
|
+
try {
|
|
33
|
+
const rules = readFileSync(join(current, ".gitignore"), "utf8");
|
|
34
|
+
scopes = [...inherited, { base: current, ignore: ignoreFactory().add(rules) }];
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code !== "ENOENT") throw error;
|
|
37
|
+
}
|
|
36
38
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
37
39
|
const full = join(current, entry.name);
|
|
38
|
-
const rel =
|
|
40
|
+
const rel = relativePath(dir, full);
|
|
39
41
|
const relForIgnore = entry.isDirectory() ? `${rel}/` : rel;
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
+
if (denied.ignores(relForIgnore)) continue;
|
|
43
|
+
let ignored = false;
|
|
44
|
+
for (const scope of scopes) {
|
|
45
|
+
const scoped = relativePath(scope.base, full) + (entry.isDirectory() ? "/" : "");
|
|
46
|
+
const result = scope.ignore.test(scoped);
|
|
47
|
+
if (result.ignored) ignored = true;
|
|
48
|
+
else if (result.unignored) ignored = false;
|
|
49
|
+
}
|
|
50
|
+
if (ignored) continue;
|
|
51
|
+
if (entry.isDirectory()) walk(full, scopes);
|
|
42
52
|
else if (entry.isFile()) files.push(rel);
|
|
43
53
|
}
|
|
44
54
|
};
|
package/src/upload.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { api, CliError } from "./api.mjs";
|
|
2
|
+
|
|
3
|
+
/** Shared by the CLI and stdio MCP so both bypass the API's 4 MiB body limit. */
|
|
4
|
+
export async function uploadSourceSnapshot(projectId, tarball) {
|
|
5
|
+
const path = `/api/v1/projects/${projectId}/snapshots`;
|
|
6
|
+
let grant;
|
|
7
|
+
try {
|
|
8
|
+
grant = await api("POST", `${path}/uploads`);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
// Older servers have no grant route. Outages and authorization failures
|
|
11
|
+
// should remain actionable, not silently retry through another transport.
|
|
12
|
+
if (!(error instanceof CliError) || error.status !== 404 || error.code) throw error;
|
|
13
|
+
if (tarball.length > 4 * 1024 * 1024) {
|
|
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
|
+
}
|
|
16
|
+
return api("POST", path, { raw: tarball });
|
|
17
|
+
}
|
|
18
|
+
if (typeof grant.upload_url !== "string" || typeof grant.upload_key !== "string" ||
|
|
19
|
+
!Number.isSafeInteger(grant.max_bytes) || grant.max_bytes <= 0) {
|
|
20
|
+
throw new CliError("invalid source upload grant");
|
|
21
|
+
}
|
|
22
|
+
if (tarball.length > grant.max_bytes) {
|
|
23
|
+
throw new CliError(
|
|
24
|
+
"tarball too large",
|
|
25
|
+
`Packed source is ${(tarball.length / 1048576).toFixed(1)} MiB; the limit is ${(grant.max_bytes / 1048576).toFixed(0)} MiB. Check .gitignore: build artifacts are recreated on the build VM.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const uploaded = await fetch(grant.upload_url, {
|
|
29
|
+
method: "PUT",
|
|
30
|
+
headers: { "content-type": "application/gzip", "content-length": String(tarball.length) },
|
|
31
|
+
body: tarball,
|
|
32
|
+
});
|
|
33
|
+
await uploaded.body?.cancel();
|
|
34
|
+
if (!uploaded.ok) throw new CliError(`upload failed (HTTP ${uploaded.status})`);
|
|
35
|
+
return api("POST", path, { body: { upload_key: grant.upload_key } });
|
|
36
|
+
}
|