orizu 0.5.1 → 0.5.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/dist/artifact-pull.js +157 -0
- package/dist/github-setup.js +24 -0
- package/dist/help.js +12 -0
- package/dist/index.js +10 -10
- package/dist/workspace.js +56 -1
- package/package.json +1 -1
- package/src/artifact-pull.ts +203 -0
- package/src/github-setup.ts +37 -1
- package/src/help.ts +12 -0
- package/src/index.ts +10 -11
- package/src/workspace.ts +62 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner/optimizer artifact pull (symmetric with `runners push` /
|
|
3
|
+
* `optimizers push`): resolve the artifact by id or name, download the
|
|
4
|
+
* content-addressed zip from storage, and extract it into --out. Version
|
|
5
|
+
* metadata is written to .orizu/pull.json — .orizu/ is excluded from artifact
|
|
6
|
+
* zips (see artifact-archive.ts), so a pulled directory re-pushes with an
|
|
7
|
+
* identical content hash.
|
|
8
|
+
*
|
|
9
|
+
* Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
|
|
10
|
+
* owns its own argument parsing and printing via injected io.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import { createHash } from 'crypto';
|
|
14
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
|
15
|
+
import { tmpdir } from 'os';
|
|
16
|
+
import { dirname, join } from 'path';
|
|
17
|
+
import { authedFetch } from './http.js';
|
|
18
|
+
// Shared with the runners exec download path in index.ts.
|
|
19
|
+
export const ARTIFACT_MAX_BYTES = 25 * 1024 * 1024;
|
|
20
|
+
function sanitizeTerminalText(value) {
|
|
21
|
+
return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
|
|
22
|
+
}
|
|
23
|
+
function expandHomePath(path) {
|
|
24
|
+
if (path.startsWith('~/')) {
|
|
25
|
+
const home = process.env.HOME || '';
|
|
26
|
+
return `${home}/${path.slice(2)}`;
|
|
27
|
+
}
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
function argValue(args, flag) {
|
|
31
|
+
const index = args.indexOf(flag);
|
|
32
|
+
if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return args[index + 1];
|
|
36
|
+
}
|
|
37
|
+
async function parseJsonPayload(response, context) {
|
|
38
|
+
const contentType = response.headers.get('content-type') || '';
|
|
39
|
+
const rawBody = await response.text();
|
|
40
|
+
if (!contentType.includes('application/json')) {
|
|
41
|
+
throw new Error(`${context} returned non-JSON response (status ${response.status}). ` +
|
|
42
|
+
`Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(rawBody);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error(`${context} returned invalid JSON (status ${response.status}). ` +
|
|
49
|
+
`Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function pullArtifactCommand(args, io) {
|
|
53
|
+
const kind = args[0] === 'runners' ? 'runner' : 'optimizer';
|
|
54
|
+
const fetcher = io.fetcher || authedFetch;
|
|
55
|
+
const positionalRef = args[2];
|
|
56
|
+
const artifactRef = positionalRef && !positionalRef.startsWith('--') ? positionalRef : null;
|
|
57
|
+
const outDir = argValue(args, '--out');
|
|
58
|
+
const label = argValue(args, '--label');
|
|
59
|
+
const version = argValue(args, '--version');
|
|
60
|
+
// Argument errors surface before project resolution, which may prompt
|
|
61
|
+
// interactively when --project is omitted.
|
|
62
|
+
const plural = kind === 'runner' ? 'runners' : 'optimizers';
|
|
63
|
+
if (!artifactRef || !outDir) {
|
|
64
|
+
throw new Error(`Usage: orizu ${plural} pull <${kind}-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]`);
|
|
65
|
+
}
|
|
66
|
+
if (label && version) {
|
|
67
|
+
throw new Error('Use either --label or --version, not both');
|
|
68
|
+
}
|
|
69
|
+
const project = argValue(args, '--project') || await io.resolveProjectSlug(null);
|
|
70
|
+
const params = new URLSearchParams({ project });
|
|
71
|
+
if (label)
|
|
72
|
+
params.set('label', label);
|
|
73
|
+
if (version)
|
|
74
|
+
params.set('version', version);
|
|
75
|
+
const response = await fetcher(`/api/cli/${plural}/${encodeURIComponent(artifactRef)}?${params.toString()}`);
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw new Error(`Failed to pull ${kind}: ${await response.text()}`);
|
|
78
|
+
}
|
|
79
|
+
const data = await parseJsonPayload(response, `${kind} pull`);
|
|
80
|
+
const artifact = kind === 'runner' ? data.runner : data.optimizer;
|
|
81
|
+
if (!artifact) {
|
|
82
|
+
throw new Error(`Failed to pull ${kind}: malformed response`);
|
|
83
|
+
}
|
|
84
|
+
// Every stored version is content-addressed; a missing hash would silently
|
|
85
|
+
// void the integrity check and the pull.json anchor, so fail instead.
|
|
86
|
+
if (!data.version.contentSha256) {
|
|
87
|
+
throw new Error(`Failed to pull ${kind}: version ${data.version.id} has no content hash`);
|
|
88
|
+
}
|
|
89
|
+
const downloadResponse = await fetcher(`/api/cli/${kind}-versions/${encodeURIComponent(data.version.id)}/download`);
|
|
90
|
+
if (!downloadResponse.ok) {
|
|
91
|
+
throw new Error(`Failed to download ${kind} artifact: ${await downloadResponse.text()}`);
|
|
92
|
+
}
|
|
93
|
+
const zipBytes = new Uint8Array(await downloadResponse.arrayBuffer());
|
|
94
|
+
if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
|
|
95
|
+
throw new Error(`${kind} artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`);
|
|
96
|
+
}
|
|
97
|
+
// Versions are content-addressed by zip SHA256 (see the push routes), so a
|
|
98
|
+
// corrupted or mismatched download is detectable before anything is written.
|
|
99
|
+
const actualSha256 = createHash('sha256').update(zipBytes).digest('hex');
|
|
100
|
+
if (actualSha256 !== data.version.contentSha256.toLowerCase()) {
|
|
101
|
+
throw new Error(`${kind} artifact hash mismatch: expected ${data.version.contentSha256}, got ${actualSha256}`);
|
|
102
|
+
}
|
|
103
|
+
// Extraction is additive, so a dirty directory would mix files from other
|
|
104
|
+
// versions and break the pull -> identical re-push hash guarantee.
|
|
105
|
+
const targetDir = expandHomePath(outDir);
|
|
106
|
+
mkdirSync(targetDir, { recursive: true });
|
|
107
|
+
if (readdirSync(targetDir).length > 0) {
|
|
108
|
+
throw new Error(`Output directory is not empty: ${targetDir}`);
|
|
109
|
+
}
|
|
110
|
+
const tempDir = mkdtempSync(join(tmpdir(), `orizu-${kind}-pull-`));
|
|
111
|
+
try {
|
|
112
|
+
const zipPath = join(tempDir, 'artifact.zip');
|
|
113
|
+
writeFileSync(zipPath, zipBytes);
|
|
114
|
+
// Artifacts are self-authored, hash-verified above, and served only to
|
|
115
|
+
// authenticated project members; Info-ZIP additionally strips ../ path
|
|
116
|
+
// components on extraction (same trust model as materializeRunnerVersion).
|
|
117
|
+
const result = spawnSync('unzip', ['-q', zipPath, '-d', targetDir], {
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
});
|
|
120
|
+
if (result.error || result.status !== 0) {
|
|
121
|
+
// targetDir was verified empty above, so everything in it came from
|
|
122
|
+
// this extraction — clear it so a re-pull isn't rejected as non-empty.
|
|
123
|
+
for (const entry of readdirSync(targetDir)) {
|
|
124
|
+
rmSync(join(targetDir, entry), { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
if (result.error) {
|
|
127
|
+
throw result.error;
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`unzip failed: ${sanitizeTerminalText(result.stderr || result.stdout || '')}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
const versionLabels = (data.labels || [])
|
|
136
|
+
.filter(item => (kind === 'runner' ? item.runnerVersionId : item.optimizerVersionId) === data.version.id)
|
|
137
|
+
.map(item => item.label);
|
|
138
|
+
const pullMetadataPath = join(targetDir, '.orizu', 'pull.json');
|
|
139
|
+
mkdirSync(dirname(pullMetadataPath), { recursive: true });
|
|
140
|
+
writeFileSync(pullMetadataPath, `${JSON.stringify({
|
|
141
|
+
schema_version: `orizu.${kind}-pull.v1`,
|
|
142
|
+
name: artifact.name,
|
|
143
|
+
[`${kind}_id`]: artifact.id,
|
|
144
|
+
version_id: data.version.id,
|
|
145
|
+
content_sha256: data.version.contentSha256,
|
|
146
|
+
labels: versionLabels,
|
|
147
|
+
}, null, 2)}\n`);
|
|
148
|
+
if (io.json) {
|
|
149
|
+
io.print(JSON.stringify({
|
|
150
|
+
[`${kind}_id`]: artifact.id,
|
|
151
|
+
[`${kind}_version_id`]: data.version.id,
|
|
152
|
+
path: targetDir,
|
|
153
|
+
}));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
io.print(`Pulled ${kind} ${sanitizeTerminalText(artifact.name)} to ${sanitizeTerminalText(targetDir)}`);
|
|
157
|
+
}
|
package/dist/github-setup.js
CHANGED
|
@@ -17,6 +17,16 @@ import { spawnSync } from 'child_process';
|
|
|
17
17
|
import { mkdirSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { authedFetch } from './http.js';
|
|
20
|
+
import { assertWorkspaceDirUsable } from './workspace.js';
|
|
21
|
+
/**
|
|
22
|
+
* Strip terminal control characters from server-supplied strings before they
|
|
23
|
+
* are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
|
|
24
|
+
* mangled provisioning `warnings[]`/`defaultBranch` value can't inject escape
|
|
25
|
+
* sequences into the user's terminal.
|
|
26
|
+
*/
|
|
27
|
+
function sanitizeServerText(value) {
|
|
28
|
+
return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
|
|
29
|
+
}
|
|
20
30
|
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
21
31
|
const DEFAULT_POLL_TIMEOUT_MS = 180000;
|
|
22
32
|
function defaultSleep(ms) {
|
|
@@ -178,6 +188,12 @@ export async function runHostedAttach(options, io) {
|
|
|
178
188
|
const data = (await response.json());
|
|
179
189
|
repoFullName = data.repo;
|
|
180
190
|
provisioned = true;
|
|
191
|
+
if (data.rulesetApplied === true) {
|
|
192
|
+
io.print(` Default-branch protection applied (ruleset "orizu-default-branch-protection"${data.defaultBranch ? ` on ${sanitizeServerText(data.defaultBranch)}` : ''}).`);
|
|
193
|
+
}
|
|
194
|
+
for (const warning of data.warnings ?? []) {
|
|
195
|
+
io.print(` ⚠ ${sanitizeServerText(warning)}`);
|
|
196
|
+
}
|
|
181
197
|
}
|
|
182
198
|
const cleanUrl = `https://github.com/${repoFullName}.git`;
|
|
183
199
|
io.print(` Cloning ${repoFullName}...`);
|
|
@@ -243,6 +259,14 @@ export async function runInteractiveHostedSetup(input, io) {
|
|
|
243
259
|
if (mode === 'local') {
|
|
244
260
|
return { mode, attached: false, repoFullName: null };
|
|
245
261
|
}
|
|
262
|
+
// The hosted paths (attach / provision-then-attach) clone the workbench repo
|
|
263
|
+
// into targetDir, and `git clone` refuses ANY non-empty directory. Fail fast
|
|
264
|
+
// here — before the provision call and the clone's git invocation, so no
|
|
265
|
+
// partial server/git state is created — with a friendly, actionable message
|
|
266
|
+
// (including the `--local` escape hatch). Only this real-clone path is gated;
|
|
267
|
+
// --local, --skip-login (not signed in), and a declined connect prompt never
|
|
268
|
+
// reach here, so they scaffold locally unblocked.
|
|
269
|
+
assertWorkspaceDirUsable(input.targetDir);
|
|
246
270
|
const result = await runHostedAttach({ workspaceId: workspace.id, repoFullName, targetDir: input.targetDir }, { print: io.print, fetcher, git: io.git ?? defaultGitRunner, ensureOrizuDir: io.ensureOrizuDir });
|
|
247
271
|
return { mode, attached: true, repoFullName: result.repoFullName };
|
|
248
272
|
}
|
package/dist/help.js
CHANGED
|
@@ -344,6 +344,12 @@ export const COMMAND_DOCS = [
|
|
|
344
344
|
summary: 'Push a local runner artifact directory.',
|
|
345
345
|
group: 'Scorers and runners',
|
|
346
346
|
},
|
|
347
|
+
{
|
|
348
|
+
path: ['runners', 'pull'],
|
|
349
|
+
usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
350
|
+
summary: 'Pull a runner artifact version to a local directory.',
|
|
351
|
+
group: 'Scorers and runners',
|
|
352
|
+
},
|
|
347
353
|
{
|
|
348
354
|
path: ['runners', 'exec'],
|
|
349
355
|
usage: 'orizu runners exec (--prompt <prompt-version-id> | --prompt-version <id> --runner-version <id> | --scorer-version <id>) --dataset-version <id> --split-set <id-or-name> --split <name> [--runner-dir <dir>] --out <results.jsonl|results.jsonl.gz>',
|
|
@@ -356,6 +362,12 @@ export const COMMAND_DOCS = [
|
|
|
356
362
|
summary: 'Push a local optimizer artifact directory.',
|
|
357
363
|
group: 'Optimizations',
|
|
358
364
|
},
|
|
365
|
+
{
|
|
366
|
+
path: ['optimizers', 'pull'],
|
|
367
|
+
usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
368
|
+
summary: 'Pull an optimizer artifact version to a local directory.',
|
|
369
|
+
group: 'Optimizations',
|
|
370
|
+
},
|
|
359
371
|
{
|
|
360
372
|
path: ['runs', 'submit'],
|
|
361
373
|
usage: 'orizu runs submit <results.jsonl|results.jsonl.gz> --prompt-version <id> --runner-version <id> --dataset-version <id> --split-set <id> --split <name> [--project <team/project>]',
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { formatTaskCreateError } from './task-create-error.js';
|
|
|
27
27
|
import { renderAgentSetupPrompt } from './setup-prompt.js';
|
|
28
28
|
import { getWorkspaceRoot, initOrizuWorkspace, workspaceExists, } from './workspace.js';
|
|
29
29
|
import { connectorsCommand } from './connectors-cli.js';
|
|
30
|
+
import { ARTIFACT_MAX_BYTES, pullArtifactCommand } from './artifact-pull.js';
|
|
30
31
|
import { killSwitchCommand } from './kill-switch-cli.js';
|
|
31
32
|
import { egressAllowlistCommand } from './egress-allowlist-cli.js';
|
|
32
33
|
import { manifestsCommand } from './manifests-cli.js';
|
|
@@ -2862,7 +2863,7 @@ async function setupCommand() {
|
|
|
2862
2863
|
const skipConfirm = hasArg('--yes');
|
|
2863
2864
|
const noInput = hasArg('--no-input') || hasArg('--non-interactive') || !isInteractiveTerminal();
|
|
2864
2865
|
const workspaceArg = getOptionalArgValue('--workspace');
|
|
2865
|
-
const workspaceRoot = workspaceArg ? resolve(expandHomePath(workspaceArg)) : process.cwd();
|
|
2866
|
+
const workspaceRoot = workspaceArg ? resolve(expandHomePath(workspaceArg)) : process.cwd(); // non-empty-dir guard now lives next to the hosted clone in runInteractiveHostedSetup, so --skip-login / decline-connect / local scaffolds are never blocked
|
|
2866
2867
|
const teamArg = getOptionalArgValue('--team');
|
|
2867
2868
|
const projectArg = getOptionalArgValue('--project');
|
|
2868
2869
|
const setupSkillHome = resolveSetupSkillHome();
|
|
@@ -4595,7 +4596,6 @@ function readRunnerManifest(runnerDir) {
|
|
|
4595
4596
|
}
|
|
4596
4597
|
const RUNNER_TIMEOUT_MS = 120_000;
|
|
4597
4598
|
const RUNNER_OUTPUT_MAX_BYTES = 2 * 1024 * 1024;
|
|
4598
|
-
const RUNNER_ARTIFACT_MAX_BYTES = 25 * 1024 * 1024;
|
|
4599
4599
|
const RUNNER_ENV_ALLOWLIST = new Set([
|
|
4600
4600
|
'PATH',
|
|
4601
4601
|
'SystemRoot',
|
|
@@ -4643,9 +4643,9 @@ async function materializeRunnerVersion(runnerVersionId) {
|
|
|
4643
4643
|
const zipPath = join(tempDir, 'runner.zip');
|
|
4644
4644
|
const runnerDir = join(tempDir, 'runner');
|
|
4645
4645
|
const zipBytes = new Uint8Array(await response.arrayBuffer());
|
|
4646
|
-
if (zipBytes.byteLength >
|
|
4646
|
+
if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
|
|
4647
4647
|
rmSync(tempDir, { recursive: true, force: true });
|
|
4648
|
-
throw new Error(`Runner artifact exceeds ${
|
|
4648
|
+
throw new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`);
|
|
4649
4649
|
}
|
|
4650
4650
|
writeFileSync(zipPath, zipBytes);
|
|
4651
4651
|
const result = spawnSync('unzip', ['-q', zipPath, '-d', runnerDir], {
|
|
@@ -4997,16 +4997,16 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
4997
4997
|
await execScorer();
|
|
4998
4998
|
return;
|
|
4999
4999
|
}
|
|
5000
|
-
if (command === 'runners' && subcommand === 'push') {
|
|
5001
|
-
await pushRunnerArtifact('runner');
|
|
5000
|
+
if ((command === 'runners' || command === 'optimizers') && subcommand === 'push') {
|
|
5001
|
+
await pushRunnerArtifact(command === 'runners' ? 'runner' : 'optimizer');
|
|
5002
5002
|
return;
|
|
5003
5003
|
}
|
|
5004
|
-
if (command === 'runners' && subcommand === '
|
|
5005
|
-
await
|
|
5004
|
+
if ((command === 'runners' || command === 'optimizers') && subcommand === 'pull') {
|
|
5005
|
+
await pullArtifactCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug });
|
|
5006
5006
|
return;
|
|
5007
5007
|
}
|
|
5008
|
-
if (command === '
|
|
5009
|
-
await
|
|
5008
|
+
if (command === 'runners' && subcommand === 'exec') {
|
|
5009
|
+
await runnersExec();
|
|
5010
5010
|
return;
|
|
5011
5011
|
}
|
|
5012
5012
|
if (command === 'runs' && subcommand === 'submit') {
|
package/dist/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readlinkSync, readdirSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync, } from 'fs';
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readlinkSync, readdirSync, readFileSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from 'fs';
|
|
2
2
|
import { dirname, join, relative, resolve } from 'path';
|
|
3
3
|
export const WORKSPACE_DIR_NAME = '.orizu';
|
|
4
4
|
export const WORKSPACE_SCHEMA_VERSION = 'orizu.workspace.v0';
|
|
@@ -82,6 +82,61 @@ export function getWorkspaceRoot(cwd) {
|
|
|
82
82
|
export function workspaceExists(cwd) {
|
|
83
83
|
return existsSync(join(getWorkspaceRoot(cwd), 'orizu.team.json'));
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* `orizu setup`'s hosted path clones the workbench repo into the resolved
|
|
87
|
+
* workspace root; `git clone` refuses to write into ANY non-empty directory —
|
|
88
|
+
* including one holding only macOS `.DS_Store` cruft — and fails late with a
|
|
89
|
+
* raw git error (ALI QA: founder ran `orizu setup` from an existing folder).
|
|
90
|
+
* Call this right before the hosted clone so the failure is immediate and
|
|
91
|
+
* actionable instead of surfacing mid-clone.
|
|
92
|
+
*
|
|
93
|
+
* A directory is usable only when it doesn't exist yet, is STRICTLY empty
|
|
94
|
+
* (`readdirSync(...).length === 0` — no `.DS_Store` allowance, since git itself
|
|
95
|
+
* grants none), or is already a recognized Orizu workspace for a re-run
|
|
96
|
+
* (`workspaceExists`, i.e. it has `orizu.team.json`) — the legitimate
|
|
97
|
+
* idempotent-setup case, which must proceed. Symlinks are followed (a symlink
|
|
98
|
+
* to an empty dir is fine); a file, an unreadable dir, and a non-empty dir each
|
|
99
|
+
* get a distinct, friendly message.
|
|
100
|
+
*/
|
|
101
|
+
export function assertWorkspaceDirUsable(root) {
|
|
102
|
+
const resolvedRoot = resolve(root);
|
|
103
|
+
if (!existsSync(resolvedRoot))
|
|
104
|
+
return;
|
|
105
|
+
if (workspaceExists(resolvedRoot))
|
|
106
|
+
return;
|
|
107
|
+
let stat;
|
|
108
|
+
try {
|
|
109
|
+
// statSync (not lstatSync) follows symlinks, so a symlink to an empty dir
|
|
110
|
+
// is treated as the empty dir it points at.
|
|
111
|
+
stat = statSync(resolvedRoot);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Broken symlink / vanished between checks — nothing to clone over.
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!stat.isDirectory()) {
|
|
118
|
+
throw new Error(`The path ${resolvedRoot} is a file, but \`orizu setup\` needs a directory to clone your workbench repo into. `
|
|
119
|
+
+ 'Choose an empty directory (or pass `--workspace <empty-path>`, or `--local` to scaffold a local-only workspace elsewhere), then re-run `orizu setup`.');
|
|
120
|
+
}
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(resolvedRoot);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const code = error.code;
|
|
127
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
128
|
+
throw new Error(`The directory ${resolvedRoot} can't be read (permission denied). `
|
|
129
|
+
+ 'Fix its permissions, or pass `--workspace <empty-path>` (or `--local`), then re-run `orizu setup`.');
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
if (entries.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
throw new Error(`The directory ${resolvedRoot} is not empty. \`orizu setup\` clones your workbench repo here and needs an empty directory. `
|
|
136
|
+
+ 'Create and enter an empty sub-directory (for example: `mkdir orizu-workbench && cd orizu-workbench`), '
|
|
137
|
+
+ 'pass `--workspace <empty-path>`, or pass `--local` to scaffold a local-only workspace in place, '
|
|
138
|
+
+ 'then re-run `orizu setup`.');
|
|
139
|
+
}
|
|
85
140
|
function formatJson(value) {
|
|
86
141
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
87
142
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner/optimizer artifact pull (symmetric with `runners push` /
|
|
3
|
+
* `optimizers push`): resolve the artifact by id or name, download the
|
|
4
|
+
* content-addressed zip from storage, and extract it into --out. Version
|
|
5
|
+
* metadata is written to .orizu/pull.json — .orizu/ is excluded from artifact
|
|
6
|
+
* zips (see artifact-archive.ts), so a pulled directory re-pushes with an
|
|
7
|
+
* identical content hash.
|
|
8
|
+
*
|
|
9
|
+
* Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
|
|
10
|
+
* owns its own argument parsing and printing via injected io.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'child_process'
|
|
14
|
+
import { createHash } from 'crypto'
|
|
15
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
|
16
|
+
import { tmpdir } from 'os'
|
|
17
|
+
import { dirname, join } from 'path'
|
|
18
|
+
|
|
19
|
+
import { authedFetch } from './http.js'
|
|
20
|
+
|
|
21
|
+
export type PullableArtifactKind = 'runner' | 'optimizer'
|
|
22
|
+
|
|
23
|
+
export interface PullArtifactIo {
|
|
24
|
+
json: boolean
|
|
25
|
+
print: (line: string) => void
|
|
26
|
+
resolveProjectSlug: (projectArg: string | null) => Promise<string>
|
|
27
|
+
fetcher?: (path: string, init?: RequestInit) => Promise<Response>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Shared with the runners exec download path in index.ts.
|
|
31
|
+
export const ARTIFACT_MAX_BYTES = 25 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
function sanitizeTerminalText(value: unknown): string {
|
|
34
|
+
return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expandHomePath(path: string): string {
|
|
38
|
+
if (path.startsWith('~/')) {
|
|
39
|
+
const home = process.env.HOME || ''
|
|
40
|
+
return `${home}/${path.slice(2)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return path
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function argValue(args: string[], flag: string): string | null {
|
|
47
|
+
const index = args.indexOf(flag)
|
|
48
|
+
if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
return args[index + 1]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function parseJsonPayload<T>(response: Response, context: string): Promise<T> {
|
|
55
|
+
const contentType = response.headers.get('content-type') || ''
|
|
56
|
+
const rawBody = await response.text()
|
|
57
|
+
|
|
58
|
+
if (!contentType.includes('application/json')) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${context} returned non-JSON response (status ${response.status}). ` +
|
|
61
|
+
`Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(rawBody) as T
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`${context} returned invalid JSON (status ${response.status}). ` +
|
|
70
|
+
`Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PullArtifactResponse {
|
|
76
|
+
runner?: { id: string; name: string }
|
|
77
|
+
optimizer?: { id: string; name: string }
|
|
78
|
+
version: {
|
|
79
|
+
id: string
|
|
80
|
+
contentSha256?: string
|
|
81
|
+
manifest?: Record<string, unknown>
|
|
82
|
+
uploadedAt?: string
|
|
83
|
+
}
|
|
84
|
+
labels?: Array<{ label: string; runnerVersionId?: string; optimizerVersionId?: string }>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function pullArtifactCommand(args: string[], io: PullArtifactIo) {
|
|
88
|
+
const kind: PullableArtifactKind = args[0] === 'runners' ? 'runner' : 'optimizer'
|
|
89
|
+
const fetcher = io.fetcher || authedFetch
|
|
90
|
+
const positionalRef = args[2]
|
|
91
|
+
const artifactRef = positionalRef && !positionalRef.startsWith('--') ? positionalRef : null
|
|
92
|
+
const outDir = argValue(args, '--out')
|
|
93
|
+
const label = argValue(args, '--label')
|
|
94
|
+
const version = argValue(args, '--version')
|
|
95
|
+
|
|
96
|
+
// Argument errors surface before project resolution, which may prompt
|
|
97
|
+
// interactively when --project is omitted.
|
|
98
|
+
const plural = kind === 'runner' ? 'runners' : 'optimizers'
|
|
99
|
+
if (!artifactRef || !outDir) {
|
|
100
|
+
throw new Error(`Usage: orizu ${plural} pull <${kind}-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]`)
|
|
101
|
+
}
|
|
102
|
+
if (label && version) {
|
|
103
|
+
throw new Error('Use either --label or --version, not both')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const project = argValue(args, '--project') || await io.resolveProjectSlug(null)
|
|
107
|
+
|
|
108
|
+
const params = new URLSearchParams({ project })
|
|
109
|
+
if (label) params.set('label', label)
|
|
110
|
+
if (version) params.set('version', version)
|
|
111
|
+
|
|
112
|
+
const response = await fetcher(`/api/cli/${plural}/${encodeURIComponent(artifactRef)}?${params.toString()}`)
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`Failed to pull ${kind}: ${await response.text()}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const data = await parseJsonPayload<PullArtifactResponse>(response, `${kind} pull`)
|
|
118
|
+
const artifact = kind === 'runner' ? data.runner : data.optimizer
|
|
119
|
+
if (!artifact) {
|
|
120
|
+
throw new Error(`Failed to pull ${kind}: malformed response`)
|
|
121
|
+
}
|
|
122
|
+
// Every stored version is content-addressed; a missing hash would silently
|
|
123
|
+
// void the integrity check and the pull.json anchor, so fail instead.
|
|
124
|
+
if (!data.version.contentSha256) {
|
|
125
|
+
throw new Error(`Failed to pull ${kind}: version ${data.version.id} has no content hash`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const downloadResponse = await fetcher(`/api/cli/${kind}-versions/${encodeURIComponent(data.version.id)}/download`)
|
|
129
|
+
if (!downloadResponse.ok) {
|
|
130
|
+
throw new Error(`Failed to download ${kind} artifact: ${await downloadResponse.text()}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const zipBytes = new Uint8Array(await downloadResponse.arrayBuffer())
|
|
134
|
+
if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
|
|
135
|
+
throw new Error(`${kind} artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Versions are content-addressed by zip SHA256 (see the push routes), so a
|
|
139
|
+
// corrupted or mismatched download is detectable before anything is written.
|
|
140
|
+
const actualSha256 = createHash('sha256').update(zipBytes).digest('hex')
|
|
141
|
+
if (actualSha256 !== data.version.contentSha256.toLowerCase()) {
|
|
142
|
+
throw new Error(`${kind} artifact hash mismatch: expected ${data.version.contentSha256}, got ${actualSha256}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Extraction is additive, so a dirty directory would mix files from other
|
|
146
|
+
// versions and break the pull -> identical re-push hash guarantee.
|
|
147
|
+
const targetDir = expandHomePath(outDir)
|
|
148
|
+
mkdirSync(targetDir, { recursive: true })
|
|
149
|
+
if (readdirSync(targetDir).length > 0) {
|
|
150
|
+
throw new Error(`Output directory is not empty: ${targetDir}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tempDir = mkdtempSync(join(tmpdir(), `orizu-${kind}-pull-`))
|
|
154
|
+
try {
|
|
155
|
+
const zipPath = join(tempDir, 'artifact.zip')
|
|
156
|
+
writeFileSync(zipPath, zipBytes)
|
|
157
|
+
// Artifacts are self-authored, hash-verified above, and served only to
|
|
158
|
+
// authenticated project members; Info-ZIP additionally strips ../ path
|
|
159
|
+
// components on extraction (same trust model as materializeRunnerVersion).
|
|
160
|
+
const result = spawnSync('unzip', ['-q', zipPath, '-d', targetDir], {
|
|
161
|
+
encoding: 'utf8',
|
|
162
|
+
})
|
|
163
|
+
if (result.error || result.status !== 0) {
|
|
164
|
+
// targetDir was verified empty above, so everything in it came from
|
|
165
|
+
// this extraction — clear it so a re-pull isn't rejected as non-empty.
|
|
166
|
+
for (const entry of readdirSync(targetDir)) {
|
|
167
|
+
rmSync(join(targetDir, entry), { recursive: true, force: true })
|
|
168
|
+
}
|
|
169
|
+
if (result.error) {
|
|
170
|
+
throw result.error
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`unzip failed: ${sanitizeTerminalText(result.stderr || result.stdout || '')}`)
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const versionLabels = (data.labels || [])
|
|
179
|
+
.filter(item => (kind === 'runner' ? item.runnerVersionId : item.optimizerVersionId) === data.version.id)
|
|
180
|
+
.map(item => item.label)
|
|
181
|
+
|
|
182
|
+
const pullMetadataPath = join(targetDir, '.orizu', 'pull.json')
|
|
183
|
+
mkdirSync(dirname(pullMetadataPath), { recursive: true })
|
|
184
|
+
writeFileSync(pullMetadataPath, `${JSON.stringify({
|
|
185
|
+
schema_version: `orizu.${kind}-pull.v1`,
|
|
186
|
+
name: artifact.name,
|
|
187
|
+
[`${kind}_id`]: artifact.id,
|
|
188
|
+
version_id: data.version.id,
|
|
189
|
+
content_sha256: data.version.contentSha256,
|
|
190
|
+
labels: versionLabels,
|
|
191
|
+
}, null, 2)}\n`)
|
|
192
|
+
|
|
193
|
+
if (io.json) {
|
|
194
|
+
io.print(JSON.stringify({
|
|
195
|
+
[`${kind}_id`]: artifact.id,
|
|
196
|
+
[`${kind}_version_id`]: data.version.id,
|
|
197
|
+
path: targetDir,
|
|
198
|
+
}))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
io.print(`Pulled ${kind} ${sanitizeTerminalText(artifact.name)} to ${sanitizeTerminalText(targetDir)}`)
|
|
203
|
+
}
|
package/src/github-setup.ts
CHANGED
|
@@ -19,9 +19,20 @@ import { mkdirSync } from 'fs'
|
|
|
19
19
|
import { join } from 'path'
|
|
20
20
|
|
|
21
21
|
import { authedFetch } from './http.js'
|
|
22
|
+
import { assertWorkspaceDirUsable } from './workspace.js'
|
|
22
23
|
|
|
23
24
|
export type SetupFetcher = (path: string, init?: RequestInit) => Promise<Response>
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Strip terminal control characters from server-supplied strings before they
|
|
28
|
+
* are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
|
|
29
|
+
* mangled provisioning `warnings[]`/`defaultBranch` value can't inject escape
|
|
30
|
+
* sequences into the user's terminal.
|
|
31
|
+
*/
|
|
32
|
+
function sanitizeServerText(value: unknown): string {
|
|
33
|
+
return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export interface GithubLinkIo {
|
|
26
37
|
print: (line: string) => void
|
|
27
38
|
fetcher?: SetupFetcher
|
|
@@ -278,9 +289,25 @@ export async function runHostedAttach(
|
|
|
278
289
|
if (!response.ok) {
|
|
279
290
|
throw new Error(`Failed to provision workbench repo: ${await readError(response)}`)
|
|
280
291
|
}
|
|
281
|
-
const data = (await response.json()) as {
|
|
292
|
+
const data = (await response.json()) as {
|
|
293
|
+
repo: string
|
|
294
|
+
defaultBranch?: string
|
|
295
|
+
rulesetApplied?: boolean
|
|
296
|
+
warnings?: string[]
|
|
297
|
+
}
|
|
282
298
|
repoFullName = data.repo
|
|
283
299
|
provisioned = true
|
|
300
|
+
|
|
301
|
+
if (data.rulesetApplied === true) {
|
|
302
|
+
io.print(
|
|
303
|
+
` Default-branch protection applied (ruleset "orizu-default-branch-protection"${
|
|
304
|
+
data.defaultBranch ? ` on ${sanitizeServerText(data.defaultBranch)}` : ''
|
|
305
|
+
}).`
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
for (const warning of data.warnings ?? []) {
|
|
309
|
+
io.print(` ⚠ ${sanitizeServerText(warning)}`)
|
|
310
|
+
}
|
|
284
311
|
}
|
|
285
312
|
|
|
286
313
|
const cleanUrl = `https://github.com/${repoFullName}.git`
|
|
@@ -388,6 +415,15 @@ export async function runInteractiveHostedSetup(
|
|
|
388
415
|
return { mode, attached: false, repoFullName: null }
|
|
389
416
|
}
|
|
390
417
|
|
|
418
|
+
// The hosted paths (attach / provision-then-attach) clone the workbench repo
|
|
419
|
+
// into targetDir, and `git clone` refuses ANY non-empty directory. Fail fast
|
|
420
|
+
// here — before the provision call and the clone's git invocation, so no
|
|
421
|
+
// partial server/git state is created — with a friendly, actionable message
|
|
422
|
+
// (including the `--local` escape hatch). Only this real-clone path is gated;
|
|
423
|
+
// --local, --skip-login (not signed in), and a declined connect prompt never
|
|
424
|
+
// reach here, so they scaffold locally unblocked.
|
|
425
|
+
assertWorkspaceDirUsable(input.targetDir)
|
|
426
|
+
|
|
391
427
|
const result = await runHostedAttach(
|
|
392
428
|
{ workspaceId: workspace.id, repoFullName, targetDir: input.targetDir },
|
|
393
429
|
{ print: io.print, fetcher, git: io.git ?? defaultGitRunner, ensureOrizuDir: io.ensureOrizuDir }
|
package/src/help.ts
CHANGED
|
@@ -369,6 +369,12 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
|
|
|
369
369
|
summary: 'Push a local runner artifact directory.',
|
|
370
370
|
group: 'Scorers and runners',
|
|
371
371
|
},
|
|
372
|
+
{
|
|
373
|
+
path: ['runners', 'pull'],
|
|
374
|
+
usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
375
|
+
summary: 'Pull a runner artifact version to a local directory.',
|
|
376
|
+
group: 'Scorers and runners',
|
|
377
|
+
},
|
|
372
378
|
{
|
|
373
379
|
path: ['runners', 'exec'],
|
|
374
380
|
usage: 'orizu runners exec (--prompt <prompt-version-id> | --prompt-version <id> --runner-version <id> | --scorer-version <id>) --dataset-version <id> --split-set <id-or-name> --split <name> [--runner-dir <dir>] --out <results.jsonl|results.jsonl.gz>',
|
|
@@ -381,6 +387,12 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
|
|
|
381
387
|
summary: 'Push a local optimizer artifact directory.',
|
|
382
388
|
group: 'Optimizations',
|
|
383
389
|
},
|
|
390
|
+
{
|
|
391
|
+
path: ['optimizers', 'pull'],
|
|
392
|
+
usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
393
|
+
summary: 'Pull an optimizer artifact version to a local directory.',
|
|
394
|
+
group: 'Optimizations',
|
|
395
|
+
},
|
|
384
396
|
{
|
|
385
397
|
path: ['runs', 'submit'],
|
|
386
398
|
usage: 'orizu runs submit <results.jsonl|results.jsonl.gz> --prompt-version <id> --runner-version <id> --dataset-version <id> --split-set <id> --split <name> [--project <team/project>]',
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
workspaceExists,
|
|
60
60
|
} from './workspace.js'
|
|
61
61
|
import { connectorsCommand } from './connectors-cli.js'
|
|
62
|
+
import { ARTIFACT_MAX_BYTES, pullArtifactCommand } from './artifact-pull.js'
|
|
62
63
|
import { killSwitchCommand } from './kill-switch-cli.js'
|
|
63
64
|
import { egressAllowlistCommand } from './egress-allowlist-cli.js'
|
|
64
65
|
import { manifestsCommand } from './manifests-cli.js'
|
|
@@ -3820,7 +3821,7 @@ async function setupCommand() {
|
|
|
3820
3821
|
const skipConfirm = hasArg('--yes')
|
|
3821
3822
|
const noInput = hasArg('--no-input') || hasArg('--non-interactive') || !isInteractiveTerminal()
|
|
3822
3823
|
const workspaceArg = getOptionalArgValue('--workspace')
|
|
3823
|
-
const workspaceRoot = workspaceArg ? resolve(expandHomePath(workspaceArg)) : process.cwd()
|
|
3824
|
+
const workspaceRoot = workspaceArg ? resolve(expandHomePath(workspaceArg)) : process.cwd() // non-empty-dir guard now lives next to the hosted clone in runInteractiveHostedSetup, so --skip-login / decline-connect / local scaffolds are never blocked
|
|
3824
3825
|
const teamArg = getOptionalArgValue('--team')
|
|
3825
3826
|
const projectArg = getOptionalArgValue('--project')
|
|
3826
3827
|
const setupSkillHome = resolveSetupSkillHome()
|
|
@@ -5994,7 +5995,6 @@ function readRunnerManifest(runnerDir: string): { command: string[]; supports_bo
|
|
|
5994
5995
|
|
|
5995
5996
|
const RUNNER_TIMEOUT_MS = 120_000
|
|
5996
5997
|
const RUNNER_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
|
|
5997
|
-
const RUNNER_ARTIFACT_MAX_BYTES = 25 * 1024 * 1024
|
|
5998
5998
|
const RUNNER_ENV_ALLOWLIST = new Set([
|
|
5999
5999
|
'PATH',
|
|
6000
6000
|
'SystemRoot',
|
|
@@ -6045,9 +6045,9 @@ async function materializeRunnerVersion(runnerVersionId: string): Promise<{ runn
|
|
|
6045
6045
|
const zipPath = join(tempDir, 'runner.zip')
|
|
6046
6046
|
const runnerDir = join(tempDir, 'runner')
|
|
6047
6047
|
const zipBytes = new Uint8Array(await response.arrayBuffer())
|
|
6048
|
-
if (zipBytes.byteLength >
|
|
6048
|
+
if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
|
|
6049
6049
|
rmSync(tempDir, { recursive: true, force: true })
|
|
6050
|
-
throw new Error(`Runner artifact exceeds ${
|
|
6050
|
+
throw new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`)
|
|
6051
6051
|
}
|
|
6052
6052
|
writeFileSync(zipPath, zipBytes)
|
|
6053
6053
|
|
|
@@ -6462,18 +6462,17 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
6462
6462
|
return
|
|
6463
6463
|
}
|
|
6464
6464
|
|
|
6465
|
-
if (command === 'runners' && subcommand === 'push') {
|
|
6466
|
-
await pushRunnerArtifact('runner')
|
|
6465
|
+
if ((command === 'runners' || command === 'optimizers') && subcommand === 'push') {
|
|
6466
|
+
await pushRunnerArtifact(command === 'runners' ? 'runner' : 'optimizer')
|
|
6467
6467
|
return
|
|
6468
6468
|
}
|
|
6469
6469
|
|
|
6470
|
-
if (command === 'runners' && subcommand === '
|
|
6471
|
-
await
|
|
6472
|
-
return
|
|
6470
|
+
if ((command === 'runners' || command === 'optimizers') && subcommand === 'pull') {
|
|
6471
|
+
await pullArtifactCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug }); return
|
|
6473
6472
|
}
|
|
6474
6473
|
|
|
6475
|
-
if (command === '
|
|
6476
|
-
await
|
|
6474
|
+
if (command === 'runners' && subcommand === 'exec') {
|
|
6475
|
+
await runnersExec()
|
|
6477
6476
|
return
|
|
6478
6477
|
}
|
|
6479
6478
|
|
package/src/workspace.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
readdirSync,
|
|
7
7
|
readFileSync,
|
|
8
8
|
renameSync,
|
|
9
|
+
statSync,
|
|
9
10
|
symlinkSync,
|
|
10
11
|
unlinkSync,
|
|
11
12
|
writeFileSync,
|
|
@@ -185,6 +186,67 @@ export function workspaceExists(cwd?: string): boolean {
|
|
|
185
186
|
return existsSync(join(getWorkspaceRoot(cwd), 'orizu.team.json'))
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* `orizu setup`'s hosted path clones the workbench repo into the resolved
|
|
191
|
+
* workspace root; `git clone` refuses to write into ANY non-empty directory —
|
|
192
|
+
* including one holding only macOS `.DS_Store` cruft — and fails late with a
|
|
193
|
+
* raw git error (ALI QA: founder ran `orizu setup` from an existing folder).
|
|
194
|
+
* Call this right before the hosted clone so the failure is immediate and
|
|
195
|
+
* actionable instead of surfacing mid-clone.
|
|
196
|
+
*
|
|
197
|
+
* A directory is usable only when it doesn't exist yet, is STRICTLY empty
|
|
198
|
+
* (`readdirSync(...).length === 0` — no `.DS_Store` allowance, since git itself
|
|
199
|
+
* grants none), or is already a recognized Orizu workspace for a re-run
|
|
200
|
+
* (`workspaceExists`, i.e. it has `orizu.team.json`) — the legitimate
|
|
201
|
+
* idempotent-setup case, which must proceed. Symlinks are followed (a symlink
|
|
202
|
+
* to an empty dir is fine); a file, an unreadable dir, and a non-empty dir each
|
|
203
|
+
* get a distinct, friendly message.
|
|
204
|
+
*/
|
|
205
|
+
export function assertWorkspaceDirUsable(root: string): void {
|
|
206
|
+
const resolvedRoot = resolve(root)
|
|
207
|
+
if (!existsSync(resolvedRoot)) return
|
|
208
|
+
if (workspaceExists(resolvedRoot)) return
|
|
209
|
+
|
|
210
|
+
let stat
|
|
211
|
+
try {
|
|
212
|
+
// statSync (not lstatSync) follows symlinks, so a symlink to an empty dir
|
|
213
|
+
// is treated as the empty dir it points at.
|
|
214
|
+
stat = statSync(resolvedRoot)
|
|
215
|
+
} catch {
|
|
216
|
+
// Broken symlink / vanished between checks — nothing to clone over.
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!stat.isDirectory()) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`The path ${resolvedRoot} is a file, but \`orizu setup\` needs a directory to clone your workbench repo into. `
|
|
223
|
+
+ 'Choose an empty directory (or pass `--workspace <empty-path>`, or `--local` to scaffold a local-only workspace elsewhere), then re-run `orizu setup`.'
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let entries: string[]
|
|
228
|
+
try {
|
|
229
|
+
entries = readdirSync(resolvedRoot)
|
|
230
|
+
} catch (error) {
|
|
231
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
232
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`The directory ${resolvedRoot} can't be read (permission denied). `
|
|
235
|
+
+ 'Fix its permissions, or pass `--workspace <empty-path>` (or `--local`), then re-run `orizu setup`.'
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
throw error
|
|
239
|
+
}
|
|
240
|
+
if (entries.length === 0) return
|
|
241
|
+
|
|
242
|
+
throw new Error(
|
|
243
|
+
`The directory ${resolvedRoot} is not empty. \`orizu setup\` clones your workbench repo here and needs an empty directory. `
|
|
244
|
+
+ 'Create and enter an empty sub-directory (for example: `mkdir orizu-workbench && cd orizu-workbench`), '
|
|
245
|
+
+ 'pass `--workspace <empty-path>`, or pass `--local` to scaffold a local-only workspace in place, '
|
|
246
|
+
+ 'then re-run `orizu setup`.'
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
188
250
|
function formatJson(value: Record<string, unknown>): string {
|
|
189
251
|
return `${JSON.stringify(value, null, 2)}\n`
|
|
190
252
|
}
|