orizu 0.5.37 → 0.5.39
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 +18 -3
- package/dist/app-commands.d.ts.map +1 -1
- package/dist/app-commands.js +215 -57
- package/dist/app-publish-failure.d.ts +6 -0
- package/dist/app-publish-failure.d.ts.map +1 -0
- package/dist/app-publish-failure.js +9 -0
- package/dist/app-publish-paths.d.ts +4 -0
- package/dist/app-publish-paths.d.ts.map +1 -0
- package/dist/app-publish-paths.js +32 -0
- package/dist/app-publish-workspace.d.ts +16 -0
- package/dist/app-publish-workspace.d.ts.map +1 -0
- package/dist/app-publish-workspace.js +175 -0
- package/dist/artifacts-git-runtime.d.ts +5 -0
- package/dist/artifacts-git-runtime.d.ts.map +1 -1
- package/dist/artifacts-git-runtime.js +70 -25
- package/dist/artifacts-session-merge.d.ts +62 -0
- package/dist/artifacts-session-merge.d.ts.map +1 -0
- package/dist/artifacts-session-merge.js +123 -0
- package/dist/dataset-append-workflow.d.ts +12 -0
- package/dist/dataset-append-workflow.d.ts.map +1 -0
- package/dist/dataset-append-workflow.js +198 -0
- package/dist/dataset-commands.d.ts.map +1 -1
- package/dist/dataset-commands.js +11 -130
- package/dist/dataset-transfer-download.js +6 -6
- package/dist/dataset-transfer-upload.js +8 -8
- package/dist/git-credential.d.ts +1 -1
- package/dist/git-credential.d.ts.map +1 -1
- package/dist/git-credential.js +8 -1
- package/dist/help.js +3 -3
- package/dist/hosted-boot.d.ts +11 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +46 -1
- package/dist/hosted-harvest.d.ts.map +1 -1
- package/dist/hosted-harvest.js +11 -1
- package/dist/hosted-session-cli.d.ts.map +1 -1
- package/dist/hosted-session-cli.js +6 -1
- package/dist/hosted-session-seed.d.ts +45 -0
- package/dist/hosted-session-seed.d.ts.map +1 -0
- package/dist/hosted-session-seed.js +61 -0
- package/dist/hosted-skill-staging.d.ts +8 -3
- package/dist/hosted-skill-staging.d.ts.map +1 -1
- package/dist/hosted-skill-staging.js +10 -3
- package/dist/jsonl-stream.d.ts +1 -0
- package/dist/jsonl-stream.d.ts.map +1 -1
- package/dist/jsonl-stream.js +7 -0
- package/dist/provider-list-workflow.d.ts +3 -0
- package/dist/provider-list-workflow.d.ts.map +1 -0
- package/dist/provider-list-workflow.js +95 -0
- package/dist/provider-wire-record.d.ts +3 -0
- package/dist/provider-wire-record.d.ts.map +1 -0
- package/dist/provider-wire-record.js +4 -0
- package/dist/providers-cli.d.ts.map +1 -1
- package/dist/providers-cli.js +5 -15
- package/dist/publish-merge-job.d.ts +68 -0
- package/dist/publish-merge-job.d.ts.map +1 -0
- package/dist/publish-merge-job.js +320 -0
- package/dist/publish-merge-timing.d.ts +18 -0
- package/dist/publish-merge-timing.d.ts.map +1 -0
- package/dist/publish-merge-timing.js +23 -0
- package/dist/workbench-cli.d.ts +4 -0
- package/dist/workbench-cli.d.ts.map +1 -1
- package/dist/workbench-cli.js +11 -1
- package/dist/workspace-bootstrap-job.d.ts +1 -0
- package/dist/workspace-bootstrap-job.d.ts.map +1 -1
- package/dist/workspace-bootstrap-job.js +1 -1
- package/package.json +5 -2
- package/src/app-commands.ts +234 -66
- package/src/app-publish-failure.ts +7 -0
- package/src/app-publish-paths.ts +27 -0
- package/src/app-publish-workspace.ts +207 -0
- package/src/artifacts-git-runtime.ts +73 -31
- package/src/artifacts-session-merge.ts +253 -0
- package/src/dataset-append-workflow.ts +205 -0
- package/src/dataset-commands.ts +10 -170
- package/src/dataset-transfer-download.ts +5 -5
- package/src/dataset-transfer-upload.ts +7 -7
- package/src/git-credential.ts +8 -1
- package/src/help.ts +3 -3
- package/src/hosted-boot.ts +56 -1
- package/src/hosted-harvest.ts +11 -1
- package/src/hosted-session-cli.ts +6 -1
- package/src/hosted-session-seed.ts +78 -0
- package/src/hosted-skill-staging.ts +11 -3
- package/src/jsonl-stream.ts +8 -0
- package/src/provider-list-workflow.ts +108 -0
- package/src/provider-wire-record.ts +4 -0
- package/src/providers-cli.ts +5 -13
- package/src/publish-merge-job.ts +350 -0
- package/src/publish-merge-timing.ts +27 -0
- package/src/workbench-cli.ts +15 -1
- package/src/workspace-bootstrap-job.ts +1 -1
- package/vendor/skills/orizu/flows/onboard.md +1 -1
- package/vendor/skills/orizu/references/authority-map.md +2 -2
- package/vendor/skills/orizu/references/building-apps.md +24 -0
- package/vendor/skills/orizu/references/cli-reference.md +13 -4
- package/vendor/skills/orizu/references/optimization-reports.md +1 -1
- package/vendor/skills/orizu/references/primer.md +1 -0
- package/vendor/skills/orizu/references/prompt-control-plane.md +9 -5
package/src/app-commands.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
join,
|
|
14
14
|
dirname,
|
|
15
15
|
} from 'path'
|
|
16
|
-
import { createHash } from 'crypto'
|
|
16
|
+
import { createHash, randomUUID } from 'crypto'
|
|
17
17
|
import {
|
|
18
18
|
getArg,
|
|
19
19
|
getArchiveListStatus,
|
|
@@ -25,10 +25,15 @@ import { resolveProjectSlug, fetchApps, selectAppIdInteractively } from './accou
|
|
|
25
25
|
import { printJson, printLine, getErrorMessage } from './cli-console-output.js'
|
|
26
26
|
import { printAppSummaries } from './archive-artifacts-cli.js'
|
|
27
27
|
import { isNodeError, formatTerminalLink } from './auth-commands.js'
|
|
28
|
+
import { parseCliProjectSlug } from './cli-project-slug.js'
|
|
29
|
+
import { AppPublishFailure } from './app-publish-failure.js'
|
|
30
|
+
import { inspectAppDestination } from './app-publish-paths.js'
|
|
28
31
|
import { authedFetch, getBaseUrl } from './http.js'
|
|
29
32
|
import { appPageUrl } from './app-page-url.js'
|
|
30
33
|
import { parseJsonResponse, sanitizeHumanInlineText, sanitizeTerminalText } from './json-response.js'
|
|
31
34
|
import { runLocalAppPreview } from './preview-runtime.js'
|
|
35
|
+
import type { AppFiles } from './app-publish-workspace.js'
|
|
36
|
+
import { findWorkspaceRoot } from './workspace.js'
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
|
|
@@ -73,7 +78,10 @@ function readSourceBytes(pathArg: string): Buffer {
|
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
function readJsonFile(pathArg: string): Record<string, unknown> {
|
|
76
|
-
|
|
81
|
+
return parseJsonFileContents(readSourceFile(pathArg), pathArg)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseJsonFileContents(raw: string, pathArg: string): Record<string, unknown> {
|
|
77
85
|
try {
|
|
78
86
|
const parsed = JSON.parse(raw) as unknown
|
|
79
87
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
@@ -163,54 +171,175 @@ function readManifestFile(dirArg: string): Record<string, unknown> {
|
|
|
163
171
|
return readJsonFile(join(expandHomePath(dirArg), 'manifest.json'))
|
|
164
172
|
}
|
|
165
173
|
|
|
174
|
+
const LOCAL_PUBLISH_NOTE =
|
|
175
|
+
'Note: this app version is not in the team repository yet; it reaches git when published from a hosted session.'
|
|
176
|
+
const APP_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
177
|
+
|
|
178
|
+
interface PublishedCommit {
|
|
179
|
+
outcome: 'merged' | 'already_merged'
|
|
180
|
+
sha: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The workspace root when this runs in a hosted session, else null. */
|
|
184
|
+
function hostedWorkspaceRoot(): string | null {
|
|
185
|
+
if (!process.env.ORIZU_SESSION_ID?.trim()) return null
|
|
186
|
+
const workspaceRoot = findWorkspaceRoot(process.cwd())
|
|
187
|
+
if (!workspaceRoot) throw new Error('Hosted app publishing must run inside the attached workspace. Run this command from the workspace containing orizu.team.json.')
|
|
188
|
+
return workspaceRoot
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The hosted publish path, loaded only when it runs: it needs Effect, which
|
|
192
|
+
* the rest of the CLI does not load at startup. */
|
|
193
|
+
function loadAppPublishWorkspace() {
|
|
194
|
+
return import('./app-publish-workspace.js')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The app as committed into its folder: the files exactly as the agent wrote them. */
|
|
198
|
+
function appFilesFromArgs(filePath: string, inputSchemaPath: string, outputSchemaPath: string): AppFiles {
|
|
199
|
+
return { sourceCode: readSourceFile(filePath), inputSchema: readSourceFile(inputSchemaPath), outputSchema: readSourceFile(outputSchemaPath) }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** ORI-2280: a hosted publish the merge stopped. The server's text says what to
|
|
203
|
+
* do; a create's rerun must name the same app id, so its folder is reused. A
|
|
204
|
+
* conflict also adds the team copy remote the text tells the agent to pull. */
|
|
205
|
+
async function publishRefusalError(responseText: string, workspaceRoot: string): Promise<Error | null> {
|
|
206
|
+
let body: unknown
|
|
207
|
+
try {
|
|
208
|
+
body = JSON.parse(responseText)
|
|
209
|
+
} catch {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
if (!isRecord(body) || typeof body.error !== 'string' || typeof body.code !== 'string' || !body.code.startsWith('publish_')) return null
|
|
213
|
+
let message = sanitizeTerminalText(body.error)
|
|
214
|
+
if (body.code === 'publish_conflict') {
|
|
215
|
+
const { addTeamCopyRemote } = await loadAppPublishWorkspace()
|
|
216
|
+
const remoteNote = await addTeamCopyRemote(workspaceRoot)
|
|
217
|
+
if (remoteNote) message = `${message} ${remoteNote}`
|
|
218
|
+
}
|
|
219
|
+
return new Error(message)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function printPublishedCommit(commit: PublishedCommit | null | undefined, isHosted: boolean) {
|
|
223
|
+
if (commit) {
|
|
224
|
+
const where = commit.outcome === 'merged' ? 'Merged into' : 'Already in'
|
|
225
|
+
printLine(`${where} the team copy: ${sanitizeTerminalText(commit.sha.slice(0, 7))}`)
|
|
226
|
+
} else if (!isHosted) {
|
|
227
|
+
printLine(LOCAL_PUBLISH_NOTE)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Quote each original argument so spaces and quotes survive a copied retry. */
|
|
232
|
+
function shellSingleQuote(value: string): string {
|
|
233
|
+
return `'${value.replace(/'/g, "'\\''")}'`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function canonicalRetryInputPath(workspaceRoot: string, folder: string, filename: string, originalPath: string): string {
|
|
237
|
+
const canonicalPath = join(workspaceRoot, folder, filename)
|
|
238
|
+
try {
|
|
239
|
+
return inspectAppDestination(workspaceRoot, join(folder, filename)) ? canonicalPath : originalPath
|
|
240
|
+
} catch {
|
|
241
|
+
return originalPath
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
166
245
|
async function createAppFromFile() {
|
|
167
|
-
const
|
|
246
|
+
const workspaceRoot = hostedWorkspaceRoot()
|
|
247
|
+
let project = getArg('--project')
|
|
168
248
|
const name = getArg('--name')
|
|
169
249
|
const datasetId = getArg('--dataset')
|
|
170
250
|
const filePath = getArg('--file')
|
|
171
251
|
const inputSchemaPath = getArg('--input-schema')
|
|
172
252
|
const outputSchemaPath = getArg('--output-schema')
|
|
173
253
|
const component = getArg('--component') || undefined
|
|
254
|
+
const requestedAppId = getArg('--id') ?? undefined
|
|
174
255
|
|
|
175
256
|
if (!project || !name || !datasetId || !filePath || !inputSchemaPath || !outputSchemaPath) {
|
|
176
|
-
throw new Error('Usage: orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]')
|
|
257
|
+
throw new Error('Usage: orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>] [--id <app-id>]')
|
|
177
258
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const inputJsonSchema = readJsonFile(inputSchemaPath)
|
|
181
|
-
const outputJsonSchema = readJsonFile(outputSchemaPath)
|
|
182
|
-
const response = await authedFetch('/api/cli/apps/create-from-file', {
|
|
183
|
-
method: 'POST',
|
|
184
|
-
headers: { 'Content-Type': 'application/json' },
|
|
185
|
-
body: JSON.stringify({
|
|
186
|
-
projectSlug: project,
|
|
187
|
-
name,
|
|
188
|
-
datasetId,
|
|
189
|
-
sourceCode,
|
|
190
|
-
componentName: component,
|
|
191
|
-
inputJsonSchema,
|
|
192
|
-
outputJsonSchema,
|
|
193
|
-
}),
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
if (!response.ok) {
|
|
197
|
-
throw new Error(`Failed to create app: ${await response.text()}`)
|
|
259
|
+
if (requestedAppId !== undefined && !APP_ID_PATTERN.test(requestedAppId)) {
|
|
260
|
+
throw new Error('--id must be a lowercase app id (UUID), as printed by the earlier publish')
|
|
198
261
|
}
|
|
199
262
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
printLine(`Created app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) v${data.app.versionNum}`)
|
|
209
|
-
if (data.app.url) {
|
|
210
|
-
printLine(`View app: ${formatTerminalLink(data.app.url)}`)
|
|
263
|
+
if (workspaceRoot) {
|
|
264
|
+
const parsedProject = parseCliProjectSlug(project)
|
|
265
|
+
const slug = /^[a-z0-9][a-z0-9-]{0,99}$/
|
|
266
|
+
if (!parsedProject || !slug.test(parsedProject.teamSlug) || !slug.test(parsedProject.projectSlug)) {
|
|
267
|
+
throw new Error('--project must be a valid <team/project> reference')
|
|
268
|
+
}
|
|
269
|
+
project = `${parsedProject.teamSlug}/${parsedProject.projectSlug}`
|
|
211
270
|
}
|
|
212
|
-
|
|
213
|
-
|
|
271
|
+
|
|
272
|
+
const files = appFilesFromArgs(filePath, inputSchemaPath, outputSchemaPath)
|
|
273
|
+
const sourceCode = files.sourceCode
|
|
274
|
+
const inputJsonSchema = parseJsonFileContents(files.inputSchema, inputSchemaPath)
|
|
275
|
+
const outputJsonSchema = parseJsonFileContents(files.outputSchema, outputSchemaPath)
|
|
276
|
+
|
|
277
|
+
// In a hosted session the app is committed into its folder and pushed first;
|
|
278
|
+
// the server merges that commit into the team copy before recording the app.
|
|
279
|
+
let appId = requestedAppId
|
|
280
|
+
let sessionCommitSha: string | undefined
|
|
281
|
+
let recoveryFolder: string | undefined
|
|
282
|
+
let filesMaterialized = false
|
|
283
|
+
try {
|
|
284
|
+
if (workspaceRoot) {
|
|
285
|
+
const projectSlug = parseCliProjectSlug(project)!.projectSlug
|
|
286
|
+
appId ??= randomUUID()
|
|
287
|
+
recoveryFolder = `projects/${projectSlug}/apps/${appId}`
|
|
288
|
+
const { pushAppToSessionCopy } = await loadAppPublishWorkspace()
|
|
289
|
+
sessionCommitSha = await pushAppToSessionCopy(workspaceRoot, {
|
|
290
|
+
projectSlug,
|
|
291
|
+
appId,
|
|
292
|
+
files,
|
|
293
|
+
})
|
|
294
|
+
filesMaterialized = true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const response = await authedFetch('/api/cli/apps/create-from-file', {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'Content-Type': 'application/json' },
|
|
300
|
+
body: JSON.stringify({
|
|
301
|
+
projectSlug: project,
|
|
302
|
+
name,
|
|
303
|
+
datasetId,
|
|
304
|
+
sourceCode,
|
|
305
|
+
componentName: component,
|
|
306
|
+
inputJsonSchema,
|
|
307
|
+
outputJsonSchema,
|
|
308
|
+
...(appId !== undefined ? { appId } : {}),
|
|
309
|
+
...(sessionCommitSha !== undefined ? { sessionCommitSha } : {}),
|
|
310
|
+
}),
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const text = await response.text()
|
|
315
|
+
const refusal = workspaceRoot ? await publishRefusalError(text, workspaceRoot) : null
|
|
316
|
+
throw refusal ?? new Error(`Failed to create app: ${text}`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const data = await parseJsonResponse<{
|
|
320
|
+
app: { id: string; name: string; versionNum: number; componentName?: string; url?: string; publishedCommit?: PublishedCommit | null }
|
|
321
|
+
warnings?: string[]
|
|
322
|
+
}>(response, 'App create')
|
|
323
|
+
if (hasJsonFlag()) {
|
|
324
|
+
printJson({ app: { ...data.app, pageUrl: data.app.url || appPageUrl(getBaseUrl(), data.app.id) }, warnings: data.warnings || [] })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
printLine(`Created app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) v${data.app.versionNum}`)
|
|
328
|
+
if (data.app.url) {
|
|
329
|
+
printLine(`View app: ${formatTerminalLink(data.app.url)}`)
|
|
330
|
+
}
|
|
331
|
+
printPublishedCommit(data.app.publishedCommit, workspaceRoot !== null)
|
|
332
|
+
if (data.warnings?.length) {
|
|
333
|
+
printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`)
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!workspaceRoot || !appId || !recoveryFolder) throw error
|
|
337
|
+
const useCanonicalInputs = filesMaterialized || (error instanceof AppPublishFailure && error.filesMaterialized)
|
|
338
|
+
const retryArgs = ['orizu', '--server', getBaseUrl(), 'apps', 'create', '--project', project, '--name', name, '--dataset', datasetId,
|
|
339
|
+
'--file', useCanonicalInputs ? canonicalRetryInputPath(workspaceRoot, recoveryFolder, 'App.tsx', filePath) : filePath, '--input-schema', useCanonicalInputs ? canonicalRetryInputPath(workspaceRoot, recoveryFolder, 'input.schema.json', inputSchemaPath) : inputSchemaPath,
|
|
340
|
+
'--output-schema', useCanonicalInputs ? canonicalRetryInputPath(workspaceRoot, recoveryFolder, 'output.schema.json', outputSchemaPath) : outputSchemaPath,
|
|
341
|
+
...(component ? ['--component', component] : []), ...(hasJsonFlag() ? ['--json'] : []), '--id', appId]
|
|
342
|
+
throw new Error(`${getErrorMessage(error)}\nApp ID: ${appId}\nFolder: ${recoveryFolder}\nRetry: ${retryArgs.map(shellSingleQuote).join(' ')}`)
|
|
214
343
|
}
|
|
215
344
|
}
|
|
216
345
|
|
|
@@ -266,6 +395,7 @@ async function previewAppFromFile() {
|
|
|
266
395
|
}
|
|
267
396
|
|
|
268
397
|
async function updateAppFromFile() {
|
|
398
|
+
const workspaceRoot = hostedWorkspaceRoot()
|
|
269
399
|
const filePath = getArg('--file')
|
|
270
400
|
const inputSchemaPath = getArg('--input-schema')
|
|
271
401
|
const outputSchemaPath = getArg('--output-schema')
|
|
@@ -282,37 +412,75 @@ async function updateAppFromFile() {
|
|
|
282
412
|
appId = selected.appId
|
|
283
413
|
}
|
|
284
414
|
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
method: 'POST',
|
|
290
|
-
headers: { 'Content-Type': 'application/json' },
|
|
291
|
-
body: JSON.stringify({
|
|
292
|
-
sourceCode,
|
|
293
|
-
componentName: component,
|
|
294
|
-
inputJsonSchema,
|
|
295
|
-
outputJsonSchema,
|
|
296
|
-
}),
|
|
297
|
-
})
|
|
415
|
+
const files = appFilesFromArgs(filePath, inputSchemaPath, outputSchemaPath)
|
|
416
|
+
const sourceCode = files.sourceCode
|
|
417
|
+
const inputJsonSchema = parseJsonFileContents(files.inputSchema, inputSchemaPath)
|
|
418
|
+
const outputJsonSchema = parseJsonFileContents(files.outputSchema, outputSchemaPath)
|
|
298
419
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
420
|
+
// In a hosted session the app is committed into its folder and pushed first;
|
|
421
|
+
// the server merges that commit into the team copy before recording the version.
|
|
422
|
+
let recoveryFolder: string | undefined
|
|
423
|
+
let filesMaterialized = false
|
|
424
|
+
try {
|
|
425
|
+
let sessionCommitSha: string | undefined
|
|
426
|
+
if (workspaceRoot) {
|
|
427
|
+
const folderResponse = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}`)
|
|
428
|
+
if (!folderResponse.ok) {
|
|
429
|
+
throw new Error(`Failed to find the app's project: ${await folderResponse.text()}`)
|
|
430
|
+
}
|
|
431
|
+
const folder = await parseJsonResponse<{ appId: string; projectSlug: string }>(folderResponse, 'App folder')
|
|
432
|
+
recoveryFolder = `projects/${folder.projectSlug}/apps/${folder.appId}`
|
|
433
|
+
const { pushAppToSessionCopy } = await loadAppPublishWorkspace()
|
|
434
|
+
sessionCommitSha = await pushAppToSessionCopy(workspaceRoot, {
|
|
435
|
+
projectSlug: folder.projectSlug,
|
|
436
|
+
appId: folder.appId,
|
|
437
|
+
files,
|
|
438
|
+
})
|
|
439
|
+
filesMaterialized = true
|
|
440
|
+
}
|
|
302
441
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
442
|
+
const response = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}/update-from-file`, {
|
|
443
|
+
method: 'POST',
|
|
444
|
+
headers: { 'Content-Type': 'application/json' },
|
|
445
|
+
body: JSON.stringify({
|
|
446
|
+
sourceCode,
|
|
447
|
+
componentName: component,
|
|
448
|
+
inputJsonSchema,
|
|
449
|
+
outputJsonSchema,
|
|
450
|
+
...(sessionCommitSha !== undefined ? { sessionCommitSha } : {}),
|
|
451
|
+
}),
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
if (!response.ok) {
|
|
455
|
+
const text = await response.text()
|
|
456
|
+
const refusal = workspaceRoot ? await publishRefusalError(text, workspaceRoot) : null
|
|
457
|
+
throw refusal ?? new Error(`Failed to update app: ${text}`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const data = await parseJsonResponse<{
|
|
461
|
+
app: { id: string; name: string; versionNum: number; componentName?: string; publishedCommit?: PublishedCommit | null }
|
|
462
|
+
warnings?: string[]
|
|
463
|
+
}>(response, 'App update')
|
|
464
|
+
const pageUrl = appPageUrl(getBaseUrl(), data.app.id)
|
|
465
|
+
if (hasJsonFlag()) {
|
|
466
|
+
printJson({ app: { ...data.app, pageUrl }, warnings: data.warnings || [] })
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
printLine(`Updated app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) to v${data.app.versionNum}`)
|
|
470
|
+
printLine(`View app: ${formatTerminalLink(pageUrl)}`)
|
|
471
|
+
printPublishedCommit(data.app.publishedCommit, workspaceRoot !== null)
|
|
472
|
+
if (data.warnings?.length) {
|
|
473
|
+
printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`)
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (!workspaceRoot || !recoveryFolder) throw error
|
|
477
|
+
const useCanonicalInputs = filesMaterialized || (error instanceof AppPublishFailure && error.filesMaterialized)
|
|
478
|
+
const retryArgs = ['orizu', '--server', getBaseUrl(), 'apps', 'update', '--app', appId,
|
|
479
|
+
'--file', useCanonicalInputs ? canonicalRetryInputPath(workspaceRoot, recoveryFolder, 'App.tsx', filePath) : filePath,
|
|
480
|
+
'--input-schema', useCanonicalInputs ? canonicalRetryInputPath(workspaceRoot, recoveryFolder, 'input.schema.json', inputSchemaPath) : inputSchemaPath,
|
|
481
|
+
'--output-schema', useCanonicalInputs ? canonicalRetryInputPath(workspaceRoot, recoveryFolder, 'output.schema.json', outputSchemaPath) : outputSchemaPath,
|
|
482
|
+
...(component ? ['--component', component] : []), ...(hasJsonFlag() ? ['--json'] : [])]
|
|
483
|
+
throw new Error(`${getErrorMessage(error)}\nApp ID: ${appId}\nFolder: ${recoveryFolder}\nRetry: ${retryArgs.map(shellSingleQuote).join(' ')}`)
|
|
316
484
|
}
|
|
317
485
|
}
|
|
318
486
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { lstatSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Inspect canonical destinations without following aliases. All callers check
|
|
5
|
+
* the whole set before materializing it; this does not claim race-proof access. */
|
|
6
|
+
export function inspectAppDestination(root: string, path: string): boolean {
|
|
7
|
+
const base = resolve(root)
|
|
8
|
+
const destination = resolve(base, path)
|
|
9
|
+
const tail = relative(base, destination)
|
|
10
|
+
if (!tail || tail === '..' || tail.startsWith(`..${sep}`) || isAbsolute(tail)) throw new Error('App destination is outside the workspace')
|
|
11
|
+
const rootStat = lstatSync(base)
|
|
12
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error('App workspace must be an ordinary directory')
|
|
13
|
+
const segments = tail.split(sep)
|
|
14
|
+
let current = base
|
|
15
|
+
for (const [index, segment] of segments.entries()) {
|
|
16
|
+
current = resolve(current, segment)
|
|
17
|
+
const stat = lstatSync(current, { throwIfNoEntry: false })
|
|
18
|
+
if (!stat) return false
|
|
19
|
+
if (stat.isSymbolicLink()) throw new Error('App destination must not contain symbolic links')
|
|
20
|
+
if (index < segments.length - 1) {
|
|
21
|
+
if (!stat.isDirectory()) throw new Error('App destination parent must be an ordinary directory')
|
|
22
|
+
} else if (!stat.isFile() || stat.nlink > 1) {
|
|
23
|
+
throw new Error('App destination must be an ordinary file without hardlink aliases')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** ORI-2280 (2280-B2): in a hosted session, an app is published from git. The
|
|
2
|
+
* CLI writes the app into its folder in the workspace, commits those three files
|
|
3
|
+
* alone, and pushes the session copy; the web app then merges that commit into
|
|
4
|
+
* the team copy before it records the version. Written with Effect: git, the
|
|
5
|
+
* files and the web app are services, and each way this can stop is a tagged
|
|
6
|
+
* failure. It holds no key (the clone's credential helper asks for one per git
|
|
7
|
+
* command) and makes no temporary folder, so it has nothing to scope. */
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { Context, Data, Effect, Layer } from 'effect'
|
|
13
|
+
|
|
14
|
+
import { isValidCloudflareArtifactsGitRemote } from './cloudflare-artifacts-git-remote.js'
|
|
15
|
+
import { AppPublishFailure } from './app-publish-failure.js'
|
|
16
|
+
import { inspectAppDestination } from './app-publish-paths.js'
|
|
17
|
+
import { resolveAttachedWorkspaceId } from './git-credential.js'
|
|
18
|
+
import { authedFetch, getBaseUrl } from './http.js'
|
|
19
|
+
|
|
20
|
+
/** The session copy's branch, which the publish merge reads (ORI-2279). */
|
|
21
|
+
const SESSION_BRANCH_REF = 'refs/heads/main'
|
|
22
|
+
const TEAM_REMOTE_NAME = 'orizu-team'
|
|
23
|
+
const PROJECT_SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/
|
|
24
|
+
const APP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
25
|
+
|
|
26
|
+
/** Where an app lives in the team repository. */
|
|
27
|
+
function appFolder(projectSlug: string, appId: string): string {
|
|
28
|
+
if (!PROJECT_SLUG.test(projectSlug)) throw new Error(`Invalid project slug for the app folder: ${projectSlug}`)
|
|
29
|
+
if (!APP_ID.test(appId)) throw new Error(`Invalid app id for the app folder: ${appId}`)
|
|
30
|
+
return `projects/${projectSlug}/apps/${appId}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AppFiles {
|
|
34
|
+
readonly sourceCode: string
|
|
35
|
+
readonly inputSchema: string
|
|
36
|
+
readonly outputSchema: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// -- Failures --------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
class PushRejected extends Data.TaggedError('PushRejected') {}
|
|
42
|
+
class GitFailed extends Data.TaggedError('GitFailed')<{ step: string; detail: string }> {}
|
|
43
|
+
class TeamRemoteUnavailable extends Data.TaggedError('TeamRemoteUnavailable')<{ reason: string }> {}
|
|
44
|
+
|
|
45
|
+
// -- Services --------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
interface GitResult {
|
|
48
|
+
readonly code: number
|
|
49
|
+
readonly stdout: string
|
|
50
|
+
readonly stderr: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class WorkspaceGit extends Context.Service<WorkspaceGit, { readonly run: (args: readonly string[]) => Effect.Effect<GitResult> }>()('WorkspaceGit') {}
|
|
54
|
+
class WorkspaceFiles extends Context.Service<WorkspaceFiles, { readonly write: (files: readonly { path: string; content: string }[]) => Effect.Effect<void, GitFailed> }>()('WorkspaceFiles') {}
|
|
55
|
+
class PublishApi extends Context.Service<PublishApi, { readonly teamReadRemote: (workspaceId: string) => Effect.Effect<string, TeamRemoteUnavailable>; readonly teamRemoteHelper: string }>()('PublishApi') {}
|
|
56
|
+
|
|
57
|
+
function liveLayer(root: string, attempt?: { filesMaterialized: boolean }) {
|
|
58
|
+
const serverOrigin = getBaseUrl()
|
|
59
|
+
const teamRemoteHelper = `!orizu --server '${serverOrigin.replace(/'/g, "'\\''")}' git-credential --purpose=team_read`
|
|
60
|
+
const git = WorkspaceGit.of({
|
|
61
|
+
run: (args) =>
|
|
62
|
+
Effect.callback<GitResult>((resume) => {
|
|
63
|
+
const child = spawn('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
64
|
+
let stdout = ''
|
|
65
|
+
let stderr = ''
|
|
66
|
+
child.stdout.on('data', (chunk) => (stdout += chunk))
|
|
67
|
+
child.stderr.on('data', (chunk) => (stderr += chunk))
|
|
68
|
+
child.on('error', (error) => resume(Effect.succeed({ code: 127, stdout, stderr: error.message })))
|
|
69
|
+
child.on('close', (code) => resume(Effect.succeed({ code: code ?? 1, stdout, stderr })))
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
const files = WorkspaceFiles.of({
|
|
73
|
+
write: (files) =>
|
|
74
|
+
Effect.try({
|
|
75
|
+
try: () => {
|
|
76
|
+
for (const file of files) inspectAppDestination(root, file.path)
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
mkdirSync(join(root, file.path, '..'), { recursive: true })
|
|
79
|
+
writeFileSync(join(root, file.path), file.content)
|
|
80
|
+
}
|
|
81
|
+
if (attempt) attempt.filesMaterialized = true
|
|
82
|
+
},
|
|
83
|
+
catch: (error) => new GitFailed({ step: 'write the app files', detail: String(error) }),
|
|
84
|
+
}),
|
|
85
|
+
})
|
|
86
|
+
const api = PublishApi.of({
|
|
87
|
+
teamRemoteHelper,
|
|
88
|
+
teamReadRemote: (workspaceId) =>
|
|
89
|
+
Effect.tryPromise({
|
|
90
|
+
try: async (signal) => {
|
|
91
|
+
const response = await authedFetch(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ purpose: 'team_read', sessionId: process.env.ORIZU_SESSION_ID?.trim() }),
|
|
95
|
+
signal,
|
|
96
|
+
})
|
|
97
|
+
if (!response.ok) throw new Error(`the repository key service answered ${response.status}`)
|
|
98
|
+
const remote = ((await response.json()) as { remote?: unknown }).remote
|
|
99
|
+
if (!isValidCloudflareArtifactsGitRemote(remote)) throw new Error('the repository key service named no team copy')
|
|
100
|
+
return remote
|
|
101
|
+
},
|
|
102
|
+
catch: (error) => new TeamRemoteUnavailable({ reason: error instanceof Error ? error.message : String(error) }),
|
|
103
|
+
}),
|
|
104
|
+
})
|
|
105
|
+
return Layer.mergeAll(Layer.succeed(WorkspaceGit, git), Layer.succeed(WorkspaceFiles, files), Layer.succeed(PublishApi, api))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const gitOrFail = (step: string, args: readonly string[]) =>
|
|
109
|
+
Effect.flatMap(WorkspaceGit, (git) => git.run(args)).pipe(
|
|
110
|
+
Effect.filterOrFail(
|
|
111
|
+
(result) => result.code === 0,
|
|
112
|
+
(result) => new GitFailed({ step, detail: (result.stderr || result.stdout).trim() })
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// -- Commit and push -------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** Writes the app into its folder, commits those three files alone (other work in
|
|
119
|
+
* the workspace stays as it is), and pushes the session copy. Answers the
|
|
120
|
+
* commit the web app should publish: the new one, or HEAD if nothing changed. */
|
|
121
|
+
const commitAndPush = (folder: string, files: AppFiles, appId: string) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
const workspaceFiles = yield* WorkspaceFiles
|
|
124
|
+
yield* workspaceFiles.write([
|
|
125
|
+
{ path: `${folder}/App.tsx`, content: files.sourceCode },
|
|
126
|
+
{ path: `${folder}/input.schema.json`, content: files.inputSchema },
|
|
127
|
+
{ path: `${folder}/output.schema.json`, content: files.outputSchema },
|
|
128
|
+
])
|
|
129
|
+
const paths = ['App.tsx', 'input.schema.json', 'output.schema.json'].map(filename => `${folder}/${filename}`)
|
|
130
|
+
yield* gitOrFail('stage the app files', ['add', '--', ...paths])
|
|
131
|
+
const git = yield* WorkspaceGit
|
|
132
|
+
// Exit 1: the three app files differ from HEAD.
|
|
133
|
+
const changed = yield* git.run(['diff', '--cached', '--quiet', 'HEAD', '--', ...paths])
|
|
134
|
+
if (changed.code === 1) {
|
|
135
|
+
yield* gitOrFail('commit the app files', ['commit', '-q', '-m', `Publish app ${appId}`, '--', ...paths])
|
|
136
|
+
} else if (changed.code !== 0) {
|
|
137
|
+
return yield* new GitFailed({ step: 'compare the app folder', detail: changed.stderr.trim() })
|
|
138
|
+
}
|
|
139
|
+
const sha = (yield* gitOrFail('read the commit', ['rev-parse', 'HEAD'])).stdout.trim()
|
|
140
|
+
const pushed = yield* git.run(['push', '-q', 'origin', `HEAD:${SESSION_BRANCH_REF}`])
|
|
141
|
+
if (pushed.code !== 0) {
|
|
142
|
+
if (/\[rejected\]|non-fast-forward|fetch first/.test(pushed.stderr)) return yield* new PushRejected()
|
|
143
|
+
return yield* new GitFailed({ step: 'push the session copy', detail: pushed.stderr.trim() })
|
|
144
|
+
}
|
|
145
|
+
return sha
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/** Commits and pushes the app, answering the commit to publish. Throws the
|
|
149
|
+
* message the agent should act on. */
|
|
150
|
+
export async function pushAppToSessionCopy(root: string, input: { projectSlug: string; appId: string; files: AppFiles }): Promise<string> {
|
|
151
|
+
const program = commitAndPush(appFolder(input.projectSlug, input.appId), input.files, input.appId).pipe(
|
|
152
|
+
Effect.catchTags({
|
|
153
|
+
PushRejected: () =>
|
|
154
|
+
Effect.fail(new Error('Publish stopped: your session copy has commits this workspace does not. Run "git pull --no-rebase --no-edit" in the workspace, then run this command again.')),
|
|
155
|
+
GitFailed: (failure) => Effect.fail(new Error(`Publish stopped before anything was sent: could not ${failure.step} (${failure.detail}).`)),
|
|
156
|
+
})
|
|
157
|
+
)
|
|
158
|
+
const attempt = { filesMaterialized: false }
|
|
159
|
+
try {
|
|
160
|
+
return await Effect.runPromise(Effect.provide(program, liveLayer(root, attempt)))
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new AppPublishFailure(error instanceof Error ? error.message : String(error), attempt.filesMaterialized)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -- The team copy, after a conflict ---------------------------------------------
|
|
167
|
+
|
|
168
|
+
/** Adds the `orizu-team` remote so the agent can pull the team copy and fix a
|
|
169
|
+
* conflict. It reads with its own team_read key: an empty helper entry first
|
|
170
|
+
* stops the session copy's helper from answering for it. Refreshes the URL and
|
|
171
|
+
* helper each time so interrupted recovery or an old remote is repaired. */
|
|
172
|
+
const addTeamRemote = (workspaceId: string | null) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const git = yield* WorkspaceGit
|
|
175
|
+
const hasRemote = (yield* git.run(['remote', 'get-url', TEAM_REMOTE_NAME])).code === 0
|
|
176
|
+
if (!workspaceId) return yield* new TeamRemoteUnavailable({ reason: 'this workspace names no Orizu workspace id' })
|
|
177
|
+
const api = yield* PublishApi
|
|
178
|
+
const remote = yield* api.teamReadRemote(workspaceId)
|
|
179
|
+
const failed = (step: string) => (failure: GitFailed) => new TeamRemoteUnavailable({ reason: `${step}: ${failure.detail}` })
|
|
180
|
+
const remoteAction = hasRemote ? 'set-url' : 'add'
|
|
181
|
+
yield* gitOrFail(remoteAction, ['remote', remoteAction, TEAM_REMOTE_NAME, remote]).pipe(Effect.mapError(failed(`git remote ${remoteAction}`)))
|
|
182
|
+
const cleared = yield* git.run(['config', '--unset-all', `credential.${remote}.helper`])
|
|
183
|
+
// Git exits 5 when there was no matching key, which is normal on first use.
|
|
184
|
+
if (cleared.code !== 0 && cleared.code !== 5) {
|
|
185
|
+
return yield* new TeamRemoteUnavailable({ reason: `git config: ${(cleared.stderr || cleared.stdout).trim()}` })
|
|
186
|
+
}
|
|
187
|
+
for (const [operation, key, value] of [
|
|
188
|
+
['--add', `credential.${remote}.helper`, ''],
|
|
189
|
+
['--add', `credential.${remote}.helper`, api.teamRemoteHelper],
|
|
190
|
+
['--replace-all', `credential.${remote}.useHttpPath`, 'true'],
|
|
191
|
+
] as const) {
|
|
192
|
+
yield* gitOrFail('config', ['config', operation, key, value]).pipe(Effect.mapError(failed('git config')))
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
/** Adds the team copy remote; answers a sentence to add to the conflict
|
|
197
|
+
* message when it could not, else null. */
|
|
198
|
+
export function addTeamCopyRemote(root: string): Promise<string | null> {
|
|
199
|
+
const workspaceId = process.env.ORIZU_WORKSPACE_ID?.trim() || resolveAttachedWorkspaceId(root)
|
|
200
|
+
const program = addTeamRemote(workspaceId).pipe(
|
|
201
|
+
Effect.as(null),
|
|
202
|
+
Effect.catchTag('TeamRemoteUnavailable', (failure) =>
|
|
203
|
+
Effect.succeed(`The ${TEAM_REMOTE_NAME} remote could not be added (${failure.reason}); add it with "git remote add ${TEAM_REMOTE_NAME} <team copy>".`)
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return Effect.runPromise(Effect.provide(program, liveLayer(root)))
|
|
207
|
+
}
|