orizu 0.5.1 → 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.
@@ -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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 { repo: string }
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/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
  }