@tryarcanist/cli 0.1.105 → 0.1.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1146 -18
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ arcanist sessions create your-org/your-repo "verify the deployed change" --cold
|
|
|
128
128
|
arcanist sessions create your-org/your-repo "retry-safe create" --idempotency-key 1f0e6f1a-...
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
`--backend` picks the agent runtime backend: `codex` (default) or `claude_code`. `--model` must be valid for the chosen backend — codex runs OpenAI models (`gpt-5.
|
|
131
|
+
`--backend` picks the agent runtime backend: `codex` (default) or `claude_code`. `--model` must be valid for the chosen backend — codex runs OpenAI models (`gpt-5.4` default, `gpt-5.5`), `claude_code` runs Anthropic models (`claude-opus-4-8` default, `claude-sonnet-4-6`) — and the CLI rejects a mismatch before any network call. `claude_code` sessions require an Anthropic API key configured in Settings.
|
|
132
132
|
|
|
133
133
|
`--wait` blocks until the created prompt finishes and exits non-zero if the prompt finishes with `status: failed`, making it suitable for cron or other schedulers that alert on command failure. `--poll-interval <ms>` tunes the completion check frequency.
|
|
134
134
|
|
package/dist/index.js
CHANGED
|
@@ -421,27 +421,29 @@ var SESSION_START_MODEL_IDS_BY_BACKEND = {
|
|
|
421
421
|
[CLAUDE_CODE_AGENT_RUNTIME_BACKEND]: [AnthropicModel.Opus48, AnthropicModel.Sonnet46]
|
|
422
422
|
};
|
|
423
423
|
var DEFAULT_SESSION_START_MODEL_ID_BY_BACKEND = {
|
|
424
|
-
[CODEX_AGENT_RUNTIME_BACKEND]: OpenAIModel.
|
|
424
|
+
[CODEX_AGENT_RUNTIME_BACKEND]: OpenAIModel.GPT54,
|
|
425
425
|
[CLAUDE_CODE_AGENT_RUNTIME_BACKEND]: AnthropicModel.Opus48
|
|
426
426
|
};
|
|
427
427
|
var SESSION_START_MODEL_IDS = SESSION_START_MODEL_IDS_BY_BACKEND[CODEX_AGENT_RUNTIME_BACKEND];
|
|
428
428
|
var DEFAULT_SESSION_START_MODEL_ID = DEFAULT_SESSION_START_MODEL_ID_BY_BACKEND[CODEX_AGENT_RUNTIME_BACKEND];
|
|
429
429
|
var MODEL_REGISTRY = [
|
|
430
430
|
{
|
|
431
|
-
id: OpenAIModel.
|
|
432
|
-
name: "GPT-5.
|
|
431
|
+
id: OpenAIModel.GPT54,
|
|
432
|
+
name: "GPT-5.4",
|
|
433
433
|
provider: "openai",
|
|
434
434
|
backends: [CODEX_AGENT_RUNTIME_BACKEND],
|
|
435
435
|
contextWindow: 1e6,
|
|
436
|
+
// Default codex model after the gpt-5.5 downgrade; carries gpt-5.5's prior
|
|
437
|
+
// "medium" default so default sessions keep the same reasoning effort.
|
|
436
438
|
reasoning: { efforts: ["none", "low", "medium", "high", "xhigh"], default: "medium" }
|
|
437
439
|
},
|
|
438
440
|
{
|
|
439
|
-
id: OpenAIModel.
|
|
440
|
-
name: "GPT-5.
|
|
441
|
+
id: OpenAIModel.GPT55,
|
|
442
|
+
name: "GPT-5.5",
|
|
441
443
|
provider: "openai",
|
|
442
444
|
backends: [CODEX_AGENT_RUNTIME_BACKEND],
|
|
443
445
|
contextWindow: 1e6,
|
|
444
|
-
reasoning: { efforts: ["none", "low", "medium", "high", "xhigh"], default:
|
|
446
|
+
reasoning: { efforts: ["none", "low", "medium", "high", "xhigh"], default: "medium" }
|
|
445
447
|
},
|
|
446
448
|
{
|
|
447
449
|
id: OpenAIModel.GPT54Mini,
|
|
@@ -579,6 +581,19 @@ function getSessionStartModelIdsForBackend(backend) {
|
|
|
579
581
|
function isSessionStartModelAllowedForBackend(modelId, backend) {
|
|
580
582
|
return VALID_SESSION_START_MODEL_IDS_BY_BACKEND[backend].has(modelId);
|
|
581
583
|
}
|
|
584
|
+
var MODEL_TIER_BY_ID = {
|
|
585
|
+
[OpenAIModel.GPT55]: "frontier",
|
|
586
|
+
[OpenAIModel.GPT54]: "mid",
|
|
587
|
+
[AnthropicModel.Opus48]: "frontier",
|
|
588
|
+
[AnthropicModel.Sonnet46]: "mid"
|
|
589
|
+
};
|
|
590
|
+
var CODEX_MODEL_ID_BY_TIER = {
|
|
591
|
+
// Single source of truth: the frontier codex model follows the codex default,
|
|
592
|
+
// so changing the default propagates here automatically. Only used for
|
|
593
|
+
// non-codex (e.g. claude) parents — codex parents verify on their own model.
|
|
594
|
+
frontier: DEFAULT_SESSION_START_MODEL_ID_BY_BACKEND[CODEX_AGENT_RUNTIME_BACKEND],
|
|
595
|
+
mid: OpenAIModel.GPT54
|
|
596
|
+
};
|
|
582
597
|
function getProviderForModel(modelId) {
|
|
583
598
|
return MODEL_PROVIDERS[modelId] ?? "openai";
|
|
584
599
|
}
|
|
@@ -750,9 +765,9 @@ function validateUploadedFilePayload(files, existingNames = []) {
|
|
|
750
765
|
label: "uploaded files",
|
|
751
766
|
normalizeItems: trimNamedItems,
|
|
752
767
|
validateItem: (file) => {
|
|
753
|
-
const
|
|
754
|
-
if (
|
|
755
|
-
return `Uploaded file too large: ${file.name} (${
|
|
768
|
+
const byteLength2 = new TextEncoder().encode(file.content).byteLength;
|
|
769
|
+
if (byteLength2 > MAX_UPLOADED_FILE_SIZE_BYTES) {
|
|
770
|
+
return `Uploaded file too large: ${file.name} (${byteLength2} bytes, max ${MAX_UPLOADED_FILE_SIZE_BYTES})`;
|
|
756
771
|
}
|
|
757
772
|
if (hasNullBytes(file.content)) {
|
|
758
773
|
return `Binary files are not supported: ${file.name}`;
|
|
@@ -2175,11 +2190,11 @@ function formatStatusLine(status) {
|
|
|
2175
2190
|
if (status.sandboxSubstate && status.sandboxSubstate !== "none") substate.push(status.sandboxSubstate);
|
|
2176
2191
|
if (status.stopMode && status.stopMode !== "none") substate.push(status.stopMode);
|
|
2177
2192
|
if (status.finalizingStep && status.finalizingStep !== "none") substate.push(status.finalizingStep);
|
|
2178
|
-
const
|
|
2193
|
+
const phaseLabel2 = substate.length > 0 ? `${label}/${substate.join("/")}` : label;
|
|
2179
2194
|
const details = [];
|
|
2180
2195
|
if (status.title) details.push(status.title);
|
|
2181
2196
|
if (status.spawnDurationMs != null) details.push(`spawn ${(status.spawnDurationMs / 1e3).toFixed(1)}s`);
|
|
2182
|
-
return details.length > 0 ? `[status] ${
|
|
2197
|
+
return details.length > 0 ? `[status] ${phaseLabel2} | ${details.join(" | ")}` : `[status] ${phaseLabel2}`;
|
|
2183
2198
|
}
|
|
2184
2199
|
function advanceStatusDisconnectMask(status, mask, lastView, now) {
|
|
2185
2200
|
const view = status.phase ? { phase: status.phase, sandboxSubstate: status.sandboxSubstate } : null;
|
|
@@ -2541,6 +2556,1103 @@ async function messageCommand(sessionId, promptArg, options = {}, command) {
|
|
|
2541
2556
|
console.log(`Message sent to session ${sessionId}.`);
|
|
2542
2557
|
}
|
|
2543
2558
|
|
|
2559
|
+
// src/commands/sandbox.ts
|
|
2560
|
+
import { execFileSync } from "child_process";
|
|
2561
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2562
|
+
import { dirname } from "path";
|
|
2563
|
+
|
|
2564
|
+
// ../../shared/github/repo-url.ts
|
|
2565
|
+
function parseGithubRepoFullName(value) {
|
|
2566
|
+
const shorthand = value.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
|
|
2567
|
+
if (shorthand) return { owner: shorthand[1], repo: shorthand[2] };
|
|
2568
|
+
const ssh = value.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
2569
|
+
if (ssh) return { owner: ssh[1], repo: ssh[2] };
|
|
2570
|
+
const https = value.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
2571
|
+
if (https) return { owner: https[1], repo: https[2] };
|
|
2572
|
+
return null;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
// ../../shared/sandbox-layer/parser.ts
|
|
2576
|
+
import { isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
2577
|
+
|
|
2578
|
+
// ../../shared/utils/hex.ts
|
|
2579
|
+
function bytesToHex(buf) {
|
|
2580
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
2581
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// ../../shared/sandbox-layer/parser.ts
|
|
2585
|
+
var textEncoder = new TextEncoder();
|
|
2586
|
+
async function computeSha256Hex(message) {
|
|
2587
|
+
const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(message));
|
|
2588
|
+
return bytesToHex(new Uint8Array(digest));
|
|
2589
|
+
}
|
|
2590
|
+
var SANDBOX_LAYER_MANIFEST_PATH = ".arcanist/sandbox.yaml";
|
|
2591
|
+
var MAX_MANIFEST_BYTES = 64 * 1024;
|
|
2592
|
+
var MAX_LAYER_BYTES = 128 * 1024;
|
|
2593
|
+
var MAX_RUN_COMMAND_BYTES = 8 * 1024;
|
|
2594
|
+
var MAX_SMOKE_COMMANDS = 20;
|
|
2595
|
+
var MAX_SMOKE_ARGS = 32;
|
|
2596
|
+
var MAX_SMOKE_ARG_BYTES = 512;
|
|
2597
|
+
var MAX_DISPLAY_NAME_CHARS = 80;
|
|
2598
|
+
var MIN_DISPLAY_NAME_CHARS = 2;
|
|
2599
|
+
var MAX_ENV_KEY_CHARS = 80;
|
|
2600
|
+
var MAX_ENV_VALUE_BYTES = 2 * 1024;
|
|
2601
|
+
var MAX_ENV_VARS = 50;
|
|
2602
|
+
var SECRET_PATTERN = /\b(secret|token|password|passwd|private_key|private-key|access_key|access-key|client_secret|client-secret)\b|BEGIN PRIVATE KEY/i;
|
|
2603
|
+
var FORBIDDEN_PATHS = [
|
|
2604
|
+
"/app",
|
|
2605
|
+
"/app/start-bridge.sh",
|
|
2606
|
+
"/app/bridge",
|
|
2607
|
+
"/app/scripts",
|
|
2608
|
+
"/workspace",
|
|
2609
|
+
"/usr/local/sbin/arcanist-enforce-egress"
|
|
2610
|
+
];
|
|
2611
|
+
var FORBIDDEN_TARGETING_OPS = /\b(rm|mv|cp|ln|chmod|chown|truncate|tee)\b|\bsed\s+-i\b|\bcat\s*>/;
|
|
2612
|
+
var ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,79}$/;
|
|
2613
|
+
var SandboxLayerValidationError = class extends Error {
|
|
2614
|
+
issues;
|
|
2615
|
+
path;
|
|
2616
|
+
detail;
|
|
2617
|
+
line;
|
|
2618
|
+
constructor(pathOrIssues, detail, line, field, code = "invalid") {
|
|
2619
|
+
const issues = Array.isArray(pathOrIssues) ? pathOrIssues : [{ path: pathOrIssues, line, field, code, message: detail ?? "invalid sandbox layer source" }];
|
|
2620
|
+
const first = issues[0] ?? { path: "sandbox-layer", message: "invalid sandbox layer source" };
|
|
2621
|
+
super(formatIssue(first));
|
|
2622
|
+
this.name = "SandboxLayerValidationError";
|
|
2623
|
+
this.issues = issues;
|
|
2624
|
+
this.path = first.path;
|
|
2625
|
+
this.detail = first.message;
|
|
2626
|
+
this.line = first.line;
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
function formatIssue(issue2) {
|
|
2630
|
+
return `${issue2.path}${issue2.line ? `:${issue2.line}` : ""}: ${issue2.message}`;
|
|
2631
|
+
}
|
|
2632
|
+
function issue(path, message, options = {}) {
|
|
2633
|
+
return {
|
|
2634
|
+
path,
|
|
2635
|
+
line: options.line,
|
|
2636
|
+
field: options.field,
|
|
2637
|
+
code: options.code ?? "invalid",
|
|
2638
|
+
message
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
function byteLength(value) {
|
|
2642
|
+
return new TextEncoder().encode(value).byteLength;
|
|
2643
|
+
}
|
|
2644
|
+
function rejectSecrets(path, text) {
|
|
2645
|
+
const match = text.match(SECRET_PATTERN);
|
|
2646
|
+
if (match) {
|
|
2647
|
+
throw new SandboxLayerValidationError(
|
|
2648
|
+
path,
|
|
2649
|
+
`sandbox layer source appears to contain a secret-like token: ${match[0]}`
|
|
2650
|
+
);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
function assertSize(path, text, maxBytes) {
|
|
2654
|
+
if (text.includes("\0")) {
|
|
2655
|
+
throw new SandboxLayerValidationError(path, "file must not contain NUL bytes");
|
|
2656
|
+
}
|
|
2657
|
+
if (byteLength(text) > maxBytes) {
|
|
2658
|
+
throw new SandboxLayerValidationError(path, `file exceeds ${maxBytes} byte limit`);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
function validateRepoRelativeArcanistPath(path, field = "path") {
|
|
2662
|
+
const normalized = path.trim();
|
|
2663
|
+
if (!normalized) throw new Error(`${field} is required`);
|
|
2664
|
+
if (/^[A-Za-z]:/.test(normalized)) throw new Error(`${field} must be a POSIX repo-relative path`);
|
|
2665
|
+
if (normalized.includes("\\")) throw new Error(`${field} must use POSIX '/' separators`);
|
|
2666
|
+
if (normalized.startsWith("/") || normalized.includes("\0")) throw new Error(`${field} must be repo-relative`);
|
|
2667
|
+
const parts = normalized.split("/");
|
|
2668
|
+
if (parts.some((part) => part === "" || part === "." || part === "..")) {
|
|
2669
|
+
throw new Error(`${field} cannot contain empty, '.', or '..' segments`);
|
|
2670
|
+
}
|
|
2671
|
+
if (!normalized.startsWith(".arcanist/")) throw new Error(`${field} must be under .arcanist/`);
|
|
2672
|
+
return normalized;
|
|
2673
|
+
}
|
|
2674
|
+
function yamlLineForPath(doc, keyPath) {
|
|
2675
|
+
let node = doc.contents;
|
|
2676
|
+
for (const segment of keyPath) {
|
|
2677
|
+
if (!isMap(node)) return void 0;
|
|
2678
|
+
const pair = node.items.find((item) => isScalar(item.key) && item.key.value === segment);
|
|
2679
|
+
if (!pair) return void 0;
|
|
2680
|
+
node = pair.value;
|
|
2681
|
+
}
|
|
2682
|
+
const range = node?.range;
|
|
2683
|
+
if (!range) return void 0;
|
|
2684
|
+
const before = doc.toString().slice(0, range[0]);
|
|
2685
|
+
return before.split(/\r?\n/).length;
|
|
2686
|
+
}
|
|
2687
|
+
function assertKnownKeys(path, doc, value) {
|
|
2688
|
+
const issues = [];
|
|
2689
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2690
|
+
return [issue(path, "manifest must be a YAML object", { code: "manifest_type" })];
|
|
2691
|
+
}
|
|
2692
|
+
const root = value;
|
|
2693
|
+
for (const key of Object.keys(root)) {
|
|
2694
|
+
if (!["version", "name", "layer", "smoke"].includes(key)) {
|
|
2695
|
+
issues.push(
|
|
2696
|
+
issue(
|
|
2697
|
+
path,
|
|
2698
|
+
key === "resources" ? "resources is not supported; resource sizing is Arcanist policy" : `unknown field '${key}'`,
|
|
2699
|
+
{
|
|
2700
|
+
line: yamlLineForPath(doc, [key]),
|
|
2701
|
+
field: key,
|
|
2702
|
+
code: key === "resources" ? "resources_not_allowed" : "unknown_field"
|
|
2703
|
+
}
|
|
2704
|
+
)
|
|
2705
|
+
);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
if (root.layer && typeof root.layer === "object" && !Array.isArray(root.layer)) {
|
|
2709
|
+
for (const key of Object.keys(root.layer)) {
|
|
2710
|
+
if (key !== "dockerfile") {
|
|
2711
|
+
issues.push(
|
|
2712
|
+
issue(path, `unknown field 'layer.${key}'`, {
|
|
2713
|
+
line: yamlLineForPath(doc, ["layer", key]),
|
|
2714
|
+
field: `layer.${key}`,
|
|
2715
|
+
code: "unknown_field"
|
|
2716
|
+
})
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
if (root.smoke && typeof root.smoke === "object" && !Array.isArray(root.smoke)) {
|
|
2722
|
+
for (const key of Object.keys(root.smoke)) {
|
|
2723
|
+
if (key !== "commands") {
|
|
2724
|
+
issues.push(
|
|
2725
|
+
issue(path, `unknown field 'smoke.${key}'`, {
|
|
2726
|
+
line: yamlLineForPath(doc, ["smoke", key]),
|
|
2727
|
+
field: `smoke.${key}`,
|
|
2728
|
+
code: "unknown_field"
|
|
2729
|
+
})
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return issues;
|
|
2735
|
+
}
|
|
2736
|
+
function parseSandboxLayerManifest(path, text) {
|
|
2737
|
+
assertSize(path, text, MAX_MANIFEST_BYTES);
|
|
2738
|
+
rejectSecrets(path, text);
|
|
2739
|
+
let doc;
|
|
2740
|
+
try {
|
|
2741
|
+
doc = parseDocument(text, { strict: true });
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
throw new SandboxLayerValidationError(path, `invalid YAML: ${err instanceof Error ? err.message : String(err)}`);
|
|
2744
|
+
}
|
|
2745
|
+
if (doc.errors.length > 0) {
|
|
2746
|
+
const error = doc.errors[0];
|
|
2747
|
+
throw new SandboxLayerValidationError(path, `invalid YAML: ${error.message}`, error.linePos?.[0]?.line);
|
|
2748
|
+
}
|
|
2749
|
+
const parsed = doc.toJSON();
|
|
2750
|
+
const issues = assertKnownKeys(path, doc, parsed);
|
|
2751
|
+
const raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2752
|
+
if (!Object.prototype.hasOwnProperty.call(raw, "version")) {
|
|
2753
|
+
issues.push(issue(path, "version is required", { field: "version", code: "required" }));
|
|
2754
|
+
} else if (raw.version !== 1) {
|
|
2755
|
+
issues.push(
|
|
2756
|
+
issue(path, "version must be exactly 1", {
|
|
2757
|
+
line: yamlLineForPath(doc, ["version"]),
|
|
2758
|
+
field: "version",
|
|
2759
|
+
code: "invalid_version"
|
|
2760
|
+
})
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
const name = raw.name == null ? null : typeof raw.name === "string" ? raw.name.trim() : "";
|
|
2764
|
+
if (raw.name != null && (typeof raw.name !== "string" || name === null || name.length < MIN_DISPLAY_NAME_CHARS || name.length > MAX_DISPLAY_NAME_CHARS)) {
|
|
2765
|
+
issues.push(
|
|
2766
|
+
issue(path, "name must be a 2-80 character display name", {
|
|
2767
|
+
line: yamlLineForPath(doc, ["name"]),
|
|
2768
|
+
field: "name",
|
|
2769
|
+
code: "invalid_name"
|
|
2770
|
+
})
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
const layer = raw.layer;
|
|
2774
|
+
if (!layer || typeof layer !== "object" || Array.isArray(layer)) {
|
|
2775
|
+
issues.push(
|
|
2776
|
+
issue(path, "layer.dockerfile is required", {
|
|
2777
|
+
line: yamlLineForPath(doc, ["layer"]),
|
|
2778
|
+
field: "layer.dockerfile",
|
|
2779
|
+
code: "required"
|
|
2780
|
+
})
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
let normalizedDockerfile;
|
|
2784
|
+
const dockerfile = layer && typeof layer === "object" && !Array.isArray(layer) ? layer.dockerfile : void 0;
|
|
2785
|
+
if (typeof dockerfile !== "string") {
|
|
2786
|
+
issues.push(
|
|
2787
|
+
issue(path, "layer.dockerfile must be a string", {
|
|
2788
|
+
line: yamlLineForPath(doc, ["layer", "dockerfile"]),
|
|
2789
|
+
field: "layer.dockerfile",
|
|
2790
|
+
code: "invalid_type"
|
|
2791
|
+
})
|
|
2792
|
+
);
|
|
2793
|
+
normalizedDockerfile = "";
|
|
2794
|
+
} else {
|
|
2795
|
+
try {
|
|
2796
|
+
normalizedDockerfile = validateRepoRelativeArcanistPath(dockerfile, "layer.dockerfile");
|
|
2797
|
+
} catch (err) {
|
|
2798
|
+
issues.push(
|
|
2799
|
+
issue(path, err instanceof Error ? err.message : String(err), {
|
|
2800
|
+
line: yamlLineForPath(doc, ["layer", "dockerfile"]),
|
|
2801
|
+
field: "layer.dockerfile",
|
|
2802
|
+
code: "invalid_path"
|
|
2803
|
+
})
|
|
2804
|
+
);
|
|
2805
|
+
normalizedDockerfile = "";
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
const commandsNode = isMap(doc.contents) ? doc.getIn(["smoke", "commands"], true) : void 0;
|
|
2809
|
+
const rawCommands = raw.smoke?.commands ?? [];
|
|
2810
|
+
const smokeCommands = [];
|
|
2811
|
+
if (!Array.isArray(rawCommands)) {
|
|
2812
|
+
issues.push(
|
|
2813
|
+
issue(path, "smoke.commands must be an array", {
|
|
2814
|
+
line: yamlLineForPath(doc, ["smoke", "commands"]),
|
|
2815
|
+
field: "smoke.commands",
|
|
2816
|
+
code: "invalid_type"
|
|
2817
|
+
})
|
|
2818
|
+
);
|
|
2819
|
+
} else {
|
|
2820
|
+
if (rawCommands.length > MAX_SMOKE_COMMANDS) {
|
|
2821
|
+
issues.push(
|
|
2822
|
+
issue(path, `smoke.commands may contain at most ${MAX_SMOKE_COMMANDS} commands`, {
|
|
2823
|
+
line: yamlLineForPath(doc, ["smoke", "commands"]),
|
|
2824
|
+
field: "smoke.commands",
|
|
2825
|
+
code: "too_many_items"
|
|
2826
|
+
})
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
rawCommands.forEach((command, index) => {
|
|
2830
|
+
const commandNode = isSeq(commandsNode) ? commandsNode.items[index] : void 0;
|
|
2831
|
+
const line = commandNode?.range ? text.slice(0, commandNode.range[0]).split(/\r?\n/).length : yamlLineForPath(doc, ["smoke", "commands"]);
|
|
2832
|
+
const field = `smoke.commands.${index}`;
|
|
2833
|
+
if (!Array.isArray(command)) {
|
|
2834
|
+
issues.push(
|
|
2835
|
+
issue(path, "smoke commands must be argument arrays, not shell strings", {
|
|
2836
|
+
line,
|
|
2837
|
+
field,
|
|
2838
|
+
code: "invalid_type"
|
|
2839
|
+
})
|
|
2840
|
+
);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (command.length === 0 || command.length > MAX_SMOKE_ARGS) {
|
|
2844
|
+
issues.push(
|
|
2845
|
+
issue(path, `smoke commands must contain 1-${MAX_SMOKE_ARGS} args`, { line, field, code: "invalid_length" })
|
|
2846
|
+
);
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const parsedCommand = [];
|
|
2850
|
+
command.forEach((arg, argIndex) => {
|
|
2851
|
+
const argField = `${field}.${argIndex}`;
|
|
2852
|
+
if (typeof arg !== "string" || arg.length === 0 || arg.includes("\0") || /[\r\n]/.test(arg) || byteLength(arg) > MAX_SMOKE_ARG_BYTES) {
|
|
2853
|
+
issues.push(
|
|
2854
|
+
issue(
|
|
2855
|
+
path,
|
|
2856
|
+
`smoke args must be non-empty strings up to ${MAX_SMOKE_ARG_BYTES} bytes with no NUL or newline`,
|
|
2857
|
+
{ line, field: argField, code: "invalid_smoke_arg" }
|
|
2858
|
+
)
|
|
2859
|
+
);
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
if (SECRET_PATTERN.test(arg)) {
|
|
2863
|
+
issues.push(
|
|
2864
|
+
issue(path, "smoke args must not contain obvious secret material", {
|
|
2865
|
+
line,
|
|
2866
|
+
field: argField,
|
|
2867
|
+
code: "secret_like"
|
|
2868
|
+
})
|
|
2869
|
+
);
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
parsedCommand.push(arg);
|
|
2873
|
+
});
|
|
2874
|
+
if (parsedCommand.length === command.length) smokeCommands.push(parsedCommand);
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
if (issues.length > 0) throw new SandboxLayerValidationError(issues);
|
|
2878
|
+
return { version: 1, name, layer: { dockerfile: normalizedDockerfile }, smoke: { commands: smokeCommands } };
|
|
2879
|
+
}
|
|
2880
|
+
function splitLogicalInstructions(path, text) {
|
|
2881
|
+
assertSize(path, text, MAX_LAYER_BYTES);
|
|
2882
|
+
rejectSecrets(path, text);
|
|
2883
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
2884
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2885
|
+
const result = [];
|
|
2886
|
+
let current = "";
|
|
2887
|
+
let startLine = 0;
|
|
2888
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
2889
|
+
const lineNumber = index + 1;
|
|
2890
|
+
const rawLine = lines[index];
|
|
2891
|
+
const trimmed = rawLine.trim();
|
|
2892
|
+
if (!current && (!trimmed || trimmed.startsWith("#"))) {
|
|
2893
|
+
if (/^#\s*syntax\s*=/i.test(trimmed)) {
|
|
2894
|
+
throw new SandboxLayerValidationError(path, "BuildKit syntax directives are not supported", lineNumber);
|
|
2895
|
+
}
|
|
2896
|
+
continue;
|
|
2897
|
+
}
|
|
2898
|
+
if (/<<\s*[-\w'"]+/.test(rawLine)) {
|
|
2899
|
+
throw new SandboxLayerValidationError(path, "heredocs are not supported in sandbox layer files", lineNumber);
|
|
2900
|
+
}
|
|
2901
|
+
if (!current) startLine = lineNumber;
|
|
2902
|
+
const continued = /\\\s*$/.test(rawLine);
|
|
2903
|
+
current += (current ? " " : "") + rawLine.replace(/\\\s*$/, "").trim();
|
|
2904
|
+
if (byteLength(current) > MAX_LAYER_BYTES) {
|
|
2905
|
+
throw new SandboxLayerValidationError(path, "continued instruction exceeds layer size limit", startLine);
|
|
2906
|
+
}
|
|
2907
|
+
if (!continued) {
|
|
2908
|
+
result.push({ text: current.trim(), startLine, endLine: lineNumber });
|
|
2909
|
+
current = "";
|
|
2910
|
+
startLine = 0;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
if (current) {
|
|
2914
|
+
throw new SandboxLayerValidationError(path, "unterminated line continuation", startLine);
|
|
2915
|
+
}
|
|
2916
|
+
return result;
|
|
2917
|
+
}
|
|
2918
|
+
function splitEnvTokens(path, body, startLine) {
|
|
2919
|
+
const tokens2 = [];
|
|
2920
|
+
let current = "";
|
|
2921
|
+
let quote = null;
|
|
2922
|
+
for (const char of body) {
|
|
2923
|
+
if (quote) {
|
|
2924
|
+
current += char;
|
|
2925
|
+
if (char === quote) quote = null;
|
|
2926
|
+
continue;
|
|
2927
|
+
}
|
|
2928
|
+
if (char === "'" || char === '"') {
|
|
2929
|
+
quote = char;
|
|
2930
|
+
current += char;
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
if (/\s/.test(char)) {
|
|
2934
|
+
if (current) {
|
|
2935
|
+
tokens2.push(current);
|
|
2936
|
+
current = "";
|
|
2937
|
+
}
|
|
2938
|
+
continue;
|
|
2939
|
+
}
|
|
2940
|
+
current += char;
|
|
2941
|
+
}
|
|
2942
|
+
if (quote) throw new SandboxLayerValidationError(path, "ENV contains an unterminated quote", startLine);
|
|
2943
|
+
if (current) tokens2.push(current);
|
|
2944
|
+
return tokens2;
|
|
2945
|
+
}
|
|
2946
|
+
function parseEnvValues(path, body, startLine) {
|
|
2947
|
+
const values = {};
|
|
2948
|
+
const tokens2 = splitEnvTokens(path, body, startLine);
|
|
2949
|
+
if (tokens2.length > MAX_ENV_VARS) {
|
|
2950
|
+
throw new SandboxLayerValidationError(path, `ENV may set at most ${MAX_ENV_VARS} variables`, startLine);
|
|
2951
|
+
}
|
|
2952
|
+
for (const token of tokens2) {
|
|
2953
|
+
const separator = token.indexOf("=");
|
|
2954
|
+
if (separator <= 0) {
|
|
2955
|
+
throw new SandboxLayerValidationError(path, "ENV entries must use KEY=value form", startLine);
|
|
2956
|
+
}
|
|
2957
|
+
const key = token.slice(0, separator);
|
|
2958
|
+
let value = token.slice(separator + 1);
|
|
2959
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
2960
|
+
throw new SandboxLayerValidationError(path, `invalid ENV key '${key}'`, startLine);
|
|
2961
|
+
}
|
|
2962
|
+
if (key.length > MAX_ENV_KEY_CHARS) {
|
|
2963
|
+
throw new SandboxLayerValidationError(path, `ENV key '${key}' is too long`, startLine);
|
|
2964
|
+
}
|
|
2965
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2966
|
+
value = value.slice(1, -1);
|
|
2967
|
+
}
|
|
2968
|
+
if (!value || value.includes("\0") || /[\r\n]/.test(value) || byteLength(value) > MAX_ENV_VALUE_BYTES) {
|
|
2969
|
+
throw new SandboxLayerValidationError(
|
|
2970
|
+
path,
|
|
2971
|
+
`ENV value for '${key}' must be 1-${MAX_ENV_VALUE_BYTES} bytes with no NUL or newline`,
|
|
2972
|
+
startLine
|
|
2973
|
+
);
|
|
2974
|
+
}
|
|
2975
|
+
if (SECRET_PATTERN.test(value)) {
|
|
2976
|
+
throw new SandboxLayerValidationError(
|
|
2977
|
+
path,
|
|
2978
|
+
`ENV value for '${key}' appears to contain obvious secret material`,
|
|
2979
|
+
startLine
|
|
2980
|
+
);
|
|
2981
|
+
}
|
|
2982
|
+
values[key] = value;
|
|
2983
|
+
}
|
|
2984
|
+
if (Object.keys(values).length === 0) {
|
|
2985
|
+
throw new SandboxLayerValidationError(path, "ENV must set at least one value", startLine);
|
|
2986
|
+
}
|
|
2987
|
+
return values;
|
|
2988
|
+
}
|
|
2989
|
+
function assertRunSafety(path, command, startLine) {
|
|
2990
|
+
if (byteLength(command) > MAX_RUN_COMMAND_BYTES) {
|
|
2991
|
+
throw new SandboxLayerValidationError(path, `RUN command exceeds ${MAX_RUN_COMMAND_BYTES} byte limit`, startLine);
|
|
2992
|
+
}
|
|
2993
|
+
if (/(^|\s)--mount=/.test(command)) {
|
|
2994
|
+
throw new SandboxLayerValidationError(path, "BuildKit RUN --mount is not supported", startLine);
|
|
2995
|
+
}
|
|
2996
|
+
if (SECRET_PATTERN.test(command)) {
|
|
2997
|
+
throw new SandboxLayerValidationError(path, "RUN command appears to contain obvious secret material", startLine);
|
|
2998
|
+
}
|
|
2999
|
+
const lower = command.toLowerCase();
|
|
3000
|
+
const targetsForbiddenPath = FORBIDDEN_PATHS.some((forbidden) => lower.includes(forbidden.toLowerCase()));
|
|
3001
|
+
if (targetsForbiddenPath && FORBIDDEN_TARGETING_OPS.test(lower)) {
|
|
3002
|
+
throw new SandboxLayerValidationError(path, "RUN modifies an Arcanist-owned runtime path", startLine);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
function parseSandboxLayerDockerfile(path, text) {
|
|
3006
|
+
const instructions = splitLogicalInstructions(path, text);
|
|
3007
|
+
return instructions.map((entry) => {
|
|
3008
|
+
const match = entry.text.match(/^([A-Za-z]+)\s*(.*)$/s);
|
|
3009
|
+
if (!match) {
|
|
3010
|
+
throw new SandboxLayerValidationError(path, "invalid Dockerfile instruction", entry.startLine);
|
|
3011
|
+
}
|
|
3012
|
+
const instruction = match[1].toUpperCase();
|
|
3013
|
+
const body = match[2].trim();
|
|
3014
|
+
if (instruction === "RUN") {
|
|
3015
|
+
if (!body) throw new SandboxLayerValidationError(path, "RUN must not be empty", entry.startLine);
|
|
3016
|
+
assertRunSafety(path, body, entry.startLine);
|
|
3017
|
+
return { kind: "run", command: body, startLine: entry.startLine, endLine: entry.endLine };
|
|
3018
|
+
}
|
|
3019
|
+
if (instruction === "ENV") {
|
|
3020
|
+
return {
|
|
3021
|
+
kind: "env",
|
|
3022
|
+
values: parseEnvValues(path, body, entry.startLine),
|
|
3023
|
+
startLine: entry.startLine,
|
|
3024
|
+
endLine: entry.endLine
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
throw new SandboxLayerValidationError(
|
|
3028
|
+
path,
|
|
3029
|
+
`${instruction} is not supported in Arcanist sandbox layer files. V1 layers support only RUN and ENV.`,
|
|
3030
|
+
entry.startLine
|
|
3031
|
+
);
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
function normalizeSandboxLayerInstructions(instructions) {
|
|
3035
|
+
return JSON.stringify(
|
|
3036
|
+
instructions.map((instruction) => {
|
|
3037
|
+
if (instruction.kind === "run") return { kind: "run", command: instruction.command };
|
|
3038
|
+
return {
|
|
3039
|
+
kind: "env",
|
|
3040
|
+
values: Object.fromEntries(
|
|
3041
|
+
Object.entries(instruction.values).sort(([left], [right]) => left.localeCompare(right))
|
|
3042
|
+
)
|
|
3043
|
+
};
|
|
3044
|
+
})
|
|
3045
|
+
);
|
|
3046
|
+
}
|
|
3047
|
+
function sortJsonValue(value) {
|
|
3048
|
+
if (Array.isArray(value)) return value.map(sortJsonValue);
|
|
3049
|
+
if (!value || typeof value !== "object") return value;
|
|
3050
|
+
return Object.fromEntries(
|
|
3051
|
+
Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, sortJsonValue(entry)])
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
function canonicalSandboxLayerSourceJson(input) {
|
|
3055
|
+
return JSON.stringify(
|
|
3056
|
+
sortJsonValue({
|
|
3057
|
+
schemaVersion: 1,
|
|
3058
|
+
manifest: {
|
|
3059
|
+
version: input.manifest.version,
|
|
3060
|
+
name: input.manifest.name,
|
|
3061
|
+
layerDockerfile: input.manifest.layer.dockerfile,
|
|
3062
|
+
smokeCommands: input.manifest.smoke.commands
|
|
3063
|
+
},
|
|
3064
|
+
layer: JSON.parse(normalizeSandboxLayerInstructions(input.instructions)),
|
|
3065
|
+
buildIdentity: input.buildIdentity ?? null
|
|
3066
|
+
})
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
async function parseSandboxLayerSource(input) {
|
|
3070
|
+
const manifest = parseSandboxLayerManifest(input.manifestPath, input.manifestText);
|
|
3071
|
+
if (manifest.layer.dockerfile !== input.layerPath) {
|
|
3072
|
+
throw new SandboxLayerValidationError(
|
|
3073
|
+
input.manifestPath,
|
|
3074
|
+
`layer.dockerfile must match fetched layer path '${input.layerPath}'`,
|
|
3075
|
+
void 0,
|
|
3076
|
+
"layer.dockerfile",
|
|
3077
|
+
"layer_path_mismatch"
|
|
3078
|
+
);
|
|
3079
|
+
}
|
|
3080
|
+
const instructions = parseSandboxLayerDockerfile(input.layerPath, input.layerText);
|
|
3081
|
+
const canonicalSource = canonicalSandboxLayerSourceJson({
|
|
3082
|
+
manifest,
|
|
3083
|
+
instructions,
|
|
3084
|
+
buildIdentity: input.buildIdentity
|
|
3085
|
+
});
|
|
3086
|
+
return {
|
|
3087
|
+
manifest: {
|
|
3088
|
+
version: manifest.version,
|
|
3089
|
+
name: manifest.name,
|
|
3090
|
+
layerDockerfile: manifest.layer.dockerfile,
|
|
3091
|
+
smokeCommands: manifest.smoke.commands
|
|
3092
|
+
},
|
|
3093
|
+
layer: { instructions },
|
|
3094
|
+
hashes: {
|
|
3095
|
+
manifestHash: await computeSha256Hex(input.manifestText),
|
|
3096
|
+
layerHash: await computeSha256Hex(input.layerText),
|
|
3097
|
+
normalizedSourceHash: await computeSha256Hex(canonicalSource)
|
|
3098
|
+
}
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// src/commands/sandbox.ts
|
|
3103
|
+
var DEFAULT_MANIFEST_PATH = SANDBOX_LAYER_MANIFEST_PATH;
|
|
3104
|
+
var DEFAULT_POLL_INTERVAL_MS = 2500;
|
|
3105
|
+
var TERMINAL_BUILD_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
|
|
3106
|
+
function git(args) {
|
|
3107
|
+
try {
|
|
3108
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
3109
|
+
} catch (err) {
|
|
3110
|
+
throw new CliError("user", `git ${args.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
function currentRepo() {
|
|
3114
|
+
const remote = git(["remote", "get-url", "origin"]);
|
|
3115
|
+
const parsed = parseGithubRepoFullName(remote);
|
|
3116
|
+
if (!parsed) throw new CliError("user", "origin remote must point at a GitHub repository");
|
|
3117
|
+
return { owner: parsed.owner, repo: parsed.repo };
|
|
3118
|
+
}
|
|
3119
|
+
function currentRef() {
|
|
3120
|
+
return git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
3121
|
+
}
|
|
3122
|
+
function assertCleanAndPushed() {
|
|
3123
|
+
const dirty = git(["status", "--porcelain"]);
|
|
3124
|
+
if (dirty) throw new CliError("user", "Sandbox layer source must be committed before build.");
|
|
3125
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
3126
|
+
const ahead = git(["rev-list", "--count", `${upstream}..HEAD`]);
|
|
3127
|
+
if (ahead !== "0")
|
|
3128
|
+
throw new CliError("user", "Current commit is not pushed; push before building the sandbox layer.");
|
|
3129
|
+
}
|
|
3130
|
+
function assertManifestExists(path) {
|
|
3131
|
+
if (!existsSync(path)) throw new CliError("user", `Missing sandbox manifest: ${path}`);
|
|
3132
|
+
}
|
|
3133
|
+
function parseRepoArg(value, fallback) {
|
|
3134
|
+
if (!value) return fallback();
|
|
3135
|
+
const parsed = parseGithubRepoFullName(value);
|
|
3136
|
+
if (!parsed) throw new CliError("user", "repo must be owner/name or a GitHub URL");
|
|
3137
|
+
return { owner: parsed.owner, repo: parsed.repo };
|
|
3138
|
+
}
|
|
3139
|
+
function repoPath(repo) {
|
|
3140
|
+
return `${repo.owner}/${repo.repo}`;
|
|
3141
|
+
}
|
|
3142
|
+
function encodePathSegment(value) {
|
|
3143
|
+
return encodeURIComponent(value);
|
|
3144
|
+
}
|
|
3145
|
+
async function resolveBusinessId(config, options) {
|
|
3146
|
+
if (options.business && options.business.trim()) return options.business.trim();
|
|
3147
|
+
const whoami = await apiFetch(config, "/api/auth/whoami");
|
|
3148
|
+
if (whoami.businessId && whoami.businessId.trim()) return whoami.businessId;
|
|
3149
|
+
throw new CliError("user", "--business is required when the authenticated token has no business context.");
|
|
3150
|
+
}
|
|
3151
|
+
function parsePollInterval2(value) {
|
|
3152
|
+
if (!value) return DEFAULT_POLL_INTERVAL_MS;
|
|
3153
|
+
const parsed = Number(value);
|
|
3154
|
+
if (!Number.isInteger(parsed) || parsed < 250) {
|
|
3155
|
+
throw new CliError("user", "--poll-interval must be an integer >= 250.");
|
|
3156
|
+
}
|
|
3157
|
+
return parsed;
|
|
3158
|
+
}
|
|
3159
|
+
function sleep2(ms) {
|
|
3160
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3161
|
+
}
|
|
3162
|
+
function printBuildRequest(buildRequest) {
|
|
3163
|
+
console.log(
|
|
3164
|
+
[
|
|
3165
|
+
`Build ${buildRequest.id} is ${buildRequest.status}.`,
|
|
3166
|
+
buildRequest.sourceRepo ? `source=${buildRequest.sourceRepo}` : null,
|
|
3167
|
+
buildRequest.targetRepo ? `target=${buildRequest.targetRepo}` : null,
|
|
3168
|
+
buildRequest.commitSha ? `commit=${buildRequest.commitSha}` : null,
|
|
3169
|
+
buildRequest.resourceProfileKey ? `profile=${buildRequest.resourceProfileKey}` : null,
|
|
3170
|
+
buildRequest.createdBy ? `builtBy=${buildActorLabel(buildRequest.createdBy)}` : null
|
|
3171
|
+
].filter(Boolean).join(" ")
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
function buildActorLabel(actor) {
|
|
3175
|
+
if (!actor) return "unknown";
|
|
3176
|
+
if (actor.login) return `@${actor.login}`;
|
|
3177
|
+
if (actor.name) return actor.name;
|
|
3178
|
+
return `User ${actor.userId}`;
|
|
3179
|
+
}
|
|
3180
|
+
function phaseLabel(phase) {
|
|
3181
|
+
return phase === "provider_build" ? "provider build" : phase;
|
|
3182
|
+
}
|
|
3183
|
+
function printActiveTemplateStatus(buildRequest) {
|
|
3184
|
+
if (buildRequest.status === "completed") {
|
|
3185
|
+
const template = buildRequest.activeTemplateRef ?? buildRequest.providerArtifactRef;
|
|
3186
|
+
console.log(template ? `Active template updated to ${template}.` : "Active: yes");
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
if (buildRequest.activeTemplateRef) {
|
|
3190
|
+
console.log("Active template unchanged; previous successful sandbox remains active.");
|
|
3191
|
+
} else {
|
|
3192
|
+
console.log("No previous active sandbox; sessions will use Arcanist default.");
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
function printTerminalBuildSummary(buildRequest) {
|
|
3196
|
+
if (buildRequest.status === "completed") {
|
|
3197
|
+
console.log("Sandbox build completed.");
|
|
3198
|
+
if (buildRequest.sourceRepo) console.log(`Source: ${buildRequest.sourceRepo}`);
|
|
3199
|
+
if (buildRequest.commitSha) console.log(`Commit: ${buildRequest.commitSha}`);
|
|
3200
|
+
if (buildRequest.baseTemplateRef && buildRequest.baseVersion) {
|
|
3201
|
+
console.log(`Built from: ${buildRequest.baseTemplateRef} @ ${buildRequest.baseVersion}`);
|
|
3202
|
+
}
|
|
3203
|
+
if (buildRequest.createdBy) console.log(`Built by: ${buildActorLabel(buildRequest.createdBy)}`);
|
|
3204
|
+
const template = buildRequest.providerArtifactRef ?? buildRequest.activeTemplateRef;
|
|
3205
|
+
if (template) console.log(`Template: ${template}`);
|
|
3206
|
+
console.log("Smoke: passed");
|
|
3207
|
+
console.log("Active: yes");
|
|
3208
|
+
printActiveTemplateStatus(buildRequest);
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
const failure = buildRequest.failureSummary;
|
|
3212
|
+
const phase = failure ? phaseLabel(failure.phase) : buildRequest.status;
|
|
3213
|
+
console.log(`Sandbox build failed during ${phase}.`);
|
|
3214
|
+
if (buildRequest.createdBy) console.log(`Built by: ${buildActorLabel(buildRequest.createdBy)}`);
|
|
3215
|
+
if (failure?.command) console.log(`Command: ${failure.command}`);
|
|
3216
|
+
if (failure?.exitCode !== void 0) console.log(`Exit code: ${failure.exitCode}`);
|
|
3217
|
+
console.log(`Reason: ${failure?.reason ?? `Build finished with status ${buildRequest.status}`}`);
|
|
3218
|
+
if (failure?.stdoutPreview) console.log(`Stdout: ${failure.stdoutPreview}`);
|
|
3219
|
+
if (failure?.stderrPreview) console.log(`Stderr: ${failure.stderrPreview}`);
|
|
3220
|
+
printActiveTemplateStatus(buildRequest);
|
|
3221
|
+
}
|
|
3222
|
+
function printBuildHistory(builds) {
|
|
3223
|
+
if (builds.length === 0) {
|
|
3224
|
+
console.log("No sandbox builds found.");
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
for (const build of builds) {
|
|
3228
|
+
console.log(
|
|
3229
|
+
[
|
|
3230
|
+
`Build ${build.id} is ${build.status}.`,
|
|
3231
|
+
`source=${build.sourceRepo}`,
|
|
3232
|
+
`commit=${build.commitSha}`,
|
|
3233
|
+
`profile=${build.resourceProfileKey}`,
|
|
3234
|
+
build.templateId ? `template=${build.templateId}` : null,
|
|
3235
|
+
`base=${build.baseTemplateRef}@${build.baseVersion}`,
|
|
3236
|
+
`builtBy=${buildActorLabel(build.createdBy)}`
|
|
3237
|
+
].filter(Boolean).join(" ")
|
|
3238
|
+
);
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
function selectionTierLabel(tier) {
|
|
3242
|
+
if (tier === "repo_local") return "repo-local";
|
|
3243
|
+
if (tier === "repo_assignment") return "assigned";
|
|
3244
|
+
return "workspace default";
|
|
3245
|
+
}
|
|
3246
|
+
function printSandboxStatus(payload) {
|
|
3247
|
+
if (payload.selection) {
|
|
3248
|
+
console.log(`Using ${selectionTierLabel(payload.selection.tier)} sandbox.`);
|
|
3249
|
+
console.log(`Template: ${payload.selection.templateId}`);
|
|
3250
|
+
console.log(`Source: ${payload.selection.sourceRepo}`);
|
|
3251
|
+
console.log(`Commit: ${payload.selection.commitSha}`);
|
|
3252
|
+
console.log(`Profile: ${payload.selection.resourceProfileKey}`);
|
|
3253
|
+
console.log(`Built from: ${payload.selection.baseTemplateRef} @ ${payload.selection.baseVersion}`);
|
|
3254
|
+
console.log(`Base status: ${payload.selection.baseStatus}`);
|
|
3255
|
+
console.log(`Built by: ${buildActorLabel(payload.selection.createdBy)}`);
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
console.log("Using Arcanist default sandbox.");
|
|
3259
|
+
if (payload.fallback?.templateId) console.log(`Template: ${payload.fallback.templateId}`);
|
|
3260
|
+
if (payload.latestRepoBuild) {
|
|
3261
|
+
console.log(`Latest custom build: ${payload.latestRepoBuild.status}`);
|
|
3262
|
+
console.log(`Commit: ${payload.latestRepoBuild.commitSha}`);
|
|
3263
|
+
console.log(`Profile: ${payload.latestRepoBuild.resourceProfileKey}`);
|
|
3264
|
+
console.log(`Built from: ${payload.latestRepoBuild.baseTemplateRef} @ ${payload.latestRepoBuild.baseVersion}`);
|
|
3265
|
+
console.log(`Built by: ${buildActorLabel(payload.latestRepoBuild.createdBy)}`);
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
function printLogs(logs) {
|
|
3269
|
+
for (const log of logs) {
|
|
3270
|
+
console.log(log.message);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
function buildLogsPath(businessId, buildId, options = {}) {
|
|
3274
|
+
const params = new URLSearchParams();
|
|
3275
|
+
if (options.afterSequence != null) params.set("afterSequence", String(options.afterSequence));
|
|
3276
|
+
if (options.limit != null) params.set("limit", String(options.limit));
|
|
3277
|
+
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
3278
|
+
return `/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/build-requests/${encodePathSegment(
|
|
3279
|
+
buildId
|
|
3280
|
+
)}/logs${suffix}`;
|
|
3281
|
+
}
|
|
3282
|
+
async function waitForBuild(config, businessId, buildId, options) {
|
|
3283
|
+
let afterSequence;
|
|
3284
|
+
for (; ; ) {
|
|
3285
|
+
if (options.follow) {
|
|
3286
|
+
const logs = await apiFetch(config, buildLogsPath(businessId, buildId, { afterSequence }));
|
|
3287
|
+
if (logs.logs.length > 0) {
|
|
3288
|
+
if (options.json) writeJson({ ok: true, logs: logs.logs });
|
|
3289
|
+
else printLogs(logs.logs);
|
|
3290
|
+
afterSequence = logs.logs[logs.logs.length - 1].sequence;
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
const status = await apiFetch(
|
|
3294
|
+
config,
|
|
3295
|
+
`/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/build-requests/${encodePathSegment(buildId)}`
|
|
3296
|
+
);
|
|
3297
|
+
if (TERMINAL_BUILD_STATUSES.has(status.buildRequest.status)) {
|
|
3298
|
+
return status.buildRequest;
|
|
3299
|
+
}
|
|
3300
|
+
await sleep2(options.pollIntervalMs);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
function formatValidationPayload(input) {
|
|
3304
|
+
return {
|
|
3305
|
+
ok: true,
|
|
3306
|
+
manifestPath: input.manifestPath,
|
|
3307
|
+
layerPath: input.layerPath,
|
|
3308
|
+
normalizedSourceHash: input.parsed.hashes.normalizedSourceHash,
|
|
3309
|
+
instructionCount: input.parsed.layer.instructions.length,
|
|
3310
|
+
smokeCommandCount: input.parsed.manifest.smokeCommands.length,
|
|
3311
|
+
diagnostics: []
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
3314
|
+
function nextBuildCommand(manifestPath) {
|
|
3315
|
+
try {
|
|
3316
|
+
const repo = currentRepo();
|
|
3317
|
+
const manifestArg = manifestPath === DEFAULT_MANIFEST_PATH ? "" : ` --manifest ${manifestPath}`;
|
|
3318
|
+
return `arcanist sandbox build ${repoPath(repo)} --wait --follow${manifestArg}`;
|
|
3319
|
+
} catch {
|
|
3320
|
+
return "configure a GitHub origin, then run arcanist sandbox build <owner/repo> --wait --follow";
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
function handleValidationError(err, options, command) {
|
|
3324
|
+
if (err instanceof SandboxLayerValidationError) {
|
|
3325
|
+
const payload = { ok: false, diagnostics: err.issues };
|
|
3326
|
+
if (isJson(command, options)) {
|
|
3327
|
+
writeJson(payload);
|
|
3328
|
+
process.exitCode = 1;
|
|
3329
|
+
return;
|
|
3330
|
+
}
|
|
3331
|
+
throw new CliError(
|
|
3332
|
+
"user",
|
|
3333
|
+
err.issues.map((issue2) => `${issue2.path}${issue2.line ? `:${issue2.line}` : ""}: ${issue2.message}`).join("\n")
|
|
3334
|
+
);
|
|
3335
|
+
}
|
|
3336
|
+
throw err;
|
|
3337
|
+
}
|
|
3338
|
+
function sourceBody(sourceRepo, manifestPath) {
|
|
3339
|
+
return {
|
|
3340
|
+
sourceRepoOwner: sourceRepo.owner,
|
|
3341
|
+
sourceRepoName: sourceRepo.repo,
|
|
3342
|
+
manifestPath
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
async function sandboxInitCommand(options = {}, command) {
|
|
3346
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3347
|
+
if (existsSync(DEFAULT_MANIFEST_PATH)) throw new CliError("conflict", `${DEFAULT_MANIFEST_PATH} already exists`);
|
|
3348
|
+
const layerPath = ".arcanist/sandbox.layer.Dockerfile";
|
|
3349
|
+
if (existsSync(layerPath)) throw new CliError("conflict", `${layerPath} already exists`);
|
|
3350
|
+
mkdirSync2(dirname(DEFAULT_MANIFEST_PATH), { recursive: true });
|
|
3351
|
+
writeFileSync2(
|
|
3352
|
+
DEFAULT_MANIFEST_PATH,
|
|
3353
|
+
[
|
|
3354
|
+
"version: 1",
|
|
3355
|
+
"layer:",
|
|
3356
|
+
" dockerfile: .arcanist/sandbox.layer.Dockerfile",
|
|
3357
|
+
"smoke:",
|
|
3358
|
+
" commands:",
|
|
3359
|
+
' - ["bash", "-lc", "command -v git && command -v python3"]',
|
|
3360
|
+
""
|
|
3361
|
+
].join("\n"),
|
|
3362
|
+
"utf8"
|
|
3363
|
+
);
|
|
3364
|
+
writeFileSync2(
|
|
3365
|
+
layerPath,
|
|
3366
|
+
[
|
|
3367
|
+
"# Only RUN and ENV instructions are supported.",
|
|
3368
|
+
"# Example:",
|
|
3369
|
+
"# RUN apt-get update && apt-get install -y --no-install-recommends ripgrep && rm -rf /var/lib/apt/lists/*",
|
|
3370
|
+
""
|
|
3371
|
+
].join("\n"),
|
|
3372
|
+
"utf8"
|
|
3373
|
+
);
|
|
3374
|
+
const payload = { ok: true, manifestPath: DEFAULT_MANIFEST_PATH, layerPath };
|
|
3375
|
+
if (isJson(command, runtime)) {
|
|
3376
|
+
writeJson(payload);
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
console.log(`Created ${DEFAULT_MANIFEST_PATH} and ${layerPath}`);
|
|
3380
|
+
}
|
|
3381
|
+
async function sandboxValidateCommand(options = {}, command) {
|
|
3382
|
+
const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
3383
|
+
assertManifestExists(manifestPath);
|
|
3384
|
+
const manifestText = readFileSync2(manifestPath, "utf8");
|
|
3385
|
+
let layerPath;
|
|
3386
|
+
try {
|
|
3387
|
+
layerPath = parseSandboxLayerManifest(manifestPath, manifestText).layer.dockerfile;
|
|
3388
|
+
assertManifestExists(layerPath);
|
|
3389
|
+
const layerText = readFileSync2(layerPath, "utf8");
|
|
3390
|
+
const parsed = await parseSandboxLayerSource({ manifestPath, manifestText, layerPath, layerText });
|
|
3391
|
+
const payload = formatValidationPayload({ manifestPath, layerPath, parsed });
|
|
3392
|
+
if (isJson(command, options)) {
|
|
3393
|
+
writeJson(payload);
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
console.log("Valid sandbox template.");
|
|
3397
|
+
console.log(`Manifest: ${payload.manifestPath}`);
|
|
3398
|
+
console.log(`Layer: ${payload.layerPath}`);
|
|
3399
|
+
console.log(`Normalized source hash: ${payload.normalizedSourceHash}`);
|
|
3400
|
+
console.log(`Instructions: ${payload.instructionCount}`);
|
|
3401
|
+
console.log(`Smoke commands: ${payload.smokeCommandCount}`);
|
|
3402
|
+
console.log(`Next: ${nextBuildCommand(manifestPath)}`);
|
|
3403
|
+
} catch (err) {
|
|
3404
|
+
handleValidationError(err, options, command);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
async function sandboxBuildCommand(sourceRepoArg, options = {}, command) {
|
|
3408
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3409
|
+
const config = requireConfig(runtime);
|
|
3410
|
+
const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
3411
|
+
assertManifestExists(manifestPath);
|
|
3412
|
+
if (!options.ref) assertCleanAndPushed();
|
|
3413
|
+
const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
|
|
3414
|
+
const targetRepo = options.targetRepo ? parseRepoArg(options.targetRepo, currentRepo) : null;
|
|
3415
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3416
|
+
const ref = options.ref ?? currentRef();
|
|
3417
|
+
const payload = await apiFetch(
|
|
3418
|
+
config,
|
|
3419
|
+
`/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(sourceRepo.owner)}/${encodePathSegment(
|
|
3420
|
+
sourceRepo.repo
|
|
3421
|
+
)}/sandbox-layer/build-requests`,
|
|
3422
|
+
{
|
|
3423
|
+
method: "POST",
|
|
3424
|
+
body: JSON.stringify({
|
|
3425
|
+
ref,
|
|
3426
|
+
manifestPath,
|
|
3427
|
+
...targetRepo ? { targetRepo: { owner: targetRepo.owner, name: targetRepo.repo } } : {}
|
|
3428
|
+
})
|
|
3429
|
+
}
|
|
3430
|
+
);
|
|
3431
|
+
if (isJson(command, options)) writeJson(payload);
|
|
3432
|
+
else printBuildRequest(payload.buildRequest);
|
|
3433
|
+
if (!options.wait && !options.follow) return;
|
|
3434
|
+
const json = isJson(command, options);
|
|
3435
|
+
const finalBuild = await waitForBuild(config, businessId, payload.buildRequest.id, {
|
|
3436
|
+
follow: options.follow,
|
|
3437
|
+
pollIntervalMs: parsePollInterval2(options.pollInterval),
|
|
3438
|
+
json
|
|
3439
|
+
});
|
|
3440
|
+
if (json) writeJson({ ok: true, buildRequest: finalBuild });
|
|
3441
|
+
else printTerminalBuildSummary(finalBuild);
|
|
3442
|
+
if (finalBuild.status !== "completed") {
|
|
3443
|
+
process.exitCode = 1;
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
async function sandboxStatusCommand(repoArg, options = {}, command) {
|
|
3447
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3448
|
+
const config = requireConfig(runtime);
|
|
3449
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3450
|
+
const repo = parseRepoArg(repoArg, currentRepo);
|
|
3451
|
+
const payload = await apiFetch(
|
|
3452
|
+
config,
|
|
3453
|
+
`/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(repo.owner)}/${encodePathSegment(
|
|
3454
|
+
repo.repo
|
|
3455
|
+
)}/sandbox-layer/resolution`
|
|
3456
|
+
);
|
|
3457
|
+
if (isJson(command, options)) {
|
|
3458
|
+
writeJson(payload);
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
printSandboxStatus(payload);
|
|
3462
|
+
}
|
|
3463
|
+
async function sandboxHistoryCommand(sourceRepoArg, options = {}, command) {
|
|
3464
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3465
|
+
const config = requireConfig(runtime);
|
|
3466
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3467
|
+
const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
|
|
3468
|
+
const params = new URLSearchParams({ sourceRepo: repoPath(sourceRepo) });
|
|
3469
|
+
if (options.targetRepo) params.set("targetRepo", repoPath(parseRepoArg(options.targetRepo, currentRepo)));
|
|
3470
|
+
if (options.status) params.set("status", options.status);
|
|
3471
|
+
if (options.limit) {
|
|
3472
|
+
const limit = Number(options.limit);
|
|
3473
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
3474
|
+
throw new CliError("user", "--limit must be a positive integer.");
|
|
3475
|
+
}
|
|
3476
|
+
params.set("limit", String(limit));
|
|
3477
|
+
}
|
|
3478
|
+
const payload = await apiFetch(
|
|
3479
|
+
config,
|
|
3480
|
+
`/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/build-requests?${params.toString()}`
|
|
3481
|
+
);
|
|
3482
|
+
if (isJson(command, options)) {
|
|
3483
|
+
writeJson(payload);
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
printBuildHistory(payload.builds);
|
|
3487
|
+
}
|
|
3488
|
+
async function sandboxRebuildStaleCommand(options = {}, command) {
|
|
3489
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3490
|
+
const config = requireConfig(runtime);
|
|
3491
|
+
if (!options.dryRun && !options.yes) {
|
|
3492
|
+
throw new CliError("user", "Use --dry-run to preview or --yes to start a rebuild campaign.");
|
|
3493
|
+
}
|
|
3494
|
+
if (options.all && options.business) {
|
|
3495
|
+
throw new CliError("user", "Use either --all or --business, not both.");
|
|
3496
|
+
}
|
|
3497
|
+
const businessId = options.all ? null : await resolveBusinessId(config, options);
|
|
3498
|
+
let payload = await apiFetch(config, "/api/admin/sandbox-layer/rebuild-campaigns", {
|
|
3499
|
+
method: "POST",
|
|
3500
|
+
body: JSON.stringify({
|
|
3501
|
+
scope: options.all ? "all" : "business",
|
|
3502
|
+
...businessId ? { businessId } : {},
|
|
3503
|
+
reason: "base_update",
|
|
3504
|
+
dryRun: Boolean(options.dryRun)
|
|
3505
|
+
})
|
|
3506
|
+
}).catch((error) => {
|
|
3507
|
+
if (error instanceof CliError && /403|Forbidden/.test(error.message)) {
|
|
3508
|
+
throw new CliError("auth", "Only Arcanist internal admins can rebuild stale sandbox layers.");
|
|
3509
|
+
}
|
|
3510
|
+
throw error;
|
|
3511
|
+
});
|
|
3512
|
+
if (options.follow && !options.dryRun) {
|
|
3513
|
+
payload = await followRebuildCampaign(config, payload.campaign.id);
|
|
3514
|
+
}
|
|
3515
|
+
if (isJson(command, options)) {
|
|
3516
|
+
writeJson(payload);
|
|
3517
|
+
return;
|
|
3518
|
+
}
|
|
3519
|
+
printRebuildCampaign(payload);
|
|
3520
|
+
}
|
|
3521
|
+
async function followRebuildCampaign(config, campaignId) {
|
|
3522
|
+
for (; ; ) {
|
|
3523
|
+
const payload = await apiFetch(
|
|
3524
|
+
config,
|
|
3525
|
+
`/api/admin/sandbox-layer/rebuild-campaigns/${encodePathSegment(campaignId)}`
|
|
3526
|
+
);
|
|
3527
|
+
if (isCampaignTerminal(payload.campaign.status)) return payload;
|
|
3528
|
+
await sleep2(DEFAULT_POLL_INTERVAL_MS);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
function isCampaignTerminal(status) {
|
|
3532
|
+
return status === "completed" || status === "completed_with_failures" || status === "failed";
|
|
3533
|
+
}
|
|
3534
|
+
function printRebuildCampaign(payload) {
|
|
3535
|
+
console.log(`Campaign: ${payload.campaign.id}`);
|
|
3536
|
+
console.log(`Status: ${payload.campaign.status}`);
|
|
3537
|
+
console.log(`Active artifacts scanned: ${payload.campaign.summary.activeArtifactsScanned}`);
|
|
3538
|
+
console.log(`Stale artifacts found: ${payload.campaign.summary.staleArtifactsFound}`);
|
|
3539
|
+
console.log(`Current artifacts skipped: ${payload.campaign.summary.currentArtifactsSkipped}`);
|
|
3540
|
+
console.log(`Missing installation skips: ${payload.campaign.summary.missingInstallationSkips}`);
|
|
3541
|
+
console.log(`Source unavailable skips: ${payload.campaign.summary.sourceUnavailableSkips}`);
|
|
3542
|
+
console.log(`Unversioned base skips: ${payload.campaign.summary.unversionedBaseSkips}`);
|
|
3543
|
+
console.log(`Active changed skips: ${payload.campaign.summary.activeChangedSkips}`);
|
|
3544
|
+
console.log(`Failed items: ${payload.campaign.summary.failedItems}`);
|
|
3545
|
+
console.log(`Promoted items: ${payload.campaign.summary.promotedItems}`);
|
|
3546
|
+
console.log(`Builds queued: ${payload.campaign.summary.buildsQueued}`);
|
|
3547
|
+
if (payload.campaign.status === "completed_with_failures" || payload.campaign.status === "failed") {
|
|
3548
|
+
console.log("Previous active templates remain active for failed or skipped rebuild items.");
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
async function sandboxLogsCommand(buildId, options = {}, command) {
|
|
3552
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3553
|
+
const config = requireConfig(runtime);
|
|
3554
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3555
|
+
let afterSequence = options.afterSequence == null ? void 0 : Number(options.afterSequence);
|
|
3556
|
+
if (afterSequence != null && (!Number.isInteger(afterSequence) || afterSequence < 0)) {
|
|
3557
|
+
throw new CliError("user", "--after-sequence must be a non-negative integer.");
|
|
3558
|
+
}
|
|
3559
|
+
const limit = options.limit == null ? void 0 : Number(options.limit);
|
|
3560
|
+
if (limit != null && (!Number.isInteger(limit) || limit < 1)) {
|
|
3561
|
+
throw new CliError("user", "--limit must be a positive integer.");
|
|
3562
|
+
}
|
|
3563
|
+
for (; ; ) {
|
|
3564
|
+
const payload = await apiFetch(
|
|
3565
|
+
config,
|
|
3566
|
+
buildLogsPath(businessId, buildId, { afterSequence, limit })
|
|
3567
|
+
);
|
|
3568
|
+
if (isJson(command, options)) writeJson(payload);
|
|
3569
|
+
else printLogs(payload.logs);
|
|
3570
|
+
if (!options.follow) return;
|
|
3571
|
+
if (payload.logs.length > 0) afterSequence = payload.logs[payload.logs.length - 1].sequence;
|
|
3572
|
+
await sleep2(parsePollInterval2(options.pollInterval));
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
async function sandboxAssignDefaultCommand(sourceRepoArg, options = {}, command) {
|
|
3576
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3577
|
+
const config = requireConfig(runtime);
|
|
3578
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3579
|
+
const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
|
|
3580
|
+
const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
3581
|
+
const payload = await apiFetch(
|
|
3582
|
+
config,
|
|
3583
|
+
`/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/default-source`,
|
|
3584
|
+
{ method: "PUT", body: JSON.stringify(sourceBody(sourceRepo, manifestPath)) }
|
|
3585
|
+
);
|
|
3586
|
+
if (isJson(command, options)) {
|
|
3587
|
+
writeJson(payload);
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
console.log(`Set default sandbox layer source to ${repoPath(sourceRepo)}.`);
|
|
3591
|
+
console.log(
|
|
3592
|
+
`Build missing target coverage with: arcanist sandbox build ${repoPath(sourceRepo)} --business ${businessId}`
|
|
3593
|
+
);
|
|
3594
|
+
}
|
|
3595
|
+
async function sandboxAssignRepoCommand(targetRepoArg, sourceRepoArg, options = {}, command) {
|
|
3596
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3597
|
+
const config = requireConfig(runtime);
|
|
3598
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3599
|
+
const targetRepo = parseRepoArg(targetRepoArg, currentRepo);
|
|
3600
|
+
const sourceRepo = parseRepoArg(sourceRepoArg, currentRepo);
|
|
3601
|
+
const manifestPath = options.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
3602
|
+
const payload = await apiFetch(
|
|
3603
|
+
config,
|
|
3604
|
+
`/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(targetRepo.owner)}/${encodePathSegment(
|
|
3605
|
+
targetRepo.repo
|
|
3606
|
+
)}/sandbox-layer/assignment`,
|
|
3607
|
+
{ method: "PUT", body: JSON.stringify(sourceBody(sourceRepo, manifestPath)) }
|
|
3608
|
+
);
|
|
3609
|
+
if (isJson(command, options)) {
|
|
3610
|
+
writeJson(payload);
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
console.log(`Assigned ${repoPath(sourceRepo)} as sandbox layer source for ${repoPath(targetRepo)}.`);
|
|
3614
|
+
console.log(
|
|
3615
|
+
`Build target coverage with: arcanist sandbox build ${repoPath(sourceRepo)} --target-repo ${repoPath(
|
|
3616
|
+
targetRepo
|
|
3617
|
+
)} --business ${businessId}`
|
|
3618
|
+
);
|
|
3619
|
+
}
|
|
3620
|
+
async function sandboxUnassignDefaultCommand(options = {}, command) {
|
|
3621
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3622
|
+
const config = requireConfig(runtime);
|
|
3623
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3624
|
+
if (!options.yes) await confirmOrThrow("Clear the business default sandbox layer source?");
|
|
3625
|
+
const payload = await apiFetch(
|
|
3626
|
+
config,
|
|
3627
|
+
`/api/businesses/${encodePathSegment(businessId)}/sandbox-layer/default-source`,
|
|
3628
|
+
{ method: "DELETE" }
|
|
3629
|
+
);
|
|
3630
|
+
if (isJson(command, options)) {
|
|
3631
|
+
writeJson(payload);
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
console.log("Cleared default sandbox layer source.");
|
|
3635
|
+
}
|
|
3636
|
+
async function sandboxUnassignRepoCommand(targetRepoArg, options = {}, command) {
|
|
3637
|
+
const runtime = getRuntimeOptions(command, options);
|
|
3638
|
+
const config = requireConfig(runtime);
|
|
3639
|
+
const businessId = await resolveBusinessId(config, options);
|
|
3640
|
+
const targetRepo = parseRepoArg(targetRepoArg, currentRepo);
|
|
3641
|
+
if (!options.yes) await confirmOrThrow(`Clear sandbox layer assignment for ${repoPath(targetRepo)}?`);
|
|
3642
|
+
const payload = await apiFetch(
|
|
3643
|
+
config,
|
|
3644
|
+
`/api/businesses/${encodePathSegment(businessId)}/repos/${encodePathSegment(targetRepo.owner)}/${encodePathSegment(
|
|
3645
|
+
targetRepo.repo
|
|
3646
|
+
)}/sandbox-layer/assignment`,
|
|
3647
|
+
{ method: "DELETE" }
|
|
3648
|
+
);
|
|
3649
|
+
if (isJson(command, options)) {
|
|
3650
|
+
writeJson(payload);
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
console.log(`Cleared sandbox layer assignment for ${repoPath(targetRepo)}.`);
|
|
3654
|
+
}
|
|
3655
|
+
|
|
2544
3656
|
// src/commands/sessions.ts
|
|
2545
3657
|
async function listSessionsCommand(options, command) {
|
|
2546
3658
|
const runtime = getRuntimeOptions(command, options);
|
|
@@ -2706,8 +3818,8 @@ function parseStopBlocked(err) {
|
|
|
2706
3818
|
}
|
|
2707
3819
|
|
|
2708
3820
|
// src/commands/test-creds.ts
|
|
2709
|
-
import { readFileSync as
|
|
2710
|
-
function
|
|
3821
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
3822
|
+
function parseRepoArg2(repo) {
|
|
2711
3823
|
const trimmed = repo.trim();
|
|
2712
3824
|
const slashIndex = trimmed.indexOf("/");
|
|
2713
3825
|
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
|
|
@@ -2724,13 +3836,13 @@ function readValue(options) {
|
|
|
2724
3836
|
throw new CliError("user", "Provide exactly one of --value, --value-file, or --value-stdin.");
|
|
2725
3837
|
}
|
|
2726
3838
|
if (options.value !== void 0) return options.value;
|
|
2727
|
-
const raw = options.valueFile ?
|
|
3839
|
+
const raw = options.valueFile ? readFileSync3(options.valueFile, "utf8") : readFileSync3(0, "utf8");
|
|
2728
3840
|
return raw.replace(/\r?\n$/, "");
|
|
2729
3841
|
}
|
|
2730
3842
|
async function listTestCredentialsCommand(repo, options, command) {
|
|
2731
3843
|
const runtime = getRuntimeOptions(command, options);
|
|
2732
3844
|
const config = requireConfig(runtime);
|
|
2733
|
-
const repoArg =
|
|
3845
|
+
const repoArg = parseRepoArg2(repo);
|
|
2734
3846
|
const payload = await apiFetch(config, basePath(options.business, repoArg));
|
|
2735
3847
|
if (isJson(command, options)) {
|
|
2736
3848
|
writeJson(payload);
|
|
@@ -2748,7 +3860,7 @@ async function listTestCredentialsCommand(repo, options, command) {
|
|
|
2748
3860
|
async function setTestCredentialCommand(repo, name, options, command) {
|
|
2749
3861
|
const runtime = getRuntimeOptions(command, options);
|
|
2750
3862
|
const config = requireConfig(runtime);
|
|
2751
|
-
const repoArg =
|
|
3863
|
+
const repoArg = parseRepoArg2(repo);
|
|
2752
3864
|
const value = readValue(options);
|
|
2753
3865
|
const payload = await apiFetch(
|
|
2754
3866
|
config,
|
|
@@ -2766,12 +3878,12 @@ async function deleteTestCredentialCommand(repo, name, options, command) {
|
|
|
2766
3878
|
throw new CliError("user", "`test-creds delete --json` requires --yes.");
|
|
2767
3879
|
}
|
|
2768
3880
|
if (options.yes !== true) {
|
|
2769
|
-
const repoArg2 =
|
|
3881
|
+
const repoArg2 = parseRepoArg2(repo);
|
|
2770
3882
|
await confirmOrThrow(`Delete test credential '${name}' for ${repoArg2.owner}/${repoArg2.name}?`);
|
|
2771
3883
|
}
|
|
2772
3884
|
const runtime = getRuntimeOptions(command, options);
|
|
2773
3885
|
const config = requireConfig(runtime);
|
|
2774
|
-
const repoArg =
|
|
3886
|
+
const repoArg = parseRepoArg2(repo);
|
|
2775
3887
|
const payload = await apiFetch(
|
|
2776
3888
|
config,
|
|
2777
3889
|
`${basePath(options.business, repoArg)}/${encodeURIComponent(name)}`,
|
|
@@ -3016,6 +4128,22 @@ Examples:
|
|
|
3016
4128
|
arcanist sessions usage <session-id> --json
|
|
3017
4129
|
`
|
|
3018
4130
|
).action((sessionId, options, command) => usageCommand(sessionId, options, command));
|
|
4131
|
+
var sandbox = program.command("sandbox").description("Sandbox layer commands");
|
|
4132
|
+
sandbox.command("init").description("Create sandbox layer source files").action((options, command) => sandboxInitCommand(options, command));
|
|
4133
|
+
sandbox.command("validate").description("Validate local sandbox layer source files").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").action((options, command) => sandboxValidateCommand(options, command));
|
|
4134
|
+
sandbox.command("build").description("Build a repo-sourced sandbox layer").argument("[source-repo]", "Source repository owner/name; defaults to current git remote").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").option("--ref <ref>", "Git ref to build").option("--target-repo <repo>", "Target repository owner/name for resource-profile coverage").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--wait", "Wait for the build to finish").option("--follow", "Follow logs while waiting").option("--poll-interval <ms>", "Polling interval in milliseconds").action((sourceRepo, options, command) => sandboxBuildCommand(sourceRepo, options, command));
|
|
4135
|
+
sandbox.command("status").description("Show sandbox layer status").argument("[repo]", "Repository owner/name; defaults to current git remote").option("--business <id>", "Business ID; defaults to authenticated user's business").action((repo, options, command) => sandboxStatusCommand(repo, options, command));
|
|
4136
|
+
sandbox.command("history").description("Show sandbox layer build history").argument("[source-repo]", "Source repository owner/name; defaults to current git remote").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--target-repo <repo>", "Target repository owner/name for resource-profile coverage").option("--status <status>", "Filter by build status").option("--limit <n>", "Maximum builds to return").action((sourceRepo, options, command) => sandboxHistoryCommand(sourceRepo, options, command));
|
|
4137
|
+
sandbox.command("logs").description("Print sandbox layer build logs").argument("<build-id>", "Build ID").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--follow", "Follow logs").option("--after-sequence <n>", "Return logs after this sequence").option("--limit <n>", "Maximum log chunks to return").option("--poll-interval <ms>", "Polling interval in milliseconds").action((buildId, options, command) => sandboxLogsCommand(buildId, options, command));
|
|
4138
|
+
sandbox.command("rebuild-stale").description("Create stale sandbox template rebuild campaigns").option("--business <id>", "Business ID to scan; defaults to authenticated user's business").option("--all", "Scan all businesses").option("--dry-run", "Preview stale rebuilds without queueing provider builds").option("--yes", "Start a rebuild campaign").option("--follow", "Follow campaign progress").action((options, command) => sandboxRebuildStaleCommand(options, command));
|
|
4139
|
+
var sandboxAssign = sandbox.command("assign").description("Assign sandbox layer sources");
|
|
4140
|
+
sandboxAssign.command("default").description("Set the business default sandbox layer source").argument("<source-repo>", "Source repository owner/name").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").option("--business <id>", "Business ID; defaults to authenticated user's business").action((sourceRepo, options, command) => sandboxAssignDefaultCommand(sourceRepo, options, command));
|
|
4141
|
+
sandboxAssign.command("repo").description("Assign a sandbox layer source to one target repo").argument("<target-repo>", "Target repository owner/name").argument("<source-repo>", "Source repository owner/name").option("--manifest <path>", "Manifest path", ".arcanist/sandbox.yaml").option("--business <id>", "Business ID; defaults to authenticated user's business").action(
|
|
4142
|
+
(targetRepo, sourceRepo, options, command) => sandboxAssignRepoCommand(targetRepo, sourceRepo, options, command)
|
|
4143
|
+
);
|
|
4144
|
+
var sandboxUnassign = sandbox.command("unassign").description("Remove sandbox layer source assignments");
|
|
4145
|
+
sandboxUnassign.command("default").description("Clear the business default sandbox layer source").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--yes", "Skip confirmation").action((options, command) => sandboxUnassignDefaultCommand(options, command));
|
|
4146
|
+
sandboxUnassign.command("repo").description("Clear a target repo sandbox layer assignment").argument("<target-repo>", "Target repository owner/name").option("--business <id>", "Business ID; defaults to authenticated user's business").option("--yes", "Skip confirmation").action((targetRepo, options, command) => sandboxUnassignRepoCommand(targetRepo, options, command));
|
|
3019
4147
|
var tokens = program.command("tokens").description("CLI token commands");
|
|
3020
4148
|
tokens.command("list").description("List CLI tokens").option("--limit <n>", "Maximum tokens to return").option("--cursor <cursor>", "Pagination cursor").action((options, command) => listTokensCommand(options, command));
|
|
3021
4149
|
tokens.command("create").description("Create a CLI token and print it once").option("--scope <scope>", "Token scope: read or write", "read").option("--expires-in-days <days>", "Token expiry in days").addHelpText(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryarcanist/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.106",
|
|
4
4
|
"description": "CLI for Arcanist — create and manage coding agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"homepage": "https://www.tryarcanist.com",
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"commander": "^12.1.0"
|
|
31
|
+
"commander": "^12.1.0",
|
|
32
|
+
"yaml": "^2.8.3"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"tsup": "^8.0.0",
|