create-octane-xplat 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +78 -1
  2. package/compatibility.json +199 -0
  3. package/compatibility.ts +109 -0
  4. package/dist/compatibility.js +66 -0
  5. package/dist/index.js +132 -0
  6. package/dist/scaffold.js +439 -0
  7. package/index.ts +198 -0
  8. package/library-template/AGENTS.md +18 -0
  9. package/{LICENSE → library-template/LICENSE} +6 -6
  10. package/library-template/README.md +84 -0
  11. package/library-template/gitignore +5 -0
  12. package/library-template/package.json +92 -0
  13. package/library-template/pnpm-workspace.yaml +156 -0
  14. package/library-template/src/Badge.tsx +21 -0
  15. package/library-template/src/Badge.web.tsx +21 -0
  16. package/library-template/src/index.ts +3 -0
  17. package/library-template/src/index.web.ts +3 -0
  18. package/library-template/src/normalizeBadgeLabel.ts +12 -0
  19. package/library-template/src/props.ts +14 -0
  20. package/library-template/tests/normalizeBadgeLabel.test.ts +6 -0
  21. package/library-template/tests/packed-consumer.mjs +156 -0
  22. package/library-template/tsconfig.envs.json +17 -0
  23. package/library-template/tsconfig.json +27 -0
  24. package/library-template/tsconfig.types.json +11 -0
  25. package/library-template/tsrx-typegen.json +16 -0
  26. package/package.json +27 -8
  27. package/{scaffold.mjs → scaffold.ts} +149 -47
  28. package/template/.agents/skills/xplat/SKILL.md +11 -3
  29. package/template/.agents/skills/xplat/references/components.md +2 -2
  30. package/template/.agents/skills/xplat/references/navigation.md +9 -5
  31. package/template/.agents/skills/xplat/references/platform.md +2 -2
  32. package/template/AGENTS.md +47 -0
  33. package/template/App_Resources/Android/app.gradle +1 -1
  34. package/template/package.json +35 -32
  35. package/template/pnpm-workspace.yaml +73 -11
  36. package/template/src/app.spec.tsrx +80 -2
  37. package/template/src/app.web.test.tsx +20 -0
  38. package/template/src/failure-reporting.spec.ts +11 -0
  39. package/template/src/main.web.tsrx +1 -0
  40. package/template/src/native-runtime.spec.ts +18 -0
  41. package/template/src/test-setup.web.ts +17 -0
  42. package/template/tsconfig.json +32 -8
  43. package/template/vite.config.ts +2 -2
  44. package/template/vitest.web.config.mts +13 -0
  45. package/index.mjs +0 -129
  46. package/template/.oxlintrc.json +0 -25
  47. package/template/tsconfig.native.json +0 -18
@@ -0,0 +1,439 @@
1
+ import { ensureCompatibilityCatalog } from "./compatibility.js";
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseDocument } from "yaml";
6
+ //#region scaffold.ts
7
+ var moduleDir = dirname(fileURLToPath(import.meta.url));
8
+ var pkgRoot = basename(moduleDir) === "dist" ? join(moduleDir, "..") : moduleDir;
9
+ var TEMPLATE_DIR = join(pkgRoot, "template");
10
+ var LIBRARY_TEMPLATE_DIR = join(pkgRoot, "library-template");
11
+ /** Scaffoldable targets. `files` entries are template-relative paths —
12
+ * directories copy recursively. `deps`/`devDeps`/`scripts` name keys whose
13
+ * values come from template/package.json. `typecheck` contributes one clause
14
+ * to the generated typecheck script. */
15
+ var targets = {
16
+ web: {
17
+ label: "Web (Vite)",
18
+ files: [
19
+ "index.html",
20
+ "vite.config.ts",
21
+ "src/main.web.tsrx",
22
+ "vitest.web.config.mts",
23
+ "src/app.web.test.tsx",
24
+ "src/test-setup.web.ts"
25
+ ],
26
+ scripts: [
27
+ "dev",
28
+ "build",
29
+ "test",
30
+ "test:web"
31
+ ],
32
+ devDeps: ["@octanejs/vite-plugin", "jsdom"]
33
+ },
34
+ ios: {
35
+ label: "iOS (NativeScript)",
36
+ requires: ["native-shared"],
37
+ files: ["App_Resources/iOS"],
38
+ scripts: [
39
+ "dev:ios",
40
+ "build:ios",
41
+ "test:ios"
42
+ ],
43
+ devDeps: ["@nativescript/ios"],
44
+ note: "needs macOS + Xcode — `pnpm xplat doctor` checks the toolchain"
45
+ },
46
+ android: {
47
+ label: "Android (NativeScript)",
48
+ requires: ["native-shared"],
49
+ files: ["App_Resources/Android"],
50
+ scripts: [
51
+ "dev:android",
52
+ "build:android",
53
+ "test:android"
54
+ ],
55
+ devDeps: ["@nativescript/android"],
56
+ note: "needs Android SDK + a compatible JDK — `pnpm xplat doctor` checks the toolchain"
57
+ },
58
+ "native-shared": {
59
+ hidden: true,
60
+ patches: [
61
+ "@nativescript/core",
62
+ "@nativescript/types",
63
+ "css",
64
+ "@nativescript/vite",
65
+ "@nativescript-community/octane",
66
+ "@nativescript-community/vite-octane"
67
+ ],
68
+ files: [
69
+ "nativescript.config.ts",
70
+ "vite.config.native.mts",
71
+ "references.d.ts",
72
+ "src/main.ts",
73
+ "vitest.config.mts",
74
+ "src/test.ts",
75
+ "src/app.spec.tsrx",
76
+ "src/native-runtime.spec.ts",
77
+ "src/failure-reporting.spec.ts"
78
+ ],
79
+ deps: [
80
+ "@nativescript-community/octane",
81
+ "@nativescript/core",
82
+ "@valor/nativescript-websockets"
83
+ ],
84
+ devDeps: [
85
+ "@nativescript-community/vite-octane",
86
+ "@nativescript/types",
87
+ "@nativescript/unit-test-runner",
88
+ "@nativescript/vite",
89
+ "nativescript"
90
+ ],
91
+ typecheck: "xplat typecheck"
92
+ }
93
+ };
94
+ var selectableTargets = Object.keys(targets).filter((id) => !targets[id].hidden);
95
+ /** `pnpm create` without --targets and `xplat add`'s prompt default. */
96
+ var defaultTargets = [
97
+ "web",
98
+ "ios",
99
+ "android"
100
+ ];
101
+ var BASE_TYPECHECK = "xplat typecheck";
102
+ var BASE_SCRIPTS = [
103
+ "xplat",
104
+ "lint",
105
+ "lint:fix",
106
+ "format"
107
+ ];
108
+ /** Expand `requires` edges, dependencies first. Throws on unknown ids. */
109
+ function resolveTargets(ids) {
110
+ const resolved = [];
111
+ const visit = (id) => {
112
+ const t = targets[id];
113
+ if (!t) throw new Error(`unknown target "${id}" — expected one of: ${selectableTargets.join(", ")}`);
114
+ for (const req of t.requires ?? []) visit(req);
115
+ if (!resolved.includes(id)) resolved.push(id);
116
+ };
117
+ for (const id of ids) visit(id);
118
+ return resolved;
119
+ }
120
+ var templateManifest = () => JSON.parse(readFileSync(join(TEMPLATE_DIR, "package.json"), "utf8"));
121
+ /** Which target claims a template-relative path (entries may be dirs). */
122
+ function ownerOf(path) {
123
+ for (const [id, t] of Object.entries(targets)) for (const entry of t.files) if (path === entry || path.startsWith(entry + "/")) return id;
124
+ return null;
125
+ }
126
+ function walk(dir, prefix = "") {
127
+ const out = [];
128
+ for (const name of readdirSync(dir)) {
129
+ if (name === "node_modules") continue;
130
+ const rel = prefix ? `${prefix}/${name}` : name;
131
+ if (statSync(join(dir, name)).isDirectory()) out.push(...walk(join(dir, name), rel));
132
+ else out.push(rel);
133
+ }
134
+ return out;
135
+ }
136
+ function pickKeys(source, names, label) {
137
+ const out = {};
138
+ for (const name of names) {
139
+ if (source[name] === void 0) throw new Error(`template/package.json has no ${label} entry "${name}"`);
140
+ out[name] = source[name];
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * The package.json a given target set produces. With every target selected
146
+ * this is identical to template/package.json.
147
+ */
148
+ function composeManifest(ids) {
149
+ const resolved = resolveTargets(ids);
150
+ const tpl = templateManifest();
151
+ const manifest = { ...tpl };
152
+ for (const section of ["dependencies", "devDependencies"]) {
153
+ const key = section === "dependencies" ? "deps" : "devDeps";
154
+ const wanted = /* @__PURE__ */ new Set();
155
+ for (const id of resolved) for (const name of targets[id][key] ?? []) wanted.add(name);
156
+ const next = {};
157
+ for (const [name, spec] of Object.entries(tpl[section] ?? {})) if (!Object.keys(targets).some((id) => (targets[id][key] ?? []).includes(name)) || wanted.has(name)) next[name] = spec;
158
+ manifest[section] = next;
159
+ }
160
+ const wantedScripts = new Set(BASE_SCRIPTS);
161
+ for (const id of resolved) for (const name of targets[id].scripts ?? []) wantedScripts.add(name);
162
+ const scripts = pickKeys(tpl.scripts ?? {}, [...wantedScripts].sort(), "script");
163
+ const typecheck = [BASE_TYPECHECK];
164
+ for (const id of resolved) if (targets[id].typecheck) typecheck.push(targets[id].typecheck);
165
+ scripts.typecheck = [...new Set(typecheck)].join(" && ");
166
+ manifest.scripts = {};
167
+ for (const name of Object.keys(tpl.scripts ?? {})) if (name === "typecheck" || wantedScripts.has(name)) manifest.scripts[name] = scripts[name];
168
+ if (!resolved.includes("native-shared")) delete manifest.main;
169
+ return manifest;
170
+ }
171
+ var PATCH_ENTRY = /^\s+"?(@?[\w./-]+@[\w.-]+)"?:\s+\S+\s*$/;
172
+ var patchPkg = (key) => key.replace(/@[^@]+$/, "");
173
+ function parsePatchEntries(lines) {
174
+ const entries = [];
175
+ let inPatches = false;
176
+ let pending = [];
177
+ for (const line of lines) {
178
+ if (/^\S/.test(line)) {
179
+ inPatches = line.startsWith("patchedDependencies");
180
+ pending = [];
181
+ continue;
182
+ }
183
+ if (!inPatches) continue;
184
+ const m = line.match(PATCH_ENTRY);
185
+ if (m) {
186
+ entries.push({
187
+ name: patchPkg(m[1]),
188
+ comments: pending,
189
+ line
190
+ });
191
+ pending = [];
192
+ } else pending.push(line);
193
+ }
194
+ return entries;
195
+ }
196
+ /** The pnpm-workspace.yaml a given target set produces. */
197
+ function composeWorkspaceYaml(ids) {
198
+ const resolved = new Set(resolveTargets(ids));
199
+ const drop = /* @__PURE__ */ new Set();
200
+ for (const [id, t] of Object.entries(targets)) if (!resolved.has(id)) for (const name of t.patches ?? []) drop.add(name);
201
+ const lines = readFileSync(join(TEMPLATE_DIR, "pnpm-workspace.yaml"), "utf8").split("\n");
202
+ const out = [];
203
+ let inPatches = false;
204
+ let pending = [];
205
+ for (const line of lines) {
206
+ if (/^\S/.test(line)) {
207
+ out.push(...pending, line);
208
+ pending = [];
209
+ inPatches = line.startsWith("patchedDependencies");
210
+ continue;
211
+ }
212
+ if (!inPatches) {
213
+ out.push(line);
214
+ continue;
215
+ }
216
+ const m = line.match(PATCH_ENTRY);
217
+ if (!m) {
218
+ pending.push(line);
219
+ continue;
220
+ }
221
+ if (drop.has(patchPkg(m[1]))) {
222
+ pending = [];
223
+ continue;
224
+ }
225
+ out.push(...pending, line);
226
+ pending = [];
227
+ }
228
+ out.push(...pending);
229
+ const doc = parseDocument(out.join("\n"));
230
+ const manifest = composeManifest(ids);
231
+ const used = /* @__PURE__ */ new Set([...Object.keys(manifest.dependencies), ...Object.keys(manifest.devDependencies)]);
232
+ for (const name of Object.keys(doc.toJS().catalogs?.xplat ?? {})) if (!used.has(name)) doc.deleteIn([
233
+ "catalogs",
234
+ "xplat",
235
+ name
236
+ ]);
237
+ return String(doc);
238
+ }
239
+ /**
240
+ * Add the patch entries that `enabledIds` owns to the app's
241
+ * pnpm-workspace.yaml. Entries already present (by package name, any
242
+ * version) are kept. Returns true if the file changed.
243
+ */
244
+ function ensurePatchEntries(appRoot, enabledIds) {
245
+ const needed = /* @__PURE__ */ new Set();
246
+ for (const id of enabledIds) for (const name of targets[id].patches ?? []) needed.add(name);
247
+ if (!needed.size) return false;
248
+ const yamlPath = join(appRoot, "pnpm-workspace.yaml");
249
+ const appText = existsSync(yamlPath) ? readFileSync(yamlPath, "utf8") : "";
250
+ const present = new Set(parsePatchEntries(appText.split("\n")).map((e) => e.name));
251
+ const missing = [...needed].filter((name) => !present.has(name));
252
+ if (!missing.length) return false;
253
+ const blocks = parsePatchEntries(readFileSync(join(TEMPLATE_DIR, "pnpm-workspace.yaml"), "utf8").split("\n")).filter((e) => missing.includes(e.name)).map((e) => [...e.comments, e.line].join("\n"));
254
+ if (!appText.trim()) {
255
+ writeFileSync(yamlPath, composeWorkspaceYaml(enabledIds));
256
+ return true;
257
+ }
258
+ let next = appText;
259
+ const lines = next.split("\n");
260
+ const sectionStart = lines.findIndex((l) => l.startsWith("patchedDependencies"));
261
+ if (sectionStart === -1) next = `${appText.trimEnd()}\npatchedDependencies:\n${blocks.join("\n")}\n`;
262
+ else {
263
+ let insertAt = lines.length;
264
+ for (let i = sectionStart + 1; i < lines.length; i++) if (/^\S/.test(lines[i])) {
265
+ insertAt = i;
266
+ break;
267
+ }
268
+ lines.splice(insertAt, 0, ...blocks.join("\n").split("\n"));
269
+ next = lines.join("\n");
270
+ }
271
+ writeFileSync(yamlPath, next.endsWith("\n") ? next : next + "\n");
272
+ return true;
273
+ }
274
+ /**
275
+ * The tsconfig.json a given target set produces. One program covers every
276
+ * platform suffix (moduleSuffixes is the fixed precedence order); a web-only
277
+ * scaffold just drops the native ambient root and entrypoint.
278
+ */
279
+ function composeTsconfig(ids) {
280
+ const resolved = new Set(resolveTargets(ids));
281
+ const config = JSON.parse(readFileSync(join(TEMPLATE_DIR, "tsconfig.json"), "utf8"));
282
+ if (!resolved.has("native-shared")) {
283
+ delete config.files;
284
+ config.compilerOptions.types = (config.compilerOptions.types ?? []).filter((t) => t !== "@nativescript/types");
285
+ config.exclude = [.../* @__PURE__ */ new Set([...config.exclude ?? [], "src/main.ts"])];
286
+ }
287
+ return JSON.stringify(config, null, " ") + "\n";
288
+ }
289
+ /** Write a scaffold with tests for `ids` into `dir` (must not exist or be empty). */
290
+ function composeTargets(ids, dir) {
291
+ const resolved = new Set(resolveTargets(ids));
292
+ mkdirSync(dir, { recursive: true });
293
+ for (const rel of walk(TEMPLATE_DIR)) {
294
+ if (rel === "package.json" || rel === "pnpm-workspace.yaml" || rel === "tsconfig.json") continue;
295
+ const owner = ownerOf(rel);
296
+ if (owner && !resolved.has(owner)) continue;
297
+ const dest = join(dir, rel === "gitignore" ? ".gitignore" : rel);
298
+ mkdirSync(dirname(dest), { recursive: true });
299
+ cpSync(join(TEMPLATE_DIR, rel), dest, { recursive: true });
300
+ }
301
+ writeFileSync(join(dir, "package.json"), JSON.stringify(composeManifest(ids), null, " ") + "\n");
302
+ writeFileSync(join(dir, "pnpm-workspace.yaml"), composeWorkspaceYaml(ids));
303
+ writeFileSync(join(dir, "tsconfig.json"), composeTsconfig(ids));
304
+ return [...resolved];
305
+ }
306
+ /** Write the cross-platform library starter into `dir`. */
307
+ function composeLibrary(dir, packageName = basename(resolve(dir))) {
308
+ if (packageName.length > 214 || !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(packageName)) throw new Error(`invalid npm package name "${packageName}" — use lowercase letters, numbers, dots, underscores, or hyphens`);
309
+ mkdirSync(dir, { recursive: true });
310
+ for (const rel of walk(LIBRARY_TEMPLATE_DIR)) {
311
+ if (rel === "package.json") continue;
312
+ const dest = join(dir, rel === "gitignore" ? ".gitignore" : rel);
313
+ mkdirSync(dirname(dest), { recursive: true });
314
+ const source = join(LIBRARY_TEMPLATE_DIR, rel);
315
+ if (/\.(?:md|ts|tsx|mjs|json)$/.test(rel)) {
316
+ const content = readFileSync(source, "utf8").replaceAll("octane-xplat-library", packageName);
317
+ writeFileSync(dest, content);
318
+ } else cpSync(source, dest, { recursive: true });
319
+ }
320
+ const manifest = JSON.parse(readFileSync(join(LIBRARY_TEMPLATE_DIR, "package.json"), "utf8"));
321
+ manifest.name = packageName;
322
+ writeFileSync(join(dir, "package.json"), JSON.stringify(manifest, null, " ") + "\n");
323
+ }
324
+ var readJson = (file) => {
325
+ try {
326
+ return JSON.parse(readFileSync(file, "utf8"));
327
+ } catch {
328
+ return null;
329
+ }
330
+ };
331
+ /** Sentinel-based enablement check, mirroring the CLI's targets.mjs. */
332
+ function targetEnabled(appRoot, id) {
333
+ if (id === "web") return existsSync(join(appRoot, "vite.config.ts")) || existsSync(join(appRoot, "vite.config.mts"));
334
+ if (id === "native-shared") return existsSync(join(appRoot, "nativescript.config.ts"));
335
+ const manifest = readJson(join(appRoot, "package.json")) ?? {};
336
+ const declared = {
337
+ ...manifest.dependencies,
338
+ ...manifest.devDependencies
339
+ };
340
+ const runtime = {
341
+ ios: "@nativescript/ios",
342
+ android: "@nativescript/android"
343
+ }[id];
344
+ return runtime ? typeof declared[runtime] === "string" : false;
345
+ }
346
+ /** Copy a template file/dir into the app unless the destination exists. */
347
+ function copyMissing(rel, appRoot, report) {
348
+ const src = join(TEMPLATE_DIR, rel);
349
+ const dest = join(appRoot, rel === "gitignore" ? ".gitignore" : rel);
350
+ if (existsSync(dest)) {
351
+ report.skipped.push(rel);
352
+ return;
353
+ }
354
+ mkdirSync(dirname(dest), { recursive: true });
355
+ cpSync(src, dest, { recursive: true });
356
+ report.copied.push(rel);
357
+ }
358
+ /**
359
+ * Fold a native-capable app's sources into the one tsconfig program: the
360
+ * manifest (tsconfig.envs.json) is gone — one program covers every platform
361
+ * suffix — so enabling native means wiring the ambient declaration file and
362
+ * the entrypoint into the app's own config.
363
+ */
364
+ function enableNativeInTsconfig(appRoot) {
365
+ const path = join(appRoot, "tsconfig.json");
366
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
367
+ const config = readJson(path);
368
+ if (!config || typeof config !== "object") return false;
369
+ const indent = text.match(/\n(\s+)"/)?.[1] ?? " ";
370
+ config.files = [.../* @__PURE__ */ new Set(["references.d.ts", ...config.files ?? []])];
371
+ config.exclude = (config.exclude ?? []).filter((e) => e !== "src/main.ts");
372
+ const types = config.compilerOptions?.types;
373
+ if (config.compilerOptions && Array.isArray(types) && !types.includes("@nativescript/types")) config.compilerOptions.types = [...types, "@nativescript/types"];
374
+ writeFileSync(path, JSON.stringify(config, null, indent) + "\n");
375
+ return true;
376
+ }
377
+ /**
378
+ * Enable `id` (plus unmet `requires`) inside an existing app at `appRoot`.
379
+ * Additive only: existing files, scripts, and dep entries are skipped, never
380
+ * overwritten. Returns a report for the caller to render.
381
+ */
382
+ function applyTarget(appRoot, id) {
383
+ const manifestPath = join(appRoot, "package.json");
384
+ const raw = readFileSync(manifestPath, "utf8");
385
+ const manifest = JSON.parse(raw);
386
+ const tpl = templateManifest();
387
+ const report = {
388
+ enabled: [],
389
+ already: [],
390
+ copied: [],
391
+ skipped: [],
392
+ scriptsAdded: [],
393
+ scriptsSkipped: [],
394
+ depsAdded: []
395
+ };
396
+ let touched = false;
397
+ for (const tid of resolveTargets([id])) {
398
+ if (targetEnabled(appRoot, tid)) {
399
+ report.already.push(tid);
400
+ continue;
401
+ }
402
+ report.enabled.push(tid);
403
+ const t = targets[tid];
404
+ for (const rel of t.files) copyMissing(rel, appRoot, report);
405
+ for (const [section, key] of [["dependencies", "deps"], ["devDependencies", "devDeps"]]) for (const name of t[key] ?? []) {
406
+ manifest[section] ??= {};
407
+ if (manifest[section][name] === void 0) {
408
+ manifest[section][name] = tpl[section][name];
409
+ report.depsAdded.push(name);
410
+ }
411
+ }
412
+ manifest.scripts ??= {};
413
+ for (const name of t.scripts ?? []) if (manifest.scripts[name] === void 0) {
414
+ manifest.scripts[name] = tpl.scripts[name];
415
+ report.scriptsAdded.push(name);
416
+ } else if (manifest.scripts[name] !== tpl.scripts[name]) report.scriptsSkipped.push(name);
417
+ if (t.typecheck) {
418
+ const current = manifest.scripts.typecheck ?? "";
419
+ if (!current.includes(t.typecheck)) {
420
+ manifest.scripts.typecheck = current ? `${current} && ${t.typecheck}` : t.typecheck;
421
+ report.scriptsAdded.push("typecheck");
422
+ }
423
+ }
424
+ if (tid === "native-shared") {
425
+ if (manifest.main === void 0) manifest.main = tpl.main;
426
+ report.tsconfigUpdated = enableNativeInTsconfig(appRoot);
427
+ }
428
+ touched = true;
429
+ }
430
+ if (ensurePatchEntries(appRoot, [...report.enabled, ...report.already])) report.patchesAdded = true;
431
+ ensureCompatibilityCatalog(appRoot, manifest);
432
+ if (touched) {
433
+ const indent = raw.match(/\n(\s+)"/)?.[1] ?? " ";
434
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, indent) + "\n");
435
+ }
436
+ return report;
437
+ }
438
+ //#endregion
439
+ export { LIBRARY_TEMPLATE_DIR, TEMPLATE_DIR, applyTarget, composeLibrary, composeManifest, composeTargets, composeTsconfig, composeWorkspaceYaml, defaultTargets, resolveTargets, selectableTargets, targetEnabled, targets };
package/index.ts ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { basename, resolve } from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+ import {
6
+ composeLibrary,
7
+ composeTargets,
8
+ defaultTargets,
9
+ resolveTargets,
10
+ selectableTargets,
11
+ targets,
12
+ } from './scaffold.ts'
13
+
14
+ const argv = process.argv.slice(2)
15
+ const noInstall = argv.includes('--no-install')
16
+ const templateIndex = argv.findIndex((arg) => arg === '--template' || arg.startsWith('--template='))
17
+ const templateValueIndex =
18
+ templateIndex !== -1 && argv[templateIndex] === '--template' ? templateIndex + 1 : -1
19
+
20
+ const templateArg =
21
+ templateIndex === -1
22
+ ? 'app'
23
+ : templateValueIndex !== -1
24
+ ? argv[templateValueIndex]
25
+ : argv[templateIndex].slice('--template='.length)
26
+
27
+ const nameIndex = argv.findIndex((arg) => arg === '--name' || arg.startsWith('--name='))
28
+ const nameValueIndex = nameIndex !== -1 && argv[nameIndex] === '--name' ? nameIndex + 1 : -1
29
+ const nameArg =
30
+ nameIndex === -1
31
+ ? undefined
32
+ : nameValueIndex !== -1
33
+ ? argv[nameValueIndex]
34
+ : argv[nameIndex].slice('--name='.length)
35
+
36
+ if (templateIndex !== -1 && (!templateArg || templateArg.startsWith('-'))) {
37
+ console.error('✗ --template needs a value: app or library')
38
+ process.exit(1)
39
+ }
40
+
41
+ if (!['app', 'library'].includes(templateArg)) {
42
+ console.error(`✗ unknown template "${templateArg}" — expected app or library`)
43
+ process.exit(1)
44
+ }
45
+
46
+ if (nameIndex !== -1 && (!nameArg || nameArg.startsWith('-'))) {
47
+ console.error('✗ --name needs an npm package name')
48
+ process.exit(1)
49
+ }
50
+
51
+ const targetsIndex = argv.findIndex((arg) => arg === '--targets' || arg.startsWith('--targets='))
52
+ const targetsValueIndex =
53
+ targetsIndex !== -1 && argv[targetsIndex] === '--targets' ? targetsIndex + 1 : -1
54
+
55
+ const targetsArg =
56
+ targetsIndex === -1
57
+ ? undefined
58
+ : targetsValueIndex !== -1
59
+ ? argv[targetsValueIndex]
60
+ : argv[targetsIndex].slice('--targets='.length)
61
+
62
+ if (targetsIndex !== -1 && (targetsArg === undefined || targetsArg.startsWith('-'))) {
63
+ console.error(`✗ --targets needs a comma list: ${selectableTargets.join(', ')}`)
64
+ process.exit(1)
65
+ }
66
+
67
+ const optionValues = new Set(
68
+ [targetsValueIndex, templateValueIndex, nameValueIndex].filter((index) => index !== -1),
69
+ )
70
+
71
+ const positional = argv.filter((arg, i) => !arg.startsWith('-') && !optionValues.has(i))
72
+ const defaultDir = templateArg === 'library' ? 'octane-xplat-library' : 'octane-xplat-app'
73
+ const dir = resolve(positional[0] ?? defaultDir)
74
+
75
+ if (templateArg === 'library' && targetsIndex !== -1) {
76
+ console.error(
77
+ '✗ --targets applies to the app template; libraries start with web and native builds',
78
+ )
79
+
80
+ process.exit(1)
81
+ }
82
+
83
+ if (templateArg === 'app' && nameIndex !== -1) {
84
+ console.error('✗ --name applies only to the library template')
85
+ process.exit(1)
86
+ }
87
+
88
+ if (existsSync(dir) && readdirSync(dir).length > 0) {
89
+ console.error(`✗ ${dir} exists and is not empty`)
90
+ process.exit(1)
91
+ }
92
+
93
+ const parseTargets = (value) => {
94
+ const ids = value
95
+ .split(',')
96
+ .map((s) => s.trim())
97
+ .filter(Boolean)
98
+
99
+ if (ids.length === 0) {
100
+ throw new Error(`--targets needs a comma list: ${selectableTargets.join(', ')}`)
101
+ }
102
+
103
+ for (const id of ids) {
104
+ if (!selectableTargets.includes(id)) {
105
+ throw new Error(
106
+ `unknown target "${id}" — expected a comma list of: ${selectableTargets.join(', ')}`,
107
+ )
108
+ }
109
+ }
110
+
111
+ return resolveTargets(ids)
112
+ }
113
+
114
+ let ids: string[] = []
115
+ if (templateArg === 'library') {
116
+ const packageName = nameArg ?? basename(dir)
117
+ try {
118
+ composeLibrary(dir, packageName)
119
+ } catch (error) {
120
+ console.error(`✗ ${error.message}`)
121
+ process.exit(1)
122
+ }
123
+
124
+ console.log(`\n✓ scaffolded ${dir} (library: ${packageName}; web + iOS + Android)`)
125
+ } else {
126
+ if (targetsArg !== undefined) {
127
+ try {
128
+ ids = parseTargets(targetsArg)
129
+ } catch (error) {
130
+ console.error(`✗ ${error.message}`)
131
+ process.exit(1)
132
+ }
133
+ } else if (process.stdout.isTTY) {
134
+ // Non-interactive creation does not need the prompt module.
135
+ const p = await import('@clack/prompts')
136
+ p.intro('create-octane-xplat')
137
+ const picked = await p.multiselect({
138
+ message: 'Target platforms',
139
+ options: selectableTargets.map((id) => ({
140
+ value: id,
141
+ label: targets[id].label,
142
+ hint: targets[id].note,
143
+ })),
144
+ initialValues: [...defaultTargets],
145
+ required: true,
146
+ })
147
+
148
+ if (p.isCancel(picked)) {
149
+ p.cancel('Cancelled')
150
+ process.exit(0)
151
+ }
152
+
153
+ ids = picked
154
+ } else {
155
+ ids = defaultTargets
156
+ }
157
+
158
+ composeTargets(ids, dir)
159
+ console.log(
160
+ `\n✓ scaffolded ${dir} (${[...new Set(resolveTargets(ids))].filter((id) => !targets[id].hidden).join(', ')})`,
161
+ )
162
+ }
163
+
164
+ if (noInstall) {
165
+ console.log('\nSkipped install (--no-install). To run manually:')
166
+ printNextSteps(dir, ids, templateArg)
167
+ process.exit(0)
168
+ }
169
+
170
+ const install = spawnSync('pnpm', ['install'], { cwd: dir, stdio: 'inherit' })
171
+ if (install.status !== 0) {
172
+ console.log(`\nInstall didn't finish (pnpm missing?). To run manually:`)
173
+ printNextSteps(dir, ids, templateArg)
174
+ process.exit(install.status ?? 1)
175
+ }
176
+
177
+ if (templateArg === 'app' && ids.includes('web')) {
178
+ console.log('\n✓ installed — starting dev server (Ctrl+C to stop)\n')
179
+ spawnSync('pnpm', ['dev'], { cwd: dir, stdio: 'inherit' })
180
+ } else {
181
+ console.log('\n✓ installed. Next steps:')
182
+ printNextSteps(dir, ids, templateArg)
183
+ }
184
+
185
+ function printNextSteps(dir, ids, template) {
186
+ console.log(` cd ${dir}\n pnpm install`)
187
+ if (template === 'library') {
188
+ console.log(' pnpm typecheck\n pnpm lint\n pnpm typegen\n pnpm test\n pnpm test:packed')
189
+ return
190
+ }
191
+
192
+ if (ids.includes('web')) {
193
+ console.log(' pnpm dev')
194
+ } else {
195
+ // No web entry — `xplat dev` discovers whichever native targets exist.
196
+ console.log(' pnpm xplat dev')
197
+ }
198
+ }
@@ -0,0 +1,18 @@
1
+ # Library package notes
2
+
3
+ - Use pnpm for package management.
4
+ - Keep build dependencies in `catalog:xplat`. Use `pnpm dlx @octane-xplat/cli@latest upgrade --check` to preview a workspace-wide version/patch update and `pnpm dlx @octane-xplat/cli@latest upgrade` to apply it. Keep consumer peer ranges under the library's control; run `pnpm xplat doctor` and the package checks afterward.
5
+ - Keep the public API in `src/index.ts` and `src/index.web.ts`; both barrels
6
+ must export the same names and types.
7
+ - Put shared prop contracts in `src/props.ts` and document public exports.
8
+ - The unsuffixed `.tsx` file is the NativeScript implementation. Add a
9
+ `.web.tsx` sibling when browser behavior or markup needs to differ. Native
10
+ files start with `/** @jsxImportSource @octane-xplat/jsx */`; browser files
11
+ start with `/** @jsxImportSource @octane-xplat/jsx/dom */`.
12
+ - Keep DOM globals out of native files. Use the shared `@octane-xplat/ui`
13
+ elements when behavior can be shared.
14
+ - Declare every imported package. Runtime plugins belong in `dependencies`;
15
+ Octane and renderer packages belong in `peerDependencies` when consumers
16
+ supply the runtime.
17
+ - Before publishing, run `pnpm typecheck`, `pnpm lint`, `pnpm test`,
18
+ `pnpm typegen`, and `pnpm test:packed`.
@@ -1,6 +1,6 @@
1
- The MIT License (MIT)
1
+ MIT License
2
2
 
3
- Copyright (c) 2026 Alec Larson
3
+ Copyright (c) [year] [copyright holder]
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
9
  copies of the Software, and to permit persons to whom the Software is
10
10
  furnished to do so, subject to the following conditions:
11
11
 
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
14
 
15
15
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
16
  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
17
  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.