orizu 0.5.49 → 0.5.50
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/account-directory.d.ts +2 -2
- package/dist/account-directory.d.ts.map +1 -1
- package/dist/account-directory.js +7 -32
- package/dist/auth-commands.d.ts.map +1 -1
- package/dist/auth-commands.js +22 -30
- package/dist/credentials.d.ts +5 -0
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +84 -3
- package/dist/dataset-transfer-upload.d.ts.map +1 -1
- package/dist/dataset-transfer-upload.js +6 -1
- package/dist/directory-list-read.d.ts +37 -0
- package/dist/directory-list-read.d.ts.map +1 -0
- package/dist/directory-list-read.js +87 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +59 -41
- package/dist/hosted-harness-opencode.d.ts +31 -0
- package/dist/hosted-harness-opencode.d.ts.map +1 -1
- package/dist/hosted-harness-opencode.js +45 -7
- package/dist/hosted-loop.d.ts +39 -2
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +127 -18
- package/dist/hosted-session-seed.d.ts +2 -0
- package/dist/hosted-session-seed.d.ts.map +1 -1
- package/dist/hosted-session-seed.js +27 -10
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +39 -234
- package/dist/keyboard-select.d.ts.map +1 -1
- package/dist/keyboard-select.js +23 -45
- package/dist/project-create-workflow.d.ts +18 -0
- package/dist/project-create-workflow.d.ts.map +1 -0
- package/dist/project-create-workflow.js +121 -0
- package/dist/self-update-command.d.ts +8 -0
- package/dist/self-update-command.d.ts.map +1 -0
- package/dist/self-update-command.js +390 -0
- package/dist/self-update.d.ts +29 -16
- package/dist/self-update.d.ts.map +1 -1
- package/dist/self-update.js +55 -244
- package/dist/setup-account.d.ts.map +1 -1
- package/dist/setup-account.js +4 -1
- package/dist/setup-and-skills-commands.d.ts.map +1 -1
- package/dist/setup-and-skills-commands.js +88 -102
- package/dist/skill-install-workflow.d.ts +23 -0
- package/dist/skill-install-workflow.d.ts.map +1 -0
- package/dist/skill-install-workflow.js +184 -0
- package/dist/skill-installer.d.ts +8 -3
- package/dist/skill-installer.d.ts.map +1 -1
- package/dist/skill-installer.js +26 -33
- package/dist/stored-session-requests.d.ts +27 -0
- package/dist/stored-session-requests.d.ts.map +1 -0
- package/dist/stored-session-requests.js +264 -0
- package/dist/terminal-key-session.d.ts +23 -0
- package/dist/terminal-key-session.d.ts.map +1 -0
- package/dist/terminal-key-session.js +74 -0
- package/package.json +1 -1
- package/src/account-directory.ts +8 -42
- package/src/auth-commands.ts +20 -29
- package/src/credentials.ts +83 -3
- package/src/dataset-transfer-upload.ts +7 -1
- package/src/directory-list-read.ts +123 -0
- package/src/hosted-boot.ts +65 -43
- package/src/hosted-harness-opencode.ts +65 -9
- package/src/hosted-loop.ts +152 -18
- package/src/hosted-session-seed.ts +25 -8
- package/src/http.ts +39 -317
- package/src/keyboard-select.ts +18 -55
- package/src/project-create-workflow.ts +139 -0
- package/src/self-update-command.ts +419 -0
- package/src/self-update.ts +70 -266
- package/src/setup-account.ts +4 -1
- package/src/setup-and-skills-commands.ts +89 -121
- package/src/skill-install-workflow.ts +215 -0
- package/src/skill-installer.ts +31 -38
- package/src/stored-session-requests.ts +316 -0
- package/src/terminal-key-session.ts +88 -0
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { createInterface } from 'readline/promises'
|
|
4
4
|
import { stdin as input, stdout as output } from 'process'
|
|
5
|
-
import { emitKeypressEvents } from 'readline'
|
|
6
5
|
import { join, relative, resolve } from 'path'
|
|
7
6
|
import { mkdirSync, writeFileSync } from 'fs'
|
|
8
7
|
import { randomBytes } from 'crypto'
|
|
@@ -25,6 +24,7 @@ import {
|
|
|
25
24
|
printJson,
|
|
26
25
|
printBannerIfInteractive,
|
|
27
26
|
} from './cli-console-output.js'
|
|
27
|
+
import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
|
|
28
28
|
import { sanitizeTerminalText } from './json-response.js'
|
|
29
29
|
import {
|
|
30
30
|
SKILL_INSTALL_AGENTS,
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
getTargetForAgent,
|
|
35
35
|
getSkillInstallPath,
|
|
36
36
|
SkillInstallMode,
|
|
37
|
+
SkillInstallOptions,
|
|
38
|
+
SkillInstallResult,
|
|
37
39
|
isSkillInstallAgent,
|
|
38
40
|
isSkillInstallTarget,
|
|
39
41
|
installSkillTarget,
|
|
@@ -112,50 +114,18 @@ async function askText(question: string, defaultValue: string): Promise<string>
|
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
type KeypressInfo = {
|
|
116
|
-
name?: string
|
|
117
|
-
sequence?: string
|
|
118
|
-
ctrl?: boolean
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
type RawInput = typeof input & {
|
|
122
|
-
setRawMode?: (mode: boolean) => void
|
|
123
|
-
isRaw?: boolean
|
|
124
|
-
}
|
|
125
|
-
|
|
126
117
|
async function waitForAnyKeyOrEscape(message: string): Promise<boolean> {
|
|
127
118
|
if (!isInteractiveTerminal()) {
|
|
128
119
|
throw new Error('Interactive terminal required.')
|
|
129
120
|
}
|
|
130
121
|
|
|
131
122
|
printLine(message)
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
input.off('keypress', onKeypress)
|
|
139
|
-
if (rawInput.setRawMode && !wasRaw) {
|
|
140
|
-
rawInput.setRawMode(false)
|
|
141
|
-
}
|
|
142
|
-
input.pause()
|
|
143
|
-
printLine('')
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function onKeypress(_chunk: string, key: KeypressInfo) {
|
|
147
|
-
cleanup()
|
|
148
|
-
if (key.ctrl && key.name === 'c') {
|
|
149
|
-
rejectPromise(new Error('Setup cancelled.'))
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
resolvePromise(key.name !== 'escape')
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
input.on('keypress', onKeypress)
|
|
156
|
-
input.resume()
|
|
157
|
-
rawInput.setRawMode?.(true)
|
|
158
|
-
})
|
|
123
|
+
const { readKeys } = await import('./terminal-key-session.js')
|
|
124
|
+
try {
|
|
125
|
+
return await readKeys(key => ({ kind: 'done', value: key.name !== 'escape' }), { hideCursor: false })
|
|
126
|
+
} finally {
|
|
127
|
+
printLine('')
|
|
128
|
+
}
|
|
159
129
|
}
|
|
160
130
|
|
|
161
131
|
interface MultiSelectOptions {
|
|
@@ -190,9 +160,6 @@ async function promptKeyboardMultiSelect<T>(
|
|
|
190
160
|
checked.add(index)
|
|
191
161
|
}
|
|
192
162
|
})
|
|
193
|
-
const rawInput = input as RawInput
|
|
194
|
-
const wasRaw = Boolean(rawInput.isRaw)
|
|
195
|
-
emitKeypressEvents(input)
|
|
196
163
|
|
|
197
164
|
function render() {
|
|
198
165
|
if (rendered) {
|
|
@@ -208,60 +175,31 @@ async function promptKeyboardMultiSelect<T>(
|
|
|
208
175
|
}
|
|
209
176
|
|
|
210
177
|
render()
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
function cleanup() {
|
|
215
|
-
input.off('keypress', onKeypress)
|
|
216
|
-
if (rawInput.setRawMode && !wasRaw) {
|
|
217
|
-
rawInput.setRawMode(false)
|
|
218
|
-
}
|
|
219
|
-
input.pause()
|
|
220
|
-
output.write('\x1b[?25h')
|
|
221
|
-
printLine('')
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function onKeypress(_chunk: string, key: KeypressInfo) {
|
|
225
|
-
if (key.ctrl && key.name === 'c') {
|
|
226
|
-
cleanup()
|
|
227
|
-
rejectPromise(new Error('Setup cancelled.'))
|
|
228
|
-
return
|
|
229
|
-
}
|
|
178
|
+
const { readKeys } = await import('./terminal-key-session.js')
|
|
179
|
+
try {
|
|
180
|
+
return await readKeys<T[]>(key => {
|
|
230
181
|
if (key.name === 'escape') {
|
|
231
|
-
|
|
232
|
-
resolvePromise(options.escapeKeepsDefaults ? items.filter((item, index) => defaultSelected(item, index)) : [])
|
|
233
|
-
return
|
|
182
|
+
return { kind: 'done', value: options.escapeKeepsDefaults ? items.filter((item, index) => defaultSelected(item, index)) : [] }
|
|
234
183
|
}
|
|
235
|
-
if (key.name === 'up') {
|
|
236
|
-
selected = (selected - 1 + items.length) % items.length
|
|
184
|
+
if (key.name === 'up' || key.name === 'down') {
|
|
185
|
+
selected = (selected + (key.name === 'up' ? -1 : 1) + items.length) % items.length
|
|
237
186
|
render()
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
if (key.name === 'down') {
|
|
241
|
-
selected = (selected + 1) % items.length
|
|
242
|
-
render()
|
|
243
|
-
return
|
|
187
|
+
return { kind: 'wait' }
|
|
244
188
|
}
|
|
245
189
|
if (key.name === 'space') {
|
|
246
|
-
if (checked.has(selected))
|
|
247
|
-
|
|
248
|
-
} else {
|
|
249
|
-
checked.add(selected)
|
|
250
|
-
}
|
|
190
|
+
if (checked.has(selected)) checked.delete(selected)
|
|
191
|
+
else checked.add(selected)
|
|
251
192
|
render()
|
|
252
|
-
return
|
|
193
|
+
return { kind: 'wait' }
|
|
253
194
|
}
|
|
254
195
|
if (key.name === 'return' || key.name === 'enter') {
|
|
255
|
-
|
|
256
|
-
cleanup()
|
|
257
|
-
resolvePromise(selectedItems)
|
|
196
|
+
return { kind: 'done', value: items.filter((_item, index) => checked.has(index)) }
|
|
258
197
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
})
|
|
198
|
+
return { kind: 'wait' }
|
|
199
|
+
}, { hideCursor: true })
|
|
200
|
+
} finally {
|
|
201
|
+
printLine('')
|
|
202
|
+
}
|
|
265
203
|
}
|
|
266
204
|
|
|
267
205
|
function formatFindingCount(count: number, label: string): string {
|
|
@@ -461,6 +399,10 @@ interface SkillInstallOutcome {
|
|
|
461
399
|
action: 'created' | 'updated' | 'skipped' | 'failed'
|
|
462
400
|
mode: 'link' | 'copy' | 'section' | null
|
|
463
401
|
error?: string
|
|
402
|
+
/** Where a person's own folder was moved before it was replaced. */
|
|
403
|
+
backup?: string
|
|
404
|
+
/** Why a person's folder, or part of it, is still beside the skill. */
|
|
405
|
+
warning?: string
|
|
464
406
|
}
|
|
465
407
|
|
|
466
408
|
async function applySkillInstallTargets(
|
|
@@ -477,7 +419,7 @@ async function applySkillInstallTargets(
|
|
|
477
419
|
): Promise<SkillInstallOutcome[]> {
|
|
478
420
|
const cliVersion = getCliVersion()
|
|
479
421
|
const jsonMode = hasJsonFlag()
|
|
480
|
-
const planned = targets.map(target =>
|
|
422
|
+
const planned = await Promise.all(targets.map(target =>
|
|
481
423
|
installSkillTarget(target, {
|
|
482
424
|
cwd: options.cwd,
|
|
483
425
|
homeDir: options.homeDir,
|
|
@@ -485,7 +427,7 @@ async function applySkillInstallTargets(
|
|
|
485
427
|
mode: options.mode,
|
|
486
428
|
cliVersion,
|
|
487
429
|
})
|
|
488
|
-
)
|
|
430
|
+
))
|
|
489
431
|
const showPlan = options.showPlan ?? true
|
|
490
432
|
const printResults = options.printResults ?? true
|
|
491
433
|
|
|
@@ -528,7 +470,7 @@ async function applySkillInstallTargets(
|
|
|
528
470
|
}
|
|
529
471
|
|
|
530
472
|
try {
|
|
531
|
-
const result = installSkillTarget(target, {
|
|
473
|
+
const result = await installSkillTarget(target, {
|
|
532
474
|
cwd: options.cwd,
|
|
533
475
|
homeDir: options.homeDir,
|
|
534
476
|
overwrite,
|
|
@@ -537,13 +479,16 @@ async function applySkillInstallTargets(
|
|
|
537
479
|
})
|
|
538
480
|
if (printResults && !jsonMode) {
|
|
539
481
|
const suffix = result.mode === 'link' ? ' (symlink)' : ''
|
|
540
|
-
printLine(`${formatSkillInstallAction(result.action)} ${result.path}${suffix}`)
|
|
482
|
+
printLine(`${formatSkillInstallAction(result.action)} ${result.path}${suffix}${describeBackup(result.backup)}`)
|
|
483
|
+
if (result.warning) printLine(sanitizeSetupHumanInlineText(`Warning: ${result.warning}`))
|
|
541
484
|
}
|
|
542
485
|
outcomes.push({
|
|
543
486
|
target,
|
|
544
487
|
path: result.path,
|
|
545
488
|
action: result.action as 'created' | 'updated',
|
|
546
489
|
mode: result.mode,
|
|
490
|
+
...(result.backup ? { backup: result.backup } : {}),
|
|
491
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
547
492
|
})
|
|
548
493
|
} catch (error: unknown) {
|
|
549
494
|
const message = getErrorMessage(error)
|
|
@@ -557,6 +502,10 @@ async function applySkillInstallTargets(
|
|
|
557
502
|
return outcomes
|
|
558
503
|
}
|
|
559
504
|
|
|
505
|
+
function describeBackup(backup: string | undefined): string {
|
|
506
|
+
return backup ? `; moved your earlier folder to ${backup}` : ''
|
|
507
|
+
}
|
|
508
|
+
|
|
560
509
|
async function installSkillCommand() {
|
|
561
510
|
const skipConfirm = hasArg('--yes')
|
|
562
511
|
const dryRun = hasArg('--dry-run')
|
|
@@ -578,15 +527,15 @@ async function installSkillCommand() {
|
|
|
578
527
|
}
|
|
579
528
|
|
|
580
529
|
const outcomes = await applySkillInstallTargets(targets, { mode, skipConfirm, dryRun })
|
|
530
|
+
if (outcomes.some(outcome => outcome.action === 'failed')) {
|
|
531
|
+
process.exitCode = 1
|
|
532
|
+
}
|
|
581
533
|
const failedCopy = outcomes.some(outcome =>
|
|
582
534
|
outcome.action === 'failed' && (PROJECT_SKILL_COPY_TARGETS as readonly string[]).includes(outcome.target))
|
|
583
|
-
if (failedCopy) {
|
|
584
|
-
|
|
585
|
-
if (!hasJsonFlag()) {
|
|
586
|
-
printLine('The two project copies were not both written. Fix the error above and run the command again.')
|
|
587
|
-
}
|
|
535
|
+
if (failedCopy && !hasJsonFlag()) {
|
|
536
|
+
printLine('The two project copies were not both written. Fix the error above and run the command again.')
|
|
588
537
|
}
|
|
589
|
-
const legacy = replaceLegacySkillInstalls({
|
|
538
|
+
const legacy = await replaceLegacySkillInstalls({
|
|
590
539
|
...legacyScopeAfterInstall(targets, outcomes, dryRun),
|
|
591
540
|
mode,
|
|
592
541
|
dryRun,
|
|
@@ -753,8 +702,18 @@ function skillsStatusCommand() {
|
|
|
753
702
|
async function skillsUpdateCommand() {
|
|
754
703
|
const dryRun = hasArg('--dry-run')
|
|
755
704
|
const cliVersion = getCliVersion()
|
|
756
|
-
const updates: Array<{ target: SkillInstallTarget, path: string, action: string }> = []
|
|
757
|
-
|
|
705
|
+
const updates: Array<{ target: SkillInstallTarget, path: string, action: string, error?: string, backup?: string, warning?: string }> = []
|
|
706
|
+
// A failed target is reported with its reason; the old install there stays whole.
|
|
707
|
+
const refresh = async (target: SkillInstallTarget, path: string, options: SkillInstallOptions, action: (result: SkillInstallResult) => string) => {
|
|
708
|
+
try {
|
|
709
|
+
const result = await installSkillTarget(target, options)
|
|
710
|
+
updates.push({ target, path, action: action(result), ...(result.backup ? { backup: result.backup } : {}), ...(result.warning ? { warning: result.warning } : {}) })
|
|
711
|
+
} catch (error: unknown) {
|
|
712
|
+
process.exitCode = 1
|
|
713
|
+
updates.push({ target, path, action: 'failed', error: getErrorMessage(error) })
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const legacy = await replaceLegacySkillInstalls({ dryRun, cliVersion })
|
|
758
717
|
// Two project copies that differ are both rewritten from the installed CLI
|
|
759
718
|
// skill, including a hand-made copy the loop below would leave alone.
|
|
760
719
|
const projectCopies = compareProjectSkillCopies()
|
|
@@ -766,8 +725,7 @@ async function skillsUpdateCommand() {
|
|
|
766
725
|
updates.push({ target, path, action: 'would-rewrite' })
|
|
767
726
|
continue
|
|
768
727
|
}
|
|
769
|
-
|
|
770
|
-
updates.push({ target, path, action: 'rewrote' })
|
|
728
|
+
await refresh(target, path, { overwrite: true, mode: 'copy', cliVersion }, () => 'rewrote')
|
|
771
729
|
}
|
|
772
730
|
}
|
|
773
731
|
|
|
@@ -791,16 +749,8 @@ async function skillsUpdateCommand() {
|
|
|
791
749
|
}
|
|
792
750
|
|
|
793
751
|
const refreshMode: SkillInstallMode = status.mode === 'link' ? 'auto' : 'copy'
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
mode: refreshMode,
|
|
797
|
-
cliVersion,
|
|
798
|
-
})
|
|
799
|
-
updates.push({
|
|
800
|
-
target,
|
|
801
|
-
path: status.path,
|
|
802
|
-
action: status.state === 'broken-link' ? 'relinked' : result.action,
|
|
803
|
-
})
|
|
752
|
+
await refresh(target, status.path, { overwrite: true, mode: refreshMode, cliVersion },
|
|
753
|
+
result => status.state === 'broken-link' ? 'relinked' : result.action)
|
|
804
754
|
}
|
|
805
755
|
|
|
806
756
|
if (hasJsonFlag()) {
|
|
@@ -816,23 +766,38 @@ async function skillsUpdateCommand() {
|
|
|
816
766
|
}
|
|
817
767
|
|
|
818
768
|
if (rewriteProjectCopies) {
|
|
819
|
-
const
|
|
820
|
-
|
|
769
|
+
const rewrote = updates.filter(update => update.action === 'rewrote')
|
|
770
|
+
if (dryRun) {
|
|
771
|
+
printLine(`Would rewrite both project copies from the installed CLI skill: ${projectCopies.paths.join(' and ')}`)
|
|
772
|
+
} else if (rewrote.length === PROJECT_SKILL_COPY_TARGETS.length) {
|
|
773
|
+
printLine(`Rewrote both project copies from the installed CLI skill: ${projectCopies.paths.join(' and ')}`)
|
|
774
|
+
}
|
|
821
775
|
}
|
|
822
776
|
|
|
823
777
|
for (const update of updates) {
|
|
824
|
-
if (update.action === '
|
|
778
|
+
if (update.action === 'failed') {
|
|
779
|
+
printLine(sanitizeSetupHumanInlineText(`Failed ${update.path}: ${update.error}`))
|
|
825
780
|
continue
|
|
826
781
|
}
|
|
827
|
-
if (update.action === '
|
|
782
|
+
if (update.action === 'would-rewrite') {
|
|
783
|
+
continue
|
|
784
|
+
}
|
|
785
|
+
if (update.action === 'rewrote') {
|
|
786
|
+
// With both rewritten, the line above names them; otherwise each says so here.
|
|
787
|
+
const bothRewritten = updates.filter(other => other.action === 'rewrote').length === PROJECT_SKILL_COPY_TARGETS.length
|
|
788
|
+
if (!bothRewritten || update.backup) {
|
|
789
|
+
printLine(sanitizeSetupHumanInlineText(`Rewrote ${update.path} from the installed CLI skill${describeBackup(update.backup)}`))
|
|
790
|
+
}
|
|
791
|
+
} else if (update.action === 'already-current') {
|
|
828
792
|
printLine(`Current ${update.path}`)
|
|
829
793
|
} else if (update.action === 'would-update') {
|
|
830
794
|
printLine(`Would update ${update.path}`)
|
|
831
795
|
} else if (update.action === 'relinked') {
|
|
832
796
|
printLine(`Relinked ${update.path}`)
|
|
833
797
|
} else {
|
|
834
|
-
printLine(`Updated ${update.path}`)
|
|
798
|
+
printLine(sanitizeSetupHumanInlineText(`Updated ${update.path}${describeBackup(update.backup)}`))
|
|
835
799
|
}
|
|
800
|
+
if (update.warning) printLine(sanitizeSetupHumanInlineText(`Warning: ${update.warning}`))
|
|
836
801
|
}
|
|
837
802
|
}
|
|
838
803
|
|
|
@@ -878,7 +843,7 @@ async function resolveSetupTeam(
|
|
|
878
843
|
if (!name) throw new Error('--create-team requires a non-empty team name.')
|
|
879
844
|
return await createSetupTeam(name, request)
|
|
880
845
|
}
|
|
881
|
-
const teams = await fetchTeams(request)
|
|
846
|
+
const teams = await fetchTeams(request, datasetTransferTimeoutMs())
|
|
882
847
|
const normalizedTeamSlug = teamSlugArg ? normalizeSlugInput(teamSlugArg) : null
|
|
883
848
|
if (normalizedTeamSlug) {
|
|
884
849
|
const team = teams.find(candidate => candidate.slug === normalizedTeamSlug)
|
|
@@ -945,7 +910,7 @@ async function resolveSetupProjects(
|
|
|
945
910
|
if (createProjectName && !canCreateProject) {
|
|
946
911
|
throw new Error(`Only team admins can create projects in '${team.slug}'. Choose an existing project or ask a team admin to create one.`)
|
|
947
912
|
}
|
|
948
|
-
const projects = await fetchProjects(team.slug, request)
|
|
913
|
+
const projects = await fetchProjects(team.slug, request, datasetTransferTimeoutMs())
|
|
949
914
|
if (createProjectName) {
|
|
950
915
|
if (dryRun) {
|
|
951
916
|
throw new Error('--create-project cannot be used with --dry-run because setup cannot preview a server-generated project slug.')
|
|
@@ -1154,7 +1119,7 @@ async function setupCommand() {
|
|
|
1154
1119
|
throw new Error(`This directory is already an Orizu workspace for team '${workspaceTeamSlug}'. Run setup in another directory to set up team '${setupTeam.slug}'.`)
|
|
1155
1120
|
}
|
|
1156
1121
|
if (fix) {
|
|
1157
|
-
setupProjects = await fetchProjects(setupTeam.slug, setupRequest)
|
|
1122
|
+
setupProjects = await fetchProjects(setupTeam.slug, setupRequest, datasetTransferTimeoutMs())
|
|
1158
1123
|
if (setupProjects.length === 0) throw new Error(`Team '${setupTeam.slug}' has no projects to restore.`)
|
|
1159
1124
|
} else {
|
|
1160
1125
|
const selection = await resolveSetupProjects(setupTeam, projectArg, createProjectArg, noInput, dryRun, setupRequest)
|
|
@@ -1277,7 +1242,7 @@ async function setupCommand() {
|
|
|
1277
1242
|
if (dryRun) {
|
|
1278
1243
|
printLine('Dry run: no skill files were changed.')
|
|
1279
1244
|
}
|
|
1280
|
-
const legacy = replaceLegacySkillInstalls({
|
|
1245
|
+
const legacy = await replaceLegacySkillInstalls({
|
|
1281
1246
|
...legacyScopeAfterInstall(targets, installOutcomes, dryRun),
|
|
1282
1247
|
mode: installMode,
|
|
1283
1248
|
dryRun,
|
|
@@ -1293,7 +1258,8 @@ async function setupCommand() {
|
|
|
1293
1258
|
} else if (outcome.action === 'skipped') {
|
|
1294
1259
|
printLine(`${label} skipped`)
|
|
1295
1260
|
} else {
|
|
1296
|
-
printLine(`${label} installed`)
|
|
1261
|
+
printLine(sanitizeSetupHumanInlineText(`${label} installed${describeBackup(outcome.backup)}`))
|
|
1262
|
+
if (outcome.warning) printLine(sanitizeSetupHumanInlineText(`Warning: ${outcome.warning}`))
|
|
1297
1263
|
}
|
|
1298
1264
|
}
|
|
1299
1265
|
}
|
|
@@ -1388,6 +1354,8 @@ async function setupCommand() {
|
|
|
1388
1354
|
action: outcome.action,
|
|
1389
1355
|
mode: outcome.mode,
|
|
1390
1356
|
error: outcome.error,
|
|
1357
|
+
...(outcome.backup ? { backup: outcome.backup } : {}),
|
|
1358
|
+
...(outcome.warning ? { warning: outcome.warning } : {}),
|
|
1391
1359
|
})),
|
|
1392
1360
|
skill: {
|
|
1393
1361
|
root: source.root,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Reads the skill source and the install target it is given.
|
|
2
|
+
// @tia-reads: skills/orizu
|
|
3
|
+
// Replaces one skill folder without ever leaving a half-written one, and never
|
|
4
|
+
// deletes a person's files: they are moved to a backup first. Loaded only
|
|
5
|
+
// through `await import(...)`, so `orizu skills path` never loads Effect.
|
|
6
|
+
import { cp, lstat, mkdir, readdir, readlink, readFile, rename, rm, symlink, unlink, copyFile } from 'node:fs/promises'
|
|
7
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { Context, Effect, Exit, Option, Schema } from 'effect'
|
|
10
|
+
|
|
11
|
+
import { computeSkillContentHash, SKILL_META_FILENAME, shouldCopySkillPath } from './skill-installer.js'
|
|
12
|
+
|
|
13
|
+
export interface ReplaceSkillFolderInput {
|
|
14
|
+
sourceRoot: string
|
|
15
|
+
targetPath: string
|
|
16
|
+
mode: 'link' | 'copy'
|
|
17
|
+
/** Where a person's own folder is moved; no agent looks for skills there. */
|
|
18
|
+
backupPath: string
|
|
19
|
+
/** Writes Orizu's marker into the new copy before it is put in place. */
|
|
20
|
+
writeMarker: (dir: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReplaceSkillFolderResult {
|
|
24
|
+
/** Where a person's own folder was moved, when there was one. */
|
|
25
|
+
backup: string | null
|
|
26
|
+
/** Why a person's folder, or part of it, is still beside the skill. */
|
|
27
|
+
warning?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every rename the replacement makes. Tests swap it to make one rename fail. */
|
|
31
|
+
const SkillFolderRename = Context.Reference<(from: string, to: string) => Promise<void>>('orizu/SkillFolderRename', {
|
|
32
|
+
defaultValue: () => rename,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const renameEntry = (from: string, to: string) => Effect.gen(function*() {
|
|
36
|
+
const move = yield* SkillFolderRename
|
|
37
|
+
yield* attempt(() => move(from, to))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const SkillMarker = Schema.Struct({ skillHash: Schema.String })
|
|
41
|
+
|
|
42
|
+
type Existing = 'missing' | 'link' | 'orizu' | 'person'
|
|
43
|
+
|
|
44
|
+
const attempt = <A>(run: () => Promise<A>) =>
|
|
45
|
+
// One file step at a time: an interruption lands between steps, never during one.
|
|
46
|
+
Effect.uninterruptible(Effect.tryPromise({ try: run, catch: cause => cause instanceof Error ? cause : new Error(String(cause)) }))
|
|
47
|
+
|
|
48
|
+
const exists = (path: string) => attempt(() => lstat(path).then(() => true, () => false))
|
|
49
|
+
|
|
50
|
+
// Orizu's own install is a link, or a copy whose files still match its marker
|
|
51
|
+
// or the skill being installed. Anything else may hold a person's work.
|
|
52
|
+
function classify(targetPath: string, sourceRoot: string): Effect.Effect<Existing, Error> {
|
|
53
|
+
return Effect.gen(function*() {
|
|
54
|
+
const stats = yield* attempt(() => lstat(targetPath).then(found => found, () => null))
|
|
55
|
+
if (!stats) return 'missing'
|
|
56
|
+
if (stats.isSymbolicLink()) return 'link'
|
|
57
|
+
if (!stats.isDirectory()) return 'person'
|
|
58
|
+
const marker = yield* attempt(() => readFile(join(targetPath, SKILL_META_FILENAME), 'utf8').then(text => JSON.parse(text) as unknown, () => null))
|
|
59
|
+
const installedHash = computeSkillContentHash(targetPath)
|
|
60
|
+
const recorded = Schema.decodeUnknownOption(SkillMarker)(marker)
|
|
61
|
+
if (Option.isSome(recorded) && recorded.value.skillHash === installedHash) return 'orizu'
|
|
62
|
+
return installedHash === computeSkillContentHash(sourceRoot) ? 'orizu' : 'person'
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function copyTree(root: string, from: string, to: string): Effect.Effect<void, Error> {
|
|
67
|
+
return Effect.gen(function*() {
|
|
68
|
+
yield* attempt(() => mkdir(to, { recursive: true }))
|
|
69
|
+
const entries = yield* attempt(() => readdir(from, { withFileTypes: true }))
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const source = join(from, entry.name)
|
|
72
|
+
const target = join(to, entry.name)
|
|
73
|
+
if (!shouldCopySkillPath(root, source)) continue
|
|
74
|
+
if (entry.isDirectory()) yield* copyTree(root, source, target)
|
|
75
|
+
else if (entry.isFile()) yield* attempt(() => copyFile(source, target))
|
|
76
|
+
else if (entry.isSymbolicLink()) yield* attempt(async () => symlink(await readlink(source), target))
|
|
77
|
+
else yield* Effect.fail(new Error(`${source} is not a file or folder, so the skill can't be copied.`))
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Links are removed as links, never by following them.
|
|
83
|
+
const removeEntry = (path: string) => attempt(async () => {
|
|
84
|
+
const stats = await lstat(path).catch(() => null)
|
|
85
|
+
if (!stats) return
|
|
86
|
+
if (stats.isSymbolicLink()) await unlink(path)
|
|
87
|
+
else await rm(path, { recursive: true, force: true })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Moves a person's folder to the backup. Once the copy is whole, the backup
|
|
93
|
+
* is reported even when the original cannot be removed; if nothing could be
|
|
94
|
+
* moved, the folder stays where it is and the warning says why.
|
|
95
|
+
*/
|
|
96
|
+
function moveToBackup(from: string, to: string): Effect.Effect<ReplaceSkillFolderResult> {
|
|
97
|
+
const stayed = (cause: Error): ReplaceSkillFolderResult => ({
|
|
98
|
+
backup: resolve(from),
|
|
99
|
+
warning: `Could not move your earlier folder to ${to}: ${cause.message}. It is still at ${from}, where agents may load it as a second skill; move it somewhere else.`,
|
|
100
|
+
})
|
|
101
|
+
return Effect.gen(function*() {
|
|
102
|
+
yield* attempt(() => mkdir(dirname(to), { recursive: true }))
|
|
103
|
+
// A project on another disk cannot be renamed into the home folder, so it is copied.
|
|
104
|
+
const renamed = yield* renameEntry(from, to).pipe(Effect.as(true), Effect.catch(error =>
|
|
105
|
+
(error as { code?: string }).code === 'EXDEV' ? Effect.succeed(false) : Effect.fail(error)))
|
|
106
|
+
if (renamed) return { backup: resolve(to) }
|
|
107
|
+
// Our own half-made copy is removed; the person's folder is untouched.
|
|
108
|
+
yield* attempt(() => cp(from, to, { recursive: true, verbatimSymlinks: true })).pipe(
|
|
109
|
+
Effect.tapError(() => removeEntry(to).pipe(Effect.ignore)))
|
|
110
|
+
return yield* attempt(() => rm(from, { recursive: true })).pipe(
|
|
111
|
+
Effect.as<ReplaceSkillFolderResult>({ backup: resolve(to) }),
|
|
112
|
+
Effect.catch(cause => Effect.succeed<ReplaceSkillFolderResult>({
|
|
113
|
+
backup: resolve(to),
|
|
114
|
+
warning: `Copied your earlier folder to ${to}, but could not remove it from ${from}: ${cause.message}. Agents may load it there as a second skill; remove it once you have checked the copy.`,
|
|
115
|
+
})),
|
|
116
|
+
)
|
|
117
|
+
}).pipe(Effect.catch(cause => Effect.succeed(stayed(cause))))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stage the new skill beside the target, move the old folder aside, rename the
|
|
122
|
+
* staged one into place, then drop Orizu's old copy or back up a person's.
|
|
123
|
+
* On a failure or an interruption, staging is removed and the old folder is
|
|
124
|
+
* put back.
|
|
125
|
+
*/
|
|
126
|
+
function replaceSkillFolder(input: ReplaceSkillFolderInput): Effect.Effect<ReplaceSkillFolderResult, Error> {
|
|
127
|
+
const { sourceRoot, targetPath, mode } = input
|
|
128
|
+
const id = `${process.pid}-${Date.now().toString(36)}`
|
|
129
|
+
const name = basename(targetPath)
|
|
130
|
+
const staging = join(dirname(targetPath), `${name}.orizu-staging-${id}`)
|
|
131
|
+
const aside = join(dirname(targetPath), `${name}.orizu-previous-${id}`)
|
|
132
|
+
|
|
133
|
+
// Set when the old folder could not be put back after a failure.
|
|
134
|
+
let strandedAt: string | null = null
|
|
135
|
+
|
|
136
|
+
// Only the copy into staging can be interrupted. Once the old folder moves
|
|
137
|
+
// aside, the swap and the clean-up after it always finish, so Ctrl-C can
|
|
138
|
+
// never skip putting the old folder back or moving a person's folder out.
|
|
139
|
+
return Effect.uninterruptibleMask(restore => Effect.gen(function*() {
|
|
140
|
+
const existing = yield* classify(targetPath, sourceRoot)
|
|
141
|
+
yield* attempt(() => mkdir(dirname(targetPath), { recursive: true }))
|
|
142
|
+
|
|
143
|
+
const swapped = yield* Effect.scoped(Effect.gen(function*() {
|
|
144
|
+
// Registered before staging starts, so a copy that stops part way is removed too.
|
|
145
|
+
// Gone already once it has been renamed into place.
|
|
146
|
+
yield* Effect.addFinalizer(() => removeEntry(staging).pipe(Effect.ignore))
|
|
147
|
+
yield* restore(mode === 'link'
|
|
148
|
+
? attempt(() => symlink(sourceRoot, staging, 'dir'))
|
|
149
|
+
: copyTree(sourceRoot, sourceRoot, staging).pipe(
|
|
150
|
+
Effect.andThen(Effect.try({ try: () => input.writeMarker(staging), catch: cause => cause instanceof Error ? cause : new Error(String(cause)) }))))
|
|
151
|
+
if (existing !== 'missing') {
|
|
152
|
+
yield* Effect.acquireRelease(
|
|
153
|
+
renameEntry(targetPath, aside),
|
|
154
|
+
(_, exit) => Exit.isSuccess(exit)
|
|
155
|
+
? Effect.void
|
|
156
|
+
// Put the old folder back unless something already holds its place.
|
|
157
|
+
: exists(targetPath).pipe(
|
|
158
|
+
Effect.flatMap(taken => taken ? Effect.fail(new Error(`${targetPath} is taken`)) : renameEntry(aside, targetPath)),
|
|
159
|
+
Effect.catch(() => Effect.sync(() => { strandedAt = aside })),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
yield* renameEntry(staging, targetPath)
|
|
164
|
+
return existing
|
|
165
|
+
})).pipe(Effect.mapError(error => strandedAt
|
|
166
|
+
? new Error(`${error.message} Your earlier folder could not be put back at ${targetPath}; it is at ${strandedAt}.`)
|
|
167
|
+
: error))
|
|
168
|
+
|
|
169
|
+
if (swapped === 'missing') return { backup: null }
|
|
170
|
+
if (swapped !== 'person') {
|
|
171
|
+
yield* removeEntry(aside).pipe(Effect.mapError(cause => new Error(`Installed ${targetPath}, but could not remove the earlier copy at ${aside}: ${cause.message}`)))
|
|
172
|
+
return { backup: null }
|
|
173
|
+
}
|
|
174
|
+
// The person's folder is safe beside the target; move it out of agents' sight.
|
|
175
|
+
return yield* moveToBackup(aside, input.backupPath)
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One replacement whose renames go through `move`, so a test can make one fail. */
|
|
180
|
+
export async function replaceSkillFolderUsing(move: (from: string, to: string) => Promise<void>, input: ReplaceSkillFolderInput): Promise<ReplaceSkillFolderResult> {
|
|
181
|
+
return await Effect.runPromise(replaceSkillFolder(input).pipe(Effect.provideService(SkillFolderRename, move)))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const STOP_SIGNALS = ['SIGINT', 'SIGTERM'] as const
|
|
185
|
+
|
|
186
|
+
/** Ends the process by the signal that stopped it, once its handlers are gone. */
|
|
187
|
+
async function endBy(signal: NodeJS.Signals, handlers: ReadonlyArray<readonly [NodeJS.Signals, () => void]>): Promise<never> {
|
|
188
|
+
for (const [name, handler] of handlers) process.removeListener(name, handler)
|
|
189
|
+
process.kill(process.pid, signal)
|
|
190
|
+
// The signal ends the process; if another listener holds it, exit as a shell would.
|
|
191
|
+
setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1_000)
|
|
192
|
+
return await new Promise<never>(() => {})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Runs one replacement. Ctrl-C or SIGTERM interrupts it; once staging is
|
|
197
|
+
* removed and the old folder is back, the process ends by that same signal.
|
|
198
|
+
*/
|
|
199
|
+
export async function runSkillFolderReplacement(input: ReplaceSkillFolderInput): Promise<ReplaceSkillFolderResult> {
|
|
200
|
+
const controller = new AbortController()
|
|
201
|
+
const stop: { by: NodeJS.Signals | null } = { by: null }
|
|
202
|
+
const handlers = STOP_SIGNALS.map(signal => [signal, () => { stop.by = signal; controller.abort() }] as const)
|
|
203
|
+
for (const [signal, handler] of handlers) process.once(signal, handler)
|
|
204
|
+
try {
|
|
205
|
+
const result = await Effect.runPromise(replaceSkillFolder(input), { signal: controller.signal })
|
|
206
|
+
// A stop that arrived as the replacement finished still stops the command.
|
|
207
|
+
if (stop.by) return await endBy(stop.by, handlers)
|
|
208
|
+
return result
|
|
209
|
+
} catch (error: unknown) {
|
|
210
|
+
if (!stop.by) throw error
|
|
211
|
+
return await endBy(stop.by, handlers)
|
|
212
|
+
} finally {
|
|
213
|
+
for (const [signal, handler] of handlers) process.removeListener(signal, handler)
|
|
214
|
+
}
|
|
215
|
+
}
|