orizu 0.5.0 → 0.5.2
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/github-setup.js +48 -2
- package/dist/index.js +1 -1
- package/dist/workspace.js +56 -1
- package/package.json +1 -1
- package/src/github-setup.ts +70 -4
- package/src/index.ts +1 -1
- package/src/workspace.ts +62 -0
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) {
|
|
@@ -54,18 +64,29 @@ export async function runGithubLink(teamSlug, io) {
|
|
|
54
64
|
const { installUrl, state } = (await start.json());
|
|
55
65
|
io.print(' Connect your team GitHub org by installing the Orizu GitHub App:');
|
|
56
66
|
io.print(` ${installUrl}`);
|
|
67
|
+
// Bug 2: personal accounts cannot host an auto-provisioned workbench.
|
|
68
|
+
io.print(' • Install into a GitHub ORGANIZATION, not a personal account. Personal');
|
|
69
|
+
io.print(' accounts are not supported for auto-provisioning; creating an org is free.');
|
|
70
|
+
// Bug 4: repo selection guidance — GitHub forces picking at least one repo.
|
|
71
|
+
io.print(' • On the "Repository access" step choose "Only select repositories" and pick');
|
|
72
|
+
io.print(' any one repo (GitHub requires at least one). Orizu adds the workbench repo');
|
|
73
|
+
io.print(' it creates automatically — do NOT choose "All repositories".');
|
|
57
74
|
if (io.openUrl) {
|
|
58
75
|
io.openUrl(installUrl);
|
|
59
|
-
io.print(' Opened the install page in your browser.
|
|
76
|
+
io.print(' Opened the install page in your browser.');
|
|
60
77
|
}
|
|
61
78
|
else {
|
|
62
|
-
io.print(' Open the URL above
|
|
79
|
+
io.print(' Open the URL above and install the App, then return here.');
|
|
63
80
|
}
|
|
81
|
+
io.print(` Waiting for you to finish installing in the browser… (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`).`);
|
|
64
82
|
const now = io.now ?? (() => Date.now());
|
|
65
83
|
const sleep = io.sleep ?? defaultSleep;
|
|
66
84
|
const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
67
85
|
const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
|
|
68
86
|
const deadline = now() + timeout;
|
|
87
|
+
// Periodic heartbeat so a long install does not look hung; never echoes state.
|
|
88
|
+
const HEARTBEAT_MS = 15000;
|
|
89
|
+
let lastHeartbeat = now();
|
|
69
90
|
for (;;) {
|
|
70
91
|
const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`);
|
|
71
92
|
if (poll.ok) {
|
|
@@ -77,11 +98,22 @@ export async function runGithubLink(teamSlug, io) {
|
|
|
77
98
|
if (data.status === 'pending_confirmation') {
|
|
78
99
|
return await confirmLink(teamSlug, data.orgLogin ?? null, io, fetcher);
|
|
79
100
|
}
|
|
101
|
+
// Distinguishable terminal errors (personal account / expired nonce): print
|
|
102
|
+
// the server's reason and exit non-zero instead of polling until timeout.
|
|
103
|
+
if (data.status === 'unsupported_account' || data.status === 'expired') {
|
|
104
|
+
throw new Error(data.error ??
|
|
105
|
+
`GitHub link could not be completed. Re-run \`orizu github link --team ${teamSlug}\`.`);
|
|
106
|
+
}
|
|
107
|
+
// status 'pending' → keep waiting.
|
|
80
108
|
}
|
|
81
109
|
if (now() >= deadline) {
|
|
82
110
|
throw new Error('Timed out waiting for the GitHub App install to complete. ' +
|
|
83
111
|
`Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`);
|
|
84
112
|
}
|
|
113
|
+
if (now() - lastHeartbeat >= HEARTBEAT_MS) {
|
|
114
|
+
io.print(' …still waiting for the GitHub install to complete.');
|
|
115
|
+
lastHeartbeat = now();
|
|
116
|
+
}
|
|
85
117
|
await sleep(interval);
|
|
86
118
|
}
|
|
87
119
|
}
|
|
@@ -156,6 +188,12 @@ export async function runHostedAttach(options, io) {
|
|
|
156
188
|
const data = (await response.json());
|
|
157
189
|
repoFullName = data.repo;
|
|
158
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
|
+
}
|
|
159
197
|
}
|
|
160
198
|
const cleanUrl = `https://github.com/${repoFullName}.git`;
|
|
161
199
|
io.print(` Cloning ${repoFullName}...`);
|
|
@@ -221,6 +259,14 @@ export async function runInteractiveHostedSetup(input, io) {
|
|
|
221
259
|
if (mode === 'local') {
|
|
222
260
|
return { mode, attached: false, repoFullName: null };
|
|
223
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);
|
|
224
270
|
const result = await runHostedAttach({ workspaceId: workspace.id, repoFullName, targetDir: input.targetDir }, { print: io.print, fetcher, git: io.git ?? defaultGitRunner, ensureOrizuDir: io.ensureOrizuDir });
|
|
225
271
|
return { mode, attached: true, repoFullName: result.repoFullName };
|
|
226
272
|
}
|
package/dist/index.js
CHANGED
|
@@ -2862,7 +2862,7 @@ async function setupCommand() {
|
|
|
2862
2862
|
const skipConfirm = hasArg('--yes');
|
|
2863
2863
|
const noInput = hasArg('--no-input') || hasArg('--non-interactive') || !isInteractiveTerminal();
|
|
2864
2864
|
const workspaceArg = getOptionalArgValue('--workspace');
|
|
2865
|
-
const workspaceRoot = workspaceArg ? resolve(expandHomePath(workspaceArg)) : process.cwd();
|
|
2865
|
+
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
2866
|
const teamArg = getOptionalArgValue('--team');
|
|
2867
2867
|
const projectArg = getOptionalArgValue('--project');
|
|
2868
2868
|
const setupSkillHome = resolveSetupSkillHome();
|
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
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
|
|
@@ -86,23 +97,40 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
|
|
|
86
97
|
|
|
87
98
|
io.print(' Connect your team GitHub org by installing the Orizu GitHub App:')
|
|
88
99
|
io.print(` ${installUrl}`)
|
|
100
|
+
// Bug 2: personal accounts cannot host an auto-provisioned workbench.
|
|
101
|
+
io.print(' • Install into a GitHub ORGANIZATION, not a personal account. Personal')
|
|
102
|
+
io.print(' accounts are not supported for auto-provisioning; creating an org is free.')
|
|
103
|
+
// Bug 4: repo selection guidance — GitHub forces picking at least one repo.
|
|
104
|
+
io.print(' • On the "Repository access" step choose "Only select repositories" and pick')
|
|
105
|
+
io.print(' any one repo (GitHub requires at least one). Orizu adds the workbench repo')
|
|
106
|
+
io.print(' it creates automatically — do NOT choose "All repositories".')
|
|
89
107
|
if (io.openUrl) {
|
|
90
108
|
io.openUrl(installUrl)
|
|
91
|
-
io.print(' Opened the install page in your browser.
|
|
109
|
+
io.print(' Opened the install page in your browser.')
|
|
92
110
|
} else {
|
|
93
|
-
io.print(' Open the URL above
|
|
111
|
+
io.print(' Open the URL above and install the App, then return here.')
|
|
94
112
|
}
|
|
113
|
+
io.print(
|
|
114
|
+
` Waiting for you to finish installing in the browser… (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`).`
|
|
115
|
+
)
|
|
95
116
|
|
|
96
117
|
const now = io.now ?? (() => Date.now())
|
|
97
118
|
const sleep = io.sleep ?? defaultSleep
|
|
98
119
|
const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
99
120
|
const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS
|
|
100
121
|
const deadline = now() + timeout
|
|
122
|
+
// Periodic heartbeat so a long install does not look hung; never echoes state.
|
|
123
|
+
const HEARTBEAT_MS = 15000
|
|
124
|
+
let lastHeartbeat = now()
|
|
101
125
|
|
|
102
126
|
for (;;) {
|
|
103
127
|
const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`)
|
|
104
128
|
if (poll.ok) {
|
|
105
|
-
const data = (await poll.json()) as {
|
|
129
|
+
const data = (await poll.json()) as {
|
|
130
|
+
status: string
|
|
131
|
+
orgLogin?: string | null
|
|
132
|
+
error?: string
|
|
133
|
+
}
|
|
106
134
|
if (data.status === 'active') {
|
|
107
135
|
io.print(` GitHub org connected${data.orgLogin ? ` (${data.orgLogin})` : ''}.`)
|
|
108
136
|
return { status: 'active', orgLogin: data.orgLogin ?? null, alreadyLinked: false }
|
|
@@ -110,6 +138,15 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
|
|
|
110
138
|
if (data.status === 'pending_confirmation') {
|
|
111
139
|
return await confirmLink(teamSlug, data.orgLogin ?? null, io, fetcher)
|
|
112
140
|
}
|
|
141
|
+
// Distinguishable terminal errors (personal account / expired nonce): print
|
|
142
|
+
// the server's reason and exit non-zero instead of polling until timeout.
|
|
143
|
+
if (data.status === 'unsupported_account' || data.status === 'expired') {
|
|
144
|
+
throw new Error(
|
|
145
|
+
data.error ??
|
|
146
|
+
`GitHub link could not be completed. Re-run \`orizu github link --team ${teamSlug}\`.`
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
// status 'pending' → keep waiting.
|
|
113
150
|
}
|
|
114
151
|
if (now() >= deadline) {
|
|
115
152
|
throw new Error(
|
|
@@ -117,6 +154,10 @@ export async function runGithubLink(teamSlug: string, io: GithubLinkIo): Promise
|
|
|
117
154
|
`Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`
|
|
118
155
|
)
|
|
119
156
|
}
|
|
157
|
+
if (now() - lastHeartbeat >= HEARTBEAT_MS) {
|
|
158
|
+
io.print(' …still waiting for the GitHub install to complete.')
|
|
159
|
+
lastHeartbeat = now()
|
|
160
|
+
}
|
|
120
161
|
await sleep(interval)
|
|
121
162
|
}
|
|
122
163
|
}
|
|
@@ -248,9 +289,25 @@ export async function runHostedAttach(
|
|
|
248
289
|
if (!response.ok) {
|
|
249
290
|
throw new Error(`Failed to provision workbench repo: ${await readError(response)}`)
|
|
250
291
|
}
|
|
251
|
-
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
|
+
}
|
|
252
298
|
repoFullName = data.repo
|
|
253
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
|
+
}
|
|
254
311
|
}
|
|
255
312
|
|
|
256
313
|
const cleanUrl = `https://github.com/${repoFullName}.git`
|
|
@@ -358,6 +415,15 @@ export async function runInteractiveHostedSetup(
|
|
|
358
415
|
return { mode, attached: false, repoFullName: null }
|
|
359
416
|
}
|
|
360
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
|
+
|
|
361
427
|
const result = await runHostedAttach(
|
|
362
428
|
{ workspaceId: workspace.id, repoFullName, targetDir: input.targetDir },
|
|
363
429
|
{ print: io.print, fetcher, git: io.git ?? defaultGitRunner, ensureOrizuDir: io.ensureOrizuDir }
|
package/src/index.ts
CHANGED
|
@@ -3820,7 +3820,7 @@ async function setupCommand() {
|
|
|
3820
3820
|
const skipConfirm = hasArg('--yes')
|
|
3821
3821
|
const noInput = hasArg('--no-input') || hasArg('--non-interactive') || !isInteractiveTerminal()
|
|
3822
3822
|
const workspaceArg = getOptionalArgValue('--workspace')
|
|
3823
|
-
const workspaceRoot = workspaceArg ? resolve(expandHomePath(workspaceArg)) : process.cwd()
|
|
3823
|
+
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
3824
|
const teamArg = getOptionalArgValue('--team')
|
|
3825
3825
|
const projectArg = getOptionalArgValue('--project')
|
|
3826
3826
|
const setupSkillHome = resolveSetupSkillHome()
|
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
|
}
|