@hla4ts/create-spacekit 0.1.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.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @hla4ts/create-spacekit
2
+
3
+ Scaffold a minimal `@hla4ts/spacekit` lunar rover app.
4
+
5
+ The generated project is intentionally simpler than the repo's validation
6
+ examples. It creates one `PhysicalEntity`, drives it with `SpacekitApp.run()`,
7
+ and keeps the common RTI/Spacekit knobs in `.env`.
8
+
9
+ ## What It Generates
10
+
11
+ - a Bun + TypeScript project
12
+ - a single-file rover runtime built on `@hla4ts/spacekit`
13
+ - `.env` defaults for RTI host/port/TLS, federation, federate, frame, timing,
14
+ and rover motion
15
+
16
+ ## Local Workspace Usage
17
+
18
+ From this repo checkout:
19
+
20
+ ```bash
21
+ bun run --filter @hla4ts/create-spacekit cli -- my-rover
22
+ ```
23
+
24
+ ## Intended Published Usage
25
+
26
+ The package is structured with a `bin` entry so it is ready for:
27
+
28
+ ```bash
29
+ bunx @hla4ts/create-spacekit my-rover
30
+ ```
31
+
32
+ That one-line path still depends on publishing the package to the npm registry.
33
+
34
+ ## Options
35
+
36
+ - `--template lunar-rover`
37
+ - `--list-templates`
38
+ - `--federation-name <name>`
39
+ - `--federate-name <name>`
40
+ - `--federate-type <type>`
41
+ - `--rti-host <host>`
42
+ - `--rti-port <port>`
43
+ - `--rti-use-tls`
44
+ - `--parent-reference-frame <frame>`
45
+ - `--rover-name <name>`
46
+ - `--rover-type <type>`
47
+ - `--rover-speed-mps <value>`
48
+ - `--lookahead-micros <value>`
49
+ - `--update-period-micros <value>`
50
+ - `--reference-frame-timeout-ms <value>`
51
+ - `--reference-frame-missing-mode <mode>`
52
+ - `--workspace-deps`
53
+ - `--yes`
54
+
55
+ `--workspace-deps` is for local monorepo validation. It writes
56
+ `"@hla4ts/spacekit": "workspace:*"` into the generated project instead of the
57
+ published semver range.
58
+
59
+ `--reference-frame-missing-mode` controls what the generated rover does when
60
+ its parent reference frame has not been published yet. The template default is
61
+ `continue`.
62
+
63
+ ## Generated App
64
+
65
+ After scaffolding:
66
+
67
+ ```bash
68
+ cd my-rover
69
+ bun install
70
+ bun run start
71
+ ```
72
+
73
+ Edit `.env` to point at your RTI and federation.
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@hla4ts/create-spacekit",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a minimal @hla4ts/spacekit rover app",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "bin": {
9
+ "spacekit": "src/cli.ts"
10
+ },
11
+ "files": [
12
+ "README.md",
13
+ "src",
14
+ "templates"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "cli": "bun run src/cli.ts",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "bun test"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "latest",
26
+ "typescript": "^5.9.0"
27
+ },
28
+ "license": "MIT"
29
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env bun
2
+ import readline from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+ import path from "node:path";
5
+ import {
6
+ DEFAULT_TEMPLATE,
7
+ TEMPLATE_DESCRIPTIONS,
8
+ listTemplates,
9
+ scaffoldApp,
10
+ type TemplateId,
11
+ type TemplateValues,
12
+ } from "./scaffold.ts";
13
+
14
+ interface CliOptions {
15
+ targetDir?: string;
16
+ template: TemplateId;
17
+ workspaceDeps: boolean;
18
+ yes: boolean;
19
+ values: Partial<TemplateValues>;
20
+ listTemplates: boolean;
21
+ help: boolean;
22
+ }
23
+
24
+ try {
25
+ await main();
26
+ } catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ console.error(message);
29
+ process.exit(1);
30
+ }
31
+
32
+ async function main(): Promise<void> {
33
+ const options = parseArgs(process.argv.slice(2));
34
+
35
+ if (options.help) {
36
+ printHelp();
37
+ return;
38
+ }
39
+
40
+ if (options.listTemplates) {
41
+ for (const template of listTemplates()) {
42
+ console.log(`${template.id}\t${template.description}`);
43
+ }
44
+ return;
45
+ }
46
+
47
+ if (!options.targetDir) {
48
+ printHelp("Target directory is required.");
49
+ process.exit(1);
50
+ }
51
+
52
+ if (!isTemplateId(options.template)) {
53
+ throw new Error(`Unknown template "${options.template}". Run --list-templates to inspect choices.`);
54
+ }
55
+
56
+ const usePrompts = stdin.isTTY && stdout.isTTY && !options.yes;
57
+ const values = usePrompts
58
+ ? await promptForMissingValues(options.targetDir, options.template, options.values)
59
+ : options.values;
60
+ if (options.workspaceDeps) {
61
+ values.spacekitDependency = "workspace:*";
62
+ }
63
+
64
+ const result = await scaffoldApp({
65
+ targetDir: options.targetDir,
66
+ template: options.template,
67
+ values,
68
+ });
69
+
70
+ const relativeTarget = path.relative(process.cwd(), result.targetDir) || ".";
71
+ const displayTarget =
72
+ relativeTarget === "." || (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget))
73
+ ? relativeTarget
74
+ : result.targetDir;
75
+ console.log(`Created ${result.template} app in ${result.targetDir}`);
76
+ console.log("");
77
+ console.log("Next steps:");
78
+ console.log(` cd ${displayTarget}`);
79
+ console.log(" bun install");
80
+ console.log(" bun run start");
81
+ console.log("");
82
+ console.log(
83
+ "This package is bunx-ready, but the one-line zero-clone flow still depends on npm publication:"
84
+ );
85
+ console.log(` bunx @hla4ts/create-spacekit ${path.basename(result.targetDir)}`);
86
+ }
87
+
88
+ function parseArgs(argv: string[]): CliOptions {
89
+ const options: CliOptions = {
90
+ template: DEFAULT_TEMPLATE,
91
+ workspaceDeps: false,
92
+ yes: false,
93
+ values: {},
94
+ listTemplates: false,
95
+ help: false,
96
+ };
97
+
98
+ for (let index = 0; index < argv.length; index += 1) {
99
+ const arg = argv[index];
100
+ if (!arg) continue;
101
+
102
+ if (arg === "--help" || arg === "-h") {
103
+ options.help = true;
104
+ continue;
105
+ }
106
+ if (arg === "--list-templates") {
107
+ options.listTemplates = true;
108
+ continue;
109
+ }
110
+ if (arg === "--yes" || arg === "-y") {
111
+ options.yes = true;
112
+ continue;
113
+ }
114
+ if (arg === "--workspace-deps") {
115
+ options.workspaceDeps = true;
116
+ continue;
117
+ }
118
+ if (arg === "--template") {
119
+ options.template = readValue(argv, ++index, arg) as TemplateId;
120
+ continue;
121
+ }
122
+ if (arg === "--federation-name") {
123
+ options.values.federationName = readValue(argv, ++index, arg);
124
+ continue;
125
+ }
126
+ if (arg === "--federate-name") {
127
+ options.values.federateName = readValue(argv, ++index, arg);
128
+ continue;
129
+ }
130
+ if (arg === "--federate-type") {
131
+ options.values.federateType = readValue(argv, ++index, arg);
132
+ continue;
133
+ }
134
+ if (arg === "--rti-host") {
135
+ options.values.rtiHost = readValue(argv, ++index, arg);
136
+ continue;
137
+ }
138
+ if (arg === "--rti-port") {
139
+ options.values.rtiPort = readValue(argv, ++index, arg);
140
+ continue;
141
+ }
142
+ if (arg === "--rti-use-tls") {
143
+ options.values.rtiUseTls = "true";
144
+ continue;
145
+ }
146
+ if (arg === "--parent-reference-frame") {
147
+ options.values.parentReferenceFrame = readValue(argv, ++index, arg);
148
+ continue;
149
+ }
150
+ if (arg === "--rover-name") {
151
+ options.values.roverName = readValue(argv, ++index, arg);
152
+ continue;
153
+ }
154
+ if (arg === "--rover-type") {
155
+ options.values.roverType = readValue(argv, ++index, arg);
156
+ continue;
157
+ }
158
+ if (arg === "--rover-speed-mps") {
159
+ options.values.roverSpeedMps = readValue(argv, ++index, arg);
160
+ continue;
161
+ }
162
+ if (arg === "--lookahead-micros") {
163
+ options.values.lookaheadMicros = readValue(argv, ++index, arg);
164
+ continue;
165
+ }
166
+ if (arg === "--update-period-micros") {
167
+ options.values.updatePeriodMicros = readValue(argv, ++index, arg);
168
+ continue;
169
+ }
170
+ if (arg === "--reference-frame-timeout-ms") {
171
+ options.values.referenceFrameTimeoutMs = readValue(argv, ++index, arg);
172
+ continue;
173
+ }
174
+ if (arg === "--reference-frame-missing-mode") {
175
+ options.values.referenceFrameMissingMode = readValue(argv, ++index, arg);
176
+ continue;
177
+ }
178
+
179
+ if (arg.startsWith("--")) {
180
+ throw new Error(`Unknown flag: ${arg}`);
181
+ }
182
+
183
+ if (!options.targetDir) {
184
+ options.targetDir = arg;
185
+ continue;
186
+ }
187
+
188
+ throw new Error(`Unexpected argument: ${arg}`);
189
+ }
190
+
191
+ return options;
192
+ }
193
+
194
+ async function promptForMissingValues(
195
+ targetDir: string,
196
+ template: TemplateId,
197
+ values: Partial<TemplateValues>
198
+ ): Promise<Partial<TemplateValues>> {
199
+ const rl = readline.createInterface({ input: stdin, output: stdout });
200
+ const defaults = {
201
+ federationName: "SpaceFederation",
202
+ federateName: "LunarRover-1",
203
+ federateType: "LunarRover",
204
+ rtiHost: "localhost",
205
+ rtiPort: "15164",
206
+ rtiUseTls: "false",
207
+ parentReferenceFrame: "AitkenBasinLocalFixed",
208
+ roverName: "LunarRover-1",
209
+ roverType: "LunarRover",
210
+ roverSpeedMps: "0.5",
211
+ lookaheadMicros: "1000000",
212
+ updatePeriodMicros: "1000000",
213
+ referenceFrameTimeoutMs: "30000",
214
+ referenceFrameMissingMode: "continue",
215
+ } satisfies Partial<TemplateValues>;
216
+
217
+ try {
218
+ console.log(`Scaffolding template "${template}" into ${path.resolve(targetDir)}`);
219
+ console.log(TEMPLATE_DESCRIPTIONS[template]);
220
+ console.log("");
221
+
222
+ const federationName = await ask(
223
+ rl,
224
+ "Federation name",
225
+ values.federationName ?? defaults.federationName
226
+ );
227
+ const federateName = await ask(
228
+ rl,
229
+ "Federate name",
230
+ values.federateName ?? defaults.federateName
231
+ );
232
+ const federateType = await ask(
233
+ rl,
234
+ "Federate type",
235
+ values.federateType ?? defaults.federateType
236
+ );
237
+ const rtiHost = await ask(rl, "RTI host", values.rtiHost ?? defaults.rtiHost);
238
+ const rtiPort = await ask(rl, "RTI port", values.rtiPort ?? defaults.rtiPort);
239
+ const rtiUseTls = await askBool(rl, "Use TLS", values.rtiUseTls ?? defaults.rtiUseTls);
240
+ const parentReferenceFrame = await ask(
241
+ rl,
242
+ "Parent reference frame",
243
+ values.parentReferenceFrame ?? defaults.parentReferenceFrame
244
+ );
245
+ const roverName = await ask(
246
+ rl,
247
+ "Rover object name",
248
+ values.roverName ?? federateName ?? defaults.roverName
249
+ );
250
+ const roverType = await ask(
251
+ rl,
252
+ "Rover type",
253
+ values.roverType ?? federateType ?? defaults.roverType
254
+ );
255
+ const roverSpeedMps = await ask(
256
+ rl,
257
+ "Rover speed (m/s)",
258
+ values.roverSpeedMps ?? defaults.roverSpeedMps
259
+ );
260
+ const lookaheadMicros = await ask(
261
+ rl,
262
+ "Lookahead (micros)",
263
+ values.lookaheadMicros ?? defaults.lookaheadMicros
264
+ );
265
+ const updatePeriodMicros = await ask(
266
+ rl,
267
+ "Update period (micros)",
268
+ values.updatePeriodMicros ?? defaults.updatePeriodMicros
269
+ );
270
+ const referenceFrameTimeoutMs = await ask(
271
+ rl,
272
+ "Reference frame timeout (ms)",
273
+ values.referenceFrameTimeoutMs ?? defaults.referenceFrameTimeoutMs
274
+ );
275
+ const referenceFrameMissingMode = await ask(
276
+ rl,
277
+ "Missing frame mode (error/continue)",
278
+ values.referenceFrameMissingMode ?? defaults.referenceFrameMissingMode
279
+ );
280
+
281
+ return {
282
+ ...values,
283
+ federationName,
284
+ federateName,
285
+ federateType,
286
+ rtiHost,
287
+ rtiPort,
288
+ rtiUseTls,
289
+ parentReferenceFrame,
290
+ roverName,
291
+ roverType,
292
+ roverSpeedMps,
293
+ lookaheadMicros,
294
+ updatePeriodMicros,
295
+ referenceFrameTimeoutMs,
296
+ referenceFrameMissingMode,
297
+ };
298
+ } finally {
299
+ rl.close();
300
+ }
301
+ }
302
+
303
+ async function ask(
304
+ rl: readline.Interface,
305
+ label: string,
306
+ fallback: string | undefined
307
+ ): Promise<string> {
308
+ const resolvedFallback = fallback ?? "";
309
+ const response = await rl.question(`${label} (${resolvedFallback}): `);
310
+ return response.trim() === "" ? resolvedFallback : response.trim();
311
+ }
312
+
313
+ async function askBool(
314
+ rl: readline.Interface,
315
+ label: string,
316
+ fallback: string | undefined
317
+ ): Promise<string> {
318
+ const response = await ask(rl, `${label} [y/N]`, fallback === "true" ? "y" : "n");
319
+ const normalized = response.toLowerCase();
320
+ return ["y", "yes", "true", "1"].includes(normalized) ? "true" : "false";
321
+ }
322
+
323
+ function readValue(argv: string[], index: number, flag: string): string {
324
+ const value = argv[index];
325
+ if (!value) {
326
+ throw new Error(`Missing value for ${flag}`);
327
+ }
328
+ return value;
329
+ }
330
+
331
+ function isTemplateId(value: string): value is TemplateId {
332
+ return value in TEMPLATE_DESCRIPTIONS;
333
+ }
334
+
335
+ function printHelp(errorMessage?: string): void {
336
+ if (errorMessage) {
337
+ console.error(errorMessage);
338
+ console.error("");
339
+ }
340
+
341
+ console.log("Usage:");
342
+ console.log(" spacekit <target-dir> [options]");
343
+ console.log("");
344
+ console.log("Options:");
345
+ console.log(" --template <name> Template to use (default: lunar-rover)");
346
+ console.log(" --list-templates Print the available templates");
347
+ console.log(" --federation-name <name> Default federation name in .env");
348
+ console.log(" --federate-name <name> Default federate name in .env");
349
+ console.log(" --federate-type <type> Default federate type in .env");
350
+ console.log(" --rti-host <host> Default RTI host in .env");
351
+ console.log(" --rti-port <port> Default RTI port in .env");
352
+ console.log(" --rti-use-tls Enable TLS in .env");
353
+ console.log(" --parent-reference-frame <name> Parent frame for the rover entity");
354
+ console.log(" --rover-name <name> Rover object instance name");
355
+ console.log(" --rover-type <type> Rover object type");
356
+ console.log(" --rover-speed-mps <value> Rover speed along the X axis");
357
+ console.log(" --lookahead-micros <value> Spacekit lookahead");
358
+ console.log(" --update-period-micros <value> Spacekit tick period");
359
+ console.log(" --reference-frame-timeout-ms <ms> How long to wait for the frame tree");
360
+ console.log(" --reference-frame-missing-mode <m> error | continue");
361
+ console.log(" --workspace-deps Use workspace:* for @hla4ts/spacekit");
362
+ console.log(" --yes, -y Skip prompts and use defaults/flags");
363
+ console.log(" --help, -h Show this help text");
364
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export {
2
+ DEFAULT_TEMPLATE,
3
+ TEMPLATE_DESCRIPTIONS,
4
+ listTemplates,
5
+ sanitizePackageName,
6
+ scaffoldApp,
7
+ type ScaffoldAppOptions,
8
+ type ScaffoldAppResult,
9
+ type TemplateId,
10
+ type TemplateValues,
11
+ } from "./scaffold.ts";
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { sanitizePackageName, scaffoldApp } from "./scaffold.ts";
6
+
7
+ describe("@hla4ts/create-spacekit", () => {
8
+ test("sanitizePackageName normalizes arbitrary folder names", () => {
9
+ expect(sanitizePackageName("My Lunar Rover!")).toBe("my-lunar-rover");
10
+ expect(sanitizePackageName(" ")).toBe("hla4ts-spacekit-app");
11
+ });
12
+
13
+ test("scaffoldApp creates a configurable lunar rover project", async () => {
14
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hla4ts-scaffold-"));
15
+ const targetDir = path.join(tempRoot, "mission-rover");
16
+
17
+ await scaffoldApp({
18
+ targetDir,
19
+ values: {
20
+ spacekitDependency: "workspace:*",
21
+ referenceFrameMissingMode: "continue",
22
+ federationName: "MoonOps",
23
+ federateName: "Rover-Blue",
24
+ federateType: "ScoutRover",
25
+ rtiHost: "10.0.0.12",
26
+ roverSpeedMps: "1.25",
27
+ },
28
+ });
29
+
30
+ const packageJson = await fs.readFile(path.join(targetDir, "package.json"), "utf8");
31
+ const envFile = await fs.readFile(path.join(targetDir, ".env"), "utf8");
32
+ const indexFile = await fs.readFile(path.join(targetDir, "src", "index.ts"), "utf8");
33
+
34
+ expect(packageJson).toContain('"name": "mission-rover"');
35
+ expect(packageJson).toContain('"@hla4ts/spacekit": "workspace:*"');
36
+ expect(envFile).toContain("FEDERATION_NAME=MoonOps");
37
+ expect(envFile).toContain("FEDERATE_NAME=Rover-Blue");
38
+ expect(envFile).toContain("ROVER_NAME=Rover-Blue");
39
+ expect(envFile).toContain("ROVER_TYPE=ScoutRover");
40
+ expect(envFile).toContain("REFERENCE_FRAME_MISSING_MODE=continue");
41
+ expect(envFile).toContain("RTI_HOST=10.0.0.12");
42
+ expect(envFile).toContain("ROVER_SPEED_MPS=1.25");
43
+ expect(indexFile).toContain('import { PhysicalEntity, SpacekitApp');
44
+ });
45
+
46
+ test("scaffoldApp refuses non-empty target directories", async () => {
47
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hla4ts-scaffold-"));
48
+ const targetDir = path.join(tempRoot, "existing");
49
+ await fs.mkdir(targetDir, { recursive: true });
50
+ await fs.writeFile(path.join(targetDir, "keep.txt"), "existing", "utf8");
51
+
52
+ await expect(scaffoldApp({ targetDir })).rejects.toThrow(
53
+ "Refusing to scaffold into a non-empty directory"
54
+ );
55
+ });
56
+ });
@@ -0,0 +1,199 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const DEFAULT_TEMPLATE = "lunar-rover";
6
+
7
+ export const TEMPLATE_DESCRIPTIONS = {
8
+ "lunar-rover":
9
+ "Minimal Spacekit lunar rover with .env-driven RTI, federation, and timing config.",
10
+ } as const;
11
+
12
+ export type TemplateId = keyof typeof TEMPLATE_DESCRIPTIONS;
13
+
14
+ export interface TemplateValues {
15
+ appName: string;
16
+ packageName: string;
17
+ spacekitDependency: "^0.1.0",
18
+ referenceFrameMissingMode: string;
19
+ federationName: string;
20
+ federateName: string;
21
+ federateType: string;
22
+ rtiHost: string;
23
+ rtiPort: string;
24
+ rtiUseTls: string;
25
+ parentReferenceFrame: string;
26
+ roverName: string;
27
+ roverType: string;
28
+ roverSpeedMps: string;
29
+ lookaheadMicros: string;
30
+ updatePeriodMicros: string;
31
+ referenceFrameTimeoutMs: string;
32
+ }
33
+
34
+ export interface ScaffoldAppOptions {
35
+ targetDir: string;
36
+ template?: TemplateId;
37
+ values?: Partial<TemplateValues>;
38
+ }
39
+
40
+ export interface ScaffoldAppResult {
41
+ template: TemplateId;
42
+ targetDir: string;
43
+ packageName: string;
44
+ filesWritten: number;
45
+ }
46
+
47
+ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<ScaffoldAppResult> {
48
+ const template = options.template ?? DEFAULT_TEMPLATE;
49
+ assertTemplate(template);
50
+
51
+ const targetDir = path.resolve(options.targetDir);
52
+ await ensureEmptyTargetDirectory(targetDir);
53
+
54
+ const appName = deriveAppName(targetDir);
55
+ const values = {
56
+ ...defaultTemplateValues(appName),
57
+ ...(options.values ?? {}),
58
+ };
59
+ values.appName = values.appName.trim() || appName;
60
+ values.packageName = sanitizePackageName(values.packageName || values.appName);
61
+ values.roverName =
62
+ options.values?.roverName === undefined
63
+ ? values.federateName
64
+ : values.roverName.trim() || values.federateName;
65
+ values.roverType =
66
+ options.values?.roverType === undefined
67
+ ? values.federateType
68
+ : values.roverType.trim() || values.federateType;
69
+
70
+ const templateDir = resolveTemplateDir(template);
71
+ const files = await collectTemplateFiles(templateDir);
72
+
73
+ let filesWritten = 0;
74
+ for (const sourceFile of files) {
75
+ const relativePath = path.relative(templateDir, sourceFile);
76
+ const destinationPath = path.join(targetDir, relativePath);
77
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
78
+ const content = await fs.readFile(sourceFile, "utf8");
79
+ const rendered = renderTemplate(content, values);
80
+ await fs.writeFile(destinationPath, rendered, "utf8");
81
+ filesWritten += 1;
82
+ }
83
+
84
+ return {
85
+ template,
86
+ targetDir,
87
+ packageName: values.packageName,
88
+ filesWritten,
89
+ };
90
+ }
91
+
92
+ export function listTemplates(): Array<{ id: TemplateId; description: string }> {
93
+ return (Object.entries(TEMPLATE_DESCRIPTIONS) as Array<[TemplateId, string]>).map(
94
+ ([id, description]) => ({ id, description })
95
+ );
96
+ }
97
+
98
+ export function sanitizePackageName(value: string): string {
99
+ const normalized = value
100
+ .trim()
101
+ .toLowerCase()
102
+ .replace(/[^a-z0-9]+/g, "-")
103
+ .replace(/^-+|-+$/g, "");
104
+
105
+ return normalized.length > 0 ? normalized : "hla4ts-spacekit-app";
106
+ }
107
+
108
+ function defaultTemplateValues(appName: string): TemplateValues {
109
+ return {
110
+ appName,
111
+ packageName: sanitizePackageName(appName),
112
+ spacekitDependency: "^0.1.0",
113
+ referenceFrameMissingMode: "continue",
114
+ federationName: "SpaceFederation",
115
+ federateName: "LunarRover-1",
116
+ federateType: "LunarRover",
117
+ rtiHost: "localhost",
118
+ rtiPort: "15164",
119
+ rtiUseTls: "false",
120
+ parentReferenceFrame: "AitkenBasinLocalFixed",
121
+ roverName: "LunarRover-1",
122
+ roverType: "LunarRover",
123
+ roverSpeedMps: "0.5",
124
+ lookaheadMicros: "1000000",
125
+ updatePeriodMicros: "1000000",
126
+ referenceFrameTimeoutMs: "30000",
127
+ };
128
+ }
129
+
130
+ function assertTemplate(template: string): asserts template is TemplateId {
131
+ if (!(template in TEMPLATE_DESCRIPTIONS)) {
132
+ throw new Error(`Unknown template "${template}". Run --list-templates to inspect choices.`);
133
+ }
134
+ }
135
+
136
+ async function ensureEmptyTargetDirectory(targetDir: string): Promise<void> {
137
+ try {
138
+ const stat = await fs.stat(targetDir);
139
+ if (!stat.isDirectory()) {
140
+ throw new Error(`Target path exists and is not a directory: ${targetDir}`);
141
+ }
142
+ const entries = await fs.readdir(targetDir);
143
+ if (entries.length > 0) {
144
+ throw new Error(`Refusing to scaffold into a non-empty directory: ${targetDir}`);
145
+ }
146
+ } catch (error) {
147
+ if (isMissing(error)) {
148
+ await fs.mkdir(targetDir, { recursive: true });
149
+ return;
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ async function collectTemplateFiles(root: string): Promise<string[]> {
156
+ const entries = await fs.readdir(root, { withFileTypes: true });
157
+ const files: string[] = [];
158
+
159
+ for (const entry of entries) {
160
+ const fullPath = path.join(root, entry.name);
161
+ if (entry.isDirectory()) {
162
+ files.push(...(await collectTemplateFiles(fullPath)));
163
+ continue;
164
+ }
165
+ if (entry.isFile()) {
166
+ files.push(fullPath);
167
+ }
168
+ }
169
+
170
+ return files.sort();
171
+ }
172
+
173
+ function renderTemplate(content: string, values: TemplateValues): string {
174
+ return content.replace(/\{\{([A-Z_]+)\}\}/g, (_match, rawKey: string) => {
175
+ const key = toTemplateValueKey(rawKey);
176
+ return values[key] ?? _match;
177
+ });
178
+ }
179
+
180
+ function toTemplateValueKey(rawKey: string): keyof TemplateValues {
181
+ const key = rawKey.toLowerCase().replace(/_([a-z])/g, (_match, letter: string) =>
182
+ letter.toUpperCase()
183
+ );
184
+ return key as keyof TemplateValues;
185
+ }
186
+
187
+ function deriveAppName(targetDir: string): string {
188
+ const base = path.basename(targetDir);
189
+ return base === "." || base.length === 0 ? "hla4ts-spacekit-app" : base;
190
+ }
191
+
192
+ function resolveTemplateDir(template: TemplateId): string {
193
+ const currentFile = fileURLToPath(import.meta.url);
194
+ return path.resolve(path.dirname(currentFile), "..", "templates", template);
195
+ }
196
+
197
+ function isMissing(error: unknown): boolean {
198
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
199
+ }
@@ -0,0 +1,14 @@
1
+ FEDERATE_NAME={{FEDERATE_NAME}}
2
+ FEDERATION_NAME={{FEDERATION_NAME}}
3
+ FEDERATE_TYPE={{FEDERATE_TYPE}}
4
+ ROVER_NAME={{ROVER_NAME}}
5
+ ROVER_TYPE={{ROVER_TYPE}}
6
+ PARENT_REFERENCE_FRAME={{PARENT_REFERENCE_FRAME}}
7
+ RTI_HOST={{RTI_HOST}}
8
+ RTI_PORT={{RTI_PORT}}
9
+ RTI_USE_TLS={{RTI_USE_TLS}}
10
+ LOOKAHEAD_MICROS={{LOOKAHEAD_MICROS}}
11
+ UPDATE_PERIOD_MICROS={{UPDATE_PERIOD_MICROS}}
12
+ REFERENCE_FRAME_MISSING_MODE={{REFERENCE_FRAME_MISSING_MODE}}
13
+ REFERENCE_FRAME_TIMEOUT_MS={{REFERENCE_FRAME_TIMEOUT_MS}}
14
+ ROVER_SPEED_MPS={{ROVER_SPEED_MPS}}
@@ -0,0 +1,37 @@
1
+ # {{APP_NAME}}
2
+
3
+ Minimal lunar rover example generated by `spacekit` from `@hla4ts/create-spacekit`.
4
+
5
+ This app uses `@hla4ts/spacekit` directly:
6
+
7
+ - `SpacekitApp.run()` owns the join/bootstrap/tick loop
8
+ - `PhysicalEntity` publishes a single rover object
9
+ - `.env` contains the RTI, federation, rover, and timing defaults
10
+
11
+ ## Prerequisites
12
+
13
+ - Bun installed
14
+ - an HLA 4 RTI reachable at the configured host/port
15
+ - SpaceMaster and a root reference frame publisher if you are running a SEE late-join federation
16
+
17
+ ## Configure
18
+
19
+ Edit `.env` if needed:
20
+
21
+ - `RTI_HOST`, `RTI_PORT`, `RTI_USE_TLS`
22
+ - `FEDERATION_NAME`, `FEDERATE_NAME`, `FEDERATE_TYPE`
23
+ - `ROVER_NAME`, `ROVER_TYPE`, `PARENT_REFERENCE_FRAME`
24
+ - `ROVER_SPEED_MPS`, `LOOKAHEAD_MICROS`, `UPDATE_PERIOD_MICROS`
25
+ - `REFERENCE_FRAME_MISSING_MODE` (`continue` by default)
26
+
27
+ ## Run
28
+
29
+ ```bash
30
+ bun install
31
+ bun run start
32
+ ```
33
+
34
+ The rover moves forward along the X axis at the configured speed and logs each
35
+ published tick. By default the generated app uses `continue` mode for missing
36
+ reference frames, so it warns and keeps running if the configured parent frame
37
+ has not been published yet.
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "{{PACKAGE_NAME}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "bun run src/index.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "dependencies": {
11
+ "@hla4ts/spacekit": "{{SPACEKIT_DEPENDENCY}}"
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "latest",
15
+ "typescript": "^5.9.0"
16
+ }
17
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ createSpaceFomConfig,
3
+ type MissingReferenceFrameMode,
4
+ type SpacekitAppOptions,
5
+ } from "@hla4ts/spacekit";
6
+
7
+ export interface LunarRoverConfig {
8
+ app: SpacekitAppOptions;
9
+ roverName: string;
10
+ roverType: string;
11
+ roverStatus: string;
12
+ parentReferenceFrame: string;
13
+ roverSpeedMetersPerSec: number;
14
+ startPosition: [number, number, number];
15
+ }
16
+
17
+ export function loadConfig(): LunarRoverConfig {
18
+ const config = createSpaceFomConfig({
19
+ envDefaults: {
20
+ federationName: "{{FEDERATION_NAME}}",
21
+ federateName: "{{FEDERATE_NAME}}",
22
+ federateType: "{{FEDERATE_TYPE}}",
23
+ rtiHost: "{{RTI_HOST}}",
24
+ rtiPort: parseNumber("{{RTI_PORT}}", 15164),
25
+ rtiUseTls: parseBoolean("{{RTI_USE_TLS}}", false),
26
+ lookaheadMicros: parseBigInt("{{LOOKAHEAD_MICROS}}", 1000000n),
27
+ updatePeriodMicros: parseBigInt("{{UPDATE_PERIOD_MICROS}}", 1000000n),
28
+ referenceFrameTimeoutMs: parseNumber("{{REFERENCE_FRAME_TIMEOUT_MS}}", 30000),
29
+ },
30
+ extras: (env) => ({
31
+ roverName: env.ROVER_NAME ?? "{{ROVER_NAME}}",
32
+ roverType: env.ROVER_TYPE ?? "{{ROVER_TYPE}}",
33
+ roverStatus: env.ROVER_STATUS ?? "ACTIVE",
34
+ parentReferenceFrame: env.PARENT_REFERENCE_FRAME ?? "{{PARENT_REFERENCE_FRAME}}",
35
+ roverSpeedMetersPerSec: parseNumber(
36
+ env.ROVER_SPEED_MPS,
37
+ parseNumber("{{ROVER_SPEED_MPS}}", 0.5)
38
+ ),
39
+ startPosition: [
40
+ parseNumber(env.ROVER_START_X, 0),
41
+ parseNumber(env.ROVER_START_Y, 0),
42
+ parseNumber(env.ROVER_START_Z, 0),
43
+ ] as [number, number, number],
44
+ }),
45
+ });
46
+ config.app.missingReferenceFrameMode = parseReferenceFrameMissingMode(
47
+ process.env.REFERENCE_FRAME_MISSING_MODE ?? "{{REFERENCE_FRAME_MISSING_MODE}}"
48
+ );
49
+ return config;
50
+ }
51
+
52
+ function parseNumber(value: string | undefined, fallback: number): number {
53
+ if (value === undefined || value.trim() === "") {
54
+ return fallback;
55
+ }
56
+ const parsed = Number(value);
57
+ if (!Number.isFinite(parsed)) {
58
+ throw new Error(`Invalid numeric environment value: ${value}`);
59
+ }
60
+ return parsed;
61
+ }
62
+
63
+ function parseBigInt(value: string | undefined, fallback: bigint): bigint {
64
+ if (value === undefined || value.trim() === "") {
65
+ return fallback;
66
+ }
67
+ try {
68
+ return BigInt(value);
69
+ } catch {
70
+ throw new Error(`Invalid bigint environment value: ${value}`);
71
+ }
72
+ }
73
+
74
+ function parseBoolean(value: string | undefined, fallback: boolean): boolean {
75
+ if (value === undefined || value.trim() === "") {
76
+ return fallback;
77
+ }
78
+ const normalized = value.toLowerCase();
79
+ if (["true", "1", "yes", "y"].includes(normalized)) {
80
+ return true;
81
+ }
82
+ if (["false", "0", "no", "n"].includes(normalized)) {
83
+ return false;
84
+ }
85
+ throw new Error(`Invalid boolean environment value: ${value}`);
86
+ }
87
+
88
+ function parseReferenceFrameMissingMode(
89
+ value: string | undefined
90
+ ): MissingReferenceFrameMode {
91
+ if (!value || value.trim() === "") {
92
+ return "continue";
93
+ }
94
+ if (value === "error" || value === "continue") {
95
+ return value;
96
+ }
97
+ throw new Error(`Invalid reference frame missing mode: ${value}`);
98
+ }
@@ -0,0 +1,57 @@
1
+ import { PhysicalEntity, SpacekitApp, type SpaceTimeCoordinateState } from "@hla4ts/spacekit";
2
+ import { loadConfig } from "./config.ts";
3
+
4
+ async function main(): Promise<void> {
5
+ const config = loadConfig();
6
+ const app = new SpacekitApp(config.app);
7
+ const rover = new PhysicalEntity(config.roverName, {
8
+ type: config.roverType,
9
+ status: config.roverStatus,
10
+ parent_reference_frame: config.parentReferenceFrame,
11
+ });
12
+
13
+ await app.run({
14
+ entities: [rover],
15
+ onTick: ({ initial, tick, timeMicros, timeSeconds, sendTimeMicros }) => {
16
+ if (initial) return;
17
+
18
+ rover.name = config.roverName;
19
+ rover.type = config.roverType;
20
+ rover.status = config.roverStatus;
21
+ rover.parent_reference_frame = config.parentReferenceFrame;
22
+ rover.state = buildRoverState(config, timeSeconds);
23
+
24
+ if (!app.shouldLogTick(tick)) return;
25
+
26
+ app.logger.info("Rover tick.", {
27
+ tick,
28
+ logicalTimeMicros: timeMicros.toString(),
29
+ sendTimeMicros: (sendTimeMicros ?? timeMicros).toString(),
30
+ position: rover.state.translation.position.map((value) => value.toFixed(3)),
31
+ velocity: rover.state.translation.velocity.map((value) => value.toFixed(3)),
32
+ });
33
+ },
34
+ });
35
+ }
36
+
37
+ function buildRoverState(
38
+ config: ReturnType<typeof loadConfig>,
39
+ timeSeconds: number
40
+ ): SpaceTimeCoordinateState {
41
+ const [startX, startY, startZ] = config.startPosition;
42
+ const positionX = startX + config.roverSpeedMetersPerSec * timeSeconds;
43
+
44
+ return {
45
+ translation: {
46
+ position: [positionX, startY, startZ],
47
+ velocity: [config.roverSpeedMetersPerSec, 0, 0],
48
+ },
49
+ rotation: {
50
+ attitude: { scalar: 1, vector: [0, 0, 0] },
51
+ angularVelocity: [0, 0, 0],
52
+ },
53
+ time: timeSeconds,
54
+ };
55
+ }
56
+
57
+ void main();
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["bun-types"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noImplicitReturns": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "allowImportingTsExtensions": true,
20
+ "experimentalDecorators": true,
21
+ "verbatimModuleSyntax": true
22
+ },
23
+ "include": ["src/**/*"]
24
+ }