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.
Files changed (98) hide show
  1. package/README.md +18 -3
  2. package/dist/app-commands.d.ts.map +1 -1
  3. package/dist/app-commands.js +215 -57
  4. package/dist/app-publish-failure.d.ts +6 -0
  5. package/dist/app-publish-failure.d.ts.map +1 -0
  6. package/dist/app-publish-failure.js +9 -0
  7. package/dist/app-publish-paths.d.ts +4 -0
  8. package/dist/app-publish-paths.d.ts.map +1 -0
  9. package/dist/app-publish-paths.js +32 -0
  10. package/dist/app-publish-workspace.d.ts +16 -0
  11. package/dist/app-publish-workspace.d.ts.map +1 -0
  12. package/dist/app-publish-workspace.js +175 -0
  13. package/dist/artifacts-git-runtime.d.ts +5 -0
  14. package/dist/artifacts-git-runtime.d.ts.map +1 -1
  15. package/dist/artifacts-git-runtime.js +70 -25
  16. package/dist/artifacts-session-merge.d.ts +62 -0
  17. package/dist/artifacts-session-merge.d.ts.map +1 -0
  18. package/dist/artifacts-session-merge.js +123 -0
  19. package/dist/dataset-append-workflow.d.ts +12 -0
  20. package/dist/dataset-append-workflow.d.ts.map +1 -0
  21. package/dist/dataset-append-workflow.js +198 -0
  22. package/dist/dataset-commands.d.ts.map +1 -1
  23. package/dist/dataset-commands.js +11 -130
  24. package/dist/dataset-transfer-download.js +6 -6
  25. package/dist/dataset-transfer-upload.js +8 -8
  26. package/dist/git-credential.d.ts +1 -1
  27. package/dist/git-credential.d.ts.map +1 -1
  28. package/dist/git-credential.js +8 -1
  29. package/dist/help.js +3 -3
  30. package/dist/hosted-boot.d.ts +11 -0
  31. package/dist/hosted-boot.d.ts.map +1 -1
  32. package/dist/hosted-boot.js +46 -1
  33. package/dist/hosted-harvest.d.ts.map +1 -1
  34. package/dist/hosted-harvest.js +11 -1
  35. package/dist/hosted-session-cli.d.ts.map +1 -1
  36. package/dist/hosted-session-cli.js +6 -1
  37. package/dist/hosted-session-seed.d.ts +45 -0
  38. package/dist/hosted-session-seed.d.ts.map +1 -0
  39. package/dist/hosted-session-seed.js +61 -0
  40. package/dist/hosted-skill-staging.d.ts +8 -3
  41. package/dist/hosted-skill-staging.d.ts.map +1 -1
  42. package/dist/hosted-skill-staging.js +10 -3
  43. package/dist/jsonl-stream.d.ts +1 -0
  44. package/dist/jsonl-stream.d.ts.map +1 -1
  45. package/dist/jsonl-stream.js +7 -0
  46. package/dist/provider-list-workflow.d.ts +3 -0
  47. package/dist/provider-list-workflow.d.ts.map +1 -0
  48. package/dist/provider-list-workflow.js +95 -0
  49. package/dist/provider-wire-record.d.ts +3 -0
  50. package/dist/provider-wire-record.d.ts.map +1 -0
  51. package/dist/provider-wire-record.js +4 -0
  52. package/dist/providers-cli.d.ts.map +1 -1
  53. package/dist/providers-cli.js +5 -15
  54. package/dist/publish-merge-job.d.ts +68 -0
  55. package/dist/publish-merge-job.d.ts.map +1 -0
  56. package/dist/publish-merge-job.js +320 -0
  57. package/dist/publish-merge-timing.d.ts +18 -0
  58. package/dist/publish-merge-timing.d.ts.map +1 -0
  59. package/dist/publish-merge-timing.js +23 -0
  60. package/dist/workbench-cli.d.ts +4 -0
  61. package/dist/workbench-cli.d.ts.map +1 -1
  62. package/dist/workbench-cli.js +11 -1
  63. package/dist/workspace-bootstrap-job.d.ts +1 -0
  64. package/dist/workspace-bootstrap-job.d.ts.map +1 -1
  65. package/dist/workspace-bootstrap-job.js +1 -1
  66. package/package.json +5 -2
  67. package/src/app-commands.ts +234 -66
  68. package/src/app-publish-failure.ts +7 -0
  69. package/src/app-publish-paths.ts +27 -0
  70. package/src/app-publish-workspace.ts +207 -0
  71. package/src/artifacts-git-runtime.ts +73 -31
  72. package/src/artifacts-session-merge.ts +253 -0
  73. package/src/dataset-append-workflow.ts +205 -0
  74. package/src/dataset-commands.ts +10 -170
  75. package/src/dataset-transfer-download.ts +5 -5
  76. package/src/dataset-transfer-upload.ts +7 -7
  77. package/src/git-credential.ts +8 -1
  78. package/src/help.ts +3 -3
  79. package/src/hosted-boot.ts +56 -1
  80. package/src/hosted-harvest.ts +11 -1
  81. package/src/hosted-session-cli.ts +6 -1
  82. package/src/hosted-session-seed.ts +78 -0
  83. package/src/hosted-skill-staging.ts +11 -3
  84. package/src/jsonl-stream.ts +8 -0
  85. package/src/provider-list-workflow.ts +108 -0
  86. package/src/provider-wire-record.ts +4 -0
  87. package/src/providers-cli.ts +5 -13
  88. package/src/publish-merge-job.ts +350 -0
  89. package/src/publish-merge-timing.ts +27 -0
  90. package/src/workbench-cli.ts +15 -1
  91. package/src/workspace-bootstrap-job.ts +1 -1
  92. package/vendor/skills/orizu/flows/onboard.md +1 -1
  93. package/vendor/skills/orizu/references/authority-map.md +2 -2
  94. package/vendor/skills/orizu/references/building-apps.md +24 -0
  95. package/vendor/skills/orizu/references/cli-reference.md +13 -4
  96. package/vendor/skills/orizu/references/optimization-reports.md +1 -1
  97. package/vendor/skills/orizu/references/primer.md +1 -0
  98. package/vendor/skills/orizu/references/prompt-control-plane.md +9 -5
@@ -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
- const raw = readSourceFile(pathArg)
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 project = getArg('--project')
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
- const sourceCode = readSourceFile(filePath)
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
- const data = await parseJsonResponse<{
201
- app: { id: string; name: string; versionNum: number; componentName?: string; url?: string }
202
- warnings?: string[]
203
- }>(response, 'App create')
204
- if (hasJsonFlag()) {
205
- printJson({ app: { ...data.app, pageUrl: data.app.url || appPageUrl(getBaseUrl(), data.app.id) }, warnings: data.warnings || [] })
206
- return
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
- if (data.warnings?.length) {
213
- printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`)
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 sourceCode = readSourceFile(filePath)
286
- const inputJsonSchema = readJsonFile(inputSchemaPath)
287
- const outputJsonSchema = readJsonFile(outputSchemaPath)
288
- const response = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}/update-from-file`, {
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
- if (!response.ok) {
300
- throw new Error(`Failed to update app: ${await response.text()}`)
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
- const data = await parseJsonResponse<{
304
- app: { id: string; name: string; versionNum: number; componentName?: string }
305
- warnings?: string[]
306
- }>(response, 'App update')
307
- const pageUrl = appPageUrl(getBaseUrl(), data.app.id)
308
- if (hasJsonFlag()) {
309
- printJson({ app: { ...data.app, pageUrl }, warnings: data.warnings || [] })
310
- return
311
- }
312
- printLine(`Updated app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) to v${data.app.versionNum}`)
313
- printLine(`View app: ${formatTerminalLink(pageUrl)}`)
314
- if (data.warnings?.length) {
315
- printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`)
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,7 @@
1
+ /** Recovery may use canonical inputs only after this attempt wrote its full snapshot. */
2
+ export class AppPublishFailure extends Error {
3
+ constructor(message: string, readonly filesMaterialized: boolean) {
4
+ super(message)
5
+ this.name = 'AppPublishFailure'
6
+ }
7
+ }
@@ -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
+ }