orizu 0.4.1 → 0.5.0
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 +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +271 -16
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +855 -237
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +39 -2
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +272 -16
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +987 -296
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +70 -2
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +3 -1
- package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
package/README.md
CHANGED
|
@@ -35,23 +35,24 @@ Install the CLI globally with your package manager:
|
|
|
35
35
|
| pnpm | `pnpm add -g orizu` |
|
|
36
36
|
| Bun | `bun add -g orizu` |
|
|
37
37
|
|
|
38
|
-
Then run the guided setup — it signs you in,
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
Then run the guided setup — it signs you in, initializes the local workspace
|
|
39
|
+
contract, installs global coding-agent skill symlinks, and shows the next
|
|
40
|
+
command for repo-specific Orizu adoption:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
43
|
orizu setup
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
To repair or customize skill installs later, run the companion skill installer
|
|
47
|
+
directly:
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
50
|
orizu install-skill --agent claude --agent codex --yes
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
in this repository.
|
|
54
|
-
either way.
|
|
53
|
+
Experimental plugin packages for Codex and Claude Code live under `plugins/`
|
|
54
|
+
in this repository. The CLI-installed skill remains the default onboarding path
|
|
55
|
+
for now; the CLI is the runtime either way.
|
|
55
56
|
|
|
56
57
|
```bash
|
|
57
58
|
# Codex
|
|
@@ -63,11 +64,11 @@ codex plugin add orizu@orizu-plugins
|
|
|
63
64
|
/plugin install orizu@orizu
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
The npm package ships the skill alongside the CLI. Run
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
The npm package ships the skill alongside the CLI. Run
|
|
68
|
+
`orizu install-skill --agent claude --agent codex --yes` for an explicit global
|
|
69
|
+
install, or use `orizu install-skill --help` for project-scope installs, sync
|
|
70
|
+
modes, and advanced targets including the managed `AGENTS.md` section for
|
|
71
|
+
non-workspace repos. Keep installs in sync after CLI upgrades with
|
|
71
72
|
`orizu skills status` and `orizu skills update`.
|
|
72
73
|
|
|
73
74
|
Coding agents can also read the bundled skill directly without installing it:
|
|
@@ -121,7 +122,7 @@ orizu logout
|
|
|
121
122
|
</tr>
|
|
122
123
|
<tr>
|
|
123
124
|
<td>Install the bundled agent skill</td>
|
|
124
|
-
<td><code>orizu install-skill --target
|
|
125
|
+
<td><code>orizu install-skill --target codex-user --yes</code></td>
|
|
125
126
|
</tr>
|
|
126
127
|
<tr>
|
|
127
128
|
<td>Locate the bundled skill (read-only)</td>
|
|
@@ -274,6 +275,14 @@ orizu logout
|
|
|
274
275
|
<td>Publish a draft task</td>
|
|
275
276
|
<td><code>orizu tasks publish --task <taskId> --assignees <userId1,userId2></code></td>
|
|
276
277
|
</tr>
|
|
278
|
+
<tr>
|
|
279
|
+
<td>Update a draft task</td>
|
|
280
|
+
<td><code>orizu tasks update --task <taskId> --title "Support QA Round 1b" --labels-per-item 2</code></td>
|
|
281
|
+
</tr>
|
|
282
|
+
<tr>
|
|
283
|
+
<td>Discard a draft task</td>
|
|
284
|
+
<td><code>orizu tasks discard --task <taskId> --yes</code></td>
|
|
285
|
+
</tr>
|
|
277
286
|
<tr>
|
|
278
287
|
<td>Check task status</td>
|
|
279
288
|
<td><code>orizu tasks status --task <taskId></code></td>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
const ARTIFACT_EXCLUDED_PATH_NAMES = new Set([
|
|
5
|
+
'.git',
|
|
6
|
+
'.DS_Store',
|
|
7
|
+
'__pycache__',
|
|
8
|
+
'.pytest_cache',
|
|
9
|
+
'.orizu',
|
|
10
|
+
]);
|
|
11
|
+
function isNodeError(error) {
|
|
12
|
+
return error instanceof Error;
|
|
13
|
+
}
|
|
14
|
+
function expandHomePath(path) {
|
|
15
|
+
if (path.startsWith('~/')) {
|
|
16
|
+
const home = process.env.HOME || '';
|
|
17
|
+
return `${home}/${path.slice(2)}`;
|
|
18
|
+
}
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Archives must be byte-identical for identical logical content across machines
|
|
23
|
+
* because content-addressed deduplication depends on deterministic inputs.
|
|
24
|
+
*
|
|
25
|
+
* Tradeoff: runner artifacts are downloaded, unzipped, and executed directly
|
|
26
|
+
* (there is no install step), so vendored dependency trees such as
|
|
27
|
+
* node_modules, .venv/venv, and build outputs are legitimate runtime payload
|
|
28
|
+
* and are deliberately NOT excluded — hash reproducibility therefore depends
|
|
29
|
+
* on the author shipping identical vendored trees across machines. Only true
|
|
30
|
+
* junk (.git, .DS_Store, __pycache__, .pytest_cache, .orizu) and secrets
|
|
31
|
+
* (.env, .env.*) are stripped: secrets must never ship, because runners
|
|
32
|
+
* receive their configuration at exec time.
|
|
33
|
+
*/
|
|
34
|
+
export function shouldExcludeArtifactPath(relativePath) {
|
|
35
|
+
const parts = relativePath.split('/');
|
|
36
|
+
return parts.some(part => ARTIFACT_EXCLUDED_PATH_NAMES.has(part) ||
|
|
37
|
+
part === '.env' ||
|
|
38
|
+
part.startsWith('.env.'));
|
|
39
|
+
}
|
|
40
|
+
export function collectArtifactFiles(sourceDir, relativeDir = '') {
|
|
41
|
+
const absoluteDir = relativeDir ? join(sourceDir, relativeDir) : sourceDir;
|
|
42
|
+
return readdirSync(absoluteDir, { withFileTypes: true })
|
|
43
|
+
.flatMap(entry => {
|
|
44
|
+
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
45
|
+
if (shouldExcludeArtifactPath(relativePath)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
return collectArtifactFiles(sourceDir, relativePath);
|
|
50
|
+
}
|
|
51
|
+
return entry.isFile() ? [relativePath] : [];
|
|
52
|
+
})
|
|
53
|
+
.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
54
|
+
}
|
|
55
|
+
export const ZIP_GENERAL_PURPOSE_UTF8 = 0x0800;
|
|
56
|
+
export const ZIP_DOS_TIME_2000_01_01 = 0;
|
|
57
|
+
export const ZIP_DOS_DATE_2000_01_01 = ((2000 - 1980) << 9) | (1 << 5) | 1;
|
|
58
|
+
const CRC32_TABLE = Array.from({ length: 256 }, (_, index) => {
|
|
59
|
+
let value = index;
|
|
60
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
61
|
+
value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
|
|
62
|
+
}
|
|
63
|
+
return value >>> 0;
|
|
64
|
+
});
|
|
65
|
+
function crc32(bytes) {
|
|
66
|
+
let crc = 0xffffffff;
|
|
67
|
+
for (const byte of bytes) {
|
|
68
|
+
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
69
|
+
}
|
|
70
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
71
|
+
}
|
|
72
|
+
export function createStoredZip(entries) {
|
|
73
|
+
const localParts = [];
|
|
74
|
+
const centralParts = [];
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const name = Buffer.from(entry.path, 'utf8');
|
|
78
|
+
const crc = crc32(entry.data);
|
|
79
|
+
const localHeader = Buffer.alloc(30);
|
|
80
|
+
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
81
|
+
localHeader.writeUInt16LE(10, 4);
|
|
82
|
+
localHeader.writeUInt16LE(ZIP_GENERAL_PURPOSE_UTF8, 6);
|
|
83
|
+
localHeader.writeUInt16LE(0, 8);
|
|
84
|
+
localHeader.writeUInt16LE(ZIP_DOS_TIME_2000_01_01, 10);
|
|
85
|
+
localHeader.writeUInt16LE(ZIP_DOS_DATE_2000_01_01, 12);
|
|
86
|
+
localHeader.writeUInt32LE(crc, 14);
|
|
87
|
+
localHeader.writeUInt32LE(entry.data.length, 18);
|
|
88
|
+
localHeader.writeUInt32LE(entry.data.length, 22);
|
|
89
|
+
localHeader.writeUInt16LE(name.length, 26);
|
|
90
|
+
localHeader.writeUInt16LE(0, 28);
|
|
91
|
+
localParts.push(localHeader, name, entry.data);
|
|
92
|
+
const centralHeader = Buffer.alloc(46);
|
|
93
|
+
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
94
|
+
centralHeader.writeUInt16LE(20, 4);
|
|
95
|
+
centralHeader.writeUInt16LE(10, 6);
|
|
96
|
+
centralHeader.writeUInt16LE(ZIP_GENERAL_PURPOSE_UTF8, 8);
|
|
97
|
+
centralHeader.writeUInt16LE(0, 10);
|
|
98
|
+
centralHeader.writeUInt16LE(ZIP_DOS_TIME_2000_01_01, 12);
|
|
99
|
+
centralHeader.writeUInt16LE(ZIP_DOS_DATE_2000_01_01, 14);
|
|
100
|
+
centralHeader.writeUInt32LE(crc, 16);
|
|
101
|
+
centralHeader.writeUInt32LE(entry.data.length, 20);
|
|
102
|
+
centralHeader.writeUInt32LE(entry.data.length, 24);
|
|
103
|
+
centralHeader.writeUInt16LE(name.length, 28);
|
|
104
|
+
centralHeader.writeUInt16LE(0, 30);
|
|
105
|
+
centralHeader.writeUInt16LE(0, 32);
|
|
106
|
+
centralHeader.writeUInt16LE(0, 34);
|
|
107
|
+
centralHeader.writeUInt16LE(0, 36);
|
|
108
|
+
centralHeader.writeUInt32LE(0, 38);
|
|
109
|
+
centralHeader.writeUInt32LE(offset, 42);
|
|
110
|
+
centralParts.push(centralHeader, name);
|
|
111
|
+
offset += localHeader.length + name.length + entry.data.length;
|
|
112
|
+
}
|
|
113
|
+
const centralDirectoryOffset = offset;
|
|
114
|
+
const centralDirectory = Buffer.concat(centralParts);
|
|
115
|
+
const endOfCentralDirectory = Buffer.alloc(22);
|
|
116
|
+
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0);
|
|
117
|
+
endOfCentralDirectory.writeUInt16LE(0, 4);
|
|
118
|
+
endOfCentralDirectory.writeUInt16LE(0, 6);
|
|
119
|
+
endOfCentralDirectory.writeUInt16LE(entries.length, 8);
|
|
120
|
+
endOfCentralDirectory.writeUInt16LE(entries.length, 10);
|
|
121
|
+
endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12);
|
|
122
|
+
endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16);
|
|
123
|
+
endOfCentralDirectory.writeUInt16LE(0, 20);
|
|
124
|
+
return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]);
|
|
125
|
+
}
|
|
126
|
+
export function zipDirectoryToBase64(dirArg) {
|
|
127
|
+
const sourceDir = expandHomePath(dirArg);
|
|
128
|
+
try {
|
|
129
|
+
const stats = statSync(sourceDir);
|
|
130
|
+
if (!stats.isDirectory()) {
|
|
131
|
+
throw new Error(`${sourceDir} is not a directory`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
136
|
+
throw new Error(`Directory not found: ${sourceDir}`);
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const files = collectArtifactFiles(sourceDir);
|
|
141
|
+
if (files.length === 0) {
|
|
142
|
+
throw new Error(`Directory contains no artifact files: ${sourceDir}`);
|
|
143
|
+
}
|
|
144
|
+
const bytes = createStoredZip(files.map(relativePath => ({
|
|
145
|
+
path: relativePath,
|
|
146
|
+
data: readFileSync(join(sourceDir, relativePath)),
|
|
147
|
+
})));
|
|
148
|
+
return {
|
|
149
|
+
zipBase64: bytes.toString('base64'),
|
|
150
|
+
contentSha256: createHash('sha256').update(bytes).digest('hex'),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connector readiness CLI (ALI-903). Thin, read-only consumer of the
|
|
3
|
+
* project-scoped `/api/cli/connectors` endpoint. Pure command logic with an
|
|
4
|
+
* injected fetcher and project resolver; the entry point owns argument parsing
|
|
5
|
+
* and human/JSON output. Secret material is never printed (the endpoint already
|
|
6
|
+
* redacts config and surfaces credentials only as a boolean).
|
|
7
|
+
*/
|
|
8
|
+
import { authedFetch } from './http.js';
|
|
9
|
+
async function responseMessage(response) {
|
|
10
|
+
try {
|
|
11
|
+
const data = (await response.clone().json());
|
|
12
|
+
const error = typeof data.error === 'string' ? data.error : null;
|
|
13
|
+
const message = typeof data.message === 'string' ? data.message : null;
|
|
14
|
+
return error || message || response.statusText || String(response.status);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return response.statusText || String(response.status);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function fetchConnectors(opts) {
|
|
21
|
+
const fetcher = opts.fetcher ?? authedFetch;
|
|
22
|
+
const response = await fetcher(`/api/cli/connectors?project=${encodeURIComponent(opts.projectSlug)}`);
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Connector status failed (${response.status}): ${await responseMessage(response)}`);
|
|
25
|
+
}
|
|
26
|
+
const data = (await response.json());
|
|
27
|
+
return { connectors: Array.isArray(data.connectors) ? data.connectors : [] };
|
|
28
|
+
}
|
|
29
|
+
function argValue(args, flag) {
|
|
30
|
+
const index = args.indexOf(flag);
|
|
31
|
+
if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return args[index + 1];
|
|
35
|
+
}
|
|
36
|
+
// Positional tokens only: skips flags AND their values, so
|
|
37
|
+
// `orizu connectors --project team/slug` never misreads the value.
|
|
38
|
+
function positionalArgs(args) {
|
|
39
|
+
const values = [];
|
|
40
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
41
|
+
const arg = args[i];
|
|
42
|
+
if (arg.startsWith('--')) {
|
|
43
|
+
if (argValue(args, arg) !== null) {
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
values.push(arg);
|
|
49
|
+
}
|
|
50
|
+
return values;
|
|
51
|
+
}
|
|
52
|
+
function formatConnector(connector) {
|
|
53
|
+
const creds = connector.hasCredentials ? 'credentials:yes' : 'credentials:no';
|
|
54
|
+
return `${connector.type} ${connector.status} ${creds}`;
|
|
55
|
+
}
|
|
56
|
+
export async function connectorsCommand(args, io) {
|
|
57
|
+
const subcommand = positionalArgs(args)[0];
|
|
58
|
+
if (subcommand && subcommand !== 'status') {
|
|
59
|
+
io.print('Usage: orizu connectors [status] [--project <team/project>] [--json]');
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
const resolveProjectSlug = io.resolveProjectSlug;
|
|
63
|
+
if (!resolveProjectSlug) {
|
|
64
|
+
throw new Error('Project resolver unavailable');
|
|
65
|
+
}
|
|
66
|
+
const projectSlug = await resolveProjectSlug(argValue(args, '--project'));
|
|
67
|
+
const result = await fetchConnectors({ fetcher: io.fetcher, projectSlug });
|
|
68
|
+
if (io.json) {
|
|
69
|
+
io.print(JSON.stringify(result));
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (result.connectors.length === 0) {
|
|
73
|
+
io.print('No connectors known for this project.');
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
for (const connector of result.connectors) {
|
|
77
|
+
io.print(formatConnector(connector));
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
package/dist/credentials.js
CHANGED
|
@@ -2,6 +2,9 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { randomBytes } from 'crypto';
|
|
5
|
+
function isNodeError(error) {
|
|
6
|
+
return error instanceof Error;
|
|
7
|
+
}
|
|
5
8
|
function getConfigDir() {
|
|
6
9
|
if (process.env.ORIZU_CONFIG_DIR) {
|
|
7
10
|
return process.env.ORIZU_CONFIG_DIR;
|
|
@@ -68,7 +71,7 @@ function writeCredentials(config) {
|
|
|
68
71
|
renameSync(tempPath, path);
|
|
69
72
|
}
|
|
70
73
|
catch (error) {
|
|
71
|
-
if (process.platform === 'win32' && error
|
|
74
|
+
if (process.platform === 'win32' && isNodeError(error) && error.code === 'EEXIST') {
|
|
72
75
|
rmSync(path, { force: true });
|
|
73
76
|
renameSync(tempPath, path);
|
|
74
77
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained rehearsal harness for the Daytona slice (ALI-973 / WS-F).
|
|
3
|
+
*
|
|
4
|
+
* Extends the smoke test's in-memory fake-server technique to the WS-F broker /
|
|
5
|
+
* session-branch / finish-branch / apply contracts, and backs it with a REAL
|
|
6
|
+
* local bare git repo so the whole flow (clone → commit → push → merge) runs
|
|
7
|
+
* against genuine git. Consumed by BOTH the unit test and the local-sim
|
|
8
|
+
* rehearsal in `scripts/daytona-workbench-slice.mjs`, so the two never drift.
|
|
9
|
+
*
|
|
10
|
+
* This is the piece that makes `--provider local-sim` a rehearsal, not a mock:
|
|
11
|
+
* `finish-branch` and `apply` shell out to git on the bare repo (rev-list /
|
|
12
|
+
* update-ref), so a session branch really is merged into `main`.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'child_process';
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
16
|
+
import { tmpdir } from 'os';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import { WORKSPACE_SCHEMA_VERSION } from './workspace.js';
|
|
19
|
+
import { createLocalSimProvider } from './sandbox-provider.js';
|
|
20
|
+
function git(args, cwd) {
|
|
21
|
+
// Strip git's repo-context env vars before spawning. When this code runs
|
|
22
|
+
// inside a git hook (e.g. the pre-push hook running `bun test`), git sets
|
|
23
|
+
// GIT_DIR in the environment — and `git init --bare <dir>` with GIT_DIR set
|
|
24
|
+
// REINITIALIZES the repo GIT_DIR points at as bare (core.bare=true in the
|
|
25
|
+
// shared config), breaking every linked worktree. Scope every child git
|
|
26
|
+
// strictly to its cwd/args instead.
|
|
27
|
+
const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' };
|
|
28
|
+
delete env.GIT_DIR;
|
|
29
|
+
delete env.GIT_WORK_TREE;
|
|
30
|
+
delete env.GIT_INDEX_FILE;
|
|
31
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
32
|
+
delete env.GIT_COMMON_DIR;
|
|
33
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8', env });
|
|
34
|
+
return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', status: res.status ?? 1 };
|
|
35
|
+
}
|
|
36
|
+
function gitOrThrow(args, cwd) {
|
|
37
|
+
const r = git(args, cwd);
|
|
38
|
+
if (r.status !== 0) {
|
|
39
|
+
throw new Error(`git ${args.join(' ')} failed: ${r.stderr.trim() || `exit ${r.status}`}`);
|
|
40
|
+
}
|
|
41
|
+
return r.stdout;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a bare git repo seeded with the Phase-1 workbench contract (matching
|
|
45
|
+
* `lib/workbench-repo-contract.ts`) on a `main` branch: root manifests, a
|
|
46
|
+
* `.gitignore` that ignores `.orizu/`, and a project stub.
|
|
47
|
+
*/
|
|
48
|
+
export function seedLocalBareRepo(baseDir, config) {
|
|
49
|
+
const gitDir = join(baseDir, 'workbench.git');
|
|
50
|
+
gitOrThrow(['init', '--bare', '--initial-branch=main', gitDir]);
|
|
51
|
+
const work = mkdtempSync(join(baseDir, 'seed-'));
|
|
52
|
+
gitOrThrow(['init', '--initial-branch=main', work]);
|
|
53
|
+
const ident = ['-c', 'user.email=seed@orizu.local', '-c', 'user.name=Seed'];
|
|
54
|
+
const teamManifest = {
|
|
55
|
+
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
56
|
+
kind: 'team',
|
|
57
|
+
slug: config.teamSlug,
|
|
58
|
+
canonical: { owner: 'repo', repoState: 'source', objectRef: null },
|
|
59
|
+
};
|
|
60
|
+
const projectManifest = {
|
|
61
|
+
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
62
|
+
kind: 'project',
|
|
63
|
+
slug: config.projectSlug,
|
|
64
|
+
canonical: { owner: 'repo', repoState: 'draft', objectRef: null },
|
|
65
|
+
};
|
|
66
|
+
writeFileSync(join(work, 'orizu.team.json'), `${JSON.stringify(teamManifest, null, 2)}\n`);
|
|
67
|
+
writeFileSync(join(work, '.gitignore'), '# Orizu workspace policy\n.orizu/\n');
|
|
68
|
+
writeFileSync(join(work, 'AGENTS.md'), '# Workbench\n');
|
|
69
|
+
const projectDir = join(work, 'projects', config.projectSlug);
|
|
70
|
+
mkdirSync(projectDir, { recursive: true });
|
|
71
|
+
writeFileSync(join(projectDir, 'orizu.project.json'), `${JSON.stringify(projectManifest, null, 2)}\n`);
|
|
72
|
+
writeFileSync(join(projectDir, '.gitkeep'), '');
|
|
73
|
+
gitOrThrow([...ident, 'add', '-A'], work);
|
|
74
|
+
gitOrThrow([...ident, 'commit', '-m', 'seed workbench contract'], work);
|
|
75
|
+
gitOrThrow(['remote', 'add', 'origin', gitDir], work);
|
|
76
|
+
gitOrThrow(['push', 'origin', 'main'], work);
|
|
77
|
+
rmSync(work, { recursive: true, force: true });
|
|
78
|
+
return { gitDir, repoFullName: `orizu-sim/orizu-workbench-${config.teamSlug}`, defaultBranch: 'main' };
|
|
79
|
+
}
|
|
80
|
+
function jsonResponse(body, status = 200) {
|
|
81
|
+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* In-memory WS-F control plane over a real bare repo. Session-start cuts a real
|
|
85
|
+
* branch; finish-branch diffs it; apply fast-forwards `main`. Tracks token mints
|
|
86
|
+
* so revocation is observable and asserts tokens never leak into stored state.
|
|
87
|
+
*/
|
|
88
|
+
export class FakeWorkbenchApi {
|
|
89
|
+
repo;
|
|
90
|
+
workspaces = new Map();
|
|
91
|
+
sessions = new Map();
|
|
92
|
+
mints = new Map();
|
|
93
|
+
manifests = new Map();
|
|
94
|
+
tokensIssued = [];
|
|
95
|
+
counter = 0;
|
|
96
|
+
constructor(repo) {
|
|
97
|
+
this.repo = repo;
|
|
98
|
+
}
|
|
99
|
+
id(prefix) {
|
|
100
|
+
this.counter += 1;
|
|
101
|
+
return `${prefix}-${this.counter}`;
|
|
102
|
+
}
|
|
103
|
+
branchHead(branch) {
|
|
104
|
+
return gitOrThrow(['--git-dir', this.repo.gitDir, 'rev-parse', branch]).trim();
|
|
105
|
+
}
|
|
106
|
+
newFetcher() {
|
|
107
|
+
return async (path, init = {}) => {
|
|
108
|
+
const method = (init.method || 'GET').toUpperCase();
|
|
109
|
+
const [rawPath] = path.split('?');
|
|
110
|
+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : {};
|
|
111
|
+
// Workspace create-or-attach (idempotent on teamSlug + slug).
|
|
112
|
+
if (method === 'POST' && rawPath === '/api/cli/workspaces') {
|
|
113
|
+
const key = `${body.teamSlug}/${body.slug}`;
|
|
114
|
+
const existing = this.workspaces.get(key);
|
|
115
|
+
if (existing)
|
|
116
|
+
return jsonResponse({ workspace: { ...existing } }, 200);
|
|
117
|
+
const workspace = { id: this.id('ws'), teamSlug: String(body.teamSlug), slug: String(body.slug) };
|
|
118
|
+
this.workspaces.set(key, workspace);
|
|
119
|
+
return jsonResponse({ workspace }, 201);
|
|
120
|
+
}
|
|
121
|
+
// Session start — cut a real `orizu/session-<id>` branch off main.
|
|
122
|
+
const sessMatch = rawPath.match(/^\/api\/cli\/workspaces\/([^/]+)\/sessions$/);
|
|
123
|
+
if (method === 'POST' && sessMatch) {
|
|
124
|
+
const sessionId = this.id('sess');
|
|
125
|
+
const stored = {
|
|
126
|
+
id: sessionId,
|
|
127
|
+
workspaceId: sessMatch[1],
|
|
128
|
+
branch: null,
|
|
129
|
+
headSha: null,
|
|
130
|
+
projectSlug: typeof body.projectSlug === 'string' ? body.projectSlug : null,
|
|
131
|
+
};
|
|
132
|
+
if (body.repoBranch !== false) {
|
|
133
|
+
const branch = `orizu/session-${sessionId}`;
|
|
134
|
+
gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', branch, this.repo.defaultBranch]);
|
|
135
|
+
stored.branch = branch;
|
|
136
|
+
stored.headSha = this.branchHead(branch);
|
|
137
|
+
}
|
|
138
|
+
this.sessions.set(sessionId, stored);
|
|
139
|
+
return jsonResponse({ session: { id: sessionId, repoBranch: stored.branch, repoHeadSha: stored.headSha, status: 'active' } }, 201);
|
|
140
|
+
}
|
|
141
|
+
// Repo-token mint.
|
|
142
|
+
const tokenMatch = rawPath.match(/^\/api\/cli\/workspaces\/([^/]+)\/repo-token$/);
|
|
143
|
+
if (method === 'POST' && tokenMatch) {
|
|
144
|
+
const purpose = String(body.purpose);
|
|
145
|
+
const mintId = this.id('mint');
|
|
146
|
+
const token = `ghs_sim_${purpose}_${mintId}_${Math.random().toString(36).slice(2)}`;
|
|
147
|
+
this.mints.set(mintId, { id: mintId, workspaceId: tokenMatch[1], purpose, revoked: false });
|
|
148
|
+
this.tokensIssued.push(token);
|
|
149
|
+
return jsonResponse({
|
|
150
|
+
token,
|
|
151
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
152
|
+
repo: this.repo.repoFullName,
|
|
153
|
+
mintId,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Repo-token revoke.
|
|
157
|
+
if (method === 'DELETE' && tokenMatch) {
|
|
158
|
+
const mintId = String(body.mintId);
|
|
159
|
+
const mint = this.mints.get(mintId);
|
|
160
|
+
if (!mint)
|
|
161
|
+
return jsonResponse({ error: 'Token mint not found' }, 404);
|
|
162
|
+
mint.revoked = true;
|
|
163
|
+
return jsonResponse({ revoked: true, mintId });
|
|
164
|
+
}
|
|
165
|
+
// Finish-branch — compare the session branch against main.
|
|
166
|
+
const finishMatch = rawPath.match(/^\/api\/cli\/sessions\/([^/]+)\/finish-branch$/);
|
|
167
|
+
if (method === 'POST' && finishMatch) {
|
|
168
|
+
const session = this.sessions.get(finishMatch[1]);
|
|
169
|
+
if (!session || !session.branch)
|
|
170
|
+
return jsonResponse({ error: 'Session has no repo branch' }, 400);
|
|
171
|
+
const ahead = Number.parseInt(gitOrThrow(['--git-dir', this.repo.gitDir, 'rev-list', '--count', `${this.repo.defaultBranch}..${session.branch}`]).trim(), 10);
|
|
172
|
+
if (ahead === 0) {
|
|
173
|
+
gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', session.branch]);
|
|
174
|
+
return jsonResponse({ outcome: 'no-changes', branch: session.branch });
|
|
175
|
+
}
|
|
176
|
+
const headSha = this.branchHead(session.branch);
|
|
177
|
+
const manifestId = this.id('man');
|
|
178
|
+
this.manifests.set(manifestId, {
|
|
179
|
+
id: manifestId,
|
|
180
|
+
status: 'pending_approval',
|
|
181
|
+
branch: session.branch,
|
|
182
|
+
headSha,
|
|
183
|
+
outcome: {},
|
|
184
|
+
});
|
|
185
|
+
return jsonResponse({ outcome: 'manifest', manifest: { id: manifestId, status: 'pending_approval', actionType: 'repo_merge' } }, 201);
|
|
186
|
+
}
|
|
187
|
+
// Manifest approve / apply / reject.
|
|
188
|
+
const manifestMatch = rawPath.match(/^\/api\/cli\/promotion-manifests\/([^/]+)$/);
|
|
189
|
+
if (method === 'POST' && manifestMatch) {
|
|
190
|
+
const manifest = this.manifests.get(manifestMatch[1]);
|
|
191
|
+
if (!manifest)
|
|
192
|
+
return jsonResponse({ error: 'Promotion manifest not found' }, 404);
|
|
193
|
+
const action = String(body.action);
|
|
194
|
+
if (action === 'approve') {
|
|
195
|
+
if (manifest.status === 'pending_approval' || manifest.status === 'draft')
|
|
196
|
+
manifest.status = 'approved';
|
|
197
|
+
return jsonResponse({ manifest: { id: manifest.id, status: manifest.status } });
|
|
198
|
+
}
|
|
199
|
+
if (action === 'apply') {
|
|
200
|
+
if (manifest.status === 'applied') {
|
|
201
|
+
return jsonResponse({ manifest: { id: manifest.id, status: 'applied', outcome: manifest.outcome } });
|
|
202
|
+
}
|
|
203
|
+
if (manifest.status !== 'approved') {
|
|
204
|
+
return jsonResponse({ error: 'Manifest must be approved before it can be applied' }, 400);
|
|
205
|
+
}
|
|
206
|
+
// Re-verify the branch head, then fast-forward main to it (real merge).
|
|
207
|
+
const head = this.branchHead(manifest.branch);
|
|
208
|
+
if (head !== manifest.headSha)
|
|
209
|
+
return jsonResponse({ error: 'branch moved' }, 409);
|
|
210
|
+
gitOrThrow(['--git-dir', this.repo.gitDir, 'update-ref', `refs/heads/${this.repo.defaultBranch}`, head]);
|
|
211
|
+
gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', manifest.branch]);
|
|
212
|
+
manifest.status = 'applied';
|
|
213
|
+
manifest.outcome = { merged: true, mergeSha: head };
|
|
214
|
+
return jsonResponse({ manifest: { id: manifest.id, status: 'applied', outcome: manifest.outcome } });
|
|
215
|
+
}
|
|
216
|
+
if (action === 'reject') {
|
|
217
|
+
manifest.status = 'rejected';
|
|
218
|
+
gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', manifest.branch]);
|
|
219
|
+
return jsonResponse({ manifest: { id: manifest.id, status: 'rejected' } });
|
|
220
|
+
}
|
|
221
|
+
return jsonResponse({ error: "action must be one of 'approve', 'reject', 'apply'" }, 400);
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`FakeWorkbenchApi: unexpected fetch ${method} ${path}`);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Wire a full self-contained rehearsal: seed a bare repo, stand up the fake
|
|
229
|
+
* control plane, and hand back a local-sim provider + resolveCloneUrl that maps
|
|
230
|
+
* the broker's repo name to the bare repo path.
|
|
231
|
+
*/
|
|
232
|
+
export function createSliceRehearsal(options = {}) {
|
|
233
|
+
const config = {
|
|
234
|
+
teamSlug: options.config?.teamSlug ?? 'acme',
|
|
235
|
+
projectSlug: options.config?.projectSlug ?? 'support-bot',
|
|
236
|
+
workspaceSlug: options.config?.workspaceSlug ?? 'support-bot-slice',
|
|
237
|
+
};
|
|
238
|
+
const base = mkdtempSync(join(tmpdir(), 'orizu-slice-rehearsal-'));
|
|
239
|
+
const repo = seedLocalBareRepo(base, config);
|
|
240
|
+
const api = new FakeWorkbenchApi(repo);
|
|
241
|
+
const provider = createLocalSimProvider({ rootDir: join(base, 'sandboxes'), keepOnDestroy: Boolean(options.keep) });
|
|
242
|
+
return {
|
|
243
|
+
config,
|
|
244
|
+
api,
|
|
245
|
+
provider,
|
|
246
|
+
makeFetcher: () => api.newFetcher(),
|
|
247
|
+
resolveCloneUrl: () => repo.gitDir,
|
|
248
|
+
bareRepoDir: repo.gitDir,
|
|
249
|
+
defaultBranch: () => gitOrThrow(['--git-dir', repo.gitDir, 'rev-parse', repo.defaultBranch]).trim(),
|
|
250
|
+
cleanup: () => {
|
|
251
|
+
if (!options.keep)
|
|
252
|
+
rmSync(base, { recursive: true, force: true });
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|