@osmo.inc/cli 0.1.16 → 0.1.17

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/osmo.js CHANGED
@@ -10,7 +10,7 @@ var __export = (target, all) => {
10
10
 
11
11
  // src/runtime.ts
12
12
  import fs from "node:fs";
13
- import path3 from "node:path";
13
+ import path2 from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { spawn } from "node:child_process";
16
16
  function assertCompatibleProject(config) {
@@ -21,10 +21,10 @@ function assertCompatibleProject(config) {
21
21
  );
22
22
  }
23
23
  function nativeBinary() {
24
- const bundled = path3.join(packageRoot, "vendor/osmo-lite");
24
+ const bundled = path2.join(packageRoot, "vendor/osmo-lite");
25
25
  if (fs.existsSync(bundled)) {
26
26
  const platform = JSON.parse(
27
- fs.readFileSync(path3.join(packageRoot, "vendor/platform.json"), "utf8")
27
+ fs.readFileSync(path2.join(packageRoot, "vendor/platform.json"), "utf8")
28
28
  );
29
29
  if (platform.os !== process.platform || platform.arch !== process.arch)
30
30
  throw new Error(
@@ -33,7 +33,7 @@ function nativeBinary() {
33
33
  return bundled;
34
34
  }
35
35
  for (const profile of ["debug", "release"]) {
36
- const candidate = path3.join(
36
+ const candidate = path2.join(
37
37
  runtimeRoot,
38
38
  `apps/native/target/${profile}/osmo-lite`
39
39
  );
@@ -57,22 +57,22 @@ function run(command, args, options = {}) {
57
57
  child.once(
58
58
  "exit",
59
59
  (code, signal) => code === 0 ? resolve() : reject(
60
- new Error(`${path3.basename(command)} failed (${signal ?? code}).`)
60
+ new Error(`${path2.basename(command)} failed (${signal ?? code}).`)
61
61
  )
62
62
  );
63
63
  });
64
64
  }
65
65
  function projectRoot(start = process.cwd()) {
66
- let dir = path3.resolve(start);
66
+ let dir = path2.resolve(start);
67
67
  while (true) {
68
- if (fs.existsSync(path3.join(dir, ".osmo/project.json"))) {
68
+ if (fs.existsSync(path2.join(dir, ".osmo/project.json"))) {
69
69
  const config = JSON.parse(
70
- fs.readFileSync(path3.join(dir, ".osmo/project.json"), "utf8")
70
+ fs.readFileSync(path2.join(dir, ".osmo/project.json"), "utf8")
71
71
  );
72
72
  assertCompatibleProject(config);
73
73
  return dir;
74
74
  }
75
- const parent = path3.dirname(dir);
75
+ const parent = path2.dirname(dir);
76
76
  if (parent === dir)
77
77
  throw new Error(
78
78
  "No Osmo project found. Run osmo studio init in your project folder first."
@@ -81,11 +81,11 @@ function projectRoot(start = process.cwd()) {
81
81
  }
82
82
  }
83
83
  function sceneInProject(root, input = "scene.tsx") {
84
- const source = fs.realpathSync(path3.resolve(root, input));
85
- const relative = path3.relative(fs.realpathSync(root), source);
86
- if (relative.startsWith(".." + path3.sep) || path3.isAbsolute(relative) || relative === "..")
84
+ const source = fs.realpathSync(path2.resolve(root, input));
85
+ const relative = path2.relative(fs.realpathSync(root), source);
86
+ if (relative.startsWith(".." + path2.sep) || path2.isAbsolute(relative) || relative === "..")
87
87
  throw new Error("Scene must be inside the current Osmo project.");
88
- return { source, scene: relative.split(path3.sep).join("/") };
88
+ return { source, scene: relative.split(path2.sep).join("/") };
89
89
  }
90
90
  var packageRoot, manifest, version, sceneVersion, runtimeRoot, studioRoot;
91
91
  var init_runtime = __esm({
@@ -93,12 +93,78 @@ var init_runtime = __esm({
93
93
  "use strict";
94
94
  packageRoot = fileURLToPath(new URL("../", import.meta.url));
95
95
  manifest = JSON.parse(
96
- fs.readFileSync(path3.join(packageRoot, "package.json"), "utf8")
96
+ fs.readFileSync(path2.join(packageRoot, "package.json"), "utf8")
97
97
  );
98
98
  version = manifest.version;
99
99
  sceneVersion = manifest.osmo.sceneVersion;
100
- runtimeRoot = fs.existsSync(path3.join(packageRoot, "runtime")) ? path3.join(packageRoot, "runtime") : path3.resolve(packageRoot, "../..");
101
- studioRoot = fs.existsSync(path3.join(packageRoot, "studio")) ? path3.join(packageRoot, "studio") : path3.join(runtimeRoot, "packages/studio/dist");
100
+ runtimeRoot = fs.existsSync(path2.join(packageRoot, "runtime")) ? path2.join(packageRoot, "runtime") : path2.resolve(packageRoot, "../..");
101
+ studioRoot = fs.existsSync(path2.join(packageRoot, "studio")) ? path2.join(packageRoot, "studio") : path2.join(runtimeRoot, "packages/studio/dist");
102
+ }
103
+ });
104
+
105
+ // src/check.ts
106
+ import ts from "typescript";
107
+ import path3 from "node:path";
108
+ import { pathToFileURL } from "node:url";
109
+ async function check(source, {
110
+ text: text2,
111
+ sources = {},
112
+ allowLegacyTracks = false
113
+ } = {}) {
114
+ const { validate } = await import(pathToFileURL(
115
+ path3.join(runtimeRoot, "packages/scene/scripts/validate-strict.mjs")
116
+ ).href);
117
+ const strict = validate(source, "strict", {
118
+ text: text2,
119
+ sources,
120
+ requireTracks: !allowLegacyTracks
121
+ });
122
+ const scene = path3.join(runtimeRoot, "packages/scene/src");
123
+ const options = {
124
+ noEmit: true,
125
+ strict: true,
126
+ skipLibCheck: true,
127
+ target: ts.ScriptTarget.ES2022,
128
+ module: ts.ModuleKind.ESNext,
129
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
130
+ jsx: ts.JsxEmit.ReactJSX,
131
+ jsxImportSource: "@osmo/scene",
132
+ baseUrl: runtimeRoot,
133
+ types: [],
134
+ allowImportingTsExtensions: true,
135
+ allowJs: true,
136
+ paths: {
137
+ "@osmo/scene": [path3.join(scene, "index.ts")],
138
+ "@osmo/scene/*": [path3.join(scene, "*")]
139
+ }
140
+ };
141
+ const host = ts.createCompilerHost(options), original = host.getSourceFile.bind(host);
142
+ const overrides = {
143
+ ...sources,
144
+ ...text2 === void 0 ? {} : { [path3.resolve(source)]: text2 }
145
+ };
146
+ host.getSourceFile = (file, language, ...rest) => Object.hasOwn(overrides, path3.resolve(file)) ? ts.createSourceFile(
147
+ file,
148
+ overrides[path3.resolve(file)],
149
+ language,
150
+ true,
151
+ ts.ScriptKind.TSX
152
+ ) : original(file, language, ...rest);
153
+ const program = ts.createProgram([source], options, host);
154
+ const diagnostics = ts.getPreEmitDiagnostics(program);
155
+ const errors = [
156
+ ...strict.errors,
157
+ ...diagnostics.map((d) => {
158
+ const pos = d.file && d.start !== void 0 ? d.file.getLineAndCharacterOfPosition(d.start) : void 0;
159
+ return `${d.file?.fileName ?? ""}${pos ? `:${pos.line + 1}:${pos.character + 1}` : ""} ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
160
+ })
161
+ ];
162
+ return { ok: !errors.length, errors };
163
+ }
164
+ var init_check = __esm({
165
+ "src/check.ts"() {
166
+ "use strict";
167
+ init_runtime();
102
168
  }
103
169
  });
104
170
 
@@ -149,76 +215,10 @@ var init_labels = __esm({
149
215
  }
150
216
  });
151
217
 
152
- // src/check.ts
153
- import ts3 from "typescript";
154
- import path10 from "node:path";
155
- import { pathToFileURL as pathToFileURL2 } from "node:url";
156
- async function check(source, {
157
- text: text2,
158
- sources = {},
159
- allowLegacyTracks = false
160
- } = {}) {
161
- const { validate } = await import(pathToFileURL2(
162
- path10.join(runtimeRoot, "packages/scene/scripts/validate-strict.mjs")
163
- ).href);
164
- const strict = validate(source, "strict", {
165
- text: text2,
166
- sources,
167
- requireTracks: !allowLegacyTracks
168
- });
169
- const scene = path10.join(runtimeRoot, "packages/scene/src");
170
- const options = {
171
- noEmit: true,
172
- strict: true,
173
- skipLibCheck: true,
174
- target: ts3.ScriptTarget.ES2022,
175
- module: ts3.ModuleKind.ESNext,
176
- moduleResolution: ts3.ModuleResolutionKind.Bundler,
177
- jsx: ts3.JsxEmit.ReactJSX,
178
- jsxImportSource: "@osmo/scene",
179
- baseUrl: runtimeRoot,
180
- types: [],
181
- allowImportingTsExtensions: true,
182
- allowJs: true,
183
- paths: {
184
- "@osmo/scene": [path10.join(scene, "index.ts")],
185
- "@osmo/scene/*": [path10.join(scene, "*")]
186
- }
187
- };
188
- const host = ts3.createCompilerHost(options), original = host.getSourceFile.bind(host);
189
- const overrides = {
190
- ...sources,
191
- ...text2 === void 0 ? {} : { [path10.resolve(source)]: text2 }
192
- };
193
- host.getSourceFile = (file, language, ...rest) => Object.hasOwn(overrides, path10.resolve(file)) ? ts3.createSourceFile(
194
- file,
195
- overrides[path10.resolve(file)],
196
- language,
197
- true,
198
- ts3.ScriptKind.TSX
199
- ) : original(file, language, ...rest);
200
- const program = ts3.createProgram([source], options, host);
201
- const diagnostics = ts3.getPreEmitDiagnostics(program);
202
- const errors = [
203
- ...strict.errors,
204
- ...diagnostics.map((d) => {
205
- const pos = d.file && d.start !== void 0 ? d.file.getLineAndCharacterOfPosition(d.start) : void 0;
206
- return `${d.file?.fileName ?? ""}${pos ? `:${pos.line + 1}:${pos.character + 1}` : ""} ${ts3.flattenDiagnosticMessageText(d.messageText, "\n")}`;
207
- })
208
- ];
209
- return { ok: !errors.length, errors };
210
- }
211
- var init_check = __esm({
212
- "src/check.ts"() {
213
- "use strict";
214
- init_runtime();
215
- }
216
- });
217
-
218
218
  // src/comments.ts
219
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile5, rename as rename2, rm as rm5 } from "node:fs/promises";
219
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile2, rename as rename2, rm as rm3 } from "node:fs/promises";
220
220
  import { randomUUID as randomUUID2 } from "node:crypto";
221
- import path11 from "node:path";
221
+ import path7 from "node:path";
222
222
  function validBounds(value) {
223
223
  return value && ["x", "y", "width", "height"].every(
224
224
  (key) => Number.isFinite(value[key]) && value[key] >= 0 && value[key] <= 1
@@ -244,7 +244,7 @@ function validAgentResponse(value) {
244
244
  }
245
245
  async function readComments(root) {
246
246
  try {
247
- const data = JSON.parse(await readFile6(filename(root), "utf8"));
247
+ const data = JSON.parse(await readFile3(filename(root), "utf8"));
248
248
  if (data.version !== 1 || !Array.isArray(data.comments))
249
249
  throw new Error("Unsupported comments file");
250
250
  const ids = /* @__PURE__ */ new Set();
@@ -272,13 +272,13 @@ function commentText(value) {
272
272
  return value.trim();
273
273
  }
274
274
  async function mutateComments(root, change) {
275
- const folder = path11.join(root, ".osmo");
276
- await mkdir5(folder, { recursive: true });
277
- const lock = path11.join(folder, "comments.lock");
275
+ const folder = path7.join(root, ".osmo");
276
+ await mkdir3(folder, { recursive: true });
277
+ const lock = path7.join(folder, "comments.lock");
278
278
  const start = Date.now();
279
279
  while (true) {
280
280
  try {
281
- await mkdir5(lock);
281
+ await mkdir3(lock);
282
282
  break;
283
283
  } catch (e) {
284
284
  if (e.code !== "EEXIST") throw e;
@@ -289,18 +289,18 @@ async function mutateComments(root, change) {
289
289
  await new Promise((resolve) => setTimeout(resolve, 25));
290
290
  }
291
291
  }
292
- const temporary = path11.join(folder, `comments.${randomUUID2()}.tmp`);
292
+ const temporary = path7.join(folder, `comments.${randomUUID2()}.tmp`);
293
293
  try {
294
294
  const data = await readComments(root);
295
295
  const result = change(data.comments);
296
- await writeFile5(temporary, JSON.stringify(data, null, 2) + "\n", {
296
+ await writeFile2(temporary, JSON.stringify(data, null, 2) + "\n", {
297
297
  flag: "wx"
298
298
  });
299
299
  await rename2(temporary, filename(root));
300
300
  return result;
301
301
  } finally {
302
- await rm5(temporary, { force: true });
303
- await rm5(lock, { recursive: true, force: true });
302
+ await rm3(temporary, { force: true });
303
+ await rm3(lock, { recursive: true, force: true });
304
304
  }
305
305
  }
306
306
  async function addComment(root, input) {
@@ -399,7 +399,7 @@ var filename, AGENT_REACTION_MIN_MS, graphemes, isoTime;
399
399
  var init_comments = __esm({
400
400
  "src/comments.ts"() {
401
401
  "use strict";
402
- filename = (root) => path11.join(root, ".osmo/comments.json");
402
+ filename = (root) => path7.join(root, ".osmo/comments.json");
403
403
  AGENT_REACTION_MIN_MS = 3e4;
404
404
  graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
405
405
  isoTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
@@ -948,7 +948,7 @@ __export(scene_document_exports, {
948
948
  outlineDocument: () => outlineDocument
949
949
  });
950
950
  import fs2 from "node:fs/promises";
951
- import path12 from "node:path";
951
+ import path11 from "node:path";
952
952
  import { createHash as createHash4 } from "node:crypto";
953
953
  import ts5 from "typescript";
954
954
  function createDocumentLoader(root) {
@@ -997,8 +997,8 @@ function createDocumentLoader(root) {
997
997
  }
998
998
  if (typeof value !== "string" || kind === "font" && value === "Inter")
999
999
  return;
1000
- const relative = path12.posix.normalize(
1001
- path12.posix.join(path12.posix.dirname(file), value)
1000
+ const relative = path11.posix.normalize(
1001
+ path11.posix.join(path11.posix.dirname(file), value)
1002
1002
  );
1003
1003
  if (document.assets[relative]) return;
1004
1004
  document.assets[relative] = await registerAsset(relative, kind);
@@ -1094,7 +1094,7 @@ function createDocumentLoader(root) {
1094
1094
  );
1095
1095
  return {
1096
1096
  bytes: await fs2.readFile(current.source),
1097
- extension: path12.extname(current.source).toLowerCase()
1097
+ extension: path11.extname(current.source).toLowerCase()
1098
1098
  };
1099
1099
  }
1100
1100
  return { load, readAsset, resources };
@@ -1110,21 +1110,21 @@ var init_scene_document2 = __esm({
1110
1110
 
1111
1111
  // src/project-resources.ts
1112
1112
  import fs3 from "node:fs/promises";
1113
- import path13 from "node:path";
1114
- import { execFile as execFile2 } from "node:child_process";
1115
- import { promisify as promisify2 } from "node:util";
1113
+ import path12 from "node:path";
1114
+ import { execFile } from "node:child_process";
1115
+ import { promisify } from "node:util";
1116
1116
  import { randomUUID as randomUUID3 } from "node:crypto";
1117
1117
  import ts6 from "typescript";
1118
1118
  async function resourceFile(root, relative) {
1119
- if (typeof relative !== "string" || path13.isAbsolute(relative))
1119
+ if (typeof relative !== "string" || path12.isAbsolute(relative))
1120
1120
  throw new Error("Resource paths must be relative.");
1121
- const base = await fs3.realpath(root), file = await fs3.realpath(path13.resolve(base, relative));
1122
- if (!inside(path13.join(base, "resources"), file) || (await fs3.stat(file)).isDirectory())
1121
+ const base = await fs3.realpath(root), file = await fs3.realpath(path12.resolve(base, relative));
1122
+ if (!inside(path12.join(base, "resources"), file) || (await fs3.stat(file)).isDirectory())
1123
1123
  throw new Error("Resource files must stay inside resources/.");
1124
1124
  return file;
1125
1125
  }
1126
1126
  async function resourceFolder(root) {
1127
- const base = await fs3.realpath(root), folder = path13.join(base, "resources");
1127
+ const base = await fs3.realpath(root), folder = path12.join(base, "resources");
1128
1128
  await fs3.mkdir(folder, { recursive: true });
1129
1129
  if ((await fs3.lstat(folder)).isSymbolicLink())
1130
1130
  throw new Error("resources/ must be a real directory.");
@@ -1133,7 +1133,7 @@ async function resourceFolder(root) {
1133
1133
  async function listProjectResources(root) {
1134
1134
  let entries;
1135
1135
  try {
1136
- entries = await fs3.readdir(path13.join(root, "resources"), {
1136
+ entries = await fs3.readdir(path12.join(root, "resources"), {
1137
1137
  withFileTypes: true
1138
1138
  });
1139
1139
  } catch (e) {
@@ -1149,8 +1149,8 @@ async function listProjectResources(root) {
1149
1149
  throw new Error("Manifest exceeds 1 MB");
1150
1150
  const meta = JSON.parse(await fs3.readFile(manifestFile, "utf8"));
1151
1151
  const entry = `resources/${item.name}/${meta.entry}`, file = await resourceFile(root, entry);
1152
- const kind = meta.kind ?? meta.category ?? kinds[path13.extname(file).toLowerCase()];
1153
- if (!["shader", "image", "video", "model", "scene"].includes(kind) || kinds[path13.extname(file).toLowerCase()] !== kind)
1152
+ const kind = meta.kind ?? meta.category ?? kinds[path12.extname(file).toLowerCase()];
1153
+ if (!["shader", "image", "video", "model", "scene"].includes(kind) || kinds[path12.extname(file).toLowerCase()] !== kind)
1154
1154
  throw new Error("Unsupported resource type");
1155
1155
  let effect;
1156
1156
  if (kind === "shader" && meta.effect) {
@@ -1201,14 +1201,14 @@ async function listProjectResources(root) {
1201
1201
  "video-references"
1202
1202
  ].includes(item.name) || item.isSymbolicLink())
1203
1203
  continue;
1204
- const file = path13.join(folder, item.name);
1204
+ const file = path12.join(folder, item.name);
1205
1205
  if (item.isDirectory()) {
1206
1206
  await scenes(file, depth + 1);
1207
1207
  continue;
1208
1208
  }
1209
1209
  if (!item.isFile() || !item.name.endsWith(".tsx") || (await fs3.stat(file)).size > 1024 * 1024)
1210
1210
  continue;
1211
- const entry = path13.relative(root, file).split(path13.sep).join("/");
1211
+ const entry = path12.relative(root, file).split(path12.sep).join("/");
1212
1212
  try {
1213
1213
  const text2 = await fs3.readFile(file, "utf8"), ast = ts6.createSourceFile(
1214
1214
  entry,
@@ -1247,7 +1247,7 @@ async function listProjectResources(root) {
1247
1247
  else
1248
1248
  resources.push({
1249
1249
  id: `scene:${entry}`,
1250
- title: item.name === "scene.tsx" ? path13.basename(folder) : item.name.slice(0, -4),
1250
+ title: item.name === "scene.tsx" ? path12.basename(folder) : item.name.slice(0, -4),
1251
1251
  kind: "scene",
1252
1252
  entry,
1253
1253
  ...info,
@@ -1271,7 +1271,7 @@ async function getProjectResource(root, id) {
1271
1271
  async function resourcePreview(root, id) {
1272
1272
  const resource = await getProjectResource(root, id);
1273
1273
  if (!resource.preview) throw new Error("No preview for this resource");
1274
- const file = await resourceFile(root, resource.preview), type = mime[path13.extname(file).toLowerCase()];
1274
+ const file = await resourceFile(root, resource.preview), type = mime[path12.extname(file).toLowerCase()];
1275
1275
  if (!type) throw new Error("Unsupported preview type");
1276
1276
  if ((await fs3.stat(file)).size > 12 * 1024 * 1024)
1277
1277
  throw new Error("Preview is too large");
@@ -1279,14 +1279,14 @@ async function resourcePreview(root, id) {
1279
1279
  }
1280
1280
  async function dimensions(file, kind) {
1281
1281
  if (!["image", "video"].includes(kind)) return {};
1282
- if (path13.extname(file).toLowerCase() === ".svg") {
1282
+ if (path12.extname(file).toLowerCase() === ".svg") {
1283
1283
  const text2 = await fs3.readFile(file, "utf8"), view = text2.match(
1284
1284
  /viewBox=["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/i
1285
1285
  );
1286
1286
  return view ? { width: Number(view[1]), height: Number(view[2]) } : {};
1287
1287
  }
1288
1288
  try {
1289
- const { stdout } = await exec2(
1289
+ const { stdout } = await exec(
1290
1290
  "ffprobe",
1291
1291
  [
1292
1292
  "-v",
@@ -1311,42 +1311,42 @@ async function dimensions(file, kind) {
1311
1311
  }
1312
1312
  }
1313
1313
  async function importProjectResource(root, input) {
1314
- const source = await fs3.realpath(path13.resolve(input)), kind = kinds[path13.extname(source).toLowerCase()];
1314
+ const source = await fs3.realpath(path12.resolve(input)), kind = kinds[path12.extname(source).toLowerCase()];
1315
1315
  if (!kind)
1316
1316
  throw new Error(
1317
1317
  "Supported files: scenes (.tsx), images, MP4/WebM/MOV, WGSL shaders and GLB models."
1318
1318
  );
1319
1319
  if (!(await fs3.stat(source)).isFile()) throw new Error("Import a file.");
1320
- const folder = await resourceFolder(root), title = path13.basename(source, path13.extname(source));
1321
- let id = slug(title), destination = path13.join(folder, id);
1320
+ const folder = await resourceFolder(root), title = path12.basename(source, path12.extname(source));
1321
+ let id = slug(title), destination = path12.join(folder, id);
1322
1322
  try {
1323
1323
  await fs3.mkdir(destination);
1324
1324
  } catch (e) {
1325
1325
  if (e.code !== "EEXIST") throw e;
1326
1326
  id += `-${randomUUID3().slice(0, 8)}`;
1327
- destination = path13.join(folder, id);
1327
+ destination = path12.join(folder, id);
1328
1328
  await fs3.mkdir(destination);
1329
1329
  }
1330
1330
  try {
1331
- const entry = kind === "scene" ? path13.basename(source) : `asset${path13.extname(source).toLowerCase()}`;
1331
+ const entry = kind === "scene" ? path12.basename(source) : `asset${path12.extname(source).toLowerCase()}`;
1332
1332
  if (kind === "scene") {
1333
1333
  const { createDocumentLoader: createDocumentLoader2 } = await Promise.resolve().then(() => (init_scene_document2(), scene_document_exports));
1334
- const base = path13.dirname(source), doc = await createDocumentLoader2(base).load(source);
1334
+ const base = path12.dirname(source), doc = await createDocumentLoader2(base).load(source);
1335
1335
  if (doc.root.type !== "Scene")
1336
1336
  throw new Error("Scene resources must export a Scene.");
1337
1337
  if (doc.nativeReason) throw new Error(doc.nativeReason);
1338
1338
  const files = /* @__PURE__ */ new Set([...doc.files, ...Object.keys(doc.assets)]);
1339
1339
  for (const relative of files) {
1340
- const file = await fs3.realpath(path13.resolve(base, relative));
1340
+ const file = await fs3.realpath(path12.resolve(base, relative));
1341
1341
  if (!inside(base, file))
1342
1342
  throw new Error(
1343
1343
  "Keep imported scene dependencies inside its folder."
1344
1344
  );
1345
- const dest = path13.join(destination, relative);
1346
- await fs3.mkdir(path13.dirname(dest), { recursive: true });
1345
+ const dest = path12.join(destination, relative);
1346
+ await fs3.mkdir(path12.dirname(dest), { recursive: true });
1347
1347
  await fs3.copyFile(file, dest);
1348
1348
  }
1349
- } else await fs3.copyFile(source, path13.join(destination, entry));
1349
+ } else await fs3.copyFile(source, path12.join(destination, entry));
1350
1350
  const meta = {
1351
1351
  id,
1352
1352
  title,
@@ -1363,7 +1363,7 @@ async function importProjectResource(root, input) {
1363
1363
  );
1364
1364
  }
1365
1365
  await fs3.writeFile(
1366
- path13.join(destination, "resource.json"),
1366
+ path12.join(destination, "resource.json"),
1367
1367
  JSON.stringify(meta, null, 2) + "\n",
1368
1368
  { flag: "wx" }
1369
1369
  );
@@ -1379,10 +1379,10 @@ async function importProjectResource(root, input) {
1379
1379
  }
1380
1380
  }
1381
1381
  async function installResourceExamples(root) {
1382
- const folder = await resourceFolder(root), source = path13.join(packageRoot, "src/resource-examples/");
1382
+ const folder = await resourceFolder(root), source = path12.join(packageRoot, "src/resource-examples/");
1383
1383
  for (const item of await fs3.readdir(source, { withFileTypes: true })) {
1384
1384
  if (!item.isDirectory()) continue;
1385
- const destination = path13.join(folder, item.name);
1385
+ const destination = path12.join(folder, item.name);
1386
1386
  try {
1387
1387
  await fs3.mkdir(destination);
1388
1388
  } catch (e) {
@@ -1390,12 +1390,12 @@ async function installResourceExamples(root) {
1390
1390
  throw e;
1391
1391
  }
1392
1392
  try {
1393
- for (const file of (await fs3.readdir(path13.join(source, item.name))).sort(
1393
+ for (const file of (await fs3.readdir(path12.join(source, item.name))).sort(
1394
1394
  (a, b) => Number(a === "resource.json") - Number(b === "resource.json")
1395
1395
  ))
1396
1396
  await fs3.copyFile(
1397
- path13.join(source, item.name, file),
1398
- path13.join(destination, file)
1397
+ path12.join(source, item.name, file),
1398
+ path12.join(destination, file)
1399
1399
  );
1400
1400
  } catch (error) {
1401
1401
  await fs3.rm(destination, { recursive: true, force: true });
@@ -1404,12 +1404,12 @@ async function installResourceExamples(root) {
1404
1404
  }
1405
1405
  return { status: "installed", ...await listProjectResources(root) };
1406
1406
  }
1407
- var exec2, kinds, mime, slug, inside;
1407
+ var exec, kinds, mime, slug, inside;
1408
1408
  var init_project_resources = __esm({
1409
1409
  "src/project-resources.ts"() {
1410
1410
  "use strict";
1411
1411
  init_runtime();
1412
- exec2 = promisify2(execFile2);
1412
+ exec = promisify(execFile);
1413
1413
  kinds = {
1414
1414
  ".tsx": "scene",
1415
1415
  ".svg": "image",
@@ -1435,8 +1435,8 @@ var init_project_resources = __esm({
1435
1435
  };
1436
1436
  slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "resource";
1437
1437
  inside = (root, file) => {
1438
- const rel = path13.relative(root, file);
1439
- return rel !== ".." && !rel.startsWith(".." + path13.sep) && !path13.isAbsolute(rel);
1438
+ const rel = path12.relative(root, file);
1439
+ return rel !== ".." && !rel.startsWith(".." + path12.sep) && !path12.isAbsolute(rel);
1440
1440
  };
1441
1441
  }
1442
1442
  });
@@ -4768,86 +4768,11 @@ var init_server = __esm({
4768
4768
  }
4769
4769
  });
4770
4770
 
4771
- // src/cli.ts
4772
- import path20 from "node:path";
4773
-
4774
- // src/features.ts
4775
- function studioEnabled(env = process.env) {
4776
- return env.OSMO_STUDIO_ENABLED === "1";
4777
- }
4778
- function requireStudio() {
4779
- if (!studioEnabled())
4780
- throw Object.assign(
4781
- new Error("Osmo Studio commands are not available in this release."),
4782
- { code: "feature_disabled" }
4783
- );
4784
- }
4785
-
4786
- // src/help.ts
4787
- function cliHelp(version2, studio) {
4788
- const common = `Osmo ${version2}
4789
-
4790
- osmo login [--name COMPUTER] [--json]
4791
- osmo login status SESSION [--json]
4792
- osmo whoami [--json]
4793
- osmo logout [--json]
4794
- osmo resources list [--category shader] [--json]
4795
- osmo resources show ID [--version VERSION] [--preset PRESET] [--json]
4796
- osmo resources add ID [--version VERSION] [--preset PRESET] [--json]
4797
- osmo resources import FILE [--json]
4798
- osmo resources local [--json]
4799
- osmo resources examples [--json]
4800
- osmo share --video FILE.mp4 [--project ID] [--name NAME] [--json]
4801
- osmo unshare --project ID [--json]
4802
- osmo comments list --project ID [--open] [--json]
4803
- osmo comments reply ID "message" --project ID
4804
- osmo comments resolve ID --project ID
4805
- osmo comments reopen ID --project ID`;
4806
- const studioCommands = `osmo studio init [folder]
4807
- osmo studio publish [--name NAME]
4808
- osmo studio push
4809
- osmo studio pull
4810
- osmo studio sync
4811
- osmo studio clone PROJECT_ID folder
4812
- osmo studio history
4813
- osmo studio open [scene.tsx] [--port NUMBER] [--no-open]
4814
- osmo studio open animations/player.tsx [--width 800 --height 450 --duration 4]
4815
- osmo studio check [scene.tsx] [--json]
4816
- osmo studio render [scene.tsx] --out animation.mp4 [--supersample 1|2]
4817
- osmo studio render [scene.tsx] --frame NUMBER --out frame.png [--supersample 1|2]
4818
- osmo studio deploy [scene.tsx]
4819
- osmo studio comments list [--open] [--scene scene.tsx] [--json]
4820
- osmo studio comments start ID [EMOJI]
4821
- osmo studio comments stop ID
4822
- osmo studio comments react ID [EMOJI|none]
4823
- osmo studio comments reply ID "message"
4824
- osmo studio comments resolve ID
4825
- osmo studio comments reopen ID`;
4826
- const video = "osmo share --video: share a rendered video from any tool, including drafts and work in progress. Video review and version history only; no editable source. Requires login and ffprobe, not init or Git. Without --project, creates a new review.";
4827
- return `${common}${studio ? "\n" + studioCommands : ""}
4828
-
4829
- ${studio ? "Choose the sharing workflow:\n " : ""}${video}${studio ? "\n osmo studio deploy: publish a full-featured editable Osmo project with source files, assets, and tracked Git history, plus a review video. Requires osmo studio init, osmo login, and osmo studio publish. Prefer this command for Osmo Studio projects. Edit locally with Studio or code; the cloud review is read-only." : ""}
4830
- ${studio ? "\nNew animation: run osmo studio init, read AGENTS.md, then resources list --json before authoring.\n" : ""}
4831
- osmo unshare: disable a project's public review and version links, preserving
4832
- videos, comments, and history. Sharing a new video to the same project restores its review links.
4833
- Previously obtained direct media URLs may still work.
4834
-
4835
- Run project commands from the initialized project folder. Native renderer included
4836
- in release archives; Node 22 and FFmpeg/ffprobe are prerequisites.
4837
- Cloud commands need osmo login; OSMO_BASE_URL picks the cloud (default studio.osmo.inc).
4838
- `;
4839
- }
4840
-
4841
4771
  // src/cli.ts
4842
4772
  import { spawnSync as spawnSync4 } from "node:child_process";
4773
+ import path20 from "node:path";
4843
4774
  import { parseArgs } from "node:util";
4844
4775
 
4845
- // src/video-deploy.ts
4846
- import { execFile } from "node:child_process";
4847
- import { stat } from "node:fs/promises";
4848
- import path2 from "node:path";
4849
- import { promisify } from "node:util";
4850
-
4851
4776
  // src/auth.ts
4852
4777
  import {
4853
4778
  mkdir,
@@ -5356,6 +5281,16 @@ ${result.next}`;
5356
5281
  Next: ${result.next}`;
5357
5282
  }
5358
5283
 
5284
+ // src/cli.ts
5285
+ init_check();
5286
+
5287
+ // src/cloud.ts
5288
+ import { spawnSync as spawnSync2 } from "node:child_process";
5289
+ import { existsSync as existsSync2 } from "node:fs";
5290
+ import { mkdir as mkdir2, readdir, readFile as readFile2, rm as rm2 } from "node:fs/promises";
5291
+ import path6 from "node:path";
5292
+ import ts3 from "typescript";
5293
+
5359
5294
  // src/cloud-request.ts
5360
5295
  async function call(origin, route, { method = "GET", body: body2, timeoutMs = 6e4, allow = [] } = {}) {
5361
5296
  let response;
@@ -5380,345 +5315,9 @@ async function call(origin, route, { method = "GET", body: body2, timeoutMs = 6e
5380
5315
  return { status: response.status, ...data };
5381
5316
  }
5382
5317
 
5383
- // src/upload-render.ts
5384
- import { openAsBlob } from "node:fs";
5385
- async function uploadRender(origin, project, file, metadata, log = () => {
5386
- }) {
5387
- const video = await openAsBlob(file, { type: metadata.contentType });
5388
- if (!video.size || video.size > 2 * 1024 ** 3)
5389
- throw new Error("Video must be non-empty and no larger than 2 GB.");
5390
- let slot;
5391
- try {
5392
- log(`uploading video (${(video.size / 1048576).toFixed(1)} MB)\u2026`);
5393
- slot = await call(
5394
- origin,
5395
- `/api/projects/${encodeURIComponent(project)}/render`,
5396
- {
5397
- method: "POST",
5398
- body: { ...metadata, sizeInBytes: video.size }
5399
- }
5400
- );
5401
- if (slot.type !== "success")
5402
- throw new Error(slot.error ?? "The cloud did not accept the video.");
5403
- const put = await fetch(slot.uploadUrl, {
5404
- method: "PUT",
5405
- headers: { "Content-Type": metadata.contentType },
5406
- body: video,
5407
- redirect: "error",
5408
- signal: AbortSignal.timeout(9e5)
5409
- });
5410
- if (!put.ok) throw new Error(`Video upload failed (${put.status}).`);
5411
- const render = slot.renderRowId;
5412
- const done = await call(origin, `/api/renders/${render}/complete`, {
5413
- method: "POST",
5414
- body: { fileKey: slot.fileKey }
5415
- });
5416
- if (done.type !== "success")
5417
- throw new Error(done.error ?? "The cloud did not finalize the video.");
5418
- slot = null;
5419
- if (done.sharingEnabled === false)
5420
- throw new Error(
5421
- "Video saved, but review sharing was disabled after this upload started. Upload again to restore sharing."
5422
- );
5423
- return {
5424
- status: "deployed",
5425
- project,
5426
- render,
5427
- link: `${origin}/share/project/${project}`,
5428
- versionLink: `${origin}/share/${render}`
5429
- };
5430
- } finally {
5431
- if (slot?.renderRowId)
5432
- await call(origin, `/api/renders/${slot.renderRowId}/fail`, {
5433
- method: "POST",
5434
- body: { error: "deploy did not complete" }
5435
- }).catch(() => void 0);
5436
- }
5437
- }
5438
-
5439
- // src/video-deploy.ts
5440
- var exec = promisify(execFile);
5441
- function projectId(value) {
5442
- if (typeof value !== "string" || !/^[a-z0-9]{10,40}$/.test(value))
5443
- throw new Error("Provide a valid cloud project ID with --project.");
5444
- return value;
5445
- }
5446
- async function unshare(project) {
5447
- projectId(project);
5448
- return call(
5449
- authOrigin(),
5450
- `/api/projects/${project}/share`,
5451
- { method: "DELETE" }
5452
- );
5453
- }
5454
- async function probeVideo(file) {
5455
- const info = await stat(file).catch(() => null);
5456
- if (!info?.isFile() || !info.size || info.size > 2 * 1024 ** 3)
5457
- throw new Error(
5458
- "Video must be an existing, non-empty file no larger than 2 GB."
5459
- );
5460
- let data;
5461
- try {
5462
- const { stdout } = await exec(
5463
- process.env.OSMO_FFPROBE ?? "ffprobe",
5464
- ["-v", "error", "-show_streams", "-show_format", "-of", "json", file],
5465
- { timeout: 3e4, maxBuffer: 2 * 1024 ** 2 }
5466
- );
5467
- data = JSON.parse(stdout);
5468
- } catch (error) {
5469
- if (error.code === "ENOENT")
5470
- throw new Error(
5471
- "ffprobe is required to inspect videos. Install FFmpeg (which includes ffprobe), then retry."
5472
- );
5473
- throw new Error(
5474
- "Cannot read this video. Provide a playable H.264 MP4 file."
5475
- );
5476
- }
5477
- const videos = (data.streams ?? []).filter(
5478
- (s) => s.codec_type === "video" && !s.disposition?.attached_pic
5479
- );
5480
- const video = videos[0], audio = (data.streams ?? []).filter((s) => s.codec_type === "audio");
5481
- if (!data.format?.format_name?.split(",").includes("mp4") || videos.length !== 1 || video?.codec_name !== "h264" || !["yuv420p", "yuvj420p"].includes(
5482
- video?.pix_fmt
5483
- ) || audio.some((s) => s.codec_name !== "aac"))
5484
- throw new Error(
5485
- "Use an H.264 MP4 with 8-bit 4:2:0 video and AAC audio (or no audio). Export a compatible MP4 from your video tool, then retry."
5486
- );
5487
- const [n, d = "1"] = String(video.avg_frame_rate).split("/");
5488
- const fps = Number(n) / Number(d), durationSeconds = Number(video.duration ?? data.format.duration);
5489
- const rotation = Number(
5490
- video.side_data_list?.find((s) => s.rotation !== void 0)?.rotation ?? video.tags?.rotate ?? 0
5491
- );
5492
- const portrait = Math.abs(rotation % 180) === 90;
5493
- const width = portrait ? video.height : video.width, height = portrait ? video.width : video.height;
5494
- if (![fps, durationSeconds, width, height].every(
5495
- (x) => Number.isFinite(x) && x > 0
5496
- ) || fps > 1e3 || durationSeconds * fps > 2147483647)
5497
- throw new Error("Video has invalid or unsupported timing or dimensions.");
5498
- return {
5499
- codec: "h264",
5500
- contentType: "video/mp4",
5501
- // The check above found both finite and positive.
5502
- compositionWidth: width,
5503
- compositionHeight: height,
5504
- fps,
5505
- durationSeconds,
5506
- durationInFrames: Math.max(1, Math.round(durationSeconds * fps))
5507
- };
5508
- }
5509
- async function shareVideo(file, {
5510
- project,
5511
- name,
5512
- log = () => {
5513
- }
5514
- } = {}) {
5515
- if (project !== void 0) projectId(project);
5516
- if (name !== void 0 && (!name.trim() || name.trim().length > 200))
5517
- throw new Error("--name must contain 1\u2013200 characters.");
5518
- const source = path2.resolve(file), metadata = await probeVideo(source), origin = authOrigin();
5519
- await currentAccount(origin);
5520
- const title = name?.trim() ?? path2.basename(source).slice(0, 200);
5521
- const id = project ?? (await call(origin, "/api/review-projects", {
5522
- method: "POST",
5523
- body: { name: title }
5524
- })).id;
5525
- try {
5526
- return await uploadRender(
5527
- origin,
5528
- id,
5529
- source,
5530
- { ...metadata, source: "video", name: title },
5531
- log
5532
- );
5533
- } catch (error) {
5534
- throw new Error(
5535
- `${error.message}
5536
- Retry with --project ${id} to use the same review project.`
5537
- );
5538
- }
5539
- }
5540
- async function videoComments(project, { open = false } = {}) {
5541
- projectId(project);
5542
- const origin = authOrigin(), comments = [];
5543
- let cursor;
5544
- do {
5545
- const page = await call(
5546
- origin,
5547
- `/api/projects/${project}/share/versions${cursor ? `?before=${encodeURIComponent(cursor)}` : ""}`
5548
- );
5549
- for (const version2 of page.versions) {
5550
- const data = await call(
5551
- origin,
5552
- `/api/renders/${version2.id}/review-comments`
5553
- );
5554
- comments.push(
5555
- ...data.comments.filter((c) => !open || c.status === "open").map((c) => ({
5556
- ...c,
5557
- render: version2.id,
5558
- link: `${origin}/share/${version2.id}`
5559
- }))
5560
- );
5561
- }
5562
- cursor = page.nextCursor;
5563
- } while (cursor);
5564
- return comments;
5565
- }
5566
-
5567
- // src/init.ts
5568
- import {
5569
- mkdir as mkdir4,
5570
- readFile as readFile5,
5571
- writeFile as writeFile4,
5572
- copyFile,
5573
- cp,
5574
- access as access2,
5575
- mkdtemp,
5576
- rm as rm4
5577
- } from "node:fs/promises";
5578
- import path9 from "node:path";
5579
-
5580
- // src/authoring.ts
5581
- init_runtime();
5582
- import { readFile as readFile2, writeFile as writeFile2, rm as rm2 } from "node:fs/promises";
5583
- import { createHash as createHash2 } from "node:crypto";
5584
- import path4 from "node:path";
5585
- var begin = "<!-- osmo:begin -->";
5586
- var end = "<!-- osmo:end -->";
5587
- var legacyPointer = "For Osmo animation tasks, read osmo.md before editing. Use osmo studio check, and read open Studio comments.";
5588
- var legacyGuideHash = "815dae34cd7e5e6f44a575a88d80516d3736f9b74c76b9379fc09144c6aa06fb";
5589
- var authoringGuide = `${begin}
5590
- ## Osmo Studio (${version})
5591
-
5592
- Project setup: osmo studio init [folder]. During internal preview, Studio commands require OSMO_STUDIO_ENABLED=1.
5593
-
5594
- TSX is the source of truth. Use the installed @osmo/scene Strict API; do not edit node_modules or the renderer.
5595
-
5596
- ### Check resources first
5597
- At the start of every animation task, run osmo resources list --json. Inspect
5598
- plausible matches with osmo resources show ID --json, including their previews.
5599
- Prefer a suitable resource over recreating it; install only resources you use with
5600
- osmo resources add ID --preset PRESET --json. If login is required, run osmo login
5601
- --json and share the approval link. If the catalog is unavailable, say so and
5602
- continue locally. Use the CLI compatibility result; older manifests may describe
5603
- an earlier CLI. After install, use its local files, types, preset values and usage.
5604
-
5605
- ### Choose the primitive first
5606
- | Visual pattern | Start with |
5607
- | --- | --- |
5608
- | Typography following a circle, curve or figure-eight | One Text with path; animate path.progress |
5609
- | Elements moving/rotating together | Group transforms and shared motion tracks |
5610
- | Panels, circles, borders or thin lines | Group fill/stroke/shape/rounding |
5611
- | Vector curves, icons, cutouts or illustrations | Path with SVG d, fill and stroke; evenodd for holes |
5612
- | Images, footage, soundtrack | Image, Video, Audio with local asset paths |
5613
- | Parallax and solid cards | Camera, z and Group.depth |
5614
- | Content on a 3D model | Model with Surface children; inspect GLB selectors first |
5615
- | Procedural backgrounds or liquid glass | Check resources; Shader with local src and explicit uniforms; glass samples backdrop |
5616
- | Softness or texture | effects={[blur({ radius: 5 }), grain({ amount: 0.04 })]} |
5617
-
5618
- Prototype the native primitive before detailed tracking; explain a tested limitation before custom reconstruction. No approval step needed.
5619
- ### Layout before coordinates
5620
- - Use Group row/column, align, justify, gap and padding for labels/cards/icon rows; position places the whole group.
5621
- - Center labels with Text width/height, align="center", verticalAlign="center". Default bounds are the line box; opt into alignBounds="glyphs" for visible-letter centering. Changing text can shift glyph bounds.
5622
- - Fit icons into equal Path width/height with fit="contain". Supply viewBox for the asset viewport, or omit it for tight geometry bounds, including negative origins. Fitting scales strokes; fill stretches; unsized paths retain coordinates.
5623
- - Text and fitted paths can overflow; use a clipping Group to crop. See the sizing JSDoc before applying manual offsets.
5624
-
5625
- ### API reference: types with short explanations
5626
- Read relevant declarations in node_modules/@osmo/scene/dist/ (JSDoc explains usage):
5627
- - index.d.ts: public exports; elements.d.ts: all primitives, props and examples.
5628
- - motion.d.ts: defineMotion, keyframes and interpolation.
5629
- - knobs.d.ts + knob-schema.d.ts: Group controls, typed values and UI metadata.
5630
- - effects.d.ts: built-in effects and the broader library effect API.
5631
-
5632
- ### Strict rules
5633
- - Named imports from @osmo/scene; export default <Scene ...> in the scene entry.
5634
- - Reuse a local default-exported Group: import Card from './animations/card'; <Card track={0} values={{ title: 'Hello' }} />. No children/knobs replacement; imports stay Strict.
5635
- - Root Group/Text/Image/Video/Audio/Shader/Path/Rect/Model clips require literal track (0\u201399). start/duration are optional seconds; same-track clips cannot overlap. Tracks do not change paint order. Nested layers omit track. Motions use local clip time; trimming never stretches motion or changes media trimIn/playbackRate.
5636
- - Optional names: Group label="Orbiting circles" (max two words); Scene tracks={{0:{label:"Shapes"}}} (one word per lane). Labels are static; track names stay with lanes. Keep stable IDs separate. Group hidden hides visuals; solo isolates siblings. Scene.tracks also accepts hidden (visuals), muted (audio), solo (isolate lanes); these affect preview and export.
5637
- - Camera/Surface are not clips. Group sizing: "fill" follows parent, "auto" fits flow content.
5638
- - Groups clip children to their shape by default. Set clip={false} to allow overflow.
5639
- - Preview Group files with studio open --width 640 --height 360; storyboard.canvas.json stores per-card placement, size and literal knob values, referencing the original TSX.
5640
- - Use explicit JSX, literal values/arrays/objects, and const defineMotion or defineKnobs declarations. Bind tracks/knobs directly to compatible props.
5641
- - No arbitrary expressions, loops, callbacks, spreads, import aliases, custom components, helper calls or computed variables. Only blur({...}) and grain({...}) effect calls are allowed in JSX; pass an explicit object, even for defaults.
5642
- - Shader.src must be a static relative local .wgsl path. Put complete uniform values inline; numeric uniforms can use motion/knob tracks. defineEffect remains unsupported.
5643
- - Keyframe at values are increasing local seconds (scene unless inside a timed clip); easing belongs to the arriving key. Add a key at 0 to define a continuous track's starting value. Tracks do not loop automatically.
5644
- - Group knobs can drive many descendant props; Group.values accepts motion tracks.
5645
- - Units: pixels, degrees, seconds; +z toward viewer. Asset/font paths are relative to their declaring file.
5646
- - Text.path handles the complete string; no width/align/lineHeight or line breaks. SVG geometry is static; progress and ellipse radii can animate.
5647
- - Path draws SVG d in local pixels; use scale to resize. Geometry stays static; transforms, stroke width and effects can animate. Use fill="transparent" for lines.
5648
-
5649
- ### Workflow
5650
- 1. Read relevant types, edit the scene, then run osmo studio check scene.tsx.
5651
- 2. Preview: osmo studio open scene.tsx. Keep the server running; saves reload it. Reopening reuses the project URL, saved in gitignored .osmo/studio.json.
5652
- 3. Export: osmo studio render scene.tsx --out exports/animation.mp4 (single PNG: --frame 90 --out exports/frame.png, zero-based; use a new filename if one exists). Share for review: osmo studio deploy (after osmo login and osmo studio publish) renders, pushes editable source/assets and Git history, and prints a review link. Prefer this command for Osmo Studio projects. osmo share --video FILE.mp4 is only for sharing a rendered video from any tool, including drafts and work in progress: video versions and comments, no editable source; it needs login and ffprobe but no init, Git, or project.
5653
- 4. When asked to apply feedback: osmo studio comments list --open --json. Check the scene/timestamp and any older revision; edit and validate the fix. If complete and verified, reply only "Done." with osmo studio comments reply ID "Done." then run osmo studio comments resolve ID. Do not leave routine fixes open for review. Keep open only for ambiguity, an incomplete fix, or a specific uncertainty needing user verification. Reply with one short sentence stating the blocker or what to check.
5654
- ${end}
5655
- `;
5656
- async function optional(file) {
5657
- try {
5658
- return await readFile2(file, "utf8");
5659
- } catch (error) {
5660
- if (error.code === "ENOENT") return "";
5661
- throw error;
5662
- }
5663
- }
5664
- async function writeAuthoringGuide(root) {
5665
- const file = path4.join(root, "AGENTS.md");
5666
- const original = await optional(file);
5667
- const hasBegin = original.includes(begin), hasEnd = original.includes(end);
5668
- if (hasBegin !== hasEnd || hasBegin && original.indexOf(end) < original.indexOf(begin))
5669
- throw new Error(
5670
- "AGENTS.md has an incomplete Osmo section; repair its osmo:begin/osmo:end markers before running init."
5671
- );
5672
- let text2;
5673
- if (hasBegin)
5674
- text2 = original.slice(0, original.indexOf(begin)) + authoringGuide.trimEnd() + original.slice(original.indexOf(end) + end.length);
5675
- else {
5676
- const rest = original.split("\n").filter((line) => line !== legacyPointer).join("\n");
5677
- text2 = rest + (rest && !rest.endsWith("\n\n") ? rest.endsWith("\n") ? "\n" : "\n\n" : "") + authoringGuide;
5678
- }
5679
- if (text2 !== original) await writeFile2(file, text2);
5680
- const legacy = await optional(path4.join(root, "osmo.md"));
5681
- if (legacy && createHash2("sha256").update(legacy).digest("hex") === legacyGuideHash)
5682
- await rm2(path4.join(root, "osmo.md"));
5683
- else if (legacy)
5684
- console.log(
5685
- "Existing osmo.md was preserved because it differs from the original generated guide. Osmo instructions now live in AGENTS.md."
5686
- );
5687
- }
5688
-
5689
- // src/init.ts
5690
- import os2 from "node:os";
5691
-
5692
- // src/starter.ts
5693
- init_runtime();
5694
- import { access, mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
5695
- import path5 from "node:path";
5696
- var template = path5.join(packageRoot, "src/starter/");
5697
- async function writeStarter(root) {
5698
- const scene = path5.join(root, "scene.tsx");
5699
- try {
5700
- await access(scene);
5701
- return;
5702
- } catch (error) {
5703
- if (error.code !== "ENOENT") throw error;
5704
- }
5705
- await mkdir2(path5.join(root, "animations"), { recursive: true });
5706
- try {
5707
- await writeFile3(scene, await readFile3(path5.join(template, "scene.tsx")), {
5708
- flag: "wx"
5709
- });
5710
- } catch (error) {
5711
- if (error.code !== "EEXIST") throw error;
5712
- }
5713
- }
5714
-
5715
- // src/init.ts
5716
- init_runtime();
5717
- import { spawnSync as spawnSync3 } from "node:child_process";
5718
-
5719
5318
  // src/git.ts
5720
5319
  import { execFileSync, spawnSync } from "node:child_process";
5721
- import { createHash as createHash3 } from "node:crypto";
5320
+ import { createHash as createHash2 } from "node:crypto";
5722
5321
  import {
5723
5322
  existsSync,
5724
5323
  mkdirSync,
@@ -5727,13 +5326,13 @@ import {
5727
5326
  rmSync,
5728
5327
  writeFileSync
5729
5328
  } from "node:fs";
5730
- import path6 from "node:path";
5329
+ import path4 from "node:path";
5731
5330
  import zlib from "node:zlib";
5732
5331
  var git = (root, ...args) => execFileSync("git", ["-C", root, ...args], {
5733
5332
  encoding: "utf8",
5734
5333
  maxBuffer: 1 << 28
5735
5334
  }).replace(/\n$/, "");
5736
- var hasRepo = (root) => existsSync(path6.join(root, ".git"));
5335
+ var hasRepo = (root) => existsSync(path4.join(root, ".git"));
5737
5336
  var initRepo = (root) => git(root, "init", "-q", "--object-format=sha256", "-b", "main");
5738
5337
  var headOf = (root) => {
5739
5338
  const r = spawnSync(
@@ -5754,7 +5353,7 @@ var objectFormat = (root) => {
5754
5353
  );
5755
5354
  return r.status === 0 ? r.stdout.trim() : null;
5756
5355
  };
5757
- var objectId = (kind, content) => createHash3("sha256").update(Buffer.from(`${kind} ${content.length}\0`)).update(content).digest("hex");
5356
+ var objectId = (kind, content) => createHash2("sha256").update(Buffer.from(`${kind} ${content.length}\0`)).update(content).digest("hex");
5758
5357
  var identity = (author2) => author2 && typeof author2 === "object" ? [`user.name=${author2.name || author2.email}`, `user.email=${author2.email}`] : [
5759
5358
  `user.name=${author2 ?? process.env.USER ?? "osmo"}`,
5760
5359
  `user.email=${author2 ?? process.env.USER ?? "osmo"}@local`
@@ -5844,13 +5443,13 @@ function objectInfo(root, hashes) {
5844
5443
  }
5845
5444
  return out;
5846
5445
  }
5847
- var looseFile = (root, hash3) => path6.join(root, ".git/objects", hash3.slice(0, 2), hash3.slice(2));
5446
+ var looseFile = (root, hash3) => path4.join(root, ".git/objects", hash3.slice(0, 2), hash3.slice(2));
5848
5447
  function looseObjectValid(root, hash3) {
5849
5448
  const file = looseFile(root, hash3);
5850
5449
  if (!existsSync(file)) return null;
5851
5450
  try {
5852
5451
  const inflated = zlib.inflateSync(readFileSync(file));
5853
- if (createHash3("sha256").update(inflated).digest("hex") === hash3)
5452
+ if (createHash2("sha256").update(inflated).digest("hex") === hash3)
5854
5453
  return true;
5855
5454
  } catch {
5856
5455
  }
@@ -5873,7 +5472,7 @@ function writeLooseObjects(root, objects) {
5873
5472
  );
5874
5473
  if (looseObjectValid(root, hash3)) continue;
5875
5474
  const file = looseFile(root, hash3);
5876
- mkdirSync(path6.dirname(file), { recursive: true });
5475
+ mkdirSync(path4.dirname(file), { recursive: true });
5877
5476
  const temporary = `${file}.${process.pid}.tmp`;
5878
5477
  writeFileSync(
5879
5478
  temporary,
@@ -5887,21 +5486,13 @@ function writeLooseObjects(root, objects) {
5887
5486
  return written;
5888
5487
  }
5889
5488
 
5890
- // src/cloud.ts
5891
- import { existsSync as existsSync2 } from "node:fs";
5892
- import { mkdir as mkdir3, readdir, readFile as readFile4, rm as rm3 } from "node:fs/promises";
5893
- import path8 from "node:path";
5894
- import { spawnSync as spawnSync2 } from "node:child_process";
5895
- import ts2 from "typescript";
5896
- init_runtime();
5897
-
5898
5489
  // src/outline.ts
5899
5490
  init_runtime();
5900
5491
  init_labels();
5901
- import ts from "typescript";
5902
- import path7 from "node:path";
5903
- import { pathToFileURL } from "node:url";
5904
- var { isClipPrimitive } = await import(pathToFileURL(path7.join(runtimeRoot, "packages/scene/src/clip.mjs")).href);
5492
+ import ts2 from "typescript";
5493
+ import path5 from "node:path";
5494
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
5495
+ var { isClipPrimitive } = await import(pathToFileURL2(path5.join(runtimeRoot, "packages/scene/src/clip.mjs")).href);
5905
5496
  var Reference = class {
5906
5497
  constructor(set, name = "") {
5907
5498
  this.set = set;
@@ -5923,35 +5514,35 @@ function merge(ranges) {
5923
5514
  return result;
5924
5515
  }
5925
5516
  function inspectScene(text2, filename2, meta, definitions = {}) {
5926
- const ast = ts.createSourceFile(
5517
+ const ast = ts2.createSourceFile(
5927
5518
  filename2,
5928
5519
  text2,
5929
- ts.ScriptTarget.Latest,
5520
+ ts2.ScriptTarget.Latest,
5930
5521
  true,
5931
- ts.ScriptKind.TSX
5522
+ ts2.ScriptKind.TSX
5932
5523
  );
5933
5524
  const motions = /* @__PURE__ */ new Map(), knobs = /* @__PURE__ */ new Map(), rows = [], byId = /* @__PURE__ */ new Map(), used = /* @__PURE__ */ new Set();
5934
5525
  const line = (n) => ast.getLineAndCharacterOfPosition(n.getStart(ast)).line + 1;
5935
5526
  function read(n) {
5936
5527
  if (!n) return true;
5937
- if (ts.isJsxExpression(n) || ts.isParenthesizedExpression(n))
5528
+ if (ts2.isJsxExpression(n) || ts2.isParenthesizedExpression(n))
5938
5529
  return read(n.expression);
5939
- if (ts.isStringLiteral(n) || ts.isNumericLiteral(n))
5940
- return ts.isNumericLiteral(n) ? Number(n.text) : n.text;
5941
- if (n.kind === ts.SyntaxKind.TrueKeyword) return true;
5942
- if (n.kind === ts.SyntaxKind.FalseKeyword) return false;
5943
- if (n.kind === ts.SyntaxKind.NullKeyword) return null;
5944
- if (ts.isPrefixUnaryExpression(n))
5945
- return n.operator === ts.SyntaxKind.MinusToken ? -read(n.operand) : read(n.operand);
5946
- if (ts.isArrayLiteralExpression(n)) return n.elements.map(read);
5947
- if (ts.isObjectLiteralExpression(n))
5530
+ if (ts2.isStringLiteral(n) || ts2.isNumericLiteral(n))
5531
+ return ts2.isNumericLiteral(n) ? Number(n.text) : n.text;
5532
+ if (n.kind === ts2.SyntaxKind.TrueKeyword) return true;
5533
+ if (n.kind === ts2.SyntaxKind.FalseKeyword) return false;
5534
+ if (n.kind === ts2.SyntaxKind.NullKeyword) return null;
5535
+ if (ts2.isPrefixUnaryExpression(n))
5536
+ return n.operator === ts2.SyntaxKind.MinusToken ? -read(n.operand) : read(n.operand);
5537
+ if (ts2.isArrayLiteralExpression(n)) return n.elements.map(read);
5538
+ if (ts2.isObjectLiteralExpression(n))
5948
5539
  return Object.fromEntries(
5949
- n.properties.filter(ts.isPropertyAssignment).map((p) => [p.name.text, read(p.initializer)])
5540
+ n.properties.filter(ts2.isPropertyAssignment).map((p) => [p.name.text, read(p.initializer)])
5950
5541
  );
5951
- if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression))
5542
+ if (ts2.isPropertyAccessExpression(n) && ts2.isIdentifier(n.expression))
5952
5543
  return new Reference(n.expression.text, n.name.text);
5953
- if (ts.isIdentifier(n)) return new Reference(n.text);
5954
- if (ts.isCallExpression(n))
5544
+ if (ts2.isIdentifier(n)) return new Reference(n.text);
5545
+ if (ts2.isCallExpression(n))
5955
5546
  return {
5956
5547
  effect: n.expression.getText(ast),
5957
5548
  inputs: read(n.arguments[0])
@@ -5959,10 +5550,10 @@ function inspectScene(text2, filename2, meta, definitions = {}) {
5959
5550
  return void 0;
5960
5551
  }
5961
5552
  for (const st of ast.statements)
5962
- if (ts.isVariableStatement(st))
5553
+ if (ts2.isVariableStatement(st))
5963
5554
  for (const d of st.declarationList.declarations) {
5964
5555
  const call2 = d.initializer;
5965
- if (!call2 || !ts.isCallExpression(call2)) continue;
5556
+ if (!call2 || !ts2.isCallExpression(call2)) continue;
5966
5557
  const definition = read(call2.arguments[0]);
5967
5558
  if (call2.expression.getText(ast) === "defineKnobs")
5968
5559
  knobs.set(d.name.text, definition);
@@ -6015,18 +5606,18 @@ function inspectScene(text2, filename2, meta, definitions = {}) {
6015
5606
  }
6016
5607
  }
6017
5608
  function visit(n, parent, depth, scope, clock = { offset: 0, start: 0, end: meta.duration }) {
6018
- if (ts.isParenthesizedExpression(n) || ts.isJsxExpression(n))
5609
+ if (ts2.isParenthesizedExpression(n) || ts2.isJsxExpression(n))
6019
5610
  return n.expression ? visit(n.expression, parent, depth, scope, clock) : [];
6020
- if (ts.isJsxFragment(n))
5611
+ if (ts2.isJsxFragment(n))
6021
5612
  return n.children.flatMap((c) => visit(c, parent, depth, scope, clock));
6022
- if (!ts.isJsxElement(n) && !ts.isJsxSelfClosingElement(n)) return [];
6023
- const opening3 = ts.isJsxElement(n) ? n.openingElement : n;
5613
+ if (!ts2.isJsxElement(n) && !ts2.isJsxSelfClosingElement(n)) return [];
5614
+ const opening3 = ts2.isJsxElement(n) ? n.openingElement : n;
6024
5615
  const tag = opening3.tagName.getText(ast), definition = definitions[tag];
6025
5616
  const type = definition ? "Group" : tag;
6026
5617
  if (type === "Fragment")
6027
- return ts.isJsxElement(n) ? n.children.flatMap((c) => visit(c, parent, depth, scope, clock)) : [];
5618
+ return ts2.isJsxElement(n) ? n.children.flatMap((c) => visit(c, parent, depth, scope, clock)) : [];
6028
5619
  const props = Object.fromEntries(
6029
- opening3.attributes.properties.filter(ts.isJsxAttribute).map((a) => [a.name.text, read(a.initializer)])
5620
+ opening3.attributes.properties.filter(ts2.isJsxAttribute).map((a) => [a.name.text, read(a.initializer)])
6030
5621
  );
6031
5622
  if (parent === null && type === "Group") props.start = 0;
6032
5623
  const id = typeof props.id === "string" ? `source:${props.id}` : `node-${rows.length}`, local = new Map(scope);
@@ -6046,14 +5637,14 @@ function inspectScene(text2, filename2, meta, definitions = {}) {
6046
5637
  if (!["knobs", "values"].includes(name))
6047
5638
  bindings(value, name, local, propertyTracks);
6048
5639
  if (ownKnobs) bindings(props.values, "knobs", scope, propertyTracks);
6049
- const rawChildren = ts.isJsxElement(n) ? n.children : [];
5640
+ const rawChildren = ts2.isJsxElement(n) ? n.children : [];
6050
5641
  if (type === "Text") {
6051
5642
  for (const child of rawChildren)
6052
- if (ts.isJsxExpression(child) && child.expression)
5643
+ if (ts2.isJsxExpression(child) && child.expression)
6053
5644
  bindings(read(child), "text", local, propertyTracks);
6054
5645
  }
6055
5646
  const words = type === "Text" ? rawChildren.map(
6056
- (c) => ts.isJsxText(c) ? c.text : ts.isJsxExpression(c) ? resolve(read(c), local).value ?? "" : ""
5647
+ (c) => ts2.isJsxText(c) ? c.text : ts2.isJsxExpression(c) ? resolve(read(c), local).value ?? "" : ""
6057
5648
  ).join(" ").replace(/\s+/g, " ").trim() : "";
6058
5649
  const media = ["Video", "Audio"].includes(type);
6059
5650
  const timedGroup = isClipPrimitive(type) && (props.start !== void 0 || props.duration !== void 0 || props.track !== void 0 || definition?.duration !== void 0 || definition?.start !== void 0);
@@ -6090,7 +5681,7 @@ function inspectScene(text2, filename2, meta, definitions = {}) {
6090
5681
  parent,
6091
5682
  depth,
6092
5683
  type,
6093
- label: words ? `\u201C${words.slice(0, 64)}${words.length > 64 ? "\u2026" : ""}\u201D` : typeof props.src === "string" ? path7.basename(props.src) : type,
5684
+ label: words ? `\u201C${words.slice(0, 64)}${words.length > 64 ? "\u2026" : ""}\u201D` : typeof props.src === "string" ? path5.basename(props.src) : type,
6094
5685
  line: line(opening3),
6095
5686
  sourceStart: opening3.getStart(ast),
6096
5687
  start,
@@ -6145,7 +5736,7 @@ function inspectScene(text2, filename2, meta, definitions = {}) {
6145
5736
  return [id];
6146
5737
  }
6147
5738
  for (const st of ast.statements)
6148
- if (ts.isExportAssignment(st)) visit(st.expression, null, 0, /* @__PURE__ */ new Map());
5739
+ if (ts2.isExportAssignment(st)) visit(st.expression, null, 0, /* @__PURE__ */ new Map());
6149
5740
  function mapPaths(row, runtimePath) {
6150
5741
  row.runtimePath = runtimePath;
6151
5742
  let index = 0;
@@ -6198,8 +5789,123 @@ function reviewManifest(text2, scene, meta, revision2) {
6198
5789
  )
6199
5790
  };
6200
5791
  }
6201
-
6202
- // src/cloud.ts
5792
+
5793
+ // src/cloud.ts
5794
+ init_runtime();
5795
+
5796
+ // src/upload-render.ts
5797
+ import { openAsBlob } from "node:fs";
5798
+ async function uploadRender(origin, project, file, metadata, log = () => {
5799
+ }) {
5800
+ const video = await openAsBlob(file, { type: metadata.contentType });
5801
+ if (!video.size || video.size > 2 * 1024 ** 3)
5802
+ throw new Error("Video must be non-empty and no larger than 2 GB.");
5803
+ let slot;
5804
+ try {
5805
+ log(`uploading video (${(video.size / 1048576).toFixed(1)} MB)\u2026`);
5806
+ slot = await call(
5807
+ origin,
5808
+ `/api/projects/${encodeURIComponent(project)}/render`,
5809
+ {
5810
+ method: "POST",
5811
+ body: { ...metadata, sizeInBytes: video.size }
5812
+ }
5813
+ );
5814
+ if (slot.type !== "success")
5815
+ throw new Error(slot.error ?? "The cloud did not accept the video.");
5816
+ const put = await fetch(slot.uploadUrl, {
5817
+ method: "PUT",
5818
+ headers: { "Content-Type": metadata.contentType },
5819
+ body: video,
5820
+ redirect: "error",
5821
+ signal: AbortSignal.timeout(9e5)
5822
+ });
5823
+ if (!put.ok) throw new Error(`Video upload failed (${put.status}).`);
5824
+ const render = slot.renderRowId;
5825
+ const done = await call(origin, `/api/renders/${render}/complete`, {
5826
+ method: "POST",
5827
+ body: { fileKey: slot.fileKey }
5828
+ });
5829
+ if (done.type !== "success")
5830
+ throw new Error(done.error ?? "The cloud did not finalize the video.");
5831
+ slot = null;
5832
+ if (done.sharingEnabled === false)
5833
+ throw new Error(
5834
+ "Video saved, but review sharing was disabled after this upload started. Upload again to restore sharing."
5835
+ );
5836
+ return {
5837
+ status: "deployed",
5838
+ project,
5839
+ render,
5840
+ link: `${origin}/share/project/${project}`,
5841
+ versionLink: `${origin}/share/${render}`
5842
+ };
5843
+ } finally {
5844
+ if (slot?.renderRowId)
5845
+ await call(origin, `/api/renders/${slot.renderRowId}/fail`, {
5846
+ method: "POST",
5847
+ body: { error: "deploy did not complete" }
5848
+ }).catch(() => void 0);
5849
+ }
5850
+ }
5851
+
5852
+ // src/cloud.ts
5853
+ var sourceAccessLevels = [
5854
+ "private",
5855
+ "signed-in-view",
5856
+ "signed-in-edit"
5857
+ ];
5858
+ function parseSourceAccess(value) {
5859
+ if (!sourceAccessLevels.includes(value))
5860
+ throw new Error(
5861
+ "Invalid --access. Use private, signed-in-view, or signed-in-edit."
5862
+ );
5863
+ return value;
5864
+ }
5865
+ async function permissions(root, action = "get", options = {}) {
5866
+ if (action === "set") parseSourceAccess(options.access ?? "");
5867
+ if (["invite", "revoke"].includes(action) && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(options.email ?? ""))
5868
+ throw new Error("Provide a valid collaborator email.");
5869
+ if (action === "invite" && !["viewer", "editor"].includes(options.role ?? ""))
5870
+ throw new Error("Use --role viewer or --role editor.");
5871
+ if (!["get", "set", "invite", "revoke"].includes(action))
5872
+ throw new Error("Unknown permissions action.");
5873
+ const { origin, id } = await cloudOf(root);
5874
+ const base = `/api/store/projects/${encodeURIComponent(id)}/permissions`;
5875
+ if (action === "set")
5876
+ await call(origin, `${base}/access`, {
5877
+ method: "PUT",
5878
+ body: { access: options.access }
5879
+ });
5880
+ if (action === "invite")
5881
+ await call(origin, `${base}/grants`, {
5882
+ method: "PUT",
5883
+ body: { email: options.email, role: options.role }
5884
+ });
5885
+ if (action === "revoke")
5886
+ await call(
5887
+ origin,
5888
+ `${base}/grants/${encodeURIComponent(options.email.trim().toLowerCase())}`,
5889
+ { method: "DELETE" }
5890
+ );
5891
+ const result = await call(origin, base);
5892
+ parseSourceAccess(result.access);
5893
+ return {
5894
+ ...result,
5895
+ link: `${origin}/share/project/${id}`,
5896
+ ...action === "revoke" ? {
5897
+ note: "Removed the direct Studio grant. Workspace, project membership, and signed-in access still apply."
5898
+ } : {}
5899
+ };
5900
+ }
5901
+ function formatPermissions(result) {
5902
+ return `Source access: ${result.access}
5903
+ Your role: ${result.role}
5904
+ Review: ${result.link}` + (result.grants?.length ? `
5905
+ Collaborators:
5906
+ ${result.grants.map((g) => ` ${g.email}: ${g.role}`).join("\n")}` : "") + (result.note ? `
5907
+ ${result.note}` : "");
5908
+ }
6203
5909
  var PROJECT_ID = /^[a-z0-9]{10,40}$/;
6204
5910
  function validOrigin(value) {
6205
5911
  try {
@@ -6420,7 +6126,8 @@ function uncommittedCount(root) {
6420
6126
  (l) => l && !l.slice(3).startsWith(".osmo/") && !excluded(l.slice(3))
6421
6127
  ).length;
6422
6128
  }
6423
- async function publish(root, { name } = {}) {
6129
+ async function publish(root, { name, access: access3 = "private" } = {}) {
6130
+ parseSourceAccess(access3);
6424
6131
  if (!hasRepo(root))
6425
6132
  throw new Error(
6426
6133
  "No repository in this project. Run osmo studio init first."
@@ -6438,15 +6145,21 @@ async function publish(root, { name } = {}) {
6438
6145
  await record(root, "record before publish", origin);
6439
6146
  const project = await call(origin, "/api/store/projects", {
6440
6147
  method: "POST",
6441
- body: { name: name ?? path8.basename(root) }
6148
+ body: { name: name ?? path6.basename(root), access: access3 }
6442
6149
  });
6443
6150
  rememberPointer(root, { origin, id: project.id });
6151
+ if ((project.access ?? "private") !== access3)
6152
+ throw new Error(
6153
+ "The server did not confirm the requested source access. Update the backend before deploying; the project connection has been saved."
6154
+ );
6444
6155
  return {
6445
6156
  ...await sync(root, { record: false }),
6446
6157
  id: project.id,
6447
6158
  name: project.name,
6448
6159
  origin,
6449
- page: `${origin}/app/projects/${project.id}`
6160
+ page: `${origin}/app/projects/${project.id}`,
6161
+ access: project.access ?? "private",
6162
+ link: `${origin}/share/project/${project.id}`
6450
6163
  };
6451
6164
  }
6452
6165
  async function push(root, { log = () => {
@@ -6645,7 +6358,7 @@ async function clone(id, dir, origin = authOrigin()) {
6645
6358
  throw new Error(
6646
6359
  info.kind === "review" ? "This is a video-only review with no editable source to clone. Use osmo studio deploy to publish full projects with source and Git history." : "This project was made in the web editor. The CLI can only clone projects published from the CLI."
6647
6360
  );
6648
- await mkdir3(dir, { recursive: true });
6361
+ await mkdir2(dir, { recursive: true });
6649
6362
  try {
6650
6363
  initRepo(dir);
6651
6364
  const pack = await call(
@@ -6668,7 +6381,7 @@ async function clone(id, dir, origin = authOrigin()) {
6668
6381
  dir
6669
6382
  };
6670
6383
  } catch (error) {
6671
- await rm3(dir, { recursive: true, force: true });
6384
+ await rm2(dir, { recursive: true, force: true });
6672
6385
  throw error;
6673
6386
  }
6674
6387
  }
@@ -6791,28 +6504,28 @@ async function sync(root, {
6791
6504
  return { ...result, steps };
6792
6505
  }
6793
6506
  function sceneMeta(source, text2) {
6794
- const sf = ts2.createSourceFile(
6507
+ const sf = ts3.createSourceFile(
6795
6508
  source,
6796
6509
  text2,
6797
- ts2.ScriptTarget.ES2022,
6510
+ ts3.ScriptTarget.ES2022,
6798
6511
  true,
6799
- ts2.ScriptKind.TSX
6512
+ ts3.ScriptKind.TSX
6800
6513
  );
6801
6514
  let meta = null;
6802
6515
  const visit = (node) => {
6803
6516
  if (meta) return;
6804
- if ((ts2.isJsxOpeningElement(node) || ts2.isJsxSelfClosingElement(node)) && node.tagName.getText() === "Scene") {
6517
+ if ((ts3.isJsxOpeningElement(node) || ts3.isJsxSelfClosingElement(node)) && node.tagName.getText() === "Scene") {
6805
6518
  const props = {};
6806
6519
  for (const attr of node.attributes.properties) {
6807
- if (!ts2.isJsxAttribute(attr) || !attr.initializer || !ts2.isJsxExpression(attr.initializer) || !attr.initializer.expression)
6520
+ if (!ts3.isJsxAttribute(attr) || !attr.initializer || !ts3.isJsxExpression(attr.initializer) || !attr.initializer.expression)
6808
6521
  continue;
6809
6522
  const e = attr.initializer.expression;
6810
- if (ts2.isNumericLiteral(e)) props[attr.name.getText()] = Number(e.text);
6523
+ if (ts3.isNumericLiteral(e)) props[attr.name.getText()] = Number(e.text);
6811
6524
  }
6812
6525
  meta = props;
6813
6526
  return;
6814
6527
  }
6815
- ts2.forEachChild(node, visit);
6528
+ ts3.forEachChild(node, visit);
6816
6529
  };
6817
6530
  visit(sf);
6818
6531
  if (!meta || !(meta.width > 0) || !(meta.height > 0) || !(meta.duration > 0))
@@ -6832,26 +6545,50 @@ async function deploy(root, {
6832
6545
  check: check2,
6833
6546
  log = () => {
6834
6547
  },
6835
- quiet = false
6548
+ quiet = false,
6549
+ access: access3
6836
6550
  }) {
6551
+ if (access3 !== void 0) parseSourceAccess(access3);
6552
+ const existing = pointerFromGit(root);
6553
+ if (existing && access3 !== void 0)
6554
+ throw new Error(
6555
+ `This project is already published. Change access with osmo studio permissions set --access ${access3}.`
6556
+ );
6837
6557
  const result = await check2(source);
6838
6558
  if (!result.ok) throw new Error(result.errors.join("\n"));
6839
- const { origin, id } = await cloudOf(root);
6840
- const cacheDir = path8.join(root, ".osmo/cache");
6841
- await mkdir3(cacheDir, { recursive: true });
6842
- const out = path8.join(cacheDir, `deploy-${Date.now()}.mp4`);
6559
+ const currentPermissions = existing ? await call(
6560
+ existing.origin,
6561
+ `/api/store/projects/${encodeURIComponent(existing.id)}`
6562
+ ) : void 0;
6563
+ if (currentPermissions?.role === "viewer")
6564
+ throw new Error(
6565
+ "This project is read-only. Ask its owner for editor access before deploying."
6566
+ );
6567
+ let effectiveAccess = currentPermissions?.access ?? access3 ?? "private";
6568
+ const cacheDir = path6.join(root, ".osmo/cache");
6569
+ await mkdir2(cacheDir, { recursive: true });
6570
+ const out = path6.join(cacheDir, `deploy-${Date.now()}.mp4`);
6843
6571
  log("rendering\u2026");
6844
6572
  try {
6845
6573
  await run(nativeBinary(), [source, "--export", out, "--supersample", "2"], {
6846
6574
  env: environment(),
6847
6575
  stdio: quiet ? ["ignore", "ignore", "inherit"] : "inherit"
6848
6576
  });
6577
+ if (!existing) {
6578
+ const published = await publish(root, { access: effectiveAccess });
6579
+ effectiveAccess = published.access;
6580
+ if (!["pushed", "up-to-date"].includes(published.status))
6581
+ throw new Error(
6582
+ `Project created but initial synchronization failed (${published.status}); run osmo studio sync before deploying again.`
6583
+ );
6584
+ }
6585
+ const { origin, id } = await cloudOf(root);
6849
6586
  if (!isClean(root)) await record(root, `deploy: ${scene}`, origin);
6850
6587
  const pushed = await push(root, { log });
6851
6588
  if (pushed.status !== "pushed" && pushed.status !== "up-to-date")
6852
6589
  return { ...pushed, hint: "osmo studio sync, then deploy again" };
6853
6590
  const head = headOf(root);
6854
- const sceneText = await readFile4(source, "utf8");
6591
+ const sceneText = await readFile2(source, "utf8");
6855
6592
  const meta = sceneMeta(source, sceneText);
6856
6593
  const review = reviewManifest(sceneText, scene, meta, head);
6857
6594
  const uploaded = await uploadRender(
@@ -6867,7 +6604,7 @@ async function deploy(root, {
6867
6604
  reviewManifest: review,
6868
6605
  contentType: "video/mp4",
6869
6606
  source: "cli",
6870
- name: `${path8.basename(root)}.mp4`,
6607
+ name: `${path6.basename(root)}.mp4`,
6871
6608
  gitCommit: head
6872
6609
  },
6873
6610
  log
@@ -6875,6 +6612,7 @@ async function deploy(root, {
6875
6612
  const renderId = uploaded.render;
6876
6613
  return {
6877
6614
  status: "deployed",
6615
+ access: effectiveAccess,
6878
6616
  head,
6879
6617
  link: `${origin}/share/project/${id}`,
6880
6618
  versionLink: `${origin}/share/${renderId}`,
@@ -6884,7 +6622,7 @@ async function deploy(root, {
6884
6622
  uploaded: pushed.uploaded ?? 0
6885
6623
  };
6886
6624
  } finally {
6887
- await rm3(out, { force: true });
6625
+ await rm2(out, { force: true });
6888
6626
  }
6889
6627
  }
6890
6628
  async function publicJson(url) {
@@ -6960,85 +6698,310 @@ async function cloudCommentAction(root, id, action, text2) {
6960
6698
  const { origin } = await cloudOf(root);
6961
6699
  return reviewCommentAction(origin, comment, action, text2);
6962
6700
  }
6963
- async function reviewCommentAction(origin, comment, action, text2) {
6964
- const post = async (route, body2, allowMissing = false) => {
6965
- const response = await fetch(`${origin}${route}`, {
6966
- method: "POST",
6967
- headers: { "Content-Type": "application/json" },
6968
- body: JSON.stringify(body2),
6969
- signal: AbortSignal.timeout(3e4)
6970
- });
6971
- if (allowMissing && response.status === 404) return null;
6972
- if (!response.ok)
6973
- throw new Error(
6974
- `Cloud ${response.status}: ${(await response.json().catch(() => ({}))).error ?? "request failed"}`
6975
- );
6976
- return response.json().catch(() => ({}));
6977
- };
6978
- if (action === "reply") {
6979
- const me = await currentAccount(origin);
6980
- const name = me.user.name || me.user.email;
6981
- const reply = await post(
6982
- `/api/renders/${comment.render}/review-comments`,
6983
- {
6984
- scene: comment.scene,
6985
- revision: comment.revision,
6986
- time: comment.time,
6987
- text: text2,
6988
- author: { name: name.slice(0, 80) },
6989
- parentId: comment.id
6990
- },
6991
- true
6701
+ async function reviewCommentAction(origin, comment, action, text2) {
6702
+ const post = async (route, body2, allowMissing = false) => {
6703
+ const response = await fetch(`${origin}${route}`, {
6704
+ method: "POST",
6705
+ headers: { "Content-Type": "application/json" },
6706
+ body: JSON.stringify(body2),
6707
+ signal: AbortSignal.timeout(3e4)
6708
+ });
6709
+ if (allowMissing && response.status === 404) return null;
6710
+ if (!response.ok)
6711
+ throw new Error(
6712
+ `Cloud ${response.status}: ${(await response.json().catch(() => ({}))).error ?? "request failed"}`
6713
+ );
6714
+ return response.json().catch(() => ({}));
6715
+ };
6716
+ if (action === "reply") {
6717
+ const me = await currentAccount(origin);
6718
+ const name = me.user.name || me.user.email;
6719
+ const reply = await post(
6720
+ `/api/renders/${comment.render}/review-comments`,
6721
+ {
6722
+ scene: comment.scene,
6723
+ revision: comment.revision,
6724
+ time: comment.time,
6725
+ text: text2,
6726
+ author: { name: name.slice(0, 80) },
6727
+ parentId: comment.id
6728
+ },
6729
+ true
6730
+ );
6731
+ if (reply === null)
6732
+ await post(`/api/renders/${comment.render}/comments`, {
6733
+ body: text2,
6734
+ frame: comment.frame,
6735
+ authorName: name,
6736
+ parentId: comment.id
6737
+ });
6738
+ return { id: comment.id, status: comment.status };
6739
+ }
6740
+ await post(`/api/renders/${comment.render}/comments/${comment.id}/resolve`, {
6741
+ resolved: action === "resolve"
6742
+ });
6743
+ return { id: comment.id, status: action === "resolve" ? "resolved" : "open" };
6744
+ }
6745
+ function format(result) {
6746
+ const steps = result.steps?.length ? `
6747
+ ${result.steps.join(" \u2192 ")}` : "";
6748
+ const pending = result.uncommitted ? `
6749
+ ${result.uncommitted} uncommitted file${result.uncommitted === 1 ? "" : "s"} not included; run osmo studio sync to record and push them.` : "";
6750
+ return formatStatus(result) + steps + pending;
6751
+ }
6752
+ function formatStatus(result) {
6753
+ switch (result.status) {
6754
+ case "pushed":
6755
+ return `Pushed ${result.commits} revision${result.commits === 1 ? "" : "s"} (${result.objects} objects${result.uploaded ? `, ${result.uploaded} media file${result.uploaded === 1 ? "" : "s"}` : ""}). Head ${result.head.slice(0, 12)}.`;
6756
+ case "fast-forward":
6757
+ return `Pulled ${result.revisions} revision${result.revisions === 1 ? "" : "s"}. Head ${result.head.slice(0, 12)}.`;
6758
+ case "up-to-date":
6759
+ return `Up to date${result.head ? ` at ${result.head.slice(0, 12)}` : ""}.`;
6760
+ case "refused":
6761
+ return `Refused: ${result.reason}. Cloud is at ${result.cloud?.slice(0, 12)}, you are at ${result.head.slice(0, 12)}. Run ${result.hint}.`;
6762
+ case "invalid":
6763
+ return `The cloud refused the revision: ${result.reason}.
6764
+ ${result.hint === "osmo studio sync" ? "Run osmo studio sync: it folds your unpushed revisions into one and tries again." : `Then ${result.hint}.`}`;
6765
+ case "diverged":
6766
+ return `Diverged: you are at ${result.head.slice(0, 12)}, the cloud at ${result.cloud.slice(0, 12)}. Run ${result.hint}.`;
6767
+ case "conflict":
6768
+ return `Conflict: the cloud's changes overlap yours. ${result.hint}.`;
6769
+ case "history":
6770
+ return result.log;
6771
+ case "deployed":
6772
+ return `Source access: ${result.access}
6773
+ Deployed ${result.head.slice(0, 12)} (${result.pushed}${result.commits ? `, ${result.commits} revision${result.commits === 1 ? "" : "s"}` : ""}).
6774
+ Review: ${result.link}`;
6775
+ default:
6776
+ return JSON.stringify(result);
6777
+ }
6778
+ }
6779
+
6780
+ // src/cli.ts
6781
+ init_comments();
6782
+
6783
+ // src/features.ts
6784
+ function studioEnabled(env = process.env) {
6785
+ return env.OSMO_STUDIO_ENABLED === "1";
6786
+ }
6787
+ function requireStudio() {
6788
+ if (!studioEnabled())
6789
+ throw Object.assign(
6790
+ new Error("Osmo Studio commands are not available in this release."),
6791
+ { code: "feature_disabled" }
6792
+ );
6793
+ }
6794
+
6795
+ // src/help.ts
6796
+ function cliHelp(version2, studio) {
6797
+ const common = `Osmo ${version2}
6798
+
6799
+ osmo login [--name COMPUTER] [--json]
6800
+ osmo login status SESSION [--json]
6801
+ osmo whoami [--json]
6802
+ osmo logout [--json]
6803
+ osmo resources list [--category shader] [--json]
6804
+ osmo resources show ID [--version VERSION] [--preset PRESET] [--json]
6805
+ osmo resources add ID [--version VERSION] [--preset PRESET] [--json]
6806
+ osmo resources import FILE [--json]
6807
+ osmo resources local [--json]
6808
+ osmo resources examples [--json]
6809
+ osmo share --video FILE.mp4 [--project ID] [--name NAME] [--json]
6810
+ osmo unshare --project ID [--json]
6811
+ osmo comments list --project ID [--open] [--json]
6812
+ osmo comments reply ID "message" --project ID
6813
+ osmo comments resolve ID --project ID
6814
+ osmo comments reopen ID --project ID`;
6815
+ const studioCommands = `osmo studio init [folder]
6816
+ osmo studio publish [--name NAME] [--access private|signed-in-view|signed-in-edit]
6817
+ osmo studio push
6818
+ osmo studio pull
6819
+ osmo studio sync
6820
+ osmo studio clone PROJECT_ID folder
6821
+ osmo studio history
6822
+ osmo studio open [scene.tsx] [--port NUMBER] [--no-open]
6823
+ osmo studio open animations/player.tsx [--width 800 --height 450 --duration 4]
6824
+ osmo studio check [scene.tsx] [--json]
6825
+ osmo studio render [scene.tsx] --out animation.mp4 [--supersample 1|2]
6826
+ osmo studio render [scene.tsx] --frame NUMBER --out frame.png [--supersample 1|2]
6827
+ osmo studio deploy [scene.tsx] [--access private|signed-in-view|signed-in-edit]
6828
+ osmo studio permissions [--json]
6829
+ osmo studio permissions set --access private|signed-in-view|signed-in-edit [--json]
6830
+ osmo studio permissions invite EMAIL --role viewer|editor [--json]
6831
+ osmo studio permissions revoke EMAIL [--json]
6832
+ osmo studio comments list [--open] [--scene scene.tsx] [--json]
6833
+ osmo studio comments start ID [EMOJI]
6834
+ osmo studio comments stop ID
6835
+ osmo studio comments react ID [EMOJI|none]
6836
+ osmo studio comments reply ID "message"
6837
+ osmo studio comments resolve ID
6838
+ osmo studio comments reopen ID`;
6839
+ const video = "osmo share --video: share a rendered video from any tool, including drafts and work in progress. Video review and version history only; no editable source. Requires login and ffprobe, not init or Git. Without --project, creates a new review.";
6840
+ return `${common}${studio ? "\n" + studioCommands : ""}
6841
+
6842
+ ${studio ? "Choose the sharing workflow:\n " : ""}${video}${studio ? "\n osmo studio deploy: publish a full-featured editable Osmo project with source files, assets, and tracked Git history, plus a review video. Requires osmo studio init and osmo login. First deploy creates the cloud project; source access defaults to private. Later deploys preserve permissions; use permissions set to change them. Prefer this command for Osmo Studio projects. Edit locally with Studio or code; the cloud review is read-only." : ""}
6843
+ ${studio ? "\nNew animation: run osmo studio init, read AGENTS.md, then resources list --json before authoring.\n" : ""}
6844
+ osmo unshare: disable a project's public review and version links, preserving
6845
+ videos, comments, and history. Sharing a new video to the same project restores its review links.
6846
+ Previously obtained direct media URLs may still work.
6847
+
6848
+ Run project commands from the initialized project folder. Native renderer included
6849
+ in release archives; Node 22 and FFmpeg/ffprobe are prerequisites.
6850
+ Cloud commands need osmo login; OSMO_BASE_URL picks the cloud (default studio.osmo.inc).
6851
+ `;
6852
+ }
6853
+
6854
+ // src/init.ts
6855
+ import {
6856
+ mkdir as mkdir5,
6857
+ readFile as readFile6,
6858
+ writeFile as writeFile5,
6859
+ copyFile,
6860
+ cp,
6861
+ access as access2,
6862
+ mkdtemp,
6863
+ rm as rm5
6864
+ } from "node:fs/promises";
6865
+ import path10 from "node:path";
6866
+
6867
+ // src/authoring.ts
6868
+ init_runtime();
6869
+ import { readFile as readFile4, writeFile as writeFile3, rm as rm4 } from "node:fs/promises";
6870
+ import { createHash as createHash3 } from "node:crypto";
6871
+ import path8 from "node:path";
6872
+ var begin = "<!-- osmo:begin -->";
6873
+ var end = "<!-- osmo:end -->";
6874
+ var legacyPointer = "For Osmo animation tasks, read osmo.md before editing. Use osmo studio check, and read open Studio comments.";
6875
+ var legacyGuideHash = "815dae34cd7e5e6f44a575a88d80516d3736f9b74c76b9379fc09144c6aa06fb";
6876
+ var authoringGuide = `${begin}
6877
+ ## Osmo Studio (${version})
6878
+
6879
+ Project setup: osmo studio init [folder]. During internal preview, Studio commands require OSMO_STUDIO_ENABLED=1.
6880
+
6881
+ TSX is the source of truth. Use the installed @osmo/scene Strict API; do not edit node_modules or the renderer.
6882
+
6883
+ ### Check resources first
6884
+ At the start of every animation task, run osmo resources list --json. Inspect
6885
+ plausible matches with osmo resources show ID --json, including their previews.
6886
+ Prefer a suitable resource over recreating it; install only resources you use with
6887
+ osmo resources add ID --preset PRESET --json. If login is required, run osmo login
6888
+ --json and share the approval link. If the catalog is unavailable, say so and
6889
+ continue locally. Use the CLI compatibility result; older manifests may describe
6890
+ an earlier CLI. After install, use its local files, types, preset values and usage.
6891
+
6892
+ ### Choose the primitive first
6893
+ | Visual pattern | Start with |
6894
+ | --- | --- |
6895
+ | Typography following a circle, curve or figure-eight | One Text with path; animate path.progress |
6896
+ | Elements moving/rotating together | Group transforms and shared motion tracks |
6897
+ | Panels, circles, borders or thin lines | Group fill/stroke/shape/rounding |
6898
+ | Vector curves, icons, cutouts or illustrations | Path with SVG d, fill and stroke; evenodd for holes |
6899
+ | Images, footage, soundtrack | Image, Video, Audio with local asset paths |
6900
+ | Parallax and solid cards | Camera, z and Group.depth |
6901
+ | Content on a 3D model | Model with Surface children; inspect GLB selectors first |
6902
+ | Procedural backgrounds or liquid glass | Check resources; Shader with local src and explicit uniforms; glass samples backdrop |
6903
+ | Softness or texture | effects={[blur({ radius: 5 }), grain({ amount: 0.04 })]} |
6904
+
6905
+ Prototype the native primitive before detailed tracking; explain a tested limitation before custom reconstruction. No approval step needed.
6906
+ ### Layout before coordinates
6907
+ - Use Group row/column, align, justify, gap and padding for labels/cards/icon rows; position places the whole group.
6908
+ - Center labels with Text width/height, align="center", verticalAlign="center". Default bounds are the line box; opt into alignBounds="glyphs" for visible-letter centering. Changing text can shift glyph bounds.
6909
+ - Fit icons into equal Path width/height with fit="contain". Supply viewBox for the asset viewport, or omit it for tight geometry bounds, including negative origins. Fitting scales strokes; fill stretches; unsized paths retain coordinates.
6910
+ - Text and fitted paths can overflow; use a clipping Group to crop. See the sizing JSDoc before applying manual offsets.
6911
+
6912
+ ### API reference: types with short explanations
6913
+ Read relevant declarations in node_modules/@osmo/scene/dist/ (JSDoc explains usage):
6914
+ - index.d.ts: public exports; elements.d.ts: all primitives, props and examples.
6915
+ - motion.d.ts: defineMotion, keyframes and interpolation.
6916
+ - knobs.d.ts + knob-schema.d.ts: Group controls, typed values and UI metadata.
6917
+ - effects.d.ts: built-in effects and the broader library effect API.
6918
+
6919
+ ### Strict rules
6920
+ - Named imports from @osmo/scene; export default <Scene ...> in the scene entry.
6921
+ - Reuse a local default-exported Group: import Card from './animations/card'; <Card track={0} values={{ title: 'Hello' }} />. No children/knobs replacement; imports stay Strict.
6922
+ - Root Group/Text/Image/Video/Audio/Shader/Path/Rect/Model clips require literal track (0\u201399). start/duration are optional seconds; same-track clips cannot overlap. Tracks do not change paint order. Nested layers omit track. Motions use local clip time; trimming never stretches motion or changes media trimIn/playbackRate.
6923
+ - Optional names: Group label="Orbiting circles" (max two words); Scene tracks={{0:{label:"Shapes"}}} (one word per lane). Labels are static; track names stay with lanes. Keep stable IDs separate. Group hidden hides visuals; solo isolates siblings. Scene.tracks also accepts hidden (visuals), muted (audio), solo (isolate lanes); these affect preview and export.
6924
+ - Camera/Surface are not clips. Group sizing: "fill" follows parent, "auto" fits flow content.
6925
+ - Groups clip children to their shape by default. Set clip={false} to allow overflow.
6926
+ - Preview Group files with studio open --width 640 --height 360; storyboard.canvas.json stores per-card placement, size and literal knob values, referencing the original TSX.
6927
+ - Use explicit JSX, literal values/arrays/objects, and const defineMotion or defineKnobs declarations. Bind tracks/knobs directly to compatible props.
6928
+ - No arbitrary expressions, loops, callbacks, spreads, import aliases, custom components, helper calls or computed variables. Only blur({...}) and grain({...}) effect calls are allowed in JSX; pass an explicit object, even for defaults.
6929
+ - Shader.src must be a static relative local .wgsl path. Put complete uniform values inline; numeric uniforms can use motion/knob tracks. defineEffect remains unsupported.
6930
+ - Keyframe at values are increasing local seconds (scene unless inside a timed clip); easing belongs to the arriving key. Add a key at 0 to define a continuous track's starting value. Tracks do not loop automatically.
6931
+ - Group knobs can drive many descendant props; Group.values accepts motion tracks.
6932
+ - Units: pixels, degrees, seconds; +z toward viewer. Asset/font paths are relative to their declaring file.
6933
+ - Text.path handles the complete string; no width/align/lineHeight or line breaks. SVG geometry is static; progress and ellipse radii can animate.
6934
+ - Path draws SVG d in local pixels; use scale to resize. Geometry stays static; transforms, stroke width and effects can animate. Use fill="transparent" for lines.
6935
+
6936
+ ### Workflow
6937
+ 1. Read relevant types, edit the scene, then run osmo studio check scene.tsx.
6938
+ 2. Preview: osmo studio open scene.tsx. Keep the server running; saves reload it. Reopening reuses the project URL, saved in gitignored .osmo/studio.json.
6939
+ 3. Export: osmo studio render scene.tsx --out exports/animation.mp4 (single PNG: --frame 90 --out exports/frame.png, zero-based; use a new filename if one exists). Share for review: osmo studio deploy (after osmo login and osmo studio publish) renders, pushes editable source/assets and Git history, and prints a review link. Prefer this command for Osmo Studio projects. osmo share --video FILE.mp4 is only for sharing a rendered video from any tool, including drafts and work in progress: video versions and comments, no editable source; it needs login and ffprobe but no init, Git, or project.
6940
+ 4. When asked to apply feedback: osmo studio comments list --open --json. Check the scene/timestamp and any older revision; edit and validate the fix. If complete and verified, reply only "Done." with osmo studio comments reply ID "Done." then run osmo studio comments resolve ID. Do not leave routine fixes open for review. Keep open only for ambiguity, an incomplete fix, or a specific uncertainty needing user verification. Reply with one short sentence stating the blocker or what to check.
6941
+ ${end}
6942
+ `;
6943
+ async function optional(file) {
6944
+ try {
6945
+ return await readFile4(file, "utf8");
6946
+ } catch (error) {
6947
+ if (error.code === "ENOENT") return "";
6948
+ throw error;
6949
+ }
6950
+ }
6951
+ async function writeAuthoringGuide(root) {
6952
+ const file = path8.join(root, "AGENTS.md");
6953
+ const original = await optional(file);
6954
+ const hasBegin = original.includes(begin), hasEnd = original.includes(end);
6955
+ if (hasBegin !== hasEnd || hasBegin && original.indexOf(end) < original.indexOf(begin))
6956
+ throw new Error(
6957
+ "AGENTS.md has an incomplete Osmo section; repair its osmo:begin/osmo:end markers before running init."
6992
6958
  );
6993
- if (reply === null)
6994
- await post(`/api/renders/${comment.render}/comments`, {
6995
- body: text2,
6996
- frame: comment.frame,
6997
- authorName: name,
6998
- parentId: comment.id
6999
- });
7000
- return { id: comment.id, status: comment.status };
6959
+ let text2;
6960
+ if (hasBegin)
6961
+ text2 = original.slice(0, original.indexOf(begin)) + authoringGuide.trimEnd() + original.slice(original.indexOf(end) + end.length);
6962
+ else {
6963
+ const rest = original.split("\n").filter((line) => line !== legacyPointer).join("\n");
6964
+ text2 = rest + (rest && !rest.endsWith("\n\n") ? rest.endsWith("\n") ? "\n" : "\n\n" : "") + authoringGuide;
7001
6965
  }
7002
- await post(`/api/renders/${comment.render}/comments/${comment.id}/resolve`, {
7003
- resolved: action === "resolve"
7004
- });
7005
- return { id: comment.id, status: action === "resolve" ? "resolved" : "open" };
7006
- }
7007
- function format(result) {
7008
- const steps = result.steps?.length ? `
7009
- ${result.steps.join(" \u2192 ")}` : "";
7010
- const pending = result.uncommitted ? `
7011
- ${result.uncommitted} uncommitted file${result.uncommitted === 1 ? "" : "s"} not included; run osmo studio sync to record and push them.` : "";
7012
- return formatStatus(result) + steps + pending;
6966
+ if (text2 !== original) await writeFile3(file, text2);
6967
+ const legacy = await optional(path8.join(root, "osmo.md"));
6968
+ if (legacy && createHash3("sha256").update(legacy).digest("hex") === legacyGuideHash)
6969
+ await rm4(path8.join(root, "osmo.md"));
6970
+ else if (legacy)
6971
+ console.log(
6972
+ "Existing osmo.md was preserved because it differs from the original generated guide. Osmo instructions now live in AGENTS.md."
6973
+ );
7013
6974
  }
7014
- function formatStatus(result) {
7015
- switch (result.status) {
7016
- case "pushed":
7017
- return `Pushed ${result.commits} revision${result.commits === 1 ? "" : "s"} (${result.objects} objects${result.uploaded ? `, ${result.uploaded} media file${result.uploaded === 1 ? "" : "s"}` : ""}). Head ${result.head.slice(0, 12)}.`;
7018
- case "fast-forward":
7019
- return `Pulled ${result.revisions} revision${result.revisions === 1 ? "" : "s"}. Head ${result.head.slice(0, 12)}.`;
7020
- case "up-to-date":
7021
- return `Up to date${result.head ? ` at ${result.head.slice(0, 12)}` : ""}.`;
7022
- case "refused":
7023
- return `Refused: ${result.reason}. Cloud is at ${result.cloud?.slice(0, 12)}, you are at ${result.head.slice(0, 12)}. Run ${result.hint}.`;
7024
- case "invalid":
7025
- return `The cloud refused the revision: ${result.reason}.
7026
- ${result.hint === "osmo studio sync" ? "Run osmo studio sync: it folds your unpushed revisions into one and tries again." : `Then ${result.hint}.`}`;
7027
- case "diverged":
7028
- return `Diverged: you are at ${result.head.slice(0, 12)}, the cloud at ${result.cloud.slice(0, 12)}. Run ${result.hint}.`;
7029
- case "conflict":
7030
- return `Conflict: the cloud's changes overlap yours. ${result.hint}.`;
7031
- case "history":
7032
- return result.log;
7033
- case "deployed":
7034
- return `Deployed ${result.head.slice(0, 12)} (${result.pushed}${result.commits ? `, ${result.commits} revision${result.commits === 1 ? "" : "s"}` : ""}).
7035
- Review: ${result.link}`;
7036
- default:
7037
- return JSON.stringify(result);
6975
+
6976
+ // src/init.ts
6977
+ import os2 from "node:os";
6978
+
6979
+ // src/starter.ts
6980
+ init_runtime();
6981
+ import { access, mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
6982
+ import path9 from "node:path";
6983
+ var template = path9.join(packageRoot, "src/starter/");
6984
+ async function writeStarter(root) {
6985
+ const scene = path9.join(root, "scene.tsx");
6986
+ try {
6987
+ await access(scene);
6988
+ return;
6989
+ } catch (error) {
6990
+ if (error.code !== "ENOENT") throw error;
6991
+ }
6992
+ await mkdir4(path9.join(root, "animations"), { recursive: true });
6993
+ try {
6994
+ await writeFile4(scene, await readFile5(path9.join(template, "scene.tsx")), {
6995
+ flag: "wx"
6996
+ });
6997
+ } catch (error) {
6998
+ if (error.code !== "EEXIST") throw error;
7038
6999
  }
7039
7000
  }
7040
7001
 
7041
7002
  // src/init.ts
7003
+ init_runtime();
7004
+ import { spawnSync as spawnSync3 } from "node:child_process";
7042
7005
  async function exists(p) {
7043
7006
  try {
7044
7007
  await access2(p);
@@ -7049,7 +7012,7 @@ async function exists(p) {
7049
7012
  }
7050
7013
  async function create(p, value) {
7051
7014
  try {
7052
- await writeFile4(p, value, { flag: "wx" });
7015
+ await writeFile5(p, value, { flag: "wx" });
7053
7016
  return true;
7054
7017
  } catch (e) {
7055
7018
  if (e.code !== "EEXIST") throw e;
@@ -7058,7 +7021,7 @@ async function create(p, value) {
7058
7021
  }
7059
7022
  async function init(root = process.cwd()) {
7060
7023
  nativeBinary();
7061
- const gitProbe = await mkdtemp(path9.join(os2.tmpdir(), "osmo-git-check-"));
7024
+ const gitProbe = await mkdtemp(path10.join(os2.tmpdir(), "osmo-git-check-"));
7062
7025
  try {
7063
7026
  const result = spawnSync3(
7064
7027
  "git",
@@ -7070,7 +7033,7 @@ async function init(root = process.cwd()) {
7070
7033
  "Osmo projects require Git with SHA-256 support (Git 2.29+). Install or update Git, then retry osmo studio init. Sharing an existing MP4 with osmo share --video does not require Git."
7071
7034
  );
7072
7035
  } finally {
7073
- await rm4(gitProbe, { recursive: true, force: true });
7036
+ await rm5(gitProbe, { recursive: true, force: true });
7074
7037
  }
7075
7038
  for (const [name, variable] of [
7076
7039
  ["ffmpeg", "OSMO_FFMPEG"],
@@ -7095,15 +7058,15 @@ async function init(root = process.cwd()) {
7095
7058
  );
7096
7059
  }
7097
7060
  }
7098
- root = path9.resolve(root);
7099
- await mkdir4(root, { recursive: true });
7061
+ root = path10.resolve(root);
7062
+ await mkdir5(root, { recursive: true });
7100
7063
  if (hasRepo(root) && objectFormat(root) !== "sha256")
7101
7064
  throw new Error(
7102
7065
  `${root} is already a git repository with ${objectFormat(root) ?? "unknown"} objects. Osmo projects are SHA-256 repositories: run osmo studio init in a new folder.`
7103
7066
  );
7104
- const manifestPath = path9.join(root, "package.json");
7105
- const manifest2 = await exists(manifestPath) ? JSON.parse(await readFile5(manifestPath, "utf8")) : {
7106
- name: path9.basename(root).toLowerCase().replace(/[^a-z0-9-]/g, "-") || "osmo-project",
7067
+ const manifestPath = path10.join(root, "package.json");
7068
+ const manifest2 = await exists(manifestPath) ? JSON.parse(await readFile6(manifestPath, "utf8")) : {
7069
+ name: path10.basename(root).toLowerCase().replace(/[^a-z0-9-]/g, "-") || "osmo-project",
7107
7070
  private: true,
7108
7071
  type: "module"
7109
7072
  };
@@ -7113,58 +7076,58 @@ async function init(root = process.cwd()) {
7113
7076
  throw new Error(
7114
7077
  `Existing @osmo/scene dependency (${previous}) was preserved. Initialize a new folder for this release.`
7115
7078
  );
7116
- const configFile = path9.join(root, ".osmo/project.json");
7079
+ const configFile = path10.join(root, ".osmo/project.json");
7117
7080
  if (await exists(configFile)) {
7118
- const config = JSON.parse(await readFile5(configFile, "utf8"));
7081
+ const config = JSON.parse(await readFile6(configFile, "utf8"));
7119
7082
  assertCompatibleProject(config);
7120
7083
  }
7121
- const vendor = path9.join(root, ".osmo/vendor");
7122
- await mkdir4(vendor, { recursive: true });
7123
- await mkdir4(path9.join(root, "assets"), { recursive: true });
7124
- const target = path9.join(vendor, `osmo-scene-${sceneVersion}.tgz`);
7084
+ const vendor = path10.join(root, ".osmo/vendor");
7085
+ await mkdir5(vendor, { recursive: true });
7086
+ await mkdir5(path10.join(root, "assets"), { recursive: true });
7087
+ const target = path10.join(vendor, `osmo-scene-${sceneVersion}.tgz`);
7125
7088
  let refreshScene = false;
7126
- const bundled = path9.join(
7089
+ const bundled = path10.join(
7127
7090
  packageRoot,
7128
7091
  "vendor",
7129
7092
  `osmo-scene-${sceneVersion}.tgz`
7130
7093
  );
7131
7094
  if (await exists(bundled)) {
7132
- if (!await exists(target) || !(await readFile5(target)).equals(await readFile5(bundled))) {
7095
+ if (!await exists(target) || !(await readFile6(target)).equals(await readFile6(bundled))) {
7133
7096
  await copyFile(bundled, target);
7134
7097
  refreshScene = true;
7135
7098
  }
7136
7099
  } else {
7137
- const scratch = await mkdtemp(path9.join(os2.tmpdir(), "osmo-scene-pack-"));
7100
+ const scratch = await mkdtemp(path10.join(os2.tmpdir(), "osmo-scene-pack-"));
7138
7101
  try {
7139
7102
  await run("npm", ["run", "build"], {
7140
- cwd: path9.join(runtimeRoot, "packages/scene")
7103
+ cwd: path10.join(runtimeRoot, "packages/scene")
7141
7104
  });
7142
- const sceneRoot = path9.join(runtimeRoot, "packages/scene");
7105
+ const sceneRoot = path10.join(runtimeRoot, "packages/scene");
7143
7106
  const { scripts, devDependencies, ...scenePackage } = JSON.parse(
7144
- await readFile5(path9.join(sceneRoot, "package.json"), "utf8")
7107
+ await readFile6(path10.join(sceneRoot, "package.json"), "utf8")
7145
7108
  );
7146
- await writeFile4(
7147
- path9.join(scratch, "package.json"),
7109
+ await writeFile5(
7110
+ path10.join(scratch, "package.json"),
7148
7111
  JSON.stringify({ ...scenePackage, files: ["dist"] }, null, 2)
7149
7112
  );
7150
- await cp(path9.join(sceneRoot, "dist"), path9.join(scratch, "dist"), {
7113
+ await cp(path10.join(sceneRoot, "dist"), path10.join(scratch, "dist"), {
7151
7114
  recursive: true
7152
7115
  });
7153
7116
  await run("npm", ["pack", "--pack-destination", scratch], {
7154
7117
  cwd: scratch
7155
7118
  });
7156
- const packed = path9.join(scratch, `osmo-scene-${sceneVersion}.tgz`);
7157
- if (!await exists(target) || !(await readFile5(target)).equals(await readFile5(packed))) {
7119
+ const packed = path10.join(scratch, `osmo-scene-${sceneVersion}.tgz`);
7120
+ if (!await exists(target) || !(await readFile6(target)).equals(await readFile6(packed))) {
7158
7121
  await copyFile(packed, target);
7159
7122
  refreshScene = true;
7160
7123
  }
7161
7124
  } finally {
7162
- await rm4(scratch, { recursive: true, force: true });
7125
+ await rm5(scratch, { recursive: true, force: true });
7163
7126
  }
7164
7127
  }
7165
7128
  if (!previous) {
7166
7129
  manifest2.dependencies = { ...manifest2.dependencies, "@osmo/scene": spec };
7167
- await writeFile4(manifestPath, JSON.stringify(manifest2, null, 2) + "\n");
7130
+ await writeFile5(manifestPath, JSON.stringify(manifest2, null, 2) + "\n");
7168
7131
  }
7169
7132
  const tsconfig = JSON.stringify(
7170
7133
  {
@@ -7183,7 +7146,7 @@ async function init(root = process.cwd()) {
7183
7146
  null,
7184
7147
  2
7185
7148
  ) + "\n";
7186
- await create(path9.join(root, "tsconfig.json"), tsconfig);
7149
+ await create(path10.join(root, "tsconfig.json"), tsconfig);
7187
7150
  await writeStarter(root);
7188
7151
  await create(
7189
7152
  configFile,
@@ -7194,8 +7157,8 @@ async function init(root = process.cwd()) {
7194
7157
  ) + "\n"
7195
7158
  );
7196
7159
  await writeAuthoringGuide(root);
7197
- const ignorePath = path9.join(root, ".gitignore");
7198
- const ignore = await exists(ignorePath) ? await readFile5(ignorePath, "utf8") : "";
7160
+ const ignorePath = path10.join(root, ".gitignore");
7161
+ const ignore = await exists(ignorePath) ? await readFile6(ignorePath, "utf8") : "";
7199
7162
  const rules = [
7200
7163
  "node_modules/",
7201
7164
  "exports/",
@@ -7212,7 +7175,7 @@ async function init(root = process.cwd()) {
7212
7175
  ];
7213
7176
  const missing = rules.filter((rule) => !ignore.split("\n").includes(rule));
7214
7177
  if (missing.length)
7215
- await writeFile4(
7178
+ await writeFile5(
7216
7179
  ignorePath,
7217
7180
  ignore + (ignore && !ignore.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n"
7218
7181
  );
@@ -7237,11 +7200,6 @@ Node 22 is required. Video/audio and MP4 export require FFmpeg and ffprobe on PA
7237
7200
  );
7238
7201
  }
7239
7202
 
7240
- // src/cli.ts
7241
- init_check();
7242
- init_runtime();
7243
- init_comments();
7244
-
7245
7203
  // src/resources.ts
7246
7204
  import { createHash as createHash5 } from "node:crypto";
7247
7205
  import {
@@ -7253,7 +7211,7 @@ import {
7253
7211
  rename as rename3,
7254
7212
  rm as rm6
7255
7213
  } from "node:fs/promises";
7256
- import path14 from "node:path";
7214
+ import path13 from "node:path";
7257
7215
  import { unzipSync } from "fflate";
7258
7216
  init_runtime();
7259
7217
  init_project_resources();
@@ -7411,7 +7369,7 @@ function unpack(zip, meta) {
7411
7369
  invalid();
7412
7370
  return { files, manifest: manifest2 };
7413
7371
  }
7414
- async function stat2(file) {
7372
+ async function stat(file) {
7415
7373
  try {
7416
7374
  return await lstat2(file);
7417
7375
  } catch (error) {
@@ -7420,14 +7378,14 @@ async function stat2(file) {
7420
7378
  }
7421
7379
  }
7422
7380
  async function install(root, id, files) {
7423
- const folder = path14.join(root, "resources");
7381
+ const folder = path13.join(root, "resources");
7424
7382
  await mkdir6(folder, { recursive: true });
7425
7383
  if (!(await lstat2(folder)).isDirectory())
7426
7384
  fail(
7427
7385
  "resource_conflict",
7428
7386
  "resources must be a real directory, not a symlink."
7429
7387
  );
7430
- const lock = path14.join(folder, `.${id}.install.lock`);
7388
+ const lock = path13.join(folder, `.${id}.install.lock`);
7431
7389
  try {
7432
7390
  await mkdir6(lock);
7433
7391
  } catch (error) {
@@ -7440,12 +7398,12 @@ async function install(root, id, files) {
7440
7398
  }
7441
7399
  let stage2;
7442
7400
  try {
7443
- const destination = path14.join(folder, id), existing = await stat2(destination);
7401
+ const destination = path13.join(folder, id), existing = await stat(destination);
7444
7402
  if (existing) {
7445
7403
  if (!existing.isDirectory())
7446
7404
  fail("resource_conflict", `resources/${id} must be a real directory.`);
7447
7405
  for (const name of names) {
7448
- const file = path14.join(destination, name), info = await stat2(file);
7406
+ const file = path13.join(destination, name), info = await stat(file);
7449
7407
  if (!info?.isFile() || !(await readFile7(file)).equals(Buffer.from(files[name])))
7450
7408
  fail(
7451
7409
  "resource_conflict",
@@ -7454,10 +7412,10 @@ async function install(root, id, files) {
7454
7412
  }
7455
7413
  return "already_installed";
7456
7414
  }
7457
- stage2 = await mkdtemp2(path14.join(folder, `.${id}-`));
7415
+ stage2 = await mkdtemp2(path13.join(folder, `.${id}-`));
7458
7416
  for (const name of names)
7459
- await writeFile6(path14.join(stage2, name), files[name], { flag: "wx" });
7460
- if (await stat2(destination))
7417
+ await writeFile6(path13.join(stage2, name), files[name], { flag: "wx" });
7418
+ if (await stat(destination))
7461
7419
  fail(
7462
7420
  "resource_conflict",
7463
7421
  `resources/${id} appeared during installation. Existing files were preserved.`
@@ -7557,6 +7515,141 @@ ${result.compatibility.note}
7557
7515
  ${result.next}`;
7558
7516
  }
7559
7517
 
7518
+ // src/cli.ts
7519
+ init_runtime();
7520
+
7521
+ // src/video-deploy.ts
7522
+ import { execFile as execFile2 } from "node:child_process";
7523
+ import { stat as stat2 } from "node:fs/promises";
7524
+ import path14 from "node:path";
7525
+ import { promisify as promisify2 } from "node:util";
7526
+ var exec2 = promisify2(execFile2);
7527
+ function projectId(value) {
7528
+ if (typeof value !== "string" || !/^[a-z0-9]{10,40}$/.test(value))
7529
+ throw new Error("Provide a valid cloud project ID with --project.");
7530
+ return value;
7531
+ }
7532
+ async function unshare(project) {
7533
+ projectId(project);
7534
+ return call(
7535
+ authOrigin(),
7536
+ `/api/projects/${project}/share`,
7537
+ { method: "DELETE" }
7538
+ );
7539
+ }
7540
+ async function probeVideo(file) {
7541
+ const info = await stat2(file).catch(() => null);
7542
+ if (!info?.isFile() || !info.size || info.size > 2 * 1024 ** 3)
7543
+ throw new Error(
7544
+ "Video must be an existing, non-empty file no larger than 2 GB."
7545
+ );
7546
+ let data;
7547
+ try {
7548
+ const { stdout } = await exec2(
7549
+ process.env.OSMO_FFPROBE ?? "ffprobe",
7550
+ ["-v", "error", "-show_streams", "-show_format", "-of", "json", file],
7551
+ { timeout: 3e4, maxBuffer: 2 * 1024 ** 2 }
7552
+ );
7553
+ data = JSON.parse(stdout);
7554
+ } catch (error) {
7555
+ if (error.code === "ENOENT")
7556
+ throw new Error(
7557
+ "ffprobe is required to inspect videos. Install FFmpeg (which includes ffprobe), then retry."
7558
+ );
7559
+ throw new Error(
7560
+ "Cannot read this video. Provide a playable H.264 MP4 file."
7561
+ );
7562
+ }
7563
+ const videos = (data.streams ?? []).filter(
7564
+ (s) => s.codec_type === "video" && !s.disposition?.attached_pic
7565
+ );
7566
+ const video = videos[0], audio = (data.streams ?? []).filter((s) => s.codec_type === "audio");
7567
+ if (!data.format?.format_name?.split(",").includes("mp4") || videos.length !== 1 || video?.codec_name !== "h264" || !["yuv420p", "yuvj420p"].includes(
7568
+ video?.pix_fmt
7569
+ ) || audio.some((s) => s.codec_name !== "aac"))
7570
+ throw new Error(
7571
+ "Use an H.264 MP4 with 8-bit 4:2:0 video and AAC audio (or no audio). Export a compatible MP4 from your video tool, then retry."
7572
+ );
7573
+ const [n, d = "1"] = String(video.avg_frame_rate).split("/");
7574
+ const fps = Number(n) / Number(d), durationSeconds = Number(video.duration ?? data.format.duration);
7575
+ const rotation = Number(
7576
+ video.side_data_list?.find((s) => s.rotation !== void 0)?.rotation ?? video.tags?.rotate ?? 0
7577
+ );
7578
+ const portrait = Math.abs(rotation % 180) === 90;
7579
+ const width = portrait ? video.height : video.width, height = portrait ? video.width : video.height;
7580
+ if (![fps, durationSeconds, width, height].every(
7581
+ (x) => Number.isFinite(x) && x > 0
7582
+ ) || fps > 1e3 || durationSeconds * fps > 2147483647)
7583
+ throw new Error("Video has invalid or unsupported timing or dimensions.");
7584
+ return {
7585
+ codec: "h264",
7586
+ contentType: "video/mp4",
7587
+ // The check above found both finite and positive.
7588
+ compositionWidth: width,
7589
+ compositionHeight: height,
7590
+ fps,
7591
+ durationSeconds,
7592
+ durationInFrames: Math.max(1, Math.round(durationSeconds * fps))
7593
+ };
7594
+ }
7595
+ async function shareVideo(file, {
7596
+ project,
7597
+ name,
7598
+ log = () => {
7599
+ }
7600
+ } = {}) {
7601
+ if (project !== void 0) projectId(project);
7602
+ if (name !== void 0 && (!name.trim() || name.trim().length > 200))
7603
+ throw new Error("--name must contain 1\u2013200 characters.");
7604
+ const source = path14.resolve(file), metadata = await probeVideo(source), origin = authOrigin();
7605
+ await currentAccount(origin);
7606
+ const title = name?.trim() ?? path14.basename(source).slice(0, 200);
7607
+ const id = project ?? (await call(origin, "/api/review-projects", {
7608
+ method: "POST",
7609
+ body: { name: title }
7610
+ })).id;
7611
+ try {
7612
+ return await uploadRender(
7613
+ origin,
7614
+ id,
7615
+ source,
7616
+ { ...metadata, source: "video", name: title },
7617
+ log
7618
+ );
7619
+ } catch (error) {
7620
+ throw new Error(
7621
+ `${error.message}
7622
+ Retry with --project ${id} to use the same review project.`
7623
+ );
7624
+ }
7625
+ }
7626
+ async function videoComments(project, { open = false } = {}) {
7627
+ projectId(project);
7628
+ const origin = authOrigin(), comments = [];
7629
+ let cursor;
7630
+ do {
7631
+ const page = await call(
7632
+ origin,
7633
+ `/api/projects/${project}/share/versions${cursor ? `?before=${encodeURIComponent(cursor)}` : ""}`
7634
+ );
7635
+ for (const version2 of page.versions) {
7636
+ const data = await call(
7637
+ origin,
7638
+ `/api/renders/${version2.id}/review-comments`
7639
+ );
7640
+ comments.push(
7641
+ ...data.comments.filter((c) => !open || c.status === "open").map((c) => ({
7642
+ ...c,
7643
+ render: version2.id,
7644
+ link: `${origin}/share/${version2.id}`
7645
+ }))
7646
+ );
7647
+ }
7648
+ cursor = page.nextCursor;
7649
+ } while (cursor);
7650
+ return comments;
7651
+ }
7652
+
7560
7653
  // src/cli.ts
7561
7654
  var help = cliHelp(version, studioEnabled());
7562
7655
  try {
@@ -7576,6 +7669,8 @@ try {
7576
7669
  category: { type: "string" },
7577
7670
  preset: { type: "string" },
7578
7671
  name: { type: "string" },
7672
+ access: { type: "string" },
7673
+ role: { type: "string" },
7579
7674
  port: { type: "string" },
7580
7675
  "no-open": { type: "boolean" },
7581
7676
  out: { type: "string" },
@@ -7593,6 +7688,15 @@ try {
7593
7688
  throw new Error(
7594
7689
  "Use --video with osmo share, and --project with osmo share, osmo unshare, or osmo comments." + (studioEnabled() ? " For full editable projects, use osmo studio deploy." : "")
7595
7690
  );
7691
+ if (!flags.help && flags.access !== void 0) {
7692
+ if (args[0] !== "studio" || !(["deploy", "publish"].includes(args[1]) || args[1] === "permissions" && args[2] === "set"))
7693
+ throw new Error(
7694
+ "--access is supported only by studio publish, first deploy, and permissions set."
7695
+ );
7696
+ parseSourceAccess(flags.access);
7697
+ }
7698
+ if (!flags.help && flags.role !== void 0 && !(args[0] === "studio" && args[1] === "permissions" && args[2] === "invite"))
7699
+ throw new Error("--role is supported only by studio permissions invite.");
7596
7700
  if (flags.version && !resourceMode) console.log(version);
7597
7701
  else if (flags.help || !args.length) console.log(help);
7598
7702
  else if (["login", "whoami", "logout"].includes(args[0])) {
@@ -7660,6 +7764,21 @@ ${c.text}${c.replies.map((r) => "\n \u21B3 " + r.text).join("")}`
7660
7764
  flags.json ? JSON.stringify(result) : `Cloned ${result.name} (${result.revisions} revision${result.revisions === 1 ? "" : "s"}) into ${args[3]}.
7661
7765
  Next: cd ${args[3]} && osmo studio open scene.tsx`
7662
7766
  );
7767
+ } else if (args[0] === "studio" && args[1] === "permissions") {
7768
+ const action = args[2] ?? "get";
7769
+ const valid = action === "get" && args.length <= 3 || action === "set" && args.length === 3 && flags.access || ["invite", "revoke"].includes(action) && args.length === 4;
7770
+ if (!valid)
7771
+ throw new Error(
7772
+ "Usage: osmo studio permissions [--json] | set --access LEVEL | invite EMAIL --role viewer|editor | revoke EMAIL"
7773
+ );
7774
+ const result = await permissions(projectRoot(), action, {
7775
+ access: flags.access,
7776
+ email: args[3],
7777
+ role: flags.role
7778
+ });
7779
+ console.log(
7780
+ flags.json ? JSON.stringify(result) : formatPermissions(result)
7781
+ );
7663
7782
  } else if (args[0] === "studio" && ["publish", "push", "pull", "sync", "history"].includes(args[1])) {
7664
7783
  if (args.length !== 2)
7665
7784
  throw new Error(
@@ -7668,7 +7787,10 @@ Next: cd ${args[3]} && osmo studio open scene.tsx`
7668
7787
  const root = projectRoot();
7669
7788
  let result;
7670
7789
  if (args[1] === "publish")
7671
- result = await publish(root, { name: flags.name });
7790
+ result = await publish(root, {
7791
+ name: flags.name,
7792
+ access: flags.access === void 0 ? void 0 : parseSourceAccess(flags.access)
7793
+ });
7672
7794
  else if (args[1] === "push")
7673
7795
  result = await push(root, { log: flags.json ? () => {
7674
7796
  } : console.log });
@@ -7680,6 +7802,8 @@ Next: cd ${args[3]} && osmo studio open scene.tsx`
7680
7802
  if (result)
7681
7803
  console.log(
7682
7804
  flags.json ? JSON.stringify(result) : args[1] === "publish" ? `Published as ${result.id}. Page: ${result.page}
7805
+ Source access: ${result.access}
7806
+ Review: ${result.link}
7683
7807
  ${format(result)}` : format(result)
7684
7808
  );
7685
7809
  if (result && ["refused", "diverged", "conflict", "invalid"].includes(result.status))
@@ -7782,7 +7906,8 @@ ${c.text}${c.replies.map((r) => "\n \u21B3 " + r.text).join("")}`
7782
7906
  check,
7783
7907
  log: flags.json ? () => {
7784
7908
  } : console.error,
7785
- quiet: !!flags.json
7909
+ quiet: !!flags.json,
7910
+ access: flags.access === void 0 ? void 0 : parseSourceAccess(flags.access)
7786
7911
  });
7787
7912
  console.log(flags.json ? JSON.stringify(result) : format(result));
7788
7913
  if (result.status !== "deployed") process.exitCode = 1;